首頁  >  文章  >  web前端  >  Vue源碼中批次非同步更新與nextTick原理的解析

Vue源碼中批次非同步更新與nextTick原理的解析

不言
不言原創
2018-07-20 11:53:502405瀏覽

這篇文章跟大家介紹的內容是關於Vue源碼中批量異步更新與nextTick原理的解析,有著一定的參考價值,有需要的朋友可以參考一下。

vue已是目前國內前端web端三分天下之一,同時也作為本人主要技術棧之一,在日常使用中知其然也好奇著所以然,另外最近的社區湧現了一大票vue源碼閱讀類的文章,在下藉這個機會從大家的文章和討論中汲取了一些營養,同時對一些閱讀源碼時的想法進行總結,出產一些文章,作為自己思考的總結

#目標Vue版本:2.5.17-beta.0

vue原始碼註解:https://github.com/SHERlocked93/vue-analysis

聲明:文章中原始碼的語法都使用Flow,並且原始碼根據需要都有刪節(為了不被迷糊@_@),如果要看完整版的請進入上面的github地址,本文是系列文章,文章地址見底部~

1. 非同步更新

我們在依賴收集原理的響應式化方法defineReactive 中的setter 存取器中有派發更新dep .notify() 方法,這個方法會挨個通知在depsubs 中收集的訂閱自己變動的watchers執行update。一起來看看update 方法的實作:

// src/core/observer/watcher.js

/* Subscriber接口,当依赖发生改变的时候进行回调 */
update() {
  if (this.computed) {
    // 一个computed watcher有两种模式:activated lazy(默认)
    // 只有当它被至少一个订阅者依赖时才置activated,这通常是另一个计算属性或组件的render function
    if (this.dep.subs.length === 0) {       // 如果没人订阅这个计算属性的变化
      // lazy时,我们希望它只在必要时执行计算,所以我们只是简单地将观察者标记为dirty
      // 当计算属性被访问时,实际的计算在this.evaluate()中执行
      this.dirty = true
    } else {
      // activated模式下,我们希望主动执行计算,但只有当值确实发生变化时才通知我们的订阅者
      this.getAndInvoke(() => {
        this.dep.notify()     // 通知渲染watcher重新渲染,通知依赖自己的所有watcher执行update
      })
    }
  } else if (this.sync) {      // 同步
    this.run()
  } else {
    queueWatcher(this)        // 异步推送到调度者观察者队列中,下一个tick时调用
  }
}

如果不是computed watcher 也非sync 會把呼叫update的目前watcher推送到調度者佇列中,下一個tick時調用,看看queueWatcher

// src/core/observer/scheduler.js

/* 将一个观察者对象push进观察者队列,在队列中已经存在相同的id则
 * 该watcher将被跳过,除非它是在队列正被flush时推送
 */
export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  if (has[id] == null) {     // 检验id是否存在,已经存在则直接跳过,不存在则标记哈希表has,用于下次检验
    has[id] = true
    queue.push(watcher)      // 如果没有正在flush,直接push到队列中
    if (!waiting) {          // 标记是否已传给nextTick
      waiting = true
      nextTick(flushSchedulerQueue)
    }
  }
}

/* 重置调度者状态 */
function resetSchedulerState () {
  queue.length = 0
  has = {}
  waiting = false
}

這裡使用了一個has 的哈希map用來檢查是否當前watcher的id是否存在,若已存在則跳過,不存在則就push到queue 隊列中並標記哈希表has,用於下次檢驗,防止重複添加。這就是一個去重的過程,比每次查重都要去queue中找要文明,在渲染的時候就不會重複patch 相同watcher的變化,這樣就算同步修改了一百次視圖中用到的data,異步patch 的時候也只會更新最後一次修改。

這裡的waiting 方法是用來標記flushSchedulerQueue 是否已經傳遞給nextTick 的標記位,如果已經傳遞則只push到隊列中不傳遞flushSchedulerQueuenextTick,等到resetSchedulerState 重置調度者狀態的時候waiting 會被置回false 允許flushSchedulerQueue 被傳遞給下一個tick的回調,總之保證了flushSchedulerQueue 回調在一個tick內只允許被傳入一次。來看看傳遞給nextTick 的回呼flushSchedulerQueue 做了什麼:

// src/core/observer/scheduler.js

