Hello, 这里是link😋, 看完了原生节点的挂载, 我们来看看Vue是怎么实例化一个组件的.本文中配备的流程图都可以在我的源码项目: vue-core-analyse中拿到哦, 欢迎Star✨
流程图
Demo
我们结合平时组件的使用习惯, 来写两个demo
Vue.component全局注册
全局下注册一个
HelloWorld
组件
Vue.component('HelloWorld', {
name: 'HelloWorld',
template: '<div>{{ msg }}</div>',
data() {
return {
msg: 'Hello-world'
}
}
})
组件内使用
这个其实就是我用脚手架生成的一个文件, 然后在
APP.vue
还加了一个HelloWorld
组件
// mian.js
import Vue from '../../../vue'
import App from './App.vue'
new Vue({
el: '#app',
render: h => h(App)
})
// App.vue
<template>
<div id="app">
<h1>普通节点</h1>
<HelloWorld />
</div>
</template>
<script>
import HelloWorld from './components/HelloWorld.vue'
export default {
name: 'App',
components: {
HelloWorld
},
}
</script>
- 这个树状图表明了它们的关系
Vue.component的原理
首先我们来看看component
函数的定义, 他是通过Vuejs文件执行的时候, 通过initAssetRegisters(Vue)
函数创建并且赋值到Vue构造函数
上的
const ASSET_TYPES = [
'component',
'directive',
'filter'
]
export function initAssetRegisters (Vue) {
/**
* 组件函数的初始化地
*/
ASSET_TYPES.forEach(type => {
/**
* Vue.component
*/
Vue[type] = function (
id,
definition
) {
if (type === 'component' && isPlainObject(definition)) {
definition.name = definition.name || id // 赋值name
// 原型链继承大法
definition = this.options._base.extend(definition)
}
this.options[type + 's'][id] = definition
return definition
}
})
}
这里我们略过directive
和filter
的逻辑, 可以看到当type === 'component'
的时候会执行一个函数extend
, 并且将组件的data
等属性传进去.
我们可把component函数抽出来, 它大概就长这样:
Vue.component = function (id, definition) {
definition.name = definition.name || id // 赋值name
definition = this.options._base.extend(definition) // 原型链继承大法
this.options[type + 's'][id] = definition
return definition
}
_base
其实就是Vue构造函数
本身, 这个我在第一篇文章有提到, 这里其实就是用Vue.extend
方法将definition对象
转成一个构造函数, 方便后面用到这个组件的时候可以去通过 new
的形式实例化它.
由于非全局组件
注册也会用到extend函数
, 我们后面再来看它的原理.
组件内使用的原理
我们先来看看脚手架下, 第一个组件App
是怎么生成的
import Vue from '../../../vue'
import App from './App.vue'
new Vue({
el: '#app',
render: h => h(App)
})
从这里可以看到我们给render函数
的h
传进去了App
组件, 这是因为我们整个项目是由webpack
构建的, 在项目跑起来以后, 我们所有的*.vue
页面都会被vue-loader
, 转化为描述当前组件的一个对象
我在Vue是怎么初始化第一个标签的?梳理了
new Vue
实例化执行流程, 和h函数
的原理, 如果你觉得以下内容有点跳跃性, 建议读一下这篇
new Vue()的执行
当第一个Vue实例化的时候, 按照初始化逻辑执行_init函数
, 最终执行$mount
函数. 并且Vue会发现我们已经有一个render函数
了, 它就会直接使用这个render函数
.
Vue实例化过程中, 会创建一个 Watcher
实例, 并且在Watcher
通过一个函数来控制组件
和标签
的生成与实现. 第一次会默认执行一次这个updateComponent
, 而后续数据发生改变也会执行这个函数, 不过这些是响应式系统
的内容了, 我们后续文章再来仔细研究
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
这个函数我们可以分成两部分看vm._render()
, vm._update()
. 前者负责标签与组件的生成, 后者负责将它们挂载到真实DOM树上
组件Vnode的生成
先来看看 vm._render()函数
, 这个函数就是把我们自定义的render函数
拿出来执行
// vm._render()
Vue.prototype._render = function (): VNode {
const vm: Component = this
const { render, _parentVnode } = vm.$options
// render self
let vnode = render.call(vm._renderProxy, vm.$createElement)
// set parent
vnode.parent = _parentVnode
return vnode
}
- 请注意这个
vm.$createElement
就是我们render函数
接收到的参数h
它做了两件事情:
- 执行我们在
main.js
传入的render函数
- 返回
render函数
生成的虚拟节点VNode
那核心就在于接收到我们传入的App
组件的vm.$createElement函数
了
vm.$createElement函数
h函数
和_createElement函数
之间, 其实还套了几层其他函数, 用于对参数做处理, 但是我们无需关注, 这里我们只需要知道tag参数
就是 我们在h函数
传入的组件App
export function _createElement (
context: Component, // 当前组件实例
tag?: string | Class<Component> | Function | Object, // 函数 组件 标签
data?: VNodeData,
children?: any,
): VNode | Array<VNode> {
let vnode, ns
// tag标签是一个字符串
if (typeof tag === 'string') {
let Ctor
// 这里会判断一下 标签 是否是一个 保留标签
if (config.isReservedTag(tag)) {
// platform built-in elements
vnode = new VNode(
tag, data, children,
undefined, undefined, context
)
} else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
// component
// 项目初始化的时候
vnode = createComponent(Ctor, data, context, children, tag)
}
} else {
// direct component options / constructor
vnode = createComponent(tag, data, context, children)
}
}
通过图形梳理, 它的逻辑是这样的:
现在显然tag
是一个组件对象, 所以它往左侧走. 接下来我们来分析createComponent
做了什么
createComponent
export function createComponent (
Ctor: Class<Component> | Function | Object | void,
data: ?VNodeData,
context: Component,
children: ?Array<VNode>,
tag?: string
): VNode | Array<VNode> | void {
// baseCtor Vue构造函数
const baseCtor = context.$options._base
// 原型链继承
// 组件进来的时候会是一个对象, 需要重新走一遍继承
// 但是全局注册过得组件就不需要, 初始化的时候已经继承了
if (isObject(Ctor)) {
Ctor = baseCtor.extend(Ctor)
}
// 注册一些组件管理钩子在占位符节点上
installComponentHooks(data)
// 返回一个占位符 vnode
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
)
return vnode
}
从代码可以看出, 这个函数总共做了三件事情:
- 通过函数
extend
将Ctor组件对象
转化成了Ctor组件构造函数
installComponentHooks(data)
挂载内联钩子- 将当前
组件构造函数
转为Vnode
并返回
extend函数的实现
extend函
数实际是通过原型链继承的形式完成了组件函数的生成
重要的代码就3步:
- 定义一个
Sub函数
, 内部执行this._init
方法, 也就是我们new Vue()
默认执行的_init方法
Sub.prototype = Object.create(Super.prototype)
Sub.prototype.constructor = Sub
这3步就完成了原型链继承的核心
Vue.cid = 0
let cid = 1
Vue.extend = function (extendOptions: Object): Function {
// 当前组件的 options
extendOptions = extendOptions || {}
const Super = this // Vue = _base = this Super 一般都是 Vue
const SuperId = Super.cid
const cachedCtors = extendOptions._Ctor || (extendOptions._Ctor = {})
// 检查cache
if (cachedCtors[SuperId]) {
return cachedCtors[SuperId]
}
// 定义Sub函数内部执行_init方法
const Sub = function VueComponent (options) {
this._init(options) // ✨
}
// 原型链继承
// 当前组件的构造函数原型指向 Vue的原型 (表明组件构造函数 是通过 Vue 实例化的)
Sub.prototype = Object.create(Super.prototype) // ✨
// 当前构造函数的原型 指向 构造函数
Sub.prototype.constructor = Sub // ✨
Sub.cid = cid++
// 合并Vue 和 当前实例的配置.
// 一般全局注册的组件, 全局混入等 都是通过这个函数合并到子组件内的
Sub.options = mergeOptions(
Super.options,
extendOptions
)
Sub['super'] = Super
// component 等创造组件的函数
ASSET_TYPES.forEach(function (type) {
Sub[type] = Super[type]
})
// 允许组件引用自己
if (name) {
Sub.options.components[name] = Sub
}
// 加入cache
cachedCtors[SuperId] = Sub
return Sub
}
}
但是这个函数还有很多值得学习的地方, 比如cachedCtors缓存写法, 能够有效缓存Ctor, 以后同样的组件就不会再走一样的步骤了. 再比如mergeOptions
也是Vue全局下复用性很高的函数, 内部通过一个策略模式能过将参数1
的各种配置合并到参数2
中, 比如全局Mixins
, 全局引入的组件
都是在这里实现合并的.后面讲到生命周期函数的时候, 我们再来看看这个函数的实现.
installComponentHooks
这个函数做的事情很简单, 就是将四个钩子(
init
,prepatch
,insert
,destroy
)赋值到Vnode上, 在组件转为真实节点的时候会用到, 我们到时再来看它们的作用
在上图中, 还有一种情况我们没有分析, 就是我们传入的tag
是一个字符串的时候, 也有可能是一个组件.
例如: 我们在HTML标签中书写的一个HelloWorld组件
(如上面的Demo). 这种情况就会走到图中的右侧情况.
我们都知道, 模板编译最终会将HTML结构转化为一个render函数, App组件内部也不例外, 他最终会被转化为这样:
- 图中有一个
_c函数
, 实际上这个就是$createElement函数
, 只是Vue
会实现两个版本, 一个是用户使用的, 而_c
则是Vue内部自己使用的.区别在于对子节点的处理方式上, 篇幅问题,就不展开啦.
vm._c = function (a, b, c, d) { return createElement(vm, a, b, c, d, false); };
vm.$createElement = function (a, b, c, d) { return createElement(vm, a, b, c, d, true); };
可以看到, 我们会给$createElement传入一个字符串的"HelloWorld", 它就会走入到resolveAsset函数
这个函数的作用就是在将组件名称转为驼峰式, 首字母大写的形式, 然后去父组件的options
找到这个组件的定义, 如果有的话则返回这个构造函数.
我们通过全局注册的组件会通过mergeoptions函数
, 将引用拷贝一份赋值给子组件, 所以在子组件内部是可以拿到这个全局注册组件的构造函数.这样在creatComponent函数
内部, 就无需再走一次extend函数
, 而是直接使用这个构造函数了.当然最终也会返回VNode.
好了, 到这里我们就看完了组件虚拟节点的生成. 我们来总结一下:
-
当
createElement函数
内部会执行creatComponent函数
这个函数通过extend完成原型链继承, 将当前组件转为一个组件构造函数, 如同Vue构造函数
一般 -
组件也像原生标签一样, 被生成为一个虚拟节点, 我们一般叫做
占位符虚拟节点
组件的挂载
我们还是回到这个函数, 上述都是vm.render()
做的事情, 它最终返回了一个Vnode
, 会通过vm._update()
挂载到DOM树上. 现在我们来看看这个函数对于组件会做哪些不同的事情
updateComponent = () => {
vm._update(vm._render(), hydrating)
}
在组件挂载的流程中_update函数
只有两个核心操作需要我们关注:
- 将组件和原生虚拟节点转为真实节点
- 插入到它的父级节点
真实节点的创建
在执行_update函数的时候, 会执行一个
creatElm函数
它会做四件事情:
- 尝试将当前
VNode
作为组件创建 - 不行的话将
Vnode
通过原生createElement
创建成真实节点 - 递归创建子节点
- 插入到父级节点
function createElm (
vnode, // 当前虚拟节点
insertedVnodeQueue,
parentElm, // 父真实节点
refElm, // 节点插入的时候要用到
nested, // 创建子节点的时候 这里是 true 用于判断是否是根节点
ownerArray,
index
) {
// 尝试将当前VNode作为组件创建
if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
return
}
// 创建真实节点
vnode.elm = vnode.ns
? nodeOps.createElementNS(vnode.ns, tag)
: nodeOps.createElement(tag, vnode)
// 递归创建子节点
createChildren(vnode, children, insertedVnodeQueue)
// 插入
insert(parentElm, vnode.elm, refElm)
}
createChildren
的实现
function createChildren (vnode, children, insertedVnodeQueue) {
for (let i = 0; i < children.length; ++i) {
// 递归创建子节点
createElm(children[i], insertedVnodeQueue, vnode.elm, null, true, children, i)
}
}
子组件的创建
接下来到我们重点是
createComponent
, 到这一步的时候, 就进入了组件内部子组件的创建了.
还记得我们组件Vnode
在创建的时候, 有一步操作是将4个内联钩子
安装到Vnode.data
中吗? 对了就在这里用到了. 我们来看看第一个用到钩子init
function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) {
let i = vnode.data
if (isDef(i = i.hook) && isDef(i = i.init)) { // 这里赋值 init
i(vnode, false /* hydrating */)
}
/**
* 1. 组件内原生节点在 creatElm阶段的时候插入了
* 2. 这时候就是插入到当前组件的上级根节点
*/
insert(parentElm, vnode.elm, refElm)
return true
}
init
函数会执行组件Vnode
的Ctor构造函数
init (vnode: VNodeWithData, hydrating: boolean): ?boolean {
const child = vnode.componentInstance = new vnode.componentOptions.Ctor(options)
// 在这里实现挂载, 全局下的_init 不会走到$mount
child.$mount(hydrating ? vnode.elm : undefined, hydrating)
},
还记得构造函数的定义吗?
const Sub = function VueComponent (options) {
this._init(options)
}
这个组件又会执行一次Vue
中的_init
然后再走一遍像new Vue()
的流程, 初始化method
, data
, 还有上面提到的vm.render()
vm._update()
去生成自己内部的原生节点, 组件等.
最终通过当组件实例化完毕就会通过insert函数
插入到父级节点.
function insert (parent, elm, ref) {
if (isDef(parent)) {
if (isDef(ref)) {
// ref元素 存在的话, 就将其插入到 ref元素 之前
// 这个元素是真实节点的下一个真实兄弟节点
// 我猜这么做, 插入位置会更准确?
if (nodeOps.parentNode(ref) === parent) {
nodeOps.insertBefore(parent, elm, ref)
}
} else {
// 否则是直接添加到 父元素下
nodeOps.appendChild(parent, elm)
}
}
}
通过代码看可能比较绕, 我们看看图
总结
本质上对于Vue来说每一个组件都是它的子类, 组件实例就是new Vue()
的过程, 是一个递归过程. 初次看到这里可能会比较难以理解, 我十分推荐你单步调试, 感受一下.
源码到这里我们就能看出Vue的第一个设计理念.
对于Vue来说, 每一个组件实例化执行_init
方法, 都会new
一个Watcher实例
, 去用于订阅数据变化.
这样做有什么好处? 可以把diff过程
限定在组件内部, 而无需从整体去做. 也就无需像React
一样由于庞大的计算量而需要提出Fiber架构
了
感谢😘
如果觉得文章内容对你有帮助:
-
❤️欢迎关注点赞哦! 我会尽最大努力产出高质量的文章
个人公众号: 前端Link
联系作者: linkcyd 😁
往期: