JavaScript 1.0 与 1.1

Netscape 和 Sun 于 1995 年 12 月 4 日在联合新闻稿 [Netscape and Sun 1995; Appendix F] 中发布了 JavaScript。通稿中 JavaScript 被描述为「一种对象脚本语言」,可用于编写脚本来动态地「修改 Java 对象的属性和行为」。它将作为「Java 的补充,方便进行在线应用开发」。尽管它们的技术设计只有表面上的相似,两家公司还是试图在 Java 和 JavaScript 语言间建立牢固的品牌联系。这种名称上的相似性及其带来的两种语言具备密切联系的暗示,长期以来都是导致混乱的根源之一。

JavaScript 1.0 与 1.1 - 图1

图 2. Mocha 控制台。Brendan Eich 的 Mocha 初始 Demo,其所演示的功能是在 SGI Unix 工作站的 Netscape 2 pre-alpha 中运行的「Mocha 控制台」。这个 Mocha 控制台除了名称改变之外,基本按原样发布在了 Netscape 2 正式版中。这是在 Windows 95 上运行的 Netscape 2.02 的屏幕截图。可以通过在浏览器地址栏中键入 mocha: 来激活这个 Mocha 控制台——正式版 Netscape 2 已将其更改为 javascript:,但 mocha: 仍然有效。激活控制台后,浏览器会打开两个页面框架。在下部文本框中键入的 Mocha 表达式,其求值运行后的效果会体现在上方页面中。这一示例展示了调用内置 alert 函数来获得表达式计算值的弹出窗口。原始演示版本的弹出窗口显示的是「Mocha Alert」,而不是 「JavaScript Alert」。

以「LiveScript」名称发布的 JavaScript,最初于 1995 年 9 月在 Netscape Navigator 2.0 的第一个 beta 版本 [Netscape 1995b] 中公开。该版本后还有四个 beta 版本,然后才是 1996 年 3 月发布的 Navigator 2.0 正式版。这个正式版支持了 JavaScript 1.0。而 Netscape Enterprise Server 2.0 也在 3 月发布 [Netscape 1996f],将 JavaScript 1.0 集成到了其 LiveWire 服务端的脚本组件中。

JavaScript 只是 Netscape Navigator 中一个相对较小的功能,因此其开发受到了 Navigator 2.0 整体规划的约束。该计划要求在 1995 年 8 月冻结特性。JavaScript 1.0 的特性集,实际上是划出了当年 8 月 Mocha 实现里正在开发或即将开发的特性。尽管 Eich 在整个 Navigator 2.0 发布历程中都在继续修复最初 Mocha 实现中的 bug,但相对于设想中的语言设计而言,这一特性集并不完整,仍然存在各种疑难 bug 和边界条件下的特殊行为。Brendan Eich 在 1.0 发行前不久接受了采访 [Shah 1996],他回应了 JavaScript 作为 Java 附属品的官方定位,以及初始发布版本的仓促性:

BE(Brendan Eich):我希望它(JavaScript)可以由其他厂商基于我和 Bill Joy 正在起草的规范来实现。我希望看到它保持小巧,但能在 Web 上随处可见,成为把对 HTML 元素的操作与 Java applet 等其他组件粘合在一起时的首选方式。

BE:……据我所知,最常见的用途是使页面更智能,更生动。比如可以根据一天中的时间,在单击链接时加载不同的 URLg

……

BE:隧道的尽头是光明的。现在 JavaScript 的单人秀成分还太重,2.0(Netscape Navigator 版本,译者注)会包含许多烦人的小 bug。我希望所有重大错误都有解决方法,我也已经花了很多时间与开发者一起寻找 bug 及其解法。

我将继续通过修正错误、添加新特性,并尝试使 JavaScript 在所有平台上保持一致的方式,来完成 2.1 版本。我不知道 2.1 版本具体何时交付,但可以保证它会在明年秋天前发布——我们这里前进得很快。

JavaScript 1.0 [Netscape 1996d] 是一种简单的动态类型g语言,它支持数字、字符串与布尔值、一等公民函数,以及对象数据类型。从语法上看,JavaScript 与 Java 一样属于 C 家族,其控制流语句借鉴了 C,其表达式语法也包括了大多数 C 的数字运算符。JavaScript 1.0 有一个小的内置函数库,其源码通常直接嵌入 HTML 文件中,但其内置库包含一个 eval 函数,可以解析并求值编码到字符串中的 JavaScript 源码。整个 JavaScript 1.0 是一门非常精简的语言。图 3 总结了一些缺失的特性。对于现代 JavaScript 程序员而言,这些特性的遗漏可能令人惊讶。

独立的 Array 对象类型Array 字面量
正则表达式对象字面量
undefined 的全局绑定=== 运算符
typeof, void, delete 运算符in, instanceof 运算符
do-while 语句switch 语句
try-catch-finally 语句break/continue 到标签
嵌套函数声明函数表达式
函数的 callapply 方法函数的 prototype 属性
基于原型的继承对内置原型对象的访问
循环垃圾回收gHTML <script> 标签的 src 属性

图 3. JavaScript 1.0 中未涉及的 JavaScript 常用特性(约 2010 年时)。

1996 年初,代号「Atlas」的 Netscape Navigator 3.0 开发工作启动 [Netscape 1996g],并于 1996 年 8 月发布。Brendan Eich 在此期间得以继续开发那些当 1995 年 8 月的 2.0 版本特性冻结时,还不够完整或缺失的特性。直到 Navigator 3.0 中发布 JavaScript 1.1 [Netscape 1996a, e] 时,JavaScript 的初始定义和开发才算完成。以下各节概述了 JavaScript 1.0/1.1 语言的设计。

JavaScript 语法

JavaScript 1.0 的语法直接以 C 语言 [ANSI X3 1989] 为基础,有一些地方受到了 AWKg 语言 [Aho et al. 1988] 的启发。一个脚本(script)就是一系列的语句(statement)和声明(declaration)。与 C 不同的是,JavaScript 的语句并不限于在函数体内出现。在 JavaScript 1.0 中,脚本源码嵌入在由 <script></script> 标签包围的 HTML 文档中。

