angular髒檢查原理及虛擬碼實現

否子戈發表於2019-03-02

我們經常聽到angular的髒檢查機制和資料的雙向繫結,這兩個詞似乎已經是它的代名詞了。那麼從程式設計層面,這到底是什麼鬼?

當$scope的一個屬性被改變時,介面可能會更新。那麼為什麼angular裡面,修改$scope上的一個屬性,可以引起介面的變化呢?這是angular的資料響應機制決定的。在angular裡面就是髒檢查機制。而髒檢查,和雙向繫結離不開。

這裡插句題外話,JavaScript裡面非常有意思的一種介面,當你修改(或新增)一個物件的某個屬性時,會觸發該物件裡面的setter。如果你對這塊不是很瞭解,可以先學一下Object.defineProperty,包括這兩年超級火的vuejs也是通過這個介面實現的。它是一個ES5的標準介面。

我們可以設計一種實現,當你修改或賦值$scope的某個屬性時,就觸發了$scope這個js物件的setter,我們可以自定義這個setter,在setter函式內部,呼叫某些邏輯去更新介面。同時,為了確保新塞進來的物件也可以被監聽到變化,在你賦值時,還要把賦值進來的物件也進行改造,改造為可以被監聽的物件。

雙向繫結顧名思義是兩個過程,一個是將$scope屬性值繫結到HTML結構中,當$scope屬性值發生變化的時候介面也發生變化;另一個是,當使用者在介面上進行操作,例如點選、輸入、選擇時,自動觸發$scope屬性的變化(介面也可能跟著變)。而髒檢查的作用是“在當$scope屬性值發生變化的時候促使介面發生變化”。

angular的資料響應機制

那麼,在程式碼層面,angular是怎麼做到監聽資料變動然後更新介面的呢?答案是,angular根本不監聽資料的變動,而是在恰當的時機從$rootScope開始遍歷所有$scope,檢查它們上面的屬性值是否有變化,如果有變化,就用一個變數dirty記錄為true,再次進行遍歷,如此往復,直到某一個遍歷完成時,這些$scope的屬性值都沒有變化時,結束遍歷。由於使用了一個dirty變數作為記錄,因此被稱為髒檢查機制。

這裡面有三個問題:

  1. “恰當的時機”是什麼時候?
  2. 如何做到知道屬性值是否有變化?
  3. 這個遍歷迴圈是怎麼實現的?

要解決這三個問題,我們需要深入瞭解angular的$watch, $apply, $digest。

$watch繫結要檢查的值

簡單的說,當一個作用域建立的時候,angular會去解析模板中當前作用域下的模板結構,並且自動將那些插值(如{{text}})或呼叫(如ng-click=”update”)找出來,並利用$watch建立繫結,它的回撥函式用於決定如果新值和舊值不同時(或相同時)要幹什麼事。當然,你也可以手動在指令碼里面使用$scope.$watch對某個屬性進行繫結。它的使用方法如下:

$scope.$watch(string|function, listener, objectEquality, prettyPrintExpression)複製程式碼

第一個引數是一個字串或函式,如果是函式,需要執行後得到一個字串,這個字串用於確定將繫結$scope上的哪個屬性。listener則是回撥函式,表示當這個屬性的值發生變化時,執行該函式。objectEquality是一個boolean,為true的時候,會對object進行深檢查(懂什麼叫深拷貝的話就懂深檢查)。第四個引數是如何解析第一個引數的表示式,使用比較複雜,一般不傳。

$digest遍歷遞迴

當使用$watch繫結了要檢查的屬性之後,當這個屬性發生變化,就會執行回撥函式。但是前面已經說過了,angular裡面沒有監聽這麼一說,那麼它怎麼會被回撥呢?它沒有用object的setter機制,而是髒檢查機制。髒檢查的核心,就是$digest迴圈。當使用者執行了某些操作之後,angular內部會呼叫$digest(),最終導致介面重新渲染。那麼它究竟是怎麼一回事呢?

呼叫$watch之後,對應的資訊被繫結到angular內部的一個$$watchers中,它是一個佇列(陣列),而當$digest被觸發時,angular就會去遍歷這個陣列,並且用一個dirty變數記錄$$watchers裡面記錄的那些$scope屬性是否有變化,當有變化的時候,dirty被設定為true,在$digest執行結束的時候,它會再檢查dirty,如果dirty為true,它會再呼叫自己,直到dirty為true。但是為了防止死迴圈,angular規定,當遞迴發生了10次或以上時,直接丟擲一個錯誤,並跳出迴圈。

遞迴流程如下:

  1. 判斷dirty是否為true,如果為false,則不進行$digest遞迴。(dirty預設為true)
  2. 遍歷$$watchers,取出對應的屬性值的老值和新值
  3. 根據objectEquality進行新老值的對比。
  4. 如果兩個值不同,則繼續往下執行。如果兩個值相同,則設定dirty為false。
  5. 檢查完所有的watcher之後,如果dirty還為true(這一點需要閱讀我下面的虛擬碼)
  6. 設定dirty為true
  7. 用新值代替老值,這樣,在下一輪遞迴的時候,老值就是這一輪的新值
  8. 再次呼叫$digest

當遞迴流程結束之後,$digest還要執行:

  1. 將變化後的$scope重新渲染到介面

