你不懂JS:类型与文法

 主页   资讯   文章   代码   电子书 

你不懂JS:类型与文法

第一章:类型

大多数开发者会说,动态语言(就像 JS)没有 类型。让我们看看 ES5.1 语言规范(http://www.ecma-international.org/ecma-262/5.1/)在这个问题上是怎么说的:

在本语言规范中的算法所操作的每一个值都有一种关联的类型。可能的值的类型就是那些在本条款中定义的类型。类型还进一步被分为 ECMAScript 语言类型和语言规范类型

一个 ECMAScript 语言类型对应于 ECMAScript 程序员使用 ECMAScript 语言直接操作的值。ECMAScript 语言类型有 Undefined,Null,Boolean,String,Number,和 Object。

现在,如果你是一个强类型(静态类型的)语言的爱好者,你可能会反对“类型”一词的用法。在那些语言中,“类型”的含义要比它在 JS 这里的含义丰富得

有些人说 JS 不应该声称拥有“类型”,它们应被称为“标签”或者“子类型”。

去他的!我们将使用这个粗糙的定义(看起来和语言规范的定义相同,只是改变了措辞):一个 类型 是一组固有的,内建的性质,对于引擎 和开发者 来说,它独一无二地标识了一个特定的值的行为,并将它与其他值区分开。

换句话说,如果引擎和开发者看待值 42(数字)与看待值 "42"(字符串)的方式不同,那么这两个值就拥有不同的 类型 -- 分别是 numberstring。当你使用 42 时,你就在 试图 做一些数字的事情,比如计算。但当你使用 "42" 时,你就在 试图 做一些字符串的事情,比如输出到页面上,等等。这两个值有着不同的类型。

这绝不是一个完美的定义。但是对于这里的讨论足够好了。而且它与 JS 描述它的方式并不矛盾。

类型的重要意义

抛开学术上关于定义的分歧,为什么 JavaScript 有或者没有 类型 那么重要?

对每一种 类型 和它的固有行为有一个正确的理解,对于理解如何正确和准确地转换两个不同类型的值来说是绝对必要的(参见第四章,强制转换)。几乎每一个被编写过的 JS 程序都需要以某种形式处理类型的强制转换,所以,你能负责任、有信心地这么做是很重要的。

如果你有一个 number42,但你想像一个 string 那样对待它,比如从位置 1 中将 "2" 作为一个字符抽取出来,那么显然你需要首先将值从 number(强制)转换成一个 string

这看起来十分简单。

但是这样的强制转换可能以许多不同的方式发生。其中有些方式是明确的,很容易推理的,和可靠的。但是如果你不小心,强制转换就可能以非常奇怪的,令人吃惊的方式发生。

对强制转换的困惑可能是 JavaScript 开发者所经历的最深刻的挫败感之一。它曾经总是因为如此 危险 而为人所诟病,被认为是一个语言设计上的缺陷而应当被回避。

带着对 JavaScript 类型的全面理解,我们将要阐明为什么强制转换的 坏名声 是言过其实的,而且是有些冤枉的 -- 以此来反转你的视角,来看清强制转换的力量和用处。但首先,我们必须更好地把握值与类型。

内建类型

JavaScript 定义了七种内建类型:

  • null
  • undefined
  • boolean
  • number
  • string
  • object
  • symbol -- 在 ES6 中被加入的!

注意: 除了 object 所有这些类型都被称为“基本类型(primitives)”。

typeof 操作符可以检测给定值的类型,而且总是返回七种字符串值中的一种 -- 令人吃惊的是,对于我们刚刚列出的七中内建类型,它没有一个恰好的一对一匹配。

typeof undefined     === "undefined"; // true
typeof true          === "boolean";   // true
typeof 42            === "number";    // true
typeof "42"          === "string";    // true
typeof { life: 42 }  === "object";    // true

// 在 ES6 中被加入的!
typeof Symbol()      === "symbol";    // true

如上所示,这六种列出来的类型拥有相应类型的值,并返回一个与类型名称相同的字符串值。Symbol 是 ES6 的新数据类型,我们将在第三章中讨论它。

正如你可能已经注意到的,我在上面的列表中剔除了 null。它是 特殊的 -- 特殊在它与 typeof 操作符组合时是有 bug 的。

typeof null === "object"; // true

要是它返回 "null" 就好了(而且是正确的!),但是这个原有的 bug 已经存在了近二十年,而且好像永远也不会被修复了,因为有太多已经存在的 web 的内容依存着这个 bug 的行为,“修复”这个 bug 将会 制造 更多的“bug”并毁掉许多 web 软件。

如果你想要使用 null 类型来测试 null 值,你需要一个复合条件:

var a = null;

(!a && typeof a === "object"); // true

null 是唯一一个“falsy”(也叫类 false;见第四章),但是在 typeof 检查中返回 "object" 的基本类型。

那么 typeof 可以返回的第七种字符串值是什么?

typeof function a(){ /* .. */ } === "function"; // true

很容易认为在 JS 中 function 是一种顶层的内建类型,特别是看到 typeof 操作符的这种行为时。然而,如果你阅读语言规范,你会看到它实际上是对象(object)的“子类型”。特别地,一个函数(function)被称为“可调用对象” —— 一个拥有 [[Call]] 内部属性、允许被调用的对象。

函数实际上是对象这一事实十分有用。最重要的是,它们可以拥有属性。例如:

function a(b,c) {
    /* .. */
}

这个函数对象拥有一个 length 属性,它被设置为函数被声明时的形式参数的数量。

a.length; // 2

因为你使用了两个正式命名的参数(bc)声明了函数,所以“函数的长度”是 2

那么数组呢?它们是 JS 原生的,所以它们是一个特殊的类型咯?

typeof [1,2,3] === "object"; // true

不,它们仅仅是对象。考虑它们的最恰当的方法是,也将它们认为是对象的“子类型”(见第三章),带有被数字索引的附加性质(与仅仅使用字符串键的普通对象相反),并维护着一个自动更新的 .length 属性。

值作为类型

在 JavaScript 中,变量没有类型 -- 值才有类型。变量可以在任何时候,持有任何值。

另一种考虑 JS 类型的方式是,JS 没有“类型强制”,也就是引擎不坚持认为一个 变量 总是持有与它开始存在时相同的 初始类型 的值。在一个赋值语句中,一个变量可以持有一个 string,而在下一个赋值语句中持有一个 number,如此类推。

42 有固有的类型 number,而且它的 类型 是不能被改变的。另一个值,比如 string 类型的 "42",可以通过一个称为 强制转换 的处理从 number 类型的值 42 中创建出来(见第四章)。

如果你对一个变量使用 typeof,它不会像表面上看起来那样询问“这个变量的类型是什么?”,因为 JS 变量是没有类型的。取而代之的是,它会询问“在这个变量里的值的类型是什么?”

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

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

typeof 操作符总是返回字符串。所以:

typeof typeof 42; // "string"

第一个 typeof 42 返回 "number",而 typeof "number""string"

undefined vs "undeclared"

当前 还不拥有值的变量,实际上拥有 undefined 值。对这样的变量调用 typeof 将会返回 "undefined"

var a;

typeof a; // "undefined"

var b = 42;
var c;

// 稍后
b = c;

typeof b; // "undefined"
typeof c; // "undefined"

大多数开发者考虑“undefined”这个词的方式会诱使他们认为它是“undeclared(未声明)”的同义词。然而在 JS 中,这两个概念十分不同。

一个“undefined”变量是在可访问的作用域中已经被声明过的,但是在 这个时刻 它里面没有任何值。相比之下,一个“undeclared”变量是在可访问的作用域中还没有被正式声明的。

考虑这段代码:

var a;

a; // undefined
b; // ReferenceError: b is not defined

一个恼人的困惑是浏览器给这种情形分配的错误消息。正如你所看到的,这个消息是“b is not defined”,这当然很容易而且很合理地使人将它与“b is undefined.”搞混。需要重申的是,“undefined”和“is not defined”是非常不同的东西。要是浏览器能告诉我们类似于“b is not found”或者“b is not declared”之类的东西就好了,那会减少这种困惑!

还有一种 typeof 与未声明变量关联的特殊行为,进一步增强了这种困惑。考虑这段代码:

var a;

typeof a; // "undefined"

typeof b; // "undefined"

typeof 操作符甚至为“undeclared”(或“not defined”)变量返回 "undefined"。要注意的是,当我们执行 typeof b 时,即使 b 是一个未声明变量,也不会有错误被抛出。这是 typeof 的一种特殊的安全防卫行为。

和上面类似地,要是 typeof 与未声明变量一起使用时返回“undeclared”就好了,而不是将其结果值与不同的“undefined”情况混为一谈。

typeof Undeclared

不管怎样,当在浏览器中处理 JavaScript 时这种安全防卫是一种有用的特性,因为浏览器中多个脚本文件会将变量加载到共享的全局名称空间。

注意: 许多开发者相信,在全局名称空间中绝不应该有任何变量,而且所有东西应当被包含在模块和私有/隔离的名称空间中。这在理论上很伟大但在实践中几乎是不可能的;但它仍然是一个值得的努力方向!幸运的是,ES6 为模块加入了头等支持,这终于使这一理论变得可行的多了。

作为一个简单的例子,想象在你的程序中有一个“调试模式”,它是通过一个称为 DEBUG 的全局变量(标志)来控制的。在实施类似于在控制台上输出一条日志消息这样的调试任务之前,你想要检查这个变量是否被声明了。一个顶层的全局 var DEBUG = true 声明只包含在一个“debug.js”文件中,这个文件仅在你开发/测试时才被加载到浏览器中,而在生产环境中则不会。

然而,在你其他的程序代码中,你不得不小心你是如何检查这个全局的 DEBUG 变量的,这样你才不会抛出一个 ReferenceError。这种情况下 typeof 上的安全防卫就是我们的朋友。

// 噢,这将抛出一个错误!
if (DEBUG) {
    console.log( "Debugging is starting" );
}

// 这是一个安全的存在性检查
if (typeof DEBUG !== "undefined") {
    console.log( "Debugging is starting" );
}

即便你不是在对付用户定义的变量(比如 DEBUG),这种检查也是很有用的。如果你为一个内建的 API 做特性检查,你也会发现不抛出错误的检查很有帮助:

if (typeof atob === "undefined") {
    atob = function() { /*..*/ };
}

注意: 如果你在为一个还不存在的特性定义一个“填补”,你可能想要避免使用 var 来声明 atob。如果你在 if 语句内部声明 var atob,即使这个 if 条件没有通过(因为全局的 atob 已经存在),这个声明也会被提升(参见本系列的 作用域与闭包)到作用域的顶端。在某些浏览器中,对一些特殊类型的内建全局变量(常被称为“宿主对象”),这种重复声明也许会抛出错误。忽略 var 可以防止这种提升声明。

另一种不带有 typeof 的安全防卫特性,而对全局变量进行这些检查的方法是,将所有的全局变量作为全局对象的属性来观察,在浏览器中这个全局对象基本上是 window 对象。所以,上面的检查可以(十分安全地)这样做:

if (window.DEBUG) {
    // ..
}

if (!window.atob) {
    // ..
}

和引用未声明变量不同的是,在你试着访问一个不存在的对象属性时(即便是在全局的 window 对象上),不会有 ReferenceError 被抛出。

另一方面,一些开发者偏好避免手动使用 window 引用全局变量,特别是当你的代码需要运行在多种 JS 环境中时(例如不仅是在浏览器中,还在服务器端的 node.js 中),全局变量可能不总是称为 window

技术上讲,这种 typeof 上的安全防卫即使在你不使用全局变量时也很有用,虽然这些情况不那么常见,而且一些开发者也许发现这种设计方式不那么理想。想象一个你想要其他人复制-粘贴到他们程序中或模块中的工具函数,在它里面你想要检查包含它的程序是否已经定义了一个特定的变量(以便于你可以使用它):

function doSomethingCool() {
    var helper =
        (typeof FeatureXYZ !== "undefined") ?
        FeatureXYZ :
        function() { /*.. 默认的特性 ..*/ };

    var val = helper();
    // ..
}

doSomethingCool() 对称为 FeatureXYZ 变量进行检查,如果找到,就使用它,如果没找到,使用它自己的。现在,如果某个人在他的模块/程序中引入了这个工具,它会安全地检查我们是否已经定义了 FeatureXYZ

// 一个 IIFE(参见本系列的 *作用域与闭包* 中的“立即被调用的函数表达式”)
(function(){
    function FeatureXYZ() { /*.. my XYZ feature ..*/ }

    // 引入 `doSomethingCool(..)`
    function doSomethingCool() {
        var helper =
            (typeof FeatureXYZ !== "undefined") ?
            FeatureXYZ :
            function() { /*.. 默认的特性 ..*/ };

        var val = helper();
        // ..
    }

    doSomethingCool();
})();

这里,FeatureXYZ 根本不是一个全局变量,但我们仍然使用 typeof 的安全防卫来使检查变得安全。而且重要的是,我们在这里 没有 可以用于检查的对象(就像我们使用 window.___ 对全局变量做的那样),所以 typeof 十分有帮助。

另一些开发者偏好一种称为“依赖注入”的设计模式,与 doSomethingCool() 隐含地检查 FeatureXYZ 是否在它外部/周围被定义过不同的是,它需要依赖明确地传递进来,就像这样:

function doSomethingCool(FeatureXYZ) {
    var helper = FeatureXYZ ||
        function() { /*.. 默认的特性 ..*/ };

    var val = helper();
    // ..
}

在设计这样的功能时有许多选择。这些模式里没有“正确”或“错误” -- 每种方式都有各种权衡。但总的来说,typeof 的未声明安全防卫给了我们更多选项,这还是很不错的。

复习

JavaScript 有七种内建 类型nullundefinedbooleannumberstringobjectsymbol。它们可以被 typeof 操作符识别。

变量没有类型,但是值有类型。这些类型定义了值的固有行为。

许多开发者会认为“undefined”和“undeclared”大体上是同一个东西,但是在 JavaScript 中,它们是十分不同的。undefined 是一个可以由被声明的变量持有的值。“未声明”意味着一个变量从来没有被声明过。

JavaScript 很不幸地将这两个词在某种程度上混为了一谈,不仅体现在它的错误消息上(“ReferenceError: a is not defined”),也体现在 typeof 的返回值上:对于两者它都返回 "undefined"

然而,当对一个未声明的变量使用 typeof 时,typeof 上的安全防卫机制(防止一个错误)可以在特定的情况下非常有用。