07

状态管理与数据流

1,638 行约 77 分钟54 个代码块21 张表格更新于 2026-11-03

07 · 状态管理与数据流(详解版)

一句话定位:状态是「应用此刻的全部事实」。数据流是「这些事实如何被读取、修改、同步」。中大型前端项目失控,八成不是组件写得多,而是状态没有归属。

读完本篇你应该能做到

  1. 拿到一个页面,能说清每份状态「从哪来、放哪、谁改、什么时候失效」;
  2. 能写出规范化的本地状态结构,并用缓存语义管理服务端数据;
  3. 能用状态机处理复杂业务流程,用乐观更新 + 幂等让弱网体验可用;
  4. 能解释多标签页、离线、多端协作场景下的数据一致性方案。

0. 领域导论

0.1 这个领域解决什么问题

先看四个真实故障,它们都「代码没报错」:

现象根因属于哪类问题
用户改完资料点保存,返回列表还是旧数据同一份数据存了两处,只更新了其中一处单一数据源缺失
搜索框快速输入,结果忽新忽旧两个请求返回顺序颠倒,旧结果覆盖新结果竞态条件
详情页刷新后筛选条件丢失,链接分享给同事打不开同一视图筛选状态存在组件内存里,没有进 URL状态归属错误
编辑弹窗关掉再打开,还是上次的输入组件没销毁,状态残留;或全局 store 没清理生命周期与重置策略缺失

状态管理的本质是三件事:

  1. 归属:这份数据应该由谁持有?(组件 / 页面 / 全局 / URL / 服务端)
  2. 流转:它如何被读取、修改、派生?
  3. 一致:多个副本、多个请求、多个标签页之间如何不打架?

只会用 useState 和某个状态库,不等于会做状态管理。真正的能力是把「业务事实」映射成「结构化的状态」。

0.2 一个类比

把前端应用想成一家餐厅:

  • 顾客的即时要求(这桌要加水)= 组件内部状态:只影响这一桌,别处不关心。
  • 桌上的桌号牌(几号桌、是否已下单)= URL 状态:谁都能看见,复制一张牌子就能复现同样的场景。
  • 后厨的订单墙 = 全局状态:多个岗位都要读,改动必须走统一流程(下单/改单/退单)。
  • 仓库里的食材 = 服务端状态:你手里只有一份「缓存」,可能过期,需要定期补货(重新验证),不能当成永久真相。
  • 收银小票 = 操作日志:出了问题能回溯「谁在什么时候改了什么」。

新手最常犯的错,是把「仓库里的食材」搬进「后厨订单墙」,然后每天手动对账——这就是把服务端数据硬塞进全局 store 的代价。

0.3 学习地图

07 状态管理与数据流
  ├─ 前置:06 框架与组件化(useState / 响应式 / 渲染机制)
  ├─ 紧接:08 路由与渲染架构(URL 状态、水合后的状态恢复)
  ├─ 交叉:14 BFF(接口聚合与字段契约)、18 可观测性(状态变更埋点)
  └─ 长期:17 架构(模块边界与数据层分层)

0.4 分级标尺

级别能力特征典型产出自检标准
L1 基础会用框架自带状态与简单全局状态页面级状态、表单能解释一次点击如何驱动视图更新
L2 进阶会用主流状态库,区分服务端/客户端状态业务模块数据层、异步流程能解释缓存、失效与竞态处理
L3 高级能做状态架构设计,处理并发与离线状态机流程、乐观更新方案能解释冲突如何检测与解决
L4 专家领域建模与跨系统数据架构数据一致性方案、多端同步引擎能对数据正确性负责

各级的分水岭

  • L1 → L2:从「状态放哪都行」到「按来源分层,各用各的工具」。
  • L2 → L3:从「处理请求成功/失败」到「处理乱序、离线、冲突」。
  • L3 → L4:从「一个应用内的状态」到「多系统、多端之间的数据契约」。

1. L1 基础:先把状态放对地方

1.1 第一原则:UI 是状态的函数

/**
 * 同一个 UI,两条不同的「状态 → 视图」映射。
 * 关键:视图里不出现「先改 DOM 再记状态」的双写。
 */
function Counter() {
  const [count, setCount] = useState(0);

  // 视图 = f(count),count 变了 React 自然会重画
  return <button onClick={() => setCount(count + 1)}>点了 {count} 次</button>;
}
// 反例:手动操作 DOM,状态和视图两份真相
let count = 0;
btn.addEventListener('click', () => {
  count += 1;
  btn.textContent = `点了 ${count} 次`; // 一旦还有别处改 count,这里就不同步
});

为什么重要:一旦你允许「DOM 里也存一份数据」,就必须手工维护两份数据的一致性。项目一大,必然出现「数据是对的,页面是旧的」。

判断标准:页面上任何一个可变的值,都应该能追溯到某个状态变量。追不到的,就是隐藏状态。

1.2 状态的五种来源:先分类,再选工具

来源典型内容该放哪生命周期常见错误
组件内部状态弹窗开关、输入框草稿、hoveruseState / ref组件挂载期间提升过度,导致父组件整体重渲染
跨组件共享状态当前登录用户、主题、购物车Context / 全局 store应用或会话高频变化的值放 Context,引发大面积重渲染
URL 状态路由、筛选、分页、Tab、搜索词Router / URLSearchParams浏览器历史存在内存里,导致刷新丢失、无法分享
服务端状态列表、详情、统计请求库缓存(TanStack Query / SWR)由缓存策略决定塞进全局 store 手动同步
持久化状态草稿、偏好、离线数据localStorage / IndexedDB跨会话存敏感信息、无版本迁移

决策顺序(照这个顺序问自己)

1. 只有这个组件用?           → 组件内部状态
2. 需要分享/收藏/刷新保留?    → URL 状态
3. 数据来自服务端?           → 请求库缓存
4. 多个不相干模块都要读?      → 全局 store(尽量小、按领域拆)
5. 需要跨会话保留?           → 持久化(注意版本与敏感信息)

一个筛选列表示例,五类状态同时出现:

/**
 * 列表页的状态分层示例。
 * 每一份状态都能回答「为什么放在这里」。
 */
function OrderListPage() {
  const [params, setParams] = useSearchParams();      // URL:可分享、可回退
  const page = Number(params.get('page') ?? 1);
  const keyword = params.get('keyword') ?? '';

  const { data, isFetching } = useQuery({             // 服务端:缓存 + 自动重验证
    queryKey: ['orders', { page, keyword }],
    queryFn: () => fetchOrders({ page, keyword }),
    staleTime: 30_000,
  });

  const [selected, setSelected] = useState<string[]>([]); // 组件:只影响本页
  const { user } = useAuth();                              // 全局:跨模块共享
  const [draft, setDraft] = useLocalStorage('order-draft', ''); // 持久化:跨会话

  return (
    <>
      <SearchBox value={keyword} onChange={(v) => setParams({ keyword: v, page: 1 })} />
      <OrderTable data={data?.list ?? []} loading={isFetching} selected={selected} onSelect={setSelected} />
    </>
  );
}

1.3 单向数据流:为什么它比双向绑定更可维护

事件 → 更新状态 → 重新计算视图
  ↑                        │
  └──── 用户看到新视图 ←────┘
// 单向:数据只能从父流向子,子通过回调「请求」修改
function Parent() {
  const [value, setValue] = useState('');
  return <Child value={value} onChange={setValue} />;
}

function Child({ value, onChange }) {
  return <input value={value} onChange={(e) => onChange(e.target.value)} />;
}

