使用 Proxy 實現簡單的 MVVM 模型

CarterLi發表於2019-02-16

繫結實現的歷史

繫結的基礎是 propertyChange 事件。如何得知 viewModel 成員值的改變一直是開發 MVVM 框架的首要問題。主流框架的處理有一下三大類:

  1. 另外開發一套 API。典型框架:Backbone.js

Backbone 有自己的 模型類集合類。這樣做雖然框架開發簡單執行效率也高,但開發者不得不使用這套 API 操作 viewModel,導致上手複雜、程式碼繁瑣。

  1. 髒檢查機制。典型框架:angularjs

特點是直接使用 JS 原生操作物件的語法操作 viewModel,開發者上手簡單、程式碼簡單。但髒檢查機制隨之帶來的就是效能問題。這點在我另外的一篇博文 《Angular 1 深度解析:髒資料檢查與 angular 效能優化》 有詳細講解這裡不另加贅述。

  1. 替換屬性。典型框架:vuejs
    vuejs 把開發者定義的 viewModel 物件(即 data 函式返回的物件)中所有的(除某些字首開頭的)成員替換為屬性。這樣既可以使用 JS 原生操作物件的語法,又是主動觸發 propertyChange 事件,效率也高。但這種方法也有一些限制,後文會分析。

Object.observe

Object.observe 是谷歌對於簡化雙向繫結機制的嘗試,在 Chrome 49 中引入。然而由於效能等問題,並沒有被其他各大瀏覽器及 ES 標準所接受。掙扎了一段時間後谷歌 Chrome 團隊宣佈收回 Object.observe 的提議,並在 Chrome 50 中完全刪除了 Object.observe 實現。

Proxy

Proxy(代理)是 ES2015 加入的新特性,用於對某些基本操作定義自定義行為,類似於其他語言中的面向切面程式設計。它的其中一個作用就是用於(部分)替代 Object.observe 以實現雙向繫結。

例如有一個物件

let viewModel = {};

可以構造對應的代理類實現對 viewModel 的屬性賦值操作的監聽:

viewModel = new Proxy(viewModel, {
  set(obj, prop, value) {
    if (obj[prop] !== value) {
      obj[prop] = value;
      console.log(`${prop} 屬性被改為 ${value}`);
    }
    return true;
  }
});

這時所有對 viewModel 的屬性賦值的操作都不會直接生效,而是將這個操作轉發給 Proxy 中註冊的 set 方法,其中的引數 obj 是原始物件(注意不能直接用 a,否則還會觸發代理函式,造成無限遞迴),prop 是被賦值的屬性名,value 是待賦的值。
如果有:

viewModel.test = 1;

這時就會輸出 test 屬性被改為 1

用 Proxy 實現簡單的單向繫結。

有了 Proxy 就可以得知 viewModel 中屬性的變更了,還需要更新頁面上繫結此屬性的元素。

簡單起見,我們用 this 表示 viewModel 本身,使用 this.XXX 就表示依賴 XXX 屬性。有 DOM 如下:

  <div my-bind="`str1 + str2 = ` + (this.str1 + this.str2)"></div>
  <div my-bind="`num1 - num2 = ` + (this.num1 - this.num2)"></div>

首先要獲得所有使用了單向繫結的元素:

const bindingElements = [...document.querySelectorAll(`[my-bind]`)];

獲取繫結表示式:

bindingElements.forEach(el => {
  const expression = el.getAttribute(`my-bind`);
});

由於獲得的表示式是個字串,需要構造一個函式去執行它,得到表示式的結果:

const expression = el.getAttribute(`my-bind`);
const result = new Function(`"use strict";
return ` + expression).call(viewModel);

程式碼中會動態建立一個函式,內容就是將字串解析執行後將其結果返回(類似 eval,但更安全)。將結果放到頁面上就可以了:

el.textContent = result;

與上文的 viewModel 結合起來:

const bindingElements = [...document.querySelectorAll(`[my-bind]`)];

