通俗易懂了解Vue双向绑定原理及实现


1. 前言

每当被问到Vue数据双向绑定原理的时候,大家可能都会脱口而出:Vue内部通过Object.defineProperty方法属性拦截的方式,把data对象里每个数据的读写转化成getter/setter,当数据变化时通知视图更新。虽然一句话把大概原理概括了,但是其内部的实现方式还是值得深究的,本文就以通俗易懂的方式剖析Vue内部双向绑定原理的实现过程。

2. 思路分析

所谓MVVM数据双向绑定,即主要是:数据变化更新视图,视图变化更新数据。如下图:

也就是说:

  • 输入框内容变化时,data 中的数据同步变化。即 view => model 的变化。
  • data 中的数据变化时,文本节点的内容同步变化。即 model => view 的变化。

要实现这两个过程,关键点在于数据变化如何更新视图,因为视图变化更新数据我们可以通过事件监听的方式来实现。所以我们着重讨论数据变化如何更新视图。

数据变化更新视图的关键点则在于我们如何知道数据发生了变化,只要知道数据在什么时候变了,那么问题就变得迎刃而解,我们只需在数据变化的时候去通知视图更新即可。

3. 使数据对象变得“可观测”

数据的每次读和写能够被我们看的见,即我们能够知道数据什么时候被读取了或数据什么时候被改写了,我们将其称为数据变的‘可观测’。

要将数据变的‘可观测’,我们就要借助前言中提到的Object.defineProperty方法了,关于该方法,MDN上是这么介绍的:

Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性, 并返回这个对象。

在本文中,我们就使用这个方法使数据变得“可观测”。

首先,我们定义一个数据对象car

let car = {
    'brand':'BMW',
    'price':3000
}

我们定义了这个car的品牌brandBMW,价格price是3000。现在我们可以通过car.brandcar.price直接读写这个car对应的属性值。但是,当这个car的属性被读取或修改时,我们并不知情。那么应该如何做才能够让car主动告诉我们,它的属性被修改了呢?

接下来,我们使用Object.defineProperty()改写上面的例子:

let car = {}
let val = 3000
Object.defineProperty(car, 'price', {
    get(){
        console.log('price属性被读取了')
        return val
    },
    set(newVal){
        console.log('price属性被修改了')
        val = newVal
    }
})

通过Object.defineProperty()方法给car定义了一个price属性,并把这个属性的读和写分别使用get()set()进行拦截,每当该属性进行读或写操作的时候就会触发get()set()。如下图:

可以看到,car已经可以主动告诉我们它的属性的读写情况了,这也意味着,这个car的数据对象已经是“可观测”的了。

为了把car的所有属性都变得可观测,我们可以编写如下两个函数:

/**
 * 把一个对象的每一项都转化成可观测对象
 * @param { Object } obj 对象
 */
function observable (obj) {
    if (!obj || typeof obj !== 'object') {
        return;
    }
    let keys = Object.keys(obj);
    keys.forEach((key) =>{
        defineReactive(obj,key,obj[key])
    })
    return obj;
}
/**
 * 使一个对象转化成可观测对象
 * @param { Object } obj 对象
 * @param { String } key 对象的key
 * @param { Any } val 对象的某个key的值
 */
function defineReactive (obj,key,val) {
    Object.defineProperty(obj, key, {
        get(){
            console.log(`${key}属性被读取了`);
            return val;
        },
        set(newVal){
            console.log(`${key}属性被修改了`);
            val = newVal;
        }
    })
}

现在,我们就可以这样定义car:

let car = observable({
    'brand':'BMW',
    'price':3000
})

car的两个属性都变得可观测了。

4. 依赖收集

完成了数据的’可观测’,即我们知道了数据在什么时候被读或写了,那么,我们就可以在数据被读或写的时候通知那些依赖该数据的视图更新了,为了方便,我们需要先将所有依赖收集起来,一旦数据发生变化,就统一通知更新。其实,这就是典型的“发布订阅者”模式,数据变化为“发布者”,依赖对象为“订阅者”。

现在,我们需要创建一个依赖收集容器,也就是消息订阅器Dep,用来容纳所有的“订阅者”。订阅器Dep主要负责收集订阅者,然后当数据变化的时候后执行对应订阅者的更新函数。

创建消息订阅器Dep:

class Dep {
    constructor(){
        this.subs = []
    };
    //增加订阅者
    addSub(sub){
        this.subs.push(sub);
    };
    //判断是否增加订阅者
    depend () {
        if (Dep.target) {
            this.addSub(Dep.target)
        }
    };
    //通知订阅者更新
    notify(){
        this.subs.forEach((sub) =>{
            sub.update()
        })
    }
}
Dep.target = null;

有了订阅器,再将defineReactive函数进行改造一下,向其植入订阅器:

