5. 「vue@2.6.11 源码分析」组件渲染之创建虚拟DOM

1,220 阅读6分钟

这是一个系列文章,请关注 vue@2.6.11 源码分析 专栏


vue@2.x中用到了虚拟DOM技术,基于第三方虚拟DOM库sanbbdom修改。建议阅读本文之前对snabbdom的使用和原理有一定的了解,可以参考 snabbdom@3.5.1 源码分析 专栏。

vue2中组件渲染的核心入口如下:

// src/core/instance/lifecycle.js
export function mountComponent (vm: Component, el: ?Element, hydrating?: boolean): Component {
  vm.$el = el
  //...

 let updateComponent
 updateComponent = () => {
   vm._update(vm._render(), hydrating)
 }
 new Watcher(vm, updateComponent, noop, {
   before () {
     if (vm._isMounted && !vm._isDestroyed) {
       callHook(vm, 'beforeUpdate')
     }
   }
 }, true /* isRenderWatcher */)
    
 //...

 return vm
}

其中vm._render用来生成虚拟DOM树的。而vm._update用来将上一步即vm._render生成的虚拟DOM树经过patch(打补丁)更新到界面上。

new Wacher(...)用法在上一节数据驱动详细分析过。updateComponent在首次创建Watcher实例时会执行一次,当updateComponent依赖的响应式数据变化时会再次执行。

因此上面new Watcher(vm, updateComponent,..)方法中的两个操作_render() -> _update(),相当于snabbdom的如下操作

  • 初始化时类比
const container = document.getElementById("container");
const vnode = h(...); // 创建虚拟节点树
patch(container, vnode); // 同步虚拟DOM树同步到界面
  • 响应式数据更新时类比
// 如果此时有数据变更引起界面变更
const newVnode = h(...); // 新的虚拟节点树
patch(vnode, newVnode); // 和上一次的虚拟节点树进行diff,将差异同步到界面上

这里的巧妙是new Watcher(...)将两个步骤合并到一起。

下面我们重点看下vue@2.x中关于虚拟DOM的相关逻辑。主要逻辑在src/core/vdom文件夹中。

image.png

从入口讲起

patch方法是跨平台的,因此在编译入口处便做了区分,web平台下

运行时的编译入口在:src/platforms/web/runtime/index.js,此时就定义了__patch__方法,然后在vm._update会调用vm.__patch__实现diff能力

// src/platforms/web/runtime/index.js

import { patch } from './patch'
//...
// install platform patch function
Vue.prototype.__patch__ = inBrowser ? patch : noop
//...
// src/platforms/web/runtime/patch.js

import * as nodeOps from 'web/runtime/node-ops'
import { createPatchFunction } from 'core/vdom/patch'
import baseModules from 'core/vdom/modules/index'
import platformModules from 'web/runtime/modules/index'

// the directive module should be applied last, after all
// built-in modules have been applied.
const modules = platformModules.concat(baseModules)

export const patch: Function = createPatchFunction({ nodeOps, modules })

createPatchFunction就相当于snabbdominit方法,nodeOps是因为跨平台的原因放在这里(私有化),这里重点关注modules,在snabbdom中说到module会借助patch过程中触发的各种钩子参与DOM的修改。这里都有哪些module呢,分为两类:基础module和跨平台module,如下: image.png

可能会单独出一个小节分析这些module

vnode

vue@2.x中vnode在snabbdom定义的vnode基础上增加了很多其他的属性

核心定义如下,但是在其构造函数中还定义了很多其他属性,是为了支持SSR、函数组件、异步组件等场景的(不必特别关注)。

// interface for vnodes in update modules
declare type VNodeWithData = {
  tag: string;
  data: VNodeData;
  children: ?Array<VNode>;
  text: void;
  elm: any;
  ns: string | void;
  context: Component;
  key: string | number | void;
  parent?: VNodeWithData;
  componentOptions?: VNodeComponentOptions;
  componentInstance?: Component;
  isRootInsert: boolean;
};

上面定义的大多数属性和snabbdom保持一致,多出的和组件有关,如下

vnode属性含义
context父组件实例
parent父vnode(placeholder vnode)
componentInstance当前组件实例
componentOptions父组件传递给当前组件实例的选项(属性,事件,孩子等等)

_render:创建虚拟DOM树

我们先看下vm._render方法的定义

  Vue.prototype._render = function (): VNode {
    const vm: Component = this
    const { render, _parentVnode } = vm.$options

    //... slot相关,暂忽略

    // set parent vnode. this allows render functions to have access
    // to the data on the placeholder node.
    vm.$vnode = _parentVnode
    // render self
    let vnode
    try {
      // There's no need to maintain a stack because all render fns are called
      // separately from one another. Nested component's render fns are called
      // when parent component is patched.
      currentRenderingInstance = vm
      vnode = render.call(vm._renderProxy, vm.$createElement)
    } catch (e) {
        //... 异常处理
    } finally {
      currentRenderingInstance = null
    }
    //... 
    // set parent
    vnode.parent = _parentVnode
    return vnode
  }

这里关注三个地方

  1. render函数的执行,render函数长什么样子呢?
    <!-- 原始模板 -->
    <div id="app"> {{ message }} </div>
    
    // 编译后的render函数
    (function anonymous() {
      with (this) {
        return _c('div', {attrs: {"id": "app"}}, [_v("\n  " + _s(message) + "\n")])
      }
    })
    
    render来自哪里?
    1. render函数可以由开发者自己提供
    2. 也提供了编译 + 运行时版本,即可有运行时编译,框架会自动处理将模板处理成render函数
    3. 更为常见的是.vue单文件开发,vue-loader会将其自动将template部分处理成render函数
  2. currentRenderingInstance 的设置
  3. 关系链接
    1. vm.$vnode = _parentVnode,当前组件实例的$vnode指向父vnode(即plcaeholder vnode)
    2. vnode.parent = _parentVnode,这里的vnode是组件实际内容的根vnode
    3. 这里返回的vnode,会在vm.update中被设置给vm:vm._vnode = vnode

所以,组件实例和vnode两个类型的父子关系都建立了:

vm._vnode = vnode // Vue.prototype._update 中设置的
vm.$vnode = _parentVnode
vnode.parent = _parentVnode

// initLifecycle 中设置的
vm.$parent = parent
parent.$children.push(vm)

下面重点看下render函数的执行,还是以上面的render函数为例,如下

<!-- 原始模板 -->
<div id="app"> {{ message }} </div>
// 编译后的render函数
(function anonymous() {
  with (this) {
    return _c('div', {attrs: {"id": "app"}}, [_v("\n  " + _s(message) + "\n")])
  }
})

显然里面用到了的_c_v都是函数,主要是_c,该函数等价于snabbdom的h函数,用来创建虚拟DOM。

需要注意到with的用法,with中的this就是组件实例,该实例上挂载_c这些方法,以及render函数中用到数据如上面demo中的messagewith特性

下面看下_c_v的定义

// src/core/instance/render.js
import { createElement } from '../vdom/create-element'
export function initRender (vm: Component) {
    //...
    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.
    vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)
    //...
}

当vue运行时代码执行时就会执行 renderMixin -> installRenderHelpers(Vue.prototype),该方法挂载了一些工具方法和创建DOM节点的方法。

export function installRenderHelpers (target: any) { // target: Vue.prototype
  //...
  target._s = toString
  //...
  target._v = createTextVNode
  //...
}

我们重点关注_c指向的createElement方法

createElement:创建vnode

import VNode, { createEmptyVNode } from './vnode'
import { createComponent } from './create-component'
//...

// alwaysNormalize: 调用 vm.$createElement 方法时,传递ture,看到 _render() -> render.call(vm, vm.$createElement),也就是执行用户自己提供的render函数时会走这里
// createFunctionComponent 又有可能
export function createElement (context: Component, tag: any, data: any, children: any, normalizationType: any, alwaysNormalize: boolean): VNode | Array<VNode> {
  //... 参数纠正  
  //... 特殊场景,属性规范化设置,不重要
  
  return _createElement(context, tag, data, children, normalizationType)
}

export function _createElement (context: Component, tag?: string | Class<Component> | Function | Object, data?: VNodeData, children?: any, normalizationType?: number): VNode | Array<VNode> {
  //... vnode data 不能是响应式数据,如果是返回空vnode
  
  // object syntax in v-bind
  if (isDef(data) && isDef(data.is)) { // 动态组件
    tag = data.is
  }
  
  //... 如果没有tag,返回空vnode  
  //... 规范化孩子,不重要
  
  let vnode
  if (typeof tag === 'string') {
    let Ctor
    if (config.isReservedTag(tag)) { 
      vnode = new VNode(config.parsePlatformTagName(tag), data, children, undefined, undefined, context)
    } else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) { // 组件
      // component
      vnode = createComponent(Ctor, data, context, children, tag)
    } else {
      // unknown or unlisted namespaced elements
      // check at runtime because it may get assigned a namespace when its
      // parent normalizes children
      vnode = new VNode(tag, data, children, undefined, undefined, context)
    }
  } else { // new Vue({render: h => h(App)}) // 用户手动提供 render函数
    // direct component options / constructor
    vnode = createComponent(tag, data, context, children)
  }
  
  if (Array.isArray(vnode)) { 
     // 如果vnode是数组,取第一个
  } else if (isDef(vnode)) {
    //...
    if (isDef(data)) registerDeepBindings(data)
    return vnode
  } else {
    // 如果没有返回空vnode
  }
}

// ref #5318
// necessary to ensure parent re-render when deep bindings like :style and
// :class are used on slot nodes
function registerDeepBindings (data) {
  if (isObject(data.style)) {
    traverse(data.style)
  }
  if (isObject(data.class)) {
    traverse(data.class)
  }
}

上面注释提到了children的规范化,解释参考黄轶-vue技术揭秘

下面看下核心逻辑,实际上很清晰了

如果tag是对象或者是组件构造函数(else分支),则调用createComponent创建组件虚拟节点即placeholder vnode。注意,这里并不会创建组件的vue实例,更不会进入组件内部去创建组件的实际内容,createComponent仅仅是创建组件标签(如<todo-item>)对应的vnode,本质上和div并无太多区别,主要是会挂载很多信息(props、events等等)

如果是保留tagdiv,直接new VNode

如果不是保留tagtodo-item,调用resolveAssetvm.$options.components中查找有没有定义该组件。注意,前面说到过组件实例选项的合并,会去合并祖先构造函数的选项如Vue.options,全局组件和指令等都保存在这里,因此这里自然也会查找到全局组件指令。

如果查找到有对应的组件定义,则调用createComponent创建placeholder vnode;否则就是创建一个未知vnode,同样会new VNode

  • ❎ registerDeepBindings 作用?看起来是处理slot场景有关,暂遗留。

下面看下组件placeholder vnode的创建过程,见createComponent分析,如下:

createComponent:创建组件tag的placeholder vnode

export function createComponent (Ctor: Class<Component> | Function | Object | void, data: ?VNodeData, context: Component, children: ?Array<VNode>, tag?: string): VNode | Array<VNode> | void {
  if (isUndef(Ctor)) return

  const baseCtor = context.$options._base
  if (isObject(Ctor)) {
    Ctor = baseCtor.extend(Ctor)
  }
 
  if (typeof Ctor !== 'function') return

  // async component
  let asyncFactory
  if (isUndef(Ctor.cid)) {
   // return ... 异步组件,单独的逻辑,后面会单独小节说
  }

  data = data || {}

  // resolve constructor options in case global mixins are applied after
  // component constructor creation
  // 从注释来看是担心先创建的组件构造函数而后再注册全局mixin
  resolveConstructorOptions(Ctor)

  // transform component v-model data into props & events
  if (isDef(data.model)) {
    transformModel(Ctor.options, data)
  }

  // extract props
  const propsData = extractPropsFromVNodeData(data, Ctor, tag)

  if (isTrue(Ctor.options.functional)) {
      // return ... 函数式组件的创建 是单独的逻辑,后面有可能单独小节说下
  }
  
  // extract listeners, since these needs to be treated as
  // child component listeners instead of DOM listeners
  const listeners = data.on
  // replace with listeners with .native modifier
  // so it gets processed during parent component patch.
  data.on = data.nativeOn

  if (isTrue(Ctor.options.abstract)) {
    //... 抽象组件的slot需要特殊处理? 如果时间允许单独看看
  }

  // install component management hooks onto the placeholder node
  installComponentHooks(data)

  // return a placeholder vnode
  const name = Ctor.options.name || tag
  const vnode = new VNode(`vue-component-${Ctor.cid}${name ? `-${name}` : ''}`, 
    data, undefined, undefined, undefined, context,
    { Ctor, propsData, listeners, tag, children }, asyncFactory)
 

  return vnode
}
  1. 创建组件肯定需要一个构造函数的,如果Ctor是组件对象(各种组件选项),会通过Vue.extend(Ctor),该方法通过原型继承返回一个构造函数,后面会说到。
  2. 如果是异步组件,则走异步组件vnode创建逻辑
  3. transformModel,有时间的话会单独分析一下v-model的实现,暂时忽略 ❎
  4. extractPropsFromVNodeData:创建一个对象res,来存储当前组件从父组件那里接受的属性值,该对象会经过initProps变成响应式对象,以被当前组件监听。注意这里的vnode.data是从render函数调用_c传过来的。
    export function extractPropsFromVNodeData (data: VNodeData, Ctor: Class<Component>, tag?: string): ?Object {
      // we are only extracting raw values here.
      // validation and default values are handled in the child component itself.
      const propOptions = Ctor.options.props
      if (isUndef(propOptions)) {
        return
      }
      const res = {}
      const { attrs, props } = data
      if (isDef(attrs) || isDef(props)) {
        for (const key in propOptions) {
           // hyphenate 将属性处理成连字符 "-" 连接
          const altKey = hyphenate(key) 
          checkProp(res, props, key, altKey, true) || checkProp(res, attrs, key, altKey, false)
        }
      }
      return res
    }
    
  5. 如果是函数组件,则单独走函数组件vnode创建逻辑
  6. 获取监听的事件名称,自定义事件在data.on上,native事件在data.nativeOn,处理后自定义事件保存到vnode.componentOptions.listeners上,native事件保存到vnode.data.on上。
    • 自定义事件是在_init -> initEvent中会用到,通过vue框架自己的事件机制(发布-订阅)实现;
    • 而native事件是在events模块(src/platforms/web/runtime/modules/events.js)上处理的,当然是通过浏览器提供的api如addEventListener来处理的
  7. installComponentHooks:给 vnode.data 添加部分钩子(initprepatchinsertdestroy),后面会碰到每个vnode钩子调用的时机,碰到时再针对每个钩子细说。注意这些钩子是安装给组件placeholder vnode上的,都非常重要。
