Vue3 → 响应式 reactive、ref、effect 、proxy等拓展的实现与浅析

610 阅读1分钟

前置知识

Vue3.X的相关优化汇总

源码优化
  • monorepo优化
    相对于Vue2.X,Vue3.X的monorepo将那些模块拆分到不同的包(package)中,每个包有各自的API、类型定义和测试,这样可以使得模块的拆分粒度更细、职责划分更明确,模块之间的依赖关系也更加明显
    • Vue2.X源码托管
      • 根据功能进行拆分出相关的模块
        • compiler:模版编译相关逻辑
        • core:与平台无关的通用运行时代码
        • platforms:平台转悠代码
        • server:服务端渲染相关代码
        • sfc:.Vue单文件解析相关逻辑
        • shared:共享工具代码
        • 等等......
    • Vue3.X源码托管图解 image.png
  • typescript
    • Vue2.X的Flow工具
      • Flow是Facebook出品的javascript静态类型检查检查工具,可以以非常低的成本对已有的javascript代码进行检测,非常灵活
      • 但是对于复杂场景下的类型检测,Flow支持度并不高(如复杂类型推导失败等),如在Vue的实现中的Props的推导就会失败,需要手动添加类型(如Props:any = vm.$options.props)
    • Vue3.X的typescript工具
      • 基于Vue2.X的Flow的缺陷,Vue3.X重构成了typescript进行代码检测
性能优化
  • 源码体积方面的优化
    • 移除一些冷门的功能(如filter、inline-template等)
    • 引入Tree-Shaking技术,减小打包体积;
      • Tree-Shaking的原理相对简单,依赖ES6模块语法的静态结构(即import和export),通过编译阶段的静态分析,找到没有导入的模块并打上标记,然后在压缩阶段删除已标记的代码
      • 压缩阶段会利用uglify-js和terser等压缩工具真正的删除这些没有用到的代码
      • 在Vue3.X中利用Tree-Shaking如果在项目里有没有用到的组件,则其对应的代码也不会被打包,这样可以减少Vue的代码体积
数据劫持优化

因为在渲染DOM的时候访问了数据,所以就建立起了数据和视图之间的依赖关系,然后可以通过劫持数据间接的实现对视图的劫持;

Vue数据劫持图解.png

  • Vue2.X的Object.defineProperty
    • 缺点:
      • 不可以检测对象属性的删除和添加
        • 官方解决方案:set()set()和delete()
      • 对深层次的对象需要通过递归将每个数据都变成响应式可监测的
  • Vue3.X的proxy API
    • 原理是劫持的是整个对象,因此可以实现对对象属性的添加和删除都可以监测到
    • 缺陷
      • Proxy API还是不能监测到内部深层次的对象变化
        • 官方解决方案:在Proxy处理对象的getter中递归相应,这样只有在访问到内部对象时才会变成响应式的

编译优化

image.png

在Vue中,响应式过程是发生在init阶段,模版template编译生成render函数的流程是借助vue-loaderwebpack编译阶段离线完成的,并不一定在运行时完成,因此可以在patch阶段通过在编译阶段优化编译结果来实现patch过程的优化

  • Vue2.X
    • 数据更新并触发重新渲染的粒度是组件级别的,为了保证更新的组件最小化,在Vue2.X中需要递归遍历该组件的所有vNode树,即使内部有很多不需要遍历收集的静态死节点,这会导致VNode的更新性能和模版大小正相关,而和动态节点数量无关,造成了较大的性能浪费
  • Vue3.X
    • 在Vue3.X中通过编译阶段静态模版的分析,编译生成了Block Tree,Block Tree是将模版基于动态节点指令切割的嵌套区块,每个区块内部的节点结构是固定的,而且每个区块只需要一个Array来追踪自生包含的动态节点;
    • 借助Block Tree,Vue3.X将VNode的更新性能由与模版整体大小相关提升为动态内容数量相关,实现了非常大的性能突破

语法API优化

