t3code:基于TypeScript与Vite的三层架构前端工程模板
做前端这几年,我越来越觉得“从零搭项目”这件事的门槛其实不在写代码,而在那些看不见的工程细节:目录结构怎么规划、类型检查怎么收敛、样式方案怎么不打架、提交代码前有没有统一的校验动作。t3code 这个项目,说白了就是我把这些年在实际业务里反复验证过的一套前端工程模板沉淀了下来,取了个代号叫 t3code,含义是 three-tier code,也就是“三层代码工程体系”。
这篇文章不会去聊什么“前端未来趋势”,我就实打实地把 t3code 的核心设计、技术选型逻辑、目录结构、关键配置,以及我在搭建过程中踩过的坑和排查思路,一次讲清楚。如果你正准备从零搭一个中型前端项目,或者你想把自己手里的老项目向“规范化工地”靠拢,这篇文章应该能给你一份可以直接抄作业的参考。
适合谁看?两种人:一种是想搭新项目但不想把时间浪费在“反复配了两天环境还没写一行业务代码”的人,另一种是被存量项目的类型混乱、样式污染、构建缓慢搞得头疼,想找个机会做一次标准化改造的人。t3code 面向的不是玩具级 demo,而是一个可以直接承载真实业务、能持续演进的基础工程。
1. t3code 内容整体设计与思路拆解
1.1 三层设计到底在解决什么问题
先说清楚 t3code 这个名字背后的设计核心。我在拆解需求时发现,绝大多数前端项目混乱的根源,是代码之间没有边界。组件里直接写接口请求、工具函数里混着 UI 逻辑、业务状态散落在各个页面里,时间一长,改一处牵一车,谁都不敢动。
t3code 的三层结构,就是给代码划出明确的地盘:
- 第一层是内核层(kernel),只放与业务无关的基础设施,比如类型定义、通用工具函数、网络请求封装、全局状态仓库的初始化。这一层不依赖任何页面和组件。
- 第二层是领域层(domain),放业务模型和业务逻辑,比如用户模块、订单模块、商品模块的 API 方法、缓存策略、业务状态。这一层可以被多个页面复用。
- 第三层是应用层(application),也就是你看到的页面和组件,这一层负责把领域层的能力组合起来,渲染成具体的 UI。
这种分层的直接收益是什么?我给你一个生活化的类比:这就像一个餐厅,内核层是后厨的公共设施,水电气管道;领域层是半成品加工间,把食材清洗切配好;应用层是出菜口,不同菜品从这里端给顾客。如果切配逻辑散落到每个厨师手里,今天 A 师傅切丝,明天 B 师傅切块,菜品质量一定不稳定。代码也是一样,把“怎么做”和“长什么样”分开,项目活得越久,这个分层的价值越大。
实际项目中我常见的一个问题,是很多人一上来就做“按页面建目录”的方案,pages 下的每个目录里又塞 hooks,又塞 api,又塞 utils。老实说,这种结构在小项目里问题不大,但业务一膨胀,页面之间的公共逻辑根本找不到地方放,最后只能满项目找 Ctrl+C、Ctrl+V。t3code 逼着你先把公共能力下沉到领域层,页面只是一个轻量的组装层,这种“倒逼”长期看非常值。
1.2 选型背后的“反直觉”思考
技术选型上,t3code 没有追新,反而走了一条看起来“老派”的路线:
- 语言:TypeScript,不要 JavaScript
- 样式:Tailwind CSS 作为主力,保留少量 CSS Modules 出口
- 构建:Vite,不用 Webpack
- 状态管理:Zustand 为主,不需要全局状态就不硬上 Redux
- 代码规范:ESLint + Prettier + Husky + lint-staged 全套
这里面最反直觉的是“不要 JavaScript”。很多人在小项目里觉得 TS 写起来“碍手碍脚”,得定义一堆类型,好像拖慢了速度。但是从工程整体来看,TS 其实是帮你节省时间的东西,尤其是项目超过一万行代码后,类型就是自带注释和自动校验的文档。t3code 直接设定成“新代码必须有类型”,老代码迁移时也要求逐步补齐,这一点在团队协作时尤为重要——你不需要靠口头约定去记住某个接口返回什么形状,类型会替你把关。
Tailwind CSS 的选择也是一个典型的“看起来慢、实际快”的决策。我知道有人抵触 Tailwind,理由是类名太长了,HTML 观感不干净。但在我实际测过的项目里,Tailwind 最大的好处不是“写起来快”,而是从根上消灭了“类名污染”和“样式死代码”。你在代码里用了哪些样式类,构建时就打包哪些,不可能出现“改了样式但没生效”的幽灵 CSS。配合我的三层理念,Tailwind 的原子化类名可以非常方便地在不同层级间复用,而不需要维护一套冗长的语义化样式表。
Vite 的选择就更不用多说了,冷启动和热更新体验是压倒性的,而 Webpack 的配置复杂度和构建耗时在大中型项目中会持续消耗团队的耐心。t3code 基于 Vite,第一次启动项目通常在一两秒内完成,开发体验好,你才愿意天天打开这个项目。
1.3 这个模板的“适用范围边界”
我还得泼一盆冷水:t3code 不是银弹。它的设计目标锁定在中大型后台管理系统、中后台前端应用、以及需要多人协作的长期项目。如果你只是写一个几十行的静态活动页,没必要上这一整套东西;反过来如果你的项目已经膨胀到了“页面之间互相乱引”的阶段,t3code 的分层和规范能帮你止血,但没法帮你瞬间把旧债还完。
这个边界必须说清楚,否则有人拿着这套模板去套活动页,会觉得“重”,这是正常的。模板的复杂度应该跟随业务复杂度,而不是反过来。
2. 核心细节解析与实操要点
2.1 目录结构:一个“见到就能懂”的骨架
t3code 的目录结构我打磨了很久,最终定下来是这样:
t3code/ ├── app/ # 应用层:页面和路由组件 │ ├── routes/ # 路由表 │ ├── pages/ # 页面级组件 │ ├── components/ # 通用 UI 组件 │ └── layouts/ # 布局组件 ├── domain/ # 领域层:业务模型与逻辑 │ ├── modules/ # 按业务模块划分,如 user、order │ │ ├── user/ │ │ │ ├── api.ts │ │ │ ├── store.ts │ │ │ ├── model.ts │ │ │ └── index.ts │ └── shared/ # 跨模块共享的业务逻辑 ├── kernel/ # 内核层:与业务无关的基础设施 │ ├── http/ # axios 实例与拦截器封装 │ ├── types/ # 全局通用类型 │ ├── utils/ # 纯函数工具库 │ ├── constants/ # 全局常量配置 │ ├── store/ # 全局 store 初始化 │ └── styles/ # 全局样式与主题变量 ├── public/ ├── tests/ # 测试用例(单元测试 + 测试工具) ├── vite.config.ts ├── tsconfig.json ├── eslint.config.js ├── tailwind.config.ts └── package.json这里有一个很多人一开始会骂的设定:页面目录不叫pages直接放最外层,而是藏在app/pages里。理由是,页面、路由、布局、通用组件本身都属于应用层,它们是“面向展示”的东西,不应该跟内核层的工具函数混在同一个平面目录里。你从入口进去,先看到的是“这个应用有哪些页面”,再往下深入才是“底层怎么支撑这些页面”,这种自上而下、由表及里的目视结构,比把所有文件夹平铺在根目录要清晰得多。
在实际使用中,我通常还会建议团队在domain/modules里为每个模块做“模块自治”,api.ts只管接口定义,store.ts管该模块的状态,model.ts定义该模块的数据类型。你永远不会在整个项目里看到userPageApi这种函数名,因为你不会再需要那种全局的、跨模块找接口的疲惫感。
2.2 类型体系:让“数据形状”成为第一等公民
t3code 在类型设计上有一个比较执拗的原则:后端的响应先建模,再对接。什么意思?就是接口联调前,先把接口的入参、出参类型定义好,前端代码全部基于这些类型编写。后端返回的数据结构变了,TS 会在编译期告诉我们,而不用等上线后用户发现页面崩了。
我举一个实际例子。假设你有一个登录接口,接口返回的数据长这样:
{ "code": 0, "data": { "token": "xxx", "user": { "id": 123, "name": "张三", "avatar": "https://..." } }, "message": "ok" }在 t3code 里,你不会写一个any类型的接盘侠,而是严格建模:
// kernel/types/api/http.ts export interface ApiResponse<T> { code: number data: T message: string } // domain/modules/user/model.ts export interface UserInfo { id: number name: string avatar: string } export interface LoginResult { token: string user: UserInfo } // domain/modules/user/api.ts import { http } from '@/kernel/http' import type { ApiResponse } from '@/kernel/types/api/http' import type { LoginResult } from './model' export function loginApi(data: { account: string; password: string }) { return http.post<ApiResponse<LoginResult>>('/api/login', data) }这样做的好处,在多人协作时体现得特别明显。两个人同时开发不同页面,都用到用户信息,他们只需要各自 import 同一个UserInfo类型,就保证了两边的数据结构认知是同步的。
关于类型,我还有一个建议:领域模型的类型定义不要带 UI 色彩。比如UserInfo里不要出现isSelected、checked这类字段,因为那是页面状态,不是用户数据。把 UI 状态和领域数据混在一个类型里,是很多项目类型混乱的根源之一。如果页面需要某些展示状态,在应用层定义一个新的页面级类型去组合领域类型就好。
2.3 样式方案:原子化 + 局部覆盖的组合拳
样式方案是我在 t3code 里花费心思较多的一块。Tailwind CSS 的原子化方案负责绝大部分样式的书写,但遇到复杂的、需要精细控制且复用性高的组件样式,我会建议使用 CSS Modules 作为局部的“重武器”。为什么不止用 Tailwind?因为有些样式是高度依赖嵌套上下文的,比如某些带有复杂伪元素、复杂响应式断点组合的“大组件”,用原子类拼起来会非常痛苦,而且可读性差。Tailwind 负责通用、高频、简单的样式,CSS Modules 负责局部复杂、复用性强的视觉模块,这个组合在实用性和维护性上达到了较优的平衡。
另外,我在 t3code 里预设了比较完整的 Tailwind 主题配置,把设计稿上常见的颜色、间距、字号都先定为 token,而不是让开发者在代码里写死text-[#ff6a00]:
// tailwind.config.ts export default { theme: { extend: { colors: { primary: { DEFAULT: '#2563eb', light: '#60a5fa', dark: '#1d4ed8', }, success: '#16a34a', warning: '#f59e0b', danger: '#dc2626', }, spacing: { 18: '4.5rem', 22: '5.5rem', }, borderRadius: { xl2: '1.25rem', }, }, }, }主题 token 的好处是,后期品牌换色,只需要改tailwind.config.ts里的配置,全站颜色自动生效。而写死色值的项目,往往改个主题要全局替换几百处,还容易漏。这是 t3code 在样式层面帮你避开的第一个坑。
2.4 网络请求封装:拦截器的正确打开方式
kernel/http这个模块,可能是基础层里最容易被搬走的部分。我在项目里把 axios 实例封装成了一个小而完整的模块:
// kernel/http/index.ts import axios from 'axios' import { message } from 'antd' import { router } from '@/app/routes' const http = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000, }) http.interceptors.request.use( (config) => { const token = localStorage.getItem('t3code_token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }, (error) => Promise.reject(error), ) http.interceptors.response.use( (response) => { const res = response.data if (res.code !== 0) { message.error(res.message || '请求异常') return Promise.reject(new Error(res.message || '请求异常')) } return res.data }, (error) => { if (error.response?.status === 401) { localStorage.removeItem('t3code_token') router.navigate('/login') message.error('登录已过期,请重新登录') } else { message.error(error.message || '网络异常') } return Promise.reject(error) }, ) export function get<T>(url: string, params?: Record<string, unknown>) { return http.get<T, T>(url, { params }) } export function post<T>(url: string, data?: unknown) { return http.post<T, T>(url, data) } export { http }有几个容易被忽视的细节:第一,响应拦截器里统一判断业务码code,如果后端业务失败,前端不需要每个页面都写if (res.code !== 0),全局统一弹出错误提示;第二,401 状态码全局处理,自动清 token 并跳转登录页,避免每个接口调用处都重复处理“登录过期”的逻辑;第三,泛型参数http.get<T, T>这层包装很重要,它保证了使用方拿到的是脱离AxiosResponse外壳的业务数据,类型也更干净。
关于封装“请求层”,我和不少人聊过,最大的争议是“要不要把后端返回的数据直接 return 而不是返回整个 response”。我的经验是,直接返回数据层更符合大多数业务组件的使用习惯,组件里不需要关心 HTTP 状态码和业务码,除非公司内部有多个后端系统、接口协议五花八门,那才需要考虑更复杂的规范适配层。t3code 默认场景是面向一套标准后端协议,足够用。
2.5 状态管理:按需使用,不做全家桶
状态管理这块,t3code 的选择是 Zustand。它比 Redux 轻量,不需要 boilerplate 代码,API 也直观;比 Context 在性能上表现好——Context 更新会导致整个 Provider 下的组件重新渲染,而 Zustand 通过细粒度的订阅,只有用到对应状态的组件才会重渲染。
举个例子,用户信息这个全局状态,我用 Zustand 定义成:
// domain/modules/user/store.ts import { create } from 'zustand' interface UserState { userInfo: UserInfo | null login: (payload: LoginParams) => Promise<void> logout: () => void } export const useUserStore = create<UserState>((set) => ({ userInfo: null, login: async (payload) => { const { token, user } = await loginApi(payload) localStorage.setItem('t3code_token', token) set({ userInfo: user }) }, logout: () => { localStorage.removeItem('t3code_token') set({ userInfo: null }) }, }))同时我有一个“硬性红线”:只有需要跨模块共享的状态才放进全局 store,模块私有的 UI 状态永远留在组件内部用 useState 管理。这条红线看起来很简单,但能有效防止全局 store 里堆积几十个不相关的字段,项目最后散成一堆“全局变量”。
3. 实操过程与核心环节实现
3.1 从零初始化项目:四步上车
如果你要基于 t3code 从零初始化一个项目,完整流程我拆解成四步。
第一步,初始化工程:
# 如果直接克隆 t3code 模板 git clone https://github.com/your-repo/t3code my-project cd my-project npm install # 或者基于 create-vite 徒手搭 npm create vite@latest my-project -- --template react-ts我推荐前者,因为 t3code 里的配置是整套联动设计的,不是徒手搭好 Vite 再临时拼起来的。
第二步,配置环境变量,在项目根目录新建.env和.env.production:
# .env VITE_API_BASE_URL=/api VITE_APP_TITLE=t3code practice # .env.production VITE_API_BASE_URL=https://api.example.com VITE_APP_TITLE=t3code practice环境变量的核心逻辑很简单:开发环境走本地代理,生产环境走线上域名。千万别把生产环境地址写死在代码里,一旦换个域名部署,你就要重新发一次代码。
第三步,配置路径别名。@指向src根目录,这在tsconfig.json和vite.config.ts里都要对应配置,否则编辑器提示和实际构建会“各认各的”:
// vite.config.ts import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import path from 'node:path' export default defineConfig({ plugins: [react()], resolve: { alias: { '@': path.resolve(__dirname, 'src'), }, }, })我一直劝大家别小看路径别名。没有别名时,深层组件的导入路径可能是../../../domain/user/types,一旦目录调整,引用全断。有别名后是@/domain/user/types,清晰稳定。
第四步,跑通链路。启动 dev server,在页面里随便调用一次 login API,确认请求能正常发出、响应能进入拦截器、状态能写进 store。这一步看起来很“傻瓜”,但很多项目的沟通问题都出在这:后端环境没就绪、代理没配、拦截器和后端协议对不上,全都被这一步暴露出来。
3.2 目录骨架落地:从模板到业务的第一个页面
模板初始化后,我通常建议先实现一个最小闭环:登录页 + 首页 + 用户信息展示。这不仅验证了模板本身能不能跑起来,也是让团队成员理解三层之间如何协作的最好方式。
在 t3code 里,我会这样拆:
kernel/http:封装 axios 实例,统一加 token、错误处理。domain/modules/user:定义UserInfo类型、loginApi函数、useUserStore状态。app/pages/Login.tsx:调用useUserStore.login,登录成功后跳转/home。app/pages/Home.tsx:从useUserStore里读取userInfo,渲染用户信息。
这个流程里最关键的是“分层依赖只允许从上往下”:app/pages可以依赖domain和kernel,domain可以依赖kernel,但kernel千万不能反过来依赖domain或app。分层依赖方向一旦乱了,整个边界就会崩掉。我在实际维护中就见过 kernel 里的工具函数偷偷 import 了 domain 里的某个 API,理由是“这样方便”。图一时方便,后面所有层面都可能开始乱引,这个口子一开,血崩只是时间问题。
3.3 开发环境代理配置细节
Vite 的本地开发代理是提高联调效率的重要一环,我建议在vite.config.ts里统一配置跨域代理:
export default defineConfig({ server: { host: '0.0.0.0', port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, ''), }, }, }, })这里的rewrite值得注意,很多后端接口路径本身不带/api前缀,所以把请求从/api/user/list转发到后端时要去掉前缀。至于为什么前端要加/api前缀而后端不需要?通常是为了让 Nginx 等反向代理能区分“静态资源请求”和“API 请求”,这算是一种行业惯例。
如果你发现请求能发出去,但状态码是 404,首先要去确认代理目标和 rewrite 的规则是否和后端路由匹配,这是最常见的联调事故之一。
3.4 代码规范与提交红线:Husky + lint-staged 的组合
代码规范不靠自觉,靠自动化卡口。t3code 里配了 Husky 和 lint-staged,在 pre-commit 阶段自动对暂存区的代码做检查。
我的package.json里核心配置是这样:
{ "scripts": { "lint": "eslint . --ext ts,tsx", "lint:fix": "eslint . --ext ts,tsx --fix", "format": "prettier --write \"src/**/*.{ts,tsx,css,json}\"", "typecheck": "tsc --noEmit" }, "lint-staged": { "*.{ts,tsx}": ["eslint --fix", "prettier --write"], "*.{css,json}": ["prettier --write"] } }.husky/pre-commit文件内容只有一行:
npm run lint-staged这样每个开发者提交代码前,暂存区的代码会被自动格式化并做 ESLint 检查,有错误就直接拦截。你可能觉得这个流程“多此一举”,但项目里一旦出现“这块代码不是我写的,怎么格式这么乱”的争论,你就知道这条红线有多救命。
讲一个技术细节:lint-staged的文件匹配规则是基于 git 暂存区,而不是全量文件。这意味着你只会检查本次提交的代码,不会因为历史遗留的“千年 lint 错误”导致每次提交都失败。老项目引入这套机制时,这一点至关重要——先给新代码立规矩,老债慢慢还。
3.5 环境适配:开发/测试/生产三套配置
t3code 对环境配置的设置也和普通模板不同。我不会只放一个.env,而是会拆成.env.development、.env.test、.env.production三份:
# .env.development VITE_ENV=development VITE_API_BASE_URL=/api # .env.test VITE_ENV=test VITE_API_BASE_URL=https://test-api.example.com # .env.production VITE_ENV=production VITE_API_BASE_URL=https://api.example.com然后在代码里通过import.meta.env.VITE_ENV来区分当前环境,做不同的行为逻辑。比如只有 test 环境才打印某些调试日志、生产环境关掉所有的 console.log。
这里有个经验:不要把测试环境的地址跟生产环境搞混,更不要因为“测试环境不就是给测试用的嘛”就把console和生产环境一视同仁。我的习惯是,生产环境的console一律关闭,减少性能损耗和数据暴露风险。
4. 常见问题与排查技巧实录
4.1 TypeScript 类型报错的典型场景
报错一:Type 'AxiosResponse<any>' is not assignable to type 'xxx'
这种报错基本说明你在使用http.get时泛型参数没配对。t3code 的get方法接收两个泛型:http.get<T, T>中第一个T是AxiosResponse的泛型,第二个T是方法返回类型的泛型。如果你只传一个泛型,返回类型可能仍然是AxiosResponse的包装形态,和你期待的“业务数据”不一致。
解决办法:确认你调用的是kernel/http里封装的get/post方法,而不是直接使用原生axios实例。如果你坚持用原生axios,就要手动剥壳,这样类型会变得复杂且容易出错。
报错二:Property 'xxx' does not exist on type 'UserInfo'
这个报错其实是好消息:它说明你的类型在起作用。大概率是后端返回的字段名和前端定义的UserInfo不完全一致,比如后端叫accountName,你类型里写的是userName。处理方式很简单,改类型定义对齐后端字段名,或者在定义类型时做字段映射,千万不要图省事把类型改成any。一个any省下的十分钟,后面可能要花十个小时来还。
报错三:Module '"@/domain/user/api"' has no exported member 'loginApi'
路径别名@配置有问题。检查tsconfig.json的paths配置和vite.config.ts的resolve.alias配置,两边必须一致。经常有人只配了 vite 没配 tsconfig,导致编译能跑但编辑器报红;配了 tsconfig 没配 vite,结果一构建就崩。t3code 已经预置了这两处配置,如果改了目录结构记得同步维护。
4.2 Tailwind CSS 样式不生效的几个常见原因
原因一:内容扫描路径没配全
Tailwind 需要通过扫描源文件来确定哪些类名应该生成。t3code 的tailwind.config.ts中,content配置如下:
content: [ './app/**/*.{ts,tsx}', './domain/**/*.{ts,tsx}', './kernel/**/*.{ts,tsx}', ],如果你新增了某个根目录下的文件,但没把它所在的目录加进content,那 Tailwind 根本不知道这些类名存在,样式自然不会生效。新手最常见的表现就是:明明写了className="text-2xl flex gap-2",页面上啥效果都没有。
原因二:类名被动态拼接
// 错误写法 const color = 'text-red-500' return <div className={`theme-${color}`}>hello</div> // 正确写法 const color = 'text-red-500' return <div className={color}>hello</div>Tailwind 在构建时是静态扫描的,它没法推断运行时拼接出来的完整类名。如果你非要做主题切换,建议抽出完整的类名字符串,放到一个theme配置对象里,不要做字符串片段拼接。
原因三:被 CSS Modules 的同名类覆盖
如果你同时使用 Tailwind 和 CSS Modules,需要约定使用范围。t3code 的默认约定是:全局布局、通用组件用 Tailwind 完成;局部复杂组件如果使用 CSS Modules,类名尽量避免和 Tailwind 的通用类名撞车,否则优先级容易失控。
4.3 ESLint 与 Prettier 打架
ESLint 负责代码质量,Prettier 负责代码格式,两者偶尔会冲突。最典型的是单引号和双引号的偏好:Prettier 默认使用双引号,而很多 ESLint 规则集默认要求单引号,结果就是:eslint --fix改成单引号,保存时 Prettier 又改回双引号,两个插件在编辑器里干架。
解决办法是在根目录放一份.prettierrc,让 Prettier 先去匹配 ESLint 的偏好:
{ "semi": true, "singleQuote": true, "printWidth": 100, "tabWidth": 2, "trailingComma": "all" }同时 ESLint 配置里关掉所有与格式化相关的规则,只保留代码质量类规则,把“长什么样”这件事完全交给 Prettier。这也符合 t3code 的自动化理念:格式问题交给机器,人只关注逻辑问题。
4.4 构建结果在老浏览器上白屏
如果你配的浏览器兼容目标是 IE11 或旧版 Chrome,Vite 默认的构建目标可能不满足需求。这时候需要修改 Vite 的build.target和引入对应的 polyfill:
// vite.config.ts export default defineConfig({ build: { target: 'es2015', }, })同时安装@vitejs/plugin-legacy,它会自动为主流旧浏览器生成兼容代码块。
不过我还是建议如果你真需要兼容 IE11,趁早确认业务需求是否真的存在,因为从 2023 年起,微软自身都已经停止对 IE11 的维护了。绝大多数情况下,我们只需要把目标降到es2015或es2018就够了。
4.5 一个流程图之外的常见“玄学”:热更新失效
Vite 热更新偶尔会出现“改了代码但页面没反应”,尤其当你修改了kernel或domain文件夹里的文件时。t3code 的应对策略是:
- 确认没有同时开启多个 dev server
- 确认文件路径没有被
.gitignore或vite.config.ts中的exclude排除 - 修改
kernel/types或tailwind.config这类“非组件文件”时,热更新本来就不会触发页面重渲染,碰到这种情况直接重启 dev server 即可
4.6 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 页面样式全丢 | Tailwind content 路径没包含目标目录 | 检查 tailwind.config.ts 的 content 配置 |
| 接口返回 401 无限循环 | 拦截器跳转登录页后登录页又发请求 | 在拦截器里对登录接口做白名单处理 |
| 类型定义改了但报错没消失 | TS Server 缓存 | 重启 TS Server,或删除node_modules/.vite缓存 |
| dev 环境代理不通 | target、rewrite、后端路由不一致 | 检查 vite.config.ts 代理配置和后端实际路由 |
| ESLint 报错和 Prettier 冲突 | 两边规则没对齐 | 按 4.3 的方式配置.prettierrc并关闭冲突规则 |
| 新增页面访问直接 404 | 路由表没注册 | 检查app/routes里的路由配置 |
| 构建产物过大 | 没做按需加载和拆包 | 使用React.lazy做路由懒加载,配置build.rollupOptions拆包 |
5. t3code 的扩展边界与后续玩法
模板这东西,最怕把路走死。t3code 虽然给了很多约束,但它也留了明确的口子:
- 如果你要做 SSR 或静态站点生成,Vite 的能力边界可以通过引入
vite-plugin-ssr或Nuxt来扩展,t3code 的内核层和领域层设计可以无缝迁移过去。 - 如果你要做移动端项目,
app/components和kernel/http这一层几乎可以原封不动地搬进 React Native 的工程里,因为业务逻辑和 UI 框架本来就是解耦的。 - 如果你团队里同时有 Vue 和 React 的项目,把
domain和kernel抽成独立的 npm 包,可以让两端共用同一套业务模型和请求层,这就是往 monorepo 方向演进的第一小步。
我在实际项目里就做过一次类似的“内核剥离”,效果非常好。两个团队从共享kernel开始,后来把domain里的用户模块、权限模块都逐步收拢,最终形成了一个底层的业务中台包。这套分层的价值,从个人项目延展到组织协作的层面,才算真正发挥到极致。
t3code 不是一个“完成了”的项目,它更像一个活的起搏器,不断往里面加模块、加规范、加持性能优化经验,它会跟着业务一起长。你抄走模板只是开始,真正的功夫是后续配合团队把约定执行成习惯。
6. 最后几条私人经验
分享几个我在实际搭建和使用 t3code 过程中“用血换来的”心得。
第一,阶段化的重构比一次性迁移靠谱。如果你想给老项目引入 t3code 规范,不要试图在一周内把所有目录全部重构。最务实的路径是:先把kernel/http和统一类型体系引入,让新代码先跑在规范上;再把domain按模块一点点从页面里抽出来;最后动app层的目录归位。每一步都可以独立上线,风险和体感都更好。
第二,约定一定要落到工具上。没有工具强制的约定,就约等于没有约定。团队可以用任何编程语言、任何框架,但规范检查必须在 CI 和 precommit 阶段自动执行。我在 t3code 里甚至配了 commitlint,要求所有 commit message 遵循 conventional commits 规范,不是为了好看,是为了让git log和版本发布能自动生成可读的 changelog。这条经验越早推行越省钱。
第三,命名占了维护成本的一大半。t3code 里所有文件名和目录名都要遵守统一的斜杠命名法,组件名用 PascalCase,工具函数用 camelCase,模块目录名一律单数。看起来细碎,但当你用全局搜索找东西的时候,这些细节决定了你是一分钟定位还是十分钟迷路。
第四,文档别追求“大而全”,写清楚“为什么”比写“怎么做”更重要。我建了一个docs/decisions目录,里面记录每个关键配置和目录设计的前因后果。比如“为什么这里用 Zustand 而不是 Redux?”“为什么kernel/http要直接 return data 而不是 response 整个对象?”三个月后你自己再回来看,都会感谢之前写下的这些“为什么”。
t3code 对我来说,不只是一份代码模板。它更像是一套工程思维的具体化。技术选型会过时、依赖库会升级,但是分层边界、类型约束、自动化卡口、按约定协作这件事,放到任何前端框架下都是通用的。如果你能把这些思想带走,那么你手里那团乱糟糟的前端代码,就会慢慢变成你愿意长期维护的项目。这也是我做这个项目,最希望看到的结果。