A

React 更新模型与调度:为什么不用 Proxy 的工程答案

2025-12-23 10:46

不是所有的框架都需要在“语言级语义”上拦截对象。React 选择了另一条路:把渲染函数视为纯函数,把“状态更新”视为任务,依靠队列、优先级与阶段化提交来实现稳定可控的 UI 驱动。

为什么 React 不用 Proxy/依赖收集

  • 渲染是纯函数:组件的 render(或函数组件的返回)只由输入 props/state 决定。数据读取发生在执行期,而不是语言拦截期。
  • 更新是显式的:setState/useState 把“状态变化”描述为一条更新,入队后统一调度,不需要拦截每一个属性读写。
  • 可中断与恢复:渲染可以被切片与暂停(Concurrent),这要求渲染过程可控、可分阶段,而非“读到就立即绑定依赖、写到就立即触发”。

结论:React 的工程核心不在“拦截对象”,而在“调度任务”。

React 的更新观:队列、批处理与阶段提交

  • 更新入队:调用 setState/useState 时,创建一条更新并放入队列。
  • 批处理:多个更新在同一轮中合并执行,避免重复渲染。
  • 阶段划分:
    • render 阶段:计算新的 UI 描述(虚拟树),比较差异(diff)
    • commit 阶段:一次性把差异应用到宿主(DOM),并执行副作用
  • 优先级:不同来源的更新(用户交互、网络数据)具有不同优先级,决定先后与是否可中断。

极简运行时:玩具 React(可运行)

JS
const TEXT = Symbol('text') function h(type, props, ...children) { return { type, props: props || {}, children: children.flat().map(c => typeof c === 'object' ? c : { type: TEXT, props: { nodeValue: String(c) }, children: [] }) } } function createDom(vnode) { const el = vnode.type === TEXT ? document.createTextNode('') : document.createElement(vnode.type) updateProps(el, {}, vnode.props) vnode.children.forEach(child => el.appendChild(createDom(child))) if (vnode.type === TEXT) el.nodeValue = vnode.props.nodeValue return el } function updateProps(el, prev, next) { Object.keys(prev).forEach(k => { if (!(k in next)) setProp(el, k, null) }) Object.keys(next).forEach(k => { if (prev[k] !== next[k]) setProp(el, k, next[k]) }) } function setProp(el, key, value) { if (key === 'nodeValue') { el.nodeValue = value } else if (key === 'className') { el.className = value || '' } else if (key.startsWith('on')) { const evt = key.slice(2).toLowerCase() if (value) el.addEventListener(evt, value) } else if (value == null || value === false) { el.removeAttribute(key) } else { el.setAttribute(key, value) } } function isSameType(a, b) { return a && b && a.type === b.type } function patch(parent, oldVNode, newVNode, index = 0) { const el = parent.childNodes[index] if (!oldVNode) { parent.appendChild(createDom(newVNode)) } else if (!newVNode) { parent.removeChild(el) } else if (!isSameType(oldVNode, newVNode)) { parent.replaceChild(createDom(newVNode), el) } else { // same type: update props and patch children updateProps(el, oldVNode.props, newVNode.props) // text node content if (newVNode.type === TEXT && oldVNode.props.nodeValue !== newVNode.props.nodeValue) { el.nodeValue = newVNode.props.nodeValue } const max = Math.max(oldVNode.children.length, newVNode.children.length) for (let i = 0; i < max; i++) { patch(el, oldVNode.children[i], newVNode.children[i], i) } } } let wipRoot = null let oldTree = null const updateQueue = new Set() let flushing = false function scheduleUpdate(component) { updateQueue.add(component) if (!flushing) { flushing = true Promise.resolve().then(() => { updateQueue.forEach(c => c._update()) updateQueue.clear() flushing = false }) } } function createApp(rootEl) { return { mount(Component) { const instance = { hooks: [], hookIndex: 0, _update() { instance.hookIndex = 0 const tree = Component() patch(rootEl, oldTree, tree) oldTree = tree } } currentInstance = instance instance._update() currentInstance = null } } } let currentInstance = null function useState(initial) { const inst = currentInstance const idx = inst.hookIndex++ if (inst.hooks[idx] === undefined) inst.hooks[idx] = initial const setState = (next) => { const value = typeof next === 'function' ? next(inst.hooks[idx]) : next if (value !== inst.hooks[idx]) { inst.hooks[idx] = value scheduleUpdate(inst) } } return [inst.hooks[idx], setState] } // 示例 const root = document.createElement('div') document.body.appendChild(root) function Counter() { const [count, setCount] = useState(0) const [text, setText] = useState('hello') return h('div', { className: 'p-4' }, h('h2', null, 'count: ', String(count)), h('button', { onClick: () => { setCount(c => c + 1); setCount(c => c + 1) } }, '++ (batched)'), h('input', { value: text, onInput: (e) => setText(e.target.value) }), h('p', null, 'text: ', text) ) } createApp(root).mount(Counter)

要点:

  • useState 使用“实例 + 索引”定位状态;setState 入队,并在微任务中批处理。
  • patch 是极简 diff:同类型更新、遍历子节点;commit 阶段一次性应用差异。
  • 示例中的双次 setCount 会被批处理合并在同一轮渲染中更新。

Hooks 的语义与约束

  • 调用顺序稳定:依赖“实例 + 索引”的模型,要求 hooks 在组件顶层、顺序一致。
  • 闭包与依赖:渲染是重新执行函数,闭包捕获的是旧值;useEffect 等需要依赖数组表达“何时重新执行”。
  • 调度的配合:在并发场景下,某些更新可以标记为“低优先级”,避免阻塞交互。

并发渲染与 Transition(概念)

  • 时间切片:把长任务拆分,让浏览器有机会处理更高优先级事件。
  • Transition:把非紧急更新降级,让交互流畅优先。

在本文的最小内核里未实现这些,但理解它们能帮助你把 React 看成“渲染调度器”,而不是“数据拦截器”。

与 Vue 响应式的互补关系

  • Vue 聚焦“对象的语言级行为”,拦截读写并建立依赖,适合在模型层做“行为防火墙”。
  • React 聚焦“渲染与调度”,让组件以纯函数的方式重建 UI,适合在视图层做“任务编排”。
  • 工程实践中,二者并不冲突:用 Proxy 做模型防御与日志,用 React 做视图优先级与任务切片。

小结

  • React 不使用 Proxy/依赖收集,是因为它选择了“更新入队、批处理、阶段提交”的工程路线。
  • 核心思路:把渲染看作纯函数,把状态变化当作任务,依靠调度保证一致性与体验。
  • 当你需要同时处理“数据语义边界”与“视图调度边界”时,记住二者的分工:模型层用语义,视图层用调度。

下一步可以在这个极简运行时上继续演示:批处理的边界、列表 key 的影响、和浏览器空闲时间的协作调度。