Vue3源码学习(2):响应式API reactive 的实现

521 阅读3分钟

Vue2 和 Vue3 响应式的区别

Vue2 的响应式,利用了 ES5 的一个 API :Object.defineProperty。它的基本用法是这样的:

const obj = {name: 'kw'}
Object.defineProperty(obj, key, {
    get() {
        return obj[key]
    },
    set(val) {
        obj[key] = val
    }
})

这样,就能拦截到对象属性的基本操作,比如访问属性和给属性设置新值。当拦截到访问属性时,可以做依赖收集;当监听到属性更改时,可以做派发更新,从而实现响应式。

它存在几个缺点:

1、重写了对象的属性,性能较差;

2、只能拦截到对象属性的操作,不能处理数组。所以 Vue2 需要单独对数组数据进行处理。

3、对于属性的新增和删除,无法拦截到。所以额外提供了 $set$delete 方法,整体不和谐。

Vue3 采用了 ES6 的API Proxy 来实现响应式。由于该 API 不兼容 IE 浏览器,所以在使用 Vue3 开发时要考虑项目是否需要兼容 IE系列。

Proxy 和 Reflect

先来看下 Proxy 的基本语法:

const proxy = new Proxy(target, handler);

target:用 Proxy 包装的被代理对象(可以是任何类型的对象,包括原生数组,函数,甚至另一个代理)。

handler:是一个对象,其声明了代理 target 的一些操作,其属性是当执行一个操作时定义代理的行为的函数。

简单示例:

const target = {
  name: "kw",
  age: 18
};
​
const handler = {
  get(target, key, receiver) {
    return target[key]
  },
  set(target, key, value, receiver) {
      target[key] = value
  }
};
​
const proxy = new Proxy(target, handler);
​
console.log(proxy.name); // "kw"
proxy.name = 'zk'  
console.log(proxy.name); // "zk"

可以看到,Proxy 的使用其实和 Object.defineProperty是差不多的,也是能拦截到对象属性的一些操作。但它的特点是:

1、不仅可以代理普通的对象,还可以代理数组,函数

2、不仅能拦截到 getset 操作,还支持 applydelete 等一共13种操作

3、不需要重写 target,性能更高

再来看一下 Reflect 对象。

ProxyReflect 是一对好兄弟,形影不离。

按照 MDN 文档的说明:

Reflect 是一个内置的对象,它提供拦截 JavaScript 操作的方法。这些方法与proxy handlers 的方法相同。

Reflect不是一个函数对象,因此它是不可构造的。

也就是说,我们在使用 Proxy 时传入的 handler 参数,它所有的属性,在 Reflect 中都有一一对应的。比如上面我们说了 Proxy 对象的 handler 可以支持 getgetapplydelete 操作,那么 Reflect 对象就提供了对应的静态方法:

const person = {
  name: 'kw',
  age: 18,
  sayHello: function() {
    console.log(`Hello! 我是${this.name}`);
  }
}
​
// 返回 name 属性的值
Reflect.get(person, 'name'); // 'kw'// 执行 sayHello 方法
// param1:要执行的函数
// param2:指定 this
// param3:函数执行需要的参数
Reflect.apply(person.sayHello, person, []); // Hello! 我是kw// 更新 age 属性。属性设置成功,返回 true
Reflect.set(person, 'age', 20); // true
Reflect.get(person, 'age'); // 20

初看起来,Reflect 的使用很繁琐,远不如传统的点语法来的方便简洁。确实如此,但是在这些 API 的设计上,它和 Proxy 拥有一致的属性和方法,所以搭配起来更加合适。再者,有些场景下,比如需要用到 receiver 参数时,此时就只有 Reflect 能堪大任了。

reactive 的基本使用

官网文档,点击访问

import { reactive } from 'vue'
​
const obj = {name: 'kw', age: 18, skill: ['JS', 'Vue']}
​
// 返回对象的响应式代理对象,并且是深层次的代理,所有嵌套的属性包括数组,都能被代理到
const state = reactive(obj)
​
// 修改响应式对象,组件可以自动更新
state.name = 'zk'
state.age = 20
state.skill.push('Node')

使用 reactive 时需要注意的地方:

  1. 只能实现对象数据的响应式
  2. 同一个对象,只会被代理一次
  3. 被代理过的对象,不会被再次代理
  4. 支持嵌套属性的响应式

实现 reactive

这是 Vue3 中一个最基础的响应式 API,它内部采用了 Proxy 来实现对象属性的拦截操作。

如下:

