簡易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設計與實現》的核心知識點總結,感興趣的朋友可以支持原版書籍。