《你不知道的JavaScript(上卷)》

前言

本文作为对本书的一些知识点的收集

正文

1. LHS 和 RHS

当变量出现在赋值操作的左侧时进行 LHS 查询,出现在右侧时进行 RHS 查询;讲得再准确一点,RHS 查询与简单地查找某个变量的值别无二致,而 LHS 查询则是试图找到变量的容器本身;从这个角度说,RHS 并不是真正意义上的 “赋值操作的右侧” ,更准确地说是 “非左侧”;可以将 RHS 理解成 retrieve his source value(取到它的源值),这意味着 “得到某某的值”

2. ReferenceError 和 TypeError

ReferenceError 同作用域判别失败相关,而 TypeError 则代表作用域判别成功了,但是对
结果的操作是非法或不合理的。

3. 不成功的 RHS 引用会导致抛出 ReferenceError 异常。不成功的 LHS 引用会导致自动隐式地创建一个全局变量(非严格模式下),该变量使用 LHS 引用的目标作为标识符,或者抛

出 ReferenceError 异常(严格模式下)。

4. 小测验答案

function foo(a) {
var b = a;
return a + b;
}
var c = foo( 2 );

// 1. 找出所有的 LHS 查询(这里有 3 处!)
c = ..;、a = 2(隐式变量分配)、b = ..
// 2. 找出所有的 RHS 查询(这里有 4 处!)
foo(2..、= a;、a ..、.. b

5. 个不推荐使用 eval(..) 和 with 的原因

会被严格模式所影响(限制)。with 被完全禁止,而在保留核心功能的前提下,间接或非安全地使用 eval(..) 也被禁止了。

JavaScript 引擎会在编译阶段进行数项的性能优化。其中有些优化依赖于能够根据代码的词法进行静态分析,并预先确定所有变量和函数的定义位置,才能在执行过程中快速找到标识符。
但如果引擎在代码中发现了 eval(..) 或 with,它只能简单地假设关于标识符位置的判断都是无效的,因为无法在词法分析阶段明确知道 eval(..) 会接收到什么代码,这些代码会如何对作用域进行修改,也无法知道传递给 with 用来创建新词法作用域的对象的内容到底是什么。
最悲观的情况是如果出现了 eval(..) 或 with,所有的优化可能都是无意义的,因此最简单的做法就是完全不做任何优化。
如果代码中大量使用 eval(..) 或 with,那么运行起来一定会变得非常慢。无论引擎多聪明,试图将这些悲观情况的副作用限制在最小范围内,也无法避免如果没有这些优化,代码会运行得更慢这个事实。

6. eval(..) 和 with 小结

JavaScript 中有两个机制可以“欺骗”词法作用域:eval(..) 和 with。前者可以对一段包含一个或多个声明的“代码”字符串进行演算,并借此来修改已经存在的词法作用域(在运行时)。后者本质上是通过将一个对象的引用当作作用域来处理,将对象的属性当作作用域中的标识符来处理,从而创建了一个新的词法作用域(同样是在运行时)。
这两个机制的副作用是引擎无法在编译时对作用域查找进行优化,因为引擎只能谨慎地认为这样的优化是无效的。使用这其中任何一个机制都将导致代码运行变慢。不要使用它们。

7. 函数声明和函数表达式

区分函数声明和表达式最简单的方法是看 function 关键字出现在声明中的位
置(不仅仅是一行代码,而是整个声明中的位置)。如果 function 是声明中
的第一个词,那么就是一个函数声明,否则就是一个函数表达式。

8. 在 JavaScript 中被称为模块的模式

function CoolModule() {
  var something = "cool";
  var another = [1, 2, 3];
  function doSomething() {
    console.log( something );
  }
  function doAnother() {
  console.log( another.join( " ! " ) );
  }
  return {
    doSomething: doSomething,
    doAnother: doAnother
  };
}
var foo = CoolModule();
foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3

9. 当只需要一个实例时,可以对上面这个模式进行简单的改进来实现单例模式:

var foo = (function CoolModule() {
  var something = "cool";
  var another = [1, 2, 3];
  function doSomething() {
    console.log( something );
  }
  function doAnother() {
    console.log( another.join( " ! " ) );
  }
  return {
    doSomething: doSomething,
    doAnother: doAnother
  };
})();
foo.doSomething(); // cool
foo.doAnother(); // 1 ! 2 ! 3

10. 现代的模块机制

大多数模块依赖加载器 / 管理器本质上都是将这种模块定义封装进一个友好的 API。这里
并不会研究某个具体的库,为了宏观了解我会简单地介绍一些核心概念:

var MyModules = (function Manager() {
  var modules = {};
  function define(name, deps, impl) {
    for (var i=0; i<deps.length; i++) {
      deps[i] = modules[deps[i]];
    }
    modules[name] = impl.apply( impl, deps );
  }
  function get(name) {
    return modules[name];
  }
  return {
    define: define,
    get: get
  };
})();

这段代码的核心是 modules[name] = impl.apply(impl, deps)。为了模块的定义引入了包装
函数(可以传入任何依赖),并且将返回值,也就是模块的 API,储存在一个根据名字来管理的模块列表中。

下面展示了如何使用它来定义模块:

MyModules.define( "bar", [], function() {
  function hello(who) {
    return "Let me introduce: " + who;
  }
  return {
    hello: hello
  };
} );
MyModules.define( "foo", ["bar"], function(bar) {
  var hungry = "hippo";
  function awesome() {
    console.log( bar.hello( hungry ).toUpperCase() );
  }
  return {
    awesome: awesome
  };
} );
var bar = MyModules.get( "bar" );
var foo = MyModules.get( "foo" );
console.log(bar.hello( "hippo" )); // Let me introduce: hippo
foo.awesome(); // LET ME INTRODUCE: HIPPO

"foo" 和 "bar" 模块都是通过一个返回公共 API 的函数来定义的。"foo" 甚至接受 "bar" 的
示例作为依赖参数,并能相应地使用它

它们符合前面列出的模块模式的两个特点:为函数定义引入包装函数,并保证它的返回值和模块的 API 保持一致。

11. 使用 new 来调用函数,或者说发生构造函数调用的原理

  • 举例来说,思考一下 Number(..) 作为构造函数时的行为,ES5.1 中这样描述它:

15.7.2 Number 构造函数
当 Number 在 new 表达式中被调用时,它是一个构造函数:它会初始化新创建的对象。

  • 所以,包括内置对象函数(比如 Number(..),详情请查看第 3 章)在内的所有函数都可以用 new 来调用,这种函数调用被称为构造函数调用。
    这里有一个重要但是非常细微的区别:实际上并不存在所谓的“构造函数”,只有对于函数的“构造调用”。
  • 使用 new 来调用函数,或者说发生构造函数调用时,会自动执行下面的操作。
      1. 创建(或者说构造)一个全新的对象。
      1. 这个新对象会被执行 [[ 原型 ]] 连接。
      1. 这个新对象会绑定到函数调用的 this。
      1. 如果函数没有返回其他对象,那么 new 表达式中的函数调用会自动返回这个新对象。

12. this 绑定的优先级

  • 根据优先级来判断函数在某个调用位置应用的是哪条规则。可以按照下面的顺序来进行判断:
      1. 函数是否在 new 中调用(new 绑定)?如果是的话 this 绑定的是新创建的对象。
    var bar = new foo()
    
      1. 函数是否通过 call、apply(显式绑定)或者硬绑定调用?如果是的话,this 绑定的是
        指定的对象。
    var bar = foo.call(obj2)
    
      1. 函数是否在某个上下文对象中调用(隐式绑定)?如果是的话,this 绑定的是那个上下文对象。
    var bar = obj1.foo()
    
      1. 如果都不是的话,使用默认绑定。如果在严格模式下,就绑定到 undefined,否则绑定到全局对象。
    var bar = foo()
    
  • 在某些场景下 this 的绑定行为会出乎意料
    • 如果你把 null 或者 undefined 作为 this 的绑定对象传入 call、apply 或者 bind,这些值在调用时会被忽略,实际应用的是默认绑定规则:
    function foo() {
      console.log( this.a );
    }
    var a = 2;
    foo.call( null ); // 2
    

13. 创建一个空对象最简单的方法

如果函数并不关心 this 的话,你
仍然需要传入一个占位值,使用这个方法比 null 更安全,它就是一个空的非委托的对象

// 因为 ø 表示 “希望 this 是空”,这比 null 的含义更清楚。不过你可以用任何喜欢的名字来命名 DMZ(demilitarized zone) 对象
var ø = Object.create(null)

Object.create(null) 和 {} 很 像, 但 是 并 不 会 创 建 Object.prototype 这个委托,所以它比 {}“更空”:

  • 代码示例:
function foo(a,b) {
  console.log( "a:" + a + ", b:" + b );
}
// 我们的 DMZ 空对象
var ø = Object.create( null );
// 把数组展开成参数
foo.apply( ø, [2, 3] ); // a:2, b:3
// 使用 bind(..) 进行柯里化
var bar = foo.bind( ø, 2 );
bar( 3 ); // a:2, b:3

14. this 全面解析 小结

  • 如果要判断一个运行中函数的 this 绑定,就需要找到这个函数的直接调用位置。找到之后就可以顺序应用下面这四条规则来判断 this 的绑定对象。

      1. 由 new 调用?绑定到新创建的对象。
      1. 由 call 或者 apply(或者 bind)调用?绑定到指定的对象。
      1. 由上下文对象调用?绑定到那个上下文对象。
      1. 默认:在严格模式下绑定到 undefined,否则绑定到全局对象。

    一定要注意,有些调用可能在无意中使用默认绑定规则。如果想“更安全”地忽略 this 绑定,你可以使用一个 DMZ 对象,比如 ø = Object.create(null),以保护全局对象。

    ES6 中的箭头函数并不会使用四条标准的绑定规则,而是根据当前的词法作用域来决定 this,具体来说,箭头函数会继承外层函数调用的 this 绑定(无论 this 绑定到什么)。这其实和 ES6 之前代码中的 self = this 机制一样。

15. 类型

  • 对象是 JavaScript 的基础。在 JavaScript 中一共有六种主要类型(术语是“语言类型”):
    • string
    • number
    • boolean
    • null
    • undefined
    • object

注意,简单基本类型(string、boolean、number、null 和 undefined)本身并不是对象。

null 有时会被当作一种对象类型,但是这其实只是语言本身的一个 bug,即对 null 执行 typeof null 时会返回字符串 "object"。实际上,null 本身是基本类型。

原理是这样的,不同的对象在底层都表示为二进制,在 JavaScript 中二进制前三位都为 0 的话会被判断为 object 类型,null 的二进制表示是全 0,自然前三位也是 0,所以执行 typeof 时会返回“object”。

有一种常见的错误说法是“JavaScript 中万物皆是对象”,这显然是错误的。

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

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

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

16. 内置对象

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

JavaScript 中,这些内置对象实际上只是一些内置函数。这些内置函数可以当作构造函数(由 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]

从代码中可以看到,strObject 是由 String 构造函数创建的一个对象。

原始值 "I am a string" 并不是一个对象,它只是一个字面量,并且是一个不可变的值。

如果要在这个字面量上执行一些操作,比如获取长度、访问其中某个字符等,那需要将其转换为 String 对象。

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 对象很少在代码中显式创建,一般是在抛出异常时被自动创建。也可以使用 new Error(..) 这种构造形式来创建,不过一般来说用不着。

17. 内容

  • 需要强调的一点是,当我们说“内容”时,似乎在暗示这些值实际上被存储在对象内部,但是这只是它的表现形式。在引擎内部,这些值的存储方式是多种多样的,一般并不会存在对象容器内部。存储在对象容器内部的是这些属性的名称,它们就像指针(从技术角度来说就是引用)一样,指向这些值真正的存储位置。
var myObject = {
a: 2
};
myObject.a; // 2
myObject["a"]; // 2

如果要访问 myObject 中 a 位置上的值,我们需要使用 . 操作符或者 [] 操作符。.a 语法通常被称为“属性访问”,["a"] 语法通常被称为“键访问”。实际上它们访问的是同一个位置,并且会返回相同的值 2,所以这两个术语是可以互换的。

这两种语法的主要区别在于 . 操作符要求属性名满足标识符的命名规范,而 [".."] 语法可以接受任意 UTF-8/Unicode 字符串作为属性名。举例来说,如果要引用名称为 "SuperFun!"的属性,那就必须使用 ["Super-Fun!"] 语法访问,因为 Super-Fun! 并不是一个有效的标识符属性名。

  • 在对象中,属性名永远都是字符串。如果你使用 string(字面量)以外的其他值作为属性名,那它首先会被转换为一个字符串。即使是数字也不例外,虽然在数组下标中使用的的确是数字,但是在对象属性名中数字会被转换成字符串,所以当心不要搞混对象和数组中数字的用法:
var myObject = { };
myObject[true] = "foo";
myObject[3] = "bar";
myObject[myObject] = "baz";
myObject["true"]; // "foo"
myObject["3"]; // "bar"
myObject["[object Object]"]; // "baz"

18. 可计算属性名

  • ES6 增加了可计算属性名,可以在文字形式中使用 [] 包裹一个表达式来当作属性名:
var prefix = "foo";
var myObject = {
  [prefix + "bar"]:"hello",
  [prefix + "baz"]: "world"
};
myObject["foobar"]; // hello
myObject["foobaz"]; // world

19. 复制对象

  • 对于 JSON 安全(也就是说可以被序列化为一个 JSON 字符串并且可以根据这个字符串解析出一个结构和值完全一样的对象)的对象来说,有一种巧妙的复制方法:
var newObj = JSON.parse( JSON.stringify( someObj ) );

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

  • ES6 定义了 Object.assign(..) 方法来实现浅复制。

Object.assign(..) 方法的第一个参数是目标对象,之后还可以跟一个或多个源对象。它会遍历一个或多个源对象的所有可枚举(enumerable,参见下面的代码)的自有键(owned key)并把它们复制(使用 = 操作符赋值)到目标对象,最后返回目标对象,就像这样:

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

由于 Object.assign(..) 就是使用 = 操作符来赋值,所
以源对象属性的一些特性(比如 writable)不会被复制到目标对象。

20. 属性描述符

  • 在 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 添加了一个普通的属性并显式指定了一些特性。然而,一般来说你不会使用这种方式,除非你想修改属性描述符。

  • writable 决定是否可以修改属性的值。
var myObject = {};
Object.defineProperty( myObject, "a", {
  value: 2,
  writable: false, // 不可写!
  configurable: true,
  enumerable: true
} );
myObject.a = 3;
myObject.a; // 2

如你所见,我们对于属性值的修改静默失败(silently failed)了。如果在严格模式下,这种方法会出错:

"use strict";
var myObject = {};
Object.defineProperty( myObject, "a", {
  value: 2,
  writable: false, // 不可写!
  configurable: true,
  enumerable: true
} );
myObject.a = 3; // TypeError

TypeError 错误表示我们无法修改一个不可写的属性。

  • configurable 只要属性是可配置的,就可以使用 defineProperty(..) 方法来修改属性描述符:
var myObject = {
  a:2
};
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。

21. 不变性

  • 对象常量(不可修改、重定义或者删除)
var myObject = {};
Object.defineProperty( myObject, "FAVORITE_NUMBER", {
  value: 42,
  writable: false,
  configurable: false
} );
  • 禁止扩展(禁 止 一 个 对 象 添 加 新 属 性 并 且 保 留 已 有 属 性)
var myObject = {
a:2
};
Object.preventExtensions( myObject );
myObject.b = 3;
myObject.b; // undefined

在非严格模式下,创建属性 b 会静默失败。在严格模式下,将会抛出 TypeError 错误。

  • 密封

Object.seal(..) 会创建一个“密封”的对象,这个方法实际上会在一个现有对象上调用 Object.preventExtensions(..) 并把所有现有属性标记为 configurable:false。所以,密封之后不仅不能添加新属性,也不能重新配置或者删除任何现有属性(虽然可以修改属性的值)

  • 冻结

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

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

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

22. 存在性

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

  • 可以在不访问属性值的情况下判断对象中是否存在这个属性:
var myObject = {
a:2
};
("a" in myObject); // true
("b" in myObject); // false
myObject.hasOwnProperty( "a" ); // true
myObject.hasOwnProperty( "b" ); // false

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

所有的普通对象都可以通过对于 Object.prototype的 委 托 来访问 hasOwnProperty(..),但是有的对象可能没有连接到 Object.prototype(通过 Object.create(null) 来创建——参见第 5 章)。在这种情况下,形如myObejct.hasOwnProperty(..)就会失败。
这 时 可 以 使 用 一 种 更 加 强 硬 的 方 法 来 进 行 判 断:Object.prototype.hasOwnProperty.call(myObject,"a"),它借用基础的 hasOwnProperty(..) 方法并把它显式绑定到 myObject 上。

看起来 in 操作符可以检查容器内是否有某个值,但是它实际上检查的是某个属性名是否存在。对于数组来说这个区别非常重要,4 in [2, 4, 6] 的结果并不是你期待的 True,因为 [2, 4, 6] 这个数组中包含的属性名是 0、1、2,没有 4。

  • 枚举(什么是“可枚举性”)
var myObject = { };
Object.defineProperty(
  myObject,
  "a",
  // 让 a 像普通属性一样可以枚举
  { enumerable: true, value: 2 }
);
Object.defineProperty(
  myObject,
  "b",
  // 让 b 不可枚举
  { enumerable: false, value: 3 }
);
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 操作符来判断是否存在)。原因是“可枚举”就相当于“可以出现在对象属性的遍历中”。

在数组上应用 for..in 循环有时会产生出人意料的结果,因为这种枚举不仅会包含所有数值索引,还会包含所有可枚举属性。最好只在对象上应用 for..in 循环,如果要遍历数组就使用传统的 for 循环来遍历数值索引。

  • 也可以通过另一种方式来区分属性是否可枚举:
var myObject = { };
Object.defineProperty(
  myObject,
  "a",
  // 让 a 像普通属性一样可以枚举
  { enumerable: true, value: 2 }
);
Object.defineProperty(
  myObject,
  "b",
  // 让 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(..) 都只会查找对象直接包含的属性。

(目前)并没有内置的方法可以获取 in 操作符使用的属性列表(对象本身的属性以及 [[Prototype]] 链中的所有属性,参见第 5 章)。不过你可以递归遍历某个对象的整条 [[Prototype]] 链并保存每一层中使用 Object.keys(..) 得到的属性列表——只包含可枚举属性。

23. 遍历

  • 对于数值索引的数组来说,可以使用标准的 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(..)。每种辅助迭代器都可以接受一个回调函数并把它应用到数组的每个元素上,唯一的区别就是它们对于回调函数返回值的处理方式不同。

    • forEach(..) 会遍历数组中的所有值并忽略回调函数的返回值
    • every(..) 会一直运行直到回调函数返回 false(或者“假”值)
    • some(..) 会一直运行直到回调函数返回 true(或者
      “真”值)

    every(..) 和 some(..) 中特殊的返回值和普通 for 循环中的 break 语句类似,它们会提前终止遍历

    使用 for..in 遍历对象是无法直接获取属性值的,因为它实际上遍历的是对象中的所有可枚举属性,你需要手动获取属性值。

    遍历数组下标时采用的是数字顺序(for 循环或者其他迭代器),但是遍历对象属性时的顺序是不确定的,在不同的 JavaScript 引擎中可能不一样。因此,在不同的环境中需要保证一致性时,一定不要相信任何观察到的顺序,它们是不可靠的。

  • ES6 增加了一种用来遍历数组的 for..of 循环语法(如果对象本身定义了迭代器的话也可以遍历对象):

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

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

数组有内置的 @@iterator,因此 for..of 可以直接应用在数组上。我们使用内置的 @@iterator 来手动遍历数组,看看它是怎么工作的:

var myArray = [ 1, 2, 3 ];
var it = myArray[Symbol.iterator]();
it.next(); // { value:1, done:false }
it.next(); // { value:2, done:false }
it.next(); // { value:3, done:false }
it.next(); // { done:true }

我们使用 ES6 中的符号 Symbol.iterator 来获取对象的 @@iterator 内部属性。之前我们简单介绍过符(Symbol,参见 3.3.1 节),跟这里的原理是相同的。引用类似 iterator 的特殊属性时要使用符号名,而不是符号包含的值。此外,虽然看起来很像一个对象,但是 @@iterator 本身并不是一个迭代器对象,而是一个返回迭代器对象的函数——这点非常精妙并且非常重要。

如你所见,调用迭代器的 next() 方法会返回形式为 { value: .. , done: .. } 的值,value 是当前的遍历值,done 是一个布尔值,表示是否还有可以遍历的值。
注意,和值“3”一起返回的是 done:false,乍一看好像很奇怪,你必须再调用一次 next() 才能得到 done:true,从而确定完成遍历。这个机制和 ES6 中发生器函数的语义相关,不过已经超出了我们的讨论范围。

和数组不同,普通的对象没有内置的 @@iterator,所以无法自动完成 for..of 遍历。之所以要这样做,有许多非常复杂的原因,不过简单来说,这样做是为了避免影响未来的对象类型。

当然,你可以给任何想遍历的对象定义 @@iterator,举例来说:

var myObject = {
  a: 2,
  b: 3
};
Object.defineProperty( myObject, Symbol.iterator, {
  enumerable: false,
  writable: false,
  configurable: true,
  value: function() {
    var o = this;
    var idx = 0;
    var ks = Object.keys( o );
    return {
      next: function() {
        return {
          value: o[ks[idx++]],
          done: (idx > ks.length)
        };
      }
    };
  }
} );
// 手动遍历 myObject
var it = myObject[Symbol.iterator]();
it.next(); // { value:2, done:false }
it.next(); // { value:3, done:false }
it.next(); // { value:undefined, done:true }
// 用 for..of 遍历 myObject
for (var v of myObject) {
  console.log( v );
}
// 2
// 3

我们使用 Object.defineProperty(..) 定义了我们自己的@@iterator(主要是为了让它不可枚举),不过注意,我们把符号当作可计算属性名。此外,也可以直接在定义对象时进行声明,比如 var myObject = { a:2, b:3, [Symbol.iterator]: function() { /* .. */ } }。

  • 一个“无限”迭代器,它永远不会“结束”并且总会返回一个新值(随机数)
var randoms = {
  [Symbol.iterator]: function() {
    return {
      next: function() {
        return { value: Math.random() };
      }
    };
  }
};
var randoms_pool = [];
for (var n of randoms) {
  randoms_pool.push( n );
  // 防止无限运行!
  if (randoms_pool.length === 100) break;
}

24. 混合对象 “类” 小结

类是一种设计模式。许多语言提供了对于面向类软件设计的原生语法。JavaScript 也有类似的语法,但是和其他语言中的类完全不同。

类意味着复制。

传统的类被实例化时,它的行为会被复制到实例中。类被继承时,行为也会被复制到子类中。

多态(在继承链的不同层次名称相同但是功能不同的函数)看起来似乎是从子类引用父类,但是本质上引用的其实是复制的结果。

JavaScript 并不会(像类那样)自动创建对象的副本。

混入模式(无论显式还是隐式)可以用来模拟类的复制行为,但是通常会产生丑陋并且脆弱的语法,比如显式伪多态(OtherObj.methodName.call(this, ...)),这会让代码更加难懂并且难以维护。
此外,显式混入实际上无法完全模拟类的复制行为,因为对象(和函数!别忘了函数也是对象)只能复制引用,无法复制被引用的对象或者函数本身。忽视这一点会导致许多问题。

25. 原型 -- [[Prototype]]

使用 for..in 遍历对象时原理和查找 [[Prototype]] 链类似,任何可以通过原型链访问到(并且是 enumerable,参见第 3 章)的属性都会被枚举。使用 in 操作符来检查属性在对象中是否存在时,同样会查找对象的整条原型链(无论属性是否可枚举):

var anotherObject = {
  a:2
};
// 创建一个关联到 anotherObject 的对象
var myObject = Object.create( anotherObject );
myObject.a; // 2

26. 原型 -- Object.prototype

所有普通的 [[Prototype]] 链最终都会指向内置的 Object.prototype。由于所有的“普通”(内置,不是特定主机的扩展)对象都“源于”(或者说把 [[Prototype]] 链的顶端设置为)这个 Object.prototype 对象,所以它包含 JavaScript 中许多通用的功能。
有 些 功 能 你 应 该 已 经 很 熟 悉 了, 比 如 说 .toString() 和 .valueOf(), 第 3 章 还 介 绍过 .hasOwnProperty(..)。稍后我们还会介绍 .isPrototypeOf(..),这个你可能不太熟悉。

27. 原型 - “类”函数

new Foo() 会生成一个新对象(我们称之为 a),这个新对象的内部链接 [[Prototype]] 关联的是 Foo.prototype 对象。
最后我们得到了两个对象,它们之间互相关联,就是这样。我们并没有初始化一个类,实际上我们并没有从“类”中复制任何行为到一个对象中,只是让两个对象互相关联。
实际上,绝大多数 JavaScript 开发者不知道的秘密是,new Foo() 这个函数调用实际上并没有直接创建关联,这个关联只是一个意外的副作用。new Foo() 只是间接完成了我们的目标:一个关联到其他对象的新对象。

28. 技术

function Foo() { /* .. */ }
Foo.prototype = { /* .. */ }; // 创建一个新原型对象
var a1 = new Foo();
a1.constructor === Foo; // false!
a1.constructor === Object; // true!

Object(..) 并没有“构造” a1,对吧?看起来应该是 Foo()“构造”了它。大部分开发者都认为是 Foo() 执行了构造工作,但是问题在于,如果你认为 “constructor” 表示“由……构造”的话,a1.constructor 应该是 Foo,但是它并不是 Foo !
到底怎么回事? a1 并没有 .constructor 属性,所以它会委托 [[Prototype]] 链上的 Foo.prototype。但是这个对象也没有 .constructor 属性(不过默认的 Foo.prototype 对象有这个属性!),所以它会继续委托,这次会委托给委托链顶端的 Object.prototype。这个对象有 .constructor 属性,指向内置的 Object(..) 函数。

  • 当然,你可以给 Foo.prototype 添加一个 .constructor 属性,不过这需要手动添加一个符合正常行为的不可枚举(参见第 3 章)属性。
    举例来说:
function Foo() { /* .. */ }
Foo.prototype = { /* .. */ }; // 创建一个新原型对象
// 需要在 Foo.prototype 上“修复”丢失的 .constructor 属性
// 新对象属性起到 Foo.prototype 的作用
// 关于 defineProperty(..),参见第 3 章
Object.defineProperty( Foo.prototype, "constructor" , {
  enumerable: false,
  writable: true,
  configurable: true,
  value: Foo // 让 .constructor 指向 Foo
} );

修复 .constructor 需要很多手动操作。所有这些工作都是源于把 “constructor” 错误地理解为“由……构造”,这个误解的代价实在太高了。

实际上,对象的 .constructor 会默认指向一个函数,这个函数可以通过对象的 .prototype 引用。“constructor” 和 “prototype” 这两个词本身的含义可能适用也可能不适用。最好的办法是记住这一点 “constructor 并不表示被构造”。

.constructor 并不是一个不可变属性。它是不可枚举(参见上面的代码)的,但是它的值是可写的(可以被修改)。此外,你可以给任意 [[Prototype]] 链中的任意对象添加一个名为 constructor 的属性或者对其进行修改,你可以任意对其赋值。

  • ES6 添加了辅助函数 Object.setPrototypeOf(..),可以用标准并且可靠的方法来修
    改关联。
    我们来对比一下两种把 Bar.prototype 关联到 Foo.prototype 的方法:
// ES6 之前需要抛弃默认的 Bar.prototype
Bar.ptototype = Object.create( Foo.prototype );
// ES6 开始可以直接修改现有的 Bar.prototype
Object.setPrototypeOf( Bar.prototype, Foo.prototype );

如果忽略掉 Object.create(..) 方法带来的轻微性能损失(抛弃的对象需要进行垃圾回收),它实际上比 ES6 及其之后的方法更短而且可读性更高。不过无论如何,这是两种完全不同的语法。

29. 原型 - 小结

如果要访问对象中并不存在的一个属性,[[Get]] 操作(参见第 3 章)就会查找对象内部 [[Prototype]] 关联的对象。这个关联关系实际上定义了一条“原型链”(有点像嵌套的作用域链),在查找属性时会对它进行遍历。

所有普通对象都有内置的 Object.prototype,指向原型链的顶端(比如说全局作用域),如果在原型链中找不到指定的属性就会停止。toString()、valueOf() 和其他一些通用的功能都存在于 Object.prototype 对象上,因此语言中所有的对象都可以使用它们。

关联两个对象最常用的方法是使用 new 关键词进行函数调用,在调用的 4 个步骤(第 2章)中会创建一个关联其他对象的新对象。

4 个步骤:
创建(或者说构造)一个全新的对象。
这个新对象会被执行 [[ 原型 ]] 连接。
这个新对象会绑定到函数调用的 this。
如果函数没有返回其他对象,那么 new 表达式中的函数调用会自动返回这个新对象。

使用 new 调用函数时会把新对象的 .prototype 属性关联到“其他对象”。带 new 的函数调用通常被称为“构造函数调用”,尽管它们实际上和传统面向类语言中的类构造函数不一样。

虽然这些 JavaScript 机制和传统面向类语言中的“类初始化”和“类继承”很相似,但
是 JavaScript 中的机制有一个核心区别,那就是不会进行复制,对象之间是通过内部的 [[Prototype]] 链关联的。

出于各种原因,以“继承”结尾的术语(包括“原型继承”)和其他面向对象的术语都无
法帮助你理解 JavaScript 的真实机制(不仅仅是限制我们的思维模式)。

相比之下,“委托”是一个更合适的术语,因为对象之间的关系不是复制而是委托。

30. 行为委托

  • 面向对象风格的代码示例
function Foo(who) {
  this.me = who;
}
Foo.prototype.identify = function() {
  return "I am " + this.me;
};
function Bar(who) {
  Foo.call( this, who );
}
Bar.prototype = Object.create( Foo.prototype );
Bar.prototype.speak = function() {
  alert( "Hello, " + this.identify() + "." );
};
var b1 = new Bar( "b1" );
var b2 = new Bar( "b2" );
b1.speak();
b2.speak();

子类 Bar 继承了父类 Foo,然后生成了 b1 和 b2 两个实例。b1 委托了 Bar.prototype,后者委托了 Foo.prototype。

  • 对象关联风格的代码示例
Foo = {
  init: function(who) {
    this.me = who;
  },
  identify: function() {
    return "I am " + this.me;
  }
};
Bar = Object.create( Foo );
Bar.speak = function() {
  alert( "Hello, " + this.identify() + "." );
};
var b1 = Object.create( Bar );
b1.init( "b1" );
var b2 = Object.create( Bar );
b2.init( "b2" );
b1.speak();
b2.speak();
最后编辑于 :
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容