手写一个500行响应式渲染引擎:拆解Vue与React的状态更新原理

发布时间:2026/10/21 5:01:19

手写一个500行响应式渲染引擎:拆解Vue与React的状态更新原理 老实说第一次看到rea这个名字的时候我脑子里闪过的是 React、Reactive、Real 这些词。等我把这个三字母项目跑起来之后才发现它完全可以有自己的定义一个约 500 行的轻量级响应式渲染引擎。rea不是什么大厂出品也不是要替代主流框架它就是我用来拆解响应式框架到底是怎么回事的一个迷你实现。如果你也好奇 Vue 里的reactive为什么能自动更新、React 的重渲染为什么发生在状态变化之后或者你在做一些不需要引入完整框架的小页面那这篇文章应该能给你一个很清晰的参考。1. 项目定位为什么手搓一个响应式引擎1.1 从黑盒调用到亲手拆开我在很长一段时间里都是用框架写业务useState、ref、reactive这些 API 用得滚瓜烂熟但某天同事问我为什么 setState 之后 DOM 会变我突然发现自己只能答出框架帮我做了 diff再往下一层就说不清了。这种感觉很糟糕。所以我决定亲手写一个最小的响应式渲染引擎也就是rea。rea的核心目标不是做出一个生产级框架而是把状态变化之后界面如何自动更新这条链路完整走通。它至少要覆盖三件事第一状态对象能被跟踪也就是响应式第二渲染函数能根据状态重新执行第三更新过程不要太浪费最好只做必要的 DOM 操作。这三件事拆开看都不复杂但串在一起就涉及依赖收集、调度器、渲染 diff 这些经典话题每一项都能单独展开好几篇文章。1.2 设计范围只保留主干砍掉枝叶动手之前我先给自己划了边界。rea不打算支持虚拟 DOM 的全量 diff也不打算实现组件生命周期、插槽、KeepAlive 这些能力。它只做四件事reactive用 Proxy 把普通对象变成响应式对象effect执行一个函数并让它自动跟踪依赖schedule把多次状态变化合并成一次更新避免重复渲染h / patch极简的渲染函数和 DOM 更新逻辑我把这四个能力组合起来就得到一个可以用的组件化方案每个组件是 setup 加 render状态变化时只有对应组件重渲染。这个粒度介于 React 整体刷新和 Vue 的响应式依赖之间好处是代码量可控而且理解起来非常直观。1.3 技术选型背后的理由为什么用 Proxy 而不是Object.defineProperty这是我在设计时第一个确定的决定。Vue 2 的defineProperty需要遍历对象属性逐个拦截新增属性和数组索引变化都是天生的漏洞需要额外补丁才能覆盖。Proxy 可以直接代理整个对象get 和 set 的拦截天然支持新增属性、删除属性、数组下标操作实现成本低很多。依赖收集方面我采用了简单的 track/trigger 模型。track负责在读取状态时把当前 effect 登记进依赖表trigger负责在状态变化时调用依赖表里的 effect。这种模式比定时器扫描或脏检查都更精确——只有真正被读取过的状态才可能触发更新读都没读过的状态改一百次也不会影响任何渲染函数。调度器则用微任务批量处理。同一轮事件循环内多次修改状态只会把 effect 加入待执行队列并计划一次 flush最终保证一帧内最多渲染一次。这一步直接决定了更新的性能表现后面我会给出完整实现。2. 环境准备与项目骨架2.1 初始化项目rea的运行时不依赖任何第三方库所以环境搭建非常简单。我用 TypeScript 写核心代码用 Vite 当开发服务器仅此而已。初始化命令如下mkdir rea cd rea npm init -y npm install -D typescript vite npx tsc --initpackage.json里只需要两个脚本{ scripts: { dev: vite, build: tsc vite build } }tsconfig.json需要保证target是ES2020以上因为 Proxy 和 WeakMap 都是标准 ES6 能力但某些新语法比如可选链、空值合并需要更高 target。我的配置大致是这样{ compilerOptions: { target: ES2020, module: ESNext, moduleResolution: bundler, strict: true, types: [vite/client] } }2.2 目录结构项目结构上我刻意保持了模块化拆分让每个核心概念都有独立文件这样排查问题的时候不用在一个大文件里翻来翻去rea/ ├── index.html ├── package.json └── src/ ├── core/ │ ├── dep.ts # 依赖收集track / trigger / effect │ ├── reactivity.ts # reactive 实现基于 Proxy │ └── scheduler.ts # 批量调度与 nextTick ├── runtime/ │ ├── h.ts # 创建 VNode │ ├── dom.ts # 创建真实 DOM属性绑定 │ └── patch.ts # 新旧 VNode 对比更新 ├── app.ts # createApp 组件入口 └── examples/ ├── counter.ts └── todos.ts对于核心模块我的职责划分很简单dep.ts只负责谁依赖了谁的关系管理reactivity.ts把对象读取操作映射到 track把写入操作映射到 triggerscheduler.ts负责决定 effect 什么时候执行。渲染层完全不知道响应式是怎么实现的它只接收一个新的 VNode然后想办法更新 DOM。这种分层让整个项目非常清爽。2.3 模块职责速览模块文件核心职责响应式reactivity.ts用 Proxy 包装对象get 时 trackset 时 trigger依赖表dep.ts维护 target - key - effects 的三层映射调度器scheduler.ts合并同轮更新微任务批量执行提供 nextTick渲染函数h.ts创建轻量 VNode 对象DOM 操作dom.ts / patch.ts创建元素、绑定事件、对比属性与子节点组件入口app.ts把 setup render 串联注册自动更新这个表格基本上就是整个rea的设计地图后面每一节内容都能在这张表里找到位置。如果你要自己抄作业按这个结构抄不会有大的偏差。3. 核心实现依赖收集与响应式状态3.1 reactive用 Proxy 接管对象读写reactive的实现思路非常直接将传入对象包装成一个 Proxy在 get 拦截器里执行 track在 set 拦截器里执行 trigger。但有几个细节值得展开。第一get 拦截器返回的是一个嵌套对象时需要递归地把它也变成响应式否则只有最外层对象是代理内部对象修改时不会触发任何通知。第二同一个原始对象不应该每次reactive调用都生成新代理否则会存在多个代理同时指向一个对象状态更新时可能各触发各的破坏一致性。我的实现如下// src/core/reactivity.ts import { track, trigger } from ./dep; import { isObject } from ../shared/utils; const proxyMap new WeakMapobject, any(); export function reactiveT extends object(target: T): T { if (proxyMap.has(target)) { return proxyMap.get(target); } const proxy new Proxy(target, { get(obj, key, receiver) { const value Reflect.get(obj, key, receiver); track(obj, key); if (isObject(value)) { return reactive(value); } return value; }, set(obj, key, value, receiver) { const result Reflect.set(obj, key, value, receiver); trigger(obj, key); return result; }, }); proxyMap.set(target, proxy); return proxy; }有一个地方容易踩坑get 拦截器里 track 放在Reflect.get之后。有些人习惯先 track 再取值但这样依赖表里记录到的可能是 undefined 或者报错尤其是当属性是访问器时getter 内部可能产生副作用。先取值后登记能保证即使 getter 抛错也不会污染依赖表。另外proxyMap用 WeakMap 而不是 Map是为了避免原始对象长期被引用导致无法被垃圾回收。这个是小型库容易忽略的点但在长时间运行的应用中泄漏问题会逐渐放大。3.2 依赖表的数据结构与 track/trigger依赖表是整个响应式系统的核心。它要回答的问题是某个对象的某个属性被哪些 effect 读取过。我采用了三层嵌套的数据结构第一层WeakMapobject, DepsMap以原始对象为键第二层Mapstring | symbol, SetEffect以属性名为键第三层SetEffect存放所有读取过该属性的 effect为什么依赖集合用Set因为同一个 effect 在一次渲染中可能多次读取同一个属性比如state.a state.a如果去重第一次读取就注册一次第二次读取不应该让 effect 在集合里出现两次。用 Set 天然去重后面遍历执行时也不会重复触发。track 和 trigger 的关键代码// src/core/dep.ts import { schedule } from ./scheduler; export type Effect () void; const targetMap new WeakMapobject, Mapstring | symbol, SetEffect(); const effectStack: Effect[] []; function currentEffect(): Effect | null { return effectStack.length 0 ? effectStack[effectStack.length - 1] : null; } export function track(target: object, key: string | symbol) { const effect currentEffect(); if (!effect) return; let depsMap targetMap.get(target); if (!depsMap) { depsMap new Map(); targetMap.set(target, depsMap); } let deps depsMap.get(key); if (!deps) { deps new Set(); depsMap.set(key, deps); } deps.add(effect); } export function trigger(target: object, key: string | symbol) { const depsMap targetMap.get(target); if (!depsMap) return; const deps depsMap.get(key); if (deps) { const effects Array.from(deps); effects.forEach((effect) schedule(effect)); } }可以这么理解track是订阅动作。effect 在读取属性时就像填写了一张订阅单系统记录下这个 effect 关心这个属性。trigger是发通知动作。属性被修改时系统拿出对应的订阅名单挨个打电话通知等待执行。注意 track 里用了effectStack而不是单个全局变量。这样设计是为了应对嵌套 effect 的场景比如一个 effect 内部又调用其他函数这些函数内部也读取响应式状态或者 effect 内部还注册另一个 effect。如果只用一个全局变量赋值读到嵌套的 effect 时会把外层 effect 覆盖掉导致依赖关系错乱。用栈来保存当前正在执行的 effect 链track 时取栈顶那一个就安全了。3.3 effect 与调度器合并更新effect 是响应式系统的执行单位。我封装了一个runEffect函数它既能立即执行一次也让内部读取状态时能正确注册依赖export function runEffect(fn: Effect) { const runner () { effectStack.push(runner); try { fn(); } finally { effectStack.pop(); } }; runner(); return runner; }runner把自身推入 effect 栈这样 fn 执行过程中读取任何响应式属性track 都能把这个 runner 登记为依赖。将来属性变化时trigger 拿到的就是这个 runner可以直接重新调用。调度器部分我用Set做去重队列并用Promise.resolve().then做批量执行// src/core/scheduler.ts import type { Effect } from ./dep; const queue new SetEffect(); const callbacks: Array() void []; let flushing false; let promise Promise.resolve(); export function schedule(effect: Effect) { queue.add(effect); if (!flushing) { flushing true; promise.then(flush); } } function flush() { flushing false; const effects Array.from(queue); queue.clear(); if (effects.length 0) { effects.forEach((effect) effect()); } const pendingCallbacks callbacks.slice(); callbacks.length 0; pendingCallbacks.forEach((cb) cb()); } export function nextTick(cb?: () void): Promisevoid { return new Promise((resolve) { callbacks.push(() { if (cb) cb(); resolve(); }); }); }这段代码解决了一个很实际的问题如果一次点击事件里连续修改了三次状态trigger会被调用三次但 effect 只会被 queue 添加一次等到微任务执行时只运行一次渲染函数。没有这个调度器同样的逻辑就会导致三次重复渲染在复杂组件里是很明显的性能浪费。顺便多说一句Vue 的nextTick、React 的批处理本质上和这个调度器是同一类思想把同步操作产生的副作用推后到当前事件循环末尾统一处理。理解了这个小调度器再看那些框架的源码会亲切很多。3.4 响应式细节的几个关键点Proxy 虽然强大但实现响应式时还是有一些容易翻车的细节。第一数组的读取和写入。arr[0]这样的下标读取会被 get 拦截arr.push(x)会触发 set 拦截器修改 length同时 length 也会触发 trigger。所以我给的实现里数组天然支持下标响应式不需要额外兼容。但要注意像arr.includes(item)这类方法的内部实现会通过 get 读取 length 和各个下标所以也能正确收集依赖只是性能上会比直接读取属性差一些。第二删除属性。Reflect.deleteProperty不会走 set 拦截器如果你需要delete state.name也触发更新必须在 deleteProperty 钩子里手动调用 trigger。rea的极简版没有处理这个我遇到过一次删除属性后组件不更新的问题排查了半天最后补上 deleteProperty 钩子才解决。第三对象整体替换。如果你写state.list newArray那么读取state.list的依赖会被触发渲染函数会拿到新的数组。这是 Proxy 方案的优势换成defineProperty需要额外$set才能做到。4. 渲染引擎从状态到真实 DOM4.1 极简 VNode 与 h 函数有了响应式状态下一步就是渲染。rea的渲染函数设计非常朴素每次执行 render返回一棵轻量的 VNode 树然后由 DOM 层负责把它变成真实元素。VNode 就是一个纯对象// src/runtime/h.ts export interface VNode { type: string; props: Recordstring, any; children: ArrayVNode | string; el?: HTMLElement | Text; } export function h( type: string, props: Recordstring, any {}, children: ArrayVNode | string [] ): VNode { return { type, props, children }; }为什么需要 VNode 而不是直接操作 innerHTML两个原因。第一直接 innerHTML 会丢失事件绑定每次更新都要重新绑定事件性能差且容易产生内存泄漏。第二VNode 是结构化数据可以在更新前进行对比只修改变化的部分。h函数就是你平时写React.createElement的简化版它不产生任何 DOM 操作只是描述我要一个这样的元素。比如下面这段 renderrender(state) { return h(div, { class: counter }, [ h(button, { onClick: () state.count }, [1]), h(p, {}, [Count: ${state.count}]), ]); }执行完h之后得到的只是一棵对象树还没有任何 DOM 节点。真正创建节点是在 mount 阶段。4.2 创建元素与属性绑定DOM 创建逻辑在dom.ts里。它的任务是把 VNode 变成真实节点处理两件事创建标签和文本节点、绑定属性和事件。// src/runtime/dom.ts import type { VNode } from ./h; export function createElement(vnode: VNode | string): HTMLElement | Text { if (typeof vnode string) { return document.createTextNode(vnode); } const el document.createElement(vnode.type); for (const [key, value] of Object.entries(vnode.props || {})) { if (key.startsWith(on) typeof value function) { el.addEventListener(key.slice(2).toLowerCase(), value); } else if (key class) { el.className value; } else if (key value) { (el as HTMLInputElement).value value; } else { el.setAttribute(key, value); } } if (Array.isArray(vnode.children)) { vnode.children.forEach((child) { const childEl createElement(child); el.appendChild(childEl); }); } vnode.el el; return el; }事件绑定的写法比较土所有以on开头的 prop 都视作事件。好处是代码量最少也够用。class特殊处理是因为setAttribute(class, value)虽然能用但 class 名含空格时依然没问题真正让我单独处理的是要兼容 className 在部分场景下更高效的情况。value属性单独处理的原因在于对 input 元素使用setAttribute(value, ...)只会设置默认值不会改变当前输入框的值如果状态已经更新必须用el.value直接赋值否则表单展示和真实状态会不一致。4.3 patch新旧 VNode 的对比更新patch 的目标是尽量复用已有 DOM 节点避免全部重建。rea采用的是一个非常保守的策略如果新旧节点 type 不同直接替换整个元素如果 type 相同更新属性子节点按索引从前往后对比多出的新节点末尾追加多的旧节点末尾移除代码大致如下// src/runtime/patch.ts import { createElement } from ./dom; import type { VNode } from ./h; export function patch(oldVNode: VNode, newVNode: VNode) { const el (newVNode.el oldVNode.el); if (!el) { return; } if (oldVNode.type ! newVNode.type) { const parent el.parentNode; const newEl createElement(newVNode); parent!.replaceChild(newEl, el); newVNode.el newEl; return; } updateProps(el, oldVNode.props, newVNode.props); const oldChildren oldVNode.children || []; const newChildren newVNode.children || []; const commonLength Math.min(oldChildren.length, newChildren.length); for (let i 0; i commonLength; i) { patch(oldChildren[i] as VNode, newChildren[i] as VNode); } if (newChildren.length oldChildren.length) { for (let i commonLength; i newChildren.length; i) { el.appendChild(createElement(newChildren[i])); } } else if (oldChildren.length newChildren.length) { for (let i commonLength; i oldChildren.length; i) { const childEl (oldChildren[i] as VNode).el; if (childEl) el.removeChild(childEl); } } } function updateProps(el: HTMLElement, oldProps, newProps) { for (const key of Object.keys(newProps || {})) { const value newProps[key]; if (oldProps?.[key] ! value) { if (key.startsWith(on)) { const eventName key.slice(2).toLowerCase(); if (oldProps?.[key]) { el.removeEventListener(eventName, oldProps[key]); } el.addEventListener(eventName, value); } else if (key class) { el.className value; } else if (key value) { (el as HTMLInputElement).value value; } else { el.setAttribute(key, value); } } } }这里有几个很重要的坑。事件更新时如果直接 addEventListener旧事件监听不会被移除会导致每次更新都叠加监听器点击一次触发多次回调。所以更新前必须用旧的事件函数对象 remove 掉再绑定新的。旧事件函数从哪来从oldVNode.props里取。子节点的更新策略是rea简化过的它无法高效处理中间插入一个节点的情况——按索引对比会错误地复用后续节点。对于待办列表这类频繁增删的场景我更推荐全部重建子节点或者引入按 key 的 diff。后者写起来会让代码量翻倍而且很容易出边界问题。作为练习框架我选择保留这个朴素的 patch但会在项目注释里标明这是性能取舍点。4.4 事件绑定与表单双向绑定有了事件系统双向绑定就很好实现了。所谓双向绑定本质上是一个语法糖输入框读取 state 的值输入事件反向修改 state 的值。一开始我在组件里写h(input, { value: state.text, onInput: (e) (state.text (e.target as HTMLInputElement).value), })这个写法没问题每次输入都会触发state.text的 set然后依赖state.text的组件重新渲染input 的 value 由 patch 更新回输入框。但有一个性能细节每次输入都走一次完整渲染对于单个输入框没有压力可如果在同一个渲染函数里读取了大量状态每次都全部重跑就不够优雅。后面我会在第 5 节给出一个更细分的方案。5. 实战用 rea 写一个完整 Demo5.1 计数器 Demorea的执行入口在app.ts// src/app.ts import { reactive } from ./core/reactivity; import { runEffect } from ./core/dep; import { createElement } from ./runtime/dom; import { patch } from ./runtime/patch; import type { VNode } from ./runtime/h; export interface ComponentOptions { setup: () Recordstring, any; render: (context: Recordstring, any) VNode; } export function createApp(options: ComponentOptions) { const context options.setup(); let rootVNode: VNode | null null; function mount(selector: string) { const container document.querySelector(selector); if (!container) throw new Error(mount target not found); const update () { const nextVNode options.render(context); if (rootVNode) { patch(rootVNode, nextVNode); } else { container.appendChild(createElement(nextVNode)); } rootVNode nextVNode; }; runEffect(update); } return { mount, context }; }然后计数器例子长这样// src/examples/counter.ts import { createApp } from ../app; import { reactive } from ../core/reactivity; import { h } from ../runtime/h; createApp({ setup() { const state reactive({ count: 0 }); return { state }; }, render({ state }) { return h(div, { class: counter }, [ h(p, {}, [Current: ${state.count}]), h(button, { onClick: () state.count }, [1]), h(button, { onClick: () state.count-- }, [-1]), ]); }, }).mount(#app);我在浏览器里第一次跑出来时最大的感受就是原来框架里那些魔法其实就是这么展开的。点击按钮触发事件事件修改 statestate 的 set 拦截器调用 triggertrigger 把渲染函数丢进调度队列微任务执行渲染函数生成新的 VNodepatch 对比旧 VNode 更新文本和按钮。整条链路没有任何隐藏机制。5.2 待办列表 Demo为了验证响应式和渲染系统的配合我还写了一个简单的待办列表。它包含输入框、列表展示、删除三个功能// src/examples/todos.ts import { createApp } from ../app; import { reactive } from ../core/reactivity; import { h } from ../runtime/h; interface Todo { id: number; text: string; } let nextId 1; createApp({ setup() { const state reactive{ text: string; todos: Todo[] }({ text: , todos: [], }); function addTodo() { if (state.text.trim() ) return; state.todos.push({ id: nextId, text: state.text }); state.text ; } return { state, addTodo }; }, render({ state, addTodo }) { return h(div, { class: todos }, [ h(input, { value: state.text, onInput: (e) (state.text (e.target as HTMLInputElement).value), }), h(button, { onClick: addTodo }, [Add]), h( ul, {}, state.todos.map((todo) h( li, { key: todo.id, onClick: () { state.todos state.todos.filter((t) t.id ! todo.id); }, }, [todo.text] ) ) ), ]); }, }).mount(#app);这个例子验证了两件事一是数组push操作可以正常触发依赖二是整体替换state.todos也能触发更新。第一次写完时我刻意没填onClick删除按钮没反应填上之后点击删除列表只更新必要的那一行因为 patch 会复用相同索引和 key 的节点。不过正如前面说的如果删除的是中间项按索引 patch 会错误复用后面的节点实测表现为文本错位。要彻底解决需要给 patch 加上 key 查找逻辑这是我目前还在优化的小模块。5.3 性能感受为什么细粒度不一定比虚拟 DOM 慢写完 Demo 后我认真想了一个问题rea每次更新都不做整树 diff只重跑依赖了状态的渲染函数是不是比虚拟 DOM 更高效实际感受是分场景看。如果应用有大量互不相关的组件用rea这种细粒度响应式方案每次状态更新只会重渲染真正依赖它的组件其他组件完全不受影响。这在大型仪表盘或编辑器里优势明显。而虚拟 DOM 框架通常需要 root 开始 diff 整棵组件树即使 React 有 memo 优化开发者也要主动去维护 memo 依赖容易遗漏。反过来说如果一个超大的组件内部有海量 DOM 节点每次状态变化都要重跑它的渲染函数rea的简版 patch 会按索引逐个对比这时反而可能比虚拟 DOM 的 keyed diff 慢。所以框架没有绝对优劣关键看是否匹配场景。rea让我真正理解了这一点比看十篇性能对比文章都有效。6. 排查实录rea 开发中的坑与解法6.1 响应式失灵解构赋值导致丢失代理这个是我最早踩到的坑。一开始我在组件里这么做const { count } state; return h(p, {}, [Count: ${count}]);渲染结果出来后倒是一切正常但点击按钮修改state.count时界面一动不动。原因是解构赋值只发生了一次getcount拿到的是原始数字之后这个数字和state.count再无关联。Proxy 只有在后续每次访问时才拦截解构得到的是快照不是活引用。解决办法也很简单在 render 里始终通过state.count访问状态或者封装一个ref函数把基本类型包装成{ value: any }对象。考虑到rea主打极简我没有特意实现 ref而是在文档里明确一个约定不要在 render 外解构响应式对象再投入使用。6.2 无限更新循环某次我写了一个 effect准备在渲染时把状态重置render({ state }) { if (state.count 0) { state.count 0; } return h(p, {}, [String(state.count)]); }结果浏览器卡死了。原因是修改state.count又会让当前 effect 入队同一微任务已经 flush 完一轮新入队的 effect 又继续执行它看到count 0为 false 才能停下来但在state.count 0这一行还没执行完时effect 已经被再次排队。如果不加保护两个微任务之间会无限循环。我的解法是在调度器里记录当前正在 flush 的 effect 集合如果一个 effect 正在执行过程中又被 schedule就把它标记为本轮已排队跳过重复入队。对于rea这个规模来说更直接的防守是在代码里约定渲染函数内部不要主动修改响应式状态。真要修改应该放到事件回调里。框架是工具工具会惯着使用者写出奇怪写法但写惯错误写法的人在别的框架照样会踩同样的坑。6.3 状态更新后拿不到最新 DOM这证明调度器的时机设计很关键。有段时间我想在修改状态后立刻读取输入框的宽度state.text longer content; console.log(inputEl.value); // 还是旧值 state.todos.push(newTodo); console.log(ul.children.length); // 还是旧值原因很简单状态修改只是入队了更新DOM 还没重新渲染必须等待微任务 flush 完成。所以我实现了nextTickimport { nextTick } from ./core/scheduler; state.text longer content; await nextTick(); console.log(inputEl.value); // 新值在 Vue 和 React 里也有同样的现象它们提供了各种回调方式来解决。亲手实现一次 nextTick 之后我对状态更新是异步的这句话有了肌肉记忆。6.4 常见问题速查现象可能原因解决办法修改状态后界面不更新解构赋值拿走了原始数据始终用state.xxx访问不要提前解构点击事件重复触发patch 更新事件时没有移除旧监听更新前用旧 props 里的函数对象调用 removeEventListener列表中间插入时内容错位简版 patch 按索引复用节点改用 key 查找的 diff或删除后整体重建子节点修改嵌套对象不触发更新嵌套对象没有递归包装成 reactive在 get 拦截器里对返回值递归调用 reactive修改状态后立刻读 DOM 拿旧值更新被调度到微任务队列使用 nextTick 等待渲染完成页面卡死或无限循环effect 内部修改了自己依赖的状态避免在 render 里改状态增加调度器去重保护删除属性后不更新没实现 deleteProperty 拦截在 Proxy 中增加 deleteProperty 钩子并触发 trigger表里列的几个问题我都真实遇到过排查过程远比表格看起来曲折。尤其是事件重复触发的那个 bug我最初以为是绑定了多个事件监听器检查半天才发现是 patch 时旧监听没清理。后来我在updateProps里强制对事件做先移除后新增问题彻底消失这种一劳永逸的写法在小型框架里反而是最稳的。7. 经验收尾与扩展思路rea跑通之后我最大的收获不是写出了几百行代码而是再回头看主流框架时那些曾经要靠死记的 API 和行为都有了底层逻辑。比如 Vue 的响应式为什么能自动追踪依赖、React 的批量更新为什么能减少渲染次数、nextTick 为什么要等待微任务队列清空这些知识点在rea的代码里都能找到一一对应的实现。如果你也打算照着这条路重写一遍我的建议是一定要给自己的框架写至少两个 Demo一个计数器一个列表增删。计数器能帮你验证最简单的响应式链路列表增删则会把 diff、事件绑定、数组响应式这几个难点全部逼出来。我自己写第三个 Demo 时才顺手实现了 ref因为计数器里解构基本类型太容易犯错了。后续想扩展rea方向其实很清晰给 patch 加上 keyed diff支持 Fragment 和组件生命周期实现computed和watch把调度器换成requestIdleCallback时间切片。任何一个方向都能让这个迷你项目再涨几百行代码并且每一步都对应着真实框架里一个具体的特性。每完成一个你对框架源码的理解就会深一层。说实话手搓框架这件事做之前觉得难做完之后最大的感慨是原来那些框架作者也在用同样的思路解决同样的问题只是他们把边界考虑得更周全做得更工程化罢了。
延伸阅读

更多相关文章

2026/10/21 4:56:19

高并发下单防超卖:从数据库原子扣减到Redis预扣减的完整方案

后端同学基本都撞见过这么个场景:活动一开始,库存明明只有 1000 件,后台订单数却蹭蹭涨到三千多。系统没崩,接口也都返回成功,仓库却压根拿不出那么多货。这个现象叫超卖,本质上是高并发场景下订单与库存两…

2026/10/21 4:56:19

Linux下用ffmpeg和tree批量提取视频时长的实战方案

1. 项目概述:为什么需要“遍历子目录批量获取视频时长”这个动作在日常运维、媒体资产整理、课程资源归档、短视频素材库管理等实际场景中,我经常遇到一个看似简单却极其耗时的问题:一个根目录下嵌套着十几层子目录,每层里散落着几…

2026/10/21 8:22:08

医学大模型入门:从通用LLM到懂医学的模型

“AI医疗”这个词,前两年还停留在新闻稿和融资PPT里,今年已经开始变成实实在在的招聘JD和项目立项书了。医学大模型也不再是少数前沿团队的研究课题,而是一个小白程序员也有机会参与的方向:智能导诊、病历结构化、医学问答、科普内…

2026/10/21 8:22:08

450元云服务器赠金实战:注册流程、计费策略与避坑指南

1. 这 450 元赠金到底能干嘛,先讲清楚刚听说"白山智算免费 450 赠金"的时候,我第一反应是:又一个注册送钱的活动?但实际摸了一圈下来发现,这个赠金跟部分平台那种"送你一堆用不上的代金券"完全不是…

2026/10/21 8:22:08

Fluent与Simpack联合仿真:多编组列车横风气动-动力学耦合分析

干列车仿真的朋友应该都遇到过这个尴尬:CFD算出来的压力云图五彩斑斓很漂亮,Simpack算出来的动力学响应曲线也挺顺滑,但你把两者的结果放在一起对账,就是怎么都对不上。问题出在哪?出在它们是两个孤岛——流场不知道车…

2026/10/21 8:22:08

深入解析 Jessibuca 播放器内核:FLV 拉流与解封装的全链路实践

1. 从“播放器内核”视角看 Jessibuca 的模块划分先交代一下背景。我接触 Jessibuca 这个项目比较早,当时团队需要做一套纯前端的实时流播放方案,不允许上插件、不允许装播放器客户端,还要能应付老旧的摄像头 RTSP 转发流。我们最初试过用 We…

2026/10/21 8:22:08

Vue面试核心梳理:响应式、虚拟DOM与组件通信实战解析

马上又要到跳槽季了,后台私信里问Vue面试题的人明显多起来。这篇东西本来是我给团队内部同学做面试辅导用的讲义,整理之后拿出来分享。我自己做前端这些年,既面过别人,也被别人面过,对于Vue面试题的套路、误区、以及面…

2026/10/21 8:17:06

气象数据分析全流程:数据清洗、可视化与机器学习预测实践

简介:气象分析(数据分析)压缩包是一份面向数据分析课程实验与初学者的完整素材,以中国天气网某城市天气数据为对象,通过爬虫代码抓取数据,生成雷达图、条形图并进行说明,覆盖数据采集、清洗、可…

2026/10/21 4:53:08

金融AI落地指南:从调研报告到技术选型与工程实践

简介:这份PDF报告聚焦2025年金融服务业中国AI现状与趋势,面向金融科技从业者、AI战略规划人员及关注行业数字化转型的研究者,帮助读者把握生成式AI在银行、支付、交易等场景中的落地路径与投资方向。资源包内含1个PDF文件,大小约1…

2026/10/21 0:00:35

交通流仿真实战:IDM跟驰与MOBIL换道模型参数调优与避坑指南

简介:这份资源是面向交通工程学习者与Python开发者的微观交通流仿真项目,核心实现智能驾驶模型(IDM)下的车辆跟驰与换道行为,可用于理解速度、密度与流量之间的动态关系,适合具备Python基础、希望深入交通模…

2026/10/21 0:00:35

揭穿智能体表面顺从:用追踪代码重建任务执行确定性

1. 表面顺从不是智能体的“礼貌”,而是任务执行链路上的隐性断点最近在调试一个跨平台长周期任务调度系统时,我反复遇到一种令人困惑的现象:智能体在用户指令发出后,几乎秒级响应“已收到”“正在处理”“请稍候”,UI上…

2026/10/21 0:00:35

统一内存+量化:锐龙AI Max PRO 400如何本地跑320B大模型

这段时间硬件圈和AI圈都在传一个事:AMD锐龙AI Max PRO 400系列把最高192GB统一内存搬进了PC形态的产品里,按宣传口径,本地跑320B参数的大家伙不再是纸面概念。我第一反应倒不是“内存真大”,而是这话背后的含金量——它把过去只能…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