defineProperty 与 Proxy 实现数据监听

1,097 阅读5分钟

这是我参与更文挑战的第7天,活动详情查看:更文挑战

我们知道 vue2 的数据绑定是通过 Object.defineProperty 实现,vue3 则是通过 Proxy 实现。文章介绍 Object.defineProperty 与 Proxy 的具体原理,尝试利用这两种方式实现简单的数据监听。

Object.defineProperty

属性描述符

首先我们先了解一下对象的属性描述符。通常我们给对象定义属性的时候是这样的:

let obj = {
    name: 'LvLin'
}

能够直观地看到 obj 对象存在名为 name 的属性,除此之外并没有定义其它的东西,那「属性描述符」是什么?要怎么查看?

事实上对于对象上的每个属性,都有一个「属性描述符」来规定这个属性的相关特性。我们可以通过Object.getOwnPropertyDescriptor()方法获取到对象上一个自有属性的属性描述符。

Object.getOwnPropertyDescriptor(obj, 'name')
// {value: "LvLin", writable: true, enumerable: true, configurable: true}

可以看到属性描述符是一个对象类型,它总共有 6 个键值可配置:

  • configurable:当且仅当该属性的 configurable 键值为 true 时,该属性的描述符才能够被改变,同时该属性也能从对应的对象上被删除。默认为 false
  • enumerable:当且仅当该属性的 enumerable 键值为 true 时,该属性才会出现在对象的枚举属性中。默认为 false
  • value:该属性对应的值。可以是任何有效的 JavaScript 值(数值,对象,函数等)。默认为 undefined
  • writable:当且仅当该属性的 writable 为 true 时,该属性才能被赋值运算符改变。默认为 false
  • get:当访问该属性时,会调用此函数。不传入参数。该函数的返回值会被用作属性的值。默认为 undefined
  • set: 该方法接受一个参数(被赋予的新值)。当属性值被修改时,会调用此函数。默认为 undefined

注意:上述默认值指的是在使用 Object.defineProperty 方法定义属性的情况,如果是直接在{}定义的属性,configurableenumerablewritable 的默认值都为 true。我们将刚刚的示例与下面这个示例比较一下:

let obj = {};
Object.defineProperty(obj, 'name', {
    value: 'LvLin'
})
Object.getOwnPropertyDescriptor(obj, 'name'
// {value: "LvLin", writable: false, enumerable: false, configurable: false}

以上键值都是可选的,但是有几个是不能同时配置的。属性描述符的键值配置只有两种存在形式,分别称为「数据描述符」和「存储描述符」,如下所示:

  • 数据描述符,可以拥有的属性为 configurableenumerablevaluewritable
  • 存储描述符:可以拥有的属性为 configurableenumerablegetset
  • 如果一个描述符不具有 valuewritablegetset 中的任意一个键,则为数据描述符。

如果一个属性的属性描述符同时拥有 valuewritablegetset 键,则会产生一个异常,如下所示:

let obj = {};
Object.defineProperty(obj, "name", {
    value: 'LvLin',
    get: function() {
        return 'LvLin';
    }
});
// 报错
// TypeError: Invalid property descriptor. Cannot both specify accessors and a value or writable attribute, #<Object>

Object.defineProperty() 方法介绍

可以看到上述代码我们使用了 Object.defineProperty() 方法来定义对象的属性和属性描述符,该方法使用语法为Object.defineProperty(obj, prop, descriptor)。参数介绍如下:

  • obj:要定义属性的对象;
  • prop:要定义或修改的属性名称;
  • descriptor:要定义或修改的属性描述符

返回值为操作后的对象。简单使用如下所示:

let obj = Object.defineProperty({}, "name", {
    value: 'LvLin',
    writable: true,
    enumerable : true,
    configurable : true
});
console.log(obj);   // {name: "LvLin"}

let value = 1;
Object.defineProperty(obj, "num", {
    get: function(){
        console.log('执行了 get 操作')
        return value;
    },
    set: function(newValue) {
        console.log('执行了 set 操作')
        value = newValue;
    }
})

obj.num; // 执行了 get 操作
obj.num = 2; // 执行了 set 操作

我们利用属性描述符中的 set 跟 get 方法,简单实现一个数据监听如下所示:

<!DOCTYPE html>
<html lang="zh-cmn-Hans">
<head>
    <meta charset="utf-8">
    <title>LvLin test</title>
</head>
<body>
    <span id="container">1</span>
    <button id="button">+</button>
    <script>
        let obj = {
            value: 1
        }
        let value = 1; 

        Object.defineProperty(obj, "value", {
            get: function() {
                return value;
            },
            set: function(newValue) {
                value = newValue;
                document.getElementById('container').innerHTML = newValue;
            }
        });

        document.getElementById('button').addEventListener("click", function() {
            obj.value += 1;
        });
    </script>
</body>
</html>

Object.defineProperty() 不兼容 IE8 及其以下浏览器,这也是 vue2 不支持 IE8 及其以下浏览器的主要原因。

Proxy

proxy 是 ES6 新增的特性,用于创建一个对象的代理,该代理是一个全新的对象,存在一系列对原对象的操作,都能够经过代理对象的拦截,从而实现对对象行为操作的过滤和改写。使用语法如下:

const p = new Proxy(target, handler)
// target: 要使用 Proxy 包装的目标对象
// handler: 一个对象,它的各属性中的函数分别定义了在执行各种操作时代理 p 的行为。即定义拦截行为。

使用示例如下所示:

var proxy = new Proxy({}, {
    get: function(obj, prop) {
        console.log('设置 get 操作')
        return obj[prop];
    },
    set: function(obj, prop, value) {
        console.log('设置 set 操作')
        obj[prop] = value;
    }
});
proxy.time = 35; // 设置 set 操作
console.log(proxy.time); // 设置 get 操作 // 35

proxy 总共可以拦截 13 种操作,同时搭配 Reflect (ES6 为了操作对象而提供的新 API)可以实现更强大的功能,具体可以翻阅阮一峰的 《ECMAScript 6 入门——Proxy》《ECMAScript 6 入门——Reflect》 进行学习。

下面我们对先前的 HTML 页面的 JS 代码部分进行改写,换成用 Proxy 来实现数据监听,如下所示:

<!DOCTYPE html>
<html lang="zh-cmn-Hans">
<head>
    <meta charset="utf-8">
    <title>LvLin test</title>
</head>
<body>
    <span id="container">1</span>
    <button id="button">+</button>
    <script>
        let proxy = new Proxy({}, {
            get: function (target, prop) {
                return target[prop];
            },
            set: function (target, prop, value) {
                target[prop] = value;
                document.getElementById('container').innerHTML = value;
            }
        });

        document.getElementById('button').addEventListener("click", function () {
            proxy.value += 1;
        });
    </script>
</body>
</html>

自己简单尝试一下,看看效果如何~

总结

当使用 defineProperty 时,我们是通过修改原来的 obj 对象属性的属性描述符来实现数据监听,而使用 proxy,实际上是返回了一个新的代理对象,通过该代理对象拦截数据操作,实现数据监听,对原始的 obj 对象没有影响。

Proxy 的缺点是存在比较大的浏览器兼容问题,很多效果无法使用 poilyfill 来弥补。

参考

Object.defineProperty(), by MDN

Proxy,by MDN

Proxy, by 阮一峰

ES6 系列之 defineProperty 与 proxy,by 冴羽