vue源码解析-组件化&虚拟DOM

23,079 阅读8分钟

上一篇,我们分析了compiler过程,其核心是将template转化为render函数。

那么,问题来了:

  • render函数执行后:得到的是什么?
  • 虚拟DOM又是什么?
  • vue多层组件嵌套,其组件化又是如何实现的?

image.png

我们带着这些问题,来一探究竟。

一. Render函数

我们知道,compiler结果是个render函数。(不熟悉的小伙伴,可以看我的上一篇文章:vue源码解析-compiler)。

先来看一个 🌰:

<html>
  <head>
    <meta charset="utf-8"/>
  </head>

  <body>
    <div id='root'>
    
    </div>
    <script src="../vue/dist/vue.js"></script>
    <script>

      Vue.component("test", {
        template: "<div>{{ testName }}</div>",
        data() {
          return {
            testName: '这是测试名称啊'
          }
        }
      })

      let vm = new Vue({
        el: '#root',
        data() {
          return {
            a: "这是根节点"
          }
        },
        template: "<div data-test='这是测试属性'> <test/> </div>",
      })
      
    </script>
  </body>
</html>

执行结果如下:

image.png

vue在mount的时候,会调用如下代码:

mountComponent 主干:

updateComponent = () => {
  vm._update(vm._render(), hydrating)
}

updateComponent是在Watcher实例化时调用,这里vm会执行update方法更新视图,而参数是render函数的返回结果。下面,我们重点分析vm._render背后发生了什么

_render函数,在vue初始化renderMixin的时候有定义。其核心代码如下:

renderMixin

Vue.prototype._render = function (): VNode {
  const vm: Component = this
  const { render, _parentVnode } = vm.$options
  
  // ...
  try {
    vnode = render.call(vm._renderProxy, vm.$createElement)
  }catch(e) {
    // ...
  }
  
  // ...
  return vnode
}

我们可以看到,执行_render方法,实际上就是执行 render.call(vm._renderProxy, vm.$createElement)

而render的定义在vm.$options上, 这个其实就是compiler出来的render函数。render函数结果如下:(这一步不清楚的小伙伴可以参考我的上一篇分享:vue源码解析-compiler)

(
  function anonymous() {
    with(this){
      return _c(
        'div',
        {
          attrs:{
             "data-test":"这是测试属性"
          }
        },
        [
         _c('test')
        ],
        1
      )
    }
  }
)

render.call实际上执行就是这个函数。this的指向,就是当前组件实例化的对象。首次执行是Vue。

那么_c又是什么呢?这个定义,实际上在vue初始化initRender定义的。

 vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)

下面,我们重点分析createElement

二. createElement

render函数执行后,最终是调用的createElement函数,其参数就是function anonymous对应的入参。

我们先来看下createElement函数的定义:

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> {
  // 做了一些判断,避免使用可观察的data对象做虚拟节点data,否则返回 空节点
  
  // ...
  
  // tag不存在,返回空节点
  
  // ...
  
  if(typeof tag == 'string') {
    let Ctor
    
    if (config.isReservedTag(tag)) {
      // 如果是平台保留标签,将返回一个虚拟DOM
       vnode = new VNode(
        config.parsePlatformTagName(tag), data, children,
        undefined, undefined, context
      )
    }else if( 
      (!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))
    ) {
      // 这里就是我们写个 组件 components
      vnode = createComponent(Ctor, data, context, children, tag)
    } else {
      // 未知标签,返回一个虚拟dom
      vnode = new VNode(tag, data, children, undefined, undefined, context)
    }
  } else {
    // 直接组件 options / constructor
    vnode = createComponent(tag, data, context, children)
  }
  
  // ...
  
  return vnode
}

根据render()执行,首先会执行 _c("test"),实际上调用的是 createElement(vm, "test", undefined, undefined, undefined, false) 这个时候,会进入 createComponent 逻辑。

需要说明的是,从这里开始,需要聊到vue组件化了。这里,我们重点关注 4个函数:

    1. Vue.component
    1. extend
    1. resolveAsset
    1. createComponent

下面,我们来逐个分析

三. 组件化-Vue.component

在我们的demo中,先注册了test组件,然后在div中使用。

我们先待了解Vue.component背后发生了什么。

在vue初始化时,会调用一个 initGlobalAPI 方法,这个函数里面,就是声明的Vue下面的全局Api,比如我们经常使用的:Vue.component, Vue.extend, Vue.use, Vue.minxin等等。

其中initAssetRegisters这一步很重要。我们先来看主干代码:

ASSET_TYPES.forEach(type => {
    Vue[type] = function(id: string, definition: Function | Object): Function | Object | void {
      // ...
      
      if (type === 'component' && isPlainObject(definition)) {
          definition.name = definition.name || id
          definition = this.options._base.extend(definition)
      }
      // ...
      this.options[type + 's'][id] = definition
      return definition
    }
})

ASSET_TYPES如下:

export const ASSET_TYPES = [
  'component',
  'directive',
  'filter'
]

这里我们可以看到Vue.component第一个参数就是组件的name,第二个是组件的options。

this.options._base指向的就是Vue的构造函数。下面我们看Vue.extend方法

四. 组件化-extend

主干代码如下:

export function initExtend (Vue: GlobalAPI) {
  const Super = this
  const SuperId = Super.cid
  
  // 省略cid cache
  // ...
  
  // 校验组件名称...
  
  const Sub = function VueComponent (options) {
    this._init(options)
  }
  Sub.prototype = Object.create(Super.prototype)
  Sub.prototype.constructor = Sub
  
  Sub.options = mergeOptions(
    Super.options,
    extendOptions
  )
  Sub['super'] = Super
  
  // 初始化props...
  
  // 初始化computed...
  
  // 初始化VueComponent构造函数的方法,实际上使用的是Vue构造函数方法
  Sub.extend = Super.extend
  Sub.mixin = Super.mixin
  Sub.use = Super.use
  
  // 添加asset types, 其实就是3个值 component, directive, filter
  ASSET_TYPES.forEach(function (type) {
    Sub[type] = Super[type]
  })
  
  // ...其他options
  
  // cache superId, 下次执行extend时,在cache中存在,直接返回,不必再走继承流程
  
  return Sub;
}

image.png

可以看到,在我们使用Vue.component注册组件的时候:被注册的组件,他也是个构造函数,只是他不是Vue,他是VueComponent。

而VueComponent继承了Vue,他拥有了Vue的一切。

好了,到这里,我们终于知道,注册组件,实际上就是以下结构:

Vue.$options.components.__proto__ = {
  test: VueComponent // 子组件的构造函数
}

而这个VueComponent通过原型继承至Vue,所以如果子组件里面,还有孙组件。那么子组件中的结构就变成如下:

VueComponent.$options.components.__proto__ = {
  "孙组件name": VueComponent // 孙组件的构造函数
}

如此递归下去,在每个组件的$options.components属性上,都存放着对应的子组件的构造函数。这一点很重要,这才是实现组件化的开始。

五. 组件化-resolveAsset

在createElement中,我们看到y有这么两行代码:

let Ctor
// ...
Ctor = resolveAsset(context.$options, 'components', tag)
// ...

这一步也很重要,我们继续看resolveAsset 主干:

export function resolveAsset (
  options: Object,
  type: string,
  id: string,
  warnMissing?: boolean
): any {
  // ...
  const assets = options[type]
  
  if (hasOwn(assets, id)) return assets[id]
  const camelizedId = camelize(id)
  if (hasOwn(assets, camelizedId)) return assets[camelizedId]
  const PascalCaseId = capitalize(camelizedId)
  if (hasOwn(assets, PascalCaseId)) return assets[PascalCaseId]
  
  
  const res = assets[id] || assets[camelizedId] || assets[PascalCaseId]
  
  // ...
  return res;
}

这里已经很清晰了,assets变量指的就是Vue.$options.components对象。id就是 我们demo中的子组件name = "test"。

这里,我们看到camelize和capitalize这2个函数。

  • 其中camelize就是将子组件名使用中划线连接起来,比如,我们的组件name = "helloWorld", 最终转化为 hello-world使用。 这也是为什么,我们使用中划线的方式可以引用组件的原因。
  • 其中capitalize函数,是将首字母进行大写,比如:name = "test",组件名称将转化为Test,这也是为什么,我们在vue中,首字母大写组件名也能正常引用的原因。

