Vue源码解析-组件化

Vue 源码解析-组件化

Vue 其中一个核心思想是组件化,把页面拆分成多个组件,组件是可复用的 Vue 实例,所以它们与 new Vue 接收相同的选项,例如 datacomputedwatchmethods 以及生命周期钩子等。仅有的例外是像 el 这样根实例特有的选项。

createComponent

1
2
3
4
5
var app = new Vue({
el: '#app',
// 这里的 h 是 createElement 方法
render: h => h(App)
});

createComponent最终会调用 _createElement 方法, 通过判断传入的参数是否是组件,而选择使用 createComponent 方法创建一个 VNode

源码: src/core/vdom/create-component.js

该方法针对组件渲染有三个关键步骤:

构造子类构造函数
mergeOptions 方法将 Vue 构造函数的 options 和用户传入的 options 合并到同一层,到 vm.$options

Vue.extend 的作用就是将一个纯对象转换成一个继承于 Vue 的构造器 Sub 并返回。
并且对 Sub 这个对象做了一些扩展,如扩展 options、全局 API 等;对配置中的 propscomputed 进行初始化工作;最后对 Sub 的构造函数缓存,避免多次 extend 同一个子组件的时候重复执行。

安装组件的钩子函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// install component management hooks onto the placeholder node/ instal
installComponentHooks(data);

function installComponentHooks(data: VNodeData) {
const hooks = data.hook || (data.hook = {});
for (let i = 0; i < hooksToMerge.length; i++) {
const key = hooksToMerge[i];
const existing = hooks[key];
const toMerge = componentVNodeHooks[key];
if (existing !== toMerge && !(existing && existing._merged)) {
hooks[key] = existing ? mergeHook(toMerge, existing) : toMerge;
}
}
}

componentVNodeHooks 的钩子函数合并到 data.hook 中,在 VNode 执行 patch 的过程中执行相关的钩子函数。

实例化 VNode

1
2
3
4
5
6
7
8
9
10
11
12
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;

最后通过 new VNode 实例化一个 VNode 并返回,与普通元素节点不同的是,组件的参数中是没有 children 的,在之后的 patch 过程中有关键性作用。

patch

上一节中 createComponent 创建的组件的 VNode,之后会执行 vm._update, 执行 vm.__patch__ (包含diff算法)把 VNode 转换成真正的 DOM 节点。

patch 的过程会调用 createElm 创建元素节点

1
2
3
if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
return;
}

组件的初始化时不传 el 的,因为自己接管了 vm$mount.

在完成组件的整个 patch 过程后,最后执行 insert(parentElm, vnode.elm, refElm) 完成组件的 DOM 插入,如果组件 patch 过程中又创建了子组件,那么 DOM 的插入顺序是先子后父。

配置合并

new Vue 通常有两种情况,一种是外部代码中主动调用 new Vue(opitons) 去实例化一个 Vue 对象,另一种是在组件过程中内部通过调用 new Vue(opitons) 实例化子组件。

无论哪种场景,都会首先执行 merge options 的逻辑。

外部调用场景

合并前

1
2
3
4
5
6
7
8
9
10
Vue.mixin({
created() {
console.log('parent created');
}
});

let app = new Vue({
el: '#app',
render: h => h(childComp)
});

合并后

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
vm.$options = {
components: {},
created: [
function created() {
console.log('parent created');
}
],
directives: {},
filters: {},
_base: function Vue(options) {
// ...
},
el: '#app',
render: function(h) {
//...
}
};

组件场景
组件的构造函数是通过 Vue.extend 继承自 Vue。其中最关键的逻辑是,将 Vue.options 和组件定义的对象合并到 Sub.options 中。

子组件初始化过程通过 initInternalComponent 方式要比外部初始化 Vue 通过 mergeOptions 的过程要快,合并完的结果保留在 vm.$options 中。

options 就是 new vue 大括号里的内容
使用 mergeOptions 合并配置,在 Vue options 扩展配置,之后执行new Vue()

生命周期

每个 Vue 实例(new Vue、子组件内部调用的 new Vue)被创建都需要经过一系列初始化过程。其中需要设置数据监听、编译模板、挂载实例到 DOM、在数据更新时更新 DOM。在这个过程中会调用相关的钩子方法。

callHook

最终执行生命周期是通过调用 callHook 方法,根据传入的字符串 hook,去拿到 vm.$options[hook] 对应的回调函数数组,然后遍历执行,执行的时候把 vm 作为函数执行的上下文。

在之前的配置合并中,这些钩子函数都被合并到了 vm.$options 中并且是一个数组。

调用时机

beforeCreate & created

1
2
3
4
5
6
7
8
9
10
11
12
Vue.prototype._init = function(options?: Object) {
// ...
initLifecycle(vm);
initEvents(vm);
initRender(vm);
callHook(vm, 'beforeCreate');
initInjections(vm); // resolve injections before data/props
initState(vm);
initProvide(vm); // resolve provide after data/props
callHook(vm, 'created');
// ...
};

