Web 前端进阶(Vue/React 双版本)

Vue 3 & React 18 Twin Editions · 双版本同纲对照:00-导读 + 初级/中级/进阶分部 + 附录,以贯穿项目「轻订单」实操,Vue 与 React 各 3
← 返回教程馆
9阶段
65章节
65源文档
vibecoding仓库目录

📊 我的学习进度 本地自动保存

加载中…
🧭 导览0/0
正文0/0
react editio0/0
react editio0/0
react editio0/0
react editio0/0
vue edition 0/0
vue edition 0/0
vue edition 0/0
vue edition 0/0
💡 每章卡片右上角「✓ 标记已学」;教程间互链已自动映射到本页章节;代码示例请配合 Gitee 仓库 使用。

🧭 Web 前端开发进阶教程(Vue / React 双版本) README.md

一套覆盖 初级 → 中级 → 高级 → 资深 完整进阶路径的系统化前端教程,提供 Vue 与 React 两个完全独立、章节一一对应的版本。所有实战围绕同一个企业级贯穿项目「轻订单」(B2B 订单管理 SaaS)展开,拒绝玩具 demo。


1. 这套教程适合谁

你是谁 建议入口
刚入行 / 转行前端 从对应版本第一部分开始,逐章完成练习
会用框架但没做过完整企业项目 直接进入第二部分,用贯穿项目补齐中后台实战
3–5 年经验,冲高级/资深 第三、四部分 + 各阶段自查清单定向补短板
团队 Tech Lead / 需要做技术决策 第四部分(架构、协作、选型)+ 附录 A 需求文档可直接用作内训教材
团队里同时有 Vue 和 React 技术栈 两版对照阅读,概念映射写在每章开头的「对照学习」框里

2. 两个版本怎么选、怎么对照

  • 只学一个框架:选你的技术栈版本即可,另一版可以完全不看。
  • 双栈对照学习:两版章节编号、目录、实战任务、自查清单完全对应。例如 Vue 版第 08 章《状态管理-Pinia实战》对应 React 版第 08 章《状态管理-ReduxToolkit与Zustand》,连练习题都是同一个需求(订单暂存区)。
  • 每章开头的「💡 对照学习」框给出该章核心概念在另一框架中的对应物(如 computed ≈ useMemo、provide/inject ≈ Context)。

3. 目录总览

frontend-vue-react-tutorial/
├── README.md                                # 本文件
├── BLUEPRINT.md                             # 教程规格与维护指南(写作者看,读者可跳过)
├── 附录A-贯穿项目「轻订单」需求说明书.md        # 两版共用:业务需求、接口约定、阶段里程碑
├── vue-edition/                             # Vue 3 版(Composition API + Vite + Pinia + Element Plus)
│   ├── 00-导读.md                           #   从这里开始
│   ├── part1-junior/        第 01–06 章 初级:基础语法与组件化
│   ├── part2-intermediate/  第 07–12 章 中级:状态、路由与服务端交互
│   ├── part3-advanced/      第 13–18 章 高级:性能、测试与工程化
│   ├── part4-senior/        第 19–24 章 资深:架构、协作与选型
│   └── appendix/            附录 B 面试题精选 / 附录 C 学习路线图与资源
└── react-edition/                           # React 版(React 19 + Vite + RTK/Zustand + Ant Design)
    ├── 00-导读.md                           #   从这里开始
    └── (目录结构与 vue-edition 完全对应)

4. 四阶段能力地图

阶段 章节 学完你能做什么 每阶段包含
🌱 初级 01–06 独立完成组件化页面开发:组件拆分、props 设计、列表与表单渲染 学习目标 · 核心知识点 · 3 级渐进练习 · 易错点对照 · 最佳实践 · 能力自查清单
🌿 中级 07–12 独立交付企业中后台:路由与权限、全局状态管理、接口层封装、复杂表单 同上 + 能力自查清单
🌳 高级 13–18 治理一个真实系统:渲染与加载性能优化、单测/E2E、TypeScript 与工程化、CI/CD 与监控 同上 + 能力自查清单
🏔 资深 19–24 主导架构:分层设计、组件库、微前端、协作规范、技术选型与方案评审 同上 + 能力自查清单

每部分末尾都有 阶段自查-XX能力清单.md:12–16 条可勾选项(能独立完成 / 能讲清原理 / 能落地规范 三组)+ 30 分钟自测小考 + 「未达标回看第几章」的补救索引。

5. 贯穿项目「轻订单」

四个阶段共用同一业务域,练习任务随阶段递进:

  1. 初级:官网门户首页组件化(导航、Banner、FAQ 手风琴、暗色主题);
  2. 中级:中后台管理系统(登录鉴权 → 动态菜单 → 订单列表/详情/新建 → 三级权限);
  3. 高级:同一系统的质量与性能治理(万级列表优化、LCP 从 4s 到 2s、测试覆盖、CI/CD 与监控);
  4. 资深:架构演进(模块化分层、内部组件库、微前端接入、v2 技术方案与评审)。

完整需求、接口约定与 TypeScript 实体定义见 附录A。

6. 使用建议

  • 动手优先:每章「渐进式实战练习」分基础/进阶/挑战三档,至少完成前两档;只读不练大约只能吸收三成。
  • 先读附录 A:开始任何一部分之前,花 15 分钟通读需求说明书,练习时你会反复用到它。
  • 用自查清单卡进度:每完成一个部分,先自查再进入下一阶段;清单里每条都标了「未达标回看第几章」。
  • 企业场景对照:每章的「常见易错点」都来自真实企业代码评审中的高频问题,建议团队把它当作 CR 时的对照表。

7. 版本基线(写作时)

Vue 3.5+ / Vue Router 4 / Pinia 3 / Vite 6+ / Element Plus | React 19 / React Router 7 / Redux Toolkit 2 + Zustand / TanStack Query v5 / Ant Design 5 | 两版第三部分起使用 TypeScript 5。详细技术栈对照见各版《00-导读》或 BLUEPRINT §5。


教程内容为教学虚构场景,文中公司、域名、账号均非真实。

📄 教程规格与维护指南(BLUEPRINT) BLUEPRINT.md

本文件是《Web 前端开发进阶教程(Vue / React 双版本)》的唯一规格基准。 所有章节作者(人与 AI)写章节前必须通读本文件并严格遵守。 后续维护者增补章节时,也以本文件为准,保证两版始终一一对应。


1. 项目定位

  • 目标读者:从初级程序员到企业级资深开发的前端工程师;也适合需要「双栈对照」的团队(一部分人写 Vue,一部分人写 React)。
  • 交付形态:两套完全独立的 Markdown 教程——vue-edition/ 与 react-edition/,两版章节编号、目录结构、实战任务、自查清单一一对应,可逐章对照学习。
  • 组织方式:四个阶段递进
    • 第一部分 · 初级阶段:基础语法与组件化开发(第 01–06 章)
    • 第二部分 · 中级阶段:状态管理、路由与服务端交互(第 07–12 章)
    • 第三部分 · 高级阶段:性能优化、测试与工程化(第 13–18 章)
    • 第四部分 · 资深阶段:架构设计、团队协作与技术选型(第 19–24 章)
  • 贯穿项目:企业级 B2B 订单管理 SaaS「轻订单」(详见根目录《附录A-贯穿项目「轻订单」需求说明书.md》)。四个阶段共用同一业务域:门户首页 → 中后台管理系统 → 性能与质量治理 → 架构演进,确保练习是真实企业场景而非玩具 demo。

2. 目录结构(最终交付物)

frontend-vue-react-tutorial/
├── README.md                          # 总览:两版对照使用说明
├── BLUEPRINT.md                       # 本文件(规格与维护指南)
├── 附录A-贯穿项目「轻订单」需求说明书.md   # 两版共用业务需求
├── vue-edition/
│   ├── 00-导读.md
│   ├── part1-junior/
│   │   ├── 01-走进Vue3-框架概览与环境搭建.md
│   │   ├── 02-模板语法与数据绑定.md
│   │   ├── 03-组件化开发基础.md
│   │   ├── 04-响应式系统核心.md
│   │   ├── 05-生命周期与组件通信.md
│   │   ├── 06-初级实战-企业门户首页.md
│   │   └── 阶段自查-初级能力清单.md
│   ├── part2-intermediate/
│   │   ├── 07-路由系统-VueRouter全解.md
│   │   ├── 08-状态管理-Pinia实战.md
│   │   ├── 09-服务端交互与接口层设计.md
│   │   ├── 10-表单处理与校验.md
│   │   ├── 11-登录鉴权与权限控制.md
│   │   ├── 12-中级实战-轻订单管理系统.md
│   │   └── 阶段自查-中级能力清单.md
│   ├── part3-advanced/
│   │   ├── 13-渲染性能优化.md
│   │   ├── 14-加载性能优化.md
│   │   ├── 15-组件测试.md
│   │   ├── 16-E2E测试与质量保障.md
│   │   ├── 17-工程化实践-TypeScript与Vite.md
│   │   ├── 18-CICD部署与前端监控.md
│   │   └── 阶段自查-高级能力清单.md
│   ├── part4-senior/
│   │   ├── 19-大型项目架构设计.md
│   │   ├── 20-设计系统与组件库建设.md
│   │   ├── 21-微前端与跨团队协作.md
│   │   ├── 22-团队协作规范.md
│   │   ├── 23-技术选型决策.md
│   │   ├── 24-资深实战-技术方案与架构评审.md
│   │   └── 阶段自查-资深能力清单.md
│   └── appendix/
│       ├── 附录B-面试题精选.md
│       └── 附录C-学习路线图与资源.md
└── react-edition/
    ├── 00-导读.md
    ├── part1-junior/
    │   ├── 01-走进React-框架概览与环境搭建.md
    │   ├── 02-JSX与渲染模型.md
    │   ├── 03-组件化开发基础.md
    │   ├── 04-Hooks核心.md
    │   ├── 05-副作用生命周期与组件通信.md
    │   ├── 06-初级实战-企业门户首页.md
    │   └── 阶段自查-初级能力清单.md
    ├── part2-intermediate/
    │   ├── 07-路由系统-ReactRouter全解.md
    │   ├── 08-状态管理-ReduxToolkit与Zustand.md
    │   ├── 09-服务端交互与TanStackQuery.md
    │   ├── 10-表单处理与校验.md
    │   ├── 11-登录鉴权与权限控制.md
    │   ├── 12-中级实战-轻订单管理系统.md
    │   └── 阶段自查-中级能力清单.md
    ├── part3-advanced/                  # 13–18 与 vue-edition 同名
    ├── part4-senior/                    # 19–24 与 vue-edition 同名
    └── appendix/
        ├── 附录B-面试题精选.md
        └── 附录C-学习路线图与资源.md

文件命名规则:全小写目录名(part1-junior 等);文件名 两位章节号-短标题.md;两版编号必须一致;文件名不得含空格;编码 UTF-8(无 BOM)。

3. 章节总表与每章内容要点

「要点」是该章必须覆盖的最低知识面。作者可自行加深,不得缺项。

第一部分 · 初级阶段(基础语法与组件化开发)

# Vue 版标题 React 版标题 必须覆盖的要点
01 走进Vue3-框架概览与环境搭建 走进React-框架概览与环境搭建 为什么需要框架;SPA/MPA 与声明式 UI 心智模型;Node+pnpm+Vite 环境搭建与项目结构逐项解读;dev server 与热更新;第一个组件;官方调试工具(Vue Devtools / React Devtools);企业场景:接手「轻订单」仓库的第一天
02 模板语法与数据绑定 JSX与渲染模型 插值与表达式;v-bind/v-on/v-model/v-if/v-show/v-for(对应:JSX 表达式、className、事件绑定、受控输入、&&与三元、map+key);key 的作用与 selection/虚拟 DOM diff 直觉;样式与 class 绑定;企业场景:渲染订单状态标签列表与筛选栏
03 组件化开发基础 组件化开发基础 组件定义与注册;props 单向数据流与类型约束;子→父通信(emit 事件 / 回调 props);插槽 slot 与作用域插槽(对应 children、组合、render 提及);组件拆分原则(单一职责、复用 vs 组合);企业场景:OrderCard、StatusTag、SearchBar
04 响应式系统核心 Hooks核心 ref/reactive 选择与解包规则、响应式丢失(解构)陷阱;computed 缓存语义;watch 与 watchEffect 的差异与清理(对应:useState 不可变更新、useEffect 依赖数组与清理、useMemo/useCallback、闭包过期陷阱、自定义 Hook 入门);企业场景:订单筛选联动(关键词+状态+分页驱动请求参数)
05 生命周期与组件通信 副作用生命周期与组件通信 生命周期全景与时序;副作用清理(定时器/事件监听);父子跨层通信:provide/inject(对应 Context);v-model 组件封装(对应受控/非受控组件);动态组件与递归组件(对应组合模式);企业场景:可复用 Table+Pagination、确认弹窗
06 初级实战-企业门户首页 初级实战-企业门户首页 组件树设计先行;导航、Banner、特性区、FAQ 手风琴、页脚、暗色主题切换;props 接口设计评审清单;项目目录组织;验收标准;文末给出一页「初级阶段能力自查清单」导读并链接阶段自查文件

第二部分 · 中级阶段(状态管理、路由与服务端交互)

# Vue 版标题 React 版标题 必须覆盖的要点
07 路由系统-VueRouter全解 路由系统-ReactRouter全解 路由表与懒加载;动态/嵌套路由;params/query/命名路由;编程式导航;导航守卫(对应 loader/ProtectedRoute);meta 权限标记;滚动行为与路由过渡;中后台 Layout 布局模式;企业场景:权限路由+面包屑
08 状态管理-Pinia实战 状态管理-ReduxToolkit与Zustand 何时需要全局状态(本地态/服务端态/全局 UI 态三分法);defineStore 两种写法、getters/actions、插件持久化、store 互相调用(对应:slice/store/thunk/selector、不可变更新、RTK Query 提及;Zustand 轻量对比);Devtools 调试;企业场景:登录用户态、字典缓存、订单暂存区
09 服务端交互与接口层设计 服务端交互与TanStackQuery axios 实例封装、请求/响应拦截器(token 注入、错误归一化、401 刷新)、取消与防抖、超时与重试;API 模块化分层(api/ 目录);环境变量管理 base URL;React 版必须含 TanStack Query v5(缓存、失效、乐观更新);Vue 版给出 composable 封装 useOrderList 示例;企业场景:订单列表分页筛选防抖与竞态处理
10 表单处理与校验 表单处理与校验 受控与非受控;校验规则与错误呈现;动态表单(增减明细行);表单联动与依赖校验;提交态与防重复提交;Element Plus Form(对应 React Hook Form + Zod schema 校验);企业场景:新建订单表单(客户、明细行、金额汇总联动校验)
11 登录鉴权与权限控制 登录鉴权与权限控制 完整登录流程(账密+图形验证码);token 存储的 XSS 权衡(localStorage vs cookie);路由守卫白名单;动态菜单;按钮级权限(自定义指令 v-permission / 权限组件 <Auth>);无感刷新 token 的请求队列;登出清理;企业场景:三级权限模型(菜单/页面/按钮)
12 中级实战-轻订单管理系统 中级实战-轻订单管理系统 整合 07–11:登录→动态菜单→订单 CRUD→权限;目录结构全览;里程碑 M1–M4 拆解;与附录A需求对应;验收清单与自测用例;文末链接中级自查清单

第三部分 · 高级阶段(性能优化、测试与工程化)

# Vue 版标题 React 版标题 必须覆盖的要点
13 渲染性能优化 渲染性能优化 渲染机制原理(响应式依赖收集+patch / Fiber 与重渲染触发条件);定位工具(Devtools、Profiler/性能面板);v-memo、shallowRef、组件缓存 keep-alive(对应 React.memo、useMemo、状态下放/上提、key 重置);虚拟滚动(自研简化版 + vue-virtual-scroller / TanStack Virtual);大表格优化;企业场景:万级订单列表卡顿治理案例
14 加载性能优化 加载性能优化 路由级与组件级代码分割;图片优化(懒加载、WebP/AVIF、响应式尺寸、CDN);preload/prefetch/dns-prefetch;核心指标 LCP/FCP/CLS/INP 与测量;Lighthouse 与性能预算;骨架屏;HTTP 缓存与压缩(gzip/brotli);企业场景:门户 LCP 4s→1.8s 优化复盘
15 组件测试 组件测试 测试金字塔与投入策略;Vitest 配置;纯函数/组合式函数(Hook)测试;组件挂载与交互测试(Vue Test Utils / Testing Library 的用户中心理念);异步与 mock(vi.mock、msw);快照测试的克制使用;可维护的测试结构;企业场景:金额格式化、StatusTag、OrderCard、订单表单校验测试
16 E2E测试与质量保障 E2E测试与质量保障 Playwright 安装与配置;定位器策略(面向用户可见性);登录态复用 storageState;测试数据准备与隔离(API 造数);trace/截图/视频;失败重试与 flaky 治理;CI 接入;E2E 覆盖策略(冒烟集 vs 全量);企业场景:下单主流程 E2E
17 工程化实践-TypeScript与Vite 工程化实践-TypeScript与Vite TS 基线:tsconfig 关键项、strict 纪律;框架类型:props/emits 类型(Vue)、泛型组件与 PropsWithChildren(React)、API 响应类型与类型守卫;ESLint+Prettier+husky+lint-staged 链路;Vite 插件机制、代理、环境变量分环境;构建分析与产物优化;pnpm workspace 初探;企业场景:轻订单渐进式 TS 迁移方案
18 CICD部署与前端监控 CICD部署与前端监控 GitHub Actions/GitLab CI 流水线(lint→test→build→deploy);多环境(dev/test/pre/prod)与配置注入;Docker+Nginx 静态部署与 SPA 回退;灰度发布与回滚;前端监控:Sentry 错误监控接入、 sourcemap 还原、性能埋点、白屏检测、告警策略;企业场景:上线流水线 + 生产告警响应 SOP

第四部分 · 资深阶段(架构设计、团队协作与技术选型)

# Vue 版标题 React 版标题 必须覆盖的要点
19 大型项目架构设计 大型项目架构设计 架构设计方法论:业务复杂度评估、模块划分(功能切片 vs 分层)、依赖规则(app/pages(views)/features/widgets/entities/shared 分层参考);路由/状态/请求层的组织约定;应用初始化编排;插件化与横切关注点;Monorepo 多包策略;ADR 决策记录;企业场景:轻订单从单体到多模块演进
20 设计系统与组件库建设 设计系统与组件库建设 设计令牌(颜色/间距/字号/暗色主题);组件 API 设计原则(受控优先、组合优于配置、命名词法);主题化与样式方案取舍;Storybook 文档站与视觉回归;无障碍(a11y)基础;包版本管理(changesets)、npm 私服发布;企业场景:内部 qing-ui 组件库 0→1 路线
21 微前端与跨团队协作 微前端与跨团队协作 微前端适用场景与常见误区(先问要不要拆);方案对比(qiankun / micro-app / wujie / Module Federation);JS/CSS 隔离原理与坑;应用间通信与公共依赖治理;路由与部署整合;存量系统渐进迁移策略;企业场景:轻订单平台接入第三方营销子系统
22 团队协作规范 团队协作规范 分支模型(trunk-based vs Git Flow 的取舍);Conventional Commits 与 changesets;PR/MR 模板与 Review 清单;Code Review 文化(对事不对人、SLA、常见坏味道);文档沉淀(ADR、wiki、onboarding 手册);与设计/后端/测试协作契约(设计交付物、OpenAPI、Mock 平台);企业场景:输出一份《前端团队协作手册》骨架
23 技术选型决策 技术选型决策 选型评估框架(业务匹配度、生态成熟度、团队现状、招聘、成本、风险、退出成本、时间窗);Vue vs React 公平深度对比(心智模型/生态/ TS 支持/招聘/长期维护);状态库、组件库、构建工具、请求库选型案例(附对比表模板);自研 vs 开源决策;技术债务管理与偿还策略;渐进式迁移(Strangler 模式);企业场景:新业务线选型报告(可套用模板)
24 资深实战-技术方案与架构评审 资深实战-技术方案与架构评审 技术方案文档写作法(背景/目标/非目标/方案对比/详细设计/灰度与回滚/里程碑);架构评审流程与 checklist;完整案例:轻订单 v2 演进(CSR→CSR+局部 SSR/岛屿、权限中台化重构)方案全文示例;向管理层沟通与影响力;季度技术规划;文末链接资深自查清单并给全书毕业项目建议

阶段自查清单(4 个文件/版)

  • 命名固定:阶段自查-初级能力清单.md 等,两版同名。
  • 结构:① 阶段画像(这个阶段的工程师在企业里能做什么)② 能力清单 12–16 条,按「能独立完成 / 能讲清原理 / 能落地规范」三组,用 - [ ] 复选框 ③ 每条注明「未达标 → 回看第 X 章 §Y」④ 一份 30 分钟自测小考(5–8 题,附答案折叠块 <details>)⑤ 升级下一阶段的判断标准。

附录

  • 附录B-面试题精选.md:对应四阶段各 6–8 题,含考察点、参考答案要点(<details> 折叠)、两版差异化题(框架原理题必须针对本框架)。
  • 附录C-学习路线图与资源.md:ASCII 学习路线图;官方文档与权威资料清单(只列稳定、长期维护的官方资源);本教程与路线图的章节映射表。

4. 单章写作模板(强制)

# 第 XX 章 · 章节标题

> 📍 **阶段**:初级阶段(第 1–6 章)|⏱ 预计学时:X 小时|🔗 前置:第 X 章
>
> [⬅ 上一章](相对链接) | [📖 本版目录](../00-导读.md) | [➡ 下一章](相对链接)
>
> 💡 **对照学习**:本页是 *Vue 版*第 XX 章。React 版对应章节为 [第 XX 章](../../react-edition/partX-xxx/XX-xxx.md),概念映射:Vue 的「A」≈ React 的「B」。

## 🎯 学习目标

学完本章,你将能够:
1. (可验证的行为动词:说出/搭建/封装/排查/设计……)
2. …(5–8 条)

## 📖 核心知识点

### 4.1 小节标题
(概念 → 为什么 → 怎么做 → 代码 → 图示/表格)

## 🛠 渐进式实战练习

### 练习 1 · 基础(10–20 min)
### 练习 2 · 进阶(30–60 min)
### 练习 3 · 挑战(选做,60+ min)

(每题给「任务描述 + 提示 + 验收标准」,关键处给参考答案 <details> 折叠)

## ⚠️ 常见易错点

(❌ 错误代码 + 错误现象/原因 → ✅ 正确写法;每章 ≥4 个)

## ✅ 最佳实践

(企业级落地建议,编号列表,每条一两句「为什么」)

## 📌 本章小结

(3–5 条要点 + 下一章预告一两句)

硬性要求:

  • 每章 450–700 行(Markdown 行数);代码块总行数占比 ≥ 35%。
  • 六大板块标题一字不差(便于全书检索):## 🎯 学习目标、## 📖 核心知识点、## 🛠 渐进式实战练习、## ⚠️ 常见易错点、## ✅ 最佳实践、## 📌 本章小结。
  • 代码围栏语言标注:Vue 用 vue(或 ts/js/bash);React 用 tsx/jsx(或 ts/bash)。代码必须语法正确、可直接运行(配合本章语境),命名与业务词贴合「轻订单」。
  • 导航链接:章与章之间用相对路径(06-xxx.md、../part2-intermediate/07-xxx.md);引用根附录用 ../../附录A-贯穿项目「轻订单」需求说明书.md。
  • 章内小节编号 ## 4.1 形式只在「核心知识点」内使用(编号 = 章号.小节号)。

5. 技术栈基线(两版对照)

维度 Vue 版 React 版
框架 Vue 3.5+(Composition API + <script setup>,不教 Options API,仅提及) React 19(仅函数组件 + Hooks,不教 class 组件,仅提及)
构建 Vite 6+ Vite 6+
路由 Vue Router 4 React Router 7(数据模式提及,主讲组件式 <Routes>,两者 API 兼容处说明)
状态 Pinia 3 Redux Toolkit 2 为主 + Zustand 5 对比
服务端 Axios 封装 + 自研 composable(提及 TanStack Query Vue 适配) Axios 封装 + TanStack Query v5
UI 库 Element Plus Ant Design 5
表单 Element Plus Form(提及 VeeValidate) React Hook Form 7 + Zod
测试 Vitest + Vue Test Utils + Playwright Vitest + Testing Library + Playwright
语言 第一、二部分 JavaScript(可含类型注释),第三部分起全面 TypeScript 同左(.jsx → .tsx)
包管理 pnpm pnpm

安全红线(全教程适用):任何代码示例不得出现真实凭据、token、密钥字面量;一律使用环境变量(import.meta.env.VITE_* / process.env.*)或 <your-token> 类占位符。示例中的测试账号一律 demo@qingorder.com 这类虚构值。

写作口径:不贬低另一框架;对比时给事实与适用条件;涉及「两版差异」处使用对照表而非散文。凡引用官方行为(API 签名、默认值),以官方文档为准,不杜撰 API;不确定的 API 细节改为讲原理+给官方链接。

6. 验收标准(章节作者自查)

  1. 文件路径、文件名与第 2 节完全一致。
  2. 六大板块齐全、标题一字不差;学习目标 5–8 条且可验证。
  3. 每章含 ≥3 个渐进式练习(基础/进阶/挑战),每个有验收标准。
  4. 易错点 ≥4 组,均有 ❌/✅ 对照代码。
  5. 行数 450–700,代码占比 ≥35%,围栏语言已标注。
  6. 导航块、对照学习框存在且链接可达(相对路径正确)。
  7. 无真实凭据字面量。
  8. 实战任务与《附录A》业务一致(订单/客户/商品/报表领域词)。

7. 维护指引

  • 新增章节:先在本文件登记(编号、两版标题、要点、目录位置),再动笔,保持两版同步。
  • 版本升级(如 React 20/Vue 4):只改受影响章节并在本文件记录变更日志。
  • 变更日志:
    • 2026-09-29 初版定稿(24 章 × 2 版 + 4 清单 × 2 版 + 附录 B/C × 2 版 + 共用附录 A)。

📄 附录 A · 贯穿项目「轻订单」需求说明书 附录A-贯穿项目「轻订单」需求说明书.md

本附录是 Vue 版与 React 版教程共用的业务需求基准。所有阶段的实战练习都围绕本项目展开,两版教程的练习任务保持一致,因此对照学习时可以精确比较「同一个需求,两种框架怎么落地」。

「轻订单」是虚构公司「青云科技」的内部 SaaS 产品,所有名称、数据、接口均为教学虚构。


1. 产品背景

青云科技是一家做智能硬件的 B2B 公司,销售渠道包括直销、代理商与电商代运营。随着订单量增长,原来靠 Excel + 微信群接单的方式出了三类高频事故:

  1. 漏单:微信里的订单没人跟进;
  2. 改单无痕:折扣随便改,财务对不上账;
  3. 权限混乱:实习生能改全公司的价格。

于是立项「轻订单」:一个面向内部员工的 B2B 订单管理系统,分两期交付:

  • 一期 · 官网门户:面向潜在客户的品牌官网(首页、产品介绍、联系方式),用于获客;
  • 二期 · 管理后台:面向内部员工的中后台系统,覆盖订单全生命周期。

2. 角色与权限模型

角色 代号 核心权限
超级管理员 super_admin 全部功能 + 成员管理 + 系统设置
运营 operator 订单查看/创建/编辑、客户管理、商品管理
销售 sales 仅本人名下订单与客户的查看/创建;可提交折扣申请
财务 finance 订单查看、订单审核(折扣>10% 需财务审批)、报表导出

权限按三级控制:

  1. 菜单级:不同角色登录后侧边栏菜单不同;
  2. 页面级:直接输入 URL 访问无权限页面时拦截到 403 页;
  3. 按钮级:例如「删除订单」按钮仅 super_admin 与 operator 可见;「折扣审批」按钮仅 finance 可见。

3. 功能模块清单

3.1 认证与个人中心

  • 账密登录(含图形验证码,输错 5 次锁定 10 分钟);
  • JWT 鉴权:accessToken(2 小时)+ refreshToken(7 天),支持无感刷新;
  • 个人中心:修改密码、查看本人操作日志。

3.2 工作台(Dashboard)

  • 今日概览卡片:今日新增订单数、待审核数、本月 GMV、异常订单数;
  • 近 30 天订单量与 GMV 趋势折线图;
  • 我的待办:待我审批 / 待我跟进的订单列表(前 10 条)。

3.3 订单管理(核心模块)

订单状态机:

草稿 draft → 待审核 pending_review → 已审核 approved → 已发货 shipped → 已完成 completed
                    │
                    └─审核拒绝 rejected → (可编辑后重新提交)
任何非终态 → 已取消 cancelled(只能取消人本人或超管操作,需填原因)
  • 列表页:分页(20/页)、筛选(关键词、状态、日期区间、销售归属)、排序(创建时间/金额);
  • 详情页:基本信息、明细行、操作日志时间线、状态流转按钮;
  • 新建/编辑订单:
    • 选择客户(搜索下拉)→ 添加明细行(商品搜索、数量、单价、行折扣);
    • 明细金额 = 数量 × 单价 × 行折扣,订单总额自动汇总;
    • 整单折扣 >10% 时提交后进入 pending_review,需财务审批;
    • 表单校验:数量 1–9999、行折扣 0–100%、必填项即时校验。
  • 批量操作:批量导出选中订单为 CSV;批量指派销售(仅超管/运营)。

3.4 客户管理

  • 客户列表(名称/等级/所属销售筛选);
  • 客户详情:基本信息、历史订单、应收账款汇总;
  • 新建客户:统一社会信用代码格式校验、去重校验(后端返回 409 时前端提示)。

3.5 商品目录

  • 商品列表(SKU、名称、分类、单价、上下架状态);
  • 上下架切换;商品仅超管/运营可编辑。

3.6 报表中心

  • 销售报表:按销售/按月/按商品维度聚合;
  • 导出 Excel(前端请求导出接口,展示进度条,完成后触发下载)。

3.7 系统设置(仅超管)

  • 成员管理:增删改查、角色分配、停用;
  • 操作日志查询。

4. 非功能需求(性能与质量目标)

指标 目标值
门户首页 LCP ≤ 2.0s(4G 网络、中端安卓机)
后台首屏可交互 ≤ 3.0s
订单列表万级数据滚动 无可感知卡顿(掉帧 < 5%)
JS 产物单包 首屏 gzip ≤ 300KB
单元测试覆盖率(核心模块:订单金额计算、权限判断、工具函数) ≥ 80%
E2E 冒烟集 登录 → 下单 → 审批 → 导出,全链路通过
浏览器兼容 Chrome/Edge 最近两个大版本

5. 接口约定(教学用约定,前后端以此对齐)

  • Base URL:https://api.qingorder.example.com/api/v1(开发期用 Vite 代理指向本地 mock);
  • 认证:Authorization: Bearer <accessToken>;
  • 统一响应体:
{
  "code": 0,
  "message": "ok",
  "data": { }
}
  • code 语义:0 成功;4xx01 参数错误;40101 未登录;40102 token 过期;40301 无权限;40901 资源冲突;5xxxx 服务端错误;
  • 分页响应:data: { list: [...], total: 0, page: 1, pageSize: 20 };
  • 错误处理约定:业务错误 toast 展示 message;40102 触发无感刷新;40301 跳转 403 页。

核心实体(TypeScript 口径,两版通用):

type OrderStatus =
  | 'draft' | 'pending_review' | 'approved'
  | 'shipped' | 'completed' | 'rejected' | 'cancelled';

interface OrderItem {
  skuId: string;
  skuName: string;
  quantity: number;        // 1–9999
  unitPrice: number;       // 单位:分,避免浮点误差
  discountPercent: number; // 0–100,100 表示无折扣
}

interface Order {
  id: string;
  orderNo: string;          // 规则:QO + yyyyMMdd + 6 位序号
  customerId: string;
  customerName: string;
  ownerName: string;        // 归属销售
  status: OrderStatus;
  items: OrderItem[];
  wholeDiscountPercent: number; // 整单折扣,≤90 才免审批
  totalAmount: number;          // 单位:分
  createdAt: string;            // ISO 8601
}

💡 金额单位用「分」是真实企业惯例,教程会在易错点中专门讲浮点求和陷阱。

6. 各阶段里程碑映射

阶段 教程章节 在「轻订单」上做什么
初级 第 01–06 章 搭建开发环境;用组件化方式实现官网门户首页(导航/Banner/特性/FAQ/页脚/主题切换)
中级 第 07–12 章 实现中后台:登录鉴权、动态菜单、订单列表/详情/新建、权限控制,即附录 §3.1–3.3 的可用版本
高级 第 13–18 章 对已有系统做治理:万级列表渲染优化、首屏提速、补齐单测与 E2E、接入 TS 与规范链路、搭 CI/CD 与监控
资深 第 19–24 章 架构演进:模块化拆分、抽离内部组件库 qing-ui、微前端接入第三方子系统、完成 v2 技术方案与评审

7. 教学口径说明

  • 后端与数据库不是本教程主角:练习阶段统一使用 Mock 数据(教程内给出 mock 方案),接口约定以上文为准,保证两版教程、前后章节的数据形状完全一致;
  • 所有公司、人名、账号均为虚构;示例邮箱统一使用 *@qingorder.example.com;
  • 教程代码以「能被同事接手」为质量底线:命名可读、关键逻辑有注释、魔法数字收敛为常量。

📄 React 版 · 导读 00-导读.md

欢迎来到《Web 前端开发进阶教程》React 版。本版基于 React 19(仅函数组件 + Hooks),带你从初级程序员一路走到企业级资深开发。如果你同时也在学 Vue,本教程有章节一一对应的 Vue 版,可以逐章对照(见「双栈对照学习」)。


1. 技术栈基线

维度 选型
框架 React 19(只教函数组件 + Hooks;class 组件仅在对比时提及)
构建 Vite 6+
路由 React Router 7(主讲组件式 <Routes>,数据模式在对比中说明)
状态管理 Redux Toolkit 2 为主,Zustand 5 作为轻量方案对比
UI 组件库 Ant Design 5
服务端交互 Axios 封装 + TanStack Query v5
表单 React Hook Form 7 + Zod
语言 第一、二部分 JavaScript(可加类型注释),第三部分起全面 TypeScript
测试 Vitest + Testing Library + Playwright
包管理 pnpm

版本号会持续演进,具体 API 以官方文档为准;教程讲解的是长期稳定的核心概念与工程方法。

2. 章节地图

部分 章节 主题
第一部分 · 初级 01 · 02 · 03 · 04 · 05 · 06 基础语法与组件化开发
初级能力自查清单
第二部分 · 中级 07 路由 · 08 状态 · 09 服务端 · 10 表单 · 11 鉴权 · 12 实战 状态管理、路由与服务端交互
中级能力自查清单
第三部分 · 高级 13 渲染性能 · 14 加载性能 · 15 组件测试 · 16 E2E · 17 工程化 · 18 CI/CD 性能优化、测试与工程化
高级能力自查清单
第四部分 · 资深 19 架构 · 20 组件库 · 21 微前端 · 22 协作 · 23 选型 · 24 实战 架构设计、团队协作与技术选型
资深能力自查清单
附录 附录A 轻订单需求说明书 · 附录B 面试题 · 附录C 路线图与资源

3. 每章的固定结构

  1. 🎯 学习目标:5–8 条可验证的能力描述;
  2. 📖 核心知识点:概念 → 为什么 → 怎么做 → 可运行代码;
  3. 🛠 渐进式实战练习:基础 / 进阶 / 挑战三档,均附验收标准;
  4. ⚠️ 常见易错点:❌/✅ 对照代码,全部来自企业代码评审高频问题;
  5. ✅ 最佳实践:可直接落进团队规范的条目;
  6. 📌 本章小结:要点回顾 + 下一章预告。

4. 贯穿项目:「轻订单」

全书所有练习围绕虚构企业级 SaaS「轻订单」展开(B2B 订单管理系统)。四个阶段在同一业务上递进:门户首页 → 中后台系统 → 性能与质量治理 → 架构演进。开工前请先通读 附录A(约 15 分钟),里面有你写练习时要反复使用的角色、状态机、接口约定与 TypeScript 实体。

5. 双栈对照学习(可选)

本教程与 Vue 版章节完全对应(同编号、同练习、同自查清单)。每章开头有「💡 对照学习」框,标注本章概念与 Vue 的对应关系,例如:

React Vue
useState ref / reactive
useMemo computed
useEffect watch / watchEffect
Context provide / inject
children / 组合 <slot>
React Router loader / 路由包裹 Vue Router 导航守卫
Redux Toolkit slice / Zustand store Pinia store

6. 环境准备清单(开工前完成)

  • [ ] Node.js 20 LTS 及以上:node -v
  • [ ] pnpm 9+:npm i -g pnpm && pnpm -v
  • [ ] VS Code + 插件:ESLint、Prettier(可选: React Developer Tools 浏览器扩展)
  • [ ] Chrome + React Developer Tools 扩展
  • [ ] Git 基础(第四部分会讲团队协作规范,但克隆/提交要会)

7. 学习节奏建议

  • 在职自学:每周 6–8 小时,初级约 3 周、中级约 5 周、高级约 6 周、资深约 4 周(含练习)。
  • 突击面试:按阶段读「学习目标 + 易错点 + 自查清单」,配合附录 B 面试题定向突破。
  • 团队内训:直接以各阶段「渐进式实战练习」为作业,自查清单作为结业考核。

准备好了?打开 第 01 章 · 走进 React。

📄 第 01 章 · 走进 React —— 框架概览与环境搭建 01-走进React-框架概览与环境搭建.md

📍 阶段:初级阶段(第 1–6 章)|⏱ 预计学时:3 小时|🔗 前置:导读

⬅ 上一章:导读 | 📖 本版目录 | ➡ 下一章:JSX 与渲染模型

💡 对照学习:本页是 React 版第 01 章,对应 Vue 版第 01 章 · 走进 Vue3。概念映射:createRoot().render(<App />) ≈ createApp(App).mount('#app')、App.jsx ≈ App.vue、React Developer Tools ≈ Vue Devtools。两版的环境搭建与目录解读思路一致,差异集中在「入口文件写法」与「组件文件形态」上。

🎯 学习目标

学完本章,你将能够:

  1. 用「轻订单」项目里的三类真实事故,说出框架三件武器(声明式 UI、组件化、状态驱动更新)分别解决了什么问题;
  2. 区分 MPA 与 SPA 的加载与交互模型,说清 React 应用从 index.html 到可见页面的完整渲染链路;
  3. 使用 Vite 脚手架创建 React 项目,用 pnpm 安装依赖并启动开发服务器;
  4. 逐项解读 Vite React 模板的目录结构,并向同事解释入口文件 main.jsx 三行核心代码的职责;
  5. 修改组件内容并验证热更新(HMR)生效,说出 HMR 相比整页刷新对开发效率的意义;
  6. 安装并使用 React Developer Tools 浏览器扩展,在组件树中定位目标组件并查看其 props;
  7. 完成「接手『轻订单』仓库的第一天」企业场景任务,产出一份可复用的接手清单页面。

📖 核心知识点

1.1 为什么需要框架:从三起「轻订单」事故说起

先读一遍贯穿项目需求:附录A。青云科技立项「轻订单」的起因是三类事故:漏单、改单无痕、权限混乱。把它们翻译到前端视角,就是三类高频工程问题:

业务事故 前端视角的根因 框架给出的武器
漏单(消息刷过去就没了) 数据与视图手工同步,漏改一处就出 bug 声明式 UI:只描述「数据长什么样」,同步交给框架
改单无痕(谁改的说不清) 页面是「意大利面」,改一处牵全身 组件化:拆成职责单一的积木,边界清晰可审计
权限混乱(人人都能改) 逻辑重复散落在各页面,改 A 忘 B 状态驱动更新:数据只有一份来源,权限判断收敛

没有框架时,你用「命令式」代码一步步指挥浏览器,每处状态变化都要手工更新 DOM:

// 命令式(jQuery 时代的写法):自己动手,一步一步改 DOM
const listEl = document.querySelector('#order-list');

fetch('/api/orders?status=pending_review')
  .then((res) => res.json())
  .then((data) => {
    listEl.innerHTML = ''; // 第 1 步:清空(忘了这步就会重复叠加)
    data.list.forEach((order) => {
      const li = document.createElement('li'); // 第 2 步:手动造节点
      li.className = 'tag tag--pending';
      li.textContent = `${order.orderNo} 待审核`;
      listEl.appendChild(li); // 第 3 步:手动插入(位置插错就是事故)
    });
  });

React 的思路是反过来:你只「声明」界面在当前数据下应该长什么样,数据一变,框架负责算出最小 DOM 改动并执行:

// 声明式(React):只描述「待审核订单列表长什么样」
function PendingOrderList({ orders }) {
  return (
    <ul id="order-list">
      {orders.map((order) => (
        <li key={order.id} className="tag tag--pending">
          {order.orderNo} 待审核
        </li>
      ))}
    </ul>
  );
}
// orders 变化时,React 自动算出最小 DOM 改动,无需手工同步

React 用「虚拟 DOM + 协调(reconciliation)」来找出两次渲染之间的差异,直觉解释见第 02 章 §2.7;原理细节以官方文档 react.dev/learn 为准。

1.2 SPA、MPA 与 React 的渲染心智模型

维度 MPA(多页应用) SPA(单页应用)
页面数量 每个 URL 一张服务端渲染的 HTML 只有一张几乎空白的 HTML(壳)
切换页面 浏览器整页跳转、重新请求 JS 在客户端切换视图,不整页刷新
首屏 快(HTML 直接可读) 稍慢(要先下载并执行 JS)
交互体验 每次跳转都有白屏 流畅,接近原生应用
代表场景 官网、资讯站 中后台系统(如轻订单二期)

React 应用默认是 SPA。它从「一张 HTML」到「完整页面」的链路如下:

浏览器请求 index.html
      │  (SPA 只有一张「壳」HTML)
      ▼
加载 /src/main.jsx ──► createRoot(#root).render(<App />)
      ▼
React 渲染组件树:<App /> → <NavBar /> → <Banner /> → ...
      ▼
此后所有交互都在 JS 里完成,页面不再整页刷新

「服务端渲染(SSR)/ 静态生成」能让 SPA 的首屏更快,属于资深阶段的内容,本章只需知道 SPA ≠ 只能客户端渲染。

1.3 环境搭建:Node + pnpm + Vite

先确认工具链版本(Node 20 LTS 起,包管理用 pnpm,与全书技术栈基线一致):

# 1. 确认 Node 版本(20 LTS 及以上)
node -v

# 2. 安装 pnpm(本书统一使用 pnpm 管理依赖)
npm i -g pnpm
pnpm -v

# 3. 用 Vite 官方脚手架创建 React 项目(react 模板 = JavaScript + JSX)
npm create vite@latest qingorder-portal -- --template react

# 4. 进入项目,安装依赖并启动
cd qingorder-portal
pnpm install
pnpm dev

终端输出类似:

  VITE v6.x  ready in 312 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose

打开 http://localhost:5173,看到 Vite + React 的默认页面,说明环境就绪。仓库会提交一个 pnpm-lock.yaml,它锁定依赖版本,保证团队每个人装到的依赖完全一致。

1.4 项目结构逐项解读:接手「轻订单」仓库的第一天

qingorder-portal/
├── index.html              # 唯一的 HTML:SPA 的「壳」,含 #root 挂载点
├── package.json            # 依赖清单与脚本(dev / build / lint / preview)
├── vite.config.js          # Vite 配置:插件、端口、开发代理
├── eslint.config.js        # ESLint 9 扁平配置(含 react / react-hooks 规则)
├── public/                 # 原样拷贝的静态资源(favicon 等,不参与打包)
│   └── vite.svg
└── src/
    ├── main.jsx            # JS 入口:createRoot + 挂载 <App />
    ├── App.jsx             # 根组件:整个页面从这里「长」出来
    ├── App.css             # 根组件的局部样式
    ├── index.css           # 全局样式(重置、字体、CSS 变量)
    └── assets/             # 参与打包的静态资源(图片、字体等)
        └── react.svg

逐个打开关键文件。index.html——整张应用只有这一张 HTML:

<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>轻订单 · 青云科技</title>
  </head>
  <body>
    <!-- SPA 的挂载点:React 会把整棵组件树渲染进这个 div -->
    <div id="root"></div>
    <!-- type="module" 让浏览器以 ES Module 方式加载入口,Vite 在此接管 -->
    <script type="module" src="/src/main.jsx"></script>
  </body>
</html>

src/main.jsx——JS 世界的入口,三行核心代码各司其职:

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.jsx';

// 1. 找到挂载点,创建 React 根
createRoot(document.getElementById('root')).render(
  // 2. StrictMode 只在开发环境生效:主动暴露不安全的写法(第 05 章详解)
  <StrictMode>
    {/* 3. 渲染根组件:整个应用都是 <App /> 的子树 */}
    <App />
  </StrictMode>,
);

vite.config.js——构建工具的配置,本章重点认识开发代理:

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

// https://vite.dev/config/
export default defineConfig({
  plugins: [react()],
  server: {
    port: 5173,
    // 开发期把 /api 代理到本地 mock 服务,避免浏览器跨域(附录A 接口约定)
    proxy: {
      '/api': {
        target: 'http://localhost:3000', // 本地 mock 服务地址,按环境修改
        changeOrigin: true,
      },
    },
  },
});

1.5 Dev Server 与热更新(HMR)

package.json 中的脚本是日常开发的入口:

{
  "name": "qingorder-portal",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "lint": "eslint .",
    "preview": "vite preview"
  }
}
  • pnpm dev:启动开发服务器(默认 5173 端口),按需编译、秒级启动;
  • pnpm build:产出生产构建(默认在 dist/);
  • pnpm preview:本地预览构建产物,验证「上线后」的样子。

现在做一个小实验:打开 App.jsx,把标题文字改掉并保存——页面不刷新就更新了。这就是 HMR(Hot Module Replacement,模块热替换):Vite 只把被修改的那个模块推给浏览器替换,整页不重载。React 侧配合 Fast Refresh,还能在替换组件的同时保留组件当前的 state——你输入了一半的表单不会因为改了一行样式而丢失。

整页刷新 = 回到初始状态,所有现场丢失;HMR = 只换「零件」,现场保留。在中大型项目里,这个差距会按「每次保存」放大。

1.6 第一个组件:把首页骨架跑起来

把模板自带的演示内容替换成「轻订单」门户的最小骨架。先认识组件的解剖图:

// src/App.jsx
const PENDING_ORDERS = [
  { id: '1001', orderNo: 'QO20260929-000001', customerName: '星辰商贸' },
  { id: '1002', orderNo: 'QO20260929-000002', customerName: '恒远智造' },
];

// 一个 React 组件就是一个「返回 JSX 的函数」:
//   名字必须大写开头(区别于普通 HTML 标签)
//   入参是 props(本章先不深入,第 03 章专门讲)
function App() {
  return (
    <header className="page-header">
      <h1>轻订单 · 青云科技</h1>
      <p>让每一张 B2B 订单都被看见、被追踪、被审计。</p>
      <ul className="pending-list">
        {PENDING_ORDERS.map((order) => (
          <li key={order.id}>
            {order.orderNo} · {order.customerName}
          </li>
        ))}
      </ul>
    </header>
  );
}

export default App;

保存后浏览器自动更新。这里的 {...} 花括号语法、map 渲染列表的细节就是第 02 章的主角,本章先「照着写、跑起来」。

1.7 官方调试工具:React Developer Tools

  1. 在 Chrome / Edge 应用商店安装扩展 React Developer Tools(React 官方出品);
  2. 打开你的页面,按 F12,面板里会多出 Components 和 Profiler 两个标签页。

Components 面板能力(企业里天天用):

  • 以树状结构查看组件层级,等价于把「设计稿 → 组件树」的关系可视化;
  • 点击任意组件,右侧可查看它的 props、hooks 状态、所属文件(点击可跳转源码);
  • 顶部搜索框可直接按组件名过滤,接手陌生仓库时先靠它建立全局感。

Profiler 面板用于录制「哪些组件因什么原因重渲染」,是第 13 章性能优化主角,初级阶段知道即可。

企业场景:接手「轻订单」仓库的第一天,按这个顺序走一遍,半天即可建立掌控感:

① 跑通:pnpm install && pnpm dev,确认页面能打开
② 读壳:index.html → main.jsx → App.jsx,画出入口链路
③ 认树:React DevTools 里从 <App /> 往下展开组件树,截图存档
④ 找配置:vite.config.js 的代理指向哪、.env 系列文件里有哪些约定
⑤ 读规范:README、目录约定、ESLint 规则——先遵守,再谈改进

🛠 渐进式实战练习

练习 1 · 基础(15 min):跑通「轻订单」门户骨架

任务描述:从零创建 qingorder-portal 项目,把 App.jsx 的演示内容替换为「门户骨架」(标题 + 一句 slogan + 一个待办订单列表),验证热更新。

提示:

  • 使用 npm create vite@latest qingorder-portal -- --template react;
  • 列表数据可以直接写在组件文件顶部;
  • 每次保存后观察浏览器是否「不刷新就更新」。

验收标准:

  • [ ] pnpm dev 启动无报错,页面显示自定义标题与 slogan;
  • [ ] 列表至少渲染 2 条订单,每条含订单号与客户名;
  • [ ] 故意写错一行保存,观察报错出现在终端与浏览器控制台,再改回来。
参考答案
// src/App.jsx
const PENDING_ORDERS = [
  { id: '1001', orderNo: 'QO20260929-000001', customerName: '星辰商贸' },
  { id: '1002', orderNo: 'QO20260929-000002', customerName: '恒远智造' },
];

function App() {
  return (
    <header className="page-header">
      <h1>轻订单 · 青云科技</h1>
      <p>让每一张 B2B 订单都被看见、被追踪、被审计。</p>
      <ul className="pending-list">
        {PENDING_ORDERS.map((order) => (
          <li key={order.id}>
            {order.orderNo} · {order.customerName}
          </li>
        ))}
      </ul>
    </header>
  );
}

export default App;

练习 2 · 进阶(30 min):环境变量与目录改造

任务描述:企业项目不允许把接口地址写死在代码里。请:① 新建 .env.development,定义应用名与 API 地址;② 在组件里读取环境变量渲染;③ 把「订单列表」抽到 src/data/ 目录,初步实践「配置与视图分离」。

提示:

  • Vite 只暴露 VITE_ 前缀的变量,代码里通过 import.meta.env.VITE_XXX 访问;
  • 环境变量在构建时被静态替换,改完 .env 需要重启 dev server;
  • 详细规则见官方文档:Vite · Env Variables。

验收标准:

  • [ ] .env.development 中定义了 VITE_APP_NAME 与 VITE_API_BASE_URL;
  • [ ] 页面标题来自环境变量,改 .env 并重启后生效;
  • [ ] 订单数据移入 src/data/,App.jsx 通过 import 使用;
  • [ ] 确认 .env 已被 Git 跟踪(团队共享),而含密钥的 .env.local 永远不提交。
参考答案
# .env.development —— 变量必须以 VITE_ 开头才会暴露给客户端代码
VITE_APP_NAME=轻订单
VITE_API_BASE_URL=http://localhost:3000/api/v1
// src/data/orders.js —— 数据配置与视图分离
export const PENDING_ORDERS = [
  { id: '1001', orderNo: 'QO20260929-000001', customerName: '星辰商贸' },
  { id: '1002', orderNo: 'QO20260929-000002', customerName: '恒远智造' },
];
// src/App.jsx
import { PENDING_ORDERS } from './data/orders';

function App() {
  // import.meta.env 是 Vite 注入的环境对象;?? 提供兜底,避免变量缺失时渲染 undefined
  const appName = import.meta.env.VITE_APP_NAME ?? '轻订单';

  return (
    <header className="page-header">
      <h1>{appName} · 青云科技</h1>
      <ul className="pending-list">
        {PENDING_ORDERS.map((order) => (
          <li key={order.id}>
            {order.orderNo} · {order.customerName}
          </li>
        ))}
      </ul>
    </header>
  );
}

export default App;

练习 3 · 挑战(选做,45 min+):产出《接手仓库第一天》页面

任务描述:把 §1.7 的「第一天清单」做成一个真实的入门页面:应用名与 API 地址读环境变量;任务清单来自 src/data/onboarding.js;页面顶部显示完成进度(如 2/4)。这个页面未来可以放进团队 wiki 的 onboarding 手册。

提示:

  • 进度 = 已完成数 / 总数,用 filter + length 计算;
  • 清单项先用静态只读渲染(勾选交互在第 04 章学完 useState 后回填);
  • 保持 App.jsx 只做拼装,数据与文案都在 data/ 里。

验收标准:

  • [ ] 页面包含:应用名(环境变量)、API 地址(环境变量)、任务清单、进度统计;
  • [ ] 任务清单由 map 渲染,每项有稳定的 key;
  • [ ] 新增一条任务只需改 data/onboarding.js,组件零改动。
参考答案
// src/data/onboarding.js —— 第一天任务清单(配置与视图分离)
export const ONBOARDING_TASKS = [
  { id: 't1', title: '读完附录A《轻订单》需求说明书', done: true },
  { id: 't2', title: '跑通 pnpm dev 并验证热更新', done: true },
  { id: 't3', title: '用 React DevTools 对照组件树与源码', done: false },
  { id: 't4', title: '认领第一个 issue:门户 StatusTag 组件', done: false },
];
// src/App.jsx
import { ONBOARDING_TASKS } from './data/onboarding';

function App() {
  const appName = import.meta.env.VITE_APP_NAME ?? '轻订单';
  const apiBase = import.meta.env.VITE_API_BASE_URL ?? '(未配置)';
  const doneCount = ONBOARDING_TASKS.filter((task) => task.done).length;

  return (
    <main className="welcome">
      <h1>欢迎接手 {appName}</h1>
      <p>API 地址:{apiBase}</p>
      <p>
        进度:{doneCount}/{ONBOARDING_TASKS.length}
      </p>
      <ul>
        {ONBOARDING_TASKS.map((task) => (
          <li key={task.id}>
            <label>
              <input type="checkbox" checked={task.done} readOnly />
              {task.title}
            </label>
          </li>
        ))}
      </ul>
    </main>
  );
}

export default App;

⚠️ 常见易错点

易错点 1:组件名小写开头

// ❌ 组件名小写开头:React 把 <orderCard /> 当成未知 HTML 标签
// 现象:页面空白,控制台警告 “<orderCard /> is acting as an unknown element”
function orderCard() {
  return <div>QO20260929-000001</div>;
}

function App() {
  return <orderCard />;
}
// ✅ 组件名必须大写开头,React 才会把它识别为组件
function OrderCard() {
  return <div>QO20260929-000001</div>;
}

function App() {
  return <OrderCard />;
}

易错点 2:JSX 返回多个并列根元素

// ❌ 相邻的 JSX 根元素:一个表达式只能有一个根
// 现象:编译报错 “Adjacent JSX elements must be wrapped in an enclosing tag”
function Header() {
  return (
    <h1>轻订单</h1>
    <p>让每一张订单都被看见</p>
  );
}
// ✅ 用一个父元素或 Fragment 包起来;Fragment 不会产生多余的 DOM 节点
import { Fragment } from 'react';

function Header() {
  return (
    <Fragment>
      <h1>轻订单</h1>
      <p>让每一张订单都被看见</p>
    </Fragment>
  );
}
// 简写:<h1>…</h1><p>…</p> 外面直接包 <>…</>(不需要 key 时用简写)

易错点 3:照搬 HTML 的 class / for 属性

// ❌ JSX 里写了 HTML 的 class 和 for
// 现象:控制台警告 “Invalid DOM property `class`. Did you mean `className`?”
function SearchBar() {
  return (
    <div>
      <label for="keyword">关键词</label>
      <input class="search-input" id="keyword" />
    </div>
  );
}
// ✅ 用 className / htmlFor(避免与 JS 的 class 关键字冲突)
function SearchBar() {
  return (
    <div>
      <label htmlFor="keyword">关键词</label>
      <input id="keyword" className="search-input" />
    </div>
  );
}

易错点 4:绕过 React 直接操作 DOM

// ❌ 在渲染期间手动改 DOM:下次渲染会被 React 覆盖回去,状态与视图打架
// 现象:改了又被还原,且调试时「看不出是谁改的」
function App() {
  document.getElementById('title').innerText = '被篡改的标题';
  return <h1 id="title">轻订单</h1>;
}
// ✅ 视图永远是状态的函数:想改什么,就改驱动它的数据
function App() {
  const title = '轻订单 · 企业门户';
  return <h1 id="title">{title}</h1>;
}

✅ 最佳实践

  1. 脚手架起手,团队同款:新项目统一用 Vite 官方模板创建,模板自带的 ESLint 与目录结构是团队协作的最大公约数。
  2. 提交锁文件:pnpm-lock.yaml 必须进 Git,保证全组依赖版本一致;Node 版本用 package.json 的 engines 字段声明。
  3. 配置走环境变量:接口地址、应用名等一切随环境变化的值进 .env 系列文件;.env 共享提交,含密钥的 .env.local 加入 .gitignore 永不提交。
  4. 接手仓库先「跑 + 认树」:先跑通 dev server,再用 React DevTools 建立组件树全局感,最后才逐行读源码——顺序错了效率减半。
  5. 一个文件一个组件:组件文件名与组件名一致(OrderCard.jsx 导出 OrderCard),新人检索零成本。
  6. 保持模板整洁:删掉脚手架的演示代码与未用资源(logo、示例样式),避免「演示残留」混入业务代码。

📌 本章小结

  1. 框架的三件武器——声明式 UI、组件化、状态驱动更新——分别对应「漏单、改单无痕、权限混乱」这类真实工程问题的解法。
  2. React 默认是 SPA:一张壳 HTML + createRoot 挂载 + 客户端渲染,之后不再整页刷新。
  3. Vite 模板的关键链路:index.html → main.jsx(createRoot + StrictMode)→ App.jsx;vite.config.js 管插件与开发代理。
  4. HMR 只替换改动模块并保留现场;React DevTools 是接手仓库时建立全局感的第一工具。
  5. 环境变量一律走 .env 系列文件与 VITE_ 前缀,任何真实密钥都不允许出现在代码里。

下一章预告:你已经在页面里见过 {}、map 和 key 了。第 02 章系统拆解 JSX 的全部语法——插值、条件、列表、受控表单——并给出虚拟 DOM diff 的正确直觉。

📄 第 02 章 · JSX 与渲染模型 02-JSX与渲染模型.md

📍 阶段:初级阶段(第 1–6 章)|⏱ 预计学时:3.5 小时|🔗 前置:第 01 章

⬅ 上一章:走进 React | 📖 本版目录 | ➡ 下一章:组件化开发基础

💡 对照学习:本页是 React 版第 02 章,对应 Vue 版第 02 章 · 模板语法与数据绑定。概念映射:Vue 的指令体系 ≈ JSX 的「纯表达式」写法——{{ }} ≈ { }、v-if ≈ 三元 / &&、v-for + :key ≈ map + key、v-show ≈ style.display、v-model ≈ 受控组件(value + onChange)。JSX 没有「指令」,一切皆 JavaScript 表达式。

🎯 学习目标

学完本章,你将能够:

  1. 说出 JSX 中花括号里「只能写表达式、不能写语句」的规则,并解释原因;
  2. 用 className、对象形式的 style 与 htmlFor 正确完成属性与样式绑定;
  3. 用 onClick / onChange / onSubmit 完成事件绑定,说清「传函数引用」与「立即调用」的区别;
  4. 针对不同场景在三元、&&、提前 return 三种条件渲染写法中做出正确选择;
  5. 用 map + key 渲染订单列表,并解释 key 的「身份证」作用与下标作 key 的风险;
  6. 用「受控组件」模式实现 React 版的 v-model,覆盖输入框、下拉框与复选框;
  7. 建立虚拟 DOM diff 的正确直觉,能预测「同类型元素更新」与「不同类型元素替换」时组件状态是否保留;
  8. 独立实现企业场景:订单状态标签列表与筛选栏。

📖 核心知识点

2.1 JSX 与插值:花括号里只能写表达式

JSX 看起来像 HTML,实质是 JavaScript。花括号 {} 里可以放任何表达式(有值的东西),不能放语句(if、for、声明):

const order = { orderNo: 'QO20260929-000001', totalAmount: 128000 }; // 金额单位:分

function OrderLine() {
  return (
    <div className="order-line">
      {/* 花括号里:变量、运算、函数调用都是表达式,可以 */}
      <span className="order-line__no">{order.orderNo}</span>
      <span>金额:¥{(order.totalAmount / 100).toFixed(2)}</span>
      <span>客户:{order.customerName ?? '未填写'}</span>
    </div>
  );
}

复杂逻辑放在 JSX 外面算好,花括号只做「最后的组装」,可读性立刻上一个台阶:

function Amount({ fen }) {
  // 金额展示是高频操作:收敛成一个组件(分 → 元),全站复用
  const yuan = (fen / 100).toLocaleString('zh-CN', { minimumFractionDigits: 2 });
  return <strong className="amount">¥{yuan}</strong>;
}

2.2 属性绑定:className、htmlFor 与 style

JSX 的属性与 HTML 有几处关键差异,对照记住:

const status = 'pending_review';

function Tag() {
  const isActive = status === 'pending_review';
  return (
    <span
      // class → className;动态拼接用模板字符串
      className={`tag ${isActive ? 'tag--active' : ''}`}
      // style 接收对象,属性名用小驼峰(CSS 的 font-size → fontSize)
      // 数值型属性自动补 px(width: 120 → 120px)
      style={{ fontSize: 12, padding: '2px 8px' }}
      // data-* 与 aria-* 保持原样书写
      data-status={status}
      aria-label="订单状态:待审核"
    >
      待审核
    </span>
  );
}

「透传 props」时注意展开顺序——后写的属性生效:

function TextField(props) {
  // 默认值写在前面,调用方的同名属性写在后面即可覆盖
  return <input className="search-input" {...props} />;
}

2.3 事件绑定:onClick 与「传引用还是调用」

React 事件名用小驼峰,处理器是普通函数。最容易踩的坑是「传函数引用」与「调用函数」:

function OrderActions({ orderId }) {
  function cancel() {
    console.log('取消订单', orderId);
  }
  function approve() {
    console.log('通过审核', orderId);
  }
  return (
    <div className="order-actions">
      {/* ❌ 写成 onClick={cancel()} 会在渲染时立刻执行一次,点击反而无效 */}
      {/* ✅ 传「函数引用」:把执行时机交给点击事件 */}
      <button onClick={cancel}>取消订单</button>
      {/* ✅ 需要带参数时,用箭头函数包一层,同样把执行时机留给点击 */}
      <button onClick={() => approve()}>通过审核</button>
    </div>
  );
}

表单提交要主动阻止浏览器默认行为(整页刷新):

function FilterForm() {
  function handleSubmit(e) {
    e.preventDefault(); // 与原生一致:拦截表单默认提交
    console.log('触发筛选');
  }
  return (
    <form onSubmit={handleSubmit}>
      <input name="keyword" placeholder="搜索订单号 / 客户名" />
      <button type="submit">筛选</button>
    </form>
  );
}

React 17 起事件对象不再池化,可以在异步回调里安全使用;事件的底层是「合成事件」统一封装,日常开发按原生事件的直觉写即可。

2.4 条件渲染:三元、&& 与提前 return

对应 Vue 的 v-if / v-else,React 有三种惯用写法,按场景选择:

// 写法一:三元 —— 两个分支二选一
function StatusBadge({ status }) {
  return status === 'pending_review' ? (
    <span className="tag tag--warning">待审核</span>
  ) : (
    <span className="tag">{status}</span>
  );
}
// 写法二:&& —— 满足条件才渲染(false / null / undefined 不渲染任何东西)
function VipMark({ isVip }) {
  return (
    <span>
      {isVip && <em className="vip-mark">VIP 客户</em>}
    </span>
  );
}
// 写法三:提前 return —— 分支多时比嵌套三元清爽得多
function StatusText({ status }) {
  if (status === 'draft') return <span className="tag">草稿</span>;
  if (status === 'pending_review') return <span className="tag tag--warning">待审核</span>;
  if (status === 'cancelled') return <span className="tag tag--danger">已取消</span>;
  return <span className="tag tag--success">进行中</span>;
}

Vue 的 v-show(隐藏但保留 DOM)对应「始终渲染 + CSS 控制显隐」:

function AdvancedFilter({ visible }) {
  // 适合频繁切换、需要保留内部输入状态的场景
  return <div style={{ display: visible ? 'block' : 'none' }}>高级筛选区</div>;
}

选择建议:用不上就不渲染(&& / 提前 return,组件卸载、状态释放);频繁切换且要保留状态(display 显隐)。

2.5 列表渲染与 key:map 的正确姿势

对应 Vue 的 v-for + :key。map 出多少个节点,就渲染多少个;key 是每个节点的「身份证」:

const ORDERS = [
  { id: '1001', orderNo: 'QO20260929-000001', status: 'pending_review' },
  { id: '1002', orderNo: 'QO20260929-000002', status: 'approved' },
  { id: '1003', orderNo: 'QO20260929-000003', status: 'shipped' },
];

function OrderList() {
  return (
    <ul className="order-list">
      {ORDERS.map((order) => (
        // key 放在这层 map 返回的最外层元素上;兄弟之间必须唯一且稳定
        <li key={order.id} className="order-item">
          <span>{order.orderNo}</span>
          <StatusText status={order.status} />
        </li>
      ))}
    </ul>
  );
}

key 的三条纪律:

  1. 唯一:同一列表的兄弟节点之间不重复;
  2. 稳定:重渲染前后,同一条数据的 key 不变(优先用后端主键 / 业务 ID);
  3. 不折腾:key 是给 React 用的,不要渲染到 DOM 上,也不要用随机数。

为什么不能用数组下标当 key?因为 diff 按位置对号入座:删除第 2 项后,原来的第 3 项「顶替」了第 2 项的位置,React 认为位置 2 上的元素还是原来那个——组件内部状态(输入框内容、勾选、展开)会串到错误的行上。规则:列表会插入、删除、排序,就绝不用下标。

key 还有一个进阶用途:换 key 强制重建。给输入框换一个不同的 key,React 会销毁旧的并新建,其内部状态(光标、已输入文字、文本选区)随之清空——这是「清空表单」的正规手段之一,也是 key 与 selection 状态相关的原因。

2.6 受控输入:React 版的 v-model

Vue 的 v-model 是语法糖;React 里对应「受控组件」:表单值被 React 状态「钉住」,每次输入通过 onChange 回写。value 与 onChange 成对出现:

import { useState } from 'react';

function OrderSearch() {
  const [keyword, setKeyword] = useState('');

  return (
    <form onSubmit={(e) => e.preventDefault()}>
      <label htmlFor="kw">搜索订单</label>
      {/* value 被 React 钉住 + onChange 回写 = React 版的 v-model */}
      <input
        id="kw"
        value={keyword}
        onChange={(e) => setKeyword(e.target.value)}
        placeholder="输入订单号或客户名"
      />
      <p>当前关键词:{keyword}</p>
    </form>
  );
}

下拉框与复选框的受控写法(筛选栏高频三件套):

function StatusFilter({ status, onChange }) {
  const STATUS_OPTIONS = [
    { value: '', label: '全部状态' },
    { value: 'pending_review', label: '待审核' },
    { value: 'approved', label: '已审核' },
    { value: 'shipped', label: '已发货' },
  ];
  return (
    <select value={status} onChange={(e) => onChange(e.target.value)}>
      {STATUS_OPTIONS.map((opt) => (
        <option key={opt.value} value={opt.value}>
          {opt.label}
        </option>
      ))}
    </select>
  );
}

function OnlyMineCheck({ checked, onChange }) {
  return (
    <label>
      {/* 复选框用 checked 而不是 value,取 e.target.checked */}
      <input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
      只看我的订单
    </label>
  );
}

2.7 渲染模型直觉:虚拟 DOM 与 diff

先建立两层认知:

① JSX 只是「描述」:编译后是一棵普通的 JS 对象树(元素树)
② 渲染是「对比 + 补丁」:
   组件函数重跑 → 得到新元素树 → 与上一次的树按层对比(diff)
   → 只把差异应用到真实 DOM

diff 的两条核心规则,决定了一个组件的状态是否保留:

场景 React 的行为 组件状态
同位置的同类型元素 保留 DOM 节点,只更新变化的属性 保留
同位置的不同类型元素 销毁旧节点(含子树),创建新节点 丢失
列表中 key 相同的项 视为同一项,原地更新 保留
列表中 key 变了 / 项被删除 对应项销毁重建 丢失

一句话直觉:React 按「位置 + 类型 + key」认人,认得出就保留状态,认不出就重建。这就是 §2.5 「换 key 清空状态」能用、而下标 key 会串状态的原因。

这是面试与排错都够用的心智模型。真正的调度细节(Fiber、优先级)见官方文档 react.dev/learn/render-and-commit,第 13 章讲性能时会回来深化。

🛠 渐进式实战练习

练习 1 · 基础(15 min):状态标签列表

任务描述:附录A 定义了订单 7 种状态(draft / pending_review / approved / shipped / completed / rejected / cancelled)。请实现 StatusTag 组件:输入 status,输出带语义色类的中文标签;再用它渲染一份含全部 7 种状态的订单列表。

提示:

  • 状态 → 文案 / 颜色的映射用配置对象驱动,不要写 7 个 if;
  • 未知状态要有兜底(显示原始值),不能白屏;
  • 每个列表项要有稳定 key。

验收标准:

  • [ ] 7 种状态全部正确渲染出中文与对应颜色类;
  • [ ] 传入不存在的状态(如 'unknown')不崩溃、有兜底展示;
  • [ ] 列表项 key 使用数据 ID。
参考答案
// src/data/statusMeta.js —— 状态 → 展示配置的映射表(配置驱动,别写 7 个 if)
export const STATUS_META = {
  draft: { label: '草稿', className: 'tag--default' },
  pending_review: { label: '待审核', className: 'tag--warning' },
  approved: { label: '已审核', className: 'tag--info' },
  shipped: { label: '已发货', className: 'tag--primary' },
  completed: { label: '已完成', className: 'tag--success' },
  rejected: { label: '已拒绝', className: 'tag--danger' },
  cancelled: { label: '已取消', className: 'tag--muted' },
};
// src/components/StatusTag.jsx
import { STATUS_META } from '../data/statusMeta';

function StatusTag({ status }) {
  // ?? 兜底:未知状态不崩溃
  const meta = STATUS_META[status] ?? { label: status, className: 'tag--default' };
  return <span className={`tag ${meta.className}`}>{meta.label}</span>;
}

export default StatusTag;
// src/App.jsx
import StatusTag from './components/StatusTag';

const ORDERS = [
  { id: '1001', orderNo: 'QO20260929-000001', status: 'draft' },
  { id: '1002', orderNo: 'QO20260929-000002', status: 'pending_review' },
  { id: '1003', orderNo: 'QO20260929-000003', status: 'approved' },
  { id: '1004', orderNo: 'QO20260929-000004', status: 'shipped' },
  { id: '1005', orderNo: 'QO20260929-000005', status: 'completed' },
  { id: '1006', orderNo: 'QO20260929-000006', status: 'rejected' },
  { id: '1007', orderNo: 'QO20260929-000007', status: 'cancelled' },
];

function App() {
  return (
    <ul className="order-list">
      {ORDERS.map((order) => (
        <li key={order.id} className="order-item">
          <span>{order.orderNo}</span>
          <StatusTag status={order.status} />
        </li>
      ))}
    </ul>
  );
}

export default App;

练习 2 · 进阶(30–45 min):订单筛选栏

任务描述:实现 OrderBoard:顶部一个筛选栏(关键词输入框 + 状态下拉 + 「只看我的订单」复选框),下方渲染按条件过滤后的订单列表;无匹配时显示空态「没有匹配的订单」。

提示:

  • 三个筛选条件都是受控组件,状态放在 OrderBoard(最近的公共父级);
  • 过滤结果在渲染时直接派生(filter 链),先不要用任何缓存优化;
  • 复选框的值是布尔,注意取 e.target.checked。

验收标准:

  • [ ] 输入关键词即时过滤(订单号或客户名命中即可);
  • [ ] 状态下拉与「只看我的」复选框独立生效且可叠加;
  • [ ] 过滤结果为空时展示空态,而不是渲染一个空列表。
参考答案
// src/pages/OrderBoard.jsx
import { useState } from 'react';
import StatusTag from '../components/StatusTag';

const MOCK_ORDERS = [
  { id: '1001', orderNo: 'QO20260929-000001', customerName: '星辰商贸', ownerName: '张晓', status: 'pending_review' },
  { id: '1002', orderNo: 'QO20260929-000002', customerName: '恒远智造', ownerName: '李慕', status: 'approved' },
  { id: '1003', orderNo: 'QO20260929-000003', customerName: '星辰商贸', ownerName: '张晓', status: 'draft' },
  { id: '1004', orderNo: 'QO20260929-000004', customerName: '云杉实业', ownerName: '王砚', status: 'shipped' },
];

const CURRENT_USER = '张晓'; // 教学用:真实项目里来自登录态(第 11 章)

function OrderBoard() {
  const [keyword, setKeyword] = useState('');
  const [status, setStatus] = useState('');
  const [onlyMine, setOnlyMine] = useState(false);

  // 派生数据:渲染时直接算,三个条件链式叠加
  const filtered = MOCK_ORDERS.filter((order) => {
    const hitKeyword =
      keyword === '' ||
      order.orderNo.includes(keyword) ||
      order.customerName.includes(keyword);
    const hitStatus = status === '' || order.status === status;
    const hitOwner = !onlyMine || order.ownerName === CURRENT_USER;
    return hitKeyword && hitStatus && hitOwner;
  });

  return (
    <div className="order-board">
      <div className="order-board__filters">
        <input
          value={keyword}
          onChange={(e) => setKeyword(e.target.value)}
          placeholder="搜索订单号 / 客户名"
        />
        <select value={status} onChange={(e) => setStatus(e.target.value)}>
          <option value="">全部状态</option>
          <option value="draft">草稿</option>
          <option value="pending_review">待审核</option>
          <option value="approved">已审核</option>
          <option value="shipped">已发货</option>
        </select>
        <label>
          <input
            type="checkbox"
            checked={onlyMine}
            onChange={(e) => setOnlyMine(e.target.checked)}
          />
          只看我的订单
        </label>
      </div>

      {filtered.length === 0 ? (
        <p className="empty">没有匹配的订单</p>
      ) : (
        <ul className="order-list">
          {filtered.map((order) => (
            <li key={order.id} className="order-item">
              <span>{order.orderNo}</span>
              <span>{order.customerName}</span>
              <span>{order.ownerName}</span>
              <StatusTag status={order.status} />
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

export default OrderBoard;

练习 3 · 挑战(选做,60 min+):排序 + 关键词高亮

任务描述:在练习 2 基础上追加:① 排序切换(按创建时间 / 按金额);② 列表中订单号与客户名的命中片段高亮(<mark>);③ 高亮要正确处理「多次命中」。

提示:

  • 排序前先浅拷贝数组(sort 会原地修改);
  • 高亮思路:text.split(keyword) 切片,切片之间插 <mark>,注意用 Fragment 包住 map 的输出;
  • 渲染文本时 React 会自动转义,不用担心注入。

验收标准:

  • [ ] 排序切换后列表顺序正确,且原数组未被改动;
  • [ ] 关键词命中的片段全部高亮,未命中时列表原样展示;
  • [ ] map 中需要 key 的位置都补上了 key(含 Fragment)。
参考答案
import { Fragment, useState } from 'react';
import StatusTag from '../components/StatusTag';

function Highlight({ text, keyword }) {
  if (!keyword || !text.includes(keyword)) return text;
  const parts = text.split(keyword);
  return (
    <>
      {/* 切片数量随输入变化、无稳定 ID,此处用下标作 key 是可接受的例外 */}
      {parts.map((part, i) => (
        <Fragment key={i}>
          {part}
          {i < parts.length - 1 && <mark>{keyword}</mark>}
        </Fragment>
      ))}
    </>
  );
}

function OrderBoard() {
  const [sortBy, setSortBy] = useState('createdAt'); // 'createdAt' | 'totalAmount'
  const [keyword, setKeyword] = useState('');

  const MOCK_ORDERS = [
    { id: '1001', orderNo: 'QO20260929-000001', customerName: '星辰商贸', createdAt: '2026-09-01', totalAmount: 128000 },
    { id: '1002', orderNo: 'QO20260929-000002', customerName: '恒远智造', createdAt: '2026-09-03', totalAmount: 66000 },
    { id: '1003', orderNo: 'QO20260929-000003', customerName: '星辰商贸', createdAt: '2026-09-05', totalAmount: 9200 },
  ];

  // 排序:先拷贝再排序,避免原地修改依赖数据
  const sorted = [...MOCK_ORDERS].sort((a, b) =>
    sortBy === 'totalAmount' ? b.totalAmount - a.totalAmount : a.createdAt.localeCompare(b.createdAt),
  );
  const displayed = keyword
    ? sorted.filter((o) => o.orderNo.includes(keyword) || o.customerName.includes(keyword))
    : sorted;

  return (
    <div className="order-board">
      <input value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="高亮关键词" />
      <select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
        <option value="createdAt">按创建时间</option>
        <option value="totalAmount">按金额</option>
      </select>
      <ul className="order-list">
        {displayed.map((order) => (
          <li key={order.id} className="order-item">
            <span>
              <Highlight text={order.orderNo} keyword={keyword} />
            </span>
            <span>
              <Highlight text={order.customerName} keyword={keyword} />
            </span>
            <StatusTag status={order.status} />
          </li>
        ))}
      </ul>
    </div>
  );
}

export default OrderBoard;

⚠️ 常见易错点

易错点 1:&& 渲染出孤零零的「0」

// ❌ 当 orders.length 为 0 时,页面上出现一个「0」
// 原因:0 是假值,但 && 会直接返回左侧操作数,而 React 会把 0 渲染成文本
function OrderCount({ orders }) {
  return <div>{orders.length && <span>共 {orders.length} 单</span>}</div>;
}
// ✅ 把左侧显式变成布尔值,短路结果只剩 true/false
function OrderCount({ orders }) {
  return <div>{orders.length > 0 && <span>共 {orders.length} 单</span>}</div>;
}

易错点 2:用数组下标当 key

// ❌ 下标作 key:删除 / 排序后 React 按「位置」对号入座
// 现象:输入框内容串行、勾选错位、动画错乱
{orders.map((order, index) => (
  <OrderRow key={index} order={order} />
))}
// ✅ 用数据里稳定的唯一 ID(后端主键 / 业务编号)
{orders.map((order) => (
  <OrderRow key={order.id} order={order} />
))}

易错点 3:style 传字符串、class 照搬 HTML

// ❌ style 接收字符串、class 用了 HTML 写法:双双报错或告警
<span class="tag" style="color: red;">待审核</span>
// ✅ className 接收字符串,style 接收对象(值跟 CSS 一样用字符串/数值)
<span className="tag" style={{ color: 'red' }}>待审核</span>

易错点 4:受控输入只给 value 不给 onChange

// ❌ 只钉住 value 却没有回写通道:输入框永远打不进字
// 现象:控制台警告 “You provided a `value` prop to a form field without an `onChange` handler”
<input value={keyword} />
// ✅ value 与 onChange 成对出现;明确要「非受控」时用 defaultValue(第 05 章 §5.5)
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />

易错点 5:花括号里写语句

// ❌ if 是语句,表达式位置容不下它:直接编译报错
function StatusBar({ loading }) {
  return <div>{if (loading) '加载中…'}</div>;
}
// ✅ 三元是表达式可以直接放;复杂逻辑提到 return 之前算好
function StatusBar({ loading }) {
  return <div>{loading ? '加载中…' : '加载完成'}</div>;
}

✅ 最佳实践

  1. 配置驱动展示:状态、枚举类 UI 一律收敛成 STATUS_META 这样的映射表,新增状态只改一处。
  2. key 一律来自业务 ID:把「下标作 key」写进团队 code review 红线,除非列表完全静态。
  3. 派生数据不存状态:过滤、排序、汇总在渲染时计算,避免「同一份数据存两份、改一处忘一处」。
  4. 表达式短小、逻辑前置:花括号里最多一层三元,复杂判断提成变量或提前 return。
  5. 受控优先:筛选、表单类输入默认走受控组件,保证「数据 → 视图」单向可追踪;确有性能或文件输入等场景再考虑非受控(第 05 章 §5.5)。
  6. 空态是需求的一部分:列表必有空态文案与样式,验收时把「过滤到 0 条」当作必测用例。

📌 本章小结

  1. JSX 是带糖的 JavaScript:{} 里只能写表达式;属性用 className / 对象 style;事件用小驼峰且传「函数引用」。
  2. 条件渲染三板斧:三元(二选一)、&&(要么渲染要么不渲染)、提前 return(多分支);v-show 对应 CSS 显隐。
  3. 列表渲染 = map + key;key 是节点的身份证,必须唯一且稳定;换 key 即重建,可用于重置组件状态。
  4. 受控组件是 React 版 v-model:value + onChange 成对出现,数据单向流动。
  5. diff 直觉:「位置 + 类型 + key」认得出就保留状态,认不出就重建——这解释了 key 的一系列行为。

下一章预告:单文件里的组件已经不够用了。第 03 章讲组件化开发基础——props 单向数据流、回调通信、children 与组合,并把 StatusTag、OrderCard、SearchBar 一次性拆出来。

📄 第 03 章 · 组件化开发基础 03-组件化开发基础.md

📍 阶段:初级阶段(第 1–6 章)|⏱ 预计学时:3.5 小时|🔗 前置:第 02 章

⬅ 上一章:JSX 与渲染模型 | 📖 本版目录 | ➡ 下一章:Hooks 核心

💡 对照学习:本页是 React 版第 03 章,对应 Vue 版第 03 章 · 组件化开发基础。概念映射:defineProps ≈ 函数参数解构、emit ≈ 回调 props(onXxx)、默认插槽 <slot> ≈ children、具名插槽 ≈ JSX props、作用域插槽 ≈ render props(传函数)。两版共同纪律:props 单向数据流、组件单一职责。

🎯 学习目标

学完本章,你将能够:

  1. 用「返回 JSX 的函数」定义组件,说清组件文件的组织约定(一文件一组件、命名一致);
  2. 为组件设计 props 接口,用 JSDoc 注明类型约束,并用默认值处理可选项;
  3. 解释 props 单向数据流:「数据向下流、事件向上冒」在企业代码里如何落地;
  4. 用回调 props(onXxx)实现子 → 父通信,并在回调中正确携带参数;
  5. 用 children 与 JSX props 实现 Vue 插槽的三种对应写法(默认 / 具名 / 作用域);
  6. 按「单一职责、复用 vs 组合」原则拆分组件,说清什么时候不该拆;
  7. 独立完成企业场景:OrderCard、StatusTag、SearchBar 三个可复用业务组件。

📖 核心知识点

3.1 组件即函数:定义与使用

React 组件没有独立语法,它就是接收 props、返回 JSX 的函数。组织约定:一个文件一个组件,文件名与组件名一致,默认导出:

// src/components/StatusTag.jsx
// 组件 = 接收 props、返回 JSX 的函数;命名大写开头
function StatusTag({ status }) {
  return <span className="tag tag--default">{status}</span>;
}

export default StatusTag;
// src/App.jsx —— 使用:当成 HTML 标签写,必须是「大写开头」的标识符
import StatusTag from './components/StatusTag';

function App() {
  return (
    <div className="order-board">
      <StatusTag status="pending_review" />
      <StatusTag status="approved" />
    </div>
  );
}

export default App;

第 02 章的 STATUS_META 配置可以直接搬进来,让这个组件立刻具备业务语义(见本章练习 1)。

3.2 props:单向数据流与类型约束

props 是组件的只读入参。两版框架共同的纪律是单向数据流:数据从父到子单向流动,子组件想改变数据,只能「提出请求」,不能擅自动手:

父组件 OrderBoard
   │  props(数据向下流)
   ▼
子组件 OrderCard ── 回调 props(事件向上冒)
   ▲
   │ 用户点击「查看详情」等操作,由父组件决定怎么改数据

用 JSDoc 给 props 写类型约束(JavaScript 阶段的推荐做法,第三部分会全面转 TypeScript):

/**
 * 订单卡片
 * @param {object}  props
 * @param {{ id: string, orderNo: string, customerName: string,
 *           ownerName: string, status: string }} props.order - 订单实体(附录A)
 * @param {boolean} [props.compact=false] - 紧凑模式(列表页用)
 * @param {(orderId: string) => void} [props.onOpen] - 点击卡片时触发
 */
function OrderCard({ order, compact = false, onOpen }) {
  const className = compact ? 'order-card order-card--compact' : 'order-card';
  return (
    <article
      className={className}
      onClick={() => onOpen?.(order.id)}
      role={onOpen ? 'button' : undefined}
    >
      <header className="order-card__header">
        <strong>{order.orderNo}</strong>
      </header>
      <p>客户:{order.customerName}</p>
      <p>归属销售:{order.ownerName}</p>
    </article>
  );
}

export default OrderCard;

三个要点:

  • 可选 props 给默认值:compact = false 用参数默认值实现,调用方不传也不崩;
  • 回调可以缺省:onOpen?.() 可选链调用,组件在没有父级监听时也能独立渲染;
  • 运行时校验可选装 prop-types:npm i prop-types 后声明 OrderCard.propTypes,团队二选一(JSDoc 或 prop-types)并写进规范,保持全组一致即可。

3.3 子 → 父通信:回调 props

Vue 用 emit 抛事件;React 更直接——子组件调用父组件传下来的函数。命名约定:on + 动词(onSearch、onApprove、onOpen):

// src/components/SearchBar.jsx —— 子组件:负责「收集」,不负责「处理」
function SearchBar({ onSearch }) {
  function handleSubmit(e) {
    e.preventDefault();
    // 从表单一次性取值(这里用非受控思路,第 05 章 §5.5 系统对比)
    const keyword = new FormData(e.currentTarget).get('keyword') ?? '';
    onSearch(keyword.trim()); // 把数据交给父组件处理
  }
  return (
    <form className="search-bar" onSubmit={handleSubmit}>
      <input name="keyword" placeholder="搜索订单号 / 客户名" />
      <button type="submit">搜索</button>
    </form>
  );
}

export default SearchBar;
// src/App.jsx —— 父组件:负责「处理」,状态只在父级变更
import { useState } from 'react';
import SearchBar from './components/SearchBar';

function App() {
  const [keyword, setKeyword] = useState('');
  return (
    <div className="page">
      {/* 回调 props ≈ Vue 的 emit:子发「请求」,父改状态 */}
      <SearchBar onSearch={setKeyword} />
      <p>当前筛选:{keyword || '(无)'}</p>
    </div>
  );
}

export default App;

状态放在哪? 放在「需要它的组件的最近公共父级」。关键词被 SearchBar 和下方列表同时需要,所以关键词状态上提到父级——这就是「状态提升」,第 04 章会反复用到。

3.4 children 与组合:slot 的 React 写法

Vue 的插槽体系在 React 里的对应物是「把 JSX 当 props 传」:

Vue 插槽 React 写法
默认插槽 <slot /> children prop
具名插槽 <slot name="footer" /> 任意命名的 JSX props(如 footer)
作用域插槽(子传数据给插槽内容) 传函数 props(render props 思路)
// src/components/Panel.jsx —— children ≈ 默认插槽,footer ≈ 具名插槽
function Panel({ title, footer, children }) {
  return (
    <section className="panel">
      <header className="panel__header">{title}</header>
      <div className="panel__body">{children}</div>
      {/* 可选区块:传了才渲染 */}
      {footer && <footer className="panel__footer">{footer}</footer>}
    </section>
  );
}

export default Panel;
// 使用:开闭标签之间的内容就是 children,任意 JSX 都可以
<Panel title="待审核订单" footer={<small>共 12 单待处理</small>}>
  <OrderCard order={orders[0]} compact />
  <OrderCard order={orders[1]} compact />
</Panel>

作用域插槽的思路(render props,先认识即可,中级阶段封装列表组件时会常用):

// 子组件把「每一行的数据」交给调用方决定怎么渲染
function OrderList({ orders, renderItem }) {
  return (
    <ul className="order-list">
      {orders.map((order) => (
        <li key={order.id}>{renderItem(order)}</li>
      ))}
    </ul>
  );
}

// 调用方控制每一行长什么样:数据来自 OrderList,结构由使用者定
<OrderList
  orders={orders}
  renderItem={(order) => <OrderCard order={order} compact />}
/>

3.5 组件拆分原则:单一职责与复用 vs 组合

什么时候该拆出一个组件?四个信号满足其一:

  1. 重复出现:同样的结构在两处以上出现(复用信号);
  2. 超过一屏:一个函数上百行 JSX,读不动了(可读性信号);
  3. 独立状态:某块 UI 拥有自己的状态(如弹窗开合),独立成组件边界更清晰(状态信号);
  4. 独立演进:它可能被单独修改、测试或替换(变更信号)。

以订单看板为例,拆出这条组件树:

OrderBoard(页面容器:持有筛选状态,负责数据加工)
├── SearchBar          —— 只管收集关键词,搜什么它不管
├── OrderFilter        —— 只管状态下拉与归属筛选
└── OrderList          —— 只管把订单数组渲染成列表
    └── OrderCard      —— 只管一张订单的展示
        └── StatusTag  —— 只管一个状态标签

拆分的同时要警惕两个反模式:

  • 过度拆分:两三行 JSX 就拆一个组件、为「可能复用」提前抽象——组件数量爆炸比重复代码更难维护;
  • 万能组件:一个 OrderCard 通过十几个 props 开关变换出所有形态——改用组合(外部传 children / JSX props)代替配置开关。

判断口诀:复用靠抽组件,变化靠组合;先写出重复的两份,确认稳定后再抽象。

🛠 渐进式实战练习

练习 1 · 基础(20 min):业务化的 StatusTag

任务描述:把第 01 章练习里的 StatusTag 升级为正式组件:基于 STATUS_META 配置渲染语义色标签;补充 JSDoc;提供 size 可选 prop('md' | 'sm',默认 'md')。

提示:

  • 映射表放 src/data/statusMeta.js,未知状态兜底;
  • size 只影响类名,不要出现两套结构;
  • 导出方式与文件名遵守 §3.1 约定。

验收标准:

  • [ ] 7 种状态渲染正确,未知状态有兜底;
  • [ ] size="sm" 时类名含 tag--sm,不传时默认 md;
  • [ ] JSDoc 完整,组件从独立文件默认导出。
参考答案
// src/data/statusMeta.js
export const STATUS_META = {
  draft: { label: '草稿', className: 'tag--default' },
  pending_review: { label: '待审核', className: 'tag--warning' },
  approved: { label: '已审核', className: 'tag--info' },
  shipped: { label: '已发货', className: 'tag--primary' },
  completed: { label: '已完成', className: 'tag--success' },
  rejected: { label: '已拒绝', className: 'tag--danger' },
  cancelled: { label: '已取消', className: 'tag--muted' },
};
// src/components/StatusTag.jsx
import { STATUS_META } from '../data/statusMeta';

/**
 * 订单状态标签
 * @param {object} props
 * @param {string} props.status - 订单状态(附录A 状态机)
 * @param {'md'|'sm'} [props.size='md'] - 尺寸
 */
function StatusTag({ status, size = 'md' }) {
  const meta = STATUS_META[status] ?? { label: status, className: 'tag--default' };
  return <span className={`tag tag--${size} ${meta.className}`}>{meta.label}</span>;
}

export default StatusTag;

练习 2 · 进阶(40 min):OrderCard + 回调上抛

任务描述:实现 OrderCard(展示 orderNo、customerName、ownerName、状态标签、金额),并支持两个回调:onOpen(orderId)、onApprove(orderId)(通过审核按钮)。父组件 OrderBoard 持有订单数组,点「通过审核」后该单状态变为 approved(先直接改数组元素也先不做,用「整份替换」思路:map 生成新数组,下一章细讲)。

提示:

  • 金额单位是「分」,展示组件负责转「元」并保留两位小数;求和才会涉及浮点问题,单笔换算安全;
  • 子组件只调用 onApprove(order.id),怎么改数据是父组件的事;
  • 卡片整体可点打开详情,内部按钮阻止事件冒泡或分开布局,避免误触。

验收标准:

  • [ ] OrderBoard 渲染 3 张卡片,字段完整;
  • [ ] 点击「通过审核」后对应卡片状态标签变为「已审核」;
  • [ ] OrderCard 内部没有出现任何「修改 order 对象」的代码。
参考答案
// src/components/OrderCard.jsx
import StatusTag from './StatusTag';

/**
 * 订单卡片
 * @param {object} props
 * @param {{ id: string, orderNo: string, customerName: string,
 *           ownerName: string, status: string, totalAmount: number }} props.order
 * @param {(orderId: string) => void} [props.onOpen]
 * @param {(orderId: string) => void} [props.onApprove]
 */
function OrderCard({ order, onOpen, onApprove }) {
  const yuan = (order.totalAmount / 100).toLocaleString('zh-CN', {
    minimumFractionDigits: 2,
  });
  return (
    <article className="order-card" onClick={() => onOpen?.(order.id)}>
      <header className="order-card__header">
        <strong>{order.orderNo}</strong>
        <StatusTag status={order.status} />
      </header>
      <p>客户:{order.customerName}</p>
      <p>归属销售:{order.ownerName}</p>
      <footer className="order-card__footer">
        <strong>¥{yuan}</strong>
        <button
          onClick={(e) => {
            e.stopPropagation(); // 避免触发卡片的 onOpen
            onApprove?.(order.id);
          }}
        >
          通过审核
        </button>
      </footer>
    </article>
  );
}

export default OrderCard;
// src/pages/OrderBoard.jsx
import { useState } from 'react';
import OrderCard from '../components/OrderCard';

const INITIAL_ORDERS = [
  { id: '1001', orderNo: 'QO20260929-000001', customerName: '星辰商贸', ownerName: '张晓', status: 'pending_review', totalAmount: 128000 },
  { id: '1002', orderNo: 'QO20260929-000002', customerName: '恒远智造', ownerName: '李慕', status: 'draft', totalAmount: 66000 },
  { id: '1003', orderNo: 'QO20260929-000003', customerName: '云杉实业', ownerName: '王砚', status: 'pending_review', totalAmount: 245000 },
];

function OrderBoard() {
  const [orders, setOrders] = useState(INITIAL_ORDERS);

  function handleApprove(orderId) {
    // 不可变更新:map 造新数组,被改的元素展开造新对象(第 04 章系统讲)
    setOrders(orders.map((o) => (o.id === orderId ? { ...o, status: 'approved' } : o)));
  }

  function handleOpen(orderId) {
    console.log('打开订单详情', orderId); // 第 07 章接路由后跳详情页
  }

  return (
    <div className="order-board">
      {orders.map((order) => (
        <OrderCard key={order.id} order={order} onOpen={handleOpen} onApprove={handleApprove} />
      ))}
    </div>
  );
}

export default OrderBoard;

练习 3 · 挑战(选做,60 min+):用 Panel 组合出订单看板

任务描述:实现通用容器 Panel(title、footer、children),用两个 Panel 组合出完整看板:「筛选面板」(含 SearchBar)+「订单列表面板」(含 OrderCard 列表与统计 footer);再把列表渲染升级为 render props 版 OrderList,让 OrderBoard 自定义每行渲染。

提示:

  • Panel 是纯展示组件:不含任何订单业务逻辑;
  • 统计文案(如「共 N 单待处理」)由 OrderBoard 算好传入 footer;
  • OrderList 只负责遍历与 key,行结构交给 renderItem。

验收标准:

  • [ ] Panel 可复用(两处使用结构一致、样式统一);
  • [ ] 搜索后列表与 footer 统计同步更新;
  • [ ] OrderList 通过 renderItem 渲染行,内部不知道 OrderCard 的存在。
参考答案(核心代码)
// src/components/OrderList.jsx —— render props:数据由我给,结构由你定
function OrderList({ orders, renderItem, emptyText = '暂无订单' }) {
  if (orders.length === 0) return <p className="empty">{emptyText}</p>;
  return (
    <ul className="order-list">
      {orders.map((order) => (
        <li key={order.id}>{renderItem(order)}</li>
      ))}
    </ul>
  );
}

export default OrderList;
// src/pages/OrderBoard.jsx(组合部分)
import OrderList from '../components/OrderList';
import Panel from '../components/Panel';
// SearchBar、OrderCard、STATUS_META 同练习 1/2

function OrderBoard() {
  // keyword / orders 状态同练习 2,此处省略
  const pendingCount = orders.filter((o) => o.status === 'pending_review').length;

  return (
    <div className="dashboard">
      <Panel title="订单筛选">
        <SearchBar onSearch={setKeyword} />
      </Panel>
      <Panel title="订单列表" footer={<small>共 {pendingCount} 单待处理</small>}>
        <OrderList
          orders={orders.filter((o) => o.orderNo.includes(keyword))}
          renderItem={(order) => <OrderCard order={order} onApprove={handleApprove} compact />}
        />
      </Panel>
    </div>
  );
}

export default OrderBoard;

⚠️ 常见易错点

易错点 1:在子组件里直接修改 props

// ❌ 在子组件里直接改 props:单向数据流被破坏
// 现象:数据「到底谁在改」无法追踪,父组件的其他依赖处可能不更新
function OrderCard({ order, orders }) {
  return (
    <button
      onClick={() => {
        order.status = 'approved'; // 偷偷改了父组件的数据!
        orders.push(order);        // 数组也被动了
      }}
    >
      通过审核
    </button>
  );
}
// ✅ 改数据是父组件的事:子组件通过回调 props「提出请求」
function OrderCard({ order, onApprove }) {
  return <button onClick={() => onApprove(order.id)}>通过审核</button>;
}

易错点 2:把「调用函数」当成「传函数」

// ❌ onClick={handleApprove(order.id)}:渲染时立刻执行一次,点击无效
function OrderCard({ order, onApprove }) {
  return <button onClick={onApprove(order.id)}>通过审核</button>;
}
// ✅ 传函数引用:箭头函数包一层,把执行时机交给点击事件
function OrderCard({ order, onApprove }) {
  return <button onClick={() => onApprove(order.id)}>通过审核</button>;
}

易错点 3:导入导出方式不一致

// ❌ 文件里是命名导出,调用处按默认导出导入
// OrderCard.jsx
export function OrderCard() { /* ... */ }

// App.jsx —— OrderCard 实际是 undefined
import OrderCard from './components/OrderCard';
// 现象:报错 “Element type is invalid: expected a string ... but got: undefined”
// ✅ 导入导出保持一致:本书约定「一文件一组件 + 默认导出」
// OrderCard.jsx
export default function OrderCard() { /* ... */ }

// App.jsx
import OrderCard from './components/OrderCard';

易错点 4:假设 children 一定存在

// ❌ 没处理空内容:调用方条件渲染后 children 可能为空,页面渲染出空面板
<Panel title="审批记录">{hasRecords && <RecordList records={records} />}</Panel>
// ✅ 容器组件对空内容做兜底,体验与健壮性双收
function Panel({ title, footer, children }) {
  return (
    <section className="panel">
      <header className="panel__header">{title}</header>
      {children ? (
        <div className="panel__body">{children}</div>
      ) : (
        <p className="panel__empty">暂无内容</p>
      )}
      {footer && <footer className="panel__footer">{footer}</footer>}
    </section>
  );
}

易错点 5:props 展开顺序导致默认值覆盖调用方

// ❌ 先展开 props 再写 className:调用方传的自定义样式被悄悄覆盖
<input {...props} className="search-input" />
// ✅ 后写的属性生效:默认值在前,调用方覆盖在后
<input className="search-input" {...props} />

✅ 最佳实践

  1. 一文件一组件、命名一致:OrderCard.jsx 只导出 OrderCard;公共小组件放 components/,页面放 pages/。
  2. props 命名词法:数据用名词(order、orders),回调一律 on + 动词(onApprove);布尔用 is/has/only 前缀(onlyMine)。
  3. 回调参数写进 JSDoc:onApprove(orderId) 的参数类型与含义必须注明——回调是组件对外的「API 文档」。
  4. 子组件零 mutation:review 时见到「子组件改 props 对象 / 数组」直接打回,一律改为回调上抛。
  5. 先重复后抽象:同一结构出现两次前不抽组件;抽出的组件先做纯展示,状态上提。
  6. 状态放最近公共父级:需要跨层共享的状态上提,用不到的状态下沉,避免「状态悬浮」在无关层级。

📌 本章小结

  1. React 组件 = 返回 JSX 的函数;一文件一组件、默认导出、文件名与组件名一致。
  2. props 只读、单向数据流:数据向下、事件向上;子组件改数据只能通过 onXxx 回调请求父组件。
  3. 插槽对应关系:children ≈ 默认插槽、JSX props ≈ 具名插槽、函数 props(render props)≈ 作用域插槽。
  4. 拆分信号:重复、超长、独立状态、独立演进;反模式是过度拆分与万能配置组件。
  5. 状态放最近公共父级——这句话会在第 04 章变成具体代码:useState 的正确打开方式。

下一章预告:组件会「记东西」了才算完整。第 04 章讲 Hooks 核心:useState 的不可变更新、useMemo 的缓存语义、useEffect 的依赖与清理、过期闭包陷阱,并完成「订单筛选联动」的真实业务闭环。

📄 第 04 章 · Hooks 核心 04-Hooks核心.md

📍 阶段:初级阶段(第 1–6 章)|⏱ 预计学时:4.5 小时|🔗 前置:第 03 章

⬅ 上一章:组件化开发基础 | 📖 本版目录 | ➡ 下一章:副作用生命周期与组件通信

💡 对照学习:本页是 React 版第 04 章,对应 Vue 版第 04 章 · 响应式系统核心。概念映射:ref / reactive ≈ useState、computed ≈ useMemo(以及渲染中直接派生)、watch / watchEffect ≈ useEffect(依赖数组即「监听目标」)、Vue「解构丢响应式」陷阱 ≈ React「过期闭包」陷阱。最大差异:Vue 改数据(可变响应式),React 换数据(不可变更新)。

🎯 学习目标

学完本章,你将能够:

  1. 说出 Hooks 的两条使用规则及其背后的原因(调用顺序一致性);
  2. 用 useState 管理基础类型与对象 / 数组状态,并坚持不可变更新(展开、map、filter);
  3. 在「新状态依赖旧状态」时使用函数式更新 setState(prev => ...);
  4. 区分「渲染中直接派生」与「useMemo 缓存」,为昂贵计算正确选择后者;
  5. 写出依赖数组完整、带清理函数的 useEffect,解释三种依赖写法的执行时机;
  6. 用 useCallback 稳定函数引用,说清它解决的是「引用相等」问题;
  7. 识别并修复过期闭包陷阱(定时器、事件回调中读到旧状态);
  8. 抽取自定义 Hook(如 useDebouncedValue),并完成「订单筛选联动」业务闭环。

📖 核心知识点

4.1 Hooks 与两条规则

Hooks 让函数组件拥有状态与副作用能力。useState、useEffect 等都是普通函数,但它们依赖调用顺序来对应每个状态的存储槽位,因此有两条铁律(官方 eslint-plugin-react-hooks 会自动检查,Vite 模板已内置):

// ❌ Hook 藏进了条件分支:提前 return 后它可能「不存在」,顺序错乱
function OrderBoard({ loading }) {
  if (loading) return <Spinner />; // 提前 return
  const [orders, setOrders] = useState([]); // React 报错:Hooks 顺序不稳定
  return <OrderList orders={orders} />;
}
// ✅ 所有 Hook 都在组件顶层调用,条件判断放在 Hook 之后
function OrderBoard({ loading }) {
  const [orders, setOrders] = useState([]);
  if (loading) return <Spinner />;
  return <OrderList orders={orders} />;
}

两条规则:只在最顶层调用(不进 if / 循环 / 嵌套函数);只在 React 函数组件或自定义 Hook 中调用(普通 JS 函数里不行)。

4.2 useState:不可变更新

与 Vue 直接改数据不同,React 要求换掉旧数据而不是修改它——React 靠引用是否变化来判断「状态变了,需要重渲染」:

import { useState } from 'react';

const INITIAL_ORDERS = [
  { id: '1001', orderNo: 'QO20260929-000001', status: 'draft', totalAmount: 128000 },
  { id: '1002', orderNo: 'QO20260929-000002', status: 'pending_review', totalAmount: 66000 },
];

function OrderBoard() {
  const [orders, setOrders] = useState(INITIAL_ORDERS);
  // 新增:不 push,展开造新数组
  function addOrder(order) {
    setOrders([order, ...orders]);
  }

  // 更新:map 造新数组;被改的元素展开造新对象
  function approveOrder(orderId) {
    setOrders(orders.map((o) => (o.id === orderId ? { ...o, status: 'approved' } : o)));
  }

  // 删除:filter 天然返回新数组
  function removeOrder(orderId) {
    setOrders(orders.filter((o) => o.id !== orderId));
  }
  return (
    <ul className="order-list">
      {orders.map((o) => (
        <li key={o.id}>
          {o.orderNo} · {o.status}
          <button onClick={() => approveOrder(o.id)}>通过审核</button>
          <button onClick={() => removeOrder(o.id)}>删除</button>
        </li>
      ))}
    </ul>
  );
}

函数式更新:新状态依赖旧状态时,传函数而不是值——React 保证拿到的是最新状态(连续多次 setState、异步回调里都安全):

// ✅ 推荐写法:prev 一定是最新值
function addOrder(order) {
  setOrders((prev) => [order, ...prev]);
}

多个 setState 在同一次事件里会自动合并成一次重渲染(自动批处理),无需担心性能;也不要在 setState 之后立刻读变量——它要等下一次渲染才生效(易错点 3)。

4.3 派生数据:渲染中直接算 vs useMemo

Vue 的 computed 在 React 里有两档对应:

  • 第一档(默认):直接在渲染中计算。简单派生就该这么写,直白且没有额外心智负担;
  • 第二档:计算昂贵(如上千条订单聚合)或结果要作为依赖参与其他 Hook 时,用 useMemo 缓存。
import { useMemo } from 'react';

function OrderStats({ orders }) {
  // 第一档:便宜的计算直接算,每次渲染重算也无所谓
  const pendingCount = orders.filter((o) => o.status === 'pending_review').length;
  // 第二档:上千条订单分组统计,orders 不变就复用上次结果
  const topSkus = useMemo(() => {
    const qtyBySku = new Map();
    for (const order of orders) {
      for (const item of order.items) {
        qtyBySku.set(item.skuName, (qtyBySku.get(item.skuName) ?? 0) + item.quantity);
      }
    }
    return [...qtyBySku.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
  }, [orders]); // 依赖数组:orders 变了才重算

  return (
    <div className="stats">
      <p>待审核订单:{pendingCount} 单</p>
      <p>热销 SKU Top5:{topSkus.map(([name, qty]) => `${name}×${qty}`).join('、')}</p>
    </div>
  );
}

两点纪律:派生值绝不存进 state(否则两份数据要人肉同步);useMemo 的缓存只是优化提示,React 不保证永不重算,所以缓存里不能放「有副作用」的逻辑。

4.4 useEffect:依赖数组与清理

useEffect 让组件与「外部世界」同步:发请求、订阅、改标题、操作 localStorage。它在渲染提交到屏幕之后执行,三种依赖写法决定执行时机:

写法 执行时机 典型用途
useEffect(fn) 不写依赖 每次渲染后 几乎不用(易造成死循环)
useEffect(fn, []) 空数组 仅挂载后执行一次 初始化、一次性订阅
useEffect(fn, [a, b]) 挂载后 + a 或 b 变化后 响应数据变化重新同步
import { useEffect, useState } from 'react';

// 教学 mock:真实项目放 src/api/ 目录(中级阶段封装 axios)
function fetchLogs(orderId) {
  return Promise.resolve([
    { id: 'l1', content: `订单 ${orderId} 创建于 2026-09-29 10:00` },
    { id: 'l2', content: `订单 ${orderId} 提交审核` },
  ]);
}
function OrderTimeline({ orderId }) {
  const [logs, setLogs] = useState([]);

  useEffect(() => {
    let ignore = false; // 竞态保护:只有最后一次请求的结果生效(第 05 章 §5.3 细讲)
    fetchLogs(orderId).then((data) => {
      if (!ignore) setLogs(data);
    });
    // 清理函数:下一次 effect 执行前 / 组件卸载时调用
    return () => {
      ignore = true;
    };
  }, [orderId]); // 依赖数组:orderId 变了才重新拉取

  return <ul>{logs.map((log) => <li key={log.id}>{log.content}</li>)}</ul>;
}

依赖数组是「监听目标」:React 用 Object.is 逐项比较。引用类型是重灾区——对象、数组、函数每次渲染都是新引用,放进依赖会导致 effect 反复执行(易错点 5)。原则:effect 里用到的响应式值,全部写进依赖,漏写就等着读到旧数据。

4.5 useCallback 与引用相等

函数在组件里每次渲染都会重新创建——handleA !== handleA(上一次的与这一次的不是同一个引用)。当这个函数被 依赖数组 或 子组件的 props 消费时,新引用会引发连锁反应:

import { useCallback, useState } from 'react';
function OrderRow({ order, onApprove }) {
  // 若 OrderRow 未来被 React.memo 包裹(第 13 章),每次新函数会让缓存失效
  return (
    <li>
      {order.orderNo}
      <button onClick={() => onApprove(order.id)}>通过</button>
    </li>
  );
}

function OrderBoard() {
  const [orders, setOrders] = useState([]);

  // useCallback:依赖不变时返回同一个函数引用
  const handleApprove = useCallback((orderId) => {
    setOrders((prev) => prev.map((o) => (o.id === orderId ? { ...o, status: 'approved' } : o)));
  }, []); // 函数体只用了 setState,依赖为空是安全的
  return (
    <ul className="order-list">
      {orders.map((o) => (
        <OrderRow key={o.id} order={o} onApprove={handleApprove} />
      ))}
    </ul>
  );
}

判断口诀:useCallback 解决的是「引用相等」,不是性能万能药——函数没有传给依赖数组 / memo 子组件 / 其他 Hook 依赖时,不需要包。

4.6 过期闭包陷阱

这是 React 新手最容易「见到鬼」的地方:回调函数捕获的是创建那一刻的状态快照,之后状态变了,回调里的旧值不变——Vue 的「解构丢响应式」在 React 里的对应物:

// ❌ 定时器把首次渲染的 count 锁进了闭包:计数永远停在 1
function OrderPoller() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const timer = setInterval(() => {
      setCount(count + 1); // count 永远是 0(首次渲染的快照)
    }, 1000);
    return () => clearInterval(timer);
  }, []); // 空依赖 = 定时器只用第一帧的变量
  return <p>已轮询 {count} 次</p>;
}
// ✅ 解法一(推荐):函数式更新,不依赖外部快照
function OrderPoller() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const timer = setInterval(() => setCount((prev) => prev + 1), 1000);
    return () => clearInterval(timer);
  }, []);
  return <p>已轮询 {count} 次</p>;
}
// 解法二:把 count 写进依赖数组——值变则销毁旧定时器、重建新定时器(见易错点 4 对照)

4.7 自定义 Hook 入门

自定义 Hook 就是名字以 use 开头、内部调用了其他 Hook 的普通函数。它是 React 版的「组合式函数」:把「状态 + 逻辑」打包复用,每个调用方拿到独立的一份状态:

// src/hooks/useDebouncedValue.js —— 输入防抖:值停稳 delayMs 后才「放行」
import { useEffect, useState } from 'react';

function useDebouncedValue(value, delayMs = 300) {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delayMs);
    return () => clearTimeout(timer); // 值再次变化:重置计时器
  }, [value, delayMs]);

  return debounced;
}
export default useDebouncedValue;
// 使用:每个组件里调用一次,各有一份独立的 debounced 状态
const [keyword, setKeyword] = useState('');
const debouncedKeyword = useDebouncedValue(keyword, 300); // 输入停 300ms 才变

自定义 Hook 的边界:只封装逻辑(状态、副作用、计算),不渲染任何 JSX;命名必须 use 开头,否则 ESLint 无法识别其中的 Hook 调用。

4.8 实战整合:订单筛选联动

企业场景(对应附录A 订单列表):关键词 + 状态 + 分页共同驱动请求参数。整合本章全部知识点:

// src/data/mockOrders.js —— 教学 mock:形状与附录A一致(金额单位:分)
export const MOCK_ORDERS = [
  { id: '1001', orderNo: 'QO20260929-000001', customerName: '星辰商贸', ownerName: '张晓', status: 'pending_review', totalAmount: 128000 },
  { id: '1002', orderNo: 'QO20260929-000002', customerName: '恒远智造', ownerName: '李慕', status: 'approved', totalAmount: 66000 },
  { id: '1003', orderNo: 'QO20260929-000003', customerName: '星辰商贸', ownerName: '张晓', status: 'draft', totalAmount: 9200 },
  { id: '1004', orderNo: 'QO20260929-000004', customerName: '云杉实业', ownerName: '王砚', status: 'shipped', totalAmount: 245000 },
];
// src/pages/OrderBoard.jsx —— 关键词(防抖) + 状态 + 分页 → 请求参数 → 列表
import { useEffect, useMemo, useState } from 'react';
import useDebouncedValue from '../hooks/useDebouncedValue';
import { MOCK_ORDERS } from '../data/mockOrders';
import StatusTag from '../components/StatusTag';

const PAGE_SIZE = 20; // 附录A:分页 20/页;魔法数字收敛为常量
function fetchOrderList({ keyword, status, page }) {
  const all = MOCK_ORDERS.filter(
    (o) =>
      (status === '' || o.status === status) &&
      (keyword === '' || o.orderNo.includes(keyword) || o.customerName.includes(keyword)),
  );
  const start = (page - 1) * PAGE_SIZE;
  // 返回形状与附录A分页响应一致
  return Promise.resolve({ list: all.slice(start, start + PAGE_SIZE), total: all.length, page, pageSize: PAGE_SIZE });
}

function OrderBoard() {
  const [keyword, setKeyword] = useState('');
  const [status, setStatus] = useState('');
  const [page, setPage] = useState(1);
  const [result, setResult] = useState({ list: [], total: 0 });
  const [loading, setLoading] = useState(false);

  const debouncedKeyword = useDebouncedValue(keyword, 300);

  // 请求参数:由三个状态「派生」,useMemo 保证引用稳定
  const query = useMemo(() => ({ keyword: debouncedKeyword, status, page }), [debouncedKeyword, status, page]);
  useEffect(() => {
    let ignore = false; // 快速连续请求时,只认最后一次
    setLoading(true);
    fetchOrderList(query).then((data) => {
      if (!ignore) {
        setResult(data);
        setLoading(false);
      }
    });
    return () => {
      ignore = true;
    };
  }, [query]);

  // 筛选条件变化:回到第 1 页
  function handleKeywordChange(value) {
    setKeyword(value);
    setPage(1);
  }
  function handleStatusChange(value) {
    setStatus(value);
    setPage(1);
  }
  const totalPages = Math.max(1, Math.ceil(result.total / PAGE_SIZE));
  return (
    <div className="order-board">
      <input value={keyword} onChange={(e) => handleKeywordChange(e.target.value)} placeholder="搜索订单号 / 客户名" />
      <select value={status} onChange={(e) => handleStatusChange(e.target.value)}>
        <option value="">全部状态</option>
        <option value="draft">草稿</option>
        <option value="pending_review">待审核</option>
        <option value="approved">已审核</option>
      </select>
      {loading ? <p>加载中…</p> : <StatusList orders={result.list} />}
      <p>
        第 {page} / {totalPages} 页(共 {result.total} 条)
        <button disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>上一页</button>
        <button disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>下一页</button>
      </p>
    </div>
  );
}

function StatusList({ orders }) {
  if (orders.length === 0) return <p className="empty">没有匹配的订单</p>;
  return (
    <ul className="order-list">
      {orders.map((o) => (
        <li key={o.id}>
          {o.orderNo} · {o.customerName} <StatusTag status={o.status} />
        </li>
      ))}
    </ul>
  );
}
export default OrderBoard;

数据流一句话:用户改状态 → query 派生变化 → effect 重新同步 → 列表更新。整个链条只有一条「数据 → 视图」的单行道,没有任何手工同步。

🛠 渐进式实战练习

练习 1 · 基础(20 min):订单状态统计面板

任务描述:实现 OrderStatsPanel:展示 7 种状态各自的订单数量;提供「通过全部待审核」按钮(一键把所有 pending_review 改为 approved)和「移除草稿」按钮。

提示:

  • 状态计数是派生数据,渲染时用 filter().length 或一次 reduce 算出;
  • 「通过全部」用 map 造新数组;「移除草稿」用 filter;
  • 计数展示用第 02 章的 STATUS_META 遍历,别写 7 行硬编码。

验收标准:

  • [ ] 7 种状态计数正确显示,操作后即时更新;
  • [ ] 「通过全部」只影响 pending_review 的订单,其他状态不变;
  • [ ] 全程没有出现 push / 直接改对象属性的写法。
参考答案
// src/components/OrderStatsPanel.jsx
import { useState } from 'react';
import { STATUS_META } from '../data/statusMeta';

const INITIAL_ORDERS = [
  { id: '1001', orderNo: 'QO20260929-000001', status: 'pending_review' },
  { id: '1002', orderNo: 'QO20260929-000002', status: 'pending_review' },
  { id: '1003', orderNo: 'QO20260929-000003', status: 'draft' },
  { id: '1004', orderNo: 'QO20260929-000004', status: 'approved' },
];

function OrderStatsPanel() {
  const [orders, setOrders] = useState(INITIAL_ORDERS);
  // 派生:一次遍历统计各状态数量
  const countByStatus = orders.reduce((acc, o) => {
    acc[o.status] = (acc[o.status] ?? 0) + 1;
    return acc;
  }, {});

  function approveAllPending() {
    setOrders((prev) =>
      prev.map((o) => (o.status === 'pending_review' ? { ...o, status: 'approved' } : o)),
    );
  }
  function removeDrafts() {
    setOrders((prev) => prev.filter((o) => o.status !== 'draft'));
  }
  return (
    <section className="stats-panel">
      <ul>
        {Object.entries(STATUS_META).map(([status, meta]) => (
          <li key={status}>
            {meta.label}:{countByStatus[status] ?? 0} 单
          </li>
        ))}
      </ul>
      <button onClick={approveAllPending}>通过全部待审核</button>
      <button onClick={removeDrafts}>移除草稿</button>
    </section>
  );
}

export default OrderStatsPanel;

练习 2 · 进阶(40 min):独立实现筛选联动

任务描述:不看 §4.8 的代码,独立实现 OrderBoard:关键词(防抖 300ms)+ 状态 + 分页驱动 fetchOrderList,含 loading 态与空态;用「快速翻页 + 快速输入」自测竞态保护是否生效。

提示:

  • 状态 → query(useMemo)→ useEffect 的三段式结构;
  • 竞态测试方法:连续快速点「下一页」10 次,最终列表必须是最后一页的数据;
  • ignore 标志是初级阶段最简竞态方案,AbortController 方案在第 05 章 §5.3。

验收标准:

  • [ ] 输入停止 300ms 后才触发过滤,输入过程中列表不闪;
  • [ ] 切换关键词 / 状态后回到第 1 页;
  • [ ] 快速连续翻页后展示的是最后一次请求的结果,无乱序。
参考答案

见 §4.8 完整实现。核心自查点:

// 三段式骨架(自查用)
const query = useMemo(() => ({ keyword: debouncedKeyword, status, page }), [debouncedKeyword, status, page]);
useEffect(() => {
  let ignore = false;
  fetchOrderList(query).then((data) => {
    if (!ignore) setResult(data);
  });
  return () => {
    ignore = true; // 每次依赖变化先「作废」上一次
  };
}, [query]);

若快速翻页后出现旧页数据覆盖新页数据,说明 ignore 未实现或 effect 依赖不完整。

练习 3 · 挑战(选做,60 min+):抽出自定义 Hook useOrderFilter

任务描述:把练习 2 的状态与请求逻辑抽成 useOrderFilter 自定义 Hook,组件里只剩渲染;再补一个 usePagination 管理页码与翻页边界。

提示:

  • Hook 返回「状态 + 处理函数」的普通对象:{ keyword, setKeyword, status, ..., orders, loading };
  • Hook 内部不 import 任何组件、不返回 JSX;
  • 抽完后用练习 2 的验收标准回归测试,行为必须完全一致。

验收标准:

  • [ ] useOrderFilter.js 内零 JSX,可被任意页面复用;
  • [ ] OrderBoard.jsx 中没有任何 fetch / 防抖 / 分页细节,只剩布局与渲染;
  • [ ] 回归测试全部通过。
参考答案(核心代码)
// src/hooks/useOrderFilter.js —— 筛选联动的逻辑收口
import { useEffect, useMemo, useState } from 'react';
import useDebouncedValue from './useDebouncedValue';
import { fetchOrderList } from '../api/orders'; // mock 函数挪到 api 层

const PAGE_SIZE = 20;

function useOrderFilter() {
  const [keyword, setKeyword] = useState('');
  const [status, setStatus] = useState('');
  const [page, setPage] = useState(1);
  const [result, setResult] = useState({ list: [], total: 0 });
  const [loading, setLoading] = useState(false);
  const debouncedKeyword = useDebouncedValue(keyword, 300);
  const query = useMemo(
    () => ({ keyword: debouncedKeyword, status, page }),
    [debouncedKeyword, status, page],
  );

  useEffect(() => {
    let ignore = false;
    setLoading(true);
    fetchOrderList(query).then((data) => {
      if (!ignore) {
        setResult(data);
        setLoading(false);
      }
    });
    return () => {
      ignore = true;
    };
  }, [query]);

  return {
    keyword,
    setKeyword: (v) => {
      setKeyword(v);
      setPage(1); // 筛选变化重置页码,逻辑收在 Hook 内
    },
    status,
    setStatus: (v) => {
      setStatus(v);
      setPage(1);
    },
    page,
    setPage,
    pageSize: PAGE_SIZE,
    orders: result.list,
    total: result.total,
    loading,
  };
}
export default useOrderFilter;
// src/pages/OrderBoard.jsx —— 组件只剩「渲染」
import useOrderFilter from '../hooks/useOrderFilter';
import StatusTag from '../components/StatusTag';

function OrderBoard() {
  const {
    keyword, setKeyword, status, setStatus,
    page, setPage, pageSize, orders, total, loading,
  } = useOrderFilter();
  const totalPages = Math.max(1, Math.ceil(total / pageSize));

  return (
    <div className="order-board">
      <input value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="搜索订单号 / 客户名" />
      <select value={status} onChange={(e) => setStatus(e.target.value)}>
        <option value="">全部状态</option>
        <option value="draft">草稿</option>
        <option value="pending_review">待审核</option>
      </select>
      {loading ? <p>加载中…</p> : null}
      <ul>
        {orders.map((o) => (
          <li key={o.id}>{o.orderNo} · {o.customerName} <StatusTag status={o.status} /></li>
        ))}
      </ul>
      <button disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>上一页</button>
      <button disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>下一页</button>
    </div>
  );
}
export default OrderBoard;

⚠️ 常见易错点

易错点 1:直接修改 state(可变更新)

// ❌ push / 直接改属性:引用没变,React 认为状态没变,界面不更新
function handleAdd(order) {
  orders.push(order); // 修改了原数组
  setOrders(orders);  // 同一个引用,重渲染被跳过
}
// ✅ 不可变更新:造新数组、新对象,引用变化驱动重渲染
function handleAdd(order) {
  setOrders((prev) => [order, ...prev]);
}

易错点 2:effect 依赖漏写

// ❌ effect 里用了 page,依赖却只写 [keyword]
// 现象:翻页后列表纹丝不动——effect 拿到的还是旧 page
useEffect(() => {
  fetchOrderList({ keyword, page }).then(setResult);
}, [keyword]);
// ✅ 用到的响应式值全部进依赖(eslint react-hooks/exhaustive-deps 会帮你盯)
useEffect(() => {
  fetchOrderList({ keyword, page }).then(setResult);
}, [keyword, page]);

易错点 3:setState 后立刻读旧值

// ❌ setPage 之后读到的 page 还是旧值:状态更新要等下一次渲染
function handleNext() {
  setPage(page + 1);
  fetchOrderList({ page }); // 这里 page 还是旧值!
}
// ✅ 让请求参数「从状态派生」,而不是在事件里手工拼接
const query = useMemo(() => ({ keyword, page }), [keyword, page]);
useEffect(() => {
  fetchOrderList(query).then(setResult);
}, [query]);

易错点 4:定时器 / 回调里的过期闭包

// ❌ 定时器闭包捕获了首次渲染的 count:setCount(count + 1) 永远算出 1
useEffect(() => {
  const timer = setInterval(() => setCount(count + 1), 1000);
  return () => clearInterval(timer);
}, []);
// ✅ 函数式更新基于最新值计算;或者把 count 写进依赖数组(代价是定时器重建)
useEffect(() => {
  const timer = setInterval(() => setCount((prev) => prev + 1), 1000);
  return () => clearInterval(timer);
}, []);

易错点 5:把「每次都是新引用」的值放进依赖

// ❌ query 每次渲染都是新对象:effect 每次渲染后都执行 → 请求风暴
const query = { keyword, status, page };
useEffect(() => {
  fetchOrderList(query).then(setResult);
}, [query]);
// ✅ useMemo 缓存参数对象;或把原始值逐个写进依赖
const query = useMemo(() => ({ keyword, status, page }), [keyword, status, page]);

✅ 最佳实践

  1. 一个 state 一件事:{ keyword, status, page } 拆成三个独立 state,更新逻辑互不牵连;真正需要原子性一起变的再合并。
  2. 派生值不入 state:计数、过滤、汇总在渲染时算;「存一份再同步」是 bug 之源。
  3. effect 只做与外部世界的同步:请求、订阅、DOM 副作用;纯数据转换留在渲染里。怀疑滥用时读官方文章 You Might Not Need an Effect。
  4. 依赖数组宁可多写,不可漏写:先写全让 ESLint 闭嘴,再做引用稳定优化(useMemo / useCallback)。
  5. 魔法数字收敛为常量:PAGE_SIZE = 20 放模块顶层并注释出处(附录A),评审时一眼可查。
  6. 逻辑复用走自定义 Hook:同一套「状态 + 副作用」出现在第二个组件之前,先忍住;出现时立刻抽 useXxx。

📌 本章小结

  1. Hooks 两条规则的本质是「调用顺序一致」;ESLint 插件是第一道防线。
  2. useState 的世界只有不可变更新:新增用展开、更新用 map、删除用 filter;依赖旧值时用函数式更新。
  3. 派生数据两档写法:便宜的直接算,昂贵的 useMemo;缓存是优化提示而非保证。
  4. useEffect = 与外部世界同步:依赖数组定时机、清理函数定退场;引用类型依赖要先用 useMemo / useCallback 稳定。
  5. 过期闭包 = 回调捕获旧快照:函数式更新是首选解法;逻辑复用抽 useXxx 自定义 Hook。

下一章预告:useEffect 的时机与清理还有更多讲究——组件的一生如何用「挂载 / 更新 / 卸载」三段表达?跨层共享的主题状态、受控弹窗、可复用表格,正是第 05 章的主题。

📄 第 05 章 · 副作用生命周期与组件通信 05-副作用生命周期与组件通信.md

📍 阶段:初级阶段(第 1–6 章)|⏱ 预计学时:4.5 小时|🔗 前置:第 04 章

⬅ 上一章:Hooks 核心 | 📖 本版目录 | ➡ 下一章:初级实战 · 企业门户首页

💡 对照学习:本页是 React 版第 05 章,对应 Vue 版第 05 章 · 生命周期与组件通信。概念映射:mounted / updated / unmounted ≈ useEffect 的三种依赖写法与清理函数、provide / inject ≈ Context、v-model 组件封装 ≈ 受控组件(value + onChange 下传)、<component :is> ≈ 「组件当值用」、递归组件两版同思路。

🎯 学习目标

学完本章,你将能够:

  1. 画出 React 组件「渲染 → 提交 → 副作用」的三段时序,并说出它与 Vue 生命周期钩子的对应关系;
  2. 为定时器、事件监听编写成对的清理函数,解释清理缺失在 StrictMode 下暴露的问题;
  3. 用 AbortController / ignore 标志处理异步竞态,保证「最后一次请求说了算」;
  4. 用 createContext + useContext 实现跨层通信(主题、当前用户),并说明 Context 的适用边界;
  5. 区分受控与非受控组件,为表单组件封装出 React 版 v-model(value + onChange 接口);
  6. 用「组件当值用」实现动态视图切换,用递归组件渲染不定层级的数据树;
  7. 独立实现企业场景:可复用 Table + Pagination、受控 ConfirmDialog。

📖 核心知识点

5.1 生命周期全景:从「钩子」到「同步」

Vue 提供了一组生命周期钩子;React 的函数组件只有一个心智模型——组件函数在每次渲染时重跑,副作用由 useEffect 在正确的时机同步:

setState / 父组件重渲染
   ▼
① 渲染(Render):调用组件函数,算出新 JSX 树(必须保持纯净,不动外部世界)
   ▼
② 提交(Commit):React 把差异更新到真实 DOM
   ▼
③ 副作用(Effect):执行 useEffect 回调(屏幕绘制之后)
   ▼
状态再变 → 回到 ①;组件卸载 → 执行清理函数

与 Vue 生命周期的对应关系(够用版):

Vue 钩子 触发时机 React 等价写法
mounted 挂载后 useEffect(fn, [])
updated(数据变化后) 响应式更新后 useEffect(fn, [deps])
unmounted / beforeUnmount 卸载前后 useEffect 返回的清理函数
watch + 手动取消 监听 + 清理 useEffect 全套(依赖数组 + cleanup)

一个关键事实:开发模式下 <StrictMode> 会把每个组件「挂载 → 卸载 → 再挂载」跑两遍,用来主动暴露「没写清理函数」的副作用。所以你在 DevTools 里看到 effect 执行两次,不是 bug,是 React 在帮你做压力测试(详见 react.dev 文档)。写好清理函数,双挂载零感知;不写清理,双挂载立刻现形。

5.2 副作用清理:定时器与事件监听

清理函数的纪律:有订阅就要有退订,成对出现。

import { useEffect, useState } from 'react';

function ElapsedTimer() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => setSeconds((s) => s + 1), 1000);
    // ✅ 卸载时停表:避免内存泄漏与「组件已卸载还在 setState」的幽灵更新
    return () => clearInterval(timer);
  }, []);

  return <p>本次会话已浏览 {seconds} 秒</p>;
}

事件监听的清理要「同一个函数引用才能移除」:

function useWindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    function handleResize() {
      setWidth(window.innerWidth);
    }
    window.addEventListener('resize', handleResize);
    // ✅ 移除时必须是同一个引用(所以 handler 要提到外面,不能写箭头内联两次)
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return width;
}

不清理的后果清单:定时器越积越多、监听器叠加触发、组件卸载后还在更新不存在的状态、StrictMode 下双份订阅导致「一次点击触发两次」。code review 看到 addEventListener / setInterval / setTimeout 后面没有 return 清理,直接打回。

5.3 异步副作用与竞态处理

两个纪律:effect 回调不能是 async 函数(它必须返回清理函数或 nothing);请求要能「作废」:

import { useEffect, useState } from 'react';

function OrderDetail({ orderId }) {
  const [order, setOrder] = useState(null);

  useEffect(() => {
    const controller = new AbortController(); // 每个 orderId 一张「作废票」

    async function load() {
      try {
        const res = await fetch(`/api/orders/${orderId}`, { signal: controller.signal });
        const data = await res.json();
        setOrder(data);
      } catch (err) {
        if (err.name !== 'AbortError') {
          console.error('加载订单失败', err); // 主动取消不算错误
        }
      }
    }
    load();
    return () => controller.abort(); // orderId 变化 / 卸载:上一个请求作废
  }, [orderId]);

  if (!order) return <p>加载中…</p>;
  return <h2>{order.orderNo}</h2>;
}

没有 abort 时,快速切换 orderId 可能出现「慢的旧请求后到,覆盖新数据」。初级阶段用第 04 章的 ignore 标志也够;进入中级(第 09 章)后,这类「请求缓存 + 竞态」整体交给 TanStack Query。

5.4 跨层通信:Context

props 要一层层传七八层才能到深层组件(「props 钻井」),Vue 用 provide / inject,React 用 Context:

// src/context/ThemeContext.jsx
import { createContext, useContext, useMemo, useState } from 'react';

const ThemeContext = createContext(null); // 没有 Provider 时的兜底值

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');
  // ✅ useMemo 缓存 value:theme 不变就不产生新对象(易错点 3)
  const value = useMemo(() => ({ theme, setTheme }), [theme]);
  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}

export function useTheme() {
  const ctx = useContext(ThemeContext); // ≈ Vue 的 inject
  if (!ctx) throw new Error('useTheme 必须在 <ThemeProvider> 内使用');
  return ctx;
}
// 任意深层的组件都能拿到主题,无需层层传 props
function ThemeButton() {
  const { theme, setTheme } = useTheme();
  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      当前主题:{theme}(点击切换)
    </button>
  );
}

// 应用根部包一层 Provider
<ThemeProvider>
  <App />
</ThemeProvider>

Context 的适用边界,初级阶段记三条:

  • 适合:低频变化的全局 UI 态——主题、语言、当前登录用户、弹窗容器;
  • 不适合:高频变化的业务数据(订单列表等)——所有消费者会一起重渲染,那是第 08 章状态库的活;
  • 封装习惯:createContext + Provider + useXxx 三件套放一个文件,消费方只 import Hook,不直接碰 Context。

5.5 受控与非受控组件

第 02 章的受控输入升级到组件设计层面:受控组件 = value 进、onChange 出——这正是 Vue「v-model 组件封装」的 React 对应物:

// src/components/OrderRemark.jsx —— 受控封装:自己不存值,值的真相在父级
function OrderRemark({ value, onChange }) {
  return (
    <label>
      订单备注:
      <textarea value={value} onChange={(e) => onChange(e.target.value)} rows={3} />
    </label>
  );
}

// 父组件持有唯一数据源
function NewOrderForm() {
  const [remark, setRemark] = useState('');
  return <OrderRemark value={remark} onChange={setRemark} />;
}

反向选择是非受控:DOM 自己管值,提交时一次性读取,适合文件输入、超大表单的性能豁免:

import { useRef } from 'react';

function QuickSearch() {
  const inputRef = useRef(null);

  function handleSubmit(e) {
    e.preventDefault();
    console.log('提交关键词:', inputRef.current.value); // 用时才读
  }
  return (
    <form onSubmit={handleSubmit}>
      <input defaultValue="" ref={inputRef} name="keyword" />
      <button type="submit">搜索</button>
    </form>
  );
}
维度 受控 非受控
数据源 React 状态(唯一真相) DOM 内部值
实时校验 / 联动 天然支持 需额外订阅
代码量 略多 少
选型建议 表单字段、筛选条件(默认) 文件输入、超大表单一次性提交

React 19 起,函数组件的 ref 可以直接作为普通 prop 接收并转发,不再需要 forwardRef 包裹;旧项目里的 forwardRef 依然兼容。细节见 React 19 发布说明。

5.6 组合模式:动态组件与递归组件

Vue 的 <component :is> 在 React 里是「组件当值用」——组件本来就是函数/变量:

const TAB_VIEWS = {
  list: OrderList,   // 组件作为值存进配置
  stats: OrderStats,
};

function OrderTabs({ activeTab }) {
  const CurrentView = TAB_VIEWS[activeTab] ?? OrderList;
  // 大写变量直接当标签渲染
  // 注意:切换组件类型 = 换「人」= 状态清空(第 02 章 §2.7 的 diff 直觉)
  // 若要各 Tab 保留状态,可全部渲染并用 CSS 显隐(v-show 思路)
  return <CurrentView />;
}

递归组件渲染不定层级的数据(商品分类树、组织架构):

// src/components/CategoryNode.jsx —— 自己渲染自己,深度驱动缩进
import { useState } from 'react';
function CategoryNode({ node, depth = 0 }) {
  const [open, setOpen] = useState(depth < 1); // 第一层默认展开

  return (
    <div style={{ paddingLeft: depth * 16 }}>
      <button onClick={() => setOpen(!open)}>
        {node.children?.length ? (open ? '▼' : '▶') : '•'} {node.name}
      </button>
      {open &&
        node.children?.map((child) => (
          <CategoryNode key={child.id} node={child} depth={depth + 1} />
        ))}
    </div>
  );
}

export default CategoryNode;

5.7 业务组件落地:Table + Pagination + ConfirmDialog

通用表格:列配置驱动 + render 函数(Ant Design / Element Plus 同款思路的极简版,render 即「作用域插槽」):

// src/components/Table.jsx
function Table({ columns, rows, emptyText = '暂无数据' }) {
  if (rows.length === 0) return <p className="table-empty">{emptyText}</p>;
  return (
    <table className="table">
      <thead>
        <tr>
          {columns.map((col) => (
            <th key={col.key}>{col.title}</th>
          ))}
        </tr>
      </thead>
      <tbody>
        {rows.map((row) => (
          <tr key={row.id}>
            {columns.map((col) => (
              <td key={col.key}>
                {/* render 优先:调用方决定单元格内容;否则直接取字段 */}
                {col.render ? col.render(row) : row[col.key]}
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
}

export default Table;
// src/pages/OrderBoard.jsx 中的列配置与使用
const ORDER_COLUMNS = [
  { key: 'orderNo', title: '订单号' },
  { key: 'customerName', title: '客户' },
  { key: 'status', title: '状态', render: (order) => <StatusTag status={order.status} /> },
  {
    key: 'totalAmount',
    title: '金额',
    render: (order) => <strong>¥{(order.totalAmount / 100).toFixed(2)}</strong>,
  },
];

<Table columns={ORDER_COLUMNS} rows={orders} emptyText="没有匹配的订单" />

分页器:受控数值 + 回调上抛,边界由组件内部兜底:

// src/components/Pagination.jsx
function Pagination({ page, pageSize, total, onPageChange }) {
  const totalPages = Math.max(1, Math.ceil(total / pageSize));
  return (
    <nav className="pagination" aria-label="分页">
      <button disabled={page <= 1} onClick={() => onPageChange(page - 1)}>
        上一页
      </button>
      <span>
        第 {page} / {totalPages} 页(共 {total} 条)
      </span>
      <button disabled={page >= totalPages} onClick={() => onPageChange(page + 1)}>
        下一页
      </button>
    </nav>
  );
}

export default Pagination;

确认弹窗:显隐状态由父组件持有(受控模式),内容区用 children 组合:

// src/components/ConfirmDialog.jsx
function ConfirmDialog({ open, title, children, onConfirm, onCancel }) {
  if (!open) return null; // 关闭即不渲染
  return (
    <div className="dialog-mask" onClick={onCancel}>
      <div
        className="dialog"
        role="dialog"
        aria-modal="true"
        onClick={(e) => e.stopPropagation()} // 点内容区不关闭
      >
        <h3>{title}</h3>
        <div className="dialog__body">{children}</div>
        <footer className="dialog__footer">
          <button onClick={onCancel}>取消</button>
          <button className="btn-danger" onClick={onConfirm}>确认</button>
        </footer>
      </div>
    </div>
  );
}
export default ConfirmDialog;
// 使用:附录A 约定「取消订单必须填原因并记日志」,先做确认流骨架
function OrderRowActions({ order }) {
  const [confirming, setConfirming] = useState(false);

  function handleConfirm() {
    console.log('取消订单', order.id); // 中级阶段接真实 API
    setConfirming(false);
  }

  return (
    <>
      <button onClick={() => setConfirming(true)}>取消订单</button>
      <ConfirmDialog
        open={confirming}
        title={`确认取消订单 ${order.orderNo}?`}
        onConfirm={handleConfirm}
        onCancel={() => setConfirming(false)}
      >
        <p>取消后需填写原因,该操作会记入操作日志。</p>
      </ConfirmDialog>
    </>
  );
}

🛠 渐进式实战练习

练习 1 · 基础(20 min):受控弹窗接入订单列表

任务描述:基于 §5.7 的 ConfirmDialog,为订单列表的每一行接入「取消订单」确认流:点击按钮 → 弹窗出现 → 确认后该行状态变为 cancelled(不可变更新)→ 弹窗关闭。

提示:

  • confirming 状态放在行组件里;确认逻辑回调给父级改数据;
  • 弹窗打开时页面其余内容应不可操作(遮罩已实现,检查点击穿透);
  • 确认按钮文案含订单号,验证 props 传递链路。

验收标准:

  • [ ] 点击「取消订单」出现弹窗,点遮罩或「取消」关闭,点「确认」执行变更;
  • [ ] 变更后行内状态标签更新为「已取消」,且用的是不可变更新;
  • [ ] ConfirmDialog 组件内部零业务逻辑,可原样搬到其他页面复用。
参考答案(关键部分)
// src/components/OrderRow.jsx
import { useState } from 'react';
import ConfirmDialog from './ConfirmDialog';
import StatusTag from './StatusTag';

function OrderRow({ order, onCancel }) {
  const [confirming, setConfirming] = useState(false);

  return (
    <li className="order-row">
      <span>{order.orderNo}</span>
      <StatusTag status={order.status} />
      <button onClick={() => setConfirming(true)}>取消订单</button>
      <ConfirmDialog
        open={confirming}
        title={`确认取消订单 ${order.orderNo}?`}
        onConfirm={() => {
          onCancel(order.id);
          setConfirming(false);
        }}
        onCancel={() => setConfirming(false)}
      >
        <p>取消后需填写原因,该操作会记入操作日志。</p>
      </ConfirmDialog>
    </li>
  );
}
export default OrderRow;
// 父组件中的取消逻辑(不可变更新)
function handleCancel(orderId) {
  setOrders((prev) => prev.map((o) => (o.id === orderId ? { ...o, status: 'cancelled' } : o)));
}

练习 2 · 进阶(45 min):Table + Pagination 组合成订单列表页

任务描述:把 §5.7 的 Table 与 Pagination 组合进 OrderBoard:列配置含状态标签与金额(分转元);接上分页状态;数据为空时展示空态文案。

提示:

  • page 状态放 OrderBoard,onPageChange 直接传 setPage;
  • 列配置数组定义在组件外(它不含状态,无需每次渲染重建);
  • 组合后用「翻页 → 状态标签颜色正确」验证 render 函数生效。

验收标准:

  • [ ] 表格四列渲染正确,状态列是彩色标签、金额列保留两位小数;
  • [ ] 「上一页 / 下一页」在边界处禁用;
  • [ ] 列表为空时显示 emptyText,分页器仍然可用且显示正确总数。
参考答案(组装部分)
// src/pages/OrderBoard.jsx
import { useState } from 'react';
import Table from '../components/Table';
import Pagination from '../components/Pagination';
import StatusTag from '../components/StatusTag';
import { MOCK_ORDERS } from '../data/mockOrders';

const PAGE_SIZE = 20;

const ORDER_COLUMNS = [
  { key: 'orderNo', title: '订单号' },
  { key: 'customerName', title: '客户' },
  { key: 'status', title: '状态', render: (order) => <StatusTag status={order.status} /> },
  {
    key: 'totalAmount',
    title: '金额',
    render: (order) => <strong>¥{(order.totalAmount / 100).toFixed(2)}</strong>,
  },
];

function OrderBoard() {
  const [page, setPage] = useState(1);
  const start = (page - 1) * PAGE_SIZE;
  const pageRows = MOCK_ORDERS.slice(start, start + PAGE_SIZE); // 教学:本地分页

  return (
    <div className="order-board">
      <Table columns={ORDER_COLUMNS} rows={pageRows} emptyText="没有匹配的订单" />
      <Pagination page={page} pageSize={PAGE_SIZE} total={MOCK_ORDERS.length} onPageChange={setPage} />
    </div>
  );
}

export default OrderBoard;

练习 3 · 挑战(选做,60 min+):主题 Context + 键盘无障碍

任务描述:把 §5.4 的 ThemeContext 补完整:主题初始值从 localStorage 读取(异常兜底 light);useEffect 把主题同步到 <html data-theme> 并持久化;给 ConfirmDialog 加「按 ESC 关闭」——注意用带清理的事件监听。

提示:

  • 读写 localStorage 都要 try/catch(隐私模式会抛错);
  • ESC 监听在 open === true 时才挂,依赖数组写 [open, onCancel];
  • 验证 StrictMode 下控制台没有「监听器泄漏」迹象(切几次弹窗,DevTools 的 Event Listeners 面板只有一份)。

验收标准:

  • [ ] 刷新页面主题保持;清掉 localStorage 后回到 light;
  • [ ] 弹窗打开时按 ESC 关闭,关闭后监听器被移除;
  • [ ] useTheme() 在 Provider 外调用时抛出可读的错误提示。
参考答案(核心代码)
// src/context/ThemeContext.jsx(完整版)
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
const ThemeContext = createContext(null);
const THEME_STORAGE_KEY = 'qingorder-theme';

function readInitialTheme() {
  try {
    const saved = localStorage.getItem(THEME_STORAGE_KEY);
    if (saved === 'light' || saved === 'dark') return saved; // 白名单校验,防脏数据
  } catch {
    /* localStorage 不可用(如隐私模式):走兜底 */
  }
  return 'light';
}

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState(readInitialTheme);

  // 副作用:state → DOM + 持久化
  useEffect(() => {
    document.documentElement.dataset.theme = theme;
    try {
      localStorage.setItem(THEME_STORAGE_KEY, theme);
    } catch {
      /* 写入失败不阻塞 UI */
    }
  }, [theme]);

  const value = useMemo(() => ({ theme, setTheme }), [theme]);
  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}

export function useTheme() {
  const ctx = useContext(ThemeContext);
  if (!ctx) throw new Error('useTheme 必须在 <ThemeProvider> 内使用');
  return ctx;
}
// ConfirmDialog 的 ESC 关闭(组件内部新增)
import { useEffect } from 'react';

function ConfirmDialog({ open, title, children, onConfirm, onCancel }) {
  useEffect(() => {
    if (!open) return; // 关闭状态不挂监听
    function handleKeydown(e) {
      if (e.key === 'Escape') onCancel();
    }
    window.addEventListener('keydown', handleKeydown);
    return () => window.removeEventListener('keydown', handleKeydown);
  }, [open, onCancel]); // onCancel 变化时同步重建监听

  if (!open) return null;
  // ……其余同 §5.7
}
export default ConfirmDialog;

⚠️ 常见易错点

易错点 1:订阅不清理

// ❌ 只订阅不退订:组件反复进出页面后监听器越积越多,StrictMode 下一次挂载订阅两份
useEffect(() => {
  window.addEventListener('resize', handleResize);
}, []);
// ✅ 订阅与退订成对出现,且用同一个函数引用
useEffect(() => {
  window.addEventListener('resize', handleResize);
  return () => window.removeEventListener('resize', handleResize);
}, []);

易错点 2:effect 回调写成 async

// ❌ useEffect 的回调必须是同步函数:async 函数会返回 Promise,React 会把它当清理函数
useEffect(async () => {
  const data = await fetchOrderList(query);
  setResult(data);
}, [query]);
// ✅ 内部定义 async 函数再调用,effect 本身保持同步
useEffect(() => {
  async function load() {
    const data = await fetchOrderList(query);
    setResult(data);
  }
  load();
}, [query]);

易错点 3:Context value 每次渲染都是新对象

// ❌ value 内联对象字面量:每次渲染都是新引用,所有消费者被迫跟着重渲染
<ThemeContext.Provider value={{ theme, setTheme }}>
  {children}
</ThemeContext.Provider>
// ✅ useMemo 缓存 value:theme 不变就给同一个引用
const value = useMemo(() => ({ theme, setTheme }), [theme]);
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>

易错点 4:没有依赖数组引发死循环

// ❌ 忘写依赖数组:每次渲染后执行 → setState 又触发渲染 → 再执行 → 死循环
useEffect(() => {
  setLogs(fetchLogsSync());
});
// ✅ 明确依赖:只在相关数据变化时同步;同时自问「这真的需要存 state 吗」
useEffect(() => {
  setLogs(fetchLogsSync(orderId));
}, [orderId]);

易错点 5:受控链路断裂

// ❌ 封装了受控组件,父级却没接 onChange:输入框永远是死的
// 现象:敲键盘毫无反应,控制台告警 value 无 onChange
<OrderRemark value={remark} />
// ✅ value 与 onChange 成对下传,数据单向流动,父级仍是唯一真相
<OrderRemark value={remark} onChange={setRemark} />

✅ 最佳实践

  1. 清理是副作用的另一半:定时器、监听器、订阅、请求Abort,写副作用时先想「退场时怎么撤销」。
  2. 一个 effect 只做一件事:同步主题与上报埋点拆成两个 effect,依赖数组各自最小化。
  3. Context 只装低频全局态:主题 / 语言 / 当前用户;高频业务数据走 props 或(中级起)状态库与请求库。
  4. Context 三件套封装:createContext + Provider + useXxx 收在一个文件,消费方只见 Hook 不见 Context。
  5. 表单组件默认受控:value + onChange 是团队约定的「v-model 接口」;非受控仅用于文件输入等豁免场景,并在 JSDoc 标注。
  6. 弹窗类组件一律受控显隐:开合权在父级,组件只负责呈现与回调,这样才有动画与联动设计的空间。

📌 本章小结

  1. React 没有「生命周期钩子数组」,有的是「渲染 → 提交 → 副作用」三段时序 + 清理函数;mounted/updated/unmounted 全部映射到 useEffect 的三种写法。
  2. StrictMode 开发期双挂载是「清理函数体检仪」:订阅与退订成对,双挂载零感知。
  3. 异步副作用两条纪律:effect 本体保持同步;用 ignore 标志或 AbortController 保证最后一次请求说了算。
  4. 跨层通信用 Context(≈ provide/inject),value 用 useMemo 稳定;受控组件(value + onChange)是 React 版 v-model 封装。
  5. 「组件当值用」替代动态组件,递归组件渲染树形数据;Table(列配置 + render)、Pagination、ConfirmDialog 是本阶段的组件设计毕业作。

下一章预告:零件都齐了。第 06 章把它们组装成完整的企业门户首页——组件树设计先行、目录组织、暗色主题、FAQ 手风琴,并附上 props 接口评审清单与验收标准。

📄 第 06 章 · 初级实战 —— 企业门户首页 06-初级实战-企业门户首页.md

📍 阶段:初级阶段(第 1–6 章)|⏱ 预计学时:6 小时|🔗 前置:第 05 章 + 附录A

⬅ 上一章:副作用生命周期与组件通信 | 📖 本版目录 | ➡ 下一章:第二部分 · 路由系统

💡 对照学习:本页是 React 版第 06 章,对应 Vue 版第 06 章 · 初级实战。两版练习任务完全一致(导航 / Banner / 特性区 / FAQ 手风琴 / 页脚 / 暗色主题切换),可对照实现差异。概念映射:App.vue ≈ App.jsx、<script setup> 组合式函数 ≈ 自定义 Hook、provide/inject ≈ Context、v-model 封装 ≈ 受控组件。

🎯 学习目标

学完本章,你将能够:

  1. 从需求出发先画组件树,再动手编码,并解释每个组件的职责边界;
  2. 按 components/ / data/ / hooks/ 的约定组织项目目录,做到「改文案不碰组件、改逻辑不碰样式」;
  3. 用「数据配置驱动」实现导航、特性区与 FAQ 内容,新增内容零组件改动;
  4. 实现单开模式的 FAQ 手风琴,并使用 aria-expanded 完成基础无障碍;
  5. 实现 useTheme 自定义 Hook 驱动的暗色主题:CSS 变量 + data-theme + localStorage 持久化;
  6. 用本章的「props 接口设计评审清单」审查自己与同伴的组件 API;
  7. 对照验收标准完成门户首页整页联调,并通过初级阶段能力自查。

📖 核心知识点

6.1 需求分析与组件树设计先行

附录A 一期需求:面向潜在客户的品牌官网首页(导航、产品介绍、联系方式),目标 LCP ≤ 2.0s。初级阶段的实现范围是静态内容 + 两个交互(FAQ 手风琴、主题切换)。

先画组件树,再写代码——这一步在企业里叫「拆页评审」,5 分钟能省掉半天返工:

App(只做拼装 + 全局状态)
├── NavBar
│   ├── Logo(内联文本即可)
│   ├── NavLinks(data 驱动锚点链接)
│   └── ThemeToggle(接收 theme + onToggle)
├── main
│   ├── Banner(主视觉 + CTA)
│   ├── FeatureGrid
│   │   └── FeatureCard × 6(map 渲染)
│   ├── FaqAccordion(持有 openId 状态)
│   │   └── FaqItem × N(展开 / 收起)
│   └── (联系方式区块并入 Footer)
└── Footer(商务信息 + 备案占位)

设计决策记录(每个决策都能追溯 to 前五章):

  • 主题状态放 App(useTheme Hook),通过 props 下发——全局唯一数据源(第 03 章 §3.3);
  • FAQ 开合状态收在 FaqAccordion 内部——外部无人需要它(第 03 章 §3.5 状态下沉);
  • 文案与列表数据全部进 data/ 目录——配置驱动(第 02 章 §2.5);
  • FeatureCard、FaqItem 保持纯展示组件——零状态、零副作用(第 05 章受控思想)。

6.2 项目目录组织

src/
├── main.jsx                  # 入口:createRoot + StrictMode
├── App.jsx                   # 根组件:只做拼装与全局状态
├── index.css                 # 全局样式 + CSS 变量(主题令牌)
├── data/                     # 内容配置:改文案不碰组件
│   ├── navLinks.js
│   ├── features.js
│   └── faqs.js
├── hooks/                    # 可复用逻辑:状态 + 副作用
│   └── useTheme.js
└── components/               # 展示组件:一文件一组件
    ├── NavBar.jsx
    ├── ThemeToggle.jsx
    ├── Banner.jsx
    ├── FeatureGrid.jsx
    ├── FeatureCard.jsx
    ├── FaqAccordion.jsx
    └── Footer.jsx

约定解读:data/ 是「内容」、hooks/ 是「逻辑」、components/ 是「视图」。三者的依赖方向永远是从视图 import 逻辑与内容,不允许反向(data/ 里绝不 import 组件)。

6.3 数据层:把内容变成配置

// src/data/navLinks.js
export const NAV_LINKS = [
  { id: 'features', label: '产品能力' },
  { id: 'faq', label: '常见问题' },
  { id: 'contact', label: '联系我们' },
];
// src/data/features.js —— 文案取材于附录A 的产品能力
export const FEATURES = [
  { id: 'f1', icon: '📦', title: '订单全生命周期', desc: '草稿到完成,7 种状态流转全程留痕。' },
  { id: 'f2', icon: '🔍', title: '多维筛选检索', desc: '关键词、状态、日期、销售归属,秒级定位订单。' },
  { id: 'f3', icon: '🛡️', title: '三级权限管控', desc: '菜单、页面、按钮三级权限,杜绝越权操作。' },
  { id: 'f4', icon: '📊', title: '经营报表', desc: '按销售、按月、按商品聚合,一键导出。' },
  { id: 'f5', icon: '⚡', title: '万级列表流畅', desc: '虚拟滚动加持,大促期间也不卡顿。' },
  { id: 'f6', icon: '🧾', title: '财务对账友好', desc: '金额以「分」存储,折扣审批全程可审计。' },
];
// src/data/faqs.js
export const FAQS = [
  { id: 'q1', q: '轻订单适合什么规模的团队?', a: '从 5 人销售团队到千人分销体系均可使用,权限模型可按角色裁剪。' },
  { id: 'q2', q: '支持哪些部署方式?', a: '提供 SaaS 与私有化两种交付形态,私有化支持内网部署。' },
  { id: 'q3', q: '订单数据如何保障安全?', a: '传输全程 TLS,关键操作二次确认并写入审计日志。' },
  { id: 'q4', q: '能否对接现有 ERP?', a: '开放 REST API 与 Webhook,支持与主流 ERP 集成。' },
];

6.4 NavBar 与 ThemeToggle:状态上提 + props 下发

主题逻辑收口到 useTheme(第 05 章挑战练习的正式版),副作用同步 DOM 与持久化:

// src/hooks/useTheme.js —— 主题 = 状态 + 同步副作用 + 持久化
import { useEffect, useState } from 'react';

const THEME_STORAGE_KEY = 'qingorder-theme';

function readInitialTheme() {
  try {
    const saved = localStorage.getItem(THEME_STORAGE_KEY);
    if (saved === 'light' || saved === 'dark') return saved; // 白名单校验
  } catch {
    /* localStorage 不可用(隐私模式):走兜底 */
  }
  return 'light';
}

function useTheme() {
  const [theme, setTheme] = useState(readInitialTheme);

  // 副作用:state → <html data-theme> + 持久化(渲染保持纯净)
  useEffect(() => {
    document.documentElement.dataset.theme = theme;
    try {
      localStorage.setItem(THEME_STORAGE_KEY, theme);
    } catch {
      /* 写入失败不阻塞 UI */
    }
  }, [theme]);

  function toggleTheme() {
    setTheme((t) => (t === 'light' ? 'dark' : 'light'));
  }

  return { theme, toggleTheme };
}

export default useTheme;

组件层只认 props,不关心主题从哪来(复用第 03 章「受控 + 上抛」模式):

// src/components/ThemeToggle.jsx
function ThemeToggle({ theme, onToggle }) {
  return (
    <button className="theme-toggle" onClick={onToggle} aria-label="切换深色模式">
      {theme === 'light' ? '深色模式' : '浅色模式'}
    </button>
  );
}

export default ThemeToggle;
// src/components/NavBar.jsx
import { NAV_LINKS } from '../data/navLinks';
import ThemeToggle from './ThemeToggle';

function NavBar({ activeId, onNavigate, theme, onToggleTheme }) {
  return (
    <header className="navbar">
      <a className="navbar__logo" href="#top">轻订单</a>
      <nav className="navbar__links" aria-label="主导航">
        {NAV_LINKS.map((link) => (
          <a
            key={link.id}
            href={`#${link.id}`}
            className={activeId === link.id ? 'is-active' : ''}
            onClick={() => onNavigate(link.id)}
          >
            {link.label}
          </a>
        ))}
      </nav>
      <ThemeToggle theme={theme} onToggle={onToggleTheme} />
    </header>
  );
}

export default NavBar;

主题的「皮肤」由 CSS 变量承担——组件与样式通过变量解耦,暗色只是一张变量覆盖表:

/* src/index.css —— 主题令牌:组件只认变量,不认具体颜色 */
:root {
  --color-bg: #ffffff;
  --color-text: #1f2328;
  --color-primary: #2563eb;
  --color-card: #f6f8fa;
}

[data-theme='dark'] {
  --color-bg: #0d1117;
  --color-text: #e6edf3;
  --color-primary: #3b82f6;
  --color-card: #161b22;
}

body {
  background: var(--color-bg);
  color: var(--color-text);
  transition: background 0.2s ease, color 0.2s ease;
}

.navbar {
  display: flex;
  align-items: center;
  gap: 24px;
  padding: 12px 32px;
  border-bottom: 1px solid var(--color-card);
}

.feature-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 16px;
  padding: 48px 32px;
}

.feature-card {
  background: var(--color-card);
  border-radius: 12px;
  padding: 20px;
}

.faq__question {
  width: 100%;
  text-align: left;
  padding: 12px 0;
  background: none;
  border: none;
  color: var(--color-text);
  font-size: 16px;
  cursor: pointer;
}

.faq__answer {
  margin: 0 0 12px;
  color: var(--color-text);
  opacity: 0.75;
}

6.5 Banner 与 FeatureGrid:纯展示组件

// src/components/Banner.jsx
function Banner() {
  return (
    <section className="banner" id="top">
      <h1 className="banner__title">让每一张 B2B 订单都被看见</h1>
      <p className="banner__desc">
        轻订单 · 青云科技自研订单管理系统:接单、审核、发货、对账,全流程数字化。
      </p>
      <a className="banner__cta" href="#contact">预约演示</a>
    </section>
  );
}

export default Banner;
// src/components/FeatureCard.jsx —— 零状态展示组件:props 进、JSX 出
function FeatureCard({ feature }) {
  return (
    <article className="feature-card">
      <div className="feature-card__icon" aria-hidden="true">{feature.icon}</div>
      <h3>{feature.title}</h3>
      <p>{feature.desc}</p>
    </article>
  );
}

export default FeatureCard;
// src/components/FeatureGrid.jsx —— 容器只负责 map + key
import { FEATURES } from '../data/features';
import FeatureCard from './FeatureCard';

function FeatureGrid() {
  return (
    <section className="feature-grid" id="features" aria-label="产品能力">
      {FEATURES.map((feature) => (
        <FeatureCard key={feature.id} feature={feature} />
      ))}
    </section>
  );
}

export default FeatureGrid;

6.6 FaqAccordion:单开手风琴

交互设计决策:同一时刻只展开一项(openId 收敛为单值状态,天然互斥);再次点击收起全部:

// src/components/FaqAccordion.jsx
import { useState } from 'react';
import { FAQS } from '../data/faqs';

function FaqAccordion() {
  const [openId, setOpenId] = useState(null); // 当前展开项;null = 全部收起

  return (
    <section className="faq" id="faq">
      <h2>常见问题</h2>
      {FAQS.map((faq) => {
        const isOpen = openId === faq.id;
        return (
          <div className="faq__item" key={faq.id}>
            <button
              className="faq__question"
              aria-expanded={isOpen} // 无障碍:告诉读屏器当前展开状态
              onClick={() => setOpenId(isOpen ? null : faq.id)}
            >
              {faq.q} {isOpen ? '▲' : '▼'}
            </button>
            {isOpen && <p className="faq__answer">{faq.a}</p>}
          </div>
        );
      })}
    </section>
  );
}

export default FaqAccordion;

要点回顾:UI 状态(openId)归组件,内容数据(FAQS)保持只读——不要把 open 塞进数据数组再直接改(易错点 3)。若产品后续要求「可多开」,把状态换成 Set 做不可变更新即可,组件结构不变。

6.7 Footer 与页面组装

// src/components/Footer.jsx
function Footer() {
  return (
    <footer className="footer" id="contact">
      <p>青云科技 · 轻订单(教学虚构项目)</p>
      <p>商务合作:demo@qingorder.example.com</p>
      <p>© 2026 Qingyun Tech. 本页面为教程演示,非真实产品。</p>
    </footer>
  );
}

export default Footer;
// src/App.jsx —— 根组件只做拼装:全局状态在此,交互逻辑已收进 Hook
import { useState } from 'react';
import NavBar from './components/NavBar';
import Banner from './components/Banner';
import FeatureGrid from './components/FeatureGrid';
import FaqAccordion from './components/FaqAccordion';
import Footer from './components/Footer';
import useTheme from './hooks/useTheme';

function App() {
  const { theme, toggleTheme } = useTheme();
  const [activeId, setActiveId] = useState('features');

  return (
    <div className="page">
      <NavBar activeId={activeId} onNavigate={setActiveId} theme={theme} onToggleTheme={toggleTheme} />
      <main>
        <Banner />
        <FeatureGrid />
        <FaqAccordion />
      </main>
      <Footer />
    </div>
  );
}

export default App;

6.8 props 接口设计评审清单

企业 code review 中,组件 API 先于实现被评审。提交前逐条自检:

  • [ ] 命名词法统一:数据用名词(order、feature),回调 on + 动词(onToggle、onNavigate),布尔带前缀(isActive、onlyMine);
  • [ ] 单向数据流无破坏:子组件零 mutation,所有变更经回调上抛;
  • [ ] 可选 props 有默认值,必传 props 有 JSDoc 类型注释;
  • [ ] 回调参数写进文档:onNavigate(linkId) 的参数含义与类型可从 JSDoc 一眼读出;
  • [ ] 列表 key 有保障:由调用方数据提供稳定唯一 ID,组件内部不生成随机 key;
  • [ ] 纯展示组件零副作用:FeatureCard / FaqItem 这类组件不碰 DOM、不发请求、不写存储;
  • [ ] 状态放对位置:全局态(主题)在顶层,局部态(openId、confirming)在最近消费者;
  • [ ] 配置与视图分离:新增一条 FAQ 只改 data/faqs.js,零组件改动。

6.9 验收标准

按附录A「门户首页」需求逐条验收,全部通过才算完成初级阶段实战:

  • [ ] pnpm dev 启动无报错,五个区块(导航 / Banner / 特性区 / FAQ / 页脚)完整呈现;
  • [ ] 导航锚点可滚动到对应区块,当前区块高亮;
  • [ ] 特性区 6 张卡片响应式排布(窄屏单列,宽屏多列);
  • [ ] FAQ 同一时刻至多展开一项,再次点击可收起,aria-expanded 状态正确;
  • [ ] 暗色主题切换即时生效,刷新页面后保持上次选择;
  • [ ] 控制台无 error 与 warning;pnpm lint 通过;
  • [ ] 目录结构与 §6.2 一致,所有文案可在 data/ 目录中找到;
  • [ ] pnpm build 构建成功,pnpm preview 预览功能无回归。

🛠 渐进式实战练习

练习 1 · 基础(30 min):页面骨架 —— NavBar + Banner + Footer

任务描述:从空项目(或本章示例仓库)出发,先完成三个静态区块:数据驱动的 NavBar(含锚点与高亮)、Banner(标题 + slogan + CTA)、Footer。主题切换暂用占位按钮。

提示:

  • 先建 data/navLinks.js,再写组件——「内容先行」是本章的肌肉记忆;
  • activeId 状态放 App,NavBar 是受控组件;
  • 样式只需要「能看出结构」,不必追求像素级还原。

验收标准:

  • [ ] 三个区块渲染完整,导航点击后锚点滚动 + 高亮切换;
  • [ ] 新增一个导航项只需改 navLinks.js;
  • [ ] NavBar、Banner、Footer 均为一文件一组件、默认导出。
参考答案(骨架版 App)
// src/App.jsx
import { useState } from 'react';
import NavBar from './components/NavBar';
import Banner from './components/Banner';
import Footer from './components/Footer';

function App() {
  const [activeId, setActiveId] = useState('features');

  return (
    <div className="page">
      <NavBar activeId={activeId} onNavigate={setActiveId} />
      <main>
        <Banner />
      </main>
      <Footer />
    </div>
  );
}

export default App;

NavBar 不含 ThemeToggle 时的签名:function NavBar({ activeId, onNavigate }),其余同 §6.4。挑战练习完成后再把主题 props 补回来。

练习 2 · 进阶(60 min):FeatureGrid + FaqAccordion

任务描述:完成特性区与 FAQ 手风琴,要求:卡片响应式排布;手风琴单开模式 + aria-expanded;FAQS / FEATURES 数据独立成文件。

提示:

  • 手风琴状态用 openId(单值),展开逻辑一行:setOpenId(isOpen ? null : faq.id);
  • 展开内容用条件渲染 {isOpen && ...},组件卸载即收起;
  • 网格布局交给 CSS(auto-fill + minmax),React 侧零介入。

验收标准:

  • [ ] 6 张特性卡片在窄屏 / 宽屏下分别呈单列 / 多列;
  • [ ] FAQ 点击展开、再点收起、点另一项时旧项自动收起;
  • [ ] DevTools 中能看到 aria-expanded="true/false" 随点击切换;
  • [ ] 两组件均为纯展示(除 FaqAccordion 持有 openId 外无其他状态)。
参考答案(手风琴核心逻辑)

完整实现见 §6.6。核心逻辑自查:

const [openId, setOpenId] = useState(null);

// 展开项与当前点击项相同时收起(置 null),否则切换到新项
<button
  aria-expanded={isOpen}
  onClick={() => setOpenId(isOpen ? null : faq.id)}
>

自查两问:① FAQS 数据里有没有被塞进 UI 状态?(不应该有)② 列表 map 的 key 是不是 faq.id?(必须是)

练习 3 · 挑战(选做,90 min+):主题系统完整版

任务描述:实现生产级暗色主题:① useTheme 支持「localStorage → 系统偏好 → light」三级兜底;② 消除「暗色用户刷新时白屏闪烁」(FOUC);③ 主题切换按钮带过渡动画。

提示:

  • 三级兜底的读取顺序:localStorage 白名单 → matchMedia('(prefers-color-scheme: dark)') → 'light';
  • FOUC 的根因:React 挂载前页面用默认浅色渲染了第一帧。解法是在 index.html 里、React 脚本之前放一段内联脚本提前设置 data-theme;
  • 这是企业门户的真实需求——附录A 要求首页 LCP ≤ 2.0s,视觉闪烁直接影响体验评审。

验收标准:

  • [ ] 系统偏好为暗色的新用户,首次打开即是暗色;
  • [ ] 暗色用户刷新页面无白屏闪烁(可用 DevTools 性能面板逐帧验证);
  • [ ] localStorage 被禁用时页面不报错、主题回落 light。
参考答案(关键代码)
<!-- index.html:在 React 加载前设置主题,消除 FOUC -->
<body>
  <script>
    (function () {
      try {
        var theme = localStorage.getItem('qingorder-theme');
        if (theme !== 'light' && theme !== 'dark') {
          theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
        }
        document.documentElement.dataset.theme = theme;
      } catch (e) {
        /* localStorage 不可用:保持默认浅色 */
      }
    })();
  </script>
  <div id="root"></div>
  <script type="module" src="/src/main.jsx"></script>
</body>
// src/hooks/useTheme.js —— 三级兜底版(只列差异)
function readInitialTheme() {
  try {
    const saved = localStorage.getItem(THEME_STORAGE_KEY);
    if (saved === 'light' || saved === 'dark') return saved;
  } catch {
    /* 忽略 */
  }
  // 兜底 2:跟随系统偏好
  return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}

注意内联脚本与 readInitialTheme 的兜底顺序必须一致,否则「首帧」与「挂载后」可能不同步。

⚠️ 常见易错点

易错点 1:主题状态散落在多个组件里

// ❌ NavBar 和 Footer 各自维护 theme:两个开关互相不知道,页面一半黑一半白
function NavBar() {
  const [theme, setTheme] = useState('light'); // 状态分裂了
  // ...
}
// ✅ 主题是全局 UI 态:收口到顶层 useTheme(多层级消费时升级为 Context),向下分发
function App() {
  const { theme, toggleTheme } = useTheme();
  return <NavBar theme={theme} onToggleTheme={toggleTheme} />;
}

易错点 2:localStorage 读写无防护

// ❌ 初始化时直接解析 localStorage:隐私模式抛异常、脏数据炸页面
const [theme, setTheme] = useState(JSON.parse(localStorage.getItem('theme')));
// ✅ try/catch + 白名单校验,异常时回退默认主题(readInitialTheme 内部已处理)
const [theme, setTheme] = useState(readInitialTheme);

易错点 3:把 UI 状态塞进数据数组

// ❌ 把 open 写进 FAQ 数据并直接修改:数据与 UI 状态混在一起,还破坏了不可变纪律
import { FAQS } from '../data/faqs';

function FaqAccordion() {
  FAQS[0].open = true; // 直接改了导入的常量数组!
  // ...
}
// ✅ UI 状态归组件(openId),内容数据保持只读
const [openId, setOpenId] = useState(null);
// 点击时:setOpenId(isOpen ? null : faq.id)

易错点 4:渲染期间操作外部世界

// ❌ 在渲染过程中直接改 DOM 属性:渲染必须保持「纯」,副作用属于 useEffect
function App() {
  const [theme] = useState('light');
  document.documentElement.dataset.theme = theme; // 渲染期间动了外部世界
  return <NavBar theme={theme} />;
}
// ✅ 副作用放进 useEffect:提交渲染之后、且依赖变化时才执行(useTheme 内部已处理)
function App() {
  const { theme, toggleTheme } = useTheme();
  return <NavBar theme={theme} onToggleTheme={toggleTheme} />;
}

易错点 5:把所有逻辑堆进 App.jsx

// ❌ 700 行的 App.jsx:导航、FAQ、主题全部纠缠在一个函数里,谁都不敢改
function App() {
  const [openId, setOpenId] = useState(null);
  const [theme, setTheme] = useState('light');
  const [activeId, setActiveId] = useState('');
  // ……300 行 JSX,FAQ 与导航互相嵌套引用
}
// ✅ 按「组件树设计先行」拆分:App 只负责拼装,每个文件单一职责
function App() {
  return (
    <div className="page">
      <NavBar activeId={activeId} onNavigate={setActiveId} theme={theme} onToggleTheme={toggleTheme} />
      <main>
        <Banner />
        <FeatureGrid />
        <FaqAccordion />
      </main>
      <Footer />
    </div>
  );
}

✅ 最佳实践

  1. 组件树设计先行:编码前先画树、标注每个组件的状态与 props,评审成本远低于返工。
  2. 内容进 data/、逻辑进 hooks/、视图进 components/:三个目录依赖方向单一,改文案不碰组件。
  3. 主题令牌化:颜色、间距全部收敛为 CSS 变量,暗色主题只是一张变量覆盖表,组件代码零感知。
  4. 无障碍是验收项:交互元素用语义标签(button / nav / section),展开类组件带 aria-expanded,弹窗带 role="dialog"。
  5. 渲染保持纯净:渲染函数里不发请求、不改 DOM、不写存储;一切「对外动作」进 useEffect。
  6. 验收即清单:上线前按 §6.9 逐条打勾,把「控制台无警告」与 pnpm lint 通过设为硬门槛。

📌 本章小结

  1. 实战方法论:需求 → 组件树 → 目录结构 → 数据配置 → 组件实现 → 清单验收,顺序不可颠倒。
  2. 门户首页五大区块全部由「配置数据 + 纯展示组件 + 容器组件」构成,新增内容零组件改动。
  3. 主题系统 = useTheme(状态 + 副作用 + 持久化)+ CSS 变量令牌 + 可选的防闪烁内联脚本。
  4. FAQ 手风琴的关键是把 UI 状态(openId)与内容数据严格分离,单开模式天然互斥。
  5. props 评审清单是初级阶段的「组件 API 合格证」,每一次 code review 都应该过一遍。

📋 初级阶段毕业检查:完成本章即完成初级阶段全部学习。请打开《阶段自查 · 初级能力清单》,用 30 分钟完成能力勾选与自测小考;达标后即可进入第二部分 · 中级阶段——多页面、全局状态与服务端交互的世界。

📄 第 07 章 · 路由系统 —— React Router 全解 07-路由系统-ReactRouter全解.md

📍 阶段:中级阶段(第 7–12 章)|⏱ 预计学时:5 小时|🔗 前置:第 04 章、第 06 章

⬅ 上一章:初级实战 · 企业门户首页 | 📖 本版目录 | ➡ 下一章:状态管理

💡 对照学习:本页是 React 版第 07 章,对应 Vue 版第 07 章 · 路由系统,两版练习任务完全一致(中后台骨架 → 订单详情/面包屑/筛选回传 → 权限路由 + 403)。概念映射:<Routes>/<Route> ≈ routes 配置数组;<Outlet /> ≈ <router-view>(嵌套出口);useNavigate ≈ router.push/replace;useSearchParams ≈ route.query 读写;ProtectedRoute 组件 ≈ router.beforeEach 导航守卫;React.lazy + Suspense ≈ 路由懒加载;路由 handle 元信息 ≈ meta 字段。

🎯 学习目标

学完本章,你将能够:

  1. 说出 React Router 7 的两种使用模式(组件式 <Routes> 与数据模式 createBrowserRouter)的差别,并解释本教程为什么主讲组件式;
  2. 用嵌套路由 + <Outlet /> 搭出「轻订单」中后台 Layout 骨架:侧边菜单常驻、内容区随路由切换;
  3. 用 React.lazy + Suspense 完成路由级代码分割,并用 DevTools 验证每个页面一个 chunk;
  4. 使用 useParams 读取动态参数,并解释「参数变化时组件被复用」的原因与两种解法;
  5. 用 useSearchParams 把订单列表的筛选条件写进 URL,实现刷新回填与可分享链接;
  6. 封装 ProtectedRoute 守卫组件完成登录拦截与 redirect 回跳,对照说出它与 Vue 导航守卫的异同;
  7. 用路由元信息驱动面包屑导航,并理解「单一事实源」在路由配置上的体现;
  8. 说出 history 路由的工作原理、部署时刷新 404 的原因与 Nginx 回退方案。

📖 核心知识点

7.1 安装与两种心智模型

React Router 7 把包合并为单一的 react-router(旧的 react-router-dom 作为兼容层继续存在,内部重导出)。安装:

pnpm add react-router

它同时支持两种写法,API 兼容、心智不同:

维度 组件式(Declarative) 数据模式(Data)
路由声明 JSX 里写 <Routes><Route/></Routes> createBrowserRouter 配置对象
数据加载 自己在组件里发请求(第 09 章) 路由级 loader(进入页面前加载)
内置能力 基础导航 loader/action、<ScrollRestoration>、错误边界路由级化
适合 常规中后台、与请求库(TanStack Query)搭配 全栈型应用、希望「路由即数据层」

本教程主讲组件式——它与第 09 章的 TanStack Query 配合最自然,也是存量企业项目的主流形态;数据模式在需要时(如希望导航守卫更像 Vue 的 beforeEach)一句话就能迁移。本节代码全部可以在两种模式下复用。

入口挂载(第 06 章项目继续演进):

// src/main.jsx —— 挂载路由:整个 SPA 从这里变成「多页面」
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router'
import App from './App'
import './index.css'

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </StrictMode>,
)

⚙️ @ 别名指向 src/ 在第 01 章已配置(vite.config.js 的 resolve.alias),后续代码沿用。

7.2 路由表、嵌套路由与 Outlet:中后台 Layout

中后台系统的经典形态是「登录页独立、其余页面共享一个 Layout(侧边栏 + 顶栏 + 内容区)」。React Router 的表达方式是嵌套路由:父路由渲染 Layout,子路由渲染进 Layout 里的 <Outlet /> 占位。

// src/App.jsx —— 路由表:哪个路径显示哪个组件的唯一事实源
import { Routes, Route, Navigate } from 'react-router'
import { lazy, Suspense } from 'react'
import AdminLayout from './layouts/AdminLayout'
import Login from './views/Login'
import NotFound from './views/NotFound'

// 页面级懒加载:每个页面单独一个 chunk,首屏只带 Login + Layout
const Dashboard = lazy(() => import('./views/Dashboard'))
const OrderList = lazy(() => import('./views/orders/OrderList'))
const OrderDetail = lazy(() => import('./views/orders/OrderDetail'))
const OrderCreate = lazy(() => import('./views/orders/OrderCreate'))
const CustomerList = lazy(() => import('./views/customers/CustomerList'))

/** 统一给懒加载页面套 Suspense,避免每处重复写 */
function LazyPage({ children }) {
  return (
    <Suspense fallback={<div className="page-loading">页面加载中…</div>}>
      {children}
    </Suspense>
  )
}

export default function App() {
  return (
    <Routes>
      <Route path="/login" element={<Login />} />
      {/* 嵌套:所有后台页面渲染进 AdminLayout 的 <Outlet /> */}
      <Route path="/" element={<AdminLayout />}>
        <Route index element={<Navigate to="/dashboard" replace />} />
        <Route path="dashboard" element={<LazyPage><Dashboard /></LazyPage>} />
        <Route path="orders" element={<LazyPage><OrderList /></LazyPage>} />
        <Route path="orders/new" element={<LazyPage><OrderCreate /></LazyPage>} />
        <Route path="orders/:id" element={<LazyPage><OrderDetail /></LazyPage>} />
        <Route path="customers" element={<LazyPage><CustomerList /></LazyPage>} />
      </Route>
      <Route path="*" element={<NotFound />} />
    </Routes>
  )
}

Layout 本身不需要知道「内容是什么」,它只负责壳 + <Outlet />:

// src/layouts/AdminLayout.jsx —— 中后台外壳:侧边菜单 + 顶栏 + 内容出口
import { Outlet, NavLink } from 'react-router'
import { Layout as AntLayout, Menu } from 'antd'
import { MENU_ITEMS } from '@/router/menus'

const { Sider, Header, Content } = AntLayout

export default function AdminLayout() {
  return (
    <AntLayout style={{ minHeight: '100vh' }}>
      <Sider>
        <div className="logo">轻订单</div>
        <Menu theme="dark" mode="inline" items={MENU_ITEMS} />
      </Sider>
      <AntLayout>
        <Header className="topbar">
          <Breadcrumbs /> {/* §7.8 实现 */}
        </Header>
        <Content className="page-container">
          {/* 子路由渲染在这里;参数变化时页面组件被「复用」而非重建 */}
          <Outlet />
        </Content>
      </AntLayout>
    </AntLayout>
  )
}

三个必须内化的规则:

  1. 子路径不带 / 开头(path="orders"),相对父路径拼接——写成 /orders 虽然多数场景也能跑,但嵌套迁移时会踩坑;
  2. index 路由匹配「父路径本身」,用它把 / 重定向到 /dashboard;
  3. path="*" 是 404 兜底,必须放最后——React Router 按声明顺序匹配,兜底放前面会吞掉所有路由。

7.3 懒加载:React.lazy + Suspense

lazy(() => import('./x')) 让 Vite 把该模块拆成独立 chunk,首次渲染到它时才加载。配合第 14 章的加载性能指标(首屏 gzip ≤ 300KB),路由级分割是性价比最高的一刀:

# 构建后观察产物:每个页面一个 chunk
pnpm build
# dist/assets/Dashboard-xxxx.js  dist/assets/OrderList-xxxx.js ...

两个纪律:

  • lazy() 必须配合 <Suspense>,否则 React 会在模块未就绪时抛错(React 19 中懒组件挂起时必须有边界接住);
  • fallback 要设计成与页面骨架高度相近的占位(骨架屏),避免「白屏 → 内容」的跳变。

数据模式还有另一种懒法:路由对象上的 lazy 导出(按路由拆模块、loader 与组件一起懒加载),细节见官方文档 https://reactrouter.com/start/data/routers ——组件式用 React.lazy 即可覆盖本章需求。

7.4 动态参数 useParams 与组件复用

订单详情页 /orders/:id 是典型动态路由。useParams 读取路径参数:

// src/views/orders/OrderDetail.jsx —— 订单详情(数据部分第 09 章补全)
import { useParams, Link } from 'react-router'
import { useEffect, useState } from 'react'
import { fetchOrderDetail } from '@/api/orders'

export default function OrderDetail() {
  const { id } = useParams() // 字符串!'/orders/1001' → { id: '1001' }
  const [order, setOrder] = useState(null)

  useEffect(() => {
    let active = true
    fetchOrderDetail(id).then((data) => {
      if (active) setOrder(data)
    })
    return () => { active = false } // 竞态防护,第 09 章换成 TanStack Query 后自动消失
  }, [id]) // ← 关键:id 变化时重新拉取

  if (!order) return <div className="page-loading">加载中…</div>
  return (
    <div>
      <h2>{order.orderNo}</h2> {/* QO20260929 + 6 位序号,见附录A */}
      <Link to="/orders">← 返回列表</Link>
    </div>
  )
}

必须理解的机制:从 /orders/1001 跳到 /orders/1002 时,React Router 匹配到的还是同一个 <OrderDetail />,React 会复用组件实例(只更新 props),状态不会被重置,也不会自动重新请求。这与 Vue 版「params 变化组件复用」是同一个问题,两个解法也一一对应:

解法 React 写法 对应 Vue 写法 代价
响应参数变化 useEffect 依赖 [id] 重新拉取 onBeforeRouteUpdate 组件状态保留,需自己处理重新加载
强制重建 外层 <Route key={id}> 或元素上加 key <router-view :key="$route.fullPath"> 零成本实现,但状态全丢、有重建开销

推荐解法一(useEffect 依赖数组),第 09 章接入 TanStack Query 后,queryKey 里的 id 变化会自动重新请求并保留缓存,这个问题直接消失。

7.5 搜索参数 useSearchParams:筛选状态的唯一事实源

订单列表的关键词、状态、页码(附录A §3.3:分页 20/页 + 多维筛选)放组件 state 还是 URL?答案是 URL——刷新不丢、可分享、可回退,这也是第 09 章 useOrderList 的地基:

// src/views/orders/OrderList.jsx(节选)—— 筛选条件与 URL 双向同步
import { useSearchParams } from 'react-router'

export default function OrderList() {
  const [searchParams, setSearchParams] = useSearchParams()
  const filters = {
    keyword: searchParams.get('keyword') ?? '',
    status: searchParams.get('status') ?? '',
    page: Number(searchParams.get('page') ?? 1),
  }

  // 更新筛选:始终基于当前 searchParams 生成新对象,避免互相覆盖
  const setFilter = (patch) => {
    const next = new URLSearchParams(searchParams)
    Object.entries(patch).forEach(([key, value]) => {
      if (value === '' || value == null) next.delete(key) // 空值不留在地址栏
      else next.set(key, String(value))
    })
    if (!('page' in patch)) next.set('page', '1') // 改筛选必须回第一页
    setSearchParams(next) // 默认 push(可回退);想「替换历史」传 { replace: true }
  }

  return (
    <div>
      <input
        value={filters.keyword}
        placeholder="搜订单号 / 客户名"
        onChange={(e) => setFilter({ keyword: e.target.value })}
      />
      <button onClick={() => setFilter({ status: 'pending_review' })}>看待审核</button>
      <button onClick={() => setFilter({ page: filters.page + 1 })}>下一页</button>
    </div>
  )
}

useSearchParams 返回 [searchParams, setSearchParams],前者是 URLSearchParams 实例(.get/.set/.delete),后者用法近似 useNavigate。URL 是唯一事实源:输入框的 value、表格的数据请求参数,全部从 searchParams 派生,刷新后状态原样还原。

7.6 编程式导航 useNavigate

表单提交成功后跳转、登录后回跳——这些「代码里决定去哪」的场景用 useNavigate(≈ router.push/replace):

import { useNavigate } from 'react-router'

function OrderActions({ orderId }) {
  const navigate = useNavigate()

  const handleSubmitSuccess = () => {
    navigate(`/orders/${orderId}`, { replace: true }) // 提交页不可「后退」回来
  }
  const handleCancel = () => {
    navigate(-1) // 等价于 history.back()
  }
  // 还可以携带 state:给目标页传「不进 URL」的数据(刷新即丢)
  // navigate('/login', { state: { from: '/orders' } })
  return <button onClick={handleSubmitSuccess}>提交订单</button>
}

三条纪律:事件处理函数与 useEffect 里调用,绝不在渲染期间调用(见易错点 2);跳详情用模板字符串拼 /orders/${id};replace 用于「逻辑上替换当前步骤」的场景(登录、提交成功)。

7.7 守卫:ProtectedRoute(对应导航守卫)

Vue Router 用全局 beforeEach 一次拦住所有路由;React Router 没有全局守卫 API,惯用法是把守卫做成路由元素:包住需要保护的路由分支,未登录就渲染 <Navigate> 重定向。第 08 章接入全局状态后,判断条件会换成 store;本章先用一个独立的 auth 工具保持自洽:

// src/utils/auth.js —— 本章最小实现;第 11 章会升级为双令牌 + store 管理
const TOKEN_KEY = 'qing_access_token'

export const auth = {
  isLoggedIn: () => Boolean(localStorage.getItem(TOKEN_KEY)),
  setToken: (token) => localStorage.setItem(TOKEN_KEY, token),
  clear: () => localStorage.removeItem(TOKEN_KEY),
}
// src/router/ProtectedRoute.jsx —— 守卫组件:包住谁,谁就需要登录
import { Navigate, Outlet, useLocation } from 'react-router'
import { auth } from '@/utils/auth'

export default function ProtectedRoute() {
  if (!auth.isLoggedIn()) {
    // state.from 记录来源路径,登录成功后回跳(第 11 章完整实现)
    return <Navigate to="/login" replace state={{ from: location.pathname + location.search }} />
  }
  return <Outlet />
}

⚠️ 上面 location 需要配合 const location = useLocation() 使用,完整版见练习 3 答案。

接入路由表——守卫是嵌套结构,天然分层:

// App.jsx 中:用 ProtectedRoute 包住整个后台分支
<Route element={<ProtectedRoute />}>
  <Route path="/" element={<AdminLayout />}>
    {/* ...各页面路由 */}
  </Route>
</Route>
<Route path="/login" element={<Login />} /> {/* 白名单:在守卫之外 */}

第 11 章会给 ProtectedRoute 加上 roles 参数与 403 分流,实现附录A 的页面级权限(直接输 URL 也进不来)。它就是 React 世界的「导航守卫」——只是从「框架钩子」变成了「组合进路由树」。

7.8 路由元信息与面包屑

Vue Router 每条路由可以挂 meta;React Router 的对应物是路由对象上的 handle(数据模式配合 useMatches 读取)。组件式模式下,更通用的做法是元信息配置表——面包屑、菜单、守卫规则都从这一份配置派生(单一事实源,第 12 章 M1 直接复用):

// src/router/routeMeta.js —— 路径模式 → 面包屑链;roles 在练习 3 与第 11 章启用
export const routeMeta = {
  '/dashboard': [{ title: '工作台' }],
  '/orders': [{ title: '订单管理' }],
  '/orders/new': [{ title: '订单管理', to: '/orders' }, { title: '新建订单' }],
  '/orders/:id': [{ title: '订单管理', to: '/orders' }, { title: '订单详情' }],
  '/customers': [{ title: '客户管理' }],
}
// src/layouts/Breadcrumbs.jsx —— 面包屑:支持 :id 参数模式匹配
import { useLocation, Link } from 'react-router'
import { routeMeta } from '@/router/routeMeta'

function matchMeta(pathname) {
  // 先精确匹配,再按「路径段数相同 + 动态段」匹配(/orders/1001 → /orders/:id)
  if (routeMeta[pathname]) return routeMeta[pathname]
  const segs = pathname.split('/').filter(Boolean)
  for (const pattern of Object.keys(routeMeta)) {
    const pSegs = pattern.split('/').filter(Boolean)
    if (pSegs.length !== segs.length) continue
    const matched = pSegs.every((seg, i) => seg.startsWith(':') || seg === segs[i])
    if (matched) return routeMeta[pattern]
  }
  return []
}

export default function Breadcrumbs() {
  const { pathname } = useLocation()
  const chain = matchMeta(pathname)
  if (chain.length === 0) return null
  return (
    <nav className="breadcrumbs" aria-label="面包屑">
      {chain.map((item, i) =>
        item.to && i < chain.length - 1 ? (
          <Link key={item.title} to={item.to}>{item.title}</Link>
        ) : (
          <span key={item.title}>{item.title}</span>
        ),
      )}
    </nav>
  )
}

7.9 滚动行为、路由过渡与部署回退

三个真实项目都会遇到的话题,点到为止(细节见官方文档,不杜撰 API 细节):

  • 滚动行为:SPA 切页不会自动回顶。数据模式提供 <ScrollRestoration />(仅数据路由可用);组件式模式常见做法是在 Layout 里监听 location.pathname 变化后 window.scrollTo(0, 0),或交给第 14 章统一治理。
  • 路由过渡:React Router 的 Link/useNavigate 支持 viewTransition 选项启用浏览器 View Transitions API(Chrome/Edge 支持),一行属性即可获得淡入切换,进阶可配 CSS。
  • history 路由与刷新 404:BrowserRouter 基于 History API,/orders/1001 是前端路由——用户刷新时浏览器会真的向服务器请求这个路径,服务器没有该文件就 404。解法是 Nginx try_files $uri /index.html; 把所有路径回退到 index.html(Vue 版第 07 章同款配置,两版部署完全一致)。若部署到对象存储子目录,BrowserRouter 换 HashRouter 是零成本兜底。

🛠 渐进式实战练习

所有练习在「轻订单」仓库的 react-app/ 中进行;Mock 数据先写死在 api/ 模块内(第 09 章换成真实请求层)。

练习 1 · 基础(10–20 min):搭出中后台骨架

任务描述:完成 §7.2 的骨架——AdminLayout(Sider 菜单 + Content)、嵌套路由(dashboard / orders / customers)、/ 重定向到 /dashboard、404 页;Dashboard、OrderList、CustomerList 三个页面全部 React.lazy 懒加载,菜单用 NavLink 或 AntD Menu 实现当前页高亮。

提示:404 放路由表最后;菜单高亮判断当前路径前缀可用 useLocation().pathname.startsWith(key);lazyPage 辅助组件直接抄 §7.2。

验收标准:

  • [ ] 直接访问 / 显示工作台且地址栏变为 /dashboard;
  • [ ] 刷新 /orders 页面内容不丢(开发服务器已处理回退);DevTools Network 里三个页面各自按需加载 chunk;
  • [ ] 访问 /not-exist 显示 404 页,菜单不高亮任何项;
  • [ ] 控制台零警告。
参考答案(菜单高亮部分)
// src/layouts/AdminLayout.jsx(完整版)
import { Outlet, NavLink } from 'react-router'
import { Layout as AntLayout, Menu } from 'antd'

const { Sider, Header, Content } = AntLayout

const MENU_ITEMS = [
  { key: '/dashboard', label: <NavLink to="/dashboard">工作台</NavLink> },
  { key: '/orders', label: <NavLink to="/orders">订单管理</NavLink> },
  { key: '/customers', label: <NavLink to="/customers">客户管理</NavLink> },
]

export default function AdminLayout() {
  return (
    <AntLayout style={{ minHeight: '100vh' }}>
      <Sider>
        <div className="logo">轻订单</div>
        <Menu theme="dark" mode="inline" items={MENU_ITEMS} />
      </Sider>
      <AntLayout>
        <Header className="topbar"><Breadcrumbs /></Header>
        <Content className="page-container"><Outlet /></Content>
      </AntLayout>
    </AntLayout>
  )
}

NavLink 自带 isActive,也可以用 className={({ isActive }) => (isActive ? 'menu-active' : '')} 自定义样式;AntD Menu 的选中态用 selectedKeys={[location.pathname]} 驱动更省心。

练习 2 · 进阶(30–60 min):订单详情 + 面包屑 + 筛选回传

任务描述:①实现 /orders/:id 详情页:读取 useParams,从 mock 列表找到对应订单(orderNo 形如 QO20260929000001),展示基本信息;列表页点行进入详情。②按 §7.8 实现面包屑,详情页显示「订单管理 / 订单详情」且可点回列表。③订单列表的关键词、状态、页码写入 URL(§7.5),刷新后筛选与页码原样还原;从详情页返回列表,筛选仍在。

提示:详情页匹配 mock 数据找不到时显示「订单不存在」而不是白屏;返回列表用 navigate(-1) 还是 Link to="/orders"?想清楚「用户可能直接刷新进详情页」的场景(此时没有历史可退),用 Link 更稳。

验收标准:

  • [ ] /orders/任意假id 不白屏,有「订单不存在」提示;
  • [ ] 面包屑对 /orders/new、/orders/1001 都正确(不把动态段当死路径);
  • [ ] 设置筛选后刷新页面,keyword/status/page 从 URL 还原,表格数据与筛选一致;
  • [ ] 改状态筛选时页码自动回 1。
参考答案(面包屑动态段匹配核心)

见 §7.8 matchMeta:先查精确表,再按段数 + 动态段规则匹配。注意 /orders/new 必须注册在路由表中且排在 /orders/:id 之前(否则 new 会被当成 :id),React Router 会按声明顺序取第一个匹配——这一点和 Vue Router 的路由排序策略一致。

练习 3 · 挑战(选做,60+ min):权限路由 + 403 页 + 登录回跳

任务描述:①封装 ProtectedRoute(补全 §7.7 中 useLocation 的使用),包住整个后台分支;②路由元信息 routeMeta 每项增加 roles 数组(如 /settings 仅 super_admin),ProtectedRoute 读元信息:已登录但无角色 → 重定向 /403;③新建 403 页(显示「无权限访问」+ 返回工作台按钮);④未登录访问 /orders 跳登录页,登录页读取 location.state.from,登录成功后回跳原路径。

提示:mock 登录:localStorage.setItem('qing_access_token', 'mock-token') + localStorage.setItem('qing_role', 'sales') 即可;from 回跳要校验必须以 / 开头(防开放重定向,第 11 章展开)。

验收标准:

  • [ ] 未登录直接输 URL /orders → 落在 /login;登录后回到 /orders 且筛选参数不丢;
  • [ ] 把 qing_role 设为 sales 后访问 /settings → 403 页;设为 super_admin 后可访问;
  • [ ] 登录页直接访问时点登录回 /dashboard(from 为空的兜底)。
参考答案(ProtectedRoute 完整版)
// src/router/ProtectedRoute.jsx —— 登录 + 页面级权限双拦截
import { Navigate, Outlet, useLocation } from 'react-router'
import { auth } from '@/utils/auth'
import { matchMeta } from '@/layouts/Breadcrumbs' // 把匹配逻辑导出复用
import { routeMeta } from '@/router/routeMeta'

export default function ProtectedRoute() {
  const location = useLocation()
  if (!auth.isLoggedIn()) {
    const from = location.pathname + location.search
    return <Navigate to="/login" replace state={{ from }} />
  }
  const chain = matchMeta(location.pathname)
  const pattern = Object.keys(routeMeta).find((p) => routeMeta[p] === chain)
  const requiredRoles = routeMeta[pattern]?.roles
  const role = localStorage.getItem('qing_role')
  if (requiredRoles?.length > 0 && !requiredRoles.includes(role)) {
    return <Navigate to="/403" replace />
  }
  return <Outlet />
}

登录页回跳(节选):

const handleSubmit = (e) => {
  e.preventDefault()
  auth.setToken('mock-token')
  const from = location.state?.from ?? '/dashboard'
  // 只允许站内路径,防开放重定向:外部链接一律回工作台
  navigate(from.startsWith('/') ? from : '/dashboard', { replace: true })
}

第 11 章会把 localStorage 判断换成 user store + <Auth> 按钮级权限,本练习的结构不变。

⚠️ 常见易错点

易错点 1 · params 变化,页面「不动」

❌ 错误代码:

// /orders/1001 → /orders/1002 后界面还是 1001 的数据
export default function OrderDetail() {
  const { id } = useParams()
  useEffect(() => { loadOrder('1001') }, []) // 依赖数组为空:只在挂载时拉一次
  // ...
}

错误原因:同一路由组件在参数变化时被复用,不会重新挂载,空依赖的 useEffect 不再执行;而闭包里还可能固化了旧 id(闭包过期,第 04 章讲过)。

✅ 正确写法:

export default function OrderDetail() {
  const { id } = useParams()
  useEffect(() => { loadOrder(id) }, [id]) // id 变化 → 重新拉取
  // ...
}

(临时兜底也可用 key 强制重建,但优先治本。)

易错点 2 · 渲染期间调用 useNavigate

❌ 错误代码:

function Login() {
  const navigate = useNavigate()
  if (auth.isLoggedIn()) {
    navigate('/dashboard') // 💥 Cannot update a component while rendering…
  }
  return <form>…</form>
}

错误原因:渲染函数必须是纯的;渲染期间触发导航会引发「渲染中更新其他组件」的警告与不可预测的循环。

✅ 正确写法:

function Login() {
  const navigate = useNavigate()
  useEffect(() => {
    if (auth.isLoggedIn()) navigate('/dashboard', { replace: true })
  }, [navigate]) // 副作用放 useEffect;事件回调里调用同理安全
  return <form>…</form>
}

易错点 3 · 懒加载没包 Suspense

❌ 错误代码:

const OrderList = lazy(() => import('./views/orders/OrderList'))
// ...
<Route path="orders" element={<OrderList />} /> // 💥 chunk 未就绪时抛错/白屏

错误原因:lazy() 组件在模块加载完成前处于「挂起」状态,必须存在 Suspense 边界提供 fallback,否则整树报错。

✅ 正确写法:

<Suspense fallback={<div className="page-loading">页面加载中…</div>}>
  <Route path="orders" element={<OrderList />} />
</Suspense>
// 更优:像 §7.2 一样用 LazyPage 统一包裹,或把 Suspense 放在 <Outlet /> 外层

易错点 4 · NavLink 高亮「传染」:/orders 把 /orders/new 也点亮

❌ 错误代码:

<NavLink to="/orders" className="active-when-matched">订单管理</NavLink>
// 结果:进入 /orders/new、/orders/1001 时「订单管理」一直处于选中态

错误原因:NavLink 默认「当前路径以 to 开头」即激活(这是刻意的,菜单场景多数时候正确);但需要「仅自身激活」时必须显式声明。

✅ 正确写法:

<NavLink to="/orders" end className={({ isActive }) => (isActive ? 'active' : '')}>
  订单管理
</NavLink>

end 表示「完整匹配才激活」;/orders 与 /orders/:id 共用菜单时,就保留默认前缀匹配并自行取舍。

易错点 5 · useSearchParams 增删互相覆盖

❌ 错误代码:

setSearchParams({ status: 'approved' })           // 把 keyword、page 全弄丢了
setSearchParams(new URLSearchParams())            // 一次性清空,误伤其他筛选

错误原因:setSearchParams(对象) 是整体替换,不是增量合并——只传一个字段就等于「其他字段全部删除」。

✅ 正确写法:

setFilter({ status: 'approved' }) // 始终基于当前 searchParams 构造新对象再增删
// 即 §7.5 的 setFilter:new URLSearchParams(searchParams) → 增删 → setSearchParams(next)

易错点 6 · 直接改 searchParams 对象

❌ 错误代码:

searchParams.set('page', '2') // 顺手改了对象……地址栏却纹丝不动

错误原因:useSearchParams 返回的实例是不可变快照,直接 .set 不触发任何更新,React Router 感知不到变化。

✅ 正确写法:

const next = new URLSearchParams(searchParams)
next.set('page', '2')
setSearchParams(next) // 让 Router 感知:通过 setter 提交新值

✅ 最佳实践

  1. 路由表是唯一事实源:路径、嵌套、重定向、404 全在 App.jsx 一处;元信息(标题/roles)收敛进 routeMeta,菜单、面包屑、守卫都从它派生,改一处全局生效。
  2. 页面全部懒加载 + 统一 Suspense:路由级分割是首屏体积的最大杠杆,LazyPage 一个辅助组件让纪律零成本。
  3. 筛选条件住进 URL:列表页状态用 useSearchParams 而不是 useState,刷新回填、可分享、可回退,还天然成为第 09 章请求参数的来源。
  4. 守卫即组件:ProtectedRoute 组合进路由树,登录拦截与角色拦截分层;不要在各个页面里散落「if (!登录) 跳转」。
  5. replace 用在「步骤替换」语义处:登录、重定向、提交成功跳详情,避免用户后退回到过期页面。
  6. 部署配好 SPA 回退:Nginx try_files $uri /index.html; 与 Vue 版完全一致,上线检查单里永远有这一条。
  7. params 组件复用问题靠 useEffect 依赖治本:key 重建只做兜底;第 09 章接入 TanStack Query 后此问题自动消失。
  8. 404 与异常路径有兜底:* 路由 + 详情页「订单不存在」分支,任何 URL 都不给用户白屏。

📌 本章小结

  1. React Router 7 组件式用 <Routes>/<Route> 声明路由,<Outlet /> 是嵌套出口,Layout 壳与内容彻底解耦。
  2. 路由懒加载 = React.lazy + Suspense,每个页面独立 chunk;守卫 = ProtectedRoute 组件组合进路由树,Navigate 承担重定向。
  3. useParams 读动态参数(组件会被复用),useSearchParams 让筛选住进 URL 成为唯一事实源,useNavigate 负责编程式导航。
  4. 元信息驱动面包屑/菜单/权限,单一事实源思想贯穿配置层;history 路由必须配服务端回退。
  5. 下一章把「登录用户态、字典缓存、订单暂存区」从组件里搬出来——Redux Toolkit 与 Zustand 登场,ProtectedRoute 里的 auth.isLoggedIn() 也会升级为真正的全局状态。

📄 第 08 章 · 状态管理 —— Redux Toolkit 与 Zustand 08-状态管理-ReduxToolkit与Zustand.md

📍 阶段:中级阶段(第 7–12 章)|⏱ 预计学时:6 小时|🔗 前置:第 07 章

⬅ 上一章:路由系统 | 📖 本版目录 | ➡ 下一章:服务端交互

💡 对照学习:本页是 React 版第 08 章,对应 Vue 版第 08 章 · Pinia 实战,两版练习任务完全一致(用户态 store → 字典缓存 → 订单暂存区)。概念映射:slice ≈ Pinia store(defineStore);reducer + action ≈ store 的 state + 函数;useSelector ≈ storeToRefs / getter;createAsyncThunk ≈ actions 里的异步函数;extraReducers ≈ 监听自身/外部 action;RTK 内置 Immer 的「看似直接改」≈ Pinia proxy 的「直接改」;Zustand ≈ Pinia 的轻量体感;persist 中间件 ≈ pinia-plugin-persistedstate;Redux DevTools ≈ Vue Devtools 的状态面板。

🎯 学习目标

学完本章,你将能够:

  1. 用「本地 UI 态 / 服务端态 / 跨页共享态」三分法判断一段状态该放哪里,说出把服务端数据塞进全局 store 的代价;
  2. 用 configureStore 组装 RTK store 并挂上 Provider,说出中间件与 DevTools 的默认配置;
  3. 用 createSlice 编写 userSlice,理解 RTK 内置 Immer 后「写法像直接改、实际是不可变更新」的机制;
  4. 用 useSelector 订阅状态,解释「selector 返回新引用导致重渲染风暴」的成因并用 createSelector 治理;
  5. 用 createAsyncThunk + extraReducers 编写带 pending/fulfilled/rejected 三态的异步逻辑,并用 unwrap 在组件里接住错误;
  6. 用 Zustand 5 实现同一个用户态,对比两种方案在样板量、订阅粒度、中间件上的差异并给出选型结论;
  7. 实现订单暂存区(跨页草稿)并用 persist 中间件完成持久化,说出哪些状态不该持久化。

📖 核心知识点

8.1 何时需要全局状态:状态三分法

React 组件间共享数据有两条路:props 逐层传递与全局状态。判断标准不是「几层」而是「谁在用」:

状态类型 判断特征 放哪里 「轻订单」例子
本地 UI 态 只有当前组件(树)关心,切页即弃 useState 弹窗开关、表单输入、表格选中行
服务端态 本质是服务端数据的缓存,会过期、需重新拉取 TanStack Query(第 09 章) 订单列表、客户详情
跨页共享态 多个页面读写、登录前也存在、与「服务端缓存」性质不同 全局 store 登录用户态、字典缓存、订单草稿

初学者最常见的两种病:什么都不敢放全局(props 穿五六层)与什么都放全局(把接口数据、表单草稿、甚至路由参数全塞 store,最后没人敢动)。三分法是第 09 章的心智地基:服务端态交给请求库,store 只管「应用自己的状态」。

React 生态里跨页共享态的两个主流答案:

  • Redux Toolkit(RTK):规范最全(slice/thunk/selector/中间件生态/DevTools),团队协作友好,企业存量项目多;
  • Zustand:极简(一个 create),订阅粒度天然到 selector 级,中小项目与局部状态岛首选。

本章两个都教:先 RTK 建立规范心智,再 Zustand 体会轻量取舍——面试与存量代码都躲不开 RTK,新项目里 Zustand 的占比在快速上升。

8.2 RTK 快速上手:configureStore 与 Provider

pnpm add @reduxjs/toolkit react-redux
// src/store/index.js —— 组装 store:reducer 目录 + 默认内置 DevTools
import { configureStore } from '@reduxjs/toolkit'
import userReducer from './userSlice'
import dictReducer from './dictSlice'

export const store = configureStore({
  reducer: {
    user: userReducer, // state.user
    dict: dictReducer, // state.dict
  },
  // middleware 与 devTools 有合理默认值(含 thunk 中间件、Redux DevTools 开关),
  // 没有明确理由不要手写覆盖——这是 RTK 的设计立场
})
// src/main.jsx —— Provider 包住整棵树(在 BrowserRouter 内外均可,习惯放外层)
import { Provider } from 'react-redux'
import { store } from './store'

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <Provider store={store}>
      <BrowserRouter>
        <App />
      </BrowserRouter>
    </Provider>
  </StrictMode>,
)

要点:一个应用一个 store(不像 Pinia 可以多个 store 实例并存);store 内部按 slice 分区;安装 Redux DevTools 浏览器扩展后,RTK 默认接好,无需配置。

8.3 createSlice:reducer、action 与不可变更新

第 04 章讲过 React 的铁律「state 不可变更新」。RTK 的答案是内置 Immer:reducer 里可以「像直接改一样写」,Immer 在背后生成不可变新对象——写法手感与 Pinia 的 proxy 直改几乎一样,但机制(Proxy 拦截写操作、产出新引用)是纯不可变。

// src/store/userSlice.js —— 用户态 slice(对应 Vue 版 useUserStore)
import { createSlice } from '@reduxjs/toolkit'

const initialState = {
  profile: null,      // { id, name, role } role: super_admin | operator | sales | finance
  status: 'idle',     // idle | loading | succeeded | failed
  error: '',
}

const userSlice = createSlice({
  name: 'user', // 前缀:action.type 形如 'user/clearUser'
  initialState,
  reducers: {
    // Immer 生效:可以「直接赋值」,无需展开拷贝
    setUser(state, action) {
      state.profile = action.payload // 看似直接改,实际产出不可变新 state
    },
    clearUser(state) {
      state.profile = null
      state.status = 'idle'
      state.error = ''
    },
  },
})

export const { setUser, clearUser } = userSlice.actions
export default userSlice.reducer

组件中使用(dispatch + useSelector):

// src/views/ProfileCard.jsx —— 读写用户态
import { useDispatch, useSelector } from 'react-redux'
import { setUser, clearUser } from '@/store/userSlice'

export default function ProfileCard() {
  const dispatch = useDispatch()
  const profile = useSelector((s) => s.user.profile) // 订阅:store 变化 → 重新计算 → 按需重渲染

  if (!profile) return <p>未登录</p>
  return (
    <div>
      <p>{profile.name}({profile.role})</p>
      <button onClick={() => dispatch(setUser({ ...profile, name: '青云-运营小李' }))}>
        改名(演示 dispatch)
      </button>
      <button onClick={() => dispatch(clearUser())}>登出</button>
    </div>
  )
}

心智对照:state.user ≈ Pinia 的 store state;setUser ≈ store 里的一个 action 函数;区别是 React 版必须显式 dispatch,且读数据走 useSelector 订阅。

8.4 selector 与引用稳定性

useSelector 每次 store 变化都会重跑,用返回值引用决定要不要重渲染。这带来一个 RTK 世界的经典陷阱:selector 里每次返回新数组/新对象 → 引用永远不同 → 组件无意义重渲染甚至死循环:

// ❌ 每次调用 filter 都产生新数组引用
const pendingOrders = useSelector((s) => s.orders.list.filter((o) => o.status === 'pending_review'))

治理方案是 memoized selector(RTK 内置 reselect 的 createSelector):输入不变就复用上次输出:

// src/store/ordersSlice.js —— 导出 selector,与 reducer 放一起内聚
import { createSelector } from '@reduxjs/toolkit'

export const selectPendingOrders = createSelector(
  [(s) => s.orders.list], // 输入
  (list) => list.filter((o) => o.status === 'pending_review'), // 输入不变则不重算、引用复用
)

// 组件里:const pendingOrders = useSelector(selectPendingOrders) ✅

三条纪律:订阅最小面(s.user.profile.name 只订阅名字,别订阅整个 user);派生数据放 selector 不放 state(能算出来的不存储,≈ Pinia getter 的缓存语义);selector 返回引用稳定值(原始值、既有引用或 createSelector 产物)。

8.5 异步:createAsyncThunk 与 extraReducers

reducer 必须是纯函数,异步逻辑住进 thunk。createAsyncThunk 自动派发 pending/fulfilled/rejected 三种 action,在 extraReducers 里响应(对应 Vue 版在 store action 里自己管理 loading 三态):

// src/store/userSlice.js 追加 —— 登录 thunk
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import { login as loginApi } from '@/api/auth' // 第 09 章补全真正实现,先 mock

export const login = createAsyncThunk('user/login', async (credentials, { rejectWithValue }) => {
  try {
    const data = await loginApi(credentials) // { profile, accessToken, refreshToken }
    return data
  } catch (err) {
    return rejectWithValue(err.message ?? '登录失败') // 把业务错误变成 action.payload
  },
})

const userSlice = createSlice({
  // ...reducers 同上
  extraReducers: (builder) => {
    builder
      .addCase(login.pending, (state) => {
        state.status = 'loading'
        state.error = ''
      })
      .addCase(login.fulfilled, (state, action) => {
        state.status = 'succeeded'
        state.profile = action.payload.profile
        // token 的存储位置与 XSS 权衡是第 11 章专题,这里先随 payload 记录
        state.token = action.payload.accessToken
      })
      .addCase(login.rejected, (state, action) => {
        state.status = 'failed'
        state.error = action.payload // rejectWithValue 的产物
      })
  },
})

组件里两种消费姿势:

// 姿势一:订阅三态渲染 UI(受控感强,适合登录页大表单)
const status = useSelector((s) => s.user.status)
const error = useSelector((s) => s.user.error)

// 姿势二:unwrap 把 thunk 当 Promise 用(轻快,适合按钮动作)
import { unwrapResult } from '@reduxjs/toolkit'
try {
  const data = await dispatch(login(credentials)).unwrap() // rejected 时真正 throw
  navigate('/dashboard', { replace: true })
} catch (err) {
  message.error(err.message)
}

thunk 第二个参数还能拿到 getState/dispatch——跨 slice 协作(Vue 版「store 互相调用」的对应物):先读 getState().user.profile 做前置校验,或 dispatch(另一个slice的action)。

8.6 实战一:字典缓存 dictSlice(并发去重)

商品分类、客户等级这类字典全站共用,进多个页面都触发请求就浪费了。用 thunk 的 condition 选项做加载中去重 + 成功后复用(对应 Vue 版字典 store 的并发去重):

// src/store/dictSlice.js —— 字典缓存:一份字典全站复用
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import { fetchSkuCategories as fetchApi } from '@/api/dict'

export const loadSkuCategories = createAsyncThunk(
  'dict/loadSkuCategories',
  fetchApi,
  {
    condition: (_arg, { getState }) => {
      const { status } = getState().dict.skuCategories
      return status !== 'loading' // 加载中 → 直接跳过本次 dispatch,天然防重复请求
    },
  },
)

const dictSlice = createSlice({
  name: 'dict',
  initialState: {
    skuCategories: { status: 'idle', data: [] },
  },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(loadSkuCategories.pending, (state) => { state.skuCategories.status = 'loading' })
      .addCase(loadSkuCategories.fulfilled, (state, action) => {
        state.skuCategories = { status: 'succeeded', data: action.payload }
      })
      .addCase(loadSkuCategories.rejected, (state) => { state.skuCategories.status = 'failed' })
  },
})

export default dictSlice.reducer

组件里「没数据就触发一次」,其余组件只管订阅:

// src/components/CategoryFilter.jsx —— 字典消费组件
import { useEffect } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { loadSkuCategories } from '@/store/dictSlice'

export default function CategoryFilter({ value, onChange }) {
  const dispatch = useDispatch()
  const { status, data } = useSelector((s) => s.dict.skuCategories)

  useEffect(() => {
    if (status === 'idle') dispatch(loadSkuCategories()) // 只在从未加载过时发请求
  }, [status, dispatch])

  return (
    <select value={value} onChange={(e) => onChange(e.target.value)}>
      <option value="">全部分类</option>
      {data.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
    </select>
  )
}

8.7 Zustand 5:更轻的选择

Zustand 把「全局状态」压缩到一个 create 调用:没有 Provider、没有 dispatch、没有 slice 边界,一个 hook 就是 store:

pnpm add zustand
// src/store/useUserStore.js —— Zustand 版用户态(与 userSlice 同职责)
import { create } from 'zustand'
import { login as loginApi } from '@/api/auth'

export const useUserStore = create((set, get) => ({
  profile: null,
  isReady: false, // 会话恢复是否完成(第 11 章使用)
  async login(credentials) {
    const data = await loginApi(credentials) // 异步直接写,无需 thunk
    set({ profile: data.profile })
    return data
  },
  logout() {
    set({ profile: null })
  },
  setReady: (ready) => set({ isReady: ready }),
}))
// 消费:selector 订阅到「字段级」,粒度天然最优
const profile = useUserStore((s) => s.profile) // 只订阅 profile
const login = useUserStore((s) => s.login)     // 函数引用稳定,可安全进 useEffect 依赖

<button onClick={() => login(credentials)}>登录</button>

与 RTK 的核心差异:

  • 没有 dispatch:调 action 就是调函数;没有 Provider:hook 直接用;
  • 订阅粒度:Zustand 的 selector 只在返回值变化(默认严格相等)时重渲染,写字段级 selector 天然精确;需要返回对象时配 useShallow(从 zustand/react/shallow 导入)做浅比较;
  • 异步零样板:不需要 thunk,async 函数直接写进 store。

8.8 选型决策表 + DevTools + 持久化

怎么选(两版教程口径保持一致):

决策因素 Redux Toolkit Zustand
样板量 中(slice 固定结构) 极小(一个 create)
团队规范与可预测性 强:action 流水可追溯、中间件生态 依赖约定自律
存量代码 / 面试 存量企业项目最多,必会 新项目上升快
订阅粒度 需注意 selector 引用稳定性 selector 级默认精确
本教程建议 10 人以上团队、复杂交互流、需要严格 review 规范 中小项目、组件库内部状态岛、原型期

两者都不该装服务端态——那是第 09 章 TanStack Query 的领地。

调试:RTK 默认接 Redux DevTools(时间旅行、action 流、diff);Zustand 加 devtools 中间件(zustand/middleware)即可同样接入。装浏览器扩展 + console.log 退役。

持久化:Vue 版用 pinia-plugin-persistedstate,React 版对应物是 persist 中间件(Zustand 原生支持最顺,示例见练习 3);RTK 生态用 redux-persist(样板较多,本教程不展开,官网 https://redux-toolkit.js.org 检索 persist)。记住红线:token 是否持久化要过安全评审(第 11 章),用户敏感数据与大面积服务端数据不进 localStorage。

🛠 渐进式实战练习

练习 1 · 基础(10–20 min):用户态 store

任务描述:用 RTK 实现 userSlice:setUser / clearUser 两个 reducer;profile 形如 { id: 'u001', name: '青云-运营小李', role: 'operator' }。在顶栏加一个 ProfileBadge 组件显示姓名与角色,未登录显示「未登录」;再提供一个「切换角色」按钮循环 operator → sales → finance → super_admin(为第 11 章权限调试埋点)。

提示:先写 slice 再挂 store 最后在组件订阅,三步各验证一次 DevTools 里的 action 流;「切换角色」一个 action 就够,别写四个。

验收标准:

  • [ ] DevTools 能看到 user/setUser、user/clearUser 的完整 action 与 state diff;
  • [ ] 切换角色后顶栏徽标即时更新,且只有顶栏重渲染(React DevTools Highlight updates 验证);
  • [ ] useSelector 无「每次返回新对象」的写法。
参考答案(切换角色)
// userSlice reducers 追加
const ROLE_CYCLE = ['operator', 'sales', 'finance', 'super_admin']

const userSlice = createSlice({
  name: 'user',
  initialState,
  reducers: {
    setUser(state, action) { state.profile = action.payload },
    clearUser(state) { state.profile = null },
    cycleRole(state) {
      if (!state.profile) return
      const next = ROLE_CYCLE[(ROLE_CYCLE.indexOf(state.profile.role) + 1) % ROLE_CYCLE.length]
      state.profile = { ...state.profile, role: next } // Immer 下也可直接 state.profile.role = next
    },
  },
  extraReducers: { /* 登录 thunk 略 */ },
})
export const { setUser, clearUser, cycleRole } = userSlice.actions

组件:const dispatch = useDispatch() + <button onClick={() => dispatch(cycleRole())}>切换角色</button>。角色循环表是常量——进 src/constants/,不要内联在组件里(第 12 章 C3 规范)。

练习 2 · 进阶(30–60 min):字典缓存与并发去重

任务描述:实现 dictSlice + loadSkuCategories thunk(§8.6 可直接参考):mock 接口延迟 800ms 返回 [{ id: 'c01', name: '智能硬件' }, { id: 'c02', name: '配件耗材' }, { id: 'c03', name: '软件服务' }]。在「新建订单」与「商品目录」两个页面都放分类下拉。用 DevTools Network(或 console 计数)验证:两页切换来回多次,只有第一次真正发请求;加载中有「字典加载中…」占位。

提示:去重靠 condition 选项 + status !== 'loading';「已成功就不再请求」要在 condition 里同时判断 status !== 'succeeded'?想清楚:练习要求是「只发一次」还是「失败后可重试」——推荐 loading 拦截 + 组件层 status === 'idle' 才 dispatch,失败态(failed)下次仍可重试。

验收标准:

  • [ ] 首次进入任一页面发 1 次请求;800ms 内火速切到另一页,第二页不发请求且拿到同一份数据;
  • [ ] 请求失败后再进页面能重试(failed → 重新 dispatch 可发请求);
  • [ ] 下拉渲染期间无重复 key 警告。
参考答案(condition 与重试的组合)
export const loadSkuCategories = createAsyncThunk(
  'dict/loadSkuCategories',
  async (_arg, { getState, rejectWithValue }) => {
    try {
      return await fetchSkuCategories() // mock: new Promise(r => setTimeout(() => r(DATA), 800))
    } catch (err) {
      return rejectWithValue(err.message)
    }
  },
  {
    condition: (_arg, { getState }) => {
      const { status } = getState().dict.skuCategories
      return status === 'idle' || status === 'failed' // 加载中/已成功都不发;失败可重试
    },
  },
)

组件侧 useEffect 只在 status === 'idle' || status === 'failed' 时 dispatch,与 condition 双保险。

练习 3 · 挑战(选做,60+ min):订单暂存区完整闭环

任务描述:用 Zustand + persist 实现订单暂存区(对应附录A「草稿 draft」状态的业务事实:新建订单填一半离开页面,回来还在):useDraftStore 维护 customerId、items(明细行数组);action:setCustomerId / addItem / updateItem(key, patch) / removeItem(key) / clear;持久化到 localStorage(key 用 qing-draft-order);在「新建订单」页添加一行、跳到「订单列表」再回来,草稿仍在;「清空草稿」按钮带确认。

提示:明细行要带自增 _key 作稳定标识(删除不串位,第 10 章 useFieldArray 会再遇到同一个问题);persist 的 partialize 可以只存业务字段不存临时态。

验收标准:

  • [ ] 刷新浏览器后草稿仍在(persist 生效);清空后 localStorage 中 qing-draft-order 被清理;
  • [ ] 删除中间一行后,其余行的内容不错位(key 稳定);
  • [ ] store 代码里没有「直接修改返回对象」的写法,全部经 set 产出新 state。
参考答案
// src/store/useDraftStore.js —— 订单暂存区:Zustand + persist
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

let seq = 0
const nextKey = () => `item_${Date.now()}_${seq++}` // 行级稳定标识

export const useDraftStore = create(
  persist(
    (set) => ({
      customerId: '',
      items: [],
      setCustomerId: (customerId) => set({ customerId }),
      addItem: (item) => set((s) => ({ items: [...s.items, { ...item, _key: nextKey() }] })),
      updateItem: (key, patch) =>
        set((s) => ({
          items: s.items.map((it) => (it._key === key ? { ...it, ...patch } : it)),
        })),
      removeItem: (key) => set((s) => ({ items: s.items.filter((it) => it._key !== key) })),
      clear: () => set({ customerId: '', items: [] }),
    }),
    {
      name: 'qing-draft-order',
      partialize: (s) => ({ customerId: s.customerId, items: s.items }), // 只持久化业务数据
    },
  ),
)

组件消费(节选):

const items = useDraftStore((s) => s.items)
const removeItem = useDraftStore((s) => s.removeItem)
// 渲染明细行时:items.map(it => <Row key={it._key} ... onRemove={() => removeItem(it._key)} />)

同一需求在 Vue 版用的是 Pinia store + 插件持久化——对比两份代码的行数与心智负担,就是 8.7 选型表的体感版。

⚠️ 常见易错点

易错点 1 · 绕过 store 直接改数据

❌ 错误代码:

const profile = useSelector((s) => s.user.profile)
profile.name = '改个名'          // 💥 直接改订阅来的对象
useUserStore.setState((s) => { s.items.push(newItem) }) // Zustand 里绕过 set 直接改

错误原因:RTK 的 Immer 只在 reducer 内部生效;组件里拿到的是真实引用,直接改不会触发订阅更新(或触发后引用混乱),且 DevTools 里看不到任何 action。Zustand 同理,一切变更必须走 set。

✅ 正确写法:

dispatch(setUser({ ...profile, name: '改个名' })) // RTK:action 驱动
useUserStore.setState((s) => ({ items: [...s.items, newItem] })) // Zustand:set 产新引用

(在 reducer 内写 state.profile = payload 是正确且推荐的——那是 Immer 的合法语法,别混淆边界。)

易错点 2 · selector 返回新引用 → 重渲染风暴

❌ 错误代码:

const pending = useSelector((s) => s.orders.list.filter((o) => o.status === 'pending_review'))
const summary = useSelector((s) => ({ total: s.orders.total, count: s.orders.count }))

错误原因:filter 每次返回新数组、对象字面量每次是新引用;任何无关 action 导致 store 变化时,useSelector 严格比较引用失败 → 组件重渲染,大列表下性能崩塌(第二种写法还可能引发「store 变化 → setState → 再渲染」的循环警告)。

✅ 正确写法:

const pending = useSelector(selectPendingOrders) // createSelector:输入不变引用复用

import { useShallow } from 'zustand/react/shallow' // Zustand 的对应解法
const summary = useUserStore(useShallow((s) => ({ total: s.total, count: s.count })))

易错点 3 · 把异步逻辑塞进 reducer

❌ 错误代码:

const userSlice = createSlice({
  reducers: {
    async login(state, action) { // 💥 reducer 不允许是异步函数
      const data = await loginApi(action.payload)
      state.profile = data.profile
    },
  },
})

错误原因:reducer 必须是同步纯函数(Redux 的时间旅行、日志、回放全都建立在「同一 action 必产出同一 state」之上)。异步副作用属于 thunk 或 Zustand 的 action 函数。

✅ 正确写法:

export const login = createAsyncThunk('user/login', (credentials) => loginApi(credentials))
// 异步在 thunk,结果经 extraReducers 写回 state(见 §8.5)

易错点 4 · thunk 报错被吞:组件里 try/catch 捕不到

❌ 错误代码:

try {
  await dispatch(login(credentials)) // rejected 时并不会 throw!
  navigate('/dashboard')             // 💥 登录失败也会跳转
} catch (err) {
  message.error('登录失败')
}

错误原因:dispatch(thunk) 返回的 Promise 默认不会 reject(错误被装进 action.error/payload 交给 extraReducers),try/catch 形同虚设。

✅ 正确写法:

try {
  await dispatch(login(credentials)).unwrap() // unwrap:rejected 转成真正的 throw
  navigate('/dashboard', { replace: true })
} catch (err) {
  message.error(err.message) // rejectWithValue 的内容在这里接住
}

(Zustand 版没有这个问题——async action 就是普通 Promise。)

易错点 5 · 状态放错层:什么都进全局 store

❌ 错误代码:

// 把「服务端缓存」和「页面临时态」都塞进 store
ordersSlice: 存订单列表数据(要自己处理 loading/竞态/失效……)
uiSlice:    存「弹窗是否打开」「表格选中行」(切页即弃的 UI 态全局化)

错误原因:订单列表是服务端态——放 store 意味着你要自己实现缓存失效、竞态取消、重试,等于手写半个请求库;弹窗开关这类本地态进全局,导致 store 膨胀、组件无法独立复用。

✅ 正确写法:

// 三分法归位:
// 服务端态 → TanStack Query(第 09 章)
// 跨页共享态 → store(用户态/字典/草稿,本章内容)
// 本地 UI 态 → useState 留在组件里

易错点 6 · 持久化不加节制

❌ 错误代码:

persist(store, { name: 'qing-app' }) // 整棵 state 树原样落盘
// 结果:用户 token、草稿、字典、甚至临时 loading 态全部进 localStorage

错误原因:localStorage 无过期、无加密、XSS 可读(第 11 章专题);把易变的服务端数据落盘还会造成「缓存了旧数据还不知道」的幽灵 bug。

✅ 正确写法:

persist(api, {
  name: 'qing-draft-order',
  partialize: (s) => ({ customerId: s.customerId, items: s.items }), // 白名单:只存该存的
})
// 清单:草稿 ✅;token → 第 11 章单独评审;字典/订单数据 ❌(它们有各自的缓存层)

✅ 最佳实践

  1. 先过三分法再写 store:任何「要不要放全局」的争论,用「本地 UI 态 / 服务端态 / 跨页共享态」一句话裁决。
  2. slice 即模块:RTK 里 reducer、action、selector、thunk 写在同一个 slice 文件里内聚导出,找代码只认 slice 名(≈ Pinia 一个 store 一个文件)。
  3. selector 订阅最小面:订阅到字段级;派生计算进 createSelector,组件里零 filter/map。
  4. Immer 双面记牢:reducer 内可直改(Immer 代理),reducer 外一切走 dispatch/set——把这条写进团队 ESLint 心智。
  5. 异步三态显式建模:idle/loading/succeeded/failed 四态是字典类数据的通用模板;组件里永远有 loading 与 error 分支。
  6. Zustand selector 一律带函数:useUserStore((s) => s.x) 而非整个 store;返回对象配 useShallow。
  7. 持久化走白名单:partialize 列字段,敏感数据不落盘,落盘 key 统一 qing- 前缀可识别。
  8. DevTools 参与开发流:提 PR 前自己先看一遍 action 流是否干净——能被回放的状态才有可调试性。

📌 本章小结

  1. 状态三分法:本地 UI 态留组件、服务端态交给第 09 章的 TanStack Query、跨页共享态进 store——选型先于写码。
  2. RTK 四件套:configureStore(默认带 DevTools)、createSlice(内置 Immer,reducer 内可直改)、useSelector + createSelector(订阅与引用稳定性)、createAsyncThunk + extraReducers(异步三态,unwrap 让错误真正可捕获)。
  3. Zustand 用一个 create 覆盖同职责场景:无 Provider、无 dispatch、selector 级订阅、persist 一行持久化;选型看团队规模与存量约束。
  4. 用户态、字典缓存、订单暂存区三个「轻订单」真实状态已就位,第 11 章的鉴权体系直接复用。
  5. 下一章进入服务端交互:axios 请求层与拦截器两版同构,而 TanStack Query 会把本章里你差点手写的「缓存、失效、竞态」全部接管——store 从此更瘦。

📄 第 09 章 · 服务端交互与 TanStack Query 09-服务端交互与TanStackQuery.md

📍 阶段:中级阶段(第 7–12 章)|⏱ 预计学时:7 小时|🔗 前置:第 08 章

⬅ 上一章:状态管理 | 📖 本版目录 | ➡ 下一章:表单处理

💡 对照学习:本页是 React 版第 09 章,对应 Vue 版第 09 章 · 服务端交互与接口层设计,两版练习任务完全一致(request 实例 → 无感刷新并发验证 → useOrderList 强化版)。概念映射:axios 实例与拦截器两版完全同构;本页的 useOrderList(TanStack Query 版)≈ Vue 版自研 useOrderList composable;queryKey ≈ 请求依赖参数;useQuery 的缓存/去重/竞态取消 ≈ Vue 版手写的 AbortController + 缓存 Map;invalidateQueries ≈ 手动 reload();useMutation 乐观更新 ≈ 「先改本地再同步服务端」手写逻辑。两章读完你会发现:手写过的每一行基建,Query 都有对应物。

🎯 学习目标

学完本章,你将能够:

  1. 按四层分工(请求层 / API 模块 / 数据编排 Hook / 视图)组织接口代码,说出每层的禁止事项;
  2. 封装 axios 实例:baseURL 环境变量化、token 注入、统一解包、ApiError 错误归一化、silent 静默选项;
  3. 实现附录A 的 40102 无感刷新:单飞锁 + 请求重放 + _retried 死循环防护,并画出完整时序;
  4. 解释竞态(先发后至)的成因,用 AbortController 与「查询键变更丢弃旧结果」两种手段治理;
  5. 配置 TanStack Query v5(QueryClientProvider),用 useQuery 声明式获取数据并说出 staleTime 与缓存策略的关系;
  6. 用 useMutation + invalidateQueries 完成写操作后的缓存失效,用 onMutate 实现乐观更新与回滚;
  7. 编写并复用 useOrderList Hook:URL 筛选 → 防抖 → Query 缓存 → 分页翻页不闪烁,对应 Vue 版同名 composable。

📖 核心知识点

9.1 接口层的四层分工

企业项目接口代码混乱的根源是不分层:组件里直接 axios.get、错误处理五花八门、同一接口三处重复。约定四层:

src/utils/request.js       —— 传输层:axios 实例、拦截器、token、错误归一化(全站唯一)
src/api/*.js               —— 接口层:一个业务域一个文件,只描述「接口长什么样」
src/hooks/useOrderList.js  —— 数据编排层:缓存、分页、防抖、失效(业务无感知)
src/views/orders/*.jsx     —— 视图层:只消费数据与动作,禁止 import axios

四条禁令:视图层禁止直接 import axios;API 层禁止出现组件逻辑(loading、跳转);数据编排只写在 hooks 里;错误 toast 全站只在请求层弹一次。这四条让「改接口超时时间」「换错误提示组件」变成一处改动。

9.2 axios 实例与环境变量

pnpm add axios @tanstack/react-query antd
# .env.development —— 开发环境:走 Vite 代理打本地 mock(vite.config.js 里配 proxy)
VITE_API_BASE_URL=/api/v1
# .env.production
VITE_API_BASE_URL=https://api.qingorder.example.com/api/v1
// src/utils/request.js —— 传输层:全站唯一的 axios 实例
import axios from 'axios'
import { message } from 'antd'

// 安全红线:接口地址一律来自环境变量,代码里不出现真实域名/密钥
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api/v1'

const request = axios.create({
  baseURL: BASE_URL,
  timeout: 10_000,
})

export default request

/** 业务错误对象:code 沿用附录A 语义(40101 未登录 / 40102 token 过期 / 40301 无权限 / 40901 冲突) */
export class ApiError extends Error {
  constructor(code, message, raw) {
    super(message)
    this.name = 'ApiError'
    this.code = code
    this.raw = raw
  }
}

💡 AntD 5 的静态 message 在多实例主题场景建议改用 <App> 组件包裹 + App.useApp() 获取实例;教学代码从简,生产规范见 https://ant.design/components/app-cn 。

9.3 请求拦截器:token 注入

// src/utils/request.js 追加
const ACCESS_TOKEN_KEY = 'qing_access_token' // 存储位置的 XSS 权衡是第 11 章专题

request.interceptors.request.use((config) => {
  const token = localStorage.getItem(ACCESS_TOKEN_KEY)
  if (token) config.headers.Authorization = `Bearer ${token}` // 附录A 认证约定
  return config
})

两个细节:token 读一次存内存也行,但登出/刷新跨模块同步麻烦,教学版直接读 localStorage;拦截器必须 return config——忘写是新手第一大事故(请求头全丢,还没有任何报错)。

9.4 响应拦截器:解包、错误归一化与静默选项

附录A 统一响应体 { code, message, data }。拦截器做三件事:code === 0 时直接返回 data(调用方永远拿业务数据);非 0 归一化成 ApiError;40102 转交无感刷新(§9.5):

// src/utils/request.js 追加
request.interceptors.response.use(
  (response) => {
    const res = response.data
    if (res.code === 0) return res.data // 解包:api 层与组件拿到的直接是 data
    return handleBusinessError(res, response.config) // 业务错误:40102 走刷新,其余抛 ApiError
  },
  (error) => {
    // HTTP 层错误:网络断开、超时、5xx
    const isTimeout = error.code === 'ECONNABORTED'
    const err = new ApiError(-1, isTimeout ? '请求超时,请重试' : '网络异常,请稍后重试', error)
    if (!error.config?.silent) message.error(err.message)
    return Promise.reject(err)
  },
)

function handleBusinessError(res, config) {
  if (res.code === 40102) return refreshAndRetry(res, config) // §9.5 无感刷新
  if (res.code === 40101) {
    // 真未登录:清会话,交给守卫跳登录(第 11 章接入 store 后在此 dispatch clearUser)
    localStorage.removeItem('qing_refresh_token')
  }
  const err = new ApiError(res.code, res.message, res)
  if (!config.silent) message.error(err.message) // 静默页面可传 silent: true 自行处理
  return Promise.reject(err)
}

silent 是重要的产品级设计:轮询、后台预取类请求不该每次失败都弹 toast,调用方在 config 里显式申请静默,责任就清晰了。

9.5 40102 无感刷新:单飞锁 + 请求重放

双令牌机制(accessToken 2 小时 / refreshToken 7 天,附录A §3.1)下,token 过期不该打断用户。完整时序:

请求A、B、C 同时收到 40102
  → 响应拦截器发现 refreshingPromise 不存在:A 创建刷新 Promise(单飞锁)
  → B、C await 同一个 Promise(等待队列),refresh 只发一次
  → 刷新成功:A/B/C 各自带新 token 重放原请求(打 _retried 标记)
  → 重放若再 40102:说明 refreshToken 也失效 → 清会话跳登录,绝不再刷(死循环防护)
// src/utils/request.js 追加 —— 无感刷新核心(与 Vue 版逐行同构)
const REFRESH_TOKEN_KEY = 'qing_refresh_token'
const rawAxios = axios.create() // 裸 axios:不经拦截器,避免刷新请求自身 40102 时递归

let refreshingPromise = null // 单飞锁:同一时刻最多一个刷新请求

function refreshAccessToken() {
  if (!refreshingPromise) {
    refreshingPromise = rawAxios
      .post(`${BASE_URL}/auth/refresh`, { refreshToken: localStorage.getItem(REFRESH_TOKEN_KEY) })
      .then(({ data }) => {
        if (data.code !== 0) throw new ApiError(data.code, data.message) // refreshToken 也失效
        localStorage.setItem(ACCESS_TOKEN_KEY, data.data.accessToken)
        return data.data.accessToken
      })
      .finally(() => { refreshingPromise = null }) // 成败都放锁,后续请求可重新触发
  }
  return refreshingPromise
}

async function refreshAndRetry(res, config) {
  if (config._retried) {
    // 重放后仍 40102:refreshToken 已死,清会话、抛未登录,绝不无限刷新
    localStorage.removeItem(ACCESS_TOKEN_KEY)
    localStorage.removeItem(REFRESH_TOKEN_KEY)
    return Promise.reject(new ApiError(40101, '登录已过期,请重新登录', res))
  }
  try {
    const token = await refreshAccessToken()
    config._retried = true
    config.headers.Authorization = `Bearer ${token}`
    return request(config) // 用原 config 重放(拦截器会重新走一遍)
  } catch (err) {
    localStorage.removeItem(ACCESS_TOKEN_KEY)
    localStorage.removeItem(REFRESH_TOKEN_KEY)
    return Promise.reject(new ApiError(40101, '登录已过期,请重新登录', res))
  }
}

必须用裸 axios 发刷新请求——刷新接口的响应也走响应拦截器的话,它自己 40102 会再次触发刷新,无限递归(Vue 版易错点 5 同款)。

9.6 取消、防抖与竞态

竞态:搜索框快速输入「订单 → 订单A → 订单AB」,三个请求并发,先发的可能后到,旧结果覆盖新结果。React 里必须显式治理:

// 手动版:AbortController 取消上一个请求(视图层/编排层写法)
let controller = null

async function search(keyword, signal) {
  controller?.abort() // 取消上一次
  controller = new AbortController()
  try {
    return await fetchOrders({ keyword }, { signal: controller.signal })
  } catch (err) {
    if (err.name !== 'CanceledError') throw err // 取消不算错误
  }
}

防抖:关键词输入 400ms 才真正生效,用一个可复用 Hook:

// src/hooks/useDebouncedValue.js —— 防抖:值稳定后再交给请求层
import { useEffect, useState } from 'react'

export function useDebouncedValue(value, delay = 400) {
  const [debounced, setDebounced] = useState(value)
  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay)
    return () => clearTimeout(timer) // 值又变了:清掉旧定时器
  }, [value, delay])
  return debounced
}

好消息是:接入 TanStack Query 后,竞态取消是内置的——queryKey 变化视为新查询,旧查询的结果自动丢弃(且框架把 signal 递给 queryFn 让你顺手取消请求);防抖也可以通过 placeholderData 缓解翻页闪烁。手写版的意义在于理解原理、以及维护没有 Query 的存量项目。

9.7 TanStack Query v5 入门:useQuery

Query 的世界观:服务端数据不是你的 state,是「远程数据的缓存视图」。你声明「这个数据依赖什么参数」(queryKey),框架负责缓存、去重、后台刷新、竞态、重试。

pnpm add @tanstack/react-query
// src/main.jsx —— QueryClientProvider 挂载
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Provider } from 'react-redux'
import { store } from './store'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 30_000, // 30s 内数据视为新鲜:切页回来不重新请求
      retry: 1,          // 失败重试 1 次(默认 3 次);写操作用 mutation,默认不重试
    },
  },
})

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <Provider store={store}>
      <QueryClientProvider client={queryClient}>
        <BrowserRouter>
          <App />
        </BrowserRouter>
      </QueryClientProvider>
    </Provider>
  </StrictMode>,
)
// src/views/orders/OrderDetail.jsx —— 最小可用 useQuery
import { useQuery } from '@tanstack/react-query'
import { useParams } from 'react-router'
import { fetchOrderDetail } from '@/api/orders'

export default function OrderDetail() {
  const { id } = useParams()

  const { data: order, isPending, isError, error } = useQuery({
    queryKey: ['orders', 'detail', id], // ① 身份证:参数进 key,id 变化 → 自动重新请求
    queryFn: ({ signal }) => fetchOrderDetail(id, { signal }), // ② signal 透传给 axios 取消请求
    enabled: Boolean(id), // ③ 条件查询:id 不存在就不发请求
  })

  if (isPending) return <div className="page-loading">加载中…</div>
  if (isError) return <div>加载失败:{error.message} <button onClick={() => refetch()}>重试</button></div>
  return <h2>{order.orderNo}</h2>
}

v5 三个必知:

  1. 对象签名:v5 只接受 useQuery({ ... }) 对象写法(v4 的位置参数已移除);
  2. isPending 取代 isLoading 表达「首次加载无数据」;isFetching 表示「含后台刷新在内的取数中」——翻页时想保留旧数据不闪 loading,见 §9.9;
  3. queryKey 是结构化哈希:['orders', 'list', { page: 1, status: 'draft' }] 中对象成员顺序无关、内容变化即视为新 key——把请求参数全部放进 queryKey 是缓存正确性的根基(易错点 4 的反面)。

9.8 API 模块化:一个业务域一个文件

// src/api/orders.js —— 订单域接口:只描述接口,不含任何组件逻辑
import request from '@/utils/request'

export function fetchOrders(params, config = {}) {
  return request.get('/orders', { params, ...config }) // 附录A 分页:{ list, total, page, pageSize }
}
export function fetchOrderDetail(id, config = {}) {
  return request.get(`/orders/${id}`, config)
}
export function createOrder(payload) {
  return request.post('/orders', payload)
}
export function submitOrder(id) {
  return request.post(`/orders/${id}/submit`) // draft → pending_review
}
export function reviewOrder(id, action, reason) {
  // 财务审批:approve / reject(>10% 折扣需 finance 角色,附录A §2)
  return request.post(`/orders/${id}/${action}`, { reason })
}
export function cancelOrder(id, reason) {
  return request.post(`/orders/${id}/cancel`, { reason }) // 需填原因,附录A 状态机
}

api 层因为拦截器已解包,返回值就是 data 本体——层与层的契约靠「返回值口径一致」维持(易错点 2)。

9.9 useOrderList 完整示例:本章集大成之作

把 URL 筛选(第 07 章)、防抖(§9.6)、Query 缓存(§9.7)、翻页保旧(placeholderData)全部收进一个 Hook,组件只剩「用」——它与 Vue 版 useOrderList composable 职责完全一致,可直接对照:

// src/hooks/useOrderList.js —— 订单列表数据编排(本章集大成,第 12 章实战直接复用)
import { useMemo } from 'react'
import { useSearchParams } from 'react-router'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { fetchOrders } from '@/api/orders'
import { useDebouncedValue } from './useDebouncedValue'

/** @param {object} initialQuery 初始筛选兜底(URL 为空时使用) */
export function useOrderList(initialQuery = {}) {
  const [searchParams, setSearchParams] = useSearchParams()

  // 1. URL 是筛选状态的唯一事实源(第 07 章练习 2 的成果)
  const filters = useMemo(
    () => ({
      keyword: searchParams.get('keyword') ?? initialQuery.keyword ?? '',
      status: searchParams.get('status') ?? initialQuery.status ?? '',
      page: Number(searchParams.get('page') ?? 1),
      pageSize: 20, // 附录A:20/页
    }),
    [searchParams, initialQuery],
  )

  // 2. 关键词防抖:输入即时写 URL(体验),请求用防抖后的值(性能)
  const debouncedKeyword = useDebouncedValue(filters.keyword, 400)
  const effectiveFilters = useMemo(
    () => ({ ...filters, keyword: debouncedKeyword }),
    [filters, debouncedKeyword],
  )

  // 3. 筛选变更:增量更新 searchParams(空值删除、改筛选回第一页)
  const setFilters = (patch) => {
    const next = new URLSearchParams(searchParams)
    Object.entries(patch).forEach(([key, value]) => {
      if (value === '' || value == null) next.delete(key)
      else next.set(key, String(value))
    })
    if (!('page' in patch)) next.set('page', '1')
    setSearchParams(next)
  }

  // 4. Query:参数全在 queryKey → 缓存正确、竞态自动取消
  const query = useQuery({
    queryKey: ['orders', 'list', effectiveFilters],
    queryFn: ({ signal }) => fetchOrders(effectiveFilters, { signal }),
    placeholderData: keepPreviousData, // 翻页/防抖期间保留上一份结果,界面不闪空
  })

  return {
    filters,
    setFilters,
    list: query.data?.list ?? [],
    total: query.data?.total ?? 0,
    isPending: query.isPending,   // 首次加载(无任何数据)
    isFetching: query.isFetching, // 翻页后台刷新中(可用于进度条)
    error: query.error,
    reload: () => query.refetch(), // 手动刷新(invalidateQueries 是更常用的失效方式)
  }
}
// src/views/orders/OrderListView.jsx —— 消费 useOrderList(组件薄如纸)
import { useOrderList } from '@/hooks/useOrderList'
import { OrderTable, OrderFilters } from './components'
import { ORDER_STATUS_OPTIONS } from '@/constants/orderStatus'

export default function OrderListView() {
  const { filters, setFilters, list, total, isPending, isFetching, reload } = useOrderList()

  return (
    <div>
      <OrderFilters value={filters} onChange={setFilters} statusOptions={ORDER_STATUS_OPTIONS} />
      <OrderTable list={list} loading={isPending} />
      {/* 分页、导出等在第 12 章 M2 补全 */}
    </div>
  )
}

9.10 useMutation:写操作、失效与乐观更新

读用 useQuery,写用 useMutation。三种用法逐级递进:

// 用法一:基础提交(新建订单)
const createMutation = useMutation({ mutationFn: createOrder })
// 调用:createMutation.mutate(payload);isPending 做防重复提交(第 10 章)

// 用法二:成功后失效缓存(提交草稿 → 列表与详情都要刷新)
const queryClient = useQueryClient()
const submitMutation = useMutation({
  mutationFn: submitOrder,
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['orders'] }) // 粗粒度:orders 下全部标记过期
    message.success('已提交审核')
  },
})
// 精确版:invalidateQueries({ queryKey: ['orders', 'detail', id] }) 只失效这一单

// 用法三:乐观更新(财务审批「通过」:先改界面,服务端确认,失败回滚)
const approveMutation = useMutation({
  mutationFn: (id) => reviewOrder(id, 'approve'),
  onMutate: async (id) => {
    await queryClient.cancelQueries({ queryKey: ['orders', 'detail', id] }) // 停掉在途请求防覆盖
    const prev = queryClient.getQueryData(['orders', 'detail', id])         // 快照
    queryClient.setQueryData(['orders', 'detail', id], (old) =>
      old ? { ...old, status: 'approved' } : old, // 乐观:UI 立即变成已审核
    )
    return { prev } // 上下文传给 onError
  },
  onError: (_err, id, ctx) => {
    if (ctx?.prev) queryClient.setQueryData(['orders', 'detail', id], ctx.prev) // 回滚
    message.error('审核失败,已还原状态')
  },
  onSettled: (_d, _e, id) => {
    // 无论成败:与服务端对齐一次(成功拿确认,失败拿真实状态)
    queryClient.invalidateQueries({ queryKey: ['orders', 'detail', id] })
  },
})

乐观更新的适用判断:操作成功率极高、用户对即时反馈敏感(审批、点赞、上下架)才用;涉及钱的金额变动,宁可等真实响应。

🛠 渐进式实战练习

延续第 07 章仓库;mock 数据统一放 mock/orders.js(导出 30+ 条订单,字段严格按附录A:orderNo = 'QO' + yyyyMMdd + 6 位序号、金额单位分)。

练习 1 · 基础(10–20 min):request 实例 + 静默选项

任务描述:完成 §9.2–9.4 的 request.js(不含无感刷新):解包、ApiError、超时与网络错误归一化、silent 选项。写一个 api/health.js:ping() 正常返回、pingSilent() 带 silent: true。用两个按钮触发,验证:普通请求失败弹一次 toast;静默请求失败不弹,但 catch 里能拿到 err.code。

提示:mock 失败可以直接请求一个不存在的路径触发 404,或临时把拦截器里 res.code 改成 4xx01 分支验证业务错误路径;silent 从 response.config / error.config 读。

验收标准:

  • [ ] code === 0 时调用方拿到的直接是 data(console 打印验证);
  • [ ] 业务错误与 HTTP 错误都抛 ApiError,且 toast 各只弹一次;
  • [ ] silent: true 时零 toast,错误对象上有 code。
参考答案(silent 的传递与读取)
// api/health.js
import request from '@/utils/request'
export function ping() {
  return request.get('/ping') // mock 返回 { code: 40901, message: '模拟业务错误' }
}
export function pingSilent() {
  return request.get('/ping', { silent: true }) // config 上的自定义字段会被 axios 透传保留
}

// 组件里:
try {
  await pingSilent()
} catch (err) {
  if (err.code === 40901) message.info('冲突但静默:' + err.message) // 自行决定呈现方式
}

关键点:axios 允许在 config 上挂自定义字段(如 silent、_retried),请求与响应拦截器都能从 config 读到——这是请求层「扩展点」的惯用法。

练习 2 · 进阶(30–60 min):无感刷新并发验证

任务描述:实现 §9.5 完整无感刷新。mock 层做一个「40102 制造机」:第一次请求任意接口返回 code: 40102,刷新接口 /auth/refresh 延迟 1s 后返回新 token;之后所有请求正常。在一个页面用 Promise.all 同时发 3 个请求。验证:Network 面板里 /auth/refresh 只出现 1 次,3 个请求全部重放成功;再 mock「刷新也失败」的场景,验证清 token + 抛 40101,无死循环。

提示:单飞锁 = 模块级 refreshingPromise 变量;重放标记挂 config._retried;mock 刷新失败时返回 code: 40102 试试你的防护是否兜住。

验收标准:

  • [ ] 3 个并发 40102 → 1 次刷新 + 3 次重放,界面无感知;
  • [ ] 刷新接口自身失败 → token 被清、ApiError(40101) 抛给调用方、没有第二次 /auth/refresh;
  • [ ] 刷新过程中新发起的请求(第 4 个)也等到同一个 Promise,拿到新 token 直接成功。
参考答案(第 4 个请求为何也能成功)

刷新期间新请求会带着旧 token 发出并收到 40102,进入 refreshAndRetry → 调 refreshAccessToken()。此时 refreshingPromise 仍非空(finally 未执行),于是它 await 的是同一个进行中的刷新 Promise——刷新完成后拿到新 token 重放。这就是「单飞锁 + 等待队列」合一的妙处:锁既是去重器也是分发器。时序图见 §9.5。

练习 3 · 挑战(选做,60+ min):useOrderList 强化版

任务描述:在 §9.9 useOrderList 基础上追加:①排序参数 sort(createdAt / totalAmount,附录A:按创建时间/金额排序)与 order(asc/desc),由表头点击触发并写入 URL;②暴露 exportSelected(ids),调用 exportOrdersCsv(返回 CSV 文本)后用 Blob 触发浏览器下载;③用 React DevTools Profiler 验证:输入关键词打字过程中,表格组件的重渲染次数 ≤ 防抖后的请求次数 + 2。

提示:导出是「拿到文本 → new Blob([text]) → URL.createObjectURL → <a download> 点击」三步,记得 revokeObjectURL;排序变化也属于 filters 的一部分,自然进 queryKey——这正是 Query 的舒服之处。

验收标准:

  • [ ] 点表头切换排序,URL 出现 sort=totalAmount&order=desc,刷新后排序保持;
  • [ ] 导出的 CSV 用 Excel 打开列头正确、金额为分(附一行表头注释「金额单位:分」);
  • [ ] 翻页时表格不闪 loading(placeholderData 生效),底部出现「数据更新中…」细进度条(isFetching 驱动)。
参考答案(导出 + 排序追加)
// src/api/orders.js 追加
export function exportOrdersCsv(ids) {
  return request.post('/orders/export-csv', { ids }) // mock 返回 CSV 文本
}

// src/hooks/useOrderList.js 追加/修改
export function useOrderList(initialQuery = {}) {
  // ...filters 增加:
  //   sort: searchParams.get('sort') ?? 'createdAt',
  //   order: searchParams.get('order') ?? 'desc',
  const exportSelected = async (ids) => {
    const csv = await exportOrdersCsv(ids)
    const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8' }) // BOM 防中文乱码
    const url = URL.createObjectURL(blob)
    const a = document.createElement('a')
    a.href = url
    a.download = `orders-${Date.now()}.csv`
    a.click()
    URL.revokeObjectURL(url) // 释放,防内存泄漏
  }
  return { /* ...原有返回值, */ exportSelected }
}

排序触达点全在 setFilters({ sort, order })——filters 变化 → queryKey 变化 → 自动重新请求并生成新缓存条目,没有任何「手动重查」代码,这就是把参数放进 queryKey 的回报。

⚠️ 常见易错点

易错点 1 · 金额浮点求和陷阱(附录A:金额单位为分)

❌ 错误代码:

// 前端把分转成元再求和、再累加折扣
const total = items.reduce((s, it) => s + (it.unitPrice / 100) * it.quantity, 0)
// unitPrice=129900(分) × 3 件 → 3897.0000000000005 元
// 展示 ¥3897.00 看似没事,但对账 diff 0.0000000000005,财务对不上账

错误原因:IEEE 754 二进制浮点无法精确表示 0.1 这类十进制小数(0.1 + 0.2 === 0.30000000000000004)。只要出现「分转元的小数运算」,误差就进来了。

✅ 正确写法:

// 全程以「分」为最小单位做整数运算,只在展示层转一次元
const totalFen = items.reduce(
  (sum, it) => sum + Math.round(it.quantity * it.unitPrice * (it.discountPercent / 100)),
  0, // 整数加减完全精确;除法仅发生在最后 formatFen
)
export const formatFen = (fen) => (fen / 100).toFixed(2) // 仅渲染层调用

易错点 2 · 拦截器返回值口径不一致

❌ 错误代码:

request.interceptors.response.use((response) => {
  if (response.data.code === 0) console.log('ok') // 忘了 return!结果:所有调用方拿到 undefined
})

错误原因:拦截器是管道,每个函数的返回值是下一站的输入;不 return 就断流。口径(返回 data 还是 response)必须全站唯一。

✅ 正确写法:

request.interceptors.response.use(
  (response) => (response.data.code === 0 ? response.data.data : handleBusinessError(response)),
  // 全站约定:api 层拿到的永远是业务 data;错误永远是 ApiError
)

易错点 3 · 竞态:先发后至,旧数据覆盖新数据

❌ 错误代码:

useEffect(() => {
  fetchOrders({ keyword }).then(setList) // 快速输入时:慢的旧响应最后到达,覆盖新结果
}, [keyword])

错误原因:网络时延不可控,「请求发出顺序 ≠ 响应到达顺序」。没有取消/丢弃机制时,界面会「输入完却显示旧关键词的结果」。

✅ 正确写法:

// 首选:TanStack Query —— key 变化即新查询,旧响应自动丢弃
useQuery({
  queryKey: ['orders', 'list', { keyword }],
  queryFn: ({ signal }) => fetchOrders({ keyword }, { signal }), // 顺带真正取消请求
})
// 手写场景:AbortController abort 上一个 + 组件卸载清理(§9.6)

易错点 4 · 筛选参数没进 queryKey:界面与缓存「对不上」

❌ 错误代码:

const { data } = useQuery({
  queryKey: ['orders', 'list'],              // 💥 key 里没有筛选条件
  queryFn: () => fetchOrders(filters),       // filters 变了,key 没变
})
// 现象:切状态筛选 → 命中同一缓存 → 界面永远显示第一次的数据

错误原因:Query 用 queryKey 识别「这是哪份数据」。参数在闭包里而不在 key 里,框架无法感知请求语义变化——这是 React 版「query string 变化不触发请求」的最常见根因。

✅ 正确写法:

const { data } = useQuery({
  queryKey: ['orders', 'list', filters], // 参数全量进 key(结构化哈希,对象顺序无关)
  queryFn: ({ signal }) => fetchOrders(filters, { signal }),
})
// 自检口诀:queryFn 里用到的每个「会变的值」,都必须出现在 queryKey 里

易错点 5 · 刷新请求自身走了拦截器,刷新失败成死循环

❌ 错误代码:

const { data } = await request.post('/auth/refresh', { refreshToken })
// request 带 token、带响应拦截器:刷新接口返回 40102 → 再次触发 refreshAccessToken → 无限循环

错误原因:刷新接口用「带拦截器的实例」发出,它的 40102 响应又进入刷新逻辑,递归到网络层限流为止。

✅ 正确写法:

const rawAxios = axios.create() // 裸实例:无拦截器、无 token 注入
const { data } = await rawAxios.post(`${BASE_URL}/auth/refresh`, { refreshToken })
// 双保险:重放请求打 _retried 标记,二次 40102 直接清会话抛 40101(§9.5)

易错点 6 · queryFn 吞错误,Query 以为成功

❌ 错误代码:

useQuery({
  queryKey: ['orders', 'list', filters],
  queryFn: async () => {
    try {
      return await fetchOrders(filters)
    } catch (err) {
      message.error(err.message)
      return [] // 💥 错误被吞:isError 永远 false,空数组进了缓存还被视为「新鲜」
    },
})

错误原因:queryFn 必须让错误抛出去,Query 才能标记 isError、触发重试、暴露给错误边界。catch 里 return 兜底值等于向缓存注入假数据。

✅ 正确写法:

useQuery({
  queryKey: ['orders', 'list', filters],
  queryFn: ({ signal }) => fetchOrders(filters, { signal }), // 错误自然上抛
})
// 呈现层处理:if (isError) return <ErrorState onRetry={refetch} />
// 请求层 toast 已由拦截器负责,queryFn 里不要重复弹

✅ 最佳实践

  1. 四层分工写进目录约定:utils/request.js 全站唯一;api/ 一个业务域一个文件;数据编排进 hooks;视图层禁止 import axios——code review 一眼可查。
  2. 错误两通道:业务错误 toast 在请求层统一弹一次;需要定制处理(409 冲突、静默轮询)走 ApiError.code 分支与 silent 选项,禁止组件里再 try/catch 弹第二遍。
  3. 无感刷新三件套缺一不可:单飞锁(防并发刷新)、_retried 标记(防重放循环)、裸 axios 发刷新(防自递归)——对照 Vue 版逐行同构,双栈团队可共用时序图评审。
  4. queryKey 即依赖:请求参数全量进 key;列表类 key 用 ['orders', 'list', filters],详情 ['orders', 'detail', id];失效时按前缀 ['orders'] 粗失效、按完整 key 细失效。
  5. staleTime 显式声明:全局默认 30s,关键列表可调;不配置意味着 0(每次挂载都后台刷新),切页频繁时请求数失控。
  6. placeholderData: keepPreviousData 用于翻页:界面不闪空、体验平滑;首次加载仍用 isPending 渲染骨架屏。
  7. 防抖收口在一个 Hook:useDebouncedValue 全站复用,禁止各页面自己写 setTimeout(清理逻辑十写九漏)。
  8. 环境变量分层:VITE_API_BASE_URL 按环境拆 .env.development/.env.production,开发走 Vite proxy 打本地 mock——密钥类变量永远不进前端代码。

📌 本章小结

  1. 接口层四层分工(传输/API/编排/视图),视图不碰 axios、错误只弹一次——分层是可维护性的第一杠杆。
  2. axios 封装三件套:token 注入、统一解包 + ApiError 归一化、40102 无感刷新(单飞锁 + 重放 + _retried 防护),与 Vue 版逐行同构。
  3. TanStack Query 的世界观:服务端数据是「缓存的视图」,queryKey 声明依赖、staleTime 声明新鲜度、竞态与去重框架全包;useMutation + invalidateQueries 管写操作,onMutate 乐观更新要配快照回滚。
  4. useOrderList 是本章集大成:URL 事实源 + 防抖 + Query 缓存 + 翻页保旧,与 Vue 版同名 composable 职责完全对应,第 12 章实战直接复用。
  5. 下一章处理「写」的另一半:React Hook Form + Zod 接管复杂表单——新建订单的动态明细行、金额联动与校验契约。

📄 第 10 章 · 表单处理与校验 10-表单处理与校验.md

📍 阶段:中级阶段(第 7–12 章)|⏱ 预计学时:6 小时|🔗 前置:第 09 章

⬅ 上一章:服务端交互 | 📖 本版目录 | ➡ 下一章:登录鉴权

💡 对照学习:本页是 React 版第 10 章,对应 Vue 版第 10 章 · 表单处理与校验,两版练习任务完全一致(静态字段校验 → 动态明细行 + 金额联动 → 草稿暂存 + 审批闭环)。概念映射:register ≈ v-model + :rules 绑定二合一;zodResolver(schema) ≈ ElForm 的 :rules 规则对象(但校验规则与 UI 彻底解耦、可单测);formState.errors ≈ formRef.validate() 回调的错误对象;useFieldArray ≈ 动态 v-for 明细行 + prop="items.${i}.quantity" 路径;Controller ≈ v-model 封装到第三方组件;reset ≈ resetFields;useWatch ≈ 精确 watch 表单字段。

🎯 学习目标

学完本章,你将能够:

  1. 说出受控与非受控两种表单模式的心智差异,解释复杂中后台表单为什么选 React Hook Form;
  2. 用 RHF 三件套(register / handleSubmit / formState)实现带校验时机的静态表单;
  3. 把附录A 的校验规则(数量 1–9999、行折扣 0–100%、必填项)写成 Zod schema,并解释「校验契约与 UI 解耦」带来的可测试性;
  4. 用 Controller 桥接 AntD 受控组件(Select / InputNumber),说出为什么 register 对它们不生效;
  5. 用 useFieldArray 实现明细行的增删改,并解释 fields.id 作 key 与「用 index 作 key」的差别;
  6. 实现「明细金额 → 订单总额 → 整单折扣 → 审批提示」的联动链,金额全程以分为单位;
  7. 处理编辑回填(reset)与防重复提交(isSubmitting),排查 defaultValues 不更新类问题。

📖 核心知识点

10.1 受控与非受控,以及为什么选 React Hook Form

第 02 章讲过:受控输入每个键击都触发重渲染。三种规模下的选择:

表单规模 典型场景 推荐方案
1–3 个字段 搜索框、单个开关 useState 受控足够
中型静态表单 登录、筛选栏 RHF 非受控 + schema 校验
大型动态表单 新建订单(明细行可增删) RHF + Zod + useFieldArray

RHF 的核心卖点恰好命中中后台表单的痛点:非受控注册(输入值存在表单内部 ref,打字不触发整棵组件树重渲染)、schema 化校验(规则一处定义、可单测、与 UI 解耦)、字段级订阅(useWatch 只重渲染需要的部分)。安装:

pnpm add react-hook-form zod @hookform/resolvers

10.2 最小可用:register / handleSubmit / formState

以「新建订单」的静态字段开刀(客户、备注),体会三件套:

// src/views/orders/OrderCreate.jsx —— 骨架版:先跑通三件套
import { useForm } from 'react-hook-form'
import { createOrder } from '@/api/orders'

export default function OrderCreate() {
  const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm({
    defaultValues: { customerId: '', remark: '' }, // 非受控模式的初始值入口
  })

  const onSubmit = async (values) => {
    // values 已通过全部校验,类型形状即 schema(配合 TS 后是强类型)
    await createOrder(values)
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <label>
        客户 ID
        {/* register 返回 ref/onChange/onBlur 并注入 input —— 这就是「非受控注册」 */}
        <input {...register('customerId', { required: '请选择客户' })} />
      </label>
      {errors.customerId && <p className="error">{errors.customerId.message}</p>}

      <label>
        备注
        <textarea {...register('remark', { maxLength: { value: 200, message: '最多 200 字' } })} />
      </label>

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? '提交中…' : '创建订单'}
      </button>
    </form>
  )
}

四个语义要点:handleSubmit 先跑校验、全过了才调 onSubmit(校验失败只渲染 errors,不会提交);register 的第二参是内联规则,简单规则够用;isSubmitting 天然覆盖提交期间,是防重复提交的第一道闸;校验时机默认 onSubmit + onBlur 后首次出错转为 onChange(输入错误字段时即时纠正),无特殊需求不要手写触发时序。

10.3 Zod schema:把附录A 的校验规则写成契约

内联规则一多就散。企业口径:校验规则收敛进 Zod schema——一份文件描述「合法的订单长什么样」,表单、API 提交前、单元测试共用:

// src/schemas/order.js —— 订单校验契约(附录A §3.3:数量 1–9999、行折扣 0–100%、必填)
import { z } from 'zod'

export const orderSchema = z.object({
  customerId: z.string().min(1, '请选择客户'),
  items: z.array(
    z.object({
      skuId: z.string().min(1, '请选择商品'),
      quantity: z.coerce
        .number({ message: '数量必须是数字' })
        .int('数量必须是整数')
        .min(1, '数量至少为 1')
        .max(9999, '数量不能超过 9999'),
      unitPriceFen: z.coerce.number().int('单价必须是整数分').min(1, '单价必须大于 0'),
      discountPercent: z.coerce
        .number()
        .min(0, '折扣不能为负')
        .max(100, '折扣不能超过 100%'),
    }),
  ),
  remark: z.string().max(200, '备注最多 200 字').optional(),
})

接上 resolver,校验逻辑整体替换掉 §10.2 的内联规则:

import { zodResolver } from '@hookform/resolvers/zod'
import { orderSchema } from '@/schemas/order'

const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm({
  resolver: zodResolver(orderSchema), // 校验全部交给 schema
  defaultValues: { customerId: '', items: [emptyItem()], remark: '' },
})

两个 Zod 细节:输入框拿到的数字是字符串('3'),z.coerce.number() 负责转数字并校验——这直接消灭「数字输入是字符串」这类经典 bug;emptyItem() 是工厂函数({ skuId: '', quantity: 1, unitPriceFen: 0, discountPercent: 100 }),保证新行结构与 schema 对齐。schema 是纯函数,第 15 章可以直接对它写单测:orderSchema.safeParse(非法数据) 的用例先于组件存在。

10.4 AntD 组件接入:Controller

register 的原理是把 ref/onChange 注入原生 input。AntD 的 Select / InputNumber / DatePicker 是受控 React 组件(不透传原生 ref 语义),必须用 Controller 桥接——它把 RHF 的字段状态交给你的 render 函数:

import { useForm, Controller } from 'react-hook-form'
import { Select, InputNumber, Input } from 'antd'
import { fetchCustomers } from '@/api/customers'

export default function OrderCreate() {
  // ...useForm 同上

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <label>客户</label>
      <Controller
        name="customerId"
        control={control}
        render={({ field, fieldState }) => (
          <Select
            {...field}                       // value / onChange / onBlur 一次性接好
            showSearch
            optionFilterProp="label"
            placeholder="搜索并选择客户"
            options={customers.map((c) => ({ value: c.id, label: `${c.name}(${c.level})` }))}
            status={fieldState.error ? 'error' : undefined} // AntD 5 的错误态视觉
          />
        )}
      />
      {errors.customerId && <p className="error">{errors.customerId.message}</p>}
      {/* InputNumber 同理:<Controller><InputNumber {...field} /></Controller> */}
    </form>
  )
}

分工口诀:原生 input 用 register,AntD 组件用 Controller。混用时先问一句「这个组件是不是受控 React 组件」,答案决定用哪把刀。

10.5 动态明细行:useFieldArray

附录A 的新建订单需要「添加明细行(商品、数量、单价、行折扣),可增删」——useFieldArray 专门为此而生:

import { useFieldArray, useWatch, useForm, Controller } from 'react-hook-form'
import { Button, Select, InputNumber } from 'antd'
import { calcItemFen, formatFen } from '@/utils/money'

export default function OrderItems({ control, errors }) {
  const { fields, append, remove } = useFieldArray({ control, name: 'items' })
  const items = useWatch({ control, name: 'items' }) ?? [] // 字段级订阅:只重渲染明细区

  return (
    <div className="order-items">
      <table>
        <thead>
          <tr><th>商品</th><th>数量</th><th>单价(分)</th><th>行折扣%</th><th>行金额</th><th /></tr>
        </thead>
        <tbody>
          {fields.map((field, index) => (
            <tr key={field.id}> {/* ✅ fields.id 是 RHF 生成且随行存亡的稳定标识 */}
              <td>
                <Controller
                  name={`items.${index}.skuId`} // 路径精确到第 index 行
                  control={control}
                  render={({ field: f }) => (
                    <Select {...f} showSearch placeholder="选择商品" options={SKU_OPTIONS} />
                  )}
                />
              </td>
              <td>
                <Controller
                  name={`items.${index}.quantity`}
                  control={control}
                  render={({ field: f }) => <InputNumber {...f} min={1} max={9999} />}
                />
              </td>
              <td>
                <Controller name={`items.${index}.unitPriceFen`} control={control}
                  render={({ field: f }) => <InputNumber {...f} min={1} precision={0} />} />
              </td>
              <td>
                <Controller name={`items.${index}.discountPercent`} control={control}
                  render={({ field: f }) => <InputNumber {...f} min={0} max={100} />} />
              </td>
              <td>{formatFen(calcItemFen(items[index] ?? {}))} 元</td>
              <td>
                {/* 只有一行时不允许删,规避「数组为空」的边界(schema 层也可加 min(1) 双保险) */}
                <Button danger size="small" disabled={fields.length === 1}
                  onClick={() => remove(index)}>删除</Button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
      <Button type="dashed" block onClick={() => append(emptyItem())}>+ 添加明细行</Button>
    </div>
  )
}

fields.map 是铁律:遍历 fields(渲染元数据,带稳定 id)而不是 watch 出来的值数组——值的数组在编辑中随时变,用它渲染会让输入框失焦、删除行内容串位(易错点 3)。行内校验错误通过父组件传入的 errors.items?.[index]?.quantity?.message 呈现。

10.6 金额汇总与联动校验:整单折扣 → 审批提示

附录A 的两条业务规则:明细金额 = 数量 × 单价 × 行折扣,订单总额自动汇总;整单折扣 >10%(即 wholeDiscountPercent < 90)提交后进入 pending_review 需财务审批。联动链如下:

// src/views/orders/OrderCreate.jsx —— 联动区(追加在表单底部)
import { useMemo } from 'react'
import { Alert } from 'antd'
import { useWatch } from 'react-hook-form'
import { sumFen, calcWholeDiscountPercent, formatFen } from '@/utils/money'

function OrderSummary({ control }) {
  const items = useWatch({ control, name: 'items' }) ?? []

  const { totalFen, wholeDiscountPercent } = useMemo(() => {
    if (items.length === 0) return { totalFen: 0, wholeDiscountPercent: 0 }
    // ① 明细金额(分):整数运算,见 utils/money(金额单位为分,附录A 约定)
    const totalFen = sumFen(items)
    // ② 整单折扣% = 实收合计 / 原价合计 × 100(≤90 免审批)
    const originalFen = items.reduce(
      (s, it) => s + Math.round((it.quantity ?? 0) * (it.unitPriceFen ?? 0)), 0,
    )
    const wholeDiscountPercent = originalFen > 0 ? Math.round((totalFen / originalFen) * 100) : 0
    return { totalFen, wholeDiscountPercent }
  }, [items])

  const needReview = wholeDiscountPercent > 0 && wholeDiscountPercent < 90 // 折扣超过 10%

  return (
    <div className="order-summary">
      <p>订单总额:<strong>{formatFen(totalFen)}</strong> 元</p>
      {needReview && (
        <Alert type="warning" showIcon
          message={`整单折扣 ${wholeDiscountPercent}%(超过 10%),提交后将进入待审核,需财务审批`} />
      )}
    </div>
  )
}
// src/utils/money.js —— 金额工具:全程「分」,展示层才转元(与第 09 章同一份,两处共用)
export function calcItemFen({ quantity = 0, unitPriceFen = 0, discountPercent = 100 }) {
  return Math.round(quantity * unitPriceFen * (discountPercent / 100))
}
export function sumFen(items) {
  return items.reduce((sum, it) => sum + calcItemFen(it), 0) // 整数累加,无浮点误差
}
export function calcWholeDiscountPercent(items) { /* 同 §10.6 内联逻辑,抽出复用 */ }
export function formatFen(fen) {
  return (fen / 100).toFixed(2)
}

联动校验属于「跨字段规则」,优先放在展示层提示 + schema refine 双轨:schema 里用 .superRefine 校验「折扣范围与总额关系」,UI 上用 Alert 提前告知审批后果——用户不必等提交失败才知道。

10.7 编辑回填与提交态:reset、values 与防重复

编辑订单 = 异步取数 + 回填表单。defaultValues 只在挂载时生效,数据后到必须用 reset:

// src/views/orders/OrderEdit.jsx —— 编辑回填(易错点 1 的正确姿势)
import { useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { fetchOrderDetail, updateOrder } from '@/api/orders'

export default function OrderEdit({ id }) {
  const { data: order } = useQuery({
    queryKey: ['orders', 'detail', id],
    queryFn: ({ signal }) => fetchOrderDetail(id, { signal }),
  })

  const { reset, handleSubmit, formState: { isSubmitting } } = useForm({
    resolver: zodResolver(orderSchema),
    defaultValues: emptyForm(), // 先给骨架,避免受控告警
  })

  useEffect(() => {
    if (order) {
      reset({
        customerId: order.customerId,
        items: order.items.map((it) => ({ ...it })), // 拷贝,避免直接改缓存对象
        remark: order.remark ?? '',
      })
    }
  }, [order, reset]) // reset 引用稳定,可安全进依赖

  const onSubmit = async (values) => {
    await updateOrder(id, values) // RHF 已把数值字段经 z.coerce 转为 number
  }
  // 提交按钮:disabled={isSubmitting} —— 提交期间不可再点,后端幂等是第二道保险
}

补充两点:RHF 也提供 useForm({ values }) 选项在数据变化时自动同步表单(比 reset 更声明式,详见 https://react-hook-form.com/docs/useform ),团队二选一即可,但必须写进规范统一;「防重复提交」是三层闸门:isSubmitting 禁用按钮 → 请求层去重(第 09 章)→ 后端幂等键,前端只负责前一层。

🛠 渐进式实战练习

练习 1 · 基础(10–20 min):静态字段 + 校验时机

任务描述:实现新建订单的静态区:客户(AntD Select,必选)、备注(最多 200 字)。规则进 orderSchema;客户未选时失焦即出红字,选完后红字即时消失;备注超限时按钮仍可点但提交被拦截并高亮错误。

提示:Select 记得用 Controller;errors.xxx.message 渲染在字段下方;先故意提交一次空表单观察校验触发顺序。

验收标准:

  • [ ] 提交空表单:客户字段红字「请选择客户」,onSubmit 未被调用(console 验证);
  • [ ] 选中客户后红字立即消失(onChange 即时重校验);
  • [ ] 备注输入 201 字被拦截提示,200 字不报错。
参考答案(schema + Controller 最小闭环)
// src/schemas/order.js(先只保留静态部分)
import { z } from 'zod'
export const orderSchema = z.object({
  customerId: z.string().min(1, '请选择客户'),
  remark: z.string().max(200, '备注最多 200 字').optional(),
})
const { control, handleSubmit, formState: { errors, isSubmitting } } = useForm({
  resolver: zodResolver(orderSchema),
  defaultValues: { customerId: '', remark: '' },
})
// <Controller name="customerId" control={control} render={({ field, fieldState }) => (
//   <Select {...field} status={fieldState.error ? 'error' : undefined} ... />
// )} />

校验时机不用配置——RHF 默认「提交时全量 + 出过错之后即时」,正好满足验收第 2 条。

练习 2 · 进阶(30–60 min):动态明细行 + 金额联动

任务描述:按 §10.5–10.6 完成明细行区:商品搜索下拉、数量(1–9999)、单价(分)、行折扣(0–100),行金额实时计算;添加/删除行;底部汇总订单总额与整单折扣,折扣 >10% 时出现「需财务审批」警告。行级校验错误显示在对应单元格下方。

提示:fields.map 渲染、useWatch 订阅值、useMemo 算汇总;删中间一行后检查「其余行的输入与校验红字是否串位」——串位了说明 key 用错了。

验收标准:

  • [ ] 数量输 0、10000、1.5 分别被拦截(schema 三条规则逐一命中);
  • [ ] 行金额 = 数量 × 单价(分) × 折扣%,与手算一致且为整数分;
  • [ ] 添加 3 行后删除第 2 行,第 1、3 行内容与红字位置不错乱;
  • [ ] 折扣合计恰为 85% 时出现审批警告;折扣 100% 时不出现。
参考答案(行级错误呈现 + 联动汇总)
// 行内错误:errors 由 useForm 顶层取,按路径下钻
<td>
  <Controller name={`items.${index}.quantity`} control={control}
    render={({ field: f, fieldState }) => (
      <>
        <InputNumber {...f} min={1} max={9999} precision={0}
          status={fieldState.error ? 'error' : undefined} />
        {fieldState.error && <div className="cell-error">{fieldState.error.message}</div>}
      </>
    )} />
</td>

// 汇总联动放在独立组件 OrderSummary(§10.6 原文),
// 它内部 useWatch('items')——输入只重渲染明细与汇总,不重渲染整个表单

体验细节:InputNumber 的 precision={0} 在 UI 层挡小数,schema 的 .int() 兜底非法输入(如粘贴),两层各司其职。

练习 3 · 挑战(选做,60+ min):草稿暂存 + 联动审批闭环

任务描述:把第 08 章练习 3 的 useDraftStore 接进表单:进入新建页时从草稿 store 回填,字段变更防抖 500ms 写回 store(persist 自动落盘);提交成功后 clear();提交时按整单折扣走「双分支」——折扣 ≤10% 直接 createOrder,>10% 提交后跳详情页并 toast「已提交审核,等待财务审批」(状态应为 pending_review)。

提示:回填用 reset(draft);写回草稿在 useEffect 里订阅 useWatch 的值;两个分支只是提交后的提示与跳转不同,接口按附录A 语义是同一个 POST /orders。

验收标准:

  • [ ] 填一半切到订单列表再回来,表单内容完整还原(含明细行行数);
  • [ ] 提交成功后草稿清空、localStorage 中 qing-draft-order 内容重置;
  • [ ] 构造折扣 85% 的订单提交:mock 返回状态 pending_review,界面跳详情并出现审批提示;折扣 100% 的订单状态为 draft。
参考答案(草稿双向同步骨架)
export default function OrderCreate() {
  const draft = useDraftStore((s) => s) // 演示取整仓;生产建议字段级 selector
  const setDraft = useDraftStore.getState

  const form = useForm({ resolver: zodResolver(orderSchema), defaultValues: emptyForm() })
  const { reset, handleSubmit, formState: { isSubmitting } } = form
  const values = useWatch({ control: form.control }) // 整表单值

  // ① 进页回填:persist 恢复是同步的,直接 reset 一次
  useEffect(() => {
    if (draft.items.length > 0) reset({ customerId: draft.customerId, items: draft.items, remark: '' })
    // eslint-disable-next-line -- 只需在挂载时执行一次
  }, [])

  // ② 变更防抖写回草稿
  useEffect(() => {
    const timer = setTimeout(() => {
      useDraftStore.getState().setCustomerId(values.customerId ?? '')
      useDraftStore.setState({ items: values.items ?? [] })
    }, 500)
    return () => clearTimeout(timer)
  }, [values])

  const onSubmit = async (payload) => {
    const created = await createOrder(payload)
    useDraftStore.getState().clear()
    message.success(created.wholeDiscountPercent < 90 ? '已提交审核,等待财务审批' : '订单已创建')
    navigate(`/orders/${created.id}`, { replace: true })
  }
}

体感对照:Vue 版用 Pinia store + watch 实现同一闭环;差异只在「订阅 API」(watch vs useWatch/useEffect),业务规则两版逐字一致。

⚠️ 常见易错点

易错点 1 · defaultValues 不更新:编辑页表单一直是旧值

❌ 错误代码:

const { data: order } = useQuery({ /* 异步取数 */ })
const form = useForm({
  defaultValues: order ?? emptyForm(), // 💥 首次渲染时 order 还是 undefined,之后永远不生效
})

错误原因:defaultValues 只在表单挂载那一刻被读取;异步数据后到,RHF 不会自动把新值灌进已挂载的非受控字段——这是 React 版「编辑回填丢失」的第一大来源。

✅ 正确写法:

useEffect(() => {
  if (order) reset({ customerId: order.customerId, items: order.items.map((it) => ({ ...it })) })
}, [order, reset]) // 数据到达后 reset 灌值;或 useForm({ values: order })(7.42+,团队统一其一)

易错点 2 · 金额在表单里转成元做浮点运算

❌ 错误代码:

const total = items.reduce((s, it) => s + it.quantity * (it.unitPriceFen / 100) * (it.discountPercent / 100), 0)
// 展示 ¥3897.0000000000005;提交的 totalAmount 再 ×100 回分,误差已固化进数据

错误原因:把「分」转「元」参与中间计算,浮点误差从中作梗(第 09 章易错点 1 在表单场景的重演)。

✅ 正确写法:

// utils/money.js:全程分,展示层才 formatFen
const totalFen = items.reduce((s, it) => s + calcItemFen(it), 0)
<p>订单总额:{formatFen(totalFen)} 元</p>

易错点 3 · useFieldArray 用 index 当 key,删行全串位

❌ 错误代码:

{items.map((item, index) => (
  <tr key={index}> {/* 💥 删除第 2 行后:原第 3 行「变成」第 2 行,输入框内容/红字跟着错位 */}
    ...

错误原因:index 是「位置」不是「身份」。删除中间行后,后续行的 index 全部前移,React 按位置复用节点,把旧内容留在错误的位置上(第 02 章 key 原则在动态表单里的重演)。

✅ 正确写法:

const { fields, append, remove } = useFieldArray({ control, name: 'items' })
{fields.map((field, index) => (
  <tr key={field.id}> {/* ✅ fields.id:RHF 生成、随行存亡 */}
    ...

易错点 4 · 给 AntD 受控组件用 register

❌ 错误代码:

<Select {...register('customerId')} options={customers} /> // 💥 值永远进不了表单 state

错误原因:register 通过原生 ref/onChange 注入,AntD 组件是受控封装、不接原生 DOM 语义,注册静默失效——字段「看起来能选」,提交时却报必填错误。

✅ 正确写法:

<Controller
  name="customerId"
  control={control}
  render={({ field, fieldState }) => (
    <Select {...field} options={customers} status={fieldState.error ? 'error' : undefined} />
  )}
/>
// 口诀:原生 input → register;受控 React 组件(AntD/自研)→ Controller

易错点 5 · 数字输入是字符串:比较与计算全乱

❌ 错误代码:

<input {...register('quantity', { max: 9999 })} /> // 输入 '10',values.quantity 是 '10'
values.quantity > 9 // '10' > 9 → true(字符串比较侥幸对)… '9' > 10 → true 💥
Math.round('3.5')   // 字符串直接进金额计算,schema 才发现不是整数

错误原因:DOM input 的值永远是字符串;不做类型归一,比较与算术全靠隐式转换的运气。

✅ 正确写法:

// schema 层统一归一:z.coerce.number() 把 '3' 转成 3 再校验
quantity: z.coerce.number().int().min(1, '数量至少为 1').max(9999, '数量不能超过 9999')
// 原生 input 也可 register('quantity', { valueAsNumber: true }),但 schema 方案对 AntD 同样有效

易错点 6 · watch 整个表单,每敲一键全页重渲染

❌ 错误代码:

const allValues = watch() // 💥 订阅整表单:任何字段键入都重渲染整棵表单树(含大表格)

错误原因:watch() 无参调用订阅全部字段,中后台表单字段上百时,打字卡顿立刻出现——这正是 RHF 非受控模式要避免的事。

✅ 正确写法:

const items = useWatch({ control, name: 'items' }) // 字段级订阅:只重渲染消费它的组件
// 且把消费端收进小组件(OrderSummary),配合 useMemo 缓存计算结果(§10.6 结构)

✅ 最佳实践

  1. schema 是唯一的校验事实源:规则全进 src/schemas/*.js,组件里零内联 max/min;schema 可单测、可复用于提交前兜底校验,对应 Vue 版把 rules 收进独立文件的同款纪律。
  2. register 与 Controller 按组件类型分流:原生 input 用 register 享受非受控性能,AntD 组件一律 Controller——混用前先确认组件是否受控封装。
  3. 渲染 fields、计算 watch:useFieldArray 的 fields(带稳定 id)负责渲染,useWatch 的值负责计算,两者绝不能互换用途。
  4. 金额链路全程分:输入(分)→ 计算(整数)→ 校验(整数)→ 提交(分)→ 展示(formatFen 转元),单位转换只发生在渲染层,写入 utils/money.js 全站共用。
  5. 联动规则双轨:能自动算的(总额、整单折扣)算给用户看;会导致后果的(>10% 折扣需审批)用 Alert 提前告知;跨字段硬校验进 schema refine——别让用户提交后才挨打。
  6. 编辑回填只有两种合法姿势:useEffect + reset 或 useForm({ values }),团队规范二选一;defaultValues 只用于「确定存在的初值」。
  7. 提交三层闸门:isSubmitting 禁用按钮 → 请求层防抖/去重 → 后端幂等;前端永远假设用户会双击。
  8. 校验时机尊重默认:RHF 的「提交全量 + 出错后即时」覆盖 95% 场景,改 mode 前先想清楚产品诉求,不要为了「高级感」调参。

📌 本章小结

  1. RHF 三件套跑通静态表单,Zod schema 把附录A 校验规则收敛成可单测的契约,zodResolver 一行接入。
  2. AntD 受控组件走 Controller 桥接;原生 input 走 register;选错注册方式是最常见的「静默失效」。
  3. 动态明细行 = useFieldArray(fields 渲染 + watch 计算)+ 行级路径 items.${index}.quantity;金额联动全程以分为单位,useWatch + useMemo 保证联动不拖垮性能。
  4. 编辑回填用 reset(或 values),defaultValues 不更新的根因是「只读挂载那一刻」;防重复提交从 isSubmitting 开始。
  5. 下一章进入鉴权:把第 08 章的 user store、第 09 章的无感刷新、本章的表单(登录页)与第 07 章的守卫拼成完整的三级权限体系——登录、动态菜单、按钮级 <Auth> 与 403 页。

📄 第 11 章 · 登录鉴权与权限控制 11-登录鉴权与权限控制.md

📍 阶段:中级阶段(第 7–12 章)|⏱ 预计学时:6 小时|🔗 前置:第 08 章、第 09 章、第 10 章

⬅ 上一章:表单处理与校验 | 📖 本版目录 | ➡ 下一章:中级实战

💡 对照学习:本页是 React 版第 11 章,对应 Vue 版第 11 章 · 登录鉴权与权限控制,两版练习任务完全一致(登录页 + 守卫白名单 → 按钮权限 + 403 + 动态菜单 → 无感刷新联调 + 登出闭环)。概念映射:ProtectedRoute 组件 ≈ router.beforeEach 全局守卫(白名单/角色分流逻辑同构);<Auth> 权限组件 ≈ v-permission 自定义指令;菜单配置 roles 过滤两版同构;useNavigate + state.from ≈ router.push({ query: { redirect } }) 回跳;无感刷新请求队列两版共用第 09 章的同一份实现;useDispatch(clearUser) + queryClient.clear() ≈ Vue 版登出时的全量状态清理。

🎯 学习目标

学完本章,你将能够:

  1. 画出「输入账密 → 校验验证码 → 换取双令牌 → 注入请求头 → 渲染受保护页面」的完整认证时序,并指出每一环的前端职责边界;
  2. 对比 localStorage 与 HttpOnly Cookie 存储 token 的 XSS/CSRF 权衡,为内部中后台给出推荐方案并说明缓解措施;
  3. 用第 10 章的 RHF + Zod 实现含图形验证码的登录页,验证码输错后可无刷新换图;
  4. 强化 ProtectedRoute:会话恢复期防闪跳、roles 参数做页面级拦截、403 页分流;
  5. 用「菜单配置 + 角色过滤」实现动态菜单,保证菜单与页面守卫共用同一份权限配置(单一事实源);
  6. 实现 usePermission Hook 与 <Auth> 按钮级权限组件,把附录A 的按钮权限(删除订单、折扣审批)落成声明式代码;
  7. 实现登出闭环:令牌、store、Query 缓存、表单草稿四类状态全量清理,并说出「清理不彻底」导致的越权数据残留问题。

📖 核心知识点

11.1 认证全景图:从前端视角拆解一次登录

附录A §3.1 的认证需求:账密 + 图形验证码(输错 5 次锁 10 分钟)、JWT 双令牌、无感刷新。前端视角的完整时序:

① 进入登录页:GET /auth/captcha → { captchaId, imageBase64 },展示图形验证码
② 用户提交:POST /auth/login { username, password, captchaId, captchaCode }
   —— 账密错误 / 验证码错误:后端返回业务错误码,前端 toast 并换一张验证码
③ 登录成功:返回 { profile, accessToken(2h), refreshToken(7d) }
   —— 前端写 token(§11.3 讨论)、写 user store、跳 redirect 来源页
④ 之后每个请求:拦截器注入 Authorization: Bearer <accessToken>(第 09 章 §9.3)
⑤ accessToken 过期:40102 → 单飞刷新 + 请求重放(第 09 章 §9.5,本章联调验证)
⑥ 登出:POST /auth/logout → 前端全量清理(§11.8)

职责边界一句话:前端管「令牌的携带与呈现层的拦截」,后端管「真伪与权限」。验证码次数锁定、角色判断的服务端复核、接口鉴权全部在后端——前端任何权限代码都只是体验优化,不是安全边界。

11.2 双令牌与无感刷新:本章要接的线

第 09 章已实现 refreshAccessToken(单飞锁 + _retried 重放)与 ApiError 归一化,本章补上认证域 API 与「会话恢复」,让刷新机制第一次真实运转:

// src/api/auth.js —— 认证域接口(示例账号一律虚构:demo@qingorder.example.com)
import request, { ApiError } from '@/utils/request'

export function fetchCaptcha() {
  return request.get('/auth/captcha', { silent: true }) // 换图失败不必 toast 打扰
}
export function login(payload) {
  return request.post('/auth/login', payload) // { username, password, captchaId, captchaCode }
}
export function logout() {
  return request.post('/auth/logout', {}, { silent: true }) // 就算后端异常前端也要清理(§11.8)
}
export function fetchProfile() {
  return request.get('/auth/profile') // 恢复会话时用 refreshToken 换新 accessToken 后调用
}

11.3 token 存储的 XSS 权衡:localStorage vs Cookie

这是每个团队都要正式做的决策,用对照表而不是拍脑袋:

维度 localStorage HttpOnly Cookie
JS 可读性 可读——XSS 一旦注入即可窃取 不可读——天然抗 XSS 窃取
CSRF 风险 无(不自动携带) 有——需 SameSite / CSRF Token 缓解
跨标签页共享 天然共享 天然共享
过期控制 自己写清理逻辑 Expires/Max-Age 浏览器托管
实现成本 低 需要后端 Set-Cookie + 跨域配置

教程推荐口径(写入团队规范 / ADR,并说清缓解措施):

  • accessToken 放内存(user store)——最短暴露窗口,刷新页面即失效;
  • refreshToken 最小化持久化到 localStorage——换取「刷新页面免重新登录」的体验;
  • 配套缓解:严格 CSP、不引入不可信第三方脚本、依赖及时升级;
  • 若产品面向公网/高安全要求,切换 HttpOnly Cookie(SameSite=Lax 起)并把 CSRF 防护写进后端方案。

本章代码沿用第 09 章 request.js 的 localStorage 口径(qing_access_token / qing_refresh_token),并在第 12 章验收清单里复查这一决策记录。

11.4 登录页实战:账密 + 图形验证码

用第 10 章的 RHF + Zod 写登录页——schema 先行,验证码状态独立管理:

// src/schemas/auth.js —— 登录校验契约
import { z } from 'zod'

export const loginSchema = z.object({
  username: z.string().min(1, '请输入账号'),
  password: z.string().min(6, '密码至少 6 位'),
  captchaCode: z.string().min(1, '请输入图形验证码'),
})
// src/views/Login.jsx —— 登录页(RHF + Zod + 验证码无刷新换图)
import { useEffect, useState, useCallback } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useNavigate, useLocation } from 'react-router'
import { useDispatch, useSelector } from 'react-redux'
import { message, Input, Button } from 'antd'
import { loginSchema } from '@/schemas/auth'
import { login, fetchCaptcha } from '@/api/auth'
import { setCredentials } from '@/store/userSlice'

export default function Login() {
  const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm({
    resolver: zodResolver(loginSchema),
    defaultValues: { username: '', password: '', captchaCode: '' },
  })
  const [captcha, setCaptcha] = useState({ captchaId: '', imageBase64: '' })
  const navigate = useNavigate()
  const location = useLocation()
  const dispatch = useDispatch()
  const profile = useSelector((s) => s.user.profile)

  // 已登录访问登录页 → 直接送走(第 07 章易错点 2 的正确姿势)
  useEffect(() => {
    if (profile) navigate('/dashboard', { replace: true })
  }, [profile, navigate])

  const reloadCaptcha = useCallback(async () => {
    const data = await fetchCaptcha()
    setCaptcha(data) // { captchaId, imageBase64 }
  }, [])

  useEffect(() => { reloadCaptcha() }, [reloadCaptcha])

  const onSubmit = async (values) => {
    try {
      const data = await login({ ...values, captchaId: captcha.captchaId })
      dispatch(setCredentials(data)) // { profile, accessToken, refreshToken } 写入 store 与存储
      const from = location.state?.from ?? '/dashboard'
      navigate(from.startsWith('/') ? from : '/dashboard', { replace: true }) // 防开放重定向
    } catch (err) {
      message.error(err.message) // 验证码错误/锁定提示由后端 message 决定
      reloadCaptcha() // 无论何种失败:换一张验证码(旧的必然作废)
    }
  }

  return (
    <form className="login-page" onSubmit={handleSubmit(onSubmit)}>
      <h1>轻订单 · 登录</h1>
      <label>账号 <input {...register('username')} placeholder="demo@qingorder.example.com" /></label>
      {errors.username && <p className="error">{errors.username.message}</p>}
      <label>密码 <input type="password" {...register('password')} /></label>
      {errors.password && <p className="error">{errors.password.message}</p>}
      <label>验证码</label>
      <div className="captcha-row">
        <input {...register('captchaCode')} maxLength={4} />
        {/* 点击换图:验证码失败不刷新整页 */}
        <img src={captcha.imageBase64} alt="图形验证码" title="点击刷新"
          onClick={reloadCaptcha} />
      </div>
      {errors.captchaCode && <p className="error">{errors.captchaCode.message}</p>}
      <Button htmlType="submit" type="primary" block loading={isSubmitting}>登 录</Button>
    </form>
  )
}

setCredentials 是第 08 章 userSlice 的新 action,负责「store + 存储」一起落账:

// src/store/userSlice.js 追加
reducers: {
  setCredentials(state, action) {
    state.profile = action.payload.profile
    state.token = action.payload.accessToken // accessToken 进内存(§11.3 口径)
    // refreshToken 持久化由 request/auth 工具负责(与拦截器同源,避免两处写 key)
  },
  // ...setUser / clearUser
}

11.5 守卫强化:会话恢复、roles 拦截与 403 页

第 07 章 ProtectedRoute 只判断了「token 是否存在」,生产版要补两件事:会话恢复期不能误判(刷新页面时 store 是空的、refreshToken 还在恢复中,直接跳登录会闪跳);页面级角色拦截(附录A:直输 URL 无权限 → 403):

// src/store/userSlice.js 追加 —— 会话恢复 thunk
export const restoreSession = createAsyncThunk('user/restore', async (_, { rejectWithValue }) => {
  if (!localStorage.getItem('qing_refresh_token')) return rejectWithValue('no-session')
  try {
    return await fetchProfile() // 拦截器会先完成无感刷新(40102 → refresh → 重放)
  } catch (err) {
    return rejectWithValue(err.code ?? 'restore-failed')
  }
})
// extraReducers:fulfilled → profile 就位、isReady = true;rejected → 清存储、isReady = true
// src/router/ProtectedRoute.jsx —— 生产版:isReady 防闪跳 + roles 页面级拦截
import { Navigate, Outlet, useLocation } from 'react-router'
import { useSelector } from 'react-redux'
import { FullPageSpinner } from '@/components/FullPageSpinner'

export default function ProtectedRoute({ roles }) {
  const { profile, isReady } = useSelector((s) => s.user)
  const location = useLocation()

  if (!isReady) return <FullPageSpinner /> // ① 恢复中:不判不跳(防闪跳,易错点 2)
  if (!profile) {
    const from = location.pathname + location.search
    return <Navigate to="/login" replace state={{ from }} /> // ② 未登录 → 登录页
  }
  if (roles && !roles.includes(profile.role)) {
    return <Navigate to="/403" replace /> // ③ 页面级权限:附录A 三级模型的第二级
  }
  return <Outlet />
}
// App.jsx 路由树:守卫分层组合,每层 roles 精确声明
<Route path="/login" element={<Login />} />
<Route path="/403" element={<Forbidden />} />
<Route element={<ProtectedRoute />}> {/* 第一层:登录即可 */}
  <Route path="/" element={<AdminLayout />}>
    <Route path="dashboard" element={<Dashboard />} />
    <Route path="orders" element={<OrderListView />} />
    {/* 第二层:页面级角色 —— 报表中心仅运营/财务,系统设置仅超管(附录A §2) */}
    <Route element={<ProtectedRoute roles={['super_admin', 'operator', 'finance']} />}>
      <Route path="reports" element={<Reports />} />
    </Route>
    <Route element={<ProtectedRoute roles={['super_admin']} />}>
      <Route path="settings" element={<Settings />} />
    </Route>
  </Route>
</Route>

403 页是「拦得住」的另一半——给出路(回工作台/切账号),而不是一句冷冰冰的 403:

// src/views/Forbidden.jsx
import { Link } from 'react-router'
import { Button, Result } from 'antd'

export default function Forbidden() {
  return (
    <Result status="403" title="403" subTitle="抱歉,您没有权限访问该页面。"
      extra={<Link to="/dashboard"><Button type="primary">返回工作台</Button></Link>} />
  )
}

11.6 动态菜单:角色过滤与单一事实源

菜单级权限(三级模型第一级)的关键不是「过滤」这个动作,而是菜单与守卫共用一份配置——否则改角色必然漏改一处:

// src/router/menus.js —— 菜单配置:roles 为空数组 = 所有已登录角色可见
export const MENUS = [
  { key: '/dashboard', label: '工作台', roles: [] },
  { key: '/orders', label: '订单管理', roles: [] },
  { key: '/customers', label: '客户管理', roles: ['super_admin', 'operator', 'sales'] },
  { key: '/reports', label: '报表中心', roles: ['super_admin', 'operator', 'finance'] },
  { key: '/settings', label: '系统设置', roles: ['super_admin'] },
]

export function filterMenus(role) {
  return MENUS.filter((m) => m.roles.length === 0 || m.roles.includes(role))
}
// src/layouts/SideMenu.jsx —— 登录后按角色渲染
import { useSelector } from 'react-redux'
import { Menu } from 'antd'
import { filterMenus } from '@/router/menus'

export default function SideMenu() {
  const role = useSelector((s) => s.user.profile?.role)
  const items = filterMenus(role).map((m) => ({ key: m.key, label: m.label }))
  return <Menu theme="dark" mode="inline" items={items} />
}

📌 企业进阶:角色 → 菜单的映射应由后端下发(profile.menus),前端只做渲染与兜底过滤。硬编码 roles 的版本是教学简化,切换数据源时只需改 filterMenus 一处——这正是「单一事实源」设计预留的接缝。

11.7 按钮级权限:usePermission 与 <Auth> 组件

附录A §2 的按钮级规则:「删除订单」仅 super_admin 与 operator;「折扣审批」仅 finance。Vue 版用 v-permission 指令;React 没有指令,惯用法是权限 Hook + 包装组件:

// src/constants/permissions.js —— 按钮权限表:一处声明,处处引用
export const BUTTON_PERMISSIONS = {
  'order:delete': ['super_admin', 'operator'],
  'order:approve': ['finance'],
  'order:assign': ['super_admin', 'operator'],   // 批量指派销售
  'order:export': ['super_admin', 'operator', 'finance'],
}
// src/hooks/usePermission.js —— 权限判断 Hook
import { useCallback } from 'react'
import { useSelector } from 'react-redux'
import { BUTTON_PERMISSIONS } from '@/constants/permissions'

export function usePermission() {
  const role = useSelector((s) => s.user.profile?.role)
  const hasPermission = useCallback(
    (perm) => {
      const allowed = typeof perm === 'string'
        ? BUTTON_PERMISSIONS[perm] ?? [] // 未登记的权限码默认拒绝(安全默认)
        : perm // 也支持直接传角色数组
      return allowed.includes(role)
    },
    [role],
  )
  return { hasPermission }
}
// src/components/Auth.jsx —— 按钮级权限组件(对应 Vue 版 v-permission 指令)
export default function Auth({ allow, children, fallback = null }) {
  const { hasPermission } = usePermission()
  return hasPermission(allow) ? children : fallback
}

// 用法:声明式、可组合,权限码零魔法字符串
<Auth allow="order:delete">
  <Button danger onClick={() => handleDelete(order.id)}>删除订单</Button>
</Auth>
<Auth allow="order:approve">
  <Button type="primary" onClick={() => approveMutation.mutate(order.id)}>审核通过</Button>
</Auth>

与 Vue 指令的差异值得记一笔:指令是「命令式 DOM 移除」(el.remove()),组件是「声明式不渲染」——React 生态更倾向后者,因为它与虚拟 DOM 的心智一致,且 fallback 可以渲染「无权限占位」而非彻底消失。

11.8 登出清理与全局兜底

登出不是「删个 token」。四类状态漏一类都会出事故:

// src/hooks/useLogout.js —— 登出闭环:四类状态全量清理
import { useNavigate } from 'react-router'
import { useQueryClient } from '@tanstack/react-query'
import { useDispatch } from 'react-redux'
import { logout as logoutApi } from '@/api/auth'
import { clearUser } from '@/store/userSlice'
import { useDraftStore } from '@/store/useDraftStore'
import { auth } from '@/utils/auth'

export function useLogout() {
  const navigate = useNavigate()
  const queryClient = useQueryClient()
  const dispatch = useDispatch()

  return async function logout() {
    try { await logoutApi() } catch { /* 后端不可达也要继续本地清理 */ }
    // ① 令牌与存储
    auth.clear()                                   // accessToken / refreshToken
    // ② 全局 store
    dispatch(clearUser())                          // profile/token 归零
    useDraftStore.getState().clear()               // 订单草稿含敏感商机信息,一并清理
    // ③ 服务端缓存:换账号后旧数据绝不能残留(易错点 6)
    queryClient.clear()
    // ④ 页面状态:跳登录页本身即重置组件树;state.from 不传,登出默认不回跳
    navigate('/login', { replace: true })
  }
}

queryClient.clear() 是 React 版最容易漏的一步:TanStack Query 的缓存独立于 Redux/Zustand,只清 store 不清缓存,切换账号后第一个请求会先渲染上一个账号的缓存数据——真实的越权数据泄漏。全局兜底方面,第 09 章拦截器里 40101 的清理逻辑与本节 useLogout 收敛到同一个 auth.clear(),保证「任何路径的会话失效」行为一致。

🛠 渐进式实战练习

练习 1 · 基础(10–20 min):登录页 + 守卫白名单

任务描述:实现 §11.4 登录页(mock:任意账密 + 验证码 1234 通过,返回 { profile, accessToken: 'mock-access', refreshToken: 'mock-refresh' });ProtectedRoute 接入 store 判断;未登录访问 /orders 跳登录并带 state.from;登录成功回跳原路径(含筛选参数)。

提示:setCredentials 里同时写 localStorage 与 store;回跳校验 from.startsWith('/');mock 验证码错误返回 code: 4xx01,前端 toast 后换图。

验收标准:

  • [ ] 未登录访问 /orders?status=draft → 登录页;登录后回到 /orders?status=draft;
  • [ ] 验证码输错:toast 提示 + 图形自动更换 + 账号密码保留不重填;
  • [ ] 已登录再访问 /login 自动跳工作台。
参考答案(from 回跳与失败保字段)
// 回跳核心(onSubmit 内):
const from = location.state?.from ?? '/dashboard'
navigate(from.startsWith('/') ? from : '/dashboard', { replace: true })

// 失败保字段的原理:RHF 非受控值存在表单内部,catch 里不调 reset 就天然保留;
// 只需重置验证码一项:
//   setValue('captchaCode', '') + reloadCaptcha()

练习 2 · 进阶(30–60 min):<Auth> + 403 页 + 动态菜单

任务描述:①实现 BUTTON_PERMISSIONS / usePermission / <Auth>(§11.7),在订单详情页按角色渲染「删除订单」「审核通过」按钮;②实现 403 页并接入 ProtectedRoute roles:/reports 允许运营/财务,/settings 仅超管;③SideMenu 按 filterMenus 渲染。用「切换角色」按钮(第 08 章练习 1)快速验证四种角色看到的菜单与按钮差异。

提示:切换角色后注意——菜单与 <Auth> 会因 store 变化自动重渲染,但页面级 403 不会自动重定向(守卫在路由切换时才求值),这正好是「当前页面权限失效」的观察点,验收时说明即可。

验收标准:

  • [ ] sales 角色看不到「删除订单」「审核通过」;finance 只见「审核通过」;operator 两者都见;
  • [ ] sales 直输 /settings → 403 页;super_admin 可进;
  • [ ] 四种角色的侧边菜单项与 MENUS.roles 配置一一对应,配置表之外无任何角色硬编码。
参考答案(按钮区组合)
// src/views/orders/OrderDetail.jsx(操作区节选)
import Auth from '@/components/Auth'
import { usePermission } from '@/hooks/usePermission'

function OrderActions({ order }) {
  const { hasPermission } = usePermission()
  return (
    <div className="order-actions">
      <Auth allow="order:approve">
        <Button type="primary" loading={approveMutation.isPending}
          onClick={() => approveMutation.mutate(order.id)}>审核通过</Button>
      </Auth>
      <Auth allow="order:delete">
        <Button danger onClick={() => handleDelete(order.id)}>删除订单</Button>
      </Auth>
      {/* 状态机限制(能点)与权限限制(可见)是两道独立闸门,第 12 章合流 */}
      <Auth allow={['super_admin', 'operator']}> {/* 也支持临时传角色数组 */}
        <Button onClick={() => navigate(`/orders/${order.id}/edit`)}>编辑</Button>
      </Auth>
    </div>
  )
}

练习 3 · 挑战(选做,60+ min):无感刷新联调 + 登出闭环

任务描述:①把「40102 制造机」(第 09 章练习 2)接入登录后的真实流程:登录 → 停 2 秒 → 任意请求触发 40102 → 观察自动刷新 + 重放 + 界面无感;②手动把 qing_refresh_token 改成非法值再触发刷新,验证「清会话 → 跳登录」且不出现死循环;③实现 §11.8 useLogout,验证登出后 DevTools 里 localStorage 无 token、Redux DevTools 中 user 为空、React Query Devtools 缓存为空。

提示:恢复会话(restoreSession)也要联调:登录后刷新浏览器,应静默恢复到登录态而不是闪跳登录页;isReady 未就位期间 ProtectedRoute 渲染 spinner。

验收标准:

  • [ ] 40102 触发后 Network 仅出现 1 次 /auth/refresh,原请求重放成功,无任何 toast;
  • [ ] refreshToken 非法:清存储 → 跳登录 → 不再有第二次刷新请求;
  • [ ] 刷新浏览器静默恢复登录态(无登录页闪现);登出后三类调试工具均无残留数据。
参考答案(restoreSession 的 extraReducers)
extraReducers: (builder) => {
  builder
    .addCase(restoreSession.fulfilled, (state, action) => {
      state.profile = action.payload.profile ?? action.payload
      state.isReady = true
    })
    .addCase(restoreSession.rejected, (state, action) => {
      state.profile = null
      state.isReady = true // 关键:失败也要「就绪」,否则守卫永远 spinner
      if (action.payload !== 'no-session') {
        localStorage.removeItem('qing_access_token')
        localStorage.removeItem('qing_refresh_token')
      }
    })
}

// App.jsx 挂载时触发一次:
// const dispatch = useDispatch()
// useEffect(() => { dispatch(restoreSession()) }, [dispatch])

死循环复盘:重放请求带 _retried: true,二次 40102 直接走「清会话 + 抛 40101」分支;且刷新请求用裸 axios 发出,自身响应不再进入拦截器——两道防线缺一即循环。

⚠️ 常见易错点

易错点 1 · token 一把梭进 localStorage,不做权衡记录

❌ 错误代码:

localStorage.setItem('qing_access_token', token)
localStorage.setItem('qing_refresh_token', token) // 双令牌全落盘,且没有任何决策说明

错误原因:localStorage 对 XSS 完全不设防——任何一个被注入的第三方脚本都能读走双令牌,7 天内冒充用户。问题不在「不能用」,而在「没权衡、没缓解、没记录」。

✅ 正确写法:

// 口径(§11.3):accessToken 进内存 store,refreshToken 最小化持久化 + CSP 缓解
// 并把决策写入 ADR:「为什么内部中后台选择 localStorage + 缓解措施清单」
state.token = action.payload.accessToken      // 内存
localStorage.setItem('qing_refresh_token', data.refreshToken) // 仅 refreshToken

易错点 2 · 会话恢复完成前,守卫就跳了登录页

❌ 错误代码:

export default function ProtectedRoute() {
  const profile = useSelector((s) => s.user.profile) // 刷新后 store 初始为 null
  if (!profile) return <Navigate to="/login" replace /> // 💥 refreshToken 还没来得及恢复
}

错误原因:刷新页面后 Redux store 是全新的,profile 必然先为 null;此时「异步恢复会话」还在路上,守卫抢先判定未登录 → 已登录用户被闪跳到登录页。

✅ 正确写法:

const { profile, isReady } = useSelector((s) => s.user)
if (!isReady) return <FullPageSpinner /> // 恢复期只等待,不判定
if (!profile) return <Navigate to="/login" replace state={{ from }} />

易错点 3 · 权限判断散落魔法字符串

❌ 错误代码:

{role === 'super_admin' && <Button danger>删除订单</Button>}
{role === 'super_admin' || role === 'operator' ? <Button>审核通过</Button> : null} // 条件还写错了

错误原因:权限规则内联在 JSX 各处,改权限 = 全局搜字符串;第二个判断甚至与附录A 冲突(审批只属 finance)。条件写错还静默通过是权限代码最危险的特征。

✅ 正确写法:

<Auth allow="order:delete"><Button danger>删除订单</Button></Auth>   // 权限码 → 常量表
<Auth allow="order:approve"><Button>审核通过</Button></Auth>
// 规则只在 BUTTON_PERMISSIONS 一处;未登记的权限码默认拒绝

易错点 4 · 401 后只清 token 不跳转,或跳转后死循环

❌ 错误代码:

if (res.code === 40101) {
  localStorage.clear() // 清了,但用户停在当前页,后续每个请求继续 401
}
// 或:在拦截器里 navigate('/login') —— 模块层拿不到 Router 上下文,写成 window.location 循环刷新

错误原因:会话失效后没有任何「送客」动作,页面留在需登录的界面不断报错;而试图在非组件层直接路由跳转,又容易写出整页 reload 的循环。

✅ 正确写法:

// 请求层:清会话 + 抛 ApiError(40101)(§9.4)
// 组件层兜底:根组件监听全局错误/状态,统一跳登录
if (err.code === 40101) navigate('/login', { replace: true, state: { from: location.pathname } })
// 职责分离:拦截器管清理与抛错,路由跳转交给组件层(有 Router 上下文)

易错点 5 · 登出/切号不清 Query 缓存

❌ 错误代码:

dispatch(clearUser())
auth.clear()
navigate('/login') // 💡 TanStack Query 缓存原封不动
// 新账号登录后访问列表页:先渲染上一账号的缓存数据(isPending 都不会出现)

错误原因:React Query 的缓存挂在 QueryClient 上,独立于 Redux/Zustand 与 localStorage;只清后两者,等于给下个用户留了一份「上个账号的数据快照」——真实系统里属于越权数据泄漏事故。

✅ 正确写法:

queryClient.clear() // 登出闭环第 ③ 步(§11.8);或 removeQueries({ queryKey: ['orders'] })

易错点 6 · redirect 回跳不做校验(开放重定向)

❌ 错误代码:

navigate(location.state?.from, { replace: true }) // from 可以是 https://evil.com 或 javascript: URL

错误原因:state.from 是客户端可控数据;攻击者构造诱导链接即可把登录用户送往外部站点(开放重定向),配合钓鱼页面危害放大。

✅ 正确写法:

const from = location.state?.from ?? '/dashboard'
navigate(from.startsWith('/') ? from : '/dashboard', { replace: true }) // 只允许站内路径

✅ 最佳实践

  1. 权限三级各归其位:菜单级 filterMenus(role)、页面级 ProtectedRoute roles、按钮级 <Auth allow>——三级共用「角色/权限码」一份事实源,改权限永远只改配置。
  2. 前端权限是体验,后端鉴权是底线:把这句话写进新人 onboarding 文档;<Auth> 隐藏按钮的同时,接口层的 40301 处理(toast + 引导)必须存在。
  3. token 存储走 ADR:localStorage vs Cookie 的权衡、缓解措施、切换条件成文;accessToken 内存化 + refreshToken 最小化持久化是内部系统的默认起点。
  4. 会话恢复必须显式建模:isReady 状态 + restoreSession thunk,守卫「恢复期不判定」;任何「刷新闪跳登录页」都是这个状态缺失的症状。
  5. 无感刷新复用第 09 章三件套:单飞锁、_retried、裸 axios;联调时必测「并发 40102」「刷新自身失败」「恢复会话」三条路径。
  6. 登出四清:令牌、store、queryClient.clear()、草稿等本地持久化——用 useLogout 收口成唯一入口,禁止散落清理。
  7. 验证码交互零刷新:失败只换图不刷页,silent: true 让换图请求的失败不打扰用户;锁定提示如实透传后端 message。
  8. 权限码语义化 + 默认拒绝:order:delete 这类「域:动作」命名写进常量表;查无此码一律视为无权限,宁可多拦不可漏放。

📌 本章小结

  1. 认证全景:验证码 → 登录 → 双令牌 → 拦截器注入 → 40102 无感刷新 → 登出清理,前端管携带与呈现拦截,后端管真伪。
  2. ProtectedRoute 是 React 版导航守卫:isReady 防闪跳、未登录重定向带 from、roles 页面级拦截进 403;403 页要给出路。
  3. 动态菜单 = 配置表 + filterMenus;按钮级 = usePermission + <Auth> 组件(对应 v-permission),权限码常量化、默认拒绝。
  4. 登出四清(令牌/store/Query 缓存/草稿)缺一不可,queryClient.clear() 是 React 版特有且最易漏的一步。
  5. 至此零件全部齐备:路由(07)、状态(08)、请求层(09)、表单(10)、鉴权(11)。下一章把它们装配成完整的「轻订单管理系统」——四个里程碑 M1–M4,交付附录A §3.1–3.3 的可用版本。

📄 第 12 章 · 中级实战 —— 轻订单管理系统 12-中级实战-轻订单管理系统.md

📍 阶段:中级阶段(第 7–12 章)|⏱ 预计学时:14–18 小时(分四个里程碑)|🔗 前置:第 07–11 章 全部完成 + 附录A

⬅ 上一章:登录鉴权与权限控制 | 📖 本版目录 | ➡ 下一章:阶段自查 · 中级能力清单

💡 对照学习:本页是 React 版第 12 章,对应 Vue 版第 12 章 · 中级实战,里程碑 M1–M4、验收清单、自测用例两版逐条一致——同一个需求,两种生态的装配现场。差异只在实现选型:路由守卫(ProtectedRoute ≈ 导航守卫)、状态(slice/Zustand store ≈ Pinia store)、请求层(useOrderList + TanStack Query ≈ composable + 自研缓存)、表单(RHF + Zod ≈ ElForm rules)、按钮权限(<Auth> ≈ v-permission)。完成本章后,建议打开 Vue 版同章互相 review 对方代码。

🎯 学习目标

学完本章,你将能够:

  1. 把第 07–11 章的全部零件装配成「轻订单」中后台:登录 → 动态菜单 → 订单列表/详情/新建 → 三级权限的完整链路;
  2. 按企业惯例组织整合后的目录结构(api / hooks / schemas / store / constants / router / views),说出每个目录的准入标准;
  3. 用状态机映射表驱动订单详情页的操作按钮(什么状态显示什么按钮),替代散落的 if/else;
  4. 按里程碑 M1–M4 拆解交付:每个里程碑有明确的验收标准,做到「每 3–4 小时可验收一次」;
  5. 在列表页完成「筛选 + 分页 + 防抖 + URL 回填」的完整体验,并保证万级数据演进(第 13 章)时不推翻结构;
  6. 按附录A §3.1–3.3 逐条自查功能覆盖,跑通 10 条功能验收与 5 条自测用例;
  7. 沉淀交付物:里程碑验收记录 + 遗留问题清单,让同事可以直接接手你的模块。

📖 核心知识点

12.1 本章目标:从零件到系统

第 07–11 章你在「练零件」,本章交付附录A §3.1–3.3 的可用版本:

登录鉴权(3.1)   —— 账密 + 图形验证码、双令牌无感刷新、个人中心入口
订单管理(3.3)   —— 列表(分页/筛选/排序)、详情(状态机/操作日志/流转按钮)、新建/编辑(明细行/金额汇总/审批分流)
权限模型(§2)    —— 菜单级(动态侧边栏)、页面级(守卫 + 403)、按钮级(<Auth>)

范围红线(高级阶段再做的,本章不碰):工作台 Dashboard、客户/商品/报表完整模块、批量导出(练习中已给出导出钩子)、性能指标达标——它们分别是第 13–18 章的主菜。克制范围是里程碑能按时验收的前提。

12.2 目录结构全览

react-app/src/
├── main.jsx                    # 入口:Provider(store) + QueryClientProvider + BrowserRouter
├── App.jsx                     # 路由表:唯一事实源(07)
├── api/                        # 接口层:一域一文件(09)
│   ├── auth.js                 #   captcha / login / logout / profile
│   └── orders.js               #   fetchOrders / detail / create / submit / review / cancel
├── schemas/                    # 校验契约(10)
│   ├── auth.js                 #   loginSchema
│   └── order.js                #   orderSchema(数量/折扣/必填)
├── hooks/                      # 数据编排(09)/ 权限(11)/ 登出(11)
│   ├── useOrderList.js         #   筛选+分页+防抖+缓存+翻页保旧
│   ├── useDebouncedValue.js
│   ├── usePermission.js
│   └── useLogout.js
├── store/                      # 跨页共享态(08)
│   ├── index.js                #   configureStore
│   ├── userSlice.js            #   profile/token/isReady + login/restoreSession thunk
│   ├── dictSlice.js            #   字典缓存
│   └── useDraftStore.js        #   订单暂存区(Zustand + persist)
├── constants/                  # 业务常量:零魔法字符串(12 章新增)
│   ├── orderStatus.js          #   状态机字典 + 状态→动作映射
│   └── permissions.js          #   BUTTON_PERMISSIONS
├── router/                     # 路由资产(07/11)
│   ├── ProtectedRoute.jsx      #   登录 + roles 双拦截
│   ├── menus.js                #   菜单配置 + filterMenus
│   └── routeMeta.js            #   面包屑元信息
├── utils/
│   ├── request.js              #   axios + 拦截器 + 无感刷新(09)
│   ├── auth.js                 #   token 存取(11)
│   └── money.js                #   金额工具:全程分(09/10)
├── components/                 # 跨页面复用
│   ├── Auth.jsx                #   按钮级权限(11)
│   ├── StatusTag.jsx           #   状态标签(本章 §12.5)
│   └── FullPageSpinner.jsx
├── layouts/
│   ├── AdminLayout.jsx         #   Sider + Header + <Outlet />
│   ├── SideMenu.jsx            #   动态菜单(11)
│   └── Breadcrumbs.jsx         #   面包屑(07)
└── views/
    ├── Login.jsx  /  Forbidden.jsx  /  NotFound.jsx
    └── orders/
        ├── OrderListView.jsx   #   列表(12.5)
        ├── OrderDetailView.jsx #   详情 + 状态流转(12.6)
        ├── OrderCreate.jsx     #   新建(复用 10 章表单)
        └── components/         #   OrderFilters / OrderTable / OrderActions

准入标准一句话:api 只描述接口、schemas 只写规则、hooks 只做编排、views 只消费——任何跨目录的越界依赖都是 review 拦截项。

12.3 基础设施盘点与装配顺序

各章零件在本章的位置(装配顺序即依赖顺序):

零件 来源 在系统中的角色
request.js 无感刷新 09 全部请求的传输层,登录后立即生效
ProtectedRoute + routeMeta 07/11 路由树骨架 + 页面级权限
userSlice + dictSlice 08 登录态、字典缓存
useOrderList 09 订单列表的数据心脏
orderSchema + useFieldArray 10 新建/编辑订单表单
<Auth> + filterMenus 11 按钮级与菜单级权限

Mock 方案(附录A §7:教学阶段统一 mock):mock/db.js 导出 30+ 条订单(字段与附录A 实体逐字一致,orderNo = QO + yyyyMMdd + 6 位序号,金额单位分),mock/server.js 拦截 api/ 的调用并模拟 200ms 延迟、40102 制造机与登录态校验。mock 的响应形状必须以附录A 统一响应体为准——后端接入时只换 request.js 的 baseURL,业务代码零改动。

12.4 状态机映射表:详情页操作按钮的引擎

附录A 订单状态机是业务的心脏。把它翻译成数据而不是 if/else——UI 从映射表派生,加状态只改表:

// src/constants/orderStatus.js —— 状态机字典:唯一事实源
export const ORDER_STATUS = {
  draft:          { label: '草稿',   color: 'default' },
  pending_review: { label: '待审核', color: 'orange' },
  approved:       { label: '已审核', color: 'blue' },
  shipped:        { label: '已发货', color: 'cyan' },
  completed:      { label: '已完成', color: 'green' },
  rejected:       { label: '已拒绝', color: 'red' },
  cancelled:      { label: '已取消', color: 'gray' },
}

// 状态 → 允许的操作(附录A:rejected 可编辑后重新提交;非终态可取消且需填原因)
export const STATUS_ACTIONS = {
  draft:          ['submit', 'edit', 'cancel'],
  pending_review: ['approve', 'reject', 'cancel'],   // 仅 finance 可见 approve/reject(权限层过滤)
  approved:       ['ship', 'cancel'],
  shipped:        ['complete'],
  completed:      [],
  rejected:       ['edit', 'submit', 'cancel'],
  cancelled:      [],
}

export const STATUS_OPTIONS = Object.entries(ORDER_STATUS)
  .map(([value, { label }]) => ({ value, label }))
// src/views/orders/components/StatusTag.jsx —— 状态标签:从字典渲染
import { Tag } from 'antd'
import { ORDER_STATUS } from '@/constants/orderStatus'

export default function StatusTag({ status }) {
  const meta = ORDER_STATUS[status]
  return <Tag color={meta?.color ?? 'default'}>{meta?.label ?? status}</Tag>
}

12.5 M2 的核心:订单列表页(筛选/分页/URL 回填)

列表页 = 第 09 章 useOrderList + AntD Table + 状态标签,几乎没有新代码——这正是「分层」的回报:

// src/views/orders/OrderListView.jsx —— 列表页装配
import { Table, Input, Select, Button, Space } from 'antd'
import { Link } from 'react-router'
import { useOrderList } from '@/hooks/useOrderList'
import StatusTag from './components/StatusTag'
import { STATUS_OPTIONS } from '@/constants/orderStatus'
import { formatFen } from '@/utils/money'
import { fetchOrders } from '@/api/orders' // mock 直连;生产走 useOrderList 内部
import { useState } from 'react'

export default function OrderListView() {
  const { filters, setFilters, list, total, isPending, isFetching } = useOrderList()
  const [rows, setRows] = useState([]) // 渲染数据:mock 阶段用 effect 填充,详见参考答案

  const columns = [
    { title: '订单号', dataIndex: 'orderNo',
      render: (no, record) => <Link to={`/orders/${record.id}`}>{no}</Link> },
    { title: '客户', dataIndex: 'customerName' },
    { title: '归属销售', dataIndex: 'ownerName' },
    { title: '状态', dataIndex: 'status', render: (s) => <StatusTag status={s} /> },
    { title: '总金额(元)', dataIndex: 'totalAmount',
      render: (fen) => formatFen(fen), align: 'right' }, // 分 → 元,仅展示层
    { title: '创建时间', dataIndex: 'createdAt' },
  ]

  return (
    <div>
      <Space style={{ marginBottom: 16 }}>
        <Input placeholder="订单号 / 客户名" value={filters.keyword}
          onChange={(e) => setFilters({ keyword: e.target.value })} allowClear />
        <Select placeholder="状态" style={{ width: 140 }} value={filters.status || undefined}
          options={STATUS_OPTIONS} onChange={(v) => setFilters({ status: v ?? '' })} allowClear />
        <Button type="primary"><Link to="/orders/new">新建订单</Link></Button>
      </Space>
      <Table
        rowKey="id"
        loading={isPending}
        columns={columns}
        dataSource={list}
        pagination={{
          current: filters.page, pageSize: filters.pageSize, total,
          showSizeChanger: false, // 附录A 固定 20/页
          onChange: (page) => setFilters({ page }),
        }}
      />
      {isFetching && <div className="updating-hint">数据更新中…</div>}
    </div>
  )
}

真实链路中 list 已由 useOrderList 从 Query 缓存返回;教学 mock 阶段可让 api/orders.js 直接返回 mock/server.js 的 Promise,useOrderList 的 queryFn 无需感知。不要为了 mock 在组件里另开一条数据通道。

12.6 M3 的核心:详情页状态流转 + 新建订单

详情页三块:基本信息(含明细行金额)、操作日志时间线(mock 数组渲染)、状态流转按钮区——由「状态机允许的操作 × 按钮级权限」双重过滤:

// src/views/orders/components/OrderActions.jsx —— 状态机 × 权限 的合流点
import { Button, Space, Modal, Input } from 'antd'
import { STATUS_ACTIONS } from '@/constants/orderStatus'
import { usePermission } from '@/hooks/usePermission'
import { useOrderActions } from '../../hooks/useOrderActions' // 本页动作集,见下

const ACTION_META = {
  submit:   { label: '提交审核', danger: false, perm: null },          // 运营/销售本人
  approve:  { label: '审核通过', danger: false, perm: 'order:approve' }, // 仅 finance
  reject:   { label: '拒绝',     danger: true,  perm: 'order:approve' },
  ship:     { label: '发货',     danger: false, perm: null },
  complete: { label: '完成',     danger: false, perm: null },
  edit:     { label: '编辑',     danger: false, perm: null },
  cancel:   { label: '取消订单', danger: true,  perm: null, needReason: true }, // 附录A:需填原因
}

export default function OrderActions({ order }) {
  const { hasPermission } = usePermission()
  const { runAction, pendingAction } = useOrderActions(order.id)

  const allowed = (STATUS_ACTIONS[order.status] ?? [])
    .filter((a) => !ACTION_META[a].perm || hasPermission(ACTION_META[a].perm))

  const handleClick = (action) => {
    if (ACTION_META[action].needReason) {
      let reason = ''
      Modal.confirm({
        title: '取消订单需要填写原因',
        content: <Input.TextArea onChange={(e) => { reason = e.target.value }} />,
        onOk: () => runAction(action, { reason }), // 附录A:取消必须带原因
      })
      return
    }
    runAction(action)
  }

  return (
    <Space>
      {allowed.map((a) => (
        <Button key={a} danger={ACTION_META[a].danger}
          loading={pendingAction === a} onClick={() => handleClick(a)}>
          {ACTION_META[a].label}
        </Button>
      ))}
    </Space>
  )
}
// src/views/orders/hooks/useOrderActions.js —— 动作集:mutation + 失效
import { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { message } from 'antd'
import { submitOrder, reviewOrder, cancelOrder } from '@/api/orders'

export function useOrderActions(orderId) {
  const queryClient = useQueryClient()
  const [pendingAction, setPendingAction] = useState(null)

  const mutation = useMutation({
    mutationFn: ({ action, payload }) => {
      setPendingAction(action)
      switch (action) {
        case 'submit':   return submitOrder(orderId)
        case 'approve':  return reviewOrder(orderId, 'approve')
        case 'reject':   return reviewOrder(orderId, 'reject', payload?.reason)
        case 'cancel':   return cancelOrder(orderId, payload?.reason)
        default:         return Promise.reject(new Error('未实现的动作: ' + action))
      }
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['orders', 'detail', orderId] }) // 详情刷新
      queryClient.invalidateQueries({ queryKey: ['orders', 'list'] })            // 列表刷新
    },
    onSettled: () => setPendingAction(null),
  })

  return { runAction: mutation.mutate, pendingAction }
}

新建订单复用第 10 章全部成果(schema、useFieldArray、金额联动、草稿 store),唯一新增是提交后的状态分流——折扣 >10% 时提示「已提交审核,等待财务审批」,跳详情确认状态为 pending_review。

12.7 里程碑拆解:M1–M4

每个里程碑 3–4 小时,完成即可独立验收——不许攒到最后一起测:

M1 骨架与登录(第 07/11 章成果落地)
   路由树 + Layout + 动态菜单 + 登录页(验证码)+ ProtectedRoute + 403/404
M2 订单列表(第 08/09 章成果落地)
   useOrderList + Table + 筛选/分页/排序 + URL 回填 + 防抖 + StatusTag
M3 详情与新建(第 10 章成果落地)
   详情页(时间线 + 状态流转)+ 新建/编辑(明细行 + 金额联动 + 审批分流)
M4 权限收口与整体验收(第 11 章成果落地)
   <Auth> 按钮权限 + roles 页面拦截复验 + 登出闭环 + 全链路自测用例

12.8 与附录A 的对照与验收

功能验收清单(10 条,附录A §3.1–3.3 口径):

  • [ ] 1. 账密 + 图形验证码登录;验证码错误 toast 且自动换图;
  • [ ] 2. 登录后按角色渲染侧边栏(sales 无「系统设置」等);
  • [ ] 3. sales 直输无权限 URL → 403 页,给出返回路径;
  • [ ] 4. 订单列表:分页 20/页、关键词/状态/日期筛选、按创建时间/金额排序;
  • [ ] 5. 刷新/回退后筛选、页码、排序从 URL 完整还原;
  • [ ] 6. 订单详情:基本信息、明细行金额、操作日志时间线、状态流转按钮;
  • [ ] 7. 新建订单:客户搜索、明细行增删、数量 1–9999 与行折扣 0–100% 即时校验、总额自动汇总;
  • [ ] 8. 整单折扣 >10% 提交后状态为 pending_review,finance 角色可见审批按钮;
  • [ ] 9. 取消订单强制填写原因(draft 等非终态);
  • [ ] 10. 双令牌:过期自动无感刷新(Network 仅 1 次刷新 + 重放成功),登出四清(token/store/缓存/草稿)。

全链路自测用例(5 条,手工跑通):

  1. operator 登录 → 新建订单(2 行明细,折扣 100%)→ 状态 draft → 提交 → pending_review;
  2. 构造整单折扣 85% 的新建 → 提交 → 详情提示待财务审批 → finance 登录 → 审批通过 → approved;
  3. sales 登录 → 列表仅见本人订单(ownerName 过滤由 mock 支撑)→ 尝试直输 /settings → 403;
  4. 停放 mock 的 40102 触发器 → 任意操作触发无感刷新 → 操作无中断、无重复 toast;
  5. 任一账号登出 → 重新以另一角色登录 → 列表、菜单、按钮与角色一致,无上账号数据残留。

交付物模板(每个里程碑提交时填写,进 PR 描述):

## M2 验收记录
- 通过项:验收清单 #4 #5(勾选并附截图/录屏路径)
- 遗留问题:日期区间筛选暂用文本占位(M2.1 跟进);mock 不校验 sales 归属(M4 复验)
- 自测用例:用例 1 通过;用例 2 阻塞(等待审批接口 mock)

🛠 渐进式实战练习

练习 1 · 基础(M1,3–4 h):骨架、登录与动态菜单

任务描述:装配 M1——完整路由树(含懒加载与 404)、AdminLayout(Sider + 面包屑 + 内容区)、登录页(账密 + 验证码 + 失败换图)、ProtectedRoute(isReady 防闪跳 + redirect 回跳)、filterMenus 动态侧边栏、403 页。mock 支持四种角色切换登录(演示账号:demo@qingorder.example.com,角色由验证码输入框旁边的下拉临时控制,仅教学用)。

提示:先让「登录 → 菜单 → 空白页面」跑通再填页面;routeMeta 与 menus 的 key 保持同源;会话恢复 restoreSession 在本里程碑就要接上(刷新不丢登录态)。

验收标准:

  • [ ] 刷新浏览器登录态保持(无登录页闪现);登出后刷新停在登录页;
  • [ ] 四种角色登录后菜单项与附录A §2 权限表一致;
  • [ ] sales 直输 /settings → 403;super_admin 可进;
  • [ ] 构建产物中登录页与业务页分离(每个页面独立 chunk)。
参考答案(App.jsx 最终形态)
// src/App.jsx —— M1 完成后的路由树(M2–M4 往里挂页面即可)
import { Routes, Route, Navigate } from 'react-router'
import { lazy, Suspense } from 'react'
import ProtectedRoute from './router/ProtectedRoute'
import AdminLayout from './layouts/AdminLayout'
import Login from './views/Login'
import Forbidden from './views/Forbidden'
import NotFound from './views/NotFound'

const Dashboard = lazy(() => import('./views/Dashboard'))
const OrderListView = lazy(() => import('./views/orders/OrderListView'))
const OrderDetailView = lazy(() => import('./views/orders/OrderDetailView'))
const OrderCreate = lazy(() => import('./views/orders/OrderCreate'))
const Settings = lazy(() => import('./views/Settings'))

function LazyPage({ children }) {
  return <Suspense fallback={<div className="page-loading">加载中…</div>}>{children}</Suspense>
}

export default function App() {
  return (
    <Routes>
      <Route path="/login" element={<Login />} />
      <Route path="/403" element={<Forbidden />} />
      <Route element={<ProtectedRoute />}>
        <Route path="/" element={<AdminLayout />}>
          <Route index element={<Navigate to="/dashboard" replace />} />
          <Route path="dashboard" element={<LazyPage><Dashboard /></LazyPage>} />
          <Route path="orders" element={<LazyPage><OrderListView /></LazyPage>} />
          <Route path="orders/new" element={<LazyPage><OrderCreate /></LazyPage>} />
          <Route path="orders/:id" element={<LazyPage><OrderDetailView /></LazyPage>} />
          <Route element={<ProtectedRoute roles={['super_admin']} />}>
            <Route path="settings" element={<LazyPage><Settings /></LazyPage>} />
          </Route>
        </Route>
      </Route>
      <Route path="*" element={<NotFound />} />
    </Routes>
  )
}

练习 2 · 进阶(M2 + M3,7–9 h):订单列表、详情与新建

任务描述:装配 M2 + M3——mock/db.js 造 30+ 条合规订单 → useOrderList 驱动列表页(筛选/分页/排序/URL 回填/防抖)→ 详情页(基本信息 + 明细金额 + 操作日志时间线 + OrderActions 状态流转)→ 新建/编辑页(第 10 章表单 + 草稿 store + 提交分流)。提交草稿、审批(approve/reject)、取消(带原因)三条 mutation 全部接通并失效缓存。

提示:时间线用 AntD Timeline 渲染 order.logs(mock 数组:{ at, operator, action, detail });审批走乐观更新(第 09 章 §9.10)体验更佳但必须有回滚;OrderActions 的按钮集合 = STATUS_ACTIONS[status] ∩ hasPermission。

验收标准:

  • [ ] 表单通过第 10 章练习 2/3 的全部验收标准,用例「折扣 85 → pending_review」成立;
  • [ ] 详情页执行「提交审核」后:状态标签即时更新、操作日志新增一条、列表页返回时数据已刷新;
  • [ ] 筛选(关键词防抖 400ms)、翻页、排序全部写 URL;翻页无 loading 闪烁(placeholderData);
  • [ ] 取消订单必须填原因(空原因不可提交);cancelled 后无任何操作按钮。
参考答案(mock/db.js 的造数骨架)
// mock/db.js —— 造数:字段与附录A 实体逐字一致(金额单位分)
const SKUS = [
  { skuId: 'sku001', skuName: '智能网关 X1', unitPriceFen: 129900 },
  { skuId: 'sku002', skuName: '温湿度传感器', unitPriceFen: 19900 },
]

function makeOrder(seq, overrides = {}) {
  const date = new Date(Date.now() - seq * 86_400_000)
  const ymd = date.toISOString().slice(0, 10).replaceAll('-', '')
  const items = overrides.items ?? [
    { skuId: 'sku001', skuName: '智能网关 X1', quantity: 3, unitPriceFen: 129900, discountPercent: 100 },
  ]
  const totalAmount = items.reduce(
    (s, it) => s + Math.round(it.quantity * it.unitPriceFen * (it.discountPercent / 100)), 0,
  )
  return {
    id: `o_${1000 + seq}`,
    orderNo: `QO${ymd}${String(seq).padStart(6, '0')}`, // 附录A:QO + yyyyMMdd + 6 位序号
    customerId: 'c_001',
    customerName: '华信电子',
    ownerName: '青云-销售小王',
    status: overrides.status ?? 'draft',
    items,
    wholeDiscountPercent: overrides.wholeDiscountPercent ?? 100,
    totalAmount,
    createdAt: date.toISOString(),
    logs: [{ at: date.toISOString(), operator: 'system', action: '创建订单', detail: '-' }],
  }
}

export const db = { orders: Array.from({ length: 32 }, (_, i) => makeOrder(i + 1, seed(i))) }
// seed(i) 可按序号轮换 status/ownerName/wholeDiscountPercent,覆盖全部状态用于联调

列表 mock 的分页过滤(附录A 分页契约):

// mock/server.js 节选
export function listOrders({ keyword, status, page = 1, pageSize = 20, sort = 'createdAt', order = 'desc' }) {
  let list = db.orders.filter((o) =>
    (!keyword || o.orderNo.includes(keyword) || o.customerName.includes(keyword))
    && (!status || o.status === status))
  list = [...list].sort((a, b) => (order === 'asc' ? 1 : -1) * (a[sort] > b[sort] ? 1 : -1))
  return { list: list.slice((page - 1) * pageSize, page * pageSize), total: list.length, page, pageSize }
}

练习 3 · 挑战(M4,2–3 h):权限收口 + 完整验收

任务描述:装配 M4——<Auth> 接入所有操作按钮(删除、审批、批量指派入口)、roles 页面拦截复验(报表/设置)、useLogout 登出闭环、批量导出选中订单为 CSV(第 09 章练习 3 的 exportSelected 挂上 AntD Table 的 rowSelection)。然后跑 §12.8 的 10 条验收 + 5 条自测用例,填写两份交付物模板。

提示:验收时切换四种角色各过一遍「菜单 → 页面 → 按钮」矩阵,建议画成表格逐格打勾;无感刷新用例要在「列表页翻页中」触发才最接近真实。

验收标准:

  • [ ] 10 条功能验收全绿;5 条自测用例全链路通过;
  • [ ] 「删除订单」按钮仅 super_admin/operator 可见;「审批」仅 finance;权限矩阵表格无一处不符;
  • [ ] 登出后以 finance 重新登录:菜单、缓存、草稿均无前一账号痕迹;
  • [ ] 交付物模板两份已填(每里程碑一份),遗留问题都有跟进编号。
参考答案(rowSelection + 导出接线)
// OrderListView 追加批量导出
import { Auth } from '@/components/Auth' // named export 亦可,团队统一即可
import { useOrderList } from '@/hooks/useOrderList'

const [selectedIds, setSelectedIds] = useState([])
const { exportSelected } = useOrderList()

<Table
  rowSelection={{ onChange: (keys) => setSelectedIds(keys) }}
  /* ...其余 props 同 §12.5 */ />

<Auth allow="order:export">
  <Button disabled={selectedIds.length === 0} loading={exporting}
    onClick={async () => { setExporting(true); await exportSelected(selectedIds); setExporting(false) }}>
    导出选中(CSV)
  </Button>
</Auth>

权限矩阵(验收工具,放进交付物):

按钮/页面 super_admin operator sales finance
删除订单 ✔ ✔ ✘ ✘
审批(通过/拒绝) ✘ ✘ ✘ ✔
批量指派入口 ✔ ✔ ✘ ✘
导出 CSV ✔ ✔ ✘ ✔
系统设置页 ✔ ✘ ✘ ✘
报表中心页 ✔ ✔ ✘ ✔

⚠️ 常见易错点

易错点 1 · 状态/权限魔法字符串散落各页

❌ 错误代码:

{order.status === 'pendding_review' && <Button>审核通过</Button>} // 拼错 + 散落三处
{role === 'admin' && <Button danger>删除</Button>}              // 角色名还与附录A 不一致

错误原因:状态机与权限模型是业务契约,散落成字符串后,附录A 改一处(比如角色更名)就是全局捉虫;拼写错误还静默通过('pendding' 永远不相等)。

✅ 正确写法:

<Auth allow="order:approve"><Button>审核通过</Button></Auth>
// 状态判断走 STATUS_ACTIONS 映射表(§12.4),角色判断走 BUTTON_PERMISSIONS
// ESLint 层面可加 no-restricted-syntax 禁止裸写状态字符串

易错点 2 · 菜单与守卫两处维护角色

❌ 错误代码:

// menus.js:{ key: '/reports', label: '报表中心', roles: ['operator', 'finance'] }
// App.jsx: <Route element={<ProtectedRoute roles={['super_admin', 'operator']} />}>  // 忘了 finance
// 结果:finance 看得到菜单、点进去 403 —— 用户视角「权限系统抽风」

错误原因:同一份权限规则写在两个文件,改配置必然漏改;这不是粗心问题,是结构问题。

✅ 正确写法:

// 菜单 roles 与路由 roles 由同一常量派生(单一事实源):
import { MENUS } from '@/router/menus'
const REPORT_ROLES = MENUS.find((m) => m.key === '/reports').roles
<Route element={<ProtectedRoute roles={REPORT_ROLES} />}>…</Route>
// 更进一步(企业口径):roles 由后端 profile.menus 下发,前端只渲染

易错点 3 · 详情页参数变化不刷新,或 mock 与真实接口 shape 漂移

❌ 错误代码:

// 详情页从 /orders/1001 点「下一单」到 /orders/1002,界面还是 1001
useEffect(() => { load(id) }, [])              // 依赖数组漏了 id(第 07 章易错点 1 重演)
// 同时:mock 返回 { list, rows },真实附录A 契约是 { list, total, page, pageSize }

错误原因:两件事同根——契约没有单一事实源。参数契约(URL→数据)与数据契约(响应 shape)只要有一处靠「记忆」维护,联调时就会爆炸。

✅ 正确写法:

useQuery({ queryKey: ['orders', 'detail', id], queryFn: ({ signal }) => fetchOrderDetail(id, { signal }) })
// id 进 key:参数变化自动重查(07 章问题的 Query 解)
// mock 的返回结构对照附录A §5 逐字段核对,统一响应体 { code, message, data } 不准走样

易错点 4 · 金额展示层转换失控:分/元混用

❌ 错误代码:

<td>{order.totalAmount / 100} 元</td>        {/* A 页面手除 100 */}
<td>¥{(order.totalAmount / 100).toFixed(1)}</td> {/* B 页面留 1 位小数 */}
<InputNumber value={form.unitPriceFen / 100} /> {/* C 处把「分」的字段直接当元展示 */}

错误原因:转换逻辑散落,精度与格式各自为政;一旦有人把「元」写回数据层,误差固化进库(第 09/10 章易错点的系统集成版)。

✅ 正确写法:

// 全站唯一入口:utils/money.js 的 formatFen / calcItemFen / sumFen
<td>{formatFen(order.totalAmount)} 元</td>
// review 清单加一条:任何 /100 或 *100 必须出现在 money.js 内

易错点 5 · 提交与流转不加锁:双击两笔订单 / 连点两次审批

❌ 错误代码:

<Button type="primary" onClick={() => createMutation.mutate(payload)}>提交订单</Button>
// isSubmitting 没接:双击 = 两次 POST /orders = 两笔真实订单(附录A 一期事故清单复刻)

错误原因:mutation 未连接 pending 态,按钮永远可点;后端幂等是最后防线,前端连第一道闸都没关。

✅ 正确写法:

<Button type="primary" loading={createMutation.isPending} disabled={createMutation.isPending}
  onClick={() => createMutation.mutate(payload)}>提交订单</Button>
// useOrderActions 的 pendingAction 同理:流转按钮逐个 loading(§12.6 已内置)

易错点 6 · 状态流转只改前端字段,不失效缓存

❌ 错误代码:

const handleApprove = async () => {
  await reviewOrder(order.id, 'approve')
  order.status = 'approved' // 直接改 props/缓存对象;列表页还是「待审核」
}

错误原因:直接修改只影响当前内存引用;Query 缓存与列表页毫不知情,跨页状态不一致直到刷新才「治好」。直接改 props 还违反单向数据流(第 03 章)。

✅ 正确写法:

onSuccess: () => {
  queryClient.invalidateQueries({ queryKey: ['orders', 'detail', order.id] })
  queryClient.invalidateQueries({ queryKey: ['orders', 'list'] }) // 相关缓存全部标记过期
}
// 需要瞬时反馈的场景用乐观更新(§9.10):onMutate 快照 + onError 回滚,而不是裸改

✅ 最佳实践

  1. 里程碑即验收:M1–M4 每个 3–4 小时一个可验收切片,验收记录进 PR 描述——「攒到大爆炸式联调」是学生作品与企业交付的第一区别。
  2. 常量先行:orderStatus.js / permissions.js 在写任何页面前就位;状态机映射表让「加一个状态」变成改一张表。
  3. 契约即文档:mock 响应逐字段对照附录A §5(统一响应体、分页结构、实体定义),后端接入时只换 VITE_API_BASE_URL。
  4. 单一事实源三连:路由表(页面)、routeMeta(面包屑/roles)、menus(菜单)同源派生;权限矩阵表格作为验收工具随代码提交。
  5. 金额纪律写进 review 清单:/100、*100 只允许出现在 money.js;列表、详情、表单、导出四处展示统一 formatFen。
  6. 每个写操作都有三层防护:按钮 loading/disabled → 请求层防抖/去重 → 后端幂等;涉及审批的动作优先乐观更新 + 回滚。
  7. 缓存失效粒度刻意为之:动作成功后「详情精确失效 + 列表前缀失效」,跨页一致性由 Query 保证,不靠手改对象。
  8. 交付可接手:目录准入标准、遗留问题编号、自测用例记录——第 22 章团队协作规范的预演,从现在开始养成。

📌 本章小结

  1. 中级实战 = 装配:07 路由、08 状态、09 请求层、10 表单、11 鉴权六个零件按 M1–M4 组装成附录A §3.1–3.3 的可用版本。
  2. 状态机映射表(STATUS_ACTIONS)与权限常量表(BUTTON_PERMISSIONS)的交集决定每个按钮的命运——业务规则数据化,UI 只是投影。
  3. 列表、详情、新建三个页面几乎没有「新代码」,全是既有 Hook/组件的复用与组合——分层的回报在本章兑现。
  4. 验收驱动交付:10 条功能验收 + 5 条全链路用例 + 权限矩阵 + 里程碑记录,全部通过才算毕业。
  5. 中级阶段的最后一站是自查:打开《阶段自查 · 中级能力清单》完成 16 条能力勾选与 30 分钟自测小考;达标后进入第三部分 · 高级阶段,开始治理万级列表、首屏性能与质量保障。

📄 第 13 章 · 渲染性能优化 13-渲染性能优化.md

📍 阶段:高级阶段(第 13–18 章)|⏱ 预计学时:6–8 小时|🔗 前置:第 12 章 · 中级实战 完成 + 附录A §4 非功能需求

⬅ 上一章:中级实战 · 轻订单管理系统 | 📖 本版目录 | ➡ 下一章:加载性能优化

💡 对照学习:本页是 React 版第 13 章,对应 Vue 版第 13 章 · 渲染性能优化。概念映射:React.memo ≈ v-memo、useMemo ≈ computed 的缓存语义、Profiler ≈ Vue Devtools 性能面板、TanStack Virtual ≈ vue-virtual-scroller、用 key 重置组件状态 ≈ 动态 :key 强制重建子树。核心心智差异:Vue 靠依赖收集做到「谁用了数据谁更新」,React 默认「父渲染 → 子树全渲染」,优化手段偏手动声明。从本章起,全书代码全面切换为 TypeScript(.jsx → .tsx),工程化配置在第 17 章展开。

🎯 学习目标

学完本章,你将能够:

  1. 说出 React 重渲染的三大触发条件,解释「渲染阶段 / 提交阶段」的划分,以及为什么子组件没有任何 props 变化也会跟着父组件重渲染;
  2. 使用 React DevTools Profiler 录制一次真实交互,读出「谁在重渲染、渲染原因、实际耗时」三个关键信息;
  3. 恰当使用 React.memo / useMemo / useCallback,并能识别让 memo 失效的典型写法(内联对象、内联函数、未缓存的 Context value);
  4. 用「状态下放」与「children 组合」两种结构性手段缩小重渲染范围,而不是到处包 memo;
  5. 用 key 重置组件状态(如切换客户后清空明细表单),避免为「重置」编写 effect;
  6. 从零实现一个简化版虚拟滚动 hook 讲清原理,再用 TanStack Virtual 替换为生产级实现;
  7. 完成万级订单列表卡顿治理:滚动掉帧 < 5%(附录A §4 目标),并输出优化前后的 Profiler 对比记录。

📖 核心知识点

13.1 渲染机制原理:Fiber 与「重渲染」到底指什么

先统一词汇。React 把一次更新分成两个阶段:

  • 渲染阶段(render phase):重新调用组件函数,得到新的元素树,与旧树 diff(这一步纯计算,不碰 DOM)。Fiber 架构把这项工作拆成一个个可中断、可恢复的小单元(Fiber 节点),让 React 可以按优先级让路给高优先级更新——这是 React 18+ 并发特性的地基。
  • 提交阶段(commit phase):把 diff 结果一次性应用到 DOM,同步执行 useLayoutEffect,随后浏览器异步执行 useEffect。

「卡」通常来自两处:渲染阶段耗时太长(大列表全量 diff),或提交阶段 DOM 变更太多(大量节点重建)。优化前先分清是哪一种。

重渲染的触发条件只有三类:

  1. 组件自身的 state 或 reducer dispatch 变化(useState / useReducer / useSyncExternalStore 等);
  2. 父组件重渲染(React 默认递归重渲染整棵子树,与 props 变没变无关);
  3. Context value 变化(消费该 Context 的子树整体重渲染)。
// 父组件重渲染,子组件必然重渲染——哪怕它没接收任何 props
function Parent() {
  const [keyword, setKeyword] = useState('')
  console.log('Parent 渲染')
  return (
    <div>
      <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
      {/* Child 没有任何 props,仍会随每次按键重渲染 */}
      <Child />
    </div>
  )
}

function Child() {
  console.log('Child 渲染')
  return <footer>轻订单 · 内部系统</footer>
}

对照 Vue:Vue 组件在渲染时收集依赖,没用到 keyword 的组件不会被触发;React 没有依赖追踪,默认「自上而下全量执行函数,diff 兜底」。这不是谁优谁劣——React 换来的是「渲染是纯函数、可预测」的心智模型,代价是优化要手动声明(memo)。理解这一点,后面所有手段才不玄学。

13.2 定位工具:React DevTools Profiler 与 <Profiler> API

先测量,再优化。React DevTools 的 Profiler 面板是最常用的定位工具:

  1. 打开 Profiler 面板 → 点录制 → 在页面上复现卡顿操作 → 停止录制;
  2. 火焰图按「渲染耗时」排序,宽 = 耗时长;颜色越黄越慢;
  3. 在设置里勾选 「Highlight updates when components render」 可实时看到谁在闪;勾选 「Record why each component rendered」 后,点选任意 Fiber 节点可看到渲染原因(如 Parent component rendered、Props changed)。

组件级埋点用官方 <Profiler> API,可以精确到某个子树:

import { Profiler, type OnRenderCallback } from 'react'

const onOrderListRender: OnRenderCallback = (id, phase, actualDuration) => {
  // phase: 'mount' | 'update' | 'nested-update'
  if (actualDuration > 16) {
    // 超过一帧预算(60fps ≈ 16.7ms)才记录,避免日志噪音
    console.warn(`[${id}] ${phase} 耗时 ${actualDuration.toFixed(1)}ms`)
  }
}

export function OrderListEntry({ orders }: { orders: Order[] }) {
  return (
    <Profiler id="OrderList" onRender={onOrderListRender}>
      <OrderListView orders={orders} />
    </Profiler>
  )
}

两个时长字段要分清:actualDuration 是本次实际渲染耗时(含子组件);baseDuration 是「不 memo 时的估计耗时」。baseDuration 大而 actualDuration 小,说明 memo 生效了;两者都大,说明这条子树本身就是热点,优先结构性优化。

API 细节以官方文档为准:https://react.dev/reference/react/Profiler

13.3 memo 三件套:React.memo / useMemo / useCallback 的恰当使用

三者解决的问题不同:

手段 缓存什么 典型场景 什么时候是负优化
React.memo 组件渲染结果(props 浅比较相同则跳过渲染) 列表行、图表等「渲染贵、props 少变」的组件 渲染很便宜的组件;props 每次都是新引用
useMemo 计算结果 1 万条订单的 filter + sort;引用型 props(对象/数组) 一次性小计算(total + 1)
useCallback 函数引用 传给 memo 子组件的回调;作为其他 Hook 的依赖 从不传给 memo 组件的局部回调
import { memo, useCallback, useMemo } from 'react'

interface OrderRowProps {
  order: Order
  active: boolean
  onOpen: (id: string) => void
}

// memo:props 浅比较相同 → 跳过整行渲染
const OrderRow = memo(function OrderRow({ order, active, onOpen }: OrderRowProps) {
  return (
    <tr className={active ? 'row row--active' : 'row'} onClick={() => onOpen(order.id)}>
      <td>{order.orderNo}</td>
      <td>{order.customerName}</td>
      <td>{formatAmount(order.totalAmount)}</td>
      <td><StatusTag status={order.status} /></td>
    </tr>
  )
})

export function OrderTable({ orders, activeId }: { orders: Order[]; activeId: string | null }) {
  // useMemo:1 万条的 filter + sort 是贵计算,只有依赖变化才重算
  const sortedOrders = useMemo(
    () => [...orders].sort((a, b) => b.createdAt.localeCompare(a.createdAt)),
    [orders],
  )

  // useCallback:保证 onOpen 引用稳定,否则下面每行 memo 全部失效
  const handleOpen = useCallback((id: string) => {
    setActiveId(id) // 稳定的 setState 引用,依赖数组为空
  }, [])

  return (
    <table>
      <tbody>
        {sortedOrders.map((order) => (
          <OrderRow key={order.id} order={order} active={order.id === activeId} onOpen={handleOpen} />
        ))}
      </tbody>
    </table>
  )
}

判断准则一句话:memo 是「逃逸舱」不是「默认装备」。先问三个问题——渲染真的贵吗?props 真的少变吗?props 引用真的稳定吗?三问有两次「否」,就不要包。

13.4 结构性优化:状态下放与 children 组合

比 memo 更治本的是改结构,让高频变化的状态影响范围天然变小。

手段一:状态下放。搜索关键词每敲一个字变一次,但只有 FilterBar 需要它——那就把 state 移进 FilterBar,而不是放在列表页顶层:

// ❌ 状态放顶层:每敲一个字,整个列表页(含 1 万行)重渲染
export function OrderListViewV1() {
  const [keyword, setKeyword] = useState('')
  const debounced = useDebouncedValue(keyword, 300)
  const { data } = useQuery({ queryKey: ['orders', debounced], queryFn: fetchOrders })
  return (
    <>
      <input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
      <OrderTable orders={data?.list ?? []} />
    </>
  )
}

// ✅ 状态下放:输入态只影响 FilterBar;防抖值经回调上抛后才触发列表更新
export function OrderListView() {
  const [query, setQuery] = useState('')
  const { data } = useQuery({ queryKey: ['orders', query], queryFn: () => fetchOrders(query) })
  return (
    <>
      <OrderFilters onSearch={setQuery} /> {/* keyword 的 state 在 OrderFilters 内部 */}
      <OrderTable orders={data?.list ?? []} />
    </>
  )
}

手段二:children 组合。如果一个组件自己有易变 state,又包着渲染很贵的 children,把 children 当 props 传进来——children 是父组件创建好的元素对象,内部 state 变化不会重新创建它:

function ScrollTracker({ children }: { children: ReactNode }) {
  const [scrollTop, setScrollTop] = useState(0)
  return (
    <div
      style={{ height: 600, overflow: 'auto' }}
      onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
    >
      <div className="scroll-hud">已滚动 {Math.round(scrollTop)}px</div>
      {/* scrollTop 变化只重渲染本组件;children 引用不变 → React 直接复用,不重渲染子树 */}
      {children}
    </div>
  )
}

// 使用方:OrderTable 是父组件里创建好的元素,滚动再频繁它也不重渲染
;<ScrollTracker>
  <OrderTable orders={orders} />
</ScrollTracker>

这就是官方文档说的「lifting content up」。对比 Vue:Vue 里靠 <KeepAlive> 或 v-once 这类机制缓存,React 里靠调整元素创建的位置,殊途同归。

13.5 key 重置组件状态

key 不只是列表 diff 的身份牌——key 变化会让 React 卸载旧实例、挂载新实例,状态随之归零。这是最干净的重置手段:

// 切换编辑的订单时,用 order.id 作为 key:表单内部所有 useState 自动回到初始值
function OrderEditEntry({ order }: { order: Order }) {
  return <OrderEditForm key={order.id} initialOrder={order} />
}

function OrderEditForm({ initialOrder }: { initialOrder: Order }) {
  const [items, setItems] = useState<OrderItem[]>(initialOrder.items)
  // 不需要 useEffect 监听 order.id 变化来「重置表单」——key 变化时本组件整体重建
  return <OrderItemsEditor items={items} onChange={setItems} />
}

典型场景:客户切换后清空明细行、Tab 切换后重置滚动位置、弹窗复用同一组件但要求每次打开是全新状态。比写「监听 props 变化再 setState」的 effect 更少、更不出错。

13.6 虚拟滚动:自研简化版 + TanStack Virtual

万级数据的终解是虚拟滚动:DOM 里只渲染可视区附近的几十行。原理三步:

  1. 容器固定高度、内部撑一个总高度占位(count × itemHeight),撑出真实滚动条;
  2. 监听滚动,算出可视区间 [startIndex, endIndex],只渲染这段;
  3. 渲染的行用 translateY(start) 定位到正确位置。

先自研一个固定行高的简化版,把原理焊进脑子:

interface VirtualItem { index: number; offsetTop: number }
interface UseVirtualListOptions { count: number; itemHeight: number; viewportHeight: number; overscan?: number }

export function useVirtualList({ count, itemHeight, viewportHeight, overscan = 5 }: UseVirtualListOptions) {
  const [scrollTop, setScrollTop] = useState(0)

  const start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan)
  const visibleCount = Math.ceil(viewportHeight / itemHeight)
  const end = Math.min(count, start + visibleCount + overscan)

  const items: VirtualItem[] = []
  for (let i = start; i < end; i++) items.push({ index: i, offsetTop: i * itemHeight })

  return {
    items,
    totalHeight: count * itemHeight,
    onScroll: (e: React.UIEvent<HTMLDivElement>) => setScrollTop(e.currentTarget.scrollTop),
  }
}

export function OrderVirtualTable({ orders }: { orders: Order[] }) {
  const { items, totalHeight, onScroll } = useVirtualList({
    count: orders.length, itemHeight: 48, viewportHeight: 600,
  })
  return (
    <div style={{ height: 600, overflow: 'auto' }} onScroll={onScroll}>
      <div style={{ height: totalHeight, position: 'relative' }}>
        {items.map(({ index, offsetTop }) => (
          <div key={orders[index].id} style={{ position: 'absolute', top: offsetTop, height: 48, width: '100%' }}>
            <OrderRow order={orders[index]} active={false} onOpen={() => {}} />
          </div>
        ))}
      </div>
    </div>
  )
}

生产环境用 @tanstack/react-virtual(TanStack Virtual v3),支持动态行高、横向虚拟、网格等:

pnpm add @tanstack/react-virtual
import { useVirtualizer } from '@tanstack/react-virtual'
import { useRef } from 'react'

export function OrderVirtualTableV2({ orders }: { orders: Order[] }) {
  const scrollRef = useRef<HTMLDivElement>(null)

  const virtualizer = useVirtualizer({
    count: orders.length,               // 数据总量(可以远超 1 万)
    getScrollElement: () => scrollRef.current,
    estimateSize: () => 48,             // 行高估计值,动态行高时可传测量函数
    overscan: 8,                        // 上下各多渲染 8 行,滚动更顺滑
  })

  return (
    <div ref={scrollRef} style={{ height: 600, overflow: 'auto' }}>
      <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
        {virtualizer.getVirtualItems().map((virtualRow) => {
          const order = orders[virtualRow.index]
          return (
            <div
              key={order.id} // 稳定业务 id,见「易错点 13-3」
              style={{
                position: 'absolute', top: 0, left: 0, width: '100%',
                height: virtualRow.size,
                transform: `translateY(${virtualRow.start}px)`,
              }}
            >
              <OrderRow order={order} active={false} onOpen={noop} />
            </div>
          )
        })}
      </div>
    </div>
  )
}

大表格优化清单(逐项自查,命中一条治一条):

症状 手段
首次渲染卡 虚拟滚动 + 首屏只取第一页
输入筛选卡 状态下放 + 防抖 + useMemo 缓存筛选结果
行 hover/点击卡 memo 行组件 + 稳定回调引用
滚动掉帧 虚拟滚动 + contain: strict 限制重排范围 + 避免每行内联阴影/大图
时间/金额格式化卡 在数据进 store 前预计算展示字段,别在每行 render 里调 toLocaleString

13.7 企业案例:万级订单列表卡顿治理

第 12 章交付的 OrderListView 在联调环境只有 30 条数据,直到灰度环境灌入 1 万条真实分布的订单 后,销售反馈:每敲一个字列表卡 300ms,滚动表格像幻灯片。Profiler 现场记录:

问题 Profiler 证据 根因
输入关键词卡 300ms 每次按键 OrderListView 整棵子树重渲染,actualDuration ≈ 280ms keyword state 放在页面顶层,1 万行全量渲染
滚动掉帧 22% 滚动时无 state 更新也掉帧 1 万个 <tr> 全量在 DOM 里
行点击卡 OrderRow 每行 actualDuration ≈ 0.8ms × 10000 行内每次 render 调 toLocaleString 格式化金额

治理五步(每步可独立验收):

  1. 状态下放:keyword 移入 OrderFilters 内部 + 300ms 防抖,输入时列表页零渲染;
  2. 虚拟滚动:全量渲染替换为 TanStack Virtual,DOM 行数从 10000 降到 ~30;
  3. 行 memo 化:OrderRow 包 memo,回调引用用 useCallback 固定;
  4. 预计算展示字段:后端返回后统一格式化 orderNoDate / amountText / statusLabel,行渲染只读字段;
  5. CSS 收口:滚动容器加 contain: strict,行内去除每行渐变阴影。

结果(同一台中端安卓机、同一数据集):

指标(附录A §4) 治理前 治理后
输入筛选响应 ~300ms < 16ms(列表零渲染)
滚动掉帧 22% 3%(达标:掉帧 < 5%)
DOM 节点数 ~62,000 ~400

把这份对比表存进仓库 docs/perf/order-list-2026Q3.md——性能治理没有「完成」,只有「回归前」,记录就是下一次治理的起点。

🛠 渐进式实战练习

练习 1 · 基础(10–20 min)

任务描述:从第 12 章仓库检出 OrderListView,先用 React DevTools Profiler 录制「在搜索框输入 华东」的操作,记录重渲染最贵的 3 个组件及其耗时;然后给 OrderRow 加上 memo,并修复让它失效的引用问题(onOpen 回调)。

提示:Profiler 面板设置里打开「Record why each component rendered」;memo 失效不代表没包,可能是父组件每次传了新函数——顺着渲染原因找。

验收标准:再次录制同样操作,OrderRow 在「父组件重渲染但 props 未变」时不再出现在火焰图中;把前后两次 Profiler 截图存入 docs/perf/。

参考答案
const OrderRow = memo(function OrderRow({ order, onOpen }: OrderRowProps) {
  return (
    <tr onClick={() => onOpen(order.id)}>
      <td>{order.orderNo}</td>
      <td>{order.customerName}</td>
      <td>{formatAmount(order.totalAmount)}</td>
    </tr>
  )
})

// 父组件中:回调引用必须稳定,否则 memo 形同虚设
export function OrderTable({ orders }: { orders: Order[] }) {
  const handleOpen = useCallback((id: string) => {
    openOrderDetail(id) // setState 或跳转,依赖稳定
  }, [])
  return <tbody>{orders.map((o) => <OrderRow key={o.id} order={o} onOpen={handleOpen} />)}</tbody>
}

练习 2 · 进阶(30–60 min)

任务描述:用「状态下放 + children 组合」重构 OrderListView:搜索关键词的 state 收进 OrderFilters 内部,防抖后通过 onSearch 上抛;再把 OrderTable 放进一个带滚动进度 HUD 的 ScrollTracker 组件中,验证 HUD 实时刷新时表格不重渲染。

提示:OrderFilters 内部自己 useDebouncedValue;ScrollTracker 接收 children: ReactNode;用 Profiler 的「why did it render」确认 OrderTable 的渲染原因是 Props changed(数据变了)而不是 Parent rendered。

验收标准:连续输入 10 个字符期间,Profiler 中 OrderTable 渲染次数 ≤ 2 次(防抖上抛);滚动时 OrderTable 零渲染。

参考答案
function OrderFilters({ onSearch }: { onSearch: (q: string) => void }) {
  const [keyword, setKeyword] = useState('') // 状态下放:高频态只属于过滤器
  const debounced = useDebouncedValue(keyword, 300)
  useEffect(() => { onSearch(debounced) }, [debounced, onSearch])
  return <SearchInput value={keyword} onChange={setKeyword} />
}

function ScrollTracker({ children }: { children: ReactNode }) {
  const [progress, setProgress] = useState(0)
  return (
    <div style={{ height: 600, overflow: 'auto' }}
         onScroll={(e) => setProgress(e.currentTarget.scrollTop / (e.currentTarget.scrollHeight - e.currentTarget.clientHeight))}>
      <div className="hud">阅读进度 {Math.round(progress * 100)}%</div>
      {children}
    </div>
  )
}

练习 3 · 挑战(选做,60+ min)

任务描述:给「轻订单」列表页接入虚拟滚动:先接入 TanStack Virtual 跑通 1 万条 mock 数据;挑战部分是给简化版 useVirtualList 增加动态行高支持(行高 40–64px 不定),并对比两者的滚动表现。

提示:固定行高时位置可算 index × h;动态行高的经典做法是「估计 + 事后校正」——先用 estimateSize 渲染,真实 DOM 挂载后 ResizeObserver 量出实际高度回写缓存,TanStack Virtual 内部就是这么做的。1 万条 mock 用 mock/db.js 的生成器循环放大,字段保持附录A 实体一致。

验收标准:1 万条数据滚动掉帧 < 5%(用 DevTools Performance 面板录 10 秒滚动验证);快速滚到第 9000 条再回滚,行高不跳动(校正逻辑生效);切换筛选后列表状态正确重置。

参考答案(关键片段)
// 动态行高的最小可运行思路:估计 → 测量 → 回写
const rowHeights = useRef(new Map<number, number>()) // index -> 实测高度

const virtualizer = useVirtualizer({
  count: orders.length,
  getScrollElement: () => scrollRef.current,
  estimateSize: (i) => rowHeights.current.get(i) ?? 48, // 优先用实测值
  measureElement: (el) => el?.getBoundingClientRect().height ?? 48, // 挂载后测量
  overscan: 8,
})

// 行节点用 ref 挂给 virtualizer,交给它测量:
;<div key={order.id} data-index={virtualRow.index} ref={virtualizer.measureElement} style={{...}} />

TanStack Virtual 会自动在测量后更新布局并校正滚动位置;不要自己再写一层 ResizeObserver,避免双重测量打架。

⚠️ 常见易错点

易错点 1:在 render 中创建新对象/函数传给 memo 组件,优化全部失效

// ❌ 每次渲染都是新的 rowStyle 对象、新的 onOpen 函数
//    props 浅比较必然不同 → memo 的 OrderRow 每次都照常重渲染
<OrderRow order={order} style={{ height: 48 }} onOpen={(id) => openOrder(id)} />
// ✅ 引用稳定:对象用 useMemo,函数用 useCallback(或提升到模块层)
const rowStyle = useMemo<CSSProperties>(() => ({ height: 48 }), [])
const handleOpen = useCallback((id: string) => openOrder(id), [])
;<OrderRow order={order} style={rowStyle} onOpen={handleOpen} />

现象:明明包了 memo,Profiler 里它还是每帧都渲染。记住:memo 比较的是引用,不是值。

易错点 2:useEffect 里加监听不清理,监听器越积越多

// ❌ 组件卸载/依赖变化时旧的监听器没人摘
//    现象:页面来回切换后,一次 resize 触发 N 次回调,内存曲线只升不降
useEffect(() => {
  window.addEventListener('resize', recalcVirtualHeight)
}, [])
// ✅ 每次 effect 返回清理函数;依赖变化时 React 先执行旧清理再跑新 effect
useEffect(() => {
  window.addEventListener('resize', recalcVirtualHeight)
  return () => window.removeEventListener('resize', recalcVirtualHeight)
}, [])

在虚拟列表里这最隐蔽:滚动相关监听堆积后,「越滚越卡」常被误判成虚拟滚动本身不行。

易错点 3:虚拟列表用 index 当 key,DOM 复用与数据错位

// ❌ 筛选变化后 index 对应的数据完全不同,key 还是 0..n
//    行内如果有 checkbox / 展开态 / 输入框,状态会「串行」到错误的数据行上
{items.map((item, index) => <OrderRow key={index} order={orders[index]} />)}
// ✅ key 用稳定业务 id(订单号全局唯一),数据 ↔ DOM 复用关系永远正确
{items.map(({ index }) => <OrderRow key={orders[index].id} order={orders[index]} />)}

配合虚拟滚动时更危险:DOM 节点被大量复用,错误 key 的错位会从「偶现」变成「高频」。

易错点 4:Context value 每次渲染都新建,下游 memo 集体失效

// ❌ Provider value 是内联对象:每次 Provider 所在组件渲染,value 都是新引用
//    所有消费者重渲染,消费者内部包的 memo 全部白包
<OrderContext.Provider value={{ orders, onOpen }}>
  {children}
</OrderContext.Provider>
// ✅ value 用 useMemo 固定引用;或把 state 与 dispatch 拆成两个 Context 减少误伤
const value = useMemo(() => ({ orders, onOpen }), [orders, onOpen])
;<OrderContext.Provider value={value}>{children}</OrderContext.Provider>

易错点 5:useMemo/useCallback 依赖数组遗漏或撒谎,缓存了脏数据

// ❌ sortKey 变了但依赖里没有它 → 永远用旧排序,还以为 memo 「生效」了
const sorted = useMemo(() => [...orders].sort(comparator(sortKey)), [])
// ✅ 依赖如实声明;靠 eslint-plugin-react-hooks 的 exhaustive-deps 规则兜底,别手工豁免
const sorted = useMemo(() => [...orders].sort(comparator(sortKey)), [orders, sortKey])

缓存正确性的代价是「依赖必须诚实」。如果一段计算简单到不值得维护依赖,就别 memo——这也是它不能当默认装备的原因。

✅ 最佳实践

  1. 先 Profiler 后动手:没有「渲染原因 + 耗时」证据的优化都是猜;优化前后的 Profiler 截图要归档(本章案例放 docs/perf/)。
  2. 结构性优化优先于 memo:状态下放、children 组合、key 重置是零运行时成本的改造;memo 三件套留给「渲染贵 + props 少变」的组件。
  3. 行组件必配稳定引用:任何 memo 化的列表行,它的回调 props 必须来自 useCallback/稳定 setState,对象 props 必须来自 useMemo。
  4. 万级数据直接上虚拟滚动:不要用「分页 + 懒加载」硬扛交互型大列表;固定行高先跑通,动态行高交给 TanStack Virtual 测量机制。
  5. 展示字段预计算:formatAmount、状态文案这类派生值在数据入库/入查询缓存时算好,别放在每行 render 里。
  6. key 永远用业务稳定 id:列表 diff 与「key 重置状态」共用同一条纪律;index key 只允许在「纯展示、无内部状态、列表永不重排」三条件同时满足时使用。
  7. 监听器必须成对出现:addEventListener 与移除写在同一个 effect 里,团队 lint 强制 react-hooks/exhaustive-deps。
  8. 性能治理写成记录:指标(掉帧率、DOM 数、渲染耗时)+ 手段 + 截图三要素齐全,交给下一位接手人而不是烂在聊天记录里。

📌 本章小结

  • React 重渲染的触发条件只有三类(自身 state、父渲染、Context value 变化),渲染阶段是纯计算、可中断,提交阶段才碰 DOM——「卡」先分清卡在哪一阶段。
  • Profiler 先行:谁在重渲染、为什么、多久,三个答案齐了再动手。
  • memo 三件套是逃逸舱不是默认装备,引用不稳定时它们是安慰剂;状态下放、children 组合、key 重置往往更治本。
  • 万级列表的终解是虚拟滚动:自研版讲原理,TanStack Virtual 讲生产,key 用业务 id 防错位。
  • 「轻订单」万级订单列表已从 300ms 输入卡顿治理到掉帧 3%,记录沉淀在 docs/perf/。

渲染变快了,但首屏还是慢——门户要 4 秒才能看到主视觉。下一章我们把矛头转向加载性能:代码分割、图片优化、核心指标测量,目标是把门户 LCP 从 4s 压到 1.8s(附录A 目标 ≤ 2.0s)。

📄 第 14 章 · 加载性能优化 14-加载性能优化.md

📍 阶段:高级阶段(第 13–18 章)|⏱ 预计学时:5–7 小时|🔗 前置:第 06 章门户首页 + 第 07 章路由 + 第 13 章

⬅ 上一章:渲染性能优化 | 📖 本版目录 | ➡ 下一章:组件测试

💡 对照学习:本页是 React 版第 14 章,对应 Vue 版第 14 章 · 加载性能优化。概念映射:React.lazy + <Suspense> ≈ defineAsyncComponent + <Suspense>、骨架屏 fallback ≈ 同名机制、web-vitals / Lighthouse / preload / prefetch / gzip / brotli 与框架无关两版完全一致。唯一差异:Vite 对 React 与 Vue 的懒加载产物都是原生动态 import(),分包思路通用,只是入口组件写法不同。

🎯 学习目标

学完本章,你将能够:

  1. 画出首屏加载瀑布图,把「首屏时间」拆解为传输字节、请求数、JS 执行、渲染路径四类开销,并对号入座选优化手段;
  2. 用 React.lazy + <Suspense> 完成路由级与组件级代码分割,说清哪些组件不该懒加载;
  3. 对首页图片实施懒加载、宽高占位、WebP/AVIF、响应式尺寸(srcset/sizes)四项优化;
  4. 区分 preload / prefetch / dns-prefetch / modulepreload 的语义,并按用户动线落地关键资源预加载;
  5. 说清 LCP / FCP / CLS / INP 的定义、达标线与测量方式,用 web-vitals 库采集并上报;
  6. 用 Lighthouse 建立基线,配置性能预算(LCP ≤ 2.0s、首屏 gzip ≤ 300KB)并卡进 CI;
  7. 实现路由级 + 组件级骨架屏,并完成「门户 LCP 4s → 1.8s」优化复盘,输出措施-收益对照表。

📖 核心知识点

14.1 全景图:首屏时间到底花在哪里

打开门户首页到用户「看到并可用」,时间花在四个地方:

DNS/TCP/TLS ──► HTML 下载解析 ──► CSS/JS 下载 ──► JS 执行 ──► 渲染 LCP
   网络层            文档层             传输层           运行层        渲染层

附录A §4 对「轻订单」门户的硬性目标:LCP ≤ 2.0s(4G 网络、中端安卓机)、JS 首屏 gzip ≤ 300KB。每层开销对应一类优化手段:

开销 优化手段 本章小节
传输字节太大 代码分割、压缩(gzip/brotli)、图片格式 14.2 / 14.3 / 14.7
请求数太多 预加载关键资源、合并与缓存策略 14.4 / 14.7
JS 执行太慢 减少首包体积、延迟非关键脚本 14.2
渲染路径太长 关键 CSS、LCP 元素提前、CLS 防抖动 14.3 / 14.5 / 14.7

优化顺序:先用 Lighthouse 建基线 → 找最大头(通常是图片和首包 JS)→ 逐项治理 → 复测记录。没有基线的优化等于盲打。

14.2 代码分割:React.lazy + Suspense

Vite 打包时默认把所有代码打进一个 bundle。React.lazy 让你把「用户此刻不需要的页面」从首包里拆出去,变成按需加载的独立 chunk:

import { lazy, Suspense } from 'react'
import { createBrowserRouter } from 'react-router'

// 路由级懒加载:访问到才加载,且 Vite 会自动拆成独立 chunk
const HomeView = lazy(() => import('@/views/HomeView'))
const OrderListView = lazy(() => import('@/views/orders/OrderListView'))
const OrderDetailView = lazy(() => import('@/views/orders/OrderDetailView'))
const ReportView = lazy(() => import('@/views/reports/ReportView'))

export const router = createBrowserRouter([
  {
    path: '/',
    element: <AppLayout />, // 布局壳常驻,不懒加载
    children: [
      { index: true, element: <HomeView /> },
      { path: 'orders', element: <OrderListView /> },
      { path: 'orders/:id', element: <OrderDetailView /> },
      { path: 'reports', element: <ReportView /> },
    ],
  },
])

lazy 返回的组件必须挂在 <Suspense> 边界内,否则加载期间会向上抛起悬挂态。把边界放在布局的内容出口处:切换路由时只有内容区闪骨架,侧边栏与顶栏保持稳定:

import { Suspense } from 'react'
import { Outlet } from 'react-router'
import { AppSkeleton } from '@/components/AppSkeleton'

export function AppLayout() {
  return (
    <div className="app-shell">
      <SideMenu />
      <Header />
      {/* Suspense 边界就近包裹会悬挂的内容,而不是包住整个应用 */}
      <main>
        <Suspense fallback={<AppSkeleton />}>
          <Outlet />
        </Suspense>
      </main>
    </div>
  )
}

React Router 7 还提供路由对象上的 lazy 属性(配合 loader 的数据路由模式),可以实现「加载模块 + 拉数据」并行;本教程主讲组件式写法,两者可共存。细节见 https://reactrouter.com 。

不该懒加载的清单:登录鉴权逻辑、全局 Layout、首屏关键组件(门户 Hero 区)、体积很小的高频组件(拆出去反而多一次请求往返)。代码分割的收益 = 「非首屏代码量」,拆 5KB 的小页面纯属折腾。

14.3 图片优化:往往比 JS 更大的那座山

真实项目的首屏字节大头通常是图片。四件套依次上:

interface HeroImageProps { srcBase: string; alt: string }

export function HeroImage({ srcBase, alt }: HeroImageProps) {
  return (
    <img
      // 1. 现代格式优先,浏览器自选;fallback 到 jpg
      srcSet={`${srcBase}-480.avif 480w, ${srcBase}-768.avif 768w, ${srcBase}-1280.avif 1280w, ${srcBase}-1280.webp 1280w`}
      src={`${srcBase}-1280.jpg`}
      // 2. 响应式尺寸:让浏览器按视口宽度选最合适的文件
      sizes="(max-width: 768px) 100vw, 50vw"
      // 3. 显式宽高,预留布局空间,防 CLS
      width={1280}
      height={720}
      // 4. 首屏 LCP 图不懒加载(见易错点 14-4);列表图才 loading="lazy"
      decoding="async"
      alt={alt}
    />
  )
}

// 非首屏图片:懒加载 + 占位
;<img src="/images/customer-cases/3.webp" loading="lazy" decoding="async" width={640} height={360} alt="客户案例" />

配合 CDN 参数按需裁剪(示例为通用约定,具体参数看所用 CDN 文档):?w=768&format=avif&q=80。原则:给多大的显示区域,就传多大的图。

14.4 预加载:preload / prefetch / dns-prefetch / modulepreload

指令 语义 适合谁
<link rel="preload"> 本页一定要用,尽早高优加载 首屏 LCP 图、关键字体、关键 CSS
<link rel="prefetch"> 下一页可能用,空闲时低优拉取 用户动线上的下一个路由 chunk
<link rel="dns-prefetch"> 提前完成 DNS 解析 第三方 CDN 域名
<link rel="modulepreload"> 预加载并解析 ESM 模块 首屏关键 JS 模块(Vite 构建会自动注入入口依赖链)
<!-- index.html:LCP 图提前加载,赢在起跑线 -->
<link rel="preload" as="image" href="/images/hero-1280.avif" fetchpriority="high" />
<link rel="preload" as="font" type="font/woff2" href="/fonts/InterVar.woff2" crossorigin />
<link rel="dns-prefetch" href="//cdn.qingorder.example.com" />

路由级 prefetch 在 React 里没有框架自动档(Next.js 的 <Link prefetch> 除外),常见做法是「悬停即预取」:

import { useEffect } from 'react'
import { Link } from 'react-router'

export function NavToReports() {
  useEffect(() => {
    // 用户动线:从门户进入报表的概率高,hover 200ms 后预取该 chunk
    const timer = window.setTimeout(() => import('@/views/reports/ReportView'), 200)
    return () => window.clearTimeout(timer)
  }, [])
  return <Link to="/reports">销售报表</Link>
}

服务端数据也可以配合 TanStack Query 预取(第 09 章 queryClient.prefetchQuery),chunk 与数据一起抢跑,进页面即可用。

14.5 核心指标与测量:LCP / FCP / CLS / INP

指标 全称 衡量什么 良好线 附录A 目标
FCP First Contentful Paint 首个内容绘制 ≤ 1.8s —
LCP Largest Contentful Paint 最大内容元素(主图/大标题)绘制 ≤ 2.5s 门户 ≤ 2.0s
CLS Cumulative Layout Shift 累积布局偏移 ≤ 0.1 —
INP Interaction to Next Paint 全程交互响应延迟(取代 FID) ≤ 200ms —

用官方 web-vitals 库采集,一次接入四个指标全拿:

pnpm add web-vitals
// src/monitor/vitals.ts
import { onCLS, onINP, onFCP, onLCP, onTTFB, type Metric } from 'web-vitals'

function reportMetric(metric: Metric) {
  // 开发期打表观察;生产期上报到监控(第 18 章),用 sendBeacon 保证页面卸载时不丢
  const body = JSON.stringify({
    name: metric.name,       // 'LCP' | 'CLS' | 'INP' | ...
    value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value),
    id: metric.id,           // 同一指标多次上报的关联 id
    page: location.pathname,
  })
  navigator.sendBeacon('/api/v1/metrics/vitals', body)
  if (import.meta.env.DEV) console.log(`[vitals] ${metric.name}`, metric.value)
}

export function initVitals() {
  onCLS(reportMetric)
  onINP(reportMetric)
  onFCP(reportMetric)
  onLCP(reportMetric)
  onTTFB(reportMetric)
}

注意:Lighthouse 是实验室数据(模拟环境、单次),web-vitals 是真实用户数据(RUM)。上线判断看 LCP p75(75 分位),上线前看实验室跑分,两者互补。

14.6 Lighthouse 与性能预算

基线测量:Chrome DevTools → Lighthouse 面板 → 选 Performance + Mobile + Slow 4G → Analyze。更重要的是把预算卡进 CI,防止优化成果被下一个迭代悄悄吃掉:

// budget.json —— 性能预算:超线即 CI 失败
[
  {
    "path": "/*",
    "timings": [
      { "metric": "largest-contentful-paint", "budget": 2000 },
      { "metric": "cumulative-layout-shift", "budget": 100 }
    ],
    "resourceSizes": [
      { "resourceType": "script", "budget": 300 },
      { "resourceType": "image", "budget": 600 }
    ]
  }
]

脚本体积预算可以先用最朴素的 Node 脚本卡住——首屏 gzip ≤ 300KB(附录A):

// scripts/check-budget.ts —— 构建后运行:对 dist 里的 js 求 gzip 总量
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { gzipSync } from 'node:zlib'
import { join } from 'node:path'

const BUDGET_KB = 300
const dist = 'dist/assets'
const total = readdirSync(dist)
  .filter((f) => f.endsWith('.js'))
  .reduce((sum, f) => sum + gzipSync(readFileSync(join(dist, f))).length, 0)
const totalKB = Math.round(total / 1024)
console.log(`首屏 JS gzip 合计:${totalKB}KB / 预算 ${BUDGET_KB}KB`)
if (totalKB > BUDGET_KB) {
  console.error('❌ 超出性能预算,禁止合并')
  process.exit(1)
}
console.log('✅ 性能预算通过')
// package.json scripts
{
  "scripts": {
    "build": "tsc -b && vite build",
    "check:budget": "tsx scripts/check-budget.ts && npx lhci autorun"
  }
}

Lighthouse CI(@lhci/cli)负责跑分与断言,budget.json 是它的预算格式;本地无头环境跑分有波动,CI 里固定机器规格与网络节流后数据才可比。

14.7 骨架屏、HTTP 缓存与压缩

骨架屏替代 spinner 的价值:提前给出布局形状,降低用户感知等待,且不引发布局跳变。路由级骨架(14.2 的 AppSkeleton)与组件级骨架可以复用同一套原语:

export function Skeleton({ width = '100%', height = 16 }: { width?: string; height?: number }) {
  return <div className="skeleton" style={{ width, height }} aria-hidden />
}

export function OrderListSkeleton() {
  return (
    <div role="status" aria-label="订单列表加载中">
      <Skeleton height={32} />
      {Array.from({ length: 8 }, (_, i) => (
        <Skeleton key={i} height={48} />
      ))}
    </div>
  )
}

HTTP 缓存策略一句话:带 hash 的资源永久缓存,HTML 永不缓存。Vite 产物自带内容 hash(如 OrderDetailView-Cj8x2b.js),服务器配置见第 18 章 Nginx 小节。

压缩:文本资源开 gzip(兼容性广)或 brotli(压缩率再高 15–20%,需 HTTPS)。Vite 侧可用 vite-plugin-compression 预生成 .gz/.br,或交给 Nginx 动态压缩(第 18 章配置)。

14.8 企业案例:门户 LCP 4s → 1.8s 优化复盘

第 06 章交付的门户首页,在中端安卓机 + Slow 4G 实验室环境下 LCP 是 4.1s,距离附录A 的 2.0s 目标差一倍。Lighthouse 定位的瀑布图显示三个大头:一张 2.4MB 的 Hero JPG、1.1MB 的单包 JS(含报表页的图表库)、阻塞渲染的字体。

# 措施 关键改动 LCP 收益
1 Hero 图治理 2.4MB JPG → AVIF(−85% 体积)+ srcset/sizes + preload + 显式宽高 −1.2s
2 路由代码分割 React.lazy 拆报表/订单/详情;首包 JS gzip 1.1MB → 286KB −0.8s
3 图表库延迟加载 报表页内 lazy(() => import('echarts')),门户首包零图表 (含在 #2)
4 字体加载策略 font-display: swap + woff2 子集化,消除文字阻塞 −0.3s
5 三方脚本延迟 客服组件 defer + 用户交互后再加载 −0.1s
6 缓存与压缩 资源 immutable 长缓存 + brotli 复访 LCP −0.6s

最终实验室 LCP 1.8s(目标 ≤ 2.0s ✅)、首屏 JS gzip 286KB(≤ 300KB ✅)、CLS 0.03。复盘沉淀两条规矩:① 所有性能改动必须附 Lighthouse 前后对比,无数据不合并;② 预算进 CI(14.6),回归自动拦截。完整治理记录存 docs/perf/portal-lcp-2026Q3.md。

🛠 渐进式实战练习

练习 1 · 基础(10–20 min)

任务描述:给第 06 章门户项目的路由做代码分割:把 HomeView 之外的所有路由视图改为 React.lazy,配置好就近的 <Suspense> 边界,然后运行 pnpm build 观察 dist/assets 里的 chunk 划分。

提示:lazy() 包的是「默认导出」组件;先跑一次 pnpm build 记录 chunk 数量,改完再跑一次对比。检查 dist/index.html 是否变瘦。

验收标准:构建产物中出现每个路由视图的独立 chunk;pnpm dev 下打开 Network 面板,访问 /reports 时才请求报表页 chunk;首屏 HTML 不再引用非首屏 JS。

参考答案
const HomeView = lazy(() => import('@/views/HomeView'))
const OrderListView = lazy(() => import('@/views/orders/OrderListView'))
const ReportView = lazy(() => import('@/views/reports/ReportView'))

;<Suspense fallback={<AppSkeleton />}>
  <Routes>
    <Route element={<AppLayout />}>
      <Route index element={<HomeView />} />
      <Route path="orders" element={<OrderListView />} />
      <Route path="reports" element={<ReportView />} />
    </Route>
  </Routes>
</Suspense>

练习 2 · 进阶(30–60 min)

任务描述:优化门户 LCP 元素(Hero 图):换成 AVIF/WebP + srcset/sizes,加显式宽高与 preload,并在 main.tsx 接入 web-vitals 采集。用 Lighthouse 对比优化前后 LCP 与 CLS,把数据记进 docs/perf/。

提示:LCP 图不要 loading="lazy";fetchpriority="high" 只给 LCP 图;sizes 按布局断点写。Lighthouse 用「移动端 + Slow 4G + 禁用缓存」跑,避免本地缓存干扰。

验收标准:优化后 Lighthouse 移动端 LCP 相比基线下降 ≥ 30%;CLS ≤ 0.1;控制台能看到四项 vitals 上报日志。

参考答案(关键片段)
<!-- index.html -->
<link rel="preload" as="image" href="/images/hero-1280.avif" fetchpriority="high" />
// Hero.tsx —— 与 14.3 相同的四件套写法
;<img
  srcSet="/images/hero-480.avif 480w, /images/hero-1280.avif 1280w"
  src="/images/hero-1280.jpg"
  sizes="(max-width: 768px) 100vw, 50vw"
  width={1280} height={720}
  decoding="async"
  alt="轻订单 —— 让 B2B 订单不再漏单"
/>
// main.tsx 中
import { initVitals } from '@/monitor/vitals'
initVitals()

练习 3 · 挑战(选做,60+ min)

任务描述:把性能预算接入 CI:配置 Lighthouse CI 断言(LCP ≤ 2000ms、CLS ≤ 100、script 资源 ≤ 300KB)+ 14.6 的 gzip 体积检查脚本,接到第 18 章将要搭建的 GitHub Actions 里(本章先本地跑通 npx lhci autorun)。

提示:lhci autorun 需要 lighthouserc.json 声明 collect.staticDistDir 与 assert.assertions;本地跑分波动大,断言阈值建议比预算放宽 10% 容忍抖动,严格线留给 CI 固定环境。

验收标准:故意把一个大依赖(如 echarts)引回首屏入口,lhci 报红且退出码非 0;恢复后通过;两次运行的报告 JSON 存档对比。

参考答案(lighthouserc.json)
{
  "ci": {
    "collect": { "staticDistDir": "./dist", "numberOfRuns": 3 },
    "assert": {
      "assertions": {
        "largest-contentful-paint": ["error", { "maxNumericValue": 2200 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
        "resource-summary:script:size": ["error", { "maxNumericValue": 330000 }]
      }
    }
  }
}
pnpm build && npx lhci autorun

⚠️ 常见易错点

易错点 1:Suspense 边界设置不当,整页白屏/闪烁

// ❌ 边界包住整个应用:任何懒加载块在加载中,侧边栏顶栏一起消失
//    切路由时整页骨架闪烁,体验比 spinner 还差;lazy 抛错时也没有就近边界可兜
;<Suspense fallback={<AppSkeleton />}>
  <BrowserRouter>
    <AppLayout />
  </BrowserRouter>
</Suspense>
// ✅ 边界放在「会悬挂的最小子树」:布局常驻,只有内容区出骨架
export function AppLayout() {
  return (
    <div className="app-shell">
      <SideMenu />
      <main>
        <Suspense fallback={<AppSkeleton />}>
          <Outlet />
        </Suspense>
      </main>
    </div>
  )
}

配合第 18 章的 ErrorBoundary 一起用:Suspense 管「等」,ErrorBoundary 管「挂」。

易错点 2:图片不给宽高,页面加载时疯狂跳动(CLS 爆表)

// ❌ 浏览器不知道图多大:图片加载前后布局反复跳动,CLS 0.25
;<img src="/images/case-3.webp" alt="客户案例" />
// ✅ 显式宽高 + CSS 约束比例,加载前就占好位
;<img src="/images/case-3.webp" width={640} height={360}
      style={{ width: '100%', height: 'auto' }} alt="客户案例" />

现象是「读着读着文字突然被顶下去」——这在 Lighthouse 里直接扣分,在真实用户那里直接掉转化。

易错点 3:把 prefetch 当 preload 滥用,首屏被「预取」拖垮

// ❌ 把所有路由 chunk 都 preload:和不做代码分割一样,还多了排队开销
document.head.insertAdjacentHTML('beforeend',
  '<link rel="preload" as="script" href="/assets/ReportView-xxx.js">')
// ✅ preload 只给本页必用资源;下页资源交给 prefetch / 悬停预取,空闲时低优拉
;<link rel="prefetch" href="/assets/ReportView-xxx.js" as="script" />

区分口诀:preload 是「这一页就要」,prefetch 是「下一页可能要」。方向搞反,预加载就是预浪费。

易错点 4:给 LCP 图加 loading="lazy",主指标自己优化死自己

// ❌ Hero 图懒加载:浏览器要等布局确定才加载,LCP 被自己推后几百毫秒
;<img src="/images/hero-1280.avif" loading="lazy" alt="主视觉" />
// ✅ LCP 图立即加载并提权;loading="lazy" 只用于视口外的列表图/配图
;<img src="/images/hero-1280.avif" fetchpriority="high" decoding="async"
      width={1280} height={720} alt="主视觉" />

优化前先弄清「当前页的 LCP 元素是谁」(Lighthouse 报告会标出来),针对它做减法,别的都要让路。

易错点 5:性能埋点用普通 XHR/fetch 上报,页面卸载时数据全丢

// ❌ 用户关页面瞬间,正在飞的 fetch 被取消——最关键的 LCP 恰恰在那时才完成采集
fetch('/api/v1/metrics/vitals', { method: 'POST', body: JSON.stringify(metric) })
// ✅ sendBeacon 专为「页面要关了也要送到」设计,浏览器保证排队发送
navigator.sendBeacon('/api/v1/metrics/vitals', JSON.stringify(metric))

监控数据的丢失规律很讽刺:丢的永远是最慢、最需要优化的那次访问。

✅ 最佳实践

  1. 先基线后优化:Lighthouse(实验室)+ web-vitals(真实用户)双轨测量,任何优化改动都要附前后数据。
  2. 代码分割按「路由 > 重型组件」两级做:路由级用 React.lazy;图表、编辑器等重型库在组件内部再 lazy 一层。
  3. 首屏三件套纪律:LCP 图 preload + 提权、关键字体 swap、首包 gzip ≤ 300KB,三项写进团队验收标准。
  4. 图片四件套默认值:现代格式、显式宽高、sizes 响应式、视口外 loading="lazy"——新组件不遵守不合并。
  5. 预加载跟着用户动线走:数据驱动地挑「高概率下一跳」做 prefetch,宁缺毋滥。
  6. 缓存策略一刀切:带 hash 资源 immutable 一年,index.html 用 no-cache,发版不靠玄学。
  7. 预算进 CI:LCP / CLS / 包体积超线即红,性能回归和功能 bug 同级对待。
  8. 骨架屏就近放置:Suspense fallback 表达「这块在加载」,而不是整页转圈;骨架形状要和真实内容一致,否则骨架本身在制造 CLS。

📌 本章小结

  • 首屏时间 = 传输字节 + 请求数 + 执行时间 + 渲染路径,先测量定位大头再动手。
  • React.lazy + <Suspense> 就近边界做路由级分割;重型库组件内再懒一层;布局壳和关键路径不懒。
  • 图片是大多数门户的第一座山:AVIF/WebP、srcset、显式宽高、LCP 图 preload 是默认动作。
  • preload(本页必用)与 prefetch(下页可能用)语义不能混;web-vitals 四指标 + Lighthouse 预算是防回归的闸门。
  • 门户 LCP 已从 4.1s 治理到 1.8s(目标 2.0s ✅),措施-收益表沉淀在 docs/perf/portal-lcp-2026Q3.md。

加载快了、跑得也顺了——但你敢在没有测试的情况下发版吗?下一章进入质量保障:用 Vitest + Testing Library 为核心模块(订单金额计算、权限判断)建立覆盖率 ≥ 80% 的单元测试防线。

📄 第 15 章 · 组件测试 15-组件测试.md

📍 阶段:高级阶段(第 13–18 章)|⏱ 预计学时:6–8 小时|🔗 前置:第 10 章表单 + 第 12 章实战 + 第 17 章 §17.1(TS 基线,可后置)

⬅ 上一章:加载性能优化 | 📖 本版目录 | ➡ 下一章:E2E 测试与质量保障

💡 对照学习:本页是 React 版第 15 章,对应 Vue 版第 15 章 · 组件测试。概念映射:Testing Library ≈ Vue Test Utils + @testing-library/vue、renderHook ≈ 测组合式函数的 withSetup 套路、Vitest / vi.mock / msw 两版完全同款。理念差异一句话:Vue Test Utils 允许你大量探组件实例内部(wrapper.vm),Testing Library 则拒绝访问内部实现,只认「用户看得见、摸得着」的 DOM——这也是本章反复强调的用户中心理念。

🎯 学习目标

学完本章,你将能够:

  1. 画出前端测试金字塔(单元/集成/E2E),按「投入产出比」说出哪类代码值得测、哪类不值得;
  2. 配置 Vitest 工程:jsdom 环境、setup 文件、路径别名、覆盖率阈值 ≥ 80%(附录A 目标);
  3. 为纯函数写单元测试,覆盖金额计算的浮点陷阱与边界值(数量 1–9999、折扣 0–100%);
  4. 用 renderHook + fake timers 测试自定义 Hook(防抖、请求竞态);
  5. 用 Testing Library 以「用户视角」写组件测试:getByRole 定位 + userEvent 交互;
  6. 分清 vi.mock 与 msw 的分工,用 msw 隔离网络层并测出错误分支与竞态;
  7. 克制地使用快照测试,说清何时快照是负资产;
  8. 把「轻订单」核心模块(订单金额计算、权限判断、工具函数)的覆盖率提至 ≥ 80% 并接入覆盖率门禁。

📖 核心知识点

15.1 测试金字塔与投入策略

企业前端的测试投入应该长这样(越往上单条越贵、越慢、越少):

        ╱  E2E 少量   ╲   关键链路冒烟(第 16 章):登录→下单→审批→导出
       ╱  集成 中量     ╲  组件 + 交互 + 数据流:OrderCard、订单表单
      ╱  单元 大量       ╲ 纯函数与 Hook:金额计算、权限判断、formatAmount

值得测的(出 bug 贵、逻辑可枚举):金额计算、权限判断、状态机流转、表单校验规则、工具函数。 不值得测的:纯样式类名、第三方库内部、useState 本身、一次性营销页。

「轻订单」附录A 的覆盖率目标只锁三个核心模块——订单金额计算、权限判断、工具函数 ≥ 80%。这是真实企业的做法:全局覆盖率是虚荣指标,核心模块覆盖率才是质量底线。

15.2 Vitest 配置:与 Vite 同源,零割裂

Vitest 直接复用项目的 Vite 配置(别名、插件全通),这是它在 Vite 项目里胜出的原因:

pnpm add -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom @vitest/coverage-v8
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import { fileURLToPath } from 'node:url'

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
  },
  test: {
    environment: 'jsdom',              // 组件测试需要 DOM
    globals: true,                     // describe/it 可免导入直接用(仍推荐显式导入)
    setupFiles: ['./src/test/setup.ts'],
    css: false,                        // 不解析 CSS,加速
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html', 'lcov'],
      include: ['src/**'],
      // 入口文件与类型声明不参与统计,避免分母注水
      exclude: ['src/main.tsx', 'src/**/*.d.ts', 'src/test/**', 'src/**/index.ts'],
      // 附录A:核心模块覆盖率 ≥ 80%,直接做成门禁
      thresholds: { lines: 80, functions: 80, branches: 80, statements: 80 },
    },
  },
})
// src/test/setup.ts —— 每个测试文件运行前的公共准备
import '@testing-library/jest-dom/vitest' // toBeInTheDocument 等断言
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'

afterEach(() => {
  cleanup() // 卸载所有已渲染组件,测试间互不污染
})
// package.json scripts
{
  "scripts": {
    "test": "vitest",
    "test:ci": "vitest run --coverage"
  }
}

15.3 纯函数测试:从金额计算开始(浮点陷阱)

src/utils/money.ts 是「轻订单」的钱袋子——明细金额 = 数量 × 单价 × 行折扣,全程用分(附录A §5):

// src/utils/money.ts
import type { OrderItem } from '@/types/order'

/** 计算单条明细行金额(单位:分)。行折扣 0–100,100 表示无折扣。 */
export function calcItemAmount(item: Pick<OrderItem, 'quantity' | 'unitPrice' | 'discountPercent'>): number {
  return Math.round((item.quantity * item.unitPrice * item.discountPercent) / 100)
}

/** 订单总额 = 明细行金额之和(单位:分) */
export function calcOrderTotal(items: Array<Pick<OrderItem, 'quantity' | 'unitPrice' | 'discountPercent'>>): number {
  return items.reduce((sum, item) => sum + calcItemAmount(item), 0)
}

/** 分 → 「¥1,234.56」展示字符串 */
export function formatAmount(fen: number): string {
  const yuan = (fen / 100).toFixed(2)
  return `¥${yuan.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}`
}
// src/utils/money.test.ts
import { describe, expect, it } from 'vitest'
import { calcItemAmount, calcOrderTotal, formatAmount } from './money'

describe('calcItemAmount(明细金额,单位分)', () => {
  it('常规计算:2 件 × 1250 分 × 80% 折扣 = 2000 分', () => {
    expect(calcItemAmount({ quantity: 2, unitPrice: 1250, discountPercent: 80 })).toBe(2000)
  })

  it('边界:数量最小 1、最大 9999(附录A 校验范围)', () => {
    expect(calcItemAmount({ quantity: 1, unitPrice: 100, discountPercent: 100 })).toBe(100)
    expect(calcItemAmount({ quantity: 9999, unitPrice: 100, discountPercent: 100 })).toBe(999900)
  })

  it('浮点陷阱:0.1 元 + 0.2 元 = 0.3 元,绝不能出 0.30000000000000004', () => {
    // 10 分 + 20 分 = 30 分;若在「元」域直接加浮点数就会翻车
    expect(calcOrderTotal([
      { quantity: 1, unitPrice: 10, discountPercent: 100 },
      { quantity: 1, unitPrice: 20, discountPercent: 100 },
    ])).toBe(30)
  })

  it('四舍五入:1 件 × 999 分 × 85% = 849.15 → 849 分(金额必须为整数分)', () => {
    expect(calcItemAmount({ quantity: 1, unitPrice: 999, discountPercent: 85 })).toBe(849)
  })
})

describe('formatAmount(分 → 展示)', () => {
  it('千分位与两位小数', () => {
    expect(formatAmount(1234567)).toBe('¥12,345.67')
  })
  it('0 分显示 ¥0.00 而不是空值', () => {
    expect(formatAmount(0)).toBe('¥0.00')
  })
})

纯函数测试的模板:输入 → 期望输出,重点喂边界值和历史上翻过车的用例(浮点求和就是「轻订单」真实翻过的车,见附录A §5 提示)。

15.4 Hook 测试:renderHook 与 fake timers

自定义 Hook 是逻辑复用的载体,renderHook(Testing Library 提供)让它在测试里「挂在隐形组件上」运行。以第 09 章的防抖 Hook 为例:

// src/hooks/useDebouncedValue.ts —— 被测对象
import { useEffect, useState } from 'react'

export function useDebouncedValue<T>(value: T, delay = 300): T {
  const [debounced, setDebounced] = useState(value)
  useEffect(() => {
    const timer = window.setTimeout(() => setDebounced(value), delay)
    return () => window.clearTimeout(timer)
  }, [value, delay])
  return debounced
}
// src/hooks/useDebouncedValue.test.ts
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useDebouncedValue } from './useDebouncedValue'

beforeEach(() => vi.useFakeTimers())   // 用假时钟,测试瞬时完成
afterEach(() => vi.useRealTimers())

describe('useDebouncedValue', () => {
  it('300ms 内连续输入只有最后一次生效', async () => {
    const { result, rerender } = renderHook(({ v }) => useDebouncedValue(v, 300), {
      initialProps: { v: '' },
    })

    rerender({ v: '华' })
    act(() => vi.advanceTimersByTime(100))
    rerender({ v: '华东' })
    act(() => vi.advanceTimersByTime(200)) // 距上次变更仅 200ms,未到 300ms

    expect(result.current).toBe('')        // 仍在防抖窗口内

    act(() => vi.advanceTimersByTime(300))
    expect(result.current).toBe('华东')     // 防抖到期,取最后值
  })
})

要点:fake timers 让 300ms 的等待在 0ms 内完成;rerender 模拟 props 变化;result.current 永远读最新返回值。

15.5 组件测试:Testing Library 的用户中心理念

Testing Library 的核心信条只有一句:测试应该像用户一样使用组件——用户不关心你的 useState 叫什么,用户只关心「我看到什么、点什么、结果是什么」。推论:

  1. 查询用 getByRole 优先(它走可访问性语义,顺带逼你写好 a11y),getByLabelText 次之,getByText 谨慎用;
  2. 交互用 userEvent(模拟完整事件序列:焦点、按键、输入),而不是 fireEvent(只派发单个事件);
  3. 禁止通过 props 或实例内部状态断言,只看渲染结果。

被测组件(第 03/12 章的 OrderCard 与 StatusTag):

// src/components/StatusTag.tsx
import { Tag } from 'antd'
import { ORDER_STATUS } from '@/constants/orderStatus'
import type { OrderStatus } from '@/types/order'

export function StatusTag({ status }: { status: OrderStatus }) {
  const meta = ORDER_STATUS[status]
  return <Tag color={meta.color}>{meta.label}</Tag>
}
// src/components/OrderCard.tsx
import { memo } from 'react'
import { formatAmount } from '@/utils/money'
import { StatusTag } from './StatusTag'
import type { Order } from '@/types/order'

export interface OrderCardProps {
  order: Order
  onOpen: (id: string) => void
}

export const OrderCard = memo(function OrderCard({ order, onOpen }: OrderCardProps) {
  return (
    <article aria-label={`订单 ${order.orderNo}`}>
      <header>
        <span>{order.orderNo}</span>
        <StatusTag status={order.status} />
      </header>
      <p>{order.customerName} · {formatAmount(order.totalAmount)}</p>
      <button type="button" onClick={() => onOpen(order.id)}>查看详情</button>
    </article>
  )
})
// src/components/OrderCard.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { OrderCard } from './OrderCard'
import { makeOrder } from '@/test/factories'

describe('OrderCard', () => {
  it('渲染订单号、客户名与格式化金额', () => {
    const order = makeOrder({ orderNo: 'QO20260929000001', customerName: '青云硬件华东代理', totalAmount: 1234567 })
    render(<OrderCard order={order} onOpen={vi.fn()} />)

    expect(screen.getByRole('article', { name: '订单 QO20260929000001' })).toBeVisible()
    expect(screen.getByText('青云硬件华东代理')).toBeInTheDocument()
    expect(screen.getByText('¥12,345.67')).toBeInTheDocument()          // 分 → 元展示正确
    expect(screen.getByText('待审核')).toBeInTheDocument()               // StatusTag 文案
  })

  it('点击「查看详情」时以订单 id 触发 onOpen', async () => {
    const user = userEvent.setup()
    const onOpen = vi.fn()
    const order = makeOrder({ id: 'ord_001' })
    render(<OrderCard order={order} onOpen={onOpen} />)

    await user.click(screen.getByRole('button', { name: '查看详情' }))

    expect(onOpen).toHaveBeenCalledExactlyOnceWith('ord_001')
  })
})
// src/test/factories.ts —— 测试数据工厂:默认合法 + 按需覆盖,避免每个用例手搓 20 个字段
import type { Order } from '@/types/order'
export function makeOrder(overrides: Partial<Order> = {}): Order {
  return {
    id: 'ord_test_1',
    orderNo: 'QO20260929000001',
    customerId: 'cus_001',
    customerName: '青云科技测试客户',
    ownerName: '测试销售',
    status: 'pending_review',
    items: [{ skuId: 'sku_001', skuName: '智能网关 X1', quantity: 2, unitPrice: 1250, discountPercent: 100 }],
    wholeDiscountPercent: 100,
    totalAmount: 2500,
    createdAt: '2026-09-29T10:00:00Z',
    ...overrides,
  }
}

上面工厂里的 @/types/order 即附录A §5 的 Order 实体——两版教程共用同一数据形状,测试用例可以直接逐行对照 Vue 版。

15.6 异步与 Mock:vi.mock 与 msw 的分工

两者解决的问题不同,不是二选一:

工具 mock 什么 适用场景
vi.mock 模块(import 的函数/组件) 隔离副作用依赖:跳转、埋点、第三方 SDK
msw 网络层(真实 HTTP 语义) 测数据流:加载态、成功、错误、竞态

msw(Mock Service Worker)在 jsdom 里拦截 fetch,请求路径、参数、状态码都是真的:

// src/test/handlers.ts
import { http, HttpResponse } from 'msw'

export const handlers = [
  http.get('/api/v1/orders', ({ request }) => {
    const keyword = new URL(request.url).searchParams.get('keyword') ?? ''
    return HttpResponse.json({
      code: 0, message: 'ok',
      data: { list: [{ id: 'ord_1', orderNo: 'QO20260929000001', customerName: `客户-${keyword}` }],
              total: 1, page: 1, pageSize: 20 },
    })
  }),
]
// src/test/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)
// src/hooks/useOrderList.test.ts —— 测「订单列表 Hook」的错误分支与竞态
import { renderHook, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { http, HttpResponse } from 'msw'
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'
import { server } from '@/test/server'
import { useOrderList } from './useOrderList'
import type { ReactNode } from 'react'

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers()) // 每个用例后恢复默认 handlers
afterAll(() => server.close())

function createWrapper() {
  const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
  return ({ children }: { children: ReactNode }) => (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  )
}

describe('useOrderList', () => {
  it('接口失败时暴露 error 而不是静默白屏', async () => {
    server.use(http.get('/api/v1/orders', () =>
      HttpResponse.json({ code: 50001, message: '服务开小差了' }, { status: 500 })))

    const { result } = renderHook(() => useOrderList({ keyword: '', page: 1 }), { wrapper: createWrapper() })
    await waitFor(() => expect(result.current.isError).toBe(true))
  })

  it('快速连续翻页时以最后一次请求为准(竞态)', async () => {
    const { result, rerender } = renderHook(({ page }) => useOrderList({ keyword: '', page }), {
      initialProps: { page: 1 }, wrapper: createWrapper(),
    })
    rerender({ page: 2 })
    await waitFor(() => expect(result.current.data?.page).toBe(2))
  })
})

vi.mock 的典型用法(隔离副作用模块):

// 路由跳转不便在单测中发生,mock 掉 react-router 的导航
import { vi } from 'vitest'
vi.mock('react-router', async (importOriginal) => ({
  ...(await importOriginal<typeof import('react-router')>()), // 保留其余导出
  useNavigate: () => vi.fn(), // 只有 useNavigate 换成空函数
}))

15.7 快照测试的克制使用与可维护的测试结构

快照(toMatchSnapshot / toEqual)是「把当前输出整个存下来,以后对比」。它只适合:纯展示型、结构稳定的小组件(如 StatusTag 的 7 个状态文案表)。它不适合:任何包含时间戳、随机 id、频繁迭代 UI 的组件——你会养成「红了就 -u 更新快照」的坏习惯,测试从此形同虚设。

可维护的测试结构遵循 AAA(Arrange-Act-Assert)+ 工厂函数 + 每个 it 只验证一件事:

it('折扣超过 10% 时展示「需财务审批」提示', async () => {
  // Arrange:工厂造数 + 渲染
  const order = makeOrder({ wholeDiscountPercent: 88 })
  render(<OrderCard order={order} onOpen={vi.fn()} />)
  // Act:(交互类用例在这里 user.click(...))
  // Assert:只断言这一件事
  expect(screen.getByText('需财务审批')).toBeInTheDocument()
})

15.8 覆盖率与门禁

pnpm test:ci
File            % Stmts  % Branch  % Funcs  % Lines
utils/money.ts     100       90      100      100   ← 核心,必须满分或接近
utils/auth.ts       85       78       88       85   ← 权限判断
hooks/useOrderList  82       75       80       82
All files           84       79       83       84   ✅ ≥ 80 门禁通过

三条解读纪律:① 看分支覆盖率(% Branch)比行覆盖率更诚实,if 没走到 false 分支行数照样算「覆盖」;② 覆盖率 80% 是底线不是目标——为凑数给 getter 写断言是负资产;③ 修 bug 前先写一个「能复现 bug 的失败测试」,修完转绿,测试即回归保险。

🛠 渐进式实战练习

练习 1 · 基础(10–20 min)

任务描述:为本章 money.ts 补一个 calcOrderTotal 的测试组:空明细返回 0、多明细求和正确、单条折扣 0% 时金额为 0。然后故意把 calcItemAmount 改错(去掉 Math.round),验证测试能拦住。

提示:折扣 0% 是附录A 允许的边界(0–100);「改错验证测试会红」是检验测试有效性的经典手法(变异测试的思想)。

验收标准:pnpm test 全绿;临时注入的 bug 使测试变红,还原后恢复绿。

参考答案
describe('calcOrderTotal(订单总额)', () => {
  it('空明细返回 0 分', () => {
    expect(calcOrderTotal([])).toBe(0)
  })
  it('多明细求和:各行金额相加', () => {
    expect(calcOrderTotal([
      { quantity: 2, unitPrice: 1250, discountPercent: 80 },
      { quantity: 1, unitPrice: 500,  discountPercent: 100 },
    ])).toBe(2500)
  })
  it('行折扣 0% 时该行金额为 0(边界合法性)', () => {
    expect(calcItemAmount({ quantity: 5, unitPrice: 1000, discountPercent: 0 })).toBe(0)
  })
})

练习 2 · 进阶(30–60 min)

任务描述:为第 10 章的「新建订单表单」写组件测试:① 必填项(客户、明细行)为空时提交展示校验错误;② 数量填 0 或 10000 时展示「数量 1–9999」错误;③ 全部合法时提交按钮触发 onSubmit 且载荷里金额汇总正确。

提示:React Hook Form + Zod 的校验是异步的,断言错误文案要 await user.click(...) 后用 findByText;提交载荷用 vi.fn() 接住后检查 calls[0][0]。

验收标准:三个用例覆盖「阻塞提交 + 错误文案 + 成功载荷」三条路径;不访问任何组件内部状态,断言全部面向用户可见文本。

参考答案(核心用例)
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { OrderForm } from './OrderForm'

async function fillValidForm(user: ReturnType<typeof userEvent.setup>) {
  await user.type(screen.getByLabelText('客户'), '青云华东代理')
  await user.type(screen.getByLabelText('商品'), '智能网关 X1')
  await user.type(screen.getByLabelText('数量'), '2')
}

describe('OrderForm(新建订单)', () => {
  it('数量 0 触发「数量 1–9999」校验且不提交', async () => {
    const user = userEvent.setup()
    const onSubmit = vi.fn()
    render(<OrderForm onSubmit={onSubmit} />)

    await fillValidForm(user)
    const qty = screen.getByLabelText('数量')
    await user.clear(qty)
    await user.type(qty, '0')
    await user.click(screen.getByRole('button', { name: '提交订单' }))

    expect(await screen.findByText('数量 1–9999')).toBeInTheDocument()
    expect(onSubmit).not.toHaveBeenCalled()
  })

  it('合法表单提交时载荷金额汇总正确(2 × 1250 分)', async () => {
    const user = userEvent.setup()
    const onSubmit = vi.fn()
    render(<OrderForm onSubmit={onSubmit} />)

    await fillValidForm(user)
    await user.click(screen.getByRole('button', { name: '提交订单' }))

    expect(onSubmit).toHaveBeenCalledExactlyOnceWith(
      expect.objectContaining({ items: [expect.objectContaining({ quantity: 2, unitPrice: 1250 })] }),
    )
  })
})

练习 3 · 挑战(选做,60+ min)

任务描述:为权限判断模块建立测试防线:hasPermission(role, action)(附录A §2:折扣审批仅 finance、删除订单仅 super_admin/operator、销售仅本人订单)。要求:全部角色 × 全部动作的决策表测试 + usePermission Hook 的组件级测试(<Auth need="order:review"> 的可见性)+ 把核心模块覆盖率门禁设为 80% 并让 test:ci 真实拦截。

提示:决策表用 it.each 生成最紧凑;Hook 测试用 renderHook;门禁即 15.2 配置里的 thresholds——先故意写一个低覆盖模块让 CI 红,再补齐。

验收标准:it.each 决策表 ≥ 20 行覆盖四角色三动作;<Auth> 在「有权限渲染子节点 / 无权限渲染 fallback 或 null」两个分支都有断言;vitest run --coverage 退出码能真实反映门禁结果。

参考答案(决策表骨架)
import { describe, expect, it } from 'vitest'
import { hasPermission } from './auth'

const cases: Array<[role: string, action: string, expected: boolean]> = [
  // [角色, 动作, 期望] —— 附录A §2 三级权限模型
  ['sales', 'order:review', false],      // 折扣审批仅 finance
  ['finance', 'order:review', true],
  ['operator', 'order:review', false],
  ['super_admin', 'order:review', true],
  ['sales', 'order:delete', false],      // 删除仅超管/运营
  ['operator', 'order:delete', true],
  ['super_admin', 'order:delete', true],
  ['finance', 'order:delete', false],
  ['sales', 'order:create', true],       // 销售可建单(仅本人名下,归属由后端校验)
  ['finance', 'order:create', false],
  ['finance', 'report:export', true],    // 导出报表仅财务
  ['operator', 'report:export', false],
  // ……继续补齐至四角色 × 全动作,见练习要求
]

describe('hasPermission 决策表', () => {
  it.each(cases)('%s 执行 %s 应为 %s', (role, action, expected) => {
    expect(hasPermission(role, action)).toBe(expected)
  })
})

⚠️ 常见易错点

易错点 1:getByText 脆弱断言,文案一改测试全崩

// ❌ 文案是设计最常改的东西;「提交」两个字还可能同时匹配按钮和提示语(multiple elements 报错)
expect(screen.getByText('提交')).toBeInTheDocument()
// ✅ 按「角色 + 可访问名称」定位,稳定且顺带保障 a11y;真有歧义再用 data-testid 兜底
expect(screen.getByRole('button', { name: '提交订单' })).toBeInTheDocument()

现象:改个错别字,二十个测试文件一起红。定位器选择的优先级记住口诀:Role > Label > Text > TestId。

易错点 2:异步更新没等,断言在「过去」执行

// ❌ 点击后校验/请求是异步的,同步的 getBy 在 DOM 更新前就查了 → 偶发红
user.click(screen.getByRole('button', { name: '提交订单' }))
expect(screen.getByText('请选择客户')).toBeInTheDocument()
// ✅ userEvent 加 await;异步出现的元素用 findBy* 或 waitFor
await user.click(screen.getByRole('button', { name: '提交订单' }))
expect(await screen.findByText('请选择客户')).toBeInTheDocument()

典型症状是「单跑绿、全跑红」或「CI 红、本地绿」——都是时序问题。

易错点 3:真时钟测防抖/超时,测试又慢又偶发失败

// ❌ 真等 300ms:网络抖一下 CI 就红;一个套件里 50 个防抖用例就是 15 秒
await new Promise((r) => setTimeout(r, 350))
expect(result.current).toBe('华东')
// ✅ fake timers 手动推时钟:零等待、零抖动、还能精确测「窗口内未生效」
vi.useFakeTimers()
act(() => { vi.advanceTimersByTime(300) })
expect(result.current).toBe('华东')
// 用完记得 vi.useRealTimers()(放 afterEach),否则污染后续用例

易错点 4:mock 无限扩散,测试之间互相污染

// ❌ 在 describe 顶层 mock + 改实现,却没有恢复;下一个文件/用例拿到的是被污染的模块
vi.spyOn(console, 'error').mockImplementation(() => {})
// ……整个文件不再恢复,别的测试里 console.error 也静默了
// ✅ msw 用 resetHandlers,spy 用 mockRestore,fake timers 用 useRealTimers
//    统一收口在生命周期钩子里,谁污染谁恢复
afterEach(() => {
  server.resetHandlers()
  vi.restoreAllMocks()
})

判断标准:任何一个测试单独跑和和别人一起跑结果必须一致,否则就是泄漏。

易错点 5:快照里混入波动字段,快照永远在「更新」

// ❌ 快照包含时间戳/随机 id:每次跑都不同 → 习惯性 -u 更新 → 快照测试报废
const { container } = render(<OrderCard order={makeOrder()} />)
expect(container.firstChild).toMatchSnapshot() // makeOrder 里 new Date() / crypto.randomUUID()
// ✅ 工厂固定波动字段;或者干脆不用快照,改成对关键文本的行为断言
export function makeOrder(overrides: Partial<Order> = {}): Order {
  return { createdAt: '2026-09-29T10:00:00Z', id: 'ord_fixed_1', /* … */ ...overrides }
}

快照审批的铁律:diff 里出现日期/uuid,先修数据再修快照;如果一份快照三个月没被认真看过,删掉它。

✅ 最佳实践

  1. 测试即需求说明书:测试名写成句子(「折扣超过 10% 时展示需财务审批提示」),业务同学也能读懂。
  2. 核心模块先测:金额计算、权限判断、状态机这三个「出事就赔钱」的模块优先补测试并钉住 80% 门禁。
  3. 行为断言替代实现断言:永远问「用户会看到什么」,不问「state 变了吗」——这类测试在重构后依然全绿。
  4. msw 管 HTTP,vi.mock 管模块:网络交互用真实语义(路径/参数/状态码),副作用模块才 mock,不混用。
  5. 数据工厂替代手搓夹具:makeOrder() 默认合法 + 按需覆盖,新增字段不用改二十个测试文件。
  6. fake timers + restore 收口:所有「改变全局状态」的 mock(时钟、监听、spy)统一在 afterEach 恢复。
  7. 快照只给稳定展示组件:动态内容、频繁迭代 UI 一律行为断言。
  8. 红灯先于修复:修 bug 前先写复现测试,保证每个 bug 至少换回一条回归保险。

📌 本章小结

  • 测试金字塔:单元大量、集成中量、E2E 少量;核心模块(金额/权限/工具)≥ 80% 是附录A 的硬目标,已做成 Vitest 门禁。
  • Testing Library 用户中心三原则:Role 定位、userEvent 交互、只断言用户可见结果。
  • renderHook + fake timers 测 Hook 又快又稳;msw 在网络层给真实语义,vi.mock 只负责副作用模块。
  • 快照是双刃剑:只给稳定展示组件用,diff 出波动字段先修数据。
  • 「轻订单」money.ts、hasPermission、OrderCard、OrderForm 的测试防线已就位,pnpm test:ci 就是质量闸门。

单元与集成防线建好了,但「登录 → 下单 → 审批 → 导出」这条完整链路还没有任何自动化验证。下一章用 Playwright 把这条冒烟链路变成每天在 CI 里自动奔跑的 E2E 用例。

📄 第 16 章 · E2E 测试与质量保障 16-E2E测试与质量保障.md

📍 阶段:高级阶段(第 13–18 章)|⏱ 预计学时:6–8 小时|🔗 前置:第 12 章实战 + 第 15 章组件测试

⬅ 上一章:组件测试 | 📖 本版目录 | ➡ 下一章:工程化实践 · TypeScript 与 Vite

💡 对照学习:本页是 React 版第 16 章,对应 Vue 版第 16 章 · E2E 测试与质量保障。Playwright 与框架无关——配置、定位器、storageState、trace、flaky 治理、CI 接入两版逐字通用,练习任务也一致(附录A 冒烟集:登录 → 下单 → 审批 → 导出)。唯一差异是「被测应用」:React 版跑的是第 12 章的 React 实现,用例里的业务断言(状态机、权限、金额)与框架无关。

🎯 学习目标

学完本章,你将能够:

  1. 说明 E2E 在质量体系中的位置,按「冒烟集 vs 全量回归」两级制定 E2E 覆盖策略;
  2. 搭建 Playwright 工程:webServer 自动拉起应用、多浏览器 projects、产物目录规范;
  3. 按「用户可见性」选择定位器(getByRole/getByLabel 优先),避免脆弱的 CSS/XPath 选择器;
  4. 用 storageState 复用登录态,把每条用例的登录开销降到零,同时保留一条「真登录」链路验证;
  5. 用 API 造数准备测试数据并实现用例间隔离(唯一后缀 + 清理钩子);
  6. 开启 trace / 截图 / 视频,在 HTML 报告中还原失败现场,并说清 flaky 的四大来源与治理手段;
  7. 把 E2E 接入 CI:PR 跑冒烟集、主干跑全量,失败自动上传报告工件;
  8. 完成「轻订单」下单主流程 E2E:登录 → 下单 → 审批 → 导出全链路通过(附录A 冒烟集目标)。

📖 核心知识点

16.1 E2E 在质量体系中的位置

第 15 章的单元/集成测试验证「零件正确」,E2E 验证「整车能跑」——真实浏览器、真实路由、真实登录态、真实接口(或高保真 mock)。它的特点是贵(慢、环境依赖重、易 flaky),所以覆盖策略必须分层:

层级 范围 触发时机 「轻订单」对应
冒烟集(Smoke) 核心链路 5–10 条 每次 PR + 部署后 登录 → 下单 → 审批 → 导出(附录A §4)
全量回归 全部业务流,30–80 条 主干合入后/每日定时 订单 CRUD、权限矩阵、客户/商品管理

原则一句话:冒烟集答「敢不敢发版」,全量答「质量水位如何」。把 80 条全量塞进 PR 流水线是新人最常犯的错误——PR 要 40 分钟,最后大家学会绕过 CI。

16.2 安装与配置

pnpm add -D @playwright/test
pnpm exec playwright install chromium   # 按需装浏览器;CI 装系统依赖加 --with-deps
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'

const AUTH_FILE = 'playwright/.auth/operator.json'
const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'

export default defineConfig({
  testDir: './e2e',
  timeout: 30_000,
  expect: { timeout: 5_000 },
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,          // 只在 CI 重试,本地失败直接暴露
  workers: process.env.CI ? 2 : undefined,  // CI 并发收敛,减少环境抖动
  reporter: process.env.CI ? [['html', { open: 'never' }], ['github']] : [['html', { open: 'never' }]],

  use: {
    baseURL: BASE_URL,
    trace: 'on-first-retry',        // 第一次重试才录制 trace(失败现场还原)
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },

  projects: [
    // 项目 0:登录态准备,只跑 auth.setup.ts
    { name: 'setup', testMatch: /auth\.setup\.ts/ },
    // 项目 1+:复用登录态跑业务用例,依赖 setup 先完成
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'], storageState: AUTH_FILE },
      dependencies: ['setup'],
    },
    // 附录A 兼容目标:Chrome/Edge 最近两个大版本 —— 本地加 webkit/firefox 抽查即可
  ],

  // CI 里自动拉起被测应用;本地若已启动则直接复用
  webServer: {
    command: 'pnpm dev --port 5173',
    url: BASE_URL,
    reuseExistingServer: !process.env.CI,
  },
})

16.3 定位器策略:面向用户可见性

Playwright 的定位器哲学与第 15 章 Testing Library 完全同源:像用户一样找元素。优先级:getByRole > getByLabel > getByPlaceholder/getByText > getByTestId,CSS/XPath 只做最后手段。

// ✅ 推荐写法:语义定位 + 用户可读的名称
await page.getByRole('link', { name: '订单管理' }).click()
await page.getByLabel('客户名称').fill('青云华东代理')
await page.getByRole('button', { name: '提交订单' }).click()
await expect(page.getByRole('cell', { name: 'QO20260929000001' })).toBeVisible()

// 无原生 role 可用的自定义组件,才用 data-testid 兜底(写进团队规范)
await page.getByTestId('order-status-filter').click()

三个内建机制让断言更稳:自动等待(定位器在动作前自动等元素可见可用,不需要手写 sleep)、严格模式(定位器匹配到多个元素直接报错,逼你写精确)、Web 优先断言(expect(locator).toBeVisible() 会自动重试到超时)。

16.4 登录态复用:storageState

每条用例都走一遍登录页又慢又增加失败面。标准做法是「setup 项目登录一次 → 存 storageState → 业务项目全量复用」:

// e2e/auth.setup.ts
import { expect, test as setup } from '@playwright/test'

const authFile = 'playwright/.auth/operator.json'

setup('以运营角色登录', async ({ page }) => {
  await page.goto('/login')
  await page.getByLabel('邮箱').fill('operator@qingorder.example.com') // 教学虚构账号
  await page.getByLabel('密码').fill('demo-password-123')
  await page.getByRole('button', { name: '登录' }).click()
  await expect(page).toHaveURL(/\/dashboard/)   // 确认真的登录成功才落盘
  await page.context().storageState({ path: authFile })
})
# .gitignore —— 登录态是环境产物,不入库
playwright/.auth/

角色不止一个时按角色建多个 setup 项目(operator.json / finance.json),业务用例用 test.use({ storageState: 'playwright/.auth/finance.json' }) 切换。注意:冒烟集里要保留一条从登录页开始的真实登录用例——storageState 复用得再好,也不能让登录功能本身失去 E2E 验证。

16.5 测试数据准备与隔离

E2E 最隐蔽的坑是用例间共享数据:并行 worker 同时操作「同一张测试订单」,结果互相踩。纪律两条:

  1. 用例数据自造自用:通过 API 造数(request fixture 直接调后端),数据带唯一后缀,不用别人造的数;
  2. afterEach 清理:造了什么删什么,数据库不随用例数量无限膨胀。
// e2e/helpers/api.ts —— API 造数封装:走真实接口,语义与前端一致
import type { APIRequestContext, Page } from '@playwright/test'
import { expect } from '@playwright/test'

export async function login(request: APIRequestContext): Promise<string> {
  const res = await request.post('/api/v1/auth/login', {
    data: { email: 'operator@qingorder.example.com', password: 'demo-password-123' },
  })
  expect(res.ok()).toBeTruthy()
  const body = await res.json()
  return body.data.accessToken
}

export async function createOrder(request: APIRequestContext, token: string, customerName: string) {
  const res = await request.post('/api/v1/orders', {
    headers: { Authorization: `Bearer ${token}` },
    data: {
      customerId: 'cus_e2e_001',
      customerName,
      items: [{ skuId: 'sku_001', skuName: '智能网关 X1', quantity: 2, unitPrice: 1250, discountPercent: 100 }],
      wholeDiscountPercent: 100,
    },
  })
  expect(res.ok()).toBeTruthy()
  return (await res.json()).data as { id: string; orderNo: string }
}
// e2e/order-crud.spec.ts —— 每条用例独立造数,天然支持并行
import { test, expect } from '@playwright/test'
import { login, createOrder } from './helpers/api'

test.afterEach(async ({ request }) => {
  // 清理本用例造的数据(按唯一前缀删),保证环境可重复
  await request.post('/api/v1/test/cleanup', { data: { prefix: 'E2E-' } })
})

test('订单列表能按客户名筛选到 API 造的订单', async ({ page, request }) => {
  const token = await login(request)
  const marker = `E2E-${Date.now()}-华东代理`
  const { orderNo } = await createOrder(request, token, marker)

  await page.goto('/orders')
  await page.getByLabel('关键词').fill(marker)
  await expect(page.getByRole('cell', { name: orderNo })).toBeVisible()
})

连不上真实后端的环境(本地/PR),用 page.route 拦截关键接口返回 mock 数据——第 12 章 mock 服务的响应形状与附录A 完全一致,可以平移。

16.6 trace / 截图 / 视频:失败现场还原

E2E 失败在「别人电脑上」是常态,报告要能自证现场。三件产物的分工:

  • trace(首选):一整条时间线——每步操作前后的 DOM 快照、网络请求、console。失败用例在 HTML 报告里点开就能「回放」,本地查看 pnpm exec playwright show-trace trace.zip;
  • screenshot:失败瞬间一帧,快速人肉判断「页面长什么样」;
  • video:完整过程录像,trace 不可用时兜底。

配置见 16.2(trace: 'on-first-retry'、screenshot: 'only-on-failure'、video: 'retain-on-failure')。本地调试时 pnpm exec playwright test --headed --debug 可单步执行、实时看定位器匹配。

16.7 flaky 治理:四大来源与对策

flaky(偶红偶绿)是 E2E 头号杀手——它让团队学会无视红色,CI 从此失效。四大来源与对策:

来源 症状 对策
竞态等待 偶尔在断言处超时 用 Web 断言自动重试;严禁 waitForTimeout 硬 sleep
数据漂移 断言的订单被别的用例改了 16.5 的独立造数 + 唯一后缀
环境抖动 网络/容器慢导致超时 合理超时、CI 重试 2 次、workers 收敛
动画/轮播 元素位置不稳定 关闭测试环境动画(CSS prefers-reduced-motion),或断言文案不断言位置
// ❌ 硬编码等待:慢 3 秒还偶发失败
await page.getByLabel('关键词').fill('华东')
await page.waitForTimeout(3000)
await expect(page.getByRole('cell', { name: 'QO20260929000001' })).toBeVisible()

// ✅ 轮询断言:条件满足立刻通过,最迟 5 秒
await page.getByLabel('关键词').fill('华东')
await expect(page.getByRole('cell', { name: 'QO20260929000001' })).toBeVisible({ timeout: 5_000 })

治理流程:标记重试后仍失败的用例为「真缺陷或真脆弱」→ 看 trace 定位根因 → 修代码或修用例,不允许用「再加一层 sleep」过关。CI 上可配置:重试 2 次后仍红即流水线失败(retries 只救环境抖动,不救真 bug)。

16.8 CI 接入

PR 跑冒烟集(--grep @smoke),主干合入后跑全量;报告工件总是上传(失败排查靠它):

# .github/workflows/e2e.yml —— 完整流水线见第 18 章,这里只看 E2E job
name: e2e
on:
  pull_request:
  push:
    branches: [main]

jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with:
          version: 9
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm exec playwright install --with-deps chromium
      - run: pnpm exec playwright test --grep @smoke     # PR 冒烟;主干用 playwright test 全量
      - uses: actions/upload-artifact@v4
        if: ${{ !cancelled() }}                          # 失败更要上传报告
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 7

给冒烟用例打标签:test('...下单主流程...', { tag: '@smoke' }, async ({ page }) => {...})。

16.9 企业案例:下单主流程 E2E(冒烟集)

附录A §4 的冒烟集定义:登录 → 下单 → 审批 → 导出,全链路通过。实现上拆成一条 test.describe.serial 主链路(步骤间有先后依赖)+ 若干独立回归用例(storageState 加速):

// e2e/order-flow.spec.ts
import { test, expect } from '@playwright/test'
import { login, createOrder } from './helpers/api'

test.describe.serial('下单主流程(冒烟集 @smoke)', () => {
  let orderNo: string
  let token: string

  test('1. 运营登录成功', async ({ page }) => {
    await page.goto('/login')
    await page.getByLabel('邮箱').fill('operator@qingorder.example.com')
    await page.getByLabel('密码').fill('demo-password-123')
    await page.getByRole('button', { name: '登录' }).click()
    await expect(page).toHaveURL(/\/dashboard/)
  })

  test('2. 创建订单(折扣 ≤ 90% 免审批)', async ({ page, request }) => {
    token = await login(request)
    await page.getByRole('link', { name: '新建订单' }).click()
    await page.getByLabel('客户').fill('青云华东代理')
    await page.getByLabel('商品').fill('智能网关 X1')
    await page.getByLabel('数量').fill('2')
    await page.getByRole('button', { name: '提交订单' }).click()

    await expect(page.getByText(/订单创建成功/)).toBeVisible()
    orderNo = (await page.getByTestId('created-order-no').textContent())!
    expect(orderNo).toMatch(/^QO\d{14}$/) // 附录A:QO + yyyyMMdd + 6 位序号
  })

  test('3. 财务审批通过', async ({ page, request }) => {
    // 切换财务角色:用财务账号登录态操作(多角色 setup 见 16.4)
    await page.getByRole('link', { name: '订单管理' }).click()
    await page.getByLabel('关键词').fill(orderNo)
    await page.getByRole('row', { name: new RegExp(orderNo) }).getByRole('button', { name: '审核' }).click()
    await page.getByRole('button', { name: '通过' }).click()
    await expect(page.getByRole('cell', { name: '已审核' })).toBeVisible()
  })

  test('4. 导出选中订单为 CSV', async ({ page }) => {
    await page.getByLabel('关键词').fill(orderNo)
    await page.getByRole('row', { name: new RegExp(orderNo) }).getByRole('checkbox').check()
    const downloadPromise = page.waitForEvent('download')
    await page.getByRole('button', { name: '批量导出' }).click()
    const download = await downloadPromise
    expect(download.suggestedFilename()).toMatch(/orders.*\.csv$/)
  })
})

配套回归集(storageState 版,节选):无权限访问 /system/settings 落 403 页(附录A 页面级权限)、销售看不到「删除订单」按钮(按钮级权限)、新建订单整单折扣 92% 后状态为 pending_review(审批分流)。至此附录A 的 E2E 冒烟集目标达成:全链路在 CI 稳定通过。

🛠 渐进式实战练习

练习 1 · 基础(10–20 min)

任务描述:搭起 Playwright 工程(16.2 配置),写第一条用例:访问登录页 → 输入错误密码 → 断言展示「邮箱或密码错误」且不跳转;再用正确账密走通登录。跑一次 --headed 亲眼看看,然后人为制造一次失败,查看自动生成的失败截图。

提示:错误提示是异步出现的,用 expect(locator).toBeVisible() 的自动重试;「制造失败」可以直接把一个断言改错。

验收标准:两条用例都绿;改错后的失败报告里有截图与错误上下文,html-report 能打开。

参考答案
// e2e/login.spec.ts
import { test, expect } from '@playwright/test'

test('错误密码提示且不跳转', async ({ page }) => {
  await page.goto('/login')
  await page.getByLabel('邮箱').fill('operator@qingorder.example.com')
  await page.getByLabel('密码').fill('wrong-password')
  await page.getByRole('button', { name: '登录' }).click()

  await expect(page.getByText('邮箱或密码错误')).toBeVisible()
  await expect(page).toHaveURL(/\/login/) // 仍在登录页
})

练习 2 · 进阶(30–60 min)

任务描述:实现登录态复用与订单筛选回归集:① 按 16.4 配置 setup 项目 + storageState;② 写用例:API 造 3 条不同状态的订单 → 列表页按状态 已审核 筛选 → 断言只出现目标订单;③ afterEach 清理造数。

提示:造数时把状态直接置为 approved(接口若不支持,先造草稿再走一次提交/审核接口);断言「只出现目标订单」用 toHaveCount(1) 而不是只断言可见。

验收标准:连续运行 pnpm exec playwright test 三次全部通过(隔离生效);单条用例耗时 < 3s(登录态复用生效)。

参考答案(核心片段)
// e2e/order-filter.spec.ts
import { test, expect } from '@playwright/test'
import { login, createOrder } from './helpers/api'

let orderNos: string[] = []

test.afterEach(async ({ request }) => {
  await request.post('/api/v1/test/cleanup', { data: { orderNos } })
  orderNos = []
})

test('按「已审核」状态筛选', async ({ page, request }) => {
  const token = await login(request)
  const marker = `E2E-${Date.now()}`
  for (const status of ['draft', 'approved', 'cancelled'] as const) {
    const { orderNo } = await createOrder(request, token, `${marker}-${status}`)
    await request.post(`/api/v1/orders/${orderNo}/transition`, {
      headers: { Authorization: `Bearer ${token}` }, data: { status },
    })
    orderNos.push(orderNo)
  }

  await page.goto('/orders')
  await page.getByTestId('order-status-filter').click()
  await page.getByRole('option', { name: '已审核' }).click()

  await expect(page.getByRole('row')).toHaveCount(1 + 1) // 表头 + 目标行
  await expect(page.getByRole('cell', { name: /approved/ })).toHaveCount(1)
})

练习 3 · 挑战(选做,60+ min)

任务描述:完整实现 16.9 的下单主流程冒烟集(登录 → 下单 → 审批 → 导出),接进 CI(PR 只跑 @smoke),并做一次 flaky 演练:在审批步骤注入 800ms 延迟 mock,验证用例依靠自动等待依然稳定;然后故意改成硬 sleep 版本,观察其在并发 worker 下的失败率。

提示:导出下载用 page.waitForEvent('download')(16.9 已给样例);CI 里先本地模拟 CI=true pnpm exec playwright test;演练数据写成对比表存进 docs/qa/flaky-drill.md。

验收标准:CI 冒烟集全绿且耗时 < 2 分钟;延迟注入下 10 次运行 0 失败;硬 sleep 版本在 workers: 2 下至少出现 1 次失败,用 trace 能定位到等待竞态。

参考答案(延迟注入 mock)
// 给审核接口注入延迟,验证自动等待的健壮性
await page.route('**/api/v1/orders/*/review', async (route) => {
  await new Promise((r) => setTimeout(r, 800)) // 模拟慢接口
  await route.continue()
})
// 之后照常点击与断言:expect 的自动重试会等到响应完成,不需要 sleep
await page.getByRole('button', { name: '通过' }).click()
await expect(page.getByRole('cell', { name: '已审核' })).toBeVisible({ timeout: 5_000 })

⚠️ 常见易错点

易错点 1:waitForTimeout 硬等待,又慢又 flaky

// ❌ 「等 3 秒应该够了吧」:环境快时白等 3 秒,环境慢时照样失败
await page.getByRole('button', { name: '提交订单' }).click()
await page.waitForTimeout(3000)
await expect(page.getByText('订单创建成功')).toBeVisible()
// ✅ Web 断言自带轮询重试:条件满足立即通过,最迟 5 秒
await page.getByRole('button', { name: '提交订单' }).click()
await expect(page.getByText('订单创建成功')).toBeVisible({ timeout: 5_000 })

评审规则:PR 里出现 waitForTimeout 直接打回——它是 flaky 的第一来源。

易错点 2:CSS 层级选择器,重构一次崩一片

// ❌ 依赖 DOM 结构与 className:改个布局、换套样式,测试全红
await page.click('.orders-page > div:nth-child(2) .btn-primary')
// ✅ 按用户看见的语义定位:改布局不改语义,测试零维护
await page.getByRole('button', { name: '提交订单' }).click()

定位器不稳定时,先怀疑「组件的可访问性命名没写好」——修 a11y 比修选择器一劳永逸。

易错点 3:用例依赖前序用例的数据,并行下互相踩踏

// ❌ test 2 假设 test 1 造的订单还在第一条:workers=2 时执行顺序不保证,偶发红
test('创建订单', ...)   // 造了订单 A
test('查看第一条订单', ...) // 断言列表第一条是订单 A
// ✅ 每条用例自带夹具:自己造数、自己断言、自己清理;有先后依赖的才用 serial 主链路
test('创建订单并查看详情', async ({ page, request }) => {
  const { orderNo } = await createOrder(request, token, `E2E-${Date.now()}`)
  await page.goto(`/orders/${orderNo}`)
  await expect(page.getByText(orderNo)).toBeVisible()
})

判断口诀:任何用例单独跑、乱序跑、并行跑,结果都必须一致。

易错点 4:storageState 一刀切,登录功能本身失去验证

// ❌ 所有用例都吃现成登录态:登录页改坏了 CI 全绿,上线当天翻车
//    (projects 里只剩 storageState 项目,没有真登录用例)
// ✅ 复用态项目 + 保留一条真实登录冒烟用例,两层各有职责
test('登录页可用(真登录链路 @smoke)', async ({ page }) => {
  await page.goto('/login')
  await page.getByLabel('邮箱').fill('operator@qingorder.example.com')
  await page.getByLabel('密码').fill('demo-password-123')
  await page.getByRole('button', { name: '登录' }).click()
  await expect(page).toHaveURL(/\/dashboard/)
})

易错点 5:trace/视频全量常开,CI 时间与磁盘双爆

// ❌ 每条用例都录 trace + video:几十条用例的 CI 产物几个 GB,上传比测试还久
use: { trace: 'on', video: 'on', screenshot: 'on' }
// ✅ 失败导向:第一次重试才录 trace、失败才截图/录像,正常路径零开销
use: { trace: 'on-first-retry', video: 'retain-on-failure', screenshot: 'only-on-failure' }

诊断产物是「失败保险」,不是「全程行车记录仪」——按需开启才是工程化做法。

✅ 最佳实践

  1. 两层覆盖策略:PR 只跑冒烟集(@smoke,5–10 条),全量回归放主干定时任务;冒烟答「敢不敢发」。
  2. 定位器三优先:Role / Label / 用户可见文本,data-testid 只兜底;把这条写进 PR 模板。
  3. 登录态两级制:storageState 提速日常用例,但永远保留一条真登录冒烟用例。
  4. 数据自治:每条用例 API 造数 + 唯一后缀 + afterEach 清理;共享数据的用例一律视为 flaky 候选。
  5. 失败现场三件套:trace(主)、截图(快)、视频(兜底),配置为失败导向而非全量开启。
  6. flaky 零容忍:重试后仍红的用例当日处理(修或删),禁止「失败重跑就绿」积攒成 CI 噪音。
  7. 环境动画归零:测试环境关闭过渡动画(prefers-reduced-motion),消除一整类不稳定。
  8. 报告是交付物:CI 失败时报告工件自动上传,任何人不本地复现也能定位失败步骤。

📌 本章小结

  • E2E 验证「整车能跑」:冒烟集(登录→下单→审批→导出)卡发布,全量回归看水位;PR 只跑冒烟。
  • Playwright 三大稳定性机制(自动等待、严格模式、Web 断言重试)+ 用户可见性定位器,从源头消灭 flaky。
  • storageState 复用登录态提效,但真登录链路必须有专属用例;数据 API 造数 + 唯一后缀 + 清理实现隔离。
  • trace/screenshot/video 是失败现场三件套,按需开启;flaky 用例当日治理,不允许积攒。
  • 附录A E2E 冒烟集目标达成:「轻订单」下单主流程已在 CI 每日自动验证。

测试防线齐了,但团队还缺两样东西:让代码风格与类型纪律自动执行的工程化链路(ESLint/Prettier/husky、tsconfig、Vite 进阶),以及让每次提交自动变成本次可发布产物的交付流水线。下一章先补前者。

📄 第 17 章 · 工程化实践:TypeScript 与 Vite 17-工程化实践-TypeScript与Vite.md

📍 阶段:高级阶段(第 13–18 章)|⏱ 预计学时:7–9 小时|🔗 前置:第 12 章实战(本章把它的 JS 代码全面迁移到 TS)

⬅ 上一章:E2E 测试与质量保障 | 📖 本版目录 | ➡ 下一章:CI/CD 部署与前端监控

💡 对照学习:本页是 React 版第 17 章,对应 Vue 版第 17 章 · 工程化实践。概念映射:泛型组件 Table<T> ≈ Vue 3.3+ 的 defineProps<{...}>() 泛型 props、PropsWithChildren ≈ 具名插槽默认内容、类型守卫 ≈ 同一概念两版通用。tsconfig / ESLint / Prettier / husky / lint-staged / Vite 插件 / 环境变量 / pnpm workspace 与框架无关,两版逐字通用;差异集中在框架类型层:React 的 props 是显式 interface,Vue 的 props 走运行时声明+类型推导。

🎯 学习目标

学完本章,你将能够:

  1. 配置 tsconfig 关键项并说出 strict 家族各开关分别拦住什么,把「strict 全开 + 禁新增 any」定为团队纪律;
  2. 为组件书写清晰的 props 类型,会写泛型组件(DataTable<T>)并正确处理 children 类型(PropsWithChildren / ReactNode);
  3. 用类型守卫收窄 API 响应类型,让「编译期类型」与「运行时数据」闭环;
  4. 搭通 ESLint(flat config)+ Prettier + husky + lint-staged 的提交前质量链路;
  5. 配置 Vite 的插件、开发代理与分环境环境变量,并给 import.meta.env 补全类型;
  6. 用 rollup-plugin-visualizer 分析构建产物,用 manualChunks 做分包优化并对照第 14 章预算验证;
  7. 搭建 pnpm workspace 骨架,把 money 工具与类型定义抽成可共享的内部包;
  8. 为存量 JS 项目制定渐进式 TS 迁移方案(轻订单 .jsx → .tsx 迁移路线 M1–M4)。

📖 核心知识点

17.1 TS 基线:tsconfig 关键项与 strict 纪律

Vite 的 React 模板默认给出三份配置(tsconfig.json 引用 tsconfig.app.json + tsconfig.node.json),业务代码的关键项在 tsconfig.app.json:

// tsconfig.app.json(关键项 + 注释说明为什么)
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "bundler",      // 配合 Vite 的打包语义,支持扩展名省略
    "jsx": "react-jsx",                 // React 17+ 自动 JSX 转换,无需 import React
    "strict": true,                     // 全家桶:null 检查/this/初始化检查等
    "noUncheckedIndexedAccess": true,   // arr[i] 类型为 T | undefined——索引访问必须判空
    "noUnusedLocals": true,             // 未用变量报错,防「注释式代码」
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true, // switch 忘 break 直接报错
    "verbatimModuleSyntax": true,       // type-only import 必须写 import type(见易错点 17-4)
    "skipLibCheck": true,               // 跳过第三方 .d.ts 检查,提速
    "noEmit": true,                     // 类型检查与构建分离,产物交给 Vite
    "baseUrl": ".",
    "paths": { "@/*": ["./src/*"] }
  },
  "include": ["src"]
}

strict 是底线,noUncheckedIndexedAccess 是进阶——它把「数组越界」从运行时炸点变成编译期红波浪线,代价是老代码要补判空,所以渐进迁移时可以后开。

// package.json —— 类型检查独立成脚本:CI 与编辑器跑的是同一套标准
{
  "scripts": {
    "typecheck": "tsc -b --noEmit"
  }
}

strict 纪律两条:① 全仓库 strict: true,不允许用 @ts-nocheck 整文件豁免;② 禁止新增 any(ESLint 层面卡死,见 17.4),存量 any 允许存在但只减不增。

17.2 组件类型:props、Children 与泛型组件

基础 props 类型:React 的 props 就是普通 TypeScript interface,必选/可选/联合类型直接表达业务约束:

// src/components/StatusTag.tsx —— props 用 interface,状态用附录A 的字面量联合
import type { OrderStatus } from '@/types/order'

interface StatusTagProps {
  status: OrderStatus
  size?: 'sm' | 'md' // 可选属性必须显式处理 undefined
}

export function StatusTag({ status, size = 'md' }: StatusTagProps) {
  const meta = ORDER_STATUS[status]
  return <Tag color={meta.color} className={`tag--${size}`}>{meta.label}</Tag>
}

Children 类型:三兄弟要分清——ReactNode(一切可渲染物:元素、字符串、数组、null)最常用;PropsWithChildren<P> 是「P 加一个 children」的快捷类型;ReactElement 只接受单个元素对象(例如明确要求 <X/> 而不是一段文字时):

import type { PropsWithChildren, ReactNode, ReactElement } from 'react'

// 写法一:PropsWithChildren 快捷方式
function Card(props: PropsWithChildren<{ title: string }>) {
  return <section className="card"><h3>{props.title}</h3>{props.children}</section>
}

// 写法二:显式声明,语义等价
function Card2({ title, children }: { title: string; children?: ReactNode }) { /* … */ }

泛型组件:列表页的 DataTable 天然是「类型随数据变」的场景——用泛型让列定义与数据行强关联:

// src/components/DataTable.tsx —— 泛型组件:T 由 items 推导,column.key 必须是 T 的键
import type { ReactNode } from 'react'

export interface DataTableColumn<T> {
  key: keyof T & string
  title: string
  render?: (row: T) => ReactNode
  width?: number
}

interface DataTableProps<T> {
  items: T[]
  columns: DataTableColumn<T>[]
  rowKey: (row: T) => string
  loading?: boolean
  empty?: ReactNode
}

export function DataTable<T>({ items, columns, rowKey, loading, empty }: DataTableProps<T>) {
  if (loading) return <DataTableSkeleton />
  if (items.length === 0) return <>{empty ?? <Empty text="暂无数据" />}</>
  return (
    <table>
      <thead>
        <tr>{columns.map((c) => <th key={c.key}>{c.title}</th>)}</tr>
      </thead>
      <tbody>
        {items.map((row) => (
          <tr key={rowKey(row)}>
            {columns.map((c) => (
              <td key={c.key}>{c.render ? c.render(row) : String(row[c.key] ?? '—')}</td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  )
}

// 使用处:T 被自动推导为 Order,写错列名或返回类型直接编译报错
;<DataTable
  items={orders}
  rowKey={(o) => o.id}
  columns={[
    { key: 'orderNo', title: '订单号' },
    { key: 'totalAmount', title: '金额', render: (o) => formatAmount(o.totalAmount) },
    { key: 'status', title: '状态', render: (o) => <StatusTag status={o.status} /> },
  ]}
/>

Ant Design 5 的 <Table<T>> 就是这套机制;手写一遍泛型组件是为了让你看懂它的类型签名。

17.3 API 响应类型与类型守卫

interface Order 只是编译期的「承诺」——接口真的返回这个形状吗?类型守卫在边界处做运行时校验,把 unknown 安全收窄为业务类型:

// src/types/api.ts —— 统一响应体(附录A §5)
export interface ApiResponse<T> {
  code: number
  message: string
  data: T
}

export interface PageData<T> {
  list: T[]
  total: number
  page: number
  pageSize: number
}
// src/api/guards.ts —— 类型守卫:运行时校验 + 编译期收窄
import type { ApiResponse, PageData } from '@/types/api'
import type { Order } from '@/types/order'

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null
}

export function isOrder(value: unknown): value is Order {
  if (!isRecord(value)) return false
  return typeof value.id === 'string'
    && typeof value.orderNo === 'string'
    && Array.isArray(value.items)
}

export function isOrderPage(value: unknown): value is PageData<Order> {
  if (!isRecord(value) || !Array.isArray(value.list)) return false
  return value.list.every(isOrder) // 列表里混进一条脏数据也拦得住
}
// src/api/orders.ts —— 在数据进入应用的边界处校验
import { request } from '@/utils/request'

export async function fetchOrders(params: { keyword: string; page: number }): Promise<PageData<Order>> {
  const body = await request.get<unknown>('/orders', { params })
  const page: unknown = (body as ApiResponse<unknown>).data
  if (!isOrderPage(page)) {
    throw new Error('订单列表响应结构异常,请检查接口契约') // 快速失败,好过脏数据流入 UI
  }
  return page
}

对照 Vue:Vue 版同样用守卫,只是入口在 api/ 模块;React 版多了 TanStack Query 一层,守卫放在 queryFn 里效果相同——校验发生的位置不重要,「数据入口必校验」这条纪律才重要。更重的场景(表单契约、多态响应)可以直接上 Zod(第 10 章已学),守卫是零依赖的最小方案。

17.4 质量链路:ESLint + Prettier + husky + lint-staged

目标:开发者 git commit 的瞬间,风格问题已被机器拦下,Code Review 只看逻辑。

pnpm add -D eslint @eslint/js typescript-eslint eslint-plugin-react-hooks eslint-plugin-react-refresh prettier husky lint-staged
pnpm exec husky init
// eslint.config.js —— ESLint 9 flat config
import js from '@eslint/js'
import tseslint from 'typescript-eslint'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'

export default tseslint.config(
  { ignores: ['dist', 'coverage', 'playwright-report'] },
  js.configs.recommended,
  ...tseslint.configs.recommended,
  {
    files: ['**/*.{ts,tsx}'],
    plugins: { 'react-hooks': reactHooks, 'react-refresh': reactRefresh },
    rules: {
      ...reactHooks.configs.recommended.rules, // hooks 依赖检查:第 13 章易错点 5 的机器防线
      '@typescript-eslint/no-explicit-any': 'error',        // 禁新增 any(strict 纪律第 2 条)
      '@typescript-eslint/consistent-type-imports': 'error', // type-only import 必须带 type
      'no-console': ['warn', { allow: ['warn', 'error'] }],
    },
  },
)
// .prettierrc —— 团队风格唯一事实源;与 ESLint 分工:Prettier 管格式,ESLint 管质量
{
  "semi": false,
  "singleQuote": true,
  "printWidth": 100,
  "trailingComma": "all"
}
// package.json —— 只 lint 暂存区文件:秒级反馈,不阻塞全仓提交
{
  "scripts": { "prepare": "husky" },
  "lint-staged": {
    "*.{ts,tsx}": ["eslint --fix", "prettier --write"],
    "*.{json,md,css}": ["prettier --write"]
  }
}
# .husky/pre-commit —— husky 初始化后生成的钩子
pnpm exec lint-staged

链路全景:git commit → husky pre-commit → lint-staged → ESLint(质量)+ Prettier(格式)→ 有问题则提交中止。CI 里再跑一遍全量 pnpm lint(第 18 章),双保险防绕过(--no-verify)。

17.5 Vite 进阶:插件、代理与环境变量分环境

// vite.config.ts
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
import { visualizer } from 'rollup-plugin-visualizer'

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd(), '')
  return {
    plugins: [
      react(),
      // 构建产物分析:跑 pnpm build 后打开 dist/stats.html(常驻,发布前看一眼)
      visualizer({ filename: 'dist/stats.html', gzipSize: true }),
    ],
    server: {
      proxy: {
        // 开发期把 /api 转发到本地 mock 或联调环境(附录A:开发期代理指向本地 mock)
        '/api': {
          target: env.VITE_PROXY_TARGET ?? 'http://localhost:8787',
          changeOrigin: true,
        },
      },
    },
    build: {
      sourcemap: false, // 生产默认不出 sourcemap;要出只能 'hidden',见第 18 章安全红线
      chunkSizeWarningLimit: 500,
      rollupOptions: {
        output: {
          // 大体积、低频变动的库单独分包:命中浏览器长缓存,业务代码改动不影响它们
          manualChunks: {
            vendor: ['react', 'react-dom', 'react-router'],
            antd: ['antd'],
          },
        },
      },
    },
  }
})

环境变量分环境:Vite 按 mode 加载对应文件,pnpm build --mode test 时加载 .env.test:

.env.development   # pnpm dev 加载:VITE_API_BASE_URL=/api(走代理)
.env.test          # --mode test:VITE_API_BASE_URL=https://api-test.qingorder.example.com/api/v1
.env.production    # pnpm build 加载:VITE_API_BASE_URL=https://api.qingorder.example.com/api/v1
# .env.development —— 只有 VITE_ 前缀的变量会暴露给客户端代码!
VITE_API_BASE_URL=/api
VITE_PROXY_TARGET=http://localhost:8787
// src/vite-env.d.ts —— 给 import.meta.env 补全类型(IDE 自动补全 + 拼错变量名编译报错)
/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_API_BASE_URL: string
  readonly VITE_PROXY_TARGET?: string
}

interface ImportMeta {
  readonly env: ImportMetaEnv
}

⚠️ 安全红线再强调一次:所有 VITE_* 变量都会被打进客户端产物,任何人打开 DevTools 都能看到。放公开配置(API 地址、开关)没问题;放密钥是事故(易错点 17-3)。需要保密的配置要么走后端,要么走第 18 章的运行时注入。

17.6 构建分析与产物优化

dist/stats.html 是一张 Treemap:方块越大占字节越多。看三件事——「不该在首包里的东西」(第 14 章分割是否真的生效)、「重复依赖」(lodash 全量 vs 按需)、「意外的大头」(moment 时区数据之类)。manualChunks(17.5)之外还有两个常见动作:

// ① 按需引入组件库:Ant Design 5 默认支持 tree-shaking,但要防「顺手全量引」
import { Button, Table } from 'antd'           // ✅ ESM 具名导入,可摇树
import * as antd from 'antd'                   // ❌ 全量命名空间导入,摇树失效

// ② 动态 import 拆重库(与第 14 章 React.lazy 配合)
const { default: ECharts } = await import('echarts') // 报表页才加载图表库

优化后验证闭环:pnpm build → 看 stats.html → 跑第 14 章的 check:budget(gzip ≤ 300KB 门禁)→ 数据归档进 docs/perf/。

17.7 pnpm workspace 初探

当「轻订单」要抽出内部组件库 qing-ui(第 20 章)、或多个应用共享 money 工具时,Monorepo 就登场了。pnpm workspace 是最轻的起步方式:

# pnpm-workspace.yaml —— 声明哪些目录是工作区包
packages:
  - 'apps/*'      # 应用:qingorder-portal / qingorder-admin
  - 'packages/*'  # 内部包:shared(工具+类型)、qing-ui(第 20 章)
qingorder/
├── pnpm-workspace.yaml
├── apps/
│   └── admin/                 # 现在的 React 应用整体迁进来
│       ├── package.json       #   "name": "@qingorder/admin"
│       └── src/...
└── packages/
    └── shared/
        ├── package.json       #   "name": "@qingorder/shared", "type": "module"
        └── src/
            ├── money.ts       # 第 15 章测试过的金额工具,原样迁出
            └── types/order.ts # 附录A 实体类型,两应用共用
// apps/admin/package.json —— 用 workspace: 协议引用内部包
{
  "dependencies": {
    "@qingorder/shared": "workspace:*"
  }
}
// apps/admin/src/utils/format.ts —— 业务代码里像普通包一样 import
import { formatAmount } from '@qingorder/shared'

三个立刻能兑现的收益:依赖只装一份(pnpm 硬链接省磁盘)、内部包改动即时生效(无需发布 npm)、money.ts 的 80% 覆盖率测试跟着包走,谁用谁受益。深入玩法(changesets 版本管理、私有 npm 源)在第 20 章组件库建设里展开。

17.8 企业案例:轻订单渐进式 TS 迁移方案

第 12 章交付的 React 应用是纯 JS(.jsx)。直接「一夜全转 TS」的方案在真实团队必然烂尾:几万行代码的报错墙会压垮业务迭代。给「轻订单」定渐进方案 M1–M4:

里程碑 内容 验收标准
M1 · 基线先行 引入 tsconfig.json(allowJs: true, checkJs: false)、ESLint 链路、typecheck 脚本 现有 JS 代码零改动通过构建
M2 · 契约层先行 api/、types/、utils/money.ts 三个「契约层」目录转 .ts:收益最大(接口契约 + 金额正确性)、依赖最少 pnpm typecheck 通过;money 测试全绿
M3 · 组件层推进 .jsx → .tsx 按「叶子组件 → 容器组件」顺序推进;每次 PR 允许迁移 ≤ 10 个文件,不与业务改动混提交 每周迁 15–20 文件,2 周内 views/ 清零
M4 · 收紧门禁 strict 全开 + noUncheckedIndexedAccess;ESLint no-explicit-any: error;CI 加 typecheck 卡点 全仓零 any 新增,CI 红 = 不可合并

三条迁移战术:① JSDoc → interface:先给 JS 里的注释类型补上真实类型,机械改写风险最低;② 临时逃逸要留痕:卡住的类型用 // TODO(ts-migration) + @ts-expect-error 标记(它会在这行被修好后自动报错提醒你删掉),绝不用 @ts-ignore(静默);③ .jsx 与 .tsx 可以长期共存,Vite 原生支持混编,迁移不阻塞任何业务需求。

🛠 渐进式实战练习

练习 1 · 基础(10–20 min)

任务描述:把第 12 章的 StatusTag.jsx 与 Auth.jsx 迁移为 .tsx:补全 props interface、给默认值标注类型、处理可选属性,并让 pnpm typecheck 通过。

提示:Auth 的 need prop 是附录A 权限动作的字面量联合('order:delete' | 'order:review' | ...),从 @/types/permissions 导入而不是写内联联合;状态机字典 ORDER_STATUS 用 Record<OrderStatus, { label: string; color: string }> 收敛。

验收标准:两个文件零 any;故意给 StatusTag 传 'shipped '(多空格的非法状态)时编译报错;typecheck 脚本可重复运行。

参考答案(StatusTag.tsx)
import { Tag } from 'antd'
import type { OrderStatus } from '@/types/order'

type TagSize = 'sm' | 'md'

interface StatusTagProps {
  status: OrderStatus
  size?: TagSize
}

const ORDER_STATUS: Record<OrderStatus, { label: string; color: string }> = {
  draft:          { label: '草稿',   color: 'default' },
  pending_review: { label: '待审核', color: 'orange' },
  approved:       { label: '已审核', color: 'blue' },
  shipped:        { label: '已发货', color: 'cyan' },
  completed:      { label: '已完成', color: 'green' },
  rejected:       { label: '已拒绝', color: 'red' },
  cancelled:      { label: '已取消', color: 'gray' },
}

export function StatusTag({ status, size = 'md' }: StatusTagProps) {
  const meta = ORDER_STATUS[status]
  return <Tag color={meta.color} className={`tag tag--${size}`}>{meta.label}</Tag>
}

练习 2 · 进阶(30–60 min)

任务描述:① 用 17.2 的泛型 DataTable<T> 同时替换订单列表与客户列表的表格实现;② 按 17.3 给 fetchCustomers 补类型守卫(isCustomer:校验 id/customerName/level 三个关键字段),并让守卫拦下一次「后端把 level 返回成数字」的模拟脏数据。

提示:两个列表共用 DataTable 后,差异只剩 columns 定义——这正是泛型组件的价值;守卫测试直接在 Vitest 里喂脏数据(第 15 章的设施复用)。

验收标准:两个列表的类型互不干扰(DataTable<Customer> 的列里返回 Order 字段会编译报错);脏数据测试用例红转绿;noUncheckedIndexedAccess 开启下无新增报错。

参考答案(isCustomer 守卫 + 测试)
// src/api/guards.ts
export interface Customer {
  id: string
  customerName: string
  level: 'A' | 'B' | 'C'
}

export function isCustomer(value: unknown): value is Customer {
  if (!isRecord(value)) return false
  return typeof value.id === 'string'
    && typeof value.customerName === 'string'
    && (value.level === 'A' || value.level === 'B' || value.level === 'C')
}
// src/api/guards.test.ts
import { describe, expect, it } from 'vitest'
import { isCustomer } from './guards'

describe('isCustomer 类型守卫', () => {
  it('合法客户通过', () => {
    expect(isCustomer({ id: 'c1', customerName: '青云华东', level: 'A' })).toBe(true)
  })
  it('level 返回数字(后端脏数据)被拦截', () => {
    expect(isCustomer({ id: 'c1', customerName: '青云华东', level: 1 })).toBe(false)
  })
  it('非对象输入被拦截', () => {
    expect(isCustomer(null)).toBe(false)
    expect(isCustomer('c1')).toBe(false)
  })
})

练习 3 · 挑战(选做,60+ min)

任务描述:完成轻订单 Monorepo 化第一步:建立 pnpm workspace,把 money.ts + 附录A 类型抽成 @qingorder/shared,admin 应用通过 workspace:* 引用;然后用 rollup-plugin-visualizer 分析 admin 产物,调整 manualChunks 并通过第 14 章的 gzip 预算检查。

提示:shared 包要有自己的 package.json("type": "module",main/exports 指向 src/index.ts 即可让 Vite 直接吃 TS 源码);stats.html 里确认 vendor 与 antd 分包是否如预期切开了。

验收标准:pnpm -r test(递归跑所有包的测试)全绿;admin 构建产物中 shared 的代码被正确打进业务 chunk;check:budget 通过;monorepo 目录结构与 17.7 一致。

参考答案(shared 包骨架)
// packages/shared/package.json
{
  "name": "@qingorder/shared",
  "version": "0.1.0",
  "type": "module",
  "exports": { ".": "./src/index.ts" },
  "scripts": { "test": "vitest run" }
}
// packages/shared/src/index.ts
export { calcItemAmount, calcOrderTotal, formatAmount } from './money'
export type { Order, OrderItem, OrderStatus } from './types/order'
pnpm install                    # workspace 重新链接
pnpm --filter @qingorder/shared test
pnpm -r test

⚠️ 常见易错点

易错点 1:用 any 兜 API 响应,类型体系从源头失守

// ❌ 类型只是「看起来安全」:level 拼写错了、结构变了,编译器都哑巴
const res = await axios.get('/api/v1/customers')
const list = res.data.data.list as Customer[] // as 是谎言,不是保障
// ✅ unknown + 类型守卫:先运行时校验再收窄,契约漂移当场爆炸而不是线上爆炸
const body: unknown = (await axios.get('/api/v1/customers')).data
if (!isCustomerPage((body as ApiResponse<unknown>).data)) {
  throw new Error('客户列表响应结构异常')
}

as 断言是「我对类型负责」,守卫是「代码对类型负责」——边界处只允许后者。

易错点 2:props 类型用 Partial/any 偷懒,丢掉必填约束

// ❌ 全可选:忘传 customerName 编译器不吭声,运行时列表出现「undefined 客户」
interface OrderCardProps { order?: Partial<Order> }
export function OrderCard({ order }: OrderCardProps) {
  return <span>{order?.customerName}</span> // 展示 undefined 的 bug 就这么来的
}
// ✅ 必填与可选如实声明;确实「两种形态」的组件用显式联合类型
interface OrderCardProps { order: Order }
// 真需要部分数据时,用工具类型精确表达:Pick<Order, 'id' | 'orderNo'>

类型是文档也是测试:interface 上的每个 ? 都是你在承诺「缺了它能跑」,想清楚再写。

易错点 3:把密钥塞进 VITE_ 环境变量,直接打进公开产物

# ❌ .env.production —— 构建后打开任意 chunk 就能搜到,等于公开泄露
VITE_API_SECRET_KEY=<your-api-secret>
VITE_SENTRY_AUTH_TOKEN=<your-sentry-auth-token>
# ✅ VITE_ 只放公开配置;机密值留占位符,真实值走 CI secrets 或服务端(第 18 章)
VITE_API_BASE_URL=https://api.qingorder.example.com/api/v1
# Sentry sourcemap 上传用的 token 属于构建机,放 CI secrets:${{ secrets.SENTRY_AUTH_TOKEN }}

判断标准一句话:能在浏览器 Network/Source 面板里看到的东西,就别指望 VITE_ 能保密。

易错点 4:类型导入不写 import type,verbatimModuleSyntax 下翻车

// ❌ Order 只是类型却被当成值导入:verbatimModuleSyntax 开启时,
//    打包器原样保留这条 import,types/order.ts 若无运行时导出直接报「找不到模块」
import { Order } from '@/types/order'
// ✅ 纯类型一律 import type:编译期擦除,不产生运行时代价
import type { Order, OrderStatus } from '@/types/order'

开了 verbatimModuleSyntax 后这是最常见的红波浪线——但它换来的回报是「类型与值在产物层面彻底分离」,值得。

易错点 5:@ts-ignore 一盖了之,类型债静默复利

// ❌ 报错看着烦,注释一盖:这一行从此对编译器隐身,
//    三个月后接口加了字段,唯一的哨兵正是你亲手禁言的它
// @ts-ignore
submitOrder(payload)
// ✅ 明确承认「我知道为什么报错、何时处理」,且修好后 @ts-expect-error 会提醒你删掉它
// TODO(ts-migration): 审批接口 v2 契约定稿后移除
// @ts-expect-error 旧版审批接口暂缺 discountPercent 字段
submitOrder(payload)

团队规范写作口径:PR 里新增 @ts-ignore = 打回;@ts-expect-error 必须带 TODO 与原因。

✅ 最佳实践

  1. strict 全开是团队纪律不是个人偏好:tsc -b --noEmit 进 CI,编辑器与流水线同一把尺。
  2. 契约层先行迁移:api/types/utils 先 .ts 化——类型收益最大、依赖最少,为组件层迁移铺路。
  3. 泛型组件服务「同构异形」:列表、下拉、表单这类结构固定数据多变的组件用 <T> 收敛,别为每种实体复制粘贴。
  4. 数据入口必校验:unknown + 守卫(或 Zod)位于唯一入口(api 层/queryFn),UI 层可以放心相信类型。
  5. 风格与质量分权:Prettier 管格式、ESLint 管质量、husky + lint-staged 管门禁,三者不重复不缺位。
  6. 环境变量分环境 + 类型补全:ImportMetaEnv 声明进仓库,新增变量先补类型;VITE_ 前缀 = 公开承诺。
  7. 产物分析常态化:每次大依赖变更跑一次 visualizer + 预算检查,与第 14 章的性能预算形成闭环。
  8. Monorepo 只在真有共享时上:先抽 shared(工具与类型),组件库(第 20 章)再扩——为了「看起来架构好」拆包是负债。

📌 本章小结

  • TS 基线 = strict 全开 + 索引访问判空 + 禁新增 any;typecheck 独立成脚本并与 CI 同尺。
  • 组件类型三件套:显式 props interface、ReactNode/PropsWithChildren 处理 children、泛型组件收敛「同构异形」。
  • unknown + 类型守卫让编译期类型与运行时数据闭环,守卫放在数据入口(api 层 / queryFn)。
  • 质量链路四件套(ESLint flat config + Prettier + husky + lint-staged)把风格问题拦截在 commit 瞬间。
  • Vite 进阶四板斧:代理、分环境变量(VITE_ 只放公开配置)、manualChunks 分包、visualizer 分析。
  • 轻订单 M1–M4 渐进迁移方案落定:契约层先行、@ts-expect-error 留痕逃逸、门禁逐步收紧。

代码已经「规范得能被同事接手」,最后一块拼图是交付:每次合并如何自动构建、多环境如何注入配置、发错了如何秒级回滚、线上挂了谁在几秒内知道——下一章把 CI/CD 流水线与前端监控一起搭起来。

📄 第 18 章 · CI/CD 部署与前端监控 18-CICD部署与前端监控.md

📍 阶段:高级阶段(第 13–18 章)|⏱ 预计学时:6–8 小时|🔗 前置:第 16 章 E2E + 第 17 章工程化

⬅ 上一章:工程化实践 · TypeScript 与 Vite | 📖 本版目录 | ➡ 下一章:大型项目架构设计

💡 对照学习:本页是 React 版第 18 章,对应 Vue 版第 18 章 · CI/CD 部署与前端监控。CI/CD、Docker、Nginx、灰度回滚、sourcemap、告警 SOP 与框架完全无关,两版逐字通用。框架差异只有两处:错误监控接入用 Sentry React SDK(@sentry/react ≈ Vue 版的 @sentry/vue),错误兜底用 ErrorBoundary(≈ Vue 的 onErrorCaptured + 全局 errorHandler)。学完本章,高级阶段全部目标(附录A §6「性能与质量治理」)交付完毕。

🎯 学习目标

学完本章,你将能够:

  1. 设计并落地 GitHub Actions 流水线:lint → typecheck → test → build → deploy 五段,PR 与主干差异化触发;
  2. 区分「构建时注入」与「运行时注入」配置的适用场景,实现 dev/test/pre/prod 四环境的配置管理;
  3. 编写多阶段 Dockerfile 与 Nginx 配置:SPA 回退、哈希资源长缓存、index.html 不缓存、brotli/gzip 压缩;
  4. 实现按版本目录发布 + 符号链接切换的部署方式,支持灰度放量与秒级回滚;
  5. 接入 Sentry 错误监控:ErrorBoundary 兜底 + sourcemap 还原,同时保证 sourcemap 不泄漏到公网;
  6. 埋点 web-vitals 性能指标并实现白屏检测,形成「错误 + 性能 + 可用性」三类监控面;
  7. 制定告警阈值与上线响应 SOP,完成一次「发布 → 告警 → 回滚 → 复盘」的完整演练;
  8. 交付高级阶段验收:附录A §6「性能与质量治理」的全部目标逐项核对通过。

📖 核心知识点

18.1 流水线全景

把第 13–17 章攒下的质量资产(性能预算、单测门禁、E2E 冒烟、lint/typecheck)串成一条机器执行的发布铁律:

git push / PR
   │
   ▼
┌────────────── quality job ──────────────┐   ┌──── e2e job ────┐
│ lint → typecheck → test(--coverage 80%) │   │ 冒烟集 @smoke    │
│ → build(性能预算 check:budget)           │   │ (16 章)          │
└───────────────┬─────────────────────────┘   └────────┬────────┘
                │            两者并行,都绿才继续         │
                ▼◄──────────────────────────────────────┘
          ┌── deploy job(仅主干 main)──┐
          │ 构建产物 → Sentry sourcemap  │
          │ → 上传版本目录 → 链接切换灰度 │
          └─────────────────────────────┘

设计原则:PR 阶段挡质量(lint/test/冒烟,10 分钟内出结果),主干阶段管交付(build/deploy),失败在任何一段都要「红得清楚、拦得住」。

18.2 GitHub Actions 编排

# .github/workflows/ci-cd.yml
name: qingorder-web

on:
  pull_request:
  push:
    branches: [main]

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true # 同分支新提交自动取消旧构建,省资源

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with:
          version: 9
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint                    # 第 17 章链路
      - run: pnpm typecheck               # tsc -b --noEmit
      - run: pnpm test:ci                 # vitest run --coverage(80% 门禁,15 章)
      - run: pnpm build
      - run: pnpm check:budget            # LCP/包体积预算,14 章
      - uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/

  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with: { version: 9 }
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm exec playwright install --with-deps chromium
      - run: pnpm exec playwright test --grep @smoke   # PR 只跑冒烟(16 章)
      - uses: actions/upload-artifact@v4
        if: ${{ !cancelled() }}
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 7

  deploy:
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    needs: [quality, e2e]
    runs-on: ubuntu-latest
    environment: production           # 环境级 secrets 与审批规则在仓库设置里配置
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist
      - name: 发布到版本目录并切换
        env:
          SSH_HOST: ${{ secrets.DEPLOY_HOST }}       # 所有凭据一律走 secrets,绝不写字面量
          SSH_USER: ${{ secrets.DEPLOY_USER }}
          SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
          RELEASE: ${{ github.sha }}
        run: |
          echo "$SSH_KEY" > deploy_key && chmod 600 deploy_key
          ssh -i deploy_key -o StrictHostKeyChecking=accept-new "$SSH_USER@$SSH_HOST" "mkdir -p /srv/qingorder/releases/$RELEASE"
          rsync -az --delete -e "ssh -i deploy_key" dist/ "$SSH_USER@$SSH_HOST:/srv/qingorder/releases/$RELEASE/"
          ssh -i deploy_key "$SSH_USER@$SSH_HOST" "ln -sfn /srv/qingorder/releases/$RELEASE /srv/qingorder/current"
          rm deploy_key

三处工程细节值得记住:concurrency 防止同一分支的构建排队爆炸;needs: [quality, e2e] 表达「全绿才部署」;environment: production 把密钥与审批策略绑定到环境而不是散落在 job 里。

18.3 多环境与配置注入

「轻订单」有 dev / test / pre / prod 四环境(附录A 上线节奏推演)。配置注入有两种路线,取舍如下:

路线 原理 优点 缺点
构建时注入 import.meta.env.VITE_* 打进产物 简单、零运行时开销 一份产物只能对应一个环境;改配置要重新构建
运行时注入 产物加载一个外部 config.js 一份产物发所有环境;改配置不动产物 首个请求多一次(可接受)

企业实践首选运行时注入——「一次构建、多环境部署」能从根本上保证「测试过的就是上线的」:

<!-- index.html:在 bundle 之前加载 -->
<script src="/config.js"></script>
// public/config.js —— 不入 git;由各环境的部署系统按环境生成/覆盖
// 教学占位值,真实环境由运维注入,不含任何密钥(密钥永远在服务端)
window.__APP_CONFIG__ = {
  apiBaseUrl: 'https://api.qingorder.example.com/api/v1',
  sentryDsn: '<your-sentry-dsn>',
  release: 'dev',
}
// src/config.ts —— 应用内统一读取,带默认值兜底
interface AppConfig {
  apiBaseUrl: string
  sentryDsn: string
  release: string
}

declare global {
  interface Window { __APP_CONFIG__?: Partial<AppConfig> }
}

export const appConfig: AppConfig = {
  apiBaseUrl: 'https://api.qingorder.example.com/api/v1',
  sentryDsn: '',
  release: 'dev',
  ...window.__APP_CONFIG__,
}

构建时注入(第 17 章的 .env.*)仍可用于「与产物绑定」的值,比如构建模式、版本号;两者并存,各管各的。

18.4 Docker + Nginx:静态部署与 SPA 回退

SPA 只有一个 index.html,用户直接访问 /orders/QO1 刷新时服务器找不到这个文件——必须回退到 index.html。多阶段 Docker 构建把「构建环境」与「运行环境」分离,最终镜像只有 Nginx + 静态产物:

# Dockerfile
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build

FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
# nginx.conf —— 静态站的四条铁律都在这里
server {
  listen 80;
  root /usr/share/nginx/html;
  index index.html;

  # 1. 带 hash 的静态资源:内容不变则永久缓存
  location /assets/ {
    add_header Cache-Control "public, max-age=31536000, immutable";
  }

  # 2. sourcemap 永不出网(第 17 章红线 + 18.6 配合)
  location ~* \.map$ { return 404; }

  # 3. SPA 回退 + HTML 永不缓存(发版后用户立即拿到新入口)
  location / {
    try_files $uri $uri/ /index.html;
    add_header Cache-Control "no-cache";
  }

  # 4. 文本压缩(gzip 兜底,有条件上 brotli 模块)
  gzip on;
  gzip_types text/css application/javascript application/json image/svg+xml;
  gzip_min_length 1k;

  # 基础安全头
  add_header X-Content-Type-Options nosniff;
  add_header X-Frame-Options SAMEORIGIN;
}

本地验证:docker build -t qingorder-web . && docker run -p 8080:80 qingorder-web,浏览器直访 http://localhost:8080/orders/whatever 刷新,不 404 即回退生效。

18.5 灰度发布与回滚

18.2 的「版本目录 + current 符号链接」结构让灰度和回滚都变成一次软链切换:

# 服务器目录结构:每个版本一个目录,current 指向当前版本
/srv/qingorder/
├── releases/
│   ├── a1b2c3...   # 上一版
│   └── d4e5f6...   # 最新版(灰度中)
└── current -> releases/a1b2c3...
# 灰度:带灰度 cookie 的用户访问新版本,其余用户走稳定版
map $cookie_qo_gray $root_dir {
  default /srv/qingorder/current;                  # 稳定版
  "on"    /srv/qingorder/releases/d4e5f6...;       # 灰度版(部署脚本写入具体版本号)
}
server {
  root $root_dir;
  # …其余配置同 18.4
}

放量节奏推荐:内部员工(发灰度 cookie)→ 5% → 50% → 100%,每档观察 30 分钟监控大盘。回滚则是秒级动作:

# rollback.sh —— 回滚 = 把 current 指回上一个版本 + reload
ln -sfn /srv/qingorder/releases/a1b2c3... /srv/qingorder/current
nginx -s reload   # Nginx 读取新软链;静态站回滚无需重启任何进程

演练要求:每个新接手的发布负责人,入职一周内必须在测试环境完整跑一次「发布 → 回滚」,做不到就不许碰生产。

18.6 错误监控:Sentry 接入与 sourcemap 还原

接入(React SDK)三步:初始化、ErrorBoundary 兜底、手动埋点。

pnpm add @sentry/react
// src/monitor/sentry.ts
import * as Sentry from '@sentry/react'
import { appConfig } from '@/config'

Sentry.init({
  dsn: appConfig.sentryDsn,             // 运行时注入(18.3),不写死
  environment: appConfig.release === 'prod' ? 'production' : appConfig.release,
  release: appConfig.release,           // 与部署版本号(git sha)对齐,还原 sourcemap 的钥匙
  tracesSampleRate: 0.2,                // 性能采样 20%,按流量成本调整
  beforeSend(event) {
    // 顺手脱敏:请求串里的 token 不进监控
    if (event.request?.query_string) delete event.request.query_string
    return event
  },
})
// src/main.tsx —— ErrorBoundary 包根组件:渲染期错误被捕获并展示兜底 UI
import { createRoot } from 'react-dom/client'
import * as Sentry from '@sentry/react'
import { App } from './App'

createRoot(document.getElementById('root')!).render(
  <Sentry.ErrorBoundary
    fallback={<ErrorPage title="页面开小差了,正在上报修复" />}
    onError={(error) => console.error('[boundary]', error)}
  >
    <App />
  </Sentry.ErrorBoundary>,
)

sourcemap 还原:压缩后的报错栈是 chunk-3f9.js:1:8842,没人看得懂。解法是构建时生成 sourcemap 并只上传给 Sentry:

// vite.config.ts —— 构建 Sentry 插件只在 CI 生效
import { sentryVitePlugin } from '@sentry/vite-plugin'

export default defineConfig({
  build: {
    // 'hidden':生成 .map 但产物 JS 不引用它——用户侧不可见,Sentry 可上传
    sourcemap: process.env.CI ? 'hidden' : false,
  },
  plugins: [
    react(),
    process.env.CI && sentryVitePlugin({
      org: 'qingtech',
      project: 'qingorder-web',
      authToken: process.env.SENTRY_AUTH_TOKEN, // CI secrets 注入,绝不写进仓库
    }),
  ].filter(Boolean),
})

双保险缺一不可:sourcemap: 'hidden'(产物不含引用)+ Nginx location ~* \.map$ { return 404; }(18.4,物理拒绝下载)。生产 sourcemap 泄漏等于公开源码,这是监控接入时最容易出也最危险的事故。

18.7 性能埋点与白屏检测

性能埋点复用第 14 章的 web-vitals 采集,把上报目标从自建接口换成监控平台(示例继续用自建接口,生产可替换为 Sentry 自定义指标或专用 RUM 平台)。

白屏检测:SPA 最严重的故障形态是「页面 200 但内容为空」(JS 报错、接口失败、版本错配都可能)。用「关键时机 + 一次性判定」实现:

// src/monitor/blank-screen.ts
import { appConfig } from '@/config'
import { reportEvent } from './reporter'

export function initBlankScreenDetection() {
  const check = () => {
    const root = document.querySelector('#root')
    const isBlank = !root || root.childElementCount === 0
    if (isBlank) {
      reportEvent('blank_screen', {
        url: location.href,
        release: appConfig.release,
        userAgent: navigator.userAgent,
      })
    }
  }

  // 两个时机各查一次:load(资源就绪)+ load 后 3s(异步渲染兜底),均只判一次
  window.addEventListener('load', () => { check(); window.setTimeout(check, 3000) })
  // 资源加载失败(chunk 404 是白屏高发原因)也作为信号
  window.addEventListener('error', (e) => {
    const target = e.target as HTMLElement | null
    if (target && (target.tagName === 'SCRIPT' || target.tagName === 'LINK')) {
      reportEvent('resource_error', { src: (target as HTMLScriptElement).src, release: appConfig.release })
    }
  }, true) // 捕获阶段才能拿到资源加载错误
}

注意两点:判定用事件驱动(load/error),不是轮询 DOM(省性能也不误报);上报带上 release,能直接回答「哪个版本引入的白屏」。

18.8 告警策略与响应 SOP

监控的终点不是看板,是「对的人在对的时间被叫醒」。给「轻订单」定的告警阈值(按 p75 / 全量错误率,初期从宽、稳定后收紧):

监控项 告警阈值 级别 通知渠道
新增错误 issue(生产) ≥ 10 次 / 5 分钟,影响 ≥ 5 用户 P1 电话 + 群
白屏率 > 1% P0 电话
JS 错误率 > 2% P1 群
LCP p75 > 3.0s(附录A 目标 2.0s 的告警水位) P2 群
部署后 30 分钟错误环比 涨幅 > 100% P1 群

响应 SOP 五步(写成值班手册,不是口头传统):

  1. 发现(分钟级):告警触达值班人;先看 Sentry issue 的 release 与告警时间是否吻合新版本;
  2. 定位(5 分钟内):issue 聚合的报错栈(sourcemap 还原后的)+ 影响用户数 + 白屏率三项定级;确认是新版本引入还是存量问题爆发;
  3. 止损(10 分钟内):新版本引入且影响面大 → 执行 18.5 回滚(秒级),先恢复再排查;定位不清但错误持续 → 同样回滚;
  4. 修复:在测试环境复现 → 按第 15 章纪律「先写失败测试再修」→ 走完整流水线重新发布;
  5. 复盘(48 小时内):一页纸复盘(时间线 / 根因 / 为什么测试没拦住 / 行动项),行动项落进 backlog 追踪。

🛠 渐进式实战练习

练习 1 · 基础(10–20 min)

任务描述:为「轻订单」仓库编写 quality job(18.2 的前 8 个 step),在 PR 上触发,并故意制造一次 typecheck 失败验证流水线真的会红。

提示:pnpm/action-setup 与 setup-node 的 cache: pnpm 缓存第二次运行才命中;本地验证可用 act 或直接推一个测试分支。

验收标准:PR 上流水线全绿;把 formatAmount 的参数类型改成 string 后 push,流水线在 typecheck 步骤失败且日志可读;还原后恢复绿。

参考答案(质量 job 最小版)
name: quality
on: [pull_request]
jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with: { version: 9 }
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint && pnpm typecheck && pnpm test:ci

练习 2 · 进阶(30–60 min)

任务描述:编写 Dockerfile 与 nginx.conf(18.4),本地 docker build + docker run 后完成三项验证:① 直访 http://localhost:8080/orders/QO20260929000001 刷新不 404(SPA 回退);② DevTools Network 里 assets/* 响应头含 immutable,index.html 含 no-cache;③ 请求任意 .map 文件返回 404。

提示:.dockerignore 里排除 node_modules 与 dist,否则构建上下文巨大;验证缓存头用 curl -I 更快。

验收标准:三项验证全部通过并把 curl -I 结果贴进练习记录;镜像体积 < 100MB(nginx:alpine + 静态产物)。

参考答案(.dockerignore 与验证命令)
node_modules
dist
coverage
playwright-report
.git
docker build -t qingorder-web .
docker run -d -p 8080:80 --name qo-web qingorder-web
curl -I http://localhost:8080/orders/QO20260929000001   # HTTP 200,Cache-Control: no-cache
curl -I http://localhost:8080/assets/                   # 任意真实资源:immutable
curl -I http://localhost:8080/assets/whatever.js.map    # HTTP 404

练习 3 · 挑战(选做,60+ min)

任务描述:完成一次「发布 → 告警 → 回滚 → 复盘」全链路演练:接好 deploy job(secrets 用占位值在本机演练)与 Sentry;故意在 pre 环境发布一个「点击提交订单必报错」的版本,观察 Sentry 告警(release 字段对齐),执行回滚脚本,最后写一页复盘(SOP 五步格式)。

提示:Sentry 免费额度足够教学使用,DSN 用 appConfig.sentryDsn 运行时注入;演练环境的「必报错版本」可以在提交按钮 onClick 里 throw new Error('drill: intentional'),标签注明 drill 用完即删。

验收标准:Sentry issue 携带还原后的源码栈(sourcemap 生效)且 release 显示演练版本号;从告警触达到回滚完成 < 15 分钟;复盘文档含时间线、根因、测试缺口、行动项四节,存 docs/qa/drill-2026Q4.md。

参考答案(回滚脚本要点)
#!/usr/bin/env bash
# scripts/rollback.sh <release-sha>
set -euo pipefail
TARGET="/srv/qingorder/releases/${1:?usage: rollback.sh <release-sha>}"
[ -d "$TARGET" ] || { echo "版本不存在: $TARGET"; exit 1; }
ln -sfn "$TARGET" /srv/qingorder/current
nginx -s reload
echo "已回滚至 $1,请立即观察监控大盘确认错误率回落"

复盘模板四节:时间线(发现/定位/止损/修复的绝对时刻)→ 根因(直因 + 深因)→ 测试缺口(为什么 CI 没拦住,对应补哪条用例)→ 行动项(负责人 + 截止日)。

⚠️ 常见易错点

易错点 1:生产环境 sourcemap 直接暴露,等于公开源码

// ❌ sourcemap: true 直接部署:.map 文件任何人可下载,
//    DevTools 里能看到全部业务源码,商业逻辑与接口结构一并泄露
build: { sourcemap: true }
// ✅ hidden 模式 + Nginx 拒绝 .map + 只上传 Sentry:报错栈可还原,公网拿不到
build: { sourcemap: process.env.CI ? 'hidden' : false },
// nginx.conf: location ~* \.map$ { return 404; }

验证方法:发布后 curl -I https://web.qingorder.example.com/assets/xxx.js.map,必须 404。

易错点 2:密钥写进工作流文件或构建日志,secrets 形同虚设

# ❌ 凭据硬编码进 yaml(会进 git 历史);或 debug 时 echo 出来(进构建日志人人可见)
deploy:
  run: |
    echo "password=<your-password>"    # 形式上像假值,但硬编码行为本身已泄漏,立即轮换
    scp -o StrictHostKeyChecking=no ...
# ✅ 一律 secrets.* 注入 env;日志脱敏;万一泄漏第一步是轮换而不是删除提交
deploy:
  env:
    SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
  run: |
    echo "$SSH_KEY" > deploy_key && chmod 600 deploy_key   # 写文件而非 echo 到日志

规则:CI 日志、错误监控、前端产物三个地方,任何密钥字面量出现即为 P0 事故,处置顺序是先轮换后清理。

易错点 3:index.html 强缓存,发版后用户拿到旧入口指向已删除的 chunk

# ❌ 对整站一刀切强缓存:新版发布删除旧 chunk 后,
#    老用户缓存的 index.html 仍引用已 404 的 chunk → 白屏
location / { add_header Cache-Control "public, max-age=31536000"; }
# ✅ 缓存策略按文件类型分层:哈希资源永久缓存,HTML 必须每次协商缓存
location /assets/ { add_header Cache-Control "public, max-age=31536000, immutable"; }
location /        { try_files $uri $uri/ /index.html; add_header Cache-Control "no-cache"; }

配套保险:发布脚本保留最近 2 个版本的 releases 目录(不立刻删旧 chunk),给「标签页开了一整天的用户」留缓冲期。

易错点 4:白屏检测靠轮询 DOM,既费性能又误报

// ❌ setInterval 每 200ms 查一次 #root:常驻定时器拖性能;
//    且在应用「本来就该空」的时刻(路由切换中)误报
setInterval(() => {
  if (document.querySelector('#root')!.childElementCount === 0) reportBlank()
}, 200)
// ✅ 事件驱动 + 关键时机一次性判定:零常驻开销,误报可控(见 18.7 实现)
window.addEventListener('load', () => { check(); window.setTimeout(check, 3000) })
window.addEventListener('error', reportResourceError, true) // 捕获阶段拿资源错误

易错点 5:发布后没有版本标识,出问题不知道线上是哪一版

// ❌ 无版本注入:Sentry issue 里 release 为空,用户截图只有「线上坏了」,
//    团队第一句永远是「现在线上到底是哪个版本?」
// ✅ release 贯穿三处:部署目录名(git sha)、config.js、Sentry.init——同一把钥匙
// deploy job: RELEASE: ${{ github.sha }} → /srv/qingorder/releases/$RELEASE
// config.js : { release: 'd4e5f6...' }(部署时写入)
// sentry.ts : release: appConfig.release → issue 可按版本过滤、sourcemap 可按版本还原

排查效率的分水岭就在这里:有 release 的团队 5 分钟定位「哪版引入」,没有的团队靠回忆和抽签。

✅ 最佳实践

  1. 流水线是发布唯一通道:任何「紧急直推服务器」都在制造不可回溯的版本,紧急需求走加速审批而不是绕过 CI。
  2. 一次构建、多环境部署:运行时注入 config.js,保证测试过的产物与上线产物字节级一致。
  3. 缓存分层铁律:哈希资源 immutable、HTML no-cache、旧版本保留 2 个发布周期,三者缺一就会复现「发版白屏」。
  4. 回滚先于修复:止损目标 10 分钟,回滚是脚本不是操作指南;每个发布负责人必须演练过一次。
  5. sourcemap 双保险:hidden + Nginx 拒绝 .map,Sentry 是唯一读者。
  6. release 字段贯穿全链路:部署目录、config、Sentry 对齐同一 git sha,版本可问、可查、可回滚。
  7. 告警必须带行动:每条告警阈值都对应 SOP 的一步动作;天天响却没人理的告警等于已下线,要么修阈值要么删掉。
  8. 演练常态化:回滚演练(新人必做)+ 故障演练(每季度一次),SOP 写下来只是第一步,跑起来才算数。

📌 本章小结

  • 流水线五段式(lint → typecheck → test → build → deploy)把第 13–17 章的质量资产变成机器铁律;PR 挡质量、主干管交付。
  • 配置注入首选运行时 config.js:一次构建多环境部署,测试过的就是上线的。
  • Docker + Nginx 四铁律:SPA 回退、资源 immutable、HTML no-cache、sourcemap 拒绝外泄。
  • 版本目录 + 软链切换让灰度放量与秒级回滚成为日常;release 字段贯穿部署、配置、监控三方。
  • 监控三面:Sentry 错误(ErrorBoundary + sourcemap 还原)、web-vitals 性能、白屏检测;告警阈值与 SOP 五步(发现→定位→止损→修复→复盘)让监控真正闭环。
  • 至此附录A §6 高级阶段目标全部交付:万级列表达标(13 章)、门户 LCP 1.8s(14 章)、核心模块覆盖率 ≥ 80%(15 章)、E2E 冒烟集通过(16 章)、TS 与规范链路(17 章)、发布与监控体系(本章)。完成 阶段自查 · 高级能力清单 的 30 分钟自测后,即可进入 第四部分 · 资深阶段——那里的问题是「系统该怎么长」,而不再只是「代码该怎么写」。

📄 第 19 章 · 大型项目架构设计 19-大型项目架构设计.md

📍 阶段:资深阶段(第 19–24 章)|⏱ 预计学时:8–10 小时|🔗 前置:第 12 章实战 + 第 17 章工程化 + 第 18 章 CI/CD

⬅ 上一章:CI/CD 部署与前端监控 | 📖 本版目录 | ➡ 下一章:设计系统与组件库建设

💡 对照学习:本页是 React 版第 19 章,对应 Vue 版第 19 章 · 大型项目架构设计。本章方法两版通用,示例代码为 React 生态:复杂度评估、分层与依赖规则、初始化编排、ADR 决策记录都是与框架无关的方法论;生态差异仅在于示例使用 React Router 7(≈ Vue Router 4)、Redux Toolkit/Zustand(≈ Pinia)、TanStack Query v5(≈ Vue 适配版 TanStack Query)。

进入资深阶段,问题从「代码怎么写」升级为「系统怎么长」。本章用「轻订单」完成第一次架构演进:从第 12 章的单体 admin,演进为分层清晰、依赖可约束、决策可追溯的多模块工程。先学评估,再学拆分——顺序不能反。

🎯 学习目标

学完本章,你将能够:

  1. 用「业务复杂度评估表」对一个真实前端项目做出架构分区判断,并说出「单体分区 / 模块化单体 / 微前端」三条路线的触发条件;
  2. 按 app / pages / widgets / features / entities / shared 分层组织 React 项目目录,并为每一层写出「能依赖谁」的规则;
  3. 配置 ESLint 边界规则(eslint-plugin-boundaries),让跨层 import 在本地与 CI 阶段直接报错;
  4. 制定大型 React 应用的路由层、状态层、请求层组织约定:路由片段按业务域拆分、状态三分法、queryKey 工厂;
  5. 实现应用初始化编排:把配置加载、会话探测、字典预取收敛为显式 bootstrap() 流程,并区分「阻塞挂载」与「后台预取」;
  6. 用插件化方式抽离埋点、监控等横切关注点,做到业务代码零侵入;
  7. 在 pnpm workspace 中规划多包结构(apps/ + packages/),配置内部包引用并说明 source 引用与产物引用的取舍;
  8. 使用 ADR 模板记录架构决策,产出两份可直接归档的决策文档(状态库选 Zustand、服务端状态引入 TanStack Query)。

📖 核心知识点

19.1 什么时候需要谈架构:业务复杂度评估法

架构不是简历素材,是对复杂度的响应。复杂度没到,任何「高级结构」都是负债;复杂度到了,没有结构就是灾难。动手拆分前,先量化五个维度:

维度 观测指标 低(1 分) 中(2 分) 高(3 分)
页面/路由规模 一级模块数 ≤ 3 个 4–8 个 ≥ 9 个
领域实体数 核心实体(订单/客户/商品…) ≤ 3 个 4–6 个 ≥ 7 个
团队规模 同时改一个仓库的人数 ≤ 3 人 4–8 人 ≥ 9 人
需求吞吐 月均需求数 ≤ 10 11–40 ≥ 40
变更冲突 月均「改 A 坏 B」事故 0 次 1–2 次 ≥ 3 次

把五个分数相加(8–15 分),对照决策矩阵选路线:

总分 8–10  ──►  单体 + 目录分区(第 12 章的做法已经够用,重点是纪律)
总分 11–13 ──►  模块化单体:分层 + 依赖规则 + 按业务域切片(本章主角)
总分 14–15 ──►  模块化单体已到收益上限,评估微前端(先过第 21 章「要不要拆」清单)

任一单项打满 3 分且与跨团队相关(如「多团队并行交付同一仓库」)
          ──►  无论总分,优先考虑物理隔离(独立仓库 + 微前端聚合,见第 21 章)

轻订单现状盘点(对照附录A §3):admin 已有认证、工作台、订单、客户、商品、报表、系统设置 7 个一级模块,核心实体 4 个(订单/客户/商品/用户),团队 6 人且运营侧需求月均 25+ 个,近一个月发生 2 次「改商品模块弄坏订单列表」。逐项打分:2 + 2 + 2 + 2 + 2 = 10 分——处在「单体分区」与「模块化单体」的临界点。而未来 3 个月确定要接入第三方营销子系统(跨团队、独立发布节奏),复杂度即将上台阶。结论:现在做模块化单体改造,为第 21 章的微前端留好接缝。

⚠️ 这一步的产出物(评分表 + 结论)应当写进 ADR 或方案文档归档,而不是停留在口头。「为什么拆」比「怎么拆」更值得记录。

19.2 分层架构:功能切片 × 依赖规则

模块化单体的经典组织方式是两条正交的轴:横切面分「技术层」,纵切面按「业务域」切片。业界通行的分层参考(源自 Feature-Sliced Design 等方法,层名通用):

┌─────────────────────────────────────────────────────────┐
│  app      应用装配层:providers、bootstrap、全局路由汇总    │  ← 只做装配,无业务
├─────────────────────────────────────────────────────────┤
│  pages    页面层:路由出口、页面级拼装(薄)                 │
├─────────────────────────────────────────────────────────┤
│  widgets  业务组合块:跨 feature 的复合区块                 │
│           (订单时间线、审批面板、工作台待办)               │
├─────────────────────────────────────────────────────────┤
│  features 用户操作单元:一个完整交互闭环                    │
│           (新建订单、折扣审批、登录、批量导出)             │
├─────────────────────────────────────────────────────────┤
│  entities 业务实体层:领域模型 + 实体级读写                  │
│           (order、customer、product、user)               │
├─────────────────────────────────────────────────────────┤
│  shared   技术基座:与业务无关(api 基座、ui 基件、lib 工具) │
└─────────────────────────────────────────────────────────┘

依赖铁律:只能「向下」依赖,禁止「向上」与「跨层跳跃」。
同级之间:features 之间不互相 import(需要共享就上提 widgets 或下沉 entities)。

落到轻订单 admin,目录树长这样(与第 12 章的单体结构对比,变化是每个业务域有了自己的领地):

apps/admin/src
├── app/
│   ├── bootstrap.ts          # 初始化编排(§19.5)
│   ├── providers.tsx         # Router/Query/Theme/Store Provider 组合
│   ├── routes.tsx            # 汇总各 pages 的路由片段
│   └── store.ts              # configureStore 汇总各 slice
├── pages/
│   ├── orders/               # 订单列表/详情页(薄壳:拼装 widgets/features)
│   │   ├── OrderListPage.tsx
│   │   ├── OrderDetailPage.tsx
│   │   └── routes.tsx
│   ├── dashboard/
│   └── settings/
├── widgets/
│   ├── order-timeline/       # 操作日志时间线:详情页与工作台待办共用
│   └── approval-panel/       # 折扣审批面板:财务视角
├── features/
│   ├── order-create/         # 新建订单:表单 + 提交 + 乐观更新
│   │   ├── ui/OrderCreateForm.tsx
│   │   ├── model/orderCartSlice.ts
│   │   └── index.ts          # 公共出口(Public API)
│   ├── order-approve/
│   ├── order-export/
│   └── auth-login/
├── entities/
│   ├── order/
│   │   ├── model/types.ts        # Order / OrderStatus(附录A §5 口径)
│   │   ├── model/statusMachine.ts # 状态机规则(哪些流转合法)
│   │   ├── model/queryKeys.ts    # queryKey 工厂(§19.4)
│   │   ├── api/orderApi.ts
│   │   └── index.ts
│   ├── customer/
│   └── product/
└── shared/
    ├── api/                  # axios 实例、拦截器、错误归一化(第 09 章产出)
    ├── ui/                   # Button/Table 基件(第 20 章上提为 qing-ui)
    ├── lib/                  # 金额计算(分单位)、日期、权限判断
    └── config/               # 环境变量与常量

每层「能依赖谁」用一张表钉死,并立刻翻译成机器可执行的 ESLint 规则:

来源层 允许依赖 禁止
app 所有层 被任何层依赖
pages widgets / features / entities / shared 直接 import 其他 pages
widgets features / entities / shared import pages、import 其他 widgets
features entities / shared import pages/widgets、import 其他 features
entities shared import 一切上层
shared 仅 shared 内部 import 任何上层
// eslint.config.js(flat config 片段)—— 违反依赖规则 = lint error = CI 红灯
import boundaries from 'eslint-plugin-boundaries';

export default [
  // ...其他配置
  {
    plugins: { boundaries },
    settings: {
      'boundaries/include': ['src/**/*.{ts,tsx}'],
      'boundaries/elements': [
        { type: 'app',      pattern: 'src/app/**' },
        { type: 'pages',    pattern: 'src/pages/**' },
        { type: 'widgets',  pattern: 'src/widgets/**' },
        { type: 'features', pattern: 'src/features/**' },
        { type: 'entities', pattern: 'src/entities/**' },
        { type: 'shared',   pattern: 'src/shared/**' },
      ],
    },
    rules: {
      'boundaries/element-types': ['error', {
        default: 'disallow',
        message: '${file.type} 不允许依赖 ${dependency.type}(依赖规则见 docs/architecture.md)',
        rules: [
          { from: 'app',      allow: ['pages', 'widgets', 'features', 'entities', 'shared'] },
          { from: 'pages',    allow: ['widgets', 'features', 'entities', 'shared'] },
          { from: 'widgets',  allow: ['features', 'entities', 'shared'] },
          { from: 'features', allow: ['entities', 'shared'] },
          { from: 'entities', allow: ['shared'] },
          { from: 'shared',   allow: ['shared'] },
        ],
      }],
    },
  },
];

两个配套纪律让规则真正生效:① 每个切片只暴露一个 index.ts(Public API),外部只允许从出口导入,内部文件路径不外泄——重构内部实现时不会波及调用方;② features 之间禁止互相 import 是最容易破也最值得守的一条,破口一开就退化回面条单体。

19.3 路由层组织:路由片段按业务域拆分

路由是 pages 层的「目录索引」。约定三条:① 每个业务域导出自己的路由片段;② 页面组件一律 React.lazy 懒加载(第 14 章的分包策略,按域分包正好对齐);③ 权限元信息(菜单、面包屑、守卫)继续沿用第 07 章的「元信息配置表」,路由片段只负责结构。

// src/pages/orders/routes.tsx —— 订单域路由片段
import { lazy, Suspense } from 'react';
import { Route } from 'react-router';
import { PageSkeleton } from '@/shared/ui/PageSkeleton';
import { RequireAuth } from '@/features/auth-login/RequireAuth'; // 第 11 章的守卫组件

const OrderListPage = lazy(() => import('./OrderListPage'));
const OrderDetailPage = lazy(() => import('./OrderDetailPage'));

export const orderRoutes = (
  <Route path="orders" element={<RequireAuth roles={['operator', 'sales', 'finance', 'super_admin']} />}>
    <Route index element={<Suspense fallback={<PageSkeleton />}><OrderListPage /></Suspense>} />
    <Route path=":orderId" element={<Suspense fallback={<PageSkeleton />}><OrderDetailPage /></Suspense>} />
  </Route>
);
// src/app/routes.tsx —— app 层只做汇总,一眼看清全站路由地图
import { Routes, Route, Navigate } from 'react-router';
import { AdminLayout } from './AdminLayout';
import { orderRoutes } from '@/pages/orders/routes';
import { dashboardRoutes } from '@/pages/dashboard/routes';
import { settingsRoutes } from '@/pages/settings/routes';

export function AppRoutes() {
  return (
    <Routes>
      <Route element={<AdminLayout />}>
        {orderRoutes}
        {dashboardRoutes}
        {settingsRoutes}
      </Route>
      <Route path="*" element={<Navigate to="/orders" replace />} />
    </Routes>
  );
}

新增一个业务域 = 新建一个 pages 目录 + 导出一个路由片段 + 在 app/routes.tsx 加一行。路由对「这个域有哪些页面、什么角色能进」的回答是局部的,不用全仓搜索。

19.4 状态层组织:三分法 + slice 就近 + queryKey 工厂

第 08 章的三分法在大型项目里升格为硬性归置约定:

状态类别 判断标准 归置位置 工具
服务端态 数据源在后端(订单列表、客户详情) entities/*/model/ TanStack Query v5
全局客户端态 多个不相邻组件共享的 UI/会话态(登录用户、字典缓存、订单暂存区) 所属 feature/entity 的 model/ Redux Toolkit(存量)/ Zustand(新增,ADR-008)
本地态 只影响单个组件树(弹窗开关、输入草稿) 组件内 useState React

第一条纪律是slice/store 就近放置:订单暂存区的 slice 不放全局 store/ 目录,而放在 features/order-create/model/ 里,由 app/store.ts 聚合。删掉一个 feature = 删掉一个目录,状态随之干净。

// src/features/order-create/model/orderCartSlice.ts —— slice 就近
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import type { OrderItem } from '@/entities/order';

interface OrderCartState {
  customerId: string | null;
  items: OrderItem[];
}

const initialState: OrderCartState = { customerId: null, items: [] };

export const orderCartSlice = createSlice({
  name: 'orderCart',
  initialState,
  reducers: {
    pickCustomer(state, action: PayloadAction<string>) {
      state.customerId = action.payload;
    },
    addItem(state, action: PayloadAction<OrderItem>) {
      state.items.push(action.payload);
    },
    removeItem(state, action: PayloadAction<string>) {
      state.items = state.items.filter((it) => it.skuId !== action.payload);
    },
  },
});

第二条纪律是服务端态绝不进 Redux,统一走 Query,并且 key 必须经由工厂函数生成——散落的字符串 key 是缓存失效(invalidate)失效的第一根源:

// src/entities/order/model/queryKeys.ts —— queryKey 工厂
import type { OrderListFilters } from '../api/orderApi';

export const orderKeys = {
  all: ['orders'] as const,                                   // 域根:一键失效全域
  lists: () => [...orderKeys.all, 'list'] as const,
  list: (filters: OrderListFilters) => [...orderKeys.lists(), filters] as const,
  details: () => [...orderKeys.all, 'detail'] as const,
  detail: (id: string) => [...orderKeys.details(), id] as const,
  timeline: (id: string) => [...orderKeys.detail(id), 'timeline'] as const,
};
// 使用侧:失效「该订单的一切」只需一句话
import { useQueryClient } from '@tanstack/react-query';

const qc = useQueryClient();
await qc.invalidateQueries({ queryKey: orderKeys.detail(orderId) });
// 列表、详情、时间线全部标记过期并按需重取——不需要记住每一个 key 的字符串长什么样

19.5 请求层组织:基座 + 领域 API

请求层延续第 09 章的产出,只增加两条归置约定:① shared/api 只放「协议能力」(axios 实例、token 注入、40102 无感刷新、错误归一化),不知道任何业务路径;② 各实体的接口定义收敛到 entities/*/api/,feature 层不直接拼 URL。

// src/entities/order/api/orderApi.ts —— 领域 API:入参出参全类型化
import { http } from '@/shared/api/http';
import type { Order, OrderStatus } from '../model/types';

export interface OrderListFilters {
  keyword?: string;
  status?: OrderStatus;
  dateFrom?: string;
  dateTo?: string;
  page: number;
  pageSize: number;
}

export interface PageResult<T> {
  list: T[];
  total: number;
  page: number;
  pageSize: number;
}

export function fetchOrderList(filters: OrderListFilters) {
  return http.get<PageResult<Order>>('/orders', { params: filters });
}

export function fetchOrderDetail(orderId: string) {
  return http.get<Order>(`/orders/${orderId}`);
}

export function updateOrderStatus(orderId: string, action: 'submit' | 'approve' | 'ship' | 'cancel', reason?: string) {
  return http.post<Order>(`/orders/${orderId}/${action}`, { reason });
}

19.6 应用初始化编排与插件化

单体项目最常见的隐形债:初始化逻辑散落在各个模块文件顶层——dict.ts 一 import 就发请求、埋点 SDK 在 main.tsx 之前已经偷偷加载。后果是启动顺序不可控、失败无从降级、测试无法隔离。解法是把「应用启动」收敛为一个显式编排函数,同时把横切关注点做成插件。

bootstrap() 启动编排时序(阻塞 vs 后台,一图定分晓)
────────────────────────────────────────────────────────────
① loadRuntimeConfig()        阻塞  读 window.__APP_CONFIG__(第 18 章运行时注入)
② await probeAuthSession()   阻塞  会话探测,失败 → 匿名态(不白屏,路由守卫接管)
③ initPlugins(config)        同步  埋点/监控/权限插件注册(§19.6 下方)
④ mountApp()                 挂载  用户此刻看到骨架屏 ← 阻塞到此为止
⑤ preloadDicts()             后台  字典预取,失败 toast 降级,不阻塞首屏
────────────────────────────────────────────────────────────
原则:影响「能否渲染」的才阻塞;影响「数据全不全」的一律后台 + 降级。
// src/app/bootstrap.ts —— 显式启动编排
import { loadRuntimeConfig, type RuntimeConfig } from './config';
import { probeAuthSession, type AuthSession } from '@/entities/user/session';
import { initPlugins } from './plugins';
import { mountApp } from './mount';
import { preloadDicts } from '@/entities/dict/api';

export async function bootstrap(): Promise<void> {
  const config: RuntimeConfig = loadRuntimeConfig();          // ① 同步读取,失败即 fail-fast
  const session: AuthSession = await probeAuthSession();       // ② 静默失败 → 匿名态,绝不抛出
  initPlugins(config, session);                                // ③ 插件注册(埋点/监控/权限)
  mountApp(config, session);                                   // ④ 挂载,用户看到 UI
  void preloadDicts().catch((err) => console.warn('字典预取失败,进入懒加载降级', err)); // ⑤ 后台
}
// src/app/plugins/types.ts —— 插件契约:横切关注点的统一入口
export interface PluginContext {
  config: RuntimeConfig;
  session: AuthSession;
}

export interface AppPlugin {
  name: string;
  setup(ctx: PluginContext): void;
}

// src/app/plugins/index.ts —— 注册表:新增横切能力 = 加一行,业务代码零改动
import { trackingPlugin } from './tracking';
import { monitoringPlugin } from './monitoring';
import type { AppPlugin, PluginContext } from './types';

const plugins: AppPlugin[] = [trackingPlugin, monitoringPlugin];

export function initPlugins(config: RuntimeConfig, session: AuthSession): void {
  const ctx: PluginContext = { config, session };
  for (const p of plugins) {
    try {
      p.setup(ctx);
    } catch (err) {
      console.error(`插件 ${p.name} 初始化失败(不阻塞启动)`, err);
    }
  }
}

对比一下:改造前埋点调用散落在 23 个组件里(track('order_submit') 手写 23 次);改造后在 trackingPlugin 里统一给 axios 挂拦截器、给路由挂 afterEach 钩子,业务代码一行不改。这就是插件化的价值——新横切关注点(如未来接入 A/B 实验 SDK)只是注册表里的一个新条目。

19.7 Monorepo 多包策略:为共享资产留位置

分层解决了「仓库内」的秩序,Monorepo 解决「跨应用」的复用:门户 portal 与后台 admin 要共享设计令牌、HTTP 基座、领域类型,第 20 章的 qing-ui 也要有独立的家。pnpm workspace 是成本最低的方案:

# pnpm-workspace.yaml —— 仓库根目录
packages:
  - 'apps/*'       # portal / admin;第 21 章的 marketing 壳也落在这里
  - 'packages/*'   # qing-ui / shared-types / api-base ……
// packages/qing-ui/package.json —— 内部包:source 引用(内部应用推荐)
{
  "name": "@qing/ui",
  "version": "0.1.0",
  "type": "module",
  "exports": {
    ".": "./src/index.ts",
    "./tokens.css": "./src/tokens.css"
  },
  "peerDependencies": {
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  }
}
// apps/admin/package.json(节选)—— workspace:* 永远指向工作区内的包
{
  "name": "@qing/admin",
  "dependencies": {
    "@qing/ui": "workspace:*",
    "@qing/shared-types": "workspace:*"
  }
}

内部包有两种引用策略,取舍要写进约定:

策略 包的 exports 指向 优点 代价 适用
source 引用 ./src/index.ts 零构建配置、改动即时生效、HMR 直通 消费方需要能编译 TS/TSX(Vite transpilePackages 已默认覆盖 workspace 包) 同仓内部应用(轻订单现状)
产物引用 ./dist/index.js + d.ts 包可脱离仓库交付(发布私服)、边界强 每个包要配构建与 d.ts 生成 对外发布的包(qing-ui 面向多团队时,见第 20 章)
// vite.config.ts(apps/admin,节选)—— workspace 包无需特殊配置,
// Vite 6 对 pnpm workspace 链接的包默认走源码编译;如遇特殊包再显式声明:
export default defineConfig({
  resolve: { alias: { '@': '/src' } },
});

Monorepo 的纪律只有两条,却决定成败:① 依赖方向单一——packages/ 不允许 import 任何 apps/ 的代码,方向反了复用立刻变纠缠;② 公共代码进包前先在应用内生长——被两个应用用到第三次才下沉,避免造出没人用的「假共享层」。

19.8 ADR:把架构决策变成可追溯的文档

ADR(Architecture Decision Record)是一篇 300 字左右的小文档,回答一个问题:当时为什么这么定。半年后新人问「为什么新代码用 Zustand 而存量是 Redux」,你不用再翻聊天记录。模板固定五段:

# ADR-<编号> · <一句话决策>

- **状态**:提议 / 已接受 / 已废弃(被 ADR-XXX 取代)
- **日期 / 决策人**:
- **关联**:受影响的其他 ADR

## 背景与问题
两三段说清「遇到了什么问题、约束是什么」。只写事实,不写情绪。

## 备选方案
表格列出 2–3 个候选,各自优点与代价——「没有备选的决策」通常意味着没有思考。

## 决策
选了什么,附加哪些约束条件(重要:决策常伴随约束,约束是方案的一部分)。

## 后果
正面收益 / 负面代价都要写;给出「回顾触发条件」——出现什么信号时重开此决策。

样例一:轻订单 ADR-008(状态库)——它演示了如何处理「存量与增量不一致」的现实约束:

# ADR-008 · 客户端状态库:新增模块统一采用 Zustand,存量 Redux 保留

- **状态**:已接受(2026-04-08)
- **决策人**:前端组 6 人 + 技术负责人
- **关联**:ADR-004(中级阶段引入 Redux Toolkit)、ADR-009(服务端状态迁移 TanStack Query)

## 背景与问题
ADR-009 落地后,Redux 承担的服务端缓存职责已移交给 TanStack Query,剩余
slice(登录用户态、字典缓存、订单暂存区)都是轻量客户端态。继续用 RTK
意味着每个新模块都要写 slice + thunk 样板;而查询类页面越来越多,样板占比
明显上升。同时团队即将扩编 2 人,新人上手成本需要纳入考量。

## 备选方案
| 方案 | 优点 | 代价 |
|------|------|------|
| A. 维持全 RTK | 单一技术栈、DevTools 强、中间件生态成熟 | 样板代码多;轻状态书写繁琐 |
| B. 新增模块用 Zustand,存量保留 | 增量零迁移成本;Zustand 无 Provider、样板极少 | 双库共存的心智成本;状态分散两处 |
| C. 全量迁移到 Zustand | 长期单一栈 | 26 个存量 slice 迁移约 3 人周,业务高峰期不可行 |

## 决策
选 B,附加三条约束:
1. Zustand 仅用于「纯客户端态」;任何服务端数据一律 TanStack Query(ADR-009);
2. 禁止在 Zustand store 内发请求绕过 Query;
3. 新模块评审时由 reviewer 检查状态归置是否符合 §19.4 三分法。

## 后果
- 正面:新模块状态代码量约减半;新人 1 天内可独立写 store。
- 负面:仓库同时存在两套范式,onboarding 手册需增加「何时用哪个」一节(第 22 章落地)。
- 回顾触发条件:若 6 个月内出现 ≥3 次「Zustand 模块需要 Redux 级中间件能力」的真实诉求,重开本 ADR。

样例二:轻订单 ADR-009(请求库)——它演示了「痛点驱动」的论证方式,注意每一条后果都能追溯到附录A的具体痛点:

# ADR-009 · 服务端数据管理统一引入 TanStack Query v5

- **状态**:已接受(2026-03-20)
- **决策人**:前端组 6 人 + 技术负责人
- **关联**:ADR-008、第 09 章接口层设计

## 背景与问题
高级阶段性能治理(第 13 章)与监控(第 18 章)暴露出三类重复实现的手写逻辑:
1. 订单列表翻页/筛选的竞态控制靠 AbortController 手写,三个页面三份实现,监控
   归因到「旧响应覆盖新数据」的脏数据告警 2 起;
2. 提交订单后需要手动 refetch 列表与详情,漏掉一处就出现「审核通过了列表还
   显示待审核」的客诉;
3. 每个列表页各自维护 loading/error/分页 state,加起来 400+ 行高度相似代码。

## 备选方案
| 方案 | 优点 | 代价 |
|------|------|------|
| A. 继续手写 hooks | 零依赖 | 三类问题持续复发;人力成本随页面数线性增长 |
| B. RTK Query | 与存量 RTK 同构 | 强绑定 Redux;缓存生命周期控制粒度粗 |
| C. TanStack Query v5 | 独立于状态库;缓存/失效/乐观更新/竞态开箱即用;社区事实标准 | 新概念学习成本(约 2 天);需配套 queryKey 规范 |

## 决策
选 C。约束:
1. queryKey 一律经实体层工厂生成(见 §19.4),禁止裸字符串 key;
2. 服务端类型定义收敛在 entities/*/api,与后端 OpenAPI 对齐(第 22 章契约);
3. 存量手写 hooks 按「改哪个页面迁哪个」的节奏渐进替换,不设一刀切死线。

## 后果
- 正面:三类手写逻辑归零;乐观更新在订单审核场景上线后审批列表交互明显顺滑;
  团队预计节省的维护成本 ≈ 每迭代 0.5 人日。
- 负面:引入约 13KB gzip 依赖;DevTools 调试面板成为团队必修项。
- 回顾触发条件:若出现「服务端数据需要复杂跨页联动派生」而 Query 表达吃力的场景超过 2 个,重评。

ADR 的存放位置固定在 docs/adr/(Monorepo 根),编号递增、只追加不修改——推翻旧决策就写新 ADR 并把旧状态改为「已废弃(被取代)」。这份不可变性正是它作为「决策日志」的价值。

🛠 渐进式实战练习

练习 1 · 基础(10–20 min):给现状做一次「架构体检」

任务描述:打开你第 12 章产出的轻订单 admin 仓库,完成三件事:① 用 §19.1 的评估表逐项打分并得出总分与路线建议;② 画出当前 src 目录树;③ 找出 3 处以上「跨域/跨层 import」违规点(例如 pages/orders 直接 import 了 pages/customers 的组件、或 features 互相引用),列成清单。

提示:违规点可以用 grep -rn "from '@/pages" src/features 这类命令半自动排查;「list 里有几条」不重要,「知道去哪找」才重要。

验收标准:产出 docs/architecture-audit.md,含评分表(5 维度 + 总分 + 路线结论)、目录树、违规 import 清单(文件路径 + 违规原因)。

参考答案(评分与违规示例)

评分(对照附录A现状):模块数 7 → 2 分;实体 4 个 → 2 分;团队 6 人 → 2 分;月需求 25+ → 2 分;月冲突 2 次 → 2 分;总分 10,叠加「即将接入第三方营销子系统」的跨团队信号 → 结论:执行模块化单体改造(§19.2),不急于微前端。

典型违规清单示例:

  • src/pages/orders/OrderListPage.jsx import 了 @/pages/customers/CustomerPicker —— 跨 pages 引用,应下沉为 entities/customer 的选择器组件;
  • src/features/order-export import 了 @/features/order-create 内部文件(非出口路径)—— feature 间互引,公共部分上提 widgets;
  • src/entities/order/api/orderApi.jsx import 了 @/shared/api 之外的业务模块 —— 实体层依赖上层,违反单向规则。 每条违规在练习 2 中用 ESLint 边界规则做机器拦截。

练习 2 · 进阶(30–60 min):分层重构 + 让机器守规矩

任务描述:把练习 1 审计出的仓库重构为 §19.2 的六层结构(不必一步到位,至少完成订单域:entities/order + features/order-create + pages/orders 三个切片),每个切片提供 index.ts 公共出口;然后安装并配置 eslint-plugin-boundaries,把练习 1 的违规清单逐条修复到 lint 全绿;最后把 pnpm lint 挂进第 18 章的 CI 流水线。

提示:重构顺序自底向上——先建 entities,再迁移 features,最后改 pages;每完成一层跑一次 pnpm build 确认无回归,利用第 15 章的单测与第 16 章的冒烟 E2E 兜底。

验收标准:① 目录树与 §19.2 六层对齐;② pnpm lint 对人为制造的跨层 import 报 error(可临时写一个违规文件验证后删除);③ CI 流水线包含 lint 门禁且通过;④ 重构后 E2E 冒烟集(登录→下单→审批→导出)保持全绿。

参考答案(重构后订单域关键文件形态)
// src/entities/order/index.ts —— 公共出口:外部只能从这里拿东西
export type { Order, OrderItem, OrderStatus } from './model/types';
export { isTransitionAllowed, canCancel } from './model/statusMachine';
export { orderKeys } from './model/queryKeys';
export { fetchOrderList, fetchOrderDetail, updateOrderStatus } from './api/orderApi';
// src/features/order-create/index.ts
export { OrderCreateForm } from './ui/OrderCreateForm';
// 注意:orderCartSlice 不导出!它由 app/store.ts 显式聚合,
// 对外只暴露表单组件——调用方不需要知道内部用什么状态库(这是 ADR-008 的隐藏收益)
// src/app/store.ts —— 聚合点
import { configureStore } from '@reduxjs/toolkit';
import { userSlice } from '@/entities/user/model/userSlice';
import { orderCartSlice } from '@/features/order-create/model/orderCartSlice';

export const store = configureStore({
  reducer: {
    user: userSlice.reducer,
    orderCart: orderCartSlice.reducer,
  },
});

机器验证:在任意 features/ 文件里写 import x from '@/pages/orders/OrderListPage',pnpm lint 应报 boundaries/element-types 错误——证明规则生效,然后删除该文件。

练习 3 · 挑战(选做,60+ min):为「营销子系统接入」写第一份架构 ADR

任务描述:青云科技计划接入第三方供应商开发的「营销活动子系统」(优惠券领取页、活动报名页,供应商有自己的技术栈与发布节奏,无法合入主仓)。请撰写 ADR-011:判断营销子系统以什么形态接入(独立域名跳转 / iframe / 微前端子应用),给出备选方案对比与决策约束。本章先写「决策依据」,具体技术方案到第 21 章完成后再补充「实施附录」。

提示:评估维度至少包含——团队边界(谁维护)、发布节奏(能否同频)、视觉一致性要求、共享登录态的耦合深度、SEO 需求(营销页可能需要)。结论可以留给第 21 章推翻,但论证过程必须完整。

验收标准:产出 docs/adr/ADR-011-营销子系统接入形态.md,五段结构完整,备选 ≥3 个且各有代价分析,含「回顾触发条件」。

参考答案(决策要点)

关键论证要点(供对照,不强求一致):

  • 独立域名跳转:成本最低,但登录态要靠 URL 传 token(安全差)或共享 cookie(跨域配置复杂),且体验割裂——用户能感知「跳走了」;
  • iframe:隔离最彻底,但登录态同步、弹窗遮罩、路由同步、埋点打通全是坑,移动端体验差;
  • 微前端子应用:登录态天然共享(同域运行)、路由无感、视觉可统一;代价是基座建设 + 公共依赖治理(第 21 章主体内容)。
  • 结论方向:营销页对视觉一致性与登录态打通要求高、且供应商需要独立发布节奏 → 微前端子应用形态,约束「供应商只交付符合接入契约的子应用,不感知基座实现」。这与第 21 章的案例走向一致;若你的评估得出 iframe(例如营销页只是静态活动页、无登录态),只要论证自洽同样成立——ADR 评的是思考质量,不是唯一答案。

⚠️ 常见易错点

易错点 19-1:过度设计——为小团队上微前端 / 过早分层

❌ 错误做法:3 人团队、5 个页面的内部工具,第一周就搭 qiankun 基座 + 四层目录 + Monorepo 八个包。结果是:新人光读懂架构就要一周,改一行文案要跨三个包,构建时间翻倍——架构复杂度远超业务复杂度。

✅ 正确做法:架构分级响应复杂度(§19.1 决策矩阵)。先跑 §19.1 评估表拿分数:8–10 分用「单体 + 目录纪律」,11–13 分上分层切片,14 分以上且跨团队才谈微前端。判断标准不是「我想不想用」,而是「当前结构的痛点是否有数据支撑」——月度「改 A 坏 B」事故数、跨域 import 数、需求吞吐都是现成证据。

易错点 19-2:分层僵化——跨层 import 与「万物下沉 shared」

❌ 错误做法:features/order-create 里 import @/features/order-approve 的内部组件(feature 互引);或者反过来,为了「复用」把只被订单模块用的组件塞进 shared/,半年后 shared 长出 40 个只有单一调用方的文件——shared 变成了「另一个 features 目录」。

✅ 正确做法:feature 间需要共享时做两个动作之一——上提(多个 feature 要用 → 升级为 widgets)或下沉(本质属于某实体 → 归入 entities);shared/ 的准入标准是「与业务词无关」:shared/lib/money.ts(金额分转元)可以,shared/ui/OrderStatusTag 不行(它属于 entities/order/ui)。这条准入标准与 ESLint 规则一起写进 docs/architecture.md。

易错点 19-3:状态大一统——服务端缓存塞进 Redux

❌ 错误做法:把订单列表数据存进 Redux slice(orders: { list, loading, error, page }),提交审核后手动 dispatch(setList(updated)) 同步——结果审核通过了、详情页还是旧状态;换个页面又得再造一份同款 slice。

✅ 正确做法:三分法归置(§19.4)。服务端数据只活在 TanStack Query 的缓存里,提交后 invalidateQueries({ queryKey: orderKeys.all }),列表、详情、时间线同时过期重取。Redux/Zustand 只管「客户端态」。检查口诀:这个 state 的真值在数据库里吗?在 → Query。

易错点 19-4:初始化散落——import 即副作用

❌ 错误做法:dict.ts 模块顶层直接发请求(getDicts() 写在文件顶层)、埋点 SDK 在某个 util 文件 import 时自动 init。后果:测试一 import 就报网络错误;启动顺序取决于打包产物的模块执行顺序,等于把关键路径交给 bundler 抽签。

✅ 正确做法:模块只导出函数与常量,一切副作用收敛到 bootstrap() 显式编排(§19.6),并区分阻塞任务与后台任务;后台任务必须配降级路径(字典预取失败 → 使用时懒加载)。

易错点 19-5:决策靠群聊——ADR 缺位

❌ 错误做法:状态库选型在群里吵两天,最后以「 leader 说用哪个就用哪个」收场,没有留痕。半年后新人问为什么,没人答得上来,于是有人开始悄悄引入第三种方案——架构在无声中腐烂。

✅ 正确做法:任何影响「多模块/多人/超过一个迭代」的技术决策写 ADR(§19.8)。ADR 的价值不在长而在可追溯:五段结构、300 字即可,重点是备选方案的代价与「回顾触发条件」。团队约定:Code Review 中出现「为什么不用 XX」的讨论超过 10 条评论,就该沉淀一篇 ADR 了。

✅ 最佳实践

  1. 先评估后动手:任何架构改造前先产出复杂度评分表,写进 ADR 或方案文档——「为什么拆」比「怎么拆」更需要被记录与挑战;
  2. 依赖规则交给机器:分层写在 wiki 上没人看,写进 ESLint 才有人守;lint 门禁进 CI(第 18 章流水线),违规 PR 无法合入;
  3. 每个切片一个公共出口:index.ts 之外的内部路径禁止外部 import,这是「重构自由」的来源;
  4. 状态归置口诀进 onboarding:「真值在数据库 → Query;跨树共享客户端态 → store 就近;只有自己用 → useState」,三条覆盖 95% 的场景;
  5. queryKey 工厂强制化:用 ESLint 自定义规则或 Code Review 清单确保没有裸字符串 key——缓存失效的正确性全押在 key 的一致性上;
  6. bootstrap 只允许一条:全仓搜索 createRoot( 只允许出现在 app/mount.tsx 一处,副作用入口唯一;
  7. 公共代码「三次法则」:被第二个使用方引用时复制不抽取,第三次引用才下沉为包——过度抽象比重复代码更难还;
  8. Monorepo 起步只建两个目录:apps/ + packages/,包数量 = 真实共享需求,不为「看起来专业」制造空包。

📌 本章小结

  • 架构是对复杂度的响应:先用五维评估表打分,再在「单体分区 / 模块化单体 / 微前端」三条路线里选——轻订单 10 分 + 跨团队信号,选择模块化单体并预留微前接缝;
  • 模块化单体 = 六层结构(app/pages/widgets/features/entities/shared)× 依赖铁律,规则必须配置成 ESLint 边界检查进 CI,靠自觉的架构活不过三个月;
  • 路由按域拆片段、状态三分法归置、queryKey 工厂化、请求层「基座 + 领域 API」——四个子系统的组织约定让每个业务域自成一体;
  • 初始化收敛为显式 bootstrap() 编排,横切关注点插件化;Monorepo 用 pnpm workspace,公共代码遵守「三次法则」;
  • 所有重要决策写 ADR 留痕:五段结构 + 备选代价 + 回顾触发条件,让半年后的新人能站在决策现场。

下一章解决分层结构里最容易失控的一层——shared/ui。当 Button、Table、StatusTag 在三个应用里被复制出五种样式,「抽内部组件库 qing-ui」就从可选项变成了必答题:设计令牌、组件 API 设计、Storybook 文档站与 changesets 版本发布,将把它从零做到一。

📄 第 20 章 · 设计系统与组件库建设 20-设计系统与组件库建设.md

📍 阶段:资深阶段(第 19–24 章)|⏱ 预计学时:8–10 小时|🔗 前置:第 06 章暗色主题 + 第 19 章 Monorepo

⬅ 上一章:大型项目架构设计 | 📖 本版目录 | ➡ 下一章:微前端与跨团队协作

💡 对照学习:本页是 React 版第 20 章,对应 Vue 版第 20 章 · 设计系统与组件库建设。本章方法两版通用,示例代码为 React 生态:设计令牌三层体系、组件 API 设计原则、a11y、changesets 发布流程完全与框架无关;生态差异在于 UI 基座用 Ant Design 5(≈ Element Plus)、文档站用 Storybook React-Vite(≈ Storybook Vue3-Vite)、组件以函数组件 + Hooks 编写(≈ <script setup> SFC)。

第 19 章的分层结构里,shared/ui 是最容易失控的一层:StatusTag 在 admin、portal、营销子系统里被复制了三份,品牌色改一次要发三版。本章把这一层升级为独立内部包 qing-ui——不是重造 Ant Design,而是「设计令牌 + 品牌封装 + 业务组件」的三层体系,并配齐文档站、视觉回归、版本发布与 0→1 路线图。

🎯 学习目标

学完本章,你将能够:

  1. 建立「原始令牌 → 语义令牌 → 组件令牌」三层设计令牌体系,并说明它与第 06 章 data-theme 暗色方案如何衔接;
  2. 使用 Ant Design 5 的 ConfigProvider token 体系完成品牌定制(主色、圆角、暗色算法),解释为什么定制要走 token 层而不是 CSS 覆盖;
  3. 应用组件 API 设计四原则(受控优先、组合优于配置、最小 API 面、可预测命名)评审并重构一个组件;
  4. 实现「受控/非受控」双模式组件(useControllableState 模式),并举出各自的适用场景;
  5. 对比 CSS-in-JS、CSS Modules、Tailwind 三种样式方案,并为内部组件库做出有理由的选型;
  6. 搭建 Storybook 文档站:CSF3 stories、autodocs、a11y 检查与截图视觉回归;
  7. 完成 StatusTag / AmountInput 的无障碍改造(键盘可达、不单靠颜色传达信息、对比度达标);
  8. 配置 changesets 版本管理流程并演练发布到 npm 私服(.npmrc 全部走环境变量),输出 qing-ui 0→1 路线图。

📖 核心知识点

20.1 qing-ui 的定位:三层封装,而不是重造轮子

内部组件库的第一课是想清楚不做什么。Ant Design 5 的通用组件(Table、Form、DatePicker)比任何内部团队维护得都好,qing-ui 重写它们是纯亏损。正确的定位是三层:

┌────────────────────────────────────────────────────────────┐
│  第三层 · 业务组件(qing-ui 独有价值)                        │
│  OrderStatusTag / AmountInput / CustomerPicker /             │
│  OrderTimeline —— 领域词直接出现在 API 里                     │
├────────────────────────────────────────────────────────────┤
│  第二层 · 基础组件封装(薄壳:默认值 + 行为统一)              │
│  QButton(默认 size/风险色策略)/ QTable(分页默认 20、       │
│  空态统一文案)/ QModal(危险操作二次确认协议)                │
├────────────────────────────────────────────────────────────┤
│  第一层 · 设计令牌(品牌规范唯一的机器可读事实源)             │
│  品牌色板 / 语义色 / 圆角 / 间距 / 字号 —— 驱动 AntD 主题     │
└────────────────────────────────────────────────────────────┘
依赖方向:业务组件 → 基础封装 → 令牌;AntD 作为 peerDependency 只从令牌层接触。

收益立竿见影:运营提「把品牌主色从蓝换成青」,改的是令牌层一个值;「所有删除操作必须二次确认」,改的是 QModal 的协议;「订单状态标签统一加图标」,改的是 OrderStatusTag 一处。业务语义沉淀一次,全应用生效——这正是第 19 章「三次法则」等到的那个下沉时刻。

20.2 设计令牌三层体系与暗色主题

令牌分三层,层与层之间有严格的引用方向:

层 命名示例 谁定义 谁消费
原始层(primitive) qing-blue-500、gray-100 设计师色板 只被语义层引用
语义层(semantic) color-bg-surface、color-text-primary 设计规范 组件与样式;随主题切换值
组件层(component) StatusTag-bg-pending 组件实现 组件内部,必须指向语义层
// packages/qing-ui/src/tokens/primitive.ts —— 原始层:与「用途」无关
export const palette = {
  blue500: '#2f6bff',
  blue600: '#1f52d6',
  gray50: '#f7f8fa',
  gray900: '#1c2333',
  green600: '#16a34a',
  amber600: '#d97706',
  red600: '#dc2626',
} as const;
/* packages/qing-ui/src/tokens/semantic.css —— 语义层:亮色为默认,暗色覆盖
   与第 06 章门户的 data-theme 方案完全同构——同一套令牌,两个应用共享 */
:root {
  --qing-color-primary: #2f6bff;
  --qing-color-bg-surface: #ffffff;
  --qing-color-bg-page: #f7f8fa;
  --qing-color-text-primary: #1c2333;
  --qing-color-success: #16a34a;
  --qing-color-warning: #d97706;
  --qing-color-danger: #dc2626;
}

:root[data-theme='dark'] {
  --qing-color-primary: #5c8dff;
  --qing-color-bg-surface: #1e2432;
  --qing-color-bg-page: #141926;
  --qing-color-text-primary: #e8ecf5;
  --qing-color-success: #4ade80;
  --qing-color-warning: #fbbf24;
  --qing-color-danger: #f87171;
}

Ant Design 5 的主题定制走 ConfigProvider 的 token(不要用 CSS 选择器硬覆盖,原因见易错点 20-2)。暗色模式由 darkAlgorithm 算法负责推导,与上面的 CSS 变量层各管各的生态:

// packages/qing-ui/src/providers/QingProvider.tsx —— 主题入口:一处配置全库生效
import { ConfigProvider, theme as antdTheme, App as AntdApp } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import type { ReactNode } from 'react';
import '../tokens/semantic.css';

export interface QingProviderProps {
  themeMode: 'light' | 'dark';
  children: ReactNode;
}

export function QingProvider({ themeMode, children }: QingProviderProps) {
  return (
    <ConfigProvider
      locale={zhCN}
      theme={{
        token: {
          colorPrimary: '#2f6bff',
          colorSuccess: '#16a34a',
          colorWarning: '#d97706',
          colorError: '#dc2626',
          borderRadius: 6,
          fontFamily: "'PingFang SC', 'Microsoft YaHei', sans-serif",
        },
        algorithm: themeMode === 'dark' ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
      }}
    >
      <AntdApp>{children}</AntdApp>
    </ConfigProvider>
  );
}

衔接第 06 章:门户的 useTheme Hook 读写 localStorage 并切换 document.documentElement.dataset.theme——这个值现在同时驱动两件事:CSS 变量层(:root[data-theme='dark'])和 AntD 算法层(QingProvider 的 themeMode)。一个状态源,两个消费方,这就是「三层方案衔接」的全部含义:不另起炉灶,把门户已有的主题开关升级为全站主题的事实源。

20.3 组件 API 设计原则:让组件「猜得到用法」

组件库的寿命由 API 质量决定。四条原则,按优先级排列:

  1. 受控优先,但保留非受控:value/onChange 受控满足表单联动(订单金额汇总),defaultValue 非受控满足简单场景(弹窗开关);
  2. 组合优于配置:需求开始「堆 prop」时(closable、footerRender、extraButton...),改用组合把结构交给调用方;
  3. 最小 API 面:每个 prop 都是长期维护承诺。加一个 prop 只要 5 分钟,养它三年;
  4. 命名词法可预测:遵循社区惯例(见下表),调用方「猜」的应该是对的一半以上。
意图 约定命名 反例
事件回调 onXxx(onChange、onSubmit) handleChange、submitEvent
布尔开关 disabled、loading、readOnly isDisabled、enableXxx
受控值/初值 value / defaultValue data / initVal
尺寸 size: 'sm' | 'md' | 'lg' size: '小' | '中'
透传钩子 className、style、...rest wrapClass、boxStyle

双模式组件的通用实现模式是 useControllableState——这是写「表单级」组件的基本功:

// packages/qing-ui/src/hooks/useControllableState.ts
import { useCallback, useState } from 'react';

export interface ControllableOptions<T> {
  value?: T;                        // 受控值:调用方持有真值
  defaultValue?: T;                 // 非受控初值:组件内部持有真值
  onChange?: (value: T) => void;    // 两种模式都要回调,调用方永远不丢事件
}

export function useControllableState<T>({ value, defaultValue, onChange }: ControllableOptions<T>) {
  const isControlled = value !== undefined;
  const [inner, setInner] = useState<T>(defaultValue as T);
  const current = isControlled ? (value as T) : inner;

  const set = useCallback(
    (next: T) => {
      if (!isControlled) setInner(next); // 非受控:内部更新
      onChange?.(next);                  // 受控:真值由外部 onChange 改
    },
    [isControlled, onChange],
  );

  return [current, set] as const;
}

组合优于配置的典型对照——需求是「危险确认弹窗」:

// 配置式:每加一种按钮组合就要加一个 prop,API 面无限膨胀(反例)
<QConfirmModal okText="确认" cancelText="再想想" dangerType="primary"
  iconPlacement="left" footerAlign="end" showCloseButton={false} ... />

// 组合式:结构交给调用方,qing-ui 只固化「协议」(推荐)
import { QModal, QButton } from '@qing/ui';

<QModal open={open} onClose={close} role="alertdialog">
  <QModal.Title>删除订单 {order.orderNo}?</QModal.Title>
  <QModal.Body>删除后不可恢复,操作将记入审计日志。</QModal.Body>
  <QModal.Footer>
    <QButton variant="text" onClick={close}>再想想</QButton>
    <QButton variant="danger" onClick={confirmDelete}>确认删除</QButton>
  </QModal.Footer>
</QModal>

20.4 样式方案取舍:给组件库选一种,给应用层留自由

维度 CSS-in-JS(emotion / styled-components) CSS Modules Tailwind CSS
运行时开销 有(或用零运行时方案如 panda/linaria) 无 无
与令牌结合 变量/主题对象直读,动态主题强 var(--qing-*) 直读 任意值语法 bg-[var(--qing-*)] 或配置映射
SSR/岛屿兼容 需要样式提取配置 天然友好 天然友好(第 24 章 v2 的前置考量)
团队心智 需要学习,babel 配置 几乎零成本 需要 class 命名约定的纪律
第三方库封装难度 高(类名不可控) 低 低
适合谁 设计驱动、重动态主题的产品 内部组件库(推荐) 应用层快速迭代页面

轻订单结论:qing-ui 包内用 CSS Modules + CSS 变量令牌——零运行时、与第 06 章变量方案无缝、封装第三方(AntD)时无样式冲突;应用层(admin 页面、portal 门户)允许 Tailwind 提效,但规定 Tailwind 只通过配置读取 --qing-* 变量做颜色,禁止硬编码色值。这个组合的判断依据记入 ADR-012,供未来推翻。

// packages/qing-ui/src/components/StatusTag/StatusTag.module.css
.tag {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 2px 10px;
  border-radius: calc(var(--qing-radius-base, 6px) - 2px);
  font-size: 12px;
  line-height: 20px;
}
.pending { background: color-mix(in srgb, var(--qing-color-warning) 12%, transparent); }
/* ...每个状态一个 modifier,色值全部来自语义令牌 */

20.5 Storybook:文档站、a11y 检查与视觉回归

组件库没有文档站等于不存在——「看代码才知道怎么用」的库一定会被绕过。Storybook 三件套配置如下:

// packages/qing-ui/.storybook/main.ts
import type { StorybookConfig } from '@storybook/react-vite';

const config: StorybookConfig = {
  stories: ['../src/**/*.stories.@(ts|tsx)'],
  addons: ['@storybook/addon-essentials', '@storybook/addon-a11y'],
  framework: { name: '@storybook/react-vite', options: {} },
  staticDirs: ['../public'],
};
export default config;
// packages/qing-ui/src/components/StatusTag/StatusTag.stories.tsx —— CSF3 写法
import type { Meta, StoryObj } from '@storybook/react';
import { StatusTag } from './StatusTag';

const meta: Meta<typeof StatusTag> = {
  title: 'Business/StatusTag',
  component: StatusTag,
  tags: ['autodocs'],                       // 自动生成 API 文档页
  argTypes: {
    status: {
      control: 'select',
      options: ['draft', 'pending_review', 'approved', 'shipped', 'completed', 'rejected', 'cancelled'],
    },
    showIcon: { control: 'boolean' },
  },
};
export default meta;
type Story = StoryObj<typeof StatusTag>;

export const Default: Story = { args: { status: 'pending_review', showIcon: true } };

export const AllStatuses: Story = {        // 一屏看全部状态:视觉回归的主战场
  render: () => (
    <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
      {(['draft', 'pending_review', 'approved', 'shipped', 'completed', 'rejected', 'cancelled'] as const)
        .map((s) => <StatusTag key={s} status={s} showIcon />)}
    </div>
  ),
};

视觉回归的务实做法(不必上重型平台也能起步):用 Playwright 对关键 story 截图,与基线 diff——

// packages/qing-ui/scripts/visual.spec.ts —— 关键组件截图比对(Playwright)
import { test, expect } from '@playwright/test';

test('StatusTag 全状态快照', async ({ page }) => {
  await page.goto('/iframe.html?id=business-statustag--all-statuses&viewMode=story');
  const story = page.locator('#storybook-root');
  await expect(story).toBeVisible();
  await expect(story).toHaveScreenshot('statustag-all.png', {
    maxDiffPixelRatio: 0.01,   // 容忍 1% 像素差(抗渲染抖动)
  });
});

流程约定:改动视觉 → 本地 pnpm visual 生成 diff 图 → diff 贴进 PR → reviewer 审「像素变化是否与意图一致」→ 确认后 --update-snapshots 更新基线。CI 里截图不符即红灯(接第 18 章流水线)。组件数量超过 30 个、截图任务超 10 分钟时,再评估迁移到 Chromatic 这类托管平台(占位符 --project-token=<your-chromatic-token>,token 走 CI secrets)。

20.6 a11y 基础:StatusTag 的三处改造

无障碍不是合规清单,是「信息传达的健壮性」。用贯穿项目的 StatusTag 演示三个最常见问题:

// packages/qing-ui/src/components/StatusTag/StatusTag.tsx
import { useId } from 'react';
import styles from './StatusTag.module.css';
import type { OrderStatus } from '@qing/shared-types';

const STATUS_META: Record<OrderStatus, { label: string; mod: string }> = {
  draft: { label: '草稿', mod: 'draft' },
  pending_review: { label: '待审核', mod: 'pending' },
  approved: { label: '已审核', mod: 'approved' },
  shipped: { label: '已发货', mod: 'shipped' },
  completed: { label: '已完成', mod: 'completed' },
  rejected: { label: '已拒绝', mod: 'rejected' },
  cancelled: { label: '已取消', mod: 'cancelled' },
};

export interface StatusTagProps {
  status: OrderStatus;
  showIcon?: boolean;   // 改造②:不单靠颜色——图标 + 文字双通道传达
}

export function StatusTag({ status, showIcon = true }: StatusTagProps) {
  const meta = STATUS_META[status];
  return (
    <span
      className={[styles.tag, styles[meta.mod]].join(' ')}
      // 改造①:给纯视觉标记补语义;③:文字对比度 ≥ 4.5:1 在 CSS 层保证
    >
      {showIcon && <StatusDot mod={meta.mod} aria-hidden="true" />}
      {meta.label}
    </span>
  );
}

三处改造对应三条 a11y 基线,写进组件库验收清单:① 信息不单靠颜色(色盲用户能看到图标与文字);② 语义完整(装饰性图形 aria-hidden,交互控件保证键盘可达与焦点可见,outline 不许被随手删掉);③ 对比度达标(正文 ≥ 4.5:1,大字 ≥ 3:1)。Storybook 的 a11y addon 会用 axe 在每个 story 上自动跑这三条,红灯挡在合并前。

20.7 changesets:多人协作的版本与发布流水线

内部库发布的三大乱象:版本号手改、changelog 凭记忆、 breaking change 无声上线。changesets 用一套流程根治:

开发者提 PR ──► 附带一个 changeset 文件(声明改了什么包、什么级别、写了什么说明)
     │
     ▼
CI(changesets/action)──► 检测到 changeset → 自动生成/更新「Version PR」:
                            bump 版本号 + 汇总 CHANGELOG.md
     │
     ▼
维护者合并 Version PR ──► CI 自动执行 changeset publish → 发布到 npm 私服
     │
     ▼
应用仓库 pnpm up @qing/ui ──► 消费方看到 CHANGELOG 才决定升不升级
// .changeset/config.json —— qing-ui 仓库(Monorepo 根)的 changesets 配置
{
  "$schema": "https://unpkg.com/@changesets/config/schema.json",
  "changelog": ["@changesets/cli/changelog", { "repo": "qingorder/qing-ui" }],
  "commit": false,
  "access": "restricted",
  "baseBranch": "main",
  "updateInternalDependencies": "patch",
  "ignore": []
}
<!-- .changeset/brave-lions-run.md —— PR 里随代码提交的变更声明(changeset add 自动生成文件名) -->
---
'@qing/ui': minor
---

StatusTag 新增 showIcon 属性(默认开启),未传时行为与旧版一致;AmountInput 修复千分位在中文输入法下的光标跳动。

发布目标用 npm 私服(内网verdaccio/Nexus 均可,充当上游 npm 的代理缓存)。凭据只走环境变量,.npmrc 里永远不出现明文 token:

# .npmrc(仓库内,可提交)—— registry 指向内网私服
registry=https://npm-registry.qingorder.example.com/
always-auth=true
# .github/workflows/release.yml(节选)—— token 注入在运行时完成
name: Release
on:
  push:
    branches: [main]
jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: pnpm, registry-url: 'https://npm-registry.qingorder.example.com/' }
      - run: pnpm install --frozen-lockfile
      - name: Publish(若有新版本)
        run: pnpm changeset publish
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_PRIVATE_TOKEN }}   # 私服令牌只存 CI secrets

版本纪律用 semver 表述:修 bug → patch(0.1.x);加属性且向后兼容 → minor(0.x.0);改已有 prop 语义/删除 → major,并且必须在 changeset 说明里写迁移示例。0.x 阶段允许 minor 携带小破坏(社区惯例),但 qing-ui 1.0 之后零容忍。

20.8 qing-ui 0→1 路线图:四个里程碑,每步都交付价值

里程碑 周期 交付物 验收标准
M0 立项 0.5 周 定位文档(三层封装边界)、样式方案 ADR-012 「不做什么」清单经团队评审通过
M1 令牌 + 3 组件 1.5 周 令牌三层 + QingProvider + StatusTag/AmountInput/QButton admin 用上且删掉本地副本;暗色可用
M2 文档站 + CI 1 周 Storybook(autodocs + a11y)、lint/test/构建流水线 每个 story 有 a11y 零违规;CI 全绿
M3 版本与发布 1 周 changesets + 私服 + 视觉回归基线 完成 0.1.0 → 0.2.0 演练发布;CHANGELOG 自动生成
M4 迁移推广 持续 admin/portal 存量组件逐批替换 每批替换 PR 附 before/after 截图;覆盖率看板

路线图的关键决策是迁移节奏与功能范围解耦:M1–M3 全部完成后库才「可用」,但 admin 的迁移从 M1 就可以开始(直接 workspace 引用),不必等 1.0。避免两种失败模式:闭门造车一年才见第一个调用方;或者反过来,边维护边打补丁导致 API 永不稳定。

🛠 渐进式实战练习

练习 1 · 基础(10–20 min):把 StatusTag 收编进 qing-ui

任务描述:在 Monorepo 中创建 packages/qing-ui(参照 §19.7 的 source 引用方式),实现令牌三层(primitive.ts + semantic.css)、QingProvider 与 StatusTag(含 7 种订单状态、图标与文字双通道),并在 admin 的订单列表里替换掉本地 StatusTag 实现。

提示:状态语义直接复用附录A §5 的 OrderStatus 类型(从 @qing/shared-types 引入,不要复制类型);替换后删除 admin 里的旧组件,用 E2E 冒烟集确认列表页无回归。

验收标准:① admin 通过 @qing/ui 引入 StatusTag,本地副本已删除;② 切换暗色主题后标签样式正确(CSS 变量随 data-theme 生效);③ Storybook 中 AllStatuses story 一屏可见 7 种状态。

参考答案(关键点核对)

核对清单:① packages/qing-ui/package.json 的 exports 指向 ./src/index.ts(source 引用),admin 依赖写 "@qing/ui": "workspace:*";② StatusTag 的 modifier 类名经语义令牌取色,组件内不出现 # 开头的字面色值;③ QingProvider 同时接管 ConfigProvider(AntD 算法)与 semantic.css 的 import;④ admin 的 main.tsx 中把原先的 ConfigProvider 替换为 QingProvider,useTheme 的状态同时喂给 themeMode 与 document.documentElement.dataset.theme——常见遗漏是只接了 CSS 变量层,暗色下 AntD 组件(表格、弹窗)仍是亮色。

练习 2 · 进阶(30–60 min):AmountInput —— 双模式 + 千分位 + a11y

任务描述:实现 AmountInput:输入/展示千分位格式(如 1,280.50),内部以「分」为单位与调用方交换值(附录A 金额口径);支持受控(value 单位分 + onChange)与非受控(defaultValue)双模式;单位切换标签(元/千元);通过 Storybook a11y addon 检查(含 label 关联与键盘操作)。

提示:格式化与解析是纯函数,放 shared/lib/money.ts 并配 Vitest 单测(浮点陷阱见第 15 章:0.1 + 0.2 !== 0.3,用「分」的整数运算);输入过程中不要边打边格式化,在 onBlur 时才格式化展示,否则光标会乱跳。

验收标准:① 双模式行为正确(受控时外部改 value 立即生效;非受控时内部自转);② showThousands 千分位展示正确,粘贴/输入法场景不崩;③ a11y addon 零违规:label 关联(htmlFor)、aria-describedby 提示单位、数字键与退格全键盘可用;④ money.ts 单测覆盖「分↔元」「非法输入」「负数拒绝」三类用例,覆盖率 100%。

参考答案(组件骨架 + 格式化核心)
// packages/qing-ui/src/components/AmountInput/AmountInput.tsx(骨架)
import { useId, useState } from 'react';
import { useControllableState } from '../../hooks/useControllableState';
import { centsToDisplay, displayToCents } from '../../lib/money';
import styles from './AmountInput.module.css';

export interface AmountInputProps {
  /** 受控值,单位:分 */
  value?: number;
  /** 非受控初值,单位:分 */
  defaultValue?: number;
  onChange?: (cents: number) => void;
  disabled?: boolean;
  unitLabel?: string;          // a11y:单位说明走 aria-describedby
}

export function AmountInput(props: AmountInputProps) {
  const { onChange, disabled, unitLabel } = props;
  const [cents, setCents] = useControllableState<number>(props); // 双模式核心
  const [raw, setRaw] = useState<string>(cents != null ? centsToDisplay(cents) : '');
  const id = useId();

  const commit = (text: string) => {
    const next = displayToCents(text);        // 解析失败返回 null
    if (next != null) {
      setCents(next);                         // 受控→回调外部;非受控→内部更新
      setRaw(centsToDisplay(next));           // blur 时才做千分位格式化
    }
  };

  return (
    <span className={styles.wrap}>
      <input
        id={id}
        className={styles.input}
        inputMode="decimal"
        disabled={disabled}
        value={raw}
        onChange={(e) => setRaw(e.target.value)}   // 输入中:存原文,不格式化
        onBlur={(e) => commit(e.target.value)}      // 失焦:解析 + 格式化 + 回调
        aria-describedby={unitLabel ? `${id}-unit` : undefined}
      />
      {unitLabel && <span id={`${id}-unit`} className={styles.unit}>{unitLabel}</span>}
    </span>
  );
}
// packages/qing-ui/src/lib/money.ts —— 金额格式化(分单位,纯函数)
export function centsToDisplay(cents: number): string {
  const negative = cents < 0 ? '-' : '';
  const abs = Math.abs(Math.round(cents));            // 全程整数运算,规避浮点误差
  const yuan = Math.floor(abs / 100);
  const fen = String(abs % 100).padStart(2, '0');
  const [int, dec] = [String(yuan), fen];
  const withComma = int.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
  return `${negative}¥${withComma}.${dec}`;
}

export function displayToCents(text: string): number | null {
  const cleaned = text.replace(/[¥,\s]/g, '');
  if (!/^\d+(\.\d{1,2})?$/.test(cleaned)) return null; // 非法输入一律拒绝
  const [int, dec = ''] = cleaned.split('.');
  return Number(int) * 100 + Number((dec + '00').slice(0, 2));
}

练习 3 · 挑战(选做,60+ min):发布流水线 0→1 演练

任务描述:为 qing-ui 仓库完整搭起发布链路:配置 .changeset/、verdaccio 本地私服(docker run 一条命令即可)、.npmrc 环境变量注入、changesets/action 的 Version PR 流程;然后真实演练一次「加属性 → 提 changeset → Version PR → 合并 → publish 0.1.0 → admin 升级」的闭环。

提示:本地演练用 pnpm changeset:version + pnpm changeset:publish 手动跑等价流程即可验证配置正确性;verdaccio 的 uplinks 配置上游为 https://registry.npmjs.org/,令牌用 npm adduser --registry <内网地址> 生成,存进本地环境变量 NPM_PRIVATE_TOKEN。

验收标准:① 私服上能看到 @qing/ui@0.1.0 与自动生成的 CHANGELOG;② 故意提交一个 breaking changeset(删 prop),Version PR 正确 bump 到 1.0.0 并在 CHANGELOG 标注迁移说明(随后回滚该 changeset,保留 0.x 线);③ admin 仓库 pnpm up @qing/ui 后构建通过。

参考答案(流程核对清单)

演练顺序:① pnpm dlx changeset 在 PR 分支生成 changeset 文件并随代码合入;② main 分支 CI 由 changesets/action 创建 Version PR(版本号、CHANGELOG 都在这个 PR 里);③ 合并 Version PR → action 自动 changeset publish(需要 NODE_AUTH_TOKEN 环境 → 私服);④ admin 侧 pnpm up @qing/ui 验证。常见翻车点:access: "restricted" 但私服未登录 → 403;.npmrc 写死 token(安全红线,必须环境变量);publishConfig.registry 指向官方 npm(内网演练时务必改为私服地址)。breaking 演练后回滚方式:git revert Version PR + 私服 unpublish(或直接把版本修回 0.2.x 再发)——内网私服允许 unpublish,公网 npm 规则更严格,这也是选私服先行演练的原因。

⚠️ 常见易错点

易错点 20-1:API 面面俱到——组件库维护爆炸

❌ 错误做法:业务方提什么就加什么 prop:QTable 半年长出 47 个 props(showBorder、headerBg、emptyImage……),每个 prop 组合都要测试与文档;一个 prop 的 bug 报修牵动 12 个调用方。组件库从「加速器」变成「排队点」。

✅ 正确做法:加 prop 前过三问——「能不能用现有 API 组合出来?」「第二个调用方在哪?」「这个需求是不是属于业务层而不是通用层?」。多数「定制需求」用组合(children/render)与 ...rest 透传解决;确需新增时先写 changeset 说明影响面(§20.7),让改动有 changelog、有迁移示例。API 面每少一个,未来三年少还一笔债。

易错点 20-2:用 !important 深层覆盖第三方样式

❌ 错误做法:global.css 里写 .ant-table-tbody > tr:nth-child(2n) td { background: #f0f7ff !important; }。AntD 5 升级调整了内部类名,斑马纹一夜失效;多套覆盖互相打架,排查样式问题时全靠碰运气。

✅ 正确做法:AntD 5 一切视觉定制走 ConfigProvider.theme.token / components(组件级 token);令牌层没有的能力(如结构布局差异)用封装组件(QTable 组合出默认行为)而不是 CSS 猜类名。类名是第三方库的私有实现,token 才是它承诺的公共 API——这条同样适用于 Element Plus 等一切组件库。

易错点 20-3:把业务逻辑写进基础组件

❌ 错误做法:为了「方便统计」,在 QButton 内部直接调用 track() 埋点并读取订单上下文;QModal 里 import 了 @/entities/order。基础组件与业务域耦合后,qing-ui 无法被 portal/营销子系统复用,且库内测试需要 mock 业务模块。

✅ 正确做法:分层红线(§20.1):qing-ui 只依赖令牌层与 AntD,不知道订单是什么。埋点这类横切需求用第 19 章的插件化方案(在应用层给 axios/路由统一挂埋点)或事件回调(QButton 只负责 onClick,埋点由调用方决定);业务上下文通过 props 注入,组件内不 import 任何 entities/。

易错点 20-4:版本号手改、changelog 凭记忆

❌ 错误做法:发布前手动把 package.json 的 version 从 0.1.0 改成 0.1.1,CHANGELOG.md 手写「更新了一些东西」。三个应用各自 pin 着不同版本,没人说得清生产环境跑的是哪个——出了 bug 连「该看哪个版本的源码」都要考古。

✅ 正确做法:版本号只由 changesets 产生(§20.7):PR 附 changeset 声明级别(patch/minor/major),Version PR 自动 bump 并汇总 CHANGELOG,合并即发布。配套纪律:admin/portal 的依赖用 ^0.2.0 区间 + lockfile 锁定,升级走独立 PR 让 CI 全量验证;发版后打 git tag 便于回溯生产版本。

易错点 20-5:视觉回归「全量截图、无基线策略」

❌ 错误做法:给全部 60 个 story × 3 个视口截 180 张图,任何字体渲染差异都红灯;基线更新随手 --update-snapshots 糊过去。三个月后团队开始闭眼点「重跑」,视觉回归沦为玄学仪式。

✅ 正确做法:从「关键少数」起步——只截 API 面宽、视觉复杂的组件(Table、Modal、AmountInput 的 AllStatuses 类 story);maxDiffPixelRatio 容忍渲染抖动;基线更新必须与 PR 关联(diff 图贴进描述,reviewer 确认「变化符合意图」才允许更新); CI 红灯的默认反应是看 diff,而不是重跑。

✅ 最佳实践

  1. 先定「不做什么」再动手:qing-ui 的立项文档第一页是边界清单(不重写 Table/Form、不做业务请求、不做埋点)——内部库死于范围膨胀比死于技术缺陷多得多;
  2. 令牌是唯一视觉事实源:组件与页面禁止字面色值,全部走 --qing-* 语义变量;品牌换肤、暗色模式因此是一次配置而非一场战役;
  3. 双模式组件是表单组件的标准形态:useControllableState + onChange 永远回调,受控/非受控由调用方自由选择,一句话写进每个表单组件的文档首行;
  4. Storybook 是组件库的官网:每个组件至少一个「全家福」story(AllStatuses 型),autodocs 自动生成的 API 表就是给业务方的说明书,README 只留入口链接;
  5. a11y 三条基线写进验收:不单靠颜色、语义完整(aria/键盘)、对比度达标,axe 检查红灯 = CI 红灯;
  6. changesets 从第一天就上:哪怕只有自己一个人发版——流程自动化建设在「没人违规」时成本最低,等混乱发生再建规范要还三倍利息;
  7. 凭据只走环境变量:.npmrc、CI 日志、文档截图里不允许出现真实 token 字面量,占位符统一 <your-token> 形式(安全红线);
  8. 迁移与建设并行:M1 起就有真实调用方(admin workspace 引用),每替换一个组件就删一份本地副本——库的价值由调用次数证明,不由组件数量证明。

📌 本章小结

  • qing-ui 的定位是三层封装:令牌(品牌事实源)→ 基础薄封装(默认值与协议)→ 业务组件(领域语义),不重造 Ant Design 的轮子;
  • 令牌三层(原始/语义/组件)向下单向引用;暗色主题一个 data-theme 状态源同时驱动 CSS 变量层与 AntD darkAlgorithm,与第 06 章门户方案无缝衔接;
  • 组件 API 四原则:受控优先、组合优于配置、最小 API 面、命名词法可预测;useControllableState 是表单类组件的基本功;
  • 样式选型「库内 CSS Modules + CSS 变量、应用层 Tailwind 读变量」;Storybook 三件套(autodocs、a11y、关键 story 视觉回归)让组件库「有官网、有体检、有监控」;
  • changesets + 私服让版本、CHANGELOG、发布全部自动化,凭据只走环境变量;0→1 路线图四个里程碑,M1 起就有真实调用方。

qing-ui 让多个应用共享了「零部件」,但它们仍是各自独立部署的整站。当第三方营销子系统带着自己的技术栈与发布节奏敲上门,「整站一体」的架构就到了边界——下一章讨论微前端:先回答要不要拆,再回答怎么拆、怎么隔离、怎么通信、怎么渐进迁移。

📄 第 21 章 · 微前端与跨团队协作 21-微前端与跨团队协作.md

📍 阶段:资深阶段(第 19–24 章)|⏱ 预计学时:7–9 小时|🔗 前置:第 19 章 Monorepo + 第 20 章 qing-ui

⬅ 上一章:设计系统与组件库建设 | 📖 本版目录 | ➡ 下一章:团队协作规范

💡 对照学习:本页是 React 版第 21 章,对应 Vue 版第 21 章 · 微前端与跨团队协作。本章方法两版通用,示例代码为 React 生态:「要不要拆」评估、四方案对比、隔离/通信/依赖治理的原理完全与框架无关;生态差异在于示例用 qiankun/wujie/micro-app 的 React 接入写法(≈ Vue 版对应的 wujie-vue3 / @micro-zoe/micro-app 的 Vue 用法),以及 Module Federation 与 React 生态(Webpack 5 / Vite 插件)的结合方式。

第 19 章的 ADR-011 留了一个待决策问题:第三方供应商的「营销子系统」(优惠券、活动报名)要接入轻订单平台,它有自己的技术栈选型、发布节奏,永远不可能合入主仓。本章先回答要不要拆,再比较四种拆法,最后在轻订单上完成真实接入与一次渐进迁移演练。

🎯 学习目标

学完本章,你将能够:

  1. 用「适用/不适用清单」对一个接入需求做判断,说出微前端的真实收益(团队边界与独立发布)与代价(复杂度、体验一致性);
  2. 从隔离、通信、改造成本、生态四个维度对比 qiankun / micro-app / wujie / Module Federation,并按场景给出选型理由;
  3. 讲清 JS 沙箱(Proxy 沙箱与快照沙箱)与 CSS 隔离(Shadow DOM、scoped 选择器、命名约定)的原理与各自的坑;
  4. 设计应用间通信矩阵:props 下发、事件总线、URL 传参、共享存储,并为每种通道写明「只准放什么」;
  5. 制定公共依赖治理策略(externals / import maps / MF shared singleton),并说出「共享哪些、绝不共享哪些」;
  6. 整合基座与子应用的路由与部署:activeRule 约定、动态 publicPath、跨域拉取 entry 的 CORS 配置;
  7. 完成轻订单平台接入第三方营销子系统的完整案例(契约清单 + 联调步骤);
  8. 用 Strangler(绞杀者)模式制定存量系统的渐进迁移计划,保证每一步可回滚。

📖 核心知识点

21.1 先问要不要拆:微前端是组织问题的解,不是技术炫技

微前端把「整站一体」拆成「壳 + 多个可独立发布的子应用」,引入的复杂度是实打实的(沙箱、通信、依赖治理、部署面 ×N)。它的收益几乎全部来自「组织」而非「技术」——先用两张清单拷问需求:

微前端的适用信号(满足越多越该拆) 不适用的信号(满足任何一条先冷静)
多个团队各自维护一个业务域,发布节奏互相牵制 单团队项目,只是「想体验新技术」
子应用技术栈/版本确实无法统一(第三方、历史包袱) 技术栈统一,痛点只是目录乱(第 19 章分层即可解决)
需要子应用独立部署、独立回滚,发布窗口冲突频繁 团队规模 ≤ 5 人、月需求吞吐低(§19.1 评分 ≤ 10 分)
子应用有独立的生命周期(快速试错、可能下线) 页面间强耦合、共享大量实时交互状态
聚合后用户仍获得一体体验(统一壳/登录/导航) 追求极致首屏性能(每层聚合都增加运行时成本)

轻订单的判断:营销子系统由供应商维护(团队边界真实存在)、需要独立发布(活动上下线以天计)、登录态与视觉需要打通(不能 iframe 简单糊弄)、主站技术栈 React 19 —— 拆是合理的,且只拆这一个子系统:admin 的订单/客户/商品仍然一体化构建(它们没有团队边界,拆了纯属自找复杂度)。

资深的克制体现在:微前端是手术刀,只切需要切的位置。全站微前端化在 90% 的企业里是负资产。

21.2 四方案对比:qiankun / micro-app / wujie / Module Federation

维度 qiankun micro-app wujie Module Federation
原理 基于 single-spa,JS 沙箱 + HTML entry WebComponent 封装(<micro-app> 元素) iframe 跑 JS + ShadowDOM 渲染 DOM 构建期声明、运行时共享模块
JS 隔离 Proxy 沙箱(旧浏览器退化为快照沙箱) Proxy 沙箱(新版可 iframe 模式) iframe 天然隔离(最强) 无沙箱,同 window 同上下文
CSS 隔离 Shadow DOM(strict)或 scoped 前缀(experimental) 自带 scoped,支持 Shadow DOM ShadowDOM 天然隔离(弹窗位置需处理) 无,靠命名约定/Modules 自律
改造成本 需暴露 mount/unmount 生命周期 极低,接近 iframe 的使用心智 极低,几乎零改造(保活/预加载好) 需构建插件配置(Webpack5 原生 / Vite 插件)
通信 props + initGlobalState data 属性 + 事件 props + window.$wujie 事件总线 导入导出(直接 import 远程模块)
跨技术栈子应用 支持(任何能挂载的页面) 支持 支持(iframe 优势,多框架最省心) 同栈最优(跨栈需各栈都有插件)
生态/维护 社区最大、资料最多、坑都有人踩过 京东系,文档清晰 腾讯系,主打「像 iframe 一样简单」 Webpack 官方、Rspack/Vite 插件承接
典型场景 中后台多子应用聚合(本教程场景) 快速接入、低改造诉求 子应用异构复杂、保活需求多 同栈(React 家族)模块级共享与独立部署

选型一句话版:异构子应用 + 多框架 → wujie/micro-app 优先(改造成本低);中后台经典聚合 + 求稳 → qiankun(生态最大);同栈 React 且要模块级共享/独立部署 → Module Federation。轻订单的两个接缝分别用了两种:营销子系统(异构、供应商)用 wujie 接入;未来自家拆出的「报表平台」若要独立部署则用 MF(同为 React 家族,共享组件最自然)。

四种方案的接入心智对比(示意代码,API 以官方文档为准):

// 方案 A · qiankun:注册式聚合(基座配置)
import { registerMicroApps, start } from 'qiankun';

registerMicroApps([
  {
    name: 'marketing',
    entry: '//marketing.qingorder.example.com',   // 子应用独立部署的 HTML 入口
    container: '#subapp-container',
    activeRule: '/marketing',                     // 路由前缀命中即挂载
    props: { token: session.accessToken, themeMode, onNavigate: router.push },
  },
]);
start({ prefetch: 'all', sandbox: { experimentalStyleIsolation: true } });
// 方案 B · micro-app:元素式接入(一行像 iframe)
import microApp from '@micro-zoe/micro-app';

microApp.start();
// JSX 中直接使用自定义元素:
<micro-app name="marketing" url="https://marketing.qingorder.example.com/" base-route="/marketing" data={sharedProps} />
// 方案 C · wujie:iframe 心智 + 组件式(本教程案例采用)
import WujieReact from 'wujie-react';

<WujieReact
  name="marketing"
  url="https://marketing.qingorder.example.com/"
  sync                             // 子应用路由与基座 URL 同步(刷新可还原)
  alive                            // 保活:切走不销毁,回来秒恢复
  props={{ token: session.accessToken, themeMode, user: session.user }}
/>
// 方案 D · Module Federation:构建期声明 + 运行时 import(Vite 用 @module-federation/vite)
// 基座 vite.config.ts(示意,配置项以插件官方文档为准)
import federation from '@module-federation/vite';

federation({
  name: 'host',
  remotes: {
    reports: 'https://reports.qingorder.example.com/assets/remoteEntry.js',
  },
  shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
});
// 消费侧像 import 本地组件一样 import 远程组件(配 lazy + Suspense)
const ReportBoard = lazy(() => import('reports/ReportBoard'));

选型定案后,把「子应用侧需要付出什么」也放进对比视野——qiankun 要求子应用暴露生命周期,这是它改造成本的主要来源:

// qiankun 方案的子应用侧改造(wujie/micro-app 几乎零改造,这正是 §21.7 选 wujie 的原因)
// apps/marketing/src/entry.tsx
import { createRoot } from 'react-dom/client';

let root: ReturnType<typeof createRoot> | null = null;

export async function mount(props: { container: HTMLElement; token: string }) {
  const el = props.container.querySelector('#marketing-root') as HTMLElement;
  root = createRoot(el);
  root.render(<MarketingApp token={props.token} />);
}

export function unmount() {
  root?.unmount();   // 卸载不干净 = 内存泄漏 + 双实例风险,qiankun 会按生命周期严格调用
  root = null;
}

21.3 隔离原理与坑:JS 沙箱与 CSS 边界

JS 沙箱的本质:给子应用一个「以为自己独占 window」的假象。两种实现各有脾性:

① Proxy 沙箱(qiankun/micro-app 主用)
   fakeWindow = new Proxy({}, { get/set/deleteProperty 全部拦截 })
   子应用的 window.xxx 实际读写 fakeWindow,卸载时整体丢弃 → 不污染真 window
   坑 1:setInterval/document/title 等仍指向真实全局,泄漏要靠手动收集清理
   坑 2:子应用直接操作 document.body 挂载的浮层(弹窗)不受沙箱保护

② iframe 沙箱(wujie 主用)
   JS 跑在隐藏 iframe 里 → window/document/定时器全部天然隔离
   DOM 渲染到基座的 ShadowDOM(wujie 做 DOM 与 JS 分离的「桥接」)
   坑 1:iframe 与主应用的内存/通信开销;弹窗默认挂 iframe 内,需要代理到主文档
   坑 2:localStorage 有 iframe 域差异,需 wujie 的同步机制或显式 props 传值

CSS 隔离的三条路,从强到弱(轻订单约定:子应用样式自己用 CSS Modules + qing-ui 令牌,基座不做全局 reset 之外的兜底):

方式 原理 坑
Shadow DOM 浏览器原生边界,样式进不去出不来 全局字体/变量要显式注入;AntD 弹窗默认挂 body 会「逃逸」出 Shadow DOM,需把 getPopupContainer 指回子应用根节点
scoped 前缀(qiankun experimental) 运行时给子应用样式规则加前缀选择器 正则改写 CSS,伪元素/@keyframes/动态 style 标签偶发漏改
命名约定(.qo-marketing- 前缀 + CSS Modules) 纪律代替机制 依赖 Code Review;第三方子应用不守约就失守

最常见的真实事故是 reset 样式互殴:子应用带了一份 * { box-sizing: content-box; } 的全局 reset,一挂载基座表格全部变形。防线有三层:接入评审时检查子应用全局样式清单(发布契约的一部分)、运行时隔离(上表前两种)、样式快照进 E2E 冒烟集(第 16 章 toHaveScreenshot 顺手覆盖基座关键页)。

21.4 应用间通信:四种通道,各管一件事

通道 方向 放什么 不放什么
props / data 下发 基座 → 子应用 登录态 token、主题、用户信息、回调函数(onNavigate/onTrack) 高频业务数据(会变成影子状态)
事件总线 双向 一次性动作通知(「优惠券已领取」「订单已创建」) 持续共享的 state(总线不带状态,回放即丢)
URL 参数 双向 跨应用跳转上下文(/marketing?orderId=xxx、来源页) 任何敏感信息(URL 会进日志与历史记录)
共享存储/后端 双向 登录态 cookie、qing-ui 令牌 CSS、经过 API 的业务数据 前端临时态(没有失效通知机制)
// 基座:下发契约 props(子应用不感知基座实现,只认这份契约)
const sharedProps = {
  token: session.accessToken,        // 子应用用它配自己的 http 基座
  themeMode,                          // 主题跟随基座
  onNavigate: (path: string) => navigate(path),   // 子应用跳基座路由
  onTrack: (event: TrackEvent) => tracking.track(event),  // 埋点统一走基座
};

通信设计的最高原则是显式契约优先于隐式魔法:props 列表就是契约文档(第 22 章的跨职能契约一节会把它写成正式交付物),事件名集中在一个共享常量包(@qing/shared-types)里定义;事件总线只传「发生了什么」,传「数据长什么样」的都是未来排查不动的灵异 bug。

// packages/shared-types/src/events.ts —— 跨应用事件常量:契约的一部分,两边 import 同一份
export const MARKETING_EVENTS = {
  couponClaimed: 'coupon:claimed',      // 券已领取
  activityEnrolled: 'activity:enrolled', // 活动已报名
} as const;

export interface CouponClaimedPayload {
  couponId: string;   // 只传标识符,业务数据由消费方回源
}
// 子应用侧:只发信号(wujie 的子应用通过 window.$wujie 拿到总线)
import type { CouponClaimedPayload } from '@qing/shared-types';
import { MARKETING_EVENTS } from '@qing/shared-types';

function onCouponClaimed(payload: CouponClaimedPayload) {
  window.$wujie?.bus.$emit(MARKETING_EVENTS.couponClaimed, payload);
}
// 基座侧:收到信号后回源刷新缓存——事件是信号,不是数据管道
import { bus } from 'wujie-js';
import { useQueryClient } from '@tanstack/react-query';
import { orderKeys } from '@/entities/order';

bus.$on(MARKETING_EVENTS.couponClaimed, ({ couponId }: CouponClaimedPayload) => {
  tracking.track('marketing_coupon_claimed', { couponId });   // 埋点统一聚合
  void queryClient.invalidateQueries({ queryKey: orderKeys.all }); // 订单域缓存过期重取
});

21.5 公共依赖治理:共享哪些,绝不共享哪些

子应用各自打包 React,基座也打包 React——用户要下载三份框架代码(版本不同还可能 Hooks 报错)。治理手段按侵入性从低到高:

手段 1 · externals(最简单):子应用把 react/react-dom/antd 声明为外部依赖,
          由基座 <script> 提前加载成全局变量。
          ✓ 零构建插件 ✗ 失去按需加载;所有子应用被锁死在同一版本

手段 2 · import maps(浏览器原生):基座输出一份「模块名 → URL」映射,
          子应用 import 时解析到同一份。
          ✓ 标准化 ✗ 浏览器兼容矩阵需确认;配置分散

手段 3 · MF shared singleton(同栈最优):构建期声明 shared,运行时协商
          版本,取最高版本单例加载。
          ✓ 版本可弹性(子应用可带自己的小版本)✗ 仅限有插件支持的构建器

轻订单约定(写进接入契约):
  必须共享:react、react-dom(singleton)、dayjs、@qing/ui 的 tokens.css(基座统一注入)
  禁止共享:业务态数据、qing-ui 组件(除非版本严格一致)、子应用私有的工具库
  原则:只共享「稳定大件 + 纯展示资产」;一切带逻辑的业务包宁可重复加载
        ——业务包共享 = 子应用之间产生了隐形依赖,独立发布立刻变联合发布

验证依赖治理是否生效的方法要写进 CI:构建产物分析(第 17 章 rollup-plugin-visualizer)确认共享包只出现在基座产物;E2E 冒烟集跑一遍「基座 + 子应用」全链路,观察监控里 React 版本号埋点是否单例(两个版本并存时 Hooks 会抛 Invalid hook call,这是最经典的翻车现场)。

21.6 路由与部署整合

聚合应用的部署比单体多三个必须想清楚的问题:

① 路由前缀即应用边界。 约定 /marketing/* 归营销子系统、/* 归主站,activeRule/加载条件都从这一个约定派生;子应用内部路由带 basename(如 React Router 的 basename="/marketing"),保证刷新、深链接、浏览器前进后退都正确。

② 动态 publicPath。 子应用的静态资源相对其 HTML 入口解析,构建时把 publicPath 设为相对(base: './',Vite)或运行时注入完整 CDN 前缀——否则基座在主域下加载子应用 chunk 时会 404 到主域路径上。

③ 跨域拉取与缓存策略。 基座域名拉取子应用 entry 属于跨源请求,子应用侧必须放行 CORS;静态资源走哈希长缓存、entry(index.html / remoteEntry.js)不缓存——与第 18 章的 Nginx 缓存分层完全一致,只是每个子应用都要独立配一份。

# 营销子系统 Nginx(要点节选)—— 与第 18 章主站配置同构
server {
  listen 443 ssl;
  server_name marketing.qingorder.example.com;

  location /assets/ {                      # 哈希资源:强缓存一年
    add_header Cache-Control "public, max-age=31536000, immutable";
  }
  location = /index.html {                 # entry:永不缓存
    add_header Cache-Control "no-cache";
    add_header Access-Control-Allow-Origin "https://admin.qingorder.example.com";
  }
  location = /assets/remoteEntry.js {      # MF 方案的 entry 同理
    add_header Cache-Control "no-cache";
    add_header Access-Control-Allow-Origin "https://admin.qingorder.example.com";
  }
}

21.7 实战案例:轻订单平台接入第三方营销子系统

需求(ADR-011 的延续):营销子系统含优惠券领取、活动报名两个页面,供应商团队 4 人,技术栈不受本团队约束,要求活动上线当天能独立发布、视觉与主站一致、登录态打通。接入后的整体形态:

admin.qingorder.example.com(基座 · React 19 · 一体化构建)
│
├── /orders、/customers、/products、/reports …  主站路由:CSR 模块化单体(不拆,§21.1 结论)
│
└── /marketing/*  ──── wujie 挂载 ────►  marketing.qingorder.example.com(供应商 · 异构栈)
        │  props 下发:token / themeMode / user / onNavigate / onTrack(契约 §②)
        │  事件回传:coupon:claimed / activity:enrolled(常量在 @qing/shared-types)
        │  资产注入:tokens.css(qing-ui 语义令牌,基座统一引入,契约 §④)
        └  路由 sync:子应用内部路由同步基座 URL,刷新可还原(契约 §③)

第一步:选型定案。 子应用异构(不承诺 React)+ 供应商改造成本要趋近于零 + 需要保活与预加载 → 选 wujie(iframe 沙箱天然隔离异构代码,供应商基本零改造)。qiankun 也完全可行(需要供应商暴露生命周期),作为备选写入 ADR。

第二步:定义接入契约(基座与子应用的「接口文档」)。

契约 V1 —— 双方评审签字后冻结,变更走版本号
────────────────────────────────────────────
① 挂载    子应用只需是一个可访问的 URL(wujie iframe 心智,无生命周期改造)
② props   token / themeMode / user / onNavigate / onTrack(见 §21.4)
③ 路由    子应用占 /marketing/* 前缀,内部路由带 basename,子应用路由变化 sync 到基座 URL
④ 样式    禁止全局 reset;使用基座注入的 tokens.css(qing-ui 语义变量);
          字号/间距基准对齐设计系统(第 20 章)
⑤ 通信    事件仅两个:coupon:claimed(券已领取)、activity:enrolled(已报名),
          常量定义在 @qing/shared-types
⑥ 发布    子应用独立 CI/CD,发版前在联调环境跑「基座集成冒烟集」;
          entry 不缓存,静态资源哈希长缓存
⑦ 安全    token 经 props 下发不进 URL;子应用域名单独配置 CSP;
          埋点统一走 onTrack 由基座聚合(避免双份埋点口径)
────────────────────────────────────────────

第三步:基座侧接线(完整可跑的骨架)。

// apps/admin/src/pages/marketing/MarketingEntry.tsx —— 基座挂载点
import { lazy, Suspense } from 'react';
import WujieReact from 'wujie-react';
import { PageSkeleton } from '@/shared/ui/PageSkeleton';
import { useSession } from '@/entities/user/session';
import { useThemeMode } from '@/shared/lib/theme';
import { navigate } from '@/app/routerBridge';
import { track } from '@/shared/lib/tracking';

const MARKETING_URL = import.meta.env.VITE_MARKETING_ENTRY_URL; // 每环境不同,见下

export function MarketingEntry() {
  const { session } = useSession();
  const themeMode = useThemeMode();

  return (
    <Suspense fallback={<PageSkeleton />}>
      <WujieReact
        name="marketing"
        url={MARKETING_URL}
        sync
        alive
        props={{
          token: session?.accessToken,
          themeMode,
          user: session?.user,
          onNavigate: navigate,                 // 子应用跳转主站路由
          onTrack: track,                       // 埋点统一聚合
        }}
      />
    </Suspense>
  );
}
# apps/admin/.env.production(节选)—— 子应用入口按环境注入,绝不硬编码
VITE_MARKETING_ENTRY_URL=https://marketing.qingorder.example.com/
# apps/admin/.env.development
VITE_MARKETING_ENTRY_URL=http://localhost:7300/

第四步:联调验收。 冒烟脚本覆盖四条链路:从主站菜单进入营销页(路由同步)、领券后回到订单页创建订单(事件 + onNavigate)、切换暗色主题子应用跟随(props)、子应用发新版后基座无感知(独立发布)。全部自动化进第 16 章的 E2E 冒烟集,供应商每次发版前在联调环境必须全绿。

21.8 Strangler 渐进迁移:每一步都可回滚

如果被拆的不是新子系统,而是存量单体(比如把 admin 的报表模块独立成子应用),绝不能「停服三周大迁移」。Strangler(绞杀者)模式的核心是新架构从边缘包抄,旧代码逐块饿死:

第 0 步  识别边界:按业务能力切(报表/消息中心/设置),不按技术层切;
         每个候选块评估「对外耦合度 × 变更频率」,选出第一个低耦合高价值的
第 1 步  搭基座:现有单体先「壳化」——布局/菜单/登录态抽为基座能力,
         业务模块原样保留(此时零风险,纯结构重排)
第 2 步  首块迁移:报表模块按新架构(子应用)重写,路由层加开关——
         /reports/* 走旧实现,featureFlag 开启后走子应用(同路径替换,用户无感)
第 3 步  灰度切流:按内部用户白名单 → 部门 → 全量;每步观察监控(错误率/性能)
第 4 步  逐块绞杀:重复第 2–3 步;旧单体每次只减不增(新功能一律进新架构)
第 5 步  收尾下线:最后一块迁完,旧单体归档;基座转正为长期平台
回滚保障:任何一步出问题,featureFlag 关掉即回到旧实现——灰度开关就是回滚按钮

迁移期的纪律比技术更决定成败:双架构共存期设定明确终点(例如两个季度),否则「共存」会变成「永久」;每块迁移产出一份迁移记录(改动面、验证结果、流量切换时间),这份记录就是第 24 章技术方案的「里程碑证据链」。

回滚按钮的实体是路由层的切流开关——旧实现与新实现挂在同一个路径下,由运行时配置(第 18 章机制)决定走哪条:

// src/pages/reports/ReportsRoute.tsx —— Strangler 第 2–3 步的「同路径替换 + 开关」
import { lazy, Suspense } from 'react';
import { useFeatureFlag } from '@/shared/lib/featureFlag';
import { PageSkeleton } from '@/shared/ui/PageSkeleton';

const LazyReportsSubApp = lazy(() => import('./ReportsSubApp'));   // 新架构(子应用形态)
const LegacyReportsPage = lazy(() => import('./LegacyReportsPage')); // 旧实现(迁移期保留)

export function ReportsRoute() {
  const { enabled, isLoading } = useFeatureFlag('reports-v2');     // 白名单/租户级灰度
  if (isLoading) return <PageSkeleton />;
  return (
    <Suspense fallback={<PageSkeleton />}>
      {enabled ? <LazyReportsSubApp /> : <LegacyReportsPage />}
    </Suspense>
  );
}

监控面板与这个开关联动:灰度期间的错误率、性能指标按 flag 维度分组上报,异常即关闸——关掉 reports-v2,用户回到旧页面,流量归零,全程不需要发版。

🛠 渐进式实战练习

练习 1 · 基础(10–20 min):为三个真实需求做「要不要拆」评审

任务描述:用 §21.1 两张清单,分别评估以下需求并给出结论与理由:① 青云科技要把 admin 的报表模块独立,由新入职的 2 人小组维护并希望独立发版;② 老板觉得「微前端很火」,要求把 portal 门户也拆成微前端;③ 供应商的营销子系统(本章主案例)。

提示:逐条对照清单打勾,输出「结论 + 关键依据 + 若不拆的替代方案」三段式;② 的替代方案值得认真想——它考察你是否理解「拆」的真实成本。

验收标准:每个需求一段 5 行以内的评审意见,至少引用清单中的 2 条信号;其中②给出明确的「拒绝理由 + 替代路径」。

参考答案(评审要点)

① 倾向拆,但设置前置条件:团队边界真实出现(2 人小组独立维护)、独立发版诉求明确——命中适用信号;但需先确认报表与订单模块的耦合度(如订单详情页内嵌报表组件就是强耦合信号),且 2 人小组先按 §19 模块化目录在主仓内生长一个迭代,验证「独立」的真实需求再物理拆分。

② 拒绝:无团队边界(同一批人)、无独立发布诉求、portal 页面间共享大量交互状态(主题、导航)——三条不适用信号全中。替代路径:用第 19 章分层目录 + 独立部署流水线(同一仓库不同 job 部署 portal/admin)满足「看起来像拆了」的真实诉求(发布隔离),不引入运行时聚合成本。这条评审意见的价值在于:用更便宜的手段满足诉求背后的真实需求。

③ 拆:供应商团队边界、活动级发布节奏、零改造成本约束全部命中,且结论需落成接入契约(§21.7)。

练习 2 · 进阶(30–60 min):搭一个 wujie 双应用最小集成

任务描述:在本机搭两个 Vite 应用——基座 admin(含菜单与 /marketing 路由)与子应用 marketing(两个简单页面 + 一个「领取优惠券」按钮);按 §21.7 契约完成接入:props 下发 token/themeMode、coupon:claimed 事件、路由 sync。验证四条冒烟链路。

提示:子应用 vite 配置 base: './' 并允许基座域名跨域(server.cors: true 或 Nginx 层);事件用 wujie 的 bus.$on/$emit,事件常量写在共享 types 包;先在 localhost:7300 直接访问子应用确认它自己能跑,再从基座进入——分层排查是微前端联调的基本功。

验收标准:① 基座菜单进入 /marketing 正常渲染,刷新后路由还原;② 子应用点击领券 → 基座收到 coupon:claimed 并 toast;③ 切换暗色主题,子应用背景色跟随;④ DevTools Elements 确认子应用 DOM 渲染在基座文档内(而非 iframe 可见框),window 上无子应用泄漏的全局变量。

参考答案(易翻车的四个点)

① 子应用 entry 404:多数是 publicPath/base 未设相对路径,或子应用侧未配 CORS(控制台会有明确的 CORS 报错,按报错定位); ② 路由刷新丢失:wujie 需开启 sync,且子应用路由实例的 base 要与接入前缀一致(React Router 的 basename="/marketing"); ③ 主题不跟随:props 下发的 themeMode 需要子应用自己切换 data-theme——子应用往往沿用第 06 章的 useTheme 但没接 props,导致基座切了它不切;契约中「跟随基座」就要落实到子应用代码; ④ 事件收不到:检查事件名常量是否两处一致(应 import 同一个常量包而不是两边手写字符串)——这正是契约要求常量集中定义的原因。

练习 3 · 挑战(选做,60+ min):Module Federation POC 与依赖共享验证

任务描述:用 Module Federation 搭「主应用 + 报表子应用」POC(同为 React 19):子应用导出 ReportBoard 组件,主应用 lazy 加载;shared 声明 react/react-dom singleton;然后做三个验证——① 断网/子应用部署关闭时主应用的降级表现;② 故意让子应用用不同小版本 React,观察 singleton 协商结果;③ 产物分析确认 react 只打包进一份。

提示:Vite 用 @module-federation/vite(配置项以插件官方文档为准);验证②可以临时把子应用 lockfile 里的 react 版本改低再构建;降级用 lazy() + ErrorBoundary + 重试按钮的「组件级降级」模式(第 18 章 ErrorBoundary 的复用)。

验收标准:① 主应用独立运行加载远程组件成功;② 子应用不可用时主应用白屏区域出现降级 UI 与重试入口,其余路由不受影响;③ singleton 协商结果与产物分析结论写成 200 字记录(两张分析截图);④ 记录「MF 相比 wujie 的差异点」至少 3 条(隔离、通信、改造方式)。

参考答案(POC 结论要点)

典型结论(供对照):① MF 无沙箱——远程组件与主应用共享 window/context,React 单例是硬要求(双实例必然 Invalid hook call),shared singleton 协商通常取更高版本;② 降级模式:lazy(() => import('reports/ReportBoard')) 外包一层 ErrorBoundary,加载失败呈现「报表暂不可用,点击重试」,重试即重新 import——比整站级降级(子应用 iframe 挂掉只是区域空白)粒度更细,这是 MF「模块级共享」的天然优势;③ 与 wujie 的差异记录示例:隔离上 wujie 强(iframe)而 MF 无、通信上 MF 直接 import 远程模块(类型可共享)而 wujie 走 props/事件、改造成本上 MF 需要双方都有构建插件而 wujie 几乎零改造。结论:自家同栈模块级复用 → MF;异构第三方 → wujie。这个「双接缝双方案」的结论可直接补进 ADR-011 的实施附录。

⚠️ 常见易错点

易错点 21-1:为拆而拆——单团队用微前端管理自己的代码

❌ 错误做法:3 人团队把 admin 拆成 5 个微前端子应用,「每人负责一个,互不干扰」。结果:联调要走两个仓库、公共组件要发两个包、一次全站改版开 5 条流水线——本想减少协作成本,实际把代码内的协作变成了跨仓库的协作,成本不降反升。

✅ 正确做法:微前端解决的是组织边界问题(不同团队、不同节奏、不同技术栈),不是代码整洁问题。单团队的代码治理用第 19 章的分层与依赖规则;「发布隔离」诉求用同一仓库多条部署流水线满足。决策口诀:先看组织架构图,再看系统架构图。

易错点 21-2:样式污染——子应用 reset 干掉基座

❌ 错误做法:子应用自带 * { margin: 0; box-sizing: border-box } 全局样式与 .btn { ... } 公共类名。挂载瞬间基座按钮变形、间距错乱,排查半天发现是「别人家的样式」。

✅ 正确做法:三层防线(§21.3)——接入契约禁止全局 reset 与无前缀公共类(发布前评审样式清单);运行时隔离(Shadow DOM / scoped);E2E 视觉快照兜底基座关键页。供应商子应用无法约束时,直接选 iframe 类沙箱(wujie)用机制而不是纪律解决问题。

易错点 21-3:公共依赖失治——React 双实例与三份框架代码

❌ 错误做法:基座与两个子应用各自打包 React,且版本分别是 19.0 / 19.1 / 18.3。症状:某个子应用里 Hooks 抛 Invalid hook call(两个 React 实例并存);用户首屏下载了三份框架(约 45KB × 3 gzip);升级 React 要改四个仓库。

✅ 正确做法:治理三步(§21.5)——明确共享清单(只共享 react/react-dom/日期库这类稳定大件);选定机制(MF singleton / externals / import maps)并用产物分析验证「共享包只打包一份」;把「React 单例验证」写进 CI 集成冒烟。业务包绝不共享,保持子应用发布独立。

易错点 21-4:事件总线变成隐式全局状态

❌ 错误做法:用事件总线传递业务数据——子应用 emit order:changed 附带整单数据,基座监听后写进自己的 store。三个月后没人说得清「订单数据到底有几份真值」,时序一乱就是「审核状态显示错误」的灵异 bug。

✅ 正确做法:总线只传「发生了什么」(事件名 + 最小标识符),状态一律回源——收到 coupon:claimed 后由基座 queryClient.invalidateQueries 让缓存重取(§19.4 的 queryKey 工厂在此复用)。检查口诀:事件是信号,不是数据管道。

易错点 21-5:一次性整站切换、没有回滚开关

❌ 错误做法:迁移报表模块时直接删旧代码上新架构,发布当天出问题只能紧急回滚整个版本(用户已产生的新数据可能已写入新链路);或者更糟——新旧两版同时在线但路由互相打架。

✅ 正确做法:Strangler 五步(§21.8)里每一步都有回滚按钮:路由层 featureFlag 决定旧实现还是新实现,灰度按白名单放量,监控异常即关闸。旧代码在新实现稳定一个迭代后才删除。渐进迁移的本质不是慢,而是每一步都可以退。

✅ 最佳实践

  1. 先过「要不要拆」清单再谈方案:清单结论写进 ADR;微前端的收益只能来自团队边界与发布节奏,不为技术热点买单;
  2. 一把手术刀:只拆真正有边界的模块(轻订单只拆营销子系统),其余保持一体化构建——聚合层每少一个,运维面小一截;
  3. 契约先行:接入契约(挂载方式/props/事件/样式/发布规则)评审冻结后再开工,契约变更走版本号——跨团队协作没有契约等于没有协作;
  4. 选型按场景对号入座:异构低改造 → wujie/micro-app;经典中后台聚合 → qiankun;同栈模块共享 → Module Federation;选型理由写进 ADR 而不是留在会议记忆里;
  5. 通信显式化:props 是唯一的状态通道,事件只传信号,跨应用跳转上下文走 URL;事件名集中定义在共享常量包;
  6. 依赖治理写进 CI:产物分析验证共享单例、集成冒烟验证 React 版本唯一、监控埋点上报框架版本号;
  7. 部署与缓存同构复用:子应用 Nginx 配置与第 18 章主站同一套分层(哈希长缓存 + entry 不缓存 + CORS 放行基座域),多一份子应用就多一份同构配置;
  8. 迁移必须可回滚:featureFlag 切流、白名单灰度、监控观察、稳定后删旧——没有回滚方案的迁移方案不配进评审会。

📌 本章小结

  • 微前端回答的是组织问题:多团队、异构栈、独立发布才拆;单团队先穷尽第 19 章的模块化手段;
  • 四方案各有生态位:qiankun 生态最大适合经典聚合,micro-app 改造成本低,wujie 用 iframe 沙箱换来最强隔离与近零改造,Module Federation 是同栈模块级共享与独立部署的最优解;
  • 隔离看机制:Proxy 沙箱防 window 污染但有逃逸面,iframe 天然隔离但有桥接成本;CSS 三条路(Shadow DOM / scoped / 命名约定)按子应用可控程度选择;
  • 通信四通道各管一件事(props 下发状态、事件传信号、URL 带上下文、共享存储放登录态),依赖治理只共享稳定大件并进 CI 验证;
  • 轻订单以 wujie 接入营销子系统:契约七条冻结边界,四条冒烟链路保障联调;存量迁移用 Strangler 五步,每一步都有回滚开关。

拆与合的问题解决了,但「接入契约」「评审签字」「迁移记录」这些词背后是另一个更大的命题:一群人如何稳定地产出高质量的软件。下一章把镜头从系统转向团队——分支模型、提交规范、Code Review 文化、跨职能契约,最终汇成一份可执行的《前端团队协作手册》。

📄 第 22 章 · 团队协作规范 22-团队协作规范.md

📍 阶段:资深阶段(第 19–24 章)|⏱ 预计学时:6–8 小时|🔗 前置:第 18 章 CI/CD + 第 20 章 changesets + 第 21 章 接入契约

⬅ 上一章:微前端与跨团队协作 | 📖 本版目录 | ➡ 下一章:技术选型决策

💡 对照学习:本页是 React 版第 22 章,对应 Vue 版第 22 章 · 团队协作规范。本章与框架几乎完全无关——协作规范的每一条(分支模型、提交规范、Review 文化、跨职能契约)两版逐字通用,差异仅在个别配置文件名(本版示例仓库为 pnpm workspace + React 技术栈,Vue 版为等价结构)。这正好说明本章的道理:好的规范是团队资产,与个人用什么框架无关。

前四章解决了「系统怎么长」,本章解决「一群人怎么稳定地产出系统」。资深的标志性产出不是某段精妙的代码,而是让整个团队变好的机制:一条自动执行的提交链路、一份被真实使用的 PR 模板、一种对事不对人的 Review 文化、一纸设计/后端/测试都认账的契约。本章以「轻订单」团队为背景,最终交付一份可执行的《前端团队协作手册》。

🎯 学习目标

学完本章,你将能够:

  1. 对比 trunk-based、GitHub Flow、Git Flow 三种分支模型,按团队规模与发布形态做出有理由的取舍;
  2. 配置 Conventional Commits + commitlint + husky 的提交链路,并说出提交类型与 changesets 版本级别的映射关系;
  3. 编写 PR/MR 模板与 10 项 Review 检查清单,并把「机器能查的」全部自动化到 lint/CI;
  4. 制定团队的 Code Review SLA(响应时限、升级机制)与坏味道清单,用对事不对人的话术完成一轮真实互审;
  5. 建立文档沉淀机制:ADR 索引、docs/ 目录结构、新人 onboarding 手册(第一周任务表);
  6. 定义跨职能协作契约:设计交付物清单(tokens/组件对照表/四态稿)、后端 OpenAPI 先行 + Mock 平台、测试用例评审与缺陷单模板;
  7. 产出《前端团队协作手册》骨架(含目录与至少三节示范内容),做到「新人拿手册即可干活」。

📖 核心知识点

22.1 分支模型:按团队规模与发布形态取舍,不按名气

维度 trunk-based(主干开发) GitHub Flow(短分支 + PR) Git Flow(双长期分支)
结构 所有人直接小步提交到 main feature 分支存活 ≤ 2–3 天,PR 合入 main main + develop 双长期分支,release/hotfix 分支
发布方式 高频发布 + feature flag 控制暴露 合并即可发布(或定时发布) 发布分支上测试、修复、定版
配套要求 必须 CI 强 + flag 体系 + 快速回滚 CI 门禁 + Review 纪律 版本管理规范、双分支同步纪律
适合 平台团队、持续部署、需要灰度 中小团队(本教程推荐默认) 多版本并行交付(ToB 私有化、App 发版)
主要风险 未完成代码暴露风险(靠 flag 兜底) 长寿分支导致合并地狱(靠小 PR 纪律兜底) 双分支合并成本高,团队越小越容易乱

轻订单的取舍:6 人团队、SaaS 持续交付、已有 featureFlag 需求(第 21 章灰度切流)→ GitHub Flow 为骨架 + 关键开关用 flag。规则只有四条,多一条都执行不下去:

① main 永远可发布(branch protection:禁止 push,只允许 PR 合入,状态检查全绿)
② feature 分支命名 <type>/<scope>-<摘要>,存活不超过 3 天(超了就拆)
③ 未完成的功能用 featureFlag 隐藏,而不是用长分支囤积
④ 发布 = 给 main 打 tag(v2.3.0),流水线自动部署与回滚(第 18 章链路)

22.2 Conventional Commits:让提交历史变成机器可读的变更日志

提交信息是团队协作的最小单元,也是 changelog 与版本号的源头(第 20 章 changesets 的输入)。规范只有一句话:<type>(<scope>): <subject>,破坏性变更加 !:

type 含义 对 changesets 版本级别 示例
feat 新功能 minor feat(order): 列表支持按归属销售筛选
fix 缺陷修复 patch fix(money): 千分位格式化修复中文输入法光标跳动
perf 性能优化 patch perf(order-list): 万级数据接入虚拟滚动
refactor 重构(不改行为) 无版本影响 refactor(auth): 无感刷新提取为独立模块
test / docs / chore / ci 测试/文档/杂务/流水线 无版本影响 ci: 冒烟集接入 pre 环境定时回归
feat!/fix! 破坏性变更 major feat(ui)!: StatusTag 移除 color 属性,改用 status 映射
// commitlint.config.js —— 提交信息机器校验(types 与 scope 白名单按团队增减)
export default {
  extends: ['@commitlint/config-conventional'],
  rules: {
    'scope-enum': [2, 'always', ['admin', 'portal', 'ui', 'api', 'release', 'docs', 'adr']],
    'subject-max-length': [2, 'always', 50],
  },
};
// package.json(根,节选)—— husky + lint-staged 提交门禁(第 17 章链路的提交侧闭环)
{
  "scripts": {
    "prepare": "husky"
  },
  "lint-staged": {
    "*.{ts,tsx}": ["eslint --fix", "prettier --write"],
    "*.{css,md,json}": ["prettier --write"]
  }
}
# .husky/commit-msg —— 校验提交信息;.husky/pre-commit 跑 lint-staged(同理一行)
pnpm commitlint --edit "$1"

合规与违规的提交信息对照(贴进团队 wiki 的一页速查):

feat(order): 列表支持按归属销售筛选            ✅ type 合规、祈使句、≤ 50 字
fix(money)!: 移除 centsToDisplay 的舍入参数     ✅ 破坏性变更用 ! 标记(changeset → major)
perf(order-list): 万级数据接入虚拟滚动          ✅ perf 也进 changelog,方便复盘
修改了一些东西                                  ❌ 无 type —— commitlint 直接拒绝
feat(banner): xxx                              ❌ scope 不在白名单(banner 不存在)
fix: 修复bug                                   ❌ subject 模糊,changelog 无法阅读

与 changesets 的联动约定:改 packages/(qing-ui、shared-types)的 PR,commit type 决定 changeset 级别(feat→minor、fix→patch、!→major),且 PR 必须附 changeset 文件——这条写进 PR 模板自查清单,由 CI 检查(changesets/action 提供 changeset status --fail-on-missing 类能力,配置以官方文档为准)。

22.3 PR 模板与 Review 清单:把评审标准写在明处

PR 模板是「作者的自查清单」,也是 reviewer 的阅读索引:

<!-- .github/pull_request_template.md -->
## 变更说明
<!-- 一两句话说清做了什么、为什么。评审者 30 秒内应能建立预期 -->

## 关联
<!-- 需求单 / issue / ADR 编号,例如:ADR-011 实施附录、需求 营销-2026-041 -->

## 变更范围
- [ ] apps/*(业务应用)
- [ ] packages/*(qing-ui / shared-types)—— 已附 changeset
- [ ] 仅文档 / 配置

## 自查清单(作者勾选,未勾选项说明原因)
- [ ] lint / typecheck / test / build 本地全绿
- [ ] E2E 冒烟集通过(涉及主流程时)
- [ ] 无新增硬编码凭据、无 VITE_ 变量存密钥
- [ ] 状态归置符合三分法(服务端态走 TanStack Query)
- [ ] 新增依赖已评估体积与维护度(附 packagephobia/bundlephobia 链接)
- [ ] 破坏性变更已写迁移说明(若有)

## 截图 / 录屏
<!-- UI 变更必附:before / after;视觉回归 diff 图贴在此处(第 20 章约定) -->

Reviewer 侧的 10 项清单(顺序即评审顺序:先看意图,再看设计,最后看细节):

① 意图一致:PR 做的确实是它声称的事,没有夹带
② 方案合理:有没有更简单/更符合既有约定的做法(分层、归置、复用)
③ 命名与领域:函数/变量/组件名使用领域词(订单/审批/折扣),无拼音缩写
④ 状态归置:服务端态是否走了 Query,客户端态是否就近(§19.4)
⑤ 错误处理:加载/空/错误三态齐全,失败有降级路径,用户不面对裸奔的 reject
⑥ 类型与安全:无新增 any、无硬编码凭据、对外部输入做了校验
⑦ 测试:新逻辑有测试;行为变化时旧测试被更新而非删除
⑧ 性能敏感点:列表渲染路径、bundle 体积、N+1 请求
⑨ 交付物齐全:changeset / 截图 / 文档 / ADR(按变更范围对应)
⑩ 可回滚:破坏性变更是否有开关或迁移期(结合 §21.8/§24.2 的灰度思路)

其中 ③⑤⑥⑦ 是机器辅助度最低、最需要人的部分——这正是 review 的价值所在;格式问题(缩进/引号/命名风格)一条都不该出现在人工评审里,它们属于 Prettier/ESLint。

评审者视角的一次高效评审(≤ 400 行 PR,20 分钟内完成):

阶段 时间 动作
建立预期 0–2 min 读标题与描述、关联需求,明确「这个 PR 声称做了什么」
全局审 3–8 min 浏览 diff 的文件清单与结构(分层归置、是否夹带),先看测试变更
细节审 9–18 min 按十项清单逐项过核心逻辑;对可疑处打开上下文(不凭 diff 猜)
结论 19–20 min 逐条写评论(实质/nit 标注),给出结论:approve / 请修改 / 有疑问

22.4 Review 文化:SLA、坏味道清单与话术

SLA(承诺要写进手册并公开遵守):

响应 SLA:PR 提交后,首个实质性响应(评审或明确排队)≤ 4 个工作小时;
          ≤ 400 行的 PR 当个工作日内完成评审,> 400 行拆分后再审;
          24 个工作小时无人响应可升级(群里 @,或按 CODEOWNERS 自动指派)。
作者义务:所有评论 24 小时内逐条回应(同意就改,不同意给出理由);
          分歧超过 10 条评论仍无共识 → 停止在 PR 里打持久战,拉 15 分钟会对齐,
          或沉淀为 ADR——PR 是评审变更的地方,不是哲学辩论场。

「按 CODEOWNERS 自动指派」的配置实体——评审指派本身也应该是机器执行的:

# .github/CODEOWNERS —— 路径 → 默认评审人(团队规模变大后必配)
*                       @qingorder/frontend
packages/qing-ui/       @zhangsan          # 组件库变更必须过库 owner
packages/permission/    @zhangsan @lisi    # 权限相关双 owner
docs/adr/               @lisi @wangwu      # ADR 需架构组双人确认
.github/workflows/      @wangwu            # 流水线变更过 DevOps owner

坏味道清单(reviewer 的「高频问题速查表」,来自轻订单真实 review 案例的抽象):

# 坏味道 一句话点评
1 巨型 PR(> 400 行) 评审质量随行数指数下降,请拆
2 重构夹带 feature 「顺手改」让 diff 无法回滚,分开两个 PR
3 魔法数字/裸字符串 if (status === 'pending_review') 应引常量或状态机
4 第三份复制粘贴 复制出现第三次必须抽取(§19「三次法则」)
5 import 副作用 模块顶层发请求/改全局,启动顺序失控(§19.6)
6 死代码与注释掉的代码 交给 git 历史,仓库里不留木乃伊
7 过度防御 捕捉不可能发生的异常属于噪音,let it crash
8 命名与领域不符 handleData1 是审不出 bug 的,但能审出下一个人的一天
9 测试断言实现细节 断言「用户看到什么」而不是「内部 state 是什么」(第 15 章理念)
10 硬编码配置 URL/阈值/密钥进环境变量或常量文件(安全红线)

话术规范(对事不对人,全部针对「代码」而非「人」):

✗ 「你这里写得有问题」               →  ✓ 「这段在 X 场景会 Y,建议改成 Z,因为 W」
✗ 「为什么不问一下就改」             →  ✓ 「这块影响 A 模块,麻烦同步一下变更原因」
✗ 「这不行,改掉」                   →  ✓ 「两个方案:A 简单但 B 更贴合契约,我倾向 B,理由…」
非阻塞的小建议加 nit: 前缀,作者可自行取舍;阻塞项明确说「请修改后再合入」。
表扬同等重要:看到漂亮的抽象/测试,明确说出来——文化的方向由被称赞的行为决定。

22.5 文档沉淀:ADR、docs/ 结构与 onboarding

文档的第一原则:写「未来还会有人问的问题」的答案。轻订单的 docs/ 结构(第 19 章起逐步生长):

docs/
├── architecture.md          # 架构总览:分层图、依赖规则、状态归置约定(§19.2 的沉淀)
├── adr/                     # 决策记录(ADR-001…ADR-012,只追加不修改)
│   ├── ADR-008-zustand-增量采用.md
│   ├── ADR-009-tanstack-query.md
│   └── ADR-011-营销子系统接入形态.md
├── contracts/               # 跨职能契约:营销接入契约 V1、设计交付物清单、OpenAPI 约定
├── runbook/                 # 运行手册:上线 SOP、回滚 SOP、告警响应(第 18 章产出)
├── postmortem/              # 事故复盘(模板:时间线/根因/防线改进)
├── onboarding.md            # 新人第一周手册
└── frontend-handbook.md     # 《前端团队协作手册》(本章产出)

新人 onboarding 手册的核心是「第一周任务表」——让新人通过完成真实小任务来学习系统,而不是通读代码:

天 任务 对应学习 验收
D1 本地起环境:pnpm i + 三条启动命令跑通 admin/portal/storybook onboarding 手册 §1 三端可访问
D2 修一个 docs 标注 good-first-issue 的文案/样式缺陷,走完整 PR 流程 分支/提交/PR 模板 PR 被合入
D3 给 entities/customer 补一个缺失的 TS 类型并加单测 目录分层 + 测试(第 15 章) 测试绿 + review 通过
D4 通读 architecture.md + ADR-008/009/011,向 mentor 复述一遍 架构与决策背景 复述无误
D5 独立领一个小需求进入正常迭代 协作手册全流程 需求交付

22.6 跨职能契约:设计、后端、测试的「接口定义」

前端不是孤岛。三个方向的协作契约,本质都是把隐式默契变成显式交付物:

与设计的契约——设计交付物清单:

① 设计稿 + 交互四态:每个交互组件必须有 default / loading / empty / error 四态稿
   (中后台最容易漏的是 loading 与 error 态,联调时才发现没设计)
② tokens 变更单:本次涉及的颜色/间距/圆角变化,对照 qing-ui 语义令牌命名
   (设计师与前端共享同一种语言——第 20 章令牌体系的协作红利)
③ 组件对照表:设计组件 ↔ qing-ui 组件映射,新组件标注「需要进库」或「一次性」
④ 视觉走查:上线前按设计稿走查,偏差单独立缺陷而不是口头改

与后端的契约——OpenAPI 先行 + Mock 平台:接口定义先于编码(OpenAPI 文档评审通过才开工),前端基于附录A §5 的响应体约定在 Mock 平台造数开发,联调只剩「换 baseURL」一件事。约定三件事:错误码语义表(40102 触发无感刷新、40301 跳 403 页)双方同一份文档;字段命名(分单位金额 unitPrice)不得出现两种口径;接口变更必须升版本或提前一个迭代公告。第 09/15 章的 msw mock 与这份契约同源——契约即代码:

# contracts/openapi/orders.yaml(节选)—— 契约评审通过才允许开工
paths:
  /orders:
    get:
      summary: 订单列表(分页 + 筛选)
      parameters:
        - { name: page, in: query, schema: { type: integer, default: 1 } }
        - { name: pageSize, in: query, schema: { type: integer, default: 20 } }
        - { name: status, in: query, schema: { $ref: '#/components/schemas/OrderStatus' } }
      responses:
        '200':
          description: 统一响应体(附录A §5 口径)
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Envelope'   # { code, message, data }
                  - type: object
                    properties:
                      data:
                        type: object
                        properties:
                          list:  { type: array,  items: { $ref: '#/components/schemas/Order' } }
                          total: { type: integer }
// src/mocks/orderHandlers.ts —— msw mock 与契约同源:字段/响应体/错误码逐项对齐
import { http, HttpResponse } from 'msw';

export const orderHandlers = [
  http.get('*/api/v1/orders', ({ request }) => {
    const params = new URL(request.url).searchParams;
    return HttpResponse.json({
      code: 0,
      message: 'ok',
      data: {
        list: makeOrders(params),   // 字段与 Order schema 一致(unitPrice 单位:分)
        total: 128,
        page: Number(params.get('page') ?? 1),
        pageSize: Number(params.get('pageSize') ?? 20),
      },
    });
  }),
  // 错误分支也是契约的一部分:40901 资源冲突(客户去重)必须有 mock
  http.post('*/api/v1/customers', () =>
    HttpResponse.json({ code: 40901, message: '统一社会信用代码已存在', data: null }, { status: 409 }),
  ),
];

与测试的契约——用例评审(测试用例在提测前评审,前端确认 E2E 可覆盖的部分)、冒烟集共识(登录→下单→审批→导出 四条链路双方同一份清单)、缺陷单模板(复现步骤/环境与账号/期望与实际/截图与日志四要素齐全,缺要素的缺陷单退回):

<!-- 缺陷单模板(测试协作契约的一部分,四要素缺一退回) -->
**标题**:[订单] 折扣 15% 提交后未进入待审核状态
**复现步骤**:1. sales 账号登录 → 2. 新建订单添加明细行 → 3. 整单折扣填 15 → 4. 提交
**环境与账号**:pre 环境 / demo@qingorder.example.com / 角色 sales
**期望 vs 实际**:期望 status=pending_review 且财务可见审批按钮;实际 status=approved
**截图与日志**:(页面截图 + 请求响应 JSON + Sentry 事件链接)

22.7 交付物:《前端团队协作手册》骨架

把本章所有机制汇总成一份团队唯一入口文档。骨架如下(★ 标记的 1、4、5 三节在练习 3 中给出示范正文):

《轻订单前端团队协作手册》 v1.0 —— docs/frontend-handbook.md
├── 1. 分支与提交规范           ★(示范正文见练习 3)
│   ├── 1.1 分支模型与命名
│   ├── 1.2 Conventional Commits 速查
│   └── 1.3 changesets 联动规则
├── 2. 开发约定速查
│   ├── 2.1 目录分层与依赖规则(链接 architecture.md)
│   ├── 2.2 状态归置三分法口诀
│   └── 2.3 样式与令牌使用规范(链接 qing-ui 文档站)
├── 3. 环境与发布
│   ├── 3.1 本地环境启动(三端)
│   ├── 3.2 多环境说明与配置注入
│   └── 3.3 发布/回滚 SOP(链接 runbook/)
├── 4. Pull Request 与 Review   ★(示范正文见练习 3)
│   ├── 4.1 PR 模板填写要求
│   ├── 4.2 Reviewer 十项清单
│   ├── 4.3 SLA 与升级机制
│   └── 4.4 坏味道速查表
├── 5. 跨职能协作契约           ★(示范正文见练习 3)
│   ├── 5.1 设计交付物清单
│   ├── 5.2 后端 OpenAPI 与 Mock 约定
│   └── 5.3 测试协作与缺陷单模板
├── 6. 安全红线
│   └── 凭据管理 / 依赖引入 / 数据脱敏(三不原则)
└── 附录:ADR 索引 / onboarding 任务表 / 常用命令速查

手册的维护规则与 ADR 一脉相承:改手册的 PR 也是 PR(走完整 review),保证手册与真实流程同步演化;手册里不写「为什么」(那是 ADR 的事),只写「怎么做」——保持手册短小、可执行、可搜索。

🛠 渐进式实战练习

练习 1 · 基础(10–20 min):提交链路搭建

任务描述:给轻订单仓库(Monorepo 根)配置完整提交门禁:husky + lint-staged + commitlint(§22.2 配置照搬适配),并测试四种提交信息的校验结果(合规 feat、合规 fix、缺 type 的 修改了一些东西、scope 越权的 feat(banner): xxx)。最后在 GitHub 上开启 main 分支保护(禁止直接 push、要求状态检查通过)。

提示:husky 9 的钩子文件已无 shebang 要求,pnpm prepare 后用 echo 'pnpm commitlint --edit "$1"' > .husky/commit-msg 即可;scope 越权用例用于验证 scope-enum 真的生效。

验收标准:① 两种违规提交被 commit-msg 钩子拒绝并输出可读错误;② 合规提交通过且 pre-commit 自动跑了 eslint --fix;③ main 分支无法直接 push(被远端拒绝)。

参考答案(验证记录模板)
提交信息 结果 原因
feat(order): 列表支持按归属销售筛选 通过 type/scope/长度全合规
fix(money): 千分位在中文输入法下光标跳动 通过 合规
修改了一些东西 拒绝 type-empty / type-enum 校验失败
feat(banner): xxx 拒绝 scope-enum 不含 banner,应使用 admin/portal/ui 等白名单 scope

分支保护路径:仓库 Settings → Branches → Add rule → 勾选 Require a pull request before merging + Require status checks(lint/test/e2e-smoke)。注意先在 CI 里跑过一次同名 job,状态检查才会在下拉列表中出现。

练习 2 · 进阶(30–60 min):一轮真实的结构化互审

任务描述:与学习伙伴(或团队同事)交换两个 PR,按 §22.3 十项清单 + §22.4 坏味道清单完成互审,要求:① 至少产出 3 条符合话术规范的实质性评论(禁止只挑格式);② 至少使用一次 nit: 前缀标注非阻塞建议;③ 若出现分歧,模拟「停战机制」——写成一段 5 句话的 ADR 草稿而不是继续跟评。

提示:先用 30 秒读 PR 描述建立预期,再按清单顺序走(意图→设计→细节);发现第 4 类坏味道(第三份复制)时,顺手评估它应该上提 widgets 还是下沉 entities(练习 §19 的分层判断)。

验收标准:① 两份 review 记录各含 ≥3 条实质性评论 + ≥1 条 nit;② 无一条纯格式评论(格式问题应记录为「建议提交 pre-commit 钩子」而不是逐条指出);③ 分歧(如有)产出 ADR 草稿或对齐结论记录。

参考答案(review 评论示例)

实质性评论示例(对照话术规范):

  • 「这个 if (discount > 10) 的 10 是附录A 里的免审批阈值(§3.3),建议提取到 entities/order/model/constants.ts 并命名为 WHOLE_DISCOUNT_REVIEW_THRESHOLD,将来业务调阈值只改一处。」——对应坏味道 3,且给出了归置位置;
  • 「OrderListPage 里同时用了 useState 存列表和 useQuery,服务端态建议统一走 Query 的缓存(§19.4 三分法),提交审核后 invalidateQueries(orderKeys.lists()) 就能同时刷新列表与详情,现在的手动 setList 会在详情页出现旧数据。」——对应十项清单④,说清了后果;
  • nit: 这里的 loading可以用 Query 的isPending 直接得到,少一个 state。——非阻塞建议。 若对方反驳「useState 版本性能更好」,这就是典型分歧:先核对事实(Query 内部同样有缓存与渲染优化),再决定是否值得写 ADR——多数情况下 10 条评论的上限根本用不到,5 条内就能对齐。

练习 3 · 挑战(选做,60+ min):完成《协作手册》并试运行一周

任务描述:按 §22.7 骨架完成手册全文(未完成小节可先放「TODO 待补」占位),其中 1、4、5 三节写出完整正文;然后把手册投入真实使用一周:每次 PR 按模板走、每条 review 按清单走,周末收集团队反馈修订出 v1.1。

提示:正文写法是「规则 + 一个反例 + 一个正例」,禁止写成散文;5.2 节直接引用附录A §5 的响应体与错误码约定作为契约正文,体现「契约即代码」;试运行的反馈收集用五问表(哪条最烦?哪条没被执行?…)。

验收标准:① 手册 v1.0 提交进 docs/ 且经真实 PR 评审合入(吃自己的狗粮);② 1/4/5 节正文完整,各含至少一组正反例;③ 试运行一周后产出 v1.1 变更说明(哪些规则被删/改/加及原因)——被真实使用过的规范才是规范,否则只是文档。

参考答案(1、4、5 三节示范正文)

§1 分支与提交规范:规则——main 永远可发布;分支命名 <type>/<scope>-<摘要>,存活 ≤ 3 天;提交信息遵循 CC 规范(type 白名单见 commitlint.config.js)。反例:zhangsan-final-2 分支存活 11 天,合入时冲突 47 处(正解:拆成三个小 PR 分批合)。正例:feat/order-filter-by-owner 存活 1.5 天,两次小步合入。

§4 Pull Request 与 Review:规则——PR 默认 ≤ 400 行,模板自查全勾;reviewer 按十项清单走,首个响应 ≤ 4 工作小时。反例:一个 1600 行的「订单模块重构」PR 评审了 5 天,夹带 3 个无关修复,最终只能整体合入、无法单独回滚。正例:重构与修复拆成 4 个 PR,各自独立评审、独立回滚,第 2 天全部合入。

§5 跨职能协作契约:规则——设计交互组件必须四态稿齐全、tokens 变更单对照语义令牌命名;后端 OpenAPI 评审通过才开工,错误码语义以附录A §5 为唯一口径(40102 无感刷新 / 40301 跳 403);测试缺陷单四要素(复现步骤/环境账号/期望实际/截图日志)缺一退回。反例:设计稿只有 happy path,订单导出失败态上线后才补设计,返工两天。正例:营销子系统接入契约 V1(第 21 章)评审冻结后双方并行开发,联调仅用半天。

⚠️ 常见易错点

易错点 22-1:Code Review 只挑格式

❌ 错误做法:reviewer 的评论全是「这里多个空格」「引号应该用单引号」「这行超过 80 字符」,而 200 行的核心逻辑变更无人置评——重要的设计问题被格式噪音淹没,作者也学会了「把 PR 拖到下班前提交」来逃过格式审查。

✅ 正确做法:分工明确——格式与风格属于机器(Prettier/ESLint + pre-commit 钩子,违规根本进不了仓库);人只评审机器审不出来的东西(十项清单的意图、设计、错误处理、测试)。团队可以正式立一条规矩:「review 评论中不允许出现任何 IDE 自动修复能解决的问题」,发现格式类评论一律让作者指回钩子配置。

易错点 22-2:照抄大厂流程

❌ 错误做法:6 人团队照搬某大厂的三级分支模型 + 双周版本火车 + 四级评审会——两周的开发量有一半花在「流程本身」上;或者反向照抄硅谷的「全员主干直推」,在没有 flag 体系与强 CI 的团队里裸奔上线。

✅ 正确做法:分支模型与发布形态匹配(§22.1 对比表):持续交付的 SaaS 用 GitHub Flow + flag;ToB 多版本并行才需要 Git Flow 的发布分支。流程的复杂度必须由真实痛点支付,每次新增一条流程前先问「它防的事故上季度发生过吗」——没有数据支撑的流程,半年后必然沦为仪式。

易错点 22-3:规范发在群里,不进仓库

❌ 错误做法:协作规范以飞书群公告 + 三页截图的形式存在,两周后没人找得到;新人入职拿到的是口口相传的「习惯」,三个月后同一份规范存在四个互相矛盾的版本。

✅ 正确做法:规范即代码——手册进 docs/(§22.5)、配置进仓库根(commitlint.config.js / pull_request_template.md)、执行交给 CI(分支保护 + lint 门禁 + changesets 检查)。检验标准很朴素:搜得到、链接得上、机器强制得了。改规范走 PR,让每次修订都有 diff 与讨论痕迹。

易错点 22-4:巨型 PR 一次合入

❌ 错误做法:一个 PR 同时包含「订单模块分层重构 + 新增筛选功能 + 修复导出 bug + 升级依赖」,1600 行 diff。reviewer 只能扫一眼放行(没人真能审完),出问题无法定位是哪个意图引入的,回滚只能全量回滚。

✅ 正确做法:一个 PR 只表达一个意图(§22.4 坏味道 2),重构、feature、修复、依赖升级分四个 PR;每个 PR ≤ 400 行——超过就说明该拆了。配套技巧:重构类 PR 用「纯移动不修改」的提交方式(diff 工具能识别 rename),让 reviewer 把注意力留给真正的逻辑变更。

易错点 22-5:跨职能契约靠口头与微信群

❌ 错误做法:「接口改了记得跟我说一声」、字段名在微信里敲定、设计四态「大概没问题吧」。结果:后端把 discountPercent 从 0–100 改成 0–1 没有公告,前端上线当天折扣全错;导出失败态没有设计稿,前端自己「看着办」。

✅ 正确做法:三个方向各有显式交付物(§22.6):设计四态稿 + tokens 变更单、后端 OpenAPI 评审 + 错误码口径统一引用附录A §5、测试用例评审 + 缺陷单四要素。契约文件放 docs/contracts/,变更走 PR——微信群里的承诺会蒸发,仓库里的契约能执行。

✅ 最佳实践

  1. 流程最小化:规范条数与团队规模成反比地审视——每条流程必须对应一个真实发生过的痛点,防「假想事故」的流程会在半年内被所有人默契绕过;
  2. 机器执行优先:凡是机器能保证的(格式、提交信息、依赖规则、changeset 检查)绝不靠人的自觉,人的注意力留给设计与意图;
  3. 十项清单顺序即阅读顺序:先意图后细节,一条不满足「意图一致」的 PR 值得直接打回,无需继续逐行审;
  4. SLA 双向约束:reviewer 有响应时限,作者也有回应时限——Review 文化 healthy 的标志是双向尊重,而不是单方面催促;
  5. 坏味道清单常换常新:每个季度从真实 review 记录里提炼新增坏味道、删除已绝迹的——清单是团队的镜子,不是圣旨;
  6. 文档分三层:手册(怎么做)/ ADR(为什么这么做)/ runbook(出事怎么办),三者互相链接但职责不混写;
  7. 新人任务即 onboarding:第一周用真实小任务串起全部流程(§22.5 任务表),比「先看一周代码」的新人快两周进入产出状态;
  8. 契约即代码:设计 tokens、OpenAPI、E2E 冒烟集三份契约文件都是仓库内可 diff 的资产,变更走 PR——跨职能协作的每个字都应可追溯。

📌 本章小结

  • 分支模型按团队规模与发布形态选:轻订单用 GitHub Flow + featureFlag,main 永远可发布,分支存活 ≤ 3 天;
  • Conventional Commits 让提交历史成为机器可读的变更日志,type 与 changesets 版本级别联动(feat→minor、fix→patch、!→major),门禁全部自动化;
  • PR 模板是作者自查、十项清单是 reviewer 之路,格式问题零人工——SLA 双向约束,坏味道清单每季度从真实案例中生长;
  • 文档三层(手册/ADR/runbook)+ onboarding 第一周任务表,让知识沉淀为「新人可自助、老手可引用」的资产;
  • 跨职能契约显式化:设计四态稿与 tokens、后端 OpenAPI 与错误码口径、测试用例评审与缺陷单四要素,全部进仓库可 diff;
  • 最终交付《前端团队协作手册》:骨架 + 示范正文 + 试运行修订——被真实使用的规范才是规范。

机制建好了,随之而来的问题是「面对新需求、新技术、新业务线时,怎么在众多选项里做出值得被写进 ADR 的选择」。下一章建立一套可复用的选型评估框架:八个维度、加权评分、公平的 Vue vs React 对比、自研与开源的决策树,以及一份可以直接套用的选型报告模板。

📄 第 23 章 · 技术选型决策 23-技术选型决策.md

📍 阶段:资深阶段(第 19–24 章)|⏱ 预计学时:6–8 小时|🔗 前置:第 19 章 ADR + 第 22 章 协作规范

⬅ 上一章:团队协作规范 | 📖 本版目录 | ➡ 下一章:资深实战 · 技术方案与架构评审

💡 对照学习:本页是 React 版第 23 章,对应 Vue 版第 23 章 · 技术选型决策。本章方法两版通用,示例代码为 React 生态:八维评估框架、自研决策树、技术债台账、选型报告模板全部与框架无关;§23.2 的 Vue vs React 对比在两版中立场互换(各从自己的视角公平陈述)——这正是公平对比的检验方式:两版对照阅读,结论应当互相成立。

选型是资深工程师最具杠杆的决策:一次选型决定未来三年的开发体验、招聘口径与维护成本。本章不做「哪个最强」的粉丝论战,而是建立可复用、可挑战、可追溯的决策基础设施:八维加权评估框架、三个完整案例、自研决策树、技术债台账,最后是一份可套用的选型报告模板。

🎯 学习目标

学完本章,你将能够:

  1. 使用八维度加权评分框架(业务匹配/生态成熟/团队现状/招聘/成本/风险/退出成本/时间窗)评估候选技术,并说明每个维度的观测方法;
  2. 从 React 视角公正对比 Vue 与 React(心智模型、TS 支持、生态、招聘、长期维护),给出「无银弹」的适用条件结论;
  3. 完成状态库、组件库、请求库三个选型案例的加权评分表,并与第 19 章 ADR-008/009 的结论互相印证;
  4. 用「自研 vs 开源」决策树判断是否自研,并说出自研的三条前置承诺;
  5. 按四象限给技术债分类,建立含偿还触发条件的债务台账,制定 20% 容量的偿还节奏;
  6. 用 Strangler 检查清单规划一次基础设施迁移(构建工具/框架版本),保证每步可回滚;
  7. 套用本章模板为一条新业务线输出完整选型报告(背景/评分/结论/风险/里程碑)。

📖 核心知识点

23.1 选型评估框架:八个维度,权重暴露你的真实优先级

选型失误的根源几乎都不是「不了解候选者」,而是没想清楚自己要什么。八维框架把「要什么」显式化——权重本身就是团队的价值观声明:

维度 权重参考 观测方法(怎么打分才不是拍脑袋)
业务匹配度 20% 需求里对技术有决定性的 3–5 条(SSR?万级表格?离线?),候选逐一对照
生态成熟度 15% 官方文档质量、周边插件、最近一年的发版频率、大版本迁移是否平滑
团队现状 15% 现有技能栈、存量代码、学习曲线评估(用 2 天 spike 验证)
招聘可得性 10% 招聘平台上候选人存量、新人上手周期(问面试官同行)
成本 10% 学习成本、集成成本、许可协议(GPL 系注意商用合规)
风险 10% 维护者集中度(个人维护 vs 组织维护)、issue 响应时长、破坏性变更频率
退出成本 10% 是否深入渗透业务代码;能否用适配层隔离;数据/代码可迁移性
时间窗 10% 项目节奏 vs 候选成熟度;过度追新的机会成本

评分纪律三条:① 每个维度的分数必须附依据(链接、数据、spike 结论),无依据的分数无效;② 权重调整必须留痕——「时间窗从 10% 提到 25%」意味着这个项目只求快速上线,这句话要写进报告;③ 加权总分接近时(差距 < 5%),不是比谁的分数高,而是比谁的退出成本更可控——选便宜的后悔。

「团队现状」「成本」两维的分数来自动手验证,不是来自想象。开评分会之前,先做一个 ≤ 2 天的 spike(最小验证):

选型 spike 清单(2 天封顶,产出直接决定两维分数)
□ 用候选库完成「决定性需求」的最小闭环
   (例:表单库 → 写一个带动态明细行的订单表单;图表库 → 画一张万级点折线)
□ 记录卡点清单:文档缺失在哪?类型报错在哪?与现有链路(Vite/TS/Query)冲突在哪?
□ 测量集成成本:安装配置耗时、构建产物体积变化(bundlephobia + 本地 visualizer)
□ 输出一页结论:能/不能覆盖决定性需求 + 三个最大隐患
   ——这页结论就是评分表里「依据」栏的实体

其中「退出成本」维度最容易被打高分却最少被认真评估。评分前先过一遍三问:

退出成本评估三问(该维度打分前必答)
□ 该库渗透了哪些层?(仅个别页面组件 / 全局 Provider / 构建配置链路)
□ 有标准替代 API 吗?(能用 React 标准能力或自研薄层替代的,不深度绑定专属特性)
□ 适配层接口今天写得出吗?——写不出,说明渗透已过深,无论选谁都该先加一层

评分会的现场计算不必口算——一个小脚本就能保证加权不出错、且计算过程可复核:

// scripts/selection-score.ts —— 八维加权计算器(评分会现场可跑,防口算翻车)
const weights = {
  business: 0.2, ecosystem: 0.15, team: 0.15, hiring: 0.1,
  cost: 0.1, risk: 0.1, exit: 0.1, timing: 0.1,
} as const;

type Scores = Record<keyof typeof weights, number>;

const candidates: Record<string, Scores> = {
  zustand: { business: 5, ecosystem: 5, team: 4, hiring: 4, cost: 5, risk: 4, exit: 4, timing: 5 },
  rtk:     { business: 3, ecosystem: 5, team: 5, hiring: 5, cost: 4, risk: 4, exit: 3, timing: 5 },
};

function weighted(scores: Scores): number {
  return Object.entries(weights)
    .reduce((sum, [dim, w]) => sum + scores[dim as keyof typeof weights] * w, 0);
}

for (const [name, scores] of Object.entries(candidates)) {
  console.log(name.padEnd(10), weighted(scores).toFixed(2));
}

23.2 Vue vs React:从 React 视角的公平对比

本教程的双版本结构本身就表明立场:两者都是成熟的一线选择。公平对比的检验标准是——Vue 版读到这段也应当点头。

维度 React Vue
心智模型 UI = f(state);不可变更新,重渲染控制(memo/useMemo)是开发者责任,粒度自己定 响应式自动依赖收集,默认更新粒度细,心智负担更小
学习曲线 JSX + Hooks 对纯新手有抽象门槛(闭包、依赖数组) 模板语法渐进友好,但 Composition API 同样需要时间
TypeScript TSX 一等公民,类型即代码,泛型组件直接 模板层 TS 支持近年大幅完善,但工具链(宏/泛型)复杂度仍高于纯 TSX
生态形态 社区驱动、选择极多(路由/请求/状态各有事实标准),需要自组装配 官方全家桶(Router/Pinia/Vite 协同),装配成本低、一致性高
招聘与市场 市场份额领先,候选人池大 市场份额稳定,中文社区尤其深厚
服务端方案 Next.js/Remix 生态活跃(本教程 v2 的岛屿方案相关) Nuxt 提供同等能力的官方一体化方案
大型项目组织 无官方强制范式,靠约定(如本教程第 19 章分层) SFC 单文件组件天然聚拢,官方风格指南明确
长期维护 Meta 主导 + 超大社区,React 19 持续演进 核心团队稳定、版本纪律好,Vue 3.5+ 持续演进

适用条件结论(而非优劣结论):团队已有深度投资与人才储备的一侧优先;重交互粒度控制、跨端(React Native)与超大型生态整合倾向 React;追求官方装配一致性、团队以中级为主、快速交付倾向 Vue。技术栈迁移的成本通常大于两者差异带来的收益——这条结论写进轻订单的选型纪律:不因框架对比而重写存量系统。

两个框架的差异最终都落到同一句需求上。用第 04 章的「订单筛选联动」做一次最小心智对照(公平起见,两版教程会用各自的语言写出等价实现):

// React:显式声明依赖——「我用了什么、变化时重跑什么」由开发者写进依赖数组
const [status, setStatus] = useState<OrderStatus | 'all'>('all');

useEffect(() => {
  fetchOrderCount(status);          // 状态变化 → 显式声明的副作用重跑
}, [status]);                       // 依赖数组即契约:漏写就是过期闭包 bug(第 04 章)
// Vue:隐式依赖收集——「用到谁就追踪谁,变了就重跑」由框架自动完成
// const status = ref<OrderStatus | 'all'>('all');
// watchEffect(() => { fetchOrderCount(status.value); });
// ↑ 没有依赖数组:fetchCount 里读了 status.value,框架自动建立依赖

两种模型的工程含义:React 的显式性带来更多样板与心智负担,但行为完全可预测、静态可分析(大型团队协作友好);Vue 的自动化降低上手成本,但依赖关系隐式存在于运行时。它们是同一权衡(显式 vs 自动)的两个解——这也是为什么本教程坚持双版本对照:概念映射见各章「对照学习」框。

23.3 案例一:状态库选型(RTK / Zustand / Jotai)

需求背景:第 19 章分层后,存量 26 个 RTK slice 与新增的轻量客户端态并存,需要定一份「新模块用哪个」的团队标准(即 ADR-008 的评分过程还原):

维度(权重) Redux Toolkit Zustand Jotai
业务匹配度 20% 3(重中间件场景强,轻状态繁琐) 5(无 Provider、样板极少,符合订单暂存区类需求) 4(原子化优雅,但团队无使用经验)
生态成熟度 15% 5(十年打磨,中间件丰富) 5(事实标准级,维护活跃) 4(活跃但社区规模较小)
团队现状 15% 5(26 个存量 slice、全员熟练) 4(第 08 章已学习对比) 2(零经验,学习成本真实存在)
招聘 10% 5 4 2
成本 10% 4(样板代码是隐性成本) 5 4
风险 10% 4 4 3(核心维护者集中度更高)
退出成本 10% 3(深度渗透存量) 4(store 独立、易替换) 3
时间窗 10% 5(无需引入) 5 4
加权总分 4.30 4.55 3.40

结论:与 ADR-008 一致——新增模块 Zustand、存量 RTK 保留,附加三分法约束(§19.4)。注意评分过程暴露的关键事实:Jotai 输掉的主要不是技术,而是「团队现状 + 招聘」两个组织维度——这正是八维框架的价值,它阻止了「炫技型选型」。

23.4 案例二:组件库选型(AntD 5 / MUI / 纯自研)

需求背景:轻订单需要中后台密集表单/表格 + 暗色主题 + 品牌定制,且已有设计令牌体系(第 20 章):

维度(权重) Ant Design 5 MUI 纯自研(基于无头库)
业务匹配度 20% 5(中后台组件全、Table/Form 强、暗色算法现成) 3(Material 风格改造量大,表格弱) 4(完全可控但要补齐全部基础件)
生态成熟度 15% 5(中文生态最深、坑都有解) 5 1
团队现状 15% 5(第 06 章起已使用) 2 2
招聘 10% 5 3 1
成本 10% 5(直接可用) 3 1(3 人年起步)
风险 10% 4(组织维护、版本稳定) 4 5(无外部依赖即无外部风险,但全靠自觉)
退出成本 10% 3(组件渗透全部页面) 3 5
时间窗 10% 5 3 1
加权总分 4.70 3.35 2.70

结论:AntD 5 为基座 + qing-ui 三层封装(第 20 章)——品牌与语义收敛在令牌与封装层,基础件不复造。「纯自研」得低分的根本原因不是做不出来,而是3 人年的投入换不来业务差异化价值。这也是 §23.6 决策树的典型输出。

「退出成本」维度的落点就是适配层——选 AntD 的同时立刻声明:业务代码只允许依赖 qing-ui 的 API,不直接接触 AntD 的 props。适配层接口长这样:

// packages/qing-ui/src/components/QTable/QTable.tsx —— 适配层即逃生门(骨架)
// 业务侧只见业务口径的 props;换底层库(如未来迁移到其他 Table 实现)只改本文件内部

export interface QingTableProps<T> {
  data: T[];
  columns: Array<{
    key: string;
    title: string;
    render?: (row: T) => ReactNode;       // 业务渲染,不暴露底层列配置结构
  }>;
  pageSize?: number;                      // 默认 20(附录A 分页口径),收敛调用方心智
  emptyText?: string;                     // 默认统一空态文案(第 20 章协议)
  onRowClick?: (row: T) => void;
}

export function QTable<T>(props: QingTableProps<T>) {
  // 内部:AntD Table 的映射逻辑。升级/更换底层时,业务代码零改动。
  return null; /* 实现见 qing-ui 仓库,此为接口契约示意 */
}

23.5 案例三:请求库选型(TanStack Query / SWR / RTK Query)

需求背景:服务端状态管理标准化(ADR-009 的评分过程还原)。三家都能覆盖「缓存、去重、重试、竞态」的基本盘,差异在细节与适配性:

维度(权重) TanStack Query v5 SWR RTK Query
业务匹配度 20% 5(乐观更新/无限滚动/失效粒度全齐) 4(缓存与重验证优秀,Mutation 与失效偏简) 4(端到端类型好,缓存失效粒度较粗)
生态成熟度 15% 5(框架无关、文档极佳) 5 4
团队现状 15% 4(第 09 章已建立范式) 4 5(存量 RTK 团队零成本)
招聘 10% 5 4 4
成本 10% 4(概念 2 天上手) 5 4
风险 10% 5 5 4(与 Redux 深度绑定)
退出成本 10% 4 4 2(API 层生成代码与 Redux 耦合)
时间窗 10% 5 5 4
加权总分 4.70 4.35 3.75

结论:与 ADR-009 一致——TanStack Query v5。决定性差异在「业务匹配度」:轻订单的乐观更新(订单审核列表)与分页筛选(订单列表)正好落在它的强项区,而 RTK Query 强绑 Redux 的特质在未来「存量 RTK 逐步瘦身」的路线下反而成为负担。三个案例连起来读你会发现:评分表的结论与 ADR 一一对应——评分是 ADR 的论据生成器,ADR 是评分的存档形式。

23.6 自研 vs 开源:一棵决策树

「自己写一个」是工程师最常见的冲动。决策树把冲动变成流程:

要解决的需求,现有开源能覆盖多少?
│
├─ ≥ 80% 且差异可以通过配置/扩展点表达
│     └─► 用开源 + 薄适配层(qing-ui 封装 AntD 即此路径)
│           适配层是未来的逃生门:换底层库时业务代码不动
│
├─ 覆盖 40%–80%,差异是核心能力(不是边角)
│     └─► 先问三个问题:
│           ① 这块能力是业务差异化价值吗?(是 → 自研预算;否 → 再找)
│           ② 三年维护成本算过吗?(初版成本 × 3 是诚实的估计)
│           ③ 有没有更便宜的「组合开源」方案?
│           三问全过 → 自研;任一存疑 → 调整需求或继续找
│
└─ 覆盖 < 40%(全新领域,开源真空)
      └─► 自研,但先做 1–2 周 POC 验证核心难度,
            POC 结束开评审会:继续 / 降级需求 / 外包采购

自研的三条前置承诺(写进 ADR,缺一不批):维护承诺(指定长期 owner 与响应 SLA)、文档承诺(Storybook/文档站从第一天就有,第 20 章标准)、退出承诺(公开「弃用时的替代方案」——自研的东西更需要退出计划)。

决策树走到「自研」分支后,POC 结束的评审用这一页纸收敛(防止 POC 变成没有出口的泥潭):

<!-- POC 结论一页纸 —— 评审通过才立项,不通过立即止损 -->
## <自研项目> POC 结论
- 核心难度验证:<做到了什么 / 什么没做到>(附 demo 链接与跑通命令)
- 成本重估:初版 X 人周;三年维护 ≈ 3X 人周(§23.6 公式),对比组合开源方案 Y 人周
- 三承诺:owner=<姓名> SLA=<响应时限>|文档=<文档站链接>|退出=<替代方案草稿>
- 结论:继续立项 / 降级需求 / 转向组合开源 / 采购
- 若继续:里程碑与验收标准(同第 24 章 §24.2 口径)

23.7 技术债管理:给债记账,给偿还定节奏

技术债的可怕不在存在,而在隐形。先用四象限定性:

                 有意为之(当时知道是债)
                 ┌──────────────────────┬──────────────────────┐
   影响面小      │ 微观·有意:临时 if 分支 │ 宏观·有意:为赶上线     │
   (局部)      │ 硬编码的开关(有注释)   │ 接受的整包 JS 产物      │
                 ├──────────────────────┼──────────────────────┤
   影响面大      │ 微观·无意:某个 hook   │ 宏观·无意:无依赖规则   │
   (系统性)    │ 的依赖数组写错          │ 的单体(19 章之前)     │
                 └──────────────────────┴──────────────────────┘
   右上(宏观·有意)与右下(宏观·无意)最危险:右上有偿还计划就不危险,
   右下靠架构体检(第 19 章练习 1)与监控告警(第 18 章)暴露。

债务台账模板(docs/tech-debts.md,进评审会的硬性输入):

编号 描述 象限 影响 偿还触发条件 预估 负责人
TD-011 订单导出未走流式接口,大数据量内存峰值高 宏观·有意 导出 > 5 万行时卡顿 触发条件:单次导出超 5 万行的需求到达 3 人日 张三
TD-014 portal 仍有 2 个 JS 文件未迁 TS 微观·有意 类型保护缺失 触发条件:该文件下一次功能改动顺路迁移 0.5 人日 李四
TD-017 存量 26 个 RTK slice 未按三分法重归置 宏观·有意 新人心智负担 触发条件:ADR-008 回顾期到达(6 个月) 5 人日 王五

偿还节奏两条线:定量的 20%——每个迭代约 20% 容量用于偿还与基建(管理层沟通话术见第 24 章);顺路的 80%——台账里标注「触发条件」的债,在条件到达时随功能 PR 顺路偿还(TD-014 模式)。台账条目偿还后不删除,标记状态并保留——它是团队「说到做到」的信用记录。

「顺路偿还」长什么样?以 TD-011(导出内存峰值)为例——债务入库时的代码与偿还后的代码,正是台账价值的实体证据:

// 入账时(宏观·有意):先全量拉进内存再转 CSV——上线快,但 5 万行 ≈ 800MB 峰值
const all = await fetchAllOrders(filters);        // 一次请求拉全量
downloadCsv(toCsv(all.rows));                     // 全量驻留内存
// 顺路偿还后(触发条件「单次导出超 5 万行的需求到达」已满足):
// 分页流式拉取 + Web Worker 聚合,内存峰值与总量解耦(详细方案见第 24 章练习 2)
const writer = new StreamingCsvWriter('orders-export.csv');
for await (const page of streamOrders(filters, { pageSize: 1000 })) {
  writer.append(toCsv(page.rows));                // 每页即转即写,不驻留
}
await writer.flushAndDownload();

注意偿还 PR 的形态:债务修复与功能改动分成两个 commit,PR 描述引用台账编号(TD-011)——这样评审者能区分「这是偿还是新增」,git 历史里也能单独回滚。台账该条目标记「已偿还 @ PR #236」,保留不删。

与台账联动的最后一环是代码内标记——债在哪一行,搜得到才算账实相符:

// TODO(debt:TD-011): 全量拉取待改流式(台账 docs/tech-debts.md,触发条件:>5 万行需求)
// CI 每周统计 TODO(debt:*) 标记与台账的差集:代码里有、台账没有 → 补记;反之 → 清理
const all = await fetchAllOrders(filters);

23.8 渐进迁移:基础设施升级的 Strangler 检查清单

第 21 章的 Strangler 模式同样适用于技术栈迁移(构建工具、框架大版本、请求库替换)。检查清单六条,逐条打勾再开工:

□ ① 增量路径:能否「新代码新方案、旧代码旧方案」并存?(Vite 可按应用逐个迁;
      TanStack Query 按页面逐个迁——共存期产物要验证构建无冲突)
□ ② 行为对齐的验收物:迁移前后 E2E 冒烟集全绿是唯一验收标准,
      不接受「看起来一样」的口头验收
□ ③ 回滚开关:每个迁移步骤有独立回滚(git revert 粒度 = PR 粒度)
□ ④ 双跑期:关键路径新旧并行跑一个迭代,监控指标(错误率/性能)对齐后切流
□ ⑤ 止损终点:共存期设明确 deadline(通常 ≤ 2 个季度),到期强制收敛或放弃
□ ⑥ 记录:每步产出迁移记录(改动面/验证结果/指标对比),
      它同时是第 24 章技术方案的里程碑证据

轻订单实例:CRA 时代遗留的门户应用迁 Vite——先在新分支跑通构建(增量路径),冒烟集对齐(行为验收),灰度发布双跑一周对比 LCP 与错误率(双跑期),稳定后删除 webpack 配置(止损终点),全程三个 PR 三个独立回滚点(回滚开关),最终迁移记录归档 docs/postmortem/。

迁移记录的模板形态(§23.8 清单第 ⑥ 条的产出物):

<!-- docs/migrations/2026-05-portal-vite.md —— 每次迁移一份,归档可查 -->
## 迁移记录 · portal 从 CRA 迁移 Vite
- 改动面:构建配置 3 文件、入口改造 1 处、环境变量前缀统一为 VITE_
- 验证:E2E 冒烟集 12/12 通过;单测无回归;构建产物 412KB → 356KB(gzip)
- 指标对比:本地冷启动 14.2s → 1.8s;CI 构建时长 3m40s → 1m10s;LCP 持平
- 回滚:revert PR #218 即完整恢复 CRA;双跑期 7 天未触发回滚
- 遗留:CRA 专用的 jest 配置已随 ESLint 迁移至 Vitest(无遗留债)

23.9 新业务线选型报告模板(可套用)

把本章所有工具装配成一份可直接套用的报告骨架——七节结构,与第 24 章的技术方案七段法一脉相承:

# 技术选型报告 · <项目/业务线名>
> 作者 / 日期 / 状态(草案·评审中·定稿)/ 关联 ADR

## 1 背景与约束
业务背景 3 句;团队规模与技能现状;交付时间窗;存量系统与依赖清单。
## 2 决定性需求(3–5 条)
只列对技术决策有区分度的需求(SSR?万级表格?合规审计?离线?),
每条标注来源(需求文档章节 / 性能口径 / 合规要求)。
## 3 候选方案(2–4 个)
说明来源(团队提名 / 行业调研),不超过 4 个——候选过多说明需求没想清。
## 4 八维加权评分表
权重 + 每格分数 + 一句依据(spike 结论/数据链接);权重调整必须注明原因。
## 5 结论与约束条件
一段话结论 + 附加约束(适配层要求/使用边界/版本锁定策略)。
## 6 风险与退出预案
Top3 风险与缓解措施;退出路径(适配层接口/数据迁移方案)。
## 7 里程碑
分阶段交付计划,每个里程碑配独立验收标准(与第 24 章口径一致)。

报告定稿前,请一位「魔鬼代言人」按下面十问攻击一遍——答不上任何一问,就回炉重写:

选型报告「魔鬼代言人」十问
□ ① 决定性需求里的每一条,真的对技术选型有区分度吗?
□ ② 被拒候选的代价分析,能不能也套用到你选的它身上?
□ ③ 每格分数的依据,换一个人独立评估会得到同样的分吗?
□ ④ 权重为什么这么定?如果时间窗从 10% 变 25%,结论会翻转吗?
□ ⑤ 一年后这个库不维护了,退出预案的第一步动作是什么?
□ ⑥ 团队里最反对这个选择的人,他的理由你写进报告了吗?
□ ⑦ 这个选择锁死了哪些未来的选择?(机会成本)
□ ⑧ spike 里遇到的三个最大隐患,方案里都有对应缓解吗?
□ ⑨ 如果预算减半、工期减半,你还会做同样的选择吗?
□ ⑩ 这份结论与既有 ADR 冲突吗?冲突时更新谁?

🛠 渐进式实战练习

练习 1 · 基础(10–20 min):表单库八维评分

任务描述:为轻订单「新建订单表单」(动态明细行、金额联动校验、 React Hook Form + Zod 已在第 10 章使用)的选型做一次完整八维评分:候选为 React Hook Form + Zod、Formik、自研表单 Hook。每个维度给出分数与一句依据,得出加权总分与结论。

提示:先写「需求里对技术有决定性的 3 条」再评分——例如「动态明细行性能(避免全表单重渲染)」「schema 复用(前后端校验同源)」「与 qing-ui 受控组件的集成成本」;这三条会直接决定业务匹配度维度的分数。

验收标准:完整评分表(8 维 × 3 候选 × 依据),结论一段话(含「若分数接近则比退出成本」的应用)。

参考答案(评分要点)

决定性需求三条:动态明细行渲染性能、Zod schema 前后端同源、非受控底层与 qing-ui 的集成成本。评分示例:RHF+Zod 业务匹配度 5(unregister 机制天然适合动态明细行、Zod 同源、非受控默认);Formik 业务匹配度 3(受控模型在长表单上重渲染压力大、schema 需引入 yup 另起一套);自研业务匹配度 4(贴合需求但维护成本高)。加权后 RHF+Zod ≈ 4.7、Formik ≈ 3.4、自研 ≈ 2.8,与第 10 章的既成选择互相印证。评分结论写法示范:「RHF+Zod 与 Formik 在生态/成本维度接近,业务匹配度(动态行 + schema 同源)是决定性差距;自研因『三年维护承诺』无法满足 §23.6 前置承诺而否决。」

练习 2 · 进阶(30–60 min):图表库选型报告

任务描述:轻订单报表中心要升级(§3.6:销售报表多维度聚合 + 大数据量渲染),候选:ECharts、Ant Design Charts(G2Plot 系)、Recharts。产出一份 2–3 页选型报告:背景与约束、决定性需求、八维评分表、结论、风险与退出预案。

提示:决定性需求候选——大数据量(万级折线)渲染性能、按月/按销售/按商品维度切换的交互成本、与 AntD 5 的视觉一致性、暗色主题支持、tree-shaking 后体积;注意「业务匹配度」要逐条对照这五项打分而不是笼统打分。

验收标准:报告含完整八维表(每格一句依据)、明确结论 + 附加约束(如「统一封装为 @qing/charts 隔离直接依赖」)、退出预案(适配层接口设计草稿)。

参考答案(报告骨架示范)
# 技术选型报告 · 报表中心图表库
## 1 背景与约束
6 人前端团队;存量 AntD 5 + qing-ui;报表中心要支撑万级点折线、
三维度聚合切换、暗色主题;交付时间窗 6 周。
## 2 决定性需求
① 万级数据渲染不卡顿(附录A §4 性能口径)② 维度切换交互成本
③ 与 AntD 视觉/暗色一致 ④ 按需打包体积 ≤ 300KB 预算(首屏口径)
## 3 加权评分表
(8 维 × 3 候选,示例行)业务匹配度 20%:ECharts 5(大数据渲染与
维度联动最强)/ AntD Charts 4(一致性最好,万级性能一般)/ Recharts 3(
SVG 路线大数据弱)……
## 4 结论
ECharts 为主,统一封装为 @qing/charts:对外暴露业务语义 API
(SalesTrendChart、DimensionSwitcher),内部隔离 ECharts 实例与主题注入。
## 5 风险与退出预案
风险:ECharts 全量包体积大 → 按需引入 ECharts 核心图表模块。
退出预案:@qing/charts 的 props 即适配层接口,换底层库只改封装内部。
## 6 里程碑
W1 封装层与 POC → W2–3 报表三页迁移 → W4 视觉走查与暗色 → W5 灰度。

练习 3 · 挑战(选做,60+ min):新业务线完整选型报告

任务描述:青云科技立项「渠道对账系统」(财务团队专用:对接三个电商平台账单、日千万行数据比对、差异标记工作流)。作为前端负责人,产出完整选型报告:技术栈(框架沿用 React 的理由也要论证)、表格方案(虚拟滚动 + 服务端分页的组件选型)、数据可视化方案、是否需要新建独立应用还是复用 admin(结合 §19.1 复杂度评估与第 21 章「要不要拆」)。

提示:这份报告的关键不是「选哪个库」,而是架构层面先回答两个问题——新建应用还是 admin 内新模块?需要微前端/独立仓库吗?然后再谈库级选型。财务场景的合规约束(数据导出审计)也要进「决定性需求」。

验收标准:报告结构完整(§23.9 模板七节),含架构层决策(新应用 vs 模块)+ 库级选型(≥2 个)+ 八维评分表 ×2 + 里程碑;可挑战性检查:找一个同学用「魔鬼代言人」角色攻击你的结论,报告附录记录攻击点与回应。

参考答案(架构层决策要点)

架构层:财务团队独立(3 人)+ 数据敏感(独立部署/审计要求)+ 与 admin 共享登录与 qing-ui → 倾向 Monorepo 内新建 apps/ledger 独立应用(复用 packages/,不拆微前端——同团队同节奏,不满足第 21 章拆分信号)。库级:表格用 TanStack Virtual + 服务端分页(千万行必然服务端聚合,前端只渲染窗口);比对逻辑在服务端(前端不做千万行计算——这条要写进「非目标」防止范围蔓延);可视化复用练习 2 的 @qing/charts。魔鬼代言人常见攻击与回应示例:「三个平台账单格式不同,前端做格式适配组件?」→ 回应:格式归一化是后端职责(写入非目标),前端只消费统一 schema;「React 19 太新?」→ 回应:React 19 已稳定一年+,存量 admin 已在用,同栈降低维护面。附录里留下攻击与回应,就是这份报告「可挑战」的证明。

⚠️ 常见易错点

易错点 23-1:只看 star 数选型

❌ 错误做法:搜索结果按 star 排序,第一名直接用。半年后发现:万 star 的库是作者个人项目,issue 三个月无人回复;star 高是因为教程带火而非生产验证;README 里连迁移指南都没有——版本一升级全团队停工。

✅ 正确做法:star 只反映「关注度」,评估要看「健康度」——最近一年的发版频率、issue 平均响应时长、BREAKING CHANGE 频率与迁移成本、维护者是个人还是组织(组织维护抗风险能力更强)、生产使用案例(依赖它的知名项目)。这些信息全部在 GitHub 的 Insights 与 issue 列表里,评估成本不超过 20 分钟,却挡掉 80% 的坑。

易错点 23-2:选型报告写成功能清单

❌ 错误做法:报告通篇是「候选 A 支持 X、候选 B 支持 Y」的功能罗列,最后以「综合考虑选 A」收尾——「综合考虑」没有留下任何可检验的推理。评审会上第一个较真的问题(「为什么不是 B?」)就把报告击穿。

✅ 正确做法:报告的骨架是权衡不是罗列:决定性需求先行(3–5 条)、八维评分附依据、分数接近时比退出成本(§23.1)。每一条结论都能追溯到「我们的需求是什么 → 候选在这个维度表现如何 → 证据是什么」。检验标准:把候选名遮住只看论证,能判断出结论,才算合格的报告。

易错点 23-3:忽视退出成本

❌ 错误做法:选了某全家桶框架,路由、状态、请求、DI 全用它的专属 API,深度渗透 200 个组件。一年后全家桶停止维护,评估替代方案时发现等于重写——「换库」在当初的对比表里看起来只是换一个 npm 包。

✅ 正确做法:选型时把「退出成本」当独立维度打分,并采取结构性防御:薄适配层隔离(qing-ui 封装 AntD、@qing/charts 封装 ECharts、http 基座隔离 axios——业务代码只见自家 API);专属能力(如全家桶的 DI)即使好用也评估「不用它的代价」,能用标准 API(React Context/TanStack Query)替代的绝不深度绑定。

易错点 23-4:追新框架踩时间窗

❌ 错误做法:某新框架发布一个月,团队在业务交付的关键期全员拥抱——坑没人踩过、文档不全、周边生态真空;三个月后作者本人都弃更了,团队回迁时业务已被拖慢一个季度。

✅ 正确做法:时间窗维度诚实打分(§23.1):业务交付压力大的时期,候选必须是「成熟期」技术(至少 1.0 + 一年生产验证 + 活跃社区);对新技术的真实需求用低成本探针满足——一次技术分享、一个内部小工具先行验证,而不是业务系统当试验田。轻订单的纪律:业务仓库只进成熟期依赖,新技术先进 sandbox 仓库。

易错点 23-5:技术债只欠不记

❌ 错误做法:「先这样,回头再优化」说了二十次,没有任何记录。一年后没人记得哪些地方是「先这样」,新人把临时方案当成规范来学;重构时不知道哪里是债、不知道从哪还起——债务从名单变成了地貌。

✅ 正确做法:台账纪律(§23.7):每次说「先这样」的同一天写进 docs/tech-debts.md(描述/象限/影响/触发条件/预估);评审会开场先过台账;偿还后标记保留。技术债不可怕,可记录、有触发条件、有人认领的债是良性的——它甚至是有价值的「明知故借」(用债务换交付速度的正当交易)。

✅ 最佳实践

  1. 先写决定性需求再评分:业务匹配度维度的分数必须逐条对照需求打——没有需求清单的评分是情绪,有清单的评分才是论证;
  2. 权重是价值观声明:每次评分先确认权重(时间紧的项目时间窗加权),调整必须写进报告——同样两个库,权重不同结论可以都正确;
  3. 分数接近比退出成本:加权分差 < 5% 时不要纠结分数,选「将来后悔时更便宜换掉」的那个,并用适配层立刻降低这个成本;
  4. 评分表进 ADR:§23.3–23.5 的三个案例证明——评分表是 ADR 的论据生成器,结论必须在两类文档里互相印证,否则就是一次性讨论;
  5. 自研走决策树 + 三承诺:维护承诺、文档承诺、退出承诺缺一不批;3 人年成本的问题优先组合开源解决;
  6. 技术债台账化:每个「先这样」当天入账,20% 定量偿还 + 触发条件顺路偿还,台账是团队信用记录;
  7. 迁移六条清单:增量路径、验收物、回滚开关、双跑期、止损终点、记录——任何一条缺失的迁移方案不配开工;
  8. 框架对比看适用条件:Vue 与 React 的对比结论是条件句而非判断句;本教程双版本互为对照,是防止立场偏见的最好机制。

📌 本章小结

  • 选型的核心是先想清楚「自己要什么」:八维框架(业务匹配/生态/团队/招聘/成本/风险/退出成本/时间窗)把价值观显式化,每格分数必须有依据,权重调整必须留痕;
  • Vue vs React 是条件句不是判断句:心智模型、生态形态、招聘各有适用条件,两版教程互为对照是公平性的检验机制;
  • 三个案例(状态库/组件库/请求库)完整演示了评分到 ADR 的闭环:评分表生成论据,ADR 存档结论,两者互相印证;
  • 自研走决策树:覆盖 ≥ 80% 用开源 + 适配层,自研需过「业务差异化 × 三年成本 × 组合方案」三问,附维护/文档/退出三承诺;
  • 技术债四象限定性 + 台账记账 + 20%/顺路双线偿还;迁移走 Strangler 六条清单,每步可回滚、有终点、有记录;
  • 选型报告七节模板(背景/需求/评分/结论/风险/退出/里程碑)可直接套用于新业务线,并经得起「魔鬼代言人」的攻击。

有了框架与台账,最后一个问题水到渠成:把这一切装进一份正式的技术方案文档,并让它在架构评审会上活下来。下一章是全书实战收官——轻订单 v2 技术方案全文示例、评审流程与 checklist、向上管理的叙事方法、季度技术规划,以及你的毕业项目。

📄 第 24 章 · 资深实战 —— 技术方案与架构评审 24-资深实战-技术方案与架构评审.md

📍 阶段:资深阶段(第 19–24 章)|⏱ 预计学时:8–10 小时|🔗 前置:第 19–23 章(本章是全书实战收官)

⬅ 上一章:技术选型决策 | 📖 本版目录 | ➡ 下一章:附录 B · 面试题精选

💡 对照学习:本页是 React 版第 24 章,对应 Vue 版第 24 章 · 资深实战。本章方法两版通用,示例代码为 React 生态:方案写作法、评审流程、向上管理与季度规划完全与框架无关;轻订单 v2 方案中的岛屿渲染示例用 React(复用 qing-ui 组件与 TanStack Query),权限 SDK 用 React Context 实现(≈ Vue 版的 provide/inject + 自定义指令 <Auth> 等价物)。

这是全书的最后一章。前面五章你已经建立了完整的能力栈:分层架构(19)、组件库(20)、微前端(21)、协作规范(22)、选型框架(23)。本章把它们装订成资深工程师的日常工作形态:写一份能通过评审的技术方案、主持一场高质量的架构评审、向管理层争取技术投入、做季度规划——最后以轻订单 v2 方案全文示例和你的毕业项目收尾。

🎯 学习目标

学完本章,你将能够:

  1. 按「背景 / 目标 / 非目标 / 方案对比 / 详细设计 / 灰度与回滚 / 里程碑」七段结构撰写技术方案文档,并说出每一段的读者与写作要点;
  2. 区分「目标」与「非目标」的防蔓延作用,把模糊需求(「体验优化一下」)改写成可验收的目标;
  3. 完整产出轻订单 v2 技术方案:渲染架构演进(CSR → CSR + 局部 SSR/岛屿)与权限中台化重构的方案全文;
  4. 组织一场架构评审:角色分工、文档预审、60 分钟议程、四类结论与行动项闭环;
  5. 用 20 条架构评审 checklist 对方案自审,在评审会之前消灭掉 80% 的问题;
  6. 用「业务风险 → 方案成本 → 所需支持」三段式向管理层陈述技术投入,并应用 70/20/10 模板完成季度技术规划;
  7. 选择并规划一个全书毕业项目,把第 19–24 章的产出物组装成可展示的工程作品集。

📖 核心知识点

24.1 技术方案写作法:写给三种读者的一页纸优先文档

技术方案文档不是作文,是决策与执行的契约。它有三类读者,各取所需:

┌──────────────┬────────────────────────────┬──────────────────┐
│ 读者          │ 他们读哪一段                │ 写作要点           │
├──────────────┼────────────────────────────┼──────────────────┤
│ 评审者/管理层 │ 背景、目标/非目标、方案对比、 │ 权衡而非细节;      │
│              │ 里程碑                      │ 一页纸能讲清骨架    │
├──────────────┼────────────────────────────┼──────────────────┤
│ 执行者(队友) │ 详细设计、接口契约、数据流    │ 可照着开工;歧义为零 │
├──────────────┼────────────────────────────┼──────────────────┤
│ 未来维护者    │ 非目标(为什么没做 X)、     │ 记录当时的边界       │
│ (半年后)    │ 灰度记录、风险表            │ 与已知妥协           │
└──────────────┴────────────────────────────┴──────────────────┘

七段结构与写作要点:

段落 回答的问题 常见败笔
① 背景 为什么现在做?现状的痛点有什么数据? 写成项目介绍,无数据
② 目标 做完怎么算成功?3–5 条可量化 「提升体验」这种不可验收的词
③ 非目标 明确不做什么,防止范围蔓延 缺失——最容易被忽略却最能救命
④ 方案对比 考虑过哪些路?各自代价?为何选这条路? 只写选中的方案(等于没做对比)
⑤ 详细设计 执行者怎么开工?接口/数据流/目录 照抄需求文档;或细节淹没权衡
⑥ 灰度与回滚 出问题怎么发现、怎么退? 缺失——理想路径文档
⑦ 里程碑 分几步交付?每步验收标准? 只有日期没有验收标准

写作纪律:先一页纸后全文——先写满一页纸的版本(每段三句话)拿去对齐方向,方向确认后再展开全文;「非目标」段永远写全(它同时是范围契约与未来维护者的地图);全文不超过 10 页,超过 10 页说明该拆方案了。

24.2 完整案例:轻订单 v2 技术方案(全文示例)

以下是贯穿项目 v2 方案的正文(节选关键段并保持完整结构,可直接作为模板套用):

# 轻订单 v2 技术方案 · 渲染架构演进与权限中台化
> 作者:前端架构组 | 状态:评审中 | 关联:ADR-008/009/011、第 21 章营销接入契约 V1

## 1. 背景
轻订单 1.x 完成了附录A §6 高级阶段全部目标(LCP 1.8s、单测 80%、E2E 冒烟、监控告警),
但业务增长暴露三个结构性问题:
① 首屏下限固定:admin 为纯 CSR,弱网(4G 中端机)下工作台首屏可交互实测 4.2s,
   高于附录A §4 的 3.0s 目标——TTFB 后的全部下载与执行都阻塞在用户看到数据之前;
② 权限逻辑散落:菜单配置、路由守卫、按钮 if 判断三处各写一套规则(全局搜索
   `super_admin` 出现 47 处、跨 31 个文件),新增受控功能平均改动 4 处,
   本季度已发生 2 起「销售看到不该看的按钮」线上缺陷(缺陷单 BUG-1042/1077);
③ 接入无标准:营销子系统(wujie,契约 V1)验证了可行性,但接入知识在个人脑中,
   评估新子系统接入需要 2 周协商。
团队 6 → 9 人,v2 需求吞吐预计 +40%(运营侧活动页增多)。

## 2. 目标(可量化验收)
G1 渲染:工作台与报表首屏(弱网 4G / 中端安卓)可交互 ≤ 3.0s、FCP ≤ 1.5s;
        门户与营销页 LCP ≤ 2.0s 维持不回退(附录A §4 口径)。
G2 权限:新增一个受控页面/按钮,权限相关改动收敛为 ≤ 1 个文件(基线 4 处);
        权限误漏报缺陷 0 起(双跑期指标)。
G3 接入:新子系统按契约接入 ≤ 3 人日(基线 2 周),契约文档版本化。

## 3. 非目标(明确不做)
- 不做整站 SSR 重写、不迁移 Next.js 全家桶(成本见 §4 对比);
- admin 不拆微前端(无团队边界,第 21 章「要不要拆」清单已否决);
- 不更换状态库/UI 库(RTK/Zustand/Query/qing-ui 维持既有 ADR);
- 不改订单金额计算等已达 80% 覆盖率的核心模块行为。

## 4. 方案对比(渲染架构)
| 方案 | 描述 | 优点 | 代价 | 结论 |
|------|------|------|------|------|
| A 整站 SSR | 全站迁移 Next.js | 首屏最优、RSC 红利 | 全站重写 3 人月起;登录态/权限/表格密集的中后台从 SSR 获益有限;交付停摆 | 拒绝 |
| B 维持纯 CSR | 只做预算压缩 | 零架构变更 | 弱网下 TTFB+执行的下限无法达到 3.0s,G1 必然失败 | 拒绝 |
| C CSR 为主 + 局部岛屿 | 保持 admin CSR 主体;把「首屏重、交互轻」的页面(工作台、报表首屏、门户营销页)改为服务端出静态壳 + React 组件选择性注水(岛屿) | 渐进按页改造;组件与 qing-ui 全量复用;交互重的订单 CRUD 保持 CSR 不受影响 | 双构建体系; islands 框架与现有路由需要桥接(M1 POC 验证) | **采用** |
G2 权限中台化与渲染方案正交,同步推进(详见 §5.2)。

## 5. 详细设计
### 5.1 岛屿渲染
- 范围:仅 portal 全站、admin 的 dashboard 与 reports 首屏(共 5 个页面),其余维持 CSR;
- 机制:服务端渲染页面外壳(导航/布局/首屏数据直出),交互区块(筛选器、图表)
  作为 React 岛屿按需注水——组件继续从 qing-ui 与 entities 层导入,零重写;
- 数据:岛屿内的请求继续走 TanStack Query(注水后与 CSR 行为完全一致),
  首屏关键数据由服务端预取并随 HTML 下发(避免注水后二次请求闪烁);
- 构建与部署:岛屿构建产物独立流水线(第 18 章链路复用),按路由灰度开关发布。

### 5.2 权限中台化(@qing/permission)
- 数据流:登录成功 → GET /me/permissions 返回权限点集合(如 order:approve、
  order:export)→ PermissionProvider(React Context)持有 → 三个消费入口:
  ① 菜单按权限点过滤(替代散落的菜单配置 if)
  ② 路由包装 <RequirePerm perm="order:export">(替代每条路由手写守卫)
  ③ 按钮级 <Auth perm="order:delete">(替代 47 处 if 判断)
- 降级策略:permissions 接口失败 → fail-closed(全部按无权限处理)+ 顶部提示重试,
  绝不允许「取不到权限就当全量放行」;
- 双跑期:新旧判定结果并行计算并 diff 上报(不一致即告警),稳定一个迭代后移除旧逻辑;
- 权限点清单与后端按附录A §2 角色模型对齐,作为 contracts/permission-V1.md 契约冻结。

## 6. 灰度与回滚
- 渲染:按路由白名单灰度(内部员工 → 10% 租户 → 全量),每页独立开关
  (运行时配置注入,第 18 章机制);回滚 = 关开关,秒级恢复 CSR 版本;
- 权限:双跑期发现 diff 超阈值(> 0.1%)自动回退旧判定逻辑并告警;
- 监控:G1/G2 指标全部进第 18 章监控面板,灰度期按日巡检。

## 7. 里程碑与验收
| 里程碑 | 周期 | 交付 | 验收标准 |
|--------|------|------|---------|
| M1 岛屿 POC | 1 周 | 工作台单页岛屿化 demo | 弱网实测可交互 ≤ 3.0s;qing-ui 组件零改动复用 |
| M2 权限 SDK | 2 周 | @qing/permission 发布 0.1 | 单测 90%+;存量 47 处判断双跑 diff = 0 |
| M3 页面迁移 | 3 周 | 5 个页面岛屿化 + 灰度 | 每页灰度期间错误率与性能达标(G1) |
| M4 收尾 | 1 周 | 旧权限逻辑删除 + 契约 v1.1 | 回归全绿;G1/G2/G3 逐条核对 |

## 8. 风险
| 风险 | 概率 | 缓解 |
|------|------|------|
| 岛屿框架与 React 19 兼容问题 | 中 | M1 POC 先行验证;不通过则降级为「预取 + 流式注入」备选 |
| 权限点清单遗漏导致误拦 | 中 | 双跑 diff 机制兜底;财务/销售角色各安排一轮人工回归 |
| 双构建体系维护成本 | 低 | 仅 5 个页面进岛屿范围,边界冻结;超范围需求走评审 |

方案 C 的运行图景(一张图讲清「CSR 主体 + 局部岛屿」的分工):

一次工作台页面加载(岛屿化后)
浏览器 ─── GET /dashboard ──────────────────────► 服务端
       ◄── HTML:布局/导航/今日概览四卡 已直出 ──  ← FCP 提前(数据在 HTML 里)
浏览器解析 HTML,静态壳立即可见可读
       ◄── dashboard-chunk.js(仅交互区块代码)──  ← 按需注水
筛选器 / 趋势图 完成注水(hydration)
       ─── 注水后 TanStack Query 接管后续数据流 ──  ← 可交互(G1 达标点)
       ─── 之后行为与纯 CSR 完全一致(缓存/失效/乐观更新复用 §19.4 约定)

这份方案值得逐段体会的地方:目标全部可量化且能在监控面板里找到对应物;非目标用一句话挡掉了「顺便把全站迁 Next 吧」的评审会跑题;方案对比里被拒绝的方案 A 写明了代价(而不是含糊地说「不合适」);里程碑每一条都有验收标准——这四点是方案能通过评审的真正原因。

再给 §5.2 权限中台化的执行视角——方案评审通过后,执行者照这段骨架当天即可开工:

// packages/permission/src/PermissionProvider.tsx —— @qing/permission 0.1 骨架
import { createContext, useContext, useMemo, type ReactNode } from 'react';

const PermissionContext = createContext<ReadonlySet<string>>(new Set());

export function PermissionProvider({ perms, children }: { perms: string[]; children: ReactNode }) {
  const permSet = useMemo(() => new Set(perms), [perms]);
  return <PermissionContext.Provider value={permSet}>{children}</PermissionContext.Provider>;
}

export function usePermission(): (perm: string) => boolean {
  const permSet = useContext(PermissionContext);
  return (perm: string) => permSet.has(perm);
}

/** 按钮级权限:无权限不渲染(fail-closed 的 UI 侧表达,替代存量 47 处 if 判断) */
export function Auth({ perm, children }: { perm: string; children: ReactNode }) {
  const has = usePermission();
  return has(perm) ? <>{children}</> : null;
}
// 路由级权限:v2 之后每个受控路由只需一处声明(目标 G2 的「≤ 1 个文件」)
<Route
  path="/reports"
  element={
    <RequirePerm perm="report:view">
      <ReportsPage />
    </RequirePerm>
  }
/>

24.3 架构评审:流程、角色与 20 条 checklist

评审的目的是在纸面上失败,而不是在生产里失败。流程四步:

① 预审(会前 2 天):方案文档发评审组,要求「带着书面评论来」;
   没有预审的评审会 = 评审员现场首次阅读 = 每次都超时且流于表面
② 评审会(60 分钟固定议程):
   作者讲 10 分钟(讲权衡与备选,不逐行念文档)
   → 逐节质疑 40 分钟(按 checklist 走,记录员全程留痕)
   → 结论 10 分钟
③ 结论四类:通过 / 有条件通过(行动项+负责人+期限)/ 驳回(缺关键信息或方向错误)/ 择期
④ 闭环:行动项进 issue 跟踪;方案定稿归档 docs/design/;结论与争议点可沉淀 ADR
角色:作者(答辩+记录行动项)、评审员 ≥2 人(至少一位非本团队——跨团队视角
      专治「内部常识盲区」)、记录员(只记结论/行动项/分歧,不记情绪)、主持(可作者兼任)

预审邀请的模板形态(第 ① 步的实体,会前 2 天发出):

<!-- 评审预审邀请(邮件/群公告均可,关键是「书面评论」要求写明) -->
主题:[预审] 轻订单 v2 技术方案 · 05-20 评审会(文档:docs/design/v2-render-auth.md)

要求:
① 会前通读全文,在文档行内留下书面评论(哪怕只有一句「这里没看懂」);
② 至少准备 2 个质疑点带到会上;
③ 会上不做首次阅读——没有预审的评审会每次都超时且流于表面。

评审员:张三(前端)、李四(后端,跨团队视角)、王五(记录)
议程:作者讲权衡 10min → 按 checklist 质疑 40min → 结论与行动项 10min

架构评审 checklist(20 条,会前自审用——自己先挂掉这 20 条里的 16 条,评审会就只剩干货):

【目标】
□ 1 背景痛点有数据支撑(监控/缺陷单/耗时实测),不是感觉
□ 2 每条目标可量化、可验收、有基线对比
□ 3 非目标明确,范围蔓延有挡板
【方案】
□ 4 至少 2 个备选方案且各有代价分析
□ 5 方案选择与既有 ADR 一致,冲突处显式说明并提议更新
□ 6 与分层规则/依赖方向(第 19 章)不冲突
【详细设计】
□ 7 接口契约(OpenAPI/权限点/事件名)已定义且冻结版本
□ 8 数据流有图;状态归置符合三分法
□ 9 失败路径与降级策略逐条列出(fail-closed/fail-open 有明确选择)
□ 10 安全:无凭据字面量;输入校验;权限点符合最小化原则
□ 11 兼容性:对存量调用方/子应用/灰度用户的影响面已列全
【灰度与回滚】
□ 12 有灰度计划(维度:路由/租户/白名单)与观察指标
□ 13 回滚方案具体到操作步骤与预计耗时
□ 14 双跑/diff 机制适用于高风险变更
【里程碑与运维】
□ 15 每个里程碑有独立验收标准,可独立交付
□ 16 监控与告警方案先于上线(不是上线后补)
□ 17 文档沉淀计划(ADR/契约/手册更新)已列入交付物
【过程】
□ 18 文档 ≤ 10 页,一页纸版本已对齐过方向
□ 19 评审员含至少一位非本团队视角
□ 20 行动项有负责人与期限,进 issue 跟踪

24.4 向上管理与影响力:把技术投入翻译成业务语言

技术方案写得再好,没有组织资源也只是文档。向管理层陈述技术投入的三段式叙事:

第一段 · 业务风险(对方语言):不说「权限代码太散需要重构」,
  说「上季度 2 起越权缺陷,按现在的增速下季度预计 4 起,每起的处置成本是 X 人天」。
第二段 · 方案与成本(可选项+账):给 2–3 个选项各自的投入与收益区间,
  让对方做选择题而不是判断题——「A 方案 2 人周解决 80% 问题;B 方案 2 人月彻底解决」。
第三段 · 所需支持(明确要什么):要人?要排期?要决策权?要一个季度 20% 容量?
  模糊的「希望支持」等于没有要求。

三段式落到一封真实的汇报(可套用的填空模板):

主题:Q3 前端技术投入申请(2.4 人周,回报:越权缺陷归零 + 高峰性能达标)

一、风险(业务语言):上季度 2 起越权缺陷(BUG-1042/1077),单起处置约 2 人天;
   v2 需求吞吐 +40% 后预计复发 4 起。季末报表高峰期弱网首屏可交互实测 4.2s,
   超出附录A §4 的 3.0s 验收口径,已影响财务季末结账效率。
二、方案与成本:A 方案(权限中台化,2 人周)消除 80% 的越权风险面;
   B 方案(含岛屿渲染,另 2 人周)彻底达标首屏指标——建议本季先做 A,
   B 依据 A 交付后的监控数据在 Q4 决策。
三、所需支持:① 2.4 人周从季度 20% 技术预算出(不动业务交付容量);
   ② 后端 /me/permissions 接口 W2 排期(已与后端 lead 初步对齐);
   ③ 授权按第 24 章 §24.3 流程组织一次含跨团队评审员的架构评审。

三条影响力纪律:① 承诺范围而非裸承诺日期——「这些内容 3 周交付,若中途插入需求,交付时间与范围由你选一个调整」;② 拒绝有替代——「这个需求本期做不了,但如果把范围缩到 X,本周可以」;③ 小胜养信用——先交付一次有数据的小战役(比如把某页首屏从 4.2s 做到 2.8s),下一次要 2 人月的预算就有凭据。季度规划的 70/20/10 配比(70% 业务需求 / 20% 技术投入 / 10% 机动预留)本身就是一种向上沟通产物——它把「技术债偿还」从额外索取变成制度化预算:

## 季度技术规划模板(Q3 · 轻订单前端)
### 1 业务北极星对齐
支撑运营活动页产能 +40%(G3 契约化接入)与财务报表季末高峰(G1 岛屿化)
### 2 投入分配(总量 12 人周)
- 70% 业务需求(8.4 人周):v2 迭代需求、营销活动支撑
- 20% 技术投入(2.4 人周):M2 权限 SDK、M3 岛屿迁移、TD-011 导出流式化
- 10% 机动(1.2 人周):线上告警响应与缺陷
### 3 技术投入明细(对应 §20 台账与 v2 方案里程碑)
TD-011 导出流式化(3 人日,触发条件已满足)|TD-017 三分法重归置(5 人日)…
### 4 风险与依赖
依赖后端 /me/permissions 接口排期(已与后端 lead 对齐 W2);
风险:季度末报表高峰与 M3 并行,缓解:M3 灰度期避开月末两周

24.5 毕业项目:把两年学习装订成作品集

全书毕业项目四选一(或组合),每个都要求产出物可展示、过程可追溯——它们同时也是面试时最有说服力的弹药:

项目 内容 用到的章节 产出物
A · v2 落地 完成轻订单 v2 方案的 M1+M2(岛屿 POC + 权限 SDK 发布)并真实接入 admin 19/20/24 方案文档、SDK 包、灰度数据报告
B · qing-ui 1.0 组件库从 0.1 推到 1.0:≥8 组件、Storybook 全量、视觉回归、私服发布、admin 全量替换 20/22 文档站、CHANGELOG、迁移记录
C · 微前端平台化 营销接入契约升级为 V2(新增第二个子系统接入 ≤ 3 人日验证 G3),依赖治理进 CI 21/22 契约文档、集成冒烟集、接入 SOP
D · 质量体系 把 13–18 章的预算门禁/测试/监控/回滚组装成「发布质量评分卡」,并复盘一次模拟故障 13–18/22 评分卡、SOP、复盘报告

毕业验收标准(也是进入附录 B 面试题演练的自测门槛):产出的方案文档能通过 §24.3 的 20 条 checklist 自审 ≥ 18 条;代码产出全部经过真实 PR 流程(第 22 章);过程记录(ADR/台账/灰度数据)构成完整证据链。

毕业项目的评分卡(自评 + 找一位同伴互评,总分 ≥ 4 分为毕业线):

毕业项目评分卡(每项 0–5 分)
□ 完整度:核心场景可用,验收标准逐条核对(1=跑通一半 / 3=全部达标 / 5=超出预期)
□ 过程证据:ADR、契约、评审记录、灰度数据构成完整证据链(1=只有代码 / 5=链路完整)
□ 工程质量:lint / 测试 / 预算门禁全绿,他人半天内可接手(onboarding 手册 §22.5)
□ 复用性:产出被第二个场景复用(组件被其他页面用、契约被第二个子系统用)
□ 表达:能给一位非前端同事 10 分钟讲清「为什么这么设计」而不被问倒

🛠 渐进式实战练习

练习 1 · 基础(10–20 min):把模糊需求改写成「目标 + 非目标」

任务描述:产品经理提了三个需求,请分别改写成「可量化目标(含基线)+ 非目标」:① 「订单列表页有点卡,优化一下体验」;② 「报表导出要更好用」;③ 「系统要更安全一点」。

提示:目标的量化基线从两处找——附录A §4 的性能口径、监控面板/缺陷单的真实数据(没有数据就先测量再写目标,这本身就是方案第一步);非目标从「你不想背的锅」反推(比如「不重构权限体系」「不更换导出格式」)。

验收标准:每个需求产出 ≤ 6 行的目标/非目标,目标含可测指标与基线值,非目标 ≥ 2 条。

参考答案(示例)

① 目标:万级订单列表滚动掉帧 < 5%(附录A §4),筛选翻页响应 ≤ 300ms(基线实测 1.2s);非目标:不改列表列配置功能、不做服务端聚合(后端已确认 P95 < 200ms,瓶颈在前端渲染)。 ② 目标:5 万行导出成功率 ≥ 99.9%(基线 87%,TD-011 记录的内存峰值问题)、导出期间列表操作不卡顿(主线程长任务 < 200ms);非目标:不改 CSV 格式与字段、不做服务端异步导出队列(那是后端议题,仅作为备选方案记录)。 ③ 目标:权限越权类缺陷 0 起(基线:本季度 2 起);新增受控页面权限改动 ≤ 1 文件(基线 4 处);非目标:不引入 SSO/企业微信登录(另立项)、不改 token 有效期策略。

注意共同点:每条目标都能在监控/缺陷系统里找到对应物,每条非目标都提前挡掉了一次评审会跑题。

练习 2 · 进阶(30–60 min):一页纸技术方案

任务描述:为「订单批量导出大数据量卡死」(TD-011)写一页纸方案:严格七段结构、每段 ≤ 5 行,目标 2 条、备选 ≥ 2 个(前端分批 + Web Worker 流式生成 CSV / 服务端异步导出 + 下载中心 / 二者结合)、灰度与回滚一段必写。

提示:先用 §24.1 的读者表自问——评审者读你的方案对比能看出权衡吗?执行者照着详细设计能开工吗?「导出失败时用户视角发生什么」这条失败路径不要漏。

验收标准:全文 ≤ 60 行;通过 §24.3 checklist 中至少 1/2/3/4/9/12/13 七条的自审(逐条标注通过/不通过);方案对比里被拒绝方案的代价有具体描述。

参考答案(骨架示范)
# TD-011 偿还方案 · 订单批量导出流式化
背景:TD-011 台账记录,> 5 万行导出内存峰值 800MB+,本季度已触发 1 次页面假死
工单;财务季末批量导出需求确定性到来(触发条件已满足)。
目标:① 5 万行导出成功率 ≥ 99.9%(基线 87%);② 导出期间主线程长任务 < 200ms。
非目标:不改 CSV 字段与编码;不做下载中心(服务端异步方案被否,见对比)。
方案对比:A 前端流式(分页拉取 + Web Worker 拼 CSV + StreamSaver 边生成边落盘)
——改动小、无后端依赖、进度条天然可做;上限 10 万行。B 服务端异步 + 下载中心
——体验最稳但后端 2 人周 + 新页面,排期冲突(Q3 规划已确认)。结论:先 A 后 B,
A 覆盖当前需求,B 立项留给超大数据场景。
详细设计:分页 pageSize=1000 拉取 → Worker 内聚合与转义 → 流式写盘;
失败重试按页粒度;导出完成埋点上报行数与耗时(G 监控)。
灰度与回滚:新旧导出入口并存,featureFlag 按角色灰度(财务先全量);
回滚 = 关 flag,秒级回旧实现。
里程碑:W1 Worker 与流式核心(验收:10 万行 mock 数据通过);W2 灰度与监控。

练习 3 · 挑战(选做,60+ min):v2 方案评审全流程模拟

任务描述:以 §24.2 的轻订单 v2 方案为底稿,完成一场完整评审演练:① 用 20 条 checklist 逐条自审并输出审查记录(含不通过项的修订);② 邀请 2 位同学/同事担任评审员(至少一位「非本团队」视角,如后端或测试背景),按 60 分钟议程开一场真实评审会;③ 输出评审记录:结论类型、行动项(负责人+期限)、分歧点及其处理(是否需要新开 ADR)。

提示:评审会的质量取决于预审——提前两天发文档并要求书面评论;「魔鬼代言人」式的尖锐问题(「岛屿方案为什么不直接上 Next?」「fail-closed 会不会把财务锁在门外?」)是评审的价值所在,请提前想好答案而非现场防御。

验收标准:① 20 条 checklist 审查记录齐全,每条不通过项有修订动作;② 评审记录含结论、≥3 条行动项、≥1 个分歧点的处理记录;③ 若触发新 ADR(如「权限 fail-closed 策略」),产出 ADR 草稿。

参考答案(评审记录模板与常见质疑)
## 评审记录 · 轻订单 v2 技术方案(2026-05-20)
结论:有条件通过(3 项行动项)
行动项:
1. §5.1 补充岛屿框架与 React 19 兼容性验证报告(M1 提前输出,负责人:张三,期限 W1)
2. §5.2 fail-closed 策略需财务角色回归清单(负责人:李四,期限 W2)
3. §6 补充「灰度期指标异常的具体阈值」(错误率 > 0.5% 即回滚,负责人:王五)
分歧记录:「为什么不整站迁移 Next.js」——方案对比 A 已给代价分析(3 人月重写、
中后台 SSR 收益有限),评审员补充质疑后确认结论不变;过程争议点沉淀为
ADR-013《渲染架构:CSR 主体 + 局部岛屿》。

高频质疑与应答方向:「fail-closed 会不会影响财务审批?」→ 双跑期 diff 兜底 + permissions 接口 P99 与降级提示重试,风险表已列;「岛屿范围以后会不会膨胀?」→ 边界冻结在 5 个页面,超范围走评审(非目标 + 风险表双重挡板)。你的模拟评审若出现本模板没覆盖的质疑——恭喜,那份方案比你想象的更有讨论价值。

⚠️ 常见易错点

易错点 24-1:技术方案写成需求文档

❌ 错误做法:文档通篇是「系统将提供 XX 功能、支持 YY 场景」的功能描述,读完后知道「要做什么」,但不知道「为什么这么选、为什么不那样做」——评审会上第一个问「为什么不用 B 方案」的人就把会开崩了,方案被打回重写。

✅ 正确做法:方案的灵魂是权衡(§24.1 表格第 ④ 段):至少 2 个备选、各有代价分析、拒绝的理由具体到成本与约束。检验方法:把「选中的方案」遮住,仅凭背景与对比段,别人应该能推导出你会选哪个——推不出来就是对比写得不够诚实。

易错点 24-2:目标不可验收

❌ 错误做法:目标写「优化用户体验」「提升系统性能」「增强可维护性」——评审通过后没人能判定「做完了没有」,验收变成「感觉快了」的主观描述,三个月后同一个「体验问题」被再次立项。

✅ 正确做法:每条目标 = 指标 + 基线 + 目标值 + 测量方式(§24.2 的 G1–G3 模式):「工作台可交互 ≤ 3.0s(基线 4.2s,4G 中端机实测)」。写不出量化指标的需求,先做测量(埋点、性能面板、缺陷统计)再立方案——测量本身就是方案的第一步产出。

易错点 24-3:只写理想路径,没有灰度与回滚

❌ 错误做法:方案到「详细设计」就结束了,上线当天「直接全量发布」。结果权限 SDK 误拦财务、岛屿页首屏闪烁,只能紧急回滚整版(连带当天其他无关功能一起回滚),用户投诉与复盘会议齐飞。

✅ 正确做法:灰度与回滚是必填段(§24.3 checklist 12/13/14 条):灰度维度(路由/租户/白名单)+ 观察指标 + 异常阈值(错误率 > 0.5% 即回滚)+ 回滚操作步骤与耗时。高风险变更(权限类)加双跑 diff 机制——回滚按钮必须在按下之前就装好,而不是出了事现场造。

易错点 24-4:评审会变成批斗会或走过场

❌ 错误做法:两种极端都常见——一种是评审员没读文档,会上逐行找茬、揪着实现细节不放(「这里为什么不用 useMemo」),作者防御性拉扯两小时无结论;另一种是作者念 PPT、评审员刷手机,全体「通过」,评审沦为流程盖章。

✅ 正确做法:用流程设计压制极端(§24.3):预审强制书面评论(会上不做首次阅读);作者只讲 10 分钟权衡;质疑按 checklist 分组走(目标→方案→设计→灰度);结论四类里「有条件通过 + 行动项」是常态——评审会的产出是行动项,不是气氛。跨团队评审员专门负责问「外行问题」,他们恰恰能暴露内部盲区。

易错点 24-5:里程碑只有日期没有验收标准

❌ 错误做法:里程碑表只写「W2 完成权限模块、W4 完成上线」——W2 结束时「完成」由谁判定?做到 60% 算不算完成?延期到 W3 要不要上报?里程碑失去管理功能,季度规划与实际进展脱节。

✅ 正确做法:每个里程碑配独立验收标准与可独立交付的产物(§24.2 表格:M2 = SDK 0.1 发布 + 单测 90% + 双跑 diff = 0)。配套两条管理动作:里程碑延期 → 当周升级到规划会(而不是月底才发现);每个里程碑交付物直接对应第 22 章的文档沉淀(ADR/记录/报告)——里程碑是给管理层的进度真相,不是给作者的愿望清单。

✅ 最佳实践

  1. 一页纸先行:先写每段三句话的一页纸版本对齐方向,再展开全文——避免三天写完十页后被告知「方向就错了」;
  2. 非目标是最被低估的段落:它同时是范围契约、评审会防跑题挡板、未来维护者的地图,永远写全;
  3. 被拒绝的方案要写清代价:「为什么不 A」是评审会第一问,答案应该已经在文档里;
  4. 20 条 checklist 会前自审:自己先消灭 16 条,评审会只讨论剩下的干货——评审效率翻倍的唯一秘诀;
  5. 评审产出行动项:结论四类 + 行动项(负责人/期限/进 issue),没有行动项的评审会等于没开;
  6. 向上沟通用三段式:业务风险(对方语言)→ 方案成本(选择题)→ 所需支持(明确要什么);小胜养信用,承诺范围而非裸承诺日期;
  7. 季度规划 70/20/10:技术投入制度化预算而非逐次乞讨,技术债台账是 20% 容量的天然清单(第 23 章 §23.7 闭环);
  8. 过程即证据链:方案、评审记录、ADR、灰度数据、复盘报告串成完整档案——它们既是团队的资产,也是你个人的作品集。

📌 本章小结

  • 技术方案写给三种读者(评审者/执行者/未来维护者),七段结构里「非目标、方案对比、灰度回滚」三段最见功力;一页纸先行,全文 ≤ 10 页;
  • 轻订单 v2 全文示例演示了 CSR → CSR + 局部岛屿的渲染演进(首屏重交互轻的 5 页岛屿化,组件与 qing-ui 零重写复用)与权限中台化(权限点集 + Provider/<Auth>/路由包装三消费入口 + fail-closed + 双跑 diff);
  • 架构评审四步(预审 → 60 分钟议程 → 四类结论 → 行动项闭环),20 条 checklist 会前自审消灭 80% 的问题;
  • 向上管理三段式(业务风险 → 方案成本 → 所需支持),季度规划 70/20/10 让技术投入变成制度化预算;
  • 毕业项目四选一(v2 落地 / qing-ui 1.0 / 微前端平台化 / 质量体系),验收标准是「checklist 自审 ≥ 18 条 + 真实 PR 流程 + 完整证据链」。

资深阶段的全部六章到此完成。接下来去两处:附录 B · 面试题精选检验这四阶段的沉淀能否经得住面试官的追问;以及本阶段自查清单——用 30 分钟小考确认你已具备资深工程师的完整能力面。祝毕业顺利。

📄 阶段自查 · 中级能力清单(第 07–12 章) 阶段自查-中级能力清单.md

⬅ 上一章 | 📖 本版目录 | ➡ 下一站

💡 Vue 版对应文件:阶段自查-中级能力清单(清单条目两版一一对应,可双栈互验)。业务基准:附录A「轻订单」需求说明书。


一、阶段画像:中级工程师在企业里能做什么

过了中级这条线,你不再是「按 ticket 改页面的人」,而是能独立负责一个业务模块从需求到上线的工程师。以「轻订单」为标尺,企业对中级 React 开发的真实预期是:

  1. 独立交付模块:拿到「订单管理」需求文档,能自己拆里程碑(M1–M4)、搭路由与目录骨架、写请求层与表单、联调、验收,全程不需要别人带你;
  2. 封装横切设施:axios 请求层(token 注入 / 错误归一化 / 40102 无感刷新)、ProtectedRoute 页面守卫、<Auth> 按钮级权限、useOrderList 数据编排 Hook——这些「全项目都要用」的东西,你能设计并守住边界;
  3. 写接得住的代码:slice 与 hook 职责清晰、常量收敛、错误处理统一、queryKey 即依赖——同事 review 后可以直接接手你的模块;
  4. 对数据安全有手感:知道金额为什么用分、token 存储为什么要过权衡、双击为什么会创建两笔订单、登出为什么必须 queryClient.clear()——并且知道怎么防。

做不到以上四条中的任何一条,说明中级阶段还有缺口,对照下面的清单定位回看。


二、能力清单(16 条,三组自评)

用法:逐条勾选。拿不准的条目给出「未达标 → 回看第 X 章 §Y」的定位,回看后重做该章实战练习再来勾。

A 组 · 能独立完成(6 条)

  • [ ] A1. 从零搭建中后台路由骨架:嵌套路由 + <Outlet /> Layout、React.lazy 分包、404 兜底、菜单高亮(未达标 → 回看第 07 章 §7.2/§7.3)
  • [ ] A2. 实现订单列表页:分页(20/页)、多维筛选、防抖、竞态治理(queryKey + signal)、URL 状态回填(未达标 → 回看第 09 章 §9.6/§9.9)
  • [ ] A3. 实现新建订单表单:RHF + Zod schema、useFieldArray 动态明细行、金额联动汇总、整单折扣审批提示、isSubmitting 防重复提交(未达标 → 回看第 10 章 §10.3–10.7)
  • [ ] A4. 实现完整登录流与登出:图形验证码、双令牌写入、state.from 回跳、登出四清(未达标 → 回看第 11 章 §11.4/§11.8)
  • [ ] A5. 落地三级权限:ProtectedRoute roles 页面守卫 + 403、filterMenus 动态菜单、<Auth> 按钮级控制(未达标 → 回看第 11 章 §11.5–11.7)
  • [ ] A6. 封装请求层:baseURL 环境变量化、token 注入、ApiError 归一化、40102 无感刷新(单飞锁 + _retried 重放)(未达标 → 回看第 09 章 §9.3–9.5)

B 组 · 能讲清原理(5 条)

  • [ ] B1. 讲清 history 路由的工作原理、刷新 404 的原因与 Nginx 回退方案;说出 params 变化时组件复用不更新的两个解法(未达标 → 回看第 07 章 §7.4/§7.9、易错点 1)
  • [ ] B2. 讲清状态三分法(本地 UI 态/服务端态/跨页共享态)、RTK 内置 Immer 的「看似直改实为不可变」、selector 引用稳定性与 createSelector(未达标 → 回看第 08 章 §8.1/§8.3/§8.4)
  • [ ] B3. 画出双令牌无感刷新的完整时序:单飞锁、等待队列、请求重放、_retried 死循环防护、刷新必须走裸 axios(未达标 → 回看第 09 章 §9.5、第 11 章 §11.2)
  • [ ] B4. 解释 TanStack Query 的缓存模型:queryKey 结构化哈希、staleTime 与后台刷新、invalidateQueries 失效、乐观更新三段式(onMutate/onError/onSettled)(未达标 → 回看第 09 章 §9.7/§9.10)
  • [ ] B5. 解释金额以「分」为单位的浮点误差根源(0.1 + 0.2 问题),说出哪些层可以转换单位、哪些层不能(未达标 → 回看第 09 章易错点 1、第 10 章 §10.6)

C 组 · 能落地规范(5 条)

  • [ ] C1. 遵守 API 分层约定:组件不直接 import axios,一个业务域一个 api 文件,数据编排进 hooks(未达标 → 回看第 09 章 §9.1)
  • [ ] C2. 遵守错误处理约定:请求层 toast 一次、组件按 ApiError.code 分支、静默用 silent 显式申请、queryFn 不吞错误(未达标 → 回看第 09 章 §9.4、易错点 6)
  • [ ] C3. 魔法数字收敛为常量:订单状态机(STATUS_ACTIONS)、权限码(BUTTON_PERMISSIONS)、审批阈值(90)全部进 constants/,组件零硬编码业务字符串(未达标 → 回看第 12 章 §12.4/§12.8)
  • [ ] C4. 权限单一事实源:menus / routeMeta / 路由 roles 同源派生,不存在两处维护;未登记权限码默认拒绝(未达标 → 回看第 11 章 §11.6–11.7、第 12 章易错点 2)
  • [ ] C5. 交付「能被同事接手」的代码:命名与附录A 业务词逐字一致、里程碑验收记录与遗留问题成文、权限矩阵随代码提交(未达标 → 回看第 12 章 §12.8)

自评结论:16 条全勾 → 直接进入第三部分;A 组全勾、B/C 组缺 1–2 条 → 回看对应章节后进入;A 组有缺口 → 重做第 12 章对应里程碑练习。


三、30 分钟自测小考(8 题)

不查资料独立完成,每题 3–4 分钟。做完再展开答案对照。

第 1 题 · 路由

/orders/1001 跳转到 /orders/1002,详情页数据没有更新。至少说出两种解法,并说明各自的代价;接入 TanStack Query 后哪种解法会「消失」?

参考答案
  1. 响应参数变化(推荐):useEffect 依赖 [id] 重新拉取(Query 版则是 queryKey 里的 id 变化自动重查);代价是要自己处理加载态,但组件状态(滚动位置、已展开面板)保留。
  2. 强制重建:路由元素上给组件加 key={id};实现零成本,但组件状态全部丢失、有重建开销。 接入 TanStack Query 后解法一退化为「写对 queryKey」——['orders', 'detail', id] 中 id 变化自动重新请求并保留旧缓存,组件复用问题随之消失。(第 07 章 §7.4、易错点 1;第 09 章 §9.7)

第 2 题 · RTK

下面两个写法,一个「非法」、一个「合法但常被误解」。分别指出并解释 Immer 的作用边界:

// 写法 A(组件内)
const profile = useSelector((s) => s.user.profile)
profile.name = '改名'

// 写法 B(reducer 内)
clearUser(state) { state.profile = null }
参考答案

写法 A 非法:组件里拿到的是真实 state 引用,直接改不产生任何 action,订阅方不更新、DevTools 无记录;RTK 的 Immer 只在 reducer 内部生效。应 dispatch(setUser({ ...profile, name: '改名' }))。 写法 B 合法且推荐:reducer 内的「直接赋值」是 Immer 的合法语法——它用 Proxy 拦截写操作,在背后产出不可变新 state。边界记牢:Immer 的魔法只在 reducer 内成立;组件、thunk 之外的一切变更必须经 dispatch。(第 08 章 §8.3、易错点 1)

第 3 题 · 金额

已知明细行 { quantity: 3, unitPriceFen: 129900, discountPercent: 90 }(单位分),写出:①正确的行金额计算;②「0.1 + 0.2 ≠ 0.3」说明什么问题、为什么「分」能规避、哪一层允许出现除法。

参考答案
// ① 行金额(分):整数运算后 Math.round 归位
const itemFen = Math.round(quantity * unitPriceFen * (discountPercent / 100))
// = Math.round(3 * 129900 * 0.9) = 350730 分 = 3507.30 元

② JavaScript 用 IEEE 754 二进制浮点表示小数,0.1 + 0.2 = 0.30000000000000004。「分」让存储与求和全部是整数(加减完全精确);除法只允许出现在展示层 formatFen(fen / 100 + toFixed(2))一次性完成。全站约定:/100、*100 只准出现在 utils/money.js。(第 09 章易错点 1、第 10 章 §10.6、第 12 章易错点 4)

第 4 题 · 无感刷新

三个并发请求同时收到 code: 40102。描述前端应发生的完整过程,以及「刷新接口自身也返回 40102」时如何避免死循环。

参考答案

过程:①响应拦截器识别 40102,进入刷新流程;②单飞锁:只有第一个请求创建 refreshingPromise(调 /auth/refresh),其余两个 await 同一个 Promise,因此 refresh 只发一次;③刷新成功后锁释放,三个等待者各自把原请求带上新 token 重放(_retried = true);④重放请求若再次 40102,说明 refreshToken 也失效——_retried 分支直接清会话并 reject 40101,绝不再次刷新。 死循环的另一起源是刷新请求走了带拦截器的实例:它自身的 40102 会再次触发刷新。防护:刷新用裸 axios 发送(无拦截器、无 token 注入)。此外刷新期间新来的第 4 个请求也会因「锁仍在」而等到同一个 Promise,拿到新 token 直接成功。(第 09 章 §9.5、易错点 5)

第 5 题 · TanStack Query

列表页切换状态筛选后界面不变。检查发现代码如下,指出两处问题并给出正确写法:

const { data } = useQuery({
  queryKey: ['orders', 'list'],
  queryFn: async () => {
    try { return await fetchOrders(filters) }
    catch (err) { message.error(err.message); return [] }
  },
})
参考答案

问题一:筛选参数没进 queryKey——filters 变化时 key 不变,永远命中同一份缓存(这正是「query string 变化不触发请求」的 React 版根因)。应写 queryKey: ['orders', 'list', filters],口诀:queryFn 里用到的每个会变的值都必须在 key 里。 问题二:queryFn 吞错误——catch 后 return [] 等于向缓存注入假数据,isError 永远 false、空列表被当作「新鲜数据」。错误应自然上抛(请求层拦截器已统一 toast),呈现层用 isError 分支渲染错误态与重试。(第 09 章 §9.7、易错点 4/6)

第 6 题 · 表单

编辑订单页用 RHF 回填异步数据,表单一直是空的或旧值。代码哪里错了?

const { data: order } = useQuery({ queryKey: ['orders', 'detail', id], queryFn: ... })
const form = useForm({ defaultValues: order ?? emptyForm(), resolver: zodResolver(orderSchema) })
参考答案

defaultValues 只在表单挂载那一刻被读取;首次渲染时 order 还是 undefined,异步数据后到不会再灌进已注册的非受控字段。正确姿势二选一(团队规范统一): ①数据到达后 reset:useEffect(() => { if (order) reset(toForm(order)) }, [order, reset]); ②声明式同步:useForm({ values: order, ... })(RHF 7.42+,数据变化自动同步)。 另注意回填时对缓存对象做拷贝(order.items.map(it => ({ ...it }))),避免直接修改 Query 缓存。(第 10 章 §10.7、易错点 1)

第 7 题 · 权限

对照附录A 的三级权限模型,说出每级「在哪实现、用什么机制」,并回答:前端把 403 做得再好,为什么还必须信任后端?

参考答案

菜单级:MENUS 配置带 roles,登录后 filterMenus(role) 过滤渲染侧边栏,决定「看见什么」;页面级:ProtectedRoute 组件带 roles 参数组合进路由树,无权限重定向 403,决定「进得去什么」;按钮级:<Auth allow="order:delete"> 组件(配 BUTTON_PERMISSIONS 常量 + usePermission)控制元素渲染,决定「能点什么」。 因为前端代码运行在用户设备上:角色数据可被篡改、接口可被绕过 UI 直接调用——前端权限只是体验优化,真正的安全边界永远是后端鉴权(接口层对每个请求复核角色与数据归属)。(第 11 章 §11.1、§11.5–11.7)

第 8 题 · token 存储与登出

团队要决定 token 存哪里,并评审登出实现。①比较 localStorage 与 HttpOnly Cookie 的风险与收益,给出「轻订单」(内部中后台)的推荐方案;②下面这段登出代码漏了哪一步?后果是什么?

async function logout() {
  await logoutApi()
  localStorage.removeItem('qing_access_token')
  localStorage.removeItem('qing_refresh_token')
  dispatch(clearUser())
  navigate('/login')
}
参考答案

①localStorage:实现简单、跨标签页共享,但 XSS 一旦注入即可读取;HttpOnly Cookie:JS 不可读、天然抗 XSS,但引入 CSRF 风险(需 SameSite/CSRF Token 缓解)与跨域配置成本。推荐口径:accessToken 进内存(store),refreshToken 最小化持久化到 localStorage,配 CSP 缓解,决策写入 ADR;公网高安全场景切 HttpOnly Cookie。 ②漏了 queryClient.clear()——TanStack Query 缓存独立于 Redux/localStorage,只清后两者,切换账号后列表页会先渲染上一个账号的缓存数据(isPending 都不出现),属越权数据泄漏。完整登出四清:令牌、store、Query 缓存、草稿等本地持久化(useLogout 收口为唯一入口)。(第 11 章 §11.3/§11.8、易错点 5)


四、升级高级阶段的判断标准

满足全部三条即可进入第三部分(第 13–18 章),否则继续补中级缺口:

  1. 能力清单自评 ≥ 14 条勾选,且 A 组 6 条全绿——「能做」是硬门槛,「能讲清」允许补课中补齐;
  2. 第 12 章实战验收全绿:附录A §3.1–3.3 的可用版本能跑通「登录 → 动态菜单 → 订单列表/详情/新建 → 三级权限」全链路,10 条功能验收 + 5 条自测用例全部通过;
  3. 产生了「治理诉求」:你能主动说出当前系统的至少三个问题(如万级列表滚动掉帧、首包超预算、核心函数无测试、useOrderList 没有单测),并且想知道怎么治——这正是第三部分(性能优化、测试、工程化)要解决的问题。当你开始「嫌它慢、怕它坏、想量化」时,就是升级的时候。

准备好了?➡ 进入第三部分 · 第 13 章 渲染性能优化

📄 阶段自查 · 初级能力清单(React 版) 阶段自查-初级能力清单.md

⬅ 上一章:第 06 章 · 初级实战 | 📖 本版目录 | ➡ 下一站:第二部分 · 中级阶段

本清单覆盖 React 版第 01–06 章。建议用法:先通读「阶段画像」定位自己 → 逐条勾选能力项 → 做 30 分钟自测小考 → 对照「升级中级的判断标准」决定是否进入第二部分。


1. 阶段画像:初级阶段的你,在企业里能做什么

完成初级阶段后,你应当能以「可被指导的独立贡献者」身份参与一个 React 项目(如轻订单门户/后台的页面层开发):

  • 能做的事:把设计稿或 PRD 拆成组件树并落地;独立开发展示型页面与业务小组件(OrderCard、StatusTag、筛选栏、弹窗);用 Vite 从零搭建并运行项目;遵守团队的目录、命名与 ESLint 规范;在 code review 中看懂评审批注并完成修改。
  • 尚不能做的事:独立设计全局状态架构与请求层(中级);对渲染/加载性能负责(高级);主导技术选型与架构评审(资深)。遇到这些问题时,你至少能说清现象、给出初步判断,并知道该回看哪一章。

2. 能力清单(14 条,三组)

勾选标准:能「不看教程独立做到」或「用自己的话向同事讲明白」才算通过。每条标注了未达标时的回看位置。

2.1 能独立完成(操作层)

  • [ ] 1. 用 Vite 从零创建 React 项目、安装依赖、启动 dev server,并解释 index.html → main.jsx → App.jsx 入口链路(未达标 → 回看第 01 章 §1.3–1.4)
  • [ ] 2. 用 JSX 完成插值、属性绑定、事件绑定、条件渲染与列表渲染,正确使用 key(未达标 → 回看第 02 章 §2.1–2.5)
  • [ ] 3. 用受控组件实现输入框 / 下拉框 / 复选框(React 版 v-model)(未达标 → 回看第 02 章 §2.6)
  • [ ] 4. 设计并实现 props 接口清晰的业务组件(如 OrderCard、StatusTag、SearchBar),含回调通信与 children 组合(未达标 → 回看第 03 章 §3.2–3.4)
  • [ ] 5. 用 useState 完成对象 / 数组的不可变增删改,并在依赖旧值时使用函数式更新(未达标 → 回看第 04 章 §4.2)
  • [ ] 6. 独立完成「关键词 + 状态 + 分页」筛选联动:防抖、派生 query、useEffect 同步、竞态保护(未达标 → 回看第 04 章 §4.8 与练习 2)
  • [ ] 7. 独立完成门户首页五个区块 + 暗色主题切换(含持久化),并通过逐条验收(未达标 → 回看第 06 章 §6.1–6.9)

2.2 能讲清原理(理解层)

  • [ ] 8. key 的作用机制:为什么下标作 key 会导致状态串行、为什么换 key 能重置组件状态(未达标 → 回看第 02 章 §2.5、§2.7)
  • [ ] 9. 渲染模型直觉:「位置 + 类型 + key」决定状态保留与否;setState 后读旧值的原因(未达标 → 回看第 02 章 §2.7、第 04 章 §4.4)
  • [ ] 10. Hooks 两条规则背后的原因(调用顺序一致性),以及 useMemo/useCallback 各自解决什么问题(未达标 → 回看第 04 章 §4.1、§4.3、§4.5)
  • [ ] 11. 过期闭包的成因与两种解法(函数式更新 / 补全依赖)(未达标 → 回看第 04 章 §4.6)
  • [ ] 12. 副作用清理的意义:StrictMode 双挂载为什么能暴露未清理的订阅;受控与非受控的选型依据(未达标 → 回看第 05 章 §5.1–5.2、§5.5)

2.3 能落地规范(工程层)

  • [ ] 13. 目录与命名规范:components/ data/ hooks/ 职责分离、一文件一组件、回调 onXxx 命名、配置数据与视图分离(未达标 → 回看第 03 章 §3.5、第 06 章 §6.2、§6.8)
  • [ ] 14. 副作用与纯渲染纪律:定时器 / 监听器成对清理、渲染函数保持纯净、状态放最近公共父级、派生值不入 state、环境变量代替硬编码配置(未达标 → 回看第 04 章 §4.3、第 05 章 §5.2、第 06 章 §6.8)

3. 30 分钟自测小考(7 题)

计时 30 分钟,先作答再展开答案。第 4–6 题请动手写代码或口头推演,不要只「看着眼熟」。

第 1 题(概念)

轻订单门户要同时展示「待审核订单数」和「今日 GMV」。同事说:「这两个数一变就要改 DOM,所以我写了个工具函数集中更新 DOM。」请指出这种写法的两个风险,并给出 React 的解法。

参考答案

风险一:手工同步遗漏——数据来源增多后(状态筛选、轮询、操作回填),每一处都要记得调用更新函数,漏一处就是「漏单」式的显示 bug。风险二:无法追踪——DOM 被多处命令式修改,出问题时无法回答「是谁在什么时候改的」。

React 解法:把订单数与 GMV 作为状态(useState),视图写成「状态的函数」(声明式 JSX)。数据一变,React 负责算出最小 DOM 改动;手工同步代码全部删除。(对应第 01 章 §1.1)

第 2 题(判断)

下面三种 key 的用法,哪些安全、哪些有风险?说明理由: ① <li key={order.id}>(id 为后端主键) ② <li key={index}>(列表支持删除与排序) ③ 给搜索框设 key={resetCount},点「重置」按钮时 resetCount + 1

参考答案

① 安全:主键唯一且稳定,是 key 的标准来源。 ② 有风险:删除/排序后,位置上的元素「顶替」了原来 key 对应的元素,React 会复用错误节点的内部状态(输入框内容、勾选状态串行)。静态纯展示列表用下标勉强可用,但不建议养成习惯。 ③ 安全且推荐:换 key 触发销毁重建,输入框内部状态(文本、光标、选区)被清空——这是「重置表单」的正规手段。(对应第 02 章 §2.5、§2.7)

第 3 题(找错)

找出下面代码里的所有问题:

function OrderList({ orders, onApprove }) {
  orders.map((o) => {
    if (o.status === 'pending_review') {
      o.status = 'approved';
    }
  });
  return (
    <ul>
      {orders.map((o, i) => (
        <li key={i} onClick={onApprove(o.id)}>
          {o.orderNo} <button onclick={() => console.log(o)}>详情</button>
        </li>
      ))}
    </ul>
  );
}
参考答案
  1. 在组件内修改 props(o.status = 'approved'):破坏单向数据流,应通过回调上抛由父组件改数据。
  2. orders.map 的返回值没有使用:这段修改代码本身就不该存在(见第 1 条)。
  3. key={i} 下标作 key:应使用 order.id。
  4. onClick={onApprove(o.id)}:渲染时立刻调用,应改为 onClick={() => onApprove(o.id)}。
  5. onclick 小写:JSX 事件是小驼峰 onClick;且直接 console.log 的调试代码不应提交。
  6. map 缺少 return / 箭头函数体(第一处):orders.map((o) => { if ... }) 没有返回值也没有副作用意义,属无效代码。

修正要点:删掉 mutation 逻辑、key 用 o.id、事件传函数引用、属性名小驼峰。(对应第 02 章 §2.3、§2.5;第 03 章 §3.2 易错点)

第 4 题(动手)

用受控组件实现一个 StatusFilter:下拉框含「全部 / 待审核 / 已审核」三个选项,选中值实时显示在旁边。要求写出完整组件(允许使用 props 传入的 value / onChange)。

参考答案
function StatusFilter({ value, onChange }) {
  const OPTIONS = [
    { value: '', label: '全部' },
    { value: 'pending_review', label: '待审核' },
    { value: 'approved', label: '已审核' },
  ];
  return (
    <label>
      状态:
      <select value={value} onChange={(e) => onChange(e.target.value)}>
        {OPTIONS.map((opt) => (
          <option key={opt.value} value={opt.value}>{opt.label}</option>
        ))}
      </select>
      <span>当前:{value || '全部'}</span>
    </label>
  );
}

评分点:value + onChange 成对(受控);选项配置化且 map 带 key;取值用 e.target.value。(对应第 02 章 §2.6)

第 5 题(推演)

下面的组件每秒打印什么?为什么?请给出两种修复方案。

function Poller() {
  const [count, setCount] = useState(0);
  useEffect(() => {
    const timer = setInterval(() => {
      console.log('count =', count);
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(timer);
  }, []);
}
参考答案

每秒打印 count = 0,且界面永远停在 1:空依赖数组把首次渲染的 count(= 0)锁进了闭包;setCount(0 + 1) 每次都算出 1,状态永远无法增长——这是典型的过期闭包陷阱。

修复一(推荐):函数式更新 setCount((prev) => prev + 1),基于最新值计算,不依赖外部快照。 修复二:把 count 写进依赖数组 useEffect(..., [count])——每次变化销毁旧定时器、重建新定时器(功能正确,但有重建成本)。(对应第 04 章 §4.6)

第 6 题(原理)

开发模式下 <StrictMode> 里组件的 effect 总是「执行 → 清理 → 再执行」。回答:① 这是 bug 吗?② 它为什么能帮助我们发现真实问题?③ 写一个会「现形」的反例。

参考答案

① 不是 bug:StrictMode 仅在开发环境把组件「挂载 → 卸载 → 再挂载」跑两遍,用于检验副作用的健壮性。 ② 如果 effect 的订阅没有成对清理,双挂载会立刻暴露:定时器双份跑、监听器叠加、请求重复——这些都是生产环境真实存在的泄漏源;写好清理函数的组件在双挂载下零感知。 ③ 反例:useEffect(() => { window.addEventListener('resize', handle); }, []) 没有 return () => window.removeEventListener(...)——双挂载后 resize 触发两次 handler;补上清理即消失。(对应第 05 章 §5.1–5.2)

第 7 题(设计)

产品要求:门户页脚要新增「微信公众号二维码」区块,FAQ 要新增 3 个问题,暗色主题需在「登录前后的所有页面」生效。分别说明你会改哪些文件、为什么,并说明哪条需求与 Context 相关。

参考答案
  • 新增页脚区块:改 src/components/Footer.jsx(新增一个纯展示块),若内容较复杂可在 data/ 增加配置——体现「内容与视图分离」。
  • 新增 3 个 FAQ:只改 src/data/faqs.js,FaqAccordion 零改动——配置驱动。
  • 全页面主题生效:主题属于低频全局 UI 态,跨层级、跨页面消费,正是 Context 的适用场景(或继续用顶层 Hook + props,但页面变多后 Context 更合适)。高频业务数据(如订单列表)则不该放进 Context。(对应第 06 章 §6.2、§6.8;第 05 章 §5.4)

4. 升级中级的判断标准

同时满足以下条件,即可进入第二部分 · 中级阶段:

  1. 能力项:§2.1 的 7 条全部勾选;§2.2、§2.3 至少各通过 4 条(允许个别原理项回炉后补)。
  2. 小考:7 题中至少 6 题正确,且第 4–6 题是独立完成而非「看完答案觉得会」。
  3. 作品:第 06 章门户首页通过 §6.9 全部验收项,包括挑战练习的防闪烁主题(选做但强烈建议完成)。
  4. 心理准备:开始主动提出这些问题——多页面怎么做路由?登录态放哪?接口数据谁缓存?带着它们进入中级阶段(第 07–12 章),正是下一步要回答的内容。

未达标怎么办:按各条目的「回看第 X 章 §Y」定向复习,重做对应章节的渐进式练习(跳过基础档、直接做进阶/挑战档),再回来补测。

📄 阶段自查 · 资深能力清单 阶段自查-资深能力清单.md

📍 阶段:资深阶段(第 19–24 章)|⏱ 建议用时:30 分钟自查 + 30 分钟自测小考

⬅ 上一章:资深实战 · 技术方案与架构评审 | 📖 本版目录 | ➡ 下一章:附录 B · 面试题精选

💡 对照学习:本清单与 Vue 版阶段自查 · 资深能力清单条目一一对应(能力项、小考知识点、毕业方向两版一致),差异仅在实现生态(React 生态 ≈ Vue 生态的对应工具)。资深阶段的方法论本身两版通用,因此这份清单也是最像「面试答案卷」的一份。


1. 阶段画像:资深工程师在「轻订单」团队能做什么

初级回答「组件怎么写」,中级回答「系统怎么拼」,高级回答「系统怎么快、怎么稳」,资深阶段回答「系统该怎么长、团队怎么协作、选择怎么做、决策怎么被记录与检验」。一个完成本阶段的学习者,在企业里应当能独立承担这些事:

  • 面对一个长大的仓库,用数据做架构分区判断:轻订单从单体到六层模块化单体的演进,评分表、依赖规则(ESLint boundaries)、初始化编排、ADR 决策记录全部有据可查——而不是「感觉该拆了」;
  • 建设共享资产:qing-ui 组件库从 0 到 1(令牌三层、双模式组件、Storybook、a11y、changesets 私服发布),让「品牌换肤、业务语义沉淀」从三处改动变成一处配置;
  • 处理组织边界的架构问题:判断要不要拆微前端,为第三方营销子系统定义接入契约并完成 wujie 集成,公共依赖与通信方案进 CI 验证;
  • 建立团队的协作机制:提交链路、PR 模板、Review SLA 与坏味道清单、跨职能契约(设计/后端/测试)、《前端团队协作手册》——机制被真实使用并迭代;
  • 做出值得记录的选择:八维加权选型、自研决策树、技术债台账与偿还节奏,结论沉淀为 ADR 且经得起「魔鬼代言人」攻击;
  • 把想法变成组织资源:写出能过评审的技术方案(轻订单 v2:岛屿渲染 + 权限中台化),主持架构评审,用 70/20/10 完成季度技术规划,向上沟通有数据、有选项、有明确诉求。

一句话画像:高级交付工程体系,资深交付「让体系持续变好的决策与机制」——并且每一项决策都留痕、可追溯、可被挑战。

2. 能力清单(15 条,勾选前先翻回对应章节重做一遍关键练习)

A 组 · 能独立完成(动手项:不看教程,从零做到验收标准)

  • [ ] 能对真实仓库完成复杂度评估(五维打分 + 路线结论)并执行模块化改造:六层目录、切片公共出口、ESLint boundaries 进 CI 拦截违规 import,同步落地路由片段/状态三分法(slice 就近)/queryKey 工厂/领域 API 的组织约定 —— 未达标 → 回看第 19 章 §19.1–19.5、练习 1/2
  • [ ] 能实现显式 bootstrap() 启动编排(区分阻塞与后台任务)与插件化横切关注点(埋点/监控零侵入业务代码) —— 未达标 → 回看第 19 章 §19.5–19.6
  • [ ] 能搭建 qing-ui 三层封装(令牌三层 + QingProvider 暗色衔接第 06 章)并实现受控/非受控双模式组件(useControllableState),通过 Storybook + a11y 检查 —— 未达标 → 回看第 20 章 §20.2–20.3、§20.5–20.6、练习 1/2
  • [ ] 能配置 changesets 完整发布闭环(PR 附 changeset → Version PR → 私服 publish,凭据全走环境变量),并演练一次 patch/minor/major 发布 —— 未达标 → 回看第 20 章 §20.7、练习 3
  • [ ] 能按接入契约(挂载/props/事件/样式/发布规则七条)用 wujie 完成第三方子系统集成,四条冒烟链路自动化通过 —— 未达标 → 回看第 21 章 §21.7、练习 2
  • [ ] 能按七段结构写出一页纸技术方案并通过 20 条 checklist 自审 ≥ 18 条 —— 未达标 → 回看第 24 章 §24.1、§24.3、练习 2

B 组 · 能讲清原理(理解项:能给同事讲明白「为什么」)

  • [ ] 能讲清三条架构路线(单体分区/模块化单体/微前端)的触发条件,以及「过度设计」的判断依据(组织边界 vs 代码整洁) —— 未达标 → 回看第 19 章 §19.1、第 21 章 §21.1、易错点 21-1
  • [ ] 能讲清 JS 沙箱两种实现(Proxy 沙箱 vs iframe 沙箱)与 CSS 隔离三条路(Shadow DOM/scoped/命名约定)的原理与各自的坑 —— 未达标 → 回看第 21 章 §21.3
  • [ ] 能讲清四类通信通道的职责边界(props 下发状态、事件传信号、URL 带上下文、共享存储),以及「事件总线当数据管道」为什么是灵异 bug 之源 —— 未达标 → 回看第 21 章 §21.4、易错点 21-4
  • [ ] 能讲清八维选型框架各维度的观测方法、权重即价值观、「分数接近比退出成本」的逻辑,并公正对比 Vue 与 React 的适用条件 —— 未达标 → 回看第 23 章 §23.1–23.2
  • [ ] 能讲清技术方案中「非目标/方案对比/灰度回滚」三段分别防住什么(范围蔓延/论证缺失/上线事故),以及为什么回滚按钮必须先于事故存在 —— 未达标 → 回看第 24 章 §24.1–24.2、易错点 24-3

C 组 · 能落地规范(制度项:能写成团队可执行的规则)

  • [ ] 能建立 ADR 机制:五段模板、编号归档、只追加不修改,并产出 ≥2 则含「备选代价 + 回顾触发条件」的真实决策记录 —— 未达标 → 回看第 19 章 §19.8
  • [ ] 能搭建提交与评审链路:Conventional Commits + commitlint + changesets 联动、PR 模板、十项 Review 清单、双向 SLA 与坏味道清单 —— 未达标 → 回看第 22 章 §22.2–22.4、练习 1/2
  • [ ] 能定义跨职能契约:设计交付物(四态稿/tokens 变更单/组件对照表)、后端 OpenAPI 先行与错误码口径(引用附录A §5)、缺陷单四要素 —— 未达标 → 回看第 22 章 §22.6
  • [ ] 能运营技术债台账(四象限定性 + 偿还触发条件)并按 70/20/10 完成季度技术规划 —— 未达标 → 回看第 23 章 §23.7、第 24 章 §24.5
  • [ ] 能产出《前端团队协作手册》并推动试运行修订(改手册走 PR,规范即代码) —— 未达标 → 回看第 22 章 §22.7、练习 3

自查方法:沿用三问打分——「能对同事讲 + 能现场做 + 有产出物」。资深阶段的产出物尤其重要:评分表、ADR、接入契约、方案文档、评审记录、季度规划——建议全部归档进仓库 docs/,这份「决策证据链」就是你区别于高级工程师的档案,也是晋升答辩与面试的弹药库。

3. 30 分钟自测小考(7 题,先作答再看答案)

建议真实作答:纸笔或新建 markdown,写完再展开答案对照。每题 3–5 分钟,总分自评 ≥ 80% 获得毕业资格。

第 1 题(架构分区)

轻订单评估得分:模块数 7(2 分)、实体 4 个(2 分)、团队 6 人(2 分)、月需求 25+(2 分)、月冲突 2 次(2 分),总分 10;同时已知 3 个月后要接入供应商的营销子系统。应该选择哪条路线?为什么不直接上微前端?

参考答案

选模块化单体(第 19 章 §19.1–19.2):10 分处于「单体分区」与「模块化单体」临界点,且「即将跨团队接入」是明确的增量信号——现在做分层与依赖规则,正是为第 21 章的微前端留好接缝(activeRule 前缀、契约 props、独立部署的落点)。不直接上微前端的理由:admin 内部订单/客户/商品没有团队边界(同一批人、同一节奏),拆了只把代码内协作变成跨仓库协作(易错点 21-1);正确姿势是「一把手术刀」——只有营销子系统(供应商维护、独立发布、异构栈)用 wujie 接入,主站保持一体化构建。核心判断句:微前端解决组织边界问题,不解决代码整洁问题。

第 2 题(分层与共享)

features/order-create 与 features/order-approve 都要用一个「订单操作日志时间线」组件。有三种处理:A. 互相 import;B. 复制一份;C. 别的路径。选哪个并说明依赖规则与 shared/ 准入标准。

参考答案

选 C——上提为 widgets:widgets/order-timeline/(第 19 章 §19.2)。A 违反「features 之间禁止互相 import」的依赖铁律(破口一开退化回面条单体);B 制造双份真值,状态标签改一次要发两版。判断依据:跨 feature 的业务组合块正是 widgets 层的定义。shared/ 的准入标准是「与业务词无关」——shared/lib/money.ts(金额分转元)可以进 shared,OrderTimeline 里全是订单领域词,进 shared 就等于把 shared 变成「另一个 features 目录」(易错点 19-2)。注意这个组件后来在第 20 章进一步沉淀进 qing-ui 业务组件层时,走的是「三次法则」(两个应用共用第三次才下沉为包)。

第 3 题(双模式组件)

useControllableState 中用 value !== undefined 判定受控模式。为什么 onChange 在两种模式下都必须回调?这个 Hook 为什么是 AmountInput 这类表单组件的标准形态?

参考答案

受控时组件不持有真值(外部 value 决定显示什么),但外部依然需要事件来知道「值该变了」——onChange 是外部更新真值的唯一入口;非受控时外部虽然不控制值,但同样需要监听变化(如实时联动校验)。所以 onChange 是「两种模式的公共契约」,永远回调(第 20 章 §20.3)。这让组件成为标准形态的原因:调用方自由选择模式——放进 RTK/Query 驱动的表单用受控(订单金额汇总联动),放进简单场景用 defaultValue,组件与调用方的状态库彻底解耦——这也让 qing-ui 不依赖任何具体状态方案(易错点 20-3 的分层红线得以成立)。

第 4 题(微前端依赖与通信)

接入营销子系统时,为什么 react/react-dom 要共享(singleton),而业务包绝不共享?事件 coupon:claimed 里应该只放什么?

参考答案

react/react-dom 共享的原因:① 三份框架代码让用户白下载(45KB gzip × 3);② 两个 React 实例并存会抛 Invalid hook call——这是 Hooks 基于单例内部状态的必然结果,所以 singleton 是硬要求;③ 统一版本让升级可控(共享清单 + 产物分析进 CI 验证,第 21 章 §21.5)。业务包绝不共享的原因:业务包共享 = 子应用之间产生隐形依赖,独立发布立刻变联合发布——违背微前端的初衷;宁可重复加载。事件里只放「发生了什么」的信号与最小标识符(如 couponId):总线不带状态、不可回放,传业务数据会造成多份真值与时序灵异 bug;正确做法是收到事件后由消费方 invalidateQueries 回源重取(§19.4 queryKey 工厂 + 易错点 21-4)。

第 5 题(选型)

某状态库 12 万 star,最近一年只发了 2 个版本,issue 平均响应 21 天,核心维护者 1 人。按八维框架,这组数据主要打击哪两个维度的分数?加权总分与竞品接近(差距 < 5%)时应该比什么?

参考答案

打击风险维度(维护者集中度高 = 单点风险:个人失联/弃更即停摆;issue 响应 21 天说明社区自救能力弱)与生态成熟度维度(一年 2 版可能是停滞而非稳定——要结合 issue 积压量与 CHANGELOG 判断「没发版」是「没问题」还是「没人管」,第 23 章 §23.1、易错点 23-1)。star 只反映关注度不反映健康度。分数接近时比退出成本:选「将来后悔时更便宜换掉」的那个,并立刻用薄适配层(qing-ui 封装 AntD、@qing/charts 封装 ECharts 的同构思路)把这个成本再降一档——同时把评估过程写进 ADR,让结论可追溯、可被挑战。

第 6 题(方案写作)

评审会上有人提议「v2 方案既然要做岛屿渲染,不如顺便把整站迁到 Next.js」。方案里的哪一段应该先挡住这个跑题?这段里必须出现什么内容才挡得住?

参考答案

先靠非目标段挡(明确「不做整站 SSR 重写、不迁移 Next.js」),但真正挡住需要方案对比段里的具体代价:整站迁移 3 人月起、中后台登录态/权限/表格密集场景从 SSR 获益有限、交付停摆——「拒绝的理由具体到成本与约束」,而不是一句「不合适」(第 24 章 §24.2 方案 A、易错点 24-1)。检验标准:遮住结论只看对比段,别人能推导出你会选 C(CSR 主体 + 局部岛屿)——推不出来说明对比不诚实。评审会上这类「顺便」需求一律导向两个出口:写入非目标留档,或独立立项走完整方案流程;在 v2 方案里夹带属于范围蔓延,评审员有职责打断。

第 7 题(灰度与回滚)

权限中台化双跑期出现「新旧判定 diff 率 0.8%」,方案设定的告警阈值是 0.1%。此刻该做什么?为什么「fail-closed 降级」和「双跑 diff」这两个机制缺一不可?

参考答案

按预案执行:触发告警 → 立即回退到旧判定逻辑(回滚开关秒级,且不影响其他功能)→ 排查 0.8% 的 diff 样本(通常是权限点清单遗漏,风险表里已列「误拦财务」场景)→ 修复后重新灰度(第 24 章 §24.2 §6、易错点 24-3)。两个机制缺一不可的原因:fail-closed 解决「接口失败时的默认安全」(取不到权限按无权限处理 + 可见重试提示,绝不能 fail-open 越权放行——对应安全红线);双跑 diff 解决「逻辑本身写错时的发现机制」(权限是静默错误,误拦和漏拦用户都不会主动报障,diff 上报是唯一能自动暴露两者的探针)。一个防数据不可用,一个防逻辑错判,覆盖的是两类不同的失败模式。

4. 毕业:管理线还是专家线

四个阶段的 24 章 + 本清单全部通过后,你面前是两条同样值得走的路。它们不是「升职方向」的玄学,而是两种不同的能量来源,选前先诚实回答「哪类工作让你下班时仍然有电」:

管理线(技术负责人 / 前端 Leader)

  • 信号:你在第 22 章(协作规范)、24 章(向上管理与季度规划)做练习时感到兴奋;你更享受「团队因你的机制而变好」而不是「自己写出精妙代码」;
  • 下一步:把资深阶段产物往「组织资产」方向推进——协作手册 v2(跨项目复用)、招聘题库与 onboarding 体系、季度规划对齐公司 OKR;刻意练习 1:1 沟通、目标拆解与「通过别人拿结果」;
  • 警惕:不要放弃技术判断力——管理线的前三年依然需要你能评审方案、主持架构评审(第 24 章 checklist 是你的日常工具),否则会沦为纯协调角色。

专家线(架构师 / 领域专家)

  • 信号:你在第 19 章(架构设计)、21 章(微前端)、24 章(技术方案)写挑战练习时进入心流;你享受「系统因你的设计而变好」,可以连续两周专注一个技术深水区;
  • 下一步:选定一个方向打穿——渲染架构(SSR/岛屿/RSC 深水区)、前端基础设施(Monorepo 工具链/构建性能)、或跨端架构;把毕业项目(第 24 章 §24.5 四选一)做成代表作,开始对外输出(技术博客/开源/分享);
  • 警惕:不要脱离一线——专家线的信用来自「写得出关键代码 + 讲得清原理」的组合,隔季度的动手量是底线。

共同的收尾动作

  1. 完成毕业项目(第 24 章 §24.5 四选一),产出物全部走真实 PR 流程归档;
  2. 去附录 B · 面试题精选——用四阶段各 6–8 题检验沉淀,重点挑战「两版差异化题」(框架原理必须针对 React 本身);
  3. 用附录 C · 学习路线图与资源规划下一年的输入(只列官方与长期维护资源);
  4. 回到附录A再看一眼——如果你现在能用「评分表、ADR、契约、方案、台账」这五样东西,把轻订单的下一步讲给任何人听:毕业了。

从第 01 章的第一个组件,到第 24 章的 v2 技术方案——你已经走完了从「会写代码」到「能对系统与团队负责」的全程。下一程,无论管理线还是专家线,把「留痕、可追溯、可被挑战」的工作方式带走,它比任何框架都保值。

📄 阶段自查 · 高级能力清单 阶段自查-高级能力清单.md

📍 阶段:高级阶段(第 13–18 章)|⏱ 建议用时:30 分钟自查 + 30 分钟自测小考

⬅ 上一章:CI/CD 部署与前端监控 | 📖 本版目录 | ➡ 下一章:大型项目架构设计

💡 对照学习:本清单与 Vue 版阶段自查 · 高级能力清单条目一一对应(能力项、小考知识点、升级标准两版一致),差异仅在实现生态(React 生态 ≈ Vue 生态的对应工具)。对照学习时,两份清单可以互为答案卷。


1. 阶段画像:高级工程师在「轻订单」团队能做什么

初级阶段回答「组件怎么写」,中级阶段回答「系统怎么拼」,高级阶段回答「系统怎么快、怎么稳、怎么交付」。一个完成本阶段的学习者,在 10 人以下的前端团队里应当能独立承担这些事:

  • 接手一个「能用但慢」的系统,用 Profiler / Lighthouse / web-vitals 定位瓶颈,用数据说话地完成渲染与加载性能治理(万级列表、LCP 4s→1.8s 这类战役,你已经打过完整的仗);
  • 建立质量防线:核心模块单测覆盖率 ≥ 80%、下单主流程 E2E 冒烟集进 CI、flaky 用例当日治理——团队开始相信「绿了才合、合了必绿」;
  • 搭起工程化底盘:TypeScript 渐进迁移方案、ESLint/Prettier/husky 提交链路、Vite 构建优化与产物预算;
  • 拥有交付能力:lint→test→build→deploy 流水线、多环境配置注入、Docker+Nginx 部署、灰度与秒级回滚、Sentry 监控与告警 SOP——发布不再是玄学仪式,而是机器执行的可重复流程。

一句话画像:中级交付功能,高级交付「可持续被交付的工程体系」。

2. 能力清单(14 条,勾选前先翻回对应章节重做一遍关键练习)

A 组 · 能独立完成(动手项:不看教程,从零做到验收标准)

  • [ ] 能用 React DevTools Profiler 定位一个真实卡顿场景,并用「状态下放 / memo / 虚拟滚动」完成治理,滚动掉帧 < 5%,且优化前后有 Profiler 对比记录 —— 未达标 → 回看第 13 章 §13.2、§13.4、§13.7
  • [ ] 能用 TanStack Virtual(并讲得出自研简化版原理)实现万级数据的虚拟滚动列表,key 全部使用稳定业务 id —— 未达标 → 回看第 13 章 §13.5–13.6
  • [ ] 能完成门户 LCP 优化到 ≤ 2.0s:代码分割、图片四件套、preload、缓存压缩逐项落地,并有措施-收益对照表 —— 未达标 → 回看第 14 章 §14.2–14.4、§14.8
  • [ ] 能为核心模块(金额计算 / 权限判断 / 工具函数)建立 Vitest + Testing Library 测试,覆盖率 ≥ 80% 且门禁真实拦截 —— 未达标 → 回看第 15 章 §15.2、§15.3、§15.8
  • [ ] 能编写「登录 → 下单 → 审批 → 导出」E2E 冒烟集:storageState 登录态、API 造数隔离、trace 失败还原,CI 上 10 次运行 0 flaky —— 未达标 → 回看第 16 章 §16.4–16.5、§16.9
  • [ ] 能搭建 lint → typecheck → test → build → deploy 的 GitHub Actions 流水线,含多环境运行时配置注入与秒级回滚脚本,并演练过一次回滚 —— 未达标 → 回看第 18 章 §18.2–18.5

B 组 · 能讲清原理(理解项:能给同事讲明白「为什么」)

  • [ ] 能讲清 React 重渲染的三大触发条件与「渲染阶段 / 提交阶段」的划分,并对比 Vue 依赖收集模型的差异与各自的适用条件 —— 未达标 → 回看第 13 章 §13.1
  • [ ] 能讲清 React.memo / useMemo / useCallback 各自缓存什么、何时是负优化,以及「引用不稳定让 memo 失效」的三种典型写法 —— 未达标 → 回看第 13 章 §13.3、易错点 13-1/13-4
  • [ ] 能讲清 LCP / FCP / CLS / INP 的定义、良好线与测量方式,以及「实验室数据 vs 真实用户数据(p75)」的区别 —— 未达标 → 回看第 14 章 §14.5–14.6
  • [ ] 能讲清 Testing Library 的用户中心理念(为什么 Role 优先于 Text、为什么拒绝断言内部状态),以及 vi.mock 与 msw 的分工 —— 未达标 → 回看第 15 章 §15.5–15.6
  • [ ] 能讲清 flaky 用例的四大来源(竞态等待 / 数据漂移 / 环境抖动 / 动画)与对应治理手段 —— 未达标 → 回看第 16 章 §16.7
  • [ ] 能讲清 sourcemap 还原原理,以及「hidden 模式 + Nginx 拒绝 .map + 只上传 Sentry」这道双保险为什么缺一不可 —— 未达标 → 回看第 18 章 §18.6、易错点 18-1

C 组 · 能落地规范(制度项:能写成团队可执行的规则)

  • [ ] 能制定并推行团队 TS 基线:strict 全开、禁新增 any、import type 纪律、@ts-expect-error 留痕规则,并配好 ESLint + husky + lint-staged 的提交门禁 —— 未达标 → 回看第 17 章 §17.1、§17.4、易错点 17-4/17-5
  • [ ] 能把性能预算(LCP ≤ 2.0s、首屏 gzip ≤ 300KB、CLS ≤ 0.1)配置成 CI 卡点,并写进团队验收标准 —— 未达标 → 回看第 14 章 §14.6、第 18 章 §18.2
  • [ ] 能制定 E2E 分层策略(PR 冒烟集 / 主干全量回归)与告警 SOP(阈值表 + 发现→定位→止损→修复→复盘五步)—— 未达标 → 回看第 16 章 §16.1、第 18 章 §18.8

自查方法:每条能力项按「能对同事讲 + 能现场做 + 有产出物」三问打分。三问全过才可勾选;产出物(Profiler 截图、覆盖率报告、复盘文档)建议归档进仓库 docs/ 目录,面试与晋升时都是证据。

3. 30 分钟自测小考(7 题,先作答再看答案)

建议真实作答:纸笔或新建 markdown,写完再展开答案对照。每题 3–5 分钟,总分自评 ≥ 80% 进入第四部分。

第 1 题(渲染机制)

父组件重渲染时,子组件一定会重渲染吗?如果不会「一定」,请说出避免子组件重渲染的两种结构性手段(不靠 memo)。

参考答案

不「一定」——React 默认「父渲染 → 子树全渲染」,但有两种结构性手段可以切断:① children 组合:子树由更外层创建成元素对象传入,父组件内部 state 变化时 children 引用不变,React 直接复用该子树;② 状态下放:把高频变化的 state 移到只消费它的小组件内部,变化天然影响不到兄弟子树。另外 React.memo 在 props 浅比较相同时也可跳过渲染,但它属于「声明式手段」而非结构性手段。(第 13 章 §13.1、§13.4)

第 2 题(memo 三件套)

useMemo 与 useCallback 分别缓存什么?给 memo 组件传 props 时,为什么「内联对象字面量」和「内联箭头函数」是等价的坑?

参考答案

useMemo 缓存计算结果(值),useCallback 缓存函数引用(等价于 useMemo(() => fn, deps))。React.memo 对 props 做浅比较,比较的是引用而非值:每次 render 新建的对象/函数引用都不同,memo 每次都判定「props 变了」,于是照常重渲染——优化完全失效。解法:对象用 useMemo、函数用 useCallback 固定引用,或结构性优化绕开。(第 13 章 §13.3、易错点 13-1)

第 3 题(加载性能)

LCP 与 FCP 的区别是什么?为什么给 LCP 图(如门户 Hero 图)加 loading="lazy" 是自己优化死自己的指标?

参考答案

FCP 是第一个内容元素绘制的时间,LCP 是最大内容元素(通常是大图/大标题)绘制的时间——用户感知的「主内容出现」看 LCP。loading="lazy" 会让浏览器推迟到布局确定该图进入视口附近才开始加载,而 LCP 图恰恰在首屏中央、是渲染路径上的关键资源:延迟加载直接推后 LCP。正确做法:LCP 图立即加载(不加 lazy)+ fetchpriority="high" + preload,lazy 只用于视口外图片。(第 14 章 §14.3、§14.5、易错点 14-4)

第 4 题(Hook 测试)

用 renderHook 测试一个 300ms 防抖 Hook 时,为什么必须用 fake timers?真时钟测试会有什么问题?

参考答案

真时钟意味着每个防抖用例真实等待 300ms+:套件变慢(几十个用例积累成秒级),且受调度抖动影响偶发失败(CI 经典的「本地绿 CI 红」)。fake timers(vi.useFakeTimers())让时间可手动推进:vi.advanceTimersByTime(300) 瞬间走完窗口,还能精确断言「窗口内未生效」这一中间态。注意 afterEach 里 vi.useRealTimers() 恢复,否则污染后续用例。(第 15 章 §15.4、易错点 15-3)

第 5 题(msw vs vi.mock)

vi.mock 和 msw 都能让测试「不依赖真实后端」,它们的分工边界是什么?各举一个典型场景。

参考答案

vi.mock 作用于模块层:替换某个 import 的实现,适合隔离副作用依赖——例如 mock 掉 react-router 的 useNavigate(断言「调用了导航」而不是真跳转)、mock 第三方 SDK。msw 作用于网络层:拦截真实 HTTP 语义(路径、参数、状态码、响应体),适合测数据流——列表加载态、接口 500 的错误分支、翻页竞态。判断口径:要测「与外界的交互契约」用 msw,要测「与某个模块的调用关系」用 vi.mock。(第 15 章 §15.6)

第 6 题(E2E 稳定性)

Playwright 用例里出现 page.waitForTimeout(3000) 通常意味着什么问题?说出两种更好的替代方式,以及 flaky 用例的治理流程。

参考答案

意味着作者在「猜」异步完成的时机:环境快时白等 3 秒(慢),环境慢时 3 秒不够(flaky)——它是 flaky 的第一来源。替代方式:① Web 断言自动重试 await expect(locator).toBeVisible({ timeout: 5000 }),条件满足立刻通过;② 事件等待如 page.waitForEvent('download')。治理流程:重试后仍红当日处理 → 看 trace 定位根因 → 修代码或修用例(数据隔离/关动画/收并发),禁止用再加一层 sleep 过关。(第 16 章 §16.6–16.7、易错点 16-1)

第 7 题(交付与监控)

生产环境既要 sourcemap 支持错误还原,又不能让源码被下载。写出完整的解决方案(构建配置 + 服务器配置 + 上传去向),并说明 VITE_ 前缀环境变量为什么不能用来存密钥。

参考答案

三件套:① 构建配置 build.sourcemap: 'hidden'——生成 .map 但产物 JS 不含对它的引用,用户侧不可见;② 服务器配置 Nginx location ~* \.map$ { return 404; } 物理拒绝下载;③ 构建时由 Sentry 插件(@sentry/vite-plugin,authToken 来自 CI secrets)把 .map 上传到 Sentry,配合 Sentry.init 的 release 字段按版本还原报错栈。VITE_ 变量在构建时被静态替换进客户端 bundle,任何人打开 DevTools/下载 chunk 都能读到——它本质是「公开配置通道」,密钥放进去等于直接公开泄漏,机密值只能存在服务端或 CI secrets。(第 17 章易错点 17-3、第 18 章 §18.6、易错点 18-1/18-2)

4. 升级资深的判断标准

满足以下全部条件,可以进入第四部分 · 资深阶段——那里的问题从「怎么写好代码」升级为「系统该怎么长、团队该怎么协作」:

  1. 从「会做」到「有记录」:本阶段 14 条能力项中,至少 10 条有可归档的产出物(Profiler 对比、性能复盘、覆盖率报告、E2E 报告、回滚演练记录)——资深的第一个标志是工作留痕,他人可接手。
  2. 从「自己快」到「让团队快」:你能把第 17 章的工程化链路讲给新人并在半天内帮他跑通;你写的 lint 规则 / CI 卡点 / PR 模板被团队默认使用——规范的价值在于被别人执行。
  3. 从「修故障」到「设计不故障」:面对一次线上事故,你的第一反应不再是「改代码」,而是「哪条防线漏了」(测试缺口?预算门禁?告警水位?)——这是从高级到资深最本质的转变。
  4. 开始问架构问题:轻订单已经长出 admin/portal 两个应用、shared/qing-ui 两个内部包,接下来模块怎么划分、依赖规则怎么定、状态与请求层怎么组织、单体要不要拆——如果你对这些问题的兴趣大于继续堆功能,第四部分就是为你准备的。

下一站:第 19 章 · 大型项目架构设计。带着你的「轻订单」仓库去——它将在资深阶段完成从单体到多模块的架构演进。

📄 附录 B · React 面试题精选 附录B-面试题精选.md

⬅ 返回目录 | 附录 A · 轻订单需求说明书 | 附录 C · 学习路线图与资源

一、这份题库怎么用

本题库按教程四阶段组织,共 30 题,每题给「考察点」与折叠的「参考答案要点」。定位:

  • 自查定位薄弱项:先只看题目作答(口头或写要点),再展开答案对照。答不上来的题,按要点中标注的「详见第 X 章」回看对应章节的「⚠️ 常见易错点」小节。
  • 答题标准:要点能复述 80% 以上、且能用自己的项目举例,才算过关;只背结论不算。
  • 标注说明:标题带 (React 特有) 的题目是 React 与 Vue 差异化最大的原理题,与 Vue 版附录 B 的同类题不同,双栈同学请分别准备;未标注的题两版概念可互译,但答案仍需用 React 术语作答。

面试前 7 天冲刺法:

天数 动作
D-7 ~ D-5 按「目标岗位级别 − 1」的阶段开始过题:面中级岗就从初级题刷起,每天一个阶段,只记「考察点」框架
D-4 ~ D-3 只攻错题与 React 特有原理题(Fiber / Hooks / 合批 / key),每题写出自己的「是什么→为什么→怎么做→边界」四段
D-2 用第 24 章「技术方案文档」结构,把简历上 2 个项目各准备一个 5 分钟 STAR 故事(背景→任务→行动→结果带数字)
D-1 过一遍每章「⚠️ 常见易错点」与本附录错题,不再学新东西
D-day 面试前 30 分钟只看「六、面试答题方法论」

二、初级阶段(第 01–06 章):JSX / 组件 / Hooks 基础

Q01 · JSX 是什么?它与 HTML、Vue 模板的本质区别是什么?(React 特有)

  • 考察点:渲染模型心智模型;是否知道 JSX 编译产物与「UI = f(state)」的表达式本质。
参考答案要点
  • JSX 是语法糖,构建期被 Babel/esbuild 编译为 jsx(...) 函数调用,产出描述 UI 的 JS 对象(React 元素),再由 React 渲染为真实 DOM(详见第 2 章)。
  • 与 HTML 的区别:属性 camelCase(className、onClick)、style 接对象、标签必须闭合、保留字转义(for → htmlFor)。
  • 与模板的区别:JSX 不引入新 DSL,条件用三元/&&、列表用 map(),全部是 JS 表达式,类型检查与调试栈更直接;代价是缺少模板的静态分析优化空间,优化更多依赖运行时与编译器。

Q02 · key 的作用是什么?为什么不建议用数组索引作 key?(React 特有)

  • 考察点:reconciliation 过程;列表增删排序时的状态错位问题。
参考答案要点
  • React diff 时只在同层级、同类型元素间比较,key 是同层列表的「身份证」,决定子元素是复用还是销毁重建(详见第 2 章)。
  • 用稳定业务 ID(如订单号)作 key,React 能把新旧元素一一配对,只更新变化项。
  • 索引作 key 的问题:在头部插入/删除/排序时,索引与内容错位,导致复用错误的组件实例——输入框内容串行、动画错乱、内部 state 残留。
  • 纯静态、只追加到尾部且无内部状态的列表,索引 key 才勉强可接受;能给出这个边界说明理解到位。

Q03 · 为什么 Hooks 不能写在条件语句或循环里?(React 特有)

  • 考察点:Hooks 实现机制;是否知道 React 靠「调用顺序」关联状态。
参考答案要点
  • React 在 Fiber 节点上按调用顺序用链表保存每个 Hook 的状态;useState 第 N 次调用对应链表第 N 个槽位(详见第 4 章、第 13 章)。
  • 条件/循环/提前 return 会导致某次渲染 Hook 数量或顺序变化,状态槽位错位,React 直接抛错「Rendered fewer hooks than expected」。
  • 条件逻辑应放进 Hook 内部(如 useEffect 依赖里判断)或拆成两个组件。
  • React 官方 ESLint 插件 eslint-plugin-react-hooks 的 rules-of-hooks 规则会在构建期拦截,团队 CI 必须开启(详见第 17 章)。

Q04 · 为什么更新数组/对象状态时必须「不可变更新」?直接 push 会怎样?

  • 考察点:引用相等性判断;setState 的浅比较机制。
参考答案要点
  • setState 前后用 Object.is 浅比较引用决定是否重渲染;push/直接改属性不产生新引用,React 认为「没变」,不触发渲染(详见第 4 章)。
  • 正确写法:数组用 [...list, item]、filter、map;对象用 { ...obj, key: newVal };嵌套结构逐层展开。
  • RTK 内置 Immer,reducer 里可以「直接改」的写法是因为 Immer 在背后生成新引用(详见第 8 章)——能区分「手写 Hook 必须 immutable」与「RTK 里写法糖」是加分项。
  • 深层嵌套更新繁琐时,先考虑扁平化数据结构(normalized state)而不是无脑深拷贝。

Q05 · 说一说 useEffect 的依赖数组机制,以及「闭包过期」是怎么发生的?(React 特有)

  • 考察点:副作用执行时序;每次渲染独立快照;依赖缺失导致的旧值问题。
参考答案要点
  • 每次渲染都是一次独立快照:render 期间定义的函数/变量只属于那一次渲染;useEffect 在提交后异步执行,读的是「本次渲染」的闭包变量(详见第 4、5 章)。
  • 闭包过期:依赖数组漏写某个变量时,effect 只在旧依赖下执行,回调里读到的是旧渲染的值——典型现象是定时器/事件监听里拿到的 state 永远是第一次的值。
  • 解法:补全依赖;setState 用函数式更新 setX(x => x + 1) 摆脱对旧值的闭包依赖;用 useRef 存最新值供回调读取;或在清理函数中取消订阅。
  • 清理函数(return 的函数)解决定时器、事件监听、订阅的重复注册与内存泄漏;StrictMode 下 effect 会「挂载→卸载→再挂载」各执行一次用于暴露这类问题。

Q06 · 受控组件与非受控组件的区别?表单场景如何选择?

  • 考察点:数据流方向;React 推荐的表单心智。
参考答案要点
  • 受控:value 由 state 驱动、onChange 回写,表单数据是「单一数据源」,可即时校验/联动/禁用提交(详见第 2、10 章)。
  • 非受控:DOM 自己持有值,用 ref 在提交时读取,如 defaultValue、文件 <input type="file">。
  • 选择:企业中后台表单默认受控(配 React Hook Form 本质是「注册制受控」+ 按需订阅,性能更好);文件上传、富文本等第三方接管 DOM 的场景用非受控。
  • 能说出受控的代价:每 keystroke 触发重渲染,大表单要用 RHF 的 register 非受控注册或拆组件隔离渲染。

Q07 · React 组件间通信有哪些方式?Context 滥用会有什么性能问题?(React 特有)

  • 考察点:通信方案光谱;Context 的 re-render 传播机制与 Vue provide/inject 的差异。
参考答案要点
  • 光谱:props 回调(父子)→ 状态提升(兄弟)→ Context(跨层主题/用户态)→ 全局 store:RTK/Zustand(跨页面业务态)→ 服务端态交 TanStack Query(详见第 3、5、8 章)。
  • Context 的机制:Provider 的 value 变化时,所有消费该 Context 的组件都会重渲染,且中间组件无法像 memo 那样轻松阻断(消费方本身订阅了 Context)。
  • 因此 Context 适合低频全局值(主题、locale、当前用户),不适合高频业务数据;高频数据用 Zustand 的 selector 按需订阅。
  • 与 Vue 的 provide/inject 对照:inject 默认也是引用共享但 Vue 有细粒度响应式,React 需要「组件自己 memo + value 拆分」来控制传播面——这是双栈高频对比题。

Q08 · 什么是自定义 Hook?它和普通函数、工具模块的区别是什么?

  • 考察点:逻辑复用的正确抽象层级;Hook 规则的延伸理解。
参考答案要点
  • 自定义 Hook = 以 use 开头、内部调用其他 Hook 的函数,用于复用「带状态与生命周期的行为」,如 useDebounce、useOrderFilter(详见第 4 章)。
  • 与普通函数区别:普通函数每次调用独立无状态;自定义 Hook 在组件间复用时每个组件有自己独立的一份状态(它只是把 Hooks 组合方式复用了,不是共享状态)。
  • 与工具模块区别:工具函数是纯计算;自定义 Hook 能访问组件生命周期与副作用。
  • 企业实践:项目里先沉淀 src/hooks/(详见第 12 章目录约定),注意每个 Hook 单一职责、返回语义化元组或对象。

三、中级阶段(第 07–12 章):Router / RTK / TanStack Query / 无感刷新 / 表单校验

Q09 · React Router 中如何实现路由懒加载与权限路由?

  • 考察点:React.lazy + Suspense;守卫思路与 Vue 导航守卫的对照。
参考答案要点
  • 懒加载:const OrderList = lazy(() => import('./pages/OrderList')),外包 <Suspense fallback={<Spin />}>,配合构建工具自动代码分割(详见第 7 章)。
  • 权限路由两种主流做法:组件式 <ProtectedRoute> 包一层,内部读用户态判断重定向;或数据模式的 loader 在进入路由前请求数据/鉴权,不满足直接 throw redirect。
  • 与 Vue Router 导航守卫对照:Vue 是「路由器级 beforeEach 集中拦截」,React 组件式方案是「声明式包裹、就近判断」,大项目建议封装统一的 <AuthGuard> + meta/路由表权限标记。
  • 补充点:动态菜单=根据权限过滤路由表再渲染 <Routes>;刷新后用户态未恢复时要先「等待鉴权状态就绪」再判断,避免误踢到登录页。

Q10 · Redux Toolkit 中 slice、reducer、selector 各自的职责?为什么推荐 selector?

  • 考察点:RTK 分层;派生数据与订阅粒度。
参考答案要点
  • slice = 一块业务状态的「切片」:内聚 initial state + reducers + 自动生成的 action creators(详见第 8 章)。
  • reducer 在 Immer 支持下可写「看似可变」的更新,背后产出不可变新状态,Devtools 仍可时间旅行。
  • selector(常配 createSelector 记忆化)从 store 派生数据:组件只订阅自己需要的切片,避免整 store 变化触发无关重渲染;派生逻辑(过滤、汇总金额)集中可测。
  • 异步用 createAsyncThunk 或 RTK Query;能说清「服务端态优先考虑 TanStack Query,RTK 管跨页面客户端态」是关键分层判断(详见第 9 章)。

Q11 · 什么场景选 Redux Toolkit,什么场景选 Zustand?

  • 考察点:状态库选型的判断依据,而非站队。
参考答案要点
  • RTK:大团队、强规范诉求——单一 store、slice 划分、Devtools 时间旅行、middleware 生态与成熟最佳实践;代价是模板代码与概念面更多(详见第 8 章)。
  • Zustand:轻量、无 Provider 包裹、create 即用、selector 粒度天然按需订阅;适合中小型应用、局部复杂交互态。
  • 共同前提:先做「本地态 / 服务端态 / 全局 UI 态」三分法——服务端态交给 TanStack Query 后,很多项目剩下的全局态其实很少,别为状态而引状态库。
  • 面试加分:给出团队现状维度(成员熟悉度、既有代码、规范成本),而不是「XX 更好」。

Q12 · TanStack Query 解决什么问题?staleTime 和 gcTime 有什么区别?

  • 考察点:服务端态与客户端态的区分;缓存语义。
参考答案要点
  • 解决的问题:手写请求层的「缓存、去重、失效、重试、竞态、加载/错误态」全是模板代码且易错,TanStack Query 把服务端态当作一等公民管理(详见第 9 章)。
  • staleTime:数据在多长时间内被视为「新鲜」,新鲜期内组件重新挂载不重新请求;默认 0(即取回即 stale)。
  • gcTime(v5 改名,旧名 cacheTime):无组件引用后缓存再保留多久才被垃圾回收。
  • 失效模型:写操作成功后 queryClient.invalidateQueries({ queryKey }) 让相关查询变 stale 并自动重取;乐观更新在 onMutate 里先改缓存、失败回滚(详见第 9 章实战)。
  • 对照记忆:staleTime 决定「多久内不重新请求」,gcTime 决定「没人用时缓存活多久」。

Q13 · 订单列表快速切换筛选条件时,旧请求后到覆盖了新结果,怎么办?

  • 考察点:竞态意识;多层解法(取消、版本号、库内置)。
参考答案要点
  • 根因:请求乱序返回,后发的请求可能先到,旧的响应覆盖新状态(详见第 9 章易错点)。
  • 解法一:取消过期请求——axios 的 AbortController,在 effect 清理函数或封装层 abort 上一次。
  • 解法二:请求携带递增版本号/当前筛选快照,响应回来先比对再落地。
  • 解法三:用 TanStack Query,queryKey 包含全部筛选参数,库内置按 key 隔离 + 只采纳最新请求结果,竞态天然解决。
  • 加分:提到对写操作(提交订单)也要防重复提交——按钮 loading + 幂等键,与读竞态是两类问题。

Q14 · 无感刷新 token 的实现原理?并发的 401 请求如何处理?

  • 考察点:拦截器 + 请求队列;刷新失败的边界处理。
参考答案要点
  • 流程:响应拦截器捕获 401 → 用 refresh token 调刷新接口 → 换新 token 后重放原请求,用户无感知(详见第 11 章)。
  • 并发 401:第一个 401 触发刷新时,把后续 401 请求挂进等待队列;刷新完成后统一取出重放,保证只刷新一次。
  • 边界:刷新接口本身 401/失败 → 清空用户态、跳登录页,并提示重新登录;要防「刷新中」的重复触发(isRefreshing 标志)。
  • 细节:重放请求要用新 token 替换 header;登录页自身路由、图形验证码接口加入白名单不拦截;登出时清理队列与缓存。

Q15 · React Hook Form + Zod 如何组织一个带明细行的订单表单校验?

  • 考察点:schema 即文档;动态数组字段与联动校验。
参考答案要点
  • Zod 定义 schema 即校验规则即类型来源:z.infer 产出 TS 类型,前后端共用一份契约,避免「校验与类型两张皮」(详见第 10 章)。
  • 明细行用 useFieldArray 动态增减,schema 里用 z.array(orderItemSchema).min(1) 表达「至少一行」。
  • 联动校验(如「金额汇总 = 明细之和」「折扣超过 10% 需审批人」)用 superRefine/refine 做跨字段校验;提交前 resolver 统一跑 schema。
  • 性能与体验:RHF 按字段订阅错误与值(watch 局部化),避免整表单每键重渲染;提交中 isSubmitting 防重复提交。

Q16 · token 存 localStorage 还是 Cookie?说说 XSS 与 CSRF 的权衡。

  • 考察点:安全权衡能力;知道没有银弹,只有组合防御。
参考答案要点
  • localStorage: convenient、可控过期,但任何 XSS 都能读走——必须配合严格的输入转义、CSP、依赖审计(详见第 11 章)。
  • HttpOnly Cookie:JS 读不到、天然防窃取,但要配 SameSite 防 CSRF,且跨域配置(credentials)更繁琐。
  • 企业常见组合:refresh token 放 HttpOnly + SameSite=Strict Cookie,access token 内存/状态中短周期流转;或 localStorage + 严格 CSP + 敏感操作二次校验。
  • 加分:提到永远不要把 token 放 URL;logout 不仅要清存储还要失效服务端会话。

四、高级阶段(第 13–18 章):性能 / 测试 / 工程化 / CI-CD

Q17 · 什么是 Fiber?组件重渲染的触发条件到底有哪些?(React 特有)

  • 考察点:架构原理;能否系统性列举重渲染来源并给出治理手段。
参考答案要点
  • Fiber 是 React 的协调(reconciliation)架构:把渲染工作拆成带优先级的可中断单元(lane 模型),高优先级更新(输入)可打断低优先级渲染,时间切片避免长任务阻塞主线程(详见第 13 章)。
  • 重渲染触发条件:① 组件自身 setState/dispatch;② 消费的 Context value 变化;③ 父组件重渲染(即使传的 props 全等,默认也会重渲染子组件)。
  • 治理手段对应:③ 用 React.memo + 稳定引用(useMemo/useCallback)阻断;② 拆分 Context 或换 Zustand selector;① 属于预期行为,靠状态下放(把 state 移到真正需要的子组件)缩小范围。
  • 与 Vue 对照:Vue 靠响应式依赖收集做到「改哪个数据、更新哪个绑定」,React 默认「自上而下重渲染再 diff」,这是两版同类题最大的差异点。

Q18 · setState 是同步还是异步?为什么 setState 后立刻读 state 拿到旧值?(React 特有)

  • 考察点:合批机制演进;函数式更新。
参考答案要点
  • 更准确的说法:setState 是「发起一次调度」,不是同步赋值;React 会把同一批次里的多次 setState 合并成一次重渲染(详见第 4、13 章)。
  • React 18 起「自动批处理」覆盖事件处理器、setTimeout、Promise.then、原生事件等所有场景;React 17 及以前只有 React 事件处理器内合批。
  • 拿旧值的原因:本次渲染的闭包里 state 是快照;同批次多次 setCount(count + 1) 基于同一快照只加 1。解法:函数式更新 setCount(c => c + 1)。
  • 需要在「提交后」拿到最新 DOM/状态的场景,放 useEffect(或事件回调的事件对象里读 e.target.value),不要试图「setState 后同步读」。

Q19 · memo / useMemo / useCallback 的真实收益边界在哪?什么情况下用了反而更慢?(React 特有)

  • 考察点:记忆化的成本收益分析;「先测量再优化」。
参考答案要点
  • memo:props 浅相等时跳过子组件渲染;收益 = 省掉的子树渲染成本,成本 = 每次父渲染的 props 浅比较 + 引用维护。子树大且 props 稳定才划算(详见第 13 章)。
  • 失效场景:父组件每次传新对象/新函数(inline object、inline arrow),memo 形同虚设——所以 memo 常和 useCallback/useMemo 成对出现。
  • useMemo 缓存计算结果、useCallback 缓存函数引用:计算本身很轻(如拼一个字符串)时,缓存与比较的成本可能超过收益;useCallback 的确定收益场景是传给被 memo 子组件的回调、以及作为其他 Hook 的依赖。
  • 依赖数组写错会让「该更新时没更新」,这类逻辑 bug 比性能问题更危险。
  • 反例即「更慢」:海量小组件全部 memo,比较与缓存内存开销累积,维护成本(依赖数组心智)远超收益。提到 React Compiler 的自动记忆化方向说明视野,但要能说清它不改变「引用稳定性」的底层逻辑。

Q20 · 谈谈 React 的并发特性与 Suspense:useTransition 解决什么问题?(React 特有)

  • 考察点:并发渲染的用户价值;区分「并发特性」与「多线程」。
参考答案要点
  • 并发渲染 = 渲染可中断、可按优先级调度,React 不是多线程,而是把一次大渲染拆成小片让出主线程,输入等高优交互始终优先(详见第 13 章)。
  • useTransition/startTransition:把「不急」的更新(大列表筛选、Tab 切换)标记为过渡更新,让输入框保持即时响应,期间用 isPending 展示轻量反馈;useDeferredValue 是值的对应版本。
  • Suspense:声明「这段 UI 还没准备好时显示什么」,覆盖 lazy 代码分包、数据请求(TanStack Query 的 useSuspenseQuery)与流式 SSR 的分段注水。
  • 与 Vue 对照:Vue 3 没有同等粒度的可中断渲染(依赖响应式细粒度更新本身轻量),面试时要讲「为什么 React 需要它」——因为默认全子树重渲染,需要调度层兜底。

Q21 · 万级订单列表卡顿,虚拟列表的实现原理是什么?有哪些坑?

  • 考察点:按需渲染原理;工程细节(动态高度、滚动锚定)。
参考答案要点
  • 原理:只渲染可视区 + 上下缓冲区(如各 5 条)的行;外层容器用总高度(估算)撑开滚动条,内层用 transform: translateY(偏移) 把可视行定位到正确位置;滚动时按 scrollTop / 行高 算出 startIndex(详见第 13 章)。
  • 生产直接用 TanStack Virtual 等成熟方案,自研简化版用于理解原理——面试能写伪代码即可。
  • 动态行高:预估值 + 渲染后测量回写缓存;坑包括快速滚动白屏(加大 buffer)、锚定偏移(滚动位置补偿)、搜索定位到指定行的偏移换算。
  • 组合拳:虚拟列表 + 行组件 memo + 单元格按列虚拟化(超宽表格)+ 分页服务端取数,才是「大表格治理」完整答案。

Q22 · 门户首页 LCP 4 秒优化到 1.8 秒,你的排查与优化路径是什么?

  • 考察点:指标定义 → 测量归因 → 分层优化的完整方法论。
参考答案要点
  • 先定义:LCP = 最大内容元素绘制时间,目标 ≤ 2.5s;用 Lighthouse 实验室值 + 真实用户数据(web-vitals 上报)双口径确认瓶颈元素是首屏大图还是标题文本(详见第 14 章)。
  • 网络层:图片 preload + fetchpriority="high"、WebP/AVIF + 响应式尺寸、CDN、brotli 压缩、强缓存指纹策略。
  • 渲染层:首屏关键 CSS、JS 代码分割与路由懒加载、非关键脚本 defer、字体 font-display: swap、SSR/预渲染让 LCP 元素更早出现。
  • 感知层与收尾:骨架屏不改善 LCP 但改善 FCP 体感——面试要区分「真优化」与「体感优化」,避免让骨架屏图成为 LCP 元素反而恶化指标;最后把性能预算进 CI(超阈值卡流水线),防止回劣(详见第 18 章)。

Q23 · 组件测试应该测什么、不测什么?Testing Library 的「用户中心」指什么?

  • 考察点:测试策略与投入产出比;实现细节测试的反模式。
参考答案要点
  • 金字塔:大量纯函数/自定义 Hook 单测(Vitest)→ 适量组件交互测试 → 少量 E2E(Playwright)跑关键路径(详见第 15 章)。
  • 测「行为与用户可见结果」:点击提交后出现错误提示、金额格式化展示正确;不测内部 state、私有方法、class 名这类实现细节——重构不应导致测试大面积翻红。
  • 「用户中心」:用 getByRole('button', { name: '提交订单' }) 这类可访问性查询定位,就像用户真实感知 UI 一样;顺带倒逼 a11y(详见第 15 章理念)。
  • 异步用 findBy* + waitFor;接口 mock 用 MSW 拦在网络层,测试更接近真实;快照测试克制使用(只用于稳定的小组件)。

Q24 · 设计一条前端 CI/CD 流水线;sourcemap 在错误监控中如何安全使用?

  • 考察点:质量门禁顺序;sourcemap 安全发布与还原。
参考答案要点
  • 流水线:lint(含 hooks 规则、TS 类型检查)→ 单测(覆盖率阈值)→ build → E2E 冒烟集 → 制品化(Docker 镜像/静态产物)→ 按环境 dev/test/pre/prod 逐级推进,多环境用环境变量注入配置(详见第 18 章)。
  • sourcemap 策略:构建生成但不随静态资源发布(hidden-source-map 或不发到 CDN),构建后上传到 Sentry 等监控平台用于把压缩报错还原为源码行号(详见第 18 章)。
  • 安全点:sourcemap 泄露等于暴露源码与内部路径;版本要与 release 号绑定,旧版本报错才能正确还原。
  • 加分:灰度发布 + 一键回滚、错误率告警阈值、上线后 30 分钟观察 SOP。

五、资深阶段(第 19–24 章):架构 / 组件库 / 微前端 / 选型

Q25 · 大型 React 应用的架构分层怎么做?依赖规则是什么?

  • 考察点:模块划分方法论;能否讲出「为什么这么分层」而非背名词。
参考答案要点
  • 常用参考:feature-sliced 分层 app / pages / features / widgets / entities / shared,或按业务功能切片(features/orders、features/customers)内聚页面无关的领域逻辑(详见第 19 章)。
  • 核心是依赖规则:只能上层 import 下层,同级之间不互相依赖,shared 不反向依赖任何业务层——保证低层可独立测试与复用。
  • 横切约定统一在 app 层编排:路由表、Provider 组合(Redux/Query/Theme)、应用初始化顺序(详见第 19 章实战)。
  • 判断力加分:分层不是越细越好——业务复杂度低时「目录约定 + ESLint 边界规则」即可,过度分层是新的技术债。

Q26 · 设计一个内部组件库的组件 API,你遵循哪些原则?

  • 考察点:API 设计语汇;受控/非受控、组合、a11y 的落地。
参考答案要点
  • 受控优先:value + onChange 双契约,同时给 defaultValue 非受控逃生口(详见第 20 章)。
  • 组合优于配置:复杂组件用子组件组合(<Table><Table.Column /></Table> 或 render props/children 函数)替代 30 个布尔 prop 的「上帝组件」。
  • 命名词法一致:状态用 loading/disabled/size 统一词表,事件统一 onXxx,跨组件可预期。
  • 工程配套:TypeScript 泛型与 PropsWithChildren、ref 转发(React 19 起函数组件 ref 即 prop)、CSS 变量设计令牌支持主题化、Storybook 文档 + 视觉回归、a11y 基线(键盘可达、ARIA)。
  • 版本治理:语义化版本 + changesets,破坏性变更给迁移期与 codemod。

Q27 · 什么时候需要微前端?选型时对比哪些维度?

  • 考察点:先问「要不要拆」;技术方案的权衡维度。
参考答案要点
  • 先质疑动机:真正需要的是「多团队独立交付 + 技术栈/发布周期解耦 + 存量系统渐进迁移」;只是「想拆」或「页面太大」应该先做模块化与权限拆分(详见第 21 章)。
  • 方案对比维度(qiankun / micro-app / wujie / Module Federation):JS 沙箱与 CSS 隔离原理与完备度、应用间通信方式、公共依赖共享、预加载与首屏代价、对路由/部署的侵入性、社区活跃度与团队熟悉度。
  • 常见坑:样式隔离反噬(弹窗挂 body 失去作用域)、公共依赖版本漂移、全局事件与路由双份、本地联调链路复杂。
  • 落地策略:从「基座 + 一个新子系统」试点,约定通信协议与公共依赖清单,存量用 Strangler 渐进迁移而非一步到位。

Q28 · 新业务线技术选型,你的评估框架是什么?

  • 考察点:结构化决策;能讲「约束条件下的最优」而不是信仰。
参考答案要点
  • 八维评估(详见第 23 章):业务匹配度、生态成熟度、团队现状与学习曲线、招聘市场、成本(开发/维护/基础设施)、风险(社区收缩、安全漏洞)、退出成本(换掉它要付多少)、时间窗(业务上线压力)。
  • 每个候选方案按维度打分并加权,输出对比表;「不选它」的理由与「选它」的理由同等重要。
  • 关键动作:做一个真实切片的 PoC(跑通登录 + 列表 + 表单主流程)验证假设,而不是看 demo。
  • 结论用 ADR 固化(详见第 23 章模板),写明有效期与复审条件,避免「祖传选型」无法重审。

Q29 · 什么是 ADR?它解决团队什么问题?

  • 考察点:决策留痕意识;轻量文档的执行力。
参考答案要点
  • ADR(Architecture Decision Record):一篇记录「一个重要技术决策」的短文档——背景与约束、候选方案、决策结果、后果与代价(详见第 19 章)。
  • 解决的问题:半年后没人记得「为什么用 RTK 而不是 Zustand」,新成员重复辩论甚至推翻重来;ADR 让决策可追溯、可复审、可交接。
  • 实践要点:一决策一文、编号递增、只增不改(推翻就写新一篇并标记 supersedes)、与代码同仓库纳入 Review。
  • 与其他文档的边界:ADR 记「为什么」,技术方案文档(第 24 章)记「怎么做」,wiki 记「怎么用」。

Q30 · 如何写一份能通过架构评审的技术方案?评审时你如何回应质疑?

  • 考察点:方案写作结构;沟通与影响力。
参考答案要点
  • 结构(详见第 24 章):背景与问题 → 目标 / 非目标(明确不做什么,防止范围蔓延)→ 方案对比(至少两案 + 取舍理由)→ 详细设计(分层、接口、数据流)→ 灰度与回滚 → 里程碑与风险。
  • 评审回应姿势:先复述对方关切确认理解,再区分「事实分歧」(拿数据/PoC 说话)与「价值排序分歧」(回到目标与约束对齐),当场记 ADR 待办。
  • 非目标与回滚方案是评审最容易被打的两点:没有回滚 = 没想清楚失败路径。
  • 向管理层沟通:用业务语言讲风险与收益(上线时间、事故成本),用「方案 A/B + 推荐」给决策权而非单选压迫。

六、面试答题方法论

原理题答题框架:是什么 → 为什么 → 怎么做 → 边界。

  1. 是什么:一句话给出准确定义(如「Fiber 是可中断的协调架构」),先给结论再展开。
  2. 为什么:它解决什么问题(长任务阻塞输入),体现你对动机的理解而非背诵。
  3. 怎么做:在你的项目里怎么用的,最好落到「轻订单」这类具体场景(虚拟列表治万级订单)。
  4. 边界:什么时候不适用、代价是什么(memo 滥用更慢、微前端别过度设计)——资深感主要来自这最后一步。

项目题答题框架:STAR。

  • S/T 情境与任务:一句话背景 + 你的职责(「万级订单列表在低配机上掉帧,我负责性能治理」)。
  • A 行动:按「测量 → 归因 → 方案 → 验证」讲,突出你做的取舍(为什么用 TanStack Virtual 而不是自研)。
  • R 结果:带数字(帧率 30→58、LCP 4s→1.8s、测试覆盖 0→72%)与沉淀(规范、工具、文档被团队复用)。
  • 面试官追问「为什么不用 X」时,回到第 23 章选型框架:约束条件下权衡,承认 X 在别的约束下更好——不贬低任何方案。

通用红线:不会的题直接说「这块我只了解到 XX 程度」,然后讲你能推导的部分;编造 API 细节是最大扣分项(与本书写作口径一致:不确定就讲原理 + 给官方文档链接)。

⬅ 返回目录 | 附录 A · 轻订单需求说明书 | 附录 C · 学习路线图与资源

📄 附录 C · React 学习路线图与资源 附录C-学习路线图与资源.md

⬅ 返回目录 | 附录 A · 轻订单需求说明书 | 附录 B · 面试题精选


一、学习路线图

主干按本教程四阶段递进:每章是一个节点,「读章节 → 做练习 → 过易错点 → 写自查清单」构成一轮闭环;实战章(06 / 12 / 18 / 24)是各阶段「结业关口」。分支选修在对应前置完成后按职业方向选学,四个方向互不依赖、可与第四部分并行。

                        ┌────────────────────────────────────────────┐
                        │           主干:四阶段递进路线              │
                        └────────────────────────────────────────────┘

 【第一部分 · 初级】基础语法与组件化开发
 ┌────────┐   ┌──────────┐   ┌──────────┐   ┌──────────┐   ┌──────────┐   ┌──────────┐
 │ 01 走进 │──▶│ 02 JSX   │──▶│ 03 组件  │──▶│ 04 Hooks │──▶│ 05 副作用 │──▶│ 06 门户  │
 │ React  │   │ 渲染模型  │   │ 化基础   │   │ 核心     │   │ 与通信   │   │ 实战 ✔   │
 └────────┘   └──────────┘   └──────────┘   └──────────┘   └──────────┘   └────┬─────┘
                                                                               │
 【第二部分 · 中级】状态管理、路由与服务端交互                                    ▼
 ┌────────┐   ┌──────────┐   ┌──────────┐   ┌──────────┐   ┌──────────┐   ┌──────────┐
 │ 07 路由 │──▶│ 08 RTK   │──▶│ 09 服务端 │──▶│ 10 表单  │──▶│ 11 登录  │──▶│ 12 系统  │
 │ Router │   │ Zustand  │   │ 交互/TQ  │   │ 与校验   │   │ 鉴权     │   │ 实战 ✔   │
 └────────┘   └──────────┘   └────┬─────┘   └──────────┘   └──────────┘   └────┬─────┘
                                  │                                            │
 【第三部分 · 高级】性能优化、测试与工程化                                         ▼
 ┌────────┐   ┌──────────┐   ┌──────────┐   ┌──────────┐   ┌──────────┐   ┌──────────┐
 │ 13 渲染 │──▶│ 14 加载  │──▶│ 15 组件  │──▶│ 16 E2E   │──▶│ 17 TS与  │──▶│ 18 CI/CD │
 │ 性能    │   │ 性能     │   │ 测试     │   │ 与质量   │   │ Vite     │   │ 监控 ✔   │
 └────────┘   └──────────┘   └──────────┘   └──────────┘   └──────────┘   └────┬─────┘
                                                                               │
 【第四部分 · 资深】架构设计、团队协作与技术选型                                  ▼
 ┌────────┐   ┌──────────┐   ┌──────────┐   ┌──────────┐   ┌──────────┐   ┌──────────┐
 │ 19 架构 │──▶│ 20 组件  │──▶│ 21 微前  │──▶│ 22 团队  │──▶│ 23 技术  │──▶│ 24 资深  │
 │ 设计    │   │ 库建设   │   │ 端协作   │   │ 协作规范 │   │ 选型决策 │   │ 实战 ✔   │
 └────────┘   └──────────┘   └──────────┘   └──────────┘   └──────────┘   └──────────┘

                        ┌────────────────────────────────────────────┐
                        │        分支选修(按职业方向选学)            │
                        └────────────────────────────────────────────┘

  前置 14/18 ──▶  A. Next.js / SSR / 流式渲染      (前端平台与服务端渲染方向)
  前置 13/17 ──▶  B. 可视化:ECharts / D3          (数据大屏与报表方向)
  前置 04/08 ──▶  C. React Native / 跨端           (移动端方向)
  前置 09/12 ──▶  D. Node 全栈:Express / NestJS   (BFF 与全栈方向)

  说明:✔ 表示该阶段实战章 + 阶段自查清单(各 part 目录下「阶段自查-XX能力清单.md」)
  通过后再进入下一阶段;四个选修方向可并行,不必全学。

单个节点的学习闭环(主干上每个节点都按此执行):

      ① 读章节              ② 做练习                ③ 过易错点
  ┌────────────┐      ┌────────────┐        ┌────────────┐
  │ 核心知识点   │ ──▶ │ 基础/进阶/  │  ──▶  │ ❌/✅ 对照码 │
  │ + 可运行代码 │      │ 挑战三档    │        │  逐条自测   │
  └────────────┘      └────────────┘        └──────┬─────┘
          ▲                                        │
          │            ④ 写阶段自查清单 ◀───────────┘
          │      ┌─────────────────────────────┐
          └───── │ 全勾 → 进入下一节点           │
                 │ 未达标 → 按「回看第 X 章 §Y」 │
                 │ 定向补课,不带病前进          │
                 └─────────────────────────────┘

阶段升级判断标准(对应图中 ✔ 关口,自查清单文件在各 part 目录下):

从 → 到 达标标准(全部满足再升级)
初级 → 中级 完成第 06 章门户实战且验收项全过;「初级能力清单」复选框全勾;附录 B 的 Q01–Q08 能不看资料复述要点框架
中级 → 高级 完成第 12 章系统实战(登录 → 动态菜单 → 订单 CRUD → 权限);「中级能力清单」全勾;Q09–Q16 要点能结合自己的项目举例
高级 → 资深 完成第 13–18 章三个治理案例(性能 / 测试 / CI-CD);「高级能力清单」全勾;至少有一个带量化数字的优化结果(如 LCP、帧率、覆盖率)
资深 → 毕业 完成第 24 章技术方案全文并通过一次(模拟)评审;用 ADR 格式沉淀过至少 2 个真实技术决策;「资深能力清单」全勾

二、权威资源清单

只收录官方、长期维护、稳定的一手资源;三方博客与教程容易过时,只作补充,不作为依赖。遇到 API 争议时,以对应官方文档为准。

框架与渲染

资源 链接 用途(一句话)
React 官方文档 https://react.dev React 一手知识源:Learn 教程、API 参考、官方博客与版本公告
React Router https://reactrouter.com 路由官方文档:组件式路由、数据模式(loader/action)与升级迁移指南
Ant Design https://ant.design 本教程 UI 组件库官方文档:组件 API、设计规范与主题化(Design Token)
Vite https://vite.dev 构建工具官方文档:插件机制、代理、环境变量与构建产物优化

状态与服务端交互

资源 链接 用途(一句话)
Redux Toolkit https://redux-toolkit.js.org RTK 官方文档:slice / createAsyncThunk / RTK Query 的权威用法与 Style Guide
TanStack Query https://tanstack.com/query/latest 服务端状态库官方文档:v5 API(staleTime / gcTime)、Suspense 集成与缓存语义

表单与校验

资源 链接 用途(一句话)
React Hook Form https://react-hook-form.com 表单库官方文档:register / useFieldArray 与「非受控注册」的性能心智
Zod https://zod.dev Schema 校验官方文档:schema 定义、z.infer 类型推导与跨字段校验

测试与质量

资源 链接 用途(一句话)
Vitest https://vitest.dev 单测框架官方文档:配置、mock(vi.mock)与覆盖率
Testing Library https://testing-library.com 组件测试官方文档:用户中心查询原则与「测行为不测实现」指引
Playwright https://playwright.dev E2E 官方文档:定位器、storageState 登录复用、trace 与 CI 接入

语言与 Web 平台

资源 链接 用途(一句话)
TypeScript https://www.typescriptlang.org/docs TS 官方手册与 tsconfig 参考:基础类型到泛型、类型收窄的权威解释
MDN Web Docs https://developer.mozilla.org Web 平台标准参考:HTML / CSS / JS 与浏览器 API 的权威手册
web.dev https://web.dev Google 性能与最佳实践官方站:Core Web Vitals(LCP / INP / CLS)口径与优化指南

使用建议:学每章时先读对应官方文档的「Getting Started」级页面建立正见,再回到教程做练习;遇到教程与官方文档说法不一致时,以官方文档为准并向维护者反馈。


三、章节映射表(路线图节点 ↔ 本教程章节)

链接均为相对路径(从 react-edition/appendix/ 出发)。「呼应附录 B」列给出该节点对应的面试题编号,学完即可自测。

第一部分 · 初级(节点 A1–A6)

节点 教程章节 呼应附录 B
A1 环境搭建与框架概览 01 · 走进 React-框架概览与环境搭建 —
A2 JSX 与渲染模型(key / diff 直觉) 02 · JSX 与渲染模型 Q01、Q02
A3 组件化基础(props / 回调 / 组合) 03 · 组件化开发基础 Q06、Q07
A4 Hooks 核心(state / effect / memo) 04 · Hooks 核心 Q03、Q04、Q05、Q08
A5 副作用、生命周期与通信(Context) 05 · 副作用生命周期与组件通信 Q05、Q07
A6 门户首页实战(结业 ✔) 06 · 初级实战-企业门户首页 Q01–Q08 综合

配套自查:阶段自查-初级能力清单

第二部分 · 中级(节点 B1–B6)

节点 教程章节 呼应附录 B
B1 路由系统(懒加载 / 守卫 / Layout) 07 · 路由系统-ReactRouter 全解 Q09
B2 全局状态(RTK 为主,Zustand 对比) 08 · 状态管理-ReduxToolkit 与 Zustand Q10、Q11
B3 服务端交互(Axios 封装 + TanStack Query v5) 09 · 服务端交互与 TanStackQuery Q12、Q13
B4 表单处理与校验(RHF + Zod) 10 · 表单处理与校验 Q15
B5 登录鉴权与权限(无感刷新 / 按钮级权限) 11 · 登录鉴权与权限控制 Q14、Q16
B6 轻订单管理系统实战(结业 ✔) 12 · 中级实战-轻订单管理系统 Q09–Q16 综合

配套自查:阶段自查-中级能力清单

第三部分 · 高级(节点 C1–C6)

节点 教程章节 呼应附录 B
C1 渲染性能(Fiber / memo / 虚拟列表) 13 · 渲染性能优化 Q17、Q19、Q20、Q21
C2 加载性能(代码分割 / LCP 指标) 14 · 加载性能优化 Q22
C3 组件测试(Vitest + Testing Library) 15 · 组件测试 Q23
C4 E2E 与质量保障(Playwright) 16 · E2E 测试与质量保障 Q23、Q24
C5 工程化(TypeScript 严格化 + Vite) 17 · 工程化实践-TypeScript 与 Vite Q19(工程化视角)
C6 CI/CD 与监控(结业 ✔) 18 · CICD 部署与前端监控 Q24

配套自查:阶段自查-高级能力清单

第四部分 · 资深(节点 D1–D6)

节点 教程章节 呼应附录 B
D1 大型项目架构分层与依赖规则 19 · 大型项目架构设计 Q25、Q29
D2 设计系统与组件库建设 20 · 设计系统与组件库建设 Q26
D3 微前端与跨团队协作 21 · 微前端与跨团队协作 Q27
D4 团队协作规范(分支 / Review / 文档) 22 · 团队协作规范 Q29、Q30
D5 技术选型决策 23 · 技术选型决策 Q28
D6 技术方案与架构评审(结业 ✔) 24 · 资深实战-技术方案与架构评审 Q30

配套自查:阶段自查-资深能力清单

全书共用

节点 文档
贯穿项目业务需求(开工前必读,约 15 分钟) 附录 A · 轻订单需求说明书
面试定向突破(四阶段 30 题) 附录 B · 面试题精选

四、选修进阶方向

主干完成后按职业方向选学。每个方向给「是什么、做什么、前置要求、第一站」;第一站一律先官网一手资料。

A. Next.js / SSR / 流式渲染

  • 是什么:React 官方推荐的全栈应用框架,提供服务端渲染(SSR)、静态生成(SSG)、流式渲染与 Route Handlers。
  • 做什么:把第 06 章门户首页改造为 Next.js 版本,量化对比 LCP;理解 CSR / SSR / SSG / ISR 的适用场景与 SEO 关系。
  • 前置要求:第 13–14 章(性能指标与代码分割)、第 18 章(部署);理解 React 19 的 Suspense。
  • 第一站:Next.js 官方文档 Learn 课程(nextjs.org)。

B. 可视化:ECharts / D3

  • 是什么:企业中后台高频需求——报表与数据大屏;ECharts 开箱即用,D3 提供可完全掌控的底层可视化原语。
  • 做什么:为「轻订单」补齐销售报表模块;用 ECharts 做常规图表,用 D3 理解比例尺与坐标轴,封装成可复用图表组件并处理 resize 与按需引入。
  • 前置要求:第 03 章(组件封装)、第 09 章(数据请求)、第 13 章(大数据量渲染性能与大屏同源)。
  • 第一站:ECharts 官网示例库(echarts.apache.org)与 D3 官方(d3js.org)。

C. React Native / 跨端

  • 是什么:用 React 语法构建原生移动应用的官方方案,「一套 React 心智,多端交付」。
  • 做什么:把「轻订单」查单/审批场景做成 RN 演示 App,体会样式体系(Flexbox)、React Navigation 与原生能力边界和 Web 的差异。
  • 前置要求:第 04–05 章(Hooks 与通信是 RN 的全部日常)、第 08 章(状态管理);不要求先学完性能篇。
  • 第一站:React Native 官方文档(reactnative.dev)的 Environment Setup 与 Tutorial。

D. Node 全栈:Express / NestJS

  • 是什么:用 JavaScript / TypeScript 编写服务端,为前端工程师补齐 BFF(Backend for Frontend)与全栈交付能力。
  • 做什么:为「轻订单」实现一个 BFF 层:聚合订单/客户接口、统一鉴权与错误码、代理第三方服务,让第 09 章的请求封装有真实对端。
  • 前置要求:第 09 章(HTTP 语义、拦截器、错误归一化)、第 12 章(系统整合)、第 17 章(TS 工程化)。
  • 第一站:Express 官网(expressjs.com)入门;进阶再看 NestJS 官方文档(docs.nestjs.com)。

五、保持更新:如何跟进版本变化

前端生态迭代快,比「追新」更重要的是建立低成本、可持续的信息习惯:

  1. 以官方 Release Notes 为唯一信源:升级前必读对应仓库的 Releases 页与 CHANGELOG.md,重点看三段——Breaking Changes、Deprecations、迁移指南;不逐字泛读。
  2. 建立版本号敏感度:主版本(如 React 19 → 20)通常含破坏性变更,先读迁移指南再动手;次版本看新特性是否命中项目痛点;补丁版本重点关注安全修复。
  3. 官方博客优先于二手解读:React 的重大方向(并发特性、Compiler、Actions 等)都会先在 react.dev/blog 发文;先建立一手认知,再看社区讨论补充视角。
  4. 用测试与类型当升级安全网:这正是第 15–17 章的价值——升级前跑通组件测试 + E2E 冒烟集 + TS 严格编译,让 Breaking Changes 在本地暴露而不是在生产。
  5. 升级节奏:业务项目遵循「落后一个稳定版本」策略,先在内部工具/新项目验证,等生态(组件库、路由、状态库)适配完成再推进主项目,并按第 23 章的「退出成本」视角评估每次引入依赖。
  6. 维护团队「版本基线」文档:记录当前锁定的版本与升级决策(复用第 19 章 ADR 格式),每次评估后更新基线并注明复审时间。

CHANGELOG 阅读模板(示意结构,非真实内容;每次升级按此清单走一遍):

## vX.Y.Z  —— 是否影响我?
├── ⚠️ Breaking Changes   → 逐条核对项目代码,逐条给出迁移动作与工期
├── 🗑 Deprecated          → 登记技术债:替换目标 + 计划替换的版本窗口
├── ✨ New Features        → 只引入命中当前痛点的项;记入 ADR/周会同步
├── 🐛 Bug Fixes           → 扫一遍是否命中自己在用的行为
└── 🔒 Security            → 安全修复触发尽快升级,不受节奏策略限制

信息渠道速查表:

渠道 看什么 频率建议
react.dev/blog React 重大方向与新版本公告 每月浏览一次
GitHub Releases(各依赖仓库) 版本变化、破坏性变更、迁移指南 每次升级前必读
各库官方文档的 Upgrade Guide 跨版本迁移步骤 大版本升级时通读
团队版本基线文档(自建) 当前锁定版本与决策记录 每季度复审一次

⬅ 返回目录 | 附录 A · 轻订单需求说明书 | 附录 B · 面试题精选

📄 Vue 版 · 导读 00-导读.md

欢迎来到《Web 前端开发进阶教程》Vue 版。本版基于 Vue 3(Composition API + <script setup>),带你从初级程序员一路走到企业级资深开发。如果你同时也在学 React,本教程有章节一一对应的 React 版,可以逐章对照(见「双栈对照学习」)。


1. 技术栈基线

维度 选型
框架 Vue 3.5+(只教 Composition API + <script setup>;Options API 仅在对比时提及)
构建 Vite 6+
路由 Vue Router 4
状态管理 Pinia 3
UI 组件库 Element Plus
服务端交互 Axios 封装(自研 composable,第三部分提及 TanStack Query Vue 适配)
语言 第一、二部分 JavaScript(可加类型注释),第三部分起全面 TypeScript
测试 Vitest + Vue Test Utils + Playwright
包管理 pnpm

版本号会持续演进,具体 API 以官方文档为准;教程讲解的是长期稳定的核心概念与工程方法。

2. 章节地图

部分 章节 主题
第一部分 · 初级 01 · 02 · 03 · 04 · 05 · 06 基础语法与组件化开发
初级能力自查清单
第二部分 · 中级 07 路由 · 08 状态 · 09 服务端 · 10 表单 · 11 鉴权 · 12 实战 状态管理、路由与服务端交互
中级能力自查清单
第三部分 · 高级 13 渲染性能 · 14 加载性能 · 15 组件测试 · 16 E2E · 17 工程化 · 18 CI/CD 性能优化、测试与工程化
高级能力自查清单
第四部分 · 资深 19 架构 · 20 组件库 · 21 微前端 · 22 协作 · 23 选型 · 24 实战 架构设计、团队协作与技术选型
资深能力自查清单
附录 附录A 轻订单需求说明书 · 附录B 面试题 · 附录C 路线图与资源

3. 每章的固定结构

  1. 🎯 学习目标:5–8 条可验证的能力描述;
  2. 📖 核心知识点:概念 → 为什么 → 怎么做 → 可运行代码;
  3. 🛠 渐进式实战练习:基础 / 进阶 / 挑战三档,均附验收标准;
  4. ⚠️ 常见易错点:❌/✅ 对照代码,全部来自企业代码评审高频问题;
  5. ✅ 最佳实践:可直接落进团队规范的条目;
  6. 📌 本章小结:要点回顾 + 下一章预告。

4. 贯穿项目:「轻订单」

全书所有练习围绕虚构企业级 SaaS「轻订单」展开(B2B 订单管理系统)。四个阶段在同一业务上递进:门户首页 → 中后台系统 → 性能与质量治理 → 架构演进。开工前请先通读 附录A(约 15 分钟),里面有你写练习时要反复使用的角色、状态机、接口约定与 TypeScript 实体。

5. 双栈对照学习(可选)

本教程与 React 版章节完全对应(同编号、同练习、同自查清单)。每章开头有「💡 对照学习」框,标注本章概念与 React 的对应关系,例如:

Vue React
ref / reactive useState
computed useMemo
watch / watchEffect useEffect
provide / inject Context
<slot> children / 组合
Vue Router 导航守卫 React Router loader / 路由包裹
Pinia store Redux Toolkit slice / Zustand store

6. 环境准备清单(开工前完成)

  • [ ] Node.js 20 LTS 及以上:node -v
  • [ ] pnpm 9+:npm i -g pnpm && pnpm -v
  • [ ] VS Code + 插件:Vue - Official(Volar)、ESLint、Prettier
  • [ ] Chrome + Vue Devtools 扩展
  • [ ] Git 基础(第四部分会讲团队协作规范,但克隆/提交要会)

7. 学习节奏建议

  • 在职自学:每周 6–8 小时,初级约 3 周、中级约 5 周、高级约 6 周、资深约 4 周(含练习)。
  • 突击面试:按阶段读「学习目标 + 易错点 + 自查清单」,配合附录 B 面试题定向突破。
  • 团队内训:直接以各阶段「渐进式实战练习」为作业,自查清单作为结业考核。

准备好了?打开 第 01 章 · 走进 Vue 3。

📄 第 01 章 · 走进 Vue 3:框架概览与环境搭建 01-走进Vue3-框架概览与环境搭建.md

📍 阶段:初级阶段(第 1–6 章)|⏱ 预计学时:3 小时|🔗 前置:无(建议先读 《导读》 与 《附录 A · 贯穿项目「轻订单」需求说明书》)

⬅ 上一章:导读 | 📖 本版目录 | ➡ 下一章:模板语法与数据绑定

💡 对照学习:本页是 Vue 版第 01 章。React 版对应章节为 第 01 章 · 走进 React。概念映射:Vue 的「单文件组件(SFC)」≈ React 的「组件文件(.jsx)」;Vue 的「template 模板」≈ React 的「JSX」;两版均使用 Vite + pnpm,工程链路完全一致,可以边学边对照。


🎯 学习目标

学完本章,你将能够:

  1. 说出前端框架解决的核心问题:手动操作 DOM 带来的「状态与视图同步」负担。
  2. 区分 SPA 与 MPA 的适用场景,并指出「轻订单」门户与后台各自的技术形态。
  3. 描述声明式 UI 的心智模型:视图是状态的函数,开发者只关心「数据是什么」而不是「DOM 怎么改」。
  4. 搭建 Node + pnpm + Vite 的 Vue 3 开发环境,并创建「轻订单门户」项目。
  5. 逐项说出 Vite 脚手架生成的每个目录与文件的职责,知道「哪些能删、哪些不能动」。
  6. 演示 dev server 热更新(HMR)流程,并解释它与整页刷新的区别。
  7. 编写第一个 <script setup> 单文件组件,并用 Vue Devtools 观察组件树与组件状态。
  8. 完成「接手轻订单仓库第一天」的企业入职流程:拉代码 → 装依赖 → 跑起来 → 看懂结构。

📖 核心知识点

1.1 为什么需要前端框架

在「轻订单」立项之前,青云科技的老官网是一坨原生 JS:几百行 document.querySelector 散落在各个页面里。当运营想把「待审核订单数」的角标同步到导航栏时,工程师要写这样的代码:

// 命令式:手动找到 DOM、手动比较、手动更新——每个状态都要写一遍
const badge = document.querySelector('#pending-badge')

function setPendingCount(count) {
  if (badge.textContent !== String(count)) {
    badge.textContent = count // 1. 改文本
  }
  badge.style.display = count > 0 ? 'inline-block' : 'none' // 2. 改显隐
  badge.classList.toggle('badge--urgent', count > 99) // 3. 改样式
  // 4. 如果还有别的地方显示同一个数字……忘改一处就是 bug
}

问题不在于「代码多」,而在于同一个状态散落在多个 DOM 更新点里,人脑无法保证它们一致。这就是企业项目里最典型的事故来源——附录 A 里「轻订单」要解决的三类事故(漏单、改单无痕、权限混乱),追到代码层,全是「状态与界面不同步」的变体。

框架的答案是把「状态 → 视图」的同步交给框架自动完成:

<script setup>
import { ref } from 'vue'

// 只有一个数据源:状态
const pendingCount = ref(3)
</script>

<template>
  <!-- 视图是状态的"函数":状态一变,这里自动更新 -->
  <span v-if="pendingCount > 0" class="badge" :class="{ 'badge--urgent': pendingCount > 99 }">
    {{ pendingCount }}
  </span>
</template>

你只改 pendingCount.value,所有依赖它的 DOM 位置自动更新。开发者从「怎么改 DOM」中解放出来,只关心「状态是什么、什么时候变」——这就是前端框架存在的理由。

1.2 SPA 与 MPA:先分清两种形态

维度 MPA(多页应用) SPA(单页应用)
页面跳转 每次跳转都向服务器请求新的 HTML 前端接管路由,只切换视图组件
首屏速度 通常快(服务端直出) 首包较重,需靠工程手段优化
后续交互 整页刷新,体验割裂 局部更新,接近原生应用
SEO 友好 需额外手段(SSR/SSG,见第四部分)
典型场景 官网、内容站 中后台系统、复杂交互产品

对应到「轻订单」的两期交付:

  • 一期 · 官网门户:内容为主、需要被搜索引擎收录获客——本阶段我们先用 SPA 实现(第 06 章实战),真实企业里常进一步用静态站点生成(SSG)优化首屏;
  • 二期 · 管理后台:登录后使用、交互密集、无需 SEO——典型的 SPA 场景,将在第二部分实现。

1.3 声明式 UI 的心智模型

三种主流声明式框架(Vue / React / Svelte)共享同一个心智模型:

        事件(用户操作)
   ┌────────────────────────┐
   ▼                        │
┌─────────┐            ┌─────────┐
│  状态    │ ───自动───► │  视图    │
│ (state) │   同步渲染   │  (DOM)  │
└─────────┘            └─────────┘

  你只负责:① 定义状态  ② 声明"状态长什么样"  ③ 在事件里改状态
  框架负责:状态变化 → 精确更新 DOM

Vue 与 React 的差异在「框架怎么知道该更新了」:Vue 3 通过响应式系统在数据被读写时自动追踪依赖(第 04 章详解);React 则通过 setState 显式触发重新渲染。两者没有优劣,只是约定不同——本教程不贬低任何一方,学到第 04 章你自然能体会两种取舍。

1.4 Vue 3 的两个关键词:Composition API 与 <script setup>

本教程只教 Composition API + <script setup>(Vue 3 的现代写法,也是官方推荐);Options API(data/methods 那套旧写法)仅在你阅读老代码时才会遇到,知道即可。

Vue 的组件是单文件组件(SFC,.vue 文件),一个文件里装齐逻辑、视图、样式三块:

<script setup>
// 【逻辑区】导入、响应式状态、函数——执行在组件创建之前
import { ref } from 'vue'

const slogan = ref('让每一张订单都有迹可循')
</script>

<template>
  <!-- 【视图区】与 HTML 同构的模板,可使用插值与指令 -->
  <h1>轻订单 · {{ slogan }}</h1>
</template>

<style scoped>
/* 【样式区】scoped 表示这些样式只作用于当前组件 */
h1 {
  color: #2563eb;
}
</style>

<script setup> 是 <script> 的编译期语法糖:里面的顶层变量、函数自动暴露给模板使用,导入的组件自动注册,不需要手写 components 选项。

1.5 环境搭建:Node + pnpm + Vite

企业前端的标准三件套。Vite 6 要求 Node 18+,建议直接安装 Node 20 LTS 或 22 LTS(用 nvm-windows 或 nvm 管理多版本)。

# 1. 确认 Node 版本(需 >= 18,建议 20 LTS 或 22 LTS)
node -v

# 2. 启用 pnpm(Node 16.13+ 自带 corepack;也可以 npm i -g pnpm)
corepack enable
pnpm -v

# 3. 用官方脚手架 create-vite 创建项目,选 vue 模板(JavaScript 版)
pnpm create vite@latest qingorder-portal --template vue

# 4. 安装依赖并启动
cd qingorder-portal
pnpm install
pnpm run dev

官方还有 pnpm create vue@latest(create-vue),它在 Vite 之上多问几个选项(是否要 TypeScript / Vue Router / Pinia / ESLint 等),适合直接开企业项目。本阶段我们先用纯净的 vite 模板,第二部分再升级为 create-vue 的全家桶形态。

终端出现如下输出即成功:

  VITE v6.x.x  ready in 412 ms

  ➜  Local:   http://localhost:5173/
  ➜  press h + enter to show help

浏览器打开 http://localhost:5173/,看到 Vite + Vue 的默认页面,环境就绪。

1.6 项目结构逐项解读

脚手架生成的目录不长,但每一项都要知道职责——企业里「看懂仓库」比「会写代码」更早成为你的日常:

qingorder-portal/
├── node_modules/            # 依赖(pnpm 硬链接安装,体积小、速度快)
├── public/                  # 原样拷贝的静态资源(favicon 等),不经构建处理
├── src/                     # ★ 源码目录,你的 99% 时间都在这里
│   ├── assets/              #   需要构建工具处理的资源(图片、字体、全局样式)
│   ├── components/          #   可复用组件(第 03 章起大量使用)
│   ├── App.vue              #   根组件:整棵组件树的起点
│   ├── main.js              #   应用入口:创建 Vue 实例并挂载
│   └── style.css            #   全局样式
├── index.html               # ★ SPA 唯一的 HTML 页面(注意:不在 src 里)
├── package.json             # 项目清单:依赖、脚本命令
├── vite.config.js           # Vite 配置:插件、代理、别名(进阶练习会改它)
└── .gitignore               # node_modules、dist 等不入库

两个入口文件的配合关系(面试常问:页面是怎么跑起来的?):

<!-- index.html:SPA 的壳。只有一小段关键内容 -->
<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>轻订单 · 青云科技</title>
  </head>
  <body>
    <!-- 挂载点:整个应用渲染进这个 div -->
    <div id="app"></div>
    <!-- 以 ES Module 方式加载入口脚本 -->
    <script type="module" src="/src/main.js"></script>
  </body>
</html>
// src/main.js:应用入口
import { createApp } from 'vue'
import './style.css' // 全局样式最先引入
import App from './App.vue'

// 创建应用实例 → 挂载到 #app,App 组件树从这里长出来
createApp(App).mount('#app')

1.7 dev server 与热更新(HMR)

pnpm run dev 启动的是 Vite 开发服务器,它有两个企业级特性:

  1. 按需编译:浏览器请求哪个模块,Vite 才即时转换哪个(基于浏览器原生 ESM),所以启动是秒级的——不像老式打包器要把整个项目先打一遍;
  2. 热更新(HMR,Hot Module Replacement):改代码保存后,只替换被修改的模块,组件状态尽量保留,页面不整页刷新。

动手验证 HMR(后面实战练习要交作业):

  1. 打开 src/App.vue,把模板里的标题改成 轻订单 · 青云科技,保存——页面文字变了,浏览器没有刷新(Network 面板没有整页请求);
  2. 修改 vite.config.js——终端会提示重启,因为配置文件改动不支持热更,需要重启 dev server。

1.8 第一个组件:BrandBadge

在 src/components/ 下新建 BrandBadge.vue(组件名用多词大驼峰,这是官方风格指南的强制建议,避免与原生 HTML 标签冲突):

<script setup>
import { ref } from 'vue'

// ref() 创建响应式数据:JS 里读写要 .value,模板里自动解包(第 04 章详解)
const productName = ref('轻订单')
const version = ref('v1.0')
</script>

<template>
  <span class="brand-badge">
    <strong>{{ productName }}</strong>
    <small>{{ version }}</small>
  </span>
</template>

<style scoped>
.brand-badge {
  display: inline-flex;
  gap: 4px;
  align-items: baseline;
  padding: 2px 10px;
  border-radius: 999px;
  background: #eef2ff;
  color: #4338ca;
  font-size: 14px;
}
</style>

在 src/App.vue 中导入并使用——<script setup> 里导入的组件无需注册即可直接当标签用:

<script setup>
import BrandBadge from './components/BrandBadge.vue'
</script>

<template>
  <header>
    <BrandBadge />
    <h1>欢迎来到轻订单门户</h1>
  </header>
</template>

保存后页面上出现了带底色的徽标——你已经完成了 Vue 开发的完整闭环:建组件 → 导入 → 使用 → 热更新生效。

1.9 官方调试工具:Vue Devtools

装上官方浏览器扩展 Vue.js devtools(Chrome/Edge 商店搜索安装,或用其独立版应用),dev 环境下打开页面按 F12 会多出一个 Vue 面板:

  • 组件树(Components):像 DOM 树一样浏览组件层级,点选任意组件可查看/临时修改它的 props、响应式状态——排查「数据没渲染」类问题的第一现场;
  • 组件事件(Timeline):观察组件间事件触发时序(第 03 章学 emit 后会常用);
  • 路由 / Pinia 面板:第二部分装上对应库后自动出现。

注意:Devtools 只在开发构建中生效,生产构建默认不暴露组件状态——这本身也是一条安全基线(不要在生成环境中把内部状态暴露给访问者)。

1.10 企业场景:接手「轻订单」仓库的第一天

真实入职不是「新建项目」,而是「接手已有仓库」。假设导师给了你内网仓库地址,你的标准动作是:

# 1. 克隆仓库(教学场景用占位地址)
git clone <your-repo-url> qingorder-portal
cd qingorder-portal

# 2. 看 README 与 package.json:确认 Node 版本、包管理器、启动命令
cat package.json

# 3. 安装依赖(永远用仓库锁定的包管理器,本教程为 pnpm)
pnpm install

# 4. 启动开发服务器,确认页面能打开
pnpm run dev

# 5. 打开 Vue Devtools,浏览组件树,对照 README 的模块说明建立全局印象

第一天能独立做到「拉下来 → 跑起来 → 说清项目结构」的工程师,就是团队眼里「上手快」的人。本阶段所有实战练习都以这个仓库(qingorder-portal)为载体。

🛠 渐进式实战练习

练习 1 · 基础(15 min):从零创建门户项目并完成首次热更新

任务描述:按 §1.5 步骤创建 qingorder-portal 项目,把 App.vue 的标题改为「轻订单 · 青云科技智能硬件」,再新建 BrandBadge.vue 组件并展示在页面上。

提示:参考 §1.8 的组件代码;注意 <script setup> 中导入组件后可直接使用。

验收标准:

  • [ ] 终端能看到 Vite 启动成功与本地访问地址;
  • [ ] 页面标题与徽标正常显示;
  • [ ] 修改标题保存后,页面更新且浏览器没有整页刷新(截图或录屏自证)。
参考答案(关键步骤)
pnpm create vite@latest qingorder-portal --template vue
cd qingorder-portal
pnpm install
pnpm run dev

src/App.vue 关键内容:

<script setup>
import BrandBadge from './components/BrandBadge.vue'
</script>

<template>
  <header>
    <BrandBadge />
    <h1>轻订单 · 青云科技智能硬件</h1>
  </header>
</template>

BrandBadge.vue 完整代码见 §1.8。

练习 2 · 进阶(30 min):目录改造与开发体验配置

任务描述:把脚手架示例清干净,建立「轻订单」的目录雏形,并完成两处工程配置:① index.html 的 <title> 与 <html lang="zh-CN">;② vite.config.js 中把 dev server 端口固定为 5173、自动打开浏览器,并配置 @ 别名指向 src。

提示:别名配置用 fileURLToPath(Node 内置 node:url);配置文件改动后要重启 dev server 才生效;使用别名:import App from '@/App.vue'。

验收标准:

  • [ ] 目录中存在 src/components/、src/styles/(放全局样式)、src/data/(放 mock 数据);
  • [ ] 页面标签页显示「轻订单 · 青云科技」;
  • [ ] 用 @/xxx 路径导入组件能正常工作;
  • [ ] 重启后访问 http://localhost:5173/ 自动打开浏览器。
参考答案(vite.config.js 与 main.js 改造)
// vite.config.js
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },
  server: {
    port: 5173,
    open: true,
  },
})
// src/main.js:入口改用 @ 别名
import { createApp } from 'vue'
import '@/styles/main.css'
import App from '@/App.vue'

createApp(App).mount('#app')

删除 src/components/HelloWorld.vue 与 src/assets/vue.svg;把原 style.css 移入 src/styles/main.css 并清空为一份空文件。

练习 3 · 挑战(选做,60 min):模拟「接手仓库第一天」并输出环境档案

任务描述:假设 qingorder-portal 是同事交接给你的仓库。完成 §1.10 的完整流程后,在项目根目录写一份 ONBOARDING.md(入职笔记,只给自己看),包含:① 启动本项目需要的环境版本(Node/pnpm 实测值);② 项目结构一句话说明(你自己的话,不许抄本教程);③ HMR 与整页刷新的区别(你的验证方式);④ 你在配置 @ 别名时踩的坑。

提示:node -v、pnpm -v 的输出直接贴进笔记;「踩坑」栏哪怕是「忘记重启 dev server 导致别名不生效」也值得记录——这是团队最看重的交接材料。

验收标准:

  • [ ] ONBOARDING.md 四个部分齐全;
  • [ ] 其中至少一条是你真实踩到并解决的问题;
  • [ ] 新环境照着这份笔记能在 10 分钟内跑起项目(可以找同学互相验证)。

⚠️ 常见易错点

易错点 1:Node 版本过老,Vite 直接起不来

# ❌ 错误现象:Node 16 下启动报语法不支持/依赖安装失败
# ERR_UNSUPPORTED_NODE_VERSION / crypto.hash is not a function
# ✅ 正确做法:用 nvm 切到 20 LTS 以上再装依赖
nvm install 20
nvm use 20
rm -rf node_modules && pnpm install

企业仓库通常在 package.json 的 engines 字段声明 Node 版本要求,先看它再动手。

易错点 2:pnpm 与 npm 混用,依赖「时有时无」

# ❌ 错误:同事用 pnpm,你顺手 npm install
# 结果:同时存在 pnpm-lock.yaml 与 package-lock.json,
# 后续安装可能装出一套不一致的依赖树,出现"我本地明明有这个包"
# ✅ 正确:跟随仓库锁定的包管理器;误装后清掉残留再重来
rm -rf node_modules package-lock.json
pnpm install

易错点 3:漏写 setup,写成普通 <script>,页面白屏

<!-- ❌ 错误:没有 setup 属性,这是 Options API 形态,
     顶层的 ref/导入组件不会自动暴露给模板,模板里全部 undefined -->
<script>
import { ref } from 'vue'
const productName = ref('轻订单')
</script>
<!-- ✅ 正确:<script setup>,顶层绑定自动可用于模板 -->
<script setup>
import { ref } from 'vue'
const productName = ref('轻订单')
</script>

现象是「变量明明定义了,模板却渲染不出来 + 控制台一堆 warning」——看到它先检查 setup。

易错点 4:改了 vite.config.js 以为热更新会生效

// ❌ 错误认知:加了 server.proxy / alias 后直接刷新页面,配置毫无反应
# ✅ 正确:Vite 的 HMR 只作用于源码(.vue/.js/.css);
# 配置文件的改动必须手动重启 dev server(Ctrl+C 后重新 pnpm run dev)

易错点 5:组件用单词命名,与原生 HTML 标签冲突

<!-- ❌ 错误:组件名叫 Badge。虽然现在能跑,
     但与未来原生自定义元素/其他库标签冲突风险大,风格指南直接禁止 -->
<Badge />
<!-- ✅ 正确:多词大驼峰命名(文件名与标签一致) -->
<BrandBadge />

✅ 最佳实践

  1. 环境版本写进文档:团队仓库的 README 必须写明 Node/pnpm 版本与启动命令,配合 engines 字段强制约束——新成员第一天不应该在装环境上浪费半天。
  2. 一个项目只用一种包管理器:在 .npmrc 或 CI 里卡死(如 engine-strict=true),杜绝 lockfile 混用造成的「薛定谔的依赖」。
  3. <script setup> 是唯一姿势:新代码一律 Composition API;读到 Options API 老代码时能看懂即可,不要在新代码里混用两种风格。
  4. 配置变更后重启:把「改配置 → 重启」养成肌肉记忆,能省掉大量「为什么没生效」的排查时间。
  5. 组件名多词大驼峰、文件名与组件名一致:BrandBadge.vue 里是 BrandBadge,检索、重命名、Devtools 里定位都受益。
  6. 目录分层提前想好:components/(组件)、styles/(样式)、data/(mock 数据)从第一天就分开,比事后大重构便宜十倍。

📌 本章小结

  • 前端框架的核心价值:把「状态 → 视图」的同步自动化,开发者声明「界面长什么样」,不再手动操作 DOM。
  • SPA 适合交互密集的中后台(轻订单二期),MPA/SSG 更适合重 SEO 的门户(轻订单一期可后置优化)。
  • 环境三件套:Node 20 LTS+、pnpm、Vite 6;pnpm create vite@latest --template vue 一键创建。
  • <script setup> 让顶层绑定与导入组件自动可用;SFC 一个文件装齐逻辑、视图、样式。
  • HMR 只替换变更模块且保留状态;配置文件改动必须重启 dev server。
  • Vue Devtools 是排查「数据没渲染」的第一工具:组件树 + 实时状态。
  • 下一章进入语言层:模板语法与数据绑定——用 v-for 渲染出你的第一份订单列表。

🔗 延伸阅读(官方文档,长期维护):Vue 3 指南 · 开始|Vite 指南|Vue Devtools

📄 第 02 章 · 模板语法与数据绑定 02-模板语法与数据绑定.md

📍 阶段:初级阶段(第 1–6 章)|⏱ 预计学时:4 小时|🔗 前置:第 01 章

⬅ 上一章:走进 Vue 3 | 📖 本版目录 | ➡ 下一章:组件化开发基础

💡 对照学习:本页是 Vue 版第 02 章。React 版对应章节为 第 02 章 · JSX 与渲染模型。概念映射:插值 {{ }} ≈ JSX 花括号 {};v-bind ≈ JSX 属性传递/className;v-on ≈ onXxx 事件 props;v-model ≈ 受控输入(value + onChange);v-if / v-show ≈ && 与三元表达式;v-for + key ≈ map() + key。


🎯 学习目标

学完本章,你将能够:

  1. 使用插值 {{ }} 输出文本与表达式,并说出模板表达式的边界(是表达式不是语句、全局白名单)。
  2. 使用 v-bind 绑定 HTML 属性与组件 props,熟练运用 : 简写与动态参数。
  3. 使用 v-on 绑定事件、传递参数,并说明 .stop / .prevent / .enter 等常用修饰符的作用。
  4. 使用 v-model 完成文本框、下拉框、复选框的双向绑定,并解释它的语法糖本质。
  5. 选用 v-if 与 v-show 控制显隐,并说出两者在切换成本与初始成本上的取舍依据。
  6. 使用 v-for 渲染订单列表,正确绑定稳定 key,并描述 key 在虚拟 DOM diff 中的作用。
  7. 使用对象/数组语法绑定 class 与 style,实现订单状态标签的动态配色。
  8. 完成「轻订单」订单状态标签列表与筛选栏的渲染(本章渐进式实战)。

📖 核心知识点

2.1 插值与表达式

双花括号插值把数据「贴」进模板,里面是表达式而不是语句:

<script setup>
import { ref } from 'vue'

const orderNo = ref('QO20260929000001')
const totalAmount = ref(128000) // 单位:分(附录 A 约定,避免浮点误差)
const ownerName = ref('林小满')
</script>

<template>
  <!-- ✅ 表达式:有值的部分代码 -->
  <p>订单号:{{ orderNo }}</p>
  <p>归属销售:{{ ownerName.toUpperCase() }}</p>
  <p>订单金额:¥{{ (totalAmount / 100).toFixed(2) }}</p>
  <p>三元判断:{{ totalAmount > 100000 ? '大额订单' : '普通订单' }}</p>

  <!-- ❌ 下面这些是"语句",模板里不允许,编译直接报错:
       {{ if (totalAmount > 0) return '有效' }}
       {{ const x = 1 }}
  -->
</template>

模板表达式在一个沙箱里求值:只能访问组件自身的状态和一份全局白名单(如 Math、Date),不能访问 window、console 这类对象。复杂逻辑应该搬进 <script setup>,而不是在模板里写长表达式——可读性是硬指标。

2.2 v-bind:属性绑定

插值只能用于文本内容;HTML 属性要用 v-bind(简写 :):

<script setup>
import { ref } from 'vue'

const logoUrl = ref('/images/logo.svg') // public 目录资源
const brandLink = ref('https://qingyun.example.com')
const isVip = ref(true)
const attrName = ref('title') // 动态参数:属性名本身也可以是变量
</script>

<template>
  <img :src="logoUrl" alt="青云科技 Logo" />
  <a :href="brandLink">了解青云科技</a>

  <!-- 绑定布尔属性:isVip 为 false 时 disabled 从 DOM 上移除 -->
  <button :disabled="!isVip">VIP 专属客服</button>

  <!-- 动态参数:等于 :title="hint" -->
  <span :[attrName]="'鼠标悬停查看提示'">订单号</span>

  <!-- 同名简写(Vue 3.4+)::src 等价于 :src="src" -->
  <!-- <img :src /> -->
</template>

2.3 v-on:事件绑定与修饰符

v-on(简写 @)绑定事件。内联语句适合一行逻辑,复杂逻辑写成函数:

<script setup>
import { ref } from 'vue'

const keyword = ref('')

function onSearch() {
  console.log('搜索:', keyword.value)
}

function onReset(event) {
  // 通过 $event(内联)或形参(函数引用)都能拿到原生事件对象
  console.log('重置按钮', event)
}
</script>

<template>
  <input v-model="keyword" placeholder="输入订单号/客户名" />
  <!-- 函数引用:事件对象自动作为第一个参数 -->
  <button @click="onSearch">搜索</button>
  <!-- 内联调用:用 $event 显式传递事件对象 -->
  <button @click="onReset($event)">重置</button>
  <!-- 内联多语句:仅限简单场景 -->
  <button @click="keyword = ''; onSearch()">清空并搜索</button>

  <!-- 按键与鼠标修饰符:替代手写 event.keyCode / stopPropagation -->
  <input @keyup.enter="onSearch" placeholder="回车搜索" />
  <div @click.stop="console.log('阻止冒泡')">卡片内按钮区</div>
  <form @submit.prevent="onSearch">搜索表单</form>
  <!-- 修饰符可链式:@click.stop.prevent -->
</template>

2.4 v-model:表单双向绑定

v-model 让表单输入与状态保持同步。它不是魔法,而是语法糖——在原生 <input> 上等价于:

<script setup>
import { ref } from 'vue'

const customerName = ref('杭州云梯科技有限公司')
</script>

<template>
  <!-- 这两行完全等价:v-model ≈ :value + @input -->
  <!-- <input :value="customerName" @input="customerName = $event.target.value" /> -->
  <input v-model="customerName" />
  <p>当前值:{{ customerName }}</p>
</template>

不同表单元素的行为差异要记住:

<script setup>
import { ref } from 'vue'

const status = ref('all') // 下拉框:绑定字符串
const channels = ref(['direct']) // 复选框组:绑定数组
const agreed = ref(false) // 单个复选框:绑定布尔
const pageSize = ref('20') // 转数字可以用修饰符
</script>

<template>
  <select v-model="status">
    <option value="all">全部状态</option>
    <option value="pending_review">待审核</option>
    <option value="approved">已审核</option>
  </select>

  <label><input type="checkbox" value="direct" v-model="channels" /> 直销</label>
  <label><input type="checkbox" value="agent" v-model="channels" /> 代理商</label>

  <label><input type="checkbox" v-model="agreed" /> 我已确认导出数据仅用于对账</label>

  <select v-model.number="pageSize">
    <option value="20">20 条/页</option>
    <option value="50">50 条/页</option>
  </select>
</template>

三个修饰符:.lazy(改用 change 事件,失焦才同步)、.number(转数字,无法解析时保留原字符串,不会报错)、.trim(去首尾空格)。

注意:v-model 也能用在自定义组件上(封装带状态的表单控件),那是第 05 章的主题。

2.5 条件渲染:v-if 与 v-show

<script setup>
import { ref } from 'vue'

const orderStatus = ref('rejected') // 订单状态机见附录 A §3.3
const showCancelReason = ref(false)
</script>

<template>
  <!-- v-if:条件块真正地创建/销毁;可接 v-else-if / v-else -->
  <p v-if="orderStatus === 'draft'">草稿:可自由编辑明细</p>
  <p v-else-if="orderStatus === 'pending_review'">待财务审核(折扣超过 10%)</p>
  <p v-else-if="orderStatus === 'rejected'">审核被拒绝,请修改后重新提交</p>
  <p v-else>订单流转中</p>

  <!-- v-show:元素永远渲染,只是切换 display 样式 -->
  <textarea v-show="showCancelReason" placeholder="请填写取消原因(必填)" />

  <button @click="showCancelReason = !showCancelReason">
    {{ showCancelReason ? '收起取消原因' : '取消订单' }}
  </button>

  <!-- v-if 可以用于 <template> 包装多个元素;v-show 不行(它需要真实元素) -->
  <template v-if="orderStatus === 'cancelled'">
    <p>订单已取消</p>
    <p>可在详情页查看取消原因</p>
  </template>
</template>

选型口诀:切换频繁用 v-show(省去反复创建销毁),条件很少为真或包含重组件用 v-if(初始渲染更省)。v-if 与 v-show 同时用一个元素没有意义,二选一。

2.6 列表渲染:v-for 与 key

<script setup>
import { ref } from 'vue'

// mock 数据,形状对齐附录 A 的 Order 实体
const orders = ref([
  { id: '1001', orderNo: 'QO20260928000001', customerName: '杭州云梯科技', status: 'approved', totalAmount: 128000 },
  { id: '1002', orderNo: 'QO20260928000002', customerName: '苏州芯动半导体', status: 'pending_review', totalAmount: 99000 },
  { id: '1003', orderNo: 'QO20260929000001', customerName: '广州华仪仪器', status: 'draft', totalAmount: 45600 },
])

// v-for 也能遍历对象(值, 键, 索引)与数字(第 n 页页码)
const pageNumbers = ref(3)
</script>

<template>
  <ul>
    <!-- key 必须是列表内稳定的唯一 id,优先业务主键 -->
    <li v-for="order in orders" :key="order.id">
      {{ order.orderNo }} — {{ order.customerName }} — ¥{{ (order.totalAmount / 100).toFixed(2) }}
    </li>
  </ul>

  <p v-for="(page, index) in pageNumbers" :key="page">第 {{ page }} 页,数组下标 {{ index }}</p>

  <!-- 空列表时给空态,永远不要渲染一片空白 -->
  <p v-if="orders.length === 0">暂无订单</p>
</template>

key 的作用与 diff 直觉:当数据变化时,Vue 用虚拟 DOM diff 计算最小更新。key 告诉 diff 算法「这个节点是谁」:

旧列表 [A, B, C]      新列表 [X, B, C]

有稳定 key:key 命中匹配 → 复用 B、C 的 DOM,只新建 X、移除 A(精准、省)
无 key / 用 index:从头逐位对比 patch → 本应"移动"的节点被"就地改内容",
                   绑定输入框等"带内部状态"的元素会出现内容错位

用 index 作 key 在「只在尾部追加」的静态列表里碰巧不出错,但列表一旦发生插入、删除、排序就会踩坑(见易错点 2)。结论先行:永远用业务唯一 id 作 key。

2.7 class 与 style 绑定

class 与 style 是 v-bind 的特例:对象语法按条件开关类名,数组语法组合多组类名:

<script setup>
import { ref, computed } from 'vue'

const order = ref({ id: '1002', status: 'pending_review', amount: 99000 })
const isActive = ref(true)

const cardClass = computed(() => ({
  'order-card': true,
  'order-card--active': isActive.value,
}))
</script>

<template>
  <!-- 对象语法:键是类名,值是布尔 -->
  <div class="order-card" :class="{ 'order-card--active': isActive }">卡片</div>

  <!-- 数组语法:多组条件合并 -->
  <div :class="['order-card', isActive ? 'is-active' : '', order.status]">卡片</div>

  <!-- 复杂条件收敛到 computed,模板保持一眼可读 -->
  <div :class="cardClass">卡片</div>

  <!-- style 绑定:动态像素值等场景 -->
  <div :style="{ width: order.amount / 1000 + 'px' }">金额进度条</div>
</template>

2.8 企业实战基础件:状态元信息表

订单状态会出现在列表、详情、报表、弹窗等十几处。用一张映射表收敛「状态 → 文案/颜色」的映射,而不是到处写三元表达式——这是本章最重要的工程习惯:

// src/data/orderStatus.js
// 附录 A §3.3 的七种订单状态:文案与主题色统一收敛
export const ORDER_STATUS_META = {
  draft: { label: '草稿', type: 'info' },
  pending_review: { label: '待审核', type: 'warning' },
  approved: { label: '已审核', type: 'primary' },
  shipped: { label: '已发货', type: 'primary' },
  completed: { label: '已完成', type: 'success' },
  rejected: { label: '已拒绝', type: 'danger' },
  cancelled: { label: '已取消', type: 'info' },
}

export const ORDER_STATUS_OPTIONS = Object.entries(ORDER_STATUS_META).map(
  ([value, meta]) => ({ value, label: meta.label })
)
<script setup>
import { ref, computed } from 'vue'
import { ORDER_STATUS_META, ORDER_STATUS_OPTIONS } from '@/data/orderStatus'

const keyword = ref('')
const status = ref('all')

const orders = ref([
  { id: '1001', orderNo: 'QO20260928000001', customerName: '杭州云梯科技', status: 'approved' },
  { id: '1002', orderNo: 'QO20260928000002', customerName: '苏州芯动半导体', status: 'pending_review' },
  { id: '1003', orderNo: 'QO20260929000001', customerName: '广州华仪仪器', status: 'draft' },
])

// 筛选逻辑放 computed:只依赖状态,不碰 DOM(computed 详见第 04 章)
const filteredOrders = computed(() =>
  orders.value.filter((order) => {
    const kw = keyword.value.trim().toLowerCase()
    const matchKeyword =
      !kw ||
      order.orderNo.toLowerCase().includes(kw) ||
      order.customerName.toLowerCase().includes(kw)
    const matchStatus = status.value === 'all' || order.status === status.value
    return matchKeyword && matchStatus
  })
)
</script>

<template>
  <!-- 筛选栏:三种 v-model 形态一次看全 -->
  <div class="order-filter">
    <input v-model.trim="keyword" placeholder="订单号 / 客户名" @keyup.enter="() => {}" />
    <select v-model="status">
      <option value="all">全部状态</option>
      <option v-for="opt in ORDER_STATUS_OPTIONS" :key="opt.value" :value="opt.value">
        {{ opt.label }}
      </option>
    </select>
  </div>

  <!-- 列表 + 状态标签:文案/配色全部来自映射表 -->
  <ul class="order-list">
    <li v-for="order in filteredOrders" :key="order.id">
      <span class="order-no">{{ order.orderNo }}</span>
      <span>{{ order.customerName }}</span>
      <span class="tag" :class="`tag--${ORDER_STATUS_META[order.status].type}`">
        {{ ORDER_STATUS_META[order.status].label }}
      </span>
    </li>
  </ul>
  <p v-if="filteredOrders.length === 0">没有符合条件的订单</p>
</template>

<style scoped>
.tag--info { background: #f3f4f6; color: #4b5563; }
.tag--warning { background: #fef3c7; color: #b45309; }
.tag--primary { background: #dbeafe; color: #1d4ed8; }
.tag--success { background: #dcfce7; color: #15803d; }
.tag--danger { background: #fee2e2; color: #b91c1c; }
</style>

🛠 渐进式实战练习

练习 1 · 基础(15 min):渲染状态标签列表

任务描述:在 qingorder-portal 中新建 src/data/orderStatus.js(映射表照抄 §2.8),再新建 StatusList.vue:用 v-for 渲染 7 种状态标签,标签文案与配色类名都从映射表读取。

提示::class 用模板字符串拼 tag--${type};v-for 的 key 用状态码本身(枚举值天然唯一)。

验收标准:

  • [ ] 7 个标签全部渲染,文案与颜色各不相同;
  • [ ] 浏览器元素面板里每个标签的 class 形如 tag tag--warning;
  • [ ] 代码里没有任何硬编码的「草稿/待审核」文案散落在模板中。
参考答案(StatusList.vue)
<script setup>
import { ORDER_STATUS_META } from '@/data/orderStatus'
</script>

<template>
  <ul class="status-list">
    <li v-for="(meta, code) in ORDER_STATUS_META" :key="code" class="tag" :class="`tag--${meta.type}`">
      {{ meta.label }}
    </li>
  </ul>
</template>

<style scoped>
.status-list { display: flex; gap: 8px; list-style: none; padding: 0; }
.tag { padding: 2px 10px; border-radius: 999px; font-size: 13px; }
.tag--info { background: #f3f4f6; color: #4b5563; }
.tag--warning { background: #fef3c7; color: #b45309; }
.tag--primary { background: #dbeafe; color: #1d4ed8; }
.tag--success { background: #dcfce7; color: #15803d; }
.tag--danger { background: #fee2e2; color: #b91c1c; }
</style>

练习 2 · 进阶(40 min):订单筛选栏

任务描述:实现 §2.8 的完整筛选栏页面 OrderFilterDemo.vue:关键词输入(回车或输入即筛)、状态下拉、结果列表、空态提示。要求筛选逻辑写在 computed 里。

提示:关键词匹配用 includes 并统一转小写;状态下拉的选项来自 ORDER_STATUS_OPTIONS。

验收标准:

  • [ ] 输入 QO20260928 能筛出前两条,输入「云梯」能筛出第一条;
  • [ ] 切换状态为「待审核」只剩第二条;
  • [ ] 无匹配时显示空态文案,列表不渲染空 <ul>;
  • [ ] 模板中除 :class 拼接外没有超过一行的表达式。
参考答案(核心 computed)
const filteredOrders = computed(() =>
  orders.value.filter((order) => {
    const kw = keyword.value.trim().toLowerCase()
    const matchKeyword =
      !kw ||
      order.orderNo.toLowerCase().includes(kw) ||
      order.customerName.toLowerCase().includes(kw)
    const matchStatus = status.value === 'all' || order.status === status.value
    return matchKeyword && matchStatus
  })
)

模板部分照 §2.8;空态用 v-if="filteredOrders.length === 0" 控制。

练习 3 · 挑战(选做,60 min):可排序订单表格 + key 错位实验

任务描述:① 把列表升级为表格,点击表头「创建时间 / 金额」切换升降序(再次点击反向);② 做一个反证实验:给「金额」列放一个 <input>(填备注),分别用 order.id 与数组下标作 key,对列表按金额排序后观察输入框内容是否串位,把现象与结论写进代码注释。

提示:排序用展开拷贝后 sort([...orders].sort(...)),不要原地排序被 computed 依赖的源数组;实验结论对应 §2.6 的 diff 直觉。

验收标准:

  • [ ] 表头可切换升/降序,箭头方向随状态变化;
  • [ ] 用 order.id 作 key 时排序后输入框内容跟随行移动;
  • [ ] 用 index 作 key 时能复现内容串位,并写下原因注释;
  • [ ] 源数组未被原地修改(排序前后 orders 引用内容不变)。
参考答案(排序 computed + 实验注释)
const sortKey = ref('createdAt') // 'createdAt' | 'amount'
const sortOrder = ref('desc') // 'asc' | 'desc'

function toggleSort(key) {
  if (sortKey.value === key) {
    sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
  } else {
    sortKey.value = key
    sortOrder.value = 'desc'
  }
}

const sortedOrders = computed(() => {
  const list = [...orders.value] // 拷贝后再排,不污染源数据
  list.sort((a, b) => (a[sortKey.value] - b[sortKey.value]) * (sortOrder.value === 'asc' ? 1 : -1))
  return list
})

// 实验结论:用 index 作 key 时,排序只改变了数组顺序,
// diff 会把"第 0 个"仍当作同一节点就地更新内容,
// 于是 input 里用户输入的备注留在了原位置(视觉上串行);
// 用 order.id 作 key 时,diff 按 key 移动节点,输入状态跟着行走。

⚠️ 常见易错点

易错点 1:v-if 与 v-for 写在同一个元素上

<!-- ❌ 错误:Vue 3 中 v-if 的优先级比 v-for 高,
     求值 v-if 时 order 变量还不存在,直接报错 -->
<li v-for="order in orders" v-if="order.status !== 'cancelled'" :key="order.id">
  {{ order.orderNo }}
</li>
<!-- ✅ 正确:用 computed 先过滤;或多元素时用 <template v-for> 包裹 -->
<li v-for="order in activeOrders" :key="order.id">{{ order.orderNo }}</li>
const activeOrders = computed(() => orders.value.filter((o) => o.status !== 'cancelled'))

官方风格指南将此列为「避免」级规则:两者永远不要同标签。

易错点 2:用数组下标当 key,增删/排序后状态串位

<!-- ❌ 错误:index 作 key + 列表会插入/删除/排序 -->
<tr v-for="(order, index) in orders" :key="index">...</tr>
<!-- ✅ 正确:业务唯一 id 作 key -->
<tr v-for="order in orders" :key="order.id">...</tr>

现象见练习 3 的实验;记住结论:key 是「节点的身份证」,不是「序号」。

易错点 3:v-show 用在 template 上

<!-- ❌ 错误:<template> 不是真实元素,v-show 靠 display 样式起作用,
     编译报错:v-show 无法使用 -->
<template v-show="showTip"><p>提示</p></template>
<!-- ✅ 正确:显隐切换用 v-if;或把 v-show 挪到真实元素上 -->
<template v-if="showTip"><p>提示</p></template>

易错点 4:v-model 绑定计算属性却没有写 setter

<!-- ❌ 错误:fullName 是只读 computed,运行时警告:
     Write operation failed: computed value is readonly -->
<input v-model="fullName" />
<!-- ✅ 正确:提供 setter 的可写 computed -->
<script setup>
import { computed, ref } from 'vue'
const firstName = ref('小满')
const lastName = ref('林')
const fullName = computed({
  get: () => lastName.value + firstName.value,
  set: (val) => {
    lastName.value = val.slice(0, 1)
    firstName.value = val.slice(1)
  },
})
</script>
<template><input v-model="fullName" /></template>

易错点 5:v-for 中修改被遍历的数组,触发无限重渲染

<!-- ❌ 错误:模板里 push 源数组,渲染期间改状态,
     控制台警告 Maximum recursive updates exceeded -->
<ul><li v-for="order in pushMore(orders)" :key="order.id">{{ order.orderNo }}</li></ul>
// ❌ 对应的错误函数:渲染函数里写副作用
function pushMore(list) {
  list.push({ id: 'x', orderNo: 'QO-X' }) // 每次渲染都 push → 再次渲染 → 死循环
  return list
}
<!-- ✅ 正确:v-for 只负责渲染;数据加工全部交给 computed -->
<li v-for="order in displayOrders" :key="order.id">{{ order.orderNo }}</li>

✅ 最佳实践

  1. 魔法值收敛为映射表:状态文案、颜色、选项统一放 data/ 目录的常量文件,新增状态只改一处(本章 §2.8 模式,后续章节反复复用)。
  2. 筛选/排序/格式化全部进 computed:模板只做「贴数据」,一眼能看出页面结构;computed 的缓存特性还避免重复计算(第 04 章展开)。
  3. key 永远用业务 id:下标 key 只在「纯展示且永不重排」时可用——而列表需求总会变,别赌。
  4. 显隐选型看频率:高频切换(tab、tooltip)用 v-show;低频且重组件(弹窗、详情块)用 v-if,让不显示的组件真正销毁、释放监听。
  5. 列表必配空态:v-if="list.length === 0" 渲染一句「暂无数据」,空 <ul> 是新手页面最常见的「看起来像坏了」。
  6. 金额展示统一「分转元 + toFixed(2)」:与附录 A 的「金额单位用分」约定配套;更严谨的舍入策略在第三部分测试章统一处理。

📌 本章小结

  • 插值贴文本、v-bind 绑属性、v-on 绑事件、v-model 绑表单——它的本质是 :value + @input 的语法糖。
  • 条件渲染:v-if 创建/销毁、v-show 切样式;v-if 不能与 v-for 同标签。
  • 列表渲染:v-for + 稳定业务 key;key 是 diff 的身份依据,index key 在重排时会造成状态串位。
  • class/style 用对象与数组语法声明式地表达「什么状态长什么样」。
  • 状态映射表(ORDER_STATUS_META)是本章交给你的第一个企业级资产,第 03 章会把状态标签抽成真正的组件。
  • 下一章进入组件化开发基础:把 StatusTag、OrderCard、SearchBar 逐一封装成可复用组件。

🔗 延伸阅读(官方文档,长期维护):模板语法|列表渲染|事件处理|风格指南 · 避免 v-if 与 v-for 同元素

📄 第 03 章 · 组件化开发基础 03-组件化开发基础.md

📍 阶段:初级阶段(第 1–6 章)|⏱ 预计学时:4 小时|🔗 前置:第 02 章

⬅ 上一章:模板语法与数据绑定 | 📖 本版目录 | ➡ 下一章:响应式系统核心

💡 对照学习:本页是 Vue 版第 03 章。React 版对应章节为 第 03 章 · 组件化开发基础。概念映射:props ≈ props(同样单向);emit 事件 ≈ 回调 props(onXxx);默认插槽 ≈ children;具名插槽 ≈ 具名 children 属性;作用域插槽 ≈ render props。


🎯 学习目标

学完本章,你将能够:

  1. 说出 SFC 三块(script/template/style)的职责与组件注册方式(<script setup> 自动注册)。
  2. 使用 defineProps 声明组件入参并做类型/必填/默认值约束,解释单向数据流为何不可破坏。
  3. 使用 defineEmits 声明并派发自定义事件,实现子 → 父通信,并按约定处理事件名大小写。
  4. 使用默认插槽与具名插槽组织可复用布局,并配置后备内容。
  5. 使用作用域插槽把子组件内部数据回传给父级定制渲染。
  6. 运用「单一职责、复用 vs 组合」原则拆分组件,为每个组件写出一句职责声明。
  7. 完成「轻订单」核心三件套组件:StatusTag、OrderCard、SearchBar。

📖 核心知识点

3.1 组件是什么,怎么注册

组件是带名字的、可复用的 UI 单元:把「状态 + 模板 + 样式」封装在一起,通过入参(props)接收数据、通过事件(emit)对外说话。

在 <script setup> 中,导入即注册——没有 components: { ... } 选项这回事:

<!-- src/views/OrderPage.vue -->
<script setup>
import StatusTag from '@/components/order/StatusTag.vue'
import OrderCard from '@/components/order/OrderCard.vue'

// 顶层导入的组件,模板里直接当标签使用,PascalCase 书写
</script>

<template>
  <StatusTag status="approved" />
  <OrderCard :order="demoOrder" />
</template>

组件文件按业务域分目录组织(而不是全部堆在 components/ 根下),这是项目超过 20 个组件后的救命习惯:

src/components/
├── common/          # 跨业务的通用组件(BaseButton、BasePagination…)
└── order/           # 订单域组件(StatusTag、OrderCard、SearchBar…)

3.2 defineProps:父 → 子的单向数据流

子组件通过 defineProps 声明它需要什么数据。运行时声明(JavaScript 版)可以写完整校验规则:

<!-- src/components/order/StatusTag.vue -->
<script setup>
import { computed } from 'vue'
import { ORDER_STATUS_META } from '@/data/orderStatus'

// 运行时声明:类型、必填、默认值、自定义校验
const props = defineProps({
  status: {
    type: String,          // 构造函数;可传数组如 [String, Number]
    required: true,        // 必填:漏传时开发环境给出警告
    validator: (value) => Object.keys(ORDER_STATUS_META).includes(value),
  },
  size: {
    type: String,
    default: 'medium',     // 默认值
  },
})

const meta = computed(() => ORDER_STATUS_META[props.status])
</script>

<template>
  <span class="status-tag" :class="[`status-tag--${meta.type}`, `status-tag--${size}`]">
    {{ meta.label }}
  </span>
</template>

<style scoped>
.status-tag { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 13px; }
.status-tag--small { font-size: 12px; padding: 1px 8px; }
.status-tag--info { background: #f3f4f6; color: #4b5563; }
.status-tag--warning { background: #fef3c7; color: #b45309; }
.status-tag--primary { background: #dbeafe; color: #1d4ed8; }
.status-tag--success { background: #dcfce7; color: #15803d; }
.status-tag--danger { background: #fee2e2; color: #b91c1c; }
</style>

使用方(父组件)这样传:

<template>
  <StatusTag status="pending_review" />
  <StatusTag :status="order.status" size="small" />
</template>

单向数据流是组件世界的宪法:数据只能从父流向子;子组件不得修改 props。原因很直白——如果子组件悄悄改了,父组件毫不知情,两边对「数据现在是什么」的认知分裂,bug 将不可追踪。正确姿势见易错点 1:让数据所有者(父组件)改,子组件只发事件。

类型约束的另一种写法是纯类型声明 defineProps<{ status: OrderStatus; size?: 'small' | 'medium' }>(),需要 TypeScript,第三部分全面启用;JS 阶段用本节的运行时声明即可,两者编译后等价。

3.3 defineEmits:子 → 父的事件通信

子组件用 defineEmits 声明自己会「喊」出哪些事件,父组件用 @事件名 监听:

<!-- src/components/order/OrderCard.vue -->
<script setup>
import StatusTag from './StatusTag.vue'

const props = defineProps({
  order: { type: Object, required: true },
})

// 声明本组件会派发的所有事件(工具链据此校验,漏写有警告)
const emit = defineEmits(['view-detail', 'cancel-order'])

function onView() {
  // 第二个参数起,是传给监听方的"事件载荷"
  emit('view-detail', props.order.id)
}

function onCancel() {
  if (confirm(`确定取消订单 ${props.order.orderNo} 吗?`)) {
    emit('cancel-order', { id: props.order.id, reason: '用户主动取消' })
  }
}

function formatAmount(cents) {
  return `¥${(cents / 100).toFixed(2)}`
}
</script>

<template>
  <article class="order-card">
    <header>
      <span class="order-no">{{ order.orderNo }}</span>
      <StatusTag :status="order.status" />
    </header>
    <p class="customer">{{ order.customerName }}</p>
    <footer>
      <strong>{{ formatAmount(order.totalAmount) }}</strong>
      <div class="actions">
        <button @click="onView">查看详情</button>
        <button class="danger" @click="onCancel" :disabled="order.status === 'completed'">
          取消订单
        </button>
      </div>
    </footer>
  </article>
</template>

<style scoped>
.order-card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 14px 16px; }
.order-no { font-weight: 600; margin-right: 8px; }
.actions { display: flex; gap: 8px; }
.danger { color: #b91c1c; }
</style>

父组件监听——声明用 camelCase / 模板监听用 kebab-case 是官方推荐约定(两者可自动转换,且 kebab-case 监听在 DOM 内模板里依然可靠):

<script setup>
import { ref } from 'vue'
import OrderCard from '@/components/order/OrderCard.vue'

const orders = ref([/* mock 订单 */])

function handleViewDetail(orderId) {
  console.log('跳转订单详情:', orderId)
}

function handleCancelOrder({ id, reason }) {
  // 修改数据是父组件的职责——单向数据流闭环
  const target = orders.value.find((o) => o.id === id)
  if (target) target.status = 'cancelled'
  console.log('取消原因:', reason)
}
</script>

<template>
  <OrderCard
    v-for="order in orders"
    :key="order.id"
    :order="order"
    @view-detail="handleViewDetail"
    @cancel-order="handleCancelOrder"
  />
</template>

3.4 插槽:给父组件留的「定制口」

props 传数据,插槽传内容。默认插槽让父组件把一段模板塞进子组件的指定位置:

<!-- src/components/common/BaseCard.vue:可复用卡片容器 -->
<script setup>
defineProps({ title: { type: String, default: '' } })
</script>

<template>
  <section class="base-card">
    <header class="base-card__header">
      <h3>{{ title }}</h3>
    </header>
    <!-- 默认插槽出口:父组件给的内容渲染在这里 -->
    <div class="base-card__body">
      <slot>默认内容:父组件没传内容时显示我(后备内容)</slot>
    </div>
  </section>
</template>

<style scoped>
.base-card { border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; }
.base-card__header { background: #f9fafb; padding: 10px 16px; }
.base-card__body { padding: 16px; }
</style>
<!-- 父组件使用:传数据用 props,传内容用插槽 -->
<template>
  <BaseCard title="今日待办">
    <ul>
      <li>3 笔订单待审核</li>
      <li>1 笔折扣申请待审批</li>
    </ul>
  </BaseCard>

  <!-- 没传内容 → 渲染后备内容 -->
  <BaseCard title="使用说明" />
</template>

需要多个「口」时用具名插槽,#name 是 v-slot:name 的简写:

<!-- 子组件:多个出口 -->
<template>
  <section class="page-block">
    <header><slot name="header">区块标题</slot></header>
    <main><slot /></main>
    <footer><slot name="footer" /></footer>
  </section>
</template>
<!-- 父组件:内容对号入座;未具名内容进默认插槽 -->
<template>
  <PageBlock>
    <template #header><strong>订单统计</strong></template>
    <p>本月 GMV:¥286,400</p>
    <template #footer><small>数据每小时更新</small></template>
  </PageBlock>
</template>

3.5 作用域插槽:子组件数据反向暴露

普通插槽的内容编译在父组件作用域,拿不到子组件的数据。当「列表长什么样」由父组件定制、「数据在哪」由子组件掌握时,用作用域插槽把子组件数据作为插槽参数传出去:

<!-- src/components/order/OrderTable.vue:通用订单表格 -->
<script setup>
defineProps({
  orders: { type: Array, required: true },
})
</script>

<template>
  <table class="order-table">
    <thead>
      <tr><th>订单号</th><th>客户</th><th>金额</th><th>自定义列</th></tr>
    </thead>
    <tbody>
      <tr v-for="(order, index) in orders" :key="order.id">
        <td>{{ order.orderNo }}</td>
        <td>{{ order.customerName }}</td>
        <td>¥{{ (order.totalAmount / 100).toFixed(2) }}</td>
        <!-- 把行数据与序号通过作用域插槽交给父组件 -->
        <td><slot name="extra" :order="order" :index="index" /></td>
      </tr>
    </tbody>
  </table>
</template>
<!-- 父组件:用 #extra="{ order }" 解构接收 -->
<template>
  <OrderTable :orders="orders">
    <template #extra="{ order }">
      <StatusTag :status="order.status" />
    </template>
  </OrderTable>
</template>

这一模式与 React 的 render props(<OrderTable renderExtra={(order) => ...} />)解决同一个问题:「数据归子组件,渲染权归父组件」。UI 组件库的表格列自定义(如 Element Plus 的 el-table-column)底层全是它。

3.6 组件拆分原则:单一职责、复用 vs 组合

原则 判断方法 反例
单一职责 能用一句话说清组件做什么;说清前出现「并且」就该拆 OrderPageAndFilterAndModal.vue
复用 vs 组合 「在 2+ 处出现且行为一致」才值得抽复用;只出现一次的区块先做成组合的普通组件 把只出现一次的 Banner 强行参数化出 8 个 props
数据归属 状态放在「最需要它的最小公共父级」,下传 props、上传事件 把输入框的临时文本提升到 App.vue
拆分粒度 先按「区块」拆(导航/列表/侧栏),再按「复用点」拆(按钮/标签) 一步到位拆出 40 个碎片组件

一个实用的职责声明格式(写进组件顶部注释,团队评审时逐条对):

<script setup>
/**
 * OrderCard · 订单卡片
 * 职责:展示单张订单摘要,并对外派发"查看详情 / 取消订单"意图。
 * 不负责:数据获取(由父级传入)、取消弹窗(由父级实现)。
 */

🛠 渐进式实战练习

练习 1 · 基础(15 min):封装 StatusTag 组件

任务描述:把第 02 章的 .tag 模板升级为独立组件 src/components/order/StatusTag.vue(§3.2 已给出完整代码):接收 status(必填、带 validator)与 size(默认 medium),内部从映射表取文案与配色。

提示:computed 里用 props.status(注意拿 props 要用变量接住 defineProps 的返回值);validator 直接复用映射表的键集合。

验收标准:

  • [ ] <StatusTag status="approved" /> 渲染出绿色「已审核」标签;
  • [ ] 传入非法状态(如 status="foo")时控制台出现警告;
  • [ ] size="small" 生效。
参考答案(validator 与计算属性)
import { computed } from 'vue'
import { ORDER_STATUS_META } from '@/data/orderStatus'

const props = defineProps({
  status: {
    type: String,
    required: true,
    validator: (value) => value in ORDER_STATUS_META,
  },
  size: { type: String, default: 'medium' },
})

const meta = computed(() => ORDER_STATUS_META[props.status])

模板与样式见 §3.2。

练习 2 · 进阶(40 min):OrderCard + 父级事件闭环

任务描述:实现 §3.3 的 OrderCard 组件,并在页面组件 OrderCardDemo.vue 中渲染 3 张 mock 订单卡:点击「查看详情」在页面上显示当前订单号;点击「取消订单」后把该订单状态改为 cancelled(映射表里有 cancelled,标签自动变灰)。

提示:状态修改必须发生在父组件(事件回调里),OrderCard 只 emit;「取消」前用 confirm 简单拦截即可。

验收标准:

  • [ ] 三张卡片颜色各异,与状态对应;
  • [ ] 点击「查看详情」页面文本随之更新(证明事件载荷传通了);
  • [ ] 取消后对应卡片标签变为「已取消」,且修改状态的代码不在 OrderCard 里。
参考答案(父组件关键代码)
<!-- src/views/OrderCardDemo.vue -->
<script setup>
import { ref } from 'vue'
import OrderCard from '@/components/order/OrderCard.vue'

const orders = ref([
  { id: '1001', orderNo: 'QO20260928000001', customerName: '杭州云梯科技', status: 'approved', totalAmount: 128000 },
  { id: '1002', orderNo: 'QO20260928000002', customerName: '苏州芯动半导体', status: 'pending_review', totalAmount: 99000 },
  { id: '1003', orderNo: 'QO20260929000001', customerName: '广州华仪仪器', status: 'draft', totalAmount: 45600 },
])

const viewedOrderNo = ref('')

function handleViewDetail(orderId) {
  viewedOrderNo.value = orders.value.find((o) => o.id === orderId)?.orderNo ?? ''
}

function handleCancelOrder({ id }) {
  const target = orders.value.find((o) => o.id === id)
  if (target) target.status = 'cancelled' // 数据所有者改数据
}
</script>

<template>
  <p>最近查看:<strong>{{ viewedOrderNo || '(无)' }}</strong></p>
  <OrderCard
    v-for="order in orders"
    :key="order.id"
    :order="order"
    @view-detail="handleViewDetail"
    @cancel-order="handleCancelOrder"
  />
</template>

练习 3 · 挑战(选做,60 min):SearchBar + BaseCard + 作用域插槽组合成页

任务描述:① 实现 SearchBar.vue:内部维护关键词状态,输入框回车或点击按钮时派发 search 事件(载荷为关键词),组件顶部写好「职责声明」注释;② 用 BaseCard(§3.4)包裹「订单概览」区块,头部具名插槽放标题,默认插槽放 OrderTable(§3.5)并通过作用域插槽自定义「操作列」(放一个 StatusTag);③ SearchBar 的关键词对表格数据做前端过滤。

提示:数据流方向自上而下——页面持有 orders 与 keyword,过滤用 computed;OrderTable 不需要知道搜索的存在。

验收标准:

  • [ ] 回车与按钮都能触发搜索,表格随关键词过滤;
  • [ ] BaseCard 的 header/footer 插槽各有内容;
  • [ ] 操作列的 StatusTag 来自父组件的作用域插槽模板,而不是写死在 OrderTable 里;
  • [ ] 三个组件各司其职:SearchBar 不改数据、OrderTable 不懂业务、页面负责编排。
参考答案(SearchBar.vue 与页面编排)
<!-- src/components/order/SearchBar.vue -->
<script setup>
/**
 * SearchBar · 订单搜索栏
 * 职责:收集关键词,回车/点击时派发 search 事件。
 * 不负责:执行搜索、管理订单数据。
 */
import { ref } from 'vue'

const emit = defineEmits(['search'])
const keyword = ref('')

function submit() {
  emit('search', keyword.value.trim())
}
</script>

<template>
  <div class="search-bar">
    <input v-model="keyword" placeholder="搜索订单号 / 客户名" @keyup.enter="submit" />
    <button @click="submit">搜索</button>
  </div>
</template>
<!-- src/views/OrderOverviewPage.vue -->
<script setup>
import { ref, computed } from 'vue'
import BaseCard from '@/components/common/BaseCard.vue'
import SearchBar from '@/components/order/SearchBar.vue'
import OrderTable from '@/components/order/OrderTable.vue'
import StatusTag from '@/components/order/StatusTag.vue'

const orders = ref([/* 同练习 2 的 mock */])
const keyword = ref('')

const visibleOrders = computed(() => {
  const kw = keyword.value.toLowerCase()
  return orders.value.filter(
    (o) => !kw || o.orderNo.toLowerCase().includes(kw) || o.customerName.toLowerCase().includes(kw)
  )
})
</script>

<template>
  <BaseCard title="订单概览">
    <template #header><strong>订单概览(前端过滤)</strong></template>
    <SearchBar @search="(kw) => (keyword = kw)" />
    <OrderTable :orders="visibleOrders">
      <template #extra="{ order }">
        <StatusTag :status="order.status" size="small" />
      </template>
    </OrderTable>
  </BaseCard>
</template>

⚠️ 常见易错点

易错点 1:在子组件里直接修改 props

// ❌ 错误:基础类型直接赋值——控制台警告,且父组件毫不知情
function archive() {
  props.order.status = 'completed' // 对象属性虽能改,但破坏单向数据流
}

// ❌ 更直白的:给 prop 本身赋值,直接报错
// props.order = newOrder
// ✅ 正确:子组件发事件,数据所有者(父组件)修改
const emit = defineEmits(['archive-order'])
function archive() {
  emit('archive-order', props.order.id)
}
// ✅ 或者:本地状态以 props 为"初始值"(仅当数据只属于子组件时)
const localKeyword = ref(props.initialKeyword)

易错点 2:对象/数组类型的 prop 用字面量写默认值

// ❌ 错误:每次实例化共享同一个数组/对象引用(潜在的跨实例污染)
defineProps({
  tags: { type: Array, default: [] },
  config: { type: Object, default: {} },
})
// ✅ 正确:引用类型默认值必须用工厂函数,每次返回新实例
defineProps({
  tags: { type: Array, default: () => [] },
  config: { type: Object, default: () => ({ pageSize: 20 }) },
})

易错点 3:忘记声明 emits,自定义事件被当成原生事件透传

<!-- ❌ 错误:子组件没有 defineEmits(['confirm']),
     emit('confirm') 会回退为原生事件透传到根元素 <button> 上,
     父组件监听 @confirm 时可能触发两次(透传的原生事件 + 预期行为) -->
<script setup>
function onOk() {
  // emit 未声明,行为不可控
  emit('confirm')
}
</script>
<template><button @click="onOk">确定</button></template>
// ✅ 正确:全量声明事件,透传行为被关闭,工具链还能校验拼写
const emit = defineEmits(['confirm'])
function onOk() {
  emit('confirm')
}

易错点 4:事件名大小写两边不一致,监听永远不触发

// ❌ 错误组合:emit('viewDetail') + 父组件里写 @viewdetail(DOM 内模板属性全小写)
// 或声明用 kebab-case 又在 JS 里 camelCase 派发——名字对不上,回调静默失效
// ✅ 正确:官方推荐——声明用 camelCase,模板监听用 kebab-case
const emit = defineEmits(['viewDetail'])
emit('viewDetail', id)
<!-- ✅ 模板里:<OrderCard @view-detail="handleViewDetail" /> -->

现象是「点了没反应、控制台也不报错」——先核对事件名两端的拼写。

易错点 5:在普通插槽内容里访问子组件数据

<!-- ❌ 错误:插槽内容编译在父组件作用域,
     order 是子组件 v-for 的变量,父组件根本看不到 -->
<OrderTable :orders="orders">
  <span>{{ order.status }}</span>
</OrderTable>
<!-- ✅ 正确:子组件用作用域插槽把 order 传出来,父组件解构接收 -->
<template>
  <tr v-for="order in orders" :key="order.id">
    <td><slot name="extra" :order="order" /></td>
  </tr>
</template>
<!-- 父组件 -->
<OrderTable :orders="orders">
  <template #extra="{ order }"><span>{{ order.status }}</span></template>
</OrderTable>

✅ 最佳实践

  1. props 只传数据,插槽传结构:当发现用 props 拼「这列显示什么、按钮文案是什么」超过 3 个开关时,改用(作用域)插槽。
  2. 每个组件顶部写职责声明注释:一句话职责 + 不负责什么,评审时 10 秒钟判断拆分是否合理。
  3. defineEmits 全量声明:既是文档也是校验,还能关掉事件透传的暗坑(易错点 3)。
  4. 事件名 = 过去式/动宾短语:search、cancel-order、update-status;载荷尽量带 id 或对象而不是「让父组件再去查一遍」。
  5. 组件按业务域分目录:components/order/、components/common/;common/ 里的组件禁止 import 业务模块(依赖只许自上而下)。
  6. 能组合就不配置:需要新外观时优先「换个父组件组合它」,而不是给老组件继续加 props——props 膨胀是组件腐烂的第一信号。

📌 本章小结

  • 组件 = 带名字的 UI 单元;<script setup> 导入即注册,无 components 选项。
  • 通信四件套:props 下行(单向数据流,子不改 props)、emit 上行、插槽传结构、作用域插槽传「数据 + 渲染权」。
  • 拆分三问:职责能一句话说清吗?复用点真实存在吗?数据该归谁?
  • 本章产出「轻订单」三件套:StatusTag(纯展示)、OrderCard(展示 + 意图事件)、SearchBar(受控输入 + 搜索事件),后续章节持续复用。
  • 下一章钻进响应式系统内部:ref/reactive 怎么选、解构为什么会「丢响应式」、computed 与 watch 的分工——把这三件套背后的数据流彻底讲透。

🔗 延伸阅读(官方文档,长期维护):组件基础|Props|事件|插槽

📄 第 04 章 · 响应式系统核心 04-响应式系统核心.md

📍 阶段:初级阶段(第 1–6 章)|⏱ 预计学时:4.5 小时|🔗 前置:第 03 章

⬅ 上一章:组件化开发基础 | 📖 本版目录 | ➡ 下一章:生命周期与组件通信

💡 对照学习:本页是 Vue 版第 04 章。React 版对应章节为 第 04 章 · Hooks 核心。概念映射:ref ≈ useState;reactive ≈ useState 持有对象(React 需不可变更新,Vue 是真正的响应式代理);computed ≈ useMemo;watch / watchEffect ≈ useEffect(显式依赖 / 自动依赖);「解构丢响应式」在 React 中不存在对应物,但心智负担对应「闭包过期」陷阱;自定义组合式函数 ≈ 自定义 Hook。


🎯 学习目标

学完本章,你将能够:

  1. 选用 ref 与 reactive,并说出各自的适用场景与解包规则(模板顶层解包、JS 中 .value、reactive 属性自动解包)。
  2. 排查并修复「解构后响应式丢失」问题,用 toRefs / toRef 给出正确写法。
  3. 使用 computed 派生数据并解释其缓存语义,遵守「computed 不写副作用」的纪律。
  4. 区分 watch 与 watchEffect 的依赖收集方式,并根据任务选对工具。
  5. 使用 watch 的 immediate、deep、回调清理(onCleanup)处理真实副作用场景。
  6. 封装一个返回响应式状态的组合式函数(composable),遵循 useXxx 命名约定。
  7. 完成「轻订单」订单筛选联动:关键词 + 状态 + 分页驱动请求参数(本章渐进式实战)。

📖 核心知识点

4.1 ref:最通用的响应式容器

ref() 把任意值包装成响应式引用,通过 .value 读写(JS 中),在模板顶层自动解包:

<script setup>
import { ref } from 'vue'

const pendingCount = ref(3)

function approveOne() {
  pendingCount.value-- // JS 中必须 .value;直接写 pendingCount-- 是改包装对象,视图不动
}
</script>

<template>
  <!-- 模板中顶层 ref 自动解包:写 pendingCount 而不是 pendingCount.value -->
  <p>待审核订单:{{ pendingCount }}</p>
  <button @click="approveOne">通过一单</button>
</template>

解包规则的三个边界(易错高发区,背下来):

import { ref, reactive } from 'vue'

const count = ref(1)

// 规则 1:reactive 对象的 ref 属性,访问时自动解包
const state = reactive({ count })
state.count++ // 不需要 .value,state.count 是数字
console.log(state.count) // 2

// 规则 2:普通对象/数组里的 ref 不解包
const plain = { count }
console.log(plain.count.value) // 是 ref 本身,要 .value

// 规则 3:reactive 数组元素里的 ref 不解包
const list = reactive([ref(1)])
console.log(list[0].value) // 同样是 ref

4.2 reactive:对象级响应式与其局限

reactive() 只接收对象(含数组/Map),返回 Proxy 代理,属性读写天然响应式、无需 .value:

<script setup>
import { reactive } from 'vue'

const orderForm = reactive({
  customerName: '',
  quantity: 1,
  unitPrice: 0, // 单位:分
})

// 深层响应式:嵌套对象同样被代理
orderForm.contact = { phone: '' } // 后加的属性也是响应式的(Proxy 特性)
orderForm.contact.phone = '138****0000'
</script>

<template>
  <input v-model="orderForm.customerName" placeholder="客户名称" />
</template>

但它有两条硬性局限,决定了选型边界:

import { reactive } from 'vue'

let state = reactive({ page: 1 })

// ❌ 局限 1:不能整体替换(对原始引用重新赋值,模板仍盯着旧代理)
state = reactive({ page: 2 }) // 视图不会跟着走

// ❌ 局限 2:解构即断链(下一节详解)
const { page } = state
page++ // 改的是普通变量副本,不是代理属性

4.3 选型:默认 ref,聚合状态用 reactive

场景 推荐 理由
单值状态(布尔、数字、字符串) ref 唯一选择,语义清晰
需要整体替换的对象 ref form.value = newForm 合法
一组强相关的表单字段 reactive form.name / form.price 写起来顺,无需 .value
组件间传值 / 组合式函数返回值 ref 解构安全(配合 toRefs)

一条企业级共识:不确定时用 ref。它没有整体替换的坑,组合式函数返回 ref(或 toRefs)对调用方最友好。

4.4 解构陷阱与 toRefs

响应式的本质是「对代理对象属性的读写会被追踪」。解构把属性值复制出来,从此与代理无关:

import { reactive, toRefs } from 'vue'

const filter = reactive({ keyword: '', status: 'all', page: 1 })

// ❌ 解构后是三个普通变量,改它们视图不会更新
const { keyword, status, page } = filter
keyword.value = 'QO' // 错上加错:keyword 是字符串,连 .value 都没有
// ✅ 正确一:不解构,始终通过 filter.xxx 访问
filter.keyword = 'QO'

// ✅ 正确二:toRefs 把每个属性转成 ref,解构后依然响应式
const { keyword, status, page } = toRefs(filter)
keyword.value = 'QO' // ref 语义,需要 .value

这个坑在「组合式函数返回 reactive 对象」时最容易爆发——所以团队约定:组合式函数一律返回 ref 或 toRefs(...) 的结果(见 §4.8)。

4.5 computed:缓存语义与副作用红线

computed 从现有状态派生新状态。它有两个关键行为:

  1. 缓存:依赖不变时重复读取直接返回上次结果,不重新计算;
  2. 自动追踪:你读了什么,它就依赖什么——没有依赖数组要维护。
<script setup>
import { ref, computed } from 'vue'

const orders = ref([
  { id: '1001', status: 'approved', totalAmount: 128000, createdAt: 1695859200000 },
  { id: '1002', status: 'pending_review', totalAmount: 99000, createdAt: 1695945600000 },
])

// 派生一:金额合计(分 → 元 展示)
const totalAmountYuan = computed(() =>
  (orders.value.reduce((sum, o) => sum + o.totalAmount, 0) / 100).toFixed(2)
)

// 派生二:待审核列表(依赖 orders,orders 变它才重算)
const pendingOrders = computed(() => orders.value.filter((o) => o.status === 'pending_review'))

// 派生三:可写 computed(带 setter),让 v-model 也能绑定派生值
const firstName = ref('小满')
const fullName = computed({
  get: () => `林${firstName.value}`,
  set: (val) => { firstName.value = val.slice(1) },
})
</script>

<template>
  <p>本月合计:¥{{ totalAmountYuan }}({{ orders.length }} 单)</p>
  <p>待审核:{{ pendingOrders.length }} 单</p>
</template>

红线:computed 的 getter 必须是纯计算——不发请求、不改其他状态、不写 DOM。需要「状态变化后做事」的场景,属于 watch 的职责(§4.6)。

4.6 watch:显式依赖的副作用

watch 侦听指定数据源,变化时执行回调。四大要点:源怎么写、immediate、deep、清理。

<script setup>
import { ref, reactive, watch } from 'vue'

const filter = reactive({ keyword: '', status: 'all', page: 1 })
const orders = ref([])
const resultMeta = ref({})

// 源写法一:ref 直接传
watch(orders, (newList, oldList) => {
  console.log('订单列表变化', newList.length)
})

// 源写法二:getter 函数(监听 reactive 的单个属性必须用它)
watch(
  () => filter.page,
  (page) => console.log('翻页到', page)
)

// 源写法三:数组多源 + options(immediate 立即跑一次;deep 深层遍历)
watch(
  () => [filter.keyword, filter.status],
  ([keyword, status]) => {
    console.log('筛选条件变化', keyword, status)
  },
  { immediate: true } // 组件初始化时先执行一次,省去"首屏手动请求"
)
</script>

清理:当监听回调里启动了异步任务,而数据在任务完成前又变了,需要用 onCleanup 作废上一次任务(防止旧结果覆盖新结果):

watch(
  () => filter.keyword,
  async (keyword, oldVal, onCleanup) => {
    const controller = new AbortController()
    onCleanup(() => controller.abort()) // 下次触发前先取消上一次请求

    const res = await fetch(`/api/v1/orders?keyword=${encodeURIComponent(keyword)}`, {
      signal: controller.signal,
    })
    resultMeta.value = await res.json()
  }
)

停止侦听:在 <script setup> 同步作用域里创建的 watcher 随组件卸载自动停止;在异步回调/定时器里创建的不会自动停,必须手动接住返回值调用:

const stop = watch(() => filter.page, onPageChange)
setTimeout(() => stop(), 10_000) // 非同步作用域创建的要手动停

4.7 watchEffect:立即执行 + 自动追踪

watchEffect 不声明依赖,回调首次立即执行,执行中「读到谁就依赖谁」:

import { watchEffect } from 'vue'

// 等价于上面"多源 + immediate"的写法,但依赖是自动收集的
const stopEffect = watchEffect(async (onCleanup) => {
  const kw = filter.keyword // 读到 → 依赖
  const status = filter.status // 读到 → 依赖
  const controller = new AbortController()
  onCleanup(() => controller.abort())
  const res = await fetch(`/api/v1/orders?keyword=${kw}&status=${status}`, {
    signal: controller.signal,
  })
  resultMeta.value = await res.json()
})

watch vs watchEffect 选型表:

维度 watch watchEffect
依赖 显式声明,一目了然 自动收集,首次执行读到谁算谁
首次执行 默认不执行(immediate: true 可选) 立即执行
能拿到旧值 能(回调第二参数) 不能
适用 「某个值变了,针对新值做一件事」 「一组状态决定一个副作用」

注意 watchEffect 的一个时序细节:依赖收集发生在首次同步执行阶段,await 之后的代码在首次执行时不参与依赖收集——所以「要被追踪的读取」应写在 await 之前。拿不准时用 watch 显式声明,更可读也更可控。

4.8 组合式函数(composable)入门

把「一组相关状态 + 逻辑」封装成 useXxx 函数,是 Vue 生态的复用单元(对应 React 的自定义 Hook):

// src/composables/useOrderStats.js
import { ref, computed } from 'vue'

/** 订单统计:入参是响应式源,返回值全部是 ref —— 调用方可安全解构 */
export function useOrderStats(orders) {
  const totalCount = computed(() => orders.value.length)

  const totalAmountYuan = computed(() =>
    (orders.value.reduce((sum, o) => sum + o.totalAmount, 0) / 100).toFixed(2)
  )

  const pendingCount = computed(() => orders.value.filter((o) => o.status === 'pending_review').length)

  return { totalCount, totalAmountYuan, pendingCount }
}
<!-- 任何组件里 -->
<script setup>
import { useOrderStats } from '@/composables/useOrderStats'

const { totalCount, totalAmountYuan, pendingCount } = useOrderStats(orders) // 直接解构,安全
</script>

约定:命名 use 开头;输入接 ref/getter,输出返回 ref;副作用(定时器/监听)必须在内部配套清理(第 05 章展开)。

4.9 企业场景:订单筛选联动

把本章知识串成「轻订单」真实需求(附录 A §3.3):关键词、状态、页码三个条件任意变化都应驱动一份请求参数,参数变化触发请求。这正是「多个源 → 一份派生参数 → 一个副作用」的标准形状:

// src/composables/useOrderFilters.js
import { ref, reactive, computed, watch, toRefs } from 'vue'

// 教学用 mock:形状对齐附录 A 的分页响应
function fetchOrders(params) {
  console.log('请求参数:', JSON.stringify(params))
  return Promise.resolve({
    list: [
      { id: '1001', orderNo: 'QO20260928000001', customerName: '杭州云梯科技', status: 'approved', totalAmount: 128000 },
      { id: '1002', orderNo: 'QO20260928000002', customerName: '苏州芯动半导体', status: 'pending_review', totalAmount: 99000 },
    ],
    total: 2,
    page: params.page,
    pageSize: params.pageSize,
  })
}

export const PAGE_SIZE = 20

export function useOrderFilters() {
  // 聚合筛选条件:一组强相关字段用 reactive
  const filter = reactive({ keyword: '', status: 'all', page: 1, pageSize: PAGE_SIZE })
  const orders = ref([])
  const total = ref(0)
  const loading = ref(false)

  // 派生一:请求参数(纯计算,含归一化)
  const queryParams = computed(() => ({
    keyword: filter.keyword.trim(),
    status: filter.status === 'all' ? undefined : filter.status,
    page: filter.page,
    pageSize: filter.pageSize,
  }))

  // 副作用:参数变化 → 重新请求(onCleanup 作废过期任务)
  let requestId = 0
  watch(
    queryParams,
    async (params) => {
      const currentId = ++requestId
      loading.value = true
      const res = await fetchOrders(params)
      // 竞态保护:只有"最后一次"发起的请求允许写入结果
      if (currentId !== requestId) return
      orders.value = res.list
      total.value = res.total
      loading.value = false
    },
    { immediate: true }
  )

  function resetFilter() {
    filter.keyword = ''
    filter.status = 'all'
    filter.page = 1 // 注意:条件变化通常要重置回第一页
  }

  // 对外只暴露 toRefs 后的解构安全视图
  return { ...toRefs(filter), orders, total, loading, resetFilter }
}
<!-- src/views/OrderListView.vue -->
<script setup>
import StatusTag from '@/components/order/StatusTag.vue'
import { useOrderFilters, PAGE_SIZE } from '@/composables/useOrderFilters'

const { keyword, status, page, orders, total, loading, resetFilter } = useOrderFilters()
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))

function prevPage() {
  if (page.value > 1) page.value--
}
function nextPage() {
  if (page.value < totalPages.value) page.value++
}
</script>

<template>
  <div class="toolbar">
    <!-- v-model 直接绑定解构出的 ref(模板自动解包) -->
    <input v-model="keyword" placeholder="订单号 / 客户名" />
    <select v-model="status">
      <option value="all">全部状态</option>
      <option value="pending_review">待审核</option>
      <option value="approved">已审核</option>
    </select>
    <button @click="resetFilter">重置</button>
    <span v-show="loading">加载中…</span>
  </div>

  <ul>
    <li v-for="order in orders" :key="order.id">
      {{ order.orderNo }} · {{ order.customerName }}
      <StatusTag :status="order.status" />
    </li>
  </ul>

  <p>
    第 {{ page }} / {{ totalPages }} 页,共 {{ total }} 条
    <button :disabled="page <= 1" @click="prevPage">上一页</button>
    <button :disabled="page >= totalPages" @click="nextPage">下一页</button>
  </p>
</template>

三个输入(关键词/状态/页码)→ 一份 computed 参数 → 一个带竞态保护的 watch 请求。第 09 章会把 fetchOrders 换成真正的 axios 封装,这层 composable 结构原封不动。

🛠 渐进式实战练习

练习 1 · 基础(15 min):明细行金额联动

任务描述:实现 OrderItemRow.vue:输入数量(1–9999)与单价(分),用 computed 实时算出行金额(元展示,两位小数);金额超过 10000 元时显示「大额订单」角标。

提示:数量用 v-model.number;computed 只算金额,「是否大额」再由一个 computed 派生——不要在两个 computed 里重复计算。

验收标准:

  • [ ] 数量或单价任一变化,金额与角标即时更新;
  • [ ] 数量为 0 或空时金额显示 ¥0.00,不出现 NaN;
  • [ ] 模板中没有金额计算表达式(全部收进 computed)。
参考答案
<script setup>
import { ref, computed } from 'vue'

const quantity = ref(1)
const unitPrice = ref(0) // 单位:分

const lineAmountYuan = computed(() => ((quantity.value * unitPrice.value) / 100).toFixed(2))
const isLarge = computed(() => quantity.value * unitPrice.value > 1_000_000) // 10000 元 = 100 万分
</script>

<template>
  <div class="item-row">
    <label>数量 <input v-model.number="quantity" type="number" min="1" max="9999" /></label>
    <label>单价(分) <input v-model.number="unitPrice" type="number" min="0" /></label>
    <output>行金额:¥{{ lineAmountYuan }}</output>
    <span v-if="isLarge" class="badge">大额订单</span>
  </div>
</template>

练习 2 · 进阶(40 min):useOrderFilters 落地

任务描述:把 §4.9 的 useOrderFilters 与 OrderListView.vue 完整实现到项目里,额外要求:① 筛选条件变化时自动把 page 重置为 1;② 连续快速输入关键词时,只有最后一次请求的结果被采用(requestId 竞态保护)。

提示:重置页码可以监听 () => [filter.keyword, filter.status] 单独写一个 watch;竞态保护用 §4.9 的自增 id 方案。

验收标准:

  • [ ] 改关键词/状态后列表刷新且页码回到 1;
  • [ ] 快速连续输入不出现「旧结果覆盖新结果」;
  • [ ] useOrderFilters 返回值解构后依然响应式(页码按钮能正常工作)。
参考答案(重置页码的 watch)
// 加在 useOrderFilters 内部
watch(
  () => [filter.keyword, filter.status],
  () => {
    filter.page = 1 // 条件变了,回到第一页
  }
)

竞态保护:watch(queryParams, ...) 回调里自增 requestId 并在写入前比对(§4.9 已给出完整代码)。

练习 3 · 挑战(选做,60 min):手写 useDebouncedRef

任务描述:封装 useDebouncedRef(sourceRef, delay):返回一个「防抖只读视图」——源 ref 连续变化时,视图值在停止变化 delay 毫秒后才更新。用它替换练习 2 的关键词过滤,让搜索输入 300ms 防抖后再触发请求。

提示:内部用 watch 监听源 + setTimeout;记得在每次触发前清掉上一个定时器,并在组件卸载时清理(onUnmounted,第 05 章会系统讲,这里先照写);返回 readonly 的 ref 视图。

验收标准:

  • [ ] 快速输入时控制台的「请求参数」日志每停顿 300ms 才出现一次;
  • [ ] 防抖视图与源值最终一致;
  • [ ] 组件卸载后 pending 的定时器不会再去写状态(无警告)。
参考答案(简化实现)
// src/composables/useDebouncedRef.js
import { ref, watch, readonly, onUnmounted } from 'vue'

/** 返回 source 的防抖只读视图 */
export function useDebouncedRef(source, delay = 300) {
  const debounced = ref(source.value)
  let timer = null

  const stopWatch = watch(source, (val) => {
    if (timer) clearTimeout(timer)
    timer = setTimeout(() => {
      debounced.value = val
    }, delay)
  })

  // 组件卸载时:停侦听 + 清定时器,防止卸载后写状态
  onUnmounted(() => {
    stopWatch()
    if (timer) clearTimeout(timer)
  })

  return readonly(debounced)
}
// 在 useOrderFilters 中替换请求依赖
const debouncedKeyword = useDebouncedRef(toRef(filter, 'keyword'), 300)

watch(
  () => [debouncedKeyword.value, filter.status],
  () => {
    filter.page = 1
  }
)

⚠️ 常见易错点

易错点 1:在 JS 里忘写 .value

// ❌ 错误:pendingCount 是 ref 包装对象,-- 操作的是包装对象,视图纹丝不动
const pendingCount = ref(3)
function approveOne() {
  pendingCount--
}
// ✅ 正确:JS 中读写 ref 必须 .value(模板里才自动解包)
function approveOne() {
  pendingCount.value--
}

现象是「代码执行了但页面没反应」——初级阶段响应式 bug 的一半都是它。

易错点 2:解构 reactive 丢失响应式

// ❌ 错误:解构出的是普通值副本,改它们不影响视图
const { keyword, status } = reactive({ keyword: '', status: 'all' })
keyword = 'QO'
// ✅ 正确一:不解构,始终 filter.keyword 访问
// ✅ 正确二:toRefs 保持链接
const { keyword, status } = toRefs(filter)
keyword.value = 'QO'

易错点 3:在 computed 里写副作用

// ❌ 错误:computed 里发请求 + 改状态。
// 副作用让缓存失效变得不可预测,还可能在渲染阶段触发请求(无限循环警告)
const orders = computed(async () => {
  const res = await fetchOrders(queryParams.value) // 副作用!
  return res.list
})
// ✅ 正确:computed 只做纯计算;"变了就发请求"交给 watch
const queryParams = computed(() => ({ keyword: keyword.value, page: page.value }))

watch(queryParams, fetchOrders, { immediate: true })

一句话分工:computed 负责「算」,watch 负责「做」。

易错点 4:watch 的源传错,拿到的是静态值

// ❌ 错误一:watch(ref.value) —— 立即求值,监听的是当时的数字,永远不再触发
watch(count.value, () => console.log('never fires'))

// ❌ 错误二:watch(reactiveObj.prop) —— 基础类型属性被拷贝,同样不触发
watch(filter.keyword, onKeywordChange)
// ✅ 正确:ref 直接传;reactive 的属性用 getter 函数包一层
watch(count, () => console.log('fires'))
watch(() => filter.keyword, onKeywordChange)

易错点 5:异步回调里创建的 watcher / 定时器不清理

// ❌ 错误:在 setTimeout 里创建的 watcher 不属于 setup 同步作用域,
// 组件卸载后它仍然活着,回调里改状态触发警告,造成内存泄漏
setTimeout(() => {
  watch(() => filter.keyword, onKeywordChange) // 永不自动停止
}, 1000)
// ✅ 正确:要么在 setup 同步作用域直接创建(自动随组件停止),
// 要么手动接住停止函数并在卸载前调用
const stop = watch(() => filter.keyword, onKeywordChange)
onUnmounted(() => stop())

✅ 最佳实践

  1. 默认 ref,聚合表单才 reactive:ref 没有整体替换与解构的心智负担,团队统一后 code review 少一半争论。
  2. 派生一律 computed,副作用一律 watch:分工写进团队规范,computed 里出现 await 直接打回。
  3. 请求类 watch 必配竞态保护与清理:requestId 自增或 AbortController + onCleanup,二选一,但必须有。
  4. 组合式函数「ref 进 ref 出」:入参接 ref/getter,返回值用 ref 或 toRefs,调用方解构永远安全。
  5. 筛选条件变化重置页码:这是所有后台列表的通用需求,写进列表 composable 而不是每个页面自己记。
  6. 金额计算收敛到一个工具函数:分↔元换算不要散落各处,第三部分会把它纳入单元测试(覆盖率 ≥ 80% 的核心模块之一,见附录 A §4)。

📌 本章小结

  • ref 万能但 JS 中要 .value;reactive 顺手但不能整体替换、不能解构——解构丢失用 toRefs 修复。
  • 解包规则三条:模板顶层自动解包;reactive 属性自动解包;普通对象与数组元素不解包。
  • computed 有缓存且自动追踪依赖,只做纯计算;watch 显式依赖可拿旧值,watchEffect 立即执行自动收集——「算」与「做」分离。
  • 副作用三件防护:immediate 首屏、onCleanup 清理、竞态 id 防旧覆盖新。
  • 本章产出了 useOrderStats 与 useOrderFilters 两个组合式函数——这是你在 Vue 里的第一份「逻辑复用资产」,第 09 章会无缝接入真实接口层。
  • 下一章补上时间维度:组件生命周期时序、副作用清理、provide/inject 跨层通信与 v-model 组件封装,让三件套组件进化成可复用的表格 + 分页 + 弹窗。

🔗 延伸阅读(官方文档,长期维护):响应式基础|计算属性|侦听器|组合式函数

📄 第 05 章 · 生命周期与组件通信 05-生命周期与组件通信.md

📍 阶段:初级阶段(第 1–6 章)|⏱ 预计学时:4.5 小时|🔗 前置:第 04 章

⬅ 上一章:响应式系统核心 | 📖 本版目录 | ➡ 下一章:初级实战 · 企业门户首页

💡 对照学习:本页是 Vue 版第 05 章。React 版对应章节为 第 05 章 · 副作用生命周期与组件通信。概念映射:onMounted ≈ useEffect(fn, [])(挂载后执行一次);onUnmounted / onCleanup ≈ useEffect 的清理函数;provide / inject ≈ Context;组件上的 v-model ≈ 受控组件(value + onChange);动态组件 ≈ 条件渲染 / 组合模式。


🎯 学习目标

学完本章,你将能够:

  1. 画出 Composition API 下的生命周期时序图(setup → mounted → updated → unmounted),并说出每个阶段「能做什么、不能做什么」。
  2. 使用 onMounted 发起首屏请求与获取 DOM 引用,说明它对应 React 的哪个钩子。
  3. 为定时器、事件监听、订阅类副作用配套清理逻辑(onUnmounted / onCleanup),杜绝内存泄漏与重复触发。
  4. 使用 provide / inject 实现跨层级通信,并说出它相对逐层传 props 的适用边界。
  5. 使用 defineModel 封装支持 v-model 的组件(含具名模型),解释它与「受控组件」的对应关系。
  6. 使用 <component :is> 实现动态组件切换,并了解递归组件渲染树形数据的原理。
  7. 完成「轻订单」可复用三件套:ConfirmDialog(v-model 封装)、BasePagination、CategoryTree(递归)。

📖 核心知识点

5.1 生命周期全景与时序

组件从「出生」到「销毁」的关键节点。<script setup> 的顶层代码本身就运行在「创建阶段」(等价于 Options API 的 setup),其余阶段通过钩子函数注册:

<script setup> 顶层代码          ← 创建阶段:定义状态、注册副作用(早于一切钩子)
        │
        ▼
onBeforeMount                   ← 挂载前:模板已编译,DOM 尚未生成(极少用)
        ▼
onMounted  ────────────────────  ← 已挂载:可访问 DOM、发首屏请求(最常用)
        │
        ▼ (响应式数据变化,触发重渲染)
onBeforeUpdate ──► onUpdated    ← 更新前 / 更新后(updated 内不要改状态,会死循环)
        │
        ▼ (v-if 移除 / 路由离开 / 父组件卸载)
onBeforeUnmount                 ← 卸载前:清理副作用的最后窗口
        ▼
onUnmounted                     ← 已卸载:实例不可再用
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'

const greeting = ref('')

// ✅ 首屏请求放 onMounted:此时组件已就位,失败也可被卸载逻辑兜底
onMounted(() => {
  greeting.value = '轻订单门户已就绪'
})

// 钩子必须在 setup 的同步作用域里注册(易错点 2),不能包在 setTimeout/回调里
</script>

<template>
  <p>{{ greeting }}</p>
</template>

与 React 的对应关系一句话说清:onMounted(fn) ≈ useEffect(fn, []);onUnmounted(fn) ≈ 该 useEffect 返回的清理函数。区别是 Vue 把「跑一次」和「清理」拆成两个钩子,语义更直白。

5.2 副作用清理:定时器与事件监听

凡是「注册了就会一直活着」的东西——setInterval、addEventListener、WebSocket 订阅——都必须在卸载前撤销,否则:内存泄漏 + 组件复用时副作用叠加 + 卸载后改状态报警告。

<!-- src/composables/useOrderPolling.js 的用法示例:Dashboard 每 10s 轮询待审核数 -->
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'

const pendingCount = ref(0)
let timer = null // 定时器句柄:非响应式,用普通变量即可

async function refresh() {
  // 教学用 mock:真实实现见第 09 章接口层
  pendingCount.value = Math.floor(Math.random() * 10)
}

onMounted(() => {
  refresh()
  timer = setInterval(refresh, 10_000)
  // 浏览器事件监听同理:
  window.addEventListener('resize', onResize)
})

function onResize() {
  console.log('窗口尺寸变化,表格列宽需要重算')
}

// ✅ 成对注册、成对清理——写 onMounted 的同时就写下 onUnmounted
onUnmounted(() => {
  clearInterval(timer)
  window.removeEventListener('resize', onResize)
})
</script>

<template>
  <p>待审核订单:{{ pendingCount }}</p>
</template>

watch 类副作用同样有清理需求,但它是「每次触发前」的清理(onCleanup,第 04 章 §4.6)与「组件卸载时」的清理(同步作用域创建则自动停止)两个层次,别混淆。

5.3 provide / inject:跨层通信

props 逐层下传(prop drilling)在层级变深后非常啰嗦:

App ──currentUser──► Layout ──currentUser──► OrderList ──currentUser──► OrderActions

provide / inject 让祖先直接「广播」,任意深度后代按需「接收」——对应 React 的 Context:

// src/keys/currentUser.js:用 Symbol 作为注入键,避免命名冲突
export const CurrentUserKey = Symbol('currentUser')
<!-- 祖先组件(如 App.vue):提供 -->
<script setup>
import { ref, provide } from 'vue'
import { CurrentUserKey } from '@/keys/currentUser'

const currentUser = ref({ name: '林小满', role: 'operator' }) // 角色见附录 A §2

// 只读包装:后代只能读,不能改(保持数据所有权在祖先)
provide(CurrentUserKey, readonly(currentUser))
</script>
<!-- 任意深度的后代:注入,带默认值兜底 -->
<script setup>
import { computed, inject } from 'vue'
import { CurrentUserKey } from '@/keys/currentUser'

// 第二参数是默认值(找不到提供者时使用),防止 undefined 引发的运行时错误
const currentUser = inject(CurrentUserKey, ref({ name: '游客', role: 'guest' }))

// 按钮级权限:删除订单仅超管/运营可见(附录 A §2)
const canDeleteOrder = computed(() =>
  ['super_admin', 'operator'].includes(currentUser.value.role)
)
</script>

<template>
  <button v-if="canDeleteOrder">删除订单</button>
</template>

适用边界:全局性、低频变化的数据(当前用户、主题、字典缓存)适合 provide/inject;组件 API 的一部分(列表行数据、表单字段)坚持走 props——滥用 inject 会让组件的依赖关系在类型与文档里「隐形」,可移植性暴跌。

5.4 v-model 组件封装:defineModel

第 02 章说过 v-model 是「:value + @input」的语法糖;用在组件上,就是「:modelValue + @update:modelValue」。Vue 3.4 起官方提供 defineModel 宏,一行声明双向绑定 prop:

<!-- src/components/common/ConfirmDialog.vue:确认弹窗 -->
<script setup>
/**
 * ConfirmDialog · 确认弹窗
 * 职责:受控地显示/隐藏确认弹窗,派发 confirm 意图。
 * 用法:<ConfirmDialog v-model:visible="show" title="删除订单" @confirm="doDelete" />
 */
const visible = defineModel('visible', { type: Boolean, default: false })

defineProps({
  title: { type: String, default: '确认操作' },
  message: { type: String, default: '' },
})

const emit = defineEmits(['confirm'])

function onConfirm() {
  emit('confirm')
  visible.value = false // defineModel 返回的是可写 ref,赋值会自动派发 update:visible
}
</script>

<template>
  <!-- v-if 控制挂载:关掉的弹窗真正销毁 -->
  <div v-if="visible" class="dialog-mask" @click.self="visible = false">
    <div class="dialog">
      <h3>{{ title }}</h3>
      <p>{{ message }}</p>
      <footer>
        <button @click="visible = false">取消</button>
        <button class="primary" @click="onConfirm">确定</button>
      </footer>
    </div>
  </div>
</template>

<style scoped>
.dialog-mask { position: fixed; inset: 0; background: rgb(0 0 0 / 40%); display: grid; place-items: center; }
.dialog { background: #fff; border-radius: 8px; padding: 20px; width: 360px; }
.primary { color: #1d4ed8; }
</style>
<!-- 父组件使用 -->
<script setup>
import { ref } from 'vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'

const showDelete = ref(false)
const targetOrder = ref(null)

function askDelete(order) {
  targetOrder.value = order
  showDelete.value = true
}

function doDelete() {
  console.log('删除订单:', targetOrder.value?.orderNo) // 真实删除见第二部分接口层
}
</script>

<template>
  <ConfirmDialog
    v-model:visible="showDelete"
    title="删除订单"
    :message="`确定删除订单 ${targetOrder?.orderNo ?? ''} 吗?该操作不可恢复。`"
    @confirm="doDelete"
  />
</template>

v-model:visible 是具名模型;不带名字的 v-model 对应默认模型(prop 名 modelValue)。一个组件可以同时有多个模型(如 v-model:page + v-model:pageSize)。与 React 受控组件对照:defineModel('visible') ≈ 接收 value prop + 内部调 onChange 的合体,只是 Vue 把这对约定标准化成了编译宏。

5.5 动态组件:<component :is>

在多个组件之间按状态切换时,<component :is> 比 v-if 链干净得多——订单详情页的「基本信息 / 操作日志」Tab 就是典型场景:

<script setup>
import { ref, shallowRef } from 'vue'
import OrderInfoPanel from '@/components/order/OrderInfoPanel.vue'
import OrderLogTimeline from '@/components/order/OrderLogTimeline.vue'

const activeTab = ref('info')
// 本地组件以"组件对象"传给 :is(非响应式包裹,shallowRef 即可)
const tabMap = {
  info: OrderInfoPanel,
  logs: OrderLogTimeline,
}
</script>

<template>
  <nav>
    <button :class="{ active: activeTab === 'info' }" @click="activeTab = 'info'">基本信息</button>
    <button :class="{ active: activeTab === 'logs' }" @click="activeTab = 'logs'">操作日志</button>
  </nav>

  <!-- 切换即卸载/挂载;keep-alive 可缓存已卸载实例(第三部分性能章详解) -->
  <component :is="tabMap[activeTab]" :order-id="orderId" />
</template>

:is 也可以接收字符串标签名(如 'section' 或全局注册的组件名),但本地导入的组件必须传组件对象。被切换的组件若不需要保活,默认行为就是「卸载 → 生命周期走 unmounted」。

5.6 递归组件:渲染树形数据

组件模板里通过自身文件名引用自己(SFC 隐式支持),即可渲染任意深度的树。商品分类树是「轻订单」商品目录(附录 A §3.5)里的真实需求:

<!-- src/components/product/CategoryTree.vue -->
<script setup>
import { ref } from 'vue'

defineProps({
  node: { type: Object, required: true }, // { id, name, children: [] }
  depth: { type: Number, default: 0 },
})

const expanded = ref(true)
</script>

<template>
  <div class="category-node" :style="{ paddingLeft: depth * 16 + 'px' }">
    <button
      v-if="node.children?.length"
      class="toggle"
      @click="expanded = !expanded"
      :aria-expanded="expanded"
    >
      {{ expanded ? '−' : '+' }}
    </button>
    <span>{{ node.name }}</span>

    <!-- 递归出口:叶子节点(无 children)自然终止 -->
    <CategoryTree
      v-for="child in expanded ? node.children : []"
      :key="child.id"
      :node="child"
      :depth="depth + 1"
    />
  </div>
</template>
<!-- 使用方:传入树形 mock 数据 -->
<script setup>
import CategoryTree from '@/components/product/CategoryTree.vue'

const categoryTree = {
  id: 'c0',
  name: '全部商品',
  children: [
    { id: 'c1', name: '智能传感器', children: [{ id: 'c11', name: '温湿度传感器', children: [] }] },
    { id: 'c2', name: '网关设备', children: [] },
  ],
}
</script>

<template>
  <CategoryTree :node="categoryTree" />
</template>

递归组件三要素:递归调用自身、递归参数(depth / 子节点)、递归出口(无 children 时不再展开)。忘记出口 = 无限循环直到栈溢出。

5.7 企业实战:可复用分页器

后台列表的标配。注意它是「受控 + 事件」双通道:page 走 v-model,翻页动作对外发 change 事件供父级埋点或刷新:

<!-- src/components/common/BasePagination.vue -->
<script setup>
/**
 * BasePagination · 通用分页器
 * 职责:展示页码与总数,双向同步当前页。
 * 不负责:数据请求、总数计算。
 */
// 注意:需要在 <script> 逻辑里访问 props 时,用变量接住 defineProps 的返回值
const props = defineProps({
  total: { type: Number, required: true },
  pageSize: { type: Number, default: 20 },
})

const page = defineModel('page', { type: Number, required: true })
const emit = defineEmits(['change'])

function go(target) {
  const totalPages = Math.max(1, Math.ceil(props.total / props.pageSize))
  const next = Math.min(Math.max(1, target), totalPages)
  if (next === page.value) return
  page.value = next
  emit('change', next)
}
</script>

<template>
  <div class="pagination">
    <span>共 {{ total }} 条</span>
    <button :disabled="page <= 1" @click="go(page - 1)">上一页</button>
    <span>第 {{ page }} / {{ Math.max(1, Math.ceil(total / pageSize)) }} 页</span>
    <button :disabled="page >= Math.ceil(total / pageSize)" @click="go(page + 1)">下一页</button>
  </div>
</template>

与第 04 章 useOrderFilters 组合:列表页把 page 交给 BasePagination 双向绑定,翻页 → filter.page 变化 → 请求参数 computed 变化 → watch 自动重新请求。这正是组件通信与响应式系统咬合运转的样子。

🛠 渐进式实战练习

练习 1 · 基础(20 min):ConfirmDialog 落地「删除订单」

任务描述:把 §5.4 的 ConfirmDialog 实现进项目,并在订单列表页接入:点击订单卡「删除」打开弹窗,确定后从列表中移除该订单。

提示:defineModel('visible') 的赋值会自动派发 update:visible;删除逻辑(过滤数组)写在父组件。

验收标准:

  • [ ] 弹窗可以打开/关闭(点遮罩、点取消、点确定三种路径);
  • [ ] 确定后对应订单从列表消失;
  • [ ] 关闭弹窗后组件被销毁(Vue Devtools 组件树中消失)。
参考答案(父组件接入片段)
<script setup>
import { ref } from 'vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'

const orders = ref([/* mock 订单 */])
const showDelete = ref(false)
const targetOrder = ref(null)

function askDelete(order) {
  targetOrder.value = order
  showDelete.value = true
}

function doDelete() {
  orders.value = orders.value.filter((o) => o.id !== targetOrder.value.id)
}
</script>

<template>
  <ConfirmDialog
    v-model:visible="showDelete"
    title="删除订单"
    :message="`确定删除订单 ${targetOrder?.orderNo ?? ''} 吗?`"
    @confirm="doDelete"
  />
</template>

练习 2 · 进阶(40 min):BasePagination + 订单列表联动

任务描述:实现 §5.7 的 BasePagination(合并后的正确版本),接入 useOrderFilters:分页器显示真实 total,翻页自动触发重新请求;另给「每页条数」加一个下拉(v-model:pageSize,验证多模型)。

提示:pageSize 也用 defineModel 声明为具名模型;改 pageSize 后记得把 page 重置为 1(第 04 章的 watch 思路)。

验收标准:

  • [ ] 翻页时列表刷新且页码同步,边界按钮正确禁用;
  • [ ] 切换每页条数后回到第 1 页并重新请求;
  • [ ] BasePagination 内部没有任何请求代码(纯受控组件)。
参考答案(列表页接入)
<script setup>
import BasePagination from '@/components/common/BasePagination.vue'
import { useOrderFilters, PAGE_SIZE } from '@/composables/useOrderFilters'

const { page, pageSize, total, orders } = useOrderFilters()
</script>

<template>
  <ul>
    <li v-for="order in orders" :key="order.id">{{ order.orderNo }}</li>
  </ul>

  <select v-model.number="pageSize">
    <option :value="20">20 条/页</option>
    <option :value="50">50 条/页</option>
  </select>

  <BasePagination v-model:page="page" v-model:page-size="pageSize" :total="total" />
</template>

useOrderFilters 内部补一条:watch(() => filter.pageSize, () => (filter.page = 1))。

练习 3 · 挑战(选做,60 min):CategoryTree + provide/inject 权限

任务描述:① 实现 §5.6 的 CategoryTree 递归组件,数据至少三层;② 用 provide/inject 注入当前用户角色:operator/super_admin 显示每个分类后的「编辑」按钮,其他角色看不到(附录 A §2 按钮级权限)。

提示:注入键用 Symbol(§5.3 的 CurrentUserKey);canEditProduct 的判断收进一个 computed,模板里只写 v-if="canEdit"。

验收标准:

  • [ ] 树正确渲染三层,折叠/展开正常(aria-expanded 状态同步);
  • [ ] 改变 App 中提供的角色,所有层级的编辑按钮同步出现/消失;
  • [ ] CategoryTree 对角色的依赖只通过 inject,不接受额外的 props。
参考答案(inject 接入)
<!-- CategoryTree.vue 内部追加 -->
<script setup>
import { computed, inject } from 'vue'
import { CurrentUserKey } from '@/keys/currentUser'

const currentUser = inject(CurrentUserKey, ref({ name: '游客', role: 'guest' }))
const canEdit = computed(() => ['super_admin', 'operator'].includes(currentUser.value.role))
</script>

<template>
  <div class="category-node" :style="{ paddingLeft: depth * 16 + 'px' }">
    <button v-if="node.children?.length" class="toggle" @click="expanded = !expanded" :aria-expanded="expanded">
      {{ expanded ? '−' : '+' }}
    </button>
    <span>{{ node.name }}</span>
    <button v-if="canEdit" class="edit">编辑</button>
    <CategoryTree
      v-for="child in expanded ? node.children : []"
      :key="child.id"
      :node="child"
      :depth="depth + 1"
    />
  </div>
</template>

⚠️ 常见易错点

易错点 1:定时器 / 监听只注册不清理

// ❌ 错误:离开页面后定时器继续跑,控制台持续输出;
// 再次进入页面(组件重建)后变成两个定时器轮番改状态
onMounted(() => {
  setInterval(refresh, 10_000)
  window.addEventListener('resize', onResize)
})
// ✅ 正确:句柄保存 + 卸载时成对清理
let timer = null
onMounted(() => {
  refresh()
  timer = setInterval(refresh, 10_000)
  window.addEventListener('resize', onResize)
})
onUnmounted(() => {
  clearInterval(timer)
  window.removeEventListener('resize', onResize)
})

易错点 2:在异步回调里注册生命周期钩子

// ❌ 错误:onMounted 被包在 setTimeout 里——它不再是"注册"而是"调用",
// Vue 无法关联到当前组件实例,钩子永不触发(开发环境有警告)
setTimeout(() => {
  onMounted(() => refresh())
}, 1000)
// ✅ 正确:钩子注册必须在 setup 同步作用域;延迟逻辑放进钩子内部
onMounted(() => {
  setTimeout(refresh, 1000)
})

易错点 3:后代直接修改 inject 到的共享状态

// ❌ 错误:provide 传出了可变对象,任何后代都能改——
// 数据所有权失控,出问题时无从追责
provide('cart', reactive({ items: [] }))
// 某个后代里:inject('cart').items.push(...)
// ✅ 正确:provide 只读状态 + 显式的"修改方法",与 props 单向数据流同构
const items = ref([])
provide('cart', {
  items: readonly(items),
  addItem: (item) => items.value.push(item), // 修改入口唯一、可追踪
})

易错点 4:v-model 封装时在子组件里改 props 的值而不是改 model

// ❌ 错误:直接给 props.visible 赋值——报错且双向绑定断裂
const props = defineProps({ visible: Boolean })
function close() {
  props.visible = false
}
// ✅ 正确:defineModel 返回可写 ref,赋值自动派发 update 事件
const visible = defineModel('visible', { default: false })
function close() {
  visible.value = false
}

易错点 5:动态组件 :is 传错形态——字符串拿不到本地组件

<!-- ❌ 错误:tabName 是 'OrderInfoPanel' 字符串,
     :is 按字符串只会找"全局注册组件/原生标签",本地导入的组件找不到,页面空白 -->
<component :is="tabName" />
<!-- ✅ 正确:本地组件以组件对象传入(用对象映射) -->
<script setup>
import OrderInfoPanel from './OrderInfoPanel.vue'
const tabMap = { info: OrderInfoPanel }
</script>
<template>
  <component :is="tabMap[activeTab]" />
</template>

✅ 最佳实践

  1. 副作用「成对书写」:写 onMounted 的当下就写 onUnmounted,把清理逻辑放在同一屏——隔屏的清理代码必然漏改。
  2. 副作用封装进组合式函数:useOrderPolling、useDebouncedRef 这类封装把「注册+清理」锁在黑盒里,调用方不可能忘(第 04 章练习 3 已示范)。
  3. provide/inject 只用于全局性低频数据:当前用户、主题、字典;组件 API 数据坚持 props/emit,保持组件可移植。
  4. 弹窗、抽屉一律 v-model 受控:defineModel 让开关状态的所有权留在父组件,子组件只表达「我想关」。
  5. 动态组件优先组件对象映射::is + 对象字典 + shallowRef,类型可查、拼写错误编译期就能暴露。
  6. 递归组件先想出口再动手:写树形渲染前先明确「叶子节点长什么样」,children: [] 是比 children: null 更好的数据约定。

📌 本章小结

  • 生命周期四站:setup(创建)→ onMounted(可碰 DOM/发请求)→ onUpdated(勿改状态)→ onUnmounted(清理副作用);钩子必须在 setup 同步作用域注册。
  • 定时器/监听/订阅必须成对清理;watch 的清理分「每次触发前(onCleanup)」与「卸载时(自动/手动 stop)」两层。
  • provide/inject 解决跨层通信(≈ Context),只放全局低频数据,且要保护数据所有权(readonly + 显式修改方法)。
  • defineModel 一行封装受控组件,v-model:xxx 具名模型支持多模型;它就是 Vue 版的「value + onChange 约定」。
  • <component :is> 组件对象映射实现 Tab 切换;递归组件靠文件名自引用渲染树形数据,出口先行。
  • 本章完成后,「轻订单」的可复用组件资产已齐:StatusTag、OrderCard、SearchBar、BaseCard、ConfirmDialog、BasePagination、CategoryTree。
  • 下一章是初级阶段毕业设计:把这些资产组装成完整的企业门户首页——组件树设计先行、暗色主题切换、验收清单驱动。

🔗 延伸阅读(官方文档,长期维护):生命周期|provide / inject|组件 v-model|动态组件

📄 第 06 章 · 初级实战:企业门户首页 06-初级实战-企业门户首页.md

📍 阶段:初级阶段(第 1–6 章)|⏱ 预计学时:6 小时|🔗 前置:第 05 章(建议先重读附录 A §1、§6)

⬅ 上一章:生命周期与组件通信 | 📖 本版目录 | ➡ 下一章:路由系统 · Vue Router 全解

💡 对照学习:本页是 Vue 版第 06 章。React 版对应章节为 第 06 章 · 初级实战:企业门户首页。两版需求、组件树、验收标准完全一致,可逐文件对照实现。概念映射:provide / inject 主题状态 ≈ React 的 Context 主题方案;CSS 变量 + data-theme 两版通用;组件树设计方法论两版相同。


🎯 学习目标

学完本章,你将能够:

  1. 从需求线框出发画出门户首页组件树,为每个组件写出一句话职责声明。
  2. 设计每个组件的 props / emits / slots 接口,并用本章 §6.5 的评审清单自检。
  3. 实现导航栏、Banner、特性区、FAQ 手风琴、页脚五大区块组件,数据全部由 data/ 目录驱动。
  4. 用 CSS 变量 + provide/inject + localStorage 实现暗色主题切换,并尊重系统主题偏好。
  5. 按功能域组织项目目录(components/portal/、data/、composables/、styles/)。
  6. 用验收清单逐项验收门户首页(含 Lighthouse 快速体检与控制台零报错)。
  7. 对照《初级能力自查清单》评估自己是否达到初级阶段毕业线。

📖 核心知识点

6.1 组件树设计先行:从线框到组件树

企业里接需求的第一步不是写代码,而是把页面切分成组件。门户首页(附录 A 一期交付物)的线框与组件树:

线框:                              组件树(职责声明):
┌──────────────────────────┐        App
│ 导航:Logo  菜单  [主题切换] │        ├── SiteNav        · 品牌、锚点导航与主题入口
├──────────────────────────┤        ├── HeroBanner     · 主视觉:标语 + 双 CTA
│   主视觉:标语 + 两个按钮    │        ├── FeatureGrid    · 六张特性卡(数据驱动)
│                          │        ├── FaqAccordion   · FAQ 手风琴(单开)
├──────────────────────────┤        ├── SiteFooter     · 备案信息与联系方式
│  特性区:2×3 特性卡网格     │        └── ThemeToggle    · 主题切换按钮(注入主题状态)
│                          │
├──────────────────────────┤        数据目录:
│  FAQ:手风琴(一次只开一条) │        data/portal.js  · features / faqs / navItems
├──────────────────────────┤        状态目录:
│  页脚:联系方式 · 备案       │        composables/useTheme.js · 主题创建与注入
└──────────────────────────┘

拆分依据(第 03 章 §3.6 原则的应用):导航、Banner、特性、FAQ、页脚是视觉上天然分界的区块,各管一摊;ThemeToggle 独立成组件是因为它的状态(主题)跨组件共享,值得单独管理。FAQ 手风琴不复用(全站只出现一次)所以放 portal/ 而不是 common/。

6.2 目录组织

src/
├── components/
│   └── portal/
│       ├── SiteNav.vue
│       ├── HeroBanner.vue
│       ├── FeatureGrid.vue
│       ├── FaqAccordion.vue
│       ├── SiteFooter.vue
│       └── ThemeToggle.vue
├── composables/
│   └── useTheme.js          # 主题状态:创建 + 注入 + 持久化
├── data/
│   ├── portal.js            # 门户文案数据(features / faqs / navItems)
│   └── keys.js              # provide/inject 的 Symbol 键
├── styles/
│   ├── main.css             # 设计令牌(CSS 变量)+ 主题变体
│   └── reset.css
├── App.vue                  # 编排层:按组件树组装
└── main.js

两条纪律:页面文案不写死在组件里(进 data/portal.js,运营改文案不动代码);样式令牌不写死色值(进 styles/main.css 的 CSS 变量)。

6.3 主题:CSS 变量 + provide/inject + 持久化

三段式方案:CSS 变量定义令牌(html[data-theme='dark'] 整组覆盖)→ composable 管理状态(创建、持久化、跟随系统)→ provide/inject 分发(App 提供,ThemeToggle 注入)。

/* src/styles/main.css —— 设计令牌:亮色为默认,暗色整组覆盖 */
:root {
  --qo-bg: #f8fafc;
  --qo-surface: #ffffff;
  --qo-text: #0f172a;
  --qo-text-muted: #64748b;
  --qo-primary: #2563eb;
  --qo-border: #e2e8f0;
}

html[data-theme='dark'] {
  --qo-bg: #0b1220;
  --qo-surface: #111a2c;
  --qo-text: #e2e8f0;
  --qo-text-muted: #94a3b8;
  --qo-primary: #60a5fa;
  --qo-border: #1e293b;
}

body {
  background: var(--qo-bg);
  color: var(--qo-text);
  transition: background-color 0.2s ease, color 0.2s ease;
}
// src/data/keys.js —— 注入键统一登记,杜绝裸字符串
export const ThemeKey = Symbol('theme')
// src/composables/useTheme.js —— 主题状态的所有者
import { ref } from 'vue'

const STORAGE_KEY = 'qingorder-theme' // 键名收敛为常量,禁止裸字符串散落

function applyTheme(value) {
  // 主题类挂在 <html> 根元素上:任何深度的组件都能吃到变量
  document.documentElement.dataset.theme = value
}

function readStoredTheme() {
  try {
    // localStorage 可能被浏览器禁用/隐私模式拦截,读取必须兜底
    return localStorage.getItem(STORAGE_KEY)
  } catch {
    return null
  }
}

export function createTheme() {
  const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
  const theme = ref(readStoredTheme() ?? (prefersDark ? 'dark' : 'light'))
  applyTheme(theme.value)

  function toggleTheme() {
    theme.value = theme.value === 'light' ? 'dark' : 'light'
    applyTheme(theme.value)
    try {
      localStorage.setItem(STORAGE_KEY, theme.value)
    } catch {
      /* 写入失败不致命:主题只在当前会话生效 */
    }
  }

  return { theme, toggleTheme }
}
<!-- App.vue:创建 + 提供 + 组装 -->
<script setup>
import { provide, readonly } from 'vue'
import { ThemeKey } from '@/data/keys'
import { createTheme } from '@/composables/useTheme'

const { theme, toggleTheme } = createTheme()
// 只读分发:切换入口唯一(toggleTheme),数据所有权清晰(第 05 章 §5.3 约定)
provide(ThemeKey, { theme: readonly(theme), toggleTheme })
</script>

<template>
  <SiteNav />
  <main>
    <HeroBanner />
    <FeatureGrid />
    <FaqAccordion />
  </main>
  <SiteFooter />
</template>
<!-- src/components/portal/ThemeToggle.vue:注入方 -->
<script setup>
import { inject } from 'vue'
import { ThemeKey } from '@/data/keys'

const { theme, toggleTheme } = inject(ThemeKey)
</script>

<template>
  <button class="theme-toggle" @click="toggleTheme" :aria-label="theme === 'light' ? '切换到暗色主题' : '切换到亮色主题'">
    {{ theme === 'light' ? '🌙' : '☀️' }}
  </button>
</template>

优先级链路是本方案的关键:用户显式选择(localStorage)> 系统偏好(prefers-color-scheme)> 默认亮色——企业产品的标准主题策略。

6.4 区块实现要点

数据文件(两个区块共用,先写数据再写组件,能逼你先想清接口):

// src/data/portal.js —— 全站文案数据(教学虚构)
export const navItems = [
  { label: '产品', href: '#features' },
  { label: '常见问题', href: '#faq' },
  { label: '联系我们', href: '#footer' },
]

export const features = [
  { id: 'f1', icon: '📋', title: '订单全流程跟踪', desc: '草稿到完成 7 个状态可视,杜绝漏单。' },
  { id: 'f2', icon: '🔐', title: '三级权限管控', desc: '菜单/页面/按钮三级,价格改动有痕可查。' },
  { id: 'f3', icon: '🧾', title: '财务审批闭环', desc: '折扣超 10% 自动进入财务审批队列。' },
  { id: 'f4', icon: '📊', title: '经营报表', desc: '按销售/月度/商品聚合,一键导出 Excel。' },
  { id: 'f5', icon: '⚡', title: '批量操作', desc: '批量导出 CSV、批量指派销售。' },
  { id: 'f6', icon: '🔌', title: '开放接口', desc: '与 ERP、CRM 平滑对接。' },
]

export const faqs = [
  { id: 'q1', question: '轻订单适合多大团队?', answer: '3 人以上销售团队即可使用,支持直销、代理商与电商代运营三种渠道。' },
  { id: 'q2', question: '支持私有化部署吗?', answer: '支持公有云 SaaS 与专有云两种交付形态,专有云方案请联系我们。' },
  { id: 'q3', question: '折扣审批流程是怎样的?', answer: '销售提交折扣超过 10% 的订单后自动进入财务审批队列,审批结果实时通知。' },
  { id: 'q4', question: '数据安全如何保障?', answer: '传输全程 TLS 加密,操作日志完整留痕,敏感数据分级存储。' },
]

导航与 Banner(props 只读、锚点导航、插槽留扩展位):

<!-- src/components/portal/SiteNav.vue -->
<script setup>
/**
 * SiteNav · 门户导航
 * 职责:品牌展示、锚点导航、主题切换入口。
 */
import { navItems } from '@/data/portal'
import BrandBadge from '@/components/common/BrandBadge.vue'
import ThemeToggle from './ThemeToggle.vue'
</script>

<template>
  <header class="site-nav">
    <BrandBadge />
    <nav class="nav-links">
      <a v-for="item in navItems" :key="item.href" :href="item.href">{{ item.label }}</a>
    </nav>
    <ThemeToggle />
  </header>
</template>

<style scoped>
.site-nav { display: flex; align-items: center; justify-content: space-between; padding: 14px 32px; background: var(--qo-surface); border-bottom: 1px solid var(--qo-border); }
.nav-links { display: flex; gap: 24px; }
.nav-links a { color: var(--qo-text-muted); text-decoration: none; }
.nav-links a:hover { color: var(--qo-primary); }
</style>
<!-- src/components/portal/HeroBanner.vue -->
<script setup>
/**
 * HeroBanner · 主视觉
 * 职责:品牌标语与两个转化按钮;内容允许父级用插槽覆盖。
 */
</script>

<template>
  <section class="hero">
    <h1>轻订单 · 让每一张订单都有迹可循</h1>
    <p>面向 B2B 销售团队的订单管理系统:从接单到回款,全程留痕。</p>
    <div class="hero-actions">
      <!-- 默认插槽:父级可整体替换按钮区(第 03 章 §3.4) -->
      <slot>
        <a class="btn btn--primary" href="#features">查看产品特性</a>
        <a class="btn" href="#footer">联系我们</a>
      </slot>
    </div>
  </section>
</template>

<style scoped>
.hero { text-align: center; padding: 96px 24px 72px; }
.hero h1 { font-size: 40px; margin-bottom: 12px; }
.hero p { color: var(--qo-text-muted); font-size: 18px; }
.hero-actions { margin-top: 32px; display: flex; gap: 16px; justify-content: center; }
.btn { padding: 10px 24px; border-radius: 8px; border: 1px solid var(--qo-border); text-decoration: none; color: var(--qo-text); }
.btn--primary { background: var(--qo-primary); border-color: var(--qo-primary); color: #fff; }
</style>

FAQ 手风琴(单开逻辑 + 无障碍属性,这是本章的交互核心):

<!-- src/components/portal/FaqAccordion.vue -->
<script setup>
/**
 * FaqAccordion · FAQ 手风琴
 * 职责:渲染 FAQ 列表,单开模式(一次只展开一条)。
 */
import { ref } from 'vue'
import { faqs } from '@/data/portal'

const activeId = ref(faqs[0]?.id ?? null) // 默认展开第一条

function toggle(id) {
  // 再点一次同一条 → 收起;点另一条 → 单开切换
  activeId.value = activeId.value === id ? null : id
}
</script>

<template>
  <section id="faq" class="faq">
    <h2>常见问题</h2>
    <ul class="faq-list">
      <li v-for="faq in faqs" :key="faq.id" class="faq-item">
        <button
          class="faq-question"
          :aria-expanded="activeId === faq.id"
          :aria-controls="`faq-panel-${faq.id}`"
          @click="toggle(faq.id)"
        >
          <span>{{ faq.question }}</span>
          <span class="indicator">{{ activeId === faq.id ? '−' : '+' }}</span>
        </button>
        <p v-show="activeId === faq.id" :id="`faq-panel-${faq.id}`" class="faq-answer">
          {{ faq.answer }}
        </p>
      </li>
    </ul>
  </section>
</template>

<style scoped>
.faq { max-width: 720px; margin: 0 auto; padding: 64px 24px; }
.faq-list { list-style: none; padding: 0; }
.faq-item { border: 1px solid var(--qo-border); border-radius: 8px; margin-bottom: 12px; background: var(--qo-surface); }
.faq-question { width: 100%; display: flex; justify-content: space-between; align-items: center; padding: 16px; background: none; border: none; cursor: pointer; color: var(--qo-text); font-size: 16px; }
.faq-answer { padding: 0 16px 16px; color: var(--qo-text-muted); margin: 0; }
</style>

交互选型:答案区用 v-show(第 02 章 §2.5)——展开/收起高频发生,且 <p> 内容轻量,切换 display 比反复创建销毁更划算。aria-expanded / aria-controls 让屏幕阅读器能感知手风琴状态,无障碍是验收项,不是加分项。

特性区与页脚:

<!-- src/components/portal/FeatureGrid.vue -->
<script setup>
/**
 * FeatureGrid · 特性网格
 * 职责:渲染特性卡;单卡渲染权留给父级(作用域插槽,可选定制)。
 */
import { features } from '@/data/portal'
</script>

<template>
  <section id="features" class="features">
    <h2>为什么选择轻订单</h2>
    <div class="grid">
      <div v-for="feature in features" :key="feature.id" class="feature-card">
        <slot name="card" :feature="feature">
          <span class="icon">{{ feature.icon }}</span>
          <h3>{{ feature.title }}</h3>
          <p>{{ feature.desc }}</p>
        </slot>
      </div>
    </div>
  </section>
</template>

<style scoped>
.features { padding: 64px 24px; max-width: 1080px; margin: 0 auto; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
.feature-card { border: 1px solid var(--qo-border); border-radius: 12px; padding: 20px; background: var(--qo-surface); }
.feature-card h3 { margin: 8px 0; }
.feature-card p { color: var(--qo-text-muted); margin: 0; }
</style>
<!-- src/components/portal/SiteFooter.vue -->
<script setup>
/** SiteFooter · 页脚:联系方式与备案占位 */
</script>

<template>
  <footer id="footer" class="site-footer">
    <p>青云科技(教学虚构)· 智能硬件 B2B</p>
    <p>商务合作:contact@qingorder.example.com</p>
    <p><small>© 2026 青云科技 · 本页面为教程虚构演示</small></p>
  </footer>
</template>

<style scoped>
.site-footer { border-top: 1px solid var(--qo-border); padding: 32px; text-align: center; color: var(--qo-text-muted); }
</style>

6.5 props 接口设计评审清单

提交 PR 前,对着这张表逐条过(贴进团队 PR 模板即可用):

# 检查项 不合格信号
1 每个 prop 有类型、必填性、默认值 defineProps(['foo']) 裸数组声明
2 没有「整页配置对象」式的超大 prop :config="pageConfig" 下传再层层透传
3 事件名是动宾/过去式短语,载荷带 id 或对象 事件名只有 click、change 这类与原生冲突的词
4 所有 emit 已在 defineEmits 声明 父组件监听的事件在子组件里搜不到
5 定制点优先插槽而非 props 开关 showIcon、iconColor、iconPosition 三连
6 子组件不修改 props,状态改动走事件 子组件里出现对 props.xxx 的赋值
7 组件顶部有一句话职责声明 说不清「这个组件不负责什么」

6.6 验收标准(本章实战的定义完成线)

  • [ ] 组件树与 §6.1 一致,六个组件 + 数据目录齐备;
  • [ ] 导航锚点可跳转到对应区块;主题切换后全站(含导航与页脚)配色整体翻转,刷新页面后保持;
  • [ ] FAQ 单开:展开 B 时 A 自动收起;键盘 Tab 聚焦后回车可切换,aria-expanded 正确;
  • [ ] 特性区 6 张卡全部来自 data/portal.js,组件内无硬编码文案;
  • [ ] 浏览器控制台零报错零警告;
  • [ ] Lighthouse 跑一遍 Performance 与 Accessibility,无红项(性能细节治理是第 14 章主题,此处只要不踩明显的坑:图片有宽高、无阻塞脚本);
  • [ ] 代码里没有真实凭据——本页面纯静态无密钥,后续接接口时一律用 import.meta.env.VITE_*。

🛠 渐进式实战练习

练习 1 · 基础(30 min):数据驱动的 HeroBanner + FeatureGrid

任务描述:创建 data/portal.js(§6.4 数据文件)与 HeroBanner、FeatureGrid 两个组件并接入 App.vue,完成主视觉与特性区。

提示:先写数据文件再写组件;FeatureGrid 用 grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)) 做响应式网格。

验收标准:

  • [ ] 六张特性卡渲染完整,颜色随主题切换;
  • [ ] 缩窄浏览器窗口时网格自动换列;
  • [ ] 组件模板里搜不到任何具体文案字符串。
参考答案(App.vue 编排)
<script setup>
import { provide, readonly } from 'vue'
import { ThemeKey } from '@/data/keys'
import { createTheme } from '@/composables/useTheme'
import SiteNav from '@/components/portal/SiteNav.vue'
import HeroBanner from '@/components/portal/HeroBanner.vue'
import FeatureGrid from '@/components/portal/FeatureGrid.vue'
import FaqAccordion from '@/components/portal/FaqAccordion.vue'
import SiteFooter from '@/components/portal/SiteFooter.vue'

const { theme, toggleTheme } = createTheme()
provide(ThemeKey, { theme: readonly(theme), toggleTheme })
</script>

<template>
  <SiteNav />
  <main>
    <HeroBanner />
    <FeatureGrid />
    <FaqAccordion />
  </main>
  <SiteFooter />
</template>

练习 2 · 进阶(45 min):FaqAccordion 单开手风琴

任务描述:实现 §6.4 的 FAQ 手风琴,要求:单开模式;默认展开第一条;再次点击收起;补齐 aria-expanded / aria-controls;动画效果交给 CSS(答案区用 max-height 过渡或保持 v-show 简洁实现,二选一并注释理由)。

提示:单开状态只需要一个 activeId ref;「为什么用 v-show / v-if」把理由写成一行注释——这是团队评审时爱问的问题。

验收标准:

  • [ ] 任意时刻至多一条展开;键盘可操作;
  • [ ] 点击已展开项可收起(此时没有展开项);
  • [ ] 代码注释里写明了显隐方案的选型理由。
参考答案(单开逻辑与选型注释)
import { ref } from 'vue'
import { faqs } from '@/data/portal'

// 单开模式:activeId 既是"哪条开着",天然互斥
const activeId = ref(faqs[0]?.id ?? null)

function toggle(id) {
  activeId.value = activeId === id ? null : id
}
// 选型注释示例:
// 用 v-show:FAQ 展开/收起是高频轻量交互,p 元素成本低,
// 保留 DOM 可让 CSS max-height 过渡生效;若内容含重组件应改用 v-if。

练习 3 · 挑战(选做,60 min):完整主题系统

任务描述:实现 §6.3 的完整主题链路:useTheme composable + ThemeKey 注入 + ThemeToggle + CSS 变量双套令牌,要求支持「跟随系统」兜底,并在系统主题变化时实时响应(监听 matchMedia 的 change 事件,且在组件卸载时移除监听——第 05 章 §5.2 的纪律)。

提示:matchMedia('(prefers-color-scheme: dark)') 返回的 MediaQueryList 有 addEventListener('change', fn);用户显式切换过后应停止跟随系统(记住这个优先级);监听逻辑放进 useTheme 内部的 onUnmounted……注意 useTheme 的调用位置在 App(根组件),等价于应用生命周期。

验收标准:

  • [ ] 切换主题 → 刷新页面保持;清除 localStorage → 刷新后跟随系统主题;
  • [ ] 系统切换深色模式时(模拟:系统设置改外观),未显式选择过的页面实时跟随;
  • [ ] 控制台无泄漏警告,localStorage 读写有 try/catch 兜底。
参考答案(跟随系统的监听)
// useTheme.js 内追加
import { ref, onUnmounted } from 'vue'

export function createTheme() {
  const media = window.matchMedia('(prefers-color-scheme: dark)')
  const theme = ref(readStoredTheme() ?? (media.matches ? 'dark' : 'light'))
  applyTheme(theme.value)

  // 用户从未显式选择时,跟随系统变化
  function onSystemChange(e) {
    if (readStoredTheme()) return // 已有用户偏好,不跟随
    theme.value = e.matches ? 'dark' : 'light'
    applyTheme(theme.value)
  }
  media.addEventListener('change', onSystemChange)
  onUnmounted(() => media.removeEventListener('change', onSystemChange))

  function toggleTheme() {
    theme.value = theme.value === 'light' ? 'dark' : 'light'
    applyTheme(theme.value)
    try {
      localStorage.setItem(STORAGE_KEY, theme.value) // 显式选择 → 之后不再跟随系统
    } catch { /* 隐私模式等场景写入失败可容忍 */ }
  }

  return { theme, toggleTheme }
}

⚠️ 常见易错点

易错点 1:主题类挂在组件根上,深层组件吃不到变量

<!-- ❌ 错误:data-theme 挂在 App 的根元素上 -->
<template>
  <div class="app-shell" data-theme="dark">...</div>
</template>
// ✅ 正确:挂在 <html> 根元素——CSS 变量沿文档树继承,
// 弹窗(可能挂到 body 层)、页脚等所有位置统一生效
document.documentElement.dataset.theme = 'dark'

变量写在 :root / html[data-theme='dark'] 上,任何组件里 var(--qo-xxx) 都能取到。

易错点 2:localStorage 裸读裸写,隐私模式下整个应用白屏

// ❌ 错误:部分浏览器/隐私模式访问 localStorage 直接抛 SecurityError,
// 未捕获异常发生在模块顶层 → 应用启动即崩
const theme = localStorage.getItem('qingorder-theme')
// ✅ 正确:读取与写入都包 try/catch,异常时退回默认主题
function readStoredTheme() {
  try {
    return localStorage.getItem(STORAGE_KEY)
  } catch {
    return null
  }
}

易错点 3:手风琴状态交给「每个子项自己管」,无法实现单开

<!-- ❌ 错误:开合状态在每个 FaqItem 内部——互相不知道,
     "单开"物理上不可能,父级也无法控制默认展开项 -->
<li v-for="faq in faqs" :key="faq.id">
  <FaqItem :faq="faq" /> <!-- FaqItem 里自己维护 expanded -->
</li>
// ✅ 正确:互斥状态上提到父级(状态放在"最需要它的最小公共父级"),
// 子项受控展示
const activeId = ref(faqs[0]?.id ?? null)
<!-- 父级 -->
<FaqItem v-for="faq in faqs" :key="faq.id" :faq="faq" :active="activeId === faq.id" @toggle="toggle(faq.id)" />

易错点 4:把整站配置做成一个巨型 props 对象层层下传

<!-- ❌ 错误:App 把 pageConfig 整包传给每个区块,
     区块内部再解构——接口面失控,任何一处改动牵动全链路 -->
<SiteNav :config="pageConfig" />
<HeroBanner :config="pageConfig" />
<FeatureGrid :config="pageConfig" />
<!-- ✅ 正确:按需切片传递(或让区块直接消费 data/ 模块),
     每个组件的 props 只包含它职责内的数据 -->
<SiteNav :items="navItems" />
<FeatureGrid :features="features" />

易错点 5:硬编码色值绕过设计令牌,暗色主题出现「白块」

<!-- ❌ 错误:写死亮色背景——切到暗色主题后这块依然刺眼 -->
<style scoped>
.faq-item { background: #ffffff; color: #0f172a; }
</style>
<!-- ✅ 正确:一律引用令牌变量,主题切换自动生效 -->
<style scoped>
.faq-item { background: var(--qo-surface); color: var(--qo-text); }
</style>

回归手段:切到暗色主题后整页扫一遍,发现任何「不跟随」的元素,多半是它。

✅ 最佳实践

  1. 组件树先行:动手前画出组件树并写职责声明,评审通过再写代码——返工成本最低的环节就是这一步。
  2. 文案、配置进数据文件:data/portal.js 让「改文案」变成运营任务而不是发版任务,也天然隔离了业务词与组件逻辑。
  3. 主题三件套成体系:CSS 变量(表现层)、composable(状态层)、localStorage + 系统偏好(策略层),三层各管各的,换肤方案升级时互不牵连。
  4. 无障碍写进验收清单:aria-expanded、语义化标签(header/nav/main/footer)、可聚焦的交互元素,初级阶段就按清单做,比高级阶段补课便宜得多。
  5. 样式令牌零裸色值:评审时全局搜索 #fff、#333,出现在组件里的色值一律打回。
  6. 用验收清单代替「我觉得做完了」:§6.6 的清单逐项打勾,打不完不算完成——企业里这叫 Definition of Done。

📌 本章小结

  • 毕业设计方法论:组件树设计 → 接口评审 → 数据驱动实现 → 验收清单收尾,四步顺序不可颠倒。
  • 组件树六大件:SiteNav / HeroBanner / FeatureGrid / FaqAccordion / SiteFooter / ThemeToggle,全部由 data/portal.js 驱动。
  • 主题系统三层:CSS 变量 + data-theme、useTheme composable(含 localStorage 兜底)、provide/inject 只读分发,优先级「用户选择 > 系统偏好 > 默认」。
  • 手风琴的互斥状态必须放在父级;高频轻量显隐用 v-show;无障碍属性是验收项。
  • 至此初级阶段全部完成:你已具备「在既有 Vue 项目里独立实现一个页面模块」的能力。进入中级(路由、Pinia、服务端交互)之前,请先完成一页自查——评估是否达到初级毕业线、补齐短板:阶段自查 · 初级能力清单。

🔗 延伸阅读(官方文档,长期维护):样式与 CSS 变量|provide / inject|无障碍基础(Vue 官方)|Lighthouse

📄 第 07 章 · 路由系统:Vue Router 全解 07-路由系统-VueRouter全解.md

📍 阶段:中级阶段(第 07–12 章)|⏱ 预计学时:6 小时|🔗 前置:第 06 章

⬅ 上一章 | 📖 本版目录 | ➡ 下一章

💡 对照学习:本页是 Vue 版第 07 章。React 版对应章节为 第 07 章 · 路由系统:React Router 全解。概念映射:<router-view> ≈ <Outlet>;useRoute() ≈ useParams() + useLocation();useRouter() ≈ useNavigate();导航守卫 beforeEach ≈ loader + <ProtectedRoute>;路由懒加载 ≈ React.lazy() + <Suspense>;路由表 meta ≈ React Router 7 数据路由的 handle。

🎯 学习目标

学完本章,你将能够:

  1. 说出 SPA 路由的本质(URL 与组件树的映射),以及 history 模式与 hash 模式的区别和各自的部署要求;
  2. 用 createRouter 与嵌套 children 搭建中后台 Layout 布局结构(侧边菜单 + 顶栏 + 内容区);
  3. 用动态路由参数实现订单详情页 /orders/:id,并正确处理「路径参数变化但组件被复用」的更新问题;
  4. 区分 params 与 query 的适用场景,用命名路由与编程式导航完成带参跳转与筛选条件回传;
  5. 编写全局前置守卫 router.beforeEach,基于 meta 实现登录拦截、白名单放行与页面级权限(403);
  6. 配置路由懒加载、scrollBehavior 滚动行为与路由过渡 + keep-alive 缓存,兼顾首屏体积与浏览体验;
  7. 基于 route.matched 实现中后台面包屑导航,并说出它与路由匹配记录数组的关系。

📖 核心知识点

7.1 路由的本质与两种模式

前六章的「轻订单」门户是单页应用(SPA):整个站点只有一份 HTML,所谓路由,就是「当前 URL ↔ 应该渲染哪棵组件树」的映射规则。Vue Router 4 的职责只有三件事:监听 URL 变化、在路由表中查找匹配项、把对应组件渲染进 <router-view> 出口。

两种模式对应两种改 URL 的方式:

维度 history 模式 hash 模式
URL 形态 /orders/1001 /#/orders/1001
原理 history.pushState 改地址栏,不发请求 改 # 后部分,不触发页面刷新
刷新行为 浏览器会真的向服务器请求 /orders/1001 永远请求 /,刷新天然安全
部署要求 服务端必须配置 SPA 回退(见易错点 5) 无额外要求
企业选择 绝大多数中后台/官网 嵌入 WebView、无法配服务端的场景

企业项目默认用 history 模式,代码如下:

// src/router/index.js —— 路由实例:全应用唯一的「URL → 组件」映射中心
import { createRouter, createWebHistory } from 'vue-router'
import { routes } from './routes'

const router = createRouter({
  // import.meta.env.BASE_URL 来自 vite.config.js 的 base 配置,默认 '/'
  history: createWebHistory(import.meta.env.BASE_URL),
  routes,
})

export default router
// src/main.js —— 安装路由插件
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

createApp(App).use(router).mount('#app')

7.2 路由表:嵌套路由与中后台 Layout

中后台系统的经典结构是「外壳 + 内容区」:外壳(Layout)负责侧边菜单、顶栏、面包屑,内容区随 URL 变化。这在 Vue Router 里就是嵌套路由:父路由渲染 Layout,children 里的子路由渲染在 Layout 内部的 <router-view> 中。

先确认路径别名,路由懒加载里的 @/views/... 依赖它:

// vite.config.js(节选):@ 指向 src 目录
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
  },
})
// src/router/routes.js —— 路由表:静态声明 + 懒加载 + meta 标记(单一数据源)
export const routes = [
  {
    path: '/login',
    name: 'login',
    component: () => import('@/views/login/LoginView.vue'),
    meta: { title: '登录', public: true }, // public:无需登录
  },
  {
    path: '/',
    component: () => import('@/views/layout/AdminLayout.vue'), // 中后台外壳
    redirect: '/dashboard',
    children: [
      {
        path: 'dashboard',
        name: 'dashboard',
        component: () => import('@/views/dashboard/DashboardView.vue'),
        meta: { title: '工作台' },
      },
      {
        path: 'orders',
        name: 'order-list',
        component: () => import('@/views/orders/OrderListView.vue'),
        meta: { title: '订单管理' },
      },
      {
        path: 'orders/:id',
        name: 'order-detail',
        component: () => import('@/views/orders/OrderDetailView.vue'),
        meta: { title: '订单详情', hidden: true }, // 不出现在菜单里
      },
    ],
  },
  // 404 兜底必须放在最外层,匹配所有未命中路径
  {
    path: '/:pathMatch(.*)*',
    name: 'not-found',
    component: () => import('@/views/error/NotFoundView.vue'),
    meta: { title: '页面不存在', public: true },
  },
]

外壳组件:注意 <el-main> 里的 <router-view /> 是二级出口,子路由页面渲染在这里。

<!-- src/views/layout/AdminLayout.vue —— 中后台外壳(侧边菜单 + 顶栏 + 内容区) -->
<script setup>
import SideMenu from './components/SideMenu.vue'
</script>

<template>
  <el-container class="admin-layout">
    <el-aside width="220px">
      <SideMenu />
    </el-aside>
    <el-container>
      <el-header class="admin-header">
        <span class="app-name">轻订单</span>
        <span class="user-name">运营小张</span>
      </el-header>
      <el-main>
        <!-- 二级路由出口:children 里的页面渲染在这里 -->
        <router-view />
      </el-main>
    </el-container>
  </el-container>
</template>

<style scoped>
.admin-layout { height: 100vh; }
.admin-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  border-bottom: 1px solid var(--el-border-color);
}
</style>

侧边菜单用 Element Plus 的 router 模式:index 就是跳转路径,:default-active 绑定当前路径实现高亮。

<!-- src/views/layout/components/SideMenu.vue —— router 模式菜单 -->
<script setup>
import { useRoute } from 'vue-router'

const route = useRoute()
// 教学阶段菜单写死;第 11 章会改为「按角色过滤」的动态菜单
const menus = [
  { path: '/dashboard', title: '工作台' },
  { path: '/orders', title: '订单管理' },
]
</script>

<template>
  <el-menu router :default-active="route.path" class="side-menu">
    <el-menu-item v-for="m in menus" :key="m.path" :index="m.path">
      {{ m.title }}
    </el-menu-item>
  </el-menu>
</template>

7.3 动态路由参数:params / query / 命名路由

订单详情页的 URL 是 /orders/1001、/orders/1002……这类「结构固定、值变化」的段用动态路由参数(params)表达:path: 'orders/:id'。推荐再配一个 props: true,让参数以 props 形式进入组件——组件不依赖路由也能复用、好测试。

<!-- src/views/orders/OrderDetailView.vue —— 详情页组件(参数通过 props 注入) -->
<script setup>
// props: true 时,路径参数直接作为 props 接收
const props = defineProps({
  id: { type: String, required: true },
})
// 后续章节在这里调用 fetchOrderById(props.id) 拉取详情
</script>

<template>
  <h2>订单详情 #{{ id }}</h2>
</template>

params 与 query 的分工:

维度 params(路径参数) query(查询参数)
URL 形态 /orders/1001 /orders?status=approved&page=2
语义 资源的身份,是 URL 结构的一部分 页面状态(筛选、分页、tab),可有可无
刷新后 只要 path 声明了 :id 就能恢复 保留在 URL 中,天然可恢复
典型场景 订单详情、客户详情 列表筛选、分页、弹窗 tab

跳转用命名路由而不是手拼字符串——路由表改名时类型安全、全局可搜索:

// 列表页 → 详情页(常见写法)
import { useRouter } from 'vue-router'

const router = useRouter()

function goDetail(row) {
  // 命名路由 + params:不依赖 path 字符串
  router.push({ name: 'order-detail', params: { id: row.id } })
}

function goDetailLogs(row) {
  // params(资源身份)+ query(页面状态)可以同时使用
  router.push({ name: 'order-detail', params: { id: row.id }, query: { tab: 'logs' } })
}

7.4 编程式导航与导航解析流程

<router-link> 声明式导航适合菜单与链接;业务逻辑中的跳转(提交成功后跳详情、审批后返回列表)用编程式导航:

router.push('/orders')                  // 追加一条历史记录
router.push({ name: 'order-list' })     // 命名路由
router.replace('/login')                // 替换当前记录:登录后不应「返回」回登录页
router.back()                           // 等价 router.go(-1)
router.go(-2)                           // 后退两步

「返回列表并保留筛选」是中后台高频需求:把筛选条件放进 query,返回时带回。第 09 章的 useOrderList 会把它消费掉:

// 详情页的「返回列表」:带上进入时的筛选(列表页存进 query 的)
function backToList() {
  router.push({
    name: 'order-list',
    query: history.state?.back ? undefined : route.query.fromList, // 简化示意,练习 2 给完整实现
  })
}

每次 push 触发的导航解析流程(简版,完整版见官方文档《导航守卫》一章):

  1. 触发导航(组件内 beforeRouteLeave 允许离开);
  2. 全局 beforeEach(登录态、页面级权限检查——本章主角);
  3. 复用组件时的 beforeRouteUpdate(如 /orders/1 → /orders/2);
  4. 路由独享 beforeEnter;
  5. 全局 beforeResolve 解析完成 → 确认导航 → afterEach(埋点、改标题);
  6. DOM 更新,beforeRouteEnter 的 next 回调执行。

7.5 导航守卫与 meta 权限标记

守卫是中后台权限的第一道闸门。核心手法:在路由表 meta 里声明「这个页面需要什么」,在守卫里统一裁决——路由表是单一数据源,守卫只读不写。

// src/router/index.js —— 守卫:白名单 + 登录拦截(页面级权限在第 11 章扩展 roles)
import { createRouter, createWebHistory } from 'vue-router'
import { routes } from './routes'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes,
  scrollBehavior(to, from, savedPosition) {
    // 浏览器前进/后退恢复滚动位置;新导航回到顶部
    return savedPosition || { top: 0 }
  },
})

// 无需登录即可访问的路径白名单
const PUBLIC_PATHS = ['/login', '/403', '/404']

router.beforeEach((to) => {
  // 教学阶段直接读 localStorage;第 11 章会替换为 useUserStore()
  const token = localStorage.getItem('qing_access_token')

  if (to.meta.public || PUBLIC_PATHS.includes(to.path)) return true

  if (!token) {
    // 未登录:重定向到登录页,并用 query 记住目标,登录成功后跳回
    return { path: '/login', query: { redirect: to.fullPath } }
  }
  return true
})

router.afterEach((to) => {
  // afterEach 适合做「不拦截导航」的副作用:标题、埋点
  document.title = to.meta.title ? `${to.meta.title} · 轻订单` : '轻订单'
})

export default router

两个关键约定:

  • Vue Router 4 推荐用返回值代替 next():return true 放行、return false 取消、return 路由地址 重定向(next 写法仍兼容,但团队内统一用一种);
  • meta 会从父到子浅合并:父路由 / 上写 meta.requiresAuth,所有 children 都继承,不必每条重复。

7.6 懒加载、滚动行为与路由过渡

路由懒加载把每个页面切成独立 chunk,首屏只加载当前页——这直接服务附录A「后台首屏可交互 ≤ 3.0s」的目标:

// ❌ 静态 import:所有页面打进首屏 bundle
import OrderListView from '@/views/orders/OrderListView.vue'

// ✅ 懒加载:访问时才加载对应 chunk
component: () => import('@/views/orders/OrderListView.vue')

列表页是高频回访页面,用 keep-alive 缓存组件实例(筛选条件、滚动位置都在),配合 <transition> 做路由切换动画:

<!-- AdminLayout.vue 内容区升级:路由过渡 + keep-alive -->
<template>
  <el-main class="admin-main">
    <router-view v-slot="{ Component }">
      <transition name="fade" mode="out-in">
        <!-- include 按组件名匹配:只有 OrderListView 被缓存 -->
        <keep-alive :include="['OrderListView']">
          <component :is="Component" />
        </keep-alive>
      </transition>
    </router-view>
  </el-main>
</template>

<style scoped>
.fade-enter-active,
.fade-leave-active { transition: opacity 0.15s ease; }
.fade-enter-from,
.fade-leave-to { opacity: 0; }
</style>
<!-- OrderListView.vue 必须显式声明组件名,keep-alive 的 include 才能命中 -->
<script setup>
defineOptions({ name: 'OrderListView' })
</script>

7.7 企业实战:权限路由 + 面包屑

面包屑来自 route.matched——它是从父到子的本条导航命中的所有路由记录数组。约定:meta.title 存在且不 hidden 的记录就是一级面包屑。

<!-- src/views/layout/components/Breadcrumb.vue —— 由 route.matched 派生 -->
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'

const route = useRoute()

const crumbs = computed(() =>
  route.matched
    .filter((r) => r.meta?.title && !r.meta?.hidden)
    .map((r) => ({
      title: r.meta.title,
      path: r.path,
      // 含 :id 的路径无法直接跳转(参数缺失),渲染为纯文本
      linkable: !r.path.includes(':'),
    })),
)
</script>

<template>
  <el-breadcrumb separator="/">
    <el-breadcrumb-item :to="{ path: '/dashboard' }">首页</el-breadcrumb-item>
    <el-breadcrumb-item v-for="c in crumbs" :key="c.path" :to="c.linkable ? c.path : undefined">
      {{ c.title }}
    </el-breadcrumb-item>
  </el-breadcrumb>
</template>

把面包屑放进 AdminLayout 的头部区域,再把挑战练习里的 meta.roles 守卫补上,你就得到了「权限路由 + 面包屑」的完整中后台骨架——这正是第 12 章里程碑 M1 的地基。

🛠 渐进式实战练习

练习 1 · 基础(10–20 min):搭出中后台骨架

任务描述:从零创建路由骨架——/login、外壳 Layout(侧边菜单 + 内容区)、/dashboard 与 /orders 两个懒加载子页面、404 兜底页;安装 Vue Router 并在 main.js 挂载。

提示:直接复用 §7.2 的三段代码;用 pnpm add vue-router@4 安装;页面组件先写占位内容。

验收标准:

  • [ ] 直达 http://localhost:5173/orders 能渲染出带侧边菜单的列表占位页;
  • [ ] DevTools Network 面板中,每个页面是独立 chunk(懒加载生效);
  • [ ] 访问任意不存在的路径(如 /xxx)落到 404 页;
  • [ ] 菜单切换时 default-active 高亮跟随。
参考答案(关键部分)
// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import { routes } from './routes'

export default createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes,
})
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

createApp(App).use(router).mount('#app')

routes 与 Layout 参考 §7.2 原文即可,注意 404 路由(/:pathMatch(.*)*)必须放在顶层。

练习 2 · 进阶(30–60 min):订单详情 + 面包屑 + 筛选回传

任务描述:给订单列表页加两个交互——①点击行进入 /orders/:id 详情页,面包屑显示「首页 / 订单详情」;②列表的筛选条件(关键词、状态、页码)同步到 URL query,刷新页面后筛选状态不丢,从详情页「返回」时回到原筛选。

提示:筛选变化时用 router.replace({ query: {...} }) 同步 URL(replace 不产生多余历史记录);onMounted 时从 route.query 恢复筛选;返回用 router.back()(历史栈里还是带筛选的列表 URL)。

验收标准:

  • [ ] /orders?status=approved&page=2 刷新后列表仍处于「已审核 + 第 2 页」;
  • [ ] 详情页面包屑两级正确;直接刷新详情页不报错;
  • [ ] 返回列表后筛选与页码与离开时一致。
参考答案(筛选与 URL 双向同步的核心)
// OrderListView.vue <script setup> 关键片段
import { reactive, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'

const route = useRoute()
const router = useRouter()

// 从 URL 恢复初始筛选(query 是字符串,注意转数字)
const filters = reactive({
  keyword: route.query.keyword || '',
  status: route.query.status || '',
  page: Number(route.query.page) || 1,
  pageSize: 20,
})

// 筛选变化 → 同步进 URL(不触发组件重新创建)
watch(
  () => ({ ...filters }),
  (next) => {
    router.replace({ query: { ...next } })
  },
)

返回按钮直接 router.back():因为列表页当时的 URL 本来就带着筛选。

练习 3 · 挑战(选做,60+ min):meta 权限守卫 + 403 页 + 缓存

任务描述:①给「系统设置」路由加 meta.roles: ['super_admin'],守卫里校验角色(角色先 mock 一个数组),无权限重定向到 /403;②新建 403 页;③给列表页加上 keep-alive 缓存与路由过渡动画。

提示:校验逻辑写在 beforeEach 里、登录拦截之后;to.meta.roles 是数组时用 some 判断交集;403 页也要加进白名单。

验收标准:

  • [ ] 无角色用户访问 /settings/members 被重定向到 /403,URL 带 from 参数;
  • [ ] super_admin 可以正常访问;
  • [ ] 列表页来回切换后筛选不重置(keep-alive 生效)。
参考答案(守卫完整版,第 11 章将在此基础上接入真实登录态)
// src/router/index.js(守卫部分)
router.beforeEach((to) => {
  const token = localStorage.getItem('qing_access_token')
  const mockRoles = ['operator'] // 第 11 章替换为 userStore.roles

  if (to.meta.public || PUBLIC_PATHS.includes(to.path)) return true
  if (!token) return { path: '/login', query: { redirect: to.fullPath } }

  // 页面级权限:meta.roles 声明「允许访问的角色」
  const required = to.meta.roles
  if (required?.length && !required.some((r) => mockRoles.includes(r))) {
    return { path: '/403', query: { from: to.fullPath } }
  }
  return true
})
<!-- src/views/error/ForbiddenView.vue -->
<template>
  <el-result icon="warning" title="403" sub-title="您没有访问该页面的权限">
    <template #extra>
      <el-button type="primary" @click="$router.replace('/dashboard')">回工作台</el-button>
    </template>
  </el-result>
</template>

⚠️ 常见易错点

易错点 1 · 详情页参数变化,组件「不更新」

/orders/1001 → /orders/1002 是同一个组件被复用,生命周期钩子(含 onMounted)不会重跑,页面停留在旧数据。

<!-- ❌ 只在 onMounted 拉数据:路由参数变了不会重新请求 -->
<script setup>
import { onMounted } from 'vue'
import { useRoute } from 'vue-router'

const route = useRoute()
onMounted(() => fetchOrderDetail(route.params.id))
</script>
<!-- ✅ 方案一(推荐):onBeforeRouteUpdate 响应参数变化 -->
<script setup>
import { onMounted, onBeforeRouteUpdate } from 'vue'
import { useRoute } from 'vue-router'

const route = useRoute()
function load(id) { fetchOrderDetail(id) }

onMounted(() => load(route.params.id))
onBeforeRouteUpdate((to, from) => {
  if (to.params.id !== from.params.id) load(to.params.id)
})
</script>

<!-- ✅ 方案二:router-view 上用完整路径作 key,强制重建组件(简单但丢失组件状态) -->
<!-- <router-view :key="$route.fullPath" /> -->

易错点 2 · 未声明的 params 一传就丢

// ❌ path 里没有声明 :id,params 会被丢弃(开发环境有告警),实际跳到 /orders
router.push({ name: 'order-list', params: { id: '1001' } })
// ✅ 路径参数必须在 path 里声明;临时数据用 query 或状态管理(第 08 章)
router.push({ name: 'order-detail', params: { id: '1001' } }) // path: 'orders/:id'
router.push({ name: 'order-list', query: { highlight: '1001' } })

易错点 3 · 守卫白名单漏配,登录页死循环

// ❌ /login 自身也要过守卫:无 token 时被重定向回 /login,无限循环
router.beforeEach((to) => {
  if (!token) return { path: '/login' } // 访问 /login 也命中这里
})
// ✅ 先放行公开路径,再做拦截
const PUBLIC_PATHS = ['/login', '/403', '/404']

router.beforeEach((to) => {
  if (to.meta.public || PUBLIC_PATHS.includes(to.path)) return true
  if (!token) return { path: '/login', query: { redirect: to.fullPath } }
  return true
})

易错点 4 · 懒加载少写了箭头函数

// ❌ import() 立即执行,component 拿到的是 Promise,页面渲染异常且失去分包意义
component: import('@/views/orders/OrderListView.vue')
// ✅ 传「函数」,由路由器在首次进入时调用
component: () => import('@/views/orders/OrderListView.vue')

易错点 5 · history 模式刷新 404

❌ 现象:开发环境一切正常(Vite dev server 自带回退),部署到 Nginx 后
   访问 /orders 刷新出现 404——因为服务器真的去找 /orders 这个文件。
# ✅ Nginx 配置 SPA 回退:找不到文件时返回 index.html,交给前端路由
location / {
  root /usr/share/nginx/html;
  try_files $uri $uri/ /index.html;
}

✅ 最佳实践

  1. 路由表是权限与菜单的单一数据源:meta 统一承载 title、public、roles、hidden,守卫、菜单、面包屑都从它派生,避免多处维护不一致。
  2. 跳转一律用命名路由:router.push({ name: 'order-detail', params }) 优于字符串拼接,路由表重构时 IDE 全局可搜。
  3. 页面组件全部懒加载,只有 Layout 与全局错误页考虑静态引入;用 vite build 产物验证分包。
  4. 详情页用 props: true 接参数,组件摆脱对 useRoute 的直接依赖,复用与测试更容易。
  5. 页面状态(筛选/分页/tab)进 query 而不是组件私有变量:刷新、分享、返回都天然可用。
  6. 守卫只做「拦截决策」,副作用(标题、埋点)放 afterEach;守卫逻辑保持纯函数式,方便单测。
  7. 缓存要白名单化:keep-alive :include 显式列出需要缓存的页面,并配 defineOptions({ name }),避免内存无限增长。
  8. 上线前自查 SPA 回退:history 模式必须确认 Nginx/网关配了 try_files ... /index.html。

📌 本章小结

  • 路由的本质是「URL ↔ 组件树」映射;history 模式美观但要求服务端 SPA 回退,hash 模式零运维成本。
  • 中后台骨架 = 父路由 Layout + children 子路由 + 二级 <router-view>;懒加载让每个页面独立分包。
  • params 表达资源身份(:id),query 表达页面状态(筛选、分页);两者都可恢复,前者靠 path 声明、后者天然在 URL。
  • 守卫 + meta 是权限路由的通用手法:白名单放行 → 登录拦截 → 角色校验 → 403。
  • 复用组件时参数变化要靠 onBeforeRouteUpdate(或 :key),这是中后台最高频的 bug 之一。

下一章预告:菜单、面包屑、守卫都就位了,但「当前登录人是谁、有什么角色」还散落在 localStorage 里。第 08 章用 Pinia 把用户态、字典缓存、订单暂存区管起来。

📄 第 08 章 · 状态管理:Pinia 实战 08-状态管理-Pinia实战.md

📍 阶段:中级阶段(第 07–12 章)|⏱ 预计学时:5 小时|🔗 前置:第 07 章

⬅ 上一章 | 📖 本版目录 | ➡ 下一章

💡 对照学习:本页是 Vue 版第 08 章。React 版对应章节为 第 08 章 · 状态管理:Redux Toolkit 与 Zustand。概念映射:defineStore ≈ RTK 的 createSlice + configureStore;getters ≈ selector;actions(含异步)≈ reducer + thunk;storeToRefs ≈ useSelector(按需订阅);持久化插件 ≈ redux-persist;Zustand 与 Pinia 同属「轻量、少样板」流派,理念相近。

🎯 学习目标

学完本章,你将能够:

  1. 用「本地 UI 态 / 服务端态 / 跨页共享态」三分法判断一段状态该放在组件内还是 Pinia;
  2. 用 defineStore 的 setup 写法创建 useUserStore、useDictStore 等 store,并遵守 useXxxStore 命名约定;
  3. 编写 getters 派生数据与 actions(同步 + 异步),并解释 getter 的缓存语义与失效条件;
  4. 正确使用 storeToRefs 与函数式访问,避免解构 store 导致的响应性丢失;
  5. 接入持久化插件缓存字典类低频数据,并说出哪些字段不适合持久化;
  6. 在一个 store 的 action 中调用另一个 store,实现跨 store 组合(订单暂存区依赖用户与字典);
  7. 使用 Vue Devtools 的 Pinia 面板追踪 state 变化与 action 调用链。

📖 核心知识点

8.1 何时需要全局状态:状态三分法

不是所有状态都值得进全局 store。企业项目里先给状态分类,再决定放哪:

类别 例子 归属 理由
本地 UI 态 弹窗开关、按钮 loading、表格多选 组件内 ref 生命周期与组件一致,全局化只会制造耦合
服务端态 订单列表、订单详情、客户下拉选项 请求层 + composable(第 09 章) 有缓存、失效、竞态等专属问题,混进 store 会重复造轮子
跨页共享态 登录用户与权限、字典缓存、新建订单草稿 Pinia 多页面读写、生命周期跨越路由跳转

「轻订单」里三个典型场景正好各占一类:用户态(每个守卫、每个按钮都要读)、字典缓存(订单状态下拉在列表、详情、表单三处复用)、订单暂存区(表单写到一半跳去查商品,回来不能丢)。本章就实现这三件套。

React 对照:这个三分法同样适用——组件 useState / TanStack Query / Redux(或 Context),两版思想完全一致,差别只在工具。

8.2 defineStore:两种写法

Pinia 的 store 由三部分组成:state(数据)、getters(派生值)、actions(修改逻辑)。两种定义写法,语义等价:

// 写法一:Options 写法(与组件 Options API 同构,本教程仅展示便于读懂存量代码)
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  getters: { double: (state) => state.count * 2 },
  actions: { increment() { this.count++ } },
})
// 写法二:Setup 写法(本教程默认):就是「一个组合式函数」
// 好处:天然获得 ref/computed 的完整能力,逻辑组织方式与组件一致
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', () => {
  // state:ref 就是 state
  const count = ref(0)
  // getters:computed 就是 getter
  const double = computed(() => count.value * 2)
  // actions:普通函数
  function increment() { count.value++ }

  // 关键:return 暴露出去的才会出现在 store 上(易错点 2)
  return { count, double, increment }
})

约定:store id 用小驼峰字符串(如 'user'、'dict'),组合式函数命名 useXxxStore,与普通 composable 区分开。

// src/main.js —— 安装 Pinia(必须在使用任何 store 之前)
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const app = createApp(App)
app.use(createPinia())
app.mount('#app')

8.3 getters 与 actions:派生与修改

getters 是带缓存的 computed:依赖不变就不重算;依赖一变才重算。所以「能派生就不存储」——存两份数据必然出现不一致。

// src/stores/user.js —— 用户态 store(轻订单核心 store)
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', () => {
  // ---------- state ----------
  const accessToken = ref('')
  const refreshToken = ref('')
  const profile = ref(null) // { id, name, roles: string[], permissions: string[] }

  // ---------- getters(派生,不存储) ----------
  const roles = computed(() => profile.value?.roles ?? [])
  const isSuperAdmin = computed(() => roles.value.includes('super_admin'))
  const isLoggedIn = computed(() => Boolean(accessToken.value))

  /** 按钮级权限判断:super_admin 拥有一切(附录A §2 三级权限模型) */
  function hasPermission(code) {
    return isSuperAdmin.value || (profile.value?.permissions ?? []).includes(code)
  }

  // ---------- actions ----------
  function setTokens({ accessToken: at, refreshToken: rt }) {
    accessToken.value = at
    refreshToken.value = rt
  }

  function setLoginResult(data) {
    // data: { accessToken, refreshToken, user }
    setTokens(data)
    profile.value = data.user
  }

  /** 登出清理:token 与个人信息全部归零(第 11 章完善跳转逻辑) */
  function clearAuth() {
    accessToken.value = ''
    refreshToken.value = ''
    profile.value = null
  }

  return {
    accessToken, refreshToken, profile,
    roles, isSuperAdmin, isLoggedIn, hasPermission,
    setTokens, setLoginResult, clearAuth,
  }
})

两个 action 相关的实用 API:

  • $patch:一次批量修改多处 state,Devtools 里合并为一条记录;
  • $reset:只有 Options 写法的 store 自带 $reset();setup 写法的 store 需要自己写 clearAuth() 这类重置 action(上面已经这么做了)。
userStore.$patch({ accessToken: 'x', refreshToken: 'y' }) // Options store 批量改

8.4 在组件里用 store:响应性规则与 storeToRefs

store 实例本身就是一个 reactive 对象。这意味着直接解构会丢响应性(第 04 章讲过的坑,在 store 上再次出现):

<!-- ❌ 解构即取值:userName 变成一次性快照,登录后模板不更新 -->
<script setup>
import { useUserStore } from '@/stores/user'
const store = useUserStore()
const { profile } = store            // 丢响应性!
const userName = profile?.name       // 更是一次性的字符串
</script>

<template>
  <span>{{ userName }}</span>
</template>
<!-- ✅ 用 storeToRefs 解构「响应式部分」,action 是函数、直接解构即可 -->
<script setup>
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'

const store = useUserStore()
// state / getters 必须 storeToRefs
const { profile, isSuperAdmin } = storeToRefs(store)
// actions 是普通函数,直接从 store 上解构没有问题
const { clearAuth, hasPermission } = store
</script>

<template>
  <span v-if="profile">{{ profile.name }}</span>
  <el-tag v-if="isSuperAdmin" type="danger">超管</el-tag>
</template>

记忆口诀:「响应式的要包一层,函数的直接拿」。偶尔读一次值的场景也可以函数式访问 store.profile?.name,不借助解构。

8.5 store 之间的组合:订单暂存区依赖字典

「新建订单表单写到一半,用户跳去商品目录查价格,回来草稿还在」——这是中后台体验的关键细节,用 Pinia 做订单暂存区最合适。跨 store 调用很简单:在 action 里直接调用另一个 useXxxStore()。

// src/stores/orderDraft.js —— 订单暂存区(跨 store 组合示例)
import { reactive, watch } from 'vue'
import { defineStore } from 'pinia'
import { useDictStore } from './dict'

const DRAFT_VERSION = 1 // 结构变更时递增,旧草稿直接丢弃,避免字段错位

function createEmptyDraft() {
  return {
    version: DRAFT_VERSION,
    customerId: null,
    customerName: '',
    wholeDiscountPercent: 100,
    remark: '',
    items: [], // 与附录A OrderItem 同构
  }
}

export const useOrderDraftStore = defineStore('orderDraft', () => {
  const draft = reactive(createEmptyDraft())

  /** 进入新建订单页时调用:恢复草稿 + 确保状态字典就绪(跨 store 调用) */
  async function initDraft() {
    const dictStore = useDictStore()
    await dictStore.ensureOrderStatusDict() // 调用另一个 store 的 action
    return draft.version === DRAFT_VERSION && (draft.customerId || draft.items.length)
      ? draft // 有未提交草稿
      : null
  }

  function saveDraft(partial) {
    Object.assign(draft, partial, { version: DRAFT_VERSION })
  }

  function clear() {
    Object.assign(draft, createEmptyDraft())
  }

  return { draft, initDraft, saveDraft, clear }
})

dict store 在 §8.7 给出。这里先看清组合规则:store 之间的引用是惰性的——在被调用的 action 执行时才 useDictStore(),天然避免了模块加载期的循环依赖问题。

8.6 插件:数据持久化

字典这类「不常变、到处用」的数据,每次进页面都请求一遍太浪费。用 pinia-plugin-persistedstate 把指定字段持久化到 localStorage:

pnpm add pinia-plugin-persistedstate
// src/main.js —— 注册持久化插件
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
// setup 写法的 store:第三个参数传 store 配置,只持久化字典字段
export const useDictStore = defineStore('dict', () => {
  // ... state/getters/actions 见 §8.7
}, {
  persist: {
    pick: ['orderStatusOptions', 'loadedAt'], // 白名单持久化,见易错点 5
  },
})

持久化的取舍(企业评审高频话题):

  • ✅ 适合持久化:字典、用户偏好(表格列宽、主题)、低频配置;
  • ⚠️ 谨慎持久化:refreshToken(localStorage 可被 XSS 读取,第 11 章专门讲权衡);
  • ❌ 不要持久化:列表等大体量服务端数据(容易过期成脏数据)、含他人隐私的敏感信息。

8.7 企业场景三件套:字典缓存(带过期与并发去重)

字典缓存的完整版有三个工程细节,缺一不可:过期时间(避免脏数据)、并发去重(三个页面同时首载只发一次请求)、强制刷新(字典被管理员改了之后)。

// src/stores/dict.js —— 字典缓存:过期 + 并发去重 + 强制刷新
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { fetchDict } from '@/api/dict'

const EXPIRE_MS = 5 * 60 * 1000 // 5 分钟过期

export const useDictStore = defineStore('dict', () => {
  // state
  const orderStatusOptions = ref([]) // [{ value: 'draft', label: '草稿' }, ...]
  const loadedAt = ref(0)

  // 模块级变量:进行中的请求 Promise(不放进 state,因为它不是「数据」)
  let pendingPromise = null

  /**
   * 确保订单状态字典可用。三个分支:
   * 1. 未过期且已有数据 → 直接返回缓存;
   * 2. 已有请求在飞 → 复用同一个 Promise(并发去重);
   * 3. 否则发起请求并记录时间戳。
   */
  async function ensureOrderStatusDict(force = false) {
    const fresh = Date.now() - loadedAt.value < EXPIRE_MS
    if (!force && fresh && orderStatusOptions.value.length) {
      return orderStatusOptions.value
    }
    if (!pendingPromise) {
      pendingPromise = fetchDict('order_status')
        .then((list) => {
          orderStatusOptions.value = list
          loadedAt.value = Date.now()
          return list
        })
        .finally(() => { pendingPromise = null })
    }
    return pendingPromise
  }

  return { orderStatusOptions, loadedAt, ensureOrderStatusDict }
}, {
  persist: { pick: ['orderStatusOptions', 'loadedAt'] },
})

Devtools 调试:Vue Devtools 的 Pinia 面板可以看每个 store 的当前值、手动改 state、回放 action。开发时给每个 action 起动词化名字(setLoginResult、clearAuth),时间线里一眼可读;这是排查「谁改了这个值」的第一工具。

🛠 渐进式实战练习

练习 1 · 基础(10–20 min):用户态 store

任务描述:实现 §8.4 的 useUserStore(可直接抄),再写一个 UserBadge.vue 组件:显示用户名与超管标签,提供「模拟登录 / 退出」两个按钮(不调接口,直接调用 action 写入假数据)。

提示:假数据结构对照附录A的权限模型:{ name: '运营小张', roles: ['operator'], permissions: ['order:create', 'order:edit'] }。

验收标准:

  • [ ] 点击「模拟登录」后用户名与角色标签立即渲染;
  • [ ] 打开 Vue Devtools → Pinia 面板,能看到 user store 的 state 变化记录;
  • [ ] 退出后 isLoggedIn 变回 false,界面回到未登录占位。
参考答案(组件部分)
<!-- src/components/UserBadge.vue -->
<script setup>
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'

const store = useUserStore()
const { profile, isLoggedIn, isSuperAdmin } = storeToRefs(store)

const mockUser = {
  id: 'u_001',
  name: '运营小张',
  roles: ['operator'],
  permissions: ['order:create', 'order:edit'],
}

function mockLogin() {
  store.setLoginResult({ accessToken: 'mock-at', refreshToken: 'mock-rt', user: mockUser })
}
</script>

<template>
  <div class="user-badge">
    <template v-if="isLoggedIn">
      <span>{{ profile.name }}</span>
      <el-tag v-if="isSuperAdmin" type="danger">超管</el-tag>
      <el-button size="small" text @click="store.clearAuth()">退出</el-button>
    </template>
    <el-button v-else size="small" @click="mockLogin">模拟登录</el-button>
  </div>
</template>

练习 2 · 进阶(30–60 min):字典缓存与并发去重

任务描述:实现 §8.7 的 useDictStore,并写一个 StatusSelect.vue 通用下拉(订单状态筛选用)。要求:三个组件实例同时挂载,Network 面板只出现一次字典请求;5 分钟后再次打开页面会重新请求。

提示:用 pendingPromise 做并发去重(§8.7 已给答案骨架);验证过期可用「把 EXPIRE_MS 临时改成 5 秒」来加速实验;fetchDict 先用假 promise 代替接口(第 09 章接入真接口)。

验收标准:

  • [ ] 首次打开下拉发起 1 次请求,三个实例共享同一份选项;
  • [ ] 重复打开不重复请求;点「强制刷新」绕过缓存重新请求;
  • [ ] 刷新浏览器后下拉直接有值(持久化生效)。
参考答案(StatusSelect 组件)
<!-- src/components/StatusSelect.vue -->
<script setup>
import { onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useDictStore } from '@/stores/dict'

const dictStore = useDictStore()
const { orderStatusOptions } = storeToRefs(dictStore)

onMounted(() => dictStore.ensureOrderStatusDict())

defineProps({ modelValue: { type: String, default: '' } })
defineEmits(['update:modelValue'])
</script>

<template>
  <el-select
    :model-value="modelValue"
    clearable
    placeholder="全部状态"
    style="width: 160px"
    @update:model-value="$emit('update:modelValue', $event)"
  >
    <el-option
      v-for="opt in orderStatusOptions"
      :key="opt.value"
      :label="opt.label"
      :value="opt.value"
    />
  </el-select>
</template>

练习 3 · 挑战(选做,60+ min):订单暂存区完整闭环

任务描述:实现 §8.5 的 useOrderDraftStore 并打通闭环:新建订单页每 3 秒自动把表单快照 saveDraft 进暂存区;中途跳走再回来,弹出「检测到未提交草稿,是否恢复?」确认框(ElMessageBox.confirm);提交成功或用户主动放弃后 clear()。要处理「草稿版本不一致直接丢弃」的兼容逻辑。

提示:定时器要在组件卸载时清理(第 05 章的副作用清理知识);版本判断放 initDraft 返回值里,由页面决定弹不弹窗。

验收标准:

  • [ ] 填写表单 → 离开 → 回来 → 确认恢复后字段完整回填;
  • [ ] 把 DRAFT_VERSION 手动改大后,旧草稿被静默丢弃;
  • [ ] 提交成功后暂存区清空,Devtools 中可见。
参考答案(表单侧的接入片段)
// OrderFormView.vue <script setup> 关键片段(完整表单见第 10 章)
import { onMounted, onUnmounted, watch } from 'vue'
import { ElMessageBox } from 'element-plus'
import { useOrderDraftStore } from '@/stores/orderDraft'

const draftStore = useOrderDraftStore()
let timer = null

onMounted(async () => {
  const recovered = await draftStore.initDraft()
  if (recovered) {
    const { value } = await ElMessageBox.confirm('检测到未提交草稿,是否恢复?', '提示', {
      confirmButtonText: '恢复',
      cancelButtonText: '放弃',
    }).catch(() => ({ value: false }))
    if (value) Object.assign(form, recovered)
    else draftStore.clear()
  }
  timer = setInterval(() => draftStore.saveDraft({ ...form }), 3000)
})

onUnmounted(() => clearInterval(timer))

watch(() => form.items.length, () => draftStore.saveDraft({ ...form }))

⚠️ 常见易错点

易错点 1 · 解构 store 丢失响应性

// ❌ 解构即取值:profile 是快照,登录后界面不更新,且很难排查
const { profile, isSuperAdmin } = useUserStore()
// ✅ storeToRefs 包住响应式部分;actions 是函数,可直接解构
import { storeToRefs } from 'pinia'
const store = useUserStore()
const { profile, isSuperAdmin } = storeToRefs(store)
const { clearAuth, hasPermission } = store

易错点 2 · setup store 忘记 return

// ❌ 定义了但没 return:store 上没有 profile,组件读到 undefined
export const useUserStore = defineStore('user', () => {
  const profile = ref(null)
  function clearAuth() { profile.value = null }
  // ← 忘了 return
})
// ✅ setup store 的「return 即导出」,漏一个字段就少一个能力
return { profile, clearAuth }

易错点 3 · 在 getter 里发请求、写状态

// ❌ getter 应该是纯派生值:发请求 + 写 state 造成不可预测的重放副作用
const fullName = computed(async () => {
  const detail = await fetchUserDetail(userId.value)
  nickname.value = detail.nick
  return detail.name
})
// ✅ 异步逻辑放 action,getter 只做同步派生
const fullName = computed(() => `${profile.value?.name ?? ''}(${nickname.value})`)

async function loadProfile() {
  const detail = await fetchUserDetail(userId.value)
  nickname.value = detail.nick
}

易错点 4 · watch 监听 store 对象:引用没变就不触发

// ❌ getter 返回对象:每次求值都产生新引用反而乱触发;
//    换成直接 watch 对象本身,深层修改又收不到通知
watch(() => draftStore.draft, (d) => console.log(d))
// ✅ 二选一:watch reactive 对象本身(默认深层);或明确写出依赖字段
watch(() => draftStore.draft, (d) => saveLocal(d))                 // reactive 对象:深层生效
watch(() => draftStore.draft.items.length, saveLocal)              // 精确监听:只在行数变化时触发

易错点 5 · 持久化白名单失守

// ❌ 整个 store 持久化:token 明文进了 localStorage(XSS 可窃取),
//    且把大列表/时间戳一并写盘,体积与脏数据风险都来了
persist: true
// ✅ 只挑低频、非敏感、可再生的字段;token 策略见第 11 章
persist: { pick: ['orderStatusOptions', 'loadedAt'] }

易错点 6 · 跨 store 顶层调用引发初始化顺序问题

// ❌ 在 store 的 setup 顶层(而非 action 内)调用另一个 store:
//    模块加载顺序变化时可能读到未初始化的依赖
export const useOrderDraftStore = defineStore('orderDraft', () => {
  const dictStore = useDictStore() // 顶层立即实例化
  // ...
})
// ✅ 在 action / getter 内惰性调用:首次使用时才实例化,顺序无忧
export const useOrderDraftStore = defineStore('orderDraft', () => {
  async function initDraft() {
    const dictStore = useDictStore() // 用到时才拿
    await dictStore.ensureOrderStatusDict()
  }
  return { initDraft }
})

✅ 最佳实践

  1. 先三分法再写 store:组件态留在组件、服务端态交给请求层,Pinia 只收「跨页共享态」——store 数量控制在个位数到十几个,越少越好治理。
  2. 一个业务域一个 store:user / dict / orderDraft,命名 useXxxStore,id 与文件名一致,Devtools 里可读。
  3. 能派生不存储:角色列表、是否超管这类值一律 getter 派生,杜绝同一数据两处存储。
  4. setup 写法为默认:与 <script setup> 心智一致;读存量 Options store 时能看懂即可。
  5. setup store 自定义重置 action(如 clearAuth),不依赖 $reset,两种写法行为统一。
  6. 持久化必须白名单:pick 明确字段;敏感字段(token)的存储策略集中到第 11 章统一决策。
  7. 跨 store 惰性调用:在 action 内部 useXxxStore(),避免加载期耦合与循环依赖。
  8. 用 Devtools 做「数据审计」:action 命名动词化,时间线能回答「谁在什么时候改了它」。

📌 本章小结

  • 状态先分类再安放:本地 UI 态 → 组件,服务端态 → 请求层,跨页共享态 → Pinia。
  • defineStore 两种写法语义等价;setup 写法与组合式函数同构,是本教程默认;setup store 没有 $reset,要自己写重置 action。
  • 解构 store 必须用 storeToRefs(state/getters),actions 直接解构;这是第 04 章「解构丢响应性」在 store 上的再现。
  • 字典缓存三件套——过期、并发去重、强制刷新——是所有「带缓存的共享数据」的通用模式。
  • 持久化是便利与安全的交换:白名单字段、远离 token,存储安全第 11 章展开。

下一章预告:store 里已经准备好「装服务端数据」的位置,但真正的请求层还没影子。第 09 章封装 axios:token 注入、错误归一化、40102 无感刷新、防抖与竞态,最后产出贯穿后续章节的 useOrderList。

📄 第 09 章 · 服务端交互与接口层设计 09-服务端交互与接口层设计.md

📍 阶段:中级阶段(第 07–12 章)|⏱ 预计学时:7 小时|🔗 前置:第 08 章

⬅ 上一章 | 📖 本版目录 | ➡ 下一章

💡 对照学习:本页是 Vue 版第 09 章。React 版对应章节为 第 09 章 · 服务端交互与 TanStack Query。概念映射:axios 实例与拦截器两版完全相同;本页自研的 useOrderList ≈ TanStack Query 的 useQuery(缓存、去重、竞态取消);AbortController 取消上一次请求 ≈ Query key 变化自动切换;Vue 生态也有官方适配的 @tanstack/vue-query 可选。

🎯 学习目标

学完本章,你将能够:

  1. 说出接口层的四层分工(环境变量 → request 实例 → api 模块 → composable),并解释每层的职责边界;
  2. 封装 axios 实例:baseURL 从 import.meta.env.VITE_* 读取、统一超时与开发期代理;
  3. 在请求拦截器中注入 Authorization: Bearer 令牌,在响应拦截器中解包「统一响应体」;
  4. 实现错误归一化:把业务码(40101/40301/40901/5xxxx)收敛为统一的 ApiError,组件层不重复 toast;
  5. 实现 40102(token 过期)的无感刷新:单飞锁防止并发刷新、原请求重放、死循环防护;
  6. 用 AbortController 与防抖解决搜索场景的竞态与请求风暴;
  7. 为幂等的 GET 请求添加有限次指数退避重试,并解释为什么 POST 不能自动重试;
  8. 说出金额用「分」的原因,并写出正确的行金额与总额计算、展示转换。

📖 核心知识点

9.1 接口层的四层分工

初级写法是把 axios.get(...) 直接散落在组件里。请求一多就会遇到:baseURL 写死、token 到处拼、401 处理复制八份、错误提示双重弹出。企业项目的答案是分层:

组件 / 视图(OrderListView.vue)
   │  只关心「要什么数据、处于什么状态」
   ▼
composables/(useOrderList.js)      —— 数据编排:分页、防抖、竞态、loading
   │  只关心「怎么把接口组织成状态」
   ▼
api/(orders.js、auth.js…)          —— 接口映射:一个业务域一个文件,纯函数
   │  只关心「怎么发」
   ▼
api/request.js                       —— axios 实例:token 注入、错误归一化、无感刷新
   │
   ▼
环境变量(.env.*)                   —— base URL、功能开关

约定一条铁律:组件不允许直接 import axios。评审时见到直接引 axios 的组件直接打回。

9.2 axios 实例与环境变量

接口约定来自附录A §5:Base URL https://api.qingorder.example.com/api/v1(教学约定),开发期用 Vite 代理指向本地 mock。baseURL 永远来自环境变量:

# .env.development —— 开发环境
VITE_API_BASE_URL=/api/v1
VITE_USE_MOCK=true

# .env.production —— 生产环境
VITE_API_BASE_URL=https://api.qingorder.example.com/api/v1
VITE_USE_MOCK=false
// vite.config.js(节选)—— 开发期把 /api/v1 代理到本地 mock/后端,同时规避浏览器跨域
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  server: {
    proxy: {
      '/api/v1': {
        target: 'http://localhost:3000', // 本地 mock 服务(第 12 章给出实现)
        changeOrigin: true,
      },
    },
  },
})
// src/api/request.js —— 第一步:创建实例
import axios from 'axios'

const service = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 10000, // 附录A 要求后台首屏可交互 ≤ 3s,超时阈值不宜再宽
})

export default service

9.3 请求拦截器:token 注入

附录A §5 约定认证头为 Authorization: Bearer <accessToken>。注入动作全站只需要写一次:

// src/api/request.js —— 请求拦截器:注入 token
import { useUserStore } from '@/stores/user'

service.interceptors.request.use((config) => {
  // 注意:在拦截器回调里(运行时)才获取 store,保证此时 Pinia 已安装
  const userStore = useUserStore()
  if (userStore.accessToken) {
    config.headers.Authorization = `Bearer ${userStore.accessToken}`
  }
  return config
})

9.4 响应拦截器:解包与错误归一化

后端统一响应体 { code, message, data }(附录A §5)。若每个组件自己判断 code,错误处理会碎片化。归一化的目标是:组件里只面对两种结果——data 或统一的 ApiError:

// src/api/request.js —— 统一业务错误对象
export class ApiError extends Error {
  constructor({ code, message, httpStatus }) {
    super(message)
    this.name = 'ApiError'
    this.code = code        // 业务码:40101/40102/40301/40901/5xxxx;网络错误为 -1
    this.httpStatus = httpStatus
  }
}
// src/api/request.js —— 响应拦截器(骨架;40102 分支在 §9.5 单独讲)
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'

service.interceptors.response.use(
  (response) => {
    const { code, message, data } = response.data ?? {}
    if (code === 0) return data // 成功:直接返回 data,调用方拿到「裸数据」

    if (code === 40102) return onTokenExpired(response.config) // 无感刷新,见 §9.5

    if (code === 40101) {
      // 未登录:清状态,交给路由守卫在下一次导航时送回登录页
      useUserStore().clearAuth()
      return Promise.reject(new ApiError({ code, message: '请先登录', httpStatus: response.status }))
    }

    // 其余业务错误(40301/40901/5xxxx…):统一 toast 一次
    ElMessage.error(message || '请求失败')
    return Promise.reject(new ApiError({ code, message, httpStatus: response.status }))
  },
  (error) => {
    // HTTP 层错误:超时、断网、4xx/5xx
    if (error.code === 'ERR_CANCELED') return Promise.reject(error) // 主动取消不算错误
    const status = error.response?.status
    const bodyCode = error.response?.data?.code
    if (bodyCode === 40102) return onTokenExpired(error.config)

    const message = error.response?.data?.message
      || (error.code === 'ECONNABORTED' ? '请求超时,请稍后重试' : '网络异常,请稍后重试')
    ElMessage.error(message)
    return Promise.reject(new ApiError({ code: bodyCode ?? status ?? -1, message, httpStatus: status }))
  },
)

错误归一化约定(写进团队规范):

  1. toast 只在请求层弹一次,组件的 catch 默认只处理「界面回退」(关弹窗、恢复按钮态),不再重复提示;
  2. 组件判断错误一律用 error instanceof ApiError && error.code === 40901 这类业务码判断,不读 error.message 字符串;
  3. 特殊页面要静默处理时,在请求 config 上传 silent: true 标记,拦截器读它跳过 toast(练习 1 实现)。

9.5 40102 无感刷新:单飞锁 + 请求重放

时序:accessToken 过期(附录A:2 小时)→ 后端返回 code: 40102 → 前端用 refreshToken(7 天)换新 token → 把过期的原请求用新 token 重放一遍,用户全程无感。三个工程细节:

  1. 单飞锁(single-flight):页面并发 10 个请求同时 40102,只允许发起 1 次刷新,其余等待同一个 Promise;
  2. 重放:刷新成功后,把每个过期请求用新 token 原样重发;
  3. 死循环防护:重放请求带上 _retried 标记,若再次 40102(refreshToken 也过期了)直接登出。
// src/api/request.js —— 无感刷新(完整实现)
let refreshingPromise = null // 单飞锁:同一时刻至多一个刷新请求

async function refreshAccessToken() {
  const userStore = useUserStore()
  // 用裸 axios(不走拦截器),避免刷新请求自身被 40102 逻辑捕获
  const { data: body } = await axios.post(
    `${import.meta.env.VITE_API_BASE_URL}/auth/refresh`,
    { refreshToken: userStore.refreshToken },
  )
  if (body.code !== 0) {
    userStore.clearAuth()
    throw new ApiError({ code: 40101, message: '登录已过期,请重新登录' })
  }
  userStore.setTokens(body.data) // { accessToken, refreshToken }
  return body.data.accessToken
}

function onTokenExpired(config) {
  const userStore = useUserStore()

  // 死循环防护:重放过的请求再次 40102 → refreshToken 也失效了
  if (config._retried) {
    userStore.clearAuth()
    return Promise.reject(new ApiError({ code: 40101, message: '登录已过期,请重新登录' }))
  }

  // 单飞:第一个请求创建刷新 Promise,后续请求共享等待
  if (!refreshingPromise) {
    refreshingPromise = refreshAccessToken().finally(() => {
      refreshingPromise = null // 无论成败都释放锁
    })
  }

  return refreshingPromise.then(() => {
    config._retried = true
    config.headers.Authorization = `Bearer ${userStore.accessToken}`
    return service(config) // 用新 token 重放原请求(仍会走一遍拦截器)
  })
}

React 对照:TanStack Query 内置了重试与失效,但 401 刷新通常仍写在 fetch 封装或拦截器里,思路与本节一致——队列 + 单飞 + 重放是通用解。

9.6 取消、防抖与竞态

竞态:输入框连续搜索「华为」「华为云」,两个请求并发,若先发的「华为」后返回,就会覆盖正确结果。请求风暴:每敲一个字发一次请求。两件事要分开治:

// src/utils/debounce.js —— 防抖:停止输入 300ms 才真正触发
export function debounce(fn, wait = 300) {
  let timer = null
  return function debounced(...args) {
    clearTimeout(timer)
    timer = setTimeout(() => fn.apply(this, args), wait)
  }
}
// 竞态治理:每次搜索前取消上一次未完成的请求(AbortController)
let searchController = null

async function onSearch(keyword) {
  searchController?.abort() // 取消上一次(响应拦截器对 ERR_CANCELED 静默放行)
  searchController = new AbortController()

  const data = await request.get('/customers', {
    params: { keyword },
    signal: searchController.signal,
  })
  customerOptions.value = data.list // 只有「最新一次」的结果会走到这里
}

另一个思路是序号比对(不依赖取消 API):发起前记录 const seq = ++latestSeq,返回时 if (seq !== latestSeq) return。两者都行,AbortController 还能省掉无谓的网络流量,优先选用。

9.7 超时与重试

超时在实例上统一配置(§9.2)。重试要克制:只重试幂等请求(GET),指数退避拉开间隔;POST 一律不自动重试——否则可能创建出两笔订单。

// src/api/request.js —— GET 专用重试(封装在 api 模块内部使用)
async function getWithRetry(url, config = {}, retries = 2) {
  try {
    return await service.get(url, config)
  } catch (e) {
    const retriable = e.httpStatus >= 500 || e.code === 'ECONNABORTED' // 只对服务端错误与超时重试
    if (retries > 0 && retriable) {
      const delay = (2 - retries + 1) * 500 // 500ms → 1000ms 指数退避
      await new Promise((resolve) => setTimeout(resolve, delay))
      return getWithRetry(url, config, retries - 1)
    }
    throw e
  }
}
export { getWithRetry }

9.8 API 模块化 + useOrderList 完整示例

先写订单域的 api 模块——只做「参数到 URL」的映射,一行业务逻辑都不放:

// src/api/orders.js —— 订单域接口(对照附录A §5 接口约定)
import request, { getWithRetry } from './request'

/** 分页查询订单 params: { page, pageSize, keyword, status, startDate, endDate, sort, order } */
export function fetchOrders(params, config = {}) {
  return getWithRetry('/orders', { params, ...config })
}

/** 订单详情 */
export function fetchOrderById(id, config = {}) {
  return getWithRetry(`/orders/${id}`, config)
}

/** 新建订单(草稿) payload 含 items: [{ skuId, quantity, unitPrice(分), discountPercent }] */
export function createOrder(payload) {
  return request.post('/orders', payload)
}

/** 提交审核:draft → pending_review */
export function submitOrder(id) {
  return request.post(`/orders/${id}/submit`)
}

/** 审批(仅 finance):action 为 'approve' | 'reject' */
export function reviewOrder(id, action, comment = '') {
  return request.post(`/orders/${id}/${action}`, { comment })
}

/** 取消订单:任何非终态 → cancelled,需填原因 */
export function cancelOrder(id, reason) {
  return request.post(`/orders/${id}/cancel`, { reason })
}

/** 批量导出选中订单为 CSV(返回 Blob,由调用方触发下载) */
export function exportOrdersCsv(ids) {
  return request.post('/orders/export', { ids }, { responseType: 'blob' })
}

金额工具——附录A 明确「金额单位为分」,原因是浮点误差(见易错点 1):

// src/utils/money.js —— 金额一律以「分」(整数)存储与运算,仅在展示层转「元」
/** 行金额 = 数量 × 单价 × 行折扣,四舍五入回「分」的整数 */
export function calcItemAmountFen({ quantity, unitPrice, discountPercent }) {
  return Math.round(quantity * unitPrice * (discountPercent / 100))
}

/** 订单总额:分整数求和——整数加法不会引入浮点误差 */
export function calcOrderTotalFen(items) {
  return items.reduce((sum, item) => sum + calcItemAmountFen(item), 0)
}

/** 展示层:分 → 元字符串 */
export function fenToYuanStr(fen) {
  return (Math.round(fen) / 100).toFixed(2)
}

最后是贯穿后续章节的 useOrderList:把分页、筛选、防抖、竞态、loading 全部收进一个 composable,组件只剩「用」:

// src/composables/useOrderList.js —— 订单列表数据编排(本章集大成之作)
import { reactive, ref, computed, watch, onUnmounted } from 'vue'
import { fetchOrders } from '@/api/orders'
import { debounce } from '@/utils/debounce'

/** @param {object} initialQuery 初始筛选(通常来自 URL query,见第 07 章练习 2) */
export function useOrderList(initialQuery = {}) {
  // ---- 状态 ----
  const filters = reactive({
    keyword: initialQuery.keyword || '',
    status: initialQuery.status || '',
    startDate: initialQuery.startDate || '',
    endDate: initialQuery.endDate || '',
    page: Number(initialQuery.page) || 1,
    pageSize: 20, // 附录A:分页 20/页
  })
  const list = ref([])
  const total = ref(0)
  const loading = ref(false)
  const error = ref(null)

  // ---- 竞态:保存「最新请求」的控制器 ----
  let controller = null

  async function load() {
    controller?.abort()
    controller = new AbortController()
    loading.value = true
    error.value = null
    try {
      const data = await fetchOrders(
        {
          page: filters.page,
          pageSize: filters.pageSize,
          keyword: filters.keyword || undefined,
          status: filters.status || undefined,
          startDate: filters.startDate || undefined,
          endDate: filters.endDate || undefined,
        },
        { signal: controller.signal },
      )
      list.value = data.list
      total.value = data.total
    } catch (e) {
      if (e.code !== 'ERR_CANCELED' && e.name !== 'CanceledError') error.value = e
    } finally {
      loading.value = false
    }
  }

  // ---- 关键词防抖:停止输入 300ms 才搜索,并回到第 1 页 ----
  const searchByKeyword = debounce(() => {
    filters.page = 1
    load()
  }, 300)

  watch(() => filters.keyword, searchByKeyword)
  watch(() => [filters.status, filters.startDate, filters.endDate], () => {
    filters.page = 1
    load()
  })
  watch(() => [filters.page, filters.pageSize], load) // 翻页立即加载

  // 组件卸载时取消在途请求,避免晚到的响应写入已卸载组件
  onUnmounted(() => controller?.abort())

  // ---- 派生:总页数(getters 思想的组件化) ----
  const totalPages = computed(() => Math.ceil(total.value / filters.pageSize) || 1)

  load() // 进入页面即首查
  return { filters, list, total, totalPages, loading, error, reload: load }
}

组件侧的消费体验:

<!-- src/views/orders/OrderListView.vue —— 消费 useOrderList(节选) -->
<script setup>
import { useOrderList } from '@/composables/useOrderList'
import { fenToYuanStr } from '@/utils/money'
import StatusSelect from '@/components/StatusSelect.vue'

const { filters, list, total, loading, reload } = useOrderList()

function handlePageChange(page) {
  filters.page = page // watch 自动触发请求
}
</script>

<template>
  <el-card>
    <el-input v-model="filters.keyword" placeholder="搜索订单号/客户名" style="width: 240px" />
    <StatusSelect v-model="filters.status" />
    <el-table v-loading="loading" :data="list">
      <el-table-column prop="orderNo" label="订单号" width="180" />
      <el-table-column prop="customerName" label="客户" />
      <el-table-column label="订单总额(元)" width="140">
        <template #default="{ row }">{{ fenToYuanStr(row.totalAmount) }}</template>
      </el-table-column>
    </el-table>
    <el-pagination
      :current-page="filters.page"
      :page-size="filters.pageSize"
      :total="total"
      layout="total, prev, pager, next"
      @current-change="handlePageChange"
    />
  </el-card>
</template>

🛠 渐进式实战练习

练习 1 · 基础(10–20 min):request 实例 + 静默选项

任务描述:完成 src/api/request.js 的实例创建、token 注入、code === 0 解包与业务错误 toast;再支持 config.silent: true——调用方声明后,拦截器对该请求跳过 toast(错误仍正常 reject)。

提示:silent 挂在请求 config 上,响应拦截器里用 response.config.silent / error.config.silent 读取。

验收标准:

  • [ ] 成功请求在组件里直接拿到 data;
  • [ ] 模拟 code: 40901 的响应,普通请求弹出 toast,silent 请求不弹但 catch 里能拿到 ApiError;
  • [ ] 请求头带上了 Authorization: Bearer ...(Devtools Network 可查)。
参考答案(silent 分支)
// 响应拦截器内的错误分支统一改为:
function notifyOnce(message, config) {
  if (!config?.silent) ElMessage.error(message)
}
// 业务错误分支:
notifyOnce(message || '请求失败', response.config)
// 网络错误分支:
notifyOnce(message, error.config)

练习 2 · 进阶(30–60 min):无感刷新并发验证

任务描述:接入 §9.5 的无感刷新,并验证三个关键行为:①并发 3 个请求同时收到 40102,只发出 1 次 /auth/refresh;②3 个请求都被重放并成功;③把 refreshToken 也改错后,3 个请求全部收到 40101 且用户态被清空。

提示:教学 mock 环境直接写死一个「会过期」的 accessToken(如 expired-token)让 mock 返回 40102;验证手段看 Network 面板请求计数。

验收标准:

  • [ ] Network 中 /auth/refresh 恰好 1 次;
  • [ ] 3 个业务请求最终都拿到 code: 0 的数据;
  • [ ] refreshToken 错误时,Devtools 中 user store 被清空,无死循环请求。
参考答案(验证用的临时代码)
// 在任意页面临时执行:三个请求并发,模拟 token 同时过期
import { fetchOrders, fetchOrderById } from '@/api/orders'
import { useUserStore } from '@/stores/user'

const userStore = useUserStore()
userStore.setTokens({ accessToken: 'expired-token', refreshToken: 'valid-rt' })

Promise.allSettled([
  fetchOrders({ page: 1, pageSize: 20 }),
  fetchOrders({ page: 2, pageSize: 20 }),
  fetchOrderById('QO20260101-000001'),
]).then((results) => console.log(results)) // 三个都应 fulfilled(mock 放行 valid-rt 换新)

练习 3 · 挑战(选做,60+ min):useOrderList 强化版

任务描述:在 §9.8 的 useOrderList 基础上追加:①筛选条件与 URL query 双向同步(第 07 章练习 2 的思路);②排序参数 sort(创建时间/金额)与 order(asc/desc)支持;③暴露 exportSelected(ids),调用 exportOrdersCsv 后用 Blob 触发浏览器下载。

提示:URL 同步用 router.replace({ query }) + onMounted 恢复;Blob 下载用 URL.createObjectURL + 隐藏 <a> 点击,用完 revokeObjectURL。

验收标准:

  • [ ] 刷新页面后筛选/页码与 URL 一致;
  • [ ] 快速切换状态筛选时不出现旧数据覆盖(Network 中旧请求显示 canceled);
  • [ ] 勾选订单导出后浏览器下载到 .csv 文件。
参考答案(导出下载函数)
// src/composables/useOrderList.js 追加
import { exportOrdersCsv } from '@/api/orders'

async function exportSelected(ids) {
  const blob = await exportOrdersCsv(ids) // responseType: 'blob',拦截器原样放行
  const url = URL.createObjectURL(blob)
  const a = document.createElement('a')
  a.href = url
  a.download = `orders-${Date.now()}.csv`
  a.click()
  URL.revokeObjectURL(url)
}

// return { ..., exportSelected }

⚠️ 常见易错点

易错点 1 · 金额浮点求和陷阱(附录A:金额单位为分)

// ❌ 用「元」做浮点累加:0.1 + 0.2 = 0.30000000000000004
//    报表对账时总额与明细之和对不上,财务事故
const total = items.reduce((s, it) => s + it.quantity * it.unitPriceYuan * 0.9, 0)
// ✅ 全程「分」(整数):quantity × unitPrice(分) × 折扣 后 Math.round 归位
//    整数加法不引入误差,展示层才转元
export function calcItemAmountFen({ quantity, unitPrice, discountPercent }) {
  return Math.round(quantity * unitPrice * (discountPercent / 100))
}
const totalFen = items.reduce((sum, it) => sum + calcItemAmountFen(it), 0)

易错点 2 · 拦截器返回值口径不一致

// ❌ 有人 return res.data.data,有人 return res.data:
//    组件里的取值写法五花八门,一改全崩
service.interceptors.response.use((res) => res.data.data)
// ✅ 全站唯一约定:拦截器返回「裸 data」,组件永远拿到业务数据本身
service.interceptors.response.use((response) => {
  const { code, data } = response.data ?? {}
  return code === 0 ? data : Promise.reject(/* ApiError */)
})

易错点 3 · 竞态:先发后至,旧数据覆盖新数据

// ❌ 每次输入都发请求且不取消:慢的旧响应最后到达,列表显示旧关键词的结果
watch(keyword, (kw) => {
  fetchCustomers({ keyword: kw }).then((data) => (options.value = data.list))
})
// ✅ 防抖(少发)+ AbortController(取消旧的):只有最新请求能写入
const debouncedFetch = debounce((kw) => fetchCustomers({ keyword: kw }), 300)
let controller = null

watch(keyword, (kw) => {
  controller?.abort()
  controller = new AbortController()
  debouncedFetch(kw, { signal: controller.signal }).then((data) => {
    options.value = data.list
  })
})

易错点 4 · watch 监听「对象」却监了个寂寞

// ❌ getter 返回新对象:引用每次都变,乱触发;或监听深层字段但没 deep
watch(() => ({ ...filters }), () => load())        // filters 内部变化不触发
watch(() => filters.range, () => load())           // range 是 reactive 内嵌对象,默认不深层
// ✅ 明确监听「原始类型列表」或对 reactive 对象整体监听(默认深层)
watch(() => [filters.status, filters.startDate, filters.endDate], load)
watch(filters, load) // filters 是 reactive,整体监听默认 deep: true

易错点 5 · 刷新接口自身走了拦截器,刷新失败成死循环

// ❌ refresh 用 service 实例发送:返回 40102 时又进入刷新逻辑,无限递归
await service.post('/auth/refresh', { refreshToken })
// ✅ 用裸 axios + 明确的业务码判断;再配合 _retried 防重放循环(§9.5 完整版)
const { data: body } = await axios.post(
  `${import.meta.env.VITE_API_BASE_URL}/auth/refresh`,
  { refreshToken },
)
if (body.code !== 0) { userStore.clearAuth(); throw new ApiError({ code: 40101, message: '请重新登录' }) }

易错点 6 · 对 POST 做自动重试

// ❌ 重试「所有失败请求」:网络抖动时 createOrder 被发两次,创建两笔订单
catch (e) { return request(payload) } // 盲目重放
// ✅ 只对幂等 GET 重试;写操作交给用户手动重试或后端幂等键
const retriable = e.httpStatus >= 500 && config.method === 'get'

✅ 最佳实践

  1. 组件零 axios:请求能力全部收敛在 api/request.js,评审见到组件 import axios 直接打回。
  2. 一个业务域一个 api 文件:orders.js、auth.js、customers.js,函数名与后端语义对齐(submitOrder 而不是 orderSubmitPost)。
  3. 错误归一化三约定:请求层 toast 一次、组件用 ApiError.code 判断、静默用 silent 标记显式申请。
  4. baseURL 与开关全部走 import.meta.env.VITE_*,仓库里不出现任何真实域名之外的环境差异硬编码。
  5. 防抖默认 300ms、超时默认 10s:写进 request.js 与工具函数的默认参数,不靠调用方记得。
  6. 竞态治理二选一并写注释:AbortController(省流量)或序号比对(兼容性最好),团队统一一种。
  7. 金额只在 utils/money.js 换算:接口出入参都是分,组件需要展示时才 fenToYuanStr,转换单点收口。
  8. 数据编排进 composable:组件只保留「绑定与渲染」,useOrderList 这类组合函数是可单测的纯逻辑层(第 15 章直接测它)。

📌 本章小结

  • 接口层四层分工:环境变量 → request 实例(横切能力)→ api 模块(接口映射)→ composable(数据编排),组件不直接碰 axios。
  • 响应拦截器承担「解包 + 归一化」:组件面对的是裸 data 与统一 ApiError;40102 用「单飞锁 + 重放 + _retried 防护」无感刷新。
  • 竞态与风暴分开治:防抖解决「发太多」,AbortController/序号解决「谁说了算」。
  • 重试只给幂等 GET,指数退避;POST 重试是资损事故。
  • 金额单位是分:整数运算 + 展示层转元,utils/money.js 单点收口。
  • useOrderList 是本章所有技术的集大成者,第 12 章实战直接复用。

下一章预告:请求层能稳定拿数据了,但「订单从表单来」——表单校验、动态明细行、金额联动与防重复提交,是下一章的主角。

📄 第 10 章 · 表单处理与校验 10-表单处理与校验.md

📍 阶段:中级阶段(第 07–12 章)|⏱ 预计学时:6 小时|🔗 前置:第 09 章

⬅ 上一章 | 📖 本版目录 | ➡ 下一章

💡 对照学习:本页是 Vue 版第 10 章。React 版对应章节为 第 10 章 · 表单处理与校验。概念映射:Element Plus Form 的 rules ≈ React Hook Form + Zod 的 schema 校验(Zod 用一个 schema 对象描述规则,ElForm 用 rules 对象 + async-validator 执行);resetFields() ≈ reset();v-model ≈ 受控组件(Controller / register);「提交锁」两版写法一致。

🎯 学习目标

学完本章,你将能够:

  1. 说出 Vue 中「受控与非受控」的对应实现(v-model 绑定 vs 组件默认值)与各自适用场景;
  2. 用 Element Plus Form 声明校验规则(required / 范围 / 自定义 validator),并控制校验触发时机(blur / change);
  3. 实现动态明细行表单:增删行、行内校验(:prop 动态路径)、删除行后校验状态不错位;
  4. 实现表单联动:整单折扣变化触发审批提示与依赖字段重新校验;
  5. 用整数「分」完成明细金额与订单总额的联动计算,避免浮点误差;
  6. 实现完整的提交链路:校验 → 组装 payload → 提交锁防重复 → 结果反馈 → 表单重置;
  7. 用 ElMessageBox 处理危险操作的二次确认,并在正确时机调用 resetFields。

📖 核心知识点

10.1 受控与非受控:Vue 的答案

React 里「受控/非受控」是显式选择(value vs defaultValue);Vue 里 v-model 默认就是受控绑定,多数情况不需要纠结,但要认识边界:

<script setup>
import { reactive } from 'vue'
const form = reactive({ remark: '', remark2: '' })
</script>

<template>
  <!-- ✅ 受控:v-model 双向绑定,form.remark 是唯一数据源 -->
  <el-input v-model="form.remark" placeholder="备注" />

  <!-- 非受控思路:只在提交时读 DOM 值(Vue 中较少见,多用于原生物)
       <input type="file" ref="fileRef" /> 文件选择就用这种 -->
</template>

三个 v-model 修饰符在表单里高频出现:

<el-input v-model.trim="form.keyword" />  <!-- trim:去首尾空格,搜索框标配 -->
<el-input v-model.number="form.age" />    <!-- number:转数字,纯 input 场景防「1+1=11」 -->
<el-input v-model.lazy="form.desc" />     <!-- lazy:change 时才同步(失焦),适合长文本 -->

结论:Element Plus 的 el-input-number 已返回数字,无需修饰符;原生 input 配合数字输入必须 v-model.number(易错点 2)。

10.2 Element Plus Form:规则声明与校验时机

ElForm 的三要素:<el-form :model="form" :rules="rules"> 声明数据与规则;<el-form-item prop="xxx"> 把规则绑定到字段;formRef.validate() 触发整体校验。校验底层由 async-validator 驱动,规则语法以 Element Plus 官方文档 Form 一节 为准。

<!-- src/views/orders/OrderFormView.vue —— 骨架:静态字段部分 -->
<script setup>
import { reactive, ref } from 'vue'

const formRef = ref()

const form = reactive({
  customerId: null,          // 客户(远程搜索下拉)
  customerName: '',
  wholeDiscountPercent: 100, // 整单折扣,100 表示无折扣(附录A)
  remark: '',
})

const rules = {
  customerId: [{ required: true, message: '请选择客户', trigger: 'change' }],
  wholeDiscountPercent: [
    { required: true, message: '请输入整单折扣', trigger: 'change' },
    { type: 'number', min: 0, max: 100, message: '整单折扣范围 0–100', trigger: 'change' },
  ],
  remark: [
    { max: 200, message: '备注不能超过 200 字', trigger: 'blur' },
  ],
}

async function validateAll() {
  // validate() 返回 Promise:通过 resolve(true),不通过 reject
  const valid = await formRef.value.validate().catch(() => false)
  return valid === true
}
</script>

<template>
  <el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
    <el-form-item label="客户" prop="customerId">
      <el-select
        v-model="form.customerId"
        filterable remote clearable
        :remote-method="searchCustomers"
        :loading="customerLoading"
        placeholder="输入客户名称搜索"
        style="width: 320px"
      >
        <el-option v-for="c in customerOptions" :key="c.id" :label="c.name" :value="c.id" />
      </el-select>
    </el-form-item>

    <el-form-item label="整单折扣(%)" prop="wholeDiscountPercent">
      <el-input-number v-model="form.wholeDiscountPercent" :min="0" :max="100" :precision="0" />
    </el-form-item>

    <el-form-item label="备注" prop="remark">
      <el-input v-model="form.remark" type="textarea" maxlength="200" show-word-limit />
    </el-form-item>
  </el-form>
</template>

触发时机的选择原则:

trigger 适用字段 理由
blur 文本类(名称、备注、折扣数值) 用户「输完一段」才校验,边打字边报错很烦
change 下拉、开关、日期等选择类 选中即定型,立即反馈

自定义 validator 统一用「回调式」签名 (rule, value, callback):合法 callback(),不合法 callback(new Error('原因'))。例:校验「折扣必须是整数」:

const wholeDiscountPercent = [
  { required: true, message: '请输入整单折扣', trigger: 'change' },
  {
    validator: (rule, value, callback) => {
      if (value == null) return callback() // 留空交给 required
      if (!Number.isInteger(value)) return callback(new Error('折扣必须是整数'))
      if (value < 0 || value > 100) return callback(new Error('折扣范围 0–100'))
      callback()
    },
    trigger: 'change',
  },
]

10.3 动态表单:明细行的增删与行内校验

新建订单的核心是明细行(附录A §3.3):商品、数量、单价、行折扣,行数不固定。ElForm 校验动态行的关键是 prop 的动态路径:items.${index}.quantity 必须与 :model 的嵌套结构精确对应。

// OrderFormView.vue <script setup> 追加:明细行逻辑
import { ElMessage } from 'element-plus'

const MAX_ITEMS = 50

const itemRules = {
  skuId: [{ required: true, message: '请选择商品', trigger: 'change' }],
  quantity: [
    { required: true, message: '请输入数量', trigger: 'blur' },
    { type: 'number', min: 1, max: 9999, message: '数量范围 1–9999', trigger: 'blur' },
  ],
  unitPrice: [
    {
      validator: (rule, value, callback) =>
        value > 0 ? callback() : callback(new Error('单价必须大于 0(单位:分)')),
      trigger: 'blur',
    },
  ],
  discountPercent: [
    { required: true, message: '请输入行折扣', trigger: 'blur' },
    { type: 'number', min: 0, max: 100, message: '行折扣范围 0–100', trigger: 'blur' },
  ],
}

function addItem() {
  if (form.items.length >= MAX_ITEMS) {
    ElMessage.warning(`最多 ${MAX_ITEMS} 行明细`)
    return
  }
  form.items.push({ skuId: '', skuName: '', quantity: 1, unitPrice: 0, discountPercent: 100 })
}

function removeItem(index) {
  form.items.splice(index, 1)
  // 删除后行序号整体位移,清掉历史校验态,避免「红字错位」(见易错点 1 补充)
  formRef.value?.clearValidate()
}
<!-- 动态明细行模板:prop 用模板字符串动态路径 -->
<el-form-item
  v-for="(item, index) in form.items"
  :key="item._key"
  :label="`明细 ${index + 1}`"
>
  <div class="item-row">
    <el-form-item :prop="`items.${index}.skuId`" :rules="itemRules.skuId">
      <el-select
        v-model="item.skuId"
        filterable remote :remote-method="searchSkus"
        placeholder="搜索商品"
        @change="(skuId) => onSkuChange(index, skuId)"
      >
        <el-option v-for="s in skuOptions" :key="s.id" :label="s.name" :value="s.id" />
      </el-select>
    </el-form-item>

    <el-form-item :prop="`items.${index}.quantity`" :rules="itemRules.quantity">
      <el-input-number v-model="item.quantity" :min="1" :max="9999" :precision="0" />
    </el-form-item>

    <el-form-item :prop="`items.${index}.unitPrice`" :rules="itemRules.unitPrice">
      <el-input-number v-model="item.unitPrice" :min="1" :precision="0" placeholder="分" />
    </el-form-item>

    <el-form-item :prop="`items.${index}.discountPercent`" :rules="itemRules.discountPercent">
      <el-input-number v-model="item.discountPercent" :min="0" :max="100" :precision="0" />
    </el-form-item>
  </div>
</el-form-item>

注意 :key="item._key":不用 index 作 key。删除中间一行时 index 全部位移,Vue 会误认为「每一行都变了」,已填内容与校验状态都会错位。addItem() 时给每行发一个自增 _key。

10.4 表单联动:折扣 → 审批提示 + 依赖校验

附录A §3.3:整单折扣 > 10%(即 wholeDiscountPercent < 90)时,提交后进入 pending_review,需财务审批。这类联动用 computed 派生提示 + watch 主动触发依赖字段的重新校验:

// OrderFormView.vue <script setup> 追加:联动
import { computed, watch } from 'vue'

const AUTO_REVIEW_THRESHOLD = 90 // 附录A:≤90 免审批

const needsReview = computed(() => form.wholeDiscountPercent < AUTO_REVIEW_THRESHOLD)

// 联动规则示例:行折扣不得低于整单折扣。整单折扣一变,所有行折扣立即重新校验
watch(() => form.wholeDiscountPercent, () => {
  if (!form.items.length) return
  formRef.value?.validateField(
    form.items.map((_, index) => `items.${index}.discountPercent`),
  ).catch(() => {}) // 校验失败的静默处理:红字由表单展示,这里不弹错误
})
<!-- 联动提示:直接由 computed 驱动 -->
<el-form-item label="整单折扣(%)" prop="wholeDiscountPercent">
  <el-input-number v-model="form.wholeDiscountPercent" :min="0" :max="100" :precision="0" />
  <el-alert
    v-if="needsReview"
    type="warning"
    :closable="false"
    title="整单折扣超过 10%,提交后将进入「待审核」,需财务审批"
    style="margin-left: 12px"
  />
</el-form-item>

10.5 金额汇总:分单位的整数联动

明细金额与订单总额的联动计算复用第 09 章的 utils/money.js——计算永远在「分」(整数)上进行,展示才转元:

// OrderFormView.vue <script setup> 追加:金额联动(纯派生,不存储第二份金额)
import { calcItemAmountFen, calcOrderTotalFen, fenToYuanStr } from '@/utils/money'

const totalFen = computed(() => calcOrderTotalFen(form.items))
// 选中商品后带出单价(分):由商品目录数据填充,用户可按权限修改
function onSkuChange(index, skuId) {
  const sku = skuOptions.value.find((s) => s.id === skuId)
  if (sku) {
    form.items[index].skuName = sku.name
    form.items[index].unitPrice = sku.unitPriceFen
  }
}
<el-form-item>
  <el-button @click="addItem">+ 添加明细</el-button>
  <span class="row-amount">
    订单总额:<strong>{{ fenToYuanStr(totalFen) }}</strong> 元
  </span>
</el-form-item>

原则:总额是 computed 派生值,绝不用另一个可写字段存它——否则「明细改了、总额没变」的不一致迟早出现(第 08 章「能派生不存储」在表单上的应用)。

10.6 提交链路:校验 → 锁 → payload → 反馈 → 重置

完整提交函数是本章所有知识点的收口:

// OrderFormView.vue <script setup> 追加:提交链路
import { useRouter } from 'vue-router'
import { createOrder, submitOrder } from '@/api/orders'
import { useOrderDraftStore } from '@/stores/orderDraft'

const router = useRouter()
const draftStore = useOrderDraftStore()
const submitting = ref(false)

async function handleSubmit() {
  // 1) 提交锁:双击第二刀被挡下(易错点 4)
  if (submitting.value) return

  // 2) 整体校验:不通过时表单自动滚动/标红,这里只补一条轻提示
  const valid = await formRef.value.validate().catch(() => false)
  if (!valid) {
    ElMessage.warning('请先修正表单中标红的字段')
    return
  }

  submitting.value = true
  try {
    // 3) 组装 payload:字段与附录A 实体对齐;金额单位分、折扣整数
    const payload = {
      customerId: form.customerId,
      wholeDiscountPercent: form.wholeDiscountPercent,
      remark: form.remark,
      items: form.items.map(({ skuId, skuName, quantity, unitPrice, discountPercent }) => ({
        skuId, skuName, quantity, unitPrice, discountPercent,
      })),
    }
    // 4) 两步提交:先建草稿,再提交审核(折扣 >10% 由后端落入 pending_review)
    const order = await createOrder(payload)
    await submitOrder(order.id)

    draftStore.clear() // 清暂存草稿(第 08 章)
    ElMessage.success('订单已提交')
    router.replace({ name: 'order-detail', params: { id: order.id } })
  } catch (e) {
    // 错误 toast 已由请求层统一弹出(第 09 章);这里可按 ApiError.code 做界面回退
  } finally {
    submitting.value = false // 5) 无论成败释放锁
  }
}
<el-form-item>
  <el-button type="primary" :loading="submitting" @click="handleSubmit">
    {{ needsReview ? '提交并送审' : '提交' }}
  </el-button>
</el-form-item>

危险操作的二次确认用 ElMessageBox.confirm(Promise 风格):

import { ElMessageBox } from 'element-plus'

async function handleClearDraft() {
  const { value } = await ElMessageBox.confirm('清空后无法恢复,确认?', '危险操作', {
    type: 'warning',
    confirmButtonText: '清空',
    cancelButtonText: '再想想',
  }).catch(() => ({ value: false }))
  if (value) {
    draftStore.clear()
    formRef.value.resetFields() // 恢复到「表单挂载时」的初始值(见易错点 5)
  }
}

🛠 渐进式实战练习

练习 1 · 基础(10–20 min):静态字段 + 校验时机

任务描述:搭出 §10.2 的静态部分:客户下拉(可用本地假选项)、整单折扣(0–100 整数)、备注(≤200 字);三种字段分别用 blur / change 合理的触发时机;提交按钮点击时执行整体校验并打印结果。

提示:先不接远程搜索,customerOptions 写死数组;校验时机按 §10.2 表格选。

验收标准:

  • [ ] 不选客户直接提交:客户字段红字提示,不发起请求;
  • [ ] 折扣输入 101 或 99.5 被拦下(整数 + 范围);
  • [ ] 备注 201 字被拦下,200 字通过。
参考答案(提交校验的最小闭环)
async function handleSubmit() {
  const valid = await formRef.value.validate().catch(() => false)
  if (!valid) {
    ElMessage.warning('请先修正标红字段')
    return
  }
  console.log('payload:', { ...form })
}

练习 2 · 进阶(30–60 min):动态明细行 + 金额联动

任务描述:加入明细行:添加/删除行、每行四个字段校验(数量 1–9999、单价 > 0 分、行折扣 0–100)、行金额与订单总额实时联动显示;选中商品自动带出单价。

提示::key 用自增 _key 而不是 index;金额计算复用 utils/money.js;删除行后 clearValidate()。

验收标准:

  • [ ] 三行明细分别填数量 0、10000、500:前两行红字,第三行通过;
  • [ ] 修改任一行数量,行金额与总额立即更新且无浮点尾巴(如 123.45 而不是 123.45000000001);
  • [ ] 删除第 1 行(共 3 行)后,剩余行的校验红字不错位。
参考答案(_key 的生成与使用)
let itemKeySeed = 0

function addItem() {
  form.items.push({
    _key: ++itemKeySeed, // 本地唯一标识,仅用于 v-for :key,不提交给后端
    skuId: '', skuName: '', quantity: 1, unitPrice: 0, discountPercent: 100,
  })
}
// 组装 payload 时剔除 _key:
items: form.items.map(({ _key, ...rest }) => rest)

练习 3 · 挑战(选做,60+ min):草稿暂存 + 联动审批闭环

任务描述:把订单表单接入第 08 章的 useOrderDraftStore:进入页面恢复草稿(弹确认)、每 3 秒自动暂存、提交成功清空;同时实现 §10.4 的联动(整单折扣 < 90 显示审批提示,行折扣低于整单折扣时触发依赖校验)。

提示:恢复草稿在 onMounted,注意等表单渲染完再回填(nextTick)并 clearValidate();定时器在 onUnmounted 清理。

验收标准:

  • [ ] 填一半刷新/离开再回来,可完整恢复;
  • [ ] 整单折扣改为 85:出现「待财务审批」提示,且所有行折扣立即重新校验;
  • [ ] 提交成功后暂存区清空(Devtools Pinia 面板确认)。
参考答案(恢复时序)
import { nextTick, onMounted, onUnmounted } from 'vue'

let timer = null

onMounted(async () => {
  await nextTick() // 等 el-form 挂载完成,resetFields/validate 才可用
  const recovered = await draftStore.initDraft()
  if (recovered) {
    const { value } = await ElMessageBox.confirm('检测到未提交草稿,是否恢复?', '提示')
      .then(() => ({ value: true }))
      .catch(() => ({ value: false }))
    if (value) {
      Object.assign(form, recovered)
      await nextTick()
      formRef.value.clearValidate() // 回填是程序行为,不该亮红字
    } else {
      draftStore.clear()
    }
  }
  timer = setInterval(() => draftStore.saveDraft({ ...form }), 3000)
})

onUnmounted(() => clearInterval(timer))

⚠️ 常见易错点

易错点 1 · 动态行 prop 路径写错,校验静默失效

<!-- ❌ prop 是字符串字面量,index 没有被插值:
     ElForm 在 model 上找不到 "items[index].quantity" 这条路径,规则形同虚设 -->
<el-form-item prop="items[index].quantity">...</el-form-item>
<!-- ✅ :prop 是绑定表达式,模板字符串插值出真实路径;必须与 :model 嵌套结构一致 -->
<el-form-item :prop="`items.${index}.quantity`" :rules="itemRules.quantity">
  <el-input-number v-model="item.quantity" :min="1" :max="9999" />
</el-form-item>

补充:删除行用 index 作 :key 会让内容与校验态错位——用自增 _key(练习 2 答案)。

易错点 2 · 数字输入拿到字符串

// ❌ 原生 input 的 v-model 永远给字符串:"2" * "3" 碰巧是 6,
//    但 "2" + "3" 是 "23";传给后端 quantity 成了字符串
const total = form.quantity * form.unitPrice // quantity 是 "2"
<!-- ✅ 数字输入用 el-input-number(天然 number);原生 input 必须加 .number -->
<el-input v-model.number="form.quantity" />

易错点 3 · 金额在「元」上做浮点运算

// ❌ 元为单位求和:0.1 + 0.2 = 0.30000000000000004,对账差异
const totalYuan = items.reduce((s, it) => s + it.quantity * it.priceYuan * (it.discount / 100), 0)
// ✅ 分为单位整数运算(附录A 约定),展示层才转元
const totalFen = items.reduce((sum, it) => sum + calcItemAmountFen(it), 0)
const display = fenToYuanStr(totalFen) // "0.30"

易错点 4 · 没有提交锁,双击创建两笔订单

// ❌ 无锁:接口慢时用户双击,两笔一模一样的订单进库(真实资损事故)
async function handleSubmit() {
  const order = await createOrder(payload)
}
// ✅ submitting 同步置位(挡住同步的第二次点击)+ finally 释放
const submitting = ref(false)
async function handleSubmit() {
  if (submitting.value) return
  submitting.value = true
  try {
    await createOrder(payload)
  } finally {
    submitting.value = false
  }
}
// 按钮同时 :loading="submitting",视觉与逻辑双保险

易错点 5 · resetFields 的「初始值」与调用时机

// ❌ 两个坑:①弹窗内容 v-if 未渲染时调用 resetFields 是空操作;
//    ②先修改 form 再打开表单,resetFields 只会回到「挂载时」的值,不是你以为的空
dialogVisible.value = false
form.remark = '残留'
formRef.value.resetFields() // 此时组件已卸载,什么也没发生
// ✅ 重置放在「打开前/关闭后」的正确时点,且初始值在挂载前就定好
const openDialog = () => {
  Object.assign(form, createEmptyForm()) // 手动恢复默认值,最可靠
  dialogVisible.value = true
}
// 或关闭后:dialogVisible 关闭动画结束后再 resetFields(配 @closed 事件)

易错点 6 · 联动字段只等自身 trigger,改了上游不重校

// ❌ 整单折扣改小后,旧行折扣的合法性状态还停留在上一次校验结果
//    (没有 watch 联动),用户带着过期状态提交
// ✅ 上游变化 → 主动 validateField 依赖字段(§10.4 完整代码)
watch(() => form.wholeDiscountPercent, () => {
  if (!form.items.length) return
  formRef.value?.validateField(form.items.map((_, i) => `items.${i}.discountPercent`)).catch(() => {})
})

✅ 最佳实践

  1. 校验规则集中声明:rules / itemRules 独立成对象(可抽到 constants/orderRules.js),与组件模板解耦、可单测、可复用。
  2. 触发时机按字段类型选:文本 blur、选择类 change;全站统一,不逐字段凭感觉。
  3. 派生优先:行金额、总额、是否需审批全部 computed;可写 state 与派生值绝不混存同一数据。
  4. 金额边界集中在 money.js:表单、列表、详情、报表都引用同一组函数,禁止组件内手写 * 100 / 100。
  5. 提交链路五步走:锁 → 校验 → payload → 提交 → finally 解锁;按钮 :loading 与锁同时存在。
  6. payload 组装贴近附录A 实体:字段名、单位、可空性与接口约定一一对应,本地辅助字段(_key)在组装时剔除。
  7. 回填不是用户输入:程序恢复草稿/编辑回显后 clearValidate(),别让程序行为触发红字。
  8. 危险操作一律 ElMessageBox 确认,确认文案说清后果(「清空后无法恢复」)。

📌 本章小结

  • Vue 的受控绑定 v-model 是默认心智;修饰符 .trim/.number/.lazy 解决字符串输入的三类边角,数字输入优先 el-input-number。
  • ElForm 校验三要素::model + rules + prop;动态行用模板字符串路径 items.${index}.quantity,:key 避开 index。
  • 联动 = computed 派生提示 + watch 主动 validateField 依赖字段。
  • 金额只在「分」上运算(整数),展示层转元;总额是派生值,不二次存储。
  • 提交链路的底线是「双击不出两单」:提交锁 + :loading + finally 释放。

下一章预告:表单能提交订单了,但「谁能提交、谁能审批」还没有闸门。第 11 章把登录鉴权、三级权限(菜单/页面/按钮)、40102 无感刷新与 403 页一次补齐。

📄 第 11 章 · 登录鉴权与权限控制 11-登录鉴权与权限控制.md

📍 阶段:中级阶段(第 07–12 章)|⏱ 预计学时:6 小时|🔗 前置:第 10 章

⬅ 上一章 | 📖 本版目录 | ➡ 下一章

💡 对照学习:本页是 Vue 版第 11 章。React 版对应章节为 第 11 章 · 登录鉴权与权限控制。概念映射:路由守卫白名单 ≈ <ProtectedRoute> 包裹 + 登录重定向;v-permission 自定义指令 ≈ <Auth> 权限组件;动态菜单的「配置过滤」两版同构;token 存储的 XSS/CSRF 权衡与框架无关;无感刷新的「单飞锁 + 请求队列」与框架无关(实现见第 09 章 §9.5)。

🎯 学习目标

学完本章,你将能够:

  1. 走通完整登录流:账密 + 图形验证码获取与刷新 + 登录态写入 + 回跳原始页面;
  2. 说出 accessToken / refreshToken 双令牌机制与无感刷新的完整时序(单飞锁、请求队列、重放);
  3. 权衡 localStorage 与 HttpOnly Cookie 存储 token 的 XSS / CSRF 风险,并为项目做出明确选择与理由;
  4. 实现路由守卫的登录拦截 + 白名单 + redirect 回跳,并接入页面级权限(meta.roles → 403 页);
  5. 用同一份菜单配置按角色过滤,实现「不同角色看到不同菜单」的动态菜单;
  6. 开发 v-permission 自定义指令,实现按钮级权限控制,并理解指令恢复能力的限制;
  7. 实现登出的完整清理(token、store、字典缓存、回登录页)与刷新页面后的登录态恢复。

📖 核心知识点

11.1 三级权限模型与本章地图

附录A §2 定义了三级权限与四个角色(super_admin / operator / sales / finance)。三级控制对应三个实现位置:

级别 控制什么 实现位置
菜单级 侧边栏哪些菜单可见 菜单配置按 roles 过滤(§11.6)
页面级 直接输 URL 访问无权限页 路由守卫 + meta.roles → 403(§11.5)
按钮级 「删除」「审批」等按钮可见/可用 v-permission 指令(§11.7)

记住一个原则:前端权限全是体验层,真正的安全边界在后端。前端把 403 拦得再好,也必须假设接口会被直接调用。

11.2 双令牌与无感刷新:完整时序

附录A §3.1:accessToken(2 小时)+ refreshToken(7 天)。access 短命、频繁使用,泄露面大但影响窗口小;refresh 长命、只在刷新接口出现一次。

┌────────┐                                 ┌────────┐                        ┌───────┐
│ 组件   │                                 │request │                        │ 后端  │
└───┬────┘                                 └───┬────┘                        └───┬───┘
    │ ① 请求 A/B/C(并发)                      │                                │
    │ ───────────────────────────────────────▶ │ ② 携带过期 accessToken          │
    │                                          │ ─────────────────────────────▶ │
    │                                          │ ◀──────── code:40102 ───────── │
    │                                          │ ③ 创建刷新 Promise(单飞锁)     │
    │                                          │ ──── POST /auth/refresh ─────▶ │
    │                                          │ ◀── 新 token 对 ─────────────── │
    │                                          │ ④ 释放锁;A/B/C 用新 token 重放  │
    │ ◀─────────────────── 全部成功,用户无感 ── │                                │
  • 单飞锁:A/B/C 同时 40102,只有第一个进入的请求创建 refreshingPromise,其余共享等待(第 09 章 §9.5 完整代码);
  • 请求队列的另一种理解:没有显式的数组队列,但「等待同一个 Promise」在效果上就是队列——每个等待者醒来后各自重放;
  • 兜底:refreshToken 也过期 → clearAuth() + 送回登录页,绝不无限重试。

教学约定的接口(与第 12 章 mock 一致):

// src/api/auth.js —— 认证域接口(教学约定,生产以真实后端为准)
import request from './request'

/** 获取图形验证码 → { captchaId, image },image 为 data URL 直接可渲染 */
export function getCaptcha() {
  return request.get('/auth/captcha')
}

/** 登录 payload: { username, password, captchaId, captchaCode }
 *  → { accessToken, refreshToken, user: { id, name, roles, permissions } } */
export function login(payload) {
  return request.post('/auth/login', payload)
}

/** 修改密码 */
export function changePassword({ oldPassword, newPassword }) {
  return request.post('/auth/change-password', { oldPassword, newPassword })
}

/** 获取当前登录用户(刷新页面后恢复登录态用)→ user */
export function fetchMe() {
  return request.get('/auth/me')
}

/** 登出(后端可吊销 refreshToken) */
export function logoutApi() {
  return request.post('/auth/logout')
}

11.3 登录页:账密 + 图形验证码

附录A:输错 5 次锁定 10 分钟。锁定计数是后端职责(前端计数可被绕过),前端职责是:把后端 message 原样展示、失败后自动刷新验证码。

<!-- src/views/login/LoginView.vue —— 完整登录页 -->
<script setup>
import { reactive, ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getCaptcha, login } from '@/api/auth'
import { useUserStore } from '@/stores/user'

const router = useRouter()
const route = useRoute()
const userStore = useUserStore()

const formRef = ref()
const submitting = ref(false)
const form = reactive({ username: '', password: '', captchaCode: '' })
const captcha = reactive({ captchaId: '', image: '' })

const rules = {
  username: [{ required: true, message: '请输入账号', trigger: 'blur' }],
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    { min: 6, message: '密码至少 6 位', trigger: 'blur' },
  ],
  captchaCode: [{ required: true, message: '请输入验证码', trigger: 'blur' }],
}

async function refreshCaptcha() {
  const data = await getCaptcha()
  captcha.captchaId = data.captchaId
  captcha.image = data.image
}

onMounted(refreshCaptcha)

async function handleSubmit() {
  if (submitting.value) return // 提交锁(第 10 章)
  const valid = await formRef.value.validate().catch(() => false)
  if (!valid) return

  submitting.value = true
  try {
    const data = await login({ ...form, captchaId: captcha.captchaId })
    userStore.setLoginResult(data) // token + profile 一次写入
    ElMessage.success(`欢迎回来,${data.user.name}`)
    // 回跳:守卫拦截时用 query.redirect 记住了原始目标(§11.5)
    router.replace(route.query.redirect || '/dashboard')
  } catch (e) {
    // 失败(密码错/验证码错/被锁定):message 已由请求层 toast;
    // 无论何种失败都换一张验证码,防止「验证码已被消费」的二次失败
    form.captchaCode = ''
    refreshCaptcha()
  } finally {
    submitting.value = false
  }
}
</script>

<template>
  <div class="login-page">
    <el-card class="login-card">
      <h2>轻订单 · 登录</h2>
      <el-form ref="formRef" :model="form" :rules="rules" label-width="0">
        <el-form-item prop="username">
          <el-input v-model.trim="form.username" placeholder="账号(如 xiaozhang@qingorder.example.com)" />
        </el-form-item>
        <el-form-item prop="password">
          <el-input v-model="form.password" type="password" show-password placeholder="密码" @keyup.enter="handleSubmit" />
        </el-form-item>
        <el-form-item prop="captchaCode">
          <div class="captcha-row">
            <el-input v-model.trim="form.captchaCode" placeholder="图形验证码" />
            <!-- 点击图片换一张;验证码的正确性由后端校验(易错点 3) -->
            <img :src="captcha.image" class="captcha-img" title="点击刷新" @click="refreshCaptcha" />
          </div>
        </el-form-item>
        <el-button type="primary" class="submit-btn" :loading="submitting" @click="handleSubmit">
          登 录
        </el-button>
      </el-form>
    </el-card>
  </div>
</template>

<style scoped>
.login-page { display: flex; align-items: center; justify-content: center; height: 100vh; background: var(--el-fill-color-light); }
.login-card { width: 380px; }
.captcha-row { display: flex; gap: 8px; width: 100%; }
.captcha-img { height: 32px; cursor: pointer; border-radius: 4px; }
.submit-btn { width: 100%; }
</style>

11.4 token 存储的 XSS 权衡

这是企业评审的必答题。三种方案没有银弹,只有权衡:

方案 优点 风险 适用
localStorage 简单、跨标签页共享、刷新不丢 XSS 可读取(任何注入脚本都能偷走) 内部系统 + 严格 CSP + 无第三方脚本
HttpOnly Cookie JS 读不到,天然抗 XSS CSRF(需 SameSite/CSRF Token 缓解);跨域配置复杂 面向公网、安全要求高
内存(Pinia) 最安全,页面一关就没了 刷新即丢,需静默重建会话 极高安全场景,常与上面两者组合

「轻订单」是内部系统,教学采用 refreshToken 持久化 + accessToken 常驻 Pinia(内存) 的折中:刷新页面后用 refreshToken 静默换新 access,最大化缩短可被 XSS 偷走的令牌暴露窗口;同时配合基本防线(不渲染未消毒 HTML、不引入不可信脚本)。无论选哪种,都要在团队规范里写明决策与理由,而不是默认照抄。

// src/stores/user.js —— 存储策略的落地(替换第 08 章版本的关键片段)
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { fetchMe } from '@/api/auth'

const RT_KEY = 'qing_refresh_token' // 只有 refreshToken 进 localStorage

function readStoredRT() {
  try { return localStorage.getItem(RT_KEY) || '' } catch { return '' }
}

export const useUserStore = defineStore('user', () => {
  const accessToken = ref('')   // 内存持有,页面关闭即失效
  const refreshToken = ref(readStoredRT())
  const profile = ref(null)

  function setTokens({ accessToken: at, refreshToken: rt }) {
    accessToken.value = at
    refreshToken.value = rt
    try { rt ? localStorage.setItem(RT_KEY, rt) : localStorage.removeItem(RT_KEY) } catch { /* 隐私模式 */ }
  }

  /** 刷新页面后恢复登录态:有 refreshToken 就静默换新 access 并拉取用户信息 */
  async function tryRestoreSession() {
    if (!refreshToken.value) return false
    // 无感刷新会消费 refreshToken 换新 token(第 09 章 §9.5),成功后再拉 profile
    return Boolean(accessToken.value)
  }

  function clearAuth() {
    accessToken.value = ''
    refreshToken.value = ''
    profile.value = null
    try { localStorage.removeItem(RT_KEY) } catch { /* noop */ }
  }

  // roles / hasPermission 等 getter 见第 08 章,此处省略
  return { accessToken, refreshToken, profile, setTokens, tryRestoreSession, clearAuth }
})

11.5 路由守卫:白名单 + 登录态 + 页面级权限

把第 07 章的守卫升级为「生产版」:读取 Pinia、恢复会话、校验角色、403 拦截,全部在一个 beforeEach 里完成。

// src/router/index.js —— 生产版全局前置守卫
router.beforeEach(async (to) => {
  const userStore = useUserStore() // 守卫回调在 app.use(pinia) 之后才执行,安全

  // 1) 白名单:登录页、403、404 直接放行(防死循环,见易错点 1)
  if (to.meta.public) return true

  // 2) 未登录 → 登录页,记住原始目标用于回跳
  if (!userStore.isLoggedIn && !userStore.refreshToken) {
    return { path: '/login', query: { redirect: to.fullPath } }
  }

  // 3) 恢复登录态:刷新页面后 accessToken 在内存里已丢失
  //    ensureProfile 内部含单飞锁:并发导航只拉一次 /auth/me
  if (!userStore.profile) {
    try {
      await userStore.ensureProfile()
    } catch (e) {
      return { path: '/login', query: { redirect: to.fullPath } }
    }
  }

  // 4) 页面级权限:meta.roles 声明允许访问的角色(附录A 三级权限 · 页面级)
  const required = to.meta.roles
  if (required?.length && !required.some((r) => userStore.roles.includes(r))) {
    return { path: '/403', query: { from: to.fullPath } } // 拦到 403 页
  }

  return true
})
// src/stores/user.js —— ensureProfile:恢复用户信息(含单飞锁)
let profilePromise = null

async function ensureProfile() {
  if (profile.value) return profile.value
  if (!profilePromise) {
    profilePromise = fetchMe()
      .then((user) => { profile.value = user; return user })
      .finally(() => { profilePromise = null })
  }
  return profilePromise
}

/auth/me 失败通常意味着 refreshToken 也失效了——catch 里送回登录页。这一步是「刷新页面不掉登录」的关键(易错点 5)。

11.6 动态菜单:配置与路由同源

主流做法不是「后端下发路由再动态注册」,而是前端持有一份带 roles 的完整配置,登录后按角色过滤——路由表仍是静态的(守卫兜底拦截 URL 直达),菜单只是它的「可见性投影」。好处:权限逻辑集中、可测试;后端只下发角色,不下发代码。

// src/router/menus.js —— 菜单配置:与路由表共用 path,roles 与 meta.roles 同源维护
export const menuConfig = [
  { path: '/dashboard', title: '工作台', roles: [] },                                   // 空 = 所有人
  { path: '/orders', title: '订单管理', roles: [] },
  { path: '/customers', title: '客户管理', roles: ['super_admin', 'operator', 'sales'] },
  { path: '/products', title: '商品目录', roles: ['super_admin', 'operator'] },
  { path: '/reports', title: '报表中心', roles: ['super_admin', 'operator', 'finance'] },
  { path: '/settings/members', title: '系统设置', roles: ['super_admin'] },
]

/** 按角色过滤菜单(纯函数,可单测) */
export function filterMenus(menus, roles) {
  return menus
    .filter((m) => !m.roles.length || m.roles.some((r) => roles.includes(r)))
    .map((m) => (m.children ? { ...m, children: filterMenus(m.children, roles) } : m))
}
<!-- src/views/layout/components/SideMenu.vue —— 升级为动态菜单 -->
<script setup>
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { menuConfig, filterMenus } from '@/router/menus'

const route = useRoute()
const userStore = useUserStore()
const visibleMenus = computed(() => filterMenus(menuConfig, userStore.roles))
</script>

<template>
  <el-menu router :default-active="route.path">
    <el-menu-item v-for="m in visibleMenus" :key="m.path" :index="m.path">
      {{ m.title }}
    </el-menu-item>
  </el-menu>
</template>

11.7 按钮级权限:v-permission 自定义指令

按钮级控制(附录A:删除仅超管/运营可见、审批仅财务可见)用自定义指令实现「声明式权限」:模板上一行 v-permission 说清意图。

// src/directives/permission.js —— v-permission:无权限的元素隐藏
import { useUserStore } from '@/stores/user'

function check(el, binding) {
  const userStore = useUserStore()
  const code = binding.value // 支持字符串或数组:v-permission="'order:delete'" / v-permission="['a','b']"
  const codes = Array.isArray(code) ? code : [code]
  const allowed = codes.some((c) => userStore.hasPermission(c))
  el.style.display = allowed ? '' : 'none'
}

export const permissionDirective = {
  mounted: check,          // 首次挂载判定
  updated: check,          // 权限/角色变化后重新判定(登录态热更新场景)
}
// src/main.js —— 注册指令
import { permissionDirective } from '@/directives/permission'

app.directive('permission', permissionDirective)
<!-- 使用:订单详情页的操作区(附录A 状态流转按钮) -->
<script setup>
import { canTransition } from '@/constants/order'
</script>

<template>
  <el-button v-permission="'order:edit'" type="primary" @click="onEdit">编辑</el-button>
  <el-button v-permission="'order:approve'" type="success" @click="onApprove">审批通过</el-button>
  <el-button v-permission="['super_admin', 'operator']" type="danger" @click="onDelete">删除订单</el-button>
</template>

权限码统一收敛在常量文件,与后端约定对齐:

// src/constants/permissions.js —— 权限码:与后端 permissions 字段一一对应
export const PERM = {
  ORDER_CREATE: 'order:create',
  ORDER_EDIT: 'order:edit',
  ORDER_DELETE: 'order:delete',
  ORDER_APPROVE: 'order:approve',
  ORDER_ASSIGN: 'order:assign', // 批量指派销售(仅超管/运营)
  MEMBER_MANAGE: 'member:manage',
  REPORT_EXPORT: 'report:export',
}

指令的天生局限:mounted 后才判定,且把元素 display: none 后权限集合变化时能恢复(有 updated 钩子),但如果改用「直接 removeChild」就无法恢复。更稳妥的替代是 v-if="userStore.hasPermission(PERM.ORDER_DELETE)"——需要响应式联动时优先 v-if,纯静态场景用指令更省事(易错点 4)。

11.8 登出与状态清理

登出不是「删个 token」。漏掉任何一处,下一个用户就会看到上一个用户的残留。

// src/composables/useLogout.js —— 完整登出:接口尽力调,本地必须清干净
import { useRouter } from 'vue-router'
import { logoutApi } from '@/api/auth'
import { useUserStore } from '@/stores/user'
import { useDictStore } from '@/stores/dict'
import { useOrderDraftStore } from '@/stores/orderDraft'

export function useLogout() {
  const router = useRouter()

  async function logout() {
    try {
      await logoutApi() // 让后端吊销 refreshToken;失败不阻塞本地清理
    } catch { /* 接口失败也要继续 */ }

    const userStore = useUserStore()
    const dictStore = useDictStore()
    const draftStore = useOrderDraftStore()

    userStore.clearAuth()        // token + 个人信息
    dictStore.$patch({ orderStatusOptions: [], loadedAt: 0 }) // 字典缓存按人隔离的清掉
    draftStore.clear()           // 个人草稿

    router.replace({ name: 'login' }) // replace:登出后不应「后退」回系统
  }

  return { logout }
}

清理清单(写进规范):accessToken、refreshToken、用户信息与角色、按用户隔离的缓存(字典/草稿)、进行中的请求(第 09 章 AbortController 已在卸载时处理)。

🛠 渐进式实战练习

练习 1 · 基础(10–20 min):登录页 + 守卫白名单

任务描述:实现 §11.3 的登录页(验证码可用 mock 假数据)与 §11.5 守卫的前两步(白名单 + 未登录拦截);访问受保护页面时跳登录并带 redirect,登录成功后回跳。

提示:mock 环境下 getCaptcha 返回 { captchaId: 'mock', image: 'data:image/svg+xml;...' };回跳用 route.query.redirect。

验收标准:

  • [ ] 未登录直达 /orders → 落到 /login?redirect=/orders;
  • [ ] 登录成功后自动回到 /orders 而不是固定去 /dashboard;
  • [ ] 已登录状态访问 /login 的行为符合你的设计(建议重定向进系统)。
参考答案(已登录访问 /login 的处理)
// 守卫白名单分支加强:
router.beforeEach(async (to) => {
  const userStore = useUserStore()
  if (to.meta.public) {
    // 已登录用户访问登录页:送回工作台
    if (to.path === '/login' && userStore.isLoggedIn) return { path: '/dashboard' }
    return true
  }
  // ...后续分支
})

练习 2 · 进阶(30–60 min):v-permission + 403 页 + 动态菜单

任务描述:实现 §11.7 的指令与权限码、§11.6 的动态菜单与 filterMenus、以及 403 页;用「切换 mock 角色」的方式验证三种角色(sales / finance / super_admin)看到的菜单与按钮不同。

提示:给 Devtools 控制台留一个 window.__setMockRole(role) 便于演示;filterMenus 是纯函数,顺手写三个断言自测。

验收标准:

  • [ ] sales 登录:看不到「系统设置」,订单页无「删除订单」按钮;
  • [ ] finance 登录:能看到报表中心,有「审批通过」按钮;
  • [ ] 直接输 URL 访问 /settings/members(非超管)→ 403 页,from 参数正确。
参考答案(filterMenus 的三个断言)
import { filterMenus } from '@/router/menus'

// sales:5 项变 2 项(工作台/订单管理),无系统设置
console.assert(filterMenus(menuConfig, ['sales']).length === 2, 'sales 菜单数应为 2')
// finance:含报表中心,不含商品目录
const financeMenus = filterMenus(menuConfig, ['finance']).map((m) => m.path)
console.assert(financeMenus.includes('/reports'), 'finance 应见报表')
console.assert(!financeMenus.includes('/products'), 'finance 不应见商品目录')
// 超管:全量
console.assert(filterMenus(menuConfig, ['super_admin']).length === menuConfig.length)

练习 3 · 挑战(选做,60+ min):无感刷新联调 + 登出闭环

任务描述:把第 09 章 §9.5 的无感刷新接入 userStore(tryRestoreSession 复用刷新逻辑),并用 mock 验证:①刷新页面后自动恢复登录态(走 refreshToken → 新 access → /auth/me);②「2 小时后」(mock 加速)任一请求自动续期成功;③登出后所有 store 清空、刷新登录页不能再恢复会话。

提示:mock 把 accessToken 设为「5 秒后过期」模拟加速;tryRestoreSession 内部先调一次 refreshAccessToken 再 ensureProfile。

验收标准:

  • [ ] 刷新页面停在 /orders 不被踢回登录页(自动恢复会话);
  • [ ] token 过期后的首个请求触发一次 refresh,用户无感知;
  • [ ] 登出后手动回退历史到 /orders,被守卫送回登录页。
参考答案(tryRestoreSession 完整版)
// src/stores/user.js 追加(复用第 09 章的 refreshAccessToken)
import { refreshAccessToken } from '@/api/request' // 从 request.js 导出该函数

async function tryRestoreSession() {
  if (!refreshToken.value) return false
  try {
    await refreshAccessToken() // 换新 accessToken(内存)
    profile.value = await fetchMe()
    return true
  } catch {
    clearAuth()
    return false
  }
}

⚠️ 常见易错点

易错点 1 · 守卫白名单漏配,登录页自我重定向

// ❌ /login 没进白名单:未登录访问任何页都 return /login,
//    包括 /login 自己 → 无限重定向,浏览器报「重定向次数过多」
router.beforeEach((to) => {
  if (!userStore.isLoggedIn) return { path: '/login' }
})
// ✅ 第一行先放行公开路由(meta.public 或白名单数组),再做登录判断
router.beforeEach(async (to) => {
  if (to.meta.public) return true
  if (!userStore.isLoggedIn && !userStore.refreshToken) {
    return { path: '/login', query: { redirect: to.fullPath } }
  }
  // ...
})

易错点 2 · token 存储只图省事,无视 XSS 暴露面

// ❌ 双 token 全部长期落在 localStorage:任何一次 XSS 注入
//    (第三方脚本、富文本漏洞)都能同时偷走两把钥匙
localStorage.setItem('tokens', JSON.stringify({ at, rt }))
// ✅ 最小暴露面:accessToken 只进内存(Pinia),仅 refreshToken 持久化
//    并配合「不渲染未消毒 HTML / 不引入不可信脚本 / CSP」基本防线(§11.4 权衡表)
function setTokens({ accessToken: at, refreshToken: rt }) {
  accessToken.value = at
  localStorage.setItem(RT_KEY, rt) // 只有 7 天期、单次出现的 refresh 落盘
}

易错点 3 · 在前端「校验」图形验证码

// ❌ 验证码答案出现在前端(mock 疏忽或设计错误):
//    攻击者直接读 DOM/接口拿答案,验证码形同虚设
if (form.captchaCode !== captcha.answer) return ElMessage.error('验证码错误')
// ✅ 前端只负责「取码、展示、随表单提交 captchaId + captchaCode」;
//    比对永远在后端会话里进行,失败计数(5 次锁 10 分钟)也在后端
await login({ username, password, captchaId: captcha.captchaId, captchaCode: form.captchaCode })

易错点 4 · 指令直接移除节点,权限变化后无法恢复

// ❌ el.parentNode.removeChild(el):元素被物理删除。
//    之后角色热更新(切换账号/权限刷新)时 updated 钩子里 el 已脱离文档,永远回不来
function check(el, binding) {
  if (!allowed) el.parentNode?.removeChild(el)
}
// ✅ 用 display 控制可见性(可恢复),或需要强响应式时改用 v-if + hasPermission
function check(el, binding) {
  el.style.display = allowed ? '' : 'none'
}
// 强响应式场景:<el-button v-if="userStore.hasPermission(PERM.ORDER_DELETE)">删除</el-button>

易错点 5 · 刷新页面后守卫读不到 profile,直接踢回登录

// ❌ accessToken 在内存里,刷新后必然为空;
//    守卫只判断 isLoggedIn → refreshToken 明明有效却被踢去登录页
router.beforeEach(async (to) => {
  if (!userStore.isLoggedIn) return '/login' // 刷新后 100% 命中
})
// ✅ 有 refreshToken 就先恢复会话(ensureProfile 单飞锁,§11.5),
//    恢复失败才送回登录页
if (!userStore.profile) {
  try { await userStore.ensureProfile() } catch { return { path: '/login' } }
}

易错点 6 · 登出只清 token,残留个人数据

// ❌ 只删 token:字典缓存、订单草稿、Pinia 里的 profile 都在,
//    换账号登录看到上一人的待办与草稿(越权观感 + 数据事故)
function logout() { localStorage.removeItem('rt'); router.replace('/login') }
// ✅ 按 §11.8 清单逐项清理:token、profile、按用户隔离的缓存与草稿
userStore.clearAuth()
dictStore.$patch({ orderStatusOptions: [], loadedAt: 0 })
draftStore.clear()
router.replace({ name: 'login' })

✅ 最佳实践

  1. 权限三级分治、一处声明:菜单 roles(menuConfig)、页面 roles(meta.roles)、按钮码(PERM 常量)各自收口,都源自附录A §2 的角色矩阵。
  2. 守卫顺序固定:白名单 → 登录态 → 会话恢复 → 角色校验 → 403;团队内不改顺序,代码可预期。
  3. redirect 回跳链路必须闭环:拦截时 query.redirect = to.fullPath,登录成功 router.replace(redirect),并对 redirect 值做「必须以 / 开头」的校验防开放重定向。
  4. 会话恢复要有单飞锁:ensureProfile 防并发重复请求;恢复失败才登出,不要「一刷新就踢人」。
  5. token 存储决策写进 ADR:方案、理由、缓解措施三行说清,新同事不用重新吵架。
  6. 登出清理清单化:token / profile / 用户态缓存 / 草稿 / 在途请求,逐项列在 useLogout 注释里,漏项一测便知。
  7. 权限码与状态机常量同源:前端 PERM 与后端 permissions 字段逐字对齐,命名进 constants/permissions.js,禁止魔法字符串。
  8. 演示与测试用 mock 角色切换:window.__setMockRole 只在 dev 环境注入,让权限验收不依赖真实账号体系。

📌 本章小结

  • 前端权限是体验层:菜单级(过滤配置)、页面级(守卫 + meta.roles → 403)、按钮级(v-permission / v-if + hasPermission),真正的安全边界永远在后端。
  • 双令牌时序三要素:单飞锁、等待队列(共享 Promise)、重放与 _retried 兜底;实现载体在第 09 章 §9.5。
  • token 存储是权衡题:access 进内存、refresh 最小化持久化是内部系统的务实解;HttpOnly Cookie 适合公网,代价是 CSRF 防护。
  • 刷新页面不掉登录的两个支点:refreshToken 恢复会话 + ensureProfile 单飞;登录回跳靠 query.redirect 闭环。
  • 登出 = 清理清单的执行,漏一项就是数据事故。

下一章预告:路由、状态、请求、表单、鉴权五块拼图全部就位。第 12 章把它们装配成完整的「轻订单」管理系统:目录结构、M1–M4 里程碑与验收清单。

📄 第 12 章 · 中级实战:轻订单管理系统 12-中级实战-轻订单管理系统.md

📍 阶段:中级阶段(第 07–12 章)|⏱ 预计学时:12–16 小时(建议分 4 次完成)|🔗 前置:第 07–11 章

⬅ 上一章 | 📖 本版目录 | ➡ 下一章

💡 对照学习:本页是 Vue 版第 12 章。React 版对应章节为 第 12 章 · 中级实战:轻订单管理系统。两版需求、里程碑、验收清单完全一致,技术方案对照:Vue Router 4 ≈ React Router 7;Pinia ≈ Redux Toolkit/Zustand;自研 useOrderList ≈ TanStack Query;Element Plus Form ≈ React Hook Form + Zod;v-permission ≈ <Auth> 组件。业务需求基准:附录A §3.1–3.3。

🎯 学习目标

学完本章,你将能够:

  1. 把附录A 的需求条目(§3.1–3.3)拆解为可验收的四个里程碑(M1–M4),并按里程碑顺序独立开发;
  2. 产出并维护一个结构清晰的中后台工程目录,说出每个目录的职责与依赖方向;
  3. 搭建教学 mock 服务(Vite 中间件),在不依赖后端的情况下完成全部前端联调;
  4. 打通「登录 → 动态菜单 → 订单列表/详情 → 新建订单 → 权限收口」的完整链路;
  5. 用状态机常量与权限码驱动订单状态流转按钮,避免逻辑散落在组件里;
  6. 执行验收清单与自测用例,交付一个「能被同事接手」的可用版本;
  7. 明确本阶段遗留的已知问题,作为第三部分(性能、测试、工程化)的输入。

📖 核心知识点

12.1 需求 → 里程碑拆解

对照附录A §3.1–3.3,中级阶段交付「可用版本」的四个里程碑:

里程碑 内容(对应附录A) 依赖章节 建议耗时
M1 骨架与登录 路由骨架、Layout、登录页(含验证码)、守卫、动态菜单、403/404 第 07/11 章 3–4 h
M2 订单列表与详情 列表(分页/筛选/排序/竞态)、详情(明细/操作日志/状态流转按钮) 第 08/09 章 4–5 h
M3 新建/编辑订单 表单(客户搜索/明细行/金额联动/折扣审批提示/草稿暂存) 第 10 章 3–4 h
M4 权限收口与验收 按钮级权限全覆盖、批量导出/指派、验收清单执行 第 11/12 章 2–3 h

里程碑纪律:每个里程碑结束时系统必须可运行、可演示——M1 结束能登录看到菜单,M2 结束能查单看单,以此类推。不要四件事同时开工。

12.2 目录结构全览

「轻订单」中后台的完整目录。依赖方向自上而下:views → composables/stores → api → request → env;constants、utils 是所有人可用的底层,禁止反向依赖(utils 里不许 import views)。

qing-order-admin/
├── .env.development              # VITE_API_BASE_URL=/api/v1、VITE_USE_MOCK=true
├── .env.production               # 生产域名,禁止硬编码进代码
├── vite.config.js                # @ 别名、/api/v1 代理、mock 插件(仅 dev)
├── index.html
└── src/
    ├── main.js                   # createApp:pinia → router → 指令 → 挂载
    ├── App.vue
    ├── router/
    │   ├── index.js              # createRouter + scrollBehavior + 全局守卫
    │   ├── routes.js             # 路由表:懒加载 + meta(public/roles/title/hidden)
    │   └── menus.js              # 菜单配置 + filterMenus(与 meta.roles 同源)
    ├── stores/
    │   ├── user.js               # 登录态/角色/权限码/ensureProfile
    │   ├── dict.js               # 字典缓存(过期 + 并发去重 + 持久化白名单)
    │   └── orderDraft.js         # 订单草稿暂存区(版本号兼容)
    ├── api/
    │   ├── request.js            # axios 实例:token 注入/错误归一化/40102 无感刷新
    │   ├── auth.js               # 登录/验证码/me/登出
    │   └── orders.js             # 订单域接口 + GET 重试
    ├── composables/
    │   ├── useOrderList.js       # 分页/筛选/防抖/竞态/导出
    │   └── useLogout.js          # 登出清理清单
    ├── constants/
    │   ├── order.js              # 状态机:枚举/文案/流转表/canTransition
    │   └── permissions.js        # 权限码 PERM(与后端逐字对齐)
    ├── directives/
    │   └── permission.js         # v-permission 自定义指令
    ├── utils/
    │   ├── money.js              # 分/元换算与金额计算(单测重点)
    │   └── debounce.js
    ├── components/               # 通用展示组件
    │   ├── StatusTag.vue         # 状态标签(文案 + 颜色由 constants 驱动)
    │   ├── StatusSelect.vue      # 状态筛选下拉(读字典缓存)
    │   └── PageHeader.vue
    ├── views/
    │   ├── layout/
    │   │   ├── AdminLayout.vue   # 外壳:菜单/面包屑/顶栏/router-view
    │   │   └── components/       # SideMenu.vue、Breadcrumb.vue
    │   ├── login/LoginView.vue
    │   ├── dashboard/DashboardView.vue
    │   ├── orders/
    │   │   ├── OrderListView.vue
    │   │   ├── OrderDetailView.vue
    │   │   └── OrderFormView.vue # 新建/编辑共用
    │   ├── error/
    │   │   ├── ForbiddenView.vue # 403
    │   │   └── NotFoundView.vue  # 404
    │   └── products/ProductListView.vue
    └── styles/
        └── main.css

12.3 教学 mock:Vite 中间件

附录A §7 约定练习阶段统一 Mock。方案:一个仅 dev 生效的 Vite 插件,在 /api/v1 前缀上实现教学约定的接口。好处是前端代码零 mock 痕迹——切真实后端只需改环境变量。

// mock/mockServer.js —— 教学用 mock:实现附录A §5 的核心接口(节选:订单列表 + 登录)
const ok = (data) => JSON.stringify({ code: 0, message: 'ok', data })
const fail = (code, message) => JSON.stringify({ code, message })

// 预置订单:金额单位为「分」
const orders = Array.from({ length: 36 }, (_, i) => ({
  id: `o_${i + 1}`,
  orderNo: `QO2026090${(i % 9) + 1}-${String(i + 1).padStart(6, '0')}`,
  customerId: `c_${(i % 6) + 1}`,
  customerName: ['青云智造', '南方电网设备', '华云信息', '蓝湾科技', '北岭矿业', '启明教育'][i % 6],
  ownerName: ['小张', '小李', '小王'][i % 3],
  status: ['draft', 'pending_review', 'approved', 'shipped', 'completed', 'cancelled'][i % 6],
  items: [{ skuId: 's_01', skuName: '智能网关 G2', quantity: 10 + i, unitPrice: 129900, discountPercent: 100 }],
  wholeDiscountPercent: 100,
  totalAmount: (10 + i) * 129900,
  createdAt: new Date(Date.now() - i * 86400000).toISOString(),
}))

const users = {
  xiaozhang: { password: 'demo123', user: { id: 'u_001', name: '运营小张', roles: ['operator'], permissions: ['order:create', 'order:edit', 'order:assign'] } },
  laowang: { password: 'demo123', user: { id: 'u_002', name: '销售小王', roles: ['sales'], permissions: ['order:create'] } },
  caiwu: { password: 'demo123', user: { id: 'u_003', name: '财务老陈', roles: ['finance'], permissions: ['order:approve', 'report:export'] } },
}

export function mockPlugin() {
  return {
    name: 'qing-order-mock',
    configureServer(server) {
      server.middlewares.use('/api/v1', async (req, res, next) => {
        const url = (req.originalUrl || '').split('?')[0].replace('/api/v1', '')
        // POST body 读取
        const body = await new Promise((resolve) => {
          if (req.method === 'GET') return resolve({})
          let raw = ''
          req.on('data', (c) => (raw += c))
          req.on('end', () => { try { resolve(JSON.parse(raw || '{}')) } catch { resolve({}) } })
        })

        res.setHeader('Content-Type', 'application/json; charset=utf-8')

        if (url === '/auth/captcha') {
          return res.end(ok({ captchaId: 'mock', image: 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="80" height="32"><text x="8" y="22">7</text></svg>' }))
        }
        if (url === '/auth/login') {
          const u = users[body.username]
          if (!u || u.password !== body.password) return res.end(fail(40001, '账号或密码错误'))
          return res.end(ok({ accessToken: 'mock-access-token', refreshToken: 'mock-refresh-token', user: u.user }))
        }
        if (url === '/orders') {
          const { page = 1, pageSize = 20, status, keyword } = req.url ? Object.fromEntries(new URL(req.originalUrl, 'http://x').searchParams) : {}
          let list = orders
          if (status) list = list.filter((o) => o.status === status)
          if (keyword) list = list.filter((o) => o.orderNo.includes(keyword) || o.customerName.includes(keyword))
          const start = (Number(page) - 1) * Number(pageSize)
          return res.end(ok({ list: list.slice(start, start + Number(pageSize)), total: list.length, page: Number(page), pageSize: Number(pageSize) }))
        }
        // ……/orders/:id、/submit、/approve、/cancel、/auth/me、/auth/logout 按同一模式补全
        res.end(fail(40400, `mock 未实现: ${url}`))
      })
    },
  }
}
// vite.config.js —— 挂载 mock(dev 环境判断用 VITE_USE_MOCK 控制)
import { mockPlugin } from './mock/mockServer'

export default defineConfig({
  plugins: [vue(), ...(process.env.VITE_USE_MOCK !== 'false' ? [mockPlugin()] : [])],
  // ……alias 与 proxy 同第 09 章
})

12.4 里程碑 M1:骨架与登录(第 07/11 章装配)

M1 的全部技术在 07/11 章已有成品代码,本里程碑的工作是装配与联调。装配顺序(有依赖关系,勿乱):

// src/main.js —— 应用初始化顺序:先 pinia(store 依赖)→ 指令 → router(守卫依赖 store)
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import { permissionDirective } from '@/directives/permission'

const app = createApp(App)
app.use(createPinia())              // 1. 状态先行:守卫与指令都要读 store
app.directive('permission', permissionDirective) // 2. 指令依赖 store
app.use(router)                     // 3. 路由最后:首个导航在守卫里做会话恢复
app.mount('#app')

路由表中给需要权限的页面标 meta.roles(示例):

// src/router/routes.js(M1 增量)
{
  path: 'settings/members',
  name: 'member-manage',
  component: () => import('@/views/settings/MemberListView.vue'),
  meta: { title: '成员管理', roles: ['super_admin'] }, // 页面级权限(附录A §3.7)
}

M1 验收:三种 mock 账号登录后菜单不同;sales 直达 /settings/members 被 403;刷新页面不掉登录;登录后回跳原始 URL。

12.5 里程碑 M2:订单列表与详情(第 08/09 章装配)

列表页直接消费 useOrderList;详情页需要状态机驱动。先把附录A 的状态机收敛为常量(全项目唯一):

// src/constants/order.js —— 订单状态机(附录A §3.3 的代码化,禁止组件里手写字符串)
export const ORDER_STATUS = {
  DRAFT: 'draft',
  PENDING_REVIEW: 'pending_review',
  APPROVED: 'approved',
  SHIPPED: 'shipped',
  COMPLETED: 'completed',
  REJECTED: 'rejected',
  CANCELLED: 'cancelled',
}

export const STATUS_TEXT = {
  [ORDER_STATUS.DRAFT]: '草稿',
  [ORDER_STATUS.PENDING_REVIEW]: '待审核',
  [ORDER_STATUS.APPROVED]: '已审核',
  [ORDER_STATUS.SHIPPED]: '已发货',
  [ORDER_STATUS.COMPLETED]: '已完成',
  [ORDER_STATUS.REJECTED]: '已拒绝',
  [ORDER_STATUS.CANCELLED]: '已取消',
}

export const STATUS_TAG_TYPE = {
  [ORDER_STATUS.DRAFT]: 'info',
  [ORDER_STATUS.PENDING_REVIEW]: 'warning',
  [ORDER_STATUS.APPROVED]: 'success',
  [ORDER_STATUS.SHIPPED]: 'primary',
  [ORDER_STATUS.COMPLETED]: 'success',
  [ORDER_STATUS.REJECTED]: 'danger',
  [ORDER_STATUS.CANCELLED]: 'info',
}

/** 允许的状态流转(附录A 状态机图) */
export const ORDER_TRANSITIONS = {
  [ORDER_STATUS.DRAFT]: [ORDER_STATUS.PENDING_REVIEW, ORDER_STATUS.CANCELLED],
  [ORDER_STATUS.PENDING_REVIEW]: [ORDER_STATUS.APPROVED, ORDER_STATUS.REJECTED, ORDER_STATUS.CANCELLED],
  [ORDER_STATUS.APPROVED]: [ORDER_STATUS.SHIPPED, ORDER_STATUS.CANCELLED],
  [ORDER_STATUS.SHIPPED]: [ORDER_STATUS.COMPLETED, ORDER_STATUS.CANCELLED],
  [ORDER_STATUS.COMPLETED]: [],
  [ORDER_STATUS.REJECTED]: [ORDER_STATUS.PENDING_REVIEW, ORDER_STATUS.CANCELLED], // 编辑后重新提交
  [ORDER_STATUS.CANCELLED]: [],
}

export function canTransition(from, to) {
  return (ORDER_TRANSITIONS[from] || []).includes(to)
}
<!-- src/components/StatusTag.vue —— 状态标签:文案与颜色全部来自常量 -->
<script setup>
import { STATUS_TEXT, STATUS_TAG_TYPE } from '@/constants/order'
import { computed } from 'vue'

const props = defineProps({ status: { type: String, required: true } })
const text = computed(() => STATUS_TEXT[props.status] || props.status)
const type = computed(() => STATUS_TAG_TYPE[props.status] || 'info')
</script>

<template>
  <el-tag :type="type">{{ text }}</el-tag>
</template>

详情页的状态流转按钮 = 状态机(能不能转)∧ 权限(谁能转):

<!-- src/views/orders/OrderDetailView.vue —— 操作区(节选) -->
<script setup>
import { computed } from 'vue'
import { ElMessageBox } from 'element-plus'
import { ORDER_STATUS, canTransition } from '@/constants/order'
import { PERM } from '@/constants/permissions'
import { useUserStore } from '@/stores/user'
import { cancelOrder, reviewOrder, submitOrder } from '@/api/orders'

const props = defineProps({ order: { type: Object, required: true } })
const userStore = useUserStore()

const isFinance = computed(() => userStore.roles.includes('finance'))
const canSubmit = computed(() =>
  [ORDER_STATUS.DRAFT, ORDER_STATUS.REJECTED].includes(props.order.status),
)
const canCancel = computed(() =>
  canTransition(props.order.status, ORDER_STATUS.CANCELLED)
  && (userStore.isSuperAdmin || props.order.ownerName === userStore.profile?.name), // 只能本人或超管
)

async function onCancel() {
  const { value: reason } = await ElMessageBox.prompt('取消订单需填写原因', '取消订单', {
    inputPattern: /\S+/,
    inputErrorMessage: '原因不能为空',
  }).catch(() => ({ value: '' }))
  if (!reason) return
  await cancelOrder(props.order.id, reason) // 任何非终态 → cancelled
  emit('refresh')
}
defineEmits(['refresh'])
</script>

<template>
  <div class="order-actions">
    <el-button v-if="canSubmit" v-permission="PERM.ORDER_EDIT" type="primary" @click="$emit('refresh', submitOrder(order.id))">
      {{ order.status === 'rejected' ? '重新提交' : '提交审核' }}
    </el-button>
    <el-button v-if="order.status === 'pending_review' && isFinance" v-permission="PERM.ORDER_APPROVE" type="success" @click="$emit('refresh', reviewOrder(order.id, 'approve'))">
      审批通过
    </el-button>
    <el-button v-if="canCancel" type="danger" plain @click="onCancel">取消订单</el-button>
  </div>
</template>

M2 验收:筛选/分页/防抖/竞态符合第 09 章验收标准;金额显示无浮点尾巴;非本人订单的 sales 看不到取消按钮。

12.6 里程碑 M3:新建/编辑订单(第 10 章装配)

表单页直接采用第 10 章的 OrderFormView 全套:动态明细行(_key 防错位)、金额联动(分单位 computed)、折扣审批提示(< 90)、提交锁、草稿暂存闭环。M3 只补充两件「编辑态」的事:

// OrderFormView.vue 编辑态增量(节选)
import { fetchOrderById } from '@/api/orders'

// 1) 编辑回显:草稿/已拒绝订单才可编辑(附录A rejected 可编辑后重新提交)
const isEdit = computed(() => Boolean(route.params.id))
onMounted(async () => {
  if (!isEdit.value) return
  const order = await fetchOrderById(route.params.id)
  Object.assign(form, {
    customerId: order.customerId,
    customerName: order.customerName,
    wholeDiscountPercent: order.wholeDiscountPercent,
    remark: order.remark ?? '',
    items: order.items.map((it) => ({ ...it, _key: ++itemKeySeed })),
  })
  await nextTick()
  formRef.value.clearValidate() // 回显不是用户输入,别亮红字
})

M3 验收:折扣 85 时出现送审提示且提交后进入 pending_review;明细行校验/删除/联动全部符合第 10 章验收标准;中途离开草稿可恢复。

12.7 里程碑 M4:权限收口与验收

M4 是「找茬」里程碑:把附录A §2 的权限矩阵逐格核对。列表页的批量操作是最后的增量:

<!-- OrderListView.vue 批量操作(节选):批量导出(所有人)+ 批量指派(仅超管/运营) -->
<script setup>
import { ref } from 'vue'
import { PERM } from '@/constants/permissions'

const selectedRows = ref([])

async function handleExport() {
  if (!selectedRows.value.length) return ElMessage.warning('请先勾选订单')
  await exportSelected(selectedRows.value.map((r) => r.id)) // useOrderList 提供的导出
}
</script>

<template>
  <div class="batch-bar">
    <el-button v-permission="PERM.REPORT_EXPORT" :disabled="!selectedRows.length" @click="handleExport">
      导出选中(CSV)
    </el-button>
    <el-button v-permission="PERM.ORDER_ASSIGN" :disabled="!selectedRows.length" type="primary" plain>
      批量指派销售
    </el-button>
  </div>
</template>

12.8 验收清单与自测用例

功能验收清单(全绿才算 M4 完成):

  • [ ] 登录:错误密码 toast 提示;验证码可点击刷新;mock 密码连错 5 次提示锁定(后端职责,前端正确展示 message);
  • [ ] 会话:刷新页面不掉登录;手动把 mock accessToken 改为过期值,任意请求触发一次 refresh 且业务请求最终成功;
  • [ ] 菜单级:operator/sales/finance 三种角色菜单互不相同,且与 menuConfig 的 roles 声明一致;
  • [ ] 页面级:无权角色直达 URL(/settings/members、/reports)落到 403 页,from 参数正确;
  • [ ] 按钮级:删除/指派仅超管与运营可见;审批按钮仅财务可见;取消按钮仅订单本人或超管可见;
  • [ ] 列表:关键词(防抖 300ms)、状态、分页(20/页)、排序均生效;快速切换状态无旧数据覆盖(Network 可见 canceled);
  • [ ] 详情:基本信息、明细行金额(分→元显示正确)、操作日志时间线、状态流转按钮符合状态机;
  • [ ] 新建/编辑:数量 1–9999、行折扣 0–100 校验生效;整单折扣 85 提交后状态为 pending_review;草稿暂存可恢复;
  • [ ] 批量:勾选导出得到 CSV;无勾选时按钮禁用并有提示;
  • [ ] 登出:store 全部清空(Devtools 验证),回退历史无法再进入系统。

核心自测用例(写进 docs/test-cases.md,第三部分将自动化):

用例 1  sales 登录 → 下单(折扣 85%)→ 提交 → 状态为 pending_review,本人无审批按钮
用例 2  finance 登录 → 打开该订单 → 审批通过 → 状态 approved,出现发货按钮
用例 3  operator 登录 → 列表勾选 2 单 → 导出 CSV → 文件包含 2 行数据 + 表头
用例 4  sales 直达 /settings/members → 403 页 → 点「回工作台」正常进入
用例 5  任一角色在详情页 5 秒内连续点击「提交审核」→ 只发出 1 次请求

🛠 渐进式实战练习

练习 1 · 基础(M1,3–4 h):骨架、登录与动态菜单

任务描述:完成 M1 全部内容——按 §12.2 建目录,装配路由/守卫/登录页/动态菜单/403/404,配好 mock 的 /auth/captcha 与 /auth/login,用三个 mock 账号完成权限验证。

提示:所有代码第 07/11 章都有成品,本练习练的是「装配顺序」(§12.4)与路径别名等工程细节;先让 mock 跑通再接表单。

验收标准:

  • [ ] pnpm dev 后可登录,三种角色菜单不同;
  • [ ] sales 直达 /settings/members → 403;刷新不掉登录;登录回跳正确;
  • [ ] 目录结构与 §12.2 一致(constants、utils 不反向依赖业务层)。
参考答案(M1 装配自检清单)
1. pnpm create vue@latest → 选 Router(或手工补 vue-router/pinia)
2. vite.config.js:@ 别名 + mockPlugin 挂载
3. main.js 顺序:createPinia → directive → router → mount(§12.4)
4. routes.js:login(public) + Layout(children: dashboard/orders/…) + 404 兜底
5. 守卫四步:白名单 → 登录态 → ensureProfile → meta.roles(§11.5)
6. SideMenu 消费 filterMenus(menuConfig, userStore.roles)
7. 三个 mock 账号逐一登录验证菜单矩阵

练习 2 · 进阶(M2 + M3,7–9 h):订单列表、详情与新建

任务描述:完成 M2 与 M3——列表页(useOrderList + StatusTag/StatusSelect + 排序 + 导出按钮占位)、详情页(状态机驱动的操作区 + 操作日志时间线,日志可先 mock 三条静态数据)、新建/编辑表单(第 10 章全套 + 编辑回显)。

提示:时间线用 el-timeline;排序参数 sort/order 加进 fetchOrders 的 params 并接 el-table 的 @sort-change;编辑态入口只在「详情页 + 状态为 draft/rejected」时显示。

验收标准:

  • [ ] 列表四大能力(筛选/分页/排序/竞态)通过第 09 章验收标准;
  • [ ] 详情页状态流转按钮 = 状态机 ∧ 权限,用例 1、2、5 全部通过;
  • [ ] 表单通过第 10 章练习 2/3 的全部验收标准,用例中「折扣 85 → pending_review」成立。
参考答案(el-table 排序接入)
<el-table :data="list" @sort-change="handleSortChange">
  <el-table-column prop="createdAt" label="创建时间" sortable="custom" />
  <el-table-column prop="totalAmount" label="订单总额(分)" sortable="custom">
    <template #default="{ row }">{{ fenToYuanStr(row.totalAmount) }}</template>
  </el-table-column>
</el-table>
// useOrderList 追加:排序进入 filters 并立即重查
filters.sort = initialQuery.sort || 'createdAt'
filters.order = initialQuery.order || 'desc'

function handleSortChange({ prop, order }) {
  filters.sort = prop
  filters.order = order === 'ascending' ? 'asc' : 'desc'
  load()
}
// load() 的 params 已含 sort/order(§9.8),记得同步进 URL query

练习 3 · 挑战(M4,2–3 h):权限收口 + 完整验收

任务描述:完成 M4——批量导出/指派、按钮级权限全量核对、执行 §12.8 的 10 条功能验收清单与 5 条自测用例,把不通过项修到全绿;最后写一份 100 字以内的「已知遗留问题」清单(给第三部分当输入)。

提示:验收时切换三个角色 + 一个未登录态逐一过清单;遗留问题的典型候选:万级数据未虚拟滚动、无单元测试、bundle 未分析。

验收标准:

  • [ ] 验收清单 10 项全绿,自测用例 5 条全部通过;
  • [ ] 批量导出 CSV 内容正确(表头 + 选中行);
  • [ ] 产出「已知遗留问题」清单并提交到项目 docs/ 目录。
参考答案(遗留问题清单的推荐写法)
<!-- docs/known-issues.md -->
# 中级阶段遗留问题(输入:第三部分)

| # | 问题 | 影响 | 对应章节 |
|---|------|------|---------|
| 1 | 订单列表 5000+ 行未虚拟滚动,滚动掉帧 | 体验 | 第 13 章 |
| 2 | 首屏 bundle 未分析,疑似超 300KB 预算 | 加载 | 第 14 章 |
| 3 | money.js / filterMenus 无单测,回归靠手点 | 质量 | 第 15 章 |
| 4 | 登录→下单→审批主流程无 E2E | 质量 | 第 16 章 |
| 5 | mock 与真实接口的响应形状靠人肉对齐 | 工程化 | 第 17 章 |

⚠️ 常见易错点

易错点 1 · 初始化顺序错误:守卫读不到 store

// ❌ router 先于 pinia 安装:守卫首次执行时 useUserStore() 抛错
//    「getActivePinia was called with no active Pinia」
app.use(router)
app.use(createPinia())
// ✅ 固定顺序:pinia → 依赖 store 的指令 → router → mount
app.use(createPinia())
app.directive('permission', permissionDirective)
app.use(router)
app.mount('#app')

易错点 2 · 同一份数据两处状态:列表进 store,组件里又存一份

// ❌ 列表数据放全局 store,组件里再 ref 一份「本地副本」:
//    谁改了都不通知谁,筛选一次就分叉
const localList = ref([])
watch(() => orderStore.list, (v) => (localList.value = v))
// ✅ 单一数据源:服务端态由 useOrderList 持有(第 09 章),
//    需要「跨页保留」的是筛选条件(URL query),不是数据本身
const { filters, list } = useOrderList(route.query)

易错点 3 · 金额单位在层与层之间悄悄变

// ❌ 接口给分、表单让用户输元、展示又乘 0.9:
//    同一笔金额出现三种口径,对账必炸
price: form.priceYuan * 0.9 // 元?分?折扣后?
// ✅ 全链路「分」:入口(表单元转分)/出口(fenToYuanStr)两处转换,
//    其余层一律不碰换算(第 09/10 章的 money.js 单点收口)
payload.items = form.items.map((it) => ({ ...it, unitPrice: it.unitPriceFen }))

易错点 4 · 菜单与路由的权限两处维护,悄悄不一致

// ❌ 菜单文件写 roles: ['operator'],路由 meta.roles 写 ['operator','sales']
//    —— sales 看得到菜单,点进去 403,永远修不完
// ✅ meta.roles 是唯一事实源,菜单配置引用/派生它;改动只发生在路由表
// menus.js: { path: '/reports', title: '报表中心', roles: [...] } 与
// routes.js: meta: { roles: [...] } 由同一份常量生成(§11.6 与 §12.5 同源)

易错点 5 · 状态流转按钮只判断角色不判断状态机

<!-- ❌ 只看权限:已完成订单也显示「取消」,点了后端 40001/40901 -->
<el-button v-permission="PERM.ORDER_EDIT" @click="onEdit">编辑</el-button>
<!-- ✅ 状态机(能不能)∧ 权限(谁能)双重判断,逻辑收敛到 computed -->
<el-button
  v-if="canSubmit"
  v-permission="PERM.ORDER_EDIT"
  @click="onEdit"
>编辑</el-button>
<!-- canSubmit = [DRAFT, REJECTED].includes(order.status),见 §12.5 -->

易错点 6 · mock 与真实接口双口径,切换当天全线崩

// ❌ 组件里偷偷写死 'http://localhost:3000' 或绕过 request 直接 fetch:
//    mock 一关,404、跨域、错误格式三连爆
fetch('http://localhost:3000/api/v1/orders').then((r) => r.json())
// ✅ mock 只在 dev server 层实现(§12.3),业务代码永远走 request 实例;
//    切真实后端 = 改 .env.production 的 VITE_API_BASE_URL 一行

✅ 最佳实践

  1. 里程碑验收制:每个 M 结束必须可演示;半成品集成是中级项目最常见的翻车姿势。
  2. 装配顺序固定:pinia → 指令 → router → mount 写进 main.js 注释,新人不用猜。
  3. 常量即文档:状态机、权限码、审批阈值(90)全部收敛进 constants/,附录A 的每条业务规则都能找到对应代码。
  4. 依赖方向单向:views → composables/stores → api → utils/constants,用 ESLint 的 import 规则固化(第 17 章)。
  5. mock 与业务代码物理隔离:mock 只存在于 Vite 插件层,切环境零改动。
  6. 验收清单先于编码写出:§12.8 的清单在 M1 之前就放 docs/ 里,开发即对表。
  7. 每个里程碑留「已知问题」:不追求中级阶段完美,把问题显式移交给高级阶段。
  8. 业务词与附录A 逐字一致:pending_review 不要写成 pendingReview,QO 前缀订单号全站统一——接口联调时拼写就是生产力。

📌 本章小结

  • 需求按 M1 骨架登录 → M2 列表详情 → M3 新建编辑 → M4 权限验收推进,每个里程碑必须可演示。
  • 目录结构的本质是依赖方向:单向的 views → 编排层 → api → utils,常量是全项目共享底座。
  • 状态机与权限码是「轻订单」的两根定海神针:按钮 = 状态机 ∧ 权限,两重判断缺一不可。
  • mock 藏在 dev server 层,业务代码对 mock 零感知;金额全程「分」,单位转换只发生在边界。
  • 验收清单 10 条 + 自测用例 5 条全绿,即完成中级阶段的交付;遗留问题显式移交给第三部分。

毕业去向:到这里你已经能独立交付一个企业级中后台模块。下一部分(第 13–18 章)回到这个系统做治理:万级列表卡顿(第 13 章)、首屏提速(第 14 章)、补测试(第 15/16 章)与工程化(第 17/18 章)。动身前,请先完成中级能力自查:阶段自查-中级能力清单.md。

📄 第 13 章 · 渲染性能优化 13-渲染性能优化.md

📍 阶段:高级阶段(第 13–18 章)|⏱ 预计学时:6–8 小时|🔗 前置:第 04 章、第 09 章、第 12 章

⬅ 上一章 | 📖 本版目录 | ➡ 下一章

💡 对照学习:本页是 Vue 版第 13 章。React 版对应章节为 第 13 章 · 渲染性能优化,概念映射:v-memo ≈ React.memo(语义不完全等价,见 §13.3);shallowRef ≈ useRef + 手动触发更新;keep-alive 无直接对应(React 需社区方案或状态缓存库);虚拟滚动 ≈ TanStack Virtual;稳定 key 两版语义一致。业务场景基准:附录A §4「万级数据滚动无可感知卡顿」。

🎯 学习目标

学完本章,你将能够:

  1. 说出一次数据变化驱动页面更新的完整链路:依赖收集 → 触发副作用 → 组件级 patch,并指出「细粒度依赖 + 组件是更新边界」这个 Vue 性能模型的关键结论;
  2. 使用 Vue Devtools(组件树 / Timeline)与 Chrome DevTools 性能面板定位「谁在渲染、渲染了多久」;
  3. 用 shallowRef、v-memo、稳定 key、组件拆分与状态下放四种手段治理不必要的渲染;
  4. 正确使用 keep-alive 缓存列表页,并用 include/max 与 onActivated/onDeactivated 规避内存泄漏;
  5. 手写一个固定行高的简化版虚拟滚动 composable,并解释「可视区渲染 + 空间占位 + DOM 回收」三要素;
  6. 用 vue-virtual-scroller 的 RecycleScroller 改造万级订单列表,达到附录A「滚动掉帧 < 5%」的目标;
  7. 沉淀一份可复用的「大表格渲染治理 checklist」,用于团队 code review。

📖 核心知识点

13.1 渲染链路原理:为什么「列表卡」要怪到组件头上

先建立心智模型,优化才有靶子。Vue 的渲染链路可以概括为三步:

数据变化(ref/reactive)
   │  ① 依赖收集阶段:渲染函数读取了哪些响应式数据,就记下「谁用了它」
   ▼
触发阶段:数据变化 → 通知订阅它的渲染副作用(每个组件一个)
   │  ② 更新粒度是「组件级」:某个组件的数据变了,默认重跑整个组件的渲染函数
   ▼
patch 阶段:新虚拟 DOM 树 vs 旧虚拟 DOM 树,只把差异 patch 到真实 DOM
   │  ③ patch 本身很快,慢的是「重跑渲染函数 + 大范围 diff」
   ▼
真实 DOM 更新

三条推论,直接决定优化手段:

  1. 数据变一次 ≠ 页面重渲染一次:只有订阅了这份数据的组件会重渲染。但如果把 1 万条订单 reactive 地塞在一个父组件里,任何一条变化都会让父组件整个重渲染——问题不在 Vue,在「订阅范围太大」。
  2. computed 有缓存:依赖没变的 computed 不会重算,也不会引发下游渲染(第 04 章 §4.3 的延伸)。
  3. diff 需要 key:v-for 的 key 是 diff 的「身份证」,key 不稳定会引发节点错位复用(§13.5 会展开)。

下面用一个最小实验验证「组件是更新边界」:

<!-- src/views/debug/RenderBoundary.vue:谁会重渲染? -->
<script setup lang="ts">
import { ref } from 'vue'
import HeavyRow from './HeavyRow.vue'

// 1 万条模拟订单(只保留演示需要的字段)
const orders = Array.from({ length: 10_000 }, (_, i) => ({
  id: `o-${i + 1}`,
  orderNo: `QO2026092900${String(i + 1).padStart(3, '0')}`,
  status: 'draft',
}))
const keyword = ref('')
</script>

<template>
  <input v-model="keyword" placeholder="搜索订单号" />
  <HeavyRow v-for="o in orders" :key="o.id" :order="o" />
</template>

在 Vue Devtools 的 Timeline 里观察:keyword 每敲一个字符,RenderBoundary 自身重渲染一次;而 HeavyRow 若 props 未变且内部无响应式依赖,则不会重渲染——前提是 HeavyRow 是独立组件且 props 引用稳定。如果 1 万行直接内联写在父组件模板里,父组件一次重渲染就要 diff 整棵万级子树,这就是「列表卡顿」最常见的根源。

13.2 定位工具:先测量,再优化

性能优化的第一纪律:没有测量就没有优化。三件工具按顺序用:

// src/main.ts:打开 Vue 内建性能追踪(仅开发环境)
import { createApp } from 'vue'
import App from './App.vue'

const app = createApp(App)

if (import.meta.env.DEV) {
  // 开启后可在浏览器 Performance 面板的 User Timing 里
  // 看到 component:init / component:update / component:render 等标记
  app.config.performance = true
}

app.use(createPinia()).use(router).mount('#app')
工具 能回答的问题 用法要点
Vue Devtools · 组件面板 「这个组件的 props/状态现在是什么」 检查传给列表项的 props 引用是否每次变化
Vue Devtools · Timeline 面板 「组件事件、更新事件何时发生、多密」 录制一次输入/滚动,看事件是否风暴式出现
Chrome DevTools · Performance 「掉帧发生在哪一帧、火焰图里谁最宽」 录制滚动 5s,看 FPS 轨道与最长的 Scripting 段

操作步骤(万级列表滚动卡顿的标准排查法):

  1. Performance 面板录制 → 滚动列表 3–5 秒 → 停止;
  2. 看顶部 FPS 轨道:掉帧(红/黄条)集中在哪个时间段;
  3. 展开该时段的火焰图,找最宽的调用栈——通常能看到 renderComponent / patch 反复出现;
  4. 记下组件名与原因(重渲染次数过多?单次渲染耗时过长?);
  5. 只针对「最宽的那一段」动手,改完重录对比。

13.3 减少渲染四板斧:shallowRef、v-memo、稳定 key、状态下放

第一斧:shallowRef——别让 Vue 深度代理万级数组。ref/reactive 会深度代理对象,1 万条订单每条都要建代理,开销可观;而列表渲染的典型写法是「整体替换、从不原地改」,天然适合浅响应。

// src/stores/orderList.ts(节选)
import { defineStore } from 'pinia'
import { shallowRef, triggerRef } from 'vue'
import { fetchOrders } from '@/api/orders'
import type { Order } from '@/types/order'

export const useOrderListStore = defineStore('orderList', () => {
  // 浅响应:只追踪「数组引用」本身,不深度代理 1 万条数据
  const orders = shallowRef<Order[]>([])
  const loading = shallowRef(false)

  async function load(params: OrderQuery) {
    loading.value = true
    try {
      const res = await fetchOrders(params)
      // 整体替换引用 → 触发依赖它的组件更新
      orders.value = res.data.list
    } finally {
      loading.value = false
    }
  }

  // 少数需要「原地改」的场景:修改后手动 triggerRef 通知
  function patchStatus(id: string, status: OrderStatus) {
    orders.value = orders.value.map((o) => (o.id === id ? { ...o, status } : o))
  }

  return { orders, loading, load, patchStatus }
})

第二斧:v-memo——给昂贵的列表行加「渲染缓存」。依赖数组里的值都没变时,整段子树跳过更新。这最接近 React 里 React.memo 的效果,但写法是模板内联的、粒度更细。

<!-- src/views/orders/OrderTable.vue(节选) -->
<script setup lang="ts">
import type { Order } from '@/types/order'

defineProps<{
  orders: Order[]
  selectedIds: Set<string>
}>()
</script>

<template>
  <tbody>
    <!-- 依赖数组必须覆盖子树用到的「全部」响应式值:
         order 对象、选中状态。漏一个就会出现「数据变了界面不更新」的 bug -->
    <tr
      v-for="order in orders"
      :key="order.id"
      v-memo="[order, selectedIds.has(order.id)]"
    >
      <td><input type="checkbox" :checked="selectedIds.has(order.id)" /></td>
      <td>{{ order.orderNo }}</td>
      <td>{{ order.customerName }}</td>
      <td>{{ order.ownerName }}</td>
      <td>{{ order.totalAmount }}</td>
    </tr>
  </tbody>
</template>

注意:v-memo 的依赖数组必须完整覆盖子树的响应式依赖,少写一个就埋雷。只对「确认昂贵且依赖明确」的行使用,不要全表滥用。

第三斧:稳定 key——diff 的身份证不能换人。key 必须来自数据的业务身份(order.id),不能来自下标或随机值,原因在 §13.5 与易错点 4 详述。

第四斧:组件拆分 + 状态下放。把「每次输入都会变」的状态留在叶子组件,让父组件不订阅它:

<!-- 改造前:keyword 提升在父组件 → 每次输入父组件连带万级子树重渲染 -->
<!-- OrderListView.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import OrderTable from './OrderTable.vue'

const keyword = ref('') // ❌ 与表格无关的输入状态驻留在父组件
</script>

<template>
  <input v-model="keyword" />
  <OrderTable />
</template>
<!-- 改造后:输入态下放到搜索组件,防抖后才向父组件提交查询参数 -->
<!-- SearchBar.vue -->
<script setup lang="ts">
import { ref, watch } from 'vue'
import { debounce } from '@/utils/debounce'

const emit = defineEmits<{ search: [keyword: string] }>()
const keyword = ref('')

// 输入时只有 SearchBar 自己重渲染;防抖 300ms 后才触发真正的列表请求
watch(keyword, debounce((kw: string) => emit('search', kw.trim()), 300))
</script>

<template>
  <el-input v-model="keyword" placeholder="搜索订单号 / 客户名" clearable />
</template>

13.4 keep-alive:缓存列表页,但要守住内存

从列表进详情再返回,万级列表要重新请求、重新渲染一遍——用 <keep-alive> 把列表页组件实例缓存下来,返回时直接复用:

<!-- src/views/layout/AdminLayout.vue(节选) -->
<script setup lang="ts">
// script setup 组件默认没有名字,include 匹配需要显式命名
</script>

<template>
  <router-view v-slot="{ Component }">
    <keep-alive :include="['OrderListView']" :max="10">
      <component :is="Component" />
    </keep-alive>
  </router-view>
</template>
<!-- src/views/orders/OrderListView.vue(节选) -->
<script setup lang="ts">
import { onActivated, onDeactivated } from 'vue'
import { useOrderListStore } from '@/stores/orderList'

defineOptions({ name: 'OrderListView' }) // 供 keep-alive include 匹配

const store = useOrderListStore()

// 返回列表页时:数据已在缓存里,只做轻量的「新数据提示」或静默刷新
onActivated(() => {
  store.refreshIfStale()
})

// 如果页面里有轮询/事件监听,离开时必须暂停,缓存期间它们不会自动停
onDeactivated(() => {
  polling.stop()
})
</script>

三个必须守住的纪律(内存泄漏的来源):include 用白名单只缓存确认需要的页面;max 限制缓存实例上限;缓存的组件在 onDeactivated 里暂停定时器、断开事件监听,在 onActivated 里恢复。默认的「无差别缓存全部页面」是内存随使用时间线性增长的第一元凶。

13.5 虚拟滚动:只渲染看得见的行

万级列表的终极手段:无论数据多少,DOM 里永远只有视口内 + 少量缓冲的几十行。三个要素:

  1. 空间占位:外层容器放一个总高度撑开的「标尺层」,保证滚动条长度正确;
  2. 可视区切片:根据 scrollTop 算出起始行号,只 slice 渲染这一段;
  3. DOM 回收:滚出视口的行被销毁,新进入的行被创建(或回收复用)。

自研简化版(固定行高,足以覆盖 90% 的后台表格场景):

// src/composables/useVirtualList.ts
import { computed, onMounted, onUnmounted, ref, type Ref } from 'vue'

interface VirtualOptions {
  itemHeight: number // 固定行高(px),简化版约定每行等高
  overscan?: number  // 视口外上/下各多渲染几行,降低白屏闪烁
}

interface VirtualRow<T> {
  data: T           // 原始数据
  index: number     // 真实下标(切片前的)——key 必须用它,不能用切片下标
  offsetTop: number // 该行在标尺层内的绝对偏移
}

export function useVirtualList<T>(
  items: Ref<readonly T[]>,
  container: Ref<HTMLElement | null>,
  { itemHeight, overscan = 5 }: VirtualOptions,
) {
  const containerHeight = ref(0)
  const scrollTop = ref(0)

  // 用 ResizeObserver 感知容器尺寸变化(分栏折叠、窗口缩放都要正确)
  let ro: ResizeObserver | null = null
  onMounted(() => {
    if (!container.value) return
    ro = new ResizeObserver(([entry]) => {
      containerHeight.value = entry.contentRect.height
    })
    ro.observe(container.value)
  })
  onUnmounted(() => ro?.disconnect())

  function onScroll(e: Event) {
    scrollTop.value = (e.target as HTMLElement).scrollTop
  }

  const startIndex = computed(() =>
    Math.max(0, Math.floor(scrollTop.value / itemHeight) - overscan),
  )
  const visibleCount = computed(
    () => Math.ceil(containerHeight.value / itemHeight) + overscan * 2,
  )
  // 标尺层总高:让滚动条长度与「1 万条」一致
  const totalHeight = computed(() => items.value.length * itemHeight)

  const visibleItems = computed<VirtualRow<T>[]>(() => {
    const end = Math.min(startIndex.value + visibleCount.value, items.value.length)
    return items.value.slice(startIndex.value, end).map((data, i) => {
      const index = startIndex.value + i
      return { data, index, offsetTop: index * itemHeight }
    })
  })

  return { visibleItems, totalHeight, onScroll }
}
<!-- src/views/orders/OrderVirtualTable.vue -->
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useVirtualList } from '@/composables/useVirtualList'
import type { Order } from '@/types/order'

const ROW_HEIGHT = 48

const props = defineProps<{ orders: Order[] }>()
defineEmits<{ select: [order: Order] }>()

const containerRef = ref<HTMLElement | null>(null)
const itemsRef = computed(() => props.orders)
const { visibleItems, totalHeight, onScroll } = useVirtualList(
  itemsRef,
  containerRef,
  { itemHeight: ROW_HEIGHT, overscan: 5 },
)
</script>

<template>
  <div ref="containerRef" class="vtable" @scroll.passive="onScroll">
    <!-- 标尺层:撑起 1 万条的总高度 -->
    <div class="vtable__spacer" :style="{ height: `${totalHeight}px` }">
      <div
        v-for="row in visibleItems"
        :key="row.data.id"
        class="vtable__row"
        :style="{ position: 'absolute', top: `${row.offsetTop}px`, height: `${ROW_HEIGHT}px` }"
        @click="$emit('select', row.data)"
      >
        {{ row.data.orderNo }} | {{ row.data.customerName }} | ¥{{ row.data.totalAmount }}
      </div>
    </div>
  </div>
</template>

<style scoped>
.vtable { position: relative; height: 560px; overflow-y: auto; }
.vtable__spacer { position: relative; }
</style>

💡 生产环境更推荐成熟方案 vue-virtual-scroller:它额外支持动态行高(DynamicScroller)、回收复用 DOM。固定行高用 RecycleScroller,API 细节以官方文档为准:

<!-- src/views/orders/OrderRecycleTable.vue:vue-virtual-scroller 版本 -->
<script setup lang="ts">
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import type { Order } from '@/types/order'

defineProps<{ orders: Order[] }>()
</script>

<template>
  <RecycleScroller
    class="order-scroller"
    :items="orders"
    :item-size="48"
    key-field="id"
    v-slot="{ item }"
  >
    <div class="order-row" :style="{ height: '48px' }">
      {{ item.orderNo }} | {{ item.customerName }}
    </div>
  </RecycleScroller>
</template>

13.6 万级订单列表治理案例(附录A §4 实战)

「轻订单」订单列表页在压测 1 万条数据后出现三个症状。下表是完整的治理记录,可直接对照自己项目复现:

症状 Performance 面板定位 根因 手段 结果
筛选输入时全表卡 200ms+ 火焰图最宽段是父组件整树 diff keyword 驻留父组件,订阅范围过大 状态下放 SearchBar + 防抖(§13.3 第四斧) 输入零卡顿,请求次数降 90%
滚动掉帧 ≈ 30% FPS 轨道掉帧集中在 patch 段 1 万行真实 DOM RecycleScroller(§13.5) DOM 常驻 ~30 行,掉帧 < 5%,达标
用 1 小时后页面发沉 内存轨迹持续上升不回落 keep-alive 无 include/max + 缓存页轮询未停 白名单 + max + onDeactivated 暂停(§13.4) 内存曲线锯齿回落

治理顺序的纪律:先测量(§13.2)→ 再减渲染(§13.3)→ 不够再上虚拟滚动(§13.5)。虚拟滚动有交互代价(浏览器查找 Ctrl+F 失效、行高固定约束),能不用就不用;用了就必须约束 key(见易错点 4)。

🛠 渐进式实战练习

练习 1 · 基础(15 min):找出「谁在渲染」

任务描述:在「轻订单」订单列表页(第 12 章产物)上完成:① 开启 app.config.performance;② 用 Vue Devtools Timeline 录制「输入搜索词 5 次」的过程;③ 把与表格无关的输入状态下放到 SearchBar,用防抖提交。

提示:重点观察输入时哪些组件事件成串出现;debounce 工具第 12 章已提供。

验收标准:

  • [ ] 录制截图能标出「改造前父组件每次输入重渲染」;
  • [ ] 改造后输入过程中列表组件零更新事件,300ms 防抖后才发一次请求;
  • [ ] 用 pnpm dev 实测肉眼无卡顿。
参考答案(SearchBar 关键实现)
<script setup lang="ts">
import { ref, watch } from 'vue'
import { debounce } from '@/utils/debounce'

const emit = defineEmits<{ search: [keyword: string] }>()
const keyword = ref('')

watch(
  keyword,
  debounce((kw: string) => emit('search', kw.trim()), 300),
  { flush: 'post' },
)
</script>
<template>
  <el-input v-model="keyword" placeholder="订单号 / 客户名" clearable />
</template>

验证方法:在 OrderTable 的 onUpdated 里临时 console.count('table-updated'),输入时应不再打印。

练习 2 · 进阶(40 min):手写虚拟滚动并接入万级列表

任务描述:把 §13.5 的 useVirtualList 完整实现到你的项目(不要复制粘贴,逐行理解后默写),接上 mock 的 1 万条订单,行高 48px、overscan 5。

提示:三个计算属性是核心——startIndex、visibleCount、totalHeight;先写标尺层,再渲染切片;@scroll 记得加 .passive。

验收标准:

  • [ ] 滚动到底部最后一行完整可见,无空白闪烁;
  • [ ] DevTools Elements 面板确认 DOM 行数恒定在约 20–40 行;
  • [ ] Performance 录制滚动 5s,FPS 掉帧 < 5%(附录A §4 目标)。
参考答案(易错点提示)

两个最容易写错的点:① 切片渲染的 :key 必须用 row.data.id(或 row.index 真实下标),用 v-for 的切片循环变量 i 会造成滚动时 DOM 复用错位(见易错点 4);② 标尺层要 position: relative,行要 position: absolute + top: offsetTop,如果忘了撑高容器,滚动条只有一屏。完整代码即 §13.5 的 useVirtualList.ts + OrderVirtualTable.vue,自检时逐行核对 startIndex 的 Math.max(0, ...) 与 endIndex 的 Math.min(..., items.length) 两个边界。

练习 3 · 挑战(选做,60+ min):虚拟列表 + keep-alive + 内存治理三合一

任务描述:① 列表页换用 RecycleScroller;② 在 Layout 用 keep-alive 缓存列表页(含返回恢复滚动位置);③ 用 Chrome DevTools Memory 面板对比「无 max / 有 include+max」两种配置下反复进出 20 次的内存曲线。

提示:滚动位置恢复利用 keep-alive 的「组件实例被缓存」特性,通常无需额外代码;若被重置,检查是否给 router-view 加了会重建组件的 :key。

验收标准:

  • [ ] 进详情返回后列表数据与滚动位置保留;
  • [ ] 列表页 1 小时轮询类副作用在 onDeactivated 暂停、onActivated 恢复;
  • [ ] Memory 面板截图对比:无 max 时堆内存阶梯上升,加 include+max 后锯齿回落,两张图入库。
参考答案(结构骨架)
<!-- AdminLayout.vue -->
<template>
  <router-view v-slot="{ Component }">
    <keep-alive :include="['OrderListView']" :max="10">
      <component :is="Component" />
    </keep-alive>
  </router-view>
</template>
// OrderListView.vue 内的副作用纪律
defineOptions({ name: 'OrderListView' })
const { pause, resume } = usePolling(fetchDashboardTodo, 30_000) // 任一轮询封装
onActivated(resume)
onDeactivated(pause)

内存实验要点:用 DevTools 的「Allocation instrumentation on timeline」,分别录制 20 次「列表 → 详情 → 返回」,对照堆快照里 OrderListView 组件实例的存活数量(无 max 会累积到 20 个,有 max 稳定在 1 个)。

⚠️ 常见易错点

易错点 1:v-if 与 v-for 用在同一元素上

<!-- ❌ 错误:Vue 3 中 v-if 优先级高于 v-for,执行 v-if 时 order 还不存在,直接报错 -->
<tr v-for="order in orders" :key="order.id" v-if="order.status !== 'cancelled'">
  <td>{{ order.orderNo }}</td>
</tr>
<!-- ✅ 正确一:template 包一层,v-for 在外、v-if 在内 -->
<template v-for="order in orders" :key="order.id">
  <tr v-if="order.status !== 'cancelled'">
    <td>{{ order.orderNo }}</td>
  </tr>
</template>

<!-- ✅ 正确二(推荐):过滤逻辑进 computed,模板只做渲染 -->
<script setup lang="ts">
const visibleOrders = computed(() => orders.filter((o) => o.status !== 'cancelled'))
</script>

易错点 2:deep watch 监听万级数据——性能黑洞

// ❌ 错误:深度遍历 1 万条订单建依赖、每次变化全量遍历对比
// 列表越大,这一次 watch 的触发成本越高,且极易被无关改动唤醒
watch(orders, (list) => {
  persistDraft(list)
}, { deep: true })
// ✅ 正确:浅响应 + 整体替换,或只监听真正关心的原始值
const orders = shallowRef<Order[]>([])

watch(orders, (list) => persistDraft(list)) // 引用替换才触发,成本 O(1)

// 只关心「行数」这种聚合值时,监听表达式而非整棵树
watch(() => orders.value.length, (n) => console.info(`当前 ${n} 行`))

易错点 3:keep-alive 无差别缓存——内存泄漏

<!-- ❌ 错误:所有页面无上限常驻缓存;缓存页里的定时器/监听还在跑
     现象:用得越久内存越高、后台网络请求越积越多 -->
<router-view v-slot="{ Component }">
  <keep-alive>
    <component :is="Component" />
  </keep-alive>
</router-view>
<!-- ✅ 正确:白名单 + 上限;副作用随 activated/deactivated 起停 -->
<router-view v-slot="{ Component }">
  <keep-alive :include="['OrderListView']" :max="10">
    <component :is="Component" />
  </keep-alive>
</router-view>

易错点 4:虚拟列表用切片下标当 key——DOM 复用错位

<!-- ❌ 错误:i 是切片后的下标。滚动后同一位置的 i 相同,
     Vue 认为节点「没变」而原地复用,行内的选中态/输入框内容会串行到别的订单上 -->
<div v-for="(row, i) in visibleItems" :key="i" :style="{ top: `${row.offsetTop}px` }">
  <input v-model="row.data.note" />
</div>
<!-- ✅ 正确:key 必须是数据的业务身份;需要下标时用保存在行对象里的「真实下标」 -->
<div
  v-for="row in visibleItems"
  :key="row.data.id"
  :style="{ position: 'absolute', top: `${row.offsetTop}px` }"
>
  <input v-model="row.data.note" />
</div>

易错点 5:v-memo 依赖数组漏写——数据变了界面不更新

<!-- ❌ 错误:行内还渲染了 ownerName,但依赖数组只有 status -->
<tr v-for="order in orders" :key="order.id" v-memo="[order.status]">
  <td>{{ order.orderNo }}</td>
  <td>{{ order.ownerName }}</td> <!-- 销售改名后此列永远显示旧值 -->
</tr>
<!-- ✅ 正确:依赖数组覆盖子树全部响应式来源;拿不准就不用 v-memo,
     先做组件拆分与状态下放,收益往往已经够大 -->
<tr v-for="order in orders" :key="order.id" v-memo="[order]">
  <td>{{ order.orderNo }}</td>
  <td>{{ order.ownerName }}</td>
</tr>

✅ 最佳实践

  1. 先测量后优化:所有优化以 Performance 面板录制的前后对比为证据,凭感觉优化常常把代码改复杂而收益为零。
  2. 默认 shallowRef 管理服务端列表:列表天然「整体替换」,浅响应省去万级代理与深度追踪,是零成本的默认正确。
  3. 输入类状态永远离表格远一点:搜索框、列设置等 UI 状态放叶子组件,防抖后再上抛,把重渲染隔离在最小范围。
  4. key 只认业务身份:code review 中出现 :key="i"(分页/筛选会变的列表)一律打回;虚拟列表场景这是正确性问题而不只是性能问题。
  5. keep-alive 三件套强制:include 白名单 + max 上限 + onDeactivated 停副作用,三者缺一就是延迟爆发的内存泄漏。
  6. 虚拟滚动是最后手段:优先「减渲染」四板斧;确需虚拟化时选 RecycleScroller(固定行高),并同步约定「浏览器内搜索失效」的替代方案(如站内搜索面板)。
  7. 大表格能分页就不虚拟化:附录A 的列表本身是 20/页分页形态;「万级列表」指导出/审计类长表,不要把优化手段错配到分页场景。
  8. 把治理结论写成 checklist:本章现象-根因-手段表放入团队 PR review 清单,让下一次优化从「个人经验」变成「团队资产」。

📌 本章小结

  1. Vue 的更新粒度是组件级:治理性能的本质是缩小「订阅范围」——数据放对地方、组件拆得够细;
  2. 四板斧按序使用:shallowRef(浅响应)→ v-memo(行级缓存)→ 稳定 key(diff 正确性)→ 状态下放(隔离重渲染);
  3. keep-alive 提升回切体验,但 include/max/副作用起停三件套是内存安全的前提;
  4. 虚拟滚动三要素:标尺占位、可视区切片、DOM 回收;固定行高自研足够,动态行高交给 vue-virtual-scroller;
  5. 附录A「万级列表掉帧 < 5%」的目标,在本章治理案例中已完整达成,手段全部可复现。

下一章从「渲染多快」转向「首屏多快」:LCP/FCP/CLS/INP 指标、代码分割、图片与缓存策略,把门户首页 LCP 从 4s 压到 1.8s。

📄 第 14 章 · 加载性能优化 14-加载性能优化.md

📍 阶段:高级阶段(第 13–18 章)|⏱ 预计学时:6–8 小时|🔗 前置:第 06 章、第 07 章

⬅ 上一章 | 📖 本版目录 | ➡ 下一章

💡 对照学习:本页是 Vue 版第 14 章。React 版对应章节为 第 14 章 · 加载性能优化,概念映射:defineAsyncComponent ≈ React.lazy(Vue 可自带 loading/error 组件,React 需配 <Suspense>);路由懒加载两版机制一致;web-vitals、Lighthouse、缓存与压缩策略两版完全通用。业务目标基准:附录A §4「门户首页 LCP ≤ 2.0s、JS 首包 gzip ≤ 300KB」。

🎯 学习目标

学完本章,你将能够:

  1. 说出 FCP、LCP、CLS、INP 四个核心指标的定义、达标线与影响它们的主要因素;
  2. 用 web-vitals 库在真实用户环境采集指标并上报,区分「实验室数据」与「真实用户数据」;
  3. 实施路由级与组件级代码分割,并用构建分析工具验证分割是否真正生效;
  4. 按图片在页面中的角色(LCP 主视觉 vs 普通配图)选择正确的加载策略(尺寸、格式、loading、fetchpriority);
  5. 正确使用 preload/prefetch/dns-prefetch/modulepreload,说清各自提升什么、何时有害;
  6. 用骨架屏控制 CLS,用「强缓存 + 指纹文件名 + HTML no-cache」设计缓存策略,产出 Nginx 配置;
  7. 复盘「轻订单」门户 LCP 4.2s → 1.8s 的完整优化过程,并为团队建立性能预算防线。

📖 核心知识点

14.1 指标先行:四个数字定义「快」

指标 含义 良好线 主要影响因素
FCP 首次内容绘制 页面首次画出文本/图片等内容 ≤ 1.8s 首包体积、服务端响应
LCP 最大内容绘制 最大元素(通常是首屏主图/大标题)渲染完成 ≤ 2.5s(附录A 要求 ≤ 2.0s) LCP 资源加载、阻塞脚本/样式
CLS 累积布局偏移 内容「跳动」的累计程度 ≤ 0.1 图片/广告无尺寸占位、异步插入内容
INP 交互到下一次绘制 用户交互响应的代表性延迟 ≤ 200ms 长任务、过量渲染(衔接第 13 章)

两类数据要分清:实验室数据(Lighthouse,可重复、用于 CI 门禁)与真实用户数据(RUM,反映真实网络与设备)。优化以 RUM 为准,CI 用实验室数据把关。用 web-vitals 采集 RUM:

// src/performance/webVitals.ts
import { onCLS, onFCP, onINP, onLCP, type Metric } from 'web-vitals'

function report(metric: Metric) {
  // sendBeacon:页面卸载也不丢数据(内容类型与解析约定由采集端负责)
  navigator.sendBeacon(
    '/api/v1/metrics/web-vitals',
    JSON.stringify({
      name: metric.name,            // 'LCP' | 'FCP' | 'CLS' | 'INP'
      value: metric.value,          // CLS 无量纲,其余为 ms
      rating: metric.rating,        // 'good' | 'needs-improvement' | 'poor'
      page: location.pathname,
      // 注意:不要采集任何用户身份信息(附录A 教学口径)
      ts: Date.now(),
    }),
  )
}

export function initWebVitals(): void {
  onLCP(report)
  onFCP(report)
  onCLS(report)
  onINP(report)
}
// src/main.ts(节选):门户入口最早期注册
import { initWebVitals } from '@/performance/webVitals'

initWebVitals()

14.2 代码分割:首屏只加载首屏需要的代码

第一层:路由级分割(必做)。第 07 章已用动态 import() 懒加载路由——它让每个路由成为独立 chunk,首屏不加载后台代码:

// src/router/routes.ts(门户 + 后台混合项目,节选)
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'

export const routes: RouteRecordRaw[] = [
  // 首页是 LCP 主战场:保持默认同段加载,避免多一次 chunk 网络往返
  {
    path: '/',
    name: 'home',
    component: () => import('@/views/portal/HomeView.vue'),
  },
  // 后台各页:全部动态导入,进哪个页面才下载哪个 chunk
  {
    path: '/orders',
    component: () => import('@/views/layout/AdminLayout.vue'),
    children: [
      { path: 'list', component: () => import('@/views/orders/OrderListView.vue') },
      { path: 'create', component: () => import('@/views/orders/OrderCreateView.vue') },
    ],
  },
]

export const router = createRouter({ history: createWebHistory(), routes })

第二层:组件级分割。页面内「先不出现但很重」的组件(图表、导出预览、富文本)单独拆:

<!-- src/views/portal/HomeView.vue(节选) -->
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'

// 访客只有点「查看演示」才看到图表,且 echarts 体积大:异步化
const DemoChart = defineAsyncComponent({
  loader: () => import('./DemoChart.vue'),
  loadingComponent: ChartSkeleton,   // 加载骨架(可选)
  errorComponent: LoadError,         // 失败兜底(可选,支持 retry)
  delay: 200,                        // 200ms 内完成则不闪骨架
  timeout: 10_000,                   // 超时转 errorComponent
})
</script>

<template>
  <DemoChart v-if="showDemo" />
</template>

验证分割是否生效:构建分析一眼看清「谁占了体积」。

# 安装可视化分析插件
pnpm add -D rollup-plugin-visualizer
// vite.config.ts(节选)
import { visualizer } from 'rollup-plugin-visualizer'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [
    vue(),
    // 仅在显式指定时产出分析报告:pnpm build -- --mode analyze
    process.env.ANALYZE &&
      visualizer({ filename: 'dist/stats.html', gzipSize: true }),
  ].filter(Boolean),
})
ANALYZE=1 pnpm build   # 打开 dist/stats.html,检查首屏路由 chunk 的构成

14.3 图片优化:按「角色」决定策略

门户首页的图片分两类,策略完全相反:

<!-- src/views/portal/HomeView.vue(节选) -->
<template>
  <!-- ① LCP 主视觉:立刻加载、高优先级、绝不懒加载 -->
  <img
    src="https://cdn.qingorder.example.com/hero-1280.avif"
    srcset="
      https://cdn.qingorder.example.com/hero-640.avif  640w,
      https://cdn.qingorder.example.com/hero-1280.avif 1280w,
      https://cdn.qingorder.example.com/hero-1920.avif 1920w
    "
    sizes="(max-width: 768px) 100vw, 1280px"
    width="1280"
    height="600"
    fetchpriority="high"
    decoding="async"
    alt="轻订单产品主视觉"
  />

  <!-- ② 普通配图/图标(首屏以下):懒加载 + 异步解码 + 明确尺寸 -->
  <img
    src="https://cdn.qingorder.example.com/feature-order.png?w=480&format=webp"
    width="480"
    height="320"
    loading="lazy"
    decoding="async"
    alt="订单管理功能示意"
  />
</template>

五条规则:① 首屏图片写 width/height(或固定比例容器)防 CLS;② LCP 图 fetchpriority="high",其余 loading="lazy";③ 格式优先 AVIF,回退 WebP(<picture> 兜底老浏览器),CDN 按需转码;④ srcset + sizes 让手机不下载桌面图;⑤ 图标小图用 SVG,走 CSS 精灵或组件化。

14.4 preload / prefetch / dns-prefetch / modulepreload

指令 作用 典型场景 何时有害
<link rel="preload"> 当前导航马上要的关键资源,提前到样式/脚本之前 LCP 主图、关键字体 滥用会与真实关键资源抢带宽
<link rel="prefetch"> 下一跳可能要的空闲期资源 登录页预取后台主 chunk 误判流量浪费
<link rel="dns-prefetch"> 提前解析第三方域名 DNS CDN、统计域名 几乎无害,但也只省 100ms 级
modulepreload 预加载 ESM 模块及其依赖 手动指定核心路由 chunk 同 preload
<!-- index.html:门户页关键资源编排 -->
<head>
  <!-- LCP 主视觉提前抢跑(与 srcset 选中的候选一致时收益最大) -->
  <link rel="preload" as="image" href="https://cdn.qingorder.example.com/hero-1280.avif"
        imagesrcset="https://cdn.qingorder.example.com/hero-640.avif 640w, https://cdn.qingorder.example.com/hero-1280.avif 1280w"
        imagesizes="(max-width: 768px) 100vw, 1280px" fetchpriority="high" />
  <!-- 第三方域名提前握手 -->
  <link rel="dns-prefetch" href="https://api.qingorder.example.com" />

  <!-- Vite 会对入口依赖自动注入 modulepreload;无需手写 -->
  <!-- 对「登录页即将跳转后台」的场景,可手动 prefetch 后台主 chunk:
       <link rel="prefetch" href="/assets/admin-xxxx.js" /> -->
</head>

Vite 的默认行为值得记住:入口模块的静态依赖会被自动 modulepreload;动态导入的路由 chunk 默认在浏览器空闲时 prefetch(可通过 build.modulePreload 配置调整)。所以「该预取的路由」通常已被框架照顾,手动加指令前先看 Network 面板确认。

14.5 骨架屏:把「等待」变成「结构」,顺带治 CLS

数据未到时,用骨架屏占住版面——既给用户「正在加载」的确定性,也因占位尺寸与真实内容一致而消除布局跳动:

<!-- src/views/dashboard/WorkbenchView.vue(节选) -->
<script setup lang="ts">
import { useOrderStats } from '@/composables/useOrderStats'

// 服务端数据未返回前渲染骨架,占位尺寸与卡片一致(CLS ≈ 0)
const { stats, loading } = useOrderStats()
</script>

<template>
  <el-skeleton :loading="loading" animated class="stat-grid">
    <template #template>
      <el-skeleton-item v-for="i in 4" :key="i" variant="rect" class="stat-card" />
    </template>
    <template #default>
      <StatCard label="今日新增订单" :value="stats.todayCount" />
      <StatCard label="待审核" :value="stats.pendingCount" />
      <StatCard label="本月 GMV" :value="stats.gmv" money />
      <StatCard label="异常订单" :value="stats.abnormalCount" />
    </template>
  </el-skeleton>
</template>

14.6 缓存与压缩:发得快、回访更快

缓存的黄金组合:带指纹的静态资源一年强缓存,入口 HTML 绝不缓存——资源内容变化时文件名(hash)变化,HTML 更新引用,天然版本化。

# /etc/nginx/conf.d/portal.conf(教学节选)
server {
  listen 80;
  root /usr/share/nginx/html;

  # ① 指纹资源:一年强缓存,immutable 提示浏览器连 304 都不用问
  location ~* \.(js|css|woff2?|png|jpe?g|webp|avif|svg)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
  }

  # ② 入口 HTML:协商缓存(no-cache 每次回源校验 ETag),保证发版即生效
  location = /index.html {
    add_header Cache-Control "no-cache";
  }

  # ③ SPA 回退(刷新 /orders/list 不 404,第 07 章原理的落地处)
  location / {
    try_files $uri $uri/ /index.html;
  }

  # ④ 文本类压缩;brotli(ngx_brotli 模块)压缩率比 gzip 再高 15–20%,优先开启
  gzip on;
  gzip_comp_level 5;
  gzip_min_length 1k;
  gzip_types text/css application/javascript application/json image/svg+xml;
}

14.7 复盘:门户首页 LCP 4.2s → 1.8s

「轻订单」一期门户上线时(4G、中端安卓模拟),Lighthouse 基线与治理记录:

# 问题(Lighthouse / Network 面板证据) 手段 收益
1 首屏 JS gzip 680KB(后台代码全打进首包) 路由级分割 + 图表组件异步化(§14.2) 首包 680 → 290KB
2 LCP 图是 2.1MB PNG,且排在 3 个 CSS 之后 AVIF + srcset + preload + fetchpriority(§14.3/14.4) 图片下载 2.1MB → 90KB,LCP 4.2 → 2.3s
3 Hero 图无尺寸,促销条异步插入 → CLS 0.25 全部图片写宽高 + 骨架屏占位(§14.5) CLS 0.25 → 0.02
4 静态资源无缓存头,回访与首访一样慢 指纹 + 一年强缓存 + HTML no-cache(§14.6) 回访 LCP 1.9 → 1.1s
5 vendor 单包 420KB 精细 manualChunks + 构建分析(§14.2) 首包 290 → 205KB(gzip ≤ 300KB 达标)

最终:FCP 0.9s、LCP 1.8s、CLS 0.02、首包 gzip 205KB——附录A §4 的 ≤ 2.0s 与 ≤ 300KB 两条全部达标。最后一步是防止回退:把指标写进 CI 预算(Lighthouse CI 断言,接入方式见第 16/18 章的流水线):

{
  "ci": {
    "collect": { "staticDistDir": "./dist", "numberOfRuns": 3 },
    "assert": {
      "assertions": {
        "largest-contentful-paint": ["error", { "maxNumericValue": 2000 }],
        "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
        "total-byte-weight": ["error", { "maxNumericValue": 900000 }]
      }
    }
  }
}

maxNumericValue 的单位:时间类为毫秒。配置细节以 Lighthouse CI 官方文档为准。

🛠 渐进式实战练习

练习 1 · 基础(15 min):给门户做一次「体检 + 分割」

任务描述:① 用 Chrome DevTools 内置 Lighthouse(选 Mobile + 模拟 4G)对「轻订单」门户跑分,截图记录 FCP/LCP/CLS/INP;② 检查路由表是否全部动态导入,把仍静态导入的路由改为 () => import(...);③ 重新构建并对比首包体积。

提示:Lighthouse 报告的「Opportunities/诊断」区按预估节省时间排序,先处理前三条。

验收标准:

  • [ ] 有优化前后两次 Lighthouse 截图;
  • [ ] dist/stats.html 中后台路由代码不在门户 chunk 内;
  • [ ] 首屏 gzip 后 JS ≤ 300KB(附录A §4)。

练习 2 · 进阶(45 min):LCP 图片治理 + RUM 埋点

任务描述:① 把门户 Hero 图换成 AVIF/WebP 双格式(<picture> 回退),补齐 srcset/sizes/宽高/fetchpriority;② 在 index.html 预加载选中的候选图;③ 接入 §14.1 的 web-vitals 上报,本地用 DevTools Network 面板确认四个指标各上报一次。

提示:<picture> 结构是 <picture><source type="image/avif" ...><img ...></picture>;本地验证 RUM 可在 report 里临时 console.debug。

验收标准:

  • [ ] Network 面板可见 Hero 图请求优先级为 High/Highest;
  • [ ] 中端安卓模拟下 LCP ≤ 2.0s;
  • [ ] 四项指标均有上报记录,且无用户身份字段。
参考答案(picture 回退结构)
<template>
  <picture>
    <source
      type="image/avif"
      srcset="https://cdn.qingorder.example.com/hero-640.avif 640w,
              https://cdn.qingorder.example.com/hero-1280.avif 1280w"
      sizes="(max-width: 768px) 100vw, 1280px"
    />
    <img
      src="https://cdn.qingorder.example.com/hero-1280.webp"
      width="1280" height="600"
      fetchpriority="high" decoding="async"
      alt="轻订单产品主视觉"
    />
  </picture>
</template>

要点:<source> 负责候选与描述,兜底的 <img> 是 LCP 候选元素——宽高、fetchpriority 必须写在 <img> 上。

练习 3 · 挑战(选做,60+ min):性能预算上 CI + 骨架屏全覆盖

任务描述:① 按 §14.7 的 lighthouserc.json 建立 Lighthouse CI 任务,在本地 npx @lhci/cli autorun 跑通并故意让预算超标验证「红灯」;② 给工作台四个统计卡与近 30 天趋势图补齐骨架屏,占位尺寸与真实内容一致;③ 把「性能预算清单」(各 chunk 上限、指标上限)写成团队文档。

提示:staticDistDir 指向构建产物即可对纯静态站跑分;SPA 涉及路由的指标需 staticDistDir 或自有服务器方案,教学场景以门户首页为准。

验收标准:

  • [ ] CI 配置可运行,LCP 超过 2000ms 时任务失败;
  • [ ] 骨架屏 → 内容切换零布局跳动(录屏对比);
  • [ ] 预算文档包含每个阈值对应的附录A 指标编号。

⚠️ 常见易错点

易错点 1:把 LCP 元素懒加载——好心办坏事

<!-- ❌ 错误:Hero 是 LCP 元素,loading="lazy" 让浏览器推迟发现它,
     LCP 反而变慢;再叠加 srcset 也没用 -->
<img src="/hero.avif" loading="lazy" alt="主视觉" />
<!-- ✅ 正确:LCP 元素立即加载 + 高优先级;懒加载只给首屏以下的图 -->
<img src="/hero.avif" fetchpriority="high" decoding="async" width="1280" height="600" alt="主视觉" />
<img src="/feature.png" loading="lazy" decoding="async" width="480" height="320" alt="功能示意" />

易错点 2:静态 import 让路由分割「悄悄失效」

// ❌ 错误:路由写了动态导入,却在别处静态 import 了同一页面
// 打包器无法拆分,整页代码仍进首包(stats.html 里一目了然)
import OrderListView from '@/views/orders/OrderListView.vue'
export const fallbackRoutes = [{ path: '/orders/list', component: OrderListView }]
// ✅ 正确:需要「提前加载」的页面用入口预取,而不是静态引用;
// 所有路由统一动态导入,分割才真正生效
export const fallbackRoutes: RouteRecordRaw[] = [
  { path: '/orders/list', component: () => import('@/views/orders/OrderListView.vue') },
]

易错点 3:图片不写宽高——CLS 制造机

<!-- ❌ 错误:图片加载完成后撑开版面,正文瞬间下移,CLS 飙升 -->
<img src="/feature.png" alt="功能示意" />
<!-- ✅ 正确:写宽高(浏览器按比例先占位),或外层容器锁比例 -->
<img src="/feature.png" width="480" height="320" loading="lazy" alt="功能示意" />

易错点 4:入口 HTML 被强缓存——「发版了用户看不到」

# ❌ 错误:对整个站点统一加长缓存。index.html 也被缓存一年,
# 它引用的旧 hash chunk 早已被新版本删除 → 用户白屏 + 404
location / {
  expires 1y;
  add_header Cache-Control "public, immutable";
}
# ✅ 正确:区分对待——指纹资源长缓存,HTML no-cache(见 §14.6 完整配置)
location ~* \.(js|css|woff2?|png|webp|avif|svg)$ { expires 1y; add_header Cache-Control "public, immutable"; }
location = /index.html { add_header Cache-Control "no-cache"; }

易错点 5:manualChunks 一刀切——「优化」出更大的首包

// ❌ 错误:把所有 node_modules 塞进一个 vendor,
// 门户只用到一小部分,却被迫下载 echarts/element-plus 全家桶
build: {
  rollupOptions: {
    output: { manualChunks: { vendor: ['vue', 'element-plus', 'echarts'] } },
  },
}
// ✅ 正确:按「使用场景」分块 + 用构建分析验证;首屏不用的库留在异步 chunk 里
build: {
  rollupOptions: {
    output: {
      manualChunks(id: string) {
        if (!id.includes('node_modules')) return undefined
        if (id.includes('echarts')) return 'chart'      // 只有图表页加载
        if (id.includes('element-plus')) return 'element'
        return 'vendor'                                  // 其余小依赖
      },
    },
  },
}

✅ 最佳实践

  1. 指标先于优化:每项优化前后都有 FCP/LCP/CLS/INP 与首包体积数据,写进复盘文档(§14.7 的表格就是模板)。
  2. 路由级分割是底线,组件级按需:所有路由动态导入;只把「重且不急」的组件异步化,不要为了分割而分割。
  3. 图片按角色定策略:LCP 图「立即 + 高优先级 + 现代格式」,普通图「懒加载 + 明确尺寸」,图标用 SVG。
  4. 缓存策略写进部署配置而非口头约定:指纹资源 immutable 一年,HTML no-cache,Nginx 配置进仓库走 review。
  5. 压缩交给服务端:优先 brotli,gzip 兜底;前端只负责保证产物可压缩(文本类资源、控制包体积)。
  6. 性能预算进 CI:指标阈值(LCP ≤ 2.0s、首包 ≤ 300KB)固化到 Lighthouse CI 断言,回退在合并前被拦截。
  7. RUM 与实验室数据互补:CI 用 Lighthouse 门禁,线上用 web-vitals 看真实分布(P75),两者异常时先信 RUM。
  8. 骨架屏同时服务体验与 CLS:占位尺寸必须与真实内容一致,否则只是把白屏换成了抖动。

📌 本章小结

  1. 四个指标四个抓手:FCP 看首包、LCP 看关键资源、CLS 看占位、INP 看长任务;
  2. 代码分两层割:路由级动态导入(底线)+ 组件级异步化(重组件),并用 stats.html 验证;
  3. 图片策略由「角色」决定,preload/prefetch 的分界线是「当前导航要」还是「下一跳可能要」;
  4. 缓存公式:指纹资源 immutable + HTML no-cache;压缩优先 brotli;
  5. 门户 LCP 4.2s → 1.8s 的复盘表明:排序正确时,五步手段即可把 LCP 砍半并稳定达标,最后用 CI 预算锁住成果。

渲染与加载都达标之后,「改得对不对」成为新的风险面:下一章进入组件测试,用 Vitest + Vue Test Utils 把金额计算、状态标签与订单卡片纳入自动化防线。

📄 第 15 章 · 组件测试 15-组件测试.md

📍 阶段:高级阶段(第 13–18 章)|⏱ 预计学时:6–8 小时|🔗 前置:第 08 章、第 10 章、第 12 章

⬅ 上一章 | 📖 本版目录 | ➡ 下一章

💡 对照学习:本页是 Vue 版第 15 章。React 版对应章节为 第 15 章 · 组件测试,概念映射:Vue Test Utils ≈ Testing Library(React 版主讲 Testing Library;Vue 生态同样有 @testing-library/vue,两者「面向用户行为断言」的理念一致);mount/shallowMount ≈ render;wrapper.emitted() 是 Vue 版特有的便捷断言;Vitest、vi.mock、msw、覆盖率策略两版完全一致。业务目标基准:附录A §4「核心模块单测覆盖率 ≥ 80%、订单金额计算与权限判断纳入测试」。

🎯 学习目标

学完本章,你将能够:

  1. 画出测试金字塔,说出「什么值得测、在哪一层测」的投入策略,并解释为什么单测是性价比最高的一层;
  2. 完成 Vitest 配置:jsdom 环境、路径别名、setup 文件与覆盖率阈值(对齐附录A 的 ≥ 80%);
  3. 用表驱动方式测试纯函数:金额计算(分/元换算、行金额、总额求和)与订单状态机流转判断;
  4. 用测试宿主组件技术测试组合式函数(含生命周期钩子的 composable);
  5. 用 Vue Test Utils 完成组件挂载、props 注入、事件触发与 emitted 断言(StatusTag、OrderCard、订单表单校验);
  6. 区分两类 mock 的适用场景:vi.mock 拦截模块 vs msw 拦截网络,并用它们消灭环境差异导致的 flaky;
  7. 克制地使用快照测试,说出「什么可以快照、什么绝对不要快照」。

📖 核心知识点

15.1 测试金字塔:把力气花在性价比最高的一层

        / E2E \          少而稳定:主流程冒烟(第 16 章)
      / 集成·组件 \       适中:组件交互、composable 行为
    /    单元测试    \     最多:纯函数、状态机、格式化、校验规则

投入策略(结合「轻订单」的具体划分):

层 测什么(轻订单实例) 不测什么
单元 fenToYuan/calcOrderTotal、canTransition 状态机、权限码判断、表单校验规则 简单的 getter 转发、纯样式常量
组件/集成 StatusTag 文案映射、OrderCard 展示与事件、useOrderList 分页筛选编排 Element Plus 内部实现、像素级样式
E2E 登录 → 下单 → 审批 → 导出(附录A 冒烟集) 每个按钮都点一遍

原则:逻辑下沉到可测层。把金额计算、状态流转收敛进 utils/、constants/(第 12 章已做),它们就能以零 DOM 成本被穷举测试;组件只测「用户可见的行为」。

15.2 Vitest 配置:一次配好,全队受益

pnpm add -D vitest @vue/test-utils jsdom @vitest/coverage-v8
// vitest.config.ts
import { fileURLToPath } from 'node:url'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vitest/config'

export default defineConfig({
  plugins: [vue()],
  resolve: {
    alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
  },
  test: {
    environment: 'jsdom',               // 组件测试需要 DOM
    globals: true,                      // describe/it/expect 免导入(setup 里配 cleanup 仍需显式导入)
    setupFiles: ['./src/test/setup.ts'],
    include: ['src/**/*.{spec,test}.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html'],
      // 只统计「核心模块」:附录A §4 的口径是金额计算、权限判断、工具函数
      include: ['src/utils/**', 'src/constants/**', 'src/composables/**', 'src/components/**'],
      thresholds: {
        lines: 80, functions: 80, branches: 80, statements: 80, // 低于 80% 直接失败
      },
    },
  },
})
// src/test/setup.ts
import { cleanup } from '@vue/test-utils'
import { afterEach, vi } from 'vitest'

// 每个用例结束卸载已挂载组件,防止用例间相互污染
afterEach(() => cleanup())

// jsdom 未实现 matchMedia,Element Plus 等库会用到:统一打桩
if (!window.matchMedia) {
  window.matchMedia = vi.fn().mockImplementation((query: string) => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: vi.fn(),
    removeListener: vi.fn(),
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
    dispatchEvent: vi.fn(),
  }))
}
// package.json(节选)
{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "test:coverage": "vitest run --coverage"
  }
}

15.3 纯函数测试:金额计算与状态机(覆盖率 80% 的主战场)

被测代码是第 12 章的 utils/money.ts 与 constants/order.ts,先回顾接口:

// src/utils/money.ts(回顾)
export interface OrderItemInput {
  quantity: number        // 1–9999
  unitPrice: number       // 单位:分
  discountPercent: number // 0–100,100 表示无折扣
}

/** 行金额 = 数量 × 单价 × 行折扣;整数运算 + 取整归位到分 */
export function calcItemAmount(item: OrderItemInput): number {
  return Math.round(item.quantity * item.unitPrice * (item.discountPercent / 100))
}

/** 订单总额:以分为单位累加,规避浮点误差 */
export function calcOrderTotal(items: OrderItemInput[]): number {
  return items.reduce((sum, it) => sum + calcItemAmount(it), 0)
}

/** 展示层专用:分 → 元字符串 */
export function fenToYuan(fen: number): string {
  return (fen / 100).toFixed(2)
}
// src/utils/__tests__/money.spec.ts
import { describe, expect, it } from 'vitest'
import { calcItemAmount, calcOrderTotal, fenToYuan } from '../money'

describe('calcItemAmount:行金额', () => {
  it.each([
    { desc: '无折扣', item: { quantity: 3, unitPrice: 129_900, discountPercent: 100 }, expected: 389_700 },
    { desc: '九折(附录A 审批阈值场景)', item: { quantity: 3, unitPrice: 129_900, discountPercent: 90 }, expected: 350_730 },
    { desc: '小数结果四舍五入到分', item: { quantity: 1, unitPrice: 999, discountPercent: 50 }, expected: 500 },
    { desc: '零数量', item: { quantity: 0, unitPrice: 129_900, discountPercent: 100 }, expected: 0 },
  ])('$desc → $expected 分', ({ item, expected }) => {
    expect(calcItemAmount(item)).toBe(expected)
  })
})

describe('calcOrderTotal:订单总额', () => {
  it('空明细为 0 分', () => {
    expect(calcOrderTotal([])).toBe(0)
  })

  it('1000 行累加不受浮点误差影响(若以元累加会得到 2049.9999 类结果)', () => {
    const items = Array.from({ length: 1000 }, () => ({
      quantity: 1, unitPrice: 205, discountPercent: 100,
    }))
    expect(calcOrderTotal(items)).toBe(205_000)
  })
})

describe('fenToYuan:展示换算', () => {
  it.each([
    [0, '0.00'],
    [350_730, '3507.30'],
    [5, '0.05'],
  ])('%i 分 → %s 元', (fen, expected) => {
    expect(fenToYuan(fen)).toBe(expected)
  })
})

状态机测试同样表驱动,把附录A §3.3 的流转图逐条翻译成用例:

// src/constants/__tests__/order.spec.ts
import { describe, expect, it } from 'vitest'
import { canTransition } from '../order'

describe('canTransition:订单状态机', () => {
  it.each([
    ['draft', 'pending_review', true],
    ['pending_review', 'approved', true],
    ['pending_review', 'rejected', true],
    ['rejected', 'pending_review', true],   // 拒绝后可编辑重提
    ['completed', 'draft', false],           // 终态不可逆
    ['draft', 'completed', false],           // 不可跳级
  ] as const)('%s → %s 应为 %s', (from, to, expected) => {
    expect(canTransition(from, to)).toBe(expected)
  })
})

15.4 组合式函数测试:测试宿主组件技术

组合式函数若使用了 onMounted/provide 等 API,必须在组件上下文中执行。标准做法是造一个一次性的宿主组件:

// src/test/withSetup.ts
import { createApp, defineComponent, type App } from 'vue'

/** 在真实组件上下文中执行 composable,返回其结果与应用实例 */
export function withSetup<T>(composable: () => T): { result: T; app: App } {
  let result!: T
  const app = createApp(
    defineComponent({
      setup() {
        result = composable()
        // 渲染函数留空:我们只需要 setup 的执行环境
        return () => null
      },
    }),
  )
  app.mount(document.createElement('div'))
  return { result, app }
}

以第 09 章 useOrderList 的公共接口(list/loading/total/page/params/load)为契约写测试——先 mock 掉 api 模块:

// src/composables/__tests__/useOrderList.spec.ts
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { withSetup } from '@/test/withSetup'
import { useOrderList } from '../useOrderList'

// 拦截 api 模块:测试不发真实网络请求
vi.mock('@/api/orders', () => ({
  fetchOrders: vi.fn().mockResolvedValue({
    code: 0,
    message: 'ok',
    data: {
      list: [{ id: 'o-1', orderNo: 'QO20260929000001', status: 'draft' }],
      total: 1, page: 1, pageSize: 20,
    },
  }),
}))

import { fetchOrders } from '@/api/orders'

describe('useOrderList:分页与筛选编排', () => {
  beforeEach(() => {
    vi.clearAllMocks()
  })

  it('首次 load 使用第 1 页、每页 20 条', async () => {
    const { result } = withSetup(() => useOrderList())
    await result.load()

    expect(fetchOrders).toHaveBeenCalledWith(
      expect.objectContaining({ page: 1, pageSize: 20 }),
    )
    expect(result.list.value).toHaveLength(1)
    expect(result.loading.value).toBe(false)
  })

  it('修改筛选关键词后 load 重置回第 1 页并携带关键词', async () => {
    const { result } = withSetup(() => useOrderList())
    result.params.value.page = 3
    result.params.value.keyword = '华为'
    await result.load()

    expect(fetchOrders).toHaveBeenLastCalledWith(
      expect.objectContaining({ page: 1, keyword: '华为' }),
    )
  })
})

15.5 组件测试:StatusTag、OrderCard 与表单校验

组件测试的第一原则:断言用户看得到的东西(文案、可见的类名、交互结果),而不是内部实现。StatusTag 把订单状态映射为文案与颜色:

// src/components/__tests__/StatusTag.spec.ts
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import StatusTag from '../StatusTag.vue'

describe('StatusTag:状态文案映射', () => {
  it.each([
    ['draft', '草稿'],
    ['pending_review', '待审核'],
    ['approved', '已审核'],
    ['cancelled', '已取消'],
  ] as const)('%s 渲染为「%s」', (status, label) => {
    const wrapper = mount(StatusTag, { props: { status } })
    expect(wrapper.text()).toBe(label)
  })
})

OrderCard 是展示 + 事件组件,测试「渲染正确的金额」与「点击触发 emit」两条用户可见行为:

// src/orders/components/__tests__/OrderCard.spec.ts
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import type { Order } from '@/types/order'
import OrderCard from '../OrderCard.vue'

// 卡片内部有「提交审批」动作会调用接口:拦截模块而非放行真实网络
vi.mock('@/api/orders', () => ({
  submitOrder: vi.fn().mockResolvedValue({ code: 0, message: 'ok', data: null }),
}))

const baseOrder: Order = {
  id: 'o-1',
  orderNo: 'QO20260929000001',
  customerId: 'c-1',
  customerName: '青云科技有限公司',
  ownerName: '王小明',
  status: 'draft',
  items: [{ skuId: 's-1', skuName: '智能网关 X1', quantity: 3, unitPrice: 129_900, discountPercent: 90 }],
  wholeDiscountPercent: 90,
  totalAmount: 350_730,
  createdAt: '2026-09-29T10:00:00+08:00',
}

describe('OrderCard', () => {
  it('展示订单号、客户名,并把「分」格式化为「3507.30」元', () => {
    const wrapper = mount(OrderCard, { props: { order: baseOrder } })
    expect(wrapper.text()).toContain('QO20260929000001')
    expect(wrapper.text()).toContain('青云科技有限公司')
    expect(wrapper.text()).toContain('3507.30')
  })

  it('点击「编辑」按钮向上发出 edit 事件并携带订单 id', async () => {
    const wrapper = mount(OrderCard, { props: { order: baseOrder } })
    await wrapper.find('[data-test="edit-btn"]').trigger('click')
    expect(wrapper.emitted('edit')).toEqual([['o-1']])
  })

  it('整单折扣 >10% 时显示「需财务审批」提示(附录A §3.3)', () => {
    const wrapper = mount(OrderCard, { props: { order: baseOrder } })
    expect(wrapper.text()).toContain('需财务审批')
  })
})

订单表单校验测试(第 10 章的 Element Plus 表单):输入非法值 → 提交 → 断言错误文案可见且没有发出 submit 事件:

// src/views/orders/__tests__/OrderForm.spec.ts
import { mount } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import { describe, expect, it } from 'vitest'
import OrderForm from '../OrderForm.vue'

describe('OrderForm:数量校验(1–9999)', () => {
  it('数量为 0 时显示校验文案,且不发出 submit 事件', async () => {
    const wrapper = mount(OrderForm, { global: { plugins: [ElementPlus] } })

    // 面向用户操作:像真实用户一样填表、点提交
    await wrapper.find('[data-test="qty-input"] input').setValue('0')
    await wrapper.find('[data-test="submit-btn"]').trigger('click')
    // Element Plus 校验是异步的:等待校验微任务与 DOM 更新完成
    await wrapper.vm.$nextTick()
    await wrapper.vm.$nextTick()

    expect(wrapper.text()).toContain('数量必须为 1–9999')
    expect(wrapper.emitted('submit')).toBeUndefined()
  })
})

15.6 mock 两板斧:vi.mock 拦模块,msw 拦网络

手段 拦截层 适用 代价
vi.mock 模块解析层(import 拿到的就是假的) 单测里替换 api/工具/第三方库 与真实实现无关联,靠人为维护返回值
msw 网络层(service worker / node 拦截 HTTP) 集成测试想走真实请求层(axios 拦截器都生效) 要维护 handlers,但更接近真实

msw 2.x 的用法(pnpm add -D msw):

// src/test/mocks/handlers.ts
import { http, HttpResponse } from 'msw'
import type { Order } from '@/types/order'

const mockOrder: Order = {
  id: 'o-1', orderNo: 'QO20260929000001', customerId: 'c-1',
  customerName: '青云科技有限公司', ownerName: '王小明', status: 'draft',
  items: [], wholeDiscountPercent: 100, totalAmount: 0,
  createdAt: '2026-09-29T10:00:00+08:00',
}

export const handlers = [
  http.get('*/api/v1/orders', ({ request }) => {
    const page = Number(new URL(request.url).searchParams.get('page') ?? '1')
    return HttpResponse.json({
      code: 0, message: 'ok',
      data: { list: [mockOrder], total: 1, page, pageSize: 20 },
    })
  }),
  http.post('*/api/v1/orders', () =>
    HttpResponse.json({ code: 0, message: 'ok', data: { id: 'o-1' } }),
  ),
]
// src/test/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)
// src/test/setup.ts(追加)
import { afterAll, afterEach, beforeAll } from 'vitest'
import { server } from './mocks/server'

beforeAll(() =>
  // unhandled: 'error' 让「测试意外发出未 mock 的请求」直接报错,
  // 是消灭「环境差异 flaky」的第一道闸
  server.listen({ onUnhandledRequest: 'error' }),
)
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

15.7 快照测试的克制

快照适合小而稳定的序列化输出(如 canTransition 的流转表对象),不适合整组件 DOM:

// ✅ 合理:锁定「结构化配置」防意外变更
import { expect, it } from 'vitest'
import { ORDER_TRANSITIONS } from '../order'

it('状态机流转表与评审稿一致', () => {
  expect(ORDER_TRANSITIONS).toMatchInlineSnapshot(`
    {
      "approved": [
        "shipped",
      ],
      "draft": [
        "pending_review",
        "cancelled",
      ],
      // ……完整流转表
    }
  `)
})

❌ 反例是对整组件 toMatchSnapshot():任何样式微调都会让快照全红,最后团队养成「无脑 -u 更新」的习惯,快照沦为形式。当快照的变更不能再被人工评审时,它就失去了价值。

15.8 覆盖率:目标 80% 是手段,不是信仰

pnpm test:coverage
# ----------|---------|----------|---------|---------|
# File      | % Stmts | % Branch | % Funcs | % Lines |
# money.ts  |     100 |      100 |     100 |     100 |
# order.ts  |     100 |       95 |     100 |     100 |
# ----------|---------|----------|---------|---------|

附录A 的口径是「核心模块(订单金额计算、权限判断、工具函数)≥ 80%」——注意 coverage.include 只圈定核心目录,而不是全仓库「平均出」一个好看的数字。 branches(分支)比行数更难看:if 漏测一半,行覆盖也可能是 100%,所以四个阈值都要设。

🛠 渐进式实战练习

练习 1 · 基础(15 min):金额计算的表驱动测试

任务描述:① 完成 §15.2 的 Vitest 配置;② 为 utils/money.ts 补齐 §15.3 的测试,并额外增加两组边界用例:quantity: 9999 的最大值、discountPercent: 0 的零折扣;③ 运行 pnpm test:coverage,确认 money.ts 达到 100%。

提示:it.each 的参数是数组套数组/对象;断言单位永远是「分」。

验收标准:

  • [ ] pnpm test 全绿,用例数 ≥ 10;
  • [ ] coverage 表格中 money.ts 四列均为 100%;
  • [ ] 边界用例含 9999 与 0 折扣两组。
参考答案(追加的边界用例)
it.each([
  { desc: '最大数量 9999 无折扣', item: { quantity: 9999, unitPrice: 100, discountPercent: 100 }, expected: 999_900 },
  { desc: '零折扣', item: { quantity: 5, unitPrice: 129_900, discountPercent: 0 }, expected: 0 },
])('$desc → $expected 分', ({ item, expected }) => {
  expect(calcItemAmount(item)).toBe(expected)
})

追问自己:discountPercent: 0 表示「打骨折免费」还是非法输入?对照附录A(0–100 合法)应在表单层拦截负值,纯函数层只需对契约内的输入正确。

练习 2 · 进阶(45 min):StatusTag + OrderCard 行为测试

任务描述:① 完成 §15.5 三个 OrderCard 用例,并补第四个:status: 'cancelled' 的订单不渲染「编辑」按钮;② 全部断言只允许使用「用户可见行为」(wrapper.text()、wrapper.emitted()、find('[data-test=...]')),禁止 wrapper.vm.xxx;③ 运行 coverage,确认 src/components 覆盖率 ≥ 80%。

提示:emitted('edit') 返回数组,第 n 次触发的载荷是 emitted('edit')![n];不渲染按钮可直接断言 find('[data-test="edit-btn"]').exists() 为 false。

验收标准:

  • [ ] 四个用例全绿;
  • [ ] 无任何实现细节断言(review 时互查);
  • [ ] components 目录覆盖率 ≥ 80%,低于则补测而非调阈值。
参考答案(第四个用例)
it('已取消订单不渲染「编辑」按钮', () => {
  const wrapper = mount(OrderCard, {
    props: { order: { ...baseOrder, status: 'cancelled' } },
  })
  expect(wrapper.find('[data-test="edit-btn"]').exists()).toBe(false)
})

配套的组件实现按状态机收敛:v-if="canTransition(order.status, 'pending_review')"——组件逻辑复用 constants 层,测试也自动跟着契约走。

练习 3 · 挑战(选做,60+ min):msw 集成测试 + 覆盖率门禁上 CI

任务描述:① 接入 msw(§15.6),把练习 2 的 OrderCard 测试改为走真实 axios 请求层(axios 实例 + 拦截器全部生效,仅 HTTP 被 msw 接住);② 为 useOrderList 增加「接口返回 40901 时 toast 提示」的用例(用 server.use 覆写 handler);③ 把 test:coverage 加进 CI(本地可用 pnpm test:coverage 验证阈值生效:故意删一个用例看是否红灯)。

提示:server.use(http.get(...)) 放在用例内部,可临时覆盖 handlers;走真实请求层时 vi.mock('@/api/orders') 必须移除,否则两层 mock 打架。

验收标准:

  • [ ] 测试期间零真实网络请求(断网跑一遍仍全绿);
  • [ ] 40901 用例断言了 toast 文案与 list 未被污染;
  • [ ] 阈值低于 80% 时命令退出码非 0(CI 会失败)。
参考答案(40901 冲突用例)
import { http, HttpResponse } from 'msw'
import { server } from '@/test/mocks/server'
import { toast } from '@/utils/toast'

it('新建订单返回 40901(客户重复)时提示且不更新列表', async () => {
  server.use(
    http.post('*/api/v1/orders', () =>
      HttpResponse.json({ code: 40901, message: '客户已存在未结订单', data: null }, { status: 200 }),
    ),
  )
  const toastSpy = vi.spyOn(toast, 'error')

  const { result } = withSetup(() => useOrderList())
  await result.create(/* 表单数据 */ {} as OrderCreatePayload)

  expect(toastSpy).toHaveBeenCalledWith('客户已存在未结订单')
  expect(result.list.value).toHaveLength(0) // 错误响应不进列表
})

这正是「集成层测试」的价值:连 axios 拦截器的错误归一化逻辑(第 09 章)也一并被验证了。

⚠️ 常见易错点

易错点 1:测 UI 组件时不 mock 外部依赖——flaky 制造机

// ❌ 错误:OrderCard 挂载即调用真实接口 → 测试结果取决于网络/后端状态
// CI 里没有后端必挂;本地偶发超时 → 同样的代码时而绿时而红(flaky)
const wrapper = mount(OrderCard, { props: { order } }) // onMounted 里 fetchOrders()
// ✅ 正确:外部依赖全部可控——模块 mock 或 msw;定时器/时间同样要钉死
vi.mock('@/api/orders', () => ({ submitOrder: vi.fn().mockResolvedValue({ code: 0 }) }))
// 依赖当前时间的逻辑用假定时器:vi.useFakeTimers() + vi.setSystemTime(...)
const wrapper = mount(OrderCard, { props: { order } }) // 确定性:同样输入永远同样结果

易错点 2:断言实现细节——重构就碎的「伪安全」

// ❌ 错误:断言组件内部状态与私有方法。改个内部命名测试全红,而行为没变
expect((wrapper.vm as any).formattedAmount).toBe('3507.30')
;(wrapper.vm as any).handleEdit() // 直接调内部方法,绕过了「用户点击」
// ✅ 正确:只断言用户看得到、感觉得到的行为
expect(wrapper.text()).toContain('3507.30')
await wrapper.find('[data-test="edit-btn"]').trigger('click')
expect(wrapper.emitted('edit')).toEqual([['o-1']])

易错点 3:异步断言没等 DOM 更新——「偶发」红测试

// ❌ 错误:trigger 后立即断言。Element Plus 校验、接口返回都是异步,
// 此刻 DOM 还没变 → 时灵时不灵
await wrapper.find('[data-test="submit-btn"]').trigger('click')
expect(wrapper.text()).toContain('数量必须为 1–9999')
// ✅ 正确:等待微任务与渲染完成再断言(或改用 find + waitFor 类 API)
await wrapper.find('[data-test="submit-btn"]').trigger('click')
await flushPromises()
await nextTick()
expect(wrapper.text()).toContain('数量必须为 1–9999')

易错点 4:vi.mock 工厂引用外部变量——提升(hoisting)翻车

// ❌ 错误:vi.mock 会被提升到文件顶部执行,此时 fakeList 还未初始化
// 报错:Cannot access 'fakeList' before initialization
const fakeList = [/* ... */]
vi.mock('@/api/orders', () => ({
  fetchOrders: vi.fn().mockResolvedValue({ data: { list: fakeList } }),
}))
// ✅ 正确一:工厂内自包含,不引用外部变量
vi.mock('@/api/orders', () => ({
  fetchOrders: vi.fn().mockResolvedValue({
    data: { list: [{ id: 'o-1', orderNo: 'QO20260929000001' }] },
  }),
}))

// ✅ 正确二:确需共享变量时用 vi.hoisted 一同提升
const { fakeList } = vi.hoisted(() => ({ fakeList: [{ id: 'o-1' }] }))
vi.mock('@/api/orders', () => ({
  fetchOrders: vi.fn().mockResolvedValue({ data: { list: fakeList } }),
}))

易错点 5:快照当「万能断言」——红灯疲劳

// ❌ 错误:整组件 DOM 快照当唯一断言。任何人改一行模板都要 -u 更新快照,
// 很快团队就「见红就刷」,快照彻底失去保护作用
it('OrderCard 渲染', () => {
  expect(wrapper.html()).toMatchSnapshot()
})
// ✅ 正确:行为断言为主;快照只用于小而稳定的数据结构(见 §15.7)
it('展示客户名与格式化金额', () => {
  expect(wrapper.text()).toContain('青云科技有限公司')
  expect(wrapper.text()).toContain('3507.30')
})

✅ 最佳实践

  1. 金字塔定投入:纯函数与状态机穷举测(成本最低收益最高),组件测用户行为,E2E 只保主流程——三层各司其职。
  2. 逻辑下沉换可测性:金额、状态机、权限判断收敛进 utils/、constants/,让 80% 覆盖率主要消耗在无 DOM 的纯逻辑上。
  3. 外部依赖零容忍:测试里不允许出现真实网络、真实定时器、真实系统时间;onUnhandledRequest: 'error' 作为防线。
  4. 断言面向用户:用 data-test 建立测试与模板的稳定契约,永远不碰 wrapper.vm 的内部实现。
  5. mock 分层选型:单测用 vi.mock(快、与请求层解耦),集成测用 msw(顺带验证拦截器与错误归一化)。
  6. 覆盖率圈定核心模块:对齐附录A 口径 include 金额/权限/工具/组合式函数,四个阈值(语句/分支/函数/行)全部 80%,不搞全仓库平均。
  7. 测试命名写业务语言:九折(附录A 审批阈值场景)比case3可读,让测试文档成为需求文档的镜像。
  8. 红灯必查根因:偶发失败(flaky)不许重跑糊弄过去,先按易错点 1/3 的清单排查确定性,治不了的先 skip 并登记。

📌 本章小结

  1. 测试金字塔的核心是投入分配:单测最多、组件适中、E2E 少而精,与「轻订单」三层划分一一对应;
  2. Vitest 四件套:jsdom 环境、setup 清理、别名复用、覆盖率阈值(附录A 的 80% 是核心模块口径,四项全设);
  3. 纯函数(金额/状态机)用表驱动穷举,组合式函数用测试宿主组件获得生命周期上下文;
  4. 组件测试只断言用户可见行为;vi.mock 拦模块、msw 拦网络,两者都是消灭 flaky 的手段而非目的;
  5. 快照克制使用:只锁小而稳定的数据结构,绝不用整组件 DOM 快照替代行为断言。

组件之上的全链路「登录 → 下单 → 审批 → 导出」冒烟集是附录A 的最后一条质量目标——下一章用 Playwright 完成它,并解决登录态复用、测试数据隔离与 flaky 治理三大工程问题。

📄 第 16 章 · E2E 测试与质量保障 16-E2E测试与质量保障.md

📍 阶段:高级阶段(第 13–18 章)|⏱ 预计学时:6–8 小时|🔗 前置:第 11 章、第 15 章

⬅ 上一章 | 📖 本版目录 | ➡ 下一章

💡 对照学习:本页是 Vue 版第 16 章。React 版对应章节为 第 16 章 · E2E 测试与质量保障。Playwright 是框架无关的:配置、定位器、storageState、trace 与 CI 接入两版几乎逐字相同,唯一差异是被测应用的启动命令与页面结构。业务目标基准:附录A §4「E2E 冒烟集:登录 → 下单 → 审批 → 导出,全链路通过」。

🎯 学习目标

学完本章,你将能够:

  1. 说清 E2E 在质量体系中的定位:测「集成缝隙」而非覆盖所有分支,并给出「冒烟集 vs 全量集」的分层覆盖策略;
  2. 完成 Playwright 安装与配置:webServer 自动起应用、baseURL、失败截图/视频与 trace 采集;
  3. 用「面向用户可见性」的定位器策略(getByRole/getByLabel/getByTestId)编写抗重构的用例;
  4. 用 setup 项目 + storageState 复用登录态,让每个用例跳过登录又保持真实鉴权链路;
  5. 用 API 造数工厂准备测试数据并保证用例间隔离,消除顺序依赖;
  6. 用 trace、截图、视频三件套定位失败用例的根因,执行「flaky 治理五步法」;
  7. 把冒烟集接进 CI(含密钥注入与报告归档),交付附录A 要求的「登录 → 下单 → 审批 → 导出」全链路测试。

📖 核心知识点

16.1 E2E 的定位:少而稳,只测「缝」

单测(第 15 章)验证零件,E2E 验证整机。它执行成本最高、也最脆弱,所以覆盖策略必须分层:

集合 内容(轻订单) 触发时机 要求
冒烟集 登录 → 下单 → 审批 → 导出(附录A §4) 每次 MR、每次部署后 稳定如钟表,失败即阻断合并
全量集 各页面主要路径、权限矩阵抽查 每日定时 / 发版前 允许标注 retry,失败开 bug 单

红线:不要试图用 E2E 覆盖表单校验规则、金额计算这类逻辑——那是单测的地盘(第 15 章)。E2E 每多一个用例,CI 就慢一分、flaky 面就大一分。

16.2 安装与配置

pnpm add -D @playwright/test
pnpm exec playwright install chromium   # 教学以 chromium 为主,需要多浏览器再加参数
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  // CI 上误提交 .only 直接失败;本地重试 0 次暴露 flaky,CI 重试 2 次容忍基础设施抖动
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 2 : undefined,
  reporter: process.env.CI
    ? [['html', { open: 'never' }], ['github']]
    : [['list'], ['html', { open: 'never' }]],

  use: {
    baseURL: 'http://localhost:5173',
    trace: 'on-first-retry',      // 首次重试自动录 trace(§16.6)
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },

  projects: [
    // ① setup 项目:先跑登录,把 storageState 写盘(§16.4)
    { name: 'setup', testMatch: /auth\.setup\.ts/ },
    // ② 真正的用例:依赖 setup 完成,带上已登录的 storageState
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/operator.json' },
      dependencies: ['setup'],
    },
  ],

  // CI 上自动起应用;本地若已在 dev 则复用
  webServer: {
    command: 'pnpm dev --port 5173',
    url: 'http://localhost:5173',
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
})

16.3 定位器策略:像用户一样找元素

Playwright 定位器的优先级,本质是「离用户意图由近到远」:

定位器 对应的用户视角 示例
getByRole 「那个按钮/标签页」 page.getByRole('button', { name: '提交订单' })
getByLabel 「那个输入框的标签」 page.getByLabel('客户')
getByText 「那行字」 page.getByText('需财务审批')
getByTestId 兜底:模板加 data-test page.getByTestId('order-total')
page.locator('.css-cls') ❌ 实现细节 样式重构即碎
<!-- 被测组件只需为「无语义可依」的元素补 data-test -->
<template>
  <el-form label-width="96px">
    <el-form-item label="客户"><el-input v-model="keyword" data-test="customer-input" /></el-form-item>
    <span data-test="order-total">{{ fenToYuan(totalFen) }}</span>
  </el-form>
</template>

定位器还有两个关键行为:自动等待(元素可见、可点击后才操作)与可交互断言轮询(expect(locator).toBeVisible() 会自动重试直到超时)——它们是 §16.7 flaky 治理的地基,记住:凡是手工写的 sleep,几乎都是错误的等待。

16.4 登录态复用:setup 项目 + storageState

每个用例都走一遍「账密 + 图形验证码」登录又慢又脆。标准做法:setup 项目登录一次,把浏览器存储(cookie + localStorage,即 token 所在)序列化到文件,其余用例直接复用。

// e2e/auth.setup.ts
import { expect, test as setup } from '@playwright/test'
import { mkdirSync } from 'node:fs'

const operatorState = 'e2e/.auth/operator.json'
const financeState = 'e2e/.auth/finance.json'

setup('登录运营账号', async ({ page }) => {
  await page.goto('/login')
  // 教学口径:测试账号为虚构值;密码经环境变量注入,绝不写进仓库
  await page.getByLabel('账号').fill('operator.demo@qingorder.example.com')
  await page.getByLabel('密码').fill(process.env.E2E_OPERATOR_PASSWORD!)
  await page.getByRole('button', { name: '登录' }).click()
  await expect(page.getByRole('heading', { name: '工作台' })).toBeVisible()

  mkdirSync('e2e/.auth', { recursive: true })
  await page.context().storageState({ path: operatorState })
})

setup('登录财务账号', async ({ page }) => {
  await page.goto('/login')
  await page.getByLabel('账号').fill('finance.demo@qingorder.example.com')
  await page.getByLabel('密码').fill(process.env.E2E_FINANCE_PASSWORD!)
  await page.getByRole('button', { name: '登录' }).click()
  await expect(page.getByRole('heading', { name: '工作台' })).toBeVisible()
  await page.context().storageState({ path: financeState })
})

需要财务身份的用例,在 describe 层切换 storageState:

import { test } from '@playwright/test'

test.describe('财务审批', () => {
  test.use({ storageState: 'e2e/.auth/finance.json' })
  test('折扣订单进入待审核列表', async ({ page }) => { /* ... */ })
})

16.5 测试数据准备与隔离:API 造数工厂

E2E 数据隔离的两条铁律:用 API 造数(比 UI 点出来快一个数量级),每个用例的数据彼此独立(名字唯一、互不共享)。

// e2e/fixtures/orders.ts
import { expect, type APIRequestContext } from '@playwright/test'
import type { Order } from '../src/types/order' // 类型与附录A §5 对齐

/** 通过 API 造一笔「九折待审批」订单,返回订单号 */
export async function seedPendingReviewOrder(
  request: APIRequestContext,
  tag: string, // 唯一标记:时间戳 + worker 序号,保证并发隔离
): Promise<{ id: string; orderNo: string }> {
  const resp = await request.post('/api/v1/orders', {
    data: {
      customerId: 'c-e2e-001',
      customerName: `E2E压测客户-${tag}`,
      items: [{ skuId: 's-001', skuName: '智能网关 X1', quantity: 3, unitPrice: 129_900, discountPercent: 90 }],
      wholeDiscountPercent: 90,
    },
  })
  await expect(resp).toBeOK()
  return (await resp.json()) as { id: string; orderNo: string }
}
// 用例内生成唯一标记,两个 worker 同时跑也不串数据
import { test } from '@playwright/test'

test('订单出现在待审核列表', async ({ page, request }) => {
  const tag = `${Date.now()}-${test.info().workerIndex}`
  const { orderNo } = await seedPendingReviewOrder(request, tag)
  // ……用例只操作自己造的这单数据
})

需要第三方依赖(图形验证码、统计脚本)稳定时,用路由拦截钉住它们,而不是祈祷:

// 验证码接口教学环境难以稳定识别:直接拦截并返回固定数据
await page.route('**/api/v1/auth/captcha', (route) =>
  route.fulfill({ json: { code: 0, message: 'ok', data: { captchaId: 'fixed', svgBase64: '...' } } }),
)

16.6 诊断三件套:trace、截图、视频

trace: 'on-first-retry' 已在 §16.2 开启——失败重试时自动录制完整 trace(每一步的 DOM 快照、网络、console)。本地手动触发与查看:

pnpm exec playwright test --trace on          # 强制录制
pnpm exec playwright show-trace test-results/xxx/trace.zip   # 打开 trace 查看器

Trace 查看器里按「Before / After」对比每步 DOM 快照、看 Network 时间线、看 console 报错——90% 的失败原因都能在这里找到,截图(失败瞬间画面)与视频(操作过程)作为补充证据。CI 上把 test-results/、playwright-report/ 归档为 artifact(§16.8)。

16.7 flaky 治理五步法

flaky(时好时坏)是 E2E 的头号杀手,治理流程固定为:

  1. 复现:pnpm exec playwright test --repeat-each=10 跑 10 遍抓现场;
  2. 归因:开 trace 找到失败那一步——超时?元素被遮挡?数据被别人改了?
  3. 分类:测试问题(硬等待/选择器/共享数据)还是产品 bug(真竞态/真漏洞)?后者转 bug 单;
  4. 修复:按下表对症下药;
  5. 回归验证:再跑 10 遍全绿才算关闭。
病因 修复
硬等待(waitForTimeout) 改为条件断言/自动等待(见易错点 1)
实现细节定位器 换 getByRole/getByTestId(§16.3)
用例间共享数据 API 造数 + 唯一标记(§16.5)
第三方依赖抖动 page.route 拦截钉死(§16.5)
并发资源冲突(同端口/同账号互踢) 造数加 workerIndex、按用例分配测试账号

16.8 实战:附录A 冒烟集——登录 → 下单 → 审批 → 导出

完整冒烟用例:运营登录态下单(九折触发审批)→ 财务上下文审批 → 回到运营导出 CSV。

// e2e/order-smoke.spec.ts
import { expect, test } from '@playwright/test'
import { seedPendingReviewOrder } from './fixtures/orders'

test.describe('冒烟集(附录A §4):登录 → 下单 → 审批 → 导出', () => {
  // 登录本身单独一条:不依赖任何前置数据,直接验证鉴权链路
  test('未登录访问后台被拦截并跳转登录页', async ({ page }) => {
    // 用例级覆盖:忽略项目默认 storageState,模拟「新用户」
    await test.use({ storageState: { cookies: [], origins: [] } })
    await page.goto('/orders/list')
    await expect(page).toHaveURL(/\/login/)
    await expect(page.getByRole('button', { name: '登录' })).toBeVisible()
  })

  test('运营创建九折订单 → 财务审批通过 → 批量导出 CSV', async ({ page, browser, request }) => {
    // ── ① 下单(默认 storageState = 运营)──────────────────
    await page.goto('/orders/create')
    await page.getByLabel('客户').fill('青云')
    await page.getByRole('option', { name: /E2E压测客户/ }).first().click()
    await page.getByRole('button', { name: '添加明细行' }).click()
    await page.getByLabel('商品').fill('智能网关')
    await page.getByRole('option', { name: /智能网关 X1/ }).first().click()
    await page.getByLabel('数量').fill('3')
    await page.getByLabel('行折扣').fill('90') // 九折 → 触发「需财务审批」
    await expect(page.getByText('需财务审批')).toBeVisible()   // 附录A §3.3 联动
    await page.getByRole('button', { name: '提交订单' }).click()

    // 提交后跳详情,断言状态为待审核
    await expect(page).toHaveURL(/\/orders\//)
    await expect(page.getByTestId('order-status')).toHaveText('待审核')

    // ── ② 审批(另开财务上下文,storageState = 财务)─────────
    const financeCtx = await browser.newContext({
      storageState: 'e2e/.auth/finance.json',
      baseURL: 'http://localhost:5173',
    })
    const financePage = await financeCtx.newPage()
    await financePage.goto('/dashboard/todo')            // 我的待办:待我审批
    await financePage.getByRole('listitem').first().click()
    await financePage.getByRole('button', { name: '审批通过' }).click()
    await financePage.getByRole('button', { name: '确认' }).click()
    await expect(financePage.getByTestId('order-status')).toHaveText('已审核')
    await financeCtx.close()

    // ── ③ 导出(回到运营上下文)───────────────────────────
    await page.goto('/orders/list')
    await page.getByLabel('状态').selectOption('approved')
    await page.getByRole('checkbox').first().check()
    const downloadPromise = page.waitForEvent('download') // 下载没有 DOM 可断言:等事件
    await page.getByRole('button', { name: '批量导出' }).click()
    const download = await downloadPromise
    expect(download.suggestedFilename()).toMatch(/orders.*\.csv$/i)
  })

  test('API 造数的订单出现在财务待办', async ({ page, request }) => {
    // 演示「造数工厂 + UI 验证」的组合:造数走 API,验证走 UI
    const tag = `${Date.now()}-${test.info().workerIndex}`
    const { orderNo } = await seedPendingReviewOrder(request, tag)

    await page.goto(`/orders/list?keyword=${orderNo}`)
    await expect(page.getByRole('cell', { name: orderNo })).toBeVisible()
    await expect(page.getByTestId('order-status').first()).toHaveText('待审核')
  })
})

注意 ② 的设计取舍:冒烟集用「财务另开上下文」在一个用例内走完三步,保证失败时能一眼看出断在哪一步;如果拆成多个串行用例,就需要用例间传递订单号(describe.serial + 共享变量),隔离性会变差。教学场景推荐前者。

16.9 CI 接入:密钥用 secrets,报告留证据

# .github/workflows/e2e.yml(冒烟集;完整 lint→test→build→deploy 流水线见第 18 章)
name: e2e-smoke
on:
  pull_request:
  push:
    branches: [main]

jobs:
  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm exec playwright install --with-deps chromium
      - run: pnpm exec playwright test
        env:
          # 密码从仓库 secrets 注入,绝不写进代码(附录A 教学口径:值本身是虚构的)
          E2E_OPERATOR_PASSWORD: ${{ secrets.E2E_OPERATOR_PASSWORD }}
          E2E_FINANCE_PASSWORD: ${{ secrets.E2E_FINANCE_PASSWORD }}
      - uses: actions/upload-artifact@v4
        if: ${{ !cancelled() }}          # 失败也要上传报告,这是排障的生命线
        with:
          name: playwright-report
          path: |
            playwright-report/
            test-results/
          retention-days: 7

🛠 渐进式实战练习

练习 1 · 基础(20 min):配置与登录冒烟

任务描述:① 完成 §16.2 配置(含 setup 项目与两个 storageState);② 编写 §16.8 的「未登录访问被拦截」用例;③ 本地跑通 pnpm exec playwright test,打开 HTML 报告确认通过。

提示:.auth/ 目录加入 .gitignore;本地起 dev server 时 reuseExistingServer 会直接复用。

验收标准:

  • [ ] setup 项目先于用例执行(报告里可见两个登录 setup);
  • [ ] 未登录用例断言了跳转 /login;
  • [ ] .auth/ 未出现在 git status 里。

练习 2 · 进阶(45 min):造数工厂 + 下单主流程

任务描述:① 实现 seedPendingReviewOrder 造数工厂;② 补全 §16.8 主流程用例到你的项目(元素文案按你的实现微调,但「九折 → 需财务审批 → 审批 → 导出」链路不可少);③ 用 --repeat-each=5 连跑 5 遍验证稳定。

提示:商品/客户下拉是搜索型组件时,先 fill 触发搜索、再点 getByRole('option', ...);断言订单状态用 data-test 兜底。

验收标准:

  • [ ] 主流程用例 5 连跑全绿;
  • [ ] 造数通过 API 完成(Network 面板或 trace 可见,无 UI 造数);
  • [ ] 导出断言了下载文件名后缀。
参考答案(搜索下拉的稳定操作序列)
// 搜索型下拉的三步法:输入 → 等选项出现(断言自动轮询)→ 点选项
await page.getByLabel('客户').fill('E2E压测客户')
await expect(page.getByRole('option', { name: /E2E压测客户/ }).first()).toBeVisible()
await page.getByRole('option', { name: /E2E压测客户/ }).first().click()

关键在第 2 步:用可见性断言替代任何 sleep,选项异步加载完成前测试会自动等待。

练习 3 · 挑战(选做,60+ min):flaky 治理实战

任务描述:从课程仓库获取(或自己故意制造)一个「病灶测试」:用了 waitForTimeout(3000)、.css-3xk2f 类名定位器、并依赖上一条用例创建的订单。按 §16.7 五步法完成治理:复现(--repeat-each=10)→ trace 归因 → 修复 → 回归验证,最后把过程写成一份 200 字的治理记录。

提示:三个病灶对应 §16.7 表格的前三行;trace 归因时重点看失败步骤前后的 DOM 快照差异。

验收标准:

  • [ ] 修复后 --repeat-each=10 全绿;
  • [ ] 治理记录包含「病灶 → trace 证据 → 修复 → 复验」四段;
  • [ ] 全程无 waitForTimeout、无 CSS 类名定位器。
参考答案(治理记录模板)
## flaky 治理记录:order-smoke.spec.ts「下单主流程」
- 现象:本地通过,CI 每 3~5 次失败一次(step:断言「待审核」超时)。
- 归因(trace):提交后 500ms 内详情页尚未跳转,旧页面上 data-test 命中失败;
  次要病灶:用 waitForTimeout(3000) 掩盖了跳转时长波动。
- 修复:删除硬等待;断言改为 expect(page).toHaveURL(/\/orders\//) →
  expect(page.getByTestId('order-status')).toHaveText('待审核')(自动轮询);
  订单号由共享变量改为 API 造数 + 唯一标记,消除用例间依赖。
- 复验:--repeat-each=10 全绿(本地 + CI 各一轮)。

⚠️ 常见易错点

易错点 1:waitForTimeout 硬等待——flaky 的头号来源

// ❌ 错误:睡 3 秒「等页面加载」。网络快时白等 3 秒,网络慢时 3 秒根本不够
await page.getByRole('button', { name: '提交订单' }).click()
await page.waitForTimeout(3000)
await expect(page.getByTestId('order-status')).toHaveText('待审核')
// ✅ 正确:断言自带轮询,条件满足立即继续,超时才失败
await page.getByRole('button', { name: '提交订单' }).click()
await expect(page).toHaveURL(/\/orders\//)                       // 等跳转
await expect(page.getByTestId('order-status')).toHaveText('待审核') // 等状态渲染

易错点 2:CSS 类名 / XPath 定位器——改个样式测试就碎

// ❌ 错误:定位器耦合实现细节。UI 微调类名/层级 → 测试大面积红灯,
// 维护成本高到团队放弃 E2E
await page.locator('.el-button--primary.css-3xk2f').click()
await page.locator('//*[@id="app"]/div/div[2]/table/tr[1]/td[3]').click()
// ✅ 正确:面向角色与可访问名(顺带促进 a11y),无语义时才用 data-test 兜底
await page.getByRole('button', { name: '提交订单' }).click()
await page.getByTestId('order-total').click()

易错点 3:用例间共享可变数据——顺序依赖的地雷阵

// ❌ 错误:所有用例共用「测试订单 A」;并发 worker 互改状态、
// 一个用例取消订单导致后面的用例全挂 → 单独跑全绿、一起跑必红
test('取消订单', async () => { await cancel(ORDER_A_ID) })
test('导出已审核订单', async () => { /* ORDER_A 已被上一条取消 */ })
// ✅ 正确:每条用例自己造数、数据自足,跑序无关
test('导出已审核订单', async ({ page, request }) => {
  const { orderNo } = await seedApprovedOrder(request, `${Date.now()}-${test.info().workerIndex}`)
  // ……只操作自己造的订单
})

易错点 4:storageState 里的过期 token——偶发 401 的隐形 flaky

// ❌ 错误:storageState 文件提交进仓库或长期复用。
// 附录A 约定 accessToken 2 小时过期 → 第二天 CI 上所有用例 401,
// 表现为「隔夜必挂」,极难排查
await page.context().storageState({ path: 'e2e/.auth/operator.json' }) // 一次性写盘
// ——却被 git add 提交,或被共享目录里的陈旧文件覆盖
// ✅ 正确:storageState 文件每次 CI 运行由 setup 项目现场生成(§16.2/16.4),
// 加入 .gitignore;断言首页可见就是「登录态有效」的自检,
// 失效时 setup 立刻失败并给出明确信号,而不是让 20 个用例各自 401
// .gitignore:  e2e/.auth/

易错点 5:CI 里 webServer 端口冲突或没等就绪

# ❌ 错误:CI 上 reuseExistingServer 为 true(默认本地行为),
# 若 runner 恰有残留进程占端口,测试会打到「别人家的应用」;或应用未就绪就开始跑
// ✅ 正确:CI 关闭复用,Playwright 会轮询 url 直到可访问(或超时明确报错)
webServer: {
  command: 'pnpm dev --port 5173',
  url: 'http://localhost:5173',
  reuseExistingServer: !process.env.CI, // 本地复用、CI 严格新建
  timeout: 120_000,
},

✅ 最佳实践

  1. 冒烟集神圣不可侵犯:主流程用例失败必须阻断合并,不许「跳过先上」;全量集失败开单跟踪。
  2. 数据三原则:API 造数、用例自足、名字唯一(时间戳 + workerIndex),让任何用例可以单独重跑。
  3. 定位器面向用户:getByRole 优先、data-test 兜底、永不 CSS 类名——同时提升用例稳定性与页面可访问性。
  4. trace 是排障的生命线:CI 保留报告 artifact 7 天以上,团队约定「讨论失败必须贴 trace 链接」。
  5. 外部依赖钉死:验证码、统计、动画等不可控因素用 page.route 拦截或减少动画配置,不给 flaky 留入口。
  6. 密钥与账号纪律:测试密码走 secrets.* 注入;storageState 与 .auth/ 永不入库;账号按角色拆分且可重复使用(附录A 虚构口径)。
  7. CI 重试但本地不重试:本地 retries: 0 暴露不确定性,CI retries: 2 容忍基础设施抖动——但被重试「救回来」的用例要打标跟进。
  8. E2E 数量有预算:冒烟 ≤ 10 条、全量 ≤ 50 条起步,超预算先问「这条能不能降级成组件测试」。

📌 本章小结

  1. E2E 的价值在「集成缝隙」:冒烟集保命脉(附录A 登录→下单→审批→导出),全量集保覆盖,两者都要少而稳;
  2. 四大工程件:webServer 自动起应用、setup 项目 + storageState 复用登录态、API 造数工厂保证隔离、trace 三件套定位失败;
  3. 稳定性三板斧:条件断言替代硬等待、面向用户的定位器、用例间数据零共享;
  4. flaky 治理是流程问题:复现 → 归因 → 分类 → 修复 → 复验五步法,把「偶发失败」变成可关闭的工单;
  5. CI 接入两条铁律:密钥走 secrets.*,报告 artifact 失败也上传。

至此质量防线成型(单测 + E2E)。下一章转向工程底座:把整个「轻订单」升级为严格模式 TypeScript,搭好 ESLint + Prettier + husky 规范链路,并系统讲解 Vite 的插件、代理与环境变量机制。

📄 第 17 章 · 工程化实践:TypeScript 与 Vite 17-工程化实践-TypeScript与Vite.md

📍 阶段:高级阶段(第 13–18 章)|⏱ 预计学时:7–9 小时|🔗 前置:第 12 章、第 14 章

⬅ 上一章 | 📖 本版目录 | ➡ 下一章

💡 对照学习:本页是 Vue 版第 17 章。React 版对应章节为 第 17 章 · 工程化实践-TypeScript与Vite,概念映射:defineProps<Props>() ≈ interface Props + 函数组件参数;generic="T" 泛型组件 ≈ function List<T>(props);vue-tsc ≈ tsc(多了 .vue 检查能力);类型守卫、ESLint/Prettier/husky、Vite 插件/代理/环境变量、pnpm workspace 两版一致(仅构建插件为 @vitejs/plugin-vue vs @vitejs/plugin-react)。

🎯 学习目标

学完本章,你将能够:

  1. 逐项解释 tsconfig 的关键选项(strict 全家桶、moduleResolution: bundler、noUncheckedIndexedAccess)并给出团队纪律:strict 一旦开启不许回退;
  2. 为 Vue 组件编写完整类型:defineProps/defineEmits/defineModel 的类型化写法与 3.5 响应式 props 解构默认值;
  3. 编写泛型组件(generic="T"),让列表类组件在使用处获得精确类型推导;
  4. 为 API 响应编写类型守卫(type guard),把「后端返回什么」从信仰变成校验;
  5. 搭建 ESLint(flat config)+ Prettier + husky + lint-staged 的提交前规范链路;
  6. 掌握 Vite 的插件机制、开发代理与环境变量分环境管理,并写出正确的 ImportMetaEnv 类型声明;
  7. 用构建分析工具验证产物,说清 tree-shaking 生效的条件;
  8. 制定「轻订单」从 JS 到 TS 的渐进式迁移方案(分六步、每步可验证)。

📖 核心知识点

17.1 tsconfig 关键项:strict 是纪律不是选项

// tsconfig.json(应用主配置;Vite 项目通常由 tsconfig.app.json / node.json 拆分引用,此处展示核心项)
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",     // Vite/打包器语义:支持 import '@/x'、扩展名省略
    "jsx": "preserve",

    // —— strict 全家桶:一条开关带出全部严格检查 ——
    "strict": true,
    "noUncheckedIndexedAccess": true,  // arr[i] 类型为 T | undefined:索引访问必须判空
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "verbatimModuleSyntax": true,      // 强制区分 type import 与 value import

    "skipLibCheck": true,              // 跳过第三方 .d.ts 检查(提速,不掩盖自己代码的问题)
    "baseUrl": ".",
    "paths": { "@/*": ["src/*"] },
    "types": ["vite/client"]           // 提供 import.meta.env 等类型
  },
  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
}
# 类型检查命令(.vue 文件的类型只能由 vue-tsc 检查,tsc 不认识它)
pnpm add -D typescript vue-tsc
pnpm exec vue-tsc --noEmit        # 进 CI(第 18 章流水线的 typecheck 步骤)

两条团队纪律:① strict: true 是底线,新项目第一天就开;存量项目按 §17.9 的迁移路径达标,不许长期挂着 strict: false。② 报错不许用 as 压掉——类型错误的正确反应是修类型,而不是骗过编译器。

17.2 组件类型:props / emits / model 全类型化

<!-- src/orders/components/OrderListItem.vue -->
<script setup lang="ts">
import type { Order } from '@/types/order'

interface Props {
  order: Order
  selectable?: boolean
  maxSelected?: number
}

// Vue 3.5:响应式 props 解构,默认值直接写在解构里(替代 withDefaults)
const { order, selectable = false, maxSelected = 10 } = defineProps<Props>()

// 事件与载荷全类型化:emit('select', ...) 的参数类型由签名决定
const emit = defineEmits<{
  select: [order: Order, checked: boolean]
  edit: [id: string]
}>()

// 双向绑定 v-model:title 的类型化写法(父组件 v-model:title="kw")
const title = defineModel<string>('title', { default: '' })

function onSelect(checked: boolean) {
  emit('select', order, checked)
}
</script>

<template>
  <div class="order-list-item">
    <input v-if="selectable" type="checkbox" @change="onSelect(($event.target as HTMLInputElement).checked)" />
    <span data-test="order-total">{{ order.orderNo }}</span>
    <input v-model="title" placeholder="备注" />
  </div>
</template>

类型化 props 的收益在使用处兑现:父组件里 <OrderListItem :order="o" :max-seleted="5" /> 会直接标红拼写错误,而运行时毫无察觉——这正是 TS 的核心价值:把运行时 bug 提前到编译期。

17.3 泛型组件:列表组件只写一次,类型处处精确

「轻订单」里客户列表、商品列表、订单列表都要「多选框 + 受控选中」,抽成一个泛型组件:

<!-- src/components/SelectableList.vue -->
<script setup lang="ts" generic="T extends { id: string | number }">
const { items } = defineProps<{ items: T[] }>()

// v-model:selected:选中项数组与 items 同类型
const selected = defineModel<T[]>('selected', { default: () => [] })

function toggle(item: T) {
  selected.value = selected.value.some((s) => s.id === item.id)
    ? selected.value.filter((s) => s.id !== item.id)
    : [...selected.value, item]
}
</script>

<template>
  <ul class="selectable-list">
    <li
      v-for="item in items"
      :key="item.id"
      :class="{ active: selected.some((s) => s.id === item.id) }"
      @click="toggle(item)"
    >
      <!-- 作用域插槽把 item 原样交出去,类型随 T 推导 -->
      <slot :item="item" />
    </li>
  </ul>
</template>
<!-- 使用处:T 自动推导为 Order,item.id/orderNo 全部有提示 -->
<script setup lang="ts">
import { ref } from 'vue'
import SelectableList from '@/components/SelectableList.vue'
import type { Order } from '@/types/order'

const orders = ref<Order[]>([])
const picked = ref<Order[]>([])
</script>

<template>
  <SelectableList v-model:selected="picked" :items="orders">
    <template #default="{ item }">
      {{ item.orderNo }} | {{ item.ownerName }}
    </template>
  </SelectableList>
</template>

17.4 API 响应类型与类型守卫:不信任任何网络数据

附录A §5 约定了统一响应体。先把「形状」类型化,再用类型守卫在运行时校验——is 谓词让 TS 在校验通过后收窄类型:

// src/types/api.ts
export interface ApiPage<T> {
  list: T[]
  total: number
  page: number
  pageSize: number
}

export interface ApiResponse<T> {
  code: number   // 语义见附录A §5:0 成功 / 4xx01 参数错误 / 40102 token 过期 / 40901 冲突……
  message: string
  data: T
}
// src/types/order.guard.ts
import type { Order, OrderStatus } from './order'

const ORDER_STATUSES: readonly OrderStatus[] = [
  'draft', 'pending_review', 'approved', 'shipped', 'completed', 'rejected', 'cancelled',
]

function isRecord(v: unknown): v is Record<string, unknown> {
  return typeof v === 'object' && v !== null
}

export function isOrderStatus(v: unknown): v is OrderStatus {
  return typeof v === 'string' && (ORDER_STATUSES as readonly string[]).includes(v)
}

/** 运行时校验「这是不是一个合法的 Order」,通过后 TS 才承认它是 Order */
export function isOrder(v: unknown): v is Order {
  if (!isRecord(v)) return false
  return (
    typeof v.id === 'string' &&
    typeof v.orderNo === 'string' &&
    isOrderStatus(v.status) &&
    Array.isArray(v.items)
  )
}

接入请求层(第 09 章的 api/orders.ts),类型不合法直接抛业务错误而不是让脏数据流入 store:

// src/api/orders.ts(节选)
import { http } from './request'
import { isOrder } from '@/types/order.guard'
import type { ApiPage, ApiResponse } from '@/types/api'
import type { Order, OrderQuery } from '@/types/order'

export async function fetchOrders(params: OrderQuery): Promise<ApiPage<Order>> {
  const res = await http.get<ApiResponse<ApiPage<unknown>>>('/orders', { params })
  const page = res.data.data

  // 收口:结构和状态码全部校验,脏数据在此拦截
  if (!Array.isArray(page?.list) || !page.list.every(isOrder)) {
    throw Object.assign(new Error('订单列表数据结构异常'), { code: 40401 })
  }
  return page as ApiPage<Order>
}

17.5 规范链路:ESLint + Prettier + husky + lint-staged

目标:不合规的代码进不了仓库,且校验只跑「改动的文件」(快)。

// eslint.config.ts(flat config,节选自 create-vue 默认结构)
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
import pluginVue from 'eslint-plugin-vue'

export default defineConfigWithVueTs(
  { ignores: ['dist/**', 'coverage/**', 'e2e/.auth/**'] },
  pluginVue.configs['flat/recommended'],
  vueTsConfigs.recommended,
  {
    rules: {
      'vue/multi-word-component-names': 'off',
      '@typescript-eslint/no-explicit-any': 'error',          // 见易错点 1
      '@typescript-eslint/consistent-type-imports': 'error',  // import type 强制
    },
  },
  skipFormatting, // 风格类冲突让位给 Prettier:ESLint 管质量,Prettier 管格式
)
// .prettierrc.json
{
  "semi": false,
  "singleQuote": true,
  "printWidth": 100,
  "trailingComma": "all"
}
pnpm add -D husky lint-staged
pnpm exec husky init    # 生成 .husky/pre-commit 并自动写入 prepare 脚本
# .husky/pre-commit(husky init 生成后替换为)
pnpm exec lint-staged
// package.json(节选)
{
  "scripts": {
    "prepare": "husky",
    "lint": "eslint . --fix",
    "format": "prettier --write .",
    "typecheck": "vue-tsc --noEmit"
  },
  "lint-staged": {
    "*.{ts,vue}": ["eslint --fix", "prettier --write"],
    "*.{json,css,scss,md}": ["prettier --write"]
  }
}

链路分工:ESLint 管代码质量(no-explicit-any、未用变量)、Prettier 管格式(缩进/引号/换行)、lint-staged 只查暂存区文件(提交快)、husky 提供触发时机。typecheck 不放 pre-commit(太慢),放 CI(第 18 章)。

17.6 Vite 工程:插件、代理、环境变量

插件机制:Vite 插件是 Rollup 插件的超集。自研插件往往只需要一两个钩子,例如构建时注入版本号(第 18 章监控要用它做 release 标识):

// vite/plugins/injectVersion.ts
import type { Plugin } from 'vite'

/** 把版本号(如 git 提交短哈希)注入 index.html 的占位注释处 */
export function injectVersion(version: string): Plugin {
  return {
    name: 'qing-inject-version',
    transformIndexHtml(html) {
      return html.replace(
        '<!--%APP_VERSION%-->',
        `<meta name="app-version" content="${version}" />`,
      )
    },
  }
}
// vite.config.ts
import { fileURLToPath, URL } from 'node:url'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
import { injectVersion } from './vite/plugins/injectVersion'
import pkg from './package.json'

export default defineConfig(({ mode }) => ({
  plugins: [vue(), injectVersion(mode === 'production' ? pkg.version : 'dev')],
  resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
  server: {
    // 开发代理:附录A §5 —— 前端请求 /api/v1,转发到本地 mock(无跨域问题)
    proxy: {
      '/api/v1': {
        target: 'http://localhost:9000', // 必须带协议
        changeOrigin: true,              // 改写 Host 头,目标服务校验域名时需要
        // 仅当 mock 服务自身没有 /api/v1 前缀时才开启:
        // rewrite: (path) => path.replace(/^\/api\/v1/, ''),
      },
    },
  },
}))

环境变量分环境:.env 全部生效 → .env.[mode] 按模式叠加(development/production,自定义模式如 test 用 --mode test 指定)。只有 VITE_ 前缀的变量会暴露给客户端代码:

# .env.development
VITE_API_BASE_URL=/api/v1
VITE_USE_MOCK=true

# .env.production
VITE_API_BASE_URL=https://api.qingorder.example.com/api/v1
VITE_USE_MOCK=false
// src/vite-env.d.ts:给环境变量补类型(否则 import.meta.env 是宽泛索引类型)
/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_API_BASE_URL: string
  readonly VITE_USE_MOCK: string
  readonly VITE_SENTRY_DSN?: string // 第 18 章监控使用;DSN 本身可公开
}

interface ImportMeta {
  readonly env: ImportMetaEnv
}
// src/config/env.ts:收敛读取点,别在业务组件里散落 import.meta.env
export const env = {
  apiBaseUrl: import.meta.env.VITE_API_BASE_URL,
  useMock: import.meta.env.VITE_USE_MOCK === 'true',
} as const

安全红线(附录A/BLUEPRINT 同款):任何密钥都不许放进 VITE_*——它会原样打进客户端产物,按 F12 就能看见。服务端密钥只存在于服务端(第 18 章经 CI secrets.* 注入)。

17.7 构建分析与产物优化

分析工具与手动分包第 14 章已讲(rollup-plugin-visualizer),本节补「tree-shaking 何时失效」:打包器按 ESM 静态结构剔除未用导出,以下任一情况会失效——CommonJS 依赖、带副作用的模块(顶层执行代码)、sideEffects: false 声明缺失。检查方式:

// package.json:声明「发布产物无副作用」,让未用导出可被安全剔除
{
  "sideEffects": ["*.css", "*.vue"]
}
pnpm build && ls -lh dist/assets | sort -k5 -h | tail -5   # 快速看最大的 5 个产物

17.8 pnpm workspace 初探:为第四部分的 monorepo 打底

把与业务无关的纯逻辑(金额计算、状态机)抽成独立包,是第 19 章架构演进的第一步。pnpm workspace 十行配置即可起步:

# pnpm-workspace.yaml(项目根目录)
packages:
  - '.'            # 主应用
  - 'packages/*'   # 内部包
qing-order-admin/
├── pnpm-workspace.yaml
├── package.json
└── packages/
    └── money/                # 内部包:金额计算(第 15 章已 100% 覆盖测试)
        ├── package.json
        └── src/index.ts
// packages/money/package.json
{
  "name": "@qing/money",
  "version": "0.1.0",
  "type": "module",
  // 开发期直接暴露 TS 源码:Vite 消费方负责编译,省掉构建链
  "main": "./src/index.ts",
  "types": "./src/index.ts"
}
# 主应用安装内部包:workspace: 协议指向本仓库
pnpm add @qing/money --workspace
// 主应用中使用
import { calcOrderTotal } from '@qing/money'

17.9 轻订单的渐进式 TS 迁移方案

第 12 章交付的是 JS 版本,全量重写不现实。按「依赖从底层往上」的顺序分六步迁移,每步都有可验证的完成标志:

步骤 迁移对象 动作 完成标志
1 基线 tsconfig 开 strict + allowJs: true;vue-tsc --noEmit 进 CI(只增量检查 .ts/.vue) CI 绿
2 types/ 实体 按附录A §5 建立 Order/OrderItem/ApiResponse 等类型 实体单一事实源
3 utils/、constants/ .js → .ts;第 15 章的单测同时守护行为不变 对应目录零 .js
4 api/ 请求层 迁移 + 类型守卫(§17.4) 接口出入参全类型化
5 stores/、composables/ 迁移;Pinia store 用 setup 写法天然亲和 TS 状态读写全提示
6 components/、views/ 逐组件迁移(每 PR 一个域);完成后移除 allowJs 全仓零 .js

两条迁移纪律:迁移 PR 不许夹带行为改动(测试全绿是唯一证据);迁移期间的 .js 文件用 JSDoc 标注关键类型(/** @param {Order} order */),让后续接手者有据可依。

🛠 渐进式实战练习

练习 1 · 基础(20 min):strict 化与类型体检

任务描述:① 按 §17.1 更新 tsconfig(strict + noUncheckedIndexedAccess);② 跑 pnpm exec vue-tsc --noEmit,把报错清单逐条修复(as 压制不算修复);③ 把 typecheck 加进 package.json scripts。

提示:最高频的三类报错:可能是 undefined 的取值、事件对象类型、import type 缺失;noUncheckedIndexedAccess 报错优先用「判空/收窄」而不是 ! 非空断言。

验收标准:

  • [ ] vue-tsc --noEmit 退出码为 0;
  • [ ] 全仓 grep -rn " as any" src 结果为 0;
  • [ ] 修复过程中没有改动任何运行时行为(测试仍全绿)。

练习 2 · 进阶(45 min):泛型组件 + 类型守卫

任务描述:① 实现 §17.3 的 SelectableList<T>,分别用 Order[] 与 Customer[](自定义实体)使用它,验证两处类型推导互不污染;② 实现 isOrder 守卫并接入 fetchOrders;③ 为 isOrder 补单测(第 15 章的表驱动风格):合法对象、缺字段、非法 status 三组用例。

提示:generic="T extends { id: string | number }" 的约束让「没有 id 的对象」在编译期就被拒绝;守卫测试不需要 jsdom,纯 Node 断言即可。

验收标准:

  • [ ] 两个使用处的 item 均有正确字段提示与拼写检查;
  • [ ] 返回脏数据(mock 一个缺 orderNo 的响应)时,请求层抛出错误而非渲染;
  • [ ] 守卫单测 ≥ 3 组且全绿。
参考答案(守卫单测)
// src/types/__tests__/order.guard.spec.ts
import { describe, expect, it } from 'vitest'
import { isOrder } from '../order.guard'

const validOrder = {
  id: 'o-1', orderNo: 'QO20260929000001', customerId: 'c-1',
  customerName: '青云科技', ownerName: '王小明', status: 'draft',
  items: [], wholeDiscountPercent: 100, totalAmount: 0,
  createdAt: '2026-09-29T10:00:00+08:00',
}

describe('isOrder 类型守卫', () => {
  it.each([
    { desc: '合法对象', input: validOrder, expected: true },
    { desc: '缺少 orderNo', input: { ...validOrder, orderNo: undefined }, expected: false },
    { desc: '非法状态码', input: { ...validOrder, status: 'hacked' }, expected: false },
    { desc: '非对象输入', input: 'null-injected-string', expected: false },
  ])('$desc → $expected', ({ input, expected }) => {
    expect(isOrder(input)).toBe(expected)
  })
})

注意 'hacked' 用例:这正是「后端契约变更/接口被注入脏数据」的防线测试。

练习 3 · 挑战(选做,60+ min):规范链路落地 + 迁移方案文档

任务描述:① 搭好 §17.5 全链路,用一次「故意不合规的提交」验证拦截:在暂存区放一个含 any 的文件,确认 pre-commit 失败且给出 lint 输出;② 抽出 @qing/money 内部包(§17.8),主应用改为 workspace 依赖,单测随包迁移;③ 参照 §17.9 输出你所在项目的《渐进式 TS 迁移方案》六步表(含每步负责人与预估工时)。

提示:husky 生效依赖 pnpm install 触发 prepare 脚本;队友克隆后提交不生效,八成是没重装依赖。

验收标准:

  • [ ] 不合规提交被 pre-commit 阻断,修复后可提交;
  • [ ] pnpm test 在 workspace 下全绿(money 包用例仍执行);
  • [ ] 迁移方案含六步、完成标志与回滚策略(某步失败怎么办)。

⚠️ 常见易错点

易错点 1:as / any 滥用——用谎言喂饱编译器

// ❌ 错误:网络数据直接断言成 Order。字段缺失/多出/类型不对,
// TS 完全不再检查,运行时在几百行外才炸
export async function fetchOrders(): Promise<Order[]> {
  const res = await http.get('/orders')
  return res.data.data.list as Order[]   // 断言 ≠ 校验
}
// ✅ 正确:unknown 起步 + 类型守卫收窄(§17.4)。让「数据合法」成为被验证的事实
const list: unknown = res.data.data.list
if (Array.isArray(list) && list.every(isOrder)) {
  return list          // 此处 list 已被收窄为 Order[]
}
throw new Error('订单列表数据结构异常')

易错点 2:把密钥放进 VITE_ 环境变量——直接打进客户端产物

# ❌ 错误:以为 .env 是「服务端配置」。
# VITE_ 前缀变量会被内联进打包产物,打开构建后的 js 就能搜到
VITE_DB_PASSWORD=super-secret-123
VITE_PAY_SECRET_KEY=sk-xxxx
# ✅ 正确:前端环境变量只放「公开配置」(接口地址、开关、公开 DSN);
# 需要保密的凭据只存在于服务端,前端经自己的后端接口间接使用(第 18 章经 CI secrets 注入)
VITE_API_BASE_URL=https://api.qingorder.example.com/api/v1

易错点 3:import.meta.env 没有类型声明——拼错变量名毫无感知

// ❌ 错误:未声明 ImportMetaEnv 时,env 是宽泛索引类型,
// VITE_API_BASEE_URL(多打一个 E)不报错,运行时拿到 undefined
const base = import.meta.env.VITE_API_BASEE_URL
// ✅ 正确:在 vite-env.d.ts 显式声明每个变量(§17.6),拼错编译期标红
interface ImportMetaEnv {
  readonly VITE_API_BASE_URL: string
  readonly VITE_USE_MOCK: string
}

易错点 4:husky 链路「看起来配了,实际没生效」

# ❌ 症状一:装了 husky 但提交完全不被拦截 —— 忘了 prepare 脚本,
# .husky/_ 目录没生成,git hooksPath 根本没指向 .husky
pnpm add -D husky lint-staged
# ……然后就没了

# ❌ 症状二:lint-staged 配置写在 .lintstagedrc.json 里却写了嵌套错误的全局,
# 提交时静默跳过所有文件
# ✅ 正确:三步缺一不可,且当场验证
pnpm pkg set scripts.prepare="husky"     # ① prepare 脚本:pnpm install 时激活 hooks
pnpm exec husky                          # ② 立即初始化(生成 .husky/_ 与 hooksPath)
pnpm exec lint-staged --diff="HEAD~1"    # ③ 手动跑一次,确认能命中文件
# 验证:暂存一个含 console.log 的文件提交,应被拦截

易错点 5:开发代理的 target 与 rewrite 配错

// ❌ 错误一:target 少协议 → 代理启动即失败
'/api/v1': { target: 'localhost:9000' }

// ❌ 错误二:mock 服务本身带 /api/v1 前缀,却配了 rewrite 删前缀 → 404
'/api/v1': { target: 'http://localhost:9000', rewrite: (p) => p.replace(/^\/api\/v1/, '') }
// ✅ 正确:target 带协议;rewrite 只在「目标没有该前缀」时才加,
// 判断方法:直接 curl 目标服务的真实路径看通不通
'/api/v1': {
  target: 'http://localhost:9000',
  changeOrigin: true,
  // rewrite: (path) => path.replace(/^\/api\/v1/, ''),  // 按需启用
}

✅ 最佳实践

  1. strict 第一天就开:类型债比代码债更难还;存量项目按 §17.9 六步走,每步 CI 绿灯后再下一步。
  2. 类型是单一事实源:附录A §5 的实体定义只存在于 types/,前后端口径变更只改一处。
  3. 网络数据零信任:一切来自接口的数据以 unknown 起步,守卫收窄后才可使用——这是运行时安全的最后防线。
  4. ESLint 管质量、Prettier 管格式:两者用 skip-formatting 类配置划清边界,禁止同一个问题两套规则打架。
  5. 提交门禁只查暂存区:lint-staged 保证秒级反馈;全量 lint/typecheck 交给 CI,不要拖慢每一次提交。
  6. 环境变量收敛读取:业务代码不直接摸 import.meta.env,统一走 src/config/env.ts,并给每个变量补类型声明。
  7. Vite 代理解决开发跨域:开发期前端永远请求同源 /api/v1,由代理转发——不要在前端写死后端域名。
  8. 内部包先行:把纯逻辑抽成 @qing/* workspace 包(测试随包走),为第四部分的 monorepo 架构铺路。

📌 本章小结

  1. TS 的价值在纪律:strict 全家桶 + 禁止 as 压错 + vue-tsc 进 CI,三者构成类型防线;
  2. 组件类型三件套(defineProps/defineEmits/defineModel)与泛型组件让「类型跟着数据走」,收益在使用处兑现;
  3. 对网络数据「unknown 起步、守卫收窄」,类型校验同时是运行时校验;
  4. 规范链路四角色:ESLint(质量)、Prettier(格式)、lint-staged(增量)、husky(时机),配置完必须当场验证生效;
  5. Vite 三板斧:插件钩子(注入版本)、代理(开发跨域)、分环境变量(VITE_ 只放公开配置);workspace 包为架构演进打底。

代码已经「又快又稳又规范」,但还没有回答两个问题:怎么可重复地发布?线上坏了谁知道? 下一章搭建 lint → test → build → deploy 的完整流水线,并接入 Sentry 监控与告警 SOP,给「轻订单」装上最后一层保障。

📄 第 18 章 · CI/CD 部署与前端监控 18-CICD部署与前端监控.md

📍 阶段:高级阶段(第 13–18 章)|⏱ 预计学时:7–9 小时|🔗 前置:第 14 章、第 16 章、第 17 章

⬅ 上一章 | 📖 本版目录 | ➡ 下一章

💡 对照学习:本页是 Vue 版第 18 章。React 版对应章节为 第 18 章 · CICD部署与前端监控。CI/CD、Docker/Nginx、灰度回滚、web-vitals 埋点两版完全一致;监控 SDK 的差异仅在初始化:@sentry/vue 挂 app 实例并自动接住 Vue 的 errorHandler,@sentry/react 用 <ErrorBoundary> 组件包裹——原理同为「框架错误钩子 + 全局兜底」。

🎯 学习目标

学完本章,你将能够:

  1. 设计并实现「lint → typecheck → test → build → deploy」的完整 GitHub Actions 流水线,说清每个 job 的职责与依赖关系;
  2. 区分「构建期注入」与「运行期注入」两种多环境配置方式,并按变量性质正确选型;
  3. 编写多阶段 Dockerfile 与 Nginx 配置,实现静态部署、SPA 回退与正确的缓存/压缩策略;
  4. 设计「版本目录 + 软链切换」的发版结构,让回滚变成一条命令,并实现按 cookie 的灰度放量;
  5. 接入 Sentry:自动采集 JS 错误与路由性能、用 release 关联版本、经 sourcemap 还原真实堆栈;
  6. 实现性能埋点(web-vitals 上报)与白屏检测,覆盖「页面死了但没人报错」的盲区;
  7. 制定分级的告警响应 SOP(P0–P2),把「报警了」变成「谁、多久内、做什么」。

📖 核心知识点

18.1 流水线总览:让机器替人守门

上线前的人工检查清单是不可靠的,全部固化进流水线:

PR / push
   │
   ▼
┌─────────────── quality job ────────────────┐
│ lint(规范)→ typecheck(类型)→            │
│ test:coverage(单测 ≥80%)→ e2e 冒烟集      │
└──────────────────┬─────────────────────────┘
                   ▼ 全绿
┌─────────────── build job ──────────────────┐
│ pnpm build(注入环境变量 / 上传 sourcemap)  │
│ 产物归档 artifact                           │
└──────────────────┬─────────────────────────┘
                   ▼ 仅 push main / 打 tag
┌─────────────── deploy job ─────────────────┐
│ 部署到 test / pre / prod(含保护与审批)     │
└────────────────────────────────────────────┘

三个设计原则:快慢分离(PR 只跑 quality,不构建部署);门禁不可跳过(不设「紧急合并」后门,紧急走热修分支同样全绿);部署可追溯(部署产物与 git 提交一一对应,回滚即回退到历史提交的产物)。

18.2 GitHub Actions:完整流水线

# .github/workflows/ci-cd.yml
name: ci-cd
on:
  pull_request:
  push:
    branches: [main]
    tags: ['v*']

# 同一分支的新提交会取消仍在跑的旧流水线,避免资源浪费与部署乱序
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint
      - run: pnpm typecheck          # vue-tsc --noEmit(第 17 章)
      - run: pnpm test:coverage      # 覆盖率阈值 80%(第 15 章)
      - run: pnpm exec playwright install --with-deps chromium
      - run: pnpm exec playwright test
        env:
          E2E_OPERATOR_PASSWORD: ${{ secrets.E2E_OPERATOR_PASSWORD }}
          E2E_FINANCE_PASSWORD: ${{ secrets.E2E_FINANCE_PASSWORD }}
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

  build:
    needs: quality                    # 质量门禁全绿才构建
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm
      - run: pnpm install --frozen-lockfile
      - run: pnpm build
        env:
          # 公开配置可用 vars(组织/仓库级变量);带敏感性的凭据一律 secrets
          VITE_API_BASE_URL: ${{ vars.VITE_API_BASE_URL }}
          VITE_SENTRY_DSN: ${{ vars.VITE_SENTRY_DSN }}
          # 仅用于构建期上传 sourcemap 到 Sentry,不会进入前端产物(§18.6)
          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
      - uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/

  deploy:
    needs: build                      # 没有这一行,deploy 会在 build 之前/并行执行
    if: startsWith(github.ref, 'refs/tags/v')
    runs-on: ubuntu-latest
    environment: production           # GitHub Environment:可要求人工审批、限定 secrets 可见性
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist
      - name: 上传产物(占位示意)
        run: |
          # 真实实现二选一:
          # ① rsync 到服务器版本目录(凭据经 secrets.DEPLOY_SSH_KEY 注入,先起 ssh-agent)
          # ② 对象存储 CLI 上传 + CDN 刷新
          echo "deploy dist/ -> releases/${GITHUB_SHA:0:12} (placeholder)"

18.3 多环境与配置注入:构建期 vs 运行期

注入方式 机制 适用 代价
构建期 VITE_* 在 pnpm build 时被内联进产物 变化极少、非敏感的公开配置 换环境要重新构建
运行期 部署脚本把配置写入 window.__APP_CONFIG__ 一份产物多环境部署、需运维可改 多一次注入步骤

「轻订单」的选型:VITE_API_BASE_URL 变化极少走构建期;灰度/私有化部署需要「同一份产物连不同后端」,关键变量走运行期:

<!-- index.html:占位在构建产物中,部署时由脚本/运维替换 -->
<script>
  window.__APP_CONFIG__ = { API_BASE_URL: '<injected-by-deploy>' }
</script>
// src/config/runtime.ts
interface AppConfig {
  API_BASE_URL: string
}

const FALLBACK: AppConfig = { API_BASE_URL: '/api/v1' }

export const runtimeConfig: AppConfig = {
  ...FALLBACK,
  ...(window as { __APP_CONFIG__?: Partial<AppConfig> }).__APP_CONFIG__,
}

18.4 Docker + Nginx:一次构建,处处运行

# Dockerfile:多阶段构建——构建链不进最终镜像(体积从 ~1GB 降到 ~50MB)
FROM node:20-alpine AS build
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build

FROM nginx:1.27-alpine
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
# deploy/nginx.conf:SPA 回退 + 缓存 + 压缩(第 14 章 §14.6 的部署落地版)
server {
  listen 80;
  root /usr/share/nginx/html;
  index index.html;

  # ① SPA 回退:前端路由由 JS 接管,未命中文件一律回 index.html
  location / {
    try_files $uri $uri/ /index.html;
  }

  # ② 指纹资源:一年强缓存
  location ~* \.(js|css|woff2?|png|jpe?g|webp|avif|svg)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
  }

  # ③ 入口 HTML:协商缓存,发版即生效
  location = /index.html {
    add_header Cache-Control "no-cache";
  }

  gzip on;
  gzip_comp_level 5;
  gzip_types text/css application/javascript application/json image/svg+xml;
}

18.5 灰度与回滚:给上线留后路

版本目录 + 软链是回滚的物理基础——每个版本一个目录,current 指向当前生效版本:

/srv/qingorder/
├── releases/
│   ├── 20260929-abc1234/     # 新版本(正在灰度)
│   └── 20260928-def5678/     # 上一版本(保留 ≥ 2 个,防旧 chunk 404)
└── current -> releases/20260929-abc1234
# 发版:解包新目录 → 健康检查 → 原子切换软链
ln -sfn /srv/qingorder/releases/20260929-abc1234 /srv/qingorder/current

# 回滚 = 把软链切回上一个版本,秒级完成、无需重新构建
ln -sfn /srv/qingorder/releases/20260928-def5678 /srv/qingorder/current

灰度放量按风险递进:先内部员工(cookie 标记)→ 小流量 → 全量。Nginx 按带标记的 cookie 分流(map 用法细节以 Nginx 文档为准):

# 灰度分流:带 qo_canary=on 的访问者看到灰度版(可由内部员工开关控制)
map $cookie_qo_canary $doc_root {
  default  /srv/qingorder/current;
  "on"     /srv/qingorder/releases/20260929-abc1234;
}
server {
  root $doc_root;
  # ……其余同 §18.4
}

灰度期盯两个指标(监控平台,§18.6):新版本的 JS 错误率、LCP 分布与稳定版对比;劣化即回滚软链。

18.6 Sentry:错误监控与 sourcemap 还原

pnpm add @sentry/vue
pnpm add -D @sentry/vite-plugin
// src/main.ts(节选)
import * as Sentry from '@sentry/vue'
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'

const app = createApp(App)

const releaseVersion =
  document.querySelector('meta[name="app-version"]')?.getAttribute('content') ?? 'dev'

Sentry.init({
  app,                                  // 传入 app:自动接住 Vue 的 errorHandler,组件内报错也带组件栈
  dsn: import.meta.env.VITE_SENTRY_DSN, // DSN 是项目公开标识,可以放前端
  release: releaseVersion,              // 与构建注入的版本号(第 17 章 §17.6 插件)对齐
  environment: import.meta.env.MODE,
  tracesSampleRate: 0.1,                // 性能追踪采样 10%,错误采集不受此影响
  integrations: [Sentry.browserTracingIntegration({ router })], // 路由级性能追踪
  beforeSend(event) {
    // 隐私脱敏:请求体可能含密码/客户资料(附录A 口径),不上传
    if (event.request?.data) delete event.request.data
    return event
  },
})

app.use(router).mount('#app')

压缩后的报错堆栈是天书,靠 sourcemap 还原。关键在三点:构建时生成但不暴露(hidden)、上传到 Sentry、上传后从产物中删除:

// vite.config.ts(节选)
import { sentryVitePlugin } from '@sentry/vite-plugin'
import pkg from './package.json'

export default defineConfig(({ mode }) => ({
  build: {
    // hidden:产物不引用 .map(浏览器 DevTools 拿不到源码),但 .map 照常生成供上传
    sourcemap: mode === 'production' ? 'hidden' : true,
  },
  plugins: [
    vue(),
    mode === 'production' &&
      sentryVitePlugin({
        org: 'qingyun',
        project: 'qing-order',
        // CI secrets 注入,绝不出现在仓库与产物中
        authToken: process.env.SENTRY_AUTH_TOKEN,
        release: { name: pkg.version },
        sourcemaps: {
          assets: ['./dist/**'],
          // 上传完成后删除 .map 文件——「不发布」之外的第二道保险(见易错点 1)
          filesToDeleteAfterUpload: ['./dist/**/*.map'],
        },
      }),
  ].filter(Boolean),
}))

还原后的 issue 会显示真实文件名、行号与源码上下文;结合 release 与 environment,可以回答「这个错误只出现在哪个版本」——这就是回滚决策的依据。

18.7 性能埋点与白屏检测:补上「没人报错」的盲区

用户不会提交 bug 工单,只会默默关掉页面。两类被动数据必须主动采集:

// src/performance/rum.ts:把第 14 章的 web-vitals 上报到 Sentry(随错误平台统一查看)
import * as Sentry from '@sentry/vue'
import { onCLS, onINP, onLCP, type Metric } from 'web-vitals'

function report(metric: Metric): void {
  Sentry.setMeasurement(metric.name, metric.value, metric.name === 'CLS' ? 'none' : 'millisecond')
  // 恶劣值额外记一条事件,方便在告警面板聚合(附录A:LCP ≤ 2.0s)
  if (metric.rating === 'poor') {
    Sentry.captureMessage(`性能劣化:${metric.name} = ${Math.round(metric.value)}`, {
      level: 'warning',
      tags: { metric: metric.name },
    })
  }
}

export function initRum(): void {
  onLCP(report)
  onCLS(report)
  onINP(report)
}
// src/performance/blankScreen.ts:白屏检测
import * as Sentry from '@sentry/vue'

/**
 * 白屏 = 页面「活着」但没有内容,用户什么也点不了,却可能不触发任何 JS 错误。
 * 常见成因:入口脚本 404(旧 HTML 引用已删除的 chunk)、渲染中断、CSS 遮挡。
 */
export function watchBlankScreen(): void {
  // ① 资源加载失败(chunk 404 是白屏第一嫌疑):error 事件不冒泡,必须用捕获阶段
  window.addEventListener(
    'error',
    (e) => {
      const target = e.target as HTMLScriptElement | HTMLLinkElement | null
      if (target && 'src' in target && target.src) {
        Sentry.captureMessage(`资源加载失败:${target.src}`, { level: 'fatal' })
      }
    },
    true,
  )

  // ② 兜底:挂载后 3s 根节点仍无子节点,判定疑似白屏
  setTimeout(() => {
    const root = document.querySelector('#app')
    if (root && root.childElementCount === 0) {
      Sentry.captureMessage('疑似白屏:#app 挂载后无子节点', {
        level: 'fatal',
        extra: { url: location.href, userAgent: navigator.userAgent },
      })
    }
  }, 3000)
}

18.8 告警 SOP:报警之后谁做什么

告警没有响应约定 = 没有告警。「轻订单」团队的分级 SOP:

级别 判定标准(示例) 响应要求 首选动作
P0 白屏 / 下单主流程不可用 / JS 错误率 > 5% 15 分钟内认领 先止血:回滚软链(§18.5),再查根因
P1 接口错误率 > 1% / 核心 LCP 劣化 > 30% 1 小时内定位 查 release 对比新版本,考虑灰度暂停
P2 零星 JS 错误 / 非核心页面异常 当日跟进 归档 issue,排进迭代

流程闭环:告警 → 值班认领 → 分级 → 止血 → 根因修复 → 复盘(复盘写清时间线、影响面、根因、防复发动作)。两条铁律:止血永远优先于根因(用户等不了);每次 P0/P1 必须有复盘,否则同类问题必然复发。

🛠 渐进式实战练习

练习 1 · 基础(20 min):quality 门禁跑通

任务描述:① 在「轻订单」仓库落地 §18.2 的 quality job(lint/typecheck/test/冒烟 E2E 可按已完成章节裁剪);② 故意提交一处 lint 错误与一处类型错误,验证流水线分别红在哪一步;③ 配置 pnpm 缓存并对比首跑与二次跑的耗时。

提示:本地先用 act 类工具不便时,直接推到 fork 仓库看 Actions 页;每步日志里都有失败原因。

验收标准:

  • [ ] 全绿一次、红两次的记录(截图);
  • [ ] 二次运行因缓存命中安装时间明显缩短;
  • [ ] deploy/build 未在 PR 事件中执行。

练习 2 · 进阶(45 min):Docker + Nginx 部署验证

任务描述:① 编写 §18.4 的 Dockerfile 与 nginx.conf,本地 docker build -t qing-order . && docker run -p 8080:80;② 验证三件事:直接访问 /orders/list(SPA 回退)、index.html 响应头为 no-cache、js 资源为 immutable;③ 用 docker run --rm 演示「换端口再起一个」的幂等部署。

提示:响应头在 DevTools Network 或 curl -I http://localhost:8080/ 查看;.dockerignore 里加上 node_modules、dist。

验收标准:

  • [ ] 容器内刷新 /orders/list 不 404;
  • [ ] 三类缓存头符合 §18.4 配置;
  • [ ] 镜像体积 < 100MB(多阶段构建生效)。
参考答案(验证命令)
# ① SPA 回退:应返回 200 与 index.html 内容(而非 404)
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/orders/list

# ② 入口 HTML 协商缓存
curl -sI http://localhost:8080/ | grep -i cache-control        # → no-cache

# ③ 指纹资源强缓存(文件名从 dist/assets 里任取)
curl -sI http://localhost:8080/assets/index-xxxx.js | grep -i cache-control  # → immutable

# 镜像体积
docker images qing-order

练习 3 · 挑战(选做,60+ min):Sentry 全链路 + 灰度回滚演练

任务描述:① 接入 §18.6 的 Sentry(Sentry 提供免费开发配额,或用自托管 GlitchTip 替代),制造一个 undefined is not a function 验证 sourcemap 还原出真实文件与行号;② 接入白屏检测,把 index.html 里的脚本路径改错复现「资源加载失败」上报;③ 搭 §18.5 的版本目录结构,完成「发版 → 切灰度 → 制造 P0(删掉 canary 目录里的 index.html)→ 30 秒内回滚」的完整演练并记录时间线。

提示:sourcemap 还原需要在 Sentry 后台确认 release 与上传的 artifact 匹配;演练用本机目录模拟服务器即可。

验收标准:

  • [ ] issue 堆栈显示 OrderCreateView.vue:行号 而非压缩代码;
  • [ ] 白屏告警带 URL 与 UA;
  • [ ] 演练记录含「发现 → 回滚 → 恢复」各步耗时,回滚 ≤ 1 分钟。
参考答案(回滚演练时间线模板)
## 灰度回滚演练记录(2026-09-29)
- 14:00 发版 v1.4.0 → releases/20260929-abc1234,软链 current 切换
- 14:02 灰度:为本机写入 cookie qo_canary=on,确认访问 canary 版本
- 14:05 注入故障:删除 canary 目录 index.html → 白屏检测 3s 后上报(level: fatal)
- 14:06 告警认领,判定 P0(主流程不可用)
- 14:06 止血:ln -sfn 切回 releases/20260928-def5678,14:06:40 恢复(回滚耗时 < 1 分钟)
- 结论:监控发现 ≈ 3s、人工响应 ≈ 40s、恢复 ≈ 20s;SOP 时限(15 分钟)内完成

⚠️ 常见易错点

易错点 1:sourcemap 泄漏到生产——把源码白送给所有人

// ❌ 错误:为了排障开着普通 sourcemap 上线。
// 用户在 DevTools 里能看到全部源码;接口注释、内部逻辑一览无余
export default defineConfig({
  build: { sourcemap: true },  // 生产环境照常生成 .map 并随产物发布
})
// ✅ 正确:生产用 'hidden'——.map 生成但不被产物引用,
// 仅供上传 Sentry 还原;上传后删除文件(§18.6 filesToDeleteAfterUpload)
build: { sourcemap: mode === 'production' ? 'hidden' : true }
// 部署校验:find dist -name "*.map" 应该为空

易错点 2:密钥进 YAML / 进 VITE_——两类典型泄漏

# ❌ 错误:把凭据明文写进 workflow。仓库一旦共享/公开即泄漏,且 git 历史擦不干净
deploy:
  run: rsync -az dist/ deploy@server:/srv/qingorder/
  env:
    SSH_PASS: MyP@ssw0rd2026     # 明文凭据
# ✅ 正确:凭据一律走 secrets.*(由仓库管理员在 Settings → Secrets 配置),
# 日志中 GitHub 会自动打码引用值;SSH 推荐改用密钥对而非密码
deploy:
  run: rsync -az dist/ deploy@server:/srv/qingorder/
  env:
    SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}   # 占位引用,值永不明文

同时注意第 17 章红线:VITE_ 前缀变量会打进前端产物,任何服务端凭据(数据库密码、支付密钥、CI 上传 token)都不得使用该前缀。

易错点 3:发版删旧 chunk——上线五分钟后「白屏投诉潮」

# ❌ 错误:部署即清空目录。已在用户浏览器里打开的旧页面,
# 之后按需加载路由 chunk 时请求到已删除的文件 → 动态加载失败/白屏
rm -rf /srv/qingorder/current && cp -r dist /srv/qingorder/current
# ✅ 正确:版本目录并存(保留 ≥ 2 个版本),软链原子切换;
# 旧页面引用的旧 chunk 在保留期内依然可访问
ln -sfn /srv/qingorder/releases/20260929-abc1234 /srv/qingorder/current
# 清理策略:只删除「下线超过 7 天」的更旧版本

易错点 4:部署任务缺 needs / 无并发控制——乱序上线

# ❌ 错误一:deploy 没写 needs: build → 与测试并行,测试还没跑完就部署了
jobs:
  quality: { … }
  deploy: { … }        # 两个 job 并行,门禁形同虚设

# ❌ 错误二:两次推送的部署任务并发执行,rsync 互相覆盖 → 线上版本混乱
# ✅ 正确:显式依赖 + concurrency 组(同 ref 新流水线取消旧的)
jobs:
  build: { needs: quality, … }
  deploy: { needs: build, if: startsWith(github.ref, 'refs/tags/v'), … }
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

易错点 5:beforeSend 不脱敏——监控平台变成隐私事故现场

// ❌ 错误:原样上报一切。请求体里的密码、客户资料(附录A 的客户/订单数据)
// 会随错误上下文进入监控平台,任何能看 issue 的人都能读到
Sentry.init({ app, dsn: env.VITE_SENTRY_DSN })
// ✅ 正确:beforeSend 里删掉请求体/本地存储等敏感上下文,只留排障必需信息
Sentry.init({
  app,
  dsn: env.VITE_SENTRY_DSN,
  beforeSend(event) {
    if (event.request?.data) delete event.request.data
    return event
  },
})

✅ 最佳实践

  1. 流水线即规范:lint/typecheck/单测/E2E 全部机器执行,人工 review 聚焦设计而非格式——门禁没有豁免通道。
  2. 一份产物 + 运行期配置应对多环境:构建产物只在 main/tag 上产出一次,环境差异用 __APP_CONFIG__ 与部署时注入消化。
  3. Docker 多阶段 + Nginx 三件套:SPA 回退、指纹强缓存、HTML no-cache 必须同时配齐,缺一个都是线上事故(第 14 章易错点 4 的部署版)。
  4. 版本目录 + 软链:让「回滚」成为一条命令、一次演练过的动作,而不是紧急情况下的临场发挥。
  5. sourcemap 三道锁:hidden 模式生成 → 上传 Sentry 还原 → 上传后删除文件;find dist -name "*.map" 纳入部署检查。
  6. 监控覆盖两类信号:错误(Sentry + 资源加载失败)与性能(web-vitals + 白屏检测),任何一项缺失都会让事故「静默」很久。
  7. 告警必须分级且演练过:P0 止血优先、P1 限时定位、P2 归档;回滚演练每个季度跑一次。
  8. release 贯穿全线:构建注入版本 → Sentry release 关联 → 部署目录同名,让「哪个版本坏了」永远是可回答的问题。

📌 本章小结

  1. 流水线五步(lint → typecheck → test → build → deploy)各司其职,靠 needs 串成门禁、靠 concurrency 防乱序、靠 secrets.* 管凭据;
  2. 多环境选型:稳定公开配置走构建期 VITE_*,多环境一份产物走运行期 window.__APP_CONFIG__;
  3. 部署形态定型:Docker 多阶段构建 + Nginx(SPA 回退/缓存/压缩)+ 版本目录软链(秒级回滚 + cookie 灰度);
  4. 监控双引擎:Sentry(错误 + sourcemap 还原 + release 追溯)与自建埋点(web-vitals 上报 + 白屏检测),告警按 P0–P2 分级响应;
  5. 至此「轻订单」完成高级阶段治理:渲染达标、加载达标、单测 ≥ 80%、冒烟集全绿、流水线可重复、线上可观测。

下一站进入第四部分 · 资深阶段:从「把系统做好」升级到「把系统设计对」——第 19 章从架构设计方法论开始,用模块化拆分、依赖规则与 ADR 决策记录,把「轻订单」推向 v2。

📄 第 19 章 · 大型项目架构设计 19-大型项目架构设计.md

📍 阶段:资深阶段(第 19–24 章)|⏱ 预计学时:8–10 小时|🔗 前置:第 12 章、第 17 章、第 18 章

⬅ 上一章 | 📖 本版目录 | ➡ 下一章

💡 对照学习:本页是 Vue 版第 19 章。React 版对应章节为 第 19 章 · 大型项目架构设计。本章方法两版通用,示例代码为 Vue 生态:复杂度评估、分层与依赖规则、ADR、Monorepo 策略在 React 项目中完全成立,差异仅在路由(Vue Router 4 vs React Router 7)与状态(Pinia vs Redux Toolkit)的具体写法。业务基准:附录A「轻订单」 §6 资深阶段里程碑。

🎯 学习目标

学完本章,你将能够:

  1. 用「复杂度四因子」评估表对一个真实项目打分,并给出「保持简单目录 / 模块化单体 / Monorepo 多包」的结构决策与理由;
  2. 按 app / views / features / widgets / entities / shared 六层组织大型 Vue 项目,说清每层的职责边界与禁止事项;
  3. 制定分层依赖规则,用 ESLint 把规则变成 CI 门禁,而不是停留在口头约定;
  4. 为路由、状态、请求三大基础设施制定「放哪里、怎么命名、谁依赖谁」的组织约定;
  5. 编写应用初始化编排器,把会话恢复、权限加载、字典预热等启动步骤变成顺序可控的显式流程;
  6. 用插件机制收敛横切关注点(监控、鉴权、埋点),让 main.ts 缩减到 20 行以内;
  7. 用 pnpm workspace 把轻订单演进为多包仓库,并说出「什么才有资格被拆成包」;
  8. 写出格式规范、论据完整、可被后来者推翻的 ADR(架构决策记录)。

📖 核心知识点

19.1 什么时候需要谈「架构」:业务复杂度评估

架构设计的第一课不是「怎么分层」,而是「要不要分层」。分层、多包、微前端都会引入间接层成本(跳转次数变多、约定变多、新人上手变慢),只有业务复杂度配得上时,这份成本才划算。

用四个因子给项目打分(每因子 1–5 分):

因子 关键问题 1 分 3 分 5 分
模块数量 业务域有几个独立模块 ≤ 3 4–8 > 8
团队规模 并行开发的人数 ≤ 2 3–6 ≥ 7
变更频率 单迭代波及的模块数 1 个 2–3 个 频繁跨模块
系统寿命 预计维护周期 < 1 年 1–3 年 > 3 年

按总分做结构决策:

总分 结构形态 说明
≤ 8 简单目录(第 06–12 章的组织方式) 按 components/ composables/ api/ 组织即可,别表演架构
9–14 模块化单体(本章主角) 单应用 + 六层分层 + 依赖规则门禁
15–20 模块化单体 + Monorepo 预备 开始抽共享包,为多应用/多团队做准备
触发附加条件(≥ 3 个团队、技术栈异构、独立发版) Monorepo 或微前端(第 21 章) 由业务形态而非技术偏好决定

对轻订单管理后台打分:模块 6 个(订单/客户/商品/报表/工作台/系统设置)= 3 分,团队 4 人 = 3 分,迭代常波及订单+报表 = 4 分,寿命 ≥ 3 年 = 5 分,总分 15 → 结论:先做模块化单体分层,同时把组件库与 API 客户端抽为共享包(为第 20、21 章铺路)。

19.2 六层参考模型与依赖规则

分层参考 Feature-Sliced Design 的思想(不必教条照搬),结合 Vue 生态习惯调整命名:

src/
├── app/                  # 应用装配层:初始化编排、全局 provider、路由实例
│   ├── bootstrap.ts
│   ├── router/
│   ├── plugins/
│   └── model/            # 会话、主题、全局 UI 状态
├── views/                # 路由页面:薄壳,只做组装
│   ├── order-list/
│   └── order-detail/
├── widgets/              # 页内大型组合块(筛选区+表格+分页整套)
│   └── order-table-panel/
├── features/             # 用户价值动作:一个动作一个目录
│   ├── order-create/
│   ├── order-approve/
│   └── order-export/
├── entities/             # 业务实体:类型、实体级 API、字典 store
│   ├── order/
│   ├── customer/
│   └── product/
└── shared/               # 框架无关的基建:工具、请求客户端、原子 UI
    ├── api/
    ├── lib/
    └── ui/

每层的职责与红线:

层 职责 允许依赖 禁止事项
app 初始化编排、全局配置、装配 provider 所有层 被任何层引用
views 路由入口,组装 feature/widget,做页面级权限 features, widgets, entities, shared 写业务规则(那是 features 的事)
widgets 可独立成立的大块 UI 组合 features, entities, shared widget 之间互相 import
features 一个完整的用户动作(含流程状态与 API) entities, shared feature 之间互相 import
entities 实体类型、实体 CRUD API、实体级缓存 store shared 出现「流程」逻辑(如审批)
shared 通用工具、请求客户端、原子组件 仅外部依赖 import 任何业务代码

两条全局纪律,比层本身更重要:

  1. 单向依赖:只能上层引下层,严禁反向(shared 出现 import { useOrderStore } 即架构事故);
  2. 公共 API 入口:每个模块只暴露一个 index.ts,外部只允许 @/features/order-create,禁止深挖 @/features/order-create/api/internal.ts——这是后续重构不被外部耦合的唯一保险。

依赖关系全景:

        app(装配一切)
          │
        views(页面薄壳)
          │
        widgets(页面组成块)
          │
        features(用户动作)
          │
        entities(业务实体)
          │
        shared(基建,不认业务)

19.3 路由层组织:模块自声明 + 顶层聚合

集中式巨型路由表的问题:所有人改同一个文件、权限字段随手写错、懒加载 chunk 命名失控。约定改为「每个 feature 自带路由片段,app 层聚合」:

// src/features/order/routes.ts —— 订单模块自己声明自己的路由
import type { RouteRecordRaw } from 'vue-router'

export const orderRoutes: RouteRecordRaw[] = [
  {
    path: 'orders',
    name: 'order-list',
    component: () => import('./views/OrderListPage.vue'),
    meta: { title: '订单管理', permission: 'order:list', keepAlive: true },
  },
  {
    path: 'orders/:id',
    name: 'order-detail',
    component: () => import('./views/OrderDetailPage.vue'),
    meta: { title: '订单详情', permission: 'order:detail' },
  },
]
// src/app/router/routes.ts —— app 层只做聚合,不写具体路由
import type { RouteRecordRaw } from 'vue-router'
import { orderRoutes } from '@/features/order/routes'
import { customerRoutes } from '@/features/customer/routes'
import { productRoutes } from '@/features/product/routes'
import { reportRoutes } from '@/features/report/routes'

/** 业务路由片段聚合,顺序即面包屑注册顺序 */
export const featureRoutes: RouteRecordRaw[] = [
  ...orderRoutes,
  ...customerRoutes,
  ...productRoutes,
  ...reportRoutes,
]

三条路由约定(写进团队规范,配合第 22 章 Review 清单执行):

  1. meta 字段收敛为类型定义,禁止裸写字符串键:
// src/app/router/types.ts
import 'vue-router'

declare module 'vue-router' {
  interface RouteMeta {
    /** 页面标题,同时用于 document.title 与面包屑 */
    title: string
    /** 访问该页所需权限点,缺省视为仅需登录 */
    permission?: string
    /** 是否纳入 keep-alive 白名单(第 13 章) */
    keepAlive?: boolean
  }
}

export {}
  1. 懒加载是默认项,同步 import 仅用于登录页(首屏依赖,见第 14 章);
  2. 路由命名 域-页面(order-list),name 是跳转与权限映射的唯一键,禁止用 path 做跳转依据。

19.4 状态层组织:先分「谁的状态」,再决定「放哪」

第 08 章讲过「本地态 / 服务端态 / 全局 UI 态」三分法,架构层把它落成一张归属表:

状态种类 归属位置 轻订单示例
服务端态(列表、详情数据) 不进 Pinia,用请求 composable 管理(第 09 章 useOrderList) 订单列表分页数据
实体级缓存 entities/<x>/model store 商品下拉字典、客户搜索缓存
会话与全局 UI app/model store authStore(token、角色、权限点)、themeStore
流程态 features/<x>/model store 新建订单草稿、批量指派选中集
组件局部 组件内 ref,不出组件 弹窗开关、输入框值

关键决策:服务端态与客户端态分离。Pinia 存「客户端态」(选中、草稿、会话),服务端数据交给带缓存与失效策略的请求层。若引入 TanStack Query 的 Vue 适配,则服务端态全部由 Query 管理,Pinia 进一步瘦身。

// src/features/order-create/model/draft.ts —— 流程态示例
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import type { OrderItem } from '@/entities/order'
import { calcItemAmount, calcOrderTotal } from '@/entities/order'

export const useOrderDraftStore = defineStore('order-draft', () => {
  const customerId = ref<string | null>(null)
  const items = ref<OrderItem[]>([])
  const wholeDiscountPercent = ref(100)

  /** 总额单位:分(附录A 约定,避免浮点误差) */
  const totalAmount = computed(() =>
    calcOrderTotal(items.value, wholeDiscountPercent.value),
  )

  /** 整单折扣 > 10% 需要财务审批(附录A §3.3) */
  const needReview = computed(() => 100 - wholeDiscountPercent.value > 10)

  function reset() {
    customerId.value = null
    items.value = []
    wholeDiscountPercent.value = 100
  }

  return { customerId, items, wholeDiscountPercent, totalAmount, needReview, reset }
})

19.5 请求层组织:按「实体 + 流程」两轴切分 API

第 09 章的 axios 封装上移到 shared/api,业务 API 按两轴归位:

src/
├── shared/api/
│   ├── client.ts          # axios 实例、拦截器、错误归一化(第 09 章成果)
│   └── types.ts           # 统一响应体 ApiResponse<T>、分页 Page<T>
├── entities/order/api/
│   └── order.api.ts       # 实体轴:getOrderList / getOrderDetail / cancelOrder
├── entities/customer/api/
│   └── customer.api.ts
└── features/order-create/api/
    └── submit.api.ts      # 流程轴:submitOrderForReview(含审批语义)
// src/entities/order/api/order.api.ts
import { http } from '@/shared/api/client'
import type { Order, Page } from '@/shared/api/types'

export interface OrderListParams {
  keyword?: string
  status?: Order['status']
  startDate?: string
  endDate?: string
  page: number
  pageSize: number
}

export function getOrderList(params: OrderListParams) {
  return http.get<Page<Order>>('/orders', { params })
}

export function getOrderDetail(id: string) {
  return http.get<Order>(`/orders/${id}`)
}

约定:类型定义(Order、OrderStatus)放在 entities/order,与附录A §5 的实体口径一一对应;API 函数只发请求、不做业务判断(金额是否需审批这类规则属于 feature 层)。

19.6 初始化编排:把「启动」变成显式流程

反例是 main.ts 里东一处 initMonitor()、router/index.ts 里顺手拉字典、某个模块顶层发请求——启动顺序全靠模块 import 顺序,出了问题无从排查。资深做法是显式编排:

启动时序(串行,后一步依赖前一步的产出)
┌─────────┐   ┌─────────┐   ┌─────────┐   ┌─────────┐   ┌─────────┐   ┌────────┐
│ pinia    │ → │ router   │ → │ auth     │ → │permission│ → │ dict     │ → │ mount  │
│ 状态容器  │   │ 路由就绪  │   │ 恢复会话  │   │ 权限点就位 │   │ 字典预热  │   │ 渲染   │
└─────────┘   └─────────┘   └─────────┘   └─────────┘   └─────────┘   └────────┘
// src/app/plugins/types.ts
import type { App } from 'vue'
import type { Router } from 'vue-router'
import type { Pinia } from 'pinia'

export interface PluginContext {
  app: App<Element>
  pinia: Pinia
  router: Router
}

export interface AppPlugin {
  /** 插件名,用于编排日志与排错 */
  name: string
  /** 前置插件名列表,编排器据此校验顺序 */
  requires?: string[]
  setup: (ctx: PluginContext) => Promise<void> | void
}
// src/app/bootstrap.ts —— 唯一的启动入口
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { piniaPluginPersistedstate } from 'pinia-plugin-persistedstate'
import { createAppRouter } from './router'
import { authPlugin } from './plugins/auth'
import { permissionPlugin } from './plugins/permission'
import { dictPlugin } from './plugins/dict'
import { monitorPlugin } from './plugins/monitor'
import App from './App.vue'

const plugins = [authPlugin, permissionPlugin, dictPlugin, monitorPlugin]

export async function bootstrap(): Promise<void> {
  const app = createApp(App)

  const pinia = createPinia()
  pinia.use(piniaPluginPersistedstate)

  const router = createAppRouter()
  app.use(pinia).use(router)

  // 串行执行:auth 必须先于 permission(权限依赖会话),顺序即契约
  for (const plugin of plugins) {
    await plugin.setup({ app, pinia, router })
  }

  await router.isReady()
  app.mount('#app')
}
// src/main.ts —— 收敛后的入口,只剩编排
import { bootstrap } from './app/bootstrap'

void bootstrap().catch((err) => {
  // 启动失败:白屏兜底,接入第 18 章监控
  document.body.innerHTML = '<div id="boot-failed">应用加载失败,请刷新重试</div>'
  console.error('[bootstrap] failed', err)
})

编排带来的三个收益:启动步骤可测(每个插件可单测)、可观测(每步打点上报耗时)、可插拔(第 21 章接入微前端时,基座只是多一个 microFrontendPlugin)。

19.7 Monorepo 多包策略:pnpm workspace

当 qing-ui(第 20 章)、API 客户端、工具函数需要被门户与后台共享时,单体仓库开始失血:两份拷贝、双重维护。此时演进为 pnpm workspace:

# pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'packages/*'
qing-order-mono/
├── apps/
│   ├── qing-portal/          # 门户(第 06 章产物,第 24 章岛屿化)
│   ├── qing-admin/           # 管理后台(第 12 章产物,持续演进)
│   └── qing-marketing/       # 第 21 章接入的第三方营销子系统(占位)
├── packages/
│   ├── qing-ui/              # 设计系统组件库(第 20 章)
│   ├── qing-api-client/      # axios 实例 + 拦截器 + 统一类型
│   ├── qing-auth/            # 权限 SDK(第 24 章中台化)
│   ├── qing-utils/           # 金额计算、CSV 导出等纯函数
│   └── qing-tsconfig/        # 共享 tsconfig 预设(第 17 章)
├── pnpm-workspace.yaml
└── package.json
// apps/qing-admin/package.json(节选)
{
  "name": "qing-admin",
  "dependencies": {
    "qing-ui": "workspace:*",
    "qing-api-client": "workspace:*",
    "qing-utils": "workspace:*"
  }
}

什么才有资格拆成包(三条同时满足才拆,缺一条就留在应用内):

判断项 问题 反例
复用 有 ≥ 2 个应用真要用它吗? 只有 admin 用的「订单金额计算」→ 留在 entities
稳定 接口能保持 ≥ 一个季度不破坏吗? 随业务每周变动的 API 封装 → 别拆
边界 能用一句话说清它「是什么/不是什么」吗? qing-common 这种杂物包 → 禁止

19.8 ADR:让「为什么这么做」可追溯

架构决策最贵的成本不是做错,而是没人记得为什么这么做。ADR(Architecture Decision Record)用一页纸把「背景 → 备选 → 决策 → 后果」钉住。模板:

# ADR-<编号> · <一句话决策标题>

- **状态**:提议中 | 已采纳 | 已废弃(被 ADR-xxx 取代)
- **日期**:YYYY-MM-DD
- **决策人**:姓名/角色(至少两人)

## 背景
当前遇到了什么问题?有哪些约束与数据?(可量化就量化)

## 备选方案
| 方案 | 优点 | 缺点/风险 |

## 决策
选择哪个方案,一句话。

## 理由
为什么是它而不是别的?写出当时的权衡依据(可含错误假设,保留原貌)。

## 后果
正面影响、负面代价、后续行动项(谁、什么时候)。

样例一:ADR-0017 · 状态管理选型 Pinia(第 08 章决策的回溯补记)

# ADR-0017 · 状态管理选型:采用 Pinia

- **状态**:已采纳
- **日期**:2025-11-12(2026-09 按回忆补记,补记性质已在评审说明)
- **决策人**:前端组(周航、李清)

## 背景
轻订单后台需要全局状态:登录会话、字典缓存、订单暂存区(附录A §3.1–3.3)。
团队此前无 Vuex 经验,项目起于 Vue 3.5 + TS strict。

## 备选方案
| 方案 | 优点 | 缺点/风险 |
|------|------|----------|
| Pinia | setup 写法与组合式 API 同构;TS 推断零配置;Devtools 支持完善 | 生态历史较浅(Vue 官方推荐位已接管) |
| Vuex 4 | 文档与存量经验多 | TS 类型支持弱;mutation 心智负担;官方已建议新项目用 Pinia |
| 纯 composables | 零依赖 | 缺 Devtools 集成与插件生态;跨模块复用纪律靠自觉 |

## 决策
采用 Pinia(setup store 写法),插件 pinia-plugin-persistedstate 处理会话持久化。

## 理由
1. 组合式 API 团队零额外心智;
2. TS strict 下 store 类型自动推断,免去 Vuex 的手写泛型样板;
3. 服务端态另有请求层承担(见 ADR-0019),Pinia 只承担客户端态,体积与复杂度可控。

## 后果
- 正面:状态代码量较 Vuex 方案预估减少约 40%;
- 负面:与 Vuex 老项目对照时需双向翻译,onboarding 文档需专列一节;
- 行动项:第 22 章协作手册收录「store 编写规范」。

样例二:ADR-0021 · 万级订单列表引入虚拟滚动

# ADR-0021 · 万级订单列表引入虚拟滚动(vue-virtual-scroller)

- **状态**:已采纳
- **日期**:2026-06-30
- **决策人**:周航、李清、财务线负责人列席

## 背景
订单列表逼近 5 万行,Performance 面板实测长列表滚动掉帧 > 20%,
不满足附录A §4「掉帧 < 5%」目标(详见第 13 章治理复盘)。

## 备选方案
| 方案 | 优点 | 缺点/风险 |
|------|------|----------|
| 服务端分页 + 翻页器 | 零新依赖 | 销售要求「连续滚动浏览」体验,翻页被业务否决 |
| vue-virtual-scroller | API 简单;支持不定高(DynamicScroller);团队可维护 | 定高表格场景需自行对齐行高 |
| TanStack Virtual | 框架无关、更新快 | Vue 适配层文档较少,示例以 React 为主 |
| 自研简化版 | 完全可控 | 维护成本高,边界情况(不定高/横向)覆盖不足 |

## 决策
订单列表采用 vue-virtual-scroller 的 RecycleScroller(定高);
客户历史订单等不定高列表采用 DynamicScroller。不引入 TanStack Virtual。

## 理由
接手成本是首要约束:组内两名成员需能在一天内维护虚拟滚动代码。
性能实测两者均达成掉帧 < 5%,此维度不构成区分度(第 13 章数据)。

## 后果
- 正面:5 万行滚动掉帧 < 3%,滚动内存稳定在 ~120MB;
- 负面:行高变更需同步标尺常量,已在组件顶部注释中钉死;
- 行动项:若未来出现「跨应用复用虚拟表格」诉求,重新评估抽 qing-ui 的 QingVirtualTable。

补记 ADR 是被允许的(且很常见):宁可补记,不要让决策理由失传,补记时注明「按回忆补记」即可。

🛠 渐进式实战练习

练习 1 · 基础(15–25 min):给轻订单做「结构体检」

任务描述:按 §19.1 四因子表给你所在团队(或轻订单 admin)打分,得出结构形态结论;然后画出「中级版目录 → 资深版六层目录」的迁移映射表。

提示:中级版的 views/、api/、stores/、components/ 分别流向六层的哪一层?记住判断口诀——「这是谁的状态/谁的动作」。

验收标准:

  • [ ] 打分表四因子齐全,总分与结论自洽;
  • [ ] 映射表覆盖中级版全部主要目录,无「孤儿目录」;
  • [ ] 至少标注 2 处「故意不迁移」的项并说明理由(如小型局部组件留在 widget 内)。
参考答案(映射表节选)
中级版位置 资深版归宿 理由
views/order/OrderListPage.vue views/order-list/ 页面薄壳
components/OrderTablePanel.vue widgets/order-table-panel/ 筛选+表格+分页的整块组合
components/AmountText.vue shared/ui/ 无业务语义的原子展示
stores/orderDraft.ts features/order-create/model/ 流程态
stores/dict.ts entities/*/model/(按实体拆开) 实体级缓存
api/order.ts entities/order/api/ 实体轴 API
composables/useOrderList.ts features/order-list/model/ 服务端态编排,属于列表这个「动作/视图单元」

故意不迁移:views/order 里的「状态流转按钮组」若只有详情页使用,留在该 view 目录内部,不上提 widget——上提的唯一理由是第二个消费方出现。

练习 2 · 进阶(45–60 min):把依赖规则变成 CI 门禁

任务描述:为六层结构配置 eslint-plugin-boundaries(规则细节以插件官方文档为准),并在第 18 章的 CI quality job 中加入 lint:arch 步骤;然后故意写一条跨层 import 验证门禁会红。

提示:在 settings 中声明元素类型与匹配路径,在 element-types 规则中按 §19.2 的「允许依赖」表配置;公共 API 入口约束可用插件的 external/internal 相关规则或 no-restricted-imports 补充。

验收标准:

  • [ ] 六层元素全部登记,规则覆盖「允许依赖表」的每一格;
  • [ ] views → features 放行、features → features 拦截、shared → entities 拦截,三条用例实测符合预期;
  • [ ] CI 中该步骤失败会阻断合并(贴出红绿两次运行的结论即可)。
参考答案(配置骨架)
// eslint.config.mjs(节选,规则细节以 eslint-plugin-boundaries 官方文档为准)
import boundaries from 'eslint-plugin-boundaries'

export default [
  {
    files: ['src/**/*.{ts,vue}'],
    plugins: { boundaries },
    settings: {
      'boundaries/elements': [
        { type: 'app', pattern: 'src/app/**' },
        { type: 'views', pattern: 'src/views/**' },
        { type: 'widgets', pattern: 'src/widgets/**' },
        { type: 'features', pattern: 'src/features/**' },
        { type: 'entities', pattern: 'src/entities/**' },
        { type: 'shared', pattern: 'src/shared/**' },
      ],
    },
    rules: {
      'boundaries/element-types': ['error', {
        default: 'disallow',
        rules: [
          { from: 'app', allow: ['views', 'widgets', 'features', 'entities', 'shared'] },
          { from: 'views', allow: ['widgets', 'features', 'entities', 'shared'] },
          { from: 'widgets', allow: ['features', 'entities', 'shared'] },
          { from: 'features', allow: ['entities', 'shared'] },
          { from: 'entities', allow: ['shared'] },
          { from: 'shared', allow: [] },
        ],
      }],
    },
  },
]
# package.json scripts
"lint:arch": "eslint src --max-warnings 0"

验证用例:在 src/shared/ui/AmountText.vue 里 import { useAuthStore } from '@/app/model/auth' → pnpm lint:arch 必须报错。

练习 3 · 挑战(60+ min):轻订单 Monorepo 化

任务描述:把单仓库重组为 §19.7 的 workspace 结构:迁移 qing-utils(金额计算、CSV 工具)与 qing-api-client 到 packages/,admin 与 portal 以 workspace:* 引用,并保证 CI 按路径过滤只构建受影响的应用。

提示:pnpm 的 --filter 支持依赖图语义;tsconfig 引用共享包时优先走 qing-tsconfig 预设 + 各包独立 tsconfig.json;先迁纯函数包(零风险),最后迁 API 客户端。

验收标准:

  • [ ] pnpm -r build 全绿;admin 与 portal 均能通过 workspace:* 拿到 qing-utils;
  • [ ] 修改 packages/qing-utils 后,构建 filter 能同时覆盖两个 app;
  • [ ] 迁移过程单 PR 完成,迁移前后应用行为一致(第 16 章冒烟集通过)。
参考答案(关键命令与配置)
# 根目录初始化
pnpm init
# 写入 pnpm-workspace.yaml(见 §19.7)

# 安装依赖(workspace 协议)
cd apps/qing-admin && pnpm add qing-utils@workspace:* qing-api-client@workspace:*
# CI 中按依赖图过滤:构建 admin 及其全部 workspace 依赖
pnpm --filter qing-admin... build

# 只跑受 qing-utils 变更影响的应用
pnpm --filter ...qing-utils build

两个坑:① 各包 package.json 必须补 main/types(或 exports)字段,否则 Vite 解析失败;② 共享包内的 Vue 组件依赖(如 qing-ui)要声明为 peerDependencies,避免出现双 Vue 实例(第 21 章还会再撞一次这个坑)。

⚠️ 常见易错点

易错点 1 · 分层僵化:要么跨层乱 import,要么「分层表演」

❌ 错误做法:画了六层目录,但 shared/ui 里 import 订单 store、features 之间互相伸手、外部直接 import('@/entities/order/api/order.api.ts') 深挖路径。后果是分层只剩目录表演——改一处 shared 工具,五个 feature 炸雷;想替换 API 层时发现它已被业务深度耦合。另一个极端是为分层而分层:三行的小组件也要建 entities/,跳转成本指数上升。

✅ 正确做法:规则 + 门禁双保险。依赖方向交给 eslint-plugin-boundaries(练习 2),公共 API 交给 index.ts 出口 + no-restricted-imports 拦截深路径;同时设置「上提门槛」——只有第二个消费方出现才允许下沉建层,避免过早抽象。

易错点 2 · 过度设计:为中型项目上全套重装

❌ 错误做法:3 人团队、5 个页面的内部工具,直接上六层 + widgets 层 + Monorepo + 插件总线 + 依赖门禁。三个月后团队一半时间在「维护架构」,需求迭代速度反而低于不分层的版本。架构税必须由业务复杂度买单。

✅ 正确做法:用 §19.1 评估表先打分,总分 ≤ 8 就老实用简单目录;评估表写进 ADR,未来分数涨了再升级——架构可以演进,前提是当下不为未来预支成本。轻订单本次得分 15,属于「分层 + 轻量抽包」,而非「全家桶」。

易错点 3 · store 大杂烩:一个 store 装宇宙,服务端态混进 Pinia

❌ 错误做法:建一个 appStore 塞下用户、字典、订单列表、筛选、分页、弹窗状态;订单列表数据也放进去并手写「请求前判断缓存」逻辑。后果:任何组件订阅都全量更新(第 13 章渲染性能问题复发)、缓存失效逻辑与后端数据生命周期打架、测试要 mock 整个宇宙。

✅ 正确做法:按 §19.4 归属表切分——Pinia 只管客户端态(会话、流程、字典缓存),服务端态交给请求 composable/Query 层;store 按「实体/流程」拆小,跨 store 调用在 action 内显式组合而非互相嵌套订阅。

易错点 4 · 初始化编排散落:副作用写在模块顶层

❌ 错误做法:dict.ts 模块顶层直接 getDicts() 发请求、monitor.ts 在 import 时自行 initSentry()。后果:import 顺序决定启动顺序(脆弱且不可测)、单测 import 该模块就触发网络请求(第 15 章测试环境炸裂)、SSR 或微前端场景下模块被重复加载时副作用重复执行。

✅ 正确做法:模块只导出 setup(ctx),一切副作用进入 §19.6 的编排器;插件声明 requires 依赖,编排器按序执行并打点耗时。判断标准一句话:main.ts 之外不存在任何「被 import 即执行」的逻辑。

易错点 5 · ADR 流水账:只记结论不记权衡,写完即死

❌ 错误做法:ADR 写成「我们决定用 Pinia。」一行结论;或决策被推翻后原文不动,新人读到的是早已失效的「已采纳」。前者让半年后的你无法判断当时假设是否还成立,后者是团队内的「错误文档污染源」。

✅ 正确做法:按 §19.8 模板写全备选与理由(当时的错误假设也保留原貌,并在「后果」节复盘);状态变更时在文首加一行「已废弃,被 ADR-xxx 取代」而非删除原文——决策历史的价值恰恰包含「我们错在哪」。

✅ 最佳实践

  1. 先评估后动工:任何结构决策前先跑 §19.1 评估表并把结论写成 ADR,让「要不要上架构」本身可追溯;
  2. 目录即契约:六层结构配一张「放置决策表」(§19.4)贴在团队 wiki,新代码先对表再动手,消灭「放哪都行」的争论;
  3. 规则必须机器化:分层依赖、公共 API、路由 meta 类型全部交给 ESLint/TS 门禁,口头规范约等于没有规范;
  4. 公共 API 原则前置:index.ts 出口从第一天执行,比事后拆除深路径引用便宜一个数量级;
  5. 启动流程显式化:初始化编排器是应用的心电图,每个插件独立可测、可打点、可插拔;
  6. 抽包三问(复用?稳定?边界清晰?):三问不过关留在应用内,Monorepo 不是目录的荣誉勋章;
  7. ADR 用轻量流程养习惯:先在 README 的 docs/adr/ 目录写 markdown 即可,别一上来就上决策管理平台,工具越重习惯越难养;
  8. 演进优于重写:从中级版目录到六层是「渐进搬迁 + 冒烟集护航」,永远不要为架构升级安排「停机重写」窗口。

📌 本章小结

  • 架构决策的起点是业务复杂度评估:四因子打分决定「简单目录 / 模块化单体 / Monorepo」,先算成本再谈理想;
  • 六层模型(app/views/widgets/features/entities/shared)配两条铁律——单向依赖与公共 API 入口,并让 ESLint 门禁替人守规矩;
  • 路由、状态、请求三大基建的组织约定:路由模块自声明、Pinia 只管客户端态、API 按实体与流程两轴切分;
  • 启动流程用编排器 + 插件显式化,横切关注点各归其位;仓库演进遵循「抽包三问」,ADR 把每个重大决策的「为什么」钉进历史。

下一章我们把 packages/qing-ui 从零建起来:设计令牌、组件 API 规范、Storybook 文档站与 changesets 发布链路,让团队共享的不只是代码,更是设计语言。

📄 第 20 章 · 设计系统与组件库建设 20-设计系统与组件库建设.md

📍 阶段:资深阶段(第 19–24 章)|⏱ 预计学时:8–10 小时|🔗 前置:第 06 章、第 17 章、第 19 章

⬅ 上一章 | 📖 本版目录 | ➡ 下一章

💡 对照学习:本页是 Vue 版第 20 章。React 版对应章节为 第 20 章 · 设计系统与组件库建设。本章方法两版通用,示例代码为 Vue 生态:设计令牌、API 设计原则、Storybook、changesets 在 React 组件库中流程一致,组件写法从 SFC 换成函数组件即可。业务基准:附录A——为轻订单抽离内部组件库 qing-ui。

🎯 学习目标

学完本章,你将能够:

  1. 说清「业务为什么需要自建组件库」,并判断现有 UI 库(Element Plus)覆盖不到的部分;
  2. 建立「primitive / semantic / component」三层设计令牌体系,并把第 06 章的暗色主题方案升级为可跨应用复用的版本;
  3. 应用组件 API 设计五原则(受控优先、组合优于配置、命名词法统一、类型即文档、少即是多)评审并改进一个组件的对外接口;
  4. 在 scoped CSS / CSS Variables / 原子化 CSS / CSS-in-JS 之间为组件库做出有依据的样式方案取舍;
  5. 搭建 Storybook 文档站,为组件编写 CSF 故事与用法文档,并接入视觉回归测试;
  6. 落实组件级无障碍(a11y)基线:键盘可达、焦点管理、ARIA 属性与对比度检查;
  7. 用 changesets 管理版本与 CHANGELOG,把 qing-ui 发布到 npm 私服并建立 CI 发布流水线;
  8. 制定 qing-ui 从 0 到 1 的分阶段路线图,明确每个里程碑的验收标准。

📖 核心知识点

20.1 为什么自建组件库:Element Plus 之外还有什么

Element Plus 解决了「通用交互组件」,但企业级场景仍有三类缺口:

缺口 具体表现 qing-ui 的应对
品牌一致性 各应用蓝色深浅不一、圆角与间距随手写 设计令牌统一发放
业务组件沉淀 订单状态标签、金额展示、审批时间线在三个应用重复实现 业务组件层(带业务语义,消费令牌)
跨应用共享 门户、后台、营销子系统(第 21 章)无法共享 UI 独立 npm 包,版本化管理

判断口径:自建库不是重写 Element Plus,而是「主题化包装 + 业务沉淀」。基础交互组件优先二次封装 Element Plus,业务组件自研——这条边界写进 qing-ui 的 README,防止库膨胀。

20.2 设计令牌三层体系(衔接第 06 章暗色主题)

第 06 章门户用 :root + .dark 类切换实现过暗色主题,那是单应用的做法。组件库层面要升级为三层令牌,主题切换只发生在「映射层」:

层 命名规范 内容 变更频率
L1 primitive(原始值) --q-brand-500、--q-gray-100 调色板、字号阶、间距阶、圆角阶 极低(品牌升级才动)
L2 semantic(语义) --q-color-bg-page、--q-text-primary 表达「用途」,L1 值的映射 低(设计语言迭代)
L3 component(组件) --q-button-height-md 组件私有参数 随组件迭代
/* packages/qing-ui/src/styles/tokens.css */

/* ── L1 primitive:只有值,没有语义 ─────────────────── */
:root {
  --q-brand-500: #2563eb;
  --q-brand-600: #1d4ed8;
  --q-danger-500: #ef4444;
  --q-gray-50: #f8fafc;
  --q-gray-900: #0f172a;
  --q-space-2: 0.5rem;
  --q-space-4: 1rem;
  --q-radius-md: 6px;
  --q-font-size-md: 14px;
}

/* ── L2 semantic:主题切换只改这一层 ────────────────── */
:root,
[data-theme='light'] {
  --q-color-bg-page: var(--q-gray-50);
  --q-color-text-primary: var(--q-gray-900);
  --q-color-brand: var(--q-brand-500);
  --q-color-brand-hover: var(--q-brand-600);
  --q-color-danger: var(--q-danger-500);
  color-scheme: light;
}

[data-theme='dark'] {
  --q-color-bg-page: #0f172a;
  --q-color-text-primary: #e2e8f0;
  --q-color-brand: #3b82f6;
  --q-color-brand-hover: #60a5fa;
  color-scheme: dark;
}
/* ── L3 component:组件只消费语义层,永远不直接用 L1 ── */
.q-button {
  min-height: var(--q-button-height, 32px);
  padding: 0 var(--q-space-4);
  border-radius: var(--q-radius-md);
  color: var(--q-color-text-on-brand, #fff);
  background: var(--q-color-brand);
}
.q-button:hover { background: var(--q-color-brand-hover); }

三个纪律:① 组件样式只允许消费 L2/L3,直接引用 --q-brand-500 视为 lint 错误;② 所有令牌带 q- 命名空间,避免与宿主应用/其他库冲突;③ 主题切换器只操作 document.documentElement.dataset.theme,与第 06 章门户的 prefers-color-scheme 检测逻辑合并进 shared/lib/useTheme。

20.3 组件 API 设计五原则

qing-ui 的每个组件在动手前先过「API 评审」,五原则如下:

原则 含义 违反的典型症状
受控优先 有状态就提供 v-model,同时保留受控/非受控两种用法 组件内部藏 open 状态关不掉
组合优于配置 结构变化用插槽/子组件表达,不堆布尔 prop showHeader、hideFooter、compactMode…
命名词法统一 同一概念全库同名:开关用 open、禁用用 disabled、尺寸只有 sm/md/lg A 组件叫 visible,B 组件叫 show
类型即文档 defineProps 泛型 + withDefaults,JSDoc 注释进编辑器悬浮 类型是 any,用法靠读源码
少即是多 第一版只暴露被真实需求证明的字段,缺了再加是加法,错了再删是破坏性变更 1.0 就有 40 个 props

以 QingInput 为例(v-model + 组合插槽 + 类型即文档的最小完整实现):

<!-- packages/qing-ui/src/components/QingInput/QingInput.vue -->
<script setup lang="ts">
import { useId } from 'vue'
import { useFormSize } from '../../composables/useFormSize'

/**
 * 文本输入框。受控用法:v-model="value";非受控:传 defaultValue。
 * 清空、前后缀图标通过插槽扩展,不设 show-prefix 类布尔开关。
 */
const props = withDefaults(
  defineProps<{
    /** 占位提示文本 */
    placeholder?: string
    /** 是否禁用 */
    disabled?: boolean
    /** 尺寸,与全局令牌 --q-control-height-* 对应 */
    size?: 'sm' | 'md' | 'lg'
    /** 是否显示清空按钮(需 modelValue 非空且聚焦过) */
    clearable?: boolean
  }>(),
  { placeholder: '', disabled: false, size: 'md', clearable: false },
)

/** Vue 3.5:defineModel 声明受控值,父组件 v-model 即可双向绑定 */
const modelValue = defineModel<string>({ default: '' })

const inputId = useId()
const sizeHeight = useFormSize(() => props.size)

function handleClear() {
  modelValue.value = ''
}
</script>

<template>
  <span class="q-input" :class="[`q-input--${size}`, { 'is-disabled': disabled }]">
    <!-- 前置插槽:搜索图标、单位等 -->
    <span v-if="$slots.prefix" class="q-input__prefix"><slot name="prefix" /></span>
    <input
      :id="inputId"
      v-model="modelValue"
      class="q-input__inner"
      type="text"
      :placeholder="placeholder"
      :disabled="disabled"
      :style="{ height: sizeHeight }"
    />
    <button
      v-if="clearable && modelValue"
      class="q-input__clear"
      type="button"
      aria-label="清空输入"
      @click="handleClear"
    >×</button>
    <span v-if="$slots.suffix" class="q-input__suffix"><slot name="suffix" /></span>
  </span>
</template>

20.4 主题化与样式方案取舍

组件库的样式方案没有银弹,按「隔离性 / 运行时成本 / 团队心智」取舍:

方案 隔离性 运行时成本 适用判断
BEM + 纯 CSS(令牌驱动) 靠 q- 前缀约定,无真隔离 零 qing-ui 选它:产物可被任何宿主消费,无构建耦合
CSS Variables + scoped SFC 内隔离,跨文件靠令牌 零 应用内页面样式(第 12 章用法)
原子化 CSS(UnoCSS 等) 类名即样式,无冲突 构建期生成,运行时零 应用内布局;组件库产物里塞原子类会绑架宿主
CSS-in-JS 强隔离 运行时生成样式,SSR 需额外处理 深度动态主题需求;Vue 生态非主流,qing-ui 不采用

选 BEM 的核心理由:组件库 npm 包的产物应当是「与宿主构建体系解耦的 CSS 文件 + JS」——宿主用 Vite 还是 webpack、有没有原子化 CSS 引擎,都不该影响 qing-ui 能不能跑。

20.5 Storybook 文档站与视觉回归

文档站是组件库的「店面」:没有店面的组件库等于不存在。技术选型 Storybook(@storybook/vue3-vite 框架),故事用 CSF3 写法:

// packages/qing-ui/src/components/QingInput/QingInput.stories.ts
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import QingInput from './QingInput.vue'

const meta = {
  title: '表单/QingInput',
  component: QingInput,
  tags: ['autodocs'],          // 自动生成 props 文档页
  argTypes: {
    size: { control: 'radio', options: ['sm', 'md', 'lg'] },
  },
} satisfies Meta<typeof QingInput>

export default meta

type Story = StoryObj<typeof meta>

export const 默认: Story = {
  args: { placeholder: '搜索订单号 / 客户名' },
}

export const 可清空: Story = {
  args: { clearable: true, modelValue: 'QO20260929000001' },
}

export const 暗色主题: Story = {
  args: { placeholder: '暗色下检查对比度' },
  parameters: {
    // Storybook 8 起官方内置 theme 切换建议走 toolbar addon:
    // addon-themes + data-theme 属性,与 §20.2 的切换机制天然对齐
    docs: { description: { story: '验证暗色令牌映射与文字对比度 ≥ 4.5:1。' } },
  },
}

每个组件的文档页固定四块:何时用 / 何时不该用(Do & Don't)/ 无障碍说明 / 与 Element Plus 版本的对应关系(迁移期必备)。

视觉回归:每次 PR 对全部故事截图,与基线图逐像素对比,防止「改个按钮全库变样」。轻量落地用 Playwright 截图对比(toHaveScreenshot),团队预算允许时可用 Chromatic 云服务(token 走环境变量,绝不写进仓库):

# 本地:Playwright 视觉基线(首次 --update-snapshots 生成基线)
pnpm --filter qing-ui exec playwright test tests/visual --update-snapshots
# .github/workflows/visual-regression.yml(节选,token 一律环境变量注入)
name: visual-regression
on: pull_request
jobs:
  screenshot:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: pnpm/action-setup@v4
      - run: pnpm install --frozen-lockfile
      - run: pnpm --filter qing-ui build-storybook
      - name: 上传构建产物并比对基线
        run: pnpm dlx chromatic --project-token=${{ secrets.CHROMATIC_PROJECT_TOKEN }} --exit-once-uploaded

20.6 a11y 基线:组件库是可达性的最后防线

业务团队没精力逐页做无障碍,但组件库可以一次性做对,所有应用自动受益。qing-ui 的四条基线:

  1. 键盘可达:所有交互元素可 Tab 聚焦、可 Enter/Space 触发;复杂组件(下拉、弹窗)定义完整方向键行为;
  2. 焦点管理:弹窗类组件 Teleport 到 body 后必须做焦点圈禁(focus trap),关闭时焦点还原到触发元素;
  3. 语义与 ARIA:优先用原生元素(button/input),不够时补 role 与 aria-*;图标按钮必须 aria-label(见 §20.3 的清空按钮);
  4. 对比度:文字对背景 ≥ 4.5:1(WCAG AA),暗色主题逐令牌实测,写进 Storybook 暗色故事的验收项。
// tests/a11y/qing-input.a11y.test.ts —— axe 自动化基线(节选)
import { describe, expect, it } from 'vitest'
import { render } from '@testing-library/vue'
import { axe } from 'vitest-axe'
import QingInput from '../../src/components/QingInput/QingInput.vue'

describe('QingInput 可达性', () => {
  it('无 axe 违规', async () => {
    const { container } = render(QingInput, {
      props: { placeholder: '搜索订单', clearable: true, modelValue: 'QO01' },
    })
    const results = await axe(container)
    expect(results).toHaveNoViolations()
  })
})

CI 门禁口径:新组件合入前 axe 零违规 + 键盘走查清单勾完,缺一项就退回(第 22 章 Review 清单收录)。

20.7 changesets 版本管理与 npm 私服发布

qing-ui 的版本纪律:语义化版本(SemVer)+ changesets 管理。开发者日常只做一件事——PR 里附带一个 changeset 文件:

# 在 workspace 根目录交互式生成变更集:选择包、版本级别(major/minor/patch)、写摘要
pnpm changeset
# 生成 .changeset/wild-lions-dance.md,随 PR 一起提交
<!-- .changeset/wild-lions-dance.md(changeset 自动生成的示例形态) -->
---
'qing-ui': minor
---

QingInput 新增 `clearable` 属性与清空按钮,附带暗色主题适配。

合并 main 后由 CI 统一执行「版本提升 + 发布」,避免任何人在本地手动改版本号:

# .github/workflows/release.yml(节选;npm token 全程环境变量,禁止字面量)
name: release
on:
  push:
    branches: [main]
concurrency: release
jobs:
  version-and-publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - uses: pnpm/action-setup@v4
      - run: pnpm install --frozen-lockfile
      - name: 消费 changesets:改版本、写 CHANGELOG
        id: changesets
        uses: changesets/action@v1
        with:
          version: pnpm changeset version
          publish: pnpm changeset publish
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}      # 私服令牌,CI Secret 注入
; .npmrc —— 私服指向与鉴权(令牌用环境变量占位,本地开发用 verdaccio)
@qing:registry=https://npm-registry.example.internal/
//npm-registry.example.internal/:_authToken=${NPM_TOKEN}
always-auth=true
# 本地演练:verdaccio 起一个私服镜像(仅教学,不暴露公网)
pnpm dlx verdaccio                     # 默认 http://localhost:4873
pnpm config set @qing:registry http://localhost:4873/ --location project
pnpm --filter qing-ui build && pnpm --filter qing-ui publish --access public

版本纪律三条:① 0.x 阶段 breaking 也走 minor,但在 CHANGELOG 标注 BREAKING;② 1.0 后破坏性变更必须 major + 迁移指南;③ 任何发布必须能从 CHANGELOG 追溯到具体 PR。

20.8 qing-ui 0→1 路线图

里程碑 周期 交付物 退出标准(验收)
M0 令牌与地基 1 周 三层令牌、主题切换器、包骨架(Vite 库模式 + TS) 令牌 lint 生效;暗/亮主题一键切换
M1 首批 10 个组件 3 周 QingButton/Input/Select/Dialog/Tag/Pagination/Table(封装) 等 每组件:故事 + 单测 + axe 零违规
M2 文档站与 CI 1 周 Storybook 站点、视觉回归、changesets 发布链 文档站可达;PR 自动出比对图;发布走 CI
M3 迁移 admin 2 周 admin 切换到 qing-ui + Element Plus 共存策略 冒烟集全绿;未覆盖场景有明确回退方案
M4 业务组件层 持续 StatusTag、AmountText、ApprovalTimeline 每沉淀一个组件,删掉一处应用内重复实现

先吃自己的狗粮(M3 迁移自家 admin),再对外(第 21 章营销子系统)推广——顺序反了的组件库通常死于「为想象中的需求而设计」。

🛠 渐进式实战练习

练习 1 · 基础(15–25 min):API 评审 + 实现 QingTag

任务描述:轻订单需要一个订单状态标签组件(附录A §3.3 七种状态),先写一份「API 提案表」(props/emits/slots),对照五原则自查后实现 QingTag,并保证它消费语义令牌而非硬编码颜色。

提示:状态 → 颜色/文案的映射属于业务知识,不要写死在组件里;让组件接受 variant 与 label,映射表放在 entities/order(第 19 章的分层立即用上)。

验收标准:

  • [ ] API 提案表齐全,且每项标注对应的设计原则;
  • [ ] 组件内零业务词(搜不到 "order"),颜色全部来自 --q-* 令牌;
  • [ ] v-model:visible(可关闭标签)受控可用,关闭时 emit 事件类型正确。
参考答案(API 提案 + 关键实现)
API 类型 说明 原则
variant 'neutral' | 'info' | 'success' | 'warning' | 'danger' 语义变体而非具体色值 命名词法统一
visible boolean(v-model:visible) 受控显隐 受控优先
closable boolean 是否显示关闭钮 少即是多(首版不加 size)
close emit () => void 关闭钮点击 类型即文档
默认插槽 标签文案 允许自定义内容 组合优于配置
<!-- packages/qing-ui/src/components/QingTag/QingTag.vue -->
<script setup lang="ts">
const props = withDefaults(
  defineProps<{
    variant?: 'neutral' | 'info' | 'success' | 'warning' | 'danger'
    closable?: boolean
  }>(),
  { variant: 'neutral', closable: false },
)
const emit = defineEmits<{ close: [] }>()
const visible = defineModel<boolean>('visible', { default: true })
</script>

<template>
  <span v-if="visible" class="q-tag" :class="`q-tag--${props.variant}`">
    <slot />
    <button
      v-if="closable"
      class="q-tag__close"
      type="button"
      aria-label="关闭标签"
      @click="emit('close')"
    >×</button>
  </span>
</template>

业务侧在 entities/order 中维护映射:draft → info、pending_review → warning、approved → success、rejected → danger、cancelled → neutral。

练习 2 · 进阶(45–60 min):三层令牌 + 暗色主题落地

任务描述:在 packages/qing-ui 建立 §20.2 的三层令牌;把第 06 章门户的主题切换按钮改造成消费 qing-ui 令牌的版本,并用 Devtools 验证「切换主题只改 data-theme,L1 值不动」。

提示:门户此前是 :root 直接定义业务色,迁移时先「翻译」成 L2 语义(--portal-bg-hero → 复用 --q-color-bg-page 还是门户自定义 L2?想清楚再动手);保留旧类名做渐进迁移,不要一次性替换全部样式。

验收标准:

  • [ ] L1/L2/L3 三层令牌文件分离,L1 无语义命名;
  • [ ] 门户暗色切换功能与第 06 章验收一致(无闪烁、持久化);
  • [ ] 门户至少 5 个视觉点改用 qing-ui 令牌,且暗色下对比度 ≥ 4.5:1。
参考答案(迁移策略要点)
  1. 门户引入依赖:pnpm --filter qing-portal add qing-ui@workspace:*,入口 import 'qing-ui/styles/tokens.css';
  2. 语义映射:门户的 --portal-color-bg 定义为 var(--q-color-bg-page),门户私有令牌留在门户层、但值引用 L1(如 --portal-brand-soft: var(--q-brand-500) 加透明度);
  3. 切换器收敛:删除门户自己的 .dark 类逻辑,改用 shared 的 useTheme,统一操作 data-theme;
  4. 闪烁治理:在 index.html 内联一段阻塞脚本,首帧前根据 localStorage/matchMedia 写入 data-theme(第 06 章结论在此沿用)。

练习 3 · 挑战(60+ min):Storybook 站点 + 视觉回归 + 发布链路走通

任务描述:为 qing-ui 搭 Storybook(vue3-vite 框架),为练习 1 的 QingTag 补齐 3 个故事(亮/暗/可关闭),接入 Playwright 截图对比;最后用 verdaccio 把 qing-ui@0.1.0 发布到本地私服,并在 admin 中安装验证。

提示:视觉基线第一次生成后,故意把 --q-tag--warning 的颜色改 1 像素看比对是否报警;发布前 changeset version 消费变更集再 changeset publish。

验收标准:

  • [ ] pnpm --filter qing-ui storybook 可访问,故事含文档页(autodocs);
  • [ ] 颜色变更触发视觉回归失败,恢复后通过;
  • [ ] admin 通过 @qing:registry 指向 verdaccio 安装到 0.1.0 并正常渲染标签。
参考答案(关键脚本)
// packages/qing-ui/package.json(节选)
{
  "name": "qing-ui",
  "version": "0.1.0",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": { "import": "./dist/index.js", "require": "./dist/index.cjs" },
    "./styles/tokens.css": "./dist/styles/tokens.css"
  },
  "files": ["dist"],
  "scripts": {
    "build": "vite build",
    "storybook": "storybook dev -p 6006",
    "build-storybook": "storybook build"
  },
  "peerDependencies": { "vue": "^3.5.0" }
}
# 发布演练全流程(本地私服)
pnpm changeset                       # 1) PR 中生成变更集
pnpm changeset version               # 2) main 合并后消费:0.0.x → 0.1.0,写 CHANGELOG
pnpm --filter qing-ui build          # 3) 产物构建
pnpm --filter qing-ui publish        # 4) 发到 verdaccio
pnpm --filter qing-admin add qing-ui@0.1.0   # 5) 宿主安装验证

⚠️ 常见易错点

易错点 1 · API 面面俱到:一个组件 60 个 props,维护爆炸

❌ 错误做法:为了「灵活性」,第一版 QingTable 就暴露 showHeader、showFooter、bordered、striped、compact、stickyFirstColumn……六十个布尔 prop。半年后每加一个功能都要排查与全部 prop 的组合矩阵,任何一个改动都是全量回归,组件库迭代速度归零,业务方开始绕过组件库直接写原生。

✅ 正确做法:按五原则做减法——结构变化交给插槽与子组件(<QingTableColumn> 声明式列)、主题变化交给令牌、场景变化交给 variant;首版 props 不超过 10 个,每个新 prop 的 PR 里必须写明「为什么插槽/令牌表达不了」。

易错点 2 · 令牌只有一层:把原始色值当语义色用

❌ 错误做法:组件样式里直接写 background: var(--q-brand-500),暗色主题上线时发现按钮底色和页面背景几乎同色,只能在暗色分支里逐个组件覆盖——覆盖散落十几个文件,改主题等于考古。

✅ 正确做法:严格执行「组件只消费 L2/L3」纪律并用 lint 拦截(匹配 var(--q-(brand|gray|danger)-\d+) 即报错);主题差异全部收敛在 L2 的 [data-theme='dark'] 一处,组件层对暗色零感知。

易错点 3 · fork 元素组件库源码:升级地狱的开端

❌ 错误做法:觉得 Element Plus 某组件不合心意,直接 fork 整个源码仓库魔改。三个月后 Element Plus 修了安全漏洞与新特性,qing-ui 永远停留在旧版,diff 冲突到无法合并。

✅ 正确做法:需要定制的用「包装层」——qing-ui 的组件 import Element Plus 并二次封装(改默认值、加令牌、收窄 API);确实要改上游行为的,给上游提 PR(这是唯一能长期白嫖维护红利的方式),等待期内用包装层兜底。

易错点 4 · 版本管理随心所欲:0.x 乱发 breaking、CHANGELOG 空白

❌ 错误做法:改了组件 API 但版本号只从 0.3.0 跳到 0.3.1,CHANGELOG 一片空白。宿主应用 pnpm update 后白屏,排查半天才发现是「minor 更新」里藏着删除的 prop。

✅ 正确做法:版本与 CHANGELOG 全部由 changesets 生成,本地禁止手改 package.json 的版本号;每个 PR 必须带 changeset 文件(缺失就用 CI bot 拦截);breaking 变更额外要求迁移指南小节。

易错点 5 · 文档站只有 Playground:能玩但不知道什么时候用

❌ 错误做法:Storybook 里只有可交互 demo,没有使用场景说明。业务团队遇到「该用 Dialog 还是 Drawer」时无人翻文档,直接口头问组件库作者——组件库成为单点依赖,作者休假即停摆。

✅ 正确做法:每个组件文档固定四块(何时用 / Do & Don't / 无障碍说明 / 迁移对照),PR 里「故事 + 文档」缺一不可;把「新组件必须带反例(Don't)截图」写进第 22 章的 Review 清单。

✅ 最佳实践

  1. 定位先行:qing-ui 明确为「主题化包装 + 业务沉淀」而非重写基础组件,边界写进 README 防止范围膨胀;
  2. 令牌三层,主题只动 L2:组件对暗色主题零感知,是主题成本最低的结构;
  3. API 先评审后编码:API 提案表(props/emits/slots + 原则对照)进 PR 描述,改 API 比改实现贵十倍;
  4. 故事即文档即测试:每个组件 ≥ 3 个故事(默认/暗色/边界),CSF 故事同时充当视觉回归的截图单元;
  5. a11y 是门禁不是建议:axe 零违规 + 键盘走查作为组件合入条件,业务应用免费继承可达性;
  6. 版本纪律机器化:changesets 全流程托管版本与 CHANGELOG,发布只发生在 CI,人手不碰版本号;
  7. 凭据零字面量:私服地址与 token 一律 .npmrc 环境变量占位 + CI Secret 注入,仓库泄密面最小化;
  8. 先内部后外部:M3 先迁移自家 admin 验证完,再把 qing-ui 提供给第 21 章的子系统使用。

📌 本章小结

  • 自建组件库的本质是设计语言的资产化:三层令牌承载主题(暗色只改映射层)、业务组件沉淀复用、npm 包承载跨应用共享;
  • 组件 API 五原则(受控优先、组合优于配置、命名词法、类型即文档、少即是多)是抵御「props 膨胀」的根本手段,API 评审在编码之前;
  • 文档站(Storybook + Do/Don't)与视觉回归让组件库「被信任」,a11y 基线让它「被所有人可用」;
  • changesets + 私服发布链路让版本演进可追溯;qing-ui 的 0→1 按令牌 → 组件 → 文档 → 迁移 → 业务层的顺序推进。

下一章面对更进一步的拆分诉求:当第三方营销子系统(另一套技术栈)要接入轻订单平台,微前端是否是答案?我们将先问「要不要拆」,再对比 qiankun、micro-app、wujie 与 Module Federation 四条路线。

📄 第 21 章 · 微前端与跨团队协作 21-微前端与跨团队协作.md

📍 阶段:资深阶段(第 19–24 章)|⏱ 预计学时:8–10 小时|🔗 前置:第 19 章、第 20 章

⬅ 上一章 | 📖 本版目录 | ➡ 下一章

💡 对照学习:本页是 Vue 版第 21 章。React 版对应章节为 第 21 章 · 微前端与跨团队协作。本章方法两版通用,示例代码为 Vue 生态:「要不要拆」评估、四方案对比、隔离原理、Strangler 迁移与框架无关;示例中的子应用恰好是 React 技术栈,正好演示跨栈集成。业务基准:附录A §6——轻订单平台接入第三方营销子系统。

🎯 学习目标

学完本章,你将能够:

  1. 用「要不要拆」评估清单判断一个场景是否适合微前端,并坚决对不合适的场景说「不」;
  2. 从隔离机制、通信能力、接入成本、生态成熟度四个维度对比 qiankun / micro-app / wujie / Module Federation,并给出选型结论与理由;
  3. 讲清 JS 沙箱(Proxy 沙箱与 iframe 隔离)与 CSS 隔离(scoped 前缀、Shadow DOM、iframe)的原理与各自的坑;
  4. 为主子应用选择并实现恰当的通信方案(初始化数据 / 状态共享 / 事件总线),避免全局事件滥用;
  5. 治理公共依赖,保证 Vue、Pinia、vue-router 在主子应用间是单例;
  6. 整合主子应用的路由与部署:激活规则、basename 对齐、Nginx 路径划分与 404 兜底;
  7. 为轻订单完成「接入第三方营销子系统」的完整落地(含跨栈通信与会话透传);
  8. 用 Strangler 模式制定存量系统渐进迁移的步骤表,避免「停机重写」。

📖 核心知识点

21.1 先问要不要拆:微前端的适用与不适用

微前端解决的是组织问题(多团队并行、独立发版、技术栈异构),不是技术炫技。它引入真实的成本:包体积翻倍、调试链路变长、一致性维护变难。先过评估清单:

适用信号(≥ 3 条才考虑) 不适用信号(出现任何一条就缓行)
≥ 3 个团队在同一站点并行开发,互相阻塞发版 团队规模小(< 8 人),没有团队墙要翻
子系统技术栈不可更换(第三方交付/历史包袱) 页面间强共享状态、频繁跨模块交互
各子系统需要独立部署、独立发布节奏 首屏性能极度敏感(多壳 = 多份运行时)
存量系统需逐页替换而非停机重写 纯内容型站点,SEO 与单包体积优先
平台型产品需要「插件式」接入外部能力 项目寿命 < 1 年,投入产出不成立

轻订单的答案:admin 主站 4 人团队,评估清单只命中 1 条(技术栈异构),不拆。但「营销活动管理」由公司另一条产品线交付(React 技术栈、独立迭代、要求嵌入订单平台统一入口),命中 3 条 → 该子系统用微前端接入,主应用作为基座(容器)。拆的是子系统边界,不是团队没有的边界。

21.2 四方案对比:qiankun / micro-app / wujie / Module Federation

维度 qiankun micro-app wujie Module Federation
基本原理 single-spa 改造:HTML 入口解析 + 生命周期包装 Web Component:<micro-app> 自定义元素 iframe + Web Component:JS 跑在 iframe,UI 渲染到主应用 构建期模块共享(Vite/Webpack 插件)
JS 隔离 Proxy 沙箱(伪隔离) Proxy 沙箱 / 1.0 可选 iframe 模式 iframe 天然真隔离 无沙箱(运行在同窗体)
CSS 隔离 scoped 前缀 / experimental StyleIsolation scoped 前缀 / Shadow DOM 可选 iframe 天然隔离(弹窗需处理) 无隔离,需自行约定
通信 initGlobalState、props data 属性 + setData/dispatch props + bus 事件中心 + window 通道 共享模块(shared singleton)
Vite(ESM)子应用支持 弱(社区补丁,坑多) 好 好 好(构建期原生 ESM)
接入改造成本 主子均需导出生命周期钩子 子应用几乎零改造 子应用零改造(少量弹窗适配) 改构建配置,主子需同构约束
生态与文档 最成熟、案例最多 京东系广泛使用 腾讯系广泛使用 Webpack/Vite 官方能力,非微前端专用

选型口诀:要真隔离与跨栈杂糅 → wujie 或 micro-app 的 iframe 模式;要生态稳、子应用是 webpack/传统栈 → qiankun;同栈(都是 Vue/都是 React)想「按模块拆包共享」而非「应用隔离」→ Module Federation;主子应用是 Vite ESM 且想要低改造 → micro-app。

选型决策流程(先定隔离级别,再定接入形态):

子应用代码是否可信(自家团队长期维护)?
├── 不可信(第三方交付)──→ 需要「真隔离」──→ iframe 类方案
│                                            (wujie / micro-app iframe 模式)
└── 可信
    ├── 与基座异构技术栈?──→ Proxy 沙箱方案(qiankun / micro-app)
    │                          + 公共依赖上翻(§21.6)
    └── 与基座同栈?
        ├── 要「应用级独立发版」──→ 同上,或拆成独立仓库
        └── 只要「模块级共享/按需加载」──→ Module Federation

轻订单的结论:营销子系统是 React + Vite(ESM)交付、需要强隔离(第三方代码不可信其全局卫生),选 micro-app(iframe 模式);同时把结论写进 ADR-0030 备查(模板见第 19 章 §19.8)。

轻订单的结论:营销子系统是 React + Vite(ESM)交付、需要强隔离(第三方代码不可信其全局卫生),选 micro-app(iframe 模式);同时把结论写进 ADR-0030 备查(模板见第 19 章 §19.8)。

21.3 JS 隔离原理与坑

Proxy 沙箱(qiankun / micro-app 默认):给子应用一个代理过的 window,写操作落在代理的假窗体上,卸载时整体丢弃:

// Proxy 沙箱的最小示意(教学简化版,生产请用成熟方案)
function createProxySandbox() {
  const fakeWindow: Record<string, unknown> = {}
  const proxy = new Proxy(fakeWindow, {
    set(target, key, value) {
      target[key] = value        // 写进沙箱,不污染真实 window
      return true
    },
    get(target, key) {
      // 沙箱没有的,回退到真实 window(document/location 等)
      return key in target ? target[key] : (window as never as Record<PropertyKey, unknown>)[key]
    },
  })
  return proxy
}

iframe 隔离(wujie / micro-app iframe 模式):子应用 JS 运行在真正的 iframe Window 里,全局变量、定时器、事件天然隔离;wujie 再把 iframe 内渲染的 DOM 移到主应用展示层,兼顾隔离与「同站体验」。

qiankun 还保留了为旧栈兜底的快照沙箱(不支持的 Proxy 的环境降级用):激活子应用前拍下 window 快照,卸载时把 window 恢复为快照再存下子应用的改动——它是「全量 diff 回滚」,性能差且同一时刻只能有一个快照沙箱活跃,因此快照方案不支持多子应用并存。这也是老项目迁移评估时要问「浏览器兼容基线到哪」的原因。

// 快照沙箱的行为示意(教学简化版)
function createSnapshotSandbox() {
  let snapshot: Record<string, unknown> = {}
  let modifyMap: Record<string, unknown> = {}
  return {
    activate() {
      // 激活:拍快照 + 恢复上次子应用对 window 的修改
      snapshot = {}
      for (const key in window) snapshot[key] = (window as never as Record<string, unknown>)[key]
      Object.keys(modifyMap).forEach((key) => {
        ;(window as never as Record<string, unknown>)[key] = modifyMap[key]
      })
    },
    deactivate() {
      // 失活:记下子应用的修改,把 window 还原成激活前
      modifyMap = {}
      for (const key in window) {
        if ((window as never as Record<string, unknown>)[key] !== snapshot[key]) {
          modifyMap[key] = (window as never as Record<string, unknown>)[key]
          ;(window as never as Record<string, unknown>)[key] = snapshot[key]
        }
      }
    },
  }
}

两类方案的坑清单(Review 必查):

坑 现象 对策
卸载不清理 子应用切走后定时器仍在跑、轮询仍打接口 子应用 unmount 钩子里统一清 setInterval/监听器/AbortController
window 污染逃逸 用了 window.top、(0, eval)、隐式全局变量,沙箱拦不住 子应用跑 ESLint no-restricted-globals;对接第三方时用 iframe 模式
localStorage 键冲突 主子应用互相覆盖 token 键名加应用前缀(qo_admin_* / mkt_*),或由主应用统一注入
全局对象共享错乱 子应用读 document.cookie 拿不到预期值 会话统一由基座透传(见 §21.5),子应用不自取

21.4 CSS 隔离原理与坑

手段 原理 主要坑
scoped 前缀约定 子应用构建时全部样式加 .micro-app[name=mkt] 前缀 动态插入的 <style> 需劫持处理;前缀可能改变选择器优先级
Shadow DOM Web Component 天然样式边界 Teleport/弹窗挂到 body 后逃出 Shadow,样式全部丢失
iframe 隔离 子应用样式只作用于 iframe 文档 弹窗若想「盖在主应用上」,需要跨窗体渲染(wujie 的弹窗适配就是这个问题的产物)
命名空间纪律 所有子应用类名带 mkt- 前缀 靠自觉,必须配 stylelint 规则(selector-class-pattern)

一条铁律:无论选哪种隔离,弹窗、消息通知这类「浮层」都是重灾区。qing-ui 的浮层组件必须暴露 appendTo 参数,子应用内浮层挂到自己的挂载点内;基座级全局浮层(如全局 toast)由基座提供并经 §21.5 通信调用。

21.5 应用间通信:能少则少

通信越少,耦合越少。优先级从高到低:

方案 机制 适用 滥用后果
初始化数据(首选) 接入时一次性下发(props/data) 会话 token、用户信息、主题、locale 无
父子事件 子 → 父 dispatch,父 → 子 setData 登录态过期通知、跳转请求、上报埋点 事件满天飞,数据流不可追溯
共享状态(慎用) 共享依赖单例的 Pinia/内存 store 高频共享且双方同栈 跨栈时不可用;共享越多边界越模糊
URL 参数 路由 query 同步 跨应用跳转带上下文 敏感信息泄漏(禁传 token)

会话透传约定(轻订单):token 只存在于基座内存与刷新令牌管理中,子应用通过初始化数据拿到短期 access token,过期时向基座发 auth:refresh 事件由基座无感刷新后再下发——子应用永远不接触 refreshToken。

一次典型交互的时序(以「子应用内下单跳转订单详情」为例):

子应用(mkt)              基座(admin)                后端
    │  mount 时收到 initData │                         │
    │   {accessToken,...}    │                         │
    │                        │                         │
    │ ── GET /api/mkt/acts ───────────────────────────▶ │ (子应用自己带短期token)
    │ ◀──────────── 40102 token 过期 ─────────────────── │
    │ ── dispatch auth:refresh ──▶ │                    │
    │                             │ ── POST /auth/refresh ──▶
    │ ◀── setData {新accessToken} ─│                    │
    │ 重试原请求,成功              │                    │
    │ ── dispatch navigate ──▶ 基座 router.push('/orders/1001')

21.6 公共依赖治理:Vue 必须是单例

若主子应用各自打包一份 Vue,会出现两个响应式实例、两份 Pinia——跨应用通信的状态形同虚设,内存也翻倍。治理三板斧:

  1. 依赖上翻:子应用把 vue、vue-router、pinia 标记为 external,运行时从基座共享(Vite 下可用 import map 或 Module Federation 的 shared: { vue: { singleton: true } });
  2. 版本协商:共享依赖声明版本区间,基座升级大版本前与全部子应用对齐(第 22 章的跨团队契约);
  3. qing-ui 复用:子应用 UI 组件消费 qing-ui 令牌层(qing-ui/styles/tokens.css),视觉一致从令牌开始,不强求子应用换组件库。
// 子应用 Vite 配置:依赖上翻(教学示例,键名以所用方案文档为准)
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  base: '/mkt/',                      // 与 §21.7 部署路径一致
  build: {
    rollupOptions: {
      external: ['react', 'react-dom'],   // iframe 模式下子应用自含运行时也可,按隔离策略定
    },
  },
  server: {
    origin: 'http://localhost:5174',    // 让 dev 模式下资源 URL 可被基座访问
  },
})

21.7 路由与部署整合

部署拓扑(同域划路径,避免跨域与 cookie 问题):

                       Nginx(qingorder.example.com)
                              │
      ┌───────────────────────┼───────────────────────────┐
      ▼                       ▼                           ▼
 /            /mkt/*                       /api/*
 qing-admin 基座          qing-marketing 子应用            后端网关
 (SPA, history 模式)      (React + Vite, history 模式)    (附录A §5)
      │                        │
      │ 404 → 回退基座 index    │ 404 → 回退子应用 index
      └────────────────────────┘
# nginx.conf(节选):路径划分与 SPA 回退
location /mkt/ {
    alias /usr/share/nginx/html/mkt/;
    try_files $uri $uri/ /mkt/index.html;      # 子应用 history 回退
}
location / {
    root /usr/share/nginx/html/admin;
    try_files $uri $uri/ /index.html;          # 基座 SPA 回退
}

路由整合三条约定:

  1. 激活规则即路径:基座监听 /mkt/* 激活营销子系统,激活时基座侧边栏高亮「营销活动」菜单(菜单注册在基座,权限仍走第 11 章模型);
  2. basename 对齐:子应用路由 base 必须与部署路径一致(/mkt/),开发期用 VITE_APP_BASE 环境变量注入,禁止硬编码;
  3. 浏览器历史单一:只有基座操作地址栏,子应用通过「跳转事件」请基座导航,避免两套路由互相改 URL 打架。
// 基座侧:把「导航事件」映射到基座路由(跳转权收口在基座)
import { router } from '@/app/router'
import microApp from '@micro-zoe/micro-app'

/** 子应用请基座跳转:mkt 内部路由 → /mkt/<path>;站内其他路由 → 原样 */
export function registerMktNavigate() {
  microApp.addDataListener((data: { type?: string; payload?: unknown }) => {
    if (data.type !== 'navigate') return
    const path = String(data.payload ?? '')
    const target = path.startsWith('/orders') || path.startsWith('/customers')
      ? path                       // 基座自己的页面
      : `/mkt#${path}`             // 子应用内部锚点(按所接方案的路由同步机制调整)
    void router.push(target)
  }, false)
}

21.8 案例:轻订单接入第三方营销子系统

背景:营销产品线交付「活动配置 + 优惠券管理」系统(React 18 + Vite),要求嵌入轻订单 admin 统一入口,复用轻订单登录态与权限,且双方独立发版。

接入实现(micro-app,iframe 模式以获得强隔离):

// apps/qing-admin/src/main.ts(节选:注册微前端插件)
import microApp from '@micro-zoe/micro-app'

microApp.start({
  // 具体选项以 micro-app 官方文档为准;本教程采用 iframe 模式 + 数据通信
})
<!-- apps/qing-admin/src/views/marketing/MktEntryPage.vue -->
<script setup lang="ts">
import { onBeforeUnmount, ref } from 'vue'
import { useAuthStore } from '@/app/model/auth'
import microApp from '@micro-zoe/micro-app'
import { http } from '@/shared/api/client'

const url = `${import.meta.env.VITE_MKT_BASE_URL}/mkt/`
const mounted = ref(false)

/** 下发给子应用的初始化数据:只给短期 token,不给 refreshToken */
function initData() {
  const auth = useAuthStore()
  microApp.setData('mkt', {
    accessToken: auth.accessToken,
    userInfo: { id: auth.userId, name: auth.userName, roles: auth.roles },
    theme: document.documentElement.dataset.theme ?? 'light',
  })
}

// 子应用上报的事件统一在基座侧收口处理
function onMktData(e: Event) {
  const detail = (e as CustomEvent<{ type: string; payload?: unknown }>).detail
  if (detail.type === 'auth:refresh') void http.post('/auth/refresh')  // 触发无感刷新后再 setData
  if (detail.type === 'navigate') location.assign(String(detail.payload))
}

function onMounted() {
  mounted.value = true
  initData()
}
onBeforeUnmount(() => { mounted.value = false })
</script>

<template>
  <div class="mkt-entry">
    <!-- iframe 模式:强 JS/CSS 隔离,第三方代码只影响自己的窗体 -->
    <micro-app
      v-if="mounted"
      name="mkt"
      :url="url"
      iframe
      keep-alive
      @mounted="onMounted"
      @datachange="onMktData"
    />
  </div>
</template>
// 子应用(React)侧:消费基座数据(API 以 micro-app 官方文档为准)
// apps/qing-marketing/src/bootstrap.tsx
import microApp from '@micro-zoe/micro-app'

const baseData = microApp.getData('mkt') // { accessToken, userInfo, theme }
// 用 accessToken 创建子应用自己的请求实例;401 时向基座 dispatch auth:refresh

跨团队协作契约(第 22 章将制度化):基座向子系统提供《接入清单》——初始化数据 schema、事件字典(auth:refresh/navigate/report:track)、qing-ui 令牌引用方式、发版窗口;子系统回传自测报告。契约以 markdown 存于 docs/contracts/mkt-integration.md,变更走双方评审。

21.9 Strangler:存量系统渐进迁移

「绞杀者模式」用新系统逐步包裹旧系统,直到旧系统自然枯死,全程不停机:

步骤 动作 验收
1 划界 按业务域列出页面清单,标记「保留/待迁移/废弃」 清单评审通过,迁移顺序按流量从低到高
2 立门面 基座接管全部路由,旧页面先以「整站 iframe/原应用」方式挂在门面后 用户无感知,URL 语义不变(旧 URL 301 到门面对应路由)
3 逐页迁移 每次迁 1–2 个低风险页面为新模块/新应用 该页 E2E 冒烟通过 + 双跑一周数据一致
4 双写过渡 迁移期新旧同时写(订单状态流转类场景),对账脚本兜底 对账差异率 = 0 连续 5 天
5 拆除 旧应用流量归零,下线门面中的旧壳与代理 归档旧仓库,ADR 记录终态

轻订单应用:admin 是新建系统无需 Strangler;但「报表导出」由老 BI 系统(jQuery 时代)承载——迁移顺序:报表中心页(低流量)→ 客户报表 → 订单报表,BI 系统按步骤 2–5 逐步绞杀,预计两个季度完成。

🛠 渐进式实战练习

练习 1 · 基础(10–20 min):三个场景的「拆不拆」评审

任务描述:对下述三个场景分别给出「拆/不拆 + 理由 + 若拆用哪个方案」:

  • 场景 A:2 人团队维护的官网 + 博客,想拆成门户/博客两个微前端;
  • 场景 B:电商平台,交易中台团队(Vue)与营销插件团队(React)并行,营销活动页要嵌入商详页,双方独立发版;
  • 场景 C:银行核心系统 Vue 2 老后台 200+ 页面,团队决定逐年升级 Vue 3 并同时接入风控子系统(Java 后端渲染页)。

提示:回到 §21.1 清单数信号,别数愿望。

验收标准:每个场景给出结论、命中的适用/不适用信号条数、若拆则方案与隔离模式选择。

参考答案
  • 场景 A:不拆。命中不适用信号 3 条(团队小、无独立发版诉求、SEO/体积敏感)。用 MPA 或单仓多页即可。
  • 场景 B:拆。命中 ≥ 3 条(两团队、技术栈异构、独立发版)。营销页是「内容+活动交互」强隔离诉求 → micro-app iframe 模式或 wujie;通信只用初始化数据 + 少量事件。
  • 场景 C:拆,且必须 Strangler。命中国企型存量替换信号。方案上 Vue 2 老应用做子应用挂在新基座之下(qiankun 对旧栈支持成熟,或 wujie iframe 兼容性最好),风控页可整体 iframe——因为它是后端渲染页,隔离天然成立。

练习 2 · 进阶(45–60 min):micro-app 接入演练

任务描述:本地起两个 Vite 应用(qing-admin 基座 Vue 3.5、qing-marketing 子应用 Vue 3.5 模拟 React 子应用同样流程),完成:子应用嵌入基座 /mkt/*、基座下发用户数据、子应用上报一次 navigate 事件、主题切换从基座同步到子应用。

提示:子应用 base 设为 /mkt/;vite.server.origin 让 dev 资源可达;通信按 §21.5 的「初始化数据 + 事件」两板斧。

验收标准:

  • [ ] /mkt 路由下子应用正常渲染,直接访问 /mkt/anything 子应用 history 回退不 404;
  • [ ] 基座修改主题,子应用 1 秒内同步(经事件,不是各自读 localStorage);
  • [ ] 卸载子应用后,子应用注册的定时器/监听全部清理(Devtools Memory 与轮询日志验证)。
参考答案(事件同步主题的关键片段)
// 基座:themeStore 订阅变化并下发
import { watch } from 'vue'
import microApp from '@micro-zoe/micro-app'
import { useThemeStore } from '@/app/model/theme'

const theme = useThemeStore()
watch(() => theme.mode, (mode) => {
  microApp.setData('mkt', { theme: mode })
})
// 子应用:监听数据变化应用主题(API 名以官方文档为准)
microApp.addDataListener((data: { theme?: string }) => {
  if (data.theme) document.documentElement.dataset.theme = data.theme
})

清理验证法:子应用挂载一个 setInterval(() => console.log('poll'), 1000),卸载后若日志仍在输出即「卸载不清理」——修复方式是在子应用卸载钩子中 clearInterval(真实第三方应用则应改用 iframe 模式让其运行时随窗体销毁)。

练习 3 · 挑战(60+ min):公共依赖单例 + 部署整合

任务描述:在练习 2 基础上完成三件事:① 主子应用共享一份 Vue/Pinia(非 iframe 模式下验证单例),② 编写 §21.7 的 Nginx 配置并在 Docker 中联调通过,③ 产出《mkt 接入清单》契约文档(schema + 事件字典 + 发版窗口)。

提示:单例验证方法:基座 window.__QING_PINIA__ = pinia,子应用打印 useAuthStore 的实例 id 对比;或直接在共享 store 写入,两边同时观察响应。

验收标准:

  • [ ] 非隔离模式下共享 store 双端响应(证明单例);iframe 模式下说明为何此法失效;
  • [ ] docker compose up 后同域访问、SPA 回退、子应用回退全部可用;
  • [ ] 契约文档覆盖初始化数据 schema(含字段类型与必填性)与 ≥ 3 个事件的触发时机、方向、payload。
参考答案(契约文档骨架)
# mkt 子系统接入契约 v1.2
## 1. 初始化数据(基座 → 子应用,一次性下发)
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| accessToken | string | 是 | 有效期 2h,过期走 auth:refresh |
| userInfo | { id, name, roles: string[] } | 是 | 权限角色码见附录A §2 |
| theme | 'light' \| 'dark' | 是 | 变更经 data 通道增量下发 |
## 2. 事件字典
| 事件 | 方向 | payload | 触发时机 |
|------|------|---------|---------|
| auth:refresh | 子→基座 | 无 | 子应用收到 40102 |
| navigate | 子→基座 | { path: string } | 子应用需要跨应用跳转 |
| report:track | 子→基座 | { event, props } | 子应用埋点统一由基座上报 |
## 3. 发版窗口与兼容
- 基座每双周四发版;子应用自定节奏但 breaking 契约变更需提前 5 个工作日公告;
- 契约变更必须更新本文档并双方评审(第 22 章 PR 流程)。

⚠️ 常见易错点

易错点 1 · 为小团队上微前端:用架构炫技替代业务判断

❌ 错误做法:3 人团队 6 个页面,因为「微前端很潮」,把后台拆成基座 + 4 个子应用。结果:首屏多加载一套运行时、调试要在 5 个 tab 间横跳、一次跨模块改动要发 4 个包。三个月后团队默默把页面又合并回去,比不分层还乱。

✅ 正确做法:把 §21.1 清单当作硬门槛——适用信号 ≥ 3 条才立项,且必须由 ADR 记录命中的信号与放弃的备选(单仓多页、路由级拆分、monorepo 内模块化)。微前端解决的是「团队边界」,不是「代码组织」;后者用第 19 章的分层就够。

易错点 2 · 公共依赖没单例:两份 Vue 各自为政

❌ 错误做法:子应用完整打包自己的 vue 与 pinia,又在「共享状态」上与基座通信。现象诡异:基座改了用户信息,子应用读到的永远是旧值(两个响应式实例互不相识);内存里躺着两份运行时。

✅ 正确做法:明确隔离策略再谈共享——iframe 模式下主动放弃共享运行时,只走 §21.5 的数据/事件通道;非隔离模式下共享依赖必须 singleton: true 并版本协商,共享面收敛到「会话 + 主题 + 埋点」三件套。

易错点 3 · CSS 隔离想当然:Shadow DOM 一开,弹窗样式全丢

❌ 错误做法:给子应用套上 Shadow DOM 认为一劳永逸。子应用弹窗 Teleport 到 document.body——元素渲染在 Shadow 边界之外,所有组件样式失效,页面「裸奔」;或反向操作:基座全局 toast 样式渗进子应用。

✅ 正确做法:选隔离方案时把「浮层归属」作为一等公民设计——子应用浮层挂自己的挂载点(qing-ui 的 appendTo 参数)、基座级浮层由基座统一提供并通过事件调用;Shadow DOM 与 iframe 的弹窗适配成本先在 spike 里验证,再写进 ADR。

易错点 4 · 卸载不清理:切走子应用,轮询还在飞

❌ 错误做法:子应用在顶层作用域 setInterval 轮询活动数据、addEventListener 挂全局监听,卸载钩子是空的。用户在基座与子应用间反复切换,轮询请求成倍叠加,接口被打爆,监控(第 18 章)出现「切页越多越卡」的诡异曲线。

✅ 正确做法:子应用把「定时器、监听器、AbortController、订阅」统一登记到生命周期管理器,unmount 一次性清理;Review 清单把「卸载清理」列为子应用代码的必查项;对接不可控的第三方时直接选 iframe 模式,让窗体销毁替你清理。

易错点 5 · 全局事件总线滥用:通信退化成「全局变量 2.0」

❌ 错误做法:为了省事,基座与子应用之间用十几个自定义事件互抛:user:changed、order:updated、theme:dark……没有任何文档与类型,事件谁发的、谁在听全靠 grep。一次改名引发雪崩式静默失败。

✅ 正确做法:通信收敛到契约文档(练习 3 的《接入清单》):每个事件有唯一名、方向、payload 类型、触发时机,事件字典变更走双方评审;能用初始化数据一次下发的绝不发事件,能用 URL 传上下文的绝不共享状态。

易错点 6 · 路由双头驾驶:基座和子应用抢地址栏

❌ 错误做法:子应用保留自己的路由实例并直接操作 history.pushState,基座同时在监听路由变化做菜单高亮。结果:前进/后退时菜单高亮错乱、刷新后落在基座 404、偶尔出现「地址改了页面没动」的僵尸状态——两套路由对同一段 URL 各自解释。

✅ 正确做法:地址栏的唯一驾驶者是基座。子应用要么运行在基座路由的子树之下(激活规则即前缀,路由同步由微前端框架负责),要么像 §21.7 那样把跳转请求抛给基座代为执行;跨应用跳转一律「子应用 dispatch → 基座 push」,并在联调时把「刷新/后退/直达 URL」三种场景列入冒烟用例。

✅ 最佳实践

  1. 先评估后立项:「要不要拆」清单 ≥ 3 条信号才启动微前端,结论固化进 ADR,防止半年后无人记得动机;
  2. 拆业务边界不拆代码边界:子系统数量 = 协作团队数量,永远不为「看起来清晰」多拆一个壳;
  3. 隔离级别与信任级别匹配:第三方/不可信代码用 iframe 真隔离;自家团队跨栈协作可用 Proxy 沙箱 + 前缀纪律;
  4. 通信最小化并契约化:初始化数据 + 少量事件,全部落进《接入清单》,事件字典是 API 一样的公共契约;
  5. 会话与安全收口在基座:refreshToken 与无感刷新永远只在基座,子应用只拿短期 token,URL 永不传凭证;
  6. 路由与部署提前对齐:base、激活规则、Nginx 回退在 spike 阶段验证完再开工,部署问题绝不留到联调;
  7. 公共依赖三板斧:上翻(external/shared singleton)、协商(版本区间写入契约)、复用(qing-ui 令牌先行);
  8. Strangler 而非重写:存量迁移按「划界 → 立门面 → 逐页迁移 → 双写对账 → 拆除」推进,每步有验收与回退。

📌 本章小结

  • 微前端是组织问题的技术解:先过适用清单,命中 ≥ 3 条才立项,轻订单的结论是「只拆营销子系统这个边界」;
  • 四方案按「隔离机制 × 通信 × 成本 × 生态」选型:跨栈强隔离选 micro-app iframe / wujie,成熟生态选 qiankun,同栈模块共享选 Module Federation;
  • 隔离的真实成本藏在浮层、卸载清理与公共依赖单例里,这三处是 Review 的固定必查项;
  • 部署整合的核心是「路径即边界」:basename、激活规则、Nginx 回退三位一体;会话收口基座、契约文档化、存量用 Strangler 渐进绞杀。

下一章把视角从「代码架构」拉到「人」:分支模型、提交与发布纪律、Code Review 文化、跨职能契约——把本章的接入契约升级为整个团队的《前端团队协作手册》。

📄 第 22 章 · 团队协作规范 22-团队协作规范.md

📍 阶段:资深阶段(第 19–24 章)|⏱ 预计学时:7–9 小时|🔗 前置:第 17 章、第 19 章、第 21 章

⬅ 上一章 | 📖 本版目录 | ➡ 下一章

💡 对照学习:本页是 Vue 版第 22 章。React 版对应章节为 第 22 章 · 团队协作规范。本章方法两版通用,示例代码为 Vue 生态:分支模型、Conventional Commits、Review 文化、跨职能契约与框架无关,差异仅在工具链示例引用的仓库是轻订单 Vue 版。本章成果物《前端团队协作手册》可直接用于你的团队。

🎯 学习目标

学完本章,你将能够:

  1. 对比 trunk-based 与 Git Flow 的适用条件,结合发布节奏为团队选择并论证分支模型;
  2. 落地 Conventional Commits 规范(commitlint 门禁),并把提交历史与 changesets 版本发布(第 20 章)串成链路;
  3. 编写结构化 PR/MR 模板与自检清单,让「提 PR」这件事有固定的最小信息集;
  4. 建立 Code Review 的分层清单(阻断项 / 建议 / 口味项)与响应 SLA,识别常见坏味道;
  5. 设计文档沉淀体系(ADR / wiki / onboarding 手册),并给每类文档定义 owner 与生命周期;
  6. 与设计、后端、测试建立协作契约:设计交付物清单、OpenAPI 先行流程、Mock 平台约定;
  7. 输出一份可直接使用的《前端团队协作手册》骨架,并写出其中三节示范内容。

📖 核心知识点

22.1 分支模型取舍:没有最好,只有匹配

维度 trunk-based(主干开发) Git Flow GitHub Flow
长期分支 仅 main + 短命 feature 分支 main/develop/release/hotfix 多轨 main + feature 分支
上线频率 随时可发(依赖特性开关) 版本列车(发布窗口) 每合并即部署
并行版本维护 需要时拉 release 分支 原生支持 弱
团队门槛 高(要求 CI 门禁 + 测试文化成熟) 低(流程约束替代纪律) 中
适用 SaaS 持续交付(轻订单 admin) 有版本号交付的 toB 私有化部署 中小项目快速迭代

轻订单的选择:admin 与门户是 SaaS 持续交付 → trunk-based:短命分支(生命周期 ≤ 2 天)+ 特性开关(未完成的按钮/入口用权限点或开关隐藏)+ 每次合并自动部署 test 环境。私有化交付线保留 release/x.y 分支做双轨维护。把「为什么这样选」写成 ADR-0025(第 19 章 §19.8 模板),防止新成员挑战时无据可查。

trunk-based 的前提是「未完成的东西能安全地待在主干上」,这就是特性开关:

// src/shared/lib/featureFlags.ts —— 开关统一收敛,禁止散落 if 手写字符串
const FLAGS = {
  /** 批量指派销售:附录A §3.3,分两期上线,首期仅灰度超管 */
  'orders.batch-assign': { rollout: ['super_admin'], expireAt: '2026-12-31' },
  /** 报表中心新导出向导 */
  'reports.export-wizard': { rollout: [], expireAt: '2026-11-30' },
} as const

export type FlagKey = keyof typeof FLAGS

export function isFlagEnabled(flag: FlagKey, roles: string[]): boolean {
  const def = FLAGS[flag]
  if (Date.now() > Date.parse(def.expireAt)) return false  // 过期开关强制关闭,防止「僵尸开关」
  return def.rollout.some((role) => roles.includes(role))
}

两个纪律:① 每个开关必须有过期时间,到期强制关闭并提醒清理代码——永不删除的开关是技术债温床;② 开关判断只出现在入口处(菜单/路由/按钮),业务逻辑内部读到的应是已收敛的状态。

分支命名与生命周期纪律:

feat/order-batch-assign     # 功能:域-动作,生命周期 ≤ 2 天
fix/order-total-rounding    # 修复
chore/ci-node-upgrade       # 工程
release/2.3                 # 私有化版本轨(仅维护用,不带新功能)

22.2 Conventional Commits:提交信息是发布流水线的输入

提交信息不是「写给自己看的日记」,而是 CHANGELOG 与版本号的自动来源(第 20 章 changesets 消费的是 PR 标题/变更集,lint 链路里二者同源)。规范要点:

<type>(<scope>)!: <subject>

[body:为什么改,而非改了什么]

[footer:BREAKING CHANGE: 说明 / Closes #123]
type 含义 对版本的影响
feat 新功能 minor
fix 缺陷修复 patch
perf 性能优化 patch
refactor 重构(无行为变化) 无(仅 CHANGELOG 记录)
docs / test / build / ci / chore 文档 / 测试 / 构建 / 流水线 / 杂务 无
feat! 或 footer 中 BREAKING CHANGE: 破坏性变更 major

门禁配置(衔接第 17 章的 husky + lint-staged 链路):

// commitlint.config.mjs
export default {
  extends: ['@commitlint/config-conventional'],
  rules: {
    // scope 与轻订单业务域对齐(附录A §3),新业务域需改这里而不是随手编
    'scope-enum': [2, 'always', [
      'auth', 'orders', 'customers', 'products', 'reports',
      'dashboard', 'settings', 'ui', 'infra',
    ]],
    'subject-case': [2, 'never', ['start-case', 'pascal-case', 'upper-case']],
  },
}
// package.json(节选)
{
  "scripts": {
    "prepare": "husky"
  }
}
# .husky/commit-msg —— 提交信息门禁
pnpm exec commitlint --edit "$1"

配套要求:一次提交一个意图。feat(orders): 批量指派销售 里不应该混着格式化半个仓库的 diff——后者用 chore: format 单独提交(或根本不该发生在你的分支里,lint-staged 已在提交前处理)。

提交信息与第 20 章 changesets 的联动关系(同一事实的两个消费端):

PR 标题/changeset ──┬──▶ commitlint 门禁(合入前拦截)
                    ├──▶ .changeset/*.md ──▶ changeset version ──▶ CHANGELOG + 版本号
                    └──▶ git log ──▶ release notes ──▶ 追溯到 PR

22.3 PR/MR 模板:合并前的最小信息集

PR 的读者是两类人:今天的 reviewer 与三个月后考古的你。模板固定五块:

<!-- .github/pull_request_template.md -->
## 变更类型
- [ ] feat / fix / perf / refactor / chore(scope:____)

## 背景 & 方案
<!-- 为什么改?一句话结论 + 关联文档:需求单 / ADR / 技术方案链接 -->

## 关联
Closes #123 | 设计稿:〈占位链接〉 | 方案:〈占位链接〉

## 自测证据
- [ ] 第 16 章冒烟集通过(影响主流程时必勾)
- [ ] 新逻辑有单测 / 修改了既有断言并说明原因
- [ ] 性能敏感变更附前后数据(列表/首屏类需求必勾)

## 风险与回滚
<!-- 影响面、灰度策略、回滚方式(第 18 章软链回滚)-->

尺寸纪律:单 PR diff ≤ 400 行(生成代码除外)。超过就拆——按层拆(API 层先行、UI 随后)或按开关拆两段合并。Review 研究共识是 PR 越大发现问题越少,400 行是质量与效率的实践平衡点,写进手册并允许声明式豁免(如锁文件、生成代码)。

22.4 Code Review:分层清单与 SLA

Review 分三层,避免「什么都想说」导致重点淹没:

层级 内容 处理方式
阻断(Must) 正确性、安全(凭据/注入/XSS)、性能回退、分层依赖破坏(第 19 章门禁之外的人眼部分)、测试缺失 必须修改后重审
建议(Should) 命名、可读性、更优实现、复用机会 作者应回应:采纳或说明理由
口味(Nit) 纯风格偏好(nit: 前缀标注) 不阻塞合并,作者自行裁量

阻断项核对清单(reviewer 逐条过,其他层自由发挥):

  1. 改动是否做了它声称的事?描述与 diff 一致吗?
  2. 边界条件:空列表(订单列表空态)、并发提交(防重复提交)、金额单位是「分」(附录A 约定);
  3. 安全:无硬编码凭据、外部输入已校验、富文本已消毒;
  4. 架构:没有跨层 import、没有「被 import 即执行」的副作用(第 19 章 §19.6);
  5. 测试:新增逻辑有对应断言,断言面向用户可见行为(第 15 章纪律);
  6. 可回滚:发布策略与风险声明是否现实。

SLA 与文化:

  • 响应时限:工作时间内 24 小时给出首次反馈;阻断项越少反馈越快,nit 可以攒着说;
  • 对事不对人:评论对象是代码(「这里在并发下会重复提交」),不是人(「你怎么又忘了」);
  • 批准权与责任对等:approve 即共担质量责任,不允许「不看代码直接过」——抽查发现一次,取消当月 review 资格;
  • 分歧升级路径:reviewer 与 author 讨论 2 轮未决 → 拉架构负责人仲裁 → 结论写进 ADR(若属规则变更)。

22.5 Review 坏味道清单

reviewer 的注意力要花在刀刃上。高频坏味道与一句话判定:

坏味道 一句话判定 正确方向
魔法数字/字符串 if (status === 2)——2 是什么? 状态枚举收敛到 entities/order 类型
隐藏副作用 composable 里悄悄注册全局监听 副作用显式化 + 清理(第 04/19 章)
复制粘贴复用 两个页面 80% 相同的筛选表单 抽 widget/composable,吃自己的狗粮
过度抽象 只有一个实现却做了三层接口 等第二个消费方出现再抽(YAGNI)
断言实现细节 测试断言内部状态而非可见行为 回归第 15 章「用户中心」测试
提交 message 垃圾 fix bug、update、修改 commitlint 门禁直接拦截
巨型组件 单文件 > 500 行且混合多职责 按 §19.2 拆分,UI 与逻辑分层

22.6 文档沉淀:让知识活过人员的流动

文档类型 存放 Owner 生命周期
ADR 仓库 docs/adr/(随代码走 PR) 决策发起人 只增不改状态(第 19 章 §19.8)
协作契约 docs/contracts/(第 21 章接入清单) 平台负责人 变更走双方评审
Wiki 页面(流程类) 团队 wiki:环境搭建、发布 SOP、告警响应(第 18 章 SOP) 流程负责人 每季度巡检一次,过期即删
onboarding 手册 wiki 固定入口 团队负责人 每个新人入职后更新一轮

onboarding 手册的验收标准:一个新人在无人工协助下,按文档在第一天完成环境搭建与本地跑通、第二天提交第一个 PR、第一周上线一行代码。做不到就是文档有洞——新人卡住的每个点都是文档欠债。

配套的「新人首周任务卡」(随手册附发,导师按卡打钩):

Day 1  环境搭建(Node/pnpm/VS Code 插件清单)→ 本地跑通 admin + 门户 → 登录 demo@qingorder.example.com
Day 2  阅读 00-导读 + 协作手册 → 提交第一个 PR(docs 类:修正手册中发现的一处过时内容)
Day 3  跟走一遍第 16 章冒烟集(本地跑 E2E)→ 认领一个 good-first-issue
Day 5  第一个线上变更:一行文案或一个令牌调整,完整走「PR → Review → 灰度 → 验证」

docs 类首 PR 是精心设计:新人为了改对文档必须通读手册,导师则从 PR 评论里第一时间发现文档漏洞——一举两得。

文档纪律两条:① 谁写的谁维护到「下一个 owner」交接为止,杜绝「离职即失传」;② 文档过时比没有更危险,季度巡检时直接删除失效内容而非「再看看」。

22.7 跨职能契约:与设计/后端/测试的接口

与设计(对照附录A 视觉与权限模型):

契约项 约定
交付物 组件级设计稿(状态齐全:默认/悬停/禁用/加载)+ 令牌值表(颜色/间距/字号对应 qing-ui 令牌名)
命名 设计图层名与组件/令牌同名(Button/Primary 对 QingButton variant=primary)
走查 上线前设计走查一轮,偏差记录进 issue 并分「必须修/可接受」两档

与后端:

  1. 契约先行:接口先出 OpenAPI 文档再开发,前端据此生成类型(第 17 章)与 Mock;字段变更必须改文档并通知,禁止「改代码不改文档」;
  2. 错误码语义:code 语义以附录A §5 为唯一口径(40102 无感刷新、40301 跳 403 页),新增错误码走联合评审;
  3. Mock 平台:契约文档导入 Mock 平台生成数据,前端联调不再依赖「后端接口今天好了没」;分页响应 data.list/total/page/pageSize 形状与附录A 完全一致。

契约先行的完整闭环:

后端改契约(PR 到契约仓库)           前端消费
──────────────────────          ─────────────────────
OpenAPI diff PR ──评审──▶ 合并    │
        │                       ▼
        ├─▶ Mock 平台数据更新 ──▶ 前端联调(不依赖后端进度)
        ├─▶ 类型再生成(openapi-typescript)──▶ 前端类型检查门禁发现「用到被改字段」即红
        └─▶ 变更广播(PR @ 相关前端)

关键收益:字段改名这类破坏,在类型门禁处被拦截(第 17 章的生成类型直接编译报错),而不是上线后靠用户反馈发现。

与测试:

  1. 提测标准:自测用例通过 + 冒烟集绿 + 数据准备脚本可用,缺一不提测;
  2. 缺陷单模板:环境、账号角色(sales/finance 等)、复现步骤、期望/实际、录屏;「复现不了」的缺陷单退回补充;
  3. 回归口径:P0 主流程(登录 → 下单 → 审批 → 导出)每版本必跑(即第 16 章 E2E 冒烟集)。

22.8 产出:《前端团队协作手册》骨架

所有约定收敛成一本手册(仓库 docs/handbook.md 或 wiki 专区),新人入职第一天必读。骨架:

# 前端团队协作手册(v1.0 · Owner:前端负责人)

## 1. 日常开发流程
  1.1 分支模型与命名(ADR-0025 结论:trunk-based + 特性开关)
  1.2 提交规范与门禁(Conventional Commits / commitlint)
  1.3 PR 流程与自检清单(≤400 行纪律、模板五块)
  1.4 环境与多环境配置(dev/test/pre/prod,第 18 章)

## 2. 代码质量约定
  2.1 技术基线(Vue 3.5 / TS strict / Pinia / 六层结构,第 19 章)
  2.2 测试要求(覆盖率阈值 80%、断言面向行为,第 15 章)
  2.3 性能与安全红线(首屏预算、凭据零字面量)
  2.4 Code Review 规范(三层清单、SLA、坏味道对照表)

## 3. 协作契约
  3.1 与设计:交付物与走查(§22.7)
  3.2 与后端:OpenAPI 先行与 Mock(§22.7)
  3.3 与测试:提测标准与缺陷单(§22.7)
  3.4 与其他前端团队:子系统接入契约(第 21 章《接入清单》)

## 4. 知识与决策
  4.1 ADR 流程与目录(docs/adr/)
  4.2 onboarding 手册(新人 1-1-1 计划:1 天环境 / 1 个 PR / 1 行上线)
  4.3 季度文档巡检(Owner 轮值表)

## 5. 应急响应
  5.1 线上告警分级与响应 SOP(第 18 章 P0–P2)
  5.2 回滚操作(软链切换)与事后复盘模板

示范节 1.1(分支模型):

本团队采用 trunk-based:所有工作在短命分支(≤ 2 天)进行,合并即部署 test。未完成功能一律隐藏在特性开关之后(权限点 feature:xxx 或配置开关),开关清理随删除代码一起走 PR。私有化版本使用 release/x.y 分支双轨维护,只接收 fix cherry-pick。为什么不用 Git Flow:SaaS 持续交付下双主干带来的合并成本高于其收益(详见 ADR-0025)。

示范节 2.4(Review 规范):

Review 三层:阻断 / 建议 / 口味(nit: 前缀)。阻断项六条核对清单见 §22.4,reviewer 必须逐条确认后才能 approve;SLA 为工作时间内 24 小时首次响应。对事不对人:评论指向代码行为而非作者;引用规范时给出文档链接(手册章节或 ADR 编号)而非个人偏好。分歧 2 轮未决升级架构负责人,规则类结论沉淀为 ADR。

示范节 3.2(后端协作):

接口契约先行:后端在编码前提交 OpenAPI 文档变更(PR 到契约仓库),前端基于文档生成类型与 Mock 并行开发。字段重命名视为 breaking,需双方确认迁移窗口;code 错误码语义以附录A §5 为准,新增错误码在联调前联合评审。前端对契约的偏离(如「顺手改个字段名」)一律视为缺陷。

🛠 渐进式实战练习

练习 1 · 基础(15–25 min):提交规范整改

任务描述:为轻订单仓库接入 commitlint 门禁(scope 枚举按 §22.2),然后用 git log --oneline 检查最近 50 条提交,产出一份「违规提交清单」,并为其中 5 条改写成规范形态(不改历史,练习改写能力)。

提示:注意 feat! 与 BREAKING CHANGE footer 的写法区别;scope 不在枚举里的提交是「合法但不合格」,想想为什么 scope 枚举要有边界。

验收标准:

  • [ ] pnpm exec commitlint 对不规范 message 报错、对规范 message 通过(双向验证);
  • [ ] 违规清单含类型分布统计(如 fix bug 类占 X%);
  • [ ] 5 条改写覆盖:普通 feat、带 scope 的 fix、breaking change、带 issue 关联、纯 chore。
参考答案(5 条改写示例)
原:update 订单相关代码
改:feat(orders): 订单列表支持按销售归属筛选

原:fix bug
改:fix(orders): 修复金额汇总浮点误差(单位改为分后求和)

原:改了权限,老的不能用(重要!)
改:feat(auth)!: 权限判断改为权限点模型

    BREAKING CHANGE: v-permission 指令参数从角色名改为权限点 code,
    迁移指南见 docs/adr/0033-permission-refactor.md

原:订单导出
改:feat(orders): 批量导出选中订单为 CSV

    Closes #456

原:格式化
改:chore(infra): 应用 prettier 至 src/views(无逻辑变更)

练习 2 · 进阶(30–60 min):PR 模板落地 + 模拟评审

任务描述:为仓库配置 §22.3 的 PR 模板与分支保护规则(require PR、≥1 approve、CI 必过);然后拿一段真实历史代码(或第 19 章练习产物)发起一个模拟 PR,用三层清单完成一次完整评审,输出评审意见并标注层级(阻断/建议/口味)。

提示:刻意在模拟 PR 里埋 3 类问题(一个阻断、一个建议、一个口味),检验清单是否真能分层拦截而不是「全挑格式」。

验收标准:

  • [ ] 保护规则生效:无 approve 或 CI 红时无法合并(截图或描述证据);
  • [ ] 评审意见每条标注层级,阻断项给出修改方向与文档出处;
  • [ ] 自查:本次评审中「口味类」占比是否 ≤ 30%——超过说明清单分层失效。
参考答案(模拟评审意见示例)
## 评审意见(模拟 PR:订单列表筛选优化)

🔴 阻断 1(正确性):筛选参数未做竞态处理,快速切换状态会旧响应覆盖新响应。
   → 参考:第 09 章 AbortController 竞态方案 / `useOrderList` 现成实现。

🔴 阻断 2(安全):`localStorage.setItem('qo_debug_token', token)` 调试代码残留。
   → 凭据零字面量,删除;如需调试走环境变量开关。

🟡 建议(复用):状态筛选下拉的选项数组在两处重复定义。
   → 收敛到 `entities/order/model/statusOptions`,第二个消费方已出现,允许抽象。

⚪ 口味:`getOrderList` 参数解构顺序建议与类型定义一致。(不阻塞)

练习 3 · 挑战(60+ min):产出团队《协作手册》

任务描述:按 §22.8 骨架产出适配你团队的手册 v1.0:目录 + 全部小节的 1–3 句要点 + 三节完整示范内容(分支模型、Review 规范、后端协作任选其二 + 自选一节);并设计「手册巡检机制」(谁、何时、怎么发现过时内容)。

提示:不要复制本教程原文——每条规范后面写上「为什么」(引用 ADR 或事故复盘),手册的价值在理由而非条款;找不到理由的条款直接删掉,那是伪需求。

验收标准:

  • [ ] 覆盖骨架全部五部分,每条规范可追溯到 ADR/事故/门禁工具三者之一;
  • [ ] 三节示范内容含「不这样做的后果」;
  • [ ] 巡检机制含 Owner 轮值与失效条目处置规则(改/删)。
参考答案(巡检机制设计要点)
  1. 轮值表:每季度一位「手册管家」(新人优先——新人正是最依赖文档的人,且发现漏洞最敏感);
  2. 触发式更新:任何 ADR 合并后,手册管家检查是否需同步手册对应章节(写进 PR 模板自检清单);
  3. 失效处置:巡检发现的过时条目,当场删除或改写,不允许标「待更新」悬挂——「待更新」是文档坟场的起点;
  4. 反馈通道:手册每节页脚放「本条是否有帮助/已过时?」轻量反馈链接,反馈直接生成 issue 给管家。

⚠️ 常见易错点

易错点 1 · Code Review 只挑格式:人干了机器的活

❌ 错误做法:reviewer 逐行点评「这里少个空格」「分号不一致」「建议变量名再短点」,真正的并发缺陷、越权漏洞、竞态问题从眼皮底下溜走。作者学会的只有「格式要注意」,规范靠人肉执行也永远执行不齐。

✅ 正确做法:格式问题零人肉——Prettier/ESLint/commitlint 门禁(第 17 章)在提交与 CI 阶段拦截,reviewer 看到 diff 时机器问题已清零;review 精力按 §22.4 三层清单分配,阻断项是唯一必查集。检验标准:一次评审意见里出现 3 条以上格式类,就是 reviewer 失职。

易错点 2 · 长期分支巨 PR:两周的活一次合并

❌ 错误做法:一个功能开发两周不合并,最后甩出 1800 行的 PR。reviewer 面对「 approve 还是自己读两周」的绝望选择,通常选择前者;半年后出问题,git blame 指向一个谁也不理解的巨提交。

✅ 正确做法:特性开关 + 小步提交——功能骨架先合并(开关隐藏)、按「API 层 → 逻辑层 → UI」拆 PR 推进;单 PR ≤ 400 行写进手册;确需大 PR(如目录重构)时提前在周会预约 reviewer 时间,并附「结构说明」降低阅读成本。

易错点 3 · 文档沦为坟场:写了没人养,养了没人信

❌ 错误做法:wiki 里 2019 年的「环境搭建」还在教 npm 3、CNPM 镜像配置早已失效,新人按文档走到黑然后口口相传「文档别看,直接问老王」——知识重新锁回个人脑子里,文档体系名存实亡。

✅ 正确做法:每份文档有 Owner 与生命周期(§22.6),季度巡检「当场删改、不许悬挂待更新」;onboarding 手册用「新人 1-1-1」验收(1 天环境、1 个 PR、1 行上线),新人卡点即文档欠债,修复责任写进入职流程。

易错点 4 · 契约靠口头:后端改字段,前端上线才炸

❌ 错误做法:「那个字段后端说改成 orderNo 了,谁通知你了?」——契约存在于聊天记录里,OpenAPI 文档停留在项目启动时的版本。前端按旧契约联调通过、上线后字段丢失,事故定级会议变成甩锅大会。

✅ 正确做法:OpenAPI 文档进契约仓库走 PR 评审,变更必通知且生成 diff;前端类型与 Mock 全部由契约生成(第 17 章),人工「同步」这一步从流程中消失;错误码语义(附录A §5)作为联合评审的常设议题。

易错点 5 · 规范只有禁止没有理由:手册变成「不许做」大全

❌ 错误做法:手册写着「禁止在组件里发请求」「禁止超过 400 行」,没有为什么。新人要么盲目遵守(遇到合理例外不会变通),要么挑战规范(因为不知道碰坏了什么)。规范与执行渐行渐远。

✅ 正确做法:每条规范附「为什么 + 事故/数据出处」与「例外申请路径」(在 ADR 里写清场景即可豁免);规范定期按「是否仍有理由存在」修剪——手册的权威来自理由的正确,不来自条款的数量。

易错点 6 · 评审语气失控:对代码开火变成对人开火

❌ 错误做法:评论写「这代码写得跟屎山一样」「我都说了三遍了你到底看不看文档」。作者要么防御性争执(精力耗在情绪),要么表面服从暗中抵触,最终结果是:敢于发 PR 的人变少、review 变成走过场。技术文化的崩坏往往从一条刻薄的评论开始。

✅ 正确做法:评审评论遵循「现象 → 影响 → 建议 → 出处」四段式,主语永远是代码不是人。对照改写:

❌「你怎么又把金额计算写在组件里?说多少遍了!」
✅「现象:calcOrderTotal 在 OrderCard 组件内部实现。
   影响:报表中心需要同一逻辑时只能复制(上季度对账差异事故的根因之一)。
   建议:移入 entities/order(第 19 章 §19.2 分层职责)。
   出处:协作手册 2.1 / ADR-0026。」

同样,作为被评审者,把每条意见当作「免费的设计咨询」而非「对能力的审判」——回应意见时先复述对方关切(「担心的是重复实现对吧」),再给采纳或替代方案,分歧按 §22.4 升级路径处理。

✅ 最佳实践

  1. 分支模型匹配交付形态:SaaS 用 trunk-based + 特性开关,版本交付用 release 轨,选择写进 ADR;
  2. 提交信息是流水线数据:Conventional Commits + scope 枚举 + commitlint 门禁,CHANGELOG 与版本号自动生长;
  3. PR 是沟通文档:模板五块(类型/背景/关联/自测证据/风险回滚)+ 400 行纪律,考古价值与评审效率兼得;
  4. Review 三层分级:阻断必查、建议应答、口味自由,SLA 24 小时,approve 即共担责任;
  5. 契约先行:设计交付物对齐 qing-ui 令牌、后端 OpenAPI 走 PR、测试提测三条标准——跨职能协作靠契约不靠默契;
  6. 文档有 Owner 有生命周期:新人 1-1-1 验收 onboarding 手册,季度巡检当场删改;
  7. 规范必须机器守门:能进 lint/CI 的规范不写在手册里重复要求,手册只讲「机器管不了的判断题」;
  8. 手册链接事故与 ADR:每条规范可追溯出处,让团队相信规则、而非背诵规则。

📌 本章小结

  • 协作规范的本质是把信任成本从人转移到系统:分支模型、提交门禁、PR 模板、Review 分层清单层层设卡,人只处理机器处理不了的判断;
  • Conventional Commits 是「提交 → CHANGELOG → 版本发布」链条的起点,与第 20 章 changesets 首尾相接;
  • Review 的价值在阻断正确性/安全/架构问题,格式与风格交给机器,三层清单 + SLA + 对事不对人是文化的三根支柱;
  • 跨职能靠契约:设计交付物、OpenAPI 先行、提测标准全部文档化并有评审流程,最终收敛为《前端团队协作手册》。

下一章进入技术选型决策:当「新业务线要用什么技术栈」「这个库能不能引入」的问题摆上桌,你将拥有一套 8 维度加权评估框架、三个真实选型案例与一份可套用的选型报告模板——并学会对「star 数」免疫。

📄 第 23 章 · 技术选型决策 23-技术选型决策.md

📍 阶段:资深阶段(第 19–24 章)|⏱ 预计学时:7–9 小时|🔗 前置:第 19 章、第 20 章、第 22 章

⬅ 上一章 | 📖 本版目录 | ➡ 下一章

💡 对照学习:本页是 Vue 版第 23 章。React 版对应章节为 第 23 章 · 技术选型决策。本章方法两版通用,示例代码为 Vue 生态:8 维度评估框架、自研 vs 开源决策树、技术债管理与 Strangler 迁移与框架无关;§23.3 的 Vue vs React 对比表则力求公平——不贬低任何一方,只给适用条件。业务基准:附录A。

🎯 学习目标

学完本章,你将能够:

  1. 用「业务匹配 / 生态成熟度 / 团队现状 / 招聘 / 成本 / 风险 / 退出成本 / 时间窗」八个维度对候选技术做加权评分,并输出可评审的评分表;
  2. 以事实与适用条件(而非立场)陈述 Vue 与 React 的差异,并说明「在什么约束下选哪个」;
  3. 完成状态库、组件库、请求库三个真实选型案例的完整推演;
  4. 用决策树判断「自研 vs 开源 vs 购买」,并说出防腐层(Adapter)在退出成本中的作用;
  5. 建立技术债台账,用「本金 + 利息」模型向管理层陈述债务,并制定偿还策略;
  6. 用 Strangler 模式(衔接第 21 章)制定 Vue 2 → Vue 3 等渐进迁移路线;
  7. 输出一份可直接套用的「新业务线技术选型报告」。

📖 核心知识点

23.1 选型的本质:约束下的最优点

选型失误的真实成本不是「写错代码的返工」,而是三笔账:迁移成本(换库要改多少调用点)、机会成本(团队本可做业务的时间)、组织成本(招聘与跨团队对齐)。因此选型的正确姿势不是「找最好的」,而是「在约束下找最合适的」:约束包括团队已会什么、业务要活多久、上下游是什么、错了能不能退。

先立三条原则:

  1. 先有结论的评分都是证明题:评分表在动笔前不允许预设立场,权重在评估候选之前确定并记录;
  2. 权重因业务而异:中后台内部系统与 C 端高并发产品,同一张表的权重完全不同;
  3. 选型报告 = 决策依据 + 退出方案:没有退出方案的选型是赌博。

23.2 八维度加权评分框架

维度 关键问题 常用证据
业务匹配度 核心场景覆盖率?缺的能力补起来多贵? 功能对照表、 spike 验证
生态成熟度 发版频率、issue 响应时长、breaking 历史、周边插件 changelog、issue 平均关闭时长、贡献者分布
团队现状 团队已会什么?学习曲线多陡? 技能盘点(不是简历盘点)
招聘 市场上会不会?接手成本? 招聘 JD 数据、社区体量
成本 License 费用、构建/运行开销、维护人力 License 条款、bundle 实测
风险 单点维护风险?供应链风险?许可证变更? 维护者数量、依赖树审计
退出成本 半年后不满意,换掉它要改多少代码? 调用点数量、是否被间接耦合
时间窗 现在引入 vs 一年后引入,哪个更划算? 团队 roadmap、业务节奏

评分表模板(权重和为 100%,每维 1–5 分,加权求和):

## 选型评分表:〈问题域〉(日期 / 评估人)

| 维度 | 权重 | 候选 A 得分 | 候选 B 得分 | 评分依据(证据链接) |
|------|------|------------|------------|---------------------|
| 业务匹配度 | 25% |  |  |  |
| 生态成熟度 | 15% |  |  |  |
| 团队现状 | 20% |  |  |  |
| 招聘 | 5%  |  |  |  |
| 成本 | 10% |  |  |  |
| 风险 | 10% |  |  |  |
| 退出成本 | 10% |  |  |  |
| 时间窗 | 5%  |  |  |  |
| **加权总分** | 100% |  |  |  |

## 一票否决项(不参与加权,命中即出局)
- [ ] License 不合规(GPL 类污染商用闭源)
- [ ] 无退出方案
- [ ] 与现有安全基线冲突(如强制上传 sourcemap 到第三方)

## 结论与退出方案
……

评分框架的五步使用流程(顺序不可倒):

  1. 定权重:结合业务形态确定八维度权重并记录在案(此时还不知道候选是谁的强项);
  2. 列一票否决项:License、安全基线、退出方案等硬红线先行;
  3. 独立取证:候选的证据由不同成员分别收集(含 spike),避免同一人的确认偏误;
  4. 打分合议:分歧维度必须落成证据讨论而非口才对决;
  5. 决策归档:结论走 ADR(第 19 章 §19.8),评分表作为附件链接进去——半年后重估时,改的是「事实变化」的那一格。

23.3 Vue vs React:一场必须公平的对比

这是双栈团队绕不开的问题。对照表给出事实,结论永远取决于约束:

维度 Vue 3 React 19 说明
心智模型 响应式驱动:依赖追踪,改数据自动更新 重渲染驱动:setState 触发函数重跑,靠 memo 化控粒度 前者「改了什么就更新什么」,后者「跑一遍再 diff」——排障思路因此不同
模板 vs JSX 模板约束强、静态分析友好、上手快 JSX 即 JS,表达力自由、与 TS 融合更自然 模板降低新手门槛,JSX 降低「转译思维」成本
TS 支持 编译宏 + 泛型组件,strict 可用 类型系统原生亲和,生态类型普遍更细 React 生态的第三方库类型完备度整体略占优
状态与服务端态 Pinia(官方)+ 请求层自研或 TanStack Query Vue 适配 Redux Toolkit / Zustand + TanStack Query 双方都有成熟答案,无决定性差异
大厂背书与招聘 国内中后台岗位量大,Vue 团队供给充足 全球与一线大厂占比高,跨栈流动性强 按所在市场看招聘数据,不看社区情绪
长期维护风险 尤雨溪团队 + 开放治理,Vue 2 EOL 教训已消化 Meta 内部持续使用 + React 团队专职维护 双方都是低风险,差异不构成决策依据
与本教程的关系 本教程 Vue 版基线(3.5 + Pinia + Router 4) React 版基线(19 + RTK/Zustand) 两版方法互为对照,团队双栈切换成本可控

决策句式(背下来,评审会上直接用):「在 A 约束(团队现状/招聘市场/存量代码)与 B 目标(交付节奏/性能目标/组织对齐)下,选 X;若约束变为 Y,则结论应当重估。」——不给立场只给条件,是资深工程师与框架信徒的分界线。

双栈团队的共存策略(第 21 章的微前端之外还有轻量解法):

策略 做法 适用
按应用分栈 新产品线用 React、存量用 Vue,互不渗透 产品边界清晰、无强复用诉求
单应用双栈 同一 SPA 里 Vue + React 混用(micro-app 局部挂载) 过渡期特殊场景,成本高,须 ADR 立项
领域层共享 框架层各选各的,但领域类型、API 客户端、工具函数(第 19 章的 packages)框架无关共享 默认推荐——把「栈」的分歧隔离在 UI 层

轻订单的原则即第三条:qing-types、qing-api-client、qing-utils 无框架依赖,Vue 应用与未来的 React 子系统(第 21 章)都消费同一份领域资产。

23.4 三个选型案例

案例一:状态库(轻订单 admin 实际发生,结论见 ADR-0017)

维度(权重) Pinia Vuex 4 纯 composables
业务匹配度(25%) 5 4 3(缺 Devtools 与插件)
生态成熟度(15%) 5 4 4
团队现状(20%) 5(组合式 API 同构) 2(需学 mutation 心智) 4
招聘(5%) 4 4 3
成本(10%) 5 5 5
风险(10%) 5(Vue 官方推荐位) 3(官方已建议新项目用 Pinia) 4
退出成本(10%) 4(store 调用面收敛) 3 5
时间窗(5%) 5 2 3
加权 4.75 3.35 3.60

案例二:组件库基座(第 20 章 qing-ui 的上游选择)

候选 一句话评估 适用条件
Element Plus 生态最全、中文文档佳、与 qing-ui 二次封装兼容 默认选项,中后台通吃
Naive UI TS 体验一流、主题系统优雅 团队 TS 强、深度定制主题诉求高
Ant Design Vue 设计规范强、与 Ant 系(React 侧兄弟库)对齐 跨栈团队视觉统一诉求高
纯自建 完全可控 团队 ≥ 5 人且有多产品长期共享(即便如此基础组件也应封装上游)

结论模式:基座(Element Plus)+ 包装层(qing-ui),把「换基座」的可能保留在 qing-ui 内部——退出成本被防腐层锁死(见 §23.5)。

案例三:请求库(第 09 章「自研封装 vs TanStack Query」)

维度(权重) 自研 useRequest TanStack Query(Vue 适配) SWRV
业务匹配度(25%) 4(分页/竞态/防抖已覆盖) 5(缓存失效/乐观更新/重试全送) 4
生态成熟度(15%) 2(自担维护) 5 4
团队现状(20%) 5 3(需学 query key/失效模型) 4
招聘(5%) 3 5 3
成本(10%) 5 4(包体积 +13KB gzip 级别,可接受) 5
风险(10%) 3(边界情况靠踩坑补齐) 4 3
退出成本(10%) 5 4(composable 封装隔离) 4
时间窗(5%) 5 4 4
加权 3.95 4.35 3.90

决策细节比总分更重要:轻订单 v1 保持自研封装(学习成本与交付节奏优先),v2 报表与工作台的数据缓存诉求激增后按本表切换 TanStack Query——同一张表在不同时间窗会给出不同结论,这正说明权重有效。

案例四:构建工具(为什么本教程从第 01 章起默认 Vite)

维度(权重) Vite 6 webpack 5 Rspack
业务匹配度(25%) 5(SPA/库模式/SSR 生态齐) 5(能力最全,微前端老将适配多) 4(兼容 webpack API,少数插件未覆盖)
生态成熟度(15%) 5(Vue 官方默认) 5(十年沉淀) 3(快速成长中)
团队现状(20%) 5(本教程 01/17 章基线) 3(配置心智重) 3
招聘(5%) 5 4 2
成本(10%) 5(dev 秒启,按需编译) 2(冷启动与 HMR 随体量恶化) 5(Rust 提速)
风险(10%) 4 4 3
退出成本(10%) 3(vite.config 生态绑定) 5(可平移到 Rspack) 5(本身就是 webpack 平替)
时间窗(5%) 5 2 4
加权 4.65 3.95 3.60

结论句式示范:「在团队以 Vue 中后台为主、追求开发体验与简单配置的约束下,新项目选 Vite;存量 webpack 大仓(尤其已深度集成 Module Federation 微前端)继续留在 webpack,性能瓶颈出现时评估平移 Rspack——平移成本远低于改写 Vite 配置。」注意退出成本列反向发挥了作用:它是webpack 得分高于 Vite 的维度,证明评分表不会一边倒,每个结论都能被质询。

23.5 自研 vs 开源:决策树与防腐层

该能力是否是产品核心差异化?
├── 是 ──▶ 团队是否有 ≥ 1 人力长期维护?
│         ├── 是 ──▶ 可自研(写 ADR,约定评估节点)
│         └── 否 ──▶ 开源 + 深度封装(防腐层)
└── 否(通用能力:请求、图表、表格……)
          ▼
   开源方案匹配度 ≥ 80%?
   ├── 是 ──▶ 直接用开源,禁止内部 fork
   └── 否 ──▶ 开源 + 包装层补齐缺口
              └── 缺口 < 20% 仍无法满足?
                        └── 换一个开源,而非自研

防腐层(Adapter)是退出成本的保险丝:业务代码只依赖自己的封装,不直接 import 第三方 API。

// src/shared/chart/echarts.adapter.ts —— 业务永远依赖 adapter,不依赖 echarts 本体
import type { EChartsOption } from 'echarts'

export interface TrendChartInput {
  dates: string[]
  orderCounts: number[]
  gmvCents: number[]      // 单位:分(附录A 约定)
}

export function toTrendOption(input: TrendChartInput): EChartsOption {
  return {
    xAxis: { type: 'category', data: input.dates },
    yAxis: [
      { type: 'value', name: '订单量' },
      { type: 'value', name: 'GMV(元)', axisLabel: { formatter: (v: number) => (v / 100).toFixed(0) } },
    ],
    series: [
      { type: 'line', data: input.orderCounts },
      { type: 'line', yAxisIndex: 1, data: input.gmvCents.map((c) => c / 100) },
    ],
  }
}

代价是约 10% 的封装工作量,换来「echarts → chart.js」迁移时业务代码零改动——对图表这类重资产依赖,这笔保险值得买。

23.6 技术债管理:台账与利息

技术债不是贬义词,是有息负债:为了交付节奏故意欠下的(有意识债)必须记账并计划偿还;无意欠下的(设计缺陷、知识流失)更要尽早显形。

台账字段(放 docs/tech-debt.md,纳入季度评审):

字段 示例(轻订单真实项)
编号/描述 TD-014 · 订单列表筛选参数未做竞态防护(第 09 章前的旧代码)
类型 有意识债(当时赶 demo 窗口)
本金 重构 + 回归测试 ≈ 3 人日
利息 每次新筛选功能都要手工排查竞态 ≈ 0.5 人日/次
触发线 利息累计 > 本金 × 2 或出现一次相关 P1 事故时强制偿还
状态 偿还中(PR #891)

再补两条不同形态的示例(说明台账不是只记代码债):

编号/描述 类型 本金 利息 触发线
TD-009 · 客户搜索下拉未用虚拟列表,2 万客户时卡顿(第 13 章同款问题在另一处复发) 无意债 2 人日 每次客诉排查 ≈ 0.5 人日/次 第 3 次客诉即偿还
TD-021 · 部署仍为「手工上传产物」,未纳入第 18 章流水线(门户遗留) 有意识债 4 人日 每次发版人工核对 ≈ 0.3 人日/次 × 2 次/周 下季度规划强制立项

偿还策略三选一(按债务性质):

  1. 童子军军规:改到相关模块时顺手偿还小额债务(每 PR 留 10% 预算);
  2. 专项偿还:利息高的债务立项专项(如第 24 章权限中台化),走技术方案与评审;
  3. 围栏止溢:不打算偿还的旧代码(如待淘汰的 Vue 2 模块),用架构围栏禁止新代码模仿,防止债务利息扩散——ESLint no-restricted-syntax 指向旧模式即围栏。

23.7 依赖治理与供应链安全

选型不是「引入那一刻」的事,依赖进来后的治理决定它三年后的形态:

  1. 锁文件即契约:pnpm-lock.yaml 必进版本库,--frozen-lockfile 上 CI;禁止任何人在本地「顺手 upgrade」后带着脏锁文件提 PR;
  2. 定期升级而非被逼升级:Renovate/Dependabot 周度自动开升级 PR,小版本自动合并(CI 绿即合),大版本积累到季度批次集中处理——安全补丁的时效性由此保证;
  3. 审计门禁:pnpm audit --prod 进 CI,高危漏洞阻断合并;引入新依赖前查「下载量、维护者、issue 响应、最近发版时间」四项健康度;
  4. 依赖瘦身:季度跑一次 pnpm dlx depcheck 与构建产物分析(第 17 章),清理「引了没用」的僵尸依赖——每删一个依赖都是对 bundle 和供应链面的双重减负。
# .github/renovate.json(节选:分组策略让升级 PR 可评审)
{
  "extends": ["config:recommended"],
  "packageRules": [
    {
      "groupName": "patch & minor(CI 绿自动合并)",
      "matchUpdateTypes": ["patch", "minor"],
      "automerge": true,
      "automergeType": "pr"
    },
    {
      "groupName": "major(季度批次,需 ADR 若 breaking)",
      "matchUpdateTypes": ["major"],
      "automerge": false
    }
  ],
  "rangeStrategy": "pin"   // 应用锁精确版本,升级意图显式化
}

23.8 Strangler 渐进迁移:以 Vue 2 → Vue 3 为例

与第 21 章同一模式的迁移版:新代码全部 Vue 3,旧代码按页绞杀,全程可上线。

步骤 动作 关键验收
1 基线 Vue 2 升 2.7(内置 Composition API),启用 vue-demi 兼容层写跨版本代码 冒烟集全绿
2 依赖盘点 逐个确认三方库的 Vue 3 可用性,形成《依赖迁移表》 无「未知名单」依赖
3 基座先行 若多页应用,先升基座/壳层(第 21 章 Strangler 立门面) 新旧页并存可导航
4 逐页迁移 低流量页先行,每页一个 PR,双跑对账 每页双跑一周数据一致
5 API 层收尾 Options API → <script setup>(工具辅助 + 人审),全量后删除兼容层 类型门禁全绿
6 拆除 移除 vue-demi 与 Vue 2 依赖,ADR 记录终态 构建产物中无 Vue 2 痕迹

23.9 新业务线选型报告模板

可整篇套用的报告骨架(下一章 §24.1 的技术方案文档是其「执行版」):

# 技术选型报告:〈新业务线名称〉(版本 / 日期 / 作者)

## 1. 业务摘要(给非技术评审者)
业务目标一句话;用户规模与增长预期;交付节奏要求。

## 2. 约束盘点
团队现状(技能矩阵)、存量系统(需对接的系统与契约)、合规要求、时间窗。

## 3. 候选与评分
§23.2 八维度评分表(≥ 2 个候选),一票否决项核对。

## 4. 关键决策
3–5 条「约束 → 结论」句式(§23.3 决策句式),每条链到评分依据。

## 5. 风险与退出方案
Top 3 风险及缓解;「半年后放弃它」的具体步骤与预估成本。

## 6. 里程碑与验收
与业务里程碑对齐的技术节点;每个节点的可观测指标。

## 7. 附录
spike 记录、性能实测数据、依赖迁移表。

🛠 渐进式实战练习

练习 1 · 基础(15–25 min):图表库评分

任务描述:报表中心需要趋势图(附录A §3.6)。用 §23.2 模板对 ECharts 与 Chart.js 做八维度评分(权重自定但需先于打分记录),并给出决策句式结论。

提示:先跑 10 分钟 spike(各画一张双 Y 轴趋势图)再打分,「业务匹配度」的证据来自 spike 而不是官网参数表;轻订单是中后台,注意「团队现状」与「退出成本」的权重应高于 C 端产品。

验收标准:

  • [ ] 权重在打分前记录且合计 100%;
  • [ ] 每个维度有证据(链接/截图/spike 仓库);
  • [ ] 结论使用「约束 → 结论」句式,且附退出方案。
参考答案(评分要点摘录)

ECharts 得分点:双 Y 轴/大数据量降采样/图形导出开箱即用(业务匹配 5);文档与中文生态成熟(生态 5)。Chart.js 得分点:体积小(成本 5)、简单图表快,但大数据量与复杂联动需插件拼装(业务匹配 3)。中后台报表场景通常 ECharts 加权胜出;若业务是「营销活动页的轻量图表」,权重变化后 Chart.js 可能反超——同一候选在不同业务里的分数不同,才是评分框架的意义。退出方案:经 §23.5 的 shared/chart adapter 接入,业务只消费 TrendChartInput,迁移只改 adapter。

练习 2 · 进阶(45–60 min):请求库切换 ADR + 试点

任务描述:以 §23.4 案例三为背景,撰写「报表中心试点 TanStack Query」的 ADR(第 19 章 §19.8 模板):背景(缓存与失效诉求)、备选、决策、灰度范围(仅报表中心)、退出方案;并写出试点的 useReportExport 骨架代码。

提示:ADR 的「后果」一节要同时写正面(缓存失效一致性)与负面(query key 管理纪律、团队学习成本);退出方案要具体到「保留自研 useRequest 并行一个迭代」。

验收标准:

  • [ ] ADR 七节齐全,灰度与退出方案具体可执行;
  • [ ] 代码骨架体现 query key 规范与失败重试策略;
  • [ ] 评审人(同伴)能只凭 ADR 做出「同意/反对」判断。
参考答案(useReportExport 骨架)
// src/features/report/model/useReportExport.ts
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { http } from '@/shared/api/client'

/** query key 规范:[域, 实体, 参数]——规范化是缓存失效正确性的前提 */
export const reportKeys = {
  all: ['reports'] as const,
  sales: (params: Record<string, unknown>) => [...reportKeys.all, 'sales', params] as const,
}

export function useReportExport() {
  const queryClient = useQueryClient()
  return useMutation({
    mutationFn: (params: { dimension: 'sales' | 'month' | 'product' }) =>
      http.post('/reports/export', params),
    onSuccess: () => {
      // 导出完成后刷新「导出记录」列表缓存
      void queryClient.invalidateQueries({ queryKey: reportKeys.all })
    },
    retry: 1, // 导出接口幂等,仅重试一次(与第 09 章重试纪律对齐)
  })
}

练习 3 · 挑战(60+ min):新业务线选型报告

任务描述:假设公司立项「轻订单移动端 H5」(销售在外用手机录单,附录A §2 的 sales 角色是核心用户)。按 §23.9 模板产出完整选型报告:框架延续性、组件库选型(移动端与 Element Plus 不同源!)、请求与离线策略(外勤弱网)是三个必答命题。

提示:先盘点约束——团队现有 Vue 栈、需要快速复用订单领域类型与 API 层(第 19 章的 qing-api-client 立即体现价值);移动端组件库候选给 Vant 与原生自建两条路。

验收标准:

  • [ ] 报告七节齐全,评分表有真实 spike 证据;
  • [ ] 三个必答命题均有「约束 → 结论」句式;
  • [ ] 至少识别出 2 条与 admin 的共享资产(类型/API 层/权限模型)并给出复用方式。
参考答案(三命题的决策要点)
  1. 框架:延续 Vue 3.5——团队零切换成本 + qing-api-client/qing-types 直接复用(约束:4 人团队 3 个月窗口 → 结论:不引入第二框架);
  2. 组件库:Vant 4(移动端交互范式成熟)+ qing-ui 令牌层跨端复用(--q-* 令牌在 H5 同样生效,第 20 章设计的回报);qing-ui 的组件层不复用到移动端(交互形态不同),只共享令牌与工具;
  3. 离线与弱网:录单流程本地优先(IndexedDB 草稿箱 + 联网后同步),请求层加超时降级与重试队列;不引入重型离线框架(退出成本优先),用最小自研队列并写 ADR。

⚠️ 常见易错点

易错点 1 · 选型只看 star 数:热度 ≠ 合适

❌ 错误做法:评分表退化成 GitHub star 排序,「这个库 30k star,就它了」。star 度量的是知名度,不是维护健康度(高频 breaking)、不是业务匹配度(缺你要的核心能力)、更不是团队适配度。历史上大量高 star 库进入「低维护」状态后,用户成了接盘侠。

✅ 正确做法:star 只作为「招聘与社区体量」维度的一个参考信号;把注意力放到健康度指标上——最近 6 个月发版频率、issue 平均响应时长、breaking change 频率、维护者集中度(bus factor ≥ 2)、以及最重要的:你的 spike 实测。评分表里每个维度都必须给出证据而非感觉。

易错点 2 · 先有结论后打分:评分表沦为证明题

❌ 错误做法:团队老大已经内定某库,评审走个流程——权重朝结论倒排(把候选强的维度权重调高),证据只挑有利的。半年后库的短板爆雷,回看评分表发现漏洞百出,团队对「评分框架」本身失去信任,选型文化崩坏。

✅ 正确做法:权重在见候选证据之前确定并记录在案;每个候选的证据由不同成员独立收集再汇总;引入「魔鬼代言人」角色——评审会上指定一人专职攻击首选方案。评分表连同反驳意见一起归档,错了要能看出当时为什么错。

易错点 3 · 忽视退出成本:耦合到无法回头

❌ 错误做法:直接在 30 个组件里 import 第三方表格库的内部 API(私有方法、 undocumented props),没有任何封装层。一年后库停止维护,评估迁移工作量时发现要改 30 处 × 平均 8 个调用点——迁移成本高到只能继续裸奔。

✅ 正确做法:重资产依赖(图表/表格/编辑器/状态库)一律过防腐层(§23.5 adapter),业务只依赖自有接口;ADR 里必须写「退出方案」,并把「调用点数量」作为依赖引入前的度量项——超过约定阈值(如 > 10 个调用点)强制走封装层。

易错点 4 · 技术债只记不还:台账变成许愿池

❌ 错误做法:技术债清单 40 条,每年 review 都是「下次处理」,债务利息持续累积:新人持续踩同一个坑、每个迭代都在为旧设计绕路,最后一次「小需求」引爆全部债务(此时连事故复盘都说不清根因)。

✅ 正确做法:给每条债务标本金与利息(§23.6),设触发线(利息 > 本金 × 2 或相关事故一次)自动升级为专项;季度规划给债务预留固定容量(第 24 章 §24.5 的 70/20/10),让「还债」成为常规排期而非道德呼吁。

易错点 5 · 新玩具驱动开发:为简历写代码

❌ 错误做法:看到社区新框架/新状态库就往生产项目里引,「练练手」。核心业务被实验性依赖绑架,作者离职后无人敢动;升级一个补丁版本都心惊胆战。

✅ 正确做法:新技术的入口是 spike 仓库(独立分支/沙盒项目),产出评估报告后按八维度表走正式流程;生产引入的门槛是「稳定 ≥ 一个 LTS 周期 + 有退出方案」;团队约定「学习在业余/spike,生产要证据」。

易错点 6 · 忽视时间窗:该升级时不升,不该动时乱动

❌ 错误做法:两个极端都常见——其一是「能跑就不动」:框架小版本落后两年,安全补丁堆积,最终被迫在业务高峰期紧急大升级(被动升级 = 最高成本形态);其二是「追新」:库刚发 major 第二天就跟进生产,替全团队当试错牺牲品。

✅ 正确做法:把升级当作有节奏的常规农业(§23.7):小版本自动跟进(CI 绿即合)、大版本按季度批次评估、安全补丁 48 小时内响应;每次大版本升级写 ADR 记录「为什么现在升/为什么不升」,时间窗判断本身可追溯。

✅ 最佳实践

  1. 八维度 + 一票否决:量化框架统一讨论语言,硬红线(License/安全/退出方案)不参与加权、直接出局;
  2. 证据先于立场:评分依据必须可点开验证(spike 仓库、实测数据、issue 链接),防止评分表变文学创作;
  3. 决策句式模板化:「在 X 约束与 Y 目标下选 Z,若约束变为 W 则重估」——结论携带条件,未来可被正确地推翻;
  4. 防腐层保护重资产依赖:图表/表格/请求/编辑器过 adapter,退出成本在引入时就被设计掉;
  5. 债务显性化:技术债台账带本金利息与触发线,季度评审给容量,童子军军规清小额债;
  6. 迁移用 Strangler:框架升级、子系统替换全程可上线、可回退,永远不要「停机重写」(与第 21 章同一模式);
  7. 报告面向两类读者:业务摘要给管理层,评分与附录给工程师——一份报告两层语言;
  8. spike 是廉价保险:任何进入评分的候选都必须跑过 10 分钟级 spike,「官网参数表」不是证据;
  9. 选型有半衰期:所有结论自带重估条件与时间点(季度规划页签),没有「一选定终身」——评审通过只是这份决策生命的开始。

📌 本章小结

  • 选型是约束下的最优化:迁移/机会/组织三本账决定「合适」的定义,八维度加权表 + 一票否决项让评估可复核、可追溯;
  • Vue 与 React 的对比给事实与条件,不给立场;结论随约束变化而重估才是正确的决策姿势;
  • 依赖治理是选型的下半场:锁文件契约、Renovate 节奏升级、审计门禁与季度瘦身,让依赖三年后仍可控;
  • 状态库/组件库/请求库三个案例证明:同一张表在不同时间窗结论不同——权重有效性的最好证明;
  • 自研 vs 开源走决策树,重资产依赖配防腐层;技术债按本金利息记账、按触发线偿还;一切存量替换交给 Strangler。
  • 选型不是一次性动作:决策归档 ADR、结论自带重估条件,报告面向管理层与工程师两种读者各给一层语言。

下一章是全书的收官实战:把第 19–23 章的方法融进一份完整的《轻订单 v2 技术方案》(CSR → CSR + 局部 SSR/岛屿、权限中台化),走一遍架构评审流程,并学习如何把技术判断转化为管理层听得懂的语言。

📄 第 24 章 · 资深实战:技术方案与架构评审 24-资深实战-技术方案与架构评审.md

📍 阶段:资深阶段(第 19–24 章)|⏱ 预计学时:9–12 小时|🔗 前置:第 19–23 章(本章为第四部分收官整合)

⬅ 上一章 | 📖 本版目录 | ➡ 下一章

💡 对照学习:本页是 Vue 版第 24 章。React 版对应章节为 第 24 章 · 资深实战-技术方案与架构评审。本章方法两版通用,示例代码为 Vue 生态:方案写作法、评审 checklist、向上管理与技术规划完全一致;文末的毕业项目建议同样适用于双栈学习者。

🎯 学习目标

学完本章,你将能够:

  1. 按「背景 / 目标 / 非目标 / 方案对比 / 详细设计 / 灰度与回滚 / 里程碑」七段结构写出一份可直接评审的技术方案文档;
  2. 区分「目标」与「非目标」的写法要领,并解释为什么非目标比目标更能防止范围蔓延;
  3. 完整复述并部分复用轻订单 v2 技术方案(CSR → CSR + 局部 SSR/岛屿、权限中台化重构)的每个决策环节;
  4. 组织与主持一次架构评审:会前 48 小时发材料、会中控场、会后行动项闭环,并用 15 条 checklist 自评方案质量;
  5. 用「现状 → 风险 → 建议 → 资源」四段电梯汇报,把技术决策翻译成管理层语言;
  6. 制定季度技术规划:业务目标映射技术目标、70/20/10 容量分配、OKR 落地;
  7. 规划并启动全书毕业项目:在轻订单上完成一次完整的架构演进闭环。

📖 核心知识点

24.1 技术方案文档写作法

技术方案是决策的载体,不是需求的复述。它回答三个问题:为什么做(背景)、做成什么样算成功(目标/验收)、为什么这样做而不是那样做(方案对比)。七段结构:

段落 写什么 常见失败
1 背景 现状 + 量化痛点 + 为什么是现在 写成产品需求复述
2 目标 可验收的目标(指标化),3–5 条 目标不可度量(「提升体验」)
3 非目标 明确不做什么、边界在哪 不写 → 评审时范围无限膨胀
4 方案对比 ≥ 2 个候选 + 对比表 + 结论句式 只有一个方案 = 通知而非决策
5 详细设计 架构图、数据流、接口、关键代码路径 贴满代码却没有一张图
6 灰度与回滚 放量计划、降级开关、回滚步骤 「上线即全量」= 赌博
7 里程碑 按周拆分 + 每节点验收物 只有结束日期的「大爆炸」

写作心法三条:① 先给结论再给论证(评审人只有前 5 分钟的耐心,结论放第一屏);② 量化一切可量化的(LCP 多少秒、错误率多少、节省多少人日);③ 篇幅控制在 3–6 页——超过说明你在写论文,评审会没人读完。

同一句话的三种写法对照(感受「方案语言」与「作文语言」的差距):

❌ 作文式:「目前系统的性能还有较大的提升空间,用户体验有待优化。」
   (无数字、无基线、无目标,评审人无从判断)

🟡 半成品:「门户首屏较慢,我们打算做 SSR 来优化。」
   (有方向没依据:慢多少?SSR 解决的是不是这个慢?)

✅ 方案式:「门户 LCP 实测 2.6s(4G/中端安卓,目标 ≤ 2.0s),瓶颈为
   JS 执行阻塞首屏渲染;采用静态优先 + 岛屿水合方案(对比见 §4),
   预期 LCP ≤ 1.6s,验收方式为 CI 中 Lighthouse 双端实测。」
   (基线 + 目标 + 根因 + 手段 + 预期 + 验收,六要素齐)

24.2 完整案例:《轻订单 v2 技术方案》全文示例

以下是可以直接套用的完整方案(内容融合第 19–23 章的所有产出)。原文保存为 docs/rfcs/0042-qing-order-v2.md。

# RFC-0042 · 轻订单 v2:门户岛屿化 + 权限中台化

- 状态:评审中 | 作者:周航 | 评审人:李清(前端)、王砚(后端)、财务线负责人
- 关联:ADR-0017/0021/0025/0030,附录A §4 性能目标

## 1. 背景

三个量化事实:
1. **门户 SEO 与内容运营受限**:门户(第 06 章产物)是纯 CSR SPA,爬虫抓取
   到的是空 div;营销团队更新产品介绍必须排前端排期,平均等待 5 个工作日;
2. **权限逻辑散落三处**:菜单过滤(store)、路由守卫(router)、按钮指令
   (v-permission)各自维护角色判断,第 11 章三级权限模型落地时重复实现了
   4 种角色组合逻辑;上个季度新增「渠道经理」角色修改了 7 个文件,回归漏了
   报表导出入口(P2 事故单 #2210);
3. **多端复用为零**:移动端 H5 立项在即(第 23 章练习 3),权限与订单领域
   类型没有可复用的资产形态。

## 2. 目标(可验收)

| # | 目标 | 验收指标 |
|---|------|---------|
| G1 | 门户核心页对爬虫可见 | 首页/产品页服务端渲染完整 HTML,Lighthouse SEO ≥ 95 |
| G2 | 门户首屏指标不回退 | LCP ≤ 2.0s 维持(附录A §4),且 TTFB ≤ 800ms |
| G3 | 权限单点仲裁 | 角色增减只改权限配置一处,菜单/路由/按钮三个消费端零改动 |
| G4 | 领域资产可复用 | qing-auth 包被 admin、H5 双端引用,类型零拷贝 |

## 3. 非目标

- 不重构 admin 后台的 SPA 架构(后台是登录态强交互应用,SSR 无收益,第 19 章复杂度评估的结论继续有效);
- 不做 i18n 与多主题运营化(等真实业务诉求);
- 不迁移营销子系统(第 21 章已按微前端接入,保持现状);
- 后端权限模型本身不动,本方案只覆盖前端消费侧的中台化。

## 4. 方案对比

**议题一:门户如何获得 SEO 与快速内容更新**

| 方案 | 优点 | 缺点/风险 |
|------|------|----------|
| A. 整站迁 Nuxt | 生态一体,SSR/SSG/CSR 一站式 | 门户+后台都要进 Nuxt 体系,admin 改造收益为零、风险大 |
| B. 门户独立走 Astro + Vue 岛屿 | 默认零 JS 静态页 + 交互岛屿按需水合,最贴合「内容为主、少量交互」的内容站形态;admin 完全不动 | 团队学一个新框架(评估成本 ≈ 3 人日,spike 已完成) |
| C. 保持 CSR + 预渲染 | 改动最小 | 内容更新仍依赖前端排期,根因未解决 |

结论(句式见第 23 章 §23.3):在「内容更新频繁 + SEO 硬指标 + admin 不动」的约束下,选 **B**;若后续门户交互密度显著上升(岛屿占比 > 50%),应重估为 Nuxt。

**议题二:权限如何中台化**

| 方案 | 优点 | 缺点/风险 |
|------|------|----------|
| A. 角色硬编码集中化 | 改动小 | 新角色仍要改代码,本质没变 |
| B. 权限点(Permission Code)注册表 + 后端下发授权集 + `useAuth` 单点仲裁 | 角色=权限点的组合由配置表达;三个消费端统一订阅授权集 | 需要后端提供授权接口(已对齐,王砚) |

结论:选 **B**,前端权限 SDK 沉淀为 `packages/qing-auth`(第 19 章 Monorepo 中的包,G4 落点)。

## 5. 详细设计

### 5.1 权限中台化(qing-auth)

架构关系:

```
 后端授权服务                前端(qing-auth 单点仲裁)
┌──────────────┐   登录后    ┌──────────────────────────┐
│ GET /auth/    │ ────────▶ │ authStore.grantedCodes    │
│ permissions   │  权限集    │ (Set<PermissionCode>)    │
└──────────────┘            └──────────┬───────────────┘
                                       │ can(code) 唯一判定入口
              ┌────────────────────────┼────────────────────┐
              ▼                        ▼                    ▼
        菜单消费端                  路由消费端            按钮消费端
   (app/model/menu)        (router 守卫 meta)    (v-permission 指令)
```

```ts
// packages/qing-auth/src/registry.ts —— 权限点注册表(前端开发期兜底,运行时以后端下发为准)
export const PERMISSION_POINTS = {
  'order:list':    { name: '订单查看' },
  'order:create':  { name: '订单创建' },
  'order:approve': { name: '折扣审批' },
  'order:export':  { name: '订单导出' },
  'customer:manage': { name: '客户管理' },
  'member:manage':   { name: '成员管理' },
} as const

export type PermissionCode = keyof typeof PERMISSION_POINTS
```

```ts
// packages/qing-auth/src/useAuth.ts —— 唯一仲裁入口(第 11 章三处逻辑的全部替代)
import { computed } from 'vue'
import { useAuthStore } from './store'

export function useAuth() {
  const store = useAuthStore()
  /** 单点判定:任何 UI/路由/菜单一律经此函数,禁止自行读角色拼判断 */
  const can = (code: PermissionCode) => store.grantedCodes.has(code)
  const isSuperAdmin = computed(() => store.roles.includes('super_admin'))
  return { can, isSuperAdmin }
}
```

```ts
// src/app/plugins/permission.ts —— 路由消费端:守卫只问 can(),不认识任何角色名
import type { PluginContext, AppPlugin } from '../types'
import { useAuth } from 'qing-auth'

export const permissionPlugin: AppPlugin = {
  name: 'permission',
  requires: ['auth'],
  setup({ router }) {
    router.beforeEach((to) => {
      const { can } = useAuth()
      const need = to.meta.permission
      if (need && !can(need as never)) return { name: 'forbidden' }   // 附录A:403 页
    })
  },
}
```

角色与权限点的映射关系存于后端配置(如 `sales = [order:list, order:create, customer:manage]`),G3 的验收即「新增渠道经理角色:后端配置一行,前端零发布」。

### 5.2 门户岛屿化(Astro + Vue islands)

```
 qing-portal(Astro 静态优先)
├── 首页/产品页/案例页 = 静态 HTML(内容来自 Markdown/CMS,营销可自助改)
│     └── 岛屿①:<ThemeToggle client:load />        (第 06 章暗色切换)
│     └── 岛屿②:<FaqAccordion client:visible />    (滚动到才水合)
│     └── 岛屿③:<InquiryForm client:idle />        (询价表单,复用 qing-ui 令牌)
└── 构建产物:纯 HTML/CSS + 每岛屿独立小 JS chunk
```

```astro
---
// apps/qing-portal/src/pages/index.astro(节选)
import FaqAccordion from '../components/FaqAccordion.vue'
import InquiryForm from '../components/InquiryForm.vue'
import { faqs } from '../content/faqs'      // 内容与代码分离:营销自助更新
---
<section>
  <h2>常见问题</h2>
  <FaqAccordion client:visible items={faqs} />
</section>
<section>
  <InquiryForm client:idle />
</section>
```

跨端复用链路:岛屿内组件消费 `qing-ui/styles/tokens.css`(第 20 章三层令牌直接生效);询价表单提交调用 `qing-api-client` 暴露的 `createLead()`——领域资产第二次复用(G4)。

### 5.3 部署与降级

| 项 | 策略 |
|----|------|
| 放量 | 门户按路径灰度:/products 先行 1 周 → 全量;admin 权限新链路按角色灰度(先 super_admin,后全角色) |
| 降级 | 岛屿水合失败(CDN 异常/JS 错误)→ 岛屿位置降级为静态占位;权限接口失败 → 回退本地缓存的授权集快照 + 上报监控 |
| 回滚 | Astro 产物与旧 SPA 产物并存于版本目录(第 18 章软链结构),Nginx 切换即回滚,1 分钟内完成 |

## 6. 灰度与回滚

- 观测指标接入第 18 章体系:门户 TTFB/LCP/SEO 分、权限相关 4xx 与 403 误拦率、岛屿水合失败率;
- 回滚触发线:403 误拦率 > 0.5% 或 LCP 劣化 > 10% 持续 1 小时 → 立即切回;
- 每个灰度阶段结束出「对比数据贴」(前后指标表),全员可见。

## 7. 里程碑

| 里程碑 | 周期 | 交付物 | 验收 |
|--------|------|--------|------|
| M1 权限中台化 | 2 周 | qing-auth 包 + 三消费端切换 + 灰度 | G3/G4;角色增减演练通过 |
| M2 门户岛屿化 | 3 周 | Astro 门户 + 3 岛屿 + CMS 内容流 | G1/G2;营销自助更新演练 |
| M3 观测与收尾 | 1 周 | 对比数据报告 + ADR 终态归档 | 全部灰度完成,回滚演练一次 |

24.3 架构评审流程与 checklist

评审生命周期
会前(T-48h)          会中(60 min)              会后(T+24h)
────────────          ─────────────              ─────────────
方案 RFC 提前发出      10min 作者讲解(只讲结论)    24h 内发出评审纪要
评审人预填书面意见      30min 质询(主持控场)        结论三分:通过 / 有条件通过 /
作者预答高频问题       15min 逐条过 checklist        (驳回) 重新设计
                      5min 表决与行动项             行动项进入 issue 并有 Owner

主持规则三条:发言先结论后理由、质询对方案不对人(第 22 章评审文化的同款纪律)、每条分歧必须有记录与归属(采纳/驳回/待 spike)。

评审意见模板(评审人书面意见的统一格式,会前预填):

### 评审意见 · RFC-0042(评审人:李清 · 预填于会前)

| # | 级别 | 意见 | 期望处置 |
|---|------|------|---------|
| 1 | 阻断 | 岛屿水合失败的降级仅写了「静态占位」,未说明询价表单降级后的线索采集方案 | 补降级链路(服务端表单直投兜底) |
| 2 | 建议 | 权限接口失败回退快照,但快照时效未定义 | 定义快照有效期与强制刷新时机 |
| 3 | 口味 | 里程碑 M2 三个岛屿建议标注各自的 client 指令 | 补充即可 |

方案质量自评 checklist(15 条):

  1. [ ] 背景有量化数据,而非形容词;
  2. [ ] 目标可验收(有指标、有数值);
  3. [ ] 非目标段落存在且具体;
  4. [ ] 方案对比 ≥ 2 个候选,且有对比表;
  5. [ ] 每个结论使用「约束 → 结论」句式;
  6. [ ] 架构图至少一张,代码只贴关键路径;
  7. [ ] 数据流与状态归属说清(第 19 章 §19.4);
  8. [ ] 兼容性/迁移路径明确(旧数据、旧 URL、存量用户);
  9. [ ] 性能影响有预估与测量方式;
  10. [ ] 安全审查过(权限、凭据、注入面);
  11. [ ] 失败模式列出 ≥ 3 条并有对策;
  12. [ ] 灰度计划具体到路径/角色/比例;
  13. [ ] 回滚步骤可操作(谁、多久、怎么切);
  14. [ ] 里程碑按周拆分且每个有验收物;
  15. [ ] 关联的 ADR/契约文档全部互链。

24.4 向上管理与影响力

技术决策推进失败的最常见原因不是方案不好,而是翻译失败。四段电梯汇报结构:

段 内容 例(权限中台化向 CTO 汇报)
现状 一句量化事实 「权限逻辑散落 7 处文件,Q3 因漏改引发 1 次 P2 事故」
风险 不做的代价 「每新增一个角色都有回归遗漏风险,H5 立项将放大 2 倍」
建议 一句话方案 + 成本 「把权限收敛为统一 SDK,2 周,我出人力计划」
资源 需要什么、回报什么 「需要后端 0.5 人力配合接口;回报是角色配置化 + H5 直接复用」

三条影响力法则:① 用业务语言陈述技术债的利息(第 23 章 §23.6 的本金利息模型就是为此设计的);② 带着选项去而不是带着问题去(给管理层 A/B/C 与你的推荐,而不是「怎么办」);③ 让正确的事可被复述——一页纸、一张图、一组数字,比一万字更能在高层会议中活下来。

24.5 季度技术规划模板

季度规划(Q4)—— 与业务目标一一对齐
┌─────────────────────────────────────────────────────┐
│ 业务输入:H5 立项 / 营销子系统二期 / 财务报表需求增多      │
├─────────────────────────────────────────────────────┤
│ 技术目标(可验收)           │ 支撑业务        │ 容量   │
│ O1 权限中台化落地(G3/G4)     │ H5 复用/事故止损 │ 20%   │
│ O2 门户岛屿化(G1/G2)         │ 获客与 SEO      │ 30%   │
│ O3 报表性能治理(TD-009 等)   │ 财务线体验      │ 20%   │
│ O4 依赖升级批次+债务偿还     │ 长期健康        │ 10%   │
│ 预留(线上应急/临时需求)     │ ——             │ 20%   │
└─────────────────────────────────────────────────────┘

容量分配参考 70/20/10:70% 业务需求、20% 技术专项(债与基建)、10% 探索(spike 与创新)——比例随团队成熟度调整,但预留线(应急)永远不可为 0。每个技术目标配 OKR 式关键结果,季度末逐条验收,未完成的滚动进下季度并写明原因。

以 O1 为例的 OKR 落地形态:

O1 权限中台化落地(支撑 H5 复用与事故止损)
  KR1 qing-auth 发布 1.0,admin 三消费端全部经 can() 仲裁(0 处角色硬编码)
  KR2 新增角色演练一次:后端配置变更后前端零发布、全功能可用
  KR3 403 误拦率 < 0.5%,权限相关 P1/P2 事故为 0
  KR4 关联资产:RFC-0042 · ADR-0033 · 技术债 TD-031 关闭

24.6 全书毕业项目建议

你现在拥有了完整的方法链。毕业项目是一次真实闭环(建议 4–6 周,可直接写进简历):

周 任务 用到的章节
W1 复杂度评估 + 六层结构迁移 + 依赖门禁 19
W2 qing-ui 令牌与 5 个核心组件 + Storybook 20
W3 接入一个「伪第三方」子应用(本地模拟 React 应用) 21
W4 协作链路:commitlint + PR 模板 + Review 清单实操 22
W5 选型报告 + v2 技术方案(自选议题:SSR 或权限或离线) 23 / 24
W6 模拟架构评审(找同伴当评审人)+ 迭代二稿 + 归档 ADR 集 24

验收清单:RFC ≥ 1 篇通过 15 条 checklist 自评 ≥ 13 条;ADR ≥ 5 篇;Monorepo 中 ≥ 2 个真实被双端消费的包;灰度与回滚方案经过一次演练。


🎓 毕业之后:前往 附录B · 面试题精选 检验四阶段的沉淀,或在 附录C · 学习路线图与资源 规划下一程(管理线 / 专家线的分岔口在阶段自查清单中讨论)。

🛠 渐进式实战练习

练习 1 · 基础(15–25 min):把「口述需求」改写成方案开头

任务描述:以下是领导的一段口述——把它改写成 RFC 的「背景 / 目标 / 非目标」三段(可合理虚构量化数据):

「那个门户吧,老是有客户说搜不到我们,还有市场那边每次改个产品介绍都得找你们排期,挺耽误事的。对了最近不是要招渠道经理嘛,权限那块听说改起来挺麻烦,顺手一起弄了?」

提示:口述里的「搜不到」对应 SEO、「排期」对应内容自助、「权限改起来麻烦」对应中台化;「顺手一起弄」正是非目标段落要拦截的范围蔓延——判断哪些该进、哪些该明确排除。

验收标准:

  • [ ] 背景含 ≥ 3 个量化数据点,且标注数据来源假设;
  • [ ] 目标 3–5 条全部可验收;
  • [ ] 非目标 ≥ 3 条,并说明排除理由。
参考答案(节选)

背景:① 门户为纯 CSR,核心页在搜索引擎几乎无收录(site 检索结果 ≈ 0,竞品官网收录 50+ 页);② 营销内容更新依赖前端排期,2026-08 至 09 共 6 次需求平均等待 5 个工作日;③ 渠道经理角色接入在即,现有权限逻辑散落 7 处,上次新增角色(Q3)引发 P2 事故单 #2210。

目标:G1 首页/产品页服务端可抓取(SEO 分 ≥ 95);G2 LCP ≤ 2.0s 不回退;G3 新增角色零前端发版;G4 权限 SDK 被 admin 与 H5 双端复用。

非目标:admin 不改 SSR(登录态应用无收益);不做 i18n;「顺手」范围外的一切后台功能改造(明确排除,另行立项)。

练习 2 · 进阶(45–60 min):权限中台化方案节 + 同伴评审

任务描述:以 §24.2 的 5.1 节为底稿,替换为「你团队的权限现实」重写一份:画仲裁架构图、写 can() 仲裁接口与路由消费端代码、列迁移步骤(旧 v-permission 逻辑如何双轨过渡)。然后交给同伴按 15 条 checklist 逐条评分。

提示:迁移要写「双轨期」——旧角色判断与新权限点并存,用开关灰度切换(第 22 章特性开关的又一次应用);想清楚旧逻辑什么时候删、谁批准删除。

验收标准:

  • [ ] checklist 自评 ≥ 13 条通过,未过条目有整改计划;
  • [ ] 双轨期有明确的起止条件与删除审批人;
  • [ ] 代码中不存在任何「角色名硬编码」残留路径。
参考答案(双轨过渡要点)
  1. 阶段一:qing-auth 上线,can() 内部实现为「权限点映射表(角色→权限点的兼容层)」,旧 v-permission 调用点逐个替换为权限点写法(PR 逐域拆分,第 22 章 ≤ 400 行纪律);
  2. 阶段二:后端授权接口上线后,can() 数据源从兼容层切换为下发授权集(配置开关 auth.remote-source 控制);
  3. 阶段三:双跑一周对比两数据源判定差异(对账脚本),差异归零后删除兼容层——删除需 Owner 批准并在 ADR 终态记录。

练习 3 · 挑战(90+ min):完整 RFC + 模拟架构评审

任务描述:从「门户岛屿化」「报表中心服务端导出」「移动端 H5 离线录单」中任选一个议题,按 §24.1 七段结构写完整 RFC(3–6 页),然后组织一次 60 分钟模拟评审(同伴任评审人,你任作者,第三人任主持人),全流程执行会前/会中/会后纪律。

提示:议题二(服务端导出)会触及第 09 章请求层与附录A §3.6 导出进度条;议题三触及弱网、数据同步与冲突处理——无论选哪个,评审人最爱问的三板斧是「数据一致性、失败模式、回滚」。

验收标准:

  • [ ] RFC 七段齐全,15 条 checklist ≥ 13 条;
  • [ ] 评审纪要含每条分歧的处置结论与行动项 Owner;
  • [ ] 二稿归档,初稿与二稿的 diff 可见(证明评审产生了实际改进)。
参考答案(模拟评审常见质询与应对)
  • 「导出失败用户重试会不会重复扣库存/重复生成文件?」→ 导出接口幂等设计 + 前端防重复提交(第 10 章)双保险;
  • 「灰度期间新旧导出入口并存,财务用哪个?」→ 按角色灰度 + 灰度名单文档化,入口互斥;
  • 「Astro 团队没人会,你休假怎么办?」→ bus factor 对策:spike 记录 + 双人熟悉(第 23 章风险维度的现场应用);
  • 「权限接口挂了后台还能用吗?」→ 降级链路(快照 + 只读模式)在 5.3 节有答案——若你的初稿没有,这就是评审的价值。

⚠️ 常见易错点

易错点 1 · 技术方案写成需求文档:只有「做什么」没有「为什么这样做」

❌ 错误做法:RFC 通篇是功能列表与界面描述(「系统将提供订单导出功能,支持 CSV……」),找不到一句技术决策与备选对比。评审会上被问「为什么不用 X」时当场抓瞎——因为作者自己也没想清楚。另一种反面:全是代码细节没有一张图,评审人三页后失去耐心。

✅ 正确做法:严格七段结构,重心压在方案对比与详细设计的决策点上;需求背景只写「影响技术决策的那部分」;每页至少一张图或一张表;代码只贴关键路径(接口签名、数据流、降级逻辑),实现细节放附录链接。

易错点 2 · 只有一个方案:通知式评审

❌ 错误做法:RFC 里只有「本方案采用 Astro……」——没有候选、没有对比。这实际是把「已经做完的决定」通知大家,评审沦为形式;一旦有评审人质疑方案本身,作者毫无防御纵深,决策公信力受损。

✅ 正确做法:≥ 2 个候选 + 对比表 + 结论句式(第 23 章 §23.3);候选可以「弱」,但必须真实评估过——「考虑过保持 CSR + 预渲染,因未解决内容排期根因而排除」一行字,比十个省略号更能建立信任。

易错点 3 · 非目标不写:范围在评审桌上疯长

❌ 错误做法:不写非目标。评审会上有人提议「顺便把 i18n 做了吧」「H5 也一起覆盖了吧」,作者被现场扩权,工期从 2 周变 2 月;或反向妥协,把合理的相关改进全推给「下个版本」,方案孤立到无法落地。

✅ 正确做法:非目标逐条写明「不做什么 + 为什么不 + 未来何时重估」;评审中对新增范围的回应模板是:「记入评审纪要,单独立项评估」——礼貌接收、边界不动。

易错点 4 · 评审变成辩论赛:赢了讨论,输了落地

❌ 错误做法:评审会无主持、无议程,两位强势工程师为一个命名争论 40 分钟;散会无纪要,口头「下次改」没有 Owner;两周后同样的争论原地重演。评审的成本(6 人 × 1 小时)被浪费,且团队开始恐惧评审。

✅ 正确做法:主持人控场(发言先结论、单议题限时、跑题即记录后置);分歧逐条处置(采纳/驳回/待 spike,各有归属);会后 24 小时纪要 + 行动项进 issue;对无法当场决策的议题,用「spike 后复议」收场而不是争论到面红耳赤。

易错点 5 · 向上汇报说术语:把「技术正确」讲成「业务无关」

❌ 错误做法:对 CTO 说「我们要做 CSR 到岛屿架构的水合策略迁移」,得到一个茫然的「所以呢」。技术债不报,事故爆发后才是「你们早就知道?」的信任危机时刻——沉默的技术债是未来最贵的沟通成本。

✅ 正确做法:按 §24.4 四段结构翻译:现状量化、不做的代价(利息!)、建议与成本、所需资源与回报;每个技术目标都在季度规划里挂着对应的业务目标(§24.5)——让管理层看到「技术投入 = 业务加速」,而不是「技术 = 成本中心」。

易错点 6 · 灰度与回滚一比零:上线即赌博

❌ 错误做法:里程碑写着「周五上线」,既没有放量计划也没有回滚步骤;出问题时的应对是「一群人开会决定回滚,但没人记得旧产物在哪」。第 18 章搭好的软链回滚机制在真正需要时才发现从未演练。

✅ 正确做法:任何变更默认带三件套——灰度计划(路径/角色/比例)、降级开关(依赖不可用时用户看到什么)、回滚步骤(一条命令 + 责任人 + 时限);回滚演练纳入季度规划的技术专项(§24.5 O4),演练不过关的方案不允许上线。

✅ 最佳实践

  1. 方案先于代码:任何跨两周或跨团队的工作先写 RFC,评审通过后才开工——改方案的成本是改代码的百分之一;
  2. 结论前置:RFC 第一屏给结论与一图流,评审人 5 分钟内能抓住全貌;
  3. 非目标是护城河:写清「不做什么」,范围蔓延在文档层就被拦截;
  4. 15 条 checklist 常驻:作者自评 + 评审人复评双保险,评审会聚焦真正的分歧点;
  5. 评审即仪式感工程:会前 48h 材料、会中控场、会后纪要三件套让评审产生复利而非消耗;
  6. 向上翻译能力是硬技能:现状-风险-建议-资源四段结构,让好方案拿到资源;
  7. 季度规划留缓冲:70/20/10 + 应急预留,技术债按本金利息排队偿还;
  8. 毕业项目作品化:把架构演进沉淀为 RFC + ADR 集 + 仓库链接,这是资深阶段最有说服力的简历素材。

📌 本章小结

  • 技术方案文档是决策的载体:背景量化、目标可验收、非目标守边界、方案对比给备选、详细设计配图、灰度回滚成套、里程碑按周拆;
  • 轻订单 v2 示例演示了方法链的合流:分层(19)→ 令牌与包(20)→ 微前端边界(21)→ 特性开关灰度(22)→ 约束化结论(23)→ RFC 与评审(24);
  • 架构评审靠流程与 checklist 保证质量,靠文化(对事不对人、分歧有归属)保证可持续;向上管理的核心是把技术语言翻译成业务利息;
  • 毕业项目是一次完整的架构演进闭环:评估、拆分、建库、接入、方案、评审——完成后,你已具备资深前端工程师的完整方法论。

🎓 全书正章到此收官。接下来请前往 附录B · 面试题精选 自测四阶段知识沉淀,再到 阶段自查 · 资深能力清单 做最后的画像校准——祝你在管理线或专家线的下一程里,把这套方法用于更大的战场。

📄 阶段自查 · 中级能力清单(第 07–12 章) 阶段自查-中级能力清单.md

⬅ 上一章 | 📖 本版目录 | ➡ 下一站

💡 React 版对应文件:阶段自查-中级能力清单(清单条目完全一致,可双栈互验)。业务基准:附录A「轻订单」需求说明书。


一、阶段画像:中级工程师在企业里能做什么

过了中级这条线,你不再是「按 ticket 改页面的人」,而是能独立负责一个业务模块从需求到上线的工程师。以「轻订单」为标尺,企业对中级开发的真实预期是:

  1. 独立交付模块:拿到「订单管理」这个模块的需求文档,能自己拆里程碑(M1–M4)、搭骨架、联调、验收,全程不需要别人带你;
  2. 封装横切设施:axios 请求层(token 注入/错误归一化/无感刷新)、权限三级控制、可复用的列表编排 composable——这些「全项目都要用」的东西,你能设计并守住边界;
  3. 写接得住的代码:目录结构有约定、常量收敛、错误处理统一,同事 review 后可以直接接手你的模块;
  4. 对数据安全有手感:知道金额为什么用分、token 为什么不能随手塞 localStorage、双击为什么会创建两笔订单——并且知道怎么防。

做不到以上四条中的任何一条,说明中级阶段还有缺口,对照下面的清单定位回看。


二、能力清单(16 条,三组自评)

用法:逐条勾选。拿不准的条目给出「未达标 → 回看第 X 章 §Y」的定位,回看后重做该章实战练习再来勾。

A 组 · 能独立完成(6 条)

  • [ ] A1. 从零搭建中后台路由骨架:嵌套路由 + Layout、懒加载分包、404 兜底、菜单高亮(未达标 → 回看第 07 章 §7.2/§7.6)
  • [ ] A2. 实现订单列表页:分页(20/页)、多维筛选、防抖、竞态取消、URL 状态回填(未达标 → 回看第 09 章 §9.6/§9.8)
  • [ ] A3. 实现新建订单表单:动态明细行、行内校验、金额联动汇总、整单折扣审批提示、防重复提交(未达标 → 回看第 10 章 §10.3–10.6)
  • [ ] A4. 实现完整登录流与登出:图形验证码、双令牌写入、redirect 回跳、全量状态清理(未达标 → 回看第 11 章 §11.3/§11.8)
  • [ ] A5. 落地三级权限:meta.roles 页面守卫 + 403、按角色过滤的动态菜单、v-permission 按钮级控制(未达标 → 回看第 11 章 §11.5–11.7)
  • [ ] A6. 封装请求层:baseURL 环境变量化、token 注入、错误归一化、40102 无感刷新(单飞锁 + 重放)(未达标 → 回看第 09 章 §9.3–9.5)

B 组 · 能讲清原理(5 条)

  • [ ] B1. 讲清 history 路由的工作原理、刷新 404 的原因与 Nginx 回退方案;说出 params 变化时组件复用不更新的两个解法(未达标 → 回看第 07 章 §7.1、易错点 1/5)
  • [ ] B2. 讲清状态三分法(本地 UI 态/服务端态/跨页共享态)、Pinia getter 的缓存语义、解构丢响应性的原因与 storeToRefs 的作用(未达标 → 回看第 08 章 §8.1/§8.3/§8.4)
  • [ ] B3. 画出双令牌无感刷新的完整时序:单飞锁、等待队列、请求重放、_retried 死循环防护(未达标 → 回看第 09 章 §9.5、第 11 章 §11.2)
  • [ ] B4. 解释竞态的成因(先发后至)与两种治理方案(AbortController / 序号比对)的取舍(未达标 → 回看第 09 章 §9.6)
  • [ ] B5. 解释金额以「分」为单位的浮点误差根源(0.1 + 0.2 问题),说出哪些层可以转换单位、哪些层不能(未达标 → 回看第 09 章 §9.8 易错点 1、第 10 章 §10.5)

C 组 · 能落地规范(5 条)

  • [ ] C1. 遵守 API 分层约定:组件不直接 import axios,一个业务域一个 api 文件,数据编排进 composable(未达标 → 回看第 09 章 §9.1)
  • [ ] C2. 遵守错误处理约定:请求层 toast 一次、组件用 ApiError.code 分支、静默用 silent 显式申请(未达标 → 回看第 09 章 §9.4)
  • [ ] C3. 魔法数字收敛为常量:订单状态机、权限码、审批阈值(90)全部进 constants/,组件零硬编码业务字符串(未达标 → 回看第 12 章 §12.2/§12.5)
  • [ ] C4. 权限单一事实源:meta.roles 驱动菜单、面包屑与守卫,不存在两处维护(未达标 → 回看第 07 章 §7.5/§7.7、第 11 章 §11.6)
  • [ ] C5. 交付「能被同事接手」的代码:命名与附录A 业务词逐字一致、关键逻辑有注释、每个里程碑留验收记录与遗留问题(未达标 → 回看第 12 章 §12.8)

自评结论:16 条全勾 → 直接进入第三部分;A 组全勾、B/C 组缺 1–2 条 → 回看对应章节后进入;A 组有缺口 → 重做第 12 章对应里程碑练习。


三、30 分钟自测小考(8 题)

不查资料独立完成,每题 3–4 分钟。做完再展开答案对照。

第 1 题 · 路由

/orders/1001 跳转到 /orders/1002,详情页数据没有更新。至少说出两种解法,并说明各自的代价。

参考答案
  1. onBeforeRouteUpdate(推荐):组件复用时响应参数变化,重新拉取数据;代价是要写额外的更新钩子,但组件状态(如滚动位置、已展开的面板)保留。
  2. <router-view :key="$route.fullPath" />:路径变化强制重建组件;实现零成本,但组件状态全部丢失、有重建开销。 另可顺带说明 watch(() => route.params.id, ...) 也可行,与方案 1 等价。(第 07 章易错点 1)

第 2 题 · Pinia

下面这行代码有什么问题?为什么组件界面在登录成功后不更新?

const { profile, isLoggedIn } = useUserStore()
参考答案

store 实例本身是 reactive 对象,直接解构会丢失响应性——profile 与 isLoggedIn 变成一次性快照,后续 store 更新不会同步到组件。正确写法:响应式部分用 storeToRefs(store) 解构;actions 是普通函数,可以直接从 store 解构。(第 08 章 §8.4、易错点 1)

第 3 题 · 金额

已知明细行 { quantity: 3, unitPrice: 129900, discountPercent: 90 }(单价单位为分),写出:①正确的行金额计算;②「0.1 + 0.2 ≠ 0.3」说明的是什么问题、为什么用「分」能规避。

参考答案
// ① 行金额:整数运算后 Math.round 归位到「分」
const itemFen = Math.round(quantity * unitPrice * (discountPercent / 100))
// = Math.round(3 * 129900 * 0.9) = 350730 分 = 3507.30 元

② JavaScript 用二进制浮点(IEEE 754)表示小数,0.1 + 0.2 = 0.30000000000000004。用「分」(整数)存储与求和,加减法完全精确;除法只在最后展示层 fen / 100 出现一次,且误差远小于分币精度,toFixed(2) 即可安全显示。附录A 约定金额单位为分正是此因。(第 09 章 §9.8、易错点 1)

第 4 题 · 无感刷新

三个并发请求同时收到 code: 40102。描述前端应发生的完整过程,以及「刷新接口自身也返回 40102」时如何避免死循环。

参考答案

过程:①响应拦截器识别 40102,进入刷新流程;②单飞锁:只有第一个请求创建 refreshingPromise(调 /auth/refresh),其余两个 await 同一个 Promise,因此 refresh 只发一次;③刷新成功后锁释放,三个等待者各自把原请求带上新 token 重放(_retried = true);④重放请求若再次 40102,说明 refreshToken 也失效——_retried 分支直接 clearAuth() 并 reject 40101,绝不再次刷新。另外刷新请求本身要用裸 axios 发送,不经过响应拦截器。(第 09 章 §9.5、易错点 5)

第 5 题 · 表单

动态明细行的数量校验写法 prop="items[index].quantity" 为什么不生效?正确写法是什么?删除中间一行后校验红字错位,怎么解决?

参考答案

prop 是字符串字面量,index 没有插值,ElForm 在 :model 上找不到 items[index].quantity 这条路径,规则静默失效。正确写法是绑定表达式::prop="items.${index}.quantity"。错位问题两个动作一起治:①:key 用行对象的自增 _key,不用 index(否则删行后 Vue 复用错节点);②删除后 formRef.clearValidate() 清掉历史校验态。(第 10 章 §10.3、易错点 1)

第 6 题 · 权限

对照附录A 的三级权限模型,说出每级「在哪实现、用什么机制」,并回答:前端把 403 做得再好,为什么还必须信任后端?

参考答案

菜单级:菜单配置带 roles,登录后 filterMenus 过滤,决定「看见什么」;页面级:路由 meta.roles + 全局守卫,无权重定向 403,决定「进得去什么」;按钮级:v-permission 指令(或 v-if + hasPermission)控制元素显隐,决定「能点什么」。因为前端代码运行在用户设备上,权限数据(角色、权限码)可被篡改、接口可被直接调用——前端权限只是体验优化,真正的安全边界永远是后端鉴权。(第 11 章 §11.1、§11.5–11.7)

第 7 题 · token 存储

团队要决定 token 存哪里。比较 localStorage 与 HttpOnly Cookie 的风险与收益,并给出「轻订单」(内部中后台)的推荐方案与理由。

参考答案

localStorage:实现简单、跨标签页共享,但 XSS 一旦注入即可读取,适合可控的内部系统并配合 CSP/不引第三方脚本。HttpOnly Cookie:JS 不可读、天然抗 XSS,但引入 CSRF 风险(需 SameSite/CSRF Token 缓解)与跨域配置成本,适合公网高安全要求。推荐(教程口径):accessToken 进内存(Pinia),refreshToken 最小化持久化到 localStorage——刷新页面用 refresh 静默恢复会话,把可被 XSS 偷走的令牌暴露窗口降到最小;决策与缓解措施写入团队规范(ADR)。(第 11 章 §11.4)

第 8 题 · watch

watch(() => draftStore.draft, cb) 在草稿深层字段(如某行数量)变化时可能不触发,而 watch(draftStore.draft, cb) 却会触发。解释原因。

参考答案

draftStore.draft 是 reactive 对象。watch 的第一个参数是 getter(函数)时,Vue 只在「返回值引用变化」时触发——getter 返回的是同一个对象引用,内部属性变化不触发;watch 的第一个参数直接是 reactive 对象时,隐式启用 deep: true,深层变化都会触发。所以前者需要显式 { deep: true },或改为精确监听 () => draftStore.draft.items.length 这类原始值表达式。团队约定:要么整体监听 reactive 对象,要么监听明确的原始值,不写「看似深层实则浅层」的 getter。(第 08 章易错点 4、第 09 章易错点 4)


四、升级高级阶段的判断标准

满足全部三条即可进入第三部分(第 13–18 章),否则继续补中级缺口:

  1. 能力清单自评 ≥ 14 条勾选,且 A 组 6 条全绿——「能做」是硬门槛,「能讲清」允许补课中补齐;
  2. 第 12 章实战验收全绿:附录A §3.1–3.3 的可用版本能跑通「登录 → 动态菜单 → 订单列表/详情/新建 → 三级权限」全链路,10 条功能验收 + 5 条自测用例全部通过;
  3. 产生了「治理诉求」:你能主动说出当前系统的至少三个问题(如万级列表滚动掉帧、首包超预算、核心函数无测试),并且想知道怎么治——这正是第三部分(性能优化、测试、工程化)要解决的问题。当你开始「嫌它慢、怕它坏、想量化」时,就是升级的时候。

准备好了?➡ 进入第三部分 · 第 13 章 渲染性能优化

📄 阶段自查 · 初级能力清单 阶段自查-初级能力清单.md

📍 适用范围:Vue 版 · 初级阶段(第 01–06 章)毕业自查

⬅ 上一章:初级实战 · 企业门户首页 | 📖 本版目录 | ➡ 下一阶段:中级(路由 / Pinia / 服务端交互)

💡 用法:本清单在完成第 06 章实战后使用。逐项打勾,诚实标注;「未达标」项按标注回看对应章节小节,重做该节练习后再来。全清单建议 20 分钟勾完,小考 30 分钟。


① 阶段画像:初级工程师在企业里能做什么

完成初级阶段后,你应该能胜任团队里的这些真实工作:

  • 接手既有 Vue 仓库:拉代码、装依赖、跑起来,30 分钟内说清项目结构与启动链路(第 01 章)。
  • 独立实现页面模块:拿到设计稿和接口文档后,用模板语法 + 组件化渲染出列表、筛选、详情等区块(第 02、03 章)。
  • 封装业务组件:按单一职责拆出 StatusTag、OrderCard、ConfirmDialog 这类组件,接口清晰、同事敢复用(第 03、05 章)。
  • 写「活」的交互逻辑:用 computed/watch 实现筛选联动、金额汇总、状态驱动显隐,不碰反模式(第 04 章)。
  • 交付完整页面:从组件树设计到验收自查,独立交付一个无报错、可达标(含无障碍基础项)的页面(第 06 章)。
  • 边界(还做不到的,也是中级阶段要学的):独立设计路由结构与全局状态、封装接口层、做权限体系、性能优化与测试。

② 能力清单(16 项,三组)

A 组 · 能独立完成(操作层,看结果)

  • [ ] 从零搭建 Node + pnpm + Vite 的 Vue 3 项目,配置 @ 别名与 dev server,并演示一次 HMR —— 未达标 → 回看 第 01 章 §1.5–1.7 与练习 2
  • [ ] 用插值、v-bind/v-on/v-model、v-if/v-show、v-for(稳定 key)实现订单列表 + 筛选栏 + 空态 —— 未达标 → 回看 第 02 章 §2.1–2.8 与练习 2
  • [ ] 封装 props 单向、emit 全量声明、插槽可定制的业务组件(StatusTag / OrderCard / SearchBar)—— 未达标 → 回看 第 03 章 §3.2–3.5 与练习 2
  • [ ] 用 computed + watch 实现多条件筛选联动(关键词 + 状态 + 分页驱动请求参数,含竞态保护)—— 未达标 → 回看 第 04 章 §4.9 与练习 2
  • [ ] 封装 v-model 受控组件(ConfirmDialog / BasePagination)与副作用自动清理的轮询逻辑 —— 未达标 → 回看 第 05 章 §5.2、§5.4、§5.7 与练习 1、2
  • [ ] 按组件树设计先行的方式交付含主题切换的完整页面,并用验收清单自查 —— 未达标 → 回看 第 06 章 §6.1、§6.6

B 组 · 能讲清原理(理解层,看表达)

  • [ ] 说清 key 在虚拟 DOM diff 中的作用,并解释为什么 index 作 key 在增删/排序时会串位 —— 未达标 → 回看 第 02 章 §2.6 与练习 3 实验
  • [ ] 说清 ref 与 reactive 的解包规则(模板顶层 / JS 中 / reactive 属性),以及为什么「解构 reactive 会丢响应式」、toRefs 如何修复 —— 未达标 → 回看 第 04 章 §4.1–4.4
  • [ ] 说清 computed 的缓存语义与「纯计算」纪律,以及 computed / watch / watchEffect 三者的分工与选型 —— 未达标 → 回看 第 04 章 §4.5–4.7
  • [ ] 画出生命周期时序图,说明 onMounted/onUnmounted 各自的职责边界,以及副作用为什么必须成对清理 —— 未达标 → 回看 第 05 章 §5.1–5.2
  • [ ] 说清 provide/inject 与 props 的适用边界(全局低频 vs 组件 API),并对应到 React 的 Context —— 未达标 → 回看 第 05 章 §5.3
  • [ ] 说清组件上的 v-model 的语法糖本质(:modelValue + @update:modelValue)与 React 受控组件的对应关系 —— 未达标 → 回看 第 05 章 §5.4

C 组 · 能落地规范(工程层,看习惯)

  • [ ] 新组件默认多词大驼峰命名、按业务域分目录(order/ common/)、顶部写一句话职责声明 —— 未达标 → 回看 第 01 章易错点 5 与 第 03 章 §3.6
  • [ ] 模板魔法值收敛为映射表/常量(如 ORDER_STATUS_META),文案进 data/ 目录不写死组件 —— 未达标 → 回看 第 02 章 §2.8 与 第 06 章 §6.2
  • [ ] 用「props 接口评审清单」自检组件设计,杜绝巨型配置对象与 props 开关膨胀 —— 未达标 → 回看 第 06 章 §6.5
  • [ ] 遵守安全红线:代码中无真实凭据,示例配置走 import.meta.env.VITE_* 环境变量或占位符;理解生产构建不暴露组件状态的 Devtools 基线 —— 未达标 → 回看 第 01 章 §1.9 与 第 06 章 §6.6

③ 30 分钟自测小考(7 题,闭卷作答后展开对照)

作答方式:拿纸笔或新建笔记,逐题写下答案与理由,再对照折叠块里的参考要点。答错或写不出的题,按括号里的指引回看。

第 1 题(概念):v-if 与 v-show 的区别是什么?给「弹窗显隐」和「tooltip 显隐」分别选一个并说明理由。

参考要点
  • v-if:条件块真实创建/销毁,切换成本高、初始成本低;可作用于 <template>。
  • v-show:元素始终渲染,切换 display 样式,初始成本高(不显示也渲染)、切换成本低;不能用于 <template>。
  • 弹窗 → v-if:低频出现、内部组件较重,不显示时彻底销毁释放资源;tooltip/悬停提示 → v-show:高频切换,避免反复创建销毁。
  • 回看:第 02 章 §2.5。

第 2 题(原理):为什么列表渲染的 key 不能用数组下标?描述一个会出 bug 的具体场景。

参考要点
  • key 是 diff 算法识别节点的「身份证」;有稳定 key 时,diff 按 key 匹配复用/移动节点。
  • 用 index 时,「第 i 个位置」永远被当作同一节点:列表插入、删除、排序后,Vue 会就地改内容而不是移动节点,绑定了内部状态的元素(输入框、复选框勾选态)会与新位置的行错位。
  • 具体场景:订单表格带「备注」输入列,按金额排序后备注内容留在原视觉位置(串行)。正确做法:用业务唯一 id 作 key。
  • 回看:第 02 章 §2.6 与练习 3 的实验。

第 3 题(原理):下面代码哪里丢了响应式?为什么?给出两种修复。

const filter = reactive({ keyword: '', status: 'all' })
const { keyword, status } = filter
keyword = 'QO'
参考要点
  • 解构把 reactive 代理的属性值复制成了普通变量,与代理断开链接,读写不再被追踪,视图不更新。
  • 修复一:不解构,始终 filter.keyword = 'QO' 访问。
  • 修复二:const { keyword, status } = toRefs(filter),之后用 keyword.value = 'QO'(ref 语义)。
  • 延伸:这也是「组合式函数返回 reactive 会被解构坑」的原因,团队约定返回 ref 或 toRefs。
  • 回看:第 04 章 §4.4。

第 4 题(选型):以下三个需求分别用 computed、watch、watchEffect 中的哪个?说明理由。① 订单列表金额合计;② 筛选条件变化后重新请求列表;③ 一组筛选状态变化后同步更新浏览器地址栏 query。

参考要点
  • ① computed:纯派生计算,需要缓存,依赖自动追踪。
  • ② watch:副作用(发请求),显式声明依赖源更可控,可配 immediate 首屏、onCleanup/竞态 id 保护。
  • ③ watchEffect 或 watch 均可:多源联动副作用;watchEffect 自动收集依赖且立即执行,但拿不到旧值;要拿旧值或依赖明确时用 watch 多源数组。
  • 总口诀:computed 负责「算」(纯函数),watch/watchEffect 负责「做」(副作用)。
  • 回看:第 04 章 §4.5–4.7。

第 5 题(手写):写一个 useTitle 组合式函数:接收一个响应式标题,实时同步到 document.title,组件卸载时恢复原标题。说出你用到的每个 API 的作用。

参考要点
import { watch, onUnmounted } from 'vue'

export function useTitle(titleRef) {
  const original = document.title
  const stop = watch(
    titleRef,
    (val) => {
      document.title = val
    },
    { immediate: true }
  )
  onUnmounted(() => {
    stop()            // 同步作用域外保险停止(此处其实可省,watch 在 setup 同步创建会自动停止)
    document.title = original // 恢复原状,属"清理"义务
  })
}
  • watch:显式依赖副作用,immediate 让初始值立即生效。
  • onUnmounted:卸载时恢复 title、清理残留。
  • 评分点:immediate、恢复逻辑、是否说明「同步作用域创建的 watch 会自动停止」。
  • 回看:第 04 章 §4.6、第 05 章 §5.2。

第 6 题(通信):<ConfirmDialog v-model:visible="show" /> 展开后等价于哪些 props/事件?子组件内部关闭弹窗的正确姿势是什么?直接改 props 为什么不行?

参考要点
  • 等价于:传 :visible="show" + 监听 @update:visible。
  • 子组件内部:用 defineModel('visible') 拿到可写 ref,visible.value = false(自动派发 update:visible);或手动 emit('update:visible', false)。
  • 直接改 props.visible = false 不行:props 是单向数据流,父组件不知情,状态所有权被破坏,且运行时报错/警告。
  • 对照 React:这正是受控组件 value + onChange 的约定,只是 Vue 把它标准化成了 v-model。
  • 回看:第 05 章 §5.4 与易错点 4。

第 7 题(规范评审):同事提交了这样的组件,指出至少 4 个问题并给出修改建议。

<script setup>
const props = defineProps({ config: Object })
function remove() {
  props.config.visible = false
  emit('removed')
}
const emit = defineEmits([])
</script>
<template>
  <div style="background: #ffffff">
    <button @click="remove">删除订单</button>
    <slot>{{ config.message }}</slot>
  </div>
</template>
参考要点
  1. 修改 props(props.config.visible = false):破坏单向数据流,应改为 emit('remove-order', id) 让父组件改状态。
  2. defineEmits([]) 空声明却 emit 了 removed:事件未声明会透传成原生事件/丢校验,应全量声明 defineEmits(['remove-order'])。
  3. prop 缺类型/默认值且形如「巨型配置对象」:config: Object 过宽,应切片传具体字段或改插槽定制。
  4. 硬编码色值(#ffffff):绕过设计令牌,暗色主题会出白块,应改 var(--qo-surface)。
  5. 按钮无类型/无语义约束、事件名 removed 载荷为空:建议动宾命名 + 载荷带 id;组件顶部补职责声明注释。
  • 回看:第 03 章易错点 1/3、第 06 章 §6.5、易错点 5。

④ 升级中级的判断标准

满足以下全部条件,再进入 第二部分 · 中级阶段:

  1. 清单层面:② 节 16 项全部打勾(C 组允许最多 1 项「理解但未完全养成」,但要有补齐计划)。
  2. 小考层面:③ 节 7 题中至少 6 题独立答对,第 5、7 题(手写与评审)必须过关——它们检验的是「能不能干活」。
  3. 作品层面:第 06 章门户首页达到验收标准,代码已提交版本库且 commit 信息清晰;建议让一位同事按 §6.6 清单帮你复核一遍(企业里这叫交叉验收)。
  4. 表达层面:能对照 React 版对应章节向同事讲出「同一个需求,两种框架的实现差异」至少 3 处(如 v-model 与受控组件、slot 与 children、watch 与 useEffect)——双栈表达力是本教程的核心卖点,中级阶段会持续强化。

任一条件不满足:按未达标项的「回看」指引补课后重新自查,不要带病升级——中级阶段的路由守卫、Pinia store、接口层全部建立在初级这些地基之上。


🔗 相关链接:第 06 章 · 初级实战|附录 A · 轻订单需求说明书|中级阶段起点 · Vue Router 全解

📄 阶段自查 · 资深能力清单(第 19–24 章) 阶段自查-资深能力清单.md

⬅ 上一章 | 📖 本版目录 | ➡ 下一站

💡 React 版对应文件:阶段自查-资深能力清单(清单条目完全一致,可双栈互验)。业务基准:附录A「轻订单」需求说明书 §6 资深阶段里程碑——模块化拆分、qing-ui 组件库、微前端接入、v2 技术方案。


一、阶段画像:资深工程师在企业里能做什么

资深阶段的产出不再是一个模块、一个优化项,而是**「系统 + 团队 + 决策」三个层面的杠杆**。以「轻订单」为标尺,企业对资深开发的真实预期是:

  1. 为系统的形态负责:面对「轻订单要不要拆」「组件库要不要自建」「v2 怎么演进」,能先评估业务复杂度再给结论,把决策写成 ADR 让半年后的人看得懂为什么——而不是凭偏好与热度拍板;
  2. 为团队的生产关系负责:分支模型、提交与发布链路、Review 文化、跨职能契约成为机器门禁与书面制度,新人照着《协作手册》第一天就能安全上线一行代码;
  3. 为跨团队的技术边界谈判:能对「为拆而拆的微前端」说不,也能在真正需要时(第三方 React 子系统接入)选对方案、立好契约、管住隔离与依赖;
  4. 为技术投入争取资源:一份 3–6 页的 RFC、一次有纪要有行动项的架构评审、一套「本金利息」语言的技术债台账,让正确的技术决策拿到管理层的资源;
  5. 为人的成长搭梯子:onboarding 手册让新人无协助跑通第一天,Review 意见带出处带理由,自己的判断都能被别人复述。

做不到以上五条中的任何一条,说明资深阶段还有缺口,对照下面的清单定位回看。


二、能力清单(16 条,三组自评)

用法:逐条勾选。拿不准的条目给出「未达标 → 回看第 X 章 §Y」的定位,回看后重做该章实战练习再来勾。

A 组 · 能独立完成(6 条)

  • [ ] A1. 完成业务复杂度评估并把轻订单从单体迁移到六层结构(app/views/widgets/features/entities/shared),路由/状态/请求三大基建按约定归位,迁移全程冒烟集护航(未达标 → 回看第 19 章 §19.1–§19.5、练习 1)
  • [ ] A2. 配置分层依赖 ESLint 门禁(跨层 import 与深路径引用均被拦截)并接入 CI,同时写出应用初始化编排器让启动顺序显式可控(未达标 → 回看第 19 章 §19.2/§19.6、练习 2)
  • [ ] A3. 建立 qing-ui 三层令牌体系(暗色主题只改 L2 映射)并交付 ≥ 5 个通过「API 五原则评审 + axe 零违规」的组件,Storybook 文档站含 Do & Don't(未达标 → 回看第 20 章 §20.2/§20.3/§20.6、练习 1/2)
  • [ ] A4. 用 changesets 走通「变更集 → 版本 → CHANGELOG → 私服发布」全链路(token 全程环境变量),并完成一次宿主应用安装验证(未达标 → 回看第 20 章 §20.7、练习 3)
  • [ ] A5. 完成第三方营销子系统接入:按评估清单论证「拆不拆」、按四方案表选型、落地强隔离接入 + 数据通信 + Nginx 路径整合 + 《接入清单》契约文档(未达标 → 回看第 21 章 §21.1/§21.2/§21.7/§21.8、练习 2/3)
  • [ ] A6. 独立产出一份通过 15 条 checklist ≥ 13 条的 RFC(背景量化/目标可验收/非目标/方案对比/灰度回滚/里程碑),并主持一次有纪要有行动项的架构评审(未达标 → 回看第 24 章 §24.1–§24.3、练习 3)

B 组 · 能讲清原理(5 条)

  • [ ] B1. 讲清六层模型的依赖规则与「公共 API 入口」的必要性,并能解释为什么「第二个消费方出现」才是抽象的上提信号(未达标 → 回看第 19 章 §19.2、易错点 1)
  • [ ] B2. 讲清设计令牌三层的分工与暗色主题「组件零感知」的实现原理,以及组件 API 五原则如何对抗 props 膨胀(未达标 → 回看第 20 章 §20.2/§20.3、易错点 1/2)
  • [ ] B3. 对比 Proxy 沙箱与 iframe 隔离的机制差异(伪隔离/真隔离、快照沙箱的局限),说清浮层逃逸、卸载不清理、双 Vue 实例三类事故的根因(未达标 → 回看第 21 章 §21.3/§21.4/§21.6、易错点 2/3/4)
  • [ ] B4. 说清 trunk-based 依赖特性开关的原因、Conventional Commits 与 changesets/CHANGELOG 的数据流关系,以及「格式交给机器、判断留给人」的 Review 分层逻辑(未达标 → 回看第 22 章 §22.1/§22.2/§22.4、易错点 1)
  • [ ] B5. 复述八维度选型框架的五个使用步骤与「一票否决」的意义,讲清防腐层如何把退出成本设计进依赖引入的那一刻(未达标 → 回看第 23 章 §23.2/§23.5、易错点 3)

C 组 · 能落地规范(5 条)

  • [ ] C1. 架构决策纪律:重大结构变更先评估(四因子表)后动工,结论写成 ADR(含备选与后果),决策被推翻时原状态可追溯(未达标 → 回看第 19 章 §19.8、易错点 5)
  • [ ] C2. 协作纪律:PR 模板五块 + 400 行纪律 + 三层评审清单 + 24h SLA 全部落进《协作手册》,每条规范可追溯到 ADR 或事故(未达标 → 回看第 22 章 §22.3/§22.4/§22.8)
  • [ ] C3. 契约纪律:与后端 OpenAPI 先行(类型与 Mock 由契约生成)、与设计交付物对齐令牌、与子系统互附接入清单——跨职能协作靠文档不靠默契(未达标 → 回看第 22 章 §22.7、第 21 章 §21.8)
  • [ ] C4. 选型纪律:权重先于打分记录、证据必须可验证(spike/实测/issue 链接)、结论用「约束 → 结论」句式并附退出方案,star 数不作为独立证据(未达标 → 回看第 23 章 §23.2/§23.3、易错点 1/2)
  • [ ] C5. 债务与发布纪律:技术债台账带本金利息与触发线、Strangler 迁移全程可上线可回退、任何上线默认带灰度计划/降级开关/回滚步骤三件套(未达标 → 回看第 23 章 §23.6/§23.8、第 24 章易错点 6)

自评结论:16 条全勾 → 完成本教程全部正章,按下面第四节规划下一程;A 组全勾、B/C 组缺 1–2 条 → 回看对应章节后视为毕业;A 组有缺口 → 重做第 19–24 章对应实战练习(资深阶段的练习产出本身就是简历素材,不要跳过)。


三、30 分钟自测小考(8 题)

不查资料独立完成,每题 3–4 分钟。做完再展开答案对照。

第 1 题 · 复杂度评估

某内部工具:4 个页面、2 人维护、预计使用半年。组长提议「一步到位上六层分层 + Monorepo + 微前端」。你的评估流程和结论是什么?

参考答案

先用四因子表打分(第 19 章 §19.1):模块 ≤ 3(1 分)、团队 ≤ 2(1 分)、变更波及 1 个模块(1 分)、寿命 < 1 年(1 分),总分 4 ≤ 8 → 保持简单目录,不上分层、不上 Monorepo,更远够不到微前端的「适用信号 ≥ 3 条」门槛(第 21 章 §21.1)。同时把评估结果写进一页 ADR 留档:若未来分数上涨(模块/团队/寿命增长),按「模块化单体 → 抽包 → 拆应用」的路径演进,而不是现在预支成本。这是典型的「为小项目上重装架构」过度设计案例。

第 2 题 · 分层依赖

src/shared/ui/AmountText.vue 里写了 import { useOrderDraftStore } from '@/features/order-create/model/draft' 以便直接展示草稿金额。指出问题、后果与修复方案。

参考答案

问题:违反单向依赖——shared 是最底层,被 features 依赖是合法方向,反向 import 形成「底层依赖上层」的环(第 19 章 §19.2 依赖规则表)。后果:shared 无法被第 21 章的子应用/新端复用(一引入就拖进订单业务与 store),且任何 order-create 的重构都会震到全局基建。修复:AmountText 退回纯展示组件(金额以 prop 传入,单位「分」与格式化由调用方负责);业务取数逻辑上移到消费它的 widget/view;同时确认 ESLint boundaries 规则覆盖 shared → allow: [],让这类代码根本进不了主干。若确实有「多个上层都要展示草稿金额」的需求,正确做法是在 widgets 层建组合块,而不是让基建层长出业务感知。

第 3 题 · 设计令牌

组件库样式里出现了 background: var(--q-brand-500)。为什么暗色主题落地时这会成为事故点?正确的结构是什么?

参考答案

--q-brand-500 是 L1 原始层(调色板),亮暗两套主题下「品牌底色」应该映射到不同的值(亮色 #2563eb、暗色提亮为 #3b82f6 之类)。直接引用 L1 意味着组件绕过了 L2 语义层(--q-color-brand),主题切换时该组件不会随映射变化,只能在暗色分支里逐组件覆盖——覆盖散落多处、维护即考古(第 20 章 §20.2、易错点 2)。正确结构:组件只消费 L2/L3(background: var(--q-color-brand)),[data-theme='dark'] 集中改写 L2 映射,组件层对主题零感知;并用 lint 规则匹配「组件样式引用 L1 原始令牌」直接报错。

第 4 题 · 沙箱与隔离

第三方交付的 React 子应用要接入轻订单基座。有人提议「qiankun 最成熟就选它」。给出你的选型分析(隔离级别判断 + 至少两个否决/支持理由)。

参考答案

先判断信任级别:第三方交付的代码不可信其全局卫生(隐式全局变量、定时器不清理、样式未命名空间)→ 需要真隔离,Proxy 沙箱(qiankun 的伪隔离)不够,应选 iframe 级隔离(wujie 或 micro-app 的 iframe 模式)(第 21 章 §21.2/§21.3)。再叠加工程事实:① 子应用是 Vite/ESM 产物,qiankun 基于 HTML 入口改写 + 生命周期包装,对 Vite ESM 支持弱(需社区补丁,坑多);② 子应用是 React,与基座 Vue 异构,不存在共享运行时的诉求,反而更需要强隔离;③ 沟通面收敛(初始化数据 + 少量事件 + refreshToken 不出基座)在 iframe 模式下同样成立。结论:micro-app iframe 模式(或 wujie),并把浮层归属、卸载清理、路由同步写进《接入清单》契约(§21.5/§21.7/§21.8)。

第 5 题 · 协作链路

团队现状:提交信息是「fix bug」「update」;CHANGELOG 不存在;发版靠「记得改 package.json 版本号」。设计一条从提交到发布的整改链路,说明每个环节由谁(人还是机器)守门。

参考答案

链路(第 22 章 §22.2、第 20 章 §20.7):① commitlint(机器,commit-msg 钩子)拦截不规范 message,scope 枚举与业务域对齐;② PR 模板 + CI(机器)强制「变更类型/关联/自测证据」最小信息集,保护规则要求 ≥ 1 approve 且 CI 绿;③ 每个 PR 附带 changeset 文件(人填摘要,CI bot 校验缺失即拦);④ 合并 main 后 CI(机器,changesets action)自动 version + publish:改版本号、生成 CHANGELOG、发私服——人从「记得改版本」的职责中彻底移除;⑤ version 与 publish 只发生在 CI,本地禁止手改版本号。配套 SLA 与 Review 三层清单(§22.4)保证「为什么改」的信息不丢失——提交历史从此可追溯、可发布、可考古。

第 6 题 · 选型框架

评分表算出来:候选 A 加权 4.2 分、候选 B 3.9 分。有人据此说「选 A,评分表都赢了」。指出这个结论的漏洞。

参考答案

三个漏洞(第 23 章 §23.2/易错点 2/3):① 先问流程再问分数——权重是否在打分前记录?证据是否可验证(spike/实测)?是否存在「先有结论后倒排权重」?评分表可能是证明题;② 一票否决项未核对——若 A 的 License 不合规、无退出方案,4.2 分无意义,硬红线不参与加权、命中即出局;③ 分数差是否超过噪声——0.3 分的差距若来自主观维度(团队现状估分 ±1 即可反转),不足以支撑结论,应回到分歧维度的证据层辩论。正确输出是决策句式:「在 X 约束与 Y 目标下选 A;若 Z 变化则重估」,连同退出方案与重估条件一起归档 ADR——分数是决策的输入之一,不是决策本身。

第 7 题 · 技术债与向上沟通

你需要向 CTO 争取 3 人日偿还「订单筛选竞态缺陷」的技术债(TD-014),怎么组织这次 3 分钟汇报?

参考答案

用四段结构(第 24 章 §24.4)+ 利息语言(第 23 章 §23.6):现状——「筛选参数竞态缺陷存在于订单列表,快速切换状态时旧响应覆盖新响应,已发生 2 次数据展示错误工单」;风险/利息——「每次新增筛选功能都要人工排查竞态,约 0.5 人日/次,Q4 已排期 3 个筛选需求;且它随时可能演变为用户看到错误订单数据的资损观感问题」;建议——「3 人日:复用第 09 章 AbortController 方案重构请求编排 + 回归测试」;资源/回报——「不占用业务排期(建议从 20% 技术专项容量出),还清后未来筛选类需求提速,并同步关闭 TD-014」。要点:全程无一个框架术语,把债务翻译成「已发生的成本 + 未来可避免的成本」,并给出明确的容量归属——管理层批准的不是「修 bug」,而是「一笔利息超过本金预期的负债重组」。

第 8 题 · 方案与评审

一份 RFC 的「目标」写着:「优化门户性能,提升用户体验,并顺便完善权限体系。」指出至少三处问题并改写。

参考答案

问题:① 不可验收——「优化/提升」无基线无数值,无法判断做没做成(第 24 章 §24.1 目标要指标化);② 范围蔓延信号——「顺便完善权限体系」正是非目标段落要拦截的「顺手需求」,权限要么独立 RFC(它有独立的后端契约与灰度风险),要么在非目标里明确排除;③ 缺非目标与对比前置信息——只写目标不写「不做什么」与「为什么是这条路」,评审无从质询。改写示例:「目标:G1 首页/产品页服务端可抓取(Lighthouse SEO ≥ 95);G2 LCP ≤ 2.0s 且 TTFB ≤ 800ms;G3 内容更新实现营销自助(前端零排期)。非目标:admin 不做 SSR(登录态应用无收益);i18n 不在本期;权限体系另见 RFC-0043。」


四、毕业判定与进阶方向

毕业判定

  • 直通:A/B/C 三组 16 条全勾,且毕业项目(第 24 章 §24.6 六周闭环)产出归档(RFC + ADR 集 + 仓库链接);
  • 有条件毕业:A 组全勾、B/C 缺 1–2 条——回看定位章节、重做对应练习后补勾;
  • 暂缓:A 组有缺口——资深阶段的练习产出(组件库、接入契约、RFC)是「能被同事接手」的工程资产,跳过练习等于只读了故事书。

毕业前最后一件事:前往 附录B · 面试题精选 完成资深阶段的 6–8 道题自测,检验「能讲清原理」是否经得起追问。

进阶方向:管理线 vs 专家线

资深之后没有唯一路径,两条线的「日常产出物」差异巨大,选择前先对照:

维度 管理线(工程经理 / 前端负责人) 专家线(架构师 / 领域专家)
核心杠杆 通过他人交付:排期、协作、成长与绩效 通过系统交付:架构、攻坚、技术判断
典型日常 1:1、季度规划(第 24 章 §24.5)、跨部门对齐、招聘 RFC 与 ADR、评审主持(§24.3)、疑难攻坚、技术雷达
本教程的迁移 第 22 章协作手册 → 团队制度;第 24 章向上管理 → 日常工作 第 19/23 章评估与选型 → 技术雷达;第 24 章 RFC → 持续产出
适合你,如果 你看到成员成长比亲自写代码更兴奋,且擅长把模糊目标翻译成清晰排期 你对「系统为什么这样设计」的追问永无止境,且能接受编码密度不降的职业
风险提示 技术钝化:需刻意保留 code review / spike 参与度 影响力局限于技术域:需补表达、跨部门协作能力

两条线不是单行道:3–5 年经验段切换成本低,且顶尖的负责人通常有过专家线的深度、顶尖的架构师通常带过小团队。无论选哪条,本阶段练出的三种底层能力——先评估再决策、把约定变成机器门禁、把技术翻译成业务价值——在两条线上都是复利资产。


🎓 全书自查到此完成。若三个阶段的自查清单全部通过,你已经走完「初级 → 中级 → 高级 → 资深」的完整路径。下一站:附录B · 面试题精选 与 附录C · 学习路线图与资源。

📄 阶段自查 · 高级能力清单(第 13–18 章) 阶段自查-高级能力清单.md

⬅ 上一章 | 📖 本版目录 | ➡ 下一站

💡 React 版对应文件:阶段自查-高级能力清单(清单条目完全一致,可双栈互验)。业务基准:附录A「轻订单」需求说明书 §4 非功能需求。


一、阶段画像:高级工程师在企业里能做什么

过了高级这条线,你的产出不再只是「功能」,而是系统的质量下限。以「轻订单」为标尺,企业对高级开发的真实预期是:

  1. 让系统变快,且能证明变快了:拿到「列表卡」「首屏慢」的模糊抱怨,能用工具定位根因、用正确手段治理、用前后指标对比收尾——而不是「我觉得加个防抖就好了」;
  2. 为系统装上质量防线:核心逻辑有单测(覆盖率 ≥ 80% 是阈值不是装饰)、主流程有 E2E、门禁在 CI 里自动执行,让「改坏了」在合并前被发现;
  3. 把工程约定变成机器约束:TS strict、规范链路、多环境配置、可回滚部署——新人不读文档也很难写出不合规的代码;
  4. 对线上有感知:出了问题先看监控而不是等用户反馈,错误能还原到源码行,P0 十五分钟内有止血动作。

做不到以上四条中的任何一条,说明高级阶段还有缺口,对照下面的清单定位回看。


二、能力清单(16 条,三组自评)

用法:逐条勾选。拿不准的条目给出「未达标 → 回看第 X 章 §Y」的定位,回看后重做该章实战练习再来勾。

A 组 · 能独立完成(6 条)

  • [ ] A1. 治理万级订单列表卡顿:Performance 面板定位 → shallowRef/v-memo/状态拆分减渲染 → RecycleScroller 虚拟滚动,达成「掉帧 < 5%」并留存前后对比数据(未达标 → 回看第 13 章 §13.2/§13.3/§13.5/§13.6)
  • [ ] A2. 用 keep-alive 缓存列表页且不漏内存:include 白名单、max 上限、onActivated/onDeactivated 起停副作用(未达标 → 回看第 13 章 §13.4、易错点 3)
  • [ ] A3. 完成首屏提速:路由/组件分割、图片按角色定策略、缓存与压缩配置,门户 LCP 达到 ≤ 2.0s 且接入 web-vitals 持续观测(未达标 → 回看第 14 章 §14.1–§14.7)
  • [ ] A4. 建立三层测试:Vitest 覆盖率阈值 80%(金额计算/状态机/权限),StatusTag/OrderCard/表单校验组件测试,vi.mock 与 msw 双板斧消灭环境差异(未达标 → 回看第 15 章 §15.2–§15.8)
  • [ ] A5. 交付附录A 冒烟集:Playwright 配置 + storageState 登录态复用 + API 造数隔离,「登录 → 下单 → 审批 → 导出」全链路 5 连跑稳定(未达标 → 回看第 16 章 §16.2/§16.4/§16.5/§16.8)
  • [ ] A6. 完成 TS strict 化与部署链路:泛型组件、API 类型守卫、lint→test→build→deploy 流水线、Docker+Nginx 部署、软链回滚(未达标 → 回看第 17 章 §17.1/§17.3/§17.4,第 18 章 §18.2/§18.4/§18.5)

B 组 · 能讲清原理(5 条)

  • [ ] B1. 画出渲染链路:依赖收集 → 组件级更新 → patch,并据此解释「为什么订阅范围决定性能」、v-memo 与 React.memo 的语义差异(未达标 → 回看第 13 章 §13.1/§13.3)
  • [ ] B2. 说清 keep-alive 的缓存原理(组件实例与 DOM 的去留)、虚拟滚动三要素(标尺占位/可视区切片/DOM 回收),以及切片下标作 key 导致复用错位的原因(未达标 → 回看第 13 章 §13.5、易错点 4)
  • [ ] B3. 讲清四个指标的测量原理与两类数据(实验室 vs RUM)的分工,说出「指纹资源 immutable + HTML no-cache」为何成立(未达标 → 回看第 14 章 §14.1/§14.6)
  • [ ] B4. 解释 flaky 的成因分类与治理五步法,说清 vi.mock(模块层)与 msw(网络层)的拦截位置与选型依据(未达标 → 回看第 15 章 §15.6,第 16 章 §16.7)
  • [ ] B5. 说清构建期 vs 运行期配置注入的机制与适用、sourcemap hidden 模式「生成但不暴露、上传后删除」的原理、tree-shaking 失效的三个条件(未达标 → 回看第 17 章 §17.6/§17.7,第 18 章 §18.6)

C 组 · 能落地规范(5 条)

  • [ ] C1. 性能优化纪律:先测量后优化,PR 必须附 Performance/Lighthouse 前后对比,凭感觉优化打回(未达标 → 回看第 13 章 §13.2、第 14 章 §14.7)
  • [ ] C2. 测试纪律:断言只面向用户可见行为(禁 wrapper.vm 内部断言)、外部依赖零容忍(无真实网络/定时器/时间)、快照只锁小而稳定的数据结构(未达标 → 回看第 15 章 §15.5/§15.7、易错点 1/2/5)
  • [ ] C3. E2E 纪律:数据三原则(API 造数/用例自足/名字唯一)、定位器面向用户、冒烟集失败阻断合并(未达标 → 回看第 16 章 §16.3/§16.5/§16.8)
  • [ ] C4. 类型与安全纪律:网络数据 unknown 起步守卫收窄、禁止 as 压错、VITE_ 前缀只放公开配置(未达标 → 回看第 17 章 §17.4/易错点 1/2/3)
  • [ ] C5. 线上纪律:sourcemap 三道锁(hidden/上传/删除)、告警分级 SOP(P0 止血优先)、每季度回滚演练(未达标 → 回看第 18 章 §18.6/§18.8、易错点 1)

自评结论:16 条全勾 → 直接进入第四部分;A 组全勾、B/C 组缺 1–2 条 → 回看对应章节后进入;A 组有缺口 → 重做第 13–18 章对应实战练习。


三、30 分钟自测小考(8 题)

不查资料独立完成,每题 3–4 分钟。做完再展开答案对照。

第 1 题 · 渲染模型

<tr v-for="order in orders" v-if="filter(order)"> 在 Vue 3 中会怎样?为什么?给出两种正确写法。

参考答案

直接报错:Vue 3 中 v-if 的优先级高于 v-for,先执行 v-if 时循环变量 order 尚不存在(Vue 2 恰好相反,这也是跨版本迁移的经典坑)。正确写法:① <template v-for="order in orders" :key="order.id"><tr v-if="filter(order)">…</tr></template>;② 更推荐把过滤下沉为 computed(visibleOrders = computed(() => orders.filter(...))),模板只做渲染,逻辑可测。(第 13 章易错点 1)

第 2 题 · 渲染缓存

v-memo="[order.status]" 的行里还渲染了 {{ order.ownerName }},会出现什么问题?正确的依赖数组应该是什么?什么情况下干脆不要用 v-memo?

参考答案

依赖数组漏项:ownerName 变化时该行被 v-memo 跳过更新,界面永远显示旧值——这是正确性 bug 而非性能问题。依赖数组必须覆盖子树用到的全部响应式来源,本例应为 v-memo="[order]"(整个对象引用)。当依赖关系复杂、拿不准是否覆盖完整时,不用 v-memo,先做组件拆分与状态下放——那两个手段无正确性风险且收益通常已足够。(第 13 章 §13.3、易错点 5)

第 3 题 · 虚拟滚动

说出虚拟滚动的三要素;解释为什么切片渲染不能用切片下标当 key;RecycleScroller 相比手写简化版多解决了什么问题?

参考答案

三要素:①标尺层占位(撑起总高度,滚动条正确);②可视区切片(按 scrollTop 算起止行号,只渲染这一段);③DOM 回收(滚出视口的行销毁,新行创建)。key 用切片下标:滚动后同一位置的切片下标相同,Vue 判定「节点未变」原地复用 DOM,行内非 props 状态(选中态、输入内容)会串行到错误的数据上——必须用业务身份 row.data.id 或真实下标 row.index。RecycleScroller 额外解决了动态行高(DynamicScroller)与 DOM 节点回收复用(减少创建销毁开销)。(第 13 章 §13.5、易错点 4)

第 4 题 · 加载性能

门户首屏有两张图:Hero 主视觉和页脚配图。分别应该怎么写?另外,为什么 index.html 必须是 no-cache 而 js/css 要 immutable 一年?

参考答案

Hero 是 LCP 元素:立即加载 + fetchpriority="high" + 现代格式(AVIF/WebP)+ 明确宽高,绝不 loading="lazy"(推迟发现 LCP 元素反而劣化指标);页脚配图在首屏以下:loading="lazy" + decoding="async" + 宽高占位。缓存策略:js/css 文件名带内容 hash,内容变则名字变,所以可以一年强缓存 + immutable(浏览器连 304 都不问);index.html 是引用这些 hash 文件名的「目录」,必须每次回源校验(no-cache),否则发版后用户拿到旧 HTML,请求已被删除的旧 chunk → 白屏。(第 14 章 §14.3/§14.6、易错点 1/4)

第 5 题 · 组件测试

两个断言问题:① (wrapper.vm as any).formattedAmount 断言金额格式化;② trigger('click') 后立即 expect(text()).toContain('已提交')。分别说明问题并改正。

参考答案

① 断言了实现细节:改内部命名/实现方式测试就红,而行为没变——测试应该断言用户可见行为:expect(wrapper.text()).toContain('3507.30')。② 异步竞态:Element Plus 校验、接口返回、DOM 更新都是异步的,断言执行时 DOM 可能未更新,表现为偶发失败(flaky);修正:await flushPromises(); await nextTick() 后再断言,或使用带轮询的异步查询 API。(第 15 章 §15.5、易错点 2/3)

第 6 题 · flaky 治理

一条 E2E 用例 CI 上「每 3~5 次失败一次」。给出排查五步法,并指出 waitForTimeout(3000)、.css-3xk2f 定位器、多用例共用一笔测试订单这三个病灶分别怎么治。

参考答案

五步法:复现(--repeat-each=10)→ trace 归因(失败步骤的 DOM/网络快照)→ 分类(测试问题 vs 产品 bug)→ 修复 → 复验(再跑 10 遍全绿)。三个病灶:硬等待改为条件断言/自动等待(断言自带轮询);CSS 类名定位器改为 getByRole/getByTestId(面向用户语义);共享订单改为 API 造数工厂 + 唯一标记(时间戳 + workerIndex),每条用例数据自足、顺序无关。另外 CI retries: 2 只容忍基础设施抖动,被重试救回的用例要打标跟进。(第 16 章 §16.5/§16.7、易错点 1/2/3)

第 7 题 · 类型与安全

接口返回的数据为什么要以 unknown 起步 + 类型守卫收窄,而不是 as Order?VITE_ 前缀的环境变量有什么安全约束?

参考答案

as 是编译期谎言:它不产生任何运行时检查,字段缺失、状态码非法、结构变更都会带着「合法类型」的假象流入组件,在几百行外才炸。unknown 起步强制先校验:isOrder(v) 守卫同时完成运行时校验与类型收窄,脏数据在请求层就被拦截——类型正确性与运行时安全性由同一份代码保证。VITE_ 前缀变量会在构建时被内联进客户端产物,按 F12 可见,因此只允许放公开配置(API 地址、公开 DSN);任何服务端凭据(数据库密码、支付密钥、CI token)不得使用该前缀,只存在于服务端并经 secrets.* 注入。(第 17 章 §17.4/§17.6、易错点 1/2)

第 8 题 · 部署与监控

上线后发现一个报错但堆栈全是压缩代码。描述 sourcemap 的正确处理链路;如果这个错误是「新版本删除了旧 chunk 导致白屏」,止血动作是什么?

参考答案

sourcemap 三道锁:①构建用 sourcemap: 'hidden'——.map 照常生成但产物不引用(用户 DevTools 拿不到源码);②构建时经 @sentry/vite-plugin 按 release 上传到监控平台,压缩堆栈自动还原为真实文件与行号;③filesToDeleteAfterUpload 在上传后删除 .map 文件,杜绝随产物泄漏。白屏止血:这是部署事故(发版清空目录 + 旧 HTML 引用已删 chunk),按 P0 处理——先把软链切回上一版本目录(ln -sfn 回滚,秒级),恢复后再根因修复(保留 ≥ 2 个版本目录、HTML no-cache),最后复盘写防复发动作。(第 18 章 §18.5/§18.6/§18.8、易错点 1/3)


四、升级资深阶段的判断标准

满足全部三条即可进入第四部分(第 19–24 章),否则继续补高级缺口:

  1. 能力清单自评 ≥ 14 条勾选,且 A 组 6 条全绿——高级阶段的「能做」全部落在附录A §4 的量化指标上:列表掉帧 < 5%、门户 LCP ≤ 2.0s、核心模块覆盖率 ≥ 80%、冒烟集全绿,这四条缺一不可;
  2. 第 13–18 章实战验收全绿:万级列表治理有前后对比数据、首屏优化有指标记录、测试与流水线在 CI 真实运转(不是本地跑跑)、部署与回滚演练过至少一次;
  3. 视角完成了转变:你开始问的不是「这个 bug 怎么修」,而是「为什么这类 bug 反复出现、用什么机制让团队不再犯」——当你想把性能 checklist、测试分层、告警 SOP 变成团队资产时,正是第四部分(架构设计、组件库建设、协作规范、技术选型)要回答的问题。

准备好了?➡ 进入第四部分 · 第 19 章 大型项目架构设计

📄 附录B · 面试题精选(Vue 版) 附录B-面试题精选.md

⬅ 返回目录 | ➡ 附录C · 学习路线图与资源

本附录按教程四阶段组织 28 道高频面试题(每阶段 7 题),用于学后自测与面试冲刺。标注 (Vue 特有) 的是框架原理差异化题,与 React 版同位置题目不同,两版不可互相替代。


📚 用途与用法

用法一 · 按阶段定位薄弱项:每学完一个阶段(或做完该阶段实战后),把对应小节当「模拟面试」过一遍——先合上答案口述作答,再展开要点对照;答不完整或说反的题,按要点里的「详见第 X 章」回看对应章节(尤其是各章「常见易错点」),并记入该阶段自查清单的待办。

用法二 · 面试前 7 天冲刺法:

时间 动作
D-7 ~ D-5 通读本附录与四份阶段自查清单,把题分成「答得出 / 答不完整 / 完全答不出」三档并标记
D-4 ~ D-2 只复习后两档标记题;每天大声口述 5 道并录音回听,目标 2 分钟讲清一道
D-1 用 STAR 法整理 3 个项目故事(见文末方法论):最难的 bug、最有价值的优化、一次技术权衡
面试当天 只看标记题要点首条与答题方法论,不学新知识

「参考答案要点」是记忆锚点而非标准答案全文:面试时按文末框架组织语言、结合「轻订单」等真实项目举例,比背诵要点更加分。


一、初级阶段 · 组件与响应式基础(第 01–06 章)

Q1 · ref 和 reactive 怎么选?ref 的解包规则是什么?(Vue 特有)

  • 考察点:响应式 API 选型、.value 心智模型、自动解包的边界。
参考答案要点
  • 组件内默认优先 ref:对原始值与对象统一处理,解构、传参、return 都不易丢响应式;reactive 适合集中定义一组强关联状态(如表单对象)。
  • 解包规则:模板顶层自动解包;reactive 嵌套的 ref 在访问时自动解包;但数组/集合元素、把 ref 挂在普通对象上、解构 reactive 等场景不会自动解包(详见第 4 章)。
  • 典型坑:解构 reactive 后响应式丢失 → 用 toRefs 或改用 ref(详见第 4 章易错点)。
  • 加分项:知道 shallowRef、readonly 的存在与开销取舍(详见第 13 章)。

Q2 · watch 与 watchEffect 有什么区别?如何取舍?(Vue 特有)

  • 考察点:副作用 API 语义、依赖追踪方式、清理时机。
参考答案要点
  • watch:显式声明监听源、默认惰性触发、能拿到旧值,适合「某状态变化 → 发请求/写日志」的精确控制场景。
  • watchEffect:立即执行一次并自动追踪回调内用到的响应式依赖,适合「多个状态 → 一个派生副作用」,依赖是隐式收集的。
  • 取舍与边界:需要旧值、多源合并用 watch;依赖随分支变化用 watchEffect;两者都用 onCleanup 清理上次副作用,操作 DOM 用 flush: 'post',异步创建的 watcher 需手动 stop(详见第 4 章)。

Q3 · v-if 和 v-for 同时用在一个元素上会发生什么?(Vue 特有)

  • 考察点:指令优先级、列表渲染规范、v-if 与 v-show 差异。
参考答案要点
  • Vue 3 中 v-if 优先级高于 v-for,此时 v-if 读不到 v-for 的迭代变量,直接报错(Vue 2 相反,跨版本迁移的高频坑)。
  • 正确写法:外层 <template v-if> 包内层 v-for,或先用 computed 过滤数据再渲染(详见第 2 章易错点)。
  • 追问延伸:v-if 真实销毁/重建(切换开销大),v-show 只切 display(初始开销大),高频切换用 v-show。

Q4 · 什么是 props 单向数据流?子组件为什么不能直接改 props?

  • 考察点:数据流向设计、组件契约、常见反模式。
参考答案要点
  • props 是父到子的单向绑定:父变子跟着变,反向修改会让数据来源不可追踪、组件难复用难调试,Vue 会警告(详见第 3 章)。
  • 需要「可编辑副本」时:本地 ref 接初始值,或封装 v-model(modelValue + update:modelValue)交回父组件修改。
  • 坑:对象/数组 props 的内部 mutation 不触发警告,但同样是反模式,评审要拦(详见第 3 章易错点)。

Q5 · computed 为什么比在模板里调方法高效?缓存语义是什么?(Vue 特有)

  • 考察点:派生状态设计、缓存与失效、副作用边界。
参考答案要点
  • computed 基于响应式依赖缓存:依赖未变时重复读取直接返回缓存值;模板方法每次重渲染都重新执行。
  • getter 必须是同步纯函数;在里面发请求、改状态属于误用,那是 watch/事件的职责(详见第 4 章易错点)。
  • 加分项:依赖失效后并非立即重算,而是标记脏(dirty)、下次读取才重算;需要双向派生用 getter/setter 形式。

Q6 · 组件上的 v-model 是如何工作的?怎么封装一个支持 v-model 的输入组件?(Vue 特有)

  • 考察点:语法糖本质、受控组件封装、多 v-model。
参考答案要点
  • 组件上的 v-model 展开为:传 modelValue prop + 监听 update:modelValue 事件(详见第 5 章)。
  • 封装要点:props 内部只读不写,任何变更都 emit('update:modelValue', v) 交回父组件——即「受控优先」。
  • 加分项:v-model:title 指定参数名实现多 v-model;自定义修饰符(如 trim)通过 modelModifiers 处理。

Q7 · 列表渲染中 key 的作用是什么?用数组下标作 key 会出什么问题?

  • 考察点:虚拟 DOM diff、节点复用策略、状态错位 bug。
参考答案要点
  • key 帮助 diff 识别「同层级节点是不是同一个」,有稳定 key 时正确移动/复用节点,而非无差别就地复用(详见第 2 章)。
  • 用 index 作 key:插入、删除、排序后 index 与数据错位,节点被错误复用,表现为输入框内容串位、勾选错乱等难排查 bug;应使用稳定唯一标识(订单号、id)。
  • 反向技巧:想「强制重建组件」时可故意改 key。

二、中级阶段 · 路由、状态与服务端交互(第 07–12 章)

Q8 · 说出一次导航中 Vue Router 各类守卫的完整执行顺序。(Vue 特有)

  • 考察点:路由系统原理、守卫链路、鉴权落点选择。
参考答案要点
  • 触发导航后:组件 beforeRouteLeave → 全局 beforeEach → 复用组件的 beforeRouteUpdate → 路由独享 beforeEnter → 进入组件的 beforeRouteEnter → 全局 beforeResolve(详见第 7 章)。
  • 导航确认后:全局 afterEach → DOM 更新 → beforeRouteEnter 的 next 回调(拿不到 this,能拿实例 vm);异步路由组件在守卫解析期间加载。
  • 任意守卫返回 false/next(false) 取消导航,抛错进入 onError。
  • 加分项:能说明「鉴权放全局 beforeEach + meta 标记」是性价比最高的落点。

Q9 · 全局前置守卫怎么做登录鉴权与白名单?动态路由要注意什么?

  • 考察点:鉴权流程设计、redirect 回跳、addRoute 时机。
参考答案要点
  • meta.requiresAuth 标记需登录路由,白名单(如登录页)直接放行;未登录跳登录页并带 redirect 参数回跳原地址(详见第 7、11 章)。
  • 登录态判断读 Pinia 用户 store(单一可信源),不在守卫里散落地读 localStorage。
  • 动态 addRoute 场景:注册完成前 next() 会匹配 404,需 next({ ...to, replace: true }) 重新进入一次确保匹配(详见第 11 章易错点)。

Q10 · Pinia 相比 Vuex 做了哪些简化?为什么去掉了 mutations?(Vue 特有)

  • 考察点:状态管理演进、store 设计理念、样板代码权衡。
参考答案要点
  • 简化点:对 TypeScript 天然友好、去模块嵌套改为扁平多 store 且可互相引用、完整支持 Devtools 与热更新、去掉同步/异步强制分层(详见第 8 章)。
  • 去 mutations 的理由:它是为「集中记录同步变更」服务的仪式层,Pinia 通过 action + $patch + 插件已能追踪变更,砍掉后样板代码显著减少。
  • 何时需要全局状态:用「本地态 / 服务端态 / 全局 UI 态」三分法判断,别把一切塞进 store(详见第 8 章最佳实践)。

Q11 · 封装 axios 实例时,请求/响应拦截器分别要做什么?错误如何归一化?

  • 考察点:接口层设计能力、横切关注点收敛。
参考答案要点
  • 请求拦截器:注入 token、附加公共参数(语言、租户)、防重复提交标记(详见第 9 章)。
  • 响应拦截器:统一「剥壳」(取 data)、按业务码分类、把错误归一化为统一结构(message/code/status)再 reject,让上层只处理一种错误形态。
  • 工程细节:超时与重试(仅幂等请求重试)、AbortController 取消上一次请求解决竞态、401 统一走刷新/登出(详见第 9 章最佳实践)。

Q12 · 讲一下无感刷新 token 的实现原理。

  • 考察点:并发控制、请求队列、失败兜底。
参考答案要点
  • 核心流程:请求返回 401 → 用 refreshToken 调刷新接口换新 token → 重放原请求;期间的后续 401 请求进入等待队列,刷新成功后统一重放(详见第 11 章)。
  • 三个关键点:刷新请求标记跳过拦截器防死循环;用共享的「刷新中 Promise」防并发重复刷新;刷新失败则登出并清理用户态。
  • 常见实现:响应拦截器把原请求配置存入队列并返回 new Promise,刷新完成后逐个 resolve 重放。

Q13 · Element Plus 表单校验怎么做联动校验?动态明细行怎么配置规则?

  • 考察点:表单校验体系、动态表单、防重复提交。
参考答案要点
  • rules 声明校验(必填、格式、自定义 async-validator),trigger 控制时机(blur/change);提交前 formRef.validate() 全量校验(详见第 10 章)。
  • 联动校验:watch 依赖字段后调 validateField 校验目标字段,或写跨字段自定义 validator。
  • 动态明细行用路径式 prop(:prop="items.${index}.qty")+ :rules,增删行后规则随索引自然成立;提交置 loading 防重复提交(详见第 10 章实战)。

Q14 · 如何实现按钮级权限指令 v-permission?它安全吗?(Vue 特有)

  • 考察点:自定义指令原理、权限模型、前后端边界。
参考答案要点
  • 自定义指令在 mounted 时比对当前用户权限码集合(来自 Pinia 用户 store)与指令参数,无权限则移除元素;复用组件的动态变化在 updated 处理(详见第 11 章)。
  • 用「移除 DOM」而非仅 display:none,避免用户从开发者工具把隐藏按钮找回来。
  • 安全边界必须答出:前端隐藏只是体验优化,真正的底线是后端接口鉴权,前后端校验缺一不可。

三、高级阶段 · 性能、测试与工程化(第 13–18 章)

Q15 · 讲讲 Vue 的响应式与更新机制,并和 React 的重渲染模型对比。(Vue 特有)

  • 考察点:框架核心原理、两种心智模型差异、优化思路来源。
参考答案要点
  • Vue:基于 Proxy 拦截读/写,渲染时「读了哪些属性」被自动收集为依赖,属性变化只通知订阅它的副作用;编译器还标记静态内容与动态点(patch flag),diff 跳过大部分内容,更新粒度细(详见第 13 章)。
  • React:状态不可变,setState 后组件函数整体重新执行再 diff;靠 memo/useMemo/useCallback 与状态下放等手段手动缩小重渲染范围。
  • 由模型差异推导实践差异:Vue 改对象内部属性即可触发更新,React 必须创建新引用。两者性能都足以支撑企业级应用,复杂度分布不同(一个在响应式系统、一个在渲染调度与手动优化),选型应基于团队与生态而非立场。

Q16 · 虚拟列表的原理是什么?不定高列表怎么处理?

  • 考察点:长列表优化、滚动定位、生产方案选型。
参考答案要点
  • 原理:只渲染可视区 + 上下缓冲条目;外层用总高度撑出真实滚动条,监听滚动计算 startIndex 与偏移,用 transform 移动内容区(详见第 13 章)。
  • 不定高:先按预估高度渲染,渲染后真实测量并缓存每项高度,定位用「已测高度前缀和 + 二分查找」修正。
  • 生产建议直接用成熟库(vue-virtual-scroller / TanStack Virtual);注意快速滚动白屏、滚动锚点、与排序筛选状态联动等坑(详见第 13 章实战)。

Q17 · keep-alive 的缓存原理是什么?有哪些使用限制?(Vue 特有)

  • 考察点:组件缓存机制、生命周期配合、路由缓存实践。
参考答案要点
  • 原理:keep-alive 把组件 vnode 缓存在内部 Map;离开时把 DOM 移出文档但保留组件实例,再进入命中缓存直接恢复、跳过重建(详见第 13 章)。
  • include/exclude 按组件名过滤,max 用 LRU 淘汰防缓存膨胀;被缓存组件改用 onActivated/onDeactivated 做数据刷新、暂停轮询(onMounted 只执行一次)。
  • 常见坑:嵌套 router-view 缓存失效需配合作用域插槽;表单页被缓存会带脏数据,要有选择地缓存(详见第 13 章易错点)。

Q18 · 门户首页 LCP 4 秒、目标 1.8 秒,说说你的分析与落地过程。

  • 考察点:性能指标体系、归因方法、优化手段全景。
参考答案要点
  • 先测量定位:Lighthouse + Performance 面板本地归因,web-vitals 上报真实用户数据;确认 LCP 元素通常是首屏大图或标题区块(详见第 14 章)。
  • 分层落地:网络层(CDN、压缩、缓存策略);资源层(关键图 preload + fetchpriority、WebP/AVIF、字体 font-display);渲染层(骨架屏、路由级代码分割、缩短关键请求链)。
  • 治理闭环:设性能预算 → 每项优化单独验证收益 → CI 加性能门禁与线上持续监控防回退(详见第 14 章最佳实践)。

Q19 · shallowRef 和 v-memo 分别适合什么场景?(Vue 特有)

  • 考察点:细粒度优化 API、开销意识、测量优先原则。
参考答案要点
  • shallowRef:只追踪 .value 本身的替换,内部属性变化不触发更新;适合大型列表、图表实例、第三方复杂对象,内部变化用 triggerRef 手动触发或整体替换(详见第 13 章)。
  • v-memo:模板层记忆一段渲染结果,依赖数组不变就跳过该段 diff;适合大列表「绝大多数行不变、少数行更新」,如 v-memo="[item.id, item.status]"。
  • 共同点:都是「白名单式」跳过响应式追踪或 diff,先测量确认瓶颈再动手(详见第 13 章最佳实践)。

Q20 · 你的组件测试策略是什么?哪些值得测,哪些不值得?

  • 考察点:测试金字塔、投入产出比、用户中心测试理念。
参考答案要点
  • 分层投入:纯函数与组合式函数的单测多写;组件挂载与交互测试挑关键路径;快照测试克制使用(详见第 15 章)。
  • 值得测:金额格式化、表单校验规则、StatusTag 状态映射、OrderCard 渲染分支与边界值。
  • 不值得测:第三方库内部行为、纯样式细节、频繁变更的展示文案(改一次断言一次,维护成本倒挂)。
  • 理念与工具:按用户可见文本/角色查询而非实现细节;用 msw 拦截网络,测试不依赖真实后端(详见第 15 章最佳实践)。

Q21 · CI/CD 流水线怎么设计?生产报错后 sourcemap 如何还原?

  • 考察点:流水线分层、发布安全、错误还原链路。
参考答案要点
  • 流水线:PR 阶段 lint → 类型检查 → 单测 → 构建;合并后自动部署 staging,人工确认或灰度进 prod(详见第 18 章)。
  • sourcemap 还原:构建时注入版本号(如 git sha),.map 只上传监控平台(如 Sentry)而不随 CDN 发布防源码泄露,报错堆栈按 release 匹配还原(详见第 18 章)。
  • 可用 hidden-source-map:浏览器看不到 map、平台侧仍可还原;没有版本绑定的 sourcemap 等于不能用。

四、资深阶段 · 架构、协作与选型(第 19–24 章)

Q22 · 大型前端项目怎么分层?功能切片和技术分层怎么选,依赖规则怎么定?

  • 考察点:架构方法论、模块边界、约束固化手段。
参考答案要点
  • 两种范式:按业务域切功能切片(features/orders、features/customers…),共享能力沉淀 shared/entities;纯技术分层适合业务简单的小项目(详见第 19 章)。
  • 复杂业务优先功能切片:改一个需求大多落在一个切片内,冲突少、可并行;依赖规则单向 pages → features → entities → shared,禁止反向依赖与切片随意互引。
  • 用 ESLint 边界规则把约束固化进 CI 而非靠自觉;横切关注点(鉴权、监控、埋点)插件化注册,应用初始化集中编排(详见第 19 章最佳实践)。

Q23 · 设计一个组件库的公开 API,你会遵守哪些原则?

  • 考察点:组件 API 设计、向后兼容、DX 意识。
参考答案要点
  • 受控优先:值与变更事件成对提供,同时保留默认非受控用法;受控/非受控行为边界写进文档(详见第 20 章)。
  • 组合优于配置:用插槽/复合组件表达结构差异,避免布尔 props 组合爆炸;同一概念全库统一命名词法(如统一 open,不与 visible 混用)。
  • 类型即文档:Props/Emits/Slots 类型完备,善用泛型组件;破坏性变更走 deprecate → 下个大版本移除,配 changelog 与迁移指引。
  • 主题能力靠 design tokens 而非散落样式覆盖;Storybook 文档站与视觉回归守住质量底线(详见第 20 章最佳实践)。

Q24 · 什么时候不该用微前端?主流方案怎么选?

  • 考察点:架构克制、隔离原理、落地难点。
参考答案要点
  • 先问要不要拆:微前端适合多团队独立发布、技术栈异构、存量渐进接入;单一团队、统一技术栈的中型项目引入只会增加复杂度(详见第 21 章)。
  • 方案对比维度:JS/CSS 隔离强度、接入改造成本、运行时性能、维护活跃度——qiankun、micro-app、wujie、Module Federation 各有适用条件,按需评估而非追新。
  • 落地难点在治理:公共依赖共享、应用间通信、路由整合、部署与版本协同,工作量常超过接入本身(详见第 21 章对比表)。

Q25 · 新业务线要选技术栈,Vue 还是 React,你怎么做这个决策?

  • 考察点:选型评估框架、事实性对比、决策表达能力。
参考答案要点
  • 用统一评估框架打分:业务匹配度、生态成熟度、团队现状与学习成本、招聘市场、长期维护成本、退出成本、时间窗(详见第 23 章)。
  • 对比给事实与适用条件:心智模型(响应式驱动 vs 不可变重渲染)、TypeScript 支持、生态规模、招聘供给各有特点;决策输入是团队已有代码资产、成员经验结构与业务形态,而非个人偏好。
  • 决策产出:候选集 → 对比评分表 → 关键场景 PoC → 结论与渐进迁移路径,结论以 ADR 留痕并保留被否方案论证(详见第 23 章选型报告模板)。

Q26 · 什么是 ADR?为什么资深工程师要坚持写?

  • 考察点:决策留痕、组织知识管理。
参考答案要点
  • ADR(Architecture Decision Record):一页纸记录一次重要技术决策的背景、备选方案、最终决策、理由与后果,按编号持续累积(详见第 19、22 章)。
  • 价值:新人 onboarding 能看懂「为什么当初这么选」;避免同样的争论反复发生;决策被推翻时有据可查——是上下文变化而非翻旧账。
  • 时机:不可逆、影响多团队、成本高的决策才写;小事不必,避免文档通胀。

Q27 · 存量系统要从旧架构迁到新架构,你的渐进迁移策略是什么?

  • 考察点:Strangler 模式、风险控制、迁移节奏。
参考答案要点
  • 绞杀者(Strangler)模式:新功能在新架构开发,旧功能按模块逐步迁出,用路由/网关按流量切换,而不是停机大爆炸重写(详见第 21、23 章)。
  • 关键策略:先立模块边界与接口契约;优先迁移变更频繁、价值高的模块;新旧双跑比对验证;每一步可独立回滚。
  • 反面教材要主动讲:整体重写周期长、需求冻结不现实、双维护成本高,失败率远高于渐进迁移。

Q28 · 一份合格的技术方案文档长什么样?架构评审怎么组织?

  • 考察点:技术写作、评审流程、风险前置。
参考答案要点
  • 文档骨架:背景 / 目标 / 非目标 / 方案对比(含被否方案与理由)/ 详细设计 / 灰度与回滚 / 里程碑与风险;「非目标」与目标同等重要,用于防范围蔓延(详见第 24 章)。
  • 评审组织:文档提前发出、会前收书面意见;会中只讨论关键分歧并当场拍板;对照 checklist(安全、性能、可回滚、监控、权限)逐项过。
  • 结论与分歧点沉淀为 ADR;评审通过不是终点,灰度数据与回滚预案是方案的一部分(详见第 24 章实战)。

🧠 面试答题方法论

1. 项目题:STAR 法

  • S/T(情境与任务):一句话交代业务背景与你的职责目标,尽量可量化(如「万级列表渲染卡顿,目标交互不掉帧」)。
  • A(行动):讲关键动作与技术权衡——为什么选 A 不选 B,这是面试官最想听的部分。
  • R(结果):量化收益(LCP 4s→1.8s、构建时间 -60%、缺陷率下降),没有精确数字就给量级。
  • 提前把 3 个故事烂熟于心:最难的 bug、最有价值的优化、一次技术权衡——「轻订单」各阶段实战与一次真实线上事故都可用。

2. 原理题:是什么 → 为什么 → 怎么做 → 边界

  • 是什么:一句话定义先给结论;为什么:解决什么问题,「没有它会怎样」最见理解深度。
  • 怎么做:核心机制与关键 API,能画图或给三五行伪代码最佳;边界:何时不适用、常见坑在哪——主动讲边界的人可信度远高于只背结论的。
  • 示例(Q2):watch 与 watchEffect 的差别在显式源与隐式追踪 → 两个 API 对应「精确触发」与「派生触发」两种形态 → 用筛选联动请求举例 → 收尾提异步创建需手动 stop、DOM 操作用 flush: 'post'。

3. 不会的题怎么处理

  • 诚实承认「没深入用过」,再讲相邻已知与排查思路(「线上遇到我会先从……入手」);编造 API 细节是大忌,原理推导能力比记忆可靠。

4. 反问环节

  • 问技术挑战、Code Review 文化、发布与回滚流程——既是了解真实团队,也展示工程素养;避免只问薪资加班。

⬅ 返回目录 | ➡ 附录C · 学习路线图与资源

📄 附录C · 学习路线图与资源(Vue 版) 附录C-学习路线图与资源.md

⬅ 返回目录 | ➡ 附录B · 面试题精选

本附录给出全书的 ASCII 学习路线图、只收录官方渠道的权威资源清单、路线图与本教程章节的映射表,以及四个选修进阶方向与版本跟进建议。做完任何一个节点,都可以回到本页确认自己在地图上的位置。


🎯 如何使用本页

  1. 开工前:看一遍路线图,确认当前位置与目标位置,估算要走的主干节点数。
  2. 学习中:每完成一章,回到 §3 的映射表勾掉对应节点,并完成该章练习(练习比阅读重要)。
  3. 阶段末:做该阶段「阶段自查清单」,未达标项按清单提示回看章节;再用 附录B 对应阶段的小节口述自测。
  4. 卡住时:先查 §2 的官方文档,再回头读教程对应小节——教程讲方法与易错点,官方文档是 API 的最终依据。
  5. 双栈读者:每完成一个阶段,可对照 React 版同编号章节做一次概念映射复盘(映射表见 00-导读 §5),一次学习两份收益。

🗺 全景学习路线图

主干四阶段按顺序推进,不要跳阶段;分支选修建议在完成第三阶段后再进入,或与第四阶段并行(前置要求见 §4)。图中 §2/§4/§5 指本附录小节。

             【起点】HTML / CSS / JavaScript 基础(资源见 §2 的 MDN)
                                │
                                ▼
 ┌──────────────────────────────────────────────────────────────┐
 │ 第一阶段 · 初级 —— 语法与组件化(第 01–06 章)                 │
 │ 环境搭建 → 模板语法 → 组件基础 → 响应式核心                    │
 │ → 生命周期与组件通信 → 实战:企业门户首页                      │
 └──────────────────────────────┬───────────────────────────────┘
                                ▼
 ┌──────────────────────────────────────────────────────────────┐
 │ 第二阶段 · 中级 —— 状态、路由与服务端交互(第 07–12 章)       │
 │ Vue Router → Pinia → 接口层设计 → 表单校验                    │
 │ → 登录鉴权与权限 → 实战:轻订单管理系统                       │
 └──────────────────────────────┬───────────────────────────────┘
                                ▼
 ┌──────────────────────────────────────────────────────────────┐
 │ 第三阶段 · 高级 —— 性能、测试与工程化(第 13–18 章)           │
 │ 渲染性能 → 加载性能 → 组件测试 → E2E 测试                     │
 │ → TypeScript 与 Vite → CI/CD 部署与前端监控                   │
 └──────────────────────────────┬───────────────────────────────┘
                                │
        ┌───────────────┬───────┴───────┬───────────────┐
        ▼               ▼               ▼               ▼
  ┌───────────┐   ┌───────────┐   ┌───────────┐   ┌───────────┐
  │ 分支 A    │   │ 分支 B    │   │ 分支 C    │   │ 分支 D    │
  │ SSR/Nuxt  │   │ 数据可视化 │   │ 跨端开发  │   │ Node 全栈 │
  │ (§4-A)  │   │ (§4-B)  │   │ (§4-C)  │   │ (§4-D)  │
  └─────┬─────┘   └─────┬─────┘   └─────┬─────┘   └─────┬─────┘
        └───────────────┴───────┬───────┴───────────────┘
                                ▼
 ┌──────────────────────────────────────────────────────────────┐
 │ 第四阶段 · 资深 —— 架构、协作与选型(第 19–24 章)             │
 │ 架构设计 → 组件库建设 → 微前端 → 团队协作规范                  │
 │ → 技术选型决策 → 实战:技术方案与架构评审                      │
 └──────────────────────────────┬───────────────────────────────┘
                                ▼
        【毕业】能独立主导企业级前端项目的设计、落地与演进,
        并用 §5 的习惯持续跟进版本变化

阶段里程碑与验收信号

阶段完成时,你应该有 验收方式
初级:一个组件化的企业门户首页(含暗色主题切换) 初级自查清单 + 附录B 初级小节
中级:可登录、带权限的轻订单管理系统(CRUD 全流程) 中级自查清单 + 附录B 中级小节
高级:性能与质量治理报告(性能预算 + 测试套件 + 流水线) 高级自查清单 + 附录B 高级小节
资深:一份通过评审的技术方案 + 团队协作手册骨架 资深自查清单 + 附录B 资深小节

学习节奏参考下表;各阶段的详细时间分配见 00-导读 §7。

学习节奏建议:

学习模式 建议节奏
在职自学 每周 6–8 小时,四阶段共约 18 周(含练习)
突击面试 按阶段读「学习目标 + 易错点 + 自查清单」,配合 附录B 对应小节定向突破
团队内训 以各阶段「渐进式实战练习」为作业,自查清单作结业考核

📚 权威资源清单(只收官方渠道)

原则:只列长期稳定、由官方维护的文档与站点,不收录易过时的三方博客。教程中的版本号可能演进,一切 API 细节以以下出处为准。

资源 链接 一句话用途
Vue 官方文档 https://cn.vuejs.org/ 框架一切的最终依据,API 疑问第一查询入口
Vue 风格指南 https://cn.vuejs.org/style-guide/ 官方代码风格分级规范(必要/强烈鼓励/谨慎),制定团队规范时以此为基线
Vue 深入响应式系统 https://cn.vuejs.org/guide/extras/reactivity-in-depth.html Proxy 依赖收集与 effect 调度的官方原理篇,回答原理题的权威出处
Vite 官方文档 https://cn.vitejs.dev/ 构建配置、插件机制、环境变量、代理与依赖预构建的手册
Pinia 官方文档 https://pinia.vuejs.org/zh/ 状态管理核心 API、Setup Store 写法、持久化插件的权威参考
Vue Router 官方文档 https://router.vuejs.org/zh/ 路由表、导航守卫、动态路由与滚动行为的完整手册
Element Plus https://element-plus.org/ 本教程使用的组件库,组件 API、主题定制与暗色模式文档
Vitest https://cn.vitest.dev/ 单元测试框架的配置、mock、覆盖率官方文档
Vue Test Utils https://test-utils.vuejs.org/ Vue 组件挂载与交互测试的官方工具,配合 Vitest 做组件测试
Playwright https://playwright.dev/ E2E 自动化测试官方文档:定位器、storageState、trace 与 CI 接入
TypeScript Handbook https://www.typescriptlang.org/docs/handbook/ 类型系统从入门到高级类型的官方教程,第三部分的 TS 基线
MDN Web Docs https://developer.mozilla.org/zh-CN/ DOM、事件、HTTP、CSS 等 Web 平台知识最可靠的参考
web.dev https://web.dev/ Core Web Vitals(LCP/CLS/INP 等)指标定义、测量与优化建议的官方站点

搭配使用建议:

  • 查 API 签名与默认值 → 对应框架/库的官方文档(上表),不凭记忆写、不靠猜。
  • 定代码风格与命名约定 → Vue 风格指南 + 本教程各章「最佳实践」,两者取更严者。
  • 追原理 → 「深入响应式系统」+ 本教程第 13 章,互为印证。
  • 性能与 Web 平台问题 → web.dev(指标)+ MDN(平台行为),这两处内容随规范更新、长期有效。
  • 组件库用法与主题定制(暗色模式)→ Element Plus 文档;教程演示常见用法,完整 API 列表以官网为准。

升级与变更信息的官方入口(GitHub Releases / CHANGELOG)统一见 §5。


🔗 章节映射表(路线图节点 ↔ 本教程章节)

路线图上每个主干节点对应本教程的一章;链接均为相对路径,从本文件出发可达。贯穿项目需求见根目录附录A。

起点 → 第一阶段 · 初级

路线图节点 教程章节
环境搭建与框架概览 第 01 章 · 走进 Vue 3
模板语法与数据绑定 第 02 章 · 模板语法与数据绑定
组件化开发基础 第 03 章 · 组件化开发基础
响应式系统核心 第 04 章 · 响应式系统核心
生命周期与组件通信 第 05 章 · 生命周期与组件通信
实战:企业门户首页 第 06 章 · 初级实战
阶段验收 初级能力自查清单

第二阶段 · 中级

路线图节点 教程章节
Vue Router 路由系统 第 07 章 · 路由系统
Pinia 状态管理 第 08 章 · 状态管理
服务端交互与接口层 第 09 章 · 服务端交互与接口层设计
表单处理与校验 第 10 章 · 表单处理与校验
登录鉴权与权限控制 第 11 章 · 登录鉴权与权限控制
实战:轻订单管理系统 第 12 章 · 中级实战
阶段验收 中级能力自查清单

第三阶段 · 高级

路线图节点 教程章节
渲染性能优化 第 13 章 · 渲染性能优化
加载性能优化 第 14 章 · 加载性能优化
组件测试 第 15 章 · 组件测试
E2E 测试与质量保障 第 16 章 · E2E 测试与质量保障
TypeScript 与 Vite 第 17 章 · 工程化实践
CI/CD 部署与前端监控 第 18 章 · CI/CD 部署与前端监控
阶段验收 高级能力自查清单

第四阶段 · 资深

路线图节点 教程章节
大型项目架构设计 第 19 章 · 大型项目架构设计
设计系统与组件库建设 第 20 章 · 设计系统与组件库建设
微前端与跨团队协作 第 21 章 · 微前端与跨团队协作
团队协作规范 第 22 章 · 团队协作规范
技术选型决策 第 23 章 · 技术选型决策
实战:技术方案与架构评审 第 24 章 · 资深实战
阶段验收 资深能力自查清单

配套附录

内容 链接
贯穿项目「轻订单」需求说明书(全书共用) 附录A
面试题精选(自测与面试冲刺) 附录B
本页 · 学习路线图与资源 附录C

🧭 选修进阶方向(分支 A–D)

四个方向互不依赖,可按兴趣或岗位需要任选;但都建议在完成第三阶段后进入,否则前置能力不足,学习成本会翻倍。

分支 A · SSR / Nuxt

  • 解决什么:纯 CSR 的 SEO 弱与首屏依赖 JS 的短板;Nuxt 提供服务端渲染、静态生成(SSG)与混合渲染的完整方案。
  • 前置要求:熟练掌握第一、二阶段(组件、路由、Pinia);理解第 14 章加载性能与核心指标,否则难以判断「什么页面值得 SSR」。
  • 学习建议:先用 Nuxt 重写「轻订单」门户首页做对照实验,再关注水合(hydration)、服务端数据获取与部署形态。
  • 进入信号:你开始负责面向公网的 C 端页面,或被 SEO/首屏问题反复困扰。
  • 官方文档:https://nuxt.com/

分支 B · 数据可视化(ECharts / D3)

  • 解决什么:报表、大盘与仪表盘场景的图表渲染;轻订单的「报表」模块即是现成练习场。
  • 前置要求:第一、二阶段基础;必须先学第 13 章渲染性能——大数据量图表是性能问题重灾区(shallowRef、按需重渲染、数据采样都在这里派上用场)。
  • 选型直觉:ECharts 上手快、图表类型全,适合企业级 BI;D3 更底层、表达力强,适合高度定制的可视化。按需求复杂度选择,二者不冲突。
  • 进入信号:工作中出现「领导要一块大屏/一组经营报表」的真实需求。
  • 官方文档:https://echarts.apache.org/ | https://d3js.org/

分支 C · 跨端开发(uni-app / 小程序)

  • 解决什么:一套代码同时产出小程序、H5 与 App;uni-app 的组件与语法体系基于 Vue,Vue 3 + Composition API 经验可直接复用。
  • 前置要求:第一、二阶段扎实;了解小程序运行时与浏览器的差异(无 DOM、包体积限制、平台 API 权限模型)。
  • 学习建议:把「轻订单」的移动端查询页做成小程序;重点关注条件编译、分包与各端差异调试的成本。
  • 进入信号:业务需要触达微信生态用户,或公司有既有小程序资产要维护。
  • 官方文档:https://uniapp.dcloud.net.cn/

分支 D · Node 全栈

  • 解决什么:补齐服务端视角——BFF 聚合接口、鉴权代理、定时任务、工具链脚本,与第 09 章的接口层设计形成闭环理解。
  • 前置要求:第一、二阶段完成;建议先学第 18 章(部署与环境配置),理解进程、端口与反向代理后再写服务。
  • 学习建议:为「轻订单」写一个 Mock/BFF 服务,实现无感刷新的 token 接口,反过来加深对拦截器与鉴权链路的理解。
  • 进入信号:你需要独立交付小型全栈项目,或团队要求前端能写 BFF 层。
  • 官方文档:https://nodejs.org/

❓ 常见问题

  • 要不要学 Options API? 本教程只教 Composition API + <script setup>;读懂 Options API 代码是维护存量项目的加分能力,官方文档有完整章节,作为低优先级自学即可。
  • 学 Vue 的同时要不要学 React? 先把一条主干走完,再考虑对照;双栈读者可用 00-导读 §5 的概念映射表配合 React 版同编号章节逐章对照,一次练习两份收益。
  • 分支选修什么时候开始? 完成第三阶段后,或与第四阶段并行;初级阶段贪多选方向是最常见的精力浪费。
  • 教程内容与我装的最新版本不一致怎么办? 一切以官方文档与 CHANGELOG 为准(见 §5),并核对 00-导读 §1 记录的版本基线;教程讲解的是长期稳定的核心概念与工程方法。
  • 学完第四阶段之后怎么继续提升? 回到第 24 章的毕业项目建议:在真实业务里找重构与演进机会,持续输出技术方案、ADR 与复盘——资深阶段的成长来自实战密度,而非更多教程。

🔄 保持更新:跟进版本变化的习惯

前端生态迭代快,能力的一半是「如何跟进变化」。建议养成以下习惯:

1. 读官方 Release Notes / CHANGELOG,而不是二手转述

  • 升级任何依赖前,先通读该版本的 CHANGELOG:重点看 Breaking Changes(必须排期处理)与 Deprecated(提前迁移)两类条目。
  • 关注核心项目的 GitHub Releases:vuejs/core、vuejs/router、vuejs/pinia、vitejs/vite、element-plus/element-plus。
  • 官方博客与公告只在重要版本发布时更新,信噪比远高于社交媒体讨论;发现「教程/文档与实际行为不符」时,以官方文档与 CHANGELOG 为准。

2. 用语义化版本(SemVer)制定升级策略

版本位 含义 升级动作
major 含破坏性变更 安排专项评估,PoC 通过后独立分支迁移并跑全量回归
minor 向后兼容的新特性 按需采用;安全相关的 minor 优先升级
patch 缺陷修复 跟随常规迭代即可

3. 工程上的配套纪律(升级 SOP)

  • 所有项目锁定依赖版本(提交 lockfile),升级永远走独立分支 + 回归测试——第 16 章的 E2E 冒烟集就是升级时的安全网。
  • 定期用 pnpm outdated 盘点依赖差距,避免「三年不升级、一升动全身」;跨多个 major 的升级不直接跳,逐个 major 过渡。
  • 团队内维护一份「升级记录」:升了什么、为什么、踩了什么坑,沉淀成下次升级的 checklist。
  • 版本基线以 00-导读 §1 为准;教程讲的是长期稳定的核心概念与工程方法,个别 API 细节请对照官方文档校正。

⬅ 返回目录 | ➡ 附录B · 面试题精选

📁 配套文件目录

frontend-vue-react-tutorial/
  BLUEPRINT.md
  README.md
  附录A-贯穿项目「轻订单」需求说明书.md
  react-edition/
    00-导读.md
    appendix/
      附录B-面试题精选.md
      附录C-学习路线图与资源.md
    part1-junior/
      01-走进React-框架概览与环境搭建.md
      02-JSX与渲染模型.md
      03-组件化开发基础.md
      04-Hooks核心.md
      05-副作用生命周期与组件通信.md
      06-初级实战-企业门户首页.md
      阶段自查-初级能力清单.md
    part2-intermediate/
      07-路由系统-ReactRouter全解.md
      08-状态管理-ReduxToolkit与Zustand.md
      09-服务端交互与TanStackQuery.md
      10-表单处理与校验.md
      11-登录鉴权与权限控制.md
      12-中级实战-轻订单管理系统.md
      阶段自查-中级能力清单.md
    part3-advanced/
      13-渲染性能优化.md
      14-加载性能优化.md
      15-组件测试.md
      16-E2E测试与质量保障.md
      17-工程化实践-TypeScript与Vite.md
      18-CICD部署与前端监控.md
      阶段自查-高级能力清单.md
    part4-senior/
      19-大型项目架构设计.md
      20-设计系统与组件库建设.md
      21-微前端与跨团队协作.md
      22-团队协作规范.md
      23-技术选型决策.md
      24-资深实战-技术方案与架构评审.md
      阶段自查-资深能力清单.md
  vue-edition/
    00-导读.md
    appendix/
      附录B-面试题精选.md
      附录C-学习路线图与资源.md
    part1-junior/
      01-走进Vue3-框架概览与环境搭建.md
      02-模板语法与数据绑定.md
      03-组件化开发基础.md
      04-响应式系统核心.md
      05-生命周期与组件通信.md
      06-初级实战-企业门户首页.md
      阶段自查-初级能力清单.md
    part2-intermediate/
      07-路由系统-VueRouter全解.md
      08-状态管理-Pinia实战.md
      09-服务端交互与接口层设计.md
      10-表单处理与校验.md
      11-登录鉴权与权限控制.md
      12-中级实战-轻订单管理系统.md
      阶段自查-中级能力清单.md
    part3-advanced/
      13-渲染性能优化.md
      14-加载性能优化.md
      15-组件测试.md
      16-E2E测试与质量保障.md
      17-工程化实践-TypeScript与Vite.md
      18-CICD部署与前端监控.md
      阶段自查-高级能力清单.md
    part4-senior/
      19-大型项目架构设计.md
      20-设计系统与组件库建设.md
      21-微前端与跨团队协作.md
      22-团队协作规范.md
      23-技术选型决策.md
      24-资深实战-技术方案与架构评审.md
      阶段自查-资深能力清单.md

完整源码 Gitee 仓库浏览 →