composition-api 源码解析

版本说明

本文是针对 composition-api v1.0.0-rc.6 版本的一次源码解析,主要是想探析以下两点:

  1. Vue 在安装 composition-api 时做了些什么?
  2. Vue 在执行每个组件的 setup 方法时做了什么?

好了,废话不多说,我们直接开始。

一、安装过程

1. 检测是否已安装

1
2
3
4
5
6
7
8
9
10
// src/install.ts

if (isVueRegistered(Vue)) {
if (__DEV__) {
warn(
'[vue-composition-api] already installed. Vue.use(VueCompositionAPI) should be called only once.'
)
}
return
}

首先是检查是否重复安装,如果是则在开发环境中发出警告,主要是调用了 isVueRegistered 方法来进行检测,下面是它的定义:

1
2
3
4
5
6
7
// src/runtimeContext.ts

const PluginInstalledFlag = '__composition_api_installed__'

export function isVueRegistered(Vue: VueConstructor) {
return hasOwn(Vue, PluginInstalledFlag)
}

通过检测 Vue 的 __composition_api_installed__   这个属性来 composition-api 是否已经安装。

那很明显后来真正安装 composition-api 时会设置这个属性。

2. 检测 Vue 版本

1
2
3
4
5
6
7
8
9
10
11
if (__DEV__) {
if (Vue.version) {
if (Vue.version[0] !== '2' || Vue.version[1] !== '.') {
warn(
`[vue-composition-api] only works with Vue 2, v${Vue.version} found.`
)
}
} else {
warn('[vue-composition-api] no Vue version found')
}
}

然后在开发环境中判断 Vue 的版本,必须是 2.x 的版本才能使用 composition-api。

3. 添加 setup 这个 option api

1
2
3
4
5
6
7
8
9
10
11
Vue.config.optionMergeStrategies.setup = function (
parent: Function,
child: Function
) {
return function mergedSetupFn(props: any, context: any) {
return mergeData(
typeof parent === 'function' ? parent(props, context) || {} : undefined,
typeof child === 'function' ? child(props, context) || {} : undefined
)
}
}

接着通过 Vue 的 自定义选项合并策略 来添加 setup 这个 api。

ps:是否还有同学不知道我们可以自定义 Vue 的 options 呢?可以尝试利用这个 api 来实现一个 asyncComputedmultiWatch 来玩玩哦!

4. 设置已安装标记

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// src/runtimeContext.ts

const PluginInstalledFlag = '__composition_api_installed__'

export function setVueConstructor(Vue: VueConstructor) {
// @ts-ignore
if (__DEV__ && vueConstructor && Vue.__proto__ !== vueConstructor.__proto__) {
warn('[vue-composition-api] another instance of Vue installed')
}
vueConstructor = Vue
Object.defineProperty(Vue, PluginInstalledFlag, {
configurable: true,
writable: true,
value: true
})
}

上面提到过,就是在这里设置一个表示已经安装的标记。

5. 设置全局混合

1
2
3
4
Vue.mixin({
beforeCreate: functionApiInit
// ... other
})

然后添加一个全局的 mixin ,在每个组件的 beforeCreate 生命周期执行一下 functionApiInit 方法。

以上就是安装 composition-api 做的事,关于 functionApiInit 的内容我们在下一小节中详细讲解 。

二、执行 setup

我们知道  composition-api 主要是新增了一个 setup 选项,以及一系列 hooks,而 steup 也不是简单调用一下就完事,在这之前需要做一些事,比如传入的两个参数: propsctx 是怎么来的,以及 setup 的返回值为何可以在 template 中使用等等。

前面讲了 compsition-api 会在每个组件的 beforeCreate 时执行一下 functionApiInit 方法 :

1
2
3
4
Vue.mixin({
beforeCreate: functionApiInit
// ... other
})

下面是这个方法主要做的事。

1. 检测是否有 render

第一步是检测是否定义 render 方法,如果有 render 方法,则修改它内部。

