破酥 | C4iN
实现一个简单的MVVM-3

实现一个简单的MVVM-3

这部分记录一下组件的实现原理。

组件的实现原理

我们可以将一个大的页面拆分为多个部分,每一个部分都可以作为单独的组件,这些组件共同组成完整的页面。组件化的实现同样需要渲染器的支持。

一个有状态组件就是一个选项对象:

1
2
3
4
5
6
7
8
const component = {
name: "componenet",
data() {
return {
foo: 1
}
}
}

如果从渲染器的内部实现来看,一个组件则是一个特殊类型的虚拟 DOM 节点。

1
2
3
4
const vnode = {
type: component
...
}

我们之前在patch函数中留下过一个接口,现在我们来完善它:

1
2
3
4
5
6
7
8
9
10
11
...
else if (typeof type === object) {
if (!oldNode) {
// 挂载组件
mountComponent(newNode, container, anchor)
} else {
// 更新组件
patchComponent(oldNode, newNode, anchor)
}
}
...

渲染器有能力处理组件后,下一步我们要做的是,设计组件在用户层面的接口。组件本身是对页面内容的封装,它用来描述页面内容的一部分,一个组件必须包含一个渲染函数,即render函数,并且渲染函数的返回值应该是虚拟 DOM。换句话说,组件的渲染函数就是用来描述组件所渲染内容的接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const component = {
name: "componenet",
render() {
// 返回vdom
return {
type: 'div',
children: 'text'
}
}
}

const compVNode = {
type: component
}

renderer.render(CompVNode, document.querySelector('#app'))

接下来我们来实现mountComponent函数,实现组件的初始渲染。

1
2
3
4
5
6
7
8
9
10
11
12
function mountComponent(newCompVNode, container, anchor) {
// 获取组件
const componentOptions = newCompVNode.type as componentOptions
// 获取组件渲染函数
const { render } = componentOptions
// 执行渲染函数,获取组件要渲染的内容,即 render 函数返回的虚拟ODM
const subTree = render()

// 挂载组件subTree
patch(undefined, subTree, container, anchor)

}

组件状态与自更新

我们约定用户必须使用data函数来定义组件自身的状态,同时可以在渲染函数中通过this访问由data函数返回的状态数据。当组件自身状态发生变化时,我们需要有能力触发组件更新,即组件的自更新。为此,我们需要将整个渲染任务包装到一个watchEffect中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function mountComponent(newCompVNode, container, anchor) {
// 获取组件
const componentOptions = newCompVNode.type
// 获取组件渲染函数
const { render, data } = componentOptions
// 调用data函数获取原始数据,并包装为响应式
const state = reactive(data())
watchEffect(() => {
// 执行渲染函数,获取组件要渲染的内容,即 render 函数返回的虚拟ODM
// 调用时将this设置为state,从而render函数内部可以使用this访问组件自身状态数据
const subTree = render.call(state)
// 挂载组件subTree
patch(undefined, subTree, container, anchor)

})

}

一旦组件自身的响应式数据发生变化,组件就会自动重新执行渲染函数,从而完成更新。但是,由于watchEffect的执行是同步的,因此当响应式数据发生变化时,与之关联的副作用函数会同步执行。换句话说,如果多次修改响应式数据的值,将会导致渲染函数执行多次,这实际上是没有必要的。

因此,我们需要设计一个机制,以使得无论对响应式数据进行多少次修改,副作用函数都只会重新执行一次。为此,我们需要实现一个调度器,当副作用函数需要重新执行时,我们不会立即执行它,而是将它缓冲到一个微任务队列中,等到执行栈清空后,再将它从微任务队列中取出并执行,我们就有机会对任务进行去重,从而避免多次执行副作用函数带来的性能开销。下面是简单实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 任务缓存序列,使用Set自动去重
const queue = new Set<EffectFunction>()
// 是否正在刷新队列
let isFlushing = false
// 立即resolve的Promise
const p = Promise.resolve()

/**
* 任务调度函数
* */
function queueJob(job: EffectFunction) {
// 将任务存入任务队列
queue.add(job)
// 如果还没开始刷新,则进行刷新
if (!isFlushing) {
// 设置标识为true避免重复刷新
isFlushing = true
// 在微任务中刷新缓冲队列
p.then(() => {
try {
// 执行任务
queue.forEach((job: EffectFunction) => job())
} finally {
// 重置状态
isFlushing = false
queue.clear()
}
})
}
}

有了queueJob函数之后,我们可以在创建渲染副作用时使用它:

1
2
3
4
5
6
watchEffect(() => {
const subTree = render.call(state, state)
patch(undefined, subTree, container, anchor)
}, {
scheduler: queueJob
})

上面这段代码存在缺陷。可以看到,我们在watchEffect函数内调用 patch函数完成渲染时,第一个参数总是null。这意味着,每次更新发生时都会进行全新的挂载,而不会打补丁,这是不正确的。正确的做法是:每次更新时,都拿新的subTree与上一次组件所渲染的subTree进行打补丁。为此,我们需要实现组件实例,用它来维护组件整个生命周期的状态,这样渲染器才能够在正确的时机执行合适的操作。

组件实例与组件的生命周期

组件实例本质上就是一个状态集合(或一个对象),它维护着组件运行过程中的所有信息,例如注册到组件的生命周期函数、组件渲染的子树(subTree)、组件是否已经被挂载、组件自身的状态(data),等等。为了解决上一节中关于组件更新的问题,我们需要引入组件实例的概念:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export interface ComponentInstance {
/**
* 状态数据
* */
state: any
/**
* 是否挂载
* */
isMounted: boolean
/**
* 渲染内容,存储组件的渲染函数返回的虚拟 DOM,即组件的子树
* */
subTree: VNode | null
}

