1. 程式人生 > >原始碼學習VUE之Watcher

原始碼學習VUE之Watcher

我們在前面推導過程中實現了一個簡單版的watcher。這裡面還有一些問題

class Watcher {
    constructors(component, getter, cb){
        this.cb = cb // 對應的回撥函式,callback
        this.getter = getter;
        this.component = component; //這就是執行上下文
    }
    
    //收集依賴
    get(){
        Dep.target = this;        
        this.getter.call(this.component)   
        if (this.deep) {
            traverse(value)
        }
        Dep.target = null;
    }
    
    update(){
        this.cb()
    }
}

同步非同步更新

所謂的同步更新是指當觀察的主體改變時立刻觸發更新。而實際開發中這種需求並不多,同一事件迴圈中可能需要改變好幾次state狀態,但檢視view只需要根據最後一次計算結果同步渲染就行(react中的setState就是典型)。如果一直做同步更新無疑是個很大的效能損耗。這就要求watcher在接收到更新通知時不能全都立刻執行callback。我們對程式碼做出相應調整

constructors(component, getter, cb, options){
        this.cb = cb // 對應的回撥函式,callback
        this.getter = getter;
        this.id = UUID() // 生成一個唯一id
        this.sync = options.sync; //預設一般為false
        this.vm = component; //這就是執行上下文
        this.value = this.getter() // 這邊既收集了依賴,又儲存了舊的值
    }
        
    update(){
        if(this.sync){ //如果是同步那就立刻執行回撥
            this.run();
        }else{
            // 否則把這次更新快取起來
            //但是就像上面說的,非同步更新往往是同一事件迴圈中多次修改同一個值,
            // 那麼一個wather就會被快取多次。因為需要一個id來判斷一下,
            queueWatcher(this)
        }
    }
    
    run: function(){
        //獲取新的值
        var newValue = this.getter();
        this.cb.call(this.vm, newValue, this.value)
    }

這裡的一個要注意的地方是,考慮到極限情況,如果正在更新佇列中wather時,又塞入進來該怎麼處理。因此,加入一個flushing來表示佇列的更新狀態。如果加入的時候佇列正在更新狀態,這時候分兩種情況:

  1. 這個watcher已經更新過, 就把這個watcher再放到當前執行的下一位,當前watcher處理完,立即處理這個最新的。
  2. 這個watcher還沒有處理,就找到這個wather在佇列中現有的位置,並再把新的放在後面。
let flushing = false;
let has = {}; // 簡單用個物件儲存一下wather是否已存在
function queueWatcher (watcher) {
  const id = watcher.id
  if (has[id] == null) {
    has[id] = true // 如果之前沒有,那麼就塞進去吧,如果有了就不用管了
    if (!flushing) {
      queue.push(watcher)
    } else {
      let i = queue.length - 1
      while (i > index && queue[i].id > watcher.id) {
        i--
      }
      queue.splice(i + 1, 0, watcher)
    }
   // ... 等同一事件迴圈結束後再依次處理佇列裡的watcher。具體程式碼放到後面nexttick部分再說
    }
  }
}

這麼設計不無道理。我們之所以為了將wather放入佇列中,就是為了較少不必要的操作。考慮如下程式碼

data: {
    a: 1
},
computed: {
    b: function(){
        this.a + 1
    }
}

methods: {
    act: function(){
        this.a = 2;
        // do someting
        this.a = 1
    }
}

在act操作中,我們先改變a,再把它變回來。我們理想狀況下是a沒變,b也不重新計算。這就要求,b的wather執行update的時候要拿到a最新的值來計算。這裡就是1。如果佇列中a的watehr已經更新過,那麼就應該把後面的a的wather放到當前更新的wather後面,立即更新。這樣可以保證後面的wather用到a是可以拿到最新的值。同理,如果a的wather還沒有更新,那麼把新的a的wather放的之前的a的wather的下一位,也是為了保證後面的wather用到a是可以拿到最新的值。

computed

之所以把計算屬性拿出愛單獨講,是因為

  1. 計算屬性存在按需載入的情況
  2. 與render和$watcher相比,計算屬性a可能依賴另一個計算屬性b。

按需載入

所謂的按需計算顧名思義就是用到了才會計算,即呼叫了某個計算屬性的get方法。在前面的方法中,我們在class Watcher的constructor中直接呼叫了getter方法收集依賴,這顯然是不符合按需載入的原則的。

