0%

组件的异步更新

effect

1
2
3
4
5
6
7
// create reactive effect for rendering
const effect = (instance.effect = new ReactiveEffect(
componentUpdateFn, // fn:组件更新实际执行函数
NOOP,
() => queueJob(update), //scheduler: update: () => effect.run() ,相当于执行componentUpdateFn
instance.scope, // track it in component's effect scope
))

queueJob

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const queue: SchedulerJob[] = []

const resolvedPromise = /*#__PURE__*/ Promise.resolve() as Promise<any>
let currentFlushPromise: Promise<void> | null = null

export function queueJob(job: SchedulerJob) {
if (
!queue.length ||
!queue.includes( // queue中是否已经存在相同job
job,
isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex,
)
) {
if (job.id == null) {
queue.push(job)
} else {
queue.splice(findInsertionIndex(job.id), 0, job)
}
queueFlush()
}
}

queueJob 执行主要是将 scheduler 添加到 queue 队列中,然后执行 queueFlush 函数。

queueFlush

1
2
3
4
5
6
7
8
9
function queueFlush() {
// isFlushing和isflushPending初始值都是false
// 说明当前没有flush任务在执行,也没有flush任务在等待执行
if (!isFlushing && !isFlushPending) {
// 初次执行queueFlush将isFlushPending设置为true,表示有flush任务在等待执行
isFlushPending = true
currentFlushPromise = resolvedPromise.then(flushJobs)
}
}

resolvedPromise 就是 promise.resolve(),flushJobs 被放到微任务队列中,等待所有同步任务执行完毕后执行,这样就可以保证flushJobs在一次组件更新中只执行一次。最后,更新 currentFlushPromise 以供 nextTick 使用。

flushJobs

当所有的同步scheduler执行完毕后,就会去处理微任务队列的任务,就会执行flushJobs回调函数。

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
function flushJobs(seen?: CountMap) {
isFlushPending = false
isFlushing = true
if (__DEV__) {
seen = seen || new Map()
}

// Sort queue before flush.
// This ensures that:
// 1. Components are updated from parent to child. (because parent is always
// created before the child so its render effect will have smaller
// priority number)
// 2. If a component is unmounted during a parent component's update,
// its update can be skipped.
// 组件更新的顺序是从父到子 因为父组件总是在子组件之前创建 所以它的渲染效果将具有更小的优先级
// 如果一个组件在父组件更新期间被卸载 则可以跳过它的更新
queue.sort(comparator)
// ...
// 先执行queue中的job 然后执行pendingPostFlushCbs中的job
// 这里可以实现watch中的 postFlush
try {
for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
const job = queue[flushIndex]
if (job && job.active !== false) {
if (__DEV__ && check(job)) {
continue
}
callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)
}
}
} finally {
// job执行完毕后清空队列
flushIndex = 0
queue.length = 0

// 执行flushPostFlushCbs 此时组件已经更新完毕
flushPostFlushCbs(seen)

isFlushing = false
currentFlushPromise = null
// some postFlushCb queued jobs!
// keep flushing until it drains.
if (queue.length || pendingPostFlushCbs.length) {
flushJobs(seen)
}
}
}

NextTick

vue3中的nextTick实现非常简单:

1
2
3
4
5
6
7
export function nextTick<T = void, R = void>(
this: T,
fn?: (this: T) => R,
): Promise<Awaited<R>> {
const p = currentFlushPromise || resolvedPromise
return fn ? p.then(this ? fn.bind(this) : fn) : p
}

这里的关键就是 currentFlushPromise,我们仔细看其实发现 currentFlushPromise 在 queueFlush 中就被赋值,它正是把执行组件更新函数的任务放入微队列中的promise,所以在此我们拿到 currentFlushPromise 正好把 nextTick 接收到的函数回调fn放到微队列中 flushJobs 的后面,等到 flushJobs 执行完成后组件也已经更新完毕,此时正是我们希望去执行 nextTick 回调的时机。

注意:我们知道在一个eventloop中,执行完微任务后才进行渲染更新,那nextTick能拿到最新的dom吗?答案是可以的,执行nextTick回调时候,dom已经被修改,只是还没渲染。我们运行下面的例子便可得到答案。

1
2
3
4
5
6
7
8
<div id="count">{{ count }}</div>

const count = ref(0);
count.value++;
nextTick(() => {
// 执行回调时候,虽然dom还没渲染,但dom已经被修改可以获取最新值
console.log('count', document.getElementById('count').innerText); // 1
});

总结

组件内当修改响应式数据后,组件更新函数会被放到queue中,然后注册一个微任务,这个微任务负责执行queue中的所有job,所以这时就算我们同步修改多次/多个响应式数据,同一个组件的更新函数只会被放入一次到queue中,nextTick的回调也会放入到微队列中 flushJobs 的后面,等到同步操作结束后才会去执行注册的微任务,组件更新函数才会被执行(nextTick在此后执行也会获取到最新的dom值),组件也会被更新。

计算属性:computed

Vue.js 3.0 提供了一个 computed 函数作为计算属性 API,我们先来看看它是如何使用的。

1
2
3
4
5
6
const count = ref(1)
const plusOne = computed(() => count.value + 1)
console.log(plusOne.value) // 2
plusOne.value++ // error
count.value++
console.log(plusOne.value) // 3

从代码中可以看到,我们先使用 ref API 创建了一个响应式对象 count,然后使用 computed API 创建了另一个响应式对象 plusOne,它的值是 count.value + 1,当我们修改 count.value 的时候, plusOne.value 就会自动发生变化。

注意,这里我们直接修改 plusOne.value 会报一个错误,这是因为如果我们传递给 computed 的是一个函数,那么这就是一个 getter 函数,我们只能获取它的值,而不能直接修改它。

在 getter 函数中,我们会根据响应式对象重新计算出新的值,这也就是它被叫做计算属性的原因,而这个响应式对象,就是计算属性的依赖。

当然,有时候我们也希望能够直接修改 computed 的返回值,那么我们可以给 computed 传入一个对象:

1
2
3
4
5
6
7
8
9
const count = ref(1)
const plusOne = computed({
get: () => count.value + 1,
set: val => {
count.value = val - 1
}
})
plusOne.value = 1
console.log(count.value) // 0

在这个例子中,结合上述代码可以看到,我们给 computed 函数传入了一个拥有 getter 函数和 setter 函数的对象,getter 函数和之前一样,还是返回 count.value + 1;而 setter 函数,请注意,这里我们修改 plusOne.value 的值就会触发 setter 函数,其实 setter 函数内部实际上会根据传入的参数修改计算属性的依赖值 count.value,因为一旦依赖的值被修改了,我们再去获取计算属性就会重新执行一遍 getter,所以这样获取的值也就发生了变化。

好了,我们现在已经知道了 computed API 的两种使用方式了,接下来就看看它是怎样实现的:

computed

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
export function computed<T>(
getterOrOptions: ComputedGetter<T> | WritableComputedOptions<T>,
debugOptions?: DebuggerOptions,
isSSR = false,
) {
// getter 函数
let getter: ComputedGetter<T>
// setter 函数
let setter: ComputedSetter<T>

// 判断是否只有getter的情况,也就是上面第一种例子情况
const onlyGetter = isFunction(getterOrOptions)
if (onlyGetter) {
getter = getterOrOptions
setter = __DEV__
? () => {
warn('Write operation failed: computed value is readonly')
}
: NOOP
} else {
getter = getterOrOptions.get
setter = getterOrOptions.set
}
// 生成computed实例
const cRef = new ComputedRefImpl(getter, setter, onlyGetter || !setter, isSSR)

if (__DEV__ && debugOptions && !isSSR) {
cRef.effect.onTrack = debugOptions.onTrack
cRef.effect.onTrigger = debugOptions.onTrigger
}

return cRef as any
}

computed接收3个参数,主要看第一个参数,观察其类型,发现可以传两种参数:一种是一个getter函数,一种是个包含get、set的对象。
首先从getterOrOptions中确定getter、setter(如果getterOrOptions是个function,说明computed是不可写的,所以会将setter设置为一个空函数),确定好之后,创建ComputedRefImpl的实例,并将其返回。

ComputedRefImpl

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
73
74
75
76
export class ComputedRefImpl<T> {
public dep?: Dep = undefined // 存储effect的集合

private _value!: T
public readonly effect: ReactiveEffect<T>

public readonly __v_isRef = true
public readonly [ReactiveFlags.IS_READONLY]: boolean = false

public _cacheable: boolean

/**
* Dev only
*/
_warnRecursive?: boolean

constructor(
private getter: ComputedGetter<T>,
private readonly _setter: ComputedSetter<T>,
isReadonly: boolean,
isSSR: boolean,
) {
// 创建effect
this.effect = new ReactiveEffect(
() => getter(this._value), // fn
// trigger
() =>
triggerRefValue(
this,
this.effect._dirtyLevel === DirtyLevels.MaybeDirty_ComputedSideEffect
? DirtyLevels.MaybeDirty_ComputedSideEffect
: DirtyLevels.MaybeDirty,
),
)
// 用于区分effect是否是computed
this.effect.computed = this
// this.effect.active与this._cacheable在SSR中为false
this.effect.active = this._cacheable = !isSSR
this[ReactiveFlags.IS_READONLY] = isReadonly
}

get value() {
// the computed ref may get wrapped by other proxies e.g. readonly() #3376
// 计算出的 ref 可能会被其他代理包装,例如 readonly(),所以转成原始类型
const self = toRaw(this)
// 当未缓存或者dirty也就是需要更新 并且值发生了改变,触发更新
if (
(!self._cacheable || self.effect.dirty) &&
hasChanged(self._value, (self._value = self.effect.run()!)) // 会触发一次run,相当于获取最新的依赖值,如果结果发生改变,触发更新
) {
triggerRefValue(self, DirtyLevels.Dirty)
}
// 进行依赖收集
trackRefValue(self)

// 如果 _dirtyLevel 高于 DirtyLevels.MaybeDirty_ComputedSideEffect, 触发更新
if (self.effect._dirtyLevel >= DirtyLevels.MaybeDirty_ComputedSideEffect) {
triggerRefValue(self, DirtyLevels.MaybeDirty_ComputedSideEffect)
}
return self._value
}

set value(newValue: T) {
this._setter(newValue)
}

// #region polyfill _dirty for backward compatibility third party code for Vue <= 3.3.x
get _dirty() {
return this.effect.dirty
}

set _dirty(v) {
this.effect.dirty = v
}
// #endregion
}

侦听器:watch

1.watch API 可以侦听一个 getter 函数,但是它必须返回一个响应式对象,当该响应式对象更新后,会执行对应的回调函数。

1
2
3
4
5
import { reactive, watch } from 'vue'
const state = reactive({ count: 0 })
watch(() => state.count, (count, prevCount) => {
// 当 state.count 更新,会触发此回调函数
})

2.watch API 也可以直接侦听一个响应式对象,当响应式对象更新后,会执行对应的回调函数。

1
2
3
4
5
import { ref, watch } from 'vue'
const count = ref(0)
watch(count, (count, prevCount) => {
// 当 count.value 更新,会触发此回调函数
})

3.watch API 还可以直接侦听多个响应式对象,任意一个响应式对象更新后,就会执行对应的回调函数。

1
2
3
4
5
6
import { ref, watch } from 'vue'
const count = ref(0)
const count2 = ref(1)
watch([count, count2], ([count, count2], [prevCount, prevCount2]) => {
// 当 count.value 或者 count2.value 更新,会触发此回调函数
})

watch

1
2
3
4
5
6
7
function watch<T = any, Immediate extends Readonly<boolean> = false>(
source: T | WatchSource<T>,
cb: any,
options?: WatchOptions<Immediate>,
): WatchStopHandle {
return doWatch(source as any, cb, options)
}

我们看到watch内部调用了doWatch:

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
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
function doWatch(
source: WatchSource | WatchSource[] | WatchEffect | object,
cb: WatchCallback | null,
{
immediate,
deep,
flush,
once,
onTrack,
onTrigger,
}: WatchOptions = EMPTY_OBJ,
): WatchStopHandle {
// 如果有回调函数,并且只监听一次后移除watch
if (cb && once) {
const _cb = cb
cb = (...args) => {
_cb(...args)
unwatch()
}
}

// 当前组件实例
const instance = currentInstance
const reactiveGetter = (source: object) =>
deep === true
? source // traverse will happen in wrapped getter below
: // for deep: false, only traverse root-level properties
traverse(source, deep === false ? 1 : undefined)

let getter: () => any
let forceTrigger = false
let isMultiSource = false

// 1. 根据不同source 创建不同的getter函数
// getter 函数与computed的getter函数作用类似
if (isRef(source)) {
getter = () => source.value
forceTrigger = isShallow(source)
} else if (isReactive(source)) {
// source是reactive对象时 自动开启deep=true
getter = () => reactiveGetter(source)
forceTrigger = true
} else if (isArray(source)) {
isMultiSource = true
forceTrigger = source.some(s => isReactive(s) || isShallow(s))
// source是一个数组的时候,getter 遍历通过类型判断
getter = () =>
source.map(s => {
if (isRef(s)) {
return s.value
} else if (isReactive(s)) {
return reactiveGetter(s)
} else if (isFunction(s)) {
return callWithErrorHandling(s, instance, ErrorCodes.WATCH_GETTER)
} else {
__DEV__ && warnInvalidSource(s)
}
})
} else if (isFunction(source)) {
// 如果有cb,代表source是个getter函数
if (cb) {
// getter with cb
getter = () =>
callWithErrorHandling(source, instance, ErrorCodes.WATCH_GETTER)
} else {
// 否则代表是watchEffect
// no cb -> simple effect
getter = () => {
if (cleanup) {
cleanup()
}
return callWithAsyncErrorHandling(
source,
instance,
ErrorCodes.WATCH_CALLBACK,
[onCleanup],
)
}
}
} else {
getter = NOOP
__DEV__ && warnInvalidSource(source)
}

// 2.x array mutation watch compat
// 兼容vue2
if (__COMPAT__ && cb && !deep) {
const baseGetter = getter
getter = () => {
const val = baseGetter()
if (
isArray(val) &&
checkCompatEnabled(DeprecationTypes.WATCH_ARRAY, instance)
) {
traverse(val)
}
return val
}
}

// 2. 深度监听
if (cb && deep) {
const baseGetter = getter
// traverse会递归遍历对象的所有属性 以达到深度监听的目的
getter = () => traverse(baseGetter())
}

// watch回调的第三个参数 可以用此注册一个cleanup函数 会在下一次watch cb调用前执行
// 常用于竞态问题的处理
let cleanup: (() => void) | undefined
let onCleanup: OnCleanup = (fn: () => void) => {
cleanup = effect.onStop = () => {
callWithErrorHandling(fn, instance, ErrorCodes.WATCH_CLEANUP)
cleanup = effect.onStop = undefined
}
}

// in SSR there is no need to setup an actual effect, and it should be noop
// unless it's eager or sync flush
let ssrCleanup: (() => void)[] | undefined
if (__SSR__ && isInSSRComponentSetup) {
// we will also not call the invalidate callback (+ runner is not set up)
// ssr处理
}

// oldValue声明 多个source监听则初始化为数组
let oldValue: any = isMultiSource
? new Array((source as []).length).fill(INITIAL_WATCHER_VALUE)
: INITIAL_WATCHER_VALUE
// 调度器调用时执行
const job: SchedulerJob = () => {
if (!effect.active || !effect.dirty) {
return
}
if (cb) {
// watch(source, cb)
// 获取newValue
const newValue = effect.run()
if (
deep ||
forceTrigger ||
(isMultiSource
? (newValue as any[]).some((v, i) => hasChanged(v, oldValue[i]))
: hasChanged(newValue, oldValue)) ||
(__COMPAT__ &&
isArray(newValue) &&
isCompatEnabled(DeprecationTypes.WATCH_ARRAY, instance))
) {
// cleanup before running cb again
if (cleanup) {
cleanup()
}
// 调用cb 参数为newValue、oldValue、onCleanup
callWithAsyncErrorHandling(cb, instance, ErrorCodes.WATCH_CALLBACK, [
newValue,
// pass undefined as the old value when it's changed for the first time
oldValue === INITIAL_WATCHER_VALUE
? undefined
: isMultiSource && oldValue[0] === INITIAL_WATCHER_VALUE
? []
: oldValue,
onCleanup,
])
// 更新oldValue
oldValue = newValue
}
} else {
// watchEffect
effect.run()
}
}

// important: mark the job as a watcher callback so that scheduler knows
// it is allowed to self-trigger (#1727)
job.allowRecurse = !!cb

// 创建任务队列的调度回调scheduler
let scheduler: EffectScheduler
if (flush === 'sync') {
// 同步更新 即每次响应式数据改变都会回调一次cb
scheduler = job as any // the scheduler function gets called directly
} else if (flush === 'post') {
// job放入pendingPostFlushCbs队列中
// pendingPostFlushCbs队列会在queue队列执行完毕后执行 函数更新effect通常会放在queue队列中
// 所以pendingPostFlushCbs队列执行时组件已经更新完毕
scheduler = () => queuePostRenderEffect(job, instance && instance.suspense)
} else {
// default: 'pre'
job.pre = true
if (instance) job.id = instance.uid
// 默认更新
// 原理在这:https://rile14929.github.io/zh-CN/vue3%E7%BB%84%E4%BB%B6%E5%BC%82%E6%AD%A5%E6%9B%B4%E6%96%B0%E5%92%8CNextTick%E7%9A%84%E8%BF%90%E8%A1%8C%E6%9C%BA%E5%88%B6.html
scheduler = () => queueJob(job)
}

// 创建effect effect.run的时候建立effect与getter内响应式数据的关系
const effect = new ReactiveEffect(getter, NOOP, scheduler)

const scope = getCurrentScope()
// 取消监听的函数
const unwatch = () => {
effect.stop()
if (scope) {
remove(scope.effects, effect)
}
}

// initial run
if (cb) {
if (immediate) {
// 立即执行一次job
job()
} else {
// 否则执行effect.run() 会执行getter 获取oldValue
oldValue = effect.run()
}
} else if (flush === 'post') {
queuePostRenderEffect(
effect.run.bind(effect),
instance && instance.suspense,
)
} else {
effect.run()
}

if (__SSR__ && ssrCleanup) ssrCleanup.push(unwatch)
return unwatch
}