JavaScript 1.0 中受 C 启发的语句包括:表达式语句;if 条件语句;forwhile 循环语句;非顺序控制流的 breakcontinuereturn 语句;以及语句块(支持使用由 {} 分隔的语句序列,就像使用单条语句一样)。ifforwhile 语句都是复合语句14。JavaScript 1.0 并未包含 C 的 do-while 语句,switch 语句,语句标签与 goto 语句。

在基本的 C 语句全家桶基础上,JavaScript 1.0 添加了两个复合语句,用于访问其对象数据类型的属性。受 AWK 启发的 for-in 语句可以遍历对象的属性键g。而在 with 语句15的语句体内,可以把某个对象的属性名称当作变量来访问。由于属性可能被动态添加(在更高版本的语言中还可以被删除),因此可见变量的绑定g可能会随 with 语句体中的执行过程而发生变化。

JavaScript 中的声明(declaration)并未遵循 C 或 Java 的风格。JavaScript 是动态类型的,没有语言层面的类型名称作为识别声明的语法前缀。相反地,JavaScript 的声明使用关键字作为前缀。JavaScript 1.0 有两种形式的声明,即 function 声明和 var 声明。function 声明16的语法是直接从 AWK 借鉴的,定义了单个可调用函数的名称、形参和语句主体。var 声明可以引入一个或多个变量绑定,并能选择性地为变量赋值。所有的 var 声明都被视为语句,并可在任何语句上下文中出现,包括语句块中。在 JavaScript 1.0/1.1 中,函数声明则只能在脚本的顶层出现,并且不支持嵌套。var 声明也可以出现在函数体内。由这类声明定义的变量,属于函数的局部变量。

与 C 不同的是,JavaScript 1.0 的语句块并未引入声明作用域的概念。在函数体内的语句块中,var 声明对这整个函数体均局部可见。位于函数外部块中的 var 声明则具备全局作用域g。如果向作用域内不存在 functionvar 声明的变量名赋值,则会隐式创建具有该名称的全局变量。事实证明这种行为是导致错误的重要原因,因为如果拼写错了已声明的变量,也会静默地创建名称错误的新变量。

JavaScript 与传统 C 语法还有一个重要区别,那就是它对语句末尾分号的处理。C 将分号视为强制性的语句终止符,而 JavaScript 则允许在分号是行中最后一个有效字符时,省略这个用于终止语句的分号。这种行为的确切规则并未包含在 JavaScript 1.0 文档中。《Netscape 2.0 手册》在描述各种 JavaScript 语句形式时也并未展示分号,它只说明「一条语句可能跨越多行。如果每条语句之间用分号分隔,则可能在一行上出现多条语句 [Netscape 1996d]」。手册的 JavaScript 代码示例使用了无分号的编码风格,如下所示:

  1. var a, x, y
  2. var r = 10
  3. with (Math) {
  4. a = PI * r * r
  5. x = r * cos(PI)
  6. y = r * sin(PI / 2)
  7. }

这种不使用分号就可以编写 JavaScript 代码的特性,称为自动分号插入(ASI)。ASI 在 JavaScript 程序员间仍然存在争议。相当一部分程序员仍然更喜欢以无分号风格编码,而其他人则从不使用 ASI。

数据类型与表达式

JavaScript 1.0/1.1 是一种动态类型语言,具有五种基本数据类型:数字、字符串、布尔值、对象和函数。这里的「动态类型」意味着运行时类型信息与每条数据相关联,而不是与诸如变量之类的「值的容器」相关联。运行时类型检查可确保操作仅应用于各操作所支持的数据值上。

布尔值、字符串和数字是不可变(immutable)的值。布尔类型具有两个值,分别为 truefalse。字符串值由 8 位字符编码的不可变序列组成,没有 Unicode 支持。数字类型由所有可能的 IEEE 754 [IEEE 2008] 双精度二进制 64 位浮点值组成,不同之处在于仅暴露了一个规范(canonical)的 NaN 值。某些运算会特殊处理与「无符号 32 位整数」和「有符号 32 位二进制补码整数」相对应的数字值。Mocha 内部使用了此类整数值的替代表示形式,但只有一个正式的数字数据类型。

JavaScript 1.0 有两个特殊值,用于表示「缺少有用的数据值」。未初始化的变量会被设置为特殊值 undefined17。这也是程序在尝试访问对象中尚不存在的属性时所返回的值。在 JavaScript 1.0 中,可以通过声明和访问未初始化变量的方式,获取到 undefined 这个值。而值 null 则旨在表示某个预期存在对象值的上下文里「没有对象」。它是根据 Java 的 null 值建模的,有助于将 JavaScript 与 Java 实现的对象进行集成。在整个历史上,同时存在这样两个相似但又有显著不同的值导致了 JavaScript 程序员的困惑,很多人不确定应在何时使用哪个。

JavaScript 1.0 的表达式语法基本上复制自 C,使用了一组相同的运算符(operator)与优先级规则。这里主要省略的部分是 C 的指针和与类型相关的运算符,以及一元的 + 运算符。二元的 + 运算符被重载,以执行数字加法与字符串连接。移位和按位逻辑运算符可以对有符号的 32 位二进制补码整数进行位级的操作。如有必要,操作数将被截断为整数,并取模减少到 32 位的值。>> 运算符可以对 32 位整数值执行符号扩展的算术右移。JavaScript 还添加了从 Java 借鉴的 >>> 运算符,用于执行无符号的右移运算。

JavaScript 1.1 添加了 deletetypeofvoid 运算符。在 JavaScript 1.1 中,delete 运算符仅会将其对应的变量或对象属性操作数设为 null 值。typeof 运算符会返回一个字符串,该字符串标识其操作数的原始类型。可能的字符串值包括 "undefined""object""function""boolean""string""number",或一个由实现环境决定的字符串值,以此来标示宿主对象的种类。令人困惑的是,typeof null 会返回字符串值 "object" 而不是 "null"。其实也可以说这与 Java 保持了一致,因为 Java 的所有值都是对象,而 null 本质上是表达「没有对象」的对象。但是,Java 缺少与 typeof 运算符等效的特性,并使用 null 作为未初始化变量的默认值。根据 Brendan Eich 的回忆,typeof null 的值是原始 Mocha 实现中抽象泄漏g的结果。null 的运行时值使用了与对象值相同的内部标记值进行编码,因此 typeof 运算符的实现就直接返回了 "object",而无需任何额外的特殊处理。实践表明,这种选择对 JavaScript 程序员带来了很大的麻烦。他们通常想在尝试访问某个值的属性之前,先测试这个值是否确实是一个对象。但光是测试值的类型是否为 "object" 并不足以保护属性访问,因为尝试访问 null 的属性也会产生运行时错误。

void 运算符仅求值其操作数,然后返回 undefined。访问 undefined 的一种常见手法是 void 0。引入 void 运算符是为了作为辅助,以便定义那些会在单击时执行 JavaScript 代码的 HTML 超链接。例如:

  1. <a href="javascript:void usefulFunction()">
  2. Click to do something useful
  3. </a>

这里 href 属性g的值应为一个 URL,而 javascript: 是浏览器可识别的特殊 URL 协议。这意味着要对后面的 JavaScript 代码求值,并使用将其转换为字符串的结果,就像使用由常规 href URL 获取的响应文档那样。除非获得 undefined,否则 <a> 元素将尝试继续处理该响应文档。通常 Web 开发者想要的只是在单击链接时对 JavaScript 表达式求值而已。给表达式加上前缀 void 即可允许以这种方式使用该表达式,避免 <a> 元素的进一步处理。

C 和 JavaScript 表达式之间的最大区别,是 JavaScript 运算符会自动将其操作数隐式转换为运算符领域内的数据类型。JavaScript 1.1 添加了一种可配置的机制,用于将任意对象转换为数字或字符串值。图 4 总结了 JavaScript 1.1 的隐式类型转换(coercion)规则。

From - Tofunctionobjectnumberbooleanstring
undefinederrornullerrorfalse“undefined”
functionN/CFunction objectvalueOf/errorvalueOf/truedecompile
object (not null)Function objectN/CvalueOf/errorvalueOf/truetoString/valueOf1
object (null)errorN/C0false“null”
number (zero)errornullN/Cfalse“0”
number (nonzero)errorNumberN/Ctruedefault
number (NaN)errorNumberN/Cfalse2“NaN”
number (+Infinity)errorNumberN/Ctrue“+Infinity”
number (-Infinity)errorNumberN/Ctrue“-Infinity”
boolean (false)errorBoolean0N/C“false”
boolean (true)errorBoolean1N/C“true”
string (empty)errorStringerror3falseN/C
string (non-empty)errorStringnumber/errortrueN/C
  • 若结果以斜杠分隔,表示 JavaScript 会先尝试前者,若未成功则使用后者。
  • N/C 表示不需转换(No Conversion Necessary)。
  • decompile 表示一份包含函数独有源码的字符串。
  • toString 表示调用 toString 方法的结果。
  • valueOf 表示在 valueOf 方法能为目标类型返回值时,对其进行调用的结果。
  • number 表示在字符串为有效整数或浮点数字面量时,其相应的数值。
  • 1 如果 valueOf 没有返回字符串,则进行默认的对象到字符串转换。
  • 2 在 Navigator 3.0 所用的 JavaScript 1.1 中,会将 NaN 转换为 true。
  • 3 在 Navigator 3.0 所用的 JavaScript 1.1 中,会将空字符串转换为 0。

图 4. Eich 和 McKinney 在 JavaScript 1.1 初始规范中提出的隐式类型转换规则 [1996, page 23],最终标准化的规则与此略有不同。这是对原始表格的复制,存在一些排版上的细微差别。脚注 3 并未出现在原文中。

对象

JavaScript 1.0 的对象是关联数组,其元素称为属性。每个属性都有一个字符串键和一个值,该值可以是任何 JavaScript 数据类型。属性可以被动态添加。JavaScript 1.0/1.1 不支持从对象中删除属性。

只要某个属性的键字符串符合标识符的语法规则,就可以用形如 obj.prop0 的点符号(dot notation)来访问它。所有属性都可以使用方括号表示法(bracket notation)来访问,包括那些键不符合标识符规则的属性。其中用方括号括起来的表达式将被求值,并转换为用作属性键的字符串。例如当 n 的值为 0 时,obj["prop" + n] 等效于 obj.prop0。赋值给不存在的属性会创建一个新属性,访问不存在的属性通常会返回 undefined。但是在 JavaScript 1.0/1.1 中,如果使用方括号表示法访问不存在的属性值,并且属性键是非负整数的字符串表示形式,则会返回 null 值。

属性既可以用作数据存储,也可以将行为与对象关联。那些值为函数的属性,可以作为对象的方法被调用。而作为对象方法被调用的函数,则可以通过关键字 this 的动态绑定来访问该对象。

要想创建对象,可以将 new 运算符应用于内置函数或用户自定义的函数。那些意图以这种方式被使用的函数,则称为构造函数(constructor)。构造函数通常会将属性添加到新对象。这些属性既可以是数据,也可以是方法。内置的构造函数 Object 可以用于创建最初没有属性的新对象。图 5 展示了如何使用 Object 构造函数或用户定义的构造函数,来创建新对象。

  1. // 使用 Object 构造函数
  2. var p1 = new Object;
  3. p1.x = 0;
  4. p2.y = 0;
  5. // 使用自定义的构造函数
  6. function Point(x, y) {
  7. this.x = x;
  8. this.y = y;
  9. }
  10. var p2 = new Point(0, 0);

图 5. JavaScript 1.0 中创建对象的可选方式。属性既可以在对象被 Object 创建之后添加,也可以通过自定义构造函数在创建对象时添加。