window.viewModel = new Proxy({}, { // 設定全域性變數方便除錯
  set(obj, prop, value) {
    if (obj[prop] !== value) {
      obj[prop] = value;

      bindingElements.forEach(el => {
        const expression = el.getAttribute(`my-bind`);
        const result = new Function(`"use strict";
return ` + expression)
          .call(obj);
        el.textContent = result;
      });
    }
    return true;
  }
});

如果實際放在瀏覽器中執行的話,改變 viewModel 中屬性的值就會觸發頁面的更新。

示例中寫了迴圈會更新所有繫結元素,比較好的方式是隻更新對當前變更屬性有依賴的元素。這時就要分析繫結表示式的屬性依賴。
簡單起見可以使用正規表示式解析屬性依賴:

let match;
while (match = /this(?:.(w+))+/g.exec(expression)) {
  match[1] // 屬性依賴
}

新增事件繫結

事件繫結即繫結原生事件,在事件觸發時執行繫結表示式,表示式呼叫 viewModel 中的某個回撥函式。

click 事件為例。依然是獲取所有繫結了 click 事件的元素,並執行表示式(表示式的值被丟棄)。與單項繫結不同的是:執行表示式需要傳入事件的 event 引數。

[...document.querySelectorAll(`[my-click]`)].forEach(el => {
  const expression = el.getAttribute(`my-click`);
  const fn = new Function(`$event`, `"use strict";
` + expression);
  el.addEventListener(`click`, event => {
    fn.call(viewModel, event);
  });
});

Function 物件的建構函式,前 n-1 個引數是生成的函式物件的引數名,最後一個是函式體。程式碼中構造了包含一個 $event 引數的函式,函式體就是直接執行繫結表示式。

雙向繫結

雙向繫結就是單項繫結和事件繫結的結合體。繫結元素的 input 事件來修改 viewModel 的屬性,然後再單項繫結元素的 value 屬性修改元素的值。

這裡是一個較為完整的示例:http://sandbox.runjs.cn/show/…。完整的程式碼放在我的 GitHub 倉庫

使用 Proxy 實現雙向繫結的優缺點

相較於 vuejs 的屬性替換,Proxy 實現的繫結至少有如下三個優點:

  • 無需預先定義待繫結的屬性。

vuejs 要做屬性(getter, setter 方法)替換,首先需要知道有哪些屬性需要替換,這樣導致必須預先定義需要替換的屬性,也就是 vuejs 中的 data 方法。vuejs 中 data 方法必須定義完整所有繫結屬性,否則對應繫結不能正常工作。
Vue 不能檢測到物件屬性的新增或刪除Property or method "XXX" is not defined on the instance but referenced during render. Make sure to declare reactive data properties in the data option.
Proxy 不需要,因為它監聽的是整個物件。

  • 對陣列相性良好。

雖說陣列裡的方法可以替換(push、pop等),但是陣列下標卻不能替換為屬性,以致必須搞出一個 set 方法用於對陣列下標賦值

  • 更容易除錯的 viewModel 物件。

由於 vuejs 把物件中的所有成員全部替換成了屬性,如果想直接用 Chrome 的原生除錯工具檢視屬性值,你不得不挨個去點屬性後面的 (...):因為獲取屬性的值其實是執行了屬性的 get 方法,執行一個方法可能會產生副作用,Chrome 把這個決定權留給開發者。
Proxy 物件不需要。Proxyset 方法只是一層包裝,Proxy 物件自身維護原始物件的值,自然也可以直接拿出原始值給開發者看。檢視一個 Proxy 物件,只需要展開其內建屬性 [[Target]] 即可看到原始物件的所有成員的值。你甚至還可以看到包裝原始物件的哪些 getset 函式——如果你感興趣的話。

雖說使用 Proxy 實現雙向繫結的優點很明顯,但是缺點也很明顯:ProxyES2015 的特性,它無法被編譯為 ES5,也無法 Polyfill。IE 自然全軍覆沒;其他各大瀏覽器實現的時間也較晚:Chrome 49、Safari 10。瀏覽器相容性極大的限制了 Proxy 的使用。但是我相信,隨著時間的推移,基於 Proxy 的前端 MVVM 框架也會出現在開發者眼前。

相關文章