1
2
3
4
5
6
7
8
9
10
const vm = this
const $options = vm.$options
const { setup, render } = $options

if (render) {
// keep currentInstance accessible for createElement
$options.render = function (...args: any): any {
return activateCurrentInstance(vm, () => render.apply(this, args))
}
}

activateCurrentInstance 的作用就是设置当前实例,所以我们可以在 render 中通过 getCurrentInstance 访问到当前实例。

ps:值得说明的是即便我们写的是 template ,但到了目前这个阶段这里它已经被转换成 render 函数了。

2. 检测是否有 setup

如果没有定义 setup ,说明这个组件没有使用 composition-api ,这时候则直接跳过该组件:

1
2
3
4
5
6
7
8
9
10
11
12
if (!setup) {
return
}
if (typeof setup !== 'function') {
if (__DEV__) {
warn(
'The "setup" option should be a function that returns a object in component definitions.',
vm
)
}
return
}

3. 在 data 方法初始化 setup

如果存在 setup ,就会修改这个组件的 data 方法,在初始化真正的 data 方法之前先初始化一下 setup 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
const {
data
} = $options
// wrapper the data option, so we can invoke setup before data get resolved
$options.data = function wrappedData() {
initSetup(vm, vm.$props)
return typeof data === 'function' ?
(data as(this: ComponentInstance, x: ComponentInstance) => object).call(
vm,
vm
) :
data || {}
}

还记得 Vue 初始化 data 的时机是什么时候吗?答案是在 beforeCreatecreated 之间,所以 setup 也是一样。

4. 初始化 setup

initSetup 方法内部还做了挺多事的,下面是这个方法的全貌,先简单瞄一眼,我们后面会一步步拆解:

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
function initSetup(vm: ComponentInstance, props: Record < any, any > = {}) {
const setup = vm.$options.setup!
const ctx = createSetupContext(vm)
// fake reactive for `toRefs(props)`
def(props, '__ob__', createObserver())
// resolve scopedSlots and slots to functions
// @ts-expect-error
resolveScopedSlots(vm, ctx.slots)
let binding: ReturnType < SetupFunction < Data, Data >> | undefined | null
activateCurrentInstance(vm, () => {
// make props to be fake reactive, this is for `toRefs(props)`
binding = setup(props, ctx)
})
if (!binding) return
if (isFunction(binding)) {
// keep typescript happy with the binding type.
const bindingFunc = binding
// keep currentInstance accessible for createElement
vm.$options.render = () => {
// @ts-expect-error
resolveScopedSlots(vm, ctx.slots)
return activateCurrentInstance(vm, () => bindingFunc())
}
return
} else if (isPlainObject(binding)) {
if (isReactive(binding)) {
binding = toRefs(binding) as Data
}
vmStateManager.set(vm, 'rawBindings', binding)
const bindingObj = binding
Object.keys(bindingObj).forEach((name) => {
let bindingValue: any = bindingObj[name]
if (!isRef(bindingValue)) {
if (!isReactive(bindingValue)) {
if (isFunction(bindingValue)) {
bindingValue = bindingValue.bind(vm)
} else if (!isObject(bindingValue)) {
bindingValue = ref(bindingValue)
} else if (hasReactiveArrayChild(bindingValue)) {
// creates a custom reactive properties without make the object explicitly reactive
// NOTE we should try to avoid this, better implementation needed
customReactive(bindingValue)
}
} else if (isArray(bindingValue)) {
bindingValue = ref(bindingValue)
}
}
asVmProperty(vm, name, bindingValue)
})
return
}
if (__DEV__) {
assert(
false,
`"setup" must return a "Object" or a "Function", got "${Object.prototype.toString
.call(binding)
.slice(8, -1)}"`
)
}
}

4.1. 初始化 context

这个 ctxsetup 中接受的第二个参数,这个对象里面的内容是怎么生成的呢?

