React

2021-06-02T22:13:02+08:00 | 119分钟阅读 | 更新于 2021-06-02T22:13:02+08:00

@

学习目标

学完这篇你应该能够:

  1. 用 JSX 描述 UI,并讲清变量插值、条件渲染、样式写法的工程取舍。
  2. map/for 把数据渲染成列表,并说明 React 中 key 与循环对象属性的要点。
  3. 用受控组件处理 input / checkbox / select / textarea 等表单事件,理解"状态即单一数据源"。
  4. useState / useEffect / useMemo / useCallback / useContext / useReducer / useRef / useTransition 管理组件状态与副作用,并说清各自的适用边界。
  5. react-router-dom 搭建多页应用(导航、路由参数、路由守卫、嵌套路由、Data API),并把 axios 封装、环境变量、代理、CSS Module 组装成一个可运行的项目骨架。

前置知识

  • 熟悉 HTML / CSS 与 ES6+ 语法(箭头函数、解构、展开运算符、模板字符串)。
  • 了解 npm / yarn 与项目初始化,会用 npm create vite 之类脚手架。
  • 有一点函数式编程直觉(把 UI 看成 state -> 视图 的映射)会更顺。

本章你会动手做的事

  1. 搭好 .editorconfig + prettier 的工程配置,统一代码风格。
  2. 写一个带搜索/列表的小组件,把 useState + 受控表单 + map 渲染串起来。
  3. react-router-dom 配出"列表页 → 详情页(路由参数)+ 登录守卫"的最小路由。

一、React 概览

类比:把 React 想象成"中央厨房的调度员 + 菜谱"。你只管把菜的原料(数据 state)摆好,调度员(Reconciler)按菜谱(JSX)把菜做好端上桌(DOM)。原料变了,他自动重做一遍菜——你不用管哪个锅先开、哪个盘子先摆,省心。

1.1 React 是什么

React 是由 Meta(原 Facebook) 开源的、用于构建用户界面的 JavaScript 库。它有三大核心特征:

特征含义解决什么问题
声明式 (Declarative)用 JSX 描述 UI 应该是"长什么样",不写"怎么一步步改 DOM"让代码可读、可推理、便于调试
组件化 (Component-Based)把 UI 拆成可复用、可组合的独立单元复用、隔离、协作
一次学习,多端运行 (Learn Once, Write Anywhere)React DOM(Web)、React Native(移动端)、React 3D / Ink(终端)等一套思维模型通吃多端

关键判断:React 是而非框架。库给你"原料调度",框架给你"全家桶"。React 官方只在视图层发力,路由(react-router-dom)、状态(Zustand / Redux)、样式(CSS-in-JS / Tailwind)都是社区生态选型——这正是它的灵活之处。

1.2 为什么学 React

flowchart LR
    A[招聘市场
React 岗位数] -->|常年 Top 3| B[生态成熟度] B --> C[组件库丰富
antd / MUI / shadcn] B --> D[工具链完整
Vite / Next / Rspack] B --> E[跨端能力
RN / 桌面 / 小程序] A --> F[团队协作
组件化天然分工] F --> G[工程心智
单向数据流 / Hooks]

学习 React 不只是为了"找工作",更重要的是它在过去十年沉淀下来的一整套工程方法论

  • 单向数据流:状态是唯一真相源,组件只是状态的"投影函数"。
  • Hooks 思维:把副作用、缓存、订阅这些"非纯逻辑"用统一接口表达。
  • 虚拟 DOM + Diff 算法:让"声明式 UI"有了可承受的性能成本。
  • Fiber / Concurrent:让"开始渲染"和"完成渲染"可以被打断,把主线程让给用户。

1.3 生态地图(学习路径)

按"由内向外"的顺序学最省力:

mindmap
  root((React 技术栈))
    核心
      JSX / 组件
      Hooks
      Context / Reducer
    原理
      Virtual DOM
      Fiber 架构
      Diff 算法
      Schedule 调度
    工具链
      Vite / Webpack
      Babel / SWC
      ESLint / TS
    路由
      react-router-dom
      嵌套 / 守卫 / 懒加载
    状态管理
      useState / useReducer
      Zustand
      Redux Toolkit
    样式
      CSS Modules
      CSS-in-JS
      Tailwind 原子化
    实战
      组件库封装
      性能优化
      SSR / Next.js

路线建议:本篇按"基础 → 工具链 → 原理 → Hooks → 组件 → CSS → 路由 → 状态 → 实战"展开,每一节都尽量做到"读完能跑、跑通能改、改完能讲"。

1.4 入门前 30 秒速记

  • JSX 是 JS 的语法扩展,看起来像 HTML,最终会被 Babel / SWC 编译成 React.createElement(...)
  • 组件分函数组件(function App())和类组件(class App extends React.Component)——新项目 100% 用函数组件。
  • 状态useState副作用useEffect跨组件共享useContextZustand
  • 单向数据流:父 → 子用 props,子 → 父用 props 传下来的回调函数。

2.7 附:编辑器配置参考

.editorconfig

在项目根路径下创建.editorconfig

# https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

.prettierrc

// 安装prettier
yarn add prettier -D

//在根路径下创建.prettierrc.cjs
module.exports = {
  printWidth: 120,     //每行最大列,超过会换行
  tabWidth: 2,         //缩进
  semi: false,         //
}

二、基础篇:开发环境搭建

类比:开发环境就像"开餐馆前的装修"——水电走线、灶台尺寸、排烟管道(脚手架、TS、Lint、目录)一次装好,后面十年不用再动。装修没做好的店,开业后天天救火。

2.1 用 Vite 搭脚手架

Vite 是当前 React 生态事实标准的脚手架(Webpack 5 + Create React App 已被官方建议弃用)。它的核心是"开发时按需编译 + 生产时 Rollup 打包",冷启动比 Webpack 快 10–100 倍。

# 步骤 1:用 create-vite 创建项目
npm create vite@latest my-react-app -- --template react-ts

# 步骤 2:安装依赖
cd my-react-app
npm install

# 步骤 3:跑起来
npm run dev

⚠️ 新手必踩的坑create-vite 默认创建的是空模板,里面只有一个 App.tsx 和 Hello World。要做真实项目,还要继续装react-router-dom(路由)、axios(请求)、zustand(状态)、antdtailwind(UI)、eslint / prettier(质量)。

2.2 tsconfig.json 关键配置

// tsconfig.app.json
{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,

    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx",            // 关键:让 .tsx 自动识别 JSX

    /* 路径别名(必配,省一堆 ../../../)*/
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    },

    /* 严格模式 */
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true
  },
  "include": ["src"]
}

关键三行

  1. "jsx": "react-jsx":让 .tsx 不用 import React from 'react' 就能用 JSX(React 17+ 新 JSX 转换)。
  2. "paths": { "@/*": ["src/*"] }:配合 vite.config.tsalias,让 import Home from '@/pages/Home' 能跳。
  3. "strict": true:开启严格模式,建议始终开——早期多写几个类型,后期少几小时排错。

2.3 Vite 别名同步

// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
})

⚠️ 坑点:只在 tsconfig.jsonpaths 但没配 Vite 别名 → 编译报错;只配 Vite 别名没配 paths → 编译器报红、跳转失效。两个都要配

2.4 ESLint + Prettier 协同

# 步骤 1:装 ESLint(含 React Hooks 规则)
npm install -D eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin \
  eslint-plugin-react eslint-plugin-react-hooks eslint-plugin-react-refresh

# 步骤 2:装 Prettier + 解决和 ESLint 的冲突
npm install -D prettier eslint-config-prettier
// .eslintrc.cjs
module.exports = {
  root: true,
  env: { browser: true, es2020: true },
  extends: [
    'eslint:recommended',
    'plugin:@typescript-eslint/recommended',
    'plugin:react/recommended',
    'plugin:react/jsx-runtime',
    'plugin:react-hooks/recommended',   // 强制 Hooks 规则
    'prettier',                          // 必须在最后,覆盖样式冲突
  ],
  parser: '@typescript-eslint/parser',
  parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
  settings: { react: { version: 'detect' } },
  plugins: ['react-refresh'],
  rules: {
    'react-refresh/only-export-components': 'warn',
    '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
  },
}

顺序很重要prettier 必须最后 extend,否则 ESLint 的格式规则会和 Prettier 打架,每次保存都看见一片红波浪。

2.5 推荐目录结构

my-react-app/
├── public/                  # 静态资源(构建时原样拷贝)
│   └── favicon.svg
├── src/
│   ├── assets/              # 图标、图片、字体(会被 hash)
│   ├── components/          # 通用组件(Button、Modal)
│   │   └── Button/
│   │       ├── index.tsx
│   │       ├── Button.tsx
│   │       └── Button.module.css
│   ├── pages/               # 路由对应的页面
│   │   ├── Home/
│   │   └── About/
│   ├── hooks/               # 自定义 Hooks
│   ├── store/               # 全局状态(Zustand / Redux)
│   ├── router/              # 路由配置
│   ├── utils/               # 工具函数(request / storage)
│   ├── types/               # 共享 TS 类型
│   ├── App.tsx
│   └── main.tsx             # 入口(挂载根节点)
├── .editorconfig
├── .eslintrc.cjs
├── .prettierrc.cjs
├── tsconfig.json
├── vite.config.ts
└── package.json

经验

  • components/ 按"组件名"分子目录,每个子目录里 index.tsx 对外暴露,xxx.tsx 写实现,便于以后加 .module.css / .test.tsx
  • pages/ 与路由一一对应/userspages/Users/,目录名和路由名一致能省一半导航脑力。
  • 不要过早抽象:5 个文件之前别建 common/,2 个相似组件前别抽 BaseButton

2.6 自测与排查清单

自测

  1. npm create vite@latest + 选 react-ts 模板,浏览器看到 Vite 启动页。
  2. 配好 @/ 路径别名,写一句 import Logo from '@/assets/logo.svg' 不报错。
  3. ESLint 故意写 const a = 1 然后不引用,命令行 npm run lint 报红。
  4. Prettier 故意把代码缩进打乱,保存后自动恢复 2 空格缩进。

排错清单

  • JSX 报红 → 缺 "jsx": "react-jsx" 或插件没装。
  • @/ 路径编辑器不识别 → 缺 tsconfig.pathsvite alias
  • 保存 ESLint 报错 → Prettier 没放最后 extend,规则打架。
  • Vite 启动报 Cannot find module 'react' → 依赖没装全,跑 npm install

3.8 JSX 基础语法(补充)

变量声明

import './App.css'

function App() {
  const name = <div>wg</div>
  const info = <h1>学习React18</h1>

    return (
   <>
     {name}
     {info}
   </>
  )
}

export default App

条件判断

import './App.css'

function App() {
  const admin = <span>管理员</span>
  const member = <span>会员</span>
  const isAdmin = false
    return (
   <>
     {isAdmin ? admin: member}      {/*输出: 会员 */}
   </>
  )
}

export default App

样式

import './App.css'

function App() {
  const admin = <span style={{color:'red',fontSize: 16}}>管理员</span>  {/* style */}
  const member = <span>会员</span>
  const isAdmin = true
  return (
    <>
      {isAdmin ? admin: member}      
    </>
  )
}

export default App

三、基础篇:tsx 语法全解

类比.jsx 是"没写过收件地址的明信片"——任何人都能塞进去,但出错时排查极累;.tsx 是"贴好邮票写清地址的挂号信"——寄出前 TS 编译器先验一遍收件人格式、发件人、收件地址,不对直接退信,不出邮筒

.tsx 让你在写 React 组件的同时享受 TypeScript 的静态类型检查。下面把最常用的 8 类写法浓缩成一节速查。

3.1 组件 Props 类型

最推荐用 interface(可继承、可声明合并)定义 props:

// 方式 1:interface(推荐)
interface ButtonProps {
  text: string
  onClick: () => void
  disabled?: boolean              // 可选
  variant?: 'primary' | 'dashed' // 字面量联合
}

function Button({ text, onClick, disabled = false, variant = 'primary' }: ButtonProps) {
  return <button disabled={disabled} onClick={onClick}>{text}</button>
}

// 方式 2:type(适合联合类型 / 工具类型)
type ButtonProps2 = {
  text: string
  onClick: () => void
}

⚠️ 坑点:React 18 已不再推荐 React.FC<Props> 写法——它会隐式注入 children(让 children 不可控),且对小项目毫无价值。直接 function Foo(props: FooProps) 即可。

3.2 常用 Hooks 的类型写法

import { useState, useRef, useEffect, useReducer } from 'react'

// useState:会自动从初值推断,必要时显式标注
const [count, setCount] = useState(0)                  // 推断为 number
const [user, setUser] = useState<User | null>(null)    // 显式联合类型

interface User { name: string; age: number }

// useRef:DOM 引用 vs 通用可变值(两种不同重载)
const inputRef = useRef<HTMLInputElement>(null)        // DOM 引用,current 是只读
const timerRef = useRef<number | null>(null)           // 通用可变值,current 可写

// useEffect:void 返回,无类型
useEffect(() => {
  const id = setInterval(() => console.log('tick'), 1000)
  return () => clearInterval(id)
}, [])

// useReducer:完整签名
type Action = { type: 'inc' } | { type: 'dec' } | { type: 'set'; payload: number }
function reducer(state: number, action: Action): number {
  switch (action.type) {
    case 'inc': return state + 1
    case 'dec': return state - 1
    case 'set': return action.payload
  }
}
const [count2, dispatch] = useReducer(reducer, 0)

3.3 事件对象类型

事件对象的类型是 SyntheticEvent 的子集,按事件名取:

// 鼠标事件
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => { /* ... */ }

// 输入事件
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  console.log(e.target.value)   // e.target 已正确收窄为 HTMLInputElement
}

// 键盘事件
const handleKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
  if (e.key === 'Enter') { /* ... */ }
}

// 表单提交
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
  e.preventDefault()
}

经验:记不得类型时,onChange={...} 里先写个普通函数,把鼠标 hover 到参数 e 上,IDE 会自动提示完整类型——这是最快的"反查"方式。

3.4 children 与 ref 转发

// 含 children 的组件
interface CardProps {
  title: string
  children: React.ReactNode    // ReactNode 比 ReactElement 更宽,能装字符串/数字/片段
}
function Card({ title, children }: CardProps) {
  return <section><h3>{title}</h3>{children}</section>
}

// ref 转发:父组件能拿到子组件内部 DOM
const InputRef = forwardRef<HTMLInputElement, { placeholder?: string }>(
  ({ placeholder }, ref) => <input ref={ref} placeholder={placeholder} />
)
// 使用:
const ref = useRef<HTMLInputElement>(null)
<InputRef ref={ref} placeholder="搜一下" />

3.5 泛型组件

可复用的"列表渲染组件"是泛型最常见的用法:

// List<T> 接受任意类型的 items
interface ListProps<T> {
  items: T[]
  renderItem: (item: T) => React.ReactNode
  keyOf: (item: T) => string | number
}

function List<T>({ items, renderItem, keyOf }: ListProps<T>) {
  return <ul>{items.map(it => <li key={keyOf(it)}>{renderItem(it)}</li>)}</ul>
}

// 使用时 T 会被自动推断
<List
  items={[{ id: 1, name: 'A' }, { id: 2, name: 'B' }]}
  renderItem={u => <span>{u.name}</span>}
  keyOf={u => u.id}
/>

3.6 工具类型:Partial / Pick / Omit / Required

interface User {
  id: number
  name: string
  age: number
  email: string
}

// Partial:所有字段变可选(编辑场景)
type UserPatch = Partial<User>

// Pick:挑几个字段(列表项 / 表单项)
type UserCard = Pick<User, 'id' | 'name'>

// Omit:去掉几个字段(提交时排除 id)
type UserCreate = Omit<User, 'id'>

// Required:所有字段变必填
type UserFull = Required<User>

实战模式:列表用 Pick<User, 'id' | 'name'>、表单提交用 Omit<User, 'id'>、编辑用 Partial<User>——三种类型直接表达"这一处只关心这几个字段"。

3.7 常见坑与排查

⚠️ 坑 1:{children} 类型用 JSX.Element 会丢字符串/数字。组件里写 <Card>hello</Card> 时,hello 是字符串,JSX.Element 收不下。要么用 React.ReactNode,要么用 React.PropsWithChildren<Props>

⚠️ 坑 2:as 类型断言绕过检查value as any 等于"把邮戳撕了,邮局不验"——只在对接老 JS 库/后端畸形数据时用,不要用来"消红"。

⚠️ 坑 3:默认导出 vs 命名导出export default function 改文件名不影响 import,但 tree-shaking 表现差;命名导出 export function 重构友好。建议组件用默认导出、工具/类型用命名导出

3.9 列表渲染(补充)

使用 map 方法

import React from 'react';

const numbers = [1, 2, 3, 4, 5];

const App = () => {
  return (
    <ul>
      {numbers.map((number) => (
        <li key={number}>{number}</li>
      ))}
    </ul>
  );
};

export default App;

使用 for 循环

import React from 'react';

const numbers = [1, 2, 3, 4, 5];

const App = () => {
  const listItems = [];
  for (let i = 0; i < numbers.length; i++) {
    listItems.push(<li key={numbers[i]}>{numbers[i]}</li>);
  }

  return <ul>{listItems}</ul>;
};

export default App;

循环对象属性

import React from 'react';

const person = {
  name: 'John',
  age: 30,
  occupation: 'Developer'
};

const App = () => {
  return (
    <ul>
      {/* 在这个例子中,我们使用 Object.entries 方法将对象的键值对转换为数组,然后使用 map 方法遍历该数组并渲染每个键值对。*/}
      {Object.entries(person).map(([key, value]) => (
        <li key={key}>{`${key}: ${value}`}</li>
      ))}
    </ul>
  );
};

export default App;

3.10 表单事件处理(补充)

文本输入框(input)事件

对于文本输入框,通常会用到 onChange 事件来实时捕获输入内容的变化,用 onSubmit 事件来处理表单提交。

import React, { useState } from 'react';

const TextInputForm = () => {
    const [inputValue, setInputValue] = useState('');

    const handleChange = (e) => {
        setInputValue(e.target.value);
    };

    const handleSubmit = (e) => {
        e.preventDefault();
        console.log('提交的内容:', inputValue);
    };

    return (
        <form onSubmit={handleSubmit}>
            <input
                type="text"
                value={inputValue}
                onChange={handleChange}
                placeholder="请输入内容"
            />
            <button type="submit">提交</button>
        </form>
    );
};

export default TextInputForm;

在上述代码中,useState 用于创建一个状态变量 inputValue 来存储输入框的值。handleChange 函数会在输入内容变化时更新该状态。handleSubmit 函数则在表单提交时被调用,使用 e.preventDefault() 来阻止表单的默认提交行为。

复选框(input[type=“checkbox”])事件

复选框通常使用 onChange 事件来处理选中状态的变化。

import React, { useState } from 'react';

const CheckboxForm = () => {
    const [isChecked, setIsChecked] = useState(false);

    const handleCheckboxChange = (e) => {
        setIsChecked(e.target.checked);
    };

    return (
        <form>
            <input
                type="checkbox"
                checked={isChecked}
                onChange={handleCheckboxChange}
            />
            <label>是否选中</label>
        </form>
    );
};

export default CheckboxForm;

这里,isChecked 状态变量用来存储复选框的选中状态,handleCheckboxChange 函数会在复选框状态改变时更新该状态。

下拉框(select)事件

下拉框同样使用 onChange 事件来处理选项的选择变化。

import React, { useState } from 'react';

const SelectForm = () => {
    const [selectedOption, setSelectedOption] = useState('option1');

    const handleSelectChange = (e) => {
        setSelectedOption(e.target.value);
    };

    return (
        <form>
            <select value={selectedOption} onChange={handleSelectChange}>
                <option value="option1">选项 1</option>
                <option value="option2">选项 2</option>
                <option value="option3">选项 3</option>
            </select>
        </form>
    );
};

export default SelectForm;

此例中,selectedOption 状态变量存储当前选中的选项值,handleSelectChange 函数会在选项改变时更新该状态。

多行文本框(textarea)事件

多行文本框和输入框类似,使用 onChange 事件处理内容变化。

import React, { useState } from 'react';

const TextareaForm = () => {
    const [textareaValue, setTextareaValue] = useState('');

    const handleTextareaChange = (e) => {
        setTextareaValue(e.target.value);
    };

    return (
        <form>
            <textarea
                value={textareaValue}
                onChange={handleTextareaChange}
                placeholder="请输入多行文本"
            />
        </form>
    );
};

export default TextareaForm;

这里,textareaValue 状态变量存储多行文本框的内容,handleTextareaChange 函数会在内容改变时更新该状态。

3.11 组件属性传递(补充)

基本属性传递

基本属性传递是最常见的方式,你可以将数据作为属性传递给子组件,子组件通过 props 对象来接收这些数据。

import React from 'react';

// 子组件
const ChildComponent = (props) => {
    return (
        <div>
            <p>接收到的名称: {props.name}</p>
            <p>接收到的年龄: {props.age}</p>
        </div>
    );
};

// 父组件
const ParentComponent = () => {
    const name = 'John';
    const age = 30;

    return (
        <div>
            <h1>父组件</h1>
            <ChildComponent name={name} age={age} />
        </div>
    );
};

export default ParentComponent;

在上述代码中,ParentComponent 作为父组件,将 nameage 作为属性传递给 ChildComponentChildComponent 通过 props 对象接收这些属性并进行渲染。

展开运算符传递属性

如果你有一个包含多个属性的对象,你可以使用展开运算符 ... 来一次性传递所有属性

import React from 'react';

// 子组件
const ChildComponent = (props) => {
    return (
        <div>
            <p>接收到的名称: {props.name}</p>
            <p>接收到的年龄: {props.age}</p>
        </div>
    );
};

// 父组件
const ParentComponent = () => {
    const person = {
        name: 'John',
        age: 30
    };

    return (
        <div>
            <h1>父组件</h1>
            <ChildComponent {...person} />
        </div>
    );
};

export default ParentComponent;

在这个例子中,person 对象包含 nameage 属性,使用展开运算符 ... 将其所有属性传递给 ChildComponent

函数作为属性传递

你可以将函数作为属性传递给子组件,子组件可以调用这个函数来与父组件进行通信。

import React, { useState } from 'react';

// 子组件
const ChildComponent = (props) => {
    const handleClick = () => {
        props.onClick('Hello from child!');
    };

    return (
        <button onClick={handleClick}>点击调用父组件函数</button>
    );
};

// 父组件
const ParentComponent = () => {
    const [message, setMessage] = useState('');

    const handleChildClick = (msg) => {
        setMessage(msg);
    };

    return (
        <div>
            <h1>父组件</h1>
            <p>接收到的消息: {message}</p>
            <ChildComponent onClick={handleChildClick} />
        </div>
    );
};

export default ParentComponent;

在这个示例中,ParentComponenthandleChildClick 函数作为 onClick 属性传递给 ChildComponentChildComponent 中的按钮点击事件调用 props.onClick 函数,并传递一个消息给父组件。

上下文(Context)传递属性

当你需要在多个层级的组件之间共享数据时,使用上下文(Context)是一个不错的选择。

import React, { createContext, useContext, useState } from 'react';

// 创建上下文
const UserContext = createContext();

// 子组件
const ChildComponent = () => {
    const user = useContext(UserContext);

    return (
        <div>
            <p>接收到的用户名称: {user.name}</p>
            <p>接收到的用户年龄: {user.age}</p>
        </div>
    );
};

// 父组件
const ParentComponent = () => {
    const [user, setUser] = useState({
        name: 'John',
        age: 30
    });

    return (
        <UserContext.Provider value={user}>
            <div>
                <h1>父组件</h1>
                <ChildComponent />
            </div>
        </UserContext.Provider>
    );
};

export default ParentComponent;

在这个例子中,使用 createContext 创建了一个 UserContextParentComponent 使用 UserContext.Provider 提供数据,ChildComponent 使用 useContext 钩子获取上下文数据。

四、工具篇:构建工具链

类比:JSX / TSX / TS 浏览器都不认——构建工具就像"翻译官",把源码翻译成浏览器能跑的语言。Babel 是慢工出细活的"人工翻译",SWC 是 Rust 写的"AI 实时翻译"。两者各有所长,新项目默认 SWC + Vite,老项目或要"花式语法实验"再选 Babel。

4.1 Babel

Babel 是 JavaScript 编译器(注意:不是打包器)。它只做一件事——把"新的 JS/JSX/TS 语法"翻译成"老浏览器认得的 ES5"@babel/preset-react 负责 JSX → React.createElement@babel/preset-typescript 负责剥掉 TS 类型注解。

// babel.config.js
module.exports = {
  presets: [
    ['@babel/preset-env', { targets: '> 0.5%, last 2 versions, not dead' }],
    ['@babel/preset-react', { runtime: 'automatic' }],   // 新 JSX 转换
    '@babel/preset-typescript',
  ],
  plugins: [
    ['@babel/plugin-transform-runtime'],                 // 复用 helper,减小体积
  ],
}
// .babelrc.json(局部覆盖)
{
  "presets": ["@babel/preset-react"],
  "plugins": ["babel-plugin-styled-components"]
}

应用场景

  • 需要写"超前的语法实验"(比如 stage-3 提案)→ Babel 插件生态最丰富。
  • 需要"在运行时改写代码"(如 Storybook 的 decorator / Jest 的 transform)→ 用 babel-jest
  • 单测覆盖率、source map 体验方面 Babel 仍是事实标准(Istanbul 集成更深)。

4.2 SWC

SWC 是用 Rust 写的编译器,单核速度比 Babel 快 20 倍左右。它是 Next.js 13+ 和 Vite(通过 @vitejs/plugin-react-swc)的默认编译器。

// vite.config.ts(用 SWC 替代 Babel)
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'

export default defineConfig({
  plugins: [react()],
})

优势

  1. 冷启动/热更新极快——Rust 编译 + 增量缓存,万行级项目 HMR < 100ms。
  2. 内置 TS / JSX,不用装一堆 preset。
  3. 生产构建配合 Rspack / Turbopack,构建时间缩短一半。