/* nextTick的回调函数,在下一个tick时flush掉两个队列同时运行watchers */
function flushSchedulerQueue () {
  flushing = true
  let watcher, id

  queue.sort((a, b) => a.id - b.id)                    // 排序

  for (index = 0; index  MAX_UPDATE_COUNT) {     // 持续执行了一百次watch代表可能存在死循环
        warn()                                  // 进入死循环的警告
        break
      }
    }
  }
  resetSchedulerState()           // 重置调度者状态
  callActivatedHooks()            // 使子组件状态都置成active同时调用activated钩子
  callUpdatedHooks()              // 调用updated钩子
}

nextTick 方法中執行flushSchedulerQueue # 方法,這個方法挨個執行queue 中的watcher的run 方法。我們看到在首先有個queue.sort() 方法把佇列中的watcher按id從小到大排了個序,這樣做可以保證:

  1. 元件更新的順序是從父元件到子元件的順序,因為父元件總是比子元件先建立。

  2. 一個元件的user watchers(偵聽器watcher)比render watcher先運行,因為user watchers往往比render watcher更早創建

  3. 如果一個元件在父元件watcher運作期間被銷毀,它的watcher執行將會被跳過

在挨個執行佇列中的for迴圈中,index 這裡沒有將length進行緩存,因為在執行處理現有watcher物件期間,更多的watcher物件可能會被push進queue。

那麼資料的修改從model層反映到view的過程:資料變更-> setter -> Dep -> Watcher -> nextTick -> patch -> 更新視圖

2. nextTick原理

2.1 巨集任務/微任務

這裡就來看看包含著每個watcher執行的方法被當作回呼傳入nextTick 之後,nextTick 對這個方法做了什麼。不過首先要了解瀏覽器中的EventLoopmacro taskmicro task幾個概念,不了解可以參考JS與Node.js中的事件循環這篇文章,這裡就用一張圖來表示一下後兩者在主執行緒的執行關係:

Vue源碼中批次非同步更新與nextTick原理的解析

解釋一下,當主執行緒執行完同步任務後:

  1. 引擎先從macrotask queue中取出第一個任務,執行完畢後,將microtask queue中的所有任務取出,依序全部執行;

  2. 然後再從macrotask queue中取下一個,執行完畢後,再次將microtask queue中的全部取出;

  3. 循環往復,直到兩個queue中的任務都取完。

浏览器环境中常见的异步任务种类,按照优先级:

  • macro task :同步代码、setImmediateMessageChannelsetTimeout/setInterval

  • micro taskPromise.thenMutationObserver

有的文章把 micro task 叫微任务,macro task 叫宏任务,因为这两个单词拼写太像了 -。- ,所以后面的注释多用中文表示~

先来看看源码中对 micro task macro task 的实现: macroTimerFuncmicroTimerFunc

// src/core/util/next-tick.js

const callbacks = []     // 存放异步执行的回调
let pending = false      // 一个标记位,如果已经有timerFunc被推送到任务队列中去则不需要重复推送

