渲染器核心:mount挂载过程

在上一篇文章中,我们深入探讨了虚拟 DOM 的设计与创建。现在,我们有了描述界面的 VNode,接下来要做的就是将它们渲染到真实的页面上。这个过程就是渲染器的职责。本文将深入剖析 Vue3 渲染器的挂载(mount)过程,看看虚拟 DOM 如何一步步变成真实 DOM。

前言:从虚拟 DOM 到真实 DOM

当我们编写这样的 Vue 组件时:

javascript 复制代码
const App = {
  render() {
    return h('div', { class: 'container' }, [
      h('h1', 'Hello Vue3'),
      h('p', '这是渲染器的工作')
    ]);
  }
};

// 创建渲染器并挂载
createApp(App).mount('#app');

在这背后发生了一系列复杂而有序的操作:

本文将聚焦于首次渲染(mount)的过程。

渲染器的设计思想

为什么需要渲染器?

在深入了解代码之前,我们先思考一个问题:为什么 Vue 不直接将模板编译成 DOM 操作指令,而是要引入虚拟 DOM 和渲染器这一层?答案是:解耦跨平台

javascript 复制代码
// 如果直接编译成 DOM 操作
function render() {
  const div = document.createElement('div');
  div.className = 'container';
  // ... 只能运行在浏览器
}

// 通过渲染器抽象
function render(vnode, container) {
  // 具体的创建操作由渲染器实现
  // 浏览器渲染器:document.createElement
  // 小程序渲染器:wx.createView
  // Native 渲染器:createNativeView
}

渲染器的三层架构

Vue3 的渲染器采用了清晰的分层设计: 这种分层设计带来了极大的灵活性:

  • 渲染核心:实现 diff 算法、生命周期等通用逻辑
  • 平台操作层:提供统一的接口,由各平台实现
  • 目标平台:浏览器、小程序、Weex 等

渲染器的创建过程

创建渲染器工厂

渲染器本身是一个工厂函数,它接收平台操作作为参数,返回一个渲染器对象:

javascript 复制代码
/**
 * 创建渲染器
 * @param {Object} options - 平台操作选项
 * @returns {Object} 渲染器对象
 */
function createRenderer(options) {
  // 解构平台操作
  const {
    createElement,  // 创建元素
    createText,     // 创建文本节点
    createComment,  // 创建注释节点
    insert,         // 插入节点
    setText,        // 设置文本内容
    setElementText, // 设置元素文本
    patchProp       // 更新属性
  } = options;

  // ... 渲染核心逻辑

  return {
    render,        // 渲染函数
    createApp      // 创建应用
  };
}

这种设计模式称为依赖注入,它将平台相关的操作从核心逻辑中抽离出来,使得渲染核心可以跨平台复用。

浏览器平台的实现

对于浏览器平台,Vue 提供了对应的 DOM 操作:

javascript 复制代码
// 浏览器平台操作
const nodeOps = {
  // 创建元素:直接调用 document.createElement
  createElement(tag) {
    return document.createElement(tag);
  },
  
  // 创建文本节点
  createText(text) {
    return document.createTextNode(text);
  },
  
  // 创建注释节点
  createComment(text) {
    return document.createComment(text);
  },
  
  // 插入节点:使用 insertBefore 实现通用插入
  insert(child, parent, anchor = null) {
    parent.insertBefore(child, anchor);
  },
  
  // 设置元素文本内容
  setElementText(el, text) {
    el.textContent = text;
  },
  
  // 设置文本节点内容
  setText(node, text) {
    node.nodeValue = text;
  }
};

创建应用 API

渲染器还负责提供 createApp API,这是 Vue 应用的入口:

javascript 复制代码
function createAppAPI(render) {
  return function createApp(rootComponent) {
    const app = {
      // 挂载方法
      mount(rootContainer) {
        // 1. 创建根 VNode
        const vnode = createVNode(rootComponent);
        
        // 2. 调用渲染器
        render(vnode, rootContainer);
        
        // 3. 返回组件实例
        return vnode.component;
      }
    };
    return app;
  };
}

