Vue3 的响应式和以前有什么区别,Proxy 无敌?

前言

大家都知道,Vue2 里的响应式其实有点像是一个半完全体,对于对象上新增的属性无能为力,对于数组则需要拦截它的原型方法来实现响应式。

举个例子:

let vm = new Vue({
 data() {
 return {
 a: 1
 }
 }
})

// ❌ oops,没反应!
vm.b = 2
let vm = new Vue({
 data() {
 return {
 a: 1
 }
 },
 watch: {
 b() {
 console.log('change !!')
 }
 }
})

// ❌ oops,没反应!
vm.b = 2

这种时候,Vue 提供了一个 api:this.$set,来使得新增的属性也拥有响应式的效果。

但是对于很多新手来说,很多时候需要小心翼翼的去判断到底什么情况下需要用 $set,什么时候可以直接触发响应式。
总之,在 Vue3 中,这些都将成为过去。本篇文章会带你仔细讲解,proxy 到底会给 Vue3 带来怎么样的便利。并且会从源码级别,告诉你这些都是如何实现的。

响应式仓库

Vue3 不同于 Vue2 也体现在源码结构上,Vue3 把耦合性比较低的包分散在 packages 目录下单独发布成 npm 包。 这也是目前很流行的一种大型项目管理方式 Monorepo。

其中负责响应式部分的仓库就是 @vue/rectivity,它不涉及 Vue 的其他的任何部分,是非常非常 「正交」 的一种实现方式。

甚至可以轻松的集成进 React。

这也使得本篇的分析可以更加聚焦的分析这一个仓库,排除其他无关部分。

区别

Proxy 和 Object.defineProperty 的使用方法看似很相似,其实 Proxy 是在 「更高维度」 上去拦截属性的修改的,怎么理解呢?

Vue2 中,对于给定的 data,如 { count: 1 },是需要根据具体的 key 也就是 count,去对「修改 data.count 」 和 「读取 data.count」进行拦截,也就是

Object.defineProperty(data, 'count', {
 get() {},
 set() {},
})

必须预先知道要拦截的 key 是什么,这也就是为什么 Vue2 里对于对象上的新增属性无能为力。
而 Vue3 所使用的 Proxy,则是这样拦截的:

new Proxy(data, {
 get(key) { },
 set(key, value) { },
})

可以看到,根本不需要关心具体的 key,它去拦截的是 「修改 data 上的任意 key」 和 「读取 data 上的任意 key」。
所以,不管是已有的 key  还是新增的 key,都逃不过它的魔爪。

但是 Proxy 更加强大的地方还在于 Proxy 除了 get 和 set,还可以拦截更多的操作符。

简单的例子🌰

先写一个 Vue3 响应式的最小案例,本文的相关案例都只会用 reactive 和 effect 这两个 api。如果你了解过 React 中的 useEffect,相信你会对这个概念秒懂,Vue3 的 effect 不过就是去掉了手动声明依赖的「进化版」的 useEffect。
React 中手动声明 [data.count] 这个依赖的步骤被 Vue3 内部直接做掉了,在 effect 函数内部读取到 data.count 的时候,它就已经被收集作为依赖了。
Vue3:

// 响应式数据
const data = reactive({
 count: 1
})

// 观测变化
effect(() => console.log('count changed', data.count))

// 触发 console.log('count changed', data.count) 重新执行
data.count = 2

React:

// 数据
const [data, setData] = useState({
 count: 1
})

// 观测变化 需要手动声明依赖
useEffect(() => {
 console.log('count changed', data.count)
}, [data.count])

// 触发 console.log('count changed', data.count) 重新执行
setData({
 count: 2
})

其实看到这个案例,聪明的你也可以把 effect 中的回调函数联想到视图的重新渲染、 watch 的回调函数等等…… 它们是同样基于这套响应式机制的。

而本文的核心目的,就是探究这个基于 Proxy 的 reactive api,到底能强大到什么程度,能监听到用户对于什么程度的修改。

先讲讲原理

