给对象定义可拦截属性

defineProperty从名字也能猜出个大概来,「定义属性」,没错,它就是用来给对象定义属性的,只不过它可以在定义属性的时候对其进行拦截操作:

let json = { _a: 10 };
Object.defineProperty(json, "a", {
get() {
    console.log("get被调用了");
    return json._a;
},
set(val) {
    console.log("set被调用了");
    json._a = val;
},
});

我们使用defineProperty来给json加了一个a属性,这个时候我们一量获取、设置a,就会分别触发里面的getset。在这两个方法里面,我们就可以对其实进行拦截,并且对属性值做一些处理:

上面我们在setget里只是做了一个简单的「获取」和「设置」操作,其实我们可以对数据做一些其它复杂的计算之后再进行操作。当然,这是根据我们实际的需求来确定的。

删除属性

我们来把刚才添加的a属性给删掉试一下:

删除a属性的时候返回了false,删除失败,这也是defineProperty添加属性的一个默认效果,那么如果想要能删除属性该如何操作呢?

configurable

其实在使用defineProperty的时候还有一个configurable属性,好多属性通过名字就知道它是干啥的-「是否可配制」,这个属性默认就是false,也就是不可配制,所以我们在删除自定义属性的时候就回返回false。接下来我们把它设置成true来试一下:

简单应用

在前面的例子当中,我们是给一个json赋值,现在我们来看这个例子:

let app = new App({
  root: "#app",
  data() {
    return {
      username: "vvheat",
      age: 18,
    };
  },
});

上面这段代码用过vue的朋友应该很熟悉吧?把App改成Vue不就是新建Vue实例了吗?当然,我们这点代码肯定比vue差了几个宇宙了。上面我们新建了一个App的实例叫app,我们想在设置它里面data的值的时候通过app.usernameapp.age这样来操作,但现在肯定是不可以的,因为usernameagedata方法返回的一个新对象,并不是直接给app加的属性,那么如何才能实现呢?

我们直接来看App这个类:


class App { constructor(options) { let data = options.data(); for (let key in data) { Object.defineProperty(this, key, { configurable: true, get() { console.log(`有人获取了${key}`); return data[key]; }, set(val) { console.log(`有人设置了${key}`); data[key] = val; }, }); } } }

我们把实例当中data里的属性全部通过defineProperty放到了App的实例上,这样,我们就可以直接在实例上找到所有在data()里的属性了:

WOW!!Amazing!!!

其实defineProperty并不是万能的,比如我们来给app实例添加一个hobbies的数组:


let app = new App({ root: "#app", data() { return { username: "vvheat", age: 18, hobbies: ["B-ball", "Painting"], }; }, });

然后我们来设置一下hobbiles里的内容:

好像没啥问题啊,诶?是不是少打了什么东西?之前每「设置」一个属性的时候都会出现 有人设置了***,可这次为什么没有了呢?其实这是defineProperty的一个缺点,就是它不会拦截「数组元素」和「对象属性」!!!它只能拦截第一层属性的变化!!!

可以看到,我们直接设置app.hobbiles的时候,才会出现有人设置了hobbies。那么我们如何来处理这种情况呢?

我们在实际场景中,设置属性的时候肯定不只是打印一下这么简单,一般我们在设置属性的时候会执行一些渲染的操作:

class App {
  constructor(options) {
    let data = options.data();
    for (let key in data) {
      Object.defineProperty(this, key, {
        configurable: true,
        get() {
          return data[key];
        },
        set(val) {
          data[key] = val;
          this.render();
        },
      });
    }
  }
  render() {
    console.log("执行渲染操作!");
  }
}

上面的render只是打印了一句话,但实际上我们会在这里做一些页面的渲染工作,这里不是我们要说的重点,所以略过。
我们可以看出来,只要我们设置了一个属性,就会调用render方法,但是我们如果要设置一个数组元素或者对象属性,它不会被拦截。所以我们需要单独写一个方法来供我们进行调用:


class App { constructor(options) { let data = options.data(); for (let key in data) { Object.defineProperty(this, key, { configurable: true, get() { return data[key]; }, set(val) { data[key] = val; this.render(); }, }); } } $set(obj, key, val) { obj[key] = val; this.render(); } render() { console.log("执行渲染操作!"); } }

我们为之前的类添加了一个$set方法,这个方法很简单,只是把传进来的东西做一个赋值,然后渲染,所以,当我们给数组元素或者对象设置值的时候就可以这样来用:

这样,我们就单独针对数组元素和对象属性进行了处理。但现在又会导致一个新的问题,这个$set方法也可以用在根层的属性上:

可以看到,我直接通过app.username来设置的时候,渲染一次是正确的,但是我通过app.$set设置的时候,却渲染了两次!一次是$set里面调用我们很清楚,还有一次是defineProperty拦截掉了,这样很明显是不正确的。所以我们要处理一下:


class App { constructor(options) { let data = options.data(); for (let key in data) { Object.defineProperty(this, key, { configurable: true, get() { return data[key]; }, set(val) { data[key] = val; this.render(); }, }); } this._updated = false; } $set(obj, key, val) { this._updated = false; obj[key] = val; if (!this._updated) { this.render(); } } render() { console.log("执行渲染操作!"); this._updated = true; } }

上面我们添加了一个标识_updated,在$set当中,我们判断了是否已经执行过render,如果没有才进行渲染,如果执行过,那么肯定是在defineProperty当中调用的。这样就是我们想要的结果。

到这里我们已经知道了defineProperty对于数组、json的监听拦截是不完整的,如果想要多层的监听,必须得用递归,但一般不会这样做,因为性能会比较低。