响应式原理:Vue 3 的 nextTick ?

响应式原理:Vue 3 的 nextTick ?

了解了对于 [Vue
3]{.mark} 中的响应式原理:我们通过对 [state]{.mark} 数据的响应式拦截,当触发 [proxy
setter]{.mark} 的时候,执行对应状态的 [effect]{.mark} 函数。接下来看一个经典的例子

前言

了解了对于 [Vue
3]{.mark} 中的响应式原理:我们通过对 [state]{.mark} 数据的响应式拦截,当触发 [proxy
setter]{.mark} 的时候,执行对应状态的 [effect]{.mark} 函数。接下来看一个经典的例子:

<template>

<div></div>

<button @click="handleClick">click</button>

</template>

<script>

import { ref } from 'vue';

export default {

setup() {

const number = ref(0)

function handleClick() {

for (let i = 0; i < 1000; i++) {

number.value ++;

}

}

return {

number,

handleClick

}

}

}

</script>

当我们按下 [click]{.mark} 按钮的时候,[number]{.mark} 会被循环增加 [1000]{.mark} 次。那么 [Vue]{.mark} 的视图会在点击按钮的时候,从 [1
-> 1000]{.mark} 刷新 [1000]{.mark} 次吗?这一小节,我们将一起探探究竟。

queueJob

我们小册第四节介绍关于”组件更新策略”的时候,提到了 [setupRenderEffect]{.mark} 函数:

const setupRenderEffect = (instance, initialVNode, container,
anchor, parentSuspense, isSVG, optimized) => {

function componentUpdateFn() {

if (!instance.isMounted) {

// 初始化组件

}

else {

// 更新组件

}

}

// 创建响应式的副作用渲染函数

instance.update = effect(componentUpdateFn, prodEffectOptions)

}

当时这里为了方便介绍组件的更新策略,我们简写了 [instance.update]{.mark} 的函数创建过程,现在我们来详细看一下 [instance.update]{.mark} 这个函数的创建:

const setupRenderEffect = (instance, initialVNode, container,
anchor, parentSuspense, isSVG, optimized) => {

function componentUpdateFn() {

// ...

}

// 创建响应式的副作用渲染函数

const effect = (instance.effect = new ReactiveEffect(

componentUpdateFn,

() => queueJob(update),

instance.scope

))

// 生成 instance.update 函数

const update = (instance.update = () => effect.run())

update.id = instance.uid

// 组件允许递归更新

toggleRecurse(instance, true)

// 执行更新

update()

}

可以看到在创建 [effect]{.mark} 副作用函数的时候,会给 [ReactiveEffect]{.mark} 传入一个 [scheduler]{.mark} 调度函数,这样生成的 [effect]{.mark} 中就包含了 [scheduler]{.mark} 属性。同时为组件实例生成了一个 [update]{.mark} 属性,该属性的值就是执行 [effect.run]{.mark} 的函数,另外需要注意的一点是 [update]{.mark} 中包含了一个 [id]{.mark} 信息,该值是一个初始值为 [0]{.mark} 的自增数字,下文我们再详细介绍其作用。

当我们触发 [proxy
setter]{.mark} 的时候,触发执行了 [triggerEffect]{.mark} 函数,这次,我们补全 [triggerEffect]{.mark} 函数的实现:

function triggerEffect(effect, debuggerEventExtraInfo) {

if (effect !== activeEffect || effect.allowRecurse) {

// effect 上存在 scheduler

if (effect.scheduler) {

effect.scheduler()

} else {

effect.run()

}

}

}

可以看到,如果 [effect]{.mark} 上有 [scheduler]{.mark} 属性时,执行的是 [effect.scheduler]{.mark} 函数,否则执行 [effect.run]{.mark} 进行视图更新。而这里显然我们需要先执行调度函数 [scheduler]{.mark}。通过上面的信息,我们也清楚地知道 [scheduler]{.mark} 函数的本质就是执行了 [queueJob(update)]{.mark} 函数,一起来看一下 [queueJob]{.mark} 的实现:

export function queueJob(job) {

// 去重判断

if (

!queue.length ||

!queue.includes(

job,

isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex

)

) {

// 添加到队列尾部

if (job.id == null) {

queue.push(job)

} else {

// 按照 job id 自增的顺序添加

queue.splice(findInsertionIndex(job.id), 0, job)

}

queueFlush()

}

}

[queueJob]{.mark} 就是维护了一个 [queue]{.mark} 队列,目的是向 [queue]{.mark} 队列中添加 [job]{.mark} 对象,这里的 [job]{.mark} 就是我们前面的 [update]{.mark} 对象。

这里有几点需要说明一下。

第一个是该函数会有一个 [isFlushing &&
job.allowRecurse]{.mark} 判断,这个作用是啥呢?简单点说就是当队列正处于更新状态中([isFlushing
= true]{.mark}) 且允许递归调用( [job.allowRecurse =
true]{.mark})时,将搜索起始位置加一,无法搜索到自身,也就是允许递归调用了。什么情况下会出现递归调用?

