Vue源码解析-响应式原理之reactive

前言

在vue3中,通过reactive可以创建响应式数据,其实现核心是通过Proxy创建代理对象,使用或更改该代理对象会触发相应的方法如get,set,再通过get,set方法里的逻辑处理并与Reflect搭配使用实现响应式,要想明白其实现原理,需要先来了解一下Proxy和Reflect。

Proxy和Reflect介绍

代码示例:

javascript 复制代码
//const proxy = new Proxy(target, handler)
//例:
const handler = {
    // receiver指的是getter或setter的this值,也就是触发操作的对象,在这里指的是代理对象proxy_obj
    get(target,key,receiver){
        console.log('该值被读取了,读取的原对象是',target,'|读取的属性是',key,'|触发操作的对象是',receiver)
        return Reflect.get(target,key,receiver)
    },
    set(target,key,value,receiver){
        console.log('该值被设置了,设置的原对象是',target,'|设置的属性是',key,'|设置的值是',value,'|触发操作的对象是',receiver)
        const result = Reflect.set(target,key,value,receiver)
        return result
    }
}
const proxy_obj = new Proxy({a:1},handler)
proxy_obj.a ++
/*
输出:
该值被读取了,读取的原对象是 { a: 1 } |读取的属性是 a |触发操作的对象是 { a: 1 }
该值被设置了,设置的原对象是 { a: 1 } |设置的属性是 a |设置的值是 2 |触发操作的对象是 { a: 1 }
*/
  • Proxy是JavaScript中原生的构造函数,其会返回一个代理对象,我们在vue中执行const obj = reactive({name:'aaa'})时,obj就是这个代理对象 ,我们一般操作的也是obj,而不是直接操作其原对象{name: 'aaa'}。其固定传入两个参数,一个是target,也就是我们需要被代理的对象,在上面的例子中,被代理的对象就是{a:1};另一个是handler,是一个用于定义拦截代理对象的get,set操作后的执行逻辑的对象。
  • Reflect是ES6提供的一个内置对象,提供一组静态方法以标准化、函数式的方式操作对象,其与Porxy方法一一对应,所以大部分场景都是两者搭配使用。比如proxy中有get,set和deleteProperty等方法,对应的在Reflect中也有Reflect.get,Reflect.set和Reflect.deleteProperty,用来获取,设置,删除指定对象的某个属性。

以Reflect.get(target, key, receiver)为例,返回的是target对象的key属性的值,那为什么不直接使用target[key]呢,原因是Reflect.get中的receiver代表了属性访问的上下文,也就是其this指向,直接使用target[key]则其this只能是target。当然还有proxy和Reflect语义一致等其他好处,这里就不展开了。

reactive核心原理

设计模式

reactive的实现采用了发布-订阅 的设计模式,以我们生活中的例子为例,微信公众号中公众号就是一个发布者,当我们去订阅(关注)这个公众号时,我们就成了订阅者,公众号那边就会记录下是谁关注了它,这个过程称为依赖收集 ,当有文章更新时,它会把文章推送给所有订阅者,这个过程就称为派发更新。那我们将这个原理映射到代码中:

js 复制代码
const reactive_obj = reactive({
    name: 'watermelon',
    age: 18
})
function reactive(target){
    const handler = {
        get(target, key, receiver){
            // 收集依赖
            track(target, key)
            return Reflect.get(target, key, receiver)
        }
        set(target, key, value, receiver){
          const result = Reflect.set(target, key, value, receiver)
          // 派发更新
          trigger(target, key);
          return result;
        }
    }
    return new Proxy(target,handler)
}

数据存储结构

在探究track和trigger的具体实现前,先来了解一下实现响应式所使用的的数据存储结构。

  1. 首先最外层是通过一个targetMap(weakMap类型)存储所有响应式对象,其key是原对象target,也就是上面代码中的{ name: 'watermelon', age: 18 },其值是一个depsMap(Map类型)。
  2. depsMap的key是target里的属性,也就是示例中的name、age,其值是dep(set类型),用于存储某个属性的依赖列表。

