我們平時可能不太關注"競態"的問題,但是你在日常的工作中可能或多少遇到過"競態"
let finalData;
watch(obj, async () => {
// 發送一個請求
let res = await fetch('/api/request');
// 將數據保存到finalData
finalData = res;
})
這段代碼看起來沒問題,實際上會發生競態的問題
假如我們第一次修改obj觸發watch,發送一個請求A
然後我們在請求A還沒有返回的時候,我們又修改了obj,又發送了一個請求B
這個時候就會發生競態的問題,最終finalData的值可能是請求B返回的值,也可能是請求A返回的值
如果B請求返回的速度比A請求快,那麼B請求先返回,A請求後返回,我們下意識會認為finalData的值是請求B返回的值
此時實際上finalData的值是請求A返回的值,那麼最終我們會拿到一個錯誤的結果
這種情況下, 我們可以把A請求看成一個過期的請求,B請求後發生,B請求的結果應該被作為最新的
我們需要一個讓副作用過期的手段,在vue.js中, watch函數的回調函數接收第三個參數onInvalidate,它是一個函數,類似於事件監聽器,我們可以在onInvalidate函數中註冊一個回調,這個回調函數會在當前副作用函數過期時執行
watch(obj, async (newValue, oldValue, onInvalidate) => {
// 定義一個標誌,表示當前副作用函數是否過期,默認false(沒過期)
let expired = false
// 調用onInvalidate()函數註冊一個過期回調
onInvalidate(() => {
// 將expired設置為true,表示當前副作用函數過期
expired = true
})
// 發送一個請求
let res = await fetch('/api/request');
// 只有當該副作用函數的執行沒有過期時,才會執行後續操作
if (!expired) {
finalData = res;
}
})
在上面的代碼中,我們定義了一個expired變量,表示當前副作用函數是否過期
然後調用onInvalidate函數註冊一個過期回調,在過期回調中,我們將expired設置為true,表示當前副作用函數過期
最後只有當沒過期是才採用請求結果更新finalData的值,這樣就避免了競態的問題
具體是怎麼實現的呢?
其實就是在watch內部每次檢測到變更後,在副作用函數重新執行之前,先調用我們通過onInvalidate函數註冊的過期回調,僅此而已
function watch(source, cb, options = {}) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
// 定義舊值和新值
let oldValue, newValue
// cleanup用來存儲用户註冊的過期回調
let cleanup
// 定義onInvalidate函數
function onInvalidate(fn) {
// 將過期回調存儲到cleanup中
// 這裏在watch調用時,用户會傳入一個過期回調,我們把這個回調函數存儲到cleanup中
cleanup = fn
}
// 提取scheduler調度函數為一個獨立的job函數
const job = () => {
newValue = effectFn()
// 在調用回調函數cb之前,先調用過期回調
// 第二次觸發watch時,由於第一次觸發存儲了一個過期回調,所以第二次觸發時,會先執行過期回調
if (cleanup) {
// 第二次觸發watch,執行傳入的過期回調,expired = true
cleanup()
}
// 將onInvalidate作為回調函數的第三個參數,以便用户使用
cb(newValue, oldValue, onInvalidate)
oldValue = newValue
}
// 使用effect註冊副作用函數時,開啓lazy選項,並把返回值存儲到effectFn中以便後續手動調用
const effectFn = effect(
() => getter(),
{
lazy: true,
scheduler: () => {
// 在調度函數中判斷flush是否為'post',如果是,將其放到微任務隊列中執行
if (options.flush === 'post') {
const p = Promise.resolve()
p.then(job)
} else {
job()
}
}
}
)
if (options.immediate) {
// 當immediate為true時立即執行job,從而觸發執行回調函數
// 否則通過調度函數在數據變化是調用
job()
}
// 手動調用副作用函數,拿到的值就是舊值
oldValue = effectFn()
}
在上面的代碼中,我們首先定義了cleanup變量,這個變量用來存儲用户通過onInvalidate函數註冊的過期回調
可以看到onInvalidate函數的實現非常簡單,只是把過期回調賦值給了cleanup變量
關鍵點在job函數內,每次執行回調函數cb之前,先檢查是否存在過期回調,如果存在,則執行過期回調函數cleanup
最後我們把onInvalidate函數作為會回調函數的第三個參數傳遞給cb,以便用户使用
我們還是通過一個例子來進一步説明
watch(
obj,
// 這裏其實是cb的回調,也就是watch最後執行的一步
async (newValue, oldValue, onInvalidate) => {
let expired = false
onInvalidate(() => {
expired = true
})
const res = await fetch('/api/request')
if (!expired) {
finalData = res
}
}
)
// 第一次修改
obj.foo++ // 需要花1000ms
setTimeout(() => {
// 第二次修改
obj.foo++
}, 200);
上述代碼我們修改了兩次obj.foo的值,第一次修改是會立即執行的,這會導致watch的回調函數執行
由於我們在回調函數內調用了onInvalidate,所以會註冊一個過期回調,接着發送請求A
假設A請求需要1000ms才會返回,而B請求在200ms後就修改了,這會導致watch回調函數執行
這時要注意,在現實中,我們每次執行回調函數之前要先檢查過期回調是否存在,如果存在,會優先執行過期回調
由於在watch的回調函數是第一次執行的時候,我們已經註冊了一個過期回調
所以在watch的回調函數第二次執行前,會優先執行之前註冊的過期回調,這會使得第一次執行的副作用函數內的閉包變量expried的值變為true
也就是副作用函數的執行過期了,所以請求A的結果返回時,其結果會被拋棄,從而避免了過期的副作用函數帶來的影響。
完整代碼
// 把當前執行的副作用函數存儲在一個全局變量中
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 watch(source, cb, options = {}) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
// 定義舊值和新值
let oldValue, newValue
// cleanup用來存儲用户註冊的過期回調
let cleanup
// 定義onInvalidate函數
function onInvalidate(fn) {
// 將過期回調存儲到cleanup中
// 這裏在watch調用時,用户會傳入一個過期回調,我們把這個回調函數存儲到cleanup中
cleanup = fn
}
// 提取scheduler調度函數為一個獨立的job函數
const job = () => {
newValue = effectFn()
// 在調用回調函數cb之前,先調用過期回調
// 第二次觸發watch時,由於第一次觸發存儲了一個過期回調,所以第二次觸發時,會先執行過期回調
if (cleanup) {
// 第二次觸發watch,執行傳入的過期回調,expired = true
cleanup()
}
// 將onInvalidate作為回調函數的第三個參數,以便用户使用
cb(newValue, oldValue, onInvalidate)
oldValue = newValue
}
// 使用effect註冊副作用函數時,開啓lazy選項,並把返回值存儲到effectFn中以便後續手動調用
const effectFn = effect(
() => getter(),
{
lazy: true,
scheduler: () => {
// 在調度函數中判斷flush是否為'post',如果是,將其放到微任務隊列中執行
if (options.flush === 'post') {
const p = Promise.resolve()
p.then(job)
} else {
job()
}
}
}
)
if (options.immediate) {
// 當immediate為true時立即執行job,從而觸發執行回調函數
// 否則通過調度函數在數據變化是調用
job()
}
// 手動調用副作用函數,拿到的值就是舊值
oldValue = effectFn()
}
function traverse(value, seen = new Set()) {
// 如果要讀取的數據是原始值,或者已經被讀取過了,那麼什麼都不做
if (typeof value !== 'object' || value === null || seen.has(value)) return
// 將數據添加到seen中,代表遍歷讀取過了,避免循環引用引起的死循環
seen.add(value)
// 暫時不考慮數組等其他結構
// 假設value是一個對象,使用for...in遍歷對象,並遞歸對traverse進行處理
// 通過遍歷+遞歸的方式就可以依次讀取到對象的所有屬性,在監聽的時候,若有數據變化,就會觸發scheduler調度器執行回調函數
for (const k in value) {
// 首次進入時seen肯定是空值,通過遍歷遞歸的方式我們會把依次把數據全都加到seen中
traverse(value[k], seen)
}
return value
}
驗證
// 驗證
watch(
obj,
// 這裏其實是cb的回調,也就是watch最後執行的一步
async (newValue, oldValue, onInvalidate) => {
let expired = false
onInvalidate(() => {
expired = true
})
const res = await fetch('/api/request')
if (!expired) {
finalData = res
}
}
)
// 第一次修改
obj.foo++ // 需要花1000ms
setTimeout(() => {
// 第二次修改
obj.foo++
}, 200);
主要是搞清楚它們的執行順序
第一次執行就是第一次的
第二次執行就是第二次的
第一次執行慢(2秒返回),第二次執行快(1秒返回)
即使第二次執行再快,它也是最新的值
第一次執行把onInvalidate存入,第二次執行把onInvalidate調用
第一次執行expired:false,第二次執行expired:true
第二次執行的時候,會執行onInvalidate回調將expired設置為true
由於第一次執行的await是2秒,所以第二次執行的時候調用onInvalidate把第一次的expired設置為true,所以第一次的請求就會被拋棄
onInvalidate函數是下一次請求時才會執行的回調,第一次是不會觸發的
從這個角度看,如果watch只走一次,值是正確的
從這個角度看,如果watch只走一次,值是正確的
參考資料:vue.js設計與實現
本篇文章為霍春陽老師的《vue.js設計與實現》的核心知識點總結,感興趣的朋友可以支持原版書籍。