依賴收集

實際開發中,我們發現一個計算屬性往往由另一個計算屬性得來。如,

computed: {
    a: function(){
        return this.name;
    },
    b: function(){
        return this.a + "123"; 
    }
}

對於a而言,它是b的依賴,因此有必要在a的wather執行update操作時也更新b,也就意味著,a的watcher裡需要收集著b的依賴。而收集的時機是執行b的回撥時,this.a呼叫了a的get方法的時候在computed部分,已經對計算屬性的get方法進行了改寫

function computedGetter () {
    const watcher = this._computedWatchers && this._computedWatchers[key]
    if (watcher) {
      //呼叫一個計算屬性的get方法時,會在watcher中收集依賴。
      watcher.depend() 
      return watcher.evaluate()
    }
  }

我們再修改一下wather程式碼:

class Watcher {
    constructors(component, getter, cb, options){
         this.cb = cb 
        this.getter = getter;
        this.id = UUID() 
        this.sync = options.sync; 
        this.vm = component; 
        if(options){
            this.computed = options.computed //由於是對計算屬性特殊處理,那肯定要給個識別符號以便判斷
        }
        this.dirty = this.computed // for computed watchers
        if(this.computed){
            // 對於計算屬性computed而言,我們需要關心preValue嗎?   *********************
            this.value = undefined
            // 如果是計算屬性,就要收集依賴
            //同時根據按需載入的原則,這邊不會手機依賴,主動執行回撥函式。
            this.dep = new Dep() 
        }else{
            this.value = this.get(); //非計算屬性是通過呼叫getter方法收集依賴。
        }
    }
    
    update(){
        //計算屬性在update時候需要更新依賴
        if(this.computed){
            //有些我們並不急著知道最新結果,因此可以先把dom渲染好再計算這一部分。
            if (this.dep.subs.length === 0) {
                //簡單做個標記,表示這個資料“髒”了,需要被更新
                this.dirty = true
              } else {
              //對於計算屬性而言,被watch和在dom渲染中使用到的肯定是要立刻更新。
                this.getAndInvoke(() => {
                  this.dep.notify()
                })
              }
            }
        }
        if(this.sync){ 
            this.run();
        }else{
            queueWatcher(this)
        }
    }
    
    run: function(){
        //獲取新的值
         this.getAndInvoke(this.cb)
    }
    
    // 新增depend方法,收集計算屬性的依賴
    depend () {
        if (this.dep && Dep.target) {
          this.dep.depend()
        }
      }
}

//這裡不再直接呼叫callback方法,在呼叫之前先將新舊兩個值進行比較,如果兩者不一樣,再呼叫callback進行更新
getAndInvoke (cb) {
    //拿到新值
    const value = this.get()
    if (value !== this.value || //基本型別的值直接比較
      // 物件沒辦法直接比較,因此都進行計算
      isObject(value)) {
      // set new value
      const oldValue = this.value
      this.value = value
      this.dirty = false
      cb.call(this.vm, value, oldValue)
    }
  }
  
  //不要忘了還要返回當前computed的最新的值
  //由於可能不是立即更新的,因此根據dirty再判斷一下,如果資料髒了,呼叫get再獲取一下
  evaluate () {
    if (this.dirty) {
      this.value = this.get()
      this.dirty = false
    }
    return this.value
  }

在繫結依賴之前(computed的get被觸發一次),computed用到的data資料改變是不會觸發computed的重新計算的。

路徑解析

對於render和computed想要收集依賴,我們只需要執行一遍回撥函式就行,但是對於$watch方法,我們並不關心他的回撥是什麼,而更關心我們需要監聽哪個值。這裡的需求多種多樣,比如單個值監聽,監聽物件的某個屬性(.),比如多個值混合監聽(&&, ||)等。這就需要對監聽的路徑進行解析。

 constructors(component, expOrFn, cb, options){
         this.cb = cb 
        this.id = UUID() 
        this.sync = options.sync; 
        this.vm = component; 
        if(options){
            this.computed = options.computed
        }
        if(typeof expOrFn === "function"){
            // render or computed
            this.getter = expOrFn 
        }else{
            this.getter = this.parsePath();
        }
        if(this.computed){
            this.value = undefined
            this.dep = new Dep() 
        }else{
            this.value = this.get(); //非計算屬性是通過呼叫getter方法收集依賴。
        }
    }
    
    parsePath: function(){
        // 簡單的路徑解析,如果都是字串則不需要解析
         if (/[^\w.$]/.test(path)) {
            return
          }
        // 這邊只是簡單解析了子屬性的情況
          const segments = path.split('.')
          return function (obj) {
            for (let i = 0; i < segments.length; i++) {
              if (!obj) return
              obj = obj[segments[i]]
            }
            return obj
          }
    }