WatchEffect

watchEffect API 的作用是注册一个副作用函数,副作用函数内部可以访问到响应式对象,当内部响应式对象变化后再立即执行这个函数。

1
2
3
4
import { ref, watchEffect } from 'vue'
const count = ref(0)
watchEffect(() => console.log(count.value))
count.value++

watchEffect 和前面的 watch API 主要有三点不同:

  1. 侦听的源不同 。watch API 可以侦听一个或多个响应式对象,也可以侦听一个 getter 函数,而 watchEffect API 侦听的是一个普通函数,只要内部访问了响应式对象即可,这个函数并不需要返回响应式对象。

  2. 没有回调函数 。watchEffect API 没有回调函数,副作用函数的内部响应式对象发生变化后,会再次执行这个副作用函数。

  3. 立即执行 。watchEffect API 在创建好 watcher 后,会立刻执行它的副作用函数,而 watch API 需要配置 immediate 为 true,才会立即执行回调函数。

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 function watchEffect(
effect: WatchEffect,
options?: WatchOptionsBase,
): WatchStopHandle {
return doWatch(effect, null, options)
}

export function watchPostEffect(
effect: WatchEffect,
options?: DebuggerOptions,
) {
return doWatch(
effect,
null,
__DEV__ ? extend({}, options as any, { flush: 'post' }) : { flush: 'post' },
)
}

export function watchSyncEffect(
effect: WatchEffect,
options?: DebuggerOptions,
) {
return doWatch(
effect,
null,
__DEV__ ? extend({}, options as any, { flush: 'sync' }) : { flush: 'sync' },
)
}

简易版实现原理:

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
if (isFunction(source)) {
// no cb -> simple effect
getter = () => {
if (cleanup) {
cleanup()
}
return callWithAsyncErrorHandling(
source,
instance,
ErrorCodes.WATCH_CALLBACK,
[onCleanup],
)
}
}

let scheduler: EffectScheduler
if (flush === 'sync') {
scheduler = job as any // the scheduler function gets called directly
} else if (flush === 'post') {
scheduler = () => queuePostRenderEffect(job, instance && instance.suspense)
} else {
// default: 'pre'
job.pre = true
if (instance) job.id = instance.uid
scheduler = () => queueJob(job)
}

const effect = new ReactiveEffect(getter, NOOP, scheduler)

// initial run
if (flush === 'post') {
queuePostRenderEffect(
effect.run.bind(effect),
instance && instance.suspense,
)
} else {
effect.run()
}

总结

以上就是计算属性computed和侦听器watch的实现原理,总的来说就是侦听器更适合用于在数据变化后执行某段逻辑的场景,而计算属性则用于一个数据依赖另外一些数据计算而来的场景。

文章里面涉及到任务队列异步更新的原理在这。

之前我们分析了 Composition API 的核心 setup 函数的实现,在 setup 函数中,我们多次使用一些 API 让数据变成响应式,那么这次我们就来深入学习和分析响应式内部的实现原理。

响应式它的本质是当数据变化后会自动执行某个函数,映射到组件的实现就是,当数据变化后,会自动触发组件的重新渲染。响应式是 Vue.js 组件化更新渲染的一个核心机制。

Vue.js 2.x的响应式:在内部通过 Object.defineProperty API 劫持数据的变化,在数据被访问的时候收集依赖,然后在数据被修改的时候通知依赖更新。我们用vue2官网的一张图可以直观地看清这个流程。

alt text

在 Vue.js 2.x 中,Watcher 就是依赖,有专门针对组件渲染的 render watcher。这里有两个流程,首先是依赖收集流程,组件在 render 的时候会访问模板中的数据,触发 getter 把 render watcher 作为依赖收集,并和数据建立联系;然后是派发通知流程,当我对这些数据修改的时候,会触发 setter,通知 render watcher 更新,进而触发了组件的重新渲染。

但是Object.defineProperty API 有一些缺点:包括不能监听对象属性新增和删除;初始化阶段递归执行 Object.defineProperty 带来的性能负担。

Vue.js 3.0 为了解决 Object.defineProperty 的这些缺陷,使用 Proxy API 重写了响应式部分,并独立维护和发布整个 reactivity 库,下面就来分析响应式部分的实现原理。

Reactive API

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
export function reactive(target: object) {
// if trying to observe a readonly proxy, return the readonly version.
// 如果尝试把一个 readonly proxy 变成响应式,直接返回这个 readonly proxy
if (isReadonly(target)) {
return target
}
return createReactiveObject(
target,
false,
mutableHandlers,
mutableCollectionHandlers,
reactiveMap,
)
}

function createReactiveObject(
target: Target,
isReadonly: boolean,
baseHandlers: ProxyHandler<any>,
collectionHandlers: ProxyHandler<any>,
proxyMap: WeakMap<Target, any>,
) {
// 目标必须是对象或数组类型
if (!isObject(target)) {
return target
}
// target is already a Proxy, return it.
// exception: calling readonly() on a reactive object
// target 已经是 Proxy 对象,直接返回
// 有个例外,如果是 readonly 作用于一个响应式对象,则继续
if (
target[ReactiveFlags.RAW] &&
!(isReadonly && target[ReactiveFlags.IS_REACTIVE])
) {
return target
}
// target already has corresponding Proxy
// target 已经有对应的 Proxy 了
const existingProxy = proxyMap.get(target)
if (existingProxy) {
return existingProxy
}
// only specific value types can be observed.
// 只有在白名单里的数据类型才能变成响应式
const targetType = getTargetType(target)
if (targetType === TargetType.INVALID) {
return target
}
// 利用 Proxy 创建响应式
const proxy = new Proxy(
target,
targetType === TargetType.COLLECTION ? collectionHandlers : baseHandlers,
)
proxyMap.set(target, proxy)
return proxy
}

可以看到,reactive 内部通过 createReactiveObject 函数把 target 变成了一个响应式对象。

在这个过程中,createReactiveObject 函数主要做了以下几件事情。

  1. 函数首先判断 target 是不是数组或者对象类型,如果不是则直接返回。所以原始数据 target 必须是对象或者数组。
  2. 如果对一个已经是响应式的对象再次执行 reactive,还应该返回这个响应式对象
  3. 如果对同一个原始数据多次执行 reactive ,那么会返回相同的响应式对象
  4. 只有在白名单里的数据类型才能变成响应式,比如,带有 __v_skip 属性的对象、被冻结的对象,以及不在白名单内的对象如 Date 类型的对象实例是不能变成响应式的。

接下来,我们继续看 Proxy 处理器对象 mutableHandlers 的实现:

1
2
3
4
5
6
7
const mutableHandlers = {
get,
set,
deleteProperty,
has,
ownKeys
}

它其实就是劫持了我们对 observed 对象的一些操作,比如:

访问对象属性会触发 get 函数;

设置对象属性会触发 set 函数;

删除对象属性会触发 deleteProperty 函数;

in 操作符会触发 has 函数;

通过 Object.getOwnPropertyNames 访问对象属性名会触发 ownKeys 函数。

因为无论命中哪个处理器函数,它都会做依赖收集和派发通知这两件事其中的一个,所以这里我只要分析常用的 get 和 set 函数就可以了。

依赖收集:get 函数

依赖收集发生在数据访问的阶段,由于我们用 Proxy API 劫持了数据对象,所以当这个响应式对象属性被访问的时候就会执行 get 函数,我们来看一下 get 函数的实现流程:

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
73
74
75
76
77
get(target: Target, key: string | symbol, receiver: object) {
const isReadonly = this._isReadonly,
shallow = this._shallow
if (key === ReactiveFlags.IS_REACTIVE) {
// 代理 __v_isReactive
return !isReadonly
} else if (key === ReactiveFlags.IS_READONLY) {
// 代理 __v_isReadonly
return isReadonly
} else if (key === ReactiveFlags.IS_SHALLOW) {
// 代理 __v_isShallow
return shallow
} else if (key === ReactiveFlags.RAW) {
// 函数中判断响应式对象是否存在 __v_raw 属性,如果存在就返回这个响应式对象本身。
if (
receiver ===
(isReadonly
? shallow
? shallowReadonlyMap
: readonlyMap
: shallow
? shallowReactiveMap
: reactiveMap
).get(target) ||
// receiver is not the reactive proxy, but has the same prototype
// this means the reciever is a user proxy of the reactive proxy
Object.getPrototypeOf(target) === Object.getPrototypeOf(receiver)
) {
return target
}
// early return undefined
return
}

// 是否是数组
const targetIsArray = isArray(target)

if (!isReadonly) {
// arrayInstrumentations 包含对数组一些方法修改的函数
if (targetIsArray && hasOwn(arrayInstrumentations, key)) {
return Reflect.get(arrayInstrumentations, key, receiver)
}
if (key === 'hasOwnProperty') {
return hasOwnProperty
}
}

const res = Reflect.get(target, key, receiver)
// 内置 Symbol key 不需要依赖收集
if (isSymbol(key) ? builtInSymbols.has(key) : isNonTrackableKeys(key)) {
return res
}

// 依赖收集
if (!isReadonly) {
track(target, TrackOpTypes.GET, key)
}

if (shallow) {
return res
}

if (isRef(res)) {
// ref unwrapping - skip unwrap for Array + integer key.
return targetIsArray && isIntegerKey(key) ? res : res.value
}

// 如果 res 是个对象或者数组类型,则递归执行 reactive 函数把 res 变成响应式
if (isObject(res)) {
// Convert returned value into a proxy as well. we do the isObject check
// here to avoid invalid value warning. Also need to lazy access readonly
// and reactive here to avoid circular dependency.
return isReadonly ? readonly(res) : reactive(res)
}

return res
}

结合上面代码来看,get 函数主要做了四件事情,首先对特殊的 key 做了代理,这就是为什么我们在 createReactiveObject 函数中判断响应式对象是否存在 __v_raw 属性,如果存在就返回这个响应式对象本身。

接着通过 Reflect.get 方法求值,如果 target 是数组且 key 命中了 arrayInstrumentations,则执行对应的函数,我们可以大概看一下 arrayInstrumentations 的实现:

arrayInstrumentations

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
const arrayInstrumentations = /*#__PURE__*/ createArrayInstrumentations()

function createArrayInstrumentations() {
const instrumentations: Record<string, Function> = {}
// instrument identity-sensitive Array methods to account for possible reactive
// values
;(['includes', 'indexOf', 'lastIndexOf'] as const).forEach(key => {
instrumentations[key] = function (this: unknown[], ...args: unknown[]) {
// toRaw 可以把响应式对象转成原始数据,this就是Reflect的receiver
const arr = toRaw(this) as any
for (let i = 0, l = this.length; i < l; i++) {
// 依赖收集
track(arr, TrackOpTypes.GET, i + '')
}
// we run the method using the original args first (which may be reactive)
const res = arr[key](...args)
if (res === -1 || res === false) {
// if that didn't work, run it again using raw values.
return arr[key](...args.map(toRaw))
} else {
return res
}
}
})
// instrument length-altering mutation methods to avoid length being tracked
// which leads to infinite loops in some cases (#2137)
;(['push', 'pop', 'shift', 'unshift', 'splice'] as const).forEach(key => {
instrumentations[key] = function (this: unknown[], ...args: unknown[]) {
pauseTracking()
pauseScheduling()
const res = (toRaw(this) as any)[key].apply(this, args)
resetScheduling()
resetTracking()
return res
}
})
return instrumentations
}

也就是说,当 target 是一个数组的时候,我们去访问 target.includes、target.indexOf、 target.lastIndexOf 就会执行 arrayInstrumentations 代理的函数,除了调用数组本身的方法求值外,还对数组每个元素做了依赖收集。因为一旦数组的元素被修改,数组的这几个 API 的返回结果都可能发生变化,所以我们需要跟踪数组每个元素的变化。

当调用 target.push、target.pop、target.shift、target.unshift、target.splice 时候,由于都会访问.length导致收集了数组的length,在某种场景下造成无限循环,看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
// https://github.com/vuejs/core/pull/2138

const arr = reactive([])

watchEffect(()=>{
arr.push(1)
})

watchEffect(()=>{
arr.push(2)
})

所以为避免此种情况发生,会暂停收集依赖,在执行完毕后,再恢复依赖收集。

回到 get 函数,第三步就是通过 Reflect.get 求值,然后会执行 track 函数收集依赖,我们稍后重点分析这个过程。

函数最后会对计算的值 res 进行判断,如果它也是数组或对象,则递归执行 reactive 把 res 变成响应式对象。这么做是因为 Proxy 劫持的是对象本身,并不能劫持子对象的变化,这点和 Object.defineProperty API 一致。但是 Object.defineProperty 是在初始化阶段,即定义劫持对象的时候就已经递归执行了,而 Proxy 是在对象属性被访问的时候才递归执行下一步 reactive,这其实是一种延时定义子对象响应式的实现,在性能上会有较大的提升。

整个 get 函数最核心的部分其实是执行 track 函数收集依赖,下面我们重点分析这个过程。

track

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
export function track(target: object, type: TrackOpTypes, key: unknown) {
// 是否应该收集依赖 和 当前激活的 effect
if (shouldTrack && activeEffect) {
// 每个 target 对应一个 depsMap
let depsMap = targetMap.get(target)
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()))
}
let dep = depsMap.get(key)
if (!dep) {
// 每个 key 对应一个 dep Map
depsMap.set(key, (dep = createDep(() => depsMap!.delete(key))))
}
trackEffect(
activeEffect,
dep,
__DEV__
? {
target,
type,
key,
}
: void 0,
)
}
}

export function trackEffect(
effect: ReactiveEffect,
dep: Dep,
debuggerEventExtraInfo?: DebuggerEventExtraInfo,
) {
if (dep.get(effect) !== effect._trackId) {
// 收集当前激活的 effect 作为依赖
dep.set(effect, effect._trackId)
const oldDep = effect.deps[effect._depsLength]
if (oldDep !== dep) {
if (oldDep) {
cleanupDepEffect(oldDep, effect)
}
// 当前激活的 effect 收集 dep 集合作为依赖
effect.deps[effect._depsLength++] = dep
} else {
effect._depsLength++
}
if (__DEV__) {
effect.onTrack?.(extend({ effect }, debuggerEventExtraInfo!))
}
}
}

分析track函数的实现前,我们先想一下要收集的依赖是什么,我们的目的是实现响应式,就是当数据变化的时候可以自动做一些事情,比如执行某些函数,所以我们收集的依赖就是数据变化后执行的副作用函数。

再来看实现,我们把 target 作为原始的数据,key 作为访问的属性。我们创建了全局的 targetMap 作为原始数据对象的 Map,它的键是 target,值是 depsMap,作为依赖的 Map;这个 depsMap 的键是 target 的 key,值是 dep 集合,dep 集合中存储的是依赖的副作用函数。为了方便理解,可以通过下图表示它们之间的关系:

../images/vue3响应式实现原理/image-20240316133230452

所以每次 track ,就是把当前激活的副作用函数 activeEffect 作为依赖,然后收集到 target 相关的 depsMap 对应 key 下的依赖集合 dep 中。

派发通知:set 函数

派发通知发生在数据更新的阶段 ,由于我们用 Proxy API 劫持了数据对象,所以当这个响应式对象属性更新的时候就会执行 set 函数。我们来看一下 set 函数的实现:

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
set(
target: object,
key: string | symbol,
value: unknown,
receiver: object,
) {
let oldValue = (target as any)[key]
if (!this._shallow) {
const isOldValueReadonly = isReadonly(oldValue)
if (!isShallow(value) && !isReadonly(value)) {
oldValue = toRaw(oldValue)
value = toRaw(value)
}
if (!isArray(target) && isRef(oldValue) && !isRef(value)) {
if (isOldValueReadonly) {
return false
} else {
oldValue.value = value
return true
}
}
} else {
// in shallow mode, objects are set as-is regardless of reactive or not
}

// 检查数组是否包含index或者对象是否有这个属性
const hadKey =
isArray(target) && isIntegerKey(key)
? Number(key) < target.length
: hasOwn(target, key)
const result = Reflect.set(target, key, value, receiver)
// don't trigger if target is something up in the prototype chain of original
// 如果目标的原型链也是一个 proxy,通过 Reflect.set 修改原型链上的属性会再次触发 setter,这种情况下就没必要触发两次 trigger 了
if (target === toRaw(receiver)) {
if (!hadKey) {
trigger(target, TriggerOpTypes.ADD, key, value)
} else if (hasChanged(value, oldValue)) {
trigger(target, TriggerOpTypes.SET, key, value, oldValue)
}
}
return result
}

