手写简化版Vue(八) 组件原理--2.消费

181 阅读7分钟

本系列文章会以实现Vue的各个核心功能为标杆(初始化、相应式、编译、虚拟dom、更新、组件原理等等), 不会去纠结于非重点或者非本次学习目标的细节, 从头开始实现简化版Vue, 但是, 即使是简化, 也需要投入一定的时间和精力去学习, 而不可能毫不费力地学习到相对复杂的知识; 所有简化代码都会附上原版源码的路径, 简化版仅仅实现了基本功能, 如需了解更多细节, 可以去根据源码路径去阅读对应的原版源码;

概述

上一节我们介绍了全局组件的注册原理, 我们了解了Vue.component方法执行之后, 其最终是将一个组件构造器挂入全局对象Vue.options.components之下, 那么挂上之后, 就要考虑它是如何被消费的了; 在介绍Vue编译原理的时候, 我们了解到, 从模版语法到页面效果, 需要经历编译阶段(包括: code, ast, render)和运行时阶段(包括: 生成VNode, 生成dom, 挂载), 并且在后续的学习中, 我们已经完成了从代码到页面效果的基本流程, 也就是基本框架已经有了, 现在讨论的组件消费问题, 其实就是在这个基本框架之上, 进行的完善, 即 将组件的情况也纳入考虑范围, 要完善的目标, 主要是运行时阶段的VNode和生成DOM阶段, 分别对应_createElement方法和createElm方法

_createElement

同时我们也知道了render方法中的_c方法, 是生成VNode的关键所在, 但之前我们在写_c即_createElement方法的时候, 根本没考虑过什么组件的情况, 全部都直接走new VNode逻辑:

// 源码地址: /src/core/vdom/create-element.ts
function _createElement (context, tag, data, children) {
  // 无论tag是啥, 只有这段逻辑
  return new VNode(tag, data, children, undefined, undefined, context)
}

显然是不合适的,因为普通节点的VNode和组件的VNode必然包含不同的信息, 所以这个方法, 必须改造, 加入组件的情况:

import config from '../config'
import { resolveAsset } from "../util/options" // 新增引入resolveAsset
import { createComponent } from "./create-component"

function _createElement (context, tag, data, children) {
  let Ctor, vnode
  // 如果是普通标签
  if (config.isReservedTag(tag)) {
    vnode = new VNode(tag, data, children, undefined, undefined, context)
  // 如果是组件
  } else if (Ctor = resolveAsset(context.$options, 'components', tag)) {
    vnode = createComponent(Ctor, data, context)
  }
  return vnode
}

总体逻辑上看, 我们将普通标签和组件标签进行了区分, 走不同的逻辑; 但是这里的config.isReservedTag又是怎么来的呢? 我们先来理一理它的逻辑

isReservedTag

初始化, 在全局配置文件中进行初始化, 当然, 这时候它是没有逻辑的

// 代码路径: /src/core/config.ts
import { no } from "../shared/util"

export default {
  optionMergeStrategies: Object.create(null),
  isReservedTag: no // 初始化
}

export const optionMergeStrategies = Object.create(null)

export const isReservedTag = no

no永远返回false, 相当于就是个占坑位的

// 源码路径: /src/shared/util.ts
export const no = (a, b, c) => false

它具体逻辑其实会在runtime入口文件被赋值

// 源码路径: /src/platforms/web/runtime/index.ts
import Vue from '../../../core/index'
import { isReservedTag } from '../util' // 增加
// 省略...

Vue.config.isReservedTag = isReservedTag // 增加
// 省略...

export default Vue

像这种, 在core中不明确定义逻辑, 到了runtime中定义的, 其实就是出于跨平台的考虑, 这个在之前的章节里已经介绍过了; 不过要注意了, 我们判断一个tag是否为组件的时候, 使用的是config.isReservedTag, 而这里, 却将isReservedTag具体逻辑赋给了Vue.config.isReservedTag! 那么config.isReservedTagVue.config.isReservedTag如何产生关联的呢? 别忘了上一节的initGlobalAPI方法中的处理:

import config from '../../core/config'
// ...
const configDef = {}
configDef.get = () => config // 注意这里
Object.defineProperty(Vue, 'config', configDef)
// ...