双向绑定(v-model)并没有错,它在表单场景非常高效。风险在于任意两个位置都能改同一份数据,一旦数据异常,你要在所有修改点里找原因。

判断标准:修改入口是否收敛?如果一份数据有 10 个地方能直接改,它一定会出现「不知道谁改的」问题。

1.4 useState 的三条硬规则

/**
 * 1) 更新是异步批处理的:连续 set 不会立刻反映到当前闭包里的变量。
 * 2) 依赖旧值时必须用函数式更新。
 * 3) 对象/数组更新必须换新引用。
 */
function Counter() {
  const [count, setCount] = useState(0);

  const handleTripleWrong = () => {
    setCount(count + 1);
    setCount(count + 1); // 三次都基于同一个旧 count,最终只 +1
    setCount(count + 1);
  };

  const handleTripleRight = () => {
    setCount((c) => c + 1);
    setCount((c) => c + 1); // 每次拿到上一次的结果,最终 +3
    setCount((c) => c + 1);
  };

  return (
    <>
      <button onClick={handleTripleWrong}>错误:+1</button>
      <button onClick={handleTripleRight}>正确:+3</button>
    </>
  );
}

原理提示:React 18 起,事件处理器、Promise、setTimeout 中的更新都会自动批处理。批处理意味着「多次 set 合并成一次渲染」,但不会帮你把 count + 1 重新求值——闭包里的 count 始终是渲染时的快照。

1.5 最小全局状态:Context + useReducer

不用任何库,也能搭出可用的全局状态。这是理解所有状态库的地基。

/**
 * 一个最小但完整的全局状态实现。
 * 结构:State + Action + reducer + Provider + 自定义 Hook。
 */
import { createContext, useContext, useReducer } from 'react';

/** @typedef {{ id: string, text: string, done: boolean }} Todo */
/** @typedef {{ todos: Todo[] }} State */
/** @typedef {{ type: 'add', text: string } | { type: 'toggle', id: string } | { type: 'remove', id: string }} Action */

const initialState = { todos: [] };

/**
 * 纯函数:给定旧状态和动作,返回新状态。不修改入参,不做副作用。
 * @param {State} state
 * @param {Action} action
 * @returns {State}
 */
function reducer(state, action) {
  switch (action.type) {
    case 'add':
      return { ...state, todos: [...state.todos, { id: crypto.randomUUID(), text: action.text, done: false }] };
    case 'toggle':
      return { ...state, todos: state.todos.map((t) => (t.id === action.id ? { ...t, done: !t.done } : t)) };
    case 'remove':
      return { ...state, todos: state.todos.filter((t) => t.id !== action.id) };
    default:
      return state;
  }
}

const TodoContext = createContext(null);

export function TodoProvider({ children }) {
  const [state, dispatch] = useReducer(reducer, initialState);
  return <TodoContext.Provider value={{ state, dispatch }}>{children}</TodoContext.Provider>;
}

/** 读取全局状态的唯一入口,便于以后替换实现。 */
export function useTodos() {
  const ctx = useContext(TodoContext);
  if (!ctx) throw new Error('useTodos 必须在 TodoProvider 内使用');
  return ctx;
}

它的问题(也就是状态库存在的原因):

  1. Context 值一变,所有消费者都重渲染,哪怕只关心其中一项;
  2. 没有选择器,无法做细粒度订阅;
  3. 没有中间件,异步流程要手写;
  4. DevTools、时间旅行、持久化都要自己实现。

1.6 不可变更新:为什么不能直接改

const next = { ...state, user: { ...state.user, name: '新名字' } };
// 反例:直接修改
state.user.name = '新名字';

直接修改的三个后果:

  1. React 靠引用比较判断「变没变」,同一个对象引用会被判定为没变,界面不更新;
  2. 时间旅行/撤销重做会失效(历史快照被一起改了);
  3. 无法做「变更前后对比」,调试与埋点失效。

Vue 3 的响应式允许直接改(Proxy 能捕捉),但仍建议不可变:可预测、易测试、易做快照。

数组的四个高频操作:

// 追加
const added = [...list, item];

// 删除
const removed = list.filter((x) => x.id !== id);

// 修改某一项
const updated = list.map((x) => (x.id === id ? { ...x, done: true } : x));

// 排序(注意 sort 会改原数组)
const sorted = [...list].sort((a, b) => a.order - b.order);

嵌套三层的更新会很难读,这就是 L2 要讲「规范化」的原因:

// 嵌套结构:改一个评论要穿过 4 层
const next = {
  ...state,
  posts: state.posts.map((p) =>
    p.id === postId
      ? {
          ...p,
          comments: p.comments.map((c) =>
            c.id === commentId ? { ...c, content } : c,
          ),
        }
      : p,
  ),
};

1.7 派生状态:能算的不要存

// ❌ 存两份:完成数量与实际列表可能不一致
const [todos, setTodos] = useState([]);
const [doneCount, setDoneCount] = useState(0);

// ✅ 每次渲染时算
const doneCount = todos.filter((t) => t.done).length;

性能顾虑:每次渲染都算,会不会慢?先测量。列表 1 万条、每次过滤 1ms,确实值得 useMemo;100 条以内,useMemo 的开销可能比计算本身还大。

const doneCount = useMemo(() => todos.filter((t) => t.done).length, [todos]);

但:如果 todos 每次渲染都是新数组(比如 todos.map(...) 内联在 JSX 里),useMemo 永远不会命中。记忆化的前提是依赖引用稳定。

1.8 表单状态:受控、非受控与提交态

/**
 * 受控表单:值是状态,改值走 onChange。
 * 优点:随时可校验、可联动、可重置;缺点:每次输入都触发渲染。
 */
function LoginForm({ onSubmit }) {
  const [form, setForm] = useState({ account: '', password: '' });
  const [errors, setErrors] = useState({});
  const [status, setStatus] = useState('idle'); // idle | submitting | success | error

  /** 字段级校验:返回错误对象,空对象表示通过。 */
  const validate = (values) => {
    const next = {};
    if (!values.account) next.account = '请输入账号';
    if (values.password.length < 6) next.password = '密码至少 6 位';
    return next;
  };

  const handleSubmit = async (e) => {
    e.preventDefault();
    const nextErrors = validate(form);
    setErrors(nextErrors);
    if (Object.keys(nextErrors).length > 0) return;

    setStatus('submitting');
    try {
      await onSubmit(form);
      setStatus('success');
    } catch {
      setStatus('error');
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={form.account}
        onChange={(e) => setForm({ ...form, account: e.target.value })}
        aria-invalid={Boolean(errors.account)}
      />
      {errors.account && <p role="alert">{errors.account}</p>}
      <button disabled={status === 'submitting'}>{status === 'submitting' ? '提交中…' : '登录'}</button>
    </form>
  );
}

非受控适合「不需要实时联动」的场景:

function SearchBar({ onSearch }) {
  const ref = useRef(null);
  return (
    <form onSubmit={(e) => { e.preventDefault(); onSearch(ref.current.value); }}>
      <input ref={ref} defaultValue="" />
    </form>
  );
}

选择标准:需要「输入时联动/校验/格式化」→ 受控;只需要「提交时取值」→ 非受控。

1.9 URL 也是状态(L1 版)

// 筛选条件写进 query,刷新不丢、可分享、可回退
const [params, setParams] = useSearchParams();
const status = params.get('status') ?? 'all';

const changeStatus = (next) => {
  setParams({ status: next }, { replace: false }); // false:留一条历史记录,支持返回
};

必须进 URL 的:筛选、分页、排序、Tab、搜索词、详情 ID。 不该进 URL 的:弹窗临时状态、鼠标 hover、未提交的草稿。

1.10 L1 小结与自检

主题关键结论
状态定义视图是状态的函数,不要手写 DOM 双份真相
来源分层组件 / URL / 服务端 / 全局 / 持久化,五类各用各的工具
单向数据流修改入口收敛,数据自上而下
更新规则批处理、函数式更新、换新引用
派生状态能算不存,记忆化要有稳定依赖
表单受控换联动,非受控换性能

自检题

  1. 一个列表页有「筛选、分页、选中项、当前用户、草稿」,分别该放哪?——筛选与分页放 URL(可分享、可回退);选中项放组件状态,需要分享时也进 URL;当前用户放全局或服务端缓存;草稿放本地存储加组件状态。
  2. setCount(count + 1) 连写三次为什么只加一?——三次读到的都是本次渲染的 count 快照,批处理后以最后一次写入为准;改用 setCount(c => c + 1)。
  3. 为什么 useMemo 有时「加了等于没加」?——依赖每次新建(对象/数组字面量)、计算本身很便宜,或父组件重渲染仍会执行组件体;只有依赖稳定且计算昂贵时缓存才有意义。

2. L2 进阶:分清「真相」与「缓存」

2.1 状态库全景与选型

方案心智模型适合代价
Redux Toolkit单一 store + action/reducer大团队、流程严格、需要时间旅行样板多(RTK 已大幅缓解)
Zustandhook 式小 store,订阅选择器中小项目、按领域拆 store生态约定弱,靠团队自律
Jotai / Recoil原子化,自底向上组合细粒度依赖、组件自治原子多了难梳理全局
PiniaVue 官方,state/getters/actionsVue 项目默认选择与 Vue 绑定
MobX响应式对象,自动追踪表单/图形密集、OOP 风格隐式依赖,调试门槛
TanStack Query / SWR服务端状态缓存任何有接口的项目不能替代客户端状态

一句话选型:

  • 服务端数据 → 请求库(必选,无论用什么全局状态)。
  • 客户端全局数据 → 团队熟悉度优先;新项目且无历史包袱,Zustand/Jotai 起步更快。
  • 复杂流程 → 状态机(见 L3),不要硬塞进 store。

2.2 Redux 数据流拆解(含 40 行实现)

UI 事件 → dispatch(action) → middleware 链 → reducer(prevState, action) → 新 state
                                                                          ↓
                                                              订阅者被通知 → 选择器算数据 → 重渲染
/**
 * 迷你 Redux:理解 store 只需要这 40 行。
 * 真实实现多了:订阅快照、批量通知、中间件组合、DevTools 协议。
 */
function createStore(reducer, preloadedState) {
  let state = preloadedState;
  const listeners = new Set();

  return {
    getState: () => state,
    dispatch(action) {
      state = reducer(state, action);
      listeners.forEach((l) => l());
      return action;
    },
    subscribe(listener) {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
  };
}

// 中间件本质:把 dispatch 包装成可组合的高阶函数
const logger = (store) => (next) => (action) => {
  console.log('dispatch', action);
  const result = next(action);
  console.log('next state', store.getState());
  return result;
};

中间件顺序很重要:applyMiddleware(a, b) 中 a 会先拿到 action,b 更靠近 reducer。日志、埋点放外层,异步处理放内层。

2.3 状态规范化:byId + allIds

// ❌ 嵌套结构:更新深、易重复
const state = {
  posts: [{ id: 1, author: { id: 9, name: 'A' }, comments: [{ id: 2, author: { id: 9, name: 'A' } }] }],
};

// ✅ 规范化:实体扁平、引用只存 id
const norm = {
  posts: { byId: { 1: { id: 1, authorId: 9, commentIds: [2] } }, allIds: [1] },
  comments: { byId: { 2: { id: 2, postId: 1, authorId: 9, content: 'hi' } }, allIds: [2] },
  users: { byId: { 9: { id: 9, name: 'A' } }, allIds: [9] },
};

收益:

  1. 更新一个实体就是 entities.users.byId[9] = {...},O(1);
  2. 同一用户只存一份,改名后所有引用自动更新;
  3. 列表只存 id 数组,排序/分页与实体解耦。

代价:读取要「组装」(selector 干这事),写入要「拆分」(归一化函数干这事)。

/** 组装:把 id 数组还原成对象数组。 */
const selectPostList = (state) =>
  state.posts.allIds.map((id) => ({
    ...state.posts.byId[id],
    author: state.users.byId[state.posts.byId[id].authorId],
  }));

现代替代:createEntityAdapter(RTK)或 normalizr。手写一遍是为了知道它在解决什么。

2.4 选择器与记忆化:引用稳定是前提

import { createSelector } from 'reselect';

/**
 * 只有输入引用变化时才重算,否则返回上一次的结果(引用不变)。
 * 注意:如果 input 每次都是新数组,缓存永远不命中。
 */
const selectVisibleTodos = createSelector(
  [(state) => state.todos, (state) => state.filter],
  (todos, filter) => todos.filter((t) => (filter === 'all' ? true : t.done === (filter === 'done'))),
);

Zustand 的坑更典型:

// ❌ 每次返回新对象 → Object.is 判定不等 → 每次都重渲染
const { name, age } = useStore((s) => ({ name: s.name, age: s.age }));

// ✅ 方案 1:分开选
const name = useStore((s) => s.name);
const age = useStore((s) => s.age);

// ✅ 方案 2:浅比较
import { useShallow } from 'zustand/react/shallow';
const { name, age } = useStore(useShallow((s) => ({ name: s.name, age: s.age })));

判断记忆化是否有效:在 selector 里打日志,看渲染次数。不要凭感觉。

2.5 服务端状态:它本质是缓存

服务端数据的四个特征,决定了它不该用普通状态管理:

  1. 你并不拥有它(别人也在改);
  2. 它会过期(需要重新验证);
  3. 它可能重复请求(需要去重与共享);
  4. 它需要分页/无限加载(缓存键要能组合)。
/**
 * TanStack Query 的核心模型。
 * queryKey 是缓存键:变了就是另一份数据。
 * staleTime:多久之内认为「新鲜」,不重新请求。
 * gcTime:没有组件使用后,多久从内存清除。
 */
const { data, error, isPending, isFetching, refetch } = useQuery({
  queryKey: ['order', orderId],
  queryFn: ({ signal }) => fetchOrder(orderId, { signal }),
  staleTime: 60_000,
  gcTime: 5 * 60_000,
  refetchOnWindowFocus: true,
  retry: 2,
});
时间轴(staleTime=60s, gcTime=5min)
0s     请求完成,写入缓存,状态 fresh
30s    组件重新挂载 → 命中缓存,不请求
61s    状态变 stale → 挂载/聚焦时后台重新请求(stale-while-revalidate)
5min   无组件使用 → 缓存被回收

staleTime 与 gcTime 的区别(高频面试题):

  • staleTime:数据「可信」的时长,决定要不要重新请求;
  • gcTime:数据「留着」的时长,决定内存里还存不存在。

什么时候手动失效:

// 写操作成功后,让相关缓存失效
await updateOrder(payload);
queryClient.invalidateQueries({ queryKey: ['orders'] });

// 精准更新某一个详情,避免多余请求
queryClient.setQueryData(['order', id], (old) => ({ ...old, ...payload }));

// 乐观更新(L3 详讲)
onMutate: async (next) => {
  await queryClient.cancelQueries({ queryKey: ['order', id] });
  const prev = queryClient.getQueryData(['order', id]);
  queryClient.setQueryData(['order', id], next);
  return { prev };
},
onError: (_err, _next, ctx) => queryClient.setQueryData(['order', id], ctx.prev),

2.6 请求竞态:为什么结果会「倒着来」

/**
 * 竞态:先发的请求后到,覆盖了新结果。
 * 三种解法,从弱到强。
 */
function Search({ keyword }) {
  const [result, setResult] = useState([]);

  useEffect(() => {
    const controller = new AbortController();

    fetch(`/api/search?q=${encodeURIComponent(keyword)}`, { signal: controller.signal })
      .then((r) => r.json())
      .then((data) => setResult(data))
      .catch((err) => {
        if (err.name === 'AbortError') return; // 主动取消,不算错误
        setResult([]);
      });

    // 下一次 effect 执行前,取消上一次请求
    return () => controller.abort();
  }, [keyword]);

  return <List data={result} />;
}

三种解法对比:

方案做法适用
取消AbortController 中断旧请求首选,省流量
忽略用请求序号/时间戳判断是否最新请求不可取消(第三方 SDK)
串行化队列,等上一次完成再发强顺序要求(如保存)
// 忽略法:只接受最后一次请求的结果
let latestId = 0;
async function load() {
  const id = ++latestId;
  const data = await fetchData();
  if (id !== latestId) return; // 过期结果直接丢弃
  setData(data);
}

2.7 异步流程方案的演进

方案写法优点缺点
thunk返回函数的 action简单、够用复杂流程嵌套深、难取消
sagagenerator + effect可测试、可取消、并发控制强概念多、样板重
observableRxJS 管道事件流组合强学习曲线陡
请求库 mutationuseMutation与缓存天然集成只覆盖数据请求流程
状态机XState流程可视化、状态穷举需要建模能力

经验法则:请求类流程用请求库;跨步骤业务流程用状态机;只有「大量并发/取消/重试策略」才值得上 saga。

2.8 持久化:三个必须解决的问题

/**
 * 持久化的最小正确实现。
 * 1) 版本迁移:结构变了要能升级旧数据
 * 2) 容量与失败:写入可能抛 QuotaExceededError
 * 3) 敏感数据:token/身份证等不落明文本地
 */
const KEY = 'app-state';
const VERSION = 3;

const migrations = {
  1: (s) => ({ ...s, theme: 'light' }),
  2: (s) => ({ ...s, filters: {} }),
  3: (s) => ({ ...s, schema: 3 }),
};

function loadState() {
  try {
    const raw = localStorage.getItem(KEY);
    if (!raw) return null;
    let data = JSON.parse(raw);
    for (let v = data.version + 1; v <= VERSION; v++) data = migrations[v](data);
    return data;
  } catch {
    return null; // 解析失败就当没有,别让坏数据卡死应用
  }
}

function saveState(state) {
  try {
    localStorage.setItem(KEY, JSON.stringify({ ...state, version: VERSION }));
  } catch (err) {
    if (err.name === 'QuotaExceededError') {
      // 降级:清掉非关键字段,或改存 IndexedDB
      console.warn('本地存储已满,已跳过持久化');
    }
  }
}

持久化选型:

存储容量同步性适合
localStorage约 5MB同步(会阻塞)少量偏好、token 的替代方案(更推荐 Cookie)
sessionStorage约 5MB同步单标签页会话
IndexedDB按配额,通常数百 MB异步结构化数据、离线缓存、草稿
Cache Storage按配额异步请求/响应缓存(Service Worker)

2.9 表单进阶:字段数组与多步表单

/**
 * 动态字段数组:增删行时保持稳定 key,避免输入框错位。
 * 多步表单:把「当前步骤」放 URL,把「已填数据」放上层状态。
 */
function ContactsForm() {
  const [contacts, setContacts] = useState([{ key: crypto.randomUUID(), name: '', phone: '' }]);

  const update = (key, patch) =>
    setContacts((list) => list.map((c) => (c.key === key ? { ...c, ...patch } : c)));

  const remove = (key) => setContacts((list) => list.filter((c) => c.key !== key));

  return (
    <>
      {contacts.map((c) => (
        <div key={c.key}>
          <input value={c.name} onChange={(e) => update(c.key, { name: e.target.value })} />
          <input value={c.phone} onChange={(e) => update(c.key, { phone: e.target.value })} />
          <button onClick={() => remove(c.key)}>删除</button>
        </div>
      ))}
      <button onClick={() => setContacts((l) => [...l, { key: crypto.randomUUID(), name: '', phone: '' }])}>
        新增联系人
      </button>
    </>
  );
}

多步表单的三个关键决策:

  1. 已填数据放哪:父级状态(简单)vs 全局(跨路由)vs 服务端草稿(可跨设备);
  2. 步骤放哪:URL(可回退、可刷新);
  3. 校验时机:每步离开时校验,还是最终提交时统一校验。推荐前者 + 最终兜底。

2.10 URL 状态的进阶用法

/**
 * 把 URL 当表单:解析、序列化、默认值、类型转换集中在一处。
 */
type ListQuery = { page: number; keyword: string; tags: string[]; sort: 'asc' | 'desc' };

function parseQuery(search: string): ListQuery {
  const p = new URLSearchParams(search);
  return {
    page: Math.max(1, Number(p.get('page')) || 1),
    keyword: p.get('keyword') ?? '',
    tags: p.getAll('tag'),
    sort: p.get('sort') === 'asc' ? 'asc' : 'desc',
  };
}

function toQuery(q: ListQuery): string {
  const p = new URLSearchParams();
  if (q.page !== 1) p.set('page', String(q.page));
  if (q.keyword) p.set('keyword', q.keyword);
  q.tags.forEach((t) => p.append('tag', t));
  if (q.sort !== 'desc') p.set('sort', q.sort);
  return p.toString();
}

注意:默认值不要写进 URL(保持链接干净),但解析时必须能补默认值。

2.11 L2 小结与自检

主题关键结论
选型服务端数据用请求库;客户端全局数据按团队熟悉度选
规范化实体扁平、列表存 id、读写分离
选择器引用稳定才谈记忆化
缓存语义staleTime 管新鲜度,gcTime 管回收
竞态默认用 AbortController
持久化版本迁移 + 容量兜底 + 敏感数据不落盘

自检题

  1. 为什么说「服务端状态不是状态,是缓存」?这句话改变了哪些设计?——它需要过期、失效、去重、重试、分页与乐观更新;设计上按「缓存键 + 新鲜度 + 失效事件」组织,而不是当成唯一真相在本地改。
  2. 两个请求乱序返回,三种解法分别适合什么场景?——递增请求 ID 只认最新(适合搜索/筛选);AbortController 取消旧请求(省资源,适合切换频繁);按 key 分别存结果(适合多个独立资源并行)。
  3. 一个 store 里既存用户信息又存订单列表,会有什么问题?——订阅粒度变粗导致无关重渲染,生命周期不同(用户长期、订单短期),权限与清理逻辑耦合;应按领域拆分或分层。

3. L3 高级:并发、离线与冲突

3.1 布尔标志为什么会爆炸

// 用一个页面描述「加载中/成功/失败/空/重试中」
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [data, setData] = useState(null);
const [retrying, setRetrying] = useState(false);

// 组合态有 2^4 = 16 种,其中大部分是非法的
// 例如:loading=true && error!=null && data!=null 同时成立

状态机的做法:显式列出「合法状态」和「合法迁移」,非法组合根本不存在。

import { createMachine } from 'xstate';

/**
 * 下单流程状态机。
 * 每个状态只允许特定事件,非法事件被忽略(或走 error 分支)。
 */
const orderMachine = createMachine({
  id: 'order',
  initial: 'editing',
  context: { items: [], orderId: null, error: null },
  states: {
    editing: {
      on: {
        SUBMIT: { target: 'submitting', guard: 'hasItems' },
      },
    },
    submitting: {
      invoke: {
        src: 'createOrder',
        onDone: { target: 'paying', actions: 'saveOrderId' },
        onError: { target: 'failed', actions: 'saveError' },
      },
      on: { CANCEL: 'editing' },
    },
    paying: {
      on: { PAID: 'done', FAIL: 'failed' },
    },
    failed: {
      on: { RETRY: 'submitting', BACK: 'editing' },
    },
    done: { type: 'final' },
  },
});

为什么状态机更容易维护:

维度布尔标志状态机
合法状态数指数级,含大量非法组合显式枚举
行为可预测性「现在的 loading 是什么含义」说不清状态名即语义
测试要覆盖组合覆盖迁移即可
可视化无可导出状态图
新人理解成本高(要读完全部赋值)低(看图)

什么时候不该用:状态少于 4 个、没有复杂迁移的表单,用 useReducer 更轻。

3.2 乐观更新:把「等待」藏起来

/**
 * 乐观更新四件套:
 * 1) 取消在途查询,避免旧结果回写
 * 2) 立即写缓存(用户马上看到结果)
 * 3) 失败回滚(恢复到快照)
 * 4) 无论成败都重新验证(保证最终一致)
 */
function useToggleTodo() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({ id, done }) => fetch(`/api/todos/${id}`, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json', 'Idempotency-Key': `${id}-${done}` },
      body: JSON.stringify({ done }),
    }).then((r) => r.json()),

    onMutate: async ({ id, done }) => {
      await queryClient.cancelQueries({ queryKey: ['todos'] });
      const prev = queryClient.getQueryData(['todos']);
      queryClient.setQueryData(['todos'], (old) =>
        old?.map((t) => (t.id === id ? { ...t, done } : t)),
      );
      return { prev };
    },

    onError: (_err, _vars, ctx) => {
      queryClient.setQueryData(['todos'], ctx.prev); // 回滚
    },

    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['todos'] }); // 最终一致
    },
  });
}

幂等键(Idempotency-Key)为什么必要:用户点了一次,弱网超时,前端重试——服务端收到两次请求。若无幂等键,会创建两条数据。这是乐观更新能从「demo」走向「生产」的关键。

3.3 离线优先:本地是主,服务端是同步目标

写入路径:用户操作 → 写本地库(立即成功)→ 入队 op-log → 有网时同步 → 服务端确认 → 清理队列
读取路径:读本地库(立即返回)→ 后台拉增量 → 合并 → 触发视图更新
/**
 * 操作日志(op-log)的极简结构。
 * 每条操作必须:可重放、可幂等、带时间戳与设备 id。
 */
type Op = {
  id: string;            // 幂等键
  entity: 'todo' | 'note';
  entityId: string;
  type: 'create' | 'update' | 'delete';
  payload: unknown;
  baseVersion: number;   // 基于哪个版本修改,用于检测冲突
  createdAt: number;
  deviceId: string;
};

/** 顺序同步:失败保留队列,下次继续;成功则按 id 去重后落库。 */
async function flush(queue: Op[]) {
  for (const op of queue) {
    try {
      const res = await fetch('/api/sync', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'Idempotency-Key': op.id },
        body: JSON.stringify(op),
      });
      if (res.status === 409) {
        await resolveConflict(op, await res.json()); // 冲突交给策略处理
      }
    } catch {
      return; // 网络失败:保留剩余队列,等下次
    }
  }
}

