動態

詳情 返回 返回

《vue.js設計與實現》——計算屬性computed與lazy - 動態 詳情

在深入瞭解計算屬性之前,我們需要先知道懶執行的effect,也就是lazyeffect
我們現在實現的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
}

在上面的新增代碼中可以看到,傳給effectfn才是真正的副作用執行函數
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.fooobj.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
}

在上面的代碼中,我們新增了兩個變量vlauedirty
value用來緩存副作用函數執行結果(上一次計算的值)
dirty表示是否需要重新計算
當我們通過sumRes.value訪問值時,只有dirtytrue才會調用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設計與實現》的核心知識點總結,感興趣的朋友可以支持原版書籍。

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

Add a new 評論

Some HTML is okay.