一文聊聊Vue響應式實現原理

2022-09-15 22:00:22

前端(vue)入門到精通課程:進入學習

vue 是一個易上手的框架,許多便捷功能都在其內部做了整合,其中最有區別性的功能就是其潛藏於底層的響應式系統。元件狀態都是響應式的 JavaScript 物件。當更改它們時,檢視會隨即更新,這讓狀態管理更加簡單直觀。那麼,Vue 響應性系統是如何實現的呢?本文也是在閱讀了 Vue 原始碼後的理解以及模仿實現,所以跟隨作者的思路,我們一起由淺入深的探索一下vue吧!【相關推薦:】

本文 Vue 原始碼版本:2.6.14,為了便於理解,程式碼都最簡化。

Vue 是如何實現的資料響應式

當你把一個普通的 JavaScript 物件傳入 Vue 範例作為 data 選項,Vue 將遍歷此物件所有的 property,並使用 Object.defineProperty 把這些 property 全部轉為 getter/setter,然後圍繞 getter/setter來執行。

一句話概括Vue 的響應式系統就是: 觀察者模式 + Object.defineProperty 攔截getter/setter

MDN ObjdefineProperty

觀察者模式

什麼是Object.defineProperty ?

Object.defineProperty() 方法會直接在一個物件上定義一個新屬性,或者修改一個物件的現有屬性,並返回此物件。

簡單的說,就是通過此方式定義的 property,執行 obj.xxx 時會觸發 get,執行 obj.xxx = xxx會觸發 set,這便是響應式的關鍵。

Object.defineProperty 是 ES5 中一個無法 shim(無法通過polyfill實現) 的特性,這也就是 Vue 不支援 IE8 以及更低版本瀏覽器的原因。

響應式系統基礎實現

現在,我們來基於Object.defineProperty實現一個簡易的響應式更新系統作為「開胃菜」

let data = {};
// 使用一箇中間變數儲存 value
let value = "hello";
// 用一個集合儲存資料的響應更新函數
let fnSet = new Set();
// 在 data 上定義 text 屬性
Object.defineProperty(data, "text", {
  enumerable: true,
  configurable: true,
  set(newValue) {
    value = newValue;
    // 資料變化
    fnSet.forEach((fn) => fn());
  },
  get() {
    fnSet.add(fn);
    return value;
  },
});

// 將 data.text 渲染到頁面上
function fn() {
  document.body.innerText = data.text;
}
// 執行函數,觸發讀取 get
fn();

// 一秒後改變資料,觸發 set 更新
setTimeout(() => {
  data.text = "world";
}, 1000);

接下來我們在瀏覽器中執行這段程式碼,會得到期望的效果

通過上面的程式碼,我想你對響應式系統的工作原理已經有了一定的理解。為了讓這個「開胃菜」易於消化,這個簡易的響應式系統還有很多缺點,例如:資料和響應更新函數是通過寫死強耦合在一起的、只實現了一對一的情況、不夠模組化等等……所以接下來,我們來一一完善。

設計一個完善的響應式系統

要設計一個完善的響應式系統,我們需要先了解一個前置知識,什麼是觀察者模式?

什麼是觀察者模式?

它就是一種行為設計模式, 允許你定義一種訂閱機制, 可在物件事件發生時通知多個 「觀察」 該物件的其他物件。

擁有一些值得關注狀態的物件通常被稱為目標,由於它自身狀態發生改變時需要通知其他物件,我們也將其成為釋出者(pub­lish­er) 。所有希望關注釋出者狀態變化的其他物件被稱為訂閱者(sub­scribers) 。此外,釋出者與所有訂閱者直接僅通過介面互動,都必須具有同樣的介面

1.png

舉個例子?:

你(即應用中的訂閱者)對某個書店的週刊感興趣,你給老闆(即應用中的釋出者)留了電話,讓老闆一有新週刊就給你打電話,其他對這本週刊感興趣的人,也給老闆留了電話。新週刊到貨時,老闆就挨個打電話,通知讀者來取。

假如某個讀者一不小心留的是 qq 號,不是電話號碼,老版打電話時就會打不通,該讀者就收不到通知了。這就是我們上面說的,必須具有相同的介面。

瞭解了觀察者模式後,我們就開始著手設計響應式系統。

抽象觀察者(訂閱者)類Watcher

在上面的例子中,資料和響應更新函數是通過寫死強耦合在一起的。而實際開發過程中,更新函數不一定叫fn,更有可能是一個匿名函數。所以我們需要抽像一個觀察者(訂閱者)類Watcher來儲存並執行更新函數,同時向外提供一個update更新介面。

// Watcher 觀察者可能有 n 個,我們為了區分它們,保證唯一性,增加一個 uid
let watcherId = 0;
// 當前活躍的 Watcher
let activeWatcher = null;

class Watcher {
  constructor(cb) {
    this.uid = watcherId++;
    // 更新函數
    this.cb = cb;
    // 儲存 watcher 訂閱的所有資料
    this.deps = [];
    // 初始化時執行更新函數
    this.get();
  }
  // 求值函數
  get() {
    // 呼叫更新函數時,將 activeWatcher 指向當前 watcher
    activeWatcher = this;
    this.cb();
    // 呼叫完重置
    activeWatcher = null;
  }
  // 資料更新時,呼叫該函數重新求值
  update() {
    this.get();
  }
}

