欢迎光临
我们一直在努力

医通项目收官:排班管理系统、国际化与生产部署

本篇是医通项目完结篇,也是整个 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: unlessstopped
    healthcheck:
    test: ["CMD", "curl", "-f", "http://localhost/"]
    interval: 30s
    timeout: 10s
    retries: 3

    # 可以一起启动后端服务
    backend:
    image: yitongbackend: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: ubuntulatest
    steps:
    name: Checkout code
    uses: actions/checkout@v4

    name: Setup Node.js
    uses: actions/setupnode@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: lintandtest # 依赖 Job1 通过
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntulatest
    steps:
    uses: actions/checkout@v4

    name: Login to Docker Hub
    uses: docker/loginaction@v3
    with:
    username: ${{ secrets.DOCKER_USERNAME }}
    password: ${{ secrets.DOCKER_TOKEN }}

    name: Build and push
    uses: docker/buildpushaction@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: buildandpush
    runs-on: ubuntulatest
    steps:
    name: Deploy via SSH
    uses: appleboy/sshaction@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) manualChunkswebpack 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: ubuntulatest
    steps:
    uses: actions/checkout@v4
    uses: actions/setupnode@v4
    with:
    node-version: '20'
    cache: 'npm'

    run: npm ci
    run: npm run build
    env:
    CI: true

    # 上传体积报告为 CI artifact
    uses: actions/uploadartifact@v4
    if: always()
    with:
    name: bundlestats
    path: dist/stats.html

    # 使用 bundlesize 工具在 PR 上评论体积变化
    name: Comment bundle size
    uses: github/bundlesize@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、状态机的联动设计、构建产物的缓存策略。技术在迭代,但原理长青。愿每位同学在医通项目的基础上,构建出属于自己的工程化体系。

    赞(0)
    未经允许不得转载:171主机测评 » 医通项目收官:排班管理系统、国际化与生产部署
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址