JavaScript 1.0 还有一个内置的 Array 构造函数,但使用 ObjectArray 构造函数所创建的对象只有一个可见的区别,那就是为该对象显示的调试字符串(形如 "[object Object]" 之类,译者注)。在 JavaScript 1.0 中,Array 构造函数创建的对象没有 length 属性。

通过将整数值作为键来创建属性的方式,可以对任何对象实现类似数组的索引行为。这样的对象还可以带有非整数键对应的属性:

  1. var a = new Object; // 或者 new Array
  2. a[0] = "zero";
  3. a[1] = "one";
  4. a[2] = "two";
  5. a.length = 3;

JavaScript 1.0 中没有对象继承g的概念。程序必须分别将所有属性添加到每个新对象上,这通常是通过为程序所使用的每个「类对象」(class object)定义一个构造函数的方式来实现的。图 6 展示了基于 JavaScript 1.0 定义的简单 Point 抽象。

  1. // 定义出作为方法被使用的函数
  2. function ptSum(pt2) {
  3. return new Point(this.x + pt2.x, this.y + pt2.y);
  4. }
  5. function ptDistance(pt2) {
  6. return Math.sqrt(Math.pow(pt2.x - this.x, 2) + Math.pow(pt2.y - this.y, 2));
  7. }
  8. // 定义 Point 构造函数
  9. function Point(x, y) {
  10. // 创建并初始化新对象的数据属性
  11. this.x = x;
  12. this.y = y;
  13. // 为每个对象实例添加方法
  14. this.sum = ptSum;
  15. this.distance = ptDistance;
  16. }
  17. var origin = new Point(0, 0); // 创建 Point 对象

图 6. 使用 JavaScript 1.0 定义的 Point 抽象,每个实例对象具备自己的方法属性。

在这个示例中值得注意的重要之处如下:

  • 每个方法都必须定义为全局可见的函数。这类函数的名称是必需的,而且其名称不应与用于定义其他「类抽象」(class-like abstraction)方法函数的名称冲突(ptSumptDistance)。
  • 构造对象时,必须为每个方法创建一个对象属性,并将其值初始化为相应的全局函数。
  • 方法是通过属性名称(origin.distance)而非声明的全局名称(ptDistance)被调用的。

JavaScript 1.1 不再需要直接在每个新实例上创建方法属性。它通过函数对象名为 prototype 的属性,将原型g对象与构造函数关联起来。《JavaScript 1.1 指南》[Netscape 1996e] 将 prototype 描述为「由所有该类型对象共享的属性」。这是个模糊的描述,更好的表述可能是这样的:原型是一种特殊的对象,其自身属性与所有「由构造函数创建的对象」所共享。

对这种共享机制没有更进一步的说明,但可以发现原型对象具备如下特征:

  • 访问对象属性时,如果这个属性的名称在「与对象构造函数相关联的原型」上已被定义,那么将返回原型对象的属性值。
  • 对原型对象属性的添加或修改,对于通过「与原型相关联的构造函数」创建的现有对象,是立即可见的。
  • 为对象属性赋值时,会遮盖g18在「与对象构造函数相关联的原型」上定义的同名属性值。

对于语言内置的 Object.prototype 对象,其所有属性都可以通过对任何对象的属性访问来获取到,除非该属性已被对象或其原型遮盖。

图 7 展示了 JavaScript 1.1 中对图 6 简单 Point 抽象的定义。

  1. // 定义出作为方法被使用的函数
  2. function ptSum(pt2) {
  3. return new Point(this.x + pt2.x, this.y + pt2.y);
  4. }
  5. function ptDistance(pt2) {
  6. return Math.sqrt(Math.pow(pt2.x - this.x, 2) + Math.pow(pt2.y - this.y, 2));
  7. }
  8. // 定义 Point 构造函数
  9. function Point(x, y) {
  10. // 创建并初始化新对象的数据属性
  11. this.x = x;
  12. this.y = y;
  13. }
  14. // 添加方法到共享的原型对象
  15. Point.prototype.sum = ptSum;
  16. Point.prototype.distance = ptDistance;
  17. var origin = new Point(0, 0); // 创建 Point 对象

图 7. 使用 JavaScript 1.1 定义的 Point 抽象。实例对象从 Point.ptototype 对象上继承方法,而不是在每个实例上定义方法属性。

这里的不同之处在于,方法仅在原型对象上挂载了一次,而不是在构造每个实例对象时重复挂载。由原型对象提供给某个对象的属性称为继承属性g,而直接在对象上定义的属性则称为自有属性g。自有属性会遮盖同名的继承属性。

原型对象的属性通常是方法。在这种情况下,构造函数提供的原型发挥的是与 C++ 中的虚函数表(vtable)或 Smalltalk 中的 MethodDictionary 相同的作用,也就是将通用的行为与一组对象相关联。构造函数实际上充当的是类对象(class object)的角色,其原型相当于与类实例共享方法的容器。这是一种对 JavaScript 1.1 对象模型的合理解释,当然也不是唯一的解释。

对构造函数原型属性的命名,清楚地表明 Brendan Eich 考虑了另一种对象模型。该模型的灵感来自于 Self 编程语言 [Ungar and Smith 1987]。在 Self 中,新对象是通过「部分克隆某些种类的原型对象」的方式来创建的。每个克隆体都有一个指回其原型的 parent 链接,这样原型就可以提供能在其所有克隆体之间共享的功能了。JavaScript 1.1 的对象模型可以看作是 Self 模型的一种变体。在原型中,原型对象可以通过构造函数被间接访问到,而 new 运算符将从原型中克隆出新实例。这些克隆出的实例,会继承g那些在原型对象属性上通用共享的功能。一些 JavaScript 程序员将此机制称为「原型继承g」。这是一种委托机制的形式。一些 JavaScript 程序员还使用带引号的「类式继承g」概念,来指代 Java 和许多其他面向对象语言中使用的继承风格。

