重新介绍 JavaScript(JS 教程)

为什么要重新介绍 JavaScript 呢,可怜的 JavaScript 曾经臭名昭著。很多程序员嘲笑它就是一个玩具而已,但是其实在其看起来简单的外面下,有着强大的语言特性。JavaScrpit 现在工作在很多牛逼的应用上,对于 Web 开发者和移动开发者来说,熟悉 JavaScript 必然是一个很重要的技能了。

简介

简单介绍一下 JavaScript 的历史,JavaScript 是在 1995 年被网景公司的一个工程师创造出来的。它的第一个发行版本在 1996 年,最早它被称作 LiveScript,但是由于市场不好,于是它打算借当时如日中天的 Sun 公司的 Java 语言的光,改名成了 JavaScript,虽然它俩没啥共同点。这个名字也确实产生了很多误解。

几个月后,微软在 IE 3 上发布了 JScrpit,它能兼容大部分的 JavaScript。又过了几个月后。网景公司把 JavaScript 提交到了 ECMA 组织(一个欧洲的标准化组织),于是第一个版本的 ECMAScript 在这一年面世了。之后的一个很重要的版本更新是 1999 年的 ECMAScript 3,并且之后一直很稳定。第 4 版由于考虑到语言的复杂性被遗弃了。不过第 4 版的很多特点是在 2009 年 12 月发布的 ECMAScript 5 的基础。在此之后,2015 年发布了 JavaScript 的第 6 个版本,ECMAScript 6。

由于我们更熟悉 JavaScript 这个名字,我们接下来将使用 JavaScript 这个名字来代替 ECMAScript。

和大多的编程语言不同,JavaScript 没有输入输出的概念。它就是被设计成依赖宿主环境运行的。它为宿主环境提供与外界交流的方法。最常用的宿主环境自然就是浏览器了,但是其实还可以在很多其他地方找到 JavaScript 的解释器,包括 Adobe Acrobat、Adobe Photoshop、矢量图片、雅虎的插件引擎,服务端环境(比如 Node.js),NoSQL 数据库(比如 Apache CouchDB),嵌入式计算机,完整的桌面环境(比如 GNOME - GNU/Linux 系统最流行的 GUI 环境之一),以及很多其他地方。

概述

JavaScript 是一个面向对象的动态编程语言,它有类型、操作符,标准内置对象和方法的概念。它的语法看起来就像 Java 和 C 语言一样,所以这些语言的很多特点也适用 JavaScript,有一个主要不同就是:JavaScript 没有类,然而,这个功能可以通过原型来实现(请查阅 ES2015 Classes)。还有一个主要的不同是,函数其实是对象。它可以像其他对象一样赋值和传递,只是它有可执行的能力。

我们接下来讨论任何语言共有的基础:类型。所有的 JavaScript 操作数都有一个类型,JavaScript 的类型有下面这些:

  • Number
  • String
  • Boolean
  • Function
  • Object
  • Symbol (ES2015 新特性)

哦,还有...奇怪的 undefinednull,还有 Array,是一种特殊的对象,还有 DateRegExp,也是对象,严格来说,函数也是对象,所以实际上 JavaScript 的类型更像是下面这样:

  • Number
  • String
  • Boolean
  • Symbol(ES2015 新特性)
  • Object

    • Function
    • Array
    • Date
    • RegExp
  • null
  • undefined

其实还有一些内建的 Error 类型,我们看第一个列表会更清晰一些,我们接下来讨论第一个列表的内容。

Number(数字)

根据规范,JavaScript 中的 Number 是 IEEE 754 标准的 64 位双精度浮点类型,于是有一些微妙的地方。JavaScript 中没有整数,所以就像在 C 中和 Java 中一样,你要小心浮点误差。

就像下面这样:

0.1 + 0.2 == 0.30000000000000004

通常,整数值是 32 位的,有些实现中甚至就这么存储它,除非特别指出必须使用 Number,因为使用整数对于位操作很重要。

JavaScript 支持标准算数元算符,比如加、减、乘、除、取余等等。还有一个我们之前没提到的内建对象 Math,它提供了常用的数学函数和常量。

Math.sin(3.5);
var circumference = Math.PI * (r + r);

你可以使用内建函数 parseInt() 把一个字符串转换为整数,第二个参数是字符串数的进制,虽然它是可选的,不过建议你一直指定它:

parseInt("123", 10); // 123
parseInt("010", 10); // 10

在旧的浏览器中,0 开头的字符串被认为是八进制的,但是在 2013 年以后就不是这样了。所以在旧的浏览器中你可能会得到错误的结果:

parseInt("010");  //  8
parseInt("0x10"); // 16

我们看到,parseInt() 方法把 010 当作了八进制数,把 0x10 当作了十六进制数。(十六进制数的表示如今仍然存在,只是八进制的移除了而已。)

如果你想把一个二进制数转换为一个整数,只要这样做就可以了:

parseInt("11", 2); // 3

类似的,你还可以使用内建函数 parseFloat() 把一个字符串转换为浮点类型,与 parseInt() 不同的是,parseFloat() 总是十进制的。

你也可以使用一元运算符 + 来把字符串转换为数字:

+ "42";   // 42
+ "010";  // 10
+ "0x10"; // 16

如果被转换的字符串不是一个数字,将返回一个特殊的值:NaN (Not a Number)。

parseInt("hello", 10); // NaN

NaN 是有毒的,对它进行任何数学操作后得到的结果仍然是 NaN

NaN + 5; // NaN

你可以使用内建函数 isNaN() 来判断一个数是否是 NaN

isNaN(NaN); // true

在 JavaScript 中还有两个特殊的值,即 Infinity-Infinity

1 / 0; //  Infinity
-1 / 0; // -Infinity

你可以使用内建函数 isFinite() 来判断 Infinity-Infinity 或者 NaN

isFinite(1/0); // false
isFinite(-Infinity); // false
isFinite(NaN); // false

使用 parseInt()parseFloat() 方法在转换字符串到数字时,从左往右一直转换到第一个不是数字的字符,然后返回到此处的转换结果。而使用一元运算符 + 时,如果待转换的字符串中有非数字的字符,就返回 NaN,你可以尝试用两种方法转换一下 "10.2abc" 就明白二者的不同之处了。

String(字符串)

JavaScript 中的字符串是 Unicode 的字符序列。这对于处理国际化语言的应用来说绝对是个好消息。更准确的说,它们是 UTF-16 的代码单元,每个代码单元是一个 16 bit 的数字,每个字符用 1 个或 2 个代码单元来表示。

如果你想表示单个字符,只需要使用包含单个字符的字符串即可。

可以使用字符串的 length 属性获取字符串的长度(代码单元)。

"hello".length; // 5

这是我们第一次使用 JavaScript 对象!我们之前有说过可以像使用对象一样使用字符串吗?我们可以使用字符串的方法来操作字符串或者获取字符串的信息:

"hello".charAt(0); // "h"
"hello, world".replace("hello", "goodbye"); // "goodbye, world"
"hello".toUpperCase(); // "HELLO"

其他类型

在 JavaScript 中,nullundefined 是不一样的,null 强调的是没有值,而 undefined 指的是未定义、未初始化的值。我们接下来会讨论变量,在 JavaScript 中,我们可以使用一个没赋值的变量,这种情况下,这个变量的值就是 undefinedundefined 实际上是一个常量。

JavaScript 有一个 Boolean 类型,即只有两种可能的值,truefalse,它们两个都是关键字。所有的值都可以通过一下方式转换为 Boolean 值。

  1. false、0、空字符串("")、NaN、null 还有 undefined 是 false。
  2. 其他的所有值都是 true。

你可以使用 Boolean() 函数来执行这种转换。

Boolean("");  // false
Boolean(234); // true

在 JavaScript 语句中,可能隐式的进行这种类型转换,比如在 if 语句中,它需要一个 Boolean 值,所以就会发生这种转换。所以,我们说一个值是真值或者假值时,是指这个值可以通过这种转换变成 true 或者 false

JavaScript 也支持 Boolean 运算符(逻辑运算符),&&(逻辑与)、||(逻辑或)、和 !(逻辑非)。下文有介绍。

变量

在 JavaScript 中,定义一个变量,使用 letconst 或者 var 运算符。

let 运算符用来创建一个块级作用域的变量,这种变量的作用域仅限于语句块当中。

let a;
let name = "Simon";

下面这个例子展示了使用 let 定义的变量的 作用域:

//myLetVariable is *not* visible out here

for( let myLetVariable = 0; myLetVariable < 5; myLetVariable++ ) {
    //myLetVariable is only visible in here
}

//myLetVariable is *not* visible out here

const 用来定义一个常量,即定义之后就不会改变了。这个变量的作用域也仅限于语句块中。

const Pi = 3.14; // variable Pi is set 
Pi = 1; // will throw an error because you cannot change a constant variable.

var 是最常用的定义变量的关键字,它没有另外两个关键字的限制。这是因为曾经只有这一种定义变量的方式。使用 var 定义的变量的作用域是函数间的。

var a; 
var name = "simon";

使用 var 定义变量的作用域的例子。

//myVarVariable *is* visible out here 

for( var myVarVariable = 0; myVarVariable < 5; myVarVariable++ ) { 
    //myVarVariable is visible to the whole function 
} 

//myVarVariable *is* visible out here

如果你定义了一个变量,并且不使用任何赋值语句,那么它的值是 undefined

JavaScript 和其他语言(比如 Java)有一个很大的不同是,以前的 JavaScript 只有函数级作用域,没有块级作用域。比如你用 var 在复合语句中定义了一个变量(比如 if 控制结构中),那么这个变量在整个函数中都是可以访问的。然而,从 ES2015 开始,letconst 关键字允许你创建块作用域的变量了。

JavaScript 的算数运算符有 +-*/%(取余)。赋值运算符是 =,还有复合赋值预算符比如 +=-=。即:x += y 就是 x = x + y

x += 5
x = x + 5

你也可以使用自增运算符 ++ 和自减运算符 -- 来执行加一或减一操作,注意前缀和后缀的区别。

此外,+ 运算符还可以用作字符串连接:

"hello" + " world"; // "hello world"

如果你把一个 String 与一个 Number (或其他值)相加,会先进行类型转换,看下面的例子:

"3" + 4 + 5;  // "345"
3 + 4 + "5"; // "75"

如果你想把一个值转换为字符串类型,用一个空字符串加上它,也是一种不错的转换方法。

JavaScript 中值的比较可以使用 ><>=<= 运算符。这些运算符对于 NumberString 都适用。而比较相等就没那么直观了,两个等号的运算符 == 比较时会进行类型转换,所以用 == 比较两个不同类型的值时,会发生有趣的现象:

123 == "123"; // true
1 == true; // true

如果你想要避免类型转换并且确定你要进行的比较是精确的,你应该是用三个等号的运算符 ===

123 === "123"; // false
1 === true;    // false

此外,!=!== 运算符也符合上面介绍的。

在 JavaScript 中也有位运算符,你可以使用它们。

控制结构

JavaScript 语言的控制结构和类 C 语言的语法相似。条件语句支持 ifelse,你也可以链式使用它们:

var name = "kittens";
if (name == "puppies") {
    name += "!";
} else if (name == "kittens") {
    name += "!!";
} else {
    name = "!" + name;
}
name == "kittens!!"

JavaScript 也有 while 循环和 do-while 循环。前者是常用的基本循环。而后者:当你要保证循环体至少执行一次时,就使用 do-while 循环。

while (true) {
  // an infinite loop!
}

var input;
do {
  input = get_input();
} while (inputIsNotValid(input))

JavaScript 的 for 循环与 C 语言、 Java 语言的一样。你能够清晰的把控制语句写在一行里,来控制循环次数。

for (var i = 0; i < 5; i++) {
    // Will execute 5 times
}

JavaScript 还有两个特殊的 for 循环。for...of

for(let value of array) {
    // do something with value
}

for...in

for(let property in object) {
    // do something with object property
}

此外,&&|| 运算符有短路的特点,也就是说,当语句的第一部分已经能够判断出整个语句的真假时,第二部分就不会执行了。当你要访问一个对象的属性时,你可以使用这个特点来先判断对象是否为空,以避免访问出错:

