1.组件的概念
一句话总结:组件就是一组DOM元素的封装, 这组DOM元素就是组件要渲染的内容,如下面代码
js
// MyComponent 是一个组件,它的值是一个选项对象
const MyComponent = {
name: 'MyComponent',
data() {
return { foo: 1 }
}
}
如果从渲染器中来看,一个组件则是一个特殊类型的虚拟DOM节点,那我们对于文本,字符串都可以用string,Text等类型来描述,那么对于组件来说,我们一样可以用虚拟节点的vnode.type属性来存储组件的选项对象,例如:
js
// 该 vnode 用来描述组件,type 属性存储组件的选项对象
const vnode = {
type: MyComponent
// ...
}
为了让渲染器可以处理组件类型的虚拟节点,我们就可以再patch函数中对组件类型的虚拟节点进行处理,简单的示例:
js
function patch(n1, n2, container, anchor) {
const { type } = n2
if (typeof type === 'string') {
// 作为普通元素处理
} else if (type === Text) {
// 作为文本节点处理
} else if (type === Fragment) {
// 作为片段处理
} else if (typeof type === 'object') {
// vnode.type 的值是选项对象,作为组件来处理
if (!n1) {
// 挂载组件
mountComponent(n2, container, anchor)
} else {
// 更新组件2 patchComponent(n1, n2, anchor)
}
}
我们通过代码就可以很直观的理解到,组件就好像跟文本,元素使一样的,我们把组件当成一个对象来处理,但是对象中又包含文本和元素等节点,我们就用mountComponent
和patchComponent
来进行组件的更新和挂载。因此,一个组件必须包含一个渲染函数,那就是我们说的render函数,并且渲染函数返回值应该是虚拟DOM。换句话说,组件渲染函数就是用来描述组件所渲染内容的接口,如下代码:
js
const MyComponent = {
// 组件名称,可选
name: 'MyComponent',
// 组件的渲染函数,其返回值必须为虚拟 DOM
render() {
// 返回虚拟 DOM
return {
type: 'div',
children: `我是文本内容`
}
}
}
这就是最简单的组件示例,那有了组件的结构后,渲染器就可以完成组件的渲染了,最终其实也是调用的就是mountComponent
函数来进行渲染
js
const CompVNode = {
type: MyComponent
}
// 调用渲染器来渲染组件
renderer.render(CompVNode, document.querySelector('#app'))
function mountComponent(vnode, container, anchor) {
// 通过 vnode 获取组件的选项对象,即 vnode.type
const componentOptions = vnode.type
// 获取组件的渲染函数 render
const { render } = componentOptions
// 执行渲染函数,获取组件要渲染的内容,即 render 函数返回的虚拟 DOM
const subTree = render() // 最后调用 patch 函数来挂载组件所描述的内容,即 subTree
patch(null, subTree, container, anchor)
}
2.组件的状态和自更新
那我们有了组件,那每个组件里面的data,包括render函数中渲染的变量,都是有状态的,当我们data实现了变化的时候,那我们组件也应该对应的更新,那我们就想,怎么样,才能让组件实现更新,其实很简单,我们只要将渲染任务包装到effect中,实现响应收集就好了,思路如下:
我们以简单的组件为例子:
js
const MyComponent = {
name: 'MyComponent',
// 用 data 函数来定义组件自身的状态
data() {
return {
foo: 'hello world'
}
},
render() {
return {
type: 'div',
children: `foo 的值是: ${this.foo}` // 在渲染函数内使用组件状态
}
}
}
那我们就是要再mountComponent
函数中,实现以来收集
js
function mountComponent(vnode, container, anchor) {
const componentOptions = vnode.type
const { render, data } = componentOptions
const state = reactive(data())
// 将组件的 render 函数调用包装到 effect 内
effect(() => {
const subTree = render.call(state, state)
patch(null, subTree, container, anchor)
})
}
这样,一旦组件的数据进行了变化,那组件就会完成更新,那这样就没有问题了嘛?我们知道effect的执行是同步的,当我们数据多次变化的时候,难道我们要多次执行渲染更新吗,这样是不是会造成性能浪费?那我们就想到,需要设计一个机制,就是响应式数据不管变化多少次,我们的副作用函数就执行一次,这就是我们常说的Vue的异步更新机制, 其实实现思路很简单,我们需要实现我们之前说的实现一个调度器,我们将它缓冲到一个微任务队列中,等到 缓冲到一个微任务队列中,等到执行栈清空后,再将它从微任务队列中取出并执行。有了缓存机制,我们就有机会对任务进行去重,从而避免多次执行副作用函数带来的性能开销。具体实现如下:执行栈清空后,再将它从微任务队列中取出并执行。有了缓存机制,我们就有机会对任务进行去重,从而避免多次执行副作用函数带来的性能开销。具体实现如下:
js
// 任务缓存队列,用一个 Set 数据结构来表示,这样就可以自动对任务进行去重
const queue = new Set()
// 一个标志,代表是否正在刷新任务队列
let isFlushing = false
// 创建一个立即 resolve 的 Promise 实例
const p = Promise.resolve()
// 调度器的主要函数,用来将一个任务添加到缓冲队列中,并开始刷新队列
function queueJob(job) {
// 将 job 添加到任务队列 queue 中
queue.add(job)
// 如果还没有开始刷新队列,则刷新之
if (!isFlushing) {
// 将该标志设置为 true 以避免重复刷新
isFlushing = true
// 在微任务中刷新缓冲队列
p.then(() => {
try {
// 执行任务队列中的任务
queue.forEach(job => job())
} finally {
// 重置状态
isFlushing = false
queue.clear = 0
}
})
}
}
本质上就是利用了微任务的异步执行机制, 那我们改写一下mountComponent
函数的实现:
js
function mountComponent(vnode, container, anchor) {
---------
effect(() => {
const subTree = render.call(state, state)
patch(null, subTree, container, anchor)
}, {
// 指定该副作用函数的调度器为 queueJob 即可
scheduler: queueJob
})
}
3.组件的实例和生命周期
组件的实例本质上就是一个对象,它维护着组件运行过程中的所有信息,例如组件的生命周期
,组件的渲染子树
,组件是否被挂载
,组件自身的状态(data)
,那我们就需要一个对象来描述上述的信息,简单示例如下:
js
function mountComponent(vnode, container, anchor)
// 定义组件实例,一个组件实例本质上就是一个对象,它包含与组件有关的状态
const instance = {
// 组件自身的状态数据,即 data
state,
// 一个布尔值,用来表示组件是否已经被挂载,初始值为 false
isMounted: false,
// 组件所渲染的内容,即子树(subTree)
subTree:null
}
vnode.component = instance
}
有了组件的实例,我们就知道组件状态,是否被挂载等信息,那我们就可以根据状态去判断组件是挂载还是更新,如下示例:
js
function mountComponent(vnode, container, anchor)
----- ----
effect(() => {
const subTree = render.call(state, state)
if (!instance.isMounted) { //判断是否挂在,没有挂载则挂载
patch(null, subTree, container, anchor)
instance.isMounted = true
} else { //要不然就更新子树
patch(instance.subTree, subTree, container, anchor)
}
instance.subTree = subTree
}, { scheduler: queueJob })
}
那我们知道了组件的挂载和更新逻辑,那对于组件的生命周期钩子实现不就是很简单了嘛,我们只需要再对应的时候,加上回调函数,就实现了生命周期的钩子,完整代码:
生命周期钩子的实现
js
function mountComponent(vnode, container, anchor) {
const componentOptions = vnode.type
const { render, data, beforeCreate, created, beforeMount,
mounted, beforeUpdate, updated } = componentOptions
// 在这里调用 beforeCreate 钩子
beforeCreate && beforeCreate()
const state = reactive(data())
const instance = {
state,
isMounted: false,
subTree: null
}
vnode.component = instance
// 在这里调用 created 钩子
created && created.call(state)
effect(() => {
const subTree = render.call(state, state)
if (!instance.isMounted) {
// 在这里调用 beforeMount 钩子
beforeMount && beforeMount.call(state)
patch(null, subTree, container, anchor)
instance.isMounted = true
// 在这里调用 mounted 钩子
mounted && mounted.call(state)
} else {
// 在这里调用 beforeUpdate 钩子
beforeUpdate && beforeUpdate.call(state)
patch(instance.subTree, subTree, container, anchor)
// 在这里调用 updated 钩子
updated && updated.call(state)
}
instance.subTree = subTree
},
{ scheduler: queueJob })
}
总结:
我们首先从组件的选项对象中取得注册到组件上的生命周期函数,然后在合适的时机调用它们,这其实就是组件生命周期的实现原理。但实际上,由于可能存在多个同样的组件生命周期钩子,例如来自 mixins 中的生命周期钩子函数,因此我们通常需要将组件生命周期钩子序列化为一个数组,但核心原理不变。