/* 挨个同步执行callbacks中回调 */
function flushCallbacks() {
  pending = false
  const copies = callbacks.slice(0)
  callbacks.length = 0
  for (let i = 0; i  {
    setImmediate(flushCallbacks)
  }
} else if (typeof MessageChannel !== 'undefined' && (
  isNative(MessageChannel) ||
  MessageChannel.toString() === '[object MessageChannelConstructor]'  // PhantomJS
)) {
  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      // fallback to macro
}

flushCallbacks 这个方法就是挨个同步的去执行callbacks中的回调函数们,callbacks中的回调函数是在调用 nextTick 的时候添加进去的;那么怎么去使用 micro taskmacro task 去执行 flushCallbacks 呢,这里他们的实现 macroTimerFuncmicroTimerFunc 使用浏览器中宏任务/微任务的API对flushCallbacks 方法进行了一层包装。比如宏任务方法 macroTimerFunc=()=>{ setImmediate(flushCallbacks) },这样在触发宏任务执行的时候 macroTimerFunc() 就可以在浏览器中的下一个宏任务loop的时候消费这些保存在callbacks数组中的回调了,微任务同理。同时也可以看出传给 nextTick 的异步回调函数是被压成了一个同步任务在一个tick执行完的,而不是开启多个异步任务。

注意这里有个比较难理解的地方,第一次调用 nextTick 的时候 pending 为false,此时已经push到浏览器event loop中一个宏任务或微任务的task,如果在没有flush掉的情况下继续往callbacks里面添加,那么在执行这个占位queue的时候会执行之后添加的回调,所以 macroTimerFuncmicroTimerFunc 相当于task queue的占位,以后 pending 为true则继续往占位queue里面添加,event loop轮到这个task queue的时候将一并执行。执行 flushCallbackspending 置false,允许下一轮执行 nextTick 时往event loop占位。

可以看到上面 macroTimerFuncmicroTimerFunc 进行了在不同浏览器兼容性下的平稳退化,或者说降级策略

  1. macroTimerFuncsetImmediate -> MessageChannel -> setTimeout 。首先检测是否原生支持 setImmediate ,这个方法只在 IE、Edge 浏览器中原生实现,然后检测是否支持 MessageChannel,如果对 MessageChannel 不了解可以参考一下这篇文章,还不支持的话最后使用 setTimeout
    为什么优先使用 setImmediate MessageChannel 而不直接使用 setTimeout 呢,是因为HTML5规定setTimeout执行的最小延时为4ms,而嵌套的timeout表现为10ms,为了尽可能快的让回调执行,没有最小延时限制的前两者显然要优于 setTimeout

  2. microTimerFuncPromise.then -> macroTimerFunc 。首先检查是否支持 Promise,如果支持的话通过 Promise.then 来调用 flushCallbacks 方法,否则退化为 macroTimerFunc
    vue2.5之后 nextTick 中因为兼容性原因删除了微任务平稳退化的 MutationObserver 的方式。

2.2 nextTick实现

最后来看看我们平常用到的 nextTick 方法到底是如何实现的:

// src/core/util/next-tick.js

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
    if (useMacroTask) {
      macroTimerFunc()
    } else {
      microTimerFunc()
    }
  }
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

/* 强制使用macrotask的方法 */
export function withMacroTask(fn: Function): Function {
  return fn._withTask || (fn._withTask = function() {
    useMacroTask = true
    const res = fn.apply(null, arguments)
    useMacroTask = false
    return res
  })
}

nextTick 在这里分为三个部分,我们一起来看一下;

  1. 首先 nextTick 把传入的 cb 回调函数用 try-catch 包裹后放在一个匿名函数中推入callbacks数组中,这么做是因为防止单个 cb 如果执行错误不至于让整个JS线程挂掉,每个 cb 都包裹是防止这些回调函数如果执行错误不会相互影响,比如前一个抛错了后一个仍然可以执行。

  2. 然后检查 pending 状态,这个跟之前介绍的 queueWatcher 中的 waiting 是一个意思,它是一个标记位,一开始是 false 在进入 macroTimerFuncmicroTimerFunc 方法前被置为 true,因此下次调用 nextTick 就不会进入 macroTimerFuncmicroTimerFunc 方法,这两个方法中会在下一个 macro/micro tick 时候 flushCallbacks 异步的去执行callbacks队列中收集的任务,而 flushCallbacks 方法在执行一开始会把 pendingfalse,因此下一次调用 nextTick 时候又能开启新一轮的 macroTimerFuncmicroTimerFunc,这样就形成了vue中的 event loop

  3. 最后检查是否传入了 cb,因为 nextTick 还支持Promise化的调用:nextTick().then(() => {}),所以如果没有传入 cb 就直接return了一个Promise实例,并且把resolve传递给_resolve,这样后者执行的时候就跳到我们调用的时候传递进 then 的方法中。

Vue源码中 next-tick.js 文件还有一段重要的注释,这里就翻译一下:

在vue2.5之前的版本中,nextTick基本上基于 micro task 来实现的,但是在某些情况下 micro task 具有太高的优先级,并且可能在连续顺序事件之间(例如#4521,#6690)或者甚至在同一事件的事件冒泡过程中之间触发(#6566)。但是如果全部都改成 macro task,对一些有重绘和动画的场景也会有性能影响,如 issue #6813。vue2.5之后版本提供的解决办法是默认使用 micro task,但在需要时(例如在v-on附加的事件处理程序中)强制使用 macro task

为什么默认优先使用 micro task 呢,是利用其高优先级的特性,保证队列中的微任务在一次循环全部执行完毕。

强制 macro task 的方法是在绑定 DOM 事件的时候,默认会给回调的 handler 函数调用 withMacroTask 方法做一层包装 handler = withMacroTask(handler),它保证整个回调函数执行过程中,遇到数据状态的改变,这些改变都会被推到 macro task 中。以上实现在 src/platforms/web/runtime/modules/events.js 的 add 方法中,可以自己看一看具体代码。

刚好在写这篇文章的时候思否上有人问了个问题 vue 2.4 和2.5 版本的@input事件不一样 ,这个问题的原因也是因为2.5之前版本的DOM事件采用 micro task ,而之后采用 macro task,解决的途径参考 中介绍的几个办法,这里就提供一个在mounted钩子中用 addEventListener 添加原生事件的方法来实现,参见 CodePen。

3. 一个例子

说这么多,不如来个例子,执行参见 CodePen

<p>
  <span>{{ name }}</span>
  <button>change name</button>
  </p><p></p>

<script>
  new Vue({
    el: &#39;#app&#39;,
    data() {
      return {
        name: &#39;SHERlocked93&#39;
      }
    },
    methods: {
      change() {
        const $name = this.$refs.name
        this.$nextTick(() => console.log(&#39;setter前:&#39; + $name.innerHTML))
        this.name = &#39; name改喽 &#39;
        console.log(&#39;同步方式:&#39; + this.$refs.name.innerHTML)
        setTimeout(() => this.console("setTimeout方式:" + this.$refs.name.innerHTML))
        this.$nextTick(() => console.log(&#39;setter后:&#39; + $name.innerHTML))
        this.$nextTick().then(() => console.log(&#39;Promise方式:&#39; + $name.innerHTML))
      }
    }
  })
</script>

执行以下看看结果:

同步方式:SHERlocked93 
setter前:SHERlocked93 
setter后:name改喽 
Promise方式:name改喽 
setTimeout方式:name改喽

为什么是这样的结果呢,解释一下:

  1. 同步方式: 当把data中的name修改之后,此时会触发name的 setter 中的 dep.notify 通知依赖本data的render watcher去 updateupdate 会把 flushSchedulerQueue 函数传递给 nextTick,render watcher在 flushSchedulerQueue 函数运行时 watcher.run 再走 diff -> patch 那一套重渲染 re-render 视图,这个过程中会重新依赖收集,这个过程是异步的;所以当我们直接修改了name之后打印,这时异步的改动还没有被 patch 到视图上,所以获取视图上的DOM元素还是原来的内容。

  2. setter前: setter前為什麼還要列印原來的是原來內容呢,是因為nextTick 在被呼叫的時候把回調挨個push進callbacks數組,之後執行的時候也是for 循環出來挨個執行,所以是類似於隊列這樣一個概念,先入先出;在修改name之後,觸發把render watcher填入schedulerQueue隊列並且把他的執行函數flushSchedulerQueue 傳遞給nextTick ,此時callbacks隊列中已經有了setter前函數 了,因為這個cb 是在setter前函數 之後被push進callbacks佇列的,那麼先入先出的執行callbacks中回呼的時候先執行setter前函數,這時並未執行render watcher的watcher.run,所以印製DOM元素仍然是原來的內容。

  3. setter後: setter後這時已經執行完flushSchedulerQueue,這時render watcher已經把改變#patch到視圖上,所以此時取得DOM是改過之後的內容。

  4. Promise方式: 相當於 Promise.then 的方式執行這個函數,此時DOM已經改變。

  5. setTimeout方式: 最後執行macro task的任務,此時DOM已經更改。

注意,在執行setter前函數 這個非同步任務之前,同步的程式碼已經執行完畢,異步的任務都還未執行,所有的 $nextTick 函數也執行完畢,所有回呼都被push進了callbacks佇列中等待執行,所以在setter前函數 執行的時候,此時callbacks佇列是這樣的:[ setter前函數flushSchedulerQueuesetter後函數Promise方式函數],它是一個micro task佇列,執行完畢之後執行macro task setTimeout,所以印出上面的結果。

另外,如果瀏覽器的巨集任務佇列裡面有setImmediateMessageChannelsetTimeout/setInterval 各種類型的任務,那麼會依照上面的順序挨個依照加入到event loop中的順序執行,所以如果瀏覽器支援MessageChannelnextTick 執行的是macroTimerFunc,那麼如果macrotask queue中同時有nextTick 新增的任務和使用者自己新增的setTimeout 類型的任務,會優先執行nextTick 中的任務,因為MessageChannel 的優先權比setTimeout的高,setImmediate 同理。

相關推薦:

分析Vue中mixin的使用方法

以上是Vue源碼中批次非同步更新與nextTick原理的解析的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn