Extreme Thinking
深入理解JS里的对象

2015-04-17


Table of Content:

    JS里一切皆对象,理解对象是理解js语言的关键,下面从几个角度解释js里的对象。

    封装,继承,多态

    在C++/Java等传统面向对象编程中,类(class)是对象(object)的模板,class不占用内存空间,object占用内存,object也称为class instance,同一个class可以被new出很多个object。

    Javascript语言不支持"类",但Javascript仍然是面向对象语言,面向对象的3要素在JavaScript里都支持:

    • 封装 - 数据与方法封装在一起,方法可以操作数据,这就是JS里的object
    • 继承 - 新创建的对象可以继承父对象的数据和方法,js里有多种方法实现继承,譬如原型方式继承,拷贝继承等
    • 多态 - 一个接口有多种实现

    实际上,js里一切皆对象。

    什么是对象?

    js里的对象(object)就是一组键值(name-value)的集合, name总是string类型, value可以是各种类型, 可以是基本数据类型, 可以是数组或其他对象, 也可以是函数, object很像是一个hash map. object里的name-value是无序的。

    对象是动态的,可以动态地增加属性和方法。

    创建对象的方式

    C++/Java等语言里创建object是使用"new YourClassName()"的方式,js里没有类,可以使用下面几种方式创建object:

    1) 字面量:

    var person = {firstName:"John", lastName:"Doe", age:50, eyeColor:"blue"};
    

    属性名可以加引号也可以不加引号,如果属性名有空格,则必须加引号。

    2) 使用new:

    var person = new Object();
    person.firstName = "John";
    person.lastName = "Doe";
    person.age = 50;
    person.eyeColor = "blue";
    

    这种方式也可以创建其他js内建对象: Function, String, Number, Date, RegExp等,但这种方式没有方法1效率高,因此不推荐.

    3) new构造函数:

    function Person(first, last, age, eyecolor) {
        this.firstName = first;
        this.lastName = last;
        this.age = age;
        this.eyeColor = eyecolor;
    }
    var myFather = new Person("John", "Doe", 50, "blue");
    var myMother = new Person("Sally", "Rally", 48, "green");
    

    这一方法其实和方法2是类似的,方法2里new调用的是js内建的构造函数,这里new调用的是自己写的构造函数。

    js里构造函数和普通函数没有任何区别,因此为了区分这是一个构造函数,一般在命名上区分,构造函数使用驼峰式命名,普通函数使用半驼峰式命名。

    4) Object.create()

    这是ECMAScript 5新定义的方式,假设已经有方法1里的person对象,可以创建新的对象:

    var myself = Object.create(myFather);
    

    这种方式是让一个现有的object(myFather)变成新创建对象(myself)的原型。

    如果浏览器不支持Object.create()方法,可以用下面的方法模拟:

    if (!Object.create) {
        Object.create = function (o) {
            function F() {}
            F.prototype = o;
            return new F();
        };
    }
    

    方法4和方法3是不同的,方法3里创建的对象(myFather)拥有自己的属性(firstName, lastName等),方法4里创建的对象(myself)没有自己的属性,虽然通过myself可以访问到firstName,lastName等属性,但这些属性属于其原型对象myFather.

    访问对象属性

    访问对象包括访问对象属性和调用对象方法。

    1) 读取属性

    访问对象非常方便,有如下几种方式:

    var array = [];
    
    array.push(123);    //方法1: 使用.访问
    array["push"](456); //方法2: 使用[]访问
    

    方法1比较直观,但当属性名是一个变量时,方法2会比较有用。

    2) 判断属性是否存在

    判断对象person1是否有属性name:

    if (person1.name) {}                   //method 1: use if
    if ("name" in person1) {}              //method 2: use in
    if (person1.hasOwnProperty("name")) {} //method 3: use hasOwnProperty()
    

    说明:

    • 方法1: 如果person1有属性name,但name的值为null, undefined, 0, false, NaN, ''时,会返回false
    • 方法2: 如果name是person1原型对象里的属性,in也会返回true
    • 方法3: hasOwnProperty判断最严格,只有person1本身拥有属性name才会返回true

    3) 删除属性

    使用delete可以删除属性:

    console.log("name" in person1); //true
    delete person1.name;
    console.log("name" in person1); //false
    

    4) 枚举属性列表

    使用for-in loop可以循环对象里的所有属性:

    for (var property in person1) {
        console.log("Name: " + property);
        console.log("Value: " + person1[property]);
    }
    

    ECMAScript5里提供了一个新方法 Object.keys():

    var properties = Object.keys(person1);
    var i, len;
    for (i=0, len=properties.length; i < len; i++){
        console.log("Name: " + properties[i]);
        console.log("Value: " + person1[properties[i]]);
    }
    

    注意:for-in loop会返回原型对象里的属性,Object.keys()只会返回own properties

    属性分类与详解

    属性分类

    我们平时使用的基本都是data properties,js对象里实际上有3种类型的属性:

    • 命名数据属性(named data properties) - 拥有一个确定的值的属性, 这也是最常见的属性.
    • 命名访问器属性(named accessor properties) - 通过getter和setter进行读取和赋值的属性.
    • 内部属性(internal properties) - 由js引擎内部使用的属性,不能通过js代码直接访问到,不过可以通过一些方法间接的读取和设置.

    访问器属性

    我们可以为accessor properties定义getter方法和setter方法,如果只有getter,说明这是只读的,如果只有setter,说明这是只写的。

    var person1 = {
        _name: "Nicholas",
        get name() {
            console.log("Reading name");
            return this._name;
        },
        set name(value) {
            console.log("Setting name to ", value);
            this._name = value;
        }
    };
    console.log(person1.name); // 打印"Reading name" 和 "Nicholas"
    person1.name = "Greg";     // 打印"Setting name to Greg"
    console.log(person1.name); // 打印"Reading name" 和 "Greg"
    

    内部属性

    由js引擎内部使用的属性,不能通过js代码直接访问到,不过可以通过一些方法间接的读取和设置, 比如:每个对象都有一个内部属性[[Prototype]], 你不能直接访问这个属性, 但可以通过Object.getPrototypeOf()方法间接的读取到它的值.虽然内部属性通常用一个双中括号包围的名称来表示,但实际上这并不是它们的名字,它们是一种抽象操作,是不可见的,根本没有上面两种属性有的那种字符串类型的属性名.

    其他的内部属性还有: [[Extensible]], [[DefineOwnProperty]], [[Put]], 以及function特有的内部属性 [[Call]]

    属性特性

    每个属性(property)都拥有4个特性(attribute).数据属性和访问器属性一共有6种属性特性:

    • 数据属性特有的特性:
      • [[Value]]: 属性的值.
      • [[Writable]]: 控制属性的值是否可以改变.
    • 访问器属性特有的特性:
      • [[Get]]: 存储着getter方法.
      • [[Set]]: 存储着setter方法.
    • 两种属性都有的特性:
      • [[Enumerable]]: 如果一个属性是不可枚举的,则在一些操作下,这个属性是不可见的,比如for...in和Object.keys()
      • [[Configurable]]: 如果一个属性是不可配置的,则该属性的所有特性(除了%%[[Value]]%%)都不可改变

    防止对象被修改

    js对象很容易被修改,但有时我们希望对象不被修改,有如下几种方法:

    1) Object.preventExtensions()

    var person1 = {
        name: "Nicholas"
    };
    
    console.log(Object.isExtensible(person1)); //true
    
    Object.preventExtensions(person1);
    
    console.log(Object.isExtensible(person1)); //false
    
    person1.age = 50;
    console.log("age" in person1); //false
    

    在strict模式下,如果试图向non-extensible的对象增加属性,程序会抛出异常。

    2) Object.seal()

    seal是密封/封口/盖章/封上信封的意思:

    var person1 = {
        name: "Nicholas"
    };
    
    console.log(Object.isExtensible(person1)); //true
    console.log(Object.isSealed(person1)); //false
    
    Object.seal(person1);
    
    console.log(Object.isExtensible(person1)); //false
    console.log(Object.isSealed(person1)); //true
    
    person1.age = 50;
    console.log("age" in person1); //false
    

    3) Object.freeze()

    var person1 = {
        name: "Nicholas"
    };
    
    console.log(Object.isExtensible(person1)); //true
    console.log(Object.isSealed(person1)); //false
    console.log(Object.isFrozen(person1)); //false
    
    Object.freeze(person1);
    
    console.log(Object.isExtensible(person1)); //false
    console.log(Object.isSealed(person1)); //true
    console.log(Object.isFrozen(person1)); //true
    
    person1.age = 50;
    console.log("age" in person1); //false
    

    new操作符的本质

    JS没有类,但有new操作符,new操作符可以用于调用构造函数,并改变了构造函数里的this:

    function Person(first, last, age, eyecolor) {
        this.firstName = first;
        this.lastName = last;
        this.age = age;
        this.eyeColor = eyecolor;
    }
    var myFather = new Person("John", "Doe", 50, "blue");
    

    上面的代码实际上类似于:

    // create an empty object
    var myMother = {}; 
    // call the function as a method of the empty object
    Person.call(myMother, "Sally", "Rally", 48, "green");
    

    所以,new操作符的本质就是,new其实就是创建了一个空对象,然后以这个空对象为context(即this)来调用构造函数,然后构造函数里的赋值语句就动态的给空对象增加了属性和方法。

    注意:上面的方法虽然和new很类似,但和new还是有区别的,myFather的原型对象是Person.prototype,而myMother的原型对象是Object.prototype

    对象的原型[[prototype]]

    在js中所有对象都有一个隐含的属性[[prototype]]指向其原型对象,原型对象也有自己的原型,如此下去便形成一个原型链,所有对象的原型链的顶层都是Object.prototype.

    请注意,[[prototype]]并不是一个真实的属性名,因此无法通过这个属性名获得原型对象,但js提供了方法来读取和判断对象的原型:

    // 接前面的例子
    console.log(Object.getPrototypeOf(myFather) === Person.prototype); //true
    console.log(Person.prototype.isPrototypeOf(myFather)); //true
    console.log(Person.prototype.isPrototypeOf(myMother)); //false
    

    对象的原型链通常是只读的,用户无法修改某个对象的原型,所以无法修改对象的继承关系。

    属性__proto__

    在js规范里,对象原型通常是不可见的属性,因此无法直接访问。但某些浏览器里支持__proto__属性,firefox/chrome/safari/nodejs都支持__proto__属性,在这些浏览器里对象原型是可见的,可以直接访问对象的__proto__属性得到对象原型,也可以通过修改对象的__proto__属性来修改对象的原型链。

    ECMAScript 6正在讨论把__proto__属性标准化,但目前属性__proto__还不是标准。

    本文接下来提到的__proto__, [[prototype]]都是指一个意思,即自身对象里指向其原型对象的属性。

    prototype

    函数也是对象,所以函数也有属性__proto__,通过字面量声明的函数其原型对象是Function.prototype,当然Function.prototype的原型对象是Object.prototype.

    函数还有一个特有的属性prototype,每个函数都有一个prototype属性(特别注意,prototype属性是函数对象特有的属性,不要和js中每个对象到其原型的连接相混淆,那个是隐藏的,只是在firefox/chrome等浏览器中你可以使用__proto__访问到)。

    __proto__, prototype, constructor的关系

    下面的例子解释了__proto__,prototype,constructor三者的关系:

    function Person(name) {
        this.name = name;
    }
    Person.prototype.sayName = function() { 
        console.log(this.name);
    };
    
    var person1 = new Person("Nicholas");
    var person2 = new Person("Greg");
    

    上述代码对应的内存模型如下:

    person1
    --------
    __proto__ -----------            -------------------------------------
    name:"Nicholas"      |           |                                    |
                         |-->  Person.prorotype                           |
    person2              |     -----------------                          |
    --------             |     constructor --------------> Person         |
    __proto__ -----------      sayName:function()          ---------      |
    name:"Greg"                __proto__ -->               prototype  ---->
                                           |               __proto__  --->
                                           V                             |
                                    Object.prototype                     v
                                           ^                     Function.prototype
                                           |                     ------------------
                                           <-------------------- __proto__
    

    Person是构造函数(当然也是对象),new出来的person1和person2这两个对象都有一个属性__proto__指向Person.prototype对象,Person.prorotype对象有一个属性cnstructor指向Person对象,Person对象的prorotype属性对应的就是Person.prorotype对象。

    参考阅读: https://ruby-china.org/topics/17164 - JavaScript 的对象本质(更适合有 java、c++、c# 等背景的)

    Object.prototype

    js里所有对象的原型链的最顶端对象都是Object.prototype, 也就是说, 所有js对象都具有Object.prototype对象里的属性和方法, 参考 Object.prototype