劣势

  1. 插件生态比 Babel 少;想加"复杂宏"(如 babel-plugin-macros)时可能踩坑。
  2. 报错信息不如 Babel 友好(Rust → JS 错误信息转换偶有丢失)。

4.3 Babel vs SWC:怎么选

flowchart LR
    A[新项目] -->|默认| B[SWC + Vite]
    A -->|要单测/运行时改写| C[Babel + Jest/Storybook]
    A -->|复杂语法实验| C
    D[老 Webpack 项目] -->|逐步替换| C
    D -->|一次性迁移| E[Rspack 替代 Webpack]
    E -->|Rust 提速| B

经验法则

  • Vite + SWC 是 2024 年新项目默认组合。
  • Webpack 老项目继续用 Babel,但可以分阶段把 Babel-loader 换成 SWC-loader,构建时间立减 50%。
  • Jest 单测 仍推荐 babel-jest(生态成熟、source map 体验最好),有性能压力再换 swc-jest

4.4 常见坑

⚠️ 坑 1:JSX 报警告 Each child in a list should have a unique "key" prop。这是 Babel/SWC 翻译出的 React 运行时检查,与编译器无关——检查你的 map 回调。

⚠️ 坑 2:SWC 不支持 Babel 的全部插件babel-plugin-import(按需引入)这类"非纯语法"插件 SWC 没有,社区用 unplugin-importvite-plugin-imp 替代。

⚠️ 坑 3:TS 类型不参与编译@babel/preset-typescript / swc 都会直接擦除类型注解,不做类型检查——tsc --noEmit 或 IDE 才是真"验类型"的人。

7.0 useState 详解(补充)

基本概念

在 React 中,状态是一种能在组件渲染过程中保存和更新数据的机制。在类组件里,状态通常通过 this.statethis.setState 来管理;而在函数组件中,就可以使用 useState 这个 Hook 来实现相同的功能。

语法

useState 函数接收一个初始状态值作为参数,并返回一个包含两个元素的数组:

const [state, setState] = useState(initialState);
  • state:当前状态的值。
  • setState:用于更新状态的函数。
  • initialState:状态的初始值,可以是任意数据类型,如数字、字符串、对象、数组等。

基本使用示例

以下是一个简单的计数器示例,展示了 useState 的基本用法:

import React, { useState } from 'react';

const Counter = () => {
    // 初始化状态,count 的初始值为 0
    const [count, setCount] = useState(0);

    const increment = () => {
        // 更新状态
        setCount(count + 1);
    };

    const decrement = () => {
        setCount(count - 1);
    };

    return (
        <div>
            <p>计数: {count}</p>
            <button onClick={increment}>增加</button>
            <button onClick={decrement}>减少</button>
        </div>
    );
};

export default Counter;

在这个示例中,useState(0)count 状态的初始值设为 0。setCount 函数用于更新 count 的值。每次点击 “增加” 或 “减少” 按钮时,相应的函数会被调用,从而更新 count 的值。

初始状态的延迟计算

如果初始状态是通过复杂计算得到的,你可以传入一个函数作为 useState 的参数。这个函数只会在组件的初始渲染时执行。

import React, { useState } from 'react';

const getInitialValue = () => {
    // 模拟复杂计算
    return Math.random() * 100;
};

const ComplexCounter = () => {
    const [value, setValue] = useState(getInitialValue);

    return (
        <div>
            <p>初始值: {value}</p>
        </div>
    );
};

export default ComplexCounter;

在这个例子中,getInitialValue 函数只会在组件首次渲染时执行,用于计算初始状态的值。

函数式更新

当新的状态值依赖于之前的状态时,你可以向 setState 函数传入一个回调函数。这个回调函数接收前一个状态值作为参数,并返回新的状态值。

import React, { useState } from 'react';

const DoubleCounter = () => {
    const [count, setCount] = useState(0);

    const doubleIncrement = () => {
        // 函数式更新
        setCount(prevCount => prevCount + 2);
    };

    return (
        <div>
            <p>计数: {count}</p>
            <button onClick={doubleIncrement}>增加 2</button>
        </div>
    );
};

export default DoubleCounter;

在这个例子中,setCount 接收一个回调函数,该函数使用前一个状态值 prevCount 来计算新的状态值。这种方式在处理多个状态更新时非常有用,因为它可以确保每次更新都基于最新的状态。

多个状态变量

你可以在一个组件中使用多个 useState 调用,以管理不同的状态

import React, { useState } from 'react';

const MultipleStates = () => {
    const [name, setName] = useState('');
    const [age, setAge] = useState(0);

    const handleNameChange = (e) => {
        setName(e.target.value);
    };

    const handleAgeChange = (e) => {
        setAge(Number(e.target.value));
    };

    return (
        <div>
            <input
                type="text"
                value={name}
                onChange={handleNameChange}
                placeholder="输入姓名"
            />
            <input
                type="number"
                value={age}
                onChange={handleAgeChange}
                placeholder="输入年龄"
            />
            <p>姓名: {name}, 年龄: {age}</p>
        </div>
    );
};

export default MultipleStates;

在这个例子中,nameage 是两个独立的状态变量,分别使用不同的 useState 调用进行管理。

注意事项

  • 状态更新是异步的setState 函数是异步的,多次调用 setState 可能会被合并。如果需要在状态更新后执行某些操作,可以使用 useEffect Hook。
  • 状态的不可变性:在更新状态时,应该避免直接修改状态对象,而是创建一个新的对象。例如,如果状态是一个数组或对象,应该使用展开运算符或其他方法创建一个新的副本进行更新。

7.0a useEffect 详解(补充)

useEffect 是 React 18 里一个十分关键的 Hook,它能让你在函数组件里执行副作用操作。副作用操作涵盖数据获取、订阅、手动修改 DOM 等。下面会详细解析 useEffect 的语法与使用场景。

基本语法

useEffect 函数接收两个参数:

useEffect(effect, dependencies);
  • effect:这是一个函数,其中包含了需要执行的副作用操作。此函数还可以返回一个清理函数,用于在组件卸载或者依赖项变更时执行清理工作。
  • dependencies:这是一个可选的数组,包含了影响副作用执行的依赖项。当这些依赖项中的任意一个发生变化时,副作用函数就会重新执行。要是省略这个参数,副作用函数会在每次组件渲染之后都执行;若传入一个空数组,副作用函数仅会在组件挂载和卸载时执行。

常见使用场景

1. 组件挂载和卸载时执行副作用

当传入一个空数组作为依赖项时,useEffect 里的副作用函数只会在组件挂载时执行一次,而返回的清理函数会在组件卸载时执行。

import React, { useEffect } from 'react';

const MountAndUnmountEffect = () => {
    useEffect(() => {
        // 组件挂载时执行的操作
        console.log('组件已挂载');

        // 返回清理函数
        return () => {
            console.log('组件将卸载');
        };
    }, []);

    return <div>组件示例</div>;
};

export default MountAndUnmountEffect;

在这个例子中,useEffect 的依赖项为空数组,所以副作用函数仅在组件挂载时执行,清理函数在组件卸载时执行。

2. 依赖项变化时执行副作用

当传入一个包含依赖项的数组时,副作用函数会在组件挂载时执行,并且在依赖项中的任意一个发生变化时重新执行。

import React, { useState, useEffect } from 'react';

const DependencyChangeEffect = () => {
    const [count, setCount] = useState(0);

    useEffect(() => {
        // 依赖项变化时执行的操作
        console.log(`计数已更新为: ${count}`);
    }, [count]);

    const increment = () => {
        setCount(count + 1);
    };

    return (
        <div>
            <p>计数: {count}</p>
            <button onClick={increment}>增加</button>
        </div>
    );
};

export default DependencyChangeEffect;

在这个例子中,useEffect 的依赖项是 [count],所以每当 count 的值发生变化时,副作用函数就会重新执行。

3. 每次组件渲染时执行副作用

若省略依赖项参数,副作用函数会在每次组件渲染之后都执行。

import React, { useState, useEffect } from 'react';

const EveryRenderEffect = () => {
    const [value, setValue] = useState('');

    useEffect(() => {
        // 每次组件渲染时执行的操作
        console.log('组件已渲染');
    });

    const handleChange = (e) => {
        setValue(e.target.value);
    };

    return (
        <div>
            <input
                type="text"
                value={value}
                onChange={handleChange}
                placeholder="输入内容"
            />
        </div>
    );
};

export default EveryRenderEffect;

在这个例子中,由于没有传入依赖项,副作用函数会在每次组件渲染之后执行。

清理函数

副作用函数可以返回一个清理函数,用于在组件卸载或者依赖项变更时执行清理工作。清理函数常用于取消订阅、清除定时器等操作。

import React, { useEffect } from 'react';

const CleanupEffect = () => {
    useEffect(() => {
        const timer = setInterval(() => {
            console.log('定时器正在运行');
        }, 1000);

        // 返回清理函数
        return () => {
            clearInterval(timer);
            console.log('定时器已清除');
        };
    }, []);

    return <div>定时器组件</div>;
};

export default CleanupEffect;

在这个例子中,副作用函数创建了一个定时器,清理函数在组件卸载时清除了这个定时器,避免了内存泄漏。

注意事项

  • 依赖项数组的准确性:要确保依赖项数组中包含了所有会影响副作用执行的变量。若遗漏了某些依赖项,副作用可能不会在需要的时候重新执行;若包含了不必要的依赖项,副作用可能会过度执行。
  • 清理函数的必要性:如果副作用函数中涉及到需要清理的资源,如定时器、订阅等,一定要返回一个清理函数,以避免内存泄漏。

7.0b useMemo 和 useCallback 详解(补充)

useMemo

useMemo 是一个重要的 Hook,它主要用于性能优化,通过缓存计算结果,避免在每次渲染时都进行高开销的计算。

基本语法

useMemo 函数接受两个参数,其基本语法如下:

const memoizedValue = useMemo(() => computeValue(), dependencies);
  • 第一个参数:是一个函数,该函数返回一个需要被缓存的值,也就是你想要进行计算并缓存的结果。在上述代码中,computeValue() 就是进行具体计算的函数。
  • 第二个参数:是一个可选的依赖项数组 dependencies。当数组中的任何一个元素发生变化时,useMemo 会重新调用第一个参数中的函数来计算新的结果;如果依赖项数组为空,那么 useMemo 只会在组件挂载时计算一次结果,后续不会再重新计算;若省略这个参数,useMemo 会在每次组件渲染时都重新计算结果。
  • 返回值useMemo 返回的是第一个参数函数计算得到的值的缓存版本,即 memoizedValue

使用场景及示例

1. 缓存昂贵的计算结果

当你有一个计算量较大的函数时,使用 useMemo 可以避免在每次渲染时都进行该计算,只有在依赖项改变时才重新计算。

import React, { useMemo, useState } from 'react';

const ExpensiveCalculation = () => {
    const [count, setCount] = useState(0);

    // 模拟一个昂贵的计算
    const expensiveValue = useMemo(() => {
        console.log('进行昂贵的计算');
        let sum = 0;
        for (let i = 0; i < 1000000; i++) {
            sum += i;
        }
        return sum;
    }, []);

    const increment = () => {
        setCount(count + 1);
    };

    return (
        <div>
            <p>计数: {count}</p>
            <button onClick={increment}>增加</button>
            <p>昂贵计算的结果: {expensiveValue}</p>
        </div>
    );
};

export default ExpensiveCalculation;

在这个例子中,由于依赖项数组为空,useMemo 中的计算函数只会在组件挂载时执行一次,后续点击按钮增加 count 时,不会重新进行昂贵的计算。

2. 依赖项变化时重新计算

如果依赖项数组中有值发生变化,useMemo 会重新计算结果。

import React, { useMemo, useState } from 'react';

const DependencyChangeCalculation = () => {
    const [a, setA] = useState(1);
    const [b, setB] = useState(2);

    const result = useMemo(() => {
        console.log('重新计算结果');
        return a + b;
    }, [a, b]);

    const incrementA = () => {
        setA(a + 1);
    };

    const incrementB = () => {
        setB(b + 1);
    };

    return (
        <div>
            <p>a 的值: {a}</p>
            <button onClick={incrementA}>增加 a</button>
            <p>b 的值: {b}</p>
            <button onClick={incrementB}>增加 b</button>
            <p>计算结果: {result}</p>
        </div>
    );
};

export default DependencyChangeCalculation;

在这个示例中,当 ab 的值发生变化时,useMemo 会重新调用计算函数来更新 result 的值。

注意事项

  • useMemo 仅用于优化:不要过度使用 useMemo,因为它本身也有一定的开销。只有在确实需要避免重复计算时才使用。
  • 引用相等性useMemo 缓存的值是基于引用相等性的。如果计算结果是一个对象或数组,即使对象或数组的内容相同,但引用不同,useMemo 也会认为是不同的值。
  • 避免在 useMemo 中执行副作用useMemo 主要用于计算和缓存值,不应该在其中执行副作用操作(如数据获取、订阅等),副作用操作应该使用 useEffect

useCallback

它主要用于性能优化,特别是在处理函数引用时非常有用

基本语法

useCallback 函数接收两个参数,其基本语法如下:

const memoizedCallback = useCallback(
  callback,
  dependencies
);
  • 第一个参数 callback:这是一个需要被记忆(缓存)的函数。当组件重新渲染时,如果依赖项没有发生变化,useCallback 会返回之前缓存的函数引用;若依赖项有变化,则会返回一个新的函数引用。
  • 第二个参数 dependencies:这是一个可选的依赖项数组。它和 useEffect 中的依赖项数组类似,数组中的元素是一些变量,当这些变量中的任何一个发生变化时,useCallback 会重新创建一个新的函数。如果依赖项数组为空,那么 useCallback 只会在组件挂载时创建一次函数,后续不会重新创建;若省略这个参数,useCallback 会在每次组件渲染时都重新创建函数。
  • 返回值 memoizedCallbackuseCallback 返回的是经过记忆化处理后的函数,也就是缓存的函数引用。

使用场景及示例

1. 避免子组件不必要的重新渲染

当你将一个函数作为属性传递给子组件时,如果这个函数在父组件每次渲染时都重新创建,可能会导致子组件不必要的重新渲染。使用 useCallback 可以缓存这个函数,只有在依赖项变化时才重新创建函数,从而避免子组件的不必要渲染。

import React, { useCallback, useState } from 'react';

// 子组件
const ChildComponent = ({ onClick }) => {
    return (
        <button onClick={onClick}>点击我</button>
    );
};

// 父组件
const ParentComponent = () => {
    const [count, setCount] = useState(0);

    // 使用 useCallback 缓存函数
    const handleClick = useCallback(() => {
        setCount(count + 1);
    }, [count]);

    return (
        <div>
            <p>计数: {count}</p>
            <ChildComponent onClick={handleClick} />
        </div>
    );
};

export default ParentComponent;

在这个例子中,handleClick 函数使用 useCallback 进行了缓存。只有当 count 发生变化时,handleClick 函数才会重新创建。这样,即使父组件因为其他原因重新渲染,只要 count 不变,传递给 ChildComponentonClick 函数引用就不会改变,从而避免了子组件的不必要重新渲染。

2. 依赖项变化时重新创建函数

如果依赖项数组中有值发生变化,useCallback 会重新创建函数。

import React, { useCallback, useState } from 'react';

const DependencyChangeCallback = () => {
    const [a, setA] = useState(1);
    const [b, setB] = useState(2);

    const calculateSum = useCallback(() => {
        return a + b;
    }, [a, b]);

    const incrementA = () => {
        setA(a + 1);
    };

    const incrementB = () => {
        setB(b + 1);
    };

    return (
        <div>
            <p>a 的值: {a}</p>
            <button onClick={incrementA}>增加 a</button>
            <p>b 的值: {b}</p>
            <button onClick={incrementB}>增加 b</button>
            <p>计算结果: {calculateSum()}</p>
        </div>
    );
};

export default DependencyChangeCallback;

在这个示例中,当 ab 的值发生变化时,calculateSum 函数会重新创建。因为 calculateSum 函数依赖于 ab 的值,所以当它们变化时需要重新创建函数以保证计算结果的正确性。

注意事项

  • 仅在必要时使用useCallback 本身也有一定的开销,因此不要过度使用。只有在确实需要避免函数的重复创建,以防止子组件不必要的重新渲染时才使用。
  • 依赖项的准确性:要确保依赖项数组中包含了所有会影响函数行为的变量。如果遗漏了某些依赖项,可能会导致函数使用到旧的变量值;如果包含了不必要的依赖项,会导致函数不必要地重新创建。
  • useMemo 的区别useCallback 缓存的是函数引用,而 useMemo 缓存的是函数的返回值。如果需要缓存一个函数,使用 useCallback;如果需要缓存一个计算结果,使用 useMemo

7.0c useContext 和 useReducer 详解(补充)

类比useReducer 像一个"带规章的前台"——你不能直接改全局状态,只能递一张"动作单"(action)给它,它按既定规章(reducer)算出新的状态再发回来;useContext 则像公司的内部广播,任何人不用一级级汇报,打开广播就能拿到最新状态。两者一结合,就实现了"集中管理 + 随处可读写"的全局状态。

下面用一张图把 dispatch → reducer → 新 state → 重渲染 的单向数据流画清楚:

flowchart LR
    U[组件触发用户操作] --> D[dispatch 一个 action]
    D --> R[reducer
按 action 算新 state] R --> S[全局 state 更新] S --> C[Context 广播新值] C --> U

useContext

useContext 是一个非常实用的钩子,其主要作用是让组件能够访问 React 上下文(Context)中的数据,而不用一级一级地手动传递 props

上下文(Context)的概念

在 React 里,数据通常是自上而下(从父组件到子组件)传递的,不过对于某些共享数据(像用户登录状态、主题颜色、语言偏好等),若要把这些数据传递给多层嵌套的子组件,逐个传递 props 会显得很繁琐。上下文(Context)能够让你在组件树里共享这些数据,不用在每个层级手动传递 props。

useContext 的用法

useContext 钩子的用途是在函数式组件中读取上下文的值。它接收一个上下文对象(由 React.createContext 创建)作为参数,并且返回该上下文中当前的值。


import React, { createContext, useContext } from 'react';

// 创建一个上下文对象
const ThemeContext = createContext();

// 定义一个提供主题的组件
const ThemeProvider = ({ children }) => {
    const theme = {
        color: 'blue',
        background: 'lightgray'
    };

    return (
        <ThemeContext.Provider value={theme}>
            {children}
        </ThemeContext.Provider> 
    );
};

// 定义一个使用主题的组件
const ThemedComponent = () => {
    // 使用 useContext 钩子获取上下文的值
    const theme = useContext(ThemeContext);

    return (
        <div style={{ color: theme.color, background: theme.background }}>
            This is a themed component.
        </div>
    );
};

// 定义根组件
const App = () => {
    return (
        <ThemeProvider>
            <ThemedComponent />
        </ThemeProvider>
    );
};

export default App;
  1. 创建上下文对象:借助 React.createContext 函数创建一个上下文对象 ThemeContext。这个函数会返回一个包含 ProviderConsumer 的对象。
  2. 提供上下文值ThemeProvider 组件使用 ThemeContext.Provider 来提供上下文的值。value 属性定义了要共享的数据,在这个例子中是 theme 对象。
  3. 使用上下文值ThemedComponent 组件运用 useContext 钩子来获取上下文的值。传入 ThemeContext 作为参数,就能得到 ThemeProvider 提供的 theme 对象。
  4. 使用上下文值渲染组件:在 ThemedComponent 里,使用从上下文中获取的 theme 对象来设置组件的样式。

注意事项

  • 上下文更新:当 Providervalue 属性发生变化时,所有使用该上下文的组件都会重新渲染。
  • 默认值createContext 函数可以接受一个默认值作为参数,当组件没有匹配到 Provider 时,就会使用这个默认值。
  • 性能考量:尽管上下文很方便,但过度使用可能会让组件的依赖关系变得复杂,影响性能。所以,仅在确实需要跨层级共享数据时使用上下文。

useReducer

useReducer 是一个非常有用的 Hook,它提供了一种管理组件状态的方式,类似于 Redux 中的 reducer 概念,适用于处理复杂的状态逻辑。

基本概念

useReducer 是 React 提供的一个 Hook,它接收一个 reducer 函数和一个初始状态,返回一个当前状态和一个用于触发状态更新的 dispatch 函数。reducer 是一个纯函数,它接收当前状态和一个 action 对象,根据 action 的类型返回一个新的状态。

const [state, dispatch] = useReducer(reducer, initialState);
  • reducer:一个纯函数,接收当前状态 state 和一个 action 对象,返回一个新的状态。
  • initialState:初始状态值。
  • state:当前的状态值。
  • dispatch:一个函数,用于触发状态更新,它接收一个 action 对象作为参数。
import React, { useReducer } from 'react';

// 定义 reducer 函数
const counterReducer = (state, action) => {
    switch (action.type) {
        case 'increment':
            return { count: state.count + 1 };
        case 'decrement':
            return { count: state.count - 1 };
        default:
            return state;
    }
};

// 定义组件
const Counter = () => {
    // 使用 useReducer 钩子
    const [state, dispatch] = useReducer(counterReducer, { count: 0 });

    return (
        <div>
            <p>Count: {state.count}</p>
            <button onClick={() => dispatch({ type: 'increment' })}>Increment</button>
            <button onClick={() => dispatch({ type: 'decrement' })}>Decrement</button>
        </div>
    );
};

export default Counter;
  1. 定义 reducer 函数counterReducer 是一个 reducer 函数,它接收当前状态 state 和一个 action 对象。根据 action 的类型(incrementdecrement),返回一个新的状态。
  2. 使用 useReducer 钩子:在 Counter 组件中,使用 useReducer 钩子来管理状态。传入 counterReducer 作为 reducer 函数,{ count: 0 } 作为初始状态。
  3. 获取状态和 dispatch 函数useReducer 返回一个数组,包含当前状态 state 和一个 dispatch 函数。
  4. 触发状态更新:通过调用 dispatch 函数并传入一个 action 对象来触发状态更新。在按钮的 onClick 事件中,分别调用 dispatch({ type: 'increment' })dispatch({ type: 'decrement' }) 来增加或减少计数器的值。

高级用法

初始化状态

可以使用第二个参数 initialState 来设置初始状态,也可以使用第三个参数来实现惰性初始化。

const initialCount = 0;
const init = (initialCount) => {
    return { count: initialCount };
};

const [state, dispatch] = useReducer(reducer, initialCount, init);
处理副作用

可以在 useEffect 中使用 dispatch 来处理副作用,例如在组件挂载时获取数据。

import React, { useReducer, useEffect } from 'react';

const dataReducer = (state, action) => {
    switch (action.type) {
        case 'FETCH_SUCCESS':
            return { data: action.payload, loading: false };
        case 'FETCH_ERROR':
            return { error: action.error, loading: false };
        default:
            return state;
    }
};

const DataComponent = () => {
    const [state, dispatch] = useReducer(dataReducer, { data: null, loading: true, error: null });

    useEffect(() => {
        const fetchData = async () => {
            try {
                const response = await fetch('https://api.example.com/data');
                const data = await response.json();
                dispatch({ type: 'FETCH_SUCCESS', payload: data });
            } catch (error) {
                dispatch({ type: 'FETCH_ERROR', error: error.message });
            }
        };

        fetchData();
    }, []);

    if (state.loading) {
        return <p>Loading...</p>;
    }

    if (state.error) {
        return <p>Error: {state.error}</p>;
    }

    return <p>{JSON.stringify(state.data)}</p>;
};

export default DataComponent;

适用场景

  • 复杂状态逻辑:当组件的状态逻辑比较复杂,包含多个子值或需要根据不同的 action 进行复杂的状态更新时,使用 useReducer 可以使状态管理更加清晰和可维护。
  • 多个相关状态的更新:当多个状态的更新相互关联时,使用 useReducer 可以将这些更新逻辑集中在一个 reducer 函数中,避免使用多个 useState 钩子带来的复杂性。

注意事项

  • reducer 必须是纯函数:reducer 函数不能有副作用,它应该根据当前状态和 action 同步返回一个新的状态。
  • 状态不可变:在 reducer 中,应该返回一个新的状态对象,而不是直接修改原状态对象。

7.0d useRef 详解(补充)

useRef 返回一个可变的 ref 对象,其 .current 属性被初始化为传入的参数(initialValue)。返回的 ref 对象在组件的整个生命周期内保持不变。

const refContainer = useRef(initialValue);
  • initialValueref 对象 .current 属性的初始值,可以是任意类型,比如 null、数字、字符串等。
  • refContainer:返回的 ref 对象,它有一个 .current 属性,可用于存储和访问值。

使用场景

1. 访问 DOM 元素

useRef 常被用于访问 DOM 元素,进而实现对元素的操作,像聚焦、滚动等。

import React, { useRef } from 'react';

const TextInputWithFocusButton = () => {
    const inputEl = useRef(null);
    const onButtonClick = () => {
        // `current` 指向已挂载到 DOM 上的文本输入元素
        inputEl.current.focus();
    };
    return (
        <>
            <input ref={inputEl} type="text" />
            <button onClick={onButtonClick}>Focus the input</button>
        </>
    );
};

export default TextInputWithFocusButton;

在这个例子中,useRef(null) 创建了一个 ref 对象 inputEl,并将其赋值给 input 元素的 ref 属性。这样,inputEl.current 就指向了这个 input DOM 元素,点击按钮时就能调用 focus 方法让输入框获取焦点。

2. 保存可变值

useRef 还能用来保存一些需要在组件渲染周期之间保持不变的值,比如定时器 ID、上一次的状态值等。

import React, { useRef, useEffect, useState } from 'react';

const Counter = () => {
    const [count, setCount] = useState(0);
    const prevCountRef = useRef();

    useEffect(() => {
        prevCountRef.current = count;
    }, [count]);

    const prevCount = prevCountRef.current;

    return (
        <div>
            <p>Now: {count}, before: {prevCount}</p>
            <button onClick={() => setCount(count + 1)}>Increment</button>
        </div>
    );
};

