持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第28天,点击查看活动详情
vue3
中对于数据和视图的更新是异步的,也就是说响应式数据更新的时候,不会立即去更新视图,而是将视图更新的逻辑放到微任务中执行
那么这就导致一个问题,如果我们在处理数据的时候,希望获取到视图更新后的组件实例的话,是没法在当前的同步代码中获取到的,因为此时视图还没有更新,这就要用到今天要讲的nextTick
去解决了,先来看看nextTick
的应用场景
1. 为什么要使用 nextTick?
首先先看一下下面这个使用场景
export const App = {
name: 'App',
setup() {
const count = ref(0);
const addCount = () => {
for (let i = 0; i < 100; i++) {
count.value++;
}
};
return {
count,
addCount,
};
},
render() {
return h('div', {}, [
h('p', {}, `count: ${this.count}`),
h('button', { onClick: this.addCount }, 'add count by 100 steps'),
]);
},
};
运行效果如下: 可以看到,数据更新了 100 次,但是也跟着调用了一百次组件的
render
函数更新了 100 次视图,但其实这 100 次更新是完全没必要的,目前之所以会出现这种情况,是因为数据和视图是同步更新的
也就是说数据一发生改变,就会立刻在当前执行的宏任务中立刻更新视图,如果能够在当前宏任务中更新数据,将视图的更新推迟到微任务中执行,这样就能让数据和视图的更新变成异步执行,从而解决这个问题
异步执行之后的效果就是数据更新了 100 次,但是视图的更新会在数据更新完毕之后才去执行一次,大大减少了渲染压力,毕竟现在这个场景还算简单,但如果是复杂场景的渲染,执行这么多次不必要的渲染无疑是不合理的
2. 将视图更新推迟到微任务中执行
将当前数据更新的代码执行视为宏任务执行处,那么将视图的更新放到微任务中的话,就会在下一次事件循环执行下一个宏任务之前先将微任务依次执行,所以我们需要实现一个将渲染任务添加到微任务队列的功能,这个能做到吗?
完全可以!之前实现响应式模块的时候,我们有给effect
函数添加一个scheduler
的功能,回顾一下它的作用,就是在首次执行effect
的时候会执行传入的函数fn
,之后触发依赖的时候,并不会去执行fn
,而是执行scheduler
中的函数(如果有传的话)
在setupRenderEffect
中我们使用到了effect
包裹渲染函数调用的逻辑,我们现在只希望首次渲染的时候会走这个逻辑,后续触发依赖,准备更新视图的时候,如果能够走scheduler
,在scheduler
中将渲染任务添加到微任务中那不就解决了吗!
Talk is cheap, show me the code
,直接开始肝相关代码!
function setupRenderEffect(instance, container, anchor) {
instance.update = effect(
() => {
// ...
},
{
scheduler() {
// 将渲染推迟到微任务队列中执行
queueJob(instance.update);
},
}
);
}
queueJob
是一个将任务添加到微任务队列中的函数,接下来我们就要去实现这个函数,由于它是scheduler
的功能,所以我们可以单独创建一个scheduler.ts
去处理scheduler
相关的逻辑
3. 实现一个微任务队列
在scheduler.ts
中维护一个微任务队列,以及相应的添加任务到微任务队列的函数
// 微任务队列
const queue: any[] = [];
export function queueJob(job) {
if (!queue.includes(job)) {
queue.push(job);
}
Promise.resolve().then(() => {
let job: any;
while ((job = queue.shift())) {
job && job();
}
});
}
当且仅当微任务队列中不存在该任务的时候才会添加,这里的添加只是将它添加到一个队列,但是我们并没有把它放到微任务中执行,要想放到微任务中,我们还需要使用到Promise
的then
方法,可以使用Promise.resolve()
得到一个resolved
状态的promise
对象,然后在它的then
方法中编写微任务
我们的微任务很简单,就是将我们自己维护的微任务队列中的任务依次出队并执行
3.1 重构微任务队列的执行
这里Promise
的处理实际上就是一个清空微任务队列的过程,出于语义化的目的,可以将它抽离到一个函数中
// 微任务队列
const queue: any[] = [];
export function queueJob(job) {
if (!queue.includes(job)) {
queue.push(job);
}
queueFlush();
}
function queueFlush() {
Promise.resolve().then(() => {
let job: any;
while ((job = queue.shift())) {
job && job();
}
});
}
4. 测试视图更新是否变为异步执行
这次我们再打开浏览器运行一下刚刚的demo
看看数据更新 100 次是否能让视图只更新一次 可以看到,控制台只输出了一次视图更新的日志,说明已经变成异步执行了,不会在每次数据更新时都去重新渲染
5. 优化 queueFlush
试想一下,如果我们有多个微任务要放进微任务队列中,那么每加入一次微任务都会调用一次queueFlush
,而每次调用ququeFlush
都会创建Promise
实例,但实际上我们需要的微任务就一个刷新微任务队列而已,因此一个Promise
实例就够了,所以这里可以进行一下优化,可以用一个isFlushPending
标志变量去控制是否需要创建Promise
实例
// 微任务队列
const queue: any[] = [];
+ let isFlushPending = false;
export function queueJob(job) {
if (!queue.includes(job)) {
queue.push(job);
}
queueFlush();
}
function queueFlush() {
+ if (isFlushPending) return;
+ isFlushPending = true;
Promise.resolve().then(() => {
+ isFlushPending = false;
let job: any;
while ((job = queue.shift())) {
job && job();
}
});
}
这样一来如果一下子添加很多个微任务的时候,只有第一次添加微任务时会创建Promise
实例,并将isFlushPending
置为true
了,后续的微任务添加时就不会再去创建Promise
实例,而Promise
中的微任务开始执行时再将isFlushPending
关闭,表示当前处理清空微任务队列的Promise
已经进入resolved
状态,可以创建新的Promise
实例去处理下一次清空微任务队列了
6. nextTick 的使用场景
现在我们修改一下我们的demo
,假如我们想在数据更新之后,获取到数据更新后的DOM
元素
export const App = {
name: 'App',
setup() {
const instance = getCurrentInstance();
const count = ref(0);
const addCount = () => {
for (let i = 0; i < 100; i++) {
count.value++;
}
+ debugger;
+ console.log(instance.vnode.el);
};
return {
count,
addCount,
};
},
render() {
return h('div', {}, [
h('p', {}, `count: ${this.count}`),
h('button', { onClick: this.addCount }, 'add count by 100 steps'),
]);
},
};
然后我们点击修改数据后,停在更新数据后的断点处,看一下此时的DOM
元素 可以很明显的看到,虽然数据更新了,但是
DOM
仍然是更新之前的,这是因为视图的更新已经被推迟到微任务中执行了,但是我们现在就是想获取到更新后的DOM
做一些操作该怎么办呢?
这个时候就要有nextTick
机制来帮我们实现了,可以将对更新后DOM
的操作用nextTick
包装,然后我们把nextTick
也作为微任务,加入到微任务队列中
注意:需要在视图渲染的微任务之后添加,这样才能保证**nextTick**
中访问到的是更新后的**DOM**
7. 实现 nextTick
在scheduler.ts
中实现nextTick
export function nextTick(fn) {
return fn ? Promise.resolve().then(fn) : Promise.resolve();
}
就是将传入的函数放到微任务中执行,由于点击按钮时,是先执行scheduler
中的queueJob
将渲染任务添加到微任务队列中,渲染微任务会执行render
函数,而以上面的场景为例,我们的nextTick
会在按钮点击后执行addCount
回调中才执行
这个过程中是先由响应式数据变更,触发scheduler
中的queueJob
,然后再执行nextTick
,所以可以保证nextTick
中的Promise
微任务是排在渲染任务后面的
8. 使用 nextTick 重构 queueFlush
看看目前的queueFlush
是怎样的:
function queueFlush() {
if (isFlushPending) return;
isFlushPending = true;
Promise.resolve().then(() => {
isFlushPending = false;
let job: any;
while ((job = queue.shift())) {
job && job();
}
});
}
可以看到,queueFlush
中也是通过Promise
来添加微任务,这和nextTick
添加微任务的方式一样,那么我们就可以把整个刷新微任务队列的操作也作为一个任务,复用nextTick
,让nextTick
帮我们把这个逻辑使用Promise
添加到微任务队列中
function queueFlush() {
if (isFlushPending) return;
isFlushPending = true;
nextTick(flushJobs);
}
function flushJobs() {
isFlushPending = false;
let job: any;
while ((job = queue.shift())) {
job && job();
}
}
同样也是为了语义化,所以把这个清空微任务队列的操作抽离到一个flushJobs
的函数中,并且用nextTick
把它推迟到微任务中执行
9. 重构 nextTick 中的多个 Promise 实例
目前nextTick
的实现中可能会创建多个Promise
实例,我们可以把使用到的两个Promise
实例抽离成同一个实例
const resolvedPromise = Promise.resolve();
export function nextTick(fn) {
return fn ? resolvedPromise.then(fn) : resolvedPromise;
}
10. 在 runtime-core 入口中导出 nextTick
为了能够在打包结果中使用到nextTick
,我们还需要在runtime-core
模块的index.ts
中将其导出
export { h } from './h';
export { renderSlots } from './helpers/renderSlots';
export { createTextVNode } from './vnode';
export { getCurrentInstance } from './component';
export { provide, inject } from './apiInject';
export { createRenderer } from './renderer';
+ export { nextTick } from './scheduler';
现在我们回到我们的demo
中使用一下这个nextTick
,看看能否在其当中获取到更新后的DOM
export const App = {
name: 'App',
setup() {
const instance = getCurrentInstance();
const count = ref(0);
const addCount = () => {
for (let i = 0; i < 100; i++) {
count.value++;
}
- debugger;
- console.log(instance.vnode.el);
+ nextTick(() => {
+ debugger;
+ console.log(instance.vnode.el);
+ });
};
return {
count,
addCount,
};
},
render() {
return h('div', {}, [
h('p', {}, `count: ${this.count}`),
h('button', { onClick: this.addCount }, 'add count by 100 steps'),
]);
},
};
可以看到,已经能够在
nextTick
中获取到更新后的DOM
了