06 · 框架与组件化(React / Vue,详解版)
一句话定位:框架替你做了三件事——把数据变化映射成 UI 更新、把 UI 拆成可复用的组件、把副作用管理起来。理解它怎么做到这三件事,比记 API 重要得多。
读完本篇你应该能做到
- 解释「为什么这次渲染会发生」,并知道怎么减少不必要的渲染;
- 独立设计一个组件的 API(受控/非受控、组合、可访问性);
- 判断什么时候该用框架、什么时候该绕过框架。
0. 领域导论
0.1 这个领域解决什么问题
没有框架时,你要手动做这些事:
// 手动同步:数据变了 → 找到 DOM → 更新文本 → 处理列表增删 → 重新绑定事件
function updateCount(next) {
state.count = next;
document.querySelector('#count').textContent = next;
document.querySelector('#total').textContent = next * price;
// 如果列表也依赖 count,还要再改一遍……
}
问题:数据和视图是两份真相,任何一处忘了同步就是 bug。
框架的核心思路:UI 是状态的函数,UI = f(state)。你只改状态,框架负责算出「该改哪块 DOM」。
0.2 一个类比
把写 UI 想成装修房子:
- 命令式(原生 DOM):你亲自搬每一块砖,改哪动哪。
- 声明式(框架):你画一张「我想要的样子」的图纸(render),施工队(框架)对比旧图纸与新图纸,只改有差异的部分(diff / 响应式追踪)。
理解「施工队怎么对比图纸」,就是理解框架的更新机制。
0.3 学习地图
06 框架与组件化
├─ 前置:03 JavaScript、05 浏览器原理
├─ 紧接着:07 状态管理、08 路由与渲染架构
├─ 版本演进:21 React 16 → 19(Fiber / 并发 / RSC / Actions)
├─ 交叉:02 CSS(样式方案)、13 测试(组件测试)
└─ 长期:17 架构(设计系统与组件治理)
0.4 分级标尺
| 级别 | 能力特征 | 典型产出 | 自检标准 |
|---|---|---|---|
| L1 基础 | 会用框架写页面,理解基本数据流 | 业务页面与组件 | 能解释 props/state 与渲染触发条件 |
| L2 进阶 | 理解更新机制,能优化性能与复用逻辑 | 业务组件库、自定义 Hook | 能解释「为什么重复渲染」 |
| L3 高级 | 源码级理解,能设计组件 API 与渲染方案 | 组件库架构、自定义渲染器 | 能解释调度与编译优化 |
| L4 专家 | 决定框架边界与技术演进方向 | 跨团队组件资产体系、迁移方案 | 能对选型的长期成本负责 |
1. L1 基础:声明式 UI 的第一层
1.1 组件与单向数据流
// React
function Counter({ initial = 0 }) {
const [count, setCount] = React.useState(initial);
return (
<button onClick={() => setCount(count + 1)}>
点击了 {count} 次
</button>
);
}
<!-- Vue -->
<script setup>
import { ref } from 'vue';
const props = defineProps({ initial: { type: Number, default: 0 } });
const count = ref(props.initial);
</script>
<template>
<button @click="count++">点击了 {{ count }} 次</button>
</template>
单向数据流的三条规则:
- 父传子用 props,子不能直接改 props(Vue 会警告,React 直接冻结语义)。
- 子要通知父,用回调/事件(
onChange/emit)。 - 同层级共享状态,提升到共同父级(状态提升)。
为什么不能双向?因为「谁都可以改」意味着「谁都不知道值什么时候变的」,调试成本会随组件数指数上升。
1.2 条件渲染与列表渲染
// React
{isLoggedIn && <Dashboard />}
{items.map((item) => <Item key={item.id} data={item} />)}
<template>
<Dashboard v-if="isLoggedIn" />
<Item v-for="item in items" :key="item.id" :data="item" />
</template>
key 为什么不能用数组索引:
// 初始:[A, B, C]
// 删除 A 后:[B, C]
// 用索引作 key:索引 0 从 A 变成 B → 框架认为「第一个组件变了内容」
// 用 id 作 key:框架知道 A 被移除,B/C 复用
// 后果:输入框里的内容错位、动画错乱、组件内部状态挂到错误的项上
v-if 与 v-show:
v-if:条件为假时不渲染(销毁组件、丢失内部状态)。v-show:始终渲染,只切display(保留状态,适合频繁切换)。
1.3 Hooks 与组合式 API 基础
// React:Hooks 必须按固定顺序调用
function Profile({ userId }) {
const [user, setUser] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
fetchUser(userId)
.then((data) => { if (!cancelled) setUser(data); })
.catch((err) => { if (!cancelled) setError(err); });
return () => { cancelled = true; }; // 清理:防止竞态与内存泄漏
}, [userId]); // 依赖变化时重新执行
if (error) return <ErrorView error={error} />;
if (!user) return <Skeleton />;
return <div>{user.name}</div>;
}
// Vue:组合式函数
import { ref, watchEffect } from 'vue';
export function useUser(userId) {
const user = ref(null);
const error = ref(null);
watchEffect(async (onCleanup) => {
let cancelled = false;
onCleanup(() => { cancelled = true; });
try {
const data = await fetchUser(userId.value);
if (!cancelled) user.value = data;
} catch (err) {
if (!cancelled) error.value = err;
}
});
return { user, error };
}
Hooks 的三条铁律(React):
- 只能在组件或自定义 Hook 顶层调用(不能放在
if/for/嵌套函数里)。 - 顺序必须稳定(React 靠调用顺序匹配状态)。
- 依赖数组要诚实(漏写会拿到旧值,多写会重复执行)。
1.4 样式方案
| 方案 | 写法 | 优点 | 缺点 |
|---|---|---|---|
| CSS Modules | import s from './x.module.css' | 局部作用域、零运行时 | 动态样式需拼类名 |
| Tailwind | className="p-4 text-sm" | 快速、约束强 | 类名长、可读性争议 |
| CSS-in-JS | styled.div\...`` | 动态样式方便 | 运行时开销、SSR 处理复杂 |
Vue scoped | <style scoped> | 简单、编译期处理 | 深度选择器需 :deep() |
选择建议:新项目优先 CSS Modules 或 Tailwind;需要「根据 props 动态生成大量样式」时再考虑 CSS-in-JS。
1.5 表单:受控与非受控
// 受控:值由 state 决定,每次输入都触发渲染
function Controlled() {
const [value, setValue] = useState('');
return <input value={value} onChange={(e) => setValue(e.target.value)} />;
}
// 非受控:值由 DOM 自己管理,需要时通过 ref 读
function Uncontrolled() {
const ref = useRef(null);
const submit = () => console.log(ref.current.value);
return <input ref={ref} defaultValue="默认值" />;
}
选择标准:
- 需要实时校验/联动/格式化 → 受控;
- 字段很多、只关心提交时的值 → 非受控(性能更好);
- 大表单用 React Hook Form / VeeValidate(它们内部用非受控 + 订阅,减少渲染)。
⚠️ 常见 bug:受控组件传了
value={undefined}会变成非受控,控制台警告「changing from uncontrolled to controlled」。
1.6 组件通信的四种方式
| 场景 | 方式 | 注意 |
|---|---|---|
| 父子 | props + 回调 | 最清晰,优先用 |
| 兄弟 | 状态提升到父级 | 会让父组件变大,必要时拆 |
| 跨层级 | Context / provide-inject | 不适合高频更新(会大范围重渲染) |
| 全局 | 状态库(见 07) | 别滥用 |
1.7 组件边界:组合、插槽与可访问性
组件 API 的好坏,取决于调用方能不能在不改组件内部的情况下表达新需求。
| 形态 | 写法 | 适合 | 代价 |
|---|---|---|---|
| 配置式 props | <Table columns={...} /> | 场景收敛、变化少 | 需求一多就变成「props 地狱」 |
| children 组合 | <Card><Header/><Body/></Card> | 布局与内容分离 | 需要约定子组件顺序与样式 |
| 复合组件 | <Tabs><Tabs.Tab/></Tabs> | 有关联状态的控件 | 需要 Context 共享状态 |
| 插槽 / render props | <Table cell={({row}) => ...} /> | 局部自定义渲染 | 可读性与性能需注意 |
复合组件的最小实现(React):
/**
* Tabs:用 Context 共享选中态,子组件只负责展示。
* 关键:键盘左右切换、aria-selected 与 tabpanel 关联。
*/
const TabsContext = React.createContext(null);
function Tabs({ defaultValue, children }) {
const [value, setValue] = React.useState(defaultValue);
return (
<TabsContext.Provider value={{ value, setValue }}>
<div role="tablist">{children}</div>
</TabsContext.Provider>
);
}
function Tab({ value, children }) {
const ctx = React.useContext(TabsContext);
const selected = ctx.value === value;
return (
<button
role="tab"
aria-selected={selected}
tabIndex={selected ? 0 : -1}
onClick={() => ctx.setValue(value)}
onKeyDown={(e) => {
// 左右键在 tab 之间移动,是 WAI-ARIA 的基本要求
if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return;
e.preventDefault();
ctx.setValue(nextValue(value, e.key));
}}
>
{children}
</button>
);
}
可访问性的四条底线:
- 能用原生元素就不用
div(button自带键盘与焦点行为); - 所有交互都能用键盘完成,且有可见焦点;
- 状态要能被读屏感知(
aria-expanded、aria-selected、aria-live); - 表单控件必须有可关联的
label,错误信息要能被播报。
| 反模式 | 问题 | 修复 |
|---|---|---|
<div onClick> | 不可聚焦、无键盘触发 | 改用 button,或补 role/tabIndex/onKeyDown |
aria-label 与可见文本不一致 | 读屏与视觉用户听到的不一样 | 以可见文本为准,必要时用 aria-describedby |
| 只用颜色表达状态 | 色觉障碍用户无法区分 | 同时用图标/文字/形状 |
| 弹窗不锁焦点 | Tab 会跑到背景内容 | 焦点陷阱 + inert + 关闭后归还焦点 |
1.8 L1 小结与自检
| 知识点 | 最该记住的一句 |
|---|---|
| 单向数据流 | props 下行、事件上行、共享状态提升 |
key | 用稳定 ID,不要用索引 |
| Hooks | 顶层调用、顺序稳定、依赖诚实 |
| 表单 | 需要实时联动用受控,大表单用非受控 |
| 通信 | 能用 props 就别上 Context |
自检题
- 为什么列表
key不能用数组索引?——插入、删除或排序后索引会重新分配,旧节点被错误复用,导致输入内容、内部 state 与动画跟错行;应使用稳定业务 ID。 v-if和v-show分别适合什么场景?——v-if用于条件不常变、需要懒加载或销毁释放的场景;v-show用于频繁切换且要保留 DOM 与状态的场景,代价是首屏就渲染。useEffect依赖数组写空会发生什么?——只在挂载后执行一次,闭包捕获的是首次渲染的 props/state,后续更新读不到;需要最新值用 ref 或补齐依赖。- 为什么复合组件通常需要 Context,而普通布局组件不需要?——复合组件(Tabs、Select)的多个子组件要共享同一份选中/展开状态,Context 避免逐层透传;纯布局组件没有共享状态,用 children 就够。
2. L2 进阶:更新机制与性能
2.1 React 的协调(Reconciliation)
React 更新分两步:
- Render 阶段:调用组件函数,生成新的虚拟 DOM(可被打断)。
- Commit 阶段:对比新旧树,把差异应用到真实 DOM(不可打断)。
diff 的三条假设(让它从 O(n³) 降到 O(n)):
- 不同类型的元素产生不同树(
div→span直接销毁重建)。 - 同层比较,不跨层级移动(跨层移动会被当作「删除 + 新建」)。
- 用
key标识同层元素的稳定性。
// 不要这样:每次渲染都创建新组件类型,导致子树被销毁重建
function Parent() {
const Child = () => <div>child</div>; // 每次都是新函数
return <Child />;
}
2.2 Vue 的响应式
const state = reactive({ count: 0, user: { name: '张三' } });
// 读取时收集依赖(track),写入时触发更新(trigger)
effect(() => {
console.log(state.count); // 收集:count → 这个 effect
});
state.count++; // 触发:只重新执行依赖 count 的 effect
Vue 3 用 Proxy 实现,能监听属性新增/删除、数组索引修改。与 React 的根本差异:
| 维度 | React | Vue |
|---|---|---|
| 更新粒度 | 组件级(重新执行函数) | 依赖级(精确到属性) |
| 数据变更检测 | 显式 setState | 自动追踪(Proxy) |
| 优化手段 | memo / useMemo | 一般不需要手动优化 |
| 心智负担 | 需要理解引用相等 | 需要理解响应式丢失 |
2.3 为什么会有「不必要的重渲染」
function Parent() {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => setCount(count + 1)}>{count}</button>
<ExpensiveChild /> {/* 每次 Parent 渲染,它也会渲染 */}
</>
);
}
ExpensiveChild 没有用到 count,但仍然重新执行了。React 的默认行为是「父渲染 → 子渲染」。
三种优化手段及其适用条件:
// 1. React.memo:props 浅比较相等时跳过渲染
const Child = React.memo(function Child({ data }) { /* ... */ });
// ⚠️ 如果父组件每次传新对象/新函数,memo 就失效
<Child data={{ id: 1 }} /> // ❌ 每次新对象
<Child data={useMemo(() => ({ id: 1 }), [])} /> // ✅ 稳定引用
// 2. useMemo:缓存计算结果
const sorted = useMemo(() => list.slice().sort(cmp), [list]);
// 3. useCallback:缓存函数引用
const onSave = useCallback(() => save(id), [id]);
关键认知:
useMemo/useCallback本身也有成本(依赖比较、内存)。只在「确实慢」或「引用必须稳定」时用。React 19 + Compiler 会自动化大部分场景。
2.4 闭包陷阱(Stale Closure)
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count); // ❌ 永远是 0:闭包捕获了首次渲染的 count
}, 1000);
return () => clearInterval(id);
}, []); // 依赖为空,effect 不会重建
// 解法 1:用函数式更新(不依赖外部值)
// setCount((c) => c + 1)
// 解法 2:把 count 放进依赖
// }, [count]) → 但会频繁重建定时器
// 解法 3:用 ref 保存最新值
const countRef = useRef(count);
countRef.current = count;
}
同类问题:useEffect 里用了 state 但依赖没写;事件监听器里读旧 state;setTimeout 里的回调。
2.5 逻辑复用:三种模式的演进
| 模式 | 写法 | 问题 |
|---|---|---|
| 高阶组件 HOC | withAuth(Component) | 嵌套地狱、props 命名冲突 |
| Render Props | <Fetch render={(data) => ...} /> | 嵌套、回调难读 |
| 自定义 Hook / 组合式函数 | useFetch(url) | 当前推荐 |
// 自定义 Hook 的经典场景:异步请求
function useFetch(url) {
const [state, setState] = useState({ data: null, loading: true, error: null });
useEffect(() => {
const controller = new AbortController();
setState({ data: null, loading: true, error: null });
fetch(url, { signal: controller.signal })
.then((res) => res.json())
.then((data) => setState({ data, loading: false, error: null }))
.catch((error) => {
if (error.name === 'AbortError') return;
setState({ data: null, loading: false, error });
});
return () => controller.abort();
}, [url]);
return state;
}
2.6 状态提升与组件拆分
判断标准:状态应该放在「最小的、所有使用者的共同祖先」。
// 两个兄弟组件都要用 keyword → 提升到父级
function SearchPage() {
const [keyword, setKeyword] = useState('');
return (
<>
<SearchBox value={keyword} onChange={setKeyword} />
<ResultList keyword={keyword} />
</>
);
}
拆分原则:
- 一个组件只做一件事;超过 200 行考虑拆;
- 按「变化原因」拆(同一个组件里两个独立的变化源 = 该拆);
- 不要为了复用而过度抽象(重复三次再抽象)。
2.7 错误处理
// React:错误边界只能捕获渲染阶段错误
class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, info) {
report(error, info.componentStack);
}
render() {
if (this.state.hasError) return <Fallback />;
return this.props.children;
}
}
错误边界捕获不到:事件处理器、异步回调、SSR、自身抛出的错误。这些要靠全局监听 + 手动 try/catch。
// Vue
app.config.errorHandler = (err, instance, info) => {
report(err, info);
};
2.8 受控性设计:一个组件如何同时支持两种用法
/**
* 既支持受控(外部传 value)也支持非受控(只传 defaultValue)。
* @param {object} props 组件属性
* @param {string} [props.value] 受控值
* @param {string} [props.defaultValue] 非受控初始值
* @param {(v: string) => void} [props.onChange] 值变化回调
*/
function Input({ value, defaultValue = '', onChange }) {
const isControlled = value !== undefined;
const [inner, setInner] = useState(defaultValue);
const current = isControlled ? value : inner;
const handleChange = (next) => {
if (!isControlled) setInner(next);
onChange?.(next);
};
return <input value={current} onChange={(e) => handleChange(e.target.value)} />;
}
关键:用 value !== undefined 判断是否受控,并在开发环境警告「受控/非受控切换」。
2.9 渲染性能:先定位,再优化
不要凭感觉加 memo。渲染性能优化的顺序是:测量 → 找到真正昂贵的组件 → 判断是「渲染次数多」还是「单次渲染贵」。
React 的诊断流程:
1. React DevTools Profiler 录制一次交互
2. 看 Ranked:哪些组件耗时最长
3. 点开组件,看 "Why did this render?"
4. 区分:props 变化 / state 变化 / context 变化 / 父组件渲染
5. 只对「耗时长且频繁」的组件做优化
| 症状 | 真正原因 | 修复 | 验证 |
|---|---|---|---|
| 输入框每敲一个字整页卡 | 状态放在顶层,整棵树重渲染 | 状态下沉到输入组件,或 useDeferredValue | Profiler 中提交时间下降 |
memo 加了没效果 | 每次传新对象/新函数 | 稳定引用(useMemo/useCallback)或自定义比较 | Why did this render 显示 props 相等 |
| Context 一变全页刷新 | 所有消费者共享一个大 Context | 拆分 Context、用选择器订阅 | 消费者渲染次数 |
| 长列表滚动掉帧 | 一次渲染上千行 | 虚拟列表 + 稳定 key | 滚动帧率与提交时长 |
| 计算属性重复执行 | 每次渲染都重算 | useMemo / computed | 计算次数打点 |
memo 的四个前提(缺一个就失效):
/**
* 1) props 是原始值或稳定引用
* 2) 没有被 Context 强制更新
* 3) 父组件没有用 cloneElement 注入新 props
* 4) 组件自身 state 未变化
*/
const Row = React.memo(function Row({ item, onSelect }) {
return <li onClick={() => onSelect(item.id)}>{item.name}</li>;
});
// 父组件:onSelect 必须稳定,否则 memo 形同虚设
const onSelect = React.useCallback((id) => setSelected(id), []);
Vue 的对应手段:
| 手段 | 作用 | 注意 |
|---|---|---|
computed | 缓存派生值 | 依赖不变就不重算 |
shallowRef | 只追踪引用变化 | 适合大对象/第三方实例 |
v-memo | 跳过子树更新 | 依赖数组要写全 |
markRaw | 跳过响应式代理 | 图表/地图实例常用 |
一个反直觉的结论:多数「性能问题」不是渲染函数慢,而是状态放错了位置。把状态从顶层下沉到真正使用它的组件,往往比任何 memo 都有效。
2.10 L2 小结与自检
| 主题 | 关键结论 |
|---|---|
| 协调 | 同层比较 + key;跨层移动会重建 |
| 响应式 | Vue 依赖级更新,React 组件级更新 |
| 重渲染 | 父渲染 → 子渲染;memo 需要稳定引用 |
| 记忆化 | 有成本,只在必要时用 |
| 闭包陷阱 | 函数式更新 / 依赖补全 / ref |
| 复用 | 自定义 Hook 优于 HOC 与 render props |
| 受控性 | 用 undefined 区分,别混用 |
自检题
useCallback什么时候是多余的?——函数不传给memo子组件、不作为其他 hook 的依赖、或依赖本身每次都变时,缓存没有收益还多一次比较与内存开销。- React 中「闭包陷阱」的三种典型场景与解法?——定时器/订阅回调读旧 state(用 ref 或函数式更新);连续
setState读旧值(改setX(x => ...));异步请求回来用旧 props(补依赖并在清理时取消)。 - 为什么
memo有时不生效?——props 每次都是新引用(对象、数组、内联函数)、context 变化、组件自身 state 更新,或自定义比较函数漏字段;需要配合useMemo/useCallback稳定引用。 - 为什么「状态下沉」比加
memo更有效?——它直接减少需要重新渲染的子树范围;memo只是跳过子树,但父组件仍要执行并做 props 比较,且引用不稳定时会失效。
3. L3 高级:内部机制与架构
3.1 React 内部:Fiber 与调度
为什么需要 Fiber:旧架构(Stack Reconciler)递归遍历不可中断,一棵大树渲染时会长时间占住主线程,导致输入无响应。Fiber 把树改成链表结构,每个节点是一个「工作单元」,可以暂停、恢复、丢弃。
Fiber 节点关键字段:
child 第一个子节点
sibling 下一个兄弟节点
return 父节点
lanes 优先级(哪些更新要处理)
alternate 上一次渲染的对应节点(用于 diff)
lane 模型:用位掩码表示优先级(SyncLane、InputContinuousLane、DefaultLane、TransitionLane…),一次更新可以同时带多个 lane。
并发特性:
// useTransition:把「非紧急更新」标记为可打断
const [isPending, startTransition] = useTransition();
function search(keyword) {
setInput(keyword); // 紧急:输入框必须立刻响应
startTransition(() => {
setResults(expensiveFilter(keyword)); // 非紧急:可以被打断
});
}
// useDeferredValue:延迟某个值的更新,保留旧 UI
const deferredQuery = useDeferredValue(query);
与防抖的区别:防抖是「等用户停下来」,useTransition 是「立刻算,但允许被打断重算」。后者在长列表过滤时体验更好。
3.2 Vue 内部:编译器优化
<template>
<div class="static">静态内容</div>
<p>{{ dynamic }}</p>
</template>
编译产物(概念上):
// 静态节点被提升到 render 函数外,不参与每次更新
const _hoisted_1 = createElementVNode('div', { class: 'static' }, '静态内容', -1 /* PATCH_FLAG.HOISTED */);
function render(_ctx) {
return createElementBlock(Fragment, null, [
_hoisted_1,
createElementVNode('p', null, toDisplayString(_ctx.dynamic), 1 /* TEXT */),
]);
}
三个优化:
- 静态提升:不会变的部分只创建一次。
- Patch Flag:标记「这个节点只会变文本」,diff 时直接跳过结构比较。
- Block Tree:收集所有动态节点,更新时只遍历动态节点(而不是整棵树)。
Vue Vapor Mode(实验性)进一步去掉虚拟 DOM,直接生成 DOM 操作代码。
3.3 渲染模型对比:VDOM / 细粒度响应式 / Signal
| 模型 | 代表 | 更新粒度 | 优点 | 缺点 |
|---|---|---|---|---|
| 虚拟 DOM | React、Vue(默认) | 组件/节点级 | 心智简单、跨端容易 | diff 有成本、需要手动优化 |
| 细粒度响应式 | Vue 3、Solid | 属性级 | 精准更新 | 依赖追踪有内存成本 |
| Signal | Solid、Preact Signals、Angular | 属性级 | 无需手动 memo、可组合 | 生态与心智仍在演进 |
核心差异:React 每次渲染都重新执行组件函数;Solid 的组件函数只执行一次,后续直接更新 DOM 节点。
3.4 自定义渲染器:跨端的底层能力
React:
// react-reconciler:把「创建/更新/删除节点」抽象成宿主环境接口
const renderer = ReactReconciler({
createInstance(type, props) { /* 返回宿主节点 */ },
appendChild(parent, child) { /* 挂载 */ },
commitUpdate(instance, payload) { /* 更新属性 */ },
// ...
});
Vue:
import { createRenderer } from '@vue/runtime-core';
const { createApp } = createRenderer({
createElement(type) { /* 创建宿主元素 */ },
insert(child, parent) { /* 插入 */ },
remove(child) { /* 删除 */ },
patchProp(el, key, prev, next) { /* 更新属性 */ },
});
这就是 React Native(渲染到原生视图)、Canvas 渲染器、终端 UI 的原理:把 DOM 操作抽象掉,换成任意宿主环境。
3.5 组件 API 设计:组合优于配置
// ❌ 配置式:props 爆炸,每加一个能力就加一个 boolean
<Select
searchable
clearable
multiple
virtualized
showFooter
footerContent={...}
/>
// ✅ 组合式:用 children 拼装
<Select multiple>
<Select.Trigger />
<Select.Content searchable virtualized>
<Select.Group label="常用">
<Select.Item value="a">选项 A</Select.Item>
</Select.Group>
<Select.Footer>自定义底部</Select.Footer>
</Select.Content>
</Select>
Headless(无样式)组件:只提供行为与可访问性,样式交给使用方。
// Radix / Headless UI 的模式
<Tabs.Root defaultValue="a">
<Tabs.List>
<Tabs.Trigger value="a">常规</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="a">内容</Tabs.Content>
</Tabs.Root>
好处:行为(键盘、ARIA、焦点)只实现一次,样式完全自由。代价:调用方要写更多结构。
多态渲染(asChild):让组件把行为「注入」到任意元素上。
<Button asChild>
<a href="/docs">文档</a>
</Button>
// 渲染成 <a>,但拥有 Button 的行为与样式
3.6 SSR 与水合
服务端:renderToString(<App />) → HTML 字符串 → 浏览器
浏览器:解析 HTML(首屏可见)→ 下载 JS → hydrate(绑定事件、恢复状态)
水合为什么贵:客户端要把整棵组件树再执行一遍,才能挂上事件。组件越多、树越深,水合越慢。
水合不一致(hydration mismatch) 的六类诱因:
- 使用
Date.now()、Math.random()渲染内容; - 服务端与客户端环境不同(
window判断、时区、语言); - 无效的 HTML 嵌套被浏览器修正(见 01);
- 服务端与客户端数据快照不一致;
- 条件渲染依赖客户端独有状态(
localStorage); - 第三方组件在挂载时立即改 DOM。
解法:useId 生成稳定 ID、把客户端逻辑放进 useEffect、用 suppressHydrationWarning 仅限确实无害的场景。
3.7 可访问组件实现
// 一个可访问的 Tabs 的骨架
function Tabs({ items, value, onChange }) {
return (
<div role="tablist" aria-label="内容分组">
{items.map((item) => (
<button
key={item.id}
role="tab"
id={`tab-${item.id}`}
aria-selected={value === item.id}
aria-controls={`panel-${item.id}`}
tabIndex={value === item.id ? 0 : -1}
onClick={() => onChange(item.id)}
>
{item.label}
</button>
))}
</div>
);
}
必须补齐的键盘行为:左右方向键切换、Home/End、焦点跟随选中项。
3.8 性能诊断
| 工具 | 看什么 |
|---|---|
| React DevTools Profiler | 每次渲染的组件树、耗时、为什么渲染 |
why-did-you-render | 找出「props 没变却重渲染」的组件 |
| Vue DevTools | 组件更新时间、依赖 |
| Performance 面板 | 渲染总耗时与长任务 |
优化顺序(重要):先用 Profiler 找到「渲染次数多」还是「单次渲染重」,再决定手段:
- 渲染次数多 →
memo+ 稳定引用; - 单次渲染重 → 拆分组件、虚拟化、减少计算;
- 两者都重 → 考虑换渲染模型或把计算移出渲染。
3.9 并发渲染:把「紧急」与「可延后」分开
React 18 的并发能力不是「多线程」,而是让一次渲染可以被打断、被放弃、被重排。它的价值在于:用户输入永远优先,重活可以稍后做。
| API | 解决什么 | 注意 |
|---|---|---|
startTransition | 把更新标记为非紧急 | 只影响调度,不会让计算变快 |
useTransition | 同上,并拿到 isPending | 适合切 Tab、筛选大列表 |
useDeferredValue | 延迟某个值的渲染 | 会先返回旧值,再补新值 |
Suspense | 声明式处理「等待」 | 需配合 lazy/数据请求 |
useSyncExternalStore | 订阅外部 store,避免撕裂 | 第三方状态库的适配层 |
/**
* 搜索框:输入立即回显(紧急),结果列表延后渲染(非紧急)。
* 注意:useDeferredValue 不是防抖,它不减少请求,只降低渲染优先级。
*/
function Search() {
const [keyword, setKeyword] = useState('');
const deferred = useDeferredValue(keyword);
const stale = keyword !== deferred;
return (
<>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
<div style={{ opacity: stale ? 0.6 : 1 }}>
<ResultList keyword={deferred} />
</div>
</>
);
}
并发渲染适合与不适合的场景:
| 场景 | 适合 | 原因 |
|---|---|---|
| 输入框 + 大列表过滤 | 是 | 输入必须即时,列表可稍后 |
| 路由切换 | 是 | 新页面渲染可等待,旧页面保持可交互 |
| 首屏关键内容 | 否 | 延后会让用户看到空白 |
| 网络请求本身 | 否 | 并发不改变 IO 速度,只改变渲染调度 |
四个容易误解的点:
startTransition不会减少总工作量,只是把工作拆开、让高优先级更新插队;useDeferredValue不是防抖/节流,请求仍可能按每次输入发出,需要另外做请求层控制;- 并发渲染要求组件是纯的:渲染期不能有副作用,否则中断/重放会出问题;
StrictMode下双调用是刻意为之,用来暴露不纯的渲染逻辑,不要因此去掉 StrictMode。
Vue 的路径不同:细粒度响应式本身就把更新范围缩到最小,通常不需要「并发调度」;它的 Suspense 主要解决异步依赖的等待编排。跨框架比较时,要区分「减少工作量」与「调整工作优先级」两件事。
3.10 L3 小结与自检
useTransition解决什么问题?它和防抖有什么区别?——把更新标记为非紧急、可中断,保证输入等紧急更新优先渲染;防抖是延迟执行减少次数,不改变渲染优先级,两者可以叠加。- 水合为什么贵?流式 SSR + Suspense 如何缓解?——要下载并执行 JS、复用 DOM 绑定事件、恢复状态并校验一致性,成本随组件树增长;流式 SSR 先发壳、Suspense 分块补内容,让首屏区域先可交互。
- 设计一个
Select,如何同时满足可访问性、受控性、异步加载与虚拟滚动?——语义用combobox+listbox+aria-activedescendant;同时支持value/onChange与defaultValue;异步做防抖、取消与 loading/empty/error 三态;虚拟滚动保持固定行高并用aria-setsize/aria-posinset补语义。 - 并发渲染下,组件为什么必须保持「纯」?——渲染可能被中断、丢弃或重放,若渲染期有副作用(改全局变量、发请求、写 DOM),会出现重复执行或状态错乱;副作用应放在 Effect 或事件里。
4. L4 专家:选型与治理
4.1 框架选型决策
| 维度 | 要问的问题 |
|---|---|
| 团队能力 | 现有成员更熟哪个?招聘市场供给? |
| 生态 | 组件库、路由、状态、SSR 方案是否成熟? |
| 性能基线 | 首屏体积、水合成本、运行时开销 |
| 长期维护 | 版本节奏、破坏性变更频率、社区活跃度 |
| 迁移成本 | 存量代码量、可渐进迁移吗? |
| 招人 | 目标城市的候选人分布 |
加分项:用 POC 做一次真实业务页面的对比(不是 todo list),记录开发时间、产物体积、首屏指标。
4.2 自研边界
| 场景 | 建议 |
|---|---|
| 通用业务系统 | 用社区框架,别自研 |
| 强约束低代码/可视化搭建 | 可自研轻量运行时(DSL → 渲染) |
| 极端性能场景(大列表、图形) | 绕过框架的渲染层,自己控制 DOM |
| 嵌入式组件(跨框架复用) | Web Components |
自研的真实成本:文档、生态、招聘、问题解决速度、长期维护。收益必须明确大于这些成本。
4.3 组件资产治理
- 版本策略:SemVer + codemod;破坏性变更必须给迁移脚本。
- 废弃流程:
@deprecated→ 警告日志 → 下一个 major 删除。 - 边界划分:基础组件(Button/Input)由平台团队维护;业务组件由业务团队维护,允许重复。
- 度量:组件复用率、重复实现检测(跨包相似代码扫描)、按需加载率。
4.4 复杂交互架构
| 场景 | 难点 | 常见方案 |
|---|---|---|
| 富文本编辑器 | 状态与 DOM 同步、光标、撤销 | ProseMirror / Lexical(自建文档模型) |
| 大数据表格 | 虚拟滚动、冻结列、编辑态 | 自研渲染层 + 虚拟化 |
| 图形画布 | 高频重绘、命中测试 | Canvas/WebGL 自绘,框架只管外层 UI |
| 拖拽编排 | 命中、吸附、撤销重做 | dnd-kit + 命令模式 |
共同点:这些场景里,框架只负责「壳」,核心渲染与状态需要自己控制。
4.5 跨框架复用与渐进迁移
// 把 React 组件包成 Web Component,供 Vue 页面使用
class ReactWrapper extends HTMLElement {
connectedCallback() {
const root = createRoot(this);
root.render(<MyWidget {...this.dataset} />);
this._root = root;
}
disconnectedCallback() {
this._root?.unmount();
}
}
customElements.define('my-widget', ReactWrapper);
迁移策略:新页面用新框架 → 路由级接管 → 共用组件逐步替换 → 存量页面按流量排序迁移。不要一次性重写。
4.6 长期演进判断
识别「框架级趋势」:
- RSC(React Server Components):把组件执行搬到服务端,减小客户端包体。
- Signals:更细粒度的响应式,减少手动优化。
- 编译时优化(React Compiler、Svelte、Vue Vapor):把运行时成本前移。
判断方法:看它解决的是真实瓶颈(包体、水合、重渲染)还是概念热度;看生态是否跟上;看迁移是否有回头路。
4.7 组件库的版本策略与破坏性变更
组件库是「被很多团队依赖的内部产品」,版本策略错了,比代码 bug 更贵。
| 变更类型 | 版本位 | 例子 | 沟通方式 |
|---|---|---|---|
| 修复、无行为变化 | patch | 样式修复、类型修正 | changelog |
| 新增可选能力 | minor | 新 prop、新组件 | changelog + 示例 |
| 破坏性变更 | major | 默认值改变、DOM 结构变化、事件签名变化 | 迁移指南 + codemod + 弃用期 |
破坏性变更不只指 API 删除,以下都算:
- 默认值改变(如
size从medium改为small); - 生成的 DOM 结构/类名改变(外部覆盖样式会失效);
- 事件回调参数顺序或语义改变;
- 样式优先级/层叠变化导致业务覆写失效;
- 对等依赖(peerDependencies)主版本提升。
弃用流程(至少跨一个 minor):
/**
* 弃用提示:只在开发环境打印,避免生产日志噪音。
* @param {string} oldName 旧 API
* @param {string} newName 新 API
* @param {string} removeIn 计划移除的版本
*/
function warnDeprecated(oldName, newName, removeIn) {
if (process.env.NODE_ENV === 'production') return;
console.warn(
`[组件库] ${oldName} 已弃用,将在 ${removeIn} 移除;请改用 ${newName}。`
);
}
发布前必须做的四件事:
- 视觉回归:对核心组件截图对比,捕捉样式与结构变化;
- 消费方冒烟:至少挑 2 个真实业务跑一遍;
- 多版本共存:允许旧版本继续被依赖,避免「升级一个组件库导致全公司阻塞」;
- 可回退:新版本以可选依赖引入,出问题能锁回旧版本。
治理指标:版本分布(多少应用还在旧 major)、升级耗时、弃用 API 调用量、视觉回归失败率。
4.8 L4 自检
- 公司要在 React 与 Vue 之间统一技术栈,评估框架与迁移路线?——按团队能力、生态(SSR、跨端、组件库)、招聘供给与迁移成本打分;路线先统一构建与规范,再用微前端或路由级替换渐进迁移,不做一次性重写。
- 300 人团队,组件库该由谁维护?如何防止重复造轮子?——由平台或设计中台 owning,业务提需求并可贡献;用 RFC 与设计评审做准入,度量复用率与重复实现数,禁止私建同名组件。
- 什么时候你会决定「不用框架」?——页面以静态内容为主、交互极少、体积极度敏感(营销页、嵌入页),或团队无力跟进依赖升级时,原生加少量工具更稳、更省。
- 组件库升级到新 major,如何避免 20 个业务应用同时被阻塞?——允许新旧版本共存,提供 codemod 与迁移指南,按应用灰度升级;旧版本只修安全问题,设明确的停止支持时间,而不是强制一次性升级。
5. 综合实战:设计一个可复用的 Select
需求:支持搜索、多选、异步加载、虚拟滚动、可访问、可受控。
步骤 1 · 先定 API(组合式)
<Select
multiple
value={selected}
onChange={setSelected}
onSearch={fetchOptions} // 异步:返回 Promise<Option[]>
>
<Select.Trigger placeholder="请选择" />
<Select.Content virtualized height={320} />
</Select>
步骤 2 · 状态设计
// 内部状态:展开、搜索词、选项、加载中、高亮项
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [options, setOptions] = useState([]);
const [loading, setLoading] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
步骤 3 · 关键实现点
- 受控:
value !== undefined判断,内部维护innerValue。 - 异步:
AbortController取消上一次请求,避免竞态。 - 虚拟滚动:只渲染可视区 ± 缓冲区,用
translateY占位。 - 可访问性:
role="combobox"、aria-expanded、aria-controls、aria-activedescendant、方向键/Enter/Esc/Home/End。 - IME 兼容:中文输入法组合期间不响应方向键(监听
compositionstart/end)。
步骤 4 · 验收清单
- 纯键盘可完成选择与搜索
- 读屏能播报「已选 2 项」「第 3 项,共 20 项」
- 快速输入时结果不会倒序(竞态处理)
- 1000 个选项滚动流畅
- 受控/非受控切换有开发期警告
6. 常见误区对照表
| 误区 | 事实 |
|---|---|
「useMemo 越多越快」 | 记忆化有成本,依赖不稳定时反而更慢 |
「key 用索引没关系」 | 顺序变化会导致状态错位 |
| 「Context 可以做全局状态管理」 | 高频更新会引发大范围重渲染 |
| 「组件越通用越好」 | 过早抽象带来大量无用 props 与分支 |
| 「源码读过就等于会」 | 能解释「某次渲染为什么发生」才算掌握 |
| 「框架能解决性能问题」 | 框架只是工具,性能取决于渲染次数与单次成本 |
7. 面试高频题(附答题思路)
Q1:React 的 diff 算法原理? 三条假设 → 同层比较 → key 的作用 → 复杂度从 O(n³) 到 O(n) → 跨层移动的代价。
Q2:Vue 响应式原理?
Proxy 拦截 get/set → 依赖收集(track)→ 触发更新(trigger)→ 与 React 的粒度差异。
Q3:为什么不能用索引做 key? diff 靠 key 识别元素身份;索引变化会让「同一个位置的元素」被误判为同一元素,导致状态错位。
Q4:Hooks 为什么不能写在条件里? React 用调用顺序匹配 state;条件调用会让顺序错乱,状态张冠李戴。
Q5:如何优化一个渲染很慢的列表?
先测量(Profiler)→ 虚拟滚动 → memo + 稳定引用 → 拆分组件 → 把计算移出渲染。
8. 面试深挖
8.1 面试官在考察什么
| 维度 | 看什么 | 加分回答 | 减分回答 |
|---|---|---|---|
| 更新机制 | 能否说清「一次 setState 到 DOM 变化」的路径 | 指出调度、协调、提交三个阶段 | 「setState 就是改数据再重渲染」 |
| 响应式原理 | 依赖收集的粒度与代价 | 对比 Proxy 与编译期标记 | 「Vue 就是双向绑定」 |
| Hooks 心智 | 闭包与渲染快照 | 解释为什么拿到旧值、怎么修 | 「加个 ref 就行」 |
| 性能判断 | 重渲染是否等于慢 | 用 Profiler 定位到具体组件 | 「全部 memo 一遍」 |
| API 设计 | 受控/非受控、组合与配置 | 能给出两种用法的统一实现 | 只会照抄组件库 |
8.2 追问链
链 1 · 响应式 → 依赖收集 → 调度时机
- 面试官:Vue 3 的响应式是怎么工作的?
- 要点:
reactive用 Proxy 拦截get/set;get时把当前活跃 effect 收进targetMap → depsMap → dep,set时触发 dep 里的 effect;组件渲染函数本身就是一个 effect,所以「读了什么就依赖什么」。
- 要点:
- 追问:一次同步修改多个属性,会渲染几次?
- 要点:默认进调度队列,同一个 tick 内合并,微任务里统一刷新,所以只渲染一次。
nextTick就是等这个队列 flush 完成。
- 要点:默认进调度队列,同一个 tick 内合并,微任务里统一刷新,所以只渲染一次。
- 再追问:Proxy 的依赖收集有什么代价?编译期方案(Signal)为什么更快?
- 要点:Proxy 拦截有运行时开销,且无法在打包时确定依赖;Signal 把依赖关系在编译阶段静态化,更新时直接定位到具体 DOM 节点,跳过组件级 diff。代价是需要编译器配合、动态访问场景(如
obj[key])仍需运行时兜底。
- 要点:Proxy 拦截有运行时开销,且无法在打包时确定依赖;Signal 把依赖关系在编译阶段静态化,更新时直接定位到具体 DOM 节点,跳过组件级 diff。代价是需要编译器配合、动态访问场景(如
链 2 · 虚拟 DOM → diff → 编译期优化
- 面试官:为什么需要虚拟 DOM?
- 要点:把「状态变化」变成「可比较的树」,让框架用声明式方式批量更新,并跨平台(自定义渲染器);它不是为了快,而是为了「可预测 + 可跨端」。
- 追问:
key到底影响了什么?- 要点:决定同层节点的复用策略。用索引做 key,在插入/删除/排序时会把旧节点错误复用,导致输入框内容错位、动画错乱;正确做法是用稳定业务 ID。
- 再追问:编译期优化能做到什么程度?
- 要点:静态节点提升到渲染函数外、
patchFlag标记动态部分、事件缓存、Block Tree跳过静态子树。本质是把「运行时全量 diff」换成「编译时告诉运行时该看哪里」,但动态绑定多时收益下降。
- 要点:静态节点提升到渲染函数外、
链 3 · Hooks → 闭包陷阱 → 并发渲染
- 面试官:为什么
setTimeout里读到的是旧 state?- 要点:每次渲染都是一次函数调用,闭包捕获的是那一次渲染的
state快照;定时器引用的是旧闭包。修复方式:函数式更新、useRef保存最新值、或把依赖补进useEffect。
- 要点:每次渲染都是一次函数调用,闭包捕获的是那一次渲染的
- 追问:依赖数组为什么不能撒谎?
- 要点:React 用
Object.is逐项比较决定是否重跑 effect;漏依赖会用旧闭包,多依赖会频繁重跑。exhaustive-deps是编译期能查的那部分,函数依赖还需要useCallback稳定引用。
- 要点:React 用
- 再追问:并发渲染为什么会出现「撕裂」(tearing)?
- 要点:并发模式下渲染可被打断,若组件直接读外部可变 store,不同组件可能读到不同版本的数据。
useSyncExternalStore通过「渲染期快照 + 提交前校验」保证一致;这也是外部状态库必须适配的接口。
- 要点:并发模式下渲染可被打断,若组件直接读外部可变 store,不同组件可能读到不同版本的数据。
链 4 · 组件 API → 受控性 → 可访问性
- 面试官:设计一个
Select,你会怎么定 API?- 要点:先定「状态归谁」:受控用
value/onChange,非受控用defaultValue,用value !== undefined区分;再定组合结构(Trigger/Content/Item),最后补键盘与 ARIA,而不是先写样式。
- 要点:先定「状态归谁」:受控用
- 追问:受控与非受控混用会出什么问题?
- 要点:组件内部同时维护两份真相,外部更新与内部更新互相覆盖;受控时内部不应再
setState,非受控时才维护内部值,并在开发期警告模式切换。
- 要点:组件内部同时维护两份真相,外部更新与内部更新互相覆盖;受控时内部不应再
- 再追问:无样式(headless)组件为什么难写?
- 要点:难点不在样式,而在交互细节:焦点管理、
aria-activedescendant、输入法组合、滚动锁定、SSR 下的 ID 一致性;这些逻辑必须与渲染结构解耦,才能被不同设计体系复用。
- 要点:难点不在样式,而在交互细节:焦点管理、
链 5 · 框架选型 → 迁移 → 长期成本
- 面试官:团队要从 Vue 2 迁到 Vue 3(或 React 17 到 19),你怎么排期?
- 要点:先盘点阻塞项(废弃 API、依赖不兼容、构建链路),再做「可并行的准备」(升级构建、补测试、统一状态层),最后按路由/应用灰度迁移,保留回退。
- 追问:为什么不一次性重写?
- 要点:重写会把「迁移风险」和「需求变更」叠加,且很难验证行为等价;渐进迁移可以每步上线、每步回退,问题定位范围小。
- 再追问:怎么证明迁移值得?
- 要点:用可量化指标:包体、首屏、构建时长、缺陷率、招聘与维护成本;同时承认迁移的隐性收益(生态延续、安全补丁)难以直接量化。
8.3 手写题
题 1 · 50 行实现 useState / useEffect
/**
* 极简 hooks 运行时:用数组按调用顺序存放 hook。
* @param {() => any} renderFn 组件函数
*/
function createHooks(renderFn) {
/** @type {Array<Record<string, any>>} */
let hooks = [];
let cursor = 0;
let pending = [];
/** 状态 hook:返回当前值与更新函数 */
const useState = (initial) => {
const i = cursor++;
if (!(i in hooks)) {
hooks[i] = { state: typeof initial === 'function' ? initial() : initial };
}
const hook = hooks[i];
const setState = (next) => {
const value = typeof next === 'function' ? next(hook.state) : next;
if (Object.is(value, hook.state)) return;
hook.state = value;
schedule();
};
return [hook.state, setState];
};
/** 副作用 hook:依赖变化时登记,渲染后统一执行 */
const useEffect = (fn, deps) => {
const i = cursor++;
const prev = hooks[i]?.deps;
const changed = !prev || !deps || deps.some((d, k) => !Object.is(d, prev[k]));
if (changed) pending.push({ i, fn, deps });
};
/** 微任务合并多次 setState */
const schedule = () => {
if (schedule.queued) return;
schedule.queued = true;
queueMicrotask(() => {
schedule.queued = false;
run();
});
};
/** 执行一次渲染并提交副作用 */
const run = () => {
cursor = 0;
renderFn();
pending.splice(0).forEach(({ i, fn, deps }) => {
hooks[i]?.cleanup?.();
const cleanup = fn();
hooks[i] = { ...(hooks[i] || {}), deps, cleanup };
});
};
return { run, useState, useEffect };
}
追问:
- 为什么 hook 不能写在条件分支里?→ 依赖「调用顺序」定位存储槽,顺序一变就错位。
- 为什么
setState要比较Object.is?→ 相同值直接跳过,避免无意义渲染;但对象每次新建引用仍会触发。 - 清理函数什么时候执行?→ 下次 effect 重跑前与组件卸载时;上面的实现只做了前者,卸载需要额外的 unmount 流程。
题 2 · 极简 reactive + effect
/** 当前正在收集依赖的 effect */
let activeEffect = null;
/** target → key → Set<effect> */
const targetMap = new WeakMap();
/** 读取时收集依赖 */
function track(target, key) {
if (!activeEffect) return;
let depsMap = targetMap.get(target);
if (!depsMap) targetMap.set(target, (depsMap = new Map()));
let dep = depsMap.get(key);
if (!dep) depsMap.set(key, (dep = new Set()));
dep.add(activeEffect);
}
/** 写入时触发依赖 */
function trigger(target, key) {
const dep = targetMap.get(target)?.get(key);
if (!dep) return;
[...dep].forEach((effect) => effect());
}
/**
* 创建响应式对象(浅层)。
* @param {object} raw
*/
function reactive(raw) {
return new Proxy(raw, {
get(target, key, receiver) {
track(target, key);
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
const ok = Reflect.set(target, key, value, receiver);
trigger(target, key);
return ok;
},
});
}
/**
* 注册副作用,立即执行一次以完成依赖收集。
* @param {() => void} fn
* @returns {() => void} 停止函数
*/
function effect(fn) {
const runner = () => {
activeEffect = runner;
try {
fn();
} finally {
activeEffect = null;
}
};
runner();
return () => {
// 简化:真实实现需从所有 dep 中删除 runner
};
}
追问:
- 嵌套 effect 为什么会丢依赖?→ 内层执行后把
activeEffect置空,外层后续的读取就收集不到;要用栈保存上一层。 - 为什么
trigger里要复制一份 Set?→ 回调中可能新增/删除依赖,直接遍历会漏跑或死循环。 - 怎么避免重复触发?→ 依赖收集去重 + 调度队列合并,同一 tick 内多次写入只刷新一次。
8.4 场景设计题
题:一个 500 字段的订单编辑表单,输入明显卡顿。你怎么优化?
评估点:是否先判断「谁在重渲染」、是否区分输入路径与校验路径。
参考方案:
- 定位:React DevTools Profiler 录一次输入,看提交耗时与重渲染组件数;若整表重渲染,说明状态放在了顶层。
- 拆订阅:字段级状态(
useReducer+ 按字段订阅,或表单库的Controller),让一次输入只重渲染一个字段。 - 降优先级:输入框保持受控但用
useDeferredValue延迟「派生展示」(合计、预览),避免校验阻塞输入。 - 移出主线程:复杂校验(跨字段规则、金额计算)防抖 + 放 Worker,主线程只做同步格式校验。
- 验证:输入延迟(INP)、每次按键的渲染次数、提交耗时三项前后对比。
加分:指出受控组件在中文输入法下的 compositionstart/end 问题;指出「字段数组」要用稳定 key,否则删除行会串值。
减分:全部改非受控导致校验时机失控;给每个组件无脑 memo 而不解决状态位置;把校验写成每次按键全量跑。
题 2:设计系统组件库发布 v5,有 3 个破坏性变更,20 个业务应用依赖它。你怎么让升级不变成全公司阻塞?
评估点:是否把版本升级当「迁移项目」而不是「发版动作」。
参考方案:
- 分级变更:把 3 个变更拆成「可自动修复(codemod)」「需人工判断」「可长期兼容」三类;能兼容的先保留旧 API 并标记弃用。
- 双版本共存:新 major 以独立包版本发布,允许应用逐个升级;禁止在一个应用里混用两个版本(用 lint 检查)。
- 迁移工具:提供 codemod、升级检查清单、常见错误对照;在 CI 里对旧 API 报警告而非直接失败。
- 试点与节奏:先选 2 个应用试点,修复工具与文档;再按「低风险 → 核心」推进,每个应用可独立回退。
- 收尾:公布旧版本支持截止时间;统计版本分布与升级耗时;把迁移经验写进组件库规范。
加分:指出「破坏性变更不只是删 API」,默认值、DOM 结构、样式层叠变化都算;指出视觉回归与消费方冒烟是必需门禁。
减分:要求所有应用同一天升级;只发 changelog 不给工具;旧版本无限期维护导致双份成本。
8.5 最容易答错的点
| 常见说法 | 纠正 |
|---|---|
| 「虚拟 DOM 比直接操作 DOM 快」 | 单次更新通常更慢,它的价值是可预测与跨平台 |
「key 只是消除警告」 | 它决定节点复用,错误 key 会导致状态错位 |
「useEffect 等价于生命周期」 | 它是「同步副作用」,时机在绘制后,且按依赖重跑 |
「useMemo 一定提升性能」 | 依赖不稳定时反而增加比较开销,缓存也有内存成本 |
「setState 是异步的」 | 是「批量 + 调度」,事件外仍可能同步刷新 |
「v-if 和 v-show 只是性能差异」 | 前者销毁重建、后者切 display,影响状态与生命周期 |
| 「受控组件更「React」所以更好」 | 大表单或非 React 环境用非受控更合适,关键在统一 |
| 「并发渲染会让更新变快」 | 它改善的是响应性(可中断),不是吞吐 |
| 「受控组件一定优于非受控」 | 大表单、第三方集成用非受控更合适,关键是模式统一 |
| 「组件库升级只是改版本号」 | 默认值、DOM 结构、样式层叠变化都会破坏消费方 |
| 「无样式组件就是没有 CSS」 | 它的难点是交互、可访问性与状态管理,不是样式 |
9. 延伸资源
- React 官方文档(新版):尤其「Escape Hatches」与并发章节。
- 21 React 16 → 19 版本演进:逐版本差异、迁移路线与机制层解读(本库)。
- Vue 官方文档「深入」章节 +
packages/reactivity源码。 - 《Build your own React》:几百行理解 Fiber。
react-reconciler/createRenderer文档:自定义渲染器。- Radix UI / Headless UI / Ark UI 源码:组件 API 设计与可访问性范例。
- 工具:React DevTools Profiler、Vue DevTools、
why-did-you-render。
下一步练习:挑一个你写过的表单页,回答三个问题——有几个组件在「数据没变」时重渲染了?有没有用索引做 key?受控与非受控是否混用?