Bootstrap

学习vue源码(13)手写$nextTick

1.概述

nextTick接收一个回调函数作为参数,它的作用是将回调延迟到下次DOM更新周期之后执行

它与全局Vue.nextTick的原理时一样的。

可能有同学对将回调延迟到下次DOM更新周期之后执行不太理解,

其实它的意思就是

在Vue.js中,当状态发生改变时,watcher会得到通知,然后触发虚拟DOM的渲染过程。而watcher触发渲染这个操作并不是同步的,而是异步的。Vue.js中有一个队列(即callbacks),每当需要渲染时,会将watcher推送到这个队列中,在下一次事件循环中再让watcher触发渲染的过程。举个例子


mounted: function () {
  this.message = "abc"
  this.$nextTick(function () {
    console.log(this.message)
  })
}

上面代码中this.message = "abc" 使得watcher会得到通知,然后触发 渲染Dom操作,然后把它 放入了队列callback中,

此时callbacks = [渲染Dom操作],

然后

this.$nextTick(function () {
    console.log(this.message)
  })

又把 打印message这个操作 放入callbacks中。

此时callbacks= [渲染Dom操作,打印message]

最后再把 依次执行callbacks里的操作这个操作 包装成 异步任务。

包装成 异步任务 很简单,作为setTimeout,或promise.then的回调就行

不懂watcher是什么的同学,推荐先看学习vue源码(12)大白话谈响应式原理

在这里 我必须先事先强调 nextTick在vue中有两种用途:

  • 一种就是 vue内部 使用nextTick,把 渲染Dom操作 这个操作 放入到callbacks 中,

  • 一种是把nextTick 给开发者使用,也就是 我们经常使用的$nextTick, 然后把我们传入$nextTick的回调函数放到callbacks中0。

刚好这两种用途 对应上面我们一开始举的例子中的两个操作。

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

Vue.js2.0开始使用虚拟DOM进行渲染,变化侦测的通知只发送到组件,组件内用到的所有状态的变化都会通知到同一个watcher,然后虚拟DOM会对整个组件进行“比对(diff)”并更改DOM。

也就是说,如果在同一轮事件循环中有两个数据发生了变化,那么组件的watcher会受到两份通知,从而进行两次渲染。事实上,并不需要渲染两次,虚拟DOM会对整个组件进行渲染,所有只需要等所有状态都修改完毕后,一次性将整个组件的DOM渲染到最新即可。

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

举个例子,假如

export default {
  data () {
    return {
      msg1: 1,
      msg2:2,
      msg3:3
    }
  },
  mounted () {
    this.msg1 = 0
    this.msg2 = 0
    this.msg3 = 0
  },
}

如果我们 不使用异步队列,那上面的例子中,

  • this.msg1 = 0 会触发 该组件 更新视图,

  • this.msg2 = 0 会触发 该组件 更新视图

  • this.msg3 = 0 会触发 该组件 更新视图

显然,就 使得 同一个组件 更新了三次 视图。

注意:同一个组件里的任何一个数据发生改变都会触发整个组件Dom的更新。

当我们使用异步队列后。

  • this.msg1 = 0 把 更新该组件Dom的操作放入callbacks中,

  • this.msg2 = 0 发现 callbacks 已经有 该组件的更新操作,没事了。

  • this.msg3 = 0 发现 callbacks 已经有 该组件的更新操作,没事了。

因此,当callbacks 被包装成 异步任务 执行时, 该组件就只会更新一次DOM,这对性能的提升是多么的高啊啊!!!

了解nextTick原理前,我们 需要 有 是事件循环 和 执行栈 的预备知识 , 不了解的可以看这一次,彻底弄懂 JavaScript 执行机制(别还不知道什么是宏任务,什么是微任务).

三 么是执行栈

当执行一个方法时,Javascript会生成一个与这个方法对应的执行环境,又叫执行上下文。这个执行环境中有这个方法的私有作用域、上层作用域的指向、方法的参数、私有作用域中定义的变量以及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(){
   <!-- 先使用setTimeout向宏任务中注册回调 -->
   setTimeout(_=>{
    <!-- DOM现在更新了 -->
   },0)
   <!-- 然后修改数据向微任务中注册回调 -->
   this.message = 'changed';
 }
})

setTimeout属于宏任务,使用它注册的回调会加入到宏任务中。宏任务的执行要比微任务晚,所以即便是先注册,也是先更新DOM后执行setTimeout中设置的回调

4. 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中。

由于vm.$nextTick会将回调添加到任务队列中延迟执行,所以在回调执行前,如果反复调用vm.$nextTick,Vue.js并不会反复将回调添加到任务队列中,只会向任务队列中添加一个任务。(这个我们在上面说过了)

Vue.js内部有一个列表用来存储vm.$nextTick参数中提供的回调。在一轮事件循环中,vm.$nextTick只会向任务队列添加一个任务,多次使用vm.$nextTick只会将回调添加到回调列表中缓存起来。当任务触发时,依次执行列表中的所有回调并清空列表。

(5)nextTick方法的实现方式

<!-- 回调列表 -->
const callbacks = [];
let pending = false;
 
<!-- 回调列表 -->
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'})

解释上面代码

1、通过数组callbacks来存储用户注册的回调。

2、声明了变量pending来标记是否已经向队列中添加一个任务了。每当向任务队列中插入任务时,将pending设置为true,每当任务被执行时将pending设置为false,这样就可以通过pending的值来判断是否需要向任务队列中添加任务。

3、函数flushCallbacks,即被注册的那个任务。当这个函数被触发时,会将callbacks中的所有函数依次执行,然后清空callbacks,并将pending设置为false。即一轮事件循环中,flushCallbacks只会执行一次。

4、microTimerFunc函数,它的作用是使用Promise.then将flushCallbacks添加到微任务队列中。这个其实就是我们所说的包装成异步。

5、执行nextTick函数注册回调时,首先将回调函数添加到callbacks中,然后使用pending判断是否需要向任务队列中新增任务。

在Vue.js2.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(){...}
<!-- 使用宏任务标识 -->
let userMacroTask = false;
const p = Promise.resolve();
<!-- 添加微任务 -->
microTimerFunc = () =>{
 p.then(flushCallbacks)
}
 
export function withMacroTask(fn){
 return fn._withTask || (fn_withTask = function({
  userMacroTask = true;
  const res = fn.apply(null,arguments);
  userMacroTask = false;
  retrun res;
 }))
}
 
export function nextTick(cb,ctx){
 <!-- 将回调加入回调队列 -->
 callbacks.push(()=>{
  if(cb){
   cb.call(ctx);
  }
 })
 <!-- 第一次进入,添加微任务 -->
 if(!pending){
  pending = true;
  <!-- 添加宏任务代码 -->
  if(userMacroTask){
   macroTimerFunc();
  }else{
   microTimerFunc();
  }
  
 }
}

1、新增了withMacroTask函数,它的作用是给回调函数做一层包装,保证在整个回调函数执行过程中,如果修改了状态(数据),那么更新DOM的操作会被推到宏任务队列中,也就是说,更新DOM的执行时间会晚于回调函数的执行时间。

2、withMacroTask先将变量userMacroTask设置为true,然后执行回调,如果这时候回调中修改了数据(触发了更新DOM的操作),而userMacroTask是true,那么更新DOM的操作会被推送到宏任务队列中。当回调执行完毕后,将userMacroTask恢复为false。

3、被withMacroTask包裹的函数所使用的所有vm.$nextTick方法都会将回调添加到宏任务队列中,其中包括状态被修改后触发的更新DOM的回调和用户自己使用vm.$nextTick注册的回调等。

macroTimerFunc如何将回调添加到宏任务队列中

Vue.js优先使用setImmediate,但是它存在兼容性问题,只能在IE中使用,所以使用MessageChannel作为备选方案。如果浏览器也不支持MessageChannel,那么最后会使用setTimeout来将回调添加到宏任务队列中。

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

microTimerFunc的实现原理是使用Promise.then,但并不是所有浏览器都支持Promise,当不支持时,会被降级成macroTimerFunc。

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

官方文档中有一句话:如果没有提供回调且在支持Promise的环境中,则返回一个Promise。

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

要实现这个功能,需要在nextTick中进行判断,如果没有提供回调且当前环境支持Promise,那么返回Promsie,并且在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(userMacroTask){
   macroTimerFunc();
  }else{
   microTimerFunc();
  }
 }
 if(!cb && typeof Promise !== 'undefined'){
  return new Promise(resolve =>{
   _resolve = resolve;
  })
 }
}

5. 完整的代码

<!-- 回调列表 -->
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 userMacroTask = false;
<!-- 添加宏任务macroTimerFunc实现 -->
<!-- setImmediate -->
if(typeof setImmediate !=='undefined' && isNative(setImmediate)){
 macroTimerFunc = () =>{
  setImmediate(flushCallbacks);
 }
<!-- MessageChannel -->
}else if(typeof MessageChannel !== 'undefined' &&(isNative(MessageChannel)||
  MessageChannel.toString()==='[Object MessageChannelConstructor]')){
   const channel = new MessageChannel();
   const port = channel.port2;
   channel.port1.onmessage = flushCallbacks;
   macroTimerFunc = () =>{
    port.postMessage(1);
   }
}else{
<!-- setTimeout -->
 macroTimerFunc = () =>{
  setTimeout(flushCallbacks,0);
 }
}
<!-- 添加微任务microTimerFunc实现 -->
<!-- 支持Promise -->
if(typeof Promise !== 'undefined' && isNative(Promise)){
 const p = Promise.resolve();
 microTimerFunc = () =>{
  p.then(flushCallbacks);
 }
}else{
<!-- 不支持Promise降级为宏任务 -->
 microTimerFunc = macroTimerFunc;
}
<!-- 将回调包在这个函数中,将任务加入到宏任务中 -->
export function withMacroTask(fn){
 return fn._withTask || (fn_withTask = function(){
  userMacroTask = true;
  const res = fn.apply(null,arguments);
  userMacroTask = false;
  retrun 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(userMacroTask){
   macroTimerFunc();
  }else{
   microTimerFunc();
  }
 }
 <!-- nextTick无回调且支持Promise返回Promise -->
 if(!cb && typeof Promise !== 'undefined'){
  return new Promise(resolve =>{
   _resolve = resolve;
  })
 }
}
;