通過幾個問題深入淺出Vue

XxjzZ發表於2018-12-24

? 前言

通常,Vue給我們的印象是“小巧易用”,憑藉其簡潔明瞭的模板開發方式,以及強大的指令系統,我們可以輕輕鬆鬆幾行程式碼搞定一個資料雙向繫結的頁面。但是,這背後Vue幫我們做了多少工作,我們是知之甚少的。

Vue就像一個黑盒子,我們輸入一些資料,它給我們輸出一個渲染好的頁面。對於開發,這很方便,我們不需要關心何時去觸發更新,因為Vue已經幫我們做了。但是,在面對一些棘手問題時,我們需要去分析資料是何時變化的、被誰更改的、為什麼會觸發更新、為什麼又不能觸發更新,等等問題,這個時候就很難,因為這些邏輯都隱藏在黑盒子內部,我們無法觀察,更無法控制。

所以說,想要用好Vue其實還挺難的。

面對這些問題,我們往往會基於個人的經驗,個人的理解去分析問題,會花費大量時間去debug,這很低效。不如我們先抽點時間,瞭解一下Vue的實現細節吧。

?問題分類

其實按照Vue的開發方式,一般都會有如下流程:

  1. 先初始化一個Vue例項,然後傳入各種配置資訊
  2. 嘗試修改一些資料
  3. 期待檢視更新

所以,我們遇到的問題可以歸為以下三類:

  1. Vue的初始化流程是怎樣的
  2. 資料的更改會不會觸發檢視的更新
  3. 資料的更改何時會觸發檢視的更新

?問題

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方法。

知道了這個順序,就能解決很多疑惑比如:

  1. 在watch, created, mounted裡面第一次用計算屬性的時候,計算屬性已經初始化了嗎?
  2. data屬效能否使用計算屬性來初始化
  3. 在created裡修改watch監聽的屬性,會不會觸發watch的執行?如果加了immediate又會執行幾次呢?
  4. ...等等自己不確定的問題

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必須弄懂兩件事:

  1. 事件迴圈本身
  2. 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,並結合一定的原始碼閱讀,我們知道本次示例的初始化順序為:

  1. 先初始化data,取出lib、lock兩個屬性,分別observe
  2. 初始化watch,取出lib屬性的key和value(即handler),並用他們初始化一個watcher,從而形成一個watcher對lib屬性的監聽,並等待一個『合適的時機』去執行我們給定的handler
  3. 執行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
複製程式碼

問題有兩個:

  1. 為什麼watch只執行一次
  2. 為什麼是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函式,會分別在兩次事件迴圈中執行,而不是一次,如下:

  1. 第一次事件迴圈(script任務, 也是task):vue初始化,created方法發起兩個非同步任務
  2. 第二次事件迴圈(task):開始執行第一個setTimeout的callback
  3. 第三次事件迴圈(task):開始執行第二個setTimeout的callback

在第二次事件迴圈中,我們修改了lib,同時出發了相應的watcher,最終執行queueWatcher方法,於是當前的watcher被push到queue佇列,待到nextTick執行,而nextTick是microTask,於是我們上面的過程變成了:

  1. 第一次事件迴圈(script任務):vue初始化,created方法發起兩個非同步任務
  2. 第二次事件迴圈(task):開始執行第一個setTimeout的callback
  3. 第二次事件迴圈(microTask):nextTick(flushSchedulerQueue)中執行watcher.run(即執行handler),並清空has[id]
  4. 第三次事件迴圈(task):開始執行第二個setTimeout的callback

注意:在js的一次事件迴圈中,先執行所有同步程式碼,之後,會從macroTask佇列裡取出1個macroTask執行。然後,再取出所有microTask佇列裡的microTask,並依次執行。這整個過程結束後,便會開啟下一次事件迴圈。

接下來到了第三次事件迴圈,我們再次修改lib,同樣的過程,因為上一步已經清空了has[id],所以本次lib 的更新其實跟上一次一模一樣,所以過程變成了:

  1. 第一次事件迴圈(script任務):vue初始化,created方法發起兩個非同步任務
  2. 第二次事件迴圈(task):開始執行第一個setTimeout的callback
  3. 第二次事件迴圈(microTask):nextTick(flushSchedulerQueue)中執行watcher.run(即執行handler),並清空has[id]
  4. 第三次事件迴圈(task):開始執行第二個setTimeout的callback
  5. 第三次事件迴圈(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只執行了一次。

  1. 第一次事件迴圈(script任務):vue初始化,created方法發起兩個microTask非同步任務
  2. 第二次事件迴圈(task):沒有macroTask
  3. 第二次事件迴圈(microTask):現在microTask任務佇列是這樣的[callback1, callback2], 從microTask佇列取出第一個callback1,執行,觸發lib更新,從而執行queueWatcher,在裡面又觸發了一個nextTick(microTask)非同步任務,並push到microTask任務佇列,佇列變成這樣[callback2, callback3]
  4. 第二次事件迴圈(microTask):從micoTask佇列取出callback2,執行,觸發lib更新,從而執行queueWatcher,而此時has[id]已經有值(因為callback3還沒執行,因此has[id]還沒被清空),所以直接略過
  5. 第二次事件迴圈(microTask):從microTask佇列取出callback3,執行

可以看到,這就是隻列印一次的原因了。

總結

其實Vue的設計思想就是:事件迴圈+雙向繫結,只要我們搞明白這兩點,我們就可以真正的掌握Vue,寫出穩定、可預測的程式碼,輕鬆的解決使用中遇到的各種問題。

有了設計思想還不夠,還需要工具去實現這種思想,那就是compiler和vdom乾的事了,還有很多東西要學呢??。

相關文章