動態

詳情 返回 返回

《vue.js設計與實現》——watch的實現原理 - 動態 詳情

簡易watch監聽

watch其實就是監聽給定的響應式數據變化,當數據變化時執行的回調函數

watch(obj, () => {
    console.log("數據變化了");
})
// 修改obj數據,watch自動觸發
obj.foo++

實際上,watch就是利用了effect以及options.scheduler選項

effect(
    () => {
        console.log(obj.foo);
    },
    {
        scheduler(fn) {
            console.log("數據變化了,執行scheduler調度函數");
        }
    }
)

在之前的例子中,我們實現了一個特性,當響應式數據發生修改時會通過觸發scheduler調度器重新執行副作用函數
從這個角度來看,scheduler調度器其實是一個回調函數,watch就是利用這點來實現觸發的
watch函數接收兩個參數,source為響應式數據,cb是回調函數

function watch(source, cb) {
    effect(
        () => source.foo,
        {
            scheduler(cb) {
                // 當數據變化時,執行回調函數cb
                cb()
            }
        }
    )
}

// 使用watch
const data = { foo: 1 }
const obj = new Proxy(data, {}); // 為了演示,省掉了代理詳情
watch(obj, () => {
    console.log("數據變化了");
})
obj.foo++

上面的代碼中,修改obj.foo就可以監聽響應數據變化了,但是我們只對source.foo進行了監聽,我們需要修改它,讓它變成一個通用監聽方式,不侷限與某一屬性

通用對象監聽

function watch(source, cb) {
    effect(
        // 調用 traverse 遞歸讀取所有屬性,讀取完後返回source對象
        // 這一步時是為了讀取source內的所有屬性
        () => traverse(source),
        {
            scheduler(cb) {
                // 當數據變化時,執行回調函數cb
                cb()
            }
        }
    )
}
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
}

在上面的代碼中,我們封裝了一個traverse通用方法用來讀取對象的所有屬性,這樣就替代了之前的source.foo,不在侷限與某一屬性

支持getter函數監聽

watch除了可以監聽響應式數據外,還可以接收一個getter函數

watch(
    // getter函數
    () => obj.foo,
    // 回調函數
    () => {
        console.log("obj.foo數據變化了");

    }
)

在上面的代碼中,watch第一個值是一個getter函數,在getter函數內部,用户可以指定依賴哪些響應式數據,這裏指定的就是obj.foo,當obj.foo的值發生變化時,就會觸發回調函數

function watch(source, cb) {
    // 定義getter
    let getter
    // 如果source是函數,説明傳遞的是getter,直接把source賦值給getter
    if (typeof source === 'function') {
        getter = source
    } else {
        // 否則按照原來的實現調用traverse遞歸讀取
        getter = () => traverse(source)
    }

    effect(
        // 執行getter
        () => getter(),
        {
            scheduler(cb) {
                // 當數據變化時,執行回調函數cb
                cb()
            }
        }
    )
}

通過上面的方式靈巧的區分了source究竟為響應式數據還是函數類型
我們通過getter創建一箇中間區,如果是函數類型則直接調用getter,若是響應式類型則使用遍歷+遞歸的方式讀取,這樣就實現了自定義getter的功能

獲得新值和舊值

vue.js中,watch會返回一個變化前和變化後的值,這個是如何實現的呢?
這需要充分利用effect函數的lazy選項

function watch(source, cb) {
    let getter
    if (typeof source === 'function') {
        getter = source
    } else {
        getter = () => traverse(source)
    }
    // 定義舊值和新值
    let oldValue, newValue
    // 使用effect註冊副作用函數時,開啓lazy選項,並把返回值存儲到effectFn中以便後續手動調用
    const effectFn = effect(
        () => getter(),
        {
            lazy: true,
            scheduler() {
                // 在scheduler中重新執行副作用函數,得到的就是新值
                newValue = effectFn()
                // 將舊值和新值作為回調函數的參數
                cb(oldValue, newValue)
                // 更新舊值,不然下一次會得到錯誤的舊值,因為下一次執行時,上次的新值就變成了舊值
                oldValue = newValue
            }
        }
    )
    // 手動調用副作用函數,拿到的值就是舊值
    oldValue = effectFn()
}

在上面代碼中,最核心的就是使用lazy創建了懶執行的effect
在最後我們手動調用effectFn函數得到的返回值就是舊值,也就是第一次執行得到的值。
當變化發生並觸發scheduler調度函數執行時,會重新調用effectFn函數並得到新值,這樣我們就拿到了舊值與新值,接着將它們作為參數傳遞給回調函數cb就可以了。
最後一定要更新舊值(oldValue = newValue),不然下一次會得到錯誤的舊值,因為下一次執行時,上次的新值就變成了舊值。

驗證

完整代碼

// 把當前執行的副作用函數存儲在一個全局變量中
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) {
    let getter
    if (typeof source === 'function') {
        getter = source
    } else {
        getter = () => traverse(source)
    }
    // 定義舊值和新值
    let oldValue, newValue
    // 使用effect註冊副作用函數時,開啓lazy選項,並把返回值存儲到effectFn中以便後續手動調用
    const effectFn = effect(
        () => getter(),
        {
            lazy: true,
            scheduler() {
                // 在scheduler中重新執行副作用函數,得到的就是新值
                newValue = effectFn()
                // 將舊值和新值作為回調函數的參數
                cb(oldValue, newValue)
                // 更新舊值,不然下一次會得到錯誤的舊值,因為下一次執行時,上次的新值就變成了舊值
                oldValue = newValue
            }
        }
    )
    // 手動調用副作用函數,拿到的值就是舊值
    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.foo,
    (newValue, oldValue) => {
        console.log(`執行了watch回調函數,新值為${newValue},舊值為${oldValue}`);

    }
)
obj.foo++;
obj.foo++;


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

user avatar codepencil 頭像 erin_5f911ffcecd4e 頭像 toopoo 頭像
點贊 3 用戶, 點贊了這篇動態!
點贊

Add a new 評論

Some HTML is okay.