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

本篇是医通项目完结篇,也是整个 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 阶段。

分包的意义

  1. 缓存命中率:业务代码频繁变更,第三方依赖(react、antd)几乎不变。分包后,部署新版本时用户只需重新下载业务代码,antd 等大库直接命中浏览器缓存。
  2. 并行加载:浏览器对同一域名有并发请求限制(HTTP/1.1 通常是 6 个),多个小 chunk 可并行下载,比单个大文件更快。
  3. 按需加载:配合 React.lazy + Suspense,路由级别的 chunk 只在用户访问对应路由时才下载。

三、市面实际应用

3.1 大型医疗信息系统的排班实践

好大夫在线、京东健康、微医等医疗平台的排班系统比本课程示例更复杂,工程实践上有以下差异:

特性课程示例生产环境
数据量每科室几十位医生每科室可能数百位医生,需虚拟滚动
日期展示7天简单翻页月历视图 + 周视图切换,支持跳转
号源显示简单余量数字实时推送(WebSocket),防止"幻象号源"
预约逻辑前端展示为主分布式锁 + 乐观锁防止超卖
状态管理useState / useReducerZustand / 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-03JSX、事件处理、函数组件、Props、State理解声明式 UI,useState 驱动视图更新
React 进阶Day 04-05生命周期、数组方法、条件/列表渲染map/filter/reduce,Diff 与 key
Hooks 全家桶Day 06-07useState、useEffect、useRef、useMemo、useCallback、useReducer函数组件状态管理完整体系
组件通信Day 07-08Props 传递、Context、自定义 Hook、React Router v6数据流设计,路由嵌套与动态参数
状态管理Day 09-10Redux Toolkit、createAsyncThunk、性能优化createSlice、immer、RTK Query
TypeScriptDay 11类型系统、接口、泛型、类型推断React 组件与 Hooks 的类型注解
Ant DesignDay 12企业级 UI 组件库、Form、Table、ModalConfigProvider、自定义主题
医通实战(上)Day 13-14医院 CRUD、分页搜索、批量操作、省市区联动、上下线完整后台管理开发模式
医通实战(下)Day 15排班三级联动、数据字典、i18n、Vite 构建、Nginx/Docker 部署国际化、工程化、生产运维

5.2 医通项目核心实现要点回顾

功能模块核心技术点最关键的一句话
分页搜索搜索时重置 current=1搜索条件变了但 current 没重置,页面数据不对
批量删除rowSelection 受控 + DELETE 携带 bodyaxios.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):

  1. 同层比较:不跨层级比较节点,只比较同一层级的兄弟节点
  2. 类型不同则直接替换<div><span> 直接销毁整棵子树重建,不做细粒度比对
  3. 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 适用于独立的简单状态(如 isOpencountinputValue),每个状态相互独立,更新逻辑简单(直接赋新值)。

useReducer 适用于以下场景:

  1. 多个状态相互依赖:更新一个状态时需要读取另一个状态的当前值(如排班三级联动:选择科室需要同时清空日期、医生、重置分页)
  2. 状态转移逻辑复杂:有明确的"操作类型"和对应的状态变更规则
  3. 状态对象层级深:需要多处访问和更新嵌套状态
  4. 需要可测试性:reducer 是纯函数,可以独立于组件进行单元测试

判断标准:如果你发现在一个 setState 调用中需要读取另一个 state 的当前值(setState(prev => ...) 的参数函数需要访问其他 state),就是 useReducer 的信号。


Q3:react-i18next 的 useTranslation Hook 如何触发组件重渲染的?

react-i18nextuseTranslation Hook 内部使用 useState 持有一个版本号(或直接订阅 i18next 的事件),当 i18n.changeLanguage() 被调用时:

  1. i18next 触发 languageChanged 事件
  2. react-i18nextI18nextProvider(基于 React Context)更新 Context value(包含当前语言)
  3. 所有通过 useTranslation 消费此 Context 的组件,因 Context value 变化而自动重渲染
  4. 组件内的 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 ShakingRollup 更彻底(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 是真实存在的。缺点是:

  1. URL 不美观(有 #
  2. Hash 变化不会被服务器访问日志记录
  3. 对 SEO 不友好(搜索引擎可能不抓取 hash 后的内容)
  4. 某些场景下 # 会被服务端解析器截断

生产环境推荐 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
最大单 chunkvisualizer 报告≤ 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.map404
敏感信息未泄露grep -r "SECRET" dist/0 条匹配
HTTP/2 已启用curl -I --http2 https://...HTTP/2 200
Brotli 压缩curl -H "Accept-Encoding: br" -Icontent-encoding: br

九、参考资料

官方文档

  1. React 官方文档 - Reconciliation(协调/Diff 算法)
    https://legacy.reactjs.org/docs/reconciliation.html

  2. React 官方文档 - useReducer
    https://react.dev/reference/react/useReducer

  3. react-i18next 官方文档
    https://react.i18next.com/

  4. i18next 官方文档(含插值、复数、命名空间)
    https://www.i18next.com/translation-function/interpolation

  5. Vite 构建选项 - rollupOptions.output.manualChunks
    https://vitejs.dev/config/build-options.html#build-rollupoptions

  6. Nginx 文档 - try_files 指令
    https://nginx.org/en/docs/http/ngx_http_core_module.html#try_files

  7. Docker 官方文档 - 多阶段构建(Multi-stage builds)
    https://docs.docker.com/build/building/multi-stage/

  8. GitHub Actions 官方文档
    https://docs.github.com/en/actions

延伸阅读

  1. React Fiber 架构深度解析(React 官方博客)
    https://github.com/acdlite/react-fiber-architecture

  2. Ant Design 国际化配置
    https://ant.design/docs/react/i18n-cn

  3. rollup-plugin-visualizer - 打包体积分析工具
    https://github.com/btd/rollup-plugin-visualizer

  4. dayjs 国际化(locale)
    https://day.js.org/docs/en/i18n/i18n

  5. 《深入浅出 React 和 Redux》- 程墨 - 推荐阅读第9章:性能优化与 Diff 算法

  6. React 性能优化实践(官方 DevTools Profiler 使用指南)
    https://react.dev/learn/react-developer-tools

  7. Nginx 最佳实践配置(Mozilla SSL 配置生成器)
    https://ssl-config.mozilla.org/


课程完结语:从第一行 JSX 代码到今天完整的排班管理系统上线部署,这15天覆盖了一个中级前端工程师所需的核心技能栈。React + TypeScript + Ant Design 只是工具,真正的能力是透过这些工具看到底层原理——Fiber 树的 Diff、状态机的联动设计、构建产物的缓存策略。技术在迭代,但原理长青。愿每位同学在医通项目的基础上,构建出属于自己的工程化体系。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值