export default Counter;

在这个例子中,prevCountRef 用于保存上一次的 count 值。每次 count 发生变化时,useEffect 会更新 prevCountRef.current 的值。

注意事项

  • ref 对象的 .current 属性是可变的:你可以随时修改 ref.current 的值,并且修改不会触发组件重新渲染。
  • ref 对象在组件的整个生命周期内保持不变:每次渲染时返回的 ref 对象都是同一个,这意味着你可以安全地在不同的渲染周期中访问和修改它。
  • 不要在渲染期间修改 ref 对象:虽然修改 ref 对象不会触发重新渲染,但在渲染期间修改它可能会导致意外的行为。通常,你应该在事件处理函数或副作用函数(如 useEffect)中修改 ref 对象。

五、原理篇:Virtual DOM / Fiber / Diff

类比:你手绘了一张会议座位图(JSX 描述的 UI)→ 助理把它扫描成电脑文件(Virtual DOM)→ 经理对比新旧两份图(Diff)→ 拿到一份"差异清单"(patch)→ 实际搬椅子(DOM 更新)。React 不直接搬椅子,而是用 Diff 算出"最少动几下",再动手。

5.1 Virtual DOM 是什么

flowchart LR
    A[JSX
源码描述] -->|Babel/SWC 编译| B[React.createElement
element 对象] B --> C[Virtual DOM
纯 JS 树] C --> D[Reconciler
Diff] D --> E[Fiber 树
可中断的更新队列] E --> F[Renderer
ReactDOM] F --> G[真实 DOM]

Virtual DOM(VDOM)不是“能让代码跑得快的魔法”,它本质是:

// element 对象(一个最简 VDOM 节点)
{
  type: 'div',
  props: { className: 'box', children: [
    { type: 'h1', props: { children: 'Hello' } },
    { type: 'ul', props: { children: [...] } }
  ] }
}

它解决的真正问题:让"声明式 UI"有了统一的中间表示。React、Vue、Solid、Preact 都可以"渲染"出同一棵树。后续的 diff、调度都是针对这棵树做的优化,而不是直接操作 DOM。

5.2 为什么要 Diff:DOM 操作有多贵

flowchart LR
    A[真实 DOM 操作] --> B[reflow 重排]
    A --> C[repaint 重绘]
    B --> D[主线程阻塞]
    C --> D
    D --> E[页面卡顿]
  • 一次 dom.appendChild 可能连带触发 reflow + repaint,量级在 O(子节点数) 以上。
  • 如果每次 setState 都"全量重建 DOM",1000 个节点的列表就崩了。
  • Diff 算法的目标:用最少的比较次数找到"哪几个节点变了"。

5.3 Diff 三大核心假设

React 放弃了"通用树最小编辑距离算法"(O(n³) 太慢),改用 O(n) 的启发式

假设含义工程意义
同层比较,不跨层不去管"A 节点的子节点变成 B 节点的子节点"这种极端情况大树变小树 → 整棵销毁重建
同类型组件继续 Diff<div><div>FooFoo跨类型(如 div → span)→ 直接销毁重建
同 key 同类型可复用列表渲染 key 稳定的元素可以原地复用key 决定节点身份,写错就重建

5.4 单节点 Diff:reconcileSingleElement

flowchart TD
    A[新节点] --> B{当前 fiber 同类型?}
    B -->|是| C[复用,props 走 update]
    B -->|否| D[标记 Deletion 旧 fiber]
    D --> E[新 fiber 走 Placement 创建]
    C --> F[继续处理 children]
    E --> F

5.5 列表 Diff:key 的作用

flowchart LR
    subgraph 旧
      A1[A key=1] --> A2[B key=2] --> A3[C key=3] --> A4[D key=4]
    end
    subgraph 新
      B1[A key=1] --> B2[B key=2] --> B3[C key=3] --> B4[D key=5]
    end
操作没有 key(用 index)有稳定 key(id)
末尾新增 DD 被识别为新增 ✓D 被识别为新增 ✓
中间插入 E后续所有节点都 “变了” ✗只新增 E,1/2/3/4 原地复用 ✓

⚠️ 新手必踩的坑:用 index 当 key。当你"在列表头部插入一项"时,所有 key 都后移一位,React 误判"所有节点都变了",列表项的 input 焦点、滚动位置、动画全部乱套。永远用稳定 ID 当 key

5.6 Fiber 架构:把"渲染"变成可中断的

React 15 的 Stack Reconciler 一旦开始 diff 就不能停,主线程被卡到几十毫秒。React 16 引入 Fiber,把一棵 VDOM 树变成双向链表,每个节点是个 Fiber 节点,可以"做一点 → 让出主线程 → 再做一点"。

flowchart LR
    A[setState] --> B[Scheduler
排任务优先级] B --> C[Reconciler
构建 workInProgress 树] C --> D[每完成一个 Fiber
检查 shouldYield?] D -->|是| E[让出主线程] E -->|下一个时间片| C D -->|否| C C --> F[Commit 阶段
一次性更新 DOM]

Fiber 的两个阶段

阶段是否可中断做什么
Render(reconciliation)✅ 可中断跑 diff、构建 workInProgress Fiber 树、收集 effect
Commit❌ 不可中断一次性把变更 commit 到 DOM、跑 useEffect 回调

结论:在 Render 阶段不要写副作用(如 setState、订阅),它可能多次执行;副作用都放 useEffect(Commit 后跑)。

六、原理篇:Schedule 调度

类比:调度器像机场塔台——紧急航班(用户输入、动画)立刻起飞;普通航班(数据加载后的渲染)排进队列;远机位(低优先级数据)可以等。如果塔台不分类,所有飞机抢道,机场就瘫痪——这就是 React 15 老 Stack 架构的"主线程长任务"问题。

6.1 为什么需要调度

flowchart LR
    A[一次大列表 setState
10000 节点] --> B[React 15 Stack
同步递归 diff] B --> C[主线程阻塞 200ms+] C --> D[用户点击按钮无反应] D --> E[体验:卡死] A2[一次大列表 setState] --> B2[React 16+ Fiber + Schedule] B2 --> C2[分片 + 可中断] C2 --> D2[每个时间片检查高优先级] D2 --> E2[用户输入立刻插入] E2 --> F2[体验:丝滑]

核心目标:让高优先级更新(用户输入)能插队打断低优先级更新(大数据渲染)。

6.2 Lane 模型(React 18+)

React 用"lane(车道)“给更新分类,每种更新有自己的车道

Lane含义例子
SyncLane同步车道,立即执行flushSync
InputContinuousLane连续输入(拖拽、滚动)onScroll / onPointerMove
DefaultLane默认普通更新setState、网络回调
TransitionLane过渡更新startTransition
RetryLane重试车道Suspense 失败重试
flowchart LR
    A[调度器] --> B{优先级}
    B -->|SyncLane| C[立即执行]
    B -->|InputContinuous| D[5ms 内必执行]
    B -->|DefaultLane| E[排进队列]
    B -->|TransitionLane| F[低优先级
可被打断]

6.3 startTransition:把更新变"非紧急”

import { startTransition, useState, useDeferredValue } from 'react'

function Search({ list }: { list: string[] }) {
  const [input, setInput] = useState('')
  const [filtered, setFiltered] = useState(list)

  function onChange(e: React.ChangeEvent<HTMLInputElement>) {
    setInput(e.target.value)                                      // 紧急:输入框必须立即回显
    startTransition(() => {
      setFiltered(list.filter(it => it.includes(e.target.value))) // 非紧急:过滤大列表慢慢来
    })
  }

  return (
    <>
      <input value={input} onChange={onChange} />
      <List items={filtered} />
    </>
  )
}

核心点

  • 输入框的回显是紧急的——必须立即响应,否则用户觉得卡。
  • 列表过滤是非紧急的——慢一点用户也接受,因为他在打字。
  • 调度器会让紧急更新插队,过滤逻辑在后台慢慢算。

6.4 Scheduler 包:时间片切分

调度器(scheduler 包)用 MessageChannel(在浏览器里)把工作切成 5ms 一个时间片:

sequenceDiagram
    participant Browser as 浏览器主线程
    participant Sched as Scheduler
    participant Fiber as Reconciler
    Browser->>Sched: 注册任务
    loop 每个时间片
        Sched->>Fiber: work()
        Fiber-->>Sched: 返回 nextUnitOfWork
        Sched->>Browser: 让出主线程 (MessageChannel)
        Browser->>Browser: 渲染 / 响应输入
        Browser->>Sched: 下一个 tick
    end
    Sched->>Fiber: 全部完成,进入 commit
    Fiber->>Browser: 一次性更新 DOM

关键点:每个时间片结束就 MessageChannel.postMessage,把控制权交还浏览器,让浏览器有机会处理输入事件 / 跑动画帧。这正是 Concurrent Mode “看起来不卡” 的本质。

6.5 useTransition vs useDeferredValue

维度useTransitionuseDeferredValue
触发点setState再"复制"一份
用法startTransition(() => setX(v))const deferred = useDeferredValue(x)
适合自己控制何时进入 transition接别人的状态、想"延迟"它

6.6 排查与坑

⚠️ 坑 1:在 transition 里写 await——会导致调度器放弃当前任务,回退到同步模式。transition 应当是纯计算,异步加载数据用 Suspense

⚠️ 坑 2:滥用 transition 把所有 setState 包起来。transition 内的 setState 是低优先级的——用户立刻看到的反馈(按钮按下的高亮)也变低优先级,反而会延迟 100ms 才看到反馈只包"重计算"那一段

⚠️ 坑 3:dev 模式下 StrictMode 会双调用 render。这是 React 故意做的"幂等性检查"——生产环境不会双调用,不要因为双调用误判性能问题。

7.0e useTransition 详解(补充)

它能帮助开发者处理紧急和非紧急的状态更新,以此优化用户体验和性能

基本概念

在 React 里,状态更新一般是紧急的,也就是一旦触发更新,React 会立刻重新渲染组件。但某些状态更新(例如搜索结果的过滤、切换视图等)并非紧急,即时更新可能会让页面卡顿,影响用户体验。useTransition 允许你把这些非紧急的更新标记为过渡(transition),从而让 React 可以在不阻塞 UI 的情况下处理它们。

const [isPending, startTransition] = useTransition();
  • isPending:这是一个布尔值,用于表明过渡是否正在进行。在过渡期间,isPendingtrue,过渡结束后则变为 false。你可以利用这个值来显示加载状态,如加载指示器。
  • startTransition:这是一个函数,用于将非紧急的状态更新包装成过渡。它接收一个回调函数作为参数,在回调函数里进行的状态更新会被标记为非紧急。

使用示例

示例 1:基本使用

import React, { useState, useTransition } from 'react';

const App = () => {
    const [inputValue, setInputValue] = useState('');
    const [list, setList] = useState([]);
    const [isPending, startTransition] = useTransition();

    const handleInputChange = (e) => {
        const value = e.target.value;
        setInputValue(value);

        startTransition(() => {
            // 模拟一个耗时的过滤操作
            const newList = Array.from({ length: 10000 }).map((_, index) => `Item ${index}`).filter(item => item.includes(value));
            setList(newList);
        });
    };

    return (
        <div>
            <input
                type="text"
                value={inputValue}
                onChange={handleInputChange}
                placeholder="Search..."
            />
            {isPending && <p>Loading...</p>}
            <ul>
                {list.map(item => (
                    <li key={item}>{item}</li>
                ))}
            </ul>
        </div>
    );
};

export default App;

在这个例子中:

  • 当输入框内容改变时,handleInputChange 函数会立即更新 inputValue(紧急更新)。
  • 然后使用 startTransition 将过滤列表的操作和更新 list 状态的操作包装起来,这些操作会被标记为非紧急。
  • isPending 用于在过渡期间显示加载提示。

示例 2:结合 useEffect

import React, { useState, useTransition, useEffect } from 'react';

const App = () => {
    const [isPending, startTransition] = useTransition();
    const [count, setCount] = useState(0);

    useEffect(() => {
        const intervalId = setInterval(() => {
            startTransition(() => {
                setCount(prevCount => prevCount + 1);
            });
        }, 1000);

        return () => clearInterval(intervalId);
    }, []);

    return (
        <div>
            {isPending && <p>Updating...</p>}
            <p>Count: {count}</p>
        </div>
    );
};

export default App;

在这个例子中,使用 useEffect 设置了一个定时器,每秒调用一次 startTransition 来更新 count 状态,把状态更新标记为非紧急。

注意事项

  • 紧急更新优先:紧急更新(如事件处理函数中的状态更新)会优先于过渡更新执行。
  • 避免嵌套过渡:虽然可以嵌套调用 startTransition,但通常不建议这样做,以免让代码逻辑变得复杂。
  • 性能优化:合理使用 useTransition 能避免不必要的渲染,提高应用性能,特别是在处理大量数据或复杂计算时。

七、Hooks 补齐:剩余 Hook 全家桶

类比:基础 hooks(useState/useEffect/useRef)是"菜刀、砧板、锅"——每家厨房必备;下面这几个是"料理机 / 烤箱 / 蒸锅"——进阶菜才用得着,但会用它们才能从"会做饭"升级到"做大餐"。

7.1 useImmer

类比useState 是"必须自己 new 一个对象再 setState"的紧箍咒,useImmer 是"直接 draft.name = 'x' 也能产生不可变更新"的魔法棒——它内部帮你做了"克隆 + 赋值 + 触发更新"三件套。

npm i immer use-immer
import { useImmer } from 'use-immer'

interface State {
  user: { name: string; age: number }
  tags: string[]
}

function Profile() {
  const [state, updateState] = useImmer<State>({
    user: { name: 'Alice', age: 18 },
    tags: ['vip'],
  })

  // 直接 draft 改写,无需展开
  function birthday() {
    updateState(draft => {
      draft.user.age += 1                 // 嵌套对象也安全
      draft.tags.push('gold')             // 数组也安全
    })
  }

  return <button onClick={birthday}>+1 </button>
}

什么时候用

  • 嵌套层级 ≥ 3 的对象 / 数组(手写展开 setState(s => ({ ...s, a: { ...s.a, b: 1 } })) 难读易错)。
  • 多处独立字段要"局部更新"(如 updateState(d => { d.x = 1; d.y = 2 }))。

什么时候别用

  • 单层状态(useState(0))——多一层 draft 反而拖慢。
  • 极高性能敏感场景——immer 有结构共享的开销。

7.2 useSyncExternalStore

类比:普通的 useState 是"自己的保险柜",useSyncExternalStore 是"接入外部老板(Redux、Zustand store、WebSocket 推送)“的接口。老板一变,你立刻收到通知——而且 React 18 concurrent 模式下还能"撕掉旧通知”,避免 tearing(撕裂)。

import { useSyncExternalStore } from 'react'

// 一个外部 store
const createStore = (initial: number) => {
  let state = initial
  const listeners = new Set<() => void>()
  return {
    get: () => state,
    set: (next: number) => { state = next; listeners.forEach(l => l()) },
    subscribe: (cb: () => void) => { listeners.add(cb); return () => listeners.delete(cb) },
  }
}

const counter = createStore(0)

function Counter() {
  const value = useSyncExternalStore(
    counter.subscribe,   // 订阅
    counter.get,         // 取值
    () => 0              // 服务端渲染快照(可省略)
  )
  return <button onClick={() => counter.set(value + 1)}>{value}</button>
}

什么时候用

  • 自己实现轻量全局 store(替代 Zustand 的极简场景)。
  • 接入非 React 数据源(WebSocket、EventEmitter、IndexedDB 观察者)。
  • 组件库需要保证并发模式下数据不撕裂。

三个参数的语义

  • subscribe(cb):订阅 store,返回 unsubscribe。
  • getSnapshot()必须返回不可变值(同一引用),否则 React 会无限重渲染。
  • getServerSnapshot():SSR 时取的快照(可省略)。

7.3 useDeferredValue

类比useDeferredValue 像"快递签收"——你拿到的是签收单副本,原件在路上慢慢走。value 一变,deferred 也跟着变,但它的更新被标记为 transition,可被打断、不会卡住输入。

import { useDeferredValue, useMemo, useState } from 'react'

function HeavyList({ query }: { query: string }) {
  const deferredQuery = useDeferredValue(query)        // 延迟版本
  const isStale = query !== deferredQuery              // 是否"还在追"

  // 重计算依赖 deferredQuery(低优先级)
  const list = useMemo(() => {
    return bigData.filter(it => it.includes(deferredQuery))
  }, [deferredQuery])

  return (
    <>
      {isStale && <span>(加载中…)</span>}
      <List items={list} />
    </>
  )
}

useTransition 怎么选

  • setState 的人 → 用 useTransition
  • 只能接别人传进来的 value → 用 useDeferredValue

7.4 useLayoutEffect

类比useEffect 是"在浏览器画完之后才做的事"(异步),useLayoutEffect 是"在浏览器画之前抢着改 DOM"(同步)。它会在 React 把变更提交到 DOM 之后、浏览器下一帧绘制之前跑——会阻塞绘制

import { useLayoutEffect, useRef } from 'react'

function AutoFocus({ value }: { value: string }) {
  const ref = useRef<HTMLInputElement>(null)

  // 必须等 DOM 准备好再 focus,否则会"焦点闪一下"
  useLayoutEffect(() => {
    ref.current?.focus()
  }, [value])

  return <input ref={ref} defaultValue={value} />
}

什么时候用

  • 读 layout(DOM 尺寸、scroll 位置)并立刻重设 layout。
  • 阻止"先看到旧值,再看到新值"的视觉闪烁。

坑点SSR 时 useLayoutEffect 会报警告(服务端没有 DOM)。要么用 useEffect,要么用 typeof window !== 'undefined' && useLayoutEffect(...) 保护。

7.5 useImperativeHandle

类比:默认情况下父组件拿到子组件 ref,能看到的"菜单"是子组件的整个 DOM(ref.current = <input>...)。useImperativeHandle 让你自定义菜单——只暴露几个方法,隐藏内部实现

import { forwardRef, useImperativeHandle, useRef, useState } from 'react'

interface Handle {
  focus: () => void
  getValue: () => string
  clear: () => void
}

const FancyInput = forwardRef<Handle, { placeholder?: string }>(
  ({ placeholder }, ref) => {
    const inputRef = useRef<HTMLInputElement>(null)
    const [, setTick] = useState(0)

    useImperativeHandle(ref, () => ({
      focus: () => inputRef.current?.focus(),
      getValue: () => inputRef.current?.value ?? '',
      clear: () => {
        if (inputRef.current) inputRef.current.value = ''
        setTick(t => t + 1)  // 触发重渲染
      },
    }))

    return <input ref={inputRef} placeholder={placeholder} />
  }
)

// 父组件
function Parent() {
  const ref = useRef<Handle>(null)
  return (
    <>
      <FancyInput ref={ref} placeholder="搜一下" />
      <button onClick={() => ref.current?.clear()}>清空</button>
    </>
  )
}

使用建议:能用 props 解决的不要用 useImperativeHandle。它本质是"命令式逃生口",主要给命令式 UI 库(编辑器、地图)做集成用。

7.6 useDebugValue

类比:给 React DevTools 看的"调试便签"——不显示在 UI,只在 DevTools 里给组件打个标签。

import { useDebugValue } from 'react'

function useFriendStatus(friendID: number) {
  const [isOnline, setIsOnline] = useState(false)
  // 简化:实际是订阅 / 取消订阅
  useEffect(() => { /* ... */ }, [friendID])

  // 在 DevTools 里看这个 Hook:FriendStatus: Online
  useDebugValue(isOnline ? 'Online' : 'Offline')
  return isOnline
}

格式化用法(避免每次渲染都重算调试值):

useDebugValue(date, d => d.toISOString())

第二个参数仅在 DevTools 打开时执行——生产构建里完全被擦除

7.7 useId

类比:服务端渲染时,组件在服务端和客户端会跑两遍。useId 像"出生证明"——给元素发一个 SSR 安全的稳定 id,保证两端 hydration 不报错。

import { useId } from 'react'

function FormField() {
  const id = useId()                       // ":r0:" 类似的稳定 id
  return (
    <>
      <label htmlFor={id}>姓名</label>
      <input id={id} type="text" />
    </>
  )
}

什么时候用

  • 写可复用的 UI 组件(需要把 label 关联到 inputid)。
  • 服务端渲染的项目(避免 Math.random() / Date.now() 导致的 hydration 警告)。

坑点:不要拿 useId 当列表 key——useId 每次挂载顺序相同但跨实例会变,不保证稳定。

7.8 Hooks 选型速查表

你要做的事选这个
存简单状态useState
嵌套对象/数组局部更新useImmer
复杂状态机/多 actionuseReducer
副作用(请求、订阅、定时器)useEffect
同步读 layout 改 DOMuseLayoutEffect
DOM 引用 / 跨渲染可变值useRef
缓存计算useMemo
缓存函数useCallback
跨组件共享useContext
外部 store 接入useSyncExternalStore
降级重计算的优先级useDeferredValue / useTransition
暴露命令式方法useImperativeHandle
DevTools 调试标签useDebugValue
SSR 稳定 iduseId

八、组件篇

类比:组件就像乐高积木。每一块积木有固定的接口(凸起和凹槽),你不需要知道它内部怎么铸造的,只要接口对得上就能拼。React 组件也是一样——接收 props(输入),返回一段 JSX(输出),内部状态自己管自己。一个大页面就是把几十块"积木"按树形结构拼起来的结果。

8.1 初识组件

函数组件 vs 类组件

React 有两种写组件的方式:函数组件类组件。自 React 16.8 引入 Hooks 以后,函数组件已经成为主流,类组件基本只在维护老项目时才会遇到。

先看两者的对比:

// 步骤 1:函数组件——一个接收 props、返回 JSX 的纯函数
type GreetingProps = {
  name: string;
};

function Greeting({ name }: GreetingProps) {
  return <h1>Hello, {name}!</h1>;
}
// 步骤 2:类组件——继承 React.Component,用 render 方法返回 JSX
import { Component } from 'react';

type GreetingProps = {
  name: string;
};

class Greeting extends Component<GreetingProps> {
  render() {
    return <h1>Hello, {this.props.name}!</h1>;
  }
}

两者核心区别如下表:

维度函数组件类组件
写法一个函数,接收 props 返回 JSX继承 React.Component,实现 render()
状态管理useState / useReducerthis.state + this.setState
生命周期useEffect / useLayoutEffectcomponentDidMount 等一堆方法
this 绑定this,不存在绑定问题事件处理函数需手动 .bind(this)
Hooks 支持原生支持不支持
官方态度推荐不再推荐新写,仅维护兼容

⚠️ 新手必踩的坑:类组件事件处理 this 丢失。类组件里写 onClick={this.handleClick},点击时报 Cannot read properties of undefined——因为 React 事件系统会把回调"拆下来"再调用,this 丢了。解决方式:要么在构造函数里 this.handleClick = this.handleClick.bind(this),要么用箭头函数属性 handleClick = () => { ... }。函数组件根本没有 this,天然没有这个问题。

组件的 props 与 children

props 是组件的输入参数,和函数参数一个道理——父组件传什么,子组件就收到什么。props 是只读的,子组件不能修改自己的 props,只能通过回调通知父组件去改。

children 是一个特殊的 prop,代表组件标签之间包裹的内容:

// 步骤 1:定义一个 Card 组件,用 children 接收"包裹内容"
type CardProps = {
  title: string;
  children: React.ReactNode;
};

function Card({ title, children }: CardProps) {
  return (
    <div className="card">
      <h2>{title}</h2>
      {/* 步骤 2:children 会被原样渲染到这个位置 */}
      <div className="card-body">{children}</div>
    </div>
  );
}

// 步骤 3:使用时把任意 JSX 塞到 Card 标签中间
function App() {
  return (
    <Card title="用户信息">
      <p>姓名:张三</p>
      <p>年龄:28</p>
    </Card>
  );
}

children 的类型是 React.ReactNode,它可以是一个字符串、一个元素、一个数组、甚至 null。这让 CardModalLayout 这类"容器型组件"非常灵活。

⚠️ 新手必踩的坑:把 children 当字符串截取children 不一定是字符串,也可能是一堆 JSX 元素。如果你要对 children 做文本处理(比如截断),先用 React.Children.toArray 规范化,再判断类型,别直接 .slice()

组件的命名规范和文件组织

React 有一套约定俗成的命名规范,遵守它能让团队协作更顺畅:

规范说明示例
组件名 PascalCase组件名首字母大写,React 靠首字母大小写区分自定义组件和 HTML 标签UserCardLoginPage
文件名与组件名一致一个文件导出一个组件,文件名即组件名UserCard.tsx
hooks 用 use 前缀自定义 Hook 以 use 开头useAuthuseDebounce
工具函数 camelCase非组件的普通函数小驼峰formatDateparseToken
常量全大写下划线配置常量用 UPPER_SNAKE_CASEMAX_RETRYAPI_BASE_URL

推荐的目录组织结构:

src/
├── components/          # 通用展示组件(纯 UI,不含业务逻辑)
│   ├── Button/
│   │   ├── index.tsx    # 组件实现
│   │   └── style.css    # 组件样式
│   └── Modal/
│       └── index.tsx
├── pages/               # 页面级组件(和路由一一对应)
│   ├── Home/
│   └── Login/
├── hooks/               # 自定义 Hook
│   ├── useAuth.ts
│   └── useDebounce.ts
├── utils/               # 工具函数
├── types/               # 全局类型定义
└── App.tsx

⚠️ 新手必踩的坑:组件名小写导致不渲染。如果你写 function userCard() { ... } 然后 <userCard />,React 会把它当成一个 HTML 标签(像 <div>),结果渲染出一个空的 <usercard> 元素,控制台也不报错。组件名必须首字母大写

组件树结构

一个 React 应用就是一棵组件树。根组件 App 在最顶端,每个组件可以嵌套子组件,层层往下展开。下面这张图展示了一个典型后台管理系统的组件树:

flowchart TD
    A[App 根组件] --> B[Header 顶栏]
    A --> C[Sidebar 侧边栏]
    A --> D[MainContent 主内容区]
    B --> B1[Logo]
    B --> B2[UserMenu 用户菜单]
    B2 --> B2a[Dropdown 下拉菜单]
    C --> C1[NavItem 导航项 1]
    C --> C2[NavItem 导航项 2]
    D --> D1[PageHeader 页面头]
    D --> D2[DataTable 数据表格]
    D2 --> D2a[TableHeader]
    D2 --> D2b[TableRow 行组件]
    D2 --> D2c[Pagination 分页]

这张图在讲什么:从 App 根节点出发,组件像树枝一样往下生长。数据从树根往下流(props),事件从树叶往上冒(回调)。理解组件树是理解 React 数据流的前提——后面所有的通讯方式,本质上都是在"这棵树"上传递信息。


8.2 组件通讯

类比:组件通讯就像公司里的沟通。父传子是"领导给下属派活"(自上而下,直接给指令);子传父是"下属向领导汇报"(自下而上,靠领导事先留好的汇报渠道);兄弟通讯是"两个同事协作"——他们没有直接通道,得找一个共同领导当中转(状态提升);跨层级通讯是"CEO 发全员公告"——不需要一层层传达,直接广播给所有人(Context)。

React 遵循单向数据流:数据只能从父组件流向子组件,子组件不能直接修改父组件的数据。所有通讯方式都是在这个约束下设计的。

下面这张图概括了四种通讯方向:

flowchart LR
    subgraph 父传子
        P1[父组件] -->|props| C1[子组件]
    end
    subgraph 子传父
        C2[子组件] -->|调用回调| P2[父组件]
    end
    subgraph 兄弟通讯
        C3[子 A] -->|回调上交| P3[共同父组件]
        P3 -->|props 下发| C4[子 B]
    end
    subgraph 跨层级
        P4[Provider 提供者] -.->|Context 广播| C5[深层子组件]
    end

父传子:props

父组件通过 props 把数据传给子组件,这是最基本的通讯方式。

基本属性传递

// 步骤 1:子组件声明 props 类型
type UserCardProps = {
  name: string;
  age: number;
};

function UserCard({ name, age }: UserCardProps) {
  return (
    <div>
      <p>姓名:{name}</p>
      <p>年龄:{age}</p>
    </div>
  );
}

// 步骤 2:父组件把数据写在标签属性上
function App() {
  return <UserCard name="John" age={30} />;
}

展开运算符传递:当属性很多时,逐个写很冗长。可以用展开运算符 ... 一次性把整个对象的属性铺到组件上:

type UserCardProps = {
  name: string;
  age: number;
};

function UserCard({ name, age }: UserCardProps) {
  return (
    <div>
      <p>姓名:{name}</p>
      <p>年龄:{age}</p>
    </div>
  );
}

function App() {
  // 步骤 1:把数据组织成对象
  const person = {
    name: 'John',
    age: 30,
  };

  // 步骤 2:用展开运算符一次性传递所有属性
  return <UserCard {...person} />;
}

⚠️ 新手必踩的坑:展开运算符传入多余属性{...person} 会把 person所有属性都传进去,如果子组件的 props 类型没有定义某个属性,TypeScript 在严格模式下会报错。用展开运算符前,确认对象的字段和组件 props 类型完全匹配。

子传父:回调函数

子组件不能直接改父组件的数据,但父组件可以传一个回调函数下来,子组件调用它来"通知"父组件。

import { useState } from 'react';

// 步骤 1:子组件声明一个回调类型的 prop
type ChildProps = {
  onClick: (msg: string) => void;
};

function Child({ onClick }: ChildProps) {
  const handleClick = () => {
    // 步骤 2:子组件调用父组件传下来的函数,把数据"送回去"
    onClick('Hello from child!');
  };

  return <button onClick={handleClick}>点击调用父组件函数</button>;
}

// 步骤 3:父组件定义回调,通过 props 传给子组件
function Parent() {
  const [message, setMessage] = useState('');

  const handleChildClick = (msg: string) => {
    setMessage(msg);
  };

  return (
    <div>
      <h1>父组件</h1>
      <p>接收到的消息:{message}</p>
      <Child onClick={handleChildClick} />
    </div>
  );
}

这里的数据流是:子组件触发事件 → 调用 props.onClick → 父组件的 handleChildClick 执行 → setMessage 更新父组件状态 → 父组件重新渲染。数据始终在父组件手里,子组件只是"按了门铃"。

兄弟组件通讯:状态提升

两个兄弟组件没有直接通道,它们的共同父组件就是唯一的"中转站"。把共享状态放到共同父组件上,这个过程叫状态提升(Lifting State Up)

import { useState } from 'react';

// 步骤 1:兄弟 A——输入框,把值通过回调上报给父组件
type InputProps = {
  value: string;
  onChange: (val: string) => void;
};

function SearchInput({ value, onChange }: InputProps) {
  return (
    <input
      value={value}
      onChange={(e) => onChange(e.target.value)}
      placeholder="输入搜索关键词"
    />
  );
}

// 步骤 2:兄弟 B——展示区,从父组件接收 value 来显示
function SearchResult({ value }: { value: string }) {
  return <p>搜索结果:{value || '暂无输入'}</p>;
}

// 步骤 3:父组件持有共享状态,分发给两个子组件
function Parent() {
  const [keyword, setKeyword] = useState('');

  return (
    <div>
      <SearchInput value={keyword} onChange={setKeyword} />
      <SearchResult value={keyword} />
    </div>
  );
}

数据流路径:SearchInput 输入 → onChange 回调 → 父组件 setKeyword → 状态更新 → SearchResult 收到新 value 重新渲染。状态"提升"到了父组件,两个兄弟通过父组件间接通讯。

⚠️ 新手必踩的坑:状态提升过度。不是所有共享数据都要提升。如果只有两个相邻组件用,提升到共同父即可;如果跨了很多层,应该用 Context 而不是一层层提升——否则中间每个组件都要当"传声筒",props 层层透传,维护成本很高。

跨层级通讯:Context

当数据需要穿过很多层组件时(比如"当前用户信息"“主题色"“国际化语言”),一层层传 props 太痛苦。Context 提供了一种"广播"机制:Provider 提供数据,任何深层的后代组件都能用 useContext 直接拿到,不需要中间组件转发。

import { createContext, useContext, useState, type ReactNode } from 'react';

// 步骤 1:创建 Context,并声明数据类型
type User = {
  name: string;
  age: number;
};

const UserContext = createContext<User | null>(null);

// 步骤 2:子组件用 useContext 直接读取数据,跳过中间层
function ChildComponent() {
  const user = useContext(UserContext);

  if (!user) {
    return <p>未登录</p>;
  }

  return (
    <div>
      <p>用户名称:{user.name}</p>
      <p>用户年龄:{user.age}</p>
    </div>
  );
}

// 步骤 3:父组件用 Provider 包裹子树,提供数据
function Parent() {
  const [user] = useState<User>({
    name: 'John',
    age: 30,
  });

  return (
    <UserContext.Provider value={user}>
      <div>
        <h1>父组件</h1>
        {/* 中间不管隔多少层,ChildComponent 都能直接拿到 user */}
        <ChildComponent />
      </div>
    </UserContext.Provider>
  );
}

下面这张图对比了"层层 props 透传"和"Context 广播"的区别:

flowchart LR
    subgraph 层层透传
        A1[App] -->|props user| B1[Layout]
        B1 -->|props user| C1[Sidebar]
        C1 -->|props user| D1[UserInfo]
    end
    subgraph Context 广播
        A2[App Provider] -.->|Context| D2[UserInfo]
    end

左边是 props 透传:user 要从 App 一路传到 UserInfo,中间的 LayoutSidebar 被迫接收一个自己用不到的 user prop 再转发。右边是 Context:AppProvider 提供数据,UserInfo 直接 useContext 拿到,中间组件完全不需要感知这个数据。

⚠️ 新手必踩的坑:Context 值变化导致全量重渲染。Context 的 value 变化时,所有消费该 Context 的组件都会重新渲染。如果你把一个频繁变化的状态(比如表单输入)放进 Context,会导致大量组件跟着重渲染。解决方案:对高频变化的数据用状态管理库(Zustand / Redux),Context 只放低频全局数据(用户信息、主题、语言)。

四种通讯方式选型速查

场景方案关键词
父给子传数据props自上而下
子通知父回调函数 props自下而上
两个兄弟共享数据状态提升到共同父中转站
跨多层共享全局数据Context广播
大量组件共享频繁变化的状态Zustand / Redux外部 store

8.3 受控组件与非受控组件

类比:受控组件像"方向盘和仪表盘同步”——你打方向盘(改变 state),仪表盘立刻显示当前方向(value 跟着 state 走),数据只有一个来源。非受控组件像"后视镜"——镜子里的画面(DOM 里的值)你不主动看就不知道,你得转头去瞄一眼(用 ref 读),React 不管镜子里的内容。

受控组件:状态即单一数据源

受控组件的核心思想:表单元素的值由 React state 控制value 绑定到 state,onChange 里更新 state,数据流形成一个闭环。state 是唯一的"真相源",UI 只是 state 的投影。

下面这张图展示受控组件的数据流:

flowchart LR
    A[用户输入] -->|onChange 事件| B[setState 更新状态]
    B -->|state 变化| C[组件重新渲染]
    C -->|value 绑定 state| D[input 显示新值]
    D -->|用户继续输入| A

这张图在讲什么:用户输入触发 onChange → 更新 state → 组件重渲染 → input 的 value 从 state 取最新值 → 显示更新。数据绕了一圈又回到 UI,整个过程 state 始终是唯一的"数据源",input 永远显示 state 的值。

文本输入框(input)

import { useState } from 'react';

function TextInputForm() {
  // 步骤 1:用 state 存储输入框的值
  const [inputValue, setInputValue] = useState('');

  // 步骤 2:onChange 时把新值写入 state
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setInputValue(e.target.value);
  };

  // 步骤 3:onSubmit 时从 state 读最终值
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    console.log('提交的内容:', inputValue);
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* value 绑定 state,onChange 更新 state——数据流闭环 */}
      <input
        type="text"
        value={inputValue}
        onChange={handleChange}
        placeholder="请输入内容"
      />
      <button type="submit">提交</button>
    </form>
  );
}

复选框(checkbox):和文本框不同,复选框绑定的不是 value 而是 checked,读取的是 e.target.checked

import { useState } from 'react';

function CheckboxForm() {
  // 步骤 1:用布尔值 state 存储选中状态
  const [isChecked, setIsChecked] = useState(false);

  const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    // 步骤 2:checkbox 读 checked 而不是 value
    setIsChecked(e.target.checked);
  };

  return (
    <form>
      <input
        type="checkbox"
        checked={isChecked}
        onChange={handleCheckboxChange}
      />
      <label>是否选中({isChecked ? '已选中' : '未选中'}</label>
    </form>
  );
}

下拉框(select)selectvalue 绑定当前选中项的值,onChange 读取 e.target.value

import { useState } from 'react';

function SelectForm() {
  // 步骤 1:state 存储当前选中的 option value
  const [selectedOption, setSelectedOption] = useState('option1');

  const handleSelectChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
    setSelectedOption(e.target.value);
  };

  return (
    <form>
      {/* select 的 value 绑定 state,选中的 option 自动高亮 */}
      <select value={selectedOption} onChange={handleSelectChange}>
        <option value="option1">选项 1</option>
        <option value="option2">选项 2</option>
        <option value="option3">选项 3</option>
      </select>
    </form>
  );
}

多行文本框(textarea):在 React 中,textarea 不像原生 HTML 那样把内容放在标签中间,而是用 value 属性绑定,和 input 完全一致:

import { useState } from 'react';

function TextareaForm() {
  const [textareaValue, setTextareaValue] = useState('');

  const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
    setTextareaValue(e.target.value);
  };

  return (
    <form>
      {/* React 中 textarea 用 value 而不是标签内文本 */}
      <textarea
        value={textareaValue}
        onChange={handleTextareaChange}
        placeholder="请输入多行文本"
      />
    </form>
  );
}

⚠️ 新手必踩的坑:受控组件设了 value 却没写 onChange。如果你给 input 设了 value={someState} 但忘了写 onChange,输入框会变成"只读"——你打字没反应,控制台会报警告 You provided a value prop to a form field without an onChange handler。React 认为你既然用 state 控制了值,就必须提供更新它的渠道。

非受控组件:用 ref 直接操作 DOM

非受控组件让表单元素自己管理状态(就像原生 HTML 那样),React 不介入数据流,需要时用 ref 去读 DOM 上的值。

import { useRef } from 'react';

function UncontrolledForm() {
  // 步骤 1:用 useRef 创建一个引用,关联到 input 元素
  const inputRef = useRef<HTMLInputElement>(null);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    // 步骤 2:提交时用 ref.current 读取 DOM 上的值
    console.log('提交的内容:', inputRef.current?.value);
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* 步骤 3:用 defaultValue 设置初始值(不是 value),ref 关联 DOM */}
      <input
        type="text"
        ref={inputRef}
        defaultValue=""
        placeholder="请输入内容"
      />
      <button type="submit">提交</button>
    </form>
  );
}

注意这里用的是 defaultValue 而不是 value——这是非受控组件的标志。React 只在初次渲染时把 defaultValue 设上去,之后 input 的值完全由用户输入决定,React 不再管。

⚠️ 新手必踩的坑:非受控组件用 value 而非 defaultValue。如果你写 value="" 又不写 onChange,input 就被锁死在空字符串。非受控组件必须用 defaultValue / defaultChecked 来设初始值,否则 React 会强制控制它。

何时用受控、何时用非受控

维度受控组件非受控组件
数据源React stateDOM 自身
读取值直接读 stateref.current.value
实时校验每次 onChange 都能校验只有提交时才能校验
条件禁用按钮state 变化自动更新 UI需要手动操作 DOM
代码量多一些(state + handler)少一些
适用场景需要实时验证、动态联动、格式化输入一次性表单、文件上传、集成第三方非 React 库

选型建议

  • 默认用受控组件。绝大多数表单场景受控更合适——你可以实时校验、动态联动、控制按钮禁用状态。
  • 以下场景用非受控
    • input[type="file"](文件上传,value 只读,必须用 ref)
    • 简单的一次性表单,不需要实时校验和联动
    • 集成非 React 的第三方库(如 jQuery 插件),它直接操作 DOM

8.4 异步组件

类比:异步组件像"按需上菜的餐厅"。你不会一进门就把整本菜单的菜全做一遍(那样厨房炸了、客人也等太久),而是客人点哪道菜才做哪道菜。React.lazy 就是"点菜单",Suspense 是"等菜时先上一壶茶"(fallback UI),Error Boundary 是"菜做糊了给你换一道"(错误兜底)。

React.lazy + Suspense 的基本用法

React.lazy 让你定义一个"动态加载"的组件——它不会在应用启动时就加载,而是在第一次被渲染时才去请求对应的 chunk。Suspense 负责在组件加载期间显示一个占位 UI。

import { Suspense, lazy } from 'react';

// 步骤 1:用 lazy 包装动态 import,HeavyChart 不会在启动时加载
const HeavyChart = lazy(() => import('./HeavyChart'));

function Loading() {
  return <div>图表加载中...</div>;
}

function App() {
  return (
    // 步骤 2:用 Suspense 包裹懒加载组件,指定 fallback
    <Suspense fallback={<Loading />}>
      <HeavyChart />
    </Suspense>
  );
}

lazy 接收一个返回 Promise 的函数(通常是 import()),import() 会返回一个模块对象,React 自动取它的 default 导出作为组件。在 HeavyChart.tsx 加载完成之前,Suspense 会渲染 fallback 里的 <Loading />

下面这张图展示懒加载的时序:

sequenceDiagram
    participant U as 用户
    participant R as React 渲染器
    participant N as 网络请求
    participant C as HeavyChart 组件

    U->>R: 首次渲染 App
    R->>N: 触发 import 动态请求 chunk
    R->>U: 先渲染 Suspense fallback 加载中
    N->>C: chunk 下载完成
    C->>R: 组件就绪
    R->>U: 替换 fallback 渲染 HeavyChart

Suspense 的 fallback UI

fallback 可以是任意 React 节点——一段文字、一个 Spinner 动画、甚至一个骨架屏。一个 Suspense 可以包裹多个懒加载组件,所有子组件都加载完成后才会撤掉 fallback:

import { Suspense, lazy } from 'react';

const Dashboard = lazy(() => import('./Dashboard'));
const UserList = lazy(() => import('./UserList'));
const Charts = lazy(() => import('./Charts'));

function App() {
  return (
    <Suspense
      fallback={
        <div style={{ padding: 24 }}>
          <p>页面加载中,请稍候...</p>
        </div>
      }
    >
      {/* Dashboard、UserList、Charts 三个都加载完才撤掉 fallback */}
      <Dashboard />
      <UserList />
      <Charts />
    </Suspense>
  );
}

如果想让每个组件独立显示各自的 loading,就给每个组件单独包一层 Suspense

function App() {
  return (
    <div>
      <Suspense fallback={<div>仪表盘加载中...</div>}>
        <Dashboard />
      </Suspense>
      <Suspense fallback={<div>用户列表加载中...</div>}>
        <UserList />
      </Suspense>
    </div>
  );
}

⚠️ 新手必踩的坑:lazy 组件没有被 Suspense 包裹。如果一个 lazy 组件渲染时外层没有 Suspense,React 会抛出错误 A component suspended while responding to synchronous inputlazy 组件必须Suspense(或 Error Boundary)包裹,否则加载期间 React 不知道该显示什么。

配合路由做懒加载

懒加载最常见的场景是配合 react-router-dom 做路由级代码分割——每个页面打成独立 chunk,用户访问到才加载,首屏体积大幅减小。

import { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

// 步骤 1:每个页面都用 lazy 动态导入
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Dashboard = lazy(() => import('./pages/Dashboard'));

function App() {
  return (
    <BrowserRouter>
      {/* 步骤 2:用一层 Suspense 统一兜底所有路由的加载状态 */}
      <Suspense fallback={<div>页面加载中...</div>}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/about" element={<About />} />
          <Route path="/dashboard" element={<Dashboard />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

这样打包后,HomeAboutDashboard 会分别生成独立的 JS 文件(chunk)。用户访问 / 时只加载 Home 的 chunk,访问 /about 时才加载 About 的 chunk,首屏加载的代码量显著减少。

错误边界 Error Boundary 处理异步加载失败

网络请求可能失败(chunk 文件 404、网络断开等),此时 lazy 的 Promise 会 reject,React 会抛出错误。Suspense 只管加载中状态,不管加载失败——失败要靠**错误边界(Error Boundary)**来兜底。

错误边界是一个类组件(目前函数组件还不支持,因为需要 getDerivedStateFromErrorcomponentDidCatch 生命周期):

import { Component, type ReactNode } from 'react';

type ErrorBoundaryProps = {
  children: ReactNode;
  fallback: ReactNode;
};

type ErrorBoundaryState = {
  hasError: boolean;
};

// 步骤 1:错误边界必须是类组件
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
  constructor(props: ErrorBoundaryProps) {
    super(props);
    this.state = { hasError: false };
  }

  // 步骤 2:子组件抛错时触发,返回新 state 让 fallback 渲染
  static getDerivedStateFromError(): ErrorBoundaryState {
    return { hasError: true };
  }

  // 步骤 3:可选——记录错误日志
  componentDidCatch(error: Error, info: React.ErrorInfo) {
    console.error('组件加载失败:', error, info);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback;
    }
    return this.props.children;
  }
}

ErrorBoundary 包在 Suspense 外层,加载失败时就能显示兜底 UI:

import { Suspense, lazy } from 'react';

const HeavyChart = lazy(() => import('./HeavyChart'));

function App() {
  return (
    // 步骤 1:ErrorBoundary 在最外层兜底错误
    <ErrorBoundary fallback={<div>组件加载失败,请刷新重试</div>}>
      {/* 步骤 2:Suspense 在内层处理加载中状态 */}
      <Suspense fallback={<div>加载中...</div>}>
        <HeavyChart />
      </Suspense>
    </ErrorBoundary>
  );
}

下面这张图展示完整的异步组件加载流程,包含成功和失败两条路径:

flowchart TD
    A[渲染 lazy 组件] --> B{触发动态 import}
    B -->|请求中| C[Suspense 显示 fallback]
    B -->|请求成功| D[组件就绪]
    B -->|请求失败| E[Promise reject]
    C --> D
    D --> F[替换 fallback 渲染真实组件]
    E --> G[ErrorBoundary 捕获错误]
    G --> H[显示错误兜底 UI]

⚠️ 新手必踩的坑:部署后 chunk 404 导致白屏。你发了一个新版本,用户浏览器里还缓存着旧版的 HTML,旧 HTML 引用的 chunk 文件名在新版部署后已经变了(Vite/Webpack 会给 chunk 加 hash),请求就 404 了。解决方案:在 ErrorBoundary 的 fallback 里检测到加载失败时,提示用户"版本已更新,请刷新页面"重新加载。


8.5 HOC 高阶组件

类比:HOC 像"装修公司的精装服务"。你有一套毛坯房(原始组件),精装公司(HOC)拿过去帮你刷墙、铺地板、装灯(注入数据、拦截逻辑、增强功能),然后交还给你一套精装房(增强后的组件)。你不需要自己刷墙,精装公司全包了——而且这套精装流程可以复用到别的房子上。

HOC 的概念:接收组件返回组件的函数

高阶组件(Higher-Order Component,简称 HOC)不是 React 的 API,而是一种设计模式。它的签名是:

组件 → HOC 函数 → 增强后的新组件

一个 HOC 是一个函数,接收一个组件作为参数,返回一个新的组件。新组件在原始组件的基础上做了"增强"。

import { useState, type ComponentType } from 'react';

// 步骤 1:定义 HOC——接收一个组件,返回一个新组件
function withLoading<T extends object>(
  WrappedComponent: ComponentType<T>
) {
  // 步骤 2:返回的新组件
  return function WithLoadingComponent(props: T) {
    const [loading] = useState(false);

    if (loading) {
      return <div>加载中...</div>;
    }

    // 步骤 3:把 props 原样透传给原始组件
    return <WrappedComponent {...props} />;
  };
}

// 步骤 4:用 HOC 包装一个普通组件
const EnhancedUserList = withLoading(UserList);

下面这张图展示 HOC 的包装过程:

flowchart LR
    A[原始组件
UserList] -->|作为参数传入| B[HOC 函数
withLoading] B -->|内部增强| C[新组件
WithLoadingUserList] C -->|渲染时| D{loading 是否为 true} D -->|是| E[显示 加载中] D -->|否| F[渲染原始 UserList
并透传 props]

常见场景

场景一:权限控制——包裹需要登录才能访问的页面,未登录时自动跳转登录页:

import { type ComponentType, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';

function withAuth<T extends object>(WrappedComponent: ComponentType<T>) {
  return function WithAuthComponent(props: T) {
    const navigate = useNavigate();
    const token = localStorage.getItem('token');

    useEffect(() => {
      // 步骤 1:没 token 就跳登录页
      if (!token) {
        navigate('/login');
      }
    }, [token, navigate]);

    if (!token) {
      return <div>请先登录...</div>;
    }

    // 步骤 2:有 token 才渲染原始组件
    return <WrappedComponent {...props} />;
  };
}

// 用法:给任意页面加上登录守卫
const ProtectedDashboard = withAuth(Dashboard);

场景二:日志打点——自动记录组件的挂载和卸载事件:

import { type ComponentType, useEffect } from 'react';

function withLogger<T extends object>(
  WrappedComponent: ComponentType<T>,
  componentName: string
) {
  return function WithLoggerComponent(props: T) {
    useEffect(() => {
      console.log(`[HOC] ${componentName} 已挂载`);
      return () => {
        console.log(`[HOC] ${componentName} 已卸载`);
      };
    }, []);

    return <WrappedComponent {...props} />;
  };
}

const LoggedUserList = withLogger(UserList, 'UserList');

场景三:数据注入——HOC 负责请求数据,把数据作为 props 注入给原始组件:

import { type ComponentType, useEffect, useState } from 'react';

function withUserData<T extends object>(WrappedComponent: ComponentType<T>) {
  return function WithUserDataComponent(props: T) {
    const [user, setUser] = useState<{ name: string; age: number } | null>(null);

    useEffect(() => {
      // 步骤 1:HOC 内部请求数据
      fetch('/api/user')
        .then((res) => res.json())
        .then(setUser);
    }, []);

    if (!user) {
      return <div>加载用户数据中...</div>;
    }

    // 步骤 2:把数据作为额外 prop 注入给原始组件
    return <WrappedComponent {...props} user={user} />;
  };
}

⚠️ 新手必踩的坑:HOC 覆盖了原始组件的同名 props。上面 withUserDatauser 注入进去,但如果原始组件本来就有一个叫 user 的 prop,就会被覆盖。约定:HOC 注入的 props 用独特的前缀(如 injectedUser),或者文档明确说明会注入哪些字段。

HOC 的注意事项

注意一:props 透传。HOC 应该是一个"透明"的中间层——除了它自己增强的部分,其余 props 要原样透传给原始组件,否则原始组件会拿不到父组件传下来的数据:

// 正确:展开运算符透传所有 props
return <WrappedComponent {...props} />;

// 错误:只传了部分 props,其余丢失
return <WrappedComponent name={props.name} />;

注意二:displayName。React DevTools 靠组件的 displayName 来显示名字。HOC 返回的新组件默认叫 WithLoadingComponent,在 DevTools 里看不出它包装的是谁。手动设置 displayName 能让调试更清晰:

function withLoading<T extends object>(WrappedComponent: ComponentType<T>) {
  function WithLoadingComponent(props: T) {
    // ...增强逻辑
    return <WrappedComponent {...props} />;
  }

  // 步骤:设置可读的 displayName
  WithLoadingComponent.displayName = `WithLoading(${WrappedComponent.displayName || WrappedComponent.name})`;

  return WithLoadingComponent;
}

这样在 DevTools 里会显示 WithLoading(UserList),一眼看出谁包装了谁。

注意三:ref 透传ref 不是普通的 prop,它不会自动透传——HOC 返回的新组件会把 ref 指向自己,而不是原始组件。如果需要让外部拿到原始组件的 ref,要用 forwardRef

import { forwardRef, type ComponentType } from 'react';

function withLogging<T extends object>(WrappedComponent: ComponentType<T>) {
  // 步骤 1:用 forwardRef 包裹,把 ref 转发给原始组件
  const WithLogging = forwardRef<InstanceType<typeof WrappedComponent>, T>(
    (props, ref) => {
      console.log('渲染增强组件');
      return <WrappedComponent {...props} ref={ref} />;
    }
  );

  WithLogging.displayName = `WithLogging(${WrappedComponent.displayName || WrappedComponent.name})`;
  return WithLogging;
}

⚠️ 新手必踩的坑:在 render 函数内部调用 HOC。不要写成 render() { const Enhanced = withAuth(MyComp); return <Enhanced />; }——每次渲染都会创建一个新组件类型,React 会卸载旧组件、挂载新组件,导致状态丢失和性能问题。HOC 应该在组件外部调用一次,把结果存成常量。

HOC vs Hooks vs 自定义组件

随着 Hooks 的普及,很多以前用 HOC 实现的场景(数据注入、日志、状态管理)都可以用自定义 Hook 更优雅地实现:

维度HOC自定义 Hook
本质包装组件提取逻辑
嵌套地狱多层 HOC 嵌套时 props 来源不清晰Hook 扁平组合,来源明确
类型推导泛型复杂,类型容易丢失类型推导自然
适用场景需要拦截渲染/包裹 UI(权限守卫、loading)只需要复用逻辑(数据请求、状态管理)

选型建议:新项目优先用自定义 Hook,只有在需要"包裹 UI"(比如统一加 loading 状态、权限拦截渲染)时才用 HOC。


九、API 补充

9.1 createPortal

类比:Portal 像"在别人家院子里摆摊"。正常情况下,React 渲染的 DOM 节点会挂在自己组件树对应的 DOM 位置——就像你只能在自家院子里摆东西。但有些场景(弹窗、Tooltip、全局通知)需要把 DOM 节点渲染到别的位置(比如 document.body 下),Portal 就是让你"把摊位摆到别人院子里"的通道——东西还是你的(事件、状态都在你的组件里管),但物理位置换了。

Portal 的概念

默认情况下,React 组件渲染出的 DOM 会出现在其父组件 DOM 节点的内部,形成和组件树一致的 DOM 树结构。但有些场景下这个约束会造成问题:

  • 弹窗(Modal):如果弹窗嵌套在深层组件里,父级有 overflow: hiddenz-index 限制,弹窗会被截断或遮挡
  • Tooltip:跟随鼠标的小提示框,需要脱离父容器定位
  • 全局通知:通知应该浮在最顶层,不受任何父级样式影响

createPortal 让组件的 DOM 节点"传送"到任意 DOM 位置,同时保持 React 组件树的逻辑结构不变。

createPortal 的 API 用法

import { createPortal } from 'react-dom';
import { useState, type ReactNode } from 'react';

type ModalProps = {
  open: boolean;
  onClose: () => void;
  children: ReactNode;
};

function Modal({ open, onClose, children }: ModalProps) {
  if (!open) return null;

  // 步骤 1:createPortal(要渲染的内容, 目标 DOM 节点)
  // 这里把弹窗内容渲染到 document.body 下
  return createPortal(
    <div
      style={{
        position: 'fixed',
        top: 0,
        left: 0,
        width: '100%',
        height: '100%',
        background: 'rgba(0, 0, 0, 0.5)',
        display: 'flex',
        justifyContent: 'center',
        alignItems: 'center',
      }}
      onClick={onClose}
    >
      {/* 步骤 2:点击遮罩关闭,点击内容不关闭 */}
      <div
        style={{
          background: '#fff',
          padding: 24,
          borderRadius: 8,
        }}
        onClick={(e) => e.stopPropagation()}
      >
        {children}
      </div>
    </div>,
    // 步骤 3:第二个参数是目标容器,通常是 document.body
    document.body
  );
}

// 使用
function App() {
  const [open, setOpen] = useState(false);

  return (
    <div style={{ overflow: 'hidden', height: 200 }}>
      <p>这个容器有 overflow hidden,但弹窗不受影响</p>
      <button onClick={() => setOpen(true)}>打开弹窗</button>
      <Modal open={open} onClose={() => setOpen(false)}>
        <h2>弹窗标题</h2>
        <p>弹窗内容渲染在 document.body </p>
      </Modal>
    </div>
  );
}

下面这张图对比 Portal 前后的 DOM 结构:

flowchart LR
    subgraph 没有Portal时
        A1[body] --> B1[App 容器
overflow hidden] B1 --> C1[Modal 弹窗
被父级截断] end subgraph 用Portal后 A2[body] --> B2[App 容器
overflow hidden] A2 --> C2[Modal 弹窗
脱离父级不受限制] end

左边没有 Portal:Modal 的 DOM 在 App 容器 内部,受 overflow: hidden 影响,弹窗可能被截断。右边用了 Portal:Modal 的 DOM 直接挂在 body 下,完全脱离父容器的样式约束,可以自由全屏定位。

事件冒泡与 Portal 的关系

这是 Portal 最容易被误解的特性:虽然 Portal 的 DOM 节点被传送到了别处,但在 React 的事件系统里,它仍然属于原来的组件树

也就是说,Portal 内部触发的事件会沿着 React 组件树(而不是 DOM 树)冒泡。看这个例子:

import { createPortal } from 'react-dom';
import { useState, type ReactNode } from 'react';

function App() {
  const [clickCount, setClickCount] = useState(0);

  // 步骤 1:父组件监听 click 事件
  return (
    <div
      onClick={() => {
        console.log('父组件捕获到 click');
        setClickCount((c) => c + 1);
      }}
    >
      <p>点击次数:{clickCount}</p>
      {/* 步骤 2:PortalContent 的 DOM 被渲染到 body 下 */}
      <PortalContent />
    </div>
  );
}

function PortalContent() {
  return createPortal(
    // 步骤 3:点击这个按钮,事件会冒泡到 App 的 div
    <button>我在 body 下,但点击事件会冒泡到 App</button>,
    document.body
  );
}

虽然 <button> 的物理 DOM 在 document.body 下,不在 App<div> 里,但点击它时,ApponClick 仍然会触发——因为 React 的事件冒泡走的是组件树,不是 DOM 树。

⚠️ 新手必踩的坑:以为 Portal 的事件不会冒泡到父组件。很多人把 Portal 理解成"完全脱离了父组件",于是在 Portal 内部触发事件后,以为父组件收不到。实际上 React 的事件系统是虚拟的,它按组件树冒泡,Portal 的事件冒泡到父组件。如果你不想让事件冒泡,需要在 Portal 内部用 e.stopPropagation() 拦截。

Portal 使用场景总结

场景为什么用 Portal
Modal 弹窗脱离父级 overflow / z-index 限制,全屏遮罩
Tooltip 提示跟随鼠标定位,不受父容器 overflow: hidden 截断
Notification 通知固定在右上角,浮在所有内容之上
Dropdown 下拉菜单内容可能超出父容器边界,需要脱离限制
全局 Loading 遮罩覆盖整个页面,不受任何组件样式影响

⚠️ 新手必踩的坑:服务端渲染时 document 不存在。在 SSR(如 Next.js)环境下,document.body 在服务端是不存在的,直接传 document.bodycreatePortal 会报错。解决方案:在 useEffect 里动态获取目标节点(useEffect 只在客户端执行),或者用 typeof document !== 'undefined' 做守卫。


12.0a React Router 基础(补充)

基本用法:BrowserRouter / Routes / Route

它是 React Router 的核心组件之一,用于包裹整个应用,创建一个基于 HTML5 history API 的路由系统。
import { BrowserRouter } from 'react-router-dom';

const App = () => {
  return (
    <BrowserRouter>
      {/* 应用的其他部分 */}
    </BrowserRouter>
  );
};

export default App;

<Routes><Route> 组件是 react-router-dom v6 中的新特性,用于包裹多个 组件,它会根据当前 URL 匹配并渲染第一个符合条件的 组件用于定义路由规则,path 属性指定匹配的路径,element 属性指定匹配成功后要渲染的组件。

import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Home from './Home';
import About from './About';

const App = () => {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
      </Routes>
    </BrowserRouter>
  );
};

export default App;

<Link> <Link> 组件用于在应用内创建链接,点击后会导航到指定的路径,它会阻止浏览器的默认跳转行为,使用 React Router 的路由系统进行导航。

import { Link } from 'react-router-dom';

const Navbar = () => {
  return (
    <nav>
      <ul>
        <li><Link to="/">Home</Link></li>
        <li><Link to="/about">About</Link></li>
      </ul>
    </nav>
  );
};

export default Navbar;

<NavLink> <NavLink><Link>用法相同,<link>生成<a>标签,<NavLink>负责导航

<useNavigate> useNavigate 是一个钩子函数,用于在函数组件中进行编程式导航

import { useNavigate } from 'react-router-dom';

const LoginButton = () => {
  const navigate = useNavigate();

  const handleLogin = () => {
    // 模拟登录成功后导航到主页
    navigate('/');
  };

  return (
    <button onClick={handleLogin}>Login</button>
  );
};

export default LoginButton;

路由参数:useParams

可以在 path 属性中使用冒号(:)来定义路由参数,然后通过 useParams 钩子函数获取这些参数。

import { BrowserRouter, Routes, Route, useParams } from 'react-router-dom';

const UserProfile = () => {
  const { id } = useParams();
  return (
    <div>
      <h1>User Profile: {id}</h1>
    </div>
  );
};

const App = () => {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/users/:id" element={<UserProfile />} />
      </Routes>
    </BrowserRouter>
  );
};

export default App;

路由守卫

可以通过自定义组件来实现路由守卫,根据条件决定是否允许用户访问某个路由。

import { Navigate, useLocation } from 'react-router-dom';

const PrivateRoute = ({ children }) => {
  const isAuthenticated = false; // 模拟用户是否已登录
  const location = useLocation();

  if (!isAuthenticated) {
    return <Navigate to="/login" state={{ from: location }} replace />;
  }

  return children;
};

export default PrivateRoute;

然后在需要保护的路由中使用 PrivateRoute:

import { BrowserRouter, Routes, Route } from 'react-router-dom';
import PrivateRoute from './PrivateRoute';
import Dashboard from './Dashboard';
import Login from './Login';

const App = () => {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/login" element={<Login />} />
        <Route path="/dashboard" element={
          <PrivateRoute>
            <Dashboard />
          </PrivateRoute>
        } />
      </Routes>
    </BrowserRouter>
  );
};

export default App;

嵌套路由

import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import Dashboard from './Dashboard';
import Users from './Users';
import Orders from './Orders';
import ErrorPage from './ErrorPage';

const router = createBrowserRouter([
  {
    path: '/dashboard',
    element: <Dashboard />,
    errorElement: <ErrorPage />,
    children: [
      {
        path: 'users',
        element: <Users />
      },
      {
        path: 'orders',
        element: <Orders />
      }
    ]
  }
]);

const App = () => {
  return (
    <RouterProvider router={router} />
    <Outlet />       {/* 指定子路由组件的渲染位置*/}
  );
};

export default App;

Router Hooks

createBrowserRouter

createBrowserRouter 是 react-router-dom v6 中用于创建路由配置的一个函数,它提供了一种更灵活、可扩展的方式来定义路由,相比于直接在组件树中使用 <Routes><Route> ,createBrowserRouter 更适合用于大型应用的路由管理

基本使用步骤

createBrowserRouter 接收一个路由配置数组作为参数,每个配置对象定义了一个路由规则。

import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import Home from './Home';
import About from './About';

// 创建路由配置
const router = createBrowserRouter([
  {
    path: '/',
    element: <Home />
  },
  {
    path: '/about',
    element: <About />
  }
]);

const App = () => {
  return (
    // 使用 RouterProvider 来渲染路由
    <RouterProvider router={router} />
  );
};

export default App;

路由配置对象详解

每个路由配置对象可以包含以下常用属性:

  • path:用于匹配 URL 的路径,可以包含动态参数(如 :id )。
  • element:当路径匹配成功时要渲染的 React 元素。
  • children:用于定义嵌套路由,是一个路由配置对象数组。
  • errorElement:当该路由或其子路由发生错误时要渲染的元素。

以下是一个包含嵌套路由和错误处理的示例:

import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import Dashboard from './Dashboard';
import Users from './Users';
import Orders from './Orders';
import ErrorPage from './ErrorPage';

const router = createBrowserRouter([
  {
    path: '/dashboard',
    element: <Dashboard />,
    errorElement: <ErrorPage />,
    children: [
      {
        path: 'users',
        element: <Users />
      },
      {
        path: 'orders',
        element: <Orders />
      }
    ]
  }
]);

const App = () => {
  return (
    <RouterProvider router={router} />
  );
};

export default App;

useRoutes

useRoutes 是 react-router-dom v6 中的一个钩子函数,它允许你以对象配置的方式来定义路由,这种方式更接近 createBrowserRouter 的配置风格,但 useRoutes 是在组件内部使用的,适合在某些需要动态生成路由或者在组件中根据条件渲染不同路由配置的场景

useRoutes 接收一个路由配置数组作为参数,该数组中的每个对象定义了一个路由规则。

import { BrowserRouter, useRoutes } from 'react-router-dom';
import Home from './Home';
import About from './About';

const App = () => {
  const routes = [
    {
      path: '/',
      element: <Home />
    },
    {
      path: '/about',
      element: <About />
    }
  ];

  const element = useRoutes(routes);

  return element;
};

const Root = () => {
  return (
    <BrowserRouter>
      <App />
    </BrowserRouter>
  );
};

export default Root;

useNavigate

可以在组件中使用 useNavigate 进行编程式导航:


import { useNavigate } from 'react-router-dom';

const LoginButton = () => {
  const navigate = useNavigate();

  const handleLogin = () => {
    // 模拟登录成功后导航到主页
    navigate('/');
  };

  return (
    <button onClick={handleLogin}>Login</button>
  );
};

export default LoginButton;

Data API

前置条件

  • createBrowserRouter
  • createMemoryRouter
  • createHashRouter
  • createStaticRouter

只有上面四个API创建的路由才有Data API功能

Loader、useLoaderData

loader 和 useLoaderData 是两个非常重要的概念,它们为在路由渲染之前进行数据加载提供了便利

Loader loader 是一个在路由匹配时执行的函数,其主要用途是在渲染路由组件之前获取所需的数据。这个函数返回一个 Promise,当 Promise 被解决后,其结果会作为数据传递给对应的路由组件。 在路由配置中,为路由对象添加 loader 属性,其值为一个异步函数。这个异步函数可以进行网络请求、访问本地存储等操作来获取数据。

import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import ProductList from './ProductList';

// 定义 loader 函数
const productListLoader = async () => {
    try {
        const response = await fetch('https://api.example.com/products');
        if (!response.ok) {
            throw new Error('Failed to fetch products');
        }
        return response.json();
    } catch (error) {
        console.error('Error fetching products:', error);
        throw error;
    }
};

const router = createBrowserRouter([
    {
        path: '/products',
        element: <ProductList />,
        loader: productListLoader
    }
]);

const App = () => {
    return <RouterProvider router={router} />;
};

export default App;    

useLoaderData useLoaderData 是一个 React 钩子函数,用于在路由组件中获取 loader 函数返回的数据。它使得组件能够方便地使用在路由加载阶段获取到的数据。 在路由组件内部调用 useLoaderData 钩子,它会返回 loader 函数解决后的结果。 结合上面的 loader 示例,以下是使用 useLoaderData 在 ProductList 组件中获取商品列表数据的代码:

import { useLoaderData } from 'react-router-dom';

const ProductList = () => {
    const products = useLoaderData();
    return (
        <div>
            <h1>Product List</h1>
            <ul>
                {products.map((product) => (
                    <li key={product.id}>{product.name}</li>
                ))}
            </ul>
        </div>
    );
};

export default ProductList;    

总结

  • loader 函数负责在路由匹配时进行数据加载,它在路由组件渲染之前执行,确保组件所需的数据已经准备好。
  • useLoaderData 钩子用于在路由组件中获取 loader 函数返回的数据,让组件能够方便地使用这些数据进行渲染。

通过使用 loader 和 useLoaderData,可以实现数据获取和组件渲染的分离,提高代码的可维护性和性能。

action、useActionData

action 和 useActionData 是用于处理表单提交、数据修改等操作的重要工具

action action 是一个在路由配置中定义的函数,通常用于处理表单提交或其他数据修改操作。当用户提交表单或触发某些交互时,action 函数会被调用,它可以执行诸如数据验证、发送请求到服务器进行数据创建、更新或删除等操作。

在路由配置对象中,为特定的路由添加 action 属性,该属性的值是一个异步函数。这个函数接收一个包含 request 对象的参数,request 对象可用于获取表单数据。

假设你有一个用于创建新文章的表单,以下是路由配置及 action 函数的示例:

import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import NewArticleForm from './NewArticleForm';

// 定义 action 函数
const createArticleAction = async ({ request }) => {
    const formData = await request.formData();
    const title = formData.get('title');
    const content = formData.get('content');

    // 模拟发送请求到服务器创建文章
    try {
        const response = await fetch('https://api.example.com/articles', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify({ title, content })
        });

        if (!response.ok) {
            throw new Error('Failed to create article');
        }

        const newArticle = await response.json();
        return newArticle;
    } catch (error) {
        console.error('Error creating article:', error);
        return { error: 'Failed to create article' };
    }
};

const router = createBrowserRouter([
    {
        path: '/articles/new',
        element: <NewArticleForm />,
        action: createArticleAction
    }
]);

const App = () => {
    return <RouterProvider router={router} />;
};

export default App;    

useActionData useActionData 是一个 React 钩子函数,用于在组件中获取 action 函数返回的数据。通过它,组件可以根据 action 函数的执行结果进行不同的渲染或操作,比如显示成功消息、错误提示或进行页面跳转。

在使用 action 的路由对应的组件中调用 useActionData 钩子,它会返回 action 函数执行后返回的数据。

import { useActionData, useNavigate } from 'react-router-dom';

const NewArticleForm = () => {
    const navigate = useNavigate();
    const actionData = useActionData();

    if (actionData && !actionData.error) {
        // 文章创建成功,跳转到文章列表页
        navigate('/articles');
    }

    const handleSubmit = (e) => {
        e.preventDefault();
        // 表单提交逻辑会触发 action 函数
    };

    return (
        <form onSubmit={handleSubmit}>
            <label>
                Title:
                <input type="text" name="title" />
            </label>
            <label>
                Content:
                <textarea name="content"></textarea>
            </label>
            <button type="submit">Create Article</button>
            {actionData && actionData.error && <p>{actionData.error}</p>}
        </form>
    );
};

export default NewArticleForm;    

总结

  • action 函数负责处理表单提交等操作,执行数据的创建、更新或删除等逻辑,并返回处理结果。
  • useActionData 钩子用于在组件中获取 action 函数的返回数据,以便根据结果进行相应的处理,如页面跳转、显示提示信息等。

通过 action 和 useActionData 的配合使用,可以方便地实现表单提交和数据修改的交互逻辑。

14.1 Axios 封装

import axios from "axios";
import {message} from "antd";


const instance = axios.create({
  baseURL:'',
  timeout:3000,
  timeoutErrorMessage:"请求无效",
  withCredentials:true,
  headers: {
    'Content-Type': 'application/json',
    Authorization:'Bearer' + localStorage.getItem("token"),
    'X-Requested-With': 'XMLHttpRequest'
  },
})

// 请求拦截器
instance.interceptors.request.use(
  (config)=>{
    return config;
  },
  (error)=>{
    return Promise.reject(error);
  }
)

// 响应拦截器
instance.interceptors.response.use(
  (response)=>{
    const data =response.data;
    if (data.code === 40001){
      window.location.href = "/login"
    }else if(data.code !==200 ){
      message.error(data.message)
    }
    return data.data;
  },
  (error)=>{
    return Promise.reject(error);
  }
)

export default {
  get:(url:string,param?:object)=>{
      return instance.get(url,param);
  },
  post:(url:string,param?:object)=>{
    return instance.post(url,param);
  },
  put:(url:string,param?:object)=>{
    return instance.put(url,param);
  },
  delete:(url:string,param?:object)=>{
    return instance.delete(url,param);
  }
}

14.2 Storage 封装

// 本地存储
export default {
  get:(key:string)=>{
    return localStorage.getItem(key)
  },
  set:(key:string,value:any)=>{
    return localStorage.setItem(key,JSON.stringify(value));
  },
  delete:(key:string)=>{
    return localStorage.removeItem(key);
  },
  clear:()=>{
    return localStorage.clear();
  }
}

14.3 环境变量

根目录创建.env.development ,修改package.json中的编译命令

// .env.development
VITE_BASE_URL=/api

// package.json
  "scripts": {
    "dev": "vite",      // 开发模式
    "build": "tsc -b && vite build --mode product",  // 打包.env.product
    "lint": "eslint .",
    "preview": "vite preview"
  },

在项目中调用

const url = import.meta.env.VITE_BASE_URL

14.4 代理配置

vite.config.ts 文件

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

// https://vite.dev/config/
export default defineConfig({
  plugins: [react()],
  server:{
    proxy:{
      '/api':{
        target: 'http://localhost:8000',
        changeOrigin: true,
      }
    }
  }
})

10.0 CSS Module(补充)

css文件在打包后都会在一个文件中,容易出现污染,只需开启css module

// 创建css文件名为如: index.module.css

// 导入: import style from 'index.module.css'

<h1 className={style.login}></h1>   

// 需要使用驼峰命名

十、CSS 方案

10.1 CSS Modules

10.1.1 用生活类比先建立直觉

类比:想象一栋写字楼,所有公司共用一个大喇叭广播系统。A 公司喊一句"张三来前台",整栋楼所有叫张三的人都站起来——这就是 CSS 的全局作用域问题。CSS Modules 干的事,就是给每家公司发一个独立频道,A 公司的"张三"只在 A 公司频道里有效,互不干扰。

对应到工程里就是:传统 CSS 的类名挂在全局作用域上,谁都能覆盖谁;CSS Modules 在编译期把类名改写成带哈希的唯一名(如 .btn.btn_3a8f2c),从源头消灭命名冲突。

flowchart LR
    A[你写 .btn 类名] --> B[CSS Modules 编译]
    B --> C[生成带哈希的类名
btn_3a8f2c] C --> D[注入到组件 DOM] C --> E[样式表里只有哈希名] D --> F[样式隔离完成] E --> F

这张图在讲 CSS Modules 的核心机制:类名在编译期被重写,源码里写 .btn,产物里变成 .btn_3a8f2c,HTML 和 CSS 两边对得上,别人写的 .btn 跟你完全无关。

10.1.2 CSS Modules 解决什么问题

传统 CSS 的三大痛点:

痛点表现后果
全局污染所有类名挂在全局作用域A 组件的样式覆盖 B 组件
命名恐惧起类名要猜会不会撞BEM 命名法越写越长
死代码难删不确定某个类是否还在用CSS 包越来越大

CSS Modules 的解法:编译期自动重写类名,让每个文件的类名天然隔离。

⚠️ 新手必踩的坑:css 文件在打包后都会在一个文件中。Vite/Webpack 默认会把所有 CSS 抽取到一个(或少数几个)打包产物文件中。如果你用的是普通 .css 文件,类名不隔离,后引入的样式会覆盖先引入的——这就是全局污染的根源。只需开启 CSS Module(文件命名为 .module.css),打包器就会自动给类名加哈希,问题就解决了。

10.1.3 基本用法

步骤 1:创建 .module.css 文件

/* Button.module.css */
/* 步骤 1:类名用驼峰命名,方便 JS 中以属性方式访问 */
.btn {
  display: inline-flex;
  align-items: center;
  padding: 8px 16px;
  border-radius: 6px;
  font-size: 14px;
  cursor: pointer;
}

/* 步骤 2:变体类名同样驼峰 */
.primary {
  background-color: #1677ff;
  color: #fff;
}

.disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

步骤 2:在组件中 import style 对象

// Button.tsx
import type { ButtonHTMLAttributes } from 'react';
// 步骤 1:import 进来是一个对象,key 是你写的类名,value 是编译后的哈希类名
import styles from './Button.module.css';

interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'default';
}

export function Button({ variant = 'default', className, ...rest }: ButtonProps) {
  // 步骤 2:用 styles.btn 访问类名,而不是写字符串 'btn'
  const classNames = [styles.btn, variant === 'primary' && styles.primary]
    .filter(Boolean)
    .join(' ');

  return <button className={classNames} {...rest} />;
}

⚠️ 新手必踩的坑:驼峰命名。CSS 文件里如果你写了 .btn-primary(短横线),在 JS 里只能用 styles['btn-primary'] 访问,不能写 styles.btnPrimary。养成习惯:.module.css 里的类名一律驼峰,短横线留给普通 CSS 文件。

10.1.4 配置与 TypeScript 声明

Vite 原生支持 CSS Modules,零配置即可使用。但 TypeScript 默认不认识 .module.css 文件,import 时会报红。需要补一个类型声明:

// src/types/css-modules.d.ts
// 步骤 1:声明所有 .module.css 文件的 import 类型
declare module '*.module.css' {
  // 步骤 2:导出一个 Record,key 是类名,value 是编译后的类名字符串
  const classes: { readonly [key: string]: string };
  export default classes;
}

// 如果还用了 SCSS,补一条
declare module '*.module.scss' {
  const classes: { readonly [key: string]: string };
  export default classes;
}

Vite 中如果想自定义 CSS Modules 的行为(如开启短横线转驼峰),在 vite.config.ts 中配置:

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  css: {
    modules: {
      // 步骤 1:开启短横线转驼峰,css 里写 .btn-primary,JS 里用 styles.btnPrimary
      localsConvention: 'camelCase',
      // 步骤 2:生成类名的格式,name 是文件名,local 是原始类名,hash 是哈希
      generateScopedName: '[name]__[local]__[hash:base64:5]',
    },
  },
});