结合上述代码来看,set 函数的实现逻辑很简单,主要就做两件事情, 首先通过 Reflect.set 求值 , 然后通过 trigger 函数派发通知 ,并依据 key 是否存在于 target 上来确定通知类型,即新增还是修改。

整个 set 函数最核心的部分就是 执行 trigger 函数派发通知 ,下面我们将重点分析这个过程。

我们先来看一下 trigger 函数的实现:

trigger

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
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
export function trigger(
target: object,
type: TriggerOpTypes,
key?: unknown,
newValue?: unknown,
oldValue?: unknown,
oldTarget?: Map<unknown, unknown> | Set<unknown>,
) {
// 通过 targetMap 拿到 target 对应的依赖集合
const depsMap = targetMap.get(target)
if (!depsMap) {
// 没有依赖,直接返回
return
}

// 创建要执行的deps数组
let deps: (Dep | undefined)[] = []
if (type === TriggerOpTypes.CLEAR) {
// collection being cleared
// trigger all effects for target
// 清空数组或者map的时候触发所有key对应的的effect函数
deps = [...depsMap.values()]
} else if (key === 'length' && isArray(target)) {
const newLength = Number(newValue)
depsMap.forEach((dep, key) => {
if (key === 'length' || (!isSymbol(key) && key >= newLength)) {
deps.push(dep)
}
})
} else {
// schedule runs for SET | ADD | DELETE
// set add delete操作 将key对应的effect函数添加到deps数组中
if (key !== void 0) {
deps.push(depsMap.get(key))
}

// also run for iteration key on ADD | DELETE | Map.SET
// 根据不同的操作类型push对应的dep
switch (type) {
case TriggerOpTypes.ADD:
if (!isArray(target)) {
deps.push(depsMap.get(ITERATE_KEY))
if (isMap(target)) {
deps.push(depsMap.get(MAP_KEY_ITERATE_KEY))
}
} else if (isIntegerKey(key)) {
// new index added to array -> length changes
deps.push(depsMap.get('length'))
}
break
case TriggerOpTypes.DELETE:
if (!isArray(target)) {
deps.push(depsMap.get(ITERATE_KEY))
if (isMap(target)) {
deps.push(depsMap.get(MAP_KEY_ITERATE_KEY))
}
}
break
case TriggerOpTypes.SET:
if (isMap(target)) {
deps.push(depsMap.get(ITERATE_KEY))
}
break
}
}

pauseScheduling()
for (const dep of deps) {
if (dep) {
triggerEffects(
dep,
DirtyLevels.Dirty,
__DEV__
? {
target,
type,
key,
newValue,
oldValue,
oldTarget,
}
: void 0,
)
}
}
resetScheduling()
}
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
export function triggerEffects(
dep: Dep,
dirtyLevel: DirtyLevels,
debuggerEventExtraInfo?: DebuggerEventExtraInfo,
) {
pauseScheduling()
// 执行triggerEffects,执行dep里所有的effect
for (const effect of dep.keys()) {
if (
effect._dirtyLevel < dirtyLevel &&
dep.get(effect) === effect._trackId
) {
const lastDirtyLevel = effect._dirtyLevel
effect._dirtyLevel = dirtyLevel
if (lastDirtyLevel === DirtyLevels.NotDirty) {
effect._shouldSchedule = true
if (__DEV__) {
effect.onTrigger?.(extend({ effect }, debuggerEventExtraInfo))
}
effect.trigger()
}
}
}
scheduleEffects(dep)
resetScheduling()
}

所以每次 trigger 函数就是根据 target 和 key ,从 targetMap 中找到相关的所有副作用函数遍历执行一遍。

ref API

通过前面的分析,我们知道 reactive API 对传入的 target 类型有限制,必须是对象或者数组类型,而对于一些基础类型(比如 String、Number、Boolean)是不支持的。

但是有时候从需求上来说,可能我只希望把一个字符串变成响应式,却不得不封装成一个对象,这样使用上多少有一些不方便,于是 Vue.js 3.0 设计并实现了 ref API。

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
export function ref(value?: unknown) {
return createRef(value, false)
}

function createRef(rawValue: unknown, shallow: boolean) {
// 如果传入的就是一个 ref,那么返回自身即可,处理嵌套 ref 的情况。
if (isRef(rawValue)) {
return rawValue
}
return new RefImpl(rawValue, shallow)
}

class RefImpl<T> {
private _value: T
private _rawValue: T

public dep?: Dep = undefined
public readonly __v_isRef = true

constructor(
value: T,
public readonly __v_isShallow: boolean,
) {
this._rawValue = __v_isShallow ? value : toRaw(value)
this._value = __v_isShallow ? value : toReactive(value)
}

get value() {
// 依赖收集,key 为固定的 value
trackRefValue(this)
return this._value
}

set value(newVal) {
const useDirectValue = this.__v_isShallow || isShallow(newVal) || isReadonly(newVal)
newVal = useDirectValue ? newVal : toRaw(newVal)
if (hasChanged(newVal, this._rawValue)) {
// 判断有变化后更新值
this._rawValue = newVal
this._value = useDirectValue ? newVal : toReactive(newVal)
// 派发通知
triggerRefValue(this, DirtyLevels.Dirty, newVal)
}
}
}

可以看到,函数首先处理了嵌套 ref 的情况,如果传入的 rawValue 也是 ref,那么直接返回。

接着对 rawValue 做了一层转换,如果 rawValue 是对象或者数组类型,那么把它转换成一个 reactive 对象。

最后定义一个对 value 属性做 getter 和 setter 劫持的对象并返回,get 部分就是执行 track 函数做依赖收集然后返回它的值;set 部分就是设置新值并且执行 trigger 函数派发通知。

总结

最后通过一张图来看一下响应式的工作流程。

alt text

Vue.js 3.0 在响应式的实现思路和 Vue.js 2.x 差别并不大,主要就是 劫持数据的方式改成用 Proxy 实现 , 以及收集的依赖由 watcher 实例变成了组件副作用渲染函数

创建和设置组件实例

首先,我们来回顾一下组件的渲染流程:创建 vnode 、渲染 vnode 和生成 DOM。

其中渲染 vnode 的过程主要就是在挂载组件:

1
2
3
4
5
6
7
8
const mountComponent = (initialVNode, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
// 创建组件实例
const instance = (initialVNode.component = createComponentInstance(initialVNode, parentComponent, parentSuspense))
// 设置组件实例
setupComponent(instance)
// 设置并运行带副作用的渲染函数
setupRenderEffect(instance, initialVNode, container, anchor, parentSuspense, isSVG, optimized)
}

可以看到,这段挂载组件的代码主要做了三件事情:创建组件实例、设置组件实例和设置并运行带副作用的渲染函数。前面的文章没有仔细分析,所以我们重点分析前两个流程。

先看创建组件实例的流程,我们要关注 createComponentInstance 方法的实现:

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
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
function createComponentInstance (vnode, parent, suspense) {
// 继承父组件实例上的 appContext,如果是根组件,则直接从根 vnode 中取。
const appContext = (parent ? parent.appContext : vnode.appContext) || emptyAppContext;
const instance = {
// 组件唯一 id
uid: uid++,
// 组件 vnode
vnode,
// 父组件实例
parent,
// app 上下文
appContext,
// vnode 节点类型
type: vnode.type,
// 根组件实例
root: null,
// 新的组件 vnode
next: null,
// 子节点 vnode
subTree: null,
// 带副作用更新函数
update: null,
// 渲染函数
render: null,
// 渲染上下文代理
proxy: null,
// 带有 with 区块的渲染上下文代理
withProxy: null,
// 响应式相关对象
effects: null,
// 依赖注入相关
provides: parent ? parent.provides : Object.create(appContext.provides),
// 渲染代理的属性访问缓存
accessCache: null,
// 渲染缓存
renderCache: [],
// 渲染上下文
ctx: EMPTY_OBJ,
// data 数据
data: EMPTY_OBJ,
// props 数据
props: EMPTY_OBJ,
// 普通属性
attrs: EMPTY_OBJ,
// 插槽相关
slots: EMPTY_OBJ,
// 组件或者 DOM 的 ref 引用
refs: EMPTY_OBJ,
// setup 函数返回的响应式结果
setupState: EMPTY_OBJ,
// setup 函数上下文数据
setupContext: null,
// 注册的组件
components: Object.create(appContext.components),
// 注册的指令
directives: Object.create(appContext.directives),
// suspense 相关
suspense,
// suspense 异步依赖
asyncDep: null,
// suspense 异步依赖是否都已处理
asyncResolved: false,
// 是否挂载
isMounted: false,
// 是否卸载
isUnmounted: false,
// 是否激活
isDeactivated: false,
// 生命周期,before create
bc: null,
// 生命周期,created
c: null,
// 生命周期,before mount
bm: null,
// 生命周期,mounted
m: null,
// 生命周期,before update
bu: null,
// 生命周期,updated
u: null,
// 生命周期,unmounted
um: null,
// 生命周期,before unmount
bum: null,
// 生命周期, deactivate
da: null
// 生命周期 activated
a: null,
// 生命周期 render triggered
rtg: null,
// 生命周期 render tracked
rtc: null,
// 生命周期 error captured
ec: null,
// 派发事件方法
emit: null
}
// 初始化渲染上下文
instance.ctx = { _: instance }
// 初始化根组件指针
instance.root = parent ? parent.root : instance
// 初始化派发事件方法
instance.emit = emit.bind(null, instance)
return instance
}

创建好 instance 实例后,接下来就是设置它的一些属性。目前已完成了组件的上下文、根组件指针以及派发事件方法的设置。

接着是组件实例的设置流程,对 setup 函数的处理就在这里完成,我们来看一下 setupComponent 方法的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function setupComponent (instance, isSSR = false) {
const { props, children, shapeFlag } = instance.vnode
// 判断是否是一个有状态的组件
const isStateful = shapeFlag & 4
// 初始化 props
initProps(instance, props, isStateful, isSSR)
// 初始化 插槽
initSlots(instance, children)
// 设置有状态的组件实例
const setupResult = isStateful
? setupStatefulComponent(instance, isSSR)
: undefined
return setupResult
}

可以看到,我们从组件 vnode 中获取了 props、children、shapeFlag 等属性,然后分别对 props 和插槽进行初始化。

根据 shapeFlag 的值,我们可以判断这是不是一个有状态组件,如果是则要进一步去设置有状态组件的实例。

接下来我们要关注到 setupStatefulComponent 函数,它主要做了三件事:创建渲染上下文代理、判断处理 setup 函数和完成组件实例设置。它代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function setupStatefulComponent (instance, isSSR) {
const Component = instance.type
// 创建渲染代理的属性访问缓存
instance.accessCache = {}
// 1.创建渲染上下文代理
instance.proxy = new Proxy(instance.ctx, PublicInstanceProxyHandlers)
// 2.判断处理 setup 函数
const { setup } = Component
if (setup) {
// 如果 setup 函数带参数,则创建一个 setupContext
const setupContext = (instance.setupContext =
setup.length > 1 ? createSetupContext(instance) : null)
// 执行 setup 函数,获取结果
const setupResult = callWithErrorHandling(setup, instance, 0 /* SETUP_FUNCTION */, [instance.props, setupContext])
// 处理 setup 执行结果
handleSetupResult(instance, setupResult)
}
else {
// 完成组件实例设置
finishComponentSetup(instance)
}
}

创建渲染上下文代理

首先是创建渲染上下文代理的流程,它主要对 instance.ctx 做了代理。

在 Vue.js 2.x 中,也有类似的数据代理逻辑,比如 props 求值后的数据,实际上存储在 this._props 上,而 data 中定义的数据存储在 this._data 上。举个例子:

1
2
3
4
5
6
7
8
9
10
<template>
<p>{{ msg }}</p>
</template>
<script>
export default {
data() {
msg: 1
}
}
</script>

在初始化组件的时候,data 中定义的 msg 在组件内部是存储在 this._data 上的,而模板渲染的时候访问 this.msg,实际上访问的是 this._data.msg,这是因为 Vue.js 2.x 在初始化 data 的时候,做了一层 proxy 代理。

到了 Vue.js 3.0,为了方便维护,我们把组件中不同状态的数据存储到不同的属性中,比如存储到 setupState、ctx、data、props 中。我们在执行组件渲染函数的时候,为了方便用户使用,会直接访问渲染上下文 instance.ctx 中的属性,所以我们也要做一层 proxy,对渲染上下文 instance.ctx 属性的访问和修改,代理到对 setupState、ctx、data、props 中的数据的访问和修改。

明确了代理的需求后,我们接下来就要分析 proxy 的几个方法: get、set 和 has。

当我们访问 instance.ctx 渲染上下文中的属性时,就会进入 get 函数。我们来看一下它的实现:

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
73
74
75
76
77
78
79
80
81
82
83
84
85
const PublicInstanceProxyHandlers = {
get ({ _: instance }, key) {
const { ctx, setupState, data, props, accessCache, type, appContext } = instance
if (key[0] !== '$') {
// setupState / data / props / ctx
// 渲染代理的属性访问缓存中
const n = accessCache[key]
if (n !== undefined) {
// 从缓存中取
switch (n) {
case 0: /* SETUP */
return setupState[key]
case 1 :/* DATA */
return data[key]
case 3 :/* CONTEXT */
return ctx[key]
case 2: /* PROPS */
return props[key]
}
}
else if (setupState !== EMPTY_OBJ && hasOwn(setupState, key)) {
accessCache[key] = 0
// 从 setupState 中取数据
return setupState[key]
}
else if (data !== EMPTY_OBJ && hasOwn(data, key)) {
accessCache[key] = 1
// 从 data 中取数据
return data[key]
}
else if (
type.props &&
hasOwn(normalizePropsOptions(type.props)[0], key)) {
accessCache[key] = 2
// 从 props 中取数据
return props[key]
}
else if (ctx !== EMPTY_OBJ && hasOwn(ctx, key)) {
accessCache[key] = 3
// 从 ctx 中取数据
return ctx[key]
}
else {
// 都取不到
accessCache[key] = 4
}
}
const publicGetter = publicPropertiesMap[key]
let cssModule, globalProperties
// 公开的 $xxx 属性或方法
if (publicGetter) {
return publicGetter(instance)
}
else if (
// css 模块,通过 vue-loader 编译的时候注入
(cssModule = type.__cssModules) &&
(cssModule = cssModule[key])) {
return cssModule
}
else if (ctx !== EMPTY_OBJ && hasOwn(ctx, key)) {
// 用户自定义的属性,也用 `$` 开头
accessCache[key] = 3
return ctx[key]
}
else if (
// 全局定义的属性
((globalProperties = appContext.config.globalProperties),
hasOwn(globalProperties, key))) {
return globalProperties[key]
}
else if ((process.env.NODE_ENV !== 'production') &&
currentRenderingInstance && key.indexOf('__v') !== 0) {
if (data !== EMPTY_OBJ && key[0] === '$' && hasOwn(data, key)) {
// 如果在 data 中定义的数据以 $ 开头,会报警告,因为 $ 是保留字符,不会做代理
warn(`Property ${JSON.stringify(key)} must be accessed via $data because it starts with a reserved ` +
`character and is not proxied on the render context.`)
}
else {
// 在模板中使用的变量如果没有定义,报警告
warn(`Property ${JSON.stringify(key)} was accessed during render ` +
`but is not defined on instance.`)
}
}
}
}

可以看到,函数首先判断 key 不以 $ 开头的情况,这部分数据可能是 setupState、data、props、ctx 中的一种,setupState 就是 setup 函数返回的数据,ctx 包括了计算属性、组件方法和用户自定义的一些数据。

如果 key 不以 $ 开头,那么就依次判断 setupState、data、props、ctx 中是否包含这个 key,如果包含就返回对应值。注意这个判断顺序很重要,在 key 相同时它会决定数据获取的优先级。

再回到 get 函数中,我们可以看到这里定义了 accessCache 作为渲染代理的属性访问缓存,它具体是干什么的呢?组件在渲染时会经常访问数据进而触发 get 函数,这其中最昂贵的部分就是多次调用 hasOwn 去判断 key 在不在某个类型的数据中,但是在普通对象上执行简单的属性访问相对要快得多。所以在第一次获取 key 对应的数据后,我们利用 accessCache[key] 去缓存数据,下一次再次根据 key 查找数据,我们就可以直接通过 accessCache[key] 获取对应的值,就不需要依次调用 hasOwn 去判断了。这也是一个性能优化的小技巧。

如果 key 以 $ 开头,那么接下来又会有一系列的判断,首先判断是不是 Vue.js 内部公开的 $xxx 属性或方法(比如 $parent);然后判断是不是 vue-loader 编译注入的 css 模块内部的 key;接着判断是不是用户自定义以 $ 开头的 key;最后判断是不是全局属性。如果都不满足,就剩两种情况了,即在非生产环境下就会报两种类型的警告,第一种是在 data 中定义的数据以 $ 开头的警告,因为 $ 是保留字符,不会做代理;第二种是在模板中使用的变量没有定义的警告。

接下来是 set 代理过程,当我们修改 instance.ctx 渲染上下文中的属性的时候,就会进入 set 函数。我们来看一下 set 函数的实现:

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
const PublicInstanceProxyHandlers = {
set ({ _: instance }, key, value) {
const { data, setupState, ctx } = instance
if (setupState !== EMPTY_OBJ && hasOwn(setupState, key)) {
// 给 setupState 赋值
setupState[key] = value
}
else if (data !== EMPTY_OBJ && hasOwn(data, key)) {
// 给 data 赋值
data[key] = value
}
else if (key in instance.props) {
// 不能直接给 props 赋值
(process.env.NODE_ENV !== 'production') &&
warn(`Attempting to mutate prop "${key}". Props are readonly.`, instance)
return false
}
if (key[0] === '$' && key.slice(1) in instance) {
// 不能给 Vue 内部以 $ 开头的保留属性赋值
(process.env.NODE_ENV !== 'production') &&
warn(`Attempting to mutate public property "${key}". ` +
`Properties starting with $ are reserved and readonly.`, instance)
return false
}
else {
// 用户自定义数据赋值
ctx[key] = value
}
return true
}
}

结合代码来看,函数主要做的事情就是对渲染上下文 instance.ctx 中的属性赋值,它实际上是代理到对应的数据类型中去完成赋值操作的。这里仍然要注意顺序问题,和 get 一样,优先判断 setupState,然后是 data,接着是 props。

最后是 has 代理过程,当我们判断属性是否存在于 instance.ctx 渲染上下文中时,就会进入 has 函数,这个在平时项目中用的比较少,同样来举个例子,当执行 created 钩子函数中的 ‘msg’ in this 时,就会触发 has 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
const PublicInstanceProxyHandlers = {
has
({ _: { data, setupState, accessCache, ctx, type, appContext } }, key) {
// 依次判断
return (accessCache[key] !== undefined ||
(data !== EMPTY_OBJ && hasOwn(data, key)) ||
(setupState !== EMPTY_OBJ && hasOwn(setupState, key)) ||
(type.props && hasOwn(normalizePropsOptions(type.props)[0], key)) ||
hasOwn(ctx, key) ||
hasOwn(publicPropertiesMap, key) ||
hasOwn(appContext.config.globalProperties, key))
}
}

这个函数的实现很简单,依次判断 key 是否存在于 accessCache、data、setupState、props 、用户数据、公开属性以及全局属性中,然后返回结果。

至此,我们就分析完创建上下文代理的过程,让我们回到 setupStatefulComponent 函数中,接下来分析第二个流程——判断处理 setup 函数。

判断处理 setup 函数

我们看一下整个逻辑涉及的代码:

1
2
3
4
5
6
7
8
9
10
11
// 判断处理 setup 函数
const { setup } = Component
if (setup) {
// 如果 setup 函数带参数,则创建一个 setupContext
const setupContext = (instance.setupContext =
setup.length > 1 ? createSetupContext(instance) : null)
// 执行 setup 函数获取结果
const setupResult = callWithErrorHandling(setup, instance, 0 /* SETUP_FUNCTION */, [instance.props, setupContext])
// 处理 setup 执行结果
handleSetupResult(instance, setupResult)
}

如果我们在组件中定义了 setup 函数,接下来就是处理 setup 函数的流程,主要是三个步骤:创建 setup 函数上下文、执行 setup 函数并获取结果和处理 setup 函数的执行结果。接下来我们就逐个来分析。

首先判断 setup 函数的参数长度,如果大于 1,则创建 setupContext 上下文。

1
2
3
4
5
6
7
8
9
10
function createSetupContext(instance) {
return {
get attrs() {
return getAttrsProxy(instance)
},
slots: instance.slots,
emit: instance.emit,
expose,
}
}

这里返回了一个对象,包括 attrs、slots 和 emit 三个属性。setupContext 让我们在 setup 函数内部可以获取到组件的属性、插槽以及派发事件的方法 emit。

可以预见的是,这个 setupContext 对应的就是 setup 函数第二个参数,我们接下来看一下 setup 函数具体是如何执行的。

我们通过callWithErrorHandling来执行 setup 函数并获取结果,具体来看一下 callWithErrorHandling 函数的实现:

1
2
3
4
5
6
7
8
9
10
function callWithErrorHandling (fn, instance, type, args) {
let res
try {
res = args ? fn(...args) : fn()
}
catch (err) {
handleError(err, instance, type)
}
return res
}

可以看到,它其实就是对 fn 做的一层包装,内部还是执行了 fn,并在有参数的时候传入参数,所以 setup 的第一个参数是 instance.props,第二个参数是 setupContext。函数执行过程中如果有 JavaScript 执行错误就会捕获错误,并执行 handleError 函数来处理。

执行 setup 函数并拿到了返回的结果,那么接下来就要用 handleSetupResult 函数来处理结果。

我们详细看一下 handleSetupResult 函数的实现:

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
function handleSetupResult(instance, setupResult) {
if (isFunction(setupResult)) {
// setup 返回渲染函数
instance.render = setupResult
}
else if (isObject(setupResult)) {
// 把 setup 返回结果变成响应式
instance.setupState = proxyRefs(setupResult)
}
finishComponentSetup(instance)
}

export function proxyRefs<T extends object>(
objectWithRefs: T,
): ShallowUnwrapRef<T> {
return isReactive(objectWithRefs)
? objectWithRefs
: new Proxy(objectWithRefs, shallowUnwrapHandlers)
}

const shallowUnwrapHandlers: ProxyHandler<any> = {
get: (target, key, receiver) => unref(Reflect.get(target, key, receiver)),
set: (target, key, value, receiver) => {
const oldValue = target[key]
if (isRef(oldValue) && !isRef(value)) {
oldValue.value = value
return true
} else {
return Reflect.set(target, key, value, receiver)
}
},
}

可以看到,当 setupResult 是一个对象的时候,我们把它变成了响应式并赋值给 instance.setupState,这样在模板渲染的时候,依据前面的代理规则,instance.ctx 就可以从 instance.setupState 上获取到对应的数据,这就在 setup 函数与模板渲染间建立了联系。

另外 setup 不仅仅支持返回一个对象,也可以返回一个函数作为组件的渲染函数。

在 handleSetupResult 的最后,会执行 finishComponentSetup 函数完成组件实例的设置,其实这个函数和 setup 函数的执行结果已经没什么关系了,提取到外面放在 handleSetupResult 函数后面执行更合理一些。

另外当组件没有定义的 setup 的时候,也会执行 finishComponentSetup 函数去完成组件实例的设置。

完成组件实例设置

接下来我们来看一下 finishComponentSetup 函数的实现:

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
export function finishComponentSetup(
instance: ComponentInternalInstance,
isSSR: boolean,
skipOptions?: boolean,
) {
const Component = instance.type as ComponentOptions

// template / render function normalization
// could be already set when returned from setup()
if (!instance.render) {
// only do on-the-fly compile if not in SSR - SSR on-the-fly compilation
// is done by server-renderer
if (!isSSR && compile && !Component.render) {
// 获取模板
const template =
(__COMPAT__ &&
instance.vnode.props &&
instance.vnode.props['inline-template']) ||
Component.template ||
resolveMergedOptions(instance).template
if (template) {
const { isCustomElement, compilerOptions } = instance.appContext.config
const { delimiters, compilerOptions: componentCompilerOptions } =
Component
// 合并最终编译选项
const finalCompilerOptions: CompilerOptions = extend(
extend(
{
isCustomElement,
delimiters,
},
compilerOptions,
),
componentCompilerOptions,
)
// 编译模板,生成render
Component.render = compile(template, finalCompilerOptions)
}
}
// 对于使用 with 块运行时编译的渲染函数,配置渲染上下文的代理RuntimeCompiledPublicInstanceProxyHandlers,是在之前渲染上下文代理 PublicInstanceProxyHandlers 的基础上进行的扩展,主要对has做处理优化。
if (installWithProxy) {
installWithProxy(instance)
}
}

// support for 2.x options
if (__FEATURE_OPTIONS_API__ && !(__COMPAT__ && skipOptions)) {
const reset = setCurrentInstance(instance)
pauseTracking()
try {
applyOptions(instance)
} finally {
resetTracking()
reset()
}
}
}

组件最终通过运行 render 函数生成子树 vnode,但是我们很少直接去编写 render 函数,通常会使用两种方式开发组件。

一个是通过编写组件的 template 模板去描述一个组件的 DOM 结构,或者是使用直接在组件的 render 方法内调用 h 方法来创建 vnode。

所以这里首先判断了组件的 render 函数是否存在,如果不存在说明使用的是 template 模版。

对于使用 with 块运行时编译的渲染函数,配置渲染上下文的代理RuntimeCompiledPublicInstanceProxyHandlers,基于PublicInstanceProxyHandlers,主要对has做处理

组件实例设置的最后一个流程——兼容 Vue.js 2.x 的 Options API。我们知道 Vue.js 2.x 是通过组件对象的方式去描述一个组件,Vue.js 3.0 仍然支持 Vue.js 2.x Options API 的写法,这主要就是通过 applyOptions方法实现的。

总结

alt text

这次主要详细分析了组件的初始化流程,主要包括创建组件实例和设置组件实例。通过进一步细节的深入,我们也了解了渲染上下文的代理过程;了解了 Composition API 中的 setup 启动函数执行的时机,以及如何建立 setup 返回结果和模板渲染之间的联系;了解了组件定义的模板或者渲染函数的标准化过程;了解了如何兼容 Vue.js 2.x 的 Options API。

前言

Vue3 官网中有下面这样一张图,基本展现出了 Vue3 的渲染原理:

alt text

本文会从源码角度来简单的看一下 Vue3 的运行全流程,旨在加深对上图的理解。

初始化渲染

从下面这个很简单的使用示例开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { createApp, ref } from 'vue'

createApp({
template: `
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
</div>
`,
setup() {
return {
count: ref(0),
}
},
}).mount('#app')

首先调用了 createApp 方法:

1
2
3
4
5
export const createApp = ((...args) => {
const app = ensureRenderer().createApp(...args)
// ...
return app
})

我们可以看到重点在于 ensureRenderer

1
2
3
4
5
6
7
8
9
const rendererOptions = /*#__PURE__*/ extend({ patchProp }, nodeOps)
// 延时创建渲染器,当用户只依赖响应式包的时候,可以通过 tree-shaking 移除核心渲染逻辑相关的代码
// 因为 ensureRenderer 是在执行 createApp 的时候调用的,如果你不执行 createApp 而只使用 vue 的一些响应式 API,就不会创建这个渲染器,所以说延时渲染。
function ensureRenderer() {
return (
renderer ||
(renderer = createRenderer<Node, Element | ShadowRoot>(rendererOptions))
)
}

rendererOptions是一个对象,渲染相关的一些配置,比如更新属性的方法,操作 DOM 的方法。

这么做主要是方便跨平台,比如在其他非浏览器环境,可以替换成对应的节点操作方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// rendererOptions
{
insert: (child, parent, anchor) => {
parent.insertBefore(child, anchor || null)
},

remove: child => {
const parent = child.parentNode
if (parent) {
parent.removeChild(child)
}
},
// ...
}
1
2
3
function createRenderer(options: RendererOptions) {
return baseCreateRenderer(options)
}

调用 baseCreateRenderer, 这个函数简直可以用庞大来形容,patch、mount、diff均在这个方法中实现,回头我们再来细看实现,现在我们只需要关心他最后返回的什么

1
2
3
4
5
6
7
8
9
10
11
function baseCreateRenderer(
options: RendererOptions,
createHydrationFns?: typeof createHydrationFunctions,
) {
// ...此处省略2000行
return {
render,
hydrate,
createApp: createAppAPI(render, hydrate),
}
}

我们发现createApp是通过createAppAPI方法调用返回的,下面我们看createAppAPI。

createAppAPI

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
73
74
75
76
77
78
79
export function createAppAPI<HostElement>(
render: RootRenderFunction<HostElement>,
hydrate?: RootHydrateFunction,
): CreateAppFunction<HostElement> {
return function createApp(rootComponent, rootProps = null) {
if (!isFunction(rootComponent)) {
rootComponent = extend({}, rootComponent)
}

if (rootProps != null && !isObject(rootProps)) {
__DEV__ && warn(`root props passed to app.mount() must be an object.`)
rootProps = null
}

const context = createAppContext()
const installedPlugins = new WeakSet()

let isMounted = false

const app: App = (context.app = {
_uid: uid++,
_component: rootComponent as ConcreteComponent,
_props: rootProps,
_container: null,
_context: context,
_instance: null,

version,

get config() {
return context.config
},

set config(v) {
if (__DEV__) {
warn(
`app.config cannot be replaced. Modify individual options instead.`,
)
}
},
// 都是一些眼熟的方法
// 注册plugin
use(plugin: Plugin, ...options: any[]) {
//...
},
// 全局混入
mixin(mixin: ComponentOptions) {
//...
},
// 注册全局组件
component(name: string, component?: Component): any {
//...
},
// 注册全局指令
directive(name: string, directive?: Directive) {
//...
},
// 挂载根组件
mount(
rootContainer: HostElement, // 挂载容器
isHydrate?: boolean,
namespace?: boolean | ElementNamespace,
): any {
//...
},
// 卸载
unmount() {
//...
},
// 分享数据
provide(key, value) {
//...
}
//...
})
//...
return app
}
}

这个就是最终的 createApp方法,接受了 rootComponent 和 rootProps 两个参数,我们在应用层面执行 createApp(App) 方法时,会把 App 组件对象作为根组件传递给 rootComponent。这样,createApp 内部就创建了一个 app 对象。

所谓的应用实例app其实就是一个对象,我们传进去的组件选项作为根组件存储在_component属性上,另外还可以看到应用实例提供的一些方法,比如注册插件的use方法,挂载实例的mount方法等。

createAppContext 实现

context其实也是一个普通对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export function createAppContext(): AppContext {
return {
app: null as any,
config: {
isNativeTag: NO,
performance: false,
globalProperties: {},
optionMergeStrategies: {},
errorHandler: undefined,
warnHandler: undefined,
compilerOptions: {},
},
mixins: [],
components: {},
directives: {},
provides: Object.create(null),
optionsCache: new WeakMap(),
propsCache: new WeakMap(),
emitsCache: new WeakMap(),
}
}

这部分是vue的初次渲染逻辑,首先官方解构了mount方法, 然后又重写了app.mount,并调用normalizeContainer校验挂载元素,临时保存了需要渲染的内容。并对vue2的写法做了兼容处理,挂载元素进行渲染。

在以上整个 app 对象创建过程中,Vue.js 利用闭包和函数柯里化的技巧,很好地实现了参数保留。比如,在执行 app.mount 的时候,并不需要传入渲染器 render,这是因为在执行 createAppAPI 的时候渲染器 render 参数已经被保留下来了。

至此,createApp的流程大概到此结束,下一部分来分析mount挂载部分。

mounted挂载

重写app.mount方法

createApp 函数中,首先取出 app 对象中的 mount 函数,然后通过 app.mount = () => {} 对 mount 函数进行重写:

  1. 首先调用 normalizeContainer 函数来获取container节点;
  2. 判断该节点是否存在,若不存在,则直接返回;
  3. 清空container的innerHTML;
  4. 调用mount函数。
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
export const createApp = ((...args) => {
const app = ensureRenderer().createApp(...args)

const { mount } = app

/*
(1)这里重写的目的是考虑到跨平台(app.mount里面只包含和平台无关的代码)
(2)这些重写的代码是完善web平台下的渲染逻辑(比如其他平台也可以进行类似的重写)
*/

app.mount = (containerOrSelector: Element | ShadowRoot | string): any => {
// 根组件容器 选择器字符串兼容
const container = normalizeContainer(containerOrSelector)
if (!container) return
// rootComponent
const component = app._component
// 如组件对象没有定义 render 函数和 template 模板,则取容器的 innerHTML 作为组件模板内容
if (!isFunction(component) && !component.render && !component.template) {
// __UNSAFE__
// Reason: potential execution of JS expressions in in-DOM template.
// The user must make sure the in-DOM template is trusted. If it's
// rendered by the server, the template should not contain any user data.
component.template = container.innerHTML
// 2.x compat check
/**
* __COMPAT__ 是启动的时候通过rollup去注入进去的
* 用来判断是否向下兼容
*/
if (__COMPAT__ && __DEV__) {
for (let i = 0; i < container.attributes.length; i++) {
const attr = container.attributes[i]
if (attr.name !== 'v-cloak' && /^(v-|:|@)/.test(attr.name)) {
compatUtils.warnDeprecation(
DeprecationTypes.GLOBAL_MOUNT_CONTAINER,
null,
)
break
}
}
}
}

// clear content before mounting
container.innerHTML = ''
// 调用初始app.mount,挂载元素进行渲染
const proxy = mount(container, false, resolveRootNamespace(container))
if (container instanceof Element) {
// vue2的话,会给#app设置一个v-cloak属性,在render的时候清空掉
container.removeAttribute('v-cloak')
container.setAttribute('data-v-app', '')
}
return proxy
}

return app
})

首先是通过 normalizeContainer 标准化容器(这里可以传字符串选择器或者 DOM 对象,但如果是字符串选择器,就需要把它转成 DOM 对象,作为最终挂载的容器),然后做一个 if 判断,如果组件对象没有定义 render 函数和 template 模板,则取容器的 innerHTML 作为组件模板内容;接着在挂载前清空容器内容,最终再调用 app.mount 的方法走标准的组件渲染流程。

在这里,重写的逻辑都是和 Web 平台相关的,所以要放在外部实现。此外,这么做的目的是既能让用户在使用 API 时可以更加灵活,也兼容了 Vue.js 2.x 的写法,比如 app.mount 的第一个参数就同时支持选择器字符串和 DOM 对象两种类型。

从 app.mount 开始,才算真正进入组件渲染流程。

真正的挂载mounted

核心流程:

  1. 根据传入的根组件App创建vnode
  2. 渲染vnode。

mount方法

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
mount(
rootContainer: HostElement,
isHydrate?: boolean,
namespace?: boolean | ElementNamespace,
) {
if (!isMounted) {
// #5571
if (__DEV__ && (rootContainer as any).__vue_app__) {
warn(
`There is already an app instance mounted on the host container.\n` +
` If you want to mount another app on the same host container,` +
` you need to unmount the previous app by calling \`app.unmount()\` first.`,
)
}
const vnode = createVNode(rootComponent, rootProps)
// store app context on the root VNode.
// this will be set on the root instance on initial mount.
vnode.appContext = context

if (namespace === true) {
namespace = 'svg'
} else if (namespace === false) {
namespace = undefined
}

// HMR root reload
if (__DEV__) {
context.reload = () => {
// casting to ElementNamespace because TS doesn't guarantee type narrowing
// over function boundaries
render(
cloneVNode(vnode),
rootContainer,
namespace as ElementNamespace,
)
}
}

if (isHydrate && hydrate) {
hydrate(vnode as VNode<Node, Element>, rootContainer as any)
} else {
render(vnode, rootContainer, namespace)
}
isMounted = true
app._container = rootContainer
// for devtools and telemetry
;(rootContainer as any).__vue_app__ = app

if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) {
app._instance = vnode.component
devtoolsInitApp(app, version)
}

return getExposeProxy(vnode.component!) || vnode.component!.proxy
} else if (__DEV__) {
warn(
`App has already been mounted.\n` +
`If you want to remount the same app, move your app creation logic ` +
`into a factory function and create fresh app instances for each ` +
`mount - e.g. \`const createMyApp = () => createApp(App)\``,
)
}
}

createVNode

createVNode方法会根据组件的类型生成一个标志,后续会通过这个标志做一些优化处理。我们传的是一个组件选项,也就是一个普通对象,shapeFlag的值为4。后续我们会重点关注我们的主线组件 vnode 和普通元素 vnode。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const createVNode = _createVNode;
function _createVNode(type, props = null, children = null, patchFlag = 0, dynamicProps = null, isBlockNode = false) {
// ...
const shapeFlag = isString(type)
? 1 /* ShapeFlags.ELEMENT */
: isSuspense(type)
? 128 /* ShapeFlags.SUSPENSE */
: isTeleport(type)
? 64 /* ShapeFlags.TELEPORT */
: isObject(type)
? 4 /* ShapeFlags.STATEFUL_COMPONENT */
: isFunction(type)
? 2 /* ShapeFlags.FUNCTIONAL_COMPONENT */
: 0;
// ...
return createBaseVNode(type, props, children, patchFlag, dynamicProps, shapeFlag, isBlockNode, true);
}

然后调用了createBaseVNode方法:

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
function createBaseVNode(
type: VNodeTypes | ClassComponent | typeof NULL_DYNAMIC_COMPONENT,
props: (Data & VNodeProps) | null = null,
children: unknown = null,
patchFlag = 0,
dynamicProps: string[] | null = null,
shapeFlag = type === Fragment ? 0 : ShapeFlags.ELEMENT,
isBlockNode = false,
needFullChildrenNormalization = false,
) {
const vnode = {
__v_isVNode: true,
__v_skip: true,
type,
props,
key: props && normalizeKey(props),
ref: props && normalizeRef(props),
scopeId: currentScopeId,
slotScopeIds: null,
children,
component: null,
suspense: null,
ssContent: null,
ssFallback: null,
dirs: null,
transition: null,
el: null,
anchor: null,
target: null,
targetAnchor: null,
staticCount: 0,
shapeFlag,
patchFlag,
dynamicProps,
dynamicChildren: null,
appContext: null,
ctx: currentRenderingInstance,
} as VNode

// ...

return vnode
}

可以看到返回的虚拟DOM也是一个普通对象,我们传进去的组件选项会存储在type属性上。

render

虚拟DOM创建完后就会调用render方法将虚拟DOM渲染为实际的DOM节点,render方法就是在baseCreateRenderer中创建的,通过参数传给createAppAPI的:

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 render: RootRenderFunction = (vnode, container, namespace) => {
// 新vnode不存在
if (vnode == null) {
// 卸载
if (container._vnode) {
unmount(container._vnode, null, null, true)
}
} else {
// 创建或者更新组件
patch(
container._vnode || null, // 如果是首次渲染,container._vnode不存在,那么直接将新VNode渲染为DOM元素即可
vnode,
container,
null,
null,
null,
namespace,
)
}
if (!isFlushing) {
isFlushing = true
flushPreFlushCbs()
flushPostFlushCbs()
isFlushing = false
}
// 缓存 vnode 节点,表示已经渲染
container._vnode = vnode
}

如果要渲染的新VNode不存在,那么从容器元素上获取之前VNode进行卸载,否则调用patch方法进行打补丁,如果是首次渲染,container._vnode不存在,那么直接将新VNode渲染为DOM元素即可,否则会对比新旧VNode,使用diff算法进行打补丁,Vue2中使用的是双端diff算法,Vue3中使用的是快速diff算法。

patch

看看render方法里调用的patch方法:

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
73
74
75
76
77
const patch: PatchFn = (
n1, // n1 表示旧的vnode,当n1为null时就表示是一次挂载(挂载or更新由n1决定)
n2, // n2 表示新的vnode,根据n2的type进行不同的处理
container, // 渲染后会将vnode渲染到container上
anchor = null,
parentComponent = null,
parentSuspense = null,
namespace = undefined,
slotScopeIds = null,
optimized = __DEV__ && isHmrUpdating ? false : !!n2.dynamicChildren,
) => {
if (n1 === n2) {
return
}

// patching & not same type, unmount old tree
// 如果新的节点和旧的节点类型不同,则销毁旧节点
if (n1 && !isSameVNodeType(n1, n2)) {
anchor = getNextHostNode(n1)
unmount(n1, parentComponent, parentSuspense, true)
n1 = null
}

if (n2.patchFlag === PatchFlags.BAIL) {
optimized = false
n2.dynamicChildren = null
}

const { type, ref, shapeFlag } = n2
switch (type) {
case Text: //... 处理文本节点
case Comment: //... 处理注释节点
case Static: //... 处理静态节点
case Fragment: //... 处理Fragment组件节点
default:
if (shapeFlag & ShapeFlags.ELEMENT) {
// 处理普通 DOM 元素
processElement(
n1,
n2,
container,
anchor,
parentComponent,
parentSuspense,
namespace,
slotScopeIds,
optimized,
)
} else if (shapeFlag & ShapeFlags.COMPONENT) {
// 处理组件
processComponent(
n1,
n2,
container,
anchor,
parentComponent,
parentSuspense,
namespace,
slotScopeIds,
optimized,
)
} else if (shapeFlag & ShapeFlags.TELEPORT) {
// 处理 TELEPORT
// ...
} else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
// 处理 SUSPENSE
// ...
} else if (__DEV__) {
warn('Invalid VNode type:', type, `(${typeof type})`)
}
}

// set ref
if (ref != null && parentComponent) {
setRef(ref, n1 && n1.ref, parentSuspense, n2 || n1, !n2)
}
}

patch这个函数有两个功能,一个是根据 vnode 挂载 DOM,一个是根据新旧 vnode 更新 DOM。

patch的多个参数中,重点关注前三个:

  1. 第一个参数 n1 表示旧的 vnode,当 n1 为 null 的时候,表示是一次挂载的过程;

  2. 第二个参数 n2 表示新的 vnode 节点,后续会根据这个 vnode 类型执行不同的处理逻辑;

  3. 第三个参数 container 表示 DOM 容器,也就是 vnode 渲染生成 DOM 后,会挂载到 container 下面。

switch里面根据VNode的类型不同做的处理也不同,因为我们的例子传的是一个组件选项对象,所以会走processComponent处理分支:

processComponent

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
const processComponent = (
n1: VNode | null,
n2: VNode,
container: RendererElement,
anchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
namespace: ElementNamespace,
slotScopeIds: string[] | null,
optimized: boolean,
) => {
n2.slotScopeIds = slotScopeIds
// n1等于null,表示挂载组件
if (n1 == null) {
if (n2.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) {
;(parentComponent!.ctx as KeepAliveContext).activate(
n2,
container,
anchor,
namespace,
optimized,
)
} else {
// 调用mountComponent挂载组件
mountComponent(
n2,
container,
anchor,
parentComponent,
parentSuspense,
namespace,
optimized,
)
}
} else { // n1不为null,表示更新组件
updateComponent(n1, n2, optimized)
}
}

根据是否存在旧的VNode判断是调用挂载方法还是更新方法,先看mountComponent方法:

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
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
const mountComponent: MountComponentFn = (
initialVNode,
container,
anchor,
parentComponent,
parentSuspense,
namespace: ElementNamespace,
optimized,
) => {
// 2.x compat may pre-create the component instance before actually
// mounting
const compatMountInstance =
__COMPAT__ && initialVNode.isCompatRoot && initialVNode.component

// 1.调用ComponentInternalInstance创建组件的实例
const instance: ComponentInternalInstance =
compatMountInstance ||
// 调用createComponentInstance函数创建一个实例对象,其属性皆为没有值
(initialVNode.component = createComponentInstance(
initialVNode,
parentComponent,
parentSuspense,
))

// ...

// resolve props and slots for setup context
if (!(__COMPAT__ && compatMountInstance)) {
if (__DEV__) {
startMeasure(instance, `init`)
}
// 2.设置组件实例
// 初始化组件, 主要是对组件的props/slots进行初始化处理
// 执行setup 生成render函数(所以setup是在所有选项式API钩子之前调用 包括beforeCreate)
setupComponent(instance)
if (__DEV__) {
endMeasure(instance, `init`)
}
}

// setup() is async. This component relies on async logic to be resolved
// before proceeding
if (__FEATURE_SUSPENSE__ && instance.asyncDep) {
// ...
} else {
// 3.调用设置和运行有副作用的渲染函数

// 1 创建一个组件更新函数
// 1.1 render获得vnode
// 1.2 patch(oldVnode, newVnode)
// 2 创建更新机制 new ReactiveEffect(更新函数)
// 执行渲染副作用函数
setupRenderEffect(
instance,
initialVNode,
container,
anchor,
parentSuspense,
namespace,
optimized,
)
}

if (__DEV__) {
popWarningContext()
endMeasure(instance, `mount`)
}
}
  1. 调用ComponentInternalInstance创建组件的实例
  2. 设置组件实例,初始化组件 处理setup的两个参数, 执行setup 生成render函数(所以setup是在所有选项式API钩子之前调用 包括beforeCreate)
  3. 调用设置和运行有副作用的渲染函数

下面我们依次来看这三个方法:

1. createComponentInstance
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
export function createComponentInstance(
vnode: VNode,
parent: ComponentInternalInstance | null,
suspense: SuspenseBoundary | null,
) {
const type = vnode.type as ConcreteComponent
// inherit parent app context - or - if root, adopt from root vnode
const appContext =
(parent ? parent.appContext : vnode.appContext) || emptyAppContext

const instance: ComponentInternalInstance = {
uid: uid++,
vnode,
type,
parent,
appContext,
// 还有非常多属性
// ...
}
// ...

return instance
}
2. setupComponent
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
export function setupComponent(
instance: ComponentInternalInstance,
isSSR = false,
) {
isSSR && setInSSRSetupState(isSSR)

// vnode的props和子元素
const { props, children } = instance.vnode
// 是否是有状态的组件
const isStateful = isStatefulComponent(instance)
// 初始化props
initProps(instance, props, isStateful, isSSR)
// 初始化slots
initSlots(instance, children)
// 执行setupStatefulComponent获取setupResult
const setupResult = isStateful
? setupStatefulComponent(instance, isSSR) // 执行setup
: undefined

isSSR && setInSSRSetupState(false)
return setupResult
}

可以看到setupComponent会初始化props和slots,然后执行setupStatefulComponent,这里主要是执行setup函数,并返回结果

setupStatefulComponent
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
73
74
75
76
77
78
79
80
81
82
83
84
85
86
function setupStatefulComponent(
instance: ComponentInternalInstance,
isSSR: boolean,
) {
// 组件描述对象
const Component = instance.type as ComponentOptions

// ...

// 0. create render proxy property access cache
// 创建render proxy属性访问缓存 作用是缓存访问过的属性
instance.accessCache = Object.create(null)
// 1. create public instance / render proxy
// also mark it raw so it's never observed
// 代理ctx,拦截ctx的属性访问 从而实现取值的优先级:setupState > data > props > ctx
instance.proxy = markRaw(new Proxy(instance.ctx, PublicInstanceProxyHandlers))
if (__DEV__) {
exposePropsOnRenderContext(instance)
}
// 2. call setup()
const { setup } = Component
if (setup) {
// 创建setupContext
const setupContext = (instance.setupContext =
setup.length > 1 ? createSetupContext(instance) : null)

// instance赋值给currentInstance
// 设置当前实例为instance 为了在setup中可以通过getCurrentInstance获取到当前实例
// 同时开启instance.scope.on()
const reset = setCurrentInstance(instance)
// 暂停tracking 暂停收集副作用函数
pauseTracking()
// 执行setup
const setupResult = callWithErrorHandling(
setup,
instance,
ErrorCodes.SETUP_FUNCTION,
[
__DEV__ ? shallowReadonly(instance.props) : instance.props,
setupContext,
],
)
// 重新开启副作用收集
resetTracking()
// currentInstance置为空
// activeEffectScope赋值为instance.scope.parent
// 同时instance.scope.off()
reset()

if (isPromise(setupResult)) {
setupResult.then(unsetCurrentInstance, unsetCurrentInstance)
if (isSSR) {
// return the promise so server-renderer can wait on it
return setupResult
.then((resolvedResult: unknown) => {
handleSetupResult(instance, resolvedResult, isSSR)
})
.catch(e => {
handleError(e, instance, ErrorCodes.SETUP_FUNCTION)
})
} else if (__FEATURE_SUSPENSE__) {
// async setup returned Promise.
// bail here and wait for re-entry.
instance.asyncDep = setupResult
if (__DEV__ && !instance.suspense) {
const name = Component.name ?? 'Anonymous'
warn(
`Component <${name}>: setup function returned a promise, but no ` +
`<Suspense> boundary was found in the parent component tree. ` +
`A component with async setup() must be nested in a <Suspense> ` +
`in order to be rendered.`,
)
}
} else if (__DEV__) {
warn(
`setup() returned a Promise, but the version of Vue you are using ` +
`does not support it yet.`,
)
}
} else {
handleSetupResult(instance, setupResult, isSSR)
}
} else {
finishComponentSetup(instance, isSSR)
}
}

在这个方法里会调用组件选项的setup方法,这个函数中返回的对象会暴露给模板和组件实例,看一下handleSetupResult方法:

handleSetupResult
1
2
3
4
5
6
7
8
function handleSetupResult(instance, setupResult, isSSR) {
if (isFunction(setupResult)) {
instance.render = setupResult;
} else if (isObject(setupResult)) {
instance.setupState = proxyRefs(setupResult);
}
finishComponentSetup(instance, isSSR);
}

如果setup返回的是一个函数,那么这个函数会直接被作为渲染函数。否则如果返回的是一个对象,会使用proxyRefs将这个对象转为Proxy代理的响应式对象。

finishComponentSetup

最后又调用了finishComponentSetup方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function finishComponentSetup(instance, isSSR) {
const Component = instance.type;
if (!instance.render) {
if (!isSSR && compile && !Component.render) {
const template = Component.template ||
resolveMergedOptions(instance).template;
if (template) {
const { isCustomElement, compilerOptions } = instance.appContext.config;
const { delimiters, compilerOptions: componentCompilerOptions } = Component;
const finalCompilerOptions = extend(extend({
isCustomElement,
delimiters
}, compilerOptions), componentCompilerOptions);
Component.render = compile(template, finalCompilerOptions);
}
}
instance.render = (Component.render || NOOP);
}
}

这个函数主要是判断组件是否存在渲染函数render,如果不存在则判断是否存在template选项,我们传的组件选项显然是没有render属性,而是传的模板template,所以会使用compile方法来将模板编译成渲染函数。

3. setupRenderEffect
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
const setupRenderEffect: SetupRenderEffectFn = (
instance,
initialVNode, // 组件 vnode
container,
anchor,
parentSuspense,
namespace: ElementNamespace,
optimized,
) => {
// 组件更新方法
const componentUpdateFn = () => {}

// create reactive effect for rendering
// 创建一个effect, 将componentUpdateFn更新方法传入响应式更新方法
const effect = (instance.effect = new ReactiveEffect(
componentUpdateFn,
NOOP,
() => queueJob(update),
instance.scope, // track it in component's effect scope
))

// 组件更新函数
const update: SchedulerJob = (instance.update = () => {
if (effect.dirty) {
effect.run()
}
})
update.id = instance.uid
// allowRecurse
// #1801, #2043 component render effects should allow recursive updates
toggleRecurse(instance, true)

if (__DEV__) {
effect.onTrack = instance.rtc
? e => invokeArrayFns(instance.rtc!, e)
: void 0
effect.onTrigger = instance.rtg
? e => invokeArrayFns(instance.rtg!, e)
: void 0
update.ownerInstance = instance
}
// 执行组件更新
update()
}

这一步就涉及到Vue3的响应式原理了,核心就是使用Proxy拦截数据,然后在属性读取时将属性和读取该属性的函数(称为副作用函数)关联起来,然后在更新该属性时取出该属性关联的副作用函数出来执行。

简化后的ReactiveEffect类就是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let activeEffect;
class ReactiveEffect {
constructor(fn, scheduler = null, scope) {
this.fn = fn;
}
run() {
activeEffect = this;
try {
return this.fn();
} finally {
activeEffect = null
}
}
}

执行它的run方法时会把自身赋值给全局的activeEffect变量,然后执行副作用函数时如果读取了Proxy代理后的对象的某个属性时就会将对象、属性和这个ReactiveEffect示例关联存储起来,如果属性发生改变,会取出关联的ReactiveEffect实例,执行它的run方法,达到自动更新的目的。

componentUpdateFn
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
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
const componentUpdateFn = () => {
// 判断组件是否已经挂载
if (!instance.isMounted) {
let vnodeHook: VNodeHook | null | undefined
const { el, props } = initialVNode
// 生命周期和父instance
const { bm, m, parent } = instance
const isAsyncWrapperVNode = isAsyncWrapper(initialVNode)
toggleRecurse(instance, false)
// beforeMount hook
if (bm) {
invokeArrayFns(bm)
}
// onVnodeBeforeMount
// ...
if (el && hydrateNode) {
// ssr 相关
} else {
if (__DEV__) {
startMeasure(instance, `render`)
}
// 执行render函数获得subTree(也是一个vnode) 将subTree挂载到instance上 以供更新使用
const subTree = (instance.subTree = renderComponentRoot(instance)) // 整个组件渲染生成 DOM 对应的 vnode 树
// ...
// patch subTree初次挂载
patch(
null,
subTree,
container,
anchor,
instance,
parentSuspense,
isSVG
)
if (__DEV__) {
endMeasure(instance, `patch`)
}
// el同步到initialVNode
initialVNode.el = subTree.el
}
// mounted hook
if (m) {
queuePostRenderEffect(m, parentSuspense)
}
// onVnodeMounted
// ...
// 组件已经挂载
instance.isMounted = true

if (__DEV__ || __FEATURE_PROD_DEVTOOLS__) {
devtoolsComponentAdded(instance)
}

// #2458: deference mount-only object parameters to prevent memleaks
initialVNode = container = anchor = null as any
} else {
// 组件更新
let { next, bu, u, parent, vnode } = instance
let originNext = next
let vnodeHook: VNodeHook | null | undefined

if (next) {
next.el = vnode.el
// 更新组件 vnode 节点信息
updateComponentPreRender(instance, next, optimized)
} else {
next = vnode
}

// 执行render函数获得nextTree
const nextTree = renderComponentRoot(instance)

// 获取老的subTree
const prevTree = instance.subTree
instance.subTree = nextTree

// patch新旧节点更新组件
patch(
prevTree,
nextTree,
// parent may have changed if it's in a teleport
hostParentNode(prevTree.el!)!,
// anchor may have changed if it's in a fragment
getNextHostNode(prevTree),
instance,
parentSuspense,
isSVG
)
// ...
}
}

这里的patch操作其实就是调用本章开头的那个patch,可以看到patch其实是一个递归操作,这里patch subtree如果根组件的根元素是组件则会继续执行processComponent,如果是一个element元素则会执行processElement,processElement中会处理children,又会调用patch,如此递归直到整个组件挂载完成。

组件无论是首次挂载,还是更新,做的事情核心是一样的,先调用renderComponentRoot方法生成组件模板的虚拟DOM,然后调用patch方法打补丁。

renderComponentRoot
1
2
3
4
5
6
7
function renderComponentRoot(instance) {
const { type: Component, vnode, proxy, withProxy, props, propsOptions: [propsOptions], slots, attrs, emit, render, renderCache, data, setupState, ctx, inheritAttrs } = instance;
// 执行render函数
// render函数内部会通过_createVNode或者_createElementVNode等函数进一步生成子vnode
let result = normalizeVNode(render.call(proxyToUse, proxyToUse, renderCache, props, setupState, data, ctx))
return result
}

renderComponentRoot核心就是调用组件的渲染函数render方法生成组件模板的虚拟DOM,然后扔给patch方法更新就好了。

updateComponent

看完了mountComponent方法,再来看看updateComponent方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const updateComponent = (n1, n2, optimized) => {
const instance = (n2.component = n1.component);
// 根据新旧子组件 vnode 判断是否需要更新子组件
if (shouldUpdateComponent(n1, n2, optimized)) {
// 新的子组件 vnode 赋值给 instance.next
instance.next = n2;
// 子组件也可能因为数据变化被添加到更新队列里了,移除它们防止对一个子组件重复更新
invalidateJob(instance.update)
// 执行子组件的副作用渲染函数
instance.update();
}else {
// 不需要更新
n2.el = n1.el;
instance.vnode = n2;
}
}

先调用shouldUpdateComponent方法判断组件是否需要更新,主要是通过检测和对比组件 vnode 中的 props、chidren、dirs、transiton 等属性,来决定子组件是否需要更新。

如果需要更新,那么会执行instance.update方法,这个方法就是前面setupRenderEffect方法里保存的effect.run方法,所以最终执行的也是componentUpdateFn方法。

updateComponentPreRender
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const updateComponentPreRender = (
instance: ComponentInternalInstance,
nextVNode: VNode,
optimized: boolean,
) => {
// 新组件 vnode 的 component 属性指向组件实例
nextVNode.component = instance
// 旧组件 vnode 的 props 属性
const prevProps = instance.vnode.props
// 组件实例的 vnode 属性指向新的组件 vnode
instance.vnode = nextVNode
// 清空 next 属性,为了下一次重新渲染准备
instance.next = null
// 更新 props
updateProps(instance, nextVNode.props, prevProps, optimized)
// 更新 插槽
updateSlots(instance, nextVNode.children, optimized)

pauseTracking()
// props update may have triggered pre-flush watchers.
// flush them before the render update.
flushPreFlushCbs(instance)
resetTracking()
}

processElement

