06

框架与组件化

1,344 行约 66 分钟41 个代码块22 张表格更新于 2026-11-03

06 · 框架与组件化(React / Vue,详解版)

一句话定位:框架替你做了三件事——把数据变化映射成 UI 更新、把 UI 拆成可复用的组件、把副作用管理起来。理解它怎么做到这三件事,比记 API 重要得多。

读完本篇你应该能做到

  1. 解释「为什么这次渲染会发生」,并知道怎么减少不必要的渲染;
  2. 独立设计一个组件的 API(受控/非受控、组合、可访问性);
  3. 判断什么时候该用框架、什么时候该绕过框架。

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>

单向数据流的三条规则:

  1. 父传子用 props,子不能直接改 props(Vue 会警告,React 直接冻结语义)。
  2. 子要通知父,用回调/事件(onChange / emit)。
  3. 同层级共享状态,提升到共同父级(状态提升)。

为什么不能双向?因为「谁都可以改」意味着「谁都不知道值什么时候变的」,调试成本会随组件数指数上升。

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):

  1. 只能在组件或自定义 Hook 顶层调用(不能放在 if/for/嵌套函数里)。
  2. 顺序必须稳定(React 靠调用顺序匹配状态)。
  3. 依赖数组要诚实(漏写会拿到旧值,多写会重复执行)。

1.4 样式方案

方案写法优点缺点
CSS Modulesimport s from './x.module.css'局部作用域、零运行时动态样式需拼类名
TailwindclassName="p-4 text-sm"快速、约束强类名长、可读性争议
CSS-in-JSstyled.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>
  );
}

可访问性的四条底线:

  1. 能用原生元素就不用 div(button 自带键盘与焦点行为);
  2. 所有交互都能用键盘完成,且有可见焦点;
  3. 状态要能被读屏感知(aria-expanded、aria-selected、aria-live);
  4. 表单控件必须有可关联的 label,错误信息要能被播报。
反模式问题修复
<div onClick>不可聚焦、无键盘触发改用 button,或补 role/tabIndex/onKeyDown
aria-label 与可见文本不一致读屏与视觉用户听到的不一样以可见文本为准,必要时用 aria-describedby
只用颜色表达状态色觉障碍用户无法区分同时用图标/文字/形状
弹窗不锁焦点Tab 会跑到背景内容焦点陷阱 + inert + 关闭后归还焦点

1.8 L1 小结与自检

知识点最该记住的一句
单向数据流props 下行、事件上行、共享状态提升
key用稳定 ID,不要用索引
Hooks顶层调用、顺序稳定、依赖诚实
表单需要实时联动用受控,大表单用非受控
通信能用 props 就别上 Context

自检题

  1. 为什么列表 key 不能用数组索引?——插入、删除或排序后索引会重新分配,旧节点被错误复用,导致输入内容、内部 state 与动画跟错行;应使用稳定业务 ID。
  2. v-if 和 v-show 分别适合什么场景?——v-if 用于条件不常变、需要懒加载或销毁释放的场景;v-show 用于频繁切换且要保留 DOM 与状态的场景,代价是首屏就渲染。
  3. useEffect 依赖数组写空会发生什么?——只在挂载后执行一次,闭包捕获的是首次渲染的 props/state,后续更新读不到;需要最新值用 ref 或补齐依赖。
  4. 为什么复合组件通常需要 Context,而普通布局组件不需要?——复合组件(Tabs、Select)的多个子组件要共享同一份选中/展开状态,Context 避免逐层透传;纯布局组件没有共享状态,用 children 就够。

2. L2 进阶:更新机制与性能

2.1 React 的协调(Reconciliation)

React 更新分两步:

  1. Render 阶段:调用组件函数,生成新的虚拟 DOM(可被打断)。
  2. Commit 阶段:对比新旧树,把差异应用到真实 DOM(不可打断)。

diff 的三条假设(让它从 O(n³) 降到 O(n)):

  1. 不同类型的元素产生不同树(div → span 直接销毁重建)。
  2. 同层比较,不跨层级移动(跨层移动会被当作「删除 + 新建」)。
  3. 用 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 的根本差异:

维度ReactVue
更新粒度组件级(重新执行函数)依赖级(精确到属性)
数据变更检测显式 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 逻辑复用:三种模式的演进

模式写法问题
高阶组件 HOCwithAuth(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. 只对「耗时长且频繁」的组件做优化
症状真正原因修复验证
输入框每敲一个字整页卡状态放在顶层,整棵树重渲染状态下沉到输入组件,或 useDeferredValueProfiler 中提交时间下降
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 区分,别混用

自检题

  1. useCallback 什么时候是多余的?——函数不传给 memo 子组件、不作为其他 hook 的依赖、或依赖本身每次都变时,缓存没有收益还多一次比较与内存开销。
  2. React 中「闭包陷阱」的三种典型场景与解法?——定时器/订阅回调读旧 state(用 ref 或函数式更新);连续 setState 读旧值(改 setX(x => ...));异步请求回来用旧 props(补依赖并在清理时取消)。
  3. 为什么 memo 有时不生效?——props 每次都是新引用(对象、数组、内联函数)、context 变化、组件自身 state 更新,或自定义比较函数漏字段;需要配合 useMemo / useCallback 稳定引用。
  4. 为什么「状态下沉」比加 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 */),
  ]);
}

三个优化:

  1. 静态提升:不会变的部分只创建一次。
  2. Patch Flag:标记「这个节点只会变文本」,diff 时直接跳过结构比较。
  3. Block Tree:收集所有动态节点,更新时只遍历动态节点(而不是整棵树)。

Vue Vapor Mode(实验性)进一步去掉虚拟 DOM,直接生成 DOM 操作代码。

3.3 渲染模型对比:VDOM / 细粒度响应式 / Signal

模型代表更新粒度优点缺点
虚拟 DOMReact、Vue(默认)组件/节点级心智简单、跨端容易diff 有成本、需要手动优化
细粒度响应式Vue 3、Solid属性级精准更新依赖追踪有内存成本
SignalSolid、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) 的六类诱因:

  1. 使用 Date.now()、Math.random() 渲染内容;
  2. 服务端与客户端环境不同(window 判断、时区、语言);
  3. 无效的 HTML 嵌套被浏览器修正(见 01);
  4. 服务端与客户端数据快照不一致;
  5. 条件渲染依赖客户端独有状态(localStorage);
  6. 第三方组件在挂载时立即改 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 速度,只改变渲染调度

四个容易误解的点:

  1. startTransition 不会减少总工作量,只是把工作拆开、让高优先级更新插队;
  2. useDeferredValue 不是防抖/节流,请求仍可能按每次输入发出,需要另外做请求层控制;
  3. 并发渲染要求组件是纯的:渲染期不能有副作用,否则中断/重放会出问题;
  4. StrictMode 下双调用是刻意为之,用来暴露不纯的渲染逻辑,不要因此去掉 StrictMode。

Vue 的路径不同:细粒度响应式本身就把更新范围缩到最小,通常不需要「并发调度」;它的 Suspense 主要解决异步依赖的等待编排。跨框架比较时,要区分「减少工作量」与「调整工作优先级」两件事。

