Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

你可能忽视的ES6语法——反射和代理 #17

Open
xingbofeng opened this issue Aug 21, 2017 · 0 comments
Open

你可能忽视的ES6语法——反射和代理 #17

xingbofeng opened this issue Aug 21, 2017 · 0 comments

Comments

@xingbofeng
Copy link
Owner

xingbofeng commented Aug 21, 2017

最近在看新出的Nicholas的大作《深入理解ES6》时,发现有一些语法还是平时基本没用到,但是也是写在了最新的ECMAScript标准中的语法,所以想用这篇文章记录一下这些可能被我们忽略的ES6语法。

反射(Reflect)和代理(Proxy)

ECMAScript6添加了一些内建对象,赋予开发者更多访问JavaScript引擎的能力。代理(Proxy)是一种可以拦截并改变底层JavaScript引擎操作的包装器,在新语言中通过它暴露内部运作的对象,从而让开发者可以创建内建的对象。

代理可以拦截JavaScript引擎内部目标的底层对象操作,这些底层操作被拦截后会触发相应特定操作的陷阱函数

反射API以Reflect对象的形式出现,对象中方法的默认特性与相同的底层操作一致,而代理可以覆写这些操作,每个代理陷阱对应一个命名和参数都相同的Reflect方法。

代理陷阱 覆写的特性 默认特性
get 读取一个属性值 Reflect.get()
set 写入一个属性 Reflect.set()
has in操作符 Reflect.has()
delectProperty delect操作符 Reflect.delectProperty()
getPrototypeOf Object.getPrototypeOf() Reflect.getPrototypeOf()
setPrototypeOf Object.setPrototypeOf() Reflect.setPrototypeOf()
isExtensible Object.isExtensible() Reflect.isExtensible()
preventExtensions Object.preventExtensions() Reflect.preventExtensions()
getOwnPropertyDescriptor Object.getOwnPropertyDescriptor() Reflect.getOwnPropertyDescriptor()
defineProperty Object.defineProperty() Reflect.defineProperty()
ownKeys Object.keys()、Object.getOwnPropertyNames()、Object.getOwnPropertySymbols() Reflect.ownKeys()
apply 调用一个函数 Reflect.apply()
construct 用new调用一个构造函数 Reflect.construct()

每个陷阱覆写JavaScript对象的一些内建特性,可以用它们拦截并修改这些特性。如果仍需使用内建特性,则可以使用相应的反射API方法。创建代理会让代理和反射API的关系变得清楚。

创建一个简单代理

let target = {};
let proxy = new Proxy(target, {});

proxy.name = 'proxy';
console.log(proxy.name); // proxy
console.log(target.name); // proxy

target.name = 'target';

console.log(proxy.name); // target
console.log(target.name); // target

这个示例中的代理将所有操作直接转发到目标。由于proxy.nametarget.name引用的都是target.name,因此二者的值相同,从而为target.name设置新值后,proxy.name也一同变化了。

使用set陷阱验证属性(代理模式之保护代理)

假设要创建一个属性值是数字的对象,对象中每次新增一个属性都要加以验证,如果不是数字必须抛出错误。为了实现这个任务,可以定义一个set陷阱来覆写设置值的默认特性。set陷阱接受四个参数:

  • trapTarget 用于接受属性(代理的目标)的对象
  • key 要写入的属性键(字符串或Symbol类型)
  • value 被写入属性的值
  • receiver 操作发生的对象(通常是代理)
let target = {
  name: 'target',
};

let proxy = new Proxy(target, {
  set(trapTarget, key, value, receiver) {
    // 忽略不希望受到影响的已有属性
    if (!trapTarget.hasOwnProperty(key)) {
      if (isNaN(value)) {
        throw new TypeError('属性必须是数字');
      }
    }
    // 添加属性
    return Reflect.set(trapTarget, key, value, receiver);
  }
});

// 添加一个新属性,值为数字
proxy.count = 1;
console.log(proxy.count);
console.log(target.count);

// 由于目标已有name属性因此可以给它赋值
proxy.name = 'proxy';
console.log(proxy.name);
console.log(target.name);

