this.$nextTick 实现原理

137 阅读3分钟

nextTick 接收一个回调函数作为参数,它的作用是将回调延迟到下次DOM更新周期之后执行。它与全局方法Vue.nextTick 一样,不同的是回调的this 自动绑定到调用它的实例上。如果没有提供回调且在支持Promise的环境中,则返回一个Promise。我们在开发项目时会遇到一种场景:当更新了状态(数据)后,需要对新DOM做一些操作,但是这时我们其实获取不到更新后的DOM,因为还没有重新渲染。这个时候我们需要使用nextTick 方法。 示例如下:

new Vue({
  // ……
  methods: {
    // ……
    example: function () {
      // 修改数据
      this.message = "changed";
      // DOM还没有更新
      this.$nextTick(function () {
        // DOM现在更新了
        // this绑定到当前实例
        this.doSomethingElse();
      });
    }
  }
});

有一个问题:下次DOM更新周期之后执行,具体是指什么时候呢?要搞清楚这个问题,需要先弄明白什么是“下次DOM更新周期”。在Vue.js中,当状态发生变化时,watcher 会得到通知,然后触发虚拟DOM的渲染流程。而watcher 触发渲染这个操作并不是同步的,而是异步的。Vue.js中有一个队列,每当需要渲染时,会将watcher推送到这个队列中,在下一次事件循环中再让watcher 触发渲染的流程。

  1. 为什么Vue.js使用异步更新队列

我们知道Vue.js 2.0开始使用虚拟DOM进行渲染,变化侦测的通知只发送到组件,组件内用到的所有状态的变化都会通知到同一个watcher ,然后虚拟DOM会对整个组件进行“比对(diff)”并更改DOM。也就是说,如果在同一轮事件循环中有两个数据发生了变化,那么组件的watcher 会收到两份通知,从而进行两次渲染。事实上,并不需要渲染两次,虚拟DOM会对整个组件进行渲染,所以只需要等所有状态都修改完毕后,一次性将整个组件的DOM渲染到最新即可。

要解决这个问题,Vue.js的实现方式是将收到通知的watcher实例添加到队列中缓存起来,并且在添加到队列之前检查其中是否已经存在相同的watcher ,只有不存在时,才将watcher 实例添加到队列中。然后在下一次事件循环(event loop)中,Vue.js会让队列中的watcher 触发渲染流程并清空队列。这样就可以保证即便在同一事件循环中有两个状态发生改变,watcher 最后也只执行一次渲染流程。

  1. 什么是事件循环

我们都知道JavaScript是一门单线程且非阻塞的脚本语言,这意味着JavaScript代码在执行的任何时候都只有一个主线程来处理所有任务。而非阻塞是指当代码需要处理异步任务时,主线程会挂起(pending)这个任务,当异步任务处理完毕后,主线程再根据一定规则去执行相应回调。事实上,当任务处理完毕后,JavaScript会将这个事件加入一个队列中,我们称这个队列为事件队列 。被放入事件队列中的事件不会立刻执行其回调,而是等待当前执行栈中的所有任务执行完毕后,主线程会去查找事件队列中是否有任务。异步任务有两种类型:微任务(microtask)和宏任务(macrotask)。不同类型的任务会被分配到不同的任务队列中。当执行栈中的所有任务都执行完毕后,会去检查微任务队列中是否有事件存在,如果存在,则会依次执行微任务队列中事件对应的回调,直到为空。然后去宏任务队列中取出一个事件,把对的回调加入当前执行栈,当执行栈中的所有任务都执行完毕后,检查微任务队列中是否有事件存在。无限重复此过程,就形成了一个无限循环,这个循环就叫作事件循环 。属于微任务的事件包括但不限于以下几种:

Promise.then

MutationObserver

Object.observe

process.nextTick

属于宏任务的事件包括但不限于以下几种:

setTimeout

setInterval

setImmediate

MessageChannel

requestAnimationFrame

I/O

UI交互事件

  1. 什么是执行栈

