【译】《Understanding ECMAScript6》- 第三章-Object

时间:2022-04-25
本文章向大家介绍【译】《Understanding ECMAScript6》- 第三章-Object,主要内容包括目录、Object分类、Object字面量扩展、函数初始化的缩写模式、计算属性名、Object.assign()、重复属性、改变原型、super引用、方法、总结、基本概念、基础应用、原理机制和需要注意的事项等,并结合实例形式分析了其使用技巧,希望通过本文能帮助到大家理解应用这部分内容。

目录

ES6针对Object的改进,旨在使JavaScript语言更加接近“万物皆对象”的理念。随着越来越多地使用Object类型进行开发,开发者们越来越不满足于Object相对低下的开发效率。

ES6通过多种途径对Object进行了改进,包括语法的调整、以及新的操作和交互方式等。

Object分类

JavaScript中的Object有很多不同的类别,比如自定义的对象和语言内置的对象,很容易产生混淆。为了更精确地区分不同类别的对象,ES6引入了几个新的术语,这些术语将Object的类别具体为以下几种;

  • 普通对象(Ordinary objects)是指具备JavaScript对象所有默认行为的对象;
  • 怪异对象(Exotic objects)是指某些行为与默认行为不同的对象;
  • 标准对象(Standard objects)是指由ES6语言规范定义的对象,比如Array、Date等。标准对象可以是普通对象,也可以是怪异对象
  • 内置对象(Built-in objects)是指JavaScript运行环境定义的对象。所有的标准对象都是内置对象

本书将在后续的内容中详细讲述每种对象的具体细节。

Object字面量扩展

Object字面量表达式被广泛使用于JavaScript程序中,几乎所有的JavaScript应用程序中都可以找到这种模式。Object字面量有着类似JSON的简洁语法,这是它之所以如此流行的主要原因。ES6对Object字面量语法进行了扩展,保持语法简洁的前提下,也增强了功能性。

属性初始化的缩写模式

在ES5及其之前的版本中,Object字面量必须写成键值对的格式,这就意味着,在某些场景下会产生一些重复的语句,如下:

function createPerson(name, age) {
    return {
        name: name,
        age: age
    };
}

上述代码中的createPerson()函数创建了一个对象,这个对象的属性值与createPerson()的实参值相同。这会令许多开发者误认为对象的值是createPerson()实参的副本。

ES6中新增了Object字面量的简洁声明语法,可以一定程度上消除以上误解。如果对象的某个属性与一个本地变量同名,就可以在声明对象时只写这个属性的key,省略冒号和value。如下:

function createPerson(name, age) {
    return {
        name,
        age
    };
}

如果Object字面量的某个属性只有key没有value,JavaScript引擎便会在当前作用域内搜索是否存在与key同名的变量。如果存在,则将同名变量的值赋值为key对应的value。上述代码中的name属性对应的value就是本地变量name的值。

ES6新增这种机制的目的是令Object字面量语法更加简洁化。使用本地变量的值作为对象属性的value是一种很常见的模式,初始化属性的缩写模式可以令代码更加简洁。

函数初始化的缩写模式

ES6同样精简了对象内函数的声明语法。在ES6之前,开发者必须按照键值对的格式声明对象内的函数,如下:

var person = {
    name: "Nicholas",
    sayName: function() {
        console.log(this.name);
    }
};

ES6中,可以省略冒号和function关键字,如下:

var person = {
    name: "Nicholas",
    sayName() {
        console.log(this.name);
    }
};

使用上述代码中的缩写模式声明的函数与上例中的作用完全相同。

计算属性名

JavaScript允许使用方括号计算对象的属性名,一方面令对象属性的操作更加动态化,另一方面避免了不能使用.直接访问的属性名引起的语法错误。如下:

var person = {},
    lastName = "last name";
person["first name"] = "Nicholas";
person[lastName] = "Zakas";
console.log(person["first name"]);      // "Nicholas"
console.log(person[lastName]);          // "Zakas"

上述代码中的person对象的两个属性名first namelast name都包含空格,无法直接使用.访问,只能通过方括号访问。方括号内可以包括字符串和变量。

ES5中可以使用字符串作为对象的属性名:

var person = {
    "first name": "Nicholas"
};
console.log(person["first name"]);      // "Nicholas"

使用字符串作为对象属性名的前提是在声明之前必须明确知道此字符串的值。如果此字符串并非固定值,比如需要根据变量值动态计算,这种场景下便不能使用上述的声明方式了。

为满足以上需求,ES6将方括号计算属性名的机制引入了Object字面量,如下:

var lastName = "last name";
var person = {
    "first name": "Nicholas",
    [lastName]: "Zakas"
};
console.log(person["first name"]);      // "Nicholas"
console.log(person[lastName]);          // "Zakas"

上述代码中,Object字面量内的方括号的作用是计算对象的属性名,其内部为字符串运算。你同样可以使用以下模式:

var suffix = " name";
var person = {
    ["first" + suffix]: "Nicholas",
    ["last" + suffix]: "Zakas"
};
console.log(person["first name"]);      // "Nicholas"
console.log(person["last name"]);       // "Zakas"

ES6中,方括号不仅可以在访问对象属性时计算属性名,同样可以在对象声明时计算属性名。

Object.assign()

mixin是组合对象常用的模式之一,本质是将一个对象的属性键值对克隆给另一个对象。很多JavaScript类库有类似如下的mixin函数:

function mixin(receiver, supplier) {
    Object.keys(supplier).forEach(function(key) {
        receiver[key] = supplier[key];
    });
    return receiver;
}

上述代码中的mixin()函数将supplier对象的自有属性(不包括原型链属性)克隆赋值给receiver对象。这种方式可以不通过继承实现receiver属性的扩展。请看如下示例:

function EventTarget() { /*...*/ }
EventTarget.prototype = {
    constructor: EventTarget,
    emit: function() { /*...*/ },
    on: function() { /*...*/ }
};
var myObject = {};
mixin(myObject, EventTarget.prototype);
myObject.emit("somethingChanged");

上述代码中,myObject对象通过克隆EventTarget.prototype的属性获取到了打印事件以及使用emit()on()函数的功能。

ES6新增的Object.assign()进一步加强了这种模式,并且更加语义化。上文提到的mixin()函数使用赋值运算符=进行属性克隆,这样的缺点是无法处理对象的存储器属性(后续章节详细讲述)。Object.assign()解决了这一问题。

不同的JavaScript类库实现mixin模式的函数取名迥异,其中extend()mix()是使用面很广泛的函数名。ES6初期除了Object.assign()以外,还曾引入了Object.mixin()方法。Object.mixin()可以克隆对象的存储器属性,但是由于super的引入(后续章节详细讲述),最终取消了Object.mixin()的使用。

Object.assign()可以取代上文提到的mixin()函数:

function EventTarget() { /*...*/ }
EventTarget.prototype = {
    constructor: EventTarget,
    emit: function() { /*...*/ },
    on: function() { /*...*/ }
}
var myObject = {}
Object.assign(myObject, EventTarget.prototype);
myObject.emit("somethingChanged");

Object.assign()可以接受任意数目的克隆源对象,克隆目标对象按照克隆源对象的顺序依次克隆。也就是说,队列后面的源对象属性会覆盖它前面的源对象同名属性。如下:

var receiver = {};
Object.assign(receiver, {
        type: "js",
        name: "file.js"
    }, {
        type: "css"
    }
);
console.log(receiver.type);     // "css"
console.log(receiver.name);     // "file.js"

上述代码最终的receiver.type值为css,因为第二个源对象覆盖了第一个源对象的同名属性。

Object.assign()对于ES6来说,并不是一个革命性的功能,但是它规范了mixin模式,而不必依赖于第三方类库。

存储器属性的处理

mixin模式下存储器属性是不能被完全克隆的,Object.assign()本质上是通过赋值运算符克隆属性,在处理存储器属性时,将源对象的存储器属性的运算结果克隆至目标对象。如下:

var receiver = {},
    supplier = {
        get name() {
            return "file.js"
        }
    };
Object.assign(receiver, supplier);
var descriptor = Object.getOwnPropertyDescriptor(receiver, "name");
console.log(descriptor.value);      // "file.js"
console.log(descriptor.get);        // undefined

上述代码中的源对象supplier有一个存储器属性name。使用Object.assign()之后,receiver被赋予一个值为"filter.js"的常规属性receiver.name。这是由于 supplier.name的运算结果为"filter.js"Object.assign()将运算结果克隆为receiver的一个常规属性。

重复属性

ES5严格模式下不允许Object字面量存在key值重复的属性,比如:

var person = {
    name: "Nicholas",
    name: "Greg"        // syntax error in ES5 strict mode
};

上述代码在ES5严格模式下会抛出语法错误。

ES6移除了重复属性的语法错误。不论是在非严格模式还是严格模式下,上例中的代码都不会抛错,而且后面的name属性值将覆盖前面的值。

var person = {
    name: "Nicholas",
    name: "Greg"        // not an error in ES6
};
console.log(person.name);       // "Greg"

上述代码person.name的取值为"Greg",因为name属性取的是最后一次赋值。

改变原型

原型是JavaScript实现继承的基础,ES6进一步加强了原型的作用。ES5中提供Object.getPrototypeOf()函数用来获取指定对象的原型。ES6引入了其逆向操作函数Object.setPrototypeOf()用来改变指定对象的原型。

不论是使用构造函数还是通过Object.create()创建的对象,它们的原型在创建的时候就被指定了。在ES6之前,并没有规范的方法改变对象的原型。Object.setPrototypeOf()打破了对象被创建后不能更改原型的规范,在此意义上,可以说Object.setPrototypeOf()是革命性的。

Object.setPrototypeOf()接收两个参数,第一个参数是被更改原型的对象,第二个参数是第一个参数被更改后的原型。如下:

let person = {
    getGreeting() {
        return "Hello";
    }
};
let dog = {
    getGreeting() {
        return "Woof";
    }
};
// prototype is person
let friend = Object.create(person);
console.log(friend.getGreeting());                      // "Hello"
console.log(Object.getPrototypeOf(friend) === person);  // true
// set prototype to dog
Object.setPrototypeOf(friend, dog);
console.log(friend.getGreeting());                      // "Woof"
console.log(Object.getPrototypeOf(friend) === dog);     // true

上述代码中定义了两个基础对象:persondog。两者都有一个getGreeting()函数。对象friend创建时继承自person,此时friend.getGreeting()的运算结果为”Hello“。然后通过Object.setPrototypeOf()函数将friend对象的原型更改为dog,此时friend.getGreeting()的运算结果为”woof“

一个对象的原型储存在内部隐藏属性[[Prototype]]中。Object.getPrototypeOf()函数返回[[Prototype]]的值,Object.setPrototypeOf()则是将[[Prototype]]的值改变为指定的value。除了上述两个函数以外,还有一些其他途径对[[Prototype]]进行操作。

在ES5之前就已经有少数JavaScript引擎实现了通过操作__proto__属性来获取和更改对象原型的方法。可以说__proto__Object.getPrototypeOf()Object.setPrototypeOf()的先行者。但是并非所有的JavaScript引擎都支持__proto__,所以ES6对此进行了规范。

在ES6中,Object.prototype.__proto__作为一个存储器属性,它的get方法为Object.getPrototypeOf()set方法为Object.setPrototypeOf()。所以,使用Object.getPrototypeOf()Object.setPrototypeOf()函数与直接操作__proto__本质上是相同的。如下:

let person = {
    getGreeting() {
        return "Hello";
    }
};
let dog = {
    getGreeting() {
        return "Woof";
    }
};
// prototype is person
let friend = {
    __proto__: person
};
console.log(friend.getGreeting());                      // "Hello"
console.log(Object.getPrototypeOf(friend) === person);  // true
console.log(friend.__proto__ === person);               // true
// set prototype to dog
friend.__proto__ = dog;
console.log(friend.getGreeting());                      // "Woof"
console.log(friend.__proto__ === dog);                  // true
console.log(Object.getPrototypeOf(friend) === dog);     // true

上述代码与前例的功能相同。唯一的区别是用Object字面量配合__proto__属性取代了Object.create()

__proto__属性有以下特性:

  1. 使用Object字面量声明时,__proto__属性只能被赋值一次。重复赋值会引起错误。__proto__是ES6中Object字面量中唯一有次限制的属性。
  2. 使用["__proto__"]访问对象属性时,方括号内的字符串只能作为一个常规的属性key,并不能操作__proto__属性。它是不适用于方括号计算对象属性key规则的少数属性之一。

操作__proto__属性时要时刻谨记上述规则。

super引用

如前文所述,原型是JavaScript中非常重要的环节,ES6针对原型新增了很多强化功能,super引用便是其中之一。举个例子,在ES5环境下,如果想复写一个对象原型的同名函数,你可能会选择类似下述代码的方式:

let person = {
    getGreeting() {
        return "Hello";
    }
};
let dog = {
    getGreeting() {
        return "Woof";
    }
};
// prototype is person
let friend = {
    __proto__: person,
    getGreeting() {
        // same as this.__proto__.getGreeting.call(this)
        return Object.getPrototypeOf(this).getGreeting.call(this) + ", hi!";
    }
};
console.log(friend.getGreeting());                      // "Hello, hi!"
console.log(Object.getPrototypeOf(friend) === person);  // true
console.log(friend.__proto__ === person);               // true
// set prototype to dog
friend.__proto__ = dog;
console.log(friend.getGreeting());                      // "Woof, hi!"
console.log(friend.__proto__ === dog);                  // true
console.log(Object.getPrototypeOf(friend) === dog);     // true

上述代码中,friend有一个与原型链中重名的getGreeting()。通过Object.getPrototypeOf()调用其原型的同名函数后追加字符串", hi!".call(this)确保原型函数中的作用域为friend

需要注意的是,Object.getPrototypeOf().call(this)必须配合使用。遗漏任何一方都可能引起问题。因此,ES6引入了super以简化这种操作。

简单来讲,super可以理解为一个指向当前对象原型的指针,等价于Object.getPrototypeOf(this)。所以,前例中的代码可以改写为以下形式:

let friend = {
    __proto__: person,
    getGreeting() {
        // in the previous example, this is the same as:
        // 1. Object.getPrototypeOf(this).getGreeting.call(this)
        // 2. this.__proto__.getGreeting.call(this)
        return super.getGreeting() + ", hi!";
    }
};

上述代码中的super.getGreeting()等价于Object.getPrototypeOf(this).getGreeting.call(this) 或者this.__proto__.getGreeting.call(this)

super的功能并不仅限于此。比如在多重继承的场景下,Object.getPrototypeOf()并不能满足需求,如下:

let person = {
    getGreeting() {
        return "Hello";
    }
};

// prototype is person
let friend = {
    __proto__: person,
    getGreeting() {
        return Object.getPrototypeOf(this).getGreeting.call(this) + ", hi!";
    }
};

// prototype is friend
let relative = {
    __proto__: friend
};

console.log(person.getGreeting());                  // "Hello"
console.log(friend.getGreeting());                  // "Hello, hi!"
console.log(relative.getGreeting());                // error!

上述代码中,执行relative.getGreeting()Object.getPrototypeOf()会报错。因为此时的this指向的是relativerelative的原型为friend。所以当friend.getGreeting().call()this被指定为relative时相当于执行relative.getGreeting(),形成了无限递归的死循环,直到堆栈溢出。使用super可以很轻易的化解这种问题。如下:

let person = {
    getGreeting() {
        return "Hello";
    }
};
// prototype is person
let friend = {
    __proto__: person,
    getGreeting() {
        return super.getGreeting() + ", hi!";
    }
};
// prototype is friend
let relative = {
    __proto__: friend
};
console.log(person.getGreeting());                  // "Hello"
console.log(friend.getGreeting());                  // "Hello, hi!"
console.log(relative.getGreeting());                // "Hello, hi!"

super的指向是固定的。不论有多少层继承关系,super.getGreeting()永远指向person.getGreeting()

super只能在对象方法中使用,不能在常规函数和全局作用域内使用,否则会抛出语法错误

方法

在ES6之前的版本中,方法并没有准确的定义。通常认为方法是一种函数类型的对象属性。ES6正式规范了方法的定义,作为方法的函数有一个内部属性[[HomeObject]]表明方法的归属对象:

let person = {
    // 方法
    getGreeting() {
        return "Hello";
    }
};
// 不是方法
function shareGreeting() {
    return "Hi!";
}

上述代码中,person对象有一个方法getGreeting()getGreeting()的内部属性[[HomeObject]]值为person,表明它的归属对象是personshareGreeting()是一个函数,它没有[[HomeObject]]属性,因为它不是任何对象的方法。大多数场景下,方法与函数的区别并不是很重要,但是使用super时需要谨慎处理两者的异同。

super引用是根据[[HomeObject]]属性来决定其指向的。其内部机制如下:

  1. 首先根据被调用方法的[[HomeObject]]属性值(即当前方法的归属对象),通过Object.getPrototypeOf()获取原型;
  2. 获取到原型后,检索同名方法;
  3. 绑定this指向并执行方法函数。

如果一个函数没有[[HomeObject]]属性或者属性值是错误的,以上的机制就无法运行。如下:

let person = {
    getGreeting() {
        return "Hello";
    }
};
// prototype is person
let friend = {
    __proto__: person,
    getGreeting() {
        return super() + ", hi!";
    }
};
function getGlobalGreeting() {
    return super.getGreeting() + ", yo!";
}
console.log(friend.getGreeting());  // "Hello, hi!"
getGlobalGreeting();                      // throws error

上述代码中的friend.getGreeting()返回了正确结果,而getGlobalGreeting()运行产生了错误,因为super并不能在常规函数内使用。由于getGlobalGreeting()函数不存在[[HomeObject]]属性,所以不能通过super向上检索。即便getGlobalGreeting()函数被动态的赋值给对象的方法,它仍然不能使用super。如下:

// prototype is person
let friend = {
    __proto__: person,
    getGreeting() {
        return super() + ", hi!";
    }
};
function getGlobalGreeting() {
    return super.getGreeting() + ", yo!";
}
console.log(friend.getGreeting());  // "Hello, hi!"
// assign getGreeting to the global function
friend.getGreeting = getGlobalGreeting;
friend.getGreeting();               // throws error

上述代码中,全局函数getGlobalGreeting()被动态地赋值给friendgetGreeting()方法。随后调用friend.getGreeting()产生和前例相同的错误。这是因为[[HomeObject]]的属性值在函数/方法被创建的时候就固定了,随后不能被改变。

总结

Object是JavaScript语言中至关重要的模块,ES6在简化操作和强化功能方面进行了许多改进。

在Object字面量方面,属性初始化的缩写模式可以更加简洁地通过当前作用域的同名变量进行赋值;计算属性名为对象扩展属性提供更多的动态化支持;函数初始化的缩写模式简化了对象方法的声明语法;属性重复声明在ES6严格和非严格模式下都不会报错。

Object.assign()函数可以进行对象多重属性的克隆,统一mixin模式的操作流程。

Object.setPrototypeOf()函数可以更改对象的原型。ES6规范了__proto__ 属性,作为一个存储器属性,它的get方法为Object.getPrototypeOf(),set方法为Object.setPrototypeOf()

super引用永远指向当前对象的原型。super可以作为函数使用,比如super(),也可以作为指针使用,比如super.getGreeting()。不论哪种形式,super调用的内部this永远指向当前的作用域。