先最小化的讲解一下响应式的原理,其实就是在 Proxy 第二个参数 handler 也就是陷阱操作符中,拦截各种取值、赋值操作,依托 track 和 trigger 两个函数进行依赖收集和派发更新。

  • track 用来在读取时收集依赖。
  • trigger 用来在更新时触发依赖。

track

function track(target: object, type: TrackOpTypes, key: unknown) {
 const depsMap = targetMap.get(target);
 // 收集依赖时 通过 key 建立一个 set
 let dep = new Set()
 targetMap.set(ITERATE_KEY, dep)
 // 这个 effect 可以先理解为更新函数 存放在 dep 里
 dep.add(effect)
}

target 是原对象。
type 是本次收集的类型,也就是收集依赖的时候用来标识是什么类型的操作,比如上文依赖中的类型就是 get,这个后续会详细讲解。
key 是指本次访问的是数据中的哪个 key,比如上文例子中收集依赖的 key 就是 count
首先全局会存在一个 targetMap,它用来建立 数据 -> 依赖 的映射,它是一个 WeakMap 数据结构。
而 targetMap 通过数据 target,可以获取到 depsMap,它用来存放这个数据对应的所有响应式依赖。
depsMap 的每一项则是一个 Set 数据结构,而这个 Set 就存放着对应 key 的更新函数。
是不是有点绕?我们用一个具体的例子来举例吧。

const target = { count: 1}
const data = reactive(target)

const effection = effect(() => {
 console.log(data.count)
})

对于这个例子的依赖关系,

全局的 targetMap 是:

targetMap: {
 { count: 1 }: dep
}

dep 则是

dep: {
 count: Set { effection }
}

这样一层层的下去,就可以通过 target 找到 count 对应的更新函数 effection 了。

trigger

这里是最小化的实现,仅仅为了便于理解原理,实际上要复杂很多,

其实 type 的作用很关键,先记住,后面会详细讲。

export function trigger(
 target: object,
 type: TriggerOpTypes,
 key?: unknown,
) {
 // 简化来说 就是通过 key 找到所有更新函数 依次执行
 const dep = targetMap.get(target)
 dep.get(key).forEach(effect => effect())
}

新增属性

这个上文已经讲了,由于 Proxy 完全不关心具体的 key,所以没问题。

// 响应式数据
const data = reactive({
 count: 1
})

// 观测变化
effect(() => console.log('newCount changed', data.newCount))

// ✅ 触发响应
data.newCount = 2

数组新增索引:

// 响应式数据
const data = reactive([])

// 观测变化
effect(() => console.log('data[1] changed', data[1]))

// ✅ 触发响应
data[1] = 5

数组调用原生方法:

const data = reactive([])
effect(() => console.log('c', data[1]))

// 没反应
data.push(1)

// ✅ 触发响应 因为修改了下标为 1 的值
data.push(2)

其实这一个案例就比较有意思了,我们仅仅是在调用 push,但是等到数组的第二项被 push的时候,我们之前关注 data[1] 为依赖的回调函数也执行了,这是什么原理呢?写个简单的 Proxy 就知道了。

const raw = []
const arr = new Proxy(raw, {
 get(target, key) {
 console.log('get', key)
 return Reflect.get(target, key)
 },
 set(target, key, value) {
 console.log('set', key)
 return Reflect.set(target, key, value)
 }
})

arr.push(1)

在这个案例中,我们只是打印出了对于 raw 这个数组上的所有 get、set 操作,并且调用 Reflect 这个 api 原样处理取值和赋值操作后返回。看看 arr.push(1) 后控制台打印出了什么?

get push
get length
set 0
set length

原来一个小小的 push,会触发两对 get 和 set,我们来想象一下流程:

  • 读取 push 方法
  • 读取 arr 原有的 length 属性
  • 对于数组第 0 项赋值
  • 对于 length 属性赋值