// 给不存在的属性赋值会抛出错误
proxy.anthorName = 'proxy';

这段代码定义了一个代理来验证添加到target的新属性,当执行proxy.count = 1时,set陷阱被调用,此时trapTarget的值等于targetkey等于countvalue等于1receiver等于proxy。由于target上没有count属性,因此代理继续将value值传入isNaN(),如果结果是true,则证明传入的属性值不是数字,同时也抛出一个错误。在这段代码中,count被设置为1,所以代理调用Reflect.set()方法并传入陷阱接受的4个参数来添加新属性。

proxy.name可以成功被赋值为一个字符串,这是因为target已经拥有一个name属性,但通过调用trapTarget.hasOwnProperty()方法验证检查后被排除了,所以目标已有的非数字属性仍然可以被操作。

然而,将proxy.anotherName赋值为一个字符串时会抛出错误。目标上没有anotherName属性,所以它的值需要被验证,而由于proxy不是一个数字值,因此抛出错误。

用get陷阱验证对象结构

JavaScript有一个时常令人感到困惑的特殊行为,即读取不存在的属性时不会抛出错误,而是用undefined代替被读取属性的值。

let target = {};
console.log(target.name); // undefined

在大多数其他语言中,如果target没有name属性,尝试读取target.name会抛出一个错误。但JavaScript却用undefined来代替target.name属性的值。这个特性会导致重大问题,特别是当错误输入属性名称的时候,而代理可以通过检查对象结构来回避这个问题

对象结构是指对象中所有可用属性和方法的集合,JavaScript引擎通过对象结构来优化代码,通常会创建类来表示对象,如果可以安全地假定一个对象将始终具有相同的属性和方法,那么当程序试图访问不存在的属性时会抛出错误。代理让对象结构检验变得简单。

因为只有当读取属性时才会检验属性,所以无论对象中是否存在某个属性,都可以通过get陷阱来检测,它接受3个参数。

  • trapTarget 被读取属性的源对象(代理的目标)
  • key 要读取的属性键(字符串或Symbol)
  • receiver 操作发生的对象(通常是代理)
let proxy = new Proxy({}, {
  get(trapTarget, key, receiver) {
    if (!(key in receiver)) {
      throw new TypeError(`属性 ${key} 不存在`);
    }
    // 一旦报错就不会执行底层操作
    return Reflect.get(trapTarget, key, receiver);
  }
});
// 添加属性的功能正常
proxy.name = 'proxy';
console.log(proxy.name); // 'proxy'
// 读取不存在属性会抛出错误
console.log(proxy.nme); // 抛出错误

此示例中的get陷阱可以拦截属性读取操作,并通过in操作符来判断receiver上是否具有被读取的属性,这里之所以用in操作符检查receiver而不检查trapTarget,是为了防止receiver代理含有has陷阱。在这种情况下检查trapTarget可能会忽略掉has陷阱,从而得到错误结果。属性如果不存在会抛出一个错误,否则就使用默认行为。

这段代码展示了如何在没有错误的情况下给proxy添加新属性name,并写入值和读取值。最后一行包含一个输入错误:proxy.nme,由于nme是一个不存在的属性,因而抛出错误。

使用has陷阱隐藏已有属性

可以用in操作符来检测给定对象中是否含有某个属性,如果自有属性或原型属性匹配这个名称或Symbol就返回true

let target = {
  value: 42,
};

console.log('value' in target); // true
console.log('toString' in target); // true

value是一个自有属性,toString是一个继承自Object的原型属性,二者在对象上都存在,所以用in操作符检测二者都返回true。在代理中使用has陷阱可以拦截这些in操作并返回一个不同的值

每当使用in操作符时都会调用has陷阱,并传入两个参数。

  • trapTaqget 读取属性的对象(代理的目标)
  • key 要检查的属性键(字符串或Symbol)

Reflect.has()方法也接受这些参数并返回in操作符的默认响应,同时使用has陷阱Reflect.has()可以改变一部分属性被in检测时的行为,并恢复另外一些属性的默认行为。例如,可以像这样隐藏之前示例中的value属性:

