🧭 學習主線
讀取響應式資料時收集依賴,修改響應式資料時觸發依賴重新執行。
可以把它拆成三個關鍵角色:
| 角色 | 作用 | 對應程式碼 |
|---|---|---|
| 📦 響應式資料 | 保存值,並攔截讀取和修改 | ref / RefImpl |
| 🧲 依賴收集 | 在讀取 .value 時記錄誰用到了這個值 |
get value() |
| 🔁 觸發更新 | 在修改 .value 時重新執行依賴函式 |
set value() |
Vue 的響應式系統核心在於:響應式物件的屬性與 effect 副作用函式之間建立依賴關係。
一般函式裡雖然讀取了 count.value,但是 Vue 並不知道這個函式之後需要被重新執行。
原因是:這次讀取沒有處在 effect 的收集環境裡,所以 count 沒有機會把這個函式記錄下來。後面就算 count.value 變了,也找不到要重新執行的函式。
javascript 代碼解讀複製代碼import { ref } from 'vue'
const count = ref(0)
// 一般函式
function fn(){
console.log(count.value)
}
fn() // 印出 0
setTimeout(()=>{
count.value = 1 // 修改值不會觸發 fn 重新執行
})
雖然 fn 讀取了響應式資料 count.value,但由於它不是在 effect 中執行的,因此當 count.value 發生變化時,該函式不會重新執行。
ref 本身只是讓資料具備「可追蹤」的能力,但是否真的追蹤,還要看讀取發生在哪裡。
effect 的作用可以先簡單理解成:告訴響應式系統「這個函式是需要被追蹤的」。
當 effect 內部讀取 count.value 時,count 就可以把目前正在執行的函式儲存起來。等後面 count.value 被修改時,再把這個函式拿出來重新執行。
javascript 代碼解讀複製代碼import { ref, effect } from 'vue'
const count = ref(0)
effect(()=>{
console.log(count.value) // 首次執行印出 0
})
setTimeout(()=>{
count.value = 1 // 觸發 effect 重新執行,印出 1
})
effect 先執行一次傳入的函式count.valueget value()setTimeout 中修改 count.valueset value()第一版 effect 先不要想得太複雜,它最基礎的能力就是:接收一個函式,並立即執行它。
這一步只是搭建入口,後面才會繼續給它加上「目前正在執行的 effect」這個狀態。
php 代碼解讀複製代碼export function effect(fn){
fn()
}
index.ts 的作用是統一出口。
以後外部使用時,不需要分別去找 ref.ts 和 effect.ts,只要從目前模組入口匯入即可。
javascript 代碼解讀複製代碼export * from './ref'
export * from './effect'
ref(value) 的本質不是直接回傳原始值,而是把原始值包一層物件。
這樣做的原因是:只有包成物件之後,才可以透過 get value() 和 set value() 攔截讀取與修改。
javascript 代碼解讀複製代碼class RefImpl{
constructor(value) {
this._value = value
}
}
export function ref(value){
return new RefImpl(value)
}
count 不是數字 0,而是一個 RefImpl 實例;真正的值被放在 _value 裡。
這個案例先用 Vue 官方的 ref 和 effect 跑通效果,目的是給後面自己實作原始碼一個對照目標。
學習原始碼時不要一上來就寫實作,先確認最終行為是什麼:首次印出一次,1 秒後值變化,再印出一次。
xml 代碼解讀複製代碼<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Document</title>
</head>
<body>
<script type="module">
import { ref, effect } from '../../../node_modules/vue/dist/vue.esm-browser.prod.js'
const count = ref(0)
effect(() => {
console.log('effect1 count.value =>', count.value)
})
setTimeout(() => {
count.value = 1
}, 1000)
</script>
</body>
</html>
逐步去完善 reactivity/src 的 ts 檔案
這一版 ref.ts 開始具備響應式的核心雛形:
| 程式碼位置 | 作用 |
|---|---|
_value |
保存真實的值 |
[ReactiveFlgs.IS_REF] = true |
為目前物件打上 ref 標記 |
get value() |
讀取 .value 時觸發 |
set value() |
修改 .value 時觸發 |
isRef |
判斷一個值是不是 ref |
這裡的 console.log(' 有人訪問我了 ') 和 console.log(' 我的值變了 ') 只是為了觀察流程。真正的原始碼裡,這兩個位置分別會做「依賴收集」和「觸發更新」。
javascript 代碼解讀複製代碼enum ReactiveFlgs = {
IS_REF = '__v_isRef' // ref 標記,證明是個 ref
}
/*
* Ref 的類別
* */
class RefImpl{
// 保存實際的值
_value;
// ref 標記,證明是個 ref
[ReactiveFlgs.IS_REF] = true
constructor(value) {
this._value = value
}
get value(){
// 收集依賴
console.log(' 有人訪問我了 ')
return this._value;
}
set value(newValue){
// 觸發更新
console.log(' 我的值變了 ')
this._value = newValue
}
}
/*
* 判斷是不是一個 ref
* @params value
* */
export function isRef(value){
return !!(value && value[ReactiveFlgs.IS_REF])
}
export function ref(value){
return new RefImpl(value)
}
響應式不是值自己會動,而是讀取和修改這兩個動作被攔截了。
要讓 count.value = 1 後重新執行 effect,需要解決兩個問題:
| 問題 | 解決方式 |
|---|---|
| 讀取時怎麼知道是誰在讀? | 用一個全域變數保存目前正在執行的 effect |
| 修改時怎麼知道通知誰? | 在 ref 實例上保存之前收集到的 effect |
所以整體思路是:
effect(fn) 執行前,把 fn 標記成目前活躍的副作用函式fnfn 內部讀取 count.valueget value() 發現目前有活躍的 effect,就把它保存到 subscount.valueset value() 執行 subsactiveSub 可以理解成一個暫存變數,用來保存「目前正在被收集的函式」。
為什麼需要這個變數?因為 get value() 觸發的時候,它本身並不知道是誰讀取了 .value。所以需要 effect 在外面先把目前函式放到一個共用位置,get value() 再從這個位置取到它。
scss 代碼解讀複製代碼// 用來保存目前正在執行的 effect
// 相當於範例中的
// () => {
// console.log('count.value =>', count.value)
// }
export let activeSub
export function effect(fn){
activeSub = fn()
activeSub() // 就是執行 fn()
activeSub = underfined
}
學習這一段時重點看思想:effect 負責打開收集視窗,ref.value 的 getter 負責在視窗打開時把依賴記下來。
最終這版 ref.ts 把依賴收集和觸發更新串起來了。
關鍵點在這兩處:
| 位置 | 做的事情 |
|---|---|
get value() |
如果存在 activeSub,表示目前讀取發生在 effect 中,於是保存依賴 |
set value() |
修改值之後,執行之前保存的依賴函式 |
javascript 代碼解讀複製代碼import { activeSub } from './effect'
enum ReactiveFlgs = {
IS_REF = '__v_isRef' // ref 標記,證明是個 ref
}
/*
* Ref 的類別
* */
class RefImpl{
// 保存實際的值
_value;
// ref 標記,證明是個 ref
[ReactiveFlgs.IS_REF] = true
// 保存和 effect 之間的關聯關係
subs
constructor(value) {
this._value = value
}
get value(){
// 收集依賴
if(activeSub){
// 如果 activeSub 有,保存起來,等我更新時觸發
this.subs = activeSub
}
return this._value;
}
set value(newValue){
// 觸發更新
this._value = newValue
this.subs?.() // 可選鏈 ?. activeSub 賦值給 this.subs 可能是空的
}
}
/*
* 判斷是不是一個 ref
* @params value
* */
export function isRef(value){
return !!(value && value[ReactiveFlgs.IS_REF])
}
export function ref(value){
return new RefImpl(value)
}
這一節可以先不用追求一次性還原 Vue 完整原始碼,先把最小響應式模型跑通:
| 階段 | 發生了什麼 |
|---|---|
建立 ref(0) |
建立一個 RefImpl 實例 |
首次執行 effect |
執行傳入的函式 |
| 讀取 | 存取 count.value,觸發 get value() |
| 收集 | get value() 把目前 effect 保存起來 |
| 修改 | count.value = 1,觸發 set value() |
| 更新 | set value() 重新執行之前保存的 effect |
原始碼學習時最重要的是抓住這個閉環:
effect 執行函式 → 函式讀取響應式資料 → 響應式資料收集函式 → 資料變化 → 函式重新執行