第二章:深入JavaScript

在上一章中,我介绍了编程的基本要素,如变量、循环、条件语句和函数等。当然,所有的代码也都是用JavaScript表示的。本章中,我们将主要关注一个JS开发者从入门到进阶需要了解的JavaScript的方方面面。

本章我我将介绍一些在以后的 YDKJS 书籍中才会详细讲解的概念。你可以把本章作为一个后续书籍深入讨论的主题的一个总览。

如果你是JavaScript的初学者,你应该多花些时间反复复习本章的概念和代码示例。任何伟大的建筑都是由一砖一瓦堆砌而成的,所以不要期望能够第一遍就能完全掌握所有的知识点。

现在让我们开始深入学习JavaScript之旅吧!

值&类型

我们在第一章中提到过,JavaScript的值是有类型的,而变量是没有类型的。JavaScript有下列的内置类型:

  • string
  • number
  • boolean
  • nullundefined
  • object
  • symbol (ES6中新引入)

JavaScript中可以用typeof操作符来查看某个值属于什么类型:

var a;
typeof a;                // "undefined"

a = "hello world";
typeof a;                // "string"

a = 42;
typeof a;                // "number"

a = true;
typeof a;                // "boolean"

a = null;
typeof a;                // "object" -- weird, bug

a = undefined;
typeof a;                // "undefined"

a = { b: "c" };
typeof a;                // "object"

typeof操作符的返回值永远是六种类型之一(ES6中是7种类型,包括'symbol'类型)的字符串值。例如,typeof "abc"返回"string",而不是string

注意上面的代码中变量a可以保存不同类型的值,typeof a不是获取变量a的类型,而是获取变量a中当前值的类型。JavaScript中只有值才有类型,而变量仅仅是存放这些值的容器。

typeof null是一个有意思的例子,因为它会错误地返回"object",而不是预料中的"null"。 注意: 这是JS的一个遗留bug,但是可能永远也不会修复。由于Web中有太多的代码依赖于这个bug,因此修正这个bug可能会导致更多的bug!

还要注意a = undefined。这里显示地给变量a赋值为undefined值,这在表现上与没有给变量a设置任何值的情况是一样的,如上面代码第一行的var a。有几种情况会使得变量的值为"undefined",包括没有返回值和使用了void操作符的函数。

对象

object类型表示一类可以设置属性(命名的位置符)的复合值,每个属性保存它们自己的任意类型的值。对象可能是JavaScirpt中最有用的值类型了。

var obj = {
    a: "hello world",
    b: 42,
    c: true
};

obj.a;        // "hello world"
obj.b;        // 42
obj.c;        // true

obj["a"];    // "hello world"
obj["b"];    // 42
obj["c"];    // true

将这个obj对象可视化为如下形式有助于理解:

a b c
"hello world" 42 true

可以用 点记法(即obj.a)或 括号记法(即obj['a'])来访问对象的属性。点记法更简单且易读,因此尽可能的使用点记法。 如果属性名中包含特殊字符,则应该使用括号记法,如obj["hello world!"]——通过括号记法访问的属性通常作为 键值[ ]中要么是一个变量(稍后解释),要么是一个 字面量字符串(包裹在".."'..'中)。 当然如果你想访问的键值对的名字保存在另一个变量中,也应该使用括号记法,如:

var obj = {
    a: "hello world",
    b: 42
};

var b = "a";

obj[b];            // "hello world"
obj["b"];        // 42

注: 查看更多有关JavaScirpt对象的知识点,参考 this&对象原型 一书,特别是第三章。

JavaScirpt中还有两种常见的值类型:数组函数。它们不是内置的值类型,而更像是子类型——特殊的object类型。

数组

数组是按数字索引顺序保存属性值的对象,如:

var arr = [
    "hello world",
    42,
    true
];

arr[0];            // "hello world"
arr[1];            // 42
arr[2];            // true
arr.length;        // 3

typeof arr;        // "object"

注: 编程语言从0开始计数,JS也不例外,将0作为数组第一个元素的索引。 arr的可视化形式如下:

0 1 2
"hello world" 42 true

由于数组是特殊的对象(如typeof的结果所示),因此也可以有属性,包括自动更新的length属性。

理论上通过自定义属性名,可以把数组当作正常的对象使用,也可以用object来模拟数组,只需将其属性用数字(0,1,2等)即可。当然这样的用法不利于区别不同的值类型。

最好且最自然的方式是用数组表示以数字做索引的值,而用object表示具有命名属性的值。

函数

JS程序中常见的另一个object子类型是函数:

function foo() {
    return 42;
}

foo.bar = "hello world";

typeof foo;            // "function"
typeof foo();        // "number"
typeof foo.bar;        // "string"

再次强调,函数是object的子类型——typeof返回"function",表明function是一个主要类型——因此可以有属性,但是一般只有在少数情况下才会用函数的对象属性(如foo.bar)。

注: 更多关于JS值及其类型的知识,参考 类型&语法 一书的第二章。

内置类型的方法

我们前面讨论的内置类型和子类型暴露了很多强大而实用的属性和方法。如:

var a = "hello world";
var b = 3.14159;

a.length;               // 11
a.toUpperCase();        // "HELLO WORLD"
b.toFixed(4);           // "3.1416"

这里a.toUpperCase()函数背后的调用机制比表面上看起来复杂的多。简单来说,与原始string类型对应地有一个“原生”String(大写字母S)的对象封装形式;正是在这个对象封装器的原型上定义了toUpperCase()方法。

当引用原始字符串值如"hello world"的属性或方法时(如a.toUpperCase()),JS自动将这个值“放入”对应的对象封装器中(表面上看不出区别)。

string值可以被String对象封装,number值可以被Number对象封装,而boolean值可以被Boolean对戏那个封装。在大多数情况下,我们直接使用值的对象封装形式就行——几乎任何时候原始值形式都用得更多,JavaScript会处理剩下的事情。

注: 更多关于JS原生和“封装”的知识,参考 类型&语法 一书的第三章。想深入理解对象原型的概念,参考 this&对象原型 一书的第五章。

比较值

在JS程序中主要需要做两种类型的值比较:等式不等式。不管进行何种值的比较,结果都是一个boolean值(true或false)。

强制转换

我们在第一章中简要讨论了强制转换,现在我们温习一下。

JavaScript中有两种形式的强制转换:显示隐式。从一种类型值转换为另一种类型值的代码中就会发生显示强制转换,而隐式转换在一些操作符不带来副作用的情况上才会发生。

你可能听说过“强制转换是恶魔”的论调,因为很多情况下强制转换会带来意想不到的结果。也许没有什么能比JS语言带给开发者更多挫败感了。

强制转换不是恶魔,也不一定不可控。实际上,在大多数情况下使用强制类型转换都是合理且可理解的,甚至可以有效地提高代码的可读性。这里我们不做详细讨论——类型&语法 一书的第四章会详细阐述。

下面是一个显示转换的例子:

var a = "42";

var b = Number( a );

a;              // "42"
b;              // 42 -- the number!

这是一个隐式转换的例子:

var a = "42";

var b = a * 1;  // "42" implicitly coerced to 42 here

a;              // "42"
b;              // 42 -- the number!

真值&假值

第一章中,我们简要提到了真值和假值的性质:当一个非boolean值强制转换为boolean值时,它的值是true还是false呢?