// inline hooks to be invoked on component VNodes during patch
const componentVNodeHooks = {
  init (vnode: VNodeWithData, hydrating: boolean): ?boolean {
       //...
  },

  prepatch (oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) {
      //...
  },

  insert (vnode: MountedComponentVNode) {
      //...
  },

  destroy (vnode: MountedComponentVNode) {
       //...
  }
}
  1. 获取组件名称,创建组件标签对应的vnode(new VNode),这里重点是保存了组件的数据(事件、属性数据等),因为在后面vm._update会进真正开始创建子组件实例而后渲染子组件,而子组件的渲染是需要这些数据支撑的。

Vue.extend

/**
 * Class inheritance
 */
Vue.extend = function (extendOptions: Object): Function {
  extendOptions = extendOptions || {}
  const Super = this
  const SuperId = Super.cid

  const Sub = function VueComponent (options) {
    this._init(options)
  }
  Sub.prototype = Object.create(Super.prototype)
  Sub.prototype.constructor = Sub
  Sub.cid = cid++
  Sub.options = mergeOptions(
    Super.options,
    extendOptions
  )
  Sub['super'] = Super

  // For props and computed properties, we define the proxy getters on
  // the Vue instances at extension time, on the extended prototype. This
  // avoids Object.defineProperty calls for each instance created.
  if (Sub.options.props) {
    initProps(Sub)
  }
  if (Sub.options.computed) {
    initComputed(Sub)
  }

  // allow further extension/mixin/plugin usage
  Sub.extend = Super.extend
  Sub.mixin = Super.mixin
  Sub.use = Super.use

  // create asset registers, so extended classes
  // can have their private assets too.
  ASSET_TYPES.forEach(function (type) {
    Sub[type] = Super[type]
  })
  // enable recursive self-lookup
  if (name) {
    Sub.options.components[name] = Sub
  }

  // keep a reference to the super options at extension time.
  // later at instantiation we can check if Super's options have
  // been updated.
  Sub.superOptions = Super.options
  Sub.extendOptions = extendOptions
  Sub.sealedOptions = extend({}, Sub.options)

  return Sub
}

function initProps (Comp) {
  const props = Comp.options.props
  for (const key in props) {
    proxy(Comp.prototype, `_props`, key)
  }
}

function initComputed (Comp) {
  const computed = Comp.options.computed
  for (const key in computed) {
    defineComputed(Comp.prototype, key, computed[key])
  }
}

这里最重要的是通过原型继承返回一个子构造函数

const Sub = function VueComponent (options) {
    this._init(options)
}
Sub.prototype = Object.create(Super.prototype)
Sub.prototype.constructor = Sub
//...
return Sub

剩下的构造函数选项处理,不赘述。

另外注意到这里也调用了initPropsinitComputed的逻辑(这里处理的是静态属性),这是出于性能考虑,将公共执行逻辑提到构造函数中执行。有注释佐证,如下:

// initProps -> initProps方法中的部分注释
// static props are already proxied on the component's prototype
// during Vue.extend(). We only need to proxy props defined at
// instantiation here.

// initProps -> initComputed方法中的部分注释
// component-defined computed properties are already defined on the
// component prototype. We only need to define computed properties defined
// at instantiation here.

总结

vnode的创建,更重要的是组件placeholder vnode的创建。最终vm._render方法会返回一个根虚拟DOM(实际上是一个虚拟DOM树,对吧)。当然,下一步就是要把虚拟DOM数更新到界面上。

下一节,重点分析虚拟DOM -> 界面的过程