邂逅 Proxy & Reflect,探索响应式的实现

前言

需求:有一个对象,我们希望监听这个对象中的属性被设置或获取的过程。

在ES6前,我们也是可以做到这一点的,可以通过属性描述符中的存储属性描述符,来对属性的操作进行监听。

const obj = {
  bar: 123,
  foo: "hello",
};

Object.keys(obj).forEach(key => {
  let value = obj[key];
  Object.defineProperty(obj, key, {
    get: function () {
      console.log(`监听到${key}属性被访问`);
      return value;
    },
    set: function (newValue) {
      console.log(`监听到${key}属性被设为${newValue}`);
      value = newValue;
    },
  });
});

console.log(obj.bar);
obj.foo = "world";
console.log(obj.foo);

Vue 2.x的响应式就是基于这一方法,下面是一个简易版的响应式实现:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
    <style>
      .box {
        width: 200px;
        height: 200px;
        margin: 0 auto;
        border: 1px solid #666;
        border-radius: 10px;
        background-color: aqua;
        display: flex;
        flex-direction: column;
        justify-content: space-around;
      }
      p {
        padding: 0 20px;
        font-size: 20px;
      }
      .input {
        width: 200px;
        height: 200px;
        display: flex;
        flex-direction: column;
        justify-content: space-evenly;
        margin: 0 auto;
      }
    </style>
  </head>
  <body>
    <div class="box">
      <p id="firstName" />
      <p id="lastName" />
      <p id="age" />
    </div>
    <div class="input">
      <input onchange="user.name = this.value" />
      <input type="date" onchange="user.birth = this.value" />
    </div>
    <script>
      let currentFn = null;

      function watchFn(fn) {
        currentFn = fn;
        fn();
        currentFn = null;
      }

      function reactive(obj) {
        Object.keys(obj).forEach(key => {
          let value = obj[key];
          const funcs = new Set();
          Object.defineProperty(obj, key, {
            get: function () {
              if (currentFn) funcs.add(currentFn);
              return value;
            },
            set: function (newValue) {
              value = newValue;
              funcs.forEach(func => func());
            },
          });
        });
        return obj;
      }

      var user = reactive({
        name: "张三",
        birth: "1998-06-08",
      });

      // 显示姓
      function showFirstName() {
        document.querySelector("#firstName").textContent =
          "姓:" + user.name[0];
      }

      // 显示名
      function showLastName() {
        document.querySelector("#lastName").textContent =
          "名:" + user.name.slice(1);
      }

      // 显示年龄
      function showAge() {
        var birthday = new Date(user.birth);
        var today = new Date();
        let age = today.getFullYear() - birthday.getFullYear();
        let m = today.getMonth() - birthday.getMonth();
        if (m < 0 || (m === 0 && today.getDate() < birthday.getDate())) {
          age--;
        }
        document.querySelector("#age").textContent = "年龄:" + age;
      }

      watchFn(showFirstName);
      watchFn(showLastName);
      watchFn(showAge);
    </script>
  </body>
</html>

但这样做是有缺点的:

  • Object.defineProperty设计的初衷,不是为了去监听一个对象中所有的属性的。我们在定义某些属性的时候,初衷其实是定义普通的属性,但是后面我们强行将它变成了数据属性描述符。
  • 如果我们想监听更加丰富的操作,比如新增属性、删除属性,那么Object.defineProperty是无能为力的。

那么有没有更好的方案呢?
当然有!ES6给我们带来了一个新的API:Proxy

Proxy - 代理

介绍

在ES6中,新增了一个Proxy类,这个类从名字就可以看出来,是用于帮助我们创建一个代理的:
如果我们希望监听一个对象(A)的相关操作,那么可以先创建一个代理对象(B)(Proxy对象),之后对A对象的所有操作,都通过代理对象B来完成,代理对象可以监听我们想要对原对象进行哪些操作;

用法

const proxy = new Proxy(target, handler);
  • target表示所要拦截的目标对象(任何类型的对象,包括原生数组,函数,甚至另一个代理)
  • handler通常以函数作为属性的对象,各属性中的函数分别定义了在执行各种操作时的代理行为

参数 handler 属性

如果我们想要侦听某些具体的操作,那么就可以在handler中添加对应的捕捉器(Trap)
关于handler拦截属性,有如下:

  • get(target, key, receiver):拦截对象属性的读取
  • set(target, key, value, receiver):拦截对象属性的设置
  • deleteProperty(target, key):拦截delete proxy[key]的操作,返回一个布尔值
  • has(target, key):拦截key in proxy的操作,返回一个布尔值
  • ownKeys(target):拦截Object.keys(proxy)for...in等循环,返回一个数组
  • getOwnPropertyDescriptor(target, key):拦截Object.getOwnPropertyDescriptor(proxy, key),返回属性的描述对象
  • defineProperty(target, key, propDesc):拦截Object.defineProperty(proxy, key, propDesc),返回一个布尔值
  • preventExtensions(target):拦截Object.preventExtensions(proxy),返回一个布尔值
  • isExtensible(target):拦截Object.isExtensible(proxy),返回一个布尔值
  • getPrototypeOf(target):拦截Object.getPrototypeOf(proxy),返回一个对象
  • setPrototypeOf(target, proto):拦截Object.setPrototypeOf(proxy, proto),返回一个布尔值
  • apply(target, object, args):拦截 Proxy 实例作为函数调用的操作
  • construct(target, args, newTarget):拦截 Proxy 实例作为构造函数调用的操作

优先重点关注 get set deleteProperty 这三个操作

取消代理

Proxy.revocable(target, handler);

案例

对象
const bar = {
  a: 123,
  b: "hello",
};
const barProxy = new Proxy(bar, {
  get: function (target, key, receiver) {
    console.log("barProxy.get", receiver);
    // receiver是创建出来的代理对象
    return target[key];
  },
  set: function (target, key, value, receiver) {
    target[key] = value;
    console.log("barProxy.set", receiver);
  },
  deleteProperty: function (target, key) {
    console.log("barProxy.delete");
    delete target[key];
  },
});
console.log(barProxy.a);
barProxy.b = "world";
console.log(barProxy.b);
delete barProxy.b;
console.log(barProxy);
console.log(bar);
函数
function foo() {
  console.log("foo函数被调用", this, arguments);
  return "foo";
}
const fooProxy = new Proxy(foo, {
  apply: function (target, thisArg, args) {
    console.log("函数的apply侦听");
    return target.apply(thisArg, args);
  },
  construct: function (target, argsArr, newTarget) {
    console.log(target, argsArr, newTarget);
    return new target(...argsArr);
  },
});
fooProxy("11");
new fooProxy("22");

Reflect - 反射

介绍

Reflect也是ES6新增的一个API,它是一个对象,字面的意思是反射

用法

Reflect主要提供了很多操作JS对象的方法,有点像Object中操作对象的方法。
比如Reflect.defineProperty(target, key, attributes)类似于Object.defineProperty()
比如Reflect.getPrototypeOf(target)类似于Object.getPrototypeOf()

既然有Object可以做这些操作,为什么还需要新增Reflect这样一个对象呢?

  • 在早期的ECMA规范中没有考虑到这种对 对象本身 的操作如何设计会更加规范,所以将这些API放到了Object上面,但是Object作为一个构造函数,这些操作实际上放到它身上并不合适
  • 另外还包含一些类似于indelete操作符,让JS看起来是会有一些奇怪的
  • 所以在ES6中新增了Reflect,让我们这些操作都集中到了Reflect对象上

ObjectReflect对象之间的API关系,可以参考MDN文档
Reflect 的所有属性和方法都是静态的(就像 Math 对象)
Reflect 对象提供了以下静态方法,这些方法与 proxy handler 方法的命名相同。

方法

  • Reflect.apply(target, thisArgument, argumentsList)对一个函数进行调用操作,同时可以传入一个数组作为调用参数。和 Function.prototype.apply() 功能类似。
  • Reflect.construct(target, argumentsList[, newTarget])对构造函数进行new操作,相当于执行 new target(...args)
  • Reflect.defineProperty(target, propertyKey, attributes)Object.defineProperty() 类似。如果设置成功就会返回 true
  • Reflect.deleteProperty(target, propertyKey)作为函数的delete操作符,相当于执行 delete target[name]
  • Reflect.get(target, propertyKey[, receiver])获取对象身上某个属性的值,类似于 target[name]
  • Reflect.getOwnPropertyDescriptor(target, propertyKey)类似于 Object.getOwnPropertyDescriptor()。如果对象中存在该属性,则返回对应的属性描述符,否则返回 undefined
  • Reflect.getPrototypeOf(target)类似于 Object.getPrototypeOf()
  • Reflect.has(target, propertyKey)判断一个对象是否存在某个属性,和in运算符的功能完全相同。
  • Reflect.isExtensible(target)类似于 Object.isExtensible().
  • Reflect.ownKeys(target)返回一个包含所有自身属性(不包含继承属性)的数组。(类似于 Object.keys(), 但不会受enumerable 影响).
  • Reflect.preventExtensions(target)类似于 Object.preventExtensions()。返回一个Boolean
  • Reflect.set(target, propertyKey, value[, receiver])将值分配给属性的函数。返回一个Boolean,如果更新成功,则返回true
  • Reflect.setPrototypeOf(target, prototype)设置对象原型的函数。返回一个 Boolean,如果更新成功,则返回 true

案例

get
const person = {
  name: "Guest",
};
const proxy = new Proxy(person, {
  get: function (target, propKey) {
    return Reflect.get(target, propKey);
    // or
    // return target[propKey]
  },
});
console.log(proxy.name); // "Guest"
set
const obj = { name: "张三", age: 18 };
const objProxy = new Proxy(obj, {
  get(target, key) {
    if (key in target) {
      return Reflect.get(target, key);
    } else {
      console.error("字段不存在");
      return undefined;
    }
  },
  set(target, key, value, receiver) {
    if (key === "age") {
      if (typeof value === "number") {
        return Reflect.set(target, key, value, receiver);
        // or
        // target[propKey] = value
        // return true
      } else {
        console.error("年龄只能输入正整数");
        return false;
      }
    } else {
      return false;
    }
  },
});
objProxy.age = 20;
console.log(objProxy.age); // 20
objProxy.age = "22";
console.log(objProxy.age); // 20
console.log(objProxy.test); // undefined

提醒:严格模式下,set代理如果没有返回true,就会报错

deleteProperty

deleteProperty方法用于拦截delete操作,如果这个方法抛出错误或者返回false,当前属性就无法被delete命令删除

const handler = {
  deleteProperty (target, key) {
    invariant(key, 'delete');
    Reflect.deleteProperty(target,key)
    return true;
  }
};
function invariant (key, action) {
  if (key[0] === '_') {
    throw new Error(`无法删除私有属性`);
  }
}

const target = { _prop: 'foo' };
const proxy = new Proxy(target, handler);
delete proxy._prop
// Error: 无法删除私有属性
console.log(proxy); // Proxy {_prop: 'foo'}

receiver的作用

案例

const obj = {
  _name: "hello",
  get name() {
    return this._name;
  },
  set name(newValue) {
    this._name = newValue;
  },
};

const objProxy = new Proxy(obj, {
  get: function (target, key, receiver) {
    console.log("get方法被访问-----", key, receiver);
    console.log(receiver === objProxy); // true
    return Reflect.get(target, key, receiver);
  },
  set: function (target, key, value, receiver) {
    console.log("set方法被访问-----", key);
    Reflect.set(target, key, value, receiver);
  },
});

console.log(objProxy.name);
objProxy.name = "world";
const parent = {
  get value() {
    return "hello";
  },
};
const proxy = new Proxy(parent, {
  get(target, key, receiver) {
    console.log(receiver === proxy); // log: false
    console.log(receiver === child); // log:true
    return target[key];
  },
});
const child = {
  name: "world",
};
// 设置child继承于parent的代理对象proxy
Object.setPrototypeOf(child, proxy);

child.value; // hello

Proxy中接受的receiver形参表示代理对象本身或者继承于代理对象的对象,本质上来说它还是为了确保陷阱函数中调用者的正确的上下文访问
Reflect中传递的receiver实参表示修改执行原始操作时的this指向
若需要在Proxy内部调用对象的默认行为,建议使用Reflect,能在触发代理对象的劫持时保证正确的this上下文指向。

面试题

如何让if里面的代码执行,成功在控制台打印出Win?

if (a == 1 && a == 2 && a == 3) {
  console.log('Win')
}

方案一:重写toString / valueOf方法

const a = {
  _a: 0,
  toString: function() {
    return ++a._a
  },
  // valueOf: function() {
  //   return ++a._a
  // },
}

因为toStringObject.prototype上面默认的方法,所以这个办法相当于把正常的隐式转换中toString方法给拦截了。

涉及原型和原型链的知识点

方案二:数组

let a = [1,2,3];
a.toString = a.shift;

方案三:Proxy

let a = new Proxy({}, {
  i: 1,
  get: function() {
    return () => this.i++;
  }
});

现在将题目简单修改一下,将双等变成三个等怎么办?
大家都知道===的话是先判断类型,再判断值。这里的toString已经默认把对象转化为字符串了,使用toString的话,结果就不成立了。

方案四:defineProperties

Object.defineProperties(window, {
  _a: {
    value: 0,
    writable: true
  },
  a: {
    get: function() {
      return  ++_a
    }
  }
})

Vue 3.x 响应式

let currentFn = null;
let dependenciesMap = new WeakMap();

function watchFn(fn) {
  currentFn = fn;
  fn();
  currentFn = null;
}

class Dependency {
  constructor() {
    this.dependencies = new Set();
  }

  depend() {
    if (currentFn) this.dependencies.add(currentFn);
  }

  run() {
    this.dependencies.forEach(fn => fn());
  }
}

function getDependency(target, key) {
  let map = dependenciesMap.get(target);
  if (!map) dependenciesMap.set(target, (map = new Map()));
  let dependency = map.get(key);
  if (!dependency) map.set(key, (dependency = new Dependency()));
  return dependency;
}

function reactive(obj) {
  return new Proxy(obj, {
    get: function (target, key, receiver) {
      console.log(target, `${key}被访问`);
      const dependency = getDependency(target, key);
      dependency.depend();
      return Reflect.get(target, key, receiver);
    },
    set: function (target, key, newValue, receiver) {
      console.log(target, `${key}被设置为${newValue}`);
      Reflect.set(target, key, newValue, receiver);
      const dependency = getDependency(target, key);
      dependency.run();
    },
    has: function (target, key) {
      console.log("正在查询对象", target, `上是否存在${key}属性`);
      return Reflect.has(target, key);
    },
  });
}

const foo = reactive({
  name: "foo",
  age: 18,
});

// console.log(foo.name);
// console.log(foo.age);
// foo.name = "bar";
// foo.age = 20;
// console.log(foo);

// console.log("dd" in foo);

watchFn(function () {
  console.log(foo.name);
  console.log(foo.age);
});
watchFn(function () {
  console.log(foo.name, "1111");
});

foo.name = "bar";
console.log(foo.name);

参考资料

  1. coderwhy
  2. 都快2023年了!赶快来熟悉一下ES6的Proxy用法吧
  3. 经典面试题:让 a == 1 && a== 2 && a == 3 成立
  4. 渡一教育 WEB前端大师课
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值