當一個作用域建立完之後,$scope.$digest會被執行一次。dirty的預設值被設定為true,因此,如果你在controller裡面使用了$watch,並且進行了屬性賦值,往往重新整理頁面就可以看到$watch的回撥函式被執行了。但是,現在問題來了,上面說的“angular內部會呼叫$digest()”,這個內部是怎麼實現的?

$apply觸發$digest

在我們自己程式設計時,並不直接使用$digest,而是呼叫$scope.$apply(),$apply內部會觸發$digest遞迴遍歷。同時,你可以給$apply傳一個引數,是個函式,這個函式會在$digest開始之前執行。現在回到上面的問題,angular內部怎麼觸發$digest?實際上,angular裡面要求你通過ng-click, ng-modal, ng-keyup等來進行資料的雙向繫結,為什麼,因為這些angular的內部指令封裝了$apply,比如ng-click,它其實包含了document.addEventListener(`click`)和$scope.$apply()。

當使用者在模板裡面使用ng-click時,如下:

<div ng-click="update()">change</div>複製程式碼
$scope.update = function() {
  $scope.name = `tom`
}複製程式碼

實際上,當使用者點選之後,angular內部還會執行$scope.$apply(),從而觸發$digest遍歷遞迴,最終觸發介面重繪。

手動呼叫$apply

但是有些情況下,我們不可能直接使用angular內部指令,有兩種情況我們需要手動呼叫$apply,一種是呼叫angular內建的語法糖,比如$http, $timeout,另一種是我們沒有使用angular內部機制去更新了$scope,比如我們用$element.on(`click`, () => $scope.name = `lucy`)。也就是說“非同步”和“機制外”修改$scope屬性值之後,我們都要手動呼叫$apply,雖然我們在呼叫$timeout的時候,沒有手寫$apply,但實際上它內部確實呼叫了$apply:

function($timeout) {
  // 當我們通過on(`click`)的方式觸發某些更新的時候,可以這樣做
  $timeout(() => {
    $scope.name = `lily`
  })
  // 也可以這樣做
  $element.on(`click`, () => {
    $scope.name = `david`
    $scope.$apply()
  })
}複製程式碼

但是,一定要注意,在遞迴過程中,絕對不能手動呼叫$apply,比如在ng-click的函式中,比如在$watch的回撥函式中。

虛擬碼實現

通過上面的講解,你可能已經對angular裡面的髒檢查已經瞭解了,但是我們還是希望更深入,用程式碼來把事情說清楚。我這裡不去抄寫angular的原始碼,而是自己寫一段虛擬碼,這樣更有助於理解整個機制。

import { isEqual } from `lodash`

class Scope {
  constructor() {
    this.$$dirty = true
    this.$$count = 0
    this.$$watchers = []
  }
  $watch(property, listener, deepEqual) {
    let watcher = {
      property,
      listener,
      deepEqual,
    }
    this.$$watchers.push(watcher)
  }
  $digest() {
    if (this.$$count >= 10) {
      throw new Error(`$digest超過10次`)
    }

    this.$$watchers.forEach(watcher => {
      let newValue = eval(`return this.` + watcher.property)
      let oldValue = watcher.oldValue
      if (watcher.deepEqual && isEqual(newValue, oldValue)) {
        watcher.dirty = false
      } 
      else if (newValue === oldValue) {
        watcher.dirty = false
      }
      else {
        watcher.dirty = true
        eval(`this.` + watcher.property + ` = ` newValue)
        watcher.listener(newValue, oldValue) // 注意,listener是在newValue賦值給$scope之後執行的
        watcher.oldValue = newValue
      }
      // 這裡的實現和angular邏輯裡面有一點不同,angular裡面,當newValue和oldValue都為undefined時,listener會被呼叫,可能是angular裡面在$watch的時候,會自動給$scope加上原本沒有的屬性,因此認為是一次變動
    })
    
    this.$$count ++

    this.$$dirty = false
    for (let watcher of this.$$watchers) {
      if (watcher.dirty) {
        this.$$dirty = true
        break
      }
    }

    if (this.$$dirty) {
      this.$digest()
    }
    else {
       this.$patch()
       this.$$dirty = true
       this.$$count = 0
    }
  }
  $apply() {
    if (this.$$count) {
      return // 當$digest執行的過程中,不能觸發$apply
    }
    this.$$dirty = true
    this.$$count = 0
    this.$digest()
  }
  $patch() {
    // 重繪介面
  }
}複製程式碼
function ControllerRegister(controllerTemplate, controllerFunction) {
  let $scope = new Scope()
  $paser(controllerTemplate, $scope) // 解析controller的模板,把模板中的屬性全部都解析出來,並且把這些屬性賦值給$scope
  controllerFunction($scope) // 在controllerFunction內部可能又給$scope新增了一些屬性,注意,不能在執行controllerFunction的時候呼叫$scope.$apply()

  let properties = Object.keys($scope) // 找出$scope上的所有屬性
  // 要把$scope上的一些內建屬性排除掉  
  properties = properties.filter(item => item.indexOf(`$`) !== 0) // 當然,這種排除方法只能保證在使用者不使用$作為屬性開頭的時候有用

  properties.forEach(property => {
    $scope.$watch(property, () => {}, true)
  })

  $scope.$digest()
}複製程式碼

上面就是用虛擬碼實現了angular內部的機制,不能作為真實的引擎去使用,但是體現了整個髒檢查的實現思路。

文章來自我的部落格:https://www.tangshuang.net/5435.html

相關文章