2.1 响应式原理与依赖收集


文档摘要

2.1 响应式原理与依赖收集 本节摘要:响应式原理的核心是依赖收集——数据在被读取时记下读取者,在被修改时通知这些读取者。Vue 2 用 Object.defineProperty 逐属性劫持实现,Vue 3 改用 Proxy 整对象代理。本节手写两版迷你实现拆解机制,并解释 Vue 2 的两个著名盲区:新增属性不更新、数组索引赋值不更新。 学习目标 阅读完本节,你应当能够: 手写一个百行以内的响应式内核,说清 getter 记录、setter 通知的闭环; 解释 Vue 2 新增属性与数组索引两个盲区的成因与官方补丁方案; 对比 Proxy 方案的能力优势与一个不多的代价(不能感知 == 号比较之类的边角); 用依赖图解释"为什么修改不存在的属性没有反应"这类真实报错场景。

2.1 响应式原理与依赖收集

本节摘要:响应式原理的核心是依赖收集——数据在被读取时记下读取者,在被修改时通知这些读取者。Vue 2 用 Object.defineProperty 逐属性劫持实现,Vue 3 改用 Proxy 整对象代理。本节手写两版迷你实现拆解机制,并解释 Vue 2 的两个著名盲区:新增属性不更新、数组索引赋值不更新。

学习目标

阅读完本节,你应当能够:

  1. 手写一个百行以内的响应式内核,说清 getter 记录、setter 通知的闭环;
  2. 解释 Vue 2 新增属性与数组索引两个盲区的成因与官方补丁方案;
  3. 对比 Proxy 方案的能力优势与一个不多的代价(不能感知 == 号比较之类的边角);
  4. 用依赖图解释"为什么修改不存在的属性没有反应"这类真实报错场景。

为什么需要依赖收集

先想一个朴素方案:数据一变就把整个页面重渲染。可行,但粗糙——页面上一百个数据,改一个全量刷新,浪费且会丢输入焦点。精细化的前提是知道每个数据被谁用了

数据不会自己开口,只能在被访问的瞬间"顺手登记"。JavaScript 里拦截对象属性的读写只有两条路:属性描述符(defineProperty)与代理对象(Proxy)。Vue 2 选了前者,Vue 3 换成后者,但登记逻辑的骨架一脉相承:

依赖收集与派发更新全景

依赖收集与派发更新全景

手写 Vue 2 风格的迷你响应式

不需要任何框架,四十行以内就能复刻 Vue 2 的核心机制:

let activeWatcher = null; // 当前正在登记的读者 function defineReactive(obj, key, val) { const dep = []; // 这份登记簿就是 Dep 的雏形 Object.defineProperty(obj, key, { enumerable: true, get() { if (activeWatcher) dep.push(activeWatcher); // 读取时登记 return val; }, set(newVal) { if (newVal === val) return; val = newVal; dep.forEach(w => w.update()); // 修改时通知 } }); } function watcher(fn) { const w = { update() { activeWatcher = w; fn(); // 重新执行,会再次触发 getter 完成新一轮登记 activeWatcher = null; } }; w.update(); // 首次执行,完成初始收集 }

用法:

const state = {}; defineReactive(state, 'count', 0); watcher(() => { // 这个渲染函数读了 state.count,于是被登记进它的 dep document.querySelector('#app').textContent = '计数:' + state.count; }); state.count = 1; // 页面自动变成 "计数:1",无需手动刷新

三个角色已经就位:数据属性(被劫持的 getter/setter)、Dep(每个属性一份的登记簿)、Watcher(读者,真实框架里分为渲染、计算、侦听三种)。注意 w.update() 里重新执行 fn 的细节——每次更新后重新收集依赖,这样模板里 v-if 分支切换时,依赖关系也能跟着变。

Vue 2 的两个盲区

defineProperty 只能劫持已存在的属性,这一实现级限制带来两个经典坑。

盲区一:新增属性不触发更新。

this.user = { name: '张三' }; // 模板里 {{ user.name }} 正常 this.user.age = 20; // 视图无反应!age 在劫持之后才出生

补救方案是框架提供的 set 方法:

this.$set(this.user, 'age', 20); // 手动触发劫持与通知 // 或整体替换引用 this.user = { ...this.user, age: 20 };

盲区二:数组索引赋值与长度修改不触发。

this.list[0] = '新值'; // 不更新 this.list.length = 0; // 不更新 this.list.push('新值'); // 更新!push 被特殊处理过

Vue 2 通过原型链劫持 patch 了七个变更方法:push、pop、shift、unshift、splice、sort、reverse。所以老工程里清空数组的惯用写法是 splice(0) 或整组换引用,而不是改 length。

Vue 3:Proxy 为什么能根治

Proxy 代理的是整个对象而非单个属性,"新增属性"这个概念在代理层根本不存在——任何属性的读写都会经过拦截器:

function reactive(obj) { return new Proxy(obj, { get(target, key, receiver) { track(target, key); // 登记 const res = Reflect.get(target, key, receiver); return typeof res === 'object' && res !== null ? reactive(res) : res; // 惰性深层代理 }, set(target, key, value, receiver) { const old = target[key]; const res = Reflect.set(target, key, value, receiver); if (old !== value) trigger(target, key); // 通知 return res; } }); }

于是在 Vue 3 里,state.user.age = 20 直接生效,list[0] = 'x'list.length = 0 也都能触发更新——两个盲区一并消失。深层对象采用"访问时才代理"的惰性策略,初始化开销比 Vue 2 递归遍历更小。

Proxy 也有边角:对基本类型无法代理(所以 Vue 3 用 ref 把基本值包进对象的 value 属性)、兼容性不支持 IE。前者引出了 ref 与 reactive 的分工——存基本值或需要整体替换的用 ref,存结构数据的用 reactive,第 10 章会展开。

三个真实场景的依赖图解释

掌握机制后,几个"诡异"现象都能秒读:

  • 改了数据视图没动:多半改的是非响应式来源——data 之外挂的属性、defineProperty 时代的新增键、解构后丢失代理的变量(let { x } = state 得到的 x 是普通值)。
  • 打印 console.log 有值但页面不更新:值确实变了,但它不在依赖登记簿上,通知链断了。
  • 深层对象性能担忧:Vue 3 只代理被访问的层级,未触碰的子树不花一分钱,这是相对 Vue 2 初始化全量递归的真实改进。

本节要点回顾

  • 闭环:getter 登记读者,setter 通知读者,Dep 是每个属性的登记簿,Watcher 是三种读者的统一抽象;
  • 重收集:每次更新后重新执行渲染函数,依赖关系随模板分支动态修正;
  • 盲区:Vue 2 新增属性与数组索引赋值不响应,用 set 方法或换引用绕过,七个数组方法被原型劫持;
  • 换代:Proxy 整对象代理根治盲区,惰性深代理降低初始化成本,代价是不支持 IE、基本类型需 ref 包装;
  • 排错直觉:视图不更新时,先问"这次修改走没走 setter、这个数据在不在登记簿上"。

数据的"变"已经被捕获,接下来看派生值与副作用怎么搭在这套机制上——计算属性与侦听器。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U