動態

詳情 返回 返回

《vue.js設計與實現》——過期的副作用 - 動態 詳情

我們平時可能不太關注"競態"的問題,但是你在日常的工作中可能或多少遇到過"競態"

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
由於第一次執行的await2秒,所以第二次執行的時候調用onInvalidate把第一次的expired設置為true,所以第一次的請求就會被拋棄

onInvalidate函數是下一次請求時才會執行的回調,第一次是不會觸發的
從這個角度看,如果watch只走一次,值是正確的
從這個角度看,如果watch只走一次,值是正確的


參考資料:vue.js設計與實現
本篇文章為霍春陽老師的《vue.js設計與實現》的核心知識點總結,感興趣的朋友可以支持原版書籍。

user avatar wangyiyunyidun 頭像 savokiss 頭像
點贊 2 用戶, 點贊了這篇動態!
點贊

Add a new 評論

Some HTML is okay.