冲突策略对比:

策略做法适合风险
LWW(最后写入获胜)比较时间戳/版本号,新的覆盖旧的个人笔记、偏好会静默丢数据
字段级合并只合并改动的字段表单、资料页需要字段级版本
OT / CRDT数学上保证收敛协同编辑(文档、白板)实现复杂、存储膨胀
人工介入弹窗让用户选财务、订单等强一致场景体验中断

3.4 CRDT 的直觉理解(不需要会实现)

两个副本各自修改,最终无论合并顺序如何,结果一致,这就是收敛性。

副本 A:["买牛奶"]
副本 B:["买牛奶"]

A 在末尾加 "买面包" → ["买牛奶", "买面包"]
B 在开头加 "买鸡蛋" → ["买鸡蛋", "买牛奶"]

合并(按位置标识而非数组下标)→ ["买鸡蛋", "买牛奶", "买面包"]

关键点:普通数组用下标描述位置,插入后下标全变;CRDT 用唯一 ID 描述位置关系,所以能正确合并。这就是为什么 Yjs/Automerge 的数据结构看起来「很怪」。

3.5 跨标签页同步

/**
 * BroadcastChannel:同源标签页之间通信。
 * 用途:登录态同步、退出登录互踢、数据变更通知。
 */
const channel = new BroadcastChannel('app-sync');

// 发送
channel.postMessage({ type: 'logout', at: Date.now() });

// 接收
channel.onmessage = (e) => {
  if (e.data.type === 'logout') {
    clearSession();
    location.replace('/login');
  }
};

storage 事件的差异:

机制触发范围能否传对象备注
storage 事件同源其他标签页否(只能通过 localStorage 值)本标签页不触发
BroadcastChannel同源所有标签页是(结构化克隆)更推荐
SharedWorker同源所有标签页是(消息端口)适合共享连接/状态
Service Worker同源所有页面是适合后台同步与推送

登录互踢的实现要点:用「最后活跃时间 + 会话 id」判断哪边是有效会话,避免多个标签页互相踢。

3.6 大数据量状态的组织

问题症状方案
一次加载 10 万条首屏卡死、内存暴涨分页/游标 + 虚拟滚动
全量 diff每次更新遍历全部按 id 索引 + 只更新变动项
store 过大任何改动都触发全局订阅按路由/领域拆 store,懒加载
序列化慢JSON.stringify 卡顿只持久化必要字段;用结构化存储
/**
 * 按路由懒加载 store:进入模块才创建,离开可保留或销毁。
 */
let orderStore = null;
export function getOrderStore() {
  if (!orderStore) orderStore = createStore(orderReducer);
  return orderStore;
}

3.7 订阅性能与「撕裂」(tearing)

/**
 * useSyncExternalStore:把外部 store 接入 React 的官方方式。
 * 它保证同一次渲染中,所有组件读到同一个快照。
 */
import { useSyncExternalStore } from 'react';

function useStoreSnapshot(store) {
  return useSyncExternalStore(
    store.subscribe,
    store.getSnapshot,
    store.getServerSnapshot, // SSR 时必须提供,否则水合不一致
  );
}

撕裂是什么:并发渲染把一次渲染拆成多段,若外部 store 在中途被修改,不同组件可能读到不同版本,界面出现「一半新一半旧」。

什么时候会遇到:自研 store + startTransition/并发特性 + 组件分别订阅。

解法:用 useSyncExternalStore(它会退化为同步渲染以保证一致),或让所有读取都经过同一快照。

3.8 并发更新下的状态设计

/**
 * 输入框必须即时响应(紧急更新),列表过滤可以被打断(过渡更新)。
 * 注意:不要把「输入框的值」也放进 transition,否则光标会卡。
 */
function SearchPage() {
  const [keyword, setKeyword] = useState('');
  const [list, setList] = useState([]);
  const [isPending, startTransition] = useTransition();

  const handleChange = (v) => {
    setKeyword(v); // 紧急
    startTransition(() => {
      setList(filterHugeList(v)); // 非紧急,可被打断
    });
  };

  return (
    <>
      <input value={keyword} onChange={(e) => handleChange(e.target.value)} />
      <List data={list} dimmed={isPending} />
    </>
  );
}