抽象被觀察者(釋出者)類Dep

我們再想一想,實際開發過程中,data 中肯定不止一個資料,而且每個資料,都有不同的訂閱者,所以說我們還需要抽象一個被觀察者(釋出者)Dep類來儲存資料對應的觀察者(Watcher),以及資料變化時通知觀察者更新。

class Dep {
  constructor() {
    // 儲存所有該依賴項的訂閱者
    this.subs = [];
  }
  addSubs() {
    // 將 activeWatcher 作為訂閱者,放到 subs 中
    // 防止重複訂閱
    if(this.subs.indexOf(activeWatcher) === -1){
      this.subs.push(activeWatcher);
    }
  }
  notify() {
    // 先儲存舊的依賴,便於下面遍歷通知更新
    const deps = this.subs.slice()
    // 每次更新前,清除上一次收集的依賴,下次執行時,重新收集
    this.subs.length = 0;
    deps.forEach((watcher) => {
      watcher.update();
    });
  }
}

抽象 Observer

現在,WatcherDep只是兩個獨立的模組,我們怎麼把它們關聯起來呢?

答案就是Object.defineProperty,在資料被讀取,觸發get方法,Dep 將當前觸發 get 的 Watcher 當做訂閱者放到 subs中,Watcher 就與 Dep建立關係;在資料被修改,觸發set方法,Dep就遍歷 subs 中的訂閱者,通知Watcher更新。

下面我們就來完善將資料轉換為getter/setter的處理。

上面基礎的響應式系統實現中,我們只定義了一個響應式資料,當 data 中有其他property時我們就處理不了了。所以,我們需要抽象一個 Observer類來完成對 data資料的遍歷,並呼叫defineReactive轉換為 getter/setter,最終完成響應式繫結。

為了簡化,我們只處理data中單層資料。

class Observer {
  constructor(value) {
    this.value = value;
    this.walk(value);
  }
  // 遍歷 keys,轉換為 getter/setter
  walk(obj) {
    const keys = Object.keys(obj);
    for (let i = 0; i < keys.length; i++) {
      const key = keys[i]
      defineReactive(obj, key, obj[key]);
    }
  }
}

這裡我們通過引數 value 的閉包,來儲存最新的資料,避免新增其他變數

function defineReactive(target, key, value) {
  // 每一個資料都是一個被觀察者
  const dep = new Dep();
  Object.defineProperty(target, key, {
    enumerable: true,
    configurable: true,
    // 執行 data.xxx 時 get 觸發,進行依賴收集,watcher 訂閱 dep
    get() {
      if (activeWatcher) {
        // 訂閱
        dep.addSubs(activeWatcher);
      }
      return value;
    },
    // 執行 data.xxx = xxx 時 set 觸發,遍歷訂閱了該 dep 的 watchers,
    // 呼叫 watcher.updata 更新
    set(newValue) {
      // 如果前後值相等,沒必要跟新
      if (value === newVal) {
        return;
      }
      value = newValue;
      // 派發更新
      dep.notify();
    },
  });
}

至此,響應式系統就大功告成了!!

測試

我們通過下面程式碼測試一下:

let data = {
  name: "張三",
  age: 18,
  address: "成都",
};
// 模擬 render
const render1 = () => {
  console.warn("-------------watcher1--------------");
  console.log("The name value is", data.name);
  console.log("The age value is", data.age);
  console.log("The address value is", data.address);
};
const render2 = () => {
  console.warn("-------------watcher2--------------");
  console.log("The name value is", data.name);
  console.log("The age value is", data.age);
};
// 先將 data 轉換成響應式
new Observer(data);
// 範例觀察者
new Watcher(render1);
new Watcher(render2);

在瀏覽器中執行這段程式碼,和我們期望的一樣,兩個render都執行了,並且在控制檯上列印了結果。

2.png

我們嘗試修改 data.name = '李四 23333333',測試兩個 render 都會重新執行:

3.png

我們只修改 data.address = '北京',測試一下是否只有render 1回撥都會重新執行:

4.png

都完美通過測試!!?

總結

5.png

Vue響應式原理的核心就是ObserverDepWatcher,三者共同構成 MVVM 中的 VM

Observer中進行資料響應式處理以及最終的WatcherDep關係繫結,在資料被讀的時候,觸發get方法,將 Watcher收集到 Dep中作為依賴;在資料被修改的時候,觸發set方法,Dep就遍歷 subs 中的訂閱者,通知Watcher更新。

本篇文章屬於入門篇,並非原始碼實現,在原始碼的基礎上簡化了很多內容,能夠便於理解ObserverDepWatcher三者的作用和關係。

本文的原始碼,以及作者學習 Vue 原始碼完整的逐行註釋原始碼地址:github.com/yue1123/vue…

(學習視訊分享:、)

以上就是一文聊聊Vue響應式實現原理的詳細內容,更多請關注TW511.COM其它相關文章!