主要是提供了composition API

  • options API
    • 在Vue1.X、Vue2.X中,编写组件的本质是编写一个包含了描述组件选项的对象,将其称为options API
    • options api的设计是按照methods、data、computed和Props等不同的选项分类组成的,其存在的缺点往往会在大型项目中体现出来,如某个小功能的实现需要在methods和computed等选项一起实现,当大型项目中有多个这种类似的小功能时,此时代码逻辑是非常分散的,更改某一小点,需要费老大劲了;
    • 缺点
      • 代码的可读性随着组件变大而变差
      • 每一种代码的复用方式都有缺陷
      • Typescript支持有限
  • composition API image.png
    • composition API也叫组合式API,Vue3中组合式API的入口就是setup函数,setup函数会在组件被创建(create)之前执行,且该函数中不可以使用this,该函数内部不会将this绑定到Vue实例上
      • setup(props,context){}中的Props是响应式的,不能进行解构(会失去响应式),解构可以结合toRefs实现;const { msg } = toRefs(props)
      • setup(props,context){}中的context是一个普通的JS对象,其暴露了再setup中可能会用到的值:attrs、slots、emit和expose;不能访问到其他属性如模版的refs属性等
    • composition API就是为了解决options API存在的问题,避免了一个功能点下的API太过于分散不便于维护,将同一个功能下的API统一放到一个地方,这样项目的开发和维护就方便多了
    • 将功能所定义的所有API都放到一起,更加的高内聚、低耦合
    • 优点
      • composition API几乎都是函数,会有更好的类型推导
      • composition API对Tree-Shaking更友好,代码也更容易压缩
      • composition API中没有this指向的问题
      • 大型项目可以使用composition API,小型项目还是阔以继续使用options API的
  • composition API与options API对比
    • 逻辑组织方面 image.png
    • 逻辑复用方面
      • Vue2.X可以通过mixin实现,但是会存在命名冲突和数据来源不清晰等问题
      <template>
          <div>
            Mouse position: x {{ x }} / y {{ y }}
          </div>
        </template>
        <script>
        import mousePositionMixin from './mixin.js'
        export default {
          mixins: [mousePositionMixin, fooMixin, barMixin, otherMixin]
        }
        </script>
      
      • Vue3.X就没有这些问题了,另外也不用写过多的复杂选项进行功能实现

响应式系统作用与实现

响应式数据和副作用函数

内部逻辑
  • 副作用函数
    • 副作用函数指的是会产生副作用的函数,该函数的执行会直接或间接的影响其他函数的执行,也可能是修改了全局的变量
  • 响应式数据
    • 响应式数据可以理解为副作用函数中依赖的外部数据变化时,该副作用函数也会随之执行,此时该外部数据就可以理解称为响应式数据,但普通的对象是没有这个功能的,需要单独实现相关代理
  • 兼容性收集副作用函数
    • 副作用函数可能是匿名函数等函数类型,所以可以提供一个用来注册副作用函数的机制-全局变量存储副作用函数,在依赖收集时只收集该变量即可
      • 注册副作用函数的逻辑
        • 创建全局变量用于接收effect中的副作用函数
        • 在初始化的时候进行副作用函数赋值到全局变量,并执行一次副作用函数
        • 当执行副作用函数时会触发响应式数据的读取操作,进而触发代理对象Proxyget拦截函数
        • 在代理对象Proxy中,根据全局的缓存变量进行依赖收集
        • 在进行依赖的响应式对象更改的时候,触发代理对象Proxyset拦截,在set中先进行更新原始值,再进行缓存的副作用函数的读取与执行