和防抖的区别:防抖是「等你停手再算」,丢掉中间态;useTransition 是「立刻开始算,但允许被新输入打断重来」,中间态也能显示旧结果。

3.9 可测试性与可观测

/**
 * 状态更新是纯函数 → 测试不需要渲染、不需要 mock 时间。
 * 这是 reducer / 状态机最大的工程价值。
 */
test('toggle 只影响目标项', () => {
  const before = { todos: [{ id: '1', done: false }, { id: '2', done: false }] };
  const after = reducer(before, { type: 'toggle', id: '1' });
  expect(after.todos[0].done).toBe(true);
  expect(after.todos[1].done).toBe(false);
  expect(before.todos[0].done).toBe(false); // 不可变
});

线上可观测:把关键状态迁移打点(from → event → to),出问题时能看到「用户卡在哪个状态」。这比只记录 JS 错误有用得多。

3.10 事件总线的反模式

// ❌ 隐式依赖:谁发的?谁收的?顺序如何?无法静态分析
bus.emit('user-updated', user);
bus.on('user-updated', refreshList);
bus.on('user-updated', refreshHeader);
bus.on('user-updated', clearCache);

问题:订阅关系散落各处,删除一个模块可能留下悬空监听;事件名冲突;调试困难。

替代:显式调用(可追踪)> 状态订阅(可推导)> 事件总线(最后手段)。若必须用,要求:统一事件名常量、统一注册/销毁、开发环境打印订阅表。

3.11 L3 小结与自检

主题关键结论
状态机用「合法状态 + 迁移」替代布尔组合
乐观更新立即写、失败回滚、最终验证,配幂等键
离线本地为主、op-log 队列、冲突有策略
跨标签页BroadcastChannel 优先
订阅useSyncExternalStore 防撕裂
并发紧急更新与过渡更新分开

自检题

  1. 一个「可取消、可重试、可离线」的下单流程,状态怎么设计?——idle → validating → submitting → pending(可取消) → success / failed(可重试),离线另加 queued;用判别联合存 orderId、attempt 与幂等键,恢复后按顺序重放。
  2. 乐观更新后服务端返回冲突(409),前端应该有哪几种处理路径?——拉取最新并提示人工合并;幂等前提下自动重试;回滚乐观更新但保留用户输入;转人工或客服;按字段重要性与冲突概率选择。
  3. 为什么自研 store 在并发渲染下容易撕裂?——外部 store 可变,渲染可被打断,不同组件可能在不同时间读到不同版本;用 useSyncExternalStore 的 snapshot 加提交前校验,或订阅不可变快照。

4. L4 专家:领域建模与跨系统一致性

4.1 从「页面状态」到「领域模型」

前端常见的错误建模:按页面建 store。

❌ pages/order/store.ts   (订单页一个 store)
   pages/user/store.ts    (用户页一个 store)
   → 同一份「订单」在两个 store 里各存一份

正确做法:按领域实体建模型,页面只是视图组合。

✅ domains/order/{model,api,selectors}.ts
   domains/user/{model,api,selectors}.ts
   页面只订阅需要的切片

聚合(Aggregate)的直觉:一组必须一起变化的数据。比如「订单 + 订单项」应该一起更新;「用户头像」和「订单列表」没有强一致关系,不该放一起。

限界上下文(Bounded Context):同一个词在不同上下文里含义不同。「商品」在「下单上下文」里有价格快照,在「商品管理上下文」里有库存与上下架状态。前端不要强行用一个 Product 类型覆盖两者。

4.2 数据一致性架构

┌──────────────┐   写    ┌──────────────┐
│  视图 / 交互  │ ──────▶ │  命令层       │──▶ API / BFF
└──────────────┘         └──────────────┘
        ▲                        │ 成功/失败/冲突
        │ 读                     ▼
┌──────────────┐         ┌──────────────┐
│  读模型缓存   │ ◀────── │  同步 / 失效  │
│ 内存→IDB→CDN │         └──────────────┘
└──────────────┘

一致性等级与业务容忍度:

等级表现适合实现成本
强一致写完立刻读到新值支付、库存扣减高(要等服务端确认)
读己之写自己看到自己的修改个人资料、评论中(本地覆盖 + 版本)
最终一致稍后一致,可能短暂旧值列表、统计、点赞数低(缓存 + 失效)

关键判断:不是所有数据都值得强一致。把「点赞数」做成强一致,是在浪费预算。

4.3 Local-first 同步引擎设计

/**
 * 增量同步的核心:游标(cursor)+ 变更集(changeset)。
 * 客户端只需拉「上次游标之后的变化」,避免全量传输。
 */
type PullResp = {
  changes: Array<{ entity: string; id: string; version: number; deleted?: boolean; data?: unknown }>;
  cursor: string; // 下次从这里继续
};

async function pull(cursor: string) {
  const res = await fetch(`/api/sync/pull?cursor=${encodeURIComponent(cursor)}`);
  const { changes, cursor: next } = await res.json();
  await applyChangesLocally(changes); // 幂等:同 version 重复应用无副作用
  await saveCursor(next);
}

版本向量(Version Vector)的直觉:每个设备记录「我见过各设备的第几版」,合并时能判断「谁更新」以及「是否存在并发修改」。

设备A: {A:3, B:2}
设备B: {A:2, B:4}
→ B 没见过 A 的第 3 版,A 没见过 B 的第 3、4 版 → 存在并发修改,需要合并策略

4.4 跨系统数据契约

方案前端拿到什么优点代价
REST固定结构简单、缓存友好容易过取/欠取
GraphQL按需字段精确、聚合强复杂度转移到客户端缓存与查询治理
BFF(聚合层)为页面定制的结构前端简单、可裁剪多一层维护、需契约管理
tRPC / 类型共享端到端类型类型安全、改动可追踪强绑定 TS 全栈

契约变更的兼容策略:

  1. 只增不减:字段先标记 @deprecated,观察调用量归零再删;
  2. 版本共存:/v1 与 /v2 并行一段时间;
  3. 契约测试:用 schema(OpenAPI/GraphQL)在 CI 中校验破坏性变更;
  4. 前端兜底:字段缺失时给默认值与降级 UI,避免白屏。

4.5 复杂业务流的可维护性

命令模式做撤销重做:

/**
 * 每个操作都是一个命令:do / undo 成对存在。
 * 撤销栈只存命令,不存完整状态快照(省内存)。
 */
interface Command {
  do(): void;
  undo(): void;
}

class History {
  private undoStack: Command[] = [];
  private redoStack: Command[] = [];

  execute(cmd: Command) {
    cmd.do();
    this.undoStack.push(cmd);
    this.redoStack = []; // 新操作让重做失效
  }

  undo() {
    const cmd = this.undoStack.pop();
    if (!cmd) return;
    cmd.undo();
    this.redoStack.push(cmd);
  }

  redo() {
    const cmd = this.redoStack.pop();
    if (!cmd) return;
    cmd.do();
    this.undoStack.push(cmd);
  }
}

审计日志:记录「谁、何时、从什么状态到什么状态、因为什么事件」,前端可在关键流程打点,出问题时还原用户路径。

4.6 治理与度量

指标含义阈值参考
store 数量全局状态模块数超过 15 个要审视是否按页面拆分
跨模块依赖A 域引用 B 域内部状态出现即告警,应通过接口/事件解耦
单 store 字段数一个 store 的状态字段超过 30 个考虑拆分
状态变更频率每秒 dispatch 次数高频(>60/s)要考虑批量与节流
缓存命中率请求被缓存命中比例低于 50% 说明 staleTime 配置不合理

重构路径(绞杀者模式):

  1. 新建旁路数据层,与旧 store 并行读取;
  2. 页面上按模块切换读取源(灰度);
  3. 新旧数据双向同步,保证一致;
  4. 旧 store 调用量归零后删除。

不要停业务做「大重构」——状态层牵一发动全身,必须可回退。

4.7 L4 自检

  1. 公司有 5 个前端应用共享「用户/权限」数据,如何设计同步与失效?——单一权威服务加短 TTL 缓存,用版本号或事件推送失效,跨应用通过 BFF 或共享 SDK 读取;禁止各应用长期缓存权限。
  2. 一个「多端 + 离线 + 协作编辑」的产品,冲突策略怎么选?为什么?——文本协同用 CRDT(离线合并好、实现成本高),表单配置用 LWW 加版本号(简单、需人工兜底),强一致业务用服务端锁或串行化;按「冲突概率 × 合并难度」选。
  3. 前端该不该做业务规则校验?边界在哪?——前端做格式校验与即时反馈,服务端做权威校验与业务规则;金额、库存、权限必须在服务端,前端只做预校验与错误展示。

5. 综合实战:离线可用的协作任务面板

需求:任务看板,支持拖拽改状态、离线操作、多标签页同步、多人协作、断网恢复。

步骤 1 · 状态分层

状态归属理由
任务列表IndexedDB + 内存读模型离线可读、可持久
当前看板/筛选URL可分享、可回退
拖拽中的位置组件状态高频、只影响当前交互
在线状态全局(轻量)多组件需要
操作队列IndexedDB断网可恢复

步骤 2 · 模型

type Task = {
  id: string;
  title: string;
  status: 'todo' | 'doing' | 'done';
  order: number;
  version: number;   // 服务端版本,用于冲突检测
  updatedAt: number;
};

步骤 3 · 关键流程

拖拽 → 本地立即改 order/status(乐观)
     → 生成 op(含 baseVersion)
     → 入队 IndexedDB
     → 在线则 flush
        ├─ 200:更新本地 version
        ├─ 409:拉最新 → 字段级合并 → 重放
        └─ 网络错误:保留队列,监听 online 事件重试

步骤 4 · 冲突处理

  • 字段级:只合并 status/order,标题冲突走 LWW 并提示;
  • 重排冲突:以服务端 order 为基准重新计算,避免顺序错乱;
  • 提示策略:静默合并 > 顶部提示 > 阻断弹窗(仅在删除等危险操作时)。

步骤 5 · 验收清单

  • 断网下可创建/拖拽/编辑,刷新不丢
  • 恢复网络后自动同步,无重复任务
  • 两个标签页操作互相同步
  • 两端同时改同一任务,不出现「静默丢失」
  • 同步失败有可见状态与手动重试
  • 1000 个任务拖拽不卡顿(虚拟化 + 局部更新)

6. 常见误区对照表

误区事实
「所有状态都放全局」全局状态越多,耦合与重渲染越难控制;能局部就局部
「服务端数据也用 Redux 存」服务端数据需要缓存语义(失效、重验证、去重),专用库更合适
「用布尔标志表示流程」组合态指数膨胀,状态机可枚举、可验证
「乐观更新就是先改 UI」必须配套回滚、冲突处理与幂等键
「持久化就是 localStorage.setItem」还要版本迁移、容量兜底、敏感数据与多标签页同步
「useMemo 一定提升性能」依赖不稳定时等于白算,甚至更慢
「Context 就是状态管理方案」它解决「传递」,不解决「订阅粒度、异步、持久化」
「状态库越新越好」选型看团队与业务流程复杂度,不看热度
「URL 只放路由」筛选/分页/搜索词进 URL,才能分享与回退
「离线就是加个 Service Worker」离线是数据层问题:本地库、队列、冲突、同步

7. 面试高频题(附答题思路)

Q1:staleTime 和 gcTime 有什么区别? 先给定义 → 再给时间轴 → 再说配置错误的表现(设置过大看到旧数据,过小请求风暴)→ 最后说手动失效的场景。

Q2:为什么 Redux 要规范化数据? 嵌套更新成本 → 数据重复导致不一致 → 扁平结构 O(1) 更新与单份真相 → 代价是读取要组装。

Q3:两个请求乱序返回怎么处理? AbortController 取消(首选)→ 序号丢弃 → 队列串行;再说请求库已内置处理。

Q4:乐观更新失败怎么回滚? onMutate 存快照 → onError 恢复 → onSettled 重新验证 → 幂等键防重复。

Q5:多标签页如何同步登录态? BroadcastChannel / storage 事件 → 服务端会话为准 → 互踢要有「最后活跃」判断避免抖动。

Q6:为什么用状态机而不是 useReducer? 状态少用 reducer;状态多且迁移复杂用状态机,因为非法组合不存在、可可视化、可生成测试。

Q7:Context 性能问题怎么解决? 拆分 Context(读写分离)→ 选择器订阅(use-context-selector)→ 换 Zustand/Jotai → 只放低频值。

Q8:前端缓存分层怎么设计? 内存(当前会话)→ IndexedDB(离线/大对象)→ HTTP 缓存(CDN/浏览器)→ BFF(聚合层),再说每层的失效策略与一致性要求。


8. 面试深挖

8.1 面试官在考察什么

维度看什么加分回答减分回答
状态分类能否区分「真相」与「缓存」按来源分五类再选工具「全部放 Redux」
更新语义不可变与结构共享的代价说清 Immer 的代理成本「深拷贝一份就行」
并发正确性竞态、重复提交、乱序响应用请求 ID 或 AbortController「加个 loading 就行」
离线与冲突是否理解一致性模型区分 LWW 与 CRDT 的适用面「本地存一份再同步」
可维护性状态机与布尔标志的取舍用状态图消除非法组合「多写几个 flag」

8.2 追问链

链 1 · 单向数据流 → 不可变更新 → 结构共享

  • 面试官:为什么状态要用不可变更新?
    • 要点:让「变化」可比较(引用变化即变更),使浅比较、时间旅行、并发快照成为可能;直接改对象会让依赖引用的组件收不到通知。
  • 追问:不可变更新的性能代价怎么控制?
    • 要点:结构共享只复制路径上的节点,未改动的子树复用引用;列表更新用 map + 稳定 ID,避免全量重建;层级过深时按领域拆分 store,减少单次复制范围。
  • 再追问:Immer 的 produce 为什么能「看起来直接改」?
    • 要点:用 Proxy 记录写入路径,结束时按 path 生成新对象,未触碰的部分保留原引用。代价是每次 produce 都有代理开销,超大对象或高频更新时不如手写展开;另外 froze 后的对象不能再被第三方库修改。

链 2 · 服务端状态 → 缓存键 → 竞态

  • 面试官:为什么服务端数据不该塞进全局 store?
    • 要点:它本质是「带过期时间的缓存」,需要缓存键、失效、重试、去重、分页、乐观更新等能力;用普通 store 手写会重复实现这些语义。
  • 追问:staleTime 与 gcTime 分别控制什么?
    • 要点:staleTime 决定数据多久被视为新鲜(期间不发请求);gcTime 决定无订阅后缓存保留多久;两者职责不同,把 staleTime 设成 Infinity 就等于放弃自动刷新。
  • 再追问:快速切换筛选条件,为什么最后显示的是旧数据?
    • 要点:响应乱序。解决方式:请求携带递增 ID,只接受最新 ID 的结果;或用 AbortController 取消上一个请求;缓存键必须包含全部影响结果的参数(筛选、分页、排序、租户)。