JavaScript中表示“假值”的有如下值:

  • ""(空字符串)
  • 0, -0, NaN(无效的number
  • null, undefined
  • false 不在“假值”列表中的其他任何值都表示“真值”,如:
  • "hello"
  • 42
  • true
  • [ ], [1,"2",3]
  • { }, {a:42}
  • function foo(){..} 需要谨记的是非boolean值强制转换为boolean值时只会做“真/假”转换。注意不要混淆了一个值看起来转换为boolean值,实际上并没有(转换为boolean值)的情况。

等式

有四个等式运算符:==,===,!=,!==。其中!表示对应的“不相等”操作符;不要混淆了 不相等不等式 的概念。

一般来说,==和===的区别在于:==仅检查两个值是否相等,而===既检查值是否相等又检查类型是否相同。然而,这种说法也不准确。更准确的说法应该是:==在允许强制类型转换的情况下来检查两个值是否相等,而===不允许强制转换来检查两个值是否相等;因此===常称作“严格相等”。

看一个==允许隐式强制转换而===不允许强制转换的例子:

var a = "42";
var b = 42;

a == b;         // true
a === b;        // false

a == b的等式中,JS发现两个值的类型不相同,所以它会经过一系列步骤将其中一个或两个值强制转换为不同的类型,使得两者的类型相同,然后再检查值是否相等。 读者会发现,经过类型转换后,a == b可能有两种相等的方式:一个是42 == 42,另一种是"42" == "42"。哪个是对的呢?

答案是:"42"变成42,因此等式变成了42 == 42。在简单的情况下,哪种比较方式其实没什么影响,因为结果是一样的。但是在更复杂的情况下,比较结果以及如何得到这个结果的过程都会对程序有影响。

a === b的结果是false,因为不允许强制转换,所以简单的值比较肯定不相等。很多开发者觉得===更可控,因此建议总是用===而抛弃==。我觉得这种观点很片面。我认为 如果花时间掌握==是怎样工作的,它将会是一个提升程序质量的强大工具。

这里我们不详细展开讨论==比较时强制转换是怎样工作的。相关的知识大部分都很好理解,但是也要注意一些重要的特殊情况。完整的转换规则请参考ES5规范的11.9.3节,你会发现与一片唱衰声相比,这其中的机制简单直接到让你大吃一惊。

这里把详细细节总结为几条简单的规则,以帮助你根据不同的情况选择用==还是===,规则如下:

  • 如果比较的两个值中有一个可能是true或false值,应用===,不用==。
  • 如果比较的两个值中有一个可能是这些特定值(0, ""或[]——空数组),应用===,不用==。
  • 在其他所有情况下,都可以放心的用==。用==不仅安全无害,而且在很多情况下都可以简化你的代码,从而提高程序的可读性。 列举的这些规则要求你认真思考你的代码,仔细考虑对变量进行比较时得到的具体是什么类型的值。如果你能够确定值的类型,用==就很安全,大胆的用吧!如果你不能确定值的类型,就用===。就这么简单。

不等于!=与==相反,而!==与===相反。我们刚刚讨论的规则和结论相应的也都适用于它们。

如果你对两个非原始值,如object(包括function和array)进行比较,需要特别注意==和===的比较规则。因为这些值实际上是引用值,==和===只会简单的检查这两个引用值是否相等,而不是底层的真实值。 例如,默认情况下array值的强制转换会用逗号(,)将数组内所有值拼接成string类型的值。你可能觉得两个内容相同的数组用==比较时结果应该是true,但实际上并不是:

var a = [1,2,3];
var b = [1,2,3];
var c = "1,2,3";

a == c;     // true
b == c;     // true
a == b;     // false

注: 更多有关==比较规则的知识,请参考ES5规范(11.9.3节)以及 类型&语法 一书的第四章,第二章有引用类型值的详细阐述。

不等式

操作符<,>,<=>=用于不等式比较,规范中也称为“关系比较”。通常它们用于有序的可比较的值,如number。不等式3 < 4是很容易理解的。

但是JavaScript的string类型的值根据一般的字母顺序("bar" < "foo")也可以用于不等式比较。

那么强制转换呢? 与==比较类似的规则(虽然不完全一致)也适用于不等式操作符。值得一提的是,不等比较没有像“严格相等”一样不允许强制转换的“严格不等”操作符。 如:

var a = 41;
var b = "42";
var c = "43";

a < b;      // true
b < c;      // true

这其中发生了什么? 如ES5规范11.8.5节所述,如果<比较的两个值都是string类型,如上的b < c,那么就进行字典化(也即按照字典的字母顺序)比较。但是如果有一个值不是或者两个值都不是string类型,如上的a < b,两个值都会强制转换为number类型,然后进行数字比较。

比较不同类型值时你可能遇到的最大的问题——记住,没有“严格不等”形式——是其中一个值不能转换为合法的number,如:

var a = 42;
var b = "foo";

a < b;      // false
a > b;      // false
a == b;     // false

等等,为什么这三个比较的结果都是false?因为在<和>比较中b值被转换为“无效的number值”NaN,规范明确指出NaN不能进行大于或小于比较。

==比较为false的原因有点不一样。前面我们讨论过,a==b转换为42 == NaN进行比较,因此结果也是false。

注: 更多有关不等式比较规则的知识,请参考ES5规范的第11.8.5节以及 类型&语法 一书的第四章。

变量

在JavaScript中,变量名(包括函数名)必须是合法的 标识符。如果考虑如Unicode等传统字符在内的话,标识符中关于合法字符的严格完整的规则就很复杂了。如果仅考虑普通的ASCII字母数字字符的话,这些规则就相当简单了。

标识符的首字符必须是a-z, A-Z,$或_,之后可以接任意字符,包括数字0-9。

一般来说,适用于属性名的规则也同样适用于变量标识符。然而,有些特殊单词不能作为变量名却可以作为属性名。这些单词称为“保留字”,也包括JS关键字在内(for, in, if, null, true, false等)。

函数作用域

如果用var关键字声明一个变量,那么这个变量属于当前函数的作用域,如果这个变量是声明在任何函数之外的最顶层,则属于全局作用域。

变量提升

无论var出现在作用域中的什么位置,该条语句声明的变量都属于整个作用域,且在整个作用域中都是可以被访问的。

由于var声明的变量在概念上被“移动”到其所在作用域的最上面,因此这种行为被形象地称为 提升。从技术上,代码的编译机制可以更准确地解释这个过程,这里我们先略过不做详细讨论。 考虑:

var a = 2;

foo();                  // works because `foo()`
                        // declaration is "hoisted"
function foo() {
    a = 3;
    console.log( a );   // 3
    var a;              // declaration is "hoisted"
                        // to the top of `foo()`
}

console.log( a );   // 2

注意: 依赖变量 提升 来在用var定义之前使用变量的方式既不常见也不是一个好的习惯;这可能会造成程序混乱。我们更多的是使用函数声明提升,上面的代码中我们在foo()函数的正式声明之前就调用了它。

嵌套作用域

当你声明了一个变量,它就可以在作用域中的任何位置被访问到,包括任何在更低级/次级的作用域内。例如:

function foo() {
    var a = 1;
    function bar() {
        var b = 2;
        function baz() {
            var c = 3;
            console.log( a, b, c ); // 1 2 3
        }
        baz();
        console.log( a, b );        // 1 2
    }
    bar();
    console.log( a );               // 1
}

foo();

注意在函数bar()内无法访问变量c,因为它是在内部的baz()函数内声明的,同样的,函数foo()内也无法访问变量b。

如果试着访问一个在该作用域不能被访问的变量,会抛出ReferenceError错误。如果试着对一个未声明的变量赋值,在“严格模式”下会报错,但是非严格模式下则会在全局作用域创建这个变量(不好!)。我们看下这个例子:

function foo() {
    a = 1;  // `a` not formally declared
}

foo();
a;          // 1 -- oops, auto global variable :(

这是一个典型的反面案例。千万不要这么做!记住永远正确地声明变量。

另外,ES6支持函数级的变量声明,用'let'关键字声明的变量只属于独立的代码块(花括号{..})内。除了一些细节不一样之外,这种作用域的规则基本上与函数中的表现一致:

function foo() {
    var a = 1;

    if (a >= 1) {
        let b = 2;

        while (b < 5) {
            let c = b * 2;
            b++;

            console.log( a + c );
        }
    }
}

foo();
// 5 7 9

由于使用let而不是var,变量b仅属于if语句而不属于整个foo()函数的作用域。同样的,c只属于while循环。块级作用域有助于更好更精细地管理变量作用域,从而简化代码的维护。

注: 更多关于作用域的知识,参考 作用域&闭包 一书。关于let块级作用域的知识参考 ES6&未来 一书。

条件语句

除了我们在第一章介绍的if语句之外,JavaScript还支持其他几种条件机制,我们后面再讨论。

有时候,你可能会写一大串的if...else...if语句,像这样:

if (a == 2) {
    // do something
}
else if (a == 10) {
    // do another thing
}
else if (a == 42) {
    // do yet another thing
}
else {
    // fallback to here
}

这种写法没什么问题,但是显得很冗余,因为每个子句都需要做一次条件判断。这种情况下,可以用switch语句:

switch (a) {
    case 2:
        // do something
        break;
    case 10:
        // do another thing
        break;
    case 42:
        // do yet another thing
        break;
    default:
        // fallback to here
}

如果希望每个子句中的语句只执行一次,则必须加上break。如果子句中没有break语句,当执行这个子句后,会继续执行下一个子句里面的语句,而不管下一个子句是否匹配条件判断语句。这种现象成为“通过”,有时候是很有用且希望出现的。

switch (a) {
    case 2:
    case 10:
        // some cool stuff
        break;
    case 42:
        // other stuff
        break;
    default:
        // fallback
}

这里,不管a等于2还是10,都会执行"some cool stuff"代码语句。

JavaScript中另一个条件语句是被称为“三元操作符”的“条件操作符”。它更像是单个if...else语句的简写,如:

var a = 42;

var b = (a > 41) ? "hello" : "world";

// 等同于:

// if (a > 41) {
//    b = "hello";
// }
// else {
//    b = "world";
// }

如果测试表达式(这里是a>41)结果为true,结果是第一个子句("hello"),否则结果就是第二个子句("world")。然后再将结果赋值给变量b。

条件操作符不一定要用于赋值语句,但这无疑是它最常见的用法。

严格模式

ES5中为JS加入了“严格模式”,收紧了某些特定行为的规则。一般来说,这些限制可以使代码更安全、更符合规范的要求。当然,遵循严格模式会让你的代码最大化利用引擎。严格模式对代码有重要意义,你应该在你的所有程序中使用它。

你可以选择对单个函数或整个文件使用严格模式,这取决于严格模式标识所在的位置:

function foo() {
    "use strict";

    // this code is strict mode

    function bar() {
        // this code is strict mode
    }
}
// this code is not strict mode

另外一种情况是:

 "use strict";

function foo() {
    // this code is strict mode

    function bar() {
        // this code is strict mode
    }
}
// this code is strict mode

严格模式的一个主要不同(改进!)是在忽略var时隐式地将变量声明为全局变量:

function foo() {
    "use strict";   // turn on strict mode
    a = 1;          // `var` missing, ReferenceError
}

foo();

如果代码中开启了严格模式,上面的代码会报错,你的程序会出现bug,导致你会想避免使用严格模式。但是纵容这种想法是不应该的。如果因为使用严格模式导致你的程序出现bug,几乎可以肯定是你的程序本身的问题,应及时修复。

严格模式不仅可以保证代码更安全、更优,而且代表了JS语言的未来发展方向。与其抛弃严格模式,现在就开始习惯严格模式将会更容易——以后再转换只会更加困难!

函数作为值

目前为止我们讨论的函数是JavaScript中作用域的主要机制。通常通过下面的语法来声明function

function foo() {
    // ...
}

从上面的语法中可能不是很明显,其实foo只是外层作用域中的一个变量,它是声明的function的一个引用。也就是说,这个function本身是一个像42或[1,2,3]一样的值。

这个概念可能一开始听起来比较奇怪,所以花点时间理解下吧。我们不仅可以给函数传递值(参数),而且 函数本身就可以作为一个值 赋给某个变量,传递给其他函数或其他函数返回。

因此,函数值应该被当作表达式,就像其他值或表达式一样。

例如:

var foo = function() {
    // ..
};

var x = function bar(){
    // ..
};

第一个函数表达式将一个 匿名函数 赋给foo变量。第二个 命名的(bar)函数表达式作为一个引用赋给变量x。一般更喜欢用 命名函数表达式,尽管 匿名函数表达式 也很常见。

更多的细节参考 作用域&闭包 一书。

立即执行函数表达式(IIFEs)

在上一个例子中,两个函数都没有被执行——我们可以通过foo()或x()来调用。

还有另一种方式来执行函数表达式,通常称为 立即执行函数表达式(IIFE):

(function IIFE(){
    console.log( "Hello!" );
})();
// "Hello!"

函数表达式(function IIFE(){ .. })外面的(..)是为了与正常的函数声明区分开来的JS语法。表达式最后的()表示立即执行它前面的函数表达式。

这种写法看起来很奇怪,但也没有第一眼看起来那么陌生。这里注意foo函数和IIFE的区别:

function foo() { .. }

// `foo` function reference expression,
// then `()` executes it
foo();

// `IIFE` function expression,
// then `()` executes it
(function IIFE(){ .. })();

可以发现,(function IIFE(){ .. })之后接()表示执行本质上与foo之后接()来执行是相同的;两种方式都是在函数引用之后接()来执行函数。

由于IIFE 也是函数,而函数会创建变量作用域,因此通常使用IIFE的方式来声明不会影响IIFE外面代码的变量:

var a = 42;

(function IIFE(){
    var a = 10;
    console.log( a );   // 10
})();

console.log( a );       // 42

IIFE也可以有返回值:

var x = (function IIFE(){
    return 42;
})();

x;  // 42

命名的IIFE函数被执行时返回42,然后赋给变量x。

闭包

闭包 是JavaScript中最重要也是最难理解的概念。我会在 作用域&闭包 书中详细阐述,这里不再赘述。但是我还是要简单介绍几点以便读者能有个基本概念。这会是你JS技能包里最重要的技术。

你可以把闭包想象成:函数结束运行之后,“保留”并继续访问函数作用域(它的变量)的一种方式。 例如:

function makeAdder(x) {
    // parameter `x` is an inner variable

    // inner function `add()` uses `x`, so
    // it has a "closure" over it
    function add(y) {
        return y + x;
    };

    return add;
}

每次调用外层函数makeAdder()都返回内部的add()函数的引用,因此传递给makeAdder()函数的变量x的值会被保留。现在,我们来使用makeAdder()函数:

// `plusOne` gets a reference to the inner `add(..)`
// function with closure over the `x` parameter of
// the outer `makeAdder(..)`
var plusOne = makeAdder( 1 );

// `plusTen` gets a reference to the inner `add(..)`
// function with closure over the `x` parameter of
// the outer `makeAdder(..)`
var plusTen = makeAdder( 10 );

plusOne( 3 );       // 4  <-- 1 + 3
plusOne( 41 );      // 42 <-- 1 + 41

plusTen( 13 );      // 23 <-- 10 + 13

这里解释一下上面的代码是如何运行的:

  1. 当执行makeAdder(1)时,返回内部函数add()的引用,该函数会保存变量x值为1。这个函数引用名字为plusOne()。
  2. 当执行makeAdder(10)时,返回内部函数add()的另一个引用,该函数会保存变量x值为10。这个函数引用名字为plusTen()。
  3. 当执行plusOne(3)时,将3(内部的y)加上1(x中保存的值),就得到结果4。
  4. 当执行plusTen(13)时,将13(内部的y)加上10(x中保存的值),就得到结果为23。 如果开始被这个绕晕了,别担心——这是正常的!需要多加练习才能完全理解闭包。

相信我,一旦掌握了闭包,它就是以后编程中最强大最有用的技术。绝对值得花精力让你的大脑熟悉闭包的概念。我们会在下一节中学习关于闭包的更多实践。

模块

JavaScript中闭包最常见的用法是模块模式。模块允许你定义对外部不可见的私有实现细节(变量、函数),外部通过公开的API来访问模块。 如:

function User(){
    var username, password;

    function doLogin(user,pw) {
        username = user;
        password = pw;

        // do the rest of the login work
    }

    var publicAPI = {
        login: doLogin
    };

    return publicAPI;
}

// create a `User` module instance
var fred = User();

fred.login( "fred", "12Battery34!" );

User()函数是一个保存了变量username和password及内部函数doLogin()的外部作用域;这些都是Usr模块私有的内部细节,在外部不能被访问到。

注: 这里我们有意不用new User(),虽然这种方式更常见。User()只是一个函数,而不是一个需要实例化的类,所以只需正常调用即可。这里不适合用new,实际上用了还会浪费资源。

执行User()会创建一个User()模块的 实例——创建一个完整的新作用域,也即内部每个变量/函数的完整新副本。然后把这个实例赋给fred变量。如果我们再次执行User(),我们会得到一个与fred完全独立的新实例。

内部的doLogin()函数对username和password有闭包引用,这意味着即使User()函数结束运行后,仍能访问到它们。

publicAPI是一个具有一个login属性/方法的对象,这个login是对内部的doLogin()函数的引用。当我们从User()返回publicAPI时,它实例fred。

此时,外层函数User()已经结束执行。按理说,内部变量username和password应该被销毁了。但是这里没有,因为login()函数内的闭包使它们得以保留。

这就是为什么我们调用fred.login(..)时——与调用内部doLogin()相同——仍能访问到内部变量username和password。

这个例子可以很好的帮助读者了解闭包和模块模式,可能还有一些不好理解,没关系,你的大脑需要花点时间来接受它!

阅读 作用域&闭包 一书进行更深入的探索吧。

this关键字

JavaScript中另一个容易被误解的概念是this关键字。

尽管this可能通常与“面向对象模式”相关,但是JS中的this具有不同的机制。

如果一个函数内部有this引用,那么这个this引用实际上指向一个对象,而具体指向哪个对象取决于这个函数被调用的方式。

需要记住的是这个this不是指向函数本身,而这是最常见的误解。

这里有一个快速的说明:

function foo() {
    console.log( this.bar );
}

var bar = "global";

var obj1 = {
    bar: "obj1",
    foo: foo
};

var obj2 = {
    bar: "obj2"
};

// --------

foo();              // "global"
obj1.foo();         // "obj1"
foo.call( obj2 );   // "obj2"
new foo();          // undefined

下面四条规则解释了如何确定this的指向,正好对应上面代码段中的最后四行。

  1. 在非严格模式下,foo()最后的this指向全局对象——而严格模式下,this是undefined,访问bar属性是会报错——所以this.bar的值为"global"。
  2. obj1.foo()中的this指向对象obj1。
  3. foo.call(obj2)中的this指向对象obj2。
  4. new foo()中的this指向一个全新的空对象。 结论:要理解this实际指向什么,需要弄清楚this所在函数是被怎么样调用的。调用方式是上面四种方式之一,然后就可以确定this的具体指向了。

注: 更多关于this的知识,参考 this&对象原型 一书的第一章和第二章。

原型

JavaScript中的原型机制相当复杂。我们这里只是简单了解下。你需要花大量时间阅读 this&对象原型 一书的第4-6章来学习详细知识。

当引用对象上的某个属性时,如果这个属性不存在,JavaScript会根据这个对象内部的原型引用自动到另一个对象上去查找这个属性。可以认为这是属性不存在时的一个回溯。

对象内部的原型引用是在这个对象被创建时建立的与它的回溯的连接。JS内置的方法Object.create(..)阐明了这个过程。 考虑:

var foo = {
    a: 42
};

// create `bar` and link it to `foo`
var bar = Object.create( foo );

bar.b = "hello world";

bar.b;      // "hello world"
bar.a;      // 42 <-- delegated to `foo`

下面的图表示了foo与bar之间的关系:

实际上bar对象上不存在属性a,但是因为bar与foo之间有原型链,JavaScript会自动回溯到对象foo上寻找属性a。

这种链接似乎是一个很奇怪的特性。这个特性用的最多的场景是——我会说,滥用——用于模拟/伪造具有“继承”特性的“类”机制。

另一种应用原型的更自然的方式是“行为委托”模式,在这种模式中,需要特意设计连接对象来从一个对象委托部分需要的行为到另一个对象。

注: 更多关于原型和行为委托的知识,参考 this&对象原型 一书的第4-6章。

旧&新

我们已经了解了一些,当然后面我们会了解更多,新引入的JS特性,这些特性在老旧浏览器中不一定被支持。实际上,规范中的一些最新的特性即使是在最稳定的浏览器中都没有被支持。

所以,我们该怎么处理新特性?我们就干等几年或几十年,等着所有的老旧浏览器退出历史舞台吗?很多人确实是这么对待这个问题的,但是这真的不是一个对JS有益的方法。

目前主要有两种技术将较新的JavaScript特性“带入”老旧浏览器中:polyfilling和转译(transpiling)

Polyfilling

术语"polyfill"是Remy Sharp发明的,表示根据较新特性的定义写一段可以在较老的JS环境中运行的代码来实现相同的行为。

例如,ES6定义了一个叫做Number.isNaN(..)的方法,用来准确无误地检查NaN值,而摒弃了原来的isNaN(..)方法。但是这个方法很容易被polyfill,因此你可以在你的代码中使用这个方法,而不用管用户的浏览器是否支持ES6. 例如:

if (!Number.isNaN) {
    Number.isNaN = function isNaN(x) {
        return x !== x;
    };
}

if语句表示在支持ES6的浏览器中不应用polyfill。如果这个方法还不存在,就定义这个Number.isNaN(..)方法。

注: 这里我们做的检查利用了NaN值的怪癖:NaN是JS中唯一不等于本身的值。所以NaN值是唯一使得 x != x结果为true的值。

不是所有的新特性都可以被polyfill。虽然大部分行为都可以被polyfill,但是会有一些偏差。因此你自己在实现polyfill的时候,应该要十分小心,尽可能确保严格遵守规范。

最好是使用已经被检验过的可信的polyfill,如ES5-ShimES6-Shim

转译

如果JS中新增的语法不能被polyfill,那么在老旧JS引擎中会因为不能被识别/非法而抛出错误。

因此通过工具将较新的代码转换为等价的较老的代码是更好的选择。这个过程通常称为术语“转译”,表示转换+编译。

本质上,你的代码是用新语法格式编写的,但是部署到浏览器中的转译后的旧语法格式的代码。一般把转译器放在构建过程中,跟代码检测和压缩类似。

你可能会想为什么要费劲用新语法格式编写代码然后又转译为旧语法格式,为什么不直接按旧代码格式编写呢?

有几个重要的原因可以回答这个问题:

  • JS中新增的语法被设计出来是为了提高程序的可读性和可维护性。其相应的旧语法通常更复杂。为了自己也为了团队中的其他成员,都应该使用更新更简洁的语法。
  • 如果转译仅是为了兼容旧浏览器,而对新浏览器使用新语法,那么新语法就可以充分利用浏览器的性能优化。这也可以帮助浏览器生产商用实际的代码来测试浏览器的功能和性能优化。
  • 更早使用新语法可以根据实际情况来测试新语法的稳定性,这也更早地提供反馈给JavaScript委员会(TC39)。如果能尽早发现问题,就可以在这些语言设计的错误变成遗留问题之前改变/修复它们。

这里有一个转译的简单例子,ES6新增了“默认参数值”的特性,它的用法如下:

function foo(a = 2) {
    console.log( a );
}

foo();      // 2
foo( 42 );  // 42

很简单,对吧?也很有用!但是这个新语法在ES6之前的引擎中是非法的。那么转译器怎样将这段代码转译成可以在旧环境中运行的代码呢?

function foo() {
    var a = arguments[0] !== (void 0) ? arguments[0] : 2;
    console.log( a );
}

如你所见,先检查arguments[0]的值是否是void 0(也即undefined),如果是,则提供默认值2;如果不是,则将传入的值赋给a。

除了可以在旧浏览器中可以使用更简洁的语法之外,转译后的代码实际上可以更好地解释程序的逻辑。

仅从ES6的这个例子中你可能不会发现,undefined是唯一一个不能作为默认参数显示传递的值,但是经转译的代码可以使这个问题看得更清晰。

关于转译器最后一个需要强调的重要的细节是应该将转译作为JS开发生态系统和开发过程中的标准部分。JS是不断发展的,而且比以前快得多,因此每隔几个月就有新增一些新特性和新语法。如果默认使用转译器的话,就可以在新语法可用时随时可以用它,而不是等几年之后不支持的浏览器被淘汰之后再使用。

现在已有一些优秀的转译器可以选择:

  • Babel:将ES6转译成ES5
  • Traceur:将ES6/ES7及更高的版本转译成ES5

非-JavaScript

到目前为止,我们了解都是JS语言本身。现实中,大多数JS都是运行在浏览器环境并与浏览器进行交互。严格来说,你在代码中写的很酷炫的东西并不是直接受JavaScript控制。这可能听起来有点奇怪。

你将遇到的最常见的非-JavaScript的JavaScript是DOM API。例如:

var el = document.getElementById( "foo" );

变量document是你的代码在浏览器中运行时才存在的一个全局变量。它不是由JS引擎提供,也不在JavaScript规范的范围内。它的形式比正常JS对象复杂的多,但它也不完全是一个对象,它是一个特殊的对象,称为“宿主对象”。

另外,document上的getElementById(..)方法看起来像一个普通的JS函数,实际上它是浏览器DOM提供的内置方法的对外接口。在有些浏览器(新一代)中,这一层的函数可能也包括在JS中,但是一般DOM及其方法用C/C++来实现。

另一个非-JavaScript的例子是输入/输出(I/O)。

大家都喜欢用alert(..)在用户浏览器中弹出消息框。alert(..)是由浏览器提供给JS程序的,而不是JS引擎提供的。调用这个函数时,将消息传递给浏览器内核,然后内核来处理并显示消息框。

类似的函数还有console.log(..);浏览器提供这些接口,并作为开发者工具钩子传递信息。

本书及本系列书只讨论JavaScript语言。因此读者不会看到对这些非-JavaScript的JavaScript机制的讨论。无论如何,你需要了解它们,因为它们会出现在你写的每个JS程序中!

复习

学习JavaScript风格编程的第一步就是基本理解JavaScript的核心机制,如值、类型、函数闭包、this和原型。

当然,这些主题都需要花比这里多得多的笔墨来阐述,因此本系列剩下的部分都有相应的章节和书来阐述它们。当你熟悉了本章中的这些概念和代码示例之后,就可以真正深入学习后续的部分并完全掌握这门语言。

本书的最后一章将简要总结本系列书籍每一本的主要内容以及除我们以及提及的概念之外的其他概念。

results matching ""

    No results matching ""