總結

我們在watcher乞丐版的基礎上,根據實際需求推匯出了更健全的watcher版本。下面是完整程式碼

class Watcher {
    constructors(component, getter, cb, options){
         this.cb = cb 
        this.getter = getter;
        this.id = UUID() 
        this.sync = options.sync; 
        this.vm = component; 
        if(options){
            this.computed = options.computed //由於是對計算屬性特殊處理,那肯定要給個識別符號以便判斷
        }
        if(typeof expOrFn === "function"){
            // render or computed
            this.getter = expOrFn 
        }else{
            this.getter = this.parsePath();
        }
        this.dirty = this.computed // for computed watchers
        if(this.computed){
            // 對於計算屬性computed而言,我們需要關心preValue嗎?   *********************
            this.value = undefined
            // 如果是計算屬性,就要收集依賴
            //同時根據按需載入的原則,這邊不會手機依賴,主動執行回撥函式。
            this.dep = new Dep() 
        }else{
            this.value = this.get(); //非計算屬性是通過呼叫getter方法收集依賴。
        }
    }
    
    update(){
        //計算屬性在update時候需要更新依賴
        if(this.computed){
            //有些我們並不急著知道最新結果,因此可以先把dom渲染好再計算這一部分。
            if (this.dep.subs.length === 0) {
                //簡單做個標記,表示這個資料“髒”了,需要被更新
                this.dirty = true
              } else {
              //對於計算屬性而言,被watch和在dom渲染中使用到的肯定是要立刻更新。
                this.getAndInvoke(() => {
                  this.dep.notify()
                })
              }
            }
        }
        if(this.sync){ 
            this.run();
        }else{
            queueWatcher(this)
        }
    }
    
    run: function(){
        //獲取新的值
         this.getAndInvoke(this.cb)
    }
    
    // 新增depend方法,收集計算屬性的依賴
    depend () {
        if (this.dep && Dep.target) {
          this.dep.depend()
        }
      }
}

//這裡不再直接呼叫callback方法,在呼叫之前先將新舊兩個值進行比較,如果兩者不一樣,再呼叫callback進行更新
getAndInvoke (cb) {
    //拿到新值
    const value = this.get()
    if (value !== this.value || //基本型別的值直接比較
      // 物件沒辦法直接比較,因此都進行計算
      isObject(value)) {
      // set new value
      const oldValue = this.value
      this.value = value
      this.dirty = false
      cb.call(this.vm, value, oldValue)
    }
  }
  
  //不要忘了還要返回當前computed的最新的值
  //由於可能不是立即更新的,因此根據dirty再判斷一下,如果資料髒了,呼叫get再獲取一下
  evaluate () {
    if (this.dirty) {
      this.value = this.get()
      this.dirty = false
    }
    return this.value
  }

可以看到,基本vue的實現一樣了。VUE中有些程式碼,比如teardown方法,清除自身的訂閱資訊我並沒有加進來,因為沒有想到合適的應用場景。這種逆推的過程我覺得比直接讀原始碼更有意思。直接讀原始碼並不難,但很容易造成似是而非的情況。邏輯很容易理解,但是真正為什麼這麼寫,一些細節原因很容易漏掉。但是不管什麼框架都是為了解決實際問題的,從需求出發,才能更好的學習一個框架,並在自己的工作中加以借鑑。借VUE的生命週期圖進行展示

clipboard.png

區域性圖:

clipboard.png

從區域性圖裡可以看出,vue收集依賴的入口只有兩個,一個是在載入之前處理$wacth方法,一個是render生成虛擬dom。而對於computed,只有在使用到時才會收集依賴。如果我們在watch和render中都沒有使用,而是在methods中使用,那麼載入的過程中是不會計算這個computed的,只有在呼叫methods中方法時才會計算。