雙十一,打包半價理解Vue的nextTick與watcher以及Dep的藍色生死戀?
一開始就只想搞清楚nextTick的一個原理,誰知道,跟吃了辣條一下,停不下來,從nextTick的原始碼到Watcher原始碼再到Dep原始碼,震驚,然後再結合自己之前看掘金小冊理解的 雙向繫結-響應式系統
,感覺有一種 頓悟
的感覺,總之,這是我個人的理解,請大佬們指教,如有轉載,請附上原文連結,畢竟我copy原始碼也挺累的~
多說一句話
因為這篇文章,有挺多原始碼的,一般來說,換作是我,我也會一掃而過,一目十行,但是筆者我! 真心! 希望! 你們能夠 耐住性子! 去看!原始碼中,會有一丟丟 註釋 , 一定要看尤大大作者給的註釋
如果有什麼地方寫錯了,懇請大佬們指教,互相進步~
請開始你的表演
那麼怎麼說nextTick呢?該從何說起,怪難為情的,還是讓我們先來看個例子吧
<template> <div> <div ref="usernmae">{{ username }}</div> <button @click="handleChangeName">click</button> </div> </template> 複製程式碼
export default { data () { return { username: 'PDK' } }, methods: { handleChangeName () { this.username = '彭道寬' console.log(this.$refs.username.innerText) // PDK } } } 複製程式碼
震驚!!!,打印出來的居然的 "PDK",怎麼回事,我明明修改了username,將值賦為"彭道寬",為什麼還是列印之前的值,而真實獲取到DOM結點的innerText並沒有得到預期中的“彭道寬”, 為啥子 ?
不方,我們再看一個例子,請看:
export default { data () { return { username: 'PDK', age: 18 } }, mounted() { this.age = 19 this.age = 20 this.age = 21 }, watch: { age() { console.log(this.age) } } } 複製程式碼
這段指令碼執行我們猜測會依次列印:19,20,21。但是實際效果中,只會輸出一次:21。為什麼會出現這樣的情況?
事不過三,所以我們再來看一個例子
export default { data () { return { number: 0 } }, methods: { handleClick () { for(let i = 0; i < 10000; i++) { this.number++ } } } } 複製程式碼
在點選click觸發handleClick()事件之後,number會被遍歷增加10000次,在vue的雙向繫結-響應式系統中,會經過 “setter -> Dep -> Watcher -> patch -> 檢視” 這個流水線。那麼是不是可以這麼理解,每次number++,都會經過這個“流水線”來修改真實的DOM,然後DOM被更新了10000次。
但是身為一位“資深”的前端小白來說,都知道,前端對效能的看中,而頻繁的操作DOM,那可是一大“忌諱”啊。Vue.js 肯定不會以如此低效的方法來處理。Vue.js在預設情況下,每次觸發某個資料的 setter 方法後,對應的 Watcher 物件其實會被 push 進一個佇列 queue 中,在下一個 tick 的時候將這個佇列 queue 全部拿出來 run一遍。這裡我們看看Vue官網的描述 : Vue 非同步執行
DOM 更新。只要觀察到資料變化,Vue 將開啟一個佇列,並緩衝在同一事件迴圈中發生的所有資料改變。如果同一個 watcher 被多次觸發,只會被推入到佇列中一次。這種在緩衝時去除重複資料對於避免不必要的計算和 DOM 操作上非常重要。然後,在下一個的事件迴圈“tick”中,Vue 重新整理佇列並執行實際 (已去重的) 工作。

Vue在修改資料的時候,不會立馬就去修改資料,例如,當你設定 vm.someData = 'new value' ,該元件不會立即重新渲染。當重新整理佇列時,元件會在事件迴圈佇列清空時的下一個 tick 更新, 為了在資料變化之後等待 Vue 完成更新 DOM ,可以在資料變化之後立即使用 Vue.nextTick(callback) 。這樣回撥函式在 DOM 更新完成後就會呼叫,下邊來自Vue官網中的例子 :
<div id="example">{{message}}</div> 複製程式碼
var vm = new Vue({ el: '#example', data: { message: '123' } }) vm.message = 'new message' // 更改資料 console.log(vm.$el.textContent === 'new message') // false, message還未更新 Vue.nextTick(function () { console.log(vm.$el.textContent === 'new message') // true, nextTick裡面的程式碼會在DOM更新後執行 }) 複製程式碼

下一個tick是什麼鬼玩意 ?
上面一直扯扯扯,那麼到底什麼是 下一個tick
?
nextTick函式其實做了兩件事情,一是生成一個timerFunc,把回撥作為microTask或macroTask參與到事件迴圈中來。二是把回撥函式放入一個callbacks佇列,等待適當的時機執行
nextTick在官網當中的定義:
在下次 DOM 更新迴圈結束之後執行延遲迴調。在修改資料之後立即使用這個方法,獲取更新後的 DOM。
在 Vue 2.4 之前都是使用的 microtasks(微任務)
,但是 microtasks 的優先順序過高,在某些情況下可能會出現比事件冒泡更快的情況,但如果都使用 macrotasks(巨集任務)
又可能會出現渲染的效能問題。所以 在新版本中,會預設使用 microtasks ,但在特殊情況下會使用 macrotasks。比如 v-on。對於不知道JavaScript執行機制的,可以去看看阮一峰老師的 ofollow,noindex">JavaScript 執行機制詳解:再談Event Loop 、又或者看看我的 Event Loop
哎呀媽,又扯遠了,回到正題,我們先去看看 vue中的原始碼 :
/* @flow */ /* globals MessageChannel */ import { noop } from 'shared/util' import { handleError } from './error' import { isIOS, isNative } from './env' const callbacks = []// 定義一個callbacks陣列來模擬事件佇列 let pending = false// 一個標記位,如果已經有timerFunc被推送到任務佇列中去則不需要重複推送 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 microtasks and (macro) tasks. // In < 2.4 we used microtasks everywhere, but there are some scenarios where // microtasks have too high a priority and fire 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 microtask 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 (macro) 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 }) } } 複製程式碼
來來來,我們仔細的扯一扯~
首先因為目前瀏覽器平臺並沒有實現 nextTick 方法,所以 Vue.js 原始碼中分別用 Promise
、 setTimeout
、 setImmediate
等方式在 microtask(或是macrotasks)中建立一個事件,目的是在當前呼叫棧執行完畢以後(不一定立即)才會去執行這個事件
對於實現 macrotasks ,會先判斷是否能使用 setImmediate ,不能的話降級為 MessageChannel ,以上都不行的話就使用 setTimeout 。 注意,是對實現 巨集任務 的判斷
問題來了?為什麼要優先定義 setImmediate
和 MessageChannel
建立,macroTasks而不是 setTimeout
呢?
HTML5中規定setTimeout的最小時間延遲是4ms,也就是說理想環境下非同步回撥最快也是4ms才能觸發。Vue使用這麼多函式來模擬非同步任務,其目的只有一個,就是讓回撥非同步且儘早呼叫。而MessageChannel 和setImmediate 的延遲明顯是小於 setTimeout 的
// 是否可以使用 setImmediate if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) { macroTimerFunc = () => { setImmediate(flushCallbacks) } } else if (typeof MessageChannel !== 'undefined' && ( isNative(MessageChannel) || // PhantomJS MessageChannel.toString() === '[object MessageChannelConstructor]' )) { // 是否可以使用 MessageChannel const channel = new MessageChannel() const port = channel.port2 channel.port1.onmessage = flushCallbacks macroTimerFunc = () => { port.postMessage(1) // 利用訊息管道,通過postMessage方法把1傳遞給channel.port2 } } else { /* istanbul ignore next */ macroTimerFunc = () => { setTimeout(flushCallbacks, 0)// 利用setTimeout來實現 } } 複製程式碼
setImmediate 和 MessageChannel 都不行的情況下,使用 setTimeout ,delay = 0 之後,執行flushCallbacks(),下邊是flushCallbacks的程式碼
// setTimeout 會在 macrotasks 中建立一個事件 flushCallbacks ,flushCallbacks 則會在執行時將 callbacks 中的所有 cb 依次執行。 function flushCallbacks () { pending = false const copies = callbacks.slice(0) callbacks.length = 0 for (let i = 0; i < copies.length; i++) { copies[i]() } } 複製程式碼
前面說了, nextTick
同時也支援 Promise 的使用,會判斷是否實現了 Promise
export function nextTick (cb?: Function, ctx?: Object) { let _resolve // 將回調函式整合至一個數組,推送到佇列中下一個tick時執行 callbacks.push(() => { if (cb) { try { cb.call(ctx) } catch (e) { handleError(e, ctx, 'nextTick') } } else if (_resolve) { _resolve(ctx) } }) if (!pending) { // pengding = false的話,說明不需要不存在,還沒有timerFunc被推送到任務佇列中 pending = true if (useMacroTask) { macroTimerFunc() // 執行巨集任務 } else { microTimerFunc()// 執行微任務 } } // 判斷是否可以使用 promise // 可以的話給 _resolve 賦值 // 回撥函式以 promise 的方式呼叫 if (!cb && typeof Promise !== 'undefined') { return new Promise(resolve => { _resolve = resolve }) } } 複製程式碼

你以為這就結束了?
ok,上邊nextTick的原始碼比較少,看得大概大概的了,但是呢,還是很懵,所以我又去github看了一下 watcher.js的原始碼 ,回到開頭的第三個例子,就是那個迴圈10000次的那個小坑逼,來,我們看下原始碼再說,原始碼裡的程式碼太多,我挑著copy,嗯,湊合看吧
import { warn, remove, isObject, parsePath, _Set as Set, handleError, noop } from '../util/index' import { traverse } from './traverse' import { queueWatcher } from './scheduler'// 這個很也重要,眼熟它 import Dep, { pushTarget, popTarget } from './dep'// 眼熟這個,這個是將 watcher 新增到 Dep 中,去看看原始碼 import type { SimpleSet } from '../util/index' let uid = 0// 這個也很重要,眼熟它 /** * A watcher parses an expression, collects dependencies, * and fires callback when the expression value changes. * This is used for both the $watch() api and directives. */ export default class Watcher { // 其中的一些我也不知道,我只能從字面上理解,如有大佬,請告知一聲 vm: Component; expression: string; cb: Function; id: number; deep: boolean; user: boolean; lazy: boolean; sync: boolean; dirty: boolean; active: boolean; deps: Array<Dep>; newDeps: Array<Dep>; depIds: SimpleSet; newDepIds: SimpleSet; before: ?Function; getter: Function; value: any; ... constructor ( vm: Component, expOrFn: string | Function, cb: Function, options?: ?Object,// 我們的options isRenderWatcher?: boolean ) { this.vm = vm if (isRenderWatcher) { vm._watch = this } vm._watchers.push(this) // options if (options) { this.deep = !!options.deep this.user = !!options.user this.lazy = !!options.lazy this.sync = !!options.sync this.before = options.before } else { this.deep = this.user = this.lazy = this.sync = false } this.cb = cb this.id = ++uid// 看到沒有,我們類似於給每個 Watcher物件起個名字,用id來標記每一個Watcher物件 this.active = true this.dirty = this.lazy this.deps = [] this.newDeps = [] this.depIds = new Set() this.newDepIds = new Set() this.expression = process.env.NODE_ENV !== 'production' ? expOrFn.toString() : '' // parse expression for getter if (typeof expOrFn === 'function') { this.getter = expOrFn } else { this.getter = parsePath(expOrFn) if (!this.getter) { this.getter = noop process.env.NODE_ENV !== 'production' && warn( `Failed watching path: "${expOrFn}" ` + 'Watcher only accepts simple dot-delimited paths. ' + 'For full control, use a function instead.', vm ) } } this.value = this.lazy ? undefined : this.get()// 執行get()方法 } get () { pushTarget(this) // 呼叫Dep中的pushTarget()方法,具體原始碼下邊貼出 let value const vm = this.vm try { value = this.getter.call(vm, vm) } catch (e) { if (this.user) { handleError(e, vm, `getter for watcher "${this.expression}"`) } else { throw e } } finally { if (this.deep) { traverse(value) } popTarget() // 呼叫Dep中的popTarget()方法,具體原始碼下邊貼出 this.cleanupDeps() } return value } // 新增到dep中 addDep(dep: Dep) { const id = dep.id // Dep 中,存在一個id和subs陣列(用來存放所有的watcher) if (!this.newDepIds.has(id)) { this.newDepIds.add(id) this.newDeps.push(dep) if (!this.depIds.has(id)) { dep.addSub(this) // 呼叫dep.addSub方法,將這個watcher物件新增到陣列中 } } } ... update () { if (this.lazy) { this.dirty = true } else if (this.sync) { this.run() } else { queueWatcher(this) // queueWatcher()方法,下邊會給出原始碼 } } run () { if (this.active) { const value = this.get() if ( value !== this.value || // 看英文註釋啊!!!很清楚了!!! // Deep watchers and watchers on Object/Arrays should fire even // when the value is the same, because the value may isObject(value) || this.deep ) { // set new value const oldValue = this.value this.value = value if (this.user) { try { this.cb.call(this.vm, value, oldValue) } catch (e) { handleError(e, this.vm, `callback for watcher "${this.expression}"`) } } else { this.cb.call(this.vm, value, oldValue) // 回撥函式 } } } } ... } 複製程式碼
太長了?染陌大佬的 《剖析 Vue.js 內部執行機制》 中給出了一個簡單而有利於理解的程式碼(群主,我不是打廣告的,別踢我)
let uid = 0; class Watcher { constructor () { this.id = ++uid; } update () { console.log('watch' + this.id + ' update'); queueWatcher(this); } run () { console.log('watch' + this.id + '檢視更新啦~'); } } 複製程式碼
queueWatcher 是個什麼鬼
夠抽象吧!再看看這個程式碼,比較一看,你會發現,都出現了一個 queueWatcher 的玩意,於是我去把原始碼也看了一下。下邊是它的原始碼(選擇copy)
import { warn, nextTick,// 看到沒有,我們一開始要講的老大哥出現了!!!! devtools } from '../util/index' export const MAX_UPDATE_COUNT = 100 /** * Flush both queues and run the watchers. */ function flushSchedulerQueue () { flushing = true let watcher, id // Sort queue before flush. // This ensures that: // 1. Components are updated from parent to child. (because parent is always //created before the child) // 2. A component's user watchers are run before its render watcher(because //user watchers are created before the render watcher) // 3. If a component is destroyed during a parent component's watcher run, //its watchers can be skipped. queue.sort((a, b) => a.id - b.id) // do not cache length because more watchers might be pushed // as we run existing watchers for (index = 0; index < queue.length; index++) { watcher = queue[index] if (watcher.before) { watcher.before() } id = watcher.id has[id] = null watcher.run()// watcher物件呼叫run方法執行 // in dev build, check and stop circular updates. if (process.env.NODE_ENV !== 'production' && has[id] != null) { circular[id] = (circular[id] || 0) + 1 if (circular[id] > MAX_UPDATE_COUNT) { warn( 'You may have an infinite update loop ' + ( watcher.user ? `in watcher with expression "${watcher.expression}"` : `in a component render function.` ), watcher.vm ) break } } } ... } /** * 看註釋看註釋!!!!!! * Push a watcher into the watcher queue. * Jobs with duplicate IDs will be skipped unless it's * pushed when the queue is being flushed. */ export function queueWatcher (watcher: Watcher) { const id = watcher.id// 獲取watcher的id // 檢驗id是否存在,已經存在則直接跳過,不存在則標記雜湊表has,用於下次檢驗 if (has[id] == null) { has[id] = true if (!flushing) { // 如果沒有flush掉,直接push到佇列中即可 queue.push(watcher) } else { // if already flushing, splice the watcher based on its id // if already past its id, it will be run next immediately. let i = queue.length - 1 while (i > index && queue[i].id > watcher.id) { i-- } queue.splice(i + 1, 0, watcher) } // queue the flush if (!waiting) { waiting = true// 標誌位,它保證flushSchedulerQueue回撥只允許被置入callbacks一次。 if (process.env.NODE_ENV !== 'production' && !config.async) { flushSchedulerQueue() return } nextTick(flushSchedulerQueue)// 看到沒有,呼叫了nextTick // 這裡面的nextTick(flushSchedulerQueue)中的flushSchedulerQueue函式其實就是watcher的檢視更新。 // 每次呼叫的時候會把它push到callbacks中來非同步執行。 } } } 複製程式碼
Dep
哎呀媽,我們再來看看Dep中的原始碼
import type Watcher from './watcher'// 眼熟它 import { remove } from '../util/index' import config from '../config' let uid = 0 /** * A dep is an observable that can have multiple * directives subscribing to it. */ export default class Dep { static target: ?Watcher; id: number; subs: Array<Watcher>; constructor () { this.id = uid++ this.subs = [] } // 將所有的watcher物件新增到陣列中 addSub (sub: Watcher) { this.subs.push(sub) } removeSub (sub: Watcher) { remove(this.subs, sub) } depend () { if (Dep.target) { Dep.target.addDep(this) } } notify () { // stabilize the subscriber list first const subs = this.subs.slice() if (process.env.NODE_ENV !== 'production' && !config.async) { // subs aren't sorted in scheduler if not running async // we need to sort them now to make sure they fire in correct // order subs.sort((a, b) => a.id - b.id) } // 通過迴圈,來呼叫每一個watcher,並且 每個watcher都有一個update()方法,通知檢視更新 for (let i = 0, l = subs.length; i < l; i++) { subs[i].update() } } } // the current target watcher being evaluated. // this is globally unique because there could be only one // watcher being evaluated at any time. Dep.target = null const targetStack = [] export function pushTarget (_target: ?Watcher) { if (Dep.target) targetStack.push(Dep.target) Dep.target = _target } export function popTarget () { Dep.target = targetStack.pop() } // 說白了,在資料【依賴收集】過程就是把 Watcher 例項存放到對應的 Dep 物件中去 // 這時候 Dep.target 已經指向了這個 new 出來的 Watcher 物件 // get 方法可以讓當前的 Watcher 物件(Dep.target)存放到它的 subs 陣列中 // 在資料變化時,set 會呼叫 Dep 物件的 notify 方法通知它內部所有的 Watcher 物件進行檢視更新。 複製程式碼