前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版

Vue响应式原理从defineProperty到Proxy

首页2021-01-16 17:20:22Front-End
Vue响应式原理Proxy

写 Vue 2 的项目,大概率你撞过这么一次:this.list[0] = newItem 执行完了,Vue DevTools 里数据确实变了,页面纹丝不动。换成 this.$set(this.list, 0, newItem) 立刻就好。当时可能就记住了「数组要用 $set」这个结论,但没想明白为什么。

这背后是 Vue 2 响应式实现方式带来的一整套边界条件。搞清楚 Object.defineProperty 在这条链路上具体做了什么,那些「什么时候能直接改、什么时候必须用 $set」的规则就不需要死记了,能自己推出来。

这篇从数据变化到视图更新的完整链路讲起,把依赖收集、数组拦截、新增属性、整体替换这几个场景逐个拆开,最后看看 Vue 3 换成 Proxy 之后补上了哪些洞。文章偏原理侧,更偏实践总结的版本可以看 Vue 响应式原理总结。

在本篇文章中,我们将从浅入深,和大家一起学习以下知识:

  • 从数据修改到 DOM 更新,中间到底经过了几道工序
  • Object.defineProperty 的 getter 和 setter 各自负责什么
  • 依赖收集里的 Dep 和 Watcher 是怎么配对的
  • 数组为什么监听不到,以及「defineProperty 做不到」这个说法哪里不准确
  • 数组变异方法的原型拦截是怎么实现的
  • 修改已有属性、新增属性、整体替换三种场景的正确写法
  • $forceUpdate 能让页面更新,但它和响应式不是一回事
  • Vue 3 换成 Proxy 之后哪些限制消失了,又引入了什么新约束

# 一、响应式的整条链路

先把全貌摆出来。

Vue 的响应式原理核心是通过 ES5 用于保护对象的 Object.defineProperty 中的访问器属性,也就是 get 和 set 方法。data 中声明的属性都被添加了访问器属性,当读取 data 中的数据时自动调用 get 方法,当修改 data 中的数据时自动调用 set 方法。检测到数据变化后会通知观察者 Watcher,Watcher 自动触发当前组件重新 render(子组件不会重新渲染),生成新的虚拟 DOM 树。Vue 框架会遍历并对比新旧两棵虚拟 DOM 树中每个节点的差别并记录下来,最后把所有记录的不同点局部修改到真实 DOM 树上。

Vue 响应式从数据变更到 DOM 更新的完整流程示意

这条链路可以拆成四道工序,每一道出问题的表现都不一样:

  • 劫持:初始化时递归遍历 data,给每个属性装上 getter/setter
  • 依赖收集:渲染过程中读取属性,getter 把当前正在渲染的 Watcher 记下来
  • 派发更新:属性被赋值,setter 通知所有记下来的 Watcher
  • 打补丁:Watcher 触发重新 render,diff 新旧虚拟 DOM,把差异打到真实 DOM 上

虚拟 DOM(Virtual DOM)是用 JS 对象模拟的,保存当前视图内所有 DOM 节点对象的基本描述属性和节点间关系的树结构。用 JS 对象描述每个节点及其父子关系,形成虚拟 DOM 对象树。

大部分「数据变了页面不动」的问题,卡的都是前两道工序。要么属性压根没被劫持,要么劫持了但依赖没收集上。

# 二、getter 和 setter 分别在干什么

很多人对 defineProperty 的理解停留在「拦截读写」,但读和写这两件事的分工是完全不对等的。

function defineReactive(obj, key, val) {
  const dep = new Dep(); // 每个属性一个依赖收集器

  Object.defineProperty(obj, key, {
    enumerable: true,
    configurable: true,
    get() {
      // 谁在读我,就把谁记下来
      if (Dep.target) {
        dep.depend();
      }
      return val;
    },
    set(newVal) {
      if (newVal === val) return;
      val = newVal;
      // 我变了,通知所有记下来的人
      dep.notify();
    }
  });
}
@前端进阶之旅: 代码已经复制到剪贴板