1
const ctx = createSetupContext(vm)

下面是 createSetupContext 所做的事,首先是定义 ctx 对象中所有的 key

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const ctx = {
slots: {}
}
as SetupContext

const propsPlain = [
'root',
'parent',
'refs',
'listeners',
'isServer',
'ssrContext',
]
const propsReactiveProxy = ['attrs']
const methodReturnVoid = ['emit']

接下来就是给这些属性利用 Object.defineProperty 做一层代理,当然它们都是只读的:

1
2
3
4
5
6
7
8
9
propsPlain.forEach((key) => {
let srcKey = `$${key}`
proxy(ctx, key, {
get: () => vm[srcKey],
set() {
warn(`Cannot assign to '${key}' because it is a read-only property`, vm)
}
})
})

另外两个 propsReactiveProxymethodReturnVoid 也差不多,这里就略过了。

4.2. 响应式 props

接着就是将 props 对象进行一遍 Observer:

1
2
3
4
5
6
def(props, '__ob__', createObserver())

// src/reactivity/reactive.ts
export function createObserver() {
return observe < any > {}.__ob__
}

首先通过 createObserver 拿到一个把空对象经过 Vue. Observer 后的 __ob__ 属性,也就是当前 Observer 实例对象,如果同学们对于 Vue Observer 的原理还不太熟悉,可以看这里 数据对象的 ,本文就不赘述了。

然后给 props 新增一个 __ob_ 属性,指向前面拿到的这个 __ob__

4.3. 解析 slots

接着就是把当前实例的 slots 给代理到前面定义的 ctx.slots 中,这时候它只是一个空对象:

1
resolveScopedSlots(vm, ctx.slots)

下面是 resolveScopedSlots 的实现:

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
export function resolveScopedSlots(
vm: ComponentInstance,
slotsProxy: {
[x: string]: Function
}
): void {
const parentVNode = (vm.$options as any)._parentVnode
if (!parentVNode) return

const prevSlots = vmStateManager.get(vm, 'slots') || []
const curSlots = resolveSlots(parentVNode.data.scopedSlots, vm.$slots)
// remove staled slots
for (let index = 0; index < prevSlots.length; index++) {
const key = prevSlots[index]
if (!curSlots[key]) {
delete slotsProxy[key]
}
}

// proxy fresh slots
const slotNames = Object.keys(curSlots)
for (let index = 0; index < slotNames.length; index++) {
const key = slotNames[index]
if (!slotsProxy[key]) {
slotsProxy[key] = createSlotProxy(vm, key)
}
}
vmStateManager.set(vm, 'slots', slotNames)
}

简单来说就是将父组件的 slots 数组(真正被使用的)代理到 ctx.slots 中,并且在这个 slots 数组有变化时 ctx.slots 也会相应地更新。

4.4. 执行 setup

终于到了最重要的关头,开始执行 setup 了:

1
2
3
4
activateCurrentInstance(vm, () => {
// make props to be fake reactive, this is for `toRefs(props)`
binding = setup(props, ctx)
})

activateCurrentInstance 之前讲过了,就是使组件的 setup 内部可以通过 getCurrentInstance 访问当前实例,相信真正使用过 composition-api 的同学们都知道这个方法的便利性了,但不知道同学们是否遇到过 getCurrentInstance 方法返回 null 值的情况呢?如果想知道为什么,可以看这篇文章:《从 Composition API 源码分析 getCurrentInstance() 为何返回 null》

然后将前面得到的 propsctx 传进去,最后将返回值赋值给 binding

4.6. 处理 setup 返回值

处理返回值前需要先对它进行类型判断,有三种条件分支:

  1. 为空,直接返回
  2. 是一个函数,当成 render 方法处理
  3. 是一个普通对象,做一系列转换

如果返回值是一个函数,则把它当成 render 方法处理,当然在这之前需要重新调用一下 resolveScopedSlots 检测 slots 的更新,并且调用 activateCurrentInstance  :

