Vue 响应式底层原理:Proxy 与视图更新(P6)
目前顯示中文(中國)原文
来源:程序员方格《Vue 面试》P6:Vue 响应式底层原理——Proxy,约 10 分 17 秒。根据本集课件画面与代码演示整理;技术补充另行标明。
核心结论
Proxy 解决“如何拦截对象操作”,依赖追踪解决“数据变化后通知谁”,视图更新由相关副作用和调度机制完成。
本集从手动更新 DOM 开始,对比 Proxy 与 Object.defineProperty,演示读取、赋值、数组操作,最后把 set 拦截与计数器的 DOM 更新连接起来。下一集继续讲 track、trigger 与依赖追踪。
1. 为什么需要响应式
手动更新页面时,开发者需要在每个改变数据的位置安排 DOM 更新。数据与视图的联系分散在事件处理函数中,容易出现“数据改了,某处显示没有更新”的问题。
响应式系统把这层联系统一管理:读取数据时建立依赖,数据变化时通知依赖它的工作,再执行相应更新。开发者主要描述状态和视图之间的关系。
不过,JavaScript 提供的 Proxy 只是实现这套机制的一项工具。单独创建一个代理对象,并不会让浏览器自动更新 DOM。
2. Proxy 拦截的是对象操作
const raw = { count: 0 };
const state = new Proxy(raw, {
get(target, key, receiver) {
console.log("get", key);
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
console.log("set", key, value);
return Reflect.set(target, key, value, receiver);
},
});
state.count; // get count
state.count++; // get count,然后 set count 1
new Proxy(target, handler) 返回一个新的代理对象。handler 中的方法称为 trap,用于拦截对应的内部操作。
| 操作 | 对应的拦截入口 |
|---|---|
state.count |
get |
state.count = 1 |
set |
state.count++ |
先读取,再写入,因此会涉及 get 和 set |
delete state.count |
deleteProperty |
"count" in state |
has |
Object.keys(state) 等枚举操作 |
涉及 ownKeys 和属性描述符查询 |
补充:只实现 get、set,并不能记录删除或枚举操作。 没有提供的 trap 通常会按目标对象的默认行为转发;要建立相应的响应式依赖,还需要实现对应逻辑。
为什么示例使用 Reflect
视频中的基础示例用 target[key] 读写目标。上面的整理版改用 Reflect.get、Reflect.set,以保留常规属性访问语义,并传递 receiver。
receiver 与访问器中的 this、原型链上的读写有关。在存在 getter、setter 或继承关系时,直接访问 target[key] 与通过代理访问,行为可能不同。
set trap 应返回表示操作是否成功的布尔值。返回 Reflect.set(...) 的结果,可以保留写入失败的语义;不能把无条件 return true 当成通用实现。
3. 与 Object.defineProperty 的区别
视频用“Proxy 拦截行为,defineProperty 拦截属性”帮助记忆。更准确地说,Object.defineProperty 用来定义或修改某一个属性的描述符,而 Proxy 在对象外层提供操作拦截入口。
| 比较项 | Vue 2 常见的属性访问器方案 | Vue 3 的 Proxy 对象代理方案 |
|---|---|---|
| 拦截方式 | 遍历属性,逐个设置 getter、setter | 创建代理,通过 trap 拦截相关操作 |
| 普通属性读写 | 经过已转换属性的 getter、setter | 通过代理访问时进入 get、set |
| 新增属性 | 初始化时没有对应访问器,通常需要 Vue.set 等补充机制 |
通过代理赋值可进入 set,再由框架识别新增与更新 |
| 删除属性 | 需要 Vue.delete 等补充机制通知依赖 |
可在 deleteProperty 中处理 |
| 数组变化 | Vue 2 对部分变更方法做了包装,直接改下标或长度存在限制 | 可拦截索引和显式长度赋值,框架仍需处理相关依赖 |
| 是否自动更新视图 | 还需要依赖管理与更新机制 | 同样需要依赖管理与更新机制 |
技术校正: Object.defineProperty 本身可以定义新属性,也可以为数组的某个索引定义访问器。“不能新增属性”“不能监听数组下标”不是这项 JavaScript API 的绝对能力限制;面试中应说明,讨论的是 Vue 2 既有观察方案的限制。
4. 数组:看清显式操作与隐式变化
视频演示通过 Proxy 观察数组下标赋值和 push:
const list = new Proxy([0], {
set(target, key, value, receiver) {
console.log("set", key, value);
return Reflect.set(target, key, value, receiver);
},
});
list[0] = 1; // set "0" 1
list.push(2); // set "1" 2,然后 set "length" 2
需要补充一个重要边界:
list[5] = 9; // set "5" 9
console.log(list.length); // 6
写入越界索引会让数组长度随之增长,但这个隐式变化不会额外产生一次 set "length" 调用。因此,不能仅靠“有没有打印 length”判断数组长度是否改变。
完整的响应式系统需要结合索引写入、旧长度和新长度,正确通知依赖数组长度、索引或遍历结果的副作用。
5. 把拦截与视图更新连起来
下面沿用视频的计数器思路,将状态写入与 DOM 更新连接起来。这是一个平面状态、单一视图的演示:
<p id="count">0</p>
<button id="inc">+1</button>
<script>
const output = document.querySelector("#count");
const raw = { count: 0 };
const state = new Proxy(raw, {
get(target, key, receiver) {
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
const previous = Reflect.get(target, key, receiver);
const success = Reflect.set(target, key, value, receiver);
if (success && key === "count" && !Object.is(previous, value)) {
output.textContent = String(state.count);
}
return success;
},
});
output.textContent = String(state.count);
document.querySelector("#inc").addEventListener("click", () => {
state.count++;
});
</script>
点击按钮后,count++ 先读取再写入,写入时执行我们安排的 DOM 更新。页面改变的原因,是开发者在 set 中写了更新逻辑。
这个例子没有实现依赖收集、多视图订阅、任务调度、深层代理和依赖清理,不能当作 Vue 响应式源码。它用于说明:拦截变化以后,还必须决定执行什么更新。
6. 从演示走向 Vue 响应式,还要补齐什么
以下是结合 Vue 文档的补充,用来明确本集知识的适用范围:
- 依赖追踪: 在副作用执行并读取属性时,记录“目标对象、属性与当前副作用”的关系;写入时找到相关依赖,再按更新策略调度。
- 深层对象:
new Proxy(raw, handler)不会自动把所有嵌套对象都代理。Vue 的深层响应式还需要在读取嵌套对象时返回相应代理,并通过缓存保持代理身份稳定。 - 访问入口: 直接执行
raw.count = 2会绕过上面代理的set。使用 Vue 响应式对象时,应通过相应的代理或响应式 API 修改状态。 - ref: Vue 3 的响应式机制不只有 Proxy。
ref的.value使用 getter、setter 参与依赖追踪与触发;不能把“Vue 3 响应式”全部等同于 Proxy。
面试回答示例
Vue 3 使用 Proxy 代理对象,通过 get、set,以及删除、枚举等相关拦截入口感知对象操作。相比 Vue 2 逐个属性转换 getter、setter 的方案,它更便于覆盖新增属性、删除属性和数组索引变化。但 Proxy 本身不负责自动更新页面。Vue 还需要在读取时收集依赖,在写入时找到并调度相关副作用,最终完成视图更新。还要注意,直接修改原始对象会绕过代理,普通 Proxy 也不会自动产生深层响应式。
关联笔记
- Proxy 与 Vue 2 属性拦截的区别:对照复习两种方案及版本边界。
- track、trigger、动态依赖与清理:继续理解“知道数据变了以后,应该通知谁”。
- Vue 响应式陷阱与 ref 自动解包纠错:跟进解构、代理与自动解包中的常见误区。
- ref、reactive、computed 与 watch:把底层机制对应到日常 API 的选择。