數據劫持,也叫數據代理。前端
所謂數據劫持,指的是在訪問或者修改對象的某個屬性時,經過一段代碼攔截這個行爲,進行額外的操做或者修改返回結果。比較典型的是 Object.defineProperty() 和 ES2015 中新增的 Proxy 對象。另外還有已經被廢棄的 Object.observe(),廢棄的緣由正是 Proxy 的出現,所以這裏咱們就不繼續討論這個已經被瀏覽器刪除的方法了。webpack
數據劫持最著名的應用當屬雙向綁定,這也是一個已經被討論爛了的面試必考題。例如 Vue 2.x 使用的是 Object.defineProperty()(Vue 在 3.x 版本以後改用 Proxy 進行實現)。此外 immer.js 爲了保證數據的 immutable 屬性,使用了 Proxy 來阻斷常規的修改操做,也是數據劫持的一種應用。程序員
咱們來分別看看這兩種方法的優劣。web
Vue 的雙向綁定已經升級爲前端面試的必考題,原理我就再也不重複了,網上一大片。簡單來講就是利用 Object.defineProperty(),而且把內部解耦爲 Observer, Dep, 並使用 Watcher 相連。面試
Object.defineProperty() 的問題主要有三個:後端
看以下代碼:數組
let arr = [1,2,3]
let obj = {}
Object.defineProperty(obj, 'arr', {
get () {
console.log('get arr')
return arr
},
set (newVal) {
console.log('set', newVal)
arr = newVal
}
})
obj.arr.push(4) // 只會打印 get arr, 不會打印 set
obj.arr = [1,2,3,4] // 這個能正常 set
數組的如下幾個方法不會觸發 set:瀏覽器
push緩存
pop性能優化
shift
unshift
splice
sort
reverse
Vue 把這些方法定義爲變異方法 (mutation method),指的是會修改原來數組的方法。與之對應則是非變異方法 (non-mutating method),例如 filter, concat, slice 等,它們都不會修改原始數組,而會返回一個新的數組。Vue 官網有相關文檔講述這個問題。
Vue 的作法是把這些方法重寫來實現數組的劫持。一個極簡的實現以下:
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, pop 這些方法時走進咱們剛定義的方法,多了一句 console.log
list.__proto__ = arrayAugmentations;
list.push('d'); // 我被改變啦!
// 這個 list2 是個普通的數組,因此調用 push 不會走到咱們的方法裏面。
let list2 = ['a', 'b', 'c'];
list2.push('d'); // 不輸出內容
使用 Object.defineProperty() 多數要配合 Object.keys() 和遍歷,因而多了一層嵌套。如:
Object.keys(obj).forEach(key => {
Object.defineProperty(obj, key, {
// ...
})
})
所謂的嵌套對象,是指相似
let obj = {
info: {
name: 'eason'
}
}
若是是這一類嵌套對象,那就必須逐層遍歷,直到把每一個對象的每一個屬性都調用 Object.defineProperty() 爲止。 Vue 的源碼中就能找到這樣的邏輯 (叫作 walk 方法)。
Proxy 在 ES2015 規範中被正式加入,它的支持度雖然不如 Object.defineProperty(),但其實也基本支持了 (除了 IE 和 Opera Mini 等少數瀏覽器,數據來自 caniuse),因此使用起來問題也不太大。
在數據劫持這個問題上,Proxy 能夠被認爲是 Object.defineProperty() 的升級版。外界對某個對象的訪問,都必須通過這層攔截。所以它是針對 整個對象,而不是 對象的某個屬性,因此也就不須要對 keys 進行遍歷。這解決了上述 Object.defineProperty() 的第二個問題。
let obj = {
name: 'Eason',
age: 30
}
let handler = {
get (target, key, receiver) {
console.log('get', key)
return Reflect.get(target, key, receiver)
},
set (target, key, value, receiver) {
console.log('set', key, value)
return Reflect.set(target, key, value, receiver)
}
}
let proxy = new Proxy(obj, handler)
proxy.name = 'Zoe' // set name Zoe
proxy.age = 18 // set age 18
如上代碼,Proxy 是針對 obj 的。所以不管 obj 內部包含多少個 key ,均可以走進 set。(省了一個 Object.keys() 的遍歷)
另外這個 Reflect.get 和 Reflect.set 能夠理解爲類繼承裏的 super,即調用原來的方法。詳細的 Reflect 能夠查看這裏,本文不做展開。
let arr = [1,2,3]
let proxy = new Proxy(arr, {
get (target, key, receiver) {
console.log('get', key)
return Reflect.get(target, key, receiver)
},
set (target, key, value, receiver) {
console.log('set', key, value)
return Reflect.set(target, key, value, receiver)
}
})
proxy.push(4)
Proxy 不須要對數組的方法進行重載,省去了衆多 hack,減小代碼量等於減小了維護成本,並且標準的就是最好的。
本質上,Proxy 也是不支持嵌套的,這點和 Object.defineProperty() 是同樣的。所以也須要經過逐層遍從來解決。Proxy 的寫法是在 get 裏面遞歸調用 Proxy 並返回,代碼以下:
let obj = {
info: {
name: 'eason',
blogs: ['webpack', 'babel', 'cache']
}
}
let handler = {
get (target, key, receiver) {
console.log('get', key)
// 遞歸建立並返回
if (typeof target[key] === 'object' && target[key] !== null) {
return new Proxy(target[key], handler)
}
return Reflect.get(target, key, receiver)
},
set (target, key, value, receiver) {
console.log('set', key, value)
return Reflect.set(target, key, value, receiver)
}
}
let proxy = new Proxy(obj, handler)
// 如下兩句都可以進入 set
proxy.info.name = 'Zoe'
proxy.info.blogs.push('proxy')
除了上述兩點以外,Proxy 還擁有如下優點:
Proxy 的第二個參數能夠有 13 種攔截方法,這比起 Object.defineProperty() 要更加豐富
Proxy 做爲新標準受到瀏覽器廠商的重點關注和性能優化,相比之下 Object.defineProperty() 是一個已有的老方法。
這第二個優點源於它是新標準。但新標準一樣也有劣勢,那就是:
Proxy 的兼容性不如 Object.defineProperty() (caniuse 的數據代表,QQ 瀏覽器和百度瀏覽器並不支持 Proxy,這對國內移動開發來講估計沒法接受,但二者都支持 Object.defineProperty())
不能使用 polyfill 來處理兼容性
這些比較僅針對「數據劫持的實現」這個需求而言。Object.defineProperty() 除了定義 get 和 set 以外,還能實現其餘功能,所以即使不考慮兼容性的狀況下,本文並非想說一個能夠徹底淘汰另外一個。
只談技術自己而不談應用場景基本都是耍流氓。一個技術只有擁有了應用場景,才真正有價值。
如開頭所說,數據劫持多出如今框架內部,例如 Vue, immer 之類的,不過這些好像和咱們普通程序員相去甚遠。除開這些,我列舉幾個可能的應用場景,你們在平時的工做中可能還能想到更多。
其實除了閱讀 Vue 的數據綁定源碼以外,我第二次瞭解這個技術是經過一道曾經在開發者羣體中小火一陣的詭異題目:
什麼樣的 a 能夠知足 (a === 1 && a === 2 && a === 3) === true 呢?(注意是 3 個 =,也就是嚴格相等)
既然是嚴格相等,類型轉換什麼的基本不考慮了。一個天然的想法就是每次訪問 a 返回的值都不同,那麼確定會想到數據劫持。(可能還有其餘解法,但這裏只講數據劫持的方法)
let current = 0
Object.defineProperty(window, 'a', {
get () {
current++
return current
}
})
console.log(a === 1 && a === 2 && a === 3) // true
使用 Proxy 也能夠,但由於 Proxy 的語法是返回一個新的對象,所以要作到 a === 1 可能比較困難,作到 obj.a === 1 仍是 OK 的,反正原理是同樣的,也沒必要糾結太多。
Javascript 經過原型鏈實現繼承,正常狀況一個對象(或者類)只能繼承一個對象(或者類)。但經過這兩個方法均可以實現一種黑科技,容許一個對象繼承兩個對象。下面的例子使用 Proxy 實現。
let foo = {
foo () {
console.log('foo')
}
}
let bar = {
bar () {
console.log('bar')
}
}
// 正常狀態下,對象只能繼承一個對象,要麼有 foo(),要麼有 bar()
let sonOfFoo = Object.create(foo);
sonOfFoo.foo(); // foo
let sonOfBar = Object.create(bar);
sonOfBar.bar(); // bar
// 黑科技開始
let sonOfFooBar = new Proxy({}, {
get (target, key) {
return target[key] || foo[key] || bar[key];
}
})
// 咱們創造了一個對象同時繼承了兩個對象,foo() 和 bar() 同時擁有
sonOfFooBar.foo(); // foo 有foo方法,繼承自對象foo
sonOfFooBar.bar(); // bar 也有bar方法,繼承自對象bar
function getObject(rawObj, privateKeys) {
return new Proxy(rawObj, {
get (target, key, receiver) {
if (privateKeys.indexOf(key) !== -1) {
throw new ReferenceError(`${key} 是私有屬性,不能訪問。`)
}
return target[key]
}
})
}
let rawObj = {
name: 'Zoe',
age: 18,
isFemale: true
}
let obj = getObject(rawObj, ['age'])
console.log(obj.name) // Zoe
console.log(obj.age) // 報錯
若是對象的某些屬性有類型要求,只能接受特定類型的值,經過 Proxy 咱們能夠在設置時即給出錯誤,而不是在使用時再統一遞歸遍歷檢查。這樣不管在執行效率仍是在使用友好度上都更好一些。
let person = {
name: 'Eason',
age: 30
}
let handler = {
set (target, key, value, receiver) {
if (key === 'name' && typeof value !== 'string') {
throw new Error('用戶姓名必須是字符串類型')
}
if (key === 'age' && typeof value !== 'number') {
throw new Error('用戶年齡必須是數字類型')
}
return Reflect.set(target, key, value, receiver)
}
}
let personForUser = new Proxy(person, handler)
personForUser.name = 'Zoe' // OK
personForUser.age = '18' // 報錯
咱們經常會向後端發送請求,等待響應並處理響應的數據,且爲了代碼健壯性,一般會有不少判斷,如:
// 發送請求代碼省略,總之獲取到了 response 對象了。
if (!response.data) {
console.log('響應體沒有信息')
return
} else if (!response.data.message) {
console.log('後端沒有返回信息')
return
} else if (!response.data.message.from || !response.data.message.text) {
console.log('後端返回的信息不完整')
return
} else {
console.log(`你收到了來自 ${response.data.message.from} 的信息:${response.data.message.text}`)
}
代碼的實質是爲了獲取 response.data.message.from 和 response.data.message.text,但須要逐層判斷,不然 JS 就會報錯。
咱們能夠考慮用 Proxy 來改造這段代碼,讓它稍微好看些。
// 故意設置一個錯誤的 data1,即 response.data = undefined
let response = {
data1: {
message: {
from: 'Eason',
text: 'Hello'
}
}
}
/ 也能夠根據 key 的不一樣給出更友好的提示
let dealError = key => console.log('Error key', key)
let isOK = obj => !obj['HAS_ERROR']
let handler = {
get (target, key, receiver) {
// 基本類型直接返回
if (target[key] !== undefined && typeof target[key] !== 'object') {
return Reflect.get(target, key, receiver)
}
// 若是是 undefined,把訪問的的 key 傳遞到錯誤處理函數 dealError 裏面
if (!target[key]) {
if (!target['HAS_ERROR']) {
dealError(key)
}
return new Proxy({HAS_ERROR: true}, handler)
}
// 正常的話遞歸建立 Proxy
return new Proxy(target[key], handler)
}
}
let resp = new Proxy(response, handler)
if (isOK(resp.data.message.text) && isOK(resp.data.message.from)) {
console.log(`你收到了來自 ${response.data.message.from} 的信息:${response.data.message.text}`)
}
由於咱們故意設置了 response.data = undefined,所以會進入 dealError 方法,參數 key 的值爲 data。
雖然從代碼量來看比上面的 if 檢查更長,但 isOK, handler 和 new Proxy 的定義都是能夠複用的,能夠移動到一個單獨的文件,僅暴露幾個方法便可。因此實際的代碼只有 dealError 的定義和最後的一個 if 而已。
設置對象默認值 - 建立一個對象,它的某些屬性自帶默認值。
優化的枚舉類型 - 枚舉類型的 key 出錯時馬上報錯而不是靜默的返回 undefined,因代碼編寫錯誤致使的重寫、刪除等也能夠被攔截。
追蹤對象和數組的變化 - 在數組和對象的某個元素/屬性發生變化時拋出事件。這可能適用於撤銷,重作,或者直接回到某個歷史狀態。
實現單例模式 - 經過設置 construct 方法,在執行 new 操做符老是返回同一個單例,從而實現單例模式。
Cookie 的類型轉換 - document.cookie 是一個用 ; 分割的字符串。咱們能夠把它轉化爲對象,並經過 Proxy 的 set 和 deleteProperty 從新定義設置和刪除操做,用以對外暴露一個可操做的 Cookie 對象,方便使用。