在正式開始實作我們自己的響應式 API 之前,我們先建立一個簡單的測試環境,來觀察 Vue 官方 ref
和effect
的實際情況。
先在packages/reactivity/
目錄下新增一個example
資料夾,並建立index.html
檔案:
- 我們預期進入頁面時,控制台會輸出
0
- 一秒後,控制台會輸出
1
接著本地啟動這個 html
檔案,這邊可以使用 live server 套件,即可在本地中運行。
1<!DOCTYPE html>2<html lang="en">3<head>4 <meta charset="UTF-8">5 <meta name="viewport" content="width=device-width, initial-scale=1.0">6 <title>Document</title>7</head>8<body>9
10
11 <script type="module">12 import { ref, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'13
14 const count = ref(0)15
10 collapsed lines
16 effect(() => {17 console.log('count.value ==>', count.value);18 })19
20 setTimeout(() => {21 count.value++22 }, 1000)23 </script>24</body>25</html>
我們可以看到 console
控制台中進入頁面時,出現輸出 0
,並且一秒後再輸出 1
。
由於我們目前使用的是 Vue 官方提供的版本,因此這個行為是完全正常的。
我們現在開始實作,現在知道有兩件事:
- 我們進入頁面時,傳入
effect
的函式會執行 ref
函式會接收一個初始值,並回傳一個物件。我們可以透過該物件的.value
屬性來存取或修改這個值。
所以我們先在 package/reactivity/src
下新增兩個檔案,分別是 ref.ts
以及effect.ts
,並且在 index.ts
集中匯出。
1class RefImpl {2 _value; // 保存實際數值3 constructor(value){4 this._value = value //儲存傳入 ref 的數值5 }6}7
8export function ref(value){9 return new RefImpl(value) // 建立一個 ref 實例10}
1export function effect(fn){2 fn() // 執行傳入的函式3}
1export * from './ref'2export * from './effect'
接著我們把官方的引用註解,引入我們的 dist
檔案,看看是否成功。
1 // import { ref, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'2 import { ref, effect } from '../dist/reactivity.esm.js'3
4 const count = ref(0)5
6 effect(() => {7 console.log('count.value ==>', count.value);8 })9
10 setTimeout(() => {11 count.value++12 }, 1000)
執行後會發現,第一次的輸出是 undefined
,且一秒後沒有任何變化。這完全正常,畢竟我們還沒實作任何依賴追蹤的機制。
這次沒有成功,讓我們了解了確切的問題所在:
- 無法取值:
count.value
讀取到的是undefined
。這是因為我們還沒有定義當讀取.value
時應該做什麼事 (缺少 getter 攔截)。 - 沒有更新: 修改
count.value++
後,effect
內的函式沒有重新執行。這是因為effect
和count
之間沒有建立任何關聯(缺少訂閱機制)。
為了解決這兩個問題,我們需要引入響應式系統中最核心的設計模式。
我們接下來要解決的核心問題:依賴收集和觸發更新。
響應式系統核心概念
1const count = ref(0)2
3effect(() => {4 console.log('count.value ==>', count.value);5})6
7setTimeout(() => {8 count.value++9}, 1000)
參考上方程式碼,我們現在想要做的是進入頁面的時候,count
會輸出 0
,但我們一但修改了count
,effect
的函式輸出就會跟著改變,這也是我們在 Vue3 裡面很常做的事,所以我們可以知道響應式的核心概念就是:當資料發生改變,相關的副作用會自動更新。
這個「資料改變,相關操作自動執行」的模式,其實可以用一個生活化的例子來比喻:出版社與訂閱者。
- 路人甲(effect 函式) 訂閱了科技雜誌
- 希望出版社將雜誌自動送到他家,不用他去催促
- 只要看雜誌(讀取
count.value
)就自動成為訂閱者 ← 這是依賴收集
- 出版社(ref) 管理雜誌內容
- 擁有所有訂閱者的名單 ← 依賴收集的結果
- 負責儲存最新的雜誌內容(資料值)
- 自動配送機制
- 當雜誌有新版(
count.value
被修改) - 出版社會自動寄送雜誌給所有訂閱者(執行 effect) ← 這是觸發更新
- 當雜誌有新版(
1// 出版社(儲存資料 + 管理訂閱者)2const count = ref(0)3
4// 路人甲訂閱(當他「閱讀」雜誌時,自動成為訂閱者)5effect(() => {6 console.log('count.value ==>', count.value); // 閱讀雜誌7})8
9// 出版社發行新版雜誌10setTimeout(() => {11 count.value++ // 新版發行,自動通知所有訂閱者12}, 1000)
Pub-Sub Pattern 發布訂閱模式
這個「出版社-訂閱者」的互動模式,在軟體設計中被稱為發布-訂閱模式 (Publish-Subscribe Pattern),或簡稱 Pub-Sub。
傳統發布訂閱模式
1// 發布者(出版社)2class Publisher {3 constructor() {4 this.subscribers = [] // 訂閱者名單5 }6
7 // 訂閱方法8 subscribe(subscriber) {9 this.subscribers.push(subscriber)10 console.log(`${subscriber.name} 已訂閱`)11 }12
13 // 發布方法14 publish(content) {15 console.log(`發布新內容: ${content}`)26 collapsed lines
16 this.subscribers.forEach(sub => {17 sub.notify(content) // 通知所有訂閱者18 })19 }20}21
22// 訂閱者23class Subscriber {24 constructor(name) {25 this.name = name26 }27
28 notify(content) {29 console.log(`${this.name} 收到: ${content}`)30 }31}32
33// 使用範例34const magazine = new Publisher()35const 路人甲 = new Subscriber('路人甲')36const 路人乙 = new Subscriber('路人乙')37
38magazine.subscribe(路人甲) // 路人甲訂閱39magazine.subscribe(路人乙) // 路人乙訂閱40
41magazine.publish('AI 特刊') // 發布新刊
圖解
這個模式的運作流程可以分為兩個主要階段:
1. 訂閱階段 (初始化):
- 註冊: 訂閱者 (Subscriber) 需要主動向發布者 (Publisher) 進行註冊。
- 收集: 發布者將所有訂閱者的資訊收集起來,存放在一個名單中。
2. 發布階段 (更新):
- 發布: 當有新內容發布時,發布者會發出通知。
- 通知: 發布者會遍歷訂閱者名單,將新內容逐一發送給所有訂閱者。
Vue 發布訂閱模式
1// 自動訂閱(依賴收集)2effect(() => {3 console.log(count.value) // 讀取即訂閱4})5
6// 修改時自動通知7count.value++ // 自動觸發更新
Vue 發布訂閱模式,與一般傳統發布訂閱模式不同
- 自動訂閱(依賴收集階段)
- 不需要手動呼叫 subscribe 方法
- effect 讀取
ref.value
時,自動建立訂閱關係 - ref 在被讀取時,自動收集當前的
effect
作為訂閱者
- 自動發布(觸發更新階段)
- 不需要手動呼叫 publish 方法
ref.value
被修改時,自動通知所有訂閱者- 相關的
effect
自動重新執行