從JS事件循環(huán)(Event Loop)機(jī)制到vue.nextTick的實(shí)現(xiàn)

前言

眾所周知签餐,為了與瀏覽器進(jìn)行交互弄诲,Javascript是一門非阻塞單線程腳本語言愚战。
  1. 為何單線程? 因?yàn)槿绻贒OM操作中齐遵,有兩個線程一個添加節(jié)點(diǎn)寂玲,一個刪除節(jié)點(diǎn),瀏覽器并不知道以哪個為準(zhǔn)梗摇,所以只能選擇一個主線程來執(zhí)行代碼拓哟,以防止沖突。雖然如今添加了webworker等新技術(shù)伶授,但其依然只是主線程的子線程断序,并不能執(zhí)行諸如I/O類的操作。長期來看谎砾,JS將一直是單線程逢倍。

  2. 為何非阻塞捧颅?因?yàn)閱尉€程意味著任務(wù)需要排隊(duì)景图,任務(wù)按順序執(zhí)行,如果一個任務(wù)很耗時碉哑,下一個任務(wù)不得不等待挚币。所以為了避免這種阻塞,我們需要一種非阻塞機(jī)制扣典。這種非阻塞機(jī)制是一種異步機(jī)制妆毕,即需要等待的任務(wù)不會阻塞主執(zhí)行棧中同步任務(wù)的執(zhí)行。這種機(jī)制是如下運(yùn)行的:

    • 所有同步任務(wù)都在主線程上執(zhí)行贮尖,形成一個執(zhí)行棧(execution context stack)
    • 等待任務(wù)的回調(diào)結(jié)果進(jìn)入一種任務(wù)隊(duì)列(task queue)笛粘。
    • 當(dāng)主執(zhí)行棧中的同步任務(wù)執(zhí)行完畢后才會讀取任務(wù)隊(duì)列,任務(wù)隊(duì)列中的異步任務(wù)(即之前等待任務(wù)的回調(diào)結(jié)果)會塞入主執(zhí)行棧,
    • 異步任務(wù)執(zhí)行完畢后會再次進(jìn)入下一個循環(huán)薪前。此即為今天文章的主角事件循環(huán)(Event Loop)

    用一張圖展示這個過程:


    Markdown

正文

1.macro task與micro task

在實(shí)際情況中润努,上述的任務(wù)隊(duì)列(task queue)中的異步任務(wù)分為兩種:微任務(wù)(micro task)宏任務(wù)(macro task)

  • micro task事件:Promises(瀏覽器實(shí)現(xiàn)的原生Promise)示括、MutationObserver铺浇、process.nextTick
    <br />
  • macro task事件:setTimeoutsetInterval垛膝、setImmediate鳍侣、I/OUI rendering
    這里注意:script(整體代碼)即一開始在主執(zhí)行棧中的同步代碼本質(zhì)上也屬于macrotask吼拥,屬于第一個執(zhí)行的task

microtask和macotask執(zhí)行規(guī)則:

  • macrotask按順序執(zhí)行倚聚,瀏覽器的ui繪制會插在每個macrotask之間
  • microtask按順序執(zhí)行,會在如下情況下執(zhí)行:
    • 每個callback之后凿可,只要沒有其他的JS在主執(zhí)行棧中
    • 每個macrotask結(jié)束時

下面來個簡單例子:

console.log(1);

setTimeout(function() {
  console.log(2);
}, 0);

new Promise(function(resolve,reject){
    console.log(3)
    resolve()
}).then(function() {
  console.log(4);
}).then(function() {
  console.log(5);
});

console.log(6);

一步一步分析如下:

  • 1.同步代碼作為第一個macrotask,按順序輸出:1 3 6
  • 2.microtask按順序執(zhí)行:4 5
  • 3.microtask清空后執(zhí)行下一個macrotask:2

再來一個復(fù)雜的例子:

// Let's get hold of those elements
var outer = document.querySelector('.outer');
var inner = document.querySelector('.inner');

// Let's listen for attribute changes on the
// outer element
new MutationObserver(function() {
  console.log('mutate');
}).observe(outer, {
  attributes: true
});

// Here's a click listener…
function onClick() {
  console.log('click');

  setTimeout(function() {
    console.log('timeout');
  }, 0);

  Promise.resolve().then(function() {
    console.log('promise');
  });

  outer.setAttribute('data-random', Math.random());
}

// …which we'll attach to both elements
inner.addEventListener('click', onClick);
outer.addEventListener('click', onClick);