targetMap使用weakMap类型是为了当其不再被引用时能够被垃圾回收,防止内存泄漏。

存储结构示意图

具体实现

副作用函数:在当前语境下,副作用函数是指依赖"依赖响应式数据"的函数,当数据发生改变时会重新执行,以实现自动更新,常见的有render(模板渲染函数),watch的回调函数等(在后面的代码实现中用effectFn模拟)。

  • 执行流程:当vue创建一个组件实例时,会先调用一个包装了render的副作用函数注册器effect,在其内部会有一个副作用函数的包裹函数wrapperedEffect,这个wrapperedEffect用于执行副作用函数以及一些逻辑处理,其会先记录下当前活跃的副作用函数(通过activeEffect),再执行副作用函数effectFn,由于用到了响应式数据,此时会触发其getter,并执行track,在track内则可将这个activeEffect进行依赖收集。随后,执行state.age++后,会触发其setter,并执行其trigger,遍历其依赖列表进行派发更新,此时会输出更新后的数据。
  • 依赖收集:按照前面的响应式的数据存储结构,将原对象target,访问的属性key,属性的依赖列表dep,按照(target,(key,dep))的结构存储在targetMap中。
  • 派发更新:派发更新会通过要修改的原对象和其属性,得到(target,(key,dep))中的dep,也就是依赖列表,然后遍历执行依赖列表里的副作用函数,从而实现自动更新的效果。
  • 在实际实现中,trigger里不是直接遍历执行的,为避免 effect 执行时又触发 trigger 导致无限循环,Vue 会用 Set 去重,将其推入异步队列并在微任务中执行。
  • activeEffect在实际实现中是栈结构,为支持嵌套 effect,避免父子effect覆盖。
js 复制代码
let activeEffect = null
const targetMap = new WeakMap();

const state = reactive({
  name: 'watermelon',
  age: 18
});
function effect(fn) {
  const wrappedEffect = () => {
    try {
      activeEffect = wrappedEffect;
      return fn();
    } finally {
      activeEffect = null;
    }
  };
  wrappedEffect();
  return wrappedEffect;
}
function track(target, key) {
  if (!activeEffect) return;
  let depsMap = targetMap.get(target);
  if (!depsMap) {
    depsMap = new Map();
    targetMap.set(target, depsMap);
  }
  let dep = depsMap.get(key);
  if (!dep) {
    dep = new Set();
    depsMap.set(key, dep);
  }
  dep.add(activeEffect);
}
function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;
  const dep = depsMap.get(key);
  if (dep) {
    dep.forEach(effectFn => {
      effectFn();
    });
  }
}
const effectFn = () => {
  console.log(`${state.name} is ${state.age}`);
}
effect(effectFn);

补充

上面就是reactive实现的核心原理了,当然在reactive内部会多一些处理,比如边界处理等。下面是一些reactive源码实现的补充,可以当做一些拓展。

1. reactive只能代理对象

在调用reactive创建响应式对象后,vue会进行判断,如果传入的值不是对象,如number,string等,会直接返回该值(开发环境下会在控制台输出错误提示)。

js 复制代码
  if (!isObject(target)) {
    if (__DEV__) {
      warn(
        `value cannot be made reactive'}: ${String(target)}`,
      )
    }
    return target
  }

2.对shallow和readonly类型数据的处理

概念

  • shallow:只代理第一层属性,不递归处理嵌套属性
  • readonly:只读,禁止修改对象,尝试修改会警告

在reactive中的处理

  1. 在get函数中的处理(核心):
  • 对shllow类型的处理:在普通的对象中,如果其属性还嵌套着对象,还会对其递归的响应式处理,如{a:1,b:{c:2}},在reactive内部会继续对属性b执行reactive(b),但如果是shallow类型,则会直接返回b;此外,shallow类型还会影响到ref的解包,比如const b = ref(2);const obj = {a:1,b},如果obj是个普通对象,会直接返回b.value,但如果obj是shallow类型,则不会进行解包,而是直接返回ref类型的b。
  • 对readonly类型的处理:如果是reaonly类型,在get中不会进行依赖收集,因为其是只读的,不会被修改,也就不必收集依赖了;并且,在遇到嵌套对象时,如{a:1,b:{c:2}},在内部其会返回readonly(b),而不是reactive(b)。
js 复制代码
// baseHandlers.ts
function createGetter(isReadonly = false, shallow = false) {
  return function get(target, key, receiver) {
    //  1. 依赖收集:只有非 readonly 才 track
    if (!isReadonly) {
      track(target, TrackOpTypes.GET, key)
    }
    const res = Reflect.get(target, key, receiver)
    //  2. ref 解包:只有非 shallow 才自动解包
    if (isRef(res)) {
      return res.value
    }
    //  3. 浅层判断:如果是 shallow,直接返回,不递归
    if (shallow) {
      return res
    }
    //  4. 深层递归:如果不是 readonly,递归 reactive;否则递归 readonly
    if (isObject(res)) {
      return isReadonly ? readonly(res) : reactive(res)
    }
    return res
  }
}

3. reactive的解构

在reactive中,是不支持响应式解构的,如果你将其属性解构出来,会使其失去响应式,需要通过toRefs方法恢复响应式。

js 复制代码
const state = reactive({ count: 0, name: 'watermelon' })
// 此时你直接使用state是属性是没有响应式的
// 需要用 toRefs 把每个属性变成 ref
const { count, name } = toRefs(state)
count.value++ //  这样才会触发更新

4.其他方法:deleteProperty,has,ownKeys

在handler中的方法除了上面提到的get和set两个方法外,还有deleteProperty,has和ownKeys。

  • deleteProperty:拦截 delete obj.key,会在方法内执行删除操作和trigger
  • has:拦截 key in obj,会在方法内部执行track
  • ownKeys:拦截 Object.keys()for...in,会在方法内部执行track

5.对于集合类数据类型的处理

像上面使用get/set进行拦截的处理方法适用于Object和Array,对于集合类的数据如Map Set WeakMap WeakSet,其内部是使用get劫持其原型方法实现响应式的。那为什么不能像上面一样,直接使用get/set呢?原因在于集合类是通过方法来执行操作的,比如const map = new Map();map.set('count', 1) ,这不是对map的属性进行修改,无法触发set拦截器。所以vue的处理思路是:

  • get 拦截所有属性访问
  • 当你访问 map.set 时,我返回一个包装过的 set 方法
  • 这个包装方法在调用前后插入 tracktrigger
相关推荐
q***385116 小时前
SpringBoot + vue 管理系统
vue.js·spring boot·后端
喵个咪16 小时前
go-kratos-admin 快速上手指南:从环境搭建到启动服务(Windows/macOS/Linux 通用)
vue.js·go
用户8417948145616 小时前
vxe-gantt table 甘特图如何设置任务视图每一行的背景色
vue.js
小章鱼学前端17 小时前
2025 年最新 Fabric.js 实战:一个完整可上线的图片选区标注组件(含全部源码).
前端·vue.js
涔溪17 小时前
实现将 Vue3 项目作为子应用,通过无界(Wujie)微前端框架接入到 Vue2 主应用中(Vue2 为主应用,Vue3 为子应用)
vue.js·前端框架·wujie
源码技术栈19 小时前
什么是云门诊系统、云诊所系统?
java·vue.js·spring boot·源码·门诊·云门诊
lcc18719 小时前
Vue3 ref函数和reactive函数
前端·vue.js
艾小码19 小时前
还在为组件通信头疼?defineExpose让你彻底告别传值烦恼
前端·javascript·vue.js
带只拖鞋去流浪20 小时前
迎接2026,重新认识Vue CLI (v5.x)
前端·vue.js·webpack
Coder-coco20 小时前
游戏助手|游戏攻略|基于SprinBoot+vue的游戏攻略系统小程序(源码+数据库+文档)
java·vue.js·spring boot·游戏·小程序·论文·游戏助手