第 23 章

项目架构与工程化最佳实践

第23章:项目架构与工程化最佳实践

大型 React 项目最常见的死亡方式不是技术选型错误,而是架构腐化。

本章核心问题:按功能组织 vs 按类型组织的核心差异是什么?中大型项目如何避免架构腐化? 读完本章你将理解


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-importimport/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

这个结构有几个关键设计决策:

  1. pages 是薄层。页面组件只负责组装 feature 组件,不包含业务逻辑。
  2. feature 是自包含单元。每个 feature 包含完整的组件、hooks、API 调用,删除一个 feature 不影响其他 feature。
  3. shared 有严格边界。只有真正跨 feature 复用的代码才进入 shared,而不是"为了复用而提前抽象"。
  4. 类型定义靠近使用者。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 · 边界与陷阱(所有人)

生产环境常见问题

在实际项目中,本章涵盖的概念最常见的问题包括:

  1. 忽视边界条件:在正常路径下工作正常的代码,在异常路径(网络失败、用户快速操作、组件卸载)下可能产生 bug。始终考虑清理和取消逻辑。

  2. 过早优化:在没有测量的情况下添加优化代码,增加了复杂度但不一定提升性能。先用 Profiler 确认问题存在,再实施优化。

  3. 文档与实际行为的差异:React 的行为在不同版本间可能有微妙差异,尤其是并发模式相关的特性。始终以实际测试结果为准。

本章评分
4.8  / 5  (6 评分)

💬 留言讨论