Vue3響應式原始碼分析 - ref + ReactiveEffect篇
在Vue3中,因為reactive建立的響應式物件是通過Proxy來實現的,所以傳入資料不能為基礎型別,所以 ref
物件是對reactive不支援的資料的一個補充。
在 ref
和 reactive
中還有一個重要的工作就是收集、觸發依賴,那麼依賴是什麼呢?怎麼收集觸發?一起來看一下吧:
我們先來看一下 ref
的原始碼實現:
export function ref(value?: unknown) { return createRef(value, false) } export function shallowRef(value?: unknown) { return createRef(value, true) } const toReactive = (value) => isObject(value) ? reactive(value) : value; function createRef(rawValue: unknown, shallow: boolean) { // 如果是ref則直接返回 if (isRef(rawValue)) { return rawValue } return new RefImpl(rawValue, shallow) } class RefImpl<T> { private _value: T // 存放 raw 原始值 private _rawValue: T // 存放依賴 public dep?: Dep = undefined public readonly __v_isRef = true constructor(value: T, public readonly __v_isShallow: boolean) { // toRaw 拿到value的原始值 this._rawValue = __v_isShallow ? value : toRaw(value) // 如果不是shallowRef,使用 reactive 轉成響應式物件 this._value = __v_isShallow ? value : toReactive(value) } // getter攔截器 get value() { // 收集依賴 trackRefValue(this) return this._value } // setter攔截器 set value(newVal) { // 如果是需要深度響應的則獲取 入參的raw newVal = this.__v_isShallow ? newVal : toRaw(newVal) // 新值與舊值是否改變 if (hasChanged(newVal, this._rawValue)) { this._rawValue = newVal // 更新value 如果是深入建立並且是物件的話 還需要轉化為reactive代理 this._value = this.__v_isShallow ? newVal : toReactive(newVal) // 觸發依賴 triggerRefValue(this, newVal) } } }
RefImpl
採用ES6類的寫法,包含 get
、 set
,其實大家可以用 webpack 等打包工具打包成 ES5 的程式碼, 發現其實就是 Object.defineProperty
。
可以看到, shallowRef
和 ref
都呼叫了 createRef
,只是傳入的引數不同。當使用 shallowRef
時,不會呼叫 toReactive
去將物件轉換為響應式,由此可見,shallowRef物件只支援對value值的響應式,ref物件支援對value深度響應式,ref.value.a.b.c中的修改都能被攔截,舉個:chestnut::
<template> <p>{{ refData.a }}</p> <p>{{ shallowRefData.a }}</p> <button @click="handleChange">change</button> </template> let refData = ref({ a: 'ref' }) let shallowRefData = shallowRef({ a: 'shallowRef' }) const handleChange = () => { refData.value.a = "ref1" shallowRefData.value.a = "shallowRef1" }
當我們點選按鈕修改資料後,介面上的 refData.a
的值會變為 ref1
,而 shallowRefData.a
應該會不發生變化,但其實在這個例子裡, shallowRefData.a
在檢視上也會發生變化的:dog:,因為修改 refData.a
時候,觸發了setter函式,內會去呼叫 triggerRefValue(this, newVal)
從而觸發了 檢視更新
,所以shallow的最新資料也會被更新到了檢視上 (把 refData.value.a = "ref1"
去掉它就不會變了)。
在 ref
裡最關鍵的還是 trackRefValue
和 triggerRefValue
,負責收集觸發依賴。
如何收集依賴:
function trackRefValue(ref) { // 判斷是否需要收集依賴 // shouldTrack 全域性變數,代表當前是否需要 track 收集依賴 // activeEffect 全域性變數,代表當前的副作用物件 ReactiveEffect if (shouldTrack && activeEffect) { ref = toRaw(ref); { // 如果沒有 dep 屬性,則初始化 dep,dep 是一個 Set<ReactiveEffect>,儲存副作用函式 // trackEffects 收集依賴 trackEffects(ref.dep || (ref.dep = createDep()), { target: ref, type: "get", key: 'value' }); } } }
為什麼要判斷 shouldTrack
和 activeEffect
,因為在Vue3中有些時候不需要收集依賴:
- 當沒有 effect 包裹時,比如定義了一個ref變數,但沒有任何地方使用到,這時候就沒有依賴,activeEffect 為 undefined,就不需要收集依賴了
- 比如在陣列的一些會改變自身長度的方法裡,也不應該收集依賴,容易造成死迴圈,此時 shouldTrack 為 false
*依賴是什麼?
ref.dep
用於儲存 依賴
(副作用物件),ref 被修改時就會觸發,那麼依賴是什麼呢?依賴就是 ReactiveEffect
:
為什麼要收集依賴(副作用物件),因為在Vue3中,一個響應式變數的變化,往往會觸發一些副作用,比如檢視更新、計算屬性變化等等,需要在響應式變數變化時去觸發其它一些副作用函式。
在我看來 ReactiveEffect
其實就和 Vue2 中的 Watcher
的作用差不多,我之前寫的 《Vue原始碼學習-響應式原理》 裡做過說明:
class ReactiveEffect { constructor(fn, scheduler = null, scope) { // 傳入一個副作用函式 this.fn = fn; this.scheduler = scheduler; this.active = true; // 儲存 Dep 物件,如上面的 ref.dep // 用於在觸發依賴後, ref.dep.delete(effect),雙向刪除依賴) this.deps = []; this.parent = undefined; recordEffectScope(this, scope); } run() { // 如果當前effect已經被stop if (!this.active) { return this.fn(); } let parent = activeEffect; let lastShouldTrack = shouldTrack; while (parent) { if (parent === this) { return; } parent = parent.parent; } try { // 儲存上一個 activeEffect this.parent = activeEffect; activeEffect = this; shouldTrack = true; // trackOpBit: 根據深度生成 trackOpBit trackOpBit = 1 << ++effectTrackDepth; // 如果不超過最大巢狀深度,使用優化方案 if (effectTrackDepth <= maxMarkerBits) { // 標記所有的 dep 為 was initDepMarkers(this); } // 否則使用降級方案 else { cleanupEffect(this); } // 執行過程中重新收集依賴標記新的 dep 為 new return this.fn(); } finally { if (effectTrackDepth <= maxMarkerBits) { // 優化方案:刪除失效的依賴 finalizeDepMarkers(this); } // 巢狀深度自 + 重置操作的位數 trackOpBit = 1 << --effectTrackDepth; // 恢復上一個 activeEffect activeEffect = this.parent; shouldTrack = lastShouldTrack; this.parent = undefined; if (this.deferStop) { this.stop(); } } } }
ReactiveEffect
是副作用物件,它就是被收集依賴的實際物件,一個響應式變數可以有多個依賴,其中最主要的就是 run
方法,裡面有兩套方案,當 effect
巢狀次數不超過最大巢狀次數的時候,使用優化方案,否則使用降級方案。
降級方案:
function cleanupEffect(effect) { const { deps } = effect; if (deps.length) { for (let i = 0; i < deps.length; i++) { // 從 ref.dep 中刪除 ReactiveEffect deps[i].delete(effect); } deps.length = 0; } }
這個很簡單,刪除全部依賴,然後重新收集。在各個 dep 中,刪除該 ReactiveEffect
物件,然後執行 this.fn()
(副作用函式) 時,當獲取響應式變數觸發 getter
時,又會重新收集依賴。之所以要先刪除然後重新收集,是因為隨著響應式變數的變化,收集到的依賴前後可能不一樣。
const toggle = ref(false) const visible = ref('show') effect(() = { if (toggle.value) { console.log(visible.value) } else { console.log('xxxxxxxxxxx') } }) toggle.value = true
- 當 toggle 為 true 時,toggle、visible 都能收集到依賴
- 當 toggle 為 false 時,只有visible 可以收集到依賴
優化方案:
全部刪除,再重新收集,明顯太消耗效能了,很多依賴其實是不需要被刪除的,所以優化方案的做法是:
// 響應式變數上都有一個 dep 用來儲存依賴 const createDep = (effects) => { const dep = new Set(effects); dep.w = 0; dep.n = 0; return dep; };
- 執行副作用函式前,給
ReactiveEffect 依賴的響應式變數
,加上w(was的意思)
標記。 - 執行 this.fn(),track 重新收集依賴時,給 ReactiveEffect 的每個依賴,加上
n(new的意思)
標記。 - 最後,對有
w
但是沒有n
的依賴進行刪除。
其實就是一個篩選的過程,我們現在來第一步,如何加上 was
標記:
// 在 ReactiveEffect 的 run 方法裡 if (effectTrackDepth <= maxMarkerBits) { initDepMarkers(this); } const initDepMarkers = ({ deps }) => { if (deps.length) { for (let i = 0; i < deps.length; i++) { deps[i].w |= trackOpBit; } } };
這裡使用了位運算,快捷高效。trackOpBit是什麼呢?代表當前巢狀深度 (effect可以巢狀)
,在Vue3中有一個全域性變數 effectTrackDepth
// 全域性變數 巢狀深度 let effectTrackDepth = 0; // 在 ReactiveEffect 的 run 方法裡 // 每次執行 effect 副作用函式前,全域性變數巢狀深度會自增1 trackOpBit = 1 << ++effectTrackDepth // 執行完副作用函式後會自減 trackOpBit = 1 << --effectTrackDepth;
當深度為 1 時,trackOpBit為 2(二進位制:00000010),這樣執行 deps[i].w |= trackOpBit
時,操作的是第二位,所以第一位是用不到的。
為什麼Vue3中巢狀深度最大是 30 ?
1 << 30 // 0100 0000 0000 0000 0000 0000 0000 0000 // 1073741824 1 << 31 // 1000 0000 0000 0000 0000 0000 0000 0000 // -2147483648 溢位
因為js中位運算是以32位帶符號的整數進行運算的,最左邊一位是符號位,所以可用的正數最多隻能到30位。
可以看到,在執行副作用函式之前,使用 deps[i].w |= trackOpBit
,對依賴在不同深度是否被依賴( w )進行標記,然後執行 this.fn()
,重新收集依賴,上面說到收集依賴呼叫 trackRefValue
方法,該方法內會呼叫 trackEffects
:
function trackEffects(dep, debuggerEventExtraInfo) { let shouldTrack = false; if (effectTrackDepth <= maxMarkerBits) { // 檢視是否記錄過當前依賴 if (!newTracked(dep)) { dep.n |= trackOpBit; // 如果 w 在當前深度有值,說明effect之前已經收集過 // 不是新增依賴,不需要再次收集 shouldTrack = !wasTracked(dep); } } else { shouldTrack = !dep.has(activeEffect); } if (shouldTrack) { // dep添加當前正在使用的effect dep.add(activeEffect); // effect的deps也記錄當前dep 雙向引用 activeEffect.deps.push(dep); } }
可以看到再重新收集依賴的時候,使用 dep.n |= trackOpBit
對依賴在不同深度是否被依賴( n )進行標記,這裡還用到兩個工具函式:
const wasTracked = (dep) => (dep.w & trackOpBit) > 0; const newTracked = (dep) => (dep.n & trackOpBit) > 0;
使用 wasTracked 和 newTracked,判斷 dep
是否在當前深度被標記。比如判斷依賴在深度 1 時 (trackOpBit第二位是1) 是否被標記,採用按位與:
最後,如果已經超過最大深度,因為採用降級方案,是全部刪除然後重新收集的,所以肯定是最新的,所以只需要把 trackOpBit
恢復,恢復上一個 activeEffect:
finally { if (effectTrackDepth <= maxMarkerBits) { // 優化方案:刪除失效的依賴 finalizeDepMarkers(this); } trackOpBit = 1 << --effectTrackDepth; // 恢復上一個 activeEffect activeEffect = this.parent; shouldTrack = lastShouldTrack; this.parent = undefined; if (this.deferStop) { this.stop(); } }
如果沒超過最大深度,就像之前說的把失效的依賴刪除掉,然後更新一下deps的順序:
const finalizeDepMarkers = (effect) => { const { deps } = effect; if (deps.length) { let ptr = 0; for (let i = 0; i < deps.length; i++) { const dep = deps[i]; // 把有 w 沒有 n 的刪除 if (wasTracked(dep) && !newTracked(dep)) { dep.delete(effect); } else { // 更新deps,因為有的可能會被刪掉 // 所以要把前面空的補上,用 ptr 單獨控制下標 deps[ptr++] = dep; } // 與非,恢復到進入時的狀態 dep.w &= ~trackOpBit; dep.n &= ~trackOpBit; } deps.length = ptr; } };
舉個簡單的:chestnut:,理解起來可能簡單點,有兩個元件,一個父元件,一個子元件,子元件接收父元件傳遞的 toggle
引數顯示在介面上, toggle
還控制著 visible
的顯示,點選按鈕切換 toggle
的值:
// Parent <script setup lang="ts"> const toggle = ref(true) const visible = ref('show') const handleChange = () => { toggle.value = false } </script> <template> <div> <p v-if="toggle">{{ visible }}</p> <p v-else>xxxxxxxxxxx</p> <button @click="handleChange">change</button> <Child :toggle="toggle" /> </div> </template>
// Child <script setup lang="ts"> const props = defineProps({ toggle: { type: Boolean, }, }); </script> <template> <p>{{ toggle }}</p> </template>
第一次渲染,因為toggle 預設為 true,我們可以收集到 toggle
、 visible
的依賴,
-
Parent
元件, 執行 run 方法中的initDepMarkers
方法,首次進入,還未收集依賴,ReactiveEffect
中deps
長度為0,跳過。 -
執行 run 方法中的
this.fn
,重新收集依賴,觸發 trackEffects:- toggle 的
dep = {n: 2, w: 0}
,shouldTrack
為 true,收集依賴。 - visible 的
dep = {n: 2, w: 0}
,shouldTrack
為 true,收集依賴。
- toggle 的
- 進入
Child
元件,執行 run 方法中的initDepMarkers
方法,首次進入,還為收集依賴,deps長度為0,跳過。 -
執行 run 方法中的
this.fn
,重新收集依賴,觸發 trackEffects:- toggle 的
dep = {n: 4, w: 0}
,shouldTrack
為 true,收集依賴。
- toggle 的
這樣首次進入頁面的收集依賴就結束了,然後我們點選按鈕,把 toggle
改為 false:
-
Parent
元件: 執行 run 方法中的initDepMarkers
方法,之前在Parent
元件裡收集到了兩個變數的依賴,所以將他們w
標記:dep = {n: 0, w: 2} dep = {n: 0, w: 2}
-
執行 run 方法中的
this.fn
,重新收集依賴,觸發 trackEffects:- toggle 的
dep = {n: 2, w: 2}
,shouldTrack
為 false,不用
收集依賴。 - visible
不顯示了
,所以沒有重新收集到,還是{n: 0, w: 2}
。
- toggle 的
- 進入
Child
元件,執行 run 方法中的initDepMarkers
方法,之前 收集過toggle
依賴了,將 toggle 的 w 做標記,toggle 的dep = {n: 0, w: 4}
。 -
執行 run 方法中的
this.fn
,重新收集依賴,觸發 trackEffects:- toggle 的
dep = {n: 4, w: 4}
,shouldTrack
為 false,不用收集依賴。
- toggle 的
最後發現 visible
有 w
沒有 n
,在 finalizeDepMarkers
中刪除掉失效依賴。
如何觸發依賴:
在一開始講到的 ref
原始碼裡,可以看到在 setter
時會呼叫 triggerRefValue
觸發依賴:
function triggerRefValue(ref, newVal) { ref = toRaw(ref); if (ref.dep) { { triggerEffects(ref.dep, { target: ref, type: "set", key: 'value', newValue: newVal }); } } } function triggerEffects( dep: Dep | ReactiveEffect[] ) { // 迴圈去取每個依賴的副作用物件 ReactiveEffect for (const effect of isArray(dep) ? dep : [...dep]) { // effect !== activeEffect 防止遞迴,造成死迴圈 if (effect !== activeEffect || effect.allowRecurse) { // effect.scheduler可以先不管,ref 和 reactive 都沒有 if (effect.scheduler) { effect.scheduler() } else { // 執行 effect 的副作用函式 effect.run() } } } }
觸發依賴最終的目的其實就是去執行 依賴
上 每個的副作用物件
的 副作用函式
,這裡的副作用函式可能是執行更新檢視、watch資料監聽、計算屬性等。
我個人再看原始碼的時候還遇到了一個問題,不知道大家遇到沒有(我看的程式碼版本算是比較新v3.2.37),一開始我也是上網看一些原始碼的解析文章,看到好多講解 effect
這個函式的,先來看看這個方法的原始碼:
function effect(fn, options) { if (fn.effect) { fn = fn.effect.fn; } const _effect = new ReactiveEffect(fn); if (options) { extend(_effect, options); if (options.scope) recordEffectScope(_effect, options.scope); } if (!options || !options.lazy) { _effect.run(); } const runner = _effect.run.bind(_effect); runner.effect = _effect; // 返回一個包裝後的函式,執行收集依賴 return runner; }
這個函式看上去挺簡單的,建立一個 ReactiveEffect
副作用物件,將使用者傳入的引數附加到物件上,然後呼叫 run
方法收集依賴,如果有 lazy
配置不會自動去收集依賴,使用者主動執行 effect 包裝後的函式,也能夠正確的收集依賴。
但我找了一圈,發現原始碼裡一個地方都沒呼叫,於是我就在想是不是以前用到過,現在去掉了,去commit記錄裡找了一圈,還真找到了:
這次更新把 ReactiveEffect
改為用類來實現,避免不必要時也建立 effect runner
,節省了17%的記憶體等。
原來的 effect
方法包括了現在的 ReactiveEffect
,在檢視更新渲染、watch等地方都直接引用了這個方法,但更新後都是直接 new ReactiveEffect
,然後去觸發 run
方法,不走 effect
了,可以說現在的 ReactiveEffect
類就是之前的 effect
方法 。
export function effect<T = any>( fn: () => T, options: ReactiveEffectOptions = EMPTY_OBJ ): ReactiveEffect<T> { const effect = createReactiveEffect(fn, options) return effect } let uid = 0 function createReactiveEffect<T = any>( fn: () => T, options: ReactiveEffectOptions ): ReactiveEffect<T> { const effect = function reactiveEffect(): unknown { if (!effect.active) { return fn() } if (!effectStack.includes(effect)) { cleanup(effect) try { enableTracking() effectStack.push(effect) activeEffect = effect return fn() } finally { effectStack.pop() resetTracking() const n = effectStack.length activeEffect = n > 0 ? effectStack[n - 1] : undefined } } } as ReactiveEffect effect.id = uid++ effect.allowRecurse = !!options.allowRecurse effect._isEffect = true effect.active = true effect.raw = fn effect.deps = [] effect.options = options return effect }
結尾
我是周小羊,一個前端萌新,寫文章是為了記錄自己日常工作遇到的問題和學習的內容,提升自己,如果您覺得本文對你有用的話,麻煩點個贊鼓勵一下喲~
- 產品說明丨Android端使用MobPush快速整合方法
- 不要在 Python 中使用迴圈,這些方法其實更棒!
- 分享 6 個 Vue3 開發必備的 VSCode 外掛
- 微服務架構的外部 API 整合模式
- 前端該如何優雅地 Mock 資料
- 記一次springboot專案結合arthas排查ClassNotFoundException問題
- 使用Vue.js編寫命令列介面,前端開發CLI的利器
- 升級Spring Cloud最新版後,有個重要的元件被棄用了!
- 微服務架構的通訊設計模式
- 視覺化拖拽元件庫一些技術要點原理分析(四)
- 我做了一個線上白板(二)
- 3 款非常實用的 Node.js 版本管理工具
- 636. 函式的獨佔時間 : 簡單棧運用模擬題
- Flutter 的 6 個最有用的 VS Code擴充套件
- TCP 學習筆記(三) 可靠傳輸
- 一文讀懂微服務架構的分解設計
- Python中常用最神祕的函式! lambda 函式深度總結!
- 從-99打造Sentinel高可用叢集限流中介軟體
- 技術分享| 小程式實現音影片通話
- Birdseye 極其強大的Python除錯工具