JavaScript中的对象描述符与响应式数据

自从接触Vue以来,就对它的响应式数据原理十分好奇。文档中提到是使用Object.defineProperty实现的,因此决定深入了解JavaScript中对象的一些特性。

<!--more-->

参考:

  • 《你不知道的JavaScript》
  • 《JavaScript权威指南》

1. 属性描述符

从ES5开始,所有的对象都具备了属性描述符。我们可以使用getOwnPropertyDescriptor来获取某个对象的某个属性的全部描述符。

var obj = {
    msg: "hello"
};
var res = Object.getOwnPropertyDescriptor(obj, "msg")
console.log(res);
// res
{
    configurable: true,
    enumerable: true,
    value: "hello",
    writable: true
}

可以看见,描述符看起来很像是某个对象的属性。除了我们熟悉的value之外,还有configurableenumerablewriteable这三个描述符。 我们还可以使用defineProperty来修改某个属性的描述符。

Object.defineProperty(obj, "msg", {
    configurable: true,
    enumerable: true,
    value: "world",
    writable: true
});

在上面,我们将obj.msg的值修改为了"world"。看起来把整个问题复杂了,直接使用obj.msg = "world";不是更直接明了吗?不要着急,我们慢慢弄明白属性描述符的含义。

1.1. value

之前了解对象的属性,基本只停留在“对象的属性是一个值,可以使用.或者[]访问”这一层面:

  • .后面直接跟属性名称,一般称作属性访问
  • []内可包含一个表达式来计算属性名,一般称作键访问

一般会把value称为数据描述符,因为它用来指代这个属性所包含的数据值的。通常地,属性访问返回的就是value所包含的值(但是,返回其他值得情形也是存在的,下面的访问描述符再提)。

1.2. writable

我们知道,常规对象的属性是可以进行读和写操作的,读指读取该属性值,而写则表示为该属性赋值。writable修饰符决定是否可以修改属性的值,即是否允许对该属性进行赋值。 在上面的例子中,如果将msgwriteable描述符修改为false,则表示对该属性进行复制的操作都会失败(在非严格模式下没有任何反应,在严格模式下报错)

Object.defineProperty(obj, "msg", {
    writable: false
});
obj.msg = "xxx"; // 仍旧是"world"

看起来可以模拟一个常量哦。

1.3. enumerable

我们知道,可以使用for in循环遍历某个对象的属性。实际上,决定某个属性是否在遍历中出现也是由属性描述符控制的,这个描述符就是enumerable。如果将该值设置为false,则该属性就不会被遍历。

Object.defineProperty(obj, "msg", {
    enumerable: false,
});
for (var key in obj){
    console.log(key); // msg不会被遍历
}

需要注意的是,for in会遍历对象的全部的enumerable为真的属性,包括从原型链委托获得的属性。如果只需要遍历对象本身的属性,需要使用hasOwnProperty()进行判断

function Fn(){
    this.x = 1;
}
Fn.prototype.y = 1000;
var obj = new Fn();
obj.msg = "hello";
for (var key in obj){
    if (obj.hasOwnProperty(key)){
        console.log(key)
    }
}

此外,也可以使用下面两种方法

  • Object.keys(),直接返回一个包含参数对象自身全部可枚举属性的数组。
  • Object.getOwnPropertyNames(),直接返回一个包含参数对象全部自身属性的数组,即使是不可枚举的

1.4. configurable

前面提到,可以使用defineProperty来修改对象某个属性的描述符。实际上,这本身也是受描述符限制的,这个描述符是configurable。如果将该值修改为false,且则再次修改该属性的属性描述符就会报TypeError的错误(例外是仍旧可以把writabletrue改为false)。 configurable为假时,除了禁止修改属性描述符,还禁止从对象上删除该属性。 ``

2. 访问描述符

除了数据描述符,在ES5中,也可以使用getter和setter来改写单个属性的默认操作,由于这些操作关注的是对于属性的操作(而不是属性的值),因此也被称为访问描述符。在了解具体的getter和setter之前,我们先来了解对象的[[get]][[put]]操作。

2.1. [[get]]和[[put]]

2.1.1. [[get]]

语言规范中,对象属性访问的实现实际上是[[get]],该操作有点类似于函数调用,即首先在对象中根据指定的表示式或字符串常量查找对应的同名属性,如果找到就返回该属性值,如果不存在则会在原型链上实现委托查询(这是另外一个很重要的话题,这里就不展开了)。 需要注意的是,如果访问的属性不存在或者存在但值为空,则均会返回undefined,因此无法根据返回值判断属性是否存在还是值为undefined

2.1.2. [[put]]

