一種JavaScript響應式系統實現 根據VueJs核心團隊成員霍春陽《Vue.js設計與實現》第四章前三節整理而成 1. 響應式數據與副作用函數 1.1 副作用函數 會產生副作用的函數。 如下示例所示: function effect () { document.body.innerText = ...
一種JavaScript響應式系統實現
根據VueJs核心團隊成員霍春陽《Vue.js設計與實現》第四章前三節整理而成
1. 響應式數據與副作用函數
1.1 副作用函數
會產生副作用的函數。
如下示例所示:
function effect () {
document.body.innerText = 'hello vue3!'
}
當effect函數執行時,會設置body的文本內容。但是,除了effect函數之外的任何函數都可以讀取或者設置body的文本內容。也就是說,effect函數的執行,會直接或間接影響其他函數的執行,這時,我們說effect函數產生了副作用。
// 此方法的結果就受effect函數的影響
function getInnerText () {
return document.body.innerText
}
副作用很容易產生,例如一個函數修改了全局變數,這其實也是一個副作用,如下麵代碼所示:
let globalValue = 1
function effect () {
globalValue = 3 // 修改全局變數,產生副作用
}
1.2 響應式數據
理解了什麼是副作用函數,再來說一說什麼是響應式數據。
假設在一個副作用函數中讀取了某個對象的屬性:
const obj = {
text: 'hello vue2'
}
function effect () {
document.body.innerText = obj.text // effect 函數的執行會讀取obj.text
}
如上面代碼所示,副作用函數effect會設置body元素的innerText屬性,其值為obj.text。當obj.text的值改變時,我們希望副作用函數effect()會重新執行:
obj.text = 'hello vue3' // 修改obj.text的值,同時希望副作用函數重新執行
這句代碼修改了欄位 obj.text 的值,我們希望當值變化後,副作用函數會重新執行,如果能實現這個目標,那麼對象 obj 就是響應式數據。(某數據改變時,依賴該數據的副作用函數會重新執行,該數據即為響應式數據)
但是,從上面代碼來看,我們還做不到這一點,因為obj是一個普通的對象,當我們修改它的值時,除了值本身發生變化外,不會有任何其他反應。
2. 響應式數據的基本實現
2.1 如何讓 obj 變為響應式數據?
通過觀察,我們可以發現 2 點線索:
- 當副作用函數effect()執行時,會觸發欄位 obj.text 的讀取操作;
- 當修改
obj.text
的值時,會觸發欄位obj.text
的設置操作;
如果我們能攔截一個對象的讀取和設置操作,事情就變得簡單了。
- 當讀取
obj.text
時,我們可以把副作用函數effect()存儲到一個“桶”里;
- 當設置
obj.text
時,再把effect()副作用函數從 “桶” 里取出,並執行;
2.2 如何攔截對象屬性的讀取和設置操作
在 es5 及之前,只能通過 Obj.defineProperty
來實現,這也是vue2中所採用的方式。
es6以後,可以使用代理對象 Proxy
來實現,這是vue3中的所採用的方式。
實現步驟:
- 創建一個用於存儲副作用函數的容器(或者通俗點說,一個桶,vue3里用了bucket這個詞,以後都稱為桶);
- 定義一個普通對象
obj
作為原始數據; - 創建
obj
對象的代理作為響應式數據,分別設置get
和set
攔截函數,用於攔截讀和寫操作; - 當在effect副作用函數中執行響應式數據讀操作時,將effect()副作用函數存到桶里;
- 當對響應式數據進行寫操作時,先更新原始數據,再從桶中取出依賴了響應式數據的函數,進行執行;
接下來,根據以上思路,使用Proxy
實現一下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app">
<p id="title"></p>
<button onclick="changeObj()">修改響應式數據</button>
</div>
</body>
</html>
<script>
// 1. 創建一個存儲副作用函數的桶
const bucket = new Set()
// 2. 一個普通的對象
const obj = {
text: 'hello vue2'
}
// 3. 普通對象的代理,一個簡單的響應式數據
const objProxy = new Proxy(obj, {
get (target, key) {
bucket.add(effect)
return target[key]
},
set (target, key, newValue) {
target[key] = newValue
bucket.forEach((fn) => {
fn()
})
return true
}
})
// 4. 副作用函數 effect(), 給p標簽設置值
const effect = function () {
document.getElementById('title').innerText = objProxy.text
}
// 5. 改變代理對象的元素值
const changeObj = function () {
objProxy.text = 'hello vue3!!!!!!'
}
// 首次進入時,給p標簽設置值
effect()
</script>
2.3 缺陷
- 副作用函數名字叫
effect
,是硬編碼的,假如叫其他的名字,就無法正確執行了;
3. 完善的響應式系統
3.1 如何存儲一個任意命名(甚至匿名)的副作用函數?
需要提供一個用來註冊副作用函數的機制,如以下代碼所示:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app">
<p id="title"></p>
<button onclick="changeObj()">修改響應式數據</button>
</div>
</body>
</html>
<script>
let activeEffect
function effectRegister (fn) {
activeEffect = fn
fn()
}
const bucket = new Set()
const obj = {
text: 'hello vue2'
}
const objProxy = new Proxy(obj, {
get (target, key) {
if (activeEffect) {
bucket.add(activeEffect)
}
return target[key]
},
set (target, key, newValue) {
target[key] = newValue
bucket.forEach((fn) => {
fn()
})
return true
}
})
const changeObj = function () {
objProxy.text = 'hello vue3!!!!!!'
}
effectRegister(() => {
document.getElementById('title').innerText = objProxy.text
})
</script>
以上方式,通過定義一個全局變數activeEffect
,用來存儲(匿名)副作用函數。並提供一個註冊副作用函數的註冊函數 effectRegister
,該函數是一個高階函數,接受一個函數作為參數,保存並執行該函數。將(匿名)副作用函數保存到activeEffect
中,當(匿名)副作用函數執行時,觸發響應式數據的讀操作,此時將activeEffect
存入副作用函數桶中。
缺陷:
以上方案成功解決了匿名的函數的保存問題,但仍存在一個嚴重問題:
響應式對象內的不同屬性和不同副作用函數的對應問題
下麵給響應式數據添加一個屬性,以上代碼微調為如下:
// ... 省略未改變代碼
const setObj = function (key, value) {
objProxy[key] = value
}
const changeObj = function () {
setObj('text', 'hello vue3!!!!!!')
setObj('notExist', 'this key is not exist')
console.log(1, bucket)
}
effectRegister(() => {
console.log('執行')
document.getElementById('title').innerText = objProxy.text
})
執行結果為:
可以看到,此時副作用函數與obj.notExist
屬性並未建立響應關係,但當給 notExist 賦值時,副作用函數也執行了,這顯然不對了。
理想情況應該是,a屬性與aFunc建立響應式關係,b屬性與bFunc建立響應式聯繫,則 a 改變時,僅 aFunc函數觸發執行,b改變時,僅bFunc觸發執行。
3.2 如何解決響應式對象內的不同屬性和不同副作用函數的對應問題?
問題分析:
導致該問題的根本原因是,我們沒有在副作用函數與被操作的目標欄位之間建立明確的聯繫。
例如,但讀取屬性值時,無論讀取到哪一個屬性,其實都一樣,都會把副作用函數收集到“桶”里;無論設置的是哪一個屬性,也會把“桶”裡面的副作用函數取出並執行。響應數據屬性和副作用函數之間沒有明確的聯繫。
解決方案很簡單,只需要在副作用函數與被操作的欄位之間建立聯繫即可。
要實現不同屬性值與副作用函數對應,Set
類型的數據作為桶已經明顯不合適了。
科普下es6幾大數據類型:
Set:ES6 提供了新的數據結構 Set。它類似於數組,但是成員的值都是唯一的,沒有重覆的值。
WeakSet:WeakSet 結構與 Set 類似,也是不重覆的值的集合。但是,它與 Set 有兩個區別。首先,WeakSet 的成員只能是對象,而不能是其他類型的值。其次,WeakSet 中的對象都是弱引用,即垃圾回收機制不考慮 WeakSet 對該對象的引用,也就是說,如果其他對象都不再引用該對象,那麼垃圾回收機制會自動回收該對象所占用的記憶體,不考慮該對象還存在於 WeakSet 之中。
Map:它類似於對象,也是鍵值對的集合,但是“鍵”的範圍不限於字元串,各種類型的值(包括對象)都可以當作鍵。也就是說,Object 結構提供了“字元串—值”的對應,Map 結構提供了“值—值”的對應,是一種更完善的 Hash 結構實現。
WeakMap:
WeakMap
結構與Map
結構類似,也是用於生成鍵值對的集合。WeakMap
與Map
的區別有兩點。首先,
WeakMap
只接受對象作為鍵名(null
除外),不接受其他類型的值作為鍵名。其次,WeakMap
的鍵名所指向的對象,不計入垃圾回收機制。
新的方案:
首先,來看下如下代碼:
effectRegister(function effectFn () {
document.getElementById('title').innerText = objProxy.text
})
這段代碼存在三個角色:
- 被讀取的響應式數據
objProxy
; - 被讀取的響應式數據的屬性名
text
; - 使用
effectRegister
函數註冊的副作用函數effectFn
;
如果使用 target
來表示一個代理對象所代理的原始對象,用key
來表示被操作的欄位名,用effectFn
來表示被註冊的副作用函數,那麼可以為這三個角色建立如下關係:
這是一種樹型結構,下麵舉幾個例子進行補充說明:
-
如果有2個副作用函數同時讀取同一個對象的屬性值:
effectRegister(function effectFn1 () { obj.text }) effectRegister(function effectFn2 () { obj.text })
那麼這三者關係如下:
-
如果一個副作用函數中讀取了同一個對象的 2 個不同屬性:
effectRegister(function effectFn () { obj.text1 obj.text2 }
那麼這三者關係如下:
-
如果 2 個不同的副作用函數中讀取了 2 個不同對象的不同屬性:
effectRegister(function effectFn1 () { obj1.text1 }) effectRegister(function effectFn2 () { obj2.text2 })
那麼這三者關係如下:
總之,這其實就是一個樹形數據結構。這個聯繫建立起來後,就可以解決前文提到的問題。上文中,如果我們設置了obj2.text2
的值,就只會導致 effectFn2
函數重新執行,並不會導致 effectFn1
函數重新執行。
接下來,需要重新設計這個桶。根據對以上幾種情況的分析,我們可以總結一下這種數據結構的模型:
首先,使用WeakMap
代替 Set
來作為桶,將 原始對象 target
作為WeakMap
的key,使用 Map
作為value。在Map
中又以屬性值 作為 key值,使用 Set
存儲key 對應的副作用函數 effectFn
,然後修改 get/set
攔截器代碼。
以下,我們將桶命名為 bucket
, 每個對象的 屬性-副作用函數 存儲塊 命名為 depsMap
,將depsMap中的value部分(Set類型,存儲副作用函數)命名為 deps
(與vue3響應式實現源碼命名保持一致),根據數據結構圖,重新編寫get/set
攔截器代碼:
js:
// WeakMap 類型的桶
const bucket = new WeakMap()
// 初始值為undefined
let activeEffect
// 定義2個對象
const obj_1 = {
text1: 'hello vue',
text2: 'hello jquery'
}
const obj_2 = {
text1: 'hello react',
text2: 'hello angular'
}
const objProxy1 = new Proxy(obj_1, {
get (target, key) {
// 如果沒有註冊的副作用函數,直接返回key對應的value值
if (!activeEffect) {
return target[key]
}
// 獲取桶內 target 對象對應的 depsMap
let depsMap = bucket.get(target)
// 如果該對象還沒有depsMap,這新建一個
if (!depsMap) {
depsMap = new Map()
bucket.set(target, depsMap)
}
// 從depsMap中取出屬性對應的副作用函數集合
let deps = depsMap.get(key)
// 同理,若不存在,則創建
if (!deps) {
deps = new Set()
depsMap.set(key, deps)
}
deps.add(activeEffect)
return target[key]
},
set (target,key, newValue) {
// 給target的key設置新的value
target[key] = newValue
// 取出該target對應的depsMap
let depsMap = bucket.get(target)
// depsMap 不存在或為空直接返回
if (!depsMap) {
return
}
// 取出deps
let deps = depsMap.get(key)
// deps 不存在或為空直接返回
if (!deps) {
return
}
// 依次執行對應副作用函數
deps.forEach((fn) => {
fn()
})
return true
}
})
const objProxy2 = new Proxy(obj_2, {
...
})
const effectRegister = function (fn) {
activeEffect = fn
fn()
}
演示:
3.3 思考:為什麼桶的結構使用WeakMap而不是Map?
看一段代碼:
const map = new Map()
const weakmap = new WeakMap()
;(function () {
const foo = { foo: 1}
const bar = { bar: 2}
map.set(foo, 1)
weakmap.set(bar, 2)
})();
console.log(map)
console.log(weakmap)
思考,列印結果分別是啥?
註意:在瀏覽器環境下,console列印結果表現出非同步行為。
原因分析:
Map對key是強引用,當立即執行函數結束後,foo仍被map引用,因此map.keys()可以成功列印出key值,而WeakMap對key
是弱引用,立即執行函數結束後,bar 失去引用,被垃圾回收器回收掉,因此weakmap.keys()無法取出key值。
結論:
WeakMap經常存儲那些只有當key值所引用的對象存在(沒有被垃圾回收)時才有價值的信息,例如示例中的target,如果target對象沒有任何引用了,說明用戶測不需要它了,這是垃圾回收器會完成回收任務。
如果使用Map來代替WeakMap,那即使用戶側沒有任何對target的引用,這個target也不會被回收,最終可能導致記憶體溢出。
4. 總結
以上,我們實現了一個簡單的響應系統,核心思路是通過代理來攔截響應數據的讀和寫的操作,將與對象屬性相關的副作用函數進行存儲,當對象屬性變化時,同步執行相關聯的副作用函數,達到響應式的效果。
其實現中混合使用了代理模式和觀察者模式。
5. 參考資料
- 霍春陽《Vue.js設計與實現》
- JS中15種常見設計模式
附:程式源碼
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app">
<p id="title_1"></p>
<p id="title_2"></p>
<button onclick="setObj_1()">修改響應式數據1</button>
<button onclick="setObj_2()">修改響應式數據2</button>
</div>
</body>
</html>
<script>
let activeEffect
let count = 3
let version = 3
const bucket = new WeakMap()
const obj_1 = {
text1: 'hello vue2',
text2: 'hello jq2'
}
const obj_2 = {
text2: 'hello react16',
text1: 'hello ng2'
}
const getProxy = function (obj) {
return new Proxy(obj, {
get (target, key) {
track(target, key)
return target[key]
},
set (target, key, newValue) {
trigger(target, key, newValue)
return true
}
})
}
const track = function (target, key) {
// 如果無副作用函數,則直接返回原始對象值
if (!activeEffect) {
return
}
// 以該代理對象的原始對象作為key值,獲取depsMap (屬性和副作用函數之間的對應關係 key --> effectFn)
let depsMap = bucket.get(target)
// 如果不存在depsMap,則新建一個Map與target關聯起來
if (!depsMap) {
depsMap = new Map()
bucket.set(target, depsMap)
}
// 根據key從depsMap中獲取對應的deps,deps是一個Set
let deps = depsMap.get(key)
// 如果不存在,則新建Set,並與key關聯
if (!deps) {
deps = new Set()
depsMap.set(key, deps)
}
// 最後將當前活躍的副作用函數添加到桶里
deps.add(activeEffect)
}
const trigger = function (target, key, newValue) {
target[key] = newValue
let depsMap = bucket.get(target)
if (!depsMap) {
return
}
let deps = depsMap.get(key)
if (!deps) {
return
}
deps.forEach(fn => {
fn()
})
}
const objProxy1 = getProxy(obj_1)
const objProxy2 = getProxy(obj_2)
const effectRegister = function (fn) {
activeEffect = fn
fn()
}
const setObj_1 = function () {
objProxy1.text1 = `hello, vue${count++}`
console.log('bucket-1', bucket)
}
const setObj_2 = function () {
objProxy2.text2 = `hello, react${version++}`
console.log('bucket-2', bucket)
}
console.log('bucket-init', bucket)
effectRegister(function effectFn1 () {
document.getElementById('title_1').innerText = objProxy1.text1
})
effectRegister(function effectFn2 () {
document.getElementById('title_2').innerText = objProxy2.text2
})
</script>
作者:CherishTheYouth
出處:https://www.cnblogs.com/CherishTheYouth/
聲明:本文版權歸作者和博客園共有,歡迎轉載,但未經作者同意必須保留此段聲明,且在文章頁面明顯位置給出原文鏈接。對於本博客如有任何問題,可發郵件與我溝通,我的QQ郵箱是:[email protected]