1
2
3
4
5
6
7
8
9
10
11
const processElement = (n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
isSVG = isSVG || n2.type === 'svg'
if (n1 == null) {
//挂载元素节点
mountElement(n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
}
else {
//更新元素节点
patchElement(n1, n2, parentComponent, parentSuspense, isSVG, optimized)
}
}

该函数的逻辑很简单,如果 n1 为 null,走挂载元素节点的逻辑,否则走更新元素节点逻辑。

我们接着来看挂载元素的 mountElement 函数的实现:

mountElement
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const mountElement = (vnode, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
let el
const { type, props, shapeFlag } = vnode
// 创建 DOM 元素节点
el = vnode.el = hostCreateElement(vnode.type, isSVG, props && props.is)
if (props) {
// 处理 props,比如 class、style、event 等属性
for (const key in props) {
if (!isReservedProp(key)) {
hostPatchProp(el, key, null, props[key], isSVG)
}
}
}
if (shapeFlag & 8 /* TEXT_CHILDREN */) {
// 处理子节点是纯文本的情况
hostSetElementText(el, vnode.children)
}
else if (shapeFlag & 16 /* ARRAY_CHILDREN */) {
// 处理子节点是数组的情况
mountChildren(vnode.children, el, null, parentComponent, parentSuspense, isSVG && type !== 'foreignObject', optimized || !!vnode.dynamicChildren)
}
// 把创建的 DOM 元素节点挂载到 container 上
hostInsert(el, container, anchor)
}

可以看到,挂载元素函数主要做四件事:创建 DOM 元素节点、处理 props、处理 children、挂载 DOM 元素到 container 上。

首先是创建 DOM 元素节点,通过 hostCreateElement 方法创建,这是一个平台相关的方法,我们来看一下它在 Web 环境下的定义:

1
2
3
4
function createElement(tag, isSVG, is) {
isSVG ? document.createElementNS(svgNS, tag)
: document.createElement(tag, is ? { is } : undefined)
}

它调用了底层的 DOM API document.createElement 创建元素,如果是其他平台比如 Weex,hostCreateElement 方法就不再是操作 DOM ,而是平台相关的 API 了,这些平台相关的方法是在创建渲染器阶段作为参数传入的。

创建完 DOM 节点后,接下来要做的是判断如果有 props 的话,给这个 DOM 节点添加相关的 class、style、event 等属性,并做相关的处理,这些逻辑都是在 hostPatchProp 函数内部做的,这里就不展开讲了。

接下来是对子节点的处理,我们知道 DOM 是一棵树,vnode 同样也是一棵树,并且它和 DOM 结构是一一映射的。

如果子节点是纯文本,则执行 hostSetElementText 方法,它在 Web 环境下通过设置 DOM 元素的 textContent 属性设置文本:

1
2
3
function setElementText(el, text) {
el.textContent = text
}

如果子节点是数组,则执行 mountChildren 方法:

mountChildren
1
2
3
4
5
6
7
8
9
10
const mountChildren = (children, container, anchor, parentComponent, parentSuspense, isSVG, optimized, start = 0) => {
for (let i = start; i < children.length; i++) {
// 预处理 child
const child = (children[i] = optimized
? cloneIfMounted(children[i])
: normalizeVNode(children[i]))
// 递归 patch 挂载 child
patch(null, child, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
}
}

子节点的挂载逻辑同样很简单,遍历 children 获取到每一个 child,然后递归执行 patch 方法挂载每一个 child 。

可以看到,mountChildren 函数的第二个参数是 container,而我们调用 mountChildren 方法传入的第二个参数是在 mountElement 时创建的 DOM 节点,这就很好地建立了父子关系。

另外,通过递归 patch 这种深度优先遍历树的方式,我们就可以构造完整的 DOM 树,完成组件的渲染。

处理完所有子节点后,最后通过 hostInsert 方法把创建的 DOM 元素节点挂载到 container 上,它在 Web 环境下这样定义:

1
2
3
4
5
6
7
8
function insert(child, parent, anchor) {
if (anchor) {
parent.insertBefore(child, anchor)
}
else {
parent.appendChild(child)
}
}

这里会做一个 if 判断,如果有参考元素 anchor,就执行 parent.insertBefore ,否则执行 parent.appendChild 来把 child 添加到 parent 下,完成节点的挂载。

因为 insert 的执行是在处理子节点后,所以挂载的顺序是先子节点,后父节点,最终挂载到最外层的容器上。

总结

image-1

到这里,从我们创建实例到页面渲染,再到更新的全流程就讲完了,总结一下,大致就是:

  1. 每个Vue组件都需要产出一份虚拟DOM,也就是组件的render函数的返回值,render函数你可以直接手写,也可以通过template传递模板字符串,由Vue内部来编译成渲染函数,平常我们开发时写的Vue单文件,最终也会编译成普通的Vue组件选项对象;

  2. render函数会作为副作用函数执行,也就是如果在模板中使用到了响应式数据(所谓响应式数据就是能拦截到它的各种读取、修改操作),那么响应式数据和属性会与render函数关联起来,那么当响应式数据被修改以后,就能找到依赖它的render函数,那么就可以通知依赖的组件进行更新;

event loop 中的 Update the rendering(更新渲染)

这是 event loop 中很重要部分,在这篇文章处理流程(processing model)中第 3 步会进行 Update the rendering(更新渲染),规范允许浏览器自己选择是否更新视图。也就是说可能不是每轮事件循环都去更新视图,只在有必要的时候才更新视图。

渲染的基本流程:

alt text

  1. 处理 HTML 标记并构建 DOM 树。
  2. 处理 CSS 标记并构建 CSSOM 树, 将 DOM 与 CSSOM 合并成一个渲染树。
  3. 根据渲染树来布局,以计算每个节点的几何信息。
  4. 将各个节点绘制到屏幕上。

Note: 可以看到渲染树的一个重要组成部分是 CSSOM 树,绘制会等待 css 样式全部加载完成才进行,所以 css 样式加载的快慢是首屏呈现快慢的关键点。

下面讨论一下渲染的时机。规范定义在一次循环中,Update the rendering 会在 Microtasks: Perform a microtask checkpoint 后运行。

渲染时机

以下的例子中,用 chrome 的 Developer tools 的 Timeline 查看各部分运行的时间点。当我们点击这个 div 的时候,截取了部分时间线。

黄色部分是脚本运行,紫色部分是更新 render 树、计算布局,绿色部分是绘制。

绿色和紫色部分可以认为是 Update the rendering。

例子 1

1
2
3
4
5
6
7
8
9
10
<div id="con">this is con</div>
<script>
var t = 0
var con = document.getElementById('con')
con.onclick = function () {
setTimeout(function setTimeout1() {
con.textContent = t
}, 0)
}
</script>

alt text

在这一轮事件循环中,setTimeout1 是作为 task 运行的,可以看到 paint 确实是在 task 运行完后才进行的。

例子 2

现在换成一个 microtask 任务,看看有什么变化

1
2
3
4
5
6
7
8
9
<div id="con">this is con</div>
<script>
var con = document.getElementById('con')
con.onclick = function () {
Promise.resolve().then(function Promise1() {
con.textContext = 0
})
}
</script>

alt text

和上一个例子很像,不同的是这一轮事件循环的 task 是 click 的回调函数,Promise1 则是 microtask,paint 同样是在他们之后完成。

标准就是那么定义的,答案似乎显而易见,我们把例子变得稍微复杂一些。

例子 3

1
2
3
4
5
6
7
8
9
10
11
12
<div id="con">this is con</div>
<script>
var con = document.getElementById('con')
con.onclick = function click1() {
setTimeout(function setTimeout1() {
con.textContent = 0
}, 0)
setTimeout(function setTimeout2() {
con.textContent = 1
}, 0)
}
</script>

alt text
alt text

经过多次测试,执行和渲染顺序可能会出现上图的两种情况,根据 timeline 可以看出,图 1 中 setTimeout 分别执行并且浏览器绘制了两次,图二中 setTimeout1 和 setTimeout2 中间并没有绘制, 而是最后绘制了一次,也基本符合规范,但是需要验证这两次 setTimeout 是否在两次 task 中。

例子 4

在两个 setTimeout 中增加 microtask。再次确认 setTimeout 在两个 task 中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<div id="con">this is con</div>
<script>
var con = document.getElementById('con')
con.onclick = function () {
setTimeout(function setTimeout1() {
con.textContent = 0
Promise.resolve().then(function Promise1() {
console.log('Promise1')
})
}, 0)
setTimeout(function setTimeout2() {
con.textContent = 1
Promise.resolve().then(function Promise2() {
console.log('Promise2')
})
}, 0)
}
</script>

alt text
alt text

从 run microtasks 中可以看出来,setTimeout1、setTimeout2 是运行在两次 event loop 中

例子 5

将时间间隔加大一些。

1
2
3
4
5
6
7
8
9
10
11
12
<div id="con">this is con</div>
<script>
var con = document.getElementById('con')
con.onclick = function click1() {
setTimeout(function setTimeout1() {
con.textContent = 0
}, 0)
setTimeout(function setTimeout2() {
con.textContent = 1
}, 17)
}
</script>

当间隔增大,大部分时候可以肉眼从看到先变成 0,再变成 1 的过程。但是有时也是会合并起来,只 paint 一次

alt text

通过 timeline,可以看到 setTimeout1 后接着 paint,后执行了 setTimeout2 后也有 paint

例子 6

我们在同一时间执行多个 setTimeout 来模拟执行间隔很短的 task。

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
<div id="con">this is con</div>
<script>
var con = document.getElementById('con')
con.onclick = function () {
setTimeout(function () {
con.textContent = 0
}, 0)
setTimeout(function () {
con.textContent = 1
}, 0)
setTimeout(function () {
con.textContent = 2
}, 0)
setTimeout(function () {
con.textContent = 3
}, 0)
setTimeout(function () {
con.textContent = 4
}, 0)
setTimeout(function () {
con.textContent = 5
}, 0)
setTimeout(function () {
con.textContent = 6
}, 0)
}
</script>

alt text
图一中总共 paint 了一次

alt text

图二中一共 paint 了两次,所以多次 task 的间隔很短,仍会进行绘制。

例子 7

有说法是一轮 event loop 执行的 microtask 有数量限制,多余的 microtask 会放到下一轮执行。下面例子将 microtask 的数量增加到 25000。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<div id="con">this is con</div>
<script>
var con = document.getElementById('con')
con.onclick = function () {
setTimeout(function setTimeout1() {
con.textContent = 'task1'
for (var i = 0; i < 250000; i++) {
Promise.resolve().then(function () {
con.textContent = i
})
}
}, 0)
setTimeout(function setTimeout2() {
con.textContent = 'task2'
}, 0)
}
</script>

alt text

可以看到脚本的运行耗费大量的时间,并且阻塞了渲染。

我们看 setTimeout2 的运行情况

alt text

可以看到 setTimeout2 这轮 event loop 没有 run microtasks,microtasks 在 setTimeout1 被全部执行完了。

25000 个 microtasks 不能说明 event loop 对 microtasks 数量没有限制,有可能这个限制数很高,远超 25000,但日常使用基本不会使用那么多了。

对 microtasks 增加数量限制,一个很大的作用是防止脚本运行时间过长,阻塞渲染。

例子 8

使用 requestAnimationFrame。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<div id="con">this is con</div>
<script>
var con = document.getElementById('con')
var i = 0
var raf = function () {
requestAnimationFrame(function () {
con.textContent = i
Promise.resolve().then(function () {
i++
if (i < 3) raf()
})
})
}
con.onclick = function () {
raf()
}
</script>

看下总体的 timeline
alt text

单看某一个 requestAnimationFrame
alt text

  • 可以看出 requestAnimationFrame 是在更新渲染阶段的执行的,其执行顺序早于 paint,并且不会被合并执行,非常适合做动画
  • 在 requestAnimationFrame 回调内有新的 microtasks 进入时,也会执行完所有的 microtasks 才会进入到渲染阶段

例子 9

验证 postMessage 是否是 task

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<script>
setTimeout(function setTimeout1() {
console.log('setTimeout1')
}, 0)
var channel = new MessageChannel()
channel.port1.onmessage = function onmessage1() {
console.log('postMessage')
Promise.resolve().then(function promise1() {
console.log('promise1')
})
}
channel.port2.postMessage(0)
setTimeout(function setTimeout2() {
console.log('setTimeout2')
}, 0)
console.log('sync')
</script>

执行顺序:

1
2
3
4
5
sync
setTimeout1
postMessage
promise1
setTimeout2

alt text

第一个黄块是 setTimeout1,第二个是 onmessage1,第三个是 promise1,第四个是 setTimeout2。显而易见,postMessage 属于 task。

总结

结合规范和以上验证案例可以得出一些结论:

  • event loop 的大致循环过程,可以用下边的图表示:

alt text

  • 在一轮 event loop 中多次修改同一 dom,只有最后一次会进行绘制。
  • 例 3-例 6 这几个结果是非常不可控的,如果这两个 Task 之间正好遇到了浏览器认定的渲染机会,那么它会重绘,否则就不会。
  • 渲染更新(Update the rendering)会在 event loop 中的 tasks 和 microtasks 完成后进行,但并不是每轮 event loop 都会更新渲染,这里有一个 rendering opportunity 的概念,判断是否需要渲染,这取决于是否修改了 dom 和浏览器觉得是否有必要在此时立即将新状态呈现给用户,也要要根据屏幕刷新率、页面性能、页面是否在后台运行来共同决定。通常来说这个渲染间隔是固定的。(所以多个 task 很可能在一次渲染之间执行)
  • 事件循环不一定每轮都伴随着重渲染,但是如果有微任务,一定会伴随着微任务执行。
  • 如果希望在每轮 event loop 都即时呈现变动,可以使用 requestAnimationFrame。

前言

在之前的分享中,我们团队已经成功运用了 Gitlab CI,并且已经结构化提交 git commit 记录,我们希望更进一步,自动管理发布版本,自动生成更新日志,自动发布 NPM 包,因此我们引入了 semantic-release 进一步自动化管理我们的发布流程。

semantic-release 概述

有关semantic-release的详细介绍可以阅读官方文档,这里只做一些概述性的总结。和 standard-version 相比,semantic-release 更适合在 CI 环境中运行,它自带支持各种 git server 的认证支持,如 Github,Gitlab,Bitbucket 等等,此外,还支持插件,以便完成其他后续的流程步骤,比如自动生成 git tag 和 release note 之后再 push 回中央仓库,自动发布 npm 包等等。

semantic-release 会根据规范化的 commit 信息生成发布日志,默认使用 angular 规则,其他规则可以配置插件完成。

semantic-release 大致的工作流如下:

  • 提交到特定的分支触发 release 流程
  • 验证 commit 信息,生成 release note,打 git tag
  • 其他后续流程,如生成 CHANGELOG.md,npm publish 等等(通过插件完成)

由 CI 自动执行之后的效果就像这样,在 Git tag 页面可以看到 tag 信息,同时包含更新记录:
86f880f284d3072ec2179cbc46c32396

如果启用了@semantic-release/git 插件,还会将生成的 CHANGELOG.md 反向 push 回中央仓库:
6cfc655f89c58bc6a2faa5e3aee6eea1

commit history 的实际效果如下
c15015e5517056c98eca17829d479a12

实践

在项目工程中添加 release.config.js 配置如下:

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
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
parserOpts = {
mergePattern: /^Merge pull request #(\d+) from (.*)$/,
mergeCorrespondence: ['id', 'source'],
}
// Copied from https://github.com/conventional-changelog/conventional-changelog/blob/master/packages/conventional-changelog-angular/writer-opts.js#L27
// and modified to support adding all commit types to the release notes
customTransform = (commit, context) => {
const issues = []
commit.notes.forEach((note) => {
note.title = `BREAKING CHANGES`
})
if (commit.type === `feat`) {
commit.type = `✨ Features`
} else if (commit.type === `fix`) {
commit.type = `🐞 Bug Fixes`
} else if (commit.type === `perf`) {
commit.type = `🎈 Performance Improvements`
} else if (commit.type === `revert`) {
commit.type = `Reverts`
} else if (commit.type === `docs`) {
commit.type = `📃 Documentation`
} else if (commit.type === `style`) {
commit.type = `🌈 Styles`
} else if (commit.type === `refactor`) {
commit.type = `🦄 Code Refactoring`
} else if (commit.type === `test`) {
commit.type = `🧪 Tests`
} else if (commit.type === `build`) {
commit.type = `🔧 Build System`
} else if (commit.type === `ci`) {
commit.type = `🐎 Continuous Integration`
} else {
return
}
if (commit.scope === `*`) {
commit.scope = ``
}
if (typeof commit.hash === `string`) {
commit.shortHash = commit.hash.substring(0, 7)
}
if (typeof commit.subject === `string`) {
commit.subject = commit.subject.substring(2)
let url = context.repository
? `${context.host}/${context.owner}/${context.repository}`
: context.repoUrl
if (url) {
url = `${url}/issues/` // Issue URLs.
commit.subject = commit.subject.replace(/#([0-9]+)/g, (_, issue) => {
issues.push(issue)
return `[#${issue}](${url}${issue})`
})
}
if (context.host) {
// User URLs.
commit.subject = commit.subject.replace(
/\B@([a-z0-9](?:-?[a-z0-9/]){0,38})/g,
(_, username) => {
if (username.includes('/')) {
return `@${username}`
}
return `[@${username}](${context.host}/${username})`
}
)
}
commit.subject = `${commit.subject} (by @${commit.committer.name})`
} // remove references that already appear in the subject
commit.references = commit.references.filter((reference) => {
if (issues.indexOf(reference.issue) === -1) {
return true
}
return false
})
return commit
}
module.exports = {
branches: 'master',
parserOpts,
writerOpts: { transform: customTransform },
plugins: [
[
'@semantic-release/commit-analyzer',
{
preset: 'angular',
releaseRules: [
{ type: 'docs', scope: 'README', release: 'patch' },
{ type: 'refactor', release: 'patch' },
{ type: 'style', release: 'patch' },
{ type: 'test', release: 'patch' },
{ type: 'build', release: 'patch' },
{ type: 'ci', release: 'patch' },
],
},
],
'@semantic-release/release-notes-generator',
['@semantic-release/changelog', { changelogFile: 'CHANGELOG.md' }],
'@semantic-release/npm',
[
'@semantic-release/git',
{
assets: ['package.json', 'CHANGELOG.md'],
},
],
[
'@semantic-release/gitlab',
{
gitlabUrl: 'http://git.example.com',
assets: [],
},
],
],
}

完成 .gitlab-ci.yml 配置如下(仅部分关键的配置片段):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
stages:
    - lint
    - release
commitlint:
    stage: lint
    rules:
        - if: '$CI_COMMIT_TITLE =~ /^chore\(release\)/'
          when: never
        - if: '$CI_COMMIT_TITLE =~ /^Merge branch/'
          when: never
        - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH # 一般当分支master有 push 或 merge 时才会执行该工作
    script:
        - sh bin/commitlint-gitlab-ci.sh
release:
    stage: release
    rules:
        - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    script:
        - semantic-release

commitlint 脚本请参考commitlint-gitlab-ci.sh

小结

至此,我们完成了通过 CI 自动管理版本号和发布日志的需求,大大节省了人力,同时,还留下了发布痕迹,方便追溯历史版本。
另外,需要注意的是上述的配置并不会修改源码部分的版本号配置内容(如 build.gradle 或 package.json 等),如果需要自动管理这些地方的版本,与 git tag 版本保持一致,可以引入@semantic-release/exec 插件,自己写脚本,通过脚本自动化修改这些地方的版本号。
还需要注意的是 semantic-release 默认产生的 commit 记录为了避免不必要的 CI 流程,会在 commit 记录加上[skip ci](见上面的截图)来跳过 CI,如果你的流水线需要由 git tag 触发,可以配置@semantic-release/git 插件,自定义 commit 记录,去掉[skip ci]。

异步的思考

提起异步,相信每个人都知道。异步背后的“靠山”就是 event loops。这里的异步准确的说应该叫浏览器的 event loops 或者说是 javaScript 运行环境的 event loops,因为ECMAScript中没有 event loops,event loops 是在HTML Standard定义的。

(1)单线程的 JavaScript

我们知道,JavaScript 是一种单线程语言,它主要用来与用户互动,以及操作 DOM。
JavaScript 有同步和异步的概念,这就解决了代码阻塞的问题:

- 同步:如果在一个函数返回的时候,调用者就能够得到预期结果,那么这个函数就是同步的;
- 异步:如果在函数返回的时候,调用者还不能够得到预期结果,而是需要在将来通过一定的手段得到,那么这个函数就是异步的。
那单线程有什么好处呢?
在 JS 运行的时候可能会阻止 UI 渲染,这说明了两个线程是互斥的。这是因为 JS 可以修改 DOM,如果在 JS 执行的时候 UI 线程还在工作,就可能导致不能安全的渲染 UI。
得益于 JS 是单线程运行的,可以达到节省内存,节约上下文切换时间的好处。

(2)多线程的浏览器

JS 是单线程的,在同一个时间只能做一件事情,那为什么浏览器可以同时执行异步任务呢?

这是因为浏览器是多线程的,当 JS 需要执行异步任务时,浏览器会另外启动一个线程去执行该任务。也就是说,JavaScript 是单线程的指的是执行 JavaScript 代码的线程只有一个,是浏览器提供的 JavaScript 引擎线程(主线程)。除此之外,浏览器中还有定时器线程、 HTTP 请求线程等线程,这些线程主要不是来执行 JS 代码的。

比如主线程中需要发送数据请求,就会把这个任务交给异步 HTTP 请求线程去执行,等请求数据返回之后,再将 callback 里需要执行的 JS 回调交给 JS 引擎线程去执行。也就是说,浏览器才是真正执行发送请求这个任务的角色,而 JS 只是负责执行最后的回调处理。所以这里的异步不是 JS 自身实现的,而是浏览器为其提供的能力。

alt text

可以看到,Chrome 不仅拥有多个进程,还有多个线程。以渲染进程为例,就包含 GUI 渲染线程、JS 引擎线程、事件触发线程、定时器触发线程、异步 HTTP 请求线程。这些线程为 JS 在浏览器中完成异步任务提供了基础。

定义

我们来看看 event loop 在 HTML Standard 中的定义章节:

第一句话:

为了协调事件,用户交互,脚本,渲染,网络等,用户代理必须使用本节所述的 event loop。

事件用户交互脚本渲染网络这些都是我们所熟悉的东西,他们都是由 event loop 协调的。触发一个 click 事件,进行一次 ajax 请求,背后都有 event loop 在运作。

task queues

一个 event loop 有一个或者多个 task 队列。

当用户代理安排一个任务,必须将该任务增加到相应的 event loop 的一个 tsak 队列中。

每一个 task 都来源于指定的任务源,比如可以为鼠标、键盘事件提供一个 task 队列,其他事件又是一个单独的队列。可以为鼠标、键盘事件分配更多的时间,保证交互的流畅。

哪些是 task 任务源呢?

规范在Generic task sources中有提及:

DOM 操作任务源:
此任务源被用来相应 dom 操作,例如一个元素以非阻塞的方式插入文档。

用户交互任务源:
此任务源用于对用户交互作出反应,例如键盘或鼠标输入。响应用户操作的事件(例如 click)必须使用 task 队列。

网络任务源:
网络任务源被用来响应网络活动。

history traversal 任务源:
当调用 history.back()等类似的 api 时,将任务插进 task 队列。

task 任务源非常宽泛,比如 ajax 的 onload,click 事件,基本上我们经常绑定的各种事件都是 task 任务源,还有数据库操作(IndexedDB ),需要注意的是 setTimeout、setInterval、setImmediate 也是 task 任务源。总结来说 task 任务源:

  • setTimeout
  • setInterval
  • setImmediate
  • I/O
  • UI rendering

microtask

微任务队列不是任务队列。

每个事件循环都有一个微任务队列,这是一个微任务队列,最初是空的。微任务是一种口语化的方式,指的是通过微任务算法队列创建的任务。

如果在初期执行时,spin the event loop,microtasks 有可能被移动到常规的 task 队列,在这种情况下,microtasks 任务源会被 task 任务源所用。通常情况,task 任务源和 microtasks 是不相关的。

microtask 队列和 task 队列有些相似,都是先进先出的队列,由指定的任务源去提供任务,不同的是一个 event loop 里只有一个 microtask 队列。

HTML Standard 没有具体指明哪些是 microtask 任务源,通常认为是 microtask 任务源有:

  • process.nextTick
  • promises
  • Object.observe
  • MutationObserver

处理流程(processing model)

  1. 在 tasks 队列中选择最老的一个 task,用户代理可以选择任何 task 队列,如果没有可选的任务,则跳到下边的 microtasks 步骤。(从任务队列中取出一个宏任务并执行)

    1.1 将上边选择的 task 设置为正在运行的 task

    1.2 Run: 运行被选择的 task。

    1.3 将 event loop 的 currently running task 变为 null。

    1.4 从 task 队列里移除前边运行的 task。

  2. Microtasks: 执行 microtasks 任务检查点。(检查微任务队列,执行并清空微任务队列,如果在微任务的执行中又加入了新的微任务,也会在这一步一起执行。)

  3. 进入更新渲染阶段,判断是否需要渲染,这里有一个 rendering opportunity 的概念,也就是说不一定每一轮 event loop 都会对应一次浏览 器渲染,要根据屏幕刷新率、页面性能、页面是否在后台运行来共同决定,通常来说这个渲染间隔是固定的。(所以多个 task 很可能在一次渲染之间执行)

    • 浏览器会尽可能的保持帧率稳定,例如页面性能无法维持 60fps(每 16.66ms 渲染一次)的话,那么浏览器就会选择 30fps 的更新速率,而不是偶尔丢帧。
    • 如果浏览器上下文不可见,那么页面会降低到 4fps 左右甚至更低。
    • 如果满足以下条件,也会跳过渲染:
      • 浏览器判断更新渲染不会带来视觉上的改变。
      • map of animation frame callbacks 为空,也就是帧动画回调为空,可以通过 requestAnimationFrame 来请求帧动画。
  4. 如果上述的判断决定本轮不需要渲染,那么下面的几步也不会继续运行:

This step enables the user agent to prevent the steps below from running for other reasons, for example, to ensure certain tasks are executed immediately after each other, with only microtask checkpoints interleaved (and without, e.g., animation frame callbacks interleaved). Concretely, a user agent might wish to coalesce timer callbacks together, with no intermediate rendering updates.
有时候浏览器希望两次「定时器任务」是合并的,他们之间只会穿插着 microTask 的执行,而不会穿插屏幕渲染相关的流程

  1. 对于需要渲染的文档,如果窗口的大小发生了变化,执行监听的 resize 方法。

  2. 对于需要渲染的文档,如果页面发生了滚动,执行 scroll 方法。

  3. 对于需要渲染的文档,执行帧动画回调,也就是 requestAnimationFrame 的回调。(后文会详解)

  4. 对于需要渲染的文档, 执行 IntersectionObserver 的回调。

  5. 对于需要渲染的文档,重新渲染绘制用户界面。

  6. 判断 task 队列和 microTask 队列是否都为空,如果是的话,则进行 Idle 空闲周期的算法,判断是否要执行 requestIdleCallback 的回调函数。(后文会详解)

对于 resize 和 scroll 来说,并不是到了这一步才去执行滚动和缩放,那岂不是要延迟很多?浏览器当然会立刻帮你滚动视图,根据 CSSOM 规范所讲,浏览器会保存一个 pending scroll event targets,等到事件循环中的 scroll 这一步,去派发一个事件到对应的目标上,驱动它去执行监听的回调函数而已。resize 也是同理。

microtasks 检查点(microtask checkpoint)

上文 event loop 处理流程第 2 步,执行了一个 microtask checkpoint,看看规范如何描述 microtask checkpoint:

当用户代理去执行一个 microtask checkpoint,如果 microtask checkpoint 的 flag(标识)为 false,用户代理必须运行下面的步骤:

  1. 将 microtask checkpoint 的 flag 设为 true。
  2. Microtask queue handling: 如果 event loop 的 microtask 队列为空,直接跳到第八步(Done)。
  3. 在 microtask 队列中选择最老的一个任务。
  4. 将上一步选择的任务设为 event loop 的 currently running task。
  5. 运行选择的任务。
  6. 将 event loop 的 currently running task 变为 null。
  7. 将前面运行的 microtask 从 microtask 队列中删除,然后返回到第二步(Microtask queue handling)。
  8. Done: 每一个 environment settings object 它们的 responsible event loop 就是当前的 event loop,会给 environment settings object 发一个 rejected promises 的通知。
  9. 清理 IndexedDB 的事务。
  10. 将 microtask checkpoint 的 flag 设为 flase。

microtask checkpoint 所做的就是执行 microtask 队列里的任务。什么时候会调用 microtask checkpoint 呢?

  • 当上下文执行栈为空时,执行一个 microtask checkpoint。
  • 在 event loop 的第六步(Microtasks: Perform a microtask checkpoint)执行 checkpoint,也就是在运行 task 之后,更新渲染之前。

多任务队列

上文也提到了,每一个 task 都来源于指定的任务源, 所以 task 队列并不是我们想象中的那样只有一个,根据规范里的描述:

An event loop has one or more task queues. For example, a user agent could have one task queue for mouse and key events (to which the user interaction task source is associated), and another to which all other task sources are associated. Then, using the freedom granted in the initial step of the event loop processing model, it could give keyboard and mouse events preference over other tasks three-quarters of the time, keeping the interface responsive but not starving other task queues. Note that in this setup, the processing model still enforces that the user agent would never process events from any one task source out of order.

事件循环中可能会有一个或多个任务队列,这些队列分别为了处理:

  1. 鼠标和键盘事件
  2. 其他的一些 Task

浏览器会在保持任务顺序的前提下,可能分配四分之三的优先权给鼠标和键盘事件,保证用户的输入得到最高优先级的响应,而剩下的优先级交给其他 Task,并且保证不会“饿死”它们。

这个规范也导致 vue 的这个Issue。简单描述一下就是采用了 task 实现的 nextTick,在用户持续滚动的情况下 nextTick 任务被延后了很久才去执行,导致动画跟不上滚动了。

迫于无奈,尤大还是改回了 microTask 去实现 nextTick,目前来说 promise.then 微任务已经很稳定并且 vue3 就是这样实现的,并且 Chrome 也已经实现了 queueMicroTask 这个官方 API。我们想要调用微任务队列的话,也可以节省掉实例化 Promise 在开销了。

从这个 Issue 的例子中我们可以看出,稍微去深入了解一下规范还是比较有好处的,以免在遇到这种比较复杂的 Bug 的时候一脸懵逼。

执行栈(JavaScript execution context stack)

task 和 microtask 都是推入栈中执行的,要完整了解 event loops 还需要认识 JavaScript execution context stack,它的规范位于https://tc39.github.io/ecma262/#execution-context-stack。

javaScript 是单线程,也就是说只有一个主线程,主线程有一个栈,每一个函数执行的时候,都会生成新的 execution context(执行上下文),执行上下文会包含一些当前函数的参数、局部变量之类的信息,它会被推入栈中, running execution context(正在执行的上下文)始终处于栈的顶部。当函数执行完后,它的执行上下文会从栈弹出。

alt text

举个简单的例子:

1
2
3
4
5
6
7
8
9
10
function bar() {
console.log('bar')
}

function foo() {
console.log('foo')
bar()
}

foo()

执行过程中栈的变化:
alt text

完整异步过程

主线程类似一个加工厂,它只有一条流水线,待执行的任务就是流水线上的原料,只有前一个加工完,后一个才能进行。event loops 就是把原料放上流水线的工人。只要已经放在流水线上的,它们会被依次处理,称为同步任务。一些待处理的原料,工人会按照它们的种类排序,在适当的时机放上流水线,这些称为异步任务。

alt text

举个简单的例子,假设一个 script 标签的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
Promise.resolve().then(function promise1() {
console.log('promise1')
})
setTimeout(function setTimeout1() {
console.log('setTimeout1')
Promise.resolve().then(function promise2() {
console.log('promise2')
})
}, 0)

setTimeout(function setTimeout2() {
console.log('setTimeout2')
}, 0)

运行过程:

script 里的代码被列为一个 task,放入 task 队列。

  • 循环 1

  • 【task 队列:script ;microtask 队列:】

    • 1.从 task 队列中取出 script 任务,推入栈中执行。
    • 2.promise1 列为 microtask,setTimeout1 列为 task,setTimeout2 列为 task。
  • task 队列:setTimeout1 setTimeout2;microtask 队列:promise1】

    • 3.script 任务执行完毕,执行 microtask checkpoint,取出 microtask 队列的 promise1 执行。
  • 循环 2

  • 【task 队列:setTimeout1 setTimeout2;microtask 队列:】

    • 4.从 task 队列中取出 setTimeout1,推入栈中执行,将 promise2 列为 microtask。
  • 【task 队列:setTimeout2;microtask 队列:promise2】

    • 5.执行 microtask checkpoint,取出 microtask 队列的 promise2 执行。
  • 循环 3

  • 【task 队列:setTimeout2;microtask 队列:】

    • 6.从 task 队列中取出 setTimeout2,推入栈中执行。
    • 7.setTimeout2 任务执行完毕,执行 microtask checkpoint。
  • 【task 队列:;microtask 队列:】

结语

以上就是对 event loop 规范 和一些运行的流程做的总结。但是包括 requestAnimationFrame,requestIdleCallback 和 浏览器中 js 的执行机制等细节的总结将会单独写文章进行归纳总结。

介绍

GitLab CI/CD 是一个内置在 GitLab 中的工具,用于通过持续方法进行软件开发:

Continuous Integration(持续集成)

假设一个应用程序,其代码存储在 GitLab 的 Git 仓库中。开发人员每天都要多次推送代码更改。对于每次向仓库的推送,你都可以创建一组脚本来自动构建和测试你的应用程序,从而减少了向应用程序引入错误的机会。这种做法称为持续集成,对于提交给应用程序(甚至是开发分支)的每项更改,它都会自动连续进行构建和测试,以确保所引入的更改通过你为应用程序建立的所有测试,准则和代码合规性标准。

Continuous Delivery(持续交付)

持续交付是超越持续集成的更进一步的操作。应用程序不仅会在推送到代码库的每次代码更改时进行构建和测试,而且,尽管部署是手动触发的,但作为一个附加步骤,它也可以连续部署。此方法可确保自动检查代码,但需要人工干预才能从策略上手动触发以必输此次变更。

Continuous Deployment(持续部署)

与持续交付类似,但不同之处在于,你无需将其手动部署,而是将其设置为自动部署。完全不需要人工干预即可部署你的应用程序。

工作流程

为了使用 GitLab CI/CD,你需要一个托管在 GitLab 上的应用程序代码库,并且在根目录中的.gitlab-ci.yml 文件中指定构建、测试和部署的脚本。

在这个文件中,你可以定义要运行的脚本,定义包含的依赖项,选择要按顺序运行的命令和要并行运行的命令,定义要在何处部署应用程序,以及指定是否 要自动运行脚本或手动触发脚本。

为了可视化处理过程,假设添加到配置文件中的所有脚本与在计算机的终端上运行的命令相同。

一旦你已经添加了.gitlab-ci.yml 到仓库中,GitLab 将检测到该文件,并使用名为 GitLab Runner 的工具运行你的脚本。该工具的操作与终端类似。

这些脚本被分组到 jobs,它们共同组成一个 pipeline。一个最简单的.gitlab-ci.yml 文件可能是这样的:

1
2
3
4
5
6
before_script:
- apt-get install rubygems ruby-dev -y

run-test:
script:
- ruby --version 6

before_script 属性将在运行任何内容之前为你的应用安装依赖,一个名为 run-test 的 job(作业)将打印当前系统的 Ruby 版本。二者共同构成了在每次推送到仓库的任何分支时都会被触发的 pipeline(管道)。

GitLab CI/CD 不仅可以执行你设置的 job,还可以显示执行期间发生的情况,正如你在终端看到的那样:
74b8f022354a48a2a663a996ae2c1dd8

通过 GitLab UI 所有的步骤都是可视化的

aae548d462b9c6b5e8bab016b96c5512

实践

创建一个.gitlab-ci.yml 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
stages:
- lint
- release
commitlint:
stage: lint
rules:
- if: '$CI_COMMIT_TITLE =~ /^chore\(release\)/'
when: never
- if: '$CI_COMMIT_TITLE =~ /^Merge branch/'
when: never
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH # 一般当分支master有 push 或 merge 时才会执行该工作
script:
- sh bin/commitlint-gitlab-ci.sh
release:
stage: release
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
script:
- semantic-release

配置一个 Runner

在 GitLab 中,Runner 运行你定义在.gitlab-ci.yml 中的作业(job)
一个 Runner 可以是一个虚拟机、物理机、docker 容器,或者一个容器集群 GitLab 与 Runner 之间通过 API 进行通信,因此只需要 Runner 所在的机器有网络并且可以访问 GitLab 服务器即可
你可以去 Settings ➔ CI/CD 看是否已经有 Runner 关联到你的项目,设置 Runner 简单又直接
7b99f010133b6b0689a1dc90c2893831

查看 pipeline 和 jobs 状态

e57d2652f09e520231cae9358ac69e10

小结

团队计划使用 semantic-release 自动管理发布版本,结合 Gitlab CI/CD 是一个很不错的选择,具体的实践过程和心得会在后面分享。

概况图

71d6a82a01519413ca2a1be4b957c544

无痕模式

无痕模式可以保证 Chrome 在一个相对干净的环境下运行,避免 chrome 上安装的插件影响性能分析结果。
文件—>打开新的无痕式窗口,或使用快捷键 ctrl + shift + N 打开无痕模式下的 chrome 新标签页

性能记录

点击面板里的 ○,可以记录运行时的性能记录,如下图:
0b82ba7820c6d3ec5b21fe563191a08d

再次点击 Record 或者点击 Stop 停止记录。

加载时性能记录录制

若要分析页面记载时性能需要录制加载时性能。

  1. 打开待分析性能的页面。
  2. 打开 Devtools 中 Performance 窗口。
  3. 点击左上角重新加载按钮。DevTools 会自动记录页面加载是各项性能指标,加载完成几秒后自动停止记录。
    Devtools 记录会自动增加
    bcc3faa215dab1b20aefba15f42fa29d

页面加载时记录
8faf902b1637f885df323e7791713c33

清除记录

清除 Performance 窗口中的记录数据。

抓取运行时屏幕快照

点选 Screenshots 选项开启为每一帧记录屏幕快照功能。

查看内存度量值

点选 Memory 选项打开内存度量功能。
DevTools 在 Summary 面板上侧显示一个新的 Memory 图表。在 NET 图表下边也显示一个 HEAP 图表。HEAP 图表提供的信息同 Memory 面板中 JS Heap 提供的信息相同。

开启加速渲染工具

点选 Enable advanced paint instrumentation 选项(会带来大量的性能开销)

控制录制过程中 CPU 工作频率

将 CPU 设置为需要的运算速度模式。
886c6b4d80132d8e286af2d6ed89cdb9

CPU 工作频率的控制结果跟实际使用的机器能力有关。例如,4x slowdown 选项会使你本地 CPU 运算速率比正常情况下降低 4 倍。不同设备由于设计架构不同,Devtools 不能精确模拟移动端设备的 CPU 运算模式。

保存记录

da01643afd3f94a7927b96b2ddd7a044
会生成 JSON 文件

加载记录

单击鼠标右键选择 Load Profile 加载记录

分析性能记录

运行时或者加载时性能录制结束后,在 Performance 窗口中会显示相关数据,从而对于记录过程中的情况进行分析。

选择记录中的一部分

在 Overview 窗口中,可以选中记录的某一部分。 Overview 窗口指的是包含 FPS, CPU 和 NET 图表部分。
3c66d685cee9f55228ca867b5a07a88e

查看主线程活动

利用 Main 区域查看页面主线程加载时的主要活动。
26247567fd6f9281d4a16d8fcbcba32f

点击某一函数在 Summary 窗口中查看更多详细信息。如图中所示 DevTools 选中_getRequestData 事件。
DevTools 采用随机的颜色标识脚本信息,如上面图中浅绿色标识函数调用,深黄色标识脚本活动。
若想隐藏火焰图中的 JavaScript 中调用的详细信息,请查看前面介绍的禁用 JavaScript 样例功能。若禁用 JavaScript 样例功能,你只可以看到初始调用事件。比如,图中标识的 Timer fired 和 Function Call 。

在表格中查看活动

录制结束后,利用 Main 窗口中信息不是分析数据的唯一方式。DevTools 另外提供了三种表格式分析活动方式,每种方式都是从不同的角度出发:
若想分析那些活动占用时间更多时,可以利用 Bottom-Up 窗口。
若想分析导致更多活动的根活动时,可以采用 Call Tree。
若想按顺序分析记录中发生的活动时,可以利用 Event Log 窗口。

根活动

根活动指的是浏览器触发的一系列流程。例如,当你点击页面内容,浏览器触发一个 Event 作为根活动,该 Event 可能回调一个事件处理事件。
在 Main 面板中的火焰图中,根活动展示在上部,在 Call Tree 和 Event Log 面板中,根活动展示在顶层。

Call Tree 标签页

Call Tree 标签页中展示记录中被选中部分的活动信息。
6f4e0500bac02ec6143b950c159c63ae

图中 Activity 列中显示的 Timer fired、 Paint、Recalculate Style 和 Layout 代表根活动。层级嵌套表示代表回调栈。如图中 Function Call 调用 u,再调用 getImageUrl,继续调用 getLinkUrl 等等。
Self Time 表示对应活动消耗的时间,Total Time 表示对应活动以及子活动共同消耗的时间。
点击 Self Time,Total Time 或者 Activity 表头区域,可按对应列排序。
利用 Filter 输入框区域,输入活动名过滤事件。
Grouping 分组菜单默认为 No Grouping,利用该功能可以根据不同的分类将活动进行分组。
点击右侧 Show Heaviest Stack,在右侧展示当前选中活动中占用时间最多的子活动信息。

6562aca6f7e21ca7a5746f1cff491455

Bottom-Up 标签页

利用 Bottom-Up 标签查看占用最多时间的活动。
Self Time 表示对应活动消耗的时间。
Total Time 表示对应活动以及子活动共同消耗的时间。

Event Log 标签页

Event Log 标签页按顺序展示记录中发生的活动。
Start Time 列表示该项活动的开始时间,该时间相对于记录开始时间计算。例如图中选中项开始时间为 1566.2 ms,代表该活动在记录开始之后 1566.2 ms 后开始。
Self Time 表示对应活动消耗的时间。
Total Time 表示对应活动以及子活动共同消耗的时间。
点击 Start Time 、Self Time、Total Time 表头区域,可按对应列排序。
利用 Filter 输入框区域,输入活动名过滤事件。
利用 Duration 下拉菜单过滤>=1ms 或者>=15ms 的活动。该菜单默认选中 All 选项,展示所有活动。
利用 Loading、Experience、Scripting、Rendering、Painting 选项进行分类过滤。
58c29a0d627c26c7d9fc1afd80a348ef

分析每秒传输帧数(FPS)

  • 查看 FPS 图表了解整个记录中 FPS 的概况。
  • Frames 模块查看每一帧时间消耗。
  • 利用 FPS meter 工具(MoreTools—>Rendering)在页面运行时实时查看 FPS 信息。

FPS 图表

FPS 图表显示了整个记录过程中帧率的概况。图表中绿色折线越高代表帧率越好。
FPS 折线图上测出现的红色横线为一条性能警示线,表示帧率低于该值会严重影响用户体验。

Frames 模块

Frames 模块清晰表明每个帧消耗时间。
鼠标在某一帧上悬停可以查看更多详细信息。

查看交互信息

利用 Interactions 模块查看并分析记录过程中用户的交互操作。

查看 GPU 活动

在 GPU 模块查看 GPU 活动信息
69c660e3d248b30ffc310f2813525e08

查看栅格活动

在 Raster 模块查看栅格活动信息