到这里,我们可以看到,最后返回的是Vue.$options.components.test,其实就是子组件的构造函数。即Ctor就是子组件test的构造函数。

六. 组件化-createComponent

主干代码如下:

export function createComponent (
  Ctor: Class<Component> | Function | Object | void,
  data: ?VNodeData,
  context: Component,
  children: ?Array<VNode>,
  tag?: string
): VNode | Array<VNode> | void {
  
  // ...
  
  // 异步组件暂不在讨论范围之内,暂时忽略,后面关注我,分析异步组件实现
  
  // 调用子组件options merge 
  
  // ...
  
  // 函数式组件,暂时忽略,不在本次主流程中讨论,后面关注我,分析函数式组件实现
  
  // ...
  
  installComponentHooks(data)
  
  
  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
  )
  
  // weex 平台兼容
  
  return vnode
}

installComponentHooks

该函数也是非常重要的一个环节,在render函数执行,最终返回虚拟DOM时,组件内部的转化处理,也有个生命周期。

其核心hooks如下:

const componentVNodeHooks = {
  init (vnode: VNodeWithData, hydrating: boolean): ?boolean {
    // keep-alive逻辑,暂省略
    // ...
    const child = vnode.componentInstance = createComponentInstanceForVnode(
      vnode,
      activeInstance
    )
    child.$mount(hydrating ? vnode.elm : undefined, hydrating)
  },
  
  prepatch (oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) {
    const options = vnode.componentOptions
    const child = vnode.componentInstance = oldVnode.componentInstance
    updateChildComponent(
      child,
      options.propsData, // updated props
      options.listeners, // updated listeners
      vnode, // new parent vnode
      options.children // new children
    )
  },
  
  insert(vnode: MountedComponentVNode) {
    // 调用子 组件 mounted hooks
    
    // ...
   
    // keep-alive 逻辑处理
  },
  
  destroy(vnode: MountedComponentVNode) {
    // 调用子组件 $destroy 方法
    // ...
    // 删除当前组件active 实例
  }
}

到这里,我们已经看到,data对象上绑定了对应的 组件 实例化的hooks。

注意,这里子组件还没有实例化,相关的Dep依赖收集还未开始。

这个时候,data: VNodeData 数据结构大致如下:

{
  on: 'xx',
  hooks: {
    init: () => { 
       // ...
    },
    prepatch: () => {
      // ...
    },
    insert: () => {
      // ...
    },
    destroy: () => {
      // ...
    }
  }
}

下面,我们进入组件化实现的另一个环节-虚拟DOM

七. 组件化-VNode

真实DOM

在html中,我们任意打印一个真实的div dom,会看到如下效果:

image.png

可以看到,一个真实dom的基础属性就有这么多,总计296个属性。

虚拟DOM

vnode 中文意为 虚拟DOM。 什么是虚拟DOM,其实他就是对 真实DOM 的一种描述。

世间万物的本质,就是 数据结构 + 算法。同样,真实的dom,我们也可以使用js对象进行描述他。

通过上图,我们可以发现,真实的dom有许多属性,而虚拟dom根本不需要那么多,只需要知道,tag名称,数据对象,是否有childrens,parent是谁等基本属性。

频繁操作真实的dom代价是昂贵的,而操作虚拟dom,代价是很小的,我们可以通过虚拟的dom,即js对象,在内存中变更对比,再一把crud。

实际上,虚拟dom存在的意义,不外乎两种:

  • 提升性能
  • 跨平台

虚拟dom主干代码:

class VNode {
  tag: string | void;
  data: VNodeData | void;
  children: ?Array<VNode>;
  text: string | void;
  elm: Node | void;
  ns: string | void;
  context: Component | void; 
  key: string | number | void;
  componentOptions: VNodeComponentOptions | void;
  componentInstance: Component | void; 
  parent: VNode | void; 
  
  // 其他属性 ...
  
  isStatic: boolean;
  isComment: boolean;
  ssrContext: Object | void;
  