var name = o && o.getName();

JavaScript 也有一个三元运算符用来做条件判断,即 (condition) ? (value1) : (value2) 表达式,当 condition 为真时,整个表达式的值是 value1,否则整个表达式的值是 value2

var allowed = (age > 18) ? "yes" : "no";

还有一个 switch 语句用来进行多分支判断:

switch(action) {
    case 'draw':
        drawIt();
        break;
    case 'eat':
        eatIt();
        break;
    default:
        doNothing();
}

需要注意的是,如果你不加上 break 语句,会继续执行下一个 case 后面的语句,这通常都不是你想要的。如果你真的想要它继续执行下去的话,你最好加一条注释,这样对调试是很有帮助的。

switch(a) {
    case 1: // fall through
    case 2:
        eatIt();
        break;
    default:
        doNothing();
}

在上面的例子中,除了使用 case 判断以外,还有一个可选的 default 判断,当所有 case 都不满足时,就会执行这个 default 后面的语句。顺便说一下,在 switch 中的判断相等使用的是 === 运算符:(为了展示这个特点,我修改了原文的例子,原文中 case2 + 2 而不是 "4",那样的话 yay() 就会执行了)

switch(1 + 3) {
    case "4":
        yay(); //not executed
        break;
    default:
        neverhappens();
}

Object(对象)

JavaScript 对象可以简单地被理解为一个 name-value 的容器。就像:

  • Python 的 Dict
  • Ruby 或 Perl 的 Hash
  • C / C++ 的哈希表(这个貌似要自己实现,我觉得可以说是 C++ STL 的 map)
  • Java 中的 HashMap
  • PHP 的关联数组

我们可以看到,这种数据结构应用如此广泛。其实在 JavaScript 中,一切都是对象(除了核心类型),所有的 JavaScript 都涉及到了大量的哈希查找。这是个好东西,而且它速度非常快。

其中,name 部分是一个 JavaScript 字符串,而 value 部分可以是任意的 JavaScript 对象,所以你可以创造一个任意复杂的数据结构。

有两种简单的方法创建一个新对象:

var obj = new Object();

或者:

var obj = {};

它们在语义上是相同的,第二种方式叫做对象字面量语法,它更方便一些。这种语法也是 JSON 的核心,推荐使用这个。

对象字面量语法可以用来这样初始化一个对象:

var obj = {
    name: "Carrot",
    "for": "Max",
    details: {
        color: "orange",
        size: 12
    }
}

你可以链式访问对象的属性:

obj.details.color; // orange
obj["details"]["size"]; // 12

下面这个例子创建一个对象原型:Person。又创建了一个这个原型的实例:You

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

// Define an object
var You = new Person("You", 24); 
// We are creating a new person named "You" 
// (that was the first parameter, and the age..)

创建完成后,对象的属性可以通过以下两种方式访问:

obj.name = "Simon";
var name = obj.name;

或者:

obj["name"] = "Simon";
var name = obj["name"];

这两种方式在语义上也是相同的。第二种方式有一个优点,就是你可以通过一个字符串来获取对象的属性,也就是说,这个字符串可能是在运行中计算出来的,而不是预先在程序代码中写好的。然而,这种方式可能会阻止编译器的优化。它也可以设置或获取一个名字是保留字的属性:

obj.for = "Simon"; // 语法错误,因为 for 是保留字
obj["for"] = "Simon"; // 正常工作

从 ES2015 开始,保留字可以正常用来设置或获取对象的属性了。也就是说,上面的两行代码,在 ES2015 以后的版本都是可以正常运行的。

Arrays(数组)

数组是一种特殊类型的对象,它们的用法和普通对象非常相似(属性名是整数的,可以通过 [] 来访问),但是它有一个神奇的属性 length,它总是比数组中最大的下标值大 1。

你可以这样创建一个数组:

var a = new Array();
a[0] = "dog";
a[1] = "cat";
a[2] = "hen";
a.length; // 3

一个更方便的方法:

var a = ["dog", "cat", "hen"];
a.length; // 3

注意,array.length 不一定就是数组中元素的个数,看下面这个例子:

var a = ["dog", "cat", "hen"];
a[100] = "fox";
a.length; // 101

记住:数组的 length 属性总是比数组中最大的下标大 1。

如果你查询一个不存在的数组下标,你会得到 undefined

typeof a[90]; // undefined

如果你考虑到了上面说的内容,你可以使用下面的方式遍历一个数组:

for (var i = 0; i < a.length; i++) {
    // Do something with a[i]
}

你可可以使用 for...in 循环来遍历数组,但是如果你在数组添加了一个新的属性,那么 for...in 也会把它遍历出来,所以不推荐这种做法。

还有一种遍历数组的方法,就是使用在 ES2015 中新增的 forEach()

["dog", "cat", "hen"].forEach(function(currentValue, index, array) {
    // Do something with currentValue or array[index]
});

如果你想在数组后面新增一个元素,你可以这样做:

a.push(item);

数组中其实有很多方法,常用的如下:

a.toString() | 返回一个字符串,包括所有以逗号分隔的元素的 toString() 值
a.toLocaleString() | 返回一个字符串,包括所有以逗号分隔的元素的 toLocaleString() 值
a.concat(item1[, item2[, ...[, itemN]]]) | 返回一个添加了新元素的数组
a.join(sep) | 返回一个由 seq 分隔的所有元素值的字符串
a.pop() | 删除并且返回数组中最后一个元素
a.push(item1, ..., itemN) | 添加一个或者多个元素到数组的尾部
a.unshift(item1[, item2[, ...[, itemN]]]) | 在数组前面插入一个或多个元素
a.shift() | 删除并且返回数组中第一个元素
a.reverse()    | 反转数组
a.slice(start[, end]) | 返回子数组
a.sort([cmpfn]) | 采用可定义的排序
a.splice(start, delcount[, item1[, ...[, itemN]]]) | 删除数组的一部分并且替换它

Functions(函数)

除了对象意外,函数是理解 JavaScript 的核心部分。基础的函数可能并不简单:

function add(x, y) {
    var total = x + y;
    return total;
}

上述例子演示了一个基本的函数,一个 JavaScript 的函数可能有 0 个或多个参数,函数体可以有任意多的语句,可以定义只属于这个函数的变量。可以使用 return 语句随时返回一个值,并且结束这个函数。如果没有 return 语句,或者 return 后面没有值的话,函数将返回 undefined

add(); // NaN 
// 你不能对 undefined 执行 + 操作

你也可以传入更多个参数:

add(2, 3, 4); // 5 
// 只加前两个,4 被忽略了

这看起来有点傻。我们其实可以在函数体内访问一个叫 arguments 的对象,它就像一个数组,包含了所有的参数,我们可以这样重写上面的函数:

var sum = 0;
    for (var i = 0, j = arguments.length; i < j; i++) {
        sum += arguments[i];
    }
    return sum;
}

add(2, 3, 4, 5); // 14

我们不再用写 2 + 3 + 4 + 5 这样的语句了!接下来我们创建一个求平均数的函数:

function avg() {
    var sum = 0;
    for (var i = 0, j = arguments.length; i < j; i++) {
        sum += arguments[i];
    }
    return sum / arguments.length;
}

avg(2, 3, 4, 5); // 3.5

这很有用,但是看起来写着挺麻烦。我们接下来将使用 Spread 语法来缩短代码。我们可以传入任意多的参数,并且保持代码的简洁。Spread 语法的格式是:...[variable],它会包含所有明确指定的参数以外的未捕获的参数列表。并且我们在下面的例子里面使用了 for...of 循环。

在上面的例子中,args 包含了所有传入的参数。

实际上,Spread 语法会包含所有 ... 符号后面的变量。比如 function avg(firstValue, ...args) 的话,第一个参数会保存在 firstValue 中,剩下的参数才保存在 args 中。

还有一个问题,就是我们的 avg 函数必须传入逗号分隔的参数才能计算平均值,那么如果我们想要计算数组的平均值怎么办?你可以这样编写这个函数:

function avgArray(arr) {
    var sum = 0;
    for (var i = 0, j = arr.length; i < j; i++) {
        sum += arr[i];
    }
    return sum / arr.length;
}

avgArray([2, 3, 4, 5]); // 3.5

如果能够使用我们已经编写好的函数那就更好了。其实在 JavaScript 中,function 对象有一个方法叫 apply(),它可以传入一个数组作为参数:

avg.apply(null, [2, 3, 4, 5]); // 3.5

apply() 的第二个参数就是一个数组,第一个参数我们过会儿讨论,这里只是强调函数也是对象。

在 JavaScript 中你也可以创建匿名函数。

var avg = function() {
    var sum = 0;
    for (var i = 0, j = arguments.length; i < j; i++) {
        sum += arguments[i];
    }
    return sum / arguments.length;
};

这在语义上等同于 function avg()。这个非常有用,它可以让你把函数定义放在任何地方来替换普通的表达式。这就可以达到隐藏变量的目的了,就像 C 语言中的块作用域。

var a = 1;
var b = 2;

(function() {
    var b = 3;
    a += b;
})();

a; // 4
b; // 2

JavaScript 允许使用函数递归。这对处理像浏览器 DOM 这样的树形结构的数据时尤其有用。

function countChars(elm) {
    if (elm.nodeType == 3) { // TEXT_NODE
        return elm.nodeValue.length;
    }
    var count = 0;
    for (var i = 0, child; child = elm.childNodes[i]; i++) {
        count += countChars(child);
    }
    return count;
}

这就又出现了一个问题,匿名函数没有名字,我们该如何对他进行递归调用呢?这时候,我们可以为他指派一个名字,并把它放到一个括号里。你也可以使用立即调用表达式就像下面这样:

var charsInBody = (function counter(elm) {
    if (elm.nodeType == 3) { // TEXT_NODE
        return elm.nodeValue.length;
    }
    var count = 0;
    for (var i = 0, child; child = elm.childNodes[i]; i++) {
        count += counter(child);
    }
    return count;
})(document.body);

上面的函数名称 counter 只有在函数体中可用,这允许编译器做更多的优化,并且代码的可读性更好。这个名称也会显示在调试器中,这样的话还可以为调试节省很多时间。

记住,函数本身也是一个对象,我们可以像操作其他对象一样为他添加或改变它的属性。

Custom Object(自定义对象)

在传统的面向对象编程中,对象是数据和方法的集合。JavaScript 是一种基于原型的编程语言,它没有 C++ 或 Java 中那样的 class 语句。而且,JavaScript 把函数当作类,我们来考虑一个 person 对象,它又 firstlast 两个属性,我们用两种方式展示它:"first last" 和 "last, first",使用我们前面讨论的函数和对象,我们可以这样做:

function makePerson(first, last) {
    return {
        first: first,
        last: last
    };
}
function personFullName(person) {
    return person.first + ' ' + person.last;
}
function personFullNameReversed(person) {
    return person.last + ', ' + person.first;
}

s = makePerson("Simon", "Willison");
personFullName(s); // "Simon Willison"
personFullNameReversed(s); // "Willison, Simon"

这份代码没问题,就是看起来有点丑。我们要在全局区域写很多函数,既然函数也是对象,我们只需要给函数返回的对象中添加两个属性就可以了,就像这样:

function makePerson(first, last) {
    return {
        first: first,
        last: last,
        fullName: function() {
            return this.first + ' ' + this.last;
        },
        fullNameReversed: function() {
            return this.last + ', ' + this.first;
        }
    };
}

s = makePerson("Simon", "Willison")
s.fullName(); // "Simon Willison"
s.fullNameReversed(); // "Willison, Simon"

这有一个东西我们之前没见过:this 关键字。当它用在一个函数中是,它表示当前对象的引用,也就是调用这个函数的对象。即:当你使用 . 或者 [] 来访问一个对象的属性或方法时,这个对象就时 this。如果你没有使用 . 或者 [] 访问的话,此时的 this 指向全局对象。

这是一个经常出错的地方,比如:

s = makePerson("Simon", "Willison");
var fullName = s.fullName;
fullName(); // undefined undefined

我们单独调用 fullName() 时,没有使用 s.fullName(),这时候 fullName() 函数体内的 this 指向全局对象而不是 s。所以我们得到了 undefined undefined 这个结果。

我们接下来使用 this 关键字改进 makePerson() 函数:

function Person(first, last) {
    this.first = first;
    this.last = last;
    this.fullName = function() {
        return this.first + ' ' + this.last;
    };
    this.fullNameReversed = function() {
        return this.last + ', ' + this.first;
    };
}
var s = new Person("Simon", "Willison");

我们引入了另外一个新的关键字 newnewthis 密切相关。它用来创建一个新对象。然后用 this 指向这个新对象,并且执行 new 后面这个特殊的函数。这个含有 this 的特殊的函数不会返回一个值,而只是修改这个对象。new 关键字返回刚刚创建的新对象给调用这。设计成被 new 调用的这个函数称作构造函数。通常我们把它们的首字母大写用来标记它们是构造函数。

我们刚刚优化的这个版本,单独使用 fullName() 时,仍然会发生返回 undefined undefined 这种情况。

我们的 person 对象已经不错了,但是仍然有一些缺陷。每次我们新创建一个对象时,我们都创建了两个相同的函数对象,怎样让所有的对象共享这两个函数呢?

function personFullName() {
    return this.first + ' ' + this.last;
}
function personFullNameReversed() {
    return this.last + ', ' + this.first;
}
function Person(first, last) {
    this.first = first;
    this.last = last;
    this.fullName = personFullName;
    this.fullNameReversed = personFullNameReversed;
}

这样就可以了,我们单独创建了两个函数,在构造函数中,只是简单的赋值成这两个函数的引用。我们还能做得更好吗?答案是肯定的。

function Person(first, last) {
    this.first = first;
    this.last = last;
}
Person.prototype.fullName = function() {
    return this.first + ' ' + this.last;
};
Person.prototype.fullNameReversed = function() {
    return this.last + ', ' + this.first;
};

Person.prototype 是所有 Person 的实例都共享的一个对象,它是原型链中的一部分。每次你访问一个 Person 中没有的属性时,它会在 Person.prototype 上查找。所以,你分配给 Person.prototype 的属性,对于这个对象是可用的。

这是一个令人难以置信的有利工具。在 JavaScript 程序中,你可以随时修改一个对象的原型,也就是说,你可以在运行中给一个类的所有实例添加新的属性或方法。

s = new Person("Simon", "Willison");
s.firstNameCaps(); // TypeError on line 1: s.firstNameCaps is not a function

Person.prototype.firstNameCaps = function firstNameCaps() {
    return this.first.toUpperCase()
};
s.firstNameCaps(); // "SIMON"

有趣的是,你也可以给内建的 JavaScript 对象的 prototype 添加新东西,比如我们给 String 添加一个反转的方法:

var s = "Simon";
s.reversed(); // TypeError on line 1: s.reversed is not a function

String.prototype.reversed = function reversed() {
    var r = "";
    for (var i = this.length - 1; i >= 0; i--) {
        r += this[i];
    }
    return r;
};

s.reversed(); // nomiS

这样我们的新方法就可以在 String 对象上使用了!

"This can now be reversed".reversed(); // desrever eb won nac sihT

正如我前面提到的,原型组成链的一部分。链的根是 Object.prototype,它有一个 toString() 方法,当你想要用 String 来表示这个对象时就会调用这个 方法。这对于我们调试 Person 对象时有用的:

var s = new Person("Simon", "Willison");
s; // [object Object]

Person.prototype.toString = function() {
    return '<Person: ' + this.fullName() + '>';
}

s.toString(); // "<Person: Simon Willison>"

还记得 avg.apply() 方法吗?它的第一个参数我们留了个悬念,这时候该揭晓了。apply() 的第一个参数就是应该当作 this 的对象。下面是关于 new 的一个简单实现:

function trivialNew(constructor, ...args) {
    var o = {}; // Create an object
    constructor.apply(o, args);
    return o;
}

这不是 new 的完整实现,因为它没有设置原型链(这个很难说明)。这东西你并不常用,但是知道它还是很有用的。在这段代码里,...args 叫剩余参数,顾名思义,它包含了没有显示指定的参数部分(前面说过这个了)。

这么用:

var bill = trivialNew(Person, "William", "Orange");

因此它基本等价于:

var bill = new Person("William", "Orange");

apply() 还有一个姊妹方法叫做 call(),它的第一个参数也是 this,但是后面的参数是一个逗号分隔的列表而不是数组。

function lastNameCaps() {
    return this.last.toUpperCase();
}
var s = new Person("Simon", "Willison");
lastNameCaps.call(s);
// 等价于:
s.lastNameCaps = lastNameCaps;
s.lastNameCaps();

(如果非说不等价的话,第二种写法会给 s 对象新增一个 lastNameCaps 属性,第一种则不会)

内部函数

你可以在一个函数体内定义另一个函数。我们之前见过这种情况,就是 makePerson() 那个例子。一个重要的细节就是,嵌套在内部的函数可以访问可以访问父函数作用域内的变量:

function betterExampleNeeded() {
    var a = 1;
    function oneMoreThanA() {
        return a + 1;
    }
    return oneMoreThanA();
}

这对于编写更利于维护的代码是很有用的。如果某个函数依赖另外一两个函数,而这一两个函数对其他部分的代码却没有用处,你就可以把这一两个函数嵌套在这个函数内,这样还减少了全局作用域内函数的数量,这么做总是应该的。

这也是一个减少全局变量的好方法。当我们写复杂的代码的时候,总是倾向于使用全局变量,因为全局变量可以方便的在多个函数之间共享,但是这样的代码会导致难以维护。我们知道了内部函数可以共享其父函数的变量,所以你可以使用这个特性把多个函数捆绑到一起而不会污染你的全局变量区。这种方法应该谨慎使用,但它确实很实用。

闭包

闭包是 JavaScript 中功能最强大的抽象概念之一,但是它也可能带来一些潜在的迷惑。那么它是做什么的呢?

function makeAdder(a) {
    return function(b) {
        return a + b;
    };
}
var x = makeAdder(5);
var y = makeAdder(20);
x(6); // ?
y(7); // ?

通过函数名字应该能够看出:它创建了一个有一个参数的 adder() 函数,这个函数被调用时,会计算这个参数和它的外层函数的参数的和。@.@

这里发生的事情和内部函数中介绍的十分相似:在一个函数内部定义的函数可以访问其外层函数的变量。唯一的不同是,外层函数已经返回了。按照常识来说,它的局部变量应该不存在了。但是事实上它们仍然存在,否则内部函数就应该不能工作了。也就是说,在上面的例子中外层函数的局部变量 a 有两个副本,第一个 a5,而第二个 a20。所以上面的例子的运行结果是这样的:

x(6); // returns 11
y(7); // returns 27

接下来我们说说究竟发生了什么。当 JavaScript 执行一个函数的时候,会创建一个用来保存局部变量的作用域对象,它和函数传入的参数一起被初始化。这就像保存全局变量和函数的全局对象一样,但是有一些重要的区别:首先,每次执行函数都会创建一个新的作用域对象。再者,与全局对象(如浏览器中的 window 对象)不同,你不能在 JavaScript 代码中直接访问作用域对象,也没有办法遍历作用域对象的属性。

所以当 makeAdder() 被调用时,会创建一个作用域对象,它有一个属性 a,就是 makeAdder() 函数传入的那个参数。然后 makeAdder() 返回了一个新函数。通常 JavaScript 的垃圾回收器会在这时候想要清理掉 makeAdder() 创建的这个作用域对象,但是返回的内部函数却保留了一个对这个作用域对象的引用。所以,这个作用域对象不会被垃圾回收器回收,直到内部函数对象对其没有引用了。

作用域对象组成了作用域链,和原型链相似,都被 JavaScript 的对象系统使用着。

一个闭包就是一个函数和它创建的作用域对象的组合。它可以保存状态,你也可以使用对象来代替它。

(本文翻译自 MDN:A re-introduction to JavaScript (JS tutorial)

标签: none

添加新评论