angular髒檢查原理及僞代碼實現

咱們常常聽到angular的髒檢查機制和數據的雙向綁定,這兩個詞彷佛已是它的代名詞了。那麼從編程層面,這究竟是什麼鬼?html

當$scope的一個屬性被改變時,界面可能會更新。那麼爲何angular裏面,修改$scope上的一個屬性,能夠引發界面的變化呢?這是angular的數據響應機制決定的。在angular裏面就是髒檢查機制。而髒檢查,和雙向綁定離不開。vue

這裏插句題外話,JavaScript裏面很是有意思的一種接口,當你修改(或新增)一個對象的某個屬性時,會觸發該對象裏面的setter。若是你對這塊不是很瞭解,能夠先學一下Object.defineProperty,包括這兩年超級火的vuejs也是經過這個接口實現的。它是一個ES5的標準接口。編程

咱們能夠設計一種實現,當你修改或賦值$scope的某個屬性時,就觸發了$scope這個js對象的setter,咱們能夠自定義這個setter,在setter函數內部,調用某些邏輯去更新界面。同時,爲了確保新塞進來的對象也能夠被監聽到變化,在你賦值時,還要把賦值進來的對象也進行改造,改造爲能夠被監聽的對象。數組

雙向綁定顧名思義是兩個過程,一個是將$scope屬性值綁定到HTML結構中,當$scope屬性值發生變化的時候界面也發生變化;另外一個是,當用戶在界面上進行操做,例如點擊、輸入、選擇時,自動觸發$scope屬性的變化(界面也可能跟着變)。而髒檢查的做用是「在當$scope屬性值發生變化的時候促使界面發生變化」。bash

angular的數據響應機制

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

這裏面有三個問題:異步

  1. 「恰當的時機」是何時?
  2. 如何作到知道屬性值是否有變化?
  3. 這個遍歷循環是怎麼實現的?

要解決這三個問題,咱們須要深刻了解angular的$watch, $apply, $digest。函數

$watch綁定要檢查的值

簡單的說,當一個做用域建立的時候,angular會去解析模板中當前做用域下的模板結構,而且自動將那些插值(如{{text}})或調用(如ng-click="update")找出來,並利用$watch創建綁定,它的回調函數用於決定若是新值和舊值不一樣時(或相同時)要幹什麼事。固然,你也能夠手動在腳本里面使用$scope.$watch對某個屬性進行綁定。它的使用方法以下:ui

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

第一個參數是一個字符串或函數,若是是函數,須要運行後獲得一個字符串,這個字符串用於肯定將綁定$scope上的哪一個屬性。listener則是回調函數,表示當這個屬性的值發生變化時,執行該函數。objectEquality是一個boolean,爲true的時候,會對object進行深檢查(懂什麼叫深拷貝的話就懂深檢查)。第四個參數是如何解析第一個參數的表達式,使用比較複雜,通常不傳。this

$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

相關文章
相關標籤/搜索