当我们执行一个方法时,JavaScript会生成一个与这个方法对应的执行环境(context),又叫执行上下文。这个执行环境中有这个方法的私有作用域、上层作用域的指向、方法的参数、私有作用域中定义的变量以及this 对象。这个执行环境会被添加到一个栈中,这个栈就是执行栈。如果在这个方法的代码中执行到了一行函数调用语句,那么JavaScript会生成这个函数的执行环境并将其添加到执行栈中,然后进入这个执行环境继续执行其中的代码。执行完毕并返回结果后,JavaScript会退出执行环境并把这个执行环境从栈中销毁,回到上一个方法的执行环境。这个过程反复进行,直到执行栈中的代码全部执行完毕。这个执行环境的栈就是执行栈。回到前面的问题,“下次DOM更新周期”的意思其实是下次微任务执行时更新DOM。而vm.$nextTick 其实是将回调添加到微任务中。只有在特殊情况下才会降级成宏任务,默认会添加到微任务中。因此,如果使用vm.$nextTick 来获取更新后的DOM,则需要注意顺序的问题。因为不论是更新DOM的回调还是使用vm.$nextTick 注册的回调,都是向微任务队列中添加任务,所以哪个任务先添加到队列中,就先执行哪个任务。注意  事实上,更新DOM的回调也是使用vm.$nextTick来注册到微任务中的。如果想在vm.$nextTick 中获取更新后的DOM,则一定要在更改数据的后面使用vm.$nextTick 注册回调,如下所示:

new Vue({
  // ……
  methods: {
    // ……
    example: function () {
      // 先修改数据
      this.message = "changed";
      // 然后使用nextTick注册回调
      this.$nextTick(function () {
        // DOM现在更新了
      });
    }
  }
});

如果是先使用vm.$nextTick 注册回调,然后修改数据,则在微任务队列中先执行使用vm.$nextTick 注册的回调,然后执行更新DOM的回调。所以在回调中得不到最新的DOM,因为此时DOM还没有更新。如下所示:

new Vue({
  // ……
  methods: {
    // ……
    example: function () {
      // 先使用nextTick注册回调
      this.$nextTick(function () {
        // DOM没有更新
      });
      // 然后修改数据
      this.message = "changed";
    }
  }
});

通过上面的介绍我们知道,在事件循环中,必须当微任务队列中的事件都执行完之后,才会从宏任务队列中取出一个事件执行下一轮,所以添加到微任务队列中的任务的执行时机优先于向宏任务队列中添加的任务。修改数据会默认将更新DOM的回调添加到微任务队列中,代码如下:

new Vue({
  // ……
  methods: {
    // ……
    example: function () {
      // 先使用setTimeout向宏任务中注册回调
      setTimeout((_) => {
        // DOM现在更新了
      }, 0);
      // 然后修改数据向微任务中注册回调11 this.message = 'changed'
    }
  }
});

setTimeout 属于宏任务,使用它注册的回调会加入到宏任务中。宏任务的执行要比微任务晚,所以即便是先注册,也是先更新DOM后执行setTimeout 中设置的回调。帮助大家彻底理解了vm.$nextTick 的作用后,我们将详细介绍其实现原理。首先,我们知道vm.$nextTick 和全局方法Vue.nextTick 是相同的,所以nextTick 的具体实现并不是在Vue 原型上的$nextTick 方法中,而是抽象成了nextTick 方法供两个方法共用。代码如下:

import { nextTick } from "../util/index";

Vue.prototype.$nextTick = function (fn) {
  return nextTick(fn, this);
};

可以看到,Vue 原型上的 $nextTick 方法只是调用了nextTick 方法,具体实现其实在nextTick 中。接下来,我们将详细介绍nextTick 方法的实现方式。由于vm.$nextTick 会将回调添加到任务队列中延迟执行,所以在回调执行前,如果反复调用vm.$nextTick ,Vue.js并不会反复将回调添加到任务队列中,只会向任务队列中添加一个任务。此外,Vue.js内部有一个列表用来存储vm.$nextTick 参数中提供的回调。在一轮事件循环中,vm.$nextTick 只会向任务队列添加一个任务,多次使用vm.$nextTick 只会将回调添加到回调列表中缓存起来。当任务触发时,依次执行列表中的所有回调并清空列表。其代码如下:

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

let microTimerFunc;

const p = Promise.resolve();

microTimerFunc = () => {
  p.then(flushCallbacks);
};

export function nextTick(cb, ctx) {
  callbacks.push(() => {
    if (cb) {
      cb.call(ctx);
    }
  });
  if (!pending) {
    pending = true;
    microTimerFunc();
  }
}

// 测试一下
nextTick(
  function () {
    console.log(this.name); // Berwin
  },
  { name: "Berwin" }
);

在上面代码中,我们通过数组callbacks 来存储用户注册的回调,声明了变量pending 来标记是否已经向任务队列中添加了一个任务。每当向任务队列中插入任务时,将pending 设置为true ,每当任务被执行时将pending 设置为false ,这样就可以通过pending 的值来判断是否需要向任务队列中添加任务。上面我们还声明了函数flushCallbacks ,它就是我们所说的被注册的那个任务。当这个函数被触发时,会将callbacks 中的所有函数依次执行,然后清空callbacks ,并将pending设置为false 。也就是说,一轮事件循环中flushCallbacks只会执行一次。接下来声明了microTimerFunc 函数,它的作用是使用 Promise.then 将flushCallbacks 添加到微任务队列中。上面的准备工作完成后,当我们执行nextTick 函数注册回调时,首先将回调函数添加到callbacks 中,然后使用pending判断是否需要向任务队列中新增任务。下面我们从执行的角度回顾nextTick 的流程。首先,当nextTick 被调用时,会将回调函数添加到callbacks 中。如果此时是本轮事件循环第一次使用nextTick ,那么需要向任务队列中添加任务。因此,我们使用microTimerFunc 函数封装Promise.then 的作用就是将任务添加到微任务队列中。如果不是本轮事件循环中第一次调用nextTick ,也就是说,此时任务队列中已经被添加了一个执行回调列表的任务,那么我们就不需要执行microTimerFunc 向任务队列中添加重复的任务,因为被添加到任务队列中的任务只需要执行一次,就可以将本轮事件循环中使用nextTick 方法注册的回调都依次执行一遍。下图给出了nextTick 的内部注册流程和执行流程。

image.png

在Vue.js 2.4版本之前,nextTick 方法在任何地方都使用微任务,但是微任务的优先级太高,在某些场景下可能会出现问题。所以Vue.js提供了在特殊场合下可以强制使用宏任务的方法。具体实现如下:

const callbacks = [];

let pending = false;

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

let microTimerFunc;

let macroTimerFunc = function () {
  // ...
};

// 新增代码17 let useMacroTask = false
const p = Promise.resolve();
microTimerFunc = () => {
  p.then(flushCallbacks);
};
// 新增代码
export function withMacroTask(fn) {
  return (
    fn._withTask ||
    (fn._withTask = function () {
      useMacroTask = true;
      const res = fn.apply(null, arguments);
      useMacroTask = false;
      return res;
    })
  );
}

export function nextTick(cb, ctx) {
  callbacks.push(() => {
    if (cb) {
      cb.call(ctx);
    }
  });
  if (!pending) {
    pending = true;
    // 修改代码
    if (useMacroTask) {
      macroTimerFunc();
    } else {
      microTimerFunc();
    }
  }
}

在上述代码中,新增了withMacroTask 函数,它的作用是给回调函数做一层包装,保证在整个回调函数执行过程中,如果修改了状态(数据),那么更新DOM的操作会被推到宏任务队列中。也就是说,更新DOM的执行时间会晚于回调函数的执行时间。下面用点击事件举例。假设点击事件的回调使用了withMacroTask 进行包装,那么在点击事件被触发时,如果回调中修改了数据,那么这个修改数据的操作所触发的更新DOM的操作会被添加到宏任务队列中。因为我们在nextTick 中新增了判断语句,当useMacroTask 为true 时,则使用macroTimerFunc 注册事件。因此,withMacroTask 的实现逻辑很简单,先将变量 useMacroTask 设置为true ,然后执行回调,如果这时候回调中修改了数据(触发了更新DOM的操作),而useMacroTask是true ,那么更新DOM的操作会被推送到宏任务队列中。当回调执行完毕后,将useMacroTask 恢复为false 。说明  更新DOM的回调也是使用nextTick 将任务添加到任务队列中。简单来说就是,被withMacroTask 包裹的函数所使用的所有vm.$nextTick 方法都会将回调添加到宏任务队列中,其中包括状态被修改后触发的更新DOM的回调和用户自己使用vm.$nextTick 注册的回调等。接下来,我们将介绍macroTimerFunc 是如何将回调添加到宏任务队列中的。前面我们介绍过几种属于宏任务的事件,Vue.js优先使用setImmediate ,但是它存在兼容性问题,只能在IE中使用,所以使用MessageChannel 作为备选方案。如果浏览器也不支持MessageChannel ,那么最后会使用setTimeout 来将回调添加到宏任务队列中。实现方式如下:

if ((typeof setImmediate !== "undefined") & isNative(setImmediate)) {
  macroTimerFunc = () => {
    setImmediate(flushCallbacks);
  };
} else if (
  typeof MessageChannel !== "undefined" &&
  (isNative(MessageChannel) ||
    MessageChannel.toString() === "[objec MessageChannelConstructor]")
) {
  const channel = new MessageChannel();
  const port = channel.port2;
  channel.port1.onmessage = flushCallbacks;
  macroTimerFunc = () => {
    port.postMessage(1);
  };
} else {
  macroTimerFunc = () => {
    setTimeout(flushCallbacks, 0);
  };
}

可以看到,macroTimerFunc 被执行时,会将flushCallbacks 添加到宏任务队列中。前面提到microTimerFunc 的实现原理是使用Promise.then,但并不是所有浏览器都支持Promise ,当不支持时,会降级成macroTimerFunc 。其实现方式如下:

if (typeof Promise !== "undefined" && isNative(Promise)) {
  const p = Promise.resolve();
  microTimerFunc = () => {
    p.then(flushCallbacks);
  };
} else {
  microTimerFunc = macroTimerFunc;
}

首先判断浏览器是否支持Promise ,然后进行相应的处理即可。官方文档中有这样一句话:如果没有提供回调且在支持Promise的环境中,则返回一个Promise 。也就是说,可以这样使用vm.$nextTick :

this.$nextTick()
    .then(function () {
        // DOM更新了
      })

要实现这个功能,我们只需要在nextTick 中进行判断,如果没有提供回调且当前环境支持Promise ,那么返回Promise ,并且在callbacks 中添加一个函数,当这个函数执行时,执行Promise 的resolve 即可,代码如下:

export function nextTick(cb, ctx) {
  // 新增代码
  let _resolve;
  callbacks.push(() => {
    if (cb) {
      cb.call(ctx);
    } else if (_resolve) {
      // 新增代码
      _resolve(ctx);
    }
  });
  if (!pending) {
    pending = true;
    if (useMacroTask) {
      macroTimerFunc();
    } else {
      microTimerFunc();
    }
  }
  // 新增代码
  if (!cb && typeof Promise !== "undefined") {
    return new Promise((resolve) => {
      _resolve = resolve;
    });
  }
}

在上面的代码中,先在函数作用域中声明了变量 _resolve ,然后进行相应的处理。最终完整的代码如下:

const callbacks = [];
let pending = false;

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

let microTimerFunc;
let macroTimerFunc;
let useMacroTask = false;

if ((typeof setImmediate !== "undefined") & isNative(setImmediate)) {
  macroTimerFunc = () => {
    setImmediate(flushCallbacks);
  };
} else if (
  typeof MessageChannel !== "undefined" &&
  (isNative(MessageChannel) ||
    MessageChannel.toString() === "[objec MessageChannelConstructor]")
) {
  const channel = new MessageChannel();
  const port = channel.port2;
  channel.port1.onmessage = flushCallbacks;
  macroTimerFunc = () => {
    port.postMessage(1);
  };
} else {
  macroTimerFunc = () => {
    setTimeout(flushCallbacks, 0);
  };
}

if (typeof Promise !== "undefined" && isNative(Promise)) {
  const p = Promise.resolve();
  microTimerFunc = () => {
    p.then(flushCallbacks);
  };
} else {
  microTimerFunc = macroTimerFunc;
}

export function withMacroTask(fn) {
  return (
    fn._withTask ||
    (fn._withTask = function () {
      useMacroTask = true;
      const res = fn.apply(null, arguments);
      useMacroTask = false;
      return res;
    })
  );
}

export function nextTick(cb, ctx) {
  let _resolve;
  callbacks.push(() => {
    if (cb) {
      cb.call(ctx);
    } else if (_resolve) {
      _resolve(ctx);
    }
  });
  if (!pending) {
    pending = true;
    if (useMacroTask) {
      macroTimerFunc();
    } else {
      microTimerFunc();
    }
  }
  if (!cb && typeof Promise !== "undefined") {
    return new Promise((resolve) => {
      _resolve = resolve;
    });
  }
}