原稿:语雀 · 【原理二】模板渲染 · 原目录:Vue › 【原理二】模板渲染

Vnode 概述

参考链接:

什么是 Vnode

export default class VNode {
  // 主要属性
  tag: string | void;
  data: VNodeData | void;
  children: ?Array<VNode>;
  text: string | void;
  elm: Node | void;
  ns: string | void;
  context: Component | void; // rendered in this component's scope
  key: string | number | void;
  componentOptions: VNodeComponentOptions | void;
  componentInstance: Component | void; // component instance
  parent: VNode | void; // component placeholder node
    //...
}

Vnode 即 Vue 中的 Virtual DOM,这是通过 js 对象来描述真实的 DOM,将 DOM 的 tag、attrs、content 都当作对象的属性。它的核心定义无非就几个关键属性,标签名、数据、子节点、键值等,其它属性都是用来扩展 VNode 的灵活性以及实现一些特殊 feature 的。由于 VNode 只是用来映射到真实 DOM 的渲染,不需要包含操作 DOM 的方法,因此它是非常轻量和简单的。Vue 在渲染时,会将 template 描述成 Vnode,将 Vnode 形成 DOM 挂载到指定位置。

为什么要使用虚拟 DOM

  • 减少 DOM 操作:浏览器对于真实 DOM 的设计非常复杂,因此直接构建并操作 DOM 的开销很大,如果构造任何页面变化,都只使用 Vnode 进行操作对比,只需要在最后一步挂载更新 DOM,就能提高性能节省开销
  • 提高跨平台能力:不管是 Node 或浏览器都能解析 JS 对象,从而支持web端、服务端渲染
  • 虚拟 DOM 不一定可以提高性能:
  • 首次渲染的时候会增加开销
  • 复杂视图情况下提升渲染性能

Vnode 生成到挂载

源码分析

这里涉及到模板渲染过程,一步步描述 2. 在 initMixin() 的最后进行挂载

if (vm.$options.el) {
  vm.$mount(vm.$options.el);
}
  1. 在 mountComponent() 中会创建为当前实例创建一个 watcher(),并传入 updateComponent()
new Watcher(vm, updateComponent, noop, {
      before: function before () {
        if (vm._isMounted && !vm._isDestroyed) {
          callHook(vm, 'beforeUpdate');
        }
      }
    }, true /* isRenderWatcher */);
  1. watcher() 中会让传入 的updateComponent() 作为当前实例的 getter(),构造函数最后会直接调用 getter(),即updateComponent()
  2. updateComponent() 中会先调用 vm._render() 获取 Vnode,再调用 vm._update()
// vm._render() 生成虚拟DOM
// vm._update用来将虚拟DOM转换为真实DOM,再渲染到页面中
vm._update(vm._render(), hydrating);
  1. vm._render() 中获取并调用 render 函数(通常由 Vue 编译模板从优化后 AST 生成,可手动传入)
vnode = render.call(vm._renderProxy, vm.$createElement);

下方是一个解析模板后生成的 render 函数

function (){
    with(this){
        return _c('div',{attrs:{"href":"xxxx"}},["1111"]).
    }
}
  1. 渲染函数会执行 _c,即 $createElement,而 $createElement 实际调用 createElement()
  // 对编译生成的render进行渲染的方法
  vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)
  // normalization is always applied for the public version, used in
  // user-written render functions.

  // 对手写render函数进行渲染的方法
  vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)
  1. createElement() 中只是处理的参数,最后调用 _createElement()
return _createElement(context, tag, data, children, normalizationType)
  1. _createElement() 是最终生成 Vnode 的地方,该函数中先做了以下处理:
  2. 将传入的 Children 规范成 Vnode 类型:Children 可能是嵌套组件或 <slot> 生成的多维数组,normalize 与 simpleNormalize 分别处理这两种情况,拍平数组
  3. 规范化 Children 后,创建Vnode实例
if (normalizationType === ALWAYS_NORMALIZE) {
  children = normalizeChildren(children);
} else if (normalizationType === SIMPLE_NORMALIZE) {
  children = simpleNormalizeChildren(children);
}