假設(shè)我們創(chuàng)建一個有里外兩部分的正方形盒子秉沼,里外都綁定了點(diǎn)擊事件,此時點(diǎn)擊內(nèi)部矿酵,代碼會如何執(zhí)行唬复?一步一步分析如下:

  • 1.觸發(fā)內(nèi)部click事件,同步輸出:click
  • 2.將setTimeout回調(diào)結(jié)果放入macrotask隊(duì)列
  • 3.將promise回調(diào)結(jié)果放入microtask
  • 4.將Mutation observers放入microtask隊(duì)列全肮,主執(zhí)行棧中onclick事件結(jié)束敞咧,主執(zhí)行棧清空
  • 5.依序執(zhí)行microtask隊(duì)列中任務(wù),輸出:promise mutate
  • 6.注意此時事件冒泡辜腺,外部元素再次觸發(fā)onclick回調(diào)休建,所以按照前5步再次輸出:click promise mutate(我們可以注意到事件冒泡甚至?xí)趍icrotask中的任務(wù)執(zhí)行之后,microtask優(yōu)先級非常高)
  • 7.macrotask中第一個任務(wù)執(zhí)行完畢评疗,依次執(zhí)行macrotask中剩下的任務(wù)輸出:timeout timeout

2.vue.nextTick實(shí)現(xiàn)

在 Vue.js 里是數(shù)據(jù)驅(qū)動視圖變化测砂,由于 JS 執(zhí)行是單線程的,在一個 tick 的過程中百匆,它可能會多次修改數(shù)據(jù)砌些,但 Vue.js 并不會傻到每修改一次數(shù)據(jù)就去驅(qū)動一次視圖變化,它會把這些數(shù)據(jù)的修改全部 push 到一個隊(duì)列里加匈,然后內(nèi)部調(diào)用 一次 nextTick 去更新視圖存璃,所以數(shù)據(jù)到 DOM 視圖的變化是需要在下一個 tick 才能完成。這便是我們?yōu)槭裁葱枰?code>vue.nextTick.

這樣一個功能和事件循環(huán)非常相似雕拼,在每個 task 運(yùn)行完以后纵东,UI 都會重渲染,那么很容易想到在 microtask 中就完成數(shù)據(jù)更新啥寇,當(dāng)前 task 結(jié)束就可以得到最新的 UI 了偎球。反之如果新建一個 task 來做數(shù)據(jù)更新洒扎,那么渲染就會進(jìn)行兩次。

所以在vue 2.4之前使用microtask實(shí)現(xiàn)nextTick衰絮,直接上源碼

var counter = 1
var observer = new MutationObserver(nextTickHandler)
var textNode = document.createTextNode(String(counter))
observer.observe(textNode, {
    characterData: true
})
timerFunc = () => {
    counter = (counter + 1) % 2
    textNode.data = String(counter)
}

可以看到使用了MutationObserver

然而到了vue 2.4之后卻混合?使用microtask macrotask來實(shí)現(xiàn)逊笆,源碼如下

/* @flow */
/* globals MessageChannel */

import { noop } from 'shared/util'
import { handleError } from './error'
import { isIOS, isNative } from './env'

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

// Here we have async deferring wrappers using both micro and macro tasks.
// In < 2.4 we used micro tasks everywhere, but there are some scenarios where
// micro tasks have too high a priority and fires in between supposedly
// sequential events (e.g. #4521, #6690) or even between bubbling of the same
// event (#6566). However, using macro tasks everywhere also has subtle problems
// when state is changed right before repaint (e.g. #6813, out-in transitions).
// Here we use micro task by default, but expose a way to force macro task when
// needed (e.g. in event handlers attached by v-on).
let microTimerFunc
let macroTimerFunc
let useMacroTask = false

// Determine (macro) Task defer implementation.
// Technically setImmediate should be the ideal choice, but it's only available
// in IE. The only polyfill that consistently queues the callback after all DOM
// events triggered in the same loop is by using MessageChannel.
/* istanbul ignore if */
if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  macroTimerFunc = () => {
    setImmediate(flushCallbacks)
  }
} else if (typeof MessageChannel !== 'undefined' && (
  isNative(MessageChannel) ||
  // PhantomJS
  MessageChannel.toString() === '[object MessageChannelConstructor]'
)) {
  const channel = new MessageChannel()
  const port = channel.port2
  channel.port1.onmessage = flushCallbacks
  macroTimerFunc = () => {
    port.postMessage(1)
  }
} else {
  /* istanbul ignore next */
  macroTimerFunc = () => {
    setTimeout(flushCallbacks, 0)
  }
}

