【源码】vue.$nextTick源码解读

336 阅读1分钟

1.在vue(2.7.4)中$nextTick的实现只有几十行代码,但是承担了vue中数据异步刷新的重要任务,这个也是面试题中经常会考察的一个vue细节点之一。

在使用nextTick时,你是不是也有下面的问题:

  1. this.$nextTick(() => {})this.$nextTick().then(() => {})这两种写法,在使用上有什么区别?
  2. vue中的$nextTick和node中的nextTick(如果熟悉node的同学)有什么区别?
  3. 面试中也经常会被问到 $nextTick的执行时机或者底层实现是什么?
  4. ...(欢迎评论补充)

带着这几个问题,我们话不多说,一起来学习一下源码:

1. 调用nextTick

let funA = () => {
  console.log('handle nextTick');
};
this.$nextTick(funA)

上面的代码可以看到,我们期望vue能在下次更新(姑且先这么描述吧)时异步的调用我们的funA,此时$nextTick做了什么事情呢?,把大象装进冰箱的第一步,就是先打开冰箱,我们看看调用$nextTick时先做了什么: 查看代码发现:

Vue.prototype.$nextTick = function (fn: (...args: any[]) => any) {
    return nextTick(fn, this)
}

而nextTick如下:

export function nextTick(cb?: (...args: any[]) => any, ctx?: object) {
  let _resolve
  // 第一部分
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e: any) {
        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
    })
  }
}

看着代码挺简单的,也确实挺简单的,大致可以分为三块逻辑:

1.1 向队列中push一个方法

队列const callbacks: Array<Function> = [],大家应该也能想到,nextTick 不是一个同步方法,你可以同时调用多次nextTick,比如:

let funA = () => {
  console.log('handle nextTick A');
};
let funB = () => {
  console.log('handle nextTick B');
};
this.$nextTick(funA)
this.$nextTick(funB)

此时代码执行结束之后,funA和funB并未执行,而是被push到了callbacks队列中,做了一个临时的混存,等待调用。 这里逻辑比较简单:

if (cb) { // 这里就不用多说了
  try {
    cb.call(ctx)
  } catch (e: any) {
    handleError(e, ctx, 'nextTick')
  }
} else if (_resolve) { // 这里需要注意
  _resolve(ctx)
}

从上面的代码我们需要注意一点,当function cb没有传递时,即this.$nextTick()进行调用时怎么处理。可以看到代码中当调用没有穿刺cb参数时,同时_resolve存在时,调用之。

这类可以直接看第三部分代码:

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

意思是当cb没有传,同时当前运行环境有Promise时,_resolve 赋值为promiseresolve,因为上方的callbacks只是push了方法,所以只会在执行时才会进行check,此时第三部分代码已经执行完成。

1.2 触发方法执行

到关键了,上面吧啦了这么多,没啥核心,现在到核心了,timerFunc()的方法的实现:

这里我们有使用微任务的异步延迟包装器。在2.5版本中,使用了(宏)任务(与微任务相结合)。然而,当状态在重新绘制之前发生更改时(例如#6813,在转换中),它会出现一些的问题。此外,在事件处理程序中使用(宏)任务会导致一些无法规避的奇怪行为(例如#7109#7153#7546#7834#8109)。所以我们现在再次在各处使用微任务。这种折衷的一个主要缺点是,在一些情况下微任务的优先级太高,并且在假定顺序事件之间(例如#4521#6690,它们有变通方法),或者甚至在同一事件的冒泡之间(#6566)触发。

所以在vue2.5之后慢慢的nextTick由宏任务和微任务一起,逐步转变为微任务为主的的设计:

let timerFunc
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]')
) {
  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)) {
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

来吧,一步步对代码进行拆分:

1.2.1 当promise存在时

这里vue对于promise的判断使用的是typeof Promise !== 'undefined'typeof Ctor === 'function' && /native code/.test(Ctor.toString()),这一点学习了(别嫌啰嗦,既然读源码就是要看细节)。

对于promise存在的环境,直接在promisethen方法中调用flushCallbacks

const p = Promise.resolve()
timerFunc = () => { 
    p.then(flushCallbacks) 
    if (isIOS) setTimeout(noop) 
} 
isUsingMicroTask = true

这里可以看到在vue中,会优先选择promise来实现nextTick,同时在代码中可以看到有then之后执行了:

if (isIOS) setTimeout(noop)

对此vue的注释为:

当在触摸事件处理程序中触发时,它在iOS>=9.3.3中的UIWebView中会出现严重错误,在有问题的UIWebViews中,Promise.then不会完全崩溃,但它可能会陷入一种奇怪的状态,即回调被推入微任务队列,但队列没有被刷新,直到浏览器需要做一些其他工作,例如处理计时器。因此,我们可以通过添加一个空计时器来“强制”刷新微任务队列。

此处的noop,意为no operation,是一个空函数,只是为了给setTimeout传参。

同时在最后将是否使用微任务的标识置成true:

isUsingMicroTask = true

这个在vue的event会使用到,这里不再展开,只需知道这个是标志vue执行任务的机制。

1.2.2不是IE并且MutationObserver可用时

相对于Promise,MutationObserver有更广泛的支持(当然了,IE另说),同时MutationObserver本质上也是微任务的实现方式,所以在不支持Promise的环境中,如果有MutationObserver,肯定是首选:

!isIE
typeof MutationObserver !== 'undefined'
isNative(MutationObserver
MutationObserver.toString() === '[object MutationObserverConstructor]' // PhantomJS and iOS 7.x

判断条件也比Promise多一个,看起来是在PhantomJS and iOS 7.x中 ,MutationObserver和其他环境不一样。

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

看到这里,如果没有了解过MutationObserver的同学可能看的不是特别懂,没关系,我们一起来看一下例子:

// 选择需要观察变动的节点
const targetNode = document.getElementById("some-id");

// 观察器的配置(需要观察什么变动)
const config = { attributes: true, childList: true, subtree: true };

// 当观察到变动时执行的回调函数
const callback = function (mutationsList, observer) {
  // Use traditional 'for loops' for IE 11
  for (let mutation of mutationsList) {
    if (mutation.type === "childList") {
      console.log("A child node has been added or removed.");
    } else if (mutation.type === "attributes") {
      console.log("The " + mutation.attributeName + " attribute was modified.");
    }
  }
};

// 创建一个观察器实例并传入回调函数
const observer = new MutationObserver(callback);

// 以上述配置开始观察目标节点
observer.observe(targetNode, config);

// 之后,可停止观察
observer.disconnect();

所以vue的代码的意思很好理解啦

  1. const observer = new MutationObserver(flushCallbacks),将callback的执行函数作为MutationObserver的回调
  2. observer.observe(textNode, { characterData: true }),观察节点textNode,方式是当textNode的值发生改变时触发上方的回调
  3. timerFunc = () => { counter = (counter + 1) % 2 textNode.data = String(counter) },上方调用timerFunc时,实际是更新textNode的值,接着触发MutationObserver的回调

其中有一点需要注意的是:

MutationObserver也是微任务 isUsingMicroTask = true

1.2.3 使用setImmediate

和上面一样,首先判断环境支持 setImmediate

typeof setImmediate !== 'undefined' && isNative(setImmediate)

为什么要用setImmediate呢,可以查看setImmediate和setTimeout的区别

setTimeout 用于安排在一定延迟后执行的回调函数,但不保证立即执行。  setImmediate 用于安排尽快执行的回调函数,在I/O操作后执行

所以此时直接使用:

timerFunc = () => {
    setImmediate(flushCallbacks)
}

1.2.4 使用setTimeout

最后的兜底方案:

timerFunc = () => {
    setTimeout(flushCallbacks, 0)
}

此时这里的setImmediatesetTimeout都是宏任务。

1.2.5 总结

这里就是nextTick的核心逻辑了,四种不同环境下,不同的实现方式

1.3 队列执行

这里就是按照队列顺序执行回调:

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

这里需要注意的是,这里有一个pending实现的锁机制,上面我们看到在调用timerFunc之前置成true,直到这里才设置成false

到这里代码解读完成,还是比较简单吧,这里附上源码学习一下:

// 是否使用微任务
export let isUsingMicroTask = false
// 存储nextTick异步任务的队列
const callbacks: Array<Function> = []
// 状态位
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 timerFunc
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]')
) {
  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)) {
  timerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else {
  timerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

export function nextTick(): Promise<void>
export function nextTick<T>(this: T, cb: (this: T, ...args: any[]) => any): void
export function nextTick<T>(cb: (this: T, ...args: any[]) => any, ctx: T): void
/**
 * @internal
 */
export function nextTick(cb?: (...args: any[]) => any, ctx?: object) {
  let _resolve
  callbacks.push(() => {
    if (cb) {
      try {
        cb.call(ctx)
      } catch (e: any) {
        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
    })
  }
}

2. 总结

现在可以回答一下上面的问题:

  1. this.$nextTick(() => {})this.$nextTick().then(() => {})这两种写法,在使用上有什么区别?

这么写的前提是环境支持promise的情况,这里要分两种情况:

  1. 如果环境支持promise,则这两种写法是两个任务的周期执行,手撸promise(超详细)
  2. 如果不支持promise,这种情况就会报错
  1. vue中的$nextTick和node中的nextTick(如果熟悉node的同学)有什么区别?

这是两个完全不同的东西,一个是node环境原生支持的方法,另一个是vue自己实现类似于前者效果的方法

  1. 面试中也经常会被问到 $nextTick的执行时机或者底层实现是什么?

本文就是最好的回答