注意点
  • 分支切换和cleanup
    • 当有分支控制时(即effect中执行的逻辑会有三元等条件控制),这时就得考虑cleanup了,不然在执行副作用函数时会触发不止一个的响应式字段的读取操作,进而触发了过多无用的依赖收集,当然有时也会触发已不用响应式字段的副作用函数的重新执行,即使这并不需要
    • 最基本的,当分支控制时,如果条件判断到某些响应式字段不会再触发读物操作,这时应该讲副作用函数在该响应式字段中的依赖收集给清除掉
    • 解决思路:每次副作用函数执行时,可以先将他从所有与之关联的依赖集合中删除,断开副作用函数与响应式数据之间的联系
      • 要实现断开联系,就需要知道副作用函数在哪些响应式数据集合中依赖包含了它,需要重新设置副作用函数的实现逻辑
      • 在track中进行activeEffect的反向收集依赖dep,将其(dep)添加到activeEffect的deps属性中activeEffect.deps.push(dep) // 反向收集依赖dep 用于stop等外部方法操作收集的dep;
      cleanupEffect(effect) {
        effect.deps.forEach((dep: any) => {
          // dep是一个Map
          dep.delete(effect);
        });
        // 内部的dep上收集的effect已经都清空了 所以effect上的deps也没必要存在了  可以清空 节约空间
        effect.deps.length = 0;
      }
      
      • 存在的问题:由于在进行cleanupEffect操作时,如果通过set出发了trigger函数,此时还是会执行fn,进而再次收集依赖,导致陷入死循环的问题,所以需要重构一下trigger函数的逻辑,用Set进行包裹(forEach循环中)
      let targetMap = new Map();//在track中进行收集
      function trigger(target, key) {
          let depsMap = targetMap.get(target),
          dep = depsMap.get(key);
      
        const effectsToRun = new Set(dep)  
        effectsToRun.forEach(item => item())  
        // effects && effects.forEach(item => item()) 
      }
      

拓展:在调用forEach遍历Set集合时,若果一个值已经被访问过了,且该值被删除后重新添加到集合中,如果此时forEach没有执行结束,那么该值又会被重新访问,这将导致代码陷入死循环的可能

  • 在进行缓存数据时,需要用weakMap代替以往的Set作为桶的数据结构
    • 是为了防止缓存不同对象、同一对象不同key、两个副作用函数读取同一对象同一key等导致的依赖收集错误的问题
const bucket = new WeakMap();
const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    // 没有 activeEffect,直接 return
    if (!activeEffect) return target[key];
    // 根据 target 从“桶”中取得 depsMap,它也是一个 Map 类型:key --> effects
    let depsMap = bucket.get(target);
    // 如果不存在 depsMap,那么新建一个 Map 并与 target 关联
    if (!depsMap) {
      bucket.set(target, (depsMap = new Map()));
    }
    // 再根据 key 从 depsMap 中取得 deps,它是一个 Set 类型,
    // 里面存储着所有与当前 key 相关联的副作用函数:effects
    let deps = depsMap.get(key);
    // 如果 deps 不存在,同样新建一个 Set 并与 key 关联
    if (!deps) {
      depsMap.set(key, (deps = new Set()));
    }
    // 最后将当前激活的副作用函数添加到“桶”里
    deps.add(activeEffect);

    // 返回属性值
    return target[key];
  },
  // 拦截设置操作
  set(target, key, newVal) {
    // 设置属性值
    target[key] = newVal;
    // 根据 target 从桶中取得 depsMap,它是 key --> effects
    const depsMap = bucket.get(target);
    if (!depsMap) return;
    // 根据 key 取得所有副作用函数 effects
    const effects = depsMap.get(key);
    // 执行副作用函数
    effects && effects.forEach((fn) => fn());
  },
});
// WeakMap 由 target --> Map 构成 (key是target,value是Map实例)
// Map 由 key --> Set 构成 (key是原始对象target的key,value是由副作用函数组成的Set)
// codeBy - <<Vue设计与实现>>

WeakMap、Map、Set和原始对象的内部关系

拓展
Proxy浅层拓展
  • proxy 拦截函数的调用操作
function fn(name,age){
  console.log(name,'name=========',age)
}
const fn1 = new Proxy(fn,{
  apply(target,thisArg,argArray){
    console.log(target,thisArg,argArray,'tar=========')
    target.call(thisArg,...argArray)
  }
})

fn1('Lbxin',123)

// [Function: fn] undefined [ 'Lbxin', 123 ] tar=========
// Lbxin name========= 123
Reflect

Reflect是一个内置的对象,提供了拦截js操作的方法,这些方法与Proxy Handlers的方法相同。Reflect不是一个函数,因此是不可以进行构造的(new或者直接函数调用),其属性和方法是静态的;

  • 规范化某些对象的返回结果
    • 未来常规对象上的新方法都部署在Reflect对象上
    • Object.defineProperty(obj, name, desc)在无法定义属性时会进行报错,而Reflect.defineProperty(obj, name, desc)则会返回false,使用更加规范化
    • 使得对象的操作变成了函数的行为
  • Reflect.get浅析
    • 语法:Reflect.get(target, propertyKey[, receiver])
    • 重点在于第三个参数,可以指定接受者receiver,可以理解为函数调用时的this
