当前位置: 首页 > news >正文

手写 vue 源码 === runtime-dom 实现

目录

1. 渲染器(Renderer)的作用与原理

2. 创建 runtime-dom 包的基本骨架

3. 实现常用的节点操作(nodeOps)

关键点讲解

4. 比对属性方法(patchProp)

4.1 操作类名:patchClass

4.2 操作样式:patchStyle

4.3 操作事件:patchEvent

4.4 操作普通属性:patchAttr

5. 组装并导出渲染器

5.1 在 index.js 中引用并组合

5.2 使用示例

6. 整体流程回顾与核心要点

7. 总结


1. 渲染器(Renderer)的作用与原理

在设计现代前端框架时,往往会先把用户编写的模板或 JSX 编译成「虚拟 DOM」(vnode)。虚拟 DOM 本质上是一个 JavaScript 对象树,用来表示页面结构、属性、事件绑定等信息。但在浏览器环境中,我们必须把这棵虚拟 DOM 树「真正地渲染」成浏览器认可的真实 DOM 元素,才能让用户看到视觉效果。

渲染器(Renderer) 就是完成这一步的核心模块:

  • 它接收一个虚拟节点(vnode),

  • 递归地比对(diff)旧 vnode 与新 vnode,

  • 生成一系列 DOM 操作(增删改)并最终更新到真实 DOM 上。

理论上,渲染器本身并不直接依赖于浏览器 API,而是通过一些抽象化的方法(如 createElementinsertsetElementText 等)来完成平台相关的操作。这样做带来了两点好处:

  1. 跨平台兼容:同一套核心 diff 算法可以复用于浏览器端(DOM)、Native 端(比如 Weex、NativeScript)等。只要实现一套对应平台的「DOM 操作 API」,就可以复用同一个渲染器核心。

  2. 职责分离:渲染器只关心如何比较 vnode 树,并推导出最小化的「变更指令」,而不关心具体怎么把指令挂到真是平台上。这样代码可维护性更高,框架也更灵活。

以 Vue 3 为例,它的核心有两个部分:

  • runtime-core:包含最核心的虚拟 DOM 描述、diff 算法、组件生命周期等,完全与平台无关。

  • runtime-dom:负责把 runtime-core 里产出的变更指令(比如「在 container 中插入一个 <div>」「把这个元素的文本改为 X」)映射成浏览器的真实 DOM API 调用。

下面就从无到有讲解,如何为浏览器环境构建一个简化版的 runtime-dom

2. 创建 runtime-dom 包的基本骨架

首先,我们在项目里创建一个文件夹 runtime-dom,用来存放与浏览器 DOM 相关的全部实现。其目录结构示例:

runtime-dom/
├─ package.json
├─ src/
│  ├─ index.js
│  ├─ nodeOps.js
│  ├─ patchProp.js
│  └─ (其他可能用到的工具文件)
└─ dist/└─ (打包后生成的文件)

package.json 中填写基本信息,示例内容如下:

{"name": "@vue/runtime-dom","version": "1.0.0", // 自行定"main": "index.js","module": "dist/runtime-dom.esm-bundler.js","unpkg": "dist/runtime-dom.global.js","buildOptions": {"name": "VueRuntimeDOM","formats": ["esm-bundler", "cjs", "global"]},"dependencies": {"@vue/shared": "^x.x.x"  // 取决于 runtime-core 所需的 shared 工具库}
}

重点说明runtime-dom 并不是一个孤立的包,它和 runtime-core(以及 shared 等)是紧密配合的。本示例中假设你已经有一套基本的 runtime-core,或者至少知道 createRendererhrender 这些 API 的签名和用法。

在完成 package.json 之后,需要安装依赖(以 pnpm 为例):

pnpm install @vue/shared@workspace --filter @vue/runtime-dom

这样就保证 @vue/shared 的工具函数(比如 isOnisReservedPropextendcamelize 等)可以被我们在 runtime-dom 中引用。

3. 实现常用的节点操作(nodeOps)

在浏览器环境下,所有操作最终都会落到原生的 DOM API 上。这里我们把常见的 DOM 操作用一个对象 nodeOps 封装好,让渲染器的核心只负责调用这些抽象方法,而不直接写 document.createElementparentNode.appendChild 等。

runtime-dom/src/nodeOps.js 中可以这样实现:

// runtime-dom/src/nodeOps.jsexport const nodeOps = {// 在 parent 节点里,把 child 插入到 anchor 之前。如果 anchor 是 null,则相当于 appendChildinsert: (child, parent, anchor) => {parent.insertBefore(child, anchor || null);},// 从父节点里移除 childremove: (child) => {const parent = child.parentNode;if (parent) {parent.removeChild(child);}},// 创建一个普通元素节点,例如 'div'、'span' 等createElement: (tag) => document.createElement(tag),// 创建一个文本节点createText: (text) => document.createTextNode(text),// 为一个文本节点设置文本内容setText: (node, text) => {node.nodeValue = text;},// 为一个元素节点设置文本内容(覆盖原先的子节点)setElementText: (el, text) => {el.textContent = text;},// 获取 node 的父节点parentNode: (node) => node.parentNode,// 获取 node 的下一个兄弟节点nextSibling: (node) => node.nextSibling,// 根据选择器查找 DOM 元素querySelector: (selector) => document.querySelector(selector)
};

关键点讲解

  1. insert:之所以接收 anchor 参数,是为了支持在列表渲染时,能够在指定位置进行插入,而不只是简单的 appendChild。如果不提供 anchor,则等同于把 child 插到 parent 的最后。

  2. remove:要先判断 child.parentNode 是否存在,避免对已经被移除的节点再次调用 removeChild

  3. createText / setText:区分文本节点和元素节点,可以让渲染器在 patch 过程中对文本做更细粒度的更新(比如文本节点直接用 nodeValue 修改)。

  4. querySelector:某些场景(例如用户调用 render(h(...), selectorString))需要先将字符串选择器解析到真实容器节点,再开始渲染。

这套 nodeOps 只是最基础的一套。你可以根据需要扩展,例如对 SVG 环境下的 createElementNS 或者事件委托等做兼容。

4. 比对属性方法(patchProp)

在虚拟 DOM 比对(diff)过程中,当节点类型相同而属性或事件发生变化时,渲染器会调用一个叫做 patchProp 的方法,把新旧值对比,然后决定对真实 DOM 做哪些更新。我们需要在 runtime-dom/src/patchProp.js 中实现这个逻辑。

// runtime-dom/src/patchProp.jsimport { patchClass } from './patchClass';
import { patchStyle } from './patchStyle';
import { patchEvent } from './patchEvent';
import { patchAttr }  from './patchAttr';export const patchProp = (el, key, prevValue, nextValue) => {if (key === 'class') {// 操作类名patchClass(el, nextValue);} else if (key === 'style') {// 操作样式patchStyle(el, prevValue, nextValue);} else if (/^on[^a-z]/.test(key)) {// 以 "onXxx" 开头,并且 X 必须大写,比如 onClick、onMouseoverpatchEvent(el, key, nextValue);} else {// 普通属性或自定义属性,比如 id、src、data-*patchAttr(el, key, nextValue);}
};

4.1 操作类名:patchClass

class 属性单独剥离,目的是处理两种情况:

  • valuenull 或者 undefined 时,要移除 class 属性。

  • 否则,直接把 el.className 赋值为字符串,让浏览器统一处理 class 列表。

// runtime-dom/src/patchClass.jsexport function patchClass(el, value) {if (value == null) {el.removeAttribute('class');} else {el.className = value;}
}

细节说明

  • 如果你写的是纯 CSS 类名(例如 "btn primary"),直接给 el.className = "btn primary" 就会由浏览器自动拆分为对应的 classList

  • 如果框架里允许 :class 绑定一个对象或数组,需要在上层把它转换为字符串后再传入。这里的 patchClass 假设接收到的就是最终要写入的字符串。

4.2 操作样式:patchStyle

style 可能是一个对象(如 { color: 'red', fontSize: '20px' }),也可能是 null。在更新时有两种场景:

  1. 新旧 style 都存在:先遍历新对象,把所有属性直接设置到 el.style 上;再遍历旧对象,把新对象没有的属性清空(赋值为 null)。

  2. stylenull,旧 style 不为 null:要把旧的所有内联样式清空。

下面是一个示例实现:

// runtime-dom/src/patchStyle.jsexport function patchStyle(el, prev, next) {const style = el.style;if (next) {// 把 next 中所有属性写入 stylefor (const key in next) {style[key] = next[key];}}if (prev) {// 把 prev 中存在但 next 中不存在的属性清除for (const key in prev) {if (next == null || next[key] == null) {style[key] = '';}}}
}

 

细节说明

  • style[key] = '' 相当于移除了该行内联样式。如果不清空,旧样式会一直残留,导致样式错误。

  • 这里假设 prev / next 都是普通对象(不是字符串)。在框架模板编译阶段,如果用户写了 :style="{ color: isRed ? 'red' : 'blue' }",最终就会得到一个 JS 对象,交给 patchStyle

4.3 操作事件:patchEvent

事件更新是最复杂的一块。原理是在 DOM 元素 el 上挂一个私有属性 _vei(Vue Event Invokers),用于存储用户真正的事件回调函数。整体思路如下:

当第一次给 el 绑定事件(nextValue 存在,existingInvoker 不存在),创建一个「可更新的调用器」invoker:

function createInvoker(initialValue) {const invoker = (e) => invoker.value(e);invoker.value = initialValue; // 把用户的回调函数挂到 invoker.valuereturn invoker;
}
  1. 这样子,我们实际给 DOM 调用 addEventListener 的回调是 invoker,而不是 initialValue,好处是:

    • 当之后需要更新事件处理函数时,只需要改变 invoker.value = newHandler,而不必频繁地 removeEventListener + addEventListener

    • 如果多次更新同一个事件(如 @click="onA"@click="onB"),可以复用同一个 invoker,只替换 .value 即可。

  2. nextValue 为空(用户移除了事件),就调用 removeEventListener 并清空缓存。

// runtime-dom/src/patchEvent.jsfunction createInvoker(initialValue) {const invoker = (e) => invoker.value(e);invoker.value = initialValue;return invoker;
}export function patchEvent(el, rawName, nextValue) {// el._vei: { [eventName: string]: invokerFunction }const invokers = el._vei || (el._vei = {});const existingInvoker = invokers[rawName];// event name 形如 "onClick",我们要把它转换为小写的 "click"// rawName.slice(2) 表示去掉前两位 "on",然后转小写const name = rawName.slice(2).toLowerCase();if (nextValue && existingInvoker) {// 1. 已经有 invoker,只替换它的 valueexistingInvoker.value = nextValue;} else if (nextValue) {// 2. 第一次绑定该事件const invoker = createInvoker(nextValue);invokers[rawName] = invoker;el.addEventListener(name, invoker);} else if (existingInvoker) {// 3. nextValue 不存在,但 existingInvoker 存在 → 用户移除了该事件el.removeEventListener(name, existingInvoker);invokers[rawName] = undefined;}
}

细节说明

  • 正则检查 rawName 是否以 on 开头且第二个字符不是小写字母,主要是为了避免把 oncaptureonmousewheelfirefox 等非法事件错误识别。

  • el._vei 上缓存的是对应 rawName 的 invoker 函数,而非原始用户传入的回调。内部调用时,invoker(e) 会在运行时去读取 invoker.value(即最新的用户回调)。

  • 这样既能保证更新回调时性能更高,也避免了多次 remove/add 事件监听器可能带来的额外开销。

4.4 操作普通属性:patchAttr

在处理非 class、非 style、非事件的属性时,我们直接把它当成普通的 DOM 属性来处理即可。如果 valuenullundefined,就移除该属性;否则,使用 setAttribute 设置即可。

// runtime-dom/src/patchAttr.jsexport function patchAttr(el, key, value) {if (value == null) {el.removeAttribute(key);} else {el.setAttribute(key, value);}
}

细节说明

  • 对于布尔属性(如 disabledchecked 等),如果你希望精准控制其布尔值,可以在上层做特定逻辑处理(例如 el.disabled = true/false)。这里 patchAttr 只是最基础、最通用的「增删属性」逻辑。

  • 某些属性(例如 value)并非用 setAttribute 写到属性上,而是要写到元素实例上 (el.value = 'some text')。如果需要更精细的行为,可以在这里做特殊分支:

if (key === 'value' && el.tagName === 'INPUT') {el.value = value;
} else {el.setAttribute(key, value);
}

这里只是示例,最简单的实现先用 setAttribute 覆盖大部分场景。

5. 组装并导出渲染器

完成了 nodeOpspatchProp 及其子方法的实现后,我们就可以把它们组装成一个「渲染选项对象(renderOptions)」,再把它交给 runtime-core 的核心函数 createRenderer。这样就能得到一个完整的浏览器渲染器。

假设 runtime-core 已经导出了如下 API:

  • createRenderer(options: RendererOptions):创建一个渲染器实例,返回一个对象,拥有 .render(vnode, container) 方法。

  • h:用于生成虚拟节点。

  • render:内置渲染器直接调用 createRenderer(options).render(...) 的快捷方式(可选)。

5.1 在 index.js 中引用并组合

// runtime-dom/src/index.jsimport { createRenderer } from '@vue/runtime-core'; // 假设 runtime-core 已经通过别名导入
import { nodeOps } from './nodeOps';
import { patchProp } from './patchProp';// 把 nodeOps 和 patchProp 组合到一起,形成渲染时所需的完整方法集
const renderOptions = Object.assign({ patchProp },  // 处理属性更新nodeOps         // 节点增删改等操作
);// 导出自定义渲染器函数:
// Vue 程序在调用时,只要传入虚拟节点和容器,就能把 vnode 渲染到 DOM
export function render(vnode, container) {// createRenderer 接受一个选项对象,返回一个渲染器实例// 渲染器实例有一个 .render 方法,接收 (vnode, container)return createRenderer(renderOptions).render(vnode, container);
}// 同时导出 h 函数,方便用户直接写
export { h } from '@vue/runtime-core';

 

细节说明

  1. Object.assign({ patchProp }, nodeOps)

    • { patchProp } 中的 key 是 patchProp

    • nodeOps 中包含:insertremovecreateElementcreateTextsetTextsetElementTextparentNodenextSiblingquerySelector

    • 最终 renderOptions 就有这 9+1(patchProp)个方法:

      {insert, remove, createElement, createText,setText, setElementText, parentNode, nextSibling,querySelector, patchProp
      }
      

  1. createRenderer(renderOptions):其实底层做了两件事:

    • renderOptions 里的方法注入到 diff 算法中,让它在进行 vnode 比对时,遇到需要增加节点、删除节点、修改文本、更新属性时,都调用我们传入的「DOM 操作方法」。

    • 返回一个包含 render(vnode, container) 的对象;调用 render() 时,会先把旧 vnode(如果有)和新 vnode 进行比对,生成最小化的「DOM 更新指令」,并调用 renderOptions 中的方法把真实 DOM 更新到页面上。

 

5.2 使用示例

在浏览器端,你可以通过两种方式来使用你的渲染器:

  1. 自定义渲染器
    如果你想改写某些 DOM 行为,或者做一些特殊平台适配,可以直接使用 createRenderer

<script type="module">import { createRenderer, h } from '/path/to/runtime-core.js';import * as runtimeDOM from '/path/to/runtime-dom.js';// 自定义渲染器const renderer = createRenderer({createElement(tag) {console.log('创建元素:', tag);return document.createElement(tag);},setElementText(el, text) {console.log('设置文本:', text);el.innerHTML = text;},insert(el, parent) {console.log('插入元素到父容器', parent);parent.appendChild(el);},/* 其余操作也要实现,或者从 nodeOps 拷贝进来 */// ...nodeOps,patchProp: runtimeDOM.patchProp});const vnode = h('h1', { class: 'title' }, 'Hello Custom Renderer');renderer.render(vnode, document.getElementById('app'));
</script>

内置渲染器
如果你只想拿到默认的浏览器端渲染器,直接调用 runtime-dom 暴露出的 render 即可:

<script type="module">import { h } from '/path/to/runtime-core.js';import { render } from '/path/to/runtime-dom.js';const vnode = h('h1', { style: { color: 'blue' } }, 'Hello Vue Runtime DOM');render(vnode, document.getElementById('app'));
</script>

上述代码等同于:

const renderer = createRenderer(renderOptions);
renderer.render(vnode, container);

6. 整体流程回顾与核心要点

  1. 虚拟 DOM (vnode) 生成

    • 用户书写模板、JSX 或直接调用 h() 辅助函数,得到一个描述 DOM 结构的 JavaScript 对象树。

    • 每个 vnode 上包含节点类型(字符串或组件)、属性 (props)、子节点等信息。

  2. 调用 render(vnode, container)

    • 如果是首次渲染,老的 vnode 树是 null,渲染器会一路往下创建真实元素并插入到 container

    • 如果是更新,渲染器会把「旧 vnode」和「新 vnode」树进行比对(Diff),只针对发生变化的地方生成对应的 DOM 操作。

  3. Diff 过程中调用 renderOptions 方法

    • 例如:

      • "创建元素" → nodeOps.createElement(tag)

      • "设置文本" → nodeOps.setElementText(el, text)

      • "更新属性" → patchProp(el, key, oldVal, newVal)

      • "插入节点" → nodeOps.insert(el, parent, anchor)

      • "删除节点" → nodeOps.remove(el)

    • 因此,渲染器核心只关心「时机」和「最小化更新」,而不关心「怎么把更新落到平台上」

  4. patchProp 细化不同属性的更新

    • class → 走 patchClass

    • style → 走 patchStyle

    • 事件 onXxx → 走 patchEvent

    • 其余属性 → 走 patchAttr

  5. 事件 invoker 技巧

    • 引入一个「事件调用封装器」(invoker) 作为真实的监听器,内部通过 invoker.value(e) 调用用户回调。

    • 更新事件处理函数时,只替换 invoker.value,避免了反复增删监听器带来的额外开销。

  6. nodeOps 插件化设计

    • 如果后续需要在其他平台(比如 SSR、Weex、Native)运行,只要编写一套针对目标平台的 nodeOps 即可复用核心 diff 算法。

7. 总结

本文从实现原理和具体代码出发,手把手演示了如何在浏览器环境下搭建一个简化版的 runtime-dom

  1. 明确渲染器的职责,把虚拟节点渲染成真实 DOM。

  2. 创建包结构,写好 package.json,引入必要依赖。

  3. 实现 nodeOps,把所有常用的 DOM 操作封装成一组方法。

  4. 实现 patchProp,并拆分出 patchClasspatchStylepatchEventpatchAttr 等细节逻辑,确保能灵活更新元素的属性、样式、事件。

  5. 组装渲染选项,把 nodeOpspatchProp 合并后传给 createRenderer,最终获得 .render(vnode, container) 方法。

通过以上步骤,你就能理解:

  • 在 Vue 3 体系下,runtime-core 负责纯算法(虚拟 DOM diff、组件生命周期管理等),

  • runtime-dom 只需提供「平台相关的方法」,并注入给核心渲染算法即可。

如此一来,整个渲染管道既保持了「高性能」、「可定制」,又能实现「一次编写,跨平台复用」。希望这篇文章能帮助你彻底搞懂 runtime-dom 的实现细节与设计思路。

相关文章:

  • 【Java算法】八大排序
  • Python学习(6) ----- Python2和Python3的区别
  • Kafka 消息队列
  • 嵌入式链表操作原理详解
  • 几何绘图与三角函数计算应用
  • 软件安全:漏洞利用与渗透测试剖析、流程、方法、案例
  • 《深度剖析Meta“Habitat 3.0”:AI训练的虚拟环境革新》
  • 蓝桥杯17114 残缺的数字
  • 大数据Spark(六十一):Spark基于Standalone提交任务流程
  • 缓存击穿 缓存穿透 缓存雪崩
  • python collections 模块
  • OffSec 基础实践课程助力美国海岸警卫队学院网络团队革新训练
  • 基于Web的安全漏洞分析与修复平台设计与实现
  • 最长连续序列
  • Kafka 单机部署启动教程(适用于 Spark + Hadoop 环境)
  • UE接口通信
  • 四款主流物联网操作系统(FreeRTOS、LiteOS、RT-Thread、AliOS)的综合对比分析
  • 常见排序算法详解与C语言实现
  • LINUX_LCD编程 TFT LCD
  • 数据结构 [一] 基本概念
  • 河南网站建设企业/怎样在平台上发布信息推广
  • 开饰品店网站建设预算/seo免费资源大全
  • 系统集成销售和网站建设销售/网络营销的工作内容包括哪些
  • 乌兰察布做网站/网盘搜索
  • 0元购怎么在网站做/推广引流最快的方法
  • 在百度怎么做网站和推广/网络游戏推广员