? 前言
通常,Vue給我們的印象是“小巧易用”,憑藉其簡潔明瞭的模板開發方式,以及強大的指令系統,我們可以輕輕鬆鬆幾行程式碼搞定一個資料雙向繫結的頁面。但是,這背後Vue幫我們做了多少工作,我們是知之甚少的。
Vue就像一個黑盒子,我們輸入一些資料,它給我們輸出一個渲染好的頁面。對於開發,這很方便,我們不需要關心何時去觸發更新,因為Vue已經幫我們做了。但是,在面對一些棘手問題時,我們需要去分析資料是何時變化的、被誰更改的、為什麼會觸發更新、為什麼又不能觸發更新,等等問題,這個時候就很難,因為這些邏輯都隱藏在黑盒子內部,我們無法觀察,更無法控制。
所以說,想要用好Vue其實還挺難的。
面對這些問題,我們往往會基於個人的經驗,個人的理解去分析問題,會花費大量時間去debug,這很低效。不如我們先抽點時間,瞭解一下Vue的實現細節吧。
?問題分類
其實按照Vue的開發方式,一般都會有如下流程:
- 先初始化一個Vue例項,然後傳入各種配置資訊
- 嘗試修改一些資料
- 期待檢視更新
所以,我們遇到的問題可以歸為以下三類:
- Vue的初始化流程是怎樣的
- 資料的更改會不會觸發檢視的更新
- 資料的更改何時會觸發檢視的更新
?問題
Q1. 我對Vue的配置資訊不理解(第一類問題)
通常我們的一個Vue例項是這樣的:
const options = {
props: ...,
data: ...,
computed: ...,
watch: ...,
methods: ...,
created: ...,
mounted: ...
}
new Vue(options).$mount('#app')
複製程式碼
也許,我們很清楚每個屬性的含義,但是我們卻不知道這些屬性是如何被呼叫的,是以怎樣的順序來初始化的。通過這種”無順序“配置物件的方式來構建Vue例項,我們先天性的丟失了一些重要的“資訊”,那就是程式碼的執行順序,這會帶來一系列理解上的問題。
其實,通過文件裡的生命週期圖,或者看原始碼,我們就會知道,在new Vue(opions)
的這個過程中,即Vue這個類的建構函式中,Vue會依次”同步“地把props,methods,data,computed,watch
取出來,然後分別初始化,之後再執行我們的created方法。最後, 在我們執行$mount('#app)
之後,再執行我們的mounted方法。
知道了這個順序,就能解決很多疑惑比如:
- 在watch, created, mounted裡面第一次用計算屬性的時候,計算屬性已經初始化了嗎?
- data屬效能否使用計算屬性來初始化
- 在created裡修改watch監聽的屬性,會不會觸發watch的執行?如果加了immediate又會執行幾次呢?
- ...等等自己不確定的問題
Q2. 我修改資料為什麼沒有生效(第二類問題)
我們或多或少都會遇到,明明自己改了資料,可是檢視就是不更新的問題,舉個例子:
<template>
<div>
<p>{{lib}}</p>
<p>{{detail.version}}</p>
<p>{{detail.type}}</p>
</div>
</template>
複製程式碼
export default {
data() {
return {
lib: 'vue',
detail: {
version: '2.5.1',
// type: 'fe'
}
}
},
mounted() {
this.detail.type = 'fe'
}
}
複製程式碼
// 輸出
vue
2.5.1
複製程式碼
問題很容易看出,是因為我們沒有事先在data裡宣告好type這個屬性,所以在data的初始化過程中,並沒有observe(即把屬性轉變為getter和setter)這個type屬性,所以我們的修改是不會觸發setter的,也就不會引起檢視更新。
接下來,我們更新一下程式碼:
// 把mounted改成created
created() {
this.detail.type = 'fe'
}
複製程式碼
我們神奇的發現,竟然顯示出來了,這跟我們剛才的結論相悖呀。其實,我們的結論沒錯,之所以這次能顯示出來,純粹是巧合。我們知道created是在data初始化之後呼叫的,同時又是在mounted之前呼叫的,所以data在mounted之前就被賦予了一個未被observe的屬性type,然後在$mount的時候,順帶顯示在頁面上了。也就是說,這次的顯示,並非setter的觸發,而是本來data就已經有了type屬性罷了。
我們再來改一下:
created() {
this.detail.type = 'fe'
},
mounted() {
this.detail.type = 'be'
}
複製程式碼
我們會發現,依舊顯示的是fe,而不是be,這驗證了上一個結論。
一句話:只有被observe的資料改變後才會觸發檢視的更新
Q3:Vue是如何使用事件迴圈的(第三類問題)
這個問題比較抽象,具體一點舉幾個例子:
- 怎麼準確的判斷watch的handler什麼時候執行、執行幾次
- 我更改了資料,何時才會觸發檢視更新
- $nextTick和setTimeou區別
可以說,Vue的本質其實就是一套精心設計的事件迴圈系統,要弄懂Vue必須弄懂兩件事:
- 事件迴圈本身
- Vue的事件迴圈系統
事件迴圈需要理解到task和microTask的層次,而Vue的事件迴圈系統需要讀透文件,理解作者的思想,另外多看原始碼。下面我們舉一些例子
Q3-1:watch的handler什麼時候執行
export default {
data() {
return {
lib: 'vue',
lock: true
}
},
watch: {
lib(val, old) {
if(this.lock) {
console.log(`lib changed from ${old} to ${val}`)
}
}
},
created() {
this.lock = false;
this.lib = 'react'
this.lock = true;
}
}
複製程式碼
// 輸出
lib changed from vue to react
複製程式碼
按照我們常規的理解,當我們執行this.lib = 'react
的時候,理應當觸發watch,而此時lock其實是false,不應該輸出。這裡有一個『陷阱』,就是我們錯誤的以為Vue的內部的執行機制是同步的,而事實上,Vue會充分利用事件迴圈做一些非同步的事情,比如這裡的handler執行機制。
根據Q1,並結合一定的原始碼閱讀,我們知道本次示例的初始化順序為:
- 先初始化data,取出lib、lock兩個屬性,分別observe
- 初始化watch,取出lib屬性的key和value(即handler),並用他們初始化一個watcher,從而形成一個watcher對lib屬性的監聽,並等待一個『合適的時機』去執行我們給定的handler
- 執行created宣告周期函式,此時資料的初始化工作已經完成,接下來先執行
this.lock = false
,這不會影響什麼。再執行this.lib = 'react'
, 此時觸發了lib的setter方法,接著Vue會找出所以正在監聽lib屬性的watcher,並執行其update方法
update () {
queueWatcher(this)
}
複製程式碼
我們發現,watcher並沒有立即執行handler,而是發起了一個queueWatcher:
flushing = false
waiting = false
export function queueWatcher (watcher) {
if (!flushing) {
queue.push(watcher)
}
// queue the flush
if (!waiting) {
waiting = true
// flushSchedulerQueueh會依次把queue中的watcher拿出來執行
nextTick(flushSchedulerQueue)
}
}
複製程式碼
已知flushing和wating預設值都是false,所以『第一次觸發watch』程式碼會像這樣執行
queue.push(watcher)
nextTick(flushSchedulerQueue)
複製程式碼
可以看到,我們的handler會在nextTick時執行(關於nextTick我們後面會講,在這裡可以暫時理解成setTimeout),而等到下一次事件迴圈,this.lock = true
已經執行,所以我們console了出來。
總結:我們現在我們對Vue的事件迴圈機制有了一個認知,即Vue中資料的變化所引起的響應,是依託事件迴圈就機制來完成的。
Q3-2: 深入Vue的事件迴圈細節
僅知道資料的響應是依託於事件迴圈還不夠,因為我們的程式碼會越寫越複雜,常常會有多個非同步任務,此時我們需要準確的知道,我們的watch何時觸發,我們的檢視何時更新。因此,我們需要更加細緻的去研究。
Q3-2-1:直接修改值
export default {
data() {
return {
lib: 'vue'
}
},
watch: {
lib(val, old) {
console.log(`lib changed from ${old} to ${val}`)
}
},
created() {
this.lib = 'react'
this.lib = 'angular'
}
}
複製程式碼
// 輸出
lib changed from vue to angular
複製程式碼
問題有兩個:
- 為什麼watch只執行一次
- 為什麼是angular
雖然這個問題比較蠢,但是為了幫助我們理解後面的例子,我們還是得深入的去研究一下。
根據Q3-1,watch中的lib會建立一個watcher,並監聽lib屬性的變化。當我們第一次this.lib = 'react'
, 此時會觸發lib的setter,並找到正在監聽的watcher,依次執行watcher的update方法,update方法會呼叫queueWatcher方法,現在我們看一下queueWatcher更完整一點的程式碼:
/**
* 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
// has維持著所有watcher的id
if (has[id] == null) {
has[id] = true
if (!flushing) {
queue.push(watcher)
} else {
...
}
// queue the flush
if (!waiting) {
waiting = true
nextTick(flushSchedulerQueue)
}
}
}
複製程式碼
可以看到,這裡有一個if (has[id] == null)
的判斷,要知道,我們watch只有一個lib屬性,所以只會初始化一個watcher,所以當第二次執行this.lib = 'angular'
的時候,queueWatcher其實什麼都沒幹。
所以結果是,雖然有兩次lib的變化,但是watcher會在下一次事件迴圈,只執行一次handler。
Q3-2-2 在setTimeout裡修改值
export default {
data() {
return {
lib: 'vue'
}
},
watch: {
lib(val, old) {
console.log(`lib changed from ${old} to ${val}`)
}
},
created() {
setTimeout(() => {
this.lib = 'react'
}, 0)
setTimeout(() => {
this.lib = 'angular'
}, 0)
}
}
複製程式碼
// 輸出
lib changed from vue to react
lib changed from react to angular
複製程式碼
為什麼現在又輸出兩次了?再看一遍queueWatcher方法:
export function queueWatcher (watcher: Watcher) {
const id = watcher.id
// has維持著所有watcher的id
if (has[id] == null) {
has[id] = true
if (!flushing) {
queue.push(watcher)
} else {
...
}
// queue the flush
if (!waiting) {
waiting = true
nextTick(flushSchedulerQueue)
}
}
}
複製程式碼
還需要看flushSchedulerQueue方法
/**
* Flush both queues and run the watchers.
*/
function flushSchedulerQueue () {
flushing = true
let watcher, 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()
...
}
...
}
複製程式碼
點我檢視預備知識(事件迴圈中的task和microTask)
當Vue執行created方法,會執行兩個setTimeout,而setTimeout又是task,所以我們寫的兩個setTimeout函式,會分別在兩次事件迴圈中執行,而不是一次,如下:
- 第一次事件迴圈(script任務, 也是task):vue初始化,created方法發起兩個非同步任務
- 第二次事件迴圈(task):開始執行第一個setTimeout的callback
- 第三次事件迴圈(task):開始執行第二個setTimeout的callback
在第二次事件迴圈中,我們修改了lib,同時出發了相應的watcher,最終執行queueWatcher方法,於是當前的watcher被push到queue佇列,待到nextTick執行,而nextTick是microTask,於是我們上面的過程變成了:
- 第一次事件迴圈(script任務):vue初始化,created方法發起兩個非同步任務
- 第二次事件迴圈(task):開始執行第一個setTimeout的callback
- 第二次事件迴圈(microTask):
nextTick(flushSchedulerQueue)
中執行watcher.run(即執行handler),並清空has[id] - 第三次事件迴圈(task):開始執行第二個setTimeout的callback
注意:在js的一次事件迴圈中,先執行所有同步程式碼,之後,會從macroTask佇列裡取出1個macroTask執行。然後,再取出所有microTask佇列裡的microTask,並依次執行。這整個過程結束後,便會開啟下一次事件迴圈。
接下來到了第三次事件迴圈,我們再次修改lib,同樣的過程,因為上一步已經清空了has[id],所以本次lib 的更新其實跟上一次一模一樣,所以過程變成了:
- 第一次事件迴圈(script任務):vue初始化,created方法發起兩個非同步任務
- 第二次事件迴圈(task):開始執行第一個setTimeout的callback
- 第二次事件迴圈(microTask):
nextTick(flushSchedulerQueue)
中執行watcher.run(即執行handler),並清空has[id] - 第三次事件迴圈(task):開始執行第二個setTimeout的callback
- 第三次事件迴圈(microTask):
nextTick(flushSchedulerQueue)
中執行watcher.run(即執行handler),並清空has[id]
所以,會列印兩次。
Q3-2-3 在nextTick裡修改值
export default {
data() {
return {
lib: 'vue'
}
},
watch: {
lib(val, old) {
console.log(`lib changed from ${old} to ${val}`)
}
},
created() {
this.$nextTick(() => {
this.lib = 'react'
})
this.$nextTick(() => {
this.lib = 'angular'
})
}
}
複製程式碼
問題是,為什麼watch只執行了一次。
- 第一次事件迴圈(script任務):vue初始化,created方法發起兩個microTask非同步任務
- 第二次事件迴圈(task):沒有macroTask
- 第二次事件迴圈(microTask):現在microTask任務佇列是這樣的
[callback1, callback2]
, 從microTask佇列取出第一個callback1,執行,觸發lib更新,從而執行queueWatcher,在裡面又觸發了一個nextTick(microTask)非同步任務,並push到microTask任務佇列,佇列變成這樣[callback2, callback3]
- 第二次事件迴圈(microTask):從micoTask佇列取出callback2,執行,觸發lib更新,從而執行queueWatcher,而此時has[id]已經有值(因為callback3還沒執行,因此has[id]還沒被清空),所以直接略過
- 第二次事件迴圈(microTask):從microTask佇列取出callback3,執行
可以看到,這就是隻列印一次的原因了。
總結
其實Vue的設計思想就是:事件迴圈+雙向繫結,只要我們搞明白這兩點,我們就可以真正的掌握Vue,寫出穩定、可預測的程式碼,輕鬆的解決使用中遇到的各種問題。
有了設計思想還不夠,還需要工具去實現這種思想,那就是compiler和vdom乾的事了,還有很多東西要學呢??。