JavaScript 1.1 的文档 [Netscape 1996e] 并未完全描述这两个对象模型。它维护的是一个与 1995 年 12 月 Netscape / Sun 新闻稿一致的营销故事。JavaScript 被定位为一种用于「脚本式编写对象交互」的语言,而对象抽象的实际定义(类定义)将用 Java 编写。此时原生 JavaScript 的对象抽象能力尚且限于次要特性。这些次要特性仅引起了微小的关注,有很多并未被文档化。

函数对象

在 JavaScript 1.0/1.1 中,函数定义(function definition)会创建并命名一个可调用的函数。JavaScript 函数是一等(first-class)的对象值。在 function 声明中提供的名称会被定义为全局变量,类似于顶层代码中的 var 声明。而它的值则是函数对象,可以赋值给变量、设置为属性值、在函数调用中作为参数传递,以及作为函数的返回值。因为函数也是对象,所以在它们上面同样可以定义属性。以下示例展示了如何将属性添加到函数对象上:

  1. function countedHello() {
  2. alert("Hello , World!");
  3. countedHello.callCount++; // 增加该函数的 callCount 属性
  4. }
  5. countedHello.callCount = 0; // 将计数器与函数相关联
  6. for (var i = 0; i < 5; i++) countedHello();
  7. alert(countedHello.callCount); // 显示 5

函数需要用形式参数列表(formal parameter list)来声明。但参数列表的大小,并不会限制调用函数时可传递的参数数量。如果调用函数时传递的实参(实际参数,argument)数量少于其声明的形参(形式参数,parameter)数量,那么多余的形参将被设置为 undefined。而如果传递的实参数量超过形参数量,则会对额外的实参求值,但无法通过形参名称获得这些值。不过在执行函数体期间,还可以使用类似数组的实参对象(arguments object)作为函数对象 arguments 属性的值。调用函数时传递的所有实参,都可以用作 arguments 对象的整数键(integer-keyed)属性。这样一来,就可以支持可变长度参数列表的函数了。

内置库

JavaScript 1.0 附带了具备内置函数、对象和构造函数的库(library)。在这个库定义的通用对象19和函数之中,有少量属于通用,而有大量则是宿主特定(host-specific)的。在 Netscape Navigator 中,宿主对象g提供的模型表达了当前 HTML 文档的一部分。这些 API 最终被称为级别 0 的文档对象模型(DOM)[Koch 2003; Netscape 1996b]。而对于 Netscape Enterprise Server,宿主对象支持客户端与服务端之间的通信,管理客户端与服务端之间的会话(session)状态,以及对文件与数据库的访问。这种服务端宿主对象的设计,并没有在 Netscape 服务器产品以外的地方被采用。

JavaScript 的早期设计,很大程度上受到了浏览器平台需求的驱动。在早期 JavaScript 版本对应的 Netscape 文档中,并没有明确区分库中的元素是意图「独立于宿主环境」还是「依赖宿主」。不过,DOM 和其他浏览器平台 API 的设计、演变和标准化,已经足够构成它们自己的重要故事了。本文仅在与 JavaScript 的总体设计相关时,才会提及与浏览器相关的问题。

JavaScript 1.0 仅具有两个通用的对象类,即 StringDate。此外还有一个单例全局对象 Math,其属性是常用的数学常量和函数。

在 JavaScript 1.0 程序中,对于某些不活跃或实现得不完整的类,也可以看到它们的构造函数,前提是程序知道该如何访问它们。

JavaScript 1.1 完成了这些特性的实现,并文档化记录了它们的存在。图 8 总结了 JavaScript 1.0 和 1.1 中定义的那些与宿主无关的类。

基础对象属性
1.01.11.01.1 新增
(global functions)eval, isNaN1, parseFloat2, parseInt2
Array3Arrayjoin, reverse, sort, toString
Boolean3BooleantoString
DategetDate, getDay, getHours, getMinutes, getMonth, getSeconds, getTime, getTimezoneOffset, getYear, setDate, setHours, setMinutes, setMonth, setSeconds, setTime, setYear, toGMTString, toLocaleString, Date.parse, Date.UTCtoString
(function objects)arguments, length, caller
Function3Functionprototype, toString
MathE, LN2, LN10, LOG2E, LOG10E, PI, SQRT1_2, SQRT2, abs, acos, asin, atan, ceil, cos, exp, floor, log, max, min, pow, random1, round, sin, sqrt, tan
Objectconstructor, eval, toString, valueOf
Number3NumbertoString, Number.NaN, Number.MAX_VALUE, Number.MIN_VALUE, Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY
(string values)length
StringcharAt4, indexOf, lastIndexOf, split3, substring, toLowerCase, toUpperCase, (plus 13 HTML wrapper methods)split, toString, valueOf
  • 1 在 1.0 中仅于 Unix 平台可用。
  • 2 在 1.0 中的行为,视宿主操作系统不同而不同。
  • 3 在 1.0 中存在,但缺乏实用性或 bug 较多。
  • 4 在 1.0 中这些方法是字符串值的属性。在 1.1 中它们是 String.prototype 的属性。

图 8. JavaScript 1.0/1.1 中宿主独立的内置库。

String 类提供了 length 属性和 6 个对不可变字符串值进行操作的通用方法,它们会在适当的时候返回新的字符串值。JavaScript 1.0 的 String 类还包括 13 种方法,用于使用各种 HTML 标签来包装字符串值。这个例子说明了 JavaScript 1.0/1.1 中「与宿主相关的特性」和「通用特性」之间的模糊界限。JavaScript 1.0 没有提供全局 String 构造函数,所有字符串值都是使用字符串字面量、运算符或内置函数创建的。JavaScript 1.1 添加了全局 String 构造函数和 split 方法。

