在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中的修改都能被攔截,舉個?:
<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
在檢視上也會發生變化的?,因為修改 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;
}
};
舉個簡單的?,理解起來可能簡單點,有兩個元件,一個父元件,一個子元件,子元件接收父元件傳遞的 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
標記:- toggle 的
dep = {n: 0, w: 2}
- visible 的
dep = {n: 0, w: 2}
- toggle 的
執行 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
}
結尾
我是周小羊,一個前端萌新,寫文章是為了記錄自己日常工作遇到的問題和學習的內容,提升自己,如果您覺得本文對你有用的話,麻煩點個贊鼓勵一下喲~