function defineReactive (obj,key,val) {
    let dep = new Dep();
    Object.defineProperty(obj, key, {
        get(){
            dep.depend();
            console.log(`${key}属性被读取了`);
            return val;
        },
        set(newVal){
            val = newVal;
            console.log(`${key}属性被修改了`);
            dep.notify()     //数据变化通知所有订阅者
        }
    })
}

从代码上看,我们设计了一个订阅器Dep类,该类里面定义了一些属性和方法,这里需要特别注意的是它有一个静态属性 target,这是一个全局唯一 的Watcher,这是一个非常巧妙的设计,因为在同一时间只能有一个全局的 Watcher 被计算,另外它的自身属性 subs 也是 Watcher 的数组。

我们将订阅器Dep添加订阅者的操作设计在getter里面,这是为了让Watcher初始化时进行触发,因此需要判断是否要添加订阅者。在setter函数里面,如果数据变化,就会去通知所有订阅者,订阅者们就会去执行对应的更新的函数。

到此,订阅器Dep设计完毕,接下来,我们设计订阅者Watcher.

5. 订阅者Watcher

订阅者Watcher在初始化的时候需要将自己添加进订阅器Dep中,那该如何添加呢?我们已经知道监听器Observer是在get函数执行了添加订阅者Wather的操作的,所以我们只要在订阅者Watcher初始化的时候出发对应的get函数去执行添加订阅者操作即可,那要如何触发get的函数,再简单不过了,只要获取对应的属性值就可以触发了,核心原因就是因为我们使用了Object.defineProperty( )进行数据监听。这里还有一个细节点需要处理,我们只要在订阅者Watcher初始化的时候才需要添加订阅者,所以需要做一个判断操作,因此可以在订阅器上做一下手脚:在Dep.target上缓存下订阅者,添加成功后再将其去掉就可以了。订阅者Watcher的实现如下:

class Watcher {
    constructor(vm,exp,cb){
        this.vm = vm;
        this.exp = exp;
        this.cb = cb;
        this.value = this.get();  // 将自己添加到订阅器的操作
    };
    update(){
        let value = this.vm.data[this.exp];
        let oldVal = this.value;
        if (value !== oldVal) {
            this.value = value;
            this.cb.call(this.vm, value, oldVal);
        };
        get(){
            Dep.target = this;  // 缓存自己
            let value = this.vm.data[this.exp]  // 强制执行监听器里的get函数
            Dep.target = null;  // 释放自己
            return value;
        }
    }

过程分析:

订阅者Watcher 是一个 类,在它的构造函数中,定义了一些属性:

  • vm:一个Vue的实例对象;
  • exp:node节点的v-modelv-on:click等指令的属性值。如v-model="name"exp就是name;
  • cb:Watcher绑定的更新函数;

当我们去实例化一个渲染 watcher 的时候,首先进入 watcher 的构造函数逻辑,就会执行它的 this.get() 方法,进入 get 函数,首先会执行:

Dep.target = this;  // 缓存自己

实际上就是把 Dep.target 赋值为当前的渲染 watcher ,接着又执行了:

let value = this.vm.data[this.exp]  // 强制执行监听器里的get函数

在这个过程中会对 vm 上的数据访问,其实就是为了触发数据对象的getter

每个对象值的 getter都持有一个 dep,在触发 getter 的时候会调用 dep.depend() 方法,也就会执行this.addSub(Dep.target),即把当前的 watcher 订阅到这个数据持有的 depsubs 中,这个目的是为后续数据变化时候能通知到哪些 subs 做准备。

这样实际上已经完成了一个依赖收集的过程。那么到这里就结束了吗?其实并没有,完成依赖收集后,还需要把 Dep.target 恢复成上一个状态,即:

Dep.target = null;  // 释放自己

因为当前vm的数据依赖收集已经完成,那么对应的渲染Dep.target 也需要改变。

update()函数是用来当数据发生变化时调用Watcher自身的更新函数进行更新的操作。先通过let value = this.vm.data[this.exp];获取到最新的数据,然后将其与之前get()获得的旧数据进行比较,如果不一样,则调用更新函数cb进行更新。

至此,简单的订阅者Watcher设计完毕。

6. 测试

完成以上工作后,我们就可以来真正的测试了。

index.html

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>Document</title>
    </head>
    <body>
        <h1 id="name"></h1>
        <input type="text">
        <input type="button" value="改变data内容" onclick="changeInput()">

        <script src="observer.js"></script>
        <script src="watcher.js"></script>
        <script>
            function myVue (data, el, exp) {
                this.data = data;
                observable(data);                      //将数据变的可观测
                el.innerHTML = this.data[exp];           // 初始化模板数据的值
                new Watcher(this, exp, function (value) {
                    el.innerHTML = value;
                });
                return this;
            }