10.2 CSS-in-JS

10.2.1 用生活类比先建立直觉

类比:CSS Modules 像是"预制板房"——你在工厂(编译期)把墙板编号切割好,运到工地拼装。CSS-in-JS 像是"3D 打印机"——你把设计图纸(JS 里的样式对象)喂给机器,机器在工地(运行期)实时打印出每块墙板,还能根据当天天气(props)自动调整厚度。

对应到工程里就是:CSS-in-JS 把样式写在 JS 里,组件渲染时动态生成 CSS,插到 <style> 标签中。好处是样式能直接读组件的 props 和 theme,天生动态;代价是运行时有开销。

flowchart TB
    subgraph 编译期方案
        A[CSS Modules
.module.css 文件] --> B[编译期重写类名] B --> C[静态 CSS 产物] end subgraph 运行期方案 D[CSS-in-JS
JS 中的样式函数] --> E[运行时生成 CSS] E --> F[动态插入 style 标签] end subgraph 原子化方案 G[Tailwind
原子类名] --> H[编译期扫描类名] H --> I[按需生成 CSS] end C --> J[浏览器渲染] F --> J I --> J

这张图对比了三种方案的工作时机:CSS Modules 和 Tailwind 在编译期就生成好静态 CSS,运行时零开销;CSS-in-JS 在运行时动态生成,灵活但有性能成本。

10.2.2 CSS-in-JS 的概念和设计理念

CSS-in-JS 的核心理念:样式是组件的一部分,应该和组件的 JS 逻辑共存

它要解决的工程问题:

  1. 样式能读组件状态:不再需要手动加/删 class,直接在样式里读 props
  2. 真正的作用域隔离:每个样式规则自动生成唯一类名
  3. 共享主题方便:通过 ThemeProvider 注入主题,所有组件共享
  4. 死代码自动消除:组件不用了,样式也跟着没了

10.2.3 styled-components 的基本用法

npm install styled-components

基础用法:styled.div 创建带样式的组件

// 步骤 1:引入 styled 工厂函数
import styled from 'styled-components';

// 步骤 2:用模板字符串定义样式,返回一个带样式的 div 组件
const Card = styled.div`
  background: #fff;
  border-radius: 8px;
  padding: 16px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
`;

// 步骤 3:像普通组件一样使用
export function App() {
  return <Card>这是一张卡片</Card>;
}

props 传参:根据组件 props 动态改变样式

import styled from 'styled-components';

// 步骤 1:定义 props 类型
interface ButtonProps {
  $primary?: boolean;
}

// 步骤 2:transient prop 用 $ 前缀,不会透传到 DOM
const Button = styled.button<ButtonProps>`
  padding: 8px 16px;
  border-radius: 6px;
  border: none;
  cursor: pointer;
  /* 步骤 3:在模板字符串里用 ${(props) => ...} 读取 props */
  background: ${(props) => (props.$primary ? '#1677ff' : '#f0f0f0')};
  color: ${(props) => (props.$primary ? '#fff' : '#333')};
`;

export function App() {
  return (
    <div>
      <Button>普通按钮</Button>
      <Button $primary>主要按钮</Button>
    </div>
  );
}

⚠️ 新手必踩的坑:props 透传到 DOM。如果你直接用 primary 而不是 $primary,styled-components 会把这个非标准属性透传到 <button> DOM 节点上,React 会报 warning:Warning: React does not recognize the 'primary' prop。加 $ 前缀的叫 transient prop,只在样式层使用,不会传到 DOM。

主题:ThemeProvider 注入全局主题

import styled, { ThemeProvider } from 'styled-components';

// 步骤 1:定义主题对象
const theme = {
  colors: {
    primary: '#1677ff',
    danger: '#ff4d4f',
    text: '#333',
  },
  spacing: {
    sm: '8px',
    md: '16px',
    lg: '24px',
  },
};

// 步骤 2:在样式中通过 ${(props) => props.theme} 读取主题
const Title = styled.h1`
  color: ${(props) => props.theme.colors.primary};
  margin-bottom: ${(props) => props.theme.spacing.md};
`;

// 步骤 3:用 ThemeProvider 包裹应用,注入主题
export function App() {
  return (
    <ThemeProvider theme={theme}>
      <Title>带主题的标题</Title>
    </ThemeProvider>
  );
}

10.2.4 emotion 的基本用法

emotion 的 API 和 styled-components 几乎一致,但更轻量、性能更好,还支持 css prop 写法。

npm install @emotion/styled @emotion/react
/** @jsxImportSource @emotion/react */
import styled from '@emotion/styled';
import { css } from '@emotion/react';

// 步骤 1:styled 用法和 styled-components 一模一样
const Button = styled.button`
  padding: 8px 16px;
  border-radius: 6px;
`;

// 步骤 2:emotion 独有的 css prop,直接在 JSX 上写样式
const cardStyle = css`
  background: #fff;
  border-radius: 8px;
  padding: 16px;
`;

export function App() {
  return (
    <div>
      <Button>styled 写法</Button>
      {/* 步骤 3:用 css prop 直接内联样式对象 */}
      <div css={cardStyle}>css prop 写法</div>
    </div>
  );
}

⚠️ 新手必踩的坑:忘记加 jsxImportSource。用 css prop 必须在每个文件顶部加 /** @jsxImportSource @emotion/react */,或者在 tsconfig.json 里全局配置 "jsxImportSource": "@emotion/react"。否则 css prop 会被当成普通属性忽略,样式不生效。

10.2.5 CSS-in-JS 的优缺点和性能考量

维度优点缺点
开发体验样式随组件走,props 驱动,动态能力强学习曲线,IDE 支持不如纯 CSS
作用域自动隔离,无命名冲突
主题ThemeProvider 天然共享
性能运行时有开销:样式计算 + DOM 插入
包体积需引入运行时库(约 12KB gzipped)
SSR支持 but 需配置首屏样式抽取较复杂
缓存生成的类名是动态的,浏览器 CSS 缓存效率低

性能考量的关键点

  1. 样式序列化开销:每次渲染都要把模板字符串序列化成 CSS 字符串,styled-components 和 emotion 都有缓存机制,但动态样式(依赖 props 的)仍会重复计算
  2. 运行时注入:样式在运行时插入 <style> 标签,大量动态样式会导致主线程阻塞
  3. SSR 场景:服务端渲染时需要额外配置才能正确抽取首屏样式,否则会闪烁

⚠️ 新手必踩的坑:过度使用动态样式。如果你把所有样式都写成 ${(props) => ...} 的动态形式,每次 props 变化都会重新生成 CSS 字符串。正确做法:静态样式用普通 CSS 写,只有真正需要随 props 变化的部分才用动态函数。


10.3 原子化 CSS:Tailwind

10.3.1 用生活类比先建立直觉

类比:传统 CSS 像是"买菜做饭"——你要洗菜、切菜、炒菜、调味,一整套流程下来做出一道菜。Tailwind 像是"乐高积木"——每种味道、颜色、尺寸都是一块预制好的积木,你不需要自己造积木,只需要把积木拼在一起。

对应到工程里就是:Tailwind 不让你写 CSS 规则,而是提供一套预定义的原子类(如 p-4 = padding 16px,text-red-500 = 红色文字),你在 JSX 的 className 里直接拼。

mindmap
  root((Tailwind 核心))
    设计哲学
      utility-first
      不写 CSS 规则
      原子类组合
    核心能力
      布局
        flex grid
        间距 p m
      视觉
        颜色 bg text
        圆角 shadow
      响应式
        sm md lg
        断点前缀
      状态
        hover focus
        dark 模式
    工程优势
      编译期按需生成
      零运行时开销
      设计令牌统一
    配置体系
      tailwind.config
      主题扩展
      插件机制

这张思维导图展示了 Tailwind 的核心概念全貌:设计哲学是 utility-first,核心能力覆盖布局、视觉、响应式、状态,工程优势是编译期按需生成零运行时,配置体系支持主题扩展和插件。

10.3.2 Tailwind 的设计哲学:utility-first

Tailwind 的核心理念:用原子类的组合代替手写 CSS 规则

<!-- 传统 CSS 写法 -->
<div class="card">内容</div>

<style>
  .card {
    background: #fff;
    border-radius: 8px;
    padding: 16px;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  }
</style>

<!-- Tailwind 写法:直接用原子类拼 -->
<div class="bg-white rounded-lg p-4 shadow-md">内容</div>

utility-first 的关键洞察:

  1. 你几乎不需要起类名:大多数场景直接在 JSX 上写原子类
  2. 设计令牌约束p-4 不是随便的 16px,而是设计系统里的 spacing token,天然统一
  3. 按需生成:编译器扫描你的源码,只生成你用到的类的 CSS,产物极小

⚠️ 新手必踩的坑:觉得 className 太长很丑。很多新手看到 class="flex items-center justify-between p-4 bg-white rounded-lg shadow-md" 就劝退了。但这恰恰是 Tailwind 的设计:所有样式信息集中在 JSX 一处,打开组件就能看到全部视觉规则,不需要跳转到 CSS 文件。习惯之后你会发现效率远高于来回切换文件。

10.3.3 在 React 项目中集成 Tailwind(Vite 配置)

步骤 1:安装依赖

npm install -D tailwindcss @tailwindcss/vite

步骤 2:配置 Vite 插件

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';

export default defineConfig({
  // 步骤 1:注册 Tailwind 的 Vite 插件
  plugins: [react(), tailwindcss()],
});

步骤 3:引入 Tailwind 的 CSS 入口

/* src/index.css */
/* 步骤 1:用 @import 引入 Tailwind 的三层样式 */
@import "tailwindcss";

步骤 4:在组件中使用

// App.tsx
export function App() {
  return (
    <div className="min-h-screen bg-gray-50 flex items-center justify-center">
      <div className="bg-white rounded-lg p-8 shadow-md max-w-md w-full">
        <h1 className="text-2xl font-bold text-gray-900 mb-4">Hello Tailwind</h1>
        <p className="text-gray-600 mb-4">用原子类快速搭建 UI</p>
        <button className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md transition-colors">
          点击按钮
        </button>
      </div>
    </div>
  );
}

⚠️ 新手必踩的坑:Tailwind v3 和 v4 配置方式不同。v4 用 @import "tailwindcss" + Vite 插件,不再需要 tailwind.config.jspostcss.config.js。如果你搜到的教程是 v3 的(用 @tailwind base 等指令),在 v4 里会报错。认准你装的版本。

10.3.4 常用原子类速查

布局类

原子类含义示例
flexdisplay: flex<div class="flex">
inline-flexdisplay: inline-flex
griddisplay: grid
blockdisplay: block
hiddendisplay: none
items-centeralign-items: center垂直居中
justify-centerjustify-content: center水平居中
justify-betweenjustify-content: space-between两端对齐
flex-colflex-direction: column纵向排列
flex-1flex: 1 1 0%等分剩余空间

间距类

原子类含义
p-4padding: 16px4 = 1rem
px-4padding-left/right: 16px
py-2padding-top/bottom: 8px
m-4margin: 16px
mx-automargin: 0 auto水平居中
gap-4gap: 16pxflex/grid 间距

间距刻度速记:0 = 0px,1 = 4px,2 = 8px,3 = 12px,4 = 16px,6 = 24px,8 = 32px,12 = 48px,16 = 64px

颜色类

原子类含义
bg-blue-500background-color: 蓝色 500
text-whitecolor: 白色
text-gray-600color: 灰色 600
border-gray-200border-color: 灰色 200

颜色刻度:50 最浅 → 900 最深,500 是标准色

响应式类

原子类断点含义
sm:>=640px小屏及以上生效
md:>=768px中屏及以上生效
lg:>=1024px大屏及以上生效
xl:>=1280px超大屏及以上生效
// 响应式示例:移动端纵向排列,桌面端横向排列
export function Layout() {
  return (
    <div className="flex flex-col md:flex-row gap-4 p-4">
      <div className="flex-1 bg-gray-100 p-4 rounded">左侧</div>
      <div className="flex-1 bg-gray-100 p-4 rounded">右侧</div>
    </div>
  );
}

状态类

原子类含义
hover:bg-blue-600鼠标悬停时背景变深
focus:ring-2聚焦时加环
active:scale-95按下时缩小
disabled:opacity-50禁用时半透明
dark:bg-gray-900深色模式下背景

10.3.5 Tailwind vs CSS Modules vs CSS-in-JS 的选型建议

维度CSS ModulesCSS-in-JSTailwind
工作时机编译期运行期编译期
运行时开销
作用域隔离编译期哈希运行时生成原子类无冲突
动态样式需手写条件 classprops 驱动,天生支持通过 class 组合实现
主题共享CSS 变量ThemeProvidertailwind.config
学习成本中(记原子类)
适合场景中大型项目、组件库高动态交互场景快速开发、设计系统统一

选型建议

  1. 中大型项目 + 组件库:CSS Modules,零运行时、作用域天然隔离、TS 友好
  2. 高动态交互场景(如主题切换、复杂动画):CSS-in-JS(emotion 优先)
  3. 快速开发 + 设计统一:Tailwind,原子类组合效率最高
  4. 混合方案也常见:Tailwind 做布局 + CSS Modules 做复杂组件样式

十一、实战:组件库

11.1 组件库设计原则

11.1.1 用生活类比先建立直觉

类比:组件库像是宜家的家具系统。宜家的每个零件都遵循统一标准——螺丝是 M6 的,板材厚度是固定的,孔位间距是有规律的。你买一个书架,不用担心螺丝拧不进另一件家具的孔里。这就是"可组合"的力量。

对应到工程里就是:组件库的每个组件都遵循统一的设计规范——props 命名一致、尺寸刻度对齐、主题变量共享。ButtonsizeInputsize 是同一套值,使用者不需要为每个组件学一套新 API。

flowchart TB
    subgraph 设计层
        A[设计令牌
颜色 间距 圆角 字号] end subgraph 基础层 B[原子组件
Button Input Icon] end subgraph 组合层 C[复合组件
Form Dialog Table] end subgraph 业务层 D[业务组件
LoginForm SearchBar] end A --> B A --> C B --> C B --> D C --> D

这张图展示组件库的分层架构:设计令牌是最底层的基础,原子组件依赖设计令牌,复合组件组合原子组件,业务组件再基于复合组件搭建。每一层只依赖比自己更底层的层,不会反向依赖。

11.1.2 单一职责、可组合、可配置

单一职责:一个组件只做一件事。Button 只负责触发动作,不负责表单校验;Input 只负责输入,不负责提交。

可组合:组件之间能像积木一样拼装。关键设计:用 children 或组合 API 而不是把所有功能塞进一个组件。

// 步骤 1:反面教材——什么都塞进 Button 的巨型组件
interface MegaButtonProps {
  text: string;
  icon?: string;
  onClick?: () => void;
  showLoading?: boolean;
  showConfirm?: boolean;
  confirmText?: string;
  // ... 越加越多,最后变成一个 200 行的怪物
}

// 步骤 2:正确做法——职责分离,用组合实现
// Button 只管样式和点击
<Button onClick={handleSubmit}>
  <Icon name="upload" />
  提交
</Button>

// Loading 状态由外层控制
{isLoading && <Spinner />}

// 确认逻辑由上层组件管理
<ConfirmDialog onConfirm={handleSubmit} />

可配置:通过 props 暴露合理的配置项,但不要过度配置。判断标准:80% 的场景用默认值就能工作,20% 的场景能通过 props 覆盖

11.1.3 API 设计规范

props 命名规范

规范示例说明
用枚举字符串而非布尔variant="primary" 而非 isPrimary布尔 prop 容易冲突,枚举可扩展
尺寸用语义名而非数字size="large" 而非 size={3}语义可读,数值无意义
回调用 on 前缀onChange / onClick业界惯例
状态用 is/has 前缀isLoading / hasError一眼看出是状态
布尔 prop 默认 falsedisabled 默认 false减少必填项

默认值设计

interface ButtonProps {
  variant?: 'primary' | 'secondary' | 'danger';
  size?: 'small' | 'medium' | 'large';
  loading?: boolean;
  disabled?: boolean;
}

// 步骤 1:所有可选 props 都给默认值,使用者不传也能工作
function Button({
  variant = 'primary',
  size = 'medium',
  loading = false,
  disabled = false,
  ...rest
}: ButtonProps) {
  // ...
}

类型推导:用泛型让组件的 props 类型自动推导,避免手动标注。

// 步骤 1:继承原生 HTML 属性,自动获得 onClick、type 等所有原生 props
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary' | 'danger';
  size?: 'small' | 'medium' | 'large';
}

// 步骤 2:使用时不需要手动写 onClick 的类型,TS 自动推导
<Button onClick={(e) => console.log(e.currentTarget)}>点击</Button>

11.2 从零搭建一个 Button 组件

11.2.1 定义 ButtonProps 类型

// Button.tsx
import type { ButtonHTMLAttributes, ReactNode } from 'react';

// 步骤 1:定义变体和尺寸的联合类型,方便复用和扩展
type ButtonVariant = 'primary' | 'secondary' | 'danger';
type ButtonSize = 'small' | 'medium' | 'large';

// 步骤 2:继承原生 button 属性,自动获得 type、onClick、disabled 等
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: ButtonVariant;
  size?: ButtonSize;
  loading?: boolean;
  // 步骤 3:允许自定义 loading 时显示的图标
  loadingIcon?: ReactNode;
}

11.2.2 完整实现:变体、尺寸、加载/禁用状态

// Button.tsx
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import styles from './Button.module.css';

type ButtonVariant = 'primary' | 'secondary' | 'danger';
type ButtonSize = 'small' | 'medium' | 'large';

interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: ButtonVariant;
  size?: ButtonSize;
  loading?: boolean;
  loadingIcon?: ReactNode;
}

// 步骤 1:定义变体到类名的映射表,用对象代替 if-else
const variantClassMap: Record<ButtonVariant, string> = {
  primary: styles.primary,
  secondary: styles.secondary,
  danger: styles.danger,
};

// 步骤 2:定义尺寸到类名的映射表
const sizeClassMap: Record<ButtonSize, string> = {
  small: styles.small,
  medium: styles.medium,
  large: styles.large,
};

export function Button({
  variant = 'primary',
  size = 'medium',
  loading = false,
  loadingIcon,
  disabled,
  className,
  children,
  ...rest
}: ButtonProps) {
  // 步骤 3:拼接类名:基础类 + 变体类 + 尺寸类 + 使用者传入的类
  const classNames = [
    styles.btn,
    variantClassMap[variant],
    sizeClassMap[size],
    className,
  ]
    .filter(Boolean)
    .join(' ');

  // 步骤 4:loading 状态下按钮禁用,防止重复提交
  const isDisabled = disabled || loading;

  return (
    <button
      className={classNames}
      disabled={isDisabled}
      // 步骤 5:loading 时设置 aria-busy 给屏幕阅读器
      aria-busy={loading}
      {...rest}
    >
      {/* 步骤 6:loading 时显示加载图标,否则显示 children */}
      {loading && (loadingIcon ?? <DefaultSpinner />)}
      {children}
    </button>
  );
}

// 步骤 7:默认的加载旋转图标
function DefaultSpinner() {
  return (
    <span
      className={styles.spinner}
      role="status"
      aria-label="loading"
    />
  );
}

配套的 CSS Modules 样式文件:

/* Button.module.css */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  border: none;
  border-radius: 6px;
  font-size: 14px;
  font-weight: 500;
  cursor: pointer;
  transition: all 0.2s ease;
  user-select: none;
}

/* 步骤 1:变体样式 */
.primary {
  background-color: #1677ff;
  color: #fff;
}
.primary:hover:not(:disabled) {
  background-color: #4096ff;
}

.secondary {
  background-color: #f0f0f0;
  color: #333;
}
.secondary:hover:not(:disabled) {
  background-color: #d9d9d9;
}

.danger {
  background-color: #ff4d4f;
  color: #fff;
}
.danger:hover:not(:disabled) {
  background-color: #ff7875;
}

/* 步骤 2:尺寸样式 */
.small {
  padding: 4px 12px;
  font-size: 12px;
}

.medium {
  padding: 8px 16px;
  font-size: 14px;
}

.large {
  padding: 12px 24px;
  font-size: 16px;
}

/* 步骤 3:禁用状态 */
.btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

/* 步骤 4:加载旋转动画 */
.spinner {
  display: inline-block;
  width: 14px;
  height: 14px;
  border: 2px solid currentColor;
  border-top-color: transparent;
  border-radius: 50%;
  animation: spin 0.6s linear infinite;
}

@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

使用示例:

import { Button } from './Button';
import { useState } from 'react';

export function Demo() {
  const [loading, setLoading] = useState(false);

  const handleClick = () => {
    // 步骤 1:模拟异步请求
    setLoading(true);
    setTimeout(() => setLoading(false), 2000);
  };

  return (
    <div style={{ display: 'flex', gap: '12px' }}>
      <Button variant="primary" size="medium">主要按钮</Button>
      <Button variant="secondary">次要按钮</Button>
      <Button variant="danger">危险按钮</Button>
      <Button size="large">大按钮</Button>
      <Button loading={loading} onClick={handleClick}>
        {loading ? '提交中' : '提交'}
      </Button>
      <Button disabled>禁用按钮</Button>
    </div>
  );
}

⚠️ 新手必踩的坑:loading 状态忘记禁用按钮。如果你只设置了 loading 显示旋转图标,但没有 disabled,用户在 loading 期间还能反复点击,导致重复提交。上面的代码用 const isDisabled = disabled || loading 统一处理,保证 loading 时一定不可点。


11.3 组件文档与测试

11.3.1 Storybook 集成

Storybook 是组件文档的事实标准:它为每个组件生成一个独立的演示页面,支持交互调试、属性面板、视觉回归测试。

步骤 1:初始化 Storybook

npx storybook@latest init

这条命令会自动安装依赖、创建 .storybook 配置目录、生成示例 stories。

步骤 2:为 Button 组件编写 Story

// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';

// 步骤 1:定义组件元信息,title 决定 Storybook 侧边栏的分类路径
const meta = {
  title: 'Components/Button',
  component: Button,
  // 步骤 2:配置参数面板的可选项
  tags: ['autodocs'],
  argTypes: {
    variant: {
      control: 'select',
      options: ['primary', 'secondary', 'danger'],
    },
    size: {
      control: 'select',
      options: ['small', 'medium', 'large'],
    },
    loading: {
      control: 'boolean',
    },
    disabled: {
      control: 'boolean',
    },
  },
} satisfies Meta<typeof Button>;

export default meta;
type Story = StoryObj<typeof meta>;

// 步骤 3:每个 export const 是一个 Story(一个演示场景)
export const Primary: Story = {
  args: {
    variant: 'primary',
    children: '主要按钮',
  },
};

export const Secondary: Story = {
  args: {
    variant: 'secondary',
    children: '次要按钮',
  },
};

export const Danger: Story = {
  args: {
    variant: 'danger',
    children: '危险操作',
  },
};

// 步骤 4:用组合展示所有变体
export const AllVariants: Story = {
  render: () => (
    <div style={{ display: 'flex', gap: '12px' }}>
      <Button variant="primary">Primary</Button>
      <Button variant="secondary">Secondary</Button>
      <Button variant="danger">Danger</Button>
    </div>
  ),
};

export const Loading: Story = {
  args: {
    loading: true,
    children: '加载中',
  },
};

export const Disabled: Story = {
  args: {
    disabled: true,
    children: '禁用',
  },
};

步骤 3:启动 Storybook

npm run storybook

打开 http://localhost:6006 即可看到组件文档页面,左侧侧边栏可切换不同 Story,右侧面板可实时调整 props。

⚠️ 新手必踩的坑:Storybook 版本与框架不匹配。Storybook v8 对 Vite + React 18 的支持最好。如果你的项目用的是 Vite,初始化时它会自动检测并安装对应版本的 @storybook/react-vite。不要手动装旧版本(v7),配置方式完全不同。

11.3.2 基础测试用例

用 Vitest + Testing Library 为 Button 组件写测试。

步骤 1:安装依赖

npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event

步骤 2:编写测试

// Button.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';

describe('Button', () => {
  // 步骤 1:测试默认渲染
  it('默认渲染为 primary 变体', () => {
    render(<Button>点击</Button>);
    const button = screen.getByRole('button', { name: '点击' });
    expect(button).toBeInTheDocument();
  });

  // 步骤 2:测试点击事件
  it('点击时触发 onClick 回调', async () => {
    const user = userEvent.setup();
    const handleClick = vi.fn();
    render(<Button onClick={handleClick}>点击</Button>);

    await user.click(screen.getByRole('button'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  // 步骤 3:测试禁用状态
  it('disabled 时点击不触发 onClick', async () => {
    const user = userEvent.setup();
    const handleClick = vi.fn();
    render(<Button disabled onClick={handleClick}>禁用</Button>);

    await user.click(screen.getByRole('button'));
    expect(handleClick).not.toHaveBeenCalled();
  });

  // 步骤 4:测试 loading 状态禁用按钮
  it('loading 时按钮自动禁用', async () => {
    const user = userEvent.setup();
    const handleClick = vi.fn();
    render(<Button loading onClick={handleClick}>加载中</Button>);

    const button = screen.getByRole('button');
    expect(button).toBeDisabled();
    expect(button).toHaveAttribute('aria-busy', 'true');

    await user.click(button);
    expect(handleClick).not.toHaveBeenCalled();
  });

  // 步骤 5:测试原生属性透传
  it('支持 type 属性透传', () => {
    render(<Button type="submit">提交</Button>);
    expect(screen.getByRole('button')).toHaveAttribute('type', 'submit');
  });
});

步骤 3:配置 Vitest

// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    // 步骤 1:测试环境设为 jsdom,模拟浏览器 DOM
    environment: 'jsdom',
    // 步骤 2:全局引入 jest-dom 的 matchers(toBeInTheDocument 等)
    setupFiles: ['./src/test-setup.ts'],
  },
});
// src/test-setup.ts
import '@testing-library/jest-dom';

步骤 4:运行测试

npx vitest run
sequenceDiagram
    participant T as 测试用例
    participant R as RTL render
    participant DOM as jsdom
    participant U as userEvent
    T->>R: render Button
    R->>DOM: 创建 DOM 节点
    T->>U: user.click button
    U->>DOM: 模拟点击事件
    DOM->>R: 触发 onClick
    T->>T: expect 断言

这张时序图展示了测试的执行流程:测试用例调用 RTL 的 render 将组件挂载到 jsdom 模拟的 DOM 中,userEvent 模拟用户交互触发事件,最后用 expect 断言结果。


十二、Router:路由模式与高级操作

类比:路由就是城市交通的"地址调度员"。你给一个地址(URL),它负责把你送到对应的页面(组件)。BrowserRouter 像坐高铁——速度快、体验好,但需要铁路基础设施(服务器配置)配合;HashRouter 像走地下通道——哪儿都能走、不用审批,但地址栏上多了一个井号,显得不太体面。

12.1 路由模式

12.1.1 两种模式对比

React Router 提供两种路由模式,核心区别在于"怎么改 URL"和"靠什么监听变化":

对比项BrowserRouterHashRouter
URL 形态example.com/users/123example.com/#/users/123
底层 APIHTML5 history API(pushState / replaceState)URL hash + hashchange 事件
服务器要求需配置 fallback 到 index.html无要求(hash 不发到服务器)
SEO 友好度好(URL 干净,爬虫可索引)差(井号后内容被爬虫忽略)
浏览器兼容IE9 以下不支持全兼容
典型场景正式项目(绝大多数)静态托管、无服务器配置权限
flowchart TD
    subgraph BrowserRouter
        A1["URL: example.com/users/123"] --> A2["底层: history API
pushState / replaceState"] A2 --> A3["服务器: 需配置 fallback
所有路由指向 index.html"] A3 --> A4["SEO: 友好
爬虫可索引完整 URL"] end subgraph HashRouter B1["URL: example.com/#/users/123"] --> B2["底层: hashchange 事件
监听井号后路径变化"] B2 --> B3["服务器: 无需配置
井号部分不发送到服务端"] B3 --> B4["SEO: 不友好
爬虫忽略井号后内容"] end

12.1.2 BrowserRouter 配置

BrowserRouter 依赖 HTML5 history API,URL 干净无井号,是正式项目的首选:

import { BrowserRouter, Routes, Route } from 'react-router-dom'
import Home from './pages/Home'
import About from './pages/About'

function App() {
  return (
    <BrowserRouter>
      {/* 步骤 1:Routes 包裹所有路由规则 */}
      <Routes>
        {/* 步骤 2:每个 Route 定义一条路径到组件的映射 */}
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
      </Routes>
    </BrowserRouter>
  )
}

export default App

但 BrowserRouter 有一个前提:服务器必须把所有未知路由都 fallback 到 index.html,否则刷新页面就会 404。

# nginx 配置:所有未匹配的路径都回退到 index.html
location / {
  try_files $uri $uri/ /index.html;
}
// Express / Connect 配置
import express from 'express'

const app = express()
app.use(express.static('dist'))
// 步骤:所有非静态文件请求都返回 index.html
app.get('*', (req, res) => {
  res.sendFile(path.resolve(__dirname, 'dist', 'index.html'))
})

12.1.3 HashRouter 配置

HashRouter 不需要服务器配置,URL 中带井号,适合静态托管(GitHub Pages、对象存储等):

import { HashRouter, Routes, Route } from 'react-router-dom'
import Home from './pages/Home'
import About from './pages/About'

function App() {
  return (
    <HashRouter>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/about" element={<About />} />
      </Routes>
    </HashRouter>
  )
}

export default App

⚠️ 新手必踩的坑:用 BrowserRouter 部署后,在首页点击导航一切正常,但直接在地址栏输入 example.com/about 或刷新页面就会 404。这是因为服务器收到 /about 请求后去找对应文件,找不到就报 404。解决方法就是配置服务器的 fallback 规则。如果你用的是 GitHub Pages 等无法配置服务器的静态托管,老老实实用 HashRouter。


12.2 路由传参

类比:传参就像寄快递——useParams 是收件人门牌号(写在地址里,固定格式),useSearchParams 是快递备注(问号后面的附加信息,随意增减),location.state 是盒子里夹的纸条(地址上看不到,但收件人能拆开看)。

12.2.1 useParams:动态路由参数

在路径中用冒号定义动态段,通过 useParams 获取:

import { BrowserRouter, Routes, Route, useParams, Link } from 'react-router-dom'

// 步骤 1:定义路由,用 :id 声明动态参数
function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/user/:id" element={<UserDetail />} />
      </Routes>
    </BrowserRouter>
  )
}

// 步骤 2:在目标组件中用 useParams 读取
function UserDetail() {
  // 步骤 3:id 的类型由泛型指定
  const { id } = useParams<{ id: string }>()

  return (
    <div>
      <p>当前用户 ID{id}</p>
      <Link to="/user/456">跳转到用户 456</Link>
    </div>
  )
}

支持多段动态参数:/post/:category/:id 可以同时获取 categoryid

12.2.2 useSearchParams:查询字符串

查询字符串是 URL 问号后的部分(?q=react&page=2),用 useSearchParams 读写:

import { useSearchParams } from 'react-router-dom'

function SearchPage() {
  const [searchParams, setSearchParams] = useSearchParams()

  // 步骤 1:读取查询参数
  const keyword = searchParams.get('q') ?? ''
  const page = Number(searchParams.get('page') ?? '1')

  // 步骤 2:更新查询参数(会合并到当前 URL)
  function handlePageChange(newPage: number) {
    setSearchParams(prev => {
      prev.set('page', String(newPage))
      return prev
    })
  }

  // 步骤 3:批量替换查询参数
  function handleReset() {
    setSearchParams({ q: '', page: '1' })
  }

  return (
    <div>
      <p>搜索关键词:{keyword},第 {page} </p>
      <button onClick={() => handlePageChange(page + 1)}>下一页</button>
      <button onClick={handleReset}>重置</button>
    </div>
  )
}

⚠️ 新手必踩的坑searchParams.get('q') 返回的是 string | null,不是 string。直接拿来当字符串用容易出 null 拼接的问题,务必加 ?? '' 兜底。

12.2.3 useLocation:获取完整 location 信息

useLocation 返回当前路由的完整信息对象:

import { useLocation } from 'react-router-dom'

function usePageViews() {
  const location = useLocation()

  useEffect(() => {
    // 步骤 1:每次路由变化时上报页面访问统计
    analytics.track({
      pathname: location.pathname,   // 路径部分:/users/123
      search: location.search,       // 查询部分:?q=react
      hash: location.hash,           // 哈希部分:#section-1
      key: location.key,             // location 唯一标识(每次导航不同)
      state: location.state,         // 导航时携带的隐藏数据
    })
  }, [location.pathname, location.search])
}

location 对象的完整结构:

属性类型示例说明
pathnamestring/users/123URL 路径部分
searchstring?q=react&page=2URL 查询字符串(含问号)
hashstring#section-1URL 哈希部分
stateunknown{ from: '/login' }导航时传递的隐藏数据
keystring3v4p9z2mlocation 唯一标识

12.2.4 state 传参:导航时传递隐藏数据

state 不出现在 URL 中,适合传递"不该被分享或书签"的数据:

import { useNavigate } from 'react-router-dom'

function LoginPage() {
  const navigate = useNavigate()

  const handleLogin = async () => {
    // 步骤 1:执行登录逻辑
    const user = await login(username, password)

    // 步骤 2:登录成功后跳转,同时传递隐藏 state
    navigate('/dashboard', {
      state: {
        from: '/login',       // 来源页面
        loginTime: Date.now(), // 登录时间戳
        welcomeMsg: `欢迎回来,${user.name}`,
      },
    })
  }

  return <button onClick={handleLogin}>登录</button>
}

// 目标页面读取 state
function Dashboard() {
  const location = useLocation()
  // 步骤 3:从 location.state 中读取数据(需要类型断言)
  const state = location.state as {
    from?: string
    loginTime?: number
    welcomeMsg?: string
  } | null

  return (
    <div>
      {state?.welcomeMsg && <p>{state.welcomeMsg}</p>}
      {state?.from && <p>你从 {state.from} 跳转过来</p>}
    </div>
  )
}

⚠️ 新手必踩的坑location.state 的数据存在浏览器的 history session 中,刷新页面后仍能保留(React Router v6 会序列化到 session history 中),但如果用户在新标签页打开链接或手动输入 URL,state 就会变成 null。所以 state 只能用于"锦上添花"的展示,不能作为关键逻辑的判断依据。


12.3 路由懒加载

类比:懒加载就像"按需上菜"的餐厅——你点了这道菜,厨房才现做。而不是一进门就把整个菜单的菜全做好摆桌上(全量打包),那样既慢又浪费带宽。React.lazy 就是那个"接到订单才开始做菜"的调度员。

12.3.1 React.lazy + Suspense 实现路由级代码分割

默认情况下,所有路由组件会被打进同一个 JS 文件。当应用变大后,首屏加载会越来越慢。用 React.lazy 把路由组件拆成独立 chunk,按需加载。

flowchart LR
    A["用户访问懒加载路由"] --> B["Suspense 拦截渲染"]
    B --> C{"对应 chunk
是否已加载"} C -->|否| D["动态 import
下载 JS 文件"] D --> E["浏览器解析并执行模块"] E --> F["渲染真实组件"] C -->|是| F B --> G["显示 fallback UI"] D --> G G --> F
import { lazy, Suspense } from 'react'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import Loading from './components/Loading'

// 步骤 1:用 lazy 把路由组件变成动态导入
// 注意:import() 返回的 Promise 需 resolve 出 { default: Component }
const Home = lazy(() => import('./pages/Home'))
const About = lazy(() => import('./pages/About'))
const Dashboard = lazy(() => import('./pages/Dashboard'))

function App() {
  return (
    <BrowserRouter>
      {/* 步骤 2:用 Suspense 包裹,提供 fallback 加载态 */}
      <Suspense fallback={<Loading />}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/about" element={<About />} />
          <Route path="/dashboard" element={<Dashboard />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  )
}

export default App

12.3.2 配置 fallback UI

fallback 是组件加载期间展示的占位内容。好的 fallback 应该保持页面布局,避免布局抖动(CLS):

// 步骤 1:骨架屏 fallback,保持与真实页面一致的布局
function PageSkeleton() {
  return (
    <div className="animate-pulse">
      <div className="h-8 bg-gray-200 rounded w-1/3 mb-4" />
      <div className="h-4 bg-gray-200 rounded w-full mb-2" />
      <div className="h-4 bg-gray-200 rounded w-2/3 mb-2" />
      <div className="h-32 bg-gray-200 rounded w-full" />
    </div>
  )
}

// 步骤 2:不同路由可以用不同 fallback
function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<PageSkeleton />}>
        <Routes>
          <Route path="/" element={<Home />} />
          {/* 详情页可以用更简单的 fallback */}
          <Route
            path="/detail/:id"
            element={
              <Suspense fallback={<div className="text-center py-20">加载详情中...</div>}>
                <Detail />
              </Suspense>
            }
          />
        </Routes>
      </Suspense>
    </BrowserRouter>
  )
}

12.3.3 配合 ErrorBoundary 处理加载失败

网络不稳定时,动态 import 可能失败(chunk 文件 404 或超时)。需要 ErrorBoundary 兜底:

import { Component, ReactNode, ErrorInfo } from 'react'

// 步骤 1:定义 ErrorBoundary 类组件
class RouteErrorBoundary extends Component<
  { children: ReactNode; fallback: ReactNode },
  { hasError: boolean }
> {
  state = { hasError: false }

  // 步骤 2:捕获错误后切换到 fallback
  static getDerivedStateFromError() {
    return { hasError: true }
  }

  // 步骤 3:记录错误信息
  componentDidCatch(error: Error, info: ErrorInfo) {
    console.error('路由加载失败:', error, info)
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback
    }
    return this.props.children
  }
}

// 步骤 4:在 App 中组合 ErrorBoundary + Suspense
function App() {
  return (
    <BrowserRouter>
      <RouteErrorBoundary
        fallback={
          <div className="flex flex-col items-center py-20">
            <p>页面加载失败</p>
            {/* 步骤 5:提供重试按钮,刷新页面重新加载 */}
            <button onClick={() => window.location.reload()}>
              重新加载
            </button>
          </div>
        }
      >
        <Suspense fallback={<PageSkeleton />}>
          <Routes>
            <Route path="/" element={<Home />} />
            <Route path="/dashboard" element={<Dashboard />} />
          </Routes>
        </Suspense>
      </RouteErrorBoundary>
    </BrowserRouter>
  )
}

⚠️ 新手必踩的坑React.lazy 只支持默认导出的组件,不支持命名导出。如果你的组件是 export function Home(),lazy 会报错。必须改成 export default function Home() 或在导入时处理:lazy(() => import('./Home').then(m => ({ default: m.Home })))。另外,部署新版本后旧 chunk 文件名变了,用户停留的页面点击导航会 chunk 加载失败——这就是为什么 ErrorBoundary 必不可少。


12.4 路由高级操作

12.4.1 路由守卫的实现(基于角色的权限控制)

类比:路由守卫就像大厦电梯的刷卡系统——你刷什么卡(角色),电梯就带你能去哪些楼层(路由)。没卡的去不了,普通员工卡去不了高管层。

import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { ReactNode } from 'react'

// 步骤 1:定义权限 store(配合第十三章 Zustand 使用)
type UserRole = 'admin' | 'editor' | 'viewer' | null
let currentUser: { role: UserRole } = { role: 'editor' } // 模拟当前用户

// 步骤 2:封装路由守卫组件
function RoleGuard({
  children,
  roles,
}: {
  children: ReactNode
  roles: UserRole[]
}) {
  // 步骤 3:检查用户是否登录
  if (!currentUser.role) {
    return <Navigate to="/login" replace />
  }
  // 步骤 4:检查用户角色是否在允许列表中
  if (!roles.includes(currentUser.role)) {
    return <Navigate to="/403" replace />
  }
  // 步骤 5:权限通过,渲染子组件
  return <>{children}</>
}

// 步骤 6:在路由中使用守卫
function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Home />} />
        {/* editor 及以上角色可访问 */}
        <Route
          path="/editor"
          element={
            <RoleGuard roles={['admin', 'editor']}>
              <EditorPage />
            </RoleGuard>
          }
        />
        {/* 仅 admin 可访问 */}
        <Route
          path="/admin"
          element={
            <RoleGuard roles={['admin']}>
              <AdminPage />
            </RoleGuard>
          }
        />
        <Route path="/login" element={<Login />} />
        <Route path="/403" element={<Forbidden />} />
      </Routes>
    </BrowserRouter>
  )
}

12.4.2 编程式导航(useNavigate 的 push / replace)

useNavigate 返回一个导航函数,支持 push(默认,留下历史记录)和 replace(替换历史记录):

import { useNavigate } from 'react-router-dom'

function LoginPage() {
  const navigate = useNavigate()

  const handleLogin = async () => {
    const ok = await login()

    if (ok) {
      // 步骤 1:push 导航——浏览器历史记录中留下当前页,用户可以点"后退"回来
      navigate('/dashboard')
      // 等价于:<Link to="/dashboard" />

      // 步骤 2:replace 导航——替换当前历史记录,用户无法点"后退"回到登录页
      navigate('/dashboard', { replace: true })

      // 步骤 3:传 state 导航
      navigate('/dashboard', {
        replace: true,
        state: { from: 'login' },
      })
    }
  }

  const handleGoBack = () => {
    // 步骤 4:后退一页,等价于浏览器的后退按钮
    navigate(-1)
    // 前进一页:navigate(1)
  }

  return (
    <div>
      <button onClick={handleLogin}>登录</button>
      <button onClick={handleGoBack}>返回</button>
    </div>
  )
}

⚠️ 新手必踩的坑:登录成功后跳转到首页,一定要用 replace: true,否则用户点"后退"又回到登录页,体验极差。同理,支付完成后跳转订单页也要 replace,防止后退回到支付页重复支付。

12.4.3 路由配置集中管理(useRoutes)

当路由变多时,JSX 形式的 <Route> 嵌套会变得难以维护。useRoutes 允许用配置对象集中管理:

import { useRoutes, BrowserRouter } from 'react-router-dom'

// 步骤 1:把所有路由抽到配置数组中,便于维护和权限遍历
const routeConfig = [
  {
    path: '/',
    element: <Layout />,
    children: [
      { index: true, element: <Home /> },
      { path: 'about', element: <About /> },
      {
        path: 'user/:id',
        element: (
          <RoleGuard roles={['admin', 'editor']}>
            <UserDetail />
          </RoleGuard>
        ),
      },
      {
        path: 'admin',
        element: (
          <RoleGuard roles={['admin']}>
            <AdminPanel />
          </RoleGuard>
        ),
      },
    ],
  },
  // 步骤 2:兜底 404 路由
  { path: '*', element: <NotFound /> },
]

function AppRoutes() {
  // 步骤 3:useRoutes 根据当前 URL 匹配配置,返回对应元素
  return useRoutes(routeConfig)
}

function App() {
  return (
    <BrowserRouter>
      <AppRoutes />
    </BrowserRouter>
  )
}

12.4.4 滚动恢复(ScrollRestoration)

用户从长列表跳到详情页再返回时,浏览器默认不会恢复滚动位置。React Router v6.4+ 提供了 ScrollRestoration

import {
  createBrowserRouter,
  RouterProvider,
  ScrollRestoration,
} from 'react-router-dom'

// 步骤 1:用 createBrowserRouter 创建路由(Data API 模式)
const router = createBrowserRouter([
  {
    path: '/',
    element: (
      <>
        <RootLayout />
        {/* 步骤 2:ScrollRestoration 放在根布局中 */}
        <ScrollRestoration />
      </>
    ),
    children: [
      { index: true, element: <Home /> },
      { path: 'list', element: <LongList /> },
      { path: 'detail/:id', element: <Detail /> },
    ],
  },
])

// 步骤 3:自定义滚动恢复逻辑(可选)
// ScrollRestoration 支持 getKey 属性来自定义 cache key
{
  /* <ScrollRestoration
  getKey={(location, matches) => {
    // 搜索页不恢复滚动位置,始终从顶部开始
    if (location.pathname.startsWith('/search')) {
      return location.key
    }
    // 默认按 pathname 恢复
    return location.pathname
  }}
/> */
}

function App() {
  return <RouterProvider router={router} />
}

⚠️ 新手必踩的坑ScrollRestoration 只能在 createBrowserRouter / createHashRouter 创建的路由中使用,不能在 <BrowserRouter> + <Routes> 的组件模式中使用。如果你的项目用的是组件模式,需要自己用 useLocation + useEffect 手动实现滚动恢复。


12.5 边界处理

12.5.1 404 页面配置

用通配符路由 * 兜底所有未匹配的路径:

import { Routes, Route, Link } from 'react-router-dom'

function NotFound() {
  return (
    <div className="flex flex-col items-center justify-center min-h-screen">
      <h1 className="text-6xl font-bold text-gray-300">404</h1>
      <p className="mt-4 text-gray-500">页面不存在</p>
      <Link
        to="/"
        className="mt-6 px-6 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
      >
        返回首页
      </Link>
    </div>
  )
}

function App() {
  return (
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/about" element={<About />} />
      {/* 步骤:通配符路由必须放在最后,匹配所有未命中的路径 */}
      <Route path="*" element={<NotFound />} />
    </Routes>
  )
}

12.5.2 路由错误边界

路由级别的错误需要边界捕获,防止整个应用白屏:

import { Component, ReactNode, ErrorInfo } from 'react'
import { Link } from 'react-router-dom'

// 步骤 1:路由级错误边界
class RouteErrorBoundary extends Component<
  { children: ReactNode },
  { hasError: boolean; error: Error | null }
> {
  state = { hasError: false, error: null }

  static getDerivedStateFromError(error: Error) {
    return { hasError: true, error }
  }

  componentDidCatch(error: Error, info: ErrorInfo) {
    // 步骤 2:上报错误到监控系统
    errorTracker.capture(error, { info })
  }

  render() {
    if (this.state.hasError) {
      return (
        <div className="flex flex-col items-center justify-center min-h-screen">
          <h1 className="text-2xl font-bold">页面出错了</h1>
          <p className="text-gray-500 mt-2">{this.state.error?.message}</p>
          {/* 步骤 3:提供返回入口 */}
          <Link to="/" className="mt-6 text-blue-500 hover:underline">
            返回首页
          </Link>
        </div>
      )
    }
    return this.props.children
  }
}

// 步骤 4:在根组件包裹
function App() {
  return (
    <RouteErrorBoundary>
      <Routes>
        <Route path="/" element={<Home />} />
      </Routes>
    </RouteErrorBoundary>
  )
}

12.5.3 路由跳转时的 loading 处理

React Router v6.4+ 的 Data API 模式提供了 useNavigation 来获取导航状态,实现全局 loading 指示:

import {
  createBrowserRouter,
  RouterProvider,
  useNavigation,
  Outlet,
} from 'react-router-dom'

// 步骤 1:根布局组件中读取导航状态
function RootLayout() {
  const navigation = useNavigation()

  // 步骤 2:navigation.state 有三种值:idle / loading / submitting
  const isLoading = navigation.state === 'loading'

  return (
    <div>
      {/* 步骤 3:顶部加载进度条 */}
      {isLoading && (
        <div className="fixed top-0 left-0 right-0 h-1 bg-blue-500 animate-pulse" />
      )}

      {/* 步骤 4:导航时可以显示当前正在加载的目标路径 */}
      {isLoading && (
        <div className="fixed top-4 right-4 text-sm text-gray-500">
          正在加载 {navigation.location?.pathname}...
        </div>
      )}

      <Outlet />
    </div>
  )
}

// 步骤 5:创建路由并配置 loader(loader 触发 loading 状态)
const router = createBrowserRouter([
  {
    path: '/',
    element: <RootLayout />,
    children: [
      {
        index: true,
        element: <Home />,
        // loader 在路由进入前执行,此时 navigation.state === 'loading'
        loader: async () => {
          const res = await fetch('/api/home-data')
          return res.json()
        },
      },
    ],
  },
])

function App() {
  return <RouterProvider router={router} />
}

12.5.4 重定向(Navigate 组件和 redirect)

React Router 提供两种重定向方式:

import {
  BrowserRouter,
  Routes,
  Route,
  Navigate,
  createBrowserRouter,
  RouterProvider,
  redirect,
} from 'react-router-dom'

// 方式一:Navigate 组件(适用于组件模式)
function OldRoutes() {
  return (
    <Routes>
      {/* 步骤 1:redirect 旧路径到新路径,replace 防止后退回旧路径 */}
      <Route path="/old-about" element={<Navigate to="/about" replace />} />
      {/* 步骤 2:带条件的重定向 */}
      <Route
        path="/legacy"
        element={<ConditionalRedirect shouldRedirect={true} to="/new" />}
      />
      {/* 步骤 3:带 state 的重定向 */}
      <Route
        path="/moved"
        element={<Navigate to="/new-location" replace state={{ moved: true }} />}
      />
    </Routes>
  )
}

function ConditionalRedirect({
  shouldRedirect,
  to,
}: {
  shouldRedirect: boolean
  to: string
}) {
  if (shouldRedirect) {
    return <Navigate to={to} replace />
  }
  return <LegacyPage />
}

// 方式二:redirect 函数(适用于 Data API 的 loader)
const router = createBrowserRouter([
  {
    path: '/old-about',
    // 步骤 4:在 loader 中用 redirect 函数重定向
    loader: () => {
      return redirect('/about')
    },
  },
  {
    path: '/old-users',
    // 步骤 5:可以根据条件决定是否重定向
    loader: async () => {
      const config = await fetchConfig()
      if (config.useNewUsersPage) {
        return redirect('/users')
      }
      return null
    },
    element: <OldUsersPage />,
  },
])

十三、Zustand 状态管理

类比:Zustand 就像一个"公共白板"。所有人(组件)都能看到白板上的内容(state),谁想改就直接擦了写新的(set),白板会自动通知所有盯着它看的人。不用像 Redux 那样先写 action type、再写 reducer、还要在根组件套一层 Provider——白板本身就是全局的,拿起来就能用。

13.1 快速入门

13.1.1 Zustand 是什么

Zustand(德语"状态")是一个极简的 React 状态管理库,核心特点:

  • 无 Provider:不需要在组件树外层包裹 Provider,store 就是全局的
  • 极简 API:一个 create 函数搞定 store 定义,没有 action/reducer/dispatch 概念
  • 按需订阅:组件只订阅自己关心的状态切片,不相关的状态变化不会触发重渲染
  • TypeScript 友好:天然支持类型推导,不需要额外的类型模板
npm install zustand immer

13.1.2 为什么选 Zustand 而不是 Redux

对比项Redux ToolkitZustand
最小实现5+ 个文件(slice / store / hooks / provider)1 个文件 10 行代码
Provider必须包裹 <Provider store={store}>不需要
样板代码action type / reducer / dispatch直接 set
包体积~1.2 KB(+ react-redux ~2 KB)~1.1 KB
学习曲线陡(概念多)平(几乎无新概念)
DevTools内置中间件集成
适用场景大型团队 / 严格架构约束中小型项目 / 快速迭代

关键判断:如果你的项目不需要 Redux 的"可预测状态流转 + 时间旅行 + 严格架构约束",Zustand 是更轻量的选择。它用更少的代码达到同样的效果,而且心智模型更简单。

13.1.3 创建第一个 store

import { create } from 'zustand'

// 步骤 1:定义 store 的接口类型(state + actions)
interface BearStore {
  // state
  bears: number
  // actions
  addBear: () => void
  removeBear: () => void
  resetBears: () => void
}

// 步骤 2:用 create 创建 store,返回一个 hook
const useBearStore = create<BearStore>((set) => ({
  // 步骤 3:定义初始 state
  bears: 0,
  // 步骤 4:定义 action,用 set 更新状态
  addBear: () => set((state) => ({ bears: state.bears + 1 })),
  removeBear: () => set((state) => ({ bears: state.bears - 1 })),
  resetBears: () => set({ bears: 0 }),
}))
flowchart LR
    A["组件调用 action"] --> B["Store 执行 set"]
    B --> C["更新内部 state"]
    C --> D["通知所有订阅者"]
    D --> E{"组件是否依赖
变化的 state 切片"} E -->|是| F["触发 re-render"] E -->|否| G["跳过 不渲染"]

13.1.4 在组件中使用 store

// 步骤 1:在组件中调用 store hook,传入 selector 选择需要的状态切片
function BearCounter() {
  // 只订阅 bears,bears 变化时才 re-render
  const bears = useBearStore((state) => state.bears)

  return <h1>{bears} 只熊</h1>
}

// 步骤 2:action 也可以单独选择,不需要和 state 绑在一起
function BearControls() {
  const addBear = useBearStore((state) => state.addBear)
  const removeBear = useBearStore((state) => state.removeBear)

  return (
    <div>
      <button onClick={addBear}>加一只</button>
      <button onClick={removeBear}>减一只</button>
    </div>
  )
}

// 步骤 3:组装到页面
function App() {
  return (
    <div>
      <BearCounter />
      <BearControls />
    </div>
  )
}

⚠️ 新手必踩的坑:如果 selector 返回的是一个新对象,每次 store 变化都会触发重渲染,即使实际数据没变。错误写法:const { bears, addBear } = useBearStore((state) => ({ bears: state.bears, addBear: state.addBear }))——每次调用都返回新对象引用,selector 永远认为"变了"。解决方法见 13.3 节的 useShallow


13.2 状态处理与 Immer 原理

13.2.1 Zustand 中的状态更新

Zustand 要求状态更新必须是**不可变(immutable)**的——不能直接修改原对象,必须返回新对象:

import { create } from 'zustand'

interface UserStore {
  user: { name: string; age: number; tags: string[] }
  setUserName: (name: string) => void
  addTag: (tag: string) => void
  setAge: (age: number) => void
}

const useUserStore = create<UserStore>((set) => ({
  user: { name: 'Alice', age: 18, tags: [] },

  // 步骤 1:简单值更新,直接返回新值
  setUserName: (name) => set((state) => ({
    user: { ...state.user, name },
  })),

  // 步骤 2:数组更新,必须用展开运算符创建新数组
  addTag: (tag) => set((state) => ({
    user: { ...state.user, tags: [...state.user.tags, tag] },
  })),

  setAge: (age) => set((state) => ({
    user: { ...state.user, age },
  })),
}))

⚠️ 新手必踩的坑:直接修改 state 是 React 中最常见的 bug 来源。state.user.tags.push(tag) 看起来"改了",但数组引用没变,Zustand 的浅比较认为状态没变,不会通知组件更新。必须用 [...state.user.tags, tag] 创建新数组。嵌套层级越深,这种展开运算符写法越痛苦——这正是 Immer 要解决的问题。

13.2.2 使用 immer 中间件实现可变风格更新

Immer 中间件让你可以"看起来在直接修改",实际上内部帮你生成不可变副本:

import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'

interface UserStore {
  user: { name: string; age: number; tags: string[] }
  setUserName: (name: string) => void
  addTag: (tag: string) => void
}

// 步骤 1:用 immer 中间件包裹 store 定义
const useUserStore = create<UserStore>()(
  immer((set) => ({
    user: { name: 'Alice', age: 18, tags: [] },

    // 步骤 2:直接在 draft 上修改,无需展开运算符
    setUserName: (name) => set((state) => {
      state.user.name = name
    }),

    // 步骤 3:嵌套数组也直接 push,Immer 自动处理
    addTag: (tag) => set((state) => {
      state.user.tags.push(tag)
    }),
  }))
)

对比没有 Immer 时的写法:

// 没有 Immer:嵌套越深,展开运算符越痛苦
addTag: (tag) => set((state) => ({
  user: {
    ...state.user,
    tags: [...state.user.tags, tag],
  },
}))

// 有 Immer:直接改,清爽
addTag: (tag) => set((state) => {
  state.user.tags.push(tag)
})

13.2.3 Immer 的原理:Proxy + copy-on-write

类比:Immer 就像一个"透明复印机"。你拿了一份原件(当前 state)放进机器,机器给你一张"可涂改的复印件"(draft / Proxy 代理对象)。你在复印件上随便改,改完后机器自动对比原件和复印件的差异,只把改动过的部分生成新副本——没改的部分仍然指向原件。这就是 copy-on-write(写时复制)。

Immer 的工作流程:

sequenceDiagram
    participant C as 组件
    participant S as Store
    participant I as Immer
    participant P as Proxy 代理层
    participant N as 新状态

    C->>S: 调用 set(draft => draft.user.name = 'Bob')
    S->>I: 传入 recipe 函数
    I->>P: 基于当前 state 创建 Proxy
    P->>P: 拦截所有写操作 记录变更路径
    P->>N: copy-on-write 只复制被修改的节点
    N->>S: 返回不可变的新 state
    S->>C: 通知订阅者 触发 re-render

核心原理拆解:

  1. 创建 Proxy:Immer 用 ES6 Proxy 包裹当前 state,生成一个 draft 对象
  2. 拦截读写:Proxy 拦截所有属性访问——读操作直接转发到原对象(零拷贝),写操作标记该路径为"已修改"
  3. copy-on-write:只有被修改的属性路径才会被浅拷贝,未修改的属性仍然共享原对象的引用(结构共享)
  4. 生成结果:recipe 执行完毕后,Immer 根据变更记录生成一个新的不可变对象
// 步骤 1:Immer 的核心 API 就是 produce
import { produce } from 'immer'

const state = { user: { name: 'Alice', age: 18 }, count: 0 }

// 步骤 2:produce 接收当前 state 和一个 recipe 函数
const nextState = produce(state, (draft) => {
  // 步骤 3:在 draft 上"直接修改"
  draft.user.name = 'Bob'
  draft.count++
})

// 步骤 4:结果是不可变的新对象
console.log(state !== nextState)           // true:顶层引用变了
console.log(state.user !== nextState.user)  // true:user 被修改了,引用变了
console.log(state === nextState)           // false
// 步骤 5:结构共享——没有修改的部分仍然共享引用
// (如果有其他顶层属性未被修改,它们的引用不变)

13.3 状态简化

13.3.1 选择器(selector)优化:避免不必要重渲染

选择器是 Zustand 性能优化的核心。组件只订阅需要的状态切片,其余状态变化不会触发重渲染:

import { create } from 'zustand'

interface Store {
  bears: number
  fishes: number
  birds: number
  addBear: () => void
  addFish: () => void
  addBird: () => void
}

const useStore = create<Store>((set) => ({
  bears: 0,
  fishes: 0,
  birds: 0,
  addBear: () => set((s) => ({ bears: s.bears + 1 })),
  addFish: () => set((s) => ({ fishes: s.fishes + 1 })),
  addBird: () => set((s) => ({ birds: s.birds + 1 })),
}))

// 步骤 1:只订阅 bears,fishes / birds 变化时不会 re-render
function BearCounter() {
  const bears = useStore((state) => state.bears)
  // fishes 和 birds 的变化不会触发这个组件的 re-render
  return <p>{bears} 只熊</p>
}

// 步骤 2:action 是稳定引用,不需要依赖
function Controls() {
  const addBear = useStore((state) => state.addBear)
  return <button onClick={addBear}>加熊</button>
}

// 步骤 3:需要多个字段时的错误写法(每次都返回新对象)
function BadComponent() {
  // 错误:返回新对象,每次 store 变化都会 re-render
  const { bears, fishes } = useStore((state) => ({
    bears: state.bears,
    fishes: state.fishes,
  }))
  return <p>{bears} 熊,{fishes} </p>
}
flowchart TD
    A["Store state 发生变更"] --> B["遍历所有 selector"]
    B --> C{"selector 返回值
是否变化"} C -->|是| D["通知订阅该 selector 的组件"] C -->|否| E["跳过 不触发 re-render"] D --> F["组件 re-render"] E --> G["组件保持不变"]

13.3.2 shallow 比较

当一个 selector 需要返回多个字段时,用 useShallow 做浅比较,避免每次都因新对象引用而重渲染:

import { create } from 'zustand'
import { useShallow } from 'zustand/react/shallow'

// 步骤 1:使用 useShallow 包裹 selector
function GoodComponent() {
  // useShallow 会对返回的对象做浅比较
  // 只有 bears 或 fishes 的值真正变了,才触发 re-render
  const { bears, fishes } = useStore(
    useShallow((state) => ({
      bears: state.bears,
      fishes: state.fishes,
    }))
  )
  return <p>{bears} 熊,{fishes} </p>
}

// 步骤 2:返回数组时同样需要 useShallow
function Summary() {
  const [bears, fishes, birds] = useStore(
    useShallow((state) => [state.bears, state.fishes, state.birds])
  )
  return <p> {bears + fishes + birds} 只动物</p>
}

useShallow 的原理:它缓存上一次 selector 返回的对象/数组,对新旧结果的每个属性做浅比较(===)。只有任意一个属性变了才认为"变了",触发重渲染。

13.3.3 拆分 store vs 合并 store 的取舍

// 方式一:合并 store——所有状态放一个 store
const useAppStore = create((set) => ({
  user: { name: 'Alice' },
  cart: { items: [] },
  theme: 'light',
  setUser: (user) => set((s) => ({ user: { ...s.user, ...user } })),
  addToCart: (item) => set((s) => ({ cart: { items: [...s.cart.items, item] } })),
  toggleTheme: () => set((s) => ({ theme: s.theme === 'light' ? 'dark' : 'light' })),
}))

// 方式二:拆分 store——按领域分多个 store
const useUserStore = create((set) => ({
  user: { name: 'Alice' },
  setUser: (user) => set((s) => ({ user: { ...s.user, ...user } })),
}))

const useCartStore = create((set) => ({
  cart: { items: [] },
  addToCart: (item) => set((s) => ({ cart: { items: [...s.cart.items, item] } })),
}))

const useThemeStore = create((set) => ({
  theme: 'light',
  toggleTheme: () => set((s) => ({ theme: s.theme === 'light' ? 'dark' : 'light' })),
}))
对比项合并 store拆分 store
代码组织一个文件管全部按领域分文件
重渲染selector 精准订阅即可天然隔离,无影响
跨 store 通信直接访问需要在 action 中引用另一个 store
可测试性较难(状态耦合)容易(各自独立)
适用场景小型应用 / 状态关联紧密中大型应用 / 领域边界清晰

最佳实践:按"领域"拆分 store,而不是按"组件"拆分。比如 useUserStoreuseCartStoreuseThemeStore 各管各的领域。跨 store 通信时,在一个 store 的 action 中直接调用另一个 store 的 getState()

const useCartStore = create((set, get) => ({
  items: [],
  checkout: () => {
    // 步骤:跨 store 通信——在 cart action 中读取 user store
    const user = useUserStore.getState().user
    if (!user) throw new Error('未登录')
    // ...
  },
}))

13.4 中间件

类比:中间件就像快递分拣中心的"传送带"。你的包裹(状态更新请求)从入口进去,经过一道道工序(中间件)——有的贴标签(devtools 记录)、有的存档(persist 持久化)、有的质检(自定义校验)——最后才送到目的地(实际 store 更新)。

13.4.1 persist:持久化到 localStorage

persist 中间件自动把 store 状态序列化到 localStorage(或 sessionStorage / AsyncStorage),实现页面刷新后状态不丢失:

import { create } from 'zustand'
import { persist } from 'zustand/middleware'

interface CartStore {
  items: { id: string; name: string; price: number }[]
  addItem: (item: { id: string; name: string; price: number }) => void
  clearCart: () => void
}

// 步骤 1:用 persist 中间件包裹 store
const useCartStore = create<CartStore>()(
  persist(
    (set) => ({
      items: [],
      addItem: (item) => set((state) => ({
        items: [...state.items, item],
      })),
      clearCart: () => set({ items: [] }),
    }),
    {
      // 步骤 2:localStorage 中的 key
      name: 'cart-storage',
      // 步骤 3:可选——只持久化部分字段
      partialize: (state) => ({ items: state.items }),
      // 步骤 4:可选——使用 sessionStorage 替代 localStorage
      // storage: createJSONStorage(() => sessionStorage),
    }
  )
)

13.4.2 devtools:Redux DevTools 集成

devtools 中间件让你能在浏览器 Redux DevTools 扩展中查看和调试 Zustand 状态变更:

import { create } from 'zustand'
import { devtools } from 'zustand/middleware'

const useStore = create<Store>()(
  devtools(
    (set) => ({
      count: 0,
      increment: () => set(
        (state) => ({ count: state.count + 1 }),
        // 步骤 1:可选——给这次 set 一个 action 名字,DevTools 中显示
        false,
        'INCREMENT_COUNT'
      ),
      decrement: () => set(
        (state) => ({ count: state.count - 1 }),
        false,
        'DECREMENT_COUNT'
      ),
    }),
    {
      // 步骤 2:DevTools 中显示的 store 名称
      name: 'CounterStore',
      // 步骤 3:只在开发环境启用
      enabled: import.meta.env.DEV,
    }
  )
)

13.4.3 自定义中间件的写法

Zustand 的中间件本质上是一个高阶函数,接收 store 配置,返回增强后的配置:

import { create, StateCreator } from 'zustand'

// 步骤 1:定义日志中间件——在每次 set 前后打印状态
function logMiddleware<T extends object>(
  config: StateCreator<T>
): StateCreator<T> {
  return (set, get, api) =>
    config(
      (...args) => {
        console.log('[before set]', get())
        set(...args)
        console.log('[after set]', get())
      },
      get,
      api
    )
}

// 步骤 2:定义性能监控中间件——记录每次 set 的耗时
function perfMiddleware<T extends object>(
  config: StateCreator<T>
): StateCreator<T> {
  return (set, get, api) =>
    config(
      (...args) => {
        const start = performance.now()
        set(...args)
        const end = performance.now()
        if (end - start > 16) {
          console.warn(`[perf] set 耗时 ${end - start}ms,超过一帧`)
        }
      },
      get,
      api
    )
}

// 步骤 3:使用自定义中间件
const useStore = create(
  perfMiddleware(
    logMiddleware((set) => ({
      count: 0,
      increment: () => set((s) => ({ count: s.count + 1 })),
    }))
  )
)

13.4.4 中间件的组合顺序

多个中间件按洋葱模型嵌套,执行顺序从外到内:

import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'

// 步骤 1:中间件按从外到内的顺序嵌套
const useStore = create<Store>()(
  devtools(          // 最外层:最先拦截,最后执行
    persist(         // 中间层:持久化
      immer(         // 最内层:Proxy 化状态
        (set) => ({
          user: { name: 'Alice', age: 18 },
          setUserName: (name) => set((state) => {
            state.user.name = name  // immer 处理不可变性
          }),
        }),
        { name: 'user-storage' }   // persist 的配置
      )
    ),
    { name: 'UserStore' }          // devtools 的配置
  )
)
flowchart TD
    A["create 函数调用"] --> B["最外层: devtools
拦截所有状态变更
记录到 DevTools"] B --> C["中间层: persist
持久化到 localStorage"] C --> D["最内层: immer
Proxy 化状态对象
处理不可变更新"] D --> E["最底层: 原始 store
定义 set / get"]

⚠️ 新手必踩的坑:中间件顺序很重要。persist 应该在 immer 外层——先让 immer 生成不可变新状态,再让 persist 序列化存储。如果顺序反了,persist 可能序列化的是 draft 对象而不是最终状态。同理 devtools 放最外层才能捕获到所有中间件处理后的最终状态。


13.5 订阅

类比:订阅就像订报纸。你不需要每天去报刊亭问"今天有新报纸吗",而是提前登记(subscribe),有新报就自动送到你家(回调函数)。Zustand 的 subscribe 就是这个"登记"机制——不仅组件内能用,组件外(工具函数、路由守卫、日志模块)也能用。

13.5.1 在组件外订阅 store 变化

subscribe 可以在 React 组件外监听 store 变化,常用于日志、持久化、副作用触发:

import { create } from 'zustand'
import { subscribeWithSelector } from 'zustand/middleware'

interface AuthStore {
  token: string | null
  user: { name: string; role: string } | null
  setAuth: (token: string, user: { name: string; role: string }) => void
  logout: () => void
}

// 步骤 1:用 subscribeWithSelector 包裹 store,才能使用 selector 式订阅
const useAuthStore = create<AuthStore>()(
  subscribeWithSelector((set) => ({
    token: null,
    user: null,
    setAuth: (token, user) => set({ token, user }),
    logout: () => set({ token: null, user: null }),
  }))
)

// 步骤 2:在组件外订阅——监听 token 变化,更新 axios 请求头
useAuthStore.subscribe(
  (state) => state.token,  // selector:只监听 token
  (token) => {
    // 步骤 3:token 变了,同步到 axios 默认请求头
    if (token) {
      axios.defaults.headers.common['Authorization'] = `Bearer ${token}`
    } else {
      delete axios.defaults.headers.common['Authorization']
    }
  }
)

// 步骤 4:监听 logout 事件,跳转到登录页
useAuthStore.subscribe(
  (state) => state.user,
  (user) => {
    if (!user) {
      // 组件外不能使用 useNavigate,用 window.location
      window.location.href = '/login'
    }
  }
)
flowchart LR
    A["外部调用 subscribe
注册回调函数"] --> B["Store 内部
listeners 集合"] C["调用 set 更新状态"] --> D["遍历 listeners 集合"] D --> E["逐个执行回调函数"] E --> F{"selector 返回值
是否匹配目标切片"} F -->|匹配| G["执行订阅回调逻辑"] F -->|不匹配| H["跳过本次通知"]

13.5.2 subscribeWithSelector 中间件

默认的 subscribe 只接收一个回调函数,每次 set 都会触发。subscribeWithSelector 中间件让你可以指定 selector,只在特定状态切片变化时才触发回调:

import { create } from 'zustand'
import { subscribeWithSelector } from 'zustand/middleware'

interface CounterStore {
  count: number
  name: string
  increment: () => void
  setName: (name: string) => void
}

// 步骤 1:用 subscribeWithSelector 包裹 store
const useCounterStore = create<CounterStore>()(
  subscribeWithSelector(
    (set) => ({
      count: 0,
      name: 'counter',
      increment: () => set((s) => ({ count: s.count + 1 })),
      setName: (name) => set({ name }),
    })
  )
)

// 步骤 2:subscribe 只监听 count 变化,name 变化不触发
const unsubscribe = useCounterStore.subscribe(
  (state) => state.count,       // 第一个参数:selector
  (count, prevCount) => {      // 第二个参数:回调(新值 + 旧值)
    console.log(`count: ${prevCount} -> ${count}`)
  }
)

// 步骤 3:不需要时取消订阅,防止内存泄漏
// unsubscribe()

// 步骤 4:也可以在组件内使用 useEffect 管理订阅生命周期
function useCountLogger() {
  useEffect(() => {
    const unsub = useCounterStore.subscribe(
      (state) => state.count,
      (count) => console.log('当前 count:', count)
    )
    // 步骤 5:组件卸载时取消订阅
    return () => unsub()
  }, [])
}

13.5.3 跨组件共享状态的最佳实践

import { create } from 'zustand'
import { useEffect } from 'react'

// 步骤 1:定义全局状态 store
const useGlobalStore = create<{
  onlineUsers: string[]
  notifications: { id: string; message: string }[]
  addOnlineUser: (user: string) => void
  addNotification: (msg: string) => void
}>((set) => ({
  onlineUsers: [],
  notifications: [],
  addOnlineUser: (user) => set((state) => ({
    onlineUsers: [...new Set([...state.onlineUsers, user])],
  })),
  addNotification: (message) => set((state) => ({
    notifications: [
      ...state.notifications,
      { id: crypto.randomUUID(), message },
    ],
  })),
}))

// 步骤 2:组件 A——消费 onlineUsers
function OnlineUsers() {
  const onlineUsers = useGlobalStore((s) => s.onlineUsers)
  // notifications 变化不会触发这个组件 re-render
  return <div>在线用户:{onlineUsers.join(', ')}</div>
}

// 步骤 3:组件 B——消费 notifications
function NotificationList() {
  const notifications = useGlobalStore((s) => s.notifications)
  // onlineUsers 变化不会触发这个组件 re-render
  return (
    <ul>
      {notifications.map(n => <li key={n.id}>{n.message}</li>)}
    </ul>
  )
}

// 步骤 4:组件 C——WebSocket 推送数据,写入 store
function WebSocketHandler() {
  useEffect(() => {
    const ws = new WebSocket('ws://localhost:8080')
    ws.onmessage = (event) => {
      const data = JSON.parse(event.data)
      // 步骤 5:在组件内更新 store,所有订阅者自动收到通知
      if (data.type === 'user_online') {
        useGlobalStore.getState().addOnlineUser(data.user)
      }
      if (data.type === 'notification') {
        useGlobalStore.getState().addNotification(data.message)
      }
    }
    return () => ws.close()
  }, [])
  return null
}

// 步骤 6:组装——所有组件共享同一个 store 实例,无需 Provider
function App() {
  return (
    <>
      <WebSocketHandler />
      <OnlineUsers />
      <NotificationList />
    </>
  )
}

⚠️ 新手必踩的坑:在组件外更新 store 时,不要用 hook(useGlobalStore((s) => s.xxx)),而是用 useGlobalStore.getState() 获取当前状态,或用 useGlobalStore.setState() 直接更新。hook 只能在组件或自定义 hook 内部调用。同理,subscribe 返回的取消函数如果在组件内使用,一定要在 useEffect 的 cleanup 中调用,否则组件卸载后回调仍在执行,造成内存泄漏。

自测题与动手练习

自测题(合上书能答出来,才算懂)

  1. JSX 里 { } 能做变量插值和表达式,但它不能直接写 if/else 语句——那条件渲染通常有哪几种写法?为什么 key 不能用数组下标?
  2. 受控组件的核心思想是"状态即单一数据源",input / checkbox / selectvalue 分别该绑定什么、变化怎么回写?受控和非受控组件什么场景选哪个?
  3. useEffect 的依赖数组为空、为 [x]、为不传,三种写法分别在什么时候执行副作用?cleanup 函数(return)在什么时机跑?
  4. useMemo / useCallback 解决的是"重复计算"还是"重复渲染"?它们用错了(比如依赖数组写错)会带来什么隐蔽 bug?
  5. BrowserRouterHashRouter 的核心区别是什么?路由守卫、嵌套路由、路由懒加载分别解决什么场景?
  6. Zustand 相比 Redux 有哪些优势?selector + useShallow 如何避免不必要的重渲染?immer 中间件解决了什么问题?
  7. HOC 高阶组件和 Hooks 分别适合什么场景?createPortal 的事件冒泡沿组件树还是 DOM 树?
  8. CSS Modules、CSS-in-JS、Tailwind 三种方案各自的核心优势和适用场景是什么?

动手练习(建议真做一遍)

  1. 写一个带搜索框的列表组件,把 useState(搜索词)+ 受控 input + map 渲染 + key 串起来,故意把 key 改成数组下标,观察列表增删时出现的诡异状态。
  2. useContext + useReducer 搭一个迷你全局状态(比如主题切换 / 登录用户),再用 Zustand 实现同样的功能,对比两种写法的代码量和开发体验。
  3. react-router-dom 配出"列表页 → 详情页(路由参数)+ 登录守卫 + 路由懒加载"的最小路由,并把 axios 封装成带 baseURL、超时、错误拦截的实例,接一个真实或 mock 接口。
  4. 用 TSX 从零写一个支持 variant(primary/danger)、size、loading、disabled 的 Button 组件,搭配 CSS Modules 做样式隔离。
  5. 用 Zustand 的 persist 中间件实现一个带 localStorage 持久化的购物车 store,配合 selector 做到"只有购物车数量变化时才重渲染徽标组件"。

本章小结

  • React 把 UI 看成 state -> 视图 的纯函数映射:JSX 描述结构,数据用 map 渲染成列表,交互通过受控组件把用户输入回流到状态。
  • Hooks 是状态与副作用的入口:useState 管局部状态、useEffect 管副作用与清理、useMemo/useCallback 缓存计算与函数引用、useRef 存跨渲染的可变值、useContext/useReducer 做跨组件状态共享、useTransition/useDeferredValue 处理并发更新。
  • 组件篇覆盖了从基础通讯到高级模式:父传子用 props、子传父用回调、跨层用 Context;HOC 适合注入式增强,异步组件用 lazy + Suspense + ErrorBoundarycreatePortal 让弹窗渲染脱离 DOM 层级但保留事件冒泡。
  • 原理篇深入 Fiber 架构:Virtual DOM 是 JS 对象树,Diff 靠三大假设降低复杂度,Fiber 把渲染变成可中断的链表遍历,Scheduler 用时间片和 Lane 模型协调优先级。
  • CSS 方案三选一:CSS Modules 做样式隔离、CSS-in-JS 做动态主题、Tailwind 做 utility-first 快速开发,按团队习惯和项目规模选型。
  • Router 把 URL 映射到组件树:BrowserRouter 用 history API(需服务器配置),HashRouter 用 hash(无配置要求);路由参数、守卫、懒加载、Data API 串起完整多页应用。
  • Zustand 以极简 API 实现 React 状态管理:create 创建 store、selector 订阅精确切片、immer 中间件实现可变风格更新、persist 做持久化、中间件洋葱模型组合功能。
  • 工程化骨架决定可维护性:.editorconfig/prettier 统一风格,axios 封装 + 环境变量 + 代理 + CSS Module 把请求、配置、样式隔离做好。
  • 学 React 别只背 API,重点理解"单向数据流"和"状态是唯一真相"——掌握了这条,hooks 间的取舍、组件通讯的模式、状态管理的选型自然就清楚了。
About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!