Date 类用于表示日历日期和时间。JavaScript 1.0 的 Date 是直接按照 Java 1.0 [Gosling et al. 1996] 中的 java.util.Date 类而实现的,连 bug 都保持了一致。这里包括了一些编码细节,如使用以 GMT 时间 1970 年 1 月 1 日 00:00:00 为中心的毫秒级分辨率时间值,在外部以 0-11 编号的月份,以及 Java 设计中存在的 2000 年歧义。这个设计决策的理由,是与 Java 互操作性方面的需求。唯一被排除的 Java 方法是 equalbeforeafter。这里并没有使用它们的必要,因为 JavaScript 具备隐式类型转换(automatic coercion)转换能力,可以将数字关系运算符直接与 Date 对象一起使用。

除了 Object 之外,Date 是 JavaScript 1.0 中唯一可用的内置构造函数。另外除了类的实例方法之外,Date 也是唯一在构造函数对象上暴露方法的类。那些浏览器特定(broswer-specific)的类则都没有暴露出构造函数。

对内置库和宿主提供的对象而言,它们的属性具有一些特殊的性质。这些性质是那些由 JavaScript 程序员自定义的属性所不具备的。比如,有的方法属性不会被 for-in 语句枚举,而某些属性会被 delete 运算符忽略,或具有只读的值。访问或修改某些这样的属性时,会产生具有可见副作用的特殊行为。

JavaScript 1.1 加入了可用的 Array 类。由 Array 构造函数创建的对象,可以用于表示由整数索引且起点为零的多个异类(heterogeneous)向量。数组元素作为对象属性表示,它们的键是其整数下标的字符串表示形式。数组对象还具有 length 属性,这一属性的值由构造函数初始化设置。每当访问大于或等于当前 length 值的元素索引时,就会更新 length 属性的值。因此,数组对象的元素数量可以动态增长。

执行模型

在 Netscape 2 和后续的浏览器中,HTML 网页都可能包含多个 <script> 元素。加载页面后,浏览器将为 HTML 文档创建一个新的 JavaScript 执行环境和全局上下文。全局上下文包括了全局对象,这个对象的属性键涵盖了(由 JavaScript 内置库与宿主环境所提供的)内置函数与变量的名称,以及脚本中定义的全局变量和函数。

在 Netscape 2 中,每个 <script> 元素里的 JavaScript 代码都会按照它们在页面 HTML 文件中的出现顺序,逐个解析和求值。在后来的浏览器中,还可以标记 <script> 元素以支持延迟求值(deferred evaluation)。这使得浏览器可以在等待从网络上请求 JavaScript 代码的同时,继续处理 HTML。但不论在哪种情况下,浏览器一次都只会求值一个脚本。脚本之间通常共享同一个全局对象。由脚本创建的全局变量和函数,对所有后续脚本均可见。每个脚本都会运行到完成(run to completion),而不会被抢占(preëmption)或中断(interruption)。早期浏览器的这一特性已成为 JavaScript 的一条基本原理。脚本是执行的基本单位。每个脚本的执行一旦开始,就会持续到它完成为止。在脚本内部,不必担心其他脚本的并发执行,因为这种情况不会发生。

Netscape 2 还引入了网页框架(Web page frame)的概念20。页框(frame)是网页的一个区域,可以在其中载入单独的 HTML 文档。页面上的所有页框都会共享相同的 JavaScript 执行环境,每个页框在这一环境中都具有单独的全局上下文。在不同页框中加载的脚本对应不同的全局对象、不同的内置对象,以及不同的全局变量与函数。不过,全局上下文并没有独立的地址空间。JavaScript 执行环境对应单个用于存储对象的地址空间(address space),这一空间会在环境内的所有页框之间共享。由于所有对象都在同一个地址空间中,对象的引用可能经由不同页框内的 JavaScript 代码互相传递,从而混杂来自不同全局上下文的对象。这可能会导致让人意想不到的行为。图 9 中的 JavaScript 1.1 示例说明了这一点。

  1. // 只要在其他页框内求值 new Object()
  2. // 就会让 alien 变量引用到在那里创建的对象
  3. var alien = createNewObjectInADifferentFrame();
  4. var native = new Object(); // 在当前页框创建对象
  5. Object.prototype.sharedProperty = "each frame has distinct built-ins";
  6. alert(native.sharedProperty); // each frame has distinct built-ins
  7. alert(alien.sharedProperty); // undefined

图 9. JavaScript 1.1 示例,表明即便不同 HTML 页框的内置对象不同,对象也可以互通。

每个页框都有独立的 Object 构造函数和 Object.prototype。它们所提供的属性,由该构造函数创建的所有对象所继承。向某个页框的 Object.prototype 添加属性,不会使该属性对其他页框内由 Object 构造函数创建的对象可见。

交互式的 JavaScript 网页是事件驱动的应用。其中的事件循环(event loop)由浏览器实现。HyperCard [Apple Computer 1988] 启发了 Brendan Eich 在最初的 Netscape 2 DOM [Netscape 1996c] 设计中使用事件的概念。最初,事件主要是由用户交互触发的。但在现代浏览器中事件有很多种,其中只有一些是源自用户的。

执行完网页定义的所有脚本后,页面的 JavaScript 环境将保持活跃状态,等待事件发生。事件处理器可以与浏览器提供的对象相关联,这包括了许多 DOM 对象。一个事件处理器也就是一个 JavaScript 函数,能响应事件的发生而被调用。将函数赋值给浏览器对象的某些特定属性,就能使该函数成为与这一属性相关联的事件处理器。例如与可点击的指点设备(鼠标)相对应的对象,就具备可设置的 onclick 属性。也可以使用一段 JavaScript 代码,直接在 HTML 元素中定义 JavaScript 事件处理器。例如:

  1. <button onclick="doSomethingWhenClicked()">
  2. Click me
  3. </button>

处理完 HTML 元素后,浏览器将创建一个 JavaScript 函数,并将其赋为按钮对象 onclick 属性的值。onclick 的代码片段会被用作函数体。当被 JavaScript 事件处理器监听的事件发生时,它将被放入未决(pending)事件池中。一旦没有正在执行的 JavaScript 代码,浏览器就会从事件池中获取一个未决事件,并调用与其关联的函数。和脚本一样,事件处理器函数也是运行到完成为止的。

