响应式

79 阅读9分钟

前置知识

Object.defineProperty

Object.defineProperty(obj, prop, descriptor)  静态方法会直接在一个对象上定义一个新属性,或修改其现有属性,并返回此对象。

Object.defineProperty() 允许精确地添加或修改对象上的属性。通过赋值添加的普通属性会在枚举属性时(例如 for...in、Object.keys() 等)出现,它们的值可以被更改,也可以被删除。此方法允许更改这些额外细节,以使其不同于默认值。默认情况下,使用 Object.defineProperty() 添加的属性是不可写、不可枚举和不可配置的。

对象中存在的属性描述符有两种主要类型:数据描述符和访问器描述符。数据描述符是一个具有可写或不可写值的属性。访问器描述符是由 getter/setter 函数对描述的属性。描述符只能是这两种类型之一,不能同时为两者。

  1. 数据描述符和访问器描述符共享的配置
    • configurable:当且仅当该属性的 configurable 键值为 true 时,该属性的描述符才能够被改变,同时该属性也能从对应的对象上被删除。默认为 false。
    • enumerable:当且仅当该属性的 enumerable 键值为 true 时,该属性才会出现在对象的枚举属性中。默认为 false。
  2. 数据描述符独有的配置
    • value
    • writable
  3. 访问描述符独有的配置
    • get:当访问该属性时,会调用此函数。执行时不传入任何参数,但是会传入 this 对象。默认为 undefined。
    • set:当属性值被修改时,会调用此函数。该方法接受一个参数(也就是被赋予的新值),会传入赋值时的 this 对象。默认为 undefined。

proxy

Proxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。

  1. 语法
const p = new Proxy(target, handler)

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

handler:一个通常以函数作为属性的对象,各属性中的函数分别定义了在执行各种操作时代理 p 的行为。

常用的捕捉器

  • handler.get():属性读取操作的捕捉器。
  • handler.set():属性设置操作的捕捉器。
  • handler.deleteProperty():delete 操作符的捕捉器。
  1. proxy对象和原对象
let target = {};
let p = new Proxy(target, {});

p.a = 37;   // 操作转发到目标

console.log(target.a);    // 37. 操作已经被正确地转发

target.a=4;
console.log(p.a)//4

console.log(target==p)//false
  1. 代理最外层对象
let obj={a:1,b:{c:2}};
let handler={
  get:function(obj,prop){
    const v = Reflect.get(obj,prop);
    return v; // 返回obj[prop]
  },
  set(obj,prop,value){
    return Reflect.set(obj,prop,value);//设置成功返回true
  }
};
let p=new Proxy(obj,handler);

p.a//会触发get方法
p.b.c//会触发get方法获取p.b,不会触发.c的set,因为c没被代理。
  1. 递归代理一个对象
let obj={a:1,b:{c:2}};
let handler={
  get:function(obj,prop){
    const v = Reflect.get(obj,prop);
    if(v !== null && typeof v === 'object'){
      return new Proxy(v,handler);//代理内层
    }else{
      return v; // 返回obj[prop]
    }
  },
  set(obj,prop,value){
    return Reflect.set(obj,prop,value);//设置成功返回true
  }
};
let p=new Proxy(obj,handler);

p.a//会触发get方法
p.b.c//会先触发get方法获取p.b,然后触发返回的新代理对象的.c的set。
  1. 基于proxy劫持对象,执行特定操作
function reactive(obj) {
    if (typeof obj !== 'object' && obj != null) {
        return obj
    }
    // Proxy相当于在对象外层加拦截
    const observed = new Proxy(obj, {
        get(target, key, receiver) {
            const res = Reflect.get(target, key, receiver)
            console.log(`获取${key}:${res}`)
            return res
        },
        set(target, key, value, receiver) {
            const res = Reflect.set(target, key, value, receiver)
            console.log(`设置${key}:${value}`)
            return res
        },
        deleteProperty(target, key) {
            const res = Reflect.deleteProperty(target, key)
            console.log(`删除${key}:${res}`)
            return res
        }
    })
    return observed
}

const obj = [1,2,3]
const proxtObj = reactive(obj)
proxtObj.push(4)

打印结果:
"获取push:function push() { [native code] }"
"获取length:3"
"设置3:4"
"设置length:4"

defineProperty和proxy的对比

  • Object.defineProperty只代理对象上的某个属性。Proxy直接可以劫持整个对象,并返回一个新对象,我们可以只操作新的对象达到响应式目的。
  • Proxy可以直接监听数组的变化(push、shift、splice)。
  • 如果对象内部要全部递归代理,则Proxy可以只在调用时递归,而Object.defineProperty需要在一开始就全部递归,Proxy性能优于Object.defineProperty。
  • 对象上定义新属性时,Proxy可以监听到,Object.defineProperty监听不到。
  • Proxy不兼容IE,Object.defineProperty不兼容IE8及以下。
  • Proxy有多达13种拦截方法,不限于apply、ownKeys、deleteProperty、has等等,这是Object.defineProperty不具备的。

Reflect

Reflect 是一个内置的对象,它提供拦截 JavaScript 操作的方法。这些方法与 proxy handler 的方法相同。Reflect 不是一个函数对象,因此它是不可构造的。

Reflect 对象提供了以下静态方法,这些方法与 proxy handler 方法的命名相同。

  • Reflect.deleteProperty(target, propertyKey)作为函数的delete操作符,相当于执行 delete target[name]。
  • Reflect.get(target, propertyKey[, receiver]) 获取对象身上某个属性的值,类似于 target[name]。
  • Reflect.set(target, propertyKey, value[, receiver]) 将值分配给属性的函数。返回一个Boolean,如果更新成功,则返回true。

响应式

《基于Proxy实现响应式数据》

demo

副作用函数

副作用函数就是会产生副作用的函数,比如一个函数除了返回了一个值以外,还

  • 修改了其他函数或全局的变量
  • 设置了一个对象的成员
  • 打印终端,读取用户输入
  • 抛出错误等。

响应式数据

假设在一个副作用函数中读取了某个对象的属性,我们希望这个属性发生变化时,这个副作用函数被重新执行。 比如,effect函数中读取了obj.text,希望该属性变化时,effect函数被重新执行。 这种情况下,obj就被称为响应式数据。

const obj = { text: 'hello anju' }
function effect() {
   // effect 函数执行会读取obj.text 
  document.getElementById('text').innerHTML = obj.text
}

通过proxy能劫持整个对象,实现响应式。

实现方案

  1. 需要一个桶,用来收集 对象-属性-副作用函数 之间的对应关系。如果在某个副作用函数中读取了某个响应式对象的某个属性,则需要将该副作用函数添加到对应对象对应属性的副作用函数集合中。当该属性的发生变化时,执行对应的副作用函数集合。
  2. 需要一个函数,将原始对象变为响应式对象,该响应式对象拦截了set和get,在get中收集副作用函数,在set中触发副作用函数的重新执行。(注意,需要递归的拦截对象的不同层级)
  3. 需要一个函数来注册副作用函数。副作用函数只要执行了就能触发响应式对象的get方法,但是副作用函数的名称是任意的,甚至是匿名的,那我们在收集的时候就必须要知道副作用函数的名称,且无法处理多个副作用函数。因此需要一种方式来注册副作用那个函数,通过把当前正在收集的副作用函数赋值给activeEffect,就可以收集起来。

具体实现

/**
 * 基于proxy实现一个响应式系统
 * 设计思路来源于vue3
 * https://juejin.cn/post/7134274995051560997
 * https://segmentfault.com/a/1190000040163047
 */

// 用一个全局变量存储当前被注册的副作用函数
let activeEffect;
// 用一个全局变量作为存储副作用函数的桶
// 将副作用函数收集起来,当副作用函数依赖的数据发生变更时,会再次执行这些副作用函数
// bucket具有如下格式
// 首先它是一个WeakMap,WeakMap对key的引用是弱引用,不会影响垃圾回收
// WeakMap的键是原始对象target,值是一个Map实例
// Map实例的键是原始对象target的key,只是一个由副作用函数组成的Set
// {
//   target-1 => {
//     key1 => [effect1, effect2, ...],
//     key2 => [effect3, effect4, ...]
//   },
//   target-2 => {
//     ...console.
//   }
//   ...
// }
const bucket = new WeakMap();

// track函数,收集副作用函数到桶中
function track(target, key) {
  if (!activeEffect) return;
  // 根据 target 从桶中取得 depsMap, 它也是一个 Map 类型:key --> effects
  let depsMap = bucket.get(target);
  if (!depsMap) {
    bucket.set(target, (depsMap = new Map()));
  }
  // 再根据key从 depsMap中取得 deps,它是一个set 类型
  // 里面存储着所有与当前 key 相关联的副作用函数 effects
  let deps = depsMap.get(key);
  if (!deps) {
    depsMap.set(key, (deps = new Set()));
  }
  // 最后将当前激活的副作用函数添加到 桶中
  deps.add(activeEffect);
}

// tigger函数,触发副作用函数重新执行
function trigger(target, key) {
  // 根据target 从桶中取得 depsMap, 它是 key --> effects
  const depsMap = bucket.get(target);
  if (!depsMap) return;
  const effects = depsMap.get(key);
  // 执行副作用函数
  effects && effects.forEach((fn) => fn());
}

// 1. 创建一个响应式的对象
export function observable(data) {
  if (typeof data !== "object" && data != null) {
    return data;
  }
  const obj = new Proxy(data, {
    get(target, key) {
      // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
      track(target, key);
      // 递归代理一个对象
      const v = Reflect.get(target, key);
      console.log("test", v);
      debugger;
      if (v !== null && typeof v === "object") {
        return observable(v); //代理内层
      } else {
        return v; // 返回obj[prop]
      }
    },

    set(target, key, newVal) {
      target[key] = newVal;
      // 取出副作用函数并执行
      trigger(target, key);
      return true;
    }
  });

  return obj;
}
// 2. effect 函数用于注册副作用函数
export function effect(fn) {
  // 调用effect 注册副作用函数时,将副作用函数fn 赋值给activeEffect
  activeEffect = fn;
  // 执行副作用函数,如果函数内部有消费 observable 数据,activeEffect就会被收集起来,当数据发生变化时,当前的副作用函数会重复执行
  fn();
  // 清空当前的activeEffect,避免除了effect以外的函数被收集起来了
  activeEffect = undefined;
}


import { observable, effect } from "./reactive.js";

// 原始数据
const data = { text: "hello test", outer: { inner: "hello initial Inner" } };
// 创建一个响应式的对象
const obj = observable(data);

// 触发响应式数据obj.text 的读取操作,
// 进而触发代理对象Proxy的 get 拦截函数
// 接收一个 tracker 函数,如果函数内部有消费 observable 数据,数据发生变化时,tracker 函数会重复执行
effect(
  // 注册一个匿名的副作用函数
  () => {
    document.getElementById("text1").innerHTML = obj.text;
    console.log("effect run", obj.text);
  }
);
effect(
  // 注册一个匿名的副作用函数
  () => {
    document.getElementById("text2").innerHTML = obj.outer.inner;
    console.log("effect run2", obj.outer.inner);
  }
);

setTimeout(() => {
  obj.text = "hello changed text";
  obj.outer.inner = "hello changed inner";
}, 2000);


参考资料

《Proxy》

《Object.defineProperty 和 proxy 区别和使用》

《基于Proxy实现响应式数据》