Vue源码阅读笔记之组件系统(五)

这是”Vue源码阅读笔记”系列第五篇文章。在之前我们分析了Vue的模板编译和渲染过程,以及响应式数据的原理,但是却有意识地避开了一个很重要的地方:组件。组件系统是Vue最强大的功能之一,接下来我们就从源码中,一步步揭开组件的神秘面纱。

<!--more-->

参考:

1. 组件化的目的

在了解Vue的组件系统实现之前,先结合之前的使用,思考一下为什么要使用组件的概念来构建应用。

写了很长一段时间的烂代码,我先谈谈自己关于好代码的认知:好代码应该是结构清晰、易维护、可扩展的。

Vue的组件系统可以让我们用搭积木一样的方式来搭建web应用。

在后端的模板引擎中,一般会提供模板继承@extends和模板引入@include的功能,从而实现模板的共用,便于开发和维护,实际上可以找到组件化的影子。

PS:这里推荐王垠的一篇文章:编程的智慧。在这篇文章的“写模块化的代码”这章,提到了如何达到很好的模块化(貌似与本文不是很相关~)。

2. 单个组件

2.1. 注册组件

// 全局注册,通过Vue.component方法
let TestCom = Vue.extend({
    template: '<h1>This is Test component!</h1>'
})

Vue.component('testCom', TestCom)

// 局部注册,通过配置参数的components属性
let localCom = {
  template: `<h1>This is local component!</h1>`
}

new Vue({
  components: {localCom}
})

然后就可以在其他模板中通过组件名(比如<test-com>)使用组件了。实际上更常使用的方式是直接通过配置参数进行注册

Vue.component('testCom', {
    template: '<h1>This is Test component!</h1>'
})

那么,我们从Vue.component方法入手,从initGlobalAPI->initAssetRegisters找到其实现

// /src/core/global-api/assets.js
Vue["component"] = function (
   id: string, // 组件id
   definition: Function | Object
  ): Function | Object | void {
    if (isPlainObject(definition)) {
      definition.name = definition.name || id
      // 在initGlobalAPI设置了Vue.options._base = Vue;
      definition = this.options._base.extend(definition)
    }
    this.options["component"+"s"][id] = definition
    return definition
}

剥离了干扰代码,可以发现Vue.component方法十分简单,如果是definition配置对象参数则调用Vue.extend方法

// /src/core/global-api/extend.js
export function initExtend (Vue: GlobalAPI) {
  Vue.cid = 0
  let cid = 1

  Vue.extend = function (extendOptions: Object): Function {
    extendOptions = extendOptions || {}
    const Super = this
    const SuperId = Super.cid
    const cachedCtors = extendOptions._Ctor || (extendOptions._Ctor = {})
    if (cachedCtors[SuperId]) {
      return cachedCtors[SuperId]
    }

    const name = extendOptions.name || Super.options.name

    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

    if (Sub.options.props) {
      initProps(Sub)
    }
    if (Sub.options.computed) {
      initComputed(Sub)
    }

    // 下面省略一些合并的属性和方法
    // ...

    cachedCtors[SuperId] = Sub
    return Sub
  }
}

可以发现Vue.extend返回一个继承自Vue的新的构造函数,这样就可以解释为什么每个组件可以当作vm实例了。

OK,现在我们了解了注册组件的过程,实际上就是生成了一个新的子类构造函数,并与Vue.component方法的第一个参数进行映射绑定(作为组件id)。那么注册完组件之后,组件对象是如何实例化然后挂载到父元素上的呢?

2.2. 组件实例

在完成组件的注册之后,在模板中以标签的形式引入组件id即可

<div id="app">
    <h1>{{ msg }}</h1>
    <test-com></test-com>    
</div>

看来我们寻找的答案需要到模板渲染的地方去寻找。先打印一下模板编译后的render函数看看

ƒ anonymous() {
    with (this) {
      return _c(
        "div",
        { attrs: { id: "app" } },
        [_c("h1", [_v(_s(msg))]), _v(" "), _c("test-com")],
        1
      );
    }
}

找到了_c("test-com"),看来也是把组件当作了跟div一样的普通标签,去看一看_c函数(即createElement)的实现

// /src/core/instance/render.js
// args order: tag, data, children, normalizationType, alwaysNormalize
vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)

// /src/core/vdome/create-element.js
export function _createElement (
  context: Component,
  tag?: string | Class<Component> | Function | Object,
  data?: VNodeData,
  children?: any,
  normalizationType?: number
): VNode {
  // 这里省略一些对参数的整理
  // ...
  // 根据tag生成不同的vnode
  if (typeof tag === 'string') {
    // resolveAsset会进行驼峰转换,格式化组件名称
    if (isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
      // 如果是全局组件这里的Ctor就是对应的组件构造函数
      // 如果是局部组件这里的Ctor就是对应的组件options
      vnode = createComponent(Ctor, data, context, children, tag)
    } else {
      // .. 生成其他形式的VNode
    }
  }
}
// /src/core/vdom/create-component.js
export function createComponent (
  Ctor: Class<Component> | Function | Object | void,
  data: ?VNodeData,
  context: Component,
  children: ?Array<VNode>,
  tag?: string
): VNode | void {
  const baseCtor = context.$options._base
  // 处理局部组件的options,并将其转换成构造函数形式
  // 后续的局部组件和全局组件的实例过程基本一致
  if (isObject(Ctor)) {
    Ctor = baseCtor.extend(Ctor)
  }

  // 这里先省略了异步组件 async component
  let asyncFactory
  // ...

  data = data || {}

  const propsData = extractPropsFromVNodeData(data, Ctor, tag)

  // 生路函数组件functional component
  // ...

  const listeners = data.on
  data.on = data.nativeOn

  // 这里将componentVNodeHooks对象上的方法合并到data.hook对象上
  mergeHooks(data)
  // return a placeholder vnode
  const name = Ctor.options.name || tag
  // 返回占位的vnode,需要注意这里的构造参数
  const vnode = new VNode(
    `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
    data, undefined, undefined, undefined, context,
    { Ctor, propsData, listeners, tag, children },
    asyncFactory
  )
  return vnode
}

通过上面的过程我们应该理解了全局组件和局部组件的问题:

  • 全局组件是先注册并缓存其构造函数
  • 局部组件是通过resolveAsset获取组件的options然后生成其构造函数

实际上其他资源如directive的全局和局部注册,也可以参照这里的组件进行理解。

可以看见的是,createComponent函数只是将组件当作一个VNode返回而已,那么我们去patch函数中寻找答案。

2.3. 组件渲染

// /src/core/vdom/patch.js
function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) {
    let i = vnode.data
    // 只有组件带vnode.data属性
    if (isDef(i)) {
      const isReactivated = isDef(vnode.componentInstance) && i.keepAlive
      if (isDef(i = i.hook) && isDef(i = i.init)) {
        // 调用vnode.data.hook.init方法
        // 而该方法是前面mergeHooks中合并的componentVNodeHooks上的
        i(vnode, false /* hydrating */, parentElm, refElm)
      }
      // hook.init方法会调用组件构造函数并实例化组件对象,
      // 并挂载到vnode.componentInstance属性上
      if (isDef(vnode.componentInstance)) {
        initComponent(vnode, insertedVnodeQueue)
        if (isTrue(isReactivated)) {
          reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm)
        }
        return true
      }
    }
  }

然后找到对应的componentVNodeHooks.init方法

// /src/core/vdom/create-component.js
const componentVNodeHooks = {
  init (
    vnode: VNodeWithData,
    hydrating: boolean,
    parentElm: ?Node,
    refElm: ?Node
  ): ?boolean {
    if (!vnode.componentInstance || vnode.componentInstance._isDestroyed) {
        // 调用组件构造函数,实例化组件对象
      const child = vnode.componentInstance = createComponentInstanceForVnode(
        vnode,
        activeInstance,
        parentElm,
        refElm
      )
      child.$mount(hydrating ? vnode.elm : undefined, hydrating)
    } else if (vnode.data.keepAlive) {
      const mountedNode: any = vnode // work around flow
      componentVNodeHooks.prepatch(mountedNode, mountedNode)
    }
  },
  // ...
}

export function createComponentInstanceForVnode (
  // 下面这个注释233
  vnode: any, // we know it's MountedComponentVNode but flow doesn't
  parent: any, 
  parentElm?: ?Node,
  refElm?: ?Node
): Component {
  const options: InternalComponentOptions = {
    _isComponent: true,
    parent,
    _parentVnode: vnode,
    _parentElm: parentElm || null,
    _refElm: refElm || null
  }
  // 这里省略了 inline-template render functions
  // ...

  // 终于找到了对应的组件构造函数
  return new vnode.componentOptions.Ctor(options)
}

在整理模板渲染的时候没有详细分析vm.__patch__方法的实现,主要也是考虑到了组件渲染的问题。对于组件的生成过程我们应该有了大致的印象,整理一下顺序:

  • 首先注册组件,实际上是生成继承自Vue的组件构造函数,然后在模板中通过组件标签引入组件
  • 然后在编译模板时,根据标签名判断如果是否是组件,如果是则调用createComponent生成对应VNode
  • 在patch时,将虚拟DOM树中的VNode转换为实际DOM节点,如果节点是已注册的组件,则调用vnode.data.hook.init方法,调用对应组件的构造函数,获得组件实例,然后将其挂载到父组件上。

至此,我们了解了单个组件从构造函数到实例化对象然后挂载到父元素的过程,接下来就应该看看多组件之间的问题了。

3. 父子组件通信

组件本身就应该像搭积木一样使用,彼此独立方便替换,但是不可避免地,组件之间也需要相互通信,配合使用,即一个组件应当封装内部实现,暴露外部接口。Vue的实现是

在 Vue 中,父子组件的关系可以总结为 prop 向下传递,事件向上传递

3.1. 向下数据传递

子组件通过配置props属性声明需要接收的数据,

Vue.component('testCom', {
    props:['msg'],
    template: `<h1>{{msg}}</h1>`
})

然后在父组件中通过组件标签的属性传入相应的值

<test-com :msg="msg"></test-com>

下面来分析整个过程

合并属性参数

还记得Vue.proptype._init中的mergeOptions方法吗?我们来看看他是如何处理props配置的

// /src/core/util/options.js
export function mergeOptions (
  parent: Object,
  child: Object,
  vm?: Component
): Object {
  if (typeof child === 'function') {
    child = child.options
  }
  // 这里处理配置参数的props属性    
  normalizeProps(child, vm)
  // 后面省略
}

function normalizeProps (options: Object, vm: ?Component) {
  const props = options.props
  const res = {}
  if (Array.isArray(props)) {
    // 将数组转换成 {key:{type:null}}的形式...
  } else if (isPlainObject(props)) {
    // 对对象属性名进行camelize处理...
  } 
  options.props = res
}

实际上只是对props做了类型处理,并统一转换成Object格式。比如上面我们传入['msg'],处理为{msg:{type:null}}形式

获取属性值

那么父组件的属性是如何传递给子组件的呢?我们知道使用方法是通过在模板中的组件标签中通过传递属性值,那么回到createComponent方法

// /src/core/vdom/create-component.js
export function createComponent (
  // 构造参数省略
): VNode | void {
  // ...
  // 这里获取从父组件传递的属性值
  const propsData = extractPropsFromVNodeData(data, Ctor, tag)
  // ...
  const vnode = new VNode(
    `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
    data, undefined, undefined, undefined, context,
    { Ctor, propsData, listeners, tag, children }, // 这里将propsData传入VNode
    asyncFactory
  )
  return vnode
}
// /src/core/vdom/helpers/extract-props.js
export function extractPropsFromVNodeData (
  data: VNodeData,
  Ctor: Class<Component>,
  tag?: string
): ?Object {
  // 检验属性值的类型和默认值在组件内部执行
  const propOptions = Ctor.options.props
  const res = {}
  // attrs即从子组件标签上解析获得的属性对象,包括属性和值
  const { attrs, props } = data
  // 这里遍历props属性,用attrs的值进行填充
  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
}

可以看见在extractPropsFromVNodeData方法中通过遍历attrsprops,从而获取从父组件传递的props属性值,然后将获得的数据挂载到VNodecomponentOptions参数上。

属性更新

拿到数据之后,剩下要做的就是监听props实现属性值的双向绑定了。

与data不同的是,处理props时

  • 父组件初始化属性值并传递给子组件
  • 父组件属性值变化,直接修改子组件的数据

先停下来思考一下,该如何实现上面的父组件数据变化,修改子视图的内容呢?回想前两章的内容,根据响应式数据和模板渲染,来梳理一下流程

  • 实例化父组件的时候,通过initData方法劫持data对象的属性访问符并在每个属性的getter中收集依赖
  • 父组件在mountComponent中,通过传入updateComponent方法实例化Watcher对象
  • updateComponent内部调用的vm._rendervm._update方法生成虚拟DOM树,并在patch中转换成真正的DOM树并挂载到页面上
  • 数据变化时会调用属性的setter,触发Watcher对象的update,然后再次调用updateComponent,生成新的虚拟DOM树
  • 通过diff和patch,为已修改的节点生成新的DOM节点,然后挂载到父元素上。

也就是说,父组件的数据发生了改变,会重新调用渲染函数并使用更新后的数据填充模板,在渲染子模板时再次调用createComponent,此时中的attrs也会改变,获取到的props也随之更新,然后更新子组件的视图。这就是上面提到的父组件直接修改子组件的数据

实际上我们只需要把子组件当做是一个普通的标签即可,他们的更新与父组件模板中其他的节点更新是一样的。

属性的响应式与单向数据流

然而在Vue.proptype._initinitState方法中除了我们之前分析的initData,还可以找到initProps

// /src/core/instance/state.js
export function initState (vm: Component) {
  vm._watchers = []
  const opts = vm.$options
  if (opts.props) initProps(vm, opts.props)
  // ...
}

function initProps (vm: Component, propsOptions: Object) {
  const propsData = vm.$options.propsData || {}
  const props = vm._props = {}
  const keys = vm.$options._propKeys = []
  const isRoot = !vm.$parent

  observerState.shouldConvert = isRoot
  for (const key in propsOptions) {
    keys.push(key)
    const value = validateProp(key, propsOptions, propsData, vm)

    defineReactive(props, key, value, () => {})

    if (!(key in vm)) {
      proxy(vm, `_props`, key)
    }
  }
  observerState.shouldConvert = true
}

通过上面的defineReactive,我们可以向操作data属性一样通过修改props属性来更新子组件的视图,但是由于Vue的单向数据流,这样操作会抛出一个警告

每次父组件更新时,子组件的所有 prop 都会更新为最新值。这意味着你不应该在子组件内部改变 prop。如果你这么做了,Vue 会在控制台给出警告。

不过即使忽略这个警告,我们也可以发现,父组件的数据更新会影响子组件,而子组件修改props,却不会修改父组件,这是因为extractPropsFromVNodeData中是通过浅复制将attrs传递给props的。

浅复制意味着在子组件中对对象和数组的props进行修改还是会影响父组件,这就违背了单向数据流的设计。

属性代理

在组件构造函数Vue.extend中可以发现initProps对props对象进行了代理,这样就可以在组件中通过this.xx对props进行访问

if (Sub.options.props) {
    initProps(Sub)
}
// ...
function initProps (Comp) {
  const props = Comp.options.props
  for (const key in props) {
    // 对数据进行代理,在组件中可以通过this.xx访问
    proxy(Comp.prototype, `_props`, key)
  }
}

###向上事件通知

上面提到了单向数据流,单向数据流的设计是为了防止子组件无意间修改父组件的状态。因此在良好的设计下,

  • 子组件的数据修改和视图更新时封闭的
  • 子组件通过触发事件实现对父组件的通知

通过事件,子组件和它的外部完全解耦了。它所做的只是报告自己的内部事件,因为父组件可能会关心这些事件。

在上一章我们简单分析了在eventsMixin(Vue)中为Vue原型对象添加的四个事件方法及其内部实现。每个vm实例都有$on$once$off$emit四个事件api。那么父组件是如何在子组件上注册事件的呢?

事件注册

通过断点理清了自定义事件的注册流程,相关的代码有点长,这里就不粘贴了。

  • 生成render函数时,会将组件标签上的的v-on指令解析到配置对象的on属性
  • render函数执行时,遇见组件标签会调用createComponent生成对应的vnode,其中on属性会挂载到vnode.componentOptions.listeners
  • patch函数执行时,会调用createComponent并传入对应的vnode,如果是组件会调用对应的组件构造函数
  • 在组件实例化的_init(option)过程中,合并配置参数时会调用initInternalComponent,并添加option._parentListeners = vnodeComponentOptions.listeners
  • 然后在initEvents中,会根据option._parentListeners调用updateComponentListeners为组件实例注册相关的事件,其中的add方法即$once$on快捷封装

可以看见,通过上面的流程,可以把父组件的事件处理函数注册到子组件的事件上,然后只需要在子组件中手动触发vm.$emit(eventName)就可以调用父组件的方法了。

组件上的原生事件

在模板解析的时候对于组件上和常规标签上的v-on指令处理不一样的(虽然他们都是被解析到render函数配置参数的on属性上),在语法细节这一章我们整理了原生事件处理函数的处理方式,会调用addEventListener进行注册。但是在组件上@click等原生事件也会被当作自定义事件处理,需要手动触发才会生效。

那么,如果需要为子组件绑定原生的事件处理函数应该怎么做呢?其实只需要添加.native修饰符即可

<test-com :message="msg" @click.native="listenChild" @done="done"></test-com>

这样相关的指令会解析到配置参数的nativeOn属性上面,然后通过addEventListener进行注册。

3.2. slot插槽

插槽的使用

在大部分情况下,组件应该是封装良好的,但凡事无绝对,在开发中也会遇到某些特殊情况。

举个例子,实现一个通用的选项卡组件,我们需要封装选项卡切换的逻辑,并在选项卡切换通知父元素。但是每个选项卡的内容,可能是由父组件控制的。如何解决这个问题呢

Vue提供了slot来实现内容分发的功能,即向子组件中动态插入模板内容,且插入的数据作用域由父元素控制

父组件模板的内容在父组件作用域内编译;子组件模板的内容在子组件作用域内编译。

但在有时候,确实需要在插槽中访问子组件的数据,比如一个通用的列表组件,列表的部分内容如序号,操作按钮等是公共的,而列表展示的具体数据样式有差异(比如用户列表项和商品列表项的内容和排版),此时每个的渲染模板来自于父组件(具体的列表),但列表项中的序号等数据需要从子组件获取。

考虑到上面这种使用情形,Vue还提供了作用域插槽的功能,通过指定slot-scope需要从子组件获取的数据

插槽的实现

接下来我们来看看在模板编译时是如何处理slot的,同理,我们先写一个最简单的插槽

<div id="app">
    <test-com>
        <slot>hello slot</slot>
    </test-com>
</div>
Vue.component('slotCom', {
    template: `
        <h1>
            <slot>default slot</slot>
        </h1>`,
})

老规矩,从渲染函数开始

// 父元素的渲染函数
function anonymous() {
  with (this) {
    return _c(
      "div",
      { attrs: { id: "app" } },
      [_c("slot-com", [_t("default", [_v("hello slot")])], 2)],
      1
    );
  }
}
// 子组件的渲染函数
function anonymous() {
  with (this) {
    return _c("h1", [_t("default", [_v("default slot")])], 2);
  }
}

可以看见,在render函数中通过_t辅助函数将对应的slot节点转换成子节点,然后通过_c函数进行渲染。我们定位到renderSlot辅助函数

export function renderSlot (
  name: string,
  fallback: ?Array<VNode>,
  props: ?Object,
  bindObject: ?Object
): ?Array<VNode> {
  const scopedSlotFn = this.$scopedSlots[name]
  let nodes
  if (scopedSlotFn) {
      // 作用域插槽
    props = props || {}
    if (bindObject) {
      props = extend(extend({}, bindObject), props)
    }
    nodes = scopedSlotFn(props) || fallback
  } else {
    // 具名插槽
    const slotNodes = this.$slots[name]
    if (slotNodes) {
      slotNodes._rendered = true
    }
    // 直接传入的vnode节点
    nodes = slotNodes || fallback
  }

  const target = props && props.slot
  if (target) {
    return this.$createElement('template', { slot: target }, nodes)
  } else {
    // 直接返回对应的vnode
    return nodes
  }
}

在解析模板的时候,会通过renderSlot函数将对应的插槽解析为vnode,然后在createChildren进行渲染。

4. 非父子组件通信

上面整理了父子组件之间的单向数据流通信的工作原理,即props_parentListeners的运行机制。那么,非父子组件之间的通信该怎么实现呢?

换个角度思考一下,所谓通信就是多个组件间共享某些数据

4.1. 基于事件

一种常见的做法是通过一个空的Vue对象来管理数据,下面是一个简单的例子

<div id="app">
    <btn-a></btn-a>
    <com-b></com-b>
</div>
let btnA = Vue.component("btn-a", {
    template: `<button @click="test">click</button>`,
    methods: {
        test(){
            this.$root.Bus.$emit("btnclick", "greet from btna");
        }
    }
});
let comB = Vue.component("com-b", {
    template: `<h1>{{greet}}</h1>`,
    props: {
        msg: {
            type: String,
            default: "hello"
        }
    },

    created(){
        console.log("created");
    },

    mounted(){
        console.log("mounted");
        this.$root.Bus.$on("btnclick", (e)=>{
            this.greet = e;
        })
    },
    data(){
        return {
            greet: "hhh"
        }
    },
    methods: {
    }
});

new Vue({
    el: "#app",
    data: {
        // 这个空的vue对象来做事件总线
        Bus: new Vue()
    },
    components: {btnA, comB}
})

4.2. vuex

如果整个应用比较复杂,则可能需要定义多个空的Vue对象来管理不同组件之间的通信,此时我们需要更专业的状态管理,Vue提供了Vuex这个库,关于Vuex的分析,咱们后面再弄

5. 小结

这章简单这里了Vue组件的相关知识。还有一些诸如异步组件、递归组件等没有一一研究了。了解组件的实现只是实现模块化的第一步,我觉得,如何将业务拆分成合适的组件是一个更难且更重要的任务,这这有在大量的实践中才能理解,因此任重而道远啊。

Vue源码阅读笔记之路由管理(六) Vue源码阅读笔记之语法细节(四)