然后修改mountComponent

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
function mountComponent(CompVNode: VNode, container: Container, anchor?: Node | null) {
...
// 组件实例
const instance: ComponentInstance = {
// 状态数据data
state,
// 是否挂载
isMounted: false,
// 渲染内容subTree
subTree: null
}

// 将组件实例设置到vnode上,用于后续更新
newCompVNode.component = instance

watchEffect(() => {
// 执行渲染函数,获取组件要渲染的内容,即 render 函数返回的虚拟ODM
// 调用时将this设置为state,从而render函数内部可以使用this访问组件自身状态数据
const subTree = render.call(state, state)

if (!instance.isMounted) {
// 初次挂载组件subTree
patch(undefined, subTree, container, anchor)
// 将组件实例的 isMounted 设置为 true,这样当更新发生时就不会再次进行挂载操作
instance.isMounted = true
} else {
// 已被挂载,更新
patch(instance.subTree as VNode, subTree, container, anchor)
instance.subTree = subTree
}

}, {
scheduler: queueJob
})

}

我们可以在需要的时候,任意地在组件实例instance上添加需要的属性。但需要注意的是,我们应该尽可能保持组件实例轻量,以减少内存占用。组件实例的instance.isMounted属性可以用来区分组件的挂载和更新,我们可以在合适的时机调用组件对应的生命周期钩子。

props与组件的被动更新

在虚拟 DOM 层面,组件的 props 与普通 HTML 标签的属性差别不大:

1
2
3
4
5
6
7
8
// <MyComponent title="A Big Title" :other="val" />
const vnode = {
type: component,
props: {
title: "A Big Title",
other: "val",
}
}

在编写组件时,我们需要显式地指定组件会接收哪些 props 数据:

1
2
3
4
5
6
7
8
9
10
11
const component = {
name: "componenet",
// props
props: {
title: String
},
render() {
type: 'div',
children: `count is: ${this.title}`
}
}

对于一个组件来说,有两部分关于 props 的内容我们需要关心:

  • 为组件传递的 props 数据,即组件的vnode.props对象;
  • 组件选项对象中定义的 props 选项,即MyComponent.props对象。

我们结合这两个选项来解析需要的props

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
function mountComponent(compVNode, container, anchor) {
// 获取组件
const componentOptions = compVNode.type as componentOptions
// 获取组件渲染函数与props定义
const { render, data, props: propsOption,beforeCreate, created, beforeMount, mounted, beforeUpdate, updated } = componentOptions

...

// 获取props, attrs
const [props, attrs] = resolveProps(propsOption, compVNode.props)

// 组件实例
const instance: ComponentInstance = {
// 状态数据data
state,
// 将解析出的 props 数据包装为 shallowReactive 并定义到组件实例上
props: shallowReactive(props),
// 是否挂载
isMounted: false,
// 渲染内容subTree
subTree: null
}

...

}

function resolveProps(options: any, propsData: object) {
const props = {}
const attrs = {}

// 遍历为组件传递的 props 数据
for (const key in propsData) {
if (key in options) {
// 如果为组件传递的 props 数据在组件自身的 props 选项中有定义,props合法
props[key] = propsData[key]

} else {
// 否则将其作为 attrs
attrs[key] = propsData[key]
}
}

// 返回 props 和 attrs 数据
return { props, attrs }
}

这里需要注意两点:

  • 在 Vue.js 3 中,没有定义在MyComponent.props选项中的 props 数据将存储到attrs对象中。
  • 上述实现中没有包含默认值、类型校验等内容的处理。

处理完 props 数据后,我们再来讨论关于props数据变化的问题。props本质上是父组件的数据,当props发生变化时,会触发父组件重新渲染。在更新过程中,渲染器发现父组件的subTree包含组件类型的虚拟节点,所以会调用patchComponent函数完成子组件的更新。我们把由父组件自更新所引起的子组件更新叫作子组件的被动更新。当子组件发生被动更新时,我们需要做的是:

  • 检测子组件是否真的需要更新,因为子组件的 props 可能是不变的
  • 如果需要更新,则更新子组件的 props、slots 等内容

实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
/**
* 组件更新函数
* */
export function patchComponent(oldVNode, newVNode, anchor) {
// 获取组件实例,同时也然后给新组件虚拟节点指向组件实例
const instance = ((newVNode as VNode).component = (oldVNode as VNode).component)
// 获取当前props
if (instance) {
const { props } = instance
if (hasPropsChanged(oldVNode, newVNode)) {
// 调用resolveProps重新获取props数据
const [ newProps ] = resolveProps((newVNode.type as componentOptions).props, newVNode.props)
// 更新props
for (const k in newProps) {
props[k] = newProps[k]
}
// 删除不存在的props
for (const k in props) {
if (!(k in newProps)) {
delete props[k]
}
}
}
} else {
console.error("instance does not exist.", oldVNode, newVNode)
}


}

/**
* props变更处理函数
* */
function hasPropsChanged(oldProps, newProps) {
// 获取新旧props的键值
const newKeys = Object.keys(newProps)
const oldKeys = Object.keys(oldProps)
// props数量变化
if (newKeys.length !== oldKeys.length) {
return true
}
// props数量未变,判断内容是否变化
for (let i = 0 ; i < newKeys.length; i++) {
const key = newKeys[i]
// 存在不同的props
if (newProps[key] !== oldProps[key]) {
return true
}
}
return false
}

需要注意的是,我们要将组件实例添加到新的组件vnode对象上,即n2.component = n1.component,否则下次更新时将无法取得组件实例;instance.props对象本身是浅响应的,因此,在更新组件的props时,只需要设置instance.props对象下的属性值即可触发组件重新渲染。

由于props数据与组件自身的状态数据都需要暴露到渲染函数中,并使得渲染函数能够通过this访问它们,因此我们需要封装一个渲染上下文对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 创建渲染上下文对象,本质是组件实例的代理
const renderContext = new Proxy(instance, {
get(target, key, receiver) {
const { state, props } = target

// 先尝试读取自身状态数据
if (state && key in state) {
return state[key]
} else if (key in props) {
return props[key]
} else {
console.error('state does not exist')
}
},
set (target, key, value, receiver) {
const { state, props } = target
if (state && key in state) {
state[key] = value
return true
} else if (key in props) {
console.warn(`Attempting to mutate prop "${String(key)}". Props are readonly.`)
return false
} else {
console.error('state does not exist')
return false
}
}
})

setup函数的作用与实现

组件的setup函数是 Vue3 新增的组件选项,它有别于 Vue2 中存在的其他组件选项。这是因为setup函数主要用于配合组合式API,为用户提供一个地方,用于建立组合逻辑、创建响应式数据、创建通用函数、注册生命周期钩子等能力。在组件的整个生命周期中,setup函数只会在被挂载时执行一次,它的返回值可以有两种情况。

  • 返回一个函数,该函数将作为组件的render函数

    这种方式常用于组件不是以模板来表达其渲染内容的情况。如果组件以模板来表达其渲染的内容,那么 setup 函数不可以再返回函数,否则会与模板编译生成的渲染函数产生冲突。

  • 返回一个对象,该对象中包含的数据将暴露给模板使用

    可以看到,setup函数暴露的数据可以在渲染函数中通过this来访问。

setup函数接收两个参数。第一个参数是props数据对象,第二个参数也是一个对象,通常称为setupContext,我们可以通过setup函数的第一个参数取得外部为组件传递的props数据对象。同时,setup函数还接收第二个参数setupContext对象,其中保存着与组件接口相关的数据和方法:

  • slot:插槽
  • emit:一个函数,用来发射自定义事件
  • attrs:当为组件传递props时,那些没有显式地声明为 props 的属性会存储到attrs对象中
  • expose:一个函数,用来显式地对外暴露组件数据(API设计讨论中)

通常情况下,不建议将 setup 与 Vue.js 2 中其他组件选项混合使用。例如 data、watch、methods 等选项,我们称之为 “传统”组件选项。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
export function mountComponent(compVNode: VNode, container: Container, anchor?: Node | null) {
// 获取组件
const componentOptions = compVNode.type as componentOptions
// 获取组件渲染函数与props定义
let { render, data, setup, props: propsOption, beforeCreate, created, beforeMount, mounted, beforeUpdate, updated } = componentOptions

...

// setupContext
const setupContext = { attrs }
// setupState用于存储由setup返回的数据
let setupState: any | null = null

// 处理setup相关
if (setup) {
// 调用setup函数
// 将只读版本的 props 作为第一个参数传递,避免用户意外地修改 props 的值
// 将setupContext作为第二个参数
const setupResult = setup(shallowReadonly(instance.props), setupContext)
// setup返回函数,将其作为渲染函数
if (typeof setupResult === 'function') {
if (render) {
// 如果存在渲染函数,则报告冲突错误
console.error('render conflicts')
// 将setupResult作为渲染函数
render = setupResult
} else {
// 否则作为数据状态赋值给setupState
setupState = setupResult
}
}
}

// 将组件实例设置到vnode上,用于后续更新
compVNode.component = instance

// 创建渲染上下文对象,本质是组件实例的代理
const renderContext = new Proxy(instance, {
get(target, key, receiver) {
const { state, props } = target

// 先尝试读取自身状态数据
if (state && key in state) {
return state[key]
} else if (key in props) {
return props[key]
} else {
console.error('state does not exist')
}
},
set (target, key, value, receiver) {
const { state, props } = target
if (state && key in state) {
state[key] = value
return true
} else if (key in props) {
console.warn(`Attempting to mutate prop "${String(key)}". Props are readonly.`)
return false
} else if (setupState && key in setupState) {
// setupState处理
setupState[key] = value
return true
} else {
console.error('state does not exist')
return false
}
}
})

...

}

组件事件与emit的实现

emit用来发射组件的自定义事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const component = {
name: 'component',
setup(props, { emit }) {
emit('change', 1, 2)
}
}

// <MyComponent @change="handler" />
const componentVNode = {
type: component,
props: {
onChange: handler
}
}

在具体的实现上,发射自定义事件的本质就是根据事件名称去props数据对象中寻找对应的事件处理函数并执行。整体实现并不复杂,只需要实现一个emit函数并将其添加到setupContext对象中,这样用户就可以通过setupContext取得emit函数了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* emit事件传递函数
* @param event - 事件名称
* @param payload - 传递给事件处理函数的参数
* */
function emit(event, ...payload) {
const eventName =`on${event[0].toUpperCase() + event.slice(1)}`
// 根据处理后的事件名去寻找对用的事件处理函数
const handler = instance.props[eventName]
if (handler) {
handler(...payload)
} else {
console.error(`[${eventName}] ${eventName} does not exist`)
}
}


// setupContext
const setupContext = { attrs, emit }

这里有一点需要额外注意,我们在讲解props时提到,任何没有显式地声明为 props 的属性都会存储到attrs中。换句话说,任何事件类型的props,即onXxx类的属性,都不会出现在props中。这导致我们无法根据事件名称在instance.props中找到对应的事件处理函数。为了解决这个问题,我们需要在解析props数据的时候对事件类型的props做特殊处理,如下面的代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
export function resolveProps(options:  Record<string | symbol, any>, propsData: object) {
const props = {}
const attrs = {}

// 遍历为组件传递的 props 数据
for (const key in propsData) {
if (key in options || key.startsWith("on")) {
// 如果为组件传递的 props 数据在组件自身的 props 选项中有定义,props合法
// 以字符串 on 开头的 props,无论是否显式地声明,都将其添加到 props数据中,而不是添加到 attrs 中
props[key] = propsData[key]

} else {
// 否则将其作为 attrs
attrs[key] = propsData[key]
}
}

// 返回 props 和 attrs 数据
return [ props, attrs ]
}

插槽的工作原理与实现

组件的插槽指组件会预留一个槽位,该槽位具体要渲染的内容由用户插入。以下是component组件的模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
<template>
<header>
<slot name="header"/>
</header>

<div>
<slot name="body"/>
</div>

<footer>
<slot name="footer"/>
</footer>
</template>

当在父组件中使用 <component> 组件时,可以根据插槽的名字来插入自定义的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<component>
<template #header>
<h1>
title
</h1>
</template>
<template #header>
<p>
context
</p>
</template>
<template #header>
<p>
footer
</p>
</template>
</component>

上面这段父组件模板会被编译成如下渲染函数:

1
2
3
4
5
6
7
8
9
10
11
function render() {
return {
type: component,
children: {
header() {
return { type: 'h1', children: 'title'}
}
... 下同
}
}
}

组件模板中的插槽内容会被编译为插槽函数,而插槽函数的返回值就是具体的插槽内容。组件component的模板则会被编译为如下渲染函数:

1
2
3
4
5
6
7
8
9
function render() {
return [
{
type: 'header',
children: [this.$slots.header()]
},
... 下同
]
}

可以看到,渲染插槽内容的过程,就是调用插槽函数并渲染由其返回的内容的过程。在运行时的实现上,插槽则依赖于setupContext中的slots对象:

1
2
3
4
5
// 使用编译好的compVNode.children作为slot对象
const slots = compVNode.children || {}

// setupContext
const setupContext = { attrs, emit, slots }

为了在 render 函数内和生命周期钩子函数内能够通过this.$slots来访问插槽内容,我们还需要在renderContext中特殊对待$slots属性:

1
2
3
// 添加这一行
// 如果key值为$slots,直接返回对应插槽
if (key === "$slots") return slots

注册生命周期

在 Vue3 中,有一部分组合式 API 是用来注册生命周期钩子函数的,例如onMountedonUpdated。对于多个钩子函数,我们需要维护一个变量currentInstance,用它来存储当前组件实例,每当初始化组件并执行组件的setup函数之前,先将currentInstance设置为当前组件实例,再执行组件的setup函数,这样我们就可以通过currentInstance来获取当前正在被初始化的组件实例,从而将那些通过onMounted函数注册的钩子函数与组件实例进行关联。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 存储当前正在被初始化的组件实例
let currentInstance: ComponentInstance | null = null

// 在调用setup函数前设置当前组件实例
setCurrentInstance(instance)
// 处理setup相关
if (setup) {
// 调用setup函数
// 将只读版本的 props 作为第一个参数传递,避免用户意外地修改 props 的值
// 将setupContext作为第二个参数
const setupResult = setup(shallowReadonly(instance.props), setupContext)
// setup返回函数,将其作为渲染函数
if (typeof setupResult === 'function') {
if (render) {
// 如果存在渲染函数,则报告冲突错误
console.error('render conflicts')
// 将setupResult作为渲染函数
render = setupResult
} else {
// 否则作为数据状态赋值给setupState
setupState = setupResult
}
}
// 执行完成后重置currentInstance
setCurrentInstance(null)
}

function setCurrentInstance(instance: ComponentInstance | null) {
currentInstance = instance
}

最后就是onMounted函数的实现:

1
2
3
4
5
6
7
8
export function onMounted(fn: EffectFunction) {
if (currentInstance) {
// 将生命周期函数添加到 instance.mounted 中
currentInstance.mounted.push(fn)
} else {
console.log("onMounted can only be used in setup.")
}
}

然后在对应生命周期逐个执行mounted内的函数即可。其他生命周期钩子函数类似。

异步组件与函数式组件

异步组件

通常在异步加载组件时,我们还要考虑以下几个方面:

  • 如果组件加载失败或加载超时,是否要渲染 Error 组件?
  • 组件在加载时,是否要展示占位的内容?例如渲染一个 Loading 组件。
  • 组件加载的速度可能很快,也可能很慢,是否要设置一个延迟展示 Loading 组件的时间?
    • 如果组件在 200ms 内没有加载成功才展示 Loading 组件,这样可以避免由组件加载过快所导致的闪烁。
  • 组件加载失败后,是否需要重试?

我们需要在框架层面为异步组件提供更好的封装支持,与之对应的能力如下:

  • 允许用户指定加载出错时要渲染的组件。
  • 允许用户指定 Loading 组件,以及展示该组件的延迟时间。
  • 允许用户设置加载组件的超时时长。
  • 组件加载失败时,为用户提供重试的能力。

异步组件的实现原理

defineAsyncComponent函数

异步组件本质上是通过封装手段来实现友好的用户接口,从而降低用户层面的使用复杂度:

1
2
3
4
5
6
7
8
9
10
11
12
13
<template>
<AsyncComp />
</template>

<script>
export default {
components: {
// 使用 defineAsyncComponent 定义一个异步组件,接收一个加载器作为参数
AsyncComp: defineAsyncComponent(() => import('CompA'))
}
}

</script>

在上面这段代码中,我们使用defineAsyncComponent来定义异步组件,并直接使用components组件选项来注册它。defineAsyncComponent的具体实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function defineAsyncComponent(loader: Function) {
// 存储异步加载组件
let innerComp: any = null
// 返回一个包装组件
return {
name: 'AsyncComponentWrapper',
setup() {
// 异步组件是否加载成功
const loaded = ref(false)
// 执行加载器函数,返回一个Promise
// 加载成功后将组件赋给innerComp,并将loaded标记为true
loader().then((c:any) => {
innerComp = c
loaded.value = true
})

return () => {
// 异步组件加载成功则渲染该组件,否则渲染一个占位内容
return loaded.value ? { type: innerComp } : { type: Text, children: ''}
}
}
}
}

通常占位内容是一个注释节点。组件没有被加载成功时,页面中会渲染一个注释节点来占位。但这里我们使用了一个空文本节点来占位。

超时与Error组件

既然存在网络请求,加载一个组件可能需要很长时间。因此,我们需要为用户提供指定超时时长的能力,当加载组件的时间超过了指定时长后,会触发超时错误。这时如果用户配置了 Error 组件,则会渲染该组件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
const AsyncComp = defineAsyncComponent({
loader: () => import('CompA.vue'),
timeout: 2000, // 超时市场,单位ms
errorComponent: MyErrorComp // 出错时渲染组件
})

// 实现
function defineAsyncComponent(options: AsyncComponentOptions) {
const { loader } = options

// 存储异步加载组件
let innerComp: any = null
// 返回一个包装组件
return {
name: 'AsyncComponentWrapper',
setup() {
// 异步组件是否加载成功
const loaded = ref(false)
// 是否超时
const timeout = ref(false)
if (loader) {
// 执行加载器函数,返回一个Promise
// 加载成功后将组件赋给innerComp,并将loaded标记为true
loader().then((c:any) => {
innerComp = c
loaded.value = true
})
}

let timer: any = null
// 如果指定超时时长,设置计时器
if (options.timeout) {
timer = setTimeout(() => {
// 超时后将 timeout 设置为true
timeout.value = true
}, options.timeout)
}
// 包装组件被卸载时清除计时器
onUnmounted(() => clearTimeout(timer))

// 占位内容
const placeholder = { type: Text, children: ''}
return () => {
// 异步组件加载成功则渲染该组件,否则渲染一个占位内容
if (loaded.value) {
return { type: innerComp }
} else if (timeout.value) {
return options.errorComponent ? { type: options.errorComponent } : placeholder
}
return placeholder
}
}
}
}

