前言
通过手写Vue2源码,更深入了解Vue;
在项目开发过程,一步一步实现Vue核心功能,我会将不同功能放到不同分支,方便查阅;
另外我会编写一些开发文档,阐述编码细节及实现思路;
源码地址:手写Vue2源码
组件原理
组件分为全局组件和局部组件;全局组件通过Vue.component()
进行注册,在任何地方都可以直接使用;局部组件定义在父组件内部,在父组件中可用。
// 全局组件
Vue.component("parent-component", {
template: `<div>我是全局组件</div>`,
});
let vm = new Vue({
el: "#root",
data() {
return {
obj1: {
a: {
a: 4,
},
},
arr1: [1, 2, [4, 5]],
number1: 2,
firstName: "shi",
lastName: "deshan",
};
},
template: `<div>hello 这是我自己写的Vue{{number1}}<parent-component></parent-component><child-component></child-component></div>`,
// 局部组件
components: {
"child-component": {
template: `<div>我是局部组件</div>`,
},
},
});
有几个问题需要思考一下:
Vue.component()
是什么?为何在其他地方可以使用全局注册的组件?- 组件内的局部组件是如何渲染的?
Vue.component(id,definition)
// src/global-api/index.js
import initMixin from './mixin'
import { ASSETS_TYPE } from './const'
import initExtend from './extend'
import initAssetRegisters from './assets'
export function initGlobalApi(Vue) {
// 每个组件初始化的时候都会和Vue.options选项进行合并
Vue.options = {} // 用来存放全局属性,例如Vue.component、Vue.filter、Vue.directive
// 注册 Vue.mixin()方法
initMixin(Vue)
// 初始化Vue.options.components、Vue.options.directives、Vue.options.filters 为空对象
ASSETS_TYPE.forEach((type) => {
Vue.options[type + 's'] = {}
})
// Vue.options会与组件的options合并,所以无论创建多少子类,都可以通过实例的options._base找到Vue
Vue.options._base = Vue
// 注册Vue.extend()方法
initExtend(Vue)
// 注册Vue.component()、Vue.filter()、Vue.directive()方法
initAssetRegisters(Vue)
}
// src/global-api/assets.js
import { ASSETS_TYPE } from './const'
export default function initAssetRegisters(Vue) {
ASSETS_TYPE.forEach((type) => {
// name 为组件名/filter名/自定义指令名
// definition为配置项
Vue[type] = function (name, definition) {
if (type === 'component') {
// Vue.component(name,definition) 就是调用 Vue.extend(definition),然后调用下方的指令,赋值 Vue.options.components[name] = definition
definition = this.options._base.extend(definition)
} else if (type === 'filter') {
// 略
} else if (type === 'directive') {
// 略
}
this.options[type + 's'][name] = definition
}
})
}
可以看到 Vue.component(id,definition)
做了两件事:
- 执行
Vue.extend(definition)
- 将执行结果赋值给
Vue.options.components.name
;根据 上一篇混入原理 我们知道,组件实例执行_init()
时,会把父类的options与自身的options进行合并,所以Vue.options.components
会与自身options进行合并
为了实现在任何地方都可以使用全局组件,并且如果当前组件存在该组件,则直接调用自身的components[key]
,否则使用原型上的components[key]
;我们考虑在对components进行合并时,采用原型继承的方式:
根据 上一篇混入原理 可知,options生命周期的合并我们采用的是策略模式,所以直接扩展三种策略来合并components、directives、filters:
// src/util/index.js
const ASSETS_TYPE = ["component", "directive", "filter"];
ASSETS_TYPE.forEach((type) => {
strategies[type + 's'] = mergeAssets
})
// components、directives、filters的合并策略是一致的
function mergeAssets(parentVal, childVal) {
// 采用原型继承
const res = Object.create(parentVal)
if (childVal) {
// childVal对res中的同名属性进行覆盖
for (const key in childVal) {
res[k] = childVal[k]
}
}
return res
}
Vue.extend()
Vue.component(id,definition)
做的另一件事是 Vue.extend(definition)
Vue.extend() 的用法:
// 创建构造器 Profile
var Profile = Vue.extend(options)
// 创建 Profile 实例,并挂载到一个元素上。
new Profile().$mount('#app')
可知Vue.extend()的结果是一个构造函数,通过new创建组件实例。 实现方式如下:
// src/global-api/extend.js
export default function initExtend(Vue) {
let cid = 0
/**
* Vue.extend流程分析:
* 1. 创建一个继承自Vue的子类
* 2. 将子类的extendOptions与Vue.options合并
* 3. 在子类中调用this._init(options),该方法会在子类实例化时调用,进行实例的数据响应式和页面渲染
*/
Vue.extend = function (extendOptions) {
// 这里的options是在使用Sub创建组件实例时需要传入的options
const Sub = function VueComponent(options) {
this._init(options) // 这里的this指向Sub的实例
}
Sub.cid = cid++
// 为什么要继承Vue?为了可以使用Vue原型上所有的方法
Sub.prototype = Object.create(this.prototype) // 这里的this指向Vue
Sub.prototype.constructor = Sub
Sub.options = mergeOptions(this.options, extendOptions)
return Sub
}
}
Vue.extend(definition)
做的事情:
- 创建一个继承自Vue的子类
- 在子类中调用
this._init(options)
,实例化时调用 - 将传入的 extendOptions 与Vue.options进行合并
- 返回该子类
Vue.component(name,definition)
本质也是创建一个子类,用于创建组件实例;再梳理一下它的流程:
- 创建了一个继承于Vue的子类Sub
- 子类中调用
this._init(options)
- 将definition与Vue.options进行合并,结果放到子类Sub.options
- 返回该子类
- 赋值
Vue.options.components.name
为 definition - 当其他组件调用
_init()
方法的时候,会将该组件的options.components与Vue.options.components进行合并(components的合并采用的是策略模式 + 继承,具体合并方式见上文),所以可以在任何地方使用全局组件
组件的渲染
流程分析:
- 基于
new Vue
给根组件创建一个Vue的实例, - 开始解析根组件,生成VNode;在生成VNode的过程中,对于组件特殊处理:在data上添加一个hook属性,详情见下文。
- 基于VNode,创建真实DOM:
- 创建真实DOM的过程中,如果遇到组件标签,特殊处理:
- 调用
createComponent(vnode)
—— 执行data.hook.init(vnode)
—— 实例化components[key]
,执行child.$mount()
生成真实dom,赋值到虚拟节点的vm.$el
- 将组件标签的
$el
插入到父容器(父组件)中
- 调用
- 渲染完成整个DOM
- 创建真实DOM的过程中,如果遇到组件标签,特殊处理:
在执行vm._render()
创建VNode时,特殊处理组件元素:
// src/vdom/index.js
export function createElement(vm, tag, data = {}, ...children) {
let key = data.key;
// 如果是普通标签
if (isReservedTag(tag)) {
return new Vnode(tag, data, key, children);
} else {
// 否则就是组件
// components[tag]可能函数或对象
let Ctor = vm.$options.components[tag]; // 获取组件的构造函数
return createComponent(vm, tag, data, key, children, Ctor);
}
}
function createComponent(vm, tag, data, key, children, Ctor) {
// Ctor如果是局部组件,则为一个对象;如果是全局组件(Vue.component创建的),则为一个构造函数
// 将局部组件,调用Vue.extend(Ctor)创建一个子类
if (isObject(Ctor)) {
Ctor = vm.$options._base.extend(Ctor);
}
// 【关键】等会创建组件真实DOM时,需要调用此初始化方法
data.hook = {
init(vnode) {
// new Ctor()相当于执行new Vue.extend(),即相当于new Sub;则组件会将自己的配置与{ _isComponent: true }合并
let child = (vnode.componentInstance = new Ctor({ _isComponent: true })); // 实例化组件
// 因为没有传入el属性,需要手动挂载,为了在组件实例上面增加$el方法可用于生成组件的真实渲染节点
child.$mount(); // 组件挂载后会在vm上添加vm.$el 真实dom节点
},
};
// 组件vnode也叫占位符vnode ==> $vnode
return new Vnode(
`vue-component-${Ctor.cid}-${tag}`,
data,
key,
undefined,
undefined,
{
Ctor,
children,
}
);
}
组件元素生成真实DOM:
// src/vdom/patch.js
export function patch(oldVnode, vnode, vm) {
// 如果没有vm.$el,也没有oldVnode,及第一次渲染组件元素
if (!oldVnode) {
// 组件的创建过程是没有el属性的
return createElm(vnode);
} else {
// 生成真实DOM
const el = createElm(vnode);
// 插入dom
parentElm.insertBefore(el, oldElm.nextSibling);
// 删除老的dom
parentElm.removeChild(oldVnode);
return el;
}
}
// 虚拟dom转成真实dom
function createElm(vnode) {
const { tag, data, key, children, text } = vnode;
// 判断虚拟dom 是元素节点、自定义组件 还是文本节点(文本节点tag为undefined)
if (typeof tag === "string") {
// 如果是组件,返回组件渲染的真实dom
if (createComponent(vnode)) {
return vnode.componentInstance.$el;
}
// 否则是元素
// 虚拟dom的el属性指向真实dom,方便后续更新diff算法操作
vnode.el = document.createElement(tag);
// 解析vnode属性
updateProperties(vnode);
// 如果有子节点就递归插入到父节点里面
children.forEach((child) => {
return vnode.el.appendChild(createElm(child)); // 递归创建子节点的真实dom(子节点可能包含组件元素)
});
} else {
// 否则是文本节点
vnode.el = document.createTextNode(text);
}
return vnode.el;
}
// 创建组件的实例,并执行实例的$mount()
function createComponent(vnode) {
// 初始化组件,创建组件实例
let i = vnode.data;
// 相当于执行 vnode.data.hook.init(vnode)
if ((i = i.hook) && (i = i.init)) {
i(vnode);
}
// 如果组件实例化完毕,有componentInstance属性,那证明是组件
if (vnode.componentInstance) {
return true;
}
}
整体流程:
- 根组件的
$mount(el)
patch(el, rootVnode)
createElm(rootVnode)
- 对于rootVnode的children遍历调用
createElm(childVnode)
,将结果append到rootVnode.el(最后会将rootVnode.el渲染到页面) - 在遍历children过程中,当对自定义组件使用
createElm(childVnode)
时,调用vnode.data.hook.init(vnode)
(data.hook
是在渲染成VNode时针对组件元素特殊处理的);创建该子类的一个实例(创建实例的时候会执行子类的_init()
方法,合并options,以及在vm上添加$options
属性),然后手动调用该实例的child.$mount()
(在vm._update()
中,会将当前的vnode添加到vm._vnode属性上,还会将生成的真实dom添加到vm.$el
上) - 将child的template编译成render函数,创建vnode,渲染成真实DOM(此过程,因为调用了
$mount
、mountComponent()
,所以子组件中的数据会收集子组件的渲染watcher) - 渲染完所有children后,将根节点的真实DOM渲染到页面
小结
- 每个组件都是一个vue实例(由上文可知,局部组件在创建成Vnode时也是使用的Vue.extend());
- 根组件是Vue的实例
- 其他组件:
child = new Ctor({ _isComponent: true })
; 其中Ctor = Vue.extend(vm.$options.components[tag])
- 在解析Vnode时如果遇到组件元素,则生成子组件的真实DOM(创建Vue的子类与子类的实例,调用实例
_init()
及$mount
方法,具有与根组件完全一致的数据劫持、响应式、模板编译、计算属性、侦听属性等功能)。
系列文章
- 手写Vue2源码(一)—— 环境搭建
- 手写Vue2源码(二)—— 数据劫持
- 手写Vue2源码(三)—— 模板编译
- 手写Vue2源码(四)—— 初次渲染
- 手写Vue2源码(五)—— 观察者模式
- 手写Vue2源码(六)—— 异步更新及nextTick
- 手写Vue2源码(七)—— 侦听属性
- 手写Vue2源码(八)—— 计算属性
- 手写Vue2源码(九)—— 混入原理与生命周期
- 手写Vue2源码(十)—— 组件原理
- 手写Vue2源码(十一)—— diff算法
- 手写Vue2源码(十二)—— keep-alive
- 手写Vue2源码(十三)—— 全局API
- vue-router原理解析
- vuex原理解析
- vue3原理解析