調度性的定義:當我們執行trigger動作觸發副作用函數重新執行時,有能力決定副作用函數的時機、次數以及方式
控制執行時機
先來看看如何決定副作用函數的執行方式
const data = { foo: 1 }
const obj = new Proxy(data, {}); // 為了演示,省略代理配置
effect(() => {
console.log(obj.foo);
})
obj.foo++
console.log('結束了');
在上面的代碼中,會先調用副作用函數執行obj.foo,然後執行自增操作,在自增操作的過程中會觸發effect的再次調用,最後打印結束了
所以結果應該是 :1、2、結束了
但是我們希望的結果是:1、結束了、2
這個時候如何實現不調整代碼結構的情況下實現呢?
其實我們思考一下,在JS中,關於任務的執行無非就是兩個維度,同步任務、微任務;同步任務會有限執行,微任務會在同步任務執行完後執行。有了這個想法之後我們就可以着手實現了
此時我們需要對副作用函數設置一個選項參數options(這個參數其實就是一個對象,在裏面寫一些配置啥的來控制一些流程),比如允許用户指定調度器
effect(
() => {
console.log(obj.foo);
},
// 選項參數options
{
// 設置調度器
scheduler(fn) {
// 省略其它...
}
}
)
這樣,用户在調用副作用函數的時候可以傳入第二個參數,這個參數是一個對象,對象中可以設置scheduler屬性
當用户設置了scheduler屬性時,在effect函數內部我們就需要把options選項掛載到對應副作用函數上
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 = []
effectFn()
}
有了調度函數,我們就可以在trigger函數執行副作用時調用用户傳入的調度器函數,控制權就在用户手上了
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 }
const obj = new Proxy(data, {})
effect(
() => {
console.log(obj.foo);
},
// 選項參數options
{
// 設置調度器
scheduler(fn) {
// 將函數放入宏任務隊列執行
setTimeout(fn)
}
}
)
obj.foo++
console.log('結束了');
最後執行的結果就是:1、結束了、2
這裏其實就是把修改obj.foo關聯trigger的副作用函數執行交給調度函數執行了,在調度器在中把副作用函數放在了異步任務隊列,所以同步會先執行:1、結束了,異步後執行:2
控制執行次數
除了控制它的執行順序外,我們還需要能夠控制他們的執行次數,這也是調度函數非常重要的一個功能
effect(
() => {
console.log(obj.foo);
}
)
obj.foo++
obj.foo++
上面的代碼中,按照正常順序執行的結果應該是: 1、2、3
其實相同的操作,中間的2作為過渡可以省略掉,只需要輸出1、3即可,中間2的打印是多餘的
這個時候我們就需要使用一個變量來記錄副作用函數是否正在執行,如果正在執行則什麼都不做,隊列執行完後才把阻塞放開
我們要對調度器稍作修改
// 定義一個任務隊列
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
})
}
// 修改副作用函數
effect(
() => {
console.log(obj.foo);
},
// 選項參數options
{
// 設置調度器
// 在修改的時候執行trigger函數,在其內部調用調度器函數並傳入副作用函數
scheduler(fn) {
// 每次調度時,將副作用函數添加到任務隊列中
jobQueue.add(fn)
// 調用flushJob刷新隊列
flushJob() // 修改操作的時候,啓動隊列任務刷新
}
}
)
obj.foo++
obj.foo++
流程解釋:
在上面的代碼中,我們會首先打印obj.foo:1,然後obj.foo++,此時觸發trigger函數,在trigger函數內部會判斷調度器是否存在,若調度器存在則將副作用函數傳入調度器中,調度器函數會將副作用函數收集到任務隊列,然後開始執行flushJob函數,啓動隊列任務刷新,flushJob函數內部會首先判斷isFlushing變量,初始的時候為false,所以剛進入時會執行一次,執行前把isFlushing設置為true,然後執行Promise的微任務,微任務執行完後會執行finally中的函數恢復isFlushing為false
整段代碼的效果:
連續對obj.foo執行自增操作,會同步執行兩次scheduler調度函數,也就是説任務隊列jobQueue會被添加兩次,但是由於Set函數具有自動去重能力,所以objQueue中只會存在一個副作用函數,也就是當前執行的副作用函數,並且flushJob函數也會執行兩次,但是由於isFlushing變量的存在,所以只會執行一次微任務,只有隊列的任務執行完畢後,isFlushing變量才會被恢復為false,由於jobQueue隊列中只有一個副作用函數,所以只會執行一次,當它執行的時候obj.foo已經變為3了,所以最終的輸出就會變為:1、3
在這裏一定要注意幾點:
● obj.foo++的自增操作是同步任務,所以會優先執行
● jobQueue.forEach(job => job())這一行實際上是在Promise.resolve()這個微任務內執行的
所以,我們剛開始會輸出一次1,而後連續修改obj.foo++,修改關聯的副作用函數會被放入微任務隊列,當微任務隊列執行完畢時,同步任務的obj.foo++已經執行到3了,所以最後obj.foo的輸出就是3
其實在vue.js中連續多次修改同一個響應式數據只會觸發一次更新,跟這個思路是相同,只不過vue.js做了一個更完善的調度器
完整代碼
// 驗證
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 = []
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 }
const obj = new Proxy(data, {
get(target, key) {
track(target, key);
return target[key]
},
set(target, key, newValue) {
target[key] = newValue
trigger(target, key);
}
})
// 驗證
effect(
() => {
console.log(obj.foo);
},
// 選項參數options
{
// 設置調度器
// 在修改的時候執行trigger函數,在其內部調用調度器函數並傳入副作用函數
scheduler(fn) {
// 每次調度時,將副作用函數添加到任務隊列中
jobQueue.add(fn)
// 調用flushJob刷新隊列
flushJob() // 修改操作的時候,啓動隊列任務刷新
}
}
)
// 控制執行時機的驗證
// obj.foo++
// console.log('結束了');
// 控制執行次數的驗證
obj.foo++
obj.foo++
參考資料:vue.js設計與實現
本篇文章為霍春陽老師的《vue.js設計與實現》的核心知識點總結,感興趣的朋友可以支持原版書籍。