既然属性访问实际上是通过[[get]]实现的,那么肯定存在对属性进行赋值的操作[[put]]。实际上,[[put]]操作的触发并不仅仅是对属性进行赋值这么简单,而是取决于许多因素:

  • 属性的writable是否为true
  • 属性是否是访问描述符(接下来就会提到)
  • 如果都不会,则会将该值设置为该属性的值

2.2. getter和setter

2.2.1. getter

前面提到,对象属性访问使用[].操作符进行,实际上,这都是通过[[get]]操作实现的,更具体一点,我们可以修改[[get]]操作,从而达到在返回属性值之前对其进行某些修饰,这是通过getter实现的,getter是一个隐藏函数,会在读取对象属性的时候被调用。

var obj = {
    get msg(){
        return "hello";
    }
};
console.log(obj.msg); // hello

2.2.2. setter

当然,我们也可以使用setter来设置属性值,setter也是一个隐藏函数,会在设置属性值时被调用。

var obj = {
    __msg: "hello",
    get msg(){
        return this.__msg;
    },
    set msg(val){
        this.__msg = "|" + val + "|";
    }
}
obj.msg = "world";
console.log(obj.msg); // |world|

这里使用了一个__msg的普通属性来关联settergetter操作,以便能观察具体的结果。实际上,可以在settergetter中使用任意的变量(只要能够明确变量的含义和作用域)。

需要注意的是,当为某个属性(这里是msg)定义一个gettersetter时(或两者都存在),该属性就会被定义为访问描述符。对于访问描述符而言,JavaScript会忽略他们的valuewritable特性,而关注getset

2.2.3. 非标准方法

除了上面在对象初始化的时候定义访问描述符,也可以使用__defineGetter____defineSetter__为已存在的某个对象定义访问描述符。

var v = 1000;
o.__defineGetter__("x", function(){
    return v;
})
o.__defineSetter__("x", function(val){
    v = val*2;
})

根据MDN的描述,这两个方法是非标准方法,且已经从web标准中移除了,所以最好不要再使用了。

2.3. 小结

通过gettersetter,我们可以更加灵活地操作对象的属性值,比如在获取属性值前对数据进行修饰,或者在设置属性值前对值进行校验。换句话说,我们可以监控对象的属性,并在属性值改变的时候执行相应的操作,更重要的是:我们可以在属性值变化的时候,操作DOM节点!

3. 响应式数据

接下来让我们实现一个简单的类似于Vue的响应式数据。 Vue文档中提到:

把一个普通 JavaScript 对象传给 Vue 实例的 data 选项,Vue 将遍历此对象所有的属性,并使用 Object.defineProperty 把这些属性全部转为 getter/setter ... 每个组件实例都有相应的 watcher 实例对象,它会在组件渲染的过程中把属性记录为依赖,之后当依赖项的 setter 被调用时,会通知 watcher 重新计算,从而致使它关联的组件得以更新

因此,我们需要具体实现的地方有:

  • 根据参数对象修改实例对应属性的访问描述符
  • 当实例某个属性的setter被调用时通知所有与该属性相关的页面节点,并执行相关操作
  • 将属性渲染成html文档,需要一个模板引擎

3.1. 修改描述符

前面提到,我们可以使用Object.defineProperty()来修改对象的描述符,考虑到需要修改整个参数对象的描述符,可以使用Object.keys()进行一次遍历

// 改写访问描述符
function defineReactive(obj, key, val) {
    Object.defineProperty(obj, key, {
        enumerable: true, 
        get: function() {
            return val;
        },
        set: function(newVal) {
            // console.log(val, "=>", newVal);
            val = newVal;
        }
    });
}

// 遍历对象属性并执行改写
function val2Reactive(obj) {
    if (!obj || typeof obj !== 'object') {
        return;
    }

    Object.keys(obj).forEach((key)=>{
        defineReactive(obj, key, obj[key]);
    });
}

var data = {
    x: 1
}

console.log(data);
val2Reactive(data);
console.log(data);
data.x = 2;

3.2. 发布-订阅模式

发布-订阅模式又叫观察者模式,由于这里需要当属性变化时通知所有依赖该属性的地方,因此很有必要了解一下。下面是阅读《JavaScript设计模式与开发实践》第八章了解的一点东西。

发布-订阅模式由一个发布者和数个订阅者组成,大致原理是:

  • 发布者提供一个订阅的接口,用于新的订阅者调用
  • 订阅者调用订阅接口,并为发布者提供自身的联系方式(一个回调函数)
  • 发布者负责维护一个订阅者的缓存列表,保存订阅者的名称和联系方式
  • 当发布者进行更新时,遍历订阅者的缓存列表,依次调用各订阅者的联系方式并进行通知

