今天我們要在保持既有鏈表架構不變的前提下,來實作 computed 的惰性計算 + 快取(dirty 旗標)與調度邏輯。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Document</title>
<style>
body {
padding: 150px;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module">
import { ref, computed, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
// import { ref, computed, effect } from '../dist/reactivity.esm.js'
const count = ref(0)
const c = computed(() => {
return count.value + 1
})
effect(() => {
console.log(c.value)
})
setTimeout(() => {
console.log(count.value)
}, 1000)
</script>
</body>
</html>
先看官方程式碼的效果:
可以看到控制台,它會先輸出1
,再輸出2
,中間的 computed 只在需要時重算(惰性)。
執行順序是:
count
、c
effect
,立刻執行console.log(c.value)
() => count.value + 1
。count.value
,函式回傳 0 + 1
,結果是 1
,輸出1
。count.value = 1
被執行。count
的值從 0
變成了 1
。count.value
被修改時,它會通知所有訂閱它的對象,這邊包含c
。c
接收到通知後,重新計算自己的值,並接著通知所有訂閱 c
的對象(也就是 effect
),最終觸發 effect
的重新執行。effect
收到通知,於是自動重新執行它內部的函式:() => console.log(c.value)
。
c.value
。() => count.value + 1
。count.value
的值已經是 1
。c
計算出的新值為 1 + 1 = 2
,輸出2
。那我們可以看這個過程中,computed 在這當中扮演的角色如下圖。
首先computed
具有雙重角色:
effect
訪問 computed
的 .value
時,computed
會將這個 effect
收集起來,建立關聯。怎麼樣是一個 Sub?怎麼樣是一個 Dep?可以查看之前定義的 interface。
/**
* 依賴項
*/
export interface Dependency {
// 訂閱者鏈表頭節點
subs: Link | undefined
// 訂閱者鏈表尾節點
subsTail: Link | undefined
}
/**
* 訂閱者
*/
export interface Sub {
// 訂閱者鏈表頭節點
deps: Link | undefined
// 訂閱者鏈表尾節點
depsTail: Link | undefined
// 是否正在收集依賴
tracking: boolean
}
Sub
deps
頭節點depsTail
尾節點Dep
subs
頭節點subsTail
尾節點我們先在 @vue/shared
,新增一個函式判斷式。
export function isFunction(value) {
return typeof value === 'function'
}
由於 computed 接收有可能是函式,也有可能是一個物件,所以我們新增一個 computed.ts
,導出一個 computed 函式,來判斷它是物件還是函式。
export function computed(getterOptions) {
let getter
let setter
if (isFunction(getterOptions)) {
getter = getterOptions
} else {
getter = getterOptions.get
setter = getterOptions.set
}
// ComputedRefImpl 是 computed 實際的響應式實作類別,再將 getter 跟 setter 傳入
return new ComputedRefImpl(getter, setter)
}
接著讓我們再來實作 ComputedRefImpl
類別,把 Dep 和 Sub 所需要的屬性加入:
class ComputedRefImpl implements Dependency, Sub {
// computed 是 ref,所以他會有這個標誌,通過 isRef 也回傳 true
[ReactiveFlags.IS_REF] = true
// 保存 fn 返回值
_value
// 如果是 Dep,要關聯 Subs,觸發更新要通知執行 fn
subs: Link
subsTail: Link
// 如果是 Sub,要知道哪些 Dep 被收集
deps: Link
depsTail: Link
tracking = false
constructor(
public fn, //getter,但原始碼是fn,為了保持跟 effect 一致
private setter
) { }
get value() {
this.update()
return this._value
}
set value(newValue) {
// 如果他有傳入 setter,表示是物件傳入
if (this.setter) {
this.setter(newValue)
} else {
console.warn('computed is readonly')
}
}
update(){
this._value = this.fn()
}
}
我們執行這段程式碼,表面上看,它似乎能正確計算出結果:
但其實目前 get value()
每次讀取都直接 update()
,都沒有導入快取/dirty 與,在多次讀值或多個 effect 下會一直重複計算。
我們剛剛提到 computed 他有雙重角色,那麼我們要如何讓 computed
同時做 Dep
和 Sub
的角色呢?
回顧我們先前的邏輯,就可以知道:
我們先在 get value()
裡建立與當前 activeSub 的關聯(link(this, activeSub)
),同時改成只有在 dirty 時才 update,避免每次讀值都重算。
class ComputedRefImpl implements Dependency, Sub {
...
...
get value() {
this.update()
if(activeSub){
link(this,activeSub)
}
console.log('computed',this)
return this._value
}
...
...
}
接下來 console 看看是不是正常收集到 Fn
看來有正確儲存 fn ,表示我們建立好關聯關係。
我們現在已經完成下方紅色區塊連結的地方:
我們需要在 fn 執行期間,收集訪問的響應式,因此我們看一下之前寫的 effect 的邏輯。
computed 的 getter
執行時仍需收集依賴。沿用先前的 setActiveSub
/ startTrack
/ endTrack
機制,不需要改寫 effect 架構。
我們只在 ComputedRefImpl.update()
內部包一層收集區段就好。
export function setActiveSub(sub) {
activeSub = sub
}
export class ReactiveEffect {
...
run() {
const prevSub = activeSub
setActiveSub(this)
startTrack(this)
try {
return this.fn()
} finally {
endTrack(this)
setActiveSub(prevSub)
}
}
...
...
}
我們透過 setActiveSub
來重新賦值給 activeSub
變數,再引入 computed.ts
import { activeSub, setActiveSub } from './effect'
...
...
update(){
// 為了在 fn 執行期間,收集訪問的響應式
const prevSub = activeSub
setActiveSub(this)
startTrack(this)
try {
this._value = this.fn()
} finally {
endTrack(this)
setActiveSub(prevSub)
console.log(this)
}
}
...
...
在 console 控制台上,我們可以看到 dep 也被成功儲存。
這樣看來,下方紅色圈起來的地方也已經完成。
但你應該還會發現有一個錯誤。
原因是 Ref
在 setTimeout 觸發更新會執行 setter
...
...
set value(newValue) {
if(hasChanged(newValue, this._value)){
this._value = isObject(newValue) ? reactive(newValue) : newValue
triggerRef(this)
}
}
...
然而執行到propagate
函式
export function propagate(subs) {
let link = subs
let queuedEffect = []
while (link) {
const sub = link.sub
// 只有不在執行中的才加入隊列
if(!sub.tracking){
queuedEffect.push(sub)
}
link = link.nextSub
}
queuedEffect.forEach(effect =>effect.notify())
}
propagate
函式預期所有 sub
都有一個 run()
方法,但我們的 ComputedRefImpl
類別沒有這個方法。
我們目前已經分別完成兩部份的鏈表,分別是:
computed
成為 count
的訂閱者 (Sub)computed
成為 effect
的依賴項目 (Dep)現在,我們需要將這兩段依賴鏈路串接起來,形成完整的更新流程。
執行觸發更新時:
因此我們現在要做的就是:
還記得我們原本在 computed 怎麼執行更新?
之前我們在 ComputedRefImpl
中已經定義了 update
方法,可以用它來更新 computed 的值。
export function processComputedUpdate(sub) {
// 通知 computed 更新
sub.update()
// 通知 sub 鏈表的其他 sub 更新
propagate(sub.subs)
}
export function propagate(subs) {
let link = subs
let queuedEffect = []
while (link) {
const sub = link.sub
if(!sub.tracking){
// 如果 link.sub有 update 方法,表是傳入的是 computed
if('update' in sub){
processComputedUpdate(sub)
}else{
queuedEffect.push(sub)
}
}
link = link.nextSub
}
queuedEffect.forEach(effect =>effect.notify())
}
所以我們可以透過傳入的 sub 是否有 update
方法來判斷他是不是 computed,如果傳入的是 computed,那除了觸發更新函式之外,還需要通知 sub 鏈表上的所有 sub 更新。
我們執行這段程式碼,表面上看,它似乎能正確計算出結果:
但如果 index.html
你這樣寫:
const count = ref(0)
const c = computed(() => {
console.count('computed')
return count.value + 1
})
effect(() => {
console.log(c.value)
})
setTimeout(() => {
count.value = 1
}, 1000)
你會發現它其實是觸發三次。
如果用官方的範例,發現它其實執行兩次而已。
這個問題的根源在於 get value()
的實作:每次訪問 .value
都會直接觸發 update()
方法,因此完全沒有實現緩存。
get value() {
this.update()
...
...
}
今天我們加上快取與 dirty
,並以 notify()
充當調度器:上游變更只標髒、下游讀取才重算。下篇我們再補上更進一步的同一 tick 多次讀值只算一次、以及多層 computed 鏈的範例,確認效能與語意
computed
完整程式碼:
import { ReactiveFlags } from './ref'
import { Dependency, Sub, Link, link, startTrack, endTrack } from './system'
import { isFunction } from '@vue/shared'
import { activeSub, setActiveSub } from './effect'
class ComputedRefImpl implements Dependency, Sub {
// computed 是 ref,所以他會有這個標誌,通過 isRef 也回傳 true
[ReactiveFlags.IS_REF] = true
// 保存 fn 返回值
_value
// 如果是 Dep,要關聯 Subs,觸發更新要通知執行 fn
subs: Link
subsTail: Link
// 如果是 Sub,要知道哪些 Dep 被收集
deps: Link
depsTail: Link
tracking = false
constructor(
public fn, //getter,源碼是fn,保持跟 effect 一致
private setter
) { }
get value() {
this.update()
if(activeSub){
link(this,activeSub)
}
return this._value
}
set value(newValue) {
if (this.setter) {
this.setter(newValue)
} else {
console.warn('computed is readonly')
}
}
update(){
/**
* 收集依賴
* 為了在 fn 執行期間,收集訪問的響應式
*/
const prevSub = activeSub
setActiveSub(this)
startTrack(this)
try {
this._value = this.fn()
} finally {
endTrack(this)
setActiveSub(prevSub)
}
}
}
export function computed(getterOptions) {
let getter
let setter
if (isFunction(getterOptions)) {
getter = getterOptions
} else {
// 傳入是物件,物件有 get 和 set
getter = getterOptions.get
setter = getterOptions.set
}
return new ComputedRefImpl(getter, setter)
}
同步更新《嘿,日安!》技術部落格