今天看啥  ›  专栏  ›  白马笑西风

Vue2.0源码阅读笔记(七):组件

白马笑西风  · 掘金  ·  · 2019-10-14 11:34
阅读 41

Vue2.0源码阅读笔记(七):组件

  传统的页面开发主张将内容、样式和行为分开,便于开发和维护。等到React、Vue等MVVM前端框架大行其道时,人们更倾向于使用html、css、js聚合在一起创建组件,通过编写小型、独立和通常可复用的组件来构建大型应用。
  组件是现代开发框架的基石,下面详细介绍Vue组件的实现原理。

一、注册组件

  在Vue中组件注册分为两种:局部注册、全局注册。全局注册是通过 Vue.component 方法进行的,局部注册是通过在实例化组件时添加 components 选项完成的。
  下面详细介绍组件注册以及相关内容。

1、Vue.options.components

  Vue.optionscomponents 属性是在 /src/core/global-api/index.js 文件中调用 initGlobalAPI 函数来定义的。

initGlobalAPI(Vue)

// initGlobalAPI 代码
Vue.options = Object.create(null)
ASSET_TYPES.forEach(type => {
  Vue.options[type + 's'] = Object.create(null)
})
Vue.options._base = Vue
extend(Vue.options.components, builtInComponents)

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

// builtInComponents
import KeepAlive from './keep-alive'
export default { KeepAlive }
复制代码

  在 /src/platforms/web/runtime/index.js 文件中会对 Vue.options.components 进一步赋值。

import platformComponents from './components/index'

extend(Vue.options.components, platformComponents)

// platformComponents
import Transition from './transition'
import TransitionGroup from './transition-group'

export default {
  Transition,
  TransitionGroup
}
复制代码

  最终 Vue.options.components 中会包含三个内置组件:

Vue.options.components = {
  KeepAlive: {/* ... */},
  Transition: {/* ... */},
  TransitionGroup: {/* ... */}
}
复制代码

  在《选项合并》一文中讲过,资源选项的合并是通过 mergeAssets 函数进行的。合并策略是以父选项对象为原型,因此:

// vm 为Vue实例,即Vue组件
vm.$options.components.prototype = Vue.options.components = {
  KeepAlive: {/* ... */},
  Transition: {/* ... */},
  TransitionGroup: {/* ... */}
}
复制代码

2、Vue.extends

  Vue.extends 用来根据传入的配置选项创建一个Vue构造函数的“子类”,精简代码如下:

Vue.extend = function (extendOptions) {
  /* ... */
  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
  )
  /* ... */
  ASSET_TYPES.forEach(function (type) {
    Sub[type] = Super[type]
  })
  if (name) {
    Sub.options.components[name] = Sub
  }
  /* ... */
  return Sub
}
复制代码

  可以看到 Vue.extend 返回的函数 VueComponent 跟Vue构造函数一样,都是调用 _init 方法进行初始化。VueComponent 函数自身也会添加跟Vue相同的静态属性和方法。
  VueComponent 与 Vue 的主要区别是静态属性 options 不同。VueComponent.options 是将 Vue.extend 参数和原有构造函数的 options 参数通过 mergeOptions 函数进行合并而得到的。
  另外,会将构造函数添加到自身的 options.components 对象属性上,也就是说通过 VueComponent 实例化的对象上的属性 $options.components.prototype 上除了内置组件还会有自定义组件的构造函数。

3、Vue.component全局注册

  Vue关于资源的静态方法(Vue.component、Vue.directive、Vue.filter)定义如下:

initAssetRegisters(Vue)

function initAssetRegisters (Vue) {
  ASSET_TYPES.forEach(type => {
    Vue[type] = function (id,definition ){
      if (!definition) {
        return this.options[type + 's'][id]
      } else {
        if (process.env.NODE_ENV !== 'production' && type === 'component') {
          validateComponentName(id)
        }
        if (type === 'component' && isPlainObject(definition)) {
          definition.name = definition.name || id
          definition = this.options._base.extend(definition)
        }
        if (type === 'directive' && typeof definition === 'function') {
          definition = { bind: definition, update: definition }
        }
        this.options[type + 's'][id] = definition
        return definition
      }
    }
  })
}
复制代码

  单看 Vue.component 方法其定义如下所示:

Vue.component = function (id, definition){
  if (!definition) {
    return this.options.components[id]
  } else {
      // 组件名合法性检测
      validateComponentName(id)

      if (isPlainObject(definition)) {
        definition.name = definition.name || id
        definition = Vue.extend(definition)
      }
      Vue.options.components[id] = definition
      return definition
  }
}
复制代码

  由以上代码可知,全局注册的实质是根据全局注册组件选项生成Vue子构造函数,然后将该子构造函数添加到Vue.options.components对象上

4、component选项局部注册

  使用 components 选项来注册组件,会将要注册组件信息存储在当前组件实例的 $options.components 对象上。
  在局部组件根据渲染函数生成对应VNode时,是由 createComponent 函数来最终生成VNode的。

function createComponent (Ctor,data,context,children,tag) {
  /*...*/
  var baseCtor = context.$options._base;
  // Ctor 为组件配置对象
  if (isObject(Ctor)) {
    Ctor = baseCtor.extend(Ctor);
  }
  /*...*/
}
复制代码

  由上述代码可知,在生成VNode的过程中会调用所在组件实例的 extend 方法根据注册信息生成对应的子构造函数。

二、组件解析

  组件的解析过程和普通标签一样:

1、根据模板生成渲染函数。
2、根据渲染函数生成虚拟DOM。
3、根据虚拟DOM生成真实DOM。

  下面以一个简单的例子来说明组件的解析过程:

<body>
  <div id="app"></div>
</body>
<script>
  var ComponentA = {
    template: '<div>组件A</div>'
  }
  var vue = new Vue({
    el: '#app',
    template: `<div id="app" class="home"><component-a></component-a></div>`,
    components: {
      "component-a": ComponentA
    }
  })
</script>
复制代码

1、渲染函数

  模板中组件生成的渲染函数比较简单,跟标签一样由 _c() 函数包裹。_c() 的第一个参数为组件名,第二个参数为组件属性对象,第三个参数为使用 <slot> 接收的内容。

function anonymous() {
  with(this){
    return _c(
      'div',
      {staticClass:"home",attrs:{"id":"app"}},
      [_c('component-a')],
      1
    )
  }
}
复制代码

  组件的具体配置参数信息存储在 vm.$options.components 中:

vm.$options.components = {
  "component-a" : {
    template: "<div>组件A</div>"
  }
}
复制代码

2、VNode

  组件生成VNode是调用渲染函数中的 _c() 完成的,_c() 最终会调用 _createElement 来生成VNode。_createElement 中关于组件处理的代码如下所示:

// context 为当前组件实例
if ((!data || !data.pre) && 
  isDef(Ctor = resolveAsset(context.$options, 'components', tag){
    vnode = createComponent(Ctor, data, context, children, tag);
}
复制代码

(一)resolveAsset 获取组件注册信息

  resolveAsset 对组件类型资源的处理代码如下:

function resolveAsset (options,type,id,warnMissing) {
  if (typeof id !== 'string') { return }
    var assets = options[type];
    if (hasOwn(assets, id)) { return assets[id] }

    var camelizedId = camelize(id);
    if (hasOwn(assets, camelizedId)) { return assets[camelizedId] }
    
    var PascalCaseId = capitalize(camelizedId);
    if (hasOwn(assets, PascalCaseId)) { return assets[PascalCaseId] }

    var res = assets[id] || assets[camelizedId] || assets[PascalCaseId];
    if (warnMissing && !res) {
      warn('Failed to resolve ' + type.slice(0, -1) + ': ' + id,options);
    }
    return res
  }
复制代码

  该函数对组件的处理比较有意思,首先是关于组件名称的问题:在模板中使用的组件名称,在组件注册时可以有三种形式。注册时可以跟使用时保持一致,也可以使用驼峰命名或者首字母大写的驼峰命名。
  其次是关于组件局部注册以及全局注册的问题:局部注册的组件会保存在 vm.$options.components 中,全局注册的组件保存在 Vue.options.components 中,而 Vue.options.componentsvm.$options.components 的原型链上。
  resolveAsset 函数查询组件注册信息会先查注册的局部变量,如果找不到再沿着原型链查询。这就是局部组件只能自身使用,全局注册的组件能够全局使用的原因。

(二)createComponent

  组件VNode生成函数 createComponent 的精简代码如下所示:

function createComponent (Ctor,data,context,children,tag){
  if (isUndef(Ctor)) { return }

  const baseCtor = context.$options._base
  if (isObject(Ctor)) {Ctor = baseCtor.extend(Ctor)}
  /* 省略异步组件相关处理代码 */
  data = data || {}
  resolveConstructorOptions(Ctor)
  /* 省略v-model相关处理代码 */
  const propsData = extractPropsFromVNodeData(data, Ctor, tag)
  /* 省略函数式组件相关处理代码 */
  const listeners = data.on
  data.on = data.nativeOn
  /* 省略抽象组件相关处理代码 */
  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
}
复制代码

  首先是根据局部组件注册信息调用 extend 方法生成子构造函数,然后调用 resolveConstructorOptions 函数来更新子构造函数的 options 属性。这里会有一个疑问:在 extend 方法中已经使用 mergeOptions 方法完成对子构造函数 options 属性合并更新,为什么还要调用 resolveConstructorOptions 函数处理 options?

function resolveConstructorOptions (Ctor) {
  let options = Ctor.options
  if (Ctor.super) {
    const superOptions = resolveConstructorOptions(Ctor.super)
    const cachedSuperOptions = Ctor.superOptions
    if (superOptions !== cachedSuperOptions) {
      Ctor.superOptions = superOptions
      const modifiedOptions = resolveModifiedOptions(Ctor)
      if (modifiedOptions) {
        extend(Ctor.extendOptions, modifiedOptions)
      }
      options = Ctor.options = mergeOptions(superOptions, Ctor.extendOptions)
      if (options.name) {
        options.components[options.name] = Ctor
      }
    }
  }
  return options
}
复制代码

  这是为了防止在组件构造函数创建以后使用全局 mixins 更改父构造函数的选项,resolveConstructorOptions 函数的作用就是根据原型链上对象的 options 值来更新子构造函数的 options
  接着调用 extractPropsFromVNodeData 函数来从当前实例中提取局部组件 props 的值,调用 installComponentHooks 来在 data 属性上安装组件的钩子函数。
  最后使用 new VNode() 来生成组件类型VNode,传入的第一个参数是根据组件名拼接处理的;第三个参数不传,也就是说组件VNode没有 children 属性;与生成其他类型VNode不同,第七个参数会传入组件选项对象 componentOptions;第八个参数会根据是否为异步组件而传入不同的值。

(三)installComponentHooks

  组件钩子安装函数 installComponentHooks 以及相关代码如下所示:

const componentVNodeHooks = {
  init (vnode, hydrating) {/* 省略具体实现 */},
  prepatch (oldVnode, vnode) {/* 省略具体实现 */},
  insert (vnode) {/* 省略具体实现 */},
  destroy (vnode) {/* 省略具体实现 */}
}

const hooksToMerge = Object.keys(componentVNodeHooks)

function installComponentHooks (data) {
  var hooks = data.hook || (data.hook = {});
  for (var i = 0; i < hooksToMerge.length; i++) {
    var key = hooksToMerge[i];
    var existing = hooks[key];
    var toMerge = componentVNodeHooks[key];
    if (existing !== toMerge && !(existing && existing._merged)) {
      hooks[key] = existing ? mergeHook(toMerge, existing) : toMerge;
    }
  }
}
function mergeHook (f1, f2) {
  const merged = (a, b) => {
    f1(a, b)
    f2(a, b)
  }
  merged._merged = true
  return merged
}
复制代码

  组件钩子函数生成逻辑比较简单:将 data.hookcomponentVNodeHooks 中的函数加以合并,合并策略为将同名函数合并到同一函数中。
  如果原本 data.hook 中没有钩子函数,则最终 data.hook 的值如下所示:

data.hook = componentVNodeHooks = {
  init (vnode, hydrating) {/* 省略具体实现 */},
  prepatch (oldVnode, vnode) {/* 省略具体实现 */},
  insert (vnode) {/* 省略具体实现 */},
  destroy (vnode) {/* 省略具体实现 */}
}
复制代码

  最终有四个钩子函数:init、prepatch、insert、destroy。钩子函数的具体功能在后面用到时再详细讲解。

(四)使用new VNode()生成组件VNode

  构造函数 VNode() 中关于生成组件实例的代码如下所示:

export default class VNode {
  constructor (tag,data,children,text,elm,
    context,componentOptions,asyncFactory) {
    this.tag = tag
    this.data = data
    this.context = context
    this.componentOptions = componentOptions
    this.asyncFactory = asyncFactory
    /*省略...*/
  }
  get child (){
    return this.componentInstance
  }
}
复制代码

  例子中的组件VNode最终如下所示:

vnode = {
  tag: 'vue-component-1-component-a',
  data:{
    on: undefined,
    hook:{
      init (vnode, hydrating) {/* 省略具体实现 */},
      prepatch (oldVnode, vnode) {/* 省略具体实现 */},
      insert (vnode) {/* 省略具体实现 */},
      destroy (vnode) {/* 省略具体实现 */}
    }
  },
  componentOptions:{
    Ctor: function VueComponent(options){/*组件构造函数*/}
    tag: "component-a"
    children: undefined
    listeners: undefined
    propsData: undefined
  },
  asyncFactory: undefined,
  componentInstance: undefined
  /*省略...*/
}
复制代码

3、patch

  在 patch 的过程中,组件类型VNode生成真实DOM是调用函数 createPatchFunction 中的内部函数 createComponent 来完成的。

(一)createComponent

  函数 createComponent 代码如下所示:

function createComponent(vnode,insertedVnodeQueue,parentElm,refElm){
  var i = vnode.data
  if (isDef(i)) {
    var isReactivated = isDef(vnode.componentInstance) && i.keepAlive
    if (isDef(i = i.hook) && isDef(i = i.init)) {
      i(vnode, false)
    }
    if (isDef(vnode.componentInstance)) {
      initComponent(vnode, insertedVnodeQueue)
      insert(parentElm, vnode.elm, refElm)
      if (isTrue(isReactivated)) {
        reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm)
      }
      return true
    }
  }
}
复制代码

  在不考虑 keepAlive 的情况下,组件类型VNode生成DOM的过程为:

1、调用 data.hook.init 方法生成组件实例 componentInstance 属性,并完成组件的挂载。
2、调用 initComponent 函数使用钩子函数完成组件初始化。
3、调用 insert 方法将生成的DOM插入。

(二)init钩子函数

  钩子函数 init 函数代码如下所示:

init (vnode, hydrating) {
  if (
    vnode.componentInstance &&
    !vnode.componentInstance._isDestroyed &&
    vnode.data.keepAlive
  ) {
    const mountedNode = vnode
    componentVNodeHooks.prepatch(mountedNode, mountedNode)
  } else {
    const child = 
    vnode.componentInstance = 
    createComponentInstanceForVnode(vnode,activeInstance)

    child.$mount(hydrating ? vnode.elm : undefined, hydrating)
  }
}
复制代码

  keepAlive 的情况在后续讲解内置组件时阐述。一般情况下会走 else 分支,使用 createComponentInstanceForVnode 函数创建 VNode 的组件实例属性。最后调用组件实例的 $mount 方法挂载实例。

function createComponentInstanceForVnode (vnode,parent) {
  var options = {
    _isComponent: true,
    _parentVnode: vnode,
    parent: parent
  };
  var inlineTemplate = vnode.data.inlineTemplate;
  if (isDef(inlineTemplate)) {
    options.render = inlineTemplate.render;
    options.staticRenderFns = inlineTemplate.staticRenderFns;
  }
  return new vnode.componentOptions.Ctor(options)
}
复制代码

  createComponentInstanceForVnode 函数主要作用是调取组件的构造函数生成组件构造实例。

(三)初始化组件函数initComponent

  initComponent 函数在组件 tag 存在的情况下,主要作用是调用局部变量 cbs.create 中的各种钩子函数来完成初始化,cbs.createVirtual DOM一文中有详细介绍。 之后使用 setScope 设置 style 作用域。

function initComponent (vnode, insertedVnodeQueue) {
  if (isDef(vnode.data.pendingInsert)) {
    insertedVnodeQueue.push.apply(insertedVnodeQueue, vnode.data.pendingInsert);
    vnode.data.pendingInsert = null;
  }
  vnode.elm = vnode.componentInstance.$el;
  if (isPatchable(vnode)) {
    invokeCreateHooks(vnode, insertedVnodeQueue);
    setScope(vnode);
  } else {
    registerRef(vnode);
    insertedVnodeQueue.push(vnode);
  }
}
复制代码

三、函数式组件

  函数式组件跟 react 里面的无状态组件很相似,函数式组件无状态 (没有响应式数据),也没有实例 (没有 this 上下文)。因为只是函数,没有实例,所以函数式组件相对于普通组件来说渲染开销较低。
  下面先简单介绍函数式组件的使用,再阐述其源码实现。

1、函数式组件的使用

  函数式组件的使用方式一般有两种:

1、使用 Vue.component 声明组件时,在选项中将 functional 属性置为 true,且手动实现 render 函数。
2、在单文件组件中,使用 <template functional> 代替 <template> 声明模板。

  函数式组件的中的 render 函数除了第一个 createElement 参数之外,还添加了第二个参数 context 对象。组件需要的一切都是通过 context 参数传递。
  context 对象包含属性如下:

context = {
  props:{ /*提供所有 prop 的对象 */ },
  children: [ /*VNode 子节点的数组*/ ],
  slots: () => {},/*一个返回了包含所有插槽的对象的函数*/
  scopedSlots: { /*暴露传入的作用域插槽的对象*/ },
  data: { /*不是数据对象,是组件属性,createElement第二个参数*/ },
  parent: { /*对父组件的引用*/ },
  listeners: { /*事件监听器的对象,data.on 的一个别名*/ },
  injections: { /*被 inject 选项注入的属性。*/ },
}
复制代码

  下面是一个简单的函数式组件的例子,后续以此为例阐述函数式组件原理。

<body>
  <div id="app"></div>
</body>
<script>
  var ComponentA = {
    functional: true,
    render: function(createElement,context) {
      return createElement('div',context.props.name)
    }
  }
  var vue = new Vue({
    el: '#app',
    template: `<div id="app" class="home">
        <component-a name='组件A'></component-a>
      </div>`,
    components: {
      "component-a": ComponentA
    }
  })
</script>
复制代码

2、函数式组件实现原理

  依旧按照组件的编译顺序来探究其实现原理。

(一)生成渲染函数

  上述示例由模板生成的渲染函数如下所示,可以看到,函数式组件生成渲染函数与普通组件并无不同之处。

with(this){
  return _c(
    'div',
    {staticClass:"home",attrs:{"id":"app"}},
    [
      _c('component-a',{attrs:{"name":"组件A"}})
    ],
    1
  )
}
复制代码

(二)生成VNode

  在由渲染函数生成VNode的过程中,会调用生成组件VNode的函数 createComponent,在该函数中有对函数式组件的特殊处理。

function createComponent(Ctor,data,context,children,tag){
  /* 省略... */
  if (isTrue(Ctor.options.functional)) {
    return createFunctionalComponent(Ctor,propsData,data,context,children)
  }
  /* 省略... */
}
复制代码

  从以上代码中可以看出,函数式组件的VNode是 createFunctionalComponent 函数的返回值。

function createFunctionalComponent(Ctor,propsData,data,contextVm,children){
  var options = Ctor.options;
  var props = {};
  var propOptions = options.props;
  if (isDef(propOptions)) {
    for (var key in propOptions) {
      props[key] = validateProp(key, propOptions, propsData || emptyObject);
    }
  } else {
    if (isDef(data.attrs)) { mergeProps(props, data.attrs); }
    if (isDef(data.props)) { mergeProps(props, data.props); }
  }

  var renderContext = new FunctionalRenderContext(
    data,
    props,
    children,
    contextVm,
    Ctor
  );

  var vnode = options.render.call(null, renderContext._c, renderContext);

  if (vnode instanceof VNode) {
    return cloneAndMarkFunctionalResult(vnode, data, renderContext.parent, options, renderContext)
  } else if (Array.isArray(vnode)) {
    var vnodes = normalizeChildren(vnode) || [];
    var res = new Array(vnodes.length);
    for (var i = 0; i < vnodes.length; i++) {
      res[i] = cloneAndMarkFunctionalResult(vnodes[i], data, renderContext.parent, options, renderContext);
    }
    return res
  }
}
复制代码

  createFunctionalComponent 函数主要有四个功能:

1、将 attrs、props 上的值都合并到 props 中。
2、根据传入的 context 值,合并生成上下文参数对象 renderContext。
3、由手写的 render 函数生成VNode。
4、克隆VNode,然后添加fnContext、fnOptions等属性。

  这里可以看出函数式组件与普通组件最大的区别:普通组件生成组件VNode,VNode对上有指向组件实例的componentInstance属性。函数式组件根据render函数生成VNode,本身并没有相应的组件实例。
  根据函数式组件生成的VNode如下所示:

VNode = {
  /* 省略... */
  tag: "div",
  children: [{/*子节点VNode*/}],
  devtoolsMeta: {renderContext: {/*createElement第二个参数对象*/}},
  fnContext: {/*上下文信息*/},
  fnOptions: {/*函数式组件选项*/},
  isCloned: true,
  isRootInsert: true,
  componentInstance: undefined,
  componentOptions: undefined,
  data: undefined
  /* 省略... */
}
复制代码

(三)patch

  在 patch 阶段,因为根据函数式组件生成的VNode上并没有组件选项 componentOptions 属性,根据VNode生成真实DOM的过程与普通组件一样。
  实际上,函数式组件仅仅是生成包裹内容对应的VNode,在生成真实DOM的时候,函数式组件完全透明,生成的DOM由根据包裹内容而定的。

四、总结

  组件注册的方式有两种:局部注册、全局注册。组件注册的实质是根据传入的选项生成Vue子构造函数,在使用组件时使用子构造函数生成组件实例。全局注册组件的信息在局部组件注册对象的原型上,因此全局注册的组件可以不重复注册而被全局使用。
  根据组件生成的渲染函数除了slot之外跟普通的标签一样,组件渲染函数生成的VNode上有组件选项信息属性 componentOptions。在 patch 的过程中,首先生成组件实例,然后根据组件实例生成真实DOM并挂载。
  普通组件都会生成对应的组件实例对象,相对而言开销比较大。函数式组件不会生成专门的VNode以及实例对象,函数式组件相当于一个容器,在组件生成时直接渲染包裹的内容。

欢迎关注公众号:前端桃花源,互相交流学习!




原文地址:访问原文地址
快照地址: 访问文章快照