《你不知道的JavaScript》--精读(八)

188 阅读18分钟

知识点

对象

1.1 语法

对象可以通过两种形式定义:声明(文字)形式和构造形式。

对象的文字语法大概是这样:

var myObj = {
    key: value,
    // ...
};

构造形式大概是这样:

var myObj = new Object();
myObj.key = value;

构造形式和文字形式生成的对象是一样的。唯一的区别是,在文字声明中你可以添加多个键值对,但是在构造形式中你必须逐个添加属性。

1.2 类型

对象是JavaScript的基础。在JavaScript中一共有六种主要类型(术语是“语言类型”):

  • string
  • number
  • boolean
  • null
  • undefined
  • object

注意,简单基本类型(string,boolean,number,null和undefined)本身并不是对象。 null有时会被当作一种对象类型,但是这其实只是语言本身的一个bug,即对null执行typeof null时会返回字符串"object"。实际上,null本身是基本类型。

JavaScript中有许多特殊的对象子类型,我们可以称之为复杂基本类型。

函数就是对象的一个子类型(从技术角度来说就是“可调用的对象”)。JavaScript中的函数是“一等公民”,因为它们本质上和普通的对象一样(只是可以调用),所以可以像操作其他对象一样操作函数(比如当作另一个函数的参数)。

数组也是对象的一种类型,具备一些额外的行为。数组中内容的组织方式比一般的对象要稍微复杂一些。

内置对象

JavaScript中还有一些对象子类型,通常被称为内置对象。有些内置对象的名字看起来和简单基础类型一样,不过实际上它们的关系更复杂。

  • String
  • Number
  • Boolean
  • Object
  • Function
  • Array
  • Date
  • RegExp
  • Error

这些内置对象实际上只是一些内置函数。这些内置函数可以当作构造函数(由new产生的函数调用)来使用,从而可以构造一个对应子类型的新对象。

var strPrimitive = "I am a string";
typeof strPrimitive; // "string"
strPrimitive instanceof String; // false

var strObject = new String("I am a string");
typeof strObject; // "object"
strObject instanceof String; // true

// 检查sub-type对象
Object.prototype.toString.call(strObject); // [Object String]

原始值“I am a string”并不是一个对象,它只是一个字面量,并且是一个不可变的值。如果要在这个字面量上执行一些操作,比如获取长度、访问其中某个字符等,那需要将其转换为String对象。

幸好,在必要时语言会自动把字符串字面量转换成一个String对象,也就是说你并不需要显式创建一个对象。JavaScript社区中的大多数人都认为能使用文字形式时就不要使用构造形式。

var strPrimitive = "I am a string";
console.log(strPrimitive.length); // 13
console.log(strPrimitive.charAt(3)); // "m"

以上两种方法,都可以直接在字符串字面量上访问属性或者方法,之所以可以这样,是因为引擎自动把字面量转换成String对象,所以可以访问属性和方法。

同样的事也会发生在数值字面量上,如果使用类似42.359.toFixed(2)的方法,引擎会把42转换成new Number(42)。对于布尔字面量来说也是如此。

null和undefined没有对应的构造形式,它们只有文字形式。相反,Date只有构造,没有文字形式。

对于Object、Array、Function和RegExp来说,无论使用文字形式还是构造形式,它们都是对象,不是字面量。

Error对象很少在代码中显式创建,一般是在抛出异常时被自动创建。

1.3 内容

对象的内容是由一些存储在特定命名位置的(任意类型的)值组成的,我们称之为属性。

var myObject = {
    a: 2
}

myObject.a; // 2
myObject['a']; // 2

.a语法通常被称为“属性访问”,["a"]语法通常被称为“键访问”。实际上它们访问的是同一个位置,并且会返回相同的值2,并且这两个术语是可以互换的。

这两种语法的主要区别在于.操作符要求属性名满足标识符的命名规范,而[""]语法可以接受任意UTF-8/Unicode字符串作为属性名。

var myObject = {
    a: 2
}

var idx;

if(wantA) {
    idx = "a";
}

console.log(myObject[idx]); // 2

在对象中,属性名永远都是字符串。如果你使用string(字面量)以外的其他值作为属性名,那它首先会被转换为一个字符串。即使是数字也不例外,虽然在数组下标中使用的的确是数字,但是在对象属性名中数字会被转换成字符串,所以当心不要搞混对象和数组中数字的用法:

var myObject = {};
myObject[true] = "foo";
myObject[3] = "bar";


myObject["true"]; // "foo"
myObject["3"]; // "bar"

1.3.1 可计算属性名

ES6增加了可计算属性名,可以在文字形式中使用[]包裹一个表达式来当作属性名:

var prefix = "foo";

var myObject = {
    [prefix + "bar"]: "hello",
    [prefix + "baz"]: "world"
}

myObject["foobar"]; // hello
myObject["foobaz"]; // world

1.3.2 属性与方法

无论返回值是什么类型,每次访问对象的属性就是属性访问。如果属性访问返回的是一个函数,那它并不是一个“方法”。属性访问返回的函数和其他函数没有任何区别(除了可能发生的隐式绑定this)。

function foo() {
    console.log("foo");
}

var someFoo = foo; // 对foo的变量引用

var myObject = {
    someFoo: foo
}

foo; // function foo() {...}
someFoo; // function foo() {...}
myObject.someFoo; // function foo() {...}

someFoo和myObject.someFoo只是对于同一个函数的不同引用,并不能说明这个函数是特别的或者“属于”某个对象。如果foo()定义时内部有一个this引用,那这两个函数引用的唯一区别就是myObject.someFoo中的this会被隐式绑定到一个对象。

即使你在对象的文字形式中声明一个函数表达式,这个函数也不会“属于”这个对象--它们只是对于相同函数对象的多个引用。

var myObject = {
    foo: function() {
        console.log("foo");
    }
}

var someFoo = myObject.foo;

someFoo; // function foo() {...}

myObject.foo; // function foo() {...}

1.3.3 数组

数组也支持[]访问形式,但是数组期望的是数值下标,也就是说值存储的位置(通常被称为索引)是非负整数,比如说0和42:

var myArray = ['foo', 42, 'bar'];

myArray.length; // 3

myArray[0]; // 'foo'

myArray[2]; // 'bar'

数组也是对象,所以虽然每个下标都是整数,你仍然可以给数组添加属性:

var myArray = ['foo', 42, 'bar'];

myArray.baz = 'baz';

myArray.length; // 3

myArray.baz; // 'baz'

可以看到虽然添加了命名属性(无论是通过.语法还是[]语法),数组的length值并未发生变化。

数组和普通的对象都根据其对应的行为和用途进行了优化,所以最好只用对象来存储键值对,只用数组来存储数值下标/值对。

注意:如果你视图向数组添加一个属性,但是属性名“看起来”像一个数字,那它会变成一个数值下标(因此会修改数组的内容而不是添加一个属性):

var myArray = ['foo', 42, 'bar'];

myArray['3'] = 'baz';

myArray.length; // 4
myArray[3]; // 'baz'

1.3.4 复制对象

JavaScript初学者最常见的问题之一就是如何复制一个对象。

举例来说,思考一下这个对象:

function anotherFunction() { /*...*/ }

var anotherObject = {
    c: true
}

var anotherArray = [];

var myObject = {
    a: 2,
    b: anotherObject, // 引用,不是复本!
    c: anotherArray, // 另一个引用
    d: anotherFunction
}

anotherArray.push(anotherObject, myObject);

如何准确地表示myObject的复制呢?

首先,我们应该判断它是浅拷贝还是深拷贝。对于浅拷贝来说,复制出的新对象中a的值会复制旧对象中a的值,也就是2,但是新对象中b,c,d三个属性其实只是三个引用,它们和旧对象中b,c,d引用的对象是一样的。对于深拷贝来说,除了复制myObject以外还好复制anotherObject和anotherArray。这时问题来了,anotherArray引用了anotherObject和myObject,所以又需要复制myObject,这样就会由于循环引用导致死循环。

对于JSON安全(也就是说可以被序列化为一个JSON字符串并且可以根据这个字符串解析出一个结构和值完全一样的对象)的对象来说,有一种巧妙的深拷贝方法:

var newObj = JSON.parse(JSON.stringify(someObj));

当然,这种方法需要保证JSON安全的,所以只适用于部分情况。

ES6定义了Object.assign(..)方法来实现浅拷贝。Object.assign(..)方法的第一个参数是目标对象,之后还可以跟一个或多个源对象。它会遍历一个或多个源对象的所有可枚举的自由键并把它们赋值(使用=操作符赋值)到目标对象,最后返回目标对象,就像这样:

var newObj = Object.assign({}, myObject);
newObj.a; //2
newObj.b === anotherObject; // true
newObj.c === anotherArray; // true
newObj.d === anotherFunction; // true

1.3.5 属性描述符

在ES5之前,JavaScript语言本身并没有提供可以直接检测属性特性的方法,比如判断属性是否可读。

但是从ES5开始,所有的属性都具备了属性描述符。

思考下面的代码:

var myObject = {
    a: 2
}

Object.getOwnPropertyDescriptor(myObject, 'a');

//    {
//        value: 2,
//        writable: true,
//        enumerable: true,
//        configurable: true
//    }

如你所见,这个普通的对象属性对应的属性描述符可不仅仅只是一个2。它还包好另外三个特性:writable(可写)、enumerable(可枚举)、和configurable(可配置)。

在创建普通属性时属性描述符会使用默认值,我们也可以使用Object.defineProperty(..)来添加一个新属性或者修改一个已有属性(如果它是configurable)并对特性进行设置。

var myObject = {};

Object.defineProperty(myObject, 'a', {
    value: 2,
    writable: true,
    configurable: true,
    enumerable: true
})

myObject.a; // 2

我们使用defineProperty(..)给myObject添加了一个普通的属性并显式指定了一些特性。然而,一般来说你不会使用这种方式,除非你想修改属性描述符。

1.Writable

writable决定是否可以修改属性的值。

var myObject = {};

Object.defineProperty(myObject, 'a', {
    value: 2,
    writable: false, // 不可写
    configurable: true,
    enumerable: true
})

myObject.a = 3;

myObjec.a; // 2

如果在严格模式下,这种方法会出错:

'use strict'
var myObject = {};

Object.defineProperty(myObject, 'a', {
    value: 2,
    writable: false, // 不可写
    configurable: true,
    enumerable: true
})

myObject.a = 3; // TypeError

2.Configurable

只要属性是可配置的,就可以使用defineProperty(..)方法来修改属性描述符:

var myObject = {
    a: 3
}
myObject.a = 3;

myObject.a; // 3

Object.defineProperty(myObject,'a', {
    value: 4,
    writable: true,
    configurable: false, // 不可配置
    enumerable: true
})

myObject.a; //4
myObject.a = 5;
myObject.a; // 5

Object.defineProperty(myObject,'a', {
    value: 6,
    writable: true,
    configurable: true,
    enumerable: true
}); // TypeError

最后一个defineProperty(..)会产生一个TypeError错误,不管是不是处于严格模式,尝试修改一个不可配置的属性描述符都会出错,注意:把configurable修改成false是单向操作,无法撤销!

有一个小小的例外,即便属性是configurable: false,

我们还是可以把writable状态由true改为false,但是无法由false改为true。

除了无法修改,configurable: false还会禁止删除这个属性:

var myObject = {
    a: 2
}

myObject.a; // 2

delete myObject.a;
myObject.a; // undefined

Object.defineProperty(myObject, 'a', {
    value: 2,
    writable: true,
    configurable: false,
    enumerable: true
})

myObject.a; // 2
delete myObject.a;

myObject.a; // 2

3.Enumerable

这个描述符控制的是属性是否会出现在对象的属性枚举中,比如说for..in循环。如果把enumerable设置成false,这个属性就不会出现在枚举中,虽然仍然可以正常访问它,相对地,设置成true就会让它出现在枚举中。

1.3.6 不变性

有时候你会希望属性或者对象是不可改变(无论是有意还是无意)的,在ES5中可以通过很多种方法来实现。

很重要的一点是,所有的方法创建的都是浅不变性,也就是说,它们只会影响目标对象和它的直接属性。如果目标对象引用了其他对象(数组、对象、函数等),其他对象的内容不受影响,仍然是可变的:

myImmutableObject.foo; // [1,2,3];
myImmutableObject.foo.push(4);
myImmutableObject.foo; // [1,2,3,4]

假设代码中的myImmutableObject已经被创建而且是不可变的,但是为了保护它的内容myImmutableObject.foo,你还需要使用下面的方法让foo也不可变。

1.对象常量

结合writable:false和configurable:false就可以创建一个真正的常量属性(不可修改、重定义或者删除):

var myObject = {};

Object.defineProperty(myObject,"FAVORITE_NUMBER", {
    value: 42,
    writable: false,
    configurable: false
})

2.禁止扩展

如果你想禁止一个对象添加新属性并且保留已有属性,可以使用Object.preventExtensions(..):

var myObject = {
    a: 2
}

Object.preventExtensions(myObject);

myObject.b = 3;

myObject.b; // undefined

3.密封

Object.seal(..)会创建一个“密封”的对象,这个方法实际上会在一个现有对象上调用Object.preventExtensions(..)并把所有现有属性标记为configurable:false。

所以,密封之后不仅不能添加新属性,也不能重新配置或者删除任何现有属性(虽然可以修改属性的值)。

4.冻结

Object.freeze(..)会创建一个冻结对象,这个方法实际上会在一个现有对象上调用Object.seal(..)并把所有“数据访问”属性标记为writable:false,这样就无法修改它们的值。

这个方法是你可以应用在对象上的级别最高的不可变性,它会禁止对于对象本身及其任意直接属性的修改(不过就像我们之前说过的,这个对象引用的其他对象是不受影响的)。

你可以“深度冻结”一个对象,具体方法为,首先在这个对象上调用Object.freeze(..),然后遍历它引用的所有对象并在这些对象上调用Object.freeze(..)。但是一定要小心,因为这样可能会在无意中冻结其他(共享)对象。

1.3.7 [[Get]]

属性访问在实现时有一个微妙却非常重要的细节,思考下面的代码:

var myObject = {
    a: 2
}

myObject.a; // 2

myObject.a是一次属性访问,但是这条语句并不仅仅是在myObject中查找名字为a的属性,虽然看起来好像是这样。

在语言规范中,myObject.a在myObject实际上是实现了[[Get]]操作。对象默认的内置的[[Get]]操作首先在对象中查找是否有名称相同的属性,如果找到就会返回这个属性的值。

然而,如果没有找到名称相同的属性,按照[[Get]]算法的定义会执行另外一种非常重要的行为。遍历可能存在的[[prototype]]链,也就是原型链。

如果无论如何都没有找到名称相同的属性,那么[[Get]]操作会返回值undefined:

var myObject = {
    a: 2
}

myObject.b; // undefined

注意,这种方法和访问变量时是不一样的。如果你引用了一个档期词法作用域中不存在的变量,并不会像对象属性一样返回undefined,而是会抛出一个ReferenceError异常:

var myObject = {
    a: undefined
}

myObject.a; // undefined

myObject.b; // undefined

从返回值的角度来说,这两个引用没有什么区别--它们都返回了undefined。然而,实际上底层的[[Get]]操作对myObject.b进行了更复杂的处理。

1.3.8 [[put]]

既然有可以获取属性值的[[Get]]操作,就一定有对应的[[Put]]操作。

[[put]]被触发时,实际的行为取决于许多因素,包括对象中是否已经存在这个属性(这是最重要的因素)。

1.3.9 Getter和Setter

在ES5中可以使用getter和setter部分改写默认操作,但是只能应用在单个属性上,无法应用在整个对象上。getter是一个隐藏函数,会在获取属性值时调用。setter也是一个隐藏函数,会在设置属性值时调用。

var myObject = {
    // 给a定义一个getter
    get a() {
        return 2;
    }
}

Object.defineProperty(myObject,'b',{
    // 给b设置一个getter
    get: function() { return this.a *2 },
    // 确保b会出现在对象的属性列表中
    enumerable: true
})

myObject.a; // 2

myObject.b; // 4

不管是对象文字语法中的get a() {...},还是defineProperty(..)中的显式定义,二者都会在对象中创建一个不包含值的属性,对于这个属性的访问会自动调用一个隐藏函数,它的返回值会被当作属性访问的返回值:

var myObject = {
    // 给a定义一个getter
    get a() {
        return 2;
    }
}

myObject.a = 3;
myObject.a; //2

通常来说,getter和setter是成对出现的(只定义一个的话通常会产生意料之外的行为):

var myObject = {
    // 给a定义一个getter
    get a() {
        return this._a_;
    },
    set a(val) {
        this._a_ = val * 2
    }
}

myObject.a = 2;
myObject.a; // 4

1.3.10 存在性

前面我们说过,如myObject.a的属性访问返回值可能是undefined,但是这个值有可能是属性中存储的undefined,也可能是因为属性不存在所以返回undefined。那么如何区分这两种情况呢?

var myObject = {
    a: 2
}

console.log('a' in myObject); // true
console.log('b' in myObject); // false

myObject.hasOwnProperty('a'); // true
myObject.hasOwnProperty('b'); // false

in操作符会检查属性名是否在对象及其[[Prototype]]原型链中。相比之下,hasOwnProperty(..)只会检查属性是否在myObject对象中,不会检查[[Prototype]链。

所有的普通对象都可以通过Object.prototype的委托来访问hasOwnPeoperty(..),但是有的对象可能没有连接到Object.prototype,(通过Object.create(null)来创建)。在这种情况下,形如myObject.hasOwnProperty(..)就会失败。

这时可以使用一种更加强硬的方法来进行判断:Object.prototype.hasOwnProperty.call(myObject,'a'),它借用基础的hasOwnProperty(..)方法并把它显式绑定到myObject上。

1.枚举

var myObject = {};

Object.defineProperty(myObject,'a',{
    value: 2,
    // 让a像普通属性一样可以枚举
    enumerable: true
})

Object.defineProperty(myObject,'b', {
    value: 3,
    // 让b不可枚举
    enumerable: false
})

myObject.b; // 3

('b' in myObject); // true
myObject.hasOwnProperty('b'); // true

for(var k in myObject) {
    console.log(k,myObject[k])
}
// 'a'  2

可以看到,myObject.b确实存在并且有访问值,但是却不会出现在for..in循环中(尽管可以通过in操作符来判断是否存在)。原因是“可枚举”就相当于“可以出现在对象属性的遍历中”。

也可以通过另一种方式来区分属性是否可枚举:

var myObject = { };

Object.defineProperty(myObject,'a',{
    enumerable: true,
    value: 2
})

Object.defineProperty(myObject,'b',{
    enumerable: false,
    value: 3
})

myObject.propertyIsEnumerable('a'); // true
myObject.propertyIsEnumerable('b'); // false

Object.keys(myObject); // ['a']
Object.getOwnPropertyNames(myObject); // ['a','b']

propertyIsEnumerable(..)会检查给定的属性名是否直接存在于对象中(而不是原型链上)并且满足enumerable:true。

Object.keys(..)会返回一个数组,包含所有可枚举属性,Object.getOwnPropertyNames(..)会返回一个数组,包含所有属性,无论它们是否可枚举。

in和hasOwnProperty(..)的区别在于是否查找[[Prototype]]链,然而,Object.keys(..)和Object.getOwnPropertyNames(..)都只会查找对象直接包含的属性。

1.4 遍历

for..in循环可以用来遍历对象的可枚举属性列表(包括[[Prototype]]链)。但是如何遍历属性的值呢?

对于数值索引的数组来说,可以使用标准的for循环来遍历值:

var myArray = [1,2,3];

for(var i = 0; i < myArray.length; i++){
    console.log(myArray[i]);
}
// 1 2 3

这实际上并不是在遍历值,而是遍历下标来指向值,如myArray[i]。

ES5中增加了一些数组的辅助迭代器,包括forEach(..)、every(..)和some(..)。每种辅助迭代器都可以接受一个回调函数并把它应用到数组的每个元素上,唯一的区别就是它们对于回调函数返回值的处理方式不同。

那么如何直接遍历值而不是数组下标(或者对象属性)呢?幸好,ES6增加了一种用来遍历数组的for..of循环语法(如果对象本身定义了迭代器的话也可以遍历对象):

var myArray = [1,2,3];

for(var v of myArray) {
    console.log(v);
}
// 1 2 3

for..of循环首先会向被访问对象请求一个迭代器对象,然后通过调用迭代器对象的next()方法来遍历所有返回值。

总结

JavaScript中的对象有字面形式(比如var a = {..})和构造形式(比如var a = new Array(..))。字面形式更常用,不过有时候构造形式可以提供更多选项。

许多人都以为“JavaScript中万物都是对象”,这是错误的。对象是6个(或者是7个,取决于你的观点)基础类型之一。对象有包括function在内的子类型,不同子类型具有不同的行为,比如内部标签[Object Array]表示这是对象的子类型数组。

对象就是键值对的集合。可以通过.propName或者['propName']语法来获取属性值。访问属性时,引擎实际上会调用内部的默认[[Get]]操作(在设置属性值时是[[Put]]),[[Get]]操作会检查对象本身是否包含这个属性,如果没找到的话,还会查找[[Prototype]]链。

属性的特性可以通过属性描述符来控制,比如writable和configurable。此外,可以使用Object.preventExtensions(..)、Object.seal(..)和Object.freeze(..)来设置对象(及其属性)的不可变性级别。

属性不一定包含值--它们可能是具备getter/setter的“访问描述符”。此外,属性可以是枚举或者不可枚举的,这决定了它们是否会出现在for..in循环中。

你可以使用ES6的for..of语法来遍历数据结构(数组、对象,等等)中的值,for..of会寻找内置或者自定义的@@iterator对象并调用它的next()方法来遍历数据值。

巴拉巴拉

感觉好久没有更新了,一直断断续续的,也是因为前段时间工作有点忙,没办法平衡工作和学习的时间,现在终于可以习惯一些,所以开始坚持把这一个系列的书好好看完。