一套覆盖 初级 → 中级 → 高级 → 资深 完整进阶路径的系统化前端教程,提供 Vue 与 React 两个完全独立、章节一一对应的版本。所有实战围绕同一个企业级贯穿项目「轻订单」(B2B 订单管理 SaaS)展开,拒绝玩具 demo。
| 你是谁 | 建议入口 |
|---|---|
| 刚入行 / 转行前端 | 从对应版本第一部分开始,逐章完成练习 |
| 会用框架但没做过完整企业项目 | 直接进入第二部分,用贯穿项目补齐中后台实战 |
| 3–5 年经验,冲高级/资深 | 第三、四部分 + 各阶段自查清单定向补短板 |
| 团队 Tech Lead / 需要做技术决策 | 第四部分(架构、协作、选型)+ 附录 A 需求文档可直接用作内训教材 |
| 团队里同时有 Vue 和 React 技术栈 | 两版对照阅读,概念映射写在每章开头的「对照学习」框里 |
computed ≈ useMemo、provide/inject ≈ Context)。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 完全对应)
| 阶段 | 章节 | 学完你能做什么 | 每阶段包含 |
|---|---|---|---|
| 🌱 初级 | 01–06 | 独立完成组件化页面开发:组件拆分、props 设计、列表与表单渲染 | 学习目标 · 核心知识点 · 3 级渐进练习 · 易错点对照 · 最佳实践 · 能力自查清单 |
| 🌿 中级 | 07–12 | 独立交付企业中后台:路由与权限、全局状态管理、接口层封装、复杂表单 | 同上 + 能力自查清单 |
| 🌳 高级 | 13–18 | 治理一个真实系统:渲染与加载性能优化、单测/E2E、TypeScript 与工程化、CI/CD 与监控 | 同上 + 能力自查清单 |
| 🏔 资深 | 19–24 | 主导架构:分层设计、组件库、微前端、协作规范、技术选型与方案评审 | 同上 + 能力自查清单 |
每部分末尾都有 阶段自查-XX能力清单.md:12–16 条可勾选项(能独立完成 / 能讲清原理 / 能落地规范 三组)+ 30 分钟自测小考 + 「未达标回看第几章」的补救索引。
四个阶段共用同一业务域,练习任务随阶段递进:
完整需求、接口约定与 TypeScript 实体定义见 附录A。
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。
教程内容为教学虚构场景,文中公司、域名、账号均非真实。
本文件是《Web 前端开发进阶教程(Vue / React 双版本)》的唯一规格基准。 所有章节作者(人与 AI)写章节前必须通读本文件并严格遵守。 后续维护者增补章节时,也以本文件为准,保证两版始终一一对应。
vue-edition/ 与 react-edition/,两版章节编号、目录结构、实战任务、自查清单一一对应,可逐章对照学习。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)。
「要点」是该章必须覆盖的最低知识面。作者可自行加深,不得缺项。
| # | 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/岛屿、权限中台化重构)方案全文示例;向管理层沟通与影响力;季度技术规划;文末链接资深自查清单并给全书毕业项目建议 |
阶段自查-初级能力清单.md 等,两版同名。- [ ] 复选框 ③ 每条注明「未达标 → 回看第 X 章 §Y」④ 一份 30 分钟自测小考(5–8 题,附答案折叠块 <details>)⑤ 升级下一阶段的判断标准。<details> 折叠)、两版差异化题(框架原理题必须针对本框架)。# 第 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 条要点 + 下一章预告一两句)
硬性要求:
## 🎯 学习目标、## 📖 核心知识点、## 🛠 渐进式实战练习、## ⚠️ 常见易错点、## ✅ 最佳实践、## 📌 本章小结。vue(或 ts/js/bash);React 用 tsx/jsx(或 ts/bash)。代码必须语法正确、可直接运行(配合本章语境),命名与业务词贴合「轻订单」。06-xxx.md、../part2-intermediate/07-xxx.md);引用根附录用 ../../附录A-贯穿项目「轻订单」需求说明书.md。## 4.1 形式只在「核心知识点」内使用(编号 = 章号.小节号)。| 维度 | 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 细节改为讲原理+给官方链接。
本附录是 Vue 版与 React 版教程共用的业务需求基准。所有阶段的实战练习都围绕本项目展开,两版教程的练习任务保持一致,因此对照学习时可以精确比较「同一个需求,两种框架怎么落地」。
「轻订单」是虚构公司「青云科技」的内部 SaaS 产品,所有名称、数据、接口均为教学虚构。
青云科技是一家做智能硬件的 B2B 公司,销售渠道包括直销、代理商与电商代运营。随着订单量增长,原来靠 Excel + 微信群接单的方式出了三类高频事故:
于是立项「轻订单」:一个面向内部员工的 B2B 订单管理系统,分两期交付:
| 角色 | 代号 | 核心权限 |
|---|---|---|
| 超级管理员 | super_admin |
全部功能 + 成员管理 + 系统设置 |
| 运营 | operator |
订单查看/创建/编辑、客户管理、商品管理 |
| 销售 | sales |
仅本人名下订单与客户的查看/创建;可提交折扣申请 |
| 财务 | finance |
订单查看、订单审核(折扣>10% 需财务审批)、报表导出 |
权限按三级控制:
super_admin 与 operator 可见;「折扣审批」按钮仅 finance 可见。accessToken(2 小时)+ refreshToken(7 天),支持无感刷新;订单状态机:
草稿 draft → 待审核 pending_review → 已审核 approved → 已发货 shipped → 已完成 completed
│
└─审核拒绝 rejected → (可编辑后重新提交)
任何非终态 → 已取消 cancelled(只能取消人本人或超管操作,需填原因)
pending_review,需财务审批;| 指标 | 目标值 |
|---|---|
| 门户首页 LCP | ≤ 2.0s(4G 网络、中端安卓机) |
| 后台首屏可交互 | ≤ 3.0s |
| 订单列表万级数据滚动 | 无可感知卡顿(掉帧 < 5%) |
| JS 产物单包 | 首屏 gzip ≤ 300KB |
| 单元测试覆盖率(核心模块:订单金额计算、权限判断、工具函数) | ≥ 80% |
| E2E 冒烟集 | 登录 → 下单 → 审批 → 导出,全链路通过 |
| 浏览器兼容 | Chrome/Edge 最近两个大版本 |
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 };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
}
💡 金额单位用「分」是真实企业惯例,教程会在易错点中专门讲浮点求和陷阱。
| 阶段 | 教程章节 | 在「轻订单」上做什么 |
|---|---|---|
| 初级 | 第 01–06 章 | 搭建开发环境;用组件化方式实现官网门户首页(导航/Banner/特性/FAQ/页脚/主题切换) |
| 中级 | 第 07–12 章 | 实现中后台:登录鉴权、动态菜单、订单列表/详情/新建、权限控制,即附录 §3.1–3.3 的可用版本 |
| 高级 | 第 13–18 章 | 对已有系统做治理:万级列表渲染优化、首屏提速、补齐单测与 E2E、接入 TS 与规范链路、搭 CI/CD 与监控 |
| 资深 | 第 19–24 章 | 架构演进:模块化拆分、抽离内部组件库 qing-ui、微前端接入第三方子系统、完成 v2 技术方案与评审 |
*@qingorder.example.com;欢迎来到《Web 前端开发进阶教程》React 版。本版基于 React 19(仅函数组件 + Hooks),带你从初级程序员一路走到企业级资深开发。如果你同时也在学 Vue,本教程有章节一一对应的 Vue 版,可以逐章对照(见「双栈对照学习」)。
| 维度 | 选型 |
|---|---|
| 框架 | 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 以官方文档为准;教程讲解的是长期稳定的核心概念与工程方法。
| 部分 | 章节 | 主题 |
|---|---|---|
| 第一部分 · 初级 | 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 路线图与资源 |
全书所有练习围绕虚构企业级 SaaS「轻订单」展开(B2B 订单管理系统)。四个阶段在同一业务上递进:门户首页 → 中后台系统 → 性能与质量治理 → 架构演进。开工前请先通读 附录A(约 15 分钟),里面有你写练习时要反复使用的角色、状态机、接口约定与 TypeScript 实体。
本教程与 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 |
node -vnpm i -g pnpm && pnpm -v准备好了?打开 第 01 章 · 走进 React。
📍 阶段:初级阶段(第 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。两版的环境搭建与目录解读思路一致,差异集中在「入口文件写法」与「组件文件形态」上。
学完本章,你将能够:
index.html 到可见页面的完整渲染链路;main.jsx 三行核心代码的职责;先读一遍贯穿项目需求:附录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 为准。
| 维度 | 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 ≠ 只能客户端渲染。
先确认工具链版本(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,它锁定依赖版本,保证团队每个人装到的依赖完全一致。
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,
},
},
},
});
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 = 只换「零件」,现场保留。在中大型项目里,这个差距会按「每次保存」放大。
把模板自带的演示内容替换成「轻订单」门户的最小骨架。先认识组件的解剖图:
// 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 章的主角,本章先「照着写、跑起来」。
Components 面板能力(企业里天天用):
Profiler 面板用于录制「哪些组件因什么原因重渲染」,是第 13 章性能优化主角,初级阶段知道即可。
企业场景:接手「轻订单」仓库的第一天,按这个顺序走一遍,半天即可建立掌控感:
① 跑通:pnpm install && pnpm dev,确认页面能打开
② 读壳:index.html → main.jsx → App.jsx,画出入口链路
③ 认树:React DevTools 里从 <App /> 往下展开组件树,截图存档
④ 找配置:vite.config.js 的代理指向哪、.env 系列文件里有哪些约定
⑤ 读规范:README、目录约定、ESLint 规则——先遵守,再谈改进
任务描述:从零创建 qingorder-portal 项目,把 App.jsx 的演示内容替换为「门户骨架」(标题 + 一句 slogan + 一个待办订单列表),验证热更新。
提示:
npm create vite@latest qingorder-portal -- --template react;验收标准:
pnpm dev 启动无报错,页面显示自定义标题与 slogan;// 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;
任务描述:企业项目不允许把接口地址写死在代码里。请:① 新建 .env.development,定义应用名与 API 地址;② 在组件里读取环境变量渲染;③ 把「订单列表」抽到 src/data/ 目录,初步实践「配置与视图分离」。
提示:
VITE_ 前缀的变量,代码里通过 import.meta.env.VITE_XXX 访问;.env 需要重启 dev server;验收标准:
.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;
任务描述:把 §1.7 的「第一天清单」做成一个真实的入门页面:应用名与 API 地址读环境变量;任务清单来自 src/data/onboarding.js;页面顶部显示完成进度(如 2/4)。这个页面未来可以放进团队 wiki 的 onboarding 手册。
提示:
已完成数 / 总数,用 filter + length 计算;useState 后回填);App.jsx 只做拼装,数据与文案都在 data/ 里。验收标准:
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;
// ❌ 组件名小写开头: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 />;
}
// ❌ 相邻的 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 时用简写)
// ❌ 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>
);
}
// ❌ 在渲染期间手动改 DOM:下次渲染会被 React 覆盖回去,状态与视图打架
// 现象:改了又被还原,且调试时「看不出是谁改的」
function App() {
document.getElementById('title').innerText = '被篡改的标题';
return <h1 id="title">轻订单</h1>;
}
// ✅ 视图永远是状态的函数:想改什么,就改驱动它的数据
function App() {
const title = '轻订单 · 企业门户';
return <h1 id="title">{title}</h1>;
}
pnpm-lock.yaml 必须进 Git,保证全组依赖版本一致;Node 版本用 package.json 的 engines 字段声明。.env 系列文件;.env 共享提交,含密钥的 .env.local 加入 .gitignore 永不提交。OrderCard.jsx 导出 OrderCard),新人检索零成本。createRoot 挂载 + 客户端渲染,之后不再整页刷新。index.html → main.jsx(createRoot + StrictMode)→ App.jsx;vite.config.js 管插件与开发代理。.env 系列文件与 VITE_ 前缀,任何真实密钥都不允许出现在代码里。下一章预告:你已经在页面里见过 {}、map 和 key 了。第 02 章系统拆解 JSX 的全部语法——插值、条件、列表、受控表单——并给出虚拟 DOM diff 的正确直觉。
📍 阶段:初级阶段(第 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 表达式。
学完本章,你将能够:
className、对象形式的 style 与 htmlFor 正确完成属性与样式绑定;onClick / onChange / onSubmit 完成事件绑定,说清「传函数引用」与「立即调用」的区别;&&、提前 return 三种条件渲染写法中做出正确选择;map + key 渲染订单列表,并解释 key 的「身份证」作用与下标作 key 的风险;v-model,覆盖输入框、下拉框与复选框;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>;
}
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} />;
}
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 起事件对象不再池化,可以在异步回调里安全使用;事件的底层是「合成事件」统一封装,日常开发按原生事件的直觉写即可。
对应 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 显隐)。
对应 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 的三条纪律:
为什么不能用数组下标当 key?因为 diff 按位置对号入座:删除第 2 项后,原来的第 3 项「顶替」了第 2 项的位置,React 认为位置 2 上的元素还是原来那个——组件内部状态(输入框内容、勾选、展开)会串到错误的行上。规则:列表会插入、删除、排序,就绝不用下标。
key 还有一个进阶用途:换 key 强制重建。给输入框换一个不同的 key,React 会销毁旧的并新建,其内部状态(光标、已输入文字、文本选区)随之清空——这是「清空表单」的正规手段之一,也是 key 与 selection 状态相关的原因。
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>
);
}
先建立两层认知:
① JSX 只是「描述」:编译后是一棵普通的 JS 对象树(元素树)
② 渲染是「对比 + 补丁」:
组件函数重跑 → 得到新元素树 → 与上一次的树按层对比(diff)
→ 只把差异应用到真实 DOM
diff 的两条核心规则,决定了一个组件的状态是否保留:
| 场景 | React 的行为 | 组件状态 |
|---|---|---|
| 同位置的同类型元素 | 保留 DOM 节点,只更新变化的属性 | 保留 |
| 同位置的不同类型元素 | 销毁旧节点(含子树),创建新节点 | 丢失 |
| 列表中 key 相同的项 | 视为同一项,原地更新 | 保留 |
| 列表中 key 变了 / 项被删除 | 对应项销毁重建 | 丢失 |
一句话直觉:React 按「位置 + 类型 + key」认人,认得出就保留状态,认不出就重建。这就是 §2.5 「换 key 清空状态」能用、而下标 key 会串状态的原因。
这是面试与排错都够用的心智模型。真正的调度细节(Fiber、优先级)见官方文档 react.dev/learn/render-and-commit,第 13 章讲性能时会回来深化。
任务描述:附录A 定义了订单 7 种状态(draft / pending_review / approved / shipped / completed / rejected / cancelled)。请实现 StatusTag 组件:输入 status,输出带语义色类的中文标签;再用它渲染一份含全部 7 种状态的订单列表。
提示:
验收标准:
'unknown')不崩溃、有兜底展示;// 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;
任务描述:实现 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;
任务描述:在练习 2 基础上追加:① 排序切换(按创建时间 / 按金额);② 列表中订单号与客户名的命中片段高亮(<mark>);③ 高亮要正确处理「多次命中」。
提示:
sort 会原地修改);text.split(keyword) 切片,切片之间插 <mark>,注意用 Fragment 包住 map 的输出;验收标准:
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;
&& 渲染出孤零零的「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>;
}
// ❌ 下标作 key:删除 / 排序后 React 按「位置」对号入座
// 现象:输入框内容串行、勾选错位、动画错乱
{orders.map((order, index) => (
<OrderRow key={index} order={order} />
))}
// ✅ 用数据里稳定的唯一 ID(后端主键 / 业务编号)
{orders.map((order) => (
<OrderRow key={order.id} order={order} />
))}
// ❌ style 接收字符串、class 用了 HTML 写法:双双报错或告警
<span class="tag" style="color: red;">待审核</span>
// ✅ className 接收字符串,style 接收对象(值跟 CSS 一样用字符串/数值)
<span className="tag" style={{ color: 'red' }}>待审核</span>
// ❌ 只钉住 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)} />
// ❌ if 是语句,表达式位置容不下它:直接编译报错
function StatusBar({ loading }) {
return <div>{if (loading) '加载中…'}</div>;
}
// ✅ 三元是表达式可以直接放;复杂逻辑提到 return 之前算好
function StatusBar({ loading }) {
return <div>{loading ? '加载中…' : '加载完成'}</div>;
}
STATUS_META 这样的映射表,新增状态只改一处。{} 里只能写表达式;属性用 className / 对象 style;事件用小驼峰且传「函数引用」。&&(要么渲染要么不渲染)、提前 return(多分支);v-show 对应 CSS 显隐。map + key;key 是节点的身份证,必须唯一且稳定;换 key 即重建,可用于重置组件状态。v-model:value + onChange 成对出现,数据单向流动。下一章预告:单文件里的组件已经不够用了。第 03 章讲组件化开发基础——props 单向数据流、回调通信、children 与组合,并把 StatusTag、OrderCard、SearchBar 一次性拆出来。
📍 阶段:初级阶段(第 1–6 章)|⏱ 预计学时:3.5 小时|🔗 前置:第 02 章
⬅ 上一章:JSX 与渲染模型 | 📖 本版目录 | ➡ 下一章:Hooks 核心
💡 对照学习:本页是 React 版第 03 章,对应 Vue 版第 03 章 · 组件化开发基础。概念映射:
defineProps≈ 函数参数解构、emit≈ 回调 props(onXxx)、默认插槽<slot>≈children、具名插槽 ≈ JSX props、作用域插槽 ≈ render props(传函数)。两版共同纪律:props 单向数据流、组件单一职责。
学完本章,你将能够:
onXxx)实现子 → 父通信,并在回调中正确携带参数;children 与 JSX props 实现 Vue 插槽的三种对应写法(默认 / 具名 / 作用域);OrderCard、StatusTag、SearchBar 三个可复用业务组件。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)。
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;
三个要点:
compact = false 用参数默认值实现,调用方不传也不崩;onOpen?.() 可选链调用,组件在没有父级监听时也能独立渲染;prop-types:npm i prop-types 后声明 OrderCard.propTypes,团队二选一(JSDoc 或 prop-types)并写进规范,保持全组一致即可。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 章会反复用到。
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 />}
/>
什么时候该拆出一个组件?四个信号满足其一:
以订单看板为例,拆出这条组件树:
OrderBoard(页面容器:持有筛选状态,负责数据加工)
├── SearchBar —— 只管收集关键词,搜什么它不管
├── OrderFilter —— 只管状态下拉与归属筛选
└── OrderList —— 只管把订单数组渲染成列表
└── OrderCard —— 只管一张订单的展示
└── StatusTag —— 只管一个状态标签
拆分的同时要警惕两个反模式:
OrderCard 通过十几个 props 开关变换出所有形态——改用组合(外部传 children / JSX props)代替配置开关。判断口诀:复用靠抽组件,变化靠组合;先写出重复的两份,确认稳定后再抽象。
任务描述:把第 01 章练习里的 StatusTag 升级为正式组件:基于 STATUS_META 配置渲染语义色标签;补充 JSDoc;提供 size 可选 prop('md' | 'sm',默认 'md')。
提示:
src/data/statusMeta.js,未知状态兜底;size 只影响类名,不要出现两套结构;验收标准:
size="sm" 时类名含 tag--sm,不传时默认 md;// 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;
任务描述:实现 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;
任务描述:实现通用容器 Panel(title、footer、children),用两个 Panel 组合出完整看板:「筛选面板」(含 SearchBar)+「订单列表面板」(含 OrderCard 列表与统计 footer);再把列表渲染升级为 render props 版 OrderList,让 OrderBoard 自定义每行渲染。
提示:
Panel 是纯展示组件:不含任何订单业务逻辑;OrderBoard 算好传入 footer;OrderList 只负责遍历与 key,行结构交给 renderItem。验收标准:
Panel 可复用(两处使用结构一致、样式统一);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;
// ❌ 在子组件里直接改 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>;
}
// ❌ 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>;
}
// ❌ 文件里是命名导出,调用处按默认导出导入
// 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';
// ❌ 没处理空内容:调用方条件渲染后 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>
);
}
// ❌ 先展开 props 再写 className:调用方传的自定义样式被悄悄覆盖
<input {...props} className="search-input" />
// ✅ 后写的属性生效:默认值在前,调用方覆盖在后
<input className="search-input" {...props} />
OrderCard.jsx 只导出 OrderCard;公共小组件放 components/,页面放 pages/。order、orders),回调一律 on + 动词(onApprove);布尔用 is/has/only 前缀(onlyMine)。onApprove(orderId) 的参数类型与含义必须注明——回调是组件对外的「API 文档」。onXxx 回调请求父组件。children ≈ 默认插槽、JSX props ≈ 具名插槽、函数 props(render props)≈ 作用域插槽。useState 的正确打开方式。下一章预告:组件会「记东西」了才算完整。第 04 章讲 Hooks 核心:useState 的不可变更新、useMemo 的缓存语义、useEffect 的依赖与清理、过期闭包陷阱,并完成「订单筛选联动」的真实业务闭环。
📍 阶段:初级阶段(第 1–6 章)|⏱ 预计学时:4.5 小时|🔗 前置:第 03 章
⬅ 上一章:组件化开发基础 | 📖 本版目录 | ➡ 下一章:副作用生命周期与组件通信
💡 对照学习:本页是 React 版第 04 章,对应 Vue 版第 04 章 · 响应式系统核心。概念映射:
ref / reactive≈useState、computed≈useMemo(以及渲染中直接派生)、watch / watchEffect≈useEffect(依赖数组即「监听目标」)、Vue「解构丢响应式」陷阱 ≈ React「过期闭包」陷阱。最大差异:Vue 改数据(可变响应式),React 换数据(不可变更新)。
学完本章,你将能够:
useState 管理基础类型与对象 / 数组状态,并坚持不可变更新(展开、map、filter);setState(prev => ...);useMemo 缓存」,为昂贵计算正确选择后者;useEffect,解释三种依赖写法的执行时机;useCallback 稳定函数引用,说清它解决的是「引用相等」问题;useDebouncedValue),并完成「订单筛选联动」业务闭环。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 函数里不行)。
与 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)。
Vue 的 computed 在 React 里有两档对应:
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 不保证永不重算,所以缓存里不能放「有副作用」的逻辑。
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 里用到的响应式值,全部写进依赖,漏写就等着读到旧数据。
函数在组件里每次渲染都会重新创建——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 依赖时,不需要包。
这是 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 对照)
自定义 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 调用。
企业场景(对应附录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 重新同步 → 列表更新。整个链条只有一条「数据 → 视图」的单行道,没有任何手工同步。
任务描述:实现 OrderStatsPanel:展示 7 种状态各自的订单数量;提供「通过全部待审核」按钮(一键把所有 pending_review 改为 approved)和「移除草稿」按钮。
提示:
filter().length 或一次 reduce 算出;map 造新数组;「移除草稿」用 filter;STATUS_META 遍历,别写 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;
任务描述:不看 §4.8 的代码,独立实现 OrderBoard:关键词(防抖 300ms)+ 状态 + 分页驱动 fetchOrderList,含 loading 态与空态;用「快速翻页 + 快速输入」自测竞态保护是否生效。
提示:
query(useMemo)→ useEffect 的三段式结构;ignore 标志是初级阶段最简竞态方案,AbortController 方案在第 05 章 §5.3。验收标准:
见 §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 依赖不完整。
useOrderFilter任务描述:把练习 2 的状态与请求逻辑抽成 useOrderFilter 自定义 Hook,组件里只剩渲染;再补一个 usePagination 管理页码与翻页边界。
提示:
{ keyword, setKeyword, status, ..., orders, loading };验收标准:
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;
// ❌ push / 直接改属性:引用没变,React 认为状态没变,界面不更新
function handleAdd(order) {
orders.push(order); // 修改了原数组
setOrders(orders); // 同一个引用,重渲染被跳过
}
// ✅ 不可变更新:造新数组、新对象,引用变化驱动重渲染
function handleAdd(order) {
setOrders((prev) => [order, ...prev]);
}
// ❌ 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]);
// ❌ setPage 之后读到的 page 还是旧值:状态更新要等下一次渲染
function handleNext() {
setPage(page + 1);
fetchOrderList({ page }); // 这里 page 还是旧值!
}
// ✅ 让请求参数「从状态派生」,而不是在事件里手工拼接
const query = useMemo(() => ({ keyword, page }), [keyword, page]);
useEffect(() => {
fetchOrderList(query).then(setResult);
}, [query]);
// ❌ 定时器闭包捕获了首次渲染的 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);
}, []);
// ❌ query 每次渲染都是新对象:effect 每次渲染后都执行 → 请求风暴
const query = { keyword, status, page };
useEffect(() => {
fetchOrderList(query).then(setResult);
}, [query]);
// ✅ useMemo 缓存参数对象;或把原始值逐个写进依赖
const query = useMemo(() => ({ keyword, status, page }), [keyword, status, page]);
{ keyword, status, page } 拆成三个独立 state,更新逻辑互不牵连;真正需要原子性一起变的再合并。useMemo / useCallback)。PAGE_SIZE = 20 放模块顶层并注释出处(附录A),评审时一眼可查。useXxx。useState 的世界只有不可变更新:新增用展开、更新用 map、删除用 filter;依赖旧值时用函数式更新。useMemo;缓存是优化提示而非保证。useEffect = 与外部世界同步:依赖数组定时机、清理函数定退场;引用类型依赖要先用 useMemo / useCallback 稳定。useXxx 自定义 Hook。下一章预告:useEffect 的时机与清理还有更多讲究——组件的一生如何用「挂载 / 更新 / 卸载」三段表达?跨层共享的主题状态、受控弹窗、可复用表格,正是第 05 章的主题。
📍 阶段:初级阶段(第 1–6 章)|⏱ 预计学时:4.5 小时|🔗 前置:第 04 章
⬅ 上一章:Hooks 核心 | 📖 本版目录 | ➡ 下一章:初级实战 · 企业门户首页
💡 对照学习:本页是 React 版第 05 章,对应 Vue 版第 05 章 · 生命周期与组件通信。概念映射:
mounted / updated / unmounted≈useEffect的三种依赖写法与清理函数、provide / inject≈ Context、v-model组件封装 ≈ 受控组件(value + onChange 下传)、<component :is>≈ 「组件当值用」、递归组件两版同思路。
学完本章,你将能够:
AbortController / ignore 标志处理异步竞态,保证「最后一次请求说了算」;createContext + useContext 实现跨层通信(主题、当前用户),并说明 Context 的适用边界;v-model(value + onChange 接口);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 文档)。写好清理函数,双挂载零感知;不写清理,双挂载立刻现形。
清理函数的纪律:有订阅就要有退订,成对出现。
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 清理,直接打回。
两个纪律: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。
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 的适用边界,初级阶段记三条:
createContext + Provider + useXxx 三件套放一个文件,消费方只 import Hook,不直接碰 Context。第 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 发布说明。
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;
通用表格:列配置驱动 + 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>
</>
);
}
任务描述:基于 §5.7 的 ConfirmDialog,为订单列表的每一行接入「取消订单」确认流:点击按钮 → 弹窗出现 → 确认后该行状态变为 cancelled(不可变更新)→ 弹窗关闭。
提示:
confirming 状态放在行组件里;确认逻辑回调给父级改数据;验收标准:
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)));
}
任务描述:把 §5.7 的 Table 与 Pagination 组合进 OrderBoard:列配置含状态标签与金额(分转元);接上分页状态;数据为空时展示空态文案。
提示:
page 状态放 OrderBoard,onPageChange 直接传 setPage;验收标准:
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;
任务描述:把 §5.4 的 ThemeContext 补完整:主题初始值从 localStorage 读取(异常兜底 light);useEffect 把主题同步到 <html data-theme> 并持久化;给 ConfirmDialog 加「按 ESC 关闭」——注意用带清理的事件监听。
提示:
localStorage 都要 try/catch(隐私模式会抛错);open === true 时才挂,依赖数组写 [open, onCancel];验收标准:
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;
// ❌ 只订阅不退订:组件反复进出页面后监听器越积越多,StrictMode 下一次挂载订阅两份
useEffect(() => {
window.addEventListener('resize', handleResize);
}, []);
// ✅ 订阅与退订成对出现,且用同一个函数引用
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// ❌ 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]);
// ❌ 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>
// ❌ 忘写依赖数组:每次渲染后执行 → setState 又触发渲染 → 再执行 → 死循环
useEffect(() => {
setLogs(fetchLogsSync());
});
// ✅ 明确依赖:只在相关数据变化时同步;同时自问「这真的需要存 state 吗」
useEffect(() => {
setLogs(fetchLogsSync(orderId));
}, [orderId]);
// ❌ 封装了受控组件,父级却没接 onChange:输入框永远是死的
// 现象:敲键盘毫无反应,控制台告警 value 无 onChange
<OrderRemark value={remark} />
// ✅ value 与 onChange 成对下传,数据单向流动,父级仍是唯一真相
<OrderRemark value={remark} onChange={setRemark} />
createContext + Provider + useXxx 收在一个文件,消费方只见 Hook 不见 Context。mounted/updated/unmounted 全部映射到 useEffect 的三种写法。AbortController 保证最后一次请求说了算。useMemo 稳定;受控组件(value + onChange)是 React 版 v-model 封装。下一章预告:零件都齐了。第 06 章把它们组装成完整的企业门户首页——组件树设计先行、目录组织、暗色主题、FAQ 手风琴,并附上 props 接口评审清单与验收标准。
📍 阶段:初级阶段(第 1–6 章)|⏱ 预计学时:6 小时|🔗 前置:第 05 章 + 附录A
⬅ 上一章:副作用生命周期与组件通信 | 📖 本版目录 | ➡ 下一章:第二部分 · 路由系统
💡 对照学习:本页是 React 版第 06 章,对应 Vue 版第 06 章 · 初级实战。两版练习任务完全一致(导航 / Banner / 特性区 / FAQ 手风琴 / 页脚 / 暗色主题切换),可对照实现差异。概念映射:
App.vue≈App.jsx、<script setup>组合式函数 ≈ 自定义 Hook、provide/inject≈ Context、v-model封装 ≈ 受控组件。
学完本章,你将能够:
components/ / data/ / hooks/ 的约定组织项目目录,做到「改文案不碰组件、改逻辑不碰样式」;aria-expanded 完成基础无障碍;useTheme 自定义 Hook 驱动的暗色主题:CSS 变量 + data-theme + localStorage 持久化;附录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);FaqAccordion 内部——外部无人需要它(第 03 章 §3.5 状态下沉);data/ 目录——配置驱动(第 02 章 §2.5);FeatureCard、FaqItem 保持纯展示组件——零状态、零副作用(第 05 章受控思想)。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 组件)。
// 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 集成。' },
];
主题逻辑收口到 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;
}
// 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;
交互设计决策:同一时刻只展开一项(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 做不可变更新即可,组件结构不变。
// 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;
企业 code review 中,组件 API 先于实现被评审。提交前逐条自检:
order、feature),回调 on + 动词(onToggle、onNavigate),布尔带前缀(isActive、onlyMine);onNavigate(linkId) 的参数含义与类型可从 JSDoc 一眼读出;FeatureCard / FaqItem 这类组件不碰 DOM、不发请求、不写存储;openId、confirming)在最近消费者;data/faqs.js,零组件改动。按附录A「门户首页」需求逐条验收,全部通过才算完成初级阶段实战:
pnpm dev 启动无报错,五个区块(导航 / Banner / 特性区 / FAQ / 页脚)完整呈现;aria-expanded 状态正确;pnpm lint 通过;data/ 目录中找到;pnpm build 构建成功,pnpm preview 预览功能无回归。任务描述:从空项目(或本章示例仓库)出发,先完成三个静态区块:数据驱动的 NavBar(含锚点与高亮)、Banner(标题 + slogan + CTA)、Footer。主题切换暂用占位按钮。
提示:
data/navLinks.js,再写组件——「内容先行」是本章的肌肉记忆;activeId 状态放 App,NavBar 是受控组件;验收标准:
navLinks.js;NavBar、Banner、Footer 均为一文件一组件、默认导出。// 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 补回来。
任务描述:完成特性区与 FAQ 手风琴,要求:卡片响应式排布;手风琴单开模式 + aria-expanded;FAQS / FEATURES 数据独立成文件。
提示:
openId(单值),展开逻辑一行:setOpenId(isOpen ? null : faq.id);{isOpen && ...},组件卸载即收起;auto-fill + minmax),React 侧零介入。验收标准:
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?(必须是)
任务描述:实现生产级暗色主题:① useTheme 支持「localStorage → 系统偏好 → light」三级兜底;② 消除「暗色用户刷新时白屏闪烁」(FOUC);③ 主题切换按钮带过渡动画。
提示:
matchMedia('(prefers-color-scheme: dark)') → 'light';index.html 里、React 脚本之前放一段内联脚本提前设置 data-theme;验收标准:
<!-- 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的兜底顺序必须一致,否则「首帧」与「挂载后」可能不同步。
// ❌ 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} />;
}
// ❌ 初始化时直接解析 localStorage:隐私模式抛异常、脏数据炸页面
const [theme, setTheme] = useState(JSON.parse(localStorage.getItem('theme')));
// ✅ try/catch + 白名单校验,异常时回退默认主题(readInitialTheme 内部已处理)
const [theme, setTheme] = useState(readInitialTheme);
// ❌ 把 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)
// ❌ 在渲染过程中直接改 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} />;
}
// ❌ 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>
);
}
data/、逻辑进 hooks/、视图进 components/:三个目录依赖方向单一,改文案不碰组件。button / nav / section),展开类组件带 aria-expanded,弹窗带 role="dialog"。useEffect。pnpm lint 通过设为硬门槛。useTheme(状态 + 副作用 + 持久化)+ CSS 变量令牌 + 可选的防闪烁内联脚本。openId)与内容数据严格分离,单开模式天然互斥。📋 初级阶段毕业检查:完成本章即完成初级阶段全部学习。请打开《阶段自查 · 初级能力清单》,用 30 分钟完成能力勾选与自测小考;达标后即可进入第二部分 · 中级阶段——多页面、全局状态与服务端交互的世界。
📍 阶段:中级阶段(第 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字段。
学完本章,你将能够:
<Routes> 与数据模式 createBrowserRouter)的差别,并解释本教程为什么主讲组件式;<Outlet /> 搭出「轻订单」中后台 Layout 骨架:侧边菜单常驻、内容区随路由切换;React.lazy + Suspense 完成路由级代码分割,并用 DevTools 验证每个页面一个 chunk;useParams 读取动态参数,并解释「参数变化时组件被复用」的原因与两种解法;useSearchParams 把订单列表的筛选条件写进 URL,实现刷新回填与可分享链接;ProtectedRoute 守卫组件完成登录拦截与 redirect 回跳,对照说出它与 Vue 导航守卫的异同;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),后续代码沿用。
中后台系统的经典形态是「登录页独立、其余页面共享一个 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>
)
}
三个必须内化的规则:
/ 开头(path="orders"),相对父路径拼接——写成 /orders 虽然多数场景也能跑,但嵌套迁移时会踩坑;index 路由匹配「父路径本身」,用它把 / 重定向到 /dashboard;path="*" 是 404 兜底,必须放最后——React Router 按声明顺序匹配,兜底放前面会吞掉所有路由。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 即可覆盖本章需求。
订单详情页 /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 变化会自动重新请求并保留缓存,这个问题直接消失。
订单列表的关键词、状态、页码(附录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 派生,刷新后状态原样还原。
表单提交成功后跳转、登录后回跳——这些「代码里决定去哪」的场景用 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 用于「逻辑上替换当前步骤」的场景(登录、提交成功)。
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 世界的「导航守卫」——只是从「框架钩子」变成了「组合进路由树」。
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>
)
}
三个真实项目都会遇到的话题,点到为止(细节见官方文档,不杜撰 API 细节):
<ScrollRestoration />(仅数据路由可用);组件式模式常见做法是在 Layout 里监听 location.pathname 变化后 window.scrollTo(0, 0),或交给第 14 章统一治理。Link/useNavigate 支持 viewTransition 选项启用浏览器 View Transitions API(Chrome/Edge 支持),一行属性即可获得淡入切换,进阶可配 CSS。BrowserRouter 基于 History API,/orders/1001 是前端路由——用户刷新时浏览器会真的向服务器请求这个路径,服务器没有该文件就 404。解法是 Nginx try_files $uri /index.html; 把所有路径回退到 index.html(Vue 版第 07 章同款配置,两版部署完全一致)。若部署到对象存储子目录,BrowserRouter 换 HashRouter 是零成本兜底。所有练习在「轻订单」仓库的
react-app/中进行;Mock 数据先写死在api/模块内(第 09 章换成真实请求层)。
任务描述:完成 §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]} 驱动更省心。
任务描述:①实现 /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 还原,表格数据与筛选一致;见 §7.8 matchMeta:先查精确表,再按段数 + 动态段规则匹配。注意 /orders/new 必须注册在路由表中且排在 /orders/:id 之前(否则 new 会被当成 :id),React Router 会按声明顺序取第一个匹配——这一点和 Vue Router 的路由排序策略一致。
任务描述:①封装 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 章展开)。
验收标准:
/orders → 落在 /login;登录后回到 /orders 且筛选参数不丢;qing_role 设为 sales 后访问 /settings → 403 页;设为 super_admin 后可访问;/dashboard(from 为空的兜底)。// 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> 按钮级权限,本练习的结构不变。
❌ 错误代码:
// /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 强制重建,但优先治本。)
❌ 错误代码:
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>
}
❌ 错误代码:
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 /> 外层
❌ 错误代码:
<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 共用菜单时,就保留默认前缀匹配并自行取舍。
❌ 错误代码:
setSearchParams({ status: 'approved' }) // 把 keyword、page 全弄丢了
setSearchParams(new URLSearchParams()) // 一次性清空,误伤其他筛选
错误原因:setSearchParams(对象) 是整体替换,不是增量合并——只传一个字段就等于「其他字段全部删除」。
✅ 正确写法:
setFilter({ status: 'approved' }) // 始终基于当前 searchParams 构造新对象再增删
// 即 §7.5 的 setFilter:new URLSearchParams(searchParams) → 增删 → setSearchParams(next)
❌ 错误代码:
searchParams.set('page', '2') // 顺手改了对象……地址栏却纹丝不动
错误原因:useSearchParams 返回的实例是不可变快照,直接 .set 不触发任何更新,React Router 感知不到变化。
✅ 正确写法:
const next = new URLSearchParams(searchParams)
next.set('page', '2')
setSearchParams(next) // 让 Router 感知:通过 setter 提交新值
App.jsx 一处;元信息(标题/roles)收敛进 routeMeta,菜单、面包屑、守卫都从它派生,改一处全局生效。LazyPage 一个辅助组件让纪律零成本。useSearchParams 而不是 useState,刷新回填、可分享、可回退,还天然成为第 09 章请求参数的来源。ProtectedRoute 组合进路由树,登录拦截与角色拦截分层;不要在各个页面里散落「if (!登录) 跳转」。replace 用在「步骤替换」语义处:登录、重定向、提交成功跳详情,避免用户后退回到过期页面。try_files $uri /index.html; 与 Vue 版完全一致,上线检查单里永远有这一条。useEffect 依赖治本:key 重建只做兜底;第 09 章接入 TanStack Query 后此问题自动消失。* 路由 + 详情页「订单不存在」分支,任何 URL 都不给用户白屏。<Routes>/<Route> 声明路由,<Outlet /> 是嵌套出口,Layout 壳与内容彻底解耦。React.lazy + Suspense,每个页面独立 chunk;守卫 = ProtectedRoute 组件组合进路由树,Navigate 承担重定向。useParams 读动态参数(组件会被复用),useSearchParams 让筛选住进 URL 成为唯一事实源,useNavigate 负责编程式导航。ProtectedRoute 里的 auth.isLoggedIn() 也会升级为真正的全局状态。📍 阶段:中级阶段(第 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 的状态面板。
学完本章,你将能够:
configureStore 组装 RTK store 并挂上 Provider,说出中间件与 DevTools 的默认配置;createSlice 编写 userSlice,理解 RTK 内置 Immer 后「写法像直接改、实际是不可变更新」的机制;useSelector 订阅状态,解释「selector 返回新引用导致重渲染风暴」的成因并用 createSelector 治理;createAsyncThunk + extraReducers 编写带 pending/fulfilled/rejected 三态的异步逻辑,并用 unwrap 在组件里接住错误;persist 中间件完成持久化,说出哪些状态不该持久化。React 组件间共享数据有两条路:props 逐层传递与全局状态。判断标准不是「几层」而是「谁在用」:
| 状态类型 | 判断特征 | 放哪里 | 「轻订单」例子 |
|---|---|---|---|
| 本地 UI 态 | 只有当前组件(树)关心,切页即弃 | useState |
弹窗开关、表单输入、表格选中行 |
| 服务端态 | 本质是服务端数据的缓存,会过期、需重新拉取 | TanStack Query(第 09 章) | 订单列表、客户详情 |
| 跨页共享态 | 多个页面读写、登录前也存在、与「服务端缓存」性质不同 | 全局 store | 登录用户态、字典缓存、订单草稿 |
初学者最常见的两种病:什么都不敢放全局(props 穿五六层)与什么都放全局(把接口数据、表单草稿、甚至路由参数全塞 store,最后没人敢动)。三分法是第 09 章的心智地基:服务端态交给请求库,store 只管「应用自己的状态」。
React 生态里跨页共享态的两个主流答案:
create),订阅粒度天然到 selector 级,中小项目与局部状态岛首选。本章两个都教:先 RTK 建立规范心智,再 Zustand 体会轻量取舍——面试与存量代码都躲不开 RTK,新项目里 Zustand 的占比在快速上升。
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 默认接好,无需配置。
第 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 订阅。
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 产物)。
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)。
商品分类、客户等级这类字典全站共用,进多个页面都触发请求就浪费了。用 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>
)
}
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 的核心差异:
useShallow(从 zustand/react/shallow 导入)做浅比较;async 函数直接写进 store。怎么选(两版教程口径保持一致):
| 决策因素 | 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。
任务描述:用 RTK 实现 userSlice:setUser / clearUser 两个 reducer;profile 形如 { id: 'u001', name: '青云-运营小李', role: 'operator' }。在顶栏加一个 ProfileBadge 组件显示姓名与角色,未登录显示「未登录」;再提供一个「切换角色」按钮循环 operator → sales → finance → super_admin(为第 11 章权限调试埋点)。
提示:先写 slice 再挂 store 最后在组件订阅,三步各验证一次 DevTools 里的 action 流;「切换角色」一个 action 就够,别写四个。
验收标准:
user/setUser、user/clearUser 的完整 action 与 state diff;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 规范)。
任务描述:实现 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)下次仍可重试。
验收标准:
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 双保险。
任务描述:用 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 可以只存业务字段不存临时态。
验收标准:
qing-draft-order 被清理;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 选型表的体感版。
❌ 错误代码:
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 的合法语法,别混淆边界。)
❌ 错误代码:
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 })))
❌ 错误代码:
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)
❌ 错误代码:
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。)
❌ 错误代码:
// 把「服务端缓存」和「页面临时态」都塞进 store
ordersSlice: 存订单列表数据(要自己处理 loading/竞态/失效……)
uiSlice: 存「弹窗是否打开」「表格选中行」(切页即弃的 UI 态全局化)
错误原因:订单列表是服务端态——放 store 意味着你要自己实现缓存失效、竞态取消、重试,等于手写半个请求库;弹窗开关这类本地态进全局,导致 store 膨胀、组件无法独立复用。
✅ 正确写法:
// 三分法归位:
// 服务端态 → TanStack Query(第 09 章)
// 跨页共享态 → store(用户态/字典/草稿,本章内容)
// 本地 UI 态 → useState 留在组件里
❌ 错误代码:
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 章单独评审;字典/订单数据 ❌(它们有各自的缓存层)
createSelector,组件里零 filter/map。idle/loading/succeeded/failed 四态是字典类数据的通用模板;组件里永远有 loading 与 error 分支。useUserStore((s) => s.x) 而非整个 store;返回对象配 useShallow。partialize 列字段,敏感数据不落盘,落盘 key 统一 qing- 前缀可识别。configureStore(默认带 DevTools)、createSlice(内置 Immer,reducer 内可直改)、useSelector + createSelector(订阅与引用稳定性)、createAsyncThunk + extraReducers(异步三态,unwrap 让错误真正可捕获)。create 覆盖同职责场景:无 Provider、无 dispatch、selector 级订阅、persist 一行持久化;选型看团队规模与存量约束。📍 阶段:中级阶段(第 7–12 章)|⏱ 预计学时:7 小时|🔗 前置:第 08 章
⬅ 上一章:状态管理 | 📖 本版目录 | ➡ 下一章:表单处理
💡 对照学习:本页是 React 版第 09 章,对应 Vue 版第 09 章 · 服务端交互与接口层设计,两版练习任务完全一致(request 实例 → 无感刷新并发验证 → useOrderList 强化版)。概念映射:axios 实例与拦截器两版完全同构;本页的
useOrderList(TanStack Query 版)≈ Vue 版自研useOrderListcomposable;queryKey≈ 请求依赖参数;useQuery的缓存/去重/竞态取消 ≈ Vue 版手写的 AbortController + 缓存 Map;invalidateQueries≈ 手动reload();useMutation乐观更新 ≈ 「先改本地再同步服务端」手写逻辑。两章读完你会发现:手写过的每一行基建,Query 都有对应物。
学完本章,你将能够:
baseURL 环境变量化、token 注入、统一解包、ApiError 错误归一化、silent 静默选项;40102 无感刷新:单飞锁 + 请求重放 + _retried 死循环防护,并画出完整时序;AbortController 与「查询键变更丢弃旧结果」两种手段治理;QueryClientProvider),用 useQuery 声明式获取数据并说出 staleTime 与缓存策略的关系;useMutation + invalidateQueries 完成写操作后的缓存失效,用 onMutate 实现乐观更新与回滚;useOrderList Hook:URL 筛选 → 防抖 → Query 缓存 → 分页翻页不闪烁,对应 Vue 版同名 composable。企业项目接口代码混乱的根源是不分层:组件里直接 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 全站只在请求层弹一次。这四条让「改接口超时时间」「换错误提示组件」变成一处改动。
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 。
// 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——忘写是新手第一大事故(请求头全丢,还没有任何报错)。
附录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 里显式申请静默,责任就清晰了。
双令牌机制(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 同款)。
竞态:搜索框快速输入「订单 → 订单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 的存量项目。
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 三个必知:
useQuery({ ... }) 对象写法(v4 的位置参数已移除);isPending 取代 isLoading 表达「首次加载无数据」;isFetching 表示「含后台刷新在内的取数中」——翻页时想保留旧数据不闪 loading,见 §9.9;['orders', 'list', { page: 1, status: 'draft' }] 中对象成员顺序无关、内容变化即视为新 key——把请求参数全部放进 queryKey 是缓存正确性的根基(易错点 4 的反面)。// 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)。
把 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>
)
}
读用 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 位序号、金额单位分)。
任务描述:完成 §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 打印验证);ApiError,且 toast 各只弹一次;silent: true 时零 toast,错误对象上有 code。// 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 读到——这是请求层「扩展点」的惯用法。
任务描述:实现 §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 试试你的防护是否兜住。
验收标准:
ApiError(40101) 抛给调用方、没有第二次 /auth/refresh;刷新期间新请求会带着旧 token 发出并收到 40102,进入 refreshAndRetry → 调 refreshAccessToken()。此时 refreshingPromise 仍非空(finally 未执行),于是它 await 的是同一个进行中的刷新 Promise——刷新完成后拿到新 token 重放。这就是「单飞锁 + 等待队列」合一的妙处:锁既是去重器也是分发器。时序图见 §9.5。
任务描述:在 §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 的舒服之处。
验收标准:
sort=totalAmount&order=desc,刷新后排序保持;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 的回报。
❌ 错误代码:
// 前端把分转成元再求和、再累加折扣
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) // 仅渲染层调用
❌ 错误代码:
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
)
❌ 错误代码:
useEffect(() => {
fetchOrders({ keyword }).then(setList) // 快速输入时:慢的旧响应最后到达,覆盖新结果
}, [keyword])
错误原因:网络时延不可控,「请求发出顺序 ≠ 响应到达顺序」。没有取消/丢弃机制时,界面会「输入完却显示旧关键词的结果」。
✅ 正确写法:
// 首选:TanStack Query —— key 变化即新查询,旧响应自动丢弃
useQuery({
queryKey: ['orders', 'list', { keyword }],
queryFn: ({ signal }) => fetchOrders({ keyword }, { signal }), // 顺带真正取消请求
})
// 手写场景:AbortController abort 上一个 + 组件卸载清理(§9.6)
❌ 错误代码:
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 里
❌ 错误代码:
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)
❌ 错误代码:
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 里不要重复弹
utils/request.js 全站唯一;api/ 一个业务域一个文件;数据编排进 hooks;视图层禁止 import axios——code review 一眼可查。ApiError.code 分支与 silent 选项,禁止组件里再 try/catch 弹第二遍。_retried 标记(防重放循环)、裸 axios 发刷新(防自递归)——对照 Vue 版逐行同构,双栈团队可共用时序图评审。['orders', 'list', filters],详情 ['orders', 'detail', id];失效时按前缀 ['orders'] 粗失效、按完整 key 细失效。staleTime 显式声明:全局默认 30s,关键列表可调;不配置意味着 0(每次挂载都后台刷新),切页频繁时请求数失控。placeholderData: keepPreviousData 用于翻页:界面不闪空、体验平滑;首次加载仍用 isPending 渲染骨架屏。useDebouncedValue 全站复用,禁止各页面自己写 setTimeout(清理逻辑十写九漏)。VITE_API_BASE_URL 按环境拆 .env.development/.env.production,开发走 Vite proxy 打本地 mock——密钥类变量永远不进前端代码。ApiError 归一化、40102 无感刷新(单飞锁 + 重放 + _retried 防护),与 Vue 版逐行同构。queryKey 声明依赖、staleTime 声明新鲜度、竞态与去重框架全包;useMutation + invalidateQueries 管写操作,onMutate 乐观更新要配快照回滚。useOrderList 是本章集大成:URL 事实源 + 防抖 + Query 缓存 + 翻页保旧,与 Vue 版同名 composable 职责完全对应,第 12 章实战直接复用。📍 阶段:中级阶段(第 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表单字段。
学完本章,你将能够:
register / handleSubmit / formState)实现带校验时机的静态表单;Controller 桥接 AntD 受控组件(Select / InputNumber),说出为什么 register 对它们不生效;useFieldArray 实现明细行的增删改,并解释 fields.id 作 key 与「用 index 作 key」的差别;reset)与防重复提交(isSubmitting),排查 defaultValues 不更新类问题。第 02 章讲过:受控输入每个键击都触发重渲染。三种规模下的选择:
| 表单规模 | 典型场景 | 推荐方案 |
|---|---|---|
| 1–3 个字段 | 搜索框、单个开关 | useState 受控足够 |
| 中型静态表单 | 登录、筛选栏 | RHF 非受控 + schema 校验 |
| 大型动态表单 | 新建订单(明细行可增删) | RHF + Zod + useFieldArray |
RHF 的核心卖点恰好命中中后台表单的痛点:非受控注册(输入值存在表单内部 ref,打字不触发整棵组件树重渲染)、schema 化校验(规则一处定义、可单测、与 UI 解耦)、字段级订阅(useWatch 只重渲染需要的部分)。安装:
pnpm add react-hook-form zod @hookform/resolvers
以「新建订单」的静态字段开刀(客户、备注),体会三件套:
// 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(输入错误字段时即时纠正),无特殊需求不要手写触发时序。
内联规则一多就散。企业口径:校验规则收敛进 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(非法数据) 的用例先于组件存在。
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 组件」,答案决定用哪把刀。
附录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 呈现。
附录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 提前告知审批后果——用户不必等提交失败才知道。
编辑订单 = 异步取数 + 回填表单。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 章)→ 后端幂等键,前端只负责前一层。
任务描述:实现新建订单的静态区:客户(AntD Select,必选)、备注(最多 200 字)。规则进 orderSchema;客户未选时失焦即出红字,选完后红字即时消失;备注超限时按钮仍可点但提交被拦截并高亮错误。
提示:Select 记得用 Controller;errors.xxx.message 渲染在字段下方;先故意提交一次空表单观察校验触发顺序。
验收标准:
onSubmit 未被调用(console 验证);// 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 条。
任务描述:按 §10.5–10.6 完成明细行区:商品搜索下拉、数量(1–9999)、单价(分)、行折扣(0–100),行金额实时计算;添加/删除行;底部汇总订单总额与整单折扣,折扣 >10% 时出现「需财务审批」警告。行级校验错误显示在对应单元格下方。
提示:fields.map 渲染、useWatch 订阅值、useMemo 算汇总;删中间一行后检查「其余行的输入与校验红字是否串位」——串位了说明 key 用错了。
验收标准:
0、10000、1.5 分别被拦截(schema 三条规则逐一命中);// 行内错误: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() 兜底非法输入(如粘贴),两层各司其职。
任务描述:把第 08 章练习 3 的 useDraftStore 接进表单:进入新建页时从草稿 store 回填,字段变更防抖 500ms 写回 store(persist 自动落盘);提交成功后 clear();提交时按整单折扣走「双分支」——折扣 ≤10% 直接 createOrder,>10% 提交后跳详情页并 toast「已提交审核,等待财务审批」(状态应为 pending_review)。
提示:回填用 reset(draft);写回草稿在 useEffect 里订阅 useWatch 的值;两个分支只是提交后的提示与跳转不同,接口按附录A 语义是同一个 POST /orders。
验收标准:
qing-draft-order 内容重置;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),业务规则两版逐字一致。
❌ 错误代码:
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+,团队统一其一)
❌ 错误代码:
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>
❌ 错误代码:
{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 生成、随行存亡 */}
...
❌ 错误代码:
<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
❌ 错误代码:
<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 同样有效
❌ 错误代码:
const allValues = watch() // 💥 订阅整表单:任何字段键入都重渲染整棵表单树(含大表格)
错误原因:watch() 无参调用订阅全部字段,中后台表单字段上百时,打字卡顿立刻出现——这正是 RHF 非受控模式要避免的事。
✅ 正确写法:
const items = useWatch({ control, name: 'items' }) // 字段级订阅:只重渲染消费它的组件
// 且把消费端收进小组件(OrderSummary),配合 useMemo 缓存计算结果(§10.6 结构)
src/schemas/*.js,组件里零内联 max/min;schema 可单测、可复用于提交前兜底校验,对应 Vue 版把 rules 收进独立文件的同款纪律。useFieldArray 的 fields(带稳定 id)负责渲染,useWatch 的值负责计算,两者绝不能互换用途。formatFen 转元),单位转换只发生在渲染层,写入 utils/money.js 全站共用。Alert 提前告知;跨字段硬校验进 schema refine——别让用户提交后才挨打。useEffect + reset 或 useForm({ values }),团队规范二选一;defaultValues 只用于「确定存在的初值」。isSubmitting 禁用按钮 → 请求层防抖/去重 → 后端幂等;前端永远假设用户会双击。mode 前先想清楚产品诉求,不要为了「高级感」调参。zodResolver 一行接入。Controller 桥接;原生 input 走 register;选错注册方式是最常见的「静默失效」。useFieldArray(fields 渲染 + watch 计算)+ 行级路径 items.${index}.quantity;金额联动全程以分为单位,useWatch + useMemo 保证联动不拖垮性能。reset(或 values),defaultValues 不更新的根因是「只读挂载那一刻」;防重复提交从 isSubmitting 开始。<Auth> 与 403 页。📍 阶段:中级阶段(第 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 版登出时的全量状态清理。
学完本章,你将能够:
ProtectedRoute:会话恢复期防闪跳、roles 参数做页面级拦截、403 页分流;usePermission Hook 与 <Auth> 按钮级权限组件,把附录A 的按钮权限(删除订单、折扣审批)落成声明式代码;附录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)
职责边界一句话:前端管「令牌的携带与呈现层的拦截」,后端管「真伪与权限」。验证码次数锁定、角色判断的服务端复核、接口鉴权全部在后端——前端任何权限代码都只是体验优化,不是安全边界。
第 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 后调用
}
这是每个团队都要正式做的决策,用对照表而不是拍脑袋:
| 维度 | localStorage | HttpOnly Cookie |
|---|---|---|
| JS 可读性 | 可读——XSS 一旦注入即可窃取 | 不可读——天然抗 XSS 窃取 |
| CSRF 风险 | 无(不自动携带) | 有——需 SameSite / CSRF Token 缓解 |
| 跨标签页共享 | 天然共享 | 天然共享 |
| 过期控制 | 自己写清理逻辑 | Expires/Max-Age 浏览器托管 |
| 实现成本 | 低 | 需要后端 Set-Cookie + 跨域配置 |
教程推荐口径(写入团队规范 / ADR,并说清缓解措施):
accessToken 放内存(user store)——最短暴露窗口,刷新页面即失效;refreshToken 最小化持久化到 localStorage——换取「刷新页面免重新登录」的体验;SameSite=Lax 起)并把 CSRF 防护写进后端方案。本章代码沿用第 09 章 request.js 的 localStorage 口径(qing_access_token / qing_refresh_token),并在第 12 章验收清单里复查这一决策记录。
用第 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
}
第 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>} />
)
}
菜单级权限(三级模型第一级)的关键不是「过滤」这个动作,而是菜单与守卫共用一份配置——否则改角色必然漏改一处:
// 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一处——这正是「单一事实源」设计预留的接缝。
<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 可以渲染「无权限占位」而非彻底消失。
登出不是「删个 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(),保证「任何路径的会话失效」行为一致。
任务描述:实现 §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;/login 自动跳工作台。// 回跳核心(onSubmit 内):
const from = location.state?.from ?? '/dashboard'
navigate(from.startsWith('/') ? from : '/dashboard', { replace: true })
// 失败保字段的原理:RHF 非受控值存在表单内部,catch 里不调 reset 就天然保留;
// 只需重置验证码一项:
// setValue('captchaCode', '') + reloadCaptcha()
<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>
)
}
任务描述:①把「40102 制造机」(第 09 章练习 2)接入登录后的真实流程:登录 → 停 2 秒 → 任意请求触发 40102 → 观察自动刷新 + 重放 + 界面无感;②手动把 qing_refresh_token 改成非法值再触发刷新,验证「清会话 → 跳登录」且不出现死循环;③实现 §11.8 useLogout,验证登出后 DevTools 里 localStorage 无 token、Redux DevTools 中 user 为空、React Query Devtools 缓存为空。
提示:恢复会话(restoreSession)也要联调:登录后刷新浏览器,应静默恢复到登录态而不是闪跳登录页;isReady 未就位期间 ProtectedRoute 渲染 spinner。
验收标准:
/auth/refresh,原请求重放成功,无任何 toast;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 发出,自身响应不再进入拦截器——两道防线缺一即循环。
❌ 错误代码:
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
❌ 错误代码:
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 }} />
❌ 错误代码:
{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 一处;未登记的权限码默认拒绝
❌ 错误代码:
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 上下文)
❌ 错误代码:
dispatch(clearUser())
auth.clear()
navigate('/login') // 💡 TanStack Query 缓存原封不动
// 新账号登录后访问列表页:先渲染上一账号的缓存数据(isPending 都不会出现)
错误原因:React Query 的缓存挂在 QueryClient 上,独立于 Redux/Zustand 与 localStorage;只清后两者,等于给下个用户留了一份「上个账号的数据快照」——真实系统里属于越权数据泄漏事故。
✅ 正确写法:
queryClient.clear() // 登出闭环第 ③ 步(§11.8);或 removeQueries({ queryKey: ['orders'] })
❌ 错误代码:
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 }) // 只允许站内路径
filterMenus(role)、页面级 ProtectedRoute roles、按钮级 <Auth allow>——三级共用「角色/权限码」一份事实源,改权限永远只改配置。<Auth> 隐藏按钮的同时,接口层的 40301 处理(toast + 引导)必须存在。accessToken 内存化 + refreshToken 最小化持久化是内部系统的默认起点。isReady 状态 + restoreSession thunk,守卫「恢复期不判定」;任何「刷新闪跳登录页」都是这个状态缺失的症状。_retried、裸 axios;联调时必测「并发 40102」「刷新自身失败」「恢复会话」三条路径。queryClient.clear()、草稿等本地持久化——用 useLogout 收口成唯一入口,禁止散落清理。silent: true 让换图请求的失败不打扰用户;锁定提示如实透传后端 message。order:delete 这类「域:动作」命名写进常量表;查无此码一律视为无权限,宁可多拦不可漏放。ProtectedRoute 是 React 版导航守卫:isReady 防闪跳、未登录重定向带 from、roles 页面级拦截进 403;403 页要给出路。filterMenus;按钮级 = usePermission + <Auth> 组件(对应 v-permission),权限码常量化、默认拒绝。queryClient.clear() 是 React 版特有且最易漏的一步。📍 阶段:中级阶段(第 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 对方代码。
学完本章,你将能够:
第 07–11 章你在「练零件」,本章交付附录A §3.1–3.3 的可用版本:
登录鉴权(3.1) —— 账密 + 图形验证码、双令牌无感刷新、个人中心入口
订单管理(3.3) —— 列表(分页/筛选/排序)、详情(状态机/操作日志/流转按钮)、新建/编辑(明细行/金额汇总/审批分流)
权限模型(§2) —— 菜单级(动态侧边栏)、页面级(守卫 + 403)、按钮级(<Auth>)
范围红线(高级阶段再做的,本章不碰):工作台 Dashboard、客户/商品/报表完整模块、批量导出(练习中已给出导出钩子)、性能指标达标——它们分别是第 13–18 章的主菜。克制范围是里程碑能按时验收的前提。
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 拦截项。
各章零件在本章的位置(装配顺序即依赖顺序):
| 零件 | 来源 | 在系统中的角色 |
|---|---|---|
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,业务代码零改动。
附录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>
}
列表页 = 第 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 在组件里另开一条数据通道。
详情页三块:基本信息(含明细行金额)、操作日志时间线(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。
每个里程碑 3–4 小时,完成即可独立验收——不许攒到最后一起测:
M1 骨架与登录(第 07/11 章成果落地)
路由树 + Layout + 动态菜单 + 登录页(验证码)+ ProtectedRoute + 403/404
M2 订单列表(第 08/09 章成果落地)
useOrderList + Table + 筛选/分页/排序 + URL 回填 + 防抖 + StatusTag
M3 详情与新建(第 10 章成果落地)
详情页(时间线 + 状态流转)+ 新建/编辑(明细行 + 金额联动 + 审批分流)
M4 权限收口与整体验收(第 11 章成果落地)
<Auth> 按钮权限 + roles 页面拦截复验 + 登出闭环 + 全链路自测用例
功能验收清单(10 条,附录A §3.1–3.3 口径):
sales 直输无权限 URL → 403 页,给出返回路径;pending_review,finance 角色可见审批按钮;draft 等非终态);全链路自测用例(5 条,手工跑通):
operator 登录 → 新建订单(2 行明细,折扣 100%)→ 状态 draft → 提交 → pending_review;finance 登录 → 审批通过 → approved;sales 登录 → 列表仅见本人订单(ownerName 过滤由 mock 支撑)→ 尝试直输 /settings → 403;交付物模板(每个里程碑提交时填写,进 PR 描述):
## M2 验收记录
- 通过项:验收清单 #4 #5(勾选并附截图/录屏路径)
- 遗留问题:日期区间筛选暂用文本占位(M2.1 跟进);mock 不校验 sales 归属(M4 复验)
- 自测用例:用例 1 通过;用例 2 阻塞(等待审批接口 mock)
任务描述:装配 M1——完整路由树(含懒加载与 404)、AdminLayout(Sider + 面包屑 + 内容区)、登录页(账密 + 验证码 + 失败换图)、ProtectedRoute(isReady 防闪跳 + redirect 回跳)、filterMenus 动态侧边栏、403 页。mock 支持四种角色切换登录(演示账号:demo@qingorder.example.com,角色由验证码输入框旁边的下拉临时控制,仅教学用)。
提示:先让「登录 → 菜单 → 空白页面」跑通再填页面;routeMeta 与 menus 的 key 保持同源;会话恢复 restoreSession 在本里程碑就要接上(刷新不丢登录态)。
验收标准:
sales 直输 /settings → 403;super_admin 可进;// 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>
)
}
任务描述:装配 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。
验收标准:
placeholderData);cancelled 后无任何操作按钮。// 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 }
}
任务描述:装配 M4——<Auth> 接入所有操作按钮(删除、审批、批量指派入口)、roles 页面拦截复验(报表/设置)、useLogout 登出闭环、批量导出选中订单为 CSV(第 09 章练习 3 的 exportSelected 挂上 AntD Table 的 rowSelection)。然后跑 §12.8 的 10 条验收 + 5 条自测用例,填写两份交付物模板。
提示:验收时切换四种角色各过一遍「菜单 → 页面 → 按钮」矩阵,建议画成表格逐格打勾;无感刷新用例要在「列表页翻页中」触发才最接近真实。
验收标准:
super_admin/operator 可见;「审批」仅 finance;权限矩阵表格无一处不符;finance 重新登录:菜单、缓存、草稿均无前一账号痕迹;// 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 | ✔ | ✔ | ✘ | ✔ |
| 系统设置页 | ✔ | ✘ | ✘ | ✘ |
| 报表中心页 | ✔ | ✔ | ✘ | ✔ |
❌ 错误代码:
{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 禁止裸写状态字符串
❌ 错误代码:
// 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 下发,前端只渲染
❌ 错误代码:
// 详情页从 /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 } 不准走样
❌ 错误代码:
<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 内
❌ 错误代码:
<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 已内置)
❌ 错误代码:
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 回滚,而不是裸改
orderStatus.js / permissions.js 在写任何页面前就位;状态机映射表让「加一个状态」变成改一张表。VITE_API_BASE_URL。routeMeta(面包屑/roles)、menus(菜单)同源派生;权限矩阵表格作为验收工具随代码提交。/100、*100 只允许出现在 money.js;列表、详情、表单、导出四处展示统一 formatFen。loading/disabled → 请求层防抖/去重 → 后端幂等;涉及审批的动作优先乐观更新 + 回滚。STATUS_ACTIONS)与权限常量表(BUTTON_PERMISSIONS)的交集决定每个按钮的命运——业务规则数据化,UI 只是投影。📍 阶段:高级阶段(第 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 章展开。
学完本章,你将能够:
React.memo / useMemo / useCallback,并能识别让 memo 失效的典型写法(内联对象、内联函数、未缓存的 Context value);key 重置组件状态(如切换客户后清空明细表单),避免为「重置」编写 effect;先统一词汇。React 把一次更新分成两个阶段:
useLayoutEffect,随后浏览器异步执行 useEffect。「卡」通常来自两处:渲染阶段耗时太长(大列表全量 diff),或提交阶段 DOM 变更太多(大量节点重建)。优化前先分清是哪一种。
重渲染的触发条件只有三类:
useState / useReducer / useSyncExternalStore 等);// 父组件重渲染,子组件必然重渲染——哪怕它没接收任何 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)。理解这一点,后面所有手段才不玄学。
<Profiler> API先测量,再优化。React DevTools 的 Profiler 面板是最常用的定位工具:
组件级埋点用官方 <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
三者解决的问题不同:
| 手段 | 缓存什么 | 典型场景 | 什么时候是负优化 |
|---|---|---|---|
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 引用真的稳定吗?三问有两次「否」,就不要包。
比 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 里靠调整元素创建的位置,殊途同归。
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 更少、更不出错。
万级数据的终解是虚拟滚动:DOM 里只渲染可视区附近的几十行。原理三步:
count × itemHeight),撑出真实滚动条;[startIndex, endIndex],只渲染这段;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 |
第 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 格式化金额 |
治理五步(每步可独立验收):
OrderFilters 内部 + 300ms 防抖,输入时列表页零渲染;OrderRow 包 memo,回调引用用 useCallback 固定;orderNoDate / amountText / statusLabel,行渲染只读字段;contain: strict,行内去除每行渐变阴影。结果(同一台中端安卓机、同一数据集):
| 指标(附录A §4) | 治理前 | 治理后 |
|---|---|---|
| 输入筛选响应 | ~300ms | < 16ms(列表零渲染) |
| 滚动掉帧 | 22% | 3%(达标:掉帧 < 5%) |
| DOM 节点数 | ~62,000 | ~400 |
把这份对比表存进仓库 docs/perf/order-list-2026Q3.md——性能治理没有「完成」,只有「回归前」,记录就是下一次治理的起点。
任务描述:从第 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>
}
任务描述:用「状态下放 + 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>
)
}
任务描述:给「轻订单」列表页接入虚拟滚动:先接入 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,避免双重测量打架。
// ❌ 每次渲染都是新的 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 比较的是引用,不是值。
// ❌ 组件卸载/依赖变化时旧的监听器没人摘
// 现象:页面来回切换后,一次 resize 触发 N 次回调,内存曲线只升不降
useEffect(() => {
window.addEventListener('resize', recalcVirtualHeight)
}, [])
// ✅ 每次 effect 返回清理函数;依赖变化时 React 先执行旧清理再跑新 effect
useEffect(() => {
window.addEventListener('resize', recalcVirtualHeight)
return () => window.removeEventListener('resize', recalcVirtualHeight)
}, [])
在虚拟列表里这最隐蔽:滚动相关监听堆积后,「越滚越卡」常被误判成虚拟滚动本身不行。
// ❌ 筛选变化后 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 的错位会从「偶现」变成「高频」。
// ❌ 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>
// ❌ 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——这也是它不能当默认装备的原因。
docs/perf/)。useCallback/稳定 setState,对象 props 必须来自 useMemo。formatAmount、状态文案这类派生值在数据入库/入查询缓存时算好,别放在每行 render 里。addEventListener 与移除写在同一个 effect 里,团队 lint 强制 react-hooks/exhaustive-deps。memo 三件套是逃逸舱不是默认装备,引用不稳定时它们是安慰剂;状态下放、children 组合、key 重置往往更治本。docs/perf/。渲染变快了,但首屏还是慢——门户要 4 秒才能看到主视觉。下一章我们把矛头转向加载性能:代码分割、图片优化、核心指标测量,目标是把门户 LCP 从 4s 压到 1.8s(附录A 目标 ≤ 2.0s)。
📍 阶段:高级阶段(第 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(),分包思路通用,只是入口组件写法不同。
学完本章,你将能够:
React.lazy + <Suspense> 完成路由级与组件级代码分割,说清哪些组件不该懒加载;srcset/sizes)四项优化;preload / prefetch / dns-prefetch / modulepreload 的语义,并按用户动线落地关键资源预加载;打开门户首页到用户「看到并可用」,时间花在四个地方:
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)→ 逐项治理 → 复测记录。没有基线的优化等于盲打。
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 的小页面纯属折腾。
真实项目的首屏字节大头通常是图片。四件套依次上:
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。原则:给多大的显示区域,就传多大的图。
| 指令 | 语义 | 适合谁 |
|---|---|---|
<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 与数据一起抢跑,进页面即可用。
| 指标 | 全称 | 衡量什么 | 良好线 | 附录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 分位),上线前看实验室跑分,两者互补。
基线测量: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 里固定机器规格与网络节流后数据才可比。
骨架屏替代 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 章配置)。
第 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。
任务描述:给第 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>
任务描述:优化门户 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()
任务描述:把性能预算接入 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 存档对比。
{
"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
// ❌ 边界包住整个应用:任何懒加载块在加载中,侧边栏顶栏一起消失
// 切路由时整页骨架闪烁,体验比 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 管「挂」。
// ❌ 浏览器不知道图多大:图片加载前后布局反复跳动,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 里直接扣分,在真实用户那里直接掉转化。
// ❌ 把所有路由 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 是「下一页可能要」。方向搞反,预加载就是预浪费。
// ❌ 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 报告会标出来),针对它做减法,别的都要让路。
// ❌ 用户关页面瞬间,正在飞的 fetch 被取消——最关键的 LCP 恰恰在那时才完成采集
fetch('/api/v1/metrics/vitals', { method: 'POST', body: JSON.stringify(metric) })
// ✅ sendBeacon 专为「页面要关了也要送到」设计,浏览器保证排队发送
navigator.sendBeacon('/api/v1/metrics/vitals', JSON.stringify(metric))
监控数据的丢失规律很讽刺:丢的永远是最慢、最需要优化的那次访问。
React.lazy;图表、编辑器等重型库在组件内部再 lazy 一层。sizes 响应式、视口外 loading="lazy"——新组件不遵守不合并。immutable 一年,index.html 用 no-cache,发版不靠玄学。React.lazy + <Suspense> 就近边界做路由级分割;重型库组件内再懒一层;布局壳和关键路径不懒。srcset、显式宽高、LCP 图 preload 是默认动作。docs/perf/portal-lcp-2026Q3.md。加载快了、跑得也顺了——但你敢在没有测试的情况下发版吗?下一章进入质量保障:用 Vitest + Testing Library 为核心模块(订单金额计算、权限判断)建立覆盖率 ≥ 80% 的单元测试防线。
📍 阶段:高级阶段(第 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——这也是本章反复强调的用户中心理念。
学完本章,你将能够:
renderHook + fake timers 测试自定义 Hook(防抖、请求竞态);getByRole 定位 + userEvent 交互;vi.mock 与 msw 的分工,用 msw 隔离网络层并测出错误分支与竞态;企业前端的测试投入应该长这样(越往上单条越贵、越慢、越少):
╱ E2E 少量 ╲ 关键链路冒烟(第 16 章):登录→下单→审批→导出
╱ 集成 中量 ╲ 组件 + 交互 + 数据流:OrderCard、订单表单
╱ 单元 大量 ╲ 纯函数与 Hook:金额计算、权限判断、formatAmount
值得测的(出 bug 贵、逻辑可枚举):金额计算、权限判断、状态机流转、表单校验规则、工具函数。
不值得测的:纯样式类名、第三方库内部、useState 本身、一次性营销页。
「轻订单」附录A 的覆盖率目标只锁三个核心模块——订单金额计算、权限判断、工具函数 ≥ 80%。这是真实企业的做法:全局覆盖率是虚荣指标,核心模块覆盖率才是质量底线。
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"
}
}
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 提示)。
自定义 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 永远读最新返回值。
Testing Library 的核心信条只有一句:测试应该像用户一样使用组件——用户不关心你的 useState 叫什么,用户只关心「我看到什么、点什么、结果是什么」。推论:
getByRole 优先(它走可访问性语义,顺带逼你写好 a11y),getByLabelText 次之,getByText 谨慎用;userEvent(模拟完整事件序列:焦点、按键、输入),而不是 fireEvent(只派发单个事件);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 版。
两者解决的问题不同,不是二选一:
| 工具 | 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 换成空函数
}))
快照(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()
})
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 的失败测试」,修完转绿,测试即回归保险。
任务描述:为本章 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)
})
})
任务描述:为第 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 })] }),
)
})
})
任务描述:为权限判断模块建立测试防线: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)
})
})
// ❌ 文案是设计最常改的东西;「提交」两个字还可能同时匹配按钮和提示语(multiple elements 报错)
expect(screen.getByText('提交')).toBeInTheDocument()
// ✅ 按「角色 + 可访问名称」定位,稳定且顺带保障 a11y;真有歧义再用 data-testid 兜底
expect(screen.getByRole('button', { name: '提交订单' })).toBeInTheDocument()
现象:改个错别字,二十个测试文件一起红。定位器选择的优先级记住口诀:Role > Label > Text > TestId。
// ❌ 点击后校验/请求是异步的,同步的 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 红、本地绿」——都是时序问题。
// ❌ 真等 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),否则污染后续用例
// ❌ 在 describe 顶层 mock + 改实现,却没有恢复;下一个文件/用例拿到的是被污染的模块
vi.spyOn(console, 'error').mockImplementation(() => {})
// ……整个文件不再恢复,别的测试里 console.error 也静默了
// ✅ msw 用 resetHandlers,spy 用 mockRestore,fake timers 用 useRealTimers
// 统一收口在生命周期钩子里,谁污染谁恢复
afterEach(() => {
server.resetHandlers()
vi.restoreAllMocks()
})
判断标准:任何一个测试单独跑和和别人一起跑结果必须一致,否则就是泄漏。
// ❌ 快照包含时间戳/随机 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,先修数据再修快照;如果一份快照三个月没被认真看过,删掉它。
makeOrder() 默认合法 + 按需覆盖,新增字段不用改二十个测试文件。afterEach 恢复。renderHook + fake timers 测 Hook 又快又稳;msw 在网络层给真实语义,vi.mock 只负责副作用模块。money.ts、hasPermission、OrderCard、OrderForm 的测试防线已就位,pnpm test:ci 就是质量闸门。单元与集成防线建好了,但「登录 → 下单 → 审批 → 导出」这条完整链路还没有任何自动化验证。下一章用 Playwright 把这条冒烟链路变成每天在 CI 里自动奔跑的 E2E 用例。
📍 阶段:高级阶段(第 13–18 章)|⏱ 预计学时:6–8 小时|🔗 前置:第 12 章实战 + 第 15 章组件测试
⬅ 上一章:组件测试 | 📖 本版目录 | ➡ 下一章:工程化实践 · TypeScript 与 Vite
💡 对照学习:本页是 React 版第 16 章,对应 Vue 版第 16 章 · E2E 测试与质量保障。Playwright 与框架无关——配置、定位器、storageState、trace、flaky 治理、CI 接入两版逐字通用,练习任务也一致(附录A 冒烟集:登录 → 下单 → 审批 → 导出)。唯一差异是「被测应用」:React 版跑的是第 12 章的 React 实现,用例里的业务断言(状态机、权限、金额)与框架无关。
学完本章,你将能够:
webServer 自动拉起应用、多浏览器 projects、产物目录规范;getByRole/getByLabel 优先),避免脆弱的 CSS/XPath 选择器;storageState 复用登录态,把每条用例的登录开销降到零,同时保留一条「真登录」链路验证;第 15 章的单元/集成测试验证「零件正确」,E2E 验证「整车能跑」——真实浏览器、真实路由、真实登录态、真实接口(或高保真 mock)。它的特点是贵(慢、环境依赖重、易 flaky),所以覆盖策略必须分层:
| 层级 | 范围 | 触发时机 | 「轻订单」对应 |
|---|---|---|---|
| 冒烟集(Smoke) | 核心链路 5–10 条 | 每次 PR + 部署后 | 登录 → 下单 → 审批 → 导出(附录A §4) |
| 全量回归 | 全部业务流,30–80 条 | 主干合入后/每日定时 | 订单 CRUD、权限矩阵、客户/商品管理 |
原则一句话:冒烟集答「敢不敢发版」,全量答「质量水位如何」。把 80 条全量塞进 PR 流水线是新人最常犯的错误——PR 要 40 分钟,最后大家学会绕过 CI。
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,
},
})
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() 会自动重试到超时)。
每条用例都走一遍登录页又慢又增加失败面。标准做法是「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 验证。
E2E 最隐蔽的坑是用例间共享数据:并行 worker 同时操作「同一张测试订单」,结果互相踩。纪律两条:
request fixture 直接调后端),数据带唯一后缀,不用别人造的数;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 完全一致,可以平移。
E2E 失败在「别人电脑上」是常态,报告要能自证现场。三件产物的分工:
pnpm exec playwright show-trace trace.zip;配置见 16.2(trace: 'on-first-retry'、screenshot: 'only-on-failure'、video: 'retain-on-failure')。本地调试时 pnpm exec playwright test --headed --debug 可单步执行、实时看定位器匹配。
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)。
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 }) => {...})。
附录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 稳定通过。
任务描述:搭起 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/) // 仍在登录页
})
任务描述:实现登录态复用与订单筛选回归集:① 按 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)
})
任务描述:完整实现 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 能定位到等待竞态。
// 给审核接口注入延迟,验证自动等待的健壮性
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 })
// ❌ 「等 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 的第一来源。
// ❌ 依赖 DOM 结构与 className:改个布局、换套样式,测试全红
await page.click('.orders-page > div:nth-child(2) .btn-primary')
// ✅ 按用户看见的语义定位:改布局不改语义,测试零维护
await page.getByRole('button', { name: '提交订单' }).click()
定位器不稳定时,先怀疑「组件的可访问性命名没写好」——修 a11y 比修选择器一劳永逸。
// ❌ 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()
})
判断口诀:任何用例单独跑、乱序跑、并行跑,结果都必须一致。
// ❌ 所有用例都吃现成登录态:登录页改坏了 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/)
})
// ❌ 每条用例都录 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' }
诊断产物是「失败保险」,不是「全程行车记录仪」——按需开启才是工程化做法。
@smoke,5–10 条),全量回归放主干定时任务;冒烟答「敢不敢发」。data-testid 只兜底;把这条写进 PR 模板。afterEach 清理;共享数据的用例一律视为 flaky 候选。prefers-reduced-motion),消除一整类不稳定。测试防线齐了,但团队还缺两样东西:让代码风格与类型纪律自动执行的工程化链路(ESLint/Prettier/husky、tsconfig、Vite 进阶),以及让每次提交自动变成本次可发布产物的交付流水线。下一章先补前者。
📍 阶段:高级阶段(第 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 走运行时声明+类型推导。
学完本章,你将能够:
strict 家族各开关分别拦住什么,把「strict 全开 + 禁新增 any」定为团队纪律;DataTable<T>)并正确处理 children 类型(PropsWithChildren / ReactNode);import.meta.env 补全类型;manualChunks 做分包优化并对照第 14 章预算验证;money 工具与类型定义抽成可共享的内部包;.jsx → .tsx 迁移路线 M1–M4)。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 允许存在但只减不增。
基础 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>>就是这套机制;手写一遍泛型组件是为了让你看懂它的类型签名。
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 章已学),守卫是零依赖的最小方案。
目标:开发者 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)。
// 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 章的运行时注入。
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/。
当「轻订单」要抽出内部组件库 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 章组件库建设里展开。
第 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 原生支持混编,迁移不阻塞任何业务需求。
任务描述:把第 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 脚本可重复运行。
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>
}
任务描述:① 用 17.2 的泛型 DataTable<T> 同时替换订单列表与客户列表的表格实现;② 按 17.3 给 fetchCustomers 补类型守卫(isCustomer:校验 id/customerName/level 三个关键字段),并让守卫拦下一次「后端把 level 返回成数字」的模拟脏数据。
提示:两个列表共用 DataTable 后,差异只剩 columns 定义——这正是泛型组件的价值;守卫测试直接在 Vitest 里喂脏数据(第 15 章的设施复用)。
验收标准:两个列表的类型互不干扰(DataTable<Customer> 的列里返回 Order 字段会编译报错);脏数据测试用例红转绿;noUncheckedIndexedAccess 开启下无新增报错。
// 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)
})
})
任务描述:完成轻订单 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 一致。
// 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
// ❌ 类型只是「看起来安全」: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 断言是「我对类型负责」,守卫是「代码对类型负责」——边界处只允许后者。
// ❌ 全可选:忘传 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 上的每个 ? 都是你在承诺「缺了它能跑」,想清楚再写。
# ❌ .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_ 能保密。
// ❌ Order 只是类型却被当成值导入:verbatimModuleSyntax 开启时,
// 打包器原样保留这条 import,types/order.ts 若无运行时导出直接报「找不到模块」
import { Order } from '@/types/order'
// ✅ 纯类型一律 import type:编译期擦除,不产生运行时代价
import type { Order, OrderStatus } from '@/types/order'
开了 verbatimModuleSyntax 后这是最常见的红波浪线——但它换来的回报是「类型与值在产物层面彻底分离」,值得。
// ❌ 报错看着烦,注释一盖:这一行从此对编译器隐身,
// 三个月后接口加了字段,唯一的哨兵正是你亲手禁言的它
// @ts-ignore
submitOrder(payload)
// ✅ 明确承认「我知道为什么报错、何时处理」,且修好后 @ts-expect-error 会提醒你删掉它
// TODO(ts-migration): 审批接口 v2 契约定稿后移除
// @ts-expect-error 旧版审批接口暂缺 discountPercent 字段
submitOrder(payload)
团队规范写作口径:PR 里新增 @ts-ignore = 打回;@ts-expect-error 必须带 TODO 与原因。
tsc -b --noEmit 进 CI,编辑器与流水线同一把尺。.ts 化——类型收益最大、依赖最少,为组件层迁移铺路。<T> 收敛,别为每种实体复制粘贴。ImportMetaEnv 声明进仓库,新增变量先补类型;VITE_ 前缀 = 公开承诺。shared(工具与类型),组件库(第 20 章)再扩——为了「看起来架构好」拆包是负债。strict 全开 + 索引访问判空 + 禁新增 any;typecheck 独立成脚本并与 CI 同尺。ReactNode/PropsWithChildren 处理 children、泛型组件收敛「同构异形」。unknown + 类型守卫让编译期类型与运行时数据闭环,守卫放在数据入口(api 层 / queryFn)。@ts-expect-error 留痕逃逸、门禁逐步收紧。代码已经「规范得能被同事接手」,最后一块拼图是交付:每次合并如何自动构建、多环境如何注入配置、发错了如何秒级回滚、线上挂了谁在几秒内知道——下一章把 CI/CD 流水线与前端监控一起搭起来。
📍 阶段:高级阶段(第 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「性能与质量治理」)交付完毕。
学完本章,你将能够:
把第 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),失败在任何一段都要「红得清楚、拦得住」。
# .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 里。
「轻订单」有 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.*)仍可用于「与产物绑定」的值,比如构建模式、版本号;两者并存,各管各的。
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.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 读取新软链;静态站回滚无需重启任何进程
演练要求:每个新接手的发布负责人,入职一周内必须在测试环境完整跑一次「发布 → 回滚」,做不到就不许碰生产。
接入(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 泄漏等于公开源码,这是监控接入时最容易出也最危险的事故。
性能埋点复用第 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,能直接回答「哪个版本引入的白屏」。
监控的终点不是看板,是「对的人在对的时间被叫醒」。给「轻订单」定的告警阈值(按 p75 / 全量错误率,初期从宽、稳定后收紧):
| 监控项 | 告警阈值 | 级别 | 通知渠道 |
|---|---|---|---|
| 新增错误 issue(生产) | ≥ 10 次 / 5 分钟,影响 ≥ 5 用户 | P1 | 电话 + 群 |
| 白屏率 | > 1% | P0 | 电话 |
| JS 错误率 | > 2% | P1 | 群 |
| LCP p75 | > 3.0s(附录A 目标 2.0s 的告警水位) | P2 | 群 |
| 部署后 30 分钟错误环比 | 涨幅 > 100% | P1 | 群 |
响应 SOP 五步(写成值班手册,不是口头传统):
release 与告警时间是否吻合新版本;任务描述:为「轻订单」仓库编写 quality job(18.2 的前 8 个 step),在 PR 上触发,并故意制造一次 typecheck 失败验证流水线真的会红。
提示:pnpm/action-setup 与 setup-node 的 cache: pnpm 缓存第二次运行才命中;本地验证可用 act 或直接推一个测试分支。
验收标准:PR 上流水线全绿;把 formatAmount 的参数类型改成 string 后 push,流水线在 typecheck 步骤失败且日志可读;还原后恢复绿。
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
任务描述:编写 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 + 静态产物)。
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
任务描述:完成一次「发布 → 告警 → 回滚 → 复盘」全链路演练:接好 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 没拦住,对应补哪条用例)→ 行动项(负责人 + 截止日)。
// ❌ 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。
# ❌ 凭据硬编码进 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 事故,处置顺序是先轮换后清理。
# ❌ 对整站一刀切强缓存:新版发布删除旧 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),给「标签页开了一整天的用户」留缓冲期。
// ❌ 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) // 捕获阶段拿资源错误
// ❌ 无版本注入: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 分钟定位「哪版引入」,没有的团队靠回忆和抽签。
config.js,保证测试过的产物与上线产物字节级一致。hidden + Nginx 拒绝 .map,Sentry 是唯一读者。config.js:一次构建多环境部署,测试过的就是上线的。📍 阶段:资深阶段(第 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,演进为分层清晰、依赖可约束、决策可追溯的多模块工程。先学评估,再学拆分——顺序不能反。
学完本章,你将能够:
app / pages / widgets / features / entities / shared 分层组织 React 项目目录,并为每一层写出「能依赖谁」的规则;import 在本地与 CI 阶段直接报错;bootstrap() 流程,并区分「阻塞挂载」与「后台预取」;架构不是简历素材,是对复杂度的响应。复杂度没到,任何「高级结构」都是负债;复杂度到了,没有结构就是灾难。动手拆分前,先量化五个维度:
| 维度 | 观测指标 | 低(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 或方案文档归档,而不是停留在口头。「为什么拆」比「怎么拆」更值得记录。
模块化单体的经典组织方式是两条正交的轴:横切面分「技术层」,纵切面按「业务域」切片。业界通行的分层参考(源自 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 是最容易破也最值得守的一条,破口一开就退化回面条单体。
路由是 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 加一行。路由对「这个域有哪些页面、什么角色能进」的回答是局部的,不用全仓搜索。
第 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 的字符串长什么样
请求层延续第 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 });
}
单体项目最常见的隐形债:初始化逻辑散落在各个模块文件顶层——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)只是注册表里的一个新条目。
分层解决了「仓库内」的秩序,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/ 的代码,方向反了复用立刻变纠缠;② 公共代码进包前先在应用内生长——被两个应用用到第三次才下沉,避免造出没人用的「假共享层」。
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 并把旧状态改为「已废弃(被取代)」。这份不可变性正是它作为「决策日志」的价值。
任务描述:打开你第 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 边界规则做机器拦截。任务描述:把练习 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 错误——证明规则生效,然后删除该文件。
任务描述:青云科技计划接入第三方供应商开发的「营销活动子系统」(优惠券领取页、活动报名页,供应商有自己的技术栈与发布节奏,无法合入主仓)。请撰写 ADR-011:判断营销子系统以什么形态接入(独立域名跳转 / iframe / 微前端子应用),给出备选方案对比与决策约束。本章先写「决策依据」,具体技术方案到第 21 章完成后再补充「实施附录」。
提示:评估维度至少包含——团队边界(谁维护)、发布节奏(能否同频)、视觉一致性要求、共享登录态的耦合深度、SEO 需求(营销页可能需要)。结论可以留给第 21 章推翻,但论证过程必须完整。
验收标准:产出 docs/adr/ADR-011-营销子系统接入形态.md,五段结构完整,备选 ≥3 个且各有代价分析,含「回顾触发条件」。
关键论证要点(供对照,不强求一致):
❌ 错误做法:3 人团队、5 个页面的内部工具,第一周就搭 qiankun 基座 + 四层目录 + Monorepo 八个包。结果是:新人光读懂架构就要一周,改一行文案要跨三个包,构建时间翻倍——架构复杂度远超业务复杂度。
✅ 正确做法:架构分级响应复杂度(§19.1 决策矩阵)。先跑 §19.1 评估表拿分数:8–10 分用「单体 + 目录纪律」,11–13 分上分层切片,14 分以上且跨团队才谈微前端。判断标准不是「我想不想用」,而是「当前结构的痛点是否有数据支撑」——月度「改 A 坏 B」事故数、跨域 import 数、需求吞吐都是现成证据。
❌ 错误做法: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。
❌ 错误做法:把订单列表数据存进 Redux slice(orders: { list, loading, error, page }),提交审核后手动 dispatch(setList(updated)) 同步——结果审核通过了、详情页还是旧状态;换个页面又得再造一份同款 slice。
✅ 正确做法:三分法归置(§19.4)。服务端数据只活在 TanStack Query 的缓存里,提交后 invalidateQueries({ queryKey: orderKeys.all }),列表、详情、时间线同时过期重取。Redux/Zustand 只管「客户端态」。检查口诀:这个 state 的真值在数据库里吗?在 → Query。
❌ 错误做法:dict.ts 模块顶层直接发请求(getDicts() 写在文件顶层)、埋点 SDK 在某个 util 文件 import 时自动 init。后果:测试一 import 就报网络错误;启动顺序取决于打包产物的模块执行顺序,等于把关键路径交给 bundler 抽签。
✅ 正确做法:模块只导出函数与常量,一切副作用收敛到 bootstrap() 显式编排(§19.6),并区分阻塞任务与后台任务;后台任务必须配降级路径(字典预取失败 → 使用时懒加载)。
❌ 错误做法:状态库选型在群里吵两天,最后以「 leader 说用哪个就用哪个」收场,没有留痕。半年后新人问为什么,没人答得上来,于是有人开始悄悄引入第三种方案——架构在无声中腐烂。
✅ 正确做法:任何影响「多模块/多人/超过一个迭代」的技术决策写 ADR(§19.8)。ADR 的价值不在长而在可追溯:五段结构、300 字即可,重点是备选方案的代价与「回顾触发条件」。团队约定:Code Review 中出现「为什么不用 XX」的讨论超过 10 条评论,就该沉淀一篇 ADR 了。
index.ts 之外的内部路径禁止外部 import,这是「重构自由」的来源;createRoot( 只允许出现在 app/mount.tsx 一处,副作用入口唯一;apps/ + packages/,包数量 = 真实共享需求,不为「看起来专业」制造空包。bootstrap() 编排,横切关注点插件化;Monorepo 用 pnpm workspace,公共代码遵守「三次法则」;下一章解决分层结构里最容易失控的一层——shared/ui。当 Button、Table、StatusTag 在三个应用里被复制出五种样式,「抽内部组件库 qing-ui」就从可选项变成了必答题:设计令牌、组件 API 设计、Storybook 文档站与 changesets 版本发布,将把它从零做到一。
📍 阶段:资深阶段(第 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 路线图。
学完本章,你将能够:
data-theme 暗色方案如何衔接;ConfigProvider token 体系完成品牌定制(主色、圆角、暗色算法),解释为什么定制要走 token 层而不是 CSS 覆盖;useControllableState 模式),并举出各自的适用场景;.npmrc 全部走环境变量),输出 qing-ui 0→1 路线图。内部组件库的第一课是想清楚不做什么。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 章「三次法则」等到的那个下沉时刻。
令牌分三层,层与层之间有严格的引用方向:
| 层 | 命名示例 | 谁定义 | 谁消费 |
|---|---|---|---|
| 原始层(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)。一个状态源,两个消费方,这就是「三层方案衔接」的全部含义:不另起炉灶,把门户已有的主题开关升级为全站主题的事实源。
组件库的寿命由 API 质量决定。四条原则,按优先级排列:
closable、footerRender、extraButton...),改用组合把结构交给调用方;| 意图 | 约定命名 | 反例 |
|---|---|---|
| 事件回调 | 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>
| 维度 | 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,色值全部来自语义令牌 */
组件库没有文档站等于不存在——「看代码才知道怎么用」的库一定会被绕过。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)。
无障碍不是合规清单,是「信息传达的健壮性」。用贯穿项目的 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 上自动跑这三条,红灯挡在合并前。
内部库发布的三大乱象:版本号手改、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 之后零容忍。
| 里程碑 | 周期 | 交付物 | 验收标准 |
|---|---|---|---|
| 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 永不稳定。
任务描述:在 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 组件(表格、弹窗)仍是亮色。
任务描述:实现 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));
}
任务描述:为 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 规则更严格,这也是选私服先行演练的原因。
❌ 错误做法:业务方提什么就加什么 prop:QTable 半年长出 47 个 props(showBorder、headerBg、emptyImage……),每个 prop 组合都要测试与文档;一个 prop 的 bug 报修牵动 12 个调用方。组件库从「加速器」变成「排队点」。
✅ 正确做法:加 prop 前过三问——「能不能用现有 API 组合出来?」「第二个调用方在哪?」「这个需求是不是属于业务层而不是通用层?」。多数「定制需求」用组合(children/render)与 ...rest 透传解决;确需新增时先写 changeset 说明影响面(§20.7),让改动有 changelog、有迁移示例。API 面每少一个,未来三年少还一笔债。
!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 等一切组件库。
❌ 错误做法:为了「方便统计」,在 QButton 内部直接调用 track() 埋点并读取订单上下文;QModal 里 import 了 @/entities/order。基础组件与业务域耦合后,qing-ui 无法被 portal/营销子系统复用,且库内测试需要 mock 业务模块。
✅ 正确做法:分层红线(§20.1):qing-ui 只依赖令牌层与 AntD,不知道订单是什么。埋点这类横切需求用第 19 章的插件化方案(在应用层给 axios/路由统一挂埋点)或事件回调(QButton 只负责 onClick,埋点由调用方决定);业务上下文通过 props 注入,组件内不 import 任何 entities/。
❌ 错误做法:发布前手动把 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 便于回溯生产版本。
❌ 错误做法:给全部 60 个 story × 3 个视口截 180 张图,任何字体渲染差异都红灯;基线更新随手 --update-snapshots 糊过去。三个月后团队开始闭眼点「重跑」,视觉回归沦为玄学仪式。
✅ 正确做法:从「关键少数」起步——只截 API 面宽、视觉复杂的组件(Table、Modal、AmountInput 的 AllStatuses 类 story);maxDiffPixelRatio 容忍渲染抖动;基线更新必须与 PR 关联(diff 图贴进描述,reviewer 确认「变化符合意图」才允许更新); CI 红灯的默认反应是看 diff,而不是重跑。
--qing-* 语义变量;品牌换肤、暗色模式因此是一次配置而非一场战役;useControllableState + onChange 永远回调,受控/非受控由调用方自由选择,一句话写进每个表单组件的文档首行;.npmrc、CI 日志、文档截图里不允许出现真实 token 字面量,占位符统一 <your-token> 形式(安全红线);data-theme 状态源同时驱动 CSS 变量层与 AntD darkAlgorithm,与第 06 章门户方案无缝衔接;useControllableState 是表单类组件的基本功;qing-ui 让多个应用共享了「零部件」,但它们仍是各自独立部署的整站。当第三方营销子系统带着自己的技术栈与发布节奏敲上门,「整站一体」的架构就到了边界——下一章讨论微前端:先回答要不要拆,再回答怎么拆、怎么隔离、怎么通信、怎么渐进迁移。
📍 阶段:资深阶段(第 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 留了一个待决策问题:第三方供应商的「营销子系统」(优惠券、活动报名)要接入轻订单平台,它有自己的技术栈选型、发布节奏,永远不可能合入主仓。本章先回答要不要拆,再比较四种拆法,最后在轻订单上完成真实接入与一次渐进迁移演练。
学完本章,你将能够:
微前端把「整站一体」拆成「壳 + 多个可独立发布的子应用」,引入的复杂度是实打实的(沙箱、通信、依赖治理、部署面 ×N)。它的收益几乎全部来自「组织」而非「技术」——先用两张清单拷问需求:
| 微前端的适用信号(满足越多越该拆) | 不适用的信号(满足任何一条先冷静) |
|---|---|
| 多个团队各自维护一个业务域,发布节奏互相牵制 | 单团队项目,只是「想体验新技术」 |
| 子应用技术栈/版本确实无法统一(第三方、历史包袱) | 技术栈统一,痛点只是目录乱(第 19 章分层即可解决) |
| 需要子应用独立部署、独立回滚,发布窗口冲突频繁 | 团队规模 ≤ 5 人、月需求吞吐低(§19.1 评分 ≤ 10 分) |
| 子应用有独立的生命周期(快速试错、可能下线) | 页面间强耦合、共享大量实时交互状态 |
| 聚合后用户仍获得一体体验(统一壳/登录/导航) | 追求极致首屏性能(每层聚合都增加运行时成本) |
轻订单的判断:营销子系统由供应商维护(团队边界真实存在)、需要独立发布(活动上下线以天计)、登录态与视觉需要打通(不能 iframe 简单糊弄)、主站技术栈 React 19 —— 拆是合理的,且只拆这一个子系统:admin 的订单/客户/商品仍然一体化构建(它们没有团队边界,拆了纯属自找复杂度)。
资深的克制体现在:微前端是手术刀,只切需要切的位置。全站微前端化在 90% 的企业里是负资产。
| 维度 | 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;
}
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 顺手覆盖基座关键页)。
| 通道 | 方向 | 放什么 | 不放什么 |
|---|---|---|---|
| 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 }); // 订单域缓存过期重取
});
子应用各自打包 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,这是最经典的翻车现场)。
聚合应用的部署比单体多三个必须想清楚的问题:
① 路由前缀即应用边界。 约定 /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";
}
}
需求(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 冒烟集,供应商每次发版前在联调环境必须全绿。
如果被拆的不是新子系统,而是存量单体(比如把 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,用户回到旧页面,流量归零,全程不需要发版。
任务描述:用 §21.1 两张清单,分别评估以下需求并给出结论与理由:① 青云科技要把 admin 的报表模块独立,由新入职的 2 人小组维护并希望独立发版;② 老板觉得「微前端很火」,要求把 portal 门户也拆成微前端;③ 供应商的营销子系统(本章主案例)。
提示:逐条对照清单打勾,输出「结论 + 关键依据 + 若不拆的替代方案」三段式;② 的替代方案值得认真想——它考察你是否理解「拆」的真实成本。
验收标准:每个需求一段 5 行以内的评审意见,至少引用清单中的 2 条信号;其中②给出明确的「拒绝理由 + 替代路径」。
① 倾向拆,但设置前置条件:团队边界真实出现(2 人小组独立维护)、独立发版诉求明确——命中适用信号;但需先确认报表与订单模块的耦合度(如订单详情页内嵌报表组件就是强耦合信号),且 2 人小组先按 §19 模块化目录在主仓内生长一个迭代,验证「独立」的真实需求再物理拆分。
② 拒绝:无团队边界(同一批人)、无独立发布诉求、portal 页面间共享大量交互状态(主题、导航)——三条不适用信号全中。替代路径:用第 19 章分层目录 + 独立部署流水线(同一仓库不同 job 部署 portal/admin)满足「看起来像拆了」的真实诉求(发布隔离),不引入运行时聚合成本。这条评审意见的价值在于:用更便宜的手段满足诉求背后的真实需求。
③ 拆:供应商团队边界、活动级发布节奏、零改造成本约束全部命中,且结论需落成接入契约(§21.7)。
任务描述:在本机搭两个 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 同一个常量包而不是两边手写字符串)——这正是契约要求常量集中定义的原因。
任务描述:用 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 条(隔离、通信、改造方式)。
典型结论(供对照):① 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 的实施附录。
❌ 错误做法:3 人团队把 admin 拆成 5 个微前端子应用,「每人负责一个,互不干扰」。结果:联调要走两个仓库、公共组件要发两个包、一次全站改版开 5 条流水线——本想减少协作成本,实际把代码内的协作变成了跨仓库的协作,成本不降反升。
✅ 正确做法:微前端解决的是组织边界问题(不同团队、不同节奏、不同技术栈),不是代码整洁问题。单团队的代码治理用第 19 章的分层与依赖规则;「发布隔离」诉求用同一仓库多条部署流水线满足。决策口诀:先看组织架构图,再看系统架构图。
❌ 错误做法:子应用自带 * { margin: 0; box-sizing: border-box } 全局样式与 .btn { ... } 公共类名。挂载瞬间基座按钮变形、间距错乱,排查半天发现是「别人家的样式」。
✅ 正确做法:三层防线(§21.3)——接入契约禁止全局 reset 与无前缀公共类(发布前评审样式清单);运行时隔离(Shadow DOM / scoped);E2E 视觉快照兜底基座关键页。供应商子应用无法约束时,直接选 iframe 类沙箱(wujie)用机制而不是纪律解决问题。
❌ 错误做法:基座与两个子应用各自打包 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 集成冒烟。业务包绝不共享,保持子应用发布独立。
❌ 错误做法:用事件总线传递业务数据——子应用 emit order:changed 附带整单数据,基座监听后写进自己的 store。三个月后没人说得清「订单数据到底有几份真值」,时序一乱就是「审核状态显示错误」的灵异 bug。
✅ 正确做法:总线只传「发生了什么」(事件名 + 最小标识符),状态一律回源——收到 coupon:claimed 后由基座 queryClient.invalidateQueries 让缓存重取(§19.4 的 queryKey 工厂在此复用)。检查口诀:事件是信号,不是数据管道。
❌ 错误做法:迁移报表模块时直接删旧代码上新架构,发布当天出问题只能紧急回滚整个版本(用户已产生的新数据可能已写入新链路);或者更糟——新旧两版同时在线但路由互相打架。
✅ 正确做法:Strangler 五步(§21.8)里每一步都有回滚按钮:路由层 featureFlag 决定旧实现还是新实现,灰度按白名单放量,监控异常即关闸。旧代码在新实现稳定一个迭代后才删除。渐进迁移的本质不是慢,而是每一步都可以退。
拆与合的问题解决了,但「接入契约」「评审签字」「迁移记录」这些词背后是另一个更大的命题:一群人如何稳定地产出高质量的软件。下一章把镜头从系统转向团队——分支模型、提交规范、Code Review 文化、跨职能契约,最终汇成一份可执行的《前端团队协作手册》。
📍 阶段:资深阶段(第 19–24 章)|⏱ 预计学时:6–8 小时|🔗 前置:第 18 章 CI/CD + 第 20 章 changesets + 第 21 章 接入契约
⬅ 上一章:微前端与跨团队协作 | 📖 本版目录 | ➡ 下一章:技术选型决策
💡 对照学习:本页是 React 版第 22 章,对应 Vue 版第 22 章 · 团队协作规范。本章与框架几乎完全无关——协作规范的每一条(分支模型、提交规范、Review 文化、跨职能契约)两版逐字通用,差异仅在个别配置文件名(本版示例仓库为 pnpm workspace + React 技术栈,Vue 版为等价结构)。这正好说明本章的道理:好的规范是团队资产,与个人用什么框架无关。
前四章解决了「系统怎么长」,本章解决「一群人怎么稳定地产出系统」。资深的标志性产出不是某段精妙的代码,而是让整个团队变好的机制:一条自动执行的提交链路、一份被真实使用的 PR 模板、一种对事不对人的 Review 文化、一纸设计/后端/测试都认账的契约。本章以「轻订单」团队为背景,最终交付一份可执行的《前端团队协作手册》。
学完本章,你将能够:
| 维度 | 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 章链路)
提交信息是团队协作的最小单元,也是 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 类能力,配置以官方文档为准)。
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 / 请修改 / 有疑问 |
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: 前缀,作者可自行取舍;阻塞项明确说「请修改后再合入」。
表扬同等重要:看到漂亮的抽象/测试,明确说出来——文化的方向由被称赞的行为决定。
文档的第一原则:写「未来还会有人问的问题」的答案。轻订单的 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 | 独立领一个小需求进入正常迭代 | 协作手册全流程 | 需求交付 |
前端不是孤岛。三个方向的协作契约,本质都是把隐式默契变成显式交付物:
与设计的契约——设计交付物清单:
① 设计稿 + 交互四态:每个交互组件必须有 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 事件链接)
把本章所有机制汇总成一份团队唯一入口文档。骨架如下(★ 标记的 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 的事),只写「怎么做」——保持手册短小、可执行、可搜索。
任务描述:给轻订单仓库(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,状态检查才会在下拉列表中出现。
任务描述:与学习伙伴(或团队同事)交换两个 PR,按 §22.3 十项清单 + §22.4 坏味道清单完成互审,要求:① 至少产出 3 条符合话术规范的实质性评论(禁止只挑格式);② 至少使用一次 nit: 前缀标注非阻塞建议;③ 若出现分歧,模拟「停战机制」——写成一段 5 句话的 ADR 草稿而不是继续跟评。
提示:先用 30 秒读 PR 描述建立预期,再按清单顺序走(意图→设计→细节);发现第 4 类坏味道(第三份复制)时,顺手评估它应该上提 widgets 还是下沉 entities(练习 §19 的分层判断)。
验收标准:① 两份 review 记录各含 ≥3 条实质性评论 + ≥1 条 nit;② 无一条纯格式评论(格式问题应记录为「建议提交 pre-commit 钩子」而不是逐条指出);③ 分歧(如有)产出 ADR 草稿或对齐结论记录。
实质性评论示例(对照话术规范):
if (discount > 10) 的 10 是附录A 里的免审批阈值(§3.3),建议提取到 entities/order/model/constants.ts 并命名为 WHOLE_DISCOUNT_REVIEW_THRESHOLD,将来业务调阈值只改一处。」——对应坏味道 3,且给出了归置位置;useState 存列表和 useQuery,服务端态建议统一走 Query 的缓存(§19.4 三分法),提交审核后 invalidateQueries(orderKeys.lists()) 就能同时刷新列表与详情,现在的手动 setList 会在详情页出现旧数据。」——对应十项清单④,说清了后果;nit: 这里的 loading可以用 Query 的isPending 直接得到,少一个 state。——非阻塞建议。
若对方反驳「useState 版本性能更好」,这就是典型分歧:先核对事实(Query 内部同样有缓存与渲染优化),再决定是否值得写 ADR——多数情况下 10 条评论的上限根本用不到,5 条内就能对齐。任务描述:按 §22.7 骨架完成手册全文(未完成小节可先放「TODO 待补」占位),其中 1、4、5 三节写出完整正文;然后把手册投入真实使用一周:每次 PR 按模板走、每条 review 按清单走,周末收集团队反馈修订出 v1.1。
提示:正文写法是「规则 + 一个反例 + 一个正例」,禁止写成散文;5.2 节直接引用附录A §5 的响应体与错误码约定作为契约正文,体现「契约即代码」;试运行的反馈收集用五问表(哪条最烦?哪条没被执行?…)。
验收标准:① 手册 v1.0 提交进 docs/ 且经真实 PR 评审合入(吃自己的狗粮);② 1/4/5 节正文完整,各含至少一组正反例;③ 试运行一周后产出 v1.1 变更说明(哪些规则被删/改/加及原因)——被真实使用过的规范才是规范,否则只是文档。
§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 章)评审冻结后双方并行开发,联调仅用半天。
❌ 错误做法:reviewer 的评论全是「这里多个空格」「引号应该用单引号」「这行超过 80 字符」,而 200 行的核心逻辑变更无人置评——重要的设计问题被格式噪音淹没,作者也学会了「把 PR 拖到下班前提交」来逃过格式审查。
✅ 正确做法:分工明确——格式与风格属于机器(Prettier/ESLint + pre-commit 钩子,违规根本进不了仓库);人只评审机器审不出来的东西(十项清单的意图、设计、错误处理、测试)。团队可以正式立一条规矩:「review 评论中不允许出现任何 IDE 自动修复能解决的问题」,发现格式类评论一律让作者指回钩子配置。
❌ 错误做法:6 人团队照搬某大厂的三级分支模型 + 双周版本火车 + 四级评审会——两周的开发量有一半花在「流程本身」上;或者反向照抄硅谷的「全员主干直推」,在没有 flag 体系与强 CI 的团队里裸奔上线。
✅ 正确做法:分支模型与发布形态匹配(§22.1 对比表):持续交付的 SaaS 用 GitHub Flow + flag;ToB 多版本并行才需要 Git Flow 的发布分支。流程的复杂度必须由真实痛点支付,每次新增一条流程前先问「它防的事故上季度发生过吗」——没有数据支撑的流程,半年后必然沦为仪式。
❌ 错误做法:协作规范以飞书群公告 + 三页截图的形式存在,两周后没人找得到;新人入职拿到的是口口相传的「习惯」,三个月后同一份规范存在四个互相矛盾的版本。
✅ 正确做法:规范即代码——手册进 docs/(§22.5)、配置进仓库根(commitlint.config.js / pull_request_template.md)、执行交给 CI(分支保护 + lint 门禁 + changesets 检查)。检验标准很朴素:搜得到、链接得上、机器强制得了。改规范走 PR,让每次修订都有 diff 与讨论痕迹。
❌ 错误做法:一个 PR 同时包含「订单模块分层重构 + 新增筛选功能 + 修复导出 bug + 升级依赖」,1600 行 diff。reviewer 只能扫一眼放行(没人真能审完),出问题无法定位是哪个意图引入的,回滚只能全量回滚。
✅ 正确做法:一个 PR 只表达一个意图(§22.4 坏味道 2),重构、feature、修复、依赖升级分四个 PR;每个 PR ≤ 400 行——超过就说明该拆了。配套技巧:重构类 PR 用「纯移动不修改」的提交方式(diff 工具能识别 rename),让 reviewer 把注意力留给真正的逻辑变更。
❌ 错误做法:「接口改了记得跟我说一声」、字段名在微信里敲定、设计四态「大概没问题吧」。结果:后端把 discountPercent 从 0–100 改成 0–1 没有公告,前端上线当天折扣全错;导出失败态没有设计稿,前端自己「看着办」。
✅ 正确做法:三个方向各有显式交付物(§22.6):设计四态稿 + tokens 变更单、后端 OpenAPI 评审 + 错误码口径统一引用附录A §5、测试用例评审 + 缺陷单四要素。契约文件放 docs/contracts/,变更走 PR——微信群里的承诺会蒸发,仓库里的契约能执行。
!→major),门禁全部自动化;机制建好了,随之而来的问题是「面对新需求、新技术、新业务线时,怎么在众多选项里做出值得被写进 ADR 的选择」。下一章建立一套可复用的选型评估框架:八个维度、加权评分、公平的 Vue vs React 对比、自研与开源的决策树,以及一份可以直接套用的选型报告模板。
📍 阶段:资深阶段(第 19–24 章)|⏱ 预计学时:6–8 小时|🔗 前置:第 19 章 ADR + 第 22 章 协作规范
⬅ 上一章:团队协作规范 | 📖 本版目录 | ➡ 下一章:资深实战 · 技术方案与架构评审
💡 对照学习:本页是 React 版第 23 章,对应 Vue 版第 23 章 · 技术选型决策。本章方法两版通用,示例代码为 React 生态:八维评估框架、自研决策树、技术债台账、选型报告模板全部与框架无关;§23.2 的 Vue vs React 对比在两版中立场互换(各从自己的视角公平陈述)——这正是公平对比的检验方式:两版对照阅读,结论应当互相成立。
选型是资深工程师最具杠杆的决策:一次选型决定未来三年的开发体验、招聘口径与维护成本。本章不做「哪个最强」的粉丝论战,而是建立可复用、可挑战、可追溯的决策基础设施:八维加权评估框架、三个完整案例、自研决策树、技术债台账,最后是一份可套用的选型报告模板。
学完本章,你将能够:
选型失误的根源几乎都不是「不了解候选者」,而是没想清楚自己要什么。八维框架把「要什么」显式化——权重本身就是团队的价值观声明:
| 维度 | 权重参考 | 观测方法(怎么打分才不是拍脑袋) |
|---|---|---|
| 业务匹配度 | 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));
}
本教程的双版本结构本身就表明立场:两者都是成熟的一线选择。公平对比的检验标准是——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 自动)的两个解——这也是为什么本教程坚持双版本对照:概念映射见各章「对照学习」框。
需求背景:第 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 输掉的主要不是技术,而是「团队现状 + 招聘」两个组织维度——这正是八维框架的价值,它阻止了「炫技型选型」。
需求背景:轻订单需要中后台密集表单/表格 + 暗色主题 + 品牌定制,且已有设计令牌体系(第 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 仓库,此为接口契约示意 */
}
需求背景:服务端状态管理标准化(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 是评分的存档形式。
「自己写一个」是工程师最常见的冲动。决策树把冲动变成流程:
要解决的需求,现有开源能覆盖多少?
│
├─ ≥ 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 口径)
技术债的可怕不在存在,而在隐形。先用四象限定性:
有意为之(当时知道是债)
┌──────────────────────┬──────────────────────┐
影响面小 │ 微观·有意:临时 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);
第 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(无遗留债)
把本章所有工具装配成一份可直接套用的报告骨架——七节结构,与第 24 章的技术方案七段法一脉相承:
# 技术选型报告 · <项目/业务线名>
> 作者 / 日期 / 状态(草案·评审中·定稿)/ 关联 ADR
## 1 背景与约束
业务背景 3 句;团队规模与技能现状;交付时间窗;存量系统与依赖清单。
## 2 决定性需求(3–5 条)
只列对技术决策有区分度的需求(SSR?万级表格?合规审计?离线?),
每条标注来源(需求文档章节 / 性能口径 / 合规要求)。
## 3 候选方案(2–4 个)
说明来源(团队提名 / 行业调研),不超过 4 个——候选过多说明需求没想清。
## 4 八维加权评分表
权重 + 每格分数 + 一句依据(spike 结论/数据链接);权重调整必须注明原因。
## 5 结论与约束条件
一段话结论 + 附加约束(适配层要求/使用边界/版本锁定策略)。
## 6 风险与退出预案
Top3 风险与缓解措施;退出路径(适配层接口/数据迁移方案)。
## 7 里程碑
分阶段交付计划,每个里程碑配独立验收标准(与第 24 章口径一致)。
报告定稿前,请一位「魔鬼代言人」按下面十问攻击一遍——答不上任何一问,就回炉重写:
选型报告「魔鬼代言人」十问
□ ① 决定性需求里的每一条,真的对技术选型有区分度吗?
□ ② 被拒候选的代价分析,能不能也套用到你选的它身上?
□ ③ 每格分数的依据,换一个人独立评估会得到同样的分吗?
□ ④ 权重为什么这么定?如果时间窗从 10% 变 25%,结论会翻转吗?
□ ⑤ 一年后这个库不维护了,退出预案的第一步动作是什么?
□ ⑥ 团队里最反对这个选择的人,他的理由你写进报告了吗?
□ ⑦ 这个选择锁死了哪些未来的选择?(机会成本)
□ ⑧ spike 里遇到的三个最大隐患,方案里都有对应缓解吗?
□ ⑨ 如果预算减半、工期减半,你还会做同样的选择吗?
□ ⑩ 这份结论与既有 ADR 冲突吗?冲突时更新谁?
任务描述:为轻订单「新建订单表单」(动态明细行、金额联动校验、 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 前置承诺而否决。」
任务描述:轻订单报表中心要升级(§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 灰度。
任务描述:青云科技立项「渠道对账系统」(财务团队专用:对接三个电商平台账单、日千万行数据比对、差异标记工作流)。作为前端负责人,产出完整选型报告:技术栈(框架沿用 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 已在用,同栈降低维护面。附录里留下攻击与回应,就是这份报告「可挑战」的证明。
❌ 错误做法:搜索结果按 star 排序,第一名直接用。半年后发现:万 star 的库是作者个人项目,issue 三个月无人回复;star 高是因为教程带火而非生产验证;README 里连迁移指南都没有——版本一升级全团队停工。
✅ 正确做法:star 只反映「关注度」,评估要看「健康度」——最近一年的发版频率、issue 平均响应时长、BREAKING CHANGE 频率与迁移成本、维护者是个人还是组织(组织维护抗风险能力更强)、生产使用案例(依赖它的知名项目)。这些信息全部在 GitHub 的 Insights 与 issue 列表里,评估成本不超过 20 分钟,却挡掉 80% 的坑。
❌ 错误做法:报告通篇是「候选 A 支持 X、候选 B 支持 Y」的功能罗列,最后以「综合考虑选 A」收尾——「综合考虑」没有留下任何可检验的推理。评审会上第一个较真的问题(「为什么不是 B?」)就把报告击穿。
✅ 正确做法:报告的骨架是权衡不是罗列:决定性需求先行(3–5 条)、八维评分附依据、分数接近时比退出成本(§23.1)。每一条结论都能追溯到「我们的需求是什么 → 候选在这个维度表现如何 → 证据是什么」。检验标准:把候选名遮住只看论证,能判断出结论,才算合格的报告。
❌ 错误做法:选了某全家桶框架,路由、状态、请求、DI 全用它的专属 API,深度渗透 200 个组件。一年后全家桶停止维护,评估替代方案时发现等于重写——「换库」在当初的对比表里看起来只是换一个 npm 包。
✅ 正确做法:选型时把「退出成本」当独立维度打分,并采取结构性防御:薄适配层隔离(qing-ui 封装 AntD、@qing/charts 封装 ECharts、http 基座隔离 axios——业务代码只见自家 API);专属能力(如全家桶的 DI)即使好用也评估「不用它的代价」,能用标准 API(React Context/TanStack Query)替代的绝不深度绑定。
❌ 错误做法:某新框架发布一个月,团队在业务交付的关键期全员拥抱——坑没人踩过、文档不全、周边生态真空;三个月后作者本人都弃更了,团队回迁时业务已被拖慢一个季度。
✅ 正确做法:时间窗维度诚实打分(§23.1):业务交付压力大的时期,候选必须是「成熟期」技术(至少 1.0 + 一年生产验证 + 活跃社区);对新技术的真实需求用低成本探针满足——一次技术分享、一个内部小工具先行验证,而不是业务系统当试验田。轻订单的纪律:业务仓库只进成熟期依赖,新技术先进 sandbox 仓库。
❌ 错误做法:「先这样,回头再优化」说了二十次,没有任何记录。一年后没人记得哪些地方是「先这样」,新人把临时方案当成规范来学;重构时不知道哪里是债、不知道从哪还起——债务从名单变成了地貌。
✅ 正确做法:台账纪律(§23.7):每次说「先这样」的同一天写进 docs/tech-debts.md(描述/象限/影响/触发条件/预估);评审会开场先过台账;偿还后标记保留。技术债不可怕,可记录、有触发条件、有人认领的债是良性的——它甚至是有价值的「明知故借」(用债务换交付速度的正当交易)。
有了框架与台账,最后一个问题水到渠成:把这一切装进一份正式的技术方案文档,并让它在架构评审会上活下来。下一章是全书实战收官——轻订单 v2 技术方案全文示例、评审流程与 checklist、向上管理的叙事方法、季度技术规划,以及你的毕业项目。
📍 阶段:资深阶段(第 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 方案全文示例和你的毕业项目收尾。
学完本章,你将能够:
技术方案文档不是作文,是决策与执行的契约。它有三类读者,各取所需:
┌──────────────┬────────────────────────────┬──────────────────┐
│ 读者 │ 他们读哪一段 │ 写作要点 │
├──────────────┼────────────────────────────┼──────────────────┤
│ 评审者/管理层 │ 背景、目标/非目标、方案对比、 │ 权衡而非细节; │
│ │ 里程碑 │ 一页纸能讲清骨架 │
├──────────────┼────────────────────────────┼──────────────────┤
│ 执行者(队友) │ 详细设计、接口契约、数据流 │ 可照着开工;歧义为零 │
├──────────────┼────────────────────────────┼──────────────────┤
│ 未来维护者 │ 非目标(为什么没做 X)、 │ 记录当时的边界 │
│ (半年后) │ 灰度记录、风险表 │ 与已知妥协 │
└──────────────┴────────────────────────────┴──────────────────┘
七段结构与写作要点:
| 段落 | 回答的问题 | 常见败笔 |
|---|---|---|
| ① 背景 | 为什么现在做?现状的痛点有什么数据? | 写成项目介绍,无数据 |
| ② 目标 | 做完怎么算成功?3–5 条可量化 | 「提升体验」这种不可验收的词 |
| ③ 非目标 | 明确不做什么,防止范围蔓延 | 缺失——最容易被忽略却最能救命 |
| ④ 方案对比 | 考虑过哪些路?各自代价?为何选这条路? | 只写选中的方案(等于没做对比) |
| ⑤ 详细设计 | 执行者怎么开工?接口/数据流/目录 | 照抄需求文档;或细节淹没权衡 |
| ⑥ 灰度与回滚 | 出问题怎么发现、怎么退? | 缺失——理想路径文档 |
| ⑦ 里程碑 | 分几步交付?每步验收标准? | 只有日期没有验收标准 |
写作纪律:先一页纸后全文——先写满一页纸的版本(每段三句话)拿去对齐方向,方向确认后再展开全文;「非目标」段永远写全(它同时是范围契约与未来维护者的地图);全文不超过 10 页,超过 10 页说明该拆方案了。
以下是贯穿项目 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>
}
/>
评审的目的是在纸面上失败,而不是在生产里失败。流程四步:
① 预审(会前 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 跟踪
技术方案写得再好,没有组织资源也只是文档。向管理层陈述技术投入的三段式叙事:
第一段 · 业务风险(对方语言):不说「权限代码太散需要重构」,
说「上季度 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 灰度期避开月末两周
全书毕业项目四选一(或组合),每个都要求产出物可展示、过程可追溯——它们同时也是面试时最有说服力的弹药:
| 项目 | 内容 | 用到的章节 | 产出物 |
|---|---|---|---|
| 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 分钟讲清「为什么这么设计」而不被问倒
任务描述:产品经理提了三个需求,请分别改写成「可量化目标(含基线)+ 非目标」:① 「订单列表页有点卡,优化一下体验」;② 「报表导出要更好用」;③ 「系统要更安全一点」。
提示:目标的量化基线从两处找——附录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 有效期策略。
注意共同点:每条目标都能在监控/缺陷系统里找到对应物,每条非目标都提前挡掉了一次评审会跑题。
任务描述:为「订单批量导出大数据量卡死」(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 灰度与监控。
任务描述:以 §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 个页面,超范围走评审(非目标 + 风险表双重挡板)。你的模拟评审若出现本模板没覆盖的质疑——恭喜,那份方案比你想象的更有讨论价值。
❌ 错误做法:文档通篇是「系统将提供 XX 功能、支持 YY 场景」的功能描述,读完后知道「要做什么」,但不知道「为什么这么选、为什么不那样做」——评审会上第一个问「为什么不用 B 方案」的人就把会开崩了,方案被打回重写。
✅ 正确做法:方案的灵魂是权衡(§24.1 表格第 ④ 段):至少 2 个备选、各有代价分析、拒绝的理由具体到成本与约束。检验方法:把「选中的方案」遮住,仅凭背景与对比段,别人应该能推导出你会选哪个——推不出来就是对比写得不够诚实。
❌ 错误做法:目标写「优化用户体验」「提升系统性能」「增强可维护性」——评审通过后没人能判定「做完了没有」,验收变成「感觉快了」的主观描述,三个月后同一个「体验问题」被再次立项。
✅ 正确做法:每条目标 = 指标 + 基线 + 目标值 + 测量方式(§24.2 的 G1–G3 模式):「工作台可交互 ≤ 3.0s(基线 4.2s,4G 中端机实测)」。写不出量化指标的需求,先做测量(埋点、性能面板、缺陷统计)再立方案——测量本身就是方案的第一步产出。
❌ 错误做法:方案到「详细设计」就结束了,上线当天「直接全量发布」。结果权限 SDK 误拦财务、岛屿页首屏闪烁,只能紧急回滚整版(连带当天其他无关功能一起回滚),用户投诉与复盘会议齐飞。
✅ 正确做法:灰度与回滚是必填段(§24.3 checklist 12/13/14 条):灰度维度(路由/租户/白名单)+ 观察指标 + 异常阈值(错误率 > 0.5% 即回滚)+ 回滚操作步骤与耗时。高风险变更(权限类)加双跑 diff 机制——回滚按钮必须在按下之前就装好,而不是出了事现场造。
❌ 错误做法:两种极端都常见——一种是评审员没读文档,会上逐行找茬、揪着实现细节不放(「这里为什么不用 useMemo」),作者防御性拉扯两小时无结论;另一种是作者念 PPT、评审员刷手机,全体「通过」,评审沦为流程盖章。
✅ 正确做法:用流程设计压制极端(§24.3):预审强制书面评论(会上不做首次阅读);作者只讲 10 分钟权衡;质疑按 checklist 分组走(目标→方案→设计→灰度);结论四类里「有条件通过 + 行动项」是常态——评审会的产出是行动项,不是气氛。跨团队评审员专门负责问「外行问题」,他们恰恰能暴露内部盲区。
❌ 错误做法:里程碑表只写「W2 完成权限模块、W4 完成上线」——W2 结束时「完成」由谁判定?做到 60% 算不算完成?延期到 W3 要不要上报?里程碑失去管理功能,季度规划与实际进展脱节。
✅ 正确做法:每个里程碑配独立验收标准与可独立交付的产物(§24.2 表格:M2 = SDK 0.1 发布 + 单测 90% + 双跑 diff = 0)。配套两条管理动作:里程碑延期 → 当周升级到规划会(而不是月底才发现);每个里程碑交付物直接对应第 22 章的文档沉淀(ADR/记录/报告)——里程碑是给管理层的进度真相,不是给作者的愿望清单。
<Auth>/路由包装三消费入口 + fail-closed + 双跑 diff);资深阶段的全部六章到此完成。接下来去两处:附录 B · 面试题精选检验这四阶段的沉淀能否经得住面试官的追问;以及本阶段自查清单——用 30 分钟小考确认你已具备资深工程师的完整能力面。祝毕业顺利。
💡 Vue 版对应文件:阶段自查-中级能力清单(清单条目两版一一对应,可双栈互验)。业务基准:附录A「轻订单」需求说明书。
过了中级这条线,你不再是「按 ticket 改页面的人」,而是能独立负责一个业务模块从需求到上线的工程师。以「轻订单」为标尺,企业对中级 React 开发的真实预期是:
ProtectedRoute 页面守卫、<Auth> 按钮级权限、useOrderList 数据编排 Hook——这些「全项目都要用」的东西,你能设计并守住边界;queryClient.clear()——并且知道怎么防。做不到以上四条中的任何一条,说明中级阶段还有缺口,对照下面的清单定位回看。
用法:逐条勾选。拿不准的条目给出「未达标 → 回看第 X 章 §Y」的定位,回看后重做该章实战练习再来勾。
<Outlet /> Layout、React.lazy 分包、404 兜底、菜单高亮(未达标 → 回看第 07 章 §7.2/§7.3)useFieldArray 动态明细行、金额联动汇总、整单折扣审批提示、isSubmitting 防重复提交(未达标 → 回看第 10 章 §10.3–10.7)state.from 回跳、登出四清(未达标 → 回看第 11 章 §11.4/§11.8)ProtectedRoute roles 页面守卫 + 403、filterMenus 动态菜单、<Auth> 按钮级控制(未达标 → 回看第 11 章 §11.5–11.7)baseURL 环境变量化、token 注入、ApiError 归一化、40102 无感刷新(单飞锁 + _retried 重放)(未达标 → 回看第 09 章 §9.3–9.5)createSelector(未达标 → 回看第 08 章 §8.1/§8.3/§8.4)_retried 死循环防护、刷新必须走裸 axios(未达标 → 回看第 09 章 §9.5、第 11 章 §11.2)queryKey 结构化哈希、staleTime 与后台刷新、invalidateQueries 失效、乐观更新三段式(onMutate/onError/onSettled)(未达标 → 回看第 09 章 §9.7/§9.10)0.1 + 0.2 问题),说出哪些层可以转换单位、哪些层不能(未达标 → 回看第 09 章易错点 1、第 10 章 §10.6)ApiError.code 分支、静默用 silent 显式申请、queryFn 不吞错误(未达标 → 回看第 09 章 §9.4、易错点 6)STATUS_ACTIONS)、权限码(BUTTON_PERMISSIONS)、审批阈值(90)全部进 constants/,组件零硬编码业务字符串(未达标 → 回看第 12 章 §12.4/§12.8)menus / routeMeta / 路由 roles 同源派生,不存在两处维护;未登记权限码默认拒绝(未达标 → 回看第 11 章 §11.6–11.7、第 12 章易错点 2)自评结论:16 条全勾 → 直接进入第三部分;A 组全勾、B/C 组缺 1–2 条 → 回看对应章节后进入;A 组有缺口 → 重做第 12 章对应里程碑练习。
不查资料独立完成,每题 3–4 分钟。做完再展开答案对照。
/orders/1001 跳转到 /orders/1002,详情页数据没有更新。至少说出两种解法,并说明各自的代价;接入 TanStack Query 后哪种解法会「消失」?
useEffect 依赖 [id] 重新拉取(Query 版则是 queryKey 里的 id 变化自动重查);代价是要自己处理加载态,但组件状态(滚动位置、已展开面板)保留。key={id};实现零成本,但组件状态全部丢失、有重建开销。
接入 TanStack Query 后解法一退化为「写对 queryKey」——['orders', 'detail', id] 中 id 变化自动重新请求并保留旧缓存,组件复用问题随之消失。(第 07 章 §7.4、易错点 1;第 09 章 §9.7)下面两个写法,一个「非法」、一个「合法但常被误解」。分别指出并解释 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)
已知明细行 { 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)
三个并发请求同时收到 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)
列表页切换状态筛选后界面不变。检查发现代码如下,指出两处问题并给出正确写法:
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)
编辑订单页用 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)
对照附录A 的三级权限模型,说出每级「在哪实现、用什么机制」,并回答:前端把 403 做得再好,为什么还必须信任后端?
菜单级:MENUS 配置带 roles,登录后 filterMenus(role) 过滤渲染侧边栏,决定「看见什么」;页面级:ProtectedRoute 组件带 roles 参数组合进路由树,无权限重定向 403,决定「进得去什么」;按钮级:<Auth allow="order:delete"> 组件(配 BUTTON_PERMISSIONS 常量 + usePermission)控制元素渲染,决定「能点什么」。
因为前端代码运行在用户设备上:角色数据可被篡改、接口可被绕过 UI 直接调用——前端权限只是体验优化,真正的安全边界永远是后端鉴权(接口层对每个请求复核角色与数据归属)。(第 11 章 §11.1、§11.5–11.7)
团队要决定 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 章),否则继续补中级缺口:
useOrderList 没有单测),并且想知道怎么治——这正是第三部分(性能优化、测试、工程化)要解决的问题。当你开始「嫌它慢、怕它坏、想量化」时,就是升级的时候。⬅ 上一章:第 06 章 · 初级实战 | 📖 本版目录 | ➡ 下一站:第二部分 · 中级阶段
本清单覆盖 React 版第 01–06 章。建议用法:先通读「阶段画像」定位自己 → 逐条勾选能力项 → 做 30 分钟自测小考 → 对照「升级中级的判断标准」决定是否进入第二部分。
完成初级阶段后,你应当能以「可被指导的独立贡献者」身份参与一个 React 项目(如轻订单门户/后台的页面层开发):
OrderCard、StatusTag、筛选栏、弹窗);用 Vite 从零搭建并运行项目;遵守团队的目录、命名与 ESLint 规范;在 code review 中看懂评审批注并完成修改。勾选标准:能「不看教程独立做到」或「用自己的话向同事讲明白」才算通过。每条标注了未达标时的回看位置。
index.html → main.jsx → App.jsx 入口链路(未达标 → 回看第 01 章 §1.3–1.4)components/ data/ hooks/ 职责分离、一文件一组件、回调 onXxx 命名、配置数据与视图分离(未达标 → 回看第 03 章 §3.5、第 06 章 §6.2、§6.8)计时 30 分钟,先作答再展开答案。第 4–6 题请动手写代码或口头推演,不要只「看着眼熟」。
轻订单门户要同时展示「待审核订单数」和「今日 GMV」。同事说:「这两个数一变就要改 DOM,所以我写了个工具函数集中更新 DOM。」请指出这种写法的两个风险,并给出 React 的解法。
风险一:手工同步遗漏——数据来源增多后(状态筛选、轮询、操作回填),每一处都要记得调用更新函数,漏一处就是「漏单」式的显示 bug。风险二:无法追踪——DOM 被多处命令式修改,出问题时无法回答「是谁在什么时候改的」。
React 解法:把订单数与 GMV 作为状态(useState),视图写成「状态的函数」(声明式 JSX)。数据一变,React 负责算出最小 DOM 改动;手工同步代码全部删除。(对应第 01 章 §1.1)
下面三种 key 的用法,哪些安全、哪些有风险?说明理由:
① <li key={order.id}>(id 为后端主键)
② <li key={index}>(列表支持删除与排序)
③ 给搜索框设 key={resetCount},点「重置」按钮时 resetCount + 1
① 安全:主键唯一且稳定,是 key 的标准来源。 ② 有风险:删除/排序后,位置上的元素「顶替」了原来 key 对应的元素,React 会复用错误节点的内部状态(输入框内容、勾选状态串行)。静态纯展示列表用下标勉强可用,但不建议养成习惯。 ③ 安全且推荐:换 key 触发销毁重建,输入框内部状态(文本、光标、选区)被清空——这是「重置表单」的正规手段。(对应第 02 章 §2.5、§2.7)
找出下面代码里的所有问题:
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>
);
}
o.status = 'approved'):破坏单向数据流,应通过回调上抛由父组件改数据。orders.map 的返回值没有使用:这段修改代码本身就不该存在(见第 1 条)。key={i} 下标作 key:应使用 order.id。onClick={onApprove(o.id)}:渲染时立刻调用,应改为 onClick={() => onApprove(o.id)}。onclick 小写:JSX 事件是小驼峰 onClick;且直接 console.log 的调试代码不应提交。return / 箭头函数体(第一处):orders.map((o) => { if ... }) 没有返回值也没有副作用意义,属无效代码。修正要点:删掉 mutation 逻辑、key 用 o.id、事件传函数引用、属性名小驼峰。(对应第 02 章 §2.3、§2.5;第 03 章 §3.2 易错点)
用受控组件实现一个 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)
下面的组件每秒打印什么?为什么?请给出两种修复方案。
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)
开发模式下 <StrictMode> 里组件的 effect 总是「执行 → 清理 → 再执行」。回答:① 这是 bug 吗?② 它为什么能帮助我们发现真实问题?③ 写一个会「现形」的反例。
① 不是 bug:StrictMode 仅在开发环境把组件「挂载 → 卸载 → 再挂载」跑两遍,用于检验副作用的健壮性。
② 如果 effect 的订阅没有成对清理,双挂载会立刻暴露:定时器双份跑、监听器叠加、请求重复——这些都是生产环境真实存在的泄漏源;写好清理函数的组件在双挂载下零感知。
③ 反例:useEffect(() => { window.addEventListener('resize', handle); }, []) 没有 return () => window.removeEventListener(...)——双挂载后 resize 触发两次 handler;补上清理即消失。(对应第 05 章 §5.1–5.2)
产品要求:门户页脚要新增「微信公众号二维码」区块,FAQ 要新增 3 个问题,暗色主题需在「登录前后的所有页面」生效。分别说明你会改哪些文件、为什么,并说明哪条需求与 Context 相关。
src/components/Footer.jsx(新增一个纯展示块),若内容较复杂可在 data/ 增加配置——体现「内容与视图分离」。src/data/faqs.js,FaqAccordion 零改动——配置驱动。同时满足以下条件,即可进入第二部分 · 中级阶段:
未达标怎么办:按各条目的「回看第 X 章 §Y」定向复习,重做对应章节的渐进式练习(跳过基础档、直接做进阶/挑战档),再回来补测。
📍 阶段:资深阶段(第 19–24 章)|⏱ 建议用时:30 分钟自查 + 30 分钟自测小考
⬅ 上一章:资深实战 · 技术方案与架构评审 | 📖 本版目录 | ➡ 下一章:附录 B · 面试题精选
💡 对照学习:本清单与 Vue 版阶段自查 · 资深能力清单条目一一对应(能力项、小考知识点、毕业方向两版一致),差异仅在实现生态(React 生态 ≈ Vue 生态的对应工具)。资深阶段的方法论本身两版通用,因此这份清单也是最像「面试答案卷」的一份。
初级回答「组件怎么写」,中级回答「系统怎么拼」,高级回答「系统怎么快、怎么稳」,资深阶段回答「系统该怎么长、团队怎么协作、选择怎么做、决策怎么被记录与检验」。一个完成本阶段的学习者,在企业里应当能独立承担这些事:
一句话画像:高级交付工程体系,资深交付「让体系持续变好的决策与机制」——并且每一项决策都留痕、可追溯、可被挑战。
bootstrap() 启动编排(区分阻塞与后台任务)与插件化横切关注点(埋点/监控零侵入业务代码) —— 未达标 → 回看第 19 章 §19.5–19.6useControllableState),通过 Storybook + a11y 检查 —— 未达标 → 回看第 20 章 §20.2–20.3、§20.5–20.6、练习 1/2自查方法:沿用三问打分——「能对同事讲 + 能现场做 + 有产出物」。资深阶段的产出物尤其重要:评分表、ADR、接入契约、方案文档、评审记录、季度规划——建议全部归档进仓库
docs/,这份「决策证据链」就是你区别于高级工程师的档案,也是晋升答辩与面试的弹药库。
建议真实作答:纸笔或新建 markdown,写完再展开答案对照。每题 3–5 分钟,总分自评 ≥ 80% 获得毕业资格。
轻订单评估得分:模块数 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 接入,主站保持一体化构建。核心判断句:微前端解决组织边界问题,不解决代码整洁问题。
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 业务组件层时,走的是「三次法则」(两个应用共用第三次才下沉为包)。
useControllableState 中用 value !== undefined 判定受控模式。为什么 onChange 在两种模式下都必须回调?这个 Hook 为什么是 AmountInput 这类表单组件的标准形态?
受控时组件不持有真值(外部 value 决定显示什么),但外部依然需要事件来知道「值该变了」——onChange 是外部更新真值的唯一入口;非受控时外部虽然不控制值,但同样需要监听变化(如实时联动校验)。所以 onChange 是「两种模式的公共契约」,永远回调(第 20 章 §20.3)。这让组件成为标准形态的原因:调用方自由选择模式——放进 RTK/Query 驱动的表单用受控(订单金额汇总联动),放进简单场景用 defaultValue,组件与调用方的状态库彻底解耦——这也让 qing-ui 不依赖任何具体状态方案(易错点 20-3 的分层红线得以成立)。
接入营销子系统时,为什么 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)。
某状态库 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,让结论可追溯、可被挑战。
评审会上有人提议「v2 方案既然要做岛屿渲染,不如顺便把整站迁到 Next.js」。方案里的哪一段应该先挡住这个跑题?这段里必须出现什么内容才挡得住?
先靠非目标段挡(明确「不做整站 SSR 重写、不迁移 Next.js」),但真正挡住需要方案对比段里的具体代价:整站迁移 3 人月起、中后台登录态/权限/表格密集场景从 SSR 获益有限、交付停摆——「拒绝的理由具体到成本与约束」,而不是一句「不合适」(第 24 章 §24.2 方案 A、易错点 24-1)。检验标准:遮住结论只看对比段,别人能推导出你会选 C(CSR 主体 + 局部岛屿)——推不出来说明对比不诚实。评审会上这类「顺便」需求一律导向两个出口:写入非目标留档,或独立立项走完整方案流程;在 v2 方案里夹带属于范围蔓延,评审员有职责打断。
权限中台化双跑期出现「新旧判定 diff 率 0.8%」,方案设定的告警阈值是 0.1%。此刻该做什么?为什么「fail-closed 降级」和「双跑 diff」这两个机制缺一不可?
按预案执行:触发告警 → 立即回退到旧判定逻辑(回滚开关秒级,且不影响其他功能)→ 排查 0.8% 的 diff 样本(通常是权限点清单遗漏,风险表里已列「误拦财务」场景)→ 修复后重新灰度(第 24 章 §24.2 §6、易错点 24-3)。两个机制缺一不可的原因:fail-closed 解决「接口失败时的默认安全」(取不到权限按无权限处理 + 可见重试提示,绝不能 fail-open 越权放行——对应安全红线);双跑 diff 解决「逻辑本身写错时的发现机制」(权限是静默错误,误拦和漏拦用户都不会主动报障,diff 上报是唯一能自动暴露两者的探针)。一个防数据不可用,一个防逻辑错判,覆盖的是两类不同的失败模式。
四个阶段的 24 章 + 本清单全部通过后,你面前是两条同样值得走的路。它们不是「升职方向」的玄学,而是两种不同的能量来源,选前先诚实回答「哪类工作让你下班时仍然有电」:
从第 01 章的第一个组件,到第 24 章的 v2 技术方案——你已经走完了从「会写代码」到「能对系统与团队负责」的全程。下一程,无论管理线还是专家线,把「留痕、可追溯、可被挑战」的工作方式带走,它比任何框架都保值。
📍 阶段:高级阶段(第 13–18 章)|⏱ 建议用时:30 分钟自查 + 30 分钟自测小考
⬅ 上一章:CI/CD 部署与前端监控 | 📖 本版目录 | ➡ 下一章:大型项目架构设计
💡 对照学习:本清单与 Vue 版阶段自查 · 高级能力清单条目一一对应(能力项、小考知识点、升级标准两版一致),差异仅在实现生态(React 生态 ≈ Vue 生态的对应工具)。对照学习时,两份清单可以互为答案卷。
初级阶段回答「组件怎么写」,中级阶段回答「系统怎么拼」,高级阶段回答「系统怎么快、怎么稳、怎么交付」。一个完成本阶段的学习者,在 10 人以下的前端团队里应当能独立承担这些事:
一句话画像:中级交付功能,高级交付「可持续被交付的工程体系」。
React.memo / useMemo / useCallback 各自缓存什么、何时是负优化,以及「引用不稳定让 memo 失效」的三种典型写法 —— 未达标 → 回看第 13 章 §13.3、易错点 13-1/13-4vi.mock 与 msw 的分工 —— 未达标 → 回看第 15 章 §15.5–15.6import type 纪律、@ts-expect-error 留痕规则,并配好 ESLint + husky + lint-staged 的提交门禁 —— 未达标 → 回看第 17 章 §17.1、§17.4、易错点 17-4/17-5自查方法:每条能力项按「能对同事讲 + 能现场做 + 有产出物」三问打分。三问全过才可勾选;产出物(Profiler 截图、覆盖率报告、复盘文档)建议归档进仓库
docs/目录,面试与晋升时都是证据。
建议真实作答:纸笔或新建 markdown,写完再展开答案对照。每题 3–5 分钟,总分自评 ≥ 80% 进入第四部分。
父组件重渲染时,子组件一定会重渲染吗?如果不会「一定」,请说出避免子组件重渲染的两种结构性手段(不靠 memo)。
不「一定」——React 默认「父渲染 → 子树全渲染」,但有两种结构性手段可以切断:① children 组合:子树由更外层创建成元素对象传入,父组件内部 state 变化时 children 引用不变,React 直接复用该子树;② 状态下放:把高频变化的 state 移到只消费它的小组件内部,变化天然影响不到兄弟子树。另外 React.memo 在 props 浅比较相同时也可跳过渲染,但它属于「声明式手段」而非结构性手段。(第 13 章 §13.1、§13.4)
useMemo 与 useCallback 分别缓存什么?给 memo 组件传 props 时,为什么「内联对象字面量」和「内联箭头函数」是等价的坑?
useMemo 缓存计算结果(值),useCallback 缓存函数引用(等价于 useMemo(() => fn, deps))。React.memo 对 props 做浅比较,比较的是引用而非值:每次 render 新建的对象/函数引用都不同,memo 每次都判定「props 变了」,于是照常重渲染——优化完全失效。解法:对象用 useMemo、函数用 useCallback 固定引用,或结构性优化绕开。(第 13 章 §13.3、易错点 13-1)
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)
用 renderHook 测试一个 300ms 防抖 Hook 时,为什么必须用 fake timers?真时钟测试会有什么问题?
真时钟意味着每个防抖用例真实等待 300ms+:套件变慢(几十个用例积累成秒级),且受调度抖动影响偶发失败(CI 经典的「本地绿 CI 红」)。fake timers(vi.useFakeTimers())让时间可手动推进:vi.advanceTimersByTime(300) 瞬间走完窗口,还能精确断言「窗口内未生效」这一中间态。注意 afterEach 里 vi.useRealTimers() 恢复,否则污染后续用例。(第 15 章 §15.4、易错点 15-3)
vi.mock 和 msw 都能让测试「不依赖真实后端」,它们的分工边界是什么?各举一个典型场景。
vi.mock 作用于模块层:替换某个 import 的实现,适合隔离副作用依赖——例如 mock 掉 react-router 的 useNavigate(断言「调用了导航」而不是真跳转)、mock 第三方 SDK。msw 作用于网络层:拦截真实 HTTP 语义(路径、参数、状态码、响应体),适合测数据流——列表加载态、接口 500 的错误分支、翻页竞态。判断口径:要测「与外界的交互契约」用 msw,要测「与某个模块的调用关系」用 vi.mock。(第 15 章 §15.6)
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)
生产环境既要 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)
满足以下全部条件,可以进入第四部分 · 资深阶段——那里的问题从「怎么写好代码」升级为「系统该怎么长、团队该怎么协作」:
下一站:第 19 章 · 大型项目架构设计。带着你的「轻订单」仓库去——它将在资深阶段完成从单体到多模块的架构演进。
本题库按教程四阶段组织,共 30 题,每题给「考察点」与折叠的「参考答案要点」。定位:
面试前 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 分钟只看「六、面试答题方法论」 |
jsx(...) 函数调用,产出描述 UI 的 JS 对象(React 元素),再由 React 渲染为真实 DOM(详见第 2 章)。className、onClick)、style 接对象、标签必须闭合、保留字转义(for → htmlFor)。&&、列表用 map(),全部是 JS 表达式,类型检查与调试栈更直接;代价是缺少模板的静态分析优化空间,优化更多依赖运行时与编译器。useState 第 N 次调用对应链表第 N 个槽位(详见第 4 章、第 13 章)。useEffect 依赖里判断)或拆成两个组件。eslint-plugin-react-hooks 的 rules-of-hooks 规则会在构建期拦截,团队 CI 必须开启(详见第 17 章)。push 会怎样?setState 前后用 Object.is 浅比较引用决定是否重渲染;push/直接改属性不产生新引用,React 认为「没变」,不触发渲染(详见第 4 章)。[...list, item]、filter、map;对象用 { ...obj, key: newVal };嵌套结构逐层展开。useEffect 在提交后异步执行,读的是「本次渲染」的闭包变量(详见第 4、5 章)。setX(x => x + 1) 摆脱对旧值的闭包依赖;用 useRef 存最新值供回调读取;或在清理函数中取消订阅。ref 在提交时读取,如 defaultValue、文件 <input type="file">。register 非受控注册或拆组件隔离渲染。use 开头、内部调用其他 Hook 的函数,用于复用「带状态与生命周期的行为」,如 useDebounce、useOrderFilter(详见第 4 章)。src/hooks/(详见第 12 章目录约定),注意每个 Hook 单一职责、返回语义化元组或对象。React.lazy + Suspense;守卫思路与 Vue 导航守卫的对照。const OrderList = lazy(() => import('./pages/OrderList')),外包 <Suspense fallback={<Spin />}>,配合构建工具自动代码分割(详见第 7 章)。<ProtectedRoute> 包一层,内部读用户态判断重定向;或数据模式的 loader 在进入路由前请求数据/鉴权,不满足直接 throw redirect。<AuthGuard> + meta/路由表权限标记。<Routes>;刷新后用户态未恢复时要先「等待鉴权状态就绪」再判断,避免误踢到登录页。createSelector 记忆化)从 store 派生数据:组件只订阅自己需要的切片,避免整 store 变化触发无关重渲染;派生逻辑(过滤、汇总金额)集中可测。createAsyncThunk 或 RTK Query;能说清「服务端态优先考虑 TanStack Query,RTK 管跨页面客户端态」是关键分层判断(详见第 9 章)。create 即用、selector 粒度天然按需订阅;适合中小型应用、局部复杂交互态。staleTime:数据在多长时间内被视为「新鲜」,新鲜期内组件重新挂载不重新请求;默认 0(即取回即 stale)。gcTime(v5 改名,旧名 cacheTime):无组件引用后缓存再保留多久才被垃圾回收。queryClient.invalidateQueries({ queryKey }) 让相关查询变 stale 并自动重取;乐观更新在 onMutate 里先改缓存、失败回滚(详见第 9 章实战)。AbortController,在 effect 清理函数或封装层 abort 上一次。queryKey 包含全部筛选参数,库内置按 key 隔离 + 只采纳最新请求结果,竞态天然解决。z.infer 产出 TS 类型,前后端共用一份契约,避免「校验与类型两张皮」(详见第 10 章)。useFieldArray 动态增减,schema 里用 z.array(orderItemSchema).min(1) 表达「至少一行」。superRefine/refine 做跨字段校验;提交前 resolver 统一跑 schema。watch 局部化),避免整表单每键重渲染;提交中 isSubmitting 防重复提交。SameSite 防 CSRF,且跨域配置(credentials)更繁琐。setState/dispatch;② 消费的 Context value 变化;③ 父组件重渲染(即使传的 props 全等,默认也会重渲染子组件)。React.memo + 稳定引用(useMemo/useCallback)阻断;② 拆分 Context 或换 Zustand selector;① 属于预期行为,靠状态下放(把 state 移到真正需要的子组件)缩小范围。setState 后立刻读 state 拿到旧值?(React 特有)setCount(count + 1) 基于同一快照只加 1。解法:函数式更新 setCount(c => c + 1)。useEffect(或事件回调的事件对象里读 e.target.value),不要试图「setState 后同步读」。memo:props 浅相等时跳过子组件渲染;收益 = 省掉的子树渲染成本,成本 = 每次父渲染的 props 浅比较 + 引用维护。子树大且 props 稳定才划算(详见第 13 章)。useMemo 缓存计算结果、useCallback 缓存函数引用:计算本身很轻(如拼一个字符串)时,缓存与比较的成本可能超过收益;useCallback 的确定收益场景是传给被 memo 子组件的回调、以及作为其他 Hook 的依赖。useTransition/startTransition:把「不急」的更新(大列表筛选、Tab 切换)标记为过渡更新,让输入框保持即时响应,期间用 isPending 展示轻量反馈;useDeferredValue 是值的对应版本。useSuspenseQuery)与流式 SSR 的分段注水。transform: translateY(偏移) 把可视行定位到正确位置;滚动时按 scrollTop / 行高 算出 startIndex(详见第 13 章)。fetchpriority="high"、WebP/AVIF + 响应式尺寸、CDN、brotli 压缩、强缓存指纹策略。font-display: swap、SSR/预渲染让 LCP 元素更早出现。getByRole('button', { name: '提交订单' }) 这类可访问性查询定位,就像用户真实感知 UI 一样;顺带倒逼 a11y(详见第 15 章理念)。findBy* + waitFor;接口 mock 用 MSW 拦在网络层,测试更接近真实;快照测试克制使用(只用于稳定的小组件)。hidden-source-map 或不发到 CDN),构建后上传到 Sentry 等监控平台用于把压缩报错还原为源码行号(详见第 18 章)。app / pages / features / widgets / entities / shared,或按业务功能切片(features/orders、features/customers)内聚页面无关的领域逻辑(详见第 19 章)。shared 不反向依赖任何业务层——保证低层可独立测试与复用。value + onChange 双契约,同时给 defaultValue 非受控逃生口(详见第 20 章)。<Table><Table.Column /></Table> 或 render props/children 函数)替代 30 个布尔 prop 的「上帝组件」。loading/disabled/size 统一词表,事件统一 onXxx,跨组件可预期。原理题答题框架:是什么 → 为什么 → 怎么做 → 边界。
项目题答题框架:STAR。
通用红线:不会的题直接说「这块我只了解到 XX 程度」,然后讲你能推导的部分;编造 API 细节是最大扣分项(与本书写作口径一致:不确定就讲原理 + 给官方文档链接)。
主干按本教程四阶段递进:每章是一个节点,「读章节 → 做练习 → 过易错点 → 写自查清单」构成一轮闭环;实战章(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 接入 |
| 资源 | 链接 | 用途(一句话) |
|---|---|---|
| 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」列给出该节点对应的面试题编号,学完即可自测。
| 节点 | 教程章节 | 呼应附录 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 综合 |
配套自查:阶段自查-初级能力清单
| 节点 | 教程章节 | 呼应附录 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 综合 |
配套自查:阶段自查-中级能力清单
| 节点 | 教程章节 | 呼应附录 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 |
配套自查:阶段自查-高级能力清单
| 节点 | 教程章节 | 呼应附录 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 · 面试题精选 |
主干完成后按职业方向选学。每个方向给「是什么、做什么、前置要求、第一站」;第一站一律先官网一手资料。
前端生态迭代快,比「追新」更重要的是建立低成本、可持续的信息习惯:
CHANGELOG.md,重点看三段——Breaking Changes、Deprecations、迁移指南;不逐字泛读。CHANGELOG 阅读模板(示意结构,非真实内容;每次升级按此清单走一遍):
## vX.Y.Z —— 是否影响我?
├── ⚠️ Breaking Changes → 逐条核对项目代码,逐条给出迁移动作与工期
├── 🗑 Deprecated → 登记技术债:替换目标 + 计划替换的版本窗口
├── ✨ New Features → 只引入命中当前痛点的项;记入 ADR/周会同步
├── 🐛 Bug Fixes → 扫一遍是否命中自己在用的行为
└── 🔒 Security → 安全修复触发尽快升级,不受节奏策略限制
信息渠道速查表:
| 渠道 | 看什么 | 频率建议 |
|---|---|---|
| react.dev/blog | React 重大方向与新版本公告 | 每月浏览一次 |
| GitHub Releases(各依赖仓库) | 版本变化、破坏性变更、迁移指南 | 每次升级前必读 |
| 各库官方文档的 Upgrade Guide | 跨版本迁移步骤 | 大版本升级时通读 |
| 团队版本基线文档(自建) | 当前锁定版本与决策记录 | 每季度复审一次 |
欢迎来到《Web 前端开发进阶教程》Vue 版。本版基于 Vue 3(Composition API +
<script setup>),带你从初级程序员一路走到企业级资深开发。如果你同时也在学 React,本教程有章节一一对应的 React 版,可以逐章对照(见「双栈对照学习」)。
| 维度 | 选型 |
|---|---|
| 框架 | 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 以官方文档为准;教程讲解的是长期稳定的核心概念与工程方法。
| 部分 | 章节 | 主题 |
|---|---|---|
| 第一部分 · 初级 | 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 路线图与资源 |
全书所有练习围绕虚构企业级 SaaS「轻订单」展开(B2B 订单管理系统)。四个阶段在同一业务上递进:门户首页 → 中后台系统 → 性能与质量治理 → 架构演进。开工前请先通读 附录A(约 15 分钟),里面有你写练习时要反复使用的角色、状态机、接口约定与 TypeScript 实体。
本教程与 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 |
node -vnpm i -g pnpm && pnpm -v准备好了?打开 第 01 章 · 走进 Vue 3。
📍 阶段:初级阶段(第 1–6 章)|⏱ 预计学时:3 小时|🔗 前置:无(建议先读 《导读》 与 《附录 A · 贯穿项目「轻订单」需求说明书》)
⬅ 上一章:导读 | 📖 本版目录 | ➡ 下一章:模板语法与数据绑定
💡 对照学习:本页是 Vue 版第 01 章。React 版对应章节为 第 01 章 · 走进 React。概念映射:Vue 的「单文件组件(SFC)」≈ React 的「组件文件(.jsx)」;Vue 的「template 模板」≈ React 的「JSX」;两版均使用 Vite + pnpm,工程链路完全一致,可以边学边对照。
学完本章,你将能够:
<script setup> 单文件组件,并用 Vue Devtools 观察组件树与组件状态。在「轻订单」立项之前,青云科技的老官网是一坨原生 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」中解放出来,只关心「状态是什么、什么时候变」——这就是前端框架存在的理由。
| 维度 | MPA(多页应用) | SPA(单页应用) |
|---|---|---|
| 页面跳转 | 每次跳转都向服务器请求新的 HTML | 前端接管路由,只切换视图组件 |
| 首屏速度 | 通常快(服务端直出) | 首包较重,需靠工程手段优化 |
| 后续交互 | 整页刷新,体验割裂 | 局部更新,接近原生应用 |
| SEO | 友好 | 需额外手段(SSR/SSG,见第四部分) |
| 典型场景 | 官网、内容站 | 中后台系统、复杂交互产品 |
对应到「轻订单」的两期交付:
三种主流声明式框架(Vue / React / Svelte)共享同一个心智模型:
事件(用户操作)
┌────────────────────────┐
▼ │
┌─────────┐ ┌─────────┐
│ 状态 │ ───自动───► │ 视图 │
│ (state) │ 同步渲染 │ (DOM) │
└─────────┘ └─────────┘
你只负责:① 定义状态 ② 声明"状态长什么样" ③ 在事件里改状态
框架负责:状态变化 → 精确更新 DOM
Vue 与 React 的差异在「框架怎么知道该更新了」:Vue 3 通过响应式系统在数据被读写时自动追踪依赖(第 04 章详解);React 则通过 setState 显式触发重新渲染。两者没有优劣,只是约定不同——本教程不贬低任何一方,学到第 04 章你自然能体会两种取舍。
<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 选项。
企业前端的标准三件套。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 的默认页面,环境就绪。
脚手架生成的目录不长,但每一项都要知道职责——企业里「看懂仓库」比「会写代码」更早成为你的日常:
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')
pnpm run dev 启动的是 Vite 开发服务器,它有两个企业级特性:
动手验证 HMR(后面实战练习要交作业):
src/App.vue,把模板里的标题改成 轻订单 · 青云科技,保存——页面文字变了,浏览器没有刷新(Network 面板没有整页请求);vite.config.js——终端会提示重启,因为配置文件改动不支持热更,需要重启 dev server。在 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 开发的完整闭环:建组件 → 导入 → 使用 → 热更新生效。
装上官方浏览器扩展 Vue.js devtools(Chrome/Edge 商店搜索安装,或用其独立版应用),dev 环境下打开页面按 F12 会多出一个 Vue 面板:
注意:Devtools 只在开发构建中生效,生产构建默认不暴露组件状态——这本身也是一条安全基线(不要在生成环境中把内部状态暴露给访问者)。
真实入职不是「新建项目」,而是「接手已有仓库」。假设导师给了你内网仓库地址,你的标准动作是:
# 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.5 步骤创建 qingorder-portal 项目,把 App.vue 的标题改为「轻订单 · 青云科技智能硬件」,再新建 BrandBadge.vue 组件并展示在页面上。
提示:参考 §1.8 的组件代码;注意 <script setup> 中导入组件后可直接使用。
验收标准:
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。
任务描述:把脚手架示例清干净,建立「轻订单」的目录雏形,并完成两处工程配置:① 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
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 并清空为一份空文件。
任务描述:假设 qingorder-portal 是同事交接给你的仓库。完成 §1.10 的完整流程后,在项目根目录写一份 ONBOARDING.md(入职笔记,只给自己看),包含:① 启动本项目需要的环境版本(Node/pnpm 实测值);② 项目结构一句话说明(你自己的话,不许抄本教程);③ HMR 与整页刷新的区别(你的验证方式);④ 你在配置 @ 别名时踩的坑。
提示:node -v、pnpm -v 的输出直接贴进笔记;「踩坑」栏哪怕是「忘记重启 dev server 导致别名不生效」也值得记录——这是团队最看重的交接材料。
验收标准:
ONBOARDING.md 四个部分齐全;# ❌ 错误现象: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 版本要求,先看它再动手。
# ❌ 错误:同事用 pnpm,你顺手 npm install
# 结果:同时存在 pnpm-lock.yaml 与 package-lock.json,
# 后续安装可能装出一套不一致的依赖树,出现"我本地明明有这个包"
# ✅ 正确:跟随仓库锁定的包管理器;误装后清掉残留再重来
rm -rf node_modules package-lock.json
pnpm install
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。
vite.config.js 以为热更新会生效// ❌ 错误认知:加了 server.proxy / alias 后直接刷新页面,配置毫无反应
# ✅ 正确:Vite 的 HMR 只作用于源码(.vue/.js/.css);
# 配置文件的改动必须手动重启 dev server(Ctrl+C 后重新 pnpm run dev)
<!-- ❌ 错误:组件名叫 Badge。虽然现在能跑,
但与未来原生自定义元素/其他库标签冲突风险大,风格指南直接禁止 -->
<Badge />
<!-- ✅ 正确:多词大驼峰命名(文件名与标签一致) -->
<BrandBadge />
engines 字段强制约束——新成员第一天不应该在装环境上浪费半天。.npmrc 或 CI 里卡死(如 engine-strict=true),杜绝 lockfile 混用造成的「薛定谔的依赖」。<script setup> 是唯一姿势:新代码一律 Composition API;读到 Options API 老代码时能看懂即可,不要在新代码里混用两种风格。BrandBadge.vue 里是 BrandBadge,检索、重命名、Devtools 里定位都受益。components/(组件)、styles/(样式)、data/(mock 数据)从第一天就分开,比事后大重构便宜十倍。pnpm create vite@latest --template vue 一键创建。<script setup> 让顶层绑定与导入组件自动可用;SFC 一个文件装齐逻辑、视图、样式。v-for 渲染出你的第一份订单列表。🔗 延伸阅读(官方文档,长期维护):Vue 3 指南 · 开始|Vite 指南|Vue Devtools
📍 阶段:初级阶段(第 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。
学完本章,你将能够:
{{ }} 输出文本与表达式,并说出模板表达式的边界(是表达式不是语句、全局白名单)。v-bind 绑定 HTML 属性与组件 props,熟练运用 : 简写与动态参数。v-on 绑定事件、传递参数,并说明 .stop / .prevent / .enter 等常用修饰符的作用。v-model 完成文本框、下拉框、复选框的双向绑定,并解释它的语法糖本质。v-if 与 v-show 控制显隐,并说出两者在切换成本与初始成本上的取舍依据。v-for 渲染订单列表,正确绑定稳定 key,并描述 key 在虚拟 DOM diff 中的作用。class 与 style,实现订单状态标签的动态配色。双花括号插值把数据「贴」进模板,里面是表达式而不是语句:
<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>,而不是在模板里写长表达式——可读性是硬指标。
插值只能用于文本内容;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>
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>
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 章的主题。
<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 同时用一个元素没有意义,二选一。
<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。
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>
订单状态会出现在列表、详情、报表、弹窗等十几处。用一张映射表收敛「状态 → 文案/颜色」的映射,而不是到处写三元表达式——这是本章最重要的工程习惯:
// 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>
任务描述:在 qingorder-portal 中新建 src/data/orderStatus.js(映射表照抄 §2.8),再新建 StatusList.vue:用 v-for 渲染 7 种状态标签,标签文案与配色类名都从映射表读取。
提示::class 用模板字符串拼 tag--${type};v-for 的 key 用状态码本身(枚举值天然唯一)。
验收标准:
class 形如 tag tag--warning;<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.8 的完整筛选栏页面 OrderFilterDemo.vue:关键词输入(回车或输入即筛)、状态下拉、结果列表、空态提示。要求筛选逻辑写在 computed 里。
提示:关键词匹配用 includes 并统一转小写;状态下拉的选项来自 ORDER_STATUS_OPTIONS。
验收标准:
QO20260928 能筛出前两条,输入「云梯」能筛出第一条;<ul>;:class 拼接外没有超过一行的表达式。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" 控制。
任务描述:① 把列表升级为表格,点击表头「创建时间 / 金额」切换升降序(再次点击反向);② 做一个反证实验:给「金额」列放一个 <input>(填备注),分别用 order.id 与数组下标作 key,对列表按金额排序后观察输入框内容是否串位,把现象与结论写进代码注释。
提示:排序用展开拷贝后 sort([...orders].sort(...)),不要原地排序被 computed 依赖的源数组;实验结论对应 §2.6 的 diff 直觉。
验收标准:
order.id 作 key 时排序后输入框内容跟随行移动;orders 引用内容不变)。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 移动节点,输入状态跟着行走。
<!-- ❌ 错误: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'))
官方风格指南将此列为「避免」级规则:两者永远不要同标签。
<!-- ❌ 错误: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 是「节点的身份证」,不是「序号」。
<!-- ❌ 错误:<template> 不是真实元素,v-show 靠 display 样式起作用,
编译报错:v-show 无法使用 -->
<template v-show="showTip"><p>提示</p></template>
<!-- ✅ 正确:显隐切换用 v-if;或把 v-show 挪到真实元素上 -->
<template v-if="showTip"><p>提示</p></template>
<!-- ❌ 错误: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>
<!-- ❌ 错误:模板里 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>
data/ 目录的常量文件,新增状态只改一处(本章 §2.8 模式,后续章节反复复用)。computed:模板只做「贴数据」,一眼能看出页面结构;computed 的缓存特性还避免重复计算(第 04 章展开)。v-show;低频且重组件(弹窗、详情块)用 v-if,让不显示的组件真正销毁、释放监听。v-if="list.length === 0" 渲染一句「暂无数据」,空 <ul> 是新手页面最常见的「看起来像坏了」。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 在重排时会造成状态串位。ORDER_STATUS_META)是本章交给你的第一个企业级资产,第 03 章会把状态标签抽成真正的组件。🔗 延伸阅读(官方文档,长期维护):模板语法|列表渲染|事件处理|风格指南 · 避免 v-if 与 v-for 同元素
📍 阶段:初级阶段(第 1–6 章)|⏱ 预计学时:4 小时|🔗 前置:第 02 章
⬅ 上一章:模板语法与数据绑定 | 📖 本版目录 | ➡ 下一章:响应式系统核心
💡 对照学习:本页是 Vue 版第 03 章。React 版对应章节为 第 03 章 · 组件化开发基础。概念映射:
props≈props(同样单向);emit事件 ≈ 回调 props(onXxx);默认插槽 ≈children;具名插槽 ≈ 具名 children 属性;作用域插槽 ≈ render props。
学完本章,你将能够:
<script setup> 自动注册)。defineProps 声明组件入参并做类型/必填/默认值约束,解释单向数据流为何不可破坏。defineEmits 声明并派发自定义事件,实现子 → 父通信,并按约定处理事件名大小写。StatusTag、OrderCard、SearchBar。组件是带名字的、可复用的 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…)
子组件通过 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 阶段用本节的运行时声明即可,两者编译后等价。
子组件用 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>
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>
普通插槽的内容编译在父组件作用域,拿不到子组件的数据。当「列表长什么样」由父组件定制、「数据在哪」由子组件掌握时,用作用域插槽把子组件数据作为插槽参数传出去:
<!-- 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)底层全是它。
| 原则 | 判断方法 | 反例 |
|---|---|---|
| 单一职责 | 能用一句话说清组件做什么;说清前出现「并且」就该拆 | OrderPageAndFilterAndModal.vue |
| 复用 vs 组合 | 「在 2+ 处出现且行为一致」才值得抽复用;只出现一次的区块先做成组合的普通组件 | 把只出现一次的 Banner 强行参数化出 8 个 props |
| 数据归属 | 状态放在「最需要它的最小公共父级」,下传 props、上传事件 | 把输入框的临时文本提升到 App.vue |
| 拆分粒度 | 先按「区块」拆(导航/列表/侧栏),再按「复用点」拆(按钮/标签) | 一步到位拆出 40 个碎片组件 |
一个实用的职责声明格式(写进组件顶部注释,团队评审时逐条对):
<script setup>
/**
* OrderCard · 订单卡片
* 职责:展示单张订单摘要,并对外派发"查看详情 / 取消订单"意图。
* 不负责:数据获取(由父级传入)、取消弹窗(由父级实现)。
*/
任务描述:把第 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" 生效。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。
任务描述:实现 §3.3 的 OrderCard 组件,并在页面组件 OrderCardDemo.vue 中渲染 3 张 mock 订单卡:点击「查看详情」在页面上显示当前订单号;点击「取消订单」后把该订单状态改为 cancelled(映射表里有 cancelled,标签自动变灰)。
提示:状态修改必须发生在父组件(事件回调里),OrderCard 只 emit;「取消」前用 confirm 简单拦截即可。
验收标准:
<!-- 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>
任务描述:① 实现 SearchBar.vue:内部维护关键词状态,输入框回车或点击按钮时派发 search 事件(载荷为关键词),组件顶部写好「职责声明」注释;② 用 BaseCard(§3.4)包裹「订单概览」区块,头部具名插槽放标题,默认插槽放 OrderTable(§3.5)并通过作用域插槽自定义「操作列」(放一个 StatusTag);③ SearchBar 的关键词对表格数据做前端过滤。
提示:数据流方向自上而下——页面持有 orders 与 keyword,过滤用 computed;OrderTable 不需要知道搜索的存在。
验收标准:
<!-- 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>
// ❌ 错误:基础类型直接赋值——控制台警告,且父组件毫不知情
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)
// ❌ 错误:每次实例化共享同一个数组/对象引用(潜在的跨实例污染)
defineProps({
tags: { type: Array, default: [] },
config: { type: Object, default: {} },
})
// ✅ 正确:引用类型默认值必须用工厂函数,每次返回新实例
defineProps({
tags: { type: Array, default: () => [] },
config: { type: Object, default: () => ({ pageSize: 20 }) },
})
<!-- ❌ 错误:子组件没有 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')
}
// ❌ 错误组合:emit('viewDetail') + 父组件里写 @viewdetail(DOM 内模板属性全小写)
// 或声明用 kebab-case 又在 JS 里 camelCase 派发——名字对不上,回调静默失效
// ✅ 正确:官方推荐——声明用 camelCase,模板监听用 kebab-case
const emit = defineEmits(['viewDetail'])
emit('viewDetail', id)
<!-- ✅ 模板里:<OrderCard @view-detail="handleViewDetail" /> -->
现象是「点了没反应、控制台也不报错」——先核对事件名两端的拼写。
<!-- ❌ 错误:插槽内容编译在父组件作用域,
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>
defineEmits 全量声明:既是文档也是校验,还能关掉事件透传的暗坑(易错点 3)。search、cancel-order、update-status;载荷尽量带 id 或对象而不是「让父组件再去查一遍」。components/order/、components/common/;common/ 里的组件禁止 import 业务模块(依赖只许自上而下)。<script setup> 导入即注册,无 components 选项。StatusTag(纯展示)、OrderCard(展示 + 意图事件)、SearchBar(受控输入 + 搜索事件),后续章节持续复用。📍 阶段:初级阶段(第 1–6 章)|⏱ 预计学时:4.5 小时|🔗 前置:第 03 章
⬅ 上一章:组件化开发基础 | 📖 本版目录 | ➡ 下一章:生命周期与组件通信
💡 对照学习:本页是 Vue 版第 04 章。React 版对应章节为 第 04 章 · Hooks 核心。概念映射:
ref≈useState;reactive≈useState持有对象(React 需不可变更新,Vue 是真正的响应式代理);computed≈useMemo;watch/watchEffect≈useEffect(显式依赖 / 自动依赖);「解构丢响应式」在 React 中不存在对应物,但心智负担对应「闭包过期」陷阱;自定义组合式函数 ≈ 自定义 Hook。
学完本章,你将能够:
ref 与 reactive,并说出各自的适用场景与解包规则(模板顶层解包、JS 中 .value、reactive 属性自动解包)。toRefs / toRef 给出正确写法。computed 派生数据并解释其缓存语义,遵守「computed 不写副作用」的纪律。watch 与 watchEffect 的依赖收集方式,并根据任务选对工具。watch 的 immediate、deep、回调清理(onCleanup)处理真实副作用场景。useXxx 命名约定。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
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++ // 改的是普通变量副本,不是代理属性
| 场景 | 推荐 | 理由 |
|---|---|---|
| 单值状态(布尔、数字、字符串) | ref |
唯一选择,语义清晰 |
| 需要整体替换的对象 | ref |
form.value = newForm 合法 |
| 一组强相关的表单字段 | reactive |
form.name / form.price 写起来顺,无需 .value |
| 组件间传值 / 组合式函数返回值 | ref |
解构安全(配合 toRefs) |
一条企业级共识:不确定时用 ref。它没有整体替换的坑,组合式函数返回 ref(或 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)。
computed 从现有状态派生新状态。它有两个关键行为:
<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)。
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) // 非同步作用域创建的要手动停
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 显式声明,更可读也更可控。
把「一组相关状态 + 逻辑」封装成 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 章展开)。
把本章知识串成「轻订单」真实需求(附录 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 结构原封不动。
任务描述:实现 OrderItemRow.vue:输入数量(1–9999)与单价(分),用 computed 实时算出行金额(元展示,两位小数);金额超过 10000 元时显示「大额订单」角标。
提示:数量用 v-model.number;computed 只算金额,「是否大额」再由一个 computed 派生——不要在两个 computed 里重复计算。
验收标准:
¥0.00,不出现 NaN;<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>
任务描述:把 §4.9 的 useOrderFilters 与 OrderListView.vue 完整实现到项目里,额外要求:① 筛选条件变化时自动把 page 重置为 1;② 连续快速输入关键词时,只有最后一次请求的结果被采用(requestId 竞态保护)。
提示:重置页码可以监听 () => [filter.keyword, filter.status] 单独写一个 watch;竞态保护用 §4.9 的自增 id 方案。
验收标准:
useOrderFilters 返回值解构后依然响应式(页码按钮能正常工作)。// 加在 useOrderFilters 内部
watch(
() => [filter.keyword, filter.status],
() => {
filter.page = 1 // 条件变了,回到第一页
}
)
竞态保护:watch(queryParams, ...) 回调里自增 requestId 并在写入前比对(§4.9 已给出完整代码)。
任务描述:封装 useDebouncedRef(sourceRef, delay):返回一个「防抖只读视图」——源 ref 连续变化时,视图值在停止变化 delay 毫秒后才更新。用它替换练习 2 的关键词过滤,让搜索输入 300ms 防抖后再触发请求。
提示:内部用 watch 监听源 + setTimeout;记得在每次触发前清掉上一个定时器,并在组件卸载时清理(onUnmounted,第 05 章会系统讲,这里先照写);返回 readonly 的 ref 视图。
验收标准:
// 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
}
)
// ❌ 错误:pendingCount 是 ref 包装对象,-- 操作的是包装对象,视图纹丝不动
const pendingCount = ref(3)
function approveOne() {
pendingCount--
}
// ✅ 正确:JS 中读写 ref 必须 .value(模板里才自动解包)
function approveOne() {
pendingCount.value--
}
现象是「代码执行了但页面没反应」——初级阶段响应式 bug 的一半都是它。
// ❌ 错误:解构出的是普通值副本,改它们不影响视图
const { keyword, status } = reactive({ keyword: '', status: 'all' })
keyword = 'QO'
// ✅ 正确一:不解构,始终 filter.keyword 访问
// ✅ 正确二:toRefs 保持链接
const { keyword, status } = toRefs(filter)
keyword.value = 'QO'
// ❌ 错误: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 负责「做」。
// ❌ 错误一: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)
// ❌ 错误:在 setTimeout 里创建的 watcher 不属于 setup 同步作用域,
// 组件卸载后它仍然活着,回调里改状态触发警告,造成内存泄漏
setTimeout(() => {
watch(() => filter.keyword, onKeywordChange) // 永不自动停止
}, 1000)
// ✅ 正确:要么在 setup 同步作用域直接创建(自动随组件停止),
// 要么手动接住停止函数并在卸载前调用
const stop = watch(() => filter.keyword, onKeywordChange)
onUnmounted(() => stop())
ref,聚合表单才 reactive:ref 没有整体替换与解构的心智负担,团队统一后 code review 少一半争论。computed,副作用一律 watch:分工写进团队规范,computed 里出现 await 直接打回。watch 必配竞态保护与清理:requestId 自增或 AbortController + onCleanup,二选一,但必须有。toRefs,调用方解构永远安全。ref 万能但 JS 中要 .value;reactive 顺手但不能整体替换、不能解构——解构丢失用 toRefs 修复。computed 有缓存且自动追踪依赖,只做纯计算;watch 显式依赖可拿旧值,watchEffect 立即执行自动收集——「算」与「做」分离。immediate 首屏、onCleanup 清理、竞态 id 防旧覆盖新。useOrderStats 与 useOrderFilters 两个组合式函数——这是你在 Vue 里的第一份「逻辑复用资产」,第 09 章会无缝接入真实接口层。provide/inject 跨层通信与 v-model 组件封装,让三件套组件进化成可复用的表格 + 分页 + 弹窗。📍 阶段:初级阶段(第 1–6 章)|⏱ 预计学时:4.5 小时|🔗 前置:第 04 章
⬅ 上一章:响应式系统核心 | 📖 本版目录 | ➡ 下一章:初级实战 · 企业门户首页
💡 对照学习:本页是 Vue 版第 05 章。React 版对应章节为 第 05 章 · 副作用生命周期与组件通信。概念映射:
onMounted≈useEffect(fn, [])(挂载后执行一次);onUnmounted/onCleanup≈useEffect的清理函数;provide/inject≈ Context;组件上的v-model≈ 受控组件(value+onChange);动态组件 ≈ 条件渲染 / 组合模式。
学完本章,你将能够:
onMounted 发起首屏请求与获取 DOM 引用,说明它对应 React 的哪个钩子。onUnmounted / onCleanup),杜绝内存泄漏与重复触发。provide / inject 实现跨层级通信,并说出它相对逐层传 props 的适用边界。defineModel 封装支持 v-model 的组件(含具名模型),解释它与「受控组件」的对应关系。<component :is> 实现动态组件切换,并了解递归组件渲染树形数据的原理。ConfirmDialog(v-model 封装)、BasePagination、CategoryTree(递归)。组件从「出生」到「销毁」的关键节点。<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 把「跑一次」和「清理」拆成两个钩子,语义更直白。
凡是「注册了就会一直活着」的东西——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)与「组件卸载时」的清理(同步作用域创建则自动停止)两个层次,别混淆。
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 会让组件的依赖关系在类型与文档里「隐形」,可移植性暴跌。
第 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 把这对约定标准化成了编译宏。
<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」。
组件模板里通过自身文件名引用自己(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 时不再展开)。忘记出口 = 无限循环直到栈溢出。
后台列表的标配。注意它是「受控 + 事件」双通道: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 自动重新请求。这正是组件通信与响应式系统咬合运转的样子。
任务描述:把 §5.4 的 ConfirmDialog 实现进项目,并在订单列表页接入:点击订单卡「删除」打开弹窗,确定后从列表中移除该订单。
提示:defineModel('visible') 的赋值会自动派发 update:visible;删除逻辑(过滤数组)写在父组件。
验收标准:
<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>
任务描述:实现 §5.7 的 BasePagination(合并后的正确版本),接入 useOrderFilters:分页器显示真实 total,翻页自动触发重新请求;另给「每页条数」加一个下拉(v-model:pageSize,验证多模型)。
提示:pageSize 也用 defineModel 声明为具名模型;改 pageSize 后记得把 page 重置为 1(第 04 章的 watch 思路)。
验收标准:
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))。
任务描述:① 实现 §5.6 的 CategoryTree 递归组件,数据至少三层;② 用 provide/inject 注入当前用户角色:operator/super_admin 显示每个分类后的「编辑」按钮,其他角色看不到(附录 A §2 按钮级权限)。
提示:注入键用 Symbol(§5.3 的 CurrentUserKey);canEditProduct 的判断收进一个 computed,模板里只写 v-if="canEdit"。
验收标准:
aria-expanded 状态同步);CategoryTree 对角色的依赖只通过 inject,不接受额外的 props。<!-- 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>
// ❌ 错误:离开页面后定时器继续跑,控制台持续输出;
// 再次进入页面(组件重建)后变成两个定时器轮番改状态
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)
})
// ❌ 错误:onMounted 被包在 setTimeout 里——它不再是"注册"而是"调用",
// Vue 无法关联到当前组件实例,钩子永不触发(开发环境有警告)
setTimeout(() => {
onMounted(() => refresh())
}, 1000)
// ✅ 正确:钩子注册必须在 setup 同步作用域;延迟逻辑放进钩子内部
onMounted(() => {
setTimeout(refresh, 1000)
})
// ❌ 错误: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), // 修改入口唯一、可追踪
})
// ❌ 错误:直接给 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
}
<!-- ❌ 错误:tabName 是 'OrderInfoPanel' 字符串,
:is 按字符串只会找"全局注册组件/原生标签",本地导入的组件找不到,页面空白 -->
<component :is="tabName" />
<!-- ✅ 正确:本地组件以组件对象传入(用对象映射) -->
<script setup>
import OrderInfoPanel from './OrderInfoPanel.vue'
const tabMap = { info: OrderInfoPanel }
</script>
<template>
<component :is="tabMap[activeTab]" />
</template>
onMounted 的当下就写 onUnmounted,把清理逻辑放在同一屏——隔屏的清理代码必然漏改。useOrderPolling、useDebouncedRef 这类封装把「注册+清理」锁在黑盒里,调用方不可能忘(第 04 章练习 3 已示范)。defineModel 让开关状态的所有权留在父组件,子组件只表达「我想关」。:is + 对象字典 + shallowRef,类型可查、拼写错误编译期就能暴露。children: [] 是比 children: null 更好的数据约定。onMounted(可碰 DOM/发请求)→ onUpdated(勿改状态)→ onUnmounted(清理副作用);钩子必须在 setup 同步作用域注册。watch 的清理分「每次触发前(onCleanup)」与「卸载时(自动/手动 stop)」两层。provide/inject 解决跨层通信(≈ Context),只放全局低频数据,且要保护数据所有权(readonly + 显式修改方法)。defineModel 一行封装受控组件,v-model:xxx 具名模型支持多模型;它就是 Vue 版的「value + onChange 约定」。<component :is> 组件对象映射实现 Tab 切换;递归组件靠文件名自引用渲染树形数据,出口先行。🔗 延伸阅读(官方文档,长期维护):生命周期|provide / inject|组件 v-model|动态组件
📍 阶段:初级阶段(第 1–6 章)|⏱ 预计学时:6 小时|🔗 前置:第 05 章(建议先重读附录 A §1、§6)
⬅ 上一章:生命周期与组件通信 | 📖 本版目录 | ➡ 下一章:路由系统 · Vue Router 全解
💡 对照学习:本页是 Vue 版第 06 章。React 版对应章节为 第 06 章 · 初级实战:企业门户首页。两版需求、组件树、验收标准完全一致,可逐文件对照实现。概念映射:
provide/inject主题状态 ≈ React 的 Context 主题方案;CSS 变量 +data-theme两版通用;组件树设计方法论两版相同。
学完本章,你将能够:
data/ 目录驱动。provide/inject + localStorage 实现暗色主题切换,并尊重系统主题偏好。components/portal/、data/、composables/、styles/)。企业里接需求的第一步不是写代码,而是把页面切分成组件。门户首页(附录 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/。
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 变量)。
三段式方案: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)> 默认亮色——企业产品的标准主题策略。
数据文件(两个区块共用,先写数据再写组件,能逼你先想清接口):
// 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>
提交 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 | 组件顶部有一句话职责声明 | 说不清「这个组件不负责什么」 |
aria-expanded 正确;data/portal.js,组件内无硬编码文案;import.meta.env.VITE_*。任务描述:创建 data/portal.js(§6.4 数据文件)与 HeroBanner、FeatureGrid 两个组件并接入 App.vue,完成主视觉与特性区。
提示:先写数据文件再写组件;FeatureGrid 用 grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)) 做响应式网格。
验收标准:
<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>
任务描述:实现 §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。
任务描述:实现 §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 读写有 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 }
}
<!-- ❌ 错误: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) 都能取到。
// ❌ 错误:部分浏览器/隐私模式访问 localStorage 直接抛 SecurityError,
// 未捕获异常发生在模块顶层 → 应用启动即崩
const theme = localStorage.getItem('qingorder-theme')
// ✅ 正确:读取与写入都包 try/catch,异常时退回默认主题
function readStoredTheme() {
try {
return localStorage.getItem(STORAGE_KEY)
} catch {
return null
}
}
<!-- ❌ 错误:开合状态在每个 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)" />
<!-- ❌ 错误:App 把 pageConfig 整包传给每个区块,
区块内部再解构——接口面失控,任何一处改动牵动全链路 -->
<SiteNav :config="pageConfig" />
<HeroBanner :config="pageConfig" />
<FeatureGrid :config="pageConfig" />
<!-- ✅ 正确:按需切片传递(或让区块直接消费 data/ 模块),
每个组件的 props 只包含它职责内的数据 -->
<SiteNav :items="navItems" />
<FeatureGrid :features="features" />
<!-- ❌ 错误:写死亮色背景——切到暗色主题后这块依然刺眼 -->
<style scoped>
.faq-item { background: #ffffff; color: #0f172a; }
</style>
<!-- ✅ 正确:一律引用令牌变量,主题切换自动生效 -->
<style scoped>
.faq-item { background: var(--qo-surface); color: var(--qo-text); }
</style>
回归手段:切到暗色主题后整页扫一遍,发现任何「不跟随」的元素,多半是它。
data/portal.js 让「改文案」变成运营任务而不是发版任务,也天然隔离了业务词与组件逻辑。aria-expanded、语义化标签(header/nav/main/footer)、可聚焦的交互元素,初级阶段就按清单做,比高级阶段补课便宜得多。#fff、#333,出现在组件里的色值一律打回。data/portal.js 驱动。data-theme、useTheme composable(含 localStorage 兜底)、provide/inject 只读分发,优先级「用户选择 > 系统偏好 > 默认」。v-show;无障碍属性是验收项。🔗 延伸阅读(官方文档,长期维护):样式与 CSS 变量|provide / inject|无障碍基础(Vue 官方)|Lighthouse
📍 阶段:中级阶段(第 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。
学完本章,你将能够:
createRouter 与嵌套 children 搭建中后台 Layout 布局结构(侧边菜单 + 顶栏 + 内容区);/orders/:id,并正确处理「路径参数变化但组件被复用」的更新问题;router.beforeEach,基于 meta 实现登录拦截、白名单放行与页面级权限(403);scrollBehavior 滚动行为与路由过渡 + keep-alive 缓存,兼顾首屏体积与浏览体验;route.matched 实现中后台面包屑导航,并说出它与路由匹配记录数组的关系。前六章的「轻订单」门户是单页应用(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')
中后台系统的经典结构是「外壳 + 内容区」:外壳(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>
订单详情页的 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' } })
}
<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 触发的导航解析流程(简版,完整版见官方文档《导航守卫》一章):
beforeRouteLeave 允许离开);beforeEach(登录态、页面级权限检查——本章主角);beforeRouteUpdate(如 /orders/1 → /orders/2);beforeEnter;beforeResolve 解析完成 → 确认导航 → afterEach(埋点、改标题);beforeRouteEnter 的 next 回调执行。守卫是中后台权限的第一道闸门。核心手法:在路由表 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
两个关键约定:
next():return true 放行、return false 取消、return 路由地址 重定向(next 写法仍兼容,但团队内统一用一种);meta 会从父到子浅合并:父路由 / 上写 meta.requiresAuth,所有 children 都继承,不必每条重复。路由懒加载把每个页面切成独立 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>
面包屑来自 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 的地基。
任务描述:从零创建路由骨架——/login、外壳 Layout(侧边菜单 + 内容区)、/dashboard 与 /orders 两个懒加载子页面、404 兜底页;安装 Vue Router 并在 main.js 挂载。
提示:直接复用 §7.2 的三段代码;用 pnpm add vue-router@4 安装;页面组件先写占位内容。
验收标准:
http://localhost:5173/orders 能渲染出带侧边菜单的列表占位页;/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(.*)*)必须放在顶层。
任务描述:给订单列表页加两个交互——①点击行进入 /orders/:id 详情页,面包屑显示「首页 / 订单详情」;②列表的筛选条件(关键词、状态、页码)同步到 URL query,刷新页面后筛选状态不丢,从详情页「返回」时回到原筛选。
提示:筛选变化时用 router.replace({ query: {...} }) 同步 URL(replace 不产生多余历史记录);onMounted 时从 route.query 恢复筛选;返回用 router.back()(历史栈里还是带筛选的列表 URL)。
验收标准:
/orders?status=approved&page=2 刷新后列表仍处于「已审核 + 第 2 页」;// 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 本来就带着筛选。
任务描述:①给「系统设置」路由加 meta.roles: ['super_admin'],守卫里校验角色(角色先 mock 一个数组),无权限重定向到 /403;②新建 403 页;③给列表页加上 keep-alive 缓存与路由过渡动画。
提示:校验逻辑写在 beforeEach 里、登录拦截之后;to.meta.roles 是数组时用 some 判断交集;403 页也要加进白名单。
验收标准:
/settings/members 被重定向到 /403,URL 带 from 参数;super_admin 可以正常访问;// 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>
/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" /> -->
// ❌ 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' } })
// ❌ /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
})
// ❌ import() 立即执行,component 拿到的是 Promise,页面渲染异常且失去分包意义
component: import('@/views/orders/OrderListView.vue')
// ✅ 传「函数」,由路由器在首次进入时调用
component: () => import('@/views/orders/OrderListView.vue')
❌ 现象:开发环境一切正常(Vite dev server 自带回退),部署到 Nginx 后
访问 /orders 刷新出现 404——因为服务器真的去找 /orders 这个文件。
# ✅ Nginx 配置 SPA 回退:找不到文件时返回 index.html,交给前端路由
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
meta 统一承载 title、public、roles、hidden,守卫、菜单、面包屑都从它派生,避免多处维护不一致。router.push({ name: 'order-detail', params }) 优于字符串拼接,路由表重构时 IDE 全局可搜。vite build 产物验证分包。props: true 接参数,组件摆脱对 useRoute 的直接依赖,复用与测试更容易。afterEach;守卫逻辑保持纯函数式,方便单测。keep-alive :include 显式列出需要缓存的页面,并配 defineOptions({ name }),避免内存无限增长。try_files ... /index.html。children 子路由 + 二级 <router-view>;懒加载让每个页面独立分包。:id),query 表达页面状态(筛选、分页);两者都可恢复,前者靠 path 声明、后者天然在 URL。meta 是权限路由的通用手法:白名单放行 → 登录拦截 → 角色校验 → 403。onBeforeRouteUpdate(或 :key),这是中后台最高频的 bug 之一。下一章预告:菜单、面包屑、守卫都就位了,但「当前登录人是谁、有什么角色」还散落在 localStorage 里。第 08 章用 Pinia 把用户态、字典缓存、订单暂存区管起来。
📍 阶段:中级阶段(第 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 同属「轻量、少样板」流派,理念相近。
学完本章,你将能够:
defineStore 的 setup 写法创建 useUserStore、useDictStore 等 store,并遵守 useXxxStore 命名约定;storeToRefs 与函数式访问,避免解构 store 导致的响应性丢失;不是所有状态都值得进全局 store。企业项目里先给状态分类,再决定放哪:
| 类别 | 例子 | 归属 | 理由 |
|---|---|---|---|
| 本地 UI 态 | 弹窗开关、按钮 loading、表格多选 | 组件内 ref |
生命周期与组件一致,全局化只会制造耦合 |
| 服务端态 | 订单列表、订单详情、客户下拉选项 | 请求层 + composable(第 09 章) | 有缓存、失效、竞态等专属问题,混进 store 会重复造轮子 |
| 跨页共享态 | 登录用户与权限、字典缓存、新建订单草稿 | Pinia | 多页面读写、生命周期跨越路由跳转 |
「轻订单」里三个典型场景正好各占一类:用户态(每个守卫、每个按钮都要读)、字典缓存(订单状态下拉在列表、详情、表单三处复用)、订单暂存区(表单写到一半跳去查商品,回来不能丢)。本章就实现这三件套。
React 对照:这个三分法同样适用——组件
useState/ TanStack Query / Redux(或 Context),两版思想完全一致,差别只在工具。
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')
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 批量改
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,不借助解构。
「新建订单表单写到一半,用户跳去商品目录查价格,回来草稿还在」——这是中后台体验的关键细节,用 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(),天然避免了模块加载期的循环依赖问题。
字典这类「不常变、到处用」的数据,每次进页面都请求一遍太浪费。用 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 章专门讲权衡);字典缓存的完整版有三个工程细节,缺一不可:过期时间(避免脏数据)、并发去重(三个页面同时首载只发一次请求)、强制刷新(字典被管理员改了之后)。
// 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),时间线里一眼可读;这是排查「谁改了这个值」的第一工具。
任务描述:实现 §8.4 的 useUserStore(可直接抄),再写一个 UserBadge.vue 组件:显示用户名与超管标签,提供「模拟登录 / 退出」两个按钮(不调接口,直接调用 action 写入假数据)。
提示:假数据结构对照附录A的权限模型:{ name: '运营小张', roles: ['operator'], permissions: ['order:create', 'order:edit'] }。
验收标准:
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>
任务描述:实现 §8.7 的 useDictStore,并写一个 StatusSelect.vue 通用下拉(订单状态筛选用)。要求:三个组件实例同时挂载,Network 面板只出现一次字典请求;5 分钟后再次打开页面会重新请求。
提示:用 pendingPromise 做并发去重(§8.7 已给答案骨架);验证过期可用「把 EXPIRE_MS 临时改成 5 秒」来加速实验;fetchDict 先用假 promise 代替接口(第 09 章接入真接口)。
验收标准:
<!-- 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>
任务描述:实现 §8.5 的 useOrderDraftStore 并打通闭环:新建订单页每 3 秒自动把表单快照 saveDraft 进暂存区;中途跳走再回来,弹出「检测到未提交草稿,是否恢复?」确认框(ElMessageBox.confirm);提交成功或用户主动放弃后 clear()。要处理「草稿版本不一致直接丢弃」的兼容逻辑。
提示:定时器要在组件卸载时清理(第 05 章的副作用清理知识);版本判断放 initDraft 返回值里,由页面决定弹不弹窗。
验收标准:
DRAFT_VERSION 手动改大后,旧草稿被静默丢弃;// 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 }))
// ❌ 解构即取值:profile 是快照,登录后界面不更新,且很难排查
const { profile, isSuperAdmin } = useUserStore()
// ✅ storeToRefs 包住响应式部分;actions 是函数,可直接解构
import { storeToRefs } from 'pinia'
const store = useUserStore()
const { profile, isSuperAdmin } = storeToRefs(store)
const { clearAuth, hasPermission } = store
// ❌ 定义了但没 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 }
// ❌ 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
}
// ❌ getter 返回对象:每次求值都产生新引用反而乱触发;
// 换成直接 watch 对象本身,深层修改又收不到通知
watch(() => draftStore.draft, (d) => console.log(d))
// ✅ 二选一:watch reactive 对象本身(默认深层);或明确写出依赖字段
watch(() => draftStore.draft, (d) => saveLocal(d)) // reactive 对象:深层生效
watch(() => draftStore.draft.items.length, saveLocal) // 精确监听:只在行数变化时触发
// ❌ 整个 store 持久化:token 明文进了 localStorage(XSS 可窃取),
// 且把大列表/时间戳一并写盘,体积与脏数据风险都来了
persist: true
// ✅ 只挑低频、非敏感、可再生的字段;token 策略见第 11 章
persist: { pick: ['orderStatusOptions', 'loadedAt'] }
// ❌ 在 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 }
})
user / dict / orderDraft,命名 useXxxStore,id 与文件名一致,Devtools 里可读。<script setup> 心智一致;读存量 Options store 时能看懂即可。clearAuth),不依赖 $reset,两种写法行为统一。pick 明确字段;敏感字段(token)的存储策略集中到第 11 章统一决策。useXxxStore(),避免加载期耦合与循环依赖。defineStore 两种写法语义等价;setup 写法与组合式函数同构,是本教程默认;setup store 没有 $reset,要自己写重置 action。storeToRefs(state/getters),actions 直接解构;这是第 04 章「解构丢响应性」在 store 上的再现。下一章预告:store 里已经准备好「装服务端数据」的位置,但真正的请求层还没影子。第 09 章封装 axios:token 注入、错误归一化、40102 无感刷新、防抖与竞态,最后产出贯穿后续章节的 useOrderList。
📍 阶段:中级阶段(第 07–12 章)|⏱ 预计学时:7 小时|🔗 前置:第 08 章
💡 对照学习:本页是 Vue 版第 09 章。React 版对应章节为 第 09 章 · 服务端交互与 TanStack Query。概念映射:axios 实例与拦截器两版完全相同;本页自研的
useOrderList≈ TanStack Query 的useQuery(缓存、去重、竞态取消);AbortController取消上一次请求 ≈ Query key 变化自动切换;Vue 生态也有官方适配的@tanstack/vue-query可选。
学完本章,你将能够:
import.meta.env.VITE_* 读取、统一超时与开发期代理;Authorization: Bearer 令牌,在响应拦截器中解包「统一响应体」;ApiError,组件层不重复 toast;40102(token 过期)的无感刷新:单飞锁防止并发刷新、原请求重放、死循环防护;AbortController 与防抖解决搜索场景的竞态与请求风暴;初级写法是把 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 的组件直接打回。
接口约定来自附录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
附录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
})
后端统一响应体 { 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 }))
},
)
错误归一化约定(写进团队规范):
catch 默认只处理「界面回退」(关弹窗、恢复按钮态),不再重复提示;error instanceof ApiError && error.code === 40901 这类业务码判断,不读 error.message 字符串;silent: true 标记,拦截器读它跳过 toast(练习 1 实现)。时序:accessToken 过期(附录A:2 小时)→ 后端返回 code: 40102 → 前端用 refreshToken(7 天)换新 token → 把过期的原请求用新 token 重放一遍,用户全程无感。三个工程细节:
_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 封装或拦截器里,思路与本节一致——队列 + 单飞 + 重放是通用解。
竞态:输入框连续搜索「华为」「华为云」,两个请求并发,若先发的「华为」后返回,就会覆盖正确结果。请求风暴:每敲一个字发一次请求。两件事要分开治:
// 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.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 }
先写订单域的 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>
任务描述:完成 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 可查)。// 响应拦截器内的错误分支统一改为:
function notifyOnce(message, config) {
if (!config?.silent) ElMessage.error(message)
}
// 业务错误分支:
notifyOnce(message || '请求失败', response.config)
// 网络错误分支:
notifyOnce(message, error.config)
任务描述:接入 §9.5 的无感刷新,并验证三个关键行为:①并发 3 个请求同时收到 40102,只发出 1 次 /auth/refresh;②3 个请求都被重放并成功;③把 refreshToken 也改错后,3 个请求全部收到 40101 且用户态被清空。
提示:教学 mock 环境直接写死一个「会过期」的 accessToken(如 expired-token)让 mock 返回 40102;验证手段看 Network 面板请求计数。
验收标准:
/auth/refresh 恰好 1 次;code: 0 的数据;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 换新)
任务描述:在 §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。
验收标准:
.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 }
// ❌ 用「元」做浮点累加: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)
// ❌ 有人 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 */)
})
// ❌ 每次输入都发请求且不取消:慢的旧响应最后到达,列表显示旧关键词的结果
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
})
})
// ❌ 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
// ❌ 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: '请重新登录' }) }
// ❌ 重试「所有失败请求」:网络抖动时 createOrder 被发两次,创建两笔订单
catch (e) { return request(payload) } // 盲目重放
// ✅ 只对幂等 GET 重试;写操作交给用户手动重试或后端幂等键
const retriable = e.httpStatus >= 500 && config.method === 'get'
api/request.js,评审见到组件 import axios 直接打回。orders.js、auth.js、customers.js,函数名与后端语义对齐(submitOrder 而不是 orderSubmitPost)。ApiError.code 判断、静默用 silent 标记显式申请。import.meta.env.VITE_*,仓库里不出现任何真实域名之外的环境差异硬编码。request.js 与工具函数的默认参数,不靠调用方记得。utils/money.js 换算:接口出入参都是分,组件需要展示时才 fenToYuanStr,转换单点收口。useOrderList 这类组合函数是可单测的纯逻辑层(第 15 章直接测它)。data 与统一 ApiError;40102 用「单飞锁 + 重放 + _retried 防护」无感刷新。utils/money.js 单点收口。useOrderList 是本章所有技术的集大成者,第 12 章实战直接复用。下一章预告:请求层能稳定拿数据了,但「订单从表单来」——表单校验、动态明细行、金额联动与防重复提交,是下一章的主角。
📍 阶段:中级阶段(第 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);「提交锁」两版写法一致。
学完本章,你将能够:
v-model 绑定 vs 组件默认值)与各自适用场景;:prop 动态路径)、删除行后校验状态不错位;ElMessageBox 处理危险操作的二次确认,并在正确时机调用 resetFields。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)。
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',
},
]
新建订单的核心是明细行(附录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。
附录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>
明细金额与订单总额的联动计算复用第 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 章「能派生不存储」在表单上的应用)。
完整提交函数是本章所有知识点的收口:
// 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)
}
}
任务描述:搭出 §10.2 的静态部分:客户下拉(可用本地假选项)、整单折扣(0–100 整数)、备注(≤200 字);三种字段分别用 blur / change 合理的触发时机;提交按钮点击时执行整体校验并打印结果。
提示:先不接远程搜索,customerOptions 写死数组;校验时机按 §10.2 表格选。
验收标准:
async function handleSubmit() {
const valid = await formRef.value.validate().catch(() => false)
if (!valid) {
ElMessage.warning('请先修正标红字段')
return
}
console.log('payload:', { ...form })
}
任务描述:加入明细行:添加/删除行、每行四个字段校验(数量 1–9999、单价 > 0 分、行折扣 0–100)、行金额与订单总额实时联动显示;选中商品自动带出单价。
提示::key 用自增 _key 而不是 index;金额计算复用 utils/money.js;删除行后 clearValidate()。
验收标准:
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)
任务描述:把订单表单接入第 08 章的 useOrderDraftStore:进入页面恢复草稿(弹确认)、每 3 秒自动暂存、提交成功清空;同时实现 §10.4 的联动(整单折扣 < 90 显示审批提示,行折扣低于整单折扣时触发依赖校验)。
提示:恢复草稿在 onMounted,注意等表单渲染完再回填(nextTick)并 clearValidate();定时器在 onUnmounted 清理。
验收标准:
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))
<!-- ❌ 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 答案)。
// ❌ 原生 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" />
// ❌ 元为单位求和: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"
// ❌ 无锁:接口慢时用户双击,两笔一模一样的订单进库(真实资损事故)
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",视觉与逻辑双保险
// ❌ 两个坑:①弹窗内容 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 事件)
// ❌ 整单折扣改小后,旧行折扣的合法性状态还停留在上一次校验结果
// (没有 watch 联动),用户带着过期状态提交
// ✅ 上游变化 → 主动 validateField 依赖字段(§10.4 完整代码)
watch(() => form.wholeDiscountPercent, () => {
if (!form.items.length) return
formRef.value?.validateField(form.items.map((_, i) => `items.${i}.discountPercent`)).catch(() => {})
})
rules / itemRules 独立成对象(可抽到 constants/orderRules.js),与组件模板解耦、可单测、可复用。* 100 / 100。:loading 与锁同时存在。_key)在组装时剔除。clearValidate(),别让程序行为触发红字。v-model 是默认心智;修饰符 .trim/.number/.lazy 解决字符串输入的三类边角,数字输入优先 el-input-number。:model + rules + prop;动态行用模板字符串路径 items.${index}.quantity,:key 避开 index。validateField 依赖字段。:loading + finally 释放。下一章预告:表单能提交订单了,但「谁能提交、谁能审批」还没有闸门。第 11 章把登录鉴权、三级权限(菜单/页面/按钮)、40102 无感刷新与 403 页一次补齐。
📍 阶段:中级阶段(第 07–12 章)|⏱ 预计学时:6 小时|🔗 前置:第 10 章
💡 对照学习:本页是 Vue 版第 11 章。React 版对应章节为 第 11 章 · 登录鉴权与权限控制。概念映射:路由守卫白名单 ≈
<ProtectedRoute>包裹 + 登录重定向;v-permission自定义指令 ≈<Auth>权限组件;动态菜单的「配置过滤」两版同构;token 存储的 XSS/CSRF 权衡与框架无关;无感刷新的「单飞锁 + 请求队列」与框架无关(实现见第 09 章 §9.5)。
学完本章,你将能够:
meta.roles → 403 页);v-permission 自定义指令,实现按钮级权限控制,并理解指令恢复能力的限制;附录A §2 定义了三级权限与四个角色(super_admin / operator / sales / finance)。三级控制对应三个实现位置:
| 级别 | 控制什么 | 实现位置 |
|---|---|---|
| 菜单级 | 侧边栏哪些菜单可见 | 菜单配置按 roles 过滤(§11.6) |
| 页面级 | 直接输 URL 访问无权限页 | 路由守卫 + meta.roles → 403(§11.5) |
| 按钮级 | 「删除」「审批」等按钮可见/可用 | v-permission 指令(§11.7) |
记住一个原则:前端权限全是体验层,真正的安全边界在后端。前端把 403 拦得再好,也必须假设接口会被直接调用。
附录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 重放 │
│ ◀─────────────────── 全部成功,用户无感 ── │ │
refreshingPromise,其余共享等待(第 09 章 §9.5 完整代码);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')
}
附录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>
这是企业评审的必答题。三种方案没有银弹,只有权衡:
| 方案 | 优点 | 风险 | 适用 |
|---|---|---|---|
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 }
})
把第 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)。
主流做法不是「后端下发路由再动态注册」,而是前端持有一份带 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>
按钮级控制(附录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)。
登出不是「删个 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 已在卸载时处理)。
任务描述:实现 §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 的行为符合你的设计(建议重定向进系统)。// 守卫白名单分支加强:
router.beforeEach(async (to) => {
const userStore = useUserStore()
if (to.meta.public) {
// 已登录用户访问登录页:送回工作台
if (to.path === '/login' && userStore.isLoggedIn) return { path: '/dashboard' }
return true
}
// ...后续分支
})
任务描述:实现 §11.7 的指令与权限码、§11.6 的动态菜单与 filterMenus、以及 403 页;用「切换 mock 角色」的方式验证三种角色(sales / finance / super_admin)看到的菜单与按钮不同。
提示:给 Devtools 控制台留一个 window.__setMockRole(role) 便于演示;filterMenus 是纯函数,顺手写三个断言自测。
验收标准:
sales 登录:看不到「系统设置」,订单页无「删除订单」按钮;finance 登录:能看到报表中心,有「审批通过」按钮;/settings/members(非超管)→ 403 页,from 参数正确。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)
任务描述:把第 09 章 §9.5 的无感刷新接入 userStore(tryRestoreSession 复用刷新逻辑),并用 mock 验证:①刷新页面后自动恢复登录态(走 refreshToken → 新 access → /auth/me);②「2 小时后」(mock 加速)任一请求自动续期成功;③登出后所有 store 清空、刷新登录页不能再恢复会话。
提示:mock 把 accessToken 设为「5 秒后过期」模拟加速;tryRestoreSession 内部先调一次 refreshAccessToken 再 ensureProfile。
验收标准:
/orders 不被踢回登录页(自动恢复会话);/orders,被守卫送回登录页。// 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
}
}
// ❌ /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 } }
}
// ...
})
// ❌ 双 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 落盘
}
// ❌ 验证码答案出现在前端(mock 疏忽或设计错误):
// 攻击者直接读 DOM/接口拿答案,验证码形同虚设
if (form.captchaCode !== captcha.answer) return ElMessage.error('验证码错误')
// ✅ 前端只负责「取码、展示、随表单提交 captchaId + captchaCode」;
// 比对永远在后端会话里进行,失败计数(5 次锁 10 分钟)也在后端
await login({ username, password, captchaId: captcha.captchaId, captchaCode: form.captchaCode })
// ❌ 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>
// ❌ 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' } }
}
// ❌ 只删 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' })
meta.roles)、按钮码(PERM 常量)各自收口,都源自附录A §2 的角色矩阵。query.redirect = to.fullPath,登录成功 router.replace(redirect),并对 redirect 值做「必须以 / 开头」的校验防开放重定向。ensureProfile 防并发重复请求;恢复失败才登出,不要「一刷新就踢人」。useLogout 注释里,漏项一测便知。PERM 与后端 permissions 字段逐字对齐,命名进 constants/permissions.js,禁止魔法字符串。window.__setMockRole 只在 dev 环境注入,让权限验收不依赖真实账号体系。meta.roles → 403)、按钮级(v-permission / v-if + hasPermission),真正的安全边界永远在后端。_retried 兜底;实现载体在第 09 章 §9.5。ensureProfile 单飞;登录回跳靠 query.redirect 闭环。下一章预告:路由、状态、请求、表单、鉴权五块拼图全部就位。第 12 章把它们装配成完整的「轻订单」管理系统:目录结构、M1–M4 里程碑与验收清单。
📍 阶段:中级阶段(第 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。
学完本章,你将能够:
对照附录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 结束能查单看单,以此类推。不要四件事同时开工。
「轻订单」中后台的完整目录。依赖方向自上而下: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
附录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 章
})
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。
列表页直接消费 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 看不到取消按钮。
表单页直接采用第 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 章验收标准;中途离开草稿可恢复。
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>
功能验收清单(全绿才算 M4 完成):
operator/sales/finance 三种角色菜单互不相同,且与 menuConfig 的 roles 声明一致;/settings/members、/reports)落到 403 页,from 参数正确;pending_review;草稿暂存可恢复;核心自测用例(写进 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 次请求
任务描述:完成 M1 全部内容——按 §12.2 建目录,装配路由/守卫/登录页/动态菜单/403/404,配好 mock 的 /auth/captcha 与 /auth/login,用三个 mock 账号完成权限验证。
提示:所有代码第 07/11 章都有成品,本练习练的是「装配顺序」(§12.4)与路径别名等工程细节;先让 mock 跑通再接表单。
验收标准:
pnpm dev 后可登录,三种角色菜单不同;sales 直达 /settings/members → 403;刷新不掉登录;登录回跳正确;constants、utils 不反向依赖业务层)。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 账号逐一登录验证菜单矩阵
任务描述:完成 M2 与 M3——列表页(useOrderList + StatusTag/StatusSelect + 排序 + 导出按钮占位)、详情页(状态机驱动的操作区 + 操作日志时间线,日志可先 mock 三条静态数据)、新建/编辑表单(第 10 章全套 + 编辑回显)。
提示:时间线用 el-timeline;排序参数 sort/order 加进 fetchOrders 的 params 并接 el-table 的 @sort-change;编辑态入口只在「详情页 + 状态为 draft/rejected」时显示。
验收标准:
<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
任务描述:完成 M4——批量导出/指派、按钮级权限全量核对、执行 §12.8 的 10 条功能验收清单与 5 条自测用例,把不通过项修到全绿;最后写一份 100 字以内的「已知遗留问题」清单(给第三部分当输入)。
提示:验收时切换三个角色 + 一个未登录态逐一过清单;遗留问题的典型候选:万级数据未虚拟滚动、无单元测试、bundle 未分析。
验收标准:
docs/ 目录。<!-- docs/known-issues.md -->
# 中级阶段遗留问题(输入:第三部分)
| # | 问题 | 影响 | 对应章节 |
|---|------|------|---------|
| 1 | 订单列表 5000+ 行未虚拟滚动,滚动掉帧 | 体验 | 第 13 章 |
| 2 | 首屏 bundle 未分析,疑似超 300KB 预算 | 加载 | 第 14 章 |
| 3 | money.js / filterMenus 无单测,回归靠手点 | 质量 | 第 15 章 |
| 4 | 登录→下单→审批主流程无 E2E | 质量 | 第 16 章 |
| 5 | mock 与真实接口的响应形状靠人肉对齐 | 工程化 | 第 17 章 |
// ❌ 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')
// ❌ 列表数据放全局 store,组件里再 ref 一份「本地副本」:
// 谁改了都不通知谁,筛选一次就分叉
const localList = ref([])
watch(() => orderStore.list, (v) => (localList.value = v))
// ✅ 单一数据源:服务端态由 useOrderList 持有(第 09 章),
// 需要「跨页保留」的是筛选条件(URL query),不是数据本身
const { filters, list } = useOrderList(route.query)
// ❌ 接口给分、表单让用户输元、展示又乘 0.9:
// 同一笔金额出现三种口径,对账必炸
price: form.priceYuan * 0.9 // 元?分?折扣后?
// ✅ 全链路「分」:入口(表单元转分)/出口(fenToYuanStr)两处转换,
// 其余层一律不碰换算(第 09/10 章的 money.js 单点收口)
payload.items = form.items.map((it) => ({ ...it, unitPrice: it.unitPriceFen }))
// ❌ 菜单文件写 roles: ['operator'],路由 meta.roles 写 ['operator','sales']
// —— sales 看得到菜单,点进去 403,永远修不完
// ✅ meta.roles 是唯一事实源,菜单配置引用/派生它;改动只发生在路由表
// menus.js: { path: '/reports', title: '报表中心', roles: [...] } 与
// routes.js: meta: { roles: [...] } 由同一份常量生成(§11.6 与 §12.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 -->
// ❌ 组件里偷偷写死 '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 一行
pinia → 指令 → router → mount 写进 main.js 注释,新人不用猜。constants/,附录A 的每条业务规则都能找到对应代码。pending_review 不要写成 pendingReview,QO 前缀订单号全站统一——接口联调时拼写就是生产力。毕业去向:到这里你已经能独立交付一个企业级中后台模块。下一部分(第 13–18 章)回到这个系统做治理:万级列表卡顿(第 13 章)、首屏提速(第 14 章)、补测试(第 15/16 章)与工程化(第 17/18 章)。动身前,请先完成中级能力自查:阶段自查-中级能力清单.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「万级数据滚动无可感知卡顿」。
学完本章,你将能够:
shallowRef、v-memo、稳定 key、组件拆分与状态下放四种手段治理不必要的渲染;keep-alive 缓存列表页,并用 include/max 与 onActivated/onDeactivated 规避内存泄漏;vue-virtual-scroller 的 RecycleScroller 改造万级订单列表,达到附录A「滚动掉帧 < 5%」的目标;先建立心智模型,优化才有靶子。Vue 的渲染链路可以概括为三步:
数据变化(ref/reactive)
│ ① 依赖收集阶段:渲染函数读取了哪些响应式数据,就记下「谁用了它」
▼
触发阶段:数据变化 → 通知订阅它的渲染副作用(每个组件一个)
│ ② 更新粒度是「组件级」:某个组件的数据变了,默认重跑整个组件的渲染函数
▼
patch 阶段:新虚拟 DOM 树 vs 旧虚拟 DOM 树,只把差异 patch 到真实 DOM
│ ③ patch 本身很快,慢的是「重跑渲染函数 + 大范围 diff」
▼
真实 DOM 更新
三条推论,直接决定优化手段:
reactive 地塞在一个父组件里,任何一条变化都会让父组件整个重渲染——问题不在 Vue,在「订阅范围太大」。computed 有缓存:依赖没变的 computed 不会重算,也不会引发下游渲染(第 04 章 §4.3 的延伸)。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 整棵万级子树,这就是「列表卡顿」最常见的根源。
性能优化的第一纪律:没有测量就没有优化。三件工具按顺序用:
// 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 段 |
操作步骤(万级列表滚动卡顿的标准排查法):
renderComponent / patch 反复出现;第一斧: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>
从列表进详情再返回,万级列表要重新请求、重新渲染一遍——用 <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 里恢复。默认的「无差别缓存全部页面」是内存随使用时间线性增长的第一元凶。
万级列表的终极手段:无论数据多少,DOM 里永远只有视口内 + 少量缓冲的几十行。三个要素:
scrollTop 算出起始行号,只 slice 渲染这一段;自研简化版(固定行高,足以覆盖 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>
「轻订单」订单列表页在压测 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)。
任务描述:在「轻订单」订单列表页(第 12 章产物)上完成:① 开启 app.config.performance;② 用 Vue Devtools Timeline 录制「输入搜索词 5 次」的过程;③ 把与表格无关的输入状态下放到 SearchBar,用防抖提交。
提示:重点观察输入时哪些组件事件成串出现;debounce 工具第 12 章已提供。
验收标准:
pnpm dev 实测肉眼无卡顿。<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'),输入时应不再打印。
任务描述:把 §13.5 的 useVirtualList 完整实现到你的项目(不要复制粘贴,逐行理解后默写),接上 mock 的 1 万条订单,行高 48px、overscan 5。
提示:三个计算属性是核心——startIndex、visibleCount、totalHeight;先写标尺层,再渲染切片;@scroll 记得加 .passive。
验收标准:
两个最容易写错的点:① 切片渲染的 :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) 两个边界。
任务描述:① 列表页换用 RecycleScroller;② 在 Layout 用 keep-alive 缓存列表页(含返回恢复滚动位置);③ 用 Chrome DevTools Memory 面板对比「无 max / 有 include+max」两种配置下反复进出 20 次的内存曲线。
提示:滚动位置恢复利用 keep-alive 的「组件实例被缓存」特性,通常无需额外代码;若被重置,检查是否给 router-view 加了会重建组件的 :key。
验收标准:
onDeactivated 暂停、onActivated 恢复;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 个)。
<!-- ❌ 错误: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>
// ❌ 错误:深度遍历 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} 行`))
<!-- ❌ 错误:所有页面无上限常驻缓存;缓存页里的定时器/监听还在跑
现象:用得越久内存越高、后台网络请求越积越多 -->
<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>
<!-- ❌ 错误: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>
<!-- ❌ 错误:行内还渲染了 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>
shallowRef 管理服务端列表:列表天然「整体替换」,浅响应省去万级代理与深度追踪,是零成本的默认正确。:key="i"(分页/筛选会变的列表)一律打回;虚拟列表场景这是正确性问题而不只是性能问题。include 白名单 + max 上限 + onDeactivated 停副作用,三者缺一就是延迟爆发的内存泄漏。RecycleScroller(固定行高),并同步约定「浏览器内搜索失效」的替代方案(如站内搜索面板)。shallowRef(浅响应)→ v-memo(行级缓存)→ 稳定 key(diff 正确性)→ 状态下放(隔离重渲染);keep-alive 提升回切体验,但 include/max/副作用起停三件套是内存安全的前提;vue-virtual-scroller;下一章从「渲染多快」转向「首屏多快」:LCP/FCP/CLS/INP 指标、代码分割、图片与缓存策略,把门户首页 LCP 从 4s 压到 1.8s。
📍 阶段:高级阶段(第 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」。
学完本章,你将能够:
web-vitals 库在真实用户环境采集指标并上报,区分「实验室数据」与「真实用户数据」;loading、fetchpriority);preload/prefetch/dns-prefetch/modulepreload,说清各自提升什么、何时有害;| 指标 | 含义 | 良好线 | 主要影响因素 |
|---|---|---|---|
| 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()
第一层:路由级分割(必做)。第 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 的构成
门户首页的图片分两类,策略完全相反:
<!-- 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 精灵或组件化。
| 指令 | 作用 | 典型场景 | 何时有害 |
|---|---|---|---|
<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 面板确认。
数据未到时,用骨架屏占住版面——既给用户「正在加载」的确定性,也因占位尺寸与真实内容一致而消除布局跳动:
<!-- 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>
缓存的黄金组合:带指纹的静态资源一年强缓存,入口 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;
}
「轻订单」一期门户上线时(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 官方文档为准。
任务描述:① 用 Chrome DevTools 内置 Lighthouse(选 Mobile + 模拟 4G)对「轻订单」门户跑分,截图记录 FCP/LCP/CLS/INP;② 检查路由表是否全部动态导入,把仍静态导入的路由改为 () => import(...);③ 重新构建并对比首包体积。
提示:Lighthouse 报告的「Opportunities/诊断」区按预估节省时间排序,先处理前三条。
验收标准:
dist/stats.html 中后台路由代码不在门户 chunk 内;任务描述:① 把门户 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。
验收标准:
<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> 上。
任务描述:① 按 §14.7 的 lighthouserc.json 建立 Lighthouse CI 任务,在本地 npx @lhci/cli autorun 跑通并故意让预算超标验证「红灯」;② 给工作台四个统计卡与近 30 天趋势图补齐骨架屏,占位尺寸与真实内容一致;③ 把「性能预算清单」(各 chunk 上限、指标上限)写成团队文档。
提示:staticDistDir 指向构建产物即可对纯静态站跑分;SPA 涉及路由的指标需 staticDistDir 或自有服务器方案,教学场景以门户首页为准。
验收标准:
<!-- ❌ 错误: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="功能示意" />
// ❌ 错误:路由写了动态导入,却在别处静态 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') },
]
<!-- ❌ 错误:图片加载完成后撑开版面,正文瞬间下移,CLS 飙升 -->
<img src="/feature.png" alt="功能示意" />
<!-- ✅ 正确:写宽高(浏览器按比例先占位),或外层容器锁比例 -->
<img src="/feature.png" width="480" height="320" loading="lazy" alt="功能示意" />
# ❌ 错误:对整个站点统一加长缓存。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"; }
// ❌ 错误:把所有 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' // 其余小依赖
},
},
},
}
渲染与加载都达标之后,「改得对不对」成为新的风险面:下一章进入组件测试,用 Vitest + Vue Test Utils 把金额计算、状态标签与订单卡片纳入自动化防线。
📍 阶段:高级阶段(第 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%、订单金额计算与权限判断纳入测试」。
学完本章,你将能够:
vi.mock 拦截模块 vs msw 拦截网络,并用它们消灭环境差异导致的 flaky; / E2E \ 少而稳定:主流程冒烟(第 16 章)
/ 集成·组件 \ 适中:组件交互、composable 行为
/ 单元测试 \ 最多:纯函数、状态机、格式化、校验规则
投入策略(结合「轻订单」的具体划分):
| 层 | 测什么(轻订单实例) | 不测什么 |
|---|---|---|
| 单元 | fenToYuan/calcOrderTotal、canTransition 状态机、权限码判断、表单校验规则 |
简单的 getter 转发、纯样式常量 |
| 组件/集成 | StatusTag 文案映射、OrderCard 展示与事件、useOrderList 分页筛选编排 | Element Plus 内部实现、像素级样式 |
| E2E | 登录 → 下单 → 审批 → 导出(附录A 冒烟集) | 每个按钮都点一遍 |
原则:逻辑下沉到可测层。把金额计算、状态流转收敛进 utils/、constants/(第 12 章已做),它们就能以零 DOM 成本被穷举测试;组件只测「用户可见的行为」。
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"
}
}
被测代码是第 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)
})
})
组合式函数若使用了 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: '华为' }),
)
})
})
组件测试的第一原则:断言用户看得到的东西(文案、可见的类名、交互结果),而不是内部实现。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()
})
})
| 手段 | 拦截层 | 适用 | 代价 |
|---|---|---|---|
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())
快照适合小而稳定的序列化输出(如 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 更新」的习惯,快照沦为形式。当快照的变更不能再被人工评审时,它就失去了价值。
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%,所以四个阈值都要设。
任务描述:① 完成 §15.2 的 Vitest 配置;② 为 utils/money.ts 补齐 §15.3 的测试,并额外增加两组边界用例:quantity: 9999 的最大值、discountPercent: 0 的零折扣;③ 运行 pnpm test:coverage,确认 money.ts 达到 100%。
提示:it.each 的参数是数组套数组/对象;断言单位永远是「分」。
验收标准:
pnpm test 全绿,用例数 ≥ 10;money.ts 四列均为 100%;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 合法)应在表单层拦截负值,纯函数层只需对契约内的输入正确。
任务描述:① 完成 §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。
验收标准:
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 层,测试也自动跟着契约走。
任务描述:① 接入 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 打架。
验收标准:
list 未被污染;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 章)也一并被验证了。
// ❌ 错误: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 } }) // 确定性:同样输入永远同样结果
// ❌ 错误:断言组件内部状态与私有方法。改个内部命名测试全红,而行为没变
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']])
// ❌ 错误: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')
// ❌ 错误: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 } }),
}))
// ❌ 错误:整组件 DOM 快照当唯一断言。任何人改一行模板都要 -u 更新快照,
// 很快团队就「见红就刷」,快照彻底失去保护作用
it('OrderCard 渲染', () => {
expect(wrapper.html()).toMatchSnapshot()
})
// ✅ 正确:行为断言为主;快照只用于小而稳定的数据结构(见 §15.7)
it('展示客户名与格式化金额', () => {
expect(wrapper.text()).toContain('青云科技有限公司')
expect(wrapper.text()).toContain('3507.30')
})
utils/、constants/,让 80% 覆盖率主要消耗在无 DOM 的纯逻辑上。onUnhandledRequest: 'error' 作为防线。data-test 建立测试与模板的稳定契约,永远不碰 wrapper.vm 的内部实现。vi.mock(快、与请求层解耦),集成测用 msw(顺带验证拦截器与错误归一化)。九折(附录A 审批阈值场景)比case3可读,让测试文档成为需求文档的镜像。skip 并登记。vi.mock 拦模块、msw 拦网络,两者都是消灭 flaky 的手段而非目的;组件之上的全链路「登录 → 下单 → 审批 → 导出」冒烟集是附录A 的最后一条质量目标——下一章用 Playwright 完成它,并解决登录态复用、测试数据隔离与 flaky 治理三大工程问题。
📍 阶段:高级阶段(第 13–18 章)|⏱ 预计学时:6–8 小时|🔗 前置:第 11 章、第 15 章
💡 对照学习:本页是 Vue 版第 16 章。React 版对应章节为 第 16 章 · E2E 测试与质量保障。Playwright 是框架无关的:配置、定位器、
storageState、trace 与 CI 接入两版几乎逐字相同,唯一差异是被测应用的启动命令与页面结构。业务目标基准:附录A §4「E2E 冒烟集:登录 → 下单 → 审批 → 导出,全链路通过」。
学完本章,你将能够:
webServer 自动起应用、baseURL、失败截图/视频与 trace 采集;getByRole/getByLabel/getByTestId)编写抗重构的用例;storageState 复用登录态,让每个用例跳过登录又保持真实鉴权链路;单测(第 15 章)验证零件,E2E 验证整机。它执行成本最高、也最脆弱,所以覆盖策略必须分层:
| 集合 | 内容(轻订单) | 触发时机 | 要求 |
|---|---|---|---|
| 冒烟集 | 登录 → 下单 → 审批 → 导出(附录A §4) | 每次 MR、每次部署后 | 稳定如钟表,失败即阻断合并 |
| 全量集 | 各页面主要路径、权限矩阵抽查 | 每日定时 / 发版前 | 允许标注 retry,失败开 bug 单 |
红线:不要试图用 E2E 覆盖表单校验规则、金额计算这类逻辑——那是单测的地盘(第 15 章)。E2E 每多一个用例,CI 就慢一分、flaky 面就大一分。
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,
},
})
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,几乎都是错误的等待。
每个用例都走一遍「账密 + 图形验证码」登录又慢又脆。标准做法: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 }) => { /* ... */ })
})
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: '...' } } }),
)
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)。
flaky(时好时坏)是 E2E 的头号杀手,治理流程固定为:
pnpm exec playwright test --repeat-each=10 跑 10 遍抓现场;| 病因 | 修复 |
|---|---|
硬等待(waitForTimeout) |
改为条件断言/自动等待(见易错点 1) |
| 实现细节定位器 | 换 getByRole/getByTestId(§16.3) |
| 用例间共享数据 | API 造数 + 唯一标记(§16.5) |
| 第三方依赖抖动 | page.route 拦截钉死(§16.5) |
| 并发资源冲突(同端口/同账号互踢) | 造数加 workerIndex、按用例分配测试账号 |
完整冒烟用例:运营登录态下单(九折触发审批)→ 财务上下文审批 → 回到运营导出 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+ 共享变量),隔离性会变差。教学场景推荐前者。
# .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
任务描述:① 完成 §16.2 配置(含 setup 项目与两个 storageState);② 编写 §16.8 的「未登录访问被拦截」用例;③ 本地跑通 pnpm exec playwright test,打开 HTML 报告确认通过。
提示:.auth/ 目录加入 .gitignore;本地起 dev server 时 reuseExistingServer 会直接复用。
验收标准:
/login;.auth/ 未出现在 git status 里。任务描述:① 实现 seedPendingReviewOrder 造数工厂;② 补全 §16.8 主流程用例到你的项目(元素文案按你的实现微调,但「九折 → 需财务审批 → 审批 → 导出」链路不可少);③ 用 --repeat-each=5 连跑 5 遍验证稳定。
提示:商品/客户下拉是搜索型组件时,先 fill 触发搜索、再点 getByRole('option', ...);断言订单状态用 data-test 兜底。
验收标准:
// 搜索型下拉的三步法:输入 → 等选项出现(断言自动轮询)→ 点选项
await page.getByLabel('客户').fill('E2E压测客户')
await expect(page.getByRole('option', { name: /E2E压测客户/ }).first()).toBeVisible()
await page.getByRole('option', { name: /E2E压测客户/ }).first().click()
关键在第 2 步:用可见性断言替代任何 sleep,选项异步加载完成前测试会自动等待。
任务描述:从课程仓库获取(或自己故意制造)一个「病灶测试」:用了 waitForTimeout(3000)、.css-3xk2f 类名定位器、并依赖上一条用例创建的订单。按 §16.7 五步法完成治理:复现(--repeat-each=10)→ trace 归因 → 修复 → 回归验证,最后把过程写成一份 200 字的治理记录。
提示:三个病灶对应 §16.7 表格的前三行;trace 归因时重点看失败步骤前后的 DOM 快照差异。
验收标准:
--repeat-each=10 全绿;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 各一轮)。
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('待审核') // 等状态渲染
// ❌ 错误:定位器耦合实现细节。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()
// ❌ 错误:所有用例共用「测试订单 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}`)
// ……只操作自己造的订单
})
// ❌ 错误: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/
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,
},
getByRole 优先、data-test 兜底、永不 CSS 类名——同时提升用例稳定性与页面可访问性。page.route 拦截或减少动画配置,不给 flaky 留入口。secrets.* 注入;storageState 与 .auth/ 永不入库;账号按角色拆分且可重复使用(附录A 虚构口径)。retries: 0 暴露不确定性,CI retries: 2 容忍基础设施抖动——但被重试「救回来」的用例要打标跟进。webServer 自动起应用、setup 项目 + storageState 复用登录态、API 造数工厂保证隔离、trace 三件套定位失败;secrets.*,报告 artifact 失败也上传。至此质量防线成型(单测 + E2E)。下一章转向工程底座:把整个「轻订单」升级为严格模式 TypeScript,搭好 ESLint + Prettier + husky 规范链路,并系统讲解 Vite 的插件、代理与环境变量机制。
📍 阶段:高级阶段(第 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-vuevs@vitejs/plugin-react)。
学完本章,你将能够:
tsconfig 的关键选项(strict 全家桶、moduleResolution: bundler、noUncheckedIndexedAccess)并给出团队纪律:strict 一旦开启不许回退;defineProps/defineEmits/defineModel 的类型化写法与 3.5 响应式 props 解构默认值;generic="T"),让列表类组件在使用处获得精确类型推导;ImportMetaEnv 类型声明;// 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 压掉——类型错误的正确反应是修类型,而不是骗过编译器。
<!-- 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 提前到编译期。
「轻订单」里客户列表、商品列表、订单列表都要「多选框 + 受控选中」,抽成一个泛型组件:
<!-- 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>
附录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>
}
目标:不合规的代码进不了仓库,且校验只跑「改动的文件」(快)。
// 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 章)。
插件机制: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 章经 CIsecrets.*注入)。
分析工具与手动分包第 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 个产物
把与业务无关的纯逻辑(金额计算、状态机)抽成独立包,是第 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'
第 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 */),让后续接手者有据可依。
任务描述:① 按 §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;任务描述:① 实现 §17.3 的 SelectableList<T>,分别用 Order[] 与 Customer[](自定义实体)使用它,验证两处类型推导互不污染;② 实现 isOrder 守卫并接入 fetchOrders;③ 为 isOrder 补单测(第 15 章的表驱动风格):合法对象、缺字段、非法 status 三组用例。
提示:generic="T extends { id: string | number }" 的约束让「没有 id 的对象」在编译期就被拒绝;守卫测试不需要 jsdom,纯 Node 断言即可。
验收标准:
item 均有正确字段提示与拼写检查;orderNo 的响应)时,请求层抛出错误而非渲染;// 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' 用例:这正是「后端契约变更/接口被注入脏数据」的防线测试。
任务描述:① 搭好 §17.5 全链路,用一次「故意不合规的提交」验证拦截:在暂存区放一个含 any 的文件,确认 pre-commit 失败且给出 lint 输出;② 抽出 @qing/money 内部包(§17.8),主应用改为 workspace 依赖,单测随包迁移;③ 参照 §17.9 输出你所在项目的《渐进式 TS 迁移方案》六步表(含每步负责人与预估工时)。
提示:husky 生效依赖 pnpm install 触发 prepare 脚本;队友克隆后提交不生效,八成是没重装依赖。
验收标准:
pnpm test 在 workspace 下全绿(money 包用例仍执行);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('订单列表数据结构异常')
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
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
}
# ❌ 症状一:装了 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 的文件提交,应被拦截
// ❌ 错误一: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/, ''), // 按需启用
}
types/,前后端口径变更只改一处。unknown 起步,守卫收窄后才可使用——这是运行时安全的最后防线。skip-formatting 类配置划清边界,禁止同一个问题两套规则打架。import.meta.env,统一走 src/config/env.ts,并给每个变量补类型声明。/api/v1,由代理转发——不要在前端写死后端域名。@qing/* workspace 包(测试随包走),为第四部分的 monorepo 架构铺路。strict 全家桶 + 禁止 as 压错 + vue-tsc 进 CI,三者构成类型防线;defineProps/defineEmits/defineModel)与泛型组件让「类型跟着数据走」,收益在使用处兑现;VITE_ 只放公开配置);workspace 包为架构演进打底。代码已经「又快又稳又规范」,但还没有回答两个问题:怎么可重复地发布?线上坏了谁知道? 下一章搭建 lint → test → build → deploy 的完整流水线,并接入 Sentry 监控与告警 SOP,给「轻订单」装上最后一层保障。
📍 阶段:高级阶段(第 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>组件包裹——原理同为「框架错误钩子 + 全局兜底」。
学完本章,你将能够:
release 关联版本、经 sourcemap 还原真实堆栈;上线前的人工检查清单是不可靠的,全部固化进流水线:
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 提交一一对应,回滚即回退到历史提交的产物)。
# .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)"
| 注入方式 | 机制 | 适用 | 代价 |
|---|---|---|---|
| 构建期 | 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__,
}
# 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;
}
版本目录 + 软链是回滚的物理基础——每个版本一个目录,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 分布与稳定版对比;劣化即回滚软链。
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,可以回答「这个错误只出现在哪个版本」——这就是回滚决策的依据。
用户不会提交 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)
}
告警没有响应约定 = 没有告警。「轻订单」团队的分级 SOP:
| 级别 | 判定标准(示例) | 响应要求 | 首选动作 |
|---|---|---|---|
| P0 | 白屏 / 下单主流程不可用 / JS 错误率 > 5% | 15 分钟内认领 | 先止血:回滚软链(§18.5),再查根因 |
| P1 | 接口错误率 > 1% / 核心 LCP 劣化 > 30% | 1 小时内定位 | 查 release 对比新版本,考虑灰度暂停 |
| P2 | 零星 JS 错误 / 非核心页面异常 | 当日跟进 | 归档 issue,排进迭代 |
流程闭环:告警 → 值班认领 → 分级 → 止血 → 根因修复 → 复盘(复盘写清时间线、影响面、根因、防复发动作)。两条铁律:止血永远优先于根因(用户等不了);每次 P0/P1 必须有复盘,否则同类问题必然复发。
任务描述:① 在「轻订单」仓库落地 §18.2 的 quality job(lint/typecheck/test/冒烟 E2E 可按已完成章节裁剪);② 故意提交一处 lint 错误与一处类型错误,验证流水线分别红在哪一步;③ 配置 pnpm 缓存并对比首跑与二次跑的耗时。
提示:本地先用 act 类工具不便时,直接推到 fork 仓库看 Actions 页;每步日志里都有失败原因。
验收标准:
deploy/build 未在 PR 事件中执行。任务描述:① 编写 §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;# ① 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
任务描述:① 接入 §18.6 的 Sentry(Sentry 提供免费开发配额,或用自托管 GlitchTip 替代),制造一个 undefined is not a function 验证 sourcemap 还原出真实文件与行号;② 接入白屏检测,把 index.html 里的脚本路径改错复现「资源加载失败」上报;③ 搭 §18.5 的版本目录结构,完成「发版 → 切灰度 → 制造 P0(删掉 canary 目录里的 index.html)→ 30 秒内回滚」的完整演练并记录时间线。
提示:sourcemap 还原需要在 Sentry 后台确认 release 与上传的 artifact 匹配;演练用本机目录模拟服务器即可。
验收标准:
OrderCreateView.vue:行号 而非压缩代码;## 灰度回滚演练记录(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 分钟)内完成
// ❌ 错误:为了排障开着普通 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" 应该为空
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)都不得使用该前缀。
# ❌ 错误:部署即清空目录。已在用户浏览器里打开的旧页面,
# 之后按需加载路由 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 天」的更旧版本
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
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
},
})
__APP_CONFIG__ 与部署时注入消化。hidden 模式生成 → 上传 Sentry 还原 → 上传后删除文件;find dist -name "*.map" 纳入部署检查。release 关联 → 部署目录同名,让「哪个版本坏了」永远是可回答的问题。needs 串成门禁、靠 concurrency 防乱序、靠 secrets.* 管凭据;VITE_*,多环境一份产物走运行期 window.__APP_CONFIG__;下一站进入第四部分 · 资深阶段:从「把系统做好」升级到「把系统设计对」——第 19 章从架构设计方法论开始,用模块化拆分、依赖规则与 ADR 决策记录,把「轻订单」推向 v2。
📍 阶段:资深阶段(第 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 资深阶段里程碑。
学完本章,你将能够:
app / views / features / widgets / entities / shared 六层组织大型 Vue 项目,说清每层的职责边界与禁止事项;main.ts 缩减到 20 行以内;架构设计的第一课不是「怎么分层」,而是「要不要分层」。分层、多包、微前端都会引入间接层成本(跳转次数变多、约定变多、新人上手变慢),只有业务复杂度配得上时,这份成本才划算。
用四个因子给项目打分(每因子 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 章铺路)。
分层参考 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 任何业务代码 |
两条全局纪律,比层本身更重要:
shared 出现 import { useOrderStore } 即架构事故);index.ts,外部只允许 @/features/order-create,禁止深挖 @/features/order-create/api/internal.ts——这是后续重构不被外部耦合的唯一保险。依赖关系全景:
app(装配一切)
│
views(页面薄壳)
│
widgets(页面组成块)
│
features(用户动作)
│
entities(业务实体)
│
shared(基建,不认业务)
集中式巨型路由表的问题:所有人改同一个文件、权限字段随手写错、懒加载 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 清单执行):
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 {}
域-页面(order-list),name 是跳转与权限映射的唯一键,禁止用 path 做跳转依据。第 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 }
})
第 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 层)。
反例是 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)。
当 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 这种杂物包 → 禁止 |
架构决策最贵的成本不是做错,而是没人记得为什么这么做。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 是被允许的(且很常见):宁可补记,不要让决策理由失传,补记时注明「按回忆补记」即可。
任务描述:按 §19.1 四因子表给你所在团队(或轻订单 admin)打分,得出结构形态结论;然后画出「中级版目录 → 资深版六层目录」的迁移映射表。
提示:中级版的 views/、api/、stores/、components/ 分别流向六层的哪一层?记住判断口诀——「这是谁的状态/谁的动作」。
验收标准:
| 中级版位置 | 资深版归宿 | 理由 |
|---|---|---|
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——上提的唯一理由是第二个消费方出现。
任务描述:为六层结构配置 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 拦截,三条用例实测符合预期;// 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 必须报错。
任务描述:把单仓库重组为 §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;# 根目录初始化
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 章还会再撞一次这个坑)。
❌ 错误做法:画了六层目录,但 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 拦截深路径;同时设置「上提门槛」——只有第二个消费方出现才允许下沉建层,避免过早抽象。
❌ 错误做法:3 人团队、5 个页面的内部工具,直接上六层 + widgets 层 + Monorepo + 插件总线 + 依赖门禁。三个月后团队一半时间在「维护架构」,需求迭代速度反而低于不分层的版本。架构税必须由业务复杂度买单。
✅ 正确做法:用 §19.1 评估表先打分,总分 ≤ 8 就老实用简单目录;评估表写进 ADR,未来分数涨了再升级——架构可以演进,前提是当下不为未来预支成本。轻订单本次得分 15,属于「分层 + 轻量抽包」,而非「全家桶」。
❌ 错误做法:建一个 appStore 塞下用户、字典、订单列表、筛选、分页、弹窗状态;订单列表数据也放进去并手写「请求前判断缓存」逻辑。后果:任何组件订阅都全量更新(第 13 章渲染性能问题复发)、缓存失效逻辑与后端数据生命周期打架、测试要 mock 整个宇宙。
✅ 正确做法:按 §19.4 归属表切分——Pinia 只管客户端态(会话、流程、字典缓存),服务端态交给请求 composable/Query 层;store 按「实体/流程」拆小,跨 store 调用在 action 内显式组合而非互相嵌套订阅。
❌ 错误做法:dict.ts 模块顶层直接 getDicts() 发请求、monitor.ts 在 import 时自行 initSentry()。后果:import 顺序决定启动顺序(脆弱且不可测)、单测 import 该模块就触发网络请求(第 15 章测试环境炸裂)、SSR 或微前端场景下模块被重复加载时副作用重复执行。
✅ 正确做法:模块只导出 setup(ctx),一切副作用进入 §19.6 的编排器;插件声明 requires 依赖,编排器按序执行并打点耗时。判断标准一句话:main.ts 之外不存在任何「被 import 即执行」的逻辑。
❌ 错误做法:ADR 写成「我们决定用 Pinia。」一行结论;或决策被推翻后原文不动,新人读到的是早已失效的「已采纳」。前者让半年后的你无法判断当时假设是否还成立,后者是团队内的「错误文档污染源」。
✅ 正确做法:按 §19.8 模板写全备选与理由(当时的错误假设也保留原貌,并在「后果」节复盘);状态变更时在文首加一行「已废弃,被 ADR-xxx 取代」而非删除原文——决策历史的价值恰恰包含「我们错在哪」。
index.ts 出口从第一天执行,比事后拆除深路径引用便宜一个数量级;docs/adr/ 目录写 markdown 即可,别一上来就上决策管理平台,工具越重习惯越难养;app/views/widgets/features/entities/shared)配两条铁律——单向依赖与公共 API 入口,并让 ESLint 门禁替人守规矩;下一章我们把 packages/qing-ui 从零建起来:设计令牌、组件 API 规范、Storybook 文档站与 changesets 发布链路,让团队共享的不只是代码,更是设计语言。
📍 阶段:资深阶段(第 19–24 章)|⏱ 预计学时:8–10 小时|🔗 前置:第 06 章、第 17 章、第 19 章
💡 对照学习:本页是 Vue 版第 20 章。React 版对应章节为 第 20 章 · 设计系统与组件库建设。本章方法两版通用,示例代码为 Vue 生态:设计令牌、API 设计原则、Storybook、changesets 在 React 组件库中流程一致,组件写法从 SFC 换成函数组件即可。业务基准:附录A——为轻订单抽离内部组件库
qing-ui。
学完本章,你将能够:
Element Plus 解决了「通用交互组件」,但企业级场景仍有三类缺口:
| 缺口 | 具体表现 | qing-ui 的应对 |
|---|---|---|
| 品牌一致性 | 各应用蓝色深浅不一、圆角与间距随手写 | 设计令牌统一发放 |
| 业务组件沉淀 | 订单状态标签、金额展示、审批时间线在三个应用重复实现 | 业务组件层(带业务语义,消费令牌) |
| 跨应用共享 | 门户、后台、营销子系统(第 21 章)无法共享 UI | 独立 npm 包,版本化管理 |
判断口径:自建库不是重写 Element Plus,而是「主题化包装 + 业务沉淀」。基础交互组件优先二次封装 Element Plus,业务组件自研——这条边界写进 qing-ui 的 README,防止库膨胀。
第 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。
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>
组件库的样式方案没有银弹,按「隔离性 / 运行时成本 / 团队心智」取舍:
| 方案 | 隔离性 | 运行时成本 | 适用判断 |
|---|---|---|---|
| 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 能不能跑。
文档站是组件库的「店面」:没有店面的组件库等于不存在。技术选型 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
业务团队没精力逐页做无障碍,但组件库可以一次性做对,所有应用自动受益。qing-ui 的四条基线:
Teleport 到 body 后必须做焦点圈禁(focus trap),关闭时焦点还原到触发元素;button/input),不够时补 role 与 aria-*;图标按钮必须 aria-label(见 §20.3 的清空按钮);// 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 清单收录)。
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。
| 里程碑 | 周期 | 交付物 | 退出标准(验收) |
|---|---|---|---|
| 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 章营销子系统)推广——顺序反了的组件库通常死于「为想象中的需求而设计」。
任务描述:轻订单需要一个订单状态标签组件(附录A §3.3 七种状态),先写一份「API 提案表」(props/emits/slots),对照五原则自查后实现 QingTag,并保证它消费语义令牌而非硬编码颜色。
提示:状态 → 颜色/文案的映射属于业务知识,不要写死在组件里;让组件接受 variant 与 label,映射表放在 entities/order(第 19 章的分层立即用上)。
验收标准:
--q-* 令牌;v-model:visible(可关闭标签)受控可用,关闭时 emit 事件类型正确。| 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。
任务描述:在 packages/qing-ui 建立 §20.2 的三层令牌;把第 06 章门户的主题切换按钮改造成消费 qing-ui 令牌的版本,并用 Devtools 验证「切换主题只改 data-theme,L1 值不动」。
提示:门户此前是 :root 直接定义业务色,迁移时先「翻译」成 L2 语义(--portal-bg-hero → 复用 --q-color-bg-page 还是门户自定义 L2?想清楚再动手);保留旧类名做渐进迁移,不要一次性替换全部样式。
验收标准:
pnpm --filter qing-portal add qing-ui@workspace:*,入口 import 'qing-ui/styles/tokens.css';--portal-color-bg 定义为 var(--q-color-bg-page),门户私有令牌留在门户层、但值引用 L1(如 --portal-brand-soft: var(--q-brand-500) 加透明度);.dark 类逻辑,改用 shared 的 useTheme,统一操作 data-theme;index.html 内联一段阻塞脚本,首帧前根据 localStorage/matchMedia 写入 data-theme(第 06 章结论在此沿用)。任务描述:为 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);@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) 宿主安装验证
❌ 错误做法:为了「灵活性」,第一版 QingTable 就暴露 showHeader、showFooter、bordered、striped、compact、stickyFirstColumn……六十个布尔 prop。半年后每加一个功能都要排查与全部 prop 的组合矩阵,任何一个改动都是全量回归,组件库迭代速度归零,业务方开始绕过组件库直接写原生。
✅ 正确做法:按五原则做减法——结构变化交给插槽与子组件(<QingTableColumn> 声明式列)、主题变化交给令牌、场景变化交给 variant;首版 props 不超过 10 个,每个新 prop 的 PR 里必须写明「为什么插槽/令牌表达不了」。
❌ 错误做法:组件样式里直接写 background: var(--q-brand-500),暗色主题上线时发现按钮底色和页面背景几乎同色,只能在暗色分支里逐个组件覆盖——覆盖散落十几个文件,改主题等于考古。
✅ 正确做法:严格执行「组件只消费 L2/L3」纪律并用 lint 拦截(匹配 var(--q-(brand|gray|danger)-\d+) 即报错);主题差异全部收敛在 L2 的 [data-theme='dark'] 一处,组件层对暗色零感知。
❌ 错误做法:觉得 Element Plus 某组件不合心意,直接 fork 整个源码仓库魔改。三个月后 Element Plus 修了安全漏洞与新特性,qing-ui 永远停留在旧版,diff 冲突到无法合并。
✅ 正确做法:需要定制的用「包装层」——qing-ui 的组件 import Element Plus 并二次封装(改默认值、加令牌、收窄 API);确实要改上游行为的,给上游提 PR(这是唯一能长期白嫖维护红利的方式),等待期内用包装层兜底。
❌ 错误做法:改了组件 API 但版本号只从 0.3.0 跳到 0.3.1,CHANGELOG 一片空白。宿主应用 pnpm update 后白屏,排查半天才发现是「minor 更新」里藏着删除的 prop。
✅ 正确做法:版本与 CHANGELOG 全部由 changesets 生成,本地禁止手改 package.json 的版本号;每个 PR 必须带 changeset 文件(缺失就用 CI bot 拦截);breaking 变更额外要求迁移指南小节。
❌ 错误做法:Storybook 里只有可交互 demo,没有使用场景说明。业务团队遇到「该用 Dialog 还是 Drawer」时无人翻文档,直接口头问组件库作者——组件库成为单点依赖,作者休假即停摆。
✅ 正确做法:每个组件文档固定四块(何时用 / Do & Don't / 无障碍说明 / 迁移对照),PR 里「故事 + 文档」缺一不可;把「新组件必须带反例(Don't)截图」写进第 22 章的 Review 清单。
.npmrc 环境变量占位 + CI Secret 注入,仓库泄密面最小化;下一章面对更进一步的拆分诉求:当第三方营销子系统(另一套技术栈)要接入轻订单平台,微前端是否是答案?我们将先问「要不要拆」,再对比 qiankun、micro-app、wujie 与 Module Federation 四条路线。
📍 阶段:资深阶段(第 19–24 章)|⏱ 预计学时:8–10 小时|🔗 前置:第 19 章、第 20 章
💡 对照学习:本页是 Vue 版第 21 章。React 版对应章节为 第 21 章 · 微前端与跨团队协作。本章方法两版通用,示例代码为 Vue 生态:「要不要拆」评估、四方案对比、隔离原理、Strangler 迁移与框架无关;示例中的子应用恰好是 React 技术栈,正好演示跨栈集成。业务基准:附录A §6——轻订单平台接入第三方营销子系统。
学完本章,你将能够:
微前端解决的是组织问题(多团队并行、独立发版、技术栈异构),不是技术炫技。它引入真实的成本:包体积翻倍、调试链路变长、一致性维护变难。先过评估清单:
| 适用信号(≥ 3 条才考虑) | 不适用信号(出现任何一条就缓行) |
|---|---|
| ≥ 3 个团队在同一站点并行开发,互相阻塞发版 | 团队规模小(< 8 人),没有团队墙要翻 |
| 子系统技术栈不可更换(第三方交付/历史包袱) | 页面间强共享状态、频繁跨模块交互 |
| 各子系统需要独立部署、独立发布节奏 | 首屏性能极度敏感(多壳 = 多份运行时) |
| 存量系统需逐页替换而非停机重写 | 纯内容型站点,SEO 与单包体积优先 |
| 平台型产品需要「插件式」接入外部能力 | 项目寿命 < 1 年,投入产出不成立 |
轻订单的答案:admin 主站 4 人团队,评估清单只命中 1 条(技术栈异构),不拆。但「营销活动管理」由公司另一条产品线交付(React 技术栈、独立迭代、要求嵌入订单平台统一入口),命中 3 条 → 该子系统用微前端接入,主应用作为基座(容器)。拆的是子系统边界,不是团队没有的边界。
| 维度 | 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)。
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),子应用不自取 |
| 手段 | 原理 | 主要坑 |
|---|---|---|
| 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 通信调用。
通信越少,耦合越少。优先级从高到低:
| 方案 | 机制 | 适用 | 滥用后果 |
|---|---|---|---|
| 初始化数据(首选) | 接入时一次性下发(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')
若主子应用各自打包一份 Vue,会出现两个响应式实例、两份 Pinia——跨应用通信的状态形同虚设,内存也翻倍。治理三板斧:
vue、vue-router、pinia 标记为 external,运行时从基座共享(Vite 下可用 import map 或 Module Federation 的 shared: { vue: { singleton: true } });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 可被基座访问
},
})
部署拓扑(同域划路径,避免跨域与 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 回退
}
路由整合三条约定:
/mkt/* 激活营销子系统,激活时基座侧边栏高亮「营销活动」菜单(菜单注册在基座,权限仍走第 11 章模型);/mkt/),开发期用 VITE_APP_BASE 环境变量注入,禁止硬编码;// 基座侧:把「导航事件」映射到基座路由(跳转权收口在基座)
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)
}
背景:营销产品线交付「活动配置 + 优惠券管理」系统(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,变更走双方评审。
「绞杀者模式」用新系统逐步包裹旧系统,直到旧系统自然枯死,全程不停机:
| 步骤 | 动作 | 验收 |
|---|---|---|
| 1 划界 | 按业务域列出页面清单,标记「保留/待迁移/废弃」 | 清单评审通过,迁移顺序按流量从低到高 |
| 2 立门面 | 基座接管全部路由,旧页面先以「整站 iframe/原应用」方式挂在门面后 | 用户无感知,URL 语义不变(旧 URL 301 到门面对应路由) |
| 3 逐页迁移 | 每次迁 1–2 个低风险页面为新模块/新应用 | 该页 E2E 冒烟通过 + 双跑一周数据一致 |
| 4 双写过渡 | 迁移期新旧同时写(订单状态流转类场景),对账脚本兜底 | 对账差异率 = 0 连续 5 天 |
| 5 拆除 | 旧应用流量归零,下线门面中的旧壳与代理 | 归档旧仓库,ADR 记录终态 |
轻订单应用:admin 是新建系统无需 Strangler;但「报表导出」由老 BI 系统(jQuery 时代)承载——迁移顺序:报表中心页(低流量)→ 客户报表 → 订单报表,BI 系统按步骤 2–5 逐步绞杀,预计两个季度完成。
任务描述:对下述三个场景分别给出「拆/不拆 + 理由 + 若拆用哪个方案」:
提示:回到 §21.1 清单数信号,别数愿望。
验收标准:每个场景给出结论、命中的适用/不适用信号条数、若拆则方案与隔离模式选择。
任务描述:本地起两个 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;// 基座: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 模式让其运行时随窗体销毁)。
任务描述:在练习 2 基础上完成三件事:① 主子应用共享一份 Vue/Pinia(非 iframe 模式下验证单例),② 编写 §21.7 的 Nginx 配置并在 Docker 中联调通过,③ 产出《mkt 接入清单》契约文档(schema + 事件字典 + 发版窗口)。
提示:单例验证方法:基座 window.__QING_PINIA__ = pinia,子应用打印 useAuthStore 的实例 id 对比;或直接在共享 store 写入,两边同时观察响应。
验收标准:
docker compose up 后同域访问、SPA 回退、子应用回退全部可用;# 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 流程)。
❌ 错误做法:3 人团队 6 个页面,因为「微前端很潮」,把后台拆成基座 + 4 个子应用。结果:首屏多加载一套运行时、调试要在 5 个 tab 间横跳、一次跨模块改动要发 4 个包。三个月后团队默默把页面又合并回去,比不分层还乱。
✅ 正确做法:把 §21.1 清单当作硬门槛——适用信号 ≥ 3 条才立项,且必须由 ADR 记录命中的信号与放弃的备选(单仓多页、路由级拆分、monorepo 内模块化)。微前端解决的是「团队边界」,不是「代码组织」;后者用第 19 章的分层就够。
❌ 错误做法:子应用完整打包自己的 vue 与 pinia,又在「共享状态」上与基座通信。现象诡异:基座改了用户信息,子应用读到的永远是旧值(两个响应式实例互不相识);内存里躺着两份运行时。
✅ 正确做法:明确隔离策略再谈共享——iframe 模式下主动放弃共享运行时,只走 §21.5 的数据/事件通道;非隔离模式下共享依赖必须 singleton: true 并版本协商,共享面收敛到「会话 + 主题 + 埋点」三件套。
❌ 错误做法:给子应用套上 Shadow DOM 认为一劳永逸。子应用弹窗 Teleport 到 document.body——元素渲染在 Shadow 边界之外,所有组件样式失效,页面「裸奔」;或反向操作:基座全局 toast 样式渗进子应用。
✅ 正确做法:选隔离方案时把「浮层归属」作为一等公民设计——子应用浮层挂自己的挂载点(qing-ui 的 appendTo 参数)、基座级浮层由基座统一提供并通过事件调用;Shadow DOM 与 iframe 的弹窗适配成本先在 spike 里验证,再写进 ADR。
❌ 错误做法:子应用在顶层作用域 setInterval 轮询活动数据、addEventListener 挂全局监听,卸载钩子是空的。用户在基座与子应用间反复切换,轮询请求成倍叠加,接口被打爆,监控(第 18 章)出现「切页越多越卡」的诡异曲线。
✅ 正确做法:子应用把「定时器、监听器、AbortController、订阅」统一登记到生命周期管理器,unmount 一次性清理;Review 清单把「卸载清理」列为子应用代码的必查项;对接不可控的第三方时直接选 iframe 模式,让窗体销毁替你清理。
❌ 错误做法:为了省事,基座与子应用之间用十几个自定义事件互抛:user:changed、order:updated、theme:dark……没有任何文档与类型,事件谁发的、谁在听全靠 grep。一次改名引发雪崩式静默失败。
✅ 正确做法:通信收敛到契约文档(练习 3 的《接入清单》):每个事件有唯一名、方向、payload 类型、触发时机,事件字典变更走双方评审;能用初始化数据一次下发的绝不发事件,能用 URL 传上下文的绝不共享状态。
❌ 错误做法:子应用保留自己的路由实例并直接操作 history.pushState,基座同时在监听路由变化做菜单高亮。结果:前进/后退时菜单高亮错乱、刷新后落在基座 404、偶尔出现「地址改了页面没动」的僵尸状态——两套路由对同一段 URL 各自解释。
✅ 正确做法:地址栏的唯一驾驶者是基座。子应用要么运行在基座路由的子树之下(激活规则即前缀,路由同步由微前端框架负责),要么像 §21.7 那样把跳转请求抛给基座代为执行;跨应用跳转一律「子应用 dispatch → 基座 push」,并在联调时把「刷新/后退/直达 URL」三种场景列入冒烟用例。
base、激活规则、Nginx 回退在 spike 阶段验证完再开工,部署问题绝不留到联调;下一章把视角从「代码架构」拉到「人」:分支模型、提交与发布纪律、Code Review 文化、跨职能契约——把本章的接入契约升级为整个团队的《前端团队协作手册》。
📍 阶段:资深阶段(第 19–24 章)|⏱ 预计学时:7–9 小时|🔗 前置:第 17 章、第 19 章、第 21 章
💡 对照学习:本页是 Vue 版第 22 章。React 版对应章节为 第 22 章 · 团队协作规范。本章方法两版通用,示例代码为 Vue 生态:分支模型、Conventional Commits、Review 文化、跨职能契约与框架无关,差异仅在工具链示例引用的仓库是轻订单 Vue 版。本章成果物《前端团队协作手册》可直接用于你的团队。
学完本章,你将能够:
| 维度 | 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 # 私有化版本轨(仅维护用,不带新功能)
提交信息不是「写给自己看的日记」,而是 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
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 行是质量与效率的实践平衡点,写进手册并允许声明式豁免(如锁文件、生成代码)。
Review 分三层,避免「什么都想说」导致重点淹没:
| 层级 | 内容 | 处理方式 |
|---|---|---|
| 阻断(Must) | 正确性、安全(凭据/注入/XSS)、性能回退、分层依赖破坏(第 19 章门禁之外的人眼部分)、测试缺失 | 必须修改后重审 |
| 建议(Should) | 命名、可读性、更优实现、复用机会 | 作者应回应:采纳或说明理由 |
| 口味(Nit) | 纯风格偏好(nit: 前缀标注) |
不阻塞合并,作者自行裁量 |
阻断项核对清单(reviewer 逐条过,其他层自由发挥):
SLA 与文化:
nit 可以攒着说;reviewer 的注意力要花在刀刃上。高频坏味道与一句话判定:
| 坏味道 | 一句话判定 | 正确方向 |
|---|---|---|
| 魔法数字/字符串 | if (status === 2)——2 是什么? |
状态枚举收敛到 entities/order 类型 |
| 隐藏副作用 | composable 里悄悄注册全局监听 | 副作用显式化 + 清理(第 04/19 章) |
| 复制粘贴复用 | 两个页面 80% 相同的筛选表单 | 抽 widget/composable,吃自己的狗粮 |
| 过度抽象 | 只有一个实现却做了三层接口 | 等第二个消费方出现再抽(YAGNI) |
| 断言实现细节 | 测试断言内部状态而非可见行为 | 回归第 15 章「用户中心」测试 |
| 提交 message 垃圾 | fix bug、update、修改 |
commitlint 门禁直接拦截 |
| 巨型组件 | 单文件 > 500 行且混合多职责 | 按 §19.2 拆分,UI 与逻辑分层 |
| 文档类型 | 存放 | 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」交接为止,杜绝「离职即失传」;② 文档过时比没有更危险,季度巡检时直接删除失效内容而非「再看看」。
与设计(对照附录A 视觉与权限模型):
| 契约项 | 约定 |
|---|---|
| 交付物 | 组件级设计稿(状态齐全:默认/悬停/禁用/加载)+ 令牌值表(颜色/间距/字号对应 qing-ui 令牌名) |
| 命名 | 设计图层名与组件/令牌同名(Button/Primary 对 QingButton variant=primary) |
| 走查 | 上线前设计走查一轮,偏差记录进 issue 并分「必须修/可接受」两档 |
与后端:
code 语义以附录A §5 为唯一口径(40102 无感刷新、40301 跳 403 页),新增错误码走联合评审;data.list/total/page/pageSize 形状与附录A 完全一致。契约先行的完整闭环:
后端改契约(PR 到契约仓库) 前端消费
────────────────────── ─────────────────────
OpenAPI diff PR ──评审──▶ 合并 │
│ ▼
├─▶ Mock 平台数据更新 ──▶ 前端联调(不依赖后端进度)
├─▶ 类型再生成(openapi-typescript)──▶ 前端类型检查门禁发现「用到被改字段」即红
└─▶ 变更广播(PR @ 相关前端)
关键收益:字段改名这类破坏,在类型门禁处被拦截(第 17 章的生成类型直接编译报错),而不是上线后靠用户反馈发现。
与测试:
sales/finance 等)、复现步骤、期望/实际、录屏;「复现不了」的缺陷单退回补充;所有约定收敛成一本手册(仓库 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 为准,新增错误码在联调前联合评审。前端对契约的偏离(如「顺手改个字段名」)一律视为缺陷。
任务描述:为轻订单仓库接入 commitlint 门禁(scope 枚举按 §22.2),然后用 git log --oneline 检查最近 50 条提交,产出一份「违规提交清单」,并为其中 5 条改写成规范形态(不改历史,练习改写能力)。
提示:注意 feat! 与 BREAKING CHANGE footer 的写法区别;scope 不在枚举里的提交是「合法但不合格」,想想为什么 scope 枚举要有边界。
验收标准:
pnpm exec commitlint 对不规范 message 报错、对规范 message 通过(双向验证);fix bug 类占 X%);原: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(无逻辑变更)
任务描述:为仓库配置 §22.3 的 PR 模板与分支保护规则(require PR、≥1 approve、CI 必过);然后拿一段真实历史代码(或第 19 章练习产物)发起一个模拟 PR,用三层清单完成一次完整评审,输出评审意见并标注层级(阻断/建议/口味)。
提示:刻意在模拟 PR 里埋 3 类问题(一个阻断、一个建议、一个口味),检验清单是否真能分层拦截而不是「全挑格式」。
验收标准:
## 评审意见(模拟 PR:订单列表筛选优化)
🔴 阻断 1(正确性):筛选参数未做竞态处理,快速切换状态会旧响应覆盖新响应。
→ 参考:第 09 章 AbortController 竞态方案 / `useOrderList` 现成实现。
🔴 阻断 2(安全):`localStorage.setItem('qo_debug_token', token)` 调试代码残留。
→ 凭据零字面量,删除;如需调试走环境变量开关。
🟡 建议(复用):状态筛选下拉的选项数组在两处重复定义。
→ 收敛到 `entities/order/model/statusOptions`,第二个消费方已出现,允许抽象。
⚪ 口味:`getOrderList` 参数解构顺序建议与类型定义一致。(不阻塞)
任务描述:按 §22.8 骨架产出适配你团队的手册 v1.0:目录 + 全部小节的 1–3 句要点 + 三节完整示范内容(分支模型、Review 规范、后端协作任选其二 + 自选一节);并设计「手册巡检机制」(谁、何时、怎么发现过时内容)。
提示:不要复制本教程原文——每条规范后面写上「为什么」(引用 ADR 或事故复盘),手册的价值在理由而非条款;找不到理由的条款直接删掉,那是伪需求。
验收标准:
❌ 错误做法:reviewer 逐行点评「这里少个空格」「分号不一致」「建议变量名再短点」,真正的并发缺陷、越权漏洞、竞态问题从眼皮底下溜走。作者学会的只有「格式要注意」,规范靠人肉执行也永远执行不齐。
✅ 正确做法:格式问题零人肉——Prettier/ESLint/commitlint 门禁(第 17 章)在提交与 CI 阶段拦截,reviewer 看到 diff 时机器问题已清零;review 精力按 §22.4 三层清单分配,阻断项是唯一必查集。检验标准:一次评审意见里出现 3 条以上格式类,就是 reviewer 失职。
❌ 错误做法:一个功能开发两周不合并,最后甩出 1800 行的 PR。reviewer 面对「 approve 还是自己读两周」的绝望选择,通常选择前者;半年后出问题,git blame 指向一个谁也不理解的巨提交。
✅ 正确做法:特性开关 + 小步提交——功能骨架先合并(开关隐藏)、按「API 层 → 逻辑层 → UI」拆 PR 推进;单 PR ≤ 400 行写进手册;确需大 PR(如目录重构)时提前在周会预约 reviewer 时间,并附「结构说明」降低阅读成本。
❌ 错误做法:wiki 里 2019 年的「环境搭建」还在教 npm 3、CNPM 镜像配置早已失效,新人按文档走到黑然后口口相传「文档别看,直接问老王」——知识重新锁回个人脑子里,文档体系名存实亡。
✅ 正确做法:每份文档有 Owner 与生命周期(§22.6),季度巡检「当场删改、不许悬挂待更新」;onboarding 手册用「新人 1-1-1」验收(1 天环境、1 个 PR、1 行上线),新人卡点即文档欠债,修复责任写进入职流程。
❌ 错误做法:「那个字段后端说改成 orderNo 了,谁通知你了?」——契约存在于聊天记录里,OpenAPI 文档停留在项目启动时的版本。前端按旧契约联调通过、上线后字段丢失,事故定级会议变成甩锅大会。
✅ 正确做法:OpenAPI 文档进契约仓库走 PR 评审,变更必通知且生成 diff;前端类型与 Mock 全部由契约生成(第 17 章),人工「同步」这一步从流程中消失;错误码语义(附录A §5)作为联合评审的常设议题。
❌ 错误做法:手册写着「禁止在组件里发请求」「禁止超过 400 行」,没有为什么。新人要么盲目遵守(遇到合理例外不会变通),要么挑战规范(因为不知道碰坏了什么)。规范与执行渐行渐远。
✅ 正确做法:每条规范附「为什么 + 事故/数据出处」与「例外申请路径」(在 ADR 里写清场景即可豁免);规范定期按「是否仍有理由存在」修剪——手册的权威来自理由的正确,不来自条款的数量。
❌ 错误做法:评论写「这代码写得跟屎山一样」「我都说了三遍了你到底看不看文档」。作者要么防御性争执(精力耗在情绪),要么表面服从暗中抵触,最终结果是:敢于发 PR 的人变少、review 变成走过场。技术文化的崩坏往往从一条刻薄的评论开始。
✅ 正确做法:评审评论遵循「现象 → 影响 → 建议 → 出处」四段式,主语永远是代码不是人。对照改写:
❌「你怎么又把金额计算写在组件里?说多少遍了!」
✅「现象:calcOrderTotal 在 OrderCard 组件内部实现。
影响:报表中心需要同一逻辑时只能复制(上季度对账差异事故的根因之一)。
建议:移入 entities/order(第 19 章 §19.2 分层职责)。
出处:协作手册 2.1 / ADR-0026。」
同样,作为被评审者,把每条意见当作「免费的设计咨询」而非「对能力的审判」——回应意见时先复述对方关切(「担心的是重复实现对吧」),再给采纳或替代方案,分歧按 §22.4 升级路径处理。
下一章进入技术选型决策:当「新业务线要用什么技术栈」「这个库能不能引入」的问题摆上桌,你将拥有一套 8 维度加权评估框架、三个真实选型案例与一份可套用的选型报告模板——并学会对「star 数」免疫。
📍 阶段:资深阶段(第 19–24 章)|⏱ 预计学时:7–9 小时|🔗 前置:第 19 章、第 20 章、第 22 章
💡 对照学习:本页是 Vue 版第 23 章。React 版对应章节为 第 23 章 · 技术选型决策。本章方法两版通用,示例代码为 Vue 生态:8 维度评估框架、自研 vs 开源决策树、技术债管理与 Strangler 迁移与框架无关;§23.3 的 Vue vs React 对比表则力求公平——不贬低任何一方,只给适用条件。业务基准:附录A。
学完本章,你将能够:
选型失误的真实成本不是「写错代码的返工」,而是三笔账:迁移成本(换库要改多少调用点)、机会成本(团队本可做业务的时间)、组织成本(招聘与跨团队对齐)。因此选型的正确姿势不是「找最好的」,而是「在约束下找最合适的」:约束包括团队已会什么、业务要活多久、上下游是什么、错了能不能退。
先立三条原则:
| 维度 | 关键问题 | 常用证据 |
|---|---|---|
| 业务匹配度 | 核心场景覆盖率?缺的能力补起来多贵? | 功能对照表、 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 到第三方)
## 结论与退出方案
……
评分框架的五步使用流程(顺序不可倒):
这是双栈团队绕不开的问题。对照表给出事实,结论永远取决于约束:
| 维度 | 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 章)都消费同一份领域资产。
案例一:状态库(轻订单 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 的维度,证明评分表不会一边倒,每个结论都能被质询。
该能力是否是产品核心差异化?
├── 是 ──▶ 团队是否有 ≥ 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」迁移时业务代码零改动——对图表这类重资产依赖,这笔保险值得买。
技术债不是贬义词,是有息负债:为了交付节奏故意欠下的(有意识债)必须记账并计划偿还;无意欠下的(设计缺陷、知识流失)更要尽早显形。
台账字段(放 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 次/周 | 下季度规划强制立项 |
偿还策略三选一(按债务性质):
no-restricted-syntax 指向旧模式即围栏。选型不是「引入那一刻」的事,依赖进来后的治理决定它三年后的形态:
pnpm-lock.yaml 必进版本库,--frozen-lockfile 上 CI;禁止任何人在本地「顺手 upgrade」后带着脏锁文件提 PR;pnpm audit --prod 进 CI,高危漏洞阻断合并;引入新依赖前查「下载量、维护者、issue 响应、最近发版时间」四项健康度;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" // 应用锁精确版本,升级意图显式化
}
与第 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 痕迹 |
可整篇套用的报告骨架(下一章 §24.1 的技术方案文档是其「执行版」):
# 技术选型报告:〈新业务线名称〉(版本 / 日期 / 作者)
## 1. 业务摘要(给非技术评审者)
业务目标一句话;用户规模与增长预期;交付节奏要求。
## 2. 约束盘点
团队现状(技能矩阵)、存量系统(需对接的系统与契约)、合规要求、时间窗。
## 3. 候选与评分
§23.2 八维度评分表(≥ 2 个候选),一票否决项核对。
## 4. 关键决策
3–5 条「约束 → 结论」句式(§23.3 决策句式),每条链到评分依据。
## 5. 风险与退出方案
Top 3 风险及缓解;「半年后放弃它」的具体步骤与预估成本。
## 6. 里程碑与验收
与业务里程碑对齐的技术节点;每个节点的可观测指标。
## 7. 附录
spike 记录、性能实测数据、依赖迁移表。
任务描述:报表中心需要趋势图(附录A §3.6)。用 §23.2 模板对 ECharts 与 Chart.js 做八维度评分(权重自定但需先于打分记录),并给出决策句式结论。
提示:先跑 10 分钟 spike(各画一张双 Y 轴趋势图)再打分,「业务匹配度」的证据来自 spike 而不是官网参数表;轻订单是中后台,注意「团队现状」与「退出成本」的权重应高于 C 端产品。
验收标准:
ECharts 得分点:双 Y 轴/大数据量降采样/图形导出开箱即用(业务匹配 5);文档与中文生态成熟(生态 5)。Chart.js 得分点:体积小(成本 5)、简单图表快,但大数据量与复杂联动需插件拼装(业务匹配 3)。中后台报表场景通常 ECharts 加权胜出;若业务是「营销活动页的轻量图表」,权重变化后 Chart.js 可能反超——同一候选在不同业务里的分数不同,才是评分框架的意义。退出方案:经 §23.5 的 shared/chart adapter 接入,业务只消费 TrendChartInput,迁移只改 adapter。
任务描述:以 §23.4 案例三为背景,撰写「报表中心试点 TanStack Query」的 ADR(第 19 章 §19.8 模板):背景(缓存与失效诉求)、备选、决策、灰度范围(仅报表中心)、退出方案;并写出试点的 useReportExport 骨架代码。
提示:ADR 的「后果」一节要同时写正面(缓存失效一致性)与负面(query key 管理纪律、团队学习成本);退出方案要具体到「保留自研 useRequest 并行一个迭代」。
验收标准:
// 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 章重试纪律对齐)
})
}
任务描述:假设公司立项「轻订单移动端 H5」(销售在外用手机录单,附录A §2 的 sales 角色是核心用户)。按 §23.9 模板产出完整选型报告:框架延续性、组件库选型(移动端与 Element Plus 不同源!)、请求与离线策略(外勤弱网)是三个必答命题。
提示:先盘点约束——团队现有 Vue 栈、需要快速复用订单领域类型与 API 层(第 19 章的 qing-api-client 立即体现价值);移动端组件库候选给 Vant 与原生自建两条路。
验收标准:
--q-* 令牌在 H5 同样生效,第 20 章设计的回报);qing-ui 的组件层不复用到移动端(交互形态不同),只共享令牌与工具;❌ 错误做法:评分表退化成 GitHub star 排序,「这个库 30k star,就它了」。star 度量的是知名度,不是维护健康度(高频 breaking)、不是业务匹配度(缺你要的核心能力)、更不是团队适配度。历史上大量高 star 库进入「低维护」状态后,用户成了接盘侠。
✅ 正确做法:star 只作为「招聘与社区体量」维度的一个参考信号;把注意力放到健康度指标上——最近 6 个月发版频率、issue 平均响应时长、breaking change 频率、维护者集中度(bus factor ≥ 2)、以及最重要的:你的 spike 实测。评分表里每个维度都必须给出证据而非感觉。
❌ 错误做法:团队老大已经内定某库,评审走个流程——权重朝结论倒排(把候选强的维度权重调高),证据只挑有利的。半年后库的短板爆雷,回看评分表发现漏洞百出,团队对「评分框架」本身失去信任,选型文化崩坏。
✅ 正确做法:权重在见候选证据之前确定并记录在案;每个候选的证据由不同成员独立收集再汇总;引入「魔鬼代言人」角色——评审会上指定一人专职攻击首选方案。评分表连同反驳意见一起归档,错了要能看出当时为什么错。
❌ 错误做法:直接在 30 个组件里 import 第三方表格库的内部 API(私有方法、 undocumented props),没有任何封装层。一年后库停止维护,评估迁移工作量时发现要改 30 处 × 平均 8 个调用点——迁移成本高到只能继续裸奔。
✅ 正确做法:重资产依赖(图表/表格/编辑器/状态库)一律过防腐层(§23.5 adapter),业务只依赖自有接口;ADR 里必须写「退出方案」,并把「调用点数量」作为依赖引入前的度量项——超过约定阈值(如 > 10 个调用点)强制走封装层。
❌ 错误做法:技术债清单 40 条,每年 review 都是「下次处理」,债务利息持续累积:新人持续踩同一个坑、每个迭代都在为旧设计绕路,最后一次「小需求」引爆全部债务(此时连事故复盘都说不清根因)。
✅ 正确做法:给每条债务标本金与利息(§23.6),设触发线(利息 > 本金 × 2 或相关事故一次)自动升级为专项;季度规划给债务预留固定容量(第 24 章 §24.5 的 70/20/10),让「还债」成为常规排期而非道德呼吁。
❌ 错误做法:看到社区新框架/新状态库就往生产项目里引,「练练手」。核心业务被实验性依赖绑架,作者离职后无人敢动;升级一个补丁版本都心惊胆战。
✅ 正确做法:新技术的入口是 spike 仓库(独立分支/沙盒项目),产出评估报告后按八维度表走正式流程;生产引入的门槛是「稳定 ≥ 一个 LTS 周期 + 有退出方案」;团队约定「学习在业余/spike,生产要证据」。
❌ 错误做法:两个极端都常见——其一是「能跑就不动」:框架小版本落后两年,安全补丁堆积,最终被迫在业务高峰期紧急大升级(被动升级 = 最高成本形态);其二是「追新」:库刚发 major 第二天就跟进生产,替全团队当试错牺牲品。
✅ 正确做法:把升级当作有节奏的常规农业(§23.7):小版本自动跟进(CI 绿即合)、大版本按季度批次评估、安全补丁 48 小时内响应;每次大版本升级写 ADR 记录「为什么现在升/为什么不升」,时间窗判断本身可追溯。
下一章是全书的收官实战:把第 19–23 章的方法融进一份完整的《轻订单 v2 技术方案》(CSR → CSR + 局部 SSR/岛屿、权限中台化),走一遍架构评审流程,并学习如何把技术判断转化为管理层听得懂的语言。
📍 阶段:资深阶段(第 19–24 章)|⏱ 预计学时:9–12 小时|🔗 前置:第 19–23 章(本章为第四部分收官整合)
💡 对照学习:本页是 Vue 版第 24 章。React 版对应章节为 第 24 章 · 资深实战-技术方案与架构评审。本章方法两版通用,示例代码为 Vue 生态:方案写作法、评审 checklist、向上管理与技术规划完全一致;文末的毕业项目建议同样适用于双栈学习者。
学完本章,你将能够:
技术方案是决策的载体,不是需求的复述。它回答三个问题:为什么做(背景)、做成什么样算成功(目标/验收)、为什么这样做而不是那样做(方案对比)。七段结构:
| 段落 | 写什么 | 常见失败 |
|---|---|---|
| 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 双端实测。」
(基线 + 目标 + 根因 + 手段 + 预期 + 验收,六要素齐)
以下是可以直接套用的完整方案(内容融合第 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 终态归档 | 全部灰度完成,回滚演练一次 |
评审生命周期
会前(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 条):
技术决策推进失败的最常见原因不是方案不好,而是翻译失败。四段电梯汇报结构:
| 段 | 内容 | 例(权限中台化向 CTO 汇报) |
|---|---|---|
| 现状 | 一句量化事实 | 「权限逻辑散落 7 处文件,Q3 因漏改引发 1 次 P2 事故」 |
| 风险 | 不做的代价 | 「每新增一个角色都有回归遗漏风险,H5 立项将放大 2 倍」 |
| 建议 | 一句话方案 + 成本 | 「把权限收敛为统一 SDK,2 周,我出人力计划」 |
| 资源 | 需要什么、回报什么 | 「需要后端 0.5 人力配合接口;回报是角色配置化 + H5 直接复用」 |
三条影响力法则:① 用业务语言陈述技术债的利息(第 23 章 §23.6 的本金利息模型就是为此设计的);② 带着选项去而不是带着问题去(给管理层 A/B/C 与你的推荐,而不是「怎么办」);③ 让正确的事可被复述——一页纸、一张图、一组数字,比一万字更能在高层会议中活下来。
季度规划(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 关闭
你现在拥有了完整的方法链。毕业项目是一次真实闭环(建议 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 · 学习路线图与资源 规划下一程(管理线 / 专家线的分岔口在阶段自查清单中讨论)。
任务描述:以下是领导的一段口述——把它改写成 RFC 的「背景 / 目标 / 非目标」三段(可合理虚构量化数据):
「那个门户吧,老是有客户说搜不到我们,还有市场那边每次改个产品介绍都得找你们排期,挺耽误事的。对了最近不是要招渠道经理嘛,权限那块听说改起来挺麻烦,顺手一起弄了?」
提示:口述里的「搜不到」对应 SEO、「排期」对应内容自助、「权限改起来麻烦」对应中台化;「顺手一起弄」正是非目标段落要拦截的范围蔓延——判断哪些该进、哪些该明确排除。
验收标准:
背景:① 门户为纯 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;「顺手」范围外的一切后台功能改造(明确排除,另行立项)。
任务描述:以 §24.2 的 5.1 节为底稿,替换为「你团队的权限现实」重写一份:画仲裁架构图、写 can() 仲裁接口与路由消费端代码、列迁移步骤(旧 v-permission 逻辑如何双轨过渡)。然后交给同伴按 15 条 checklist 逐条评分。
提示:迁移要写「双轨期」——旧角色判断与新权限点并存,用开关灰度切换(第 22 章特性开关的又一次应用);想清楚旧逻辑什么时候删、谁批准删除。
验收标准:
can() 内部实现为「权限点映射表(角色→权限点的兼容层)」,旧 v-permission 调用点逐个替换为权限点写法(PR 逐域拆分,第 22 章 ≤ 400 行纪律);can() 数据源从兼容层切换为下发授权集(配置开关 auth.remote-source 控制);任务描述:从「门户岛屿化」「报表中心服务端导出」「移动端 H5 离线录单」中任选一个议题,按 §24.1 七段结构写完整 RFC(3–6 页),然后组织一次 60 分钟模拟评审(同伴任评审人,你任作者,第三人任主持人),全流程执行会前/会中/会后纪律。
提示:议题二(服务端导出)会触及第 09 章请求层与附录A §3.6 导出进度条;议题三触及弱网、数据同步与冲突处理——无论选哪个,评审人最爱问的三板斧是「数据一致性、失败模式、回滚」。
验收标准:
❌ 错误做法:RFC 通篇是功能列表与界面描述(「系统将提供订单导出功能,支持 CSV……」),找不到一句技术决策与备选对比。评审会上被问「为什么不用 X」时当场抓瞎——因为作者自己也没想清楚。另一种反面:全是代码细节没有一张图,评审人三页后失去耐心。
✅ 正确做法:严格七段结构,重心压在方案对比与详细设计的决策点上;需求背景只写「影响技术决策的那部分」;每页至少一张图或一张表;代码只贴关键路径(接口签名、数据流、降级逻辑),实现细节放附录链接。
❌ 错误做法:RFC 里只有「本方案采用 Astro……」——没有候选、没有对比。这实际是把「已经做完的决定」通知大家,评审沦为形式;一旦有评审人质疑方案本身,作者毫无防御纵深,决策公信力受损。
✅ 正确做法:≥ 2 个候选 + 对比表 + 结论句式(第 23 章 §23.3);候选可以「弱」,但必须真实评估过——「考虑过保持 CSR + 预渲染,因未解决内容排期根因而排除」一行字,比十个省略号更能建立信任。
❌ 错误做法:不写非目标。评审会上有人提议「顺便把 i18n 做了吧」「H5 也一起覆盖了吧」,作者被现场扩权,工期从 2 周变 2 月;或反向妥协,把合理的相关改进全推给「下个版本」,方案孤立到无法落地。
✅ 正确做法:非目标逐条写明「不做什么 + 为什么不 + 未来何时重估」;评审中对新增范围的回应模板是:「记入评审纪要,单独立项评估」——礼貌接收、边界不动。
❌ 错误做法:评审会无主持、无议程,两位强势工程师为一个命名争论 40 分钟;散会无纪要,口头「下次改」没有 Owner;两周后同样的争论原地重演。评审的成本(6 人 × 1 小时)被浪费,且团队开始恐惧评审。
✅ 正确做法:主持人控场(发言先结论、单议题限时、跑题即记录后置);分歧逐条处置(采纳/驳回/待 spike,各有归属);会后 24 小时纪要 + 行动项进 issue;对无法当场决策的议题,用「spike 后复议」收场而不是争论到面红耳赤。
❌ 错误做法:对 CTO 说「我们要做 CSR 到岛屿架构的水合策略迁移」,得到一个茫然的「所以呢」。技术债不报,事故爆发后才是「你们早就知道?」的信任危机时刻——沉默的技术债是未来最贵的沟通成本。
✅ 正确做法:按 §24.4 四段结构翻译:现状量化、不做的代价(利息!)、建议与成本、所需资源与回报;每个技术目标都在季度规划里挂着对应的业务目标(§24.5)——让管理层看到「技术投入 = 业务加速」,而不是「技术 = 成本中心」。
❌ 错误做法:里程碑写着「周五上线」,既没有放量计划也没有回滚步骤;出问题时的应对是「一群人开会决定回滚,但没人记得旧产物在哪」。第 18 章搭好的软链回滚机制在真正需要时才发现从未演练。
✅ 正确做法:任何变更默认带三件套——灰度计划(路径/角色/比例)、降级开关(依赖不可用时用户看到什么)、回滚步骤(一条命令 + 责任人 + 时限);回滚演练纳入季度规划的技术专项(§24.5 O4),演练不过关的方案不允许上线。
🎓 全书正章到此收官。接下来请前往 附录B · 面试题精选 自测四阶段知识沉淀,再到 阶段自查 · 资深能力清单 做最后的画像校准——祝你在管理线或专家线的下一程里,把这套方法用于更大的战场。
💡 React 版对应文件:阶段自查-中级能力清单(清单条目完全一致,可双栈互验)。业务基准:附录A「轻订单」需求说明书。
过了中级这条线,你不再是「按 ticket 改页面的人」,而是能独立负责一个业务模块从需求到上线的工程师。以「轻订单」为标尺,企业对中级开发的真实预期是:
做不到以上四条中的任何一条,说明中级阶段还有缺口,对照下面的清单定位回看。
用法:逐条勾选。拿不准的条目给出「未达标 → 回看第 X 章 §Y」的定位,回看后重做该章实战练习再来勾。
meta.roles 页面守卫 + 403、按角色过滤的动态菜单、v-permission 按钮级控制(未达标 → 回看第 11 章 §11.5–11.7)storeToRefs 的作用(未达标 → 回看第 08 章 §8.1/§8.3/§8.4)_retried 死循环防护(未达标 → 回看第 09 章 §9.5、第 11 章 §11.2)ApiError.code 分支、静默用 silent 显式申请(未达标 → 回看第 09 章 §9.4)constants/,组件零硬编码业务字符串(未达标 → 回看第 12 章 §12.2/§12.5)meta.roles 驱动菜单、面包屑与守卫,不存在两处维护(未达标 → 回看第 07 章 §7.5/§7.7、第 11 章 §11.6)自评结论:16 条全勾 → 直接进入第三部分;A 组全勾、B/C 组缺 1–2 条 → 回看对应章节后进入;A 组有缺口 → 重做第 12 章对应里程碑练习。
不查资料独立完成,每题 3–4 分钟。做完再展开答案对照。
/orders/1001 跳转到 /orders/1002,详情页数据没有更新。至少说出两种解法,并说明各自的代价。
onBeforeRouteUpdate(推荐):组件复用时响应参数变化,重新拉取数据;代价是要写额外的更新钩子,但组件状态(如滚动位置、已展开的面板)保留。<router-view :key="$route.fullPath" />:路径变化强制重建组件;实现零成本,但组件状态全部丢失、有重建开销。
另可顺带说明 watch(() => route.params.id, ...) 也可行,与方案 1 等价。(第 07 章易错点 1)下面这行代码有什么问题?为什么组件界面在登录成功后不更新?
const { profile, isLoggedIn } = useUserStore()
store 实例本身是 reactive 对象,直接解构会丢失响应性——profile 与 isLoggedIn 变成一次性快照,后续 store 更新不会同步到组件。正确写法:响应式部分用 storeToRefs(store) 解构;actions 是普通函数,可以直接从 store 解构。(第 08 章 §8.4、易错点 1)
已知明细行 { 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)
三个并发请求同时收到 code: 40102。描述前端应发生的完整过程,以及「刷新接口自身也返回 40102」时如何避免死循环。
过程:①响应拦截器识别 40102,进入刷新流程;②单飞锁:只有第一个请求创建 refreshingPromise(调 /auth/refresh),其余两个 await 同一个 Promise,因此 refresh 只发一次;③刷新成功后锁释放,三个等待者各自把原请求带上新 token 重放(_retried = true);④重放请求若再次 40102,说明 refreshToken 也失效——_retried 分支直接 clearAuth() 并 reject 40101,绝不再次刷新。另外刷新请求本身要用裸 axios 发送,不经过响应拦截器。(第 09 章 §9.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)
对照附录A 的三级权限模型,说出每级「在哪实现、用什么机制」,并回答:前端把 403 做得再好,为什么还必须信任后端?
菜单级:菜单配置带 roles,登录后 filterMenus 过滤,决定「看见什么」;页面级:路由 meta.roles + 全局守卫,无权重定向 403,决定「进得去什么」;按钮级:v-permission 指令(或 v-if + hasPermission)控制元素显隐,决定「能点什么」。因为前端代码运行在用户设备上,权限数据(角色、权限码)可被篡改、接口可被直接调用——前端权限只是体验优化,真正的安全边界永远是后端鉴权。(第 11 章 §11.1、§11.5–11.7)
团队要决定 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)
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 章),否则继续补中级缺口:
📍 适用范围:Vue 版 · 初级阶段(第 01–06 章)毕业自查
⬅ 上一章:初级实战 · 企业门户首页 | 📖 本版目录 | ➡ 下一阶段:中级(路由 / Pinia / 服务端交互)
💡 用法:本清单在完成第 06 章实战后使用。逐项打勾,诚实标注;「未达标」项按标注回看对应章节小节,重做该节练习后再来。全清单建议 20 分钟勾完,小考 30 分钟。
完成初级阶段后,你应该能胜任团队里的这些真实工作:
@ 别名与 dev server,并演示一次 HMR —— 未达标 → 回看 第 01 章 §1.5–1.7 与练习 2:modelValue + @update:modelValue)与 React 受控组件的对应关系 —— 未达标 → 回看 第 05 章 §5.4import.meta.env.VITE_* 环境变量或占位符;理解生产构建不暴露组件状态的 Devtools 基线 —— 未达标 → 回看 第 01 章 §1.9 与 第 06 章 §6.6作答方式:拿纸笔或新建笔记,逐题写下答案与理由,再对照折叠块里的参考要点。答错或写不出的题,按括号里的指引回看。
v-if:条件块真实创建/销毁,切换成本高、初始成本低;可作用于 <template>。v-show:元素始终渲染,切换 display 样式,初始成本高(不显示也渲染)、切换成本低;不能用于 <template>。v-if:低频出现、内部组件较重,不显示时彻底销毁释放资源;tooltip/悬停提示 → v-show:高频切换,避免反复创建销毁。const filter = reactive({ keyword: '', status: 'all' })
const { keyword, status } = filter
keyword = 'QO'
filter.keyword = 'QO' 访问。const { keyword, status } = toRefs(filter),之后用 keyword.value = 'QO'(ref 语义)。computed:纯派生计算,需要缓存,依赖自动追踪。watch:副作用(发请求),显式声明依赖源更可控,可配 immediate 首屏、onCleanup/竞态 id 保护。watchEffect 或 watch 均可:多源联动副作用;watchEffect 自动收集依赖且立即执行,但拿不到旧值;要拿旧值或依赖明确时用 watch 多源数组。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、清理残留。<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 是单向数据流,父组件不知情,状态所有权被破坏,且运行时报错/警告。value + onChange 的约定,只是 Vue 把它标准化成了 v-model。<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>
props.config.visible = false):破坏单向数据流,应改为 emit('remove-order', id) 让父组件改状态。defineEmits([]) 空声明却 emit 了 removed:事件未声明会透传成原生事件/丢校验,应全量声明 defineEmits(['remove-order'])。config: Object 过宽,应切片传具体字段或改插槽定制。#ffffff):绕过设计令牌,暗色主题会出白块,应改 var(--qo-surface)。removed 载荷为空:建议动宾命名 + 载荷带 id;组件顶部补职责声明注释。满足以下全部条件,再进入 第二部分 · 中级阶段:
任一条件不满足:按未达标项的「回看」指引补课后重新自查,不要带病升级——中级阶段的路由守卫、Pinia store、接口层全部建立在初级这些地基之上。
💡 React 版对应文件:阶段自查-资深能力清单(清单条目完全一致,可双栈互验)。业务基准:附录A「轻订单」需求说明书 §6 资深阶段里程碑——模块化拆分、qing-ui 组件库、微前端接入、v2 技术方案。
资深阶段的产出不再是一个模块、一个优化项,而是**「系统 + 团队 + 决策」三个层面的杠杆**。以「轻订单」为标尺,企业对资深开发的真实预期是:
做不到以上五条中的任何一条,说明资深阶段还有缺口,对照下面的清单定位回看。
用法:逐条勾选。拿不准的条目给出「未达标 → 回看第 X 章 §Y」的定位,回看后重做该章实战练习再来勾。
app/views/widgets/features/entities/shared),路由/状态/请求三大基建按约定归位,迁移全程冒烟集护航(未达标 → 回看第 19 章 §19.1–§19.5、练习 1)自评结论:16 条全勾 → 完成本教程全部正章,按下面第四节规划下一程;A 组全勾、B/C 组缺 1–2 条 → 回看对应章节后视为毕业;A 组有缺口 → 重做第 19–24 章对应实战练习(资深阶段的练习产出本身就是简历素材,不要跳过)。
不查资料独立完成,每题 3–4 分钟。做完再展开答案对照。
某内部工具:4 个页面、2 人维护、预计使用半年。组长提议「一步到位上六层分层 + Monorepo + 微前端」。你的评估流程和结论是什么?
先用四因子表打分(第 19 章 §19.1):模块 ≤ 3(1 分)、团队 ≤ 2(1 分)、变更波及 1 个模块(1 分)、寿命 < 1 年(1 分),总分 4 ≤ 8 → 保持简单目录,不上分层、不上 Monorepo,更远够不到微前端的「适用信号 ≥ 3 条」门槛(第 21 章 §21.1)。同时把评估结果写进一页 ADR 留档:若未来分数上涨(模块/团队/寿命增长),按「模块化单体 → 抽包 → 拆应用」的路径演进,而不是现在预支成本。这是典型的「为小项目上重装架构」过度设计案例。
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 层建组合块,而不是让基建层长出业务感知。
组件库样式里出现了 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 原始令牌」直接报错。
第三方交付的 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)。
团队现状:提交信息是「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)保证「为什么改」的信息不丢失——提交历史从此可追溯、可发布、可考古。
评分表算出来:候选 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——分数是决策的输入之一,不是决策本身。
你需要向 CTO 争取 3 人日偿还「订单筛选竞态缺陷」的技术债(TD-014),怎么组织这次 3 分钟汇报?
用四段结构(第 24 章 §24.4)+ 利息语言(第 23 章 §23.6):现状——「筛选参数竞态缺陷存在于订单列表,快速切换状态时旧响应覆盖新响应,已发生 2 次数据展示错误工单」;风险/利息——「每次新增筛选功能都要人工排查竞态,约 0.5 人日/次,Q4 已排期 3 个筛选需求;且它随时可能演变为用户看到错误订单数据的资损观感问题」;建议——「3 人日:复用第 09 章 AbortController 方案重构请求编排 + 回归测试」;资源/回报——「不占用业务排期(建议从 20% 技术专项容量出),还清后未来筛选类需求提速,并同步关闭 TD-014」。要点:全程无一个框架术语,把债务翻译成「已发生的成本 + 未来可避免的成本」,并给出明确的容量归属——管理层批准的不是「修 bug」,而是「一笔利息超过本金预期的负债重组」。
一份 RFC 的「目标」写着:「优化门户性能,提升用户体验,并顺便完善权限体系。」指出至少三处问题并改写。
问题:① 不可验收——「优化/提升」无基线无数值,无法判断做没做成(第 24 章 §24.1 目标要指标化);② 范围蔓延信号——「顺便完善权限体系」正是非目标段落要拦截的「顺手需求」,权限要么独立 RFC(它有独立的后端契约与灰度风险),要么在非目标里明确排除;③ 缺非目标与对比前置信息——只写目标不写「不做什么」与「为什么是这条路」,评审无从质询。改写示例:「目标:G1 首页/产品页服务端可抓取(Lighthouse SEO ≥ 95);G2 LCP ≤ 2.0s 且 TTFB ≤ 800ms;G3 内容更新实现营销自助(前端零排期)。非目标:admin 不做 SSR(登录态应用无收益);i18n 不在本期;权限体系另见 RFC-0043。」
毕业前最后一件事:前往 附录B · 面试题精选 完成资深阶段的 6–8 道题自测,检验「能讲清原理」是否经得起追问。
资深之后没有唯一路径,两条线的「日常产出物」差异巨大,选择前先对照:
| 维度 | 管理线(工程经理 / 前端负责人) | 专家线(架构师 / 领域专家) |
|---|---|---|
| 核心杠杆 | 通过他人交付:排期、协作、成长与绩效 | 通过系统交付:架构、攻坚、技术判断 |
| 典型日常 | 1:1、季度规划(第 24 章 §24.5)、跨部门对齐、招聘 | RFC 与 ADR、评审主持(§24.3)、疑难攻坚、技术雷达 |
| 本教程的迁移 | 第 22 章协作手册 → 团队制度;第 24 章向上管理 → 日常工作 | 第 19/23 章评估与选型 → 技术雷达;第 24 章 RFC → 持续产出 |
| 适合你,如果 | 你看到成员成长比亲自写代码更兴奋,且擅长把模糊目标翻译成清晰排期 | 你对「系统为什么这样设计」的追问永无止境,且能接受编码密度不降的职业 |
| 风险提示 | 技术钝化:需刻意保留 code review / spike 参与度 | 影响力局限于技术域:需补表达、跨部门协作能力 |
两条线不是单行道:3–5 年经验段切换成本低,且顶尖的负责人通常有过专家线的深度、顶尖的架构师通常带过小团队。无论选哪条,本阶段练出的三种底层能力——先评估再决策、把约定变成机器门禁、把技术翻译成业务价值——在两条线上都是复利资产。
🎓 全书自查到此完成。若三个阶段的自查清单全部通过,你已经走完「初级 → 中级 → 高级 → 资深」的完整路径。下一站:附录B · 面试题精选 与 附录C · 学习路线图与资源。
💡 React 版对应文件:阶段自查-高级能力清单(清单条目完全一致,可双栈互验)。业务基准:附录A「轻订单」需求说明书 §4 非功能需求。
过了高级这条线,你的产出不再只是「功能」,而是系统的质量下限。以「轻订单」为标尺,企业对高级开发的真实预期是:
做不到以上四条中的任何一条,说明高级阶段还有缺口,对照下面的清单定位回看。
用法:逐条勾选。拿不准的条目给出「未达标 → 回看第 X 章 §Y」的定位,回看后重做该章实战练习再来勾。
shallowRef/v-memo/状态拆分减渲染 → RecycleScroller 虚拟滚动,达成「掉帧 < 5%」并留存前后对比数据(未达标 → 回看第 13 章 §13.2/§13.3/§13.5/§13.6)keep-alive 缓存列表页且不漏内存:include 白名单、max 上限、onActivated/onDeactivated 起停副作用(未达标 → 回看第 13 章 §13.4、易错点 3)vi.mock 与 msw 双板斧消灭环境差异(未达标 → 回看第 15 章 §15.2–§15.8)v-memo 与 React.memo 的语义差异(未达标 → 回看第 13 章 §13.1/§13.3)vi.mock(模块层)与 msw(网络层)的拦截位置与选型依据(未达标 → 回看第 15 章 §15.6,第 16 章 §16.7)hidden 模式「生成但不暴露、上传后删除」的原理、tree-shaking 失效的三个条件(未达标 → 回看第 17 章 §17.6/§17.7,第 18 章 §18.6)wrapper.vm 内部断言)、外部依赖零容忍(无真实网络/定时器/时间)、快照只锁小而稳定的数据结构(未达标 → 回看第 15 章 §15.5/§15.7、易错点 1/2/5)as 压错、VITE_ 前缀只放公开配置(未达标 → 回看第 17 章 §17.4/易错点 1/2/3)自评结论:16 条全勾 → 直接进入第四部分;A 组全勾、B/C 组缺 1–2 条 → 回看对应章节后进入;A 组有缺口 → 重做第 13–18 章对应实战练习。
不查资料独立完成,每题 3–4 分钟。做完再展开答案对照。
<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)
v-memo="[order.status]" 的行里还渲染了 {{ order.ownerName }},会出现什么问题?正确的依赖数组应该是什么?什么情况下干脆不要用 v-memo?
依赖数组漏项:ownerName 变化时该行被 v-memo 跳过更新,界面永远显示旧值——这是正确性 bug 而非性能问题。依赖数组必须覆盖子树用到的全部响应式来源,本例应为 v-memo="[order]"(整个对象引用)。当依赖关系复杂、拿不准是否覆盖完整时,不用 v-memo,先做组件拆分与状态下放——那两个手段无正确性风险且收益通常已足够。(第 13 章 §13.3、易错点 5)
说出虚拟滚动的三要素;解释为什么切片渲染不能用切片下标当 key;RecycleScroller 相比手写简化版多解决了什么问题?
三要素:①标尺层占位(撑起总高度,滚动条正确);②可视区切片(按 scrollTop 算起止行号,只渲染这一段);③DOM 回收(滚出视口的行销毁,新行创建)。key 用切片下标:滚动后同一位置的切片下标相同,Vue 判定「节点未变」原地复用 DOM,行内非 props 状态(选中态、输入内容)会串行到错误的数据上——必须用业务身份 row.data.id 或真实下标 row.index。RecycleScroller 额外解决了动态行高(DynamicScroller)与 DOM 节点回收复用(减少创建销毁开销)。(第 13 章 §13.5、易错点 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)
两个断言问题:① (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)
一条 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)
接口返回的数据为什么要以 unknown 起步 + 类型守卫收窄,而不是 as Order?VITE_ 前缀的环境变量有什么安全约束?
as 是编译期谎言:它不产生任何运行时检查,字段缺失、状态码非法、结构变更都会带着「合法类型」的假象流入组件,在几百行外才炸。unknown 起步强制先校验:isOrder(v) 守卫同时完成运行时校验与类型收窄,脏数据在请求层就被拦截——类型正确性与运行时安全性由同一份代码保证。VITE_ 前缀变量会在构建时被内联进客户端产物,按 F12 可见,因此只允许放公开配置(API 地址、公开 DSN);任何服务端凭据(数据库密码、支付密钥、CI token)不得使用该前缀,只存在于服务端并经 secrets.* 注入。(第 17 章 §17.4/§17.6、易错点 1/2)
上线后发现一个报错但堆栈全是压缩代码。描述 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 章),否则继续补高级缺口:
本附录按教程四阶段组织 28 道高频面试题(每阶段 7 题),用于学后自测与面试冲刺。标注 (Vue 特有) 的是框架原理差异化题,与 React 版同位置题目不同,两版不可互相替代。
用法一 · 按阶段定位薄弱项:每学完一个阶段(或做完该阶段实战后),把对应小节当「模拟面试」过一遍——先合上答案口述作答,再展开要点对照;答不完整或说反的题,按要点里的「详见第 X 章」回看对应章节(尤其是各章「常见易错点」),并记入该阶段自查清单的待办。
用法二 · 面试前 7 天冲刺法:
| 时间 | 动作 |
|---|---|
| D-7 ~ D-5 | 通读本附录与四份阶段自查清单,把题分成「答得出 / 答不完整 / 完全答不出」三档并标记 |
| D-4 ~ D-2 | 只复习后两档标记题;每天大声口述 5 道并录音回听,目标 2 分钟讲清一道 |
| D-1 | 用 STAR 法整理 3 个项目故事(见文末方法论):最难的 bug、最有价值的优化、一次技术权衡 |
| 面试当天 | 只看标记题要点首条与答题方法论,不学新知识 |
「参考答案要点」是记忆锚点而非标准答案全文:面试时按文末框架组织语言、结合「轻订单」等真实项目举例,比背诵要点更加分。
Q1 · ref 和 reactive 怎么选?ref 的解包规则是什么?(Vue 特有)
.value 心智模型、自动解包的边界。ref:对原始值与对象统一处理,解构、传参、return 都不易丢响应式;reactive 适合集中定义一组强关联状态(如表单对象)。reactive 嵌套的 ref 在访问时自动解包;但数组/集合元素、把 ref 挂在普通对象上、解构 reactive 等场景不会自动解包(详见第 4 章)。reactive 后响应式丢失 → 用 toRefs 或改用 ref(详见第 4 章易错点)。shallowRef、readonly 的存在与开销取舍(详见第 13 章)。Q2 · watch 与 watchEffect 有什么区别?如何取舍?(Vue 特有)
watch:显式声明监听源、默认惰性触发、能拿到旧值,适合「某状态变化 → 发请求/写日志」的精确控制场景。watchEffect:立即执行一次并自动追踪回调内用到的响应式依赖,适合「多个状态 → 一个派生副作用」,依赖是隐式收集的。watch;依赖随分支变化用 watchEffect;两者都用 onCleanup 清理上次副作用,操作 DOM 用 flush: 'post',异步创建的 watcher 需手动 stop(详见第 4 章)。Q3 · v-if 和 v-for 同时用在一个元素上会发生什么?(Vue 特有)
v-if 与 v-show 差异。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?
v-model(modelValue + update:modelValue)交回父组件修改。Q5 · computed 为什么比在模板里调方法高效?缓存语义是什么?(Vue 特有)
computed 基于响应式依赖缓存:依赖未变时重复读取直接返回缓存值;模板方法每次重渲染都重新执行。watch/事件的职责(详见第 4 章易错点)。Q6 · 组件上的 v-model 是如何工作的?怎么封装一个支持 v-model 的输入组件?(Vue 特有)
v-model。v-model 展开为:传 modelValue prop + 监听 update:modelValue 事件(详见第 5 章)。emit('update:modelValue', v) 交回父组件——即「受控优先」。v-model:title 指定参数名实现多 v-model;自定义修饰符(如 trim)通过 modelModifiers 处理。Q7 · 列表渲染中 key 的作用是什么?用数组下标作 key 会出什么问题?
key 帮助 diff 识别「同层级节点是不是同一个」,有稳定 key 时正确移动/复用节点,而非无差别就地复用(详见第 2 章)。Q8 · 说出一次导航中 Vue Router 各类守卫的完整执行顺序。(Vue 特有)
beforeRouteLeave → 全局 beforeEach → 复用组件的 beforeRouteUpdate → 路由独享 beforeEnter → 进入组件的 beforeRouteEnter → 全局 beforeResolve(详见第 7 章)。afterEach → DOM 更新 → beforeRouteEnter 的 next 回调(拿不到 this,能拿实例 vm);异步路由组件在守卫解析期间加载。false/next(false) 取消导航,抛错进入 onError。beforeEach + meta 标记」是性价比最高的落点。Q9 · 全局前置守卫怎么做登录鉴权与白名单?动态路由要注意什么?
addRoute 时机。meta.requiresAuth 标记需登录路由,白名单(如登录页)直接放行;未登录跳登录页并带 redirect 参数回跳原地址(详见第 7、11 章)。addRoute 场景:注册完成前 next() 会匹配 404,需 next({ ...to, replace: true }) 重新进入一次确保匹配(详见第 11 章易错点)。Q10 · Pinia 相比 Vuex 做了哪些简化?为什么去掉了 mutations?(Vue 特有)
$patch + 插件已能追踪变更,砍掉后样板代码显著减少。Q11 · 封装 axios 实例时,请求/响应拦截器分别要做什么?错误如何归一化?
data)、按业务码分类、把错误归一化为统一结构(message/code/status)再 reject,让上层只处理一种错误形态。AbortController 取消上一次请求解决竞态、401 统一走刷新/登出(详见第 9 章最佳实践)。Q12 · 讲一下无感刷新 token 的实现原理。
new Promise,刷新完成后逐个 resolve 重放。Q13 · Element Plus 表单校验怎么做联动校验?动态明细行怎么配置规则?
rules 声明校验(必填、格式、自定义 async-validator),trigger 控制时机(blur/change);提交前 formRef.validate() 全量校验(详见第 10 章)。watch 依赖字段后调 validateField 校验目标字段,或写跨字段自定义 validator。:prop="items.${index}.qty")+ :rules,增删行后规则随索引自然成立;提交置 loading 防重复提交(详见第 10 章实战)。Q14 · 如何实现按钮级权限指令 v-permission?它安全吗?(Vue 特有)
mounted 时比对当前用户权限码集合(来自 Pinia 用户 store)与指令参数,无权限则移除元素;复用组件的动态变化在 updated 处理(详见第 11 章)。display:none,避免用户从开发者工具把隐藏按钮找回来。Q15 · 讲讲 Vue 的响应式与更新机制,并和 React 的重渲染模型对比。(Vue 特有)
setState 后组件函数整体重新执行再 diff;靠 memo/useMemo/useCallback 与状态下放等手段手动缩小重渲染范围。Q16 · 虚拟列表的原理是什么?不定高列表怎么处理?
startIndex 与偏移,用 transform 移动内容区(详见第 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 秒,说说你的分析与落地过程。
Q19 · shallowRef 和 v-memo 分别适合什么场景?(Vue 特有)
shallowRef:只追踪 .value 本身的替换,内部属性变化不触发更新;适合大型列表、图表实例、第三方复杂对象,内部变化用 triggerRef 手动触发或整体替换(详见第 13 章)。v-memo:模板层记忆一段渲染结果,依赖数组不变就跳过该段 diff;适合大列表「绝大多数行不变、少数行更新」,如 v-memo="[item.id, item.status]"。Q20 · 你的组件测试策略是什么?哪些值得测,哪些不值得?
Q21 · CI/CD 流水线怎么设计?生产报错后 sourcemap 如何还原?
.map 只上传监控平台(如 Sentry)而不随 CDN 发布防源码泄露,报错堆栈按 release 匹配还原(详见第 18 章)。hidden-source-map:浏览器看不到 map、平台侧仍可还原;没有版本绑定的 sourcemap 等于不能用。Q22 · 大型前端项目怎么分层?功能切片和技术分层怎么选,依赖规则怎么定?
Q23 · 设计一个组件库的公开 API,你会遵守哪些原则?
Q24 · 什么时候不该用微前端?主流方案怎么选?
Q25 · 新业务线要选技术栈,Vue 还是 React,你怎么做这个决策?
Q26 · 什么是 ADR?为什么资深工程师要坚持写?
Q27 · 存量系统要从旧架构迁到新架构,你的渐进迁移策略是什么?
Q28 · 一份合格的技术方案文档长什么样?架构评审怎么组织?
flush: 'post'。本附录给出全书的 ASCII 学习路线图、只收录官方渠道的权威资源清单、路线图与本教程章节的映射表,以及四个选修进阶方向与版本跟进建议。做完任何一个节点,都可以回到本页确认自己在地图上的位置。
主干四阶段按顺序推进,不要跳阶段;分支选修建议在完成第三阶段后再进入,或与第四阶段并行(前置要求见 §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 等)指标定义、测量与优化建议的官方站点 |
搭配使用建议:
升级与变更信息的官方入口(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 |
四个方向互不依赖,可按兴趣或岗位需要任选;但都建议在完成第三阶段后进入,否则前置能力不足,学习成本会翻倍。
shallowRef、按需重渲染、数据采样都在这里派上用场)。<script setup>;读懂 Options API 代码是维护存量项目的加分能力,官方文档有完整章节,作为低优先级自学即可。前端生态迭代快,能力的一半是「如何跟进变化」。建议养成以下习惯:
1. 读官方 Release Notes / CHANGELOG,而不是二手转述
2. 用语义化版本(SemVer)制定升级策略
| 版本位 | 含义 | 升级动作 |
|---|---|---|
| major | 含破坏性变更 | 安排专项评估,PoC 通过后独立分支迁移并跑全量回归 |
| minor | 向后兼容的新特性 | 按需采用;安全相关的 minor 优先升级 |
| patch | 缺陷修复 | 跟随常规迭代即可 |
3. 工程上的配套纪律(升级 SOP)
pnpm outdated 盘点依赖差距,避免「三年不升级、一升动全身」;跨多个 major 的升级不直接跳,逐个 major 过渡。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 仓库浏览 →