手写 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 的实现细节与设计思路。

相关推荐
hang_bro5 分钟前
使用js方法实现阻止按钮的默认点击事件&触发默认事件
前端·react.js·html
哈贝#5 分钟前
vue和uniapp聊天页面右侧滚动条自动到底部
javascript·vue.js·uni-app
用户907387036486414 分钟前
pnpm是如何解决幻影依赖的?
前端
树上有只程序猿20 分钟前
Claude 4提升码农生产力的5种高级方式
前端
傻球21 分钟前
没想到干前端2年了还能用上高中物理运动学知识
前端·react.js·开源
咚咚咚ddd21 分钟前
前端组件:pc端通用新手引导组件最佳实践(React)
前端·react.js
Lazy_zheng22 分钟前
🚀 前端开发福音:用 json-server 快速搭建本地 Mock 数据服务
前端·javascript·vue.js
HJ_Coder22 分钟前
基于Proxyman的实时解密和预览方案
前端
用户25191624271122 分钟前
ES6之块级绑定
javascript
Gixy22 分钟前
聊聊纯函数与不可变数据结构
前端·设计模式