            var ele = document.querySelector('#name');
            var input = document.querySelector('input');

            var myVue = new myVue({
                name: 'hello world'
            }, ele, 'name');

            //改变输入框内容
            input.oninput = function (e) {
                myVue.data.name = e.target.value
            }
            //改变data内容
            function changeInput(){
                myVue.data.name = "难凉热血"

            }
        </script>
    </body>
</html>

observer.js

/**
 * 把一个对象的每一项都转化成可观测对象
 * @param { Object } obj 对象
 */
function observable (obj) {
    if (!obj || typeof obj !== 'object') {
        return;
    }
    let keys = Object.keys(obj);
    keys.forEach((key) =>{
        defineReactive(obj,key,obj[key])
    })
    return obj;
}
/**
 * 使一个对象转化成可观测对象
 * @param { Object } obj 对象
 * @param { String } key 对象的key
 * @param { Any } val 对象的某个key的值
 */
function defineReactive (obj,key,val) {
    let dep = new Dep();
    Object.defineProperty(obj, key, {
        get(){
            dep.depend();
            console.log(`${key}属性被读取了`);
            return val;
        },
        set(newVal){
            val = newVal;
            console.log(`${key}属性被修改了`);
            dep.notify()                    //数据变化通知所有订阅者
        }
    })
}
class Dep {

    constructor(){
        this.subs = []
    }
    //增加订阅者
    addSub(sub){
        this.subs.push(sub);
    }
    //判断是否增加订阅者
    depend () {
        if (Dep.target) {
            this.addSub(Dep.target)
        }
    }

    //通知订阅者更新
    notify(){
        this.subs.forEach((sub) =>{
            sub.update()
        })
    }

}
Dep.target = null;

watcher.js

class Watcher {
    constructor(vm,exp,cb){
        this.vm = vm;
        this.exp = exp;
        this.cb = cb;
        this.value = this.get();  // 将自己添加到订阅器的操作
    }
    get(){
        Dep.target = this;  // 缓存自己
        let value = this.vm.data[this.exp]  // 强制执行监听器里的get函数
        Dep.target = null;  // 释放自己
        return value;
    }
    update(){
        let value = this.vm.data[this.exp];
        let oldVal = this.value;
        if (value !== oldVal) {
            this.value = value;
            this.cb.call(this.vm, value, oldVal);
        }
    }
}

效果:

7. 总结

总结一下:

实现数据的双向绑定,首先要对数据进行劫持监听,所以我们需要设置一个监听器Observer,用来监听所有属性。如果属性发上变化了,就需要告诉订阅者Watcher看是否需要更新。因为订阅者是有很多个,所以我们需要有一个消息订阅器Dep来专门收集这些订阅者,然后在监听器Observer和订阅者Watcher之间进行统一管理的。

以上内容转自:难凉热血,码梦为生!,原文链接:通俗易懂了解Vue双向绑定原理及实现


以下内容为作者对原文进行学习备注

8.知识扩展

1.Object.defineProperty()

Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。

语法

Object.defineProperty(obj, prop, descriptor)

参数:

obj          //要定义属性的对象。
prop         //要定义或修改的属性的名称或 Symbol 。
descriptor   //要定义或修改的属性描述符。

返回值:

被传递给函数的对象。

注:在ES6中,由于 Symbol类型的特殊性,用Symbol类型的值来做对象的key与常规的定义或修改不同,而Object.defineProperty 是定义key为Symbol的属性的方法之一。

描述

该方法允许精确地添加或修改对象的属性。通过赋值操作添加的普通属性是可枚举的,在枚举对象属性时会被枚举到(for...inObject.keys方法),可以改变这些属性的值,也可以删除这些属性。这个方法允许修改默认的额外选项(或配置)。默认情况下,使用 Object.defineProperty() 添加的属性值是不可修改(immutable)的。

对象里目前存在的属性描述符有两种主要形式:数据描述符存取描述符数据描述符是一个具有值的属性,该值可以是可写的,也可以是不可写的。存取描述符是由 getter 函数和 setter 函数所描述的属性。一个描述符只能是这两者其中之一;不能同时是两者。

这两种描述符都是对象。它们共享以下可选键值(默认值是指在使用 Object.defineProperty() 定义属性时的默认值):

configurable

当且仅当该属性的 configurable 键值为 true 时,该属性的描述符才能够被改变,同时该属性也能从对应的对象上被删除。

默认为 false

enumerable

当且仅当该属性的 enumerable 键值为 true 时,该属性才会出现在对象的枚举属性中。

默认为 false

数据描述符还具有以下可选键值:

value

该属性对应的值。可以是任何有效的 JavaScript 值(数值,对象,函数等)。

默认为 undefined

writable

当且仅当该属性的 writable 键值为 true 时,属性的值,也就是上面的 value,才能被赋值运算符改变。

默认为 false

存取描述符还具有以下可选键值:

get

属性的 getter 函数,如果没有 getter,则为 undefined。当访问该属性时,会调用此函数。执行时不传入任何参数,但是会传入 this 对象(由于继承关系,这里的this并不一定是定义该属性的对象)。该函数的返回值会被用作属性的值。

默认为 undefined

set

属性的 setter 函数,如果没有 setter,则为 undefined。当属性值被修改时,会调用此函数。该方法接受一个参数(也就是被赋予的新值),会传入赋值时的 this 对象。

默认为 undefined

描述符默认值汇总:

  • 拥有布尔值的键 configurableenumerablewritable 的默认值都是 false
  • 属性值和函数的键 valuegetset 字段的默认值为 undefined

描述符可拥有的键值:

configurable enumerable value writable get set
数据描述符 可以 可以 可以 可以 不可以 不可以
存取描述符 可以 可以 不可以 不可以 可以 可以

如果一个描述符不具有 valuewritablegetset 中的任意一个键,那么它将被认为是一个数据描述符。如果一个描述符同时拥有 valuewritablegetset 键,则会产生一个异常。

记住,这些选项不一定是自身属性,也要考虑继承来的属性。为了确认保留这些默认值,在设置之前,可能要冻结 Object.prototype,明确指定所有的选项,或者通过 Object.create(null)__proto__ 属性指向 null

// 使用 __proto__
var obj = {};
var descriptor = Object.create(null); // 没有继承的属性
// 默认没有 enumerable,没有 configurable,没有 writable
descriptor.value = 'static';
Object.defineProperty(obj, 'key', descriptor);

// 显式
Object.defineProperty(obj, "key", {
    enumerable: false,
    configurable: false,
    writable: false,
    value: "static"
});

// 循环使用同一对象
function withValue(value) {
    var d = withValue.d || (
        withValue.d = {
        enumerable: false,
        writable: false,
        configurable: false,
        value: null
        }
    );
    d.value = value;
    return d;
}
// ... 并且 ...
Object.defineProperty(obj, "key", withValue("static"));

// 如果 freeze 可用, 防止后续代码添加或删除对象原型的属性
// (value, get, set, enumerable, writable, configurable)
(Object.freeze||Object)(Object.prototype);

示例

创建属性:

如果对象中不存在指定的属性,Object.defineProperty() 会创建这个属性。当描述符中省略某些字段时,这些字段将使用它们的默认值。

var o = {}; // 创建一个新对象

// 在对象中添加一个属性与数据描述符的示例
Object.defineProperty(o, "a", {
    value : 37,
    writable : true,
    enumerable : true,
    configurable : true
});

// 对象 o 拥有了属性 a,值为 37

// 在对象中添加一个设置了存取描述符属性的示例
var bValue;
Object.defineProperty(o, "b", {
    // 使用了方法名称缩写(ES2015 特性)
    // 下面两个缩写等价于:
    // get : function() { return bValue; },
    // set : function(newValue) { bValue = newValue; },
    get() { return bValue; },
    set(newValue) { bValue = newValue; },
    enumerable : true,
    configurable : true
});

o.b; // 38
// 对象 o 拥有了属性 b,值为 38
// 现在,除非重新定义 o.b,o.b 的值总是与 bValue 相同

// 数据描述符和存取描述符不能混合使用
Object.defineProperty(o, "conflict", {
    value: 0x9f91102,
    get() { return 0xdeadbeef; } 
});
// 抛出错误 TypeError: value appears only in data descriptors, get appears only in accessor descriptors

修改属性:

如果属性已经存在,Object.defineProperty()将尝试根据描述符中的值以及对象当前的配置来修改这个属性。如果旧描述符将其configurable 属性设置为false,则该属性被认为是“不可配置的”,并且没有属性可以被改变(除了单向改变 writable 为 false)。当属性不可配置时,不能在数据和访问器属性类型之间切换。

当试图改变不可配置属性(除了 valuewritable 属性之外)的值时,会抛出TypeError,除非当前值和新值相同。

①Writable 属性

writable 属性设置为 false 时,该属性被称为“不可写的”。它不能被重新赋值。

var o = {}; // 创建一个新对象

Object.defineProperty(o, 'a', {
    value: 37,
    writable: false
});

console.log(o.a); // logs 37
o.a = 25; // No error thrown
// (it would throw in strict mode,
// even if the value had been the same)
console.log(o.a); // logs 37. The assignment didn't work.

// strict mode
(function() {
    'use strict';
    var o = {};
    Object.defineProperty(o, 'b', {
        value: 2,
        writable: false
    });
    o.b = 3; // throws TypeError: "b" is read-only
    return o.b; // returns 2 without the line above
}());

如示例所示,试图写入非可写属性不会改变它,也不会引发错误。

②Enumerable 属性

enumerable 定义了对象的属性是否可以在 for...in循环和 Object.keys()中被枚举。

var o = {};
Object.defineProperty(o, "a", { value : 1, enumerable: true });
Object.defineProperty(o, "b", { value : 2, enumerable: false });
Object.defineProperty(o, "c", { value : 3 }); // enumerable 默认为 false
o.d = 4; // 如果使用直接赋值的方式创建对象的属性,则 enumerable 为 true
Object.defineProperty(o, Symbol.for('e'), {
    value: 5,
    enumerable: true
});
Object.defineProperty(o, Symbol.for('f'), {
    value: 6,
    enumerable: false
});

for (var i in o) {
    console.log(i);
}
// logs 'a' and 'd' (in undefined order)

Object.keys(o); // ['a', 'd']

o.propertyIsEnumerable('a'); // true
o.propertyIsEnumerable('b'); // false
o.propertyIsEnumerable('c'); // false
o.propertyIsEnumerable('d'); // true
o.propertyIsEnumerable(Symbol.for('e')); // true
o.propertyIsEnumerable(Symbol.for('f')); // false

var p = { ...o }
p.a // 1
p.b // undefined
p.c // undefined
p.d // 4
p[Symbol.for('e')] // 5
p[Symbol.for('f')] // undefined

③Configurable 属性

configurable 特性表示对象的属性是否可以被删除,以及除 valuewritable 特性外的其他特性是否可以被修改。

var o = {};
Object.defineProperty(o, 'a', {
    get() { return 1; },
    configurable: false
});

Object.defineProperty(o, 'a', {
    configurable: true
}); // throws a TypeError
Object.defineProperty(o, 'a', {
    enumerable: true
}); // throws a TypeError
Object.defineProperty(o, 'a', {
    set() {}
}); // throws a TypeError (set was undefined previously)
Object.defineProperty(o, 'a', {
    get() { return 1; }
}); // throws a TypeError
// (even though the new get does exactly the same thing)
Object.defineProperty(o, 'a', {
    value: 12
}); // throws a TypeError // ('value' can be changed when 'configurable' is false but not in this case due to 'get' accessor)

console.log(o.a); // logs 1
delete o.a; // Nothing happens
console.log(o.a); // logs 1

如果 o.aconfigurable 属性为 true,则不会抛出任何错误,并且,最后,该属性会被删除。

添加多个属性和默认值:

考虑特性被赋予的默认特性值非常重要,通常,使用点运算符和 Object.defineProperty() 为对象的属性赋值时,数据描述符中的属性默认值是不同的,如下例所示。

var o = {};

o.a = 1;
// 等同于:
Object.defineProperty(o, "a", {
    value: 1,
    writable: true,
    configurable: true,
    enumerable: true
});


// 另一方面,
Object.defineProperty(o, "a", { value : 1 });
// 等同于:
Object.defineProperty(o, "a", {
    value: 1,
    writable: false,
    configurable: false,
    enumerable: false
});

自定义 Setters 和 Getters:

下面的例子展示了如何实现一个自存档对象。当设置temperature 属性时,archive 数组会收到日志条目。

function Archiver() {
    var temperature = null;
    var archive = [];

    Object.defineProperty(this, 'temperature', {
        get: function() {
        console.log('get!');
        return temperature;
        },
        set: function(value) {
        temperature = value;
        archive.push({ val: temperature });
        }
    });

    this.getArchive = function() { return archive; };
}

var arc = new Archiver();
arc.temperature; // 'get!'
arc.temperature = 11;
arc.temperature = 13;
arc.getArchive(); // [{ val: 11 }, { val: 13 }]

下面这个例子中,getter 总是会返回一个相同的值。

var pattern = {
    get: function () {
        return 'I alway return this string,whatever you have assigned';
    },
    set: function () {
        this.myname = 'this is my name string';
    }
};


function TestDefineSetAndGet() {
    Object.defineProperty(this, 'myproperty', pattern);
}


var instance = new TestDefineSetAndGet();
instance.myproperty = 'test';

// 'I alway return this string,whatever you have assigned'
console.log(instance.myproperty);
// 'this is my name string'
console.log(instance.myname);继承属性

继承属性:

如果访问者的属性是被继承的,它的 getset 方法会在子对象的属性被访问或者修改时被调用。如果这些方法用一个变量存值,该值会被所有对象共享。

function myclass() {
}

var value;
Object.defineProperty(myclass.prototype, "x", {
  get() {
    return value;
  },
  set(x) {
    value = x;
  }
});

var a = new myclass();
var b = new myclass();
a.x = 1;
console.log(b.x); // 1

这可以通过将值存储在另一个属性中解决。在 getset 方法中,this 指向某个被访问和修改属性的对象。

function myclass() {
}

Object.defineProperty(myclass.prototype, "x", {
  get() {
    return this.stored_x;
  },
  set(x) {
    this.stored_x = x;
  }
});

var a = new myclass();
var b = new myclass();
a.x = 1;
console.log(b.x); // undefined

不像访问者属性,值属性始终在对象自身上设置,而不是一个原型。然而,如果一个不可写的属性被继承,它仍然可以防止修改对象的属性。

function myclass() {
}

myclass.prototype.x = 1;
Object.defineProperty(myclass.prototype, "y", {
    writable: false,
    value: 1
});

var a = new myclass();
a.x = 2;
console.log(a.x); // 2
console.log(myclass.prototype.x); // 1
a.y = 2; // Ignored, throws in strict mode
console.log(a.y); // 1
console.log(myclass.prototype.y); // 1

兼容性问题

重定义数组 Array 对象的 length 属性:

重定义数组的 length 属性是可能的,但是会受到一般的重定义限制。(length 属性初始为 non-configurable,non-enumerable 以及 writable。对于一个内容不变的数组,改变其 length属性的值或者使它变为 non-writable 是可能的。但是改变其可枚举性和可配置性或者当它是 non-writable 时尝试改变它的值或是可写性,这两者都是不允许的。)然而,并不是所有的浏览器都允许 Array.length 的重定义。

在 Firefox 4 至 22 版本中,尝试重定义数组的 length 属性都会抛出 TypeError异常。

一些版本的 Chrome 中,Object.defineProperty() 在某些情况下会忽略不同于数组当前length属性的length值。有些情况下改变可写性并不起作用(也不抛出异常)。同时,比如Array.prototype.push的一些数组操作方法也不会考虑不可读的length属性。

一些版本的 Safari 中,Object.defineProperty() 在某些情况下会忽略不同于数组当前length属性的length值。尝试改变可写性的操作会正常执行而不抛出错误,但事实上并未改变属性的可写性。

只在Internet Explorer 9及以后版本和Firefox 23及以后版本中,才完整地正确地支持数组 length属性的重新定义。目前不要依赖于重定义数组 length属性能够起作用,或在特定情形下起作用。与此同时,即使你能够依赖于它,你也没有合适的理由这样做。

2.typeof()

typeof操作符是一个运算符,返回一个字符串,表示未经计算的操作数的类型。有2种使用方式:typeof(表达式)和typeof 变量名,第一种是对表达式做运算,第二种是对变量做运算。

语法

typeof 运算符后接操作数:

typeof operand
typeof(operand)

描述

下表总结了 typeof 可能的返回值。有关类型和原始值的更多信息,可查看 JavaScript 数据结构 页面。

类型 结果
Undefined "undefined"
Null "object"
Boolean "boolean"
Number "number"
BigInt "bigint"
String "string"
Symbol(ECMAScript 2015 新增) "symbol"
宿主对象(由 JS 环境提供) 取决于具体实现
Function 对象 (按照 ECMA-262 规范实现 [[Call]]) "function"
其他任何对象 "object"
// 数值
typeof 37 === 'number';
typeof 3.14 === 'number';
typeof(42) === 'number';
typeof Math.LN2 === 'number';
typeof Infinity === 'number';
typeof NaN === 'number'; // 尽管它是 "Not-A-Number" (非数值) 的缩写
typeof Number(1) === 'number'; // Number 会尝试把参数解析成数值

typeof 42n === 'bigint';


// 字符串
typeof '' === 'string';
typeof 'bla' === 'string';
typeof `template literal` === 'string';
typeof '1' === 'string'; // 注意内容为数字的字符串仍是字符串
typeof (typeof 1) === 'string'; // typeof 总是返回一个字符串
typeof String(1) === 'string'; // String 将任意值转换为字符串,比 toString 更安全


// 布尔值
typeof true === 'boolean';
typeof false === 'boolean';
typeof Boolean(1) === 'boolean'; // Boolean() 会基于参数是真值还是虚值进行转换
typeof !!(1) === 'boolean'; // 两次调用 ! (逻辑非) 操作符相当于 Boolean()


// Symbols
typeof Symbol() === 'symbol';
typeof Symbol('foo') === 'symbol';
typeof Symbol.iterator === 'symbol';


// Undefined
typeof undefined === 'undefined';
typeof declaredButUndefinedVariable === 'undefined';
typeof undeclaredVariable === 'undefined'; 


// 对象
typeof {a: 1} === 'object';

// 使用 Array.isArray 或者 Object.prototype.toString.call
// 区分数组和普通对象
typeof [1, 2, 4] === 'object';

typeof new Date() === 'object';
typeof /regex/ === 'object'; // 历史结果请参阅正则表达式部分


// 下面的例子令人迷惑,非常危险,没有用处。避免使用它们。
typeof new Boolean(true) === 'object';
typeof new Number(1) === 'object';
typeof new String('abc') === 'object';

// 函数
typeof function() {} === 'function';
typeof class C {} === 'function'
typeof Math.sin === 'function';

附加信息

// JavaScript 诞生以来便如此
typeof null === 'object';

在 JavaScript 最初的实现中,JavaScript 中的值是由一个表示类型的标签和实际数据值表示的。对象的类型标签是 0。由于 null 代表的是空指针(大多数平台下值为 0x00),因此,null 的类型标签是 0,typeof null 也因此返回 "object"

曾有一个 ECMAScript 的修复提案(通过选择性加入的方式),但被拒绝了。该提案会导致 typeof null === 'null'

使用 new 操作符:

// 除 Function 外的所有构造函数的类型都是 'object'
var str = new String('String');
var num = new Number(100);

typeof str; // 返回 'object'
typeof num; // 返回 'object'

var func = new Function();

typeof func; // 返回 'function'

语法中的括号:

// 括号有无将决定表达式的类型。
var iData = 99;

typeof iData + ' Wisen'; // 'number Wisen'
typeof (iData + ' Wisen'); // 'string'

正则表达式:

对正则表达式字面量的类型判断在某些浏览器中不符合标准:

typeof /s/ === 'function'; // Chrome 1-12 , 不符合 ECMAScript 5.1
typeof /s/ === 'object'; // Firefox 5+ , 符合 ECMAScript 5.1

错误:

在 ECMAScript 2015 之前,typeof 总能保证对任何所给的操作数返回一个字符串。即便是没有声明的标识符,typeof 也能返回 'undefined'。使用 typeof 永远不会抛出错误。

但在加入了块级作用域的letconst之后,在其被声明之前对块中的 letconst 变量使用 typeof 会抛出一个 ReferenceError。块作用域变量在块的头部处于“暂存死区”,直至其被初始化,在这期间,访问变量将会引发错误。

typeof undeclaredVariable === 'undefined';

typeof newLetVariable; // ReferenceError
typeof newConstVariable; // ReferenceError
typeof newClass; // ReferenceError

let newLetVariable;
const newConstVariable = 'hello';
class newClass{};

例外:

当前所有的浏览器都暴露了一个类型为 undefined 的非标准宿主对象 document.all

typeof document.all === 'undefined';

尽管规范允许为非标准的外来对象自定义类型标签,但它要求这些类型标签与已有的不同。document.all 的类型标签为 'undefined' 的例子在 Web 领域中被归类为对原 ECMA JavaScript 标准的“故意侵犯”。

总结:typeof运算符用于判断对象的类型,但是对于一些创建的对象,它们都会返回’object’,有时我们需要判断该实例是否为某个对象的实例,那么这个时候需要用到instanceof运算符,后续记录instanceof运算符的相关用法。

3.Object.keys()

Object.keys() 方法会返回一个由一个给定对象的自身可枚举属性组成的数组,数组中属性名的排列顺序和正常循环遍历该对象时返回的顺序一致 。

语法:

Object.keys(obj)

参数:

要返回其枚举自身属性的对象。

返回值:

一个表示给定对象的所有可枚举属性的字符串数组。

描述

Object.keys 返回一个所有元素为字符串的数组,其元素来自于从给定的object上面可直接枚举的属性。这些属性的顺序与手动遍历该对象属性时的一致。

// simple array
var arr = ['a', 'b', 'c'];
console.log(Object.keys(arr)); // console: ['0', '1', '2']

// array like object
var obj = {
    0: 'a',
    1: 'b',
    2: 'c'
};
console.log(Object.keys(obj)); // console: ['0', '1', '2']

// array like object with random key ordering
var anObj = {
    100: 'a',
    2: 'b',
    7: 'c'
};
console.log(Object.keys(anObj)); // console: ['2', '7', '100']

// getFoo is a property which isn't enumerable
var myObj = Object.create({}, {
    getFoo: {
        value: function() {
            return this.foo;
        }
    }
});
myObj.foo = 1;
console.log(Object.keys(myObj)); // console: ['foo']

注意:如果你想获取一个对象的所有属性,甚至包括不可枚举的,请查看Object.getOwnPropertyNames()

在ES5里,如果此方法的参数不是对象(而是一个原始值),那么它会抛出 TypeError。在ES2015中,非对象的参数将被强制转换为一个对象。

Object.keys("foo");
// TypeError: "foo" is not an object (ES5 code)

Object.keys("foo");
// ["0", "1", "2"]                   (ES2015 code)

要在原生不支持的旧环境中添加兼容的Object.keys,请复制以下代码段:

if (!Object.keys) {
  Object.keys = (function () {
    var hasOwnProperty = Object.prototype.hasOwnProperty,
        hasDontEnumBug = !({toString: null}).propertyIsEnumerable('toString'),
        dontEnums = [
          'toString',
          'toLocaleString',
          'valueOf',
          'hasOwnProperty',
          'isPrototypeOf',
          'propertyIsEnumerable',
          'constructor'
        ],
        dontEnumsLength = dontEnums.length;

    return function (obj) {
      if (typeof obj !== 'object' && typeof obj !== 'function' || obj === null) throw new TypeError('Object.keys called on non-object');

      var result = [];

      for (var prop in obj) {
        if (hasOwnProperty.call(obj, prop)) result.push(prop);
      }

      if (hasDontEnumBug) {
        for (var i=0; i < dontEnumsLength; i++) {
          if (hasOwnProperty.call(obj, dontEnums[i])) result.push(dontEnums[i]);
        }
      }
      return result;
    }
  })()
};

上面的代码在IE7(也许IE8也是)下有个问题,就是如果传入一个来自其他 window 对象下的对象时,不可枚举的属性也会获取到。

4.Object.getOwnPropertyNames()

Object.getOwnPropertyNames()方法返回一个由指定对象的所有自身属性的属性名(包括不可枚举属性但不包括Symbol值作为名称的属性)组成的数组。

语法

Object.getOwnPropertyNames(obj)

参数:

一个对象,其自身的可枚举和不可枚举属性的名称被返回。

返回值:

在给定对象上找到的自身属性对应的字符串数组。

描述

Object.getOwnPropertyNames() 返回一个数组,该数组对元素是 obj自身拥有的枚举或不可枚举属性名称字符串。 数组中枚举属性的顺序与通过 for...in循环(或 Object.keys)迭代该对象属性时一致。数组中不可枚举属性的顺序未定义。

var arr = ["a", "b", "c"];
console.log(Object.getOwnPropertyNames(arr).sort()); // ["0", "1", "2", "length"]

// 类数组对象
var obj = { 0: "a", 1: "b", 2: "c"};
console.log(Object.getOwnPropertyNames(obj).sort()); // ["0", "1", "2"]

// 使用Array.forEach输出属性名和属性值
Object.getOwnPropertyNames(obj).forEach(function(val, idx, array) {
  console.log(val + " -> " + obj[val]);
});
// 输出
// 0 -> a
// 1 -> b
// 2 -> c

//不可枚举属性
var my_obj = Object.create({}, {
  getFoo: {
    value: function() { return this.foo; },
    enumerable: false
  }
});
my_obj.foo = 1;

console.log(Object.getOwnPropertyNames(my_obj).sort()); // ["foo", "getFoo"]

如果你只要获取到可枚举属性,查看Object.keys或用for...in循环(还会获取到原型链上的可枚举属性,不过可以使用hasOwnProperty()方法过滤掉)。

下面的例子演示了该方法不会获取到原型链上的属性:

function ParentClass() {}
ParentClass.prototype.inheritedMethod = function() {};

function ChildClass() {
  this.prop = 5;
  this.method = function() {};
}

ChildClass.prototype = new ParentClass;
ChildClass.prototype.prototypeMethod = function() {};

console.log(
  Object.getOwnPropertyNames(
    new ChildClass()  // ["prop", "method"]
  )
);

只获取不可枚举的属性:

下面的例子使用了 Array.prototype.filter()方法,从所有的属性名数组(使用Object.getOwnPropertyNames()方法获得)中去除可枚举的属性(使用Object.keys()方法获得),剩余的属性便是不可枚举的属性了:

var target = myObject;
var enum_and_nonenum = Object.getOwnPropertyNames(target);
var enum_only = Object.keys(target);
var nonenum_only = enum_and_nonenum.filter(function(key) {
    var indexInEnum = enum_only.indexOf(key);
    if (indexInEnum == -1) {
        // 没有发现在enum_only健集中意味着这个健是不可枚举的,
        // 因此返回true 以便让它保持在过滤结果中
        return true;
    } else {
        return false;
    }
});

console.log(nonenum_only);
//注:Array.filter(filt_func)方法创建一个新数组, 其包含通过所提供函数实现的测试的所有元素。

提示

在 ES5 中,如果参数不是一个原始对象类型,将抛出一个 TypeError异常。在 ES2015 中,非对象参数被强制转换为对象。

Object.getOwnPropertyNames('foo');
// TypeError: "foo" is not an object (ES5 code)

Object.getOwnPropertyNames('foo');
// ['length', '0', '1', '2']  (ES2015 code)

本文完。


文章作者: 弈心
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 弈心 !
评论
  目录