07 · 状态管理与数据流(详解版)
一句话定位:状态是「应用此刻的全部事实」。数据流是「这些事实如何被读取、修改、同步」。中大型前端项目失控,八成不是组件写得多,而是状态没有归属。
读完本篇你应该能做到
- 拿到一个页面,能说清每份状态「从哪来、放哪、谁改、什么时候失效」;
- 能写出规范化的本地状态结构,并用缓存语义管理服务端数据;
- 能用状态机处理复杂业务流程,用乐观更新 + 幂等让弱网体验可用;
- 能解释多标签页、离线、多端协作场景下的数据一致性方案。
0. 领域导论
0.1 这个领域解决什么问题
先看四个真实故障,它们都「代码没报错」:
| 现象 | 根因 | 属于哪类问题 |
|---|---|---|
| 用户改完资料点保存,返回列表还是旧数据 | 同一份数据存了两处,只更新了其中一处 | 单一数据源缺失 |
| 搜索框快速输入,结果忽新忽旧 | 两个请求返回顺序颠倒,旧结果覆盖新结果 | 竞态条件 |
| 详情页刷新后筛选条件丢失,链接分享给同事打不开同一视图 | 筛选状态存在组件内存里,没有进 URL | 状态归属错误 |
| 编辑弹窗关掉再打开,还是上次的输入 | 组件没销毁,状态残留;或全局 store 没清理 | 生命周期与重置策略缺失 |
状态管理的本质是三件事:
- 归属:这份数据应该由谁持有?(组件 / 页面 / 全局 / URL / 服务端)
- 流转:它如何被读取、修改、派生?
- 一致:多个副本、多个请求、多个标签页之间如何不打架?
只会用 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 状态的五种来源:先分类,再选工具
| 来源 | 典型内容 | 该放哪 | 生命周期 | 常见错误 |
|---|---|---|---|---|
| 组件内部状态 | 弹窗开关、输入框草稿、hover | useState / 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;
}
它的问题(也就是状态库存在的原因):
- Context 值一变,所有消费者都重渲染,哪怕只关心其中一项;
- 没有选择器,无法做细粒度订阅;
- 没有中间件,异步流程要手写;
- DevTools、时间旅行、持久化都要自己实现。
1.6 不可变更新:为什么不能直接改
const next = { ...state, user: { ...state.user, name: '新名字' } };
// 反例:直接修改
state.user.name = '新名字';
直接修改的三个后果:
- React 靠引用比较判断「变没变」,同一个对象引用会被判定为没变,界面不更新;
- 时间旅行/撤销重做会失效(历史快照被一起改了);
- 无法做「变更前后对比」,调试与埋点失效。
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 / 服务端 / 全局 / 持久化,五类各用各的工具 |
| 单向数据流 | 修改入口收敛,数据自上而下 |
| 更新规则 | 批处理、函数式更新、换新引用 |
| 派生状态 | 能算不存,记忆化要有稳定依赖 |
| 表单 | 受控换联动,非受控换性能 |
自检题
- 一个列表页有「筛选、分页、选中项、当前用户、草稿」,分别该放哪?——筛选与分页放 URL(可分享、可回退);选中项放组件状态,需要分享时也进 URL;当前用户放全局或服务端缓存;草稿放本地存储加组件状态。
setCount(count + 1)连写三次为什么只加一?——三次读到的都是本次渲染的count快照,批处理后以最后一次写入为准;改用setCount(c => c + 1)。- 为什么
useMemo有时「加了等于没加」?——依赖每次新建(对象/数组字面量)、计算本身很便宜,或父组件重渲染仍会执行组件体;只有依赖稳定且计算昂贵时缓存才有意义。
2. L2 进阶:分清「真相」与「缓存」
2.1 状态库全景与选型
| 方案 | 心智模型 | 适合 | 代价 |
|---|---|---|---|
| Redux Toolkit | 单一 store + action/reducer | 大团队、流程严格、需要时间旅行 | 样板多(RTK 已大幅缓解) |
| Zustand | hook 式小 store,订阅选择器 | 中小项目、按领域拆 store | 生态约定弱,靠团队自律 |
| Jotai / Recoil | 原子化,自底向上组合 | 细粒度依赖、组件自治 | 原子多了难梳理全局 |
| Pinia | Vue 官方,state/getters/actions | Vue 项目默认选择 | 与 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] },
};
收益:
- 更新一个实体就是
entities.users.byId[9] = {...},O(1); - 同一用户只存一份,改名后所有引用自动更新;
- 列表只存 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 服务端状态:它本质是缓存
服务端数据的四个特征,决定了它不该用普通状态管理:
- 你并不拥有它(别人也在改);
- 它会过期(需要重新验证);
- 它可能重复请求(需要去重与共享);
- 它需要分页/无限加载(缓存键要能组合)。
/**
* 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 | 简单、够用 | 复杂流程嵌套深、难取消 |
| saga | generator + effect | 可测试、可取消、并发控制强 | 概念多、样板重 |
| observable | RxJS 管道 | 事件流组合强 | 学习曲线陡 |
| 请求库 mutation | useMutation | 与缓存天然集成 | 只覆盖数据请求流程 |
| 状态机 | 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>
</>
);
}
多步表单的三个关键决策:
- 已填数据放哪:父级状态(简单)vs 全局(跨路由)vs 服务端草稿(可跨设备);
- 步骤放哪:URL(可回退、可刷新);
- 校验时机:每步离开时校验,还是最终提交时统一校验。推荐前者 + 最终兜底。
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 |
| 持久化 | 版本迁移 + 容量兜底 + 敏感数据不落盘 |
自检题
- 为什么说「服务端状态不是状态,是缓存」?这句话改变了哪些设计?——它需要过期、失效、去重、重试、分页与乐观更新;设计上按「缓存键 + 新鲜度 + 失效事件」组织,而不是当成唯一真相在本地改。
- 两个请求乱序返回,三种解法分别适合什么场景?——递增请求 ID 只认最新(适合搜索/筛选);
AbortController取消旧请求(省资源,适合切换频繁);按 key 分别存结果(适合多个独立资源并行)。 - 一个 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 防撕裂 |
| 并发 | 紧急更新与过渡更新分开 |
自检题
- 一个「可取消、可重试、可离线」的下单流程,状态怎么设计?——
idle → validating → submitting → pending(可取消) → success / failed(可重试),离线另加queued;用判别联合存orderId、attempt与幂等键,恢复后按顺序重放。 - 乐观更新后服务端返回冲突(409),前端应该有哪几种处理路径?——拉取最新并提示人工合并;幂等前提下自动重试;回滚乐观更新但保留用户输入;转人工或客服;按字段重要性与冲突概率选择。
- 为什么自研 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 全栈 |
契约变更的兼容策略:
- 只增不减:字段先标记
@deprecated,观察调用量归零再删; - 版本共存:
/v1与/v2并行一段时间; - 契约测试:用 schema(OpenAPI/GraphQL)在 CI 中校验破坏性变更;
- 前端兜底:字段缺失时给默认值与降级 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 配置不合理 |
重构路径(绞杀者模式):
- 新建旁路数据层,与旧 store 并行读取;
- 页面上按模块切换读取源(灰度);
- 新旧数据双向同步,保证一致;
- 旧 store 调用量归零后删除。
不要停业务做「大重构」——状态层牵一发动全身,必须可回退。
4.7 L4 自检
- 公司有 5 个前端应用共享「用户/权限」数据,如何设计同步与失效?——单一权威服务加短 TTL 缓存,用版本号或事件推送失效,跨应用通过 BFF 或共享 SDK 读取;禁止各应用长期缓存权限。
- 一个「多端 + 离线 + 协作编辑」的产品,冲突策略怎么选?为什么?——文本协同用 CRDT(离线合并好、实现成本高),表单配置用 LWW 加版本号(简单、需人工兜底),强一致业务用服务端锁或串行化;按「冲突概率 × 合并难度」选。
- 前端该不该做业务规则校验?边界在哪?——前端做格式校验与即时反馈,服务端做权威校验与业务规则;金额、库存、权限必须在服务端,前端只做预校验与错误展示。
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取消上一个请求;缓存键必须包含全部影响结果的参数(筛选、分页、排序、租户)。
- 要点:响应乱序。解决方式:请求携带递增 ID,只接受最新 ID 的结果;或用
链 3 · 状态机 → 非法状态不可达 → 落地方式
- 面试官:为什么布尔标志会爆炸?
- 要点:
n个布尔有2^n种组合,其中大部分非法(如「加载中且已成功」);组合爆炸让 UI 分支不可穷举,bug 都藏在非法组合里。
- 要点:
- 追问:状态机怎么保证非法状态不可达?
- 要点:显式枚举状态,转移只能通过事件触发,未定义转移直接忽略或报错;
context存数据、state存阶段;XState 还能生成状态图供产品确认。
- 要点:显式枚举状态,转移只能通过事件触发,未定义转移直接忽略或报错;
- 再追问:所有页面都该上状态机吗?
- 要点:不必。状态少(≤3 个)且线性时,
useReducer+ 判别联合类型就够;状态机适合多步骤、可中断、有并行区域的流程(支付、向导、播放器)。引入前要评估学习成本与调试工具链。
- 要点:不必。状态少(≤3 个)且线性时,
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 场景设计题
题:一个客服工单系统,客服会在多个标签页同时操作同一张工单,还经常在地铁里断网。你怎么保证状态不丢、不覆盖?
评估点:是否区分「本地暂存」与「服务端真相」、是否给出冲突策略。
参考方案:
- 本地为主:编辑内容写 IndexedDB(不是
localStorage,避免同步阻塞与容量限制),带updatedAt与version。 - 跨标签页同步:
BroadcastChannel广播变更,配合storage事件兜底;单一标签页持有写锁(navigator.locks)避免双写。 - 提交策略:在线时增量提交(字段级 patch),离线时进队列,恢复后按顺序重放;每个操作带幂等键。
- 冲突处理:字段级 LWW(后写覆盖)用于普通文本;状态流转等关键字段用版本号做乐观锁,冲突时提示人工合并。
- 可观测:记录同步队列长度、失败次数、冲突次数;给用户明确的「已保存到本地 / 已同步 / 有冲突」三态。
加分:指出「后写覆盖」会让两个客服互相覆盖,关键场景需要 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(运行时校验)源码与示例