面試官系列(4): 實現雙向繫結Proxy比defineproperty優劣如何?
往期
前言
雙向繫結其實已經是一個老掉牙的問題了,只要涉及到MVVM框架就不得不談的知識點,但它畢竟是Vue的三要素之一.
Vue三要素
- 響應式: 例如如何監聽資料變化,其中的實現方法就是我們提到的雙向繫結
- 模板引擎: 如何解析模板
- 渲染: Vue如何將監聽到的資料變化和解析後的HTML進行渲染
可以實現雙向繫結的方法有很多,KnockoutJS基於觀察者模式的雙向繫結,Ember基於資料模型的雙向繫結,Angular基於髒檢查的雙向繫結,本篇文章我們重點講面試中常見的基於資料劫持的雙向繫結。
常見的基於資料劫持的雙向繫結有兩種實現,一個是目前Vue在用的Object.defineProperty
,另一個是ES2015中新增的Proxy
,而Vue的作者宣稱將在Vue3.0版本後加入Proxy
從而代替Object.defineProperty
,通過本文你也可以知道為什麼Vue未來會選擇Proxy
。
嚴格來講Proxy應該被稱為『代理』而非『劫持』,不過由於作用有很多相似之處,我們在下文中就不再做區分,統一叫『劫持』。
我們可以通過下圖清楚看到以上兩種方法在雙向繫結體系中的關係.
基於資料劫持的當然還有已經涼透的
Object.observe
方法,已被廢棄。
提前宣告: 我們沒有對傳入的引數進行及時判斷而規避錯誤,僅僅對核心方法進行了實現.
文章目錄
- 基於資料劫持實現的雙向繫結的特點
- 基於Object.defineProperty雙向繫結的特點
- 基於Proxy雙向繫結的特點
1.基於資料劫持實現的雙向繫結的特點
1.1 什麼是資料劫持
資料劫持比較好理解,通常我們利用Object.defineProperty
劫持物件的訪問器,在屬性值發生變化時我們可以獲取變化,從而進行進一步操作。
// 這是將要被劫持的物件
const data = {
name: '',
};
function say(name) {
if (name === '古天樂') {
console.log('給大家推薦一款超好玩的遊戲');
} else if (name === '渣渣輝') {
console.log('戲我演過很多,可遊戲我只玩貪玩懶月');
} else {
console.log('來做我的兄弟');
}
}
// 遍歷物件,對其屬性值進行劫持
Object.keys(data).forEach(function(key) {
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: function() {
console.log('get');
},
set: function(newVal) {
// 當屬性值發生變化時我們可以進行額外操作
console.log(`大家好,我係${newVal}`);
say(newVal);
},
});
});
data.name = '渣渣輝';
//大家好,我係渣渣輝
//戲我演過很多,可遊戲我只玩貪玩懶月
複製程式碼
1.2 資料劫持的優勢
目前業界分為兩個大的流派,一個是以React為首的單向資料繫結,另一個是以Angular、Vue為主的雙向資料繫結。
其實三大框架都是既可以雙向繫結也可以單向繫結,比如React可以手動繫結onChange和value實現雙向繫結,也可以呼叫一些雙向繫結庫,Vue也加入了props這種單向流的api,不過都並非主流賣點。
單向或者雙向的優劣不在我們的討論範圍,我們需要討論一下對比其他雙向繫結的實現方法,資料劫持的優勢所在。
- 無需顯示呼叫: 例如Vue運用資料劫持+釋出訂閱,直接可以通知變化並驅動檢視,上面的例子也是比較簡單的實現
data.name = '渣渣輝'
後直接觸發變更,而比如Angular的髒檢測則需要顯示呼叫markForCheck
(可以用zone.js避免顯示呼叫,不展開),react需要顯示呼叫setState
。 - 可精確得知變化資料:還是上面的小例子,我們劫持了屬性的setter,當屬性值改變,我們可以精確獲知變化的內容
newVal
,因此在這部分不需要額外的diff操作,否則我們只知道資料發生了變化而不知道具體哪些資料變化了,這個時候需要大量diff來找出變化值,這是額外效能損耗。
1.3 基於資料劫持雙向繫結的實現思路
資料劫持是雙向繫結各種方案中比較流行的一種,最著名的實現就是Vue。
基於資料劫持的雙向繫結離不開Proxy
與Object.defineProperty
等方法對物件/物件屬性的"劫持",我們要實現一個完整的雙向繫結需要以下幾個要點。
- 利用
Proxy
或Object.defineProperty
生成的Observer針對物件/物件的屬性進行"劫持",在屬性發生變化後通知訂閱者 - 解析器Compile解析模板中的
Directive
(指令),收集指令所依賴的方法和資料,等待資料變化然後進行渲染 - Watcher屬於Observer和Compile橋樑,它將接收到的Observer產生的資料變化,並根據Compile提供的指令進行檢視渲染,使得資料變化促使檢視變化
我們看到,雖然Vue運用了資料劫持,但是依然離不開釋出訂閱的模式,之所以在系列2做了Event Bus的實現,就是因為我們不管在學習一些框架的原理還是一些流行庫(例如Redux、Vuex),基本上都離不開釋出訂閱模式,而Event模組則是此模式的經典實現,所以如果不熟悉釋出訂閱模式,建議讀一下系列2的文章。
2.基於Object.defineProperty雙向繫結的特點
關於Object.defineProperty
的文章在網路上已經汗牛充棟,我們不想花過多時間在Object.defineProperty
上面,本節我們主要講解Object.defineProperty
的特點,方便接下來與Proxy
進行對比。
對
Object.defineProperty
還不瞭解的請閱讀文件
兩年前就有人寫過基於Object.defineProperty
實現的文章,想深入理解Object.defineProperty
實現的推薦閱讀,本文也做了相關參考。
上面我們推薦的文章為比較完整的實現(400行程式碼),我們在本節只提供一個極簡版(20行)和一個簡化版(150行)的實現,讀者可以循序漸進地閱讀。
2.1 極簡版的雙向繫結
我們都知道,Object.defineProperty
的作用就是劫持一個物件的屬性,通常我們對屬性的getter
和setter
方法進行劫持,在物件的屬性發生變化時進行特定的操作。
我們就對物件obj
的text
屬性進行劫持,在獲取此屬性的值時列印'get val'
,在更改屬性值的時候對DOM進行操作,這就是一個極簡的雙向繫結。
const obj = {};
Object.defineProperty(obj, 'text', {
get: function() {
console.log('get val'); 
},
set: function(newVal) {
console.log('set val:' + newVal);
document.getElementById('input').value = newVal;
document.getElementById('span').innerHTML = newVal;
}
});
const input = document.getElementById('input');
input.addEventListener('keyup', function(e){
obj.text = e.target.value;
})
複製程式碼
線上示例 極簡版雙向繫結 by Iwobi (@xiaomuzhu) on CodePen.
2.2 升級改造
我們很快會發現,這個所謂的雙向繫結貌似並沒有什麼亂用。。。
原因如下:
- 我們只監聽了一個屬性,一個物件不可能只有一個屬性,我們需要對物件每個屬性進行監聽。
- 違反開放封閉原則,我們如果瞭解開放封閉原則的話,上述程式碼是明顯違反此原則,我們每次修改都需要進入方法內部,這是需要堅決杜絕的。
- 程式碼耦合嚴重,我們的資料、方法和DOM都是耦合在一起的,就是傳說中的麵條程式碼。
那麼如何解決上述問題?
Vue的操作就是加入了釋出訂閱模式,結合Object.defineProperty
的劫持能力,實現了可用性很高的雙向繫結。
首先,我們以釋出訂閱的角度看我們第一部分寫的那一坨程式碼,會發現它的監聽、釋出和訂閱都是寫在一起的,我們首先要做的就是解耦。
我們先實現一個訂閱釋出中心,即訊息管理員(Dep),它負責儲存訂閱者和訊息的分發,不管是訂閱者還是釋出者都需要依賴於它。
let uid = 0;
// 用於儲存訂閱者併發布訊息
class Dep {
constructor() {
// 設定id,用於區分新Watcher和只改變屬性值後新產生的Watcher
this.id = uid++;
// 儲存訂閱者的陣列
this.subs = [];
}
// 觸發target上的Watcher中的addDep方法,引數為dep的例項本身
depend() {
Dep.target.addDep(this);
}
// 新增訂閱者
addSub(sub) {
this.subs.push(sub);
}
notify() {
// 通知所有的訂閱者(Watcher),觸發訂閱者的相應邏輯處理
this.subs.forEach(sub => sub.update());
}
}
// 為Dep類設定一個靜態屬性,預設為null,工作時指向當前的Watcher
Dep.target = null;
複製程式碼
現在我們需要實現監聽者(Observer),用於監聽屬性值的變化。
// 監聽者,監聽物件屬性值的變化
class Observer {
constructor(value) {
this.value = value;
this.walk(value);
}
// 遍歷屬性值並監聽
walk(value) {
Object.keys(value).forEach(key => this.convert(key, value[key]));
}
// 執行監聽的具體方法
convert(key, val) {
defineReactive(this.value, key, val);
}
}
function defineReactive(obj, key, val) {
const dep = new Dep();
// 給當前屬性的值新增監聽
let chlidOb = observe(val);
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get: () => {
// 如果Dep類存在target屬性,將其新增到dep例項的subs陣列中
// target指向一個Watcher例項,每個Watcher都是一個訂閱者
// Watcher例項在例項化過程中,會讀取data中的某個屬性,從而觸發當前get方法
if (Dep.target) {
dep.depend();
}
return val;
},
set: newVal => {
if (val === newVal) return;
val = newVal;
// 對新值進行監聽
chlidOb = observe(newVal);
// 通知所有訂閱者,數值被改變了
dep.notify();
},
});
}
function observe(value) {
// 當值不存在,或者不是複雜資料型別時,不再需要繼續深入監聽
if (!value || typeof value !== 'object') {
return;
}
return new Observer(value);
}
複製程式碼
那麼接下來就簡單了,我們需要實現一個訂閱者(Watcher)。
class Watcher {
constructor(vm, expOrFn, cb) {
this.depIds = {}; // hash儲存訂閱者的id,避免重複的訂閱者
this.vm = vm; // 被訂閱的資料一定來自於當前Vue例項
this.cb = cb; // 當資料更新時想要做的事情
this.expOrFn = expOrFn; // 被訂閱的資料
this.val = this.get(); // 維護更新之前的資料
}
// 對外暴露的介面,用於在訂閱的資料被更新時,由訂閱者管理員(Dep)呼叫
update() {
this.run();
}
addDep(dep) {
// 如果在depIds的hash中沒有當前的id,可以判斷是新Watcher,因此可以新增到dep的陣列中儲存
// 此判斷是避免同id的Watcher被多次儲存
if (!this.depIds.hasOwnProperty(dep.id)) {
dep.addSub(this);
this.depIds[dep.id] = dep;
}
}
run() {
const val = this.get();
console.log(val);
if (val !== this.val) {
this.val = val;
this.cb.call(this.vm, val);
}
}
get() {
// 當前訂閱者(Watcher)讀取被訂閱資料的最新更新後的值時,通知訂閱者管理員收集當前訂閱者
Dep.target = this;
const val = this.vm._data[this.expOrFn];
// 置空,用於下一個Watcher使用
Dep.target = null;
return val;
}
}
複製程式碼
那麼我們最後完成Vue,將上述方法掛載在Vue上。
class Vue {
constructor(options = {}) {
// 簡化了$options的處理
this.$options = options;
// 簡化了對data的處理
let data = (this._data = this.$options.data);
// 將所有data最外層屬性代理到Vue例項上
Object.keys(data).forEach(key => this._proxy(key));
// 監聽資料
observe(data);
}
// 對外暴露呼叫訂閱者的介面,內部主要在指令中使用訂閱者
$watch(expOrFn, cb) {
new Watcher(this, expOrFn, cb);
}
_proxy(key) {
Object.defineProperty(this, key, {
configurable: true,
enumerable: true,
get: () => this._data[key],
set: val => {
this._data[key] = val;
},
});
}
}
複製程式碼
看下效果:
線上示例 雙向繫結實現---無漏洞版 by Iwobi (@xiaomuzhu) on CodePen.
至此,一個簡單的雙向繫結算是被我們實現了。
2.3 Object.defineProperty的缺陷
其實我們升級版的雙向繫結依然存在漏洞,比如我們將屬性值改為陣列。
let demo = new Vue({
data: {
list: [1],
},
});
const list = document.getElementById('list');
const btn = document.getElementById('btn');
btn.addEventListener('click', function() {
demo.list.push(1);
});
const render = arr => {
const fragment = document.createDocumentFragment();
for (let i = 0; i < arr.length; i++) {
const li = document.createElement('li');
li.textContent = arr[i];
fragment.appendChild(li);
}
list.appendChild(fragment);
};
// 監聽陣列,每次陣列變化則觸發渲染函式,然而...無法監聽
demo.$watch('list', list => render(list));
setTimeout(
function() {
alert(demo.list);
},
5000,
);
複製程式碼
線上示例 雙向繫結-陣列漏洞 by Iwobi (@xiaomuzhu) on CodePen.
是的,Object.defineProperty
的第一個缺陷,無法監聽陣列變化。
然而Vue的文件提到了Vue是可以檢測到陣列變化的,但是隻有以下八種方法,vm.items[indexOfItem] = newValue
這種是無法檢測的。
push()
pop()
shift()
unshift()
splice()
sort()
reverse()
複製程式碼
其實作者在這裡用了一些奇技淫巧,把無法監聽陣列的情況hack掉了,以下是方法示例。
const aryMethods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'];
const arrayAugmentations = [];
aryMethods.forEach((method)=> {
// 這裡是原生Array的原型方法
let original = Array.prototype[method];
// 將push, pop等封裝好的方法定義在物件arrayAugmentations的屬性上
// 注意:是屬性而非原型屬性
arrayAugmentations[method] = function () {
console.log('我被改變啦!');
// 呼叫對應的原生方法並返回結果
return original.apply(this, arguments);
};
});
let list = ['a', 'b', 'c'];
// 將我們要監聽的陣列的原型指標指向上面定義的空陣列物件
// 別忘了這個空陣列的屬性上定義了我們封裝好的push等方法
list.__proto__ = arrayAugmentations;
list.push('d'); // 我被改變啦! 4
// 這裡的list2沒有被重新定義原型指標,所以就正常輸出
let list2 = ['a', 'b', 'c'];
list2.push('d'); // 4
複製程式碼
由於只針對了八種方法進行了hack,所以其他陣列的屬性也是檢測不到的,其中的坑很多,可以閱讀上面提到的文件。
我們應該注意到在上文中的實現裡,我們多次用遍歷方法遍歷物件的屬性,這就引出了Object.defineProperty
的第二個缺陷,只能劫持物件的屬性,因此我們需要對每個物件的每個屬性進行遍歷,如果屬性值也是物件那麼需要深度遍歷,顯然能劫持一個完整的物件是更好的選擇。
Object.keys(value).forEach(key => this.convert(key, value[key]));
複製程式碼
3.Proxy實現的雙向繫結的特點
Proxy在ES2015規範中被正式釋出,它在目標物件之前架設一層“攔截”,外界對該物件的訪問,都必須先通過這層攔截,因此提供了一種機制,可以對外界的訪問進行過濾和改寫,我們可以這樣認為,Proxy是Object.defineProperty
的全方位加強版,具體的文件可以檢視此處;
3.1 Proxy可以直接監聽物件而非屬性
我們還是以上文中用Object.defineProperty
實現的極簡版雙向繫結為例,用Proxy進行改寫。
const input = document.getElementById('input');
const p = document.getElementById('p');
const obj = {};
const newObj = new Proxy(obj, {
get: function(target, key, receiver) {
console.log(`getting ${key}!`);
return Reflect.get(target, key, receiver);
},
set: function(target, key, value, receiver) {
console.log(target, key, value, receiver);
if (key === 'text') {
input.value = value;
p.innerHTML = value;
}
return Reflect.set(target, key, value, receiver);
},
});
input.addEventListener('keyup', function(e) {
newObj.text = e.target.value;
});
複製程式碼
線上示例 Proxy版 by Iwobi (@xiaomuzhu) on CodePen.
我們可以看到,Proxy直接可以劫持整個物件,並返回一個新物件,不管是操作便利程度還是底層功能上都遠強於Object.defineProperty
。
3.2 Proxy可以直接監聽陣列的變化
當我們對陣列進行操作(push、shift、splice等)時,會觸發對應的方法名稱和length的變化,我們可以藉此進行操作,以上文中Object.defineProperty
無法生效的列表渲染為例。
const list = document.getElementById('list');
const btn = document.getElementById('btn');
// 渲染列表
const Render = {
// 初始化
init: function(arr) {
const fragment = document.createDocumentFragment();
for (let i = 0; i < arr.length; i++) {
const li = document.createElement('li');
li.textContent = arr[i];
fragment.appendChild(li);
}
list.appendChild(fragment);
},
// 我們只考慮了增加的情況,僅作為示例
change: function(val) {
const li = document.createElement('li');
li.textContent = val;
list.appendChild(li);
},
};
// 初始陣列
const arr = [1, 2, 3, 4];
// 監聽陣列
const newArr = new Proxy(arr, {
get: function(target, key, receiver) {
console.log(key);
return Reflect.get(target, key, receiver);
},
set: function(target, key, value, receiver) {
console.log(target, key, value, receiver);
if (key !== 'length') {
Render.change(value);
}
return Reflect.set(target, key, value, receiver);
},
});
// 初始化
window.onload = function() {
Render.init(arr);
}
// push數字
btn.addEventListener('click', function() {
newArr.push(6);
});
複製程式碼
線上示例 Proxy列表渲染 by Iwobi (@xiaomuzhu) on CodePen.
很顯然,Proxy不需要那麼多hack(即使hack也無法完美實現監聽)就可以無壓力監聽陣列的變化,我們都知道,標準永遠優先於hack。
3.3 Proxy的其他優勢
Proxy有多達13種攔截方法,不限於apply、ownKeys、deleteProperty、has等等是Object.defineProperty
不具備的。
Proxy返回的是一個新物件,我們可以只操作新的物件達到目的,而Object.defineProperty
只能遍歷物件屬性直接修改。
Proxy作為新標準將受到瀏覽器廠商重點持續的效能優化,也就是傳說中的新標準的效能紅利。
當然,Proxy的劣勢就是相容性問題,而且無法用polyfill磨平,因此Vue的作者才宣告需要等到下個大版本(3.0)才能用Proxy重寫。
下期預告
下期準備一篇我們主要講為什麼我們需要前端框架,或者換幾種問法,對於此專案你為什麼選擇Angular、Vue、React等框架,而不是直接JQuery或者js?不使用框架可能遇到什麼問題?使用框架的優勢在哪裡?框架解決了JQuery解決不了的什麼問題?
這個問題是電面神器,問題開放性很好,也不需要面對面摳一些細節,同時有功底有思考的同學與跟風學框架的同學差距很容易暴露出來。
我們會邊解答這個問題邊用Proxy構建一個Mini版Vue,構建Vue的過程就是我們不斷解決不使用框架的情況下遇到的各種問題的過程。