blog icon indicating copy to clipboard operation
blog copied to clipboard

Vue源码探秘(nextTick)

Open Cosen95 opened this issue 4 years ago • 1 comments

引言

前两节我带大家分别分析了依赖收集派发更新。在上一节的最后,提到了nextTick,这一节我们一起来看下nextTick 的源码实现。

为什么要异步更新

我们先思考一个问题:Vue为什么要引入异步更新队列这一概念?

其实这块,在上一节有提到。如果渲染 Watcher 的回调是同步执行的,那执行流程是这样子的:在修改一个属性值的时候,会触发它的 setter ,然后就马上去触发渲染 Watcher 的回调,引起页面的重新渲染,乍看好像没有什么问题。

但是我们在实际开发中经常会在一个函数内修改多个属性,也就是说这些属性是同时或者说几乎同时被修改了,那实际上渲染 Watcher 完全可以等同一时间点的所有属性都修改完了再去执行回调重新渲染。

而这对于同步 Watcher 是无法实现的,有几个属性修改它就会被触发渲染页面几次,显然这会造成严重的性能问题。

这也就是引入异步更新队列的意义所在,在数据修改时,不会直接触发 Watcher 的回调,而是先把它放入一个异步队列中,并且我们可以通过相关逻辑控制当有多个属性同时修改时同个 Watcher 不会被重复添加到队列中。

因为有 nextTick 的存在,Watcher 的回调是异步执行的,所以它会一直等待同一时刻被修改的属性的 setter 都触发完,相关 Watcher 都添加入队列中后(也就是同步代码执行完后),才会触发渲染 Watcher 重新渲染,这样页面只需要重新渲染一次,性能也得到了很大的提升。

js 运行机制

由于 nextTick 涉及到了 macrotaskmicrotask 的概念。而这又与js的运行机制有关,所以这里我们有必要先大概了解一下js的运行机制,也有助于我们理解 nextTick

单线程

js 是一门单线程语言,或者说它只有一个主线程。

为什么 js 不能是多线程呢,这是因为 js 是一种与浏览器交互的语言。假设 js 是多线程,然后同时有多个线程操作一个 DOM 节点,那浏览器要以哪个线程为准呢,这显然会造成错乱。

js 引擎

一个典型的浏览器会有图形引擎和一个 js 引擎。js 引擎是一个专门处理 js 脚本的虚拟机,比如 Chromejs 引擎 V8

执行上下文和执行栈

js 在执行一个函数的时候,会创建这个函数的执行上下文,并将执行上下文压入执行栈。当前执行上下文在执行栈的栈顶,当执行完后会弹出栈。

事件循环和任务队列

浏览器的Event loopNodeEvent loop是两个概念。具体可以参考https://segmentfault.com/a/1190000013861128

这里我主要说下在浏览器的Event loop中的microtaskmacrotask

实际上异步任务有两种,分别是微任务 microtask 和宏任务 macrotask 。像是 PromisesMutationObserver 还有 node.js 中的 process.nextTick 都属于微任务;而 setIntervalsetTimeoutsetImmediaterequestAnimationFrameDOM 事件回调则属于宏任务。因此相对的任务队列也分为微任务队列宏任务队列

microtaskmacrotask 的区别在哪里呢?当主线程处于闲置状态时,会先去微任务队列看是否有事件需要执行,有的话会执行完微任务队列的所有事件直到微任务队列为空。然后再去宏任务队列看,有的话则取出第一位的事件执行。

也就是说在每一次事件循环中,只会提取一个 macrotask 出来执行,而 microtask 会一直提取,直到微任务队列清空,并且 microtask 先于 macrotask 执行。

同样举一个例子来帮助理解:

setTimeout(() => {
  console.log(1);
}, 0);
new Promise((resolve) => {
  console.log(2);
  resolve(3);
}).then((res) => {
  console.log(res);
});
console.log(4);
setTimeout(() => {
  console.log(5);
}, 0);

上述例子的输出结果是 2、4、3、1、5 。因为 Promise 属于 microtasksetTimeout 属于 macrotask ,会先输出 3 再输出 1、5 。

nextTick

nextTick 除了有提供给内部使用的接口,也有暴露给外部使用的接口。从官方文档我们可以了解到,外部接口有 vm.$nextTickVue.nextTick 。其中 vm.$nextTick 定义在 src/core/instance/render.js 文件中:

// src/core/instance/render.js

export function renderMixin(Vue: Class<Component>) {
  // ...
  Vue.prototype.$nextTick = function (fn: Function) {
    return nextTick(fn, this);
  };
  // ...
}

Vue.nextTick 定义在 src/core/global-api/index.js 文件中:

// src/core/global-api/index.js

export function initGlobalAPI(Vue: GlobalAPI) {
  // ...
  Vue.nextTick = nextTick;
  // ...
}

这两个外部接口实际上都是调用了内部的 nextTick 接口,和 nextTick 相关的代码都定义在 src/core/util/next-tick.js 文件中:

// src/core/util/next-tick.js
/* @flow */
/* globals MutationObserver */

import { noop } from "shared/util";
import { handleError } from "./error";
import { isIE, isIOS, isNative } from "./env";

export let isUsingMicroTask = false;

const callbacks = [];
let pending = false;

function flushCallbacks() {
  // ...
}

