专栏名称: 全栈前端精选
内容为王,精选为则。从前端到全栈,定期分享前端、客户端、Node、面试、职场感悟等相关高质量文章。小白的大神养成记,你我共勉!
目录
相关文章推荐
51好读  ›  专栏  ›  全栈前端精选

你不知道的 Proxy:ES6 Proxy 可以做哪些有意思的事情?

全栈前端精选  · 公众号  ·  · 2024-06-14 11:37

正文

请到「今天看啥」查看全文



只听空相大声道:“请道长立即禀报张真人,事在紧急,片刻延缓不得!”那道人道:“大师来得不巧,敝师祖自去岁坐关,至今一年有余,本派弟子亦已久不见他老人家慈范。”


前言

在武侠小说中,经常看到这样的桥段。某位武林人士前来拜访德高望重的帮派掌门,往往需要经过手下弟子的通报。如果掌门外出或者不想见来人,就会让弟子婉拒。

今天要讲的 Proxy 和这个有异曲同工之妙。顾名思义,Proxy 的意思是代理,作用是为其他对象提供一种代理以控制对这个对象的访问。

本文会涉及到 Proxy 和 Reflect、Function、扩展运算符 等知识,主要以实践为主,对语法不会进行详细地讲解,建议配合阮一峰的 ES6入门 中相关章节服用。


1. Proxy 提供了哪些拦截方式?

Proxy 一般是用来架设在目标对象之上的一层拦截,来实现对目标对象访问和修改的控制。Proxy 是一个构造函数,使用的时候需要配合 new 操作符,直接调用会报错。


Proxy 构造函数接收两个参数,第一个参数是需要拦截的目标对象,这个对象只可以是对象、数组或者函数;

第二个参数则是一个配置对象,提供了拦截方法,即使这个配置对象为空对象,返回的 Proxy 实例也不是原来的目标对象。

const person = {
    name'tom'
}
// 如果第二个参数为空对象
const proxy = new Proxy(person, {});
proxy === person; // false

// 第二个参数不为空
const proxy = new Proxy(person, {
    get(target, prop) {
        console.log(`${prop} is ${target[prop]}`);
        return target[prop];
    }
})
proxy.name // 'name is tom'

Proxy 支持13种拦截操作,本文将会重点介绍其中四种。


  1. get(target, prop, receiver) :拦截对象属性的访问。

  2. set(target, prop, value, receiver) :拦截对象属性的设置,最后返回一个布尔值。

  3. apply(target, object, args) :用于拦截函数的调用,比如 proxy()

  4. construct(target, args) :方法用于拦截 new 操作符,比如 new proxy() 。为了使 new操作符在生成的Proxy对象上生效,用于初始化代理的目标对象自身必须具有 [[Construct]] 内部方法(即 new target 必须是有效的)。

  5. has(target, prop) :拦截例如 prop in proxy的操作,返回一个布尔值。

  6. deleteProperty(target, prop) :拦截例如 delete proxy[prop] 的操作,返回一个布尔值。

  7. ownKeys(target) :拦截 Object.getOwnPropertyNames(proxy) Object.keys(proxy) for in 循环等等操作,最终会返回一个数组。

  8. getOwnPropertyDescriptor(target, prop) :拦截 Object.getOwnPropertyDescriptor(proxy, propKey) ,返回属性的描述对象。

  9. defineProperty(target, propKey, propDesc) :拦截 Object.defineProperty(proxy, propKey, propDesc )、 Object.defineProperties(proxy, propDescs) ,返回一个布尔值。

  10. preventExtensions(target) :拦截 Object.preventExtensions(proxy) ,返回一个布尔值。

  11. getPrototypeOf(target) :拦截 Object.getPrototypeOf(proxy) ,返回一个对象。

  12. isExtensible(target) :拦截 Object.isExtensible(proxy) ,返回一个布尔值。

  13. setPrototypeOf(target, proto) :拦截 Object.setPrototypeOf(proxy, proto) ,返回一个布尔值。如果目标对象是函数,那么还有两种额外操作可以拦截。

2. Proxy vs Object.defineProperty

在 Proxy 出现之前,JavaScript 中就提供过 Object.defineProperty ,允许对对象的 getter/setter 进行拦截,那么两者的区别在哪里呢?


2.1  Object.defineProperty 不能监听所有属性

Object.defineProperty 无法一次性监听对象所有属性,必须遍历或者递归来实现。

   let girl = {
     name"marry",
     age22
   }
   /* Proxy 监听整个对象*/
   girl = new Proxy(girl, {
     get() {}
     set() {}
   })
   /* Object.defineProperty */
   Object.keys(girl).forEach(key => {
     Object.defineProperty(girl, key, {
       set() {},
       get() {}
     })
   })

2.2 Object.defineProperty 无法监听新增加的属性

Proxy 可以监听到新增加的属性,而 Object.defineProperty 不可以,需要你手动再去做一次监听。因此,在 Vue 中想动态监听属性,一般用 Vue.set(girl, "hobby", "game") 这种形式来添加。

   let girl = {
     name"marry",
     age22
   }
   /* Proxy 监听整个对象*/
   girl = new Proxy(girl, {
     get() {}
     set() {}
   })
   /* Object.defineProperty */
   Object.keys(girl).forEach(key => {
     Object.defineProperty(girl, key, {
       set() {},
       get() {}
     })
   });
   /* Proxy 生效,Object.defineProperty 不生效 */
   girl.hobby = "game"

2.3. Object.defineProperty 无法响应数组操作

Object.defineProperty 可以监听数组的变化, Object.defineProperty 无法对 push shift pop unshift 等方法进行响应。

   const arr = [123];
   /* Proxy 监听数组*/
   arr = new Proxy(arr, {
     get() {},
     set() {}
   })
   /* Object.defineProperty */
   arr.forEach((item, index) => {
     Object.defineProperty(arr, `${index}`, {
       set() {},
       get() {}
     })
   })

   arr[0] = 10// 都生效
   arr[3] = 10// 只有 Proxy 生效
   arr.push(10); // 只有 Proxy 生效

对于新增加的数组项, Object.defineProperty 依旧无法监听到。因此,在 Mobx 中为了监听数组的变化,默认将数组长度设置为1000,监听 0-999 的属性变化。

   /* mobx 的实现 */
   const arr = [123];
   /* Object.defineProperty */
   [...Array(1000)].forEach((item, index) => {
     Object.defineProperty(arr, `${index}`, {
       set() {},
       get() {}
     })
   });
   arr[3] = 10// 生效
   arr[4] = 10// 生效

如果想要监听到 push shift pop unshift 等方法,该怎么做呢?在 Vue 和 Mobx 中都是通过重写原型实现的。