  // ...
  
  constructor (
    tag?: string,
    data?: VNodeData,
    children?: ?Array<VNode>,
    text?: string,
    elm?: Node,
    context?: Component,
    componentOptions?: VNodeComponentOptions,
    asyncFactory?: Function
  ) {
    this.tag = tag
    this.data = data
    this.children = children
    this.text = text
    this.elm = elm
    this.ns = undefined
    this.context = context
    // ...
    this.key = data && data.key
    this.componentOptions = componentOptions
    this.componentInstance = undefined
    this.parent = undefined
    // ...
  }
  
  get child (): Component | void {
    return this.componentInstance
  }
}

// 其他方法,创建空节点

// clone vnode...

// 创建文本节点...

我们可以看到,VNode是一个class类,其中常用的属性: tag, data, children, text, elm, context, componentOptions等,这些参数很重要。

接着第六步,createComponent:

需要指出的是:

  • data: VNodeData 不一定有值。如果是组件,该值为空。 componentOptions和componentInstance是组件的特有属性。
  • 组件的tag和普通的不一样,组件的tag统一为: "vue-component-${cid}-${name}"

createComponent方法,最后返回虚拟dom,子组件Test: 其核心结构大致如下:

{
  tag: "vue-component-1-test",
  text: '',
  isStatic: false, // 是否是静态节点,后面做diff算法时,是很关键的一步
  isComment: false,
  ele: div, // div为真实的dom div 对象, 每个子组件,都会先生成一个空div占位节点
  data: {
    hooks: {
      init: () => {
        // ...
      },
      prepatch: () => {
        // ...
      },
      insert: () => {
        // ...
      },
      destroy: () => {
        // ...
      }
    }
  }, // data数据很重要,组件实例化时需要使用到
  context: Vue,
  componentOptions: {
    // 此项很重要,是组件特有属性
    Ctor: VueComponent,   // 子组件构造函数
    children: undefined,  // 子组件中是否还有嵌套,这里我们demo中没有
    tag: "test",
    // ...
  },
  componentInstance: undefined, // 子组件的实例对象, 为什么这里是undefined,因为还没实例化
  children: undefined,
  // ...
}

好了,到这里,虚拟dom神秘的面纱被揭开了,他就是个js对象。

demo中,整个div的虚拟dom结构如下:

{
  tag: "div",
  isStatic: false,
  isRootInsert: true,
  isComment: false,
  // ...
  data: {
    attrs: {
      "data-test": "这是测试属性"
    }
  },
  context: Vue,
  componentOptions: undefined,
  componentInstance: undefined,
  children: [
    // 上述子组件VNode
  ],
  // ...
}

我们再回归到 菜单一 中的 mountComponent函数。

我们可以看到vm._render() 函数执行结果,就是返回的一个虚拟DOM。 这个虚拟dom,实际上就是个js对象,如上述代码所述。

而组件化工作并未结束,高潮即将来临:

下面,我们来分析:update patch工作。

image.png

八. 组件化-Patch

patch过程,是vue将虚拟dom转化为真实dom,展示在页面上的最后一个环节了。 但在此处,我们只看组件化实现相关部分。 其他部分:更新队列,diff算法,映射真实dom等,我会在下个章节里面,重点来分析。

废话少说,开干。

image.png

patch环节,核心的入口是 createPatchFunction。我先开看 与 组件化相关的 核心代码

function createPatchFunction(backend) {
  const { modules, nodeOps } = backend
  
  function createElm(
    vnode,
    insertedVnodeQueue,
    parentElm,
    refElm,
    nested,
    ownerArray,
    index
  ) {
    if( createComponent(vnode, insertedVnodeQueue, parentElm, refElm) ) return
    
    // ...
    
    if( isDef(tag) ) {
      // ...
      
      if(__WEEX__) {
        // weex 平台相关处理
      }else {
        createChildren(vnode, children, insertedVnodeQueue)
      }
    }
  }
  
  
  function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) {
    // ...
    let i = vnode.data
    if (isDef(i)) {

      const isReactivated = isDef(vnode.componentInstance) && i.keepAlive
      
      if (isDef(i = i.hook) && isDef(i = i.init)) {
        i(vnode, false /* hydrating */)
      }
      
      if (isDef(vnode.componentInstance)) {
        // 初始化component, insert队列
        
        // 暂时忽略...
      }
    }
  }
  
  function createChildren (vnode, children, insertedVnodeQueue) {
    // ...
    for (let i = 0; i < children.length; ++i) {
      createElm(children[i], insertedVnodeQueue, vnode.elm, null, true, children, i)
    }
    // ...
  }
  
  return function patch (oldVnode, vnode, hydrating, removeOnly) {
    // ...
    
    createElm(
      vnode, 
      insertedVnodeQueue, 
      oldElm._leaveCb ? null : parentElm, 
      nodeOps.nextSibling(oldElm)
    )
    // ...
  }
}

抽丝剥茧,我们直接看组件化相关逻辑,vnode就是 render函数执行之后的虚拟dom js对象。

不难看出,

  • 第一层的vdom对象,没有hook属性,最后整个函数返回undefined,不会进行相关hooks
  • 往下执行到createChildren 方法,递归执行所有childrens
  • 我们demo只,只有一个子组件test,这里将再次调用 createElm 方法。而此时传入的vnode就是子组件vnode数据

重点来了,子组件vnode进入createComponent方法,此时vnode.componentInstance 是存在的。(前面说过,这是组件的特有属性)

核心:

 let i = vnode.data
 if (isDef(i = i.hook) && isDef(i = i.init)) {
    i(vnode, false)
 }

子组件vnode.data 实际上就是 之前提到的 组件实例化中的生命周期hooks,包括: init, prepatch, insert, destroy

显示子组件的Init方法是存在的, 那么执行子组件的init方法。

init核心如下:

 init (vnode: VNodeWithData, hydrating: boolean): ?boolean {
   // keep-alive处理省略...
   
   const child = vnode.componentInstance = createComponentInstanceForVnode(
      vnode,
      activeInstance
    )
    child.$mount(hydrating ? vnode.elm : undefined, hydrating)
 }

createComponentInstanceForVnode核心如下:

function createComponentInstanceForVnode (vnode: any, parent: any) {
  // ...
  
  return new vnode.componentOptions.Ctor(options)
}

image.png

终于,我们看到了子组件处理的本质。

前面我们提到,vnode.componentOptions.Ctor 就是子组件的构造函数。即VueComponent构造函数。

而此构造函数通过原型继承至Vue构造函数。 那么当子组件的VueComponent构造函数被实例化时。我们再回顾下,VueComponent相关代码:

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

所以,当实例化子组件的时候,就会执行_init方法,而_init方法继承至Vue构造函数。那么子组件实例化时,实际上就是走了一遍 new Vue的过程,只是当前的this指向是VueComponent,而不是Vue。

具体实例化发了什么,不清楚的小伙伴,可以看我的《vue源码解析-开始》。

实例化子组件后,其实_watcher 还是 undefined。

紧接着执行:

child.$mount(hydrating ? vnode.elm : undefined, hydrating)

子组件被挂载了。 不清楚$mount背后发生了什么的小伙伴,可以看我的《vue源码解析-$mount》

而子组件挂载时,又将执行子组件的compiler,返回对应的子组件render函数。(demo中,第一次执行时,子组件是个_c(test))。这里,子组件render函数如下:

function anonymous(
) {
with(this){return _c('div',[_v(_s(testName)+" "+_s(a))])}
}

那么当子组件执行render函数,返回虚拟dom时, 那么将触发子组件的依赖收集。(而不是Watcher, Dep, Observe实例化时,真正的执行dep收集在render函数返回虚拟dom阶段)。

嗯,综上,如果是n层嵌套,那么将递归执行上面的流程,每个组件其实都是单独的一个 VueComponent构造函数。每个子组件被实例化时,都是新的vm实例。

组件化的设计很巧妙,这里只分析了核心流程,而细枝末节还有许多,比如:异步组件,函数式组件,keep-alive等。这些我会在后面的章节,详细探讨。

下一章,我们将分析,patch详细过程

码字不易,多多关注~😽