这里的重点是第三步,对于第 index 项的赋值,那么下次再 push,可以想象也就是对于第 1 项触发 set 操作。
而我们在例子中读取 data[1],是一定会把对于 1 这个下标的依赖收集起来的,这也就清楚的解释了为什么 push 的时候

也能精准的触发响应式依赖的执行。

对了,记住这个对于 length 的 set 操作,后面也会用到,很重要。

遍历后新增

// 响应式数据
const data = reactive([])

// 观测变化
effect(() => console.log('data map +1', data.map(item => item + 1))

// ✅ 触发响应 打印出 [2]
data.push(1)

这个拦截很神奇,但是也很合理,转化成现实里的一个例子来看,

假设我们要根据学生 id 的集合 ids, 去请求学生详细信息,那么仅仅是需要这样写即可:

const state = reactive({})
const ids = reactive([1])

effect(async () => {
 state.students = await axios.get('students/batch', ids.map(id => ({ id })))
})

// ✅ 触发响应
ids.push(2)

这样,每次调用各种 api 改变 ids 数组,都会重新发送请求获取最新的学生列表。

如果我在监听函数中调用了 map、forEach 等 api,

说明我关心这个数组的长度变化,那么 push 的时候触发响应是完全正确的。

但是它是如何实现的呢?感觉似乎很复杂啊。

因为 effect 第一次执行的时候, data 还是个空数组,怎么会 push 的时候能触发更新呢?

还是用刚刚的小测试,看看 map 的时候会发生什么事情。

const raw = [1, 2]
const arr = new Proxy(raw, {
 get(target, key) {
 console.log('get', key)
 return Reflect.get(target, key)
 },
 set(target, key, value) {
 console.log('set', key)
 return Reflect.set(target, key, value)
 }
})

arr.map(v => v + 1)
get map
get length
get constructor
get 0
get 1

和 push 的部分有什么相同的?找一下线索,我们发现 map 的时候会触发 get length,而在触发更新的时候, Vue3 内部会对 「新增 key」 的操作进行特殊处理,这里是新增了 0 这个下标的值,会走到 trigger  中这样的一段逻辑里去:

源码地址

// 简化版
if (isAddOrDelete) {
 add(depsMap.get('length'))
}

把之前读取 length 时收集到的依赖拿到,然后触发函数。
这就一目了然了,我们在 effect 里 map 操作读取了 length,收集了 length 的依赖。
在新增 key 的时候, 触发 length 收集到的依赖,触发回调函数即可。
对了,对于 for of 操作,也一样可行:

// 响应式数据
const data = reactive([])

// 观测变化
effect(() => {
 for (const val of data) {
 console.log('val', val)
 }
})

// ✅ 触发响应 打印出 val 1
data.push(1)

可以按我们刚刚的小试验自己跑一下拦截, for of 也会触发 length 的读取。
length 真是个好同志…… 帮了大忙了。

遍历后删除或者清空

注意上面的源码里的判断条件是 isAddOrDelete,所以删除的时候也是同理,借助了 length 上收集到的依赖。

// 简化版
if (isAddOrDelete) {
 add(depsMap.get('length'))
}
const arr = reactive([1])

effect(() => {
 console.log('arr', arr.map(v => v))
})

// ✅ 触发响应
arr.length = 0

// ✅ 触发响应
arr.splice(0, 1)

真的是什么操作都能响应,爱了爱了。

获取 keys

const obj = reactive({ a: 1 })

effect(() => {
 console.log('keys', Reflect.ownKeys(obj))
})

effect(() => {
 console.log('keys', Object.keys(obj))
})

effect(() => {
 for (let key in obj) {
 console.log(key)
 }
})

// ✅ 触发所有响应
obj.b = 2

这几种获取 key 的方式都能成功的拦截,其实这是因为 Vue 内部拦截了 ownKeys 操作符。

const ITERATE_KEY = Symbol( 'iterate' );

function ownKeys(target) {
 track(target, "iterate", ITERATE_KEY);
 return Reflect.ownKeys(target);
}

ITERATE_KEY 就作为一个特殊的标识符,表示这是读取 key 的时候收集到的依赖。它会被作为依赖收集的 key。
那么在触发更新时,其实就对应这段源码:

if (isAddOrDelete) {
 add(depsMap.get(isArray(target) ? 'length' : ITERATE_KEY));
}

其实就是我们聊数组的时候,代码简化掉的那部分。判断非数组,则触发 ITERATE_KEY 对应的依赖。

小彩蛋:

  • Reflect.ownKeys、 Object.keys 和 for in 其实行为是不同的,
  • Reflect.ownKeys 可以收集到 Symbol 类型的 key,不可枚举的 key。

举例来说:

var a = {
 [Symbol(2)]: 2,
}

Object.defineProperty(a, 'b', {
 enumerable: false,
})

Reflect.ownKeys(a) // [Symbol(2), 'b']
Object.keys(a) // []

回看刚刚提到的 ownKeys 拦截,

function ownKeys(target) {
 track(target, "iterate", ITERATE_KEY);
 // 这里直接返回 Reflect.ownKeys(target)
 return Reflect.ownKeys(target);
}

内部直接之间返回了 Reflect.ownKeys(target),按理来说这个时候 Object.keys 的操作经过了这个拦截,也会按照 Reflect.ownKeys 的行为去返回值。

然而最后返回的结果却还是 Object.keys 的结果,这是比较神奇的一点。

删除对象属性

有了上面 ownKeys 的基础,我们再来看看这个例子

const obj = reactive({ a: 1, b: 2})

effect(() => {
 console.log(Object.keys(obj))
})

// ✅ 触发响应
delete obj['b']

这也是个神奇的操作,原理在于对于 deleteProperty 操作符的拦截:

function deleteProperty(target: object, key: string | symbol): boolean {
 const result = Reflect.deleteProperty(target, key)
 trigger(target, TriggerOpTypes.DELETE, key)
 return result
}

这里又用到了 TriggerOpTypes.DELETE 的类型,根据上面的经验,一定对它有一些特殊的处理。
其实还是 trigger 中的那段逻辑:

const isAddOrDelete = type === TriggerOpTypes.ADD || type === TriggerOpTypes.DELETE
if (isAddOrDelete) {
 add(depsMap.get(isArray(target) ? 'length' : ITERATE_KEY))
}

这里的 target 不是数组,所以还是会去触发 ITERATE_KEY 收集的依赖,也就是上面例子中刚提到的对于 key 的读取收集到的依赖。

判断属性是否存在

const obj = reactive({})

effect(() => {
 console.log('has', Reflect.has(obj, 'a'))
})

effect(() => {
 console.log('has', 'a' in obj)
})

// ✅ 触发两次响应
obj.a = 1

这个就很简单了,就是利用了 has 操作符的拦截。

function has(target, key) {
 const result = Reflect.has(target, key);
 track(target, "has", key);
 return result;
}

性能

  1. 首先 Proxy 作为浏览器的新标准,性能上是一定会得到厂商的大力优化的,拭目以待。
  2. Vue3 对于响应式数据,不再像 Vue2 中那样递归对所有的子数据进行响应式定义了,而是再获取到深层数据的时候再去利用 reactive 进一步定义响应式,这对于大量数据的初始化场景来说收益会非常大。

比如,对于

const obj = reactive({
 foo: {
 bar: 1
 }
})

初始化定义 reactive 的时候,只会对 obj 浅层定义响应式,而真正读取到 obj.foo 的时候,才会对 foo 这一层对象定义响应式,简化源码如下:

function get(target: object, key: string | symbol, receiver: object) {
 const res = Reflect.get(target, key, receiver)
 // 这段就是惰性定义
 return isObject(res)
 ? reactive(res)
 : res
}

推荐阅读

其实 Vue3 对于 Map 和 Set 这两种数据类型也是完全支持响应式的,对于它们的原型方法也都做了完善的拦截,限于篇幅原因本文不再赘述。

说实话 Vue3 的响应式部分代码逻辑分支还是有点过多,对于代码理解不是很友好,因为它还会涉及到 readonly 等只读化的操作,如果看完这篇文章你对于 Vue3 的响应式原理非常感兴趣的话,建议从简化版的库入手去读源码。

这里我推荐observer-util,我解读过这个库的源码,和 Vue3 的实现原理基本上是一模一样!但是简单了很多。麻雀虽小,五脏俱全。里面的注释也很齐全。

当然,如果你的英文不是很熟练,也可以看我精心用 TypeScript + 中文注释基于 observer-util 重写的这套代码:
typescript-proxy-reactive

对于这个库的解读,可以看我之前的两篇文章:
带你彻底搞懂Vue3的Proxy响应式原理!TypeScript从零实现基于Proxy的响应式库。
带你彻底搞懂Vue3的Proxy响应式原理!基于函数劫持实现Map和Set的响应式

在第二篇文章里,你也可以对于 Map 和 Set 可以做什么拦截操作,获得源码级别的理解。

总结

Vue3 的 Proxy 真的很强大,把 Vue2 里我认为心智负担很大的一部分给解决掉了。(在我刚上手 Vue 的时候,我是真的不知道什么情况下该用 $set),它的 composition-api 又可以完美对标 React Hook,并且得益于响应式系统的强大,在某些方面是优胜于它的。精读《Vue3.0 Function API》

希望这篇文章能在 Vue3 正式到来之前,提前带你熟悉 Vue3 的一些新特性。

扩展阅读

Proxy 的拦截器里有个 receiver 参数,在本文中为了简化没有体现出来,它是用来做什么的?国内的网站比较少能找到这个资料:

new Proxy(raw, {
 get(target, key, receiver) {
 return Reflect.get(target, key, receiver)
 }
})

可以看 StackOverflow 上的问答:what-is-a-receiver-in-javascript
也可以看我的总结
Proxy 和 Reflect 中的 receiver 到底是什么?

到此这篇关于Vue3 的响应式和以前有什么区别,Proxy 无敌?的文章就介绍到这了,更多相关Vue3 响应式内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

时间: 2020-05-19

稍微学一下Vue的数据响应式(Vue2及Vue3区别)

什么是数据响应式 从一开始使用 Vue 时,对于之前的 jq 开发而言,一个很大的区别就是基本不用手动操作 dom,data 中声明的数据状态改变后会自动重新渲染相关的 dom. 换句话说就是 Vue 自己知道哪个数据状态发生了变化及哪里有用到这个数据需要随之修改. 因此实现数据响应式有两个重点问题: 如何知道数据发生了变化? 如何知道数据变化后哪里需要修改? 对于第一个问题,如何知道数据发生了变化,Vue3 之前使用了 ES5 的一个 API Object.defineProperty Vue

茶余饭后聊聊Vue3.0响应式数据那些事儿

"别再更新了,实在是学不动了"这句话道出了多少前端开发者的心声,"不幸"的是 Vue 的作者在国庆区间发布了 Vue3.0 的 pre-Aplha 版本,这意味着 Vue3.0 快要和我们见面了.既来之则安之,扶我起来我要开始讲了.Vue3.0 为了达到更快.更小.更易于维护.更贴近原生.对开发者更友好的目的,在很多方面进行了重构: 使用 Typescript 放弃 class 采用 function-based API 重构 complier 重构 virtual

Vue3.0 响应式系统源码逐行分析讲解

前言 关于响应式原理想必大家都很清楚了,下面我将会根据响应式API来具体讲解Vue3.0中的实现原理, 另外我只会针对get,set进行深入分析,本文包含以下API实现,推荐大家顺序阅读 effect reactive readonly computed ref 对了,大家一定要先知道怎么用哦~ 引子 先来段代码,大家可以直接复制哦,注意引用的文件 <!DOCTYPE html> <html lang="en"> <head> <meta ch

Vue3.0数据响应式原理详解

基于Vue3.0发布在GitHub上的第一版源码(2019.10.05)整理 预备知识 ES6 Proxy,整个响应式系统的基础. 新的composition-API的基本使用,目前还没有中文文档,可以先通过这个仓库(composition-api-rfc)了解,里面也有对应的在线文档. 先把Vue3.0跑起来 先把vue-next仓库的代码clone下来,安装依赖然后构建一下,vue的package下的dist目录下找到构建的脚本,引入脚本即可. 下面一个简单计数器的DEMO: <!DOCTY

你了解vue3.0响应式数据怎么实现吗

从 Proxy 说起 什么是Proxy proxy翻译过来的意思就是"代理",ES6对Proxy的定位就是target对象(原对象)的基础上通过handler增加一层"拦截",返回一个新的代理对象,之后所有在Proxy中被拦截的属性,都可以定制化一些新的流程在上面,先看一个最简单的例子 const target = {}; // 要被代理的原对象 // 用于描述代理过程的handler const handler = { get: function (target,

浅谈实现vue2.0响应式的基本思路

最近看了vue2.0源码关于响应式的实现,以下博文将通过简单的代码还原vue2.0关于响应式的实现思路. 注意,这里只是实现思路的还原,对于里面各种细节的实现,比如说数组里面数据的操作的监听,以及对象嵌套这些细节本实例都不会涉及到,如果想了解更加细节的实现,可以通过阅读源码 observer文件夹以及instance文件夹里面的state文件具体了解. 首先,我们先定义好实现vue对象的结构 class Vue { constructor(options) { this.$options = o

Vue实现双向绑定的原理以及响应式数据的方法

一.vue中的响应式属性 Vue中的数据实现响应式绑定 1.对象实现响应式: 是在初始化的时候利用definePrototype的定义set和get过滤器,在进行组件模板编译时实现water的监听搜集依赖项,当数据发生变化时在set中通过调用dep.notify进行发布通知,实现视图的更新. 2.数组实现响应式: 对于数组则是通过继承重写数组的方法splice.pop.push.shift.unshift.sort.reverse.等可以修改原数组的方式实现响应式的,但是通过length以及直接

100行代码理解和分析vue2.0响应式架构

分享前啰嗦 我之前介绍过vue1.0如何实现observer和watcher.本想继续写下去,可是vue2.0横空出世..所以直接看vue2.0吧.这篇文章在公司分享过,终于写出来了.我们采用用最精简的代码,还原vue2.0响应式架构实现. 以前写的那篇 vue 源码分析之如何实现 observer 和 watcher可以作为本次分享的参考. 不过不看也没关系,但是最好了解下Object.defineProperty 本文分享什么 理解vue2.0的响应式架构,就是下面这张图 顺带介绍他比rea

为什么Vue3.0使用Proxy实现数据监听(defineProperty表示不背这个锅)

导 读 vue3.0中,响应式数据部分弃用了 Object.defineProperty ,使用 Proxy 来代替它.本文将主要通过以下方面来分析为什么vue选择弃用 Object.defineProperty . Object.defineProperty 真的无法监测数组下标的变化吗? 分析vue2.x中对数组 Observe 部分源码 对比 Object.defineProperty 和 Proxy 一.无法监控到数组下标的变化? 在一些技术博客上看到过这样一种说法,认为 Object.

Vue响应式原理Observer、Dep、Watcher理解

开篇 最近在学习Vue的源码,看了网上一些大神的博客,看起来感觉还是蛮吃力的.自己记录一下学习的理解,希望能够达到简单易懂,不看源码也能理解的效果

vue.js响应式原理解析与实现

从很久之前就已经接触过了angularjs了,当时就已经了解到,angularjs是通过脏检查来实现数据监测以及页面更新渲染.之后,再接触了vue.js,当时也一度很好奇vue.js是如何监测数据更新并且重新渲染页面.今天,就我们就来一步步解析vue.js响应式的原理,并且来实现一个简单的demo. 首先,先让我们来了解一些基础知识. 基础知识 Object.defineProperty es5新增了Object.defineProperty这个api,它可以允许我们为对象的属性来设定gette