3.10 L3 小结与自检

  1. useTransition 解决什么问题?它和防抖有什么区别?——把更新标记为非紧急、可中断,保证输入等紧急更新优先渲染;防抖是延迟执行减少次数,不改变渲染优先级,两者可以叠加。
  2. 水合为什么贵?流式 SSR + Suspense 如何缓解?——要下载并执行 JS、复用 DOM 绑定事件、恢复状态并校验一致性,成本随组件树增长;流式 SSR 先发壳、Suspense 分块补内容,让首屏区域先可交互。
  3. 设计一个 Select,如何同时满足可访问性、受控性、异步加载与虚拟滚动?——语义用 combobox + listbox + aria-activedescendant;同时支持 value/onChange 与 defaultValue;异步做防抖、取消与 loading/empty/error 三态;虚拟滚动保持固定行高并用 aria-setsize / aria-posinset 补语义。
  4. 并发渲染下,组件为什么必须保持「纯」?——渲染可能被中断、丢弃或重放,若渲染期有副作用(改全局变量、发请求、写 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}。`
  );
}

发布前必须做的四件事:

  1. 视觉回归:对核心组件截图对比,捕捉样式与结构变化;
  2. 消费方冒烟:至少挑 2 个真实业务跑一遍;
  3. 多版本共存:允许旧版本继续被依赖,避免「升级一个组件库导致全公司阻塞」;
  4. 可回退:新版本以可选依赖引入,出问题能锁回旧版本。

治理指标:版本分布(多少应用还在旧 major)、升级耗时、弃用 API 调用量、视觉回归失败率。

4.8 L4 自检

  1. 公司要在 React 与 Vue 之间统一技术栈,评估框架与迁移路线?——按团队能力、生态(SSR、跨端、组件库)、招聘供给与迁移成本打分;路线先统一构建与规范,再用微前端或路由级替换渐进迁移,不做一次性重写。
  2. 300 人团队,组件库该由谁维护?如何防止重复造轮子?——由平台或设计中台 owning,业务提需求并可贡献;用 RFC 与设计评审做准入,度量复用率与重复实现数,禁止私建同名组件。
  3. 什么时候你会决定「不用框架」?——页面以静态内容为主、交互极少、体积极度敏感(营销页、嵌入页),或团队无力跟进依赖升级时,原生加少量工具更稳、更省。
  4. 组件库升级到新 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 完成。
  • 再追问:Proxy 的依赖收集有什么代价?编译期方案(Signal)为什么更快?
    • 要点:Proxy 拦截有运行时开销,且无法在打包时确定依赖;Signal 把依赖关系在编译阶段静态化,更新时直接定位到具体 DOM 节点,跳过组件级 diff。代价是需要编译器配合、动态访问场景(如 obj[key])仍需运行时兜底。

链 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 稳定引用。
  • 再追问:并发渲染为什么会出现「撕裂」(tearing)?
    • 要点:并发模式下渲染可被打断,若组件直接读外部可变 store,不同组件可能读到不同版本的数据。useSyncExternalStore 通过「渲染期快照 + 提交前校验」保证一致;这也是外部状态库必须适配的接口。

链 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 字段的订单编辑表单,输入明显卡顿。你怎么优化?

评估点:是否先判断「谁在重渲染」、是否区分输入路径与校验路径。

参考方案:

  1. 定位:React DevTools Profiler 录一次输入,看提交耗时与重渲染组件数;若整表重渲染,说明状态放在了顶层。
  2. 拆订阅:字段级状态(useReducer + 按字段订阅,或表单库的 Controller),让一次输入只重渲染一个字段。
  3. 降优先级:输入框保持受控但用 useDeferredValue 延迟「派生展示」(合计、预览),避免校验阻塞输入。
  4. 移出主线程:复杂校验(跨字段规则、金额计算)防抖 + 放 Worker,主线程只做同步格式校验。
  5. 验证:输入延迟(INP)、每次按键的渲染次数、提交耗时三项前后对比。

加分:指出受控组件在中文输入法下的 compositionstart/end 问题;指出「字段数组」要用稳定 key,否则删除行会串值。

减分:全部改非受控导致校验时机失控;给每个组件无脑 memo 而不解决状态位置;把校验写成每次按键全量跑。

题 2:设计系统组件库发布 v5,有 3 个破坏性变更,20 个业务应用依赖它。你怎么让升级不变成全公司阻塞?

评估点:是否把版本升级当「迁移项目」而不是「发版动作」。

参考方案:

  1. 分级变更:把 3 个变更拆成「可自动修复(codemod)」「需人工判断」「可长期兼容」三类;能兼容的先保留旧 API 并标记弃用。
  2. 双版本共存:新 major 以独立包版本发布,允许应用逐个升级;禁止在一个应用里混用两个版本(用 lint 检查)。
  3. 迁移工具:提供 codemod、升级检查清单、常见错误对照;在 CI 里对旧 API 报警告而非直接失败。
  4. 试点与节奏:先选 2 个应用试点,修复工具与文档;再按「低风险 → 核心」推进,每个应用可独立回退。
  5. 收尾:公布旧版本支持截止时间;统计版本分布与升级耗时;把迁移经验写进组件库规范。

加分:指出「破坏性变更不只是删 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?受控与非受控是否混用?