beforeCreatecreated 的钩子调用是在 initState 的前后,initState 的作用是初始化 propsdatamethodswatchcomputed 等属性,之后我们会详细分析。那么显然 beforeCreate 的钩子函数中就不能获取到 propsdata 中定义的值,也不能调用 methods 中定义的函数。

这两个方法执行完后,并没有渲染 DOM,此时是不能访问 DOM。 一把来说后端的接口请求,放在这里两个钩子函数中都可以,如果需要访问 propsdata 等数据,就需要在 created 中执行了。

vue-routervuex 都混合了 beforeCreatd 钩子函数。

beforeMount & mounted
这一对方法是以 DOM 挂载前后为分界线。

在执行 vm._render 方法前,执行了 beforeMount, 当在执行完 vm.update 方法后,把 VNode patch 到真是 DOM 后,执行了 mouted 钩子。

mounted 钩子函数的执行顺序也是先子后父。

beforeUpdate & updated
beforeUpdate 的执行时机是在渲染 Watcher 的 before 函数中,组件已经 mounted 之后,才会去调用这个钩子函数。

update 的执行时机是在 flushSchedulerQueue 函数调用的时候。

组件 mount 的过程中,会实例化一个渲染的 Watcher 去监听 vm 上的数据变化重新渲染。

beforeDestroy & destroyed
beforeDestroydestroyed 钩子函数的执行时机在组件销毁的阶段

beforeDestroy 钩子函数的执行时机是在 $destroy 函数执行最开始的地方,接着执行了一系列的销毁动作,包括从 parent$children 中删掉自身,删除 watcher,当前渲染的 VNode 执行销毁钩子函数等,执行完毕后再调用 destroy 钩子函数。

$destroy 的执行过程中,它又会执行 vm.__patch__(vm._vnode, null) 触发它子组件的销毁钩子函数,这样一层层的递归调用,所以 destroy 钩子函数执行顺序是先子后父,和 mounted 过程一样。

activated & deactivated
activateddeactivated 钩子函数是专门为 keep-alive 组件定制的钩子。

总结
created 中可以访问到数据,在 mounted 钩子函数中可以访问到 DOM在destroyed 钩子函数中做定时器销毁

组件注册

Vue.js 中,除了它内置的组件如 keep-alivecomponenttransitiontransition-group 等,其它用户自定义组件在使用前必须注册。

全局注册

可以在任意地方使用,扩展在Vue options

1
2
3
Vue.component('my-component', {
// 选项
});

局部注册

在当前组件内,扩展在 Sub options

1
2
3
4
5
6
7
import HelloWorld from './components/HelloWorld';

export default {
components: {
HelloWorld
}
};

总结

局部注册和全局注册不同的是,只有该类型的组件才可以访问局部注册的子组件,而全局注册是扩展到 Vue.options 下,所以在所有组件创建的过程中,都会从全局的 Vue.options.components 扩展到当前组件的 vm.$options.components 下,这就是全局注册的组件能被任意使用的原因。

异步组件

开发中,为了减少首屏代码体积,往往把一些非首屏的组件设计成异步组件,按需加载。
本质是两次渲染,先渲染成注释节点,在组件加载成功了,在通过 foreceRender 重新渲染。

普通函数异步组件

1
2
3
4
5
6
Vue.component('async-example', function(resolve, reject) {
// 这个特殊的 require 语法告诉 webpack
// 自动将编译后的代码分割成不同的块,
// 这些块将通过 Ajax 请求自动下载。
require(['./my-async-component'], resolve);
});

component 的第二个参数传入的是函数, 普通组件传入的是对象。

⚠️ 在整个异步组件加载过程中是没有数据发生变化的,所以通过执行 $forceUpdate 可以强制组件重新渲染一次。

Promise 异步组件

1
2
3
4
5
Vue.component(
'async-webpack-example',
// 这个 `import` 函数会返回一个 `Promise` 对象。
() => import('./my-async-component')
);

判断是否是 Promise 的方法:typeof res.then === 'function'

高级异步组件
由于异步加载组件需要动态加载 JS,有一定网络延时,而且有加载失败的情况,所以通常我们在开发异步组件相关逻辑的时候需要设计 loading 组件和 error 组件,并在适当的时机渲染它们。

1
2
3
4
5
6
7
8
9
10
11
12
13
const AsyncComponent = () => ({
// 需要加载的组件 (应该是一个 `Promise` 对象)
component: import('./MyComponent.vue'),
// 异步组件加载时使用的组件
loading: LoadingComponent,
// 加载失败时使用的组件
error: ErrorComponent,
// 展示加载时组件的延时时间。默认值是 200 (毫秒)
delay: 200,
// 如果提供了超时时间且组件加载也超时了,
// 则使用加载失败时使用的组件。默认值是:`Infinity`
timeout: 3000
});