大话 JavaScript(Speaking JavaScript):第十六章到第二十章

发布时间 2024-01-11 12:28:43作者: 绝不原创的飞龙

第十六章:变量:作用域、环境和闭包

原文:16. Variables: Scopes, Environments, and Closures

译者:飞龙

协议:CC BY-NC-SA 4.0

本章首先解释了如何使用变量,然后详细介绍了它们的工作方式(环境、闭包等)。

声明变量

在 JavaScript 中,您在使用变量之前通过var语句声明变量:

var foo;
foo = 3; // OK, has been declared
bar = 5; // not OK, an undeclared variable

您还可以将声明与赋值结合在一起,立即初始化变量:

var foo = 3;

未初始化变量的值为undefined

> var x;
> x
undefined

背景:静态与动态

您可以从两个角度来检查程序的工作方式:

静态(或词法)

您在不运行程序的情况下检查程序的存在。给定以下代码,我们可以得出静态断言,即函数“g”嵌套在函数“f”内部:

function f() {
    function g() {
    }
}

形容词词法静态是同义词,因为两者都与程序的词汇(单词,源代码)有关。

动态

您在执行程序时检查发生的情况(“在运行时”)。给定以下代码:

function g() {
}
function f() {
    g();
}

当我们调用f()时,它调用g()。在运行时,gf调用表示动态关系。

背景:变量的范围

在本章的其余部分,您应该了解以下概念:

变量的范围

变量的范围是它可访问的位置。例如:

function foo() {
    var x;
}

在这里,“x”的直接作用域是函数“foo()”。

词法作用域

JavaScript 中的变量是词法作用域的,因此程序的静态结构决定了变量的作用域(不受例如函数从何处调用的影响)。

嵌套范围

如果作用域嵌套在变量的直接作用域内,则该变量在所有这些作用域中都是可访问的:

function foo(arg) {
    function bar() {
        console.log('arg: '+arg);
    }
    bar();
}
console.log(foo('hello')); // arg: hello

“arg”的直接范围是“foo()”,但它也可以在嵌套范围“bar()”中访问。就嵌套而言,“foo()”是外部范围,“bar()”是内部范围

遮蔽

如果作用域声明了与外部作用域中的变量同名的变量,则内部作用域中将阻止对外部变量的访问,并且所有嵌套在其中的作用域。对内部变量的更改不会影响外部变量,在离开内部作用域后,外部变量再次可访问:

var x = "global";
function f() {
    var x = "local";
    console.log(x); // local
}
f();
console.log(x); // global

在函数“f()”内部,全局“x”被本地“x”遮蔽。

变量是函数作用域的

大多数主流语言都是块作用域的:变量“存在于”最内部的周围代码块中。以下是 Java 的一个例子:

public static void main(String[] args) {
    { // block starts
        int foo = 4;
    } // block ends
    System.out.println(foo); // Error: cannot find symbol
}

在前面的代码中,变量foo只能在直接包围它的块内部访问。如果我们在块结束后尝试访问它,将会得到编译错误。

相比之下,JavaScript 的变量是函数作用域的:只有函数引入新的作用域;在作用域方面忽略了块。例如:

function main() {
    { // block starts
        var foo = 4;
    } // block ends
    console.log(foo); // 4
}

换句话说,“foo”在“main()”中是可访问的,而不仅仅是在块内部。

变量声明被提升

JavaScript 提升所有变量声明,将它们移动到其直接作用域的开头。这样可以清楚地说明如果在声明之前访问变量会发生什么:

function f() {
    console.log(bar);  // undefined
    var bar = 'abc';
    console.log(bar);  // abc
}

我们可以看到变量“bar”已经存在于“f()”的第一行,但它还没有值;也就是说,声明已经被提升,但赋值没有。JavaScript 执行f()时,就好像它的代码是:

function f() {
    var bar;
    console.log(bar);  // undefined
    bar = 'abc';
    console.log(bar);  // abc
}

如果声明已经声明了一个变量,那么什么也不会发生(变量的值不变):

> var x = 123;
> var x;
> x
123

每个函数声明也会被提升,但方式略有不同。完整的函数会被提升,而不仅仅是存储它的变量的创建(参见提升)。

最佳实践:了解提升,但不要害怕它

一些 JavaScript 风格指南建议您只在函数开头放置变量声明,以避免被提升所欺骗。如果您的函数相对较小(无论如何都应该是这样),那么您可以放松这个规则,将变量声明在使用它们的地方附近(例如,在for循环内部)。这样更好地封装了代码片段。显然,您应该意识到这种封装只是概念上的,因为函数范围的提升仍然会发生。

陷阱:给未声明的变量赋值会使其成为全局变量

在松散模式下,对未经var声明的变量进行赋值会创建一个全局变量:

> function sloppyFunc() { x = 123 }
> sloppyFunc()
> x
123

值得庆幸的是,严格模式在发生这种情况时会抛出异常:

> function strictFunc() { 'use strict'; x = 123 }
> strictFunc()
ReferenceError: x is not defined

通过 IIFE 引入新的作用域

通常,您会引入新的作用域来限制变量的生命周期。一个例子是您可能希望在if语句的“then”部分中这样做:只有在条件成立时才执行;如果它专门使用辅助变量,我们不希望它们“泄漏”到周围的作用域中:

function f() {
    if (condition) {
        var tmp = ...;
        ...
    }
    // tmp still exists here
    // => not what we want
}

如果要为then块引入新的作用域,可以定义一个函数并立即调用它。这是一种解决方法,模拟块作用域:

function f() {
    if (condition) {
        (function () {  // open block
            var tmp = ...;
            ...
        }());  // close block
    }
}

这是 JavaScript 中的一种常见模式。Ben Alman 建议将其称为立即调用函数表达式(IIFE,发音为“iffy”)。一般来说,IIFE 看起来像这样:

(function () { // open IIFE
    // inside IIFE
}()); // close IIFE

以下是关于 IIFE 的一些注意事项:

它立即被调用

在函数的闭括号后面的括号立即调用它。这意味着它的主体立即执行。

它必须是一个表达式

如果语句以关键字function开头,解析器会期望它是一个函数声明(参见Expressions Versus Statements)。但是函数声明不能立即调用。因此,我们通过以开括号开始语句来告诉解析器关键字function是函数表达式的开始。在括号内,只能有表达式。

需要分号

如果您在两个 IIFE 之间忘记了它,那么您的代码将不再起作用:

(function () {
    ...
}()) // no semicolon
(function () {
    ...
}());

前面的代码被解释为函数调用——第一个 IIFE(包括括号)是要调用的函数,第二个 IIFE 是参数。

注意

IIFE 会产生成本(在认知和性能方面),因此在if语句内部使用它很少有意义。上面的例子是为了教学目的而选择的。

IIFE 变体:前缀运算符

您还可以通过前缀运算符强制执行表达式上下文。例如,您可以通过逻辑非运算符这样做:

!function () { // open IIFE
    // inside IIFE
}(); // close IIFE

或通过void运算符(参见The void Operator):

void function () { // open IIFE
    // inside IIFE
}(); // close IIFE

使用前缀运算符的优点是忘记结束分号不会引起麻烦。

IIFE 变体:已经在表达式上下文中

请注意,如果您已经处于表达式上下文中,则不需要强制执行 IIFE 的表达式上下文。然后您不需要括号或前缀运算符。例如:

var File = function () { // open IIFE
    var UNTITLED = 'Untitled';
    function File(name) {
        this.name = name || UNTITLED;
    }
    return File;
}(); // close IIFE

在上面的例子中,有两个不同的变量名为File。一方面,有一个函数,只能直接在 IIFE 内部访问。另一方面,在第一行声明的变量。它被赋予在 IIFE 中返回的值。

IIFE 变体:带参数的 IIFE

您可以使用参数来定义 IIFE 内部的变量:

var x = 23;
(function (twice) {
    console.log(twice);
}(x * 2));

这类似于:

var x = 23;
(function () {
    var twice = x * 2;
    console.log(twice);
}());

IIFE 应用

IIFE 使您能够将私有数据附加到函数上。然后,您就不必声明全局变量,并且可以将函数与其状态紧密打包。您避免了污染全局命名空间:

var setValue = function () {
    var prevValue;
    return function (value) { // define setValue
        if (value !== prevValue) {
            console.log('Changed: ' + value);
            prevValue = value;
        }
    };
}();

IIFE 的其他应用在本书的其他地方提到:

全局变量

包含程序的所有范围称为全局范围程序范围。这是当进入脚本时所在的范围(无论是网页中的<script>标签还是.js文件)。在全局范围内,你可以通过定义一个函数来创建一个嵌套作用域。在这样的函数内部,你可以再次嵌套作用域。每个作用域都可以访问自己的变量以及包围它的作用域中的变量。由于全局范围包围所有其他作用域,它的变量可以在任何地方访问:

// here we are in global scope
var globalVariable = 'xyz';
function f() {
    var localVariable = true;
    function g() {
        var anotherLocalVariable = 123;

        // All variables of surround scopes are accessible
        localVariable = false;
        globalVariable = 'abc';
    }
}
// here we are again in global scope

最佳实践:避免创建全局变量

全局变量有两个缺点。首先,依赖全局变量的软件部分会受到副作用的影响;它们不够健壮,行为不够可预测,也不够可重用。

其次,网页上的所有 JavaScript 共享相同的全局变量:你的代码,内置函数,分析代码,社交媒体按钮等等。这意味着名称冲突可能会成为一个问题。这就是为什么最好尽可能隐藏全局范围内的变量。例如,不要这样做:

<!-- Don’t do this -->
<script>
    // Global scope
    var tmp = generateData();
    processData(tmp);
    persistData(tmp);
</script>

变量tmp变成了全局变量,因为它的声明是在全局范围内执行的。但它只在本地使用。因此,我们可以使用 IIFE(参见[通过 IIFE 引入新作用域](ch16.html#iife "通过 IIFE 引入新作用域"))将其隐藏在嵌套作用域中:

<script>
    (function () {  // open IIFE
        // Local scope
        var tmp = generateData();
        processData(tmp);
        persistData(tmp);
    }());  // close IIFE
</script>

模块系统导致全局变量减少

值得庆幸的是,模块系统(参见[模块系统](ch31.html#module_systems "模块系统"))大多消除了全局变量的问题,因为模块不通过全局范围进行接口,并且每个模块都有自己的模块全局变量的作用域。

全局对象

ECMAScript 规范使用内部数据结构环境来存储变量(参见[环境:管理变量](ch16.html#environments "环境:管理变量"))。该语言具有一个有点不寻常的特性,即通过对象(所谓的全局对象)使全局变量的环境可访问。全局对象可用于创建、读取和更改全局变量。在全局范围内,this指向它:

> var foo = 'hello';
> this.foo  // read global variable
'hello'

> this.bar = 'world';  // create global variable
> bar
'world'

请注意,全局对象具有原型。如果你想列出它的(自己的和继承的)所有属性,你需要一个诸如getAllPropertyNames()的函数,来自[列出所有属性键](ch17_split_000.html#getAllPropertyNames "列出所有属性键"):

> getAllPropertyNames(window).sort().slice(0, 5)
[ 'AnalyserNode', 'Array', 'ArrayBuffer', 'Attr', 'Audio' ]

JavaScript 的创造者 Brendan Eich 认为全局对象是他的“最大遗憾”之一。它对性能产生负面影响,使变量作用域的实现更加复杂,并导致代码模块化程度降低。

跨平台考虑

浏览器和 Node.js 都有用于引用全局对象的全局变量。不幸的是,它们是不同的:

  • 浏览器包含window,它作为文档对象模型(DOM)的一部分进行了标准化,而不是作为 ECMAScript 5 的一部分。每个框架或窗口都有一个全局对象。

  • Node.js 包含global,这是一个特定于 Node.js 的变量。每个模块都有自己的作用域,其中this指向具有该作用域变量的对象。因此,在模块内部,thisglobal是不同的。

在两个平台上,this都指向全局对象,但只有在全局范围内才是如此。这在 Node.js 上几乎从不会发生。如果你想以跨平台的方式访问全局对象,可以使用以下模式:

(function (glob) {
    // glob points to global object
}(typeof window !== 'undefined' ? window : global));

从现在开始,我使用window来指代全局对象,但在跨平台代码中,你应该使用前面的模式和glob

使用window的用例

本节描述了通过window访问全局变量的用例。但一般规则是:尽量避免这样做。

使用情况:标记全局变量

前缀window是代码引用全局变量而不是局部变量的视觉线索:

var foo = 123;
(function () {
    console.log(window.foo);  // 123
}());

但是,这会使您的代码变得脆弱。一旦您将foo从全局范围移动到另一个周围范围,它就会停止工作:

(function () {
    var foo = 123;
    console.log(window.foo);  // undefined
}());

因此,最好将foo作为变量而不是window的属性引用。如果您想明显地表明foo是全局或类似全局的变量,可以添加一个名称前缀,例如g_

var g_foo = 123;
(function () {
    console.log(g_foo);
}());

使用情况:内置

我不喜欢通过window引用内置全局变量。它们是众所周知的名称,因此从指示它们是全局的角度获得的帮助很少。而且前缀的window会增加混乱:

window.isNaN(...)  // no
isNaN(...)  // yes

使用情况:样式检查器

当您使用诸如 JSLint 和 JSHint 之类的样式检查工具时,使用window意味着在引用当前文件中未声明的全局变量时不会出错。但是,这两个工具都提供了方法来告诉它们这样的变量,并防止出现此类错误(在其文档中搜索“全局变量”)。

使用情况:检查全局变量是否存在

这不是一个常见的用例,但是 shim 和 polyfill 特别需要检查全局变量someVariable是否存在(请参阅Shims Versus Polyfills)。在这种情况下,window有所帮助:

if (window.someVariable) { ... }

这是执行此检查的安全方式。如果someVariable未声明,则以下语句会引发异常:

// Don’t do this
if (someVariable) { ... }

您可以通过window进行两种额外的检查方式;它们大致等效,但更加明确:

if (window.someVariable !== undefined) { ... }
if ('someVariable' in window) { ... }

检查变量是否存在(并具有值)的一般方法是通过typeof(请参阅typeof: Categorizing Primitives):

if (typeof someVariable !== 'undefined') { ... }

使用情况:在全局范围创建事物

window允许您将事物添加到全局范围(即使您处于嵌套范围),并且它允许您有条件地这样做:

if (!window.someApiFunction) {
    window.someApiFunction = ...;
}

通常最好通过var将事物添加到全局范围,而您处于全局范围。但是,window提供了一种有条件地进行添加的清晰方式。

环境:管理变量

提示

环境是一个高级主题。它们是 JavaScript 内部的细节。如果您想更深入地了解变量的工作原理,请阅读本节。

当程序执行进入其作用域时,变量就会出现。然后它们需要存储空间。提供该存储空间的数据结构在 JavaScript 中称为环境。它将变量名映射到值。其结构与 JavaScript 对象的结构非常相似。环境有时会在您离开其作用域后继续存在。因此,它们存储在堆上,而不是栈上。

变量以两种方式传递。如果您愿意的话,它们有两个维度:

动态维度:调用函数

每次调用函数时,它都需要为其参数和变量提供新的存储空间。完成后,该存储通常可以被回收。例如,考虑阶乘函数的以下实现。它递归调用自身多次,每次都需要n的新存储空间:

function fac(n) {
    if (n <= 1) {
        return 1;
    }
    return n * fac(n - 1);
}

词法(静态)维度:保持与周围作用域的连接

无论函数被调用多少次,它总是需要访问自己(新鲜的)局部变量和周围作用域的变量。例如,以下函数doNTimes内部有一个辅助函数doNTimesRec。当doNTimesRec多次调用自身时,每次都会创建一个新的环境。但是,在这些调用期间,doNTimesRec也保持与doNTimes的单个环境的连接(类似于所有函数共享单个全局环境)。doNTimesRec需要该连接来访问(1)行中的action

function doNTimes(n, action) {
    function doNTimesRec(x) {
        if (x >= 1) {
            action();  // (1)
            doNTimesRec(x-1);
        }
    }
    doNTimesRec(n);
}

这两个维度的处理方式如下:

动态维度:执行上下文的堆栈

每次调用函数时,都会创建一个新的环境,用于将标识符(参数和变量)映射到值。为了处理递归,执行上下文——对环境的引用——在堆栈中进行管理。该堆栈反映了调用堆栈。

词法维度:环境链

为了支持这个维度,函数通过内部属性[[Scope]]记录了它被创建时的作用域。当调用函数时,会为进入的新作用域创建一个环境。该环境有一个称为outer的字段,指向外部作用域的环境,并通过[[Scope]]设置。因此,始终存在一条环境链,从当前活动环境开始,继续到它的外部环境,依此类推。每条链以全局环境结束(最初调用函数的作用域)。全局环境的outer字段为null

为了解析标识符,会遍历完整的环境链,从活动环境开始。

让我们看一个例子:

function myFunction(myParam) {
    var myVar = 123;
    return myFloat;
}
var myFloat = 1.3;
// Step 1
myFunction('abc');  // Step 2

变量的动态维度通过执行上下文的堆栈处理,静态维度通过环境链处理。活动的执行上下文、环境和函数被突出显示。步骤 1 显示了在调用 myFunction(abc)函数之前这些数据结构。步骤 2 显示了在函数调用期间。图 16-1:变量的动态维度通过执行上下文的堆栈处理,静态维度通过环境链处理。活动的执行上下文、环境和函数被突出显示。步骤 1 显示了在调用 myFunction(abc)函数之前这些数据结构。步骤 2 显示了在函数调用期间。

图 16-1 函数之前这些数据结构。步骤 2 显示了在函数调用期间。")说明了在执行前述代码时会发生什么:

  1. myFunctionmyFloat已经存储在全局环境(#0)中。请注意,由myFunction引用的function对象通过内部属性[[Scope]]指向它的作用域(全局作用域)。

  2. 对于执行myFunction('abc'),会创建一个新的环境(#1),其中包含参数和局部变量。它通过outer(从myFunction.[[Scope]]初始化)引用其外部环境。由于外部环境,myFunction可以访问myFloat

闭包:函数保持与它们诞生作用域的连接

如果一个函数离开了它被创建的作用域,它仍然与该作用域(以及周围作用域)的变量保持连接。例如:

function createInc(startValue) {
    return function (step) {
        startValue += step;
        return startValue;
    };
}

createInc()返回的函数不会失去与startValue的连接——该变量为函数提供了跨函数调用持续存在的状态:

> var inc = createInc(5);
> inc(1)
6
> inc(2)
8

闭包是一个函数加上它被创建时的作用域的连接。这个名字源于闭包“封闭”了函数的自由变量。如果一个变量不是在函数内声明的,即来自“外部”,那么它就是自由的。

通过环境处理闭包

提示

这是一个深入探讨闭包工作原理的高级部分。您应该熟悉环境(请查看环境:管理变量)。

闭包是执行离开其范围后仍然存在的环境的一个例子。为了说明闭包的工作原理,让我们将之前与createInc()的交互分解为四个步骤(在每个步骤中,突出显示活动执行上下文及其环境;如果函数是活动的,它也会被突出显示):

  1. 这一步发生在交互之前,并且在评估createInc的函数声明之后。createInc的条目已添加到全局环境(#0)中,并指向一个函数对象。

没有标题的图片

  1. 这一步发生在函数调用createInc(5)期间。为createInc创建了一个新的环境(#1)并推送到堆栈上。它的外部环境是全局环境(与createInc.[[Scope]]相同)。该环境保存了参数startValue

没有标题的图片

  1. 这一步发生在对inc进行赋值之后。当我们从createInc返回时,指向其环境的执行上下文已从堆栈中移除,但是环境仍然存在于堆中,因为inc.[[Scope]]引用它。inc是一个闭包(函数加出生环境)。

没有标题的图片

  1. 这一步发生在执行inc(1)期间。已创建了一个新的环境(#1),并且指向它的执行上下文已被推送到堆栈上。它的外部环境是inc[[Scope]]。外部环境使inc可以访问startValue

没有标题的图片

  1. 这一步发生在执行inc(1)之后。不再有引用(执行上下文,outer字段或[[Scope]])指向inc的环境。因此它不再需要,并且可以从堆中删除。

没有标题的图片

陷阱:无意中共享环境

有时,您创建的函数的行为受当前范围内的变量的影响。在 JavaScript 中,这可能会有问题,因为每个函数应该使用函数创建时变量的值。但是,由于函数是闭包,函数将始终使用变量的当前值。在for循环中,这可能会导致事情无法正常工作。通过示例可以更清楚地说明:

function f() {
    var result = [];
    for (var i=0; i<3; i++) {
        var func = function () {
            return i;
        };
        result.push(func);
    }
    return result;
}
console.log(f()1;  // 3

f返回一个包含三个函数的数组。所有这些函数仍然可以访问f的环境,因此也可以访问i。实际上,它们共享相同的环境。然而,在循环结束后,i在该环境中的值为 3。因此,所有函数都返回3

这不是我们想要的。为了解决问题,我们需要在创建使用它的函数之前对索引i进行快照。换句话说,我们希望将每个函数与函数创建时i的值打包在一起。因此,我们采取以下步骤:

  1. 为返回的数组中的每个函数创建一个新的环境。

  2. 在该环境中存储(复制)i的当前值。

只有函数创建环境,因此我们使用 IIFE(参见通过 IIFE 引入新作用域)来完成第 1 步:

function f() {
    var result = [];
    for (var i=0; i<3; i++) {
        (function () { // step 1: IIFE
            var pos = i; // step 2: copy
            var func = function () {
                return pos;
            };
            result.push(func);
        }());
    }
    return result;
}
console.log(f()1;  // 1

请注意,该示例具有现实世界的相关性,因为在通过循环向 DOM 元素添加事件处理程序时会出现类似的情况。

第十七章:对象和继承

原文:17. Objects and Inheritance

译者:飞龙

协议:CC BY-NC-SA 4.0

JavaScript 中的面向对象编程(OOP)有几个层次:

每个新层只依赖于之前的层,使您能够逐步学习 JavaScript OOP。第 1 层和第 2 层形成一个简单的核心,您可以在更复杂的第 3 层和第 4 层让您感到困惑时进行参考。

第 1 层:单个对象

大致上,JavaScript 中的所有对象都是从字符串到值的映射(字典)。对象中的(键,值)条目称为属性。属性的键始终是文本字符串。属性的值可以是任何 JavaScript 值,包括函数。方法是其值为函数的属性。

属性的种类

有三种属性:

属性(或命名数据属性)

对象中的普通属性—即从字符串键到值的映射。命名数据属性包括方法。这是最常见的属性类型。

访问器(或命名访问器属性)

特殊方法的调用看起来像是读取或写入属性。普通属性是属性值的存储位置;访问器允许您计算属性的值。如果你愿意,它们是虚拟属性。有关详细信息,请参见访问器(getter 和 setter)

内部属性

仅存在于 ECMAScript 语言规范中。它们无法直接从 JavaScript 中访问,但可能有间接访问它们的方法。规范使用方括号写入内部属性的键。例如,[[Prototype]]保存对象的原型,并且可以通过Object.getPrototypeOf()读取。

对象字面量

JavaScript 的对象字面量允许您直接创建普通对象Object的直接实例)。以下代码使用对象字面量将对象分配给变量jane。对象具有两个属性:namedescribedescribe是一个方法:

var jane = {
    name: 'Jane',

    describe: function () {
        return 'Person named '+this.name;  // (1)
    },  // (2)
};
  1. 在方法中使用this来引用当前对象(也称为方法调用的接收者)。

  2. ECMAScript 5 允许在对象字面量中使用尾随逗号(在最后一个属性之后)。遗憾的是,并非所有旧版浏览器都支持它。尾随逗号很有用,因为您可以重新排列属性,而不必担心哪个属性是最后一个。

您可能会有这样的印象,即对象只是从字符串到值的映射。但它们不仅如此:它们是真正的通用对象。例如,您可以在对象之间使用继承(请参见第 2 层:对象之间的原型关系),并且可以保护对象免受更改。直接创建对象的能力是 JavaScript 的一大特点:您可以从具体对象开始(无需类!),然后稍后引入抽象。例如,构造函数是对象的工厂(如第 3 层:构造函数—实例的工厂中讨论的),大致类似于其他语言中的类。

点运算符(.):通过固定键访问属性

点运算符提供了一种紧凑的语法来访问属性。属性键必须是标识符(参见Legal Identifiers)。如果您想要读取或写入具有任意名称的属性,您需要使用括号运算符(参见Bracket Operator ([]): Accessing Properties via Computed Keys)。

本节中的示例与以下对象一起使用:

var jane = {
    name: 'Jane',

    describe: function () {
        return 'Person named '+this.name;
    }
};

获取属性

点运算符允许您“获取”属性(读取其值)。以下是一些示例:

> jane.name  // get property `name`
'Jane'
> jane.describe  // get property `describe`
[Function]

获取不存在的属性返回undefined

> jane.unknownProperty
undefined

调用方法

点运算符也用于调用方法:

> jane.describe()  // call method `describe`
'Person named Jane'

设置属性

您可以使用赋值运算符(=)通过点表示法设置属性的值。例如:

> jane.name = 'John';  // set property `name`
> jane.describe()
'Person named John'

如果属性尚不存在,则设置它会自动创建它。如果属性已经存在,则设置它会更改其值。

删除属性

delete运算符允许您完全从对象中删除属性(整个键值对)。例如:

> var obj = { hello: 'world' };
> delete obj.hello
true
> obj.hello
undefined

如果您仅将属性设置为undefined,则该属性仍然存在,对象仍然包含其键:

> var obj = { foo: 'a', bar: 'b' };

> obj.foo = undefined;
> Object.keys(obj)
[ 'foo', 'bar' ]

如果删除属性,则其键也将消失:

> delete obj.foo
true
> Object.keys(obj)
[ 'bar' ]

delete仅影响对象的直接(“自有”,非继承的)属性。其原型不会受到影响(参见Deleting an inherited property)。

提示

谨慎使用delete运算符。大多数现代 JavaScript 引擎会优化通过构造函数创建的实例的性能,如果它们的“形状”不发生变化(大致上:不会删除或添加属性)。删除属性会阻止该优化。

delete的返回值

如果属性是自有属性,但无法删除,则delete返回false。在所有其他情况下,它返回true。以下是一些示例。

作为准备工作,我们创建一个可以删除的属性和另一个无法删除的属性(Getting and Defining Properties via Descriptors解释了Object.defineProperty()):

var obj = {};
Object.defineProperty(obj, 'canBeDeleted', {
    value: 123,
    configurable: true
});
Object.defineProperty(obj, 'cannotBeDeleted', {
    value: 456,
    configurable: false
});

对于无法删除的自有属性,delete返回false

> delete obj.cannotBeDeleted
false

在所有其他情况下,delete都会返回true

> delete obj.doesNotExist
true
> delete obj.canBeDeleted
true

即使不改变任何内容,delete也会返回true(继承属性永远不会被删除):

> delete obj.toString
true
> obj.toString // still there
[Function: toString]

不寻常的属性键

虽然您不能将保留字(例如varfunction)用作变量名,但您可以将它们用作属性键:

> var obj = { var: 'a', function: 'b' };
> obj.var
'a'
> obj.function
'b'

数字可以在对象文字中用作属性键,但它们被解释为字符串。点运算符只能访问其键为标识符的属性。因此,您需要使用括号运算符(如下例所示)来访问其键为数字的属性:

> var obj = { 0.7: 'abc' };
> Object.keys(obj)
[ '0.7' ]
> obj['0.7']
'abc'

对象文字还允许您使用任意字符串(既不是标识符也不是数字)作为属性键,但您必须对其进行引用。同样,您需要使用括号运算符来访问属性值:

> var obj = { 'not an identifier': 123 };
> Object.keys(obj)
[ 'not an identifier' ]
> obj['not an identifier']
123

括号运算符([]):通过计算键访问属性

虽然点运算符适用于固定属性键,但括号运算符允许您通过表达式引用属性。

通过括号运算符获取属性

括号运算符允许您通过表达式计算属性的键:

> var obj = { someProperty: 'abc' };

> obj['some' + 'Property']
'abc'

> var propKey = 'someProperty';
> obj[propKey]
'abc'

这也允许您访问其键不是标识符的属性:

> var obj = { 'not an identifier': 123 };
> obj['not an identifier']
123

请注意,括号运算符会将其内部强制转换为字符串。例如:

> var obj = { '6': 'bar' };
> obj[3+3]  // key: the string '6'
'bar'

通过括号运算符调用方法

调用方法的工作方式与您期望的一样:

> var obj = { myMethod: function () { return true } };
> obj['myMethod']()
true

通过括号运算符设置属性

设置属性的工作方式与点运算符类似:

> var obj = {};
> obj['anotherProperty'] = 'def';
> obj.anotherProperty
'def'

通过括号运算符删除属性

删除属性的工作方式也与点运算符类似:

> var obj = { 'not an identifier': 1, prop: 2 };
> Object.keys(obj)
[ 'not an identifier', 'prop' ]
> delete obj['not an identifier']
true
> Object.keys(obj)
[ 'prop' ]

将任何值转换为对象

这并不是一个常见的用例,但有时你需要将任意值转换为对象。Object() 作为函数(而不是构造函数)提供了这种服务。它产生以下结果:

结果
(不带参数调用) {}
undefined {}
null {}
布尔值 bool new Boolean(bool)
数字 num new Number(num)
字符串 str new String(str)
对象 obj obj(不变,无需转换)

以下是一些例子:

> Object(null) instanceof Object
true

> Object(false) instanceof Boolean
true

> var obj = {};
> Object(obj) === obj
true

以下函数检查 value 是否为对象:

function isObject(value) {
    return value === Object(value);
}

请注意,如果 value 不是对象,则前面的函数将创建一个对象。你可以通过 typeof 实现相同的功能,而不需要这样做(参见Pitfall: typeof null)。

你也可以将 Object 作为构造函数调用,这将产生与作为函数调用相同的结果:

> var obj = {};
> new Object(obj) === obj
true

> new Object(123) instanceof Number
true

提示

避免使用构造函数;几乎总是更好的选择是一个空对象字面量:

var obj = new Object(); // avoid
var obj = {}; // prefer

函数和方法的隐式参数 this

当你调用一个函数时,this 总是一个(隐式)参数:

松散模式下的普通函数

即使普通函数对 this 没有用处,它仍然存在作为一个特殊变量,其值始终是全局对象(在浏览器中是 window;参见全局对象):

> function returnThisSloppy() { return this }
> returnThisSloppy() === window
true

严格模式下的普通函数

this 总是 undefined

> function returnThisStrict() { 'use strict'; return this }
> returnThisStrict() === undefined
true

方法

this 指的是调用方法的对象:

> var obj = { method: returnThisStrict };
> obj.method() === obj
true

在方法的情况下,this 的值被称为方法调用的接收者

在设置 this 的情况下调用函数:call()、apply() 和 bind()

记住函数也是对象。因此,每个函数都有自己的方法。本节介绍了其中三个方法,并帮助调用函数。这三种方法在以下部分中用于解决调用函数的一些陷阱。即将出现的示例都涉及以下对象 jane

var jane = {
    name: 'Jane',
    sayHelloTo: function (otherName) {
        'use strict';
        console.log(this.name+' says hello to '+otherName);
    }
};

Function.prototype.call(thisValue, arg1?, arg2?, ...)

第一个参数是被调用函数内部的 this 的值;其余参数作为参数传递给被调用的函数。以下三次调用是等价的:

jane.sayHelloTo('Tarzan');

jane.sayHelloTo.call(jane, 'Tarzan');

var func = jane.sayHelloTo;
func.call(jane, 'Tarzan');

对于第二次调用,你需要重复 jane,因为 call() 不知道你是如何得到它被调用的函数的。

Function.prototype.apply(thisValue, argArray)

第一个参数是被调用函数内部的 this 的值;第二个参数是一个提供调用参数的数组。以下三次调用是等价的:

jane.sayHelloTo('Tarzan');

jane.sayHelloTo.apply(jane, ['Tarzan']);

var func = jane.sayHelloTo;
func.apply(jane, ['Tarzan']);

对于第二次调用,你需要重复 jane,因为 apply() 不知道你是如何得到它被调用的函数的。

用于构造函数的 apply() 解释了如何在构造函数中使用 apply()

Function.prototype.bind(thisValue, arg1?, ..., argN?)

这个方法执行部分函数应用——意味着它创建一个新的函数,以以下方式调用 bind() 的接收者:this 的值是 thisValue,参数从 arg1 开始直到 argN,然后是新函数的参数。换句话说,当新函数调用原始函数时,它将其参数附加到 arg1, ..., argN。让我们看一个例子:

function func() {
    console.log('this: '+this);
    console.log('arguments: '+Array.prototype.slice.call(arguments));
}
var bound = func.bind('abc', 1, 2);

数组方法 slice 用于将 arguments 转换为数组,这对于记录它是必要的(这个操作在类数组对象和通用方法中有解释)。bound 是一个新函数。以下是交互:

> bound(3)
this: abc
arguments: 1,2,3

以下三次对 sayHelloTo 的调用都是等价的:

jane.sayHelloTo('Tarzan');

var func1 = jane.sayHelloTo.bind(jane);
func1('Tarzan');

var func2 = jane.sayHelloTo.bind(jane, 'Tarzan');
func2();

用于构造函数的 apply()

假设 JavaScript 有一个三个点运算符(...),可以将数组转换为实际参数。这样的运算符将允许您使用Math.max()(参见其他函数)与数组。在这种情况下,以下两个表达式将是等价的:

Math.max(...[13, 7, 30])
Math.max(13, 7, 30)

对于函数,您可以通过apply()实现三个点运算符的效果:

> Math.max.apply(null, [13, 7, 30])
30

三个点运算符对构造函数也有意义:

new Date(...[2011, 11, 24]) // Christmas Eve 2011

遗憾的是,这里apply()不起作用,因为它只能帮助函数或方法调用,而不能帮助构造函数调用。

手动模拟构造函数的 apply()

我们可以分两步模拟apply()

步骤 1

通过方法调用将参数传递给Date(它们还不在数组中):

new (Date.bind(null, 2011, 11, 24))

前面的代码使用bind()创建一个没有参数的构造函数,并通过new调用它。

步骤 2

使用apply()将数组传递给bind()。因为bind()是一个方法调用,所以我们可以使用apply()

new (Function.prototype.bind.apply(
         Date, [null, 2011, 11, 24]))

前面的数组仍然有一个多余的元素null。我们可以使用concat()来添加:

var arr = [2011, 11, 24];
new (Function.prototype.bind.apply(
         Date, [null].concat(arr)))

一个库方法

前面的手动解决方法是受到 Mozilla 发布的库方法的启发。以下是它的一个稍微编辑过的版本:

if (!Function.prototype.construct) {
    Function.prototype.construct = function(argArray) {
        if (! Array.isArray(argArray)) {
            throw new TypeError("Argument must be an array");
        }
        var constr = this;
        var nullaryFunc = Function.prototype.bind.apply(
            constr, [null].concat(argArray));
        return new nullaryFunc();
    };
}

这是使用的方法:

> Date.construct([2011, 11, 24])
Sat Dec 24 2011 00:00:00 GMT+0100 (CET)

另一种方法

与之前的方法相比的另一种方法是通过Object.create()创建一个未初始化的实例,然后通过apply()调用构造函数(作为函数)。这意味着您实际上正在重新实现new运算符(一些检查被省略):

Function.prototype.construct = function(argArray) {
    var constr = this;
    var inst = Object.create(constr.prototype);
    var result = constr.apply(inst, argArray); // (1)

    // Check: did the constructor return an object
    // and prevent `this` from being the result?
    return result ? result : inst;
};

警告

前面的代码对于大多数内置构造函数都不起作用,当作为函数调用时总是产生新的实例。换句话说,(1)处的步骤没有设置inst为期望的值。

陷阱:提取方法时丢失 this

如果您从对象中提取一个方法,它将再次成为一个真正的函数。它与对象的连接被切断,通常不再正常工作。例如,考虑以下对象counter

var counter = {
    count: 0,
    inc: function () {
        this.count++;
    }
}

提取inc并调用它(作为函数!)失败:

> var func = counter.inc;
> func()
> counter.count  // didn’t work
0

这是解释:我们已经将counter.inc的值作为函数调用。因此,this是全局对象,我们执行了window.count++window.count不存在,是undefined。对它应用++运算符会将其设置为NaN

> count  // global variable
NaN

如何获得警告

如果方法inc()处于严格模式,您会收到一个警告:

> counter.inc = function () { 'use strict'; this.count++ };
> var func2 = counter.inc;
> func2()
TypeError: Cannot read property 'count' of undefined

原因是当我们调用严格模式函数func2时,thisundefined,导致错误。

如何正确提取方法

由于bind(),我们可以确保inc不会失去与counter的连接:

> var func3 = counter.inc.bind(counter);
> func3()
> counter.count  // it worked!
1

回调和提取的方法

在 JavaScript 中,有许多接受回调的函数和方法。浏览器中的示例包括setTimeout()和事件处理。如果我们将counter.inc作为回调传入,它也会作为函数调用,导致刚才描述的相同问题。为了说明这一现象,让我们使用一个简单的回调调用函数:

function callIt(callback) {
    callback();
}

通过callIt执行counter.count会触发警告(由于严格模式):

> callIt(counter.inc)
TypeError: Cannot read property 'count' of undefined

与以前一样,我们通过bind()来修复问题:

> callIt(counter.inc.bind(counter))
> counter.count  // one more than before
2

警告

每次调用bind()都会创建一个新的函数。这在注册和注销回调时(例如事件处理)会产生后果。您需要将注册的值存储在某个地方,并且也需要用它来进行注销。

陷阱:方法内部的函数会遮蔽 this

您经常在 JavaScript 中嵌套函数定义,因为函数可以是参数(例如回调),并且因为它们可以通过函数表达式在原地创建。当一个方法包含一个普通函数并且您想在后者内部访问前者的this时,这会导致问题,因为方法的this被普通函数的this遮蔽了(后者甚至对自己的this没有任何用处)。在以下示例中,(1)处的函数尝试访问(2)处的方法的this

var obj = {
    name: 'Jane',
    friends: [ 'Tarzan', 'Cheeta' ],
    loop: function () {
        'use strict';
        this.friends.forEach(
            function (friend) {  // (1)
                console.log(this.name+' knows '+friend);  // (2)
            }
        );
    }
};

显然,这会失败,因为函数(1)有自己的this,在这里是undefined

> obj.loop();
TypeError: Cannot read property 'name' of undefined

有三种方法可以解决这个问题。

解决方法 1:that = this

我们将this分配给一个不会在嵌套函数中被遮蔽的变量:

loop: function () {
    'use strict';
    var that = this;
    this.friends.forEach(function (friend) {
        console.log(that.name+' knows '+friend);
    });
}

以下是交互:

> obj.loop();
Jane knows Tarzan
Jane knows Cheeta

解决方法 2:bind()

我们可以使用bind()this提供一个固定值,即方法的this(行(1)):

loop: function () {
    'use strict';
    this.friends.forEach(function (friend) {
        console.log(this.name+' knows '+friend);
    }.bind(this));  // (1)
}

解决方法 3:forEach()的 thisValue

针对forEach()(参见检查方法)的一个特定解决方法是在回调之后提供第二个参数,该参数成为回调的this

loop: function () {
    'use strict';
    this.friends.forEach(function (friend) {
        console.log(this.name+' knows '+friend);
    }, this);
}

第 2 层:对象之间的原型关系

两个对象之间的原型关系涉及继承:每个对象都可以有另一个对象作为其原型。然后前一个对象继承其原型的所有属性。对象通过内部属性[[Prototype]]指定其原型。每个对象都有这个属性,但它可以是null。通过[[Prototype]]属性连接的对象链称为原型链(图 17-1)。

原型链。图 17-1。原型链。

为了了解基于原型(或原型式)继承的工作原理,让我们看一个例子(使用虚构的语法来指定[[Prototype]]属性):

var proto = {
    describe: function () {
        return 'name: '+this.name;
    }
};
var obj = {
    [[Prototype]]: proto,
    name: 'obj'
};

对象objproto继承了属性describe。它还有一个所谓的自有(非继承的,直接的)属性,name

继承

obj继承了属性describe;您可以像对象本身拥有该属性一样访问它:

> obj.describe
[Function]

每当通过obj访问属性时,JavaScript 会从该对象开始搜索,并继续搜索其原型、原型的原型等。这就是为什么我们可以通过obj.describe访问proto.describe。原型链的行为就像它是一个单一对象一样。当调用方法时,这种幻觉总是保持:this的值始终是方法开始搜索的对象,而不是找到方法的对象。这允许方法访问原型链的所有属性。例如:

> obj.describe()
'name: obj'

describe()内部,thisobj,这允许该方法访问obj.name

覆盖

在原型链中,对象中的属性覆盖了“后来”对象中具有相同键的属性:首先找到前者属性。它隐藏了后者属性,后者属性无法再被访问。例如,让我们在obj中覆盖方法proto.describe()

> obj.describe = function () { return 'overridden' };
> obj.describe()
'overridden'

这类似于类语言中方法覆盖的工作原理。

通过原型在对象之间共享数据

原型非常适合在对象之间共享数据:多个对象获得相同的原型,其中包含所有共享的属性。让我们看一个例子。对象janetarzan都包含相同的方法describe()。这是我们希望通过共享来避免的事情:

var jane = {
    name: 'Jane',
    describe: function () {
        return 'Person named '+this.name;
    }
};
var tarzan = {
    name: 'Tarzan',
    describe: function () {
        return 'Person named '+this.name;
    }
};

两个对象都是人。它们的name属性不同,但我们可以让它们共享describe方法。我们通过创建一个名为PersonProto的共同原型,并将describe放入其中来实现这一点(图 17-2)。

对象 jane 和 tarzan 共享原型 PersonProto,因此共享属性 describe。图 17-2。对象 jane 和 tarzan 共享原型 PersonProto,因此共享属性 describe。

以下代码创建了对象janetarzan,它们共享原型PersonProto

var PersonProto = {
    describe: function () {
        return 'Person named '+this.name;
    }
};
var jane = {
    [[Prototype]]: PersonProto,
    name: 'Jane'
};
var tarzan = {
    [[Prototype]]: PersonProto,
    name: 'Tarzan'
};

以下是交互:

> jane.describe()
Person named Jane
> tarzan.describe()
Person named Tarzan

这是一种常见的模式:数据驻留在原型链的第一个对象中,而方法驻留在后来的对象中。JavaScript 的原型继承设计支持这种模式:设置属性仅影响原型链中的第一个对象,而获取属性则考虑整个链条(参见设置和删除仅影响自有属性)。

获取和设置原型

到目前为止,我们假装你可以从 JavaScript 中访问内部属性[[Prototype]]。但是语言不允许你这样做。相反,有用于读取原型和创建具有给定原型的新对象的函数。

创建具有给定原型的新对象

这个调用:

Object.create(proto, propDescObj?)

创建一个原型为proto的对象。可以通过描述符添加属性(在属性描述符中有解释)。在以下示例中,对象jane获得了原型PersonProto和一个可变属性name,其值为'Jane'(通过属性描述符指定):

var PersonProto = {
    describe: function () {
        return 'Person named '+this.name;
    }
};
var jane = Object.create(PersonProto, {
    name: { value: 'Jane', writable: true }
});

以下是交互:

> jane.describe()
'Person named Jane'

但是你经常只是创建一个空对象,然后手动添加属性,因为描述符太啰嗦了:

var jane = Object.create(PersonProto);
jane.value = 'Jane';

读取对象的原型

这个方法调用:

Object.getPrototypeOf(obj)

返回obj的原型。继续前面的例子:

> Object.getPrototypeOf(jane) === PersonProto
true

检查一个对象是否是另一个对象的原型

这种语法:

Object.prototype.isPrototypeOf(obj)

检查方法的接收者是否是obj的(直接或间接)原型。换句话说:接收者和obj是否在同一个原型链中,obj是否在接收者之前?例如:

> var A = {};
> var B = Object.create(A);
> var C = Object.create(B);
> A.isPrototypeOf(C)
true
> C.isPrototypeOf(A)
false

查找定义属性的对象

以下函数遍历对象obj的属性链。它返回第一个具有键propKey的自有属性的对象,如果没有这样的对象,则返回null

function getDefiningObject(obj, propKey) {
    obj = Object(obj); // make sure it’s an object
    while (obj && !{}.hasOwnProperty.call(obj, propKey)) {
        obj = Object.getPrototypeOf(obj);
        // obj is null if we have reached the end
    }
    return obj;
}

在前面的代码中,我们通用地调用了方法Object.prototype.hasOwnProperty(参见通用方法:从原型中借用方法)。

特殊属性 proto

一些 JavaScript 引擎有一个特殊的属性,用于获取和设置对象的原型:__proto__。它为语言带来了对[[Prototype]]的直接访问:

> var obj = {};

> obj.__proto__ === Object.prototype
true

> obj.__proto__ = Array.prototype
> Object.getPrototypeOf(obj) === Array.prototype
true

有几件事情你需要知道关于__proto__

  • __proto__的发音是“dunder proto”,是“double underscore proto”的缩写。这种发音是从 Python 编程语言中借来的(如 Ned Batchelder 在 2006 年所建议的)。在 Python 中,双下划线的特殊变量非常频繁。

  • __proto__不是 ECMAScript 5 标准的一部分。因此,如果您希望您的代码符合该标准并且可以在当前的 JavaScript 引擎中可靠运行,那么您不应该使用它。

  • 然而,越来越多的引擎正在添加对__proto__的支持,它将成为 ECMAScript 6 的一部分。

  • 以下表达式检查引擎是否支持__proto__作为特殊属性:

    Object.getPrototypeOf({ __proto__: null }) === null
    

设置和删除仅影响自有属性

只有获取属性才会考虑对象的完整原型链。设置和删除会忽略继承,只影响自有属性。

设置属性

设置属性会创建一个自有属性,即使存在具有该键的继承属性。例如,给定以下源代码:

var proto = { foo: 'a' };
var obj = Object.create(proto);

objproto继承了foo

> obj.foo
'a'
> obj.hasOwnProperty('foo')
false

设置foo会产生期望的结果:

> obj.foo = 'b';
> obj.foo
'b'

然而,我们创建了一个自有属性,而没有改变proto.foo

> obj.hasOwnProperty('foo')
true
> proto.foo
'a'

其原因是原型属性应该被多个对象共享。这种方法允许我们非破坏性地“改变”它们——只有当前对象受到影响。

删除继承属性

您只能删除自有属性。让我们再次设置一个对象obj,并具有原型proto

var proto = { foo: 'a' };
var obj = Object.create(proto);

删除继承的属性foo没有效果:

> delete obj.foo
true
> obj.foo
'a'

有关delete运算符的更多信息,请参阅删除属性

在原型链的任何位置更改属性

如果要更改继承的属性,首先必须找到拥有该属性的对象(参见查找定义属性的对象),然后在该对象上执行更改。例如,让我们从前面的示例中删除属性foo

> delete getDefiningObject(obj, 'foo').foo;
true
> obj.foo
undefined

属性的迭代和检测

迭代和检测属性的操作受以下因素的影响:

继承(自有属性与继承属性)

对象的自有属性直接存储在该对象中。继承的属性存储在其原型之一中。

可枚举性(可枚举属性与不可枚举属性)

属性的可枚举性是一个属性(参见属性特性和属性描述符),一个可以是truefalse的标志。可枚举性很少重要,通常可以忽略(参见可枚举性:最佳实践)。

您可以列出自有属性键,列出所有可枚举属性键,并检查属性是否存在。以下各小节显示了如何操作。

列出自有属性键

您可以列出所有自有属性键,或仅列出可枚举的属性键:

  • Object.getOwnPropertyNames(obj)返回obj的所有自有属性的键。

  • Object.keys(obj)返回obj的所有可枚举自有属性的键。

请注意,属性通常是可枚举的(参见可枚举性:最佳实践),因此您可以使用Object.keys(),特别是对于您创建的对象。

列出所有属性键

如果要列出对象的所有属性(自有和继承的属性),则有两种选择。

选项 1 是使用循环:

for («variable» in «object»)
    «statement»

遍历object的所有可枚举属性的键。有关更详细的描述,请参见for-in

选项 2 是自己实现一个函数,该函数迭代所有属性(而不仅仅是可枚举的属性)。例如:

function getAllPropertyNames(obj) {
    var result = [];
    while (obj) {
        // Add the own property names of `obj` to `result`
        Array.prototype.push.apply(result, Object.getOwnPropertyNames(obj));
        obj = Object.getPrototypeOf(obj);
    }
    return result;
}

检查属性是否存在

您可以检查对象是否具有属性,或者属性是否直接存在于对象内部:

propKey in obj

如果obj具有键为propKey的属性,则返回true。继承的属性也包括在此测试中。

Object.prototype.hasOwnProperty(propKey)

如果接收者(this)具有键为propKey的自有(非继承)属性,则返回true

警告

避免直接在对象上调用hasOwnProperty(),因为它可能被覆盖(例如,由一个键为hasOwnProperty的自有属性):

> var obj = { hasOwnProperty: 1, foo: 2 };
> obj.hasOwnProperty('foo')  // unsafe
TypeError: Property 'hasOwnProperty' is not a function

相反,最好通用调用它(参见通用方法:从原型中借用方法):

> Object.prototype.hasOwnProperty.call(obj, 'foo')  // safe
true
> {}.hasOwnProperty.call(obj, 'foo')  // shorter
true

示例

以下示例基于这些定义:

var proto = Object.defineProperties({}, {
    protoEnumTrue: { value: 1, enumerable: true },
    protoEnumFalse: { value: 2, enumerable: false }
});
var obj = Object.create(proto, {
    objEnumTrue: { value: 1, enumerable: true },
    objEnumFalse: { value: 2, enumerable: false }
});

Object.defineProperties()通过描述符获取和定义属性中有解释,但它的工作原理应该是相当明显的:proto具有自有属性protoEnumTrueprotoEnumFalseobj具有自有属性objEnumTrueobjEnumFalse(并继承了proto的所有属性)。

注意

请注意,对象(例如前面示例中的proto)通常至少具有原型Object.prototype(其中定义了标准方法,如toString()hasOwnProperty()):

> Object.getPrototypeOf({}) === Object.prototype
true

可枚举性的影响

在属性相关的操作中,可枚举性只影响for-in循环和Object.keys()(它也影响JSON.stringify(),参见JSON.stringify(value, replacer?, space?))。

for-in循环遍历所有可枚举属性的键,包括继承的属性(注意Object.prototype的不可枚举属性都不会显示):

> for (var x in obj) console.log(x);
objEnumTrue
protoEnumTrue

Object.keys()返回所有自有(非继承的)可枚举属性的键:

> Object.keys(obj)
[ 'objEnumTrue' ]

如果你想要所有自有属性的键,你需要使用Object.getOwnPropertyNames()

> Object.getOwnPropertyNames(obj)
[ 'objEnumTrue', 'objEnumFalse' ]

继承的影响

只有for-in循环(参见上面的示例)和in运算符考虑继承:

> 'toString' in obj
true
> obj.hasOwnProperty('toString')
false
> obj.hasOwnProperty('objEnumFalse')
true

计算对象的自有属性数量

对象没有lengthsize这样的方法,所以你必须使用以下的解决方法:

Object.keys(obj).length

最佳实践:遍历自有属性

遍历属性键:

  • 结合for-inhasOwnProperty(),以for-in中描述的方式。这甚至可以在旧的 JavaScript 引擎上工作。例如:

    for (var key in obj) {
        if (Object.prototype.hasOwnProperty.call(obj, key)) {
            console.log(key);
        }
    }
    
  • 结合Object.keys()Object.getOwnPropertyNames()forEach()数组迭代:

    var obj = { first: 'John', last: 'Doe' };
    // Visit non-inherited enumerable keys
    Object.keys(obj).forEach(function (key) {
        console.log(key);
    });
    

遍历属性值或(key, value)对:

  • 遍历键,并使用每个键检索相应的值。其他语言可能会更简单,但 JavaScript 不会。

访问器(Getters 和 Setters)

ECMAScript 5 允许你编写方法,其调用看起来像是在获取或设置属性。这意味着属性是虚拟的,而不是存储空间。例如,你可以禁止设置属性,并且总是在读取时计算返回的值。

通过对象字面量定义访问器

以下示例使用对象字面量为属性foo定义了一个 setter 和一个 getter:

var obj = {
    get foo() {
        return 'getter';
    },
    set foo(value) {
        console.log('setter: '+value);
    }
};

以下是交互:

> obj.foo = 'bla';
setter: bla
> obj.foo
'getter'

通过属性描述符定义访问器

指定 getter 和 setter 的另一种方式是通过属性描述符(参见属性描述符)。以下代码定义了与前面的字面量相同的对象:

var obj = Object.create(
    Object.prototype, {  // object with property descriptors
        foo: {  // property descriptor
            get: function () {
                return 'getter';
            },
            set: function (value) {
                console.log('setter: '+value);
            }
        }
    }
);

访问器和继承

Getter 和 setter 是从原型继承的:

> var proto = { get foo() { return 'hello' } };
> var obj = Object.create(proto);

> obj.foo
'hello'

属性属性和属性描述符

提示

属性属性和属性描述符是一个高级主题。通常你不需要知道它们是如何工作的。

在本节中,我们将看一下属性的内部结构:

  • 属性属性是属性的原子构建块。

  • 属性描述符是一个用于以编程方式处理属性的数据结构。

属性属性

属性的所有状态,包括其数据和元数据,都存储在属性中。它们是属性具有的字段,就像对象具有属性一样。属性键通常用双括号写入。属性对于普通属性和访问器(getter 和 setter)都很重要。

以下属性是特定于普通属性的:

  • [[Value]]保存属性的值,它的数据。

  • [[Writable]]保存一个布尔值,指示属性的值是否可以被更改。

以下属性是特定于访问器的:

  • [[Get]]保存 getter,当属性被读取时调用的函数。该函数计算读取访问的结果。

  • [[Set]]保存 setter,当属性被设置为一个值时调用的函数。该函数将该值作为参数接收。

所有属性都具有以下属性:

  • [[Enumerable]]保存一个布尔值。使属性不可枚举会隐藏它,使其无法被某些操作检测到(参见属性的迭代和检测)。

  • [[可配置]]保存一个布尔值。如果它是false,您不能删除属性,更改其任何属性(除了[[值]]),或者将其从数据属性转换为访问器属性,反之亦然。换句话说,[[可配置]]控制属性元数据的可写性。有一个例外规则 - JavaScript 允许您将不可配置的属性从可写更改为只读,出于历史原因;数组的属性length一直是可写的且不可配置的。没有这个例外,您将无法冻结(参见冻结)数组。

默认值

如果您不指定属性,则使用以下默认值:

属性键 默认值
[[值]] undefined
[[获取]] undefined
[[设置]] undefined
[[可写]] false
[[可枚举]] false
[[可配置]] false

当您通过属性描述符创建属性时,这些默认值非常重要(请参阅下一节)。

属性描述符

属性描述符是用于以编程方式处理属性的数据结构。它是一个编码属性的属性的对象。描述符的每个属性对应一个属性。例如,以下是值为 123 的只读属性的描述符:

{
    value: 123,
    writable: false,
    enumerable: true,
    configurable: false
}

您可以通过访问器实现相同的目标,即不可变性。然后描述符如下所示:

{
    get: function () { return 123 },
    enumerable: true,
    configurable: false
}

通过描述符获取和定义属性

属性描述符用于两种操作:

获取属性

属性的所有属性都作为描述符返回。

定义属性

定义属性意味着根据属性是否已存在而有所不同:

  • 如果属性不存在,则创建一个新属性,其属性由描述符指定。如果描述符中没有相应的属性,则使用默认值。默认值由属性名称的含义决定。它们与通过赋值创建属性时使用的值相反(然后属性是可写的,可枚举的和可配置的)。例如:

    > var obj = {};
    > Object.defineProperty(obj, 'foo', { configurable: true });
    > Object.getOwnPropertyDescriptor(obj, 'foo')
    { value: undefined,
      writable: false,
      enumerable: false,
      configurable: true }
    

我通常不依赖默认值,并明确声明所有属性,以确保完全清晰。

  • 如果属性已经存在,则根据描述符指定的属性更新属性的属性。如果描述符中没有相应的属性,则不要更改它。这是一个例子(从上一个例子继续):

    > Object.defineProperty(obj, 'foo', { writable: true });
    > Object.getOwnPropertyDescriptor(obj, 'foo')
    { value: undefined,
      writable: true,
      enumerable: false,
      configurable: true }
    

以下操作允许您通过属性描述符获取和设置属性的属性:

Object.getOwnPropertyDescriptor(obj, propKey)

返回obj的自有(非继承的)属性的描述符,其键为propKey。如果没有这样的属性,则返回undefined

> Object.getOwnPropertyDescriptor(Object.prototype, 'toString')
{ value: [Function: toString],
  writable: true,
  enumerable: false,
  configurable: true }

> Object.getOwnPropertyDescriptor({}, 'toString')
undefined

Object.defineProperty(obj, propKey, propDesc)

创建或更改obj的属性,其键为propKey,其属性通过propDesc指定。返回修改后的对象。例如:

var obj = Object.defineProperty({}, 'foo', {
    value: 123,
    enumerable: true
    // writable: false (default value)
    // configurable: false (default value)
});

Object.defineProperties(obj, propDescObj)

Object.defineProperty()的批量版本。propDescObj的每个属性都保存一个属性描述符。属性的键和它们的值告诉Object.definePropertiesobj上创建或更改哪些属性。例如:

var obj = Object.defineProperties({}, {
    foo: { value: 123, enumerable: true },
    bar: { value: 'abc', enumerable: true }
});

Object.create(proto, propDescObj?)

首先创建一个原型为proto的对象。然后,如果已指定可选参数propDescObj,则以与Object.defineProperties相同的方式向其添加属性。最后,返回结果。例如,以下代码片段产生与上一个片段相同的结果:

var obj = Object.create(Object.prototype, {
    foo: { value: 123, enumerable: true },
    bar: { value: 'abc', enumerable: true }
});

复制对象

要创建对象的相同副本,您需要正确获取两件事:

  1. 复制必须具有与原始对象相同的原型(参见第 2 层:对象之间的原型关系)。

  2. 复制必须具有与原始对象相同的属性,并且具有相同的属性。

以下函数执行这样的复制:

function copyObject(orig) {
    // 1\. copy has same prototype as orig
    var copy = Object.create(Object.getPrototypeOf(orig));

    // 2\. copy has all of orig’s properties
    copyOwnPropertiesFrom(copy, orig);

    return copy;
}

属性通过这个函数从orig复制到copy

function copyOwnPropertiesFrom(target, source) {
    Object.getOwnPropertyNames(source)  // (1)
    .forEach(function(propKey) {  // (2)
        var desc = Object.getOwnPropertyDescriptor(source, propKey); // (3)
        Object.defineProperty(target, propKey, desc);  // (4)
    });
    return target;
};

涉及的步骤如下:

  1. 获取一个包含source的所有自有属性键的数组。

  2. 遍历这些键。

  3. 检索属性描述符。

  4. 使用该属性描述符在target中创建一个自有属性。

请注意,这个函数与 Underscore.js 库中的函数_.extend()非常相似。

属性:定义与赋值

以下两个操作非常相似:

然而,有一些微妙的差异:

  • 定义属性意味着创建一个新的自有属性或更新现有自有属性的属性。在这两种情况下,原型链完全被忽略。

  • 对属性进行赋值 prop意味着改变现有属性。过程如下:

  • 如果prop是一个 setter(自有或继承的),调用该 setter。

  • 否则,如果prop是只读的(自有或继承的),抛出异常(在严格模式下)或不做任何操作(在松散模式下)。下一节将更详细地解释这个(稍微意外的)现象。

  • 否则,如果prop是自有的(并且可写的),改变该属性的值。

  • 否则,要么没有属性prop,要么它是继承的并且可写的。在这两种情况下,定义一个可写、可配置和可枚举的自有属性prop。在后一种情况下,我们刚刚覆盖了一个继承的属性(非破坏性地改变了它)。在前一种情况下,一个丢失的属性已经被自动定义。这种自动定义是有问题的,因为在赋值中的拼写错误可能很难检测到。

继承的只读属性不能被赋值。

如果一个对象obj从原型继承了属性foo,并且foo不可写,那么你不能对obj.foo进行赋值:

var proto = Object.defineProperty({}, 'foo', {
    value: 'a',
    writable: false
});
var obj = Object.create(proto);

objproto继承了只读属性foo。在松散模式下,设置属性没有效果。

> obj.foo = 'b';
> obj.foo
'a'

在严格模式下,会抛出异常:

> (function () { 'use strict'; obj.foo = 'b' }());
TypeError: Cannot assign to read-only property 'foo'

这符合继承属性会改变,但是非破坏性的想法。如果继承属性是只读的,你希望禁止所有更改,甚至是非破坏性的更改。

请注意,您可以通过定义一个自有属性来规避此保护(请参阅前一小节了解定义和赋值之间的区别):

> Object.defineProperty(obj, 'foo', { value: 'b' });
> obj.foo
'b'

枚举性:最佳实践

一般规则是系统创建的属性是不可枚举的,而用户创建的属性是可枚举的:

> Object.keys([])
[]
> Object.getOwnPropertyNames([])
[ 'length' ]

> Object.keys(['a'])
[ '0' ]

这对于内置实例原型的方法特别适用:

> Object.keys(Object.prototype)
[]
> Object.getOwnPropertyNames(Object.prototype)
[ hasOwnProperty',
  'valueOf',
  'constructor',
  'toLocaleString',
  'isPrototypeOf',
  'propertyIsEnumerable',
  'toString' ]

枚举性的主要目的是告诉for-in循环它应该忽略哪些属性。正如我们刚才在查看内置构造函数的实例时所看到的,用户未创建的所有内容都会被for-in隐藏。

受枚举性影响的唯一操作是:

以下是一些需要牢记的最佳实践:

  • 对于你自己的代码,通常可以忽略枚举性,并且应该避免使用for-in循环(最佳实践:遍历数组)。

  • 通常不应向内置原型和对象添加属性。但如果您这样做,应该使它们不可枚举,以避免破坏现有代码。

保护对象

保护对象有三个级别,从弱到强依次列出:

  • 防止扩展

  • 封印

  • 冻结

防止扩展

通过以下方式防止扩展:

Object.preventExtensions(obj)

使向obj添加属性变得不可能。例如:

var obj = { foo: 'a' };
Object.preventExtensions(obj);

现在在松散模式下,添加属性会悄悄失败:

> obj.bar = 'b';
> obj.bar
undefined

并在严格模式下抛出错误:

> (function () { 'use strict'; obj.bar = 'b' }());
TypeError: Can't add property bar, object is not extensible

您仍然可以删除属性:

> delete obj.foo
true
> obj.foo
undefined

您可以通过以下方式检查对象是否可扩展:

Object.isExtensible(obj)

封印

通过以下方式封印:

Object.seal(obj)

防止扩展并使所有属性“不可配置”。后者意味着属性的属性(参见属性属性和属性描述符)不能再改变。例如,只读属性将永远保持只读。

以下示例演示了封印使所有属性都不可配置:

> var obj = { foo: 'a' };

> Object.getOwnPropertyDescriptor(obj, 'foo')  // before sealing
{ value: 'a',
  writable: true,
  enumerable: true,
  configurable: true }

> Object.seal(obj)

> Object.getOwnPropertyDescriptor(obj, 'foo')  // after sealing
{ value: 'a',
  writable: true,
  enumerable: true,
  configurable: false }

你仍然可以改变属性foo

> obj.foo = 'b';
'b'
> obj.foo
'b'

但你不能改变它的属性:

> Object.defineProperty(obj, 'foo', { enumerable: false });
TypeError: Cannot redefine property: foo

您可以通过以下方式检查对象是否被封闭:

Object.isSealed(obj)

冻结

通过以下方式进行冻结:

Object.freeze(obj)

它使所有属性都不可写,并封闭obj。换句话说,obj不可扩展,所有属性都是只读的,没有办法改变。让我们看一个例子:

var point = { x: 17, y: -5 };
Object.freeze(point);

在松散模式下再次出现悄悄失败:

> point.x = 2;  // no effect, point.x is read-only
> point.x
17

> point.z = 123;  // no effect, point is not extensible
> point
{ x: 17, y: -5 }

在严格模式下会出现错误:

> (function () { 'use strict'; point.x = 2 }());
TypeError: Cannot assign to read-only property 'x'

> (function () { 'use strict'; point.z = 123 }());
TypeError: Can't add property z, object is not extensible

您可以通过以下方式检查对象是否被冻结:

Object.isFrozen(obj)

陷阱:保护是浅层的

保护对象是浅层的:它影响自有属性,但不影响这些属性的值。例如,考虑以下对象:

var obj = {
    foo: 1,
    bar: ['a', 'b']
};
Object.freeze(obj);

即使您已经冻结了obj,它并不是完全不可变的——您可以改变属性bar的(可变)值:

> obj.foo = 2; // no effect
> obj.bar.push('c'); // changes obj.bar

> obj
{ foo: 1, bar: [ 'a', 'b', 'c' ] }

此外,obj具有原型Object.prototype,它也是可变的。

第三层:构造函数——实例的工厂

构造函数(简称构造函数)有助于生成某种相似的对象。它是一个普通函数,但是命名、设置和调用方式都不同。本节解释了构造函数的工作原理。它们对应于其他语言中的类。

我们已经看到了两个相似的对象的例子(在通过原型在对象之间共享数据中):

var PersonProto = {
    describe: function () {
        return 'Person named '+this.name;
    }
};
var jane = {
    [[Prototype]]: PersonProto,
    name: 'Jane'
};
var tarzan = {
    [[Prototype]]: PersonProto,
    name: 'Tarzan'
};

对象janetarzan都被认为是“人”,并共享原型对象PersonProto。让我们将该原型转换为一个构造函数Person,用于创建像janetarzan这样的对象。构造函数创建的对象称为它的实例。这样的实例与janetarzan具有相同的结构,由两部分组成:

  1. 数据是特定于实例的,并存储在实例对象的自有属性中(在前面的例子中是janetarzan)。

  2. 所有实例共享行为——它们有一个共同的原型对象和方法(在前面的例子中是PersonProto)。

构造函数是通过new运算符调用的函数。按照惯例,构造函数的名称以大写字母开头,而普通函数和方法的名称以小写字母开头。函数本身设置了第一部分:

function Person(name) {
    this.name = name;
}

Person.prototype中的对象成为Person的所有实例的原型。它贡献了第二部分:

Person.prototype.describe = function () {
    return 'Person named '+this.name;
};

让我们创建并使用Person的一个实例:

> var jane = new Person('Jane');
> jane.describe()
'Person named Jane'

我们可以看到Person是一个普通函数。只有当通过new调用它时,它才成为构造函数。new运算符执行以下步骤:

  • 首先设置行为:创建一个新对象,其原型是Person.prototype

  • 然后数据设置完成:Person接收该对象作为隐式参数this并添加实例属性。

图 17-3 展示了实例jane的样子。Person.prototypeconstructor属性指向构造函数,并在实例的构造函数属性中有解释。

jane 是构造函数 Person 的一个实例;它的原型是对象 Person.prototype。图 17-3: jane 是构造函数 Person 的一个实例;它的原型是对象 Person.prototype。

instanceof运算符允许我们检查一个对象是否是给定构造函数的实例:

> jane instanceof Person
true
> jane instanceof Date
false

JavaScript 中实现的new运算符

如果你手动实现new运算符,它看起来大致如下:

function newOperator(Constr, args) {
    var thisValue = Object.create(Constr.prototype); // (1)
    var result = Constr.apply(thisValue, args);
    if (typeof result === 'object' && result !== null) {
        return result; // (2)
    }
    return thisValue;
}

在第(1)行,你可以看到由构造函数Constr创建的实例的原型是Constr.prototype

第(2)行揭示了new运算符的另一个特性:你可以从构造函数中返回任意对象,并且它将成为new运算符的结果。如果你希望构造函数返回一个子构造函数的实例,这是很有用的(一个例子在从构造函数返回任意对象中给出)。

术语:两个原型

不幸的是,在 JavaScript 中术语prototype被使用得含糊不清:

原型 1:原型关系

一个对象可以是另一个对象的原型:

> var proto = {};
> var obj = Object.create(proto);
> Object.getPrototypeOf(obj) === proto
true

在前面的例子中,protoobj的原型。

原型 2:属性prototype的值

每个构造函数C都有一个指向对象的prototype属性。这个对象成为C的所有实例的原型:

> function C() {}
> Object.getPrototypeOf(new C()) === C.prototype
true

通常上下文会清楚表明是指两个原型中的哪一个。如果需要消除歧义,那么我们就需要使用prototype来描述对象之间的关系,因为这个名称已经通过getPrototypeOfisPrototypeOf进入了标准库。因此,我们需要为prototype属性引用的对象找到一个不同的名称。一个可能的选择是constructor prototype,但这是有问题的,因为构造函数也有原型:

> function Foo() {}
> Object.getPrototypeOf(Foo) === Function.prototype
true

因此,instance prototype是最佳选择。

实例的构造函数属性

默认情况下,每个函数C都包含一个实例原型对象C.prototype,它的constructor属性指向C

> function C() {}
> C.prototype.constructor === C
true

因为每个实例都从原型继承了constructor属性,所以你可以使用它来获取实例的构造函数:

> var o = new C();
> o.constructor
[Function: C]

构造函数属性的用例

切换对象的构造函数

在下面的catch子句中,我们根据捕获的异常的构造函数采取不同的操作:

try {
    ...
} catch (e) {
    switch (e.constructor) {
        case SyntaxError:
            ...
            break;
        case CustomError:
            ...
            break;
        ...
    }
}

警告

这种方法只能检测给定构造函数的直接实例。相比之下,instanceof可以检测直接实例和所有子构造函数的实例。

确定对象的构造函数名称

例如:

> function Foo() {}
> var f = new Foo();
> f.constructor.name
'Foo'

警告

并非所有的 JavaScript 引擎都支持函数的name属性。

创建类似的对象

这是如何创建一个新对象y,它具有与现有对象x相同的构造函数:

function Constr() {}
var x = new Constr();

var y = new x.constructor();
console.log(y instanceof Constr); // true

这个技巧对于一个必须适用于子构造函数的实例并且想要创建一个类似于this的新实例的方法非常有用。然后你就不能使用一个固定的构造函数:

SuperConstr.prototype.createCopy = function () {
    return new this.constructor(...);
};

引用超级构造函数

一些继承库将超级原型分配给子构造函数的一个属性。例如,YUI 框架通过Y.extend提供子类化:

function Super() {
}
function Sub() {
    Sub.superclass.constructor.call(this); // (1)
}
Y.extend(Sub, Super);

在第(1)行的调用有效,因为extendSub.superclass设置为Super.prototype。由于constructor属性,你可以将超级构造函数作为方法调用。

注意

instanceof运算符(参见The instanceof Operator)不依赖于constructor属性。

最佳实践

确保对于每个构造函数C,以下断言成立:

C.prototype.constructor === C

默认情况下,每个函数f已经有一个正确设置的属性prototype

> function f() {}
> f.prototype.constructor === f
true

因此,你应该避免替换这个对象,只向它添加属性:

// Avoid:
C.prototype = {
    method1: function (...) { ... },
    ...
};

// Prefer:
C.prototype.method1 = function (...) { ... };
...

如果你替换它,你应该手动将正确的值赋给constructor

C.prototype = {
    constructor: C,
    method1: function (...) { ... },
    ...
};

请注意,JavaScript 中没有任何关键的东西取决于constructor属性;但是设置它是一个好的风格,因为它可以启用本节中提到的技术。

instanceof 运算符

instanceof运算符:

value instanceof Constr

通过检查Constr.prototype是否在value的原型链中,确定value是由构造函数Constr或子构造函数创建的。因此,以下两个表达式是等价的:

value instanceof Constr
Constr.prototype.isPrototypeOf(value)

以下是一些例子:

> {} instanceof Object
true

> [] instanceof Array  // constructor of []
true
> [] instanceof Object  // super-constructor of []
true

> new Date() instanceof Date
true
> new Date() instanceof Object
true

预期的是,对于原始值,instanceof总是false

> 'abc' instanceof Object
false
> 123 instanceof Number
false

最后,如果它的右侧不是一个函数,instanceof会抛出一个异常:

> [] instanceof 123
TypeError: Expecting a function in instanceof check

陷阱:不是Object的实例的对象

几乎所有的对象都是Object的实例,因为它们的原型链中有Object.prototype。但也有一些对象不是这样。以下是两个例子:

> Object.create(null) instanceof Object
false
> Object.prototype instanceof Object
false

前一个对象在The dict Pattern: Objects Without Prototypes Are Better Maps中有更详细的解释。后一个对象是大多数原型链的终点(它们必须在某个地方结束)。两个对象都没有原型:

> Object.getPrototypeOf(Object.create(null))
null
> Object.getPrototypeOf(Object.prototype)
null

但是typeof正确地将它们分类为对象:

> typeof Object.create(null)
'object'
> typeof Object.prototype
'object'

对于instanceof的大多数用例来说,这个陷阱并不是一个断点,但你必须意识到它。

陷阱:跨越领域(框架或窗口)

在 Web 浏览器中,每个框架和窗口都有自己的领域,具有单独的全局变量。这可以防止instanceof对跨越领域的对象起作用。要了解原因,请看下面的代码:

if (myvar instanceof Array) ...  // Doesn’t always work

如果myvar是来自不同领域的数组,那么它的原型是该领域的Array.prototype。因此,instanceof不会在myvar的原型链中找到当前领域的Array.prototype,并且会返回false。ECMAScript 5 有一个函数Array.isArray(),它总是有效的:

<head>
    <script>
        function test(arr) {
            var iframe = frames[0];

            console.log(arr instanceof Array); // false
            console.log(arr instanceof iframe.Array); // true
            console.log(Array.isArray(arr)); // true
        }
    </script>
</head>
<body>
    <iframe srcdoc="<script>window.parent.test([])</script>">
    </iframe>
</body>

显然,这也是非内置构造函数的问题。

除了使用Array.isArray(),还有几件事情可以解决这个问题:

  • 避免对象跨越领域。浏览器有postMessage()方法,可以将一个对象复制到另一个领域,而不是传递一个引用。

  • 检查实例的构造函数的名称(仅适用于支持函数name属性的引擎):

    someValue.constructor.name === 'NameOfExpectedConstructor'
    
  • 使用原型属性标记实例属于类型T。有几种方法可以这样做。检查value是否是T的实例如下:

  • value.isT(): T实例的原型必须从这个方法返回true;一个常见的超级构造函数应该返回默认值false

  • 'T' in value: 你必须用一个属性标记T实例的原型,其键是'T'(或者更独特的东西)。

  • value.TYPE_NAME === 'T': 每个相关的原型必须有一个TYPE_NAME属性,具有适当的值。

实现构造函数的提示

本节提供了一些实现构造函数的提示。

防止忘记新的:严格模式

如果你在使用构造函数时忘记了new,你是将它作为函数而不是构造函数来调用。在松散模式下,你不会得到一个实例,全局变量会被创建。不幸的是,所有这些都是没有警告发生的:

function SloppyColor(name) {
    this.name = name;
}
var c = SloppyColor('green'); // no warning!

// No instance is created:
console.log(c); // undefined
// A global variable is created:
console.log(name); // green

在严格模式下,你会得到一个异常:

function StrictColor(name) {
    'use strict';
    this.name = name;
}
var c = StrictColor('green');
// TypeError: Cannot set property 'name' of undefined

从构造函数返回任意对象

在许多面向对象的语言中,构造函数只能生成直接实例。例如,考虑 Java:假设您想要实现一个类Expression,它有子类AdditionMultiplication。解析会生成后两个类的直接实例。您不能将其实现为Expression的构造函数,因为该构造函数只能生成Expression的直接实例。作为解决方法,在 Java 中使用静态工厂方法:

class Expression {
    // Static factory method:
    public static Expression parse(String str) {
        if (...) {
            return new Addition(...);
        } else if (...) {
            return new Multiplication(...);
        } else {
            throw new ExpressionException(...);
        }
    }
}
...
Expression expr = Expression.parse(someStr);

在 JavaScript 中,您可以从构造函数中简单地返回您需要的任何对象。因此,前面代码的 JavaScript 版本看起来像:

function Expression(str) {
    if (...) {
        return new Addition(..);
    } else if (...) {
        return new Multiplication(...);
    } else {
        throw new ExpressionException(...);
    }
}
...
var expr = new Expression(someStr);

这是个好消息:JavaScript 构造函数不会将你锁定,因此您可以随时改变构造函数是否应返回直接实例或其他内容的想法。

原型属性中的数据

本节解释了在大多数情况下,您不应该将数据放在原型属性中。然而,这个规则也有一些例外。

避免具有实例属性初始值的原型属性

原型包含多个对象共享的属性。因此,它们非常适用于方法。此外,通过下面描述的一种技术,您还可以使用它们来为实例属性提供初始值。稍后我会解释为什么不建议这样做。

构造函数通常将实例属性设置为初始值。如果其中一个值是默认值,那么您不需要创建实例属性。您只需要一个具有相同键的原型属性,其值是默认值。例如:

/**
 * Anti-pattern: don’t do this
 *
 * @param data an array with names
 */
function Names(data) {
    if (data) {
        // There is a parameter
        // => create instance property
        this.data = data;
    }
}
Names.prototype.data = [];

参数data是可选的。如果缺少它,实例将不会获得属性data,而是继承Names.prototype.data

这种方法基本上有效:您可以创建Names的实例n。获取n.data会读取Names.prototype.data。设置n.data会在n中创建一个新的自有属性,并保留原型中的共享默认值。我们只有一个问题,如果我们更改默认值(而不是用新值替换它):

> var n1 = new Names();
> var n2 = new Names();

> n1.data.push('jane'); // changes default value
> n1.data
[ 'jane' ]

> n2.data
[ 'jane' ]

在前面的示例中,push()改变了Names.prototype.data中的数组。由于该数组被所有没有自有属性data的实例共享,因此n2.data的初始值也发生了变化。

最佳实践:不要共享默认值

鉴于我们刚刚讨论的内容,最好不要共享默认值,并且始终创建新的默认值:

function Names(data) {
    this.data = data || [];
}

显然,如果该值是不可变的(就像所有原始值一样;请参阅Primitive Values),那么修改共享默认值的问题就不会出现。但为了保持一致性,最好坚持一种设置属性的方式。我也更喜欢保持通常的关注点分离(参见Layer 3: Constructors—Factories for Instances):构造函数设置实例属性,原型包含方法。

ECMAScript 6 将使这更加成为最佳实践,因为构造函数参数可以具有默认值,并且您可以通过类定义原型方法,但不能定义具有数据的原型属性。

按需创建实例属性

偶尔,创建属性值是一个昂贵的操作(在计算或存储方面)。在这种情况下,您可以按需创建实例属性:

function Names(data) {
    if (data) this.data = data;
}
Names.prototype = {
    constructor: Names, // (1)
    get data() {
        // Define, don’t assign
        // => avoid calling the (nonexistent) setter
        Object.defineProperty(this, 'data', {
            value: [],
            enumerable: true,
            configurable: false,
            writable: false
        });
        return this.data;
    }
};

我们无法通过赋值向实例添加属性data,因为 JavaScript 会抱怨缺少 setter(当它只找到 getter 时会这样做)。因此,我们通过Object.defineProperty()来添加它。请参阅Properties: Definition Versus Assignment来查看定义和赋值之间的区别。在第(1)行,我们确保属性constructor被正确设置(参见The constructor Property of Instances)。

显然,这是相当多的工作,所以你必须确保它是值得的。

避免非多态原型属性

如果相同的属性(相同的键,相同的语义,通常不同的值)存在于几个原型中,则称为多态。然后,通过实例读取属性的结果是通过该实例的原型动态确定的。未多态使用的原型属性可以被变量替换(这更好地反映了它们的非多态性质)。

例如,你可以将常量存储在原型属性中,并通过this访问它:

function Foo() {}
Foo.prototype.FACTOR = 42;
Foo.prototype.compute = function (x) {
    return x * this.FACTOR;
};

这个常量不是多态的。因此,你可以通过变量访问它:

// This code should be inside an IIFE or a module
function Foo() {}
var FACTOR = 42;
Foo.prototype.compute = function (x) {
    return x * FACTOR;
};

多态原型属性

这是一个具有不可变数据的多态原型属性的示例。通过原型属性标记构造函数的实例,可以将它们与不同构造函数的实例区分开来:

function ConstrA() { }
ConstrA.prototype.TYPE_NAME = 'ConstrA';

function ConstrB() { }
ConstrB.prototype.TYPE_NAME = 'ConstrB';

由于多态的“标签”TYPE_NAME,即使它们跨越领域(然后instanceof不起作用;参见陷阱:跨领域(帧或窗口)),你也可以区分ConstrAConstrB的实例。

保持数据私有

JavaScript 没有专门的手段来管理对象的私有数据。本节将描述三种解决这个限制的技术:

  • 构造函数环境中的私有数据

  • 使用标记键在属性中存储私有数据

  • 使用具体键在属性中存储私有数据

此外,我将解释如何通过 IIFE 保持全局数据私有。

构造函数环境中的私有数据(Crockford 隐私模式)

当调用构造函数时,会创建两个东西:构造函数的实例和一个环境(参见环境:管理变量)。实例由构造函数初始化。环境保存构造函数的参数和局部变量。在构造函数内部创建的每个函数(包括方法)都将保留对环境的引用——它被创建的环境。由于这个引用,即使构造函数完成后,它仍然可以访问环境。这种函数和环境的组合被称为闭包闭包:函数保持与它们的诞生作用域连接)。构造函数的环境因此是独立于实例的数据存储,与实例只有因为它们同时创建而相关。为了正确连接它们,我们必须有生活在两个世界中的函数。使用Douglas Crockford 的术语,一个实例可以有三种与之关联的值(参见图 17-4):

公共属性

存储在属性中的值(无论是在实例中还是在其原型中)都是公开可访问的。

私有值

存储在环境中的数据和函数是私有的——只能由构造函数和它创建的函数访问。

特权方法

私有函数可以访问公共属性,但原型中的公共方法无法访问私有数据。因此,我们需要特权方法——实例中的公共方法。特权方法是公共的,可以被所有人调用,但它们也可以访问私有值,因为它们是在构造函数中创建的。

当构造函数 Constr 被调用时,会创建两个数据结构:参数和局部变量的环境以及要初始化的实例。图 17-4:当构造函数 Constr 被调用时,会创建两个数据结构:参数和局部变量的环境以及要初始化的实例。

以下各节详细解释了每种值。

公共属性

请记住,对于构造函数Constr,有两种公共属性可供所有人访问。首先,原型属性存储在Constr.prototype中,并由所有实例共享。原型属性通常是方法:

Constr.prototype.publicMethod = ...;

其次,实例属性对每个实例都是唯一的。它们在构造函数中添加,通常保存数据(而不是方法):

function Constr(...) {
    this.publicData = ...;
    ...
}

私有值

构造函数的环境包括参数和局部变量。它们只能从构造函数内部访问,因此对实例是私有的:

function Constr(...) {
    ...
    var that = this; // make accessible to private functions

    var privateData = ...;

    function privateFunction(...) {
        // Access everything
        privateData = ...;

        that.publicData = ...;
        that.publicMethod(...);
    }
    ...
}

特权方法

私有数据是如此安全,以至于原型方法无法访问它。但是离开构造函数后你还能怎么使用它呢?答案是特权方法:在构造函数中创建的函数被添加为实例方法。这意味着,一方面,它们可以访问私有数据;另一方面,它们是公共的,因此被原型方法看到。换句话说,它们在私有数据和公共数据(包括原型方法)之间充当中介:

function Constr(...) {
    ...
    this.privilegedMethod = function (...) {
        // Access everything
        privateData = ...;
        privateFunction(...);

        this.publicData = ...;
        this.publicMethod(...);
    };
}

一个例子

以下是使用 Crockford 隐私模式实现的StringBuilder

function StringBuilder() {
    var buffer = [];
    this.add = function (str) {
        buffer.push(str);
    };
    this.toString = function () {
        return buffer.join('');
    };
}
// Can’t put methods in the prototype!

以下是交互:

> var sb = new StringBuilder();
> sb.add('Hello');
> sb.add(' world!');
> sb.toString()
’Hello world!’

Crockford 隐私模式的利弊

在使用 Crockford 隐私模式时需要考虑的一些要点:

它不是很优雅

通过特权方法介入私有数据的访问引入了不必要的间接性。特权方法和私有函数都破坏了构造函数(设置实例数据)和实例原型(方法)之间的关注点分离。

它是完全安全的

无法从外部访问环境的数据,这使得这种解决方案在需要时非常安全(例如,对于安全关键代码)。另一方面,私有数据不可被外部访问也可能会带来不便。有时你想对私有功能进行单元测试。而一些临时的快速修复依赖于访问私有数据的能力。这种快速修复是无法预测的,所以无论你的设计有多好,都可能会出现这种需求。

它可能会更慢

在当前 JavaScript 引擎中,访问原型链中的属性是高度优化的。访问闭包中的值可能会更慢。但这些事情不断变化,所以你必须测量一下,看看这对你的代码是否真的很重要。

它会消耗更多的内存

保留环境并将特权方法放在实例中会消耗内存。再次,确保这对你的代码真的很重要,并进行测量。

带有标记键的属性中的私有数据

对于大多数非安全关键的应用程序来说,隐私更像是 API 的一个提示:“你不需要看到这个。”这就是封装的关键好处——隐藏复杂性。尽管底层可能有更多的东西,但你只需要理解 API 的公共部分。命名约定的想法是通过标记属性的键来让客户端了解隐私。通常会使用前缀下划线来实现这一目的。

让我们重写先前的StringBuilder示例,以便缓冲区保存在名为_buffer的私有属性中,但按照惯例而言:

function StringBuilder() {
    this._buffer = [];
}
StringBuilder.prototype = {
    constructor: StringBuilder,
    add: function (str) {
        this._buffer.push(str);
    },
    toString: function () {
        return this._buffer.join('');
    }
};

以下是通过标记属性键实现隐私的一些利弊:

它提供了更自然的编码风格

能够以相同的方式访问私有和公共数据比使用环境实现隐私更加优雅。

它污染了属性的命名空间

具有标记键的属性可以在任何地方看到。人们使用 IDE 的越多,它们就会越烦人,因为它们会显示在公共属性旁边,而应该隐藏在那里。理论上,IDE 可以通过识别命名约定并在可能的情况下隐藏私有属性来进行适应。

可以从“外部”访问私有属性

这对单元测试和快速修复很有用。此外,子构造函数和辅助函数(所谓的“友元函数”)可以更轻松地访问私有数据。环境方法不提供这种灵活性;私有数据只能从构造函数内部访问。

它可能导致关键冲突

私有属性的键可能会发生冲突。这已经是子构造函数的一个问题,但如果您使用多重继承(某些库允许的),这将更加棘手。通过环境方法,就不会发生任何冲突。

使用具体化键在属性中保持私有数据

私有属性的一个问题是,键可能会发生冲突(例如,来自构造函数的键与来自子构造函数的键,或来自混入的键与来自构造函数的键)。通过使用更长的键,例如包含构造函数名称的键,可以减少这种冲突的可能性。然后,在前面的情况下,私有属性_buffer将被称为_StringBuilder_buffer。如果这样的键对您来说太长,您可以选择具体化它,将其存储在变量中:

var KEY_BUFFER = '_StringBuilder_buffer';

现在我们通过this[KEY_BUFFER]访问私有数据。

var StringBuilder = function () {
    var KEY_BUFFER = '_StringBuilder_buffer';

    function StringBuilder() {
        this[KEY_BUFFER] = [];
    }
    StringBuilder.prototype = {
        constructor: StringBuilder,
        add: function (str) {
            this[KEY_BUFFER].push(str);
        },
        toString: function () {
            return this[KEY_BUFFER].join('');
        }
    };
    return StringBuilder;
}();

我们已经将 IIFE 包装在StringBuilder周围,以便常量KEY_BUFFER保持本地化,不会污染全局命名空间。

具体化的属性键使您能够在键中使用 UUID(通用唯一标识符)。例如,通过 Robert Kieffer 的node-uuid

var KEY_BUFFER = '_StringBuilder_buffer_' + uuid.v4();

每次代码运行时,KEY_BUFFER的值都不同。例如,可能如下所示:

_StringBuilder_buffer_110ec58a-a0f2-4ac4-8393-c866d813b8d1

具有 UUID 的长键使关键冲突几乎不可能发生。

通过 IIFE 将全局数据保持私有

本小节解释了如何通过 IIFE(请参阅通过 IIFE 引入新作用域)将全局数据保持私有,以供单例对象、构造函数和方法使用。这些 IIFE 创建新环境(请参阅环境:管理变量),您可以在其中放置私有数据。

将私有全局数据附加到单例对象

您不需要构造函数来将对象与环境中的私有数据关联起来。以下示例显示了如何使用 IIFE 来实现相同的目的,方法是将其包装在单例对象周围:

var obj = function () {  // open IIFE

    // public
    var self = {
        publicMethod: function (...) {
            privateData = ...;
            privateFunction(...);
        },
        publicData: ...
    };

    // private
    var privateData = ...;
    function privateFunction(...) {
        privateData = ...;
        self.publicData = ...;
        self.publicMethod(...);
    }

    return self;
}(); // close IIFE

将全局数据保持私有以供所有构造函数使用

某些全局数据仅适用于构造函数和原型方法。通过同时将 IIFE 包装在两者周围,可以将其隐藏起来,不让公众看到。使用具体化键在属性中保持私有数据举例说明:构造函数StringBuilder及其原型方法使用常量KEY_BUFFER,其中包含属性键。该常量存储在 IIFE 的环境中:

var StringBuilder = function () { // open IIFE
    var KEY_BUFFER = '_StringBuilder_buffer_' + uuid.v4();

    function StringBuilder() {
        this[KEY_BUFFER] = [];
    }
    StringBuilder.prototype = {
        // Omitted: methods accessing this[KEY_BUFFER]
    };
    return StringBuilder;
}(); // close IIFE

请注意,如果您使用模块系统(请参阅第三十一章),您可以通过将构造函数加上方法放入模块中,以更干净的代码实现相同的效果。

将全局数据附加到方法

有时您只需要单个方法的全局数据。通过将其放在您包装在方法周围的 IIFE 的环境中,可以使其保持私有。例如:

var obj = {
    method: function () {  // open IIFE

        // method-private data
        var invocCount = 0;

        return function () {
            invocCount++;
            console.log('Invocation #'+invocCount);
            return 'result';
        };
    }()  // close IIFE
};

以下是交互:

> obj.method()
Invocation #1
'result'
> obj.method()
Invocation #2
'result'

第 4 层:构造函数之间的继承

在本节中,我们将研究如何从构造函数中继承:给定一个构造函数Super,我们如何编写一个新的构造函数Sub,它具有Super的所有特性以及一些自己的特性?不幸的是,JavaScript 没有内置的机制来执行这个任务。因此,我们需要做一些手动工作。

图 17-5 说明了这个想法:子构造函数Sub应该具有Super的所有属性(原型属性和实例属性),另外还有自己的。因此,我们对Sub应该是什么样子有了一个大致的想法,但不知道如何实现。我们需要弄清楚几件事情,接下来我会解释:

  • 继承实例属性。

  • 继承原型属性。

  • 确保instanceof的工作:如果subSub的一个实例,我们也希望sub instanceof Super为真。

  • 覆盖方法以适应Sub中的Super方法之一。

  • 进行超级调用:如果我们覆盖了Super的一个方法,我们可能需要从Sub中调用原始方法。

Sub 应该从 Super 继承:它应该具有 Super 的所有原型属性和所有 Super 的实例属性,另外还有自己的。请注意,methodB 覆盖了 Super 的 methodB。图 17-5:Sub 应该从 Super 继承:它应该具有 Super 的所有原型属性和所有 Super 的实例属性,另外还有自己的。请注意,methodB 覆盖了 Super 的 methodB。

继承实例属性

实例属性是在构造函数本身中设置的,因此继承超级构造函数的实例属性涉及调用该构造函数:

function Sub(prop1, prop2, prop3, prop4) {
    Super.call(this, prop1, prop2);  // (1)
    this.prop3 = prop3;  // (2)
    this.prop4 = prop4;  // (3)
}

当通过new调用Sub时,它的隐式参数this指向一个新实例。它首先将该实例传递给Super(1),后者添加其实例属性。之后,Sub设置自己的实例属性(2,3)。关键是不要通过new调用Super,因为那样会创建一个新的超级实例。相反,我们将Super作为一个函数调用,并将当前(子)实例作为this的值传递进去。

继承原型属性

诸如方法之类的共享属性保存在实例原型中。因此,我们需要找到一种方法,让Sub.prototype继承Super.prototype的所有属性。解决方案是将Sub.prototype设置为Super.prototype的原型。

对两种原型感到困惑吗?

是的,JavaScript 术语在这里很令人困惑。如果你感到迷茫,请参阅术语:两个原型,其中解释了它们的区别。

这是实现这一点的代码:

Sub.prototype = Object.create(Super.prototype);
Sub.prototype.constructor = Sub;
Sub.prototype.methodB = ...;
Sub.prototype.methodC = ...;

Object.create()生成一个原型为Super.prototype的新对象。之后,我们添加Sub的方法。正如在实例的构造函数属性中解释的那样,我们还需要设置constructor属性,因为我们已经替换了原始实例原型,其中它具有正确的值。

图 17-6 显示了现在SubSuper的关系。Sub的结构确实类似于我在图 17-5 中所勾画的。该图未显示实例属性,这些属性是由图中提到的函数调用设置的。

构造函数 Sub 通过调用构造函数 Super 并使 Sub.prototype 成为 Super.prototype 的原型而继承了构造函数 Super。图 17-6:构造函数 Sub 通过调用构造函数 Super 并使 Sub.prototype 成为 Super.prototype 的原型而继承了构造函数 Super。

确保instanceof的工作

“确保instanceof的工作”意味着Sub的每个实例也必须是Super的实例。图 17-7 显示了Sub的实例subInstance的原型链的样子:它的第一个原型是Sub.prototype,第二个原型是Super.prototype

subInstance 是由构造函数 Sub 创建的。它具有两个原型 Sub.prototype 和 Super.prototype。图 17-7:subInstance 是由构造函数 Sub 创建的。它具有两个原型 Sub.prototype 和 Super.prototype。

让我们从一个更简单的问题开始:subInstanceSub的一个实例吗?是的,因为以下两个断言是等价的(后者可以被视为前者的定义):

subInstance instanceof Sub
Sub.prototype.isPrototypeOf(subInstance)

如前所述,Sub.prototypesubInstance的原型之一,因此两个断言都为真。同样,subInstance也是Super的一个实例,因为以下两个断言成立:

subInstance instanceof Super
Super.prototype.isPrototypeOf(subInstance)

重写一个方法

我们通过在Sub.prototype中添加与相同名称的方法来重写Super.prototype中的方法。methodB就是一个例子,在图 17-7 中,我们可以看到它为什么有效:对methodB的搜索始于subInstance,在找到Super.prototype.methodB之前找到了Sub.prototype.methodB

进行超级调用

要理解超级调用,您需要了解术语主对象。方法的主对象是拥有其值为方法的属性的对象。例如,Sub.prototype.methodB的主对象是Sub.prototype。超级调用方法foo涉及三个步骤:

  1. 从当前方法的主对象的原型“之后”(在原型中)开始搜索。

  2. 查找一个名为foo的方法。

  3. 使用当前的this调用该方法。其理由是超级方法必须与当前方法使用相同的实例;它必须能够访问相同的实例属性。

因此,子方法的代码如下所示。它超级调用自己,调用它已经重写的方法:

Sub.prototype.methodB = function (x, y) {
    var superResult = Super.prototype.methodB.call(this, x, y); // (1)
    return this.prop3 + ' ' + superResult;
}

阅读(1)处的超级调用的一种方式是:直接引用超级方法并使用当前的this调用它。但是,如果我们将其分为三个部分,我们会发现上述步骤:

  1. Super.prototype:从Super.prototype开始搜索,即Sub.prototype的原型(当前方法Sub.prototype.methodB的主对象)。

  2. methodB:查找一个名为methodB的方法。

  3. call(this, ...):调用在上一步中找到的方法,并保持当前的this

避免硬编码超级构造函数的名称

到目前为止,我们总是通过提及超级构造函数名称来引用超级方法和超级构造函数。这种硬编码使您的代码不够灵活。您可以通过将超级原型分配给Sub的属性来避免这种情况:

Sub._super = Super.prototype;

然后调用超级构造函数和超级方法如下所示:

function Sub(prop1, prop2, prop3, prop4) {
    Sub._super.constructor.call(this, prop1, prop2);
    this.prop3 = prop3;
    this.prop4 = prop4;
}
Sub.prototype.methodB = function (x, y) {
    var superResult = Sub._super.methodB.call(this, x, y);
    return this.prop3 + ' ' + superResult;
}

设置Sub._super通常由一个实用函数处理,该函数还将子原型连接到超级原型。例如:

function subclasses(SubC, SuperC) {
    var subProto = Object.create(SuperC.prototype);
    // Save `constructor` and, possibly, other methods
    copyOwnPropertiesFrom(subProto, SubC.prototype);
    SubC.prototype = subProto;
    SubC._super = SuperC.prototype;
};

此代码使用了辅助函数copyOwnPropertiesFrom(),该函数在复制对象中显示并解释。

提示

将“子类”解释为一个动词:SubC 子类 SuperC。这样一个实用函数可以减轻创建子构造函数的痛苦:手动操作的事情更少,而且不会多次提及超级构造函数的名称。以下示例演示了它如何简化代码。

示例:使用中的构造函数继承

具体示例,假设构造函数Person已经存在:

function Person(name) {
    this.name = name;
}
Person.prototype.describe = function () {
    return 'Person called '+this.name;
};

我们现在想要创建构造函数Employee作为Person的子构造函数。我们手动这样做,看起来像这样:

function Employee(name, title) {
    Person.call(this, name);
    this.title = title;
}
Employee.prototype = Object.create(Person.prototype);
Employee.prototype.constructor = Employee;
Employee.prototype.describe = function () {
    return Person.prototype.describe.call(this)+' ('+this.title+')';
};

以下是交互:

> var jane = new Employee('Jane', 'CTO');
> jane.describe()
Person called Jane (CTO)
> jane instanceof Employee
true
> jane instanceof Person
true

前一节的实用函数subclasses()使Employee的代码稍微简化,并避免了硬编码超级构造函数Person

function Employee(name, title) {
    Employee._super.constructor.call(this, name);
    this.title = title;
}
Employee.prototype.describe = function () {
    return Employee._super.describe.call(this)+' ('+this.title+')';
};
subclasses(Employee, Person);

示例:内置构造函数的继承层次结构

内置构造函数使用本节描述的相同子类化方法。例如,ArrayObject的子构造函数。因此,Array的实例的原型链如下所示:

> var p = Object.getPrototypeOf

> p([]) === Array.prototype
true
> p(p([])) === Object.prototype
true
> p(p(p([]))) === null
true

反模式:原型是超级构造函数的实例

在 ECMAScript 5 和Object.create()之前,经常使用的解决方案是通过调用超级构造函数来创建子原型:

Sub.prototype = new Super();  // Don’t do this

在 ECMAScript 5 下不推荐这样做。原型将具有所有Super的实例属性,而它没有用处。因此,最好使用上述模式(涉及Object.create())。

所有对象的方法

几乎所有对象的原型链中都有Object.prototype

> Object.prototype.isPrototypeOf({})
true
> Object.prototype.isPrototypeOf([])
true
> Object.prototype.isPrototypeOf(/xyz/)
true

以下各小节描述了Object.prototype为其原型提供的方法。

转换为原始值

以下两种方法用于将对象转换为原始值:

Object.prototype.toString()

返回对象的字符串表示:

> ({ first: 'John', last: 'Doe' }.toString())
'[object Object]'
> [ 'a', 'b', 'c' ].toString()
'a,b,c'

Object.prototype.valueOf()

这是将对象转换为数字的首选方法。默认实现返回this

> var obj = {};
> obj.valueOf() === obj
true

valueOf被包装构造函数覆盖以返回包装的原始值:

> new Number(7).valueOf()
7

数字和字符串的转换(无论是隐式还是显式)都建立在原始值的转换基础上(有关详细信息,请参见算法:ToPrimitive()—将值转换为原始值)。这就是为什么您可以使用上述两种方法来配置这些转换。valueOf() 是数字转换的首选方法:

> 3 * { valueOf: function () { return 5 } }
15

toString() 是首选的字符串转换方法:

> String({ toString: function () { return 'ME' } })
'Result: ME'

布尔转换不可配置;对象始终被视为true(参见转换为布尔值)。

Object.prototype.toLocaleString()

此方法返回对象的区域特定的字符串表示。默认实现调用toString()。大多数引擎对于此方法的支持不会超出此范围。然而,ECMAScript 国际化 API(参见ECMAScript 国际化 API)由许多现代引擎支持,它为几个内置构造函数覆盖了此方法。

原型继承和属性

以下方法有助于原型继承和属性:

Object.prototype.isPrototypeOf(obj)

如果接收者是obj的原型链的一部分,则返回true

> var proto = { };
> var obj = Object.create(proto);
> proto.isPrototypeOf(obj)
true
> obj.isPrototypeOf(obj)
false

Object.prototype.hasOwnProperty(key)

如果this拥有一个键为key的属性,则返回true。“拥有”意味着属性存在于对象本身,而不是其原型链中的一个。

警告

通常应该通用地调用此方法(而不是直接调用),特别是在静态不知道属性的对象上。为什么以及如何在迭代和检测属性中有解释:

> var proto = { foo: 'abc' };
> var obj = Object.create(proto);
> obj.bar = 'def';

> Object.prototype.hasOwnProperty.call(obj, 'foo')
false
> Object.prototype.hasOwnProperty.call(obj, 'bar')
true

Object.prototype.propertyIsEnumerable(propKey)

如果接收者具有具有可枚举键propKey的属性,则返回true,否则返回false

> var obj = { foo: 'abc' };
> obj.propertyIsEnumerable('foo')
true
> obj.propertyIsEnumerable('toString')
false
> obj.propertyIsEnumerable('unknown')
false

通用方法:从原型中借用方法

有时实例原型具有对更多对象有用的方法,而不仅仅是继承自它们的对象。本节解释了如何使用原型的方法而不继承它。例如,实例原型Wine.prototype具有方法incAge()

function Wine(age) {
    this.age = age;
}
Wine.prototype.incAge = function (years) {
    this.age += years;
}

交互如下:

> var chablis = new Wine(3);
> chablis.incAge(1);
> chablis.age
4

incAge()方法适用于具有age属性的任何对象。我们如何在不是Wine实例的对象上调用它?让我们看看前面的方法调用:

chablis.incAge(1)

实际上有两个参数:

  1. chablis是方法调用的接收器,通过this传递给incAge

  2. 1是一个参数,通过years传递给incAge

我们不能用任意对象替换前者——接收器必须是Wine的实例。否则,找不到方法incAge。但前面的方法调用等同于(参见Calling Functions While Setting this: call(), apply(), and bind()):

Wine.prototype.incAge.call(chablis, 1)

通过前面的模式,我们可以使一个对象成为接收器(call的第一个参数),而不是Wine的实例,因为接收器不用于查找方法Wine.prototype.incAge。在下面的例子中,我们将方法incAge()应用于对象john

> var john = { age: 51 };
> Wine.prototype.incAge.call(john, 3)
> john.age
54

可以以这种方式使用的函数称为通用方法;它必须准备好this不是“它”的构造函数的实例。因此,并非所有方法都是通用的;ECMAScript 语言规范明确规定了哪些方法是通用的(参见A List of All Generic Methods)。

通过文字直接访问 Object.prototype 和 Array.prototype

通用调用方法相当冗长:

Object.prototype.hasOwnProperty.call(obj, 'propKey')

您可以通过访问空对象文字创建的 Object 实例来更简洁地访问hasOwnProperty

{}.hasOwnProperty.call(obj, 'propKey')

同样,以下两个表达式是等价的:

Array.prototype.join.call(str, '-')
[].join.call(str, '-')

这种模式的优势在于它不太啰嗦。但它也不太容易理解。性能不应该是一个问题(至少从长远来看),因为引擎可以静态确定文字不应该创建对象。

通用调用方法的示例

以下是一些通用方法的使用示例:

这个例子是关于将数组转换为参数,而不是从另一个构造函数中借用方法。

  • 将数组方法join()应用于字符串(不是数组):

    > Array.prototype.join.call('abc', '-')
    'a-b-c'
    
  • 将数组方法map()应用于字符串:¹⁵

    > [].map.call('abc', function (x) { return x.toUpperCase() })
    [ 'A', 'B', 'C' ]
    

通用地使用map()比使用split('')更有效,后者会创建一个中间数组:

    > 'abc'.split('').map(function (x) { return x.toUpperCase() })
    [ 'A', 'B', 'C' ]
    ```

+   将字符串方法应用于非字符串。`toUpperCase()`将接收器转换为字符串并将结果大写:

    ```js
    > String.prototype.toUpperCase.call(true)
    'TRUE'
    > String.prototype.toUpperCase.call(['a','b','c'])
    'A,B,C'
    ```

在普通对象上使用通用数组方法可以让您了解它们的工作原理:

+   在伪数组上调用数组方法:

    ```js
    > var fakeArray = { 0: 'a', 1: 'b', length: 2 };
    > Array.prototype.join.call(fakeArray, '-')
    'a-b'
    ```

+   看看数组方法如何转换一个被视为数组的对象:

    ```js
    > var obj = {};
    > Array.prototype.push.call(obj, 'hello');
    1
    > obj
    { '0': 'hello', length: 1 }
    ```

### 类似数组的对象和通用方法

JavaScript 中有一些感觉像数组但实际上不是的对象。这意味着它们具有索引访问和`length`属性,但它们没有任何数组方法(`forEach()`,`push`,`concat()`等)。这很不幸,但正如我们将看到的,通用数组方法可以实现一种解决方法。类似数组的对象的示例包括:

+   特殊变量`arguments`(参见[All Parameters by Index: The Special Variable arguments](ch15.html#arguments_variable "All Parameters by Index: The Special Variable arguments")),它是一个重要的类数组对象,因为它是 JavaScript 的一个基本部分。`arguments`看起来像一个数组:

    ```js
    > function args() { return arguments }
    > var arrayLike = args('a', 'b');

    > arrayLike[0]
    'a'
    > arrayLike.length
    2
    ```

但是没有任何数组方法可用:

```js
    > arrayLike.join('-')
    TypeError: object has no method 'join'
    ```

这是因为`arrayLike`不是`Array`的实例(并且`Array.prototype`不在原型链中):

```js
    > arrayLike instanceof Array
    false
    ```

+   浏览器 DOM 节点列表,由`document.getElementsBy*()`(例如`getElementsByTagName()`)、`document.forms`等返回:

    ```js
    > var elts = document.getElementsByTagName('h3');
    > elts.length
    3
    > elts instanceof Array
    false
    ```

+   字符串也是类数组的:

    ```js
    > 'abc'[1]
    'b'
    > 'abc'.length
    3
    ```

术语*类数组*也可以被视为通用数组方法和对象之间的契约。对象必须满足某些要求;否则,这些方法将无法在它们上面工作。这些要求是:

+   类数组对象的元素必须可以通过方括号和从 0 开始的整数索引访问。所有方法都需要读取访问权限,一些方法还需要写入访问权限。请注意,所有对象都支持这种索引:方括号中的索引被转换为字符串并用作查找属性值的键:

    ```js
    > var obj = { '0': 'abc' };
    > obj[0]
    'abc'
    ```

+   类数组对象必须有一个`length`属性,其值是其元素的数量。一些方法要求`length`是可变的(例如`reverse()`)。长度不可变的值(例如字符串)不能与这些方法一起使用。

#### 处理类数组对象的模式

以下模式对处理类数组对象很有用:

+   将类数组对象转换为数组:

    ```js
    var arr = Array.prototype.slice.call(arguments);
    ```

方法`slice()`(参见[Concatenating, Slicing, Joining (Nondestructive)](ch18.html#Array.prototype.slice "Concatenating, Slicing, Joining (Nondestructive"))没有任何参数时会创建一个数组接收者的副本:

```js
    var copy = [ 'a', 'b' ].slice();
    ```

+   要遍历类数组对象的所有元素,可以使用简单的`for`循环:

    ```js
    function logArgs() {
        for (var i=0; i<arguments.length; i++) {
            console.log(i+'. '+arguments[i]);
        }
    }
    ```

但你也可以借用`Array.prototype.forEach()`:

```js
    function logArgs() {
        Array.prototype.forEach.call(arguments, function (elem, i) {
            console.log(i+'. '+elem);
        });
    }
    ```

在这两种情况下,交互如下:

```js
    > logArgs('hello', 'world');
    0\. hello
    1\. world
    ```

### 通用方法列表

以下列表包括所有在 ECMAScript 语言规范中提到的通用方法:

+   `Array.prototype`(参见[Array Prototype Methods](ch18.html#array_prototype_methods "Array Prototype Methods")):

+   `concat`

+   `every`

+   `filter`

+   `forEach`

+   `indexOf`

+   `join`

+   `lastIndexOf`

+   `map`

+   `pop`

+   `push`

+   `reduce`

+   `reduceRight`

+   `reverse`

+   `shift`

+   `slice`

+   `some`

+   `sort`

+   `splice`

+   `toLocaleString`

+   `toString`

+   `unshift`

+   `Date.prototype`(参见[Date Prototype Methods](ch20.html#date_prototype_methods "Date Prototype Methods"))

+   `toJSON`

+   `Object.prototype`(参见[Methods of All Objects](ch17_split_001.html#methods_of_all_objects "Methods of All Objects"))

+   (所有`Object`方法都自动是通用的——它们必须适用于所有对象。)

+   `String.prototype`(参见[String Prototype Methods](ch12.html#string_prototype_methods "String Prototype Methods"))

+   `charAt`

+   `charCodeAt`

+   `concat`

+   `indexOf`

+   `lastIndexOf`

+   `localeCompare`

+   `match`

+   `replace`

+   `search`

+   `slice`

+   `split`

+   `substring`

+   `toLocaleLowerCase`

+   `toLocaleUpperCase`

+   `toLowerCase`

+   `toUpperCase`

+   `trim`

## 陷阱:使用对象作为映射

由于 JavaScript 没有内置的映射数据结构,对象经常被用作从字符串到值的映射。然而,这比看起来更容易出错。本节解释了在这个任务中涉及的三个陷阱。

### 陷阱 1:继承影响属性读取

读取属性的操作可以分为两种:

+   一些操作会考虑整个原型链并查看继承的属性。

+   其他操作只访问对象的*自有*(非继承的)属性。

当你读取对象作为映射的条目时,你需要仔细选择这些操作。为了理解原因,考虑以下示例:

```js
var proto = { protoProp: 'a' };
var obj = Object.create(proto);
obj.ownProp = 'b';

obj是一个具有一个自有属性的对象,其原型是protoproto也有一个自有属性。proto的原型是Object.prototype,就像所有通过对象文字创建的对象一样。因此,objprotoObject.继承属性。

我们希望obj被解释为具有单个条目的映射:

ownProp: 'b'

也就是说,我们希望忽略继承的属性,只考虑自有属性。让我们看看哪些读取操作以这种方式解释obj,哪些不是。请注意,对于对象作为映射,我们通常希望使用存储在变量中的任意属性键。这排除了点表示法。

检查属性是否存在

in运算符检查对象是否具有给定键的属性,但它会考虑继承的属性:

> 'ownProp' in obj  // ok
true
> 'unknown' in obj  // ok
false
> 'toString' in obj  // wrong, inherited from Object.prototype
true
> 'protoProp' in obj  // wrong, inherited from proto
true

我们需要检查以忽略继承的属性。hasOwnProperty()正是我们想要的:

> obj.hasOwnProperty('ownProp')  // ok
true
> obj.hasOwnProperty('unknown')  // ok
false
> obj.hasOwnProperty('toString')  // ok
false
> obj.hasOwnProperty('protoProp')  // ok
false

收集属性键

我们可以使用什么操作来找到obj的所有键,同时又尊重我们对其作为映射的解释?for-in看起来可能有效。但是,不幸的是,它不行:

> for (propKey in obj) console.log(propKey)
ownProp
protoProp

它会考虑继承的可枚举属性。Object.prototype的属性没有显示在这里的原因是它们都是不可枚举的。

相比之下,Object.keys()只列出自有属性:

> Object.keys(obj)
[ 'ownProp' ]

这个方法只返回可枚举的自有属性;ownProp是通过赋值添加的,因此默认情况下是可枚举的。如果你想列出所有自有属性,你需要使用Object.getOwnPropertyNames()

获取属性值

对于读取属性值,我们只能在点运算符和括号运算符之间进行选择。我们不能使用前者,因为我们有存储在变量中的任意键。这就只剩下了括号运算符,它会考虑继承的属性:

> obj['toString']
[Function: toString]

这不是我们想要的。没有内置操作可以只读取自有属性,但你可以很容易地自己实现一个:

function getOwnProperty(obj, propKey) {
    // Using hasOwnProperty() in this manner is problematic
    // (explained and fixed later)
    return (obj.hasOwnProperty(propKey)
            ? obj[propKey] : undefined);
}

有了这个函数,继承的属性toString被忽略了:

> getOwnProperty(obj, 'toString')
undefined

陷阱 2:覆盖影响调用方法

函数getOwnProperty()obj上调用了方法hasOwnProperty()。通常情况下,这是可以的:

> getOwnProperty({ foo: 123 }, 'foo')
123

然而,如果你向obj添加一个键为hasOwnProperty的属性,那么该属性将覆盖方法Object.prototype.hasOwnProperty()getOwnProperty()将不起作用:

> getOwnProperty({ hasOwnProperty: 123 }, 'foo')
TypeError: Property 'hasOwnProperty' is not a function

你可以通过直接引用hasOwnProperty()来解决这个问题。这避免了通过obj来查找它:

function getOwnProperty(obj, propKey) {
    return (Object.prototype.hasOwnProperty.call(obj, propKey)
            ? obj[propKey] : undefined);
}

我们已经通用地调用了hasOwnProperty()(参见通用方法:从原型中借用方法)。

陷阱 3:特殊属性 proto

在许多 JavaScript 引擎中,属性__proto__(参见特殊属性 proto)是特殊的:获取它会检索对象的原型,设置它会改变对象的原型。这就是为什么对象不能在键为'__proto__'的属性中存储映射数据。如果你想允许映射键'__proto__',你必须在使用它作为属性键之前对其进行转义:

function get(obj, key) {
    return obj[escapeKey(key)];
}
function set(obj, key, value) {
    obj[escapeKey(key)] = value;
}
// Similar: checking if key exists, deleting an entry

function escapeKey(key) {
    if (key.indexOf('__proto__') === 0) {  // (1)
        return key+'%';
    } else {
        return key;
    }
}

我们还需要转义'__proto__'(等等)的转义版本,以避免冲突;也就是说,如果我们将键'__proto__'转义为'__proto__%',那么我们还需要转义键'__proto__%',以免它替换'__proto__'条目。这就是第(1)行发生的情况。

Mark S. Miller 在一封电子邮件中提到了这个陷阱的现实影响:

认为这个练习是学术性的,不会在实际系统中出现吗?正如在一个支持主题中观察到的,直到最近,在所有非 IE 浏览器上,如果你在新的 Google Doc 开头输入“proto”,你的 Google Doc 会卡住。这是因为将对象作为字符串映射的错误使用。

dict 模式:没有原型的对象更适合作为映射

你可以这样创建一个没有原型的对象:

var dict = Object.create(null);

这样的对象比普通对象更好的映射(字典),这就是为什么有时这种模式被称为dict 模式dict代表dictionary)。让我们首先检查普通对象,然后找出为什么无原型对象是更好的映射。

普通对象

通常,您在 JavaScript 中创建的每个对象至少都有Object.prototype在其原型链中。Object.prototype的原型是null,因此大多数原型链都在这里结束:

> Object.getPrototypeOf({}) === Object.prototype
true
> Object.getPrototypeOf(Object.prototype)
null

无原型对象

无原型对象作为映射有两个优点:

  • 继承的属性(陷阱#1)不再是问题,因为根本没有。因此,您现在可以自由使用in运算符来检测属性是否存在,并使用括号来读取属性。

  • 很快,__proto__将被禁用。在 ECMAScript 6 中,如果Object.prototype不在对象的原型链中,特殊属性__proto__将被禁用。您可以期望 JavaScript 引擎慢慢迁移到这种行为,但目前还不太常见。

唯一的缺点是您将失去Object.prototype提供的服务。例如,dict 对象不再可以自动转换为字符串:

> console.log('Result: '+obj)
TypeError: Cannot convert object to primitive value

但这并不是真正的缺点,因为直接在 dict 对象上调用方法是不安全的。

推荐

在快速的 hack 和库的基础上使用 dict 模式。在(非库)生产代码中,库更可取,因为您可以确保避免所有陷阱。下一节列出了一些这样的库。

最佳实践

使用对象作为映射有许多应用。如果所有属性键在开发时已经静态知道,那么你只需要确保忽略继承,只查看自有属性。如果可以使用任意键,你应该转向库,以避免本节中提到的陷阱。以下是两个例子:

速查表:使用对象

本节是一个快速参考,指向更详细的解释。

  • 对象字面量(参见对象字面量):

    var jane = {
        name: 'Jane',
    
        'not an identifier': 123,
    
        describe: function () { // method
            return 'Person named '+this.name;
        },
    };
    // Call a method:
    console.log(jane.describe()); // Person named Jane
    
  • 点运算符(.)(参见点运算符(.):通过固定键访问属性):

    obj.propKey
    obj.propKey = value
    delete obj.propKey
    
  • 括号运算符([])(参见括号运算符([]):通过计算键访问属性):

    obj['propKey']
    obj['propKey'] = value
    delete obj['propKey']
    
  • 获取和设置原型(参见获取和设置原型):

    Object.create(proto, propDescObj?)
    Object.getPrototypeOf(obj)
    
  • 属性的迭代和检测(参见属性的迭代和检测):

    Object.keys(obj)
    Object.getOwnPropertyNames(obj)
    
    Object.prototype.hasOwnProperty.call(obj, propKey)
    propKey in obj
    
  • 通过描述符获取和定义属性(参见通过描述符获取和定义属性):

    Object.defineProperty(obj, propKey, propDesc)
    Object.defineProperties(obj, propDescObj)
    Object.getOwnPropertyDescriptor(obj, propKey)
    Object.create(proto, propDescObj?)
    
  • 保护对象(参见保护对象):

    Object.preventExtensions(obj)
    Object.isExtensible(obj)
    
    Object.seal(obj)
    Object.isSealed(obj)
    
    Object.freeze(obj)
    Object.isFrozen(obj)
    
  • 所有对象的方法(参见所有对象的方法):

    Object.prototype.toString()
    Object.prototype.valueOf()
    
    Object.prototype.toLocaleString()
    
    Object.prototype.isPrototypeOf(obj)
    Object.prototype.hasOwnProperty(key)
    Object.prototype.propertyIsEnumerable(propKey)
    

¹⁵通过这种方式使用map()是 Brandon Benvie(@benvie)的一个提示。

第十八章:数组

原文:18. Arrays

译者:飞龙

协议:CC BY-NC-SA 4.0

数组是从索引(从零开始的自然数)到任意值的映射。值(映射的范围)称为数组的元素。创建数组的最方便的方法是通过数组字面量。这样的字面量列举了数组元素;元素的位置隐含地指定了它的索引。

在本章中,我将首先介绍基本的数组机制,如索引访问和length属性,然后再介绍数组方法。

概述

本节提供了数组的快速概述。详细内容将在后面解释。

作为第一个例子,我们通过数组字面量创建一个数组 arr(参见[创建数组](ch18.html#creating_arrays "Creating Arrays"))并访问元素(参见[数组索引](ch18.html#array_indices "Array Indices"):

> var arr = [ 'a', 'b', 'c' ]; // array literal
> arr[0]  // get element 0
'a'
> arr[0] = 'x';  // set element 0
> arr
[ 'x', 'b', 'c' ]

我们可以使用数组属性 length(参见length)来删除和追加元素:

> var arr = [ 'a', 'b', 'c' ];
> arr.length
3
> arr.length = 2;  // remove an element
> arr
[ 'a', 'b' ]
> arr[arr.length] = 'd';  // append an element
> arr
[ 'a', 'b', 'd' ]

数组方法 push() 提供了另一种追加元素的方式:

> var arr = [ 'a', 'b' ];
> arr.push('d')
3
> arr
[ 'a', 'b', 'd' ]

数组是映射,不是元组

ECMAScript 标准将数组规定为从索引到值的映射(字典)。换句话说,数组可能不是连续的,并且可能有空洞。例如:

> var arr = [];
> arr[0] = 'a';
'a'
> arr[2] = 'b';
'b'
> arr
[ 'a', , 'b' ]

前面的数组有一个空洞:索引 1 处没有元素。数组中的空洞 更详细地解释了空洞。

请注意,大多数 JavaScript 引擎会在内部优化没有空洞的数组,并将它们连续存储。

数组也可以有属性

数组仍然是对象,可以有对象属性。这些属性不被视为实际数组的一部分;也就是说,它们不被视为数组元素:

> var arr = [ 'a', 'b' ];
> arr.foo = 123;
> arr
[ 'a', 'b' ]
> arr.foo
123

创建数组

你可以通过数组字面量创建一个数组:

var myArray = [ 'a', 'b', 'c' ];

数组中的尾随逗号会被忽略:

> [ 'a', 'b' ].length
2
> [ 'a', 'b', ].length
2
> [ 'a', 'b', ,].length  // hole + trailing comma
3

数组构造函数

有两种使用构造函数 Array 的方式:可以创建一个给定长度的空数组,或者数组的元素是给定的值。对于这个构造函数,new 是可选的:以普通函数的方式调用它(不带 new)与以构造函数的方式调用它是一样的。

创建一个给定长度的空数组

给定长度的空数组中只有空洞!因此,很少有意义使用这个版本的构造函数:

> var arr = new Array(2);
> arr.length
2
> arr  // two holes plus trailing comma (ignored!)
[ , ,]

一些引擎在以这种方式调用 Array() 时可能会预先分配连续的内存,这可能会稍微提高性能。但是,请确保增加的冗余性值得!

初始化带有元素的数组(避免!)

这种调用 Array 的方式类似于数组字面量:

// The same as ['a', 'b', 'c']:
var arr1 = new Array('a', 'b', 'c');

问题在于你不能创建只有一个数字的数组,因为那会被解释为创建一个 length 为该数字的数组:

> new Array(2)  // alas, not [ 2 ]
[ , ,]

> new Array(5.7)  // alas, not [ 5.7 ]
RangeError: Invalid array length

> new Array('abc')  // ok
[ 'abc' ]

多维数组

如果你需要为元素创建多个维度,你必须嵌套数组。当你创建这样的嵌套数组时,最内层的数组可以根据需要增长。但是,如果你想直接访问元素,你至少需要创建外部数组。在下面的例子中,我为井字游戏创建了一个三乘三的矩阵。该矩阵完全填满了数据(而不是让行根据需要增长):

// Create the Tic-tac-toe board
var rows = [];
for (var rowCount=0; rowCount < 3; rowCount++) {
    rows[rowCount] = [];
    for (var colCount=0; colCount < 3; colCount++) {
        rows[rowCount][colCount] = '.';
    }
}

// Set an X in the upper right corner
rows[0][2] = 'X';  // [row][column]

// Print the board
rows.forEach(function (row) {
    console.log(row.join(' '));
});

以下是输出:

. . X
. . .
. . .

我希望这个例子能够演示一般情况。显然,如果矩阵很小并且具有固定的维度,你可以通过数组字面量来设置它:

var rows = [ ['.','.','.'], ['.','.','.'], ['.','.','.'] ];

数组索引

当你使用数组索引时,你必须牢记以下限制:

  • 索引是范围在 0 ≤ i < 2³²−1 的数字 i

  • 最大长度为 2³²−1。

超出范围的索引被视为普通的属性键(字符串!)。它们不会显示为数组元素,也不会影响属性 length。例如:

> var arr = [];

> arr[-1] = 'a';
> arr
[]
> arr['-1']
'a'

> arr[4294967296] = 'b';
> arr
[]
> arr['4294967296']
'b'

in 操作符和索引

in 操作符用于检测对象是否具有给定键的属性。但它也可以用于确定数组中是否存在给定的元素索引。例如:

> var arr = [ 'a', , 'b' ];
> 0 in arr
true
> 1 in arr
false
> 10 in arr
false

删除数组元素

除了删除属性之外,delete 操作符还可以删除数组元素。删除元素会创建空洞(length 属性不会更新):

> var arr = [ 'a', 'b' ];
> arr.length
2
> delete arr[1]  // does not update length
true
> arr
[ 'a',  ]
> arr.length
2

你也可以通过减少数组的长度来删除尾随的数组元素(参见length了解详情)。要删除元素而不创建空洞(即,后续元素的索引被减少),你可以使用 Array.prototype.splice()(参见添加和删除元素(破坏性))。在这个例子中,我们删除索引为 1 的两个元素:

> var arr = ['a', 'b', 'c', 'd'];
> arr.splice(1, 2) // returns what has been removed
[ 'b', 'c' ]
> arr
[ 'a', 'd' ]

数组索引详解

提示

这是一个高级部分。通常情况下,您不需要知道这里解释的细节。

数组索引并非看起来那样。 到目前为止,我一直假装数组索引是数字。这也是 JavaScript 引擎在内部实现数组的方式。然而,ECMAScript 规范对索引的看法不同。引用第 15.4 节的话来说:

  • 如果且仅当ToString``(ToUint32(P))等于PToUint32(P)不等于 2³²−1 时,属性键P(一个字符串)才是数组索引。这意味着什么将在下面解释。

  • 属性键为数组索引的数组属性称为元素

换句话说,在规范中,括号中的所有值都被转换为字符串,并解释为属性键,甚至是数字。以下互动演示了这一点:

> var arr = ['a', 'b'];
> arr['0']
'a'
> arr[0]
'a'

要成为数组索引,属性键P(一个字符串!)必须等于以下计算结果:

  1. P转换为数字。

  2. 将数字转换为 32 位无符号整数。

  3. 将整数转换为字符串。

这意味着数组索引必须是 32 位范围内的字符串化整数i,其中 0 ≤ i < 2³²−1。规范明确排除了上限(如前面引用的)。它保留给了最大长度。要了解这个定义是如何工作的,让我们使用通过位运算符实现 32 位整数中的ToUint32()函数。

首先,不包含数字的字符串总是转换为 0,这在字符串化后不等于字符串:

> ToUint32('xyz')
0
> ToUint32('?@#!')
0

其次,超出范围的字符串化整数也会转换为完全不同的整数,与字符串化后不相等:

> ToUint32('-1')
4294967295
> Math.pow(2, 32)
4294967296
> ToUint32('4294967296')
0

第三,字符串化的非整数数字会转换为整数,这些整数又是不同的:

> ToUint32('1.371')
1

请注意,规范还强制规定数组索引不得具有指数:

> ToUint32('1e3')
1000

它们不包含前导零:

> var arr = ['a', 'b'];
> arr['0']  // array index
'a'
> arr['00'] // normal property
undefined

长度

length属性的基本功能是跟踪数组中的最高索引:

> [ 'a', 'b' ].length
2
> [ 'a', , 'b' ].length
3

因此,length不计算元素的数量,因此您必须编写自己的函数来执行此操作。例如:

function countElements(arr) {
    var elemCount = 0;
    arr.forEach(function () {
        elemCount++;
    });
    return elemCount;
}

为了计算元素(非空洞),我们已经利用了forEach跳过空洞的事实。以下是互动:

> countElements([ 'a', 'b' ])
2
> countElements([ 'a', , 'b' ])
2

手动增加数组的长度

手动增加数组的长度对数组几乎没有影响;它只会创建空洞:

> var arr = [ 'a', 'b' ];
> arr.length = 3;
> arr  // one hole at the end
[ 'a', 'b', ,]

最后的结果末尾有两个逗号,因为尾随逗号是可选的,因此总是被忽略。

我们刚刚做的并没有添加任何元素:

> countElements(arr)
2

然而,length属性确实作为指针,指示在哪里插入新元素。例如:

> arr.push('c')
4
> arr
[ 'a', 'b', , 'c' ]

因此,通过Array构造函数设置数组的初始长度会创建一个完全空的数组:

> var arr = new Array(2);
> arr.length
2
> countElements(arr)
0

减少数组的长度

如果您减少数组的长度,则新长度及以上的所有元素都将被删除:

> var arr = [ 'a', 'b', 'c' ];
> 1 in arr
true
> arr[1]
'b'

> arr.length = 1;
> arr
[ 'a' ]
> 1 in arr
false
> arr[1]
undefined

清除数组

如果将数组的长度设置为 0,则它将变为空。这样可以清除数组。例如:

function clearArray(arr) {
    arr.length = 0;
}

以下是互动:

> var arr = [ 'a', 'b', 'c' ];
> clearArray(arr)
> arr
[]

但是,请注意,这种方法可能会很慢,因为每个数组元素都会被显式删除。具有讽刺意味的是,创建一个新的空数组通常更快:

arr = [];

清除共享数组

您需要知道的是,将数组的长度设置为零会影响共享数组的所有人:

> var a1 = [1, 2, 3];
> var a2 = a1;
> a1.length = 0;

> a1
[]
> a2
[]

相比之下,分配一个空数组不会:

> var a1 = [1, 2, 3];
> var a2 = a1;
> a1 = [];

> a1
[]
> a2
[ 1, 2, 3 ]

最大长度

最大数组长度为 2³²−1:

> var arr1 = new Array(Math.pow(2, 32));  // not ok
RangeError: Invalid array length

> var arr2 = new Array(Math.pow(2, 32)-1);  // ok
> arr2.push('x');
RangeError: Invalid array length

数组中的空洞

数组是从索引到值的映射。这意味着数组可以有空洞,即长度小于数组中缺失的索引。在这些索引中读取元素会返回undefined

提示

建议避免数组中的空洞。JavaScript 对它们的处理不一致(即,一些方法忽略它们,其他方法不会)。幸运的是,通常你不需要知道如何处理空洞:它们很少有用,并且会对性能产生负面影响。

创建空洞

通过给数组索引赋值可以创建空洞:

> var arr = [];
> arr[0] = 'a';
> arr[2] = 'c';
> 1 in arr  // hole at index 1
false

也可以通过在数组字面量中省略值来创建空洞:

> var arr = ['a',,'c'];
> 1 in arr  // hole at index 1
false

警告

需要两个尾随逗号来创建尾随的空洞,因为最后一个逗号总是被忽略:

> [ 'a', ].length
1
> [ 'a', ,].length
2

稀疏数组与密集数组

本节将检查空洞和undefined作为元素之间的区别。鉴于读取空洞会返回undefined,两者非常相似。

带有空洞的数组称为稀疏数组。没有空洞的数组称为密集数组。密集数组是连续的,并且在每个索引处都有一个元素——从零开始,到length-1 结束。让我们比较以下两个数组,一个是稀疏数组,一个是密集数组。这两者非常相似:

var sparse = [ , , 'c' ];
var dense  = [ undefined, undefined, 'c' ];

空洞几乎就像在相同索引处有一个undefined元素。两个数组的长度都是一样的:

> sparse.length
3
> dense.length
3

但是稀疏数组没有索引为 0 的元素:

> 0 in sparse
false
> 0 in dense
true

通过for进行迭代对两个数组来说是一样的:

> for (var i=0; i<sparse.length; i++) console.log(sparse[i]);
undefined
undefined
c
> for (var i=0; i<dense.length; i++) console.log(dense[i]);
undefined
undefined
c

通过forEach进行迭代会跳过空洞,但不会跳过未定义的元素:

> sparse.forEach(function (x) { console.log(x) });
c
> dense.forEach(function (x) { console.log(x) });
undefined
undefined
c

哪些操作会忽略空洞,哪些会考虑它们?

涉及数组的一些操作会忽略空洞,而另一些会考虑它们。本节解释了细节。

数组迭代方法

forEach()会跳过空洞:

> ['a',, 'b'].forEach(function (x,i) { console.log(i+'.'+x) })
0.a
2.b

every()也会跳过空洞(类似的:some()):

> ['a',, 'b'].every(function (x) { return typeof x === 'string' })
true

map()会跳过,但保留空洞:

> ['a',, 'b'].map(function (x,i) { return i+'.'+x })
[ '0.a', , '2.b' ]

filter()消除空洞:

> ['a',, 'b'].filter(function (x) { return true })
[ 'a', 'b' ]

其他数组方法

join()将空洞、undefinednull转换为空字符串:

> ['a',, 'b'].join('-')
'a--b'
> [ 'a', undefined, 'b' ].join('-')
'a--b'

sort()在排序时保留空洞:

> ['a',, 'b'].sort()  // length of result is 3
[ 'a', 'b', ,  ]

for-in 循环

for-in循环正确列出属性键(它们是数组索引的超集):

> for (var key in ['a',, 'b']) { console.log(key) }
0
2

Function.prototype.apply()

apply()将每个空洞转换为一个值为undefined的参数。以下交互演示了这一点:函数f()将其参数作为数组返回。当我们传递一个带有三个空洞的数组给apply()以调用f()时,后者接收到三个undefined参数:

> function f() { return [].slice.call(arguments) }
> f.apply(null, [ , , ,])
[ undefined, undefined, undefined ]

这意味着我们可以使用apply()来创建一个带有undefined的数组:

> Array.apply(null, Array(3))
[ undefined, undefined, undefined ]

警告

apply()将空洞转换为undefined在空数组中,但不能用于在任意数组中填补空洞(可能包含或不包含空洞)。例如,任意数组[2]

> Array.apply(null, [2])
[ , ,]

数组不包含任何空洞,所以apply()应该返回相同的数组。但实际上它返回一个长度为 2 的空数组(它只包含两个空洞)。这是因为Array()将单个数字解释为数组长度,而不是数组元素。

从数组中移除空洞

正如我们所见,filter()会移除空洞:

> ['a',, 'b'].filter(function (x) { return true })
[ 'a', 'b' ]

使用自定义函数将任意数组中的空洞转换为undefined

function convertHolesToUndefineds(arr) {
    var result = [];
    for (var i=0; i < arr.length; i++) {
        result[i] = arr[i];
    }
    return result;
}

使用该函数:

> convertHolesToUndefineds(['a',, 'b'])
[ 'a', undefined, 'b' ]

数组构造方法

Array.isArray(obj)

如果obj是数组则返回true。它正确处理跨realms(窗口或框架)的对象——与instanceof相反(参见Pitfall: crossing realms (frames or windows))。

数组原型方法

在接下来的章节中,数组原型方法按功能分组。对于每个子章节,我会提到这些方法是破坏性的(它们会改变被调用的数组)还是非破坏性的(它们不会修改它们的接收者;这样的方法通常会返回新的数组)。

添加和删除元素(破坏性)

本节中的所有方法都是破坏性的:

Array.prototype.shift()

删除索引为 0 的元素并返回它。随后元素的索引减 1:

> var arr = [ 'a', 'b' ];
> arr.shift()
'a'
> arr
[ 'b' ]

Array.prototype.unshift(elem1?, elem2?, ...)

将给定的元素添加到数组的开头。它返回新的长度:

> var arr = [ 'c', 'd' ];
> arr.unshift('a', 'b')
4
> arr
[ 'a', 'b', 'c', 'd' ]

Array.prototype.pop()

移除数组的最后一个元素并返回它:

> var arr = [ 'a', 'b' ];
> arr.pop()
'b'
> arr
[ 'a' ]

Array.prototype.push(elem1?, elem2?, ...)

将给定的元素添加到数组的末尾。它返回新的长度:

> var arr = [ 'a', 'b' ];
> arr.push('c', 'd')
4
> arr
[ 'a', 'b', 'c', 'd' ]

apply()(参见Function.prototype.apply(thisValue, argArray))使您能够破坏性地将数组arr2附加到另一个数组arr1

> var arr1 = [ 'a', 'b' ];
> var arr2 = [ 'c', 'd' ];

> Array.prototype.push.apply(arr1, arr2)
4
> arr1
[ 'a', 'b', 'c', 'd' ]

Array.prototype.splice(start, deleteCount?, elem1?, elem2?, ...)

start开始,删除deleteCount个元素并插入给定的元素。换句话说,您正在用elem1elem2等替换位置start处的deleteCount个元素。该方法返回已被移除的元素:

> var arr = [ 'a', 'b', 'c', 'd' ];
> arr.splice(1, 2, 'X');
[ 'b', 'c' ]
> arr
[ 'a', 'X', 'd' ]

特殊的参数值:

  • start可以为负数,这种情况下它将被加到长度以确定起始索引。因此,-1指的是最后一个元素,依此类推。

  • deleteCount是可选的。如果省略(以及所有后续参数),则删除从索引start开始的所有元素及之后的所有元素。

在此示例中,我们删除最后两个索引后的所有元素:

> var arr = [ 'a', 'b', 'c', 'd' ];
> arr.splice(-2)
[ 'c', 'd' ]
> arr
[ 'a', 'b' ]

排序和颠倒元素(破坏性)

这些方法也是破坏性的:

Array.prototype.reverse()

颠倒数组中元素的顺序并返回对原始(修改后的)数组的引用:

> var arr = [ 'a', 'b', 'c' ];
> arr.reverse()
[ 'c', 'b', 'a' ]
> arr // reversing happened in place
[ 'c', 'b', 'a' ]

Array.prototype.sort(compareFunction?)

对数组进行排序并返回它:

> var arr = ['banana', 'apple', 'pear', 'orange'];
> arr.sort()
[ 'apple', 'banana', 'orange', 'pear' ]
> arr  // sorting happened in place
[ 'apple', 'banana', 'orange', 'pear' ]

请记住,排序通过将值转换为字符串进行比较,这意味着数字不会按数字顺序排序:

> [-1, -20, 7, 50].sort()
[ -1, -20, 50, 7 ]

您可以通过提供可选参数compareFunction来解决这个问题,它控制排序的方式。它具有以下签名:

function compareFunction(a, b)

此函数比较ab并返回:

  • 如果a小于b,则返回小于零的整数(例如,-1

  • 如果a等于b,则返回零

  • 如果a大于b,则返回大于零的整数(例如,1

比较数字

对于数字,您可以简单地返回a-b,但这可能会导致数值溢出。为了防止这种情况发生,您需要更冗长的代码:

function compareCanonically(a, b) {
    if (a < b) {
        return -1;
    } else if (a > b) {
        return 1;
    } else {
        return 0;
    }
}

我不喜欢嵌套的条件运算符。但在这种情况下,代码要简洁得多,我很想推荐它:

function compareCanonically(a, b) {
    return return a < b ? -1 (a > b ? 1 : 0);
}

使用该函数:

> [-1, -20, 7, 50].sort(compareCanonically)
[ -20, -1, 7, 50 ]

比较字符串

对于字符串,您可以使用String.prototype.localeCompare(参见比较字符串):

> ['c', 'a', 'b'].sort(function (a,b) { return a.localeCompare(b) })
[ 'a', 'b', 'c' ]

比较对象

参数compareFunction对于排序对象也很有用:

var arr = [
    { name: 'Tarzan' },
    { name: 'Cheeta' },
    { name: 'Jane' } ];

function compareNames(a,b) {
    return a.name.localeCompare(b.name);
}

使用compareNames作为比较函数,arrname排序:

> arr.sort(compareNames)
[ { name: 'Cheeta' },
  { name: 'Jane' },
  { name: 'Tarzan' } ]

连接、切片、连接(非破坏性)

以下方法对数组执行各种非破坏性操作:

Array.prototype.concat(arr1?, arr2?, ...)

创建一个新数组,其中包含接收器的所有元素,后跟数组arr1的所有元素,依此类推。如果其中一个参数不是数组,则将其作为元素添加到结果中(例如,这里的第一个参数'c'):

> var arr = [ 'a', 'b' ];
> arr.concat('c', ['d', 'e'])
[ 'a', 'b', 'c', 'd', 'e' ]

调用concat()的数组不会改变:

> arr
[ 'a', 'b' ]

Array.prototype.slice(begin?, end?)

将数组元素复制到一个新数组中,从begin开始,直到end之前的元素:

> [ 'a', 'b', 'c', 'd' ].slice(1, 3)
[ 'b', 'c' ]

如果缺少end,则使用数组长度:

> [ 'a', 'b', 'c', 'd' ].slice(1)
[ 'b', 'c', 'd' ]

如果两个索引都缺失,则复制数组:

> [ 'a', 'b', 'c', 'd' ].slice()
[ 'a', 'b', 'c', 'd' ]

如果任一索引为负数,则将数组长度加上它。因此,-1指的是最后一个元素,依此类推:

> [ 'a', 'b', 'c', 'd' ].slice(1, -1)
[ 'b', 'c' ]
> [ 'a', 'b', 'c', 'd' ].slice(-2)
[ 'c', 'd' ]

Array.prototype.join(separator?)

通过对所有数组元素应用toString()并在结果之间放置separator字符串来创建一个字符串。如果省略separator,则使用,

> [3, 4, 5].join('-')
'3-4-5'
> [3, 4, 5].join()
'3,4,5'
> [3, 4, 5].join('')
'345'

join()undefinednull转换为空字符串:

> [undefined, null].join('#')
'#'

数组中的空位也会转换为空字符串:

> ['a',, 'b'].join('-')
'a--b'

搜索值(非破坏性)

以下方法在数组中搜索值:

Array.prototype.indexOf(searchValue, startIndex?)

startIndex开始搜索数组中的searchValue。它返回第一次出现的索引,如果找不到则返回-1。如果startIndex为负数,则将数组长度加上它;如果缺少startIndex,则搜索整个数组:

> [ 3, 1, 17, 1, 4 ].indexOf(1)
1
> [ 3, 1, 17, 1, 4 ].indexOf(1, 2)
3

搜索时使用严格相等(参见相等运算符:=),这意味着indexOf()无法找到NaN

> [NaN].indexOf(NaN)
-1

Array.prototype.lastIndexOf(searchElement, startIndex?)

startIndex开始向后搜索searchElement,返回第一次出现的索引或-1(如果找不到)。如果startIndex为负数,则将数组长度加上它;如果缺失,则搜索整个数组。搜索时使用严格相等(参见相等运算符:=):

> [ 3, 1, 17, 1, 4 ].lastIndexOf(1)
3
> [ 3, 1, 17, 1, 4 ].lastIndexOf(1, -3)
1

迭代(非破坏性)

迭代方法使用一个函数来迭代数组。我区分三种迭代方法,它们都是非破坏性的:检查方法主要观察数组的内容;转换方法从接收器派生一个新数组;减少方法基于接收器的元素计算结果。

检查方法

本节中描述的每个方法都是这样的:

arr.examinationMethod(callback, thisValue?)

这样的方法需要以下参数:

  • callback是它的第一个参数,一个它调用的函数。根据检查方法的不同,回调返回布尔值或无返回值。它具有以下签名:

    function callback(element, index, array)
    

elementcallback要处理的数组元素,index是元素的索引,array是调用examinationMethod的数组。

  • thisValue允许您配置callback内部的this的值。

现在是我刚刚描述的检查方法的签名:

Array.prototype.forEach(callback, thisValue?)

迭代数组的元素:

var arr = [ 'apple', 'pear', 'orange' ];
arr.forEach(function (elem) {
    console.log(elem);
});

Array.prototype.every(callback, thisValue?)

如果回调对每个元素返回true,则返回true。一旦回调返回false,迭代就会停止。请注意,不返回值会导致隐式返回undefinedevery()将其解释为falseevery()的工作方式类似于全称量词(“对于所有”)。

这个例子检查数组中的每个数字是否都是偶数:

> function isEven(x) { return x % 2 === 0 }
> [ 2, 4, 6 ].every(isEven)
true
> [ 2, 3, 4 ].every(isEven)
false

如果数组为空,则结果为true(并且不调用callback):

> [].every(function () { throw new Error() })
true

Array.prototype.some(callback, thisValue?)

如果回调对至少一个元素返回true,则返回true。一旦回调返回true,迭代就会停止。请注意,不返回值会导致隐式返回undefinedsome()将其解释为falsesome()的工作方式类似于存在量词(“存在”)。

这个例子检查数组中是否有偶数:

> function isEven(x) { return x % 2 === 0 }
> [ 1, 3, 5 ].some(isEven)
false
> [ 1, 2, 3 ].some(isEven)
true

如果数组为空,则结果为false(并且不调用callback):

> [].some(function () { throw new Error() })
false

forEach()的一个潜在陷阱是它不支持break或类似的东西来提前中止循环。如果你需要这样做,可以使用some()

function breakAtEmptyString(strArr) {
    strArr.some(function (elem) {
        if (elem.length === 0) {
            return true; // break
        }
        console.log(elem);
        // implicit: return undefined (interpreted as false)
    });
}

some()如果发生了中断,则返回true,否则返回false。这使您可以根据迭代是否成功完成(这在for循环中有点棘手)做出不同的反应。

转换方法

转换方法接受一个输入数组并产生一个输出数组,而回调控制输出的产生方式。回调的签名与检查方法相同:

function callback(element, index, array)

有两种转换方法:

Array.prototype.map(callback, thisValue?)

每个输出数组元素是将callback应用于输入元素的结果。例如:

> [ 1, 2, 3 ].map(function (x) { return 2 * x })
[ 2, 4, 6 ]

Array.prototype.filter(callback, thisValue?)

输出数组仅包含那些callback返回true的输入元素。例如:

> [ 1, 0, 3, 0 ].filter(function (x) { return x !== 0 })
[ 1, 3 ]

减少方法

对于减少,回调具有不同的签名:

function callback(previousValue, currentElement, currentIndex, array)

参数previousValue是回调先前返回的值。当首次调用回调时,有两种可能性(描述适用于Array.prototype.reduce();与reduceRight()的差异在括号中提到):

  • 提供了明确的initialValue。然后previousValueinitialValuecurrentElement是第一个数组元素(reduceRight:最后一个数组元素)。

  • 没有提供明确的initialValue。然后previousValue是第一个数组元素,currentElement是第二个数组元素(reduceRight:最后一个数组元素和倒数第二个数组元素)。

有两种减少的方法:

Array.prototype.reduce(callback, initialValue?)

从左到右迭代,并像之前描述的那样调用回调。该方法的结果是回调返回的最后一个值。此示例计算所有数组元素的总和:

function add(prev, cur) {
    return prev + cur;
}
console.log([10, 3, -1].reduce(add)); // 12

如果你在一个只有一个元素的数组上调用reduce,那么该元素会被返回:

> [7].reduce(add)
7

如果你在一个空数组上调用reduce,你必须指定initialValue,否则你会得到一个异常:

> [].reduce(add)
TypeError: Reduce of empty array with no initial value
> [].reduce(add, 123)
123

Array.prototype.reduceRight(callback, initialValue?)

reduce()相同,但从右到左迭代。

注意

在许多函数式编程语言中,reduce被称为foldfoldl(左折叠),reduceRight被称为foldr(右折叠)。

reduce方法的另一种方式是它实现了一个 n 元运算符OP

OP[1≤i≤n] x[i]

通过一系列二元运算符op2的应用:

(...(x[1] op2 x[2]) op2 ...) op2 x[n]

这就是前面代码示例中发生的事情:我们通过 JavaScript 的二进制加法运算符实现了一个数组的 n 元求和运算符。

例如,让我们通过以下函数来检查两个迭代方向:

function printArgs(prev, cur, i) {
    console.log('prev:'+prev+', cur:'+cur+', i:'+i);
    return prev + cur;
}

如预期的那样,reduce()从左到右迭代:

> ['a', 'b', 'c'].reduce(printArgs)
prev:a, cur:b, i:1
prev:ab, cur:c, i:2
'abc'
> ['a', 'b', 'c'].reduce(printArgs, 'x')
prev:x, cur:a, i:0
prev:xa, cur:b, i:1
prev:xab, cur:c, i:2
'xabc'

reduceRight()从右到左迭代:

> ['a', 'b', 'c'].reduceRight(printArgs)
prev:c, cur:b, i:1
prev:cb, cur:a, i:0
'cba'
> ['a', 'b', 'c'].reduceRight(printArgs, 'x')
prev:x, cur:c, i:2
prev:xc, cur:b, i:1
prev:xcb, cur:a, i:0
'xcba'

陷阱:类数组对象

JavaScript 中的一些对象看起来像数组,但它们并不是数组。这通常意味着它们具有索引访问和length属性,但没有数组方法。例子包括特殊变量arguments,DOM 节点列表和字符串。类数组对象和通用方法提供了处理类数组对象的提示。

最佳实践:迭代数组

要迭代一个数组arr,你有两个选择:

  • 一个简单的for循环(参见for):

    for (var i=0; i<arr.length; i++) {
        console.log(arr[i]);
    }
    
  • 数组迭代方法之一(参见迭代(非破坏性))。例如,forEach()

    arr.forEach(function (elem) {
        console.log(elem);
    });
    

不要使用for-in循环(参见for-in)来迭代数组。它遍历索引,而不是值。在这样做的同时,它包括正常属性的键,包括继承的属性。

第十九章:正则表达式

原文:19. Regular Expressions

译者:飞龙

协议:CC BY-NC-SA 4.0

本章概述了正则表达式的 JavaScript API。它假定你对它们的工作原理有一定了解。如果你不了解,网上有很多好的教程。其中两个例子是:

正则表达式语法

这里使用的术语与 ECMAScript 规范中的语法非常接近。我有时会偏离以使事情更容易理解。

原子:一般

一般原子的语法如下:

特殊字符

以下所有字符都具有特殊含义:

\ ^ $ . * + ? ( ) [ ] { } |

你可以通过在前面加上反斜杠来转义它们。例如:

> /^(ab)$/.test('(ab)')
false
> /^\(ab\)$/.test('(ab)')
true

其他特殊字符包括:

  • 在字符类[...]中:

    -
    
  • 在以问号(?...)开头的组内:

    : = ! < >
    

尖括号仅由 XRegExp 库(参见第三十章)使用,用于命名组。

模式字符

除了前面提到的特殊字符之外,所有字符都与它们自己匹配。

.(点)

匹配任何 JavaScript 字符(UTF-16 代码单元),除了行终止符(换行符、回车符等)。要真正匹配任何字符,请使用[\s\S]。例如:

> /./.test('\n')
false
> /[\s\S]/.test('\n')
true

字符转义(匹配单个字符)

  • 特定的控制字符包括\f(换页符)、\n(换行符、新行)、\r(回车符)、\t(水平制表符)和\v(垂直制表符)。

  • \0匹配 NUL 字符(\u0000)。

  • 任何控制字符:\cA\cZ

  • Unicode 字符转义:\u0000\xFFFF(Unicode 代码单元;参见第二十四章)。

  • 十六进制字符转义:\x00\xFF

字符类转义(匹配一组字符中的一个)

  • 数字:\d匹配任何数字(与[0-9]相同);\D匹配任何非数字(与[^0-9]相同)。

  • 字母数字字符:\w匹配任何拉丁字母数字字符加下划线(与[A-Za-z0-9_]相同);\W匹配所有未被\w匹配的字符。

  • 空白:\s匹配空白字符(空格、制表符、换行符、回车符、换页符、所有 Unicode 空格等);\S匹配所有非空白字符。

原子:字符类

字符类的语法如下:

  • [«charSpecs»]匹配至少一个charSpecs中的任何一个的单个字符。

  • [^«charSpecs»]匹配任何不匹配charSpecs中任何一个的单个字符。

以下构造都是字符规范:

  • 源字符匹配它们自己。大多数字符都是源字符(甚至许多在其他地方是特殊的字符)。只有三个字符不是:

        \ ] -
    

像往常一样,您可以通过反斜杠进行转义。如果要匹配破折号而不进行转义,它必须是方括号打开后的第一个字符,或者是范围的右侧,如下所述。

  • 类转义:允许使用先前列出的任何字符转义和字符类转义。还有一个额外的转义:

  • 退格(\b):在字符类之外,\b匹配单词边界。在字符类内,它匹配控制字符退格

  • 范围包括源字符或类转义,后跟破折号(-),后跟源字符或类转义。

为了演示使用字符类,此示例解析了按照 ISO 8601 标准格式化的日期:

function parseIsoDate(str) {
    var match = /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.exec(str);

    // Other ways of writing the regular expression:
    // /^([0-9][0-9][0-9][0-9])-([0-9][0-9])-([0-9][0-9])$/
    // /^(\d\d\d\d)-(\d\d)-(\d\d)$/

    if (!match) {
        throw new Error('Not an ISO date: '+str);
    }
    console.log('Year: '  + match[1]);
    console.log('Month: ' + match[2]);
    console.log('Day: '   + match[3]);
}

以下是交互:

> parseIsoDate('2001-12-24')
Year: 2001
Month: 12
Day: 24

原子:组

组的语法如下:

  • («pattern»)是一个捕获组。由pattern匹配的任何内容都可以通过反向引用或作为匹配操作的结果来访问。

  • (?:«pattern»)是一个非捕获组。pattern仍然与输入匹配,但不保存为捕获。因此,该组没有您可以引用的编号(例如,通过反向引用)。

\1\2等被称为反向引用;它们指回先前匹配的组。反斜杠后面的数字可以是大于或等于 1 的任何整数,但第一个数字不能是 0。

在此示例中,反向引用保证了破��号之前和之后的 a 的数量相同:

> /^(a+)-\1$/.test('a-a')
true
> /^(a+)-\1$/.test('aaa-aaa')
true
> /^(a+)-\1$/.test('aa-a')
false

此示例使用反向引用来匹配 HTML 标签(显然,通常应使用适当的解析器来处理 HTML):

> var tagName = /<([^>]+)>[^<]*<\/\1>/;
> tagName.exec('<b>bold</b>')[1]
'b'
> tagName.exec('<strong>text</strong>')[1]
'strong'
> tagName.exec('<strong>text</stron>')
null

量词

任何原子(包括字符类和组)都可以后跟一个量词:

  • ?表示匹配零次或一次。

  • *表示匹配零次或多次。

  • +表示匹配一次或多次。

  • {n}表示精确匹配n次。

  • {n,}表示匹配n次或更多次。

  • {n,m}表示至少匹配n次,最多匹配m次。

默认情况下,量词是贪婪的;也就是说,它们尽可能多地匹配。您可以通过在任何前述量词(包括大括号中的范围)后加上问号(?)来获得勉强匹配(尽可能少)。例如:

> '<a> <strong>'.match(/^<(.*)>/)[1]  // greedy
'a> <strong'
> '<a> <strong>'.match(/^<(.*?)>/)[1]  // reluctant
'a'

因此,.*?是一个用于匹配直到下一个原子出现的有用模式。例如,以下是刚刚显示的 HTML 标签的正则表达式的更紧凑版本(使用[^<]*代替.*?):

/<(.+?)>.*?<\/\1>/

断言

断言,如下列表所示,是关于输入中当前位置的检查:

^ 仅在输入的开头匹配。
` ^
--- ---
仅在输入的末尾匹配。
\b 仅在单词边界处匹配。不要与[\b]混淆,它匹配退格。
\B 仅当不在单词边界时匹配。
(?=«pattern») 正向预查:仅当“模式”匹配接下来的内容时才匹配。“模式”仅用于向前查看,否则会被忽略。
(?!«pattern») 负向预查:仅当“模式”不匹配接下来的内容时才匹配。“模式”仅用于向前查看,否则会被忽略。

此示例通过\b匹配单词边界:

> /\bell\b/.test('hello')
false
> /\bell\b/.test('ello')
false
> /\bell\b/.test('ell')
true

此示例通过\B匹配单词内部:

> /\Bell\B/.test('ell')
false
> /\Bell\B/.test('hell')
false
> /\Bell\B/.test('hello')
true

注意

不支持后行断言。手动实现后行断言解释了如何手动实现它。

分歧

分歧运算符(|)分隔两个选择;任一选择必须匹配分歧才能匹配。这些选择是原子(可选包括量词)。

该运算符的绑定非常弱,因此您必须小心,以确保选择不会延伸太远。例如,以下正则表达式匹配所有以'aa'开头或以'bb'结尾的字符串:

> /^aa|bb$/.test('aaxx')
true
> /^aa|bb$/.test('xxbb')
true

换句话说,分歧比甚至^$都要弱,两个选择是^aabb$。如果要匹配两个字符串'aa''bb',则需要括号:

/^(aa|bb)$/

同样,如果要匹配字符串'aab''abb'

/^a(a|b)b$/

Unicode 和正则表达式

JavaScript 的正则表达式对 Unicode 的支持非常有限。特别是当涉及到星际飞船中的代码点时,您必须小心。第二十四章解释了详细信息。

创建正则表达式

您可以通过文字或构造函数创建正则表达式,并通过标志配置其工作方式。

文字与构造函数

有两种方法可以创建正则表达式:您可以使用文字或构造函数RegExp

文字:/xyz/i:在加载时编译

构造函数(第二个参数是可选的):new RegExp('xyz','i'):在运行时编译

文字和构造函数在编译时不同:

  • 文字在加载时编译。在评估时,以下代码将引发异常:

    function foo() {
        /[/;
    }
    
  • 构造函数在调用时编译正则表达式。以下代码不会引发异常,但调用foo()会:

    function foo() {
        new RegExp('[');
    }
    

因此,通常应使用文字,但如果要动态组装正则表达式,则需要构造函数。

标志

标志是正则表达式文字的后缀和正则表达式构造函数的参数;它们修改正则表达式的匹配行为。存在以下标志:

短名称 长名称 描述
g 全局 给定的正则表达式多次匹配。影响几种方法,特别是replace()
i 忽略大小写 在尝试匹配给定的正则表达式时忽略大小写。
m 多行模式 在多行模式下,开始运算符^和结束运算符$匹配每一行,而不是完整的输入字符串。

短名称用于文字前缀和构造函数参数(请参见下一节中的示例)。长名称用于正则表达式的属性,指示在创建期间设置了哪些标志。

正则表达式的实例属性

正则表达式具有以下实例属性:

  • 标志:表示设置了哪些标志的布尔值:

  • 全局:标志/g设置了吗?

  • 忽略大小写:标志/i设置了吗?

  • 多行:标志/m设置了吗?

  • 用于多次匹配的数据(设置了/g标志):

  • lastIndex是下次继续搜索的索引。

以下是访问标志的实例属性的示例:

> var regex = /abc/i;
> regex.ignoreCase
true
> regex.multiline
false

创建正则表达式的示例

在这个例子中,我们首先使用文字创建相同的正则表达式,然后使用构造函数,并使用test()方法来确定它是否匹配一个字符串:

> /abc/.test('ABC')
false
> new RegExp('abc').test('ABC')
false

在这个例子中,我们创建一个忽略大小写的正则表达式(标志/i):

> /abc/i.test('ABC')
true
> new RegExp('abc', 'i').test('ABC')
true

RegExp.prototype.test:是否有匹配?

test()方法检查正则表达式regex是否匹配字符串str

regex.test(str)

test()的操作方式取决于标志/g是否设置。

如果标志/g未设置,则该方法检查str中是否有匹配。例如:

> var str = '_x_x';

> /x/.test(str)
true
> /a/.test(str)
false

如果设置了标志/g,则该方法返回true,直到strregex的匹配次数。属性regex.lastIndex包含最后匹配后的索引:

> var regex = /x/g;
> regex.lastIndex
0

> regex.test(str)
true
> regex.lastIndex
2

> regex.test(str)
true
> regex.lastIndex
4

> regex.test(str)
false

String.prototype.search:有匹配的索引吗?

search()方法在str中查找与regex匹配的内容:

str.search(regex)

如果有匹配,返回找到匹配的索引。否则,结果为-1regexgloballastIndex属性在执行搜索时被忽略(lastIndex不会改变)。

例如:

> 'abba'.search(/b/)
1
> 'abba'.search(/x/)
-1

如果search()的参数不是正则表达式,则会转换为正则表达式:

> 'aaab'.search('^a+b+$')
0

RegExp.prototype.exec:捕获组

以下方法调用在匹配regexstr时捕获组:

var matchData = regex.exec(str);

如果没有匹配,matchDatanull。否则,matchData是一个匹配结果,一个带有两个额外属性的数组:

数组元素

  • 元素 0 是完整正则表达式的匹配(如果愿意的话,是第 0 组)。

  • 元素n > 1 是第n组的捕获。

属性

  • input是完整的输入字符串。

  • index是找到匹配的索引。

第一个匹配(标志/g 未设置)

如果标志/g未设置,则只返回第一个匹配:

> var regex = /a(b+)/;
> regex.exec('_abbb_ab_')
[ 'abbb',
  'bbb',
  index: 1,
  input: '_abbb_ab_' ]
> regex.lastIndex
0

所有匹配(标志/g 设置)

如果设置了标志/g,则如果反复调用exec(),所有匹配都会被返回。返回值null表示没有更多的匹配。属性lastIndex指示下次匹配将继续的位置:

> var regex = /a(b+)/g;
> var str = '_abbb_ab_';

> regex.exec(str)
[ 'abbb',
  'bbb',
  index: 1,
  input: '_abbb_ab_' ]
> regex.lastIndex
6

> regex.exec(str)
[ 'ab',
  'b',
  index: 7,
  input: '_abbb_ab_' ]
> regex.lastIndex
10

> regex.exec(str)
null

在这里我们循环匹配:

var regex = /a(b+)/g;
var str = '_abbb_ab_';
var match;
while (match = regex.exec(str)) {
    console.log(match[1]);
}

我们得到以下输出:

bbb
b

String.prototype.match:捕获组或返回所有匹配的子字符串

以下方法调用匹配regexstr

var matchData = str.match(regex);

如果regex的标志/g未设置,此方法的工作方式类似于RegExp.prototype.exec()

> 'abba'.match(/a/)
[ 'a', index: 0, input: 'abba' ]

如果设置了标志,则该方法返回一个包含str中所有匹配子字符串的数组(即每次匹配的第 0 组),如果没有匹配则返回null

> 'abba'.match(/a/g)
[ 'a', 'a' ]
> 'abba'.match(/x/g)
null

String.prototype.replace:搜索和替换

replace()方法搜索字符串str,找到与search匹配的内容,并用replacement替换它们:

str.replace(search, replacement)

有几种方式可以指定这两个参数:

search

可以是字符串或正则表达式:

  • 字符串:在输入字符串中直接查找。请注意,只有第一次出现的字符串会被替换。如果要替换多个出现,必须使用带有/g标志的正则表达式。这是一个意外和一个主要的陷阱。

  • 正则表达式:与输入字符串匹配。警告:使用global标志,否则只会尝试一次匹配正则表达式。

replacement

可以是字符串或函数:

  • 字符串:描述如何替换已找到的内容。

  • 功能:通过参数提供匹配信息来计算替换。

替换是一个字符串

如果replacement是一个字符串,它的内容将被直接使用来替换匹配。唯一的例外是特殊字符美元符号($),它启动所谓的替换指令

  • 组:$n插入匹配中的第 n 组。n必须至少为 1($0没有特殊含义)。

  • 匹配的子字符串:

  • `$``(反引号)插入匹配前的文本。

  • $&插入完整的匹配。

  • $'(撇号)插入匹配后的文本。

  • $$插入一个单独的$

这个例子涉及匹配的子字符串及其前缀和后缀:

> 'axb cxd'.replace(/x/g, "[$`,$&,$']")
'a[a,x,b cxd]b c[axb c,x,d]d'

这个例子涉及到一个组:

> '"foo" and "bar"'.replace(/"(.*?)"/g, '#$1#')
'#foo# and #bar#'

替换是一个函数

如果replacement是一个函数,它会计算要替换匹配的字符串。此函数具有以下签名:

function (completeMatch, group_1, ..., group_n, offset, inputStr)

completeMatch与以前的$&相同,offset指示找到匹配的位置,inputStr是正在匹配的内容。因此,您可以使用特殊变量arguments来访问组(通过arguments[1]访问第 1 组,依此类推)。例如:

> function replaceFunc(match) { return 2 * match }
> '3 apples and 5 oranges'.replace(/[0-9]+/g, replaceFunc)
'6 apples and 10 oranges'

标志/g的问题

设置了/g标志的正则表达式如果必须多次调用才能返回所有结果,则存在问题。这适用于两种方法:

  • RegExp.prototype.test()

  • RegExp.prototype.exec()

然后 JavaScript 滥用正则表达式作为迭代器,作为结果序列的指针。这会导致问题:

问题 1:无法内联/g正则表达式

例如:

// Don’t do that:
var count = 0;
while (/a/g.test('babaa')) count++;

前面的循环是无限的,因为每次循环迭代都会创建一个新的正则表达式,从而重新开始结果的迭代。因此,必须重写代码:

var count = 0;
var regex = /a/g;
while (regex.test('babaa')) count++;

这是另一个例子:

// Don’t do that:
function extractQuoted(str) {
    var match;
    var result = [];
    while ((match = /"(.*?)"/g.exec(str)) != null) {
        result.push(match[1]);
    }
    return result;
}

调用前面的函数将再次导致无限循环。正确的版本是(为什么lastIndex设置为 0 很快就会解释):

var QUOTE_REGEX = /"(.*?)"/g;
function extractQuoted(str) {
    QUOTE_REGEX.lastIndex = 0;
    var match;
    var result = [];
    while ((match = QUOTE_REGEX.exec(str)) != null) {
        result.push(match[1]);
    }
    return result;
}

使用该函数:

> extractQuoted('"hello", "world"')
[ 'hello', 'world' ]

提示

最好的做法是不要内联(然后您可以给正则表达式起一个描述性的名称)。但是您必须意识到您不能这样做,即使是在快速的 hack 中也不行。

问题 2:/g正则表达式作为参数

调用test()exec()多次的代码在作为参数传递给它的正则表达式时必须小心。它的标志/g必须激活,并且为了安全起见,它的lastIndex应该设置为零(下一个示例中提供了解释)。

问题 3:共享的/g正则表达式(例如,常量)

每当引用尚未新创建的正则表达式时,您应该在将其用作迭代器之前将其lastIndex属性设置为零(下一个示例中提供了解释)。由于迭代取决于lastIndex,因此这样的正则表达式不能同时在多个迭代中使用。

以下示例说明了问题 2。这是一个简单的实现函数,用于计算字符串str中正则表达式regex的匹配次数:

// Naive implementation
function countOccurrences(regex, str) {
    var count = 0;
    while (regex.test(str)) count++;
    return count;
}

以下是使用此函数的示例:

> countOccurrences(/x/g, '_x_x')
2

第一个问题是,如果正则表达式的/g标志未设置,此函数将进入无限循环。例如:

countOccurrences(/x/, '_x_x') // never terminates

第二个问题是,如果regex.lastIndex不是 0,函数将无法正确工作,因为该属性指示从哪里开始搜索。例如:

> var regex = /x/g;
> regex.lastIndex = 2;
> countOccurrences(regex, '_x_x')
1

以下实现解决了两个问题:

function countOccurrences(regex, str) {
    if (! regex.global) {
        throw new Error('Please set flag /g of regex');
    }
    var origLastIndex = regex.lastIndex;  // store
    regex.lastIndex = 0;

    var count = 0;
    while (regex.test(str)) count++;

    regex.lastIndex = origLastIndex;  // restore
    return count;
}

一个更简单的替代方法是使用match()

function countOccurrences(regex, str) {
    if (! regex.global) {
        throw new Error('Please set flag /g of regex');
    }
    return (str.match(regex) || []).length;
}

有一个可能的陷阱:如果设置了/g标志并且没有匹配项,str.match()将返回null。在前面的代码中,我们通过使用[]来避免这种陷阱,如果match()的结果不是真值。

提示和技巧

本节提供了一些在 JavaScript 中使用正则表达式的技巧和窍门。

引用文本

有时,当手动组装正则表达式时,您希望逐字使用给定的字符串。这意味着不能解释任何特殊字符(例如,*[)-所有这些字符都需要转义。JavaScript 没有内置的方法来进行这种引用,但是您可以编写自己的函数quoteText,它将按以下方式工作:

> console.log(quoteText('*All* (most?) aspects.'))
\*All\* \(most\?\) aspects\.

如果您需要进行多次搜索和替换,则此函数特别方便。然后要搜索的值必须是设置了global标志的正则表达式。使用quoteText(),您可以使用任意字符串。该函数如下所示:

function quoteText(text) {
    return text.replace(/[\\^$.*+?()[\]{}|=!<>:-]/g, '\\$&');
}

所有特殊字符都被转义,因为您可能希望在括号或方括号内引用多个字符。

陷阱:没有断言(例如,^,$),正则表达式可以在任何地方找到

如果您不使用^$等断言,大多数正则表达式方法会在任何地方找到模式。例如:

> /aa/.test('xaay')
true
> /^aa$/.test('xaay')
false

匹配一切或什么都不匹配

这是一个罕见的用例,但有时您需要一个正则表达式,它匹配一切或什么都不匹配。例如,一个函数可能有一个用于过滤的正则表达式参数。如果该参数缺失,您可以给它一个默认值,一个匹配一切的正则表达式。

匹配一切

空正则表达式匹配一切。我们可以基于该正则表达式创建一个RegExp的实例,就像这样:

> new RegExp('').test('dfadsfdsa')
true
> new RegExp('').test('')
true

但是,空正则表达式文字将是//,这在 JavaScript 中被解释为注释。因此,以下是通过文字获得的最接近的:/(?:)/(空的非捕获组)。该组匹配一切,同时不捕获任何内容,这个组不会影响exec()返回的结果。即使 JavaScript 本身在显示空正则表达式时也使用前面的表示:

> new RegExp('')
/(?:)/

匹配什么都不匹配

空正则表达式具有一个反义词——匹配什么都不匹配的正则表达式:

> var never = /.^/;
> never.test('abc')
false
> never.test('')
false

手动实现后行断言

后行断言是一种断言。与先行断言类似,模式用于检查输入中当前位置的某些内容,但在其他情况下被忽略。与先行断言相反,模式的匹配必须结束在当前位置(而不是从当前位置开始)。

以下函数将字符串'NAME'的每个出现替换为参数name的值,但前提是该出现不是由引号引导的。我们通过“手动”检查当前匹配之前的字符来处理引号:

function insertName(str, name) {
    return str.replace(
        /NAME/g,
        function (completeMatch, offset) {
            if (offset === 0 ||
                (offset > 0 && str[offset-1] !== '"')) {
                return name;
            } else {
                return completeMatch;
            }
        }
    );
}
> insertName('NAME "NAME"', 'Jane')
'Jane "NAME"'
> insertName('"NAME" NAME', 'Jane')
'"NAME" Jane'

另一种方法是在正则表达式中包含可能转义的字符。然后,您必须临时向您正在搜索的字符串添加前缀;否则,您将错过该字符串开头的匹配:

function insertName(str, name) {
    var tmpPrefix = ' ';
    str = tmpPrefix + str;
    str = str.replace(
        /([^"])NAME/g,
        function (completeMatch, prefix) {
            return prefix + name;
        }
    );
    return str.slice(tmpPrefix.length); // remove tmpPrefix
}

正则表达式速查表

原子(参见原子:一般):

  • .(点)匹配除行终止符(例如换行符)之外的所有内容。使用[\s\S]来真正匹配一切。

  • 字符类转义:

  • \d匹配数字([0-9]);\D匹配非数字([^0-9])。

  • \w匹配拉丁字母数字字符加下划线([A-Za-z0-9_]);\W匹配所有其他字符。

  • \s匹配所有空白字符(空格、制表符、换行符等);\S匹配所有非空白字符。

  • 字符类(字符集):[...][^...]

  • 源字符:[abc](除\ ] -之外的所有字符都与它们自身匹配)

  • 字符类转义(参见前文):[\d\w]

  • 范围:[A-Za-z0-9]

  • 组:

  • 捕获组:(...);反向引用:\1

  • 非捕获组:(?:...)

量词(参见量词):

  • 贪婪:

  • ? * +

  • {n} {n,} {n,m}

  • 勉强:在任何贪婪量词后面加上?

断言(参见断言):

  • 输入的开始,输入的结束:^ $

  • 在词边界处,不在词边界处:\b \B

  • 正向先行断言:(?=...)(模式必须紧跟其后,但在其他情况下被忽略)

  • 负向先行断言:(?!...)(模式不能紧跟其后,但在其他情况下被忽略)

分支:|

创建正则表达式(参见创建正则表达式):

  • 字面量:/xyz/i(在加载时编译)

  • 构造函数:new RegExp('xzy', 'i')(在运行时编译)

标志(参见标志):

  • 全局:/g(影响几种正则表达式方法)

  • ignoreCase:/i

  • 多行:/m^$按行匹配,而不是完整的输入)

方法:

有关使用标志/g的提示,请参阅Problems with the Flag /g

致谢

Mathias Bynens(@mathias)和 Juan Ignacio Dopazo(@juandopazo)建议使用match()test()来计算出现次数,Šime Vidas(@simevidas)警告我在没有匹配项时要小心使用match()。全局标志导致无限循环的陷阱来自Andrea Giammarchi 的演讲(@webreflection)。Claude Pache 告诉我在quoteText()中转义更多字符。

第二十章:日期

原文:20. Dates

译者:飞龙

协议:CC BY-NC-SA 4.0

JavaScript 的Date构造函数有助于解析、管理和显示日期。本章描述了它的工作原理。

日期 API 使用术语UTC(协调世界时)。在大多数情况下,UTC 是 GMT(格林尼治标准时间)的同义词,大致意味着伦敦,英国的时区。

日期构造函数

有四种调用Date构造函数的方法:

new Date(year, month, date?, hours?, minutes?, seconds?, milliseconds?)

从给定数据构造一个新的日期。时间相对于当前时区进行解释。Date.UTC()提供了类似的功能,但是相对于 UTC。参数具有以下范围:

  • year:对于 0 ≤ year ≤ 99,将添加 1900。

  • month:0-11(0 是一月,1 是二月,依此类推)

  • date:1-31

  • hours:0-23

  • minutes:0-59

  • seconds:0-59

  • milliseconds:0-999

以下是一些示例:

> new Date(2001, 1, 27, 14, 55)
Date {Tue Feb 27 2001 14:55:00 GMT+0100 (CET)}
> new Date(01, 1, 27, 14, 55)
Date {Wed Feb 27 1901 14:55:00 GMT+0100 (CET)}

顺便说一句,JavaScript 继承了略微奇怪的约定,将 0 解释为一月,1 解释为二月,依此类推,这一点来自 Java。

new Date(dateTimeStr)

这是一个将日期时间字符串转换为数字的过程,然后调用new Date(number)日期时间格式解释了日期时间格式。例如:

> new Date('2004-08-29')
Date {Sun Aug 29 2004 02:00:00 GMT+0200 (CEST)}

非法的日期时间字符串导致将NaN传递给new Date(number)

new Date(timeValue)

根据自 1970 年 1 月 1 日 00:00:00 UTC 以来的毫秒数创建日期。例如:

> new Date(0)
Date {Thu Jan 01 1970 01:00:00 GMT+0100 (CET)}

这个构造函数的反函数是getTime()方法,它返回毫秒数:

> new Date(123).getTime()
123

您可以使用NaN作为参数,这将产生Date的一个特殊实例,即“无效日期”:

> var d = new Date(NaN);
> d.toString()
'Invalid Date'
> d.toJSON()
null
> d.getTime()
NaN
> d.getYear()
NaN

new Date()

创建当前日期和时间的对象;它与new Date(Date.now())的作用相同。

日期构造函数方法

构造函数Date有以下方法:

Date.now()

以毫秒为单位返回当前日期和时间(自 1970 年 1 月 1 日 00:00:00 UTC 起)。它产生与new Date().getTime()相同的结果。

Date.parse(dateTimeString)

dateTimeString 转换为自 1970 年 1 月 1 日 00:00:00 UTC 以来的毫秒数。日期时间格式解释了 dateTimeString 的格式。结果可用于调用 new Date(number)。以下是一些示例:

> Date.parse('1970-01-01')
0
> Date.parse('1970-01-02')
86400000

如果无法解析字符串,此方法将返回 NaN

> Date.parse('abc')
NaN

Date.UTC(year, month, date?, hours?, minutes?, seconds?, milliseconds?)

将给定数据转换为自 1970 年 1 月 1 日 00:00:00 UTC 以来的毫秒数。它与具有相同参数的 Date 构造函数有两种不同之处:

  • 它返回一个数字,而不是一个新的日期对象。

  • 它将参数解释为世界协调时间,而不是本地时间。

日期原型方法

本节涵盖了 Date.prototype 的方法。

时间单位的获取器和设置器

时间单位的获取器和设置器可使用以下签名:

  • 本地时间:

  • Date.prototype.get«Unit»() 返回根据本地时间的 Unit

  • Date.prototype.set«Unit»(number) 根据本地时间设置 Unit

  • 世界协调时间:

  • Date.prototype.getUTC«Unit»() 返回根据世界协调时间的 Unit

  • Date.prototype.setUTC«Unit»(number) 根据世界协调时间设置 Unit

Unit 是以下单词之一:

  • FullYear:通常是四位数

  • Month:月份(0-11)

  • Date:月份中的某一天(1-31)

  • Day(仅获取器):星期几(0-6);0 代表星期日

  • Hours:小时(0-23)

  • Minutes:分钟(0-59)

  • Seconds:秒(0-59)

  • Milliseconds:毫秒(0-999)

例如:

> var d = new Date('1968-11-25');
Date {Mon Nov 25 1968 01:00:00 GMT+0100 (CET)}
> d.getDate()
25
> d.getDay()
1

各种获取器和设置器

以下方法使您能够获取和设置自 1970 年 1 月 1 日以来的毫秒数以及更多内容:

Year 单位已被弃用,推荐使用 FullYear

  • Date.prototype.getYear() 已被弃用;请改用 getFullYear()

  • Date.prototype.setYear(number) 已被弃用;请改用 setFullYear()

将日期转换为字符串

请注意,转换为字符串高度依赖于实现。以下日期用于计算以下示例中的输出(在撰写本书时,Firefox 的支持最完整):

new Date(2001,9,30, 17,43,7, 856);

时间(可读)

  • Date.prototype.toTimeString()

    17:43:07 GMT+0100 (CET)
    

以当前时区的时间显示。

  • Date.prototype.toLocaleTimeString()

    17:43:07
    

以特定于区域的格式显示的时间。此方法由 ECMAScript 国际化 API(参见ECMAScript 国际化 API)提供,并且如果没有它,就没有太多意义。

日期(可读)

  • Date.prototype.toDateString()

    Tue Oct 30 2001
    

日期。

  • Date.prototype.toLocaleDateString()

    10/30/2001
    

以特定于区域的格式显示的日期。此方法由 ECMAScript 国际化 API(参见ECMAScript 国际化 API)提供,并且如果没有它,就没有太多意义。

日期和时间(可读)

  • Date.prototype.toString()

    Tue Oct 30 2001 17:43:07 GMT+0100 (CET)
    

日期和时间,以当前时区的时间。对于没有毫秒的任何 Date 实例(即秒数已满),以下表达式为真:

    Date.parse(d.toString()) === d.valueOf()
    ```

+   `Date.prototype.toLocaleString()`:

    ```js
    Tue Oct 30 17:43:07 2001
    ```

以区域特定格式的日期和时间。此方法由 ECMAScript 国际化 API 提供(请参见[ECMAScript 国际化 API](ch30.html#i18n_api "ECMAScript 国际化 API")),如果没有它,这个方法就没有太多意义。

+   `Date.prototype.toUTCString()`:

    ```js
    Tue, 30 Oct 2001 16:43:07 GMT
    ```

日期和时间,使用 UTC。

+   `Date.prototype.toGMTString()`:

已弃用;请改用`toUTCString()`。

日期和时间(机器可读)

+   `Date.prototype.toISOString()`:

    ```js
    2001-10-30T16:43:07.856Z
    ```

所有内部属性都显示在返回的字符串中。格式符合[日期时间格式](ch20.html#date_time_formats "日期时间格式");时区始终为`Z`。

+   `Date.prototype.toJSON()`:

此方法内部调用`toISOString()`。它被`JSON.stringify()`(参见[JSON.stringify(value, replacer?, space?)](ch22.html#JSON.stringify "JSON.stringify(value, replacer?, space?)"))用于将日期对象转换为 JSON 字符串。

## 日期时间格式

本节描述了以字符串形式表示时间点的格式。有许多方法可以这样做:仅指示日期,包括一天中的时间,省略时区,指定时区等。在支持日期时间格式方面,ECMAScript 5 紧密遵循标准 ISO 8601 扩展格式。JavaScript 引擎相对完全地实现了 ECMAScript 规范,但仍然存在一些变化,因此您必须保持警惕。

最长的日期时间格式是:

```js
YYYY-MM-DDTHH:mm:ss.sssZ

每个部分代表日期时间数据的几个十进制数字。例如,YYYY表示格式以四位数年份开头。以下各小节解释了每个部分的含义。这些格式与以下方法相关:

  • Date.parse() 可以解析这些格式。

  • new Date()可以解析这些格式。

  • Date.prototype.toISOString()创建了上述“完整”格式的字符串:

    > new Date().toISOString()
    '2014-09-12T23:05:07.414Z'
    

日期格式(无时间)

以下日期格式可用:

YYYY-MM-DD
YYYY-MM
YYYY

它们包括以下部分:

  • YYYY 指的是年份(公历)。

  • MM指的是月份,从 01 到 12。

  • DD 指的是日期,从 01 到 31。

例如:

> new Date('2001-02-22')
Date {Thu Feb 22 2001 01:00:00 GMT+0100 (CET)}

时间格式(无日期)

以下时间格式可用。如您所见,时区信息Z是可选的:

THH:mm:ss.sss
THH:mm:ss.sssZ

THH:mm:ss
THH:mm:ssZ

THH:mm
THH:mmZ

它们包括以下部分:

  • T是格式时间部分的前缀(字面上的T,而不是数字)。

  • HH指的是小时,从 00 到 23。您可以使用 24 作为HH的值(指的是第二天的 00 小时),但是接下来的所有部分都必须为 0。

  • mm 表示分钟,从 00 到 59。

  • ss 表示秒,从 00 到 59。

  • sss 表示毫秒,从 000 到 999。

  • Z指的是时区,以下两者之一:

  • Z”表示 UTC

  • +”或“-”后跟时间“hh:mm

一些 JavaScript 引擎允许您仅指定时间(其他需要日期):

> new Date('T13:17')
Date {Thu Jan 01 1970 13:17:00 GMT+0100 (CET)}

日期时间格式

日期格式和时间格式也可以结合使用。在日期时间格式中,您可以使用日期或日期和时间(或在某些引擎中仅使用时间)。例如:

> new Date('2001-02-22T13:17')
Date {Thu Feb 22 2001 13:17:00 GMT+0100 (CET)}

时间值:自 1970-01-01 以来的毫秒数

日期 API 称之为time的东西在 ECMAScript 规范中被称为时间值。它是一个原始数字,以自 1970 年 1 月 1 日 00:00:00 UTC 以来的毫秒数编码日期。每个日期对象都将其状态存储为时间值,在内部属性[[PrimitiveValue]]中(与包装构造函数BooleanNumberString的实例用于存储其包装的原始值的相同属性)。

警告

时间值中忽略了闰秒。

以下方法适用于时间值:

  • new Date(timeValue) 使用时间值创建日期。

  • Date.parse(dateTimeString)解析带有日期时间字符串的字符串并返回时间值。

  • Date.now()返回当前日期时间作为时间值。

  • Date.UTC(year, month, date?, hours?, minutes?, seconds?, milliseconds?) 解释参数相对于 UTC 并返回时间值。

  • Date.prototype.getTime() 返回接收器中存储的时间值。

  • Date.prototype.setTime(timeValue)根据指定的时间值更改日期。

  • Date.prototype.valueOf()返回接收者中存储的时间值。该方法确定了如何将日期转换为原始值,如下一小节所述。

JavaScript 整数的范围(53 位加上一个符号)足够大,可以表示从 1970 年前约 285,616 年开始到 1970 年后约 285,616 年结束的时间跨度。

以下是将日期转换为时间值的几个示例:

> new Date('1970-01-01').getTime()
0
> new Date('1970-01-02').getTime()
86400000
> new Date('1960-01-02').getTime()
-315532800000

Date构造函数使您能够将时间值转换为日期:

> new Date(0)
Date {Thu Jan 01 1970 01:00:00 GMT+0100 (CET)}
> new Date(24 * 60 * 60 * 1000)  // 1 day in ms
Date {Fri Jan 02 1970 01:00:00 GMT+0100 (CET)}
> new Date(-315532800000)
Date {Sat Jan 02 1960 01:00:00 GMT+0100 (CET)}

将日期转换为数字

通过Date.prototype.valueOf()将日期转换为数字,返回一个时间值。这使您能够比较日期:

> new Date('1980-05-21') > new Date('1980-05-20')
true

您也可以进行算术运算,但要注意闰秒被忽略:

> new Date('1980-05-21') - new Date('1980-05-20')
86400000

警告

使用加号运算符(+)将日期加到另一个日期或数字会得到一个字符串,因为将日期转换为原始值的默认方式是将日期转换为字符串(请参阅加号运算符(+)了解加号运算符的工作原理):

> new Date('2024-10-03') + 86400000
'Thu Oct 03 2024 02:00:00 GMT+0200 (CEST)86400000'
> new Date(Number(new Date('2024-10-03')) + 86400000)
Fri Oct 04 2024 02:00:00 GMT+0200 (CEST)