// reactivity/src/reactive.ts

import { isObject } from '@my-vue/shared'

export function reactive (target) {
   
  // 只能代理对象
  if(!isObject(target)) {
    return target
  }
  
  const handler = {
    // 监听属性访问操作
    get(target, key, receiver) {
      console.log(`${key}属性被访问,依赖收集`)
      return Reflect.get(target, key)
    },
    
    // 监听设置属性操作
    set(target, key, value, receiver) {
      console.log(`${key}属性变化了,派发更新`)
     
      // 当属性的新值和旧值不同时,再进行设置
      if(target[key] !== value) {
         const result = Reflect.set(target, key, value, receiver);;
         return result
      }
    }
  }
  
  // 实例化代理对象
  const proxy = new Proxy(target, handler)
​
  return proxy
}

无需多次代理

前面我们提到,如果一个对象被代理过了,就无需再被代理。实现的思路就是利用缓存,将代理过的对象进行缓存,每当调用 reactive 方法时,先判断缓存中是否存在 target ;每次 target 被代理后,都将 targetproxy 放到缓存中:

// 缓存响应式对象
const reactiveMap = new WeakMap

export function reactive (target) {
  // ...
    
  // 判断 target 是否被代理过。如果被代理过,则直接返回代理对象
  const existing = reactiveMap.get(target)
  if(existing) {
    return existing
  }
  
  // ...

  // 将代理对象进行缓存
  reactiveMap.set(target, proxy)

  return proxy
}

仅能够被代理一次

除此之外,对于一个已经被代理的对象 proxy,再次调用响应式 API 时,应该直接返回该 proxy 对象,而不会对 proxy 再做一次代理。

它的实现思路也很简单,是通过标识符的方式进行判断。

Vue2 中通过给 Observer 实例 增加一个__ob__属性作为标识,表示它已经被观测过了,无需再被观测。Vue3 是通过给 proxy 对象增加一个__v_isReactive 属性,表示该 proxy 对象已经是响应式数据了,从而无需再被代理:

const enum ReactiveFlags {
  IS_REACTIVE = '__v_isReactive',
} 

export function reactive (target) {
  
  // 判断 target 是否是响应式对象
  // 每当一个 target 需要响应式时,先判断其有没有该属性。此时产生属性访问操作,如果 target 被代理过,则会进入下面的 get 方法中,做进一步的判断。
  if(target[ReactiveFlags.IS_REACTIVE]) {
    return target
  }
    
  // ...
  
  const handler = {
    // 监听属性访问操作
    get(target, key, receiver) {
      // 访问到 __v_isReactive 属性时,说明此时的 target 其实是一个 proxy 对象,无需再被代理
      if(key === ReactiveFlags.IS_REACTIVE) {
        return true
      }
      console.log(`${key}属性被访问,依赖收集`)
      return Reflect.get(target, key)
    },  
  }
  
   // ...
}

嵌套代理

Vue3 实现响应式采用的原则是懒代理,并不像 Vue2 那样在初始化时,就递归所有的属性进行属性重写。 只有在访问到某个属性,且该属性是对象类型时,才会再进行一层响应式包装:

到此,我们实现的 reactive 方法,就能监听到对象属性的访问和设置操作,从而在此时机做一些处理,从而实现响应式系统。同时也做了一些优化处理。

reactive 方法通过响应式模块的入口文件对外暴露出去:

// reactivity/src/index.ts

export { reactive } from './reactive' 

测试

执行打包命令:

pnpm dev

编写测试文件:

// test/1.reactive.html

<script src="../dist/reactivity.global.js"></script>

<script>
  // VueReactivity:在响应式模块的 package.json 中通过 buildOptions.name 起的名字
  const { reactive } = VueReactivity

  const obj = { name: 'kw', age: 18, grade: { math: 88 } }

  const state = reactive(obj)

  // 访问响应式对象的属性
  console.log(state.name)
  console.log(state.grade.math)
  // 修改响应式对象的属性
  state.age = 20
</script>

打开浏览器,查看控制台:

Snipaste_2022-07-20_18-19-37.png

和我们预想的一样,reactive 方法能监听到对象属性的取值和设置值的操作。

小结

这只是实现响应式系统的第一步。我们还需要在访问属性时,做依赖收集,也就是记录下此刻谁用到了这个属性;当以后属性发生变化时,就可以告诉它,你用到的属性更新了,你也可以更新一下啦。这样,就能实现一个完整的响应式系统了。下一篇文章,我们就来实现这个功能。

仓库地址,点击访问

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