项目架构与工程化最佳实践
第23章:项目架构与工程化最佳实践
大型 React 项目最常见的死亡方式不是技术选型错误,而是架构腐化。
本章核心问题:按功能组织 vs 按类型组织的核心差异是什么?中大型项目如何避免架构腐化? 读完本章你将理解:
- 按功能组织让相关代码物理聚合,删除功能只需删除对应目录
- barrel 导出有利有弊——shared 层面使用,feature 内部避免
- ESLint + Prettier + Husky + commitlint 构成完整的代码规范体系
Level 1 · 你需要知道的(1-3年经验)
目录结构:按功能组织 vs 按类型组织
按类型组织(Type-based)
src/
├── components/ ← 所有组件
├── hooks/ ← 所有自定义 Hook
├── pages/ ← 所有页面
├── services/ ← 所有 API 调用
├── utils/ ← 所有工具函数
└── store/ ← 所有状态管理
这种结构在项目初期非常直观,但在项目规模增长后会产生严重问题:修改一个功能需要同时修改 5 个目录下的文件,文件的相关性完全无法从目录结构中看出来。
按功能组织(Feature-based)
src/
├── features/
│ ├── auth/
│ │ ├── components/ ← 仅 auth 功能使用的组件
│ │ │ ├── LoginForm.tsx
│ │ │ └── OAuthButton.tsx
│ │ ├── hooks/
│ │ │ └── useAuth.ts
│ │ ├── services/
│ │ │ └── authApi.ts
│ │ ├── store/
│ │ │ └── authSlice.ts
│ │ └── index.ts ← 公开 API
│ ├── dashboard/
│ │ ├── components/
│ │ ├── hooks/
│ │ └── index.ts
│ └── settings/
│ └── ...
├── shared/ ← 跨功能的共享代码
│ ├── components/ ← 通用 UI 组件
│ │ ├── Button/
│ │ ├── Modal/
│ │ └── DataTable/
│ ├── hooks/ ← 通用 Hook
│ └── utils/ ← 工具函数
├── pages/ ← 路由页面(薄层,组合 feature 组件)
│ ├── DashboardPage.tsx
│ └── SettingsPage.tsx
└── app/ ← 应用初始化
├── router.tsx
├── store.ts
└── main.tsx
按功能组织的核心价值:功能的相关代码在物理上聚合在一起,新开发者能快速理解一个功能的完整实现,删除一个功能也只需要删除对应目录。每个 feature 通过 index.ts 暴露公开 API,其他 feature 只能引用公开 API,而不能深入引用内部实现(通过 ESLint 规则强制)。
barrel 导出(index.ts)的争议
barrel 文件(index.ts 重导出模块内所有公开内容)是一个争议话题:
优势:
// 使用 barrel
import { Button, Input, Modal } from '@/shared/components';
// 不使用 barrel(路径暴露内部结构)
import { Button } from '@/shared/components/Button/Button';
import { Input } from '@/shared/components/Input/Input';
import { Modal } from '@/shared/components/Modal/Modal';
问题一:bundle size。构建工具(Vite、Webpack)在处理 barrel 时,可能无法做到精确的 tree-shaking——即使你只用了 Button,整个 barrel 可能被引入。现代构建工具(尤其是 Rollup/Vite 的 ESM 模式)已经大幅改善了这个问题,但在使用 CommonJS 模块的环境中仍然存在。
问题二:循环依赖。barrel 文件很容易引入循环依赖,尤其是 feature 之间互相引用时。eslint-plugin-import 的 import/no-cycle 规则可以检测,但无法从根本上消除。
问题三:IDE 性能。在超大型项目(数万个文件)中,barrel 文件会显著拖慢 TypeScript 语言服务的响应速度,因为解析一个 import 需要遍历整个 barrel。
实用建议:在 shared/components 层面使用 barrel(因为这里的组件高度稳定,且确实是跨功能通用的);在 feature 内部不使用 barrel(直接引用具体文件);feature 的 index.ts 仅导出需要对外暴露的内容。
Monorepo:Turborepo 在大型项目中的应用
当项目规模扩展到多个应用(管理后台、用户端、营销页面)共享组件库、工具函数和 API 类型时,Monorepo 是正确的选择。
npx create-turbo@latest my-monorepo
典型 Turborepo 结构
apps/
├── web/ ← 用户端 Next.js 应用
├── admin/ ← 管理后台 Vite + React 应用
└── docs/ ← 文档站点
packages/
├── ui/ ← 共享 UI 组件库
│ ├── src/
│ │ ├── Button/
│ │ └── index.ts
│ └── package.json
├── shared/ ← 共享工具、类型、常量
│ └── src/
│ ├── types/
│ └── utils/
└── tsconfig/ ← 共享 TypeScript 配置
├── base.json
├── react.json
└── nextjs.json
turbo.json 配置
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"], // 先构建依赖包
"inputs": ["src/**", "package.json"],
"outputs": [".next/**", "dist/**"]
},
"test": {
"dependsOn": ["^build"],
"inputs": ["src/**", "test/**"]
},
"lint": {
"inputs": ["src/**", ".eslintrc.*"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}
Turborepo 的核心价值是增量构建缓存:只有文件变更的包才会重新构建,其他包直接使用缓存。在大型 monorepo 中,这能将 CI 时间从 20 分钟压缩到 2 分钟。
环境配置管理
.env ← 所有环境共享的默认值(提交到 git)
.env.local ← 本地覆盖(不提交,加入 .gitignore)
.env.development ← 开发环境(提交到 git)
.env.production ← 生产环境(提交到 git,不含敏感值)
.env.production.local ← 生产敏感配置(不提交,CI 环境变量注入)
在 Vite 项目中,只有 VITE_ 前缀的变量才会暴露到前端代码:
// src/config/env.ts - 集中验证和导出环境变量
const requiredEnvVars = {
apiBaseUrl: import.meta.env.VITE_API_BASE_URL,
sentryDsn: import.meta.env.VITE_SENTRY_DSN,
appEnv: import.meta.env.VITE_APP_ENV ?? 'development',
} as const;
// 在应用启动时验证必需变量
Object.entries(requiredEnvVars).forEach(([key, value]) => {
if (!value && import.meta.env.PROD) {
throw new Error(`缺少必需的环境变量:${key}`);
}
});
export const env = requiredEnvVars;
永远不要在代码里散落 import.meta.env.VITE_* 调用,集中管理、集中验证,让环境配置问题在启动时就暴露,而不是在运行时某个深层功能里。
Level 2 · 它是怎么运行的(3-5年经验)
中大型应用完整架构示例
以一个 SaaS 管理后台为例,展示完整的文件组织:
src/
├── app/
│ ├── router.tsx ← React Router 路由配置
│ ├── store.ts ← Redux/Zustand 根 store
│ ├── queryClient.ts ← React Query 客户端配置
│ └── main.tsx ← 应用入口,Provider 组装
├── features/
│ ├── auth/
│ │ ├── components/
│ │ │ ├── LoginForm.tsx
│ │ │ └── ProtectedRoute.tsx
│ │ ├── hooks/
│ │ │ ├── useAuth.ts
│ │ │ └── usePermission.ts
│ │ ├── services/
│ │ │ └── authApi.ts ← 封装 auth 相关 API 调用
│ │ ├── store/
│ │ │ └── authSlice.ts
│ │ └── index.ts ← export { useAuth, ProtectedRoute }
│ ├── users/
│ │ ├── components/
│ │ │ ├── UserTable.tsx
│ │ │ ├── UserForm.tsx
│ │ │ └── UserFilters.tsx
│ │ ├── hooks/
│ │ │ └── useUsers.ts ← 封装 useQuery + useMutation
│ │ ├── services/
│ │ │ └── usersApi.ts
│ │ └── index.ts
│ └── billing/
│ └── ...
├── shared/
│ ├── components/
│ │ ├── Button/
│ │ │ ├── Button.tsx
│ │ │ ├── Button.test.tsx
│ │ │ └── index.ts
│ │ ├── DataTable/
│ │ └── index.ts ← 共享组件的 barrel
│ ├── hooks/
│ │ ├── useDebounce.ts
│ │ ├── useLocalStorage.ts
│ │ └── index.ts
│ └── utils/
│ ├── format.ts
│ ├── validate.ts
│ └── index.ts
├── pages/ ← 薄层,组合 feature 组件
│ ├── LoginPage.tsx
│ ├── UsersPage.tsx
│ └── BillingPage.tsx
└── types/ ← 全局类型声明
├── api.ts ← API 响应类型
└── global.d.ts
这个结构有几个关键设计决策:
- pages 是薄层。页面组件只负责组装 feature 组件,不包含业务逻辑。
- feature 是自包含单元。每个 feature 包含完整的组件、hooks、API 调用,删除一个 feature 不影响其他 feature。
- shared 有严格边界。只有真正跨 feature 复用的代码才进入 shared,而不是"为了复用而提前抽象"。
- 类型定义靠近使用者。API 类型放在对应的
services/里,全局类型放在types/,不强制所有类型都集中。
良好的工程架构不是一次性设计好的,而是在业务增长过程中不断演进的结果。关键是建立清晰的规则(什么代码放在哪里、feature 之间如何通信)并配套工具链强制执行,这样即使团队规模扩大,代码库也能保持可理解、可维护的状态。
Level 3 · 规范怎么定义的(资深)
组件设计原则
单一职责:组件只做一件事
组件职责过多是大型项目最常见的问题。一个组件不应该同时负责:获取数据、转换数据、渲染 UI、处理用户交互。
// 错误:一个组件做了太多事情
function UserDashboard() {
const [users, setUsers] = useState<User[]>([]);
const [filter, setFilter] = useState('');
// 数据获取
useEffect(() => { fetchUsers().then(setUsers); }, []);
// 数据过滤
const filtered = users.filter(u => u.name.includes(filter));
// 渲染、事件处理...全在一个组件里
}
// 正确:职责分离
function UserDashboardPage() {
return (
<UserDataProvider> {/* 数据获取 */}
<UserDashboardLayout> {/* 布局 */}
<UserFilterBar /> {/* 过滤交互 */}
<UserTable /> {/* 数据展示 */}
</UserDashboardLayout>
</UserDataProvider>
);
}
组合优于继承
React 的组合模型天然适合构建灵活的组件:
// 通过 children 和 slots 实现灵活组合
interface CardProps {
header?: React.ReactNode;
footer?: React.ReactNode;
children: React.ReactNode;
className?: string;
}
function Card({ header, footer, children, className }: CardProps) {
return (
<div className={`card ${className ?? ''}`}>
{header && <div className="card-header">{header}</div>}
<div className="card-body">{children}</div>
{footer && <div className="card-footer">{footer}</div>}
</div>
);
}
// 消费方完全控制每个区域的内容,无需继承
<Card
header={<h2>用户信息</h2>}
footer={<Button>编辑</Button>}
>
<UserProfile user={user} />
</Card>
代码规范体系
ESLint + Prettier 配置
npm install -D eslint @eslint/js typescript-eslint eslint-plugin-react-hooks eslint-plugin-jsx-a11y prettier eslint-config-prettier
// eslint.config.js(ESLint v9 flat config)
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import reactHooks from 'eslint-plugin-react-hooks';
import jsxA11y from 'eslint-plugin-jsx-a11y';
import prettier from 'eslint-config-prettier';
export default tseslint.config(
js.configs.recommended,
...tseslint.configs.recommended,
{
plugins: {
'react-hooks': reactHooks,
'jsx-a11y': jsxA11y,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-hooks/exhaustive-deps': 'error', // 升级为 error,不允许忽略
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'jsx-a11y/alt-text': 'error',
// 禁止跨 feature 的内部引用(需要 eslint-plugin-import)
'import/no-restricted-paths': [
'error',
{
zones: [
{
target: './src/features/auth',
from: './src/features/dashboard',
message: '不允许从 dashboard feature 引用 auth feature 的内部模块',
},
],
},
],
},
},
prettier // 放在最后,关闭与 Prettier 冲突的规则
);
Husky + lint-staged:提交前自动检查
npm install -D husky lint-staged
npx husky init
// package.json
{
"lint-staged": {
"*.{ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{json,css,md}": [
"prettier --write"
]
}
}
# .husky/pre-commit
npx lint-staged
# .husky/commit-msg
# 可选:强制 Conventional Commits 格式
npx commitlint --edit $1
commitlint:规范提交信息
npm install -D @commitlint/cli @commitlint/config-conventional
// commitlint.config.js
export default {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [2, 'always', [
'feat', 'fix', 'docs', 'style', 'refactor',
'test', 'chore', 'perf', 'ci', 'build', 'revert'
]],
},
};
规范的提交信息(feat: 添加用户登录功能)不仅让 git log 可读,也是自动生成 CHANGELOG 和语义化版本控制(semantic-release)的基础。
Level 4 · 边界与陷阱(所有人)
生产环境常见问题
在实际项目中,本章涵盖的概念最常见的问题包括:
-
忽视边界条件:在正常路径下工作正常的代码,在异常路径(网络失败、用户快速操作、组件卸载)下可能产生 bug。始终考虑清理和取消逻辑。
-
过早优化:在没有测量的情况下添加优化代码,增加了复杂度但不一定提升性能。先用 Profiler 确认问题存在,再实施优化。
-
文档与实际行为的差异:React 的行为在不同版本间可能有微妙差异,尤其是并发模式相关的特性。始终以实际测试结果为准。