getter 干的是收集的活。Dep.target 是一个全局变量,指向当前正在执行的 Watcher。组件渲染的时候,Vue 先把这个组件的渲染 Watcher 挂到 Dep.target 上,再执行 render 函数。render 过程中读到哪个属性,哪个属性的 dep 就把这个 Watcher 收进去。

setter 干的是通知的活。属性被赋值时,遍历自己的 dep 里存的所有 Watcher,挨个通知它们该更新了。

所以有个推论很关键:没有在模板里被读到的属性,它的 dep 是空的,改了也不会触发任何更新。

这解释了一个常见的困惑。你在 data 里声明了一个属性但模板里没用,改它页面当然没反应,这不是 bug,是依赖收集根本没发生。关于 Object.defineProperty 本身的更多用法,可以看 Object.defineProperty 详解。

# 三、数组为什么监听不到

因为只要在 data 中声明的基本数据类型的数据基本不存在数据不响应的问题,所以重点是数组和对象在 Vue 中的数据响应问题。Vue 可以检测对象属性的修改,但无法监听数组的所有变动以及对象的新增和删除,只能使用数组变异方法及 $set 方法。

这里要澄清一个流传很广的说法。很多文章会写「Object.defineProperty 监听不到数组」,这句话其实不太准确。

数组的下标本身也是对象的属性,技术上完全可以对 arr[0]、arr[1] 逐个 defineProperty。Vue 2 没这么做,是主动权衡的结果。数组的长度可能是几万甚至几十万,初始化时逐个装 getter/setter 的开销扛不住,而且 push 之后新增的下标还得重新处理一遍。尤雨溪在多个场合都解释过这是性能取舍,不是能力缺失。

所以 Vue 2 换了个思路,不拦截下标,改成拦截方法。

Vue 2 中 arrayMethods 对数组变异方法的重写实现

可以看到,arrayMethods 首先继承了 Array.prototype,然后对数组中所有能改变数组自身的方法进行重写。重写后的方法会先执行它们本身原有的逻辑,并对能增加数组长度的三个方法 push、unshift、splice 做了判断,获取到插入的值,然后把新添加的值变成响应式对象,最后调用 ob.dep.notify() 手动触发依赖通知。这就很好地解释了为什么 vm.items.splice(newLength) 能被检测到变化。

被重写的一共是七个方法:

方法 会改变原数组 需要额外 observe 新元素
push 是 是
unshift 是 是
splice 是 是
pop 是 否
shift 是 否
sort 是 否
reverse 是 否

反过来看,filter、map、concat、slice 这些方法不在列表里,因为它们返回新数组,不改变原数组。用它们的时候把结果整体赋值回去就能触发响应,走的是对象属性 setter 那条路。

至于两个经典的失效场景,现在就能自己解释了:

  • this.list[0] = x 直接改下标,没走任何被重写的方法,dep.notify() 不会被调用
  • this.list.length = 0 改长度同理,length 属性上没有 setter

# 四、三种修改场景该怎么写

# 4.1 修改已有属性,直接改就行

当想要修改对象已有的属性而非新增属性时,一个已经在 data 中声明过的响应式数据可以直接操作改变,数据改变会经过上图的步骤触发视图更新,直接 obj.xxx = xxx 即可。

数组除外,但后台传过来的 JSON 数组,数组中嵌套的对象也可以直接修改。因为 Object.defineProperty 的限制导致无法监听数组的变动,但 Vue 始终会深度遍历 data 中的数据,给数组中嵌套的对象添加上 get 和 set 方法,完成对对象的监听。所以数组中嵌套对象的情况是可以直接修改数组中的对象,并且保持响应式。

这一条特别实用。this.list[0].name = 'new' 是能触发更新的,因为 list[0] 这个对象本身被 observe 过,它的 name 属性有 setter。但 this.list[0] = {...} 不行,因为这一步操作的是下标。

一字之差,行为完全不同。

