Vue3響應式對象是如何實現的(1)
簡單的響應式實現
為瞭方便說明,先來看一個簡單的例子。
const obj = { text: 'hello vue' } function effect() { document.body.innerText = obj.text }
這段代碼中,如果obj
是一個響應式數據,會產生什麼效果呢?當obj.text
中的內容改變時,document.body.innerText
也會隨之改變,從而修改頁面上顯示的內容。因此,如果僅從這個簡單的例子出發,在修改obj
後,再次執行effect()
,就能夠實現響應式。此時,effect()
被稱為副作用函數,其副作用體現在document.body.innerText
這個非effect()
函數作用域內的值被修改瞭。
實際使用中,響應式實現要復雜的多。為瞭進一步實現響應式,讓我們基於上文的例子,再來捋一捋響應式實現需要什麼。我們需要在修改響應式數據後觸發副作用函數。仔細思考這句話,這裡其實要求瞭兩個功能:
- 當
obj
被讀取時,收集副作用函數; - 當
obj
被修改時,觸發收集的副作用函數;
問題更清晰瞭,我們隻需要攔截讀取和修改操作分別進行收集和觸發,就能夠實現響應式瞭。這裡我們可以使用Proxy
Proxy與響應式
為什麼需要Proxy?
Vue3的核心特征之一就是響應式,而實現數據響應式依賴於底層的Proxy。因此,想要完成Vue的響應式功能,首先需要理解Proxy。
以reactive
為例,當想要創建一個響應式對象時,僅需要使用reactive
對原始對象進行包裹。
例如:
const user = reactive({ age: 25 })
在Vue3的官方文檔中,對reactive
的描述是:返回對象的響應式副本。既然是響應式副本,就需要產生一個與原始對象含有相同內容的響應式對象,之後使用這個響應式對象替代原始對象,代替原始對象去做事情。這就體現瞭Proxy的價值——創建原始對象的代理。
Proxy創建的代理對象與原始對象有何不同?
先來看看如何使用Proxy創建代理對象。
let proxy = new Proxy(target, handler)
Proxy可以接收兩個參數,其中,target
表示被代理的原始對象,handler
表示代理配置,代理配置中的捕捉器允許我們攔截並重新定義針對代理對象的操作。因此,如果在Proxy中,不進行任何代理配置,Proxy僅會成為原始對象的一個透明包裝器(僅創建原始對象副本,但不產生任何其它功能)。因此,為瞭利用Proxy實現響應式對象,我們必須使用Proxy中的代理配置。
在JavaScript規范中,存在一個描述JavaScript運行機制的“內部方法”。其中,讀取屬性的操作被稱為[[Get]]
,設置屬性的操作被稱為[[Set]]
。通常,我們無法直接使用這些“內部方法”,但Proxy給我們提供瞭捕捉器,使得對讀取和設置操作的攔截成為可能。
const p = new Proxy(obj, { get(target, property, receiver) {/*攔截讀取屬性操作*/} set(target, property, value, receiver) {/*攔截設置屬性操作*/} })
其中,target
為被代理的原始對象,property
為原始對象的屬性名,value
為設置目標屬性的值receiver
是屬性所在的this
對象,即Proxy代理對象。
Proxy
是由JavaSciprt引擎實現的ES6特性,因此,沒有ES5的polyfill,也無法使用Babel轉譯。這也是Vue3不支持低版本IE的主要原因。
let fn const data = { text: 'hello vue' } const obj = new Proxy(data, { get(target, key) { fn = effect return target[key] }, set(target, key, newValue) { target[key] = newValue fn() return true } })
多副作用函數的響應式實現
如果響應式數據有多個相關的副作用函數應該如何處理。
在上例中,存儲一個副作用函數我們可以使用一個fn
進行緩存,多個副作用函數,我們在收集時用數組把它裝起來就好,需要觸發時,再遍歷數組挨個觸發收集到的副作用函數。
const fns = [] const data = { text: 'hello vue' } const obj = new Proxy(data, { get(target, key) { fns.push(effect) return target[key] }, set(target, key, newValue) { target[key] = newValue fns.forEach(fn => fn()) return true } })
這樣可以嗎?可以是可以,功能上已經實現瞭。接下來讓我們來看看有沒有哪些地方可以優化。
第一個可以優化的點是,需要考慮被重復收集的函數。
舉個例子:
const effect1 = () => { document.body.innerText = obj.text } const effect2 = effect1
在這個例子中,effect1
與effect2
都表示同一個函數,但在收集時會被重復收集,執行時也會被重復執行。這些重復顯然是不必要的,因此,我們需要在收集副作用函數的時候,幹掉重復的函數。去重可以考慮Set
。
const fns = new Set() const data = { text: 'hello vue' } const obj = new Proxy(data, { get(target, key) { fns.push(effect) return target[key] }, set(target, key, newValue) { target[key] = newValue fns.forEach(fn => fn()) return true } })
不知道大傢是否註意到,為瞭演示方便,我們在收集元素時,都默認被收集的副作用函數名是effect
。但實際開發中,函數名肯定不會是固定的或是有規律可循的,我們肯定不能按函數名一個個手動收集,因此,我們要想一種方式能夠不依賴於函數名進行副作用函數收集。為瞭實現這一點,我們將副作用函數進行包裹,用一個activeEffect
統一註冊。
let activeEffect function effect(fn) { activeEffect = fn fn() } const fns = new Set() const data = { text: 'hello vue' } const obj = new Proxy(data, { get(target, key) { if(activeEffect) { fns.add(activeEffect) } return target[key] }, set(target, key, newValue) { target[key] = newValue fns.forEach(fn => fn()) return true } }) function fn() { document.body.innerText = obj.text } effect(fn)
到這裡,我們已經把容易想到的優化都處理瞭。但這還沒完,這裡還有個隱蔽的點沒有考慮到。此時,我們的響應式粒度還不夠細,副作用函數的收集和觸發的最小單位是響應式對象,這會導致不必要的副作用函數更新。例如,我們給出一個obj
上不存在的屬性obj.notExist
,對其進行賦值操作。為瞭方便演示,我們在fn()
中加一條打印語句,觀察結果。
function fn() { document.body.innerText = obj.text console.log('Done!') }
可以看到,副作用函數被觸發瞭。這裡obj.notExist
屬性在obj
上是不存在的,更談不上對應瞭哪個副作用函數,因此,這裡的副作用函數不應該被觸發,或者換句話說,副作用函數僅能被對應的響應式對象的屬性影響,且僅在該屬性被修改時觸發。
更細粒度的觸發條件就要求我們收集副作用函數時,針對響應式對象的屬性進行收集。聽上去有點無從下手,讓我們再來捋一捋。既然是響應式對象,首先還是要保證利用響應式對象的Proxy
來進行收集和觸發,但在收集的時候,就不能一股腦的把所有屬性對應的副作用函數塞到一塊瞭,需要把誰是誰的分清楚(即一個屬性對應瞭哪些副作用函數),讓每個屬性擁有自己收集和觸發副作用函數的能力,具體對應關系如圖所示。
讓我們從後往前看。首先,屬性的副作用函數收集器我們可以沿用上文的思路,使用Set
實現。再往前,每個屬性都需要配備一個副作用函數收集器,這是一個一對一的關系,我們可以使用將屬性值作為key,副作用收集器作為value,使用Map
實現,而這個Map
就包含瞭單個響應式對象的全部內容。
回看我們上面的代碼,其實我們在進行副作用函數收集的時候,僅使用瞭一個全局的容器承載。在實際開發中,我們需要盡量避免大量出現全局定義。因此,我們將響應式對象放進一個全局容器中統一管理。我們需要將每一個對象和其對應的Map
建立一對一的映射關系。整體關系如下圖所示:
按圖中思路,我們在代碼中重新組織數據結構。
let activeEffect function effect(fn) { activeEffect = fn fn() } const objsMap = new Map() // 全局容器 const data = { text: 'hello vue' } const obj = new Proxy(data, { get(target, key) { if(!activeEffect) return let propsMap = objsMap.get(target) // 屬性容器 if(!propsMap) { objsMap.set(target, (propsMap = new Map())) } let fns = propsMap.get(key) // 副作用函數收集器 if(!fns) { propsMap.set(key, (fns = new Set())) } fns.add(activeEffect) return target[key] }, set(target, key, newValue) { target[key] = newValue const propsMap = objsMap.get(target) // 屬性容器 if(!propsMap) return const fns = propsMap.get(key) // 副作用函數收集器 fns && fns.forEach(fn => fn()) return true } }) function fn() { document.body.innerText = obj.text console.log('Done!') } effect(fn)
執行我們更新的代碼,我們就能避免無效更新,隻觸發與屬性相關的更新瞭。
到這結束瞭嗎?我們其實還能再做一點優化。如果一個響應式對象沒有被引用時,說明這個對象不被使用,不被使用的對象應該被JavaScript的垃圾回收器回收,避免造成內存占用。但Map
的特性(Map
會被其key值持續引用)決定瞭,即使沒有任何引用,Map
中的對象也不能被垃圾回收器回收。解決這個問題,隻需要用WeakMap
來代替Map
,WeakMap
的key是弱引用。到這裡我們終於大功告成瞭,把功能實現完瞭。
功能實現完接下來要幹嘛?對,要重構,要看有沒有能重構的地方。get
和set
中的內容有點臃腫瞭,想想一開始說的,get
時收集,set
時觸發。最後,就讓我們來抽離封裝這兩部分。
let activeEffect function effect(fn) { activeEffect = fn fn() } const objsMap = new WeakMap() const data = { text: 'hello vue' } const obj = new Proxy(data, { get(target, key) { track(target, key) return target[key] }, set(target, key, newValue) { target[key] = newValue trigger(target, key) return true } }) // 收集 function track(target, key) { if(!activeEffect) return let propsMap = objsMap.get(target) if(!propsMap) { objsMap.set(target, (propsMap = new Map())) } let fns = propsMap.get(key) if(!fns) { propsMap.set(key, (fns = new Set())) } fns.add(activeEffect) } // 觸發 function trigger(target, key) { const propsMap = objsMap.get(target) if(!propsMap) return const fns = propsMap.get(key) fns && fns.forEach(fn => fn()) } function fn() { document.body.innerText = obj.text console.log('Done!') } effect(fn)
到此這篇關於Vue3響應式對象是如何實現的的文章就介紹到這瞭,更多相關Vue3響應式內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!