欢迎光临
我们一直在努力

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

本篇是医通项目完结篇,也是整个 React + TypeScript 课程的收官章节。从排班系统三级联动、树形数据字典,到 react-i18next 国际化、React Diff 算法与 key 的终极理解,再到 Vite 构建优化、Nginx 配置与 Docker 容器化部署,构成一条完整的企业级前端开发闭环。


一、名词解释

1.1 排班系统(Scheduling System)

排班系统是医院 HIS(Hospital Information System)的核心业务模块,用于管理医生的出诊计划。其核心特征是三级数据联动:医院 → 科室(Department) → 排班时间(Schedule),每一级数据依赖上一级的选择结果异步加载。与普通省市区联动相比,排班系统在展示层更复杂:需要按周(7天)展示排班日期、区分早/中/晚班、用颜色编码显示号源紧张程度。

1.2 数据字典(Data Dictionary)

数据字典是系统中所有枚举数据的统一管理模块,包含省市区地址、医院等级类型、科室分类、职称级别等树形结构数据。其特点是层级不固定(可能有 2-N 层),数据量大(数千条),因此采用**懒加载(Lazy Load)**策略:展开节点时才发起网络请求加载子节点,避免一次性加载全量数据。

1.3 国际化(i18n / Internationalization)

i18n 是 internationalization 的缩写(首尾字母 i 和 n,中间 18 个字母)。在前端工程中,国际化分为两个层面:

  • 文案国际化:将页面中的硬编码文字替换为翻译 key,运行时根据语言设置动态映射为对应语言的文字
  • 格式国际化:日期、数字、货币的格式按不同语言地区规范显示

react-i18next 是 React 生态中最主流的 i18n 解决方案,基于 i18next 核心库封装,提供 useTranslation Hook。

1.4 Diff 算法(Reconciliation Algorithm)

React 的 Diff 算法(也称协调算法 Reconciliation)是指 React 在组件重渲染时,对比新旧虚拟 DOM 树(Virtual DOM Tree),计算出最小化 DOM 操作集合的算法。React 采用启发式 O(n) 算法(而非完整 diff 的 O(n³)),其核心假设之一就是:同层级、同类型、相同 key 的节点代表同一个组件实例,因此 key 是 Diff 算法能否高效运行的关键。

1.5 代码分割(Code Splitting)

代码分割是指将打包后的 JavaScript 文件拆分成多个较小的 chunk(代码块),按需加载,从而减小首屏加载体积。Vite 通过 Rollup 的 manualChunks 配置实现精细化分包策略,将第三方库(如 React、Ant Design、i18n 资源)单独打包,利用浏览器缓存机制,只在依赖版本变更时才重新下载。

1.6 容器化部署(Containerized Deployment)

将应用及其运行环境打包成 Docker 镜像,通过容器运行的部署方式。容器化的优势是环境一致性("在我机器上能跑"问题的根本解决方案)和快速水平扩展。前端应用通常将构建产物放入 Nginx 容器提供静态服务,配合 Kubernetes 或 Docker Compose 管理多容器编排。

1.7 CI/CD(持续集成/持续部署)

  • CI(Continuous Integration):每次代码提交后自动执行代码检查、单元测试、构建,确保主分支始终可用
  • CD(Continuous Deployment/Delivery):构建通过后自动将新版本部署到测试环境或生产环境