# 4.2 新增属性,用 $set 或变异方法

即使是一个后台传过来的 JSON 数组,也可以使用 this.$set 向数组中的某个对象添加一个响应式的属性,例如 this.$set(arr[0], 'xxx', xxx)。或者使用数组变异方法例如 splice。

$set 内部做的事情其实就三步。判断目标是数组还是对象,是数组就调重写过的 splice 塞进去;是对象就先检查这个 key 是不是已经存在,存在就直接赋值,不存在才走 defineReactive 给它补上 getter/setter,最后手动 ob.dep.notify() 通知一次。

// 给对象新增响应式属性
this.$set(this.form, 'phone', '13800000000');

// 给数组指定位置替换元素
this.$set(this.list, 0, newItem);
// 等价写法
this.list.splice(0, 1, newItem);
@前端进阶之旅: 代码已经复制到剪贴板

这里有个坑要注意,$set 不能作用在 Vue 实例本身或者根级 data 对象上。也就是说 this.$set(this, 'newProp', 1) 会在开发环境报警告。根级属性必须在 data 里预先声明,哪怕先给个 null 占位。

# 4.3 整体替换

向响应式的数组和对象替换为新的数据时,可以直接赋值。因为 data 中声明的数据已经添加了访问器属性 setter,当重新赋值一个新的堆内存地址时,该数组或对象也会被循环遍历添加访问器属性,所以依然是有响应式的。

整体替换数组或对象后依然保持响应式的示意

这是处理列表数据最省心的写法。与其纠结怎么往数组里精确插入,不如构造一个新数组整体赋值:

// 与其这样
this.list[index] = newItem;

// 不如这样,走的是 list 属性的 setter,一定触发更新
this.list = this.list.map((item, i) => (i === index ? newItem : item));
@前端进阶之旅: 代码已经复制到剪贴板

整体替换的代价是这个数组会被重新完整地 observe 一遍,数据量很大时有性能开销。列表上万条的场景要斟酌,几十上百条完全不用考虑。

# 五、$forceUpdate 能救场,但它不是响应式

Vue 无法监听对象的新增和删除。直接通过 obj.xxx = xxx 新增一个原本没有的属性,同时修改当前组件的另一个响应式数据,会重新触发当前组件 render,可以让非响应式数据也保持更新状态,但它并不是响应式的。

具体来说,给一个已经在 data 中声明过的对象 obj 新增一个原本没有的属性,同时修改组件中其他某个响应式数据,该 obj 也会同步更新到最新的数据。另一种情况是,当你向一个对象或数组中同时增加一个响应式和一个非响应式数据,非响应式数据也会同步更新到页面上。

只要触发当前组件重新 render,就可以让数据显示为最新状态,例如 this.$forceUpdate()。

通过强制重新渲染让非响应式数据同步到页面的示意

我一开始也觉得这算是个可用的技巧,后来发现它坑很深。

$forceUpdate 做的只是「这一次把最新值渲染出来」,属性上依然没有 setter。下一次这个属性再变,页面不会有任何反应,除非你再调一次。更麻烦的是它只强制当前组件重新渲染,不影响子组件,所以你会遇到父组件更新了、子组件还是老数据这种极难排查的现象。

线上代码里出现 $forceUpdate,九成是某处该用 $set 却没用。

# 六、为什么 Vue 3 换成了 Proxy

fe
  • 一、响应式的整条链路
  • 二、getter 和 setter 分别在干什么
  • 三、数组为什么监听不到
  • 四、三种修改场景该怎么写
    • 4.1 修改已有属性,直接改就行
    • 4.2 新增属性,用 $set 或变异方法
    • 4.3 整体替换
  • 五、$forceUpdate 能救场,但它不是响应式
  • 六、为什么 Vue 3 换成了 Proxy
  • 七、动手写一个最小的双向绑定
  • 总结
  • 参考

← Typora+PicGo+阿里云OSS实现图片自动上传JS内存泄漏与垃圾回收机制完全梳理 →