<!-- 父组件 -->

<template>

<div></div>

<Child />

</template>

<script>

import { ref, provide } from 'vue';

import Child from './components/Child.vue';

export default {

setup() {

const msg = ref("initial");

provide("CONTEXT", { msg });

return {

msg

};

},

components: {

Child

}

}

</script>

<!-- 子组件 Child -->

<template>

<div>child</div>

</template>

<script>

import { inject } from 'vue';

export default {

setup() {

const ctx = inject("CONTEXT");

ctx.msg.value = "updated";

}

}

</script>

对于这种情况,首先是父组件进入 [job]{.mark} 然后渲染父组件,接着进入子组件渲染,但是子组件内部修改了父组件的状态 [msg]{.mark}。此时父组件需要支持递归渲染,也就是递归更新。

注意,这里的更新已经不属于单选数据流了,如果过多地打破单向数据流,会导致多次递归执行更新,可能会导致性能下降。

第二个是,[queueJob]{.mark} 函数向 [queue]{.mark} 队列中添加的 [job]{.mark} 是按照 [id]{.mark} 排序的,[id]{.mark} 小的 [Job]{.mark} 先被推入 [queue]{.mark} 中执行,这保证了,父组件永远比子组件先更新(因为先创建父组件,再创建子组件,子组件可能依赖父组件的数据)。

再回到函数的本身来说,当我们执行 [for]{.mark} 循环 [1000]{.mark} 次 [setter]{.mark} 的时候,因为在第一步进行了去重判断,所以 [update]{.mark} 函数只会被添加一次到 [queue]{.mark} 中。这里的 [update]{.mark} 函数就是组件的渲染函数。所以无论这里执行多少次循环,渲染更新函数只会被执行一次。

queueFlush

上面说到了无论循环多少次 [setter]{.mark},这里相同 [id]{.mark} 的 [update]{.mark} 只会被添加一次到 [queue]{.mark} 中。

细心的小伙伴可能会有这样的疑问:那么为什么视图不是从 [0 ->
1]{.mark} 而是直接从 [0 -> 1000]{.mark} 了呢?

要回答上面的问题,就得了解一下 [queue]{.mark} 的执行更新相关的内容了,也就是 [queueJob]{.mark} 的最后一步 [queueFlush]{.mark}:

function queueFlush() {

// 是否正处于刷新状态

if (!isFlushing && !isFlushPending) {

isFlushPending = true

currentFlushPromise = resolvedPromise.then(flushJobs)

}

}

可以看到这里,[vue
3]{.mark} 完全抛弃了除了 [promise]{.mark} 之外的异步方案,不再支持[vue
2]{.mark} 的 [Promise > MutationObserver > setImmediate >
setTimeout]{.mark} 其他三种异步操作了。

所以这里,[vue
3]{.mark} 直接通过 [promise]{.mark} 创建了一个微任务 [flushJobs]{.mark} 进行异步调度更新,只要在浏览器当前 [tick]{.mark} 内的所有更新任务都会被推入 [queue]{.mark} 中,然后在下一个 [tick]{.mark} 中统一执行更新。