我们希望有更加完善的机制来处理异步组件加载过程中发生的错误,当错误发生时,把错误对象作为Error组件的props传递过去, 以便用户后续能自行进行更细粒度的处理。除了超时之外,有能力处理其他原因导致的加载错误,例如网络失败等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
function defineAsyncComponent(options: AsyncComponentOptions) {
const { loader } = options

// 存储异步加载组件
let innerComp: any = null
// 返回一个包装组件
return {
name: 'AsyncComponentWrapper',
setup() {
// 异步组件是否加载成功
const loaded = ref(false)
// 是否超时
const timeout = ref(false)
// 定义error存储错误对象
const error = ref(null)
if (loader) {
// 执行加载器函数,返回一个Promise
// 加载成功后将组件赋给innerComp,并将loaded标记为true
loader().then((c:any) => {
innerComp = c
loaded.value = true
})
.catch((err: any) => error.value = err)
}

let timer: any = null
// 如果指定超时时长,设置计时器
if (options.timeout) {
timer = setTimeout(() => {
// 超时后将 timeout 设置为true,并创建一个错误对象
const err = new Error(`Async component timed out after ${options.timeout}ms`)
error.value = err
}, options.timeout)
}
// 包装组件被卸载时清除计时器
onUnmounted(() => clearTimeout(timer))

// 占位内容
const placeholder = { type: Text, children: ''}
return () => {
// 异步组件加载成功则渲染该组件,否则渲染一个占位内容
if (loaded.value) {
return { type: innerComp }
} else if (error.value && options.errorComponent) {
// 只有当错误存在且用户配置了 errorComponent 时才展示 Error组件,同时将 error 作为 props 传递
return { type: options.errorComponent, props: { error: error.value } }
}
return placeholder
}
}
}
}

延迟与 Loading 组件

我们经常会从加载开始的那一刻起就展示 Loading 组件。但在网络状况良好的情况下,异步组件的加载速度会非常快,这会导致Loading组件刚完成渲染就立即进入卸载阶段,于是出现闪烁的情况。对于用户来说这是非常不好的体验。因此,我们需要为Loading组件设置一个延迟展示的时间。

  • delay,用于指定延迟展示Loading组件的时长。
  • loadingComponent,类似于errorComponent选项,用于配置Loading组件。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
export function defineAsyncComponent(options: AsyncComponentOptions) {
const { loader } = options

// 存储异步加载组件
let innerComp: any = null
// 返回一个包装组件
return {
name: 'AsyncComponentWrapper',
setup() {
// 异步组件是否加载成功
const loaded = ref(false)
// 定义error存储错误对象
const e = new Error()
const err = ref(false)
const error = ref(e)
// 加载标识
const loading = ref(false)

let loadingTimer: any = null
// 存在delay则开启计时器
if (options.delay) {
loadingTimer = setTimeout(() => {
loading.value = true
}, options.delay)
} else {
// 没有则直接标记为加载中
loading.value = true
}

if (loader) {
// 执行加载器函数,返回一个Promise
// 加载成功后将组件赋给innerComp,并将loaded标记为true
loader().then((c:any) => {
innerComp = c
loaded.value = true
})
.catch((e: any) => {
error.value = e
err.value = true
})
.finally(() => {
// 加载完毕后无论成功与否都要清除延迟定时器
loaded.value = false
// 加载完毕后无论成功与否都要清除延迟定时器
clearTimeout(loadingTimer)
})
}

...

// 占位内容
const placeholder = { type: Text, children: ''}
return () => {
// 异步组件加载成功则渲染该组件,否则渲染一个占位内容
if (loaded.value) {
return { type: innerComp }
} else if (error.value && err.value && options.errorComponent) {
return { type: options.errorComponent, props: { error: error.value } }
} else if (loading.value && options.loadComponent) {
// 渲染加载组件
return { type: options.loadComponent }
}
return placeholder
}
}
}
}

当异步组件加载成功后,会卸载加载组件并渲染异步加载组件,这里我们需要修改unmounted函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
function unmount(vnode) {
// 如果为Fragment,则卸载所有子节点
if (vnode.type === "Fragment") {
...
} else if (typeof vnode.type === 'object') {
// 对于组件的卸载,本质上是要卸载组件所渲染的内容,即 subTree
unmount(vnode.component.subTree)
return
}

const parent = vnode.el.parentNode
if (parent) parent.removeChild(vnode.el)
}

重试机制

重试指的是当加载出错时,有能力重新发起加载组件的请求。在加载组件的过程中,发生错误的情况非常常见,尤其是在网络不稳定的情况下。异步组件加载失败后的重试机制,与请求服务端接口失败后的重试机制一样。

我们使用fetch函数发送HTTP请求,并封装一个load函数实现失败后的重试。load函数内部调用了fetch函数来发送请求,并得到一个Promise实例。接着,添加catch语句块来捕获该实例的错误。当捕获到错误时,我们有两种选择:要么抛出错误,要么返回一个新的Promise实例,并把该实例的resolvereject方法暴露给用户,让用户来决定下一步应该怎么做。这里,我们将新的Promise实例的resolvereject分别封装为retry函数和fail函数,并将它们作为onError回调函数的参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
export function defineAsyncComponent(options: AsyncComponentOptions) {
const { loader } = options

// 存储异步加载组件
let innerComp: any = null

// 记录重试次数
let retries = 0
// 封装load函数
function load() {
if (loader) {
return loader()
.catch((err: Error) => {
// 如果指定了onError回调,将控制权交给用户
if (options.onError) {
// 返回一个新的Promise实例
return new Promise((resolve, reject) => {
// 重试
const retry = () => {
resolve(load())
retries++
}

// 失败
const fail = () => { reject(err) }
// 作为 onError 函数回调参数
options.onError && options.onError(retry, fail, retries)
})
} else {
throw err
}
})
} else {
console.error("loader does not exist.", options)
}
}

// 返回一个包装组件
return {
name: 'AsyncComponentWrapper',
setup() {
...

if (loader) {
// 执行加载器函数,返回一个Promise
// 调用load,加载成功后将组件赋给innerComp,并将loaded标记为true
load().then((c:any) => {
innerComp = c
loaded.value = true
})
.catch((e: any) => {
error.value = e
err.value = true
})
.finally(() => {
// 加载完毕后无论成功与否都要清除延迟定时器
loaded.value = false
// 加载完毕后无论成功与否都要清除延迟定时器
clearTimeout(loadingTimer)
})
}

...
}

函数式组件

函数式组件的实现相对容易。一个函数式组件本质上就是一个普通函数,该函数的返回值是虚拟 DOM。

函数式组件没有自身状态,但它仍然可以接收由外部传入的props。为了给函数式组件定义props,我们需要在组件函数上添加静态的props属性,如下面的代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// render.d.ts
export interface FuncComponent extends Function {
props: Record<string, any>
}

// patch
else if (typeof type === 'object' || typeof type === 'function') {
// type是object活function类型,是组件
if (!oldNode) {
mountComponent(newNode, container, anchor)
} else {
patchComponent(oldNode, newNode)
}
}

// mountComponent

// 检查是否是函数式组件
const isFunctional = typeof compVNode.type === 'function'

// 获取组件
let componentOptions = compVNode.type as componentOptions
// 如果是函数式组件,则将compVNode.type作为渲染函数
// 将compVNode.type.props作为props选项
if (isFunctional) {
componentOptions.render = compVNode.type as FuncComponent
componentOptions.props = (compVNode.type as FuncComponent).props
}

出于更加严谨的考虑,我们需要通过isFunctional变量实现选择性地执行初始化逻辑,因为对于函数式组件来说,它无须初始化data以及生命周期钩子。从这一点可以看出,函数式组件的初始化性能消耗小于有状态组件。

内建组件和模块

KeepAlive组件

KeepAlive一词借鉴于 HTTP 协议。在 HTTP 协议中,KeepAlive又称 HTTP 持久连接(HTTP persistent connection),其作用是允许多个请求或响应共用一个 TCP 连接,在没有KeepAlive的情况下,一个 HTTP 连接会在每次请求/响应结束后关闭,当下一次请求发生时,会建立一个新的 HTTP 连接。频繁地销毁、创建 HTTP 连接会带来额外的性能开销,KeepAlive就是为了解决这个问题而生的。

KeepAlive的本质是缓存管理,再加上特殊的挂载/卸载逻辑。首先,KeepAlive 组件的实现需要渲染器层面的支持。这是因为被KeepAlive的组件在卸载时,我们不能真的将其卸载,否则就无法维持组件的当前状态了。正确的做法是,将被KeepAlive的组件从原容器搬运到另外一个隐藏的容器中,实现“假卸载”。当被搬运到隐藏容器中的组件需要再次被“挂载”时,我们也不能执行真正的挂载逻辑,而应该把该组件从隐藏容器中再搬运到原容器。这个过程对应到组件的生命周期,其实就是activateddeactivated

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
export const KeepAlive = {
// KeepAlive独有的属性,用作标识
_isKeepAlive: true,
setup(props: Record<symbol | string, any>, { slots }) {
/**
* 缓存对象
* key: vnode.type
* value: vnode
* */
const cache = new Map<string | componentOptions | FuncComponent, VNode>()
// 当前 KeepAlive 组件实例
const instance: ComponentInstance = currentInstance as ComponentInstance
(instance.keepAliveCtx as KeepAliveCtx).move = (vnode: VNode, container: Container, anchor?: Node | null) => {
if (anchor) {
container.insertBefore(vnode.el, anchor)
}
container.appendChild(vnode.el)
}
(instance.keepAliveCtx as KeepAliveCtx).createElement = (type: any) => { document.createElement(type) }
// 实例上存在特殊的 keepAliveCtx 对象,由渲染器注入
// 会暴露渲染器的一些内部方法,其中 move 函数用来将一段 DOM 移动到另一个容器中
const { move, createElement } = instance.keepAliveCtx as KeepAliveCtx

// 创建隐藏容器
const storageContainer = createElement('div')

// KeepAlive 组件的实例会添加两个内部函数 _deActivated 和 _activated,在渲染器中被调用
(instance.keepAliveCtx as KeepAliveCtx)._deActivated = (vnode: VNode): void => {
move(vnode, storageContainer)
}
(instance.keepAliveCtx as KeepAliveCtx)._activated = (vnode: VNode, container: Container, anchor: Node): void => {
move(vnode, container, anchor)
}

return () => {
// KeepAlive 的默认插槽就是要被 KeepAlive 的组件
let rawVNode = slots.default()
// 不是组件直接渲染
if (typeof rawVNode.type !== 'object') {
return rawVNode
}

// 挂载时先获取缓存组件 vnode
const cachedVNode = cache.get(rawVNode.type)
if (cachedVNode) {
// 如果由缓存内容,说明不应该执行挂载,应该执行激活,继承组件实例
rawVNode.component = cachedVNode.component

// 在 vnode 上添加keptAlive 属性,标记为true,避免渲染器重新挂载
rawVNode.component = (cachedVNode.component as ComponentInstance).keptAlive = true
} else {
// 如果没有缓存则添加
cache.set(rawVNode.type, rawVNode)
}

// 添加shouldKeepAlive属性,并标记为 true ,避免渲染器真的将组件卸载
rawVNode.shouldKeepAlive = true

// 将KeepAlive组件实例也添加到vnode上,以便在渲染器中访问
rawVNode.keepAliveInstance = instance

// 渲染组件 vnode
return rawVNode

}


}
}

KeepAlive 组件与渲染器的结合非常深。首先,KeepAlive 组件本身并不会渲染额外的内容,它的渲染函数最终只返回需要被 KeepAlive 的组件,我们把这个需要被 KeepAlive 的组件称为“内部组件”。KeepAlive组件会对“内部组件”进行操作,主要是在“内部组件”的 vnode 对象上添加一些标记属性,以便渲染器能够据此执行特定的逻辑。这些标记属性包括如下几个。

  • shouldKeepAlive:该属性会被添加到“内部组件”的vnode对象上,这样当渲染器卸载“内部组件”时,可以通过检查该属性得知“内部组件”需要被KeepAlive。于是,渲染器就不会真的卸载“内部组件”,而是会调用_deActivate函数完成搬运工作
1
2
3
4
5
6
7
8
else if (typeof vnode.type === 'object') {
// 对于需要被 KeepAlive 的组件,我们不应该真的卸载它,而应调用该组件的父组件
// 即 KeepAlive 组件的 _deActivate 函数使其失活
if ((vnode as KeepAliveVNode).shouldKeepAlive && (vnode as KeepAliveVNode).keepAliveInstance) {
(vnode as KeepAliveVNode).keepAliveInstance?._deActivated(vnode)
}
...
}
  • keepAliveInstance:“内部组件”的 vnode 对象会持有KeepAlive组件实例,在unmount函数中会通过keepAliveInstance来访问_deActivate函数。

  • keptAlive:“内部组件”如果已经被缓存,则还会为其添加一个keptAlive标记。这样当“内部组件”需要重新渲染时,渲染器并不会重新挂载它,而会将其激活,如下面 patch 函数的代码所示:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    else if (typeof type === 'object' || typeof type === 'function') {
    // type是object活function类型,是组件
    if (!oldNode) {
    // 如果被 KeepAlive,不重新挂载而调用_activate
    if ((newNode as KeepAliveVNode).keptAlive) {
    (newNode as KeepAliveVNode).keepAliveInstance?._activated(newNode, container, anchor)
    } else {
    mountComponent(newNode, container, anchor)
    }

    }

includeexclude

在默认情况下,KeepAlive组件会对所有“内部组件”进行缓存。但有时候用户期望只缓存特定组件。为了使用户能够自定义缓存规则,我们需要让KeepAlive组件支持两个props,分别是includeexclude。其中,include用来显式地配置应该被缓存组件,而exclude用来显式地配置不应该被缓存组件。

为了简化问题,我们只允许为 include 和 exclude 设置正则类型的值。在 KeepAlive 组件被挂载时,它会根据“内部组件”的名称(即name选项)进行匹配,如下面的代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// include, exclude
props: {
include: RegExp,
exclude: RegExp
},

// 不是组件直接渲染
if (typeof rawVNode.type !== 'object') {
return rawVNode
}

// 获取内部组件的名称
const name = (rawVNode.type as componentOptions).name
// 匹配name
if (name &&
(
// 如果name无法被include匹配
(props.include && !props.include.test(name)) ||
// 或者无法被exclude匹配
(props.exclude && props.exclude.test(name))
)
) {
// 直接渲染内部组件
return rawVNode
}

在此基础上,我们可以任意扩充匹配能力。例如,可以将includeexclude设计成多种类型值,允许用户指定字符串或函数,从而提供更加灵活的匹配机制。

缓存管理

缓存的处理逻辑可以总结为:

  • 如果缓存存在,则继承组件实例,并将用于描述组件的vnode对象标记为keptAlive,这样渲染器就不会重新创建新的组件实例;
  • 如果缓存不存在,则设置缓存。

这里的问题在于,当缓存不存在的时候,总是会设置新的缓存。这会导致缓存不断增加,极端情况下会占用大量内存。为了解决这个问题,我们必须设置一个缓存阈值,当缓存数量超过指定阈值时对缓存进行修剪。

Vue.js 当前所采用的修剪策略叫作“最新一次访问”。首先,你需要为缓存设置最大容量,也就是通过KeepAlive组件的 max属性来设置:

1
2
3
<KeepAlive :max="2">
...
</KeepAlive>

“最新一次访问”的缓存修剪策略的核心在于,需要把当前访问(或渲染)的组件z作为最新一次渲染的组件,并且该组件在缓存修剪过程中始终是安全的,即不会被修剪。

Teleport组件

Teleport用于解决组件的内容无法跨越 DOM 层级渲染的为每桶。该组件可以将指定内容渲染到特定容器中,而不受 DOM 层级的限制。通过为 Teleport 组件指定渲染目标,即 to 属性的值,该组件就会直接把它的插槽内容渲染到目标下,而不会按照模板的 DOM 层级来渲染。

1
2
3
4
5
...
else if (typeof type === 'object' && (type as teleport)?._isTeleport) {
// 组件选项中如果存在 __isTeleport 标识,则它是 Teleport 组件,需要移交控制权
(type as teleport).process(newNode, oldNode, container, anchor)
}

KeepAlive组件一样,Teleport 组件也需要渲染器的底层支持。首先我们要将 Teleport 组件的渲染逻辑从渲染器中分离出来,这么做有两点好处:

  • 可以避免渲染器逻辑代码“膨胀”;
  • 当用户没有使用Teleport组件时,由于Teleport的渲染逻辑被分离,因此可以利用TreeShaking机制在最终的 bundle 中删除Teleport相关的代码,使得最终构建包的体积变小。
1
2
3
4
5
6
7
export const Teleport = {
_isTeleport: true,

process(oldVNode: VNode, newVNode: VNode, container: Container, anchor?: Node | null) {

}
}

我们调用patchpatchChild完成挂载和更新。其中更新时需要注意更新操作可能是由于Teleport组件的to属性值的变化引起的,因此,在更新时我们应该考虑这种情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
export const Teleport: teleport = {
_isTeleport: true,

process(oldVNode: VNode, newVNode: VNode, container: Container, anchor: Node | null) {
// 如果旧 VNode 不存在,则挂载,否则更新
if (!oldVNode) {
// 获取挂载点
const target = typeof newVNode.props?.to === 'string'
? document.querySelector(newVNode.props.to)
: newVNode.props.to
// 将 newVnode.children 渲染到指定挂载点
(newVNode.children as VNode[]).forEach((child: VNode) => patch(undefined, child, target, anchor))

} else {
// 更新
patchChild(oldVNode, newVNode, container)
// 如果新旧to值不同则需要移动
if (newVNode.props.to !== oldVNode.props.to) {
// 获取新的容器
const newTarget = typeof newVNode.props.to === 'string'
? document.querySelector(newVNode.props.to)
: newVNode.props.to
// 移动到新容器
(newVNode.children as VNode[]).forEach((child: VNode) => move(child, newTarget))
}
}
}
}

其中move函数的实现为:

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* Teleport节点移动函数
* */
export function move(vnode: VNode, container: Container, anchor?: Node | null) {
const target = vnode.component
? vnode.component.subTree.el
: vnode.el

if (anchor) {
container.insertBefore(target, anchor)
}
container.appendChild(target)
}

Transition组件

Transition组件的核心原理是:

  • 当 DOM 元素被挂载时,将动效附加到该 DOM 元素上;
  • 当 DOM 元素被卸载时,不要立即卸载 DOM 元素,而是等到附加到该 DOM 元素上的动效执行完成后再卸载它。

下面是Vue文档中的示意图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
export const Transition: transition = {
name: 'Transition',
setup(props, { slots }) {
return () => {
// 通过默认插槽获取需要过渡的元素
const innerVNode = slots.default()

// 在过渡元素的VNode上添加transition相应钩子函数
innerVNode.transition = {
beforeEnter(el: Container) {
el.classList.add('c-enter-from')
el.classList.add('c-enter-active')
},
enter(el: Container) {
// 在下一帧切换到结束状态
nextFrame(() => {
el.classList.remove('c-enter-from')
el.classList.add('c-enter-to')
// 监听transition 事件完成收尾工作
el.addEventListener('transitionend', () => {
el.classList.remove('c-enter-to')
el.classList.remove('c-enter-active')


})
})
},
leave(el: Container) {
el.classList.add('c-leave-from')
el.classList.add('c-leave-active')

// 强制 reflow ,使初始状态生效
document.body.offsetHeight
// 下一帧修改状态
nextFrame(() => {
el.classList.remove('c-leave-from')
el.classList.add('c-leave-to')
// 监听transition 事件完成收尾工作
el.addEventListener('transitionend', () => {
el.classList.remove('c-leave-to')
el.classList.remove('c-leave-active')

// 卸载DOM元素
el.parentNode?.removeChild(el)

})
})



performRemove()
}
}


return innerVNode

}
}
}

function nextFrame(cb: () => void) {
requestAnimationFrame(() => {
requestAnimationFrame(cb)
})
}

浏览器的实现有一个 bug ,使用requestAnimationFrame函数注册回调会在当前帧执行,除非其他代码已经调用了一次requestAnimationFrame函数。通过嵌套一层requestAnimationFrame函数的调用即可解决上述问题。

然后我们需要在合适的时机调用附加到该虚拟节点上的过渡相关的生命周期钩子函数,具体体现在mountElement函数以及unmount函数中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
function mountElement(vnode: VNode, container: Container, anchor?: Node | null) {
...

// 判断VNode是否需要过渡
const needTransition = vnode.transition
if (needTransition) {
// 调用 beforeEnter钩子
vnode.transition?.beforeEnter(el)
}

...

if (needTransition) {
vnode.transition?.enter(el)
}

}

function unmount(vnode: VNode | KeepAliveVNode) {
// 是否需要过渡处理
const needTransition = vnode.transition

...

const parent = vnode.el.parentNode
if (parent) {
if (needTransition) {
vnode.transition?.leave(vnode.el)
} else {
parent.removeChild(vnode.el)
}
}
}

我们硬编码了过渡状态的类名,例如enter-fromenter-to等。实际上,我们可以轻松地通过props来实现允许用户自定义类名的能力,从而实现一个更加灵活的Transition组件。另外,我们也没有实现“模式”的概念,即先进后出(in-out)或后进先出(out-in)。实际上,模式的概念只是增加了对节点过渡时机的控制,原理上与将卸载动作封装到performRemove函数中一样,只需要在具体的时机以回调的形式将控制权交接出去即可。

总结

一个有状态组件就是一个选项对象。如果从渲染器的内部实现来看,一个组件则是一个特殊类型的虚拟 DOM 节点。组件本身是对页面内容的封装,它用来描述页面内容的一部分,一个组件必须包含一个渲染函数,即render函数,并且渲染函数的返回值应该是虚拟 DOM。换句话说,组件的渲染函数就是用来描述组件所渲染内容的接口

当组件自身状态发生变化时,我们需要有能力触发组件更新,即组件的自更新。为此,我们需要将整个渲染任务包装到一个watchEffect中。我们需要实现一个调度器,当副作用函数需要重新执行时,我们不会立即执行它,而是将它缓冲到一个微任务队列中,等到执行栈清空后,再将它从微任务队列中取出并执行,我们就有机会对任务进行去重,从而避免多次执行副作用函数带来的性能开销。

对于组件的 props 与组件的被动更新,副作用自更新所引起的子组件更新叫作子组件的被动更新。我们还介绍了渲染上下文(renderContext),它实际上是组件实例的代理对象。在渲染函数内访问组件实例所暴露的数据都是通过该代理对象实现的。

组件的setup函数是 Vue3 新增的组件选项,它有别于 Vue2 中存在的其他组件选项。这是因为setup函数主要用于配合组合式API,为用户提供一个地方,用于建立组合逻辑、创建响应式数据、创建通用函数、注册生命周期钩子等能力。在组件的整个生命周期中,setup函数只会在被挂载时执行一次,它的返回值可以有两种情况。

在具体的实现上,发射自定义事件的本质就是根据事件名称去props数据对象中寻找对应的事件处理函数并执行。整体实现并不复杂,只需要实现一个emit函数并将其添加到setupContext对象中,这样用户就可以通过setupContext取得emit函数了。注意,我们需要在解析props数据的时候对事件类型的props做特殊处理。

我们定义了defineAsyncComponent函数,用来定义异步组件。我们还实现了超时、加载、错误组件的选项,用于处理各类网络状况。函数式组件本质上是一个函数,其内部实现逻辑可以复用有状态组件的实现逻辑。

Author:破酥 | C4iN
Link:https://c4in1.github.io/2024/08/27/MVVM/实现一个简单的MVVM-3/
版权声明:本文采用 CC BY-NC-SA 3.0 CN 协议进行许可