抽丝剥茧般的阅读源码,将$nextTick()拉下神坛!

    技术2022-07-11  90

    ❝ 古人云:知其然知其所以然❞

    前言 相信有很多开发第一次碰到vue中的$nextTick()时都会把它当作一次setTimeout()调用。这个理解是对的吗?

    前置知识 这个方法理解起来并不难,但需要知道下面的概念:

    调用栈任务队列事件循环 下面假设大家对这些概念已经非常清楚了。 $nextTick()

    概念 掌握一个知识点的背后原理,就必须对它的使用要非常熟悉。来看官方的介绍。 ❝ vm.$nextTick([callback])

    参数:{function} [callback]用法:将回调延迟到下次DOM更新循环之后执行。在修改数据之后立即使用它,然后等待DOM更新。回调的this自动绑定到调用它的实例上。 ❞

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

    上面有一句话说"「将回调延迟到下次DOM更新循环之后在执行」"。这句话的意思是: Vue在更新DOM时是异步执行的。只要侦听到数据变化,Vue将开启一个队列,并缓冲在同一事件循环中发生的所有数据变更。如果同一个watcher被多次触发,只会被推入到队列中一次。然后,在下一个的事件循环中,Vue刷新队列并执行实际的工作。 例如,当设置vm.someData = ‘new value’,该组件不会立即重新渲染。当刷新队列时,组件会在下一个事件循环中更新。但是如果想基于更新后的DOM状态来做点什么,这就有点难办了。所以Vue就推出了 n e x t T i c k ( ) , 此 方 法 接 收 的 回 调 函 数 将 在 D O M 更 新 完 成 后 被 调 用 。 V u e 文 档 上 也 写 了 : ❝ V u e 在 内 部 对 异 步 队 列 尝 试 使 用 原 生 的 P r o m i s e . t h e n 、 M u t a t i o n O b s e r v e r 和 s e t I m m e d i a t e , 如 果 执 行 环 境 不 支 持 , 则 会 采 用 s e t T i m e o u t ( f n , 0 ) 代 替 。 ❞ 所 以 将 nextTick(),此方法接收的回调函数将在DOM更新完成后被调用。 Vue文档上也写了: ❝ Vue在内部对异步队列尝试使用原生的 Promise.then、MutationObserver 和 setImmediate,如果执行环境不支持,则会采用 setTimeout(fn, 0) 代替。 ❞ 所以将 nextTick()DOMVueVue使Promise.thenMutationObserversetImmediatesetTimeout(fn,0)nextTick()当作一次setTimeout()调用,并不能说是错的。只是没有那么准确。 2. 源码解读 $nextTick()是在/src/core/instance/render.js中定义的:

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

    n e x t T i c k ( ) 方 法 是 在 r e n d e r M i x i n 函 数 中 挂 载 到 V u e 原 型 上 的 。 可 以 看 出 nextTick()方法是在renderMixin函数中挂载到Vue原型上的。可以看出 nextTick()renderMixinVuenextTick()是对nextTick函数的简单封装。 而nextTick函数是在/src/core/util/next-tick.js中定义的。next-tick.js文件中主体是一段4层if else语句。

    if () { ... } else if () { ... } else if () { ... } else { ... }

    「第一层」

    if (typeof Promise !== 'undefined' && isNative(Promise)) { const p = Promise.resolve() timerFunc = () => { p.then(flushCallbacks) // In problematic UIWebViews, Promise.then doesn't completely break, but // it can get stuck in a weird state where callbacks are pushed into the // microtask queue but the queue isn't being flushed, until the browser // needs to do some other work, e.g. handle a timer. Therefore we can // "force" the microtask queue to be flushed by adding an empty timer. if (isIOS) setTimeout(noop) } }

    首先判断当前环境支不支持Promise,如果支持则优先使用Promise。 总所周知,任务队列并非只有一个队列,总的来说可以将其分为微任务–microtask和宏任务–macrotask。当调用栈空闲后,事件循环就会在宏任务消息队列中读取一个任务并执行。宏任务执行的过程中,有时候会产生多个微任务,将其保存在微任务队列中。也就是说每个宏任务都关联了一个微任务队列。当主函数执行结束之后、当前宏任务结束之前,事件循环就会将当前微任务队列执行并清空。 另外两个宏任务之间可能穿插着UI的重渲染,那么只需要在微任务中把所有UI重渲染之前把需要更新的数据全部更新,这样只需要一次重渲染就能得到最新的DOM了。 对于vue来说,vue是一个数据驱动的框架,要是能在UI重渲染之前更新所有数据状态,这对性能的提升是一个非常大的帮助,所以要优先使用微任务去更新数据状态而不是宏任务,这就是为什么优先使用promise,而不是setTimeout的原因。 「接着解读:」 首先定义常量 p 它的值是一个立即 resolve 的 Promise 实例对象。 接着将变量 timerFunc 定义为一个函数,这个函数的执行将会把 flushCallbacks 函数注册为微任务。 「接着」

    // In problematic UIWebViews, Promise.then doesn't completely break, but // it can get stuck in a weird state where callbacks are pushed into the // microtask queue but the queue isn't being flushed, until the browser // needs to do some other work, e.g. handle a timer. Therefore we can // "force" the microtask queue to be flushed by adding an empty timer. if (isIOS) setTimeout(noop)

    注释说,在一些UIWebViews中微任务没有被刷新,解决方案就是让浏览器做一些其他的事件,比如注册一个宏任务,即使这个宏任务什么都不做,这样就能间接触发微任务的刷新。 「第二层」 如果当前环境不支持Promise(IE:看我干嘛),走到第二层else if语句中。

    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) } }

    判断当前环境是否支持MutationObserver。 MutationObserver也是一个微任务,提供了监视对DOM树所做更改的能力。它被设计为旧的Mutation Events功能的替代品,该功能是DOM3 Events规范的一部分。 首先将flushCallbacks传入MutationObserver构造函数中,创建并返回一个新的MutationObserver它会在指定的DOM发生变化时被调用。

    const observer = new MutationObserver(flushCallbacks)

    然后创建一个根据counter变量的文本DOM节点,并配置MutationObserver订阅此DOM节点,所以当这个DOM节点变化时,flushCallbacks就会注册在微任务队列。

    let counter = 1 const textNode = document.createTextNode(String(counter)) observer.observe(textNode, { characterData: true })

    最后将timeFun注册为一个函数,当timeFun执行时,立即更改counter的值,从而引起MutationObserver的更改,将flushCallbacks注册在微任务队列中。

    timerFunc = () => { counter = (counter + 1) % 2 textNode.data = String(counter) }

    「第三层」 接着解读第三层else if语句

    else if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) { // Fallback to setImmediate. // Techinically it leverages the (macro) task queue, // but it is still a better choice than setTimeout. timerFunc = () => { setImmediate(flushCallbacks) } }

    可以看出setImmediate函数优先setTimeout函数。这是因为setImmediate比setTimeout有更好的性能。 setTimeout将回调函数注册在宏任务队列中之前要不断的做超时检测,而setImmediate不需要。但是setImmediate有明显的缺陷,只有IE实现了它。 「第四层」 最后第四层else语句,就轮到setTimeout出场了。

    else { // Fallback to setTimeout. timerFunc = () => { setTimeout(flushCallbacks, 0) } } 非浏览器环境 翻查源码的过程中,发现nextTick函数的定义不只有一处地方。在packages/weex-vue-framework/factory.js中也定义了。 也不难理解,因为上面介绍的都是基于是浏览器环境的,weex是运行在node环境下的。 也来看看这个nextTick定义与上面的有什么不同。 主体是围绕着mircotask与marcotask进行,也就是分别定义宏任务与微任务。 var macroTimerFunc; if () { macroTimerFunc = function () {...} } else if () { macroTimerFunc = function () {...} } else {...} var microTimerFunc; if () { microTimerFunc = function () {...} } else { microTimerFunc = macroTimerFunc }

    可以看到macroTimerFunc有三层if判断,microTimerFunc有两层。 「macroTimerFunc」 先看macroTimerFunc

    if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) { macroTimerFunc = function () { setImmediate(flushCallbacks); }; }

    优先判断当前环境是否支持setImmediate,最后的else才是使用setTimeout。

    else { /* istanbul ignore next */ macroTimerFunc = function () { setTimeout(flushCallbacks, 0); }; }

    「MessageChannel」 而中间的else if是判断是否支持MessageChannel

    else if (typeof MessageChannel !== 'undefined' && ( isNative(MessageChannel) || // PhantomJS MessageChannel.toString() === '[object MessageChannelConstructor]' )) { var channel = new MessageChannel(); var port = channel.port2; channel.port1.onmessage = flushCallbacks; macroTimerFunc = function () { port.postMessage(1); }; }

    了解过Web Workers都知道,Web Workers的内部实现就是用到MessageChannel。一个MessageChannel实例对象拥有两个属性port1和port2,只要让port1监听onmessage事件,然后使用port2的postMessage向port1发送消息即可,这样port1的onmessage回调就会被注册为宏任务,由于它也不需要任何检测工作,所以性能也比setTimeout要好。 总之macroTimerFunc函数的作用就是将flushCallbacks注册为宏任务。 「microTimerFunc」 举一反三,microTimerFunc函数的作用就是将flushCallbacks注册为微任务。

    if (typeof Promise !== 'undefined' && isNative(Promise)) { var p = Promise.resolve(); microTimerFunc = function () { p.then(flushCallbacks); // in problematic UIWebViews, Promise.then doesn't completely break, but // it can get stuck in a weird state where callbacks are pushed into the // microtask queue but the queue isn't being flushed, until the browser // needs to do some other work, e.g. handle a timer. Therefore we can // "force" the microtask queue to be flushed by adding an empty timer. if (isIOS) { setTimeout(noop); } }; } else { // fallback to macro microTimerFunc = macroTimerFunc; }

    如果不支持Promise,那么microTimerFunc = macroTimerFunc;。 4. nextTick 最后,真正的看一下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) } }) // 忽略 }

    nextTick函数会在callbacks数组中添加一个新的函数,callbacks数组定义在文件头部:const callback = []。注意并不是将cb回调函数直接添加到callbacks数组中,会使用一个新的函数包裹回调函数并将新函数添加到callbacks数组中。但这个被添加到callbacks数组中的函数执行会间接调用cd回调函数,并且可以看到cb函数时使用.call方法将函数cb的作用域设置为ctx,也就是nextTick函数的第二个参数。所以对于 n e x t T i c k 方 法 来 讲 , 传 递 给 nextTick方法来讲,传递给 nextTicknextTick方法的回调函数的作用域是当前组件实例对象,前提是回调函数不能是箭头函数,其实在平时的使用中,回调函数使用箭头函数也没关系,只要达到目的就行。 「继续看源码」

    export function nextTick (cb?: Function, ctx?: Object) { ... if (!pending) { pending = true timerFunc() } ... }

    进行一个if条件判断,判断pending的真假,pending变量定义在文件头部:let pending = false,它是一个标识,它的真假代表回调队列是否处于等待刷新的状态,初始值为false代表回调队列为空不需要等待刷新。假如此时在某个地方调用了$nextTick方法,那么if语句块内的代码将会被执行,在if语句块内优先将变量pending的值设置为true,代表着此时回调队列不为空,正在等待刷新。既然等待刷新,那么当然要刷新回调队列。这时就用到前面的timerFunc函数。在week中则是micTimeFunc或者marcoTaskFunc。无论是哪种任务类型,它们都将会等待调用栈清空之后才执行。 「举例:」

    created () { this.$nextTick( () => {console.log(1)}) this.$nextTick( () => {console.log(2)}) this.$nextTick( () => {console.log(3)}) }

    在created钩子函数中连续调用三次 n e x t T i c k 方 法 , 但 只 有 第 一 次 调 用 nextTick方法,但只有第一次调用 nextTicknextTick方法时才会执行timerFunc函数将flushCallbacks注册为微任务,但此时flushCallbacks函数并不会执行,因为它要等待接下来的两次 n e x t T i c k 方 法 的 调 用 语 句 执 行 完 后 才 会 执 行 , 准 确 的 说 等 待 调 用 栈 被 清 空 之 后 才 会 进 行 。 也 就 是 说 f l u s h C a l l b a c k s 函 数 执 行 的 时 候 , c a l l b a c k s 回 调 队 列 中 将 包 含 本 次 事 件 循 环 所 收 集 的 所 有 通 过 nextTick方法的调用语句执行完后才会执行,准确的说等待调用栈被清空之后才会进行。也就是说flushCallbacks函数执行的时候,callbacks回调队列中将包含本次事件循环所收集的所有通过 nextTickflushCallbackscallbacksnextTick方法注册的回调,而接下来的任务就是在flushCallbacks函数内将这些回调全部执行并清空。 「下面是flushCallbacks的源码」

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

    首先将变量pending设置为false,接着开始执行回调,但需要注意的是在执行callbacks队列中的回调函数时并没有直接遍历callbacks数组,而是使用copies常量保存一份复制品。然后遍历copies数组,并且在遍历copies数值之前将callbacks数组清空:callbacks.length = 0。 为什么要这样做呢?「举个例子」

    created () { this.age = 20 this.$nextTick(() => { this.age = 21 this.$nextTick(() => { console.log('第二个nextTick')}) }) }

    n e x t T i c k ( ) 的 回 调 函 数 中 再 次 调 用 了 nextTick()的回调函数中再次调用了 nextTick()nextTick方法,理论上外层 n e x t T i c k 方 法 的 回 调 函 数 不 应 该 与 内 层 nextTick方法的回调函数不应该与内层 nextTicknextTick的回调函数在同一个微任务中被执行,而是两个不同的微任务,虽然在结果上看或许没什么差别,但从设计角度就应该这样做。 上面代码修改了两次age属性的值,首先将age的值修改为20,上面说到Vue在更新DOM时也是异步执行的,这个过程中就是将flushSchedulerQueue添加到callbacks数组中

    callbacks = [ flushSchedulerQueue ]

    同时将flushCallbacks函数注册为微任务,所以微任务队列为

    // 微任务队列 [ flushCallbacks ]

    接着调用第一个 n e x t T i c k 方 法 , nextTick方法, nextTicknextTick会将回调函数添加到callbacks数组中,那么此时的callbacks数组如下:

    callbacks = [ flushSchedulerQueue, () => { this.age = 21 this.$nextTick(() => {console.log('第二个$nextTick')}) } ]

    接下来主线程出于空闲状态,开始执行微任务队列,即执行flushCallbacks函数,flushCallbacks函数会按照顺序执行callbacks数组中的函数,首先会执行flushSchedulerQueue函数,这个函数会遍历queue中所有观察者并重新求值。接着执行如下函数:

    () => { this.age = 21 this.$nextTick(() => {console.log('第二个$nextTick')}) }

    这个函数是第一个$nextTick的回调函数,由于在执行该回调函数之前已经完成了重新求值,所以该回调函数内的代码是能够访问更新后的值。在该回调函数内再次修改age属性的值后,同样会调用nextTick函数将flushSchedulerQueue添加到callbacks数组中,但是由于在执行flushCallbacks函数时优先将pending的设置为false,所以nextTick函数会将flushCallbacks函数注册为一个新的微任务。此时目的就达成了,队列包含两个微任务。

    // 此时微任务队列 [ flushCallbacks, flushCallbacks ]

    第二个flushCallbacks函数的一切流程与第一个flushCallbacks是完全相同的。 以上,$nextTick()就介绍完毕。

    作者:zhangwinwin 链接:https://juejin.im/post/5efa96ce5188252e654409e6

    服务推荐

    蜻蜓代理ip代理服务器企业级代理ip微信域名检测微信域名拦截检测
    Processed: 0.011, SQL: 9