在深入瞭解計算屬性之前,我們需要先知道懶執行的effect,也就是lazy的effect
我們現在實現的effect函數會立即執行傳遞給它的副作用函數,例如:
effect(
// 這個函數會立即執行
() => {
console.log('執行了');
}
)
但有的時候我們不需要他立即執行,而是在需要的時候才執行,這個特性就跟計算屬性很像
我們可以在options中添加lazy屬性來達到目的
effect(
// 這個函數會立即執行
() => {
console.log('執行了');
},
// options
{
lazy: true
}
)
lazy選項和之前介紹的scheduler一樣,通過options選項配置
有了lazy就可以實現懶執行的邏輯了
let activeEffect;
// 棧
const effectStack = []
function effect(fn, options = {}) {
const effectFn = () => {
// 利用 cleanup 函數清除 effectFn 中存儲的副作用函數
cleanup(effectFn);
// 當effectFn執行時,activeEffect保存當前effectFn副作用函數
activeEffect = effectFn
// 在調用副作用函數之前將當前副作用函數壓入棧中
effectStack.push(effectFn);
fn(); // 真正執行的副作用函數
// 當前副作用函數執行完畢後,將當前副作用函數彈出棧,並把activeEffect還原為之前的值
effectStack.pop();
// activeEffect還原為之前的值
activeEffect = effectStack[effectStack.length - 1]
}
// 將options掛載到effectFn上
effectFn.options = options // 新增
// 定義一個deps數組,用來收集與該副作用函數相關的依賴集合
effectFn.deps = []
// 只有非lazy的時候才執行
if (!options.lazy) {
// 執行副作用函數
effectFn()
}
// 將副作用函數作為返回值返回
return effectFn
}
通過lazy判斷,我們就可以讓副作用函數不立即執行
同時,我們還需要知道副作用函數正確執行的時機
我們在上面的代碼中,將effectFn作為函數返回值返回了,它為我們提供了手動執行副作用函數的能力
當我們調用effect的時候,拿到它的返回值就可以手動執行副作用函數了
const effectFn = effect(() => {
console.log('執行了');
}, {
lazy: true
})
// 手動執行副作用函數
effectFn();
但僅僅只是手動執行副作用函數沒什麼太大意義,我們還需要在執行的時候拿到它的返回值
我們可以換一個視角去看這個副作用函數機制,如果我們把傳遞給effect的函數看成一個getter,這個getter函數可以返回任何值,例如:
const effectFn = effect(
// 這裏堪稱一個getter,它返回obj.foo + obj.bar 的和
() => obj.foo + obj.bar,
{
lazy: true
}
)
// 手動執行副作用函數,拿到其返回值
const value = effectFn()
為了實現上述代碼的效果,我們需要在effect函數內做一些修改
let activeEffect;
// 棧
const effectStack = []
function effect(fn, options = {}) {
const effectFn = () => {
// 利用 cleanup 函數清除 effectFn 中存儲的副作用函數
cleanup(effectFn);
// 當effectFn執行時,activeEffect保存當前effectFn副作用函數
activeEffect = effectFn
// 在調用副作用函數之前將當前副作用函數壓入棧中
effectStack.push(effectFn);
// 將fn的執行結果存儲到res中-新增
const res = fn();
// 當前副作用函數執行完畢後,將當前副作用函數彈出棧,並把activeEffect還原為之前的值
effectStack.pop();
// activeEffect還原為之前的值
activeEffect = effectStack[effectStack.length - 1]
// 將res作為effectFn的返回值-新增
return res
}
// 將options掛載到effectFn上
effectFn.options = options
// 定義一個deps數組,用來收集與該副作用函數相關的依賴集合
effectFn.deps = []
// 只有非lazy的時候才執行
if (!options.lazy) {
// 執行副作用函數
effectFn()
}
// 將副作用函數作為返回值返回
return effectFn
}
在上面的新增代碼中可以看到,傳給effect的fn才是真正的副作用執行函數
而effectFn只是包裝函數,它負責去觸發副作用函數的執行並把結果返回出去
現在我們就可以實現懶執行副作用函數並拿到執行結果了,如下:
function computed(getter) {
// 把getter作為副作用函數,其實getter就是fn
// 在options中添加lazy
const effectFn = effect(getter, {
lazy: true
})
const obj = {
// 讀取value的時候才會執行effectFn
get value() {
return effectFn()
}
}
return obj
}
在這裏我們定義了一個computed函數,接收getter參數(副作用函數fn),並且在內部的effect是懶執行的
computed會返回一個對象,這個對象只有在訪問value值的時候才會執行effectFn並返回其執行結果
我們可以用computed創建一個計算屬性
const data = { foo: 1, bar: 2 }
const obj = new Proxy(data, {}); // 為了演示省略代理詳情
const sumRes = computed(() => obj.foo + obj.bar)
console.log(sumRes.value); // 拿到執行結果 3
在上面的計算屬性中,只有讀取value時才會拿到最後的執行結果,可以看到上面已經正確的執行了計算函數
在vue.js中計算屬性還具備緩存功能,目前我們的實現的還無法做到,例如多次調用計算屬性,即使obj.foo和obj.bar的值本身並沒有變化也會重複執行
console.log(sumRes.value); // 3
console.log(sumRes.value); // 3
console.log(sumRes.value); // 3
為了解決這個問題,我們需要對值進行緩存
function computed(getter) {
// value用來緩存上一次計算的值
let value;
// dirty標誌,用來識別是否需要重新計算,true則表示需要計算
let dirty = true;
// 把getter作為副作用函數,其實getter就是fn
// 在options中添加lazy
const effectFn = effect(getter, {
lazy: true
})
const obj = {
// 讀取value的時候才會執行effectFn
get value() {
if (dirty) {
value = effectFn()
// 將dirty設置為false,下一次訪問則直接拿緩存的value值
dirty = false
}
// 返回副作用函數結果
return value
}
}
return obj
}
在上面的代碼中,我們新增了兩個變量vlaue和dirty
value用來緩存副作用函數執行結果(上一次計算的值)
dirty表示是否需要重新計算
當我們通過sumRes.value訪問值時,只有dirty為true才會調用effectFn重新計算,為false則直接拿緩存值
第一次的時候會重新計算,後續都會通過dirty來控制是否重新計算的功能
這個dirty是我們控制的關鍵,在上面代碼中,我們僅僅實現了緩存功能,但是我們還需要知道值是否變化了,變化的時候我們需要將dirty改為true來重新計算
此時我們就需要scheduler函數來協助我們完成這一壯舉
function computed(getter) {
// value用來緩存上一次計算的值
let value;
// dirty標誌,用來識別是否需要重新計算,true則表示需要計算
let dirty = true;
// 把getter作為副作用函數,其實getter就是fn
// 在options中添加lazy
const effectFn = effect(getter, {
lazy: true,
// 添加調度器,在調度器中將dirty的狀態修改為true
scheduler() {
dirty = true
}
})
const obj = {
// 讀取value的時候才會執行effectFn
get value() {
if (dirty) {
value = effectFn()
// 將dirty設置為false,下一次訪問則直接拿緩存的value值
dirty = false
}
// 返回副作用函數結果
return value
}
}
return obj
}
在上面的代碼中新增了scheduler調度器,scheduler是在對象值修改的時候調用的,也就是Prosy -> set的時候
當響應式數據變化(如obj.foo++)時觸發scheduler調度器將dirty重置為true,當訪問sumRes.value的時候就能拿到正確結果
完整代碼
let activeEffect;
// 棧
const effectStack = []
function effect(fn, options = {}) {
const effectFn = () => {
// 利用 cleanup 函數清除 effectFn 中存儲的副作用函數
cleanup(effectFn);
// 當effectFn執行時,activeEffect保存當前effectFn副作用函數
activeEffect = effectFn
// 在調用副作用函數之前將當前副作用函數壓入棧中
effectStack.push(effectFn);
// 將fn的執行結果存儲到res中-新增
const res = fn();
// 當前副作用函數執行完畢後,將當前副作用函數彈出棧,並把activeEffect還原為之前的值
effectStack.pop();
// activeEffect還原為之前的值
activeEffect = effectStack[effectStack.length - 1]
// 將res作為effectFn的返回值-新增
return res
}
// 將options掛載到effectFn上
effectFn.options = options // 新增
// 定義一個deps數組,用來收集與該副作用函數相關的依賴集合
effectFn.deps = []
// 只有非lazy的時候才執行
if (!options.lazy) {
// 執行副作用函數
effectFn()
}
// 將副作用函數作為返回值返回
return effectFn
}
const jobQueue = new Set();
// 定義一個任務隊列執行函數,這是一個Promise的微任務,並且是resolve狀態,調用後可以直接.then
const p = Promise.resolve();
// 定義一個變量來標記是否正在刷新隊列
let isFlushing = false;
function flushJob() {
// 如果正在刷新隊列,則什麼都不做
if (isFlushing) return
// 當任務隊列中有任務時,將isFlushing設置為true,表示正在刷新隊列
isFlushing = true
// 在微任務隊列中刷新jobQueue任務隊列
p.then(() => {
jobQueue.forEach(job => job())
}).finally(() => {
// 刷新完成後將isFlushing設置為false
isFlushing = false
})
}
// 重置依賴關係
function cleanup(effectFn) {
// 遍歷 effectFn.deps 數組
for (let i = 0; i < effectFn.deps.length; i++) {
// deps 是依賴集合
const deps = effectFn.deps[i]
// 將 effectFn 從依賴集合中移除
// 也就是在依賴收集的時候的deps,這個deps其實就是key對應的Set集合
// deps.delete(effectFn)也就是刪除key對應的Set集合中的activeEffect
deps.delete(effectFn)
}
// 最後重置 effectFn.deps 數組
effectFn.deps.length = 0
}
// 存儲副作用函數的桶
const bucket = new WeakMap()
function track(target, key) {
// 沒有activeEffect,直接return
if (!activeEffect) return target[key]
let depsMap = bucket.get(target)
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
let deps = depsMap.get(key)
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
// 把當前激活的副作用函數添加到依賴集合中:deps -> Set構造函數
// 把activeEffect當作依賴收集給deps
deps.add(activeEffect)
// deps就是與當前副作用函數存在聯繫的依賴集合
// 把依賴集合添加到activeEffect.deps數組中 -> effectFn.deps
activeEffect.deps.push(deps)
}
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
// 獲取依賴集合effects[] -> activeEffect.deps[] -> effectFn.deps
const effects = depsMap.get(key)
// 用一個新Set保存依賴集合,直接使用effects會出現無限循環
// 因為effectFn函數內部調用了cleanup函數在執行刪除操作,而track函數內部的deps.add(activeEffect)又在新增,導致無限循環
const effectsToRun = new Set()
effects && effects.forEach(effectFn => {
// 如果trigger觸發執行的副作用函數與當前正在執行的副作用函數不同則執行,否則不執行
if (effectFn !== activeEffect) {
// 收集依賴
effectsToRun.add(effectFn)
}
})
// 調用effectFn函數,並且effectFn函數內部會調用cleanup重置依賴關係
effectsToRun.forEach(effectFn => {
// 新增,若有調度函數則調用調度函數,傳入副作用函數
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn)
} else {
// 否則直接執行副作用函數
effectFn()
}
})
}
const data = { foo: 1, bar: 2 }
const obj = new Proxy(data, {
get(target, key) {
track(target, key);
return target[key]
},
set(target, key, newValue) {
target[key] = newValue
trigger(target, key);
}
})
// 計算屬性
function computed(getter) {
// value用來緩存上一次計算的值
let value;
// dirty標誌,用來識別是否需要重新計算,true則表示需要計算
let dirty = true;
// 把getter作為副作用函數,其實getter就是fn
// 在options中添加lazy
const effectFn = effect(getter, {
lazy: true,
// 添加調度器,在調度器中將dirty的狀態修改為true
scheduler() {
if (!dirty) {
dirty = true
// 當計算屬性以來的響應式數據變化時,手動調用trigger函數觸發響應
// trigger(obj, 'value') // effect嵌套計算屬性時
}
}
})
const obj = {
// 讀取value的時候才會執行effectFn
get value() {
// dirty為true時執行計算
if (dirty) {
// 將計算值緩存
value = effectFn()
// 將dirty設置為false,下一次訪問則直接拿緩存的value值
dirty = false
}
// 當讀取value時,手動調用track函數進行追蹤
// track(obj, 'value') // effect嵌套計算屬性時
// 返回副作用函數結果
return value
}
}
return obj
}
驗證
const sumRes = computed(() => obj.foo + obj.bar)
console.log(sumRes.value);
// 修改obj.foo
obj.foo++
console.log(sumRes.value);
console.log(sumRes.value);
console.log(sumRes.value);
計算屬性到這裏基本上是完善了,但是還有一個小的問題,那就是在另外一個副作用函數effect內訪問計算屬性值,如下:
const sumRes = computed(() => obj.foo + obj.bar)
effect(() => {
// 在另一個副作用函數內部讀取計算屬性的值
console.log(sumRes.value);
})
// 修改obj.foo
obj.foo++
我們修改obj.foo並不會觸發另一個副作用函數的執行
這個問題就是一個典型的effect嵌套
計算屬性內部擁有自己的effect,並且是懶執行的且只有真正讀取時才會執行
對於計算屬性的getter函數來説,它裏面訪問的響應式數據只會把computed內部的effect收集為依賴
而當計算屬性被另一個effect嵌套時,外層的effect就不會被computed內層的effect中的響應式數據收集
解決辦法:當讀取計算屬性的值時,手動調用track函數進行跟蹤,當計算屬性依賴的響應式數據變化時,我們可以手動調用trigger函數觸發響應
function computed(getter) {
// value用來緩存上一次計算的值
let value;
// dirty標誌,用來識別是否需要重新計算,true則表示需要計算
let dirty = true;
// 把getter作為副作用函數,其實getter就是fn
// 在options中添加lazy
const effectFn = effect(getter, {
lazy: true,
// 添加調度器,在調度器中將dirty的狀態修改為true
scheduler() {
if (!dirty) {
dirty = true
// 當計算屬性以來的響應式數據變化時,手動調用trigger函數觸發響應
trigger(obj, 'value')
}
}
})
const obj = {
// 讀取value的時候才會執行effectFn
get value() {
if (dirty) {
value = effectFn()
// 將dirty設置為false,下一次訪問則直接拿緩存的value值
dirty = false
}
// 當讀取value時,手動調用track函數進行追蹤
track(obj, 'value')
// 返回副作用函數結果
return value
}
}
return obj
}
當讀取計算屬性value值時,手動調用track函數,把計算屬性返回的對象obj作為target,同時作為第一個參數傳遞給track函數
當計算屬性所依賴的響應式數據變化時會執行調度器函數,在調度器函數內手動調用trigger函數觸發響應,如下:
effect(function effectFn() {
console.log(sumRes.value);
})
它會建立這樣的聯繫
computed(obj) -> value -> effectFn
完整代碼
let activeEffect;
// 棧
const effectStack = []
function effect(fn, options = {}) {
const effectFn = () => {
// 利用 cleanup 函數清除 effectFn 中存儲的副作用函數
cleanup(effectFn);
// 當effectFn執行時,activeEffect保存當前effectFn副作用函數
activeEffect = effectFn
// 在調用副作用函數之前將當前副作用函數壓入棧中
effectStack.push(effectFn);
// 將fn的執行結果存儲到res中-新增
const res = fn();
// 當前副作用函數執行完畢後,將當前副作用函數彈出棧,並把activeEffect還原為之前的值
effectStack.pop();
// activeEffect還原為之前的值
activeEffect = effectStack[effectStack.length - 1]
// 將res作為effectFn的返回值-新增
return res
}
// 將options掛載到effectFn上
effectFn.options = options // 新增
// 定義一個deps數組,用來收集與該副作用函數相關的依賴集合
effectFn.deps = []
// 只有非lazy的時候才執行
if (!options.lazy) {
// 執行副作用函數
effectFn()
}
// 將副作用函數作為返回值返回
return effectFn
}
const jobQueue = new Set();
// 定義一個任務隊列執行函數,這是一個Promise的微任務,並且是resolve狀態,調用後可以直接.then
const p = Promise.resolve();
// 定義一個變量來標記是否正在刷新隊列
let isFlushing = false;
function flushJob() {
// 如果正在刷新隊列,則什麼都不做
if (isFlushing) return
// 當任務隊列中有任務時,將isFlushing設置為true,表示正在刷新隊列
isFlushing = true
// 在微任務隊列中刷新jobQueue任務隊列
p.then(() => {
jobQueue.forEach(job => job())
}).finally(() => {
// 刷新完成後將isFlushing設置為false
isFlushing = false
})
}
// 重置依賴關係
function cleanup(effectFn) {
// 遍歷 effectFn.deps 數組
for (let i = 0; i < effectFn.deps.length; i++) {
// deps 是依賴集合
const deps = effectFn.deps[i]
// 將 effectFn 從依賴集合中移除
// 也就是在依賴收集的時候的deps,這個deps其實就是key對應的Set集合
// deps.delete(effectFn)也就是刪除key對應的Set集合中的activeEffect
deps.delete(effectFn)
}
// 最後重置 effectFn.deps 數組
effectFn.deps.length = 0
}
// 存儲副作用函數的桶
const bucket = new WeakMap()
function track(target, key) {
// 沒有activeEffect,直接return
if (!activeEffect) return target[key]
let depsMap = bucket.get(target)
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
let deps = depsMap.get(key)
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
// 把當前激活的副作用函數添加到依賴集合中:deps -> Set構造函數
// 把activeEffect當作依賴收集給deps
deps.add(activeEffect)
// deps就是與當前副作用函數存在聯繫的依賴集合
// 把依賴集合添加到activeEffect.deps數組中 -> effectFn.deps
activeEffect.deps.push(deps)
}
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
// 獲取依賴集合effects[] -> activeEffect.deps[] -> effectFn.deps
const effects = depsMap.get(key)
// 用一個新Set保存依賴集合,直接使用effects會出現無限循環
// 因為effectFn函數內部調用了cleanup函數在執行刪除操作,而track函數內部的deps.add(activeEffect)又在新增,導致無限循環
const effectsToRun = new Set()
effects && effects.forEach(effectFn => {
// 如果trigger觸發執行的副作用函數與當前正在執行的副作用函數不同則執行,否則不執行
if (effectFn !== activeEffect) {
// 收集依賴
effectsToRun.add(effectFn)
}
})
// 調用effectFn函數,並且effectFn函數內部會調用cleanup重置依賴關係
effectsToRun.forEach(effectFn => {
// 新增,若有調度函數則調用調度函數,傳入副作用函數
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn)
} else {
// 否則直接執行副作用函數
effectFn()
}
})
}
const data = { foo: 1, bar: 2 }
const obj = new Proxy(data, {
get(target, key) {
track(target, key);
return target[key]
},
set(target, key, newValue) {
target[key] = newValue
trigger(target, key);
}
})
// 計算屬性
function computed(getter) {
// value用來緩存上一次計算的值
let value;
// dirty標誌,用來識別是否需要重新計算,true則表示需要計算
let dirty = true;
// 把getter作為副作用函數,其實getter就是fn
// 在options中添加lazy
const effectFn = effect(getter, {
lazy: true,
// 添加調度器,在調度器中將dirty的狀態修改為true
scheduler() {
if (!dirty) {
dirty = true
// 當計算屬性以來的響應式數據變化時,手動調用trigger函數觸發響應
trigger(obj, 'value') // effect嵌套計算屬性時
}
}
})
const obj = {
// 讀取value的時候才會執行effectFn
get value() {
// dirty為true時執行計算
if (dirty) {
// 將計算值緩存
value = effectFn()
// 將dirty設置為false,下一次訪問則直接拿緩存的value值
dirty = false
}
// 當讀取value時,手動調用track函數進行追蹤
track(obj, 'value') // effect嵌套計算屬性時
// 返回副作用函數結果
return value
}
}
return obj
}
驗證
const sumRes = computed(() => obj.foo + obj.bar)
effect(() => {
// 在另一個副作用函數內部讀取計算屬性的值
console.log(sumRes.value);
})
obj.foo++
參考資料:vue.js設計與實現
本篇文章為霍春陽老師的《vue.js設計與實現》的核心知識點總結,感興趣的朋友可以支持原版書籍。