首次渲染的完整流程

从 render 到 patch

当调用 app.mount('#app') 时,渲染器开始工作:

javascript 复制代码
function render(vnode, container) {
  if (vnode) {
    // 存在新 VNode,进行 patch
    // container._vnode 存储上一次的 VNode,首次为 null
    patch(container._vnode || null, vnode, container);
  } else {
    // 没有新 VNode,卸载旧节点
    if (container._vnode) {
      unmount(container._vnode);
    }
  }
  // 保存当前 VNode
  container._vnode = vnode;
}

patch 的分发逻辑

patch 是整个渲染器的核心函数,它根据节点类型分发到不同的处理函数:

javascript 复制代码
function patch(oldVNode, newVNode, container, anchor = null) {
  // 首次渲染,oldVNode 为 null
  if (oldVNode == null) {
    // 根据类型选择挂载方式
    const { type, shapeFlag } = newVNode;
    
    switch (type) {
      case Text:      // 文本节点
        mountText(newVNode, container, anchor);
        break;
      case Comment:   // 注释节点
        mountComment(newVNode, container, anchor);
        break;
      case Fragment:  // 片段
        mountFragment(newVNode, container, anchor);
        break;
      default:
        if (shapeFlag & ShapeFlags.ELEMENT) {
          // 原生元素
          mountElement(newVNode, container, anchor);
        } else if (shapeFlag & ShapeFlags.COMPONENT) {
          // 组件
          mountComponent(newVNode, container, anchor);
        }
    }
  }
}

下图展示了 patch 的分发流程:

为什么需要这么多类型?

不同类型的节点在 DOM 中的表现完全不同:

节点类型 真实 DOM 表示 特点
元素节点 HTMLElement 有标签名、属性、子节点
文本节点 TextNode 只有文本内容
注释节点 Comment 用于注释,不影响渲染
Fragment 无对应节点 多个根节点的容器

原生元素的挂载详解

mountElement 的四个步骤

挂载一个原生元素需要四个核心步骤:

  1. 创建 DOM 元素
  2. 保存 DOM 元素引用
  3. 处理子节点和属性
  4. 插入到容器
javascript 复制代码
function mountElement(vnode, container, anchor) {
  const { type, props, shapeFlag } = vnode;
  
  // 步骤1:创建 DOM 元素
  const el = hostCreateElement(type);
  
  // 步骤2:保存 DOM 元素引用
  vnode.el = el;
  
  // 步骤3:处理子节点和属性
  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    // 情况A:文本子节点
    hostSetElementText(el, vnode.children);
  } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    // 情况B:数组子节点
    mountChildren(vnode.children, el);
  }
  
  if (props) {
    for (const key in props) {
      hostPatchProp(el, key, null, props[key]);
    }
  }
  
  // 步骤4:插入到容器
  hostInsert(el, container, anchor);
}

子节点的递归挂载

数组子节点的挂载是一个递归过程:

javascript 复制代码
function mountChildren(children, container) {
  for (let i = 0; i < children.length; i++) {
    const child = children[i];
    // 递归调用 patch 挂载每个子节点
    // 注意:这里传入的 oldVNode 为 null
    patch(null, child, container);
  }
}

一个完整的挂载示例

让我们通过一个具体例子,观察挂载的全过程:

javascript 复制代码
// 示例 VNode
const vnode = {
  type: 'div',
  props: {
    class: 'card',
    id: 'card-1',
    'data-index': 0
  },
  children: [
    {
      type: 'h2',
      props: { class: 'title' },
      children: '标题',
      shapeFlag: ShapeFlags.ELEMENT | ShapeFlags.TEXT_CHILDREN
    },
    {
      type: 'p',
      props: { class: 'content' },
      children: '内容',
      shapeFlag: ShapeFlags.ELEMENT | ShapeFlags.TEXT_CHILDREN
    }
  ],
  shapeFlag: ShapeFlags.ELEMENT | ShapeFlags.ARRAY_CHILDREN
};

// 执行挂载
mountElement(vnode, document.getElementById('app'));

// 生成的真实 DOM:
// <div class="card" id="card-1" data-index="0">
//   <h2 class="title">标题</h2>
//   <p class="content">内容</p>
// </div>

属性的处理

属性的分类

在 Web 开发中,元素的属性分为以下几类:

  1. 普通属性:<div id="app" title="标题"></div>
  2. 类名:<div class="container active"></div>
  3. 样式:<div style="color: red; font-size: 16px"></div>
  4. 事件:<div onclick="handleClick"></div>
  5. DOM 属性:<div hidden disabled></div>

属性的设置方式

不同类型的属性,设置方式也不同:

类型 设置方式 示例
普通属性 setAttribute el.setAttribute('id', 'app')
类名 className el.className = 'container'
样式 style 对象 el.style.color = 'red'
事件 addEventListener el.addEventListener('click', handler)
DOM 属性 直接赋值 el.hidden = true

patchProp 的分发逻辑

Vue3 的 patchProp 函数需要处理以下这些情况:

  1. 处理事件:patchEvent(el, key, prevValue, nextValue);
  2. 处理 class:patchClass(el, nextValue);
  3. 处理 style:patchStyle(el, prevValue, nextValue);
  4. 处理 DOM 属性:patchDOMProp(el, key, nextValue);
  5. 处理普通属性:patchAttr(el, key, nextValue);

事件处理的优化

事件处理有一个重要的优化点:避免频繁添加/移除事件监听

不好的做法:每次更新都移除再添加

javascript 复制代码
function patchEventBad(el, key, prevValue, nextValue) {
  const eventName = key.slice(2).toLowerCase();
  
  if (prevValue) {
    el.removeEventListener(eventName, prevValue);
  }
  if (nextValue) {
    el.addEventListener(eventName, nextValue);
  }
}

Vue3 的做法:使用 invoker 缓存

javascript 复制代码
function patchEvent(el, rawKey, prevValue, nextValue) {
  const eventName = rawKey.slice(2).toLowerCase();
  
  // 使用 el._vei 存储事件调用器
  const invokers = el._vei || (el._vei = {});
  let invoker = invokers[eventName];
  
  if (nextValue && invoker) {
    // 有旧调用器:只更新值
    invoker.value = nextValue;
  } else if (nextValue && !invoker) {
    // 无旧调用器:创建新调用器
    invoker = createInvoker(nextValue);
    invokers[eventName] = invoker;
    el.addEventListener(eventName, invoker);
  } else if (!nextValue && invoker) {
    // 没有新值:移除监听
    el.removeEventListener(eventName, invoker);
    invokers[eventName] = null;
  }
}

function createInvoker(initialValue) {
  const invoker = (e) => {
    invoker.value(e);
  };
  invoker.value = initialValue;
  return invoker;
}

这种设计的优势在于:事件监听只添加一次,后续更新只改变回调函数:

样式的合并处理

patchStyle 需要处理三种情况:

  1. 没有新样式:el.removeAttribute('style');
  2. 新样式是字符串:style.cssText = next;
  3. 新样式是对象:
javascript 复制代码
// 设置新样式
for (const key in next) {
  setStyle(style, key, next[key]);
}

// 移除旧样式中不存在于新样式的属性
if (prev && typeof prev !== 'string') {
  for (const key in prev) {
    if (next[key] == null) {
      setStyle(style, key, '');
    }
  }
}

文本节点和注释节点

文本节点的处理

文本节点是最简单的节点类型:

javascript 复制代码
// 文本节点的类型标识(Symbol 保证唯一性)
const Text = Symbol('Text');
function mountText(vnode, container, anchor) {
  // 1. 创建文本节点
  const textNode = document.createTextNode(vnode.children);
  
  // 2. 保存真实节点引用
  vnode.el = textNode;
  
  // 3. 插入到容器
  container.insertBefore(textNode, anchor);
}

文本节点在 DOM 中的表现:

html 复制代码
<!-- 文本节点没有标签,只有内容 -->
Hello World

注释节点的处理

注释节点用于调试和特殊场景:

javascript 复制代码
const Comment = Symbol('Comment');

function mountComment(vnode, container, anchor) {
  // 创建注释节点
  const commentNode = document.createComment(vnode.children);
  vnode.el = commentNode;
  container.insertBefore(commentNode, anchor);
}

注释节点在 DOM 中的表现:

html 复制代码
<!-- 这是一个注释节点,不会显示在页面上 -->

Fragment 的处理

Fragment 是 Vue3 新增的特性,允许组件返回多个根节点:

javascript 复制代码
const Fragment = Symbol('Fragment');

function mountFragment(vnode, container, anchor) {
  const { children, shapeFlag } = vnode;
  
  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    // 文本子节点:挂载为文本节点
    mountText(createTextVNode(children), container, anchor);
  } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    // 数组子节点:挂载所有子节点
    mountChildren(children, container);
  }
  
  // Fragment 本身没有真实 DOM
  // el 指向第一个子节点的 el
  vnode.el = children[0]?.el;
  // anchor 指向最后一个子节点的 el
  vnode.anchor = children[children.length - 1]?.el;
}

Fragment 的 DOM 表现:

html 复制代码
<!-- 没有外层包裹元素 -->
<h1>标题</h1>
<p>段落1</p>
<p>段落2</p>

完整的渲染器实现

让我们将上述所有概念整合,实现一个可工作的简化版渲染器:

javascript 复制代码
class Renderer {
  constructor(options) {
    // 注入平台操作
    this.createElement = options.createElement;
    this.createText = options.createText;
    this.createComment = options.createComment;
    this.insert = options.insert;
    this.setElementText = options.setElementText;
    this.patchProp = options.patchProp;
  }

  render(vnode, container) {
    if (vnode) {
      this.patch(null, vnode, container);
      container._vnode = vnode;
    } else if (container._vnode) {
      this.unmount(container._vnode);
    }
  }

  patch(oldVNode, newVNode, container, anchor = null) {
    if (oldVNode === newVNode) return;
    
    const { type, shapeFlag } = newVNode;
    
    // 根据类型分发
    if (type === Text) {
      this.processText(oldVNode, newVNode, container, anchor);
    } else if (type === Comment) {
      this.processComment(oldVNode, newVNode, container, anchor);
    } else if (type === Fragment) {
      this.processFragment(oldVNode, newVNode, container, anchor);
    } else if (shapeFlag & ShapeFlags.ELEMENT) {
      this.processElement(oldVNode, newVNode, container, anchor);
    } else if (shapeFlag & ShapeFlags.COMPONENT) {
      this.processComponent(oldVNode, newVNode, container, anchor);
    }
  }

  processElement(oldVNode, newVNode, container, anchor) {
    if (oldVNode == null) {
      this.mountElement(newVNode, container, anchor);
    } else {
      this.patchElement(oldVNode, newVNode);
    }
  }

  mountElement(vnode, container, anchor) {
    // 1. 创建元素
    const el = this.createElement(vnode.type);
    vnode.el = el;
    
    // 2. 处理属性
    if (vnode.props) {
      for (const key in vnode.props) {
        this.patchProp(el, key, null, vnode.props[key]);
      }
    }
    
    // 3. 处理子节点
    const { shapeFlag, children } = vnode;
    if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
      this.setElementText(el, children);
    } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      this.mountChildren(children, el);
    }
    
    // 4. 插入容器
    this.insert(el, container, anchor);
  }

  mountChildren(children, container) {
    for (let i = 0; i < children.length; i++) {
      this.patch(null, children[i], container);
    }
  }

  processText(oldVNode, newVNode, container, anchor) {
    if (oldVNode == null) {
      const textNode = this.createText(newVNode.children);
      newVNode.el = textNode;
      this.insert(textNode, container, anchor);
    } else {
      const el = (newVNode.el = oldVNode.el);
      if (newVNode.children !== oldVNode.children) {
        el.nodeValue = newVNode.children;
      }
    }
  }

  processComment(oldVNode, newVNode, container, anchor) {
    if (oldVNode == null) {
      const commentNode = this.createComment(newVNode.children);
      newVNode.el = commentNode;
      this.insert(commentNode, container, anchor);
    } else {
      newVNode.el = oldVNode.el;
    }
  }

  processFragment(oldVNode, newVNode, container, anchor) {
    if (oldVNode == null) {
      const { shapeFlag, children } = newVNode;
      if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
        this.patch(null, {
          type: Text,
          children
        }, container, anchor);
      } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        this.mountChildren(children, container);
      }
    } else {
      this.patchChildren(oldVNode, newVNode, container);
    }
  }

  unmount(vnode) {
    const parent = vnode.el.parentNode;
    if (parent) {
      parent.removeChild(vnode.el);
    }
  }
}

性能优化与最佳实践

避免不必要的挂载

在实际开发中,需要注意避免频繁的挂载和卸载:

javascript 复制代码
// 不推荐:频繁切换导致反复挂载/卸载
function BadExample() {
  return show.value 
    ? h(HeavyComponent) 
    : null;
}

// 推荐:使用 keep-alive 缓存组件
function GoodExample() {
  return h(KeepAlive, null, [
    show.value ? h(HeavyComponent) : null
  ]);
}

合理使用 key

key 在 diff 算法中起着关键作用:

javascript 复制代码
// 不推荐:使用索引作为 key
items.map((item, index) => 
  h('div', { key: index }, item.text)
);

// 推荐:使用唯一标识
items.map(item => 
  h('div', { key: item.id }, item.text)
);

为什么不推荐使用索引作为 key:

静态内容提升

对于不会变化的静态内容,应该避免重复创建 VNode:

javascript 复制代码
// 编译器会自动优化
// 源码:
// <div>
//   <span>静态文本</span>
//   <span>{{ dynamic }}</span>
// </div>

// 编译后:
const _hoisted_1 = h('span', '静态文本');

function render(ctx) {
  return h('div', [
    _hoisted_1,  // 直接复用
    h('span', ctx.dynamic)
  ]);
}

事件委托优化

对于大量相似元素的交互,使用事件委托:

javascript 复制代码
// 不推荐:每个元素独立事件
list.value.map(item => 
  h('button', {
    onClick: () => handleItem(item)
  }, item.name)
);

// 推荐:使用事件委托
function handleListClick(e) {
  const target = e.target;
  if (target.tagName === 'BUTTON') {
    const index = target.dataset.index;
    handleItem(list.value[index]);
  }
}

h('div', { onClick: handleListClick },
  list.value.map((item, index) => 
    h('button', { 
      'data-index': index 
    }, item.name)
  )
);

完整挂载流程图

下面是完整的挂载流程图:

结语

本文主要介绍了 Vue3 渲染器的挂载全过程,对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

相关推荐
wuhen_n1 小时前
Vue3 组件生命周期详解
前端·javascript·vue.js
简离1 小时前
JS 函数参数默认值误区解析:传 null 为何不触发默认值?
前端
正儿八经蛙1 小时前
AI应用开发框架对比:LangChain vs. Semantic Kernel vs. DSPy 深度解析
前端
不想秃头的程序员1 小时前
vue3 Pinia 全解析:从入门到实战。
前端·javascript·vue.js
Mintopia1 小时前
提升 Canvas 2D 绘图技术:应对全面工业化场景的系统方法
前端
wuhen_n1 小时前
组件渲染:从组件到DOM
前端·javascript·vue.js
zhougl9961 小时前
Composition API 和 Options API
前端·javascript·vue.js
wuhen_n1 小时前
虚拟DOM:VNode的设计与创建
前端·javascript·vue.js
归叶再无青2 小时前
web服务安装部署、性能升级等(Apache、Nginx)
运维·前端·nginx·云原生·apache·bash