
1. 项目概述从“用框架”到“造引擎”的转变最近几年前端圈子里“造轮子”的风气似乎又回来了但这次大家聊的不再是简单的UI组件库而是更底层、更核心的东西——引擎。你可能在社区里看到过“自研前端引擎”、“低代码渲染引擎”这样的标题心里犯嘀咕React、Vue这些现成的框架不香吗为什么还要自己从头搞一个引擎我自己也带着同样的疑问花了几个月时间从零开始摸索着搭建了一个前端引擎的原型。这个过程与其说是开发不如说是一次对前端技术栈的深度解构。今天我就把这段“造引擎”的记录和思考分享出来希望能给同样对底层技术好奇的你一些启发。简单来说我做的这个“前端引擎”目标不是替代React或Vue而是尝试理解并实现一个现代前端框架最核心的几块基石虚拟DOM的协调与更新、响应式数据绑定、以及基于组件的声明式渲染。它更像是一个教学项目或技术验证但麻雀虽小五脏俱全。通过亲手实现一遍你会对“数据如何驱动视图更新”、“组件生命周期到底在干什么”、“Diff算法如何高效比对节点”这些问题有刻骨铭心的理解。无论你是想应对越来越深入的前端面试还是希望提升自己的架构能力亦或是单纯对技术原理有极客般的热情这个探索过程都价值连城。2. 核心架构设计与技术选型思考在动手写第一行代码之前最重要的就是确定架构方向和技术栈。市面上主流框架的思路各有千秋我的目标是实现一个足够简洁、易于理解但又具备关键现代特性的原型。2.1 为何选择“类React”的虚拟DOM路径虚拟DOMVirtual DOM几乎是现代前端框架的标配。它的核心思想是用一个轻量的JavaScript对象即虚拟节点来描述真实的DOM树。当状态变化时不是直接操作昂贵的真实DOM而是生成新的虚拟DOM树然后通过高效的Diff算法比较新旧两棵树计算出最小化的更新操作Patch最后再批量应用到真实DOM上。我选择从虚拟DOM入手主要基于以下几点考量概念清晰边界明确虚拟DOM作为数据与视图之间的中间层职责单一描述UI使得渲染逻辑与平台浏览器、小程序、Native解耦。这对于理解框架的跨端能力基础非常有帮助。算法驱动性能优化有迹可循Diff算法是虚拟DOM的核心这里有无数的优化点可以钻研如Key的作用、同级比较、组件层级优化实现过程本身就是对算法思维的极佳训练。生态与心智模型成熟React的巨大成功证明了这套模型的可行性社区有海量的资料、讨论和最佳实践可供参考降低了独自探索的认知负担。我没有选择Vue 3的基于Proxy的编译时优化路径主要是因为其编译器和运行时深度耦合对于第一个引擎原型来说复杂度偏高。虚拟DOM方案虽然运行时开销相对大一点但更容易实现和调试。2.2 响应式系统的实现方案取舍数据变了视图要自动更新。这就是响应式系统要解决的问题。主要有两种主流方案推Push模型基于Object.definePropertyVue 2或ProxyVue 3拦截数据的读写操作在getter中收集依赖哪个组件用了这个数据在setter中触发更新。这是“主动通知”型。拉Pull模型像React那样不直接检测数据变化而是通过setState或useStatehook显式地发起一个更新请求然后重新执行组件函数来“拉取”最新的UI描述。这是“被动拉取”型。为了更贴近虚拟DOM的协调过程我采用了一种混合模式我实现了一个简易的、基于Proxy的响应式数据容器但它不直接关联视图。组件的状态State或属性Props变化时手动调用一个scheduleUpdate函数将这个组件标记为“待更新”并放入一个更新队列。然后通过类似requestIdleCallback或setTimeout的机制在下一帧批量处理队列中的所有更新执行虚拟DOM的Diff和Patch过程。注意这里没有实现完整的、细粒度的依赖追踪因为我们的更新单元是组件级别的。这简化了实现也符合React的“组件级重渲染”哲学。真正的挑战在于如何优化避免不必要的子组件渲染这就要用到shouldComponentUpdate或React.memo类似的机制。2.3 技术栈为什么是TypeScript Node.jsTypeScript这是必选项。开发编译器、运行时这类复杂系统类型就是最好的文档和约束。定义清晰的虚拟节点VNode接口、组件定义、更新队列类型能避免大量运行时错误让开发体验提升好几个等级。Node.js引擎本身是库Library不需要Node.js来运行。但Node.js环境用于构建与打包使用Rollup或ESBuild将TS代码编译、打包成UMD/ESM格式供浏览器使用。简易开发服务器提供一个静态服务器实时加载和测试引擎。单元测试用Jest或Vitest对虚拟DOM算法、响应式对象进行充分测试。不依赖任何现有框架这是原则。从零开始只使用最原始的DOM API (document.createElement,appendChild,removeChild等)和语言特性。3. 核心模块实现深度解析接下来我们深入到代码层面看看各个核心模块是如何从零搭建的。3.1 虚拟DOMVNode的结构定义虚拟DOM的本质是对象。我们需要设计一个数据结构能完整描述一个UI节点。interface VNode { // 节点类型如 div, span, 或自定义组件函数 type: string | Function; // 属性对象如 { id: app, className: container } props: { [key: string]: any } | null; // 子节点可以是字符串文本或其他VNode组成的数组 children: VNode[] | string | null; // 对应的真实DOM节点在挂载mount和更新update时关联 el: HTMLElement | Text | null; // 用于Diff算法的关键标识 key: string | number | null; // 组件实例引用如果是组件类型 componentInstance?: any; }文本节点可以简化为一个只有children为字符串的VNode。对于组件节点type就是一个函数函数式组件或一个包含render方法的对象类组件。3.2 渲染器Renderer与挂载Mount渲染器的职责是将VNode转化为真实的DOM节点。mount函数是起点。function mount(vnode: VNode, container: HTMLElement) { const el createElement(vnode); // 根据vnode.type创建真实DOM if (vnode.props) { setProps(el, vnode.props); // 设置属性、事件监听器等 } if (typeof vnode.children string) { el.textContent vnode.children; // 处理文本子节点 } else if (Array.isArray(vnode.children)) { vnode.children.forEach(child { mount(child, el); // 递归挂载子节点 }); } vnode.el el; // 建立反向引用 container.appendChild(el); } function createElement(vnode: VNode): HTMLElement | Text { if (typeof vnode.type string) { return document.createElement(vnode.type); } // 如果是组件类型需要先创建组件实例再渲染其内容 // ... 组件处理逻辑稍后讨论 }setProps函数需要处理普通HTML属性如id、class、布尔属性如disabled、以及事件如onClick。事件处理需要特别注意要将用户传入的事件处理函数包装一层以便于未来更新时能正确解绑旧事件、绑定新事件。3.3 Diff算法协调Reconcile的核心这是引擎最复杂、最精华的部分。patch函数接收旧VNode (n1) 和新VNode (n2)以及它们的父容器。function patch(n1: VNode | null, n2: VNode, container: HTMLElement) { if (n1 null) { // 旧节点不存在直接挂载新节点 mount(n2, container); } else if (n2.type ! n1.type || n2.key ! n1.key) { // 节点类型或key不同直接替换 container.replaceChild(createElement(n2), n1.el!); } else { // 节点类型相同可以复用DOM元素进行精细化更新 const el (n2.el n1.el)!; // 1. 更新属性 (patchProps) updateProps(el, n1.props, n2.props); // 2. 更新子节点 (patchChildren) —— 这里是Diff算法的重中之重 patchChildren(n1, n2, el); } }patchChildren的实现决定了性能。子节点可能的情况有文本-文本、文本-数组、数组-文本、数组-数组。最复杂的是数组到数组的对比也就是常说的“列表Diff”。一个基础但低效的做法是直接遍历新旧列表按索引对比。但这在列表顺序变化时如插入、删除、移动会产生大量不必要的DOM操作。我实现了一个基于Key的双端Diff算法的简化版。基本步骤是预处理建立新节点key到索引的映射。从头、尾同时开始比对处理相同的前置和后置节点。如果旧列表先处理完说明有新节点需要挂载。如果新列表先处理完说明有旧节点需要卸载。如果两者都有剩余则进入最复杂的移动逻辑根据新节点的顺序找出旧节点中需要移动的最长递增子序列LIS只移动不在这个序列中的节点。实操心得实现完整的Diff算法非常具有挑战性。建议先从最简单的“按索引全量替换”开始让它能跑起来。然后逐步优化先加上Key实现相同Key节点的复用再实现头尾比对最后再挑战最复杂的移动逻辑。每完成一步都可以写测试用例验证其正确性和性能提升。这个过程会让你对React源码中reconcileChildrenArray函数的精妙设计佩服不已。3.4 组件系统的搭建组件是声明式UI的基石。我同时实现了函数组件和类组件。函数组件最简单它本身就是一个返回VNode的函数。渲染时直接调用它传入props然后挂载其返回的VNode即可。function mountFunctionComponent(vnode: VNode, container: HTMLElement) { const { type, props } vnode; const childVNode (type as Function)(props); // 执行组件函数 mount(childVNode, container); }类组件更复杂需要管理实例状态和生命周期。class Component { props: any; state: any; vnode: VNode | null null; isMounted: boolean false; constructor(props: any) { this.props props; this.state this.state || {}; } setState(partialState: any) { // 1. 合并状态 this.state { ...this.state, ...partialState }; // 2. 调度更新 scheduleUpdate(this); } render(): VNode { throw new Error(Component must implement render method); } }scheduleUpdate是更新调度的核心。它会将需要更新的组件实例添加到一个微任务队列中确保在一次事件循环中无论调用多少次setState都只进行一次最终的渲染和Patch这就是“批量更新”。let updateQueue: Component[] []; let isFlushing false; function scheduleUpdate(componentInstance: Component) { if (!updateQueue.includes(componentInstance)) { updateQueue.push(componentInstance); } if (!isFlushing) { isFlushing true; // 使用微任务如Promise或 requestAnimationFrame 进行批量更新 Promise.resolve().then(flushUpdates); } } function flushUpdates() { updateQueue.forEach(comp { // 获取组件旧VNode和新VNode进行patch const oldVNode comp.vnode; const newVNode comp.render(); // 重新执行render patch(oldVNode, newVNode, comp.vnode.el.parentNode); comp.vnode newVNode; }); updateQueue.length 0; isFlushing false; }生命周期如componentDidMount、componentDidUpdate可以在mount或patch流程的特定时机调用组件实例的对应方法。4. 工程化、调试与性能优化实践一个可用的引擎原型和一个健壮的引擎之间隔着工程化和深度优化的鸿沟。4.1 构建、测试与调试流水线构建使用Rollup打包配置输出ES模块和UMD格式。利用rollup/plugin-typescript处理TSrollup-plugin-terser进行压缩。测试使用Vitest因为它对Vite项目友好且速度快。为虚拟DOM的创建、Diff算法的各种边界情况空节点、列表反转、Key重复、组件生命周期等编写详尽的单元测试。测试是重构和优化时的安全网。调试开发一个极简的示例应用一个TodoList足矣用最直观的方式测试引擎功能。利用浏览器的DevTools特别是Performance面板和Elements面板观察DOM操作的实际发生情况。可以为自己引擎的VNode添加一个_debug属性在控制台打印出更新流程辅助调试。4.2 关键性能优化点实录在开发过程中我遇到了几个典型的性能瓶颈并尝试了优化避免不必要的子组件渲染这是最大的性能陷阱。父组件更新即使子组件的props没变默认也会重新渲染。我实现了一个简单的PureComponent基类或memo高阶函数在shouldComponentUpdate或比较函数中对新旧props和state进行浅比较shallow compare只有真正变化了才允许更新。事件委托如果给每个可交互元素都绑定原生事件数量多了内存占用和初始化开销都很大。我借鉴了React的思路在根容器上做事件委托。利用事件冒泡在根节点监听所有事件然后通过事件对象的target属性找到触发事件的具体VNode再调用其对应的处理函数。这需要维护一个从DOM元素到VNode的映射。Diff算法优化如前所述实现基于Key和LIS的列表Diff是必须的。此外对于静态子树永远不会变的节点可以打上标记在Diff时直接跳过整个子树的对比。更新调度优化scheduleUpdate使用了微任务批量更新。但对于复杂动画或高频交互场景微任务可能仍然会阻塞渲染。更高级的优化是引入时间切片Time Slicing和优先级调度将Diff和Patch过程分解成小块在浏览器的空闲时段执行这需要用到requestIdleCallback或自己模拟一个调度器。这是我下一步计划探索的方向。4.3 遇到的典型问题与排查内存泄漏组件卸载时如果事件监听器没有正确移除或者全局缓存没有清理就会导致内存泄漏。我的解决方案是在VNode上维护一个清理函数列表在unmount时统一执行。对于事件委托也需要在根节点解除监听。状态更新不同步在setState后立即读取this.state拿到的可能是旧值。这是因为我的setState是异步的。这实际上是符合预期的与React类组件行为一致。需要在componentDidUpdate或setState的回调函数中获取最新状态。列表渲染Key警告在实现Diff早期没有处理Key导致列表顺序打乱时状态错乱。后来强制要求渲染列表时必须提供稳定的key并在开发模式下给出明确的控制台警告帮助使用者避免这个问题。Hooks的模拟为了更完整我尝试模拟了基础的useState和useEffect。Hooks的核心在于调用顺序的稳定性。我通过一个全局的“当前渲染组件”指针和一个索引计数器将Hook调用与组件实例及其渲染顺序牢牢绑定。这让我深刻理解了React Hooks“不能在条件语句中调用”这条规则背后的原因。5. 从引擎开发看前端技术演进与个人成长完成这个引擎原型虽然离生产级框架相差甚远但带来的收获是巨大的。它不再是一个黑盒当你再用React写setState时你脑子里能大致勾勒出从状态变更到视图更新的完整链条。当你遇到性能问题你会本能地去想是哪个组件渲染次数过多是不是该用memo了或者列表的Key是不是没处理好。前端技术的发展从直接操作DOM的jQuery时代到以数据驱动为核心的MVVM框架时代再到现在React 18的并发特性、Vue 3的编译时优化、以及Svelte这种“消失的框架”其核心追求始终是在保证开发者体验Declarative, Component-Based的同时最大化运行时性能。自己动手实现一遍你会明白这些架构选择背后的权衡与智慧。对于想尝试的开发者我的建议是不要畏惧从最小可行产品MVP开始。先实现一个能创建静态VNode并渲染到页面的函数。然后加上属性更新。接着实现最简单的组件和setState。再挑战Diff算法。每一步都确保可运行、可测试。这个过程里官方文档、社区优秀的开源实现如preact、inferno都是极好的学习资料但看源码的前提是自己先思考、先尝试。最后这个自研引擎项目也成了我面试时的“硬通货”。当被问到虚拟DOM、Diff、响应式原理时我不再是背诵概念而是可以打开代码仓库指着具体的函数讲设计思路、踩过的坑和优化选择。这种由实践带来的底气和深度是任何面经都无法替代的。前端之路既要抬头看天紧跟新技术也要低头挖井深挖基础原理这个自制引擎的项目无疑是一次非常值得的“挖井”之旅。