JS 中的資料代理
所謂資料代理(也叫資料劫持),指的是在訪問或者修改物件的某個屬性時,通過一段程式碼攔截這個行為,進行額外的操作或者修改返回結果。比較典型的是Object.defineProperty()
和 ES2015 中新增的Proxy
物件。另外還有已經被廢棄的Object.observe()
,廢棄的原因正是Proxy
的出現,因此這裡我們就不繼續討論這個已經被瀏覽器刪除的方法了。
資料劫持最著名的應用當屬雙向繫結,這也是一個已經被討論爛了的面試必考題。例如 Vue 2.x 使用的是Object.defineProperty()
(Vue 在 3.x 版本之後改用Proxy
進行實現)。此外 immer.js 為了保證資料的 immutable 屬性,使用了Proxy
來阻斷常規的修改操作,也是資料劫持的一種應用。
我們來分別看看這兩種方法的優劣。
Object.defineProperty
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
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) // 能夠打印出很多內容 // get push(尋找 proxy.push 方法) // get length(獲取當前的 length) // set 3 4(設定 proxy[3] = 4) // set length 4 (設定 proxy.length = 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 複製程式碼
當然實際有啥用處我暫時還沒想到,且考慮到程式碼的可讀性,多數可能只存在於炫技或者面試題中吧我猜……
隱藏私有變數
既然能夠操縱 get,自然就可以實現某些屬性可以訪問,而某些不可以,這就是共有和私有屬性的概念。實現起來也很簡單:
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
而已。