watch的本質其實是對effect的二次封裝
watch的兩個特性:
1、立即執行的回調函數
2、回調函數的執行時機
立即執行的回調函數
在vue.js中,watch通過immediate屬性來實現立即執行,如下
watch(obj, () => {
console.log('變化');
}, { immediate: true })
當immediate存在並且為true的時候,回調函數會在watch創建時立即執行一次
這個立即執行實際上與後續的監聽執行本質上沒區別,只是執行時機的問題
我們可以把scheduler調度函數封裝為一個通用函數,分別在初始化和變更時執行它
function watch(source, cb) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
// 定義舊值和新值
let oldValue, newValue
// 提取scheduler調度函數為一個獨立的job函數
const job = () => {
newValue = effectFn()
cb(newValue, oldValue)
oldValue = newValue
}
// 使用effect註冊副作用函數時,開啓lazy選項,並把返回值存儲到effectFn中以便後續手動調用
const effectFn = effect(
() => getter(),
{
lazy: true,
// 使用 job 函數作為調度器函數
scheduler: job
}
)
if (options.immediate) {
// 當immediate為true時立即執行job,從而觸發執行回調函數
// 否則通過調度函數在數據變化是調用
job()
}
// 手動調用副作用函數,拿到舊值
oldValue = effectFn()
}
這樣就實現了回調函數的立即執行功能,由於第一次執行回調函數時沒有舊值,所以oldValue為undefined
回調函數的執行時機
除了立即執行外,我們還可以通過其它選項參數來指定回調函數執行時機
vue.js就是通過flush選項參數來指定回調函數的執行時機
flush有三個可選值:'pre'、'post'、'sync'
watch(
obj,
() => {
console.log('變化');
},
{
// 回調函數會在watch創建時立即執行一次
flush: 'pre' // 還可以指定為 'post' 和 'sync'
}
)
flush本質是指定調度函數的執行時機
在計算屬性computed這一節中,我們知道了如何在為任務中執行調度函數scheduler,這與flush功能相同
當flush值為post時,表示調度函數需要將副作用函數放到一個微任務隊列中,等待DOM更新結束後再執行,如下:
function watch(source, cb, options = {}) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
// 定義舊值和新值
let oldValue, newValue
// 提取scheduler調度函數為一個獨立的job函數
const job = () => {
newValue = effectFn()
cb(newValue, oldValue)
oldValue = newValue
}
// 使用effect註冊副作用函數時,開啓lazy選項,並把返回值存儲到effectFn中以便後續手動調用
const effectFn = effect(
() => getter(),
{
lazy: true,
// 使用 job 函數作為調度器函數
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()
}
在上面代碼中,我們在scheduler內檢測options.flush的值是否為'post',如果是,就將job函數放到微任務隊列中執行,否則直接執行job函數(本質上相當於'sync'的實現機制,也就是同步執行)
對於'pre'我們暫時還無法模擬,因為它涉及到組件更新時機,'pre'和'post'原本的語義就是組件的更新前和更新後
我們這裏明白如何控制回調函數的更新時機就可以了
驗證
完整代碼
// 把當前執行的副作用函數存儲在一個全局變量中
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
// 提取scheduler調度函數為一個獨立的job函數
const job = () => {
newValue = effectFn()
cb(newValue, oldValue)
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.foo,
(newValue, oldValue) => {
console.log(`執行了watch回調函數,新值為${newValue},舊值為${oldValue}`);
},
{
immediate: true,
}
)
obj.foo++
console.log('同步執行');
watch(
() => obj.foo,
(newValue, oldValue) => {
console.log(`執行了watch回調函數,新值為${newValue},舊值為${oldValue}`);
},
{
flush: 'post'
}
)
obj.foo++
console.log('同步執行');
參考資料:vue.js設計與實現
本篇文章為霍春陽老師的《vue.js設計與實現》的核心知識點總結,感興趣的朋友可以支持原版書籍。