const obj = {
  foo: 1,
  get bar(){
    return this.foo
  }
}

const p = new Proxy(obj,{
  get(target, key) {
      return target[key]
  }
})

effect(() => {
 console.log(p.bar)
})

// 改造后
const p = new Proxy(obj,{
  get(target, key, receiver) {
      return Reflect.get(target, key, receiver)
  }
})
WeakMap、Map、Set、WeakMap
  • set
    • 定义及特征
      • js中的set对象允许存储任何类型的唯一值,不区分存储值的类型
      • 顶层数据结构不具备keyvalue的特征,内部会自动index序列区分
      • 对于原始数据类型的存储,如果存储值相同会有覆盖操作,对于引用类型的值当==相同时(引用地址相同)时也会存在覆盖操作 - set不具备key值,index做的区分
      • 在调用forEach遍历set集合时,当一个值已经被访问过了,但该值被删除后又重新添加到集合中,如果此时forEach还没有结束,那么该值会被重新访问,此时此forEach会陷入死循环中
    • 具备的方法属性
      • size、delete,has、clear
      • add:向集合中添加一个元素,当添加相同元素时(或已存在),集合不变
    • 应用场景
      • 数组去重
      • 求并集、交际、差集
  • WeakSet
    • 定义及特征
      • 结构上和Set类似,都是不重复的值集合,但是其值只能是对象
      • 对元素的引用时弱引用,没有其他引用于这个对象时,会被GC进行回收
      • 当遍历元素时,有可能造成对象的不能正常的销毁可能 - 不能遍历
    • 具备的方法属性
      • add、delete、has
  • Map
    • 定义及特征
      • Map对象保存键值对,其keyvalue不区分数据类型
      • 没有弱引用的逻辑,所以会有内存溢出的风险
    • 具备的方法属性
      • size、delete,has、clear、set、get、forEach、for~of
  • WeakMap
    • 定义及特征
      • key必须是对象,不接受其他类型的数据作为key值
      • 不能被遍历
      • 是一对键值对的集合,但是相对于Map,其中的键是弱引用,值是常规的引用
      • 由于弱引用的存在,WeakMap中存储的键值对有可能被垃圾回收(该键没有其他地方引用时)

参考文献 详解es6中set,map,WeakSet,Weakmap的区别和用法

实现思路
  • 当执行副作用函数时,会触发字段obj.text响应式数据的读取操作;
  • 当修改obj.text的时候,会触发响应式数据的设置操作
  • 思路:通过设置一个对象的拦截代理操作,就可以实现了
    • 读取响应式数据的时候,可以将副作用函数存储的一个"桶new Set()"里; image.png
    • 设置相应式数据的时候,读取对应"桶里new Set()"缓存的副作用函数并执行即可,在执行前需要先更新原始数据,然后再执行读取和执行缓存函数;

image.png

内部实现

问题关键变成了如何拦截一个对象的读取设置操作,在Vue3之前版本是采用ES2015之前的Object.defineProperty函数实现的,在Vue3开始使用ES2015的Proxy代理对象进行实现

Vue3响应式图解 Vue3响应式原理.webp

Vue3 响应式流程解析

在Vue3中,一般通过在setup里运用reactiveref实现响应式,接着数据和视图就形成了响应式关系,数据改变的时候视图也会随之改变;
Vue3中,响应式和视图是抽离开的,通过reactiverefeffect技术来实现

常规数据变化引起其他关联数据变化的方式

  • 通过在每次依赖数据变化时,单独调用赋值操作实现
  • 通过将赋值过程封装到函数中,在每次依赖数据变化时,都调用一遍该函数即可

Vue3 实现方式

通过reactive或ref对数据做一层代理,借助effect收集依赖,原始数据变化时,触发依赖,自动执行一遍effect中收集的依赖函数

reactive 或 ref对原始数据做代理
  • reactive 对应用类型的数据进行响应式代理
  • ref 对基本数据类型的数据进行响应式代理
  • get的时候进行track依赖收集
  • set的时候进行trigger触发依赖
effect 收集依赖,依赖数据变化时触发依赖

effect的作用就是在trigger的时候来收集当前的fn,并对外提供一个run函数,想啥时候调用就调用 - 单独包装一下effect的逻辑

effect会默认调用一次传入的依赖函数,并且在默认执行依赖函数的过程中将依赖的函数进行全局变量缓存,供响应式get的时候通过track进行依赖收集

单侧示例

describe('effect', () => {
    it('happy path', () => {
        const user = reactive({
            age: 12
        })
        let logAge;
        effect(()=>{
            logAge = user.age + 1
        })
        expect(logAge).toBe(13)
        user.age++;
        expect(logAge).toBe(14)
    });
})

reactive 实现

功能概述

作用就是创建原始对象的响应式对象的副本,即将引用类型的数据转换为响应式数据,入参必须是对象或者数组

reactive 其实就是返回一个Proxy,在get的操作的时候,先运行Reflect.get返回对应的值,然后通过track方法收集依赖,在set的时候,先运行Reflect.set设置对应的值,然后通过trigger触发依赖;

Proxy只能代理引用类型的数据,对于基本类型的数据需要单独处理,在Vue3中是通过将基本数据类型转换为一个对象,该对象只有一个value,value属性的值就是这个基本数据类型的值,然后就可以用reactive方法将这个对象转换为响应式的proxy对象;即ref(0) --> reactive( { value:0 })

内部实现

返回一个proxy,内部通过get收集依赖,set触发更新依赖

const isObject = val => val !== null && typeof val === 'object';
function reactive(from) {
  // 首先先判断是否为对象 
  if (!isObject(from)) return from;
  return new Proxy(from, {
    get(target, key) {
      const res = Reflect.get(target, key);
      
      // 深度监听(惰性) 
      if (isObject(res)) { 
        return reactive(res); 
      }
      
      // 需要进行依赖收集
      //effect 中实现
      track(target, key);
      return res;
    },
    set(target, key, value) {
      const oldRes = Reflect.get(target, key, value); 
      const res = Reflect.set(target, key, value); 
      
      // 需要触发收集的依赖
      //effect 中实现
      if(res && res !== oldRes){
        trigger(target, key);
      }
      
      return res;
    },
  });
}

当进行依赖收集的时候,当每一个reactive中的每一个key发生变化时都需要去收集与之对应的一个fn依赖,由于是修改对象的属性,收集时收集的是对象的Map,因此需要考虑让一个对象对应多个key,每个key对应不同的fn依赖

effect 实现

功能概述

effect接收一个函数作为入参,内部会维护一个对象,将入参fn缓存起来,通过自定义的run方法执行该fn,同时将this实例挂载到全局变量中,供后续收集,在执行fn时会触发reactive返回的proxy中的get方法,这会触发effect中的track方法,收集依赖

内部实现

export function effect(fn) {
    const _effect = new ReactiveEffect(fn)  
    _effect.run()
}

// 全局变量 挂载缓存的this实例
let activeEffect = null;
class ReactiveEffect {
  constructor(fn) {
    this._fn = fn;
  }
  run() {
    // 该 this 指的是当前的实例对象  就是effect传入的fn
    activeEffect = this;
    this._fn();
  }
}

const targetMap = new Map() //保证缓存数据唯一性
// `track`在`reactive`中的get中触发 用来收集依赖  并将数据缓存到全局变量中 供后续的`trigger`进行依赖获取
// 收集的依赖存储在dep中
function track(target, key) {
  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);
  }
  if(dep.has(activeEffect)) return 
  dep.add(activeEffect);
}

// `trigger`触发`track`中收集的依赖  数据通过全局变量`targetMap`实现
function trigger(target, key) {
  const depsMap = targetMap.get(target);
  const dep = depsMap.get(key);
  dep.forEach((effect) => effect.run());
}

//targetMap 数据格式
targetMap: {
  // key 是对象,value 是 depsMap
  {age: 25} : {
    // key 是对象里边的 key, value 是 dep 即全局变量缓存的this实例
    age: [ ...此处存储一个个依赖 ]
  }
}

ref 实现

功能概述

作用是将基本数据类型数据转换为响应式数据

reactive 用来响应式复杂数据的,普通数据类型则需要通过ref来实现响应式,由于只需要存储简单数据类型,所以就不需要用Map数据结构了,只要Set数据结构就可以了;

实现原理

内部可以借助reactive进行实现,即通过将简单数据结构的数据转换为对象格式再通过reactive进行代理,但需要内部进行__v_isRef的标识,用来标识是ref创建的;

// 封装一个 ref 函数
function ref(val) {
  // 在 ref 函数内部创建包裹对象
  const wrapper = {
    value: val
  }
  // __v_isRef不可枚举
  Object.defineProperty(wrapper, '__v_isRef', {
    value: true
  })
  // 可以通过__v_isRef来判断一个数据是不是`ref`了
  // 将包裹对象变成响应式数据
  return reactive(wrapper)
}

内部实现

// 提取track和trigger中的dep缓存
function trackEffects(dep) {  
  dep.add(activeEffect)
}
function triggerEffects(dep) {  
  dep.forEach(effect => effect.run()) 
}


function ref(from) {
  return new RefImpl(from)
}
class RefImpl {
  constructor(from) {
    this._value = from
    this.dep = new Set()
  }
  get() {
    // 将effect新建的实例存储到RefImpl自身的dep中
    trackEffects(this.dep)
    return this._value
  }
  set(newValue) {
    this._value = newValue
    triggerEffects(this.dep)
  }
}
  • 示例
const refExam = ref(1)
//只返回实例对象,并没有执行get和Set方法
let result

//effect会先执行内部缓存的函数fn,然后将实例缓存到全局变量上
//执行内部函数时会触发ref的get方法,进而通过trackEffects方法将effect中缓存的实例存储到ref自身的dep中
effect(() => {
  result = refExam.value + 2
})

//当执行赋值操作时,会触发RefImpl的Set方法,赋值后通过triggerEffects将缓存的响应式实例取出后执行对应的run方法,进而执行了缓存函数实例-effect中的复制逻辑
refExam.value = 2
console.log(result) // 4

拓展点

proxy 拓展

对象的分类
  • 常规对象与异质对象
    • 在js中,对象的实际语义是由对象的内部方法指定的,所谓内部方法指的是当对一个对象进行操作时在引擎内部的调用方法,这些方法对js使用者是不可见的;
      • 例如obj.foo的执行访问,引擎内部会调用[[get]]这个内部方法来读取属性值,在ES规范中使用[[xxx]]来代表内部方法或内部槽;
      • 在ES规范中,一个对象必须部署11个必要的内部方法,当一个对象需要作为一个方法调用时,那么这个对象就必须部署内部的方法[[call]]
      • 通过内部方法和内部槽来区分对象,一个对象如果部署了内部的[[call]]方法,它就是函数,是可以作为函数调用的;
      • 内部方法具有多态性,类似于面向对象的多态的概念,也就是说不同的对象可能部署了相同的方法,却有着不同的逻辑,例如Proxy[[get]]和普通对象的[[get]]是不同的逻辑;
      • 代理在某些情况下是需要透明性的,创建代理对象实际也可以理解成为指定拦截函数,是用来自定义代理对象本身的内部方法和行为的,而不是用来指定被代理对象内部的方法和行为的,当在创建代理对象时没有指定对应的拦截函数,此时会默认走到调用原始对象的对应内部方法了;
      • 当代理对象是函数和构造函数时才会指定除常规对象内必须部署的11个内部方法外的[[call]][[construct]]内制动额拓展方法;
对象的代理拦截
  • 触发对象读取操作的操作和对应的拦截方法
    • 访问属性:obj.foo
      • 直接通过Proxy进行代理,在get中通过Reflect返回对应的值即可Reflect.get(target,key,receiver)
      const obj = { foo: 1 };
      const p = new Proxy(obj, {
        get(target, key, receiver) {
          // 建立联系
          track(target, key);
          // 返回属性值
          return Reflect.get(target, key, receiver);
        },
      });
      
    • 判断对象或者原型上是否有指定的keykey in obj
    • 使用for...in循环遍历对象for(const key in obj){}
      • 为了解决副作用函数的触发时机问题,需要在trigger中进行ITERATE_KEY的读取与执行,这样就可以兼顾到for...in循环遍历对象时的响应式问题了,但是为了节省性能,需要在ITERATE_KEY依赖的读取与执行前进行判断是新添值还是修改值,如果是新添值则执行对应的操作
      • 在触发trigger之前需要判断值是否相等,但是会存在例如NaN !==NaN的问题,所以需要多加一层逻辑处理
      // ...
      
      if(oldVal !== newVal && (oldVal === oldVal || newVal === newVal)){
        trigger(target,key,type)
        //type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD”
      }
      // ...
      
      • for...in内部是通过generator函数来实现的,内部关键点在于用到了Reflect.ownKeys(obj)来获取自身拥有的键然后遍历的,所以可以通过ownKeys拦截函数来实现Reflect.ownKeys的操作
      const obj = { foo: 1 },
        ITERATE_KEY = Symbol();
        //使用ITERATE_KEY是因为在调用ownKeys时,是无法获取到具体的key值的,不像不同的对象get/set拦截可以知道具体的key值,ownKeys是用来访问所有的键值的,因此采用Symbol进行key的唯一标识
      const p = new Proxy(obj, {
        ownKeys(target) {
          // 副作用函数与ITERATE_KEY关联
          track(target, ITERATE_KEY);
          // 返回属性值
          return Reflect.ownKeys(target);
        },
      });
      