let target = {
  name: 'target',
  value: 42,
};


let proxy = new Proxy(target, {
  has(trapTarget, key) {
    // 拦截in操作符,隐藏了value属性
    if (key === 'value') {
      return false;
    } else {
      return Reflect.has(trapTarget, key);
    }
  }
});


console.log('value' in proxy); // false
console.log('name' in proxy); // true
console.log('toString' in proxy); // true

代理中的has陷阱会检查key是否为'value',如果是的话返回false,若不是则调用Reflect.has()方法返回默认行为。结果是即使target上实际存在value属性,但用in操作符检查还是会返回false,而对于nametoString则正确返回true

用delectProperty陷阱防止删除属性

delect操作符可以从对象中移除属性,如果成功则返回true,不成功则返回false。在严格模式下,如果尝试删除一个不可配置(configurablefalse)属性则会导致程序抛出错误,而在非严格模式下只是返回false

let target = {
  name: 'target',
  value: 42,
};

Object.defineProperty(target, 'name', {
  configurable: false
});

console.log('value' in target); // true

let result1 = delete target.value;

console.log(result1); // true
console.log('value' in target); // false

// 注意:下一行代码在严格模式下会抛出错误
let result2 = delete target.name;

console.log(result2); // false
console.log('name' in target); // true

delete操作符删除value属性后,第三个console.log()调用中的in操作最终返回false。不可配置属性name无法被删除,所以delete操作返回false(如果这段代码运行在严格模式下会抛出错误)。在代理中,可以通过deleteProperty陷阱来改变这个行为。

每当通过delete操作符删除对象属性时,deleteProperty陷阱都会被调用,它接受两个参数:

  • trapTarget 要删除属性的对象(代理的目标)
  • key 要删除的属性键(字符串或Symbol)

Reflect.deleteProperty()方法为deleteProperty陷阱提供默认实现,并且接受同样的两个参数。结合二者可以改变delete的具体表现行为,例如,可以像这样来确保value属性不会被删除。

let target = {
  name: 'target',
  value: 42
};

let proxy = new Proxy(target, {
  deleteProperty(trapTarget, key) {
    // 拦截delect操作符,不允许删除value属性
    if (key === 'value') {
      return false;
    } else {
      return Reflect.deleteProperty(trapTarget, key);
    }
  }
});

// 尝试删除 proxy.value
console.log('value' in proxy); // true
let result1 = delete proxy.value;
console.log(result1); // false
console.log('value' in proxy); // true


// 尝试删除 proxy.name
console.log('name' in proxy); // true
let result2 = delete proxy.name;
console.log(result2); // true
console.log('name' in proxy); // false

函数代理中的apply和construct陷阱

所有代理陷阱中,只有applyconstruct的代理目标是一个函数。函数有两个内部方法[[Call]][[Construct]]apply陷阱和construct陷阱可以覆写这些内部方法。若使用new操作符调用函数,则执行[[Construct]]方法;若不用,则执行[[Construct]]方法,此时会执行apply陷阱,它和Reflect.apply()都接受以下参数:

Reflect.apply()apply陷阱支持下面这些参数:

  • trapTaqget 被执行的函数(代理的目标)
  • thisArg 函数被调用时内部this的值
  • argumentsList 传递给函数的参数数组

而使用new调用函数时调用的construct陷阱和Reflect.construct()则接受以下参数:

  • trapTarget 被执行的函数(代理的目标)
  • argumentsList 传递给函数的参数数组

有了applyconstruct陷阱,可以完全控制任何代理目标函数的行为。

let target = function() {
  return 42;
};

let proxy = new Proxy(target, {
  apply: function(trapTarget, thisArg, argumentList) {
    return Reflect.apply(trapTarget, thisArg, argumentList);
  },
  construct: function(trapTarget, argumentList) {
    return Reflect.construct(trapTarget, argumentList);
  }
});

// 使用了函数的代理,其目标对象会被视为函数
console.log(typeof proxy); // 'function'
console.log(proxy()); // 42
var instance = new proxy();
console.log(instance instanceof proxy); // true
console.log(instance instanceof target); // true