了解了大致的原理,可以实现一个简陋的发布-订阅者模式

// 发布者
class Dep{
    constructor(){
        this.subs = {};
    }
    // 订阅
    subscibe(name, cb){
        if (typeof this.subs[name] !== "undefined"){
            throw new Error("已存在相同的订阅者,换个名字~");
        }
        this.subs[name] = cb;
    }
    // 通知
    notify(data){
        var subs = this.subs;
        Object.keys(subs).forEach((key)=>{
            subs[key](data);
        })
    }
}

var dep = new Dep();

// 订阅者
var a = {
    init(){
        dep.subscibe("a", function(data){
            console.log("a receive changes: " + data);
        })
    }
}
var b = {
    init(){
        dep.subscibe("b", function(data){
            console.log("b receive changes: " + data);
        })
    }
}
a.init();
b.init();

// 这里进行一些操作,然后发布者对订阅者进行通告
dep.notify(100);

3.3. 渲染模板

由于一个将数据解析成html文档的模板引擎,这里使用laytpllaytpl的是一个很精简却十分强大的模板引擎,大致使用方法

var wrap = document.getElementById("#wrap")
// 定义模板和数据
var tpl = "<h1>{{ d.msg }}</h1>",
    data = {msg: "Hello World"};

// 调用api进行渲染
laytpl(tpl).render(data, (html)=>{
      wrap.innerHTML = html;
})

更多的语法规则请参考官方文档,这里就不扯了。

3.4. 完整实现

现在,整个流程基本就跑通了:

  • 实例化一个vue对象,并遍历参数对象的值,修改vue实例的访问描述符
  • 定义一个发布者,当值发生改变时(即setter触发时)通知订阅者
  • 订阅者使用数据进行渲染
// main.js
class Dep {
    constructor() {
        this.subs = {};
    }
    subscibe(name, cb) {
        if (typeof this.subs[name] !== "undefined") {
            throw new Error("已存在相同的订阅者,换个名字~");
        }
        this.subs[name] = cb;
    }
    notify(data) {
        var subs = this.subs;
        Object.keys(subs).forEach((key) => {
            subs[key](data);
        })
    }
}


class V {
    constructor(params) {
        this.el = document.querySelector(params.el);
        this.tpl = document.querySelector(params.tpl);

        this.dep = new Dep();
        this.data = params.data;

        this.init();
    }

    init(){
        this.observe(this.data);
        this.render();
    }

    render(){
        var htm = this.tpl.innerHTML,
            data = this.data;

        laytpl(htm).render(data, (html)=>{
            this.el.innerHTML = html;
        })
    }

    defineReactive(obj, key, val) {
        // 递归监听子属性
        this.observe(val); 

        Object.defineProperty(obj, key, {
            enumerable: true,
            get: ()=>{
                return val;
            },
            set: (data)=>{
                val = data;
                this.dep.notify(data);
            }
        });
    }

      // 绑定发布者
    observe(obj) {
        if (!obj || typeof obj !== 'object') {
            return;
        }

        Object.keys(obj).forEach((key) => {
            this.defineReactive(obj, key, obj[key]);
            this.dep.subscibe(key, ()=>{
                // 每次数据改变都重新渲染页面,这里可以进行优化
                this.render();
            })
        });
    }
}

在页面上进行测试

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>inedx</title>
</head>
<body>
    <div id="app"></div>
    <button onclick="changeMsg()">change</button>
    <script type="text/html" id="appTpl">
        {{ d.msg }}
        <button>{{d.count}}</button>
    </script>
    <script type="text/javascript" src="laytpl.js"></script>
    <script type="text/javascript" src="main.js"></script>
    <script type="text/javascript">
        var vx = new V({
            el: "#app",
            tpl: "#appTpl",
            data: {
                msg: "hello",
                count: 0
            }
        })
        function changeMsg(){
            vx.data.msg = "world";
            vx.data.count++;
        }
    </script>
</body>
</html>

3.5. 最后

现在,一个基本的响应式数据渲染框架就实现了。当然,需要改进的地方还有很多,

  • 每次都会重新生成整个模板(订阅者的通知方式内都只是调用了render()方法),存在不必要的性能消耗
  • 每次访问数据都需要使用vx.data,可以在vx上指定快捷方式
  • 增强模板标签的功能,比如事件和双向数据绑定等

Vue是我学习前端以来接触到的第一个MVVM框架,起初觉得十分神奇,但是完全无法猜想内部的实现原理,现在正在逐渐深入JavaScript,感觉新的大门正在敞开,加油吧。