function flushJobs(seen) {

// 是否正在等待执行

isFlushPending = false

// 正在执行

isFlushing = true

// 在更新前,重新排序好更新队列 queue 的顺序

// 这确保了:

// 1.
组件都是从父组件向子组件进行更新的。(因为父组件都在子组件之前创建的

// 所以子组件的渲染的 effect 的优先级比较低)

// 2. 如果父组件在更新前卸载了组件,这次更新将会被跳过。

queue.sort(comparator)

try {

// 遍历主任务队列,批量执行更新任务

for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {

const job = queue[flushIndex]

if (job && job.active !== false) {

callWithErrorHandling(job, null, ErrorCodes.SCHEDULER)

}

}

} finally {

// 队列任务执行完,重置队列索引

flushIndex = 0

// 清空队列

queue.length = 0

// 执行后置队列任务

flushPostFlushCbs(seen)

// 重置队列执行状态

isFlushing = false

// 重置当前微任务为 Null

currentFlushPromise = null

// 如果主任务队列、后置任务队列还有没被清空,就继续递归执行

if (queue.length || pendingPostFlushCbs.length) {

flushJobs(seen)

}

}

}

在详细介绍 [flushJobs]{.mark} 之前,我想先简单介绍一下 [Vue]{.mark} 的更新任务执行机制中的一个重要概念:更新时机。 [Vue]{.mark} 整个更新过程分成了三个部分:

更新前,称之为

pre 阶段;

更新中,也就是

flushing 中,执行

update 更新;

更新后,称之为

flushPost 阶段。

更新前

什么是 [pre]{.mark} 阶段呢?拿组件更新举例,就是在 [Vue]{.mark} 组件更新之前被调用执行的阶段。默认情况下,[Vue]{.mark} 的 [watch]{.mark} 和 [watchEffect]{.mark} 函数中的 [callback]{.mark} 函数都是在这个阶段被执行的,我们简单看一下 [watch]{.mark} 中的源码实现:

function watch(surce, cb, {immediate, deep, flush, onTrack,
onTrigger} = {}) {

// ...

if (flush === 'sync') {

scheduler = job

} else if (flush === 'post') {

scheduler = () => queuePostRenderEffect(job, instance &&
instance.suspense)

} else {

// 默认会给 job 打上 pre 的标记

job.pre = true

if (instance) job.id = instance.uid

scheduler = () => queueJob(job)

}

}

可以看到 [watch]{.mark} 的 [job]{.mark} 会被默认打上 [pre]{.mark} 的标签。而带 [pre]{.mark} 标签的 [job]{.mark} 则会在渲染前被执行:

const updateComponent = () => {

// ... 省略 n 行代码

updateComponentPreRender(instance, n2, optimized)

}

function updateComponentPreRender() {

// ... 省略 n 行代码

flushPreFlushCbs()

}

export function flushPreFlushCbs(seen, i = isFlushing ? flushIndex + 1 :
0) {

for (; i < queue.length; i++) {

const cb = queue[i]

if (cb && cb.pre) {

queue.splice(i, 1)

i--

cb()

}

}

}

可以看到,在执行 [updateComponent]{.mark} 更新组件之前,会调用 [flushPreFlushCbs]{.mark} 函数,执行所有带上 [pre]{.mark} 标签的 [job]{.mark}。

更新中

更新中的过程就是 [flushJobs]{.mark} 函数体前面的部分,首先会通过一个 [comparator]{.mark} 函数对 [queue]{.mark} 队列进行排序,这里排序的目的主要是保证父组件优先于子组件执行,另外在执行后续循环执行 [job]{.mark} 任务的时候,通过判断 [job.active
!==
false]{.mark} 来剔除被 [unmount]{.mark} 卸载的组件,卸载的组件会有 [active
= false]{.mark} 的标记。

最后即通过 [callWithErrorHandling]{.mark} 函数执行 [queue]{.mark} 队列中的每一个 [job]{.mark}:

export function callWithErrorHandling(fn, instance, type,
args) {

let res

try {

res = args ? fn(...args) : fn()

} catch (err) {

handleError(err, instance, type)

}

return res

}

更新后

当页面更新后,需要执行的一些回调函数都存储在 [pendingPostFlushCbs]{.mark} 中,通过 [flushPostFlushCbs]{.mark} 函数来进行回调执行:

export function flushPostFlushCbs(seen) {

// 存在 job 才执行

if (pendingPostFlushCbs.length) {

// 去重

const deduped = [...new Set(pendingPostFlushCbs)]

pendingPostFlushCbs.length = 0

// #1947 already has active queue, nested flushPostFlushCbs call

// 已经存在activePostFlushCbs,嵌套flushPostFlushCbs调用,直接return

if (activePostFlushCbs) {

activePostFlushCbs.push(...deduped)

return

}

activePostFlushCbs = deduped

// 按job.id升序

activePostFlushCbs.sort((a, b) => getId(a) - getId(b))

// 循环执行job

for (

postFlushIndex = 0;

postFlushIndex < activePostFlushCbs.length;

postFlushIndex++

) {

activePostFlushCbs[postFlushIndex]()

}

activePostFlushCbs = null

postFlushIndex = 0

}

}

一些需要渲染完成后再执行的钩子函数都会在这个阶段执行,比如 [mounted
hook]{.mark} 等等。

总结

通过上面的一些介绍,我们可以了解到本小节开头的示例中,[number]{.mark} 的更新函数只会被同步地添加一次到更新队列 [queue]{.mark} 中,但更新是异步的,会在 [nextTick]{.mark} 也就是 [Promise.then]{.mark} 的微任务中执行 [update]{.mark},所以更新会直接从 [0
-> 1000]{.mark}。

另外,需要注意的是一个组件内的相同 [update]{.mark} 只会有一个被推入 [queue]{.mark} 中。比如下面的例子:

<template>

<div></div>

<div></div>

<button @click="handleClick">click</button>

</template>

<script>

import { ref } from 'vue'

export default {

setup() {

const number = ref(0)

const msg = ref('init')

function handleClick() {

for (let i = 0; i < 1000; i++) {

number.value ++;

}

msg.value = 'hello world'

}

return {

number,

msg,

handleClick

}

}

}

</script>

当点击按钮时,因为 [update]{.mark} 内部执行的是当前组件的同一个 [componentUpdateFn]{.mark} 函数,状态 [msg]{.mark} 和 [number]{.mark} 的 [update]{.mark} 的 [id]{.mark} 是一致的,所以 [queue]{.mark} 中,只有一个 [update]{.mark} 函数,只会进行一次统一的更新。

# 推荐文章
  1.卡片翻动效果
  2.每日进步:一篇文章带你走进3D的世界
  3.每日进步:动画的暂停与恢复
  4.每日进步:大整数相加
  5.响应式原理:Vue 3 的 nextTick ?

:D 舔狗日记获取中...