之后若 tag 是标签,则会直接生成 Vnode,否则会调用 createComponent ,后者也会调用 Vnode 构造函数

function _createElement(
    context, tag, data,
    children, normalizationType
) {
    var vnode;
    if (tag是正常html标签) {
        vnode = new VNode(
            tag, data, children, undefined,
            undefined, context
        );
    }
    else if (tag 是组件) {
        vnode = createComponent(
            Ctor, data, context,
            children, tag
        );
    }
    return vnode
}
  1. 调用 Vnode 构造函数,一个 Vue 组件可能有多个 Vnode 构成树形结构
function VNode(
    tag, data, children,
    text, elm, context,
    componentOptions
) {
    this.tag = tag; // 标签名
    this.data = data;
    this.children = children; // 子元素
    this.text = text; // 文本内容
    this.elm = elm; // Dom 节点
    this.context = context;
    this.componentOptions = componentOptions;
    this.componentInstance = undefined;
    this.parent = undefined;
    this.isStatic = false; // 是否静态节点
    this.isComment = false; // 是否是注释节点
    this.isCloned = false; // 是否克隆节点
};
  1. 当vm._render() 调用完毕,Vnode tree 渲染完毕,开始执行 update(),_vnode 存放的是旧节点,接收的 vnode 是 render() 最新生成的 Vnode
Vue.prototype._update = function (vnode, hydrating) {
  var prevVnode = vm._vnode;
  vm._vnode = vnode;
  if (!prevVnode) {
    // 没有旧节点,直接使用vnode创建DOM并挂载
    vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */);
  } else {
    // 有旧节点,则比较新旧节点,打补丁后再挂载
    vm.$el = vm.__patch__(prevVnode, vnode);
  }
};

示例

假设有一个 template

<div class="parent" style="height:0" href="2222">
    111111
</div>

经过上述流程,最终生成的 Vnode

{
    tag: 'div',
    data: {
        attrs:{href:"2222"}
        staticClass: "parent",
        staticStyle: {
            height: "0"
        }
    },
    children: [{
        tag: undefined,
        text: "111111"
    }]
}

之后可以根据这个对象构造真实 DOM,因为这个过程也可以发生在服务端,因此有了服务端渲染


Patch & Diff 算法

上面的 vm.__patch__() 会调用 patch(),之后又会调用 patchVnode()

patchVnode() PatchPatch

该方法主要负责对比新旧 Vnode,找到差异,并更新真实 DOM。当新旧 Vnode 有不同子节点时,会执行 updateChildren()

function patchVnode (oldVnode, vnode, hydrating, removeOnly) {
  // 新旧节点是同一个对象,直接返回
  if (oldVnode === vnode) {
    return
  }

  // 新节点无text
  if (isUndef(vnode.text)) {
    // 新旧节点都有children
    if (isDef(oldCh) && isDef(ch)) {
      // 若新旧节点children不同,执行updateChildren()
      if (oldCh !== ch) { updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly); }
    }
    // 只有新节点有children
    else if (isDef(ch)) {
      { // 子节点是否有重复key
        checkDuplicateKeys(ch);
      }
      // 清空旧节点text
      if (isDef(oldVnode.text)) { nodeOps.setTextContent(elm, ''); }
      // 执行addVnodes
      addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
    }
    // 只有旧节点有children
    else if (isDef(oldCh)) {
      // 执行removeVnodes
      removeVnodes(oldCh, 0, oldCh.length - 1);
    }
    // 新旧节点都无children,旧节点有text
    else if (isDef(oldVnode.text)) {
      nodeOps.setTextContent(elm, '');
    }
  }
  // 新旧节点text不一致
  else if (oldVnode.text !== vnode.text) {
    nodeOps.setTextContent(elm, vnode.text);
  }
}

patch 算法小结

【原理二】模板渲染思维导图

updateChildren() DiffDiff

对比同一层的新旧子节点,找到可复用的节点,一共涉及8个变量:

  • 旧节点开始索引 oldStartIdx;新节点开始索引 newStartIdx;