迷惑行为与 Bug

JavaScript 有一些令人感到奇特或意外的特性。它们之中有些是故意为之,有些则是在最初的 Mocha 10 天冲刺期间做出的快速设计决策的产物。JavaScript 1.0 也有 bug 和未完成的半成品特性。

冗余声明

JavaScript 允许作用域内存在多个具有相同名称的声明。函数内部声明的所有同名变量名称,都会对应到同一个变量绑定。这个绑定在整个函数体中都是可见的。例如以下就是个有效的函数定义:

  1. function f(x, x) { // x 对应第二个形参,忽略第一个 x
  2. var x; // 和第二个形参相同的绑定
  3. for (var x in obj) { // 和第二个形参相同的绑定
  4. var x = 1, x = 2; // 和第二个形参相同的绑定
  5. }
  6. var x = 3; // 和第二个形参相同的绑定
  7. }

函数 f 中所有的 var 声明都会指向相同的变量绑定,也就是函数第二个形参的绑定。在函数的形参列表中,同一名称可以多次出现。在执行函数体之前,由 var 声明定义的变量都会初始化为 undefined,但名称与形参名相同的 var 变量则不在此列。在这种情况下,变量初始值会与「为同名形参传递的实参」相同。var 声明的初始化过程(包括冗余声明在内)与「为初始化后的变量赋值」的语义相同。它们在函数体内按正常执行顺序,依次在(初始化阶段)到达时执行。

脚本中可能有多个具有相同名称的 function 声明。在发生这种情况时,具有该名称的最后一个函数声明将被提升(hoist)到脚本顶部,并用这个名称初始化全局变量。所有其他同名的 function 声明都将被忽略。如果同时存在相同名称的全局 function 声明和全局 var 声明,它们都会指向相同的变量。在执行流程中遇到初始化器(即字面量)时,所有带初始化器的 var 声明都会覆盖函数值。

隐式类型转换与 == 运算符

隐式类型转换旨在降低最初采用 JavaScript 作为简单脚本语言的入门障碍。但随着 JavaScript 逐渐演变为通用语言,事实证明它是导致混淆和编码错误的重要来源,对 == 运算符来说尤其如此。在最初的 10 天冲刺之后,添加到 Mocha 中的一些有问题的转换规则,原本是为了响应 alpha 用户的请求,以简化 JavaScript 同 HTTP / HTML 的集成。例如,Netscape 的内部用户要求使用 == 来比较包含字符串值 "404" 的 HTTP 状态码与数字 404。他们还要求在数字上下文中将空字符串自动转换为 0,从而为 HTML 表单的空字段提供默认值。这些类型转换规则带来了一些意外,例如 1 == '1'1 == '1.0',但 '1' != '1.0'

JavaScript 1.0 还会在 if 语句的断言内,将 = 运算符视为 ==。例如:

  1. // JavaScript 1.0-1.2
  2. if (a = 0) alert("true"); // 这两条语句是等价的
  3. if (a == 0) alert("true");

32 位算术

JavaScript 的按位逻辑运算符,会对编码为 IEEE double 浮点数的 32 位值进行运算。按位运算符首先将整数截断,然后在执行按位运算前为其操作数做模转换,获得 32 位二进制补码值。因此,可以通过表达式 x|0,将数字值 x 强制转换为 32 位值,其中 | 是按位逻辑或运算符。基于这种手法,我们就能按以下步骤执行 32 位的带符号加法:

  1. function int32bitAdd(x, y) {
  2. return ((x | 0) + (y | 0)) | 0; // 将结果 32 位截断的加法
  3. }

可以使用类似的模式来执行无符号 32 位算术运算,但这时应使用无符号右移运算符 >>>0 来代替 |0

this 关键字

每个函数都有一个隐式的 this 形参。将函数作为方法调用时,这个参数会被设置为用于访问该方法的对象。这和大多数面向对象语言中的 this(或 self)含义相同。但是 JavaScript 在「关联到对象的方法」与「独立函数」这两者之间,使用了单一的定义形式。这使 this 导致了许多程序员的困惑和 bug。

当直接调用函数而未为其限定(qualify)对象时,this 将被隐式设置为全局对象。而全局对象的属性包括了程序的所有全局变量。因此在直接调用函数时,this 所限定的属性引用,等价于对全局变量的引用。因为对 this 的处理取决于函数的调用方式,所以相同的 this 引用在不同的调用场景下,可能具有不同的含义。例如:

  1. function setX(value) {
  2. this.x = value;
  3. }
  4. var obj = new Object;
  5. obj.setX = setX; // 将 setX 作为 obj 的方法
  6. obj.setX(42); // 将 setX 作为方法调用
  7. alert(obj.x); // 显示 42
  8. setX(84); // 直接调用 setX
  9. alert(x); // 获取全局变量 x,显示 84
  10. alert(obj.x); // 显示 42

由于某些 HTML 会将 JavaScript 代码段隐式转换成作为方法调用的函数,因此 this 引起了进一步的混乱。例如:

  1. <button name="B" onclick="alert(this.name + " clicked")>
  2. Click me
  3. </button>

当执行事件处理器时,它将触发按钮的 onclick 方法。这时 this 指向按钮对象,然后 this.name 会检索其 name 属性的值。

Arguments 对象

函数的 arguments 对象与它的形参联系在一起——在 arguments 对象的数字索引属性与函数的形参之间,存在着动态的映射。对 arguments 对象属性的更改,也会更改相应形参的值。并且可以发现对形参的更改,也会对相应的 arguments 对象属性生效:

  1. // JavaScript 1.0-1.1
  2. f(1, 2);
  3. function f(argA, argB) {
  4. alert(argA); // 显示 1
  5. alert(f.arguments[0]); // 显示 1
  6. f.arguments[0] = "one";
  7. alert(argA); // 显示 one
  8. argB = "two";
  9. alert(f.arguments[1]); // 显示 two
  10. alert(f.arguments.argB); // 显示 two
  11. }

