petite-vue原始碼剖析-逐行解讀@vue-reactivity之effect
當我們通過effect
將副函式向響應上下文註冊後,副作用函式內訪問響應式物件時即會自動收集依賴,並在相應的響應式屬性發生變化後,自動觸發副作用函式的執行。
// ./effect.ts export funciton effect<T = any>( fn: () => T, options?: ReactiveEffectOptions ): ReactiveEffectRunner { if ((fn as ReactiveEffectRunner).effect) { fn = (fn as ReactiveEffectRunner).effect.fn } const _effect = new ReactiveEffect(fn) if (options) { extend(_effect, options) if (options.scope) recordEffectScope(_effect, options.scope) } // 預設是馬上執行副作用函式收集依賴,但可通過lazy屬性延遲副作用函式的執行,延遲依賴收集。 if (!options || !options.lazy) { _effect.run() } // 型別為ReactiveEffectRunner的runner是一個繫結this的函式 const runner = _effect.run.bind(_effect) as ReactiveEffectRunner runner.effect = _effect return runner }
effect
函式的程式碼十分少,主要流程是
- 將基於副作用函式構建
ReactiveEffect
物件 - 若為預設模式則馬上呼叫
ReactiveEffect
物件的run
方法執行副作用函式。
不過這裡我們有幾個疑問
-
ReactiveEffectRunner
是什麼? -
ReactiveEffect
生成的物件究竟是什麼?顯然ReactiveEffect
的run
方法才是夢開始的地方,到底它做了些什麼? - 針對配置項
scope
,recordEffectScope
的作用?
ReactiveEffectRunner
是什麼?
// ./effect.ts // ReactiveEffectRunner是一個函式,而且有一個名為effect的屬性且其型別為RectiveEffect export interface ReactiveEffectRunner<T = any> { (): T effect: ReactiveEffect }
ReactiveEffect
生成的物件究竟是什麼?
// 用於記錄位於響應上下文中的effect巢狀層次數 let effectTrackDepth = 0 // 二進位制位,每一位用於標識當前effect巢狀層級的依賴收集的啟用狀態 export left trackOpBit = 1 // 表示最大標記的位數 const maxMarkerBits = 30 const effectStack: ReactiveEffect[] = [] let activeEffect: ReactiveEffect | undefined export class ReactiveEffect<T = any> { // 用於標識副作用函式是否位於響應式上下文中被執行 active = true // 副作用函式持有它所在的所有依賴集合的引用,用於從這些依賴集合刪除自身 deps: Dep[] = [] // 預設為false,而true表示若副作用函式體內遇到`foo.bar += 1`則無限遞迴執行自身,直到爆棧 allowRecurse?: boolean constructor( public fn: () => T, public scheduler: EffectScheduler | null = null, scope?: EffectScope | null ) { recordEffectScope(this, scope) } run() { /** * 若當前ReactiveEffect物件脫離響應式上下文,那麼其對應的副作用函式被執行時不會再收集依賴,並且其內部訪問的響應式物件發生變化時,也會自動觸發該副作用函式的執行 */ if (!this.active) { return this.fn() } // 若參與響應式上下文則需要先壓棧 if (!effectStack.includes(this)) { try { // 壓棧的同時必須將當前ReactiveEffect物件設定為活躍,即程式棧中當前棧幀的意義。 effectStack.push(activeEffect = this) enableTracking() trackOpBit = 1 << ++effectTrackDepth if (effectTrackDepth <= maxMarkerBits) { // 標記已跟蹤過的依賴 initDepMarkers(this) } else { cleanupEffect(this) } return this.fn() } finally { if (effectTrackDepth <= maxMarkerBits) { /** * 用於對曾經跟蹤過,但本次副作用函式執行時沒有跟蹤的依賴,採取刪除操作。 * 即,新跟蹤的 和 本輪跟蹤過的都會被保留。 */ finalizeDepMarkers(this) } trackOpBit = 1 << --effectTrackDepth resetTracking() // 最後當然彈棧,把控制權交還給上一個棧幀咯 effectStack.pop() const n = effectStack.length activeEffect = n > 0 ? effectStack[n - 1] : undefined } } /** * 讓當前ReactiveEffect物件脫離響應式上下文,請記住這是一去不回頭的操作哦! */ stop() { if (this.active) { cleanupEffect(this) this.active = false } } } }
為應對巢狀effect
內部將當前位於響應上下文的ReactiveEffect物件壓入棧結構effectStack: ReactiveEffect[]
,噹噹前副作用函式執行後再彈出棧。另外,雖然我們通過effect
函式將副作用函式註冊到響應上下文中,但我們仍能通過呼叫stop
方法讓其脫離響應上下文。
function cleanupEffect(effect: ReactiveEffect) {
const { deps } = effect
if (deps.length) {
// 將當前ReactiveEffect物件從它依賴的響應式屬性的所有Deps中刪除自己,那麼當這些響應式屬性發生變化時則不會遍歷到當前的ReactiveEffect物件
for (let i = 0; i < deps.length; ++i) {
deps[i].delete(effect)
}
// 當前ReactiveEffect物件不再參與任何響應了
deps.length = 0
}
}
在執行副作用函式前和執行後我們會看到分別呼叫了enableTracking()
和resetTracking()
函式,它們分別表示enableTracking()
執行後的程式碼將啟用依賴收集,resetTracking()
則表示後面的程式碼將在恢復之前是否收集依賴的開關執行下去。要理解它們必須結合pauseTracking()
和實際場景說明:
let shouldTrack = true
const trackStack: boolean[] = []
export function enableTracking() {
trackStack.push(shouldTrack)
shouldTrack = true
}
export function resetTracking() {
const last = trackStack.pop()
shouldTrack = last === undefined ? true : last
}
export function pauseTracking() {
trackStack.push(shouldTrack)
shouldTrack = false
}
假設我們如下場景
const values = reactive([1,2,3])
effect(() => {
values.push(1)
})
由於在執行push
時內部會訪問代理物件的length
屬性,並修改length
值,因此會導致不斷執行該副作用函式直到丟擲異常Uncaught RangeError: Maximum call stack size exceeded
,就是和(function error(){ error() })()
不斷呼叫自身導致棧空間不足一樣的。而@vue/reactivity是採用如下方式處理
;(['push', 'pop', 'shift', 'unshift', 'splice'] as const).forEach(key => {
instrumentations[key] = function (this: unknown[], ...args: unknown[]) {
pauseTracking()
const res = (toRaw(this) as any)[key].apply(this, args)
resetTracking()
return res
}
})
即通過pauseTracking()
暫停push
內部的發生意外的依賴收集,即push
僅僅會觸發以其他形式依賴length
屬性的副作用函式執行。然後通過resetTracking()
恢復到之前的跟蹤狀態。
最後在執行副作用函式return this.fn()
前,居然有幾句難以理解的語句
try {
trackOpBit = 1 << ++effectTrackDepth
if (effectTrackDepth <= maxMarkerBits) {
initDepMarkers(this)
}
else {
cleanupEffect(this)
}
return this.fn()
}
finally {
if (effectTrackDepth <= maxMarkerBits) {
finalizeDepMarkers(this)
}
trackOpBit = 1 << --effectTrackDepth
}
我們可以將其簡化為
try {
cleanupEffect(this)
return this.fn()
}
finally {}
為什麼在執行副作用函式前需要清理所有依賴呢?我們可以考慮一下如下的情況:
const state = reactive({ show: true, values: [1,2,3] })
effect(() => {
if (state.show) {
console.log(state.values)
}
})
setTimeout(() => {
state.values.push(4)
}, 5000)
setTimeout(() => {
state.show = false
}, 10000)
setTimeout(() => {
state.values.push(5)
}, 15000)
一開始的時候副作用函式將同時依賴show
和values
,5秒後向values
追加新值副作用函式馬上被觸發重新執行,再過10秒後show
轉變為false
,那麼if(state.show)
無論如何運算都不成立,此時再對values
追加新值若副作用函式再次被觸發顯然除了佔用系統資源外,別無用處。
因此,在副作用函式執行前都會先清理所有依賴(cleanupEffect
的作用),然後在執行時重新收集。
面對上述情況,先清理所有依賴再重新收集是必須的,但如下情況,這種清理工作反而增加無謂的效能消耗
const state = reactive({ show: true, values: [1,2,3] })
effect(() => {
console.log(state.values)
})
@vue/reactivity給我們展示了一個非常優秀的處理方式,那麼就是通過標識每個依賴集合的狀態(新依賴和已經被收集過),並對新依賴和已經被收集過兩個標識進行對比篩選出已被刪除的依賴項。
優化無用依賴清理演算法
export type Dep = Set<ReactiveEffect> & Trackedmarkers
type TrackedMarkers = {
/**
* wasTracked的縮寫,採用二進位制格式,每一位表示不同effect巢狀層級中,該依賴是否已被跟蹤過(即在上一輪副作用函式執行時已經被訪問過)
*/
w: number
/**
* newTracked的縮寫,採用二進位制格式,每一位表示不同effect巢狀層級中,該依賴是否為新增(即在本輪副作用函式執行中被訪問過)
*/
n: number
}
export const createDep = (effects) => {
const dep = new Set<ReactiveEffect>(effects) as Dep
// 雖然TrackedMarkers標識是位於響應式物件屬性的依賴集合上,但它每一位僅用於表示當前執行的副作用函式是否曾經訪問和正在訪問該響應式物件屬性
dep.w = 0
dep.n = 0
return dep
}
export const wasTracked = (dep: Dep): boolean => (dep.w & trackOpBit) > 0
export const newTracked = (dep: Dep): boolean => (dep.n & trackOpBit) > 0
/**
* 將當前副作用函式的依賴標記為 `已經被收集`
*/
export const initDepMarkers = ({ deps }: ReactiveEffect) => {
if (deps.length) {
for (let i = 0; i < deps.length; i++) {
deps[i].w |= trackOpBit
}
}
}
/**
* 用於對曾經跟蹤過,但本次副作用函式執行時沒有跟蹤的依賴,採取刪除操作。
* 即,新跟蹤的 和 本輪跟蹤過的都會被保留。
*/
export const finalizeDepMarkers = (effect: ReactiveEffect) => {
const { deps } = effect
if (deps.length) {
let ptr = 0
for (let i = 0; i < deps.length; i++) {
const dep = deps[i]
if (wasTracked(dep) && !newTracked(dep)) {
// 對於曾經跟蹤過,但本次副作用函式執行時沒有跟蹤的依賴,採取刪除操作。
dep.delete(effect)
}
else {
// 縮小依賴集合的大小
deps[ptr++] = dep
}
// 將w和n中對應的巢狀層級的二進位制位置零,如果缺少這步後續副作用函式重新執行時則無法重新收集依賴。
dep.w &= ~trackOpBit
dep.n &= ~trackOpBit
}
// 縮小依賴集合的大小
deps.length = ptr
}
}
// 在位於響應式上下文執行的副作用函式內,訪問響應式物件屬性,將通過track收集依賴
export function track(target: object, type: TrackOpTypes, key: unknown) {
if (!isTracking()) {
return
}
// targetMap用於儲存響應式物件-物件屬性的鍵值對
// depsMap用於儲存物件屬性-副作用函式集合的鍵值對
let depsMap = targetMap.get(target)
if (!depsMap) {
target.set(target, (depsMap = new Map()))
}
let dep = depsMap.get(key)
if (!dep) {
depsMap.set(key, (dep = createDep()))
}
trackEffects(dep)
}
// 收集依賴
export function trackEffects(
dep: Dep
) {
let shouldTrack = false
if (effectTrackDepth <= maxMarkerBits) {
// 如果本輪副作用函式執行過程中已經訪問並收集過,則不用再收集該依賴
if (!newTracked(dep)) {
dep.n |= trackOpBit
shouldTrack = !wasTracked(dep)
}
}
else {
// 對於全面清理的情況,如果當前副作用函式對應的ReactiveEffect物件不在依賴集合中,則標記為true
shouldTrack = !dep.has(activeEffect!)
}
if (shouldTrack) {
dep.add(activeEffect!)
activeEffect!.deps.push(dep)
}
}
單單從程式碼實現角度能難理解這個優化方式,不如我們從實際的例子出發吧!
const runAync = fn => setTimeout(fn, 1000)
const state = reactive({ show: true, values: [1,2,3] })
// 1
effect(() => {
if (state.show) {
console.log(state.values)
}
})
// 2
runAync(() => {
state.values.push(4)
})
// 3
runAync(() => {
state.show = false
})
- 首次執行副作用函式
a.effectTrackDepth
為0,因此1 << ++effectTrackDepth
得到的effectTrackDepth
和trackOpBit
均為1,但由於此時副作用函式還沒有收集依賴,因此initDepMarkers
函式沒有任何效果;
b. 訪問state.show
時由於之前沒有收集過響應式物件state
的show
屬性,因此會呼叫createDep
建立w
和n
均為0的依賴集合,並呼叫trackEffects
發現newTracked(dep)
為未跟蹤過,則將n
設定為1,然後開始收集依賴;
c. 訪問state.values
會重複第2步的操作;
d. 由於state.show
和state.values
都是新跟蹤的(n
為1),因此在finalizeDepMarkers
處理後仍然將副作用函式保留在這兩個屬性對應的依賴集合中。 - 執行
state.values.push(4)
觸發副作用函式變化
a.effectTrackDepth
為0,因此1 << ++effectTrackDepth
得到的effectTrackDepth
和trackOpBit
均為1,此時副作用函式已經收集過依賴,因此initDepMarkers
將該副作用函式所在的依賴集合都都標記為已收集過(w
為1);
b. 訪問state.show
時會呼叫trackEffects
發現newTracked(dep)
為未跟蹤過(在finalizeDepMarkers
中已被置零),則將n
設定為1,然後開始收集依賴;
c. 訪問state.values
會重複第2步的操作;
d. 由於state.show
和state.values
都是新跟蹤的(n
為1),因此在finalizeDepMarkers
處理後仍然將副作用函式保留在這兩個屬性對應的依賴集合中。 - 執行
state.show = false
觸發副作用函式變化
a.effectTrackDepth
為0,因此1 << ++effectTrackDepth
得到的effectTrackDepth
和trackOpBit
均為1,此時副作用函式已經收集過依賴,因此initDepMarkers
將該副作用函式所在的依賴集合都都標記為已收集過(w
為1);
b. 訪問state.show
時會呼叫trackEffects
發現newTracked(dep)
為未跟蹤過(在finalizeDepMarkers
中已被置零),則將n
設定為1,然後開始收集依賴;
c. 由於state.values
沒有標記為新跟蹤的(n
為0),因此在finalizeDepMarkers
處理後會將副作用函式從state.values
對應的依賴集合中移除,僅保留在state.values
對應的依賴集合中。
到這裡,我想大家已經對這個優化有更深的理解了。那麼接下來的問題自然而然就是為什麼要硬編碼將優化演算法啟動的巢狀層級設定為maxMarkerBits = 30
?
SMI優化原理
首先maxMarkerBits = 30
表示僅支援effect巢狀31層,註釋中描述該值是因為想讓JavaScript影響使用SMI。那麼什麼是SMI呢?
由於ECMAScript標準約定number
數字需要轉換為64位雙精度浮點數處理,但所有數字都用64位儲存和處理是十分低效的,所以V8內部採用其它記憶體表示方式(如32位)然後向外提供64位表現的特性即可。其中數組合法索引範圍是[0, 2^32 - 2]
,V8引擎就是採用32位的方式來儲存這些合法的下標數字。另外,所有在[0, 2^32 - 2]
內的數字都會優先使用32位二進位制補碼的方式儲存。
針對32位有符號位範圍內的整型數字V8為其定義了一種特殊的表示法SMI
(非SMI
的數字則被定義為HeapNumber
),而V8引擎針對SMI啟用特殊的優化:當使用SMI內的數字時,引擎不需要為其分配專門的記憶體實體,並會啟用快速整型操作。
對於非SMI
的數字
let o = {
x: 42, // SMI
y: 4.2 // HeapNumber
}
記憶體結構為HeapNumber{ value: 4.2, address: 1 }
和JSObject{ x: 42, y: 1 }
,由於x值型別為SMI
因此直接儲存在物件上,而y為HeapNumber
則需要分配一個獨立的記憶體空間存放,並通過指標讓物件的y屬性指向HeapNumber
例項的記憶體空間。
然而在修改值時,然後x為SMI
所以可以原地修改記憶體中的值,而HeapNumber
為不可變,因此必須再分配一個新的記憶體空間存放新值,並修改o.y
中的記憶體地址。那麼在沒有啟用Mutable HeapNumber
時,如下程式碼將產生1.1
、1.2
和1.3
3個臨時例項。
let o = { x: 1.1 }
for (let i = 0; i < 4; ++i) {
o.x += 1;
}
有SMI
是帶符號位的,那麼實際儲存數字是31位,因此設定maxMarkerBits = 30
且通過if (effectTrackDepth <= maxMarkerBits)
判斷層級,即當effec巢狀到31層時不再使用無用依賴清理優化演算法。而優化演算法中採用的是二進位制位對上一輪已收集和本輪收集的依賴進行比較,從而清理無用依賴。若n
和w
值所佔位數超過31位則內部會採用HeapNumber
儲存,那麼在位運算上效能將有所下降。
其實我們還看到若effectTrackDepth
等於31時還會執行trackOpBit = 1 << ++effectTrackDepth
,這會導致trackOpBit
從SMI
的儲存方式轉換為HeapNumber
,那是不是可以加個判斷修改成下面這樣呢!
const maxMarkerBit = 1 << 30
if (trackOpBit & maxMarkerBit !== 1) {
trackOpBit = 1 << ++effectTrackDepth
}
副作用函式觸發器-trigger
由於在講解"優化無用依賴清理演算法"時已經對track
進行了剖析,因此現在我們直接分析trigger
就好了。
export function trigger(
target: object,
// set, add, delete, clear
type: TriggerOpTypes,
key?: unknown,
newValue?: unknown,
oldValue?: unknown,
oldTarget?: Map<unknown, unknown> | Set<unknown>
) {
const depsMap = targetMap.get(target)
if (!depsMap) {
// 該屬性沒有被任何副作用函式跟蹤過,所以直接返回就好了
return
}
/**
* 用於儲存將要被觸發的副作用函式。
* 為什麼不直接通過類似depsMap.values().forEach(fn => fn())執行副作用函式呢?
* 那是因為副作用函式執行時可能會刪除或增加depsMap.values()的元素,導致其中的副作用函式執行異常。
* 因此用另一個變數儲存將要執行的副作用函式集合,那麼執行過程中修改的是depsMap.values()的元素,而正在遍歷執行的副作用函式集合結構是穩定的。
*/
let deps: (Dep | undefined)[] = []
if (type === TriggerOpTypes.CLEAR) {
// 物件的所有屬性值清空,所有依賴該響應式物件的副作用函式都將被觸發
deps = [...depsMap.values()]
}
else if (key === 'length' && isArray(target)) {
// 若設定length屬性,那麼依賴length屬性和索引值大於等於新的length屬性值的元素的副作用函式都會被觸發
depsMap.forEach((dep, key) => {
if (key === 'length' || key >= (newValue as number)) {
deps.push(dep)
}
})
}
else {
// 將依賴該屬性的
if (key !== void 0) {
// 即使插入的是undefined也沒有關係
deps.push(depsMap.get(key))
}
/**
* 新增間接依賴的副作用函式
* 1. 新增陣列新值索引大於陣列長度時,會導致陣列容量被擴充,length屬性也會發生變化
* 2. 新增或刪除Set/WeakSet/Map/WeakMap元素時,需要觸發依賴迭代器的副作用函式
* 3. 新增或刪除Map/WeakMap元素時,需要觸發依賴鍵迭代器的副作用函式
* 4. 設定Map/WeakMap元素的值時,需要觸發依賴迭代器的副作用函式
*/
switch(type) {
case TriggerOpTypes.ADD:
if (!isArray(target)) {
// 對於非陣列,則觸發通過迭代器遍歷的副作用函式
deps.push(depsMap.get(ITERATE_KEY))
if (isMap(target)) {
deps.push(depsMap.get(MAP_KEY_ITERATE_KEY))
}
}
else if (isIntegerKey(key)) {
// 對陣列插入新元素,則需要觸發依賴length的副作用函式
deps.push(depsMap.get('length'))
}
break
case TriggerOpTypes.DELETE:
if (!isArray(target)) {
// 對於非陣列,則觸發通過迭代器遍歷的副作用函式
deps.push(depsMap.get(ITERATE_KEY))
if (isMap(target)) {
deps.push(depsMap.get(MAP_KEY_ITERATE_KEY))
}
}
break
case TriggerOpTypes.SET:
// 對於Map/WeakMap需要觸發依賴迭代器的副作用函式
if (isMap(target)) {
deps.push(depsMap.get(ITERATE_KEY))
}
}
if (deps.length === 1) {
// 過濾掉undefined
if (deps[0]) {
triggerEffects(deps[0])
}
}
else {
const effects: ReactiveEffect[] = []
// 過濾掉undefined
for (const dep of deps) {
if (dep) {
effects.push(...dep)
}
}
triggerEffects(createDep(effects))
}
}
}
export function triggerEffects(
dep: Dep | ReactiveEffect[]
) {
for (const effect of isArray(dep) ? dep : [...dep]) {
/**
* 必須保證將要觸發的副作用函式(effect)不是當前執行的副作用函式(activeEffect),否則將嵌入無限遞迴。
* 假設存在如下情況
* let foo = reactive({ bar: 1 })
* effect(() => {
* foo.bar = foo.bar + 1
* })
* 若沒有上述的保障,則將會不斷遞迴下去直接爆棧。
*
* 假如ReactiveEffect物件的allowRecurse設定為true,那麼表示不對上述問題作防禦。
*/
if (effect !== activeEffect || effect.allowRecurse) {
if (effect.scheduler) {
// 若設定有排程器則呼叫呼叫器
effect.scheduler()
}
else {
// 立即執行副作用函式
effect.run()
}
}
}
}
排程器
在上一節的triggerEffects
中我們看到預設採用同步方式執行副作用函式,若要同步執行數十個副作用函式那麼勢必會影響當前事件迴圈主邏輯的執行,這時就是排程器閃亮登場的時候了。我們回顧以下petite-vue中提供的排程器吧!
import { effect as rawEffect } from '@vue/reactivity'
const effect = (fn) => {
const e: ReactiveEffectRunner = rawEffect(fn, {
scheduler: () => queueJob(e)
})
return e
}
// ./scheduler.ts
let queued = false
const queue: Function[] = []
const p = Promise.resolve()
export const nextTick = (fn: () => void) => p.then(fn)
export const queueJob = (job: Function) => {
if (!queue.includes(job)) queue.push(job)
if (!queued) {
queued = true
nextTick(flushJobs)
}
}
const flushJobs = () => {
for (const job of queue) {
job()
}
queue.length = 0
queued = false
}
副作用函式壓入佇列中,並將遍歷佇列執行其中的副作用函式後清空佇列的flushJobs
壓入micro queue。那麼當前事件迴圈主邏輯執行完後,JavaScript引擎將會執行micro queue中的所有任務。
什麼是EffectScope
?
Vue 3.2引入新的Effect scope API,可自動收集setup
函式中建立的effect
、watch
和computed
等,當元件被銷燬時自動銷燬作用域(scope)和作用域下的這些例項(effect
、watch
和computed
等)。這個API主要是提供給外掛或庫開發者們使用的,日常開發不需要用到它。
還記得petite-vue中的context嗎?當遇到v-if
和v-for
就會為每個子分支建立新的block例項和新的context例項,而子分支下的所有ReactiveEffect
例項都將統一被對應的context例項管理,當block例項被銷燬則會對對應的context例項下的ReactiveEffect
例項統統銷燬。
block例項對應是DOM樹中動態的部分,可以大概對應上Vue元件,而context例項就是這裡的EffectScope
物件了。
使用示例:
cosnt scope = effectScope()
scope.run(() => {
const state = reactive({ value: 1 })
effect(() => {
console.log(state.value)
})
})
scope.stop()
那麼effect
生成的ReactiveEffect
例項是如何和scope關聯呢?
那就是ReactiveEffect
的建構函式中呼叫的recordEffectScope(this, scope)
export function recordEffectScope(
effect: ReactiveEffect,
scope?: EffectScope | null
) {
// 預設將activeEffectScope和當前副作用函式繫結
scope = scope || activeEffectScope
if (scope && scope.active) {
scope.effects.push(effect)
}
}
總結
petite-vue中使用@vue/reactivity的部分算是剖析完成了,也許你會說@vue/reactivity可不止這些內容啊,這些內容我將會在後續的《vue-lit原始碼剖析》中更詳盡的梳理分析,敬請期待。
下一篇我們將看看eval
中是如何使用new Function
和with
來構造JavaScript解析執行環境的。
尊重原創,轉載請註明來自:https://www.cnblogs.com/fsjohnhuang/p/16163888.html肥仔John