1
2
3
4
5
6
7
8
9
10
11
if (isFunction(binding)) {
// keep typescript happy with the binding type.
const bindingFunc = binding
// keep currentInstance accessible for createElement
vm.$options.render = () => {
// @ts-expect-error
resolveScopedSlots(vm, ctx.slots)
return activateCurrentInstance(vm, () => bindingFunc())
}
return
}

ps:也可以直接在 setup 中返回 JSX 哦,因为 Babel 会把它变成一个函数。

但通常我们是在 setup 返回一个对象,然后可以直接在 template 中使用这个这些值,所以我们看看返回值是一个对象的情况:

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
else if (isPlainObject(binding)) {
if (isReactive(binding)) {
binding = toRefs(binding) as Data
}

vmStateManager.set(vm, 'rawBindings', binding)
const bindingObj = binding

Object.keys(bindingObj).forEach((name) => {
let bindingValue: any = bindingObj[name]

if (!isRef(bindingValue)) {
if (!isReactive(bindingValue)) {
if (isFunction(bindingValue)) {
bindingValue = bindingValue.bind(vm)
} else if (!isObject(bindingValue)) {
bindingValue = ref(bindingValue)
} else if (hasReactiveArrayChild(bindingValue)) {
// creates a custom reactive properties without make the object explicitly reactive
// NOTE we should try to avoid this, better implementation needed
customReactive(bindingValue)
}
} else if (isArray(bindingValue)) {
bindingValue = ref(bindingValue)
}
}
asVmProperty(vm, name, bindingValue)
})

return
}

首先如果返回的对象是经过 reactive 的,则要调用 toRefs 将它的子属性变成 ref 包装过的,然后调用 vmStateManager.set 将这些属性存放起来,以供别的地方使用。

然后遍历这个对象,经过一系列类型判断和处理后,将它的子属性设置为当前实例的变量,这样我们就可以在 templte 或者通过 this.xxx 去访问这些变量。

这里的类型处理简单总结一下就是:

  1. 如果属性值是一个函数,则这个函数被调用时已经 this 就是当前实例
  2. 如果属性值一个非对象非函数的值,则会自动经过 ref 包装
  3. 如果属性值是一个普通对象且有子属性值为经过 reactive 后的数组,则要将这个普通对象也要转换为经过 reactive 包装才行,所以我们在开发时要避免如下情况:
1
2
3
4
5
6
7
setup() {
return {
obj: {
arr: reactive([1, 2, 3, 4])
}
}
}

最后,在开发环境下判断返回值不是对象是抛出一个错误。到此 setup 函数的执行就完了。

总结

关于 composition-api 的安装和执行过程就讲完了,下面我们来简单总结一下,composition-api 在安装时会做以下事情:

  1. 通过检查 Vue 的 __composition_api_installed__ 属性来判断是否重复安装
  2. 检查 Vue 版本是否 2.x
  3. 使用合并策略添加 setup api
  4. 标记安装
  5. 利用全局混入来对 setup 进行初始化

而在执行 setup 时会做以下事情:

  1. 检查当前组件是否使用 render 方法,如果有则在这之前标记当前实例,以便 render 方法内部可以通过 getCurrentInstance 方法访问到当前实例。
  2. 检查当前组件有 setup api,没有则直接返回,否则在初始化 data 时先初始化一下 setup
  3. 而初始化 setup 做的事就是构造 setup 接受的两个参数:props、ctx
  4. 然后执行 setup ,根据它的返回值类型进行相应的处理

当然,compsition-api 真正的魅力在于 hooks,下次我就来讲讲 composition-api 的一系列 hooks 是如何实现的,这也能帮助我们更好地利用这些 hooks 方法来编写更优雅、可复用的代码。

本文就到此,感谢你的阅读。


作者 : 4Ark
来源 : https://4ark.me
著作权归作者所有,转载请联系作者获得授权。

🥳 加载 Disqus 评论