在定义变量的时候,判断其是否为数组,如果是数组,那么就修改它的 __proto__ ,将其指向 subArrProto ,从而实现重写原型链。

   const arrayProto = Array.prototype;
   const subArrProto = Object.create(arrayProto);
   const methods = ['pop''shift''unshift''sort''reverse''splice''push'];
   methods.forEach(method => {
     /* 重写原型方法 */
     subArrProto[method] = function({
       arrayProto[method].call(this, ...arguments);
     };
     /* 监听这些方法 */
     Object.defineProperty(subArrProto, method, {
       set() {},
       get() {}
     })
   })

2.4 Proxy 拦截方式更多

Proxy 提供了13种拦截方法,包括拦截 constructor apply deleteProperty 等等,而 Object.defineProperty 只有 get set

2.5. Object.defineProperty 兼容性更好

Proxy 是新出的 API,兼容性还不够好,不支持 IE 全系列。

3. 语法

3.1 get

get 方法用来拦截对目标对象属性的读取,它接收三个参数,分别是目标对象、属性名和 Proxy 实例本身。
基于 get 方法的特性,可以实现很多实用的功能,比如在对象里面设置私有属性(一般定义属性我们以 _ 开头表明是私有属性) ,实现禁止访问私有属性的功能。

const person = {
    name'tom',
    age20,
    _sex'male'
}
const proxy = new Proxy(person, {
    get(target, prop) {
        if (prop[0] === '_') {
            throw new Error(`${prop} is private attribute`);
        }
        return target[prop]
    }
})
proxy.name; // 'tom'
proxy._sex; // _sex is private attribute

还可以给对象中未定义的属性设置默认值。通过拦截对属性的访问,如果是 undefined ,那就返回最开始设置的默认值。

let person = {
    name'tom',
    age20
}
const defaults = (obj, initial) => {
    return new Proxy(obj, {
        get(target, prop) {
            if (prop in target) {
                return target[prop]
            }
            return initial
        }
    })
}
person = defaults(person, 0);
person.name // 'tom'
person.sex // 0
person = defaults(person, null);
person.sex // null

3.2 set

set 方法可以拦截对属性的赋值操作,一般来说接收四个参数,分别是目标对象、属性名、属性值、Proxy 实例。
下面是一个 set 方法的用法,在对属性进行赋值的时候打印出当前状态。

const proxy = new Proxy({}, {
    set(target, key, value, receiver) {
        console.log(`${key} has been set to ${value}`);
        Reflect.set(target, key, value);
    }
})
proxy.name = 'tom'// name has been setted ygy

第四个参数 receiver 则是指当前的 Proxy 实例,在下例中指代 proxy

const proxy = new Proxy({}, {
    set(target, key, value, receiver) {
        if (key === 'self') {
            Reflect.set(target, key, receiver);
        } else {
            Reflect.set(target, key, value);
        }
    }
})
proxy.self === proxy; // true

如果你写过表单验证,也许会被各种验证规则搞得很头疼。使用 Proxy 可以在填写表单的时候,拦截其中的字段进行格式校验。
通常来说,大家都会用一个对象来保存验证规则,这样会更容易对规则进行扩展。

// 验证规则
const validators = {
    name: {
        validate(value) {
            return value.length > 6;
        },
        message'用户名长度不能小于六'
    },
    password: {
        validate(value) {
            return value.length > 10;
        },
        message'密码长度不能小于十'
    },
    moblie: {
        validate(value) {
            return /^1(3|5|7|8|9)[0-9]{9}$/.test(value);
        },
        message'手机号格式错误'
    }
}

然后编写验证方法,用 set 方法对 form 表单对象设置属性进行拦截,拦截的时候用上面的验证规则对属性值进行校验,如果校验失败,则弹窗提示。

// 验证方法
function validator(obj, validators{
    return new Proxy(obj, {
        set(target, key, value) {
            const validator = validators[key]
            if (!validator) {
                target[key] = value;
            } else if (validator.validate(value)) {
                target[key] = value;
            } else {
                alert(validator.message || "");
            }
        }
    })
}
let form = {};
form = validator(form, validators);
form.name = '666'// 用户名长度不能小于六
form.password = '113123123123123';

但是,如果这个属性已经设置为不可写,那么 set 将不会生效(但 set 方法依然会执行)。

const person = {
    name'tom'
}
Object.defineProperty(person, 'name', {
    writablefalse
})
const proxy = new Proxy(person, {  
    set(target, key, value) {
        console.log(666 )
        target[key] = 'jerry'
    }
})
proxy.name = '';

3.3. apply

apply 一般是用来拦截函数的调用,它接收三个参数,分别是目标对象、上下文对象(this)、参数数组。

function test({
    console.log('this is a test function');
}
const func = new Proxy(test, {
    apply(target, context, args) {
        console.log('hello, world');
        target.apply(context, args);
    }
})
func();

通过 apply 方法可以获取到函数的执行次数,也可以打印出函数执行消耗的时间,常常可以用来做性能分析。

function log({}
const func = new Proxy(log, {
    _count0,
    apply(target, context, args) {
        target.apply(context, args);
        console.log(`this function has been called ${++this._count} times`);
    }
})
func()

3.4. construct

construct 方法用来拦截 new 操作符。它接收三个参数,分别是目标对象、构造函数的参数列表、Proxy 对象,最后需要返回一个对象。
使用方式可以参考下面这么一个例子:

function Person(name, age{
    this.name = name;
    this.age = age;
}
const P = new Proxy(Person, {
    construct(target, args, newTarget) {
        console.log('construct');
        return new target(...args);
    }
})
const p = new P('tom'21); // 'construct'

我们知道,如果构造函数没有返回任何值或者返回了原始类型的值,那么默认返回的就是 this ,如果返回的是一个引用类型的值,那么最终 new 出来的就是这个值。
因此,你可以代理一个空函数,然后返回一个新的对象。

function noop({}
const Person = new Proxy(noop, {
    construct(target, args, newTarget) {
        return {
            name: args[0],
            age: args[1]
        }
    }
})
const person = new Person('tom'21); // { name: 'tom', age: 21 }

4. Proxy 可以做哪些有意思的事情?

Proxy 的使用场景非常广泛,可以用来拦截对象的 set/get 从而实现数据响应。在 Vue3 和 Mobx5 中都使用了 Proxy 代替 Object.defineProperty 。那么接下来就来看看 Proxy 都可以做哪些事情吧。

4.1 骚操作:代理类

使用 construct 可以代理类,你可能会好奇,Proxy 不是只能代理 Object 类型吗?类该怎么代理呢?


其实类的本质也是构造函数和原型(对象)组成的,完全可以对其进行代理。
考虑有这么一个需求,需要拦截对属性的访问,以及计算原型上函数的执行时间,这样该怎么去做就比较清晰了。可以对属性设置 get 拦截,对原型函数设置 apply 拦截。

先考虑对下面的 Person 类的原型函数进行拦截。使用 Object.getOwnPropertyNames 来获取原型上面所有的函数,遍历这些函数并对其使用 apply 拦截。

class Person {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }
  say() {
    console.log(`my name is ${this.name}, and my age is ${this.age}`)
  }
}
const prototype = Person.prototype;
// 获取 prototype 上所有的属性名
Object.getOwnPropertyNames(prototype).forEach((name) => {
    Person.prototype[name] = new Proxy(prototype[name], {
        apply(target, context, args) {
            console.time();
            target.apply(context, args);
            console.timeEnd();
        }
    })
 })

拦截了原型函数后,开始考虑拦截对属性的访问。前面刚刚讲过 construct 方法的作用,那么是不是可以在 new 的时候对所有属性的访问设置拦截呢?
没错,由于 new 出来的实例也是个对象,那么完全可以对这个对象进行拦截。

new Proxy(Person, {
    // 拦截 construct 方法
    construct(target, args) {
        const obj = new target(...args);
        // 返回一个代理过的对象
        return new Proxy(obj, {
            get(target, prop) {
                  console.log(`${target.name}.${prop} is being getting`);
                  return target[prop]
            }
        })
    }
})       

所以,最后完整的代码如下:

class Person {
  constructor(name, age) {
    this.name = name;
    this.age = age;
  }
  say() {
    console.log(`my name is ${this.name}, and my age is ${this.age}`)
  }
}
const proxyTrack = (targetClass) => {
  const prototype = targetClass.prototype;
  Object.getOwnPropertyNames(prototype).forEach((name) => {
        targetClass.prototype[name] = new Proxy(prototype[name], {
            apply(target, context, args) {
                console.time();
                target.apply(context, args);
                console.timeEnd();
            }
        })
  })

  return new Proxy(targetClass, {
    construct(target, args) {
      const obj = new target(...args);
      return new Proxy(obj, {
        get(target, prop) {
              console.log(`${target.name}.${prop} is being getting`);
              return target[prop]
        }
      })
    }
  })       
}

const MyClass = proxyTrack(Person);
const myClass = new MyClass('tom'21);
myClass.say();
myClass.name;

4.2 等不及可选链:深层取值(get)

平时取数据的时候,经常会遇到深层数据结构,如果不做任何处理,很容易造成 JS 报错。
为了避免这个问题,也许你会用多个 && 进行处理:

const country = {
    name: 'china' ,
    province: {
        name: 'guangdong',
        city: {
            name: 'shenzhen'
        }
    }
}
const cityName = country.province
    && country.province.city
    && country.province.city.name;

但这样还是过于繁琐了,于是 Lodash 提供了 get 方法帮处理这个问题:

_.get(country, 'province.city.name');

虽然看起来似乎还不错,但总觉得哪里不太对(好是好,就是太丑了)。
最新的 ES 提案中提供了可选链的语法糖,支持我们用下面的语法来深层取值。

country?.province?.city?.name

但是这个特性只是处于 stage3 阶段,还没有被正式纳入 ES 规范中,更没有浏览器已经支持了这个特性。
所以,我们只能另辟蹊径。这时你可能会想到如果使用 Proxy 的 get 方法拦截对属性的访问,这样是不是就可以实现深层取值了呢?


接下来,我将会带着你一步步实现下面的这个 get 方法。

const obj = {
    person: {}
}
// 预期结果(这里为什么要当做函数执行呢?)
get(obj)() === obj;
get(obj).person(); // {}
get(obj).person.name(); // undefined
get(obj).person.name.xxx.yyy.zzz(); // undefined

首先,创建一个 get 方法,使用 Proxy 中的 get 对传入的对象进行拦截。

function get (obj{
    return new Proxy(obj, {
        get(target, prop) {
            return target[prop];
        }
    })
}

来运行一下上面的三个例子,看一下结果如何:

get(obj).person; // {}
get(obj).person.name; // undefined
get(obj).person.name.xxx.yyy.zzz; // Cannot read property 'xxx' of undefined

前两个测试用例是成功了,但第三个还是不行,因为 get(obj).person.name undefined ,所以接下来的重点是处理属性为 undefined 的情况。
对这个 get 方法进行一下简单的改造,这次不再直接返回 target[prop] ,而是返回一个代理对象,让第三个例子不再报错。

function get (obj{
    return new Proxy(obj, {
        get(target, prop) {
            return get(target[prop]);
        }
    })
}

嗯,看起来有点儿高大上了,但是 target[prop] undefined 的时候,传给 get 方法的就是 undefined 了,而 Proxy 第一个参数必须为对象,这样岂不是会报错?
所以,需要对 obj undefined 的时候进行特殊处理,为了能够深层取值,只能对值为 undefined 的属性设置默认值为空对象。

function get (obj = {}) {
    return new Proxy(obj, {
        get(target, prop) {
            return get(target[prop]);
        }
    })
}
get(obj).person; // {}
get(obj).person.name; // {}
get(obj).person.name.xxx.yyy.zzz; // {}

虽然不报错了,可是后两个返回值却不对了。不设置默认值为空对象就无法继续访问,设置默认值为空对象就会改变返回值。这可该怎么办呢?
仔细看一下上面的预期设计,是不是发现少了一个括号,这就是为什么每个属性都被当做函数来执行。
所以需要对这个函数稍加修改,让其支持 apply 拦截的方式。

function noop({}
function get (obj{
    // 注意这里拦截的是 noop 函数
    return new Proxy(noop, {
        // 这里支持返回执行的时候传入的参数
        apply(target, context, [arg]) {
            return obj;
        },
        get(target, prop) {
            return get(obj[prop]);
        }
    })
}

所以这个 get 方法已经可以这样使用了。

get(obj)() === obj; // true
get(obj).person.name(); // undefined
get(obj).person.name.xxx.yyy.zzz(); // Cannot read property 'xxx' of undefined

我们理想中的应该是,如果属性为 undefined 就返回 undefined ,但仍要支持访问下级属性,而不是抛出错误。顺着这个思路来的话,很明显当属性为 undefined 的时候也需要用 Proxy 进行特殊处理。
所以我们需要一个具有下面特性的 get 方法:

get(undefined)() === undefined// true
get(undefined).xxx.yyy.zzz() // undefined

和前面的困扰不一样的地方是,这里完全不需要注意 get(undefined).xxx 是否为正确的值,因为想获取值必须要执行才能拿到。那么只需要对所有 undefined 后面访问的属性都默认为 undefined 就好了。

function noop({}
function get (obj{
    if (obj === undefined) {
        return proxyVoid;
    }
    // 注意这里拦截的是 noop 函数
    return new Proxy(noop, {
        // 这里支持返回执行的时候传入的参数
        apply(target, context, [arg]) {
            return obj === undefined ? arg : obj;
        },
        get(target, prop) {
            if (
                obj !== undefined &&
                obj !== null &&
                obj.hasOwnProperty(prop)
            ) {
                return get(obj[prop]);
            }
            return proxyVoid;
        }
    })
}

接下来思考一下这个 proxyVoid 函数该如何实现呢?很明显它应该是一个代理了 undefined 后返回的对象。直接这样好不好?

const proxyVoid = get(undefined);

但是这样很明显会造成死循环了,那么就需要判断临界值了,让 get 方法第一次接收 undefined 的时候不会死循环。

let isFirst = true;
function noop({}
let proxyVoid = get(undefined);
function get(obj{
    if (obj === undefined && !isFirst) {
        return proxyVoid;
    }
    if (obj === undefined && isFirst) {
        isFirst = false;
    }
    // 注意这里拦截的是 noop 函数
    return new Proxy(noop, {
        // 这里支持返回执行的时候传入的参数
        apply(target, context, [arg]) {
            return obj === undefined ? arg : obj;
        },
        get(target, prop) {
            if (
                obj !== undefined &&
                obj !== null &&
                obj.hasOwnProperty(prop)
            ) {
                return get(obj[prop]);
            }
            return proxyVoid;
        }
    })
}

我们再来验证一下,这种方式是否可行:

get(obj)() === obj; // true
get(obj).person.name(); // undefined
get(obj).person.name.xxx.yyy.zzz(); // undefined

bingo,这个方法完全实现了我们的需求。最后,完整的代码如下:

    let isFirst = true;
    function noop({}
    let proxyVoid = get(undefined);
    function get(obj{
        if (obj === undefined) {
            if (!isFirst) {
                return proxyVoid;
            }
            isFirst = false;
        }
        // 注意这里拦截的是 noop 函数
        return new Proxy(noop, {
            // 这里支持返回执行的时候传入的参数
            apply(target, context, [arg]) {
                return obj === undefined ? arg : obj;
            },
            get(target, prop) {
                if (
                    obj !== undefined &&
                    obj !== null &&
                    obj.hasOwnProperty(prop)
                ) {
                    return get(obj[prop]);
                }
                return proxyVoid;
            }
        })
    }
    this.get = get;

这个基于 Proxy 的 get 方法的灵感来自于 Github 上的一个名为 safe-touch 的库,感兴趣的可以去看一下它的源码实现:safe-touch

4.3 管道

在最新的 ECMA 提案中,出现了原生的管道操作符 |> ,在 RxJS 和 NodeJS 中都有类似的 pipe 概念。


使用 Proxy 也可以实现 pipe 功能,只要使用 get 对属性访问进行拦截就能轻易实现,将访问的方法都放到 stack 数组里面,一旦最后访问了 execute 就返回结果。

const pipe = (value) => {
    const stack = [];
    const proxy = new Proxy({}, {
        get(target, prop) {
            if (prop === 'execute') {
                return stack.reduce(function (val, fn{
                    return fn(val);
                }, value);
            }
            stack.push(window[prop]);
            return proxy;
        }
    })
    return proxy;
}
var double = n => n * 2;
var pow = n => n * n;
pipe(3).double.pow.execute;

注意:这里为了在 stack 存入方法,使用了 window[prop] 的形式,是为了获取到对应的方法。也可以将 double pow 方法挂载到一个对象里面,用这个对象替换 window

推荐阅读

  1. Proxy

  2. JavaScript的Proxy可以做哪些有意思的事儿

  3. Proxy 的巧用







请到「今天看啥」查看全文