GitHub Actions 是目前最流行的 CI/CD 平台之一,通过在仓库中配置 .github/workflows/*.yml 文件定义自动化流程。

1.8 SPA 兜底路由(SPA Fallback Routing)

单页应用(SPA)使用 History API 模式的路由(如 /hospital/list),这些路径在服务器上并不存在对应的文件。当用户直接访问或刷新这些 URL 时,服务器会返回 404。SPA 兜底路由的解决方案是:对所有非静态资源的请求,统一返回 index.html,让前端 JavaScript 接管路由解析。Nginx 中用 try_files 指令实现,Express 中用 app.get('*') 兜底。


二、底层原理深度解析

2.1 排班三级联动的状态机模型

在这里插入图片描述

三级联动的本质是一个状态机(State Machine)。每次上级选项变更时,下级状态需要经历清空 → 请求 → 填充三个阶段。

状态转移图:

[初始状态]
↓ 选择科室
[科室已选 + 加载排班日期]
↓ 日期加载完成
[科室已选 + 排班日期已加载]
↓ 选择日期
[科室已选 + 日期已选 + 加载医生列表]
↓ 医生加载完成
[完整状态:科室/日期/医生 均已选]

↑ 若重新选择科室,则从第一步重新开始(清空日期和医生)
↑ 若重新选择日期,则从第三步重新开始(清空医生列表)
↑ 若日期翻页,清空已选日期和医生列表

这种"上级变动,下级清空"的设计是防止数据错乱的关键。若不清空,可能出现用户选了内科第3天的李医生,切换到外科后医生列表仍显示内科数据的问题。

2.2 useReducer 管理复杂联动状态

当联动状态超过 3 个 useState 相互依赖时,应该升级为 useReducer。它使状态转移逻辑集中在 reducer 函数中,更易于追踪和测试。

// 排班系统状态类型定义
interface ScheduleState {
// 科室层
departments: IDepartment[]
selectedDepCode: string
// 日期层
scheduleDates: IScheduleDate[]
selectedDate: string
datePage: { current: number; total: number }
loadingDates: boolean
// 医生层
doctors: IDoctor[]
loadingDoctors: boolean
}

// Action 类型(联合类型覆盖所有状态转移)
type ScheduleAction =
| { type: 'LOAD_DEPARTMENTS_SUCCESS'; payload: IDepartment[] }
| { type: 'SELECT_DEPARTMENT'; payload: string } // 选择科室 → 清空下级
| { type: 'LOADING_DATES' }
| { type: 'LOAD_DATES_SUCCESS'; payload: { records: IScheduleDate[]; total: number } }
| { type: 'SELECT_DATE'; payload: string } // 选择日期 → 清空医生
| { type: 'LOADING_DOCTORS' }
| { type: 'LOAD_DOCTORS_SUCCESS'; payload: IDoctor[] }
| { type: 'CHANGE_DATE_PAGE'; payload: number } // 翻页 → 清空日期选择

const initialState: ScheduleState = {
departments: [],
selectedDepCode: '',
scheduleDates: [],
selectedDate: '',
datePage: { current: 1, total: 0 },
loadingDates: false,
doctors: [],
loadingDoctors: false,
}

function scheduleReducer(state: ScheduleState, action: ScheduleAction): ScheduleState {
switch (action.type) {
case 'LOAD_DEPARTMENTS_SUCCESS':
return { …state, departments: action.payload }

case 'SELECT_DEPARTMENT':
// 选择科室时,清空所有下级状态
return {
…state,
selectedDepCode: action.payload,
scheduleDates: [],
selectedDate: '',
doctors: [],
loadingDates: true,
}

case 'LOAD_DATES_SUCCESS':
return {
…state,
scheduleDates: action.payload.records,
datePage: { …state.datePage, total: action.payload.total },
loadingDates: false,
}

case 'SELECT_DATE':
// 选择日期时,清空医生列表
return {
…state,
selectedDate: action.payload,
doctors: [],
loadingDoctors: true,
}

case 'LOAD_DOCTORS_SUCCESS':
return { …state, doctors: action.payload, loadingDoctors: false }

case 'CHANGE_DATE_PAGE':
// 翻页时清空日期选择和医生列表
return {
…state,
selectedDate: '',
doctors: [],
datePage: { …state.datePage, current: action.payload },
loadingDates: true,
}

default:
return state
}
}

使用 useReducer 的组件代码变得更清晰:

// pages/Schedule/index.tsx(使用 useReducer 版本)
export default function Schedule() {
const { hoscode } = useParams<{ hoscode: string }>()
const [state, dispatch] = useReducer(scheduleReducer, initialState)

// 加载科室列表
useEffect(() => {
if (!hoscode) return
scheduleAPI.getDepartments(hoscode).then(data =>
dispatch({ type: 'LOAD_DEPARTMENTS_SUCCESS', payload: data })
)
}, [hoscode])

// 选择科室
const handleDepSelect = async (depCode: string) => {
if (depCode === state.selectedDepCode) return
dispatch({ type: 'SELECT_DEPARTMENT', payload: depCode })
const res = await scheduleAPI.getScheduleDates(hoscode!, depCode, 1, 7)
dispatch({ type: 'LOAD_DATES_SUCCESS', payload: res })
}

// 选择日期
const handleDateSelect = async (workDate: string) => {
dispatch({ type: 'SELECT_DATE', payload: workDate })
const res = await scheduleAPI.getDoctors(hoscode!, state.selectedDepCode, workDate)
dispatch({ type: 'LOAD_DOCTORS_SUCCESS', payload: res })
}

// 翻页
const handleDatePageChange = async (page: number) => {
dispatch({ type: 'CHANGE_DATE_PAGE', payload: page })
const res = await scheduleAPI.getScheduleDates(hoscode!, state.selectedDepCode, page, 7)
dispatch({ type: 'LOAD_DATES_SUCCESS', payload: res })
}

// 渲染逻辑…
}

2.3 React Diff 算法工作原理(源码级解析)

React 的 Reconciliation 核心是 reconcileChildFibers 函数,对于列表节点,它的 Diff 分两轮遍历:

第一轮(处理常见情况):

for (let i = 0; i < newChildren.length; i++) {
// 1. 从 oldFiber 链表头部取节点
// 2. 比较 key 是否相同
// – 相同:继续比较 type,相同则复用,不同则创建新节点
// – 不同:立即跳出第一轮循环
}

第二轮(处理节点移动/新增/删除):

// 将剩余 oldFiber 放入 Map(key → fiber)
const existingChildren = mapRemainingChildren(oldFiber)

for (remaining new children) {
// 在 Map 中查找同 key 的 oldFiber
if (found) {
复用节点,从 Map 中删除该 key
} else {
创建新节点
}
}

// Map 中剩余的节点 → 标记为删除
existingChildren.forEach(fiber => deleteChild(fiber))

这就是 key 的本质:key 是 Diff 算法在 Map 中查找可复用 Fiber 节点的索引。没有 key,React 只能按位置(index)匹配,导致大量无效的 DOM 操作。

2.4 index 作为 key 的具体危害

场景:表单列表,每项含输入框,初始数据 [A, B, C]

旧虚拟DOM:
<Item key=0> input value="A"
<Item key=1> input value="B"
<Item key=2> input value="C"

操作:删除第一项 A,新数据 [B, C]

新虚拟DOM:
<Item key=0> input value="B" ← React 认为这是 key=0 的节点(原来的A),只更新 props
<Item key=1> input value="C" ← React 认为这是 key=1 的节点(原来的B),只更新 props
// key=2 的节点被删除

问题:input 是非受控组件时,DOM 中的真实 input 节点被复用
key=0 的 input 实际上是 A 对应的 DOM 节点,用户在其中输入的内容未清空
结果:显示的 label 是 B,但 input 中还是 A 留下的用户输入内容!

这就是"组件状态错位"——React 更新了 props,但内部的 DOM 状态(input value)属于旧节点的残留。

2.5 react-i18next 的工作机制

在这里插入图片描述

i18next 维护一个全局 instance,包含:

  • 语言资源(resources):按 { locale: { namespace: { key: value } } } 结构存储所有翻译
  • 当前语言(language):当前激活的 locale,如 'zh'
  • 检测策略(detection):按优先级依次检测 URL 参数、localStorage、浏览器 Accept-Language 等

react-i18next 基于 React Context 封装,I18nextProvider 将 i18n instance 注入 Context,useTranslation() Hook 订阅语言变化,当调用 i18n.changeLanguage() 时,所有使用 useTranslation 的组件自动重渲染。

i18n.changeLanguage('en')
↓
i18next 内部更新 language 状态
↓
通知所有订阅者(React Context consumer)
↓
所有使用 useTranslation 的组件重渲染
↓
t('key') 返回英文翻译

2.6 Vite 构建原理与 Rollup 分包

Vite 生产构建基于 Rollup。manualChunks 是 Rollup 的分包策略,允许开发者指定哪些模块应被打包到同一个 chunk 中。其执行时机是在 Rollup 模块图分析完成后,分配 chunk ID 阶段。

分包的意义:

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

  • 三、市面实际应用

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

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

    特性课程示例生产环境
    数据量 每科室几十位医生 每科室可能数百位医生,需虚拟滚动
    日期展示 7天简单翻页 月历视图 + 周视图切换,支持跳转
    号源显示 简单余量数字 实时推送(WebSocket),防止"幻象号源"
    预约逻辑 前端展示为主 分布式锁 + 乐观锁防止超卖
    状态管理 useState / useReducer Zustand / Jotai 或 React Query

    号源的"超卖"问题是医疗平台的核心技术挑战。生产环境中,点击预约不能在前端直接扣减显示数量(可能有并发),而是通过后端分布式锁(Redis SETNX)或数据库乐观锁(CAS)保证原子性。前端采用乐观更新(Optimistic Update)策略先更新 UI,若后端返回失败再回滚。

    3.2 企业级国际化方案对比

    方案适用场景优势劣势
    react-i18next 中小型应用 功能完整、社区活跃 配置略繁琐
    react-intl 需要严格 ICU 格式 格式化强大(数字/日期/复数) Bundle 体积较大
    lingui 大型多语言应用 编译时提取、类型安全 工具链复杂
    Ant Design ConfigProvider 仅 Ant Design 项目 零配置,内置组件文案 只覆盖组件文案,不覆盖业务文案

    字节跳动、阿里巴巴等大厂在超大型国际化项目中,通常会自研翻译平台,与 i18n 库对接,实现翻译文案的在线热更新(不需要重新部署就能修改翻译内容)。

    3.3 现代前端部署架构演进

    演进路径:

    阶段1:纯静态托管
    Nginx/Apache 直接提供静态文件
    问题:API 跨域、SPA 路由 404

    阶段2:BFF(Backend for Frontend)层
    Node.js(Express/Koa)做中间层
    静态托管 + API 代理 + SSR
    本课程 Express 方案属于此阶段

    阶段3:CDN + 边缘计算
    静态资源上传 CDN(阿里云 OSS、AWS S3 + CloudFront)
    API 网关统一处理跨域、鉴权
    边缘节点(Cloudflare Workers)处理个性化逻辑

    阶段4:Serverless + JAMStack
    静态页面预渲染(Next.js SSG/ISR)
    接口由 Serverless Function 提供
    Vercel、Netlify 等平台一键部署

    3.4 Docker 在前端工程中的标准实践

    字节跳动、腾讯等大厂的前端部署全部容器化,标准 Dockerfile 采用多阶段构建(Multi-stage Build):

    # 阶段1:构建
    FROM node:18-alpine AS builder
    WORKDIR /app
    COPY package*.json ./
    RUN npm ci –only=production=false
    COPY . .
    RUN npm run build

    # 阶段2:运行
    # 只将构建产物复制到 Nginx 镜像,不包含 node_modules(节省 80%+ 镜像体积)
    FROM nginx:1.25-alpine
    COPY –from=builder /app/dist /usr/share/nginx/html
    COPY nginx.conf /etc/nginx/conf.d/default.conf
    EXPOSE 80
    CMD ["nginx", "-g", "daemon off;"]

    最终镜像体积通常在 20-30MB(相比包含 node_modules 的镜像可节省 400-800MB)。


    四、实战要点与常见陷阱

    4.1 排班系统完整实现

    // src/api/schedule.ts

    export interface IDepartment {
    depCode: string // 科室编码(唯一标识)
    depName: string // 科室名称
    intro: string // 科室介绍
    }

    export interface IScheduleDate {
    workDate: string // 完整日期,如 "2024-11-20"
    workDateMd: string // 月日格式,如 "11-20"(简洁显示用)
    dayOfWeek: string // 星期,如 "周一"
    docCount: number // 当日可预约医生数
    status: number // 1=正常 0=停诊
    }

    export interface IDoctor {
    id: string
    docname: string
    title: string // 职称:主任医师/副主任医师/主治医师
    skill: string // 专长
    workDate: string
    workTime: number // 0=上午 1=下午 2=晚上
    reservedNumber: number // 已预约
    availableNumber: number // 剩余
    amount: number // 挂号费(元)
    }

    export const scheduleAPI = {
    getDepartments: (hoscode: string) =>
    request.get<any, IDepartment[]>(`/admin/hosp/schedule/getDeptList/${hoscode}`),

    getScheduleDates: (hoscode: string, depCode: string, page: number, limit: number) =>
    request.get<any, IPageResult<IScheduleDate>>(
    `/admin/hosp/schedule/getScheduleList/${page}/${limit}/${hoscode}/${depCode}`
    ),

    getDoctors: (hoscode: string, depCode: string, workDate: string) =>
    request.get<any, IDoctor[]>(
    `/admin/hosp/schedule/findScheduleList/${hoscode}/${depCode}/${workDate}`
    ),
    }

    // pages/Schedule/index.tsx(完整实现)
    import React, { useEffect, useReducer, useCallback } from 'react'
    import { useParams } from 'react-router-dom'
    import {
    Card, List, Tag, Table, Pagination,
    Progress, Spin, Empty, Badge
    } from 'antd'
    import type { ColumnsType } from 'antd/es/table'
    import {
    scheduleAPI,
    type IDepartment,
    type IScheduleDate,
    type IDoctor,
    } from '../../api/schedule'
    import { scheduleReducer, initialState } from './reducer'

    // 号源颜色和状态映射
    function getAvailableStatus(available: number, total: number) {
    if (available === 0) return { color: '#ff4d4f', text: '无号', status: 'error' as const }
    const ratio = available / total
    if (ratio < 0.2) return { color: '#fa8c16', text: '紧张', status: 'warning' as const }
    return { color: '#52c41a', text: '充足', status: 'success' as const }
    }

    // 出诊时段映射
    const workTimeMap: Record<number, { label: string; color: string }> = {
    0: { label: '上午', color: 'blue' },
    1: { label: '下午', color: 'orange' },
    2: { label: '晚上', color: 'purple' },
    }

    export default function Schedule() {
    const { hoscode } = useParams<{ hoscode: string }>()
    const [state, dispatch] = useReducer(scheduleReducer, initialState)

    // 初始化加载科室列表
    useEffect(() => {
    if (!hoscode) return
    scheduleAPI.getDepartments(hoscode).then(data =>
    dispatch({ type: 'LOAD_DEPARTMENTS_SUCCESS', payload: data })
    )
    }, [hoscode])

    // 选择科室
    const handleDepSelect = useCallback(async (depCode: string) => {
    if (depCode === state.selectedDepCode) return
    dispatch({ type: 'SELECT_DEPARTMENT', payload: depCode })
    try {
    const res = await scheduleAPI.getScheduleDates(hoscode!, depCode, 1, 7)
    dispatch({ type: 'LOAD_DATES_SUCCESS', payload: res })
    } catch {
    dispatch({ type: 'LOADING_DATES_FAIL' })
    }
    }, [hoscode, state.selectedDepCode])

    // 选择日期
    const handleDateSelect = useCallback(async (workDate: string) => {
    if (workDate === state.selectedDate) return
    dispatch({ type: 'SELECT_DATE', payload: workDate })
    try {
    const res = await scheduleAPI.getDoctors(hoscode!, state.selectedDepCode, workDate)
    dispatch({ type: 'LOAD_DOCTORS_SUCCESS', payload: res })
    } catch {
    dispatch({ type: 'LOADING_DOCTORS_FAIL' })
    }
    }, [hoscode, state.selectedDepCode, state.selectedDate])

    // 日期翻页
    const handleDatePageChange = useCallback(async (page: number) => {
    dispatch({ type: 'CHANGE_DATE_PAGE', payload: page })
    const res = await scheduleAPI.getScheduleDates(
    hoscode!, state.selectedDepCode, page, 7
    )
    dispatch({ type: 'LOAD_DATES_SUCCESS', payload: res })
    }, [hoscode, state.selectedDepCode])

    // 医生列表列定义
    const doctorColumns: ColumnsType<IDoctor> = [
    { title: '医生姓名', dataIndex: 'docname', width: 100 },
    { title: '职称', dataIndex: 'title', width: 110 },
    {
    title: '出诊时段',
    dataIndex: 'workTime',
    width: 90,
    render: (workTime: number) => {
    const info = workTimeMap[workTime] ?? { label: '未知', color: 'default' }
    return <Tag color={info.color}>{info.label}</Tag>
    },
    },
    {
    title: '擅长',
    dataIndex: 'skill',
    ellipsis: true,
    },
    {
    title: '号源情况',
    width: 220,
    render: (_, r) => {
    const total = r.reservedNumber + r.availableNumber
    const percent = total > 0 ? Math.round((r.reservedNumber / total) * 100) : 0
    const { color, text, status } = getAvailableStatus(r.availableNumber, total)
    return (
    <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
    <Progress
    percent={percent}
    size="small"
    status={status === 'error' ? 'exception' : 'normal'}
    strokeColor={status === 'warning' ? '#fa8c16' : undefined}
    style={{ flex: 1 }}
    />
    <Badge
    color={color}
    text={<span style={{ color, fontSize: 12 }}>{text}({r.availableNumber})</span>}
    />
    </div>
    )
    },
    },
    {
    title: '挂号费',
    dataIndex: 'amount',
    width: 90,
    render: (amount: number) => (
    <span style={{ color: '#f5222d', fontWeight: 600 }}>¥{amount}</span>
    ),
    },
    ]

    return (
    <div style={{ display: 'flex', gap: 16, height: 'calc(100vh – 150px)' }}>
    {/* 左侧:科室列表(固定宽度,可滚动) */}
    <Card
    title="科室列表"
    style={{ width: 200, overflow: 'auto', flexShrink: 0 }}
    bodyStyle={{ padding: '4px 0' }}
    >
    {state.departments.length === 0 ? (
    <Empty description="暂无科室数据" image={Empty.PRESENTED_IMAGE_SIMPLE} />
    ) : (
    <List
    size="small"
    dataSource={state.departments}
    renderItem={(dep) => (
    <List.Item
    style={{
    cursor: 'pointer',
    padding: '10px 14px',
    background: state.selectedDepCode === dep.depCode ? '#e6f4ff' : 'transparent',
    borderLeft: state.selectedDepCode === dep.depCode
    ? '3px solid #1677ff' : '3px solid transparent',
    color: state.selectedDepCode === dep.depCode ? '#1677ff' : 'inherit',
    fontWeight: state.selectedDepCode === dep.depCode ? 600 : 'normal',
    transition: 'all 0.2s',
    userSelect: 'none',
    }}
    onClick={() => handleDepSelect(dep.depCode)}
    >
    {dep.depName}
    </List.Item>
    )}
    />
    )}
    </Card>

    {/* 右侧:排班日期 + 医生表格 */}
    <div style={{ flex: 1, overflow: 'auto', display: 'flex', flexDirection: 'column', gap: 16 }}>
    {/* 引导提示 */}
    {!state.selectedDepCode && (
    <div style={{
    flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center',
    color: '#bfbfbf', fontSize: 16, flexDirection: 'column', gap: 12,
    }}>
    <span style={{ fontSize: 48 }}>🏥</span>
    <span>请在左侧选择科室查看排班信息</span>
    </div>
    )}

    {/* 排班日期卡片 */}
    {state.selectedDepCode && (
    <Card
    title="选择出诊日期"
    extra={
    state.datePage.total > 7 && (
    <Pagination
    current={state.datePage.current}
    pageSize={7}
    total={state.datePage.total}
    simple
    size="small"
    onChange={handleDatePageChange}
    />
    )
    }
    >
    <Spin spinning={state.loadingDates}>
    {state.scheduleDates.length === 0 && !state.loadingDates ? (
    <Empty description="该科室暂无排班" />
    ) : (
    <div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
    {state.scheduleDates.map((date) => (
    <Tag.CheckableTag
    key={date.workDate}
    checked={state.selectedDate === date.workDate}
    onChange={() => handleDateSelect(date.workDate)}
    style={{
    padding: '8px 14px',
    borderRadius: 8,
    border: `1px solid ${state.selectedDate === date.workDate ? '#1677ff' : '#d9d9d9'}`,
    minWidth: 80,
    textAlign: 'center',
    cursor: date.status === 0 ? 'not-allowed' : 'pointer',
    opacity: date.status === 0 ? 0.5 : 1,
    }}
    >
    <div style={{ fontWeight: 600, fontSize: 14 }}>{date.workDateMd}</div>
    <div style={{ fontSize: 12, color: '#8c8c8c' }}>{date.dayOfWeek}</div>
    <div style={{ fontSize: 12 }}>
    {date.status === 0 ? (
    <span style={{ color: '#ff4d4f' }}>停诊</span>
    ) : date.docCount > 0 ? (
    <span style={{ color: '#52c41a' }}>{date.docCount}位医生</span>
    ) : (
    <span style={{ color: '#ff4d4f' }}>无排班</span>
    )}
    </div>
    </Tag.CheckableTag>
    ))}
    </div>
    )}
    </Spin>
    </Card>
    )}

    {/* 出诊医生列表 */}
    {state.selectedDate && (
    <Card title={`${state.selectedDate} 出诊医生列表`}>
    <Table<IDoctor>
    rowKey="id"
    columns={doctorColumns}
    dataSource={state.doctors}
    loading={state.loadingDoctors}
    pagination={false}
    locale={{ emptyText: '该日期暂无出诊医生' }}
    />
    </Card>
    )}
    </div>
    </div>
    )
    }

    4.2 树形数据字典(递归懒加载)

    // pages/Dict/index.tsx
    import React, { useEffect, useState, useCallback } from 'react'
    import { Table, Button, Space, Tag, message } from 'antd'
    import type { ColumnsType } from 'antd/es/table'
    import { dictAPI } from '../../api/dict'

    interface IDictItem {
    id: string
    name: string
    dictCode: string
    value: string
    parentId: string
    hasChildren: boolean
    children?: IDictItem[]
    }

    export default function Dict() {
    const [data, setData] = useState<IDictItem[]>([])
    const [loading, setLoading] = useState(false)

    useEffect(() => {
    setLoading(true)
    dictAPI.getByParentId('0')
    .then(setData)
    .finally(() => setLoading(false))
    }, [])

    // 递归更新树中指定节点的 children
    const updateTreeNode = useCallback(
    (nodes: IDictItem[], targetId: string, children: IDictItem[]): IDictItem[] => {
    return nodes.map((node) => {
    if (node.id === targetId) {
    return { …node, children }
    }
    if (node.children && node.children.length > 0) {
    return { …node, children: updateTreeNode(node.children, targetId, children) }
    }
    return node
    })
    },
    []
    )

    // 展开行时懒加载子节点
    const handleExpand = async (expanded: boolean, record: IDictItem) => {
    if (!expanded || !record.hasChildren) return
    // 已经加载过,不重复请求
    if (record.children && record.children.length > 0) return

    try {
    const children = await dictAPI.getByParentId(record.id)
    setData((prev) => updateTreeNode(prev, record.id, children))
    } catch {
    message.error('加载子节点失败')
    }
    }

    const columns: ColumnsType<IDictItem> = [
    {
    title: '名称',
    dataIndex: 'name',
    width: 200,
    render: (name: string, record) => (
    <span>
    {name}
    {record.hasChildren && (
    <Tag color="blue" style={{ marginLeft: 8, fontSize: 11 }}>
    有子节点
    </Tag>
    )}
    </span>
    ),
    },
    { title: '编码', dataIndex: 'dictCode', width: 150 },
    { title: '值', dataIndex: 'value', width: 150 },
    { title: '父ID', dataIndex: 'parentId', width: 120 },
    {
    title: '操作',
    width: 120,
    render: (_, record) => (
    <Space size="small">
    <Button type="link" size="small">编辑</Button>
    <Button type="link" size="small" danger>删除</Button>
    </Space>
    ),
    },
    ]

    return (
    <Table<IDictItem>
    rowKey="id"
    columns={columns}
    dataSource={data}
    loading={loading}
    onExpand={handleExpand}
    expandable={{
    rowExpandable: (record) => record.hasChildren,
    // 有子节点才显示展开图标
    }}
    pagination={false}
    />
    )
    }

    陷阱:递归更新的不可变性

    // 错误写法:直接修改原对象(违反 React 不可变原则)
    const updateTreeNode_WRONG = (nodes: IDictItem[], targetId: string, children: IDictItem[]) => {
    for (const node of nodes) {
    if (node.id === targetId) {
    node.children = children // 直接修改!React 无法检测到变化,不会重渲染
    return nodes
    }
    if (node.children) updateTreeNode_WRONG(node.children, targetId, children)
    }
    return nodes
    }

    // 正确写法:每层都用 map 返回新数组,命中节点用展开运算符创建新对象
    const updateTreeNode_CORRECT = (nodes: IDictItem[], targetId: string, children: IDictItem[]) => {
    return nodes.map((node) => {
    if (node.id === targetId) {
    return { …node, children } // 新对象
    }
    if (node.children?.length) {
    return { …node, children: updateTreeNode_CORRECT(node.children, targetId, children) }
    }
    return node // 未命中节点直接返回原引用(节省内存)
    })
    }

    4.3 react-i18next 完整配置

    // src/i18n/index.ts
    import i18n from 'i18next'
    import { initReactI18next } from 'react-i18next'
    import LanguageDetector from 'i18next-browser-languagedetector'
    import zhCN from './locales/zh-CN.json'
    import enUS from './locales/en-US.json'

    i18n
    .use(LanguageDetector) // 自动检测浏览器语言
    .use(initReactI18next) // 注入 React 插件
    .init({
    resources: {
    zh: { translation: zhCN },
    en: { translation: enUS },
    },
    lng: localStorage.getItem('i18n-lang') || 'zh', // 优先使用已保存的偏好
    fallbackLng: 'zh', // 找不到翻译时降级到中文
    interpolation: {
    escapeValue: false, // React 已经做了 XSS 防护,无需 i18n 再转义
    },
    detection: {
    order: ['localStorage', 'navigator'], // 检测顺序
    caches: ['localStorage'],
    lookupLocalStorage: 'i18n-lang',
    },
    })

    export default i18n

    // src/i18n/locales/zh-CN.json
    {
    "common": {
    "search": "搜索",
    "reset": "重置",
    "add": "新增",
    "edit": "编辑",
    "delete": "删除",
    "confirm": "确认",
    "cancel": "取消",
    "loading": "加载中…",
    "noData": "暂无数据"
    },
    "schedule": {
    "title": "排班管理",
    "selectDept": "请选择科室",
    "selectDate": "请选择日期",
    "morning": "上午",
    "afternoon": "下午",
    "evening": "晚上",
    "available": "余号: {{count}}",
    "full": "已满",
    "suspended": "停诊",
    "doctorCount": "{{count}}位医生"
    },
    "hospital": {
    "title": "医院管理",
    "name": "医院名称",
    "level": "医院等级",
    "address": "医院地址",
    "statusOn": "已上线",
    "statusOff": "已下线"
    },
    "nav": {
    "home": "首页",
    "hospital": "医院管理",
    "schedule": "排班管理",
    "dict": "数据字典",
    "user": "用户管理"
    }
    }

    // src/i18n/locales/en-US.json
    {
    "common": {
    "search": "Search",
    "reset": "Reset",
    "add": "Add",
    "edit": "Edit",
    "delete": "Delete",
    "confirm": "Confirm",
    "cancel": "Cancel",
    "loading": "Loading…",
    "noData": "No Data"
    },
    "schedule": {
    "title": "Schedule Management",
    "selectDept": "Please select a department",
    "selectDate": "Please select a date",
    "morning": "Morning",
    "afternoon": "Afternoon",
    "evening": "Evening",
    "available": "Available: {{count}}",
    "full": "Full",
    "suspended": "Suspended",
    "doctorCount": "{{count}} Doctors"
    },
    "hospital": {
    "title": "Hospital Management",
    "name": "Hospital Name",
    "level": "Hospital Level",
    "address": "Address",
    "statusOn": "Online",
    "statusOff": "Offline"
    },
    "nav": {
    "home": "Home",
    "hospital": "Hospital",
    "schedule": "Schedule",
    "dict": "Dictionary",
    "user": "Users"
    }
    }

    // 在组件中使用 useTranslation
    import { useTranslation } from 'react-i18next'
    import { useCallback } from 'react'
    import i18n from '../i18n'
    import dayjs from 'dayjs'
    import 'dayjs/locale/zh-cn'
    import 'dayjs/locale/en'
    import zhCN from 'antd/locale/zh_CN'
    import enUS from 'antd/locale/en_US'

    // 语言切换 Hook(封装所有切换逻辑)
    export function useLangSwitch() {
    const { i18n: i18nInstance } = useTranslation()

    const switchLang = useCallback((lang: 'zh' | 'en') => {
    // 1. 切换 i18next 语言
    i18nInstance.changeLanguage(lang)
    // 2. 切换 dayjs locale(日期格式化)
    dayjs.locale(lang === 'zh' ? 'zh-cn' : 'en')
    // 3. 持久化
    localStorage.setItem('i18n-lang', lang)
    }, [i18nInstance])

    const antdLocale = i18nInstance.language === 'zh' ? zhCN : enUS

    return { switchLang, currentLang: i18nInstance.language, antdLocale }
    }

    // 使用示例:排班组件
    function ScheduleHeader() {
    const { t } = useTranslation()
    const { switchLang, currentLang } = useLangSwitch()

    return (
    <div>
    <h2>{t('schedule.title')}</h2>
    {/* 动态插值 */}
    <span>{t('schedule.available', { count: 12 })}</span>
    {/* 按钮切换语言 */}
    <button onClick={() => switchLang(currentLang === 'zh' ? 'en' : 'zh')}>
    {currentLang === 'zh' ? 'EN' : '中文'}
    </button>
    </div>
    )
    }

    4.4 Vite 生产构建优化

    // vite.config.ts
    import { defineConfig, splitVendorChunkPlugin } from 'vite'
    import react from '@vitejs/plugin-react'
    import { visualizer } from 'rollup-plugin-visualizer'

    export default defineConfig(({ mode }) => ({
    plugins: [
    react(),
    // 打包体积分析(执行 npm run build — –mode analyze 时生成)
    mode === 'analyze' && visualizer({
    open: true,
    gzipSize: true,
    filename: 'dist/stats.html',
    }),
    ].filter(Boolean),

    build: {
    target: 'es2015',
    minify: 'terser',
    terserOptions: {
    compress: {
    drop_console: true, // 移除所有 console.log
    drop_debugger: true, // 移除 debugger 语句
    pure_funcs: ['console.info', 'console.debug'],
    },
    },
    rollupOptions: {
    output: {
    // 精细化分包策略
    manualChunks: (id) => {
    // React 核心(体积小,更新频率极低)
    if (id.includes('node_modules/react/') ||
    id.includes('node_modules/react-dom/') ||
    id.includes('node_modules/react-router')) {
    return 'vendor-react'
    }
    // Ant Design(体积最大,但版本稳定)
    if (id.includes('node_modules/antd/') ||
    id.includes('node_modules/@ant-design/') ||
    id.includes('node_modules/rc-')) {
    return 'vendor-antd'
    }
    // 状态管理库
    if (id.includes('node_modules/@reduxjs/') ||
    id.includes('node_modules/redux') ||
    id.includes('node_modules/react-redux')) {
    return 'vendor-redux'
    }
    // i18n 资源(翻译文件可能经常更新,单独分包)
    if (id.includes('node_modules/i18next') ||
    id.includes('node_modules/react-i18next') ||
    id.includes('/i18n/locales/')) {
    return 'vendor-i18n'
    }
    // 工具库
    if (id.includes('node_modules/axios') ||
    id.includes('node_modules/dayjs') ||
    id.includes('node_modules/lodash')) {
    return 'vendor-utils'
    }
    },
    // 静态资源分类输出目录
    chunkFileNames: 'assets/js/[name]-[hash].js',
    entryFileNames: 'assets/js/[name]-[hash].js',
    assetFileNames: (assetInfo) => {
    const ext = assetInfo.name?.split('.').pop() ?? ''
    if (['png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'webp'].includes(ext)) {
    return 'assets/images/[name]-[hash][extname]'
    }
    if (ext === 'css') {
    return 'assets/css/[name]-[hash][extname]'
    }
    return 'assets/[name]-[hash][extname]'
    },
    },
    },
    // 超过 500KB 发出警告(默认 500KB)
    chunkSizeWarningLimit: 800,
    },

    // 环境变量类型声明
    envPrefix: 'VITE_',
    }))

    # 环境变量管理
    # .env.development(开发环境,会提交到 git)
    VITE_API_BASE_URL=http://localhost:3000
    VITE_APP_TITLE=医通管理系统(开发)
    VITE_MOCK_ENABLED=true

    # .env.production(生产环境)
    VITE_API_BASE_URL=https://api.yitong.com
    VITE_APP_TITLE=医通管理系统
    VITE_MOCK_ENABLED=false

    # .env.local(本地敏感配置,加入 .gitignore!)
    VITE_SECRET_KEY=your-local-key

    // 在代码中使用环境变量(TypeScript 有类型提示)
    // src/vite-env.d.ts
    interface ImportMetaEnv {
    readonly VITE_API_BASE_URL: string
    readonly VITE_APP_TITLE: string
    readonly VITE_MOCK_ENABLED: string
    }

    interface ImportMeta {
    readonly env: ImportMetaEnv
    }

    // 使用
    const baseURL = import.meta.env.VITE_API_BASE_URL
    const isMock = import.meta.env.VITE_MOCK_ENABLED === 'true'

    4.5 Nginx 生产配置

    # /etc/nginx/conf.d/yitong.conf

    server {
    listen 80;
    server_name yitong.example.com;
    root /usr/share/nginx/html;
    index index.html;

    # Gzip 压缩配置(可减少 60-80% 传输体积)
    gzip on;
    gzip_vary on;
    gzip_min_length 1024; # 小于 1KB 不压缩(压缩开销 > 收益)
    gzip_comp_level 6; # 压缩级别 1-9,6 是性能/压缩率的平衡点
    gzip_types
    text/plain
    text/css
    text/javascript
    application/javascript
    application/json
    application/xml
    image/svg+xml
    font/woff2;

    # 静态资源缓存策略
    # 带 hash 的 JS/CSS:内容不变文件名不变,可以长期缓存
    location ~* \\.(js|css)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
    # immutable 告知浏览器:即使缓存未过期也不需要验证,大幅减少请求
    }

    # 字体和图片:较长缓存,但不用 immutable(可能需要更新)
    location ~* \\.(woff|woff2|ttf|eot|png|jpg|jpeg|gif|ico|svg|webp)$ {
    expires 30d;
    add_header Cache-Control "public";
    }

    # API 反向代理(适用于前后端同域部署)
    location /api/ {
    proxy_pass http://backend:8080/;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    # 禁用代理缓存
    add_header Cache-Control "no-store";
    }

    # SPA 兜底路由(最关键的配置!)
    # try_files 含义:
    # $uri → 查找精确匹配的文件(如 /assets/logo.png)
    # $uri/ → 查找目录下的 index.html(如 /about/)
    # /index.html → 以上都未命中,返回 index.html(SPA 兜底)
    location / {
    try_files $uri $uri/ /index.html;
    # index.html 本身不缓存(每次部署可能更新)
    add_header Cache-Control "no-cache, no-store, must-revalidate";
    }

    # 安全响应头
    add_header X-Frame-Options SAMEORIGIN;
    add_header X-Content-Type-Options nosniff;
    add_header X-XSS-Protection "1; mode=block";
    add_header Referrer-Policy "strict-origin-when-cross-origin";
    }

    4.6 Docker 多阶段构建

    在这里插入图片描述

    # Dockerfile

    # ========== 阶段1:Node.js 构建阶段 ==========
    FROM node:18-alpine AS builder

    # 设置工作目录
    WORKDIR /app

    # 优先复制依赖文件(利用 Docker 层缓存)
    # 只要 package*.json 不变,npm ci 层就会被缓存,加快构建速度
    COPY package.json package-lock.json ./
    RUN npm ci

    # 复制源代码
    COPY . .

    # 接受构建参数(用于传入环境变量)
    ARG VITE_API_BASE_URL
    ARG VITE_APP_TITLE
    ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
    ENV VITE_APP_TITLE=$VITE_APP_TITLE

    # 执行构建
    RUN npm run build

    # ========== 阶段2:Nginx 运行阶段 ==========
    FROM nginx:1.25-alpine

    # 删除默认配置
    RUN rm /etc/nginx/conf.d/default.conf

    # 复制 Nginx 配置
    COPY nginx.conf /etc/nginx/conf.d/default.conf

    # 从构建阶段复制构建产物(不包含 node_modules,镜像大幅缩小)
    COPY –from=builder /app/dist /usr/share/nginx/html

    # 暴露端口
    EXPOSE 80

    # 以非 daemon 模式启动(容器需要前台进程)
    CMD ["nginx", "-g", "daemon off;"]

    # docker-compose.yml(本地开发/测试环境)
    version: '3.8'

    services:
    frontend:
    build:
    context: .
    dockerfile: Dockerfile
    args:
    VITE_API_BASE_URL: https://api.yitong.com
    VITE_APP_TITLE: 医通管理系统
    ports:
    – "80:80"
    restart: unless–stopped
    healthcheck:
    test: ["CMD", "curl", "-f", "http://localhost/"]
    interval: 30s
    timeout: 10s
    retries: 3

    # 可以一起启动后端服务
    backend:
    image: yitong–backend:latest
    ports:
    – "8080:8080"
    environment:
    – SPRING_PROFILES_ACTIVE=prod

    4.7 GitHub Actions CI/CD 流程

    在这里插入图片描述

    # .github/workflows/deploy.yml
    name: Build and Deploy

    on:
    push:
    branches: [main] # main 分支推送时触发
    pull_request:
    branches: [main] # PR 到 main 时触发(只运行检查,不部署)

    jobs:
    # JOB 1:代码质量检查
    lint-and-test:
    runs-on: ubuntu–latest
    steps:
    – name: Checkout code
    uses: actions/checkout@v4

    – name: Setup Node.js
    uses: actions/setup–node@v4
    with:
    node-version: '18'
    cache: 'npm' # 缓存 node_modules

    – name: Install dependencies
    run: npm ci

    – name: Run ESLint
    run: npm run lint

    – name: Run TypeScript check
    run: npx tsc ––noEmit

    – name: Run unit tests
    run: npm test –– ––coverage ––watchAll=false

    # JOB 2:构建并推送 Docker 镜像(仅 main 分支)
    build-and-push:
    needs: lint–and–test # 依赖 Job1 通过
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu–latest
    steps:
    – uses: actions/checkout@v4

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

    – name: Build and push
    uses: docker/build–push–action@v5
    with:
    push: true
    tags: |
    ${{ secrets.DOCKER_USERNAME }}/yitong-frontend:latest
    ${{ secrets.DOCKER_USERNAME }}/yitong-frontend:${{ github.sha }}

    build-args: |
    VITE_API_BASE_URL=${{ secrets.PROD_API_URL }}
    VITE_APP_TITLE=医通管理系统

    # JOB 3:部署到生产服务器
    deploy:
    needs: build–and–push
    runs-on: ubuntu–latest
    steps:
    – name: Deploy via SSH
    uses: appleboy/ssh–action@v1
    with:
    host: ${{ secrets.SERVER_HOST }}
    username: ${{ secrets.SERVER_USER }}
    key: ${{ secrets.SERVER_SSH_KEY }}
    script: |
    docker pull ${{ secrets.DOCKER_USERNAME }}/yitong-frontend:latest
    docker stop yitong-frontend || true
    docker rm yitong-frontend || true
    docker run -d \\
    –name yitong-frontend \\
    -p 80:80 \\
    –restart unless-stopped \\
    ${{ secrets.DOCKER_USERNAME }}/yitong-frontend:latest
    echo "部署完成!$(date)"

    4.8 常见陷阱汇总

    陷阱1:三级联动中忘记清空下级状态

    // 错误:选择新科室时没有清空医生列表
    const handleDepSelect = async (depCode: string) => {
    setSelectedDep(depCode)
    // 忘记 setDoctors([]) 和 setSelectedDate('')
    const res = await scheduleAPI.getScheduleDates(…)
    setScheduleDates(res.records)
    }
    // 后果:切换科室后,医生列表仍显示上一个科室的数据

    // 正确:先清空所有下级状态
    const handleDepSelect = async (depCode: string) => {
    setSelectedDep(depCode)
    setSelectedDate('') // 清空已选日期
    setDoctors([]) // 清空医生列表
    setScheduleDates([]) // 清空排班(等待新数据)
    const res = await scheduleAPI.getScheduleDates(…)
    setScheduleDates(res.records)
    }

    陷阱2:i18n 切换后 dayjs 日期格式没有同步变更

    // 问题:只切换了 i18n 语言,忘记切换 dayjs locale
    i18n.changeLanguage('en')
    // 结果:界面文字变成英文,但日期选择器仍显示"一月"

    // 完整切换(三者缺一不可)
    const switchLanguage = (lang: 'zh' | 'en') => {
    i18n.changeLanguage(lang) // 1. 切换业务文案
    dayjs.locale(lang === 'zh' ? 'zh-cn' : 'en') // 2. 切换 dayjs 日期语言
    // Ant Design locale 通过 ConfigProvider 响应 i18n state 自动切换(3)
    }

    陷阱3:Vite 分包导致循环依赖警告

    // 问题:manualChunks 将某个工具函数分到 vendor 包,
    // 但该函数又被业务代码引用,业务代码又被 vendor 包中的其他模块引用
    // → Rollup 报循环依赖警告,甚至构建失败

    // 解决:只将纯粹的第三方 node_modules 分包,不将业务代码分入 vendor 包
    manualChunks: (id) => {
    if (id.includes('node_modules')) {
    // 仅处理 node_modules 中的模块
    if (id.includes('/react/') || id.includes('/react-dom/')) return 'vendor-react'
    // …
    }
    // 业务代码不做手动分包,由 Rollup 自动处理
    },

    陷阱4:Nginx try_files 配置顺序错误

    # 错误:把 /index.html 兜底放在精确匹配之前
    location / {
    try_files /index.html $uri $uri/;
    # 后果:所有请求都返回 index.html,包括 /assets/main.js!
    }

    # 正确:精确文件 → 目录 → 兜底 index.html
    location / {
    try_files $uri $uri/ /index.html;
    }

    陷阱5:Docker 构建中环境变量问题

    # 问题:Vite 的环境变量在构建时注入,不是运行时
    # 这行在运行阶段设置环境变量,但 Vite 已经在构建时将变量值硬编码到 JS 中了

    # 错误思路:想在 docker run 时动态注入
    docker run -e VITE_API_BASE_URL=https://prod.api.com yitong-frontend
    # 无效!JS 文件已构建完成,环境变量已固化

    # 正确方案1:构建时注入(通过 ARG)
    docker build –build-arg VITE_API_BASE_URL=https://prod.api.com .

    # 正确方案2:运行时替换(用 envsubst)
    # 将配置抽取到单独的 config.js 文件,通过 nginx 的 sub_filter 或
    # 容器启动脚本(entrypoint.sh)替换占位符


    五、本章小结

    5.1 课程完整知识地图

    阶段天数核心内容关键技能
    React 基础 Day 01-03 JSX、事件处理、函数组件、Props、State 理解声明式 UI,useState 驱动视图更新
    React 进阶 Day 04-05 生命周期、数组方法、条件/列表渲染 map/filter/reduce,Diff 与 key
    Hooks 全家桶 Day 06-07 useState、useEffect、useRef、useMemo、useCallback、useReducer 函数组件状态管理完整体系
    组件通信 Day 07-08 Props 传递、Context、自定义 Hook、React Router v6 数据流设计,路由嵌套与动态参数
    状态管理 Day 09-10 Redux Toolkit、createAsyncThunk、性能优化 createSlice、immer、RTK Query
    TypeScript Day 11 类型系统、接口、泛型、类型推断 React 组件与 Hooks 的类型注解
    Ant Design Day 12 企业级 UI 组件库、Form、Table、Modal ConfigProvider、自定义主题
    医通实战(上) Day 13-14 医院 CRUD、分页搜索、批量操作、省市区联动、上下线 完整后台管理开发模式
    医通实战(下) Day 15 排班三级联动、数据字典、i18n、Vite 构建、Nginx/Docker 部署 国际化、工程化、生产运维

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

    功能模块核心技术点最关键的一句话
    分页搜索 搜索时重置 current=1 搜索条件变了但 current 没重置,页面数据不对
    批量删除 rowSelection 受控 + DELETE 携带 body axios.delete 用 { data: ids } 传请求体
    省市区联动 Promise.all 并行初始化 切换时两步清空:form 字段值 + options 数据
    排班三级联动 useReducer 状态机 上级变动时立即清空所有下级状态
    数据字典 递归不可变更新 map 返回新数组,命中节点用展开运算符创建新对象
    国际化 i18next + dayjs + ConfigProvider 三者都需同步切换,缺一不可
    Vite 分包 manualChunks 按库分包 只对 node_modules 分包,不对业务代码手动分
    Nginx 部署 try_files + 缓存策略 index.html 不缓存;带 hash 的资源永久缓存
    Docker 化 多阶段构建 环境变量在构建时注入,不是运行时

    六、记忆口诀

    排班联动三要点:选上清下是关键,状态机思想记心间。
    翻页前先清选择,数据错乱不再现。

    Diff 算法心法诀:key 是节点的身份证,index 做 key 麻烦缠。
    增删重排用唯一 ID,强制刷新换一换。

    i18n 切换三件套:文案 dayjs ConfigProvider,
    忘了任一都不全,白换语言徒然。

    Nginx 配置四板斧:
    try_files 兜 SPA,带 hash 缓存一整年,
    index.html 莫缓存,gzip 传输瘦一半。

    Docker 两阶段精髓:
    builder 装 node 做构建,runner 只留 nginx 轻如燕,
    ARG 传参在构建时,运行时换无济于事,牢记在前。

    Vite 分包三原则:
    react antd 各一包,第三方库不混淆,
    业务代码随 Rollup,manualChunks 别乱搅。


    七、面试考点精讲(5 Q&As)

    Q1:React 的 Diff 算法为什么是 O(n)?key 的作用是什么?

    标准答案框架:

    React 的 Diff 算法(Reconciliation)通过三个启发式假设将复杂度从 O(n³) 降到 O(n):

  • 同层比较:不跨层级比较节点,只比较同一层级的兄弟节点
  • 类型不同则直接替换:<div> 变 <span> 直接销毁整棵子树重建,不做细粒度比对
  • key 标识节点身份:列表渲染中,React 用 key 在新旧节点之间建立映射关系
  • key 的具体作用:Diff 在处理列表时分两轮遍历。第一轮按顺序比对,遇到 key 不同则停止。第二轮将剩余旧节点放入 Map<key, fiber>,然后遍历新节点,用 key 在 Map 中查找可复用的 Fiber 节点。

    如果没有 key(或用 index 做 key),React 只能按位置匹配,头部插入一个节点会导致后面所有节点都被认为是"更新",触发大量无效 DOM 操作。有了稳定的 key,头部插入只需创建一个新节点,其他节点复用原 Fiber,仅调整位置。

    index 作为 key 的具体危害:当列表有增删操作时,原位置0的节点(key=0)被新数据复用,但如果该节点内部有非受控的 DOM 状态(如 input 的用户输入内容),会出现"props 更新了但 DOM 状态残留"的状态错位问题。


    Q2:useReducer 和 useState 如何选择?什么情况下必须用 useReducer?

    答:

    useState 适用于独立的简单状态(如 isOpen、count、inputValue),每个状态相互独立,更新逻辑简单(直接赋新值)。

    useReducer 适用于以下场景:

  • 多个状态相互依赖:更新一个状态时需要读取另一个状态的当前值(如排班三级联动:选择科室需要同时清空日期、医生、重置分页)
  • 状态转移逻辑复杂:有明确的"操作类型"和对应的状态变更规则
  • 状态对象层级深:需要多处访问和更新嵌套状态
  • 需要可测试性:reducer 是纯函数,可以独立于组件进行单元测试
  • 判断标准:如果你发现在一个 setState 调用中需要读取另一个 state 的当前值(setState(prev => …) 的参数函数需要访问其他 state),就是 useReducer 的信号。


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

    答:

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

  • i18next 触发 languageChanged 事件
  • react-i18next 的 I18nextProvider(基于 React Context)更新 Context value(包含当前语言)
  • 所有通过 useTranslation 消费此 Context 的组件,因 Context value 变化而自动重渲染
  • 组件内的 t() 函数重新从 i18next 资源中查找对应语言的翻译字符串并返回
  • 这与 Redux 的 useSelector 机制类似:都是通过 Context 传递订阅机制,语言变化时批量触发所有相关组件重渲染。需要注意的是,如果大量组件都使用了 useTranslation,切换语言时会触发大量重渲染,可用 React.memo 结合不依赖语言的组件进行优化。


    Q4:Vite 的 manualChunks 分包策略如何设计?与 webpack 的 SplitChunks 有何异同?

    答:

    设计原则:

    • 按变更频率分包:变更频率低的(react、antd)单独分包,充分利用浏览器缓存
    • 按体积平衡:避免单个 chunk 过大(>500KB),也避免过多过小的 chunk(增加 HTTP 请求)
    • 只对 node_modules 分包:业务代码交给 Rollup 自动处理,避免手动分包引入循环依赖

    经典分包策略:

    vendor-react(~150KB) → react + react-dom + react-router
    vendor-antd(~800KB) → antd + @ant-design/* + rc-*
    vendor-redux(~50KB) → @reduxjs/toolkit + redux + react-redux
    vendor-i18n(~100KB) → i18next + react-i18next + 翻译文件
    vendor-utils(~200KB) → axios + dayjs + lodash

    与 webpack SplitChunks 的异同:

    特性Vite (Rollup) 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: ubuntu–latest
    steps:
    – uses: actions/checkout@v4
    – uses: actions/setup–node@v4
    with:
    node-version: '20'
    cache: 'npm'

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

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

    # 使用 bundlesize 工具在 PR 上评论体积变化
    – name: Comment bundle size
    uses: github/bundle–size@v1
    with:
    github_token: ${{ secrets.GITHUB_TOKEN }}
    files: dist/assets/js/*.js

    10.5 Nginx 生产部署优化

    # /etc/nginx/sites-available/yitong.conf

    server {
    listen 80;
    server_name yitong.example.com;
    return 301 https://$host$request_uri;
    }

    server {
    listen 443 ssl http2;
    server_name yitong.example.com;

    ssl_certificate /etc/ssl/certs/yitong.crt;
    ssl_certificate_key /etc/ssl/private/yitong.key;

    root /var/www/yitong/dist;
    index index.html;

    # ━━━━ Brotli/Gzip 压缩 ━━━━
    brotli on;
    brotli_comp_level 6;
    brotli_types text/plain text/css application/javascript application/json image/svg+xml;

    gzip on;
    gzip_vary on;
    gzip_types text/plain text/css application/javascript application/json;

    # ━━━━ 静态资源长期缓存(hash 命名,内容不变 URL 不变)━━━━
    location ~* \\.(?:js|css|woff2|png|jpg|svg)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
    add_header X-Content-Type-Options nosniff;
    }

    # ━━━━ HTML 不缓存(每次请求最新版本)━━━━
    location = /index.html {
    expires -1;
    add_header Cache-Control "no-store, no-cache, must-revalidate";
    }

    # ━━━━ React Router — 所有路径回退到 index.html ━━━━
    location / {
    try_files $uri $uri/ /index.html;
    }

    # ━━━━ 安全响应头 ━━━━
    add_header X-Frame-Options SAMEORIGIN;
    add_header X-XSS-Protection "1; mode=block";
    add_header Referrer-Policy "strict-origin-when-cross-origin";
    add_header Permissions-Policy "camera=(), microphone=(), geolocation=()";
    add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;";
    }

    10.6 生产部署 Checklist

    检查项命令/工具目标值
    总 JS 体积(gzip) du -sh dist/assets/js/ ≤ 150KB gzip
    最大单 chunk visualizer 报告 ≤ 200KB raw
    Lighthouse 性能分 npx lighthouse https://… ≥ 90
    LCP(最大内容绘制) Lighthouse / WebVitals ≤ 2.5s
    FID/INP(交互延迟) Lighthouse ≤ 100ms
    CLS(布局偏移) Lighthouse ≤ 0.1
    Tree-shaking 验证 visualizer — 搜索未用模块 无全量引入
    Sourcemap 未暴露 curl https://…/main.js.map 404
    敏感信息未泄露 grep -r "SECRET" dist/ 0 条匹配
    HTTP/2 已启用 curl -I –http2 https://… HTTP/2 200
    Brotli 压缩 curl -H "Accept-Encoding: br" -I content-encoding: br

    九、参考资料

    官方文档

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

  • React 官方文档 – useReducer
    https://react.dev/reference/react/useReducer

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

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

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

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

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

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

  • 延伸阅读

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

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

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

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

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

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

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


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

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

    评论 抢沙发

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