在这里,有一个返回数字42的函数,该函数的代理分别使用apply陷阱和construct陷阱来将那些行为委托给Reflect.apply()方法和Reflect.construct()方法。最终结果是代理函数与目标函数完全相同,包括在使用typeof时将自己标识为函数。不用new调用代理时返回42,用new调用时创建一个instance对象,它同时是代理和目标的实例,因为instanceof通过原型链来确定此信息,而原型链查找不受代理影响,这也就是代理和目标好像有相同原型的原因。

验证函数参数

apply陷阱和construct陷阱增加了一些可能改变函数执行方式的可能性,例如,假设验证所有参数都属于特定类型,则可以在apply陷阱中检查参数:

// 将所有参数相加
function sum(...values) {
  return values.reduce((previous, current) => previous + current, 0);
};

let sumProxy = new Proxy(sum, {
  apply: function(trapTarget, thisArg, argumentList) {
    argumentList.forEach((arg) => {
      if (typeof arg !== 'number') {
        throw new TypeError('所有参数都必须是Number类型!');
      }
    });
    return Reflect.apply(trapTarget, thisArg, argumentList);
  },
  construct: function(trapTarget, argumentList) {
    throw new TypeError('该函数不能通过new来调用!');
  }
});

console.log(sumProxy(1, 2, 3, 4)); // 10
// 抛出错误
console.log(sumProxy(1, '2', 3, 4));
// 同样抛出错误
let result = new sumProxy();

不用new调用构造函数

new.target元属性,是用new调用函数时对该函数的引用,所以可以通过检查new.target的值来确定函数是否是通过new来调用的。

function Numbers(...values) {
  if (typeof new.target === 'undefined') {
    throw new TypeError('该函数必须通过new来调用!');
  }
  this.values = values;
};

let instance = new Numbers(1, 2, 3, 4);
console.log(instance.values); // [1, 2, 3, 4]

// 抛出错误
Numbers(1, 2, 3, 4);

在这段代码中,不用new调用Numbers()会抛出一个错误。如果目标是防止用new调用函数,则这样编写代码比使用代理简单得多。但有时不能控制要修改行为的函数,在这种情况下,使用代理才有意义。

但是假设Numbers()函数定义在无法修改的代码中(如看不到的源码),知道代码依赖new.target,希望函数避免检查却仍想调用函数。在这种情况下,用new调用时的行为已被设定,所以只能使用apply陷阱了。

function Numbers(...values) {
  if (typeof new.target === 'undefined') {
    throw new TypeError('该函数必须通过new来调用!');
  }
  this.values = values;
};

let NumbersProxy = new Proxy(Numbers, {
  apply: function(trapTarget, thisArg, argumentsList) {
    return Reflect.construct(trapTarget, argumentsList);
  }
});

let instance = NumbersProxy(1, 2, 3, 4);
console.log(instance.values); // [1, 2, 3, 4]

apply陷阱用传入的参数调用Reflect.construct(),就可以让Numbersproxy()函数无须使用new就能实现用new调用Numbers()的行为。Numbers()内部的new.target等于Numbers(),所以不会有错误抛出。尽管这个修改new.target的示例非常简单,但这样做显得更加直接。

覆写抽象基类构造函数

进一步修改new.target,可以将第三个参数指定为Reflect.construct()作为赋值给new.target的特定值。这项技术在函数根据已知值检查new.target时很有用,例如创建抽象基类构造函数。在一个抽象基类构造函数中,new.target理应不同于类的构造函数,就像在这个示例中:

class AbstractNumbers {
  constructor(...values) {
    // 判断new的调用函数是否是自身,如果是,则抛出错误
    if (new.target === AbstractNumbers) {
      throw new TypeError('此函数必须被继承');
    }
    this.values = values;
  }
}

class Numbers extends AbstractNumbers {}

let instance = new Numbers(1, 2, 3, 4);
console.log(instance.values); // [1, 2, 3, 4]

// 抛出错误
new AbstractNumbers(1, 2, 3, 4);

AbstractNumbersProxy使用construct陷阱来拦截对new AbstractNumbersProxy()方法的调用。然后传入陷阱的参数来调用Reflect.construct()方法,并添加一个空函数作为第三个参数。这个空函数被用作构造函数内部new.target的值。由于new.target不等于AbstractNumbers,因此不会抛出错误,构造函数可以完全执行。

可调用的类构造函数

必须用new来调用类构造函数,因为类构造函数的内部方法[[Call]]被指定来抛出一个错误。但是代理可以拦截对[[Call]]方法的调用,这意味着可以通过使用代理来有效地创建可调用类构造函数。例如,如果希望类构造函数不用new就可以运行,那么可以使用apply陷阱来创建一个新实例。

class Person {
  constructor(name) {
    this.name = name;
  }
}

let PersonProxy = new Proxy(Person, {
  apply: function(trapTarget, thisArg, argumentList) {
    return new trapTarget(...argumentList);
  }
});

let me = PersonProxy('Nicholas');
console.log(me.name); // 'Nicholas'
console.log(me instanceof Person); // true
console.log(me instanceof PersonProxy); // true

可撤销代理

通常,在创建代理后,代理不能脱离其目标。但是可能存在希望撤销代理的情况,然后代理便失去效力。无论是出于安全目的通过API提供一个对象,还是在任意时间点切断访问,撤销代理都非常有用

可以使用proxy.revocable()方法创建可撤销的代理,该方法采用与Proxy构造函数相同的参数:目标对象和代理处理程序,返回值是具有以下属性的对象

  • proxy 可被撤销的代理对象
  • revoke 撤销代理要调用的函数
let target = {
  name: 'target',
};

// 创建一个可被撤销的代理
let { proxy, revoke } = Proxy.revocable(target, {});

console.log(proxy.name); // 'target'
revoke();
// 抛出错误
console.log(proxy.name);

此示例创建一个可撤销代理,它使用解构功能将proxyrevoke变量赋值给Proxy.revocable()方法返回的对象上的同名属性。之后,proxy对象可以像不可撤销代理对象一样使用。因此proxy.name返回'target',因为它直接透传了target.name的值。然而,一旦revoke()函数被调用,代理不再是函数,尝试访问proxy.name会抛出一个错误,正如任何会触发代理上陷阱的其他操作一样。

在业务中,可以这样炫技了

/**
 * 验证用户登录态
 * @param  {String} code 登录返回的状态码
 */
const validateLogin = (code) => {
  if (code === 603) {
    document.getElementsByClassName('login-text')[0].innerText = '此帐号已在其它地方登录,请重新扫码登录';
    document.getElementsByClassName('TimeoutDialog')[0].style.display = 'block';
  } else if (code >= 600 && code <= 602) {
    document.getElementsByClassName('login-text')[0].innerText = '登录超时,请重新扫码登录';
    document.getElementsByClassName('TimeoutDialog')[0].style.display = 'block';
  }
};

function setProxy(func) {
  return new Proxy(func, {
    apply: function (trapTarget, thisArg, argumentList) {
      // argumentList[0]是Promise.then的第一个参数,为一个函数
      if (argumentList[0]) {
        // 在这个函数调用时,拿到函数参数,即response,之后去校验登录态
        argumentList[0] = new Proxy(argumentList[0], {
          apply: function(trapTarget1, thisArg1, argumentList1) {
            // 只要能取到code,则证明是走业务逻辑的Promise
            if (argumentList1[0] && argumentList1[0].data && argumentList1[0].data.code !== void 0) {
              // 如果有code,则校验登录态
              validateLogin(argumentList1[0].data.code);
            }
            // 否则直接调用它
            return Reflect.apply(trapTarget1, thisArg1, argumentList1);
          }
        });
      }
      return Reflect.apply(trapTarget, thisArg, argumentList);
    }
  });
};

这样我们悄无声息地在Promise.then的参数函数中,校验了用户登录态。

@xingbofeng xingbofeng changed the title 你可能忽视的ES6语法 你可能忽视的ES6语法——反射和代理 Aug 21, 2017
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant