本篇是医通项目完结篇,也是整个 React + TypeScript 课程的收官章节。从排班系统三级联动、树形数据字典,到 react-i18next 国际化、React Diff 算法与 key 的终极理解,再到 Vite 构建优化、Nginx 配置与 Docker 容器化部署,构成一条完整的企业级前端开发闭环。
一、名词解释
1.1 排班系统(Scheduling System)
排班系统是医院 HIS(Hospital Information System)的核心业务模块,用于管理医生的出诊计划。其核心特征是三级数据联动:医院 → 科室(Department) → 排班时间(Schedule),每一级数据依赖上一级的选择结果异步加载。与普通省市区联动相比,排班系统在展示层更复杂:需要按周(7天)展示排班日期、区分早/中/晚班、用颜色编码显示号源紧张程度。
1.2 数据字典(Data Dictionary)
数据字典是系统中所有枚举数据的统一管理模块,包含省市区地址、医院等级类型、科室分类、职称级别等树形结构数据。其特点是层级不固定(可能有 2-N 层),数据量大(数千条),因此采用**懒加载(Lazy Load)**策略:展开节点时才发起网络请求加载子节点,避免一次性加载全量数据。
1.3 国际化(i18n / Internationalization)
i18n 是 internationalization 的缩写(首尾字母 i 和 n,中间 18 个字母)。在前端工程中,国际化分为两个层面:
- 文案国际化:将页面中的硬编码文字替换为翻译 key,运行时根据语言设置动态映射为对应语言的文字
- 格式国际化:日期、数字、货币的格式按不同语言地区规范显示
react-i18next 是 React 生态中最主流的 i18n 解决方案,基于 i18next 核心库封装,提供 useTranslation Hook。
1.4 Diff 算法(Reconciliation Algorithm)
React 的 Diff 算法(也称协调算法 Reconciliation)是指 React 在组件重渲染时,对比新旧虚拟 DOM 树(Virtual DOM Tree),计算出最小化 DOM 操作集合的算法。React 采用启发式 O(n) 算法(而非完整 diff 的 O(n³)),其核心假设之一就是:同层级、同类型、相同 key 的节点代表同一个组件实例,因此 key 是 Diff 算法能否高效运行的关键。
1.5 代码分割(Code Splitting)
代码分割是指将打包后的 JavaScript 文件拆分成多个较小的 chunk(代码块),按需加载,从而减小首屏加载体积。Vite 通过 Rollup 的 manualChunks 配置实现精细化分包策略,将第三方库(如 React、Ant Design、i18n 资源)单独打包,利用浏览器缓存机制,只在依赖版本变更时才重新下载。
1.6 容器化部署(Containerized Deployment)
将应用及其运行环境打包成 Docker 镜像,通过容器运行的部署方式。容器化的优势是环境一致性("在我机器上能跑"问题的根本解决方案)和快速水平扩展。前端应用通常将构建产物放入 Nginx 容器提供静态服务,配合 Kubernetes 或 Docker Compose 管理多容器编排。
1.7 CI/CD(持续集成/持续部署)
- CI(Continuous Integration):每次代码提交后自动执行代码检查、单元测试、构建,确保主分支始终可用
- CD(Continuous Deployment/Delivery):构建通过后自动将新版本部署到测试环境或生产环境
GitHub Actions 是目前最流行的 CI/CD 平台之一,通过在仓库中配置 .github/workflows/*.yml 文件定义自动化流程。
1.8 SPA 兜底路由(SPA Fallback Routing)
单页应用(SPA)使用 History API 模式的路由(如 /hospital/list),这些路径在服务器上并不存在对应的文件。当用户直接访问或刷新这些 URL 时,服务器会返回 404。SPA 兜底路由的解决方案是:对所有非静态资源的请求,统一返回 index.html,让前端 JavaScript 接管路由解析。Nginx 中用 try_files 指令实现,Express 中用 app.get('*') 兜底。
二、底层原理深度解析
2.1 排班三级联动的状态机模型

三级联动的本质是一个状态机(State Machine)。每次上级选项变更时,下级状态需要经历清空 → 请求 → 填充三个阶段。
状态转移图:
[初始状态]
↓ 选择科室
[科室已选 + 加载排班日期]
↓ 日期加载完成
[科室已选 + 排班日期已加载]
↓ 选择日期
[科室已选 + 日期已选 + 加载医生列表]
↓ 医生加载完成
[完整状态:科室/日期/医生 均已选]
↑ 若重新选择科室,则从第一步重新开始(清空日期和医生)
↑ 若重新选择日期,则从第三步重新开始(清空医生列表)
↑ 若日期翻页,清空已选日期和医生列表
这种"上级变动,下级清空"的设计是防止数据错乱的关键。若不清空,可能出现用户选了内科第3天的李医生,切换到外科后医生列表仍显示内科数据的问题。
2.2 useReducer 管理复杂联动状态
当联动状态超过 3 个 useState 相互依赖时,应该升级为 useReducer。它使状态转移逻辑集中在 reducer 函数中,更易于追踪和测试。
// 排班系统状态类型定义
interface ScheduleState {
// 科室层
departments: IDepartment[]
selectedDepCode: string
// 日期层
scheduleDates: IScheduleDate[]
selectedDate: string
datePage: { current: number; total: number }
loadingDates: boolean
// 医生层
doctors: IDoctor[]
loadingDoctors: boolean
}
// Action 类型(联合类型覆盖所有状态转移)
type ScheduleAction =
| { type: 'LOAD_DEPARTMENTS_SUCCESS'; payload: IDepartment[] }
| { type: 'SELECT_DEPARTMENT'; payload: string } // 选择科室 → 清空下级
| { type: 'LOADING_DATES' }
| { type: 'LOAD_DATES_SUCCESS'; payload: { records: IScheduleDate[]; total: number } }
| { type: 'SELECT_DATE'; payload: string } // 选择日期 → 清空医生
| { type: 'LOADING_DOCTORS' }
| { type: 'LOAD_DOCTORS_SUCCESS'; payload: IDoctor[] }
| { type: 'CHANGE_DATE_PAGE'; payload: number } // 翻页 → 清空日期选择
const initialState: ScheduleState = {
departments: [],
selectedDepCode: '',
scheduleDates: [],
selectedDate: '',
datePage: { current: 1, total: 0 },
loadingDates: false,
doctors: [],
loadingDoctors: false,
}
function scheduleReducer(state: ScheduleState, action: ScheduleAction): ScheduleState {
switch (action.type) {
case 'LOAD_DEPARTMENTS_SUCCESS':
return { ...state, departments: action.payload }
case 'SELECT_DEPARTMENT':
// 选择科室时,清空所有下级状态
return {
...state,
selectedDepCode: action.payload,
scheduleDates: [],
selectedDate: '',
doctors: [],
loadingDates: true,
}
case 'LOAD_DATES_SUCCESS':
return {
...state,
scheduleDates: action.payload.records,
datePage: { ...state.datePage, total: action.payload.total },
loadingDates: false,
}
case 'SELECT_DATE':
// 选择日期时,清空医生列表
return {
...state,
selectedDate: action.payload,
doctors: [],
loadingDoctors: true,
}
case 'LOAD_DOCTORS_SUCCESS':
return { ...state, doctors: action.payload, loadingDoctors: false }
case 'CHANGE_DATE_PAGE':
// 翻页时清空日期选择和医生列表
return {
...state,
selectedDate: '',
doctors: [],
datePage: { ...state.datePage, current: action.payload },
loadingDates: true,
}
default:
return state
}
}
使用 useReducer 的组件代码变得更清晰:
// pages/Schedule/index.tsx(使用 useReducer 版本)
export default function Schedule() {
const { hoscode } = useParams<{ hoscode: string }>()
const [state, dispatch] = useReducer(scheduleReducer, initialState)
// 加载科室列表
useEffect(() => {
if (!hoscode) return
scheduleAPI.getDepartments(hoscode).then(data =>
dispatch({ type: 'LOAD_DEPARTMENTS_SUCCESS', payload: data })
)
}, [hoscode])
// 选择科室
const handleDepSelect = async (depCode: string) => {
if (depCode === state.selectedDepCode) return
dispatch({ type: 'SELECT_DEPARTMENT', payload: depCode })
const res = await scheduleAPI.getScheduleDates(hoscode!, depCode, 1, 7)
dispatch({ type: 'LOAD_DATES_SUCCESS', payload: res })
}
// 选择日期
const handleDateSelect = async (workDate: string) => {
dispatch({ type: 'SELECT_DATE', payload: workDate })
const res = await scheduleAPI.getDoctors(hoscode!, state.selectedDepCode, workDate)
dispatch({ type: 'LOAD_DOCTORS_SUCCESS', payload: res })
}
// 翻页
const handleDatePageChange = async (page: number) => {
dispatch({ type: 'CHANGE_DATE_PAGE', payload: page })
const res = await scheduleAPI.getScheduleDates(hoscode!, state.selectedDepCode, page, 7)
dispatch({ type: 'LOAD_DATES_SUCCESS', payload: res })
}
// 渲染逻辑...
}
2.3 React Diff 算法工作原理(源码级解析)
React 的 Reconciliation 核心是 reconcileChildFibers 函数,对于列表节点,它的 Diff 分两轮遍历:
第一轮(处理常见情况):
for (let i = 0; i < newChildren.length; i++) {
// 1. 从 oldFiber 链表头部取节点
// 2. 比较 key 是否相同
// - 相同:继续比较 type,相同则复用,不同则创建新节点
// - 不同:立即跳出第一轮循环
}
第二轮(处理节点移动/新增/删除):
// 将剩余 oldFiber 放入 Map(key → fiber)
const existingChildren = mapRemainingChildren(oldFiber)
for (remaining new children) {
// 在 Map 中查找同 key 的 oldFiber
if (found) {
复用节点,从 Map 中删除该 key
} else {
创建新节点
}
}
// Map 中剩余的节点 → 标记为删除
existingChildren.forEach(fiber => deleteChild(fiber))
这就是 key 的本质:key 是 Diff 算法在 Map 中查找可复用 Fiber 节点的索引。没有 key,React 只能按位置(index)匹配,导致大量无效的 DOM 操作。
2.4 index 作为 key 的具体危害
场景:表单列表,每项含输入框,初始数据 [A, B, C]
旧虚拟DOM:
<Item key=0> input value="A"
<Item key=1> input value="B"
<Item key=2> input value="C"
操作:删除第一项 A,新数据 [B, C]
新虚拟DOM:
<Item key=0> input value="B" ← React 认为这是 key=0 的节点(原来的A),只更新 props
<Item key=1> input value="C" ← React 认为这是 key=1 的节点(原来的B),只更新 props
// key=2 的节点被删除
问题:input 是非受控组件时,DOM 中的真实 input 节点被复用
key=0 的 input 实际上是 A 对应的 DOM 节点,用户在其中输入的内容未清空
结果:显示的 label 是 B,但 input 中还是 A 留下的用户输入内容!
这就是"组件状态错位"——React 更新了 props,但内部的 DOM 状态(input value)属于旧节点的残留。
2.5 react-i18next 的工作机制

i18next 维护一个全局 instance,包含:
- 语言资源(resources):按
{ locale: { namespace: { key: value } } }结构存储所有翻译 - 当前语言(language):当前激活的 locale,如
'zh' - 检测策略(detection):按优先级依次检测 URL 参数、localStorage、浏览器 Accept-Language 等
react-i18next 基于 React Context 封装,I18nextProvider 将 i18n instance 注入 Context,useTranslation() Hook 订阅语言变化,当调用 i18n.changeLanguage() 时,所有使用 useTranslation 的组件自动重渲染。
i18n.changeLanguage('en')
↓
i18next 内部更新 language 状态
↓
通知所有订阅者(React Context consumer)
↓
所有使用 useTranslation 的组件重渲染
↓
t('key') 返回英文翻译
2.6 Vite 构建原理与 Rollup 分包
Vite 生产构建基于 Rollup。manualChunks 是 Rollup 的分包策略,允许开发者指定哪些模块应被打包到同一个 chunk 中。其执行时机是在 Rollup 模块图分析完成后,分配 chunk ID 阶段。
分包的意义:
- 缓存命中率:业务代码频繁变更,第三方依赖(react、antd)几乎不变。分包后,部署新版本时用户只需重新下载业务代码,antd 等大库直接命中浏览器缓存。
- 并行加载:浏览器对同一域名有并发请求限制(HTTP/1.1 通常是 6 个),多个小 chunk 可并行下载,比单个大文件更快。
- 按需加载:配合
React.lazy+Suspense,路由级别的 chunk 只在用户访问对应路由时才下载。
三、市面实际应用
3.1 大型医疗信息系统的排班实践
好大夫在线、京东健康、微医等医疗平台的排班系统比本课程示例更复杂,工程实践上有以下差异:
| 特性 | 课程示例 | 生产环境 |
|---|---|---|
| 数据量 | 每科室几十位医生 | 每科室可能数百位医生,需虚拟滚动 |
| 日期展示 | 7天简单翻页 | 月历视图 + 周视图切换,支持跳转 |
| 号源显示 | 简单余量数字 | 实时推送(WebSocket),防止"幻象号源" |
| 预约逻辑 | 前端展示为主 | 分布式锁 + 乐观锁防止超卖 |
| 状态管理 | useState / useReducer | Zustand / Jotai 或 React Query |
号源的"超卖"问题是医疗平台的核心技术挑战。生产环境中,点击预约不能在前端直接扣减显示数量(可能有并发),而是通过后端分布式锁(Redis SETNX)或数据库乐观锁(CAS)保证原子性。前端采用乐观更新(Optimistic Update)策略先更新 UI,若后端返回失败再回滚。
3.2 企业级国际化方案对比
| 方案 | 适用场景 | 优势 | 劣势 |
|---|---|---|---|
| react-i18next | 中小型应用 | 功能完整、社区活跃 | 配置略繁琐 |
| react-intl | 需要严格 ICU 格式 | 格式化强大(数字/日期/复数) | Bundle 体积较大 |
| lingui | 大型多语言应用 | 编译时提取、类型安全 | 工具链复杂 |
| Ant Design ConfigProvider | 仅 Ant Design 项目 | 零配置,内置组件文案 | 只覆盖组件文案,不覆盖业务文案 |
字节跳动、阿里巴巴等大厂在超大型国际化项目中,通常会自研翻译平台,与 i18n 库对接,实现翻译文案的在线热更新(不需要重新部署就能修改翻译内容)。
3.3 现代前端部署架构演进
演进路径:
阶段1:纯静态托管
Nginx/Apache 直接提供静态文件
问题:API 跨域、SPA 路由 404
阶段2:BFF(Backend for Frontend)层
Node.js(Express/Koa)做中间层
静态托管 + API 代理 + SSR
本课程 Express 方案属于此阶段
阶段3:CDN + 边缘计算
静态资源上传 CDN(阿里云 OSS、AWS S3 + CloudFront)
API 网关统一处理跨域、鉴权
边缘节点(Cloudflare Workers)处理个性化逻辑
阶段4:Serverless + JAMStack
静态页面预渲染(Next.js SSG/ISR)
接口由 Serverless Function 提供
Vercel、Netlify 等平台一键部署
3.4 Docker 在前端工程中的标准实践
字节跳动、腾讯等大厂的前端部署全部容器化,标准 Dockerfile 采用多阶段构建(Multi-stage Build):
# 阶段1:构建
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production=false
COPY . .
RUN npm run build
# 阶段2:运行
# 只将构建产物复制到 Nginx 镜像,不包含 node_modules(节省 80%+ 镜像体积)
FROM nginx:1.25-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
最终镜像体积通常在 20-30MB(相比包含 node_modules 的镜像可节省 400-800MB)。
四、实战要点与常见陷阱
4.1 排班系统完整实现
// src/api/schedule.ts
export interface IDepartment {
depCode: string // 科室编码(唯一标识)
depName: string // 科室名称
intro: string // 科室介绍
}
export interface IScheduleDate {
workDate: string // 完整日期,如 "2024-11-20"
workDateMd: string // 月日格式,如 "11-20"(简洁显示用)
dayOfWeek: string // 星期,如 "周一"
docCount: number // 当日可预约医生数
status: number // 1=正常 0=停诊
}
export interface IDoctor {
id: string
docname: string
title: string // 职称:主任医师/副主任医师/主治医师
skill: string // 专长
workDate: string
workTime: number // 0=上午 1=下午 2=晚上
reservedNumber: number // 已预约
availableNumber: number // 剩余
amount: number // 挂号费(元)
}
export const scheduleAPI = {
getDepartments: (hoscode: string) =>
request.get<any, IDepartment[]>(`/admin/hosp/schedule/getDeptList/${hoscode}`),
getScheduleDates: (hoscode: string, depCode: string, page: number, limit: number) =>
request.get<any, IPageResult<IScheduleDate>>(
`/admin/hosp/schedule/getScheduleList/${page}/${limit}/${hoscode}/${depCode}`
),
getDoctors: (hoscode: string, depCode: string, workDate: string) =>
request.get<any, IDoctor[]>(
`/admin/hosp/schedule/findScheduleList/${hoscode}/${depCode}/${workDate}`
),
}
// pages/Schedule/index.tsx(完整实现)
import React, { useEffect, useReducer, useCallback } from 'react'
import { useParams } from 'react-router-dom'
import {
Card, List, Tag, Table, Pagination,
Progress, Spin, Empty, Badge
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import {
scheduleAPI,
type IDepartment,
type IScheduleDate,
type IDoctor,
} from '../../api/schedule'
import { scheduleReducer, initialState } from './reducer'
// 号源颜色和状态映射
function getAvailableStatus(available: number, total: number) {
if (available === 0) return { color: '#ff4d4f', text: '无号', status: 'error' as const }
const ratio = available / total
if (ratio < 0.2) return { color: '#fa8c16', text: '紧张', status: 'warning' as const }
return { color: '#52c41a', text: '充足', status: 'success' as const }
}
// 出诊时段映射
const workTimeMap: Record<number, { label: string; color: string }> = {
0: { label: '上午', color: 'blue' },
1: { label: '下午', color: 'orange' },
2: { label: '晚上', color: 'purple' },
}
export default function Schedule() {
const { hoscode } = useParams<{ hoscode: string }>()
const [state, dispatch] = useReducer(scheduleReducer, initialState)
// 初始化加载科室列表
useEffect(() => {
if (!hoscode) return
scheduleAPI.getDepartments(hoscode).then(data =>
dispatch({ type: 'LOAD_DEPARTMENTS_SUCCESS', payload: data })
)
}, [hoscode])
// 选择科室
const handleDepSelect = useCallback(async (depCode: string) => {
if (depCode === state.selectedDepCode) return
dispatch({ type: 'SELECT_DEPARTMENT', payload: depCode })
try {
const res = await scheduleAPI.getScheduleDates(hoscode!, depCode, 1, 7)
dispatch({ type: 'LOAD_DATES_SUCCESS', payload: res })
} catch {
dispatch({ type: 'LOADING_DATES_FAIL' })
}
}, [hoscode, state.selectedDepCode])
// 选择日期
const handleDateSelect = useCallback(async (workDate: string) => {
if (workDate === state.selectedDate) return
dispatch({ type: 'SELECT_DATE', payload: workDate })
try {
const res = await scheduleAPI.getDoctors(hoscode!, state.selectedDepCode, workDate)
dispatch({ type: 'LOAD_DOCTORS_SUCCESS', payload: res })
} catch {
dispatch({ type: 'LOADING_DOCTORS_FAIL' })
}
}, [hoscode, state.selectedDepCode, state.selectedDate])
// 日期翻页
const handleDatePageChange = useCallback(async (page: number) => {
dispatch({ type: 'CHANGE_DATE_PAGE', payload: page })
const res = await scheduleAPI.getScheduleDates(
hoscode!, state.selectedDepCode, page, 7
)
dispatch({ type: 'LOAD_DATES_SUCCESS', payload: res })
}, [hoscode, state.selectedDepCode])
// 医生列表列定义
const doctorColumns: ColumnsType<IDoctor> = [
{ title: '医生姓名', dataIndex: 'docname', width: 100 },
{ title: '职称', dataIndex: 'title', width: 110 },
{
title: '出诊时段',
dataIndex: 'workTime',
width: 90,
render: (workTime: number) => {
const info = workTimeMap[workTime] ?? { label: '未知', color: 'default' }
return <Tag color={info.color}>{info.label}</Tag>
},
},
{
title: '擅长',
dataIndex: 'skill',
ellipsis: true,
},
{
title: '号源情况',
width: 220,
render: (_, r) => {
const total = r.reservedNumber + r.availableNumber
const percent = total > 0 ? Math.round((r.reservedNumber / total) * 100) : 0
const { color, text, status } = getAvailableStatus(r.availableNumber, total)
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Progress
percent={percent}
size="small"
status={status === 'error' ? 'exception' : 'normal'}
strokeColor={status === 'warning' ? '#fa8c16' : undefined}
style={{ flex: 1 }}
/>
<Badge
color={color}
text={<span style={{ color, fontSize: 12 }}>{text}({r.availableNumber})</span>}
/>
</div>
)
},
},
{
title: '挂号费',
dataIndex: 'amount',
width: 90,
render: (amount: number) => (
<span style={{ color: '#f5222d', fontWeight: 600 }}>¥{amount}</span>
),
},
]
return (
<div style={{ display: 'flex', gap: 16, height: 'calc(100vh - 150px)' }}>
{/* 左侧:科室列表(固定宽度,可滚动) */}
<Card
title="科室列表"
style={{ width: 200, overflow: 'auto', flexShrink: 0 }}
bodyStyle={{ padding: '4px 0' }}
>
{state.departments.length === 0 ? (
<Empty description="暂无科室数据" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<List
size="small"
dataSource={state.departments}
renderItem={(dep) => (
<List.Item
style={{
cursor: 'pointer',
padding: '10px 14px',
background: state.selectedDepCode === dep.depCode ? '#e6f4ff' : 'transparent',
borderLeft: state.selectedDepCode === dep.depCode
? '3px solid #1677ff' : '3px solid transparent',
color: state.selectedDepCode === dep.depCode ? '#1677ff' : 'inherit',
fontWeight: state.selectedDepCode === dep.depCode ? 600 : 'normal',
transition: 'all 0.2s',
userSelect: 'none',
}}
onClick={() => handleDepSelect(dep.depCode)}
>
{dep.depName}
</List.Item>
)}
/>
)}
</Card>
{/* 右侧:排班日期 + 医生表格 */}
<div style={{ flex: 1, overflow: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* 引导提示 */}
{!state.selectedDepCode && (
<div style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#bfbfbf', fontSize: 16, flexDirection: 'column', gap: 12,
}}>
<span style={{ fontSize: 48 }}>🏥</span>
<span>请在左侧选择科室查看排班信息</span>
</div>
)}
{/* 排班日期卡片 */}
{state.selectedDepCode && (
<Card
title="选择出诊日期"
extra={
state.datePage.total > 7 && (
<Pagination
current={state.datePage.current}
pageSize={7}
total={state.datePage.total}
simple
size="small"
onChange={handleDatePageChange}
/>
)
}
>
<Spin spinning={state.loadingDates}>
{state.scheduleDates.length === 0 && !state.loadingDates ? (
<Empty description="该科室暂无排班" />
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
{state.scheduleDates.map((date) => (
<Tag.CheckableTag
key={date.workDate}
checked={state.selectedDate === date.workDate}
onChange={() => handleDateSelect(date.workDate)}
style={{
padding: '8px 14px',
borderRadius: 8,
border: `1px solid ${state.selectedDate === date.workDate ? '#1677ff' : '#d9d9d9'}`,
minWidth: 80,
textAlign: 'center',
cursor: date.status === 0 ? 'not-allowed' : 'pointer',
opacity: date.status === 0 ? 0.5 : 1,
}}
>
<div style={{ fontWeight: 600, fontSize: 14 }}>{date.workDateMd}</div>
<div style={{ fontSize: 12, color: '#8c8c8c' }}>{date.dayOfWeek}</div>
<div style={{ fontSize: 12 }}>
{date.status === 0 ? (
<span style={{ color: '#ff4d4f' }}>停诊</span>
) : date.docCount > 0 ? (
<span style={{ color: '#52c41a' }}>{date.docCount}位医生</span>
) : (
<span style={{ color: '#ff4d4f' }}>无排班</span>
)}
</div>
</Tag.CheckableTag>
))}
</div>
)}
</Spin>
</Card>
)}
{/* 出诊医生列表 */}
{state.selectedDate && (
<Card title={`${state.selectedDate} 出诊医生列表`}>
<Table<IDoctor>
rowKey="id"
columns={doctorColumns}
dataSource={state.doctors}
loading={state.loadingDoctors}
pagination={false}
locale={{ emptyText: '该日期暂无出诊医生' }}
/>
</Card>
)}
</div>
</div>
)
}
4.2 树形数据字典(递归懒加载)
// pages/Dict/index.tsx
import React, { useEffect, useState, useCallback } from 'react'
import { Table, Button, Space, Tag, message } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { dictAPI } from '../../api/dict'
interface IDictItem {
id: string
name: string
dictCode: string
value: string
parentId: string
hasChildren: boolean
children?: IDictItem[]
}
export default function Dict() {
const [data, setData] = useState<IDictItem[]>([])
const [loading, setLoading] = useState(false)
useEffect(() => {
setLoading(true)
dictAPI.getByParentId('0')
.then(setData)
.finally(() => setLoading(false))
}, [])
// 递归更新树中指定节点的 children
const updateTreeNode = useCallback(
(nodes: IDictItem[], targetId: string, children: IDictItem[]): IDictItem[] => {
return nodes.map((node) => {
if (node.id === targetId) {
return { ...node, children }
}
if (node.children && node.children.length > 0) {
return { ...node, children: updateTreeNode(node.children, targetId, children) }
}
return node
})
},
[]
)
// 展开行时懒加载子节点
const handleExpand = async (expanded: boolean, record: IDictItem) => {
if (!expanded || !record.hasChildren) return
// 已经加载过,不重复请求
if (record.children && record.children.length > 0) return
try {
const children = await dictAPI.getByParentId(record.id)
setData((prev) => updateTreeNode(prev, record.id, children))
} catch {
message.error('加载子节点失败')
}
}
const columns: ColumnsType<IDictItem> = [
{
title: '名称',
dataIndex: 'name',
width: 200,
render: (name: string, record) => (
<span>
{name}
{record.hasChildren && (
<Tag color="blue" style={{ marginLeft: 8, fontSize: 11 }}>
有子节点
</Tag>
)}
</span>
),
},
{ title: '编码', dataIndex: 'dictCode', width: 150 },
{ title: '值', dataIndex: 'value', width: 150 },
{ title: '父ID', dataIndex: 'parentId', width: 120 },
{
title: '操作',
width: 120,
render: (_, record) => (
<Space size="small">
<Button type="link" size="small">编辑</Button>
<Button type="link" size="small" danger>删除</Button>
</Space>
),
},
]
return (
<Table<IDictItem>
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
onExpand={handleExpand}
expandable={{
rowExpandable: (record) => record.hasChildren,
// 有子节点才显示展开图标
}}
pagination={false}
/>
)
}
陷阱:递归更新的不可变性
// 错误写法:直接修改原对象(违反 React 不可变原则)
const updateTreeNode_WRONG = (nodes: IDictItem[], targetId: string, children: IDictItem[]) => {
for (const node of nodes) {
if (node.id === targetId) {
node.children = children // 直接修改!React 无法检测到变化,不会重渲染
return nodes
}
if (node.children) updateTreeNode_WRONG(node.children, targetId, children)
}
return nodes
}
// 正确写法:每层都用 map 返回新数组,命中节点用展开运算符创建新对象
const updateTreeNode_CORRECT = (nodes: IDictItem[], targetId: string, children: IDictItem[]) => {
return nodes.map((node) => {
if (node.id === targetId) {
return { ...node, children } // 新对象
}
if (node.children?.length) {
return { ...node, children: updateTreeNode_CORRECT(node.children, targetId, children) }
}
return node // 未命中节点直接返回原引用(节省内存)
})
}
4.3 react-i18next 完整配置
// src/i18n/index.ts
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import zhCN from './locales/zh-CN.json'
import enUS from './locales/en-US.json'
i18n
.use(LanguageDetector) // 自动检测浏览器语言
.use(initReactI18next) // 注入 React 插件
.init({
resources: {
zh: { translation: zhCN },
en: { translation: enUS },
},
lng: localStorage.getItem('i18n-lang') || 'zh', // 优先使用已保存的偏好
fallbackLng: 'zh', // 找不到翻译时降级到中文
interpolation: {
escapeValue: false, // React 已经做了 XSS 防护,无需 i18n 再转义
},
detection: {
order: ['localStorage', 'navigator'], // 检测顺序
caches: ['localStorage'],
lookupLocalStorage: 'i18n-lang',
},
})
export default i18n
// src/i18n/locales/zh-CN.json
{
"common": {
"search": "搜索",
"reset": "重置",
"add": "新增",
"edit": "编辑",
"delete": "删除",
"confirm": "确认",
"cancel": "取消",
"loading": "加载中...",
"noData": "暂无数据"
},
"schedule": {
"title": "排班管理",
"selectDept": "请选择科室",
"selectDate": "请选择日期",
"morning": "上午",
"afternoon": "下午",
"evening": "晚上",
"available": "余号: {{count}}",
"full": "已满",
"suspended": "停诊",
"doctorCount": "{{count}}位医生"
},
"hospital": {
"title": "医院管理",
"name": "医院名称",
"level": "医院等级",
"address": "医院地址",
"statusOn": "已上线",
"statusOff": "已下线"
},
"nav": {
"home": "首页",
"hospital": "医院管理",
"schedule": "排班管理",
"dict": "数据字典",
"user": "用户管理"
}
}
// src/i18n/locales/en-US.json
{
"common": {
"search": "Search",
"reset": "Reset",
"add": "Add",
"edit": "Edit",
"delete": "Delete",
"confirm": "Confirm",
"cancel": "Cancel",
"loading": "Loading...",
"noData": "No Data"
},
"schedule": {
"title": "Schedule Management",
"selectDept": "Please select a department",
"selectDate": "Please select a date",
"morning": "Morning",
"afternoon": "Afternoon",
"evening": "Evening",
"available": "Available: {{count}}",
"full": "Full",
"suspended": "Suspended",
"doctorCount": "{{count}} Doctors"
},
"hospital": {
"title": "Hospital Management",
"name": "Hospital Name",
"level": "Hospital Level",
"address": "Address",
"statusOn": "Online",
"statusOff": "Offline"
},
"nav": {
"home": "Home",
"hospital": "Hospital",
"schedule": "Schedule",
"dict": "Dictionary",
"user": "Users"
}
}
// 在组件中使用 useTranslation
import { useTranslation } from 'react-i18next'
import { useCallback } from 'react'
import i18n from '../i18n'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
import 'dayjs/locale/en'
import zhCN from 'antd/locale/zh_CN'
import enUS from 'antd/locale/en_US'
// 语言切换 Hook(封装所有切换逻辑)
export function useLangSwitch() {
const { i18n: i18nInstance } = useTranslation()
const switchLang = useCallback((lang: 'zh' | 'en') => {
// 1. 切换 i18next 语言
i18nInstance.changeLanguage(lang)
// 2. 切换 dayjs locale(日期格式化)
dayjs.locale(lang === 'zh' ? 'zh-cn' : 'en')
// 3. 持久化
localStorage.setItem('i18n-lang', lang)
}, [i18nInstance])
const antdLocale = i18nInstance.language === 'zh' ? zhCN : enUS
return { switchLang, currentLang: i18nInstance.language, antdLocale }
}
// 使用示例:排班组件
function ScheduleHeader() {
const { t } = useTranslation()
const { switchLang, currentLang } = useLangSwitch()
return (
<div>
<h2>{t('schedule.title')}</h2>
{/* 动态插值 */}
<span>{t('schedule.available', { count: 12 })}</span>
{/* 按钮切换语言 */}
<button onClick={() => switchLang(currentLang === 'zh' ? 'en' : 'zh')}>
{currentLang === 'zh' ? 'EN' : '中文'}
</button>
</div>
)
}
4.4 Vite 生产构建优化
// vite.config.ts
import { defineConfig, splitVendorChunkPlugin } from 'vite'
import react from '@vitejs/plugin-react'
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig(({ mode }) => ({
plugins: [
react(),
// 打包体积分析(执行 npm run build -- --mode analyze 时生成)
mode === 'analyze' && visualizer({
open: true,
gzipSize: true,
filename: 'dist/stats.html',
}),
].filter(Boolean),
build: {
target: 'es2015',
minify: 'terser',
terserOptions: {
compress: {
drop_console: true, // 移除所有 console.log
drop_debugger: true, // 移除 debugger 语句
pure_funcs: ['console.info', 'console.debug'],
},
},
rollupOptions: {
output: {
// 精细化分包策略
manualChunks: (id) => {
// React 核心(体积小,更新频率极低)
if (id.includes('node_modules/react/') ||
id.includes('node_modules/react-dom/') ||
id.includes('node_modules/react-router')) {
return 'vendor-react'
}
// Ant Design(体积最大,但版本稳定)
if (id.includes('node_modules/antd/') ||
id.includes('node_modules/@ant-design/') ||
id.includes('node_modules/rc-')) {
return 'vendor-antd'
}
// 状态管理库
if (id.includes('node_modules/@reduxjs/') ||
id.includes('node_modules/redux') ||
id.includes('node_modules/react-redux')) {
return 'vendor-redux'
}
// i18n 资源(翻译文件可能经常更新,单独分包)
if (id.includes('node_modules/i18next') ||
id.includes('node_modules/react-i18next') ||
id.includes('/i18n/locales/')) {
return 'vendor-i18n'
}
// 工具库
if (id.includes('node_modules/axios') ||
id.includes('node_modules/dayjs') ||
id.includes('node_modules/lodash')) {
return 'vendor-utils'
}
},
// 静态资源分类输出目录
chunkFileNames: 'assets/js/[name]-[hash].js',
entryFileNames: 'assets/js/[name]-[hash].js',
assetFileNames: (assetInfo) => {
const ext = assetInfo.name?.split('.').pop() ?? ''
if (['png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'webp'].includes(ext)) {
return 'assets/images/[name]-[hash][extname]'
}
if (ext === 'css') {
return 'assets/css/[name]-[hash][extname]'
}
return 'assets/[name]-[hash][extname]'
},
},
},
// 超过 500KB 发出警告(默认 500KB)
chunkSizeWarningLimit: 800,
},
// 环境变量类型声明
envPrefix: 'VITE_',
}))
# 环境变量管理
# .env.development(开发环境,会提交到 git)
VITE_API_BASE_URL=http://localhost:3000
VITE_APP_TITLE=医通管理系统(开发)
VITE_MOCK_ENABLED=true
# .env.production(生产环境)
VITE_API_BASE_URL=https://api.yitong.com
VITE_APP_TITLE=医通管理系统
VITE_MOCK_ENABLED=false
# .env.local(本地敏感配置,加入 .gitignore!)
VITE_SECRET_KEY=your-local-key
// 在代码中使用环境变量(TypeScript 有类型提示)
// src/vite-env.d.ts
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
readonly VITE_APP_TITLE: string
readonly VITE_MOCK_ENABLED: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
// 使用
const baseURL = import.meta.env.VITE_API_BASE_URL
const isMock = import.meta.env.VITE_MOCK_ENABLED === 'true'
4.5 Nginx 生产配置
# /etc/nginx/conf.d/yitong.conf
server {
listen 80;
server_name yitong.example.com;
root /usr/share/nginx/html;
index index.html;
# Gzip 压缩配置(可减少 60-80% 传输体积)
gzip on;
gzip_vary on;
gzip_min_length 1024; # 小于 1KB 不压缩(压缩开销 > 收益)
gzip_comp_level 6; # 压缩级别 1-9,6 是性能/压缩率的平衡点
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
image/svg+xml
font/woff2;
# 静态资源缓存策略
# 带 hash 的 JS/CSS:内容不变文件名不变,可以长期缓存
location ~* \.(js|css)$ {
expires 1y;
add_header Cache-Control "public, immutable";
# immutable 告知浏览器:即使缓存未过期也不需要验证,大幅减少请求
}
# 字体和图片:较长缓存,但不用 immutable(可能需要更新)
location ~* \.(woff|woff2|ttf|eot|png|jpg|jpeg|gif|ico|svg|webp)$ {
expires 30d;
add_header Cache-Control "public";
}
# API 反向代理(适用于前后端同域部署)
location /api/ {
proxy_pass http://backend:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 禁用代理缓存
add_header Cache-Control "no-store";
}
# SPA 兜底路由(最关键的配置!)
# try_files 含义:
# $uri → 查找精确匹配的文件(如 /assets/logo.png)
# $uri/ → 查找目录下的 index.html(如 /about/)
# /index.html → 以上都未命中,返回 index.html(SPA 兜底)
location / {
try_files $uri $uri/ /index.html;
# index.html 本身不缓存(每次部署可能更新)
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# 安全响应头
add_header X-Frame-Options SAMEORIGIN;
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
add_header Referrer-Policy "strict-origin-when-cross-origin";
}
4.6 Docker 多阶段构建

# Dockerfile
# ========== 阶段1:Node.js 构建阶段 ==========
FROM node:18-alpine AS builder
# 设置工作目录
WORKDIR /app
# 优先复制依赖文件(利用 Docker 层缓存)
# 只要 package*.json 不变,npm ci 层就会被缓存,加快构建速度
COPY package.json package-lock.json ./
RUN npm ci
# 复制源代码
COPY . .
# 接受构建参数(用于传入环境变量)
ARG VITE_API_BASE_URL
ARG VITE_APP_TITLE
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
ENV VITE_APP_TITLE=$VITE_APP_TITLE
# 执行构建
RUN npm run build
# ========== 阶段2:Nginx 运行阶段 ==========
FROM nginx:1.25-alpine
# 删除默认配置
RUN rm /etc/nginx/conf.d/default.conf
# 复制 Nginx 配置
COPY nginx.conf /etc/nginx/conf.d/default.conf
# 从构建阶段复制构建产物(不包含 node_modules,镜像大幅缩小)
COPY --from=builder /app/dist /usr/share/nginx/html
# 暴露端口
EXPOSE 80
# 以非 daemon 模式启动(容器需要前台进程)
CMD ["nginx", "-g", "daemon off;"]
# docker-compose.yml(本地开发/测试环境)
version: '3.8'
services:
frontend:
build:
context: .
dockerfile: Dockerfile
args:
VITE_API_BASE_URL: https://api.yitong.com
VITE_APP_TITLE: 医通管理系统
ports:
- "80:80"
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/"]
interval: 30s
timeout: 10s
retries: 3
# 可以一起启动后端服务
backend:
image: yitong-backend:latest
ports:
- "8080:8080"
environment:
- SPRING_PROFILES_ACTIVE=prod
4.7 GitHub Actions CI/CD 流程

# .github/workflows/deploy.yml
name: Build and Deploy
on:
push:
branches: [main] # main 分支推送时触发
pull_request:
branches: [main] # PR 到 main 时触发(只运行检查,不部署)
jobs:
# JOB 1:代码质量检查
lint-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm' # 缓存 node_modules
- name: Install dependencies
run: npm ci
- name: Run ESLint
run: npm run lint
- name: Run TypeScript check
run: npx tsc --noEmit
- name: Run unit tests
run: npm test -- --coverage --watchAll=false
# JOB 2:构建并推送 Docker 镜像(仅 main 分支)
build-and-push:
needs: lint-and-test # 依赖 Job1 通过
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
push: true
tags: |
${{ secrets.DOCKER_USERNAME }}/yitong-frontend:latest
${{ secrets.DOCKER_USERNAME }}/yitong-frontend:${{ github.sha }}
build-args: |
VITE_API_BASE_URL=${{ secrets.PROD_API_URL }}
VITE_APP_TITLE=医通管理系统
# JOB 3:部署到生产服务器
deploy:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
script: |
docker pull ${{ secrets.DOCKER_USERNAME }}/yitong-frontend:latest
docker stop yitong-frontend || true
docker rm yitong-frontend || true
docker run -d \
--name yitong-frontend \
-p 80:80 \
--restart unless-stopped \
${{ secrets.DOCKER_USERNAME }}/yitong-frontend:latest
echo "部署完成!$(date)"
4.8 常见陷阱汇总
陷阱1:三级联动中忘记清空下级状态
// 错误:选择新科室时没有清空医生列表
const handleDepSelect = async (depCode: string) => {
setSelectedDep(depCode)
// 忘记 setDoctors([]) 和 setSelectedDate('')
const res = await scheduleAPI.getScheduleDates(...)
setScheduleDates(res.records)
}
// 后果:切换科室后,医生列表仍显示上一个科室的数据
// 正确:先清空所有下级状态
const handleDepSelect = async (depCode: string) => {
setSelectedDep(depCode)
setSelectedDate('') // 清空已选日期
setDoctors([]) // 清空医生列表
setScheduleDates([]) // 清空排班(等待新数据)
const res = await scheduleAPI.getScheduleDates(...)
setScheduleDates(res.records)
}
陷阱2:i18n 切换后 dayjs 日期格式没有同步变更
// 问题:只切换了 i18n 语言,忘记切换 dayjs locale
i18n.changeLanguage('en')
// 结果:界面文字变成英文,但日期选择器仍显示"一月"
// 完整切换(三者缺一不可)
const switchLanguage = (lang: 'zh' | 'en') => {
i18n.changeLanguage(lang) // 1. 切换业务文案
dayjs.locale(lang === 'zh' ? 'zh-cn' : 'en') // 2. 切换 dayjs 日期语言
// Ant Design locale 通过 ConfigProvider 响应 i18n state 自动切换(3)
}
陷阱3:Vite 分包导致循环依赖警告
// 问题:manualChunks 将某个工具函数分到 vendor 包,
// 但该函数又被业务代码引用,业务代码又被 vendor 包中的其他模块引用
// → Rollup 报循环依赖警告,甚至构建失败
// 解决:只将纯粹的第三方 node_modules 分包,不将业务代码分入 vendor 包
manualChunks: (id) => {
if (id.includes('node_modules')) {
// 仅处理 node_modules 中的模块
if (id.includes('/react/') || id.includes('/react-dom/')) return 'vendor-react'
// ...
}
// 业务代码不做手动分包,由 Rollup 自动处理
},
陷阱4:Nginx try_files 配置顺序错误
# 错误:把 /index.html 兜底放在精确匹配之前
location / {
try_files /index.html $uri $uri/;
# 后果:所有请求都返回 index.html,包括 /assets/main.js!
}
# 正确:精确文件 → 目录 → 兜底 index.html
location / {
try_files $uri $uri/ /index.html;
}
陷阱5:Docker 构建中环境变量问题
# 问题:Vite 的环境变量在构建时注入,不是运行时
# 这行在运行阶段设置环境变量,但 Vite 已经在构建时将变量值硬编码到 JS 中了
# 错误思路:想在 docker run 时动态注入
docker run -e VITE_API_BASE_URL=https://prod.api.com yitong-frontend
# 无效!JS 文件已构建完成,环境变量已固化
# 正确方案1:构建时注入(通过 ARG)
docker build --build-arg VITE_API_BASE_URL=https://prod.api.com .
# 正确方案2:运行时替换(用 envsubst)
# 将配置抽取到单独的 config.js 文件,通过 nginx 的 sub_filter 或
# 容器启动脚本(entrypoint.sh)替换占位符
五、本章小结
5.1 课程完整知识地图
| 阶段 | 天数 | 核心内容 | 关键技能 |
|---|---|---|---|
| React 基础 | Day 01-03 | JSX、事件处理、函数组件、Props、State | 理解声明式 UI,useState 驱动视图更新 |
| React 进阶 | Day 04-05 | 生命周期、数组方法、条件/列表渲染 | map/filter/reduce,Diff 与 key |
| Hooks 全家桶 | Day 06-07 | useState、useEffect、useRef、useMemo、useCallback、useReducer | 函数组件状态管理完整体系 |
| 组件通信 | Day 07-08 | Props 传递、Context、自定义 Hook、React Router v6 | 数据流设计,路由嵌套与动态参数 |
| 状态管理 | Day 09-10 | Redux Toolkit、createAsyncThunk、性能优化 | createSlice、immer、RTK Query |
| TypeScript | Day 11 | 类型系统、接口、泛型、类型推断 | React 组件与 Hooks 的类型注解 |
| Ant Design | Day 12 | 企业级 UI 组件库、Form、Table、Modal | ConfigProvider、自定义主题 |
| 医通实战(上) | Day 13-14 | 医院 CRUD、分页搜索、批量操作、省市区联动、上下线 | 完整后台管理开发模式 |
| 医通实战(下) | Day 15 | 排班三级联动、数据字典、i18n、Vite 构建、Nginx/Docker 部署 | 国际化、工程化、生产运维 |
5.2 医通项目核心实现要点回顾
| 功能模块 | 核心技术点 | 最关键的一句话 |
|---|---|---|
| 分页搜索 | 搜索时重置 current=1 | 搜索条件变了但 current 没重置,页面数据不对 |
| 批量删除 | rowSelection 受控 + DELETE 携带 body | axios.delete 用 { data: ids } 传请求体 |
| 省市区联动 | Promise.all 并行初始化 | 切换时两步清空:form 字段值 + options 数据 |
| 排班三级联动 | useReducer 状态机 | 上级变动时立即清空所有下级状态 |
| 数据字典 | 递归不可变更新 | map 返回新数组,命中节点用展开运算符创建新对象 |
| 国际化 | i18next + dayjs + ConfigProvider | 三者都需同步切换,缺一不可 |
| Vite 分包 | manualChunks 按库分包 | 只对 node_modules 分包,不对业务代码手动分 |
| Nginx 部署 | try_files + 缓存策略 | index.html 不缓存;带 hash 的资源永久缓存 |
| Docker 化 | 多阶段构建 | 环境变量在构建时注入,不是运行时 |
六、记忆口诀
排班联动三要点:选上清下是关键,状态机思想记心间。
翻页前先清选择,数据错乱不再现。
Diff 算法心法诀:key 是节点的身份证,index 做 key 麻烦缠。
增删重排用唯一 ID,强制刷新换一换。
i18n 切换三件套:文案 dayjs ConfigProvider,
忘了任一都不全,白换语言徒然。
Nginx 配置四板斧:
try_files 兜 SPA,带 hash 缓存一整年,
index.html 莫缓存,gzip 传输瘦一半。
Docker 两阶段精髓:
builder 装 node 做构建,runner 只留 nginx 轻如燕,
ARG 传参在构建时,运行时换无济于事,牢记在前。
Vite 分包三原则:
react antd 各一包,第三方库不混淆,
业务代码随 Rollup,manualChunks 别乱搅。
七、面试考点精讲(5 Q&As)
Q1:React 的 Diff 算法为什么是 O(n)?key 的作用是什么?
标准答案框架:
React 的 Diff 算法(Reconciliation)通过三个启发式假设将复杂度从 O(n³) 降到 O(n):
- 同层比较:不跨层级比较节点,只比较同一层级的兄弟节点
- 类型不同则直接替换:
<div>变<span>直接销毁整棵子树重建,不做细粒度比对 - key 标识节点身份:列表渲染中,React 用 key 在新旧节点之间建立映射关系
key 的具体作用:Diff 在处理列表时分两轮遍历。第一轮按顺序比对,遇到 key 不同则停止。第二轮将剩余旧节点放入 Map<key, fiber>,然后遍历新节点,用 key 在 Map 中查找可复用的 Fiber 节点。
如果没有 key(或用 index 做 key),React 只能按位置匹配,头部插入一个节点会导致后面所有节点都被认为是"更新",触发大量无效 DOM 操作。有了稳定的 key,头部插入只需创建一个新节点,其他节点复用原 Fiber,仅调整位置。
index 作为 key 的具体危害:当列表有增删操作时,原位置0的节点(key=0)被新数据复用,但如果该节点内部有非受控的 DOM 状态(如 input 的用户输入内容),会出现"props 更新了但 DOM 状态残留"的状态错位问题。
Q2:useReducer 和 useState 如何选择?什么情况下必须用 useReducer?
答:
useState 适用于独立的简单状态(如 isOpen、count、inputValue),每个状态相互独立,更新逻辑简单(直接赋新值)。
useReducer 适用于以下场景:
- 多个状态相互依赖:更新一个状态时需要读取另一个状态的当前值(如排班三级联动:选择科室需要同时清空日期、医生、重置分页)
- 状态转移逻辑复杂:有明确的"操作类型"和对应的状态变更规则
- 状态对象层级深:需要多处访问和更新嵌套状态
- 需要可测试性:reducer 是纯函数,可以独立于组件进行单元测试
判断标准:如果你发现在一个 setState 调用中需要读取另一个 state 的当前值(setState(prev => ...) 的参数函数需要访问其他 state),就是 useReducer 的信号。
Q3:react-i18next 的 useTranslation Hook 如何触发组件重渲染的?
答:
react-i18next 的 useTranslation Hook 内部使用 useState 持有一个版本号(或直接订阅 i18next 的事件),当 i18n.changeLanguage() 被调用时:
- i18next 触发
languageChanged事件 react-i18next的I18nextProvider(基于 React Context)更新 Context value(包含当前语言)- 所有通过
useTranslation消费此 Context 的组件,因 Context value 变化而自动重渲染 - 组件内的
t()函数重新从 i18next 资源中查找对应语言的翻译字符串并返回
这与 Redux 的 useSelector 机制类似:都是通过 Context 传递订阅机制,语言变化时批量触发所有相关组件重渲染。需要注意的是,如果大量组件都使用了 useTranslation,切换语言时会触发大量重渲染,可用 React.memo 结合不依赖语言的组件进行优化。
Q4:Vite 的 manualChunks 分包策略如何设计?与 webpack 的 SplitChunks 有何异同?
答:
设计原则:
- 按变更频率分包:变更频率低的(react、antd)单独分包,充分利用浏览器缓存
- 按体积平衡:避免单个 chunk 过大(>500KB),也避免过多过小的 chunk(增加 HTTP 请求)
- 只对 node_modules 分包:业务代码交给 Rollup 自动处理,避免手动分包引入循环依赖
经典分包策略:
vendor-react(~150KB) → react + react-dom + react-router
vendor-antd(~800KB) → antd + @ant-design/* + rc-*
vendor-redux(~50KB) → @reduxjs/toolkit + redux + react-redux
vendor-i18n(~100KB) → i18next + react-i18next + 翻译文件
vendor-utils(~200KB) → axios + dayjs + lodash
与 webpack SplitChunks 的异同:
| 特性 | Vite (Rollup) manualChunks | webpack SplitChunks |
|---|---|---|
| 配置方式 | 函数回调,根据模块 ID 返回 chunk 名 | 对象配置,声明规则和条件 |
| 自动化程度 | 需要手动枚举,更精细可控 | 默认配置即可工作,开箱即用 |
| 动态导入支持 | 原生支持(import()) | 原生支持 |
| Tree Shaking | Rollup 更彻底(ES module 静态分析) | 较好,但不如 Rollup |
| 循环依赖处理 | 需要开发者注意,不自动解决 | 有更好的循环依赖检测 |
Q5:为什么 SPA 必须配置服务端兜底路由?Hash 模式可以避免这个问题吗?
答:
History 模式需要服务端兜底的原因:
React Router 的 History 模式(createBrowserRouter)使用 HTML5 History API(pushState/replaceState)改变 URL,URL 中的路径(如 /hospital/list)对服务器来说是真实的 HTTP 路径请求。当用户直接访问或刷新这个 URL 时,浏览器向服务器发起 GET /hospital/list 请求,服务器磁盘上没有这个文件,返回 404。
解决方案:服务器配置兜底规则——所有未命中静态文件的请求,统一返回 index.html,让前端 JavaScript 加载后由 React Router 解析 URL 路径,渲染正确的页面组件。
Hash 模式(createHashRouter)确实不需要服务端兜底:Hash 模式的 URL 形如 http://example.com/#/hospital/list,# 后面的内容(Fragment)不会被发送到服务器,服务器始终只看到 GET /(根路径),而根路径对应的 index.html 是真实存在的。缺点是:
- URL 不美观(有
#) - Hash 变化不会被服务器访问日志记录
- 对 SEO 不友好(搜索引擎可能不抓取 hash 后的内容)
- 某些场景下
#会被服务端解析器截断
生产环境推荐 History 模式 + 服务端兜底,URL 更规范,SEO 更好(配合 SSR 或预渲染时尤为重要)。
八、交互式 HTML 演示
将以下代码保存为 schedule-demo.html,在浏览器中直接打开即可体验排班日历组件(纯 HTML/CSS/JS,无需任何依赖):
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>医通排班系统 - 交互演示</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f0f2f5;
min-height: 100vh;
padding: 24px;
}
.app-header {
background: #1677ff;
color: white;
padding: 16px 24px;
border-radius: 12px;
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 4px 12px rgba(22,119,255,0.3);
}
.app-header h1 { font-size: 20px; font-weight: 700; }
.lang-btn {
background: rgba(255,255,255,0.2);
color: white;
border: 1px solid rgba(255,255,255,0.4);
padding: 6px 16px;
border-radius: 20px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.lang-btn:hover { background: rgba(255,255,255,0.35); }
.layout { display: flex; gap: 16px; }
.dept-panel {
width: 180px;
background: white;
border-radius: 10px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
flex-shrink: 0;
}
.panel-title {
font-weight: 700;
font-size: 15px;
color: #262626;
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid #f0f0f0;
}
.dept-item {
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
color: #595959;
transition: all 0.15s;
border-left: 3px solid transparent;
margin-bottom: 2px;
}
.dept-item:hover { background: #f5f5f5; }
.dept-item.active {
background: #e6f4ff;
color: #1677ff;
font-weight: 600;
border-left-color: #1677ff;
}
.main-panel { flex: 1; display: flex; flex-direction: column; gap: 16px; }
.card {
background: white;
border-radius: 10px;
padding: 16px 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.card-title { font-weight: 700; font-size: 15px; color: #262626; }
/* 日期周视图 */
.week-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 8px;
}
.day-card {
border: 2px solid #e8e8e8;
border-radius: 10px;
overflow: hidden;
cursor: pointer;
transition: all 0.2s;
}
.day-card:hover { border-color: #1677ff; box-shadow: 0 2px 8px rgba(22,119,255,0.2); }
.day-card.selected {
border-color: #1677ff;
background: #e6f4ff;
}
.day-header {
background: #fafafa;
padding: 8px;
text-align: center;
border-bottom: 1px solid #f0f0f0;
}
.day-card.selected .day-header { background: #1677ff; color: white; }
.day-date { font-size: 16px; font-weight: 700; line-height: 1; }
.day-week { font-size: 11px; color: #8c8c8c; margin-top: 2px; }
.day-card.selected .day-week { color: rgba(255,255,255,0.8); }
.day-slots { padding: 6px; display: flex; flex-direction: column; gap: 4px; }
.slot-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 6px;
border-radius: 5px;
font-size: 11px;
cursor: pointer;
transition: all 0.15s;
}
.slot-item:hover { opacity: 0.8; transform: scale(0.98); }
.slot-available { background: #f6ffed; color: #389e0d; }
.slot-limited { background: #fff7e6; color: #d46b08; }
.slot-full { background: #fff1f0; color: #cf1322; cursor: not-allowed; opacity: 0.6; }
.slot-label { font-weight: 500; }
.slot-count { font-size: 10px; font-weight: 600; }
/* 预约摘要 */
.booking-summary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 10px;
padding: 16px 20px;
}
.booking-list { list-style: none; margin-top: 8px; }
.booking-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
border-bottom: 1px solid rgba(255,255,255,0.15);
font-size: 13px;
}
.booking-list li:last-child { border-bottom: none; }
.cancel-btn {
background: rgba(255,255,255,0.2);
color: white;
border: none;
padding: 2px 8px;
border-radius: 10px;
cursor: pointer;
font-size: 11px;
}
.cancel-btn:hover { background: rgba(255,255,255,0.35); }
.guide {
text-align: center;
color: #bfbfbf;
padding: 40px;
font-size: 14px;
}
.pagination-bar {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
}
.page-btn {
background: white;
border: 1px solid #d9d9d9;
padding: 4px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
transition: all 0.15s;
}
.page-btn:hover { border-color: #1677ff; color: #1677ff; }
.page-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.page-info { font-size: 12px; color: #8c8c8c; }
.dot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; margin-right: 4px; }
.dot-green { background: #52c41a; }
.dot-orange { background: #fa8c16; }
.dot-red { background: #ff4d4f; }
.legend { display: flex; gap: 16px; font-size: 12px; color: #595959; align-items: center; }
@media (max-width: 700px) {
.layout { flex-direction: column; }
.dept-panel { width: 100%; }
.week-grid { grid-template-columns: repeat(3, 1fr); }
}
</style>
</head>
<body>
<div class="app-header">
<h1 id="app-title">🏥 医通排班系统</h1>
<button class="lang-btn" onclick="toggleLang()" id="lang-btn">EN</button>
</div>
<div class="layout">
<!-- 左侧科室面板 -->
<div class="dept-panel">
<div class="panel-title" id="dept-panel-title">科室列表</div>
<div id="dept-list"></div>
</div>
<!-- 右侧主内容 -->
<div class="main-panel" id="main-panel">
<div class="guide" id="guide-text">← 请选择左侧科室查看排班</div>
</div>
</div>
<script>
// ===================== 国际化配置 =====================
const I18N = {
zh: {
appTitle: '🏥 医通排班系统',
deptPanelTitle: '科室列表',
dateCardTitle: '选择出诊日期',
guideText: '← 请选择左侧科室查看排班',
morning: '上午',
afternoon: '下午',
evening: '晚上',
available: '充足',
limited: '紧张',
full: '已满',
bookingTitle: '我的预约',
noBooking: '暂无预约',
cancelBtn: '取消',
prevWeek: '上一周',
nextWeek: '下一周',
weekLabel: ['周日','周一','周二','周三','周四','周五','周六'],
legendAvailable: '号源充足',
legendLimited: '号源紧张',
legendFull: '已满号',
countUnit: '余',
langBtnText: 'EN',
depts: ['心内科','骨科','神经内科','消化内科','呼吸科','眼科','耳鼻喉科'],
},
en: {
appTitle: '🏥 YiTong Schedule',
deptPanelTitle: 'Departments',
dateCardTitle: 'Select Appointment Date',
guideText: '← Please select a department',
morning: 'AM',
afternoon: 'PM',
evening: 'Eve',
available: 'Open',
limited: 'Limited',
full: 'Full',
bookingTitle: 'My Bookings',
noBooking: 'No bookings yet',
cancelBtn: 'Cancel',
prevWeek: 'Prev Week',
nextWeek: 'Next Week',
weekLabel: ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'],
legendAvailable: 'Available',
legendLimited: 'Limited',
legendFull: 'Full',
countUnit: 'Left',
langBtnText: '中文',
depts: ['Cardiology','Orthopedics','Neurology','Gastroenterology','Respiratory','Ophthalmology','ENT'],
}
}
// ===================== 应用状态 =====================
let state = {
lang: 'zh',
selectedDept: null, // 选中的科室索引
selectedDate: null, // 选中的日期(Date 对象)
weekOffset: 0, // 周偏移(0=本周,1=下周,...)
scheduleData: {}, // 缓存的排班数据 { 'deptIdx_dateStr': slots }
bookings: [], // 已预约列表
}
// ===================== 数据生成 =====================
function generateWeekDates(offset) {
const today = new Date()
today.setHours(0, 0, 0, 0)
const result = []
for (let i = 0; i < 7; i++) {
const d = new Date(today)
d.setDate(today.getDate() + offset * 7 + i)
result.push(d)
}
return result
}
function formatDate(date) {
return `${date.getMonth() + 1}/${date.getDate()}`
}
function formatFullDate(date) {
return `${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')}`
}
// 根据科室和日期生成伪随机排班数据(保持稳定:相同输入返回相同结果)
function getSlotData(deptIdx, date) {
const key = `${deptIdx}_${formatFullDate(date)}`
if (state.scheduleData[key]) return state.scheduleData[key]
// 使用日期 + 科室索引作为随机种子(保证数据稳定)
const seed = (date.getDate() * 31 + date.getMonth() * 7 + deptIdx * 13) % 100
const t = state.lang // 当前语言不影响数据生成
const slots = [
{ id: `${key}_0`, period: 0, total: 20 + (seed % 10), available: Math.max(0, (seed % 15) - 3) },
{ id: `${key}_1`, period: 1, total: 15 + (seed % 8), available: Math.max(0, ((seed * 3) % 12) - 2) },
{ id: `${key}_2`, period: 2, total: 10, available: (seed % 5) },
]
// 周末减少号源
if (date.getDay() === 0 || date.getDay() === 6) {
slots.forEach(s => { s.total = Math.floor(s.total * 0.6); s.available = Math.min(s.available, s.total) })
}
state.scheduleData[key] = slots
return slots
}
// ===================== 渲染函数 =====================
function t(key) {
return I18N[state.lang][key] || key
}
function getSlotClass(available, total) {
if (available === 0) return 'slot-full'
if (available / total < 0.25) return 'slot-limited'
return 'slot-available'
}
function renderDeptList() {
const depts = t('depts')
const container = document.getElementById('dept-list')
container.innerHTML = depts.map((name, idx) => `
<div class="dept-item ${state.selectedDept === idx ? 'active' : ''}"
onclick="selectDept(${idx})">
${name}
</div>
`).join('')
}
function renderWeekCalendar() {
const mainPanel = document.getElementById('main-panel')
if (state.selectedDept === null) {
mainPanel.innerHTML = `<div class="guide" id="guide-text">${t('guideText')}</div>`
return
}
const dates = generateWeekDates(state.weekOffset)
const today = new Date()
today.setHours(0, 0, 0, 0)
const weekDaysHtml = dates.map(date => {
const slots = getSlotData(state.selectedDept, date)
const isSelected = state.selectedDate && formatFullDate(date) === formatFullDate(state.selectedDate)
const isPast = date < today
const dayOfWeek = t('weekLabel')[date.getDay()]
const slotsHtml = slots.map(slot => {
const periodLabels = [t('morning'), t('afternoon'), t('evening')]
const cls = isPast ? 'slot-full' : getSlotClass(slot.available, slot.total)
const countText = slot.available > 0 ? `${slot.available} ${t('countUnit')}` : t('full')
return `
<div class="slot-item ${cls}"
onclick="${!isPast && slot.available > 0 ? `bookSlot(event,'${formatFullDate(date)}',${slot.period},${slot.id.split('_')[2] || slot.period})` : ''}">
<span class="slot-label">${periodLabels[slot.period]}</span>
<span class="slot-count">${countText}</span>
</div>
`
}).join('')
return `
<div class="day-card ${isSelected ? 'selected' : ''} ${isPast ? '' : ''}"
onclick="selectDate('${formatFullDate(date)}')">
<div class="day-header">
<div class="day-date">${formatDate(date)}</div>
<div class="day-week">${dayOfWeek}</div>
</div>
<div class="day-slots">
${slotsHtml}
</div>
</div>
`
}).join('')
const bookingsHtml = state.bookings.length === 0
? `<p style="color:rgba(255,255,255,0.7);font-size:13px;text-align:center;padding:8px 0">${t('noBooking')}</p>`
: `<ul class="booking-list">
${state.bookings.map((b, i) => `
<li>
<span>${b.dept} · ${b.date} · ${b.period}</span>
<button class="cancel-btn" onclick="cancelBooking(${i})">${t('cancelBtn')}</button>
</li>
`).join('')}
</ul>`
mainPanel.innerHTML = `
<div class="card">
<div class="card-header">
<span class="card-title">${t('dateCardTitle')}</span>
<div style="display:flex;align-items:center;gap:12px">
<div class="legend">
<span><span class="dot dot-green"></span>${t('legendAvailable')}</span>
<span><span class="dot dot-orange"></span>${t('legendLimited')}</span>
<span><span class="dot dot-red"></span>${t('legendFull')}</span>
</div>
<div class="pagination-bar">
<button class="page-btn" onclick="changeWeek(-1)" ${state.weekOffset <= 0 ? 'disabled' : ''}>${t('prevWeek')}</button>
<span class="page-info">${state.weekOffset === 0 ? (state.lang === 'zh' ? '本周' : 'This Week') : (state.lang === 'zh' ? `第${state.weekOffset+1}周` : `Week ${state.weekOffset+1}`)}</span>
<button class="page-btn" onclick="changeWeek(1)" ${state.weekOffset >= 3 ? 'disabled' : ''}>${t('nextWeek')}</button>
</div>
</div>
</div>
<div class="week-grid">
${weekDaysHtml}
</div>
</div>
<div class="booking-summary">
<div style="font-weight:700;font-size:15px;margin-bottom:4px">${t('bookingTitle')} (${state.bookings.length})</div>
${bookingsHtml}
</div>
`
}
function render() {
// 更新文案
document.getElementById('app-title').textContent = t('appTitle')
document.getElementById('lang-btn').textContent = t('langBtnText')
document.getElementById('dept-panel-title').textContent = t('deptPanelTitle')
document.title = t('appTitle')
renderDeptList()
renderWeekCalendar()
}
// ===================== 事件处理 =====================
function selectDept(idx) {
state.selectedDept = idx
state.selectedDate = null
state.weekOffset = 0
render()
}
function selectDate(dateStr) {
const [y, m, d] = dateStr.split('-').map(Number)
state.selectedDate = new Date(y, m - 1, d)
render()
}
function changeWeek(delta) {
state.weekOffset = Math.max(0, Math.min(3, state.weekOffset + delta))
state.selectedDate = null
render()
}
function bookSlot(event, dateStr, period, slotPeriod) {
event.stopPropagation()
const deptName = t('depts')[state.selectedDept]
const periodLabels = [t('morning'), t('afternoon'), t('evening')]
const periodLabel = periodLabels[period]
// 查找排班数据并扣减
const cacheKey = `${state.selectedDept}_${dateStr}`
const slots = state.scheduleData[cacheKey]
if (!slots) return
const slot = slots.find(s => s.period === period)
if (!slot || slot.available <= 0) return
// 检查是否已预约同一时段
const isDuplicate = state.bookings.some(
b => b.deptIdx === state.selectedDept && b.date === dateStr && b.period === periodLabel
)
if (isDuplicate) {
alert(state.lang === 'zh' ? '您已预约该时段!' : 'Already booked this slot!')
return
}
slot.available -= 1 // 扣减号源
state.bookings.push({
deptIdx: state.selectedDept,
dept: deptName,
date: dateStr,
period: periodLabel,
})
render()
}
function cancelBooking(idx) {
const booking = state.bookings[idx]
// 归还号源
const cacheKey = `${booking.deptIdx}_${booking.date}`
const slots = state.scheduleData[cacheKey]
if (slots) {
const periodLabels = [t('morning'), t('afternoon'), t('evening')]
const periodIdx = periodLabels.indexOf(booking.period)
// 跨语言取消时用索引匹配(更健壮的做法是存 period 索引而非文字)
const slot = slots.find((s, i) => i === (periodIdx >= 0 ? periodIdx : 0))
if (slot) slot.available += 1
}
state.bookings.splice(idx, 1)
render()
}
function toggleLang() {
state.lang = state.lang === 'zh' ? 'en' : 'zh'
// 语言切换后,已有预约的 period 文字需要更新
const periodMapZhToEn = { '上午': 'AM', '下午': 'PM', '晚上': 'Eve' }
const periodMapEnToZh = { 'AM': '上午', 'PM': '下午', 'Eve': '晚上' }
state.bookings = state.bookings.map(b => ({
...b,
dept: t('depts')[b.deptIdx],
period: state.lang === 'en'
? (periodMapZhToEn[b.period] || b.period)
: (periodMapEnToZh[b.period] || b.period),
}))
render()
}
// ===================== 初始化 =====================
render()
</script>
</body>
</html>
十、Bundle 体积分析与生产部署优化
前端性能优化的第一步是量化:你不知道自己的包多大、大在哪里,就无法有针对性地优化。本章从工具配置到优化策略,建立医通项目的完整 Bundle 分析体系。
10.1 rollup-plugin-visualizer:Vite 打包体积可视化
npm install --save-dev rollup-plugin-visualizer
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig(({ mode }) => ({
plugins: [
react(),
// 只在分析模式下启用,避免影响正常构建
mode === 'analyze' && visualizer({
open: true, // 构建完成后自动打开浏览器
filename: 'dist/stats.html',
gzipSize: true, // 显示 gzip 后体积
brotliSize: true, // 显示 brotli 后体积(Nginx 支持时更优)
template: 'treemap', // 'treemap' | 'sunburst' | 'network'
}),
].filter(Boolean),
build: {
rollupOptions: {
output: {
// 手动分包:将第三方库分到独立 chunk
manualChunks: (id) => {
// React 核心(变化极少,长期缓存)
if (id.includes('node_modules/react/') ||
id.includes('node_modules/react-dom/')) {
return 'vendor-react';
}
// Ant Design(体积大,单独分包)
if (id.includes('node_modules/antd/') ||
id.includes('node_modules/@ant-design/') ||
id.includes('node_modules/rc-')) {
return 'vendor-antd';
}
// React Router
if (id.includes('node_modules/react-router') ||
id.includes('node_modules/@remix-run/')) {
return 'vendor-router';
}
// Redux
if (id.includes('node_modules/@reduxjs/') ||
id.includes('node_modules/redux') ||
id.includes('node_modules/immer')) {
return 'vendor-redux';
}
// 其余 node_modules
if (id.includes('node_modules/')) {
return 'vendor-misc';
}
},
// chunk 文件名(含 hash 用于缓存破坏)
chunkFileNames: 'assets/js/[name]-[hash].js',
entryFileNames: 'assets/js/[name]-[hash].js',
assetFileNames: 'assets/[ext]/[name]-[hash].[ext]',
},
},
// 生成 sourcemap(用于错误追踪,不上传到生产,仅上传到 Sentry)
sourcemap: 'hidden',
// chunk 大小警告阈值(默认 500KB,调整为更严格的 300KB)
chunkSizeWarningLimit: 300,
},
}));
# package.json scripts
{
"scripts": {
"build": "tsc && vite build",
"build:analyze": "cross-env ANALYZE=true vite build --mode analyze",
"preview": "vite preview"
}
}
# 执行分析构建
npm run build:analyze
# 浏览器自动打开 dist/stats.html
# 可交互式查看每个模块占用的体积
10.2 source-map-explorer:精确的模块级分析
source-map-explorer 通过 sourcemap 反推每一行代码的来源,精度优于 visualizer。
npm install --save-dev source-map-explorer
{
"scripts": {
"analyze:source": "source-map-explorer 'dist/assets/js/*.js' --html dist/source-map-report.html"
}
}
# 先构建(需要 sourcemap)
npm run build
# 分析(交互式报告)
npx source-map-explorer 'dist/assets/js/*.js' --html dist/report.html
open dist/report.html
两者对比:
| 工具 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|
| rollup-plugin-visualizer | 集成在构建流程,无需额外步骤;Treemap/Sunburst 视图直观 | 依赖 rollup chunk 粒度,不是模块级 | 日常 CI 监控 |
| source-map-explorer | 模块级精度,能看到 lodash 的每个函数 | 需要 sourcemap,分析步骤独立 | 深度优化特定包 |
10.3 Tree-shaking 失效案例与修复
Tree-shaking 要求:ES Module 静态导入 + 无副作用。以下是医通项目的典型失效案例:
案例 1:Ant Design 全量引入
// ❌ 全量引入(即使只用了 Button)
import antd from 'antd';
const { Button } = antd;
// ✅ 按需引入(antd 5.x 默认支持 Tree-shaking)
import { Button, Table, Form } from 'antd';
// antd 5.x 的 ESM 包已支持 Tree-shaking,无需额外 babel 插件
// 验证:检查构建输出中是否包含未使用的组件
// 在 visualizer 报告中搜索 "antd/es/date-picker",如果引入了但未使用则需检查
案例 2:lodash 全量引入
// ❌ 全量引入 lodash(72KB gzip)
import _ from 'lodash';
const debounced = _.debounce(fn, 300);
// ✅ 方式 1:按需引入(最小化,但 TS 类型需额外安装)
import debounce from 'lodash/debounce';
import throttle from 'lodash/throttle';
// ✅ 方式 2:使用 lodash-es(完整 ESM,Tree-shaking 友好)
import { debounce, throttle } from 'lodash-es';
// 构建后只包含 debounce + throttle,约 2KB gzip
// ✅ 方式 3:用原生替代(最优)
const debounce = <T extends (...args: unknown[]) => unknown>(fn: T, delay: number) => {
let timer: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
};
案例 3:moment.js 语言包全量引入
// ❌ moment 包含所有语言(67KB gzip)
import moment from 'moment';
// ✅ 替换为 dayjs(2KB gzip,API 兼容)
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
dayjs.locale('zh-cn');
// vite.config.ts 中排除 moment(如果依赖中有其他包引入了 moment)
export default defineConfig({
resolve: {
alias: {
moment: 'dayjs', // 将所有 moment 引入重定向到 dayjs
},
},
});
案例 4:barrel 文件(index.ts 重导出)阻碍 Tree-shaking
// src/components/index.ts(barrel 文件)
// ❌ 重导出所有组件(即使只用了 Button,整个文件都被包含)
export * from './Button';
export * from './Table';
export * from './Modal';
// ...50 个组件
// 使用时
import { Button } from '@/components'; // 引入了全部 50 个组件!
// ✅ 方式 1:直接引入具体文件
import { Button } from '@/components/Button';
// ✅ 方式 2:配置 sideEffects(在 package.json 或 vite 中)
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
// 告诉 rollup 哪些文件无副作用,可以 Tree-shake
treeshake: {
moduleSideEffects: false, // 激进模式,全部无副作用
// 或精细控制:
// moduleSideEffects: ['src/styles/**', 'src/polyfills/**']
},
},
},
});
10.4 Bundle 体积预算与 CI 监控
// vite-bundle-budget.ts — 自定义体积预算检查插件
import type { Plugin } from 'vite';
import { statSync } from 'fs';
import { join } from 'path';
interface BudgetConfig {
maxTotalKB: number; // 所有 JS 总大小
maxChunkKB: number; // 单个 chunk 最大大小
warnOnExceed?: boolean; // true=警告,false=报错
}
export function bundleBudget(config: BudgetConfig): Plugin {
return {
name: 'bundle-budget',
closeBundle() {
const distJs = join('dist', 'assets', 'js');
let totalKB = 0;
let violations: string[] = [];
try {
const files = readdirSync(distJs).filter(f => f.endsWith('.js'));
for (const file of files) {
const sizeKB = statSync(join(distJs, file)).size / 1024;
totalKB += sizeKB;
if (sizeKB > config.maxChunkKB) {
violations.push(`${file}: ${sizeKB.toFixed(1)}KB > ${config.maxChunkKB}KB 预算`);
}
}
if (totalKB > config.maxTotalKB) {
violations.push(`总计: ${totalKB.toFixed(1)}KB > ${config.maxTotalKB}KB 预算`);
}
if (violations.length > 0) {
const msg = `Bundle 超出体积预算:\n${violations.join('\n')}`;
if (config.warnOnExceed) {
console.warn(`\x1b[33m⚠ ${msg}\x1b[0m`);
} else {
throw new Error(msg);
}
} else {
console.log(`\x1b[32m✓ Bundle 体积检查通过,总计 ${totalKB.toFixed(1)}KB\x1b[0m`);
}
} catch (e) {
if (e instanceof Error && e.message.includes('预算')) throw e;
// dist/assets/js 不存在时跳过
}
},
};
}
// 在 vite.config.ts 中使用
import { bundleBudget } from './vite-bundle-budget';
export default defineConfig({
plugins: [
react(),
bundleBudget({
maxTotalKB: 500, // 总 JS ≤ 500KB(gzip 后约 150KB)
maxChunkKB: 200, // 单 chunk ≤ 200KB
warnOnExceed: process.env.CI === 'true' ? false : true,
}),
],
});
# .github/workflows/bundle-check.yml
name: Bundle Size Check
on: [pull_request]
jobs:
bundle:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run build
env:
CI: true
# 上传体积报告为 CI artifact
- uses: actions/upload-artifact@v4
if: always()
with:
name: bundle-stats
path: dist/stats.html
# 使用 bundlesize 工具在 PR 上评论体积变化
- name: Comment bundle size
uses: github/bundle-size@v1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
files: dist/assets/js/*.js
10.5 Nginx 生产部署优化
# /etc/nginx/sites-available/yitong.conf
server {
listen 80;
server_name yitong.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name yitong.example.com;
ssl_certificate /etc/ssl/certs/yitong.crt;
ssl_certificate_key /etc/ssl/private/yitong.key;
root /var/www/yitong/dist;
index index.html;
# ━━━━ Brotli/Gzip 压缩 ━━━━
brotli on;
brotli_comp_level 6;
brotli_types text/plain text/css application/javascript application/json image/svg+xml;
gzip on;
gzip_vary on;
gzip_types text/plain text/css application/javascript application/json;
# ━━━━ 静态资源长期缓存(hash 命名,内容不变 URL 不变)━━━━
location ~* \.(?:js|css|woff2|png|jpg|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
add_header X-Content-Type-Options nosniff;
}
# ━━━━ HTML 不缓存(每次请求最新版本)━━━━
location = /index.html {
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
# ━━━━ React Router — 所有路径回退到 index.html ━━━━
location / {
try_files $uri $uri/ /index.html;
}
# ━━━━ 安全响应头 ━━━━
add_header X-Frame-Options SAMEORIGIN;
add_header X-XSS-Protection "1; mode=block";
add_header Referrer-Policy "strict-origin-when-cross-origin";
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()";
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;";
}
10.6 生产部署 Checklist
| 检查项 | 命令/工具 | 目标值 |
|---|---|---|
| 总 JS 体积(gzip) | du -sh dist/assets/js/ | ≤ 150KB gzip |
| 最大单 chunk | visualizer 报告 | ≤ 200KB raw |
| Lighthouse 性能分 | npx lighthouse https://... | ≥ 90 |
| LCP(最大内容绘制) | Lighthouse / WebVitals | ≤ 2.5s |
| FID/INP(交互延迟) | Lighthouse | ≤ 100ms |
| CLS(布局偏移) | Lighthouse | ≤ 0.1 |
| Tree-shaking 验证 | visualizer — 搜索未用模块 | 无全量引入 |
| Sourcemap 未暴露 | curl https://.../main.js.map | 404 |
| 敏感信息未泄露 | grep -r "SECRET" dist/ | 0 条匹配 |
| HTTP/2 已启用 | curl -I --http2 https://... | HTTP/2 200 |
| Brotli 压缩 | curl -H "Accept-Encoding: br" -I | content-encoding: br |
九、参考资料
官方文档
-
React 官方文档 - Reconciliation(协调/Diff 算法)
https://legacy.reactjs.org/docs/reconciliation.html -
React 官方文档 - useReducer
https://react.dev/reference/react/useReducer -
react-i18next 官方文档
https://react.i18next.com/ -
i18next 官方文档(含插值、复数、命名空间)
https://www.i18next.com/translation-function/interpolation -
Vite 构建选项 - rollupOptions.output.manualChunks
https://vitejs.dev/config/build-options.html#build-rollupoptions -
Nginx 文档 - try_files 指令
https://nginx.org/en/docs/http/ngx_http_core_module.html#try_files -
Docker 官方文档 - 多阶段构建(Multi-stage builds)
https://docs.docker.com/build/building/multi-stage/ -
GitHub Actions 官方文档
https://docs.github.com/en/actions
延伸阅读
-
React Fiber 架构深度解析(React 官方博客)
https://github.com/acdlite/react-fiber-architecture -
Ant Design 国际化配置
https://ant.design/docs/react/i18n-cn -
rollup-plugin-visualizer - 打包体积分析工具
https://github.com/btd/rollup-plugin-visualizer -
dayjs 国际化(locale)
https://day.js.org/docs/en/i18n/i18n -
《深入浅出 React 和 Redux》- 程墨 - 推荐阅读第9章:性能优化与 Diff 算法
-
React 性能优化实践(官方 DevTools Profiler 使用指南)
https://react.dev/learn/react-developer-tools -
Nginx 最佳实践配置(Mozilla SSL 配置生成器)
https://ssl-config.mozilla.org/
课程完结语:从第一行 JSX 代码到今天完整的排班管理系统上线部署,这15天覆盖了一个中级前端工程师所需的核心技能栈。React + TypeScript + Ant Design 只是工具,真正的能力是透过这些工具看到底层原理——Fiber 树的 Diff、状态机的联动设计、构建产物的缓存策略。技术在迭代,但原理长青。愿每位同学在医通项目的基础上,构建出属于自己的工程化体系。

1969

被折叠的 条评论
为什么被折叠?



