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

目录

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

[2. 创建 runtime-dom 包的基本骨架](#2. 创建 runtime-dom 包的基本骨架)

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

关键点讲解

[4. 比对属性方法(patchProp)](#4. 比对属性方法(patchProp))

[4.1 操作类名:patchClass](#4.1 操作类名:patchClass)

[4.2 操作样式:patchStyle](#4.2 操作样式:patchStyle)

[4.3 操作事件:patchEvent](#4.3 操作事件:patchEvent)

[4.4 操作普通属性:patchAttr](#4.4 操作普通属性:patchAttr)

[5. 组装并导出渲染器](#5. 组装并导出渲染器)

[5.1 在 index.js 中引用并组合](#5.1 在 index.js 中引用并组合)

[5.2 使用示例](#5.2 使用示例)

[6. 整体流程回顾与核心要点](#6. 整体流程回顾与核心要点)

[7. 总结](#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.js

export const nodeOps = {
  // 在 parent 节点里,把 child 插入到 anchor 之前。如果 anchor 是 null,则相当于 appendChild
  insert: (child, parent, anchor) => {
    parent.insertBefore(child, anchor || null);
  },

  // 从父节点里移除 child
  remove: (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.js

import { 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、onMouseover
    patchEvent(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.js

    export 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.js

export function patchStyle(el, prev, next) {
  const style = el.style;

  if (next) {
    // 把 next 中所有属性写入 style
    for (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.value
  return invoker;
}
  1. 这样子,我们实际给 DOM 调用 addEventListener 的回调是 invoker,而不是 initialValue,好处是:

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

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

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

    // runtime-dom/src/patchEvent.js

    function 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,只替换它的 value
    existingInvoker.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.js

export 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.js

import { 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
      }
  2. 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 的实现细节与设计思路。

相关推荐
崔庆才丨静觅4 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60615 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了5 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅5 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅5 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅6 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment6 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅6 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊6 小时前
jwt介绍
前端
爱敲代码的小鱼6 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax