实现mini-vue -- runtime-core模块(二十)实现nextTick

1,073 阅读2分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 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'),
    ]);
  },
};

运行效果如下: 数据和视图同步更新.gif 可以看到,数据更新了 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();
    }
  });
}

当且仅当微任务队列中不存在该任务的时候才会添加,这里的添加只是将它添加到一个队列,但是我们并没有把它放到微任务中执行,要想放到微任务中,我们还需要使用到Promisethen方法,可以使用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 次是否能让视图只更新一次 image.png 可以看到,控制台只输出了一次视图更新的日志,说明已经变成异步执行了,不会在每次数据更新时都去重新渲染


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元素 image.png 可以很明显的看到,虽然数据更新了,但是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'),
    ]);
  },
};

image.png 可以看到,已经能够在nextTick中获取到更新后的DOM