数组也是一种对象,但是是属于异质对象,其内部的 [[DefineOwnProperty]]与常规对象的内置方法逻辑是不同的,其他的内置方法是一致的

  • 触发数组读取操作的操作和对应的拦截方法
    • 通过索引访问数组元素值:arr[0]
    • 访问数组长度:arr.length
    • 把数组作为对象,使用for...in循环遍历
      • 使用for...in时与常规对象的for...in一致,都是通过ownKeys拦截函数进行拦截的
      • 与常规对象的ownKeys拦截不同,数组的ownKeys拦截需要将数组的length属性作为key进行响应式关联 - 这样可以解决在更改数组length时能够正常的触发响应式的(副作用函数)重新执行
      const obj = { foo: 1 },
        ITERATE_KEY = Symbol();
        //使用ITERATE_KEY是因为在调用ownKeys时,是无法获取到具体的key值的,不像不同的对象get/set拦截可以知道具体的key值,ownKeys是用来访问所有的键值的,因此采用Symbol进行key的唯一标识
      const p = new Proxy(obj, {
        ownKeys(target) {
          // 副作用函数的关联需要与target类型关联,对象时与ITERATE_KEY关联,数组时与length关联
          track(target, Array.isArray(target) ? 'length' : ITERATE_KEY);
          // 返回属性值
          return Reflect.ownKeys(target);
        },
      });
      
    • 使用for...of迭代遍历数组
      • for...of是用来遍历可迭代对象(iterable object)的;
      • 可迭代对象(iterable object)是一种协议(iterable protocal),一个对象能否被迭代,取决于该对象的原型是否实现了@@iterator(Symbol.iterator)方法,如果实现了该方法,那么该对象就是可以被迭代的
      • 出了数组的for...of会触发@@iterator(Symbol.iterator)方法外,当调用values方法也会读取数组的Symbol.iterator属性
      • 数组的for...ofvalues方法本质上都是将数组的长度或索引与副作用函数建立响应式关联,而内部还是会调用数组的Symbol.iterator属性,所以就得在依赖收集时排除掉副作用函数与Symbol.iterator 这类 symbol值之间建立响应式关联
      // ...
      // 添加判断,如果 key 的类型是 symbol,则不进行追踪
      if (!isReadonly && typeof key !== 'symbol') {
        track(target, key)
      }
      // ...
      
      const obj = {
        val: 0,
        [Symbol.iterator]() {
          // 要有具体的返回值和具体的终止循环的条件 - done = false,否则将陷入死循环中
          return {
            next() {
              return {
                value: obj.val++,
                done: obj.val > 10 ? true : false,
              };
            },
          };
        },
      };
      
    • 数组的原型方法
  • 触发数组设置操作的方法和对应的拦截方法
    • 通过索引修改数组元素值:arr[0] = 123

      • 该设置会执行数组对象所部署的内部方法[[set]],这个与常规对象的属性值一致;
      • 内部方法[[set]]依赖于[[DefineOwnProperty]],这里就体现出了差异
    • 修改数组长度:arr.length = 0

      • 当修改数组长度时,需要找到所有索引值大于或等于新的length值的元素,然后取出对应的副作用函数后执行
    • 数组的栈方法:push/pop/shift/unshift

      • 在调用栈方法时,大多还是依赖于数组的length属性,不同点在于既会读取数组的length属性值,也会设置数组的length属性值,因此就出现了数组响应式重写栈方法的操作 - 在执行原始方法前进行加锁操作,防止追踪,当执行完毕后打开锁,允许追踪
      const arrayInstrumentations = {
        splice: function() {/* ... */},
        push: function() {/* ... */},
        pop: function() {/* ... */},
        shift: function() {/* ... */},
        unshift: function() {/* ... */},
      }
      let shouldTrack = true;
      // 重写数组的 push、pop、shift、unshift 以及 splice 方法
      ["push", "pop", "shift", "unshift", "splice"].forEach((method) => {
        const originMethod = Array.prototype[method];
        arrayInstrumentations[method] = function (...args) {
          shouldTrack = false; // 加锁 防止追踪
          let res = originMethod.apply(this, args);
          shouldTrack = true; // 开锁,允许追踪
          return res;
        };
      });
      
      export function isTracking(){
          // 判断是否进行依赖收集 其依据是在 run 中进行的 shouldTrack 和 activeEffect 为有值时表示可以进行依赖收集
          return shouldTrack && activeEffect !== undefined
      }
      let targetMap = new Map();
      
      export function trck(target,key){
      
          // if(!activeEffect) return;
          // if(!shouldTrack) return
          if(!isTracking() ||  !shouldTrack) 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)
          }
          trackEffects(dep);
          // if(dep.has(activeEffect)) return 
          // dep.add(activeEffect)
      
          // activeEffect.deps.push(dep) // 反向收集依赖dep 用于stop等外部方法操作收集的dep
      }
      
    • 修改原数组的原型方法:splice/fill/sort等

      function createReactive(obj, isShallow = false, isReadonly = false) {
        return new Proxy(obj, {
          // 拦截设置操作
          set(target, key, newVal, receiver) {
            if (isReadonly) {
              console.warn(`属性 ${key} 是只读的`);
              return true;
            }
            const oldVal = target[key];
            // 数组 - 当被设置的索引值如果小于数组长度,就视作 SET 操作,因为它不会改变数组长度;
            // 数组 - 如果设置的索引值大于数组的当前长度,则视作 ADD 操作,因为这会隐式地改变数组的 length 属性值
            // 对象 - 当设置的key在对象自身属性中,则视为修改值
            // 对象 - 当设置的key不在对象自身属性中,则视为添加值
            const type = Array.isArray(target)
              ? Number(key) < target.length
                ? "SET"
                : "ADD"
              : Object.prototype.hasOwnProperty.call(target, key)
              ? "SET"
              : "ADD";
            // receiver类似于this 防止通过深层对象配合拷贝操作造成的target错乱的问题
            const res = Reflect.set(target, key, newVal, receiver);
            if (target === receiver.raw) {
              if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
                // 增加第四个参数,即触发响应的新值
                trigger(target, key, type, newVal);
              }
            }
            return res;
          },
        });
      }
      
      // 为 trigger 函数增加第四个参数,newVal,即新值
      function trigger(target, key, type, newVal) {
        const depsMap = bucket.get(target);
        if (!depsMap) return;
        // 省略其他代码
        // 如果操作目标是数组,并且修改了数组的 length 属性
        if (Array.isArray(target) && key === "length") {
          // 对于索引大于或等于新的 length 值的元素,
          // 需要把所有相关联的副作用函数取出并添加到 effectsToRun 中待执行
          depsMap.forEach((effects, key) => {
            if (key >= newVal) {
              effects.forEach((effectFn) => {
                if (effectFn !== activeEffect) {
                  effectsToRun.add(effectFn);
                }
              });
            }
          });
        }
        effectsToRun.forEach((effectFn) => {
          if (effectFn.options.scheduler) {
            effectFn.options.scheduler(effectFn);
          } else {
            effectFn();
          }
        });
      }
      

参考文献

Ref拓展原理实现