
在正式开始实现我们自己的响应式 API 之前,我们先创建一个简单的测试环境,来观察 Vue 官方 ref
和 effect
的实际情况。 先在 packages/reactivity/
目录下新建一个 example
文件夹,并创建 index.html
文件:
- 我们预期进入页面时,控制台会输出
0
- 一秒后,控制台会输出
1
接着在本地启动这个 html
文件,这里可以使用 VS Code 的 Live Server 插件,即可在本地运行。
HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<script type="module">
import { ref, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
const count = ref(0)
effect(() => {
console.log('count.value ==>', count.value);
})
setTimeout(() => {
count.value++
}, 1000)
</script>
</body>
</html>
我们可以看到 console
控制台中进入页面时,出现了输出 0
,并且一秒后又输出了 1
。
由于我们目前使用的是 Vue 官方提供的版本,因此这个行为是完全正常的。

我们现在开始实现,目前已知有两件事:
- 我们进入页面时,传入
effect
的函数会执行。 ref
函数会接收一个初始值,并返回一个对象。我们可以通过该对象的.value
属性来访问或修改这个值。
所以我们先在 packages/reactivity/src
下新建两个文件,分别是 ref.ts
以及 effect.ts
,并且在 index.ts
中集中导出。
TypeScript
// packages/reactivity/src/ref.ts
class RefImpl {
_value; // 保存实际值
constructor(value){
this._value = value // 存储传入 ref 的值
}
}
export function ref(value){
return new RefImpl(value) // 创建一个 ref 实例
}
TypeScript
// packages/reactivity/src/effect.ts
export function effect(fn){
fn() // 执行传入的函数
}
TypeScript
// packages/reactivity/src/index.ts
export * from './ref'
export * from './effect'
接着我们把官方的引用注释掉,引入我们自己的 dist
文件,看看是否成功。
JavaScript
// import { ref, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
import { ref, effect } from '../dist/reactivity.esm.js'
const count = ref(0)
effect(() => {
console.log('count.value ==>', count.value);
})
setTimeout(() => {
count.value++
}, 1000)

执行后会发现,第一次的输出是 undefined
,且一秒后没有任何变化。这完全正常,毕竟我们还没实现任何依赖追踪的机制。
这次的失败,让我们明确了问题所在:
- 无法取值:
count.value
读取到的是undefined
。这是因为我们还没有定义当读取.value
时应该做什么事(缺少 getter 拦截)。 - 没有更新: 修改
count.value++
后,effect
内的函数没有重新执行。这是因为effect
和count
之间没有建立任何关联(缺少订阅机制)。
为了解决这两个问题,我们需要引入响应式系统中最核心的设计模式。
我们接下来要解决的核心问题:依赖收集 和触发更新。
响应式系统核心概念
JavaScript
const count = ref(0)
effect(() => {
console.log('count.value ==>', count.value);
})
setTimeout(() => {
count.value++
}, 1000)
参考上方代码,我们现在想要做的是进入页面的时候,count
会输出 0
,但我们一旦修改了 count
,effect
的函数输出就会跟着改变,这也是我们在 Vue3 里面很常做的事。所以我们可以知道响应式的核心概念就是:当数据发生改变,相关的副作用会自动更新。
这个"数据改变,相关操作自动执行"的模式,其实可以用一个生活化的例子来比喻:出版社与订阅者。
-
路人甲 (effect 函数) 订阅了科技杂志
- 希望出版社将杂志自动送到他家,不用他去催促
- 只要看杂志(读取
count.value
)就自动成为订阅者 ← 这是依赖收集
-
出版社 (ref) 管理杂志内容
- 拥有所有订阅者的名单 ← 依赖收集的结果
- 负责存储最新的杂志内容(数据值)
-
自动配送机制
- 当杂志有新版(
count.value
被修改) - 出版社会自动寄送杂志给所有订阅者(执行 effect) ← 这是触发更新
- 当杂志有新版(
JavaScript
// 出版社(存储数据 + 管理订阅者)
const count = ref(0)
// 路人甲订阅(当他"阅读"杂志时,自动成为订阅者)
effect(() => {
console.log('count.value ==>', count.value); // 阅读杂志
})
// 出版社发行新版杂志
setTimeout(() => {
count.value++ // 新版发行,自动通知所有订阅者
}, 1000)
Pub-Sub Pattern 发布订阅模式
这个"出版社-订阅者"的互动模式,在软件设计中被称为发布-订阅模式 (Publish-Subscribe Pattern) ,或简称 Pub-Sub。
传统发布订阅模式
JavaScript
// 发布者(出版社)
class Publisher {
constructor() {
this.subscribers = [] // 订阅者名单
}
// 订阅方法
subscribe(subscriber) {
this.subscribers.push(subscriber)
console.log(`${subscriber.name} 已订阅`)
}
// 发布方法
publish(content) {
console.log(`发布新内容: ${content}`)
this.subscribers.forEach(sub => {
sub.notify(content) // 通知所有订阅者
})
}
}
// 订阅者
class Subscriber {
constructor(name) {
this.name = name
}
notify(content) {
console.log(`${this.name} 收到: ${content}`)
}
}
// 使用示例
const magazine = new Publisher()
const 路人甲 = new Subscriber('路人甲')
const 路人乙 = new Subscriber('路人乙')
magazine.subscribe(路人甲) // 路人甲订阅
magazine.subscribe(路人乙) // 路人乙订阅
magazine.publish('AI 特刊') // 发布新刊
图解


这个模式的运作流程可以分为两个主要阶段:
1. 订阅阶段 (初始化):
- 注册: 订阅者 (Subscriber) 需要主动向发布者 (Publisher) 进行注册。
- 收集: 发布者将所有订阅者的信息收集起来,存放在一个名单中。
2. 发布阶段 (更新):
- 发布: 当有新内容发布时,发布者会发出通知。
- 通知: 发布者会遍历订阅者名单,将新内容逐一发送给所有订阅者。
Vue 发布订阅模式

JavaScript
// 自动订阅(依赖收集)
effect(() => {
console.log(count.value) // 读取即订阅
})
// 修改时自动通知
count.value++ // 自动触发更新
Vue 发布订阅模式,与一般传统发布订阅模式不同:
-
自动订阅(依赖收集阶段)
- 不需要手动调用
subscribe
方法 effect
读取ref.value
时,自动建立订阅关系ref
在被读取时,自动收集当前的effect
作为订阅者
- 不需要手动调用
-
自动发布(触发更新阶段)
- 不需要手动调用
publish
方法 ref.value
被修改时,自动通知所有订阅者- 相关的
effect
自动重新执行
- 不需要手动调用
想了解更多 Vue 的相关知识,抖音、B站搜索我师父「远方os」,一起跟日安当同学。