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

我心飞翔 分类:vue

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用来生产虚拟节点树的,就像snabbdom中使用h函数创建虚拟节点树一样。而vm._update用来将上一步即vm._render生成的虚拟节点树经过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文件夹中。

.

从入口讲起(对应snabbdom库 init 方法)

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

.

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

小结

这里主要是创建patch方法的过程

vnode

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

  • 后面用到逐个介绍?最终统一在这里汇总一次?

_render:创建虚拟节点树

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

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

    if (_parentVnode) {
      //... 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
  }

这里关注三个地方

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函数

currentRenderingInstance 的设置

关系链接

  1. vm.vnode = _parentVnode,当前组件实例的vnode指向父虚拟节点
  2. 虚拟节点父子关系建立:vnode.parent = _parentVnode
  3. 这里返回的vnode,会在vm.update中被设置给vm:vm._vnode = vnode

下面重点看下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) &amp;&amp; isDef(data.is)) { // 动态组件
    tag = data.is
  }
  
  //... 如果没有tag,返回空vnode  
  //... 规范化孩子,不重要
  
  let vnode, ns
  if (typeof tag === 'string') {
    let Ctor
    ns = (context.$vnode &amp;&amp; context.$vnode.ns) || config.getTagNamespace(tag)
    if (config.isReservedTag(tag)) { 
      vnode = new VNode(config.parsePlatformTagName(tag), data, children, undefined, undefined, context)
    } else if ((!data || !data.pre) &amp;&amp; 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技术揭秘
  • 记个todo,验证下(虽然不影响整体流程)

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

  1. 如果tag是对象或者是组件构造函数,则调用createComponent创建组件的虚拟节点(注意,这里并不会创建组件的vue实例,更不会进入组件内部去创建组件的实际内容),createComponent仅仅是创建组件标签(如<todo-item>)对应的vnode,本质上和div并无太多区别,主要是会挂载很多信息(props, events等等)
  2. 如果是保留tagdiv,直接new vnode
  3. 如果不是保留tag如todo-item,并从 vm.components 中查找有没有定义该组件
    1. 如果有则 createComponent
    2. 否则就是创建一个位置节点,同样会new vnode (和div的vnode的创建没啥区别)
  • 记个todo, registerDeepBindings 作用?看起来是处理slot场景的bug❓❓❓

下面看下组件vnode创建场景:createComponent

createComponent:创建【组件tag】的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
  resolveConstructorOptions(Ctor)

  // transform component v-model data into props &amp; 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
}

创建组件肯定需要一个构造函数的,如果是组件对象如Ctor(data/props/methods等等),会通过Vue.extend(Ctor),该方法通过原型继承返回一个构造函数,后面会说到。

如果是异步组件,则走异步组件vnode创建逻辑

resolveConstructorOptions:从注释来看,是担心先创建的组件构造函数而后再注册全局mixin

  • 待验证,处理特殊场景,非核心逻辑,不重要

transformModel:(自定义组件v-model),看起来只是语法糖:取值,添加事件回调,

TODO,demo验证下?,不影响主流程

// transform component v-model info (value and callback) into
// prop and event handler respectively.
function transformModel (options, data: any) {
const prop = (options.model &amp;&amp; options.model.prop) || 'value'
const event = (options.model &amp;&amp; options.model.event) || 'input'
;(data.attrs || (data.attrs = {}))[prop] = data.model.value
const on = data.on || (data.on = {})
const existing = on[event]
const callback = data.model.callback
if (isDef(existing)) {
 if (Array.isArray(existing) ? existing.indexOf(callback) === -1 : existing !== callback) {
   on[event] = [callback].concat(existing)
 }
} else {
 on[event] = callback
}
}

extractPropsFromVNodeData:这里只是从传递个组件的数据(vnode.data.attrs || vnode.data.props)中提取属性值(组件定义的props)。这里返回的是一个新对象,该对象后面会变成响应式对象,以被当前组件监听。
注意: 有个细节:从vnode.data.attrs中提取完数据,会对应的属性删除掉,而vnode.data.props则不会。

为什么这么做呢❓❓❓❓

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) {
   const altKey = hyphenate(key) 
   checkProp(res, props, key, altKey, true) || checkProp(res, attrs, key, altKey, false)
 }
}
return res
}

如果是函数组件,则单独走函数组件vnode创建逻辑

获取事件回调,自定义事件在data.on上,native事件在data.nativeOn,处理后自定义事件保存到vnode.componentOptions.listeners上,native事件保存到vnode.data.on上。(在 _init -> initEvent中会用到)

installComponentHooks:给 vnode.data 添加部分钩子(init、prepatch、insert、destroy),后面会碰到每个vnode钩子调用的时机,碰到时在针对每个钩子细说。

获取组件名称,创建组件标签对应的vnode(new vnode),这里重点是保存了组件的数据(事件、属性数据等),因为在后面_update会深入组件内部,进入组件的渲染,而组件的渲染是需要这些数据支撑的。

总结

创建虚拟DOM树,下一步就是调用vm._update将虚拟DOM树同步到界面上。

下一节,重点分析虚拟DOM到界面的过程(包括初始化和更新)。

回复

我来回复
  • 暂无回复内容