本系列文章会以实现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.isReservedTag和Vue.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又有什么关系? 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, 也就具备局部和全局的属性, 这就是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是一个组件标签, 反之, 就不是;
- 先查看options.components上是否有对应的id, 如果有则返回, 否则进入下一步;
- 将id转为驼峰格式, 再查看options.components上是否有对应的id, 如果有则返回, 否则进入下一步;
- 将驼峰格式的id的再转为首字母大写格式, 再查看options.components上是否有对应的id, 如果有则返回, 否则进入下一步;
- 以上都不成立, 则将以上三种格式分别在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阶段我们都改造了些什么:
- 我们利用isReservedTag方法, 找出了普通标签, 让普通标签继续走我们之前的new VNode
逻辑;
- 我们利用resolveAsset方法, 判断出了一个非普通标签的tag, 是否为一个组件, 期间, 我们还完善了_init方法中的options上的components属性,判断出当前tag是否是一个组件名!
- 最后, 在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内最终会执行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赋值, 该值即为VNode转换的真实Dom, 但是, child.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, 将组件挂载到页面上, 至此, 组件就可以显示出来了!
往期回顾