博客 / 詳情

返回

細説Vue2的響應式原理

什麼是響應式

在 Vue 開發中,我們修改了數據,所有用到這份數據的視圖都會更新。

響應式概括來説就是數據驅動視圖的自動更新

舉個例子,本文也將以下面這段代碼來講解與實現響應式

HTML

<div id="app">
  {{ obj.message }}
</div>

JS

let data = {
  obj: {
    message: 'Hello Vue!',
  },
}

new Vue({
  el: '#app',
  data,
})

setTimeout(() => {
  data.obj = {
    message: 'Obj have changed!',
  }
}, 1000)
setTimeout(() => {
  data.obj.message = 'Message have changed!'
}, 2000)

Vue 會將 “ Hello Vue! ” 渲染在頁面中,在一秒後修改了 data.obj,頁面也隨之更新為 “ Obj have changed! ”,隨後又過一秒 data.obj.message 被修改,頁面顯示為 “ Message have changed! ”

如何實現響應式

為了實現響應式,要解決2個問題

  1. 數據什麼時候變化了 --- 監視數據
  2. 哪些地方用到了數據 --- 解析模板

監視數據

Vue 使用了 3 個類,實現了數據的攔截、更新的訂閲與發佈

  • Observer,監視者類,監視數據的變化,在數據變化時告訴通知者,在這個類中將數據的所有屬性用 Object.defineProperty 重新定義一遍,綁定了存取器(getter/setter)
  • Dep,通知者類,通知訂閲者更新視圖,因為一個數據可能被多處使用,所以一個通知者會存儲多位訂閲者
  • Watcher,訂閲者類,用於存儲數據變化後要執行的更新函數,調用更新函數可以使用新的數據更新視圖

下面我們來詳述 Vue 是如何操作我們傳入的 data 數據的

  1. Vue 拿到了 data 這個對象,創建一個監視者,綁定到對象的 __ob__ 屬性上
  2. 創建監視者會將對象身上的所有屬性用 Object.defineProperty 重新一遍,在定義的同時,為每一個數據創建它的通知者。通知者會在閉包環境中創建,只有該數據的存取器能夠訪問到。
  3. 如果對象的值中還有對象,會遞歸上面的過程
  4. 處理完 data 後,將其所有屬性映射到 Vue 實例身上(允許vm.xxx直接訪問)

以本文舉例,監視數據完成時,創建了兩個監視者,分別監視 data 與 obj。創建了四個通知者,分別屬於數據 obj 與 message 和兩個監視者,為了方便之後的講解,我們為這些通知者編號

QQ圖片20220706112937.png

解析模板

數據監視完畢後,Vue 會解析模板

模板解析的內容較為複雜,這一過程會創建虛擬節點 vnode,匹配到 {{ }} v- 等響應式寫法,會根據當前節點的類型、響應式語法等建立該節點的更新函數 patch ,將數據與視圖綁定

本文主要是幫助理解響應式更新的邏輯,模板解析語法並不是本文的重點,所以在之後的講解與實現中使用的還是 DOM 節點

接下來繼續以本文的例子來講解這一流程

  1. Vue 會根據 el: '#app' 配置項獲取根 DOM 元素,遍歷其所有子節點
  2. 發現一個文本節點的內容是 {{ obj.message }},檢測到特定的響應式寫法,建立更新函數並提取數據表達式(字符串 'obj.message'
  3. 因為要操作的是隻是文本節點的內容,所以更新函數較為簡單(下方代碼)

    const patch = (value) => {
        node.textContent = value
    }
  4. 創建訂閲者,保存更新函數與數據表達式,並將此訂閲者存入一個全局變量中(表示進入依賴收集階段),然後執行這個更新函數
  5. 執行函數會時會根據表達式訪問數據,觸發了監視者的綁定在其身上的 getter,getter 從全局變量獲取訂閲者,存入其綁定的通知者
  6. 每個數據訪問結束時,表示本輪的依賴收集完成,清除全局變量中的訂閲者
  7. 然後針對模板中用到的每一個響應式數據,都會重複以上的過程。對於多層嵌套的數據,也是轉換成字符串一層層訪問,監視者會為一路上所有數據的通知者添加本輪的訂閲者

模板解析完成時,只創建了一個訂閲者但被添加到了四個通知者中

QQ圖片20220706114311.png

如果細想會發現 Dep2 與 Dep3 的內容完全一致,其實 Dep2 主要是給 vue 其他 api 用的

更新數據

以後修改數據就會觸發監視者的 setter,setter 就能告訴通知者,通知其內部的訂閲者執行更新函數修改視圖,實現了數據的響應式

如果修改的數據值是一個對象,會先為其創建監視者,再告訴通知者發佈訂閲,執行更新函數訪問這些數據時,所有子數據的新通知者又存儲了之前解析模板時創建的訂閲者

還是根據例子來講解更新流程

  1. 第一次修改的是 data.obj,是一個對象,原對象的監視者、Dep2、Dep4被移除
  2. 觸發 obj 的 setter,新值是一個對象,為其創建監視者,同時創建了空的 Dep2、Dep4
  3. 告訴 Dep3,通知其中的訂閲者執行更新函數
  4. 更新函數執行訪問到了 objobj.message,將此訂閲者又添加進 Dep2 和 Dep4 中
  5. 第二次修改的是data.obj.message
  6. 觸發 message 的 setter,告訴 Dep4,通知其中的訂閲者更新視圖

這就是完整的更新流程了

通知者是用集合存儲訂閲者的,所以多次訪問也只會添加一個訂閲者

代碼實現

Vue 的源碼非常複雜,本文只提取了一小部分,以下代碼只實現了響應式更新文本節點的功能

// 公開的Vue類
class Vue {
  constructor(options) {
    // 保存數據
    this._data = options.data
    // 創建監視者
    observe(this._data)
    // 將數據都映射到實例上
    this._initData()
    // 模板解析
    compile(options.el, this)
  }

  // 遍歷數據,映射到實例上
  _initData() {
    for (const key of Object.keys(this._data)) {
      Object.defineProperty(this, key, {
        get() {
          return this._data[key]
        },
        set(newVal) {
          this._data[key] = newVal
        },
      })
    }
  }
}

// 創建監視者並返回
function observe(obj) {
  // 如果不是對象,不需要創建監視者
  if (typeof obj != 'object') return null
  let ob
  if (typeof obj.__ob__ !== 'undefined') {
    ob = obj.__ob__
  } else {
    // 創建監視着,傳入對象
    ob = new Observer(obj)
  }
  return ob
}

// 監視者
class Observer {
  constructor(obj) {
    // 創建通知者
    this.dep = new Dep()
    // 將監視者添加到對象的身上
    Object.defineProperty(obj, '__ob__', {
      value: this,
    })
    // 遍歷對象數據,定義存取器
    for (let k of Object.keys(obj)) {
      defineReactive(obj, k)
    }
  }
}

// 通知者
class Dep {
  constructor() {
    // 用集合存儲自己的訂閲者
    this.subs = new Set()
  }
  // 添加訂閲者
  addSub(watcher) {
    // 存儲訂閲者
    this.subs.add(watcher)
  }
  //發佈訂閲
  notify() {
    // 依次執行更新函數
    // 淺克隆是為了避免在訂閲者中修改同一數據,無限更新
    for (const sub of [...this.subs]) {
      sub.update()
    }
  }
  // 標誌,表示是否處於依賴收集階段,值為 Wather
  static target = null
}

// 定義存取攔截器,創建閉包環境
function defineReactive(data, key) {
  // 為數據創建通知者
  const dep = new Dep()
  // 在閉包環境中用局部變量保存數據
  let val = data[key]
  // 子數據如果是對象,也創建監視者
  let childOb = observe(val)

  // 定義存取器
  Object.defineProperty(data, key, {
    // getter
    get() {
      // 如果處於依賴收集階段
      if (Dep.target != null) {
        // 添加訂閲
        dep.addSub(Dep.target)
        // 監視者的通知者也要添加訂閲
        if (childOb != null) {
          childOb.dep.addSub(Dep.target)
        }
      }
      // 從局部變量獲取值
      return val
    },
    // setter
    set(newValue) {
      if (val === newValue) {
        return
      }
      // 更新局部變量
      val = newValue
      // 新值也需要嘗試創建監視着
      childOb = observe(newValue)
      // 告訴通知者發佈訂閲
      dep.notify()
    },
  })
}

// 訂閲者
class Watcher {
  constructor(vue, expression, callback) {
    this.target = vue
    this.expression = expression
    this.callback = callback
    this.value = this.get()
  }
  update() {
    // 獲取新值,如果不相等,則執行更新函數
    const value = this.get()
    if (value !== this.value) {
      this.value = value
      this.callback.call(this.target, value)
    }
  }
  get() {
    // 進入依賴收集階段,讓全局的Dep.target設置成Watcher本身
    Dep.target = this

    // 沿着路徑一致尋找
    let val = getObjVal(this.target, this.expression)
    // 依賴收集結束
    Dep.target = null

    return val
  }
}

// 根據字符串表達式獲取值
function getObjVal(obj, exp) {
  let val = obj
  exp = exp.split('.')
  exp.forEach((k) => {
    val = val[k]
  })
  return val
}

// 編譯模板
function compile(el, vue) {
  // 獲取掛載節點
  const $el = document.querySelector(el)

  // 創建片段,存儲dom節點
  let fragment = document.createDocumentFragment()

  // 將所有dom節點都放入片段中
  let child
  while ((child = $el.firstChild)) {
    fragment.appendChild(child)
  }

  // 匹配響應式寫法
  const reg = /\{\{(.*)\}\}/
  // 遍歷子節點
  // 簡便起見,直解析文本節點
  for (const node of fragment.childNodes) {
    const text = node.textContent
    if (node.nodeType == 3 && reg.test(text)) {
      // 獲取字符串表達式
      let name = text.match(reg)[1].trim()
      // 從vue中獲取數據賦值
      node.textContent = getObjVal(vue, name)
      // 創建訂閲者,綁定表達式與更新函數
      new Watcher(vue, name, (value) => {
        node.textContent = value
      })
    }
  }

  // 上樹
  $el.appendChild(fragment)
}

總結

Vue2 實現響應式的三個類非常繞,希望讀者仔細思考,理清其中關係

最後再強調一遍三個類的功能

  • 觀察者會在每一個對象身上創建,為其所有屬性添加存取器,用於操作通知者發佈訂閲
  • 每一個數據(包括對象)都會獨有一個通知者通知者內使用一個集合存儲所有依賴這個數據的訂閲者
  • 數據在模板中的每一次使用,都會創建一個訂閲者,存儲更新函數與數據訪問表達式

結語

如果文中有不理解或不嚴謹的地方,歡迎評論提問。

如果喜歡或有所幫助,希望能點贊關注,鼓勵一下作者。

user avatar tigerandflower 頭像 ivyzhang 頭像 huishou 頭像 flymon 頭像 sunhengzhe 頭像 zhangxishuo 頭像 buxia97 頭像 gaoming13 頭像 mulander 頭像 pugongyingxiangyanghua 頭像 waweb 頭像 iymxpc3k 頭像
46 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.