// Determine MicroTask defer implementation.
/* istanbul ignore next, $flow-disable-line */
if (typeof Promise !== 'undefined' && isNative(Promise)) {
  const p = Promise.resolve()
  microTimerFunc = () => {
    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
}

/**
 * Wrap a function so that if any code inside triggers state change,
 * the changes are queued using a Task instead of a MicroTask.
 */
export function withMacroTask (fn: Function): Function {
  return fn._withTask || (fn._withTask = function () {
    useMacroTask = true
    const res = fn.apply(null, arguments)
    useMacroTask = false
    return res
  })
}

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()
    }
  }
  // $flow-disable-line
  if (!cb && typeof Promise !== 'undefined') {
    return new Promise(resolve => {
      _resolve = resolve
    })
  }
}

可以看到使用setImmediate、MessageChannel等mascrotask事件來實(shí)現(xiàn)nextTick岂傲。

為什么會如此修改难裆,其實(shí)看之前的事件冒泡例子就可以知道,由于microtask優(yōu)先級太高镊掖,甚至?xí)让芭菘炷烁辏詴斐梢恍┰幃惖腷ug。如 issue #4521亩进、#6690症虑、#6556;但是如果全部都改成 macro task归薛,對一些有重繪和動畫的場景也會有性能影響谍憔,如 issue #6813。所以最終 nextTick 采取的策略是默認(rèn)走 micro task主籍,對于一些 DOM 交互事件习贫,如 v-on 綁定的事件回調(diào)函數(shù)的處理,會強(qiáng)制走 macro task千元。

參考資料
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末苫昌,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子幸海,更是在濱河造成了極大的恐慌祟身,老刑警劉巖,帶你破解...
    沈念sama閱讀 219,589評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件物独,死亡現(xiàn)場離奇詭異袜硫,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)挡篓,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,615評論 3 396
  • 文/潘曉璐 我一進(jìn)店門婉陷,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人瞻凤,你說我怎么就攤上這事憨攒∈郎保” “怎么了阀参?”我有些...
    開封第一講書人閱讀 165,933評論 0 356
  • 文/不壞的土叔 我叫張陵,是天一觀的道長瞻坝。 經(jīng)常有香客問我蛛壳,道長杏瞻,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,976評論 1 295
  • 正文 為了忘掉前任衙荐,我火速辦了婚禮捞挥,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘忧吟。我一直安慰自己砌函,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,999評論 6 393
  • 文/花漫 我一把揭開白布溜族。 她就那樣靜靜地躺著讹俊,像睡著了一般。 火紅的嫁衣襯著肌膚如雪煌抒。 梳的紋絲不亂的頭發(fā)上仍劈,一...
    開封第一講書人閱讀 51,775評論 1 307
  • 那天,我揣著相機(jī)與錄音寡壮,去河邊找鬼贩疙。 笑死,一個胖子當(dāng)著我的面吹牛况既,可吹牛的內(nèi)容都是我干的这溅。 我是一名探鬼主播,決...
    沈念sama閱讀 40,474評論 3 420
  • 文/蒼蘭香墨 我猛地睜開眼棒仍,長吁一口氣:“原來是場噩夢啊……” “哼芍躏!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起降狠,我...
    開封第一講書人閱讀 39,359評論 0 276
  • 序言:老撾萬榮一對情侶失蹤对竣,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后榜配,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體否纬,經(jīng)...
    沈念sama閱讀 45,854評論 1 317
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 38,007評論 3 338
  • 正文 我和宋清朗相戀三年蛋褥,在試婚紗的時候發(fā)現(xiàn)自己被綠了临燃。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 40,146評論 1 351
  • 序言:一個原本活蹦亂跳的男人離奇死亡烙心,死狀恐怖膜廊,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情淫茵,我是刑警寧澤爪瓜,帶...
    沈念sama閱讀 35,826評論 5 346
  • 正文 年R本政府宣布,位于F島的核電站匙瘪,受9級特大地震影響铆铆,放射性物質(zhì)發(fā)生泄漏蝶缀。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,484評論 3 331
  • 文/蒙蒙 一薄货、第九天 我趴在偏房一處隱蔽的房頂上張望翁都。 院中可真熱鬧,春花似錦谅猾、人聲如沸柄慰。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,029評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽先煎。三九已至,卻和暖如春巧涧,著一層夾襖步出監(jiān)牢的瞬間薯蝎,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,153評論 1 272
  • 我被黑心中介騙來泰國打工谤绳, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留占锯,地道東北人。 一個月前我還...
    沈念sama閱讀 48,420評論 3 373
  • 正文 我出身青樓缩筛,卻偏偏與公主長得像消略,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子瞎抛,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,107評論 2 356

推薦閱讀更多精彩內(nèi)容