手写简易响应式系统深度解析
一句话概括
手写 Vue3 响应式系统的核心——reactive、effect、computed——本质上就是利用 Proxy 拦截对象读写 + 依赖收集 + 调度执行的三大机制组合,不到 200 行代码即可实现核心逻辑。
一、背景与意义
1.1 从框架演进看响应式
前端框架的发展史,本质上就是状态与视图同步方式的进化史:
| 时代 | 代表 | 方式 | 缺点 |
|---|---|---|---|
| 手动 DOM 操作 | jQuery | 开发者手动更新 DOM | 繁琐、易遗漏、数据流混乱 |
| 脏检查 | AngularJS | $digest 循环遍历所有 watcher | 性能随 watcher 数量线性下降 |
| 虚拟 DOM + setState | React | 不可变数据 + 手动触发更新 | 需要额外注意不可变性 |
| 响应式 + 依赖追踪 | Vue3 | Proxy 拦截 + 自动收集依赖 | 学习成本较低 ✔ |
Vue3 的响应式系统(通常称为 @vue/reactivity)是它区别于 React 最核心的设计:开发者只需要像操作普通对象一样修改数据,视图就会自动更新。
1.2 为什么需要手写
面试中手写响应式系统的价值不在于写出完整可用的库,而在于验证你对以下概念的理解:
- Proxy 与 Reflect:拦截机制的底层 API 掌握
- 发布-订阅模式:依赖收集的本质是一个观察者模式
- 副作用与调度:
effect如何在数据变化时重新执行 - 惰性计算:
computed如何在读取时才计算、依赖不变时返回缓存值
二、概念与定义
2.1 核心概念
| 概念 | 类比 | 说明 |
|---|---|---|
| 响应式对象 | 一个会”广播”变化的冰箱 | Proxy 包装后,读写操作会被拦截并广播 |
| 依赖(Dependency) | 贴在冰箱上的便签 | 记录了哪些函数正在使用某个属性 |
| 副作用(Effect) | 根据冰箱存货决定的购物清单 | 一个会在数据变化时重新运行的函数 |
| 主动追踪(Track) | 扫描冰箱时记录需要的食材 | GET 拦截时收集当前副作用 |
| 触发更新(Trigger) | 冰箱门打开食材被更换了 | SET 拦截时通知所有相关副作用重新执行 |
2.2 Vue3 响应式 vs Vue2 响应式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Vue2 (Object.defineProperty)
Object.keys(data).forEach(key => {
let internalValue = data[key];
Object.defineProperty(data, key, {
get() { /* 收集依赖 */ return internalValue; },
set(val) { internalValue = val; /* 触发更新 */ }
});
});
// 缺陷:新增/删除属性无法检测(需 Vue.set/Vue.delete)
// 缺陷:数组索引/长度变更无法检测
// Vue3 (Proxy)
const proxy = new Proxy(data, {
get(target, key, receiver) {
track(target, key);
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver);
trigger(target, key);
return result;
}
});
// 优势:可拦截所有属性操作(包括新增、删除、in 运算符)
// 优势:完整支持数组变更检测
三、最小示例:30 行构建响应式核心
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
// ========== reactive 核心:Proxy 拦截 ==========
const targetMap = new WeakMap(); // 对象 → Map(属性 → Set(副作用))
let activeEffect = null; // 当前正在执行的副作用
// 拦截 GET:收集依赖
function track(target, key) {
if (!activeEffect) return;
let depsMap = targetMap.get(target);
if (!depsMap) {
depsMap = new Map();
targetMap.set(target, depsMap);
}
let deps = depsMap.get(key);
if (!deps) {
deps = new Set();
depsMap.set(key, deps);
}
deps.add(activeEffect);
}
// 拦截 SET:触发更新
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const deps = depsMap.get(key);
if (deps) {
deps.forEach(effect => {
// 避免无限循环:如果是当前正在执行的 effect,跳过
if (effect !== activeEffect) {
effect();
}
});
}
}
// reactive 函数
function reactive(target) {
if (typeof target !== 'object' || target === null) return target;
return new Proxy(target, {
get(target, key, receiver) {
track(target, key);
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
const oldValue = Reflect.get(target, key, receiver);
const result = Reflect.set(target, key, value, receiver);
if (oldValue !== value) {
trigger(target, key);
}
return result;
}
});
}
// effect 函数
function effect(fn) {
const wrapped = () => {
activeEffect = wrapped;
fn();
activeEffect = null;
};
wrapped(); // 立即执行以收集依赖
return wrapped;
}
// ========== 使用示例 ==========
const state = reactive({ count: 0, name: '响应式' });
effect(() => {
console.log(`count 发生变化:${state.count}`);
});
state.count = 1; // 输出:count 发生变化:1
state.count = 2; // 输出:count 发生变化:2
state.name = '测试'; // 不输出(name 不在 effect 依赖中)
验证
将上述代码粘贴到浏览器控制台或 Node.js 运行:
1
2
3
4
5
6
7
8
9
10
11
node -e "
$(cat << 'EOF'
// 粘贴上述 reactive/effect 代码
const state = reactive({ count: 0, name: '响应式' });
effect(() => { console.log('count:', state.count); });
state.count = 1;
state.count = 2;
state.name = '不触发'; // 不会触发 effect
EOF
)
"
输出:
1
2
3
count: 0
count: 1
count: 2
四、核心知识点拆解
4.1 WeakMap 的选择:内存安全
targetMap 使用了 WeakMap 而不是普通的 Map。这是一个关键的设计决策:
1
2
// WeakMap 的 key 是弱引用
const targetMap = new WeakMap();
- Map 的 key 是强引用:即使外部不再引用目标对象,Map 仍然持有引用,导致无法被 GC 回收 → 内存泄漏
- WeakMap 的 key 是弱引用:外部引用消失后,WeakMap 中的键值对会自动被 GC 回收
真实场景:在 Vue 组件中,reactive() 创建的响应式对象在组件销毁后,如果没有 WeakMap 的自动回收,所有依赖信息会永远留在内存中。
4.2 嵌套对象的响应式处理
最简单的 reactive 实现只代理了第一层属性。对于嵌套对象(如 state.user.name),我们需要递归处理:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function reactive(target) {
if (typeof target !== 'object' || target === null) return target;
// 防止重复代理
if (target.__v_raw) return target;
return new Proxy(target, {
get(target, key, receiver) {
track(target, key);
const result = Reflect.get(target, key, receiver);
// 关键:如果读取的值是对象,递归使其也成为响应式
if (typeof result === 'object' && result !== null) {
return reactive(result);
}
return result;
},
set(target, key, value, receiver) {
const oldValue = Reflect.get(target, key, receiver);
const result = Reflect.set(target, key, value, receiver);
if (oldValue !== value && !Object.is(oldValue, value)) {
trigger(target, key);
}
return result;
}
});
}
延迟代理:Vue3 采用了”延迟代理”策略——直到实际访问嵌套属性时,才创建该子对象的 Proxy。这避免了初始化时递归遍历整个对象树的性能开销。
4.3 分支切换与依赖清理
考虑以下场景:
1
2
3
4
5
6
7
8
9
const state = reactive({ show: true, name: 'Alice', age: 25 });
effect(() => {
if (state.show) {
console.log(state.name); // 依赖 show 和 name
} else {
console.log(state.age); // 依赖 show 和 age
}
});
当 state.show 从 true 变为 false 时,effect 重新执行会走向 else 分支。此时:
- 之前收集的
name依赖应该被清除 age依赖应该被重新收集
不清理的话,修改 state.name 仍然会触发这个 effect 重新执行 → 不必要的更新。
解决方案:在每次执行 effect 前清理旧依赖,执行后重新收集:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
function effect(fn) {
const effectFn = () => {
cleanup(effectFn); // 清除旧的依赖关系
activeEffect = effectFn;
fn();
activeEffect = null;
};
// 在 effect 函数上挂载依赖集合用于清理
effectFn.deps = [];
effectFn();
return effectFn;
}
function cleanup(effectFn) {
for (let i = 0; i < effectFn.deps.length; i++) {
const deps = effectFn.deps[i]; // deps 是一个 Set
deps.delete(effectFn);
}
effectFn.deps.length = 0;
}
// 在 track 中需要记录
function track(target, key) {
if (!activeEffect) return;
let depsMap = targetMap.get(target);
if (!depsMap) { targetMap.set(target, (depsMap = new Map())); }
let deps = depsMap.get(key);
if (!deps) { depsMap.set(key, (deps = new Set())); }
deps.add(activeEffect);
// 让 activeEffect 记住这个 deps 集合(用于后续 cleanup)
activeEffect.deps.push(deps);
}
4.4 computed 的实现
computed 的核心特性是惰性求值 + 缓存:只有当读取 .value 且依赖变化时,才重新计算。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function computed(getter) {
// 缓存:值和是否需要重新计算的标志
let cachedValue;
let dirty = true; // 第一次读取时需计算
const runner = effect(() => {
if (!dirty) {
dirty = true;
// 通知依赖 computed 的 effect 更新
trigger(computedRef, 'value');
}
});
const computedRef = {
get value() {
track(computedRef, 'value');
if (dirty) {
cachedValue = getter();
dirty = false;
}
return cachedValue;
}
};
return computedRef;
}
对比简化版和 Vue3 源码版:
- 简化版:每次 effect 执行时标记
dirty = true,下次读取时重新计算 - 完整版:需要区分嵌套 effect 时的优先级——computed 的 effect 不应该把 track 污染到外层
4.5 调度器(Scheduler)
Vue3 的 effect 支持一个可选的调度器选项,用于控制副作用的执行时机和方式:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn);
activeEffect = effectFn;
fn();
activeEffect = null;
};
effectFn.options = options;
effectFn.deps = [];
if (!options.lazy) {
effectFn(); // 立即执行
}
return effectFn;
}
// 在 trigger 中
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const effects = new Set(depsMap.get(key));
effects.forEach(fn => {
if (fn.options.scheduler) {
fn.options.scheduler(fn); // 由调度器决定执行时机
} else {
fn();
}
});
}
调度器的经典用法:Vue 的异步队列更新
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const queue = new Set();
let isFlushing = false;
function queueJob(job) {
queue.add(job);
if (!isFlushing) {
isFlushing = true;
Promise.resolve().then(() => {
queue.forEach(fn => fn());
queue.clear();
isFlushing = false;
});
}
}
effect(() => {
// 渲染副作用——多次同步修改只会触发一次更新
updateDOM(state.count);
}, {
scheduler: queueJob
});
state.count = 1;
state.count = 2;
state.count = 3;
// 只会执行一次 updateDOM(3),通过 Promise 微任务合并
五、实战案例:带依赖追踪的待办事项管理系统
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>响应式 TodoMVC - 手写版</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
background: #f5f5f5;
padding: 40px;
}
.todo-app {
max-width: 500px;
margin: 0 auto;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0,0,0,0.1);
padding: 24px;
}
h1 { font-size: 24px; margin-bottom: 20px; color: #333; }
.input-row { display: flex; gap: 8px; margin-bottom: 16px; }
.input-row input {
flex: 1;
padding: 10px 14px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
.input-row input:focus { border-color: #4a90d9; }
.input-row button {
padding: 10px 20px;
background: #4a90d9;
color: #fff;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
transition: background 0.2s;
}
.input-row button:hover { background: #357abd; }
.todo-item {
display: flex;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
transition: opacity 0.3s;
}
.todo-item:last-child { border-bottom: none; }
.todo-item input[type="checkbox"] {
margin-right: 12px;
width: 18px;
height: 18px;
cursor: pointer;
}
.todo-item .todo-text {
flex: 1;
font-size: 14px;
color: #333;
cursor: pointer;
}
.todo-item .todo-text.completed {
text-decoration: line-through;
color: #999;
}
.todo-item .delete-btn {
background: none;
border: none;
color: #ccc;
cursor: pointer;
font-size: 18px;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.2s;
}
.todo-item .delete-btn:hover {
color: #e74c3c;
background: #fff0f0;
}
.stats {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
display: flex;
justify-content: space-between;
font-size: 13px;
color: #999;
}
.stats .count { font-weight: bold; color: #333; }
.clear-btn {
background: none;
border: none;
color: #e74c3c;
cursor: pointer;
font-size: 13px;
}
.clear-btn:hover { text-decoration: underline; }
</style>
</head>
<body>
<div id="app"></div>
<script>
// ========== 1. 完善版响应式系统 ==========
const targetMap = new WeakMap();
let activeEffect = null;
function track(target, key) {
if (!activeEffect) return;
let depsMap = targetMap.get(target);
if (!depsMap) { targetMap.set(target, depsMap = new Map()); }
let deps = depsMap.get(key);
if (!deps) { depsMap.set(key, deps = new Set()); }
deps.add(activeEffect);
activeEffect.deps.push(deps);
}
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const effects = new Set(depsMap.get(key));
effects.forEach(fn => {
if (fn.options?.scheduler) {
fn.options.scheduler(fn);
} else {
fn();
}
});
}
function cleanup(effectFn) {
for (const deps of effectFn.deps) {
deps.delete(effectFn);
}
effectFn.deps.length = 0;
}
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn);
activeEffect = effectFn;
fn();
activeEffect = null;
};
effectFn.options = options;
effectFn.deps = [];
if (!options.lazy) effectFn();
return effectFn;
}
function reactive(target) {
if (typeof target !== 'object' || target === null) return target;
return new Proxy(target, {
get(target, key, receiver) {
track(target, key);
const result = Reflect.get(target, key, receiver);
if (typeof result === 'object' && result !== null) {
return reactive(result);
}
return result;
},
set(target, key, value, receiver) {
const oldValue = Reflect.get(target, key, receiver);
const result = Reflect.set(target, key, value, receiver);
if (oldValue !== value && !Object.is(oldValue, value)) {
trigger(target, key);
}
return result;
}
});
}
function computed(getter) {
let cachedValue;
let dirty = true;
const runner = effect(getter, {
lazy: true,
scheduler: () => {
if (!dirty) {
dirty = true;
trigger(computedRef, 'value');
}
}
});
const computedRef = {
get value() {
track(computedRef, 'value');
if (dirty) {
cachedValue = runner();
dirty = false;
}
return cachedValue;
}
};
return computedRef;
}
// ========== 2. 迷你渲染引擎 ==========
// 一个简化的响应式 UI 渲染器
function createRenderer() {
const updateQueue = new Set();
let isFlushing = false;
function scheduleUpdate(fn) {
updateQueue.add(fn);
if (!isFlushing) {
isFlushing = true;
Promise.resolve().then(() => {
updateQueue.forEach(f => f());
updateQueue.clear();
isFlushing = false;
});
}
}
return { scheduleUpdate };
}
const { scheduleUpdate } = createRenderer();
// ========== 3. Todo 应用逻辑 ==========
// 创建响应式状态
const todos = reactive([
{ id: 1, text: '学习 Proxy 和 Reflect', completed: false },
{ id: 2, text: '手写 reactive 函数', completed: true },
{ id: 3, text: '手写 computed', completed: false },
]);
const state = reactive({
newTodo: '',
filter: 'all', // 'all' | 'active' | 'completed'
editingId: null,
nextId: 4,
});
// 计算属性
const filteredTodos = computed(() => {
const filter = state.filter;
if (filter === 'all') return todos;
const completed = filter === 'completed';
return todos.filter(t => t.completed === completed);
});
const activeCount = computed(() => {
return todos.filter(t => !t.completed).length;
});
const completedCount = computed(() => {
return todos.filter(t => t.completed).length;
});
// 操作方法
function addTodo() {
const text = state.newTodo.trim();
if (!text) return;
todos.push({
id: state.nextId++,
text,
completed: false
});
state.newTodo = '';
}
function deleteTodo(id) {
const index = todos.findIndex(t => t.id === id);
if (index !== -1) todos.splice(index, 1);
}
function toggleTodo(id) {
const todo = todos.find(t => t.id === id);
if (todo) todo.completed = !todo.completed;
}
function clearCompleted() {
for (let i = todos.length - 1; i >= 0; i--) {
if (todos[i].completed) todos.splice(i, 1);
}
}
// ========== 4. 绑定 UI ==========
const app = document.getElementById('app');
function render() {
const filterList = filteredTodos.value;
const active = activeCount.value;
const completed = completedCount.value;
app.innerHTML = `
<div class="todo-app">
<h1>📋 响应式待办</h1>
<div class="input-row">
<input id="newTodoInput" value="${state.newTodo}"
placeholder="输入新的待办事项..." />
<button id="addBtn">添加</button>
</div>
<div>
${filterList.map(todo => `
<div class="todo-item" data-id="${todo.id}">
<input type="checkbox" ${todo.completed ? 'checked' : ''} />
<span class="todo-text ${todo.completed ? 'completed' : ''}"
ondblclick="startEdit(${todo.id})">${todo.text}</span>
<button class="delete-btn">×</button>
</div>
`).join('')}
</div>
<div class="stats">
<span>剩余 <span class="count">${active}</span> 项</span>
${completed > 0 ? '<button class="clear-btn" id="clearBtn">清除已完成</button>' : ''}
</div>
<div style="margin-top: 12px; display: flex; gap: 8px;">
${['all', 'active', 'completed'].map(f => `
<button style="padding: 4px 12px; border: 1px solid ${state.filter === f ? '#4a90d9' : '#ddd'};
background: ${state.filter === f ? '#4a90d9' : '#fff'};
color: ${state.filter === f ? '#fff' : '#333'};
border-radius: 4px; cursor: pointer; font-size: 12px;"
onclick="setFilter('${f}')">${f}</button>
`).join('')}
</div>
</div>
`;
// 绑定事件
document.getElementById('newTodoInput')?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') addTodo();
});
document.getElementById('newTodoInput')?.addEventListener('input', (e) => {
state.newTodo = e.target.value;
});
document.getElementById('addBtn')?.addEventListener('click', addTodo);
document.getElementById('clearBtn')?.addEventListener('click', clearCompleted);
document.querySelectorAll('.todo-item').forEach(item => {
const id = parseInt(item.dataset.id);
const checkbox = item.querySelector('input[type="checkbox"]');
const deleteBtn = item.querySelector('.delete-btn');
checkbox?.addEventListener('change', () => toggleTodo(id));
deleteBtn?.addEventListener('click', () => deleteTodo(id));
});
}
function setFilter(filter) {
state.filter = filter;
}
function startEdit(id) {
state.editingId = id;
}
// 用 effect 注册渲染逻辑,数据变化自动重渲染
effect(() => {
// 访问依赖,建立追踪
filteredTodos.value;
activeCount.value;
completedCount.value;
state.filter;
state.newTodo;
scheduleUpdate(render);
});
</script>
</body>
</html>
案例亮点
- 纯手写响应式系统:未使用任何第三方库,完整实现了 reactive/effect/computed
- 调度器批处理:多次状态变更合并为一次 UI 更新(通过微任务)
- 计算属性缓存:
filteredTodos只在 filter 或 todos 变化时才重新计算 - 分支切换:
cleanup确保动态条件分支下的依赖正确性
六、底层原理
6.1 Proxy vs Object.defineProperty: V8 引擎视角
从 V8 引擎的优化角度来看两种方案:
Object.defineProperty 的问题:
1
2
3
4
5
6
7
8
// V8 对对象使用 Hidden Class(隐藏类)进行形状优化
const obj = { a: 1, b: 2 };
// V8: obj 的 Hidden Class ≈ Map({a: 0, b: 1})
// 使用 defineProperty 后,属性变成 accessor property
Object.defineProperty(obj, 'a', { get() {}, set() {} });
// V8: a 变成 accessor pair,不再存储在对象内联属性中
// 访问 a 需要调用 accessor 函数,V8 的 Inline Cache(IC) 失效
当属性被 Object.defineProperty 定义为访问器属性时,V8 会为对象创建 DeOpt 后的表示,速度减慢约 10-30 倍。
Proxy 的优势:
Proxy 在 V8 中有专门的优化路径(自 V8 v6.9+ 起)。当 Proxy 的 handler 方法(get/set)是 JavaScript 函数时,V8 会尝试内联处理:
1
2
3
4
V8 Proxy 优化路径(简化):
1. 检查 target 和 handler 是否稳定(未替换)
2. 内联 handler.get 到调用方
3. 如果 handler 不变,下次调用直接走内联路径
6.2 依赖追踪的粒度
Vue3 的依赖追踪粒度经历了两个阶段:
Vue3.0 - 属性级:
1
2
3
state.user.name
→ state 的 "user" key(track state, 'user')
→ user 对象的 "name" key(track user, 'name')
Vue3.4+ - 引入了对数组和 Map 的更深优化:
对于数组,Vue3 拦截了会引起长度变化的操作(push、pop、splice 等),使用了一个技巧:
1
2
3
4
5
6
7
8
9
10
11
// 源码中,数组的某些方法会触发两次追踪
const arrayInstrumentations = {};
['push', 'pop', 'shift', 'unshift', 'splice'].forEach(key => {
arrayInstrumentations[key] = function(...args) {
// 暂停追踪——防止 push 操作读取 length 产生循环依赖
pauseTracking();
const result = Array.prototype[key].apply(this, args);
resetTracking();
return result;
};
});
这个细节非常精妙:arr.push(1) 会读取 arr.length,如果此时正在追踪,就会把当前的 effect 意外地收集到 length 的依赖中,造成不必要的更新。
6.3 Vue3 源码架构
1
2
3
4
5
6
7
packages/reactivity/src/
├── reactive.ts # reactive() / readonly() / shallowReactive()
├── effect.ts # effect() / track() / trigger()
├── computed.ts # computed()
├── ref.ts # ref() / isRef() / unref()
├── baseHandlers.ts # Proxy handler 实现
└── operations.ts # TrackOpTypes / TriggerOpTypes 枚举
核心数据流:
1
2
3
4
5
6
7
8
9
10
11
reactive(obj) → Proxy(obj, baseHandlers)
↓
effect(fn) → fn() 访问 obj.key
↓
track(obj, 'key')
→ targetMap.get(obj).get('key').add(activeEffect)
↓
设置 obj.key = newValue
↓
trigger(obj, 'key')
→ targetMap.get(obj).get('key').forEach(fn => fn())
6.4 内存与性能权衡
响应式系统的代价:
内存开销:每个响应式对象需要一个 Proxy 对象(约 40-80 字节),每个属性需要维护一个 Set 的依赖集合。1000 个对象 × 每个 10 个属性 ≈ 10,000 个依赖 Set,约 500KB-1MB 内存。
访问性能:Proxy 的 get/set 比普通属性访问慢约 2-10 倍。但在实际应用中,绝大多数属性访问发生在热循环之外,性能差异可忽略不计。
Vue 内部的优化:使用
ReactiveFlags(如__v_raw、__v_isReadonly)标记 Proxy 对象,避免重复代理。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// reactive.ts (简化)
export const enum ReactiveFlags {
SKIP = '__v_skip',
IS_REACTIVE = '__v_isReactive',
IS_READONLY = '__v_isReadonly',
RAW = '__v_raw',
}
function reactive(target) {
// 如果已经是 reactive,返回自身
if (target[ReactiveFlags.IS_REACTIVE]) return target;
// 跳过白名单对象
if (target[ReactiveFlags.SKIP]) return target;
// ...创建 Proxy
}
七、高频面试题解析
面试题 1:Vue3 的 reactive 和 ref 有什么区别?ref 的内部实现是什么?
问题分析:考察对基础 API 的理解深度。很多人知道 ref 用于基本类型,reactive 用于对象,但 ref 的”包装”机制是什么?
深度解答:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// ref 的核心实现(简化)
function ref(value) {
// 如果已经是 ref,直接返回
if (value && value.__v_isRef) return value;
// ref 的本质:将值包装成一个带有 value 属性的对象
return new RefImpl(value);
}
class RefImpl {
constructor(value) {
this.__v_isRef = true;
// 如果 value 是对象,用 reactive 包装它的深层
this._rawValue = value;
this._value = toReactive(value); // toReactive 内部会调用 reactive
}
get value() {
track(this, 'value');
return this._value;
}
set value(newVal) {
if (hasChanged(newVal, this._rawValue)) {
this._rawValue = newVal;
this._value = toReactive(newVal);
trigger(this, 'value');
}
}
}
function toReactive(value) {
return isObject(value) ? reactive(value) : value;
}
关键洞察:
ref内部是一个带有value属性的对象,通过get value()/set value()实现依赖追踪- 当
ref()接收对象时,内部自动调用reactive()将其深度响应化 ref的存在是因为 Proxy 无法拦截基本类型(如数字、字符串)- 在模板中自动解包是 Vue 编译器的魔法,不是 ref 本身的能力
面试题 2:effect 的嵌套怎么处理?Vue3 如何维护 activeEffect 的栈?
问题分析:当 effect 内又调用了另一个 effect(如组件嵌套渲染)时,activeEffect 会被覆盖。
深度解答:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 问题场景
effect(() => {
// 外层 effect A
effect(() => {
// 内层 effect B
console.log(state.inner);
});
console.log(state.outer); // ❌ 此时 activeEffect 还是 B!
});
// Vue3 的解决方案:effect 栈
const effectStack = []; // 相当于函数调用栈
function effect(fn) {
const effectFn = () => {
cleanup(effectFn);
// 入栈
effectStack.push(effectFn);
activeEffect = effectFn;
fn();
// 出栈,恢复为上一个 effect
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1] || null;
};
// ...
}
原理:effect 栈模拟了函数调用栈的行为。内层 effect 执行完毕后,通过 effectStack.pop() 恢复父级 effect 为 activeEffect。这确保了 track 操作永远正确地关联到当前执行的 effect。
面试题 3:Vue2 无法检测数组下标变更,Vue3 为什么可以?Proxy 如何处理数组的 push/pop?
问题分析:深挖数组拦截的细节,考察是否跟踪过 Vue3 的源码实现。
深度解答:
为什么 Vue2 不行: Vue2 使用 Object.defineProperty 对数组的每个索引进行劫持。但性能开销巨大——一个长度为 1000 的数组需要定义 1000 个 getter/setter。因此 Vue2 选择放弃对索引的拦截,转而重写 7 个数组方法(push、pop、shift、unshift、splice、sort、reverse)。
Vue3 Proxy 天然支持: Proxy 的 get / set 陷阱天然可以拦截所有属性访问,包括数字索引:
1
2
3
4
5
const arr = reactive([1, 2, 3]);
// 以下操作全部被 Proxy 拦截:
arr[0] = 10; // set(0, 10) ✓
arr[100] = 5; // set(100, 5) ✓ — Vue2 无法检测
arr.length = 1; // set(length, 1) ✓ — Vue2 无法检测
但是 Proxy 也有陷阱: Array.prototype.push 会同时触发多项操作:
1
2
3
4
5
// arr.push(4) 内部发生了什么?
arr.push(4);
// 1. arr.length → get('length') — 读取长度
// 2. arr[3] = 4 → set(3, 4) — 设置新元素
// 3. arr.length = 4 → set('length', 4) — 更新长度
问题在于:步骤 1 读取 length 时可能会意外追踪到 length 依赖;步骤 2 和 3 会触发多次 trigger。
Vue3 的处理方式(已在 4.5 节提及):拦截 push 等修改方法,在执行期间暂停依赖追踪:
1
2
3
4
5
6
7
8
9
10
11
12
13
// Vue3 源码中的数组重写
const arrayProto = Array.prototype;
const arrayMethods = Object.create(arrayProto);
['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
.forEach(method => {
arrayMethods[method] = function(...args) {
pauseTracking(); // ⭐ 暂停追踪
const result = arrayProto[method].apply(this, args);
resetTracking(); // ⭐ 恢复追踪
return result;
};
});
八、总结与扩展
已实现的核心能力
| 能力 | 实现状态 |
|---|---|
| Proxy 拦截 get/set | ✔️ |
| 依赖收集(track) | ✔️ |
| 触发更新(trigger) | ✔️ |
| effect 函数 | ✔️ |
| 分支切换(cleanup) | ✔️ |
| 嵌套 effect(effect 栈) | ✔️ |
| computed 惰性求值 | ✔️ |
| 调度器(scheduler) | ✔️ |
| 递归响应式 | ✔️ |
| 数组响应式 | ✔️ |
未实现但 Vue3 包含的
readonly():只读代理shallowReactive()/shallowRef():浅层响应式toRef()/toRefs():解构响应式对象时保持响应性watchEffect():基于 effect 的侦听器customRef():自定义 ref(如防抖输入)markRaw():标记对象不被代理
从手写源码到面试满分
掌握响应式系统的原理,不仅应对像”手写 reactive”这样的面试题,更重要的是理解 Vue3 的设计决策:
- 为什么 Proxy 完胜 defineProperty? → 拦截全面性 + 数组支持 + 动态属性
- 为什么需要 WeakMap? → 内存安全,避免对象未被回收
- 为什么 computed 需要脏检查标志? → 避免每次访问都重新计算
- 为什么需要调度器? → 异步批量更新,避免重复渲染
- 为什么 effect 需要栈? → 正确追踪嵌套 effect 的依赖
当你把这些问题都串联起来,就已经完全掌握了 Vue3 响应式系统的精髓。