链 3 · 状态机 → 非法状态不可达 → 落地方式

  • 面试官:为什么布尔标志会爆炸?
    • 要点:n 个布尔有 2^n 种组合,其中大部分非法(如「加载中且已成功」);组合爆炸让 UI 分支不可穷举,bug 都藏在非法组合里。
  • 追问:状态机怎么保证非法状态不可达?
    • 要点:显式枚举状态,转移只能通过事件触发,未定义转移直接忽略或报错;context 存数据、state 存阶段;XState 还能生成状态图供产品确认。
  • 再追问:所有页面都该上状态机吗?
    • 要点:不必。状态少(≤3 个)且线性时,useReducer + 判别联合类型就够;状态机适合多步骤、可中断、有并行区域的流程(支付、向导、播放器)。引入前要评估学习成本与调试工具链。

8.3 手写题

题 1 · 带选择器与批量通知的 store

/**
 * 极简 store:支持选择器订阅、浅比较与批量通知。
 * @param {(state: any, action: any) => any} reducer
 * @param {any} initialState
 */
function createStore(reducer, initialState) {
  let state = initialState;
  /** @type {Set<{selector: Function, last: any, listener: Function}>} */
  const subscribers = new Set();
  let notifying = false;

  /** 浅比较,避免选择器返回新对象导致的无效通知 */
  const shallowEqual = (a, b) => {
    if (Object.is(a, b)) return true;
    if (typeof a !== 'object' || typeof b !== 'object' || !a || !b) return false;
    const ka = Object.keys(a);
    const kb = Object.keys(b);
    return ka.length === kb.length && ka.every((k) => Object.is(a[k], b[k]));
  };

  const getState = () => state;

  const dispatch = (action) => {
    const next = reducer(state, action);
    if (Object.is(next, state)) return;
    state = next;
    if (notifying) return;
    notifying = true;
    try {
      subscribers.forEach((sub) => {
        const value = sub.selector(state);
        if (!shallowEqual(value, sub.last)) {
          sub.last = value;
          sub.listener(value);
        }
      });
    } finally {
      notifying = false;
    }
  };

  /**
   * 订阅选择器结果。
   * @param {(s: any) => any} selector
   * @param {(value: any) => void} listener
   * @returns {() => void} 取消订阅
   */
  const subscribe = (selector, listener) => {
    const sub = { selector, listener, last: selector(state) };
    subscribers.add(sub);
    return () => subscribers.delete(sub);
  };

  return { getState, dispatch, subscribe };
}

追问:

  • 为什么通知过程中要加 notifying 标志?→ 监听器里再次 dispatch 会递归通知,导致同一次更新触发多轮渲染。
  • 选择器返回数组怎么办?→ 浅比较对数组同样按索引比较,长度一致且元素相同即可;返回新对象字面量时仍会失效。
  • 多组件订阅同一片状态怎么去重?→ 可在内部按选择器结果缓存,或用 useSyncExternalStore 的 getSnapshot 保证引用稳定。

题 2 · useReducer 版撤销/重做(含输入合并)

/**
 * 撤销/重做历史:同类连续操作在时间窗内合并,避免每敲一个字都入栈。
 * @param {number} [windowMs=500] 合并时间窗
 */
function createHistory(windowMs = 500) {
  /** @type {Array<{state: any, tag: string, at: number}>} */
  let past = [];
  let present = null;
  let future = [];

  /**
   * 提交一次变更。
   * @param {any} next 新状态
   * @param {string} tag 操作类型,用于合并判断
   */
  const commit = (next, tag) => {
    const now = Date.now();
    const last = past[past.length - 1];
    const mergeable = last && last.tag === tag && now - last.at < windowMs;

    if (mergeable) {
      last.at = now; // 保留最早的状态,只更新时间窗
    } else if (present !== null) {
      past.push({ state: present, tag, at: now });
    }
    present = next;
    future = [];
  };

  const undo = () => {
    if (past.length === 0) return present;
    const prev = past.pop();
    future.push({ state: present, tag: prev.tag, at: Date.now() });
    present = prev.state;
    return present;
  };

  const redo = () => {
    if (future.length === 0) return present;
    const next = future.pop();
    past.push({ state: present, tag: next.tag, at: Date.now() });
    present = next.state;
    return present;
  };

  return {
    commit,
    undo,
    redo,
    get state() {
      return present;
    },
    get canUndo() {
      return past.length > 0;
    },
    get canRedo() {
      return future.length > 0;
    },
  };
}

追问:

  • 为什么合并时要保留「最早」的状态?→ 撤销应回到连续输入开始前,而不是上一个字符。
  • 怎么做「非合并型」操作?→ 给不同 tag(如 typing 与 submit),标签变化即断开合并。
  • 历史无限增长怎么办?→ 设上限(如 50 步)并丢弃最旧项;同时注意历史里的对象会阻止 GC,大对象应存差异而非全量。

8.4 场景设计题

题:一个客服工单系统,客服会在多个标签页同时操作同一张工单,还经常在地铁里断网。你怎么保证状态不丢、不覆盖?

评估点:是否区分「本地暂存」与「服务端真相」、是否给出冲突策略。

参考方案:

  1. 本地为主:编辑内容写 IndexedDB(不是 localStorage,避免同步阻塞与容量限制),带 updatedAt 与 version。
  2. 跨标签页同步:BroadcastChannel 广播变更,配合 storage 事件兜底;单一标签页持有写锁(navigator.locks)避免双写。
  3. 提交策略:在线时增量提交(字段级 patch),离线时进队列,恢复后按顺序重放;每个操作带幂等键。
  4. 冲突处理:字段级 LWW(后写覆盖)用于普通文本;状态流转等关键字段用版本号做乐观锁,冲突时提示人工合并。
  5. 可观测:记录同步队列长度、失败次数、冲突次数;给用户明确的「已保存到本地 / 已同步 / 有冲突」三态。

加分:指出「后写覆盖」会让两个客服互相覆盖,关键场景需要 CRDT 或字段级合并;指出离线队列要设过期与上限。

减分:只用 localStorage 存整份 JSON;断网时直接丢弃;用时间戳当唯一版本号(时钟不一致)。

8.5 最容易答错的点

常见说法纠正
「状态越集中越好」集中会增加耦合与重渲染范围,应按生命周期与订阅范围拆分
「派生状态也存一份,读起来快」双份状态必然不一致,能算的不要存
「useMemo 能替代选择器」它只缓存计算,不控制订阅粒度
「乐观更新失败了再回滚就行」回滚要处理并发覆盖与用户已继续编辑的情况
「localStorage 够用了」同步 API、约 5MB 上限、无事务,不适合大量结构化数据
「离线就是加个 Service Worker」离线核心是数据一致性与冲突解决,缓存只是第一层
「状态机一定比 reducer 好」简单线性流程用状态机是过度设计
「BroadcastChannel 能同步所有状态」只传消息不共享内存,仍需设计消息协议与冲突策略

9. 延伸资源

  • Redux 官方 Style Guide:状态结构与命名的事实标准
  • TanStack Query 文档:Caching、Optimistic Updates、Query Invalidation
  • XState 文档 + 「Statecharts vs State Machines」
  • Ink & Switch Local-first software 论文:离线与协作的架构基石
  • Martin Kleppmann 关于 CRDT / OT 的公开课
  • React 官方 useSyncExternalStore 与并发渲染文档
  • Linear / Notion / Figma 工程博客:同步引擎与冲突处理实践
  • idb(IndexedDB 封装)、Yjs(CRDT)、zod(运行时校验)源码与示例