
一句话:React 不是「做不到」属性级响应式,而是「为了保住『组件级重渲染 + 渲染是纯函数 + 并发渲染』这个心智模型而主动放弃它」——而它的替代路线是「
useSyncExternalStore(订阅外部可变状态的正规入口)+ React Compiler(编译期达到类似的优化效果)」。
一、两种响应式模型:拉(pull)vs 推(push)
⭐⭐⭐ 先看清「两种模型的根本差异」:
【React 的模型:自上而下的「拉」】
状态变 → 「**状态所在的组件**」重渲染 → 「它的子树默认也重渲染」
· ⭐ 粒度:**组件**(「哪个组件需要重渲染」由「状态在哪个组件」决定)
· ⭐ 优化手段:`memo`(在组件边界上剪枝)、`useMemo`(缓存计算)、
状态下移、`children` 透传(⭐ 见 5.4)
· ⭐ 渲染的输入是「**props + state 的快照**」(一次渲染里它们不会变)
【Signals / 代理式的模型:细粒度的「推」】
signal 变 → 「**只更新依赖它的那段 UI**」(不需要「组件级 diff」)
· ⭐ 粒度:**「值 → 用到它的那段代码」**(⭐ 可以细到「一个文本节点」)
· ⭐ 不需要 memo(因为「没读它就不会被通知」)
· ⭐ 代表:Solid、Svelte 5(runes)、Vue 3.4+(signal 化的 ref)、Preact Signals、
MobX/Valtio(代理式,见 3.12)
⭐⭐⭐ 一个「直观对比」(同一个计数器):
【Signals 的写法】
const count = signal(0);
// ⭐ 「注意:下面这段「读取」只在编译后「订阅 count」,不「重渲染组件」」
<button onClick={() => count.value++}>点击 {count.value} 次</button>
⭐ 「count 变了」→ 「只更新那个文本节点」→ 连「onClick 闭包」都不用重建
【React 的写法】
const [count, setCount] = useState(0);
// ⚠️ 「count 变了」→ 「整个组件函数重跑」→ 重新创建「onClick 闭包」+ 重新 diff
⭐ 所以 Signals 的「优势」很直观:
· 不需要 memo/useMemo(⭐ 因为「不读就不订阅」)
· 「更新一个值」的代价「与「用到它的地方」成正比」(而不是「与组件大小」成正比)
· 「高频更新」(如动画、拖拽)天然更省
⭐ 那 React 为什么不改?—— 因为「这个优势」的代价「藏在并发渲染与心智模型里」。
二、React 坚持「组件级」的六个理由
⭐⭐⭐⭐ 理由 ①【「渲染是纯函数」是 React 的根基,而 signals 破坏它】
React 的核心假设:
· ⭐ 「**渲染的输入是「快照」(props/state)**」
· ⭐ 「**同样的输入 → 同样的输出**」(⭐ 所以「渲染可以被重放」)
· ⭐ 「**渲染中「读到的值」在这一轮不会变**」
⚠️ 而 signals 是「**活的值**」:
⭐ 如果「在渲染中途读 signal」,而它「在渲染被中断期间」变了 →
同一次渲染里「两次读同一个 signal」会得到**不同的值** →
⭐ **界面「撕裂」**(同一个页面里「两处显示不一致」)。
⭐ 这就是 React 官方一直强调的「**tearing(撕裂)**」问题。
⭐⭐⭐⭐ 理由 ②【并发渲染(可中断)与「活的值」天然冲突】
React 18 的并发渲染**允许**:
· ⭐ 「**中断一次渲染**」(让出主线程)
· ⭐ 「**丢弃并「用新优先级重来」**」
· ⭐ 「**渲染可能被执行多次**」
⭐ 这些能力「**都建立在「渲染是「一个纯函数在算一个快照」」上**」——
一旦引入「渲染中读可变值」→ 「两次渲染读到的值不同」→
「你没法「重放」它」→ ⭐ 「中断/丢弃」就变得「不安全」。
⭐⭐⭐ 理由 ③【`useSyncExternalStore` 是为「外部可变状态」留的正规口子】
⭐ React 知道「外部有可变状态」(Redux/Zustand/Valtio/DOM API)→
所以它提供了 **`useSyncExternalStore`**:
· 「订阅外部 store」
· ⭐ 「**在渲染中检查「快照是否还有效」**」(如果「变了」就「丢弃并重渲染」)
⭐ 这就是「**在「不破坏纯渲染」的前提下用可变状态**」的方式(见 11.13)。
⭐ 注意它的**代价**:「读外部状态要「走快照 + 一致性检查」」——
所以它「没有 signals 那么「细粒度」」,但「**换来了「并发安全」**」。
⭐ 换句话说:**React 不是「不支持可变状态」,而是「要求它通过一个「会做一致性检查」的入口进来」**。
⭐⭐⭐ 理由 ④【生态与兼容性:不能「把组件当「订阅单元」的假设全部推翻」】
如果引入「属性级订阅」→ 大量既有假设会「失效」:
· 「渲染是纯的」(大量库、测试、StrictMode 的双调用都依赖它)
· 「父组件渲染 = 子组件重渲染」(⭐ 大量「性能优化」都是「针对这个模型」写的)
· 「Hooks 的顺序稳定」
⚠️ 而且「14 年的生态」不可能「一夜换模型」——
⭐ 所以 React 的选择是「**在新模型之上做「加法」(Concurrent、Suspense、Compiler)而不是「替换模型」**」。
⭐⭐⭐ 理由 ⑤【调试与可预测性】
· ⭐ 「组件级重渲染」的调试很直观:React DevTools 能「高亮重渲染」+「Why did this render」(见 5.12)
· ⚠️ 「细粒度更新」的调试更难:你「不知道「这个更新是从哪来的」」
(⭐ 「依赖图」在运行时是隐式的)——这也是「Vue/Solid 的调试体验」常被讨论的原因
⭐⭐⭐ 理由 ⑥【「Compiler」是「保留心智模型」的解法】⭐
React 的立场是:
「**我们不想让开发者「手动管理订阅」,
而是「让编译器自动算出「哪些计算需要缓存」」**」
⭐ React Compiler(见 4.15)做的事正是:
· 「识别「哪些计算依赖响应式输入」」
· 「自动插入 `useMemoCache` 的读写」
⭐ 效果上「接近 signals 的「按需计算」」,但:
· ⭐ **心智模型不变**(还是「组件级重渲染 + 纯渲染」)
· ⭐ **并发渲染的假设不变**(因为「缓存的还是快照」)
⭐ 一句话:「**React 选择「用编译期优化」而不是「换响应式模型」**」。
三、Signals 的优势与代价(客观地说)
| 维度 | React(组件级 + Compiler) | Signals(细粒度) |
|---|---|---|
| 心智模型 | ⭐ 「UI = f(state)」+ 纯渲染(直观、统一) | 「值 → 依赖它的地方」(⭐ 「不需要 memo」,但「要理解「谁订阅了谁」」) |
| 「多余重渲染」 | ⚠️ 需要 memo/Compiler/结构优化 |
⭐ 天然没有(「不读就不订阅」) |
| 高频更新(动画/拖拽) | ⭐ 靠「transform/opacity 走合成」+ 「减少状态」(见 5.17) |
⭐ 天然省(「只改那个节点」) |
| 并发渲染 | ⭐ 一等公民(可中断/丢弃/优先级) | ⚠️ 「细粒度更新」与「可中断渲染」的交互更复杂(⭐ 各框架方案不同) |
| 调试 | ⭐ DevTools 成熟(高亮重渲染、Why did this render) | ⚠️ 「依赖图在运行时隐式」→ 调试更依赖工具 |
| 与「不可变数据流」的关系 | ⭐ 契合(快照 = 不可变) | ⚠️ 「可变值」是常态 → 与「时间旅行/撤销」等要额外设计 |
| 生态 | ⭐⭐ 最大 | ⭐ 各框架各自的生态 |
⭐⭐⭐ 「在 React 里用 Signals」的真实困难(以 Preact Signals 的 React 适配为例):
Preact Signals 提供了「在 React 里用 signal」的适配(`@preact/signals-react`),
它的机制是:
① 「在渲染期间「收集「读到的 signal」」」(类似「依赖追踪」)
② 「渲染后「订阅这些 signal」」→ 变了就「让组件重渲染」
⚠️ 但它必须**面对「并发渲染」**:
· 「渲染被中断」时,「收集到的依赖」是「半成品」
· 「signal 在中断期间变了」→ 「这次渲染已经「不一致」」
→ ⭐ 所以它「需要额外的机制」来「保证一致性」(⭐ 或「在不支持并发的模式下运行」)
⭐ 结论:「**细粒度响应式要想「与并发渲染共存」,需要额外的一大套工程**」
—— 这不是「signals 不好」,而是「**两者要融合的成本很高**」。
⭐ 所以「React 不上 signals」的准确表述是:
「**React 认为「保住『纯渲染 + 并发 + 生态』」的价值 > 「属性级订阅的收益」,
并且「Compiler 能在不换模型的前提下拿到大部分收益」**」。
四、那 React 的「性能问题」怎么解
⭐⭐⭐ 一张「对症下药」的表(⭐ 这是面试里「有实操经验」的体现):
| 症状 | 该用什么 | 备注 |
| --- | --- | --- |
| ⭐ 「改一个字段,整棵树重渲染」 | **状态下移 / 组件拆分 / `children` 透传** | ⭐ 结构性优化优于 memo(见 5.4) |
| 「内联函数/对象导致 memo 失效」 | 「稳定引用」(`useCallback`/`useMemo`/提到模块外) | ⭐ 或用 **Compiler 自动处理**(见 4.15) |
| ⭐ 「列表项很多,滚动卡」 | **虚拟滚动**(见 5.5)+ 「`content-visibility`」(见 5.17) | Signals 也救不了「DOM 太多」 |
| ⭐ 「高频变更(拖拽/鼠标)」 | **`transform`/`opacity`(走合成)** + 「不把高频值放进 React state」(⭐ 用 `ref` + 直接改 DOM,见 11.15) | ⭐ 这正是「signals 的优势场景」→ React 用「绕开 state」解决 |
| 「Context 一变全量重渲染」 | **拆分 Context / 用外部 store + 选择器**(见 5.9/11.13) | ⭐ `useSyncExternalStore` 是正规入口 |
| ⭐ 「大量派生计算」 | **`useMemo` 或 Compiler** | ⭐ MobX 的 `computed`/Jotai 的派生 atom 更省心(见 3.11/3.12) |
| 「首屏 JS 太大」 | 代码分割(见 5.6/5.14) | ⭐ Signals 无关 |
⭐ 一句话:**「React 用「结构 + 边界(memo/Compiler)+ 走合成(transform)」替代「属性级订阅」」**
—— 在很多场景里「效果接近」,而「心智模型与并发能力保住了」。
/** ⭐⭐ 「高频更新」在 React 里的正确姿势(⭐ 与 11.15 的拖拽实现一致) */
function DraggableBox() {
const ref = useRef<HTMLDivElement>(null);
// ⚠️ 「不要把「每帧都变的值」放进 state」
// const [pos, setPos] = useState({x: 0, y: 0}); // ❌ 每帧触发重渲染
useEffect(() => {
const el = ref.current;
if (!el) return;
let rafId = 0;
let latest = { x: 0, y: 0 };
const onMove = (e: PointerEvent) => {
latest = { x: e.clientX, y: e.clientY };
// ⭐⭐ 「用 rAF 节流 + 直接改 DOM」——
// ⭐ 「不经过 React 的渲染管线」(这就是「signals 优势场景」的 React 解法)
if (!rafId) {
rafId = requestAnimationFrame(() => {
rafId = 0;
el.style.transform = `translate3d(${latest.x}px, ${latest.y}px, 0)`;
});
}
};
window.addEventListener('pointermove', onMove, { passive: true });
return () => { window.removeEventListener('pointermove', onMove); cancelAnimationFrame(rafId); };
}, []);
return <div ref={ref} />;
}
五、面试怎么答(⭐ 这是本题的关键)
⭐⭐⭐ 一个「平衡、有深度」的答法(⭐ 四步):
① 【先承认 signals 的优势(⭐ 不要否定它)】
「细粒度响应式在「高频更新」「派生计算」上确实更省——
因为「不读就不订阅」,所以「不需要 memo」。」
② 【指出 React 的「不同目标」(⭐ 这是核心)】
「但 React 的目标不只是「渲染效率」,还包括:
· ⭐ **渲染是纯函数**(→ 「可中断、可丢弃、可重放」)
· ⭐ **并发渲染**(优先级、过渡、Suspense)
· ⭐ **生态兼容**(14 年的库与心智模型)
「属性级订阅」会「从根上改变「渲染的输入是不是快照」」——
所以 React 选择「保住这些,而不是换模型」。」
⭐ 并点出「撕裂(tearing)」这个具体问题(⭐ 这是最能体现理解的细节)。
③ 【说明 React 的「替代路线」】⭐
「React 的解法是两条:
· ⭐ **`useSyncExternalStore`**:让「外部可变状态」通过「一个会做一致性检查的入口」进来(⭐ 见 Zustand/Valtio 的实现)
· ⭐ **React Compiler**:在「保留心智模型」的前提下,用「编译期自动记忆化」拿到大部分收益(⭐ 见 4.15)」
④ 【给出「什么时候该考虑别的方案」】⭐
「如果项目「极度依赖高频细粒度更新」(如「复杂可视化/画布/实时协作」)
→ 那些场景更该用「专门的技术」:
· 「Canvas/WebGL」而不是「DOM」
· 「`transform` 走合成」而不是「每帧 setState」
· 「外部 store + 选择器」而不是「context」
⭐ 也就是「**不是「换框架」,而是「换「数据经过 React 的深度」」**」。
⭐ 三个「不要」:
❌ 「signals 就是比 React 快」(⭐ 太简化 —— 「不同场景不同结论」)
❌ 「React 技术落后」(⭐ 它的「并发渲染」是「signals 也还没完全解决」的领域)
❌ 「用 Preact Signals 就能「让 React 变快」」(⚠️ 「并发渲染下的行为」要「实测验证」)
六、本篇特有的坑(表达层面的)
① 「把「React 不做 signals」说成「React 做不到」」
⭐ 不准确:React 有 `useSyncExternalStore`,能做「外部可变状态订阅」;
它「不做」是「价值权衡」,不是「能力缺失」。
② 「忽略「撕裂(tearing)」这个技术理由」(⭐ 只谈「生态/习惯」会显得浅)
⭐ 「撕裂」是「技术上最硬的理由」:⭐ 「渲染中途可变值变化 → 同一次渲染不一致」。
③ 「把「signals」与「不可变」对立起来」(⭐ 它们不是「必须二选一」)
⭐ 很多 signals 实现「也能表达不可变数据流」(⭐ 只是「默认是可变」)
④ 「用「Vue 也很快」来论证「signals 一定更好」」
⭐ 反例:「Vue 3 的「编译期优化」也是重要收益来源」(⭐ 与 React Compiler 是「同一个方向」)
⑤ 「忽略「React Compiler」这条路线」(⭐ 这是 React 对 signals 的「正面回应」)
⑥ 「认为「signals 就不需要优化了」」
⚠️ 「DOM 太多」「包太大」「合成层」这些问题「signals 也救不了」(见 5.5/5.17)
⑦ 「在「需要细粒度」的场景硬用 React state」(⭐ 应该「绕开 React」)
⭐ 做法:「`ref` + 直接改 DOM/样式」「Canvas」「外部 store」(见 11.15 的拖拽)
⑧ ⭐ 「忽略「并发渲染下 signals 适配的复杂度」」
⭐ 「Preact Signals 的 React 适配」需要「额外的一致性机制」——
这正说明「融合两者的成本很高」(⭐ 这是「有深度」的点)
⑨ 「把「React Compiler」说成「能解决一切性能问题」」
⚠️ 它只管「组件内部记忆化」(见 4.15 的边界):
Context 粒度、DOM 数量、包体积、长任务它都管不了。
⑩ 「忽略「不同应用的『痛』不一样」」
⭐ 「后台系统的痛」往往是「表格太大/请求太多」;
「可视化应用的痛」往往是「高频更新」——
⭐ 「前者 React 完全够,后者应该换「渲染方式」而不是「换框架」」。
七、面试延伸
- 「React 为什么不用 Proxy / 不做细粒度响应式?」
⭐ 三个层次回答:① 技术上最硬的理由是「撕裂(tearing)」——React 的核心假设是「渲染的输入是快照」(同一轮渲染里读到的值不会变,⭐ 所以「渲染是纯函数、可重放」);而 Proxy/Signals 是「活的值」→ 如果「在渲染中途读它」,而它「在渲染被中断期间」变了 → 同一次渲染里两处读到不同值 → 界面不一致;② ⭐ 并发渲染的假设——React 18 允许「中断、丢弃、用新优先级重来」,这些都建立在「渲染是「纯函数在算一个快照」」上,引入可变值会让「重放」不安全;③ ⭐ 生态与心智模型——「组件级重渲染 + 纯渲染」是 14 年生态与大量性能优化的基础,React 选择「在新模型上做加法」而不是「替换模型」。 -
「那 React 怎么处理『外部可变状态』?」
⭐ 通过useSyncExternalStore这个「正规入口」:它让组件「订阅外部 store」,并且 React 会「在渲染中检查『快照是否还有效』」——如果「渲染期间外部状态变了」,React 就「丢弃这次渲染并重渲染」→ ⭐ 在「不破坏纯渲染」的前提下用可变状态(见 11.13 的 Zustand 实现)。它的代价是「读外部状态要走『快照 + 一致性检查』」,所以没有 signals 那么细粒度——但换来了「并发安全」。⭐ 一句话:「React 不是不支持可变状态,而是要求它「通过一个会做一致性检查的入口进来」」。 -
「Signals 的优势是什么?React 怎么达到类似效果?」
Signals 的优势:⭐ 「不读就不订阅」→ 「更新一个值」的代价「与「用到它的地方」成正比」(而不是「与组件大小」成正比)→ 不需要memo/useMemo,且「高频更新」天然更省。React 的对应手段:① ⭐memo+ 稳定引用(在组件边界上剪枝);② ⭐ React Compiler(编译期自动记忆化——「识别哪些计算依赖响应式输入」并「自动插入useMemoCache」,⭐ 效果接近 signals 的「按需计算」但心智模型不变,见 4.15);③ ⭐ 结构性优化(状态下移、组件拆分、children透传);④ ⭐ 绕开 React(对「高频更新」用「ref+ 直接改 DOM/样式」或「Canvas」,见 11.15)。⭐ 一句话:「React 用「边界(memo/Compiler)+ 结构 + 走合成」替代「属性级订阅」」。 -
「什么是『撕裂(tearing)』?」
它是「同一次渲染里,同一个值被读到不同的版本」的现象。怎么发生:① 渲染是「可中断的」(并发渲染)→ 「渲染进行到一半,让出主线程」;② ⭐ 在让出期间,外部状态(如 signal/可变 store)变了;③ 渲染继续执行时读到了「新值」——⭐ 于是「同一个界面的上半部分显示旧值、下半部分显示新值」(视觉上「撕裂」)。为什么 React 特别在意:⭐ 因为「并发渲染」是 React 的一等特性,而「撕裂」正是「可中断渲染」带来的新问题——所以 React 要求「外部状态必须通过useSyncExternalStore进入」(⭐ 它会「在渲染中检查快照一致性」,不一致就「丢弃重来」)。 -
「在 React 里用 Signals(如 Preact Signals)有什么坑?」
⚠️ 核心困难是「与并发渲染的交互」:① 「渲染被中断」时「收集到的 signal 依赖是『半成品』」;② ⭐ 「signal 在中断期间变了」→ 「这次渲染已经不一致」→ 需要额外的一致性机制(或「在「不支持并发的模式」下运行」)。其他:③ 调试更难(「依赖图在运行时隐式」→ 「不知道更新从哪来」);④ 与「不可变数据流」的协作(⭐ 「时间旅行/撤销」要额外设计);⑤ 生态与版本耦合(适配层要跟随 React 版本更新)。⭐ 结论:「这不是「signals 不好」,而是「细粒度响应式要与并发渲染共存,需要额外的一大套工程」」——所以「选它就要接受这个复杂度」(⭐ 并在「真的需要的场景」用,而不是「为了快一点」)。 -
「如果 React 真的引入 signals,会破坏什么?」
五件事:① ⭐ 「渲染是纯函数」——「大量库、测试、StrictMode 双调用」都依赖它(⭐ 一旦「渲染中读可变值」,「重放」就不再安全);② ⭐ 「并发渲染的能力」——「中断/丢弃/优先级」都需要「渲染的输入是快照」;③ 「父组件渲染 = 子组件默认重渲染」——⚠️ 大量既有性能优化(memo的用法、children透传技巧)都是针对这个模型写的,模型一变它们「要么失效、要么语义变化」;④ 「Hooks 的顺序稳定性」与「闭包语义」(如「事件处理里的 state 是快照」);⑤ ⭐ 「14 年的生态」不可能一夜迁移。⭐ 所以 React 的选择是「加法而不是替换」:useSyncExternalStore(给外部状态留口子)+ Concurrent/Suspense(调度能力)+ Compiler(自动优化)。 -
「什么情况下你会建议『不用 React』(或不用 React 管这块)?」
⭐ 不是「换框架」,而是「换数据经过 React 的深度」——四种情况:① ⭐ 「极端高频的细粒度更新」(如「复杂可视化、实时协作光标、画布编辑器」)→ ✅ 用Canvas/WebGL而不是 DOM(⭐ React 只负责「外壳」,内部用「命令式渲染」,⭐ 很多图表库就是这么做的);② ⭐ 「拖拽/鼠标跟随这类高频交互」→ ✅ 「ref+ 直接改transform」(⭐ 不经过 React state,见 11.15);③ ⭐ 「海量 DOM」(十万行表格)→ ✅ 虚拟滚动(见 5.5);④ ⭐ 「重 CPU 计算」→ ✅ Web Worker(见 8.10)。⭐ 判据:「这个问题是「React 的渲染模型导致的」,还是「DOM/CPU/网络本身的问题」」——只有前者才该动 React 的用法;后者「换框架也一样」。
一句话速记
React 不上 Proxy/Signals 不是「做不到」而是「价值权衡」——⭐ 技术上最硬的理由是「撕裂(tearing)」:React 假设「渲染的输入是快照」(所以「渲染是纯函数、可重放」),而 signals 是「活的值」→ 「渲染被中断期间值变了」会导致「同一次渲染里两处显示不一致」;⭐ 加上并发渲染的需要(「中断/丢弃/用新优先级重来」都建立在「渲染是在算一个快照」上)+ 14 年生态与心智模型(「组件级重渲染 + 纯渲染」是大量优化的基础);⭐ React 的替代路线是两条:①
useSyncExternalStore——让「外部可变状态」通过「一个会在渲染中做一致性检查的入口」进来(⭐ 代价是「没有属性级细粒度,但换来了并发安全」);② React Compiler——在「保留心智模型」的前提下用「编译期自动记忆化」拿到大部分收益;⭐ 「signals 的优势场景」(高频更新/派生计算)在 React 里的解法是「绕开 React 的深度」:结构优化(状态下移/拆分/children)→memo/Compiler →transform走合成 → 「ref+ 直接改 DOM」→ Canvas/WebGL → Worker;⚠️ 在 React 里用 signals 的真实困难是「与并发渲染共存」(依赖收集在中断时是半成品 + 需要额外一致性机制)——⭐ 这正说明「融合两者的成本很高」;⭐ 面试答法要平衡:先承认 signals 的优势 → 再指出 React 的不同目标(纯渲染/并发/生态)并点出「撕裂」这个技术细节 → 说明 React 的两条替代路线 → 最后给「什么时候该换「数据经过 React 的深度」而不是换框架」。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!