/* istanbul ignore next, $flow-disable-line */
if (typeof Promise !== "undefined" && isNative(Promise)) {
  const p = Promise.resolve();
  timerFunc = () => {
    p.then(flushCallbacks);
    if (isIOS) setTimeout(noop);
  };
  isUsingMicroTask = true;
} else if (
  !isIE &&
  typeof MutationObserver !== "undefined" &&
  (isNative(MutationObserver) ||
    // PhantomJS and iOS 7.x
    MutationObserver.toString() === "[object MutationObserverConstructor]")
) {
  // Use MutationObserver where native Promise is not available,
  // e.g. PhantomJS, iOS7, Android 4.4
  // (#6466 MutationObserver is unreliable in IE11)
  let counter = 1;
  const observer = new MutationObserver(flushCallbacks);
  const textNode = document.createTextNode(String(counter));
  observer.observe(textNode, {
    characterData: true,
  });
  timerFunc = () => {
    counter = (counter + 1) % 2;
    textNode.data = String(counter);
  };
  isUsingMicroTask = true;
} else if (typeof setImmediate !== "undefined" && isNative(setImmediate)) {
  // Fallback to setImmediate.
  // Technically it leverages the (macro) task queue,
  // but it is still a better choice than setTimeout.
  timerFunc = () => {
    setImmediate(flushCallbacks);
  };
} else {
  // Fallback to setTimeout.
  timerFunc = () => {
    setTimeout(flushCallbacks, 0);
  };
}

export function nextTick(cb?: Function, ctx?: Object) {
  // ...
}

代码刚开始定义的几个变量和flushCallbacks函数的具体作用,我会在后面介绍。

接着定义了 timerFunc ,它的作用就是异步执行 flushCallbacks 函数。这里 timerFunc 通过一系列的判断来确定最适合的取值。

来看下timerFunc的取值逻辑:

  • 我们知道异步任务有两种,其中 microtask 要优于 macrotask ,所以优先选择 Promise 。因此这里先判断浏览器是否支持 Promise
  • 如果不支持再考虑 macrotask 。对于 macrotask 会先后判断浏览器是否支持 MutationObserversetImmediate
  • 如果都不支持就只能使用 setTimeout 。这也从侧面展示出了 macrotasksetTimeout 的性能是最差的。

我们再来看 nextTick 函数的具体逻辑:

export function nextTick(cb?: Function, ctx?: Object) {
  let _resolve;
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx);
      } catch (e) {
        handleError(e, ctx, "nextTick");
      }
    } else if (_resolve) {
      _resolve(ctx);
    }
  });
  if (!pending) {
    pending = true;
    timerFunc();
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== "undefined") {
    return new Promise((resolve) => {
      _resolve = resolve;
    });
  }
}

nextTick 函数会创建一个匿名函数 push 到 callbacks 中。

接着 if (!pending) 语句中 pending 作用显然是让 if 语句的逻辑只执行一次,而它其实就代表 callbacks 中是否有事件在等待执行。

if 语句的逻辑也很简单,就是执行 timerFunc 函数,也就是异步执行 flushCallbacks 函数。现在我们回过头来看 flushCallbacks 函数的定义:

function flushCallbacks() {
  pending = false;
  const copies = callbacks.slice(0);
  callbacks.length = 0;
  for (let i = 0; i < copies.length; i++) {
    copies[i]();
  }
}

flushCallbacks函数的主要逻辑就是将 pending 置为 false 以及清空 callbacks 数组,然后遍历 callbacks 数组,执行里面的每一个函数。

我们回到 nextTick 函数,来看最后一段代码:

if (!cb && typeof Promise !== "undefined") {
  return new Promise((resolve) => {
    _resolve = resolve;
  });
}

这里 if 对应的情况是我们调用 nextTick 函数时没有传入回调函数并且浏览器支持 Promise ,那么就会返回一个 Promise 实例,并且将 resolve 赋值给 _resolve 。我们再来看前面的代码:

let _resolve;
callbacks.push(() => {
  if (cb) {
    // ...
  } else if (_resolve) {
    _resolve(ctx);
  }
});

当我们执行 callbacks 的函数时,发现没有 cb 而有 _resolve 时就会执行之前返回的 Promise 对象的 resolve 函数。

总结

这一节我们学习了 nextTick 的实现原理,nextTick 会把要执行的回调保存到一个队列 callbacks 中,然后把遍历 callbacks 执行回调作为一个异步任务,所以会在执行栈为空时也就是同步代码执行完后才会执行这些回调。

同时结合上一节派发更新的内容,我们就明白了数据发生改变后,页面重新渲染是一个异步的过程。

Cosen95 avatar May 19 '20 06:05 Cosen95

关于这个pending一直有点困惑 ,我的理解如下:

比如我在某个数据更新之后写了两个nextTick执行函数

this.$nextTick(() => {
    console.log('执行第一个nextTick')
})
this.$nextTick(() => {
    console.log('执行第二个nextTick')
})

按照这个里的pending的逻辑的话是:

执行第一个nexttick的时候 ,pending就变成true, 然后执行flushCallbacks ,假设程序比较简单的话,当前callback只有一个

() => {
    console.log('执行第一个nextTick')
}

按照任务队列的逻辑,其实还不会触发callback中的函数执行,假设callback[0]是异步的promise,所以先不会执行,而是等 然后第二个nexttick触发之后再收集了,重复上面的逻辑再进行,然后再排队执行微任务?

是正确的?

fashen007 avatar Mar 24 '21 08:03 fashen007