如以上示例的最后一行所示,还可以将形参名称作为 arguments 对象的属性键,以此来访问形参。

从概念上说,在调用函数时,应该为这次触发的函数创建一个新的 arguments 对象,并将该函数对象 arguments 属性的值设置为这个新 arguments 对象。但在 JavaScript 1.0/1.1 中,函数对象和 arguments 对象是相同的对象:

  1. // JavaScript 1.0-1.1
  2. function f(a, b) {
  3. if (f == f.arguments) alert("f and f.arguments are the same object");
  4. }
  5. if (f.arguments == null) alert("but only while a call to f is active");

理想情况下,函数的 arguments 对象只能在其函数体内访问。这是通过在函数调用返回时,自动将函数的 arguments 属性设置为 null 来部分实现的。但假设有两个函数 f1f2,如果 f1 调用 f2,那么 f2 就可以通过对 f1.arguments 求值的方式,访问到 f1 的实参。

arguments 对象还有一个名为 caller 的属性。这个 caller 属性的值是「触发当前函数调用」的函数对象。但如果是最外层的函数调用,这个值则为 null。通过使用 callerarguments,任何函数都可以检查当前调用栈上的函数及其实参,甚至还可以修改调用栈上函数的形参值。还有一个具备相同含义的 caller 属性可以通过函数对象直接访问,而无需通过 arguments 对象。

对数值属性键的特殊处理

在 JavaScript 1.0 中,方括号在与整数键一起使用时具有不寻常的语义。在某些情况下,带方括号的整数键会按照属性的创建顺序,来依次访问对象的属性。如果对象上尚不存在具有该键的属性,并且该整数值 n 小于对象属性的总数,那么就会使用属性顺序来访问对象。在这种情况下,将会访问在该对象上创建的第 n 个属性(起点为零),例如:

  1. // JavaScript 1.0
  2. var a = new Object; // 或者 new Array
  3. a[0] = "zero";
  4. a[1] = "one";
  5. a.p1 = "two";
  6. alert(a[2]); // 显示 two
  7. a[2] = "2";
  8. alert(a.p1); // 显示 2

JavaScript 1.1 删除了对方括号的这种特殊处理。

原始值的属性

在 JavaScript 1.0 中,数字和布尔值没有属性。并且在尝试访问它们或为其分配属性时,会产生错误消息。字符串值的行为则类似于具有属性的对象,但它们除了只读的 length 属性之外,都共享一组相同的属性和值。例如:

  1. // JavaScript 1.0
  2. "xyz".prop = 42; // 设置所有字符串的 prop 属性为 42
  3. alert("xyz".prop); // 显示 42
  4. alert("abc".prop); // 显示 42

在 JavaScript 1.1 中,对数字、布尔值或字符串值做属性访问或赋值时,会使用内置的 Number / Boolean / String 构造函数隐式创建「包装器对象」(wrapper object)。属性访问是在包装器(wrapper)上执行的,并且通常会从其内置原型来访问继承的属性。通过自动调用 valueOftoString 方法执行的类型转换,使得在大多数情况下,包装器可以被视为原始值来使用。还可以通过赋值的方式,在包装器对象上创建新属性。但隐式创建的包装器,通常会在赋值后立即不可访问。例如:

  1. // JavaScript 1.1
  2. "xyz".prop = 42; // 设置字符串包装器的 prop 属性为 42
  3. alert("xyz".prop); // 隐式创建另一个包装器,显示 undefined
  4. var abc = new String("abc"); // 显式创建一个包装器对象
  5. alert(abc + "xyz"); // 隐式将包装器转为字符串,显示 abcxyz
  6. abc.prop = 42; // 在包装器对象上创建属性
  7. alert(abc.prop); // 显示 42

JavaScript 中的 HTML 注释

Netscape 1 和 Mosaic 浏览器在遇到 HTML <script> 元素时所做的操作,引起了 Netscape 2 中潜在的 JavaScript 互操作性问题。那些较旧但仍被广泛使用的浏览器,在显示网页时会以文本形式显示 <script> 正文,亦即实际的 JavaScript 源码。在这些浏览器中,可以用 HTML 注释21将脚本主体括起,从而避免出现这种情况。例如:

  1. <!-- Mosaic and Netscape 1 -->
  2. <script>
  3. <!-- 这是包住脚本体的 HTML 注释
  4. alert("this is a message from JavaScript"); // 对旧浏览器不可见
  5. // 下一行结束 HTML 注释
  6. -->
  7. </script>

基于这种编码模式,Netscape 1 和 Mosaic 中的 HTML 解析器会将整个脚本主体识别为 HTML 注释,而不去显示它。但按照最初的 Mocha 实现方式,这会使得浏览器无法将脚本解析为 JavaScript,因为 HTML 注释的分隔符(delimiter)在 JavaScript 代码中属于无效语法。为避免该问题,Brendan Eich 使 JavaScript 1.0 支持用 <!-- 作为单行注释的开始,和 // 等效。他没有让 --> 成为可识别的 JavaScript 注释分隔符,因为在它前面加上 // 即可。这样一来就可以实现脚本的向后兼容支持了,如下所示:

  1. <!-- Mosaic, Netscape 1, and Netscape 2 with JavaScript 1.0 -->
  2. <script>
  3. <!-- 这既是旧浏览器中的 HTML 注释,也是一条 JS 单行注释
  4. alert("this is a message from JavaScript"); // 对旧浏览器不可见
  5. // 下一行既结束了 HTML 注释,也是一条 JS 单行注释
  6. //-->
  7. </script>

尽管 <!-- 注释并未记录为正式的 JavaScript 语法,但 Web 开发者已使用了它们,并且其他浏览器的 JavaScript 实现也支持它。结果 <!-- 成为了事实上的 Web Realityg。二十年后的 2015 年,它终于被添加到了 ECMAScript 标准中——笑到最后的总是 Web Reality。