所以, 当我们设置Vue.config.isReservedTag的时候, 其实就是设置config.isReservedTag! 好了, 我们继续探讨这个isReservedTag的具体逻辑;

前面在runtime/index.ts顶部导入的util文件

// 源码路径: /src/platforms/web/util/index.ts
export * from './element'

element:

// 源码路径: /src/platforms/web/util/element.ts
import { makeMap } from '../../../shared/util'

// 是否是html标签
export const isHTMLTag = makeMap('html,body,base,head,link,meta,style,title,' +
'address,article,aside,footer,header,h1,h2,h3,h4,h5,h6,hgroup,nav,section,' +
'div,dd,dl,dt,figcaption,figure,picture,hr,img,li,main,ol,p,pre,ul,' +
'a,b,abbr,bdi,bdo,br,cite,code,data,dfn,em,i,kbd,mark,q,rp,rt,rtc,ruby,' +
's,samp,small,span,strong,sub,sup,time,u,var,wbr,area,audio,map,track,video,' +
'embed,object,param,source,canvas,script,noscript,del,ins,' +
'caption,col,colgroup,table,thead,tbody,td,th,tr,' +
'button,datalist,fieldset,form,input,label,legend,meter,optgroup,option,' +
'output,progress,select,textarea,' +
'details,dialog,menu,menuitem,summary,' +
'content,element,shadow,template,blockquote,iframe,tfoot')

// 是否是svg标签
export const isSVG = makeMap('svg,animate,circle,clippath,cursor,defs,desc,ellipse,filter,font-face,' +
'foreignobject,g,glyph,image,line,marker,mask,missing-glyph,path,pattern,' +
'polygon,polyline,rect,switch,symbol,text,textpath,tspan,use,view',
true)

// 是否是内置标签
export const isReservedTag = (tag) => isHTMLTag(tag) || isSVG(tag)

//源码路径: /src/shared/util.ts
export function makeMap(str, expectsLowerCase) {
  const map = Object.create(null)
  const list = str.split(',')
  for (let i = 0; i < list.length; i++) {
    map[list[i]] = true
  }
  return expectsLowerCase ? val => map[val.toLowerCase()] : val => map[val]
}

makeMap方法前面已经介绍过了, 其实就是利用闭包来简化一些固定的查询操作; 所以, 可以得出结论只要是html标签或者是svg标签, 那都属于普通标签, 还是走我们之前的老逻辑; 至此, 我们已经可以区分出普通标签了;

resolveAsset

区分了普通标签之后, 剩下的tag就一定是组件吗? 我随便写一个haha-component, 也没注册, 那也算组件? 这个错误相信大家都见过吧

所以, 我们还是要验证, 它是否是真的组件, 那么如何验证呢? 这里要介绍resolveAsset了

// ...
else if (Ctor = resolveAsset(context.$options, 'components', tag)) {
  vnode = createComponent(Ctor, data, context)
}
// ...

前面我们已经说了, 注册全局组件是将组件挂到Vue.options.components之下, 所以, 这里给resolveAsset的第一个入參就是context.options!等下,我只说全局组件挂在Vue.options.components之下,Vue.optionscontext.options! 等下,我只说全局组件挂在Vue.options.components之下, 那Vue.options和context.options又有什么关系? context.$options是否也有全局组件的信息呢?如果它都不具备全局组件的信息, 那我们用它来判断一个tag是否为组件, 是否有问题?

在第一节, 我们介绍Vue初始化的时候, 曾经介绍过$options, 说白了, 它就是实例的一个属性, 包含了各种参数

// 源码路径: src/core/instance/init.ts
export function initMixin (Vue) {
  Vue.prototype._init = function (options) {
    const vm = this
    vm.$options = options // 这里
    // ...省略
  }
}

那么_init方法中的这个options从哪来的? 没错就是new Vue(options)或者new VueComponent(options) 中的options! 显然, 它不包含任何全局信息, 只是本实例的局部信息而已! 所以, 我们要让 $options继承Vue.options上的所有属性! 这样, resolveAsset才能获取到全局/局部的所有信息, 才能准确判断, 这个tag, 是否被注册为了组件(无论局部还是全局); 因此, 这里又要用到mergeOptions了, 所以, 现在_init的逻辑应该是这样:

Vue.prototype._init = function (options) {
  const vm = this
  // $options 不再是局部的options, 而是继承了全局的信息
  vm.$options = mergeOptions(resolveConstructorOptions(vm.constructor), options, vm)
  // ...省略
}
export function resolveConstructorOptions(Ctor) {
  let options = Ctor.options

  return options
}

在上一节中, 我们用mergeOptions把Vue.options合并到了Sub.options了, 即 Sub.options继承了Vue.options, (mergeOptions的逻辑这里就不再重复展开了,如不清楚, 请参考上一节的initExtend方法); 我们重点看下resolveConstructorOptions, 不错, 很简单, 它就是将构造器Ctor上的options取出并返回! 注意resolveConstructorOptions的入參vm.constructor就是当前实例的构造器! 在组件实例化的时候, 构造器就是Sub或者说是VueComponent! 在Vue本身实例化的时候, 构造器就是Vue! 所以, 无论是组件和Vue的实例化, 这里的$options都将拥有Vue.options, 即全局的属性! 局部的属性那就更不用说了;

因此, 现在的context.options,即当前实例的options, 即当前实例的options, 也就具备局部和全局的属性, 这就是resolveAsset的第一个入參options

// 源码路径: /src/core/util/options.ts
// 解析options中的属性值
export function resolveAsset (options, type, id) {
  if (typeof id !== 'string') return
  const assets = options[type]
  // 如果有id, 则返回
  if (hasOwn(assets, id)) return assets[id]
  // 将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
}

resolveAsset写了很多判断, 其实归根到底就是一件事, 寻找options本身和原型链上, 是否有对应的[id]组件, 这个id, 就是tag; 由于此时的options, 已经具备了全局/局部的所有信息了! 所以, 如果能获取到, 那就说明这个tag是一个组件标签, 反之, 就不是;

  1. 先查看options.components上是否有对应的id, 如果有则返回, 否则进入下一步;
  2. 将id转为驼峰格式, 再查看options.components上是否有对应的id, 如果有则返回, 否则进入下一步;
  3. 将驼峰格式的id的再转为首字母大写格式, 再查看options.components上是否有对应的id, 如果有则返回, 否则进入下一步;
  4. 以上都不成立, 则将以上三种格式分别在options的原型上查找! 这一步其实就是, 如果你不是局部组件, 那就看你是不是全局组件; 如果能获取到, 无论是局部还是全局, 都返回该组件的构造器; 反之, 则说明这个标签不是组件标签!

补充: 驼峰和首字母大写转换逻辑如下

// 源码路径: /src/shared/util.ts
export const cached = (fn) => {
  const cache = Object.create(null)
  return function (str) {
    const hit = cache[str]
    return hit ||( cache[str] = fn(str))
  }
}

// 分隔符转为驼峰
const camelizeRE = /-(\w)/g
export const camelize = cached((str) => {
  return str.replace(camelizeRE, (_, r) =>(r ? r.toLowerCase() : ''))
})

// 首字母大写
export const capitalize = cached((str) => {
  return str.charAt(0).toUpperCase() + str.slice(1)
})

createComponent

已经完成了组件的判断, 即如果这个tag真的是一个组件的标签, 那么, resolveAsset就会返回该组件的构造器, 我们再将这个构造器传给createComponent方法

// ...
if (Ctor = resolveAsset(context.$options, 'components', tag)) {
  vnode = createComponent(Ctor, data, context)
}
...

createComponent

// 源码地址: /src/core/vdom/create-component.ts
export function getComponentName (options) {
  return options.name
}
export function createComponent (Ctor, data, context, children, tag) {
  installComponentHooks(data)
  const name = getComponentName(Ctor.options)
  const vnode = new VNode(
    `vue-component-${Ctor.cid}${name ? `-${name}`: ''}`,
    data,
    undefined,
    undefined,
    undefined,
    context,
    {Ctor}
  )
  return vnode
}

createComponent方法最终返回的还是一个VNode实例, 毕竟, 此时还是VNode阶段, 其核心目的就是生成VNode! 但是这个实例VNode, 显然和之前的普通标签的VNode实例有所不同; 最明显的一点, 就是它的组件名, 不再是一个单纯的tag ,而是vue-component-作为前缀; 其次就是它的参数数量, 之前我们介绍VNode的时候, 只考虑普通标签的时候, 我们只有6个入參:

// 源码地址: /src/core/vdom/vnode.ts
export default class VNode {
  /**
   *
   * @param tag 标签
   * @param data 属性
   * @param children 子vdom
   * @param text 文本
   * @param elm 真实节点
   * @param context 上下文, 通常就是Vue实例
   */
  constructor (tag, data, children, text, elm, context) {
    this.tag = tag
    this.data = data
    this.children = children
    this.text = text
    this.elm = elm
    this.context = context
  }
}

现在, 多增加了一个入參, 即组件相关的参数

// ...
constructor (tag, data, children, text, elm, context, componentOptions) {
	// 省略...
  this.componentOptions = componentOptions
}
// ...

再来看看installComponentHooks方法, 也是组件解析很重要的一步:

// 代码路径: 同createComponent
import { activeInstance } from "../instance/lifecycle"
import VNode from "./vnode"

const componentVnodeHooks = {
  init (vnode) {
    const child = (vnode.componentInstance = createComponentInstanceForVnode(vnode))
    return child.$mount()
  }
}

export function createComponentInstanceForVnode (vnode) {
  const options = {
    _isComponent: true,
    parent: activeInstance
  }
  return new vnode.componentOptions.Ctor(options)
}

// 组件生命钩子
const hooksToMerge = Object.keys(componentVnodeHooks)
// 创建组件的钩子方法
export function installComponentHooks (data) {
  const hooks = data.hooks || (data.hooks = {})
  for (let i = 0; i < hooksToMerge.length; i++) {
    const key = hooksToMerge[i]
    const toMerge = componentVnodeHooks[key]
    hooks[key] = toMerge
  }
}

显然, 这个方法主要是设置data.hooks属性, 即组件的VNode的各个生命周期钩子, 这里主要展示init钩子,即初始化, 而初始化的过程, 本质上也就是组件实例化, 然后实例赋给vnode.componentInstance属性 当然, init钩子现在还不会执行, 只是定义, 我们现在记住有这么一个方法就行;

总结一下VNode阶段我们都改造了些什么:

  1. 我们利用isReservedTag方法, 找出了普通标签, 让普通标签继续走我们之前的new VNode

逻辑;

  1. 我们利用resolveAsset方法, 判断出了一个非普通标签的tag, 是否为一个组件, 期间, 我们还完善了_init方法中的options,使之不仅拥有局部属性,还继承了构造器上的属性;并以options, 使之不仅拥有局部属性, 还继承了构造器上的属性; 并以options上的components属性,判断出当前tag是否是一个组件名!
  2. 最后, 在createComponent方法中, 我们生成了组件的VNode, 期间我们将原本的VNode类进行了改造, 增加componentOptions入參; 并且还为data增加了hook方法, 介绍了init方法, 当然, 我们现阶段并未运行它, 只是了解了它的基本逻辑;

createElm

我们已经有了代表组件的VNode, 现在下一步就是要将其转为真实Dom; 大家还是否记得, VNode是如何转换为真实Dom的? 没错, 核心就在patch方法, 而patch方法中, 负责VNode转换的, 就是createElm方法, 我们来看下之前createElm方法:

// 源码地址: /src/core/vdom/patch.ts
// 将虚拟dom转为真实dom
function createElm (vnode, insertedVNodeQueue, parentElm, refElm, nested, owerArray, index) {
  const children = vnode.children
  const tag = vnode.tag
  const data = vnode.data //属性
  if (isDef(tag)) {
    // 根据表情名创建真实dom节点
    vnode.elm = nodeOps.createElement(tag)
    // 创建子节点
    createChildren(vnode, children, insertedVNodeQueue)
    if (isDef(data)) {
      invokeCreateHooks(emptyVnode, vnode)
    }
    insert(parentElm, vnode.elm, refElm)
  } else {
    vnode.elm = nodeOps.createTextNode(String(vnode.text))
    insert(parentElm, vnode.elm, refElm)
  }
}

和前面的_createElement方法一样, 之前也没有考虑组件的情况, 以上逻辑仅适用于普通标签, 我们可以看到, 其生成节点的一步是nodeOps.createElement(tag), 即document.createElement(tag); 还记得前面说组件的VNode的tag是什么样的吗? vue-component-xx这种格式, 这样去走document.createElement(tag)显然是不可能有用的; 我们还要将组件的情况也纳入进入! 所以, 我们在该方法的起始部分, 就添加该逻辑:

// 将虚拟dom转为真实dom
function createElm (vnode, insertedVNodeQueue, parentElm, refElm, nested, owerArray, index) {
  if (createComponent(vnode, insertedVNodeQueue, parentElm, refElm)) {
    return
  }
  // ...普通标签逻辑, 省略
}

如果判断为组件VNode, 就直接不走后续的逻辑了, 而所有组件的逻辑, 都集中在了createComponent方法中, 注意, 这个和_createElement中的createComponent不是一个方法, 只是同名而已!

createComponent

// 源码地址: 同createElm
function createComponent (vnode, insertedVNodeQueue, parentElm, refElm) {
  let i = vnode.data
  if (isDef(i)) {
    // 执行hooks上的init方法
    if (isDef( i = i.hooks) && isDef(i = i.init)) {
      i(vnode)
    }
  }
  if (isDef(vnode.componentInstance)) {
    vnode.elm = vnode.componentInstance.$el
    insert(parentElm, vnode.elm, refElm)
    return true
  }
}

createComponent方法做的事情其实就是给vnode.elm属性赋值, 也就是将VNode转为对应的真实Dom; 我们来看看它具体怎么做的; 首先, 我们前面说的init钩子, 这里派上用场了, 还记得init方法做了什么吗?

// ...
const componentVnodeHooks = {
  init (vnode) {
    const child = (vnode.componentInstance = createComponentInstanceForVnode(vnode))
    return child.$mount()
  }
}

export function createComponentInstanceForVnode (vnode) {
  const options = {
    _isComponent: true,
    parent: activeInstance
  }
  return new vnode.componentOptions.Ctor(options)
}
// ...

init的功能其实就是给vnode.componentInstance赋值, 也就是组件的实例new vnode.componentOptions.Ctor(options), 组件实例化的逻辑请参考上一节initExtend中的逻辑; 然后执行了该实例的mount方法,之前我们在学render方法的时候,我们知道,mount方法, 之前我们在学习_render方法的时候, 我们知道, mount内最终会执行mountComponent方法

export function mountComponent (vm, el) {
  vm.$el = el
  const updateComponent = function () {
    vm._update(vm._render())
  }
  new Watcher(vm, updateComponent)
}

而mountComponent最后执行的又是vm._update(vm._render()), 其执行结果就是给当前实例设置el属性,即为vm.el属性, 即为vm.el赋值, 该值即为VNode转换的真实Dom, 但是, child.mount()又没有任何参数,意味着,真实节点生成了,仅存在于vm.mount()又没有任何参数, 意味着, 真实节点生成了, 仅存在于vm.el上, 并没有被挂到页面, 具体可以参考之前讲过的createPatchFunction中的insert方法:

// 源码地址: /src/core/vdom/patch.ts
// ...
function insert(parent, elm, ref) {
  // parent存在, 才会执行挂载
  if (isDef(parent)) {
    if (isDef(ref)) {
      if (nodeOps.parentNode(ref) === parent) {
        nodeOps.insertBefore(parent, elm, ref)
      }
    } else {
      nodeOps.appendChild(parent, elm)
    }
  }
}
// ...

回到init方法中, 我们可以知道, 此时的vnode.componentInstance应该就是这种格式了:

可以看到, 此时的组件实例, 已经具备了真实组件根节点$el; 其_vnode属性的成员也都具备了elm这个代表真实节点的属性; 可以说就差挂载到页面了; 回到createComponent方法, 看看后续又做了什么:

// ...
if (isDef(vnode.componentInstance)) {
  vnode.elm = vnode.componentInstance.$el
  insert(parentElm, vnode.elm, refElm)
  return true
}
// ...

没错, 就是通过insert, 将组件挂载到页面上, 至此, 组件就可以显示出来了!

往期回顾

手写简化版Vue(一) 初始化

手写简化版Vue(二) 响应式原理

手写简化版Vue(三) 编译器原理

手写简化版Vue(四) render的实现

手写简化版Vue(五) _update的实现解析

手写简化版Vue(六) 更新队列

手写简化版Vue(七) 组件原理--1.注册