JavaScript-开发高级教程-全-

JavaScript 开发高级教程(全)

原文:Pro JavaScript Development

协议:CC BY-NC-SA 4.0

一、面向对象的 JavaScript

如果你开发网站已经有一段时间了,你可能会听到其他程序员说 JavaScript 不是面向对象的编程语言,结果经常在同一句话中否定这种语言。作为 JavaScript 开发人员,我们有责任互相教育,教育任何对 JavaScript 语言持反对意见的人,因为它确实是一种面向对象的语言,而且是一种非常强大的语言。

实际上,当其他程序员放弃 JavaScript 时,他们经常贬低它,因为它不符合传统语言(如 C++、Java、PHP 和 Objective-C)的所有相同结构和约定。在我看来,这并不一定是负面的,因为 JavaScript 如果以正确的方式编写,实际上可以提供更大的灵活性,因为它没有强加这样一种僵化的结构。

在这一章中,我将解释如何利用 JavaScript 的能力,使用其他语言所采用的面向对象编程原则来编写代码,重点是通过 JavaScript 使之更加灵活的方法。我还将介绍该语言本身包含的一些内置对象,以及其中一些鲜为人知的方面。

Note

经典编程语言是一种通过称为类的蓝图或模板来定义和创建对象的语言,因此得名。

JavaScript 中的对象

JavaScript 中的对象是一个独立的实体,由一个或多个相关的变量和函数组成,分别称为属性和方法。对象用于将相关的概念或功能组合在一起,通常是与现实世界或特定软件行为相关的东西。它们让开发人员更容易理解代码,最终也让代码更容易阅读和编写。

自定义对象

创建 JavaScript 代码中使用的对象的最简单方法是在定义变量时使用对象文字符号,用花括号表示。然后,可以使用清单 1-1 所示的格式,通过将属性和方法的名称和值封装在大括号中,将它们附加到对象上。这里我们创建一个新对象来表示一所房子,它有两个属性和两个方法。创建后,我们可以通过点符号在对象中读写属性和方法,其中对象名称由带有点(.)字符的属性或方法名称分隔。

清单 1-1。使用对象文字表示法创建对象

var house = {

rooms: 7,

sharedEntrance: false,

lock: function() {},

unlock: function() {}

};

// Read out the values of the two properties

alert(house.rooms); // 7

alert(house.sharedEntrance); // false

// Execute the 'lock' method of the object

house.lock();

// Update the value for the 'rooms' property

house.rooms = 8;

// Add a completely new property dynamically

house.floors = 2;

// Read out the 'rooms' property again – notice it has now changed

alert(house.rooms); // 8

假设我们想要表示另一种类型的财产,一套公寓。它类似于一栋房子,但通常房间较少,分布在一层,可能有一个通往街道的共用入口。让我们在一个新变量中将它表示为一个对象文字:

var apartment = {

floors: 1,

rooms: 4,

sharedEntrance: true,

lock: function() {},

unlock: function() {}

};

从概念上讲,公寓就像房子,但有不同的属性。如果我们选择以同样的方式表示更多类型的住宿,我们很快就会陷入这样的境地:改变我们想要在所有这些对象之间共享的属性的名称,或者为它们添加一个新的属性或方法,都会很困难或令人沮丧。理想情况下,我们会希望创建一个模板或蓝图来表示我们的对象的属性和方法,这样,如果我们想要更改属性名或添加新方法,我们就可以轻松地完成。JavaScript 允许我们通过构造函数创建这种对象模板,在其他经典语言中,构造函数通常被称为类。

班级

类是创建共享一组属性和方法的类似对象的模板或蓝图。像 Java 和 Objective-C 这样的编程语言允许开发者通过特定的关键字和结构来定义类。在 JavaScript 中,定义一个简单的函数会创建一些与类相同的行为。它与其他任何函数的不同之处不在于它是如何定义的,而在于如何从它创建对象。

Note

JavaScript 语言中一直有一个保留字叫做class,这意味着你不能用这个名字创建你自己的变量。它实际上从未在语言中被使用过;这个名字只是留作后用。看来这个关键字可能最终会在该语言的下一个版本中得到一些使用,该版本被称为 ECMAScript 6,目前正在起草中。

让我们创建一个构造函数,我们将使用它作为房子和公寓对象的蓝图。稍后我们将添加属性和方法。

function Accommodation() {};

这看起来和我们用 JavaScript 创建的其他函数没有什么不同。使用它作为模板创建对象涉及到使用关键字new,然后执行函数。

var house = new Accommodation();

var apartment = new Accommodation();

使用new关键字创建的任何对象都被认为是由函数表示的结构的对象实例,本质上它是作为这个模板或蓝图的实例创建的。以这种方式创建的每个对象实例不连接到从同一模板创建的任何其他实例;它们被视为完全独立的变量,只是共享相同的蓝图结构。尽管模板结构类似于经典编程语言中的一个类,但它并不完全相同。

我们将在本章后面更仔细地看一下构造函数 t。

检测对象的构造函数

以这种方式从模板创建的任何对象文字都有一个额外的属性,称为constructor,它指向用来创建它的 JavaScript 构造函数。有了这些知识,您就可以通过直接比较constructor和构造函数来检查您的应用中是否有任何对象文字与您的某个构造函数匹配。

house.constructor === Accommodation;     // true

apartment.constructor === Accommodation; // true

您可以使用instanceof关键字执行类似的比较,它将一个对象文字与用于创建它的构造函数进行比较。

house instanceof Accommodation;     // true

apartment instanceof Accommodation; // true

事实上,因为constructor属性直接映射到用于创建实例的函数,所以理论上可以直接使用这个属性和new关键字创建新的实例。这是一种不常见的用法,但仍然值得注意。

var apartment = new house.constructor();

apartment instanceof Accommodation; // true

因为我们用一个空函数定义了我们的“类”,所以它没有任何我们想要用作每个对象实例的模板的属性和方法。有两种方式将属性和方法分配给一个“类”,通过它的原型和通过它的作用域。现在,让我们依次看一看每一个。

使用原型分配属性和方法

在 JavaScript 中创建的每个函数以及每个构造函数都有一个prototype属性。这是一个包含属性和方法的对象,这些属性和方法与使用new关键字从“类”创建的任何对象实例相关联。我们可以在这个prototype对象上使用点符号,将我们自己的属性和方法添加到所有关联的对象实例中。我们指定的每一个属性,我们给它一个默认值,这样就不会有未定义的值。清单 1-2 显示了我们如何使用prototype关键字定义模板的属性和方法,或者“类”。

清单 1-2。使用 prototype 关键字和点标记法将属性和方法分配给构造函数

// Define a constructor called Accommodation

function Accommodation() {}

// Assign properties to our "class" blueprint

Accommodation.prototype.floors = 0;

Accommodation.prototype.rooms = 0;

Accommodation.prototype.sharedEntrance = false;

// Assign methods to our "class" blueprint

Accommodation.prototype.lock = function() {};

Accommodation.prototype.unlock = function() {};

// Create object instances from our Accommodation "class"

var house = new Accommodation();

var apartment = new Accommodation();

// Read properties from object instances

alert(house.floors); // 0

alert(house.sharedEntrance); // false

// Write properties to object instances to set the correct values

house.floors = 2;

accommodation.sharedEntrance = true;

// Execute methods on object instances

house.unlock();

apartment.lock();

因为原型是与我们用作“类”的函数相关联的对象属性,所以我们也可以使用对象文字符号而不是点符号。清单 1-3 显示了我们将如何做。

清单 1-3。使用对象文本向构造函数分配属性和方法

// Define a constructor called Accommodation

function Accommodation() {}

// Assign properties and methods to our "class" blueprint with an object literal

Accommodation.prototype = {

floors: 0,

rooms: 0,

sharedEntrance: false,

lock: function() {},

unlock: function() {}

};

// Create object instances from our Accommodation "class"

var house = new Accommodation();

var apartment = new Accommodation();

// Read properties from object instances

alert(house.floors); // 0

alert(house.sharedEntrance); // false

// Write properties to object instances to set the correct values

house.floors = 2;

accommodation.sharedEntrance = true;

// Execute methods on object instances

house.unlock();

apartment.lock();

关键字prototype的一个强大特性是,你可以向它添加属性和方法,甚至在对象实例被创建之后,那些新的属性和方法会自动添加到所有对象实例中,包括之前和之后创建的,如清单 1-4 所示。

清单 1-4。向预先存在的对象实例动态添加属性和方法

// Define a constructor called Accommodation

function Accommodation() {};

// Assign properties and methods to our "class" blueprint with an object literal

Accommodation.prototype = {

floors: 0,

rooms: 0,

sharedEntrance: false,

lock: function() {},

unlock: function() {}

};

// Create an object instance

var house = new Accommodation();

// Dynamically add a new method to the "class" prototype

Accommodation.prototype.alarm = function() {};

// The existing object instance gains the new method automatically

house.alarm();

使用范围分配属性和方法

在函数中定义的任何变量或函数都限定在该函数的范围内,这意味着不能在该函数之外访问它——对于在函数中声明的变量和方法,函数就像沙盒开发环境或闭包。这对开发人员来说很好,因为这意味着一个函数中声明的变量不会影响另一个函数中的变量;相同的变量名甚至可以在不同的函数中使用而不会发生冲突。

在任何其他函数之外声明变量或函数,直接在 JavaScript 或 HTML 文件中声明,将该变量或函数置于全局范围内,这意味着它可以在代码中的任何地方使用,甚至在另一个函数中。事实上,由于作用域的原因,任何类型的嵌套函数都可以访问其父函数中声明的变量。清单 1-5 展示了这个原则。

清单 1-5。可变范围

// Variable declared outside of any function is in global scope and available to access anywhere

var myLibrary = {

myName: "Dennis"

};

function doSomething() {

// Variable declared within a function is not accessible outside that function

var innerVariable = 123;

// The global variable is accessible from within the function

myLibrary.myName = "Hello";

function doSomethingElse() {

// Variables declared in a surrounding scope are accessible

innerVariable = 1234;

}

doSomethingElse();

alert(innerVariable); // 1234

}

doSomething();

// This property was overridden within the doSomething function

alert(myLibrary.myName); // "Hello"

// Trying to access a variable declared within a function from outside results in an error

alert(innerVariable); // ERROR!

上下文和this关键字

JavaScript 保留关键字this用于表示函数的上下文,在大多数情况下,它表示运行时封装函数的对象。当在一个对象之外使用时,它采用全局window对象。在一个对象的方法中使用this引用周围的对象,在本例中是house对象。美妙之处在于,通过引用this而不是对象的变量名,您可以随时轻松地更改变量名,而不会影响它所包含的方法的行为。因为this关键字变成了包围它所在函数的对象的同义词,所以可以在关键字本身上使用点符号,就像在对象上一样。清单 1-6 中的代码演示了上下文和this关键字。

清单 1-6。对 this 关键字使用点标记法

// Outside of any function, 'this' represents the global 'window' object

alert(this === window); // true

// Because the doSomething function is called outside of an object, the keyword this adopts

// the global JavaScript window object in the browser.

function doSomething() {

alert(this === window); // true

}

doSomething();

var house = {

floors: 2,

isLocked: false,

lock: function() {

alert(this === house); // true, as the this keyword represents the object containing this method

// We can treat 'this' as equivalent to the 'house' object, including using dot notation

this.isLocked = true;

}

};

house.lock();

alert(house.isLocked); // true

一个对象中的嵌套函数采用全局window对象,而不是周围的对象,这可能不是您所期望的行为,并且让许多人感到困惑。通过创建一个变量来存储包含周围对象的关键字this的值,然后使用该变量代替周围对象的名称,就可以解决这个问题。许多开发人员选择使用一个名为that的变量来存储对象的引用,如清单 1-7 所示。

清单 1-7。将 this 关键字的值存储到变量中

var apartment = {

isLocked: false,

lock: function() {

var that = this;

// Set the isLocked property

this.isLocked = true;

function doSomething() {

alert(this === apartment); // false

alert(this === window); // true

alert(that === apartment); // true

// Overwrite the isLocked property of the object,

// accessing it through the stored variable

that.isLocked = false;

}

doSomething();

}

};

apartment.lock();

alert(apartment.isLocked); // false

当与new关键字一起使用时,this关键字具有不同的值。在这种情况下,它指的是从构造函数创建的对象实例。因此,我们可以利用这种行为来设置所有对象实例的构造属性和方法,而不是使用prototype关键字,如清单 1-8 所示。

清单 1-8。在构造函数中使用 this 关键字

// Define a new constructor to represent a type of accommodation

function Accommodation() {

// The 'this' keyword refers to the individual object instance created from this "class"

this.floors = 0;

this.rooms = 0;

this.sharedEntrance = false;

this.isLocked = false;

this.lock = function() {

// Using this within a function refers to its surrounding object, which in this

// case refers to the object instance, since it's that which calls the method

this.isLocked = true;

};

this.unlock = function() {

this.isLocked = false;

};

}

// Create object instances from the constructor

var house = new Accommodation();

var apartment = new Accommodation();

// Read and write properties and execute methods as normal with these object instances

alert(house.floors); // 0

house.floors = 2;

apartment.lock();

JavaScript 开发人员为他们的对象实例声明属性和方法的最常见方式是结合使用prototype关键字和this关键字,前者用于方法,后者用于属性。每次从构造函数创建一个新的对象实例时,都会执行构造函数。这种组合方法用于避免每次实例化对象时都需要执行代码来初始化方法。通过在prototype关键字上定义方法,它们只被定义一次,并且可用于从该构造函数创建的每个对象,这使得对象创建更加有效。分配给prototype的方法可以引用this来获得对实例化对象的引用,如清单 1-9 所示。

清单 1-9。使用 this 和 prototype 关键字的组合来创建有效的构造函数

// Create a constructor function to represent types of accommodation

function Accommodation() {

// Use the this keyword to set properties on the instantiated object

this.floors = 0;

this.isLocked = false;

}

// Define methods for instantiated objects using the prototype keyword

Accommodation.prototype.lock = function() {

// Methods can refer to the this keyword to reach those properties created

// in the constructor function

this.isLocked = true;

};

Accommodation.prototype.unlock = function() {

this.isLocked = false;

};

// Instantiate an object of the Accommodation type

var house = new Accommodation();

// Execute the 'lock' method

house.lock();

// Check that the 'isLocked' property was set as expected

alert(house.isLocked); // true

开发人员更喜欢在构造函数中使用this设置属性的另一个原因是,它允许在执行时根据传递给构造函数的值来初始化某些属性。我个人更喜欢对那些我可能希望在创建时初始化的属性使用this关键字,并使用 prototype 和我的方法来设置其他属性。这样,构造函数就清除了在对象实例化时实际上不需要执行的任何代码,从而提高了代码的效率,如清单 1-10 所示。

清单 1-10。在构造函数中使用 this 关键字初始化属性

// Define a constructor function with three parameters representing values to initialize

// properties of the instantiated object with

function Accommodation(floors, rooms, sharedEntrance) {

// Initialize three properties with values passed in when an object is instantiated

// from this "class". The Logical OR operation - || - allows a default value to be specified

// in case no value is passed in

this.floors = floors || 0;

this.rooms = rooms || 0;

this.sharedEntrance = sharedEntrance || false;

}

// Properties that don't need values set at instantiation time should be set with prototype

// as these are then defined and executed only once.

Accommodation.prototype.isLocked = false;

Accommodation.prototype.lock = function() {

this.isLocked = true;

};

Accommodation.prototype.unlock = function() {

this.isLocked = false;

};

// Instantiate an object from the "class", passing in two out of the possible three values

// for initialization. Arguments are passed in the order defined on the constructor function

var house = new Accommodation(2, 7);

alert(house.floors); // 2

alert(house.rooms); // 7

// A value for sharedEntrance wasn't passed into the constructor function, so its value

// defaults to false because of the Logical OR operation in the constructor function – see above

alert(house.sharedEntrance); // false

随着“类”的增长,您可能会发现需要向构造函数传递一些参数,以便在对象实例中设置属性的初始值。虽然依次列出每个参数对于少量的函数输入很好,但是一旦参数的数量超过三个或四个,它很快就会变得笨拙和混乱。幸运的是,有一个对象文字形式的解决方案。通过将单个参数传递给构造函数,该函数由包含所有初始值的对象文字组成,以设置属性,我们不仅消除了多个函数参数的混淆,还提高了对代码的理解,因为对象文字描述的是名称-值对,而不是未命名的函数输入。这是我向任何需要两个或三个以上输入的函数传递参数的首选方式;您可以在清单 1-11 中看到这一点。

清单 1-11。使用对象文本作为构造函数的输入

function Accommodation(defaults) {

// If no argument is passed, default to an empty object literal

defaults = defaults || {};

// If the defaults object contains a named property, set the property of the

// same name in the object instance to the supplied value, otherwise resort to a default

this.floors = defaults.floors || 0;

this.rooms = defaults.rooms || 0;

this.sharedEntrance = defaults.sharedEntrance || false;

}

Accommodation.prototype.isLocked = false;

Accomodation.prototype.lock = function() {

this.isLocked = true;

};

Accommodation.prototype.unlock = function() {

this.isLocked = false;

};

// Instantiate two objects from the Accommodation "class", passing in named arguments

// through an object literal

var house = new Accommodation({

floors: 2,

rooms: 7

});

var apartment = new Accommodation({

floors: 1,

rooms: 4,

sharedEntrance: true

});

链接方法

我们已经定义了被我们的对象实例采用的方法,这些方法像任何函数一样被执行,方法名后面有左括号和右括号。为了在我们的对象实例上连续执行许多方法,我们目前需要在新的一行上依次执行每一个方法,每次指定对象文字的名称。

house.lock();

house.alarm();

house.unlock();

通过对每个方法做一点小小的改变,我们可以允许方法链接,这意味着一个方法调用可以直接跟随另一个方法调用。如果您使用过 jQuery 库( http://bit.ly/jquerycom ),您可能会看到类似的行为,它允许这种相同类型的方法链接。

house.lock().alarm().unlock();

我们通过在“类”中每个方法末尾的关键字this简单地返回一个对对象实例的引用来做到这一点,如清单 1-12 所示,这返回了准备再次立即使用的对象实例。

清单 1-12。使用 this 关键字链接方法调用

function Accommodation() {}

Accommodation.prototype.isLocked = false;

Accommodation.prototype.lock = function() {

this.isLocked = true;

// By returning the context, we are in fact returning an instance of the object instance

// which called this function. Since that object contains all the methods, we're able to

// call the other methods immediately after calling this one

return this;

};

Accommodation.prototype.unlock = function() {

this.isLocked = false;

return this;

};

Accommodation.prototype.alarm = function() {

alert("Sounding alarm!");

return this;

};

// Create an object instance

var house = new Accommodation();

// Because each method returns its context , which in this case is the object instance, we can

// chain method calls one after another

house.lock().alarm().unlock();

遗产

经典编程语言的一个关键方面是创建新类的能力,这些新类继承或扩展了与它们共享相似逻辑连接的父类的属性和方法。这些被称为子类或子类。这种相同类型的继承在 JavaScript 中也是可能的,尽管与经典语言的方式不尽相同。这里称为原型继承,它利用了 JavaScript 对象的所谓的prototype链,如清单 1-13 所示。

清单 1-13。使用原型继承创建子类

// Define a "class" with two methods

function Accommodation() {}

Accommodation.prototype.lock = function() {};

Accommodation.prototype.unlock = function() {};

// Define a constructor function for what will become our subclass

function House(defaults) {

defaults = defaults || {};

// Initialize the floors property to '2' for all instances of this "class"

this.floors = 2;

// If a 'rooms' property is passed within an object literal to this constructor, use its

// value, otherwise default to 7 rooms

this.rooms = defaults.rooms || 7;

}

// Map an instance of the Accommodation "class" to the prototype of the House "class".

// This executes the constructor function for Accommodation with the 'new' keyword, which

// creates and returns an object containing all its properties and methods. This is passed into

// the prototype of the House "class", making that "class" inherit everything from Accommodation

House.prototype = new Accommodation();

// The 'constructor' property of an object instance points to the constructor function that

// created it. However, by mapping everything from Accommodation to House, we also copied over

// the 'constructor' value, which we now need to reset to point to the new subclass instead.

// If we miss this step, object literals created from the House "class" will report that they

// were created from the Accommodation "class" instead.

House.prototype.constructor = House;

// Create an instance of a House, inheriting properties and methods from Accommodation, also

var myHouse = new House();

// Pass in a value for 'rooms' to set that value at the point of object instantiation

var myNeighborsHouse = new House({

rooms: 8

});

alert(myHouse.rooms); // 7 (the default value set in the House constructor function)

alert(myNeighborsHouse.rooms); // 8

// Methods that were set on Accommodation are also available to objects created from House

myHouse.lock();

myNeighborsHouse.unlock();

// Objects created from House report that fact, thanks to us fixing the 'constructor'

// property earlier

alert(myHouse.constructor === House); // true

alert(myHouse.constructor === Accommodation); // false, since we pointed the constructor to House

// The instanceof keyword looks up the prototype chain, so can also be used to check if an

// object instance is derived from a particular parent "class"

alert(myNeighborsHouse instanceof House); // true

alert(myNeighborsHouse instanceof Accommodation); // true, since House inherits Accommodation

我们使用了prototype关键字将方法和属性添加到构造函数中,然后这些方法和属性将可用于从该构造函数创建的对象实例。如果我们试图引用对象上的一个方法或属性,而这个方法或属性在构造函数的原型上并不存在,JavaScript 不会立即引发错误,而是会首先检查是否有同名的方法或属性存在于当前构造函数所继承的父构造函数上。

Caution

当创建一个子类时,确保将它的constructor属性指向它自己的构造函数,因为默认情况下这将指向父类的构造函数,直接从它的原型复制。

注意到instanceof关键字跟在原型链后面,这意味着它可以识别特定的对象实例是从特定的构造函数创建的,还是从继承该构造函数的任何构造函数创建的。原型链一直延伸到 JavaScript 中内置的Object类型,因为语言中的每个变量最终都是从这个类型继承而来的。

alert(myHouse instanceof House); // true

alert(myHouse instanceof Accommodation); // true, since House inherits Accommodation

alert(myHouse instanceof Object); // true, since objects are inherited from JavaScript's

// built-in Object type

包装

当使用继承创建现有类的变体或专门化时,父“类”的所有属性和方法对子“类”都是可用的。您不需要在子类中声明或定义任何额外的东西就可以使用父类的属性和方法。这种能力被称为封装;子类只需要包含父类属性和方法之外的属性和方法的定义。

多态性

当继承和扩展一个“类”以形成一个新的子类时,您可能会发现您需要用另一个同名的方法来替换一个方法,以执行类似的目的,但是要对该子类进行特定的修改。这就是所谓的多态性,在 JavaScript 中只要重写函数并给它一个与原始方法相同的名字就可以实现,如清单 1-14 所示。

清单 1-14。多态性

// Define our parent Accommodation "class"

function Accommodation() {

this.isLocked = false;

this.isAlarmed = false;

}

// Add methods for common actions to all types of accommodation

Accommodation.prototype.lock = function() {

this.isLocked = true;

};

Accommodation.prototype.unlock = function() {

this.isLocked = false;

};

Accommodation.prototype.alarm = function() {

this.isAlarmed = true;

alert("Alarm activated");

};

Accommodation.prototype.deactivateAlarm = function() {

this.isAlarmed = false;

alert("Alarm deactivated");

};

// Define a subclass for House

function House() {}

// Inherit from Accommodation

House.prototype = new Accommodation();

// Redefine the 'lock' method specifically for the House "class" - known as Polymorphism

House.prototype.lock = function() {

// Execute the 'lock' method from the parent Accommodation "class". We can access this

// directly through the prototype property of the "class" definition. We pass our context

// to the function using the 'call' method of the function, ensuring that any references to

// 'this' within the 'lock' method refer to the current object instance of House

Accommodation.prototype.lock.call(this);

alert(this.isLocked); // true, showing that the call to the lock method above worked as expected

// Call the alarm method, inherited from Accommodation

this.alarm();

};

// Redefine the 'unlock' method in the same way

House.prototype.unlock = function() {

Accommodation.prototype.unlock.call(this);

this.deactivateAlarm();

};

观察我们如何引用我们在新方法中变形的原始方法,只需在父“类”定义的prototype属性中直接引用它。因为该方法包含对其上下文this的引用,所以我们需要确保它引用从该子类创建的对象实例的上下文。我们通过执行call方法来做到这一点,该方法可用于 JavaScript 中的任何函数,用于将上下文从一个函数应用到另一个函数。

JavaScript 函数的applycall方法

我们之前看了一下上下文。JavaScript 中的关键字this指的是当前方法周围的对象,在面向对象的 JavaScript 编程中,它指的是从“类”创建的特定对象实例。

如果您从当前上下文之外的另一个对象调用一个方法,那么该方法中对this的任何引用都将指向它周围的对象,而不是您正在其中执行代码的对象——您已经跳到了一个不同的上下文。当从其他对象调用方法时,我们需要一种方法来维护我们原来的this上下文。JavaScript 通过任何functionapplycall都可以使用的两种类似的方法提供了实现这一点的手段。

我们在前面关于多态性的章节中看到了使用的call方法,这是一种从子类中的父“类”调用函数的方式。在这种情况下,我们将从子类创建的对象实例的上下文直接传递给父类的prototype上的一个方法。在那个方法中任何对this的使用都是指对象实例,所以我们有一种从一个地方到另一个地方应用上下文的方法。如果还需要向函数传递参数,可以在上下文之后列出这些参数。callapply的区别在于apply的参数应该包含在一个数组参数中,而它们应该用call连续列出,用逗号分隔,如清单 1-15 所示。

清单 1-15。函数的 apply 和 call 方法

// Define a simple "class"

function Accommodation() {

this.isAlarmed = false;

}

// Create an object whose functions can be used in conjunction with an object in your code

// – also known as a 'mixin'

var AlarmSystem = {

arm: function(message) {

this.isAlarmed = true;

alert(message);

},

disarm: function(message) {

this.isAlarmed = false;

alert(message);

}

};

var myHouse = new Accommodation();

// Pass the object instance context into the 'arm' function using 'call'.

AlarmSystem.arm.call(myHouse, "Alarm activated");

// The 'arm' function's 'this' value was the object instance , therefore the 'isAlarmed' property

// of myHouse was changed

alert(myHouse.isAlarmed); // true

// The same effect can be achieved using 'apply', this time the parameters are sent as an array

AlarmSystem.disarm.apply(myHouse, ["Alarm deactivated"]);

alert(myHouse.isAlarmed); // false

参数对象

当执行一个函数时,我们传递括号中的任何参数,这些参数可以作为变量在该函数中使用。此外,JavaScript 中有一个保留关键字arguments,它出现在函数中,就像一个数组,包含按顺序传递给函数的参数列表。

假设您有一个函数,您希望使用它将作为参数传递给它的所有数字相加。因为您不希望指定参数的确切数目,所以您可以将它们留空,而是依赖于arguments伪数组,如清单 1-16 所示。我们称它为伪数组,因为它可以在一个for循环中迭代,但是不展示标准数组可用的其他方法,比如排序,在代码中处理它时不需要排序。

清单 1-16。参数对象

// Create a function to add together any parameters ('arguments') passed to it

var add = function() {

// Create a variable to store the total of the addition in

var total = 0;

// The 'arguments' pseudo-array contains the arguments passed into this function.

// Loop through each and add them together to form a total

for (var index = 0, length = arguments.length; index < length; index++) {

total = total + arguments[index];

}

return total;

};

// Try the function out with different numbers of parameters

alert(add(1, 1)); // 2

alert(add(1, 2, 3)); // 6

alert(add(17, 19, 12, 25, 182, 42, 2)); // 299

当与函数apply方法一起使用时,arguments伪数组发挥了自己的作用。因为该方法将参数作为数组传递给函数,所以我们有一种简单的方法从任何其他具有相同输入参数的函数中调用函数——我们有效地将参数从一个函数调用传递给另一个函数调用。这在对象继承和多态中很有用,允许我们将子类的方法的参数传递给父类的类似方法,如清单 1-17 所示。

清单 1-17。在子类中使用伪数组参数

// Define our parent Accommodation "class"

function Accommodation() {

this.isAlarmed = false;

}

Accommodation.prototype.alarm = function(note, time) {

var message = "Alarm activated at " + time + " with the note: " + note;

this.isAlarmed = true;

alert(message);

};

// Define a subclass for House

function House() {

this.isLocked = false;

}

// Inherit from Accommodation

House.prototype = new Accommodation();

// Redefine the 'alarm' method specifically for the House "class". No need to list the arguments

// in the function definition here since we're going to simply pass them through to the same

// method on the parent "class"

House.prototype.alarm = function() {

// Set the 'isLocked' property on this object instance to 'true'

this.isLocked = true;

// Execute the 'alarm' method from the parent Accommodation "class", passing all the

// arguments from the execution of this method onto the parent method – no need to

// explicitly list the arguments!

Accommodation.prototype.alarm.apply(this, arguments );

};

// Create an object instance from the subclass and try it out

var myHouse = new House();

myHouse.alarm("Activating alarm", new Date()); // Alerts "Alarm activated at Fri Feb 14 2014

// 13:02:56 GMT+0100 (BST) with the note:

// Activating alarm"

alert(myHouse.isLocked); // true

对属性和方法的公共、私有和受保护的访问

在我们到目前为止的例子中,我们已经创建了“类”模板,它将属性和方法绑定到构造函数的prototype属性,或者绑定到使用this关键字从“类”创建的对象实例的范围。以这两种方式之一创建的每个属性和方法都被称为是公共的,也就是说,所有属性和方法对于从该“类”创建的所有对象实例都是可用的,因此对于可以访问该对象实例的代码库的任何其他部分也是可用的。

但是,在某些情况下,您可能希望限制某些属性和方法的公开,以便它们不能被自由访问、直接操作或从对象实例本身调用。许多经典编程语言都具有通过将属性和方法定义为 public、private 或 protected 来限制对它们的访问的能力。私有变量或函数不能从类定义之外读取或写入,受保护变量不能直接访问,但可以通过包装方法读取或写入。这样的包装方法通常被称为 getters 和 setters,它们允许你从对象实例中获取变量的值和/或设置它的值。通过只创建一个 getter 函数,可以在类定义之外将变量设为只读。在 JavaScript 中,对于私有或受保护的变量或函数,我们没有特定的符号,但是我们可以通过对声明“类”的方式进行一些更改来减少对属性和方法的访问

在一个构造函数中用var声明一个变量,使得这个变量的作用域仅限于这个函数——任何放在prototype对象上的方法都不能访问它,因为这个方法有自己的作用域。为了允许我们通过公共方法访问私有变量,我们需要创建一个包含两者的新范围。我们通过创建一个称为闭包的自执行函数来做到这一点,它完全包含了“类”的定义、私有变量和原型方法,如清单 1-18 所示。

尽管 JavaScript 语言中并不要求,但一个好的惯例是在任何私有变量或函数名前面加上一个下划线字符(_)来表示它是私有的。这将有助于您和项目中的其他开发人员更好地理解每个“类”的开发人员的意图

清单 1-18。公共、私有和受保护的属性和方法

// We wrap our "class" definition code in a self-executing function which returns the "class" we

// create and places it into a variable for use throughout the rest of our code.

var Accommodation = (function() {

// Create our constructor function for our "class". Since we are inside a new function, we

// have a new scope, therefore we can use the same name as the variable we are returning

// our "class" to, for use in the rest of our code

function Accommodation() {}

// Any variable defined here is considered 'private', it isn't available outside this scope

// We can denote it as such by prefixing its name with an underscore.

var _isLocked = false,

_isAlarmed = false,

_alarmMessage = "Alarm activated!";

// Any function defined in this scope only (not on the prototype of the constructor

// function), is considered 'private' also

function _alarm() {

_isAlarmed = true;

alert(_alarmMessage);

}

function _disableAlarm() {

_isAlarmed = false;

}

// Any method placed on the prototype is going to be 'public', accessible outside this scope

// once the "class" is returned later on in this closure

Accommodation.prototype.lock = function() {

_isLocked = true;

_alarm();

};

Accommodation.prototype.unlock = function() {

_isLocked = false;

_disableAlarm();

};

// Create a 'getter' function to allow public read-only access to the value inside the

// private variable 'isLocked' – effectively making this variable 'protected'

Accommodation.prototype.getIsLocked = function() {

return _isLocked;

};

// Create a 'setter' function to allow public write-only access to the '_alarmMessage'

// private variable – effectively making it 'protected'

Accommodation.prototype.setAlarmMessage = function(message) {

_alarmMessage = message;

};

// Return the "class" we created in this scope to make it available to the surrounding scope

// and hence the rest of our code. Only the public properties and methods will be available

return Accommodation;

}());

// Create an object instance

var house = new Accommodation();

house.lock();  // Alerts “Alarm activated”

house._alarm();  // error! The '_alarm' function was never exposed publicly so it's not

// available directly to any object instance created from the "class"

alert(house._isLocked);// undefined ('_isLocked' is private and cannot be accessed outside

// the closure)

house.getIsLocked(); // true (returns the value of the '_isLocked' variable, but doesn't allow

// direct access to it, so it's a read-only value)

house.setAlarmMessage("The alarm is now activated!");

house.lock();  // Alerts "The alarm is now activated"

一般来说,您应该将所有变量和函数声明为私有的,除非您特别需要公开它们。即使这样,也要考虑使用 getter 和/或 setter 方法来访问变量,将其他人对您的“类”的操作限制在严格要求的范围内,这将减少他们的代码出错的机会。

简化继承

我们可以通过定义一个基础“类”来简化对象的构造和继承,从这个基础“类”可以创建所有其他的“类”。通过给这个“类”一个从自身继承的方法,并允许子类通过一个属性访问父类,我们使得创建和使用子类的任务变得更加简单。我们还可以将原型上设置方法的所有代码包装在一个对象文字中,甚至可以在该文字中包含我们的构造函数,从而使“类”的创建变得轻而易举。研究清单 1-19 中的代码,并随意在自己的项目中使用它来简化“类”的创建

清单 1-19。简化其他“类”创建的基础“类”

// Define an object called Class with a create() method for use creating "classes".

// Use a closure to maintain inner functions without exposing them publicly.

var Class = (function() {

// The create() method defines and returns a new "class" when called, based on an object

// literal representing the public properties and methods for its prototype. A method named

// initialize() will be executed as the constructor function. If an optional

// 'parentPrototype' property is passed in, representing a parent "class", it creates the

// new "class" as a subclass of that.

function create(classDefinition, parentPrototype) {

// Define the constructor function of a new "class", using the initialize() method from

// the 'classDefinition' object literal if it exists

var _NewClass = function() {

if (this.initialize && typeof this.initialize === 'function') {

this.initialize.apply(this, arguments);

}

},

_name;

// If a 'parentPrototype' object has been passed in (when inheriting from other

// "classes"), inherit everything from the parent to this subclass

if (parentPrototype) {

_NewClass.prototype = new parentPrototype.constructor();

for (_name in parentPrototype) {

if (parentPrototype.hasOwnProperty(_name)) {

_NewClass.prototype[_name] = parentPrototype[_name];

}

}

}

// Define a function to create a closure and return a function to replace the one

// passed in, wrapping it and providing a __parent() method which points to the

// method of the same name from a parent "class", to enable support for polymorphism

function polymorph(thisFunction, parentFunction) {

return function () {

var output;

this.__parent = parentFunction;

output = thisFunction.apply(this, arguments);

delete this.__parent;

return output;

};

}

// Apply the newly provided "class" definition, overriding anything that already exists

// from the parentPrototype

for (_name in classDefinition) {

if (classDefinition.hasOwnProperty(_name)) {

// If we're attempting polymorphism, creating new methods named the same as

// ones from the parent "class", then we want to expose a way of calling the

// parent function of the same name in a simple way

if (parentPrototype && parentPrototype[_name] &&

typeof classDefinition[_name] === 'function') {

_NewClass.prototype[_name] = polymorph(classDefinition[_name], parentPrototype[_name]);

} else {

// If we're not attempting polymorphism, just map over the entry from the

// 'classDefinition' object literal to the prototype directly

_NewClass.prototype[_name] = classDefinition[_name];

}

}

}

// Ensure the constructor is set correctly, whether inherited or not (in case a

// 'constructor' property or method was passed in the 'classDefinition' object literal)

_NewClass.prototype.constructor = _NewClass;

// Define an extend() method on the "class" itself, pointing to the private extend()

// function, below, which allows the current "class" to be used as a parent for

// a new subclass

_NewClass.extend = extend;

return _NewClass;

}

// The extend() method is the same as the create() method but with an additional parameter

// containing the prototype from the parent "class" for inheriting from

function extend(classDefinition) {

return create(classDefinition, this.prototype);

}

// Expose the private create() method publicly under the same name

return {

create: create

};

}());

我们可以使用这个基础“类”创建器以一种易于理解的方式创建和继承类,如清单 1-20 所示。

清单 1-20。运行中的基本“类”创建者

// Define a "class" using Class.create, passing an object literal representing the public

// properties and methods to be made available to that "class". The 'initialize' method will

// become the constructor function of the new "class"

var Accommodation = Class.create({

isLocked: true,

isAlarmed: true,

lock: function() {

this.isLocked = true;

},

unlock: function() {

this.isLocked = false;

},

initialize: function() {

this.unlock();

}

});

// Create a subclass of Accommodation, using the 'extend' method that Class.create adds to any

// "classes" it creates, for simple inheritance. All the public properties and methods from the

// parent "class" are available to the subclass, with those of the same name overriding those

// from the parent.

var House = Accommodation.extend({

floors: 2,

lock: function() {

// Even though we're using polymorphism to replace the parent "class" of the same name,

// we can still access that parent "class" method using 'this.parent()'

this._parent();

alert("Number of floors locked: " + this.floors);

}

});

// Create objec t instances from the new "classes"

var myAccommodation = new Accommodation();

alert(myAccommodation instanceof Accommodation); // true

alert(myAccommodation instanceof House); // false

var myHouse = new House();

alert(myHouse.isLocked); // false (set by the parent "class"'s initialize method,

// inherited by House)

myHouse.lock(); // Alerts “Number of floors locked: 2”

alert(myHouse.isLocked); // true

alert(myHouse instanceof House); // true

alert(myHouse instanceof Accommodation); // true

编码约定和命名

现在,我们已经介绍了 JavaScript 中面向对象编程的复杂性,让我们看看编码约定,以及我们命名变量和函数的方式,以暗示含义并确保大型团队中的所有开发人员以相似的方式编码。

JavaScript 允许您将变量存储在内存中,以便在整个代码中重用,方法是使用保留字var,后跟变量名和一个可选的初始值。类似地,通过在名字前面加上function关键字,可以将函数存储在内存中以便重复执行。只要遵守以下规则,您可以随意命名变量和函数:

其名称必须以下列之一开头:

  • 一个字母,例如a-zA-Z
  • 一个下划线字符,_
  • $美元符号

在变量名的第一个字符后,允许数字(0-9)与上述数字一起出现。

以下是 JavaScript 中有效变量和函数名称的所有示例:

var a;

function A() {};

var a1;

function _() {};

var _a;

function $() {};

var $_$;

除了这些固定的规则之外,作为开发人员,我们希望在工作和维护时确保代码是可读和可理解的,因此我们使用许多开发人员和编程语言通用的命名约定。通过坚持这些变量命名的规则,您将理解如何在您的代码中使用变量,并且您也将更好地理解其他人编写的代码。

规则 1:使用描述性名称

这条规则是最重要的,所以我选择首先强调它。变量名用于表示存储在其中的数据,因此选择一个最能描述其用途的名称将有助于提高代码的可读性,从而使代码更容易理解,如下面的示例所示:

var greeting = “Hello, world”;

规则 2:以小写字母开头

用小写字母开始你的变量名,然后尽可能用小写字母继续你的变量名。这样做可以避免与 JavaScript 的内置类型和对象混淆,这些类型和对象都以大写字母开头,例如,StringObjectMath。例如:

var age = 35;

我在编码中使用的这条规则有一些特殊的例外。首先,当使用 jQuery 时,我更喜欢将定位的 DOM 元素存储在变量中,以避免在我的代码中稍后从页面中再次查找它们的需要。在这种情况下,我在这些变量名前面加上$,以区分 DOM 节点和代码中的其他变量。然后,$字符后的变量名称的剩余部分遵循与其他变量相同的规则,例如:

var $body = $(document.body);

第二个例外是命名要在代码中用作构造函数的函数。我们稍后将更详细地讨论构造函数,但本质上 JavaScript 中的内置类型是构造函数,例如StringNumberBoolean等等。本质上,这些是任何期望与关键字new一起使用的函数。这些名称的首字母应该大写,如下所示:

function MyType() {};

var myTypeInstance = new MyType();

第三个例外是,如前所述,当在一个构造函数中命名被设计为私有的变量和函数时,要在它们的名字前加上一个下划线(_)字符,以区别于那些打算公开的变量和函数。

function MyType() {

var _myPrivateVariable;

};

var myTypeInstance = new MyType();

规则 3:使用大小写来表示单词划分

规则 1 指导我们使用描述性名称,但是如果我们只使用小写字符,例如,当我们到达单词的边界时,我们创建的变量名将很难阅读。

var myemailaddress = "den.odell@me.com";

在变量名的第一个字母上,除了第一个单词之外,使用大写字符,这样名称更容易阅读,如下所示。

var myEmailAddress = "den.odell@me.com";

规则 4:使用全部大写字符来表示通用常量

这条规则涉及到通常在计算中使用的幻数。这些是在处理日期和时间,或者基于真实世界的常量值(如 Pi)进行计算时经常使用的杂散数字。许多开发人员只是在需要的时候使用这些数字,这是可行的,但经常会导致混乱。看看下面的例子,它演示了这一点。

var today = new Date(),

todayInDays = today * 1000 * 60 * 60 * 24;

如果不仔细检查,快速浏览一下这个代码示例就会发现一系列用途不明的数字。通过创建变量并命名这些数字,代码变得更容易理解。我们使用所有大写字符来表示这些是固定的数值,也称为常数,虽然这是许多其他编程语言的一个特性,但不是 JavaScript 中的特性,并且使用下划线(_)字符来实现单词分隔,如下面给出的更新示例所示:

var today = new Date(),

MILLISECS_IN_1_SEC = 1000,

SECS_IN_1_MIN = 60,

MINS_IN_1_HOUR = 60,

HOURS_IN_1_DAY = 24,

todayInDays = today * MILLISECS_IN_1_SEC * SECS_IN_1_MIN * MINS_IN_1_HOUR * HOURS_IN_1_DAY;

不可否认,这产生了更多的代码,但是我相信这是值得的,因为它提供了可读性。每个声明的常数可以在整个代码中重用,使得计算更容易理解。

规则 5:将变量声明放在每个函数块顶部的一个语句中

JavaScript 允许使用var关键字同时声明多个变量,通过用逗号(,)字符分割每个变量声明。明智的做法是确保在尝试使用变量之前声明它们,以避免在运行代码时出现错误。因此,我建议您将所有使用的变量声明为任何函数块或 JavaScript 文件的顶部,并将它们组合成一条语句。请注意,您不必将值初始化到稍后要初始化的任何变量中,只需预先声明所有变量。用逗号和换行符分隔每个变量,并将每个变量名称的开头与前一个对齐,以确保可读性,如以下示例所示:

var myString = "Hello, world",

allStrongTags = /<strong>(.*?)</strong>/g,

tagContents = "&1",

outputString;

outputString = myString.replace(allStrongTags, tagContents);

变量和函数名托管

在许多其他常见的编程语言中,变量可以在任何代码块中定义,如for循环或任何通常用花括号{}表示的代码块,并且它们的范围仅限于该代码块。然而,在 JavaScript 中,我们知道范围仅限于函数级,然而这可能会使一些习惯于用其他语言编程的开发人员出错;清单 1-21 展示了这一点。

清单 1-21。代码块和范围

function myFunction() {

var myArray = ['January', 'February', 'March', 'April', 'May'],

myArrayLength = myArray.length,

counter = 0;

for (var index = 0; index < myArrayLength; index++) {

// Increment counter each time around the loop

counter = index + 1;

}

// The values of the variables should be as expected

alert(counter); // 5

alert(index); // 5 (since the loop increments before testing its condition)

alert(myArrayLength); // 5

if (myArrayLength > 0) {

// In many languages, defining variables in a code block like this keeps their scope

// locked to that code block. Not so in JavaScript, so beware defining variables locally

// to code blocks in this way

var counter,

index = 0,

myArrayLength;

counter = 0;

}

// The values of 'counter' and 'index' were altered within the 'if' statement, regardless of

// the use of the 'var' statement in that code block

alert(counter); // 0

alert(index); // 0

// Note that the value of 'myArrayLength' has not changed, despite it being redefined within

// the code block with the 'var' statement. This is because variable names are 'hoisted' to

// the top of functions by JavaScript before the function executes

alert(myArrayLength); // 5

}

// Execute the defined function

myFunction();

JavaScript 展示了一种有趣的行为,通常被称为提升,在这种行为中,变量和函数声明在内部被提升到定义它们的函数块的顶部。这意味着任何变量名定义都是有效可用的,尽管不一定从其周围范围(通常是一个函数)的顶部初始化为一个值。为了最小化这可能导致的任何奇怪的影响,建议在任何函数开始时都列出该函数中要使用的所有变量,无论是初始化的还是其他的,因为这最好地模仿了 JavaScript 提升的内部行为,并减少了在未知变量定义和值代码中混淆的机会;清单 1-22 显示了这一点。

清单 1-22。以函数中使用的变量开始所有函数

function myFunction() {

// All variables defined up front in the function to avoid being tripped up by hoisting

var myArray = ['January', 'February', 'March', 'April', 'May'],

myArrayLength = myArray.length,

counter = 0,

index = 0;

// The first statement within the for loop definition, which would normally be used to

// initialize a variable can be skipped now we've moved all variable declarations to

// the top of the function

for (; index < myArrayLength; index++) {

counter = index +1;

}

// The values of the variables should be as expected

alert(counter); // 5

alert(index); // 5

alert(myArrayLength); // 5

}

// Execute the function

myFunction();

这同样适用于函数,由于提升,函数的名字在它们当前作用域的任何地方都是可用的,甚至在它们定义之前,如清单 1-23 所示。

清单 1-23。功能提升

function myFunction() {

// Executing a function before its definition is possible due to 'hoisting' in JavaScript

doSomething(); // The function below is executed

function doSomething() {

alert("Doing something");

}

}

myFunction();

ECMAScript 5

1996 年,JavaScript 的创始人 Brendan Eich 将他的语言提交给标准机构 Ecma international 进行审核和标准化,该语言的第一个官方版本 ECMAScript 于 1997 年发布。JavaScript 不是 ECMAScript 的唯一实现;多年来,Adobe 一直在他们的 Flash 和 Flex 产品中使用一种称为 ActionScript 的风格。

从 1999 年开始,Ecma International 做了很少的工作,当时他们推出了该语言的第三个版本,在此期间没有看到该语言本身的变化,主要是因为对语言复杂性的分歧;出于这些原因,计划中的第四版被放弃了。这种情况在 2009 年底发生了变化,当时该组织发布了该语言的第五个版本(他们需要与他们的内部编号保持一致,尽管第四个版本被放弃了)。第六个版本正在计划中,尽管在撰写本文时还不知道发布日期。

由于浏览器制造商已经转向更有规律的发布时间表,尽管 ECMAScript 5 才推出两年,但浏览器对 ECMAScript 5 的支持却出人意料地好,各大浏览器制造商的最新版本产品都支持 ECMAScript 5。您可以通过访问 http://bit.ly/ecma_compat 查看最新的功能支持矩阵。

作为一名试用者,我将在这一部分重点介绍该规范的几个主要特性,但是我鼓励您通过 http://bit.ly/ecma_5 在 ECMAScript 官方网站上阅读更多详细信息。

JSON 数据格式解析

大多数专业 JavaScript 开发人员以前都会遇到 JSON 格式的数据。这是以字符串形式存储的数据,或者存储在文本文件中的数据,其格式非常类似于对象文字,例如下面显示的 JSON 格式的数据结构:

{

"success": false,

"error_message": "The wrong parameters were passed to this web service."

}

将这样的字符串转换成 JavaScript 对象文字,以便在函数中使用,这些函数通常涉及下载并包含 Doug Crockford 的 JSON 解析库,来自 http://bit.ly/crock_json 的 json.js。然而,ECMAScript 5 将这个库嵌入到 JavaScript 语言中,使得以下函数可用于将 JSON 数据转换为对象文字,反之亦然:

JSON.parse(stringOfJSONFormattedData); // returns an object literal

JSON.stringify(objectLiteral); // returns a string of JSON-formatted data

严格模式

ECMAScript 5 引入了将函数或整个 JavaScript 文件置于新的严格编码模式的能力,只需在文件或函数中放置以下字符串。

"use strict";

包含该字符串的文件或函数中的任何代码都将遵循更严格的语言规则,这应该有助于避免潜在的错误和陷阱。现在,如果你试图使用一个没有定义的变量,JavaScript 将会在执行严格模式下的代码时抛出一个错误。如果您试图使用一个包含两个相同命名属性的对象文字(这是我以前犯过的一个错误),如果您试图在变量或函数上使用delete关键字,而不是在一个想要使用该关键字的对象属性上,它也会报错。严格模式也将禁止使用eval来执行包含 JavaScript 代码的字符串,因为这可能是一个安全隐患,并从您自己编写的代码中夺走对代码的控制。

由一个简单的字符串强制执行的严格模式的优点是,旧的浏览器在遇到代码中的语句时不会出错,它们会简单地将它作为一个字符串执行,并且因为它没有被赋给变量值,所以会有效地忽略它。比较清单 1-24 中的两个函数,一个在正常模式下,一个在严格模式下。我已经开始在我的所有代码中使用这种新的严格模式,以确保我的代码质量足够高,我也向您推荐这种模式。

清单 1-24。演示 ECMAScript 5 严格模式

// Define a function

function myFunction() {

// Using a previously undefined variable will implicitly create it as a global variable

counter = 1;

// Executing strings of JavaScript code using eval() throws no errors

eval("alert(counter)");

// The delete keyword is for removing properties and methods from an object, but

// calling it on a variable throws no error

delete counter;

}

// Execute the function

myFunction();

// Redefine the same function using ECMAScript 5 strict mode

function myFunction() {

// Enforce strict mode for the code within this function

"use strict";

counter = 1; // Throws an error when executed, since the 'counter' variable was not defined

eval("alert(counter)"); // Throws an error as 'eval' is to be avoided for security reasons

delete counter; // Throws an error since the 'delete' keyword is only to be used for

// removing named properties and methods from object literals

}

// Execute the function

myFunction();

功能连接

我们已经看了 JavaScript 中所有函数可用的applycall方法。ECMAScript 5 添加了另一个方法bind,它不执行函数,而是返回一个新函数,函数的上下文设置为传递到调用bind的第一个参数中的任何对象。

您可能在自己的代码中遇到过这种需求,特别是在引用事件处理程序时。如果您的事件处理函数是对象实例上的一个方法,您可能会尝试将它与您的处理函数一起使用,以执行另一个方法或访问对象实例上的一个属性。如果您尝试这样做,您可能会意识到事件处理程序的上下文是对发生的事件的引用,而不是方法的对象实例。这个函数的新的bind方法允许你改变这个行为,根据需要把你自己的上下文传递给处理程序,如清单 1-25 所示。

清单 1-25。函数绑定方法

var header = document.createElement("header"),

mouseState = "up",

// Define an object containing three methods

eventHandlers = {

onClick: function() {

// If the context is wrong when 'onClick' is called, the next two calls will fail

this.onMouseDown();

this.onMouseUp();

},

onMouseDown: function() {

mouseState = "down";

},

onMouseUp: function() {

mouseState = "up";

}

};

// Force the correct context for 'eventHandlers.onClick' by using 'bind' to return a new

// function, bound to the context we require

header.addEventListener("click", eventHandlers.onClick.bind(eventHandlers), false);

// Add the <header> element to the page

document.body.appendChild(header);

数组方法

大多数专业 JavaScript 开发人员每天都使用数组来循环、排序和组织数据。ECMAScript 5 为 JavaScript 开发人员的工具包提供了一些急需的新方法,用于处理这些类型的数据结构。

首先,也可能是最重要的,是能够轻松确定变量是否包含数组数据。这听起来可能很奇怪,但是要检测一个变量是否真的包含数组数据,需要将它转换成一个对象类型,并以字符串形式读出它的值——太疯狂了!要在 ECMAScript 5 中检测一个变量是否包含数组数据,只需调用Array.isArray方法,如清单 1-26 所示。

清单 1-26。ECMAScript 5 是一个数组方法

var months = ["January", "Febraury", "March", "April", "May"],

items = {

"0": "January",

"1": "February",

"2": "March",

"3": "April",

"4": "May"

};

alert(Array.isArray(months)); // true

alert(Array.isArray(items)); // false

遍历数组目前包括创建一个for循环,并遍历某种类型的索引计数器。ECMAScript 5 引入了一个新的forEach方法,允许更简单的循环;给这个方法提供一个函数,它对数组中的每一项执行一次这个函数,传入迭代的当前值、索引,最后传入对整个数组的引用,如清单 1-27 所示。

清单 1-27。ECMAScript 5 forEach 方法

var months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];

// The forEach method allows you to loop through each item in an array, executing a function

// each time

months.forEach(function(value, index, fullArray) {

alert(value + " is month number " + (index + 1) + " of " + fullArray.length);

});

如果您曾经需要确定数组中的每个元素是否满足由函数定义的特定条件,那么您已经等待 ECMAScript 5 的新方法every太久了。如果数组中至少有一项匹配给定条件,类似的some方法将返回trueeverysome方法都采用与forEach方法相同的参数,如清单 1-28 所示。

清单 1-28。ECMAScript 5 every 和一些方法

var months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"],

// The every method loops through each item in an array, comparing it to a

// condition. If the condition returns true for every item in the array, the

// every method returns true, otherwise it returns false

everyItemContainsR = months.every(function(value, index, fullArray) {

// returns a true or false value indicating whether the current

// iteration matches your condition, in this case whether the value contains

// the letter 'r'

return value.indexOf("r") >= 0;

}),

// The some method loops through each item in an array, comparing it to a

// condition. If the condition returns true for any item in the array, the

// some method returns true, otherwise it returns false

someItemContainsR = months.some(function(value, index, fullArray) {

return value.indexOf("r") >= 0;

});

// Not every item contains the letter 'r'…

alert(everyItemContainsR); // false

// …but some do!

alert(someItemContainsR); // true

新的map方法允许你从一个现有的数组创建一个全新的数组,在新数组形成时对每个项目执行一次函数,如清单 1-29 所示。

清单 1-29。ECMAscript 5 映射方法

var daysOfTheWeek = ["Monday", "Tuesday", "Wednesday"],

// The map method allows a whole new array to be created by looping through an existing one,

// executing a function for each item to determine the equivalent item in the new array

daysFirstLetters = daysOfTheWeek.map(function(value, index, fullArray) {

return value + " starts with " + value.charAt(0);

});

alert(daysFirstLetters.join(", ")); // "Monday starts with M, Tuesday starts with T,

// Wednesday starts with W"

ECMAScript 5 的新的filter array 方法创建了一个新的数组,就像map一样,但是只有符合特定条件的项目才被允许进入新的数组,如清单 1-30 所示。

清单 1-30。ECMAScript 5 筛选方法

var months = ["January", "February", "March", "April", "May"],

// The filter method creates a cut-down array from an original array, only permitting those

// items that match a certain condition into the new array

monthsContainingR = months.filter (function(value, index, fullArray) {

// return a true or false value indicating whether the current array item should be

// included in your filtered array, i.e. whether its value contains the letter 'r'

return value.indexOf("r") >= 0;

});

// The only month that didn't contain the letter 'r' was 'May'

alert(monthsContainingR.join(", ")); // "January, February, March, April"

对象方法

ECMAScript 5 引入了对 native Object类型的大量扩展,带来了许多其他编程语言中存在的 JavaScript 能力。首先,如果使用严格模式,它引入了锁定一个对象的能力,这样通过一个新的Object.preventExtensions方法和一个关联的Object.isExtensible方法,在你的代码中的某个点之后就不能添加额外的属性或方法,它允许你检测一个对象是否能够被添加,如清单 1-31 所示。

清单 1-31。ECMAScript 5 对象方法

// Define a simple object with two properties

var personalDetails = {

name: "Den Odell",

email: "den.odell@me.com"

};

alert(Object.isExtensible(personalDetails)); // true, as by default all objects can be extended

// Prevent the 'personalDetails' object being added to

Object.preventExtensions(personalDetails);

alert(Object.isExtensible(personalDetails)); // false, as the object is now locked down

// Attempt to add a new property to the 'personalDetails' object

personalDetails.age = 35; // Throws an exception if using 'strict' mode as the object is locked

如果你想锁定一个对象,甚至它现有的属性都不能被修改,你可以通过 ECMAScript 5 的新的Object.freeze方法,如清单 1-32 所示。

清单 1-32。ECMAScript 5 对象冻结方法

// Define a simple object with two properties

var personalDetails = {

name: "Den Odell",

email: "den.odell@me.com"

};

// Lock down the object so that not even its existing properties can be manipulated

Object.freeze(personalDetails);

alert(Object.isFrozen(personalDetails)); // true

personalDetails.name = "John Odell"; // Throws an error if using strict mode as the object

// cannot be altered once frozen

对象的每个属性现在都有一组选项,这些选项决定了如何在代码的其余部分使用它,称为属性描述符,表示为具有四个属性的对象文字。要读取属性的描述符,使用新的Object.getOwnPropertyDescriptior方法,如清单 1-33 所示。除了value属性之外,描述符中的所有属性都默认为true

清单 1-33。ECMAScript 5 对象 getOwnPropertyDescriptor 方法

// Define a simple object with two properties

var personalDetails = {

name: "Den Odell",

email: "den.odell@me.com"

};

Object.getOwnPropertyDescriptor(personalDetails, "name");

// Returns the following object literal representing the 'name' property:

// {

//  configurable: true,

//  enumerable: true,

//  value: "Den Odell",

//  writable: true

// }

使用 ECMAscript 5,您可以创建属性并同时定义它们的属性描述符,如清单 1-34 所示。

清单 1-34。ECMAScript 5 属性定义

// Define a simple object with two properties

var personalDetails = {

name: "Den Odell",

email: "den.odell@me.com"

};

// Define a new individual property for the object

Object.defineProperty(personalDetails, "age", {

value: 35,

writable: false,

enumerable: true,

configurable: true

});

// Define multiple new properties at the same time

Object.defineProperty (personalDetails, {

age: {

value: 35,

writable: false,

enumerable: true,

configurable: true

},

town: {

value: "London",

writable: true

}

});

如果你需要生成一个在对象中使用的属性名的数组,ECMAScript 5 有新的Object.keys方法支持你,如清单 1-35 所示。

清单 1-35。ECMAScript 5 对象键方法

// Define a simple object with two properties

var personalDetails = {

name: "Den Odell",

email: "den.odell@me.com"

},

keys = Object.keys(personalDetails);

alert(keys.join(", ")); // "name, email"

ECMAScript 5 的Object.create方法提供了一个强大的新方法,用于从另一个对象的属性创建新对象。这个方法的一个可能的用途是复制一个现有的对象,如清单 1-36 所示。

清单 1-36。ECMAScript 5 对象创建方法

// Define a simple object with two properties

var personalDetails = {

firstName: "Den",

lastName: "Odell"

},

// Create a duplicate of this object

fathersDetails = Object.create(personalDetails);

// Customize the duplicated object

fathersDetails.firstName = "John";

// The properties set via the original object are still intact

alert(fathersDetails.lastName); // "Odell"

如果 ECMAScript 5 中有一个新方法,你应该多研究一下,那就是Object.create方法,因为它可以在我们本章所讨论的内容之前,打开一个全新的对象继承世界。ECMAScript5 提供了极大简化代码运行和可读性的机会,这只是学习如何最好地使用它的一个例子。

摘要

在本章中,我们已经介绍了面向对象 JavaScript 编程的基础知识,包括对象、“类”、继承以及其他经典编程语言中流行的其他编程特性。我分享了我在变量和函数命名约定方面的一些经验,以及我发现最有效的一些经验。最后,我们介绍了 ECMAScript 5,这是浏览器制造商支持的 JavaScript fast 的最新版本。

在下一章中,我们将探讨如何以一致和可读的方式记录代码,使代码更容易理解,从而更容易使用,从而更好地帮助自己和其他开发人员。

二、使用文档记录 JavaScript

在前一章,我们看了面向对象的 JavaScript 和编码惯例。编写面向对象代码和建立约定的目的是确保代码对开发人员来说清晰易懂。浏览器中的 JavaScript 引擎并不关心你的代码写得有多整洁,或者对你来说是否有意义——它只是遵循一套规则。对你和你的团队来说,更重要的是理解你写的代码以及如何使用它,因为这简化了维护你的代码库的任务。代码可维护性意味着任何规模的团队都可以在同一组文件上协作,并对如何添加、修改和删除部分代码有共同的理解,从而获得一致的结果。除了代码本身可以理解之外,你会发现你需要添加小的注释或大块的文档来向其他开发人员解释,包括你自己(如果你将来不记得你的推理),代码的特定部分执行什么任务以及如何使用它。

有两类用户受益于好的文档,这取决于您的代码的目标受众。第一组是你自己和你的项目合作者。好的文档让每个人都知道你的代码是做什么的,它是如何做的,以及为什么要做,从而减少混乱和引入错误的机会。

第二组是其他开发者,与你的项目无关。如果您的项目将公共函数公开为某种 API(应用编程接口),那么您将需要确保您的文档是最新的、可读的和可理解的,最好有工作示例来帮助其他开发人员采用它。想象一下,如果 jQuery 的文档质量很差,它肯定不会像现在这样迅速而容易地被采用。良好、全面的文档是让更多开发人员采用您的代码的关键。

文档可以采取多种形式,从代码文件中的一些关键注释到专门描述代码库的整个网站;每一种都更适合不同的情况。本章将主要关注文档的结构化形式,在 JavaScript 代码文件的注释中使用特殊格式的标记,并使用这种结构生成一个完全可用的文档网站,而无需编写任何 HTML 或 CSS 代码。

内嵌和阻止注释

有时候代码本身并不能解释到底发生了什么,你不能逃避给你的代码添加注释以使代码更容易理解。注释代码是好的,但是作为一般规则,尽量让代码说话。如果可以通过更好地命名变量来避免添加注释,那么就这样做。如果您需要添加注释来增加代码的价值,请使用行内注释,它是为单行 JavaScript 代码设计的:

// JavaScript recognises this line a comment because of the double slashes at the start

或者使用块注释,它被设计成跨越多行代码:

/*

* JavaScript recognises this as a block comment because of the slash / asterisk combination

* at the start and the asterisk / slash combination at the end. Many developers choose to

* begin each line of commented text with an asterisk and apply spacing such that each

* asterisk lines up with the one from the previous line.

*/

结构化 JavaScript 文档

我们可以简单地创建一个单独的文档文件,其中包含描述我们代码的用法说明和示例,但是,我们必须确保每当对代码本身进行更改时,该文件都是最新的,这可能非常耗时,并且很可能不会完成,这意味着它将永远与它所描述的代码不同步。

在我看来,创建文档的最佳方式是将用法说明和示例直接添加到代码文件本身的块注释中,就在代码所在的地方。这样,对于添加或更改代码的开发人员来说,他们看到的与代码相关的文档也需要更新,这就为文档保持最新提供了更好的机会。

因此,我们有一个策略来确保文档保持相关性,但是如果它与源代码一致,那么阅读起来就不那么容易了,你想阅读的只是文档本身。因此,我们需要一种将文档从源代码中提取出来的方法,使其成为一种更可展示的形式——理想情况下,这将是运行一个程序来为我们做这件事的一个简单例子,因为我们不想自己手动执行提取——我们是开发人员,我们没有耐心去做那件事!

幸运的是,有几个程序可以从源代码文件中提取特殊格式的文档,并在一个简单的网页中以易于使用的格式呈现出来。这样的程序有 JSDoc(bit . ly/JSDoc _ 3)、dox ( bit. ly/ d_ o_ x )、YUIDoc(bit . ly/yui _ doc)。行业偏好似乎倾向于后者,这也是我们在本章剩余部分用来生成文档的原因。

YUIDoc 文档格式

YUIDoc 不处理和理解你的源文件中的任何代码;相反,它只观察您自己围绕代码编写的特殊格式的块注释。在我使用其他文档处理程序的经验中,一些自动处理程序经常错过我理解的关于我的代码的重要因素,但是他们没有。然后,您被迫手动添加额外的文档,以覆盖其自动处理器。因此,我倾向于使用只理解我告诉它的内容的处理器,这样我就可以在文档中清楚明了地表达我的代码,而不会有任何虚假的信息进入我的文档。

YUIDoc 将从文件中只读块注释,即使这样也会忽略所有不以组合/**开头的注释,如下所示。您可以选择在注释的每一行的开头添加额外的星号(*)字符,以继续表示每一行都是同一注释的一部分,并将每一行排成一行。Sublime Text(bit . ly/Sublime _ Text)等代码编辑器会自动为你做到这一点:

/**

* This will be read by YUIDoc

*/

/*

* This will not

*/

/***

* Neither will this

*/

现在我们知道了如何让 YUIDoc 读取注释块,我们需要知道以 YUIDoc 可以理解的格式在块中放入什么。YUIDoc 支持@-标签,这是一种小标签,每个标签都以@字符开头,后跟其他相关的名称和信息。YUIDoc 的词汇表中有两组标签,主要标签和次要标签。描述一些周围代码的每个注释块必须包含一个且只能包含一个主标记,后面跟随着所需数量的辅助标记。

与其列出每个标签及其定义,不如让我们看看一些真实的用例,看看哪些标签适合哪种情况。

记录“类”、构造函数、属性和方法

我们在第一章中仔细研究了面向对象的 JavaScript 编码,其中我们介绍了“类”的创建以及将方法和属性与它们相关联。让我们从那一章中选取一个例子,并以 YUIDoc 格式添加一些基本的文档注释。

清单 2-1 使用清单 1-19 中的Class.create方法定义了一个名为Accommodation的“类”,带有一个名为initialize的构造函数,两个公共属性isLockedisAlarmed,以及两个公共方法lockunlock

清单 2-1。需要记录的简单 JavaScript“类”

var Accommodation = Class.create({

isLocked: true,

isAlarmed: true,

lock: function() {

this.isLocked = true;

},

unlock: function() {

this.isLocked = false;

},

initialize: function() {

this.unlock();

}

});

我们将从顶部开始我们的文档,使用 YUIDoc 的@class标签来标记一个“类”。标签前面是“类”的定义,后面是“类”的名称。我们之所以包括这个名字,是因为 YUIDoc 的解析器只读取这些格式化的注释,并不执行或解析代码中的变量名。首先,我们描述“类”,留下一个空行,然后我们放置 YUIDoc 标签。

/**

* A "class" defining types of accommodation

*

* @class Accommodation

* @constructor

*/

我们从我们定义的“类”的简单文本描述开始特殊格式的注释。这可以是我们喜欢的或长或短,如果需要,跨越多行。然后,我们用 YUIDoc 标签开始每一个新行,第一个标签是@class,后跟“class”公共变量名,然后添加一个带有@constructor标签的新行,这表明该变量是一个构造函数,可以从该函数创建对象实例。我认为,YUIDoc 的美妙之处在于,它不会试图解析您的代码,通过变量和函数的使用来推断文档。在这种情况下,我们使用Class.create而不是定义一个简单的function来创建一个构造函数和“类”的事实对 YUIDoc 来说并不重要,只要使用正确的@标签来描述它。这种方法可能会抛出另一个文档引擎来声明一个“类”,并且可能会返回与我们预期的完全不同的文档。

您的代码中可能有一个“类”,它并不打算每次都用 new 关键字进行实例化,而是在定义后进行实例化,代码的其余部分应该使用这个实例化。这就是所谓的单例,或者静态“类”,可以在你的文档中通过使用@static标签而不是@constructor标签来表示你的“类”定义,如清单 2-2 所示。

清单 2-2。记录静态“类”

/**

* A static "class" containing methods for reading and writing browser cookies

*

* @class Cookies

* @static

*/

// A self-instantiating "class" such as this is known as a singleton or static "class"

var Cookies = (function() {

// Properties and methods to get and set cookie values go here…

}());

记录属性遵循与“类”本身相似的模式;我们在代码中记录的属性上方直接创建格式化的注释。

/**

* Denotes whether the accommodation is currently locked

*

* @property {Boolean} isLocked

*/

在我们的属性定义之后,我们使用@property标签通过名称将我们的属性定义为 YUIDoc。注意,我们可以在@-标记和属性名之间的花括号中声明类型。这有助于其他开发人员了解用于您的属性的正确值类型,因此记住将这一点添加到您的文档中尤为重要。您可以使用 JavaScript 中任何可用的默认类型,例如BooleanStringNumberObjectArrayDateFunction,以及您在代码中自己创建的任何自定义“类”类型。

记录方法的工作方式与属性非常相似,只是我们使用了@method标签,不需要指定数据类型,因为方法总是属于Function类型。

/**

* Unlocks the accommodation

*

* @method unlock

*/

清单 2-3 显示了清单 2-1 中Accommodation“类”的完整文档版本。

清单 2-3。一个简单的、完整的 JavaScript“类”

/**

* A "class" defining types of accommodation

*

* @class Accommodation

* @constructor

*/

var Accommodation = Class.create({

/**

* Denotes whether the acommodation is currently locked

*

* @property {Boolean} isLocked

*/

isLocked: true,

/**

* Denotes whether the acommodation is currently alarmed—thieves beware!

*

* @property {Boolean} isAlarmed

*/

isAlarmed: true,

/**

* Locks the accommodation

*

* @method lock

*/

lock: function() {

this.isLocked = true;

},

/**

* Unlocks the accommodation

*

* @method unlock

*/

unlock: function() {

this.isLocked = false;

},

/**

* Executed automatically upon creation of an object instance of this "class".

* Unlocks the accommodation.

*

* @method initialize

*/

initialize: function() {

this.unlock();

}

});—

指定方法的输入参数和返回值

我们在清单 2-3 中记录的方法不包含输入或输出,所以记录它们就像描述函数的目的一样简单。为了用输入和输出的描述来标记一个方法,如果其他开发人员要理解你的方法应该如何被使用,这是必不可少的,你把@param@return标签添加到我们已经看到的@method标签中。看看下面的方法,我们将把它添加到清单 2-1 的Accommodation“类”中。

alarm: function(message) {

this.isAlarmed = true;

alert("Alarm is now activated. " + message);

return this.isAlarmed;

}

我们将向该方法添加文档,详细说明该方法做什么,以及它的输入和输出,每一项都列在单独的行上。

/**

* Activates the accommodation's alarm and displays a message to that effect

*

* @method alarm

* @param {String} message The message to display once the alarm is activated

* @return {Boolean} The current activation state of the alarm

*/

注意@param@property的相似之处在于应指定输入参数的类型;然而,与@property不同的是,参数的描述应该紧跟在它的名字之后。@return标签要求我们在花括号中指定返回值的数据类型,然后描述该值代表什么。

您可能已经编写了代码,将您的某个方法的输入组合成一个单一的对象文字参数,正如第一章中推荐的那样。这可以通过将对象文字中的每个属性作为单独的参数列出来进行记录,使用点符号表示每个属性都是同一对象的一部分。

/**

* Set all object instance properties in one shot

*

* @method setProperties

* @param {Object} options Properties object

* @param {Boolean} options.isAlarmed Denotes whether the accommodation is alarmed

* @param {Boolean} options.isLocked Denotes whether the accommodation is locked

* @param {String} options.message Message to display when alarm is activated

*/

Accommodation.prototype.setProperties(options) {

options = options || {};

this.isAlarmed = options.isAlarmed || false;

this.isLocked = options.isLocked || false;

this.message = options.message || "Alarm activated!";

};

记录可选的方法输入参数

如果您有包含可选参数的方法,您可以用 YUIDoc 格式表示这些方法,方法是将参数名放在方括号中。

/**

* Activates the accommodation's alarm, optionally displaying a custom message

*

* @method alarm

* @param {String} [message] Custom message to display once the alarm is activated

* @return {Boolean} The current activation state of the alarm

*/

如果没有提供可选值,某些可选输入参数可能会有默认值。在我们的例子中,我们可能希望将可选的message输入参数默认为一个特定的文本字符串。我们可以用 YUIDoc 格式来表示这一点,方法是在参数名后面加上=,然后是该参数的默认值。

/**

* Activates the accommodation's alarm, optionally displaying a custom message

*

* @method alarm

* @param {String} [message=Alarm activated!] Custom message to display when alarm is activated

* @return {Boolean} The current activation state of the alarm

*/

记录包含常数值的属性

在第一章的中,我们看了用大写字母表示“常数”变量,或幻数。我们可以在 YUIDoc 格式文档中使用@final标签来表示常量:

/**

* The mathemtical constant Pi

*

* @property PI

* @final

*/

var PI = 3.1415;

记录私有、受保护和公共的方法和属性

为了您自己和其他开发人员的利益,您应该在代码中记录公共的、受保护的和私有的属性和方法。在你的文档中,一个方法或属性可以被标记为私有的,只需在一行中添加@private标签,受保护的属性可以用@protected标签来标记。清单 2-4 展示了一个完整的文档化的“类”,它有公共的、受保护的和私有的属性和方法,摘自我们在《??》第一章第三节中写的代码。

清单 2-4。包含私有和公共方法和属性的完整记录的“类”

/**

* A "class" defining types of accommodation

*

* @class Accommodation

* @constructor

*/

var Accommodation = (function() {

function Accommodation() {}

/**

* Denotes whether the property is currently locked

*

* @property {Boolean} _isLocked

* @protected

*/

var _isLocked = false,

/**

* Denotes whether the property is currently alarmed

*

* @property {Boolean} _isAlarmed

* @private

*/

_isAlarmed = false,

/**

* Message to display when the alarm is activated

*

* @property {String} _alarmMessage

* @protected

*/

_alarmMessage = "Alarm activated!";

/**

* Activates the alarm

*

* @method _alarm

* @private

*/

function _alarm() {

_isAlarmed = true;

alert(_alarmMessage);

}

/**

* Disables the alarm

*

* @method _disableAlarm

* @private

*/

function _disableAlarm() {

_isAlarmed = false;

}

/**

* Locks the accommodation

*

* @method lock

*/

Accommodation.prototype.lock = function() {

_isLocked = true;

_alarm();

};

/**

* Unlocks the accommodation

*

* @method unlock

*/

Accommodation.prototype.unlock = function() {

_isLocked = false;

_disableAlarm();

};

/**

* Returns the current lock state of the accommodation

*

* @method getIsLocked

* @return {Boolean} Indicates lock state, ‘true' indicates that the accommodation is locked

*/

Accommodation.prototype.getIsLocked = function() {

return _isLocked;

};

/**

* Sets a new alarm message to be displayed when the accommodation is locked

*

* @method setAlarmMessage

* @param {String} message The new alarm message text

*/

Accommodation.prototype.setAlarmMessage = function(message) {

_alarmMessage = message;

};

return Accommodation;

}());

记录继承的“类”

从第一章的内容中,我们知道如何通过我们的代码展示继承,但是我们也需要在我们的文档中表示父类和子类之间的关系。我们可以使用 YUIDoc @extends标签来表示哪个“类”是当前记录的“类”的父类。

/**

* Define a "class" representing a house

*

* @class House

* @constructor

* @extends Accommodation

*/

function House() {};

House.prototype = new Accommodation();

/**

* Locks the house and activates the alarm

*

* @method lock

*/

House.prototype.lock = function() {

Accommodation.prototype.lock.call(this);

this.alarm();

};

记录链接的方法

您的文档可以被标记,以表示代码中的某些方法可以用@chainable标签链接在一起。正如我们在第一章中看到的,这仅仅涉及在方法结束时返回方法调用的上下文,因此同样的方法可以被立即调用:

/**

* Locks the house and activates the alarm

*

* @method lock

* @chainable

*/

House.prototype.lock = function() {

Accommodation.prototype.lock.call(this);

this.alarm();

return this;

};

记录相关“类”的组

您的代码文件可能包含几个“类”,所有这些类共享一个相似的分组或彼此相关的含义。在许多情况下,这种分组将自然地形成为从单个父“类”继承的一组“类”。您可以使用@module标签和分组名称向 YUIDoc 表示这个分组,其中至少有一个这样的标签应该存在于您的代码库中,即使这个分组只包含一个“类”。

/**

* Group of accommodation-related "classes"

*

* @module Accommodation-related

*/

如果你想进一步细化你的分组,你可以使用@submodule标签和@module来表示你代码中“类”的子分组模块。

/**

* House-specific "classes"

*

* @module Accommodation-related

* @submodule House-specific

*/

记录事件

如果您正在编写在代码中触发自定义事件的代码,当这些事件被触发时将调用侦听器函数,您应该使用@event标记按名称记录事件,并使用@param标记描述传递给侦听该事件触发的任何函数的任何参数:

/**

* Fired when the accommodation is alarmed

*

* @event accommodationAlarmed

* @param {String} message The message that was shown when the accommodation was alarmed

*/

记录代码示例

有时基于文本的文档不能代替代码示例。这就是为什么 YUIDoc 能够通过使用@example标签在结构化文档注释中标记代码,作为后面代码的用法示例。您可以指定任意数量的示例,用另一个@example标签分隔每个示例。示例代码应该缩进四个空格或一个制表符,原因将在本章讨论 Markdown 格式时变得更加清楚。

/**

* Sets a new alarm message to be displayed when the accommodation is locked

*

* @method setAlarmMessage

* @example

*     var myAccommodation = new Accommodation();

*     myAccommodation.setAlarmMessage("My alarm is enabled – thieves beware! ");

*     myAccommodation.alarm(); // Alerts "My alarm is enabled – thieves beware! "

*

* @example

*     var myAccommodation = new Accommodation();

*     myAccommodation.setAlarmMessage(); // Leave message blank

*     myAccommodation.alarm(); // Alerts an empty box with no message

*/

其他 YUIDoc 文档标签

在这一章中,我们已经讨论了您可能在 90%的真实案例中需要的文档标记。然而,随着你对记录你自己的代码越来越熟悉,你可能会发现你需要记录你的代码中本章没有解释的方面。要查看与 YUIDoc 一起使用的可用标签的完整列表,请通过bit . ly/YUIDoc _ syntax访问官方语法参考网站。

表达性文档格式–降价

不要用 HTML 标记编写更长形式的“类”、方法和属性描述以及用法文档,对于开发人员来说,这很难阅读,可以使用一种对开发人员更友好的格式,称为 Markdown—bit . ly/Markdown _ format

约翰·格鲁伯和艾伦·施瓦茨在 2004 年开发了 Markdown,作为开发人员编写纯文本文档的一种简单方法,这些文档可以作为纯文本轻松阅读,但也可以轻松转换为 HTML 以便阅读。它使用了当时纯文本消息中已经存在的约定,特别是在电子邮件和新闻组中。它在 GitHub、Stack Overflow 和 SourceForge 等网站上生成富文本而不需要编写 HTML,因此变得很流行。

在你的结构化文档中使用 Markdown 将有助于你的文档更加丰富和富有表现力,而不会使你的源代码文件过于臃肿或者更加难以阅读。YUIDoc 支持描述和示例中自由格式文档的 Markdown 格式,允许生成非常有表现力和全面的文档。因此,熟悉格式的细节非常重要。让我们来看看编写 Markdown 的一些最常见的方法,以及这些方法在 Markdown 处理器上运行时产生的 HTML 输出,比如 YUIDoc 中的处理器。

将标题下的内容分组

HTML 标题标签<h1><h6>可以使用以下示例中所示的形式作为 markdown 文本块的输出:

A Main Heading

==============

A Secondary Heading

-------------------

# Alternative Form Of Main Heading, aka Header Level 1

## Alternative Form Of Secondary Heading, aka Header Level 2

### Header Level 3

#### Header Level 4 ####

##### Header Level 5

###### Header Level 6 ######

这个 Markdown 块在处理成 HTML 时会产生以下内容:

<h1>A Main Heading</h1>

<h2>A Secondary Heading</h2>

<h1>Alternative Form Of Main Heading, aka Header Level 1</h1>

<h2>Alternative Form Of Seconday Heading, aka Header Level 2</h2>

<h3>Header Level 3</h3>

<h4>Header Level 4</h4>

<h5>Header Level 5</h5>

<h6>Header Level 6</h6>

直观地比较 markdown 输入和 HTML 输出应该会向您揭示,与相对难以扫描的 HTML 输出相比,读写 Markdown 是多么简单。

请注意主标题和次标题的两种可能形式,以及原始降价中第 4 层和第 6 层标题上过时的结束标记。这两者都是为了在不影响输出的情况下让输入更容易阅读,根据你喜欢的外观,你可以随意使用任何你认为合适的方法。

换行和创建段落

使用 markdown 创建文本段落不需要特殊符号;只需在一个文本块和另一个文本块之间添加两个换行符,就可以在生成的 HTML 中的每个文本块周围生成一个<p>标签。

80 days around the world, we'll find a pot of gold just sitting where the rainbow's ending. Time—we'll fight against the time, and we'll fly on the white wings of the wind.

Ten years ago a crack commando unit was sent to prison by a military court for a crime they didn't commit. These men promptly escaped from a maximum-security stockade to the Los Angeles underground.

此处示例中显示的降价将被转换为以下 HTML 输出。

<p>80 days around the world, we'll find a pot of gold just sitting where the rainbow's ending. Time—we'll fight against the time, and we'll fly on the white wings of the wind.</p>

<p>Ten years ago a crack commando unit was sent to prison by a military court for a crime they didn't commit. These men promptly escaped from a maximum-security stockade to the Los Angeles underground.</p>

由此你可能会认为,要在 HTML 输出中创建一个换行符而不是一个段落换行符,你只需在 Markdown 文件中包含一个换行符即可,但事实并非如此。在 Markdown 中,一个单独的换行符会将输出文本合并成一个段落。

80 days around the world, we'll find a pot of gold just sitting where the rainbow's ending.

Time—we'll fight against the time, and we'll fly on the white wings of the wind.

此处的示例在处理后产生以下 HTML 输出。

<p>80 days around the world, we'll find a pot of gold just sitting where the rainbow's ending.

Time—we'll fight against the time, and we'll fly on the white wings of the wind.</p>

要在输出中生成 HTML 换行符<br />,必须在 Markdown 中的换行符前加上两个或更多空格。这将在之前的行尾,所以在读取输入文件时看不到。

80 days around the world, we'll find a pot of gold just sitting where the rainbow's ending.

Time—we'll fight against the time, and we'll fly on the white wings of the wind.

示例中第一行末尾的␣符号表示使用了空格字符,因此请确保不要使用␣字符本身。处理这个 Markdown 输入会产生下面的 HTML 输出。

<p>80 days around the world, we'll find a pot of gold just sitting where the rainbow's ending.<br />Time—we'll fight against the time, and we'll fly on the white wings of the wind.</p>

创建列表

有序列表和无序列表都可以通过 markdown 表示,并解释为正确的 HTML 标签。

无序列表可以用星号(*)、连字符(-)或加号(+)来表示,每个符号后面都有一个或多个空格字符或制表符,这意味着它非常灵活,您可以使用任何您觉得最自然的符号。

* Monkey

* Donkey

* Wonky

-       Monkey

-       Wonky

-       Donkey

+ Donkey

+ Monkey

+ Wonky

所示示例在处理后会产生以下 HTML 输出。注意每个列表的 HTML 都是相同的,尽管在原始的减价列表中使用了不同的项目符号。

<ul>

<li>Monkey</li>

<li>Donkey</li>

<li>Wonky</li>

</ul>

<ul>

<li>Monkey</li>

<li>Wonky</li>

<li>Donkey</li>

</ul>

<ul>

<li>Donkey</li>

<li>Monkey</li>

<li>Wonky</li>

</ul>

有序列表可以这样生成:每行以一个数字开头,后跟一个句点,然后是一个或多个制表符或空格字符。降价文件中使用的实际数字并不重要;它们将作为从数字 1 开始的单个有序数字列表进行处理。这实际上对编写降价文本的人是有益的,因为他们只需要将所有数字设置为相同的数字,这意味着如果他们向列表中添加额外的项目,他们不必重新排序数字。

1\. Monkey

2\. Donkey

3\. Wonky

1.      Monkey

1.      Wonky

1.      Donkey

1\. Donkey

2\. Monkey

3\. Wonky

该示例在处理后会产生以下 HTML 输出。注意每个列表的 HTML 都是相同的,尽管在原始的减价列表中使用了不同的数字。

<ol>

<li>Monkey</li>

<li>Donkey</li>

<li>Wonky</li>

</ol>

<ol>

<li>Monkey</li>

<li>Wonky</li>

<li>Donkey</li>

</ol>

<ol>

<li>Donkey</li>

<li>Monkey</li>

<li>Wonky</li>

</ol>

并不是所有的列表都像我们目前看到的例子一样简单;您可能希望将列表嵌套在一起以创建层次结构。幸运的是,这可以通过使用 markdown 简单地将子列表项缩进四个空格或一个制表符来实现。

* Monkey

* Wonky

* Donkey

1\. Monkey

1\. Wonky

1\. Donkey

示例中的代码经过处理后会生成以下 HTML 输出。请注意列表项的嵌套。

<ul>

<li>Monkey

<ul>

<li>Wonky</li>

</ul>

</li>

<li>Donkey</li>

</ul>

<ol>

<li>Monkey

<ol>

<li>Wonky</li>

</ol>

</li>

<li>Donkey</li>

</ol>

只需在列表项之间添加一个换行符,就可以在列表项文本周围放置段落。您可以在每个列表项中包含多个文本段落,只需将每个额外的段落缩进四个空格或一个制表符。

*   Monkey

A monkey is a primate of the Haplorrhini suborder and simian infraorder.

*   Donkey

The donkey or ass, Equus africanus asinus, is a domesticated member of the Equidae or horse family.

此示例在处理后会产生以下 HTML 输出。

<ul>

<li>

<p>Monkey</p>

<p>A monkey is a primate of the Haplorrhini suborder and simian infraorder.</p>

</li>

<li>

<p>Donkey</p>

<p> The donkey or ass, Equus africanus asinus, is a domesticated member of the Equidae or horse family.</p>

</li>

</ul>

强调文本

使用 markdown,您可以通过在要强调的文本周围使用星号(*)或下划线(_)来强调句子中的文本。

Donkeys are *not* monkeys.##

I repeat, Donkeys are _not_ monkeys.

该示例在处理后会产生以下 HTML 输出。使用强调时,请注意斜体文本。

<p>Donkeys are <em>not</em> monkeys.<br />

I repeat, Donkeys are <em>not</em> monkeys.</p>

您可以通过将星号或下划线加倍来增加额外的强调。

Donkeys are **really** not monkeys.##

I repeat, Donkeys are __really__ not monkeys.

此示例在处理后会产生以下 HTML 输出。当在减价文件中强调时,请注意加粗文本的使用。

<p>Donkeys are <strong>really</strong> not monkeys.<br />

I repeat, Donkeys are <strong>really</strong> not monkeys.</p>

显示代码

有两种方法来区分 Markdown 格式的代码片段:内联方法和块方法。前者由代码周围的反斜杠(```)字符表示,用于您希望将一小部分代码作为一行文本的一部分。后者用于显示一行或多行代码,只需将每行代码缩进四个空格字符或一个制表符即可。这就是为什么当我们使用@example YUIDoc 标签时,我们缩进它旁边列出的代码。

Simply install Grunt by typing npm install grunt at the command line.

<!doctype html>

<html>

<head></head>

</html>

所示示例在处理后会产生以下输出 HTML。请注意,任何 HTML 标签都将它们的一些字符替换为实体,因此它们呈现为文本,而不是试图解析为它们所代表的标签。

<p>Simply install Grunt by typing <code>npm install grunt</code> at the command line.</p>

<pre><code>&lt;!doctype html&gt;

&lt;html&gt;

&lt;head&gt; &lt;/head&gt;

&lt;/html&gt;</code></pre>

添加报价

要在自己的段落中引用引文,只需在文本前加上一个向右的尖括号,后跟一个或多个空格或制表符。通过包含空引用行来分隔每个段落,可以在块引用中嵌套多个段落。

> Little Henry took his book one day and went into the garden to study. He sat where the arbor cast a pleasant shade, and he could smell the fragrance of the flowers that he himself had planted.

>

> At times, he would forget his book while listening to the music of the birds, or gazing at the peonies and tulips, but he would soon think again of his lesson, and commence studying with new zeal.

此示例在处理为 HTML 后会产生以下输出。

<blockquote>

<p>Little Henry took his book one day and went into the garden to study. He sat where the arbor cast a pleasant shade, and he could smell the fragrance of the flowers that he himself had planted. </p>

<p>At times, he would forget his book while listening to the music of the birds, or gazing at the peonies and tulips, but he would soon think again of his lesson, and commence studying with new zeal.</p>

</blockquote>

链接到 URL

链接到一个 URL 就像在你的 Markdown 格式的内容中包含那个 URL 一样简单。URL 可以按原样书写,也可以用尖括号<>括起来。绝对和相对 URL 都是允许的。

For more, seehttp://www.google.comor <http://www.bing.com

此示例在处理后生成以下 HTML。

For more, see <a href="``http://www.google.com">http://www.google.com</a``> or <a href="``http://www.bing.com">http://www.bing.com</a

然而,通常情况下,您会希望将特定文本链接到 URL。这是通过将要链接的文本放在方括号[]中,然后在其后立即将链接 URL 包含在标准方括号中来实现的。

`One popular search engine is Google

此示例在处理后会产生以下 HTML 输出。

One popular search engine is <a href="``http://www.google.com">Google</a

最后,Markdown 支持引用链接的使用,这是一种在整个文档中使用的链接查找表。这允许 markdown 文件的作者将所有链接 URL 移动到文档中的另一个点,通常在末尾,以使文档的其余部分更容易阅读。然后给每个链接分配一个引用名称或方括号内的编号,用冒号(:)和一个或多个空格或制表符与 URL 分隔。然后,在剩余的减价内容中,可以通过方括号中的相同数字名称来引用该链接。参考名称本身可以包含字母、数字、空格和标点符号的任意组合,但请注意它们不区分大小写。

Popular search engines include [Google][1], [Bing][2], and [Yahoo][yahoo], but the most popular today is [Google][1].

[1]: http://www.google.com

[2]:http://www.bing.com

[yahoo]:http://www.yahoo.com

下面的 HTML 是经过处理后产生的。请注意,参考列表本身不会显示在输出中。

Popular search engines include <a href="``http://www.google.com">Google</a``>, <a href="``http://www.bing.com">Bing</a``>, and <a href="``http://www.yahoo.com">Yahoo</a``>, but the most popular today is <a href="``http://www.google.com">Google</a``>.

如果您希望在 HTML 输出中向 link 标签添加 title 属性,那么您可以在 link URL 后面使用引号将它提供给 markdown 内容。这适用于内联链接和引用链接。

`The most popular search engine today is Google

[1]:http://www.bing.com

[2]:http://www.yahoo.com

所示示例在处理后会产生以下 HTML 输出。

The most popular search engine today is <a href=http://www.google.comtitle="Visit Google">Google</a>, followed by <a href=http://www.bing.comtitle="Visit Bing">Bing</a> and <a href=``title="Visit Yahoo">Yahoo</a>.

插入图像

向 markdown 内容添加图像的语法与已经看到的链接语法非常相似。主要区别在于,它由前面的感叹号(!)字符表示为图像,没有多余的空格或制表符。然后,这后面是一个放在方括号中的名称,这将在结果 HTML 输出中产生一个包含该文本的alt属性。然后将图像 URL 本身放在括号中,并在 URL 在处理后的 HTML 输出中添加 title 属性后,在引号中提供一个可选的标题。

![A squirrel](https://gitee.com/OpenDocCN/vkdoc-js-pt2-zh/raw/master/docs/pro-js-dev/img/squirrel-image.jpg)

`Homepages

![Company Logo][logo]

![Main Image][1]

[logo]: /path/to/logo.png

[1]: /path/to/main.jpg "The Main Page Image"

所示示例在处理后会产生以下 HTML 输出。

<img src="/path/to/squirrel-image.jpg" alt="A squirrel" />

<img src="http://www.homepages.com/image.jpg

<img src="/path/to/logo.png" alt="Company Logo" />

<img src="/path/to/main.jpg" alt="Main Image" title="The Main Page Image" />

创建水平嵌线

要分割减价内容中的部分,可以插入一个水平分隔线。这可以在 markdown 中实现,在它们自己的行上使用三个或更多的星号(*)、连字符(-)或下划线(_)。与其他一些降价规则不同,您可以在这些字符之间使用空格而不影响输出,但是您不能在同一行上混合使用这三种类型的字符。

***

---

___

* * *

- - - -

______________

所示的每个示例在处理后都会产生完全相同的 HTML 输出,如下所示。

<hr />

使用反斜杠插入保留字符

你会看到某些字符在 markdown 中有特定的含义,并被转换成 HTML。如果您想在 markdown 中使用这些字符中的一个而不进行转换,markdown 提供了一种机制来实现这一点——只需在本来会被替换的字符前插入一个反斜杠(\)字符。这适用于以下字符:

\  backslash

``  backtick`

*  asterisk

_  underscore

{} curly braces

[] square braces

() parentheses

#  hash

+  plus

-  minus (hyphen)

.  dot

!  exclamation point

对于其他一切,有 HTML

如果你发现你需要表现一些 Markdown 的格式规则不支持的东西,你不必沮丧。Markdown 支持根据需要添加 HTML 标签,以提供额外的格式。请注意,HTML 块将按原样处理,这意味着 HTML 块中任何 Markdown 格式的内容都将被忽略。

Markdown doesn't support tables, but *HTML* does!

<table>

<tr>

<td>Hello</td>

<td>World</td>

</tr>

</table>

And now we're back into __Markdown__ again!

上面的例子在处理后会产生下面的 HTML 输出。

<p>Markdown doesn't support tables, but <em>HTML</em> does!</p>

<table>

<tr>

<td>Hello</td>

<td>World</td>

</tr>

</table>

<p>And now we're back into <strong>Markdown</strong> again!</p>

使用 YUIDoc 创建文档网站

现在我们已经为我们的代码编写了一些文档,是时候以一种易于使用的格式呈现出来,以便其他人理解。我们一直用 YUIDoc 格式编写文档,所以我们将使用相关的 YUIDoc 工具来生成一个站点。

这个 YUIDoc 工具是一个 JavaScript 应用,旨在与 Node.js 一起工作,node . js 是一个应用框架,它在命令行上运行完全用 JavaScript 编写的文件。我们将在下一章中更详细地讨论 Node.js,但是现在你需要做的就是安装它。

访问 bit. ly/ node_ js 并从那里下载 Node.js,按照说明安装;这还会安装一个名为节点包管理器(NPM)的工具,它允许您快速方便地从中央存储库中下载应用(称为包)。

接下来我们需要下载 YUIDoc 包本身。在命令行上,执行以下命令,这将自动安装 YUIDoc 工具,并使其可以在计算机上的任何文件夹中运行:

npm –g install yuidocjs

对于 Mac 或其他基于 Unix 的系统,您可能需要在命令前加上sudo,以允许代码以足够的权限执行,从而在系统范围内安装该工具。

现在我们已经安装了 YUIDoc,让我们运行它。使用命令行导航到包含您记录的 JavaScript 代码文件的文件夹,并运行以下命令(不要去掉末尾的句点字符):

yuidoc.

当执行时,YUIDoc 将查看当前文件夹及其子文件夹中的所有 JavaScript 文件,并从中提取结构化文档。它不会试图执行你的任何代码,它只是读取每个文件,就像它是纯文本一样,寻找它的特定标签集和特殊格式的开始注释块字符。然后,它获取从文档中收集的信息,并生成一个 JSON 格式的文件,以结构化的形式表示这些数据,同时生成一个使用默认模板样式的完整点击 HTML 站点。

如果您希望 YUIDoc 只查看当前文件夹,而不查看子文件夹,请为该命令提供参数–n

yuidoc –n.

现在我们可以运行 YUIDoc 来为我们生成文档,我们需要一些代码来为生成文档。让我们使用清单 2-5 中的代码,您应该将它保存到您计算机上一个文件夹中的一个名为Listing2-5.js的文件中。你可以从 GitHub 的 bit. ly/ pro_ js_ dev 上下载这一章的代码。这段代码定义了一个父“类”和一个子类,以及方法和属性,有些是公共的,有些是受保护的。

清单 2-5。使用 YUIDoc 生成站点的文档代码

/**

* Accommodation-related "classes"

*

* @module Accommodation-related

*/

/**

* A "class" defining types of accommodation

*

* @class Accommodation

* @constructor

* @example

*     var myAccommodation = new Accommodation();

*/

var Accommodation = Class.create((function() {

/**

* Denotes whether the accommodation is currently locked

*

* @property {Boolean} _isLocked

* @protected

*/

var _isLocked = true,

publicPropertiesAndMethods = {

/**

* Locks the accommodation

*

* @method lock

* @example

*     var myAccommodation = new Accommodation();

*     myAccommodation.lock();

*/

lock: function() {

_isLocked = true;

},

/**

* Unlocks the accommodation

*

* @method unlock

* @example

*     var myAccommodation = new Accommodation();

*     myAccommodation.unlock();

*/

unlock: function() {

_isLocked = false;

},

/**

* Establishes whether the accommodation is currently locked or not

*

* @method getIsLocked

* @return {Boolean} Value indicating lock status—'true' means locked

* @example

*     var myAccommodation = new Accommodation();

*     myAccommodation.getIsLocked(); // false

*

* @example

*     var myAccommodation = new Accommodation();

*     myAccommodation.lock();

*     myAccommodation.getIsLocked(); // true

*/

getIsLocked: function() {

return _isLocked;

},

/**

* Executed automatically upon creation of an object instance of this "class".

* Unlocks the accommodation.

*

* @method initialize

*/

initialize: function() {

this.unlock();

}

};

return publicPropertiesAndMethods;

}()));

/**

* "Class" representing a house, a specific type of accommodation

*

* @class House

* @constructor

* @extends Accommodation

* @example

*     var myHouse = new House();

*/

var House = Accommodation.extend({

/**

* Indicates whether the house is alarmed or not—'true' means alarmed

*

* @property {Boolean} isAlarmed

*/

isAlarmed: false,

/**

* Activates the house alarm

*

* @method alarm

*/

alarm: function() {

this.isAlarmed = true;

alert("Alarm activated!");

},

/**

* Locks the house and activates the alarm

*

* @method lock

*/

lock: function() {

Accommodation.prototype.lock.call(this);

this.alarm();

}

});——

从命令行导航到保存代码的文件夹,并执行 YUIDoc 以生成代码清单的文档网站:

yuidoc –n.

这将在当前文件夹中生成一个名为out的子文件夹,并用代表文档的 HTML 和 CSS 填充它。在 web 浏览器中打开名为index.html的文件,查看生成的网站。这将显示一个看起来很像图 2-1 所示的页面。

A978-1-4302-6269-5_2_Fig1_HTML.jpg

图 2-1。

YUIDoc generates a full HTML site automatically from your documented JavaScript code

页面左侧的两个选项卡允许您在已记录的“类”和模块列表之间跳转—我们记录了两个“类”和一个模块。单击一个模块名称,可以在页面中央看到该模块的描述,以及归入该模块的所有“类”的列表。单击此处或左侧栏中的“类”名称,将在页面的中心区域显示该“类”的完整文档。

“类”文档显示了“类”描述、构造函数以及我们在文档中声明的示例代码,并突出显示了语法。下面的三个选项卡允许您查看所有方法和属性的索引列表,或者详细查看该“类”的方法或属性。单击一个方法或属性名称会将您直接带到相应的选项卡,将页面滚动到它的定义和文档。我们代码的所有文档都在这里,格式化为易读的文本。

默认情况下,生成的站点只显示公共属性和方法。要查看或隐藏受保护的和私有的属性和方法,请切换页面右上角的相应复选框。当查看House子类的文档时,您可以使用页面右上角的inherited复选框在显示该“类”可用的所有方法和属性之间切换,或者只显示那些在“类”定义本身上定义的方法和属性。这对于检查继承的“类”的特定文档或者仅仅查看该“类”特有的那些方法和属性是一种有用的方式

更进一步

YUIDoc 支持各种定制,从在页面左上方设置徽标,到为文档提供完全不同的外观的完整预建主题。要了解关于定制 YUIDoc 的更多信息,请通过 bit. ly/ yui_ doc 访问项目文档页面。对于一个好的替代主题,请尝试 Dana,可通过bit . ly/yuidoc _ Dana获得。如果你想学习如何创建自己的自定义主题,完整的文档可以通过bit . ly/yuidoc _ themes获得。体验 YUIDoc 的乐趣,记住一定要记录代码,这既是为了其他开发人员的利益,也是为了您自己的健康。

摘要

在这一章中,我们已经讨论了 JavaScript 代码文档,无论是非正式的还是结构化的,它都可以让你自己、你的项目团队以及任何接触你的代码的公众受益。我们已经介绍了 YUIDoc 和 Markdown 格式,并基于代码中的结构化文档自动生成了一个成熟的文档网站,而无需编写任何 HTML 或 CSS 代码。

在下一章中,我们将研究如何使用技巧、提示和技术的组合来编写最高质量的 JavaScript 代码,使我们的代码运行良好并且尽可能少出错。

三、编写高质量的 JavaScript

没有没有错误的软件这种东西,运行你的软件的系统的未知越多,出错的可能性就越大。作为 JavaScript 开发人员,我们编写的代码可以在至少五种主要的 web 浏览器上运行,其中大多数都有相当快的发布时间表。将这种移动目标与浏览器中 JavaScript 代码的一个错误有可能停止该页面中所有其他 JavaScript 的运行这一事实结合起来,您可以看到我们编写高质量、无错误代码所面临的挑战。

本章的目的是帮助您编写高质量的 JavaScript 代码,并向您展示如何对您在页面中执行的代码充满信心。我将向您介绍代码分析工具、单元测试的原则以及如何处理运行时错误。最后,我将向您展示如何衡量您的代码质量,以便对其进行持续改进。

执行静态代码分析

寻找代码中潜在错误的最佳第一步是在第一次运行代码之前,使用代码分析工具检查代码。这种类型的分析称为静态分析,因为代码是作为静态文本文件而不是在执行的上下文中进行检查的。

使用这种方法,可以向您强调常见的错误来源和编码陷阱,这样它们就不会出现在您正在运行的代码中,从而可能引入 bug。这种形式的代码分析可以检测到的一系列问题包括缺少分号、使用未定义的变量,更重要的是,使用 JavaScript eval方法来执行字符串,就好像它们是 JavaScript 代码一样——这在代码安全性方面是一大禁忌,因为它可能会允许恶意代码在您的代码上下文中执行。通过确保这些潜在的危险操作不会出现在您的代码中,您可以增强信心,减少软件中出现错误的机会。现在让我们来看看几个比较常见的 JavaScript 静态代码分析工具。

杰林特

静态代码分析工具在其他编程语言中已经存在了一段时间。随着 20 世纪 70 年代早期 C 语言的出现,出现了相当基本的编译器,它们将 C 语言编写的代码转换成在计算机处理器上运行的机器代码。一些程序员编写的代码可能会引入错误,而编译器没有发现这些错误。这导致了一个名为 lint 的静态代码分析工具于 1979 年发布,允许开发人员在编译前检查他们的代码,以降低发布错误软件的风险。

雅虎的道格拉斯·克洛克福特创造了一个工具来对 JavaScript 代码做类似的事情,允许在发布代码之前检查已知的错误,他以 C 语言的原始工具命名这个工具为 JSLint。他通过在线表单提供了这个工具,你可以在 http://www.jslint.com 复制并粘贴你的 JavaScript 代码。

A978-1-4302-6269-5_3_Fig1_HTML.jpg

图 3-1。

The JSLint homepage. Paste your JavaScript code into the box to check its quality

如果您尝试分析 JSLint 中的一些代码,您会发现自己因为编写了被认为质量很差的代码而受到了惩罚。事实上,使用清单 2-5 中的代码,在被检查的 29 行 JavaScript 代码中总共发现了 16 个错误。项目主页宣称 JSLint“会伤害你的感情”,在这句话听起来正确之前,检查你的代码质量并不需要很多尝试。

仔细查看产生的错误列表,如图 3-2 所示,可以发现大多数错误是由于在function关键字和其后的左括号字符之间没有空格字符。就我个人而言,我更喜欢在使用了function关键字之后不留下任何空格;然而,这正是开发这个工具的道格拉斯·克洛克福特喜欢的编写代码的方式。因此,默认情况下会启用该特定规则。我们将很快看到如何改变应用的规则,以便忽略这个特定的约束。

A978-1-4302-6269-5_3_Fig2_HTML.jpg

图 3-2。

The results of running code from Listing 2-4 in JSLint don’t make for happy reading

其他错误包括:我们在使用类对象之前没有定义它,缺少用于强制首选 ECMAScript 严格模式的"use strict"语句,我们在变量名的开头使用了下划线字符,以及在定义之前调用了alert方法。后一个错误可能看起来很奇怪,因为警报可以在任何浏览器中工作。它被标记的原因是因为它不是官方 JavaScript 语言规范的一部分;它只是由浏览器制造商添加到他们的软件中,允许开发者提供弹出消息。JSLint 希望您的代码不包含特定于浏览器的 JavaScript,这样它就可以在任何支持该语言的环境中正确运行。

在 JSLint 站点上的错误列表下面,有一个标题为 Options 的部分,其中包含许多复选框,允许为您的代码覆盖默认的规则集。尝试设置选项控制台、警报。。。、dangling _ in 标识符、杂乱的空格到true,然后再次运行 JSLint 工具;您应该会发现错误列表现在已经缩短了。向下滚动到页面的最底部,您会发现一个标题为 JSLint 指令的部分,其中包含一个特殊格式的 JavaScript 注释。您可以在代码中为每个文件设置不同的规则,方法是将其中一个指令放在文件的顶部。该指令以/*jslint开始,然后是通过 JSLint 运行时应用于该文件的选项列表,后面是一个truefalse值,分别指示该选项应该被启用还是禁用;多个选项用逗号分隔。对于我们选择的选项,我们收到以下指令:

/*jslint devel: true, nomen: true, white: true */

我们可以将这个指令复制并粘贴到文件的顶部,不管 JSLint 页面上的设置状态如何,这些都是 Lint 时应用到文件的选项。有关可应用选项的完整列表及其目的和背后的原因,请通过 http://bit.ly/jslint_opts 在线浏览文档。

设置了这些选项后,我们仍然需要解决剩下的两种类型的错误。首先是缺少对Class对象的定义,在这种情况下,它存在于一个单独的文件中。我们可以使用另一个特殊格式的注释来指示 JSLint 关于从其他文件中声明的全局变量,我们向该注释提供变量的名称和一个truefalse值,分别指示该变量是在该文件中赋值还是只读。对于我们的Class对象,指令看起来是这样的:

/*global Class: false */

在代码文件顶部的现有指令之后添加这个代码,可以在再次执行 JSLint 时从列表中删除错误。这就留下了 ECMAScript 5 的严格模式缺乏执行力的问题。这是我们应该在代码中解决的问题,启用严格模式,以减少代码运行时发生错误的可能性。JSLint 强调了这个特殊的疏忽,这使得它的使用非常值得,可以提高最终 JavaScript 代码的质量,让我们对它更有信心。基于清单 2-5 的最终修改后的源代码如清单 3-1 所示。为了简洁起见,这里以及将来的代码清单中已经删除了文档注释。

清单 3-1。传递 JSLint 的示例代码,在特殊格式的注释块中设置了特定的选项

/*jslint devel: true, nomen: true, white: true */

/*global Class: false */

var Accomodation = Class.create((function() {

"use strict";

var _isLocked = true,

publicPropertiesAndMethods = {

lock: function() {

_isLocked = true;

},

unlock: function() {

_isLocked = false;

},

getIsLocked: function() {

return _isLocked;

},

initialize: function() {

this.unlock();

}

};

return publicPropertiesAndMethods;

}()));

var House = Accomodation.extend({

isAlarmed: false,

alarm: function() {

"use strict";

this.isAlarmed = true;

alert("Alarm activated!");

},

lock: function() {

"use strict";

Accomodation.prototype.lock.call(this);

this.alarm();

}

});

对于大型项目来说,通过 JSLint homepage 为每个文件运行代码并不总是很方便,因为这会给工作流增加相当多的开销时间。幸运的是,Yahoo 的开发人员 Reid Burke 为 Node.js 应用框架开发了一个 JSLint 版本,可以在您自己的机器上从命令提示符运行。我们将在下一章更详细地讨论 Node.js,但是现在,如果你还没有安装它,请访问网站 http://nodejs.org 下载并安装这个框架。

安装 Node.js 及其关联的 NPM 工具后,在命令提示符下运行以下命令,将 JSLint 工具安装到您的计算机上。请注意,Mac 和 Linux 用户可能需要在命令前面加上sudo才能对该命令应用管理员权限:

npm install jslint -g

现在,您可以从命令提示符下在任何目录中运行该工具;只需导航到包含代码文件的目录,并运行以下命令来 lint 文件夹中的每个 JavaScript 文件:

jslint *.js

要了解如何使用自定义选项和其他设置配置该工具,请通过 http://bit.ly/jslint_node 在线访问该工具的 GitHub 项目。

JSHint(联合提示)

JSHint 代码分析工具与 JSLint 有更多的共同之处,而不仅仅是相似的名称;它实际上是原始 JSLint 代码的一个分支,允许对用于分析 JavaScript 代码的选项进行更大的定制。它是作为一个社区驱动的工作,通过其网站 http:// jshint 进行组织和贡献的。com 。与 JSLint 一样,您可以通过复制并粘贴到其主页上的在线表单中来检查您的代码,并配置您希望如何分析代码的选项。

JSHint 项目始于 2011 年,是对社区内部感觉 JSLint(最初的 JavaScript 静态代码分析工具)变得过于固执己见的反应。JSLint 试图实现的一些规则实际上只是其创建者道格拉斯·克洛克福特喜欢的代码风格规则,人们认为代码分析工具应该更多地关注于发现语法和其他错误,这些错误会阻止代码正确运行,而不是因为格式问题而拒绝让代码通过。

将清单 2-5 中的代码提交到 JSHint 主页上的表单中会产生一系列错误,其中许多与 JSLint 之前报告的错误类似。用 JSHint 报告的错误和用 JSLint 报告的错误有两个显著的区别。首先,JSHint 不会抱怨在关键字function和其后的左括号之间使用空格字符,这纯粹是 JSLint 工具作者的编码偏好。其次,JSHint 产生了一个额外的错误,让我们知道我们正在定义一个新变量House,这个变量在文件的其余部分中没有使用。

为了让 JSHint 知道我们很高兴我们声明了一个在同一个文件中没有使用的变量,并且我们在代码中使用了alert方法,我们可以在文件的顶部放置一个特殊格式的注释,以类似于 JSLint 的方式,设置在分析中使用的选项:

/*jshint devel:true, unused:false */

可通过 http://bit.ly/jshint_opts 在在线文档网站上获得选项的完整列表,并且有许多选项。

为了让 JSHint 知道我们在别处声明的全局Class对象变量,我们可以使用与 JSLint 相同的注释:

/*global Class */

包括清单 3-1 中的适当的"use strict"命令,以及文件顶部的这些特殊格式的注释,允许代码通过 JSHint 静态代码分析。

可以在命令行上使用 JSHint,就像使用 JSLint 一样。要安装,只需确保安装了 Node.js,并在命令提示符下执行以下命令:

npm install jshint –g

然后,您可以从任何文件夹中运行 JSHint,就像使用 JSLint 一样:

jshint *.js

除了命令行工具,JSHint 背后的团队还提供了一系列其他可能的方法来使用该工具,包括作为通用文本编辑器和 ide 的插件,如 Sublime Text 和 Visual Studio,以在您键入时提供实时代码检查。插件的完整列表可以通过 http://bit.ly/jshint_install 在他们的网站上获得。

Google 闭包编译器和 Linter

Google 在 2009 年底开源了一些他们内部用于 JavaScript 开发的工具,统称为 Closure Tools。其中包括一个名为 Closure Library 的 JavaScript UI 库,一个名为 Closure Templates 的 JavaScript 模板解决方案,以及一个名为 Closure Compiler 的完整优化和代码检查工具。2010 年,他们增加了第四个工具,Closure Linter,用于根据一组样式规则验证 JavaScript 代码。这些工具中的前两个我们将在后面的章节中介绍。目前,我们对后两者感兴趣。

闭包编译器的主要目标是让 JavaScript 代码下载和运行得更快。它解析和分析、删除未使用的代码,并尽可能地重写以减少结果代码的大小。它是将 JavaScript 文件优化到尽可能小的最佳工具之一,这意味着它可以在最终用户的浏览器中更快地下载和执行。由于其解析能力,它还可以识别 JavaScript 中的语法错误,以及突出潜在的危险操作,这是它作为静态代码分析工具的有用之处。

要使用 Closure 编译器来分析和优化您的代码,尝试通过 http://bit.ly/closure_compile 将您的代码复制并粘贴到在线表单中,然后按下编译按钮。优化后的代码将出现在页面右侧的“已编译代码”选项卡中。通过分析发现的任何 JavaScript 语法错误或潜在的编码危险分别列在 errors 和 Warnings 选项卡中,供您采取措施。如果您愿意,该工具可以使用 Java(通过 http://bit.ly/closure_install 在线安装说明)以及基于 REST 的 Web 服务 API 在命令行上运行,以便集成到您自己的系统中(通过 http://bit.ly/closure_api 在线使用说明)。

Closure Linter 的主要目标是分析和比较代码文件的编写方式和 Google 自己的 JavaScript 风格指南,最新版本可以通过 http://bit.ly/google_style 在线获得。除了报告它发现的问题,它还包含一个工具,如果可能的话,可以自动修复它发现的错误。谷歌内部使用它来确保 Gmail 和 Drive 以及其他产品的代码遵循相同的编码风格规则。

要使用 Closure Linter 根据 Google 的 JavaScript 风格指南检查您的代码,您必须首先通过 http://bit.ly/dl_py 将应用框架 Python 下载并安装到您的计算机上。Mac 和 Linux 用户可能已经在他们的操作系统中安装了这个。Windows 用户还需要通过 http://bit.ly/py_easy 安装 Python 的简易安装包。安装完成后,执行以下命令将 Closure Linter 安装到您的机器上。请注意,Mac 和 Linux 用户可能需要在命令前加上sudo来授予安装的管理权限:

easy_installhttp://closure-linter.googlecode.com/files/closure_linter-latest.tar.gz

要对一个目录中的所有 JavaScript 文件运行该工具来报告您对 Google 风格指南的遵守情况,请在命令提示符下执行以下命令:

gjslint *.js

如果您希望只针对单个文件,也可以用特定的文件名替换*.js。然后,该工具会在命令窗口中列出它发现的问题。如果您想尝试自动修复这些问题,请执行以下命令,用它们的修复覆盖您的文件:

fixjsstyle *.js

通过简单地在文件目录上运行后一个命令,您可以用最少的努力更新您的代码来遵循 Google 的风格指南。如果您想了解该工具的更多信息,请通过 http://bit.ly/linter_howto 访问在线文档网站。

选择静态代码分析工具

在这一章中,我们已经看了一些比较常见的 JavaScript 静态代码分析工具。选择哪个工具适合您的项目取决于您特别希望检查什么。只需选择使用本章中介绍的任何工具,就可以确保语法错误和常见的编程错误被捕获,从而提高代码的质量,并增强运行时减少错误发生的信心。此外,您需要决定您希望仔细检查多少代码样式和格式,因为这才是真正需要做出决定的地方。我个人更喜欢 JSHint,因为它更侧重于语法检查,而不是特定的编码风格,我知道它不会妨碍我编写和发布代码。您的需求可能不同。研究并尝试每种工具,找出最适合您、您的团队成员和您的项目的工具。

JavaScript 中的单元测试

一旦您习惯了使用静态代码分析来让您确信您的 JavaScript 是高质量的,那么是时候进入确保高质量 JavaScript 代码的下一个层次了:单元测试。如果您将 JavaScript 文件中的每个函数编写为一个单独的行为单元,由输入和输出组成,并执行一个清晰的、文档化的操作,那么单元测试就是一个 JavaScript 函数,它使用不同的输入依次执行您编写的每个函数,并检查输出是否与预期的相匹配。

清单 3-2 中的 JavaScript 函数将传递给它的所有数字相加。

清单 3-2。一个简单的函数,将传递给它的任何数字相加

var add = function() {

var total = 0,

index = 0,

length = arguments.length;

for (; index < length; index++) {

total  = total + arguments[index];

}

return total;

};

对这种函数的单元测试将使用几个不同的输入来运行该函数,包括将输入留空等边缘情况,以确保原始函数以预期的方式运行,并为每个输入组合生成适当的结果。通过以这种方式严格测试代码库中的每个函数,您可以提高代码的质量,并且当这些经过单元测试的函数在您的系统中运行时,您可以更加确信您的系统将按预期运行。

JavaScript 的单元测试框架

虽然您可以自己编写代码来对自己的代码进行单元测试,但是从这一领域的其他人的工作中获益并使用一个已建立的单元测试框架来测试您的代码是有意义的。在撰写本文时有几个这样的框架可用,包括 QUnit ( http://bit.ly/q_test )、Mocha ( http://bit.ly/mocha_test )和 Jasmine ( http://bit.ly/jas_test )。每一个都以相似的方式工作,由包含框架代码的 JavaScript 库文件、包含要测试的 JavaScript 代码的文件和包含要针对该代码运行的单元测试的文件组成,该框架代码被设计用于需要包含框架库文件的 HTML 页面中。当在浏览器中打开 HTML 页面时,测试会自动运行,并在屏幕上显示详细的结果,包括测试通过和失败。因为这些框架彼此相似,所以我将带您了解如何只使用 Jasmine 框架为您的代码编写单元测试,这是我个人对这项任务的选择。

使用 Jasmine 进行 JavaScript 单元测试

Jasmine 单元测试框架允许您将一系列针对单个函数或方法的单个测试组合在一起;然后,可以将组集合再次组合在一起,以便一起测试相关的代码。测试组被称为套件,单个测试被称为规格。您的代码库中的每个文件通常都应该有一个相关的测试套件文件,组合到一个单独的文件夹中,我通常将其命名为spec

Tip

尝试使用与相关代码文件相同的名称来命名您的测试规范文件,并加上–spec。例如,一个名为add.js的代码文件可能有一个名为add-spec.js的相关测试套件文件。

A978-1-4302-6269-5_3_Fig3_HTML.jpg

图 3-3。

The Jasmine unit testing framework project homepage features extensive documentation

一个简单的套件文件可能如清单 3-3 所示,它针对清单 3-2 中定义的add()函数执行了两个单独的测试。

清单 3-3。一个简单的测试规范文件

describe("The add function", function() {

it("Adds 1 + 2 + 3 together correctly", function() {

var output = add(1, 2, 3);

expect(output).toEqual(6);

});

it("Adds negative numbers together correctly", function() {

var output = add(-1, -2, -3);

expect(output).toEqual(-6);

});

});

清单 3-3 中的代码使用 Jasmine 的describe()函数将add()函数的单元测试组合在一起,用字符串"The add function"描述组中的测试,并将组中的单个单元测试包装在一个单独的匿名函数中。每一个单元测试都是用 Jasmine 的it方法定义的,用一个前面带 it 的字符串来描述,并且包含一个对add()函数的调用,用 Jasmine 的expect()函数检查它的返回值。然后通过调用toEqual()函数将它链接起来,这是一个匹配器的例子,一个 Jasmine 函数,它检测并比较测试的输出是否与预期的结果匹配。我们在清单 3-3 中使用的匹配器函数toEqual(),检查函数调用的结果是否与我们已知的操作值完全匹配。

为了运行测试规范来测试我们的功能,在执行测试之前,我们需要一个网页来加载 Jasmine 框架、源文件和测试文件。通过 http://bit.ly/jas_dl 下载 Jasmine 的最新版本,您将获得框架本身和一个名为SpecRunner.html的示例 HTML 文件,该文件名为 spec runner,包含一些要运行的示例源文件和示例测试。用我们自己的替换示例源文件和测试,我们可以更新 HTML 页面,如清单 3-4 所示,来加载我们的测试。我假设您已经将您的源文件命名为add.js并将您的规范命名为add-spec.js,并且这些文件与规范运行程序包含在同一个文件夹中。

清单 3-4。一个 Jasmine spec runner HTML 文件,配置为加载和运行我们的单元测试

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"

"http://www.w3.org/TR/html4/loose.dtd

<html>

<head>

<title>Jasmine Spec Runner</title>

<link rel="shortcut icon" type="image/png" href="lib/jasmine-1.3.1/jasmine_favicon.png">

<link rel="stylesheet" type="text/css" href="lib/jasmine-1.3.1/jasmine.css">

<script type="text/javascript" src="lib/jasmine-1.3.1/jasmine.js"></script>

<script type="text/javascript" src="lib/jasmine-1.3.1/jasmine-html.js"></script>

<script type="text/javascript" src="add.js"></script>

<script type="text/javascript" src="add-spec.js"></script>

<script type="text/javascript">

(function() {

var jasmineEnv = jasmine.getEnv();

jasmineEnv.updateInterval = 1000;

var htmlReporter = new jasmine.HtmlReporter();

jasmineEnv.addReporter(htmlReporter);

jasmineEnv.specFilter = function(spec) {

return htmlReporter.specFilter(spec);

};

var currentWindowOnload = window.onload;

window.onload = function() {

if (currentWindowOnload) {

currentWindowOnload();

}

execJasmine();

};

function execJasmine() {

jasmineEnv.execute();

}

})();

</script>

</head>

<body>

</body>

</html>

在 web 浏览器中加载这个 HTML 页面将自动执行单元测试,在屏幕上显示结果,指示通过或失败。运行清单 3-4 中的 spec runner 的输出表明所有测试都通过了,如图 3-4 所示。

A978-1-4302-6269-5_3_Fig4_HTML.jpg

图 3-4。

Running the spec runner from Listing 3-4 indicates that all tests passed

当编写单元测试时,重要的是只编写证明代码正确运行所必需的测试;这将涉及对一组正常的、预期的输入至少进行一次测试。您还应该测试边缘情况,即提供不寻常或意外输入的情况,因为这些情况将迫使您考虑在发生这种情况时代码中会发生什么,从而在潜在错误影响最终用户之前捕获它们。在我们的add()函数的例子中,明智的做法是测试当没有输入时会发生什么,或者当提供非数字输入时会发生什么。我们知道我们的功能不会以这种方式被心甘情愿地使用;然而,在边缘情况下,我们试图保护我们的代码免受意外的,以及故意的误用。

让我们扩展清单 3-3 中的测试规范,添加一个测试用于没有输入的情况,另一个测试用于提供非数字输入的情况。在前一种情况下,当没有提供输入时,我们希望输出为0,而在后一种情况下,我们希望添加所有提供的数字输入,并忽略任何非数字输入。

清单 3-5。清单 3-2 中的add()函数的单元测试

describe("The add function", function() {

it("Adds 1 + 2 + 3 together correctly", function() {

var output = add(1, 2, 3);

expect(output).toEqual(6);

});

it("Adds negative numbers together correctly", function() {

var output = add(-1, -2, -3);

expect(output).toEqual(-6);

});

it("Returns 0 if no inputs are provided", function() {

var output = add();

expect(output).toEqual(0);

});

it("Adds only numeric inputs together", function() {

var output = add(1, "1", 2, "2", 3, "3");

expect(output).toEqual(6);

});

});

在我们的 spec runner 中运行这些新的单元测试显示,前三个测试成功完成,由页面顶部的绿色圆圈表示,如图 3-5 所示,但是第四个测试失败,由红色叉号表示。每个失败的单元测试的标题下列出了失败的详细信息。在这里,我们期望在最后的单元测试中函数输出是6是不正确的,实际产生的输出是"112233",所有提供的输入的字符串连接。

A978-1-4302-6269-5_3_Fig5_HTML.jpg

图 3-5。

Running the spec runner with the unit tests from Listing 3-4 shows that one test fails

现在,我们的单元测试揭示了我们代码中的一个潜在问题,这可能导致在现实世界的网页中使用该代码时出现错误,我们需要返回并修改清单 3-2 中的原始add()函数,以便为非数字输入生成我们期望的输出。我们将加法封装在一个if语句中,该语句检查输入参数是否是一个数字,如果是,则允许加法,如果不是,则拒绝加法。

针对清单 3-6 中更新后的add()函数再次运行 spec runner,会为每个测试生成一个通过,这让我们相信在页面中使用它时不太可能出现错误。

清单 3-6。更新清单 3-2 中的 add 函数来处理非数字输入

var add = function() {

var total = 0,

index = 0,

length = arguments.length;

for (; index < length; index++) {

if (typeof arguments[index] === "number") {

total  = total + arguments[index];

}

}

return total;

};

Tip

只为您自己编写的代码编写单元测试。您不应该为您不负责的第三方库或其他脚本编写测试;这是图书馆代码作者的责任。

当在具有不同输入的网页中运行代码时,您可能会遇到其他情况,即对于给定的输入显示出意外的输出。如果发生这种情况,在修改您的函数以纠正问题之前,您应该使用您发现导致页面错误的确切输入集创建一个单元测试,以便重现该问题。然后,您应该重写代码,直到您的所有单元测试(包括新测试)都通过。从那时起,通过将这个新的单元测试留在规范中,您可以确保在将来更新原始函数的代码时,可以再次捕捉到错误。

Tip

当选择第三方库用于您的页面时,考虑给那些采用单元测试原则的库额外的权重。这将使您确信您的页面中的所有代码都已经过单元测试,并且发生错误的可能性变得更小。

基于虚拟机的在线测试服务 Browserstack ( http://bit.ly/brow_stack )和 Sauce Labs ( http://bit.ly/sauce_labs )能够在多个不同的 web 浏览器和操作系统上同时自动运行单元测试,并向您报告结果。您可能会认为这是手动运行测试的一个有价值的替代方法。这两项服务都提供了基本的免费选项供您开始使用。

其他匹配者

到目前为止,我们编写的单元测试是基于一组给定的输入,产生一个精确的数字输出。为此,我们使用了茉莉匹配器toEqual()。您可能正在测试的函数很可能需要其他更具体类型的比较,在这种情况下,Jasmine 为您提供了许多其他匹配。

为了查明函数调用的结果是否与特定情况相反,您可以将属性not与另一个匹配器组合在一起,如下所示:

expect(output).not.toEqual(6);

要比较函数结果和正则表达式,使用toMatch()匹配器。如果每次运行时输出可能不一样,这一点特别有用:

expect(output).toMatch(/[a-s]/);

expect(output).not.toMatch(/[t-z]/);

如果您想与真值、假值或空值进行比较,请使用toBe()toBeTruthy()toBeFalsy()toBeNull()匹配器:

expect(output).toBe(true);

expect(output).not.toBe(null);

expect(output).toBeTruthy();

expect(output).not.toBeNull();

expect(output).toBeFalsy();

如果您需要找出一个单元测试输出值是否有一个undefined值,使用toBeDefined()匹配器:

expect(output).toBeDefined();

expect(output).not.toBeDefined();

如果您需要确定函数的数组结果是否包含特定的值,您应该使用toContain()匹配器:

expect(output).toContain("Monday");

expect(output).not.toContain("Sunday");

当测试包含数学运算的函数时,对于相同的输入并不总是产生相同的输出,您可以使用匹配器toBeGreaterThan()toBeLessThan():

expect(output).toBeGreaterThan(2);

expect(output).not.toBeLessThan(3);

第三个数学匹配器toBeCloseTo(),允许您确定函数的浮点(一个带小数位的数字)输出是否接近一个期望的数字,直到特定的小数位,作为第二个参数传递给匹配器:

expect(3.1415).toBeCloseTo(3.14, 2); // true

expect(3.1415).toBeCloseTo(3, 0); // true

如果您的函数在其输入参数无效的情况下抛出错误,您可以在单元测试中用toThrow()匹配器检查这一点:

expect(output).toThrow();

expect(output).not.ToThrow();

最后,如果 Jasmine 的核心匹配器都不能满足您的需求,您可以编写自己的自定义匹配器函数来满足您的特定需求。相关文档可通过 http://bit.ly/new_matchers 在线获取。

从这些例子中你应该已经注意到,Jasmine 的核心匹配器函数是以这样一种方式命名的,该语句可以被理解为几乎是用简单的英语编写的。这意味着,只要稍加练习,为您的代码编写单元测试就会变得简单,并且您会发现自己越来越多地这样做,增强了您对代码的信心,即代码是以最高质量编写的,并且在页面上运行时不太可能导致错误。

Jasmine 的其他特性

在这一章中,我们已经了解了使用 Jasmine 的基本知识,让单元测试在您的函数上运行,并根据给定的一组输入检查它们的输出是否符合预期。Jasmine 的能力远远超出了我们在这里讨论的范围,包括在每个测试运行前后运行特定的设置和拆卸代码的能力,对包括 JavaScript 定时器在内的异步操作的支持,从其他位置模拟您没有明确测试的对象的能力,等等。我鼓励通过 http://bit.ly/jas_test ,使用这个令人印象深刻的单元测试框架的完整在线文档,为自己进行实验和研究。

处理运行时错误

到目前为止,我们已经看到了如何使用静态代码分析和单元测试来提高您的代码质量,以及您在运行时不会产生错误的信心。然而,有时当代码运行在单元测试没有预料到的页面上时,确实会发生错误。如前所述,如果确实发生了这种情况,您需要添加一个新的单元测试来涵盖这种情况,以便将来可以捕捉到它,但这对于试图使用您的页面的用户没有帮助,他们对页面停止正常运行感到沮丧。幸运的是,JavaScript 为我们提供了一种在运行时捕捉错误的方法,允许我们以这样的方式处理错误,以确保代码的其余部分不会因此而阻塞。通过编写代码来捕捉和解决潜在的错误,您可以确保对代码有更大的信心,确保它在运行时不会产生意外的错误,避免用户失望,这意味着您的代码质量可以被认为是非常高的。

JavaScript 的本地错误类型

运行 JavaScript 代码时会出现六种类型的错误:语法错误、类型错误、范围错误、求值错误、引用错误和 URI 错误。

当 JavaScript 代码无法被正确解析时,就会发生语法错误,这可能是由于误用了语言规则,或者更有可能是由于杂散字符进入了代码的错误部分,这意味着它对浏览器不再有意义:

var PI = ; // throws a syntax error as JavaScript was expecting a value between = and ;

当操作中使用的值不是该操作预期使用的类型(例如,字符串、数字、布尔值)时,或者当方法用于错误数据类型的值时,将发生类型错误。在许多情况下,发生这种情况是因为使用的值意外地是null或具有undefined值:

var PI = 3.1415;

PI.concat(9); // throws a JavaScript type error – the concat method only applies to Strings

当 JavaScript 的一个核心函数期望提供一个特定范围内的数字,而您提供给它一个超出该范围的数字时,就会出现范围错误。例如,当对一个数字使用toFixed()方法时,如果作为参数提供给函数的位数超出了020的范围,则会引发范围错误:

var PI = 3.1415;

PI.toFixed(21); // throws a JavaScript range error

如果您试图以不正确的方式调用 JavaScript eval()方法,无论是试图用new关键字实例化它还是试图将其设置为变量,都会发生 eval 错误。然而,对于前一种情况,大多数浏览器会抛出一个类型错误,而在后一种情况下,允许它作为一个变量被覆盖,所以您在日常编码中不太可能遇到这种错误。然而,您应该不惜一切代价避免使用eval(),因为它对您的代码有安全风险:

new eval(); // Should throw a JavaScript eval error, commonly throws a type error instead

eval = "var PI = 3.1415"; // Should throw a JavaScript eval error, but lets it run anyway

当您试图从尚未声明的变量中访问数据时,会发生引用错误。确保在您创建的每个函数的顶部声明您希望在该函数范围内使用的变量:

alert(PI); // throws a JavaScript reference error since PI has not yet been defined

如果您向 JavaScript 的 URI 函数之一提供格式错误的 URL,就会发生 URI 错误:decodeURI()encodeURI()decodeURIComponent()encodeURIComponent()escape(),unescape():

decodeURIComponent("%"); // throws a JavaScript URI error – the parameter contains an invalid

// URI escape sequence

包装 try-catch 语句中可能出错的代码

如果您怀疑 JavaScript 代码的某一行在执行时可能抛出六种本机错误类型中的一种,您应该将它封装在一个try块中,该块将捕获错误,防止它阻止您的代码的其余部分运行。如果在您的try块中有多个语句,并且其中一个发生了错误,那么抛出错误的语句之后的语句将不会运行。相反,控制将传递给一个相关的catch块,该块必须紧随其后,从这里您可以选择如何优雅地处理错误。向catch块传递一个参数,该参数包含所发生错误的细节,包括其类型;这允许您在该块中以不同的方式处理不同类型的错误。一旦执行了catch块,控制就流向该块之后的代码,在try - catch语句之外。

清单 3-7。试抓块

var PI = 3.141592653589793238462643383279502884197,

decimalPlaces = Math.floor((Math.random() * 40) + 1), // random number between 1 and 40

shortPi;

// Wrap any code you suspect might cause an error in a try-catch statement

try {

shortPi = PI.toFixed(decimalPlaces); // Throws a range error if decimalPlaces > 20

} catch (error) {

// This block is executed only if an error occurs within the try block, above

alert("An error occurred!");

}

鉴于catch块仅在try块中发生错误时执行,如果您希望无论是否发生错误都执行代码,您可以在语句中添加一个可选的finally块。这很少使用,因为无论是否发生错误,紧跟在try - catch语句之后的代码都会被执行,但是您可能会发现使用finally将完整的try - catch - finally语句中的相关代码组合在一起很有用。

清单 3-8。try-catch-finally 块

var PI = 3.141592653589793238462643383279502884197,

decimalPlaces = Math.floor((Math.random() * 40) + 1),

shortPi;

try {

shortPi = PI.toFixed(decimalPlaces);

} catch (error) {

decimalPlaces = 20;

shortPi = PI.toFixed(decimalPlaces);

} finally {

alert("The value of PI to " + decimalPlaces + " decimal places is " + newPi);

}

Note

一个try程序块可以在没有catch程序块的情况下使用,但前提是包含一个finally程序块。在这种情况下,浏览器中仍然会抛出一个错误,但在此之前,finally 块会被执行,从而允许您在抛出错误之前处理好代码中的任何遗留问题。

检测引发的错误类型

传递给catch块的参数是一个“类”或类型的对象实例,与 JavaScript 语言的六种本地错误类型之一相关;对于前面描述的六种错误类型,它们分别被命名为SyntaxErrorTypeErrorRangeErrorEvalErrorReferenceErrorURIError。这些错误类型中的每一种都是从 JavaScript 本地基本类型Error继承而来的。在您的catch块中,您可以通过使用instanceof关键字来确定发生的错误的类型,正如我们在第一章中所述。

清单 3-9。检测 catch 块中捕获的错误类型

try {

// Code that might throw an error to go here

} catch (error) {

if (error instanceof SyntaxError) {

// A syntax error was thrown

} else if (error instanceof TypeError) {

// A type error was thrown

} else if (error instanceof RangeError) {

// A range error was thrown

} else if (error instanceof EvalError) {

// An eval error was thrown

} else if (error instanceof ReferenceError) {

// A reference error was thrown

} else if (error instanceof URIError) {

// A URI error was thrown

}

}

error对象包含一个名为message的属性,该属性包含发生的错误的文本描述。根据您的浏览器,该对象还可能包含错误发生的行号的详细信息和其他信息,尽管这是对该语言的非标准补充。明智的做法是避免读取特定于浏览器的属性,而是依靠错误类型来决定在catch块中采取什么动作。

创建自定义错误类型

如果您的项目中出现了特定类型的错误,您可能希望用自己的自定义代码来处理它,而不是重复使用基于六种本机错误类型的代码。幸运的是,创建一个定制的错误类型就像用 JavaScript 创建一个新的“类”一样简单。

清单 3-10。使用清单 1-19 中的 Class.create()创建自定义错误类型

var ElementNotFoundError = Class.create({

id: "",

message: "The element could not be found by the given ID",

initialize: function(id) {

this.id = id;

}

});

如果您想随后执行它,以便它被浏览器识别为一个错误,使用throw关键字和您的错误类型“class”的一个实例:

throw new ElementNotFoundError("header");

实际上,您可以在错误中抛出任何值,从简单的字符串到对象文字,再到完整的对象实例,就像我们在这里所做的那样。在您的catch块中,您将可以访问抛出的数据,因此这取决于您的项目在发生错误时需要多少数据。我更喜欢创建一个自定义的错误“类”,因为我可以检测它的类型并存储我可能需要的任何属性,以便在错误发生时帮助我调试错误。

让我们把这些放到一个真实的自定义错误处理的例子中,如清单 3-11 所示。

清单 3-11。定义和抛出自定义 JavaScript 错误

var ElementNotFoundError = Class.create({

id: "",

message: "The element could not be found by the given ID",

initialize: function(id) {

this.id = id;

}

});

function findElement(id) {

var elem = document.getElementById(id);

if (!elem) {

throw new ElementNotFoundError(id);

}

return elem;

}

try {

findElement("header");

} catch (error) {

if (error instanceof ElementNotFoundError) {

alert("Sorry, the 'header' element was not found");

}

}

Tip

尝试创建您自己的基本错误“类”,所有自定义错误类型都从该类继承。您可以使用这个基本“类”使用 Ajax 将系统中发生的任何错误的详细信息发布到服务器上的日志中,从而允许您主动跟踪和修复代码中的错误,提高代码质量。

通过使用静态代码分析和单元测试,您可以限制在网页中执行代码时会出现的运行时错误的数量。然而,为了捕捉那些漏网的未知错误,可以在代码中使用try - catch语句来防止这些错误导致代码在用户浏览器中停止执行。

衡量代码质量

本章的目的是帮助您学习如何通过使用静态代码分析、单元测试和运行时错误处理来提高代码质量,并减少出错的可能性。但是如果没有某种方法来衡量代码的质量,你怎么知道我们的代码有多好呢?通过运行工具来建立关于代码质量的度量,我们可以采取措施来改进代码质量,并在相同工具的每次后续运行中看到改进的度量。

单元测试代码覆盖率

我们在本章前面已经看到,一个单元测试针对你编写的一个函数运行,目的是证明在给定一组特定输入的情况下,它能产生一个预期的输出。如果我们能够检测出该函数中哪些特定的代码行被执行了,哪些没有被执行,我们就可以基于此生成代码质量的度量标准。这种度量被程序员称为代码覆盖率,可以使用 JavaScript 生成。然后,可以使用收集到的关于执行了哪些行和代码分支的信息来添加额外的单元测试,以确保覆盖所有的行和分支,这增加了度量,但更重要的是,增加了您对代码的信心。

为了生成一个列表,列出哪些代码行被执行了,哪些没有被执行,我们的原始函数的每一行代码都需要包装在一个函数调用中,该函数调用增加一个计数器,并存储一个对它在原始文件中对应的行号的引用。这样,这样一个函数在代码行被执行时被调用,记录哪些行被执行了,哪些行没有被执行。幸运的是,您不需要自己编写代码来生成这些函数包装器,因为有一个经过良好测试的 JavaScript 库伊斯坦布尔( http://bit.ly/istanbul_cover )可以帮您完成这项工作。

伊斯坦布尔是一个 JavaScript 应用,旨在与 Node.js 一起工作,node . js 是一个应用框架,它在命令行上运行完全用 JavaScript 编写的文件。我们将在下一章中更详细地讨论 Node.js,但是现在您需要做的就是安装它。

访问 http://bit.ly/node_js 并下载 Node.js,按照说明安装;这还会安装一个名为节点包管理器(NPM)的工具,它允许您快速方便地从中央存储库中下载应用(称为包)。

接下来,我们将下载 Grunt,这是一个基于 JavaScript 的任务运行器,它将简化使用伊斯坦布尔运行 Jasmine 单元测试的工作。我们将在第一章 2 中更详细地介绍 Grunt,但是现在我们只需要在命令提示符下运行以下命令来安装它。这将安装 Grunt 命令行界面,它允许我们在同一台机器的不同项目文件夹中安装和运行不同版本的 Grunt。Mac 和 Linux 用户可能需要在下面的命令前面加上sudo,以便使用必要的权限运行它:

npm install –g grunt-cli

Grunt 需要在我们的项目文件夹中创建两个专门命名和格式化的文件才能运行。第一个是package.json,包含项目细节,比如名称、版本和代码依赖。我们稍后会添加依赖项,但是现在使用清单 3-12 中的代码创建一个名为package.json的文件,它只定义了项目名称和版本号。

清单 3-12。通过 Grunt 用伊斯坦布尔运行 Jasmine 单元测试的 package.json 文件

{

"name": "jasmine-istanbul-grunt",

"version": "1.0.0"

}

现在我们需要将我们需要的 Grunt 的特定版本安装到我们的项目文件夹中,方法是在本地安装它,并将其作为一个依赖项列在我们的package.json文件中。我们可以通过一个简单的步骤做到这一点,在命令行上执行以下命令:

npm install grunt --save-dev

现在我们已经安装了 Grunt,我们需要安装 Jasmine 和伊斯坦布尔任务来运行它。在命令行上执行以下命令来安装它们:

npm install grunt-contrib-jasmine --save-dev

npm install grunt-template-jasmine-istanbul --save-dev

为了从 Grunt 运行我们的测试,我们需要创建第二个文件Gruntfile.js,它将包含我们希望运行的任务的定义以及我们希望为它们使用的设置。在本例中,这是 Jasmine 和伊斯坦布尔任务以及要测试的原始 JavaScript 函数及其单元测试的具体位置。我们还需要指定一个输出文件夹位置,用于保存伊斯坦布尔生成的代码覆盖率报告。清单 3-13 显示了这个文件应该如何实现这一点,假设要测试的代码在当前目录下的一个src文件夹中,单元测试在一个spec文件夹中。将创建一个reports文件夹,如果它还不存在的话,用来存储生成的覆盖报告。因为我们将在第一章 2 中详细介绍 Grunt,所以我不会在这里进一步解释Gruntfile.js的代码。在这里,您可以随意地向前跳,以完全理解每一行代码,或者只是暂时坚持下去,以查看通过伊斯坦布尔生成的代码覆盖报告。

清单 3-13。通过 Grunt 用伊斯坦布尔调优 Jasmine 单元测试的 Gruntfile.js

module.exports = function(grunt) {

grunt.initConfig({

jasmine: {

coverage: {

src: ["src/*.js"],

options: {

specs: ["spec/*.js"],

template: require("grunt-template-jasmine-istanbul"),

templateOptions: {

coverage: "reports/coverage.json",

report: [

{

type: "lcov",

options: {

dir: "reports"

}

},

{

type: "text-summary"

}

]

}

}

}

}

});

grunt.loadNpmTasks("grunt-contrib-jasmine");

grunt.loadNpmTasks("grunt-template-jasmine-istanbul");

grunt.registerTask("default", ["jasmine:coverage"]);

};

最后一步是在命令行上运行 Grunt,这将触发 Gruntfile.js 中列出的任务执行,在伊斯坦布尔的检测代码中包装要测试的原始函数,然后通过 PhantomJS 在命令行上运行 Jasmine 中的那些单元测试,PhantomJS 是一个基于 WebKit 的无头 web 浏览器,它没有用户界面,但可以通过 Node.js 通过 JavaScript API 访问。org 。

grunt default

一旦运行,您应该注意到生成了一个新的reports文件夹,其中包含一个 HTML 文件和其他文件。在浏览器中打开这个 HTML 文件将会向您展示代码覆盖结果,如图 3-6 所示,以一种被称为 LCOV 的格式运行单元测试。页面顶部显示的结果表明在单元测试运行期间执行了多少行原始函数。点击文件名将显示您的原始代码清单,突出显示所有单元测试运行时哪些行被执行,哪些行没有被执行。

A978-1-4302-6269-5_3_Fig6_HTML.jpg

图 3-6。

Code coverage results of running Istanbul, in LCOV format

这给你一个分数,表明你对代码的质量和信心。如果您的单元测试覆盖了每一行代码,那么与测试覆盖的代码行较少的情况相比,您可以更确定您的代码在应用中运行时将按预期方式运行。然后,您可以使用来自伊斯坦布尔报告的信息来改进并添加到您的单元测试中,以便在再次运行伊斯坦布尔时获得更高的覆盖率。

测量代码复杂性

任何有经验的专业 JavaScript 开发人员都会告诉你,你的代码越复杂,就越难保证它的质量。复杂的代码更难编写单元测试,更难维护,当出现问题时更难调试。您可以通过降低复杂性来提高 JavaScript 代码的质量。设计模式,我们将在第五章的中讨论,可以帮助解决这个问题,但是有一些更简单的事情会有所不同,比如将长于几行的函数代码分解成更小的块。较小的函数更容易测试,更容易重用,而且由于它们被赋予了可读的名称,有助于使代码更容易阅读,从而更容易维护。你还应该尝试通过查看代码中的分支数量来降低复杂性,比如ifelseswitch语句,看看是否可以通过将代码分解成更小的函数,或者重新安排或重构代码来降低复杂性。

Jarrod Overson 的 Plato 工具( http://bit.ly/plato_tool )可以针对您的 JavaScript 代码运行,以生成报告,突出显示根据其内部复杂性规则可以被视为复杂的函数。然后,您可以努力改进这些函数,使代码更易于测试和维护,增加在应用中执行时对它们的信心,从而提高应用代码的质量。生成的报告非常有创意,我发现它有助于激励代码质量改进的过程。

该工具会生成一份关于整个代码文件以及其中包含的单个函数的报告。这是静态代码分析的一种形式,因为代码本身并不运行,它只是被观察。该报告包含许多指标,包括:

  • 代码行数,函数中的行数越多,越有可能变得复杂;
  • 可维护性得分,满分为 100 分,表示代码的可维护性——数字越高,Plato 认为代码越好。通过 http://bit.ly/maintain_score 阅读关于该可维护性得分的更多信息;
  • 估计的错误数量,基于 Maurice Howard Halstead 在 1977 年引入的度量标准,他认为软件开发可以作为一门经验科学来建立。这不是通过运行代码建立的实际错误计数值,而是基于代码复杂性的估计值。阅读有关如何通过 http://bit.ly/halstead_complex 计算的更多信息;
  • 难度,衡量代码编写和理解的难度,基于 Halstead 编写的公式。分数越低,代码被认为越不容易编写和理解;
  • 圈复杂度,一种表示代码分支、循环和对被测函数中存在的其他函数的调用的数量的度量,其中较低的分数表示复杂度较低;
  • 找到的 JSHint 静态代码分析错误数;和
  • 每个指标的历史结果,用于随时间进行质量比较。

要安装 Plato 工具,您需要 Node.js 及其 NPM 工具。请参阅本章前面有关安装 Node.js 的说明。然后在命令提示符下执行以下命令,在计算机上的任何文件夹中安装用于访问的工具。Mac 和 Linux 用户可能需要在命令前加上sudo来授予必要的安装权限:

npm install –g plato

要运行该工具,请在命令行上导航到包含要运行该工具的代码的文件夹,并执行以下命令,为名为src的文件夹中的所有文件生成报告,并将生成的报告放在名为reports的文件夹中:

plato –d reports src/*.js

您将在reports文件夹中找到一个 HTML 格式的报告,如果它不存在的话,它会自动创建。图 3-7 显示了该报告的顶部。重复运行相同的命令会导致历史数据显示在报告中,表明您的代码朝着实现更高质量的方向前进。

A978-1-4302-6269-5_3_Fig7_HTML.jpg

图 3-7。

A JavaScript complexity report generated by Plato

点击 Plato 概览报告中的单个文件名,跳转到特定于文件的报告,如图 3-8 所示,该报告详细描述了该文件中代码的更具体的度量,以及该文件的代码本身,并突出显示了任何 JSHint 错误和警告。

A978-1-4302-6269-5_3_Fig8_HTML.jpg

图 3-8。

A Plato JavaScript complexity report for an individual file

Plato 是一个测量 JavaScript 代码质量的极好的工具,我建议您在自己的项目中彻底研究一下。

摘要

本章的目的是帮助您编写高质量的 JavaScript 代码,并向您展示如何对您在页面中执行的代码充满信心。我们详细研究了静态代码分析工具、单元测试、运行时错误捕获和处理,以及如何测量代码质量,所有这些都是为了减少错误并改善应用最终用户的体验。随着时间的推移,努力提高代码质量,你不仅会发现你的代码有更少的错误,而且你还会发现它更容易阅读、编写和理解,使你的日常工作生活更加简单。

在下一章,我们将看看如何通过结合编码技巧、技术、最佳实践和现代 API 的使用来提高 JavaScript 应用的性能。

四、提升 JavaScript 性能

在这一章中,我们将看看如何通过编码技巧、技术、最佳实践和现代 API 来提高 JavaScript 应用的性能。我从整体上看待性能,这意味着我考虑了整个应用的生命周期。这意味着,我认为,应用的感知性能可以通过更快、更高效的 JavaScript 文件加载,以及更高效的调用、选择器,甚至通过 CSS 和 HTML 优化来提高,而不是仅仅关注执行的代码行数。然而,在这一章中,我们将特别关注如何利用 JavaScript 代码和文件来提高网站和应用的性能。如果你想了解更多关于提高页面渲染性能的信息,可以看看 Addy Osmani 关于这个主题的优秀案例研究,题为“通过 http://bit.ly/gone_60 以每秒 60 帧的速度消失”。

改善页面加载时间

在我们开始修改任何 JavaScript 代码以提高应用的性能之前,我们需要查看浏览器与 JavaScript 代码的第一次接触,这是通过 HTML <script>标签加载的引用。我们在此阶段所做的更改将确保您的代码快速有效地加载,这意味着您的代码可以更快地执行,并提高您的应用的感知响应能力。

HTML 标签顺序很重要

当浏览器遇到一个<script>标签时,在大多数情况下,它会停止呈现页面,直到它设法读取并解析该脚本的内容,以防该脚本包含一个document.write()方法调用,这意味着在该点上页面的呈现发生变化。因此,将所有的<script>标签移到 HTML 中的</body>标签之前。这样,您的整个页面将在脚本加载和解析之前呈现,从而提高页面的感知性能。

您 JavaScript 文件的 GZip 编码交付

简单的服务器设置可以确保您的 JavaScript 代码文件以及 HTML、CSS 和任何其他基于文本的文件以最有效的方式发送到浏览器,方法是在发送之前压缩或“压缩”数据,并在数据到达浏览器时解压缩或“解压缩”,从而减少网络上传输的数据,以更快的速度到达浏览器。此设置称为 gzip 编码,只要您能够控制服务器的设置,几乎可以在任何 web 服务器上启用:

  • 对于托管在 Apache web 服务器上的网站,安装并配置 mod_deflate 模块( http://httpd.apache.org/docs/2.2/mod/mod_deflate.html )以启用 gzip 压缩。
  • 对于托管在微软 IIS 7 上的站点,打开 IIS 管理器程序并选择功能视图。打开压缩选项并选中复选框,为静态和/或动态内容类型启用 gzip 压缩。静态内容每次都产生相同的输出,例如 CSS 或平面 HTML 文件,而动态内容使用应用服务器端代码产生不同的输出。
  • 对于 Node.js 上的站点主机和使用 Express 框架( http://expressjs.com ),只需在应用代码中尽早使用 Express 对象的compress()方法。接下来的所有回答都将被 gzip 编码。

每个请求的动态 gzip 编码过程会消耗服务器上的额外资源和 CPU 时间。如果您使用的服务器的性能受到您的关注,您可以提前压缩您的 JavaScript 和其他基于文本的文件。只要确保使用额外的 HTTP 头Content-Encoding: gzip来提供这些预压缩的文件,就可以获得相同的性能提升,而不会对服务器的性能造成任何损失。

缩小、混淆和编译

JavaScript 文件越小,通过网络下载到浏览器的速度就越快,浏览器读取和解析它的速度也就越快。因此,我们需要尽我们所能来确保我们的代码尽可能的小,我们通过三个过程来实现,即缩小、混淆和编译。

缩小是指从 JavaScript 中删除所有空格和换行符,以产生更小的文件大小,但仍然包含开发人员编写的完全相同的代码语句。因为 JavaScript 的执行基于特定的关键字和语句,而不是它所包含的空白字符,所以我们可以安全地删除这些以减小文件大小。

模糊处理是一种更高级的代码优化形式,它查看变量和函数名,并确定哪些是全局可访问的,哪些仅限于特定范围。任何全局变量和函数名称都保持不变,但是那些具有有限范围的名称被重命名为更短的名称,从而显著减少这些名称在代码文件中占用的空间。通过确保以正确的方式在正确的位置替换名称,代码将继续像混淆发生之前一样运行。代码中的全局变量和函数越少,这是一个很好的实践,因为它减少了你的代码和其他代码之间相互干扰的机会,你的混淆代码就变得越小。

编译是一个更高级的过程,它从整体上研究您的代码,并试图简化、减少代码语句,以及将代码语句组合成具有相同行为的其他语句。尽管可用于执行这种特定类型优化的工具较少,但当与缩小和模糊处理结合使用时,它在产生最小文件大小方面是最有效的。

让我们以清单 4-1 中的函数为例,分别尝试使用缩小、混淆和编译来减小它的大小。这个清单中的代码有 205 字节,我们需要知道它,以便发现我们得到的文件有多小。

清单 4-1。要缩小、混淆和编译的函数

var add = function() {

var total = 0,

index = 0,

length = arguments.length;

for (; index < length; index++) {

total  = total + arguments[index];

}

return total;

};

使用 JSMin 进行代码精简

道格·克罗克福德的 JSMin 工具,可通过 http://bit.ly/js_min 获得,如图 4-1 所示,写于 2001 年,目的是缩小 JavaScript 文件以减小文件大小,从而缩短下载时间。它被认为是第一个可用的此类工具,它基本上删除了文件中所有不必要的空白和回车。最初只作为 MS-DOS 命令行工具提供,现在它可以作为大多数使用 Node.js 应用框架的操作系统的命令行工具,这要感谢开发人员 Peteris Krumins,他将它移植到了这个应用平台上(通过 http://bit.ly/node_jsmin 下载)。

A978-1-4302-6269-5_4_Fig1_HTML.jpg

图 4-1。

The JSMin homepage describes how the process of minification works under the hood

在你的计算机上安装 Node.js 之后(从 http://nodejs.org ,在你的命令行上执行下面的命令来安装 JSMin 以便在你的计算机上的任何文件夹中使用。Mac 和 Linux 用户可能需要在命令前加上sudo来以足够的权限执行命令:

npm install -g jsmin

缩小文件就像指定输出文件名一样简单,前面是命令行工具的–o选项,后面是要缩小的文件名,如下所示:

jsmin –o Listing4-1.min.js Listing4-1.js

通过 JSMin 运行清单 4-1 中的代码,产生了清单 4-2 中所示的代码,它重 136 字节,文件大小减少了 33.6%。

清单 4-2。用 JSMin 缩小后的清单 4-1 中的代码

var add=function(){var total=0,index=0,length=arguments.length;for(;index<length;index++){total=total+arguments[index];}

return total;};

使用丑陋的 JS 进行代码混淆

几年后出现的比 JSMin 更先进的工具是 UglifyJS,其主页通过 http://bit.ly/uglifyjs_home 在线,可以在图 4-2 中看到。可以通过 http://bit.ly/uglify_js 作为独立工具下载。现在,在它的第二个版本中,它缩小并混淆了您的 JavaScript 代码,以减小文件大小。它可以直接从项目主页运行,也可以使用 Node.js 通过命令行工具运行。安装 Node.js 后,在命令行上执行以下命令,安装 UglifyJS,以便在计算机上的任何文件夹中使用。Mac 和 Linux 用户应该知道在这之前加上sudo,通常用于他们系统的全局操作:

npm install uglify-js –g

A978-1-4302-6269-5_4_Fig2_HTML.jpg

图 4-2。

Code can be obfuscated directly from the UglifyJS homepage

通过 UglifyJS 运行清单 4-1 中的代码会产生下面清单 4-3 中所示的代码,它的重量为 88 字节,减少了 57%。

清单 4-3。清单 4-1 中的代码在用丑陋的 JS 混淆后

var add=function(){for(var r=0,n=0,a=arguments.length;a>n;n++)r+=arguments[n]

return r}

使用 Google Closure 编译器进行代码编译

我们在第三章看了一下谷歌的 Closure 编译器,把它作为检查你的代码错误的工具,但是它的主要目的实际上是缩小、混淆和编译你的代码到一个比你的原始文件小得多的文件中。在我看来,这是目前最有效的 JavaScript 压缩工具。

要使用闭包编译器优化您的代码,尝试通过 http://bit.ly/closure_compile 将您的代码复制并粘贴到在线表单中,如图 4-3 所示,然后按下编译按钮。优化后的代码将出现在页面右侧的“已编译代码”选项卡中。这个框的上方会出现一个链接,允许您直接下载编译后的代码作为文件。如果您喜欢独立运行编译器,而不是通过 web 浏览器,那么可以使用 Node.js 的包在命令行上运行该工具。

A978-1-4302-6269-5_4_Fig3_HTML.jpg

图 4-3。

JavaScript code may be compiled through the Google Closure Compiler online service

通过 Google Closure Compiler 运行清单 4-1 中的代码产生了清单 4-4 中所示的代码,它只有 88 个字节,减少了 57 %,比任何通过 JSMin 缩小得到的结果都好,实际上与通过 UglifyJS 混淆得到的结果相同。

清单 4-4。用 Google Closure 编译器编译后的清单 4-1 中的代码

var add=function(){for(var a=0,b=0,c=arguments.length;b<c;b++)a+=arguments[b];return a};

对于更大的代码块,这种方法甚至可以产生比丑陋的 JS 更大的改进。这是因为,与缩小和混淆工具不同,Google Closure Compiler 实际上运行并研究代码,不断寻找功能与原始代码完全相同的最佳最终代码。这与缩小和混淆形成对比,缩小和混淆使用一种静态分析形式,其中代码不运行,只是简单地按原样观察。

在大多数情况下,Google Closure Compiler 会产生最好的结果和最小的输出文件大小,我建议在您的应用中采用它来显著减小 JavaScript 文件的大小,使它们更快地加载,并为您的最终用户带来更好的体验。

避免全局变量以获得更好的压缩

代码中的任何全局变量或函数名将在缩小、混淆或编译后保持其名称,因此代码的行为不受这些压缩操作的影响。因此,您拥有的全局变量或函数越少,您的代码就越小,因为这些名称可以在压缩期间用更短的名称重写。避免无意中创建全局变量的一个好的编码技术是将你的整个代码或者部分代码放在一个匿名的、自执行的函数闭包块中,这将为变量和函数名定义创建一个新的非全局范围,如清单 4-5 所示。这些变量名中实际上需要是全局变量的任何一个都可以在匿名函数之外显式列出,剩下的所有变量都可以更有效地压缩。

清单 4-5。用匿名自执行函数闭包最小化全局变量

// Define a global variable

var myGlobalVariable;

// Create a self-executing, anonymous (unnamed) function to wrap around your code

(function() {

// Your code, that before was global, goes here with a new, non-global scope,

// making it easier to generate smaller compressed files via minification,

// obfuscation, or compilation

// Define a local variable

var myLocalVariable = "Local";

// Set the global variable to a string

myGlobalVariable = "Global";

// The open-close bracket combination here executes the function straight away

}());

按需延迟加载 JavaScript 文件

当使用 HTML 文件中的文件引用正常加载 JavaScript 文件时,浏览器会阻止下载和解析页面的其余部分,直到脚本下载并执行完毕。这并不理想,尤其是如果有大量代码要下载的话。有一种方法可以使用 JavaScript 本身来克服这个性能瓶颈,但是您必须小心。脚本通常会阻止浏览器并行加载脚本,以避免竞争情况,即一段代码在另一段代码之前完成,从而导致执行顺序错误。如果您打算以这种方式加载脚本,那么您需要一种技术来关联一个 JavaScript 代码块,以便在文件完成下载后执行,从而防止这种竞争情况的发生。

您可以通过 JavaScript 动态创建一个新的<script>标记,设置它的src属性指向要异步加载的文件的位置,从而为 JavaScript 文件创建一个非阻塞请求。通过将一个函数连接到标签的onload方法,我们可以执行依赖于这个被加载的脚本的特定代码。清单 4-6 显示了一个函数,它将这种行为封装在一个简单的函数中,该函数的参数分别是一个要加载的脚本文件位置和一个文件加载后要执行的函数。

清单 4-6。在不阻塞浏览器的情况下按需加载 JavaScript 文件

function loadScript(src, onLoad) {

var scriptTag = document.createElement("script");

scriptTag.src = src;

if (typeof onLoad === "function") {

scriptTag.onload = onLoad;

scripTag.onreadystatechange = function() {

if (scriptTag.readyState === 4) {

onLoad();

}

}

}

document.body.appendChild(scriptTag);

}

清单 4-6 中的loadScript方法可以在你的代码中的任何地方使用,如下所示:

// Loads my-script.js, then outputs "script loaded and available" when complete

loadScript("my-script.js", function() {

alert("script loaded and available!");

});

// Loads my-script.js, which is self-contained and does not require extra code to be

// execute once it has loaded. The second parameter is therefore not passed in.

loadScript("my-script.js");

我们将在第六章中进一步探讨这个主题,在那一章中,我将介绍 RequireJS 库,它允许 JavaScript 文件的延迟加载和使用标准化格式对代码文件依赖关系的简单管理。

优化文档对象操作

大多数网站和应用性能低下的一个最大原因是通过 JavaScript 对 HTML 页面元素的低效访问。因为任何 web 浏览器中的 JavaScript 引擎都是独立于其呈现引擎的,所以通过 JavaScript 获取对页面元素的引用涉及到从一个引擎跳到另一个引擎,浏览器充当两者之间的中介。为了提高性能,我们需要减少这种跳跃发生的次数。在这一节中,我概述了一些技术来帮助避免 JavaScript 对 HTML 页面上的元素进行不必要的访问。

最小化对页面元素的访问

减少对页面元素的访问是相当简单的,因为一旦 JavaScript 中有了对页面元素的引用,你只需将该引用存储在一个变量中,并在整个代码中引用该变量,而不是返回页面再次获取相同的引用,如清单 4-7 所示。

清单 4-7。将 DOM 元素引用存储在变量中以供将来访问

var header = document.getElementById("header"),

nav = document.getElementById("nav");

header.className += " " + nav.className;

如果您需要访问位于同一父元素中的多个页面元素,只获取对父元素的引用,并使用 JavaScript 从该引用中定位子元素,这将通过访问父元素的单个请求在页面中实现,如清单 4-8 所示。避免简单地获取对整个页面的引用,并将其用作页面的整个 DOM 树所占用的内存,再加上 JavaScript 需要额外的时间来定位您希望在树中进一步访问的元素,这实际上可能会对您的应用产生负面的性能影响。

清单 4-8。通过引用单个父元素来访问子 DOM 元素

var wrapper = document.getElementById("wrapper"),

header = wrapper.getElementsByTagName("header")[0],

nav = wrapper.getElementsByTagName("nav")[0];

header.className += " " + nav.className;

如果你需要动态地创建和添加 DOM 元素到页面,在添加新的元素到页面之前,应用所有的属性并设置所有必要的属性,如清单 4-9 所示。这样,浏览器就不需要一直访问动态 HTML 页面来进行更改。

清单 4-9。在将新元素添加到动态页面之前对其进行 DOM 更改

var list = document.createElement("ul"),

listItem = document.createElement("li");

// Perform all the DOM manipulation possible within JavaScript first

listItem.innerHTML = "I am a list item";

list.appendChild(listItem);

// Finally, add the element to the page when you are sure you no longer need to alter it

// before display

document.body.appendChild(list);

尽可能关闭现有元素

动态创建 DOM 元素在网站和应用中是一个相当常见的需求,但是每次使用标准的document.createElement()方法创建一个元素并以类似于另一个现有元素的方式配置它时,您都会付出性能代价。为了提高性能,复制现有的元素而不是从头开始创建新的元素,如清单 4-10 所示。如果您正在创建多个具有相似属性的元素,那么创建一个实例,然后使用 DOM 元素的cloneNode()方法来复制元素及其相关属性。

清单 4-10。复制现有元素以提高性能

var list1 = document.createElement("ul"),

list2,

listItem1 = document.createElement("li"),

listItem2,

listItem3;

listItem1.className = "list-item";

listItem1.innerHTML = "I am a list item";

// The cloneNode method duplicates the element efficiently. Setting the optional parameter

// to 'true' also copies across any child elements and properties. Leaving this property out

// copies just the individual element itself

listItem2 = listItem1.cloneNode(true);

listItem3 = listItem1.cloneNode(true);

// Add the three list items to the unordered list element

list1.appendChild(listItem1);

list1.appendChild(listItem2);

list1.appendChild(listItem3);

// Duplicate the entire unordered list

list2 = list1.cloneNode(true);

// Add the two identical unordered list elements to the live page

document.body.appendChild(list1);

document.body.appendChild(list2);

利用离线 DOM

DOM 规范的一个经常被忽视的方面是文档片段,或者离线 DOM,它是 DOM 的一个轻量级版本,用于创建和操作小的元素树结构,以便以后添加到动态页面中,如清单 4-11 所示。使用这种技术操作元素比使用动态页面 DOM 有更好的性能。

清单 4-11。利用离线 DOM 避免访问在线 DOM

// Create a DocumentFragment object as an offline DOM structure, disconnected from the live DOM

var offlineDOM = document.createDocumentFragment(),

// Create elements for adding to the page dynamically

header = document.createElement("header"),

nav = document.createElement("nav");

// Add each element to the offline DOM

offlineDOM.appendChild(header);

offlineDOM.appendChild(nav);

// Add a copy of the offline DOM to the live page

document.body.appendChild(offlineDOM);

使用 CSS 而不是 JavaScript 来操作页面样式

CSS 样式属性可以通过 DOM 使用元素的style属性直接操作。更新影响其布局的页面元素的样式属性会导致浏览器中发生回流,如清单 4-12 所示,它计算了对页面上该元素和其他元素所做的更改的效果。回流需要时间来完成,因此按顺序操作多个样式属性会导致不必要的回流次数。

清单 4-12。演示直接更新 DOM 元素样式属性时浏览器回流

var nav = document.getElementsByTagName("nav");

nav.style.backgroundColor = "#000"; // Causes a reflow in the browser

nav.style.color = "#fff"; // Causes a reflow

nav.style.opacity = 0.5; // Causes a reflow

这个问题有两个解决方案。第一种是通过 JavaScript 将 CSS 类应用于页面元素,而不是单独的样式。这会导致所有的 CSS 规则同时应用到元素上,导致只发生一次回流,如清单 4-13 所示。这样做还有一个额外的逻辑好处,就是将所有的视觉和布局规则保存在一个专门用于此任务的 CSS 文件中,而不是用一些通过 CSS 应用的样式和一些通过 JavaScript 应用的样式来搅浑水。

清单 4-13。将 CSS 类应用于 DOM 元素以减少浏览器回流

var nav = document.getElementsByTagName("nav");

nav.className += " selected"; // The CSS class name "selected" contains multiple style settings

第二个解决方案是,如果第一个在您的特定环境中无法实现,那么在对其他样式属性进行更改之前,将display样式属性设置为none。这将从页面流中直观地移除元素,导致浏览器回流,但这意味着当元素不在页面流中时,任何其他样式属性的更改都不会导致回流。一旦做了更改,元素应该通过设置它的display属性为block或任何其他可接受的值返回到页面流,如清单 4-14 所示。

清单 4-14。通过隐藏元素同时改变它们的样式属性来减少浏览器重排

var nav = document.getElementsByTagName("nav");

nav.style.display = "none"; // Causes a browser reflow, hiding the element from display

nav.style.backgroundColor = "#000"; // Causes no reflow since the element is hidden

nav.style.color = "#fff"; // Causes no reflow

nav.style.opacity = 0.5; // Causes no reflow

nav.style.display = "block"; // Causes a browser reflow, bringing the element back on display

提高 DOM 事件性能

自从 JavaScript 出现以来,我们一直在编写代码,将网站和应用上的功能与用户操作联系起来。当用户提交表单、点击链接或加载页面时,我们希望能够拦截这些动作,并通过 JavaScript 改善页面体验。我们需要这些动作快速,并且不中断浏览器中其余页面的行为。附加或处理不当的事件会导致性能问题,尽管幸运的是,通过适当的事件委托和框架,我们可以将事件处理的性能影响降至最低。

将事件委托给父元素

DOM 事件从它们第一次被触发的元素冒泡到文档结构的最顶层。这意味着,例如,当用户点击一个链接时,JavaScript 为链接元素触发一个click事件,然后是父元素上的一个click事件,依此类推,对于 DOM 树中的每个元素,直到树结构的最顶端,最后到达根<html>元素。这被称为事件的泡沫阶段。在此之前,有一个捕获阶段,在这个阶段中,事件从 DOM 树的顶部向下触发到元素。当使用元素的addEventListener()方法设置事件处理程序时,您需要提供事件名称、事件在元素上发生时将执行的处理程序函数,以及最后的第三个参数,一个布尔型truefalse值,它分别指示您希望在事件生命周期的捕获阶段还是冒泡阶段触发事件。

在提高应用的性能时,我们可以利用冒泡阶段,因为这意味着我们只需要添加一个事件处理程序,通过将该处理程序应用于用户所操作的元素的父元素来处理多个元素上的事件。然后,您可以根据事件发生的元素的属性来分配要发生的动作,如清单 4-15 所示,其中单个事件处理程序处理在许多子元素上触发的事件。这些被称为事件委托,因为它们成为基于事件和页面元素的属性委托动作的逻辑块。假设此清单在 HTML 页面的上下文中运行,该页面的一部分包含以下标记:

<ul id="list">

<li class="list-item"><a href="/" class="list-item-link">Home</a></li>

<li class="list-item"><a href="/news" class="list-item-link">News</a></li>

<li class="list-item"><a href="/events" class="list-item-link">Events</a></li>

</ul>

清单 4-15。链接列表上的事件委托

// Get a reference to the list element surrounding all the links we wish to

// assign the event handler to

var list = document.getElementById("list");

// Define a function to execute when either the link or an element within the link

// is executed

function onClick(evt) {

// Get a reference to the actual element clicked on using the event's 'target' property

var clickedElem = evt.target,

tagNameSought = "A";

// Check to see if the element clicked on is of the type we are looking for, in this

// case if it is an <a> tag

if (clickedElem && clickedElem.tagName === tagNameSought) {

// If it is, we get the link's 'href' value and open this in a new window

window.open(clickedElem.href);

}

}

// Assign the event handler to the list item, the parent surrounding all the links. Adding

// one event handler is faster than assigning an event handler to each of the individual

// links. The third parameter is set to 'false', indicating that events should be handled

// in the bubble phase of the event lifecycle, from the element the event occurred on, up the

// tree to the list item itself

list.addEventListener("click", onClick, false);

通过利用事件委托,您可以确保在页面加载时只有少数事件可以绑定到页面元素,从而减少 DOM 访问并提高性能。这有助于实现减少最终用户能够与页面交互所需时间的总体目标。

用框架处理速射事件

某些事件可能会非常快速地连续触发,例如当页面被主动调整大小时的浏览器resize事件,当鼠标或触摸屏被主动使用时的mousemovetouchmove事件,或者当页面被滚动时的scroll事件;这些事件可能每隔几毫秒就会发生一次。将事件处理程序直接连接到这些执行大量代码或潜在计算密集型操作的事件可能会导致性能问题。如果在另一个事件触发时正在执行事件处理程序代码,则函数调用被堆叠;只有当第一个事件的代码完成时,第二个事件的代码才能开始执行。如果许多事件快速连续发生,浏览器很快就会在负载下挣扎,导致用户界面更新的延迟,从而导致无响应、糟糕的用户体验。

对于这些类型的快速触发事件,请调整您的代码,以便事件处理函数只需将事件的当前值保存到一个变量中。这意味着对于每个mousemovetouchmoveresizescroll事件,事件处理程序只是将鼠标位置、触摸位置、浏览器宽度和高度以及滚动位置存储在变量中。这不会导致任何性能问题。将计算量大的代码移到一个单独的函数中,该函数在不太频繁的计时器或时间间隔上触发,运行代码并使用存储在变量中的值,而不是直接从事件处理程序中触发。清单 4-16 展示了这个被称为事件框架的原则。

清单 4-16。提高性能的事件框架

// Create variables to store the scroll position of the page

var scrollTopPosition = 0,

scrollLeftPosition = 0,

body = document.body,

header = document.getElementById("header");

// Create an event handler function that does nothing more than store the current

// scroll position

function onScroll() {

scrollTopPosition = body.scrollTop;

scrollLeftPosition = body.scrollLeft;

}

// Add a function to write the current scroll position to the header element of the page

function writeScrollPosition () {

header.innerHTML = scrollTopPosition + "px, " + scrollLeftPosition + "px";

}

// Connect the event to the handler function as usual

document.addEventListener("scroll", onScroll, false);

// Execute the writeScrollPosition function once every 500 ms rather than every time the

// scroll event fires, improving application performance

window.setInterval(writeScrollPosition, 500);

尽可能避免将计算密集型事件处理函数直接分配给可能连续快速触发的事件。请改用事件框架来提高事件处理的性能。

提高功能性能

提高 JavaScript 应用的性能很大程度上是为了提高执行代码的效率。函数是应用效率的主要领域,其中执行的每一行都关系到代码的速度和性能。因此,减少执行的行数是游戏的名字,我们可以通过一种叫做记忆化的技术来实现。

用记忆存储以前的函数返回值

当涉及到减少应用中执行的代码行数时,我们需要确保在相同的函数以相同的参数被执行两次的情况下,我们将第一次执行的结果存储在一个变量中,用于代替对相同函数的第二次调用。清单 4-17 显示了一个计算任意数的数学阶乘的函数,这个函数在一个应用中可能会被调用很多次。

清单 4-17。函数计算一个数的阶乘

// getFactorial calculates the factorial of a number, i.e. the multiplication of each number

// from 1 up to the supplied number. The factorial of 3, for example, is (1 * 2 * 3) = 6

function getFactorial(num) {

var result = 1,

index = 1;

for (; index <= num; index++) {

result *= index;

}

return result;

}

// Example usage

alert(getFactorial(3)); // = (1 * 2 * 3) =  6

alert(getFactorial(4)); // = (1 * 2 * 3 * 4) = 24

alert(getFactorial(5)); // = (1 * 2 * 3 * 4 * 5) = 120

一旦我们调用了清单 4-17 中的函数来计算一个数的阶乘,将结果存储在一个变量中以便在我们的代码中使用以避免再次执行整个函数将是有益的。然而,理想情况下,我们需要一种方法来自动完成这项工作,因为在一个大型应用中,我们可能不知道某个特定的函数之前是否被调用过,也不知道我们是否提供了相同的输入。这就是 memoizer 的概念的由来,如果一个函数可以包含一个存储机制来保存以前执行特定输入的结果,那么它可以调用其存储来返回以前执行的函数输出,而不是再次重新执行整个函数,从而提高其性能。清单 4-18 展示了我们如何在计算阶乘的函数中增加存储,使用一个对象文字,并根据提供给函数的输入设置属性名。

清单 4-18。记忆清单 4-17 中的函数以提高它的性能

function getFactorial(num) {

var result = 1,

index = 1;

if (!getFactorial.storage) {

getFactorial.storage = {};

} else if (getFactorial.storage[num]) {

return getFactorial.storage[num];

}

for (; index <= num; index++) {

result *= index;

}

getFactorial.storage[num] = result;

return result;

}

// Example usage

alert(getFactorial(50)); // Executes the whole function

alert(getFactorial(50)); // Returns a stored value. Avoids full function execution,

// boosts performance

记忆技术可以产生显著的效果,通过大的因素改善复杂函数的性能;然而,要使它真正有价值,我们需要一种方法来将记忆应用到任何函数,而不必每次都手动添加它。清单 4-19 展示了如何构建一个实用函数,它允许你为任何函数增加存储结果值的能力,在可能的情况下从函数的内部存储属性自动返回函数的结果以提高性能。

清单 4-19。一个通用的 memoizer 函数,可与任何函数一起使用以提高其性能

// memoize() expects a function as an input and returns the same function

// with storage capabilities added

function memoize(fn) {

return function() {

var propertyName;

// Add a memory object property to this function, if it does not exist

fn.storage = fn.storage || {};

// Create a property name to use to store and retrieve function results within

// the 'storage' object literal. This should be based on a combination of

// all the arguments passed to the function to ensure it is unique based

// on all possible combinations of inputs.

// We borrow the 'join' method from the Array type as 'arguments' isn't a

// proper array type and doesn't contain this method.

propertyName = Array.prototype.join.call(arguments, "|");

// Does the key exist in the memory object?

if (propertyName in fn.storage) {

// If it does, then return the associated value to avoid re-execution of

// the full function

return fn.storage[propertyName];

} else {

// If it doesn't, execute the associated function then save the result

// to the memory object

fn.storage[propertyName] = fn.apply(this, arguments);

// Return the newly saved value, the result of the function's execution

return fn.storage[propertyName];

}

}

};

清单 4-20 中的代码展示了如何将清单 4-19 中的memorize()函数应用于清单 4-17 中的原始getFactorial()函数。

清单 4-20。将泛型记忆应用于函数

function getFactorial(num) {

var result = 1,

index = 1;

for (; index <= num; index++) {

result *= index;

}

return result;

}

// Add the generic memoize capability to the function

var getFactorialMemoized = memoize(getFactorial);

// Example usage

alert(getFactorialMemoized(50)); // Executes the whole function

alert(getFactorialMemoized(50)); // Returns a stored value. Avoids full function execution,

// boosts performance

对基于给定输入返回特定输出的 JavaScript 函数采用记忆化实践,您会发现应用的性能显著提高,尤其是对于计算密集型函数。

使用正则表达式进行更快的字符串操作

正则表达式提供了一种有用、强大和高效的方法来执行字符串定位、操作和模式匹配——比任何其他方法都快,这解释了为什么许多编程语言的开发人员从 20 世纪 60 年代首次在软件产品中使用它们以来就一直在使用它们!

在 JavaScript 中,正则表达式可以用两种不同的方式定义,使用对象构造函数或通过文字表达式,如清单 4-21 所示。

清单 4-21。在 JavaScript 中定义正则表达式

// Define a regular expression through JavaScript's RegExp constructor, where the expression

// is passed in the first parameter as a string, and any modifiers are passed as a string to

// the second parameter

var caps1 = new RegExp("[A-Z]", "g");

// Define a regular expression literal, where the expression is delimited by slashes (/) and

// any modifiers follow immediately after

var caps2 = /[A-Z]/g;

RegExp构造函数获取一个字符串并将其转换成正则表达式,而文字形式无需任何额外处理即可使用;这使得文字形式成为创建正则表达式的最快方式。因此,您应该避免使用RegExp,除非您绝对需要动态生成正则表达式。

正则表达式语法涉及使用特殊字符和字符序列来表达特定的含义,并描述应该如何处理表达式。表 4-1 总结了正则表达式中特殊字符的一些常见用法。

表 4-1。

Characters commonly used in regular expressions

| 特性 | 描述 | | --- | --- | | `[exp]` | 字符序列周围的方括号(`[]`)通知正则表达式处理器匹配括号内的任何字符。例如,`[ABC]`匹配任意一个字符`A`、`B`或`C`。 | | `[^exp]` | 将`^`字符放在方括号内将匹配除方括号内列出的字符之外的任何字符。例如,`[^ABC]`匹配除了字符`A`、`B`或`C`之外的任何内容。 | | `[exp1-exp2]` | 使用`-`字符表示表达式应该匹配从第一个指定字符到最后一个字符的序列。例如,`[A-Z]`匹配从`A`到`Z`的任何字符,包括这两个数字,`[0-9]`匹配`0`到`9`之间的任何数字,包括这两个数字。 | | `(exp)` | 在字符序列周围使用标准括号表示表达式应该与指定顺序的字符序列完全匹配。例如,`(great)`仅精确匹配字符序列`great`。 | | `(exp1|exp2)` | 在括号中使用管道字符`|`表示正则表达式应该与提供的表达式相匹配。例如,`(great|escape)`匹配字符序列`great`或字符序列`escape`。 | | `exp+` | 仅当表达式包含一次或多次时,在表达式后使用的`+`字符才匹配。例如,`A+`只有在字符`A`出现一次或多次时才匹配。 | | `exp*` | 如果表达式出现了零次或多次,在表达式后使用`*`字符进行匹配,这意味着它可能出现也可能不出现,这对于匹配表达式的可选部分很有用。例如,`A*` matches 是字符 A 出现一次或多次,或者根本不出现。 | | `exp?` | 如果表达式出现了 0 次或 1 次,则在表达式后使用的`?`字符匹配。例如,`A?`仅在字符`A`出现零次或一次(不超过一次)时匹配。 | | `\s` | 匹配空白字符,即空格、制表符、回车符、换行符、制表符或换页符。例如,如果表达式包含一个`A`字符,后跟一个空白字符,再后跟一个`B`字符,则`A\sB`匹配。 | | `\S` | 匹配除空白字符以外的所有内容。 | | `\d` | 匹配一个数字,`0`到`9`。 | | `\D` | 匹配除数字以外的所有内容。 | | `\w` | 匹配单词字符,即字母。 | | `\W` | 匹配非单词字符的所有内容。 |

正则表达式修饰符是一个定义正则表达式使用方式的选项。有三种可能的值,可以单独使用来应用单个选项,也可以一起应用多个选项,如表 4-2 所示。

表 4-2。

Regular expression modifiers

| 修饰语 | 描述 | | --- | --- | | `g` | 应用正则表达式,查找正在比较的字符串中的所有匹配项,而不只是返回第一个匹配项 | | `i` | 应用表达式,不考虑文本字符串的字母大小写 | | `m` | 将表达式应用于字符串中的多行文本,而不仅仅是第一行 |

正则表达式通常应用于字符串,以定位或替换其中的子字符串。有三种String类型的方法可以使用正则表达式— match()replace()search()match()方法查找与正则表达式匹配的子串,并将它们作为字符串数组返回,replace()方法查找相同的子串,然后用传递给该方法的另一个字符串替换它们,而search()方法仅定位正则表达式找到的子串的第一个实例,并将该子串在整个字符串中的位置作为数字索引返回。每种方法的例子如清单 4-22 所示。

清单 4-22。用于字符串查找子字符串的正则表达式方法

// This regular expression locates all capital letters from A to M, inclusive. The 'g' modifier

// indicates that the expression shouldn't stop when it reaches the first match, but continue to

// search through the rest of the applied string

var regEx = /[A-M]/g,

string = "The Great Escape",

match,

search,

replace;

// match() returns an array of the characters in the string found by the regular expression

match = string.match(regEx); // = ["G", "E"]

// search() returns the index of the first located character - the 'g' modifier in the regular

// expression is ignored

search = string.search(regEx); // = 4

// replace() switches out any located characters matched by the regular expression with the

// value in the second parameter

replace = string.replace(regEx, "_"); // = "The _reat _scape"

string replace()方法实际上比大多数开发人员认为的要强大得多,尤其是在与正则表达式一起使用时。第二个参数中给出的特定字符序列能够动态地将定位文本中的文本添加到替换文本中,如表 4-3 所示。

表 4-3。

Special characters for use in the second parameter of the JavaScript string replace() method

| 字符序列 | 意义 | | --- | --- | | `$$` | 用单个`$`字符替换找到的子字符串。比如:`"Hello World".replace(/o/g, "$$"); // "Hell$ W$rld"` | | `$&` | 用第一个参数中给定的子字符串替换找到的子字符串。例如:`"Hello World".replace(/o/g, "$&"); // "Hello World"` | | `$`` | 用定位的子字符串之前的文本替换定位的子字符串。例如:`"Hello World".replace(/o/g, "$`"); // "HellHell WHello Wrld"` | | `$'` | 用定位的子字符串后面的文本替换定位的子字符串。例如:`"Hello World".replace(/o/g, "$'"); // "Hell World Wrldrld"` | | `$1, $2, etc.` | 当第一个参数包含一个用括号将表达式分组的正则表达式时,这种表示法允许您提取在特定表达式中找到的子串。例如:`"Hello World".replace(/(o)(\s)/g, "$1$1$2"); // "Helloo World"` |

关于 string replace()方法的另一个鲜为人知的事实是,第二个参数可以作为函数而不是字符串值传递。在这种情况下,对于原始字符串中的每个子字符串匹配,该函数执行一次,将匹配的子字符串传递给该函数。然后使用函数的返回值,并代入替换后的字符串,如清单 4-23 所示。

清单 4-23。使用函数作为字符串replace()方法调用的第二个参数

// Initialize a value to use as a counter

var count = 0;

// Define a function to be executed on each matched substring, where the supplied parameter

// is the matched substring itself

function replaceWithCount(value) {

// Increment the counter

count = count + 1;

// Return to the replaced string the passed in value, with the current value of the

// counter appended to it

return value + count;

}

// Example usage

alert("Hello World".replace(/o/g, replaceWithCount)); // Hello1 Wo2rld

alert("Hello World".replace(/\s/g, replaceWithCount)); // Hello 3World

正则表达式可能变得非常复杂和强大,可能需要很多年才能理解和掌握。我在这里只讨论了最基本的内容;如果你想更深入地了解这个奇妙的世界,你可以在 Mozilla 的开发者网络上通过 http://bit.ly/reg_exps 找到关于 JavaScript 在线正则表达式使用的非常全面的参考资料。

更快地使用阵列

处理大型数据数组可能会降低代码的速度,因为您发现需要创建、访问、排序或循环访问它们,这通常涉及访问或操作数组中的每个单独的项。因此,数组越大,速度就越慢,你应该更加重视确保你的代码尽可能的高效。但是,有一些处理大型数据数组的提示和技巧,我将在本节中详细解释。

快速阵列创建

在 JavaScript 中有两种创建数组的方法,如下所示。JavaScript 创建和初始化数组的最快方法是后者,因为前者需要一个额外的步骤来获取Array类型的构造函数并对其执行new关键字:

var myArray = new Array();

var myArray = [];

快速数组循环

大多数 JavaScript 代码都充满了循环。通常,您需要处理数组或遍历对象文字来对存储在其中的数据执行计算或操作。众所周知,在 JavaScript 中循环数据是一项非常慢的任务,尤其是在较旧的浏览器中。清单 4-24 中的代码展示了一个典型的 JavaScript for循环,以及一个类似的,但是更加高效的版本。

清单 4-24。两种类型的 for 循环,一种比另一种更有效

var myArray = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100];

// The most common type of loop

for (var index = 0; index < myArray.length; index++) {

// On every iteration through the loop, the value of myArray.length must

// be recomputed to ensure it has not changed since the last iteration

// - this is slow

}

// A similar but much faster version of the same loop

for (var index = 0, length = myArray.length; index < length; index++) {

// The value of myArray.length is computed once and stored in a variable.

// the value is read back from the variable on each iteration instead of being

// recomputed - much faster!

}

您可以使用两个 JavaScript 命令来管理循环:

  • break停止当前循环的执行,继续执行循环后面的代码。
  • continue停止循环的当前迭代,进入下一次迭代。

如果您已经找到了要寻找的值,您可以使用这些命令有效地停止循环迭代,或者如果某些代码块与循环的当前迭代无关,则跳过这些代码块的执行。清单 4-25 中的代码显示了这两个命令的例子。

清单 4-25。使用中断和继续命令来缩短循环的迭代次数

var myArray = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100];

for (var index = 0, length = myArray.length; index < length; index++) {

if (myArray[index] < 50) {

// Ignore any values in the array below 50

// continue executes the next iteration immediately, ignoring any other code

// within the loop

continue;

}

if (myArray[index] == 90) {

// Ignore any values in the array above 90

// break stops the loop from iterating immediately, ignoring any other code

// no other iterations will be performed in the loop

break;

}

}

循环大量数据的最快方法是反向 while 循环。在这种技术中,我们使用一个while循环来代替一个for循环,并从数组的最后一个元素开始向下计数,直到到达第一个元素。这种方法比前面提到的for循环更快的原因是,在围绕for循环的每次迭代中,JavaScript 解释器必须运行一次比较,例如index < length,以知道何时停止循环。在while循环的情况下,当传递给while循环的参数为 falsy 值时,循环将停止运行,当其值为0时就会发生这种情况。正是因为这个原因,我们向下计数到数组的第一个索引0,因为它不需要执行更复杂的比较,所以这种类型的循环是最快的。清单 4-26 演示了反向 while 循环。

清单 4-26。反向 while 循环

var daysOfWeek = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],

len = daysOfWeek.length,

// The start index of the loop is the end item of the array

index = len,

daysOfWeekInReverse = [];

// Decrement the index each time through the loop, just after it's been compared in the while

// loop parameter. When the index is 0, the while loop will stop executing.

while(index--) {

daysOfWeekInReverse.push(daysOfWeek[index]);

}

// Because of the decrement in the while loop, at the end of the code, the value of index will

// be -1

alert(index); // -1

我觉得我应该强调一下,虽然这是最快的方法,但是对于大型数组来说,我们谈论的是几分之一秒的速度,所以本质上,这更像是一种教育,而不是编码技巧。

避免在循环中创建函数

为了更有效地使用数组,应该注意在循环中创建函数的陷阱。每次创建一个函数时,机器上的内存都是为该函数保留的,并用表示该函数的对象数据填充。因此,遍历 100 个项目并在每次循环中创建一个相同的函数将导致在内存中创建 100 个独立但相同的函数。这个原则在清单 4-27 中用一个更小的七项数组进行了演示,其中一个函数在循环的每一次迭代中被添加到一个结果对象文本中,这个结果对象文本可以用来反转原始数组项中的字符串。

清单 4-27。在循环中创建函数

var daysOfWeek = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],

index = 0,

length = daysOfWeek.length,

daysObj = {},

dayOfWeek;

// Loop through each day of the week

for (; index < length; index++) {

dayOfWeek = daysOfWeek[index];

// Add a property to the daysObj object literal for each day of the week, adding

// a function that reverses the name of the day of the week to each

daysObj[dayOfWeek] = {

name: dayOfWeek,

getReverseName: function() {

return this.name.split("").reverse().join("");

}

};

}

为了避免在每个循环中创建一个函数,只需在循环前创建并定义一个函数,并在循环中引用它,如清单 4-28 所示。

清单 4-28。在循环迭代中使用单个函数

var daysOfWeek = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],

index = 0,

length = daysOfWeek.length,

daysObj = {},

dayOfWeek;

// Define a single function to be used within any iteration of the loop

function getReverseName() {

// When called, the 'this' keyword will refer to the context in which it was called,

// namely the property in the daysObj object literal which it was called on

return this.name.split("").reverse().join("");

}

for (; index < length; index++) {

dayOfWeek = daysOfWeek[index];

daysObj[dayOfWeek] = {

name: dayOfWeek,

// Simply refer to the existing function here, rather than creating a new function

getReverseName: getReverseName

};

}

将密集型任务转移给 Web 工作器

您可能已经敏锐地意识到,JavaScript 只在浏览器的单个线程中运行。它对异步函数调用的处理意味着,例如,当对服务器进行 Ajax 调用时,整个用户界面不会锁定。然而,创建一个在每次迭代中进行大量处理的循环,你很快就会发现这是多么的受限,在某些情况下整个用户界面都会被锁住。

Web workers 是 Google 以 Gears 的名字创建的 W3C 标准化版本,它解决了这个问题,允许您旋转一个新的线程来执行特定的密集代码,避免了原来的线程锁定浏览器,本质上是在后台运行代码。如果您从操作系统的角度熟悉线程,您会很高兴听到 web worker 创建的每个新线程实际上都在操作系统中启动了一个全新的线程,这意味着它与原始线程完全不同,不涉及虚拟化。事实上,每个线程都与原始浏览器代码截然不同,它甚至不能访问 DOM 中的页面元素,也不能访问页面上的任何全局变量。为了在 web 工作线程中使用变量或对象,必须将其显式传递给它。所有 Web 浏览器的最新版本都支持 Web workers,尤其是从版本 10 开始的 Internet Explorer。

创建一个 web worker 线程如清单 4-29 所示,所有需要传递给 worker 构造函数的是包含在 Worker 线程中运行的代码的 JavaScript 文件的位置。这会创建 worker 对象并初始化它,但它还不会在 worker 线程中运行代码。这是为了让您有时间在创建 worker 之后、代码实际运行之前配置它。

清单 4-29。创建 web worker 是一项简单的任务

var workerThread = new Worker("filename.js");

创建一个 worker 固然很好,但在大多数情况下,您会希望将一些密集的代码卸载到一个新的 worker 线程,并明确表示希望从该线程取回代码的输出,以便在原始浏览器代码中使用。web worker 规范定义了两个事件,messageerror,当消息从 worker 回传或者 worker 内部发生错误时,分别调用这两个事件。可以使用 worker thread 对象的postMessage方法将消息发送到 worker thread,并且首先通过向它发送一条消息来启动 worker 运行,该消息中包含以这种方式触发它行动所需的任何输入数据,如清单 4-30 所示。

清单 4-30。配置 web 工作线程以侦听从工作线程发布的消息

//Create the worker thread

var workerThread = new Worker("filename.js");

// Start listening for messages posted from the code in the thread

workerThread.addEventListener("message", function(e) {

// The object e passed into the event handler contains the

// posted message in its data property

alert(e.data);

}, false);

// Run the code in the worker thread

workerThread.postMessage("");

在工作线程文件本身中,您使用self.addEventListener监听收到的消息,并可以使用self.postMessage将消息发送回调用浏览器脚本,从而完成两个脚本之间的通信循环。

如果一个 worker 已经完成了有用的工作,需要被关闭,可以从浏览器端或者在 worker 内部完成。在浏览器中,调用 worker 对象的terminate()方法会立即停止 worker 中的代码执行,无论它当时处于什么状态,它的线程都会立即终止。在工作线程本身中,对self.close()方法的调用将停止工作线程的运行并终止其线程,向调用浏览器脚本发回一条close消息。

使用 Web Worker 处理图像数据

让我们使用一个 web worker 来做一些繁重的图像处理,否则会阻塞标准浏览器脚本的用户界面。我们将从页面上的图像中获取原始图像数据,处理像素以创建同一图像的变体,然后在处理完成后替换原始图像。

为了从页面上的图像中提取原始像素数据,我们需要获取该图像并在 HTML5 <canvas>元素中绘制它,该元素用于在页面上的某个区域中绘制像素数据,我们可以使用 JavaScript 动态创建该区域。然后,我们可以获取这些原始像素数据并进行处理,使用 web worker 来避免在处理过程中锁定页面。这个 worker 将创建一组新的图像像素数据,我们可以将这些数据绘制到同一个<canvas>元素上,在将该元素添加到页面之前,用新处理的图像替换原始图像。我们将在第八章中更详细地介绍 HTML5 <canvas>元素,在那里我将解释如何使用这个强大而简单的新浏览器插件来构建简单的游戏。

清单 4-31。Web 工作器的图像处理

<!doctype html>

<html>

<head>

<meta charset="utf-8">

<title>Listing 4-31</title>

</head>

<body>

<img id="image" src="Listing4-31.jpg" alt="">

<script src="Listing4-32.js"></script>

</body>

</html>

清单 4-31 中的代码显示了一个简单的 HTML 页面,包含一个由imageid引用的图像,允许 JavaScript 轻松定位这个 DOM 元素。然后页面加载清单 4-32 中包含的 JavaScript 代码,如下所示。

清单 4-32。JavaScript 文件来启动图像处理

// Create a <canvas> element dynamically in JavaScript and get a reference to its

// 2d drawing context

var canvas = document.createElement("canvas"),

context = canvas.getContext("2d"),

// Get a reference to the image on the page

img = document.getElementById("image");

// Define a function to process the image data

function processImage() {

// Store the image width and height to avoid looking them up each time

var imgWidth = img.width,

imgHeight = img.height,

// Define a new web worker, using the code from the 'Listing4-33.js' file

workerThread = new Worker("Listing4-33.js");

// Set the new <canvas> element's dimensions to match that of the image

canvas.width = imgWidth;

canvas.height = imgHeight;

// Copy the image to the canvas, starting in the top-left corner

context.drawImage(img, 0, 0, imgWidth, imgHeight);

// Define the code to execute once a message is received from the web worker, which

// will be fired once the image data has been processed

workerThread.addEventListener("message", function(e) {

// Get the image data sent in the message from the event's data property

var imageData = e.data;

// Push the new image pixel data to the canvas, starting in the top-left corner

context.putImageData(imageData, 0, 0);

// Now add the resulting <canvas> element to the page. By performing all the necessary

// canvas actions before it's added to the page, we avoid the need for the browser to

// repaint the canvas element as we added and then replaced the image displayed on it

document.body.appendChild(canvas);

}, false);

// Kick off the web worker, sending it the raw image data displayed on the canvas

workerThread.postMessage(context.getImageData(0,0, imgWidth, imgHeight));

}

// Execute the processImage function once the image has finished loading

img.addEventListener("load", processImage, false);

清单 4-32 中的代码获取了一个对页面上图像的引用,一旦它被加载,就决定了它的宽度和高度,用来创建一个类似大小的<canvas>元素。然后提取原始图像像素数据,并使用清单 4-33 中的代码创建一个 web worker 对象。使用工作线程对象的postMessage()方法,像素数据被发送到工作线程,工作线程处理数据。一旦处理完成,worker 调用它自己的self.postMessage()方法,该方法是通过调用脚本的message事件监听器接收的,在将元素最终添加到页面之前,返回的、经过处理的图像数据被绘制到<canvas>元素上。

清单 4-33。Web worker 处理图像—反转其颜色

// Call the invertImage method when this worker receives a message from the calling script.

// The 'self' object contains the only methods a web worker can access apart from those it

// defines and creates itself

self.addEventListener("message", invertImage, false);

// Define a function to take an image and invert it, pixel by pixel, using its raw data

function invertImage(e) {

// The 'data' property of the 'message' event contains the pixel data passed from

// the calling script

var message = e.data,

// The 'data' property of the message passed contains the raw image pixel data

imagePixels = message.data,

x = 0,

len = imagePixels.length;

// Loop through each pixel, inverting its value within the original pixel data array.

// Pixel data is arranged in groups of 4 values, representing the red, green, blue, and

// opacity values of each visible screen pixel. We therefore loop through in jumps of 4

// on each iteration

for (; x < len; x += 4) {

// To invert a pixel's value, subtract it from the maximum possible value, which is 255

imagePixels[x] = 255 - imagePixels[x];

imagePixels[x + 1] = 255 - imagePixels[x + 1];

imagePixels[x + 2] = 255 - imagePixels[x + 2];

}

// Finally, post a message containing the updated pixel data back to the calling script

self.postMessage(message);

}

该图像处理操作的结果如图 4-4 所示,原始图像在左边,由 web worker 处理并绘制成<canvas>元素的反转图像在右边。

A978-1-4302-6269-5_4_Fig4_HTML.jpg

图 4-4。

Using a web worker and <canvas> to invert an image

基本性能测量

在这一章中,我们已经研究了很多提高 JavaScript 应用性能的技术,但是由于没有测量性能的方法,我们只能依靠自己的感觉来判断性能是否有所提高。

测量一段代码执行速度的最简单的方法是通过测量计算机时钟在代码执行开始和结束之间的差异来计时,如清单 4-34 所示。

清单 4-34。测量一个函数执行的时间

// Define variables to calculate the time taken to execute the function

var startTime,

endTime,

duration;

// Function to execute which we wish to measure

function doSomething() {

var index = 0,

length = 10000000,

counter = 0;

for (; index < length; index++) {

counter += index;

}

}

// Set the initial time to be the current date/time at this exact point, just before execution

// of the function

startTime = new Date();

// Execute the function

doSomething();

// Set the end time to be the current date/time just after execution

endTime = new Date();

// The time taken is the end time minus the first time, with both represented in milliseconds,

// the most precise measurement we have with JavaScript times

duration = endTime.getTime() - startTime.getTime();

alert(duration); // Took ∼700 ms on my machine

因为 JavaScript date 对象只表示低至毫秒级别的时间,所以使用这种技术我们无法获得任何更精确的度量。在第十四章的中,我们将看到console.time(),一种更精确的 JavaScript 代码测量形式,使用大多数浏览器内置的开发工具。

摘要

在这一章中,我们研究了可以用来提高 JavaScript 代码性能的技巧和技术,从脚本文件的初始加载,到处理 DOM 元素和事件,再到处理数组和字符串以提高性能的最佳方式。最后,我们讨论了如何将密集型代码任务卸载到单独的操作系统线程,以便在后台执行大量繁重操作的同时保持用户界面的响应,以及一种测量 JavaScript 代码性能的简单方法。关于 JavaScript 性能的主题,可能还有成千上万的技巧可以写,但是在大多数情况下,实现本章中详细介绍的技巧将会显著提高应用的性能。

在下一章中,我们将探讨 JavaScript 设计模式,解决某些代码问题的常用方法,以及如何组织您的代码,使其易于您和其他开发人员理解。

五、设计模式:创造型

在这一章和接下来的三章中,我将解释大规模 JavaScript 应用的设计模式和代码架构模式的原则,这些原则将使您的代码保持合理的组织和易于理解,使维护和添加代码的工作更加简单。通过实现这些章节中的一种或多种模式和技术,你会发现你的许多代码文件看起来彼此非常相似,这使得在同一个项目中一起工作的许多开发人员很快就熟悉了。事实上,如果您选择在多个项目中采用这些技术中的任何一种,您很可能会发现相同的模式和架构习惯用法适用于所有项目,这使得新开发人员更容易掌握不同项目的速度,并使这些开发人员能够专注于编写优秀的代码。

使用设计模式的秘诀是将它们视为编程工具箱中的工具,每种工具都有特定的用途。首先熟悉可用的模式以及何时使用每种模式,这些章节将有助于做到这一点,然后再尝试将它们应用到您的代码中——应用错误的工具会导致不必要的麻烦和浪费时间。除非您是一名经验丰富的 JavaScript 开发人员,否则您将在一开始就没有特定设计模式的情况下开始为您的应用编写代码,随着代码的增长,您会发现您需要对其进行更改,以便使进一步的开发更易于管理,并为代码库中的文件提供一些结构和熟悉度。这个过程通常被称为重构,通常在开发的这个阶段,您会考虑将特定的设计或架构模式应用到您的代码中,以简化未来的开发。对于那些坚持以特定模式开始新项目的人,或者那些坚持在一开始就使用特定的预建 JavaScript 框架的人,要保持警惕,因为除非他们是经验丰富的专业人士,否则这就相当于在确定需要工具解决的问题之前选择了一个新的、闪亮的工具。

每章都涵盖了您应该熟悉的设计模式和架构模式。研究每个模式并理解它是如何使用的,然后随着时间的推移,您将开始识别代码中需要应用的特定模式,以提高代码的可维护性,并且在某些情况下提高代码的效率。

什么是设计模式?

设计模式是经过试验和测试的、经过验证的编程和结构化代码的方式,因此它易于理解、易于维护,并且易于扩展,因为它有利于清晰性,为开发人员消除了不必要的复杂性,并且分离了大型代码库的不同部分之间的连接。它们是你编程工具箱中的工具。

设计模式最初是在 1994 年出版的一本名为《设计模式:可重用面向对象软件的元素》的书中介绍的,该书由 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 四人组合写。他们最初的例子是用 C++和 Smalltalk 编程语言编写的,但是他们描述的大多数模式的原则适用于任何语言,包括 JavaScript。作者描述了 23 种不同的设计模式,分为三个不同的类别:创造、结构和行为。在这一章和接下来的章节中,我们将讨论那些最适用于 JavaScript 编程的模式,跳过那些不相关的,包括一些原著中没有的同样适用于 JavaScript 的现代模式。虽然许多关于设计模式的教程一遍又一遍地重复使用了许多相同的例子,但是我为这里介绍的每种模式都创建了原始的例子,这些例子通常比您可能找到的其他例子更好地解决了现实世界中的编码问题。

创造性设计模式

创造性设计模式描述了一个为您创建对象的“类”或方法,而不是您自己直接创建它们。这一抽象层为您和您的代码提供了更大的灵活性来决定哪种对象或哪种类型的对象最适合您的特定情况和需求。在这里,我将向你介绍五种在你的代码中可能有用的创造模式,每种模式都有例子。

工厂模式

工厂设计模式允许您创建一个对象,而无需指定用于创建它的“类”。当我们在前面的章节中讨论“类”时,我们已经直接使用了new JavaScript 关键字来创建一个特定“类”或子类的实例;使用工厂模式,对象创建过程是抽象的,允许相对复杂的对象创建过程隐藏在一个简单的接口后面,该接口不需要new关键字。这种抽象意味着底层的“类”类型和创建它们的方法可以在任何时候被完全替换,而不需要为其他开发人员更改“类”创建的接口——如果您知道将来可能需要进行大量的更改,但是您不想在大量代码文件中重写您的“类”实例化代码,这是一个理想的选择。

清单 5-1 显示了工厂模式的一个例子,它基于依赖于工厂方法输入参数的许多不同的“类”来实例化对象。

清单 5-1。工厂设计模式

// Define the factory that will make form field objects for us using the most appropriate

// "class" depending on the inputs

var FormFieldFactory = {

// The makeField method takes two options:

// - type, which defines the type of form field object to create, e.g. text, email,

//    or button

// - displayText, which defines either the placeholder text for the form field, or the

//    text to display on the button, depending on the type

makeField: function(options) {

var options = options || {},

type = options.type || "text",

displayText = options.displayText || "",

field;

// Create an object instance using the most appropriate "class" based on the

// supplied input type

switch (type) {

case "text":

field = new TextField(displayText);

break;

case "email":

field = new EmailField(displayText);

break;

case "button":

field = new ButtonField(displayText);

break;

// If in doubt, use the TextField "class"

default:

field = new TextField(displayText);

break;

}

return field;

}

};

// Define the TextField "class" to be used for creating <input type="text"> form elements

function TextField(displayText) {

this.displayText = displayText;

}

// The getElement method will create a DOM element using the supplied placeholder text value

TextField.prototype.getElement = function() {

var textField = document.createElement("input");

textField.setAttribute("type", "text");

textField.setAttribute("placeholder", this.displayText);

return textField;

};

// Define the EmailField "class" to be used for creating <input type="email"> form elements

function EmailField(displayText) {

this.displayText = displayText;

}

// The getElement method will create a DOM element using the supplied placeholder text value

EmailField.prototype.getElement = function() {

var emailField = document.createElement("input");

emailField.setAttribute("type", "email");

emailField.setAttribute("placeholder", this.displayText);

return emailField;

};

// Define the ButtonField "class" to be used for creating <button> form elements

function ButtonField(displayText) {

this.displayText = displayText;

}

// The getElement method will create a DOM element using the supplied button text value

ButtonField.prototype.getElement = function() {

var button = document.createElement("button");

button.setAttribute("type", "submit");

button.innerHTML = this.displayText;

return button;

};

清单 5-2 展示了如何在应用中使用清单 5-1 中创建的工厂。

清单 5-2。正在使用的工厂设计模式

// Use the factory to create a text input form field, an email form field, and a submit button.

// Note how we do not need to know about the underlying "classes" or their specific inputs to

// create the form fields - the FormFieldFactory abstracts this away

var textField = FormFieldFactory.makeField({

type: "text",

displayText: "Enter the first line of your address"

}),

emailField = FormFieldFactory.makeField({

type: "email",

displayText: "Enter your email address"

}),

buttonField = FormFieldFactory.makeField({

type: "button",

displayText: "Submit"

});

// Wait for the browser's "load" event to fire, then add the DOM elements represented by the

// three newly created objects to the current page

window.addEventListener("load", function() {

var bodyElement = document.body;

// Use the getElement() method of each object to get a reference to its DOM element for

// adding to the page

bodyElement.appendChild(textField.getElement());

bodyElement.appendChild(emailField.getElement());

bodyElement.appendChild(buttonField.getElement());

}, false);

当您希望通过屏蔽创建特定对象的更复杂操作来简化其余代码中这些对象的创建时,最好使用工厂模式。要在线阅读更多关于工厂模式的详细信息,请查看以下资源:

抽象工厂模式

抽象工厂模式将我们刚刚看到的工厂模式向前推进了一步,允许根据共同的用途或主题一起创建多个工厂,如果您的应用需要的话,创建一个额外的抽象层。清单 5-3 中的代码演示了这种模式,通过将两个工厂视为一个新工厂类型的实例来扩展了清单 5-1 中的例子,从这两个工厂中它们隐藏了相似的行为。

清单 5-3。抽象工厂设计模式

// Define a base factory "class" for creating form fields, from which other, more specialised

// form field creation factory "classes" will be inherited.

function FormFieldFactory() {

// Define a list of supported field types to be applied to all inherited form field

// factory classes

this.availableTypes = {

TEXT: "text",

EMAIL: "email",

BUTTON: "button"

};

}

FormFieldFactory.prototype = {

// Define a makeField() method which will be overwritten by sub classes using polymorphism.

// This method should therefore not be called directly from within this parent "class" so

// we'll throw an error if it is

makeField: function() {

throw new Error("This method should not be called directly.");

}

};

// Define a factory "class", inherited from the base factory, for creating HTML5 form fields.

// Read more about the differences in these form fields from HTML4 at

//http://bit.ly/html5_webforms

function Html5FormFieldFactory() {}

Html5FormFieldFactory.prototype = new FormFieldFactory();

// Override the makeField() method with code specific for this factory

Html5FormFieldFactory.prototype.makeField = function(options) {

var options = options || {},

type = options.type || this.availableTypes.TEXT,

displayText = options.displayText || "",

field;

// Select the most appropriate field type based on the provided options

switch (type) {

case this.availableTypes.TEXT:

field = new Html5TextField(displayText);

break;

case this.availableTypes.EMAIL:

field = new Html5EmailField(displayText);

break;

case this.availableTypes.BUTTON:

field = new ButtonField(displayText);

break;

default:

throw new Error("Invalid field type specified: " + type);

}

return field;

};

// Define a factory "class", also inherited from the same base factory, for creating

// older-style HTML4 form fields

function Html4FormFieldFactory() {}

Html4FormFieldFactory.prototype = new FormFieldFactory();

// Override the makeField() method with code specific for this factory

Html4FormFieldFactory.prototype.makeField = function(options) {

var options = options || {},

type = options.type || this.availableTypes.TEXT,

displayText = options.displayText || "",

field;

switch (type) {

case this.availableTypes.TEXT:

case this.availableTypes.EMAIL:

field = new Html4TextField(displayText);

break;

case this.availableTypes.BUTTON:

field = new ButtonField(displayText);

break;

default:

throw new Error("Invalid field type specified: " + type);

}

return field;

};

// Define the form field "classes" to be used for creating HTML5 and HTML4 form elements

function Html5TextField(displayText) {

this.displayText = displayText || "";

}

Html5TextField.prototype.getElement = function() {

var textField = document.createElement("input");

textField.setAttribute("type", "text");

textField.setAttribute("placeholder", this.displayText);

return textField;

};

// Since the placeholder attribute isn't supported in HTML4, we'll instead create and return a

// <div> element containing the text field and an associated <label> containing the

// placeholder text

function Html4TextField(displayText) {

this.displayText = displayText || "";

}

Html4TextField.prototype.getElement = function() {

var wrapper = document.createElement("div"),

textField = document.createElement("input"),

textFieldId = "text-field-" + Math.floor(Math.random() * 999),

label = document.createElement("label"),

labelText = document.createTextNode(this.displayText);

textField.setAttribute("type", "text");

textField.setAttribute("id", textFieldId);

// Associate the <label> with the <input> using the label 'for' attribute and the input 'id'

label.setAttribute("for", textFieldId);

label.appendChild(labelText);

wrapper.appendChild(textField);

wrapper.appendChild(label);

return wrapper;

};

function Html5EmailField(displayText) {

this.displayText = displayText;

}

Html5EmailField.prototype.getElement = function() {

var emailField = document.createElement("input");

emailField.setAttribute("type", "email");

emailField.setAttribute("placeholder", this.displayText);

return emailField;

};

// We define the button form element to be identical for both HTML5 and HTML4 form field types,

// so no need for two separate "classes". If we ever needed to create a different HTML5 version

// in future, we'd only need to update the relevant factory "class" with the change, and the

// rest of the code in our full application will adapt accordingly

function ButtonField(displayText) {

this.displayText = displayText;

}

ButtonField.prototype.getElement = function() {

var button = document.createElement("button");

button.setAttribute("type", "submit");

button.innerHTML = this.displayText;

return button;

};

我们可以使用清单 5-3 中的抽象工厂,如清单 5-4 所示,根据运行代码的浏览器的支持,产生正确类型的表单域。

清单 5-4。正在使用的抽象工厂设计模式

// Establish if the browser supports HTML5, and select the appropriate form field factory

var supportsHtml5FormFields = (function() {

// This self-executing function attempts to create a HTML5 form field type:

// <input type="email">

var field = document.createElement("input");

field.setAttribute("type", "email");

// If the new form field returns the corrent field type then it was created correctly

// and is a browser that supports HTML5\. If not, the browser is HTML4-only

return field.type === "email";

}()),

// Use the value returned previously to select the appropriate field field creation factory

// "class" and create an instance of it

formFieldFactory = supportsHtml5FormFields ? new Html5FormFieldFactory() : new Html4FormFieldFactory(),

// Use the factory to create a text input form field, an email form field, and a submit

// button, which will now use the most appropriate field type and attributes for the

// current browser

textField = formFieldFactory.makeField({

type: "text",

displayText: "Enter the first line of your address"

}),

emailField = formFieldFactory.makeField({

type: "email",

displayText: "Enter your email address"

}),

// Notice how we can harness the availableTypes property containing the list of supported

// field types from the factory "class" instead of using a hard-coded text string for the

// form field type. This is preferred, just as variables are preferable over

// hard-coded values.

buttonField = formFieldFactory.makeField({

type: formFieldFactory.availableTypes.BUTTON,

displayText: "Submit"

});

// Wait for the browser's "load" event to fire, then add the DOM elements represented by the

// three newly created objects to the current page

window.addEventListener("load", function() {

var bodyElement = document.body;

// Use the getElement() method of each object to get a reference to its DOM element for .

// adding to the page

bodyElement.appendChild(textField.getElement());

bodyElement.appendChild(emailField.getElement());

bodyElement.appendChild(buttonField.getElement());

}, false);

当您需要根据现有代码中多个“类”之间的共享目的或共同主题,在它们之外创建一个额外的抽象层时,最好使用抽象工厂模式,以便降低应用其余部分的开发复杂性。要在线阅读有关抽象工厂模式的更多详细信息,请查看以下资源:

构建器模式

像我们到目前为止看到的工厂和抽象工厂模式一样,构建器模式抽象了对象的创建。在这种模式中,我们只需要提供我们希望创建的对象的内容和类型,以及决定使用哪个“类”来创建它的过程,由构建器抽象出来。我们有效地构造了一个完整的对象,方法是将它的创建分成一系列更小的步骤,最后调用一个“构建”结果对象的操作,将它返回给调用代码。一个构建器可能包含相当数量的代码,所有这些代码都是为了让开发人员尽可能轻松地创建对象。

清单 5-5 展示了构建器模式,它定义了一个构建器来创建简单的 HTML 表单,该表单包含任意数量和类型的表单字段,可以按任意顺序添加,也可以随时添加;一旦添加完所有字段,使用getForm()“build”方法,仅在需要时创建并返回<form>元素。

清单 5-5。构建器模式

// Define a builder "class" for constructing simple forms which can be configured according to

// the end developer's needs. The end developer will instantiate the builder and add fields to

// the form as needed throughout the course of their application, finally calling a method to

// return a <form> element containing all the fields added

function FormBuilder() {}

FormBuilder.prototype = {

// Define a property for storing fields created

fields: [],

// Define a method for adding fields to the form instance

addField: function(type, displayText) {

var field;

// Use the supplied form field type and display text to instantiate the relevant form

// field "class"

switch (type) {

case "text":

field = new TextField(displayText);

break;

case "email":

field = new EmailField(displayText);

break;

case "button":

field = new ButtonField(displayText);

break;

default:

throw new Error("Invalid field type specified: " + type);

}

// Add the created field object to the storage array

this.fields.push(field);

},

// Define a method for returning the resulting <form> element, containing the fields added

// using the addField method

getForm: function() {

// Create a new <form> element

var form = document.createElement("form"),

index = 0,

numFields = this.fields.length,

field;

// Loop through each field in the fields property, getting the DOM element from each and

// adding it to the <form> element

for (; index < numFields; index++) {

field = this.fields[index];

form.appendChild(field.getElement());

}

// Return the populated <form> element

return form;

}

};

// Define the underlying form field "classes", as in Listing 5-1

function TextField(displayText) {

this.displayText = displayText || "";

}

TextField.prototype.getElement = function() {

var textField = document.createElement("input");

textField.setAttribute("type", "text");

textField.setAttribute("placeholder", this.displayText);

return textField;

};

function EmailField(displayText) {

this.displayText = displayText || "";

}

EmailField.prototype.getElement = function() {

var emailField = document.createElement("input");

emailField.setAttribute("type", "email");

emailField.setAttribute("placeholder", this.displayText);

return emailField;

};

function ButtonField(displayText) {

this.displayText = displayText || "";

}

ButtonField.prototype.getElement = function() {

var button = document.createElement("button");

button.setAttribute("type", "submit");

button.innerHTML = this.displayText;

return button;

};

然后,清单 5-5 中的表单生成器可以用于清单 5-6 所示的应用中,在该应用中,许多字段被添加到表单中,而不必直接实例化任何表单或字段“类”,也不必手动创建任何 DOM 元素。然后使用getForm()方法“构建”最终的对象,返回它以在调用代码中使用。

清单 5-6。正在使用的生成器模式

// Instantiate the form builder

var formBuilder = new FormBuilder(),

form;

// Add fields in any order and at any time required in the application - only the type and

// content is required, the actual object creation is abstracted away in the builder

formBuilder.addField("text", "Enter the first line of your address");

formBuilder.addField("email", "Enter your email address");

formBuilder.addField("button", "Submit");

// When the final form is required, call the builder's getForm method to return a <form> element

// containing all the fields

form = formBuilder.getForm();

window.addEventListener("load", function() {

document.body.appendChild(form);

}, false);

当您需要通过一系列较小的步骤在代码中创建一个大对象,并根据应用的要求在特定的点返回创建的对象时,最适合使用生成器模式。要在线阅读关于构建器模式的更多详细信息,请查阅以下资源:

原型模式

原型模式通过使用原型继承克隆现有对象来创建新对象。阅读完《??》第一章后,你会很熟悉这一点,因为原型继承是 JavaScript 创建时所围绕的继承类型,可以使用现有对象的prototype属性来实现,就像我们在 JavaScript 中创建“类”时看到的那样,或者通过使用 ECMAScript 5 的Object.create()方法来实现,这是首选方法,但仍然需要更好的 web 浏览器支持才能独占使用。清单 5-7 展示了使用第一种技术的原型模式,而清单 5-8 展示了使用第二种技术的模式。

清单 5-7。使用 prototype 关键字的原型模式

var textField,

emailField;

// Define a Field "class" to be used for creating <input> form elements

function Field(type, displayText) {

this.type = type || "";

this.displayText = displayText || "";

}

// Use the prototype property to adopt the Prototype pattern of defining methods that will be

// applied to any object instantiated from this "class"

Field.prototype = {

getElement: function() {

var field = document.createElement("input");

field.setAttribute("type", this.type);

field.setAttribute("placeholder", this.displayText);

return field;

}

};

// Create two object instances, both of which receive the getElement method from the prototype

textField = new Field("text", "Enter the first line of your address");

emailField = new Field("email", "Enter your email address");

// Add the elements stored in these objects to the current page once loaded

window.addEventListener("load", function() {

var bodyElement = document.body;

bodyElement.appendChild(textField.getElement());

bodyElement.appendChild(emailField.getElement());

}, false);

清单 5-8。使用 ECMAScript 5 的原型模式

// Define a base object with two properties, type and displayText, and a getElement() method

// which creates a HTML <input> element, configuring it using the values from the two properties

var field = {

type: "",

displayText: "",

getElement: function() {

var field = document.createElement("input");

field.setAttribute("type", this.type);

field.setAttribute("placeholder", this.displayText);

return field;

}

},

// Create a new object based upon the base object, using ECMAScript 5's Object.create()

// method to clone the original object and apply values to the two properties type and

// displayText, in order to create an object capable of creating a <input type="text">

// element when the object's getElement() method is called

textField = Object.create(field, {

// The second parameter of Object.create() allows values from the first parameter to be

// overwritten using the format described in Chapter 1

'type': {

value: "text",

enumerable: true

},

'displayText':{

value: 'Enter the first line of your address',

enumerable: true

}

}),

// Create another new object based upon the base object, using different property values in

// order to allow the creation of a <input type="email"> element when the object's

// getElement() method is called

emailField = Object.create(field, {

'type': {

value: "email",

enumerable: true

},

'displayText':{

value: 'Enter your email address',

enumerable: true

}

});

// Call the getElement() method of both objects, appending the created <input> DOM elements to

// the current page once loaded

window.addEventListener("load", function() {

var bodyElement = document.body;

bodyElement.appendChild(textField.getElement());

bodyElement.appendChild(emailField.getElement());

}, false);

当您想要动态地创建新对象作为现有对象的克隆,或者基于“类”模板创建对象时,最好使用原型模式。要在线阅读关于原型模式的更多详细信息,请查看以下资源:

单一模式

当应用于 JavaScript 时,singleton 模式定义了只有一个实例的对象的创建。最简单的形式是,单例可以是一个简单的对象文字,封装了特定的相关行为,如清单 5-9 所示。

清单 5-9。单一模式

// Group related properties and methods together into a single object literal, which

// we call a Singleton

var element = {

// Create an array for storage of page element references

allElements: [],

// Get an element reference by its ID and store it

get: function(id) {

var elem = document.getElementById(id);

this.allElements.push(elem);

return elem;

},

// Create a new element of a given type, and store it

create: function(type) {

var elem = document.createElement(type);

this.allElements.push(elem);

return elem;

},

// Return all stored elements

getAllElements: function() {

return this.allElements;

}

},

// Get and store a reference to a page element with ID of "header"

header = element.get("header"),

// Create a new <input> element

input = element.create("input"),

// Contains id="header", and new <input> elements

allElements = element.getAllElements();

// Check to see how many elements are stored

alert(allElements.length); // 2

但是,在某些情况下,您可能希望执行一些初始化代码,作为创建单例的一部分。对于这些,使用一个自执行函数,如清单 5-10 所示,并使用return关键字来显示你希望对代码的其余部分可用的对象结构。当我在下一章讲述模块模式时,我将进一步研究以这种方式使用自执行函数。

清单 5-10。具有自执行功能的单例模式

// Define a singleton containing cookie-related methods. Initialization code is achieved by

// using a self-executing function closure, which allows code to be executed at creation which

// is then unavailable publicly to the rest of the application

var cookie = (function() {

// Cookies are stored in the document.cookie string, separated by semi-colons (;)

var allCookies = document.cookie.split(";"),

cookies = {},

cookiesIndex = 0,

cookiesLength = allCookies.length,

cookie;

// Loop through all cookies, adding them to the "cookies" object, using the cookie names

// as the property names

for (; cookiesIndex < cookiesLength; cookiesIndex++) {

cookie = allCookies[cookiesIndex].split("=");

cookies[unescape(cookie[0])] = unescape(cookie[1]);

}

// Returning methods here will make them available to the global "cookie" variable defined

// at the top of this code listing

return {

// Create a function to get a cookie value by name

get: function(name) {

return cookies[name] || "";

},

// Create a function to add a new session cookie

set: function(name, value) {

// Add the new cookie to the "cookies" object as well as the document.cookie string

cookies[name] = value;

document.cookie = escape(name) + "=" + escape(value);

}

};

}());

// Set a cookie using the "set" method exposed through the "cookie" singleton

cookie.set("userID", "1234567890");

// Check that the cookie was set correctly

alert(cookie.get("userID")); // 1234567890

许多开发人员使用这种单例模式将相关代码封装和分组到一个层次结构中,称为命名空间,这在 Java 等其他编程语言中很流行。通过像这样将所有内容保存在一个全局变量中,可以降低与应用中使用的任何第三方代码冲突的风险。看一下清单 5-11,它显示了一个基本的命名空间结构,用于在命名的部分中将代码联系在一起,以减少开发人员的困惑,简化维护和开发,使代码更容易阅读和理解。

清单 5-11。使用单例模式的命名空间

// Use an object literal to create a hierarchy of grouped properties and methods,

// known as a "namespace"

var myProject = {

data: {

// Each nested property represents a new, deeper level in the namespace hierarchy

ajax: {

// Create a method to send an Ajax GET request

get: function(url, callback) {

var xhr = new XMLHttpRequest(),

STATE_LOADED = 4,

STATUS_OK = 200;

xhr.onreadystatechange = function() {

if (xhr.readyState !== STATE_LOADED) {

return;

}

if (xhr.status === STATUS_OK) {

callback(xhr.responseText);

}

};

xhr.open("GET", url);

xhr.send();

}

}

}

};

// Add to the namespace after creation using dot notation

myProject.data.cookies = {

// Create a method for reading a cookie value by name

get: function(name) {

var output = "",

escapedName = escape(name),

start = document.cookie.indexOf(escapedName + "="),

end = document.cookie.indexOf(";", start);

end = end === -1 ? (document.cookie.length - 1) : end;

if (start > = 0) {

output = document.cookie.substring(start + escapedName.length + 1, end);

}

return unescape(output);

},

// Create a method for setting a cookie name/value pair

set: function(name, value) {

document.cookie = escape(name) + "=" + escape(value);

}

};

// Execute methods directly through the "namespace" hierarchy using dot notation

myProject.data.ajax.get("/", function(response) {

alert("Received the following response: " + response);

});

// Note how using the hierarchy adds clarity to the final method call

myProject.data.cookies.set("userID", "1234567890");

myProject.data.cookies.set("name", "Den Odell");

// Read back the cookie valus set previously

alert(myProject.data.cookies.get("userID")); // 1234567890

alert(myProject.data.cookies.get("name")); // Den Odell

当您需要创建一个在整个代码中使用的对象的单个实例时,或者需要命名您的代码时,最好使用 singleton 模式,通过在单个全局对象下定义的层次结构将代码划分为命名的部分。要在线阅读关于单例模式的更多细节,请查阅以下资源:

摘要

在这一章中,我介绍了设计模式的概念,并展示了如何使用创造性的设计模式在您自己的 JavaScript 应用中抽象出对象创建。设计模式是 JavaScript 开发的瑞士军刀中的工具,但是像所有工具一样,您需要知道何时何地最好地使用它们。熟悉本章中的模式,并确保在认识到代码中需要设计模式之前,不要使用它。

在下一章,我将继续关注结构化设计模式,您可以在 JavaScript 代码中将对象组合成更大、更结构化的形式。

六、设计模式:结构型

在这一章中,我们将继续关注设计模式,重点是结构设计模式。我们在前一章中看到的创造性设计模式集中在对象创建上,而结构化设计模式帮助你将对象组合成一个更大、更结构化的代码库。它们是灵活的、可维护的、可扩展的,并且确保如果系统的一部分发生变化,您不需要完全重写其余部分来适应。结构化设计模式还可以用来帮助与其他代码结构进行交互,您需要在应用中轻松地使用这些代码结构。让我们一起来看看你可能会发现在你的代码中有用的八种结构设计模式,以及一些例子。

适配器模式

adapterpattern 是一种有用的设计模式,当您需要将两个或更多通常不会连接在一起的代码组件连接在一起时,可以使用它;类似地,当您开发的 API 被更新,不再以相同的方式调用时,它就变得有用了——提供了一个适配器来连接新旧版本,有助于 API 用户的迁移,他们可以利用您代码中的其他改进,而不会破坏他们的代码。清单 6-1 中的例子展示了如何使用这种模式为你的代码创建一个适配器来映射一个新的 API 接口到一个旧的接口。

清单 6-1。适配器模式

// Imagine the following interface exists deep in your large code base for making Ajax requests

// over HTTP

var http = {

makeRequest: function(type, url, callback, data) {

var xhr = new XMLHttpRequest(),

STATE_LOADED = 4,

STATUS_OK = 200;

xhr.onreadystatechange = function() {

if (xhr.readyState !== STATE_LOADED) {

return;

}

if (xhr.status === STATUS_OK) {

callback(xhr.responseText);

}

};

xhr.open(type.toUpperCase(), url);

xhr.send(data);

}

};

// The http.makeRequest() method defined above could be called as follows, for getting and

// updating user data in a system for a user with an ID of "12345":

http.makeRequest("get", "/user/12345", function(response) {

alert("HTTP GET response received. User data: " + response);

});

http.makeRequest("post", "/user/12345", function(response) {

alert("HTTP POST response received. New user data: " + response);

}, "company=AKQA&name=Den%20Odell");

// Now imagine in a refactor of your project, you decide to introduce a new structure using a

// namespace and splitting out the makeRequest() method into separate methods for HTTP GET

// and POST requests

var myProject = {

data: {

ajax: (function() {

function createRequestObj(callback) {

var xhr = new XMLHttpRequest(),

STATE_LOADED = 4,

STATUS_OK = 200;

xhr.onreadystatechange = function() {

if (xhr.readyState !== STATE_LOADED) {

return;

}

if (xhr.status === STATUS_OK) {

callback(xhr.responseText);

}

};

return xhr;

}

return {

get: function(url, callback) {

var requestObj = createRequestObj(callback);

requestObj.open("GET", url);

requestObj.send();

},

post: function(url, data, callback) {

var requestObj = createRequestObj(callback);

requestObj.open("POST", url);

requestObj.send(data);

}

};

}())

}

};

// These new get() and post() methods could be called as follows:

myProject.data.ajax.get("/user/12345", function(response) {

alert("Refactored HTTP GET response received. User data: " + response);

});

myProject.data.ajax.post("/user/12345", "company=AKQA&name=Den%20Odell", function(response) {

alert("Refactored HTTP POST response received. New user data: " + response);

});

// To avoid rewriting every call to the http.makeRequest() method in the rest of your code

// base, you could create an adapter to map the old interface to the new methods. The adapter

// needs to take the same input parameters as the original method it is designed to replace,

// and calls the new methods internally instead

function httpToAjaxAdapter(type, url, callback, data) {

if (type.toLowerCase() === "get") {

myProject.data.ajax.get(url, callback);

} else if (type.toLowerCase() === "post") {

myProject.data.ajax.post(url, data, callback);

}

}

// Finaly, apply the adapter to replace the original method. It will then map the old

// interface to the new one without needing to rewrite the rest of your code at the same time

http.makeRequest = httpToAjaxAdapter;

// Use the new adapter in the same way as the original method - internally it will call the

// newer code, but externally it will appear identical to the old makeRequest() method

http.makeRequest("get", "/user/12345", function(response) {

alert("Adapter HTTP GET response received. User data: " + response);

});

http.makeRequest("post", "/user/12345", function(response) {

alert("Adapter HTTP POST response received. New user data: " + response);

}, "company=AKQA&name=Den%20Odell");

适配器模式最适合在需要将本来不能组合在一起的代码连接在一起时使用,例如,当外部 API 被更新时——您创建一个适配器来将新方法映射到旧方法,以避免需要对依赖于这些方法的代码的其余部分进行更改。

要在线阅读有关适配器模式的更多信息,请查看以下资源:

复合模式

复合模式为一个或多个对象创建了一个界面,而最终用户不需要知道他们正在处理多少个对象。当你想简化其他人访问你的函数的方式时,它就派上用场了;无论是将单个对象还是一组对象传递给同一个方法,都没有区别。清单 6-2 显示了复合模式的一个简单例子,允许用户向一个或多个 DOM 节点添加类名,而不需要知道他们是否需要向方法传递一个或多个 DOM 节点。

清单 6-2。复合模式

// Define a singleton containing methods to get references to page elements and to add

// class names to those elements

var elements = {

// Define a method to get DOM elements by tag name. If one element is found, it is

// returned as an individual node, or multiple elements are found, an array of those

// found elements are returned

get: function(tag) {

var elems = document.getElementsByTagName(tag),

elemsIndex = 0,

elemsLength = elems.length,

output = [];

// Convert the found elements structure into a standard array

for (; elemsIndex < elemsLength; elemsIndex++) {

output.push(elems[elemsIndex]);

}

// If one element is found, return that single element, otherwise return the array

// of found elements

return output.length === 1 ? output[0] : output;

},

// Define a composite method which adds an class name to one or more elements, regardless

// of how many are passed when it is executed

addClass: function(elems, newClassName) {

var elemIndex = 0,

elemLength = elems.length,

elem;

// Determine if the elements passed in are an array or a single object

if (Object.prototype.toString.call(elems) === "[object Array]") {

// If they are an array, loop through each elements and add the class name to each

for (; elemIndex < elemLength; elemIndex++) {

elem = elems[elemIndex];

elem.className += (elem.className === "" ? "" : " ") + newClassName;

}

} else {

// If a single element was passed in, add the class name value to it

elems.className += (elems.className === "" ? "" : " ") + newClassName;

}

}

};

// Use the elements.get() method to locate the single <body> element on the current page, and

// potentially numerous <a> elements

var body = elements.get("body"),

links = elements.get("a");

// The composite elements.addClass() method gives the same interface to single elements

// as it does to multiple elements, simplifying its use considerably

elements.addClass(body, "has-js");

elements.addClass(links, "custom-link");

当您不希望与您的方法交互的开发人员担心有多少对象作为参数传递给它们,从而简化方法调用时,最好使用复合模式。

要在线阅读关于复合模式的更多信息,请查阅以下资源:

装饰图案

装饰模式是一种扩展和定制从“类”创建的对象的方法和属性的方式,而不需要创建大量可能变得难以管理的子类。这是通过有效地将对象包装在另一个实现相同公共方法的对象中来实现的,其中相关方法根据我们试图增强的行为被覆盖。清单 6-3 中的代码展示了一个创建几个装饰器的例子,每个装饰器都用额外的属性和行为来扩充一个现有的对象。

清单 6-3。装饰图案

var FormField = function(type, displayText){

this.type = type || "text";

this.displayText = displayText || "";

};

FormField.prototype = {

createElement: function() {

this.element = document.createElement("input");

this.element.setAttribute("type", this.type);

this.element.setAttribute("placeholder", this.displayText);

return this.element;

},

isValid: function() {

return this.element.value !== "";

}

};

// The form field deocorator, which implements the same public methods as FormField

var FormFieldDecorator = function(formField) {

this.formField = formField;

};

FormFieldDecorator.prototype = {

createElement: function() {

this.formField.createElement();

},

isValid: function() {

return this.formField.isValid();

}

};

var MaxLengthFieldDecorator = function(formField, maxLength) {

FormFieldDecorator.call(this, formField);

this.maxLength = maxLength || 100;

};

MaxLengthFieldDecorator.prototype = new FormFieldDecorator() ;

MaxLengthFieldDecorator.prototype.createElement = function() {

var element = this.formField.createElement();

element.setAttribute("maxlength", this.maxLength);

return element;

};

var AutoCompleteFieldDecorator = function(formField, autocomplete) {

FormFieldDecorator.call(this, formField);

this.autocomplete = autocomplete || "on";

};

AutoCompleteFieldDecorator.prototype = new FormFieldDecorator();

AutoCompleteFieldDecorator.prototype.createElement = function() {

var element = this.formField.createElement();

element.setAttribute("autocomplete", this.autocomplete);

return element;

};

清单 6-3 中创建的装饰器可以如清单 6-4 所示用于生成一个表示表单中表单字段的对象,使用这些装饰器而不是通过子类来扩充它的属性和行为。

清单 6-4。正在使用的装饰模式

// Create an empty <form> tag and a new FormField object to represent

// a <input type="search"> field

var form = document.createElement("form"),

formField = new FormField("search", "Enter your search term");

// Extend the formField object using our decorators to add maxlength and autocomplete properties

// to the resulting form field element. Note how we pass the extended formField object into each

// decorator in turn, which extends it further.

formField = new MaxLengthFieldDecorator(formField, 255);

formField = new AutoCompleteFieldDecorator(formField, "off");

// Create the HTML form field element and add it to the <form> element

form.appendChild(formField.createElement());

// Add an event handler to the <form> tag's submit event, preventing the form from submitting if

// the form field we added contains no value

form.addEventListener("submit", function(e) {

// Stop the form from submitting

e.preventDefault();

// Test to see if our form field is valid, i.e. that it contains a value

if (formField.isValid()) {

// If it does, go ahead and submit the form

form.submit();

} else {

// If it doesn't, alert the user that something is wrong and they need to correct it

alert("Please correct the issues in the form field.");

}

}, false);

// Add the <form> field to the current page once it has loaded

window.addEventListener("load", function() {

document.body.appendChild(form);

}, false);

当您需要快速简单地增加从一个“类”创建的对象实例的行为,而不必求助于从它创建一长串继承的子类时,最好使用装饰模式。要在线阅读关于装饰模式的更多信息,请查阅以下资源:

立面图案

外立面图案很常见;它只是编写一个函数来简化对一个或多个更大、可能更复杂的函数的访问。可能有人会说,任何简单地调用另一个函数的函数都是这种模式的一个例子,但是我发现最好是从简化一些原本需要多个步骤的事情的角度来考虑,或者提供一个访问更大系统的单点,这将使其他开发人员访问该系统更加容易。清单 6-5 中的代码演示了一个简单的外观,它提供了一个包装器来简化跨浏览器 Ajax 调用。

清单 6-5。立面图案

// Define a function which acts as a façade to simplify and facilitate cross-browser Ajax calls,

// supporting browsers all the way back to Internet Explorer 5

function ajaxCall(type, url, callback, data) {

// Get a reference to an Ajax connection object relevant to the current browser

var xhr = (function() {

try {

// The standard method, used in all modern browsers

return new XMLHttpRequest();

}

catch(e) {}

// Older versions of Internet Explorer utilise an ActiveX object installed on the

// user's machine

try {

return new ActiveXObject("Msxml2.XMLHTTP.6.0");

}

catch(e) {}

try {

return new ActiveXObject("Msxml2.XMLHTTP.3.0");

}

catch(e) {}

try {

return new ActiveXObject("Microsoft.XMLHTTP");

}

catch(e) {}

// If no relevant Ajax connection object can be found, throw an error

throw new Error("Ajax not supported in this browser.");

}()),

STATE_LOADED = 4,

STATUS_OK = 200;

// Execute the given callback method once a succesful response is received from the server

xhr.onreadystatechange = function() {

if (xhr.readyState !== STATE_LOADED) {

return;

}

if (xhr.status === STATUS_OK) {

callback(xhr.responseText);

}

};

// Use the browser's Ajax connection object to make the relevant call to the given URL

xhr.open(type.toUpperCase(), url);

xhr.send(data);

}

清单 6-5 中的外观模式可以用在你的代码中,如清单 6-6 所示,掩盖了跨浏览器 Ajax 操作背后的复杂性。

清单 6-6。正在使用的立面图案

// The ajaxCall() facade function can make cross-browser Ajax calls as follows

ajaxCall("get", "/user/12345", function(response) {

alert("HTTP GET response received. User data: " + response);

});

ajaxCall("post", "/user/12345", function(response) {

alert("HTTP POST response received. New user data: " + response);

}, "company=AKQA&name=Den%20Odell");

当您希望通过单个函数或方法来提供对一系列函数或方法调用的访问,以便简化代码库的其余部分,使其更容易遵循,因此将来更易于维护和扩展时,最好使用外观模式。要在线阅读有关外观模式的更多信息,请查看以下资源:

轻量级模式

flyweight 模式是一种优化模式;这对于创建大量相似对象的代码非常有用,否则这些对象会消耗大量内存。它用一些共享对象代替了大量的相似对象,使得代码更轻,性能更好;因此得名,它来自拳击界,指的是最轻重量级的运动员,那些最敏捷的运动员。清单 6-7 显示了一个例子,这个例子说明了 flyweight 模式旨在解决的一个问题,即对象的低效存储。

清单 6-7。低效的对象实例

// Create a "class" to store data to related to employees working for one or more different

// companies

function Employee(data) {

// Represent an employee's ID within an organisation

this.employeeId = data.employeeId || 0;

// Represent an employee's social security number

this.ssId = data.ssId || "0000-000-0000";

// Represent an employee's name

this.name = data.name || "";

// Represent an employee's occupation

this.occupation = data.occupation || "";

// Represent an employee's company name, address and country

this.companyName = data.companyName || "";

this.companyAddress = data.companyAddress || "";

this.companyCountry = data.companyCountry || "";

}

// Create three methods to get the employee's name, occupation and company details from the

// stored object

Employee.prototype.getName = function() {

return this.name;

};

Employee.prototype.getOccupation = function() {

return this.occupation;

};

Employee.prototype.getCompany = function() {

return [this.companyName, this.companyAddress, this.companyCountry].join(", ");

};

// Create four employee objects - note that two share the same company information, and two

// share the same ssId and name. As more objects are created, the amount of data repeated will

// grow, consuming more memory due to inefficiency

var denOdell = new Employee({

employeeId: 1456,

ssId: "1234-567-8901",

name: "Den Odell",

occupation: "Head of Web Development",

companyName: "AKQA",

companyAddress: "1 St. John's Lane, London",

companyCountry: "GB"

}),

steveBallmer = new Employee({

employeeId: 3,

ssId: "8376-940-1673",

name: "Steve Ballmer",

occupation: "Ex-CEO",

companyName: "Microsoft",

companyAddress: "1 Microsoft Way, Redmond, WA",

companyCountry: "US"

}),

billGates = new Employee({

employeeId: 1,

ssId: "7754-342-7584",

name: "Bill Gates",

occupation: "Founder",

companyName: "Microsoft",

companyAddress: "1 Microsoft Way, Redmond, WA",

companyCountry: "US"

}),

billGatesPhilanthropist = new Employee({

employeeId: 2,

ssId: "7754-342-7584",

name: "Bill Gates",

occupation: "Philanthropist",

companyName: "Gates Foundation",

companyAddress: "500 Fifth Avenue North, Seattle, WA",

companyCountry: "US"

});

flyweight 模式是通过尝试解构一个现有的“类”来应用的,这样可以最小化对象实例之间可能重复的任何数据。这是通过研究重复数据的任何当前对象实例并创建单独的“类”来表示该数据来实现的。然后,单个对象实例可以表示重复的数据,这些数据可以从原始“类”的多个对象实例中引用,从而减少存储的数据,从而减少应用的内存占用。

每个当前对象实例的任何数据核心都称为该“类”的内部数据,任何可以从对象中提取、单独存储和引用的数据都称为其外部数据。在清单 6-7 中,与雇员相关的内在数据——本质上是唯一的——是它的employeeIdoccupation值。目前复制在多个Employee对象上的公司数据可以单独提取和存储;每个人的数据也是如此,比如他们的namessId值。因此,一个雇员可以用四个属性来表示:employeeIdoccupationcompanyperson。最后两个属性引用其他对象实例。

flyweight 模式分三个阶段应用,如清单 6-8 所示:首先,创建新的“类”来表示外部数据;第二,通过应用工厂模式来确保先前创建的对象不会被重新创建;最后,通过编写代码,以与最初相同的方式创建对象,允许所有 flyweight 的繁重工作在幕后进行。

清单 6-8。轻量级模式

// The first stage of applying the flyweight pattern is to extract intrinsic data from

// extrinsic data in the objects we wish to make more memory-efficient

//

// There are two sets of extrinsic data in an Employee object from Listing 6-7 - people data

// and company data. Let's create two "classes" to represent those types of data

//

// A Person object represents an individual's social security number and their name

function Person(data) {

this.ssId = data.ssId || "";

this.name = data.name || "";

}

// A Company object represents a company's name, address and country details

function Company(data) {

this.name = data.name || "";

this.address = data.address || "";

this.country = data.country || "";

}

// The second stage of the flyweight pattern is to ensure any objects representing unique

// extrinsic data are only created once and stored for use in future. This is achieved by

// harnessing the factory pattern for each of the new extrinsic data "classes" to abstract

// away the creation of the object instance so that if a previously-existing object is found,

// that can be returned instead of creating a new instance

var personFactory = (function() {

// Create a variable to store all instances of the People "class" by their ssId

var people = {},

personCount = 0;

return {

// Provide a method to create an instance of the People "class" if one does not

// already exist by the given ssId provided in the data input. If one exists,

// return that object rather than creating a new one

createPerson: function(data) {

var person = people[data.ssId],

newPerson;

// If the person by the given ssId exists in our local data store, return their

// object instance, otherwise create a new one using the provided data

if (person) {

return person;

} else {

newPerson = new Person(data);

people[newPerson.ssId] = newPerson;

personCount++;

return newPerson;

}

},

// Provide a method to let us know how many Person objects have been created

getPersonCount: function() {

return personCount;

}

};

}()),

// Create a similar factory for Company objects, storing company data by name

companyFactory = (function() {

var companies = {},

companyCount = 0;

return {

createCompany: function(data) {

var company = companies[data.name],

newCompany;

if (company) {

return company;

} else {

newCompany = new Company(data);

companies[newCompany.name] = newCompany;

companyCount++;

return newCompany;

}

},

getCompanyCount: function() {

return companyCount;

}

};

}()),

// The third stage of the flyweight pattern is to allow the creation of objects in a

// simliar way to that in Listing 6-7, providing all the handling of data storage in the

// most efficient way in a transparent way to the end user

//

// Create an object with methods to store employee data and to return data from each

// object by their employeeId. This simplifies the end user's code as they do not need to

// access methods on underlying objects directly, they only need interface with this handler

employee = (function() {

// Create a data store for all employee objects created

var employees = {},

employeeCount = 0;

return {

// Provide a method to add employees to the data store, passing the provided data

// to the Person and Company factories and storing the resulting object, consisting

// of the enployeeId, occupation, person object reference, and company object

// reference in the local data store

add: function(data) {

// Create or locate Person or Company objects that correspond to the provided

// data, as appropriate

var person = personFactory.createPerson({

ssId: data.ssId,

name: data.name

}),

company = companyFactory.createCompany({

name: data.companyName,

address: data.companyAddress,

country: data.companyCountry

});

// Store a new object in the local data store, containing the employeeId,

// their occupation, and references to the company they work for and their

// unique personal data, including their name and social security number

employees[data.employeeId] = {

employeeId: data.employeeId,

occupation: data.occupation,

person: person,

company: company

};

employeeCount++;

},

// Provide a method to return the name of an employee by their employeeId - the

// data is looked up from the associated Person object

getName: function(employeeId) {

return employees[employeeId].person.name;

},

// Provide a method to return the occupation of an employee by their employeeId

getOccupation: function(employeeId) {

return employees[employeeId].occupation;

},

// Provide a method to return the address of the company an employee works for -

// the data is looked up from the associated Company object

getCountry: function(employeeId) {

var company = employees[employeeId].company;

return [company.name, company.address, company.country].join(", ");

},

// Provide a utlility method to tell us how many employees have been created

getTotalCount: function() {

return employeeCount;

}

};

}());

清单 6-8 中的 flyweight 代码可以如清单 6-9 所示使用,它复制了清单 6-7 的行为。应用 flyweight 模式的原始内存消耗对象中的重复数据越多,共享的对象就越多,因此减少了应用的内存占用,证明了这种设计模式的有用性。

清单 6-9。正在使用的轻量级模式

// Create four employee objects - note that two share the same company information, and two

// share the same ssId and name. Behind the scenes, the flyweight pattern from Listing 6-8

// ensures that repeated person and company data is stored in the most efficient way possible.

var denOdell = employee.add({

employeeId: 1456,

ssId: "1234-567-8901",

name: "Den Odell",

occupation: "Head of Web Development",

companyName: "AKQA",

companyAddress: "1 St. John's Lane, London",

companyCountry: "GB"

}),

steveBallmer = employee.add({

employeeId: 3,

ssId: "8376-940-1673",

name: "Steve Ballmer",

occupation: "Ex-CEO",

companyName: "Microsoft",

companyAddress: "1 Microsoft Way, Redmond, WA",

companyCountry: "US"

}),

billGates = employee.add({

employeeId: 1,

ssId: "7754-342-7584",

name: "Bill Gates",

occupation: "Founder",

companyName: "Microsoft",

companyAddress: "1 Microsoft Way, Redmond, WA",

companyCountry: "US"

}),

billGatesPhilanthropist = employee.add({

employeeId: 2,

ssId: "7754-342-7584",

name: "Bill Gates",

occupation: "Philanthropist",

companyName: "Gates Foundation",

companyAddress: "500 Fifth Avenue North, Seattle, WA",

companyCountry: "US"

});

// We've created three objects representing people by ssId and name - Den Odell, Steve Ballmer

// and Bill Gates

alert(personFactory.getPersonCount()); // 3

// We've created three objects representing companies by name, address and country - AKQA,

// Microsoft and the Gates Foundation

alert(companyFactory.getCompanyCount()); // 3

// We've created four objects representing employees, with two unique properties and two

// properties linking to existing person and company objects. The more employee objects we

// create with shared person and company data, the less data we're storing in our application

// and the more effective the flyweight pattern becomes

alert(employee.getTotalCount()); // 4

当您有大量具有相似共享属性名称-值对的对象时,最好使用 flyweight 模式,这些对象可以被分成更小的对象,这些对象之间通过引用共享数据,以便减少代码的内存占用,提高代码的效率。要在线阅读更多关于 flyweight 模式的内容,请查阅以下资源:

混合模式

mixin 模式通过快速方便地将一组方法和属性从一个对象直接应用到另一个对象,或者直接应用到“类”的原型,使得所有对象实例都可以访问这些属性和方法,从而避免了对大量子类化和继承链的需求。尽管这听起来像是“黑客”,特别是对于那些从传统的面向对象背景开始接触 JavaScript 的开发人员来说,这种模式直接利用了 JavaScript 语言的优势及其对原型的使用,而不是其他语言所应用的严格的经典继承,并且如果小心使用,可以简化开发和代码维护。清单 6-10 中的代码展示了如何使用 mixin 模式简单快速地将一组通用方法应用到多个对象上。

清单 6-10。混合模式

// Define a mixin which enables debug logging, to be applied to any object or "class"

var loggingMixin = {

// Define a storage array for logs

logs: [],

// Define a method to store a message in the log

log: function(message) {

this.logs.push(message);

},

// Define a method to read out the stored logs

readLog: function() {

return this.logs.join("\n");

}

},

element,

header,

textField,

emailField;

// Function to apply methods and properties from one object to another, which we'll use to apply

// the mixin to other objects

function extendObj(obj1, obj2) {

var obj2Key;

for (obj2Key in obj2) {

if (obj2.hasOwnProperty(obj2Key)) {

obj1[obj2Key] = obj2[obj2Key];

}

}

return obj1;

}

// Define a singleton to which we will apply the mixin, though will function fine without it

element = {

allElements: [],

create: function(type) {

var elem = document.createElement(type);

this.allElements.push(elem);

// Use the mixin method log(), ensuring it exists first before calling it. If the mixin

// is not applied, then the method will still function fine

if (typeof this.log === "function") {

this.log("Created an element of type: " + type);

}

return elem;

},

getAllElements: function() {

return this.allElements;

}

};

// Define a simple "class" to which we will apply the mixin

function Field(type, displayText) {

this.type = type || "";

this.displayText = displayText || "";

// Ensure the mixin method log() exists before executing

if (typeof this.log === "function") {

this.log("Created an instance of Field");

}

}

Field.prototype = {

getElement: function() {

var field = document.createElement("input");

field.setAttribute("type", this.type);

field.setAttribute("placeholder", this.displayText);

if (typeof this.log === "function") {

this.log("Created a DOM element with placeholder text: " + this.displayText);

}

return field;

}

};

// Apply the mixin directly to the 'element' object by essentially copying over methods and

// properties from the mixin to the singleton

element = extendObj(element, loggingMixin);

// Apply the mixin to the Field "class" prototype, making its methods available to each object

// instance created from it

Field.prototype = extendObj(Field.prototype, loggingMixin);

// Create a new DOM element using the element.create() method

header = element.create("header");

// Create two object instances, both of which receive the getElement method from the prototype

textField = new Field("text", "Enter the first line of your address");

emailField = new Field("email", "Enter your email address");

// Add the elements stored in these objects to the current page

document.body.appendChild(textField.getElement());

document.body.appendChild(emailField.getElement());

// Output the logs stored via the mixin

alert(loggingMixin.readLog());

// Outputs the following - note how all the logs from each usage of the mixin are

// stored together:

/*

Created an element of type: header

Created an instance of Field

Created an instance of Field

Created a DOM element with placeholder text: Enter the first line of your address

Created a DOM element with placeholder text: Enter your email address

*/

如果您研究清单 6-10 中的代码,您可能会注意到一些意想不到的事情:尽管将 mixin 独立地应用于 singleton 和“class”,但是所有记录的数据都存储在一起。对任何包含该方法的对象调用readLog()方法都会输出相同的结果。发生这种情况是因为当extendObj()函数将 objectlike 属性从一个对象复制到另一个对象时,比如本例中的logs数组(记住数组是 JavaScript 中的一种对象类型),这些是通过引用复制的,而不是实际的数据副本。每次从任何对象访问该属性时,都使用相同的属性,最初来自loggingMixin对象。在这个例子中,我们希望看到所有的日志,所以这是有用的;然而,在您自己的代码中使用这种模式时,这可能不是您需要的结果。如果你想为复制的属性创建单独的副本,更新extendObj()函数,如清单 6-11 所示。

清单 6-11。更新了 extendObj()函数以复制属性,而不是通过引用复制

// Update extendObj() to duplicate object-based properties rather than point to them

// by reference

function extendObj(obj1, obj2) {

var obj2Key,

value;

for (obj2Key in obj2) {

if (obj2.hasOwnProperty(obj2Key)) {

value = obj2[obj2Key];

// If the value being copied is an array, then copy a duplicate of that array using

// the slice() method

if (Object.prototype.toString.apply(value) === "[object Array]") {

obj1[obj2Key] = value.slice();

// Otherwise, if the value being copied in an object, and not an array, then copy

// across a duplicate of that object using a recursive call to this function

} else if (typeof obj2[obj2Key] === "object") {

obj1[obj2Key] = extendObj({}, value);

// Otherwise, copy across the value as usual

} else {

obj1[obj2Key] = value;

}

}

}

return obj1;

}

当您希望快速地将一组属性和方法直接从一个对象应用到另一个对象,或者应用到一个“类”以供其所有对象实例使用时,mixin 模式是最好的选择,而不需要求助于复杂的子类化和继承。要在线阅读更多关于 mixin 模式的内容,请参考以下资源:

模块模式

模块模式可能是专业 JavaScript 开发人员最常用的模式。事实上,我们已经在前面的章节中两次讨论了模式的基础:第一次是在第一章中讨论公共、私有和受保护变量时,第二次是在第四章中讨论改进 JavaScript 压缩的方法时。这一切都基于自执行函数闭包,它允许我们创建一个沙盒代码区域,可以访问全局变量和函数,但不会将其中声明的变量或函数暴露给周围的作用域,除非使用return语句显式声明。自执行函数的最简单示例如下所示:

(function() {

// Any variables or functions declared within this function aren't accessible outside it

}());

我们可以使用这种模式将我们的代码库划分成更小的、相关的代码块,我们称之为模块,这就是该模式的名字。这些模块中的每一个都应该清楚地说明它们对代码的其他部分的依赖性,如果有的话,这些部分应该作为参数传递给函数,如下所示:

(function($) {

// We very clearly define jQuery as a dependency for this 'module', making it available

// internally through the $ variable

}(jQuery));

Tip

在函数内访问 JavaScript 参数比在函数外访问全局变量更快,因为语言解释器不必执行离开当前函数范围来搜索变量的额外步骤。

模块模式的基本形式是通过使用函数闭包内的return语句来传递回任何可能对其他模块或主应用本身有用的声明代码来完成的。清单 6-12 显示了模块模式的完整形式,基于上一章的清单 5-10。

清单 6-12。模块模式

// The module pattern is distinctive as it uses a combination of a self-executing anonymous

// function closure, with any dependencies passed in as parameters, and an optional return

// statement which allows code created within the closure to be made available externally

// Our only dependency is the 'document' object which contains the browser's cookie data. As an

// added security measure, we can include a final listed parameter named 'undefined' to which we

// never pass a value. This ensures that the variable named 'undefined' always contains an

// undefined value provided we always ensure we never pass in a value to this parameter.

// Otherwise it might be possible for other code, whether through malicious reasons or

// otherwise, to overwrite this value as it is not a reserved word in the language causing all

// kinds of havoc to the way our code behaves.

var cookie = (function(document, undefined) {

var allCookies = document.cookie.split(";"),

cookies = {},

cookiesIndex = 0,

cookiesLength = allCookies.length,

cookie;

for (; cookiesIndex < cookiesLength; cookiesIndex++) {

cookie = allCookies[cookiesIndex].split("=");

cookies[unescape(cookie[0])] = unescape(cookie[1]);

}

// Return any methods, properties or values that you wish to make available to the rest of

// your code base. In this case, the following two methods will be exposed through the

// 'cookie' variable, creating a singleton

return {

get: function(name) {

return cookies[name] || "";

},

set: function(name, value) {

cookies[name] = value;

document.cookie = escape(name) + "=" + escape(value);

}

};

// Pass in any dependencies at the point of function execution

}(document));

在通过单例对象结构利用命名空间的大型代码库中,模块模式的使用方式与我们看到的略有不同;在这种情况下,我们传入一个依赖项,然后在函数闭包结束时返回,使用该模块用新的属性和方法来增加单例。清单 6-13 显示了模块模式应用于名称空间的扩充,这是它最常见的用途之一。

清单 6-13。使用模块模式扩充名称空间

// Define a namespace which we will populate with code modules

var myData = {};

// Ajax module, added to the myData namespace through augmentation

// The namespace is passed in as a parameter and, once it has been augmented with new method, is

// finally returned back, overwriting the original namespace with the new, augmented one

myData = (function(myNamespace, undefined) {

// Add an 'ajax' object property to the namespace and populate it with related methods

myNamespace.ajax = {

get: function(url, callback) {

var xhr = new XMLHttpRequest(),

LOADED_STATE = 4,

OK_STATUS = 200;

xhr.onreadystatechange = function() {

if (xhr.readyState !== LOADED_STATE) {

return;

}

if (xhr.status === OK_STATUS) {

callback(xhr.responseText);

}

};

xhr.open("GET", url);

xhr.send();

}

};

// Return the new, augmented namespace back to the myData variable

return myNamespace;

// We can use the following defence mecahnism, which reverts to an empty object if the myData

// namespace object does not yet exist. This is useful when you have modules split over several

// files in a large namespace and you're unsure if the namespace passed in has been initialized

// elsewhere before

}(myData || {}));

// Cookies module, added to the myData namespace through augmentation

// As before, the namespace is passed in, augmented, and then returned, overwriting the original

// namespace object. At this point, the myData namespace contains the Ajax module code

myData = (function(myNamespace, undefined) {

// Add a 'cookies' object property to the namespace and populate it with related methods

myNamespace.cookies = {

get: function(name) {

var output = "",

escapedName = escape(name),

start = document.cookie.indexOf(escapedName + "="),

end = document.cookie.indexOf(";", start);

end = end === -1 ? (document.cookie.length - 1) : end;

if (start >=0) {

output = document.cookie.substring(start + escapedName.length + 1, end);

}

return unescape(output);

},

set: function(name, value) {

document.cookie = escape(name) + "=" + escape(value);

}

};

return myNamespace;

}(myData || {}));

// Execute methods directly through the myData namespace object, which now contains both Ajax

// and Cookies modules

myData.ajax.get("/user/12345", function(response) {

alert("HTTP GET response received. User data: " + response);

});

myData.cookies.set("company", "AKQA");

myData.cookies.set("name", "Den Odell");

alert(myData.cookies.get("company")); // AKQA

alert(myData.cookies.get("name"));    // Den Odell

当您希望将大型代码库分解成更小的、可管理的、自包含的部分时,最好使用模块模式,每个部分都有一组清晰的依赖项和定义明确的目的。由于它们的沙箱特性,它们的自执行功能块也是通过混淆和编译创建较小文件的主要领域,我们在第四章中讨论过这些主题。在第九章中,我们将会看到一种使用异步模块定义(AMD) API 来定义模块并将模块加载到 JavaScript 代码中的替代方法,但是现在如果你想在线阅读更多关于模块模式的内容,请查阅以下资源:

代理模式

代理模式是一种定义代理或替代对象或方法的模式,用于替换或增强现有的对象或方法,以提高其性能或添加额外的功能,而不会影响已经使用该对象或方法的代码的其他部分。我和许多其他专业 JavaScript 开发人员使用这种模式的最常见方式是在不改变方法或函数名的情况下包装现有的方法或函数,如清单 6-14 所示。

清单 6-14。代理模式

// To proxy the myData.cookies.get() method from Listing 6-13, we begin by storing the current

// method in a variable

var proxiedGet = myData.cookies.get;

// Override the get() method with a new function which proxies the original and augments its

// behavior

myData.cookies.get = function() {

// Call the proxied (original) method to get the value it would have produced

var value = proxiedGet.apply(this, arguments);

// Do something with the value returned from the proxied method

value = value.toUpperCase();

// Return the manipulated value with the same type as the proxied method, so that the use of

// this new method does not break any existing calls to it

return value;

};

代理模式的一个变种叫做虚拟代理,它可以通过延迟对象实例化,从而延迟构造函数的执行,直到来自对象实例的方法被实际调用,来提高性能和内存使用,如清单 6-15 所示。

清单 6-15。虚拟代理模式

// Define a "class" for constructing an object representing a simple form field

function FormField(type, displayText){

this.type = type || "text";

this.displayText = displayText || "";

// Create and initialize a form field DOM element

this.element = document.createElement("input");

this.element.setAttribute("type", this.type);

this.element.setAttribute("placeholder", this.displayText);

}

// Define two methods for object instances to inherit

FormField.prototype = {

getElement: function() {

return this.element;

},

isValid: function() {

return this.element.value !== "";

}

};

// Now replace the FormField "class" with a proxy that implements the same methods, yet delays

// calling the original constructor function until those methods are actually called, saving on

// memory resources and improving performance

// Optionally, use the module pattern to localise the scope of the proxy "class", passing in the

// original FormField "class" and returning the proxied version of it

FormField = (function(FormField) {

// Define a proxy constructor, similar to the original FormField "class"

function FormFieldProxy(type, displayText) {

this.type = type;

this.displayText = displayText;

}

FormFieldProxy.prototype = {

// Define a property to store the reference to the object instance of the original

// "class" once instantiated

formField: null,

// Define a new 'initialize' method whose task it is to create the object instance of

// FormField if it does not already exist and execute the constructor function from the

// original "class"

initialize: function() {

if (!this.formField) {

this.formField = new FormField(this.type, this.displayText);

}

},

// Proxy the original methods with new ones that call the intialize() method to

// instantiate the FormField "class" only when one of these methods are called

getElement: function() {

this.initialize();

return this.formField.getElement();

},

isValid: function() {

this.initialize();

return this.formField.isValid();

}

};

// Return the proxied "class" to replace the original with

return FormFieldProxy;

}(FormField));

// Create two object instances, both of which will actually be calling the proxy rather than the

// original "class", meaning the DOM elements will not be created at this stage, saving memory

// and improving performance

var textField = new FormField("text", "Enter the first line of your address"),

emailField = new FormField("email", "Enter your email address");

// Add the elements stored in these objects to the current page when loaded - at this point the

// getElement() method is called, which in turn calls initialize(), creating an instance of the

// original "class" and executing its constructor function which performs the actual DOM element

// creation. This ensures the memory used to store the DOM element is only taken up at the exact

// point it is required

window.addEventListener("load", function() {

document.body.appendChild(textField.getElement());

document.body.appendChild(emailField.getElement());

}, false);

// Execute another method from the proxy, this time the object instance of the original "class"

// won't be recreated and the stored instance will be used instead

alert(emailField.isValid()); // false

对于可能同时进行多个调用的对象,可以通过延迟或分组调用(如 Ajax 请求或其他与网络相关的调用)来进一步扩展代理模式,以提高性能和减少内存。

当您需要覆盖一个对象或“类”上的特定方法的行为时,最好使用代理模式,或者应用代理模式来提高现有“类”的性能,以便在调用它的一个方法之前,它不会被实际实例化。要在线阅读有关代理模式的更多信息,请查看以下资源:

摘要

在这一章中,我们研究了结构化设计模式,你可以在适当的时候使用这些模式来帮助你构建大型的 JavaScript 应用并提高它们的性能。这些是 JavaScript 开发的瑞士军刀中的工具,但是像所有工具一样,您需要知道何时何地最好地使用它们。记住那句古老的格言:“当你有一把锤子时,一切看起来都像钉子。”熟悉本章中的模式及其用例,并确保在代码中认识到需要使用设计模式之前,不要使用它。

在下一章中,我们将会看到一些行为设计模式,它们可以用来简化 JavaScript 应用代码库中不同对象之间的通信。

七、设计模式:行为型

在这一章中,我们将继续关注设计模式,重点是行为设计模式。我们在第五章中看到的创造性设计模式侧重于对象创建,而我们在前一章中看到的结构性设计模式侧重于对象结构,行为设计模式侧重于帮助代码库中多个对象之间的通信。这里的要点是让你更容易理解你的代码是如何作为一个整体运作的,而不是仅仅关注单个对象的构造和结构。让我们一起来看看八种行为设计模式,你可能会发现它们在你的代码中很有用,还有一些例子。

责任链模式

当基于同一个“类”的多个对象中的任何一个可以处理一个请求或方法调用时,就使用责任链模式。请求被发送给一个对象,如果它不是处理请求的最合适的对象,它就把请求传递给另一个对象来处理。所以它会一直继续下去,直到一个对象处理了请求,并通过对象链将操作的结果传递回原始请求或方法调用。链中的每个对象都知道另一个对象,如果链中的下一个对象不能完成请求,那么它可以处理请求。这种模式最适用于共同形成某种层次结构的对象,如清单 7-1 所示,你不希望将的实现暴露给代码的其他部分。

清单 7-1。责任链模式

// Define an object listing different levels of logging in a system - info, warn, and error –

// each indicating something more severe than the last

var LogLevel = {

INFO: 'INFO',

WARN: 'WARN',

ERROR: 'ERROR'

},

log;

// Define a "class" to create appropriately formatted log messages for different logging levels

function LogFormatter(logLevel) {

this.logLevel = logLevel;

}

LogFormatter.prototype = {

// Define a property to store the successor to this object instance in the chain

// of responsibility

nextInChain: null,

// Define a method to set the successor in the chain of responsibility

setNextInChain: function(next) {

this.nextInChain = next;

},

// Define a method to create an appropriately formatted log message based on the current

// logging level

createLogMessage: function(message, logLevel) {

var returnValue;

// If the logging level assigned to the current object instance is the same as that

// passed in, then format the log message

if (this.logLevel === logLevel) {

// Format the log message as appropriate according to the logging level

if (logLevel === LogLevel.ERROR) {

returnValue = logLevel + ": " + message.toUpperCase();

} else if (logLevel === LogLevel.WARN) {

returnValue = logLevel + ": " + message;

} else {

returnValue = message;

}

// If the logging level assigned to the current object instance does not match that

// passed in, then pass the message onto the next object instance in the chain

// of responsibility

} else if (this.nextInChain) {

returnValue = this.nextInChain.createLogMessage(message, logLevel);

}

return returnValue;

}

};

// Define a singleton we can use for storing and outputting logs in a system

log = (function() {

// Define a storage array for log messages

var logs = [],

// Create object instances representing the three levels of logging - info, warn,

// and error

infoLogger = new LogFormatter(LogLevel.INFO),

warnLogger = new LogFormatter(LogLevel.WARN),

errorLogger = new LogFormatter(LogLevel.ERROR),

// Set the 'error' logging level to be the first and highest level in our chain of

// responsibility, which we'll store in the 'logger' variable

logger = errorLogger;

// Set the chain of responsibility hierarchy using the setNextInChain() method on each

// object instance - we're assuming that the 'error' logging level is the most important and

// is first in the chain

// The next in the logging hierarchy after 'error' should be 'warn' as this is

// less important

errorLogger.setNextInChain(warnLogger);

// The next in the chain after the 'warn' logging level should be 'info' as this is the

// least important level

warnLogger.setNextInChain(infoLogger);

return {

// Define a method for reading out the stored log messages

getLogs: function() {

return logs.join("\n");

},

// Define a method for formatting a log message appropriately according to its

// logging level

message: function(message, logLevel) {

// We call the createLogMessage() method on the first object instance in our

// hierarchy only, which in turn calls those further down the chain if it does not

// handle the specified logging level itself. The message passes further down the

// chain of responsibility until it reaches an object instance who can handle the

// specific logging level

var logMessage = logger.createLogMessage(message, logLevel);

// Add the formatted log message to the storage array

logs.push(logMessage);

}

};

}());

// Execute the message() method of the 'log' singleton, passing in a message and the logging

// level. The first object in the chain of responsibility handles the 'error' logging level, so

// the message is not passed down the chain of responsibility and is returned by the

// errorLogger object

log.message("Something vary bad happened", LogLevel.ERROR);

// This message is passed through the errorLogger object to the warnLogger object through the

// chain of responsibility since the errorLogger object is only told to handle messages with the

// 'error' logging level

log.message("Something bad happened", LogLevel.WARN);

// This message is passed through the errorLogger object to the warnLogger object, and onto the

// infoLogger object which is the one handling 'info' type log messages

log.message("Something happened", LogLevel.INFO);

// Output the stored logs

alert(log.getLogs());

// Outputs the following:

/*

ERROR: SOMETHING VERY BAD HAPPENED

WARN: Something bad happened

Something happened

*/

当您有一个对象层次结构,并且希望在整个代码中访问它而不暴露这个结构时,最好使用责任链模式。要了解有关责任链模式的更多信息,请查看以下在线资源:

命令模式

command 模式用于在调用代码和对象的特定方法之间提供一个抽象层,确保所有调用都是通过该对象上的一个公共方法进行的,通常命名为run()execute()。使用这种模式提供了在不影响调用代码的情况下更改底层代码和 API 的能力。清单 7-2 中的例子显示了一个简单的命令模式的例子,它把要执行的方法名和参数传递给一个单独的execute()方法。

清单 7-2。命令模式

var cookie = (function() {

var allCookies = document.cookie.split(";"),

cookies = {},

cookiesIndex = 0,

cookiesLength = allCookies.length,

cookie;

for (; cookiesIndex < cookiesLength; cookiesIndex++) {

cookie = allCookies[cookiesIndex].split("=");

cookies[unescape(cookie[0])] = unescape(cookie[1]);

}

return {

get: function(name) {

return cookies[name] || "";

},

set: function(name, value) {

cookies[name] = value;

document.cookie = escape(name) + "=" + escape(value);

},

remove: function(name) {

// Remove the cookie by removing its entry from the cookies object and setting its

// expiry date in the past

delete cookies[name];

document.cookie = escape(name) + "=; expires=Thu, 01 Jan 1970 00:00:01 GMT;";

},

// Supply an execute() method, which is used to abstract calls to other methods so that

// other method names can be changed as needs be in future without affecting the API

// available to the rest of the code - provided this execute() method continues to exist

execute: function(command, params) {

// The command parameter contains the method name to execute, so check that the

// method exists and is a function

if (this.hasOwnProperty(command) && typeof this[command] === "function") {

// If the method exists and can be executed, then execute it, passing across the

// supplied params

return this[command].apply(this, params);

}

}

};

}());

// Set a cookie using the execute() method to indirectly call the set() method of the cookie

// singleton and supplying parameters to pass onto that method

cookie.execute("set", ["name", "Den Odell"]);

// Check that the cookie was set correctly using execute() with the "get" method

alert(cookie.execute("get", ["name"])); // Den Odell

命令模式也可以在需要“撤消”功能的应用的上下文中使用,其中执行的语句可能需要在将来的某个时间点被撤销,例如,在文字处理 web 应用的上下文中。在这种情况下,命令是通过一个命令执行对象来传递的,该对象使用这个抽象来存储适当的函数,以反转传递给它的方法调用,如清单 7-3 所示,它显示了一个简单的命令执行对象和一个基于清单 7-2 中的代码使用 cookies 的例子。

清单 7-3。支持 web 应用中多级撤消的命令执行对象

// Create a singleton for allowing execution of other methods and providing the ability to

// 'undo' the actions of those methods

var command = (function() {

// Create an array to store the 'undo' commands in order, also known as a 'stack'

var undoStack = [];

return {

// Define a method to execute a supplied function parameter, storing a second function

// parameter for later execution to 'undo' the action of the first function

execute: function(command, undoCommand) {

if (command && typeof command === "function") {

// If the first parameter is a function, execute it, and add the second

// parameter to the stack in case the command needs to be reversed at some point

// in future

command();

undoStack.push(undoCommand);

}

},

// Define a method to reverse the execution of the last command executed, using the

// stack of 'undo' commands

undo: function() {

// Remove and store the last command from the stack, which will be the one most

// recently added to it. This will remove that command from the stack, reducing the

// size of the array

var undoCommand = undoStack.pop();

if (undoCommand && typeof undoCommand === "function") {

// Check the command is a valid function and then execute it to effectively

// 'undo' the last command

undoCommand();

}

}

};

}());

// Wrap each piece of functionality that can be 'undone' in a call to the command.execute()

// method, passing the command to execute immediately as the first parameter, and the function

// to execute to reverse that command as the second parameter which will be stored until such

// point as it is needed

command.execute(function() {

// Using the code from Listing 7-2, set a cookie - this will be executed immediately

cookie.execute("set", ["name", "Den Odell"]);

}, function() {

// The reverse operation of setting a cookie is removing that cookie - this operation will

// be stored for later execution if the command.undo() method is called

cookie.execute("remove", ["name"]);

});

// Execute a second piece of functionality, setting a second cookie

command.execute(function() {

cookie.execute("set", ["company", "AKQA"]);

}, function() {

cookie.execute("remove", ["company"]);

});

// Check the value of the two cookies

alert(cookie.get("name"));    // Den Odell

alert(cookie.get("company")); // AKQA

// Reverse the previous operation, removing the 'company' cookie

command.undo();

// Check the value of the two cookies

alert(cookie.get("name"));    // Den Odell

alert(cookie.get("company")); // "" (an empty string), since the cookie has now been removed

// Reverse the first operation, removing the 'name' cookie

command.undo();

// Check the value of the two cookies

alert(cookie.get("name"));    // "", since the cookie has now been removed

alert(cookie.get("company")); // ""

当您需要从代码的其余部分中抽象出特定的方法名时,最好使用 command 模式。通过按名称引用方法,就像存储在字符串中一样,底层代码可以随时更改,而不会影响代码的其余部分。要在线阅读有关命令模式的更多信息,请查阅以下资源:

迭代器模式

顾名思义,交互模式允许应用中的代码在一组数据上迭代或循环,而不需要知道数据是如何在内部存储或构造的。迭代器通常提供一组标准方法,用于移动到集合中的下一项,并检查当前项是集合中的第一项还是最后一项。

清单 7-4 显示了一个通用“类”的例子,它可以迭代Array类型和Object类型的数据。这个迭代器的实例可以使用提供的方法rewind()current()next()hasNext()first()手动操作和查询,或者可以使用其each()方法提供自动自迭代,其中函数回调参数为数据集中的每一项执行一次,提供了一个有用的for循环的等效形式。

清单 7-4。迭代器模式

// Define a generic iterator "class" for iterating/looping over arrays or object-like data

// structures

function Iterator(data) {

var key;

// Store the supplied data in the 'data' property

this.data = data || {};

this.index = 0;

this.keys = [];

// Store an indicator to show whether the supplied data is an array or an object

this.isArray = Object.prototype.toString.call(data) === "[object Array]";

if (this.isArray) {

// If the supplied data is an array, store its length for fast access

this.length = data.length;

} else {

// If object data is supplied, store each property name in an array

for (key in data) {

if (data.hasOwnProperty(key)) {

this.keys.push(key);

}

}

// The length of the property name array is the length of the data to iterate over,

// so store this

this.length = this.keys.length;

}

}

// Define a method to reset the index, effectively rewinding the iterator back to the start of

// the data

Iterator.prototype.rewind = function() {

this.index = 0;

};

// Define a method to return the value stored at the current index position of the iterator

Iterator.prototype.current = function() {

return this.isArray ? this.data[this.index] : this.data[this.keys[this.index]];

};

// Define a method to return the value stored at the current index position of the iterator,

// and then advance the index pointer to the next item of data

Iterator.prototype.next = function() {

var value = this.current();

this.index = this.index + 1;

return value;

};

// Define a method to indicate whether the index position is at the end of the data

Iterator.prototype.hasNext = function() {

return this.index < this.length;

};

// Define a method to reset the index of the iterator to the start of the data and return

// the first item of data

Iterator.prototype.first = function() {

this.rewind();

return this.current();

};

// Define a method to iterate, or loop, over each item of data, executing a callback

// function each time, passing in the current data item as the first parameter to

// that function

Iterator.prototype.each = function(callback) {

callback = typeof callback === "function" ? callback : function() {};

// Iterate using a for loop, starting at the beginning of the data (achieved using the

// rewind() method) and looping until there is no more data to iterate over (indicated

// by the hasNext() method)

for (this.rewind(); this.hasNext();) {

// Execute the callback function each time through the loop, passing in the current

// data item value and incrementing the loop using the next() method

callback(this.next());

}

};

清单 7-4 中的代码可以像清单 7-5 中所示的那样使用,它展示了使用通用迭代器“class”对存储的数据进行迭代和循环的不同方式

清单 7-5。正在使用的迭代器模式

// Define an object and an array which we can use to iterate over

var user = {

name: "Den Odell",

occupation: "Head of Web Development",

company: "AKQA"

},

daysOfWeek = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],

// Create instances of the Iterator "class" using these two different types of data

userIterator = new Iterator(user),

daysOfWeekIterator = new Iterator(daysOfWeek),

// Create three arrays for storing outputs of interations to be displayed later

output1 = [],

output2 = [],

output3 = [];

// The userIterator is ready for use, so let's use a for loop to iterate over the stored data –

// note how we don't need to supply the first argument to the for loop as the data is already

// reset and initialized in its start position, and we don't require the last argument since the

// next() method call within the for loop body performs the advancement of the index position

// for us

for (; userIterator.hasNext();) {

output1.push(userIterator.next());

}

// Since we iterated over an object, the resulting data consists of the values stored in each of

// the object's properties

alert(output1.join(", ")); // Den Odell, Head of Web Development, AKQA

// Before iterating over the same data again, its index must be rewound to the start

userIterator.rewind();

// Iterate over the object properties using a while loop, which continues to execute until the

// iterator has no further data items

while (userIterator.hasNext()) {

output2.push(userIterator.next());

}

alert(output2.join(", ")); // Den Odell, Head of Web Development, AKQA

// Iterate over the array data using the Iterator's built-in each() method - using this

// approach requires no manual work to manipulate the position of the index, simply pass a

// callback function

daysOfWeekIterator.each(function(item) {

output3.push(item);

});

alert(output3.join(", ")); // Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday

当您需要为代码的其余部分提供一种标准方式来遍历复杂的数据结构,而又不暴露数据最终是如何存储或表示的时候,最好使用迭代器模式。要在线了解迭代器模式的更多信息,请查阅以下在线资源:

观察者模式

观察者模式用于由许多独立的代码模块组成的大型代码库中,这些代码模块相互依赖或者必须相互通信。在这样的代码库中,从一个模块到其他模块的硬编码引用提供了所谓的紧耦合,即需要明确了解系统中的每个其他模块,以便整个代码能够一起正确运行。然而,理想情况下,大型代码库中的模块应该是松散耦合的。没有明确地引用其他模块;相反,整个代码库都会触发和监听系统范围的事件,就像标准 DOM 事件处理的定制版本一样。

例如,如果一个模块负责通过 Ajax 进行的所有客户端-服务器通信,而另一个模块负责在将表单传输到服务器之前对其进行渲染和验证,那么当用户成功提交和验证表单时,代码库可以触发一个全局的“表单已提交”事件以及表单中的数据,通信模块将监听这些数据。然后,通信模块将执行其任务,向服务器发送数据,并在自身触发“接收到响应”事件之前接收其响应,表单模块将监听该事件。收到该事件后,表单模块可以显示一条消息,表明表单已成功提交,所有这些都不需要任何一个模块相互了解——每个模块唯一知道的是一组全局配置的事件名,系统中的任何模块都可以触发或响应这些事件名。

实现观察者模式的系统必须有三个对系统代码库可用的全局方法:publish(),它通过名称触发事件,传递任何可选数据;subscribe(),它允许模块分配一个函数,当特定的命名事件被触发时执行;和unsubscribe(),它取消了函数的设计,这样当指定的事件被触发时,它将不再被执行。清单 7-6 中的代码演示了一个简单的对象,它可以在您的应用中全局使用,以实现 observer 模式中的这些方法。

清单 7-6。观察者模式

// Define an object containing global publish(), subscribe(), and unsubscribe() methods to

// implement the observer pattern

var observer = (function() {

// Create an object for storing registered events in by name along with the associatedw

// callback functions for any part of the full code base that subscribes to those

// event names

var events = {};

return {

// Define the subscribe() method, which stores a function along with its associated

// event name to be called at some later point when the specific event by that name

// is triggered

subscribe: function(eventName, callback) {

// If an event by the supplied name has not already been subscribed to, create an

// array property named after the event name within the events object to store

// functions to be called at a later time when the event by that name is triggered

if (!events.hasOwnProperty(eventName)) {

events[eventName] = [];

}

// Add the supplied callback function to the list associated to the specific

// event name

events[eventName].push(callback);

},

// Define the unsubscribe() method, which removes a given function from the list of

// functions to be executed when the event by the supplied name is triggered

unsubscribe: function(eventName, callback) {

var index = 0,

length = 0;

if (events.hasOwnProperty(eventName)) {

length = events[eventName].length;

// Cycle through the stored functions for the given event name and remove the

// function matching that supplied from the list

for (; index < length; index++) {

if (events[eventName][index] === callback) {

events[eventName].splice(index, 1);

break;

}

}

}

},

// Define the publish() method, which executes all functions associated with the given

// event name in turn, passing to each the same optional data passed as arguments to

// the method

publish: function(eventName) {

// Store all parameters but the first passed to this function as an array

var data = Array.prototype.slice.call(arguments, 1),

index = 0,

length = 0;

if (events.hasOwnProperty(eventName)) {

length = events[eventName].length;

// Cycle through all of the functions associated with the given event name and

// execute them each in turn, passing along any supplied parameters

for (; index < length; index++) {

events[eventName][index].apply(this, data);

}

}

}

};

}());

清单 7-7 中的代码演示了如何使用清单 7-6 中给出的观察者模式的publish()subscribe()unsubscribe()方法。假设它运行在一个 HTML 页面的上下文中,该页面包含一个具有有效action属性的<form id= "my-form" >标签,并且包含几个表示表单字段的<input type= "text" >标签。

清单 7-7。正在使用的观察者模式

// Define a module for Ajax communication, with a dependency on the observer object

// from Listing 7-6

(function(observer) {

// Define a function for performing an Ajax POST based on a supplied URL, form-encoded data

// string, and a callback function to execute once a response has been received from

// the server

function ajaxPost(url, data, callback) {

var xhr = new XMLHttpRequest(),

STATE_LOADED = 4,

STATUS_OK = 200;

xhr.onreadystatechange = function() {

if (xhr.readyState !== STATE_LOADED) {

return;

}

if (xhr.status === STATUS_OK) {

// Execute the supplied callback function once a successful response has been

// received from the server

callback(xhr.responseText);

}

};

xhr.open("POST", url);

// Inform the server that we will be sending form-encoded data, where names and values

// are separated by the equals sign (=) character, and name/value pairs are separated by

// the ampersand (&) character

xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded");

// POST the data to the server

xhr.send(data);

}

// Subscribe to the global, custom "form-submit" event and, when this event is triggered by

// another module in the code base, make a Ajax POST request to the server using the

// supplied URL and data. Trigger the "ajax-response" event when complete, passing in the

// server's response from the Ajax call

observer.subscribe("form-submit", function(url, formData) {

ajaxPost(url, formData, function(response) {

// Trigger the global "ajax-response" event, passing along the data returned from

// the server during the Ajax POST

observer.publish("ajax-response", response);

});

});

}(observer));

// Define a module for handling submission of a simple form on the page containing text fields

// only with an ID of "my-form". Note that neither of the modules in this code listing reference

// each other, they only reference the observer object which handles all communication between

// modules in the system. Each module is said to be "loosely-coupled" as it has no hardcoded

// dependency on any other module

(function(observer) {

// Get a reference to a form on the current HTML page with ID "my-form"

var form = document.getElementById("my-form"),

// Get the "action" attribute value from the form, which will be the URL we perform an

// Ajax POST to

action = form.action,

data = [],

// Get a reference to all <input> fields within the form

fields = form.getElementsByTagName("input"),

index = 0,

length = fields.length,

field,

// Create a HTML <p> tag for use as a thank you message after form submission has

// taken place

thankYouMessage = document.createElement("p");

// Define a function to execute on submission of the form which uses the observer pattern to

// submit the form field data over Ajax

function onFormSubmit(e) {

// Prevent the default behavior of the submit event, meaning a normal in-page HTML form

// submission will not occur

e.preventDefault();

// Loop through all <input> tags on the page, creating an array of name/value pairs of

// the data entered into the form

for (; index < length; index++) {

field = fields[index];

data.push(escape(field.name) + "=" + escape(field.value));

}

// Trigger the global "form-submit" event on the observer object, passing it the URL to

// use for the Ajax POST and the form data to be sent. The Ajax communication module is

// listening for this event and will handle everything pertaining to the submission of

// that data to the server.

observer.publish("form-submit", action, data.join("&"));

}

// Wire up the onFormSubmit() function to the "submit" event of the form

form.addEventListener("submit", onFormSubmit, false);

// Subscribe to the global, custom "ajax-response" event, and use the server's response data

// sent along with the event to populate a Thank You message to display on the page beside

// the form

observer.subscribe("ajax-response", function(response) {

thankYouMessage.innerHTML = "Thank you for your form submission.<br>The server responded with: " + response;

form.parentNode.appendChild(thankYouMessage);

});

}(observer));

observer 模式允许您删除代码中模块之间的硬编码引用,以维护自定义的系统范围的事件列表。随着代码库的增长和模块数量的增加,可以考虑使用这种模式来简化代码,并将模块彼此分离。请注意,如果在您的模块之一中发生错误,并且没有触发应该触发的事件,错误的来源可能不会立即显现出来,并且可能需要额外的调试。我建议在开发期间将您自己的调试日志记录添加到您的 observer 对象中,以允许您更容易地跟踪代码中的事件。

当您希望将模块松散地耦合在一起以减少杂乱无章的代码时,最好使用观察者模式。要在线阅读有关这种流行模式的更多信息,请查看以下资源:

中介模式

中介模式是观察者模式的一种变体,在一个关键方面有所不同。观察者模式定义了一个全局对象,用于在整个系统中发布和订阅事件,而中介模式定义了用于特定目的的本地化对象,每个对象都有相同的publish()subscribe()unsubscribe()方法。随着您的代码库变得越来越大,observer 模式被证明会产生大量难以管理的事件,因此可以使用 mediator 模式将这个较大的事件列表分成较小的组。观察者模式是通过一个全局单例对象实现的,而中介者模式是通过使用一个“类”来实现的,因此可以根据需要创建尽可能多的对象实例来支持代码的特性。清单 7-8 显示了用来在你的代码中实现中介模式的“类”。注意与清单 7-6 中为实现观察者模式而创建的对象的相似之处。

清单 7-8。中介模式

// Define a "class" containing publish(), subscribe(), and unsubscribe() methods to implement

// the mediator pattern. Note the similarilty to the observer pattern, the only difference is

// that we are creating a "class" here for creating object instances from later, and that we

// initialize the events array afresh for each object instance to avoid all instances sharing

// the same array in memory.

function Mediator() {

this.events = {};

}

Mediator.prototype.subscribe = function(eventName, callback) {

if (!this.events.hasOwnProperty(eventName)) {

this.events[eventName] = [];

}

this.events[eventName].push(callback);

};

Mediator.prototype.unsubscribe = function(eventName, callback) {

var index = 0,

length = 0;

if (this.events.hasOwnProperty(eventName)) {

length = this.events[eventName].length;

for (; index < length; index++) {

if (this.events[eventName][index] === callback) {

this.events[eventName].splice(index, 1);

break;

}

}

}

};

Mediator.prototype.publish = function(eventName) {

var data = Array.prototype.slice.call(arguments, 1),

index = 0,

length = 0;

if (this.events.hasOwnProperty(eventName)) {

length = this.events[eventName].length;

for (; index < length; index++) {

this.events[eventName][index].apply(this, data);

}

}

};

清单 7-8 中的中介模式可以如清单 7-9 所示实现,创建中介对象来表示代码中的特定特性,并允许代码库中有模块。假设它运行在包含一个<form id= "my-form" >标签的 HTML 页面的上下文中,该标签包含几个表示表单字段的<input type= "text" >标签。

清单 7-9。正在使用的中介模式

// Define two mediators for our code base, one pertaining to code for a forms feature, and

// another to enable a message logging feature.

// The formsMediator will feature two events: "form-submit", and "ajax-response", whereas

// the loggingMediator will feature three events, "log", "retrieve-log", and "log-retrieved".

// Note how we're able to separate events for different features in our code using the

// mediator pattern

var formsMediator = new Mediator(),

loggingMediator = new Mediator();

// Define a module for Ajax communication which POSTs some supplied data to the server when a

// "form-submit" event is triggered within the formsMediator

(function(formsMediator) {

function ajaxPost(url, data, callback) {

var xhr = new XMLHttpRequest(),

STATE_LOADED = 4,

STATUS_OK = 200;

xhr.onreadystatechange = function() {

if (xhr.readyState !== STATE_LOADED) {

return;

}

if (xhr.status === STATUS_OK) {

callback(xhr.responseText);

}

};

xhr.open("POST", url);

xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded");

xhr.send(data);

}

formsMediator.subscribe("form-submit", function(url, formData) {

ajaxPost(url, formData, function(response) {

formsMediator.publish("ajax-response", response);

});

});

}(formsMediator));

// Define a module for handling submission of a simple form on the page containing text fields

// only with an ID of "my-form". When the form is submitted, the "form-submit" event is

// triggered within the formsMediator

(function(formsMediator) {

var form = document.getElementById("my-form"),

action = form.action,

data = [],

fields = form.getElementsByTagName("input"),

index = 0,

length = fields.length,

field,

thankYouMessage = document.createElement("p");

function onFormSubmit(e) {

e.preventDefault();

for (; index < length; index++) {

field = fields[index];

data.push(escape(field.name) + "=" + escape(field.value));

}

formsMediator.publish("form-submit", action, data.join("&"));

}

form.addEventListener("submit", onFormSubmit, false);

formsMediator.subscribe("ajax-response", function(response) {

thankYouMessage.innerHTML = "Thank you for your form submission.<br>The server responded with: " + response;

form.parentNode.appendChild(thankYouMessage);

});

}(formsMediator));

// Define a module for logging messages within the system to aid with debugging of issues that

// might occur. Uses the loggingMediator to separate the logging feature of the code base

// separate from that handling the form submission with the formsMediator

(function(loggingMediator) {

// Create an array to store the logs

var logs = [];

// When the "log" event is triggered on the loggingMediator, add an object to the logs

// containing a supplied message and the date / time that the message was received at

loggingMediator.subscribe("log", function(message) {

logs.push({

message: message,

date: new Date()

});

});

// When the "retrieve-log" event is triggered on the loggingMediator, trigger the

// "log-retrieved" event, passing along the current state of the stored logs

loggingMediator.subscribe("retrieve-log", function() {

loggingMediator.publish("log-retrieved", logs);

});

}(loggingMediator));

// Define a module which allows the stored logs in the loggingMediator to be displayed on screen

(function(loggingMediator) {

// Create a button which, when clicked, will display the current state of the log

var button = document.createElement("button");

button.innerHTML = "Show logs";

button.addEventListener("click", function() {

// Trigger the "retrieve-log" event within the loggingMediator. This triggers the

// "log-retrieved" event, passing along the current state of the logs

loggingMediator.publish("retrieve-log");

}, false);

// When the "log-retrieved" event occurs, display the logs on screen

loggingMediator.subscribe("log-retrieved", function(logs) {

var index = 0,

length = logs.length,

ulTag = document.createElement("ul"),

liTag = document.createElement("li"),

listItem;

// Loop through each log in the list of logs, rendering the date / time and message

// stored within a <li> tag

for (; index < length; index++) {

listItem = liTag.cloneNode(false);

listItem.innerHTML = logs[index].date.toUTCString() + ": " + logs[index].message;

ulTag.appendChild(listItem);

}

// Add the <ul> tag containing all the <li> tags representing the log data to the bottom

// of the page

document.body.appendChild(ulTag);

});

// Add the button to the bottom of the current page

document.body.appendChild(button);

}(loggingMediator));

// Define a module which logs events that occur within the formsMediator. This is the only

// module in this example to use more than one mediator

(function(formsMediator, loggingMediator) {

// Use the loggingMediator's "log" events to log the URL the form is submitted to when the

// "form-submit" event is triggered within the formsMediator

formsMediator.subscribe("form-submit", function(url) {

loggingMediator.publish("log", "Form submitted to " + url);

});

// Log the response from the server that is supplied when the "ajax-response" event is

// triggered within the formsMediator

formsMediator.subscribe("ajax-response", function(response) {

loggingMediator.publish("log", "The server responded to an Ajax call with: " + response);

});

}(formsMediator, loggingMediator));

随着代码库的增长,您可能会发现从观察者模式转移到中介者模式是有意义的,这样可以将系统的事件组织成更易于管理的特性。

当您希望在一个非常大的代码库中将模块松散地耦合在一起时,最好使用 mediator 模式,如果使用 observer 模式,要处理的事件数量将会非常多。要了解有关中介模式的更多信息,请查看以下在线资源:

纪念品图案

memento 模式定义了对象数据在存储器中以静态形式的存储,使得它可以在代码执行过程中的稍后时间被恢复;这就好像您可以在任何时候拍摄一个对象的快照,然后您可以恢复它。清单 7-10 显示了一个简单的“类”,通过将对象的快照存储为 JSON 格式的字符串表示,并提供存储和恢复原始 JavaScript 对象的方法,可以用来实现这种模式。

清单 7-10。纪念品图案

// Define a simple "class" to be used to implement the memento pattern. It can be used to

// provide the ability to save and restore a snapshot of an object in memory.

// Certain older browsers (e.g. Internet Explorer 7) do not support the JSON.stringify() and

// JSON.parse() methods natively. For these, you should include Doug Crockford's json2.js

// library found athttps://github.com/douglascrockford/JSON-js

function Memento() {

// Define an object in memory to store snapshots of other objects under a specified key

this.storage = {};

}

// Define a method to save the state of any object under a specified key

Memento.prototype.saveState = function(key, obj) {

// Convert the supplied object to a string representation in JSON format

this.storage[key] = JSON.stringify(obj);

};

// Define a method to restore and return the state of any object stored under a specified key

Memento.prototype.restoreState = function(key) {

var output = {};

// If the supplied key exists, locate the object stored there

if (this.storage.hasOwnProperty(key)) {

output = this.storage[key];

// Convert the stored value from a JSON string to a proper object

output = JSON.parse(output) ;

}

return output;

};

清单 7-11 展示了清单 7-10 中 memento“class”的应用。

清单 7-11。正在使用的纪念品图案

// Define an instance of a memento to allow us to save and restore the state of objects

var memento = new Memento(),

// Define an object whose state we wish to be able to save and restore

user = {

name: "Den Odell",

age: 35

};

// Save the current state of the user object using the memento

memento.saveState("user", user);

// Prove that the state of the object is save in JSON format by reading from the storage object

// of the memento directly

alert(memento.storage["user"]); // {"name":"Den Odell","age":35}

// Now change the values in the user object as you wish

user.name = "John Smith";

user.age = 21;

// Output the current state of the user object

alert(JSON.stringify(user)); // {"name":"John Smith","age":21}

// Whenever you wish to restore the last saved state of the user object, simply call the restoreState() method of the memento

user = memento.restoreState("user");

// Output the new value of the user object, which has been restored to its last saved state

alert(JSON.stringify(user)); // {"name":"Den Odell","age":35}

当您需要在应用执行的特定时刻存储和恢复应用中对象的快照时,最好使用 memento 模式。要在线阅读有关 memento 模式的更多信息,请查看以下资源:

承诺模式

当处理异步函数时,通常会将回调函数传递给这样的函数。该函数在完成工作后,将代表我们执行回调函数。这正如我们所希望的那样工作;唯一的问题是,它可能会创建更难阅读、更模糊的代码——您必须知道调用的函数是异步函数,传递给它的函数被用作回调函数。如果您希望在执行回调之前等待几个异步函数的结果完成,这会使结果代码更加模糊,难以理解。进入 promises 模式,这是一个创建于 20 世纪 70 年代的设计模式,但在 CommonJS 小组( http://bit.ly/common_js )的工作中针对 JavaScript 进行了更新。它定义了一种从异步调用返回承诺的方法,然后可以将该方法与对另一个函数的调用链接起来,该函数只有在承诺完成时才会执行,这发生在异步调用完成时。这样做的好处是确保回调与异步函数的调用充分分离,从而提高代码的清晰度,使代码更具可读性,从而更易于理解和维护。

承诺在 JavaScript 中表现为包含一个then()方法的对象实例,一旦相关的异步函数完成执行,就会执行该方法。考虑一个简单的 Ajax 调用,它需要一个回调函数作为第二个参数,执行如下:

ajaxGet("/my-url", function(response) {

// Do something with response

}).

使用 promises 模式,对同一个 Ajax 调用生成的 JavaScript 如下所示:

ajaxGet("/my-url").then(function(response) {

// Do something with response

});

您可能认为这两者之间没有什么区别,但事实上后者更加清晰:它清楚地告诉我们,一旦第一个函数完成,第二个函数将会执行,而这仅仅是暗示了前者的情况。一旦与多个异步调用一起使用,promises 模式比使用回调的等价代码更加清晰。例如,考虑下面的代码,它对一个 URL 进行 Ajax 调用,然后对另一个 URL 进行第二次 Ajax 调用:

ajaxGet("/my-url", function() {

ajaxGet("/my-other-url", function() {

// Do something

});

});

使用 promises 模式,这段代码被简化为更容易理解的代码,并且避免了嵌套代码的层次,这种层次在链中的异步调用越多,就会变得越极端:

ajaxGet("/my-url").then(ajaxGet("/my-other-url")).then(function() {

// Do something

});

当在标准 JavaScript 中发生了大量同步异步调用之后,试图执行单个回调时,事情会变得更加复杂。使用 promises 模式,您可以简单地将一个承诺数组传递给它的all()方法,它将同时执行每个承诺,当数组中的每个方法都实现了它自己的承诺时,返回一个承诺,如下所示:

Promise.all([ajaxGet("/my-url"), ajaxGet("/my-other-url")]).then(function() {

// Do something with the data returned from both calls

});

清单 7-12 显示了 JavaScript 中表示承诺的“类”。我在 http://bit.ly/js_promises 的 GitHub 项目中管理这个代码的独立版本,你可以在你的项目中自由使用。

清单 7-12。承诺模式

// Define a "class" representing a promise, allowing readable and understandable code to be

// written to support asynchronous methods and their callbacks. Instances created from this

// "class" adhere to the Promises/A+ specification detailed athttp://promisesaplus.com

// pass all the official unit tests found athttps://github.com/promises-aplus/promises-tests

// which prove compliance of this specification.

var Promise = (function() {

// Define the three possible states a promise can take - "pending" - the default value

// meaning it has not resolved yet, "fulfilled" - meaning the promise has resolved

// successfully, and "rejected" - meaning the promise has failed and an error has occurred

var state = {

PENDING: "pending",

FULFILLED: "fulfilled",

REJECTED: "rejected"

};

// Define the "class" to represent a promise. If an asynchronous function is passed in at

// the point of instantiation, it will be executed immediately

function Promise(asyncFunction) {

var that = this;

// Define a property to represent the current state of the promise, set to "pending" by

// default

this.state = state.PENDING;

// Define a property to be used to store a list of callback functions to call once the

// asynchronous method has completed execution

this.callbacks = [];

// Define a property to store the value returned by the asynchronous method represented

// by this promise

this.value = null;

// Define a property to store the details of any error that occurs as a result of

// executing the asynchronous method

this.error = null;

// Define two functions which will be passed to the asynchronous function

// represented by this promise. The first will be executed if the asynchronous

// function executed successfully, the second will be executed if the execution

// failed in some way

function success(value) {

// Executes the resolve() method of this promise, which will ensure that any

// functions linked to this promise to be executed once its asynchronous method

// has executed successfully is executed at this point

that.resolve(value);

}

function failure(reason) {

// Executes the reject() method of this promise, which will execute any

// linked callback functions for displaying or handling errors. Any furthe r

// associated promises chained to this one will not be executed.

that.reject(reason);

}

// If an asynchronous function is passed to this promise at instantiation, it is

// executed immediately, and the success() and failure() functions defined above

// are passed in as function parameters. The asynchronous function must ensure it

// executes the most appropriate of these two functions depending on the outcome

// of the behaviour it is attempting to perform

if (typeof asyncFunction === "function") {

asyncFunction(success, failure);

}

}

// Define a then() method, the crux of the Promises/A+ spec, which allows callbacks to

// be associated to the result of the asynchronous function's execution depending on

// whether that function completed its task successfully or not. It allows chaining of

// promises to each other to allow further asynchronous functions to be executed at

// the point at which the current one is completed successfully

Promise.prototype.then = function(onFulfilled, onRejected) {

// Create a new promise (and return it at the end of this method) to allow for

// chaining of calls to then()

var promise = new Promise(),

// Define a callback object to be stored in this promise and associate the new

// promise instance to it to act as the context of any callback methods

callback = {

promise: promise

};

// If a function was provided to be executed on successful completion of the

// asynchronous function's action, store that function in the callback object

// together with its newly created promise as context

if (typeof onFulfilled === "function") {

callback.fulfill = onFulfilled;

}

// If a function was provided to be executed on unsuccessful completion of the

// asynchronous function's action, store that function in the callback object

// together with the new context promise

if (typeof onRejected === "function") {

callback.reject = onRejected;

}

// Add the callback object to the list of callbacks

this.callbacks.push(callback);

// Attempt to execute the stored callbacks (will only do this if the asynchronous

// function has completed execution by this point - if not, it will be called at

// such time as it has by other code in the "class")

this.executeCallbacks();

// Return the newly created promise, to allow for chaining of other asynchronous

// functions through repeated calls to the then() method

return promise;

};

// Define a method to execute any callbacks associated with this promise if the

// associated asynchronous function has completed execution

Promise.prototype.executeCallbacks = function() {

var that = this,

value,

callback;

// Define two functions to use as defaults to execute if an equivalent function has

// not been stored in the list of callbacks tied to this promise

function fulfill(value) {

return value;

}

function reject(reason) {

throw reason;

}

// Only execute the callbacks if the promise is not in its pending state, i.e. that

// the asynchronous function has completed execution

if (this.state !== state.PENDING) {

// Point 2.2.4 of the Promises/A+ spec dictates that callback functions should

// be executed asynchronously, outside of the flow of any other calls to then()

// which might take place. This ensures the whole chain of promises is in place

// before calls to the callbacks take place. Using a setTimeout with a delay of

// 0 milliseconds gives the JavaScript engine a split second to complete the

// process of going through the promise chain before any callbacks are run.

// Browsers have a minimum delay value possible for a setTimeout call so in

// reality the callbacks will be executed after, typically, 4 milliseconds

setTimeout(function() {

// Loop through all the callbacks associated with this promise and execute

// them each in turn, selecting the callback's fulfill method if the promise

// was fulfilled (by the asynchronous function completing execution

// successfully), or its reject method if the function returned an error

// during execution

while(that.callbacks.length) {

callback = that.callbacks.shift();

// Wrap the execution of the callback in a try/catch block, in case it

// throws an error. We don't want the promise chain to stop executing if

// an error is thrown, rather we want to reject the promise, allowing

// the calling code to handle the error itself

try {

// Execute the appropriate callback method based on the state of

// the promise. If no callback method has been associated, fall

// back to the default fulfill() and reject() functions defined at

// the top of the executeCallbacks() method, above

if (that.state === state.FULFILLED) {

value = (callback.fulfill || fulfill)(that.value);

} else {

value = (callback.reject || reject)(that.error);

}

// Pass the result of executing the callback function to the

// resolve() method, which will either mark the promise as fulfilled

// or continue to further execute chained calls to the then() method

callback.promise.resolve(value);

} catch (reason) {

// If an error is thrown by the callback

callback.promise.reject(reason);

}

}

}, 0);

}

};

// The fulfill() method will mark this promise as fulfilled provided it has not already

// been fulfilled or rejected before. Any associated callbacks will be executed at

// this point

Promise.prototype.fulfill = function(value) {

// Only transition the promise to the fulfilled state if it is still in the pending

// state, and a value is passed to this method when it is executed

if (this.state === state.PENDING && arguments.length) {

this.state = state.FULFILLED;

this.value = value;

this.executeCallbacks();

}

};

// The reject() method will mark this promise as rejected provided it has not already

// been fulfilled or rejected before. Any associated callbacks will be executed at

// this point

Promise.prototype.reject = function(reason) {

// Only transition the promise to the rejected state if it is still in the pending

// state, and a value is passed to this method when it is executed

if (this.state === state.PENDING && arguments.length) {

this.state = state.REJECTED;

this.error = reason;

this.executeCallbacks();

}

};

// The resolve() method takes the return value from a successfull call to a promise's

// fulfill() callback and uses it to fulfill the promise if it is the last promise in

// a chain of then() method calls. If it is not the last promise, it continues down

// the promise chain, recursively fulfilling and rejecting the linked promises as

// appropriate

Promise.prototype.resolve = function(value) {

var promise = this,

// Detect the type of the value returned from the fulfill() callback method. If

// this is the last promise in a chain, this should be the result of executing

// the asynchronous function itself. If this promise has other chained promises

// then the value passed to this method will contain another promise which will

// call the resolve() method again, recursively

valueIsThisPromise = promise === value,

valueIsAPromise = value && value.constructor === Promise,

// The term "thenable" refers to an object that looks like a promise in that it

// contains a then() method of its own, yet isn't an instance of this Promise

// "class" - useful for connecting promises created by other implementations of

// the Promises/A+ spec together

valueIsThenable = value && (typeof value === "object" || typeof value === "function"),

isExecuted = false,

then;

// Reject this promise if the value passed to this method represents the same

// promise represented here - otherwise we could potentially get stuck in a loop

if (valueIsThisPromise) {

// The Promises/A+ spec dictates that should this promise be the same as the

// one passed to this method, then a TypeError should be passed to the reject()

// method, effectively stopping execution of further promises in the chain

promise.reject(new TypeError());

// If the value passed to the resolve() method is another instance of this Promise

// "class", then either fulfill or reject the current promise based on the state of

// the provided promise

} else if (valueIsAPromise) {

// If the promise passed into this method has already been fulfilled or

// rejected, pass on the value or error contained within it to this promise

if (value.state === state.FULFILLED) {

promise.fulfill(value.value);

} else if (value.state === state.REJECTED) {

promise.reject(value.error);

// If the promise passed into this method hasn't yet been fulfilled or rejected,

// execute its then() method to ensure the current promise will get resolved

// or rejected along with that promise once it has completed execution of its

// asynchronous function

} else {

value.then(function(value) {

promise.resolve(value);

}, function(reason) {

promise.reject(reason);

});

}

// If the value passed to the resolve() method is not an instance of this Promise

// "class" but resembles a promise in that it is an object containing its own

// then() method, then execute its then() method, fulfilling or rejecting the

// current promise based on the state of this promise. This comes in useful when

// attempting to connect promises created with other implementations of the same

// spec together with this one

} else if (valueIsThenable) {

// Wrap execution in a try/catch block in case an error is thrown in the

// underlying code of the other promise implementation

try {

then = value.then;

// If the object stored in the value variable contains a then() method,

// execute it to ensure the current promise gets fulfilled or rejected when

// that promise does

if (typeof then === "function") {

then.call(value, function(successValue) {

if (!isExecuted) {

isExecuted = true;

promise.resolve(successValue);

}

}, function(reason) {

if (!isExecuted) {

isExecuted = true;

promise.reject(reason);

}

});

} else {

promise.fulfill(value);

}

} catch (reason) {

if (!isExecuted) {

isExecuted = true;

promise.reject(reason);

}

}

// If the value passed to the resolve() method is not a promise, then fulfill the

// current promise using its value. Any associated callbacks will then be executed

} else {

promise.fulfill(value);

}

};

// Add a bonus method, Promise.all(), which isn't part of the Promises/A+ spec, but is part

// of the spec for ECMAScript 6 Promises, which bring the benefits of promises straight into

// the JavaScript language itself.

//

// The method accepts an array of promises, each representing an asynchronous function,

// which are executed simultaneously, and returns a single promise, allowing a single

// then() method to be executed at such point all the supplied promsies are fulfilled. The

// value passed on fulfillment contains an array of all the returned values of the

// individual promises, in the same order as the promises in the original array passed to

// this method

Promise.all = function(promises) {

var index = 0,

promiseCount = promises.length;

// Return a single promise representing all the promises supplied to this method. It

// will be fulfilled as soon as every one of the supplied promises have been fulfilled.

return new Promise(function(fulfill, reject) {

var promise,

results = [],

resultsCount = 0;

// Execute an onSuccess() function each time one of the supplied promises is

// fulfilled, adding its resulting value to an array in the same index position as

// the promise was in the original array

function onSuccess(result, index) {

results[index] = result;

resultsCount++;

// If we have collected the results for all of the promises, then fulfill the

// current single promise, passing across the array of fulfilled values from

// the individual promises

if (resultsCount === promiseCount) {

fulfill(results);

}

}

// If any of the supplied promises are rejected, then reject the current promise

function onError(error) {

reject(error);

}

// Resolve a given promise, executing onSuccess() if fulfilled, or onError() if not

function resolvePromise(index, promise) {

promise.then(function(value) {

onSuccess(value, index);

}, onError);

}

// Loop through all the promises supplied to this method, resolving each in turn

for (; index < promiseCount; index++) {

promise = promises[index];

resolvePromise(index, promise);

}

});

};

return Promise;

}());

看一下清单 7-13,它展示了如何利用清单 7-12 中的Promise“class ”,在你的代码中创建和使用承诺的例子。

清单 7-13。正在使用的承诺模式

// Define a variable to use as a counter further down in this code

var millisecondCount = 0;

// Define a method to get the data returned by a GET request to a given URL. Returns a promise

// to which callback functions can be hooked into using its then() method.

function ajaxGet(url) {

// Return a new promise, initializing it with the asynchronous function to perform the Ajax

// request. When the promise executes the function, it will pass in two function parameters,

// the first should be called by our code if and when the asynchronous request succeeds, and

// the second should be called if and when an error occurs in the execution of the

// asynchronous request.

return new Promise(function(fulfill, reject) {

var xhr = new XMLHttpRequest(),

STATE_LOADED = 4,

STATUS_OK = 200;

xhr.onreadystatechange = function() {

if (xhr.readyState !== STATE_LOADED) {

return;

}

// If the Ajax GET request returns data successfully, execute the fulfill method

if (xhr.status === STATUS_OK) {

fulfill(xhr.responseText);

// If the Ajax request does not return data successfully, execute the reject method

} else {

reject("For the URL '" + url + "', the server responded with: " + xhr.status);

}

};

// Perform the Ajax GET request

xhr.open("GET", url);

xhr.send();

});

}

// Define a method which waits a given number of milliseconds before continuing. Returns

// a promise.

function wait(milliseconds) {

return new Promise(function(fulfill, reject) {

// If the value provided for milliseconds is a number greater than 0, call the

// setTimeout method to wait that number of milliseconds before executing the fulfill

// method

if (milliseconds && typeof milliseconds === "number" && milliseconds > 0) {

setTimeout(function() {

fulfill(milliseconds);

}, milliseconds);

// If the value provided for milliseconds is not a number or is less than or equal to

// 0, then reject the promise immediately

} else {

reject("Not an acceptable value provided for milliseconds: " + milliseconds);

}

});

}

// Define two functions for use if a particular promise is fulfilled or rejected, respectively

function onSuccess(milliseconds) {

alert(milliseconds + "ms passed");

}

function onError(error) {

alert(error);

}

// EXAMPLE 1: Success

// Execute the wait() function with a value we know will cause it to succeed, and show that

// the first of the two supplied functions to the then() method is executed

wait(500).then(onSuccess, onError); // After 0.5 seconds, outputs: "500ms passed"

// EXAMPLE 2: Error

// Execute the wait() function with a value we know will cause it to error. Because this

// rejects immediately, this will alert the user before the result of example 1 is known

wait(0).then(onSuccess, onError); // "Not an acceptable value provided for milliseconds: 0"

// EXAMPLE 3: Chaining

// Multiple promises can be chained together using the then() method which allows operations to

// be executed in order once the result of the execution of the previous asynchronous function

// is known. This considerably simplifies the nesting of callbacks which would be necessary

// without the use of promises.

wait(1000)

.then(function(milliseconds) {

// After a delay of 1 second, increment the counter by the number of milliseconds

// passed into the function parameter (in this case, 1000)

millisecondCount += milliseconds;

// Returning a promise in this function means that the operation indicated by that

// promise will be executed once the previous operation is complete

return wait(1600);

})

.then(function(milliseconds) {

// By this point, 2600 milliseconds have passed, and this is stored in our counter

// variable

millisecondCount += milliseconds;

// Return another promise, indicating that a delay of 400 milliseconds should now

// take place before the function specified in the following then() statement is

// executed

return wait(400);

})

.then(function(milliseconds) {

// Increment the counter by the 400 milliseconds just passed, making its total 3000

millisecondCount += milliseconds;

// Finally, output the combined value of the counter, which indicates the number of

// milliseconds passed since the first operation in this chain began

alert(millisecondCount + "ms passed"); // After 3 seconds, outputs: "3000ms passed"

});

// EXAMPLE 4: Multiple Promises

// Different promises can be chained together, since as in this example, which gets a page by

// the URL /page1.html (assuming it exists on the server), then waits 3 seconds before getting

// another page by the URL /page2.html (again, assuming it exists).

ajaxGet("/page1.html")

.then(function() {

return wait(3000);

})

.then(function() {

return ajaxGet("/page2.html");

})

.then(function() {

// This alert will fire only if both /page1.html and /page2.html exist and can

// be accessed

alert("/page1.html and /page2.html received, with a 3s gap between requests");

});

// EXAMPLE 5: Simultaneous Promises

// The Promise.all() method accepts an array of promises which will be resolved simultaneously,

// passing the results as an array to the success function passed to its then() method. Get

// both /page1.html and /page2.html simultaneously, and when they are both complete, execute

// the success callback function with the contents of both files in the array parameter passed

// into this function, in the same order as in the array of promises. If any of the supplied

// promises fails, the error callback function will be executed, with the detail of the first

// error that occurred passed into this function parameter.

Promise.all([ajaxGet("/page1.html"), ajaxGet("/page2.html")])

.then(function(files) {

alert("/page1.html = " + files[0].length + " bytes. /page2.html = " + files[1].length + " bytes.");

}, function(error) {

alert(error);

});

当代码中出现许多异步操作,导致嵌套回调函数混乱时,最好使用 promises 模式。它允许将回调函数链接到异步调用,使代码更容易理解,因此更容易开发和维护。要在线阅读有关承诺模式的更多信息,请查看以下资源:

战略模式

策略模式适用于这样的情况:您有一个包含大型条件语句(ifelseswitch)的“类”,其中每个选项都会导致该“类”的特定行为以不同的方式改变。与其管理一个大的条件语句,不如将每个行为拆分成单独的对象,每个对象称为一个策略。在任何时候,只有其中一个应用于原始对象,称为客户端。拥有多个策略对象也有助于提高代码的质量,因为策略对象可以彼此独立地进行单元测试。

清单 7-14 显示了一个应用策略模式的“类”的例子——它包含了许多条件语句,这些语句改变了从它创建的对象的一个非常特殊的行为。

清单 7-14。将策略模式应用于的代码已经成熟

// Define a "class" representing a form field in an HTML page

function FormField(type, displayText){

this.type = type || "text";

this.displayText = displayText || "";

// Create a new <input> tag, setting its field type to the value supplied upon instantiation

this.element = document.createElement("input");

this.element.setAttribute("type", this.type);

// Create a new <label> tag, setting its text to the value supplied upon instantiation

this.label = document.createElement("label");

this.label.innerHTML = this.displayText;

// Add the <label> and <input> tags to the current page

document.body.appendChild(this.label);

document.body.appendChild(this.element);

}

// Give each form field object instance three methods

FormField.prototype = {

// Return the current value stored in the form field

getValue: function() {

return this.element.value;

},

// Set a new value for the form field

setValue: function(value) {

this.element.value = value;

},

// Return a true / false value depending on whether the value in the form field is valid

isValid: function() {

var isValid = false,

value;

// If this is a <input type="text"> field, it is considered valid if its value is not

// an empty string

if (this.type === "text") {

isValid = this.getValue() !== "";

// If this is a <input type="email"> field, it is considered valid if its value is not

// an empty string, contains the "@" character and contains the "." character after "@"

} else if (this.type === "email") {

value = this.getValue();

isValid = value !== "" && value.indexOf("@") > 0 && value.indexOf(".",             value.indexOf("@")) > 0;

// If this is a <input type="number"> field, it is considered valid if its value is

// a number

} else if (this.type === "number") {

value = this.getValue();

isValid = !isNaN(parseInt(value, 10));

// This could go on a while as there are 24 possible <input> types in HTML5\. We need a

// way to simplify this to make it easier to understand and extend in future - this is

// where the strategy pattern comes into play, as shown in Listing 7-14

} else {

// etc.

}

return isValid;

}

};

清单 7-15 中的代码展示了我们如何通过应用策略模式将清单 7-14 中的代码重构为一个更有效、更易于管理的结构。

清单 7-15。战略模式

// Define a "class" representing a form field in an HTML page. Note a new object is passed into

// the third parameter at instantiation, containing a strategy object. This object contains a

// specific implementation of the isValid() method pertaining to the specific type of form field

// we are creating - for example, a "text" field would require an isValid() method that checks

// to see if the stored value is not an empty string, so we create an object containing this

// method and pass it in through the strategy object at instantiation time

function FormField(type, displayText, strategy){

this.type = type || "text";

this.displayText = displayText || "";

this.element = document.createElement("input");

this.element.setAttribute("type", this.type);

this.label = document.createElement("label");

this.label.innerHTML = this.displayText;

// Check to see if the strategy object passed in contains the isValid() method to use and,

// if so, store the stragety object for use when the isValid() method of this object is

// executed. If no strategy object is supplied, use a default

if (strategy && typeof strategy.isValid === "function") {

this.strategy = strategy;

} else {

this.strategy = {

isValid: function() {

return false;

}

};

}

document.body.appendChild(this.label);

document.body.appendChild(this.element);

}

FormField.prototype = {

getValue: function() {

return this.element.value;

},

setValue: function(value) {

this.element.value = value;

},

// Replace the previous isValid() method with one that simply calls the isValid() method

// provided by the stored strategy object - no more extensive if..else statements, making

// the code for this "class" much smaller and easier to manage

isValid: function() {

return this.strategy.isValid.call(this);

}

};

// Define three strategy objects for three different types of form field to be used with the

// FormField "class" when it is instantiated. Here we provide specific implementations for the

// isValid() method, but we could have extended these to include more methods and/or properties

// to meet our needs. In cases like this, we would have created a strategy "class" and created

// these objects as instances of that "class". Here we have simple objects so it is smarter to

// keep the code short and to the point

var textFieldStrategy = {

// Specific functionality for validation of a <input type="text"> field

isValid: function() {

return this.getValue() !== "";

}

},

emailFieldStrategy = {

// Specific functionality for validation of a <input type="email"> field

isValid: function() {

var value = this.getValue();

return value !== "" && value.indexOf("@") > 0 && value.indexOf(".",             value.indexOf("@")) > 0;

}

},

numberFieldStrategy = {

// Specific functionality for validation of a <input type="number"> field

isValid: function() {

var value = this.getValue();

return !isNaN(parseInt(value, 10));

}

};

清单 7-15 中的代码可以如清单 7-16 所示使用。

清单 7-16。正在使用的策略模式

// Create three form fields for our HTML page, each with different types. We pass in the type,

// the text for the associated <label> tag, and the strategy object associated with this field

// type to provide the required behavior for field value validation

var textField = new FormField("text", "First Name", textFieldStrategy),

emailField = new FormField("email", "Email", emailFieldStrategy),

numberField = new FormField("number", "Age", numberFieldStrategy);

// Set values for each form field we know will validate

textField.setValue("Den Odell");

emailField.setValue("denodell@me.com");

numberField.setValue(35);

// Check to see if the values in the fields validate correctly

alert(textField.isValid());   // true

alert(emailField.isValid());  // true

alert(numberField.isValid()); // true

// Change the values in the fields to ones we know will fail validation

textField.setValue("");

emailField.setValue("denodell");

numberField.setValue("Den Odell");

// Check to ensure the isValid() method is working correctly, reflecting the new field values

alert(textField.isValid());   // false

alert(emailField.isValid());  // false

alert(numberField.isValid()); // false

当您需要管理大量条件逻辑来实现“类”中方法的行为时,最好使用策略模式要了解有关策略模式的更多信息,请查看以下在线资源:

摘要

在这一章中,我们已经看到了行为设计模式,你可以在你自己的 JavaScript 应用中使用这些模式来简化不同对象之间的通信。这些是 JavaScript 开发的瑞士军刀中的工具,但是像所有工具一样,您需要知道何时何地最好地使用它们。熟悉本章中的模式及其用例,并确保在代码中认识到需要使用设计模式之前,不要使用它。

在下一章中,我们将着眼于架构设计模式,它实际上是我们已经讨论过的现有设计模式的组合,用来解决大型 JavaScript 代码库中的特定问题。

八、设计模式:构建型

我们在过去三章中看到的许多创造、结构和行为设计模式可以结合在一起,形成架构模式,帮助解决更大代码库中的特定问题。在这一章中,我们将看看三种最常见的适用于 JavaScript 应用的架构模式,以及每种模式的例子。

模型-视图-控制器(MVC)模式

模型-视图-控制器(MVC)模式允许将 JavaScript 应用中的代码分成三个不同的部分:模型,它将与代码中底层数据结构相关的代码组合在一起,包括数据的存储和检索;视图,它将与存储在模型中的数据在屏幕上的显示相关的代码组合在一起——本质上是处理 DOM 元素;以及控制器,它处理系统中的任何业务逻辑,并在必要时更新模型和/或视图——它确保模型和视图不需要直接相互对话,使它们彼此松散耦合。这种关注点的分离使代码更容易理解和使用,更容易测试,并允许从事同一项目的多个开发人员能够在应用的模型、视图和控制器层之间划分任务。

MVC 架构模式实际上是我们之前在第六章和第七章中看到的三种特定设计模式的组合:观察者、复合和策略。当模型中的数据发生变化时,观察者模式被用来触发一个事件,该事件传递更新后的数据以供系统的其他部分使用。类似地,视图使用相同的模式来监听模型数据的变化,并用这些新数据更新用户界面。视图只能直接从模型中读取数据,而不能设置它;那是管制员的角色。视图还可以包含子视图,以处理更大 UI 的可重用部分,复合模式用于确保控制器不需要知道其逻辑需要影响的视图数量。最后,控制器利用策略模式将一个特定的视图应用于自身,允许一个更大的系统中的多个视图共享相同的控制器逻辑,前提是它们都公开一个类似的方法,我选择将其命名为render(),该方法从模型传递数据,并将视图放在当前页面上,将其连接到系统其余部分广播的事件,以备使用。值得注意的是,在 JavaScript 应用中,该模型通常通过 Ajax 连接到一个后端服务,作为存储数据的数据库。

清单 8-1 展示了我们如何创建一个“类”来处理一个简单系统中模型数据的表示,这个系统采用 MVC 模式,允许管理屏幕上的电子邮件地址列表。

清单 8-1。模型

// The Model represents the data in the system. In this system, we wish to manage a list of

// email addresses on screen, allowing them to be added and removed from the displayed list. The

// Model here, therefore, represents the stored email addresses themselves. When addresses are

// added or removed, the Model broadcasts this fact using the observer pattern methods from

// Listing 7-6

//

// Define the Model as a "class" such that multiple object instances can be created if desired

function EmailModel(data) {

// Create a storage array for email addresses, defaulting to an empty array if no addresses

// are provided at instantiation

this.emailAddresses = data || [];

}

EmailModel.prototype = {

// Define a method which will add a new email address to the list of stored addresses

add: function(email) {

// Add the new email to the start of the array

this.emailAddresses.unshift(email);

// Broadcast an event to the system, indicating that a new email address has been

// added, and passing across that new email address to any code module listening for

// this event

observer.publish("model.email-address.added", email);

},

// Define a method to remove an email address from the list of stored addresses

remove: function(email) {

var index = 0,

length = this.emailAddresses.length;

// Loop through the list of stored addresses, locating the provided email address

for (; index < length; index++) {

if (this.emailAddresses[index] === email) {

// Once the email address is located, remove it from the list of stored email

// addresses

this.emailAddresses.splice(index, 1);

// Broadcast an event to the system, indicating that an email address has been

// removed from the list of stored addresses, passing across the email address

// that was removed

observer.publish("model.email-address.removed", email);

// Break out of the for loop so as not to waste processor cycles now we've

// found what we were looking for

break;

}

}

},

// Define a method to return the entire list of stored email addresses

getAll: function() {

return this.emailAddresses;

}

};

清单 8-2 中的代码显示了我们如何定义用户界面的视图代码。它将由一个包含两个子视图的面板组成,一个包含一个用于添加新电子邮件地址的简单输入表单,另一个显示存储的电子邮件地址的列表,每个列表旁边有一个“删除”按钮,允许用户从列表中删除单个电子邮件地址。图 8-1 中的截图显示了我们正在构建的视图的一个例子。

A978-1-4302-6269-5_8_Fig1_HTML.jpg

图 8-1。

A system for managing email addresses, built using the MVC architectural pattern

清单 8-2。查看

// We will be building a page consisting of two parts: a text input field and associated

// button, for adding new email addresses to our list of stored addresses, and a list displaying

// the stored email addresses with a "Remove" button beside each to allow us to remove email

// addresses from the list of stored addresses. We will also define a generic View which acts

// as a holder for multiple child views, and we'll use this as a way of linking the two views

// together in Listing 8-3\. As with the Model in Listing 8-1, we will be taking advantage of

// the observer pattern methods from Listing 7-6

//

// Define a View representing a simple form for adding new email addresses to the displayed

// list. We define this as a "class" so that we can create and display as many instances of

// this form as we wish within our user interface

function EmailFormView() {

// Create new DOM elements to represent the form we are creating (you may wish to store

// the HTML tags you need directly within your page rather than create them here)

this.form = document.createElement("form");

this.input = document.createElement("input");

this.button = document.createElement("button");

// Ensure we are creating a <input type="text"> field with appropriate placeholder text

this.input.setAttribute("type", "text");

this.input.setAttribute("placeholder", "New email address");

// Ensure we are creating a <button type="submit">Add</button> tag

this.button.setAttribute("type", "submit");

this.button.innerHTML = "Add";

}

EmailFormView.prototype = {

// All Views should have a render() method, which is called by the Controller at some point

// after its instantiation by the Controller. It would typically be passed the data from

// the Model also, though in this particular case, we do not need that data

render: function() {

// Nest the <input> field and <button> tag within the <form> tag

this.form.appendChild(this.input);

this.form.appendChild(this.button);

// Add the <form> to the end of the current HTML page

document.body.appendChild(this.form);

// Connect up any events to the DOM elements represented in this View

this.bindEvents();

},

// Define a method for connecting this View to system-wide events

bindEvents: function() {

var that = this;

// When the form represented by this View is submitted, publish a system-wide event

// indicating that a new email address has been added via the UI, passing across this

// new email address value

this.form.addEventListener("submit", function(evt) {

// Prevent the default behavior of the submit action on the form (to prevent the

// page refreshing)

evt.preventDefault();

// Broadcast a system-wide event indicating that a new email address has been

// added via the form represented by this View. The Controller will be listening

// for this event and will interact with the Model on behalf of the View to

// add the data to the list of stored addresses

observer.publish("view.email-view.add", that.input.value);

}, false);

// Hook into the event triggered by the Model that tells us that a new email address

// has been added in the system, clearing the text in the <input> field when this

// occurs

observer.subscribe("model.email-address.added", function() {

that.clearInputField();

});

},

// Define a method for emptying the text value in the <input> field, called whenever an

// email address is added to the Model

clearInputField: function() {

this.input.value = "";

}

};

// Define a second View, representing a list of email addresses in the system. Each item in

// the list is displayed with a "Remove" button beside it to allow its associated address to

// be removed from the list of stored addresses

function EmailListView() {

// Create DOM elements for <ul>, <li>, <span> and <button> tags

this.list = document.createElement("ul");

this.listItem = document.createElement("li");

this.listItemText = document.createElement("span");

this.listItemRemoveButton = document.createElement("button");

// Give the <button> tag the display text "Remove"

this.listItemRemoveButton.innerHTML = "Remove";

}

EmailListView .prototype = {

// Define the render() method for this View, which takes the provided Model data and

// renders a list, with a list item for each email address stored in the Model

render: function(modelData) {

var index = 0,

length = modelData.length,

email;

// Loop through the array of Model data containing the list of stored email addresses

// and create a list item for each, appending it to the list

for (; index < length; index++) {

email = modelData[index];

this.list.appendChild(this.createListItem(email));

}

// Append the list to the end of the current HTML page

document.body.appendChild(this.list);

// Connect this View up to the system-wide events

this.bindEvents();

},

// Define a method which, given an email address, creates and returns a populated list

// item <li> tag representing that email

createListItem: function(email) {

// Cloning the existing, configured DOM elements is more efficient than creating new

// ones from scratch each time

var listItem = this.listItem.cloneNode(false),

listItemText = this.listItemText.cloneNode(false),

listItemRemoveButton = this.listItemRemoveButton.cloneNode(true);

// Assign a "data-email" attribute to the <li> element, populated with the email

// address it represents - this simplifies the attempt to locate the list item

// associated with a particular email address in the removeEmail() method later

listItem.setAttribute("data-email", email);

listItemRemoveButton.setAttribute("data-email", email);

// Display the email address within the <span> element, and append this, together with

// the "Remove" button, to the list item element

listItemText.innerHTML = email;

listItem.appendChild(listItemText).appendChild(listItemRemoveButton);

// Return the new list item to the calling function

return listItem;

},

// Define a method for connecting this View to system-wide events

bindEvents: function() {

var that = this;

// Create an event delegate on the list itself to handle clicks of the <button> within

this.list.addEventListener("click", function(evt) {

if (evt.target && evt.target.tagName === "BUTTON") {

// When the <button> is clicked, broadcast a system-wide event which will be

// picked up by the Controller. Pass the email address associated with the

// <button> to the event

observer.publish("view.email-view.remove", evt.target.getAttribute("data-email"));

}

}, false);

// Listen for the event fired by the Model indicating that a new email address has

// been added, and execute the addEmail() method

observer.subscribe("model.email-address.added", function(email) {

that.addEmail(email);

});

// Listen for the event fired by the Model indicating that an email address has been

// removed, and execute the removeEmail() method

observer.subscribe("model.email-address.removed", function(email) {

that.removeEmail(email);

});

},

// Define a method , called when an email address is added to the Model, which inserts a

// new list item to the top of the list represented by this View

addEmail: function(email) {

this.list.insertBefore(this.createListItem(email), this.list.firstChild);

},

// Define a method, called when an email address is removed from the Model, which removes

// the associated list item from the list represented by this View

removeEmail: function(email) {

var listItems = this.list.getElementsByTagName("li"),

index = 0,

length = listItems.length;

// Loop through all the list items, locating the one representing the provided email

// address, and removing it once found

for (; index < length; index++) {

if (listItems[index].getAttribute("data-email") === email) {

this.list.removeChild(listItems[index]);

// Once we've removed the email address, stop the for loop from executing

break;

}

}

}

};

// Define a generic View which can contain child Views. When its render() method is called, it

// calls the render() methods of its child Views in turn, passing along any Model data

// provided upon instantiation

function EmailView(views) {

this.views = views || [];

}

EmailView.prototype = {

// All Views need to have a render() method - in the case of this generic View, it simply

// executes the render() method of each of its child Views

render: function(modelData) {

var index = 0,

length = this.views.length;

// Loop through the child views, executing their render() methods, passing along any

// Model data provided upon instantiation

for (; index < length; index++) {

this.views[index].render(modelData);

}

}

};

通过使用观察者模式,模型中所做的更改可以立即反映在视图中。但是,在视图中所做的更改不会立即传递到模型中;它们将由控制器处理,如清单 8-3 所示。

清单 8-3。控制器

// The Controller connects a Model to a View, defining the logic of the system. This allows

// alternative Models and Views to be provided whilst still enabling a similar system behavior,

// provided the Model provides add(), remove() and getAll() methods for accessing its data, and

// the View provides a render() method - this is the strategy pattern in action. We will also

// use the observer pattern methods from Listing 7-6.

//

// Define a "class" to represent the Controller for connecting the Model and Views in our email

// address system. The Controller is instantiated after the Model and View, and their object

// instances provided as inputs

function EmailController(model, view) {

// Store the provided Model and View objects

this.model = model;

this.view = view;

}

EmailController.prototype = {

// Define a method to use to initialize the system, which gets the data from the Model using

// its getAll() method and passes it to the associated View by executing that View's

// render() method

initialize: function() {

// Get the list of email addresses from the associated Model

var modelData = this.model.getAll();

// Pass that data to the render() method of the associated View

this.view.render(modelData);

// Connect Controller logic to system-wide events

this.bindEvents();

},

// Define a method for connecting Controller logic to system-wide events

bindEvents: function() {

var that = this;

// When the View indicates that a new email address has been added via the user

// interface, call the addEmail() method

observer.subscribe("view.email-view.add", function(email) {

that.addEmail(email);

});

// When the View indicates that an email address has been remove via the user

// interface, call the removeEmail() method

observer.subscribe("view.email-view.remove", function(email) {

that.removeEmail(email);

});

},

// Define a method for adding an email address to the Model, called when an email address

// has been added via the View's user interface

addEmail: function(email) {

// Call the add() method on the Model directly, passing the email address added via

// the View. The Model will then broadcast an event indicating a new email address has

// been added, and the View will respond to this event directly, updating the UI

this.model.add(email);

},

// Define a method for removing an email address from the Model, called when an email

// address has been removed via the View's user interface

removeEmail: function(email) {

// Call the remove() method on the Model directly, passing the email address added via

// the View. The Model will then broadcast an event indicating an email address has

// been removed, and the View will respond to this event directly, updating the UI

this.model.remove(email);

}

};

清单 8-4 展示了我们如何根据 MVC 架构模式,使用清单 8-1 到 8-3 中创建的“类”来构建如图 8-1 所示的简单页面。

清单 8-4。正在使用的模型-视图-控制器模式

// Create an instance of our email Model "class", populating it with a few email addresses to

// get started with

var emailModel = new EmailModel([

"``denodell@me.com

"``denodell@gmail.com

"``den.odell@akqa.com

]),

// Create instances of our form View and list View "classes"

emailFormView = new EmailFormView(),

emailListView = new EmailListView(),

// Combine together the form and list Views as children of a single View object

emailView = new EmailView([emailFormView, emailListView]),

// Create an instance of our email system Controller, passing it the Model instance and

// the View to use. Note that the Controller does not need to be aware whether the View

// contains a single View or multiple, combined Views, as it does here - this is an example

// of the composite pattern in action

emailController = new EmailController(emailModel, emailView);

// Finally, initialize the Controller which gets the data from the Model and passes it to the

// render() method of the View, which, in turn, connects up the user interface to the

// system-wide events, bringing the whole application together

emailController.initialize();

通过将清单 7-6(观察者模式)中的代码与清单 8-1 到 8-4 中的代码按顺序组合,这个 MVC 应用示例可以在任何简单的 HTML 页面的上下文中运行。例如:

<!DOCTYPE html>

<html>

<head>

<title>MVC Example</title>

</head>

<body>

<script src="Listing7-6.js"></script>

<script src="Listing8-1.js"></script>

<script src="Listing8-2.js"></script>

<script src="Listing8-3.js"></script>

<script src="Listing8-4.js"></script>

</body>

</html>

当用户使用<input>字段输入新的电子邮件地址并提交表单时,新的电子邮件将出现在下方列表的顶部(消息在系统中从视图传递到控制器再到模型,然后模型广播一个事件,更新视图)。当用户单击任何电子邮件地址旁边的Remove按钮时,该电子邮件将从显示中删除,也将从模型中的底层数据存储中删除。

MVC 模式在包含一组数据的大型应用中非常有用,这些数据需要在用户界面中显示、交互和更新,而不会使代码库过于复杂。代码分为负责存储和操作数据的代码、负责数据显示的代码以及负责数据和显示之间的业务逻辑和连接的代码。要了解有关模型-视图-控制器模式的更多信息,请查看以下在线资源:

模型-视图-演示者(MVP)模式

模型-视图-展示者(MVP)架构模式是 MVC 模式的衍生物,它试图阐明模型、视图和连接它们的代码之间的界限(在 MVC 中,这是控制器,在 MVP 中,这被称为展示者)。它基于相同的底层设计模式,但是在 MVC 模式中,视图可以直接基于模型中的更改进行更新,而在 MVP 中,模型和视图之间的所有通信都必须通过表示层——这是一个微妙但重要的区别。此外,MVP 模式中的视图不应该直接包含事件处理程序代码——这应该从 Presenter 传递到视图中,这意味着视图代码只呈现用户界面,而 Presenter 执行事件处理。

让我们以图 8-1 所示的电子邮件地址列表为例,它是我们之前使用 MVC 模式构建的,现在使用 MVP 模式构建它。我们将保持与清单 8-1 中相同的模型,但是我们需要构建一个新的视图,并用一个演示者替换之前的控制器。清单 8-5 中的代码显示了如何编写演示者;与清单 8-3 中的控制器有相似之处,但是请注意模型和视图之间的所有通信在这里是如何处理的,而不是在演示者和视图之间分开。

清单 8-5。电子邮件地址列表应用的演示者

// The Presenter "class" is created in much the same was as the Controller in the MVC pattern.

// Uses the observer pattern methods from Listing 7-6.

function EmailPresenter(model, view) {

this.model = model;

this.view = view;

}

EmailPresenter.prototype = {

// The initialize() method is the same as it was for the Controller in the MVC pattern

initialize: function() {

var modelData = this.model.getAll();

this.view.render(modelData);

this.bindEvents();

},

// The difference is in the bindEvents() method, where we connect the events triggered from

// the Model through to the View, and vice versa - no longer can the Model directly update

// the View without intervention. This clarifies the distinction between the Model and View,

// making the separation clearer, and giving developers a better idea where to look should

// problems occur connecting the data to the user interface

bindEvents: function() {

var that = this;

// When the View triggers the "add" event, execute the add() method of the Model

observer.subscribe("view.email-view.add", function(email) {

that.model.add(email);

});

// When the View triggers the "remove" event, execute the remove() method of the Model

observer.subscribe("view.email-view.remove", function(email) {

that.model.remove(email);

});

// When the Model triggers the "added" event, execute the addEmail() method of the View

observer.subscribe("model.email-address.added", function(email) {

// Tell the View that the email address has changed. We will need to ensure this

// method is available on any View passed to the Presenter on instantiation, which

// includes generic Views that contain child Views

that.view.addEmail(email);

});

// When the Model triggers the "removed" event, execute the removeEmail() method of

// the View

observer.subscribe("model.email-address.removed", function(email) {

that.view.removeEmail(email);

});

}

};

视图现在会比以前更短,因为我们已经将它的一些事件处理代码提取到了 Presenter 中,如清单 8-6 所示。注意每个视图上添加的addEmail()removeEmail()方法,包括通用视图,它将包含子视图。

清单 8-6。MVP 模式的视图

// Define the EmailFormView "class" constructor as before to initialize the View's DOM elements.

// Uses the observer pattern methods from Listing 7-6.

function EmailFormView() {

this.form = document.createElement("form");

this.input = document.createElement("input");

this.button = document.createElement("button");

this.input.setAttribute("type", "text");

this.input.setAttribute("placeholder", "New email address");

this.button.setAttribute("type", "submit");

this.button.innerHTML = "Add";

}

EmailFormView.prototype = {

// The render() method is the same as it was in the MVC pattern

render: function() {

this.form.appendChild(this.input);

this.form.appendChild(this.button);

document.body.appendChild(this.form);

this.bindEvents();

},

// Note how the bindEvents() method differs from that in the MVC pattern - we no longer

// subscribe to events broadcast from the Model, we only trigger View-based events and the

// Presenter handles the communication between Model and View

bindEvents: function() {

var that = this;

this.form.addEventListener("submit", function(evt) {

evt.preventDefault();

observer.publish("view.email-view.add", that.input.value);

}, false);

},

// We make an addEmail() method available to each View, which the Presenter calls when

// the Model indicates that a new email address has been added

addEmail: function() {

this.input.value = "";

},

// We make an removeEmail() method available to each View, which the Presenter calls when

// the Model indicates that an email address has been removed. Here we do not need to do

// anything with that information so we leave the method empty

removeEmail: function() {

}

};

// Define the EmailListView "class" constructor as before to initialize the View's DOM elements.

function EmailListView() {

this.list = document.createElement("ul");

this.listItem = document.createElement("li");

this.listItemText = document.createElement("span");

this.listItemRemoveButton = document.createElement("button");

this.listItemRemoveButton.innerHTML = "Remove";

}

EmailListView.prototype = {

render: function(modelData) {

var index = 0,

length = modelData.length,

email;

for (; index < length; index++) {

email = modelData[index];

this.list.appendChild(this.createListItem(email));

}

document.body.appendChild(this.list);

this.bindEvents();

},

createListItem: function(email) {

var listItem = this.listItem.cloneNode(false),

listItemText = this.listItemText.cloneNode(false),

listItemRemoveButton = this.listItemRemoveButton.cloneNode(true);

listItem.setAttribute("data-email", email);

listItemRemoveButton.setAttribute("data-email", email);

listItemText.innerHTML = email;

listItem.appendChild(listItemText).appendChild(listItemRemoveButton);

return listItem;

},

// The bindEvents() method only publishes View events, it no longer subscribes to Model

// events - these are handled in the Presenter

bindEvents: function() {

this.list.addEventListener("click", function(evt) {

if (evt.target && evt.target.tagName === "BUTTON") {

observer.publish("view.email-view.remove", evt.target.getAttribute("data-email"));

}

}, false );

},

// Create this View's addEmail() method, called by the Presenter when the Model indicates

// that an email address has been added

addEmail: function(email) {

this.list.insertBefore(this.createListItem(email), this.list.firstChild);

},

// Create this View's removeEmail() method, called by the Presenter when the Model indicates

// that an email address has been removed

removeEmail: function(email) {

var listItems = this.list.getElementsByTagName("li"),

index = 0,

length = listItems.length;

for (; index < length; index++) {

if (listItems[index].getAttribute("data-email") === email) {

this.list.removeChild(listItems[index]);

break;

}

}

}

};

// Create the generic View which can contain child Views

function EmailView(views) {

this.views = views || [];

}

EmailView.prototype = {

// The render() method is as it was in the MVC pattern

render: function(modelData) {

var index = 0,

length = this.views.length;

for (; index < length; index++) {

this.views[index].render(modelData);

}

},

// Even the generic View needs the addEmail() and removeEmail() methods. When these are

// called, they must execute the methods of the same name on any child Views, passing

// along the email address provided

addEmail: function(email) {

var index = 0,

length = this.views.length;

for (; index < length ; index++) {

this.views[index].addEmail(email);

}

},

removeEmail: function(email) {

var index = 0,

length = this.views.length;

for (; index < length; index++) {

this.views[index].removeEmail(email);

}

}

};

最后,清单 8-7 显示了如何将 MVP 系统和我们在本章前面看到的 MVC 模式结合起来。

清单 8-7。正在使用的模型-视图-演示者模式

// Use EmailModel from Listing 8-1

var emailModel = new EmailModel([

"``denodell@me.com

"``denodell@gmail.com

"``den.odell@akqa.com

]),

emailFormView = new EmailFormView(),

emailListView = new EmailListView(),

emailView = new EmailView([emailFormView, emailListView]),

// Create the Presenter as you would the Controller in the MVC pattern

emailPresenter = new EmailPresenter(emailModel, emailView);

emailPresenter.initialize();

通过将清单 7-6(观察者模式)中的代码与清单 8-1(我们最初的 MVC 应用的共享模型)、8-5、8-6 和 8-7 中的代码按顺序组合,这个 MVP 应用示例可以在任何简单的 HTML 页面的上下文中运行。例如:

<!DOCTYPE html>

<html>

<head>

<title>MVP Example</title>

</head>

<body>

<script src="Listing7-6.js"></script>

<script src="Listing8-1.js"></script>

<script src="Listing8-5.js"></script>

<script src="Listing8-6.js"></script>

<script src="Listing8-7.js"></script>

</body>

</html>

与模型-视图-控制器模式相比,模型-视图-展示者模式在层之间提供了更明确的分隔,这在代码库变得更大时非常有用。它被视为简化 MVC 模式的一种方式,因为在大型应用中跟踪事件变得不那么容易了。要在线阅读有关模型-视图-演示者模式的更多信息,请参考以下资源:

模型-视图-视图模型(MVVM)模式

模型-视图-视图模型(MVVM)模式是 MVC 模式的一个较新的衍生模式,和 MVP 模式一样,它的目标是将模型和视图完全分开,避免它们之间的直接通信。然而,这两者被 ViewModel 分开,而不是 Presenter,ViewModel 实现了类似的角色,但包含了视图中存在的所有代码。因此,视图本身可以用更简单的东西代替,并通过 HTML5 data-属性连接(或绑定)到视图模型。事实上,ViewModel 和 View 之间的分离非常明显,以至于 View 实际上可以作为一个静态 HTML 文件提供,该文件可以用作一个模板,通过绑定到这些data-属性中包含的 ViewModel 来直接构建用户界面。

让我们回到图 8-1 所示的同一个邮件列表应用示例,并对其应用 MVVM 模式。我们可以重用与清单 8-1 中相同的模型代码,但是我们将创建一个新的视图,并用一个视图模型替换之前的控制器或表示器。清单 8-8 中的代码显示了一个 HTML 页面,在相关的标签上有特定的 HTML5 data-属性,向视图模型指示它应该根据其内部业务逻辑对这个视图做什么。

清单 8-8。定义为简单 HTML 页面的视图

<!--

The View is now a simple HTML document - it could be created through DOM elements in JavaScript

but it does not need to be any more. The View is connected to the ViewModel via HTML5 data

attributes on certain HTML tags which are then bound to specific behaviors in the ViewModel as

required

-->

<!DOCTYPE html>

<html>

<head>

<meta charset="utf-8">

<title>Model-View-ViewModel (MVVM) Example</title>

</head>

<body>

<!--

The <form> tag has a specific HTML5 data attribute indicating that when submitted it should

be bound to an addEmail() method in the ViewModel

-->

<form data-submit="addEmail">

<input type="text" placeholder="New email address">

<button type="submit">Add</button>

</form>

<!--

The <ul> list has a specific HTML5 data attribute indicating that the tags within should be

looped through for each item in the stored Model data. So if the Model contained three email

addresses, three <li> tags would be produced and rendered in place of the one templated <li>

tag that currently exists

-->

<ul data-loop>

<li>

<!--

We will use the data-text attribute to indicate that the ViewModel should replace

the tag contents with the individual email address represented as we loop through

the stored Model data

-->

<span data-text></span>

<!--

We use the data-click attribute as we used data-submit previously on the <form>

tag, i.e. to execute a specific method exposed by the ViewModel when the button is

clicked

-->

<button data-click="removeEmail">Remove</button>

</li>

</ul>

<!--

We will add <script> tags to the end of this page in future to load the observer pattern,

the Model, the ViewModel, and the initialization code

-->

</body>

</html>

我在<ul>标签上选择了一个data-loop属性,以指示下面显示的<li>标签应该为存储在模型中的每个电子邮件地址重复出现。这个循环中特定标签上的data-text属性表示它的内容应该被电子邮件地址本身替换。一个data-submit属性,以及作为其值的特定方法名,表明一个submit事件处理程序应该连接到该元素,在事件发生时执行存储在 ViewModel 中的给定方法名。类似地,data-click属性的值表示当用户点击该元素时将被执行的来自 ViewModel 的方法名。这些属性名称是任意选择的;除了我在这里定义的以外,它们在 HTML 或 JavaScript 中没有特定的含义。请注意文件末尾的注释,它表明我们将在代码清单的末尾添加<script>标记来加载和初始化代码,这是我们在查看了视图模型和初始化代码之后添加的。

清单 8-9 中的代码显示了特定的视图模型,用于将相关的数据和行为绑定到清单 8-8 中的视图来表示应用。

清单 8-9。视图模型

// Define a ViewModel for the email system which connects up a static View to the data stored in

// a Model. It parses the View for specific HTML5 data attributes and uses these as instructions

// to affect the behavior of the system. Provided the ViewModel is expecting the specific data

// attributes included in the View, the system will work as expected. The more generic the

// ViewModel, therefore, the more variation is possible in the View without needing to update

// thes code here. Uses the observer pattern methods from Listing 7-6.

function EmailViewModel(model, view) {

var that = this;

this.model = model;

this.view = view;

// Define the methods we wish to make available to the View for selection via HTML5 data

// attributes

this.methods = {

// The addEmail() method will add a supplied email address to the Model, which in turn

// will broadcast an event indicating that the Model has updated

addEmail: function(email) {

that.model.add(email);

},

// The removeEmail() method will remove a supplied email address from the Model, which

// in turn will broadcast an event indicating that the Model has updated

removeEmail: function(email) {

that.model.remove(email);

}

};

}

// Define the method to initialize the connection between the Model and the View

EmailViewModel.prototype.initialize = function() {

// Locate the <ul data-loop> element which will be used as the root element for looping

// through the email addresses stored in the Model and displaying each using a copy of the

// <li> tag located beneath it in the DOM tree

this.listElement = this.view.querySelectorAll("[data-loop]")[0];

// Store the <li> tag beneath the <ul data-loop> element

this.listItemElement = this.listElement.getElementsByTagName("li")[0];

// Connect the <form data-submit> in the View to the Model

this.bindForm();

// Connect the <ul data-loop> in the View to the Model

this.bindList();

// Connect the events broadcast by the Model to the View

this.bindEvents();

};

// Define a method to configure the <form data-submit> in the View

EmailViewModel.prototype.bindForm = function() {

var that = this,

// Locate the <form data-submit> tag

form = this.view.querySelectorAll("[data-submit]")[0],

// Get the method name stored in the "data-submit" HTML5 attribute value

formSubmitMethod = form.getAttribute("data-submit");

// Create an event listener to execute the method by the given name when the <form> is

// submitted

form.addEventListener("submit", function(evt) {

// Ensure the default <form> tag behavior does not run and the page does not refresh

evt.preventDefault();

// Grab the email address entered in the <input> field within the <form>

var email = form.getElementsByTagName("input")[0].value;

// Locate the given method in the ViewModel's "methods" property and execute it,

// passing in the email address entered in the <form>

if (that.methods[formSubmitMethod] && typeof that.methods[formSubmitMethod] === "function") {

that .methodsformSubmitMethod;

}

});

};

// Define a method to construct the list of email addresses from the data stored in the Model.

// This method is later connected to the events triggered by the Model such that the list is

// recreated each time the data in the Model changes

EmailViewModel.prototype.bindList = function() {

// Get the latest data from the Model

var data = this.model.getAll(),

index = 0,

length = data.length,

that = this;

// Define a function to create an event handler function based on a given email address,

// which executes the method name stored in the "data-click" HTML5 data attribute when the

// <button> tag containing that attribute is clicked, passing across the email address

function makeClickFunction(email) {

return function(evt) {

// Locate the method name stored in the HTML5 "data-click" attribute

var methodName = evt.target.getAttribute("data-click");

// Locate the given method in the ViewModel's "methods" property and execute it,

// passing in the email address provided

if (that.methods[methodName] && typeof that.methods[methodName] === "function") {

that.methodsmethodName;

}

};

}

// Empty the contents of the <ul data-loop> element, removing all previously created <li>

// elements within it

this.listElement.innerHTML = "";

// Loop through the email addresses stored in the Model, creating <li> tags for each

// based on the structure from the original state of the View which we stored previously

for (; index < length; index++) {

email = data[index];

// Create a new <li> tag as a clone of the stored tag

newListItem = this.listItemElement.cloneNode(true);

// Locate the <span data-text> element and populate it with the email address

newListItem.querySelectorAll("[data-text]")[0].innerHTML = email;

// Locate the <button data-click> element and execute the makeClickFunction() function

// to create an event handler specific to the email address in this turn of the loop

newListItem.querySelectorAll("[data-click]")[0].addEventListener("click", makeClickFunction(email), false);

// Append the populated <li> tag to the <ul data-loop> element in the View

this.listElement.appendChild(newListItem);

}

};

// Define a method to clear the email address entered in the <input> field

EmailViewModel.prototype.clearInputField = function() {

var textField = this.view.querySelectorAll("input[type=text]")[0];

textField.value = "";

};

// The bindEvents() method connects the events broadcast by the Model to the View

EmailViewModel.prototype.bindEvents = function() {

var that = this;

// Define a function to execute whenever the data in the Model is updated

function updateView() {

// Recreate the list of email addresses from scratch

that.bindList();

// Clear any text entered in the <input> field

that.clearInputField();

}

// Connect the updateView() function to the two events triggered by the Model

observer.subscribe("model.email-address.added", updateView);

observer.subscribe("model.email-address.removed", updateView);

};

最后,我们可以将清单 8-1 中的模型、清单 8-8 中的普通 HTML 视图和清单 8-9 中的视图模型放在一起,使用清单 8-10 中的代码来初始化应用。要运行代码,在文件末尾指定的地方添加对清单 8-8 中视图的<script>标记引用,以加载这些代码清单和清单 7-6 中的 observer 模式。例如:

<script src="Listing7-6.js"></script>

<script src="Listing8-1.js"></script>

<script src="Listing8-9.js"></script>

<script src="Listing8-10.js"></script>

因为我们将这些<script>引用添加到视图 HTML 页面中,所以我们能够简单地使用document.body属性获得对页面的 DOM 表示的引用,如清单 8-10 所示,它初始化了应用。

清单 8-10。正在使用的 MVVM 模式

// Use EmailModel from Listing 8-1

var emailModel = new EmailModel([

"``denodell@me.com

"``denodell@gmail.com

"``den.odell@akqa.com

]),

// Now our View is a HTML document, we can get a reference to the whole page and use that

emailView = document.body,

// Create an instance of our ViewModel as we would do with either Controller or Presenter in

// MVC and MVP patterns, respectively. Pass in the Model data and View (HTML document).

emailViewModel = new EmailViewModel(emailModel, emailView);

emailViewModel.initialize();

应该清楚的是,模型-视图-视图模型模式的好处是,与模型-视图-展示者和模型-视图-控制器模式相比,视图可以采用更简单的形式,这在应用中视图越多就越有用。将视图从连接它和模型的代码中分离出来,也意味着团队中的不同开发人员可以独立地在不同的层上工作,在适当的阶段将他们的工作合并在一起,降低了与彼此代码冲突的风险。在撰写本文时,MVVM 正迅速成为专业 JavaScript 开发人员最常用的架构模式。

要了解有关模型-视图-视图模型模式的更多信息,请查看以下在线资源:

架构模式框架

有许多预先构建的模型-视图-控制器(MVC)、模型-视图-演示者(MVP)和模型-视图-视图模型(MVVM) JavaScript 库可以在你自己的应用中实现我们在本章中讨论的架构模式。这些可以简化大型代码库的开发,因为它们将数据管理代码与呈现用户界面的代码分开。但是要小心,因为这些框架很多都很大,结果可能会降低应用的加载速度。一旦你的代码达到一定的规模,将一个框架应用到你的代码中,你将会意识到使用这些架构模式之一将会解决你正在经历的一个开发问题。请记住,设计模式是开发工具箱中的工具,必须小心使用,以满足代码中的特定需求。

如果您正在寻找一个在代码中采用架构模式的框架,请查看下面的流行备选方案列表:

| 结构 | 模式 | 笔记 | | --- | --- | --- | | 玛丽亚 | 手动音量调节 | 基于 20 世纪 70 年代最初的 MVC 框架,这是 MVC 框架最真实的表现。[`http://bit.ly/maria_mvc`](http://bit.ly/maria_mvc) | | SpineJS | 手动音量调节 | Spine 力争拥有所有 JavaScript MVC 框架中最全面的文档。[`http://bit.ly/spinejs`](http://bit.ly/spinejs) | | EmberJS | 手动音量调节 | Ember 依靠配置来简化开发,让开发人员快速熟悉框架。[`http://bit.ly/ember_js`](http://bit.ly/ember_js) | | 毅力 | 最有价值球员 | Backbone 是一个流行的框架,它有一个大的 API,允许几乎任何类型的应用使用 MVP 模式蓬勃发展。[`http://bit.ly/backbone_mvp`](http://bit.ly/backbone_mvp) | | 敏捷性 JS | 最有价值球员 | Agility 引以为豪的是它是目前最轻的 MVP 框架,压缩了 4KB。[`http://bit.ly/agilityjs`](http://bit.ly/agilityjs) | | KnockoutJS | 视图模型 | Knockout 是为了支持 MVVM 模式及其与 HTML 用户界面的数据绑定而构建的。它有很好的、全面的文档,支持从 Internet Explorer 的旧版本到版本 6,并且有一个庞大的社区支持它。[`http://bit.ly/knockout_mvvm`](http://bit.ly/knockout_mvvm) | | 安古斯 | 视图模型 | 谷歌的 Angular 正迅速成为最流行的架构框架,支持数据绑定的 MVVM 原则,将用户界面连接到底层数据模型。请注意,更高版本仅支持 Internet Explorer 的版本 9 及更高版本。[`http://bit.ly/angular_js`](http://bit.ly/angular_js) |

摘要

在这一章中,我们研究了三种主要的架构模式,可以用来更好地构建和维护您的 JavaScript 应用,从而结束了关于设计模式的部分。这些是 JavaScript 开发的瑞士军刀中的工具,但是像所有工具一样,您需要知道何时何地最好地使用它们。熟悉本章中的模式,并确保在代码中意识到需要某个模式之前不要使用它。这是一个特别重要的建议,因为许多人犯了这样的错误,例如,在一个大的已存在的框架上构建他们的小应用,而没有意识到他们可以通过编写他们自己需要的精确代码来节省大量的开发时间和页面加载时间。不要落入这个陷阱——从您的应用需要的确切代码开始,然后在您意识到需要时应用设计模式,而不是相反。

在下一章中,我们将会看到我们在第六章中提到的对模块设计模式的现代改进,允许模块在大型 JavaScript 应用需要它们的时候异步加载,以及它们所需的任何依赖。

九、管理代码文件依赖项

随着时间一年一年地过去,我们开发人员进一步踏入了一个充满 JavaScript 的网站和应用的勇敢新世界。使用 jQuery 之类的代码库、AngularJS ( http://angularjs.org )、Backbone ( http://backbonejs.org )或 Ember ( http://emberjs.com )之类的框架,以及许多其他高质量、可重用的插件,可以简化 JavaScript 开发的核心方面,使我们能够构建更丰富的用户体验,既实用又有趣。

我们添加到解决方案中的每个额外的 JavaScript 文件都会带来额外的复杂性,特别是我们如何管理该文件与代码库中其余 JavaScript 文件的关系。我们可以在一个文件中编写一些代码,使用一个单独文件中的 jQuery 插件与我们的页面进行交互,这反过来依赖于 jQuery 的存在和从另一个文件中加载。随着解决方案规模的增长,文件之间可能的连接数也在增长。我们说,任何需要另一个文件才能正常运行的 JavaScript 文件都依赖于该文件。

大多数情况下,我们以线性和手动的方式管理我们的依赖关系。在 HTML 文件的末尾,在结束的</body>标记之前,我们通常按顺序列出 JavaScript 文件,从最普通的库和框架文件开始,一直到最特定于应用的文件,确保每个文件都列在它的依赖项之后,这样在试图访问其他尚未加载的脚本文件中定义的变量时就不会出现错误。随着我们的解决方案中文件数量的增长,这种依赖关系管理的方法变得越来越难以维护,特别是如果您希望在不影响依赖它的任何其他代码的情况下删除一个文件。

使用 RequireJS 管理代码文件依赖项

我们显然需要一种比这更健壮的方法来管理大型网站和应用中的依赖关系。在这一章中,我将解释如何使用 RequireJS 更好地管理您的代码文件依赖性,这是一个 JavaScript 模块加载器,旨在解决这个问题,它具有按需异步脚本文件加载的额外优势,这是我们在第四章中提到的一种提高网站性能的方法。

RequireJS 库基于异步模块定义(AMD) API ( http://bit.ly/amd_api ),这是一种定义代码块及其依赖关系的跨语言统一方式,在行业中获得了很大的吸引力,并在 BBC、Hallmark、Etsy 和 Instagram 等网站上实现。

为了演示如何将 RequireJS 合并到一个应用中,让我们从清单 9-1 所示的简单的索引 HTML 页面开始,它包含一个非常基本的表单,当提交时,会将一个电子邮件地址发布到一个单独的感谢页面,如清单 9-2 所示。清单 9-3 中的代码显示了应用于这个演示页面的 CSS 样式,我们将把它存储在一个名为main.css的文件中。我们使用了谷歌字体( http://bit.ly/g_fonts )中的字体龙虾和亚伯。

清单 9-1。主演示页面的 HTML 代码,包含一个向邮件列表添加电子邮件的表单

<!doctype html>

<html>

<head>

<meta charset="utf-8">

<title>Mailing list</title>

<link href="http://fonts.googleapis.com/css?family=Lobster|Abel

<link rel="stylesheet" href="Listing9-3.css">

</head>

<body>

<form action="Listing9-2.html" id="form" method="post">

<h1>Join our mailing list</h1>

<label for="email">Enter your email address</label>

<input type="text" name="email" id="email" placeholder="e.g. me@mysite.com">

<input type="submit" value="Sign up">

</form>

</body>

</html>

清单 9-2。提交电子邮件地址后,HTML 感谢页面将用户导向

<!doctype html>

<html>

<head>

<meta charset="utf-8">

<title>Thank you</title>

<link href="http://fonts.googleapis.com/css?family=Lobster|Abel

<link rel="stylesheet" href="Listing9-3.css">

</head>

<body>

<div class="card">

<h1>Thank you</h1>

<p>Thank you for joining our mailing list.</p>

</div>

</body>

</html>

清单 9-3。应用于清单 9-1 和清单 9-2 中 HTML 页面的 CSS 样式规则

html,

body {

height: 100%;

}

body {

font-size: 62.5%;

margin: 0;

background: #32534D;

background-image: -webkit-gradient(linear, 0% 0%, 0% 100%, from(#1a82f7), to(#2F2727));

background-image: -webkit-linear-gradient(top, #1a82f7, #2F2727);

background-image: -moz-linear-gradient(top, #1a82f7, #2F2727);

background-image: -ms-linear-gradient(top, #1a82f7, #2F2727);

background-image: -o-linear-gradient(top, #1a82f7, #2F2727);

}

body,

input {

font-family: "Lobster", sans-serif;

}

h1 {

font-size: 4.4em;

letter-spacing: -1px;

padding-bottom: 0.25em;

}

form,

.card {

position: absolute;

top: 100px;

bottom: 100px;

min-height: 250px;

left: 50%;

margin-left: -280px;

width: 400px;

padding: 20px 80px 80px;

border: 2px solid #333;

border-radius: 5px;

box-shadow: 5px 5px 15px #000;

background: #fff;

background-image: -webkit-gradient(linear, 0% 0%, 0% 100%, from(#eee), to(#fff));

background-image: -webkit-linear-gradient(top, #eee, #fff);

background-image: -moz-linear-gradient(top, #eee, #fff);

background-image: -ms-linear-gradient(top, #eee, #fff);

background-image: -o-linear-gradient(top, #eee, #fff);

}

label,

input,

p {

display: block;

font-size: 1.8em;

width: 100%;

}

label,

input[type=email],

p {

font-family: "Abel", cursive;

}

input {

margin-bottom: 1em;

border: 1px solid #42261B;

border-radius: 5px;

padding: 0.25em;

}

input[type=submit] {

background: #dda;

color: #000;

font-weight: bold;

width: 103%;

font-size: 3em;

margin: 0;

box-shadow: 1px 1px 2px #000;

}

.error {

border: 1px solid #f99;

background: #fff5f5;

}

运行到目前为止我们已经拥有的代码,产生如图 9-1 所示的页面布局。

A978-1-4302-6269-5_9_Fig1_HTML.jpg

图 9-1。

The final page we’re building represents a newsletter sign-up form, which will only submit if the e-mail address provided is in a valid format

在我们编写任何 JavaScript 代码之前,我们将从项目主页的 http://bit.ly/require_dl 下载一份 RequireJS。在撰写本文时,该库的当前版本是 2.1.9,它在所有主流 web 浏览器中都受到支持,包括 Internet Explorer 6、Firefox 2、Safari 3.2、Chrome 3 和 Opera 10。

现在,在我们将 RequireJS 添加到页面之前,我们需要回顾一下我们的应用需要哪些 JavaScript 文件,并将它们组织到适当的文件夹结构中。我们将从主应用脚本文件开始,该文件使用 jQuery 监听 HTML 表单上的提交事件,并在发生时执行表单验证,只有在没有错误的情况下才允许表单继续提交。因此,除了 RequireJS 库之外,我们还有三个 JavaScript 文件,如表 9-1 所示。

表 9-1。

The three JavaScript files used in our project, in addition to RequireJS

| 文件名 | 描述 | | --- | --- | | `jquery-1.10.2.js` | jQuery 库的最新版本,用于访问和操作页面上的 DOM 元素 | | `validation-plugin.js` | 作为 jQuery 插件的表单验证脚本 | | `main.js` | 主应用脚本文件 |

让我们将这些文件与 RequireJS 和项目的其余文件一起整理到一个合理的文件夹结构中,如图 9-3 所示,第三方脚本和插件一起分组到scripts文件夹中一个名为lib的子文件夹中。

A978-1-4302-6269-5_9_Fig3_HTML.jpg

图 9-3。

Folder structure for our RequireJS-based project

A978-1-4302-6269-5_9_Fig2_HTML.jpg

图 9-2。

The RequireJS homepage at requirejs.org contains the library files plus plenty of documentation

加载和初始化要求

让我们借此机会在我们的 HTML 页面上加载并设置 RequireJS,方法是在清单 9-1 中的 HTML 页面的末尾添加一个<script>标记,就在</body>标记的末尾之前,指向库文件的位置。尽管我们可以在这一点之后添加多个<script>标签来包含我们剩余的代码,但是我们可以依靠 RequireJS 的异步文件加载特性来加载这些标签。通过向我们的<script>标签添加一个data-main属性,我们可以为我们的项目指定主应用脚本文件的位置。当 RequireJS 初始化时,它将自动和异步地加载该属性值中引用的任何文件。我们只需要在页面上有一个<script>标签:

<script src="scripts/require.js" data-main="scripts/main"></script>

注意,在我们的data-main属性中,当引用任何带有 RequireJS 的文件时,可以排除.js文件扩展名,因为默认情况下它采用这个扩展名。

具有特定用途或行为的可重用代码块(称为模块)由 RequireJS 使用其内置的define()函数定义,该函数遵循此处所示的模式,具有三个输入参数,分别命名模块、定义其依赖项和包含模块代码本身:

define(

moduleName,   // optional, defaults to name of file if parameter is not present

dependencies, // optional array listing this file's dependencies

function(parameters) {

// Function to execute once dependencies have been loaded

// parameters contain return values from the dependencies

}

);

A978-1-4302-6269-5_9_Fig4_HTML.jpg

图 9-4。

The BBC is a proponent of RequireJS and has its own documentation site for their developers to refer to when building JavaScript modules

在对define()的调用中所需要的只是一个包含要执行的模块代码的函数。通常,我们会为代码库中的每个模块创建一个单独的文件,默认情况下,模块名称会在 RequireJS 中通过其文件名来标识。如果您的模块依赖于其他代码文件才能正常运行(例如,一个 jQuery 插件需要 jQuery),您应该在传递给define()的数组中列出这些所谓的依赖关系,放在包含模块代码的函数参数之前。在这个数组中使用的名称通常对应于依赖项的文件名,这些依赖项相对于 RequireJS 库文件本身的位置。在我们的项目中,如果我们想将 jQuery 库作为另一个模块的依赖项列出,我们可以将它包含在依赖项数组中,如清单 9-4 所示。

清单 9-4。定义依赖于 jQuery 的模块

define(["lib/jquery-1.10.2"], function($) {

// Module code to execute once jQuery is loaded goes here. The jQuery library

// is manifest through the first parameter to this function, here named $

});

回想一下,我们不需要指定文件扩展名.js,所以在数组参数中列出依赖项时,我们不考虑这个。顺便提一下,jQuery 的最新版本包含使用define()函数将自己注册为模块的代码,如果这个函数出现在页面上,那么我们不需要编写任何特殊的代码来将 jQuery 库转换为我们需要的格式,以便与 RequireJS 一起使用。其他库可能需要一些初始设置才能与 RequireJS 一起使用。通过 http://bit.ly/require_shim 阅读关于创建在这种情况下使用的垫片的文档部分。

依赖代码文件提供的任何返回值都通过输入参数传递给模块的函数,如清单 9-4 所示。只有传递给该函数的这些参数才应该在该模块中使用,以便正确地封装代码及其依赖项。这也有轻微的性能优势,因为 JavaScript 访问函数范围内的局部变量比提升到周围的范围以解析变量名和值要快。我们现在有了一种方法来整理我们的模块代码和它所依赖的代码之间的关系,正是这种关系告诉 RequireJS 在执行模块功能之前加载对我们的模块功能至关重要的所有代码。

对模块名称使用别名

jQuery 开发团队有一个惯例,用它所代表的发布版本号来命名它的库文件;这里我们使用的是版本1.10.2。如果我们在多个文件中大量引用 jQuery 作为依赖项,那么如果我们希望在以后更新我们站点中使用的 jQuery 版本,我们就会给自己制造一个维护问题。我们必须使用 jQuery 作为依赖项对所有这些文件进行修改,以匹配包含更新版本号的新文件名。幸运的是,RequireJS 允许我们通过为某些模块定义替代别名来解决这个问题;我们能够创建一个映射到我们的版本化文件名的单个模块名别名,这样我们就可以在我们的文件中使用该名称来代替直接文件名。这是在 RequireJS 配置对象中设置的。让我们从清单 9-5 所示的代码开始我们的主应用脚本文件(main.js),为 jQuery 创建这个模块别名到文件名的映射。

清单 9-5。通过创建到 jQuery 的别名映射开始主应用脚本文件

requirejs.config({

paths: {

"jquery": "lib/jquery-1.10.2"

}

});

我们现在可以在模块的依赖数组中使用模块名jquery而不是它的文件名,这将映射到 jQuery 的指定版本。

内容交付网络和回退

许多开发人员更喜欢参考来自 web 上众多全球内容交付网络(CDN)之一的 jQuery 或其他流行库的副本。在适当的条件下,这将减少下载文件所需的时间,并增加文件可能已经缓存在用户机器上的可能性,如果他们以前访问过从同一 CDN 加载相同版本的 jQuery 的另一个网站。

RequireJS 允许您通过使用依赖关系数组中的 URL 来链接到托管在其他域上的模块,但是我们可以使用前面配置 jQuery 时使用的配置设置来简化外部文件依赖关系的管理。我们将用一个新的代码片段替换最后一个代码片段,以引用来自 Google CDN 的 jQuery,同时仍然允许它在外部文件加载失败时回退到文件的本地版本。我们可以在配置对象中使用一个数组来链接回退脚本列表,如清单 9-6 所示。

清单 9-6。一个有两个可能位置的模块,一个在第一个没有加载的情况下用作后备

requirejs.config({

paths: {

"jquery": [

"https://ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min

// If the CDN fails, load from this local file instead

"lib/jquery-1.10.2"

]

}

});

创建模块

现在我们已经有了文件结构,并需要在页面上加载和配置,是时候考虑应用中的文件是如何相互依赖的了。我们已经确定我们的主应用脚本依赖于 jQuery,以便设置表单提交处理程序和验证脚本来验证表单。因为验证脚本将作为 jQuery 插件构建,所以它也依赖于 jQuery。我们可以用表 9-2 中的表格来描述这些依赖关系。

表 9-2。

The code file dependencies for each script in our project

| 脚本文件 | 属国 | | --- | --- | | 框架 | 没有依赖关系 | | 验证外挂程式 | 仅 jQuery | | 主应用脚本 | jQuery 和验证插件 |

我们现在可以在validation-plugin.js文件中为我们的验证 jQuery 插件模块编写代码,指定 jQuery 作为它唯一的依赖项。该模块检查给定字段的值是否是电子邮件地址的典型格式,如果是有效的电子邮件地址,则返回 true,否则返回 false,如清单 9-7 所示。

清单 9-7。作为 jQuery 验证插件的 RequireJS 模块

define(["jquery"], function($) {

$.fn.isValidEmail = function() {

var isValid = true,

// Regular expression that matches if one or more non-whitespace characters are

// followed by an @ symbol, followed by one or more non-whitespace characters,

// followed by a dot (.) character, and finally followed by one or more non-

// whitespace characters

regEx = /\S+@\S+\.\S+/;

this.each(function() {

if (!regEx.test(this.value)) {

isValid = false;

}

});

return isValid;

};

});

我们在对define()函数的调用中省略了可选的模块名参数,所以模块将用相对于 RequireJS 位置的文件名注册。它可以在其他依赖关系中被模块名lib/validation-plugin引用。

现在我们已经建立了依赖关系,是时候完成主应用脚本文件中的代码了。这里我们不打算使用define()函数;相反,我们将使用 RequireJS 的require()函数和 AMD API。这两种方法具有相同的模式,但是它们的使用方式不同。前者用于声明模块以备后用,而后者用于加载依赖项以便立即执行,而无需从中创建可重用的模块。后一种情况适合我们的主应用脚本,它将只执行一次。

在我们的main.js文件中的配置代码下面,我们需要声明附加到 HTML 表单提交事件的代码,如清单 9-8 所示,使用我们新的 jQuery 插件脚本执行验证,如果提供的电子邮件地址有效,允许表单提交。

清单 9-8。添加到我们项目的主应用脚本中,将页面连接到我们的模块

require(["jquery", "lib/validation-plugin"], function($) {

var $form = $("#form"),

$email = $("#email");

$form.on("submit", function(e) {

e.preventDefault();

if ($email.isValidEmail()) {

$form.get(0).submit();

} else {

$email.addClass("error").focus();

}

});

$email.on("keyup", function() {

$email.removeClass("error");

});

});

当清单 9-8 中的代码在清单 9-1 中登录页面的上下文中的浏览器中执行时,jQuery 库将首先被加载,然后是我们的验证插件模块。您还记得,当我们定义验证模块时,我们指定它也依赖于 jQuery。RequireJS 的一个很棒的特性是,如果它遇到一个已经被引用的依赖项,它将使用内存中存储的值,而不是再次下载它,这允许我们正确地定义我们的代码文件依赖项,而不会影响下载的数据量。

一旦加载了依赖项,就执行该函数,并将依赖项的任何返回值作为参数传递。因为我们将验证器模块定义为一个 jQuery 插件,所以我们没有指定返回值;和其他插件一样,它将被添加到 jQuery $变量中。

按需加载附加脚本

我们可以扩展我们的代码,以利用 RequireJS 可以在您的应用中需要 JavaScript 依赖项的时候按需加载这些依赖项。我们不需要在页面加载时立即加载验证插件(就像我们现在做的),我们只需要在用户提交表单时加载插件并使其可用。通过减少最初请求页面时下载的数据量和执行的代码量,转移到更高效的按需脚本模型,卸载该脚本的负载将提高页面加载性能。

RequireJS 允许我们在希望下载额外的依赖项时简单地调用require()函数。我们可以重写我们的主应用脚本,在初始页面加载时删除对验证插件的依赖,并在用户试图提交表单时将其添加到页面中。如果用户从未提交表单,则不会加载该文件。我在清单 9-9 所示的主应用脚本的更新代码中突出显示了对require()函数的额外调用。

清单 9-9。更新了主应用脚本,以便在需要时按需加载验证插件

require(["jquery"], function($) {

var $form = $("#form"),

$email = $("#email");

$form.on("submit", function(e) {

e.preventDefault();

require(["lib/validation-plugin"], function() {

if ($email.isValidEmail()) {

$form.get(0).submit();

} else {

$email.addClass("error").focus();

}

});

});

$email.on("keyup", function() {

$email.removeClass("error");

});

});

当用户试图使用这个更新的脚本提交表单时,会加载验证器插件并尝试验证。如果用户第二次尝试验证,那么验证器插件已经被加载,所以不会再被下载。图 9-5 显示了加载到页面上的脚本的瀑布时间表。第二条垂直线表示页面已经完全加载,而最右边的小圆点表示验证插件脚本加载的时间,此时用户与表单进行交互,从而减少了页面准备使用之前加载的数据量。

A978-1-4302-6269-5_9_Fig5_HTML.jpg

图 9-5。

RequireJS supports the loading of JavaScript files dynamically on demand as needed by your application, reducing the number of HTTP requests on page load

当然,表单提交可能要等到文件下载后才会发生,所以如果插件脚本是一个大文件,这可能会影响页面的响应。如果您愿意,您可以通过在用户第一次关注表单中的文本字段时下载验证器插件来抵消这种影响。这应该给浏览器足够的时间来下载插件文件,以便它为用户提交表单做好准备。

RequireJS 代码优化工具

如果您在开发设置中运行构建工具,或者在编码后打包代码用于部署,您可以利用 http://requirejs.org 提供的 RequireJS 优化工具。这结合了相关的脚本,并使用 UglifyJS 或 Google Closure 编译器对它们进行了精简,我们在第三章的中提到过。

优化器被构建为在 Java 或 NodeJS 上运行(首选,因为它运行得更快),因此它可以从命令行或通过自动化工具运行。它研究应用中列出的、与 JavaScript 文件中每个模块相关联的依赖项,并将总是一起使用的依赖项文件合并到一个文件中,动态更新文件中的依赖项列表以进行匹配。这使得代码执行时的 HTTP 请求更少,从而在不改变开发中使用的原始代码的情况下改善了最终用户的体验。

如果您想了解更多关于 RequireJS 优化工具的信息,请通过 http://bit.ly/require_opt 查看文档和示例。

所需的附加插件

RequireJS 通过将插件脚本与 RequireJS 一起放在 scripts 文件夹中来支持其自身功能的扩展。表 9-3 显示了我遇到的一组优秀插件,你可能希望考虑在你的应用中使用它们。

表 9-3。

Plugins for RequireJS

| 插件 | 描述 | | --- | --- | | 詹姆斯·伯克的 i18n | 用于应用中文本本地化的插件。通过创建以 ISO 语言环境命名的文件,并且每个文件都包含一个表示 web 应用中与该特定语言和国家相关的文本字符串的相似对象,您可以配置 RequireJS 只加载与用户当前查看的站点的语言环境版本相关的文件。可通过 [`http://bit.ly/req_i18n`](http://bit.ly/req_i18n) 在线获得 | | 詹姆斯·伯克的文本 | 允许将任何基于文本的文件作为依赖项加载进来(默认情况下,只加载脚本文件)。任何列出的带有模块名前缀`text!`的依赖项将使用 XmlHttpRequest (XHR)加载,并作为代表文件全部内容的字符串传递给模块。这对于从外部文件加载固定的 HTML 标记块以便在您自己的模块中呈现或处理非常方便。可通过 [`http://bit.ly/req_text`](http://bit.ly/req_text) 在线获得 | | 米勒·梅德罗斯字体 | 允许您通过 Google 的 WebFont Loader API 加载字体,将您需要的字体指定为前缀为字符串`font!`的依赖项。可通过 [`http://bit.ly/req_font`](http://bit.ly/req_font) 在线获得 | | 亚历克斯·塞顿设计的车把 | 插件加载到`handlebars.js`模板文件中作为模块中的依赖项。返回的模板参数是一个函数,您可以将数据传递给它,也可以将它作为一个依赖项加载进来。执行该函数的结果是一个 HTML 字符串,然后将它注入到页面中。访问 [`http://handlebarsjs.com`](http://handlebarsjs.com/) 了解更多关于车把模板库的信息。可通过 [`http://bit.ly/req_handle`](http://bit.ly/req_handle) 在线获得 | | 由 Jens Arps 缓存 | 默认情况下,RequireJS 会在加载后将模块存储在内存中,如果发生页面刷新,会再次下载模块。有了这个插件,任何加载的模块都将存储在浏览器的`localStorage`中,并在随后的页面刷新时从那里加载,以减少页面刷新时的 HTTP 请求数量。可通过 [`http://bit.ly/req_cache`](http://bit.ly/req_cache) 在线获得 |

如果你看到你觉得缺少的功能或者没有达到标准(或者你只是想冒险),你可以通过使用 RequireJS 插件 API 编写你自己的插件,详情通过 http://bit.ly/req_plugin

要求的替代方案 j

尽管 RequireJS 是浏览器中管理代码依赖关系最常用的库,但它不是唯一可用的选项。因为每一个都是基于 AMD 规范的,所以表 9-4 中显示的每一个备选方案都以相似的方式工作,所以,只要稍加协调,它们就可以相互替换使用。

表 9-4。

Browser-based module loaders

| 图书馆 | 统一资源定位器 | | --- | --- | | BDLoad | [`http://bdframework.org/bdLoad/`](http://bdframework.org/bdLoad/) | | 狭谷 | [`https://github.com/requirejs/cajon`](https://github.com/requirejs/cajon) | | 卷发 | [`https://github.com/cujojs/curl`](https://github.com/cujojs/curl) | | LoaderJS | [`https://github.com/pinf/loader-js`](https://github.com/pinf/loader-js) | | 要求的 | [`http://requirejs.org`](http://requirejs.org/) | | UMD | [`https://github.com/umdjs/umd`](https://github.com/umdjs/umd) | | Yabble | [`https://github.com/jbrantly/yabble`](https://github.com/jbrantly/yabble) |

摘要

在这一章中,我已经向你介绍了如何构建一个简单的页面,这个页面使用 RequireJS 来简化代码文件依赖关系的管理,并允许将脚本文件的加载延迟到需要的时候。这种方法不仅使管理不断增长的代码变得更加容易,还允许您通过只在需要的时候加载所需的代码来提高 web 应用的性能。

RequireJS 的能力甚至超过了我在这一章中提到的。我鼓励您通读库主页上的文档:了解如何使用许多有用的配置选项,如何为模块提供替代名称,如何直接从 JSONP web 服务加载和存储数据,以及如何同时加载和管理同一模块的多个版本(以及许多其他功能)。

这种代码依赖管理和脚本文件加载的方法是当今世界中新兴的行业最佳实践,在这个世界中,网站和应用的 JavaScript 代码库不断增长。我全心全意地鼓励你更多地了解这种方法,并在你自己的网站中采用它,为你自己获得好处。

在下一章中,我们将介绍与移动设备相关的 JavaScript 开发,包括以最小的内存占用充分利用您的代码的技术,以及学习如何处理来自当今市场上许多智能手机和平板设备上的传感器的输入。

十、移动 JavaScript 开发

在最近几年的所有技术进步中,很少有哪一项能像移动智能手机和平板电脑革命那样影响深远。行业趋势表明,台式电脑的销售正在下降,而移动设备的采用正在以极快的速度增长。对许多人来说,在小屏幕设备上上网比在台式机上更频繁。我们需要确保跟上这种变化,使我们的网站和应用适应我们用户的需求,他们中的许多人很快就会熟悉通过手指访问的万维网。

在本章中,我们将学习移动设备 web 开发的限制以及如何克服这些限制,如何通过 JavaScript 从这些设备上的传感器访问数据,如何处理网络问题和不良连接,最后,如何调整我们的 JavaScript 以适应采用响应式设计原则和技术的网站。

移动网络发展的制约因素

由于设备比台式电脑小得多,不难相信在移动设备的设计和构造中会做出一些妥协。其中一些损害与设备本身的技术有关,一些损害是用于在设备和最近的手机发射塔或 WiFi 网络之间传输数据的网络的连锁效应。了解这些限制可以让我们调整我们的网站和应用开发,以尽量减少它们的影响,并为我们的最终用户提供最佳体验。

电池寿命

在撰写本文时,大多数智能手机电池在正常情况下只能给用户一两天的电池寿命。性能密集型应用和网站消耗的电池电量可能会超过设备空闲时的标准功耗。

我们需要在网站和应用中构建代码,尽可能减少对电池寿命的影响。这包括尽可能少地更新页面的显示区域,并尽可能少地执行 JavaScript 来提供所需的用户体验。避免使用 JavaScript 在页面上执行动画和过渡;如果这些是必要的,使用 CSS3 转场( http://bit.ly/css_trans )和动画( http://bit.ly/css_anims )来代替,它们更有效地利用 CPU,节省电池。如果您打算从移动设备的传感器访问数据,如地理位置或方向,请仅在您实际需要的时间内偶尔访问这些值,并在您不再需要访问数据时断开代码中任何事件侦听器的连接。使用第四章中讨论的事件框架原理,在传感器数据频繁变化时执行较少的代码。

网络带宽速度和延迟

移动网络提供商宣传的速度并不总是与现实相符,你可能不会对此感到惊讶。事实是,移动设备的连接速度可能会受到许多因素的限制,包括与您所连接的手机信号发射塔的距离、连接到同一发射塔的其他人的数量以及环境因素,如天气和周围建筑物的密度。在现代 4G/LTE 移动网络中,速度问题变得不那么尖锐,因为潜在的数据传输速率要高得多;然而,影响设备感知网络速度的另一个因素是延迟,即 HTTP 数据包从设备到达服务器并返回所需的时间。WiFi 和有线网络的延迟很低,这意味着客户端-服务器通信很快,感觉响应很快;然而,在移动网络上,延迟是一个更大的问题,主要是因为设备和手机信号塔之间的距离要远得多。

低延迟会影响网站或应用发出的每一个 HTTP 请求。因此,你的请求越多,你的应用的响应就越慢,因为完整的页面需要更长的时间来呈现。记住这一点,尽可能将 JavaScript 文件缩小并连接在一起用于页面加载,并使用第四章和第九章中介绍的技术在需要时卸载不必要的脚本以按需加载。以同样的方式缩小和连接 CSS 文件(在 http://bit.ly/css_min 尝试 CSS 缩小器),并将图像文件合并到精灵中(在 http://bit.ly/css_sprite 中详细解释了该技术),如果可能,或者使用 base64 编码直接从 CSS 文件中引用较小的图标大小的图像文件作为数据 URIs(在 http://bit.ly/data_uris 阅读更多关于 base64 编码和数据 URIs 的信息)。浏览器对每个域名同时下载的文件数量有限制,因此通过在多个域之间分割资源,可以同时下载更多文件,这一过程称为并行化。然而,这是一项需要非常小心使用的技术,因为每次 DNS 查找都需要时间,并且会增加页面加载的总延迟。通过尝试同时下载大量文件,没有一个文件会在合理的时间内完成下载的风险会增加,因为可用的网络带宽分布在所有文件上。明智地使用这种并行化技术,因为它很容易产生与预期相反的效果。确保您的 web 服务器支持 JavaScript 和 CSS 文件的缓存和 help 压缩,因为这将有助于减少每次从服务器加载的数据量。在这一章的后面,我们将看看如何使用 HTML5 应用缓存来存储文件,以减少页面每次请求 HTTP 的次数。

板载内存大小

在最近的智能手机普及浪潮之前,Web 开发人员不必担心设备上的可用内存量;一段时间以来,桌面设备已经容纳了相当数量的内存,足够用。在撰写本文时,大多数入门级台式计算机现在都配有 2GB 的板载内存,而最高端的移动设备只有大约 512MB。也不是所有的可用内存都可以被网页访问,因为它必须在操作系统和其他后台应用和进程之间共享。实际上,一个 512MB 的设备可能只有不到一半的空间可用于实际的前台运行应用。

考虑图像;一旦从服务器上下载了图像,它就会以未压缩的像素数据形式出现在设备的内存中。包含大量图像的网页会消耗更多的设备可用内存,图像越大,使用的内存就越多。因此,在通过网络将大图像传输到移动设备上,然后在页面上将它们调整得更小时要小心,因为它们与大得多的图像消耗相同的内存量。这同样适用于 JavaScript,它可能会被压缩以更有效地传输数据,但一旦它到达设备,就会被解压缩并存储在内存中。确保不要为了显示页面而传输过多或过大的文件,因为每个文件都会消耗内存。当内存被填满时,操作系统将试图通过关闭未使用的后台任务和应用来释放内存,从而影响用户移动体验的响应性和便利性。

除了标准处理器之外,许多移动设备还配备了图形处理器(GPU ),每个处理器都有自己分配的内存。通过将清单 10-1 所示的 CSS 转换应用到包含图像的元素,可以将图像卸载到图形内存中,在那里对图像进行硬件加速。

清单 10-1。将可视页面元素卸载到图形内存的 CSS 规则

.element {

/* enforces hardware acceleration on the GPU */

-webkit-transform: translateZ(0); /* Vendor-specific prefix for Safari 3.1+ / Chrome */

-moz-transform: translateZ(0); /* Vendor-specific prefix for Firefox 3.5 - 15 */

-ms-transform: translateZ(0); /* Vendor-specific prefix for Internet Explorer 9 */

-o-transform: translateZ(0); /* Vendor-specific prefix for Opera 10.50—12.00 */

transform: translateZ(0);  /* Standard rule (Firefox 16+, IE 10+, Opera 12.10+) */

}

但是,使用这种技术时要小心,并且要有限度,因为硬件合成图像占用的视频内存是标准内存中相同图像的四倍,对应于 GPU 上单独显示的图像中的红色、蓝色、绿色和 alpha 通道。

操作系统响应能力

我们网站和应用的用户希望他们的应用有一定程度的响应能力。轶事证据表明,如果用户在 300 毫秒内没有收到动作的反馈,他们会感到明显的滞后,并对应用形成负面印象。出于这个原因,他们与页面的任何交互都必须在这个时间段内产生一些可见的反应。如果他们点击了一个触发 Ajax 调用的按钮,但是由于延迟或连接速度的原因,该调用在 300 毫秒内没有产生响应,那么您应该在页面上显示一个指示器,表明后台正在发生一个操作。这可以采取纺车指示器的形式,尽管许多开发人员和设计师选择使用新一代的指示器,如蒂姆·霍尔曼在线在 http://bit.ly/loaders_and_spinners 收集和策划的指示器。

让这个问题更加复杂的是,大多数移动设备上的标准click事件处理程序直到用户在屏幕上点击后抬起手指 300 毫秒后才被触发。这种延迟是有意的,因为设备需要等待以查看用户是否想要双击屏幕来触发不同的动作,例如页面缩放;如果没有延迟,每个动作都将被解释为单击。为了在用户轻击屏幕后抬起手指的瞬间触发一个动作,将你的代码连接到touchend事件,而不是click事件,尽管要记住这对想要双击屏幕上该元素的用户来说意味着什么。

使用 JavaScript 访问移动设备传感器

正如我们人类依靠我们的感官为我们提供关于我们环境的数据一样,智能手机和平板设备也使用自己的数字感官——触摸屏、地理位置、方位、方向和运动——来提供交互,并根据用户及其现实世界的环境来定制应用和游戏。添加外部附件可以给移动设备带来更多的感官体验——这些包括:健康附件,如测量血糖水平( http://bit.ly/bg_module )或跟踪血压(http://bit.ly/blood_pressure);健身的附加装置,如心率监测器( http://bit.ly/hr_monitor )和鞋内传感器(http://bit.ly/nike_ipod);以及用于小型企业的附加设备,例如用于接受支付的信用卡读卡器( http://bit.ly/cc_readers )。

web 开发人员可以通过三种主要方式访问内置设备传感器报告的数据:

  • 为他们希望支持的每个平台(例如 Google Android、Apple iOS、Microsoft Windows Phone)使用本地操作系统应用编程接口(API)。
  • 使用 PhoneGap ( http://phonegap.com )之类的框架,这使得开发人员可以在 HTML5 中编写一次代码,并将其重新编译为适用于每个操作系统和设备的原生应用,使用原生 API 进行交互。
  • 可以使用标准化的 web 标准 API(API 的细节通过 http://bit.ly/1aIQV0x )来访问传感器数据,这些 API 可以在移动浏览器中使用 JavaScript 与不同的设备一起工作,例如 iOS 的 Mobile Safari、Android 的 Chrome、Windows Phone 的 IE、Opera Mobile 和 Firefox 等。

第三种基于 web 标准的方法的优势在于,它避开了每次更新应用或发布漏洞修复时都要经过应用商店审批流程的要求。用户也不必手动更新他们的应用(它可以自动完成),它仍然允许构建功能性和美观的应用。这是最吸引我的方法,我将在本节中详细介绍。

我将依次讨论每个传感器,并描述如何通过 JavaScript 访问其数据,给出真实世界的使用示例,并提供一些我的个人经验以获得最佳结果。请参考移动 HTML5 兼容性表( http://mobilehtml5.org )了解当前哪些浏览器和设备支持访问传感器数据的完整详细信息。

访问地理定位传感器

地理定位传感器是移动地图应用背后的动力,定位用户在地球上的位置,以帮助他们绘制通往不同目的地的路线。该传感器使用多种方法的组合,可能包括 WiFi 定位(通过 http://bit.ly/wifi_positioning 读取更多)、GSM 蜂窝塔三角测量(通过 http://bit.ly/mobile_tracking 读取更多)、GPS 卫星定位(通过 http://bit.ly/gps_triangulate 读取更多)来检索代表用户位置的经纬度坐标。为了保护用户的隐私,网站或应用在从地理位置传感器访问数据之前必须请求许可(通过 http://bit.ly/geo_api 指定 W3C 指南)。用户会看到一个对话框,询问他们是否允许访问他们的位置,如图 10-1 所示,这是向苹果 iOS 7 用户显示的。

A978-1-4302-6269-5_10_Fig1_HTML.jpg

图 10-1。

Operating system dialog asking the user to permit access to their location from a website

使用这些地理位置数据,开发人员可以改善他们网站或应用的用户体验,例如,在 web 表单中自动预填充城市和国家字段,或者查找用户附近的电影院在什么时间上映什么电影。将这些数据与谷歌地图 API(使用说明通过 http://bit.ly/maps_api )结合使用,意味着我们可以构建专用的地图和路线应用,当用户改变位置时,这些应用会动态更新用户界面。知道位置还使 Web 应用能够使用 Panoramio API(通过 http://bit.ly/panoramio_api 的使用说明)或训练助手(通过 http://bit.ly/exercise_app 跟随教程)显示在用户附近区域拍摄的照片,以计算跑步者跑完一定距离需要多长时间,并能够与过去和未来的跑步进行比较。

W3C 地理定位 API ( http://bit.ly/w3c_geo_api )允许我们通过 JavaScript 访问用户的位置坐标,提供一次性的位置锁定,或者在用户移动时持续跟踪用户的能力。浏览器的navigation.geolocation.getCurrentPosition()方法只执行一次传递给它的回调函数,该函数接收用户的位置坐标,而navigation.geolocation.watchPosition()方法在每次用户位置改变时执行一次传递的回调函数,从而允许地理位置监控。该 API 还允许我们确定返回坐标的准确性,并指定我们是希望位置被正常返回还是以高精度返回。

Note

精确定位将花费更长的时间来确定用户,并且在此过程中可能会消耗更多的设备电池电量。

清单 10-2 中的例子显示了如何使用这个 API 根据用户的位置动态更新屏幕上的地图,为了简单起见,使用了谷歌的静态地图 API(使用说明通过 http://bit.ly/static_maps )。它假设它正在 HTML 页面的上下文中运行,它将在该页面中放置地图切片图像。

清单 10-2。访问地理定位传感器并在地图上显示位置

// Create a <img> element on the page to display the map tile in

var mapElem = document.createElement("img");

// Define a function to execute once the user's location has been established,

// plotting their latitude and longitude as a map tile image

function successCallback(position) {

var lat = position.coords.latitude,

long = position.coords.longitude;

mapElem.setAttribute("src", "http://maps.googleapis.com/maps/api/staticmap?markers

}

// Define a function to execute if the user's location couldn't be established

function errorCallback() {

alert("Sorry - couldn't get your location.");

}

// Detect the Geolocation API before using it—'feature detection'—exposed in the

// navigator.geolocation object in the browser

if (navigator.geolocation) {

// Start watching the user's location, updating once per second (1s = 1000ms)

// and execute the appropriate callback function based on whether the user

// was successfully located or not

navigator.geolocation.watchPosition(successCallback, errorCallback, {

maximumAge: 1000

});

// Size the map tile image element and add it to the current page

mapElem.setAttribute("width", 300);

mapElem.setAttribute("height", 300);

document.body.appendChild(mapElem);

}

为了防止 JavaScript 错误,我们使用功能检测来确保在针对其 API 编码之前可以访问地理位置传感器,在代码周围使用简单的if语句,浏览器可能支持也可能不支持。可以在页面加载时请求访问用户的位置,但最好避免这样做,因为这会迫使他们在知道如何使用之前选择共享他们的位置,从而引起怀疑。为用户提供一个按钮,让他们按下就可以访问他们的位置,这让他们对网站或应用有更强的控制感,这使他们更有可能授予权限。

如果用户拒绝访问他们的位置,您可以使用基于 IP 的回退(例如在 http://freegeoip.net 的 FreeGeoIP)来定位他们在城市或国家级别的大致位置。如果这不合适,礼貌地向用户解释,在他们授予你权限之前,你不能向他们提供某些特定的功能,这样让他们觉得他们可以控制自己的数据以及数据的使用方式。

地理定位的进一步阅读

如果您想了解 W3C 地理定位 API 的更多信息,下面的链接将帮助您更深入地了解这个迷人的传感器。

访问触摸传感器

触摸屏允许用户以简单自然的方式控制他们的移动设备的界面。屏幕下方的触摸传感器可以检测一个或多个手指的接触,并跟踪它们在屏幕上的移动。在 JavaScript 中,这种移动导致了一个touchevent,你可以通过 http://bit.ly/w3c_touch_event 在 W3C 网站上读到更多。

使用 W3C 触摸事件 API ( http://bit.ly/w3c_touchevents )在 JavaScript 中访问来自触摸传感器的数据。这使得网站和应用能够通过图像传送带和幻灯片来增强,例如,对手指滑动做出反应。它还允许开发先进的 Web 应用,允许人们用手指画画,如 Artistic Abode 通过 http://bit.ly/abode_touch 演示的一个,如图 10-2 所示,或者通过用手指翻动卡片找到配对来测试他们的记忆,如在 http://bit.ly/mem_vitamins 在线找到的 MemoryVitamins。

A978-1-4302-6269-5_10_Fig2_HTML.jpg

图 10-2。

A picture being drawn using a finger on a touchscreen with Artistic Abode’s web app

每当用户触摸、移动或从屏幕上移开手指时,浏览器中就会触发一个触摸事件,首先是手指放在屏幕上时的touchstart事件,当手指移动时的touchmove事件,最后是手指从屏幕上移开时的touchend事件。可以为每个事件分配事件处理函数,从而为我们的 web 应用创建所需的行为。除了给我们当前触摸点的位置,传感器还可以通过我们的事件处理程序告诉我们哪个页面元素被触摸,并提供当前屏幕上所有其他手指触摸的列表,那些在特定元素内的触摸,以及那些自上次触发触摸事件以来发生变化的触摸。

某些触摸动作触发移动设备本身的操作系统内的行为:例如,在图像上按住手指可能会触发上下文菜单出现,或者两个手指在页面上分开可能会触发页面缩放。如果您正在为触摸传感器编写代码,那么当触摸事件触发时,您可以使用传递给事件处理程序的event对象的preventDefault()方法,在您的事件处理程序函数中覆盖这个默认的操作系统行为。

清单 10-3 展示了如何使用触摸屏 API 来显示屏幕上任意时刻的当前触摸次数,每当一个或多个手指被添加到屏幕上或从屏幕上移除时都会更新。它假设运行在一个 HTML 页面的上下文中,并向其中添加了一个用于显示屏幕触摸次数的<p>元素。

清单 10-3。从触摸传感器访问数据

// Create a <p> element on the page to output the total number of current touches

// on the screen to

var touchCountElem = document.createElement("p");

// Define an event handler to execute when a touch event occurs on the screen

function handleTouchEvent(event) {

// Get the list of all touches currently on the screen

var allTouches = event.touches,

allTouchesLength = allTouches.length;

// Prevent the default browser action from occurring

// when the user touches and holds their finger on the screen

if (event.type === "touchstart") {

event.preventDefault();

}

// Write the number of current touches onto the page

touchCountElem.innerHTML = "There are currently " + allTouchesLength + " touches on the screen.";

}

// Add the output <p> element to the current page

document.body.appendChild(touchCountElem);

// Assign the event handler to execute when a finger touches (touchstart) or is removed

// from (touchend) the screen

window.addEventListener("touchstart", handleTouchEvent, false);

window.addEventListener("touchend", handleTouchEvent, false);

苹果 iOS 设备支持一组更高级的与手势相关的 JavaScript 事件。当用户在屏幕上捏或旋转两个或更多手指并报告数字移动了多远时,这些事件就会触发。然而,这些都是特定于设备的,因此如果您希望在不同的设备上复制这些事件,您可能会发现 JavaScript 库 Hammer.js ( http://bit.ly/hammer_js )很有用,它使您能够在网站和应用中的多个设备之间轻松使用触摸手势。

触摸传感器的进一步阅读

如果您想了解更多关于 W3C touch events API 的信息,您可能会发现以下链接很有用:

访问方位和方向传感器

方位传感器确定设备被握持的方向;它还可以检测设备如何围绕三个不同的旋转轴定位,如图 10-3 所示,假设设备具有内部陀螺仪。一些设备,如苹果的 iPhone 和 iPad,也包括磁力计,有助于确定设备指向的精确方向。围绕 x 轴、y 轴和 z 轴的旋转可以分别称为滚动俯仰和偏航,或者用β、γ和α旋转的度数来表示。

A978-1-4302-6269-5_10_Fig3_HTML.jpg

图 10-3。

Rotation around the x, y, z axes of a mobile device. Source: http://hillcrestlabs.com

通过了解移动设备的方向,我们可以调整我们网站的功能来适应,比如在主要内容区域的上方或旁边重新定位导航菜单。JavaScript 中的 W3C 屏幕方向 API ( http://bit.ly/screen_orientation )通知我们设备的当前方向,是纵向还是横向,以及它是否被倒置。它触发一个orientationchange事件,我们可以将代码挂入其中,在设备重定向的那一刻执行。清单 10-4 中的例子显示了如何使用屏幕方向 API 来添加一个 CSS 类到你的页面的<body>标签中,以指示设备是纵向的还是横向的,并允许通过它进行适当的样式改变。

清单 10-4。根据移动设备的方向更改 HTML 页面上的类名

// Define an event handler function to execute when the device orientation changes between

// portrait and landscape

function onOrientationChange() {

// The device is in portrait orientation if the device is held at 0 or 180 degrees, and in

// landscape orientation if the device is held at 90 or -90 degrees

var isPortrait = window.orientation % 180 === 0;

// Add a class to the <body> tag of the page according to the orientation of the device

document.body.className += isPortrait ? " portrait" : " landscape";

}

// Execute the event handler function when the browser tells us the device has

// changed orientation

window.addEventListener("orientationchange", onOrientationChange, false);

// Execute the same function on page load to set the initial <body> class

onOrientationChange();

如果您只想在设备重定向时更改可见的页面样式,请考虑使用 CSS 媒体查询( http://bit.ly/css_mq )而不是 JavaScript 来实现,因为这将提供正确的关注点分离( http://bit.ly/concerns_web )。

使用内置陀螺仪可以让我们创建游戏的移动版本,如 Jenga ( http://bit.ly/jenga_game )或 Marble Madness ( http://bit.ly/marble_madness )来测试用户的稳定性和神经。当旋转包含陀螺仪的移动设备时,浏览器根据 W3C device orientation API(http://bit.ly/orientation_event)触发重定向事件。此事件提供的数据表示设备围绕其三个轴旋转的量,以度为单位。将这些运动数据反馈给我们的 JavaScript 代码允许我们根据程序逻辑更新显示。清单 10-5 中的代码展示了如何使用内置的陀螺仪和 DeviceOrientation API 来根据设备的精确方向旋转 3D 页面上的图像。它将一个<img>标签添加到当前的 HTML 页面中以显示图像。由此产生的伪 3D 效果可以在图 10-4 中看到。

清单 10-5。根据移动设备的精确方位以伪 3D 旋转图像

// Create a <img> element on the page and point to an image of your choosing

var imageElem = document.createElement("img");

imageElem.setAttribute("src", "Listing10-5.jpg");

// Create an event handler function for processing the device orientation event

function handleOrientationEvent(event) {

// Get the orientation of the device in 3 axes, known as alpha, beta, and gamma, and

// represented in degrees from the initial orientation of the device on load

var alpha = event.alpha,

beta = event.beta,

gamma = event.gamma;

// Rotate the <img> element in 3 axes according to the device's orientation using CSS

imageElem.style.webkitTransform = "rotateZ(" + alpha + "deg) rotateX(" + beta + "deg) rotateY(" + gamma + "deg)";

}

// Add the <img> element to the page

document.body.appendChild(imageElem);

// Listen for changes to the device orientation using the gyroscope and fire the event

// handler accordingly

window.addEventListener("deviceorientation", handleOrientationEvent, false);

A978-1-4302-6269-5_10_Fig4_HTML.jpg

图 10-4。

Running Listing 10-5, creating a pseudo-3D effect on an image using device sensors

我们可以将磁力计的数据与 CSS 旋转变换结合起来,构建一个虚拟罗盘,或者将屏幕上的地图与用户面对的方向对齐。Apple 的 Mobile Safari 浏览器提供了一个特定于 Webkit 的实验性属性,每当设备移动时,都会返回以正北度数为单位的当前指南针方向,允许我们相应地更新显示。目前没有用于访问磁力计的标准化 API,尽管这被设想为已经提到的 DeviceOrientation API 的扩展。

清单 10-6 中的代码显示了如何根据设备当前指向的方向,旋转一个表示指南针的 HTML 页面上的<img>标签(正北由页面上直接向上的图像表示)。

清单 10-6。根据移动设备的罗盘航向旋转图像

// Create a <img> element on the page and point to an image of a compass

var imageElem = document.createElement("img");

imageElem.setAttribute("src", "Listing10-6.jpg");

// Create a function to execute when the compass heading of the device changes

function handleCompassEvent(event) {

// Get the current compass heading of the iPhone or iPad, in degrees from due north

var compassHeading = event.webkitCompassHeading;

// Rotate an image according to the compass heading value. The arrow pointing to due north

// in the image will continue to point north as the device moves

imageElem.style.webkitTransform = "rotate(" + (-compassHeading) + "deg)";

}

// Add the <img> element to the page

document.body.appendChild(imageElem);

// Observe the orientation of the device and call the event handler when it changes

window.addEventListener("deviceorientation", handleCompassEvent, false);

方位和方向传感器的进一步阅读

要了解更多关于方位和方向传感器的编码,请通过 http://bit.ly/detect_orientation 查看 Mozilla 开发者网络网站上的“检测设备方位”。

访问运动传感器

移动设备的运动传感器告诉我们用户在三个线性轴(x(左右)、y(向前/向后)、z(向上/向下)上移动设备的速度,对于内置陀螺仪的设备,还告诉我们设备围绕三个旋转轴(x(β旋转角度,即滚动)、y(γ,即俯仰)和 z(α,即偏航)移动的速度。

运动传感器用于 flip-to-silence 应用,如 Flip4Silence(通过 http://bit.ly/flip4silence 通过 Google Play 提供给 Android)和游戏,如世嘉的《超级猴子球 2》(通过 http://bit.ly/smball2 在 App Store 上提供给苹果 iOS)。运动传感器开辟了各种可能性,从让用户通过摇动他们的设备来重置表格或撤销动作,到高级 Web 应用,如通过 http://bit.ly/is_quake 在线找到的虚拟地震仪。

W3C DeviceMotionEvent API(http://bit.ly/device_motion)规定,每当移动设备移动或旋转时,移动设备都会触发一个 JavaScript 事件,这将传递传感器数据,给出设备加速度(以米每秒平方为单位— m/s²)和旋转速度(以度每秒为单位— deg/s)。加速度数据以两种形式给出:一种考虑重力的影响,另一种忽略重力的影响。在后一种情况下,即使完全静止不动,该设备也会报告每秒平方 9.81 米的向下加速度。清单 10-7 中的代码展示了如何使用 DeviceMotionEvent API 向用户报告设备的当前加速度。它假设运行在一个 HTML 页面的上下文中,并添加了两个<p>标签来显示运动传感器返回的值,分别是没有重力的影响和有重力的影响。

清单 10-7。访问运动传感器以显示设备在任何方向上的最大加速度

// Create <p> elements for displaying current device acceleration values in

var accElem = document.createElement("p"),

accGravityElem = document.createElement("p");

// Define an event handler function for processing the device's acceleration values

function handleDeviceMotionEvent(event) {

// Get the current acceleration values in 3 axes and find the greatest of these

var acc = event.acceleration,

maxAcc = Math.max(acc.x, acc.y, acc.z),

// Get the acceleration values including gravity and find the greatest of these

accGravity = event.accelerationIncludingGravity,

maxAccGravity = Math.max(accGravity.x, accGravity.y, accGravity.z);

// Output to the user the greatest current acceleration value in any axis, as well as the

// greatest value in any axis including the effect of gravity

accElem.innerHTML = "Current acceleration: " + maxAcc + "m/s²";

accGravityElem.innerHTML = "Including gravity: " + maxAccGravity + "m/s²";

}

// Add the <p> elements to the page

document.body.appendChild(accElem);

document.body.appendChild(accGravityElem);

// Assign the event handler function to execute when the device is moving

window.addEventListener("devicemotion", handleDeviceMotionEvent, false);

运动传感器的进一步读数

要了解有关在移动设备上使用运动传感器的更多信息,请查看以下在线资料。

失踪的传感器

在撰写本文时,无论是摄像头还是麦克风传感器都无法在移动浏览器中通过 JavaScript 访问。例如,如果我们能够访问这些传感器,我们就有可能捕获用户面部的图像并分配给一个在线帐户,或者允许用户为自己录制音频笔记。

不同浏览器供应商之间的分歧是缺乏访问这些数据的标准化 API 的部分原因。然而,最近的 W3C 媒体捕获和流 API ( http://bit.ly/media_capture )正在获得关注,并及时使开发人员能够从相机中捕获静态图像或视频流,或者从麦克风中捕获音频流(在用户允许的情况下),以便在我们的 JavaScript 代码中使用。目前,这个 API 只在谷歌的 Chrome 浏览器和 Mozilla 的 Firefox 浏览器上可用,但支持看起来很快就会添加。通过访问浏览器中的 http://bit.ly/caniuse_stream ,查看浏览器对此功能的最新支持。

传感器数据的事件帧

在第四章的中,我描述了处理频繁触发事件的事件框架过程,通过减少每次触发事件时执行的代码量来提高性能。当涉及到移动设备时,应该认真使用这种技术,因为移动设备不能像桌面浏览器那样快速地处理 JavaScript。如果没有帧,事件处理函数将消耗设备上额外的额外内存,并导致网站或应用感觉无响应。清单 10-8 显示了我们如何调整清单 10-5 中的代码,将事件框架技术应用到 DeviceOrientation API。

清单 10-8。使用事件帧根据移动设备的精确方向旋转图像

// Create variables to store the data returned by the device orientation event

var alpha = 0,

beta = 0,

gamma = 0,

imageElem = document.createElement("img");

imageElem.setAttribute("src", "Listing10-5.jpg");

// Update the event handler to do nothing more than store the values from the event

function handleOrientationEvent(event) {

alpha = event.alpha;

beta = event.beta;

gamma = event.gamma;

}

// Add a new function to perform just the image rotation using the stored variables

function rotateImage() {

imageElem.style.webkitTransform = "rotateZ(" + alpha + "deg) rotateX(" + beta + "deg) rotateY(" + gamma + "deg)";

}

document.body.appendChild(imageElem);

// Connect the event to the handler function as normal

window.addEventListener("deviceorientation", handleOrientationEvent, false);

// Execute the new image rotation function once every 500 milliseconds, instead of every time

// the event fires, effectively improving application performance

window.setInterval(rotateImage, 500);

进一步利用传感器数据

使用 JavaScript 增强网站和创建基于传感器数据的 Web 应用的可能性和机会是巨大的。我在这里考虑了一些例子——只要有点创造力,我相信你可以想出更多的例子。尝试将来自不同传感器的数据(如地理位置和方向或运动和方向)结合起来,以帮助您构建增强的网站和 web 应用,从而以令人兴奋的新方式响应用户及其环境。尝试并从中获得乐趣!

网络连接故障和脱机状态

在移动设备上浏览网页的一个众所周知的问题是网络连接的掉线问题,特别是如果用户在运动中,例如在火车上或在汽车后座。当点击一个链接打开一个新页面时,用户很清楚网络已经断开,因为他们将看到一个移动设备上熟悉的屏幕。图 10-5 展示了这样的屏幕在苹果 iOS 7 上的样子。

A978-1-4302-6269-5_10_Fig5_HTML.jpg

图 10-5。

Network drops on mobile devices cause inconvenient experiences for those browsing the web

如果我们正在构建一个由 JavaScript 驱动的 web 应用,其中删除了硬页面转换以支持单页面体验,如果网络连接断开,用户将不会看到这样的屏幕,因此,作为开发人员,我们需要自己在应用中处理这个问题,例如,向用户指示网络连接已断开,或者将 HTTP 调用存储在缓冲区中,直到网络连接恢复。

检测联机和脱机状态

清单 10-9 中的代码展示了如何使用浏览器的navigator.onLine属性,在 JavaScript 代码执行的任何时候检测网络连接是否断开。

清单 10-9。在 JavaScript 代码执行期间的特定时间点检测网络连接的中断

var isOnline = navigator.onLine;

if (isOnline) {

// Run code dependent on network access, for example, execute an Ajax call to the server

} else {

alert("The network has gone offline. Please try again later.");

}

清单 10-9 中的代码对于包装任何网络连接代码都很有用,比如使用XmlHttpRequest的 Ajax 调用,或者动态创建引用外部文件资源的<script><img><link> DOM 元素。但是,您可能希望在屏幕上向用户显示网络是否已连接。我们可以利用网络断开和恢复时触发的两个 JavaScript 事件,分别命名为offlineonline,而不是连续轮询navigator.onLine的值。然后,当网络状态改变时,您可以将代码挂接到这些事件上来更新页面,如清单 10-10 所示。

清单 10-10。检测 JavaScript 应用中任何一点的网络连接变化

// Define a function to execute when the network drops

function goneOffline() {

alert("No network connection");

}

// Define a function to execute when the network connection returns

function backOnline() {

alert("The network connection has been restored");

}

// Connect these functions up to the relevant JavaScript events that fire when the

// network goes offline and back online, respectively

window.addEventListener("offline", goneOffline, false);

window.addEventListener("online", backOnline, false);

清单 10-11 展示了我们如何将两种形式的网络连接断开检测结合到一个代码例程中,该例程在网络离线时存储 Ajax 调用,并在网络连接恢复时立即执行它们。

清单 10-11。当网络中断时堆叠 Ajax 调用,并在网络恢复时释放

// Define a variable to store our stack of Ajax calls in if they can't be made immediately

// because of a dropped network connection

var stack = [];

// Define the function that makes Ajax calls

function ajax(url, callback) {

// The XMLHttpRequest class enables Ajax requests to be made in the browser

var xhr = new XMLHttpRequest(),

LOADED_STATE = 4,

OK_STATUS = 200;

// If the browser has gone offline, add the function arguments (the url and callback) to the

// stack for sending later

if (!navigator.onLine) {

stack.push(arguments);

} else {

// If the browser is online, make the Ajaz call

xhr.onreadystatechange = function() {

// A readyState of 4 indicates that the server response is complete

if (xhr.readyState !== LOADED_STATE) {

return;

}

// Execute the callback function if the server responded with a HTTP 200

// status message ("OK")

if (xhr.status === OK_STATUS) {

callback(xhr.responseText);

}

};

// Trigger the Ajax HTTP GET operation

xhr.open("GET", url);

xhr.send();

}

}

// Define a function that loops through the stack of unsent Ajax calls, sending each in turn

function clearStack() {

// Loop through the items in the stack until the stack length is 0 (a falsy value)

while (stack.length) {

// Make the Ajax call, using the data from the stack. The shift() method pulls the first

// item off the array and returns it, altering the original array

ajax.apply(ajax, stack.shift());

}

}

// Ensure the clearStack function executes as soon as the network connection is restored

window.addEventListener("online", clearStack, false);

然后使用ajax()方法在代码中进行 Ajax 调用,如下所示。清单 10-11 中的代码将处理是立即进行网络调用还是等到网络连接恢复。

ajax("/my-service-url", function(data) {

alert("Received the following data: " + JSON.stringify(data));

});

您可以进一步修改这个示例,以允许您的网站或应用处理偶尔的网络中断,而不会影响用户在与您的代码交互的任何时候的体验。

用 Web 存储 API 保存数据

当您的 web 应用离线时,我们在清单 10-11 中看到了如何堆叠调用,以便一旦网络连接恢复,它们可以继续。然而,在这种情况下,用户并不知道正在发生这种情况,例如,他们发出的保存个人数据的调用没有通过服务器,而只是存储在内存中。如果他们选择关闭浏览器中的选项卡,这些内存内容将被清除,这意味着这些调用将永远不会被发送到服务器。我们需要一种方法将这个堆栈保存在内存中,即使浏览器关闭了,这样当用户将来返回应用时,只要网络连接,就可以从堆栈中进行调用。

持久变量存储过去是通过创建 cookiess 来处理的,cookie 是放在用户机器上的小文件,随每个 HTTP 请求一起发送到服务器。这是低效的——随每个请求发送的大 cookie 文件可能会导致应用的性能大大降低。今天,我们可以访问 HTML5 Web 存储 API ( http://bit.ly/webstorage_api ),特别是该规范中定义的window.sessionStoragewindow.localStorage对象。前者sessionStorage,只允许在用户浏览器会话期间存储数据。通常,一旦他们关闭浏览器,任何存储的值都会被删除。另一方面,localStorage对象允许数据跨会话持久化,直到被用户或应用删除。对象上有三种方法可以从本地存储内存块中按名称获取、设置和删除项目:分别是getItemsetItemremoveItem。清单 10-12 展示了如何使用这些方法将变量数据保存在内存中,即使是在浏览器关闭之后。

清单 10-12。使用 Web 存储 API 在浏览器关闭后保存数据值

// Check to see if we have stored a value for the "favoriteBrowser" key before

var favoriteBrowser = window.localStorage.getItem("favoriteBrowser");

// If not, prompt the user to tell us their favorite web browser

if (!favoriteBrowser || favoriteBrowser === "") {

favoriteBrowser = prompt("Which is your favorite web browser?", "Google Chrome");

// Store their favorite browser in localStorage for next time they visit

window.localStorage.setItem("favoriteBrowser", favoriteBrowser);

}

// Show the user that we know what their favorite browser is, even if they told us some time ago

alert("Your favorite browser is " + favoriteBrowser);

// Ask if the user would like us to remove their favorite browser value from persistent storage

if (confirm("Would you like us to forget your favorite browser?")) {

// Remove the value from localStorage

window.localStorage.removeItem("favoriteBrowser");

}

getItemsetItemremoveItem方法可以被替换成简化的、更熟悉的语法,将localStorage对象视为 JavaScript 中的标准对象,创建、访问和删除该对象的属性以持久化它们的数据,如清单 10-13 所示,它执行与清单 10-12 完全相同的功能。还要注意如何直接访问localStorage对象,而不需要通过window对象来获取对它的引用。

清单 10-13。访问 Web 存储 API 的另一种方法

// Data within localStorage can be accessed as if they were properties on a standard object

var favoriteBrowser = localStorage["favoriteBrowser"];

if (!favoriteBrowser || favoriteBrowser === "") {

localStorage["favoriteBrowser"] = prompt("Which is your favorite web browser?", "Google Chrome");

}

alert("Your favorite browser is " + favoriteBrowser);

if (confirm("Would you like us to forget your favorite browser?")) {

// The delete keyword allows the removal of a property from localStorage

delete localStorage["favoriteBrowser"];

}

我们可以将 Web 存储 API 应用到我们在清单 10-11 中编写的代码中,以便在网络断开时堆栈 Ajax 调用,如果用户关闭浏览器,则持久保存该堆栈,并在用户重新打开浏览器并且网络连接恢复时进行这些调用,如清单 10-14 所示。

清单 10-14。当网络中断时堆叠 Ajax 调用,并在浏览器关闭后持久化它们

localStorage["stack"] = localStorage["stack"] || [];

function ajax(url, callback) {

var xhr = new XMLHttpRequest(),

LOADED_STATE = 4,

OK_STATUS = 200;

if (!navigator.onLine) {

// Data in localStorage is stored as strings, so to store complex data structures such

// as arrays or objects, we need to convert those into a JSON-formatted string first

localStorage["stack"].push(JSON.stringify(arguments));

} else {

xhr.onreadystatechange = function() {

if (xhr.readyState !== LOADED_STATE) {

return;

}

if (xhr.status === OK_STATUS) {

callback(xhr.responseText);

}

};

xhr.open("GET", url);

xhr.send();

}

}

function clearStack() {

if (navigator.onLine) {

while (localStorage["stack"].length) {

// After reading the JSON-formatted string data out of localStorage, it needs to be

// converted back into a complex data form for use with the ajax() function

ajax.apply(ajax, JSON.parse(localStorage["stack"].shift()));

}

}

}

// Check on page load if there are any previously stacked Ajax calls that could now be sent

window.addEventListener("load", clearStack, false);

window.addEventListener("online", clearStack, false);

在开始使用这个 API 在本地添加兆字节的数据之前,有必要考虑一下浏览器对以这种方式存储的数据量的限制。每个域名最多可以在本地存储 5MB 的数据。虽然这是一个可以在许多浏览器中更改的设置,但这是默认的数量,没有办法通过 JavaScript 来更改。如果您试图使用localStorage写入超过 5MB 的数据,JavaScript 将抛出一个错误,并且不允许您保存额外的数据,直到您删除先前存储的数据。如果您希望以这种方式清除本地存储的全部内容,您可以调用localStorage.clear()方法,这将释放您的应用的所有可用空间,将您恢复到 5MB 的默认数据存储量。

要深入了解 Web 存储 API,请通过 http://bit.ly/dom_storage 阅读 Mozilla 开发者网络在线上的“DOM 存储指南”。

HTML5 应用缓存

我们处理离线状态的最后一项技术是 HTML5 应用缓存。使用一种特殊格式的文件(称为缓存清单),您可以在应用中列出要下载的特定文件,并将其存储在本地设备的缓存中,这样每次都可以从缓存中加载这些文件,而不是直接从网络中加载。这意味着,一旦您访问了一个网站或应用,即使您的网络连接离线,您也应该能够再次访问同一网站。这不仅有利于那些使用移动设备或网络连接不良的用户,桌面设备也可以体验到这种好处,这意味着一旦以这种方式缓存,网站几乎可以瞬间加载。

缓存清单文件是一个简单的文本文件,约定规定它应该有一个.appcache文件扩展名,尽管这并不是任何规范的本质或一部分。更重要的是,缓存清单文件必须使用 MIME 类型的text/cache-manifest,对于大多数 web 服务器来说,这意味着需要为特定的文件扩展名向服务器配置添加特定的规则。

必须通过使用<html>标记上的manifest属性引用 HTML 文件来引用清单文件:

<html manifest="manifest.appcache">

清单文件本身看起来像一个标准的文本文件,它的第一行必须是CACHE MANIFEST才能被正确识别。在它最简单的用法中,应该有一个文件列表,每行一个,然后将被缓存以备将来对同一页面的请求。如果清单文件中列出的任何文件不存在,或者在请求时返回 HTTP 错误状态(例如,404 或 500),则整个清单文件将被视为无效,并且不会使用此机制缓存任何文件。下次加载页面时,浏览器会将清单文件视为从未见过,尝试再次下载所有引用的资源。

如果浏览器检测到自上次缓存文件以来清单文件已被更新,它将返回到 web 服务器下载任何已更改的引用文件—当它这样做时,它会为每个文件请求发送一个If-Modified-Since HTTP 头,这意味着只有自上一轮缓存以来被更新的文件才会在缓存中被替换。一个好的技巧是在清单文件中包含一个注释行,由行首的散列字符(#)表示,它引用文件的版本号和/或可选的更改日期。更新引用的文件时,更新文件中的版本号,以便浏览器检测到更改并开始检查更新的文件。如果清单文件没有更改,浏览器将继续提供文件的缓存版本,直到用户手动删除缓存。当请求过去以这种方式缓存的页面时,浏览器将首先加载页面的缓存版本,然后在后台下载更新的文件。只有在下次刷新页面时,才会加载更新的资产。这与许多人对浏览器行为的预期相反,如果检测到清单文件中的更改,他们会立即根据新的资源重新下载并呈现整个页面。

清单 10-15 显示了一个简单的缓存清单文件,列出了下次页面加载时应该加载和缓存的文件资产,无论是在线还是离线。请注意,您不需要列出引用清单文件本身的 HTML 页面,因为默认情况下会缓存该页面。

清单 10-15。一个简单的缓存清单文件

CACHE MANIFEST

# Version 1.0.1 - 2013-01-02

/library/styles/main.css

/library/scripts/lib/jquery.min.js

/library/scripts/main.js

/img/background.jpg

/img/logo.png

缓存清单文件可能包含三个可选部分,每个部分由标题表示:CACHE:NETWORK:FALLBACK:CACHE:部分与没有列出的部分相同,也就是说,它包含应该存储在离线缓存中的列出的文件。

NETWORK:部分列出了需要用户在线才能访问的 URL,例如表单操作 URL、web 服务和其他网络基本文件。本节中列出的任何资源都将通过网络直接访问,完全绕过缓存。本节只需要部分 URL,所以如果一组 web 服务通过相同的基础 URL 公开,例如, https://api.twitter.com/1.1/ ,那么这就是需要列出的所有内容。通配符值允许使用星号(*)字符。

FALLBACK:部分列出了当网络离线时用来代替网络基本 URL 的本地缓存文件。它由文件名、URL 或模式组成,后跟在网络连接中断时使用的本地缓存文件。在最简单的情况下,对于一个静态 HTML 站点,您可以使用通配符来引用所有的.html文件,并让它们退回到一个单独的脱机 HTML 文件,向用户解释该站点当前在那个单独的页面上是脱机的,以获得更令人满意的用户体验。在更高级的情况下,您可以提供对任何服务器端脚本、图像、样式表、JavaScript 文件等的回退,以便在网络中断时为您的用户提供良好的体验。

清单 10-16 显示了一个更高级的缓存清单文件,它使用了可选的CACHE:NETWORK:FALLBACK:部分。

清单 10-16。包含三个部分的缓存清单文件

CACHE MANIFEST

# Version 1.0.1 - 2013-10-02

CACHE:

/library/styles/main.css

/library/scripts/lib/jquery.min.js

/library/scripts/main.js

/img/background.jpg

/img/logo.png

# Always go straight to the network for API calls from a base /api/ URL

NETWORK:

/api/

# Replace a 'network online' image with a 'network offline' image when the network is down

FALLBACK:

/img/network-status-online.png /img/network-status-offline.png

如果你想了解更多关于 HTML5 应用缓存的内容,请访问 Mozilla 开发者网站上的“使用应用缓存”一文( http://bit.ly/app_cache )。

响应式设计的 JavaScript

响应式网页设计是一种新兴的设计和构建网站和应用的技术,它允许界面适应正在浏览的设备的特性。小屏幕设备(如智能手机)将显示适当大小和比例的用户界面,大屏幕设备上的用户也是如此。CSS3 媒体查询允许根据设备的当前特征对页面元素应用不同的样式规则。

在许多情况下,使用这种技术对网站进行视觉上的改变可能会导致界面行为的改变。可能在较大设备上完全显示的导航菜单可能在较小设备上隐藏在屏幕外,使用切换按钮来触发菜单的显示;切换按钮的行为仅适用于小屏幕视图。

通过使用浏览器的window.matchMedia()方法,传递要与当前显示进行比较的媒体查询或部分查询,可以基于当前活动的 CSS3 媒体查询规则执行不同的 JavaScript 代码。这将返回一个包含一个matches属性的MediaQueryList对象,如果它所代表的媒体查询在那时是活动的,那么这个属性将被设置为true

如果应用的媒体查询发生变化,您将需要重新检查每个MediaQueryList对象的matches属性的状态。幸运的是,在绝大多数情况下,这应该是挂钩到浏览器窗口的resize事件的简单情况,如清单 10-17 所示。

清单 10-17。基于 CSS3 媒体查询执行特定的 JavaScript

// Create MediaQueryList objects for different CSS3 Media Query rules

var landscapeMQL = window.matchMedia("(orientation: landscape)"),

smallScreenMQL = window.matchMedia("(max-width: 480px)");

function checkMediaQueries() {

// Execute specific code if the browser is now in landscape orientation

if (landscapeMQL.matches) {

alert("The browser is now in landscape orientation");

}

// Execute specific code if the browser window is 480px or narrower in width

if (smallScreenMQL.matches) {

alert("Your browser window is 480px or narrower in width");

}

}

// Execute the function on page load and when the screen is resized or its orientation changes

window.addEventListener("load", checkMediaQueries, false);

window.addEventListener("resize", checkMediaQueries, false);

通过 http://bit.ly/matchmedia 阅读 Mozilla 开发者网络上关于 matchMedia 方法的更多信息。

摘要

在本章中,我们考虑了 web,特别是 JavaScript,因为它适用于用户在移动设备、智能手机或平板设备上浏览。我们已经看到了在内存、带宽、延迟和速度方面需要考虑的限制,以及如何最好地解决这些问题。我们研究了如何直接从这类设备的板载传感器中访问数据,使我们的应用能够对位置、运动、方向等做出反应。我们还了解了网络连接中断时会发生什么,以及如何在网络恢复后立即处理网络操作,从而提供流畅的用户体验。最后,我们学习了如何基于在响应网站的浏览器中应用的 CSS3 媒体查询来执行特定的 JavaScript。

以移动为中心的开发是一个不断增长的领域,随着每年新设备和操作系统更新的发布,我们可以使用开放的标准 W3C APIs 通过 JavaScript 直接访问越来越多的设备功能。确保你定期了解最新的发展,这样你才不会在这个快速发展的技术进步的世界中落后。

在下一章中,我们将了解如何使用 HTML5 Canvas drawing API 来构建桌面和移动在线游戏,而不依赖于任何第三方插件,如 Adobe Flash。

十一、使用画布 API 构建游戏

近年来,web 浏览器最令人兴奋的新功能之一是在 HTML5 及其相关 JavaScript API 中采用了<canvas>标签。单独在 HTML 文档中,它什么也不做。然而,将它与 JavaScript 的强大功能结合起来,您的页面中就有了一个空白的绘图表面,您可以向其中添加形状、图像和文本。canvas 元素的内容由平面绘图表面上的像素数据表示,而不是由文档中的单个 DOM 元素表示,因此,如果您用另一个形状覆盖现有形状,文档中不会有原始形状曾经存在的记录。通过在一段时间内在同一个画布上反复清除和重绘,并进行细微的变化,我们可以给人一种元素内的动画和运动的印象。通过将这个动画连接到触摸屏、鼠标或键盘上的控制器,我们允许用户能够操纵屏幕上发生的事情。结合一些行为逻辑,我们可以使用<canvas>元素来构建在浏览器中运行的游戏。在这一章中,我们将先看看 Canvas JavaScript API 中的基本绘图操作,然后再深入研究如何使用它来构建游戏的细节,包括构建经典街机游戏 Frogger 的工作版本。

画布中的基本绘图操作

画布表面上的所有绘制都只通过 JavaScript 进行,默认情况下所有画布都是空白的。在绘制之前,我们需要获得对 canvas 元素的二维绘制上下文的引用,这将返回对我们希望在其上绘制的表面的引用——canvas 规范的未来发展可以通过为不同的需求创建额外的上下文来实现,例如通过 WebGL 规范( http://webgl.org )现在可以在 canvas 中实现三维图形。获取绘图上下文引用就像执行 canvas DOM 元素的getContext()方法一样简单,从那里可以执行许多方法来绘制形状和在画布上添加文本,如清单 11-1 所示。

清单 11-1。画布中的基本绘图操作

// Create a new <canvas> element

var canvas = document.createElement("canvas"),

// Get a reference to the drawing context of the canvas

context = canvas.getContext("2d");

// Set the dimensions of the canvas

canvas.width = 200;

canvas.height = 200;

// By default, a canvas is drawn empty, however if we needed to empty its contents after

// drawing to it, we could execute this function

function emptyCanvas() {

// Erase the contents of the canvas from the top-left of the canvas to the position at

// 200px x 200px from the top-left corner

context.clearRect(0, 0, 200, 200);

}

// With the drawing context established, we can now execute any of the drawing commands we

// would like on our blank canvas. For example, if we want to draw a circle in the top-left

// corner of our canvas, we could execute the following function

function drawCircle() {

// First, we tell the drawing context that we're creating a path—essentially a line

// between one point and another that could take any course between the two points

context.beginPath();

// The context's arc() method tells the path to take an arc shape. The method’s first

// two parameters indicate its starting position of the arc in pixels along the x- and

// y-axes, respecitvely. The third parameter indicates the size of the arc, in pixels,

// and the final two parameters indicate the arc's start and end angle, in radians,

// respsecitvely. To draw a circle, the start angle will always be 0, and the end angle

// will always be twice the value of PI, which indicates a full 360 degrees in radians.

context.arc(100, 100, 100, 0, 2 * Math.PI);

// By default, this line's path would be invisible, however the stroke() method ensures

// that a visible line is drawn along the path making its outline visible. We could also

// have used the fill() method to fill the circle with a fixed color.

context.stroke();

}

// Drawing a straight line works in a similar way to drawing a circle in that we must define

// our line before calling the stroke() method to actually apply the graphical "ink" to the

// canvas

function drawLine() {

// Move the drawing context location to position 50px (from the left edge of the canvas) x 40px

// (from the top edge of the canvas)

context.moveTo(50, 40);

// Mark out a staright line from the context's current position to position 150px x 160px,

// without actually drawing a line onto the canvas

context.lineTo(150, 160);

// Apply the "ink" to the canvas to fill in the marked-out line

context.stroke();

}

// Define a function to draw a red square onto the canvas using the drawing context's

// fillRect() method, setting the draw color to use first before performing the action

function drawSquare() {

// Set the fill style of the next draw operation. #FF000 is the hex value representing red.

context.fillStyle = "#FF0000";

// Draw a 100px red square starting at position 20px x 20px

context.fillRect(20, 20, 100, 100);

}

// We could even add text onto our canvas using the fillText() and strokeText() drawing

// context methods as shown in this function

function writeText() {

// First set the font style to use for the text to draw onto the canvas

context.font = "30px Arial";

// Write some text onto the canvas at position 0px x 0px

context.fillStyle = "#000";

context.fillText("Filled Text", 0, 30);

// Write some outlined text onto the canvas beneath the existing text at position 0px x 40px

context.strokeText("Outlined Text", 0, 70);

}

// Execute the defined drawing functions, adding their shapes and text to the canvas

emptyCanvas();

drawCircle();

drawLine();

drawSquare();

writeText();

// Add the new <canvas> DOM element to the end of the current HTML page once loaded

window.addEventListener("load", function() {

document.body.appendChild(canvas);

}, false);

在一个 web 页面的上下文中执行清单 11-1 中的代码会导致图 11-1 中所示的图像被添加到页面的<canvas>元素中。

A978-1-4302-6269-5_11_Fig1_HTML.jpg

图 11-1。

Basic drawing operations in Canvas

关于 画布 API 中可用的绘图方法的更详细的概述,请通过 http://bit.ly/canvas_tutorial 查看 Mozilla 开发者网络上的“Canvas 教程”。

高清画布元素

用于移动和桌面设备的屏幕技术的最新进展已经为这种设备带来了高清晰度图形的出现,有时称为视网膜图形,因为单个像素边界对于肉眼内的视网膜来说是不可区分的。默认情况下,画布元素不会为这种屏幕类型创建更高清晰度的图形,标准的基于画布的图形在这些屏幕上通常看起来像素化或模糊不清。幸运的是,有一些技术可以帮助我们创建高分辨率的画布元素。

为了确保您的画布只为显示它的设备屏幕呈现正确数量的像素,我们可以利用浏览器的window对象的devicePixelRatio属性。存储在该属性中的值表示当前屏幕支持的标准显示分辨率之上的分辨率因子,例如,1表示标准屏幕,2表示视网膜显示器。如果适合屏幕类型,我们使用这个值来放大我们的<canvas>元素的宽度和高度,像以前一样使用 CSS 来再次将其缩小到正确的大小以便显示。因此,所有绘图操作的尺寸和大小也必须适当地按该因子缩放,以便在设备上以正确的大小呈现。Paul Lewis 在 HTML5 Rocks blog 上通过 http://bit.ly/hidpi_canvas 写了一些关于如何实现这一点的完整文档,值得一读,但是如果您确信您正在绘制的图形不是需要执行的特别密集的操作,您可以简单地创建两倍于您打算显示它的大小的画布,呈现假设这个更大大小的所有内容(即,不使用比例因子),然后简单地使用 CSS 将画布元素的宽度和高度设置回 HTML 页面中所需的显示大小。这样,通过在较小的空间中简单地渲染更多的像素,就可以在画布上看到更高分辨率的图形。如果您的最终用户没有高清屏幕,我们会渲染不显示的额外像素,这就是为什么这种技术应该只用于不太密集的图形操作,以免影响浏览器性能。

使用画布构建游戏

画布 API 的一个常见用途是构建在浏览器中运行的游戏,这曾经只是为 Adobe 的 Flash player 编写的专有代码的领域。由于该 API 在桌面和移动设备上都受到广泛支持,因此使用 画布 API 编写的游戏也可以在移动中从浏览器中玩。

许多游戏由相似的程序和结构组成,包括:

  • 游戏板或世界的存在,它定义了游戏中动作的约束
  • 绘制用户控制的玩家以及游戏板上出现的任何敌人或障碍物并制作动画,并跟踪每个敌人或障碍物在游戏板上的位置
  • 使用诸如按键、点击、轻敲、移动和其他相关输入设备的输入机制来控制玩家在游戏板上的移动
  • 保持更新的分数、高分数,并跟踪玩家剩余的生命数和/或玩家剩余多少时间来完成该关卡
  • 检测一个或多个玩家或障碍物何时在游戏板上相互碰撞,以及玩家失去生命或完成关卡或游戏的处理

现在让我们更详细地看看如何使用 画布 API 对这些结构中的每一个进行编码,使它们能够协同工作,形成一个可工作的游戏。

在画布上绘制图像

大多数游戏都涉及图像在屏幕上的移动——玩家的角色很少是简单的形状,如圆形或方形,将游戏图形设计为在游戏中使用的图像可能是最容易的。这需要将图像从文件直接绘制到画布上,这可以使用画布绘制上下文的drawImage()方法来完成,向它传递一个对<img>元素的引用和画布上绘制图像的位置,如清单 11-2 所示。

清单 11-2。在画布上绘制图像

// Create a new <canvas> element to draw the image to

var canvas = document.createElement("canvas"),

// Get the drawing context of the <canvas> element

context = canvas.getContext("2d"),

// Create a new <img> element to reference the image to draw onto the <canvas>

img = document.createElement("img");

// Assign a function to execute once the assigned image has loaded—the image will not begin to

// load until its "src" attribute has been set

img.addEventListener("load", function() {

// Draw the image onto the <canvas> element at position 0px x 0px—the top-left corner of

// the element

context.drawImage(img, 0, 0);

}, false);

// Assign the "src" attribute of the <img> element to point to the location of the image we wish

// to display within the <canvas> element. The image will then load and the event handler

// assigned previously will be executed

img.src = "filename.png";

// Append the new <canvas> element to the end of the current HTML page once loaded

window.addEventListener("load", function() {

document.body.appendChild(canvas);

}, false);

使用精灵贴图图像避免多个图像文件

避免加载在网页上一起使用的多个小图像文件的常用技术是将这些图像组合成一个精灵图,一个包含每个单独图像的较大图像。这有助于通过减少浏览器和服务器需要完成的 HTTP 请求的数量来提高性能。在一个标准网页中,通过结合使用 CSS background-position属性和widthheight属性,可以从较大的图像中提取单独的图像进行显示。在画布上显示图像的情况下,drawImage()方法中使用的参数的变化允许我们从一个较大的精灵贴图图像文件中提取图像的较小部分,如清单 11-3 所示。

清单 11-3。将一个单独的图像从精灵贴图绘制到画布上

var canvas = document.createElement("canvas"),

context = canvas.getContext("2d"),

img = document.createElement("img");

img.addEventListener("load", function() {

var individualImagePositionTop = 200,

individualImagePositionLeft = 150,

individualImageWidth = 300,

individualImageHeight = 40,

displayPositionTop = 100,

displayPositionLeft = 100,

displayWidth = 150,

displayHeight = 40;

// Draw the individual image located at position 200px x 150px and with dimensions 300px x

// 40px onto the <canvas> element at position 100px x 100px, rendering at half the size of

// the original, at 150px x 40px

context.drawImage(img, individualImagePositionTop, individualImagePositionLeft, individualImageWidth, individualImageHeight, displayPositionTop, displayPositionLeft, displayWidth, displayHeight);

}, false);

img.src = "sprite-map.png";

window.addEventListener("load", function() {

document.body.appendChild(canvas);

}, false);

画布中的动画

动画是任何游戏的基本方面,画布 API 要成为构建游戏的良好平台,它需要支持更新其中绘制的像素的位置和外观的能力。因为画布的内容只表示为固定空间中的像素,所以我们无法在不影响画布其余内容的情况下定位单个图像、形状或画布的其他部分并进行更新。为了创造动画的幻觉,我们因此需要足够频繁地重新渲染画布的内容,以使人眼察觉不到除了平滑动画之外的任何变化。我们绘制画布的每个组成部分,然后清除画布,并在固定时间后重新绘制,如果需要,将元素移动到新的位置。通过每秒重画几次,我们创造了动画的错觉。

清单 11-4 显示了一个简单的圆形移动穿过一个<canvas>元素的动画,它是通过每 50 毫秒重绘一次画布来创建的,每次都更新圆形的新位置。

清单 11-4。画布中的简单动画

var canvas = document.createElement("canvas"),

context = canvas.getContext("2d"),

// Define the position, size and properties of the circle to be drawn onto the canvas

leftPosition = 0,

topPosition = 100,

radius = 100,

startDegree = 0,

endDegree = 2 * Math.PI; // = 360 degrees in radians

// Define a function to be executed periodically to update the position of the circle and redraw

// it in its new position

function animate() {

// Update the position on the screen where the circle should be drawn

leftPosition++;

// Empty the contents of the canvas

context.clearRect(0, 0, canvas.width, canvas.height);

// Draw the circle onto the canvas at the new position

context.beginPath();

context.arc(leftPosition, topPosition, radius, startDegree, endDegree);

context.stroke();

}

// Execute the animate() function once every 50 milliseconds, redrawing the circle in its

// updated position each time

setInterval(animate, 50);

// Add the <canvas> element to the current page once loaded

window.addEventListener("load", function() {

document.body.appendChild(canvas);

}, false);

游戏控制

所有游戏都会对来自游戏设备的某种形式的输入做出反应——否则它们会很无聊。最常见的是,这包括控制一个主要角色,试图使用某种形式的灵巧来确保角色达到某个目标,避免途中的敌人和障碍。在台式计算机上,按下键盘上的特定键或者通过移动或点击鼠标来控制角色的位置。在移动设备上,可以通过在触摸屏上点击,或者以某种方式旋转或移动设备来控制字符。因为基于画布的游戏可以在这两种类型的设备上运行,所以您应该确保您创建的任何游戏都可以由任何类型的设备上的输入类型来控制。

清单 11-5 展示了如何在一个基于画布的游戏中捕捉特定的按键或轻击来控制玩家。

清单 11-5。捕捉输入以控制游戏中的角色

var canvas = document.createElement("canvas");

// Define a function call to move the player’s character in the <canvas>

function move(direction) {

// Insert code here to update the position of the character on the canvas

}

// When the player presses the arrow keys on the keyboard, move the player's

// character in the appropriate direction

window.addEventListener("keydown", function(event) {

// Define the key codes for the arrow keys

var LEFT_ARROW = 37,

UP_ARROW = 38,

RIGHT_ARROW = 39,

DOWN_ARROW = 40;

// Execute the move() function, passing along the correct direction based on the

// arrow key pressed. Ignore any other key presses.

if (event.keyCode === LEFT_ARROW) {

move("left");

} else if (event.keyCode === RIGHT_ARROW) {

move("right");

} else if (event.keyCode === UP_ARROW) {

move("up");

} else if (event.keyCode === DOWN_ARROW) {

move("down");

}

}, false);

// When the player taps in certain places on the <cavnas> on their touch-sensitive

// screen, move the player's character in the appropriate direction according to where the

// screen has been tapped

canvas.addEventListener("touchstart", function(event) {

// Get a reference to the position of the touch on the screen in pixels from the

// top-left position of the <canvas>

var touchLeft = event.targetTouches[0].clientX,

touchTop = event.targetTouches[0].clientY;

// Execute the move() function, passing along the correct direction based on the

// position tapped on the <canvas> element

if (touchLeft < (canvas.width / 8)) {

move("left");

} else if (touchLeft > (3 * canvas.width / 8)) {

move("right");

} else if (touchTop < (canvas.height / 8)) {

move("up");

} else if (touchTop > (3 * canvas.height / 8)) {

move("down");

}

}, false);

// Add the <canvas> element to the current HTML page once loaded

window.addEventListener("load", function() {

document.body.appendChild(canvas);

}, false);

冲突检出

到目前为止,我们已经看到了如何在<canvas>元素上绘制、动画和控制游戏的图形元素,接下来要处理的是当玩家的角色接触到障碍物或敌人时会发生什么——在游戏开发的说法中,这被称为碰撞。在许多游戏中,当玩家的角色与敌方角色相撞时,会受到一些伤害或可能失去一条生命。因为<canvas>元素只包含像素数据,所以我们无法简单地通过使用 JavaScript 扫描元素的可视内容来区分字符。我们在游戏中需要做的是保持我们的主角和所有障碍和敌人的位置,因为我们无论如何都要计算他们在动画序列中的下一个动作。我们可以获得每个元素的位置,并使用一个函数来比较它们,以确定玩家角色周围的边界是否与障碍物或敌人角色周围的边界相交。清单 11-6 中的代码显示了一个示例函数,它可以用来判断玩家和<canvas>中的另一个元素之间是否发生了冲突。

清单 11-6。简单碰撞检测

// Define a function to establish if the bounds of the player’s character intersects with those

// of an obstacle or enemy, causing a collision

function intersects(characterLeft, characterWidth, characterTop, characterHeight, obstacleLeft, obstacleWidth, obstacleTop, obstacleHeight) {

// Define Boolean variables to indicate whether a collision occurs on the y-axis and whether

// it occurs on the x-axis

var doesIntersectVertically = false,

doesIntersectHorizontally = false,

// Establish the bounds of the character and obstacle based on the supplied parameters

characterRight = characterLeft + characterWidth,

characterBottom = characterTop + characterHeight,

obstacleRight = obstacleLeft + obstacleWidth,

obstacleBottom = obstacleTop + obstacleHeight;

// A collision occurs on the y-axis if the top position of the character sits between the

// top and bottom positions of the obstacle or if the bottom position of the character sits

// between the same positions of the obstacle

if ((characterTop > obstacleTop && characterTop < obstacleBottom) ||

(characterBottom > obstacleTop && characterTop < obstacleBottom)) {

doesIntersectVertically = true;

}

// A collision occurs on the x-axis if the left position of the character sits between the

// left and right positions of the obstacle or if the right position of the character sits

// between the same positions of the obstacle

if ((characterLeft > obstacleLeft && characterLeft < obstacleRight) ||

(characterRight > obstacleLeft && characterLeft < obstacleRight)) {

doesIntersectHorizontally = true;

}

// A collision occurs if the character intersects the obstacle on both the x- and y-axes.

return doesIntersectVertically && doesIntersectHorizontally;

}

游戏循环

游戏循环是一个根据固定持续时间重复调用的函数,本质上是游戏的核心——它更新游戏板中任何角色的位置,检查冲突,并在更新的位置呈现<canvas>元素中的角色。虽然玩家的输入可以在任何时候出现,以尝试更新角色在屏幕上的位置,但是只有当下一次调用游戏循环函数时,角色才基于该输入被绘制在其新位置。

确保游戏循环在特定的时间间隔运行以保持动画以固定的帧速率流畅的一种技术是使用浏览器的setInterval()功能,如清单 11-7 所示。

清单 11-7。使用 setInterval()函数以固定的帧速率运行游戏循环

// Define a function to act as the game loop

function gameLoop() {

// Update character positions, check for collisions and draw characters in new positions

}

// Execute the gameLoop() function once every 50 milliseconds, resulting in a frame rate of 20

// frames per second (=1000/50)

setInterval(gameLoop, 50);

使用setInterval()函数运行游戏循环的问题是,如果浏览器在再次启动之前没有及时完成执行游戏循环函数的代码,积压的代码会聚集起来,导致浏览器似乎被锁定,或者导致任何动画中出现口吃——这不是好事。幸运的是,浏览器制造商已经找到了解决这个问题的方法,这样,你就不必不顾代码对浏览器的影响而要求代码运行,浏览器可以告诉你它何时可用,何时能够处理更多的命令。这是通过调用window对象上的requestAnimationFrame()方法来实现的,向其传递一个函数,并由浏览器在下一个可用的时机执行。通过结合使用这个方法和一个定时器来确保命令按照一个固定的帧速率执行,我们给了浏览器更多的控制,允许更平滑的动画,如清单 11-8 所示。由于在规范确定之前,跨浏览器的命名存在一些差异,我们需要一个简单的 polyfill 来确保跨浏览器的操作,如清单 11-8 开头所示,它展示了一个游戏循环的例子。

清单 11-8。使用 requestAnimationFrame 运行游戏循环

// Create a simple cross-browser polyfill for modern browsers' requestAnimationFrame()

// method to enable smooth, power-efficient animations. Credit to Paul Irish via

//http://bit.ly/req_anim_frame

window.requestAnimationFrame = (function(){

return window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || function (callback){

window.setTimeout(callback, 1000 / 60);

};

})();

// Store a reference to the last time the game loop began in a local variable—initialize it

// to the current time

var lastTimeGameLoopRan = (new Date()).getTime(),

// Define the refresh rate we desire for our game loop to re-render our canvas within.

// A 20 millisecond refresh rate gives a frame rate of 50 frames per second (=1000 / 20)

refreshRate = 20;

// Define a function to act as the game loop

function gameLoop() {

// Get the current time and infer from there the difference between it and the last time

// the game loop ran

var currentTime = (new Date()).getTime(),

timeDifference = currentTime - lastTimeGameLoopRan;

// Execute this function again when the next animation frame is ready for use by

// the browser - keeps the game loop looping but within the confines of the browser's

// performance and constraints, which is ultimately best for the player

window.requestAnimationFrame(gameLoop);

// the time difference between the current execution of the gameLoop() function and

// its previous execution is greater than or equal to the defined refresh rate, then

// run the typical game loop operations

if (timeDifference >=refreshRate) {

// Update character positions, check for collisions and draw characters in

// new positions

// Update the last time the game loop ran so its next execution will occur at the

// correct time

lastTimeGameLoopRan = currentTime;

}

}

// Start the first run of the game loop

gameLoop();

分层画布以获得更好的性能

对一个<canvas>元素的每个绘制操作都需要一定的时间来执行。如果你有一个有很多角色的复杂游戏,并且你开始意识到每一个绘制操作在性能方面都很重要,那么将这个数字乘以无数倍,因此应该尽可能地避免重绘游戏的静态部分。例如,对于一些具有静态背景的游戏,创建两个<canvas>元素更有意义,一个用于绘制背景,另一个用于所有定期更新的角色移动和动画。然后可以使用 CSS 将这两个元素放置在彼此之上,包含所有移动的元素放置在包含背景的元素之上,背景只需绘制一次,从不更新。

在画布上构建“青蛙过河”游戏

让我们使用 画布 API 将我们所学的知识付诸实践。我们将基于 1981 年的街机经典游戏 Frogger ( http://bit.ly/frogger_game )构建一个简单的游戏,展示如何最好地绘制角色并制作动画,处理来自游戏控制器的输入,处理游戏中两个或更多角色之间的冲突检测,以及如何调节帧速率以保持玩家的最佳性能。

如果你不熟悉,这个游戏的目的是引导一只青蛙从下到上穿过屏幕,首先带领角色穿过一条繁忙的道路,不要碰到任何交通,然后使用漂浮的木头和偶尔浮出水面的乌龟的背越过一条河,同时避免潜伏在水中的危险,最后将我们的英雄角色送到屏幕顶部的五个目标站之一,此时屏幕底部又会创建一只新的青蛙。一旦角色进入每个目标站一次,游戏就赢了。作为控制游戏的人,用户必须确保青蛙不会被车辆撞上,不会被运出屏幕,接触到水,包括当海龟潜入水下时站在它的上面,并且角色必须在规定的时间内到达其中一个目标站。如果一个事故降临到角色身上,它的五条生命中的一条将会失去,直到一条都没有了,游戏被认为结束。

青蛙过河器中的一个典型关卡如图 11-2 所示。

A978-1-4302-6269-5_11_Fig2_HTML.jpg

图 11-2。

Frogger—the arcade classic

让我们从创建基本的 HTML 页面来存放我们的游戏开始,如清单 11-9 所示。我们在<head>中使用特殊格式的<meta>标签来固定移动设备的视口宽度。我们还需要一个自定义字体,名为“Arcade Classic”,可以从 http://bit.ly/arcade_font 免费下载,以在画布中显示分数和其他文本,所以我们也将它加载到这里。然后,我们创建两个<canvas>元素,一个容纳背景,一个容纳动画前景,并定义 CSS 将两个画布层叠在一起,确保它们以实际画布尺寸的一半显示,以支持高清显示。

清单 11-9。在画布上托管青蛙游戏的 HTML 页面

<!DOCTYPE html>

<html>

<head>

<title>Frogger In Canvas</title>

<meta charset="utf-8">

<meta name="viewport" content="width=480, initial-scale=1.0">

<style>

@font-face {

font-family: "Arcade Classic";

src: url("arcadeclassic.eot");

src: url("arcadeclassic.eot?#iefix") format("embedded-opentype"),

url("arcadeclassic.woff") format("woff"),

url("arcadeclassic.ttf") format("truetype"),

url("arcadeclassic.svg#arcadeclassic") format("svg");

font-weight: normal;

font-style: normal;

}

.canvas {

position: absolute;

top: 0;

left: 0;

border: 2px solid #000;

width: 480px;

height: 640px;

}

</style>

</head>

<body>

<canvas id="background-canvas" class="canvas" width="960" height="1280"></canvas>

<canvas id="canvas" class="canvas" width="960" height="1280"></canvas>

<!-- Load in scripts here once defined -->

</body>

</html>

有了 HTML 之后,我们从 JavaScript 开始。清单 11-10 显示了我们如何开始我们的游戏代码,通过创建一个命名空间来存放我们的代码,并定义一些关键的属性和方法用于代码的其余部分,包括观察者设计模式方法,我们将使用它在整个游戏代码中的代码模块之间进行通信。

清单 11-10。定义一个命名空间和关键属性和方法,以便在我们的游戏中使用

// Define a namespace to contain the code for our game within a single global variable

var Frogger = (function() {

// Locate the main <canvas> element on the page

var canvas = document.getElementById("canvas"),

// Get a reference to the <canvas> element's 2-D drawing surface context

drawingSurface = canvas.getContext("2d"),

// Locate the background <canvas> element on the page

backgroundCanvas = document.getElementById("background-canvas"),

// Get a reference to the background <canvas> element's 2-D drawing surface context

backgroundDrawingSurface = backgroundCanvas.getContext("2d"),

// Get a reference to the <canvas> element's width and height, in pixels

drawingSurfaceWidth = canvas.width,

drawingSurfaceHeight = canvas.height;

return {

// Expose the <canvas> element, its 2-D drawing surface context, its width and

// its height for use in other code modules

canvas: canvas,

drawingSurface: drawingSurface,

drawingSurfaceWidth: drawingSurfaceWidth,

drawingSurfaceHeight: drawingSurfaceHeight,

// Expose the background <canvas> element's 2-D drawing surface context

backgroundDrawingSurface: backgroundDrawingSurface,

// Define an object containing references to directions the characters in our game can

// move in. We define it here globally for use across our whole code base

direction: {

UP: "up",

DOWN: "down",

LEFT: "left",

RIGHT: "right"

},

// Define the observer design pattern methods subscribe() and publish() to allow

// application-wide communication without the need for tightly-coupled modules. See

// Chapter 7 for more information on this design pattern.

observer: (function() {

var events = {};

return {

subscribe: function(eventName, callback) {

if (!events.hasOwnProperty(eventName)) {

events[eventName] = [];

}

events[eventName].push(callback);

},

publish: function(eventName) {

var data = Array.prototype.slice.call(arguments, 1),

index = 0,

length = 0;

if (events.hasOwnProperty(eventName)) {

length = events[eventName].length;

for (; index < length; index++) {

events[eventName][index].apply(this, data);

}

}

}

};

}()),

// Define a method to determine whether two obstacles on the game board intersect

// each other on the horizontal axis. By passing in two objects, each with a 'left'

// and 'right' property indicating the left-most and right-most position of each

// obstacle in pixels on the game board, we establish whether the two intersect

// each other - if they do, and they are both on the same row as each other on the

// game board, this can be considered a collision between these two obstacles

intersects: function(position1, position2) {

var doesIntersect = false;

if ((position1.left > position2.left && position1.left < position2.right) ||

(position1.right > position2.left && position1.left < position2.right)) {

doesIntersect = true;

}

return doesIntersect;

}

};

}());

接下来的每个代码清单都需要在清单 11-9 中的 HTML 页面的<script>标签中被依次引用,才能看到最终的结果。

现在让我们创建我们的核心游戏逻辑,包括游戏状态,游戏循环,分数处理,以及建立玩家的剩余生命和完成关卡的剩余时间,如清单 11-11 所示。

清单 11-11。青蛙过河的核心游戏逻辑

// Create a simple cross-browser polyfill for modern browsers' requestAnimationFrame()

// method to enable smooth, power-efficient animations. Credit to Paul Irish via

//http://bit.ly/req_anim_frame

window.requestAnimationFrame = (function(){

return window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || function (callback){

window.setTimeout(callback, 1000 / 60);

};

})();

// Define the game logic module which keeps track of the game state, the players's score,

// the number of lives remaining, handles collisions between the player's character and

// other obstacles and ensures the game graphics are drawn onto the <canvas> at the

// right moment. This module contains the brains behind the game play and instructs other

// code modules to do the heavy lifting through the use of the observer design pattern.

(function(Frogger) {

// Define a variable to hold the current player's score

var _score = 0,

// Define and initialize a variable to hold the high score achieved in the game

_highScore = 1000,

// Define the number of lives the player has remaining before the game is over

_lives = 5,

// Define the number of milliseconds the player has to get their character to

// the goal (60 seconds). If they take too long, they will lose a life

_timeTotal = 60000,

// Define a variable to store the current time remaining for the player to reach

// the goal

_timeRemaining = _timeTotal,

// Define the refresh rate of the graphics on the <canvas> element (one draw every

// 33 1/3 milliseconds = 30 frames per second). Attempting to redraw too frequently

// can cause the browser to slow down so choose this value carefully to maintain a

// good balance between fluid animation and smooth playability

_refreshRate = 33.333,

// Define a variable to store the number of times the player's character has

// reached the goal

_timesAtGoal = 0,

// Define a variable to indicate the number of times the player's character needs

// to reach the goal for the game to be won

_maxTimesAtGoal = 5,

// Define a Boolean variable to indicate whether the player's movement is currently

// frozen in place

_isPlayerFrozen = false,

// Define a variable to store the last time the game loop ran - this helps keep

// the animation running smoothly at the defined refresh rate

_lastTimeGameLoopRan = (new Date()).getTime();

// Define a function to be called to count down the time remaining for the player to

// reach the goal without forfeiting a life

function countDown() {

if (_timeRemaining > 0) {

// This function will be called as frequently as the _refreshRate variable

// dictates so we reduce the number of milliseconds remaining by the

// _refreshRate value for accurate timing

_timeRemaining -= _refreshRate;

// Publish the fact that the remaining time has changed, passing along the

// new time remaining as a percentage - which will help when we come to display

// the remaining time on the game board itself

Frogger.observer.publish("time-remaining-change", _timeRemaining / _timeTotal);

} else {

// If the remaining time reaches zero, we take one of the player's remaining

// lives

loseLife();

}

}

// Define a function to be called when all the player's lives have gone and the game

// is declared over

function gameOver() {

// Pause the player's movements as they are no longer in the game

freezePlayer();

// Inform other code modules in this application that the game is over

Frogger.observer.publish("game-over");

}

// Define a function to be called when the player has reached the goal

function gameWon() {

// Inform other code modules that the game has been won

Frogger.observer.publish("game-won");

}

// Define a function to be called when the player loses a life

function loseLife() {

// Decrease the number of lives the player has remaining

_lives--;

// Pause the player's movements

freezePlayer();

// Inform other code modules that the player has lost a life

Frogger.observer.publish("player-lost-life");

if (_lives === 0) {

// Declare the game to be over if the player has no lives remaining

gameOver();

} else {

// If there are lives remaining, wait 2000 milliseconds (2 seconds) before

// resetting the player's character and other obstacles to their initial

// positions on the game board

setTimeout(reset, 2000);

}

}

// Define a function to be called when the player's character is required to be frozen

// in place, such as when the game is over or when the player has lost a life

function freezePlayer() {

// Set the local variable to indicate the frozen state

_isPlayerFrozen = true;

// Inform other code modules - including that which controls the player's

// character - that the player is now be frozen

Frogger.observer.publish("player-freeze");

}

// Define a function to be called when the player's character is free to move after

// being previously frozen in place

function unfreezePlayer() {

// Set the local variable to indicate the new state

_isPlayerFrozen = false;

// Inform other code modules that the player's character is now free to move around

// the game board

Frogger.observer.publish("player-unfreeze");

}

// Define a function to increase the player's score by a specific amount and update

// the high score accordingly

function increaseScore(increaseBy) {

// Increase the score by the supplied amount (or by 0 if no value is provided)

_score += increaseBy || 0;

// Inform other code modules that the player's score has changed, passing along

// the new score

Frogger.observer.publish("score-change", _score);

// If the player's new score beats the current high score then update the high

// score to reflect the player's new score and inform other code modules of a

// change to the high score, passing along the new high score value

if (_score > _highScore) {

_highScore = _score;

Frogger.observer.publish("high-score-change", _highScore);

}

}

// Define a function to execute once the player reaches the designated goal

function playerAtGoal() {

// When the player reaches the goal, increase their score by 1000 points

increaseScore(1000);

// Increment the value indicating the total number of times the player's character

// has reached the goal

_timesAtGoal++;

// Freeze the player's character movement temporarily to acknowledge they have

// reached the goal

freezePlayer();

if (_timesAtGoal < _maxTimesAtGoal) {

// The player must enter the goal a total of 5 times, as indicated by the

// _maxTimesAtGoal value. If the player has not reached the goal this many

// times yet, then reset the player's character position and obstacles on the

// game board after a delay of 2000 milliseconds (2 seconds)

setTimeout(reset, 2000);

} else {

// If the player has reached the goal 5 times, the game has been won!

gameWon();

}

}

// Define a function to execute when the player moves their character on the game

// board, increasing their score by 20 points when they do

function playerMoved() {

increaseScore(20);

}

// Define a function to be called when the game board needs to be reset, such as when

// the player loses a life

function reset() {

// Reset the variable storing the current time remaining to its initial value

_timeRemaining = _timeTotal;

// Release the player's character if it has been frozen in place

unfreezePlayer();

// Inform other code modules to reset themselves to their initial conditions

Frogger.observer.publish("reset");

}

// The game loop executes on an interval at a rate dictated by value of the

// _refreshRate variable (once every 50 milliseconds), in which the game board is

// redrawn with the character and obstacles drawn at their relevant positions on

// the board and any collisions between the player's character and any obstacles

// are detected

function gameLoop() {

// Calculate how many milliseconds have passed since the last time the game loop

// was called

var currentTime = (new Date()).getTime(),

timeDifference = currentTime - _lastTimeGameLoopRan;

// Execute this function again when the next animation frame is ready for use by

// the browser - keeps the game loop looping

window.requestAnimationFrame(gameLoop);

// If the number of milliseconds passed exceeds the defined refresh rate, draw

// the obstacles in the updated position on the game board and check for collisions

if (timeDifference >= _refreshRate) {

// Clear the <canvas> element's drawing surface - erases everything on the

// game board so we can redraw the player's character and obstacles in their

// new positions

Frogger.drawingSurface.clearRect(0, 0, Frogger.drawingSurfaceWidth, Frogger.            drawingSurfaceHeight);

if (!_isPlayerFrozen) {

// As long as the player's character is not frozen in place, ensure the

// timer is counting down, putting pressure on the player to reach the

// goal in time

countDown();

// Inform other code modules to check the player has not collided with an

// obstacle on the game board

Frogger.observer.publish("check-collisions");

}

// Now on our empty canvas we draw our game board and the obstacles upon it in

// their respective positions

Frogger.observer.publish("render-base-layer");

// After the game board and obstacles, we draw the player's character so that

// it is always on top of anything else on the <canvas> drawing surface

Frogger.observer.publish("render-character");

// Store the current time for later comparisons to keep the frame rate smooth

_lastTimeGameLoopRan = currentTime;

}

}

// Define a function to kick-start the application and run the game loop, which renders

// each frame of the game graphics and checks for collisions between the player's

// character and any obstacles on the game board

function start() {

// Inform other code modules of the initial state of the game's high score

Frogger.observer.publish("high-score-change", _highScore);

// Start the game loop running

gameLoop();

}

// Execute the start() function to kick off the game loop once the "game-load" event

// is fired. We'll trigger this event after we've configured the rest of our code

// modules for the game

Frogger.observer.subscribe("game-load", start);

// Execute the playerAtGoal() function when another code module informs us that the

// player has reached the goal

Frogger.observer.subscribe("player-at-goal", playerAtGoal);

// Execute the playerMoved() function when we have been informed that the player has

// moved their character

Frogger.observer.subscribe("player-moved", playerMoved);

// Execute the loseLife() function when we are informed by another code base that the

// player's character has collided with an obstacle on the game board

Frogger.observer.subscribe("collision", loseLife);

// Pass the global Frogger variable into the module so it can be accessed locally,

// improving performance and making its dependency clear

}(Frogger));

现在让我们创建一些可重用的基础代码,用于创建我们的主要角色的图像和动画,以及从一个大的精灵图像创建游戏板上的障碍,如图 11-3 所示。您应该能够看到每个单独的图像的边界位于较大的精灵。

A978-1-4302-6269-5_11_Fig3_HTML.jpg

图 11-3。

The sprite map containing all the images required to support our game in a single file

你可以通过 http://bit.ly/frogger_image 下载这个精灵图片,用于本章的代码清单。清单 11-12 显示了允许我们从这个精灵图像创建单独的图像和动画的代码。

清单 11-12。从精灵图像创建图像和动画的基本代码

// Define a "class" for creating images to place on the game board. All the individual

// images are stored together in a single large image file called a Sprite Map. By knowing

// the position within this sprite file of the image to display, together with its width

// and height, we can pull out the individual images for display. By only loading in a

// single image file we improve the loading performance of the game

Frogger.ImageSprite = function(startPositionLeft, startPositionTop) {

// Each instance stores its starting position on the game board so it can later be

// reset to its initial position if necessary

this.startLeft = startPositionLeft || 0;

this.startTop = startPositionTop || 0;

// Initialize an object property to later store any animations for this image

this.animations = {};

// Set this image to its initial state for display

this.reset();

};

// Define a "class" for assigning animations to an ImageSprite instance to allow any image

// on the game board to appear to animate. An animation is a sequence of images which will

// be displayed in order over a fixed time period to give the impression of movement

Frogger.Animation = function(options) {

options = options || {};

// Store the rate to move between the images in the animation sequence, in milliseconds

// - defaults to a rate of 150 milliseconds

this.rate = options.rate || 150;

// Store a Boolean value to indicate whether this animation is to loop or play once

this.loop = options.loop || false;

// Store the supplied position in pixels from the left-hand side of the spite map image

// where the first image in this animation sequence is located

this.spriteLeft = options.spriteLeft || 0;

// Store the animation sequence which indicates a multiple of the image with as an

// offset from the spriteLeft value. A sequence value of [0, 1, 2] would indicate there

// are three images in this animation sequence located at the position stored in the

// spriteLeft property, that position + the width of the sprite image, and that

// position + double the width of the sprite image, respectively. It is therefore

// expected that an animation sequence of images are stored horizontally beside each

// other in order within the sprite map image file

this.sequence = options.sequence || [];

};

// Define and initialize properties and methods to be inherited by each instance of the

// Frogger.Animation "class"

Frogger.Animation.prototype = {

// Define a value to indicate the current frame shown from the animation sequence.

// As the sequence property is an Array, this is effectively an index within that Array

frame: 0,

// Define a property to indicate whether the animation is currently playing - that is

// that the frame index of the animation sequence is being actively incremented at the

// rate supplied at initiation time

playing: false,

// Define a property to store a timer indicator to start and stop the incrementing of

// the frame index on demand

timer: null,

// Define a function to start playing the animation - essentially incrementing the

// frame index on a timer at the rate supplied upon instantiation

play: function() {

var that = this;

// If the animation is not currently playing, then reset it to its initial state

if (!this.playing) {

this.reset();

this.playing = true;

}

// Increment the current frame index of the animation on a timer at a rate given

// by the supplied value upon instantiation, storing a reference to the timer in

// the timer property so that it can be stopped at a later time

this.timer = setInterval(function() {

that.incrementFrame();

}, this.rate);

},

// Define a function to rewind the current frame index of the animation sequence back

// to the start

reset: function() {

this.frame = 0;

},

// Define a function to increment the current frame index of the animation sequence

incrementFrame: function() {

// Only increment the current frame if the animation should be playing

if (this.playing) {

// Increment the current frame index of the animation sequence

this.frame++;

// If we have reached the end of the animation sequence, stop the animation if

// it was not intended to loop, otherwise reset the current frame index of the

// animation back to the start

if (this.frame === this.sequence.length - 1) {

if (!this.loop) {

this.stop();

} else {

this.reset();

}

}

}

},

// Define a function to return the value stored in the animation sequence at the

// current frame index. This value will be used later on to correctly identify which

// individual image from the large sprite map to display within the <canvas> element

getSequenceValue: function() {

return this.sequence[this.frame];

},

// Define a function to return the number of pixels from the left-hand edge of the

// sprite map of the first frame of this animation. This is used in conjunction with

// the current value of the animation sequence and the image width to decide which

// image to display within the <canvas> element

getSpriteLeft: function() {

return this.spriteLeft;

},

// Define a function to stop the timer from incrementing the current frame index, and

// hence stop the animation from playing

stop: function() {

// Terminate the timer

clearInterval(this.timer);

// Indicate that the animation is no longer playing

this.playing = false;

}

};

// Define and initialize properties and methods to be inherited by each instance of the

// Frogger.ImageSprite "class" to enable individual images from a larger sprite map to be

// drawn onto the <canvas> element

Frogger.ImageSprite.prototype = {

// Define properties to store the current position in pixels of the image on the

// game board from the top and left-hand edges

top: 0,

left: 0,

// Define properties to store the initial position in pixels of the images on the game

// board from the top and left-hand edges so that the image can be returned to its

// initial position at a later stage if needed

startLeft: 0,

startTop: 0,

// Define a property containing a reference to a new <img> tag holding the single

// large sprite map image. Because this is an object, it will be shared across all

// instances of the Frogger.ImageSprite "class", saving on memory usage

sprite: (function() {

var img = document.createElement("img");

img.src = "spritemap.png";

return img;

}()),

// Define properties to define the default width and height, in pixels, of an

// individual image within the large sprite map image file

width: 80,

height: 80,

// Define properties denoting the location of the top and left positions, in pixels,

// of the individual image within the large sprite map image. Together with the width

// and height properties, we are able to pull out an individual image from the sprite

// map to display within the <canvas> element

spriteTop: 0,

spriteLeft: 0,

// Declare no animations by default

animations: null,

// Define a property indicating the name of the currently playing animation, if any

currentAnimation: "",

// Define a property to indicate whether the individual image represented by this

// object instance is currently hidden from display

isHidden: false,

// Define a function to reset this image back to its initial position and to reset any

// associated animation of that image

reset: function() {

// Reset the top and left position of the image on the game board back to its

// initial position defined upon instantiation

this.left = this.startLeft;

this.top = this.startTop;

// Reset any associated animations to their initial state

this.resetAnimation();

// Declare this image no longer to be hidden

this.isHidden = false;

},

// Define a function to associate one or more animation with this image - data is

// passed in as an object literal with each key representing the name of the animation

registerAnimation: function(animations) {

var key,

animation;

// Loop through the supplied object literal data indicating the animations to

// register

for (key in animations) {

animation = animations[key];

// Create instances of the Frogger.Animation "class" for each item in the

// supplied data object. Each item's data is passed to the "class" upon

// instantiation to define its animation sequence, animation rate, and other

// initial properties

this.animations[key] = new Frogger.Animation(animation);

}

},

// Define a function to reset any currently playing animation back to its initial state

resetAnimation: function() {

if (this.animations[this.currentAnimation]) {

// If an animation is currently playing, then call its reset() method to

// restore it to its initial state

this.animations[this.currentAnimation].reset();

}

// Once reset, there should be no currently playing animation

this.currentAnimation = "";

},

// Define a function to play a specific animation sequence by name. The name must

// correspond with one provided to the registerAnimation() method previously

playAnimation: function(name) {

// Set the current animation to the provided name

this.currentAnimation = name;

if (this.animations[this.currentAnimation]) {

// If an animation is found by the supplied name, then call its play() method

// to begin incrementing its current frame index using its internal timer

this.animations[this.currentAnimation].play();

}

},

// Define a function to draw the individual image onto the <canvas> element at the

// supplied left and top positions, in pixels. If an animation is currently playing,

// ensure the correct image is displayed based on that animation's current sequence

// value

renderAt: function(left, top) {

// Locate the animation that is currently playing, if any

var animation = this.animations[this.currentAnimation],

// If an animation is playing, get its current sequence value based on its

// internal frame index. If no animation is playing, assume a sequence value

// of 0\. This value will be multiplied by the width of the individual image

// within the sprite map to identify the exact image to show based on the

// animation's current frame index

sequenceValue = animation ? animation.getSequenceValue() : 0,

// If an animation is playing, get the location of the animation's initial

// frame as an offset in pixels from the left-hand edge of the sprite map image.

// We make an assumption that the top offset of the animation images is the

// same as the main image itself represented in this object instance - meaning

// that all frames of the animation should be positioned together with the main

// non-animating image on the same row of the sprite map image

animationSpriteLeft = animation ? animation.getSpriteLeft() : 0,

// Calculate the offset in pixels from the left-hand edge of the sprite map

// image where the individual image to display is to be found, based on whether

// an animation is currently playing or not. If no animation is playing, the

// offset will be the same as that stored in the spriteLeft property of this

// object instance

spriteLeft = this.spriteLeft + animationSpriteLeft + (this.width * sequenceValue);

// If the image is not currently to be considered hidden, then extract the individual

// image from the sprite map and draw it onto the <canvas> drawing surface at the

// top and left positions, in pixels, as provided to this method, when called

if (!this.isHidden) {

Frogger.drawingSurface.drawImage(this.sprite, spriteLeft, this.spriteTop, this.width,             this.height, left, top, this.width, this.height);

}

},

// Define a function to set the stored left and top offset positions, in pixels,

// indicating where on the game board the image should be displayed. These values are

// then used in the renderAt() method to draw the image at this position

moveTo: function(left, top) {

this.left = left || 0;

// Since most images are moved left and right in this game, rather than up and down,

// we let the top offset value be optional

if (typeof top !== "undefined") {

this.top = top || 0;

}

},

// Define a function return the width of the individual image we are extracting from

// the large sprite map image

getWidth: function() {

return this.width;

},

// Define a function to return the left and right positions, in pixels, of the image

// which we can use later to perform collision detection with other obstacles on the

// game board

getPosition: function() {

return {

left: this.left,

// The right position is derived as the left position plus the width of the

// individual image

right: this.left + this.width

};

},

// Define a function to hide this image from the game board by effectively stopping

// the drawing of the image to the <canvas> within the renderAt() method

hide: function() {

this.isHidden = true;

}

};

现在让我们创建一个代码模块来定义游戏板的限制和参数,包括角色可以移动的范围,如清单 11-13 所示。游戏板本身本质上是一个网格,其中的障碍物固定在某个垂直网格位置,只能水平移动-玩家的角色是这个规则的一个例外,因为它能够在游戏板上上下跳跃,一次移动一个网格位置。

清单 11-13。编码游戏板参数

// Define a code module to define the parameters of the game board itself, the number of

// rows and columns within the grid, along with their relative positions in pixels, and

// the bounds within which the player's character may move

(function(Frogger) {

// Define the width and height of each square on the game board grid, in pixels. The

// game board is divided into rows with different obstacles on each, and columns within

// which the player's character can move

var _grid = {

width: 80,

height: 80

},

// Define the number of rows on the game board. The top two rows contain the score,

// the next two contain the home base the player is attempting to reach. There then

// follow five rows of water-based obstacles before reaching a 'safe' row where the

// player's character may take refuge without obstacles. There then follow five rows

// of road-based obstacles before another 'safe' row, which is where the player's

// character starts its game from. The final row holds the remaining time and number

// of lives remaining. There are 17 rows, therefore, though since we start counting

// rows at position 0, the total number of rows is said to be 16 using the grid

// square defined previously

_numRows = 16,

// Define the number of columns on the game board, from left to right, based on the

// game board grid defined previously. The total number of columns is 12 but since

// we count position 0 as a column, we represent the number as 11 instead

_numColumns = 11,

// Define the limits of movement of the player's character on the game board in

// pixels, returning the left-, right-, top- and bottom-most positions the

// character can be placed. This is to ensure the player is not able to move

// their character onto the parts of the game board that show the score, the time

// remaining, etc.

_characterBounds = {

left: 0,

right: _numColumns * _grid.width,

top: 2 * _grid.height,

bottom: (_numRows - 2) * _grid.height

},

// Define an array containing the pixel positions of each of the 17 rows as

// measured from the left-most edge of the game board - each is essentially a

// multiple of the grid width. This allows easy access to pixel positions by

// row number.

_rows = (function() {

var output = [],

index = 0,

length = _numRows;

for (; index < length; index++) {

output.push(index * _grid.width);

}

return output;

}()),

// Define an array containing the pixel positions of each of the 12 columns as

// measured from the top-most edge of the game board - each is essentially a

// multiple of the grid height. This allows easy access to pixel positions by

// column number.

_columns = (function() {

var output = [],

index = 0,

length = _numColumns;

for (; index < length; index++) {

output.push(index * _grid.height);

}

return output;

}());

// Listen for the "game-load" event, which will be fired once all our code modules

// are configured

Frogger.observer.subscribe("game-load", function() {

// Publish the "game-board-initialize" event, passing along relevant information

// about the game board for other code modules to use to ensure they draw their

// images to the correct place on the board, and allow the character to only

// move between certain limits as defined in this code module

Frogger.observer.publish("game-board-initialize", {

// Pass across the number of rows and columns the board consists of

numRows: _numRows,

numColumns: _numColumns,

// Pass across arrays representing the pixel positions of each of the rows

// and columns on the board to simplify the drawing of images onto the <canvas>

// element in the correct place

rows: _rows,

columns: _columns,

// Pass across the width and height of each grid square on the game board

grid: {

width: _grid.width,

height: _grid.height

},

// Pass across the object containing the left, right, top and bottom positions

// in pixels which the player's character is allowed to move within on the

// game board

characterBounds: _characterBounds

});

});

}(Frogger));

清单 11-14 展示了我们如何使用自定义的 Arcade Classic 字体将当前和高分以及其他文本添加到<canvas>元素中。通过将所有代码一起存储在一个模块中,该模块处理文本和游戏状态消息的呈现,例如“游戏结束”和“你赢了!”对于游戏板,如果在画布上呈现文本时出现问题,我们确切地知道应该在哪里查找。

清单 11-14。在游戏板上添加文本,包括分数和消息,如“游戏结束”

// Define a code module to add text-based visuals to the game board, e.g. the score, high

// score, and any informative text for the player about the game state, such as "Game Over"

// or "You Win!"

(function(Frogger) {

// Define the text size and font name to use for the text. You can find the Arcade

// Classic font for download for free online athttp://bit.ly/arcade_font

var _font = "67px Arcade Classic",

// Define variables to store the current game state locally in this module

_score = 0,

_highScore = 0,

_gameWon = false,

_gameOver = false,

// Define a variable to store the initialized data from the game board module

// defined previously - this will be populated later with data from that module

_gameBoard = {};

// Define a function to render the player's score and high score to the <canvas> element

function renderScore() {

// Select the font face and size

Frogger.drawingSurface.font = _font;

// Right-align text at the position we define to draw the text at

Frogger.drawingSurface.textAlign = "end";

// Write the text "1-UP", right-aligned to the 4th column position and ending half

// a row down from the top of the game board in white (hex color value #FFF)

Frogger.drawingSurface.fillStyle = "#FFF";

Frogger.drawingSurface.fillText("1-UP", _gameBoard.columns[3], _gameBoard.grid.height / 2);

// Write out the current score in red (hex color value #F00) right-aligned beneath

// the "1-UP" text previously drawn to the <canvas>

Frogger.drawingSurface.fillStyle = "#F00";

Frogger.drawingSurface.fillText(_score, _gameBoard.columns[3], _gameBoard.grid.height);

// Write the text "HI-SCORE", right-aligned to the 8th column position and ending

// half a row down from the top of the game board in white (hex color value #FFF)

Frogger.drawingSurface.fillStyle = "#FFF";

Frogger.drawingSurface.fillText("HI-SCORE", _gameBoard.columns[8],         _gameBoard.grid.height / 2);

// Write out the current high score in red (hex color value #F00) right-aligned

// beneath the "HI-SCORE" text previously drawn to the <canvas>

Frogger.drawingSurface.fillStyle = "#F00";

Frogger.drawingSurface.fillText(_highScore, _gameBoard.columns[8], _gameBoard.grid.height);

}

// Define a function to render the text "GAME OVER" to the <canvas>. This will only be

// called when the game is over

function renderGameOver() {

// Use the Arcade Classic font as previously defined, and write the text centered

// around the given drawing position in white

Frogger.drawingSurface.font = _font;

Frogger.drawingSurface.textAlign = "center";

Frogger.drawingSurface.fillStyle = "#FFF";

// Write the text center aligned within the <canvas> and at the 9th row position

// from the top of the game board

Frogger.drawingSurface.fillText("GAME OVER", Frogger.drawingSurfaceWidth / 2,         _gameBoard.rows[9]);

}

// Define a function to render the text "YOU WIN!" to the <canvas> which will be called

// when the player has won the game by reaching the home base position five times

function renderGameWon() {

// Use the Arcade Classic font as previously defined, and write the text centered

// around the given drawing position in yellow (hex value #FF0)

Frogger.drawingSurface.font = _font;

Frogger.drawingSurface.textAlign = "center";

Frogger.drawingSurface.fillStyle = "#FF0";

// Write the text center aligned within the <canvas> and at the 9th row position

// from the top of the game board

Frogger.drawingSurface.fillText("YOU WIN!", Frogger.drawingSurfaceWidth / 2,         _gameBoard.rows[9]);

}

// Define a function to render the "TIME" label in the bottom-right corner of the

// game board

function renderTimeLabel() {

// Use the Arcade Classic font as previously defined, and write the text centered

// around the given drawing position in yellow (hex value #FF0)

Frogger.drawingSurface.font = _font;

Frogger.drawingSurface.textAlign = "end";

Frogger.drawingSurface.fillStyle = "#FF0";

// Write the text right aligned within the <canvas> and in the bottom right corner

// of the game board

Frogger.drawingSurface.fillText("TIME", Frogger.drawingSurfaceWidth, Frogger.        drawingSurfaceHeight);

}

// Define a function to render the text-based visuals to the game board as appropriate

// depending on the current game state - we'll connect this up later to be called

// once on every cycle of the game loop

function render() {

renderScore();

renderTimeLabel();

// Only render the "GAME OVER" text if the game is actually over

if (_gameOver) {

renderGameOver();

}

// Only render the "YOU WIN!" text if the players has won the game

if (_gameWon) {

renderGameWon();

}

}

// When the game logic publishes a message declaring that the player has won the game,

// set the local variable to indicate this also so that the "YOU WIN!" text will be

// drawn onto the <canvas> during any following execution of the game loop

Frogger.observer.subscribe("game-won", function() {

_gameWon = true;

});

// When the game logic module publishes a message indicating that the game has been

// lost, set the local variable to reflect this fact so that the "GAME OVER" text gets

// written to the <canvas> element on the next cycle around the game loop

Frogger.observer.subscribe("game-over", function() {

_gameOver = true;

});

// Reset the local variables indicating the game state if the game logic has forced

// a game state reset to occur

Frogger.observer.subscribe("reset", function() {

_gameOver = false;

_gameWon = false;

});

// Update the local score variable when the player's score changes throughout the

// course of the game. The updated score will then be written onto the <canvas> on

// the next cycle of the game loop

Frogger.observer.subscribe("score-change", function(newScore) {

_score = newScore;

});

// Update the local high score variable when the game's high score changes throughout

// the course of the game. The updated high score will then be drawn to the <canvas>

// on the next cycle of the game loop

Frogger.observer.subscribe("high-score-change", function(newHighScore) {

_highScore = newHighScore;

});

// Subscribe to the "game-board-initialize" event fired by the previous code module,

// storing the game board properties and settings in a local variable

Frogger.observer.subscribe("game-board-initialize", function(gameBoard) {

_gameBoard = gameBoard;

// Start listening to the "render-base-layer" event, fired from within the game

// loop, and execute the render() function when it occurs, drawing the text onto

// the game board in the appropriate position for each cycle of the game loop

Frogger.observer.subscribe("render-base-layer", render);

});

}(Frogger));

该是我们把背景图像添加到背景画布上的时候了,这就是我们在清单 11-15 中要做的,以及渲染玩家剩余的生命数和玩家角色到达目标的剩余时间。因为背景是静态的,我们只会在游戏开始时绘制一次,之后就不需要再触摸或修改了。我们将用于游戏板的图像如图 11-4 所示,可以通过 http://bit.ly/frogger_gameboard 下载与本章的代码清单一起使用。

清单 11-15。画出游戏背景,剩余生命数,剩余时间

// Define a code module to draw the game board background image to the background <canvas>

// element. We will draw the image once only since it is static and will not change - all

// graphical elements that could change are drawn to the main <canvas> element instead.

(function(Frogger) {

// To draw an image file onto the <canvas> we need to create a new <img> element to

// contain the image first

var _background = document.createElement("img");

// Once the image has loaded, draw the image onto the background <canvas> element's

// drawing surface, starting at the top-left corner and covering the full width and

// height of the drawing surface

_background.addEventListener("load", function() {

Frogger.backgroundDrawingSurface.drawImage(_background, 0, 0, Frogger.drawingSurfaceWidth,         Frogger.drawingSurfaceHeight);

}, false);

// Setting the "src" attribute of the <img> causes the file to load immediately, which

// is why it was essential to configure our "load" event handler first. We load the

// file named "gameboard.gif" which contains the background of the game board. This

// will only be drawn once since we are not within the game loop at this point. By

// splitting the background out into a separate element, we avoid needing to redraw

// the background each time the game loop executes since it is static.

_background.src = "gameboard.gif";

}(Frogger));

// Define a code module to show the number of lives the player has remaining, and how much

// time remains before automatically losing a life, within the <canvas> element

(function(Frogger) {

// Define an array, to be populated later, which will represent the number of lives the

// player has remaining

var _lives = [],

// Define a variable indicating the time remaining on the countdown before the

// player automatically loses a life, represented as a percentage, starting at

// 100% and counting down to 0

_timeRemainingAsPercentage = 100,

// Define a variable for storing the game board properties and settings

_gameBoard;

// Define a subclass of Frogger.ImageSprite to represent the individual image found

// at position 720px from the left and 80px from the top of the sprite map image which

// is 40px wide by 40px tall and depicts a small frog to be used to denote a remaining

// life

function Life(left, top) {

// The left and top parameters indicate the starting position of this instance of

// the Life "class". We pass those parameters directly onto the parent

// Frogger.ImageSprite() constructor function

Frogger.ImageSprite.call(this, left, top);

}

// Inherit properties and methods from the Frogger.ImageSprite "class"

Life.prototype = new Frogger.ImageSprite();

Life.prototype.constructor = Life;

// Set the dimensions and location of the remaining life image from within the larger

// sprite map image file

Life.prototype.spriteLeft = 720;

Life.prototype.spriteTop = 80;

Life.prototype.width = 40;

Life.prototype.height = 40;

// Define a function to be executed when the game board has initialized, passing along

// the properties and settings from the game board code module

function initialize(gameBoard) {

// Define a variable representing the position from the top of the game board

// to display the remaining lives

var lifePositionTop;

// Store the game board properties and settings in a local variable within this

// code module

_gameBoard = gameBoard;

// Set the lifePositionTop variable to the appropriate position in the bottom-left

// corner of the game board

lifePositionTop = (_gameBoard.numRows - 1) * _gameBoard.grid.height;

// Define five lives for the player by populating the _lives array with five

// instances of the Life "class", each one initialized with its starting position

// from left to right along the bottom-left corner of the game board

_lives = [

// Each life is displayed at the same position from the top of the game board

// and each spaced horizontally according to the width of the individual

// image so they sit right beside each other

new Life(0, lifePositionTop),

new Life(1 * Life.prototype.width, lifePositionTop),

new Life(2 * Life.prototype.width, lifePositionTop),

new Life(3 * Life.prototype.width, lifePositionTop),

new Life(4 * Life.prototype.width, lifePositionTop)

];

// Listen for the "render-base-layer" event fired from within the game loop and

// execute the render() function, defined further down, when it is called

Frogger.observer.subscribe("render-base-layer", render);

}

// Define a function to render the number of lives remaining on the game board

function renderLives() {

var index = 0,

length = _lives.length,

life;

// Loop through the number of remaining lives stored in the _lives array, and

// call the renderAt() method of each of the Life "class" instances contained

// within, drawing the life on the game board at the appropriate position

for (; index < length; index++) {

life = _lives[index];

life.renderAt(life.left, life.top);

}

}

// Define a function to render the time remaining as a green rectangular bar along the

// bottom edge of the game board

function renderTimeRemaining() {

// Define the width of the rectangle. When full, this will be the width of 10

// columns on the game board. As the time remaining decreases, the width will

// decrease accordingly

var rectangleWidth = _timeRemainingAsPercentage * _gameBoard.rows[10],

// Define the height of the rectangle, which will always be half of one grid

// square on the game board

rectangleHeight = _gameBoard.grid.height / 2,

// Define the left-hand edge, in pixels, where the rectangle should be drawn

// from on the <canvas>. Since the countdown should appear to be decreasing

// from the left to the right, this will be the inverse of the time remaining

// percentage, multiplied by the full width of the rectangle

rectangleLeft = (1 - _timeRemainingAsPercentage) * _gameBoard.rows[10],

// Define the top edge, in pixels, where the rectangle should be drawn from

// on the <canvas> element. This will be the bottom edge of the game board so

// we need to subtract the desired height of the rectangle from the height

// of the game board itself

rectangleTop = Frogger.drawingSurfaceHeight - rectangleHeight;

// Set the drawing context to draw in green (hex color #0F0)

Frogger.drawingSurface.fillStyle = "#0F0";

// Draw the rectangle on the game board at the given positions

Frogger.drawingSurface.fillRect(rectangleLeft, rectangleTop, rectangleWidth,         rectangleHeight);

}

// Define a function to draw the remaining lives and time remaining on the game board,

// executed when the "render-base-layer" event is fired from within the game loop

function render() {

renderLives();

renderTimeRemaining();

}

// When the game logic module informs us that the player has lost a life, we remove

// the last entry from the _lives array, which removes the right-most life image from

// the bottom-left corner of the canvas, indicating the correct number of lives

// remaining

Frogger.observer.subscribe("player-lost-life", function() {

_lives.pop();

});

// When the game logic module informs us that the time remaining for the player to

// reach the goal has changed, we store the new value returned as a percentage

Frogger.observer.subscribe("time-remaining-change", function(newTimeRemainingPercentage) {

_timeRemainingAsPercentage = newTimeRemainingPercentage;

});

// When the game board initializes its properties and settings, execute the

// initialize() function

Frogger.observer.subscribe("game-board-initialize", initialize);

}(Frogger));

A978-1-4302-6269-5_11_Fig4_HTML.jpg

图 11-4。

The game board background image, containing all the static elements in one image file

背景可见,并且我们的基本代码已经就绪,可以从精灵贴图中提取各个图像,现在是时候为我们的游戏板定义障碍了,包括车辆、木头、乌龟和玩家试图达到的目标,以及为每个目标显示的图像和动画。清单 11-16 显示了这一点。

清单 11-16。为游戏板上的障碍和目标创建图像和动画

// Define a namespace to store the individual obstacles and images to place on the game

// board as "classes" representing the individual images from the sprite map for each

Frogger.Image = (function(Frogger) {

// Define a race car obstacle whose starting position on the x-axis can be set when

// instantiated

function RaceCar(left) {

Frogger.ImageSprite.call(this, left);

}

// The race car is defined as the image found in the sprite map at position 0px x 80px

// respectively from the left and top edges of the sprite map image file

RaceCar.prototype = new Frogger.ImageSprite();

RaceCar.prototype.constructor = RaceCar;

RaceCar.prototype.spriteLeft = 0;

RaceCar.prototype.spriteTop = 80;

// Define a bulldozer obstacle

function Bulldozer(left) {

Frogger.ImageSprite.call(this, left);

}

// The bulldozer is the image found at position 80px x 80px within the sprite map

Bulldozer.prototype = new Frogger.ImageSprite();

Bulldozer.prototype.constructor = Bulldozer;

Bulldozer.prototype.spriteLeft = 80;

Bulldozer.prototype.spriteTop = 80;

// Define a turbo race car obstacle

function TurboRaceCar(left) {

Frogger.ImageSprite.call(this, left);

}

// The turbo race car is the image found at position 160px x 80px within the sprite map

TurboRaceCar.prototype = new Frogger.ImageSprite();

TurboRaceCar.prototype.constructor = TurboRaceCar;

TurboRaceCar.prototype.spriteLeft = 160;

TurboRaceCar.prototype.spriteTop = 80;

// Define a road car obstacle

function RoadCar(left) {

Frogger.ImageSprite.call(this, left);

}

// The road car is the image found at position 240px x 80px within the sprite map

RoadCar.prototype = new Frogger.ImageSprite();

RoadCar.prototype.constructor = RoadCar;

RoadCar.prototype.spriteLeft = 240;

RoadCar.prototype.spriteTop = 80;

// Define a truck obstacle

function Truck(left) {

Frogger.ImageSprite.call(this, left);

}

// The truck is the image found at position 320px x 80px within the sprite map, with a

// width of 122px as opposed to the standard 80px width of the other individual images

Truck.prototype = new Frogger.ImageSprite();

Truck.prototype.constructor = Truck;

Truck.prototype.spriteLeft = 320;

Truck.prototype.spriteTop = 80;

Truck.prototype.width = 122;

// Define a short log obstacle

function ShortLog(left) {

Frogger.ImageSprite.call(this, left);

}

// The short log is the image found at position 0px x 160px within the sprite map, with

// a width of 190px

ShortLog.prototype = new Frogger.ImageSprite();

ShortLog.prototype.constructor = ShortLog;

ShortLog.prototype.spriteLeft = 0;

ShortLog.prototype.spriteTop = 160;

ShortLog.prototype.width = 190;

// Define a medium log obstacle

function MediumLog(left) {

Frogger.ImageSprite.call(this, left);

}

// The medium log is the image found at position 0px x 240px within the sprite map,

// with a width of 254px

MediumLog.prototype = new Frogger.ImageSprite();

MediumLog.prototype.constructor = MediumLog;

MediumLog.prototype.spriteLeft = 0;

MediumLog.prototype.spriteTop = 240;

MediumLog.prototype.width = 254;

// Define a long log obstacle

function LongLog(left) {

Frogger.ImageSprite.call(this, left);

}

// The long log is the image found at position 240px x 160px within the sprite map,

// with a width of 392px

LongLog.prototype = new Frogger.ImageSprite();

LongLog.prototype.constructor = LongLog;

LongLog.prototype.spriteLeft = 240;

LongLog.prototype.spriteTop = 160;

LongLog.prototype.width = 392;

// Define a turtle obstacle. There are two types of turtle obstacle on the game board,

// one representing a group of two turtles and one representing a group of three

// turtles. Both types of turtle obstacle have some shared behavior which is defined

// in this "class" which acts as a base for both obstacles to inherit from.

function Turtle(left) {

Frogger.ImageSprite.call(this, left);

}

Turtle.prototype = new Frogger.ImageSprite();

Turtle.prototype.constructor = Turtle;

// The turtles will animate and appear to dip underwater on occasion. We need to

// know when the turtle is underwater so that if the player's character is positioned

// above the turtle at that point, they will lose a life. This will be handled by the

// collision detection code later, but for now we just need to create a method to

// tell us when the turtle in underwater

Turtle.prototype.isUnderwater = function() {

var isUnderwater = false,

// Get a reference to the current animation of the turtle diving underwater

// and resurfacing

animation = this.animations[this.currentAnimation];

// The turtle is deemed to be underwater when it is showing the furthestmost image

// from the sprite map in the animation sequence. This is represented by the

// largest number in the animation frame sequence which we can get using the

// Math.max() method in JavaScript. If the current animation sequence value matches

// this furthestmost image in the sprite map, the turtle is underwater.

if (animation.getSequenceValue() === Math.max.apply(Math, animation.sequence)) {

isUnderwater = true;

}

return isUnderwater;

};

// Define an obstacle representing a group of two turtles together

function TwoTurtles(left) {

Turtle.call(this, left);

}

// Inherit from the Turtle base "class" defined previously

TwoTurtles.prototype = new Turtle();

TwoTurtles.prototype.constructor = TwoTurtles;

// The group of two turtles is the image found at position 320px x 240px within the

// sprite map, with a width of 130px

TwoTurtles.prototype.spriteLeft = 320;

TwoTurtles.prototype.spriteTop = 240;

TwoTurtles.prototype.width = 130;

// Override the reset() method to define and auto-play the animation of the turtle

// diving and surfacing

TwoTurtles.prototype.reset = function() {

Turtle.prototype.reset.call(this);

// Register the dive and surface animation which plays each frame in the sequence

// at a frame rate of 200 milliseconds, and loops once it reaches the end of the

// sequence. The numbers in the sequence represent the multiples of offset of the

// width of the individual image to grab the animating image from - essentially

// switching between a number of side-by-side images from the sprite map file to

// give the illusion of movement

this.registerAnimation({

"diveAndSurface": {

sequence: [0, 1, 2, 3, 3, 2, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],

loop: true,

rate: 200

}

});

// Play the animation straight away

this.playAnimation("diveAndSurface");

};

// Define an obstacle representing a group of three turtles together

function ThreeTurtles(left) {

Turtle.call(this, left);

}

// Inherit from the Turtle base "class" defined previously

ThreeTurtles.prototype = new Turtle();

ThreeTurtles.prototype.constructor = ThreeTurtles;

// The group of three turtles is the image found at position 0px x 320px within the

// sprite map, with a width of 200px

ThreeTurtles.prototype.spriteLeft = 0;

ThreeTurtles.prototype.spriteTop = 320;

ThreeTurtles.prototype.width = 200;

// Register the dive and surface animation as before, but animating over a greater

// number of frames and at a slower animation rate than with the group of two turtles

ThreeTurtles.prototype.reset = function() {

Turtle.prototype.reset.call(this);

this.registerAnimation({

"diveAndSurface": {

sequence: [0, 1, 2, 3, 3, 3, 2, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],

loop: true,

rate: 300

}

});

this.playAnimation("diveAndSurface");

};

// Define a "class" representing the frog image displayed when the player's character

// reaches the goal

function GoalFrog(left) {

Frogger.ImageSprite.call(this, left);

}

// The goal frog is the image found at position 640px x 80px within the sprite map

GoalFrog.prototype = new Frogger.ImageSprite();

GoalFrog.prototype.constructor = GoalFrog;

GoalFrog.prototype.spriteLeft = 640;

GoalFrog.prototype.spriteTop = 80;

// Override the moveTo() method so that this image cannot be moved from its place

// on the game board once it has been placed down

GoalFrog.prototype.moveTo = function() {};

// Define a "class" representing the goal the player will be aiming to meet with at

// the far end of the game board from their start position

function Goal(left) {

Frogger.ImageSprite.call(this, left);

}

// Since the goal is drawn onto the game board as part of the background <canvas>

// we do not need to draw it again here, so we specify the image position as being

// a transparent block from the sprite map image so effectively nothing is actually

// drawn to the canvas. We can still take advantage of the other features of the

// ImageSprite "class", however, to simplify collision checking later on, which

// will tell us when the player's character has reached a goal

Goal.prototype = new Frogger.ImageSprite();

Goal.prototype.constructor = Goal;

Goal.prototype.spriteLeft = 800;

Goal.prototype.spriteTop = 320;

// Override the moveTo() method so that the goal cannot be moved from its place

// on the game board once it has been placed down

Goal.prototype.moveTo = function() {};

// Add a custom property to this "class" to denote whether the goal instance has been

// met by the player's character

Goal.prototype.isMet = false;

// Expose the "classes" defined in this module to the wider code base within the

// Frogger.Image namespace

return {

RaceCar: RaceCar,

Bulldozer: Bulldozer,

RoadCar: RoadCar,

TurboRaceCar: TurboRaceCar,

Truck: Truck,

ShortLog: ShortLog,

MediumLog: MediumLog,

LongLog: LongLog,

TwoTurtles: TwoTurtles,

ThreeTurtles: ThreeTurtles,

GoalFrog: GoalFrog,

Goal: Goal

};

}(Frogger));

定义完障碍后,就该定义代码、图像和动画来表现玩家的角色及其在游戏板上的蛙泳动作了,如清单 11-17 所示。

清单 11-17。玩家的角色

// Define a code module to represent the player's character on the game board and to

// handle its movement and behavior according to the current game state

Frogger.Character = (function(Frogger) {

// Define a variable to store the image representing the player's character

var _character,

// Define a variable to store the game board properties and settings

_gameBoard = {},

// Define a variable to denote the starting row of the player's character on the

// game board

_startRow = 14,

// Define a variable to denote the starting columns of the player's character on

// the game board - essentially centered

_startColumn = 6,

// Define a variable to store the current row the player's character has reached

_currentRow = _startRow,

// Define a Boolean variable to indicate whether the player's character is

// currently frozen in place, as happens temporarily when the player loses a life

// or reaches the goal

_isFrozen = false;

// Define a "class" to represent the player's frog character, inheriting from the

// Frogger.ImageSprite "class". The left and top values passed in on instantiation

// reflect the starting position in pixels of the player's character from the top-left

// hand corner of the game board

function Character(left, top) {

Frogger.ImageSprite.call(this, left, top);

// Register five animations to play when the player loses a life or when the

// character is moved in any of four different directions - up, down, left or right

this.registerAnimation({

// When the player loses a life, switch between the three images found starting

// at 640px from the left of the sprite map image at a rate of 350 milliseconds,

// stopping on the last image

"lose-life": {

spriteLeft: 640,

sequence: [0, 1, 2],

rate: 350

},

// When the player's character moves up a row on the game board, switch between

// the two images found starting at the left-hand edge of the sprite map

"move-up": {

spriteLeft: 0,

sequence: [1, 0]

},

// When the player's character moves right on the game board, switch between

// the two images found starting at 160px from left-hand edge of the sprite map

"move-right": {

spriteLeft: 160,

sequence: [1, 0]

},

// When the player's character moves down on the game board, switch between

// the two images found starting at 320px from left-hand edge of the sprite map

"move-down": {

spriteLeft: 320,

sequence: [1, 0]

},

// When the player's character moves left on the game board, switch between

// the two images found starting at 480px from left-hand edge of the sprite map

"move-left": {

spriteLeft: 480,

sequence: [1, 0]

}

});

}

// Inherit from the Frogger.ImageSprite "class"

Character.prototype = new Frogger.ImageSprite();

Character.prototype.constructor = Character;

// Define the individual images for the player's character sprite as being found at

// position 0px x 0px within the sprite map image file

Character.prototype.spriteLeft = 0;

Character.prototype.spriteTop = 0;

// Define a method to move the character up one row on the game board

Character.prototype.moveUp = function() {

// Move the top position of the character up by the height of one grid square

// on the game board

this.top -= _gameBoard.grid.height;

// Ensure the character does not move outside of the bounds restricting its

// movement around the game board - we don't want it appearing on top of the

// score at the top of the screen

if (this.top < _gameBoard.characterBounds.top) {

this.top = _gameBoard.characterBounds.top;

}

// Play the animation named "move-up", making it look like the character is moving

this.playAnimation("move-up");

// Keep track of the current row the character sits upon

_currentRow--;

};

// Define a method to move the character down one row on the game board

Character.prototype.moveDown = function() {

// Move the top position of the character down by the height of one grid square

// on the game board

this.top += _gameBoard.grid.height;

// Ensure the character does not move outside of the bounds restricting its

// movement around the game board - we don't want it appearing on top of the

// countdown timer at the base of the screen

if (this.top > _gameBoard.characterBounds.bottom) {

this.top = _gameBoard.characterBounds.bottom;

}

// Play the animation named "move-down", making it look like the character is moving

this.playAnimation("move-down");

// Keep track of the current row the character sits upon

_currentRow++;

};

// Define a method to move the character one column to the left on the game board

Character.prototype.moveLeft = function() {

// Move the position of the character on the game board left by the width of one

// grid square on the game board

this.left -= _gameBoard.grid.width;

// Ensure the character does not move outside of the bounds restricting its

// movement around the game board - we don't want it disappearing off the side

if (this.left < _gameBoard.characterBounds.left) {

this.left = _gameBoard.characterBounds.left;

}

// Play the animation named "move-left", making it look like the character is moving

this.playAnimation("move-left");

};

// Define a method to move the character one column to the right on the game board

Character.prototype.moveRight = function() {

// Move the position of the character on the game board right by the width of one

// grid square on the game board

this.left += _gameBoard.grid.width;

// Ensure the character does not move outside of the bounds restricting its

// movement around the game board - we don't want it disappearing off the side

if (this.left > _gameBoard.characterBounds.right) {

this.left = _gameBoard.characterBounds.right;

}

// Play the animation named "move-right", making it look like the character is moving

this.playAnimation("move-right");

};

// Define a function which returns the current position of the player's character in

// pixels from the top of the game board

function getTop() {

// Look up the top position in pixels from the game board properties by the current

// row the character is sitting upon

return _gameBoard.rows[_currentRow];

}

// Define a function which hides the player's character from display

function hide() {

// Call the hide() method on the instance of the Character "class" that will

// represent the player's character

_character.hide();

}

// Define a function which moves the player's character in one of four possible

// directions - up, down, left, or right

function move(characterDirection) {

// Only move the player's character if it is not deemed to be frozen in place

if (!_isFrozen) {

// Call the appropriate method on the Character instance based on the

// direction the character is to move in

if (characterDirection === Frogger.direction.LEFT) {

_character.moveLeft();

} else if (characterDirection === Frogger.direction.RIGHT) {

_character.moveRight();

} else if (characterDirection === Frogger.direction.UP) {

_character.moveUp();

} else if (characterDirection === Frogger.direction.DOWN) {

_character.moveDown();

}

// Publish an event to the rest of the code modules, indicating that the

// player's position has been moved by the player

Frogger.observer.publish("player-moved");

}

}

// Define a function to render the player's character on screen

function render() {

// Call the Character instance's renderAt() method, passing along its current

// left and top position

_character.renderAt(_character.left, _character.top);

}

// Define a function, to be executed when the player loses a life, which plays the

// appropriate animation

function loseLife() {

_character.playAnimation("lose-life");

}

// Define a function to move the player's character to the given position in pixels

// from the left-hand edge of the game board - this will be used when the character

// is sitting on a moving object to keep the character aligned with that object

function setPosition(left) {

// Ensure the character does not move outside of its defined bounds on the game

// board

if (left > _gameBoard.characterBounds.right) {

left = _gameBoard.characterBounds.right;

} else if (left < _gameBoard.characterBounds.left) {

left = _gameBoard.characterBounds.left;

}

// Move the character's position from the left-hand edge of the game board to match

// the given position

_character.moveTo(left);

}

// Define a function to reset the player's character's position on the game board

function reset() {

_character.reset();

// Reset the local variable indicating the current row the character sits upon

_currentRow = _startRow;

}

// Define a function to return the current position of the character on the game board

function getPosition() {

return _character.getPosition();

}

// Define a function to set the local _isFrozen variable to true, indicating that the

// player's character's position on the game board should be frozen in place

function freeze() {

_isFrozen = true;

}

// Define a function to set the local _isFrozen variable to false, indicating that the

// player's character is free to move around the game board

function unfreeze() {

_isFrozen = false;

}

// Define a function to be executed when the game board has initialized, passing along

// the properties and settings from the game board code module

function initialize(gameBoard) {

_gameBoard = gameBoard;

// Initialize an instance of the Character "class" to represent the player's

// character, setting its start position on the game board

_character = new Character(_gameBoard.columns[_startColumn], _gameBoard.rows[_startRow]);

// Ensure the local render() function is executed when the "render-character"

// event is fired from within the game loop to draw the player's character on

// the screen

Frogger.observer.subscribe("render-character", render);

}

// When the game logic module informs us that the player has lost a life, execute the

// loseLife() function to play the appropriate animation

Frogger.observer.subscribe("player-lost-life", loseLife);

// When the game logic informs us the player's position needs to be reset, execute the

// reset() function

Frogger.observer.subscribe("reset", reset);

// When the player has reached the goal, hide the player from the screen temporarily

Frogger.observer.subscribe("player-at-goal", hide);

// When the game logic tells us the player's character must stay in place on the

// game board, we set the appropriate local variable to reflect this

Frogger.observer.subscribe("player-freeze", freeze);

// When the game logic tells us the player's character is free to move around the

// game board again, we set the appropriate local variable to reflect this

Frogger.observer.subscribe("player-unfreeze", unfreeze);

// When the game board module initializes its properties and settings, execute the

// initialize() function

Frogger.observer.subscribe("game-board-initialize", initialize);

// When the player presses the arrow keys on the keyboard, move the player's

// character in the appropriate direction on the game board

window.addEventListener("keydown", function(event) {

// Define the key codes for the arrow keys

var LEFT_ARROW = 37,

UP_ARROW = 38,

RIGHT_ARROW = 39,

DOWN_ARROW = 40;

// Execute the move() function, passing along the correct direction based on the

// arrow key pressed. Ignore any other key presses

if (event.keyCode === LEFT_ARROW) {

move(Frogger.direction.LEFT);

} else if (event.keyCode === RIGHT_ARROW) {

move(Frogger.direction.RIGHT);

} else if (event.keyCode === UP_ARROW) {

move(Frogger.direction.UP);

} else if (event.keyCode === DOWN_ARROW) {

move(Frogger.direction.DOWN);

}

}, false);

// When the player taps in certain places on the game board on their touch-sensitive

// screen, move the player's character in the appropriate direction on the game board

// according to where the screen has been tapped. This is useful since users with

// touch screens are typically on mobile devices that do not have access to

// physical keyboards to press the arrow keys to move the character.

Frogger.canvas.addEventListener("touchstart", function(event) {

// Get a reference to the position of the touch on the screen in pixels from the

// top-left position of the touched element, in this case the game board

var touchLeft = event.targetTouches[0].clientX,

touchTop = event.targetTouches[0].clientY;

// Execute the move() function, passing along the correct direction based on the

// position tapped on the game board

if (touchLeft < (Frogger.drawingSurfaceWidth / 8)) {

move(Frogger.direction.LEFT);

} else if (touchLeft > (3 * Frogger.drawingSurfaceWidth / 8)) {

move(Frogger.direction.RIGHT);

} else if (touchTop < (Frogger.drawingSurfaceHeight / 8)) {

move(Frogger.direction.UP);

} else if (touchTop > (3 * Frogger.drawingSurfaceHeight / 8)) {

move(Frogger.direction.DOWN);

}

}, false);

// Expose the local getTop(), getPosition() and setPosition() methods so they are

// available to other code modules

return {

getTop: getTop,

getPosition: getPosition,

setPosition: setPosition

};

}(Frogger));

看一下图 11-2 中的游戏棋盘,可以发现所有相似的障碍物都一起包含在游戏棋盘的同一行上,所有相似的车辆都在一起,所有的乌龟都在一起,所有的原木都在一起,所有的目标都在一起。因此,我们可以定义游戏棋盘上一个“行”的行为,如清单 11-18 所示,它将包含对该 raw 中障碍物对象的引用,以及该行中所有障碍物将遵循的运动速度和方向。

清单 11-18。为可以以相同速度向某个方向移动的障碍物创建一个“行”

// Define a code module to define the types of obstacle rows that exist on the game board,

// representing a road-type row which will house vehicles, a water row containing log

// obstacles, a water row containing turtle obstacles, and a goal row containing the

// locations the player's character aims to reach to win the game

Frogger.Row = (function() {

// Define a base row "class" containing the shared code required for each different

// type of specialist row on the game board

function Row(options) {

options = options || {};

// Define the direction of obstacles moving on this row, defaults to moving left

this.direction = options.direction || Frogger.direction.LEFT;

// Define the set of obstacles to place on this row and move

this.obstacles = options.obstacles || [];

// Define the top position, in pixels, of where this row sits on the game board

this.top = options.top || 0;

// Define the speed with which obstacles on this row move in the given direction

// as a factor of the render rate set in game loop

this.speed = options.speed || 1;

}

Row.prototype = {

// Define a method to render each of the obstacles in the correct place on the

// current row

render: function() {

var index = 0,

length = this.obstacles.length,

left,

obstaclesItem;

// Loop through each of the obstacles within this row

for (; index < length; index++) {

obstaclesItem = this.obstacles[index];

// Update the left position, in pixels, of this obstacle based on its

// current position along with the direction and speed of movement

left = obstaclesItem.getPosition().left + ((this.direction === Frogger.direction.                RIGHT ? 1 : -1) * this.speed);

// Adjust the left position such that if the obstacle falls off one edge of

// the game board, it then appears to return from the other edge

if (left < -obstaclesItem.getWidth()) {

left = Frogger.drawingSurfaceWidth;

} else if (left >=Frogger.drawingSurfaceWidth) {

left = -obstaclesItem.getWidth();

}

// Move the obstacle and draw it on the game board in the updated position

obstaclesItem.moveTo(left);

obstaclesItem.renderAt(left, this.top);

}

},

// Define a method to return the top position, in pixels, of this row

getTop: function() {

return this.top;

},

// Define a method to detect whether the player's character is currently colliding

// with an obstacle on this row

isCollision: function(characterPosition) {

var index = 0,

length = this.obstacles.length,

obstaclesItem,

isCollision = false;

// Loop through each of the obstacles on this row

for (; index < length; index++) {

obstaclesItem = this.obstacles[index];

// If the player's character touches the current obstacle, a collision

// has taken place and we return this fact to the calling code

if (Frogger.intersects(obstaclesItem.getPosition(), characterPosition)) {

isCollision = true;

}

}

return isCollision;

},

// Define a method to reset the obstacles on this row to their default state and

// position on the game board

reset: function() {

var index = 0,

length = this.obstacles.length;

// Loop through each of the obstacles within this row, and call their reset()

// methods in turn

for (; index < length; index++) {

this.obstacles[index].reset();

}

}

};

// Define a new "class" representing a road-type row, containing vehicle obstacles which

// inherits from our base Row "class"

function Road(options) {

Row.call(this, options);

}

Road.prototype = new Row();

Road.prototype.constructor = Road;

// Define a new "class" representing a row containing logs floating on water which

// inherits from our base Row "class"

function Log(options) {

Row.call(this, options);

}

Log.prototype = new Row();

Log.prototype.constructor = Log;

// Override the isCollision() method, reversing its behavior. If the player's character

// touches a log it is safe, however it should be considered a collision if it touches

// the water beneath rather than the obstacle itself

Log.prototype.isCollision = function(characterPosition) {

// Return the opposite Boolean state returned by a normal call to the isCollision()

// method

return !Row.prototype.isCollision.call(this, characterPosition);

};

// Override the render() method so that when the player's character lands on a log,

// it gets transported along the water with the log

Log.prototype.render = function() {

// If the player's character is on this row, update its position based on the

// direction and speed of motion of the log the player has landed on

if (Frogger.Character.getTop() === this.getTop()) {

Frogger.Character.setPosition(Frogger.Character.getPosition().left +             ((this.direction === Frogger.direction.RIGHT ? 1 : -1) * this.speed));

}

// Call the inherited render() method to draw the log in its new position

Row.prototype.render.call(this);

};

// Define a new "class" representing a row containing turtles swimming in the water

// which inherits from our Log "class" as it shares similarities

function Turtle(options) {

Log.call(this, options);

}

Turtle.prototype = new Log();

Turtle.prototype.constructor = Turtle;

// Override the isCollision() method such that it behaves like the same method on

// the Log "class" apart from when the turtle obstacle has dipped underwater, in which

// case there will always be a collision if the player's character is on this row

Turtle.prototype.isCollision = function(characterPosition) {

var isCollision = Log.prototype.isCollision.call(this, characterPosition);

return this.obstacles[0].isUnderwater() || isCollision;

};

// Define a new "class" representing the goal row the player's character is aiming for

// in order to win the game, which inherits from our base Row "class"

function Goal(options) {

// The goals placed within this row never move so we always force the speed

// property to be 0

options.speed = 0;

Row.call(this, options);

}

Goal.prototype = new Row();

Goal.prototype.constructor = Goal;

// Override the isCollision() method to detect if the player's character has reached

// one of the available goals stored in this row

Goal.prototype.isCollision = function(characterPosition) {

var index = 0,

length = this.obstacles.length,

obstaclesItem,

isCollision = true;

// Loop through the goals in this row to find out if the player has reached one

// of them

for (; index < length; index++) {

obstaclesItem = this.obstacles[index];

// If this goal has not been reached before and the player's character is

// positioned above the goal, fire the "player-at-goal" event so the game logic

// module registers that the goal has been reached

if (!obstaclesItem.isMet && Frogger.intersects(obstaclesItem.getPosition(),             characterPosition)) {

this.obstacles[index].isMet = true;

Frogger.observer.publish("player-at-goal");

isCollision = false;

// Add the image of the goal-reached frog to the row within the goal

// reached so the user can see that they have reached this goal before

this.obstacles.push(new Frogger.Image.GoalFrog(obstaclesItem.getPosition().left));

}

}

return isCollision;

};

// Return the "classes" defined in this code module for use in the rest of our code

return {

Road: Road,

Log: Log,

Turtle: Turtle,

Goal: Goal

};

}(Frogger));

定义了基本的“行”和障碍类型后,是时候创建行和障碍的实际实例,并把它们放在游戏板上,如清单 11-19 所示。

清单 11-19。定义行和障碍,并将它们放在游戏板上

// Define a code module to add rows containing obstacles to the game board for the player

// to avoid or make contact with in order to progress from the bottom to the top of the

// game board in order to win the game by reaching each of the five goals without losing

// all their lives or the allocated time running out

(function(Frogger) {

// Define variables to store the populated rows on the game board, and the properties

// and settings of the game board itself

var _rows = [],

_gameBoard = {};

// Define a function to be called when the game board has initialized onto which we

// place our rows and obstacles

function initialize(gameBoard) {

_gameBoard = gameBoard;

// Add elevent rows of obstacles to the game board

_rows = [

// Add a goal row to the 3rd row on the game board (the rows start from index

// 0), containing five goals positioned in the respective places according to

// the designation on the game board background image

new Frogger.Row.Goal({

top: _gameBoard.rows[2],

obstacles: [new Frogger.Image.Goal(33, 111), new Frogger.Image.Goal(237, 315), new                 Frogger.Image.Goal(441, 519), new Frogger.Image.Goal(645, 723), new Frogger.Image.                Goal(849, 927)]

}),

// Add a row of medium-length logs to the 4th row on the game board, moving

// right at a rate of 5 pixels per each time the game loop is called to

// render this row within the <canvas>

new Frogger.Row.Log({

top: _gameBoard.rows[3],

direction: Frogger.direction.RIGHT,

speed: 5,

// Add three medium-sized log obstacles to the game board, spaced out evenly

obstacles: [new Frogger.Image.MediumLog(_gameBoard.columns[1]), new Frogger.Image.                MediumLog(_gameBoard.columns[6]), new Frogger.Image.MediumLog(_gameBoard.                columns[10])]

}),

// Add a row of turtles, grouped in twos, on the 5th row of the game board,

// moving left (the default direction) at a rate of 6 pixels on each turn of the

// game loop

new Frogger.Row.Turtle({

top: _gameBoard.rows[4],

speed: 6,

// Add four obstacles spaced out across the width of the game board

obstacles: [new Frogger.Image.TwoTurtles(_gameBoard.columns[0]), new Frogger.Image.                TwoTurtles(_gameBoard.columns[3]), new Frogger.Image.TwoTurtles(_gameBoard.                columns[6]), new Frogger.Image.TwoTurtles(_gameBoard.columns[9])]

}),

// Add a row of long-length logs to the 6th row on the game board, moving right

// at a rate of 7 pixels on each turn of the game loop

new Frogger.Row.Log({

top: _gameBoard.rows[5],

direction: Frogger.direction.RIGHT,

speed: 7,

// Add two long-length log obstacles to this row

obstacles: [new Frogger.Image.LongLog(_gameBoard.columns[1]), new Frogger.Image.                LongLog(_gameBoard.columns[10])]

}),

// Add a row of short-length logs to the 7th row of the game board, moving right

// at a rate of 3 pixels each time the game loop is called

new Frogger.Row.Log({

top: _gameBoard.rows[6],

direction: Frogger.direction.RIGHT,

speed: 3,

// Add three short-length logs to this row

obstacles: [new Frogger.Image.ShortLog(_gameBoard.columns[1]), new Frogger.Image.                ShortLog(_gameBoard.columns[6]), new Frogger.Image.ShortLog(_gameBoard.columns[10])]

}),

// Add a row of turtles, grouped in threes, on the 8th row of the game board,

// moving left at a rate of 5 pixels each time the game loop is called

new Frogger.Row.Turtle({

top: _gameBoard.rows[7],

speed: 5,

obstacles: [new Frogger.Image.ThreeTurtles(_gameBoard.columns[0]), new Frogger.                Image.ThreeTurtles(_gameBoard.columns[3]), new Frogger.Image.ThreeTurtles                (_gameBoard.columns[7]), new Frogger.Image.ThreeTurtles(_gameBoard.columns[10])]

}),

// Add a set of truck-style vehicle obstacles to the 10th row of the game

// board (the 9th row is considered a "safe" row that contains no obstacles)

new Frogger.Row.Road({

top: _gameBoard.rows[9],

speed: 3,

obstacles: [new Frogger.Image.Truck(_gameBoard.columns[1]), new Frogger.Image.                Truck(_gameBoard.columns[7])]

}),

// Add a set of turbo race car obstacles to the 11th row of the game board,

// moving right at a fast rate

new Frogger.Row.Road({

top: _gameBoard.rows[10],

direction: Frogger.direction.RIGHT,

speed: 12,

obstacles: [new Frogger.Image.TurboRaceCar(_gameBoard.columns[1]), new Frogger.                Image.TurboRaceCar(_gameBoard.columns[7])]

}),

// Add a set of simple road car obstacles to the 12th row of the game board

new Frogger.Row.Road({

top: _gameBoard.rows[11],

speed: 4,

obstacles: [new Frogger.Image.RoadCar(_gameBoard.columns[1]), new Frogger.Image.                RoadCar(_gameBoard.columns[7])]

}),

// Add a set of bulldozer-style obstacles to the 13th row of the game board

new Frogger.Row.Road({

top: _gameBoard.rows[12],

direction: Frogger.direction.RIGHT,

speed: 3,

obstacles: [new Frogger.Image.Bulldozer(_gameBoard.columns[1]), new Frogger.Image.                Bulldozer(_gameBoard.columns[7])]

}),

// Add a set of race car obstacles to the 14th row of the game board, which is

// one row above where the player's character's starting position is

new Frogger.Row.Road({

top: _gameBoard.rows[13],

speed: 4,

obstacles: [new Frogger.Image.RaceCar(_gameBoard.columns[2]), new Frogger.Image.                RaceCar(_gameBoard.columns[6])]

})

];

// With the rows and obstacles initialized, connect the local render() function to

// the "render-base-layer" event fired from within the game loop to draw those

// obstacles onto the game board

Frogger.observer.subscribe("render-base-layer", render);

}

// Define a function to render each of the defined rows of obstacles onto the game board

function render() {

var row,

index = 0,

length = _rows.length;

// Loop through each row calling its render() method, which in turn calls the

// render() method of each of the obstacles stored within it

for (; index < length; index++) {

row = _rows[index];

row.render();

}

}

// Define a function to detect whether a collision has occured between the player's

// character and the obstacles within each row

function isCollision() {

var collided = false,

row,

index = 0,

length = _rows.length;

// Loop through each row calling its isCollision() method, which determines

// whether the obstacles on that row come into contact with the player's

// character on the game board

for (; index < length; index++) {

row = _rows[index];

if (Frogger.Character.getTop() === row.getTop()) {

collided = row.isCollision(Frogger.Character.getPosition());

if (collided) {

break;

}

}

}

// If a collision has occured, trigger the "collision" event which the game logic

// module uses to cause the player to lose a life

if (collided) {

Frogger.observer.publish("collision");

}

return collided;

}

// Define a function to reset each of the rows to reset to their initial state

function reset() {

var row;

// Loop through each row calling its reset() method, which in turn calls the

// reset() method of each of the obstacles within that row

for (var index = 0, length = _rows.length; index < length; index++) {

row = _rows[index];

row.reset();

}

}

// When the game logic wishes the game board to reset, call the local reset() function

Frogger.observer.subscribe("reset", reset);

// When the game loop wishes to check for collisions, call the local isCollision()

// function, which will fire a "collision" event if a collision occurs

Frogger.observer.subscribe("check-collisions", isCollision);

// When the game board has initialized its properties and settings, call the local

// initialize() function to place the rows and obstacles onto the game board

Frogger.observer.subscribe("game-board-initialize", initialize);

}(Frogger));

我们游戏的所有部分都已经就绪,我们可以简单地通过触发game-load事件来启动游戏,这将启动游戏循环运行,将所有内容绘制到屏幕上,并检查玩家角色和障碍物之间的碰撞,如清单 11-20 所示。使用键盘上的箭头键,或点击触摸屏,在游戏板内向上、向下、向左或向右移动玩家角色,以达到屏幕顶部的目标并赢得游戏。

清单 11-20。开始游戏

// Now the code modules have been registered, kick off the game logic and start the game

Frogger.observer.publish("game-load");

我已经上传了这个游戏的完整代码,以及支持它所需要的字体和图片到我的 GitHub 账户,供你在这里查看: https://github.com/denodell/frogger 。你可以通过网址 http://denodell.github.io/frogger 直接从 GitHub 运行游戏。

我希望通过遵循创建这个游戏的代码,你将有灵感和知识来使用这里收集的一些相同的技术来创建自己的游戏,包括精灵、游戏循环和碰撞检测。要进一步了解 画布 API 游戏开发的广泛领域,我建议您查看以下在线资源:

摘要

在本章中,我们已经介绍了通过 JavaScript 在 HTML5 <canvas>元素上绘制形状、文本和图像的基础知识。我们已经看到了一系列的技术和步骤,它们构成了游戏开发中的大部分代码,我们也看到了如何使用这些技术来构建一个真正的、可运行的游戏。

在下一章中,我们将看到另一个本地 JavaScript API,我们可以用它来构建一个视频聊天客户端,将两个远程用户连接在一起,所有这些都在浏览器中完成。

十二、将 WebRTC 用于视频聊天

最近几年,浏览器制造商突破了以前认为的原生 JavaScript 代码的界限,增加了 API 来支持许多新功能,包括我们在前一章中看到的基于像素的绘制,现在,终于有了一种方法,使用称为 Web 实时通信(WebRTC) API 的 API 通过互联网将多媒体数据(视频和音频)从一个浏览器传输到另一个浏览器,所有这些都不需要使用插件。虽然在撰写本文时,这种支持目前只出现在 Chrome、Firefox 和 Opera 的桌面版本中,占全球 web 使用量的 50%多一点(source:bit . ly/cani use _ webrtc),但我觉得这种技术对互联网通信的未来非常重要,作为开发人员,我们需要在这种 API 还处于采用阶段时就了解它。

在本章中,我们将介绍 WebRTC 规范的基础知识,包括如何使用对等网络从连接到设备的网络摄像头和麦克风发送和接收数据,以及如何使用 JavaScript 在浏览器中构建简单的视频聊天客户端。

WebRTC 规范

WebRTC 规范最初是由 Google 发起的,目的是包含在他们的 Chrome 浏览器中,并承诺提供一个 API,允许开发人员:

  • 检测设备功能,包括基于连接到设备的摄像头和/或麦克风的视频和/或音频支持
  • 从设备连接的硬件捕获媒体数据
  • 编码并通过网络传输媒体
  • 在浏览器之间建立直接的对等连接,自动处理防火墙或网络地址转换(NAT)带来的任何复杂问题
  • 解码媒体流,将其呈现给最终用户,使音频和视频同步,并消除任何音频回声

WebRTC 项目页面可以通过 www。webrtc。org ,包括规范的当前状态,并包含跨浏览器互操作性的注释,以及演示和到其他网站的链接。

接入网络摄像头和麦克风

如果我们想使用 WebRTC 规范创建一个视频聊天应用,我们需要确定如何从连接到运行该应用的设备的网络摄像头和麦克风访问数据。JavaScript API 方法navigator.getUserMedia()是其中的关键。我们通过传递三个参数来调用它:一个详细说明我们希望访问哪种类型的媒体的对象(videoaudio是目前唯一可用的属性选项),一个在成功建立到网络摄像头和/或麦克风的连接时执行的回调函数,以及一个在没有成功建立到网络摄像头和/或麦克风的连接时执行的回调函数。当执行该方法时,浏览器会提示用户当前网页正试图访问他们的网络摄像头和/或麦克风,并提示他们是否允许或拒绝访问,如图 12-1 所示。如果他们拒绝访问,或者用户没有网络摄像头或麦克风连接,则执行第二个回调函数,指示多媒体数据不能被访问;否则,执行第一个回调函数。

A978-1-4302-6269-5_12_Fig1_HTML.jpg

图 12-1。

The user must allow or deny access to their webcam and microphone before we can access them

在撰写本文时,在某些浏览器中通过带前缀的方法名来访问getUserMedia()方法,但是在所有浏览器中具有相同的输入参数序列。因此,我们可以编写一个小的 polyfill 来支持在所有支持的浏览器中访问这个 API,这样我们就可以在整个代码中通过getUserMedia()方法名来访问它。清单 12-1 中的代码显示了一个简单的 polyfill,允许在所有支持的 web 浏览器中通过相同的方法调用来访问网络摄像头和麦克风。

清单 12-1。getUserMedia() API 的简单聚合填充

// Expose the browser-specific versions of the getUserMedia() method through the standard

// method name. If the standard name is already supported in the browser (as it is in Opera),

// use that, otherwise fall back to Mozilla's, Google's or Microsoft's implementations as

// appropriate for the current browser

navigator.getUserMedia = navigator.getUserMedia || navigator.mozGetUserMedia ||

navigator.webkitGetUserMedia || navigator.msGetUserMedia;

出于安全原因,WebRTC 仅在使用 web 服务器访问试图使用它的文件时工作,而不是直接从浏览器中加载的本地文件运行代码。有许多方法可以在本地启动 web 服务器来运行本章中的代码清单,尽管最简单的方法可能是从 http://httpd.apache.org 下载 Apache web 服务器软件并在您的机器上运行。如果你更喜欢冒险,请跳到第十四章,在那里我将解释如何使用 Node.js 应用平台创建和运行本地 web 服务器。

我们可以使用清单 12-1 中的 polyfill 来调用getUserMedia()方法,尝试访问用户的网络摄像头和麦克风,如清单 12-2 所示。

清单 12-2。接入网络摄像头和麦克风

// Define a function to execute if we are successfully able to access the user's webcam and

// microphone

function onSuccess() {

alert("Successful connection made to access webcam and microphone");

}

// Define a function to execute if we are unable to access the user's webcam and microphone -

// either because the user denied access or because of a technical error

function onError() {

throw new Error("There has been a problem accessing the webcam and microphone");

}

// Using the polyfill from Listing 12-1, we know the getUserMedia() method is supported in the

// browser if the method exists

if (navigator.getUserMedia) {

// We can now execute the getUserMedia() method, passing in an object telling the browser

// which form of media we wish to access ("video" for the webcam, "audio" for the

// microphone). We pass in a reference to the onSuccess() and onError() functions which

// will be executed based on whether the user grants us access to the requested media types

navigator.getUserMedia({

video: true,

audio: true

}, onSuccess, onError);

} else {

// Throw an error if the getUserMedia() method is unsupported by the user's browser

throw new Error("Sorry, getUserMedia() is not supported in your browser");

}

当运行清单 12-2 中的代码时,如果您发现没有提示您允许访问网络摄像头和麦克风,请检查以确保您正在 web 服务器上的 HTML 页面的上下文中运行您的代码,无论是在本地计算机上还是通过网络连接。如果你的浏览器显示你正在使用file:///协议浏览一个 URL,那么你将需要通过http://使用一个网络服务器来运行它。在 Chrome 和 Opera 中,浏览器会在浏览器标签中显示的页面名称旁边使用红色圆圈图标,直观地表明它当前正在录制您的音频和/或视频,而 Firefox 会在地址栏中显示绿色摄像头图标来表明这一事实。

既然我们能够访问用户的网络摄像头和麦克风,我们需要能够对他们返回的数据做一些事情。由getUserMedia()方法触发的onSuccess()回调方法被传递一个参数,该参数表示由设备提供的原始数据流,以便在应用中使用。我们可以将这个流直接传递到页面上的 HTML5 <video>元素的输入中,允许用户从他们自己的网络摄像头和麦克风接收数据。清单 12-3 中的代码展示了如何使用浏览器的window.URL.createObjectURL()方法来实现这一点,它创建了一个特定的本地 URL,可以用来访问多媒体流以这种方式提供的数据。

清单 12-3。将网络摄像头和麦克风传回给用户

// Use the getUserMedia() polyfill from Listing 12-1 for best cross-browser support

// Define a function to execute if we are successfully able to access the user's webcam and

// microphone, taking the stream of data provided and passing it as the "src" attribute of a

// new <video> element, which is then placed onto the current HTML page, relaying back to the

// user the output from theirwebcam and microphone

function onSuccess(stream) {

// Create a new <video> element

var video = document.createElement("video"),

// Get the browser to create a unique URL to reference the binary data directly from

// the provided stream, as it is not a file with a fixed URL

videoSource = window.URL.createObjectURL(stream);

// Ensure the <video> element start playing the video immediately

video.autoplay = true;

// Point the "src" attribute of the <video> element to the generated stream URL, to relay

// the data from the webcam and microphone back to the user

video.src = videoSource;

// Add the <video> element to the end of the current page

document.body.appendChild(video);

}

function onError() {

throw new Error("There has been a problem accessing the webcam and microphone");

}

if (navigator.getUserMedia) {

navigator.getUserMedia({

video: true,

audio: true

}, onSuccess, onError);

} else {

throw new Error("Sorry, getUserMedia() is not supported in your browser");

}

既然我们有能力访问用户的网络摄像头和麦克风,并将他们的输入反馈给用户,我们就有了完全在浏览器中运行的简单双向视频聊天应用的开端。

创建一个简单的视频聊天 Web 应用

让我们通过创建一个浏览器内视频聊天 web 应用来了解 WebRTC 规范的重要部分。

视频聊天应用的要点是,我们从正在浏览同一 web 服务器的两个用户的设备中捕获视频和音频,并将捕获的视频和音频流从一个设备传输到另一个设备,反之亦然。我们已经介绍了如何使用getUserMedia() API 方法从用户设备捕获数据流,所以让我们研究一下如何建立一个呼叫,以及如何发送和接收数据流,以便构建我们的视频聊天应用。

连接和信号

我们需要一种方法将一个设备直接连接到另一个设备,并在两者之间保持开放的数据连接。我们希望在两台设备之间建立直接或对等的连接,而不需要中间服务器来中继数据,从而将连接速度以及视频和音频质量保持在最佳水平。无论设备是使用公共 IP 地址直接连接到互联网、位于防火墙之后,还是位于采用网络地址转换(NAT)与本地网络中大量设备共享有限公共 IP 地址的路由器设备之后,这种连接都必须是可能的。

WebRTC 依赖于交互式连接建立(ICE)框架,该框架是一种规范,允许两个设备直接相互建立对等连接,而不管一个或两个设备是否直接连接到公共 IP 地址、防火墙后面或采用 NAT 的网络上。它简化了整个连接过程,所以我们不必担心这个问题,并且它可以在支持的浏览器中使用RTCPeerConnection“class”接口。

建立对等连接的过程包括创建这个RTCPeerConnection“类”的一个实例,传入一个包含一个或多个能够帮助建立设备间连接的服务器的详细信息的对象。这些服务器可以采用两种服务器协议,以不同的方式帮助建立这种连接:NAT 的会话穿越实用程序(STUN)和使用 NAT 周围中继的穿越(TURN)。

STUN 服务器将设备的内部 IP 地址和未使用的本地端口号映射到其外部可见的 IP 地址和未使用的面向外部的端口号(在本地网络之外),以便可以使用该端口将流量直接路由到本地设备。这是一个快速而有效的系统,因为服务器仅在初始连接时需要,并且一旦建立就可以移动到其他任务,然而仅在相当简单的 NAT 配置上工作,其中只有一个设备,即客户端,实际上位于 NAT 之后。任何支持多个 NAT 设备或其他大型企业系统的设置都不能使用这种类型的服务器建立对等连接。这就是另一个选择,转而介入的地方。

TURN 服务器使用公共互联网上的中继 IP 地址,通常是公共 TURN 服务器本身的 IP 地址,将一个设备连接到另一个设备,其中两个设备都在 NAT 之后。因为它起着中继的作用,所以它必须中继通信双方之间的所有数据,因为直接连接是不可能的。这意味着数据带宽被有效地减少,并且服务器必须在整个视频呼叫期间都是活动的以中继数据,这使得它不是一个有吸引力的解决方案,尽管即使在这样复杂的 NAT 设置中仍然允许视频呼叫发生。

ICE framework 使用适合所连接设备的 STUN 或 TURN 服务器,在视频聊天双方之间建立连接。ICE 的配置是通过传递一个被称为候选服务器的列表,然后对其进行优先级排序。它使用会话描述协议(SDP)来通知远程用户本地用户的连接细节,这被称为要约,并且远程用户在它识别要约时做同样的事情,被称为应答。一旦双方都有了对方的详细资料,他们之间的对等连接就建立了,通话就可以开始了。虽然看起来有很多步骤,但这一切都发生得非常快。

您的应用中有许多公共 STUN 服务器可供使用,包括一些由 Google 运行和操作的服务器,您可以在 http:// bit. ly/ stun_ list 上找到这些服务器的在线列表。公共 TURN 服务器不太常见,因为它们中继数据,因此需要大量的可用带宽,这可能是昂贵的;然而,一项服务可以通过number。维亚吉尼。ca ,这将允许你建立一个免费账户,通过他们的服务器运行你自己的眩晕/变身服务器。

包含 ICE 服务器详细信息的示例配置对象可能如下所示,用于建立与RTCPeerConnection“类”的对等连接:

{

iceServers: [{

// Mozilla's public STUN server

url: "stun:23.21.150.121"

}, {

// Google's public STUN server

url: "stun:stun.l.google.com:19302"

}, {

// Create your own TURN server athttp://numb.viagenie.ca

// escape any extended characters in the username, e.g. the @ character becomes %40

url: "turn:numb.viagenie.ca",

username: "denodell%40gmail.com",

credential: "password"

}]

}

现在,如果你仔细阅读,你可能会注意到一个令人困惑的情况,关于我们的点对点连接的设置,即:如果我们还不知道我们连接的是谁,我们如何通过中介在两个对等点之间建立连接?当然,答案是我们不能,这让我们进入了理解如何建立视频通话的下一部分——信令通道。

我们需要做的是提供一种机制,在呼叫建立之前和建立期间在连接方之间发送消息。每一方都需要监听另一方发送的消息,并在需要时发送消息。这种消息传递可以使用 Ajax 来处理,尽管这样效率会很低,因为双方都必须频繁地询问中间服务器另一方是否发送了任何消息——大多数情况下答案是“没有”。更好的解决方案是更新的EventSource API(在bit . ly/event source _ basics了解更多信息)或 WebSockets 技术(在bit . ly/web sockets _ basics了解更多信息),这两者都允许服务器将消息“推送”到连接的客户端后一种方法的好处是,它在支持 WebRTC 的所有浏览器中都受支持,因此使用这种方法不需要多种填充或变通方法。

将 Firebase 服务用于简单的信令

我们不用构建自己的服务器来支持 WebSocket 连接,而是可以利用现有的基于云的解决方案来代表我们在连接方之间存储和传输数据。一个这样的解决方案是 Firebase(可从 http:// firebase 获得)。com ,如图 12-2 所示,它提供了一个简单的在线数据库和一个小的 JavaScript API,用于使用 WebSockets 通过 web 访问数据(如果运行 WebSockets 的浏览器不支持 web sockets,它实际上会退回到其他解决方案)。其免费的基本解决方案足以满足我们作为信令服务连接和配置视频聊天客户端的需求。

A978-1-4302-6269-5_12_Fig2_HTML.jpg

图 12-2。

Firebase provides a data-access API perfect for realtime communication between devices

访问 Firebase 网站并注册一个免费帐户。然后,您将收到一封电子邮件,告知您新创建的在线数据库的详细访问信息。每一个都有自己独特的 URL 和在页面上使用的<script>标签的详细信息,以加载在代码中使用的 Firebase API。不管创建的 URL 是什么,<script>标签总是相同的:

<script src="https://cdn.firebase.com/v0/firebase.js"></script

对于使用给定名称 https://glaring-fire-9593.firebaseio.com/ ,创建的 URL,可以使用以下 JavaScript 代码在您的代码中建立连接,并在数据库中保存数据:

var dataRef = new Firebase("https://glaring-fire-9593.firebaseio.com

dataRef.set("I am now writing data into Firebase!");

Firebase 中的数据以类似于 JavaScript 对象的方式存储为嵌套对象。事实上,当您访问您的唯一 URL 时,您能够以您应该非常熟悉的伪 JSON 格式查看您的数据。可以使用 Firebase API 的set()方法将数据添加到数据库中,并且我们能够检测任何连接的客户端何时使用 API 的on()方法添加了数据。这意味着在我们的视频聊天中,一旦使用 ICE 框架建立了连接,双方就可以通知对方,当双方从对方收到所需的信息时,视频通话就可以开始了。

现在,如果我们在任何人都可以访问的公共 web 服务器上托管我们的视频聊天客户端,我们将需要一种方法来限制哪些人可以相互聊天,否则我们会冒着完全陌生的人相互建立呼叫的风险,因为他们是最先连接到服务器的两个用户——我们在这里不是在构建聊天轮盘!我们需要一种方式让特定的用户能够相互连接,一种简单的技术是允许连接的用户创建聊天室,并允许连接的客户能够创建或加入这些聊天室之一。当两个用户加入一个房间时,我们可以限制我们的信令只发生在该房间的用户之间,以便只有指定的各方可以相互通信。我们将允许视频聊天的第一方(发起者)命名他们的聊天室。然后,他们可以将这个房间名称通知给他们的呼叫伙伴,他们将在访问聊天客户端网页时指定这个房间名称。然后,我们将把各方之间发送和接收的要约和应答消息与聊天室名称相关联,并与 ICE 候选人详细信息一起直接连接双方,这样我们就可以通过这种方式将多方相互连接起来。这导致 Firebase 中的数据库结构可以在 JSON 中以类似于下面的格式表示:

{

"chatRooms": {

"room-001": {

"offer": "...",

"answer": "...",

"candidate": "..."

},

"room-002": {

...

}

}

}

Firebase 在需要客户端和服务器互相推送消息的应用中使用简单,这是它与视频聊天客户端一起使用的优势,提供了在同一个聊天中将双方连接在一起所需的信令通道。

构建视频聊天客户端

我们已经到了这样一个地步,我们可以将我们所学的一切整合在一起,构建一个视频聊天客户端,我们将构建一个如图 12-3 所示的例子。我们可以访问本地用户的网络摄像头和麦克风,我们可以建立一个信号通道,锁定到一个已知的聊天室名称,以共享有关如何将聊天双方相互连接的信息,然后我们可以使用 ICE 框架在双方之间建立一个对等连接,通过该连接流式传输视频和音频数据,以显示在远程方的浏览器窗口中。

A978-1-4302-6269-5_12_Fig3_HTML.jpg

图 12-3。

Example video call using our simple chat client

我们将代码分成三个文件,第一个是包含两个 HTML5 <video>标签的 HTML 页面,一个向用户显示本地视频,另一个显示远程方的视频——音频也通过这个标签输出。该页面还包含 Start Call 和 Join Call 按钮,前者将随机生成一个聊天室名称,然后可以传递给远程方,后者允许用户输入一个聊天室名称来加入,通过使用相同的聊天室名称来连接双方。第二个和第三个文件是 JavaScript 文件,前者用于配置一个可重用的“类”,用于创建支持视频聊天所必需的代码,后者是一个特定的使用示例,根据 HTML 网页中显示的当前应用本身的需求进行配置。

我们首先需要建立一个 web 服务器,因为getUserMedia() API 只能在运行在httphttp协议上的 HTML 页面的上下文中工作。有许多产品可供使用;然而,最简单的方法可能是从 httpd 下载、安装和配置 Apache。阿帕奇。org

一旦您在本地或使用托管的在线解决方案启动并运行了 web 服务器,您将使用 HTML5 <video>标签创建一个 HTML 页面,在该页面中将呈现来自远程用户的视频和音频。清单 12-4 显示了这样一个 HTML 页面的例子。

清单 12-4。一个简单的 HTML 页面,显示从远程方捕获的视频和音频

<!DOCTYPE html>

<html>

<head>

<title>In-Browser Video Chat</title>

<meta charset="utf-8">

<style>

body {

font-family: Helvetica, Arial, sans-serif;

}

.videos {

position: relative;

}

.video {

display: block;

position: absolute;

top: 0;

left: 0;

}

.local-video {

z-index: 1;

border: 1px solid black;

}

</style>

</head>

<body>

<h1>In-Browser Video Chat</h1>

<!-- Display the video chat room name at the top of the page -->

<p id="room-name"></p>

<!-- Create buttons which start a new video call or join an existing video call -->

<button id="start-call">Start Call</button>

<button id="join-call">Join Call</button>

<!-- Display the local and remote video streams, with the former displayed smaller

and layered above to the top left corner of the other -->

<div class="videos">

<video class="video local-video" id="local-video" width="200" autoplay muted></video>

<video class="video" id="remote-video" width="600" autoplay></video>

</div>

<!-- Load the script to enable Firebase support within this application -->

<script src="https://cdn.firebase.com/v0/firebase.js"></script

<!-- Load the VideoChat "class" definition -->

<script src="Listing12-5.js"></script>

<!-- Load the code to instantiate the VideoChat "class" and connect it to this page -->

<script src="Listing12-6.js"></script>

</body>

</html>

清单 12-5 中的代码显示了如何创建 VideoChat“类”,它创建了必要的代码来处理本地浏览器和远程浏览器之间的通信,在两者之间传输视频和音频。

清单 12-5。支持浏览器内视频聊天的视频聊天“类”

// Define a "class" that can be used to create a peer-to-peer video chat in the browser. We

// have a dependency on Firebase, whose client API script should be loaded in the browser

// before this script executes

var VideoChat = (function(Firebase) {

// Polyfill the required browser features to support video chat as some are still using

// prefixed method and "class" names

// The PeerConnection "class" allows the configuration of a peer to peer connection

// between the current web page running on this device and the same running on another,

// allowing the addition of data streams to be passed from one to another, allowing for

// video chat-style appliations to be built

var PeerConnection = window.mozRTCPeerConnection || window.webkitRTCPeerConnection,

// The RTCSessionDescription "class" works together with the RTCPeerConnection to

// initialize the peer to peer data stream using the Session Description Protocol (SDP)

SessionDescription = window.mozRTCSessionDescription || window.RTCSessionDescription,

// The IceCandidate "class" allows instances of peer to peer "candidates" to be created

//  - a candidate provides the details of the connection directly to our calling

// partner, allowing the two browsers to chat

IceCandidate = window.mozRTCIceCandidate || window.RTCIceCandidate,

// Define the two types of participant in a call, the person who initiated it and the

// person who responded

_participantType = {

INITIATOR: "initiator",

RESPONDER: "responder"

},

// Define an object containing the settings we will use to create our PeerConnection

// object, allowing the two participants in the chat to locate each other's IP

// addresses over the internet

_peerConnectionSettings = {

// Define the set of ICE servers to use to attempt to locate the IP addresses

// of each of the devices participating in the chat. For best chances of

// success, we include at least one server supporting the two different

// protocols, STUN and TURN, which provide this IP lookup mechanism

server: {

iceServers: [{

// Mozilla's public STUN server

url: "stun:23.21.150.121"

}, {

// Google's public STUN server

url: "stun:stun.l.google.com:19302"

}, {

// Create your own TURN server athttp://numb.viagenie.ca

url: "turn:numb.viagenie.ca",

username: "denodell%40gmail.com",

credential: "password"

}]

},

// For interoperability between different browser manufacturers' code, we set

// this DTLS/SRTP property to "true" as it is "true" by default in Firefox

options: {

optional: [{

DtlsSrtpKeyAgreement: true

}]

}

};

// Polyfill the getUserMedia() method, which allows us to access a stream of data provided

// by the user's webcam and/or microphone

navigator.getUserMedia = navigator.getUserMedia || navigator.mozGetUserMedia || navigator.webkitGetUserMedia || navigator.msGetUserMedia;

// If the current browser does not support the "classes" and methods required for video

// chat, throw an error - at the time of writing, Google Chrome, Mozilla Firefox and

// Opera are the only browsers supporting the features required to support video chat

if (!navigator.getUserMedia && !window.RTCPeerConnection) {

throw new Error("Your browser does not support video chat");

}

// Define a generic error handler function which will throw an error in the browser

function onError(error) {

throw new Error(error);

}

// Define the VideoChat "class" to use to create a new video chat on a web page

function VideoChat(options) {

options = options || {};

// Allow two callback functions, onLocalStream() and onRemoteStream() to be passed in.

// The former is executed once a connection has been made to the local webcam and

// microphone, and the latter is executed once a connection has been made to the remote

// user's webcam and microphone. Both pass along a stream URL which can be used to

// display the contents of the stream inside a <video> tag within the HTML page

if (typeof options.onLocalStream === "function") {

this.onLocalStream = options.onLocalStream;

}

if (typeof options.onRemoteStream === "function") {

this.onRemoteStream = options.onRemoteStream;

}

// Initialize Firebase data storage using the provided URL

this.initializeDatabase(options.firebaseUrl || "");

// Set up the peer-to-peer connection for streaming video and audio between two devices

this.setupPeerConnection();

}

VideoChat.prototype = {

// Define the participant type for the current user in this chat - the "initiator", the

// one starting the call

participantType: _participantType.INITIATOR,

// Define the participant type for the remote user in this chat - the "responder", the

// one responding to a request for a call

remoteParticipantType: _participantType.RESPONDER,

// Create a property to store the name for the chat room in which the video call will

// take place - defined later

chatRoomName: "",

// Define a property to allow loading and saving of data to the Firebase database

database: null,

// Define a method to be called when a local data stream has been initiated

onLocalStream: function() {},

// Define a method to be called when a remote data stream has been connected

onRemoteStream: function() {},

// Define a method to initialize the Firebase database

initializeDatabase: function(firebaseUrl) {

// Connect to our Firebase database using the provided URL

var firebase = new Firebase(firebaseUrl);

// Define and store the data object to hold all the details of our chat room

// connections

this.database = firebase.child("chatRooms");

},

// Define a method to save a given name-value pair to Firebase, stored against the

// chat room name given for this call

saveData: function(chatRoomName, name, value) {

if (this.database) {

this.database.child(chatRoomName).child(name).set(value);

}

},

// Define a method to load stored data from Firebase by its name and chat room name,

// executing a callback function when that data is found - the connection will wait

// until that data is found, even if it is generated at a later time

loadData: function(chatRoomName, name, callback) {

if (this.database) {

// Make a request for the data asynchronously and execute a callback function once

// the data has been located

this.database.child(chatRoomName).child(name).on("value", function(data) {

// Extract the value we're after from the response

var value = data.val();

// If a callback function was provided to this method, execute it, passing

// along the located value

if (value && typeof callback === "function") {

callback(value);

}

});

}

},

// Define a method to set up a peer-to-peer connection between two devices and stream

// data between the two

setupPeerConnection: function() {

var that = this;

// Create a PeerConnection instance using the STUN and TURN servers defined

// earlier to establish a peer-to-peer connection even across firewalls and NAT

this.peerConnection = new PeerConnection(_peerConnectionSettings.server,         _peerConnectionSettings.options);

// When a remote stream has been added to the peer-to-peer connection, get the

// URL of the stream and pass this to the onRemoteStream() method to allow the

// remote video and audio to be presented within the page inside a <video> tag

this.peerConnection.onaddstream = function(event) {

// Get a URL that represents the stream object

var streamURL = window.URL.createObjectURL(event.stream);

// Pass this URL to the onRemoteStream() method, passed in on instantiation

// of this VideoChat instance

that.onRemoteStream(streamURL);

};

// Define a function to execute when the ICE framework finds a suitable candidate

// for allowing a peer-to-peer data connection

this.peerConnection.onicecandidate = function(event) {

if (event.candidate) {

// Google Chrome often finds multiple candidates, so let's ensure we only

// ever get the first it supplies by removing the event handler once a

// candidate has been found

that.peerConnection.onicecandidate = null;

// Read out the remote party's ICE candidate connection details

that.loadData(that.chatRoomName, "candidate:" + that.remoteParticipantType, function(candidate) {

// Connect the remote party's ICE candidate to this connection forming

// the peer-to-peer connection

that.peerConnection.addIceCandidate(new IceCandidate(JSON.parse(candidate)));

});

// Save our ICE candidate connection details for connection by the remote

// party

that.saveData(that.chatRoomName, "candidate:" + that.participantType, JSON.stringify(event.candidate));

}

};

},

// Define a method to get the local device's webcam and microphone stream and handle

// the handshake between the local device and the remote party's device to set up the

// video chat call

call: function() {

var that = this,

// Set the constraints on our peer-to-peer chat connection. We want to be

// able to support both audio and video so we set the appropriate properties

_constraints = {

mandatory: {

OfferToReceiveAudio: true,

OfferToReceiveVideo: true

}

};

// Get the local device's webcam and microphone stream - prompts the user to

// authorize the use of these

navigator.getUserMedia({

video: true,

audio: true

}, function(stream) {

// Add the local video and audio data stream to the peer connection, making

// it available to the remote party connected to that same peer-to-peer

// connection

that.peerConnection.addStream(stream);

// Execute the onLocalStream() method, passing the URL of the local stream,

// allowing the webcam and microphone data to be presented to the local

// user within a <video> tag on the current HTML page

that.onLocalStream(window.URL.createObjectURL(stream));

// If we are the initiator of the call, we create an offer to any connected

// peer to join our video chat

if (that.participantType === _participantType.INITIATOR) {

// Create an offer of a video call in this chat room and wait for an

// answer from any connected peers

that.peerConnection.createOffer(function(offer) {

// Store the generated local offer in the peer connection object

that.peerConnection.setLocalDescription(offer);

// Save the offer details for connected peers to access

that.saveData(that.chatRoomName, "offer", JSON.stringify(offer));

// If a connected peer responds with an "answer" to our offer, store

// their details in the peer connection object, opening the channels

// of communication between the two

that.loadData(that.chatRoomName, "answer", function(answer) {

that.peerConnection.setRemoteDescription(

new SessionDescription(JSON.parse(answer))

);

});

}, onError, _constraints);

// If we are the one joining an existing call, we answer an offer to set up

// a peer-to-peer connection

} else {

// Load an offer provided by the other party - waits until an offer is

// provided if one is not immediately present

that.loadData(that.chatRoomName, "offer", function(offer) {

// Store the offer details of the remote party, using the supplied

// data

that.peerConnection.setRemoteDescription(

new SessionDescription(JSON.parse(offer))

);

// Generate an "answer" in response to the offer, enabling the

// two-way peer-to-peer connection we need for the video chat call

that.peerConnection.createAnswer(function(answer) {

// Store the generated answer as the local connection details

that.peerConnection.setLocalDescription(answer);

// Save the answer details, making them available to the initiating

// party, opening the channels of communication between the two

that.saveData(that.chatRoomName, "answer", JSON.stringify(answer));

}, onError, _constraints);

});

}

}, onError);

},

// Define a method which initiates a video chat call, returning the generated chat

// room name which can then be given to the remote user to use to connect to

startCall: function() {

// Generate a random 3-digit number with padded zeros

var randomNumber = Math.round(Math.random() * 999);

if (randomNumber < 10) {

randomNumber = "00" + randomNumber;

} else if (randomNumber < 100) {

randomNumber = "0" + randomNumber;

}

// Create a simple chat room name based on the generated random number

this.chatRoomName = "room-" + randomNumber;

// Execute the call() method to start transmitting and receiving video and audio

// using this chat room name

this.call();

// Return the generated chat room name so it can be provided to the remote party

// for connection

return this.chatRoomName;

},

// Define a method to join an existing video chat call using a specific room name

joinCall: function(chatRoomName) {

// Store the provided chat room name

this.chatRoomName = chatRoomName;

// If we are joining an existing call, we must be the responder, rather than

// initiator, so update the properties accordingly to reflect this

this.participantType = _participantType.RESPONDER;

this.remoteParticipantType = _participantType.INITIATOR;

// Execute the call() method to start transmitting and receiving video and audio

// using the provided chat room name

this.call();

}

};

// Return the VideoChat "class" for use throughout the rest of the code

return VideoChat;

}(Firebase));

清单 12-5 中的代码可以如清单 12-6 所示在浏览器中创建一个简单的视频聊天应用,与清单 12-4 中创建的 HTML 页面一起工作。

清单 12-6。使用视频聊天“类”创建浏览器内视频聊天

// Get a reference to the <video id="local-video"> element on the page

var localVideoElement = document.getElementById("local-video"),

// Get a reference to the <video id="remote-video"> element on the page

remoteVideoElement = document.getElementById("remote-video"),

// Get a reference to the <button id="start-call"> element on the page

startCallButton = document.getElementById("start-call"),

// Get a reference to the <button id="join-call"> element on the page

joinCallButton = document.getElementById("join-call"),

// Get a reference to the <p id="room-name"> element on the page

roomNameElement = document.getElementById("room-name"),

// Create an instance of the Video Chat "class"

videoChat = new VideoChat({

// The Firebase database URL for use when loading and saving data to the cloud - create

// your own personal URL athttp://firebase.com

firebaseUrl: "https://glaring-fire-9593.firebaseio.com/

// When the local webcam and microphone stream is running, set the "src" attribute

// of the <div id="local-video"> element to display the stream on the page

onLocalStream: function(streamSrc) {

localVideoElement.src = streamSrc;

},

// When the remote webcam and microphone stream is running, set the "src" attribute

// of the <div id="remote-video"> element to display the stream on the page

onRemoteStream: function(streamSrc) {

remoteVideoElement.src = streamSrc;

}

});

// When the <button id="start-call"> button is clicked, start a new video call and

// display the generated room name on the page for providing to the remote user

startCallButton.addEventListener("click", function() {

// Start the call and get the chat room name

var roomName = videoChat.startCall();

// Display the chat room name on the page

roomNameElement.innerHTML = "Created call with room name: " + roomName;

}, false);

// When the <button id="join-call"> button is clicked, join an existing call by

// entering the room name to join at the prompt

joinCallButton.addEventListener("click", function() {

// Ask the user for the chat room name to join

var roomName = prompt("What is the name of the chat room you would like to join?");

// Join the chat by the provided room name - as long as this room name matches the

// other, the two will be connected over a peer-to-peer connection and video streaming

// will take place between the two

videoChat.joinCall(roomName);

// Display the room name on the page

roomNameElement.innerHTML = "Joined call with room name: " + roomName;

}, false);

因此,我们创建了一个简单而实用的浏览器内视频聊天客户端。你可以进一步扩展这一想法,用特定的登录用户 id 取代聊天室名称的概念,通过一个列出你的“朋友”的界面(如 Skype 等提供的界面)将用户相互连接,但增加的好处是在浏览器内运行,而不需要下载任何特殊的应用或插件来支持这一点。

随着浏览器对本章所涉及的 API 的支持的提高,可以构建的应用的可能性也会提高。通过bit . ly/can use _ WebRTC了解当前浏览器对 WebRTC 的支持水平,并在浏览器中尝试自己的点对点聊天想法。

摘要

在本教程中,我解释了什么是 WebRTC,并演示了如何通过这个 API 从用户的机器上访问网络摄像头和麦克风。然后,我解释了流、对等连接和信令信道的基础知识,当它们一起使用时,有助于支持在浏览器中构建简单的视频聊天客户端。

在下一章中,我将介绍 JavaScript 中的 HTML 模板,以及它在基于 web 的应用中简化服务器返回的数据量的潜力。

十三、使用客户端模板

基于 JavaScript 的单页面 web 应用(其中页面的某些部分会动态更新以响应服务器端数据更新或用户操作)为用户提供了一种体验,这种体验与以前只保留给本机桌面和移动应用的体验非常相似,从而避免了为更新页面的各个部分或向当前页面添加新的用户界面组件而刷新整个页面的需要。在本章中,我们将研究更新当前页面的选项,同时保持要显示的数据和呈现数据的 DOM 结构之间的明显分离。

动态更新页面内容

我们知道,为了通过 JavaScript 在页面上更新或创建 HTML 内容,我们使用文档对象模型(DOM)来改变属性和元素树,以便影响所需的内容变化。这适用于简单和小型的页面更新,但是扩展性不好,因为每个元素需要大量的 JavaScript 代码,并且可能需要复杂和耗时的查找来定位要更新的确切元素。如果在 JavaScript 代码不知道的情况下更新了 HTML 页面结构,我们也有可能找不到需要更新的元素。

我们可以使用元素的innerHTML属性来动态访问和更新该元素中的 HTML,就像它是一个普通的文本字符串一样,而不是逐个节点地操作 DOM 树。唯一的问题是,当复杂的 HTML 结构表示为字符串时,在其中插入动态字符串和其他数据的代码可能难以理解,这使得维护和开发更加困难,这是专业 JavaScript 开发人员不惜一切代价想要避免的。清单 13-1 显示了这样一个例子,文本在添加到页面之前被插入到一长串 HTML 中。

清单 13-1。将 JavaScript 数据与 HTML 字符串组合在一起

var firstName = "Den",

lastName = "Odell",

company = "AKQA",

city = "London",

email = "denodell@me.com",

divElem = document.createElement("div");

// Applying data and strings to HTML structures results in a complicated mess of difficult to

// maintain code

divElem.innerHTML = "<p>Name: <a href=\"mailto:" + email + "\">" + firstName + " " + lastName +

"</a><br>Company: " + company + "</p><p>City: " + city + "</p>";

// Add the new <div> DOM element to the end of the current HTML page once loaded

window.addEventListener("load", function() {

document.body.appendChild(divElem);

}, false);

尽管这种方法很复杂,但是确实需要将 JavaScript 数据(可能通过 Ajax 加载,也可能不通过 Ajax 加载)与动态显示在页面上的 HTML 文本字符串结合起来。在这一章中,我们将讨论解决这个问题的可行方案,主要集中在客户端 HTML 模板解决方案,它允许页面动态更新,同时保持我们希望呈现的数据与用于适当标记它的 HTML 分开。作为专业的 JavaScript 开发人员,这种关注点的分离对我们来说非常重要,因为它可以随着我们的应用的增长而扩展,并且为我们自己和其他项目团队成员带来最小的困惑。

通过 Ajax 动态加载

动态更新页面内容的最简单的解决方案是在服务器端执行数据与 HTML 代码的组合,通过一个简单的 Ajax 调用将组合的 HTML 代码作为字符串返回,我们可以简单地将它放在页面上,也许替换现有元素的内容。当然,这本身并不能解决问题;我们只是将问题从客户端转移到服务器端,然后需要一个合适的解决方案。这样的服务器端模板解决方案有很多,比如 Smarty(对于 PHP,bit . ly/Smarty _ template),Liquid(对于 Ruby,bit . ly/Liquid _ template),Apache Velocity(对于 Java,bit . ly/Velocity _ template),Spark(对于 ASP.NET,bit . ly/Spark _ template)。我们只需要点击服务器提供的特定 web 服务 URL,然后返回一个 HTML 字符串,使用元素的innerHTML属性直接放入页面上的元素中。

这种技术的明显优势在于,它只需要在浏览器中运行很少的 JavaScript 代码。我们只需要一个函数来从服务器加载所需的 HTML,并将其放在页面上指定的元素中。清单 13-2 显示了一个这样的函数的例子,它请求一个 HTML 字符串并把它附加到当前页面。

清单 13-2。通过动态加载 HTML 并用响应填充当前页面

// Define a method to load a string of HTML from a specific URL and place this within a given

// element on the current page

function loadHTMLAndReplace(url, element) {

// Perform an Ajax request to the given URL and populate the given element with the response

var xhr = new XMLHttpRequest(),

LOADED_STATE = 4,

OK_STATUS = 200;

xhr.onreadystatechange = function() {

if (xhr.readyState !== LOADED_STATE) {

return;

}

if (xhr.status === OK_STATUS) {

// Populate the given element with the returned HTML

element.innerHTML = xhr.responseText;

}

};

xhr.open("GET", url);

xhr.send();

}

// Load the HTML from two specific URLs and populate the given elements with the returned markup

loadHTMLAndReplace("/ajax/ticket-form.html", document.getElementById("ticket-form"));

loadHTMLAndReplace("/ajax/business-card.html", document.getElementById("business-card"));

这种技术的缺点是,应用需要频繁的可视化更新来响应变化的数据,最终会从服务器下载大量多余的信息,因为它反映了更新的数据及其周围的标记,而我们真正想要显示的是已经变化的数据。显然,如果数据周围的标记每次都保持不变,就会有冗余数据被下载,这将导致每次下载量更大,因此可能会更慢。根据您的应用,这可能是也可能不是一个大问题,但请始终考虑您的用户,特别是那些传统上速度较慢的移动连接用户,他们可能会为下载的兆字节数据付费,并可能会因为这样的决定而遭受损失。

用一个单独的 HTML 块作为模板效率会高得多,Ajax 请求服务器只提供原始数据(可能是 JSON 格式的),在相关位置填充该模板以产生结果页面结构来更新显示。这就是客户端模板思想发挥作用并找到其主要用例的地方。

客户端模板

模板只是一个文本字符串,其中包含特定的占位符文本标记,在结果输出到当前页面之前,应该用适当的数据替换这些标记。考虑下面的简单模板,它使用双括号标记模式{{}},这在任何其他类型的文本字符串中都不常见,用来表示要替换的文本的位置,以及其值应该用于替换标记的数据变量的名称:

Template:

<p>

Name: <a href="mailto:{{email}}">{{firstName}} {{lastName}}</a><br>

Company: {{company}}

</p>

<p>City: {{city}}</p>

通过将该模板与存储在 JavaScript 数据对象中的值相结合,如下所示:

Data:

{

"firstName": "Den",

"lastName": "Odell",

"email": "denodell@me.com",

"company": "AKQA",

"city": "London"

}

我们将产生一个字符串,然后我们可以输出到我们的页面,包含我们希望显示的文本。通过将模板本地存储在我们的页面中,并且只需要通过 Ajax 更新数据,我们减少了下载多余或重复数据的需要:

Output:

<p>

Name: <a href="mailto:denodell@me.com">Den Odell</a><br>

Company: AKQA

</p>

<p>City: London</p>

不同的模板解决方案使用不同的标记文本模式来表示应该提供数据的点。虽然理论上可以使用任何标记,但重要的是要确保您的标记足够明显,这样它们通常不会出现在您希望显示的模板中的任何其他文本中,否则它们会被意外替换。

在本章的剩余部分,我们将看看 web 应用中客户端模板的一些解决方案,包括其他专业 JavaScript 开发人员使用的一些流行的第三方开放模板库。

没有库的客户端模板

因为客户端模板化是通过字符串替换实现的,所以我们可以围绕用于执行替换的正则表达式,用几行 JavaScript 编写一个非常基本的实现,使用 DOM 元素的innerHTML属性将结果 HTML 或文本字符串附加到我们的页面。清单 13-3 显示了这样一个模板解决方案的例子,它用 JavaScript 对象的属性值替换模板字符串中特殊格式的标记,产生一个 HTML 字符串,然后添加到当前页面。

清单 13-3。通过字符串替换的基本客户端模板

// Define the HTML template to apply data to, using {{ ... }} to denote the data property name

// to be replaced with real data

var template = "<p>Name: <a href=\"mailto:{{email}}\">{{firstName}} {{lastName}}</a><br>Company: {{company}}</p><p>City: {{city}}</p>",

// Define two data objects containing properties to be inserted into the HTML template using

// the property name as key

me = {

firstName: "Den",

lastName: "Odell",

email: "denodell@me.com",

company: "AKQA",

city: "London"

},

bill = {

firstName: "Bill",

lastName: "Gates",

email: "bill@microsoft.com",

company: "Microsoft",

city: "Seattle"

};

// Define a simple function to apply data from a JavaScript object into a HTML template,

// represented as a string

function applyDataToTemplate(templateString, dataObject) {

var key,

value,

regex;

// Loop through each property name in the supplied data object, replacing all instances of

// that name surrounded by {{ and }} with the value from the data object

for (key in dataObject) {

regex = new RegExp("{{" + key + "}}", "g");

value = dataObject[key];

// Perform the replace

templateString = templateString.replace(regex, value);

}

// Return the new, replaced HTML string

return templateString;

}

// Outputs:

// <p>Name: <a href="mailto:denodell@me.com">Den Odell</a><br>Company: AKQA</p>

//     <p>City: London</p>

alert(applyDataToTemplate(template, me));

// Outputs:

// <p>Name: <a href="mailto:bill@microsoft.com">Bill Gates</a><br>Company: Microsoft</p>

//     <p>City: Seattle</p>

alert(applyDataToTemplate(template, bill));

对于简单的模板和 JavaScript 数据,这种解决方案工作得很好;但是,如果需要迭代数组或数据对象,或者添加逻辑来根据某些数据属性的值显示或隐藏不同的部分,这种解决方案是不够的,需要进行相当大的扩展来支持这一点。在这种情况下,最好交给预先编写好的、成熟的第三方开源 JavaScript 客户端模板库来完成这项工作。

使用 Mustache.js 的客户端模板

Mustache 是一种无逻辑的模板语言,由 Chris Wanstrath 在 2009 年开发,以最流行的编程语言实现为特色;Mustache.js 是它的 JavaScript 实现。它最初源于 Google Templates(后来被称为 cTemplates),后者被用作生成 Google 搜索结果页面的模板系统。术语无逻辑是指定义的模板结构不包含ifthenelsefor循环语句;但是,它包含一个称为标记的通用结构,允许根据存储在被引用的 JavaScript 数据中的值类型(称为数据散列)来执行这种行为。每个标签由双括号{{}}表示,当从直角看时,看起来很像八字胡,因此得名。你可以通过bit . ly/mustache _ Github从它的 Github 项目页面下载 Mustache.js。该库在缩小后仅重 1.8KB,并启用了 gzip 压缩。

让我们开始研究 Mustache.js,使用它来执行我们的初始示例的模板化,以呈现与清单 13-3 中相同的 HTML 输出。我们将把它分成两个代码清单:一个 HTML 页面,如清单 13-4 所示,包含在特殊配置的<script>标签中写出的模板本身,并引用 Mustache.js 库;一个 JavaScript 文件,其内容如清单 13-5 所示,包含应用于模板的数据,并调用 Mustache.js 来执行由此模板产生的 HTML 的呈现。

清单 13-4。包含用于 Mustache.js 的客户端模板的 HTML 页面

<!doctype html>

<html>

<head>

<meta charset="utf-8">

<title>Mustache.js Example</title>

</head>

<body>

<h1>Mustache.js Example</h1>

<!-- Define the template we wish to apply our data to. The "type" attribute

needs to be any non-standard MIME type in order for the element's contents

to be interpreted as plain text rather than executed -->

<script id="template" type="x-tmpl-mustache">

<p>

Name: <a href="mailto:{{email}}">{{firstName}} {{lastName}}</a><br>

Company: {{company}}

</p>

<p>

City: {{city}}

</p>

</script>

<!-- Load the Mustache.js library -->

<script src="lib/mustache.min.js"></script>

<!-- Execute our script to combine the template with our data -->

<script src="Listing13-5.js"></script>

</body>

</html>

清单 13-4 中包含模板的<script>标签被赋予了一个type属性,浏览器不会将其识别为标准的 MIME 类型。这将导致浏览器将其内容视为纯文本,而不是要执行的内容,但不会将标签的内容明显地写到页面上。然后可以通过 JavaScript 引用标签的内容,方法是通过元素的id属性值定位元素并获取其innerHTML属性的内容。清单 13-4 中的 HTML 页面引用了清单 13-5 中的 JavaScript 代码,如下所示,在浏览器中运行时产生了如图 13-1 所示的结果。请注意用于基于两个输入参数产生输出字符串的Mustache.render()方法的使用:模板字符串和 JavaScript 数据散列对象。

清单 13-5。使用 Mustache.js 将数据与 HTML 模板结合起来

// Locate and store a reference to the <script id="template"> element from our HTML page

var templateElement = document.getElementById("template"),

// Extract the template as a string from within the template element

template = templateElement.innerHTML,

// Create two elements to store our resulting HTML in once our template is

// combined with our data

meElement = document.createElement("div"),

billElement = document.createElement("div"),

// Define two objects containing data to apply to the stored template

meData = {

firstName: "Den",

lastName: "Odell",

email: "denodell@me.com",

company: "AKQA",

city: "London"

},

billData = {

firstName: "Bill",

lastName: "Gates",

email: "bill@microsoft.com",

company: "Microsoft",

city: "Seattle"

};

// Use Mustache.js to apply the data to the template and store the result within the

// newly created elements

meElement.innerHTML = Mustache.render(template, meData);

billElement.innerHTML = Mustache.render(template, billData);

// Add the new elements, populated with HTML, to the current page once loaded

window.addEventListener("load", function() {

document.body.appendChild(meElement);

document.body.appendChild(billElement);

}, false);

A978-1-4302-6269-5_13_Fig1_HTML.jpg

图 13-1。

An example HTML page with data populated into templates with Mustache.js

Mustache 模板格式的完整详细文档可以通过bit . ly/Mustache _ docs在线查看。这种格式分为四种不同类型的数据表示:变量、节、注释和分部。

变量

Mustache 模板格式允许用一个同名的关联数据属性替换任何由双括号{{}}包围的文本标记,就像我们在清单 13-4 的 HTML 页面中创建的模板一样。在 Mustache 的说法中,双括号内的名称被称为变量或键。

所有变量都被替换为 HTML 转义字符串,这意味着放入变量中的数据字符串中的任何 HTML 都将作为文本写出,而不是解释为 HTML 元素。如果您希望文本被解释为 HTML,您应该用三重括号标记{{{}}}将您的键名括起来,如下所示。如果需要呈现 JavaScript 对象属性的值,可以使用标准的点符号来导航对象层次结构。

Template:

{{name}}

{{{name}}}

From {{address.country}}

Data Hash:

{

name: "Den <strong>Odell</strong>",

address: {

country: "UK"

}

}

Output:

Den &lt;strong&gt;Odell&lt;/strong&gt;

Den <strong>Odell</strong>

From UK

如果模板标记中表示的键名在提供的数据哈希对象中不存在,则输出中的标记将被一个空字符串替换。这不同于清单 13-3 中我们最初的语义模板示例,如果相关的数据值不存在,那么在结果输出中标签将保持不变。

部分

Mustache 模板中的一个部分有围绕模板块的开始和结束标记。然后,这些标签之间的模板块的内容在结果输出中重复一次或多次,这取决于存储在该标签的关联数据键值中的数据类型。标签中使用的关键字名称前面有一个散列字符(#)来表示该部分的开始标签,前面有一个斜线字符(/)来表示该部分的结束标签,例如{{#section}}{{/section}}。有四种类型的节——条件节、迭代器节、函数节和反转节——它们的声明类似,但根据传递给它们的数据类型执行不同的功能。

条件部分

如果被引用的标记键的数据值是布尔类型true,则显示节块的内容;如果选择false,则不显示该部分。这同样适用于falsy值,例如空字符串或空数组——在这些情况下,该部分不会显示。这种行为为我们提供了执行条件if语句的等效操作的能力,如此处所示。只有当isAvailable数据属性的值为truthy时,字符串YES才会包含在输出中。

Template:

Available:

{{#isAvailable}}

YES

{{/isAvailable}}

Data Hash:

{

isAvailable: true

}

Output:

Available: YES

迭代器部分

如果节标记引用的数据属性的格式是包含一个或多个项的数组列表,则开始和结束标记之间的节的内容将为列表中的每个项重复一次,每个单独项的数据将被传递到每次迭代的节中。这为我们提供了执行迭代数据循环的能力,相当于 JavaScript for循环,如下所示:

Template:

<h1>People</h1>

{{#people}}

<p>Name: {{name}}</p>

{{/people}}

Data Hash:

{

people: [

{name: "Den Odell"},

{name: "Bill Gates"}

]

}

Output:

<h1>People</h1>

<p>Name: Den Odell</p>

<p>Name: Bill Gates</p>

职能部门

如果 section 标签引用的数据格式是一个function,那么事情就变得非常有趣了。Mustache.js 将立即执行该函数,并应返回一个函数,该函数将在每次 section 标记引用该函数的名称时执行,并传入两个参数:作为字符串的模板 section 块的文字文本内容(在任何模板替换发生之前),以及一个直接引用 Mustache.js 的内部render()方法的函数,该方法允许以某种方式操作第一个参数中的值,然后将其内容与应用的数据一起输出。这使得基于输入数据创建过滤器、应用缓存或执行其他基于字符串的模板操作成为可能。此行为的一个示例如下所示:

Template:

{{#strongLastWord}}

My name is {{name}}

{{/strongLastWord}}

Data Hash:

{

name: "Den Odell",

strongLastWord: function() {

return function(text, render) {

// Use the supplied Mustache.js render() function to apply the data to the

// supplied template text

var renderedText = render(text),

// Split the resulting text into an array of words

wordArray = renderedText.split(" "),

wordArrayLength = wordArray.length,

// Extract the final word from the array

finalWord = wordArray[wordArrayLength - 1];

// Replace the last entry in the array of words with the final word wrapped

// in a HTML <strong> tag

wordArray[wordArrayLength - 1] = "<strong>" + finalWord + "</strong>";

// Join together the word array into a single string and return this

return wordArray.join(" ");

}

}

}

Output:

My name is Den <strong>Odell</strong>

倒置截面

当您开始认真使用 Mustache 模板时,您会发现需要根据反转条件显示文本或 HTML 块。如果一个数据值代表一个truthy值或包含要迭代的项目,您希望显示一个节块。如果值是falsy或者不包含要迭代的项目,您希望显示另一个块。反转部分允许这种行为,并通过使用脱字符(^)代替标签键名前的哈希(#)字符来表示,如下所示:

Template:

Available:

{{#isAvailable}}

YES

{{/isAvailable}}

{{^isAvailable}}

NO

{{/isAvailable}}

{{#people}}

<p>Name: {{name}}</p>

{{/people}}

{{^people}}

<p>No names found</p>

{{/people}}

Data Hash:

{

isAvailable: false,

people: []

}

Output:

Available: NO

<p>No names``found

评论

如果您希望在 Mustache 模板中包含不希望输出到结果字符串的开发注释或评论,只需创建一个标签,以双括号和感叹号(!)字符开始,以双右括号结束,如下所示:

Template:

<h1>People</h1>

{{! This section will contain a list of names}}

{{^people}}

<p>No names found</p>

{{/people}}

Data Hash:

{

people: []

}

Output:

<h1>People</h1>

<p>No names found</p>

部分模板

Mustache 支持跨多个<script>标签分离模板的能力,甚至支持在运行时可以组合在一起产生最终结果的分离文件。这允许创建可重复使用的代码片段并分别存储,以便跨多个模板使用。这种包含用于更大模板的代码片段的文件称为部分模板,简称为部分模板。

分部由标准标记中的给定名称引用,名称前面带有大于号(>)字符,表示它是分部模板。想象一个包含以下两个模板的 HTML 页面,这两个模板包含在分别标有templatepeopleid属性值的<script>标签中:

<script id="template" type="x-tmpl-mustache">

<h1>People</h1>

{{>people}}

</script>

<script id="people" type="x-tmpl-mustache">

{{#people}}

<p>Name: {{name}}</p>

{{/people}}

</script>

注意第一个模板中对名为people的分部的引用。尽管这个名字与赋予第二个模板的id属性相匹配,但是两者之间的引用并不是自动的,这需要在 Mustache.js 中进行配置。要做到这一点,你必须将你想在 JavaScript 对象中使用的任何部分传递给Mustache.render()方法的第三个参数,如清单 13-6 所示。第一个参数是主模板,第二个参数是应用于模板的数据。partials JavaScript 对象(第三个参数)中的属性名与模板中用于引用任何 partials 的标记名相关联。请注意,数据可用于组合模板,就好像在将数据应用到模板之前,两个模板已组合成一个文件一样。

清单 13-6。用 Mustache.js 引用分部模板

// Locate and store a reference to the <script id="template"> element from our HTML page

var templateElement = document.getElementById("template"),

// Locate and store a reference to the <script id="people"> element

peopleTemplateElement = document.getElementById("people"),

// Extract the template as a string from within the template element

template = templateElement.innerHTML,

// Extract the "people" template as a string from within the <script> element

peopleTemplate = peopleTemplateElement.innerHTML,

// Create an element to store our resulting HTML in once our template is

// combined with the partial and our data

outputElement = document.createElement("div"),

// Define an object containing data to apply to the stored template

data = {

people: [{

name: "Den Odell"

}, {

name: "Bill Gates"

}]

};

// Use Mustache.js to apply the data to the template, and allow access to the named partial

// templates and store the result within the newly created element

outputElement.innerHTML = Mustache.render(template, data, {

people: peopleTemplate

});

// Add the new element, populated with HTML, to the current page once loaded

window.addEventListener("load", function() {

document.body.appendChild(outputElement);

}, false);

// The resulting HTML will be:

/*

People</h1>

<p>Name: Den Odell</p>

<p>Name: Bill Gates</p>

*/

以这种方式使用分部文件允许创建具有共享组件、导航、页眉和页脚以及可重用代码片段的大型应用,因为分部文件可以从其他分部文件继承代码。这减少了代码的重复,并允许生成和维护高效的模板。唯一要记住的是,当引用它们的主模板被渲染时,所有的分部模板都必须准备好使用。这意味着,如果您选择通过 Ajax 加载模板文件,而不是将它们存储在当前的 HTML 页面中,那么所有的文件都必须在呈现时加载,这样它们就可以传递给主模板的单个Mustache.render()方法。

Mustache.js 是一个非常有用的小程序库,除了简单的变量替换之外,它还支持客户端模板,允许条件和迭代部分,并支持外部分部模板。

使用 Handlebars.js 的客户端模板

Handlebars 一种客户端模板格式,旨在扩展 Mustache 格式的功能。Handlebars 向后兼容 Mustache 模板,但支持额外的功能,包括块助手,这是对 Mustache 部分原则的扩展,用于阐明和改进每个模板块的显示逻辑的行为。

支持这些模板的 Handlebars.js 库可以直接从其主页下载,网址为 handlebarsjs。com ,如图 13-2 所示,当缩小并使用 gzip 压缩时,该库的重量为 13KB,因此与 Mustache.js 相比,要大得多。然而,正如我们将在本节稍后看到的,有一种预编译模板的技术,这样它们可以用于 Handlebars 库的缩减版本,从而产生更具可比性的文件大小。

A978-1-4302-6269-5_13_Fig2_HTML.jpg

图 13-2。

Handlebars.js home page

Handlebars 的用法与 Mustache 非常相似,从 HTML 页面中引用这个库,模板与 JavaScript 数据散列对象相结合,产生所需的输出字符串,插入到当前页面中。模板可以存储在页面本身的<script>标签中,就像我们在 Mustache 中看到的那样,或者使用 Ajax 通过单独的文件加载。同样,数据对象可以直接存储在 JavaScript 文件中,或者通过 Ajax 动态加载。

该库的全局Handlebars对象包含帮助呈现模板的方法;Handlebars.compile()方法将模板字符串作为参数,并返回一个函数。然后可以执行该函数,向它传递用于呈现模板的数据哈希对象,它将返回组合两者的结果字符串,以便在您的页面中使用。清单 13-7 显示了一个简单的例子,它使用了一个基本的页面内模板和本地 JavaScript 数据的compile()方法。

清单 13-7。包含简单把手模板的 HTML 页面

<!doctype html>

<html>

<head>

<meta charset="utf-8">

<title>Handlebars.js Example</title>

</head>

<body>

<h1>Handlebars.js Example</h1>

<div id="result"></div>

<!-- Define the template we wish to apply our data to. The "type" attribute

needs to be any non-standard MIME type in order for the element's contents

to be interpreted as plain text rather than executed -->

<script id="template" type="x-tmpl-handlebars">

<p>

Name: <a href="mailto:{{email}}">{{firstName}} {{lastName}}</a><br>

Company: {{company}}

</p>

<p>

City: {{city}}

</p>

</script>

<!-- Load the Handlebars.js library -->

<script src="lib/handlebars.min.js"></script>

<!-- Execute our script to combine the template with our data. Included

here for brevity, move to an external JavaScript file for production -->

<script>

// Get a reference to the template element and result element on the page

var templateElem = document.getElementById("template"),

resultElem = document.getElementById("result"),

// Get the string representation of the Handlebars template from the template

// element

template = templateElem.innerHTML,

// Define the data object to apply to the template

data = {

firstName: "Den",

lastName: "Odell",

email: "denodell@me.com",

company: "AKQA",

city: "London"

},

// The compile() method returns a function which the data object can be passed to

// in order to produce the desired output string

compiledTemplate = Handlebars.compile(template);

// Combine the data with the compiled template function and add the result to the page

resultElem.innerHTML = compiledTemplate(data);

</script>

</body>

</html>

现在让我们一起看看 Handlebars 模板和 Handlebars.js 库的一些特性,包括片段、帮助器和用于提高性能的模板预编译。

部分的

Handlebars 模板中的部分模板看起来与 Mustache 模板中的部分模板相同,但是两者在将部分模板的细节提供给库进行呈现的方式上有所不同。Handlebars.registerPartial()方法允许使用两个参数将分部注册到手柄:分部的名称和分部模板的字符串表示。必须在从正在呈现的模板中引用分部之前调用此函数。通过多次调用registerPartial()方法可以注册多个分部。就像 Mustache.js 一样,在呈现主模板之前,必须加载并注册所有分部。

助手

Handlebars 用自己的功能丰富的处理程序(称为助手)扩展了 Mustache 部分的概念。这些使您能够迭代数据列表,或执行条件表达式,使用具有清晰名称的帮助器,使模板更容易阅读和理解——这很重要,特别是当您的模板变得更加复杂时,这与 Mustache 有显著的不同。使用 Mustache,在不知道传递给模板的数据类型的情况下,您无法确定一个部分是用作条件块、迭代还是其他什么;在这里,帮助者的名字清楚地表明了区别。有几个内置的助手,Handlebars 让您能够轻松地创建自己的助手,这些助手可以简单地在您的所有模板中重用。助手名称的前面是标签中的散列(#)字符,后面是应用于助手的数据键的名称。在这一节中,我们将看看一些常见的助手,我将解释如何轻松地创建自己的助手来满足特定模板的需求。

with帮手

with助手允许您将不同的数据上下文应用到它所围绕的模板块。这为重复使用点符号导航传递给助手的特定数据属性层次结构提供了一种替代方法。为了从一个节中导航回父上下文,可以在变量名前使用../标记。这里展示了一个简单的with助手的例子,它应该可以让一切变得清晰。

Template:

<h1>{{name}}</h1>

{{#with address}}

<p>{{../name}} lives at: {{street}}, {{city}}, {{region}}, {{country}}</p>

{{/with}}

Data Hash:

{

name: "Den Odell",

address: {

street: "1 Main Street",

city: "Hometown",

region: "Homeshire",

country: "United Kingdom"

}

}

Output:

<h1>Den Odell</h1>

<p>Den Odell lives at: 1 Main Street, Hometown, Homeshire, United Kingdom</p>

each帮手

each助手允许你迭代一个数据列表,比如一个数组或对象。数组项的值,如果不是对象或数组本身,可以通过使用保留的变量名{{this}}来访问。在数组中循环时,原数组中当前项的索引由保留的变量名{{@index}}提供。当迭代一个对象时,当前属性的键的名称由保留的变量名{{@key}}提供。如果提供的数据列表为空,可以添加一个可选的{{else}}部分,允许您提供一个要呈现的部分块,如下例所示:

Template:

<h1>People</h1>

{{#each people}}

<p>Item {{@index}}: {{this}}</p>

{{else}}

<p>No names found</p>

{{/each}}

Data Hash:

{

people: [

"Den Odell",

"Bill Gates"

]

}

Output:

<h1>People</h1>

<p>Item 0: Den Odell</p>

<p>Item 1: Bill Gates</p>

ifunless助手

使用ifunless手柄辅助工具,可以根据某些数据属性的值有条件地显示模板截面块。如果传递给它的值是一个truthy值(除了falseundefinednull,空字符串或空数组之外的任何值),那么if助手将显示相关的节块,而unless助手仅在数据值为falsy时才显示相关的节块。在任一情况下,可以添加可选的{{else}}部分来捕捉反转的情况,如下例所示:

Template:

<h1>People</h1>

{{#if people}}

<p>Item {{@index}}: {{this}}</p>

{{else}}

<p>No names found</p>

{{/if}}

{{#unless isAvailable}}

<p>Not available</p>

{{/unless}}

Data Hash:

{

isAvailable: false,

people: []

}

Output:

<h1>People</h1>

<p>No names found</p>

<p>Not available</p>

log帮手

如果您正在为模板提供一个大型的多级数据对象,当在整个模板中使用多个助手时,要知道您在数据层次结构中的位置,或者在任何特定的点上您有哪些可用的数据,有时会变得令人困惑。幸运的是,Handlebars 提供了一个log调试助手,允许您在模板中的任意点查看可用数据的状态。只需传递您希望查看的数据对象变量的名称,或者使用{{log this}}来显示当前上下文中可用的数据。如果最终页面生成是通过命令行工具处理的,则数据不是被写出到生成的页面本身,而是被写出到命令行;如果生成是在浏览器中实时发生的,则数据被写出到浏览器的开发人员控制台窗口中。

自定义助手

除了内置的帮助器,Handlebars 还提供了创建您自己的自定义帮助器的能力,允许您在模板中提供您需要的确切功能。这些可以在块级工作,以对模板的一部分执行操作,或者在单个数据项级工作,例如格式化特定的数据以供显示。

可以使用Handlebars.registerHelper()方法创建一个定制的帮助器,向其传递两个参数:帮助器的唯一名称,以及在呈现时在模板中遇到帮助器时要执行的函数,该函数对模板中传递给它的数据执行所需的行为。清单 13-8 展示了一些简单的自定义助手,你可能会发现它们对你自己的模板很有用。

清单 13-8。自定义车把助手示例

// The registerHelper() method accepts two arguments - the name of the helper, as it will

// be used within the template, and a function which will be executed whenever the

// helper is encountered within the template. The function is always passed at least one

// parameter, an object containing, amongst others, a fn() method which performs the same

// operation as Handlebars' own render ability. This method takes a data object and

// returns a string combining the template within the block helper with the data in

// this object

// Define a block helper which does nothing other than pass through the data in the

// current context and combine it with the template section within the block

Handlebars.registerHelper("doNothing", function(options) {

// To use the current data context with the template within the block, simply use

// the 'this' keyword

return options.fn(this);

});

// The helper can be passed parameters, if required, listed one by one after the helper

// name within double braces. These are then made available within the function as

// separate input parameters. The final parameter is always the options object, as before

Handlebars.registerHelper("ifTruthy", function(conditional, options) {

return conditional ? options.fn(this) : options.inverse(this);

});

// If more than one or two parameters need to be passed into the helper, named parameters

// can be used. These are listed as name/value pairs in the template when the helper is

// called, and are made available within the options.hash property as a standard

// JavaScript object ready to pass to the options.fn() method and used to render the

// data within

Handlebars.registerHelper("data", function(options) {

// The options.hash property contains a JavaScript object representing the name/value

// pairs supplied to the helper within the template. Rather than pass through the

// data context value 'this', here we pass through the supplied data object to the

// template section within the helper instead

return options.fn(options.hash);

});

// Create a simple inline helper for converting simple URLs into HTML links. Inline helpers

// can be used without being preceded by a hash (#) character in the template.

Handlebars.registerHelper("link", function(url) {

// The SafeString() method keeps HTML content intact when rendered in a template

return new Handlebars.SafeString("<a href=\"" + url + "\">" + url + "</a>");

});

清单 13-8 中给出的自定义助手可以像下面的示例模板中演示的那样使用:

Base Template:

{{#doNothing}}

<h1>Dictionary</h1>

{{/doNothing}}

{{#ifTruthy isApiAvailable}}

<p>An API is available</p>

{{/ifTruthy}}

{{#ifTruthy words}}

<p>We have preloaded words</p>

{{else}}

<p>We have no preloaded words</p>

{{/ifTruthy}}

<dl>

{{#data term="vastitude" definition="vastness; immensity" url="http://dictionary.com/browse/vastitude

{{>definition}}

{{/data}}

{{#data term="achromic" definition="colorless; without coloring matter" url="http://dictionary.com/browse/achromic

{{>definition}}

{{/data}}

</dl>

Partial "definition" Template

<dt>{{term}} {{link url}}</dt>

<dd>{{definition}}</dd>

Data hash:

{

isApiAvailable: true,

words: []

}

Output:

<h1>Dictionary</h1>

<p>An API is available</p>

<p>We have no preloaded words</p>

<dl>

<dt>vastitude <a href="http://dictionary.com/browse/vastitude">http://dictionary.com/browse/vastitude</a></dt

<dd>vastness; immensity</dd>

<dt>achromic <a href="http://dictionary.com/browse/achromic">http://dictionary.com/browse/achromic</a></dt

<dd>colorless; without coloring``matter

</dl>

预编译模板以获得最佳性能

正如我们已经看到的,Handlebars.js compile()方法获取一个模板字符串,并将其转换为一个函数,然后可以执行该函数,传递数据以应用于模板。结果是显示在页面上的最终标记字符串。如果您知道您的模板在应用运行时不会改变,您可以利用 Handlebars.js 的预编译特性,该特性允许您提前执行这种模板到函数的转换,向您的应用提供一个较小的 JavaScript 文件,其中只包含要应用数据的模板函数。文件大小要小得多,因为它们可以利用许多优化,否则在运行时是不可能的,并且它们只需要在 HTML 页面中运行 Handlebars.js 的精简运行时版本。这个特殊版本的库删除了通过预编译过程变得多余的函数。手柄的运行时版本可以从主页 handlebarsjs 下载。com 和重量在一个更苗条的 2.3 KB 缩小后,当使用 gzip 压缩服务。这在大小上与 Mustache 库相当,但包含了 Handlebars 的额外好处和简单性,因此它是在代码中使用模板的一个很好的解决方案,而不会牺牲文件大小,因此也不会减少下载时间。如果您正在寻找一个解决方案来提供本章中提到的 gzip 压缩版本的库,请访问。com 来定位对所需文件的内容交付网络托管版本的引用。

预编译步骤需要提前进行,在页面上使用模板之前,我们可以利用 Handlebars.js 提供的命令行工具来执行这个步骤。该工具运行在 Node.js 应用框架上,我们将在下一章详细介绍。现在,你可以从 bit. ly/ node_ js 下载该框架,并按照说明进行安装。同时安装节点包管理器(NPM)工具,并允许轻松安装在框架内运行的应用,包括 Handlebars.js 预编译器应用。在命令行中输入以下命令,安装 Handlebars.js 预编译器 1.3.0 版(Mac 和 Linux 用户可能需要在命令前加上sudo,以授予安装该工具的必要权限,从而可以访问机器上的任何文件夹):

npm install –g handlebars@1.3.0

我们明确说明了与 Handlebars.js 主页上可下载的库版本相匹配的工具的版本号。如果两个版本不匹配,我们就不能保证任何预编译模板都能正常工作。

在命令行上导航到包含模板文件的目录,并执行以下命令来预编译模板,用要预编译的模板文件的名称替换templatefile.handlebars:

handlebars templatefile.handlebars

您会注意到,生成的预编译模板函数被直接写到命令行,而不是保存到文件中,这并不是我们所需要的。要将生成的模板保存到一个新文件中,在命令行中添加--output选项,并指定一个扩展名为.js的文件名(因为我们将返回一个 JavaScript 函数供页面使用)。如果我们还添加了--min选项,生成的 JavaScript 文件将被缩小,为我们节省了以后的优化任务。因此,最终的命令如下所示,其中templatefile.handlebars被替换为要预编译的模板的名称,而templatefile.js被替换为预编译输出 JavaScript 模板文件的名称:

handlebars templatefile.handlebars --output templatefile.js --min

让我们创建一个真实的例子来展示如何使用预编译模板。考虑清单 13-9 所示的模板文件。

清单 13-9。要预编译的手柄模板

<dl>

{{#each words}}

<dt>{{term}} <a href="{{url}}">{{url}}</a></dt>

<dd>{{definition}}</dd>

{{else}}

<p>No words supplied</p>

{{/each}}

</dl>

让我们用清单 13-9 中的模板,用handlebars命令行工具预编译它。我们将使用下面的命令,它生成清单 13-10 所示的简化 JavaScript 代码,代表我们的预编译模板:

handlebars Listing13-9.handlebars --output Listing13-10.js --min

清单 13-10。清单 13-9 中模板的预编译版本

!function(){var a=Handlebars.template,t=Handlebars.templates=Handlebars.templates||{};t["Listing13-9"]=a(function(a,t,e,l,n){function r(a,t){var l,n,r="";return r+="\n        <dt>",(n=e.term)?l=n.call(a,{hash:{},data:t}):(n=a&&a.term,l=typeof n===i?n.call(a,{hash:{},data:t}):n),r+=o(l)+' <a href="',(n=e.url)?l=n.call(a,{hash:{},data:t}):(n=a&&a.url,l=typeof n===i?n.call(a,{hash:{},data:t}):n),r+=o(l)+'">',(n=e.url)?l=n.call(a,{hash:{},data:t}):(n=a&&a.url,l=typeof n===i?n.call(a,{hash:{},data:t}):n),r+=o(l)+"</a></dt>\n        <dd>",(n=e.definition)?l=n.call(a,{hash:{},data:t}):(n=a&&a.definition,l=typeof n===i?n.call(a,{hash:{},data:t}):n),r+=o(l)+"</dd>\n    "}function s(){return"\n        <p>No words supplied</p>\n    "}this.compilerInfo=[4,">=1.0.0"],e=this.merge(e,a.helpers),n=n||{};var d,h="",i="function",o=this.escapeExpression,c=this;return h+="<dl>\n    ",d=e.each.call(t,t&&t.words,{hash:{},inverse:c.program(3,s,n),fn:c.program(1,r,n),data:n}),(d||0===d)&&(h+=d),h+="\n</dl>"})}();

一旦预编译,模板就可以在我们的 HTML 页面中引用使用。因为编译阶段不再需要直接发生在浏览器中,所以我们获得了比浏览器内编译更好的性能。将这一点与该解决方案所需的较小下载量结合起来,您可以看到这种技术对于确保大型 web 应用的良好性能是多么有用。

清单 13-11 中的代码显示了一个示例 HTML 页面,我们可以用它将 JavaScript 数据对象插入预编译的模板,并将结果 HTML 字符串写到当前页面。

清单 13-11。引用预编译模板和 Handlebars.js 库的运行时版本的 HTML 页

<!doctype html>

<html>

<head>

<meta charset="utf-8">

<title>Handlebars.js Example</title>

</head>

<body>

<h1>Handlebars.js Example</h1>

<!-- Create an element to store the result of applying the template to the data -->

<div id="result"></div>

<!-- Load the Handlebars.js runtime library, which should be used with

precompliled templates only -->

<script src="lib/handlebars.runtime.min.js"></script>

<!-- Load our precompiled template -->

<script src="Listing13-10.js"></script>

<!-- Plug the data into the template and render onto the page -->

<script>

// Precompiled templates are added as properties to the Handlebars.templates object

// using their original template name as the key (this name was set by the command line

// tool and stored in the Listing13-10.js file)

var template = Handlebars.templates["Listing13-9"],

// The template is a function, which should be passed the data to render within the

// template. The result is the combination of the two, as a String.

result = template({

words: [{

term: "vastitude",

url: "http://dictionary.com/browse/vastitude

definition: "vastness; immensity"

}, {

term: "achromic",

url: "http://dictionary.com/browse/achromic

definition: "colorless; without coloring matter"

}]

});

// Write the resulting string onto the current page within the <div id="result">

// element. Produces the following result:

/*

<dl>

<dt>vastitude

<a href="``http://dictionary.com/browse/vastitude">http://dictionary.com/browse/vastitude</a

</dt>

<dd>vastness; immensity</dd>

<dt>achromic

<a href="``http://dictionary.com/browse/achromic">http://dictionary.com/browse/achromic</a

</dt>

<dd>colorless; without coloring matter</dd>

</dl>

*/

document.getElementById("result").innerHTML = result;

</script>

</body>

</html>

运行清单 13-11 中的代码会产生如图 13-3 所示的结果。

A978-1-4302-6269-5_13_Fig3_HTML.jpg

图 13-3。

The resulting page from running the code in Listing 13-9 to combine data with a precompiled template

Handlebars 提供了比 Mustache 更具描述性的模板语言,并通过其自定义助手功能提供了可扩展性。为了克服处理这种模板所需的额外库大小,它提供了预编译模板的能力,以便与库的缩减版本一起使用,从而提供改进的性能和与 Mustache tiny 相似的数据占用大小。js 库。正是由于这些原因,Handlebars 在大型 web 应用中得到了广泛的应用。

备选客户端模板库

我们已经详细研究了 Mustache 和 Handlebars 模板,包括我在内的许多人都认为它是最流行、最受支持的 JavaScript 模板解决方案。然而,这些并不是您唯一可用的选项,包括嵌入式 JavaScript (EJS)和下划线. JS 在内的库也提供了类似的模板功能,我将在本节中解释。

嵌入 JavaScript 的客户端模板(EJS)

嵌入式 JavaScript (EJS)是一种开源的 JavaScript 模板语言,专为那些最熟悉 JavaScript 语言并且喜欢以熟悉的代码友好的方式编码模板逻辑的人而设计。它允许使用简单的 JavaScript 格式的if语句、for循环和数组索引从一组输入数据中输出所需的文本字符串。EJS 图书馆可以从主页通过bit . ly/ejs-template下载,当缩小并使用 gzip 压缩时,重量只有 2.4 KB。该库拥有广泛的浏览器支持,可以追溯到 Firefox 1.5 和 Internet Explorer 6。

在 EJS 模板中,要执行的代码包含在以<%开始并以%>结束的部分中,这是 Ruby 语言开发人员用于模板化的风格,要输出的变量包装在<%=%>中——注意附加的等号(=)。

EJS 支持添加视图助手的能力,这些助手可以简化常见输出类型的创建,比如 HTML 链接,可以使用命令<%= link_to("link text", "/url") %>创建以下输出:<a href="/url">link text</a>。可用助手的完整列表可在 EJS 维基网站 bit. ly/ ejs_ wiki 上找到。

这里显示了一个简单的 EJS 示例模板:

Template:

<h1><%= title %></h1>

<dl>

<% for (var index = 0; index < words.length; index++) { %>

<dt><%= link_to(words[index].term, words[index].url) %></dt>

<dd><%= words[index].definition %></dd>

<% }

if (!words) { %>

<p>No words supplied</p>

<% } %>

</dl>

Data Hash:

{

title: "EJS Example",

words: [{

term: "vastitude",

url: "http://dictionary.com/browse/vastitude

definition: "vastness; immensity"

}, {

term: "achromic",

url: "http://dictionary.com/browse/achromic

definition: "colorless; without coloring matter"

}]

}

Output:

<h1>EJS Example</h1>

<dl>

<dt><a href="http://dictionary.com/browse/vastitude">vastitude</a></dt

<dd>vastness; immensity</dd>

<dt><a href="http://dictionary.com/browse/achromic">achromic</a></dt

<dd>colorless; without coloring matter</dd>

</dl>

与 Mustache.js 和 Handlebars.js 相比,EJS 的一个优势是,它不需要任何特殊代码就可以将远程 JSON 文件中的 JavaScript 数据应用到存储在另一个远程文件中的模板,这需要使用 Mustache.js 和 Handlebars.js 编写额外的 Ajax 代码

典型地,EJS“类”的实例化是通过将 URL 传递给外部模板文件以异步加载,然后执行其render()update()方法,这取决于要应用于模板的数据是否已经存在并加载到 JavaScript 中,在这种情况下,render()方法被传递数据对象并返回输出字符串。向update()方法传递了两个参数,一个是 HTML 页面元素的id,用于在其中呈现结果模板,另一个是 JSON 数据文件的 URL,用于在将数据应用到模板之前异步加载。

new EJS({url: "/templatefile.ejs"}).render(dataObject);

new EJS({url: "/templatefile.ejs"}).update("pageElementId", "/datafile.json");

如果您喜欢使用逻辑与您熟悉的 JavaScript 文件非常相似的模板,那么 EJS 可能是满足您需求的最佳模板解决方案。

下划线. js

Underscore.js 是一个 JavaScript 库,包含 80 多个有用的帮助函数,用于处理代码中的数据集合、数组、对象和函数,以及一系列实用函数,其中一个是专门针对基本模板的。该库可以通过 bit. ly/ u-js 从其主页下载,大小为 5 KB,采用 gzip 编码。当包含在页面中时,它通过下划线(_)全局变量提供对其方法的访问。如果您在代码中使用了 Backbone.js MVC 库(bit . ly/backbone _ MVP,您可能会认出 Underscore.js,因为它依赖于这个库。

模板化是通过下划线. js _.template()方法实现的,该方法被传递一个模板字符串并返回一个可以执行的函数,传递用于以 JavaScript 对象的形式呈现模板的数据,与模板化通过 Handlebars.js 实现的方式非常相似。与 EJS 类似,<%%>分隔符表示要执行的代码,<%=%>分隔符表示要写出到结果字符串的变量。除了像iffor这样的 JavaScript 命令,您还可以设置变量并访问整个下划线. js 库,以便在您的模板中利用它的其他实用方法。

一个简单的下划线模板可能如下所示,使用下划线. js _.each()方法迭代数据列表,而不是使用for循环:

Template:

<h1><%= title %></h1>

<dl>

<% _.each(words, function(word) { %>

<dt><a href="<%= word.url %>"><%= word.term %></a></dt>

<dd><%= word.definition %></dd>

<% }

if (!words) { %>

<p>No words supplied</p>

<% } %>

</dl>

Data Hash:

{

title: "Underscore.js Example",

words: [{

term: "vastitude",

url: "http://dictionary.com/browse/vastitude

definition: "vastness; immensity"

}, {

term: "achromic",

url: "http://dictionary.com/browse/achromic

definition: "colorless; without coloring matter"

}]

}

Output:

<h1>Underscore.js Example</h1>

<dl>

<dt><a href="http://dictionary.com/browse/vastitude">vastitude</a></dt

<dd>vastness; immensity</dd>

<dt><a href="http://dictionary.com/browse/achromic">achromic</a></dt

<dd>colorless; without coloring matter</dd>

</dl>

要在 HTML 页面上使用下划线模板,请引用库和模板,这需要通过 Ajax 手动加载,或者直接从 HTML 页面或 JavaScript 变量引用。然后执行_.template()方法将模板字符串编译成函数,然后可以用数据调用该函数。

var template = _.template(templateString),

output = template(data);

如果您需要在模板中添加一些熟悉的 helper 方法,或者您已经在应用中使用了 Backbone.js MVC 库,那么您可能会发现 Underscore.js 是满足您需求的最佳模板解决方案。

考虑渐进增强

在这一章中,我们已经了解了客户端模板解决方案,它允许您动态加载特殊格式的模板,将它们与 JavaScript 数据结合,然后将结果输出附加到当前 HTML 页面。这使得 web 应用用户无需刷新页面即可获取和显示新内容,就像桌面应用一样。然而,动态内容加载带来了一个警告:构建一个完全通过 JavaScript 呈现和更新显示的 web 应用意味着意外的页面刷新可能会将整个应用重置回其初始视图状态,并且搜索引擎可能会很难抓取通过应用表示的内容,因为不是所有的应用都可以处理 JavaScript。这也违背了网络上的 URL 原则:URL 代表一个对象或一段内容,而不是一个完整的应用。

使用渐进式增强的原则构建您的 web 应用,其中 HTML 链接和表单在被跟踪时会转到单独且不同的 URL,JavaScript 用于防止这些链接和表单导致页面刷新和分层,从而改善用户体验。执行 Ajax 调用并动态加载模板和数据,使用 HTML5 历史 API(bit . ly/History API)更新地址栏中的 URL,以匹配表示加载的新数据或显示的页面部分的 URL。如果用户在浏览器中意外刷新了页面,他们将被带到地址栏中更新后的 URL 所代表的内容,并保持在他们之前到达的应用中的相同位置。当 JavaScript 被禁用时,就像大多数搜索引擎爬虫一样,链接仍然工作,允许内容和站点数据像你希望的那样被爬行,在关键点直接创建到你的应用的链接。

摘要

在本章中,我介绍了客户端模板作为一种解决方案,用于构建具有动态更新页面内容的大型 web 应用,将专门标记的模板与 JavaScript 数据相结合,生成包含在当前页面中的 HTML。我已经介绍了流行的 Mustache 和 Handlebars 模板语言,以及它们相关的 JavaScript 库,还有一些替代语言,比如嵌入式 JavaScript (EJS)和下划线. JS

在下一章中,我将介绍 Node.js,这是一个为 JavaScript 语言构建的应用框架,它允许专业 JavaScript 开发人员编写服务器端代码和服务器软件来支持他们的 web 应用,从而将完整的开发堆栈带入 JavaScript 开发人员的领域。

十四、Node.js 应用平台

在这一章中,我们将有意远离运行在浏览器中的客户端 JavaScript 代码,转而走向服务器端领域,仔细研究一个名为 Node.js 的应用平台,它是由 JavaScript 开发人员设计的,也是为他们设计的。

Note

JavaScript 在服务器端有历史。JavaScript 在产品中的首次使用实际上根本不是在 web 浏览器中;它位于 1994 年发布的服务器产品 Netscape Enterprise Server 中,在该产品中,它作为一种为 HTTP 应用编写服务器端行为的语言。

Node.js 最初是由它的创造者 Ryan Dahl 在 2011 年发布的,这是他构建一个允许开发人员构建轻量级、可扩展的命令行或 Web 应用的应用平台的愿景的高潮。它基于谷歌 V8 JavaScript 引擎( http://bit.ly/google-v8 ),这是一个虚拟机,也用于谷歌的 Chrome 浏览器中解析和执行 JavaScript。就像浏览器中的 JavaScript 一样,应用是围绕基于异步事件的模型构建的,这意味着代码应该高效运行,并且看起来为专业 JavaScript 开发人员所熟悉。Node.js 对开发人员的吸引力很大一部分在于,它是为在 Microsoft Windows、Mac OS X、Linux 和 SunOS 操作系统上运行而构建的,这使它成为一个有用的平台,可以交付跨平台运行的命令行应用,而不需要任何额外的开发步骤。因为 Node.js 项目的最初目标之一是允许开发人员轻松地构建支持服务器推送功能的应用,所以很多注意力都集中在它的异步事件驱动 I/O(输入/输出)系统上,与其他一些应用平台不同,该系统不会导致主应用进程在等待写入或读取数据时暂停。这使得它非常适合运行能够处理大量并发连接的可伸缩 web 服务器,以及运行服务器端代码来支持在连接的 web 浏览器中运行的应用或网站。要了解 Node.js 成为现在这个样子背后的技术原因,请查看该项目的网站上的以下页面: http://bit.ly/about-node

安装 Node.js

Node.js 可以在 http://nodejs.org 从项目主页下载安装包进行安装,如图 14-1 所示。按照说明安装节点系统本身(在编写本文时,当前版本是 v0.10.29)和节点包管理器工具(npm),我们将在本章后面更详细地介绍它。

A978-1-4302-6269-5_14_Fig1_HTML.jpg

图 14-1。

The Node.js homepage

现在在您的机器上全局安装了node命令行工具,您可以导航到包含为 Node.js 编写的合适 JavaScript 应用文件的任何文件夹,并通过执行以下命令来运行它,将filename.js替换为要运行的 JavaScript 文件的名称:

node filename.js

编写 Node.js 应用

让我们通过创建一个基本示例来演示平台的功能和特性,从而熟悉 Node.js 应用的编写。首先,让我们编写世界上最简单的 Node.js 应用,它在命令行窗口中显示Hello, World。代码只有一行,如清单 14-1 所示。

清单 14-1。Hello World Node.js 应用

console.log("Hello, World");

将它保存到名为Listing14-1.js的文件中,并在命令行上用以下命令执行它:

node Listing14-1.js

从命令行运行这个新应用会产生如图 14-2 所示的输出。完成后,应用将停止运行,并将控制权返回给命令行光标,以执行您希望执行的下一个命令。

A978-1-4302-6269-5_14_Fig2_HTML.jpg

图 14-2。

Running the Hello World application

控制台

Node.js 本机console对象包含将不同类型的消息写出到命令行的方法,log()方法是其中最基本的方法——当以这种方式运行时,传递给它的任何字符串、数字、数组或基本对象都将被写出到命令行。通过 http://bit.ly/nodejs_console_api .阅读更多关于 API 文档中console方法的信息

如果您希望编写一个可以在命令行上传递参数的命令行应用,您可以使用process.argv数组,该数组包含在命令行上使用的每个参数,以调用node本身和要执行的文件名开始。清单 14-2 中的代码显示了一个简单的应用,它将传递给它的每个参数重复到命令行中。将这段代码保存到一个名为Listing14-2.js的文件中。

清单 14-2。访问命令行参数

var index = 2,

length = process.argv.length;

// We start from index 2 as the first two arguments are node itself and the filename

// we are executing

for (; index < length; index++) {

console.log(process.argv[index]);

}

在命令行上执行下面的命令来运行清单 14-2 中的代码,向应用传递参数。这产生了如图 14-3 所示的结果。

node Listing14-2.js one 2 three

A978-1-4302-6269-5_14_Fig3_HTML.jpg

图 14-3。

Repeating command line arguments back to the user

有关process.argv数组的更多信息,请查看位于 http://bit.ly/nodejs_argv .的在线 API 文档

加载模块

在第九章中,我们介绍了异步模块定义(AMD)和 JavaScript 库,它们允许使用名为require()的全局方法以清晰和描述性的方式加载文件依赖关系。Node.js 支持 AMD 和开箱即用的require()方法,这是处理文件依赖关系加载的首选方式。

为了使 Node.js 保持一个精简的环境,许多不重要的行为包在默认情况下不会被加载,必须作为依赖项专门列出才能被加载。假设您只列出了应用代码中需要的依赖项,这可以确保加载的代码是您实际需要的代码,从而保持最佳性能。

http 模块

Node.js 通常被用作一个网络环境,更具体地说,是一个 web 服务器,其精简、简单的方法非常适合它。您显式地包含了减少其他服务器产品中常见的膨胀所需的行为。Node.js' http模块依赖包含启动简单 web 服务器所需的方法。清单 14-3 展示了这一点,在一个给定的端口号上创建一个本地 web 服务器,当通过 web 浏览器访问时,它将Hello, World作为 HTML 写出到请求浏览器的窗口。

清单 14-3。一个简单的 Node.js web 服务器

// Define a dependency on the Node.js "http" module which contains methods to allow the

// creation of a simple HTTP web server

var http = require("http"),

// Define a variable to represent our HTTP server

server,

// Define a constant to represent the HTTP status code for an OK response

HTTP_OK = 200,

// Define a port number to listen for requests on

PORT = 8000;

// Call the http.createServer() method to spin up a web server, defining the response to send

// to the calling HTTP application (usually a web browser) based on the request received. Here

// we ignore the request received (which would contain the URL requested and any data sent

// with the request, for example cookie or POST data) and simply respond with a single chunk

// of HTML to read "Hello, World" for any request received (try different URLs to prove this).

// The callback function passed to the method will be executed once for each request received

// at the time it is received, asynchronously.

server = http.createServer(``function

// Send a HTTP header to the requesting browser to indicate a successful HTTP response

// and defining the response body data will be sent as HTML text

response.writeHead(HTTP_OK, {

"Content-Type": "text/html"

});

// Send the HTML response

response.write("<h1>Hello, World</h1>\n");

// Close the connection - without this, the HTTP server will expect to continue to send

// more data to the browser, the connection to the server would be kept open unnecessarily,

// wasting server resources and potentially preventing others from connecting to that same

// server, depending on demand. The end() method tells the connection that we're done

// sending our response data. If we knew we were only going to send one string of data and

// then close the connection, we could actually pass that string to the response.end()

// method, which would call the write() method for us internally in that case

response.end();

});

// The final step is to tell our new web server to start listening for requests on a specific

// socket port number. The host name by default will be http://localhost or http://127.0.0.1

// since we are running the application locally on our development machine. The listen() method

// is different to many others in that it keeps the Node.js application running - if it didn't

// we would no longer be able to listen for requests. You can manually stop the application on

// the command line by typing Ctrl+X (Microsoft Windows) or Command-X (Mac OS X) which will

// stop the web server from listening on this port

server.listen(PORT);

// Output a message to the command line to instruct the user that the web server is running

// and what address they need to browse in order to view the defined response

console.log("Now try browsinghttp://127.0.0.1

将清单保存到一个名为Listing14-3.js的文件中,并在命令行中执行以下命令来启动简单的 web 服务器。

node Listing14-3.js

当执行时,清单 14-3 中的代码将在命令行上产生如图 14-4 所示的响应。当在 web 浏览器中访问 URL http://127.0.0.1:8000 时,会输出如图 14-5 所示的响应。请注意,您可以在主机名的末尾添加任何额外的路径或查询字符串值,服务器每次都会用完全相同的 HTML 数据进行响应。在这一章的后面,我们将看到 web 服务器框架,它允许我们更容易地创建 web 服务器,以适当地响应不同的路径和查询字符串值。

要了解关于http模块的更多信息,请通过 http://bit.ly/nodejs_http 访问特定的 Node.js API 文档。

A978-1-4302-6269-5_14_Fig5_HTML.jpg

图 14-5。

Visiting the simple web server’s URL in a browser outputs “Hello, World” in HTML

A978-1-4302-6269-5_14_Fig4_HTML.jpg

图 14-4。

Starting up a simple Node.js web server

现在,我们已经看到了如何创建一个运行在命令行上的简单应用,以及另一个通过 HTTP 打开与连接的 web 浏览器的通信通道的应用,这是任何 web 服务器的基础,也是我们希望编写的任何服务器端代码的基础。要了解更多关于其他可用的低级模块的信息,请访问完整的 API 文档站点。ly/ nodejs_ apidocs 。

Node.js 包

Node.js 与 Node Package Manager ( npm)捆绑在一起,这是一个独立的命令行应用,允许第三方模块(称为包)直接在命令行上作为应用加载和使用,或者作为 Node.js 应用中的代码依赖项来添加额外的功能。在 http://npmjs.org 可以在线找到可用套餐的目录,如图 14-6 所示,在这里可以搜索你感兴趣的套餐或套餐类型。

A978-1-4302-6269-5_14_Fig6_HTML.jpg

图 14-6。

The list of Node.js packaged modules at http://npmjs.org

从这个列表安装应用就像在命令行上运行命令npm install一样简单,后面跟着目录中列出的包名;例如,要安装 YUIDoc 命令行工具(我们在第二章的中看到过)用于当前目录,执行以下命令:

npm install yuidocjs

当这个命令运行时,你会看到一个响应写到命令行,如图 14-7 所示,因为这个工具使用 HTTP 下载请求的包及其依赖项。安装完成后,我们可以使用当前文件夹中安装的yuidoc应用。你会注意到在当前文件夹中出现了一个node_modules文件夹,这是默认位置,npm存储所请求的应用的文件和依赖关系,所以请确保不要删除它。

A978-1-4302-6269-5_14_Fig7_HTML.jpg

图 14-7。

Installing a package from npmon the command line

我们希望能够从我们机器上的任何文件夹中访问某些应用,并且npm允许您在安装包时使用-g全局选项来实现这一点。Mac 和 Linux 用户可能会发现,他们需要在命令前加上sudo,以此方式授予管理员用户全局安装包的权限:

npm install -g yuidocjs

一旦全局安装完毕,yuidoc应用就可以从你机器上的任何文件夹中运行。

每个 Node.js 包在其根文件夹中都包含一个package.json数据文件,该文件包含描述包的结构化元数据。该文件列出了项目的内部包名,该名称不得与 NPM 目录中使用的另一个名称相同,并且必须是 URL 友好的(例如,不包含空格),以及语义版本化格式的版本号,在 http://semver.org 在线描述,例如1.0.1。正是这个文件还列出了项目的依赖项,以便这些依赖项可以随包一起安装。

命令行工具npm包含代表您创建一个package.json文件来回答几个问题的能力。要在当前目录中创建文件,只需在命令行中执行以下命令:

npm init

清单 14-4 显示了一个示例文件package.json,它包含了一些公共属性。请注意,在任何应用中使用任何 JSON 格式文件之前,都应该删除注释。

清单 14-4。与npm一起使用的示例package.json文件

{

// The "name" value is mandatory and must be URL-friendly so cannot contain any spaces

// and should be in lower case

"name": "my-test-project",

// The "version" value is mandatory and must adhere to the semantic versioning format

"version": "0.0.1",

// An optional friendly description of the project to assist users when searching the npm

// directory

"description": "This is my test project",

// A pointer to the project's homepage online - many use this field to point to the

// GitHub (or equivalent hosting service) code repository page

"homepage": "https://github.com/denodell/my-test-project

// Details of the code repository which other developers may find useful if they wish

// to contribute to the project

"repository": {

// The type of repository, e.g. "git" (for Git) or "svn" (for Subversion)

"type": "git",

// The URL of the repository itself, designed for direct use with software and should

// not be a link to the project home page

"url": "https://github.com/denodell/my-test-project.git

},

// Details of the project author, if there is only one. For multiple authors, this key

// name should be changed to "contributors" and its value will be an array of names and

// email addresses of those who have worked on the project

"author": {

"name": "Den Odell",

"email": "denodell@me.com"

},

// List of package dependencies needed to run the project described by this file. Each is

// listed by its package name as it is in the npm directory and the version number of the

// dependency needed. By specifying the version, we can ensure that future breaking updates

// to dependent packages won't impact our package

"dependencies": {

// Specify the exact version number of the dependency required by using its full

// version number

"async": "0.9.0",

// Versions greater than or equal to specific releases can be specified using >=

"request": ">=2.36.0",

// Versions reasonably close to a given release can be specific using tilde (∼).

// Here, this means any version between 1.6.0 and any future release up to but not

// including the next major release (i.e. 1.7.0 in this case)

"underscore": "∼1.6.0",

// Git URLs can be used in place of version numbers to reference dependencies that

// are stored in places outside of the npm directory. The latest contents of the repo

// will be downloaded when this package is installed

"promise-the-earth": "git+ssh://github.com/denodell/promise-the-earth.git"

},

// List of additional package dependencies required for developers who wish to contribute

// to this project. Often this list includes development build tools, code quality checks

// and unit test runners

"devDependencies": {

"yuidocjs": "∼0.3.50"

}

}

欲了解关于package. n文件的更多信息,并阅读关于dependenciesdevDependencies部分的不同设置,请阅读位于 http://bit.ly/package_json 的在线文档。

如果您的机器上有一个包含package.json文件的本地项目,您可以通过在命令行上导航到该文件夹并执行以下命令来安装该项目及其所有必需的依赖项:

npm install

这与我们之前看到的使用命令的行为是一样的,除了省略了包名之外,npm工具将查看它运行所在的本地文件夹来发现项目的设置和依赖项。

如果您想将依赖项添加到您的项目中,并在您的package.json文件中自动添加对它的引用,您可以通过将--save选项添加到通常的 install 命令中来实现,这将把引用添加到包文件的dependencies部分并下载该包,例如:

npm install yuidocjs --save

要将依赖关系保存到devDependencies部分,请使用--save-dev选项:

npm install jshint --save-dev

如果您已经编写了一个项目,并希望将自己发布到npm供其他人用作其项目的依赖项,您只需在包含您的package.json文件的项目目录中运行以下命令,将它推送到该目录,使其可供任何其他开发人员使用:

npm publish

一旦有了package.json文件,并在项目运行之前运行了npm install下载了所有项目的依赖项,就可以使用require()方法从 Node.js 应用中访问依赖项。假设您有一个package.json文件,包含下面的dependencies部分,该部分引用了npm目录中的requestpicture-tube包:

"dependencies": {

"request": "∼2.36.0",

"picture-tube": "∼0.0.4"

}

然后,通过使用require(),这些包可以作为应用 JavaScript 文件中的依赖项被引用,如清单 14-5 所示。

清单 14-5。在 Node.js 应用中引用依赖包

// Reference a dependency on the "request" package, a simple HTTP request client

var request = require("request"),

// Reference a dependency on the "picture-tube" package, allowing 256 color images to be

// rendered out to the command line

pictureTube = require("picture-tube"),

// Reference an online image URL (PNG format only) to render to the command line

imageUrl = "http://upload.wikimedia.org/wikipedia/commons/8/87/Google_Chrome_icon_(2011).png

// Make a request to download the image URL, then use the Node.js Stream API to "pipe" the

// data through the "picture-tube" package to create command line codes representing the image.

// Finally pipe the output of that process out to the command line, represented by the

// process.stdout stream

request(imageUrl).pipe(pictureTube()).pipe(process.stdout);

安装 depdendencies,然后在命令行上执行清单 14-5 中的代码,结果如图 14-8 所示。如您所见,依赖关系的使用允许用相对较少的代码编写高级应用。在撰写本文时,npm目录包含超过 85,000 个包,并且每天都在增加,所以我鼓励您在组装自己的应用以保存他人已经编写的重写代码时,先看看这里。

A978-1-4302-6269-5_14_Fig8_HTML.jpg

图 14-8。

Referencing dependencies allows us to draw simple images to the command line with very little code

将 Node.js 应用拆分为多个文件

到目前为止,在这一章中,我们已经讨论过的应用的类型是简单的,并且,除了它们的依赖关系之外,它们自包含在一个文件中。然而,Node.js 确实支持将一个较大的应用分割成多个文件,然后可以使用require()方法相互引用。假设我们有一个名为app.js的主应用文件和一个名为utility.js的第二个文件,它们位于同一个文件夹中,我们希望在其中存储一些在应用中使用的实用方法。我们可以使用下面的require()方法从主应用文件中引用实用程序文件。注意,为了表示这是一个文件,而不是通过npm加载的外部依赖项,我们指定了文件夹名和文件名(文件夹名./表示与当前文件相同的文件夹)。我们可以排除文件扩展名.js,因为这是假设的:

var utlity = require("./utility");

然而,在我们可以使用实用程序方法之前,我们需要指定我们的utility.js文件中的哪些方法是可公开访问的,因此可以在该文件之外使用。为此,我们使用 Node.js module.exports属性,该属性对于每个文件都是本地的,并将我们希望从文件外部可用的方法和属性设置到该属性。我们可以看到清单 14-6 中的utility.js文件的简单实现。

清单 14-6。导出用于 Node.js 应用中其他文件的属性和方法

// Define a function which converts a text string to camel case, with an uppercase letter at

// the start of each word

function toCamelCase(inputString) {

return inputString.replace(/\s+[a-z]/g, function(firstLetter) {

return firstLetter.toUpperCase();

});

}

// Define a function which converts a text string from camel case to hyphens, where all letters

// become lowercase, and spaces replaced with hyphens

function toHyphens(inputString) {

return inputString.replace(/\s+([A-Z])/g, '-$1').toLowerCase();

}

// Export two public methods to any referenced file, toCamelCase(), which internally references

// the function of the same name, and toHyphens() which does the same

module.exports = {

toCamelCase: toCamelCase,

toHyphens: toHyphens

};

从清单 14-6 导出的方法,代表我们的utility.js文件,可以在我们的主应用文件中使用,如清单 14-7 所示。通过将 JSON 文件名传递给require(),观察如何将 JSON 格式文件中的数据作为 JavaScript 对象直接导入 Node.js 应用。

清单 14-7。在 Node.js 应用中使用从单独文件导出的方法

// Reference our exported utility methods the utility.js file

var utility = require("./utility"),

// Load the data from our comment-free package.json file (see Listing 14-4)

pkg = require("./package.json"),

// Use the exported utility method toCamelCase() to convert the description text from the

// package.json file into camel case

camelCaseDescription = utility.toCamelCase(pkg.description),

// Use the utility method toHyphens() to convert the camel case description into a

// lower case hyphenated form

hyphensDescription = utility.toHyphens(camelCaseDescription);

// Write out the description of the package to the command line in its different forms

console.log(camelCaseDescription); // outputs: "This Is My Test Project"

console.log(hyphensDescription); // outputs: "this-is-my-test-project"

我们可以使用module.exports导出任何类型的标准 JavaScript 数据,包括函数、对象、字符串、数字、日期和数组,然后可以直接在它们的引用文件中使用。要了解 Node.js 中模块的更多信息,请通过 http://bit.ly/nodejs_modules 在线查看 API 文档。

Web 应用的 Node.js 框架

既然我们已经了解了用 JavaScript 编写 Node.js 应用的基础知识,那么让我们来看看一些旨在帮助编写可伸缩的 web 应用的服务器端的框架。请务必阅读每种框架的文档,以确保使用正确的框架来满足您自己的项目需求。

表达

最流行的 Node.js web 框架之一是 Express,它简化了设置 web 服务器的过程,该服务器能够使用任何类型的 HTTP 方法(例如 GET 或 POST)来响应对不同 URL 的请求。然后可以用 JavaScript 为每个 URL 编写所需的行为,例如,简单地用 HTML 页面响应,或者处理提交的表单数据。清单 14-8 中的代码展示了一个使用 Express 框架配置的简单 web 服务器,展示了如何响应 HTTP GET 和 POST 方法到不同的 URL。它利用了 Sencha Labs 的 Connect 中间件,该中间件设计用于 Node.js 中的 web 服务器框架,以添加对常见操作的支持,如访问 cookie 数据、解码 HTTP POST 数据和 Gzip 压缩。要了解更多关于连接中间件库的信息,请查看位于 http://bit.ly/nodejs_connect 的在线文档。

清单 14-8。使用 Express 框架的简单 Node.js web 服务器

// Reference the Express framework through the "express" npm package dependency. Ensure

// you have a package.json file referencing this dependency

var express = require("express"),

// Reference the "connect" package which provides a set of middleware for use with other

// web server Node.js packages, including Express

connect = require("connect"),

// Initialize the framework, making its methods available through the app variable

app = express(),

// Define the port number we will host our web server on

PORT = 3000;

// The Express use() method allows a piece of middleware to be connected up to the current

// server. Here we``connect

app.use(connect.bodyParser());

// The express.static() middleware allows the contents of a particular directory on the file

// system to be made available beneath a particular path name. Here we define a "/assets" path

// on our web server which maps to the contents of a "/dist/assets" folder found within the

// current directory this application file finds itself in (the __dirname is a Node.js global

// variable available to any file. A request to any file within the "/dist/assets" folder can

// now be requested, e.g. "/assets/css/styles.css" would return the contents of the file found

// at location "/dist/assets/css/styles.css" within the current folder. This is perfect for

// serving static files, such as JavaScript CSS, images, and flat HTML required as part of a

// web site or application

app.use('/assets', express.static(__dirname + "/dist/assets"));

// The get() method allows us to respond to a HTTP GET request at a specific URL, in this

// case we specify the server root, "/", which will give us our homepage. The callback will

// be executed when the given URL is requested using the GET method, passing in the details

// of the request in the "request" object, including referer, user agent, and other useful

// information. The "response" object contains properties that can be set, and methods that

// can be called to alter the output data and headers sent to the requesting browser

app.get("/", function(request, response) {

// The send() method of the response object allows us the send data to the requesting

// browser. This method is smart enough to detect the data type being sent and to adjust

// the HTTP response headers accordingly. Here we pass in a string, which is interpreted

// as being in HTML format, but if we'd passed in a JavaScript object, this would be

// sent as a JSON string to the browser, with the appropriate headers sent. This method

// also sets the Content-Length HTTP header according to the length of the data sent,

// which informs the browser that there is no more data to be sent back to the browser

// besides that passed to the method

response.send("<h1>Hello, World</h1>");

});

// Send a HTML form as a response to a GET request to the URL "/email"

app.get("/email", function(request, response) {

// Send a HTML form whose action points to the URL "/email" and whose HTTP method is POST.

// When the form is submitted, rather than hitting this callback, the callback below will

// be called, which is associated specifically with the POST HTTP method. The form here has

// one named field, "email", which will be set as the POST data when the form is submitted.

response.send("<form method=\"post\" action=\"/email\">\

<label for=\"email\">Email address</label>\

<input type=\"email\" id=\"email\" name=\"email\" value=\"\">\

<input type=\"submit\">\

</form>");

});

// Respond to a HTTP POST of data to the URL "/email", writing out the

app.post("/email", function(request, response) {

// When the "connect" package is used, and the current Express app is associated with the

// bodyParser() middleware method from this package, the request.body property is an object

// containing properties that directly correspond to the names of POSTed data values. Since

// we posted a form field with the name "email", the request.body.email property contains

// the value entered into the form field by that name

var email = request.body.email || "";

// Show the POSTed email address value within a HTML <h1> tag on the page

response.send("<h1>Posted``email

});

// Just like the listen() method with the "http" package we saw in Listing 14-3, this starts

// the process of accepting requests to the web server from a browser on the given port. This

// keeps the Node.js application running continuously. If the application is stopped, the

// server will no longer be running and won't be able to accept any browser requests

app.listen(PORT);

// Output a message to the command line to instruct the user that the web server is running

console.log("Now try browsinghttp://127.0.0.1

清单 14-8 中的代码展示了如何使用 Node.js 的 Express framework 用不到 20 行代码编写一个能够提供静态文件、响应 HTTP GET 和 POST 请求的 web 服务器。在包含这个代码清单的文件夹中,在命令行上运行npm init来创建一个基本的package.json文件。接下来用以下命令安装依赖项,这也将在package.json文件中保存依赖项版本引用:

npm install express --save

npm install connect --save

现在我们可以在命令行上用下面的命令运行我们的应用(假设您将代码清单保存到一个名为Listing14-8.js的文件中):

npm Listing14-8.js

要了解更多关于 Express 框架的高级特性,请访问 http://bit.ly/nodejs_express_api .查看 API 在线文档

Socket.IO

尽管 Express framework 非常适合编写使用标准 HTTP 方法(如 GET 和 POST)的强大、可伸缩的 web 服务器应用,但有时您会发现自己想要编写一个实时应用,其中服务器上或浏览器中更新的数据需要立即发送并从其他位置响应,如在聊天消息应用、在线白板或协作游戏体验中。尽管 Ajax 对于这些类型的应用来说是一个相当不错的解决方案,但它只适合定期轮询服务器的数据,服务器无法在新数据可用时通知浏览器有新数据。插座。IO 是一个 web 服务器框架,它在后台使用了许多解决方案,包括 W3C WebSocket API ( http://bit.ly/websocket-api ),所有主流 web 浏览器的最新版本都支持它,包括 Internet Explorer 10 和更高版本,以提供浏览器和服务器之间的双向实时通信。

让我们构建一个简单的应用来演示套接字。IO 框架。我们将创建一个带有单个文本表单字段的 HTML 页面,用于输入消息,当消息被提交时,它将给定的消息发送到服务器,并添加消息以显示在页面上,同时显示当前时间。然后,服务器将立即用一条新消息作出响应,将收到的消息包装在一个对象中,并将其发送回浏览器。然后,浏览器将显示收到的消息和当前时间,这将显示使用 Socket 的服务器的实时响应速度。木卫一不到一秒钟。生成的应用如图 14-9 所示,显示了对通过表单提交的Hello, World消息的响应。注意每条消息旁边的时间,这表明消息被发送到服务器,并且在不到一秒的时间内再次收到响应。

A978-1-4302-6269-5_14_Fig9_HTML.jpg

图 14-9。

A simple Socket.IO application for communicating simple messages in real time with a server

清单 14-9 中的代码显示了 Node.js 服务器应用,它将初始化 web 套接字连接,监听从浏览器客户端发送的消息,并立即用一个新消息响应它们。

清单 14-9。插座。通过浏览器实时交流简单消息的 IO 应用

// Reference the Express framework through the "express" npm package dependency. Ensure

// you have a package.json file referencing this dependency

var express = require("express"),

// Initialize the framework, making its methods available through the app variable

app = express(),

// Define a dependency on the Node.js "http" module - required for use with the

// Socket.IO framework

http = require("http"),

// Create a simple web server, ensuring the Express framework handles all the requests by

// passing the app variable to the http.createServer() method

server = http.createServer(app),

// Reference the Socket.IO framework through its "socket.io" npm package dependency.

// Ensure this dependency is listed in your package.json file

socketIo = require("socket.io"),

// Connect the Socket.IO framework up to the web server to piggy back on its connection

io = socketIo.listen(server),

// Define a port number to listen for requests on

PORT = 4000;

// Make the contents of the current directory available through our web server so we can

// serve up a HTML file

app.use("/", express.static(__dirname));

// Wait for the Socket.IO connection to the browser to initiate before executing the callback,

// passing in a reference to the socket connection

io.sockets.on("connection", function(socket) {

// Send a message using the emit() method of the socket connection, passing some data to

// any connected browser listening for the "new-data-on-server" event sent from the socket

socket.emit("new-data-on-server", "Ready and waiting for messages");

// When a message named "new-data-on-client" is received from the client, execute the

// callback function, passing in the data passed along with the message

socket.on("new-data-on-client", function(data) {

// Immediate broadcast back out the received message wrapped in a simple JavaScript

// object with a property named "received"

socket.emit("new-data-on-server", {

received: data

});

});

});

// Start listening for web requests on the given port number

server.listen(PORT);

// Output a message to the command line to instruct the user that the web server is running

console.log("Now try browsinghttp://127.0.0.1

在我们将清单 14-9 中的代码作为 Node.js 应用运行之前,我们需要创建我们的package.json文件并安装我们的依赖项。在包含这个代码清单的文件夹中,在命令行上运行npm init来创建一个基本的package.json文件。接下来用以下命令安装依赖项,这也将在package.json文件中保存依赖项版本引用:

npm install express --save

npm install socket.io --save

现在我们可以在命令行上用下面的命令运行我们的应用(假设您将代码清单保存到一个名为Listing14-9.js的文件中):

npm Listing14-9.js

接下来,我们需要 HTML 页面的代码,如清单 14-10 所示。该页面将包含一个用于输入消息的表单字段和两个显示区域,以反映发送到服务器的消息和从服务器接收的消息。因为我们的 Node.js web 应用已经被配置为允许通过 web 服务器访问当前文件夹中的任何文件,如果我们将这个文件命名为Listing14-10.html,并将其放在与清单 14-9 中的代码清单相同的文件夹中,我们可以通过我们的 web 浏览器导航到 http://127.0.0.1:400/Listing14-10.html 来访问它。请注意服务器如何自动提供与套接字通信所需的 JavaScript 库。IO 网络服务器通过 URL 路径/socket.io/socket.io.js。这简化了编写代码将 HTML 页面连接到 web 服务器的任务。

清单 14-10。用于通过套接字传递实时消息的 HTML 页面。IO web 服务器应用

<!doctype html>

<html>

<head>

<title>Socket.IO Example</title>

<meta charset="utf-8">

</head>

<body>

<h1>Socket.IO Example</h1>

<!-- Create a form with a single text field, named "message-field". When the form is

submitted, we want to send the field value to the server, and show the message

in the <div id="messages-sent"> element below -->

<form method="post" action="/" id="send-message-form">

<input type="text" name="message-field" id="message-field">

<input type="submit" value="Send message">

</form>

<!-- Create an element to present messages sent from the browser to the server -->

<h2>Messages Sent From Browser</h2>

<div id="messages-sent"></div>

<!-- Create an element to present messages received from the server -->

<h2>Messages Received From Server</h2>

<div id="messages-received"></div>

<!-- Load the Socket.IO JavaScript file provided by the server - this is automatically

available at this location if Socket.IO is running on the server and provides the

necessary web socket connection back to the server. It also supplies the fallback

code in case web sockets are not supported by the current browser -->

<script src="/socket.io/socket.io.js"></script>

<!-- Load the script found in Listing 14-11 to connect the page up to the web socket

connection -->

<script src="Listing14-11.js"></script>

</body>

</html>

有了我们的 web 服务器和 HTML 页面,我们需要做的就是把它们连接在一起,这就是清单 14-11 中代码的作用,从清单 14-10 中 HTML 页面的底部引用。

清单 14-11。将页面连接到插座。IO web 服务器,用于通信和显示消息

// This script has a dependency on the "/socket.io/socket.io.js" script provided by the

// Socket.IO framework when it is referenced from the web server, which surfaces the global

// variable "io"

// Establish the web socket connection to the server to enable sending and receiving of

// messages

var socket = io(),

// Get references to the form element and text form field element from the page

formElem = document.getElementById("send-message-form"),

messageFormFieldElem = document.getElementById("message-field"),

// Get references to the empty <div> tags on the page which we will populate with messages

// sent to and received from the server

messagesSentElem = document.getElementById("messages-sent"),

messagesReceivedElem = document.getElementById("messages-received");

// Listen for the "new-data-on-server" event sent from the server over the web socket

// connection. The callback method will be executed immediately on reception of the message,

// passing along the message data sent with the event. We can then append this message to the

// current page within the <div id="messages-received"> element

socket.on("new-data-on-server", function(data) {

// Create a new paragraph element

var paragraphElem = document.createElement("p");

// Populate the new <p> tag with the current time and the message received - use

// JSON.stringify() to convert the message from its current type to a string for display

paragraphElem.innerHTML = new Date().toUTCString() + " - " + JSON.stringify(data);

// Add the message to the <div id="messages-received"> element on the page

messagesReceivedElem.appendChild(paragraphElem);

});

// Connect a form handler to the "submit" event of the <form> element to send the message

// written in the form field to the server

formElem.addEventListener("submit", function(e) {

// Define a variable for storing the message to send to the server when the form is

// submitted, populating it with the value stored in the form field

var message = {

sent: messageFormFieldElem.value

},

// Create a new paragraph element

paragraphElem = document.createElement("p");

// Prevent the default submit behavior of the <form> element from occurring, to avoid

// the page refreshing - we'll send the message data to the server manually

e.preventDefault();

// Emit a web socket broadcast event to send the message to the server using the event

// name "new-data-on-client". The server can then react to this message type as it wishes

socket.emit("new-data-on-client", message);

// Populate the new <p> tag with the current time and a copy of the sent message - using

// JSON.stringify() to convert the message from its current object type to a string for

// display

paragraphElem.innerHTML = new Date().toUTCString() + " - " + JSON.stringify(message);

// Add the message to the <div id="messages-sent"> element on the page

messagesSentElem.appendChild(paragraphElem);

// Clear the form field text to allow a new message to be sent

messageFormFieldElem.value = "";

}, false);

一起运行清单 14-9 到 14-11 中的代码会产生如图 14-9 所示的结果。如果您在多个浏览器窗口中打开页面,您应该会发现在一个窗口中发送和接收的消息与发送到另一个窗口或从另一个窗口接收的消息是分开的。这对于我们在这里讨论的应用来说是理想的;然而,对于其他类型的应用,例如聊天应用或其他需要多个连接用户之间协作的应用,这不是所期望的行为。

对从套接字发送的消息的限制。IO 服务器被包装在socket.emit()方法中,该方法用于清单 14-9 中的 web 服务器应用。按原样使用时,消息被本地化到每个单独的浏览器-服务器连接。但是,如果您希望发送一条消息,让任何连接的浏览器(包括发送浏览器)接收,您可以使用io.emit()方法,其用法与此相同:

io.emit("new-data-on-server", "Message Text");

如果你想发送一个消息到除了一个特定的浏览器之外的所有连接的浏览器,例如,发送消息的浏览器,你可以使用socket.broadcast.emit()方法socket.broadcast.emit()方法,它发送消息到除了由socket对象代表的以外的所有套接字:

socket.broadcast.emit("new-data-on-server", "Message Text");

通过创建套接字。IO 应用结合每种类型的emit()方法,你能够用少得惊人的代码创建实时多用户连接的在线应用。

了解关于套接字的更多信息。IO 以及如何使用该框架构建特定类型的应用,请通过 http://bit.ly/sockdoc 查看在线文档。

Node.js 托管

我们已经了解了如何在本地机器上设置和运行 Node.js web 服务器应用,但是在现实世界中,我们无法在本地机器上托管我们的应用。我们需要一个能够在云中运行我们的应用的在线托管解决方案。Joyent 是 Node.js 应用平台的支持者,该公司在 http://bit.ly/node_hosting 在线管理已知主机提供商的最新列表,当您编写好应用并准备就绪时,这可以是一个很好的第一个参考点。许多支持直接从 Git 库部署代码,许多提供免费的基本托管包。一旦你有了一个可以与世界分享的应用,这是一个值得一试的资源。

摘要

在本章中,我们已经了解了 Node.js,这是一个应用框架,它允许 JavaScript 开发人员编写命令行应用、web 服务器和服务器端应用。此外,我们还看到了节点包管理器和相关的npm工具、package.json文件的重要性,以及如何导入相关的代码包用于 Node.js 应用。最后,我们研究了一些流行的框架,以帮助您为标准 HTTP 应用以及现代实时 web 套接字应用编写可伸缩的 web 服务器。

在下一章中,我们将研究一组建立在 Node.js 基础上的自动化工具,您可以将它们应用到您的开发工作流中,使您的日常编码更容易,并且还可以提高您的 JavaScript 的质量。

十五、构建工具和自动化

多年来,web 开发人员的日常工作流程基本保持不变,主要是管理资产、编写代码,然后在多种浏览器中测试代码的手动过程。用于检查代码质量和简化某些任务的工具已经存在,但是这些工具都是手动运行的,因此容易被遗忘或者运行不一致,从而很容易忽略这些检查产生的结果。类似地,第三方库的管理经常是从一个项目到另一个项目的复制和粘贴,几乎没有版本控制和管理。最后,新项目的建立经常涉及到每次都完全从头开始,尽管可能会重用其他项目的代码。在这一章中,我将介绍一些工具,这些工具可以让你自动化你的开发工作流程和代码发布过程,管理你的第三方依赖,并且每次都为你的项目建立一个可靠的基础。

构建工具

当其他语言的程序员想要发布代码时,他们熟悉运行某种构建过程;除了为输出而编译代码之外,该过程通常还会根据预定义的基准检查代码质量,针对代码库中的每个功能运行单元测试,并根据解决方案的需要运行其他自动化任务。作为 JavaScript 开发人员,是时候通过使用 JavaScript 构建工具或自动化任务运行器将同样的原则应用到我们的工作中,来简化我们的开发工作流,提高我们的代码质量,并打包我们的代码的生产就绪版本了。

构建工具服务于任何想要针对他们的代码库自动运行任务的 web 开发人员,例如 JavaScript 静态代码分析、单元测试和最小化、图像压缩、SASS ( sass-lang。com )编译成 CSS,或者与其他系统的专业集成任务。如果您在团队中工作,您可以将您的构建配置与您的其余代码一起存储在一个文件中,允许每个人共享相同的任务集。全球许多公司和项目团队都在使用构建工具,包括 Adobe、Twitter 和 jQuery。在我领导的 AKQA 开发团队中,我们在运行的每个项目中都使用了本章中提到的构建工具。

在整个章节中,我们遇到了许多旨在帮助专业 JavaScript 开发人员的工具——简化新开发人员的入职,提高他们代码的质量,并减少他们代码的大小。从第二章的自动代码文档生成器和第三章的代码质量工具,到第四章的的缩小和混淆工具,都是为了确保我们的代码是最好的,也是最容易理解的。在这一节中,我们将探讨如何将所有这些类型的工具与任务运行器结合在一起,这些任务运行器将自动执行典型的开发工作流,并生成面向公共发布的代码文件的生产就绪版本。

任务运行器允许您将一系列任务或操作链接在一起,然后这些任务或操作可以按顺序运行,以针对您的代码执行某些操作。一些流行的命令行任务运行程序,如果你从其他编程语言转到 JavaScript,你可能有过经验,包括 Ant(bit . ly/Ant-build)、grad le(bit . ly/grad le-build)、Rake(bit . ly/Rake-build)、Make(bit . ly/Make-build)和 Maven(bit . ly/ly 对于基于 JavaScript 的项目,拥有一个命令行任务运行器是有意义的,它可以本地执行基于 JavaScript 的任务,这样开发人员就可以根据他们的特定需求轻松地编写和修改他们自己的任务。它还允许本地处理和执行项目中的任何 JavaScript 文件,从而可以轻松地运行概要分析和代码检查。通过在 Node.js 应用平台上构建这样一个构建系统,我们获得了跨平台兼容性的额外好处。

grunt—JavaScript 任务运行器

专业 JavaScript 开发人员在撰写本文时使用的最流行的构建工具是 Grunt ( gruntjs。com ),你可能还记得在第三章中简单提到过。Grunt 是一个自动化的任务运行器,用 JavaScript 编写,通过 Node.js 运行,可以设置任意数量的插件任务,配置为在需要时运行,可以单独运行,也可以按定义的顺序运行,以帮助您自动化 JavaScript 工作流——检查代码质量,将文件连接在一起,缩小文件,并最终生成您最满意的最终 JavaScript 代码发布给公众。通过自动化任务序列,您可以确保不会遗漏任何步骤,并且任务总是以正确的顺序运行,从而产生一致的开发工作流和构建过程。

A978-1-4302-6269-5_15_Fig1_HTML.jpg

图 15-1。

The Grunt homepage features a wealth of resources to help you get started running tasks

在撰写本文时,Grunt 有超过三千个插件任务,列在bit . ly/Grunt-plugins上,可用于您的任务序列,涵盖了您可能希望执行的最流行的任务。

安装咕噜声

在我们开始使用 grunt 之前,我们需要安装 Grunt 命令行界面(CLI)工具,可通过 bit. ly/ grunt-cli 获得,它提供了对机器上所有文件夹的 Grunt 命令行工具的全局访问。Grunt CLI 工具允许您在不同的项目文件夹中运行不同版本的 Grunt 任务运行器,如果您计划在您的机器上安装多个项目,这是一个好消息。

要安装 Grunt CLI 工具,请在机器上的任意文件夹中的命令提示符下执行以下命令(Mac 和 Linux 用户可能需要在该命令前加上sudo以作为超级用户进行身份验证):

npm install -g grunt-cli

Grunt CLI 工具、Grunt 任务运行器及其每个插件任务都是 Node.js 包,托管在 NPM 目录中。要下载 Grunt task runner 工具用于您的项目,请在您的项目目录中执行以下命令(如果该文件夹中没有文件,请运行npm init来创建一个新的package.json文件):

npm install grunt --save-dev

注意--save-dev命令行参数的使用,它自动将下载的 Grunt 包的特定版本的引用保存在package.json文件的devDependencies部分。这特别表明 Grunt 是一个仅用于开发的工具,而不是运行项目代码本身所需的包。类似地,我们安装的所有其他任务都将放在这个devDependencies部分中。例如,要为 Grunt 安装 JSHint 插件任务以对 JavaScript 文件执行静态代码分析,请在命令行上执行以下命令。关于这个名为grunt-contrib-jshint的插件的更多细节,可以通过bit . ly/grunt _ js hint找到——插件名称的contrib部分表明它是 Grunt 的官方插件,与任务运行器工具本身由同一团队开发:

npm install grunt-contrib-jshint --save-dev

配置普通任务

Grunt 及其相关的 JSHint 插件安装在我们的项目文件夹中后,下一步是配置 Grunt,用我们选择的设置执行我们的插件任务。Grunt 是通过一个名为 Gruntfile 的配置文件进行配置的,这个文件必须命名为Gruntfile.js,并且应该存在于您的项目的根文件夹中,与您的package.json文件位于同一位置。

Gruntfile 是一个标准的 JavaScript 文件,它将通过 Node.js 应用框架与 Grunt 工具一起运行,并且应该配置为 Node.js 模块——我们希望在文件外部访问的任何代码,以及 Grunt 本身,都必须设置为 Gruntfile 中的module.exports属性。

Gruntfile.js文件应该用包装函数初始化,通过grunt参数提供对 Grunt API 的访问,然后通过module.exports属性公开访问。我们所有的 Grunt 配置设置都将存在于这个函数体中:

module.exports = function(grunt) {

// Configuration goes here. The 'grunt' function parameter contains the Grunt API methods.

};

下一步是使用 API 的loadNpmTasks()方法加载我们希望 Grunt 能够运行的每个插件任务包。应该为每个插件重复以下代码行,在相关位置替换插件名称:

grunt.loadNpmTasks("grunt-contrib-jshint");

我们现在可以使用 Grunt API 的initConfig()方法来配置我们的任务设置。我们将一个 JavaScript 对象传递给该方法,每个任务有一个属性,包含应用于该任务的设置。对于 JSHint 插件任务,要使用的属性名是jshint——要使用的确切任务名将在您选择引用的任何插件的文档中详细说明。对于 JSHint 任务,该文档可在bit . ly/grunt _ JSHint上在线获得。

JSHint 插件文档详细说明了可以应用于该任务的设置,其中包括一个用于引用要应用该任务的文件的src属性,以及一个用于指定覆盖默认设置的确切规则的options属性。JSHint 规则选项的完整列表可以在bit . ly/JSHint _ opts上在线查看。如果我们希望针对名为scripts/的项目文件夹的特定子目录中的所有 JavaScript 文件以及Gruntile.js本身运行 JSHint 任务,我们可以为该任务的src属性使用以下数组值:

src: ["Gruntfile.js", "scripts/*.js"]

请注意,我们不仅可以指定确切的文件名,还可以使用星号(*)通配符值来引用匹配特定文件名模式的所有文件。例如,"scripts/*.js"模式直接在scripts/文件夹中匹配任何扩展名为.js的文件。然而,包含双星号(**)的模式,比如"scripts/**/*.js",将匹配所有扩展名为.js的文件,不仅仅是scripts/目录,还包括该文件夹的任何子目录。如果您不知道想要应用 Grunt 任务的文件夹层次结构的确切结构,双星号模式会非常有用。

因此,更新我们的 Gruntfile 来配置 JSHint 任务以对这些文件执行静态代码分析,在所有文件中强制使用严格模式"use strict"语句,这是将配置对象传递给 Grunt API 的initConfig()方法的一个简单例子,如下所示:

grunt.initConfig({

jshint: {

options: {

strict: true

},

src: ["Gruntfile.js", "scripts/**/*.js"]

}

});

执行 Grunt 时,任何任务都可以通过在命令行上直接指定其名称来运行;然而,我们需要能够轻松地管理最终将按顺序运行的任务列表,这可以通过定义别名任务名称并将其与我们希望执行的任务列表相关联来实现。在命令行上将这个别名传递给 Grunt,然后依次执行所有相关的任务。为了简单起见,Grunt 将允许一个名为default的别名任务执行,而不需要在命令行上传递它的名字。别名任务使用 Grunt API 的registerTask()方法registerTask()方法registerTask()方法注册在 Gruntfile 中,如图所示;我们可以在稍后阶段添加更多任务,方法是向数组中的任务列表添加:

grunt.registerTask("default", ["jshint"]);

清单 15-1 显示了我们基本 Gruntfile 的最终版本。将这个文件命名为Gruntfile.js,这样 Grunt 就可以识别它。

清单 15-1。一个基本的 Gruntfile

// Specify the wrapper function, which will be passed the Grunt API as a parameter, making this

// function externally available outside of this file by applying it to the module.exports

// property, as with any other Node.js modules

module.exports = function(grunt) {

// Load the Grunt plugin tasks we have previously installed with the npm tool

grunt.loadNpmTasks("grunt-contrib-jshint");

// Configure the JSHint task loaded previously with options to apply to the listed files

grunt.initConfig({

jshint: {

options: {

strict: true

},

// The use of the ** and * wildcard values ensures all .js files within the

// scripts/ folder and any subfolders are loaded for use with JSHint

src: ["Gruntfile.js", "scripts/**/*.js"]

}

});

// Register a task alias. The "default" task name ensures the listed tasks will load

// simply by executing the "grunt" command on the command line

grunt.registerTask("default", ["jshint"]);

};

跑步咕噜声

要使用任务别名default运行 Grunt,只需在命令行上执行以下命令:

grunt

如果希望运行单个任务或另一个命名别名,可以直接在命令行上指定任务或别名,如下所示:

grunt jshint

使用清单 15-1 中的 Gruntfile,当 Grunt 运行时,会产生如图 15-2 所示的输出,表明发生了一个错误并给出了该错误的详细信息。构建在这一点上停止,直到错误被修复,Grunt 再次运行。响应表明 Gruntfile 缺少一个"use strict"语句,这是我们在任务配置中强制实现的一个条件。

A978-1-4302-6269-5_15_Fig2_HTML.jpg

图 15-2。

The command line output from Grunt, indicating that a warning has occurred

在 Gruntfile 中的函数顶部添加一个"use strict"并重新运行 Grunt 会产生如图 15-3 所示的响应,表明 JSHint 任务现在已经成功,因此 Grunt default别名任务已经完成,没有错误。

A978-1-4302-6269-5_15_Fig3_HTML.jpg

图 15-3。

The command line output from Grunt, indicating that all tasks have completed successfully

扩展咕哝配置

Grunt 支持比我们到目前为止介绍的更多的特性,包括对 JSON 数据文件导入的支持,允许不同需求的替代配置设置的多任务,以及在没有可接受的现有解决方案的情况下编写自己的定制插件的能力。插件目录可以在bit . ly/grunt-plugins找到。

JSON 文件导入

Grunt API 包含一系列处理外部文件读取、写入和复制的方法。其中最常用的是file.readJSON()方法,该方法允许将存储在外部 JSON 格式文件中的数据加载到 Gruntfile 中,使其数据可以作为标准 JavaScript 对象在任务配置中使用。通常,存储在package.json文件中的属性被导入以访问项目细节,比如名称、版本、描述和存储在其中的任何其他属性。

Note

Grunt API 中的一个伴随方法file.readYAML()允许将 YAML 格式的数据文件读入 Grunt 配置文件。YAML 格式被设计成 JSON 的一个更易于阅读的等价物,更适合需要经常由人而不是机器来编辑数据文件的情况。要了解关于这种数据格式的更多信息,请访问项目主页。org 。

package.json文件的内容加载到 Gruntfile 中,如图所示,将返回值设置为与initConfig()方法一起使用的配置对象中的pkg属性:

grunt.initConfig({

pkg: grunt.file.readJSON("package.json")

});

然后可以从模板分隔符<%=%>包围的字符串值中的pkg对象属性或配置对象中的任何其他命名属性中读出值。例如,为了从package.json文件中读取nameversion属性,并在我们的 JSHint 任务中使用它们,我们引用了它们,如下所示:

grunt.initConfig({

pkg: grunt.file.readJSON("package.json"),

jshint: {

options: {

strict: true

},

src: ["Gruntfile.js", "<%= pkg.name %>-<%= pkg.version %>.js"]

}

});

假设我们的package.json文件包含以下属性,例如:

{

"name": "my-project",

"version": "0.0.1"

}

我们的 JSHint 配置将有效地解析为其src配置属性中的以下数组值:

src: ["Gruntfile.js", my-project-0.0.1.js"]

在 Grunt 运行之前,中的值被替换为指定的值,就像这些值被硬编码在文件中一样。我们可以在多个地方引用它们,对我们的package.json文件中的属性进行简单的更改,就可以在我们的 Gruntfile 中的任何需要的地方对我们的任务配置进行所有必要的更改,而不需要我们做任何额外的工作。

多任务和目标

Grunt 支持将每个任务配置分成几个独立的配置,允许不同的任务选项来适应不同的场景,例如,将不同的设置应用于不同的文件。这个特性被称为多任务,通过在 Gruntfile 的配置部分的每个任务属性名称下添加一个额外的命名对象属性来实现。这些多任务配置对象中的每一个都被称为一个目标,不同的目标可以通过 Grunt 一次运行一个,或者所有目标按照定义的顺序一起运行。

清单 15-2 中的代码显示了一个完整的 Gruntfile,其中为配置了两个目标,分别命名为gruntprojectjshint.options属性中的设置将应用于所有目标,但是可以通过在每个目标的单独配置属性中放置一个额外的options对象来覆盖每个目标。

清单 15-2。为 JSHint 多任务指定了多个目标的 grunt 文件

module.exports = function(grunt) {

// Enable strict mode

"use strict";

grunt.loadNpmTasks("grunt-contrib-jshint");

grunt.initConfig({

// Load external data from another file for use within task configuration

pkg: grunt.file.readJSON("package.json"),

jshint: {

// Apply a set of JSHint options to apply to all targets

options: {

strict: true

},

// Define a target with settings to apply in addition to those defined for all

// JSHint tasks above, including which files to apply JSHint to

grunt: {

src: ["Gruntfile.js"]

},

// Define a second target, named "project"

project: {

// Apply extra options for this target, in addition to those options applied

// for all JSHint tasks. In this case, both "strict" and "trailing" properties

// will be set to "true". Settings at this level with the same name as those

// previously defined will cause the setting to be overwritten

options: {

trailing: true

},

// Use the settings from the package.json file, stored locally in the "pkg"

// property to dynamically apply values to the Gruntfile configuration

src: ["<%= pkg.name %>-<%= pkg.version %>.js"]

}

}

});

grunt.registerTask("default", ["jshint"]);

};

对清单 15-2 所示的 Gruntfile 运行 Grunt 会产生如图 15-4 所示的输出,多任务运行中的每个单独的目标都按照 Gruntfile 本身指定的顺序依次运行。

A978-1-4302-6269-5_15_Fig4_HTML.jpg

图 15-4。

Running Grunt with a JSHint multitask containing two separate

可以在别名任务中执行特定的目标,也可以直接在命令行上执行,方法是指定任务名称后接冒号(:),然后指定目标名称。例如,要在命令行上运行 JSHint 任务的project目标,执行以下命令,将产生如图 15-5 所示的输出:

grunt jshint:project

A978-1-4302-6269-5_15_Fig5_HTML.jpg

图 15-5。

Executing a single target of an individual Grunt multitask

多任务是强大的,随着你的 Gruntfile 的增长,它变得更加有用,允许你将任务分成更具体的目标,以满足你的确切需求。

编写自己的咕哝插件

随着您使用 Grunt 的经验的积累,您可能会发现您所需要的插件在。如果是这种情况,您会很高兴听到您可以用 JavaScript 编写自己的定制插件来满足您特定的项目需求。通过bit . ly/grunt-write-plugin查看在线文档,了解如何编写自己的 Grunt 插件。

除了我们在本节中介绍的特性之外,Grunt 还支持在您的任务配置中使用大量其他选项。要了解更多关于 Grunt 提供的附加功能和示例,请查看项目网站上关于 Grunt 任务的文档页面,网址是 http:// bit. ly/ grunt-tasks 。

gulp . js——流式构建系统

紧随 Grunt 之后的是相对较新的 Gulp.js。作为一个较新的解决方案,在撰写本文时,可用插件的数量超过 600 个,约占 Grunt 可用插件数量的 20%,尽管列表每周都在增长。关于 Gulp.js 和可用插件的全部细节可以在项目主页上找到。com ,如图 15-6 所示。

A978-1-4302-6269-5_15_Fig6_HTML.jpg

图 15-6。

The Gulp.js project homepage

Gulp.js 构建系统以 Grunt 为基础,并以类似于 Grunt 的方式执行任务,但有三个明显的区别。首先,它利用了 Node.js 的 streams 功能,该功能允许一个任务的输出通过管道输入到另一个任务的输入,而不需要将临时文件写入磁盘的中间步骤;这使得任务运行更有效,在大多数情况下,更快。第二,配置从一个单一的大型配置对象中移出,支持基于代码的链式方法调用方法,将相关的配置传递给代表每个任务的方法。第三,Gulp.js 任务被有意设计成小任务——做一件事并把这件事做好——这意味着大任务执行多个动作被避开,而有利于将较小的任务连接在一起,一个任务的输出馈入下一个任务。

安装 Gulp.js

要安装 Gulp.js 以便在系统中任何文件夹的所有项目中使用,请在命令行上执行以下命令(Mac 和 Linux 用户可能需要在命令前加上sudo):

npm install -g gulp

接下来,将gulp包安装到您的项目文件夹中,将其保存到您的package.json文件的devDependencies部分——如果您还没有 package.json 文件,那么首先执行npm init来初始化该文件:

npm install gulp --save-dev

正如 Grunt 一样,插件任务是通过npm工具安装的。要为 Gulp.js 安装 JSHint 任务,我们需要通过在命令行上执行以下命令来安装gulp-jshint任务,保存它对我们的package.json文件的devDependencies部分的引用:

npm install gulp-jshint --save-dev

这个任务的文档可以在 http:// bit. ly/ gulp-jshint 上找到。

配置 Gulp.js

与 Grunt 一样,需要一个 JavaScript 配置文件来设置要运行的任务以及运行的顺序。这个文件被称为 gulpfile,必须命名为gulpfile.js。这个配置文件的结构实际上是 Node.js 应用本身,而不是像 Grunt 那样创建 Node.js 模块。因此,为了设置文件,我们需要require()“??”包并调用由此暴露的方法,这些方法代表 Gulp.js API。

插件是 Node.js 模块,它公开了表示其功能的方法,所以它们不像在 Grunt 中那样直接定义任务名。因此,要定义一个任务,必须调用 Gulp.js API 的task()方法,为新任务传入一个惟一的名称和一个在任务运行时要执行的函数,执行任何引用的插件方法并返回输出。

gulpfile 的基本结构如下:

var gulp = require("gulp");

gulp.task("default", function() {

// Tasks get called here, with the output returned to the calling function

});

Gulp.js API 包含我们将在 gulpfile 任务中使用的方法,包括src(),它让我们定义应用于插件的文件列表,dest(),它允许我们将插件和其他方法的输出写入文件系统,以及pipe(),它将一个插件或方法的输出传递给。每个方法都被设计成链式的,从而产生由一系列小的组成函数调用表示的任务。

清单 15-3 显示了一个定义了一个jshint任务的 gulpfile 示例,它在将一组文件传递给 JSHint 插件方法以对这些文件运行静态代码分析之前加载这些文件。然后,结果被传递给一个报告函数,该函数在命令窗口中显示代码分析的结果——这正是 Gulp.js 所基于的任务划分,这使得它很容易配置。与 Grunt 一样,别名任务(包括一个default任务)可以被定义为将其他任务组合成一系列应该按顺序执行的任务。

清单 15-3。样品

// Reference the gulp package and JSHint plugin package

var gulp = require("gulp"),

jshint = require("gulp-jshint");

// Define a Gulp.js task, naming it "jshint" - we can then execute it directly by name, or link

// it together with other tasks under an alias name, to be executed in sequence

gulp.task("jshint", function() {

// Return the result of the operation to the calling function

// Locate the files to use with this task - here, this gulpfile itself and any .js file

// within the scripts/ folder and its subfolders

return gulp.src(["gulpfile.js", "scripts/**/*.js"])

// Pipe those files to the "jshint" plugin, specifying the options to apply - here, we

// ensure strict mode is enforced in the selected files. This runs JSHint but does not

// display its results

.pipe(jshint({

strict: true

}))

// Finally, pipe the output of JSHint to a reporter for displaying on the command line.

// By splitting up JSHint into one part that performs the static code analysis and one

// part that displays the results, we are capable of creating more functional tasks

// that can take the direct results from JSHint and use them in any other way we

// wish to. This is what makes Gulp.js so highly configurable.

.pipe(jshint.reporter("default"));

});

// Define a default task - naming it "default" ensures it will be executed without the need to

// pass through a specific task name from the command line

gulp.task("default", ["jshint"]);

运行 Gulp.js

针对清单 15-3 中的代码运行 Gulp.js 工具,通过在命令行上执行以下命令,首先将清单 15-3 中的代码保存在名为gulpfile.js的文件中:

gulp

由于没有提供任务名称,将执行default任务,产生如图 15-7 所示的结果。

A978-1-4302-6269-5_15_Fig7_HTML.jpg

图 15-7。

The command line output from Gulp.js, indicating that an error has occurred

响应表明gulpfile.js文件缺少一个"use strict"。将该语句添加到 gulpfile 中的函数顶部,并重新运行 Gulp.js,产生如图 15-8 所示的响应,表明 JSHint 已经成功运行,因此整个 Gulp.js default别名任务已经完成,而没有。

A978-1-4302-6269-5_15_Fig8_HTML.jpg

图 15-8。

The command line output from Gulp.js, indicating that all tasks have completed successfully

扩展 Gulp.js 配置

因为 gulpfile 只是一个 Node.js 应用,所以 Grunt 配置中需要的特殊 API 方法和模板字符串在这里不需要。要从一个package.json文件中导入属性以便在任务设置中使用,只需使用 Node.js 并直接以标准 JavaScript 对象的形式访问属性:

var pkg = require("package.json");

pkg.name;   // "my-project"

pkg.version; // "0.0.1"

编写自己的 Gulp.js 插件

随着使用 Gulp.js 的经验的积累,您可能会发现您所需要的插件并不在插件目录中。如果是这种情况,您会很高兴听到您可以用 JavaScript 编写自己的定制插件来满足您特定的项目需求。在 http://bit . ly/gulp-write-plugin 查看在线文档,了解如何编写自己的 Gulp.js 插件。

除了我们在本节中介绍的特性,Gulp.js 还支持大量其他选项,供您在任务配置中使用。要了解更多关于 Gulp.js 提供的附加功能和示例,请查看项目网站上的 Gulp.js API 的文档页面,网址为 bit. ly/ gulp-api

使用构建工具自动化常见任务

构建工具能够通过提供自动化来彻底改变常见的重复性任务,使开发人员能够专注于他们的代码,而构建工具可以确保他们不需要担心其他的事情。JavaScript 构建工具的两个常见用途是在开发过程中自动执行标准工作流任务,以及准备或构建一组供公众使用的代码,确保交付高质量和高效的代码供最终的 web 应用使用。在这一节中,我将带您了解通过 Grunt 和 Gulp.js 实现满足这两种用途所需的自动化的过程。

改进您的开发工作流程

专业的 JavaScript 开发人员希望将时间花在他们的代码上,但需要确保当他们需要在浏览器中运行代码时,代码是无错误的、最新的,并且在看到任何结果之前不涉及耗时的设置过程。在本节中,我们将配置 Grunt 和 Gulp.js 来启动一个本地 web 服务器来运行我们的代码,并使用静态代码分析来检查 JavaScript 文件中的潜在错误。然后,它将等待我们对文件进行更改,当我们这样做时重新运行静态代码分析,并在分析完成后动态地重新加载连接到本地 web 服务器的任何打开的 web 浏览器。我们只需要启动我们选择的构建工具,剩下的就是自动的了。

让我们假设我们从一个项目文件夹开始,这个项目文件夹包含我们正在工作的网站的许多文件,包括我们根文件夹中的一个index.html文件和存储在一个scripts/文件夹中的 JavaScript 文件。接下来的部分解释了如何配置您选择的构建工具来改进我们在这个项目中的开发工作流程。

使用 Grunt 改进您的开发工作流程

我们可以通过使用三个 Grunt 插件来改进我们的开发工作流程:JSHint,它将提供静态代码分析;Connect(bit . ly/Grunt-Connect),它将快速轻松地启动本地 web 服务器;Watch(bit . ly/Grunt-Watch),它是一个文件更改观察器,它将查找对指定文件夹中的文件所做的更改,触发静态代码分析,并在这些更改以任何方式发生时重新加载连接到本地 web 服务器的任何打开的网页。

动态网页重载是通过 Live Reload 功能实现的()http://livere load。com 。本地 web 服务器将一个特殊的 JavaScript 文件引用注入到它所服务的任何 HTML 页面中,打开并维护到服务器的 web 套接字连接。当对代码文件进行更改时,监视任务通过此套接字连接广播一条消息,指示 web 浏览器重新加载页面,使其加载对已更改文件所做的更改。

首先将 Grunt 和三个必需的插件安装到您的项目目录中(如果这个文件夹中没有文件,使用npm init来建立一个package.json文件):

npm install grunt --save-dev

npm install grunt-contrib-jshint --save-dev

npm install grunt-contrib-connect --save-dev

npm install grunt-contrib-watch --save-dev

清单 15-4 中的代码展示了一个 Gruntfile,它可以用来改进你的开发工作流程,方法是启动一个 web 服务器,每当一个 JavaScript 文件发生变化时,使用 JSHint 运行静态代码分析,同时自动刷新连接到那个服务器的打开的浏览器窗口。遵循内联代码注释来理解每个任务是如何配置的。

清单 15-4。改进开发工作流程的 Gruntfile 文件

module.exports = function(grunt) {

"use strict";

// Define the location of the JavaScript files in our application that we wish to run our

// tasks against - along with this Gruntfile, the use of wildcard (* and **) values means

// that we're representing .js files within the scripts/ folder directly, as well as files

// one level beneath that in the folder hierarchy

var scriptFiles = ["Gruntfile.js", "scripts/**/*.js"];

// Load the JSHint, Connect and Watch plugin tasks previously installed via npm

grunt.loadNpmTasks("grunt-contrib-jshint");

grunt.loadNpmTasks("grunt-contrib-connect");

grunt.loadNpmTasks("grunt-contrib-watch");

// Configure Grunt with the JSHint, Connect and Watch tasks

grunt.initConfig({

// Configure the JSHint task to perform static code analysis on the files in our

// scripts/ folder and enforce strict mode in all

jshint: {

options: {

strict: true

},

src: scriptFiles

},

// Configure the Connect task to start up a web server at``http://localhost:3000

// default it will point to the files in the root of the project folder, so a file

// named index.html in the root folder will be displayed when browser this new URL.

// By enabling the "livereload" property, the server will inject a reference to a

// Live Reload script into your HTML pages automatically - used in conjunction with

// another task that will trigger the Live Reload in specific circumstances, such as

// the Watch task, below

connect: {

server: {

options: {

port: 3000,

livereload: true

}

}

},

// Configure the Watch task to observe changes made to any JavaScript file in our

// scripts/ folder and trigger the JSHint task when those files are changed, ensuring

// code quality standards are kept high throughout project development. Enabling the

// "livereload" option ensures that any Live Reload script in a running web page is

// notified once the JSHint task has run, causing it to be reloaded automatically,

// saving us the task of manually refreshing the page. This option thus works in

// conjunction with the Live Reload script injected into the page by the Connect task

watch: {

scripts: {

files: scriptFiles,

tasks: ["jshint"],

options: {

livereload: true

}

}

// Extra targets can be added in here for different file types, such as CSS or HTML

// files, to allow specific tasks to be triggered when those file types are changed

}

});

// Configure the default Grunt task to run JSHint, Connect and Watch tasks in sequence.

// The Watch plugin will continue to monitor for changes and, together with the LiveReload

// capability, will ensure that the web site hosted on our new web server will be kept

// up to date automatically as we change the JavaScript files in our project - no need to

// even press Refresh in our browsers!

grunt.registerTask("default", ["jshint", "connect", "watch"]);

};

对清单 15-4 中保存到名为Gruntfile.js的文件中的代码运行 Grunt,将对scripts/文件夹中的 JavaScript 文件执行 JSHint,然后导致 web 服务器在 http://localhost:3000 上旋转,指向我们项目文件夹中的文件。Grunt 进程将在命令窗口中保持活动状态,因为它会监视对 JavaScript 文件所做的更改。当它检测到发生了变化时,它将针对更新的文件重新运行 JSHint,重新加载任何打开的浏览器窗口来查看index.html页面,或者我们的 web 应用中的任何其他 HTML 页面。如果您希望停止 Grunt 进程,请使用 Ctrl+C (Windows、Linux)或 Cmd+C (Mac)键盘快捷键来终止该命令。

使用 Gulp.js 改进您的开发工作流程

我们可以利用 JSHint 和 Connect(bit . ly/gulp-Connect)插件,通过 Gulp.js 实现这种自动化开发工作流。与 Grunt 不同,它没有单独的监视插件,因为文件更改观察功能通过其watch()方法直接内置在 Gulp.js API 中。虽然这个方法不直接支持实时重载,但是我们可以配置一个定制的监视任务来执行这个方法,然后调用 Connect 插件的reload()方法,这将产生我们需要的结果。

首先将 Gulp.js 和两个必需的插件安装到您的项目目录中(如果这个文件夹中没有文件,使用npm init来建立一个package.json文件):

npm install gulp --save-dev

npm install gulp-jshint --save-dev

npm install gulp-connect --save-dev

清单 15-5 中的代码展示了一个 gulpfile,它可以用来改进你的开发工作流程,方法是启动一个 web 服务器,每当一个 JavaScript 文件发生变化时,使用 JSHint 运行静态代码分析,同时自动刷新连接到那个服务器的打开的浏览器窗口。遵循内联代码注释来理解每个任务是如何配置的。

清单 15-5。Gulpfile 改进开发工作流程

// Load the Gulp package, along with the JSHint and Connect plugins for Gulp, all of which

// have previously been installed through npm

var gulp = require("gulp"),

jshint = require("gulp-jshint"),

connect = require("gulp-connect"),

// Define the location of the JavaScript files in our application that we wish to run our

// tasks against - this gulpfile and any .js file within the scripts/ folder and its

// sub directories

scriptFiles = ["gulpfile.js", "scripts/**/*.js"];

// Define a Connect task, which will start up a web server at``http://localhost:3000

// to the files stored in the project root folder. Enabling the "livereload" property injects

// a Live Reload script into any running HTML page so that, if a message is received to reload

// the page, or any files within, the browser will do so - we will trigger this message in the

// JSHint task below

gulp.task("connect", function() {

"use strict";

connect.server({

port: 3000,

livereload: true

});

});

// Define the JSHint task to perform static code analysis on our code files, ensuring that

// strict mode is enabled for all our functions. This is similar to the JSHint task from

// Listing 15-3 with an additional command at the end of the function chain to force a Live

// Reload of any running HTML page through the web server spun up in the Connect task previously

gulp.task("jshint", function() {

"use strict";

return gulp.src(scriptFiles)

.pipe(jshint({

strict: true

}))

.pipe(jshint.reporter("default"))

// Send the message through the web server to perform a Live Reload of any HTML pages

// running from the server in any connected web browser

.pipe(connect.reload());

});

// Define a Watch task to execute the JSHint task when any of the predefined JavaScript files

// are altered. Gulp.js features its own built-in watch() method - no external plugin required

gulp.task("watch", function() {

"use strict";

gulp.watch(scriptFiles, ["jshint"]);

});

// Configure the default Grunt task to run JSHint, Connect and Watch tasks in sequence, ensuring

// high code quality whilst hosting our application and reloading the browser when changes are

// made to JavaScript files

gulp.task("default", ["jshint", "connect", "watch"]);

针对清单 15-5 中保存到名为Gruntfile.js的文件中的代码运行 Gulp.js 工具,将针对scripts/文件夹中的 JavaScript 文件执行 JSHint,然后导致 web 服务器在 http://localhost:3000 上启动,指向我们项目文件夹中的文件。Gulp.js 进程将在命令窗口中保持活动状态,因为它会监视对 JavaScript 文件所做的更改。当它检测到发生了变化时,它将针对更新的文件重新运行 JSHint,重新加载任何打开的浏览器窗口来查看index.html页面,或者我们的 web 应用中的任何其他 HTML 页面。如果您希望停止 Gulp.js 进程,请使用 Ctrl+C (Windows、Linux)或 Cmd+C (Mac)键盘快捷键来终止该命令。

创建生产就绪代码

开发人员喜欢使用的 JavaScript 代码通常被分成几个文件,提供了应用结构的逻辑划分,并允许项目团队的几个成员之间进行更简单的协作。然而,供公众使用的理想 JavaScript 代码应该分布在尽可能少的文件上,以减少 HTTP 请求,并缩小文件大小和缩短下载时间。专业 JavaScript 开发人员不会选择一种结构而非另一种结构,而是转向构建工具,从他们的原始开发文件中自动生成这种形式的生产就绪代码,通过结合静态代码分析和运行单元测试,确保只发布高质量的代码,生成代码覆盖报告,甚至能够根据原始 JavaScript 文件中特殊格式的代码注释自动生成文档网站。

为了在开发代码和发布代码之间提供良好的分离,许多开发人员选择在单个文件夹中执行他们的开发工作,该文件夹通常被命名为src/,它形成了应用于构建工具的原始文件集的基础。构建通常会生成一个单独的文件夹,通常命名为dist/,所有应用文件的生产就绪版本都将放入其中。使用这种结构,可以很少混淆哪些文件应该用于开发,哪些文件是通过构建过程自动生成的,并提供单个输出文件夹,以便在将应用代码部署到实时 web 服务器供公众使用时使用。

让我们假设我们从项目文件夹中的一个src/文件夹开始,它包含了我们正在工作的网站的一些文件,包括一个index.html文件和我们存储在scripts/子文件夹中的 JavaScript 文件。以下部分解释了如何配置您选择的构建工具来创建生产就绪代码。

使用 Gulp.js 创建生产就绪代码

我们可以添加代码来改进我们的开发工作流,通过使用七个额外的 Grunt 插件来创建生产就绪的代码:Clean(bit . ly/Grunt-Clean),它将清空一个文件夹的内容(在本例中是dist/文件夹,确保它在每次运行构建时都被清空),Copy(bit . ly/Grunt-Copy),它将把选定的静态非 JavaScript 文件从src/目录复制到dist/目录, jasmine(bit . ly/grunt-jasmine)和一个相关的伊斯坦布尔插件(bit . ly/grunt-Istanbul),用于运行单元测试并生成代码覆盖报告,Concat(bit . ly/grunt-Concat),我们将使用它将几个 JavaScript 文件的内容组合成一个,Uglify(bit . ly/grunt-Uglify)。

首先将 Grunt 和所需的插件安装到您的项目目录中(如果这个文件夹中没有文件,使用npm init来建立一个package.json文件):

npm install grunt --save-dev

npm install grunt-contrib-jshint --save-dev

npm install grunt-contrib-connect --save-dev

npm install grunt-contrib-watch --save-dev

npm install grunt-contrib-clean --save-dev

npm install grunt-contrib-copy --save-dev

npm install grunt-contrib-jasmine --save-dev

npm install grunt-template-jasmine-istanbul --save-dev

npm install grunt-contrib-concat --save-dev

npm install grunt-contrib-uglify --save-dev

npm install grunt-contrib-yuidoc --save-dev

清单 15-6 中的代码显示了一个完整的 Gruntfile,除了改进你的开发工作流程之外,还可以用来生成一个存储在src/scripts/文件夹中的 JavaScript 代码的生产就绪版本。遵循内联代码注释来理解每个任务是如何配置的。

清单 15-6。使用单独的开发和生成任务生成文件

module.exports = function(grunt) {

"use strict";

// Define variables to represent the folder and file locations required for task

// configuration - saves repetition

// The "src/" folder contains the code we will work on during development, including

// "scripts/" and "tests/" folders containing our JavaScript code and Jasmine test spec

// scripts, respectively

var srcFolder = "src/",

scriptFolder = srcFolder + "scripts/",

scriptFiles = scriptFolder + "**/*.js",

unitTestFolder = srcFolder + "tests/",

unitTestFiles = unitTestFolder + "**/*.js",

// The "dist/" folder will be generated automatically by this Gruntfile when run, and

// populated with the release version of our application files

outputFolder = "dist/",

outputScriptFolder = outputFolder + "scripts/",

// Define the name and location of a single script file into which all others will

// be concatenated into, becoming the main JavaScript file of our application

outputScriptFile = outputScriptFolder + "main.js",

// Define the name and location for a minified version of our single application script

outputScriptFileMinified = outputScriptFolder + "main.min.js",

// Define output folders for generated Istanbul reports and YUIDoc documentation files

outputReportFolder = outputFolder + "report/",

outputDocsFolder = outputFolder + "docs/";

// Load the JSHint, Connect and Watch tasks, which will be used for local development

grunt.loadNpmTasks("grunt-contrib-jshint");

grunt.loadNpmTasks("grunt-contrib-connect");

grunt.loadNpmTasks("grunt-contrib-watch");

// Load the Clean, Copy, Jasmine, Concat, Uglify and YUIDoc tasks, which will be used

// together with JSHint (loaded previously) to form our release build, preparing all

// files for public consumption

grunt.loadNpmTasks("grunt-contrib-clean");

grunt.loadNpmTasks("grunt-contrib-copy");

grunt.loadNpmTasks("grunt-contrib-jasmine");

grunt.loadNpmTasks("grunt-contrib-concat");

grunt.loadNpmTasks("grunt-contrib-uglify");

grunt.loadNpmTasks("grunt-contrib-yuidoc");

// Configure Grunt for all tasks

grunt.initConfig({

// Load the properties from the package.json file into a property for use in task

// configuration

pkg: grunt.file.readJSON("package.json"),

// Configure JSHint as in Listing 15-4

jshint: {

options: {

strict: true

},

src: scriptFiles

},

// Configure Connect as in Listing 15-4

connect: {

server: {

options: {

port: 3000,

livereload: true,

// Now we're working within the "src/" folder, use this as the location

// to find the files to host on this web server

base: srcFolder

}

}

},

// Configure Watch as in Listing 15-4

watch: {

scripts: {

files: scriptFiles,

tasks: ["jshint"],

options: {

livereload: true

}

}

},

// Probably the simplest Grunt plugin to configure, the Clean task empties the contents

// of a given folder - here we wish to ensure the "dist/" folder is empty each time

// we wish to regenerate our production-ready files

clean: [outputFolder],

// We'll use the Copy task to duplicate static files from the "src/" folder that need

// no extra processing, placing them into the "dist/" folder. In this case, we copy

// over everything except the contents of the "scripts/" and "tests/" folders

copy: {

all: {

files: [{

// The use of the exclamation point (!) before a folder or file name

// causes it to be excluded from the list of files. Here we wish to copy

// all files witin "src/", except those in the "scripts/" and "tests/"

// folders, over to the "dist/" output folder

cwd: srcFolder,

src: ["**", "!scripts/**", "!tests/**"],

dest: outputFolder,

// The "expand" property ensures the orginal folder structure is kept

// intact when the files are copied over

expand: true

}]

}

},

// Configure Jasmine to run together with Istanbul to ensure unit tests pass and to

// generate a code coverage report which will be placed in the "dist/report" output

// folder for review. We saw this Jasmine task first in Listing 3-13.

jasmine: {

coverage: {

src: scriptFiles,

options: {

// Point to the location of the unit test spec files

specs: unitTestFiles,

// Import the Istanbul template plugin for the Jasmine plugin task

template: require("grunt-template-jasmine-istanbul"),

// Configure the output folder and file for Istanbul's code coverage

// reports

templateOptions: {

coverage: outputReportFolder + "coverage.json",

report: outputReportFolder

}

}

}

},

// Instruct the Concat task to combine all the JavaScript files located in the

// "src/scripts/" folder into a single file, which we'll call "main.js". We can

// then separate our development across separate JavaScript files and combine them

// in this stage to avoid the need for an excessive number of HTTP requests to load

// all our scripts on our page

concat: {

scripts: {

src: scriptFiles,

dest: outputScriptFile

}

},

// The Uglify task will minify our concatenated JavaScript file, reducing its file

// size without removing any functionality

uglify: {

// The "banner" option allows us to add a comment to the top of the generated

// minified file, in which we can display the name and version of our project, as

// taken from our package.json file

options: {

banner: "/*! <%= pkg.name %> - version <%= pkg.version %> */\n"

},

scripts: {

// Execute a function to dynamically create the name of the destination file

// from the variable names above. This is equivalent of an object of the

// following structure, which will minify the "dist/scripts/main.js" file,

// storing the result in "dist/scripts/main.min.js", ready for use in our

// HTML page:

// {

//     "dist/scripts/main.min.js": "dist/scripts/main.js"

// }

files: (function() {

var files = {};

files[outputScriptFileMinified] = outputScriptFile;

return files;

}())

}

},

// The YUIDoc task will generate a separate static web site derived from specially

// formatted comments placed in our JavaScript files, allowing new developers to get

// up to speed with the structure of the project code without needing to comb through

// each line of code

yuidoc: {

docs: {

// The generated site will feature the name and version number, taken directly

// from the project package.json file

name: "<%= pkg.name %>",

version: "<%= pkg.version %>",

// Tell YUIDoc where to find the JavaScript files for this project, and where

// to place the generated web site files

options: {

paths: scriptFolder,

outdir: outputDocsFolder

}

}

}

});

// Define the default task to run JSHint, Connect and Watch, for local development

grunt.registerTask("default", ["jshint", "connect", "watch"]);

// Define a new "build" task to empty the "dist/" folder, copy over site files, run JSHint

// and Jasmine to check code quality, generate code coverage reports through Istanbul,

// concatenate the JavaScript files into a single application file, minify the contents of

// that file, and finally generate a documentation site based on the YUIDoc-formatted code

// comments in the original JavaScript files

grunt.registerTask("build", ["clean", "copy", "jshint", "jasmine", "concat", "uglify", "yuidoc"]);

};

对清单 15-6 中的代码运行 Grunt,并保存到一个名为的文件中,将执行default任务来改进您的开发工作流,或者执行单独的build任务来生成您的 JavaScript 应用代码文件的生产就绪版本,这取决于执行时在命令行上传递给 Grunt 工具的任务名称。运行后一项任务将自动在您的项目根文件夹中创建或清空一个dist/文件夹,将静态文件,如 HTML、图像和样式表文件(但不是 JavaScript 文件)复制到这个新文件夹中,保持与原始src/文件夹中相同的文件夹结构,执行静态代码分析并对您的 JavaScript 代码进行单元测试,生成代码覆盖报告,然后将 JavaScript 文件合并到一个单独的main.js文件中,该文件放在dist/scripts/文件夹中。然后这个文件被缩小并保存到相同的位置,命名为main.min.js,从这里可以从你的 HTML 页面中引用它。最后,该任务基于原始 JavaScript 文件中特殊格式的代码注释创建一个文档网站。

使用 Gulp.js 创建生产就绪代码

我们可以添加代码来改进我们的开发工作流,通过使用八个额外的 Gulp.js 插件来创建生产就绪的代码:Clean(bit . ly/gulp-Clean)、Jasmine(bit . ly/gulp-Jasmine)、伊斯坦布尔(bit . ly/gulp-伊斯坦布尔)、Concat(bit . ly/gulp-Concat)、Uglify(bit . com 它允许 Gulp.js 任务以不同于其输入文件的名称保存文件,非常适合为我们的小型 JavaScript 文件提供一个.min.js文件后缀和头(bit . ly/gulp-Header),这允许在任何文件的开头插入一个额外的文本字符串,非常适合在我们的小型 JavaScript 文件的开头添加注释,详细说明项目名称和版本号——这是 Grunt 的 Uglify 插件自动提供的。

首先将 Gulp.js 和所需的插件安装到您的项目目录中(如果这个文件夹中没有文件,使用npm init来建立一个package.json文件):

npm install gulp --save-dev

npm install gulp-jshint --save-dev

npm install gulp-connect --save-dev

npm install gulp-clean --save-dev

npm install gulp-jasmine --save-dev

npm install gulp-istanbul --save-dev

npm install gulp-concat --save-dev

npm install gulp-uglify --save-dev

npm install gulp-yuidoc --save-dev

npm install gulp-rename --save-dev

npm install gulp-header --save-dev

清单 15-7 中的代码展示了一个完整的 gulpfile,除了改进你的开发工作流程,它还可以用来生成一个保存在src/scripts/文件夹中的 JavaScript 代码的生产就绪版本。遵循内联代码注释来理解每个任务是如何配置的。

清单 15-7。Gulpfile 具有独立的开发和构建任务

// Load the Gulp.js package

var gulp = require("gulp"),

// Load the JSHint, Connect, Clean, Jasmine, Istanbul, Concat, Uglify, YUIDoc, Rename and

// Header plugin tasks

jshint = require("gulp-jshint"),

connect = require("gulp-connect"),

clean = require("gulp-clean"),

jasmine = require("gulp-jasmine"),

istanbul = require("gulp-istanbul"),

concat = require("gulp-concat"),

uglify = require("gulp-uglify"),

yuidoc = require("gulp-yuidoc"),

rename = require("gulp-rename"),

// The Header task adds a given string of text to the top of a file, useful for adding

// dynamic comments at the start of a file

header = require("gulp-header"),

// Load the properties from the package.json file into a variable for use in task

// configuration

pkg = require("./package.json"),

// Define variables to represent the folder and file locations required for task

// configuration - saves repetition

// The "src/" folder contains the code we will work on during development, including

// "scripts/" and "tests/" folders containing our JavaScript code and Jasmine test spec

// scripts, respectively

srcFolder = "src/",

scriptFolder = srcFolder + "scripts/",

scriptFiles = scriptFolder + "**/*.js",

unitTestFolder = srcFolder + "tests/",

unitTestFiles = unitTestFolder + "**/*.js",

// The "dist/" folder will be generated automatically by this Gruntfile when run, and

// populated with the release version of our application files

outputFolder = "dist/",

outputScriptFolder = outputFolder + "scripts/",

// Define the name and location of a single script file into which all others will

// be concatenated into, becoming the main JavaScript file of our application

outputScriptFileName = "main.js",

outputScriptFile = outputScriptFolder + outputScriptFileName,

// Define the file suffix to apply to the minified version of our single``application

outputScriptFileMinifiedSuffix = ".min",

// Define output folders for generated Istanbul reports and YUIDoc documentation files

outputReportFolder = outputFolder + "report/",

outputDocsFolder = outputFolder + "docs/";

// Configure Connect as in Listing 15-5

gulp.task("connect", function() {

"use strict";

connect.server({

port: 3000,

livereload: true

});

});

// Configure JSHint as in Listing 15-5

gulp.task("jshint", function() {

"use strict";

return gulp.src(scriptFiles)

.pipe(jshint({

strict: true

}))

.pipe(jshint.reporter("default"))

.pipe(connect.reload());

});

// Configure a Watch task as in Listing 15-5

gulp.task("watch", function() {

"use strict";

gulp.watch(scriptFiles, ["jshint"]);

});

// Define a Clean task to empty the contents of the "dist/" output folder each time we prepare

// our production-ready release code

gulp.task("clean", function() {

"use strict";

return gulp.src(outputFolder, {

// Setting the "read" option to false with gulp.src() causes Gulp.js to ignore the

// contents of the input files, resulting in a faster task

read: false

});

// Pipe the output folder through the clean() task method, erasing it from the file

// system

.pipe(clean());

});

// Define a Copy task to duplicate static files that we wish to bundle with our release code

// into our output "dist/" folder

gulp.task("copy", function() {

"use strict";

// Copy all files witin "src/", except those in the "scripts/" and "tests/" folders, over

// to the "dist/" output folder. There is no need for a special plugin to perform file

// copying, it is handled directly through the Gulp.js API methods src() and dest()

return gulp.src(["**", "!scripts/**", "!tests/**"], {

cwd: srcFolder

})

.pipe(gulp.dest(outputFolder));

});

// Define a task to perform unit testing through Jasmine and code coverage report generation

// via Istanbul. To save running two tasks, which will effectively end up running the unit

// tests twice, we combine the two together into one task

gulp.task("jasmine-istanbul", function() {

"use strict";

// Pipe the files from the "src/scripts/" directory into Istanbul, which "instruments" the

// files, ensuring that code coverage reports can be generated later. No files are actually

// saved to the file system, they are kept in memory while they are being used, and are

// then destroyed when the task is complete

return gulp.src(scriptFiles)

.pipe(istanbul())

// When the script files have been instrumented, execute Jasmine against the unit

// test specs, piping the code coverage reports generated by Istanbul when these tests

// are run into the "dist/reports" folder

.on("finish", function() {

// Run the unit test files through Jasmine. Due to the nature of the gulp-jasmine

// plugin, the unit test files must be Node.js application files, meaning that

// before the tests can be run, we need to include a line at the top of the test

// script to require() the original script file we are testing - this means that

// the script file we're testing needs to have a "module.exports = " line to

// expose the functions to test for the unit test script. A few extra hoops to jump

// through compared to when testing with Grunt, however it's not too unfamiliar

// territory!

gulp.src(unitTestFiles)

.pipe(jasmine())

// Create the Istanbul code coverage reports now the unit tests have been run

// against the instrumented code

.pipe(istanbul.writeReports({

dir: outputReportFolder,

reporters: ['lcov'],

reportOpts: {

dir: outputReportFolder

}

}));

});

});

// Define a Concat task to combine the files in the "src/scripts/" folder into a single

// JavaScript application file

gulp.task("concat", function() {

"use strict";

return gulp.src(scriptFiles)

// Pass the name of the new script file to create to the concat() function

.pipe(concat(outputScriptFileName))

// Place the new file into the "dist/scripts/" output folder

.pipe(gulp.dest(outputScriptFolder));

});

// Define an Uglify task to minify the contents of our concatenated JavaScript file to reduce

// its size without removing its functionality, before adding a header comment to the minified

// file, renaming it to add a ".min" suffix to the resulting file, and then placing the new

// file in the "dist/scripts/" output folder

gulp.task("uglify", function() {

"use strict";

// Run the "dist/scripts/main.js" file through the uglify() task method to produce a

// minified version of that file

return gulp.src(outputScriptFile)

.pipe(uglify())

// Add a comment header to the minified JavaScript file, including the name and

// version details from the package.json file

.pipe(header("/*! " + pkg.name + " - version " + pkg.version + " */\n"))

// Rename the minified file to add the ".min" suffix, the resulting file name will

// then be "main.min.js"

.pipe(rename({suffix: outputScriptFileMinifiedSuffix}))

// Place the minified file into the "dist/scripts/" output folder

.pipe(gulp. (outputScriptFolder));

});

// Define a YUIDoc task to generate a separate static web site derived from specially formatted

// comments placed in our JavaScript files, allowing new developers to get up to speed with the

// structure of the project code without needing to comb through each line of code

gulp.task("yuidoc", function() {

"use strict";

// Load the JavaScript files from the "src/scripts/" folder and run these through YUIDoc,

// passing in the name and version number of the project from the package.json file to

// include in the resulting static documentation web site

return gulp.src(scriptFiles)

.pipe(yuidoc({

project: {

"name": pkg.name,

"version": pkg.version

}

}))

// Place the resulting static web site files in the "dist/docs/" output folder

.pipe(gulp.dest(outputDocsFolder));

});

// Define a default task to run JSHint, Connect and Watch, for local development

gulp.task("default", ["jshint", "connect", "watch"]);

// Define a new "build" task to empty the "dist/" folder, copy over site files, run JSHint

// and Jasmine to check code quality, generating code coverage reports with Istanbul,

// concatenate the JavaScript files into a single application file, minify the contents of that

// file, and finally generate a documentation site based on the YUIDoc-formatted code comments

// in the original JavaScript files

gulp.task("build", ["clean", "copy", "jshint", "jasmine-istanbul", "concat", "uglify", "yuidoc"]);

针对清单 15-7 中的代码运行 Gulp.js,并保存到一个名为gulpfile.js的文件中,将执行default任务来改进您的开发工作流,或者执行单独的build任务来生成您的 JavaScript 应用代码文件的生产就绪版本,这取决于执行时在命令行上传递给 Gulp.js 工具的任务名称。运行后一项任务将自动在您的项目根文件夹中创建或清空一个dist/文件夹,将静态文件,如 HTML、图像和样式表文件(但不是 JavaScript 文件)复制到这个新文件夹中,保持与原始src/文件夹中相同的文件夹结构,执行静态代码分析并对您的 JavaScript 代码进行单元测试,生成代码覆盖报告,然后将 JavaScript 文件合并到一个单独的main.js文件中,该文件放在dist/scripts/文件夹中。然后,这个文件被缩小,放在同一个位置,名为main.min.js,从这里可以从您的 HTML 页面引用它。最后,该任务基于原始 JavaScript 文件中特殊格式的代码注释创建一个文档网站。

Grunt 和 Gulp.js 都非常好地执行了这些自动化任务,尽管 Gulp.js 在速度上有优势,而 Grunt 有最好的插件支持。考虑到这一点和项目的需求,您应该选择合适的构建工具。

管理第三方库和框架

随着您的 web 应用的增长,您可能会发现您的代码依赖于许多第三方库、框架和插件脚本,您需要在项目中本地存储和管理它们。其中一些脚本最好与您引用的其他脚本的特定版本一起使用,确保您安装了每个脚本的正确版本可能是一项维护工作。受 Node.js package.json文件方法的启发,许多前端工具如雨后春笋般涌现,允许第三方库和框架以及它们的确切版本号在配置文件中定义。然后,包管理器可以使用这个配置在您的项目中安装这些库依赖项,这个过程可以使用 Grunt 和 Gulp.js 自动完成,这意味着您不需要将第三方依赖项文件提交到您的项目源代码控制系统,每次都可以使用您选择的构建工具动态安装每个依赖项的正确版本。

最常见的前端包管理器之一是 Bower ( bower。io ,由 Twitter 开发。通过其类似于 NPM 目录的包目录,开发人员可以访问超过 15000 个 JavaScript 库和框架,包括 jQuery、RequireJS、AngularJS、Backbone 和许多其他流行的脚本和插件。与 Grunt 和 Gulp.js 一样,Bower 也是基于 Node.js 构建的,因此要安装该工具以便在您机器上的所有文件夹中使用,需要在命令行上执行以下命令(Mac 和 Linux 用户可能需要在命令前加上sudo):

npm install -g bower

与 Node.js 通常使用的用于管理包和依赖项的特定版本的package.json文件非常相似,Bower 包管理器需要使用一个bower.json文件,它包含一般的项目细节以及前端库和框架依赖项的列表。除了主项目依赖项之外,还可以指定仅开发依赖项,这些依赖项定义了仅开发所需的那些不应出现在代码的生产版本中的库,如调试脚本和单元测试框架。

用 Bower 安装软件包与使用npm工具安装软件包非常相似——命令行中提供了软件包的名称,取自在线目录中的 bower. io/ search/ ,还有用于将软件包添加到bower.json文件的dependencies部分的--save选项,以及允许将软件包添加到文件的devDependencies部分的--save-dev选项,表明软件包不应该是主应用的一部分,而只是开发所需的。因此,要安装 jQuery 包,请在项目的根文件夹中的命令行上执行以下命令:

bower install jquery --save

默认情况下,所有的包都被安装在一个bower_components/文件夹中,您应该确保这个文件夹不会被提交到您的源代码控制系统,因为特定版本的已定义包应该使用 Bower 动态安装到每个开发人员的机器上。然后,您的代码可以在应用中引用该目录中的文件。

清单 15-8 显示了一个示例bower.json文件,为一个 JavaScript 应用定义了三个第三方库依赖——jQuery、AngularJS 和 RequireJS,以及一个只用于开发的 Firebug Lite(bit . ly/Firebug-Lite)脚本。

清单 15-8。一个示例bower.json文件

{

"name": "my-project",

"version": "0.0.1",

"dependencies": {

"jquery": "∼2.1.1",

"angular": "∼1.2.17",

"requirejs": "∼2.1.14"

},

"devDependencies": {

"firebug-lite": "∼1.5.1"

}

}

一旦您的项目有了一个已定义的bower.json文件,为新开发人员安装所有包就是一个简单的例子,在命令行上执行以下命令,将所有已定义的包安装到项目文件夹中:

bower install

Bower 的依赖项安装可以分别通过使用grunt-bowercopy(bit . ly/Grunt-Bower copy)和gulp-bower(bit . ly/gulp-Bower)插件使用 Grunt 或 Gulp.js 实现自动化,这两个插件都将安装本地bower.json文件中定义的包,然后将结果文件从本地bower_components/目录复制到定义的输出目录,比如我们在前面的自动化部分看到的dist/文件夹结构。

Bower 和其他类似的包管理器——包括 spm。io 、组件( component。io ),以及 Jam ( jamjs。org ),所有这些都以类似的方式配置和工作——允许在您的前端 web 应用中简单而有效地管理特定版本的第三方依赖项,确保不太可能出现错误,并且只为您的项目安装所需的确切依赖项。

项目设置和搭建

启动和设置一个新项目的过程通常很耗时,涉及到许多关于最佳文件夹和文件结构、最合适的框架以及自动化和依赖管理的最佳方法的决策。本着让开发人员有更多时间专注于开发的精神,谷歌的一个团队创建了一个名为 Yeoman 的自动化工具。io )旨在用于建立新项目、配置文件夹结构、创建初始文件以及安装和配置构建工具,使您(开发人员)能够在最快的时间内开始开发。

Yeoman 整合了用于自动化任务运行的 Grunt、用于 web 包管理的 Bower 以及 Yeoamn 的核心 yo——将 Grunt 和 Bower 工具与正确的配置连接在一起的粘合剂,并代表您创建默认文件夹和文件。因为不同类型的项目需要不同的配置、结构和文件,所以称为生成器的 Yeoman 插件提供了设置和初始化不同类型的项目所需的特定设置。

Yo 是一个 Node.js 工具,可以通过在命令行上执行以下命令来安装,如果 Grunt 和 Bower 尚未安装,它也会安装它们(Mac 和 Linux 用户可能需要在命令前加上sudo):

npm install -g yo

接下来,您需要选择一个合适的生成器,作为您想要创建的新项目的基础。一个超过二十个官方支持的生成器的目录,专门为 AngularJS、Backbone、jQuery 和其他框架定制,可以在bit . ly/yeoman-official在线获得,一个超过九百个非官方社区生成器的列表可以通过bit . ly/yeoman-community获得。如果您刚刚开始使用 Yeoman,并且需要使用 HTML5 样板文件(html 5 样板文件)生成一个简单的网站。com 、jQuery、Modernizr ( modernizr。com )和 Bootstrap(get Bootstrap。com ,那么推荐generator-webapp插件(bit . ly/yeoman-web app)。要安装这个生成器,以便在机器上的任何文件夹中使用,只需在任何文件夹中的命令行上执行以下命令:

npm install –g generator-webapp

要使用生成器来设置新项目,请创建一个新文件夹,并在命令行上导航到该文件夹,然后执行 yo 工具,传入生成器的名称,该名称始终是其名称中连字符(-)之后的部分。例如,要使用generator-webapp生成器来初始化一个新的项目文件夹,请在新文件夹中的命令行上执行以下命令:

yo webapp

执行该命令产生如图 15-9 所示的响应,显示一个介绍图形,并询问用户关于他们想要安装哪些额外库的问题,该问题的答案将决定在结果结构中创建哪些文件夹和文件,以及如何设置配置文件。回答这个问题会触发目录中项目文件的安装和配置。在命令行上选择默认选项时产生的结果文件夹和文件结构如图 15-10 所示,只需几秒钟即可生成。

A978-1-4302-6269-5_15_Fig9_HTML.jpg

图 15-9。

Yeoman running on the command line, helping lay the foundation for a new project

A978-1-4302-6269-5_15_Fig10_HTML.jpg

图 15-10。

Yeoman generates and configures folders and files for use with your new project

Yeoman 允许开发人员使用预构建的生成器在几秒钟内初始化具有最佳文件夹、文件和配置的新项目,这些生成器会向开发人员询问他们希望如何设置项目。可以根据具体的项目需求,按照bit . ly/yeoman-create上的在线说明来构建定制的生成器。利用 Yeoman 大大减少建立新项目所需的时间,并受益于其预装的 Grunt 和 Bower,简化您的开发工作流程,帮助您专注于编写最佳代码。

摘要

在这一章中,我们研究了 JavaScript 构建工具和重复耗时任务的自动化,以改进您的开发工作流程并确保您发布给公众的代码的质量。我们已经看到了 web 包管理器如何更好地为您的项目代码定义第三方前端依赖项,以及如何为您的项目文件创建初始设置,从而为进一步的开发提供坚实的基础。专业 JavaScript 开发人员使用构建工具让他们有更多的时间专注于编写和改进他们的代码,而不是担心他们的工作流程和管理他们的发布过程。尝试我在本章中介绍的工具,找到最适合您的特定项目需求的工具和插件,并在您的日常工作中从中受益。

在下一章中,我们将着眼于内置于当今 web 浏览器中的开发工具,这些工具可以帮助您在现实环境中运行时调试和分析 JavaScript 代码。

十六、浏览器开发工具

在本书中,我解释了专业 JavaScript 开发人员如何使用高级编码技术、利用语言和浏览器功能,以及将工具应用到他们的工作流和代码中,以便生成高质量、可维护的 JavaScript 代码,使他们的应用的最终用户和其他开发人员受益。web 开发的主要前沿是浏览器,它是在野外运行我们的应用以供公众访问的平台。虽然我们可以针对我们的代码运行大量的工具来预先检查其质量,但是在 web 浏览器中运行代码以确保它不仅正确运行,而且高性能和高内存效率,这确实是无可替代的。我们可以在浏览器环境中使用内置于所有主流浏览器中的开发工具集来测量和调试我们的代码,以便深入了解代码执行时发生的情况。我们可以使用从这些工具中收集的数据来改进我们的代码,将最后的润色添加到我们的 JavaScript 中,以确保它能够为我们的最终用户高效地执行。在这一章中,我将带你浏览浏览器开发工具,这些工具将有助于确保你的代码是高性能和准确的。

最早流行的浏览器开发工具之一是 Firebug ( getfirebug。com ,2006 年发布的 Mozilla Firefox 浏览器的扩展,它允许开发人员针对当前运行的网页执行任务,包括检查当前页面结构的实时表示中的 DOM 元素和属性,观察应用于任何元素的 CSS 样式规则,跟踪渲染当前页面的所有网络请求的列表,以及针对内存中运行的页面代码执行命令的 JavaScript 开发人员控制台。由于其易用性和强大、准确的工具集,它很快被世界各地的专业 web 开发人员采用,并确保 Firefox 成为当时开发人员的首选浏览器。

自 2006 年以来,每个主要的浏览器制造商都将他们自己的一套等效的开发工具集成到他们的产品中,其中许多功能都是受 Firebug 的启发或从 Firebug 中派生出来的,而今天的 web 开发人员现在对于哪个是最好的浏览器内工具集存在分歧。在这一章中,我将向您介绍各种主流浏览器中的开发工具,并详细介绍它们共享的功能,这些功能将帮助您调试和提高 JavaScript 代码的效率。

定位隐藏的浏览器开发工具

桌面设备上最新版本的 Microsoft Internet Explorer、Google Chrome、Apple Safari、Opera 和 Mozilla Firefox 浏览器都包含一组隐藏的开发人员工具,可以通过以下方式访问:

  • 在 Internet Explorer 中,只需按键盘上的 F12 键;或者,从浏览器的设置菜单中选择“F12 开发者工具”。
  • 在 Chrome 中,使用 Windows 中的 Ctrl + Shift + I 组合键或 Mac OS X 中的 Option ( A978-1-4302-6269-5_16_Fige_HTML.jpg ) + Command ( A978-1-4302-6269-5_16_Figa_HTML.jpg ) + I 组合键,或者从菜单中选择 Tools\Developer Tools。
  • 在 Safari 中,打开“偏好设置…”菜单,选择“高级”标签,然后选中“在菜单栏中显示开发菜单”选项。然后组合键 Option ( A978-1-4302-6269-5_16_Figf_HTML.jpg ) + Command ( A978-1-4302-6269-5_16_Figb_HTML.jpg ) + I 打开开发者工具。或者,在选择此选项时出现在菜单栏中的“新开发”菜单中,选择“显示 Web 检查器”菜单项。
  • 在基于谷歌 Chrome 的 Opera 中,使用相同的组合键,Windows 中的 Ctrl + Shift + I 或 Mac OS X 中的 Option ( A978-1-4302-6269-5_16_Figg_HTML.jpg ) + Command ( A978-1-4302-6269-5_16_Figc_HTML.jpg ) + I,或者选择菜单栏中的 View\Show Developer 菜单选项。
  • 要在 Firefox 中打开标准的开发人员工具,请在 Windows 中使用 Ctrl + Shift + C 组合键,或者在 Mac OS X 中使用 Option ( A978-1-4302-6269-5_16_Figh_HTML.jpg ) + Command ( A978-1-4302-6269-5_16_Figd_HTML.jpg ) + C 组合键,或者从菜单栏中选择 Tools\Web Developer\Inspector。
  • 要打开 Firefox 的 Firebug 开发工具,首先从 getfirebug 安装浏览器扩展。然后从菜单栏中选择 Tools \ Web Developer \ Firebug \ Show Firebug 选项。

或者,许多浏览器允许您右键单击当前页面上的任何元素,并选择 Inspect Element 上下文菜单项,以调出开发人员工具栏,显示页面 DOM 当前状态的默认视图。

图 16-1 显示了在 Safari 浏览器中针对网页运行的浏览器开发工具。请注意,在工具栏的左侧可以看到正在运行的页面的动态 DOM 结构,在工具栏的右侧可以看到应用于每个元素的 CSS 样式规则。将鼠标悬停在 DOM 结构中的某个元素上,会在运行的网页上突出显示该元素,以帮助调试。您可以单击编辑 DOM 和 CSS 属性,以在浏览器窗口中实时更新页面内容。

A978-1-4302-6269-5_16_Fig1_HTML.jpg

图 16-1。

The browser developer tools running against a web page in the

所有主流浏览器中的开发工具都共享许多相同的数据视图。在这一章中,我们将关注那些最适合 JavaScript 开发的;但是,我鼓励您亲自体验每一种特性,以发现这些特性不仅仅可以帮助您进行 JavaScript 开发。

JavaScript 控制台

JavaScript 开发人员的浏览器开发工具的主要选项卡称为控制台。这不仅允许您针对从当前运行页面加载到内存中的代码执行 JavaScript 命令,还允许您使用开发者工具提供的console JavaScript 对象从代码中输出调试信息。我们在第十四章中看到了这个相同的对象,它被用来向运行 Node.js 应用的命令行输出消息。这里,它将消息输出到浏览器开发人员工具中的 JavaScript 控制台窗口。

将消息输出到控制台窗口

如果您希望输出变量值,或者向控制台窗口写一条消息来帮助您调试正在运行的 JavaScript 代码中的问题,请使用console.log()方法而不是标准浏览器alert()对话框。为了显示所需的信息,您可以根据需要随时写入控制台;您甚至可以向该方法传递多个参数,以便在控制台的同一行上并排写出这些参数。传递给函数的任何变量都将根据其值类型以适当的方式显示,包括对象和函数,对象显示时带有可单击的句柄,允许您浏览其层次结构以查看其属性和方法的内容,函数显示为字符串,允许您查看其中的代码内容。清单 16-1 显示了console.log()方法的一些可能用途,图 16-2 显示了当代码在浏览器的网页环境中运行时,Chrome 浏览器开发人员工具控制台的输出。

清单 16-1。将值和变量输出到 JavaScript 开发人员工具控制台

console.log("Code executed at this point");

console.log("The <body> element:", document.body);

console.log("<body> element class name:", document.body.className);

console.log("The window object:", window);

A978-1-4302-6269-5_16_Fig2_HTML.jpg

图 16-2。

The console output of running Listing 16-1 within a web page

您可以使用console.log()方法的四种不同方法将特定的控制台消息和变量值表示为不同的类型:

  • console.info(),表示该消息仅供参考
  • console.debug(),表示该消息旨在帮助调试错误
  • console.warn(),表示代码中可能出现了问题,该消息指出了潜在问题的详细信息
  • 这表示发生了错误,并且附带的消息包含该错误的详细信息

每一个都以不同的方式在开发人员控制台中突出显示,控制台窗口顶部的一个过滤器控件允许根据显示消息的不同方法所表示的类型来隐藏和显示消息。

如果您希望使用自己的配色方案突出显示您的控制台消息,您可以提供 CSS 样式信息作为第二个参数给一个console.log()方法调用,前提是您以%c控制代码序列开始第一个参数中包含的基于字符串的消息。

清单 16-2 显示了可以应用于发送到控制台窗口的消息的不同类别级别,以及如何将您自己的定制样式应用到您的消息中。图 16-3 显示了当清单 16-2 中的代码在浏览器的网页环境中运行时,Chrome 浏览器开发者工具控制台的输出。请注意不同类别的消息如何以不同的方式显示,以便识别它们。

清单 16-2。向 JavaScript 开发人员工具控制台输出不同的类别级别和消息样式

console.info("info(): Code executed at this point");

console.debug("debug(): The <body> element:", document.body);

console.warn("warn(): <body> element class name:", document.body.className);

console.error("error(): The window object:", window);

console.log("%clog(): This is a custom-styled message", "font-weight: bold; background: black; color: white;");

A978-1-4302-6269-5_16_Fig3_HTML.jpg

图 16-3。

The console window output in Google Chrome when running Listing 16-2 within a web page

console对象的info()debug()warn()error()方法对于较大的 JavaScript 应用非常有用,这些应用可能需要在开发期间进行大量的调试,以便在发布给公众之前识别和修复潜在的问题。您应该尽可能多地使用它们来理解您的代码是如何运行的,利用控制台窗口的消息过滤功能,只标记您在任何时候都需要的那些类型的消息。

使用控制台进行性能测量

console对象包含两个方法,time()timeEnd(),用于处理时间的流逝,我们可以用它们来创建代码性能度量的基本形式。执行console.time(),将唯一标识符标签字符串作为参数传递给它,启动与给定标识符相关联的浏览器中运行的计时器。使用不同的标识符调用同一个方法允许启动多个计时器,而不会互相干扰。要停止计时器运行,执行console.timeEnd(),向其传递与启动计时器相同的标识符值。此时,计时器开始和结束之间经过的毫秒数将连同与该计时器相关联的标识符名称一起被写到 JavaScript 控制台。这允许对代码的各个部分进行基本的性能测量,这可以帮助您确定代码中的性能瓶颈,并帮助您重写代码以简化任何相对较慢的操作。

清单 16-3 中的代码演示了如何使用console对象的time()timeEnd()方法来测量特定代码循环的性能。

清单 16-3。使用 JavaScript 开发人员控制台测量运行一段代码所需的时间

function counter(length) {

var output = 0,

index = 0;

console.time("Loop timer " + length);

for (; index < length; index++) {

output = output + index;

}

console.timeEnd("Loop timer " + length);

}

counter(10000);

counter(100000);

counter(1000000);

在浏览器的网页环境中运行清单 16-3 中的代码会产生如图 16-4 所示的 JavaScript 控制台输出。注意每个time()timeEnd()方法调用之间的持续时间被写入控制台,旁边是与每个counter()方法调用相关联的唯一标识符名称。在本章的后面,我们将进一步了解 JavaScript 性能分析。

A978-1-4302-6269-5_16_Fig4_HTML.jpg

图 16-4。

The console output of running Listing 16-3 within the context of a web page

移除对用于释放的控制台对象的代码引用

在发布最终的公共代码之前,请确保从 JavaScript 文件中删除了对console对象的引用,因为该对象只存在于当前安装并启用了开发工具的浏览器中,而在大多数用户的浏览器中,情况可能并非如此。如果你正在使用一个自动化的 Grunt 构建过程,你可以使用grunt-strip插件任务(bit . ly/Grunt-strip)来为你做这件事。对于 Gulp.js task runner 用户来说,gulp-strip-debug包( bit. ly/ gulp-strip )是你需要在发布前移除这个对象的方法调用的插件。

对于开发人员来说,JavaScript 控制台是一个非常有用的工具,可以在代码运行时输出消息,检查变量的状态,确定哪些代码分支正在执行,以及某些操作需要多长时间才能完成。要了解关于 JavaScript 控制台的更多信息,请查看 Matt West 在 Treehouse 博客上发表的文章“掌握开发人员工具控制台”,网址是 http:// bit. ly/ js-console 。

调试正在运行的 JavaScript 代码

除了 JavaScript 控制台,浏览器开发人员工具还允许观察和调试正在运行的代码,让代码停止运行,一行一行地执行,并在任何时候恢复运行。缩小的代码对于开发人员工具来说不是问题,因为我们有办法通过按下按钮将其转换为未缩小的代码,或者引入到每个 JavaScript 文件的未缩小版本的链接,当在工具窗口中显示时,浏览器工具用该链接替换运行的代码。

使用精简代码

尽管 JavaScript 开发人员控制台非常适合于在开发过程中调试和跟踪代码,但有时我们需要调试已经被缩小并从 web 服务器而不是本地开发机器上运行的代码。幸运的是,在浏览器开发工具中,有两个选项可以用来处理缩小的 JavaScript 文件:漂亮打印和源地图。

漂亮的印刷

在 browser developer tools 中处理缩小代码的第一个选项是美化打印,通过遵循标准的制表符和空格模式将缩小过程中从 JavaScript 文件中删除的空格添加回文件中,以嵌套函数、对象和其他代码块,从而使缩小后的文件再次可读。

在 Firefox developer tools 中启用 pretty-print 是一个简单的例子,选择它的 Debugger 选项卡来显示当前由显示的 web 页面加载到内存中的 JavaScript 文件。从左侧面板的列表中选择一个缩小的文件,然后单击底部工具栏中标有“{}”的图标按钮,观察文件如何被赋予适当的间距,以便于阅读。如果变量已经被混淆,被更短、更晦涩的名字所取代,这些仍然会以它们的混淆形式显示,这可能会使调试更加困难;但是,漂亮的打印可以帮助您更好地识别原始文件的结构,以便在浏览器中调试它。

Firebug 扩展支持通过显示在其脚本选项卡顶部工具栏中的相同按钮进行漂亮打印。启用此选项后,选择左侧面板中的任何文件都会显示该文件的精美打印版本,无论原始文件是否被缩小。

在 Safari 的开发者工具中,选择“资源”选项卡,从左侧面板的列表中找到一个缩小的 JavaScript 文件,然后单击文件文本上方右上角工具栏中的“{}”按钮来美化其内容。

类似地,在 Chrome 和 Opera 开发工具中,它们共享相同的底层代码库,选择 Sources 选项卡并识别一个缩小的文件;点击文件下方的“{}”按钮,将打印出文件内容。

在 Internet Explorer 中,打开开发工具并选择调试器选项卡,找到一个缩小的文件,然后单击顶部工具栏中的“{}”按钮来美化所选文件的内容。

漂亮打印是在浏览器开发工具中查看缩小文件的一种非常快速和简单的方法,尽管对于更简单的调试,以及访问模糊变量和函数名的原始名称,源映射是更好的选择。

源地图

在浏览器开发工具中处理缩小代码的第二个选项称为 source maps,其中缩小的文件通过引用相同 JavaScript 代码的完整、未缩小版本进行编码,即使原始代码分布在多个文件中。源映射文件使用一个.map扩展名,并具有一个 JSON 结构,描述原始文件和缩小文件之间的映射,包括它们的变量和函数名,如果它们在缩小过程中被混淆的话。

将一个缩小的文件链接到一个关联的源映射文件非常简单,只需在引用源映射位置的 JavaScript 文件的末尾附加一个特殊格式的注释。例如,要将名为scripts.min.js的缩小文件连接到名为/scripts/scripts.js.map的 JSON 格式的源映射文件,请在缩小文件的末尾添加以下注释:

//# sourceMappingURL=/scripts/scripts.js.map

或者,如果您可以访问您的 web 服务器,您可以将以下 HTTP 头添加到缩小文件的响应中,尽管许多人为了简单起见选择使用特殊格式的注释:

X-SourceMap: /scripts/scripts/js.map

在压缩 JavaScript 文件的同时生成源映射文件。UglifyJS(bit . ly/uglify _ js)和 Google Closure Compiler(bit . ly/Closure _ compile—使用create_source_map选项)这两个在第四章中介绍的工具都能够自动生成一个源映射文件,并且会自动在缩小文件的底部包含特殊格式的推荐引用。

在 Chrome 开发者工具中,你必须启用识别源地图的功能。单击工具栏中的设置菜单图标,并选择常规选项部分中的启用源地图。默认情况下,其他浏览器的开发人员工具会启用该功能。在 Chrome 开发者工具中选择 Sources 标签,你会看到一个文件的分层文件夹视图,这些文件组成了当前的网页。从源映射的数据结构链接到的任何文件都显示在这个层次结构中,就好像它们是通过 HTML 页面本身的直接链接加载的一样。

类似地,在 Firefox 开发工具中,选择 Debugger 选项卡会在左侧的面板中生成一个 JavaScript 源文件列表。此列表中自动包括从任何源地图链接的每个文件的完整、未统一版本。在撰写本文时,Firebug 不支持源地图,所以您应该借助内置的 Firefox 开发工具来利用这一特性。

Safari 开发人员工具在“资源”标签的左侧面板中,将带有关联源地图的缩小文件显示为已加载文件层次结构中的额外项目。然后,通过源映射链接的文件显示在层次结构中缩小文件名称的下方,如图 16-5 所示,其中有一个名为scripts.js的缩小文件,链接到许多原始的、未缩小的文件。

A978-1-4302-6269-5_16_Fig5_HTML.jpg

图 16-5。

Unminified files from source maps are shown one level beneath the original minified file in Safari

Internet Explorer 开发人员工具通过调试器选项卡提供对源映射功能的访问。当选择引用源地图的 JavaScript 文件时,工具栏上的文件内容上方会出现一个额外的按钮,按下该按钮后,会在主面板中加载并显示文件的未缩小版本,替换缩小的文件。

暂停并观察正在运行的 JavaScript 代码

浏览器开发工具具有额外的功能,可以近距离观察 JavaScript 代码在浏览器中的运行情况,甚至允许一次执行一行代码,并在每行代码执行时观察变量值的变化。

要在 JavaScript 代码到达特定代码文件中的某一行时停止执行,请使用本章稍后介绍的两种技术之一在该行插入一个断点。加载或刷新页面并到达代码行后,页面暂停并让您控制 JavaScript 代码的运行。

在代码中插入断点的第一个技巧是添加一行代码,强制执行在文件中的该点暂停。为此,在 JavaScript 文件中您希望暂停执行的位置添加以下行:

debugger;

执行时,开发人员工具将在这一点暂停,让您控制执行流程,并允许您在代码执行的那一刻观察任何全局或局部变量的值。请确保在发布之前从代码中删除对该命令的任何引用,但是,与console对象一样,这是一个自定义的 JavaScript 命令,仅用于浏览器开发工具。

插入断点的第二种技术是,在浏览器开发人员工具中查看 JavaScript 文件时,只需单击希望暂停执行的代码行号。代码行旁边会出现一个标记,表示已经设置了一个断点,再次单击可以删除该断点。刷新页面将保留所有断点,代码将在到达第一个断点时暂停,等待您的下一个操作。设置完成后,大多数浏览器开发工具都允许您右键单击断点并编辑适用于它的条件,从而允许您指定在触发特定断点之前应用应该处于的确切状态。如果您希望在循环中放置一个断点,但只希望断点在到达该循环的特定迭代时停止代码执行,这将非常有用。

当在断点处暂停时,只需将鼠标悬停在任何变量名上,就可以检查断点周围代码中由该点设置的任何变量的值(不幸的是,Firefox 开发人员工具不支持这一点,所以请使用 Firebug 来利用这一特性)。工具提示将显示突出显示的变量中的值,并允许您扩展对象以显示其属性中的值。您也可以在暂停时打开 JavaScript 控制台,并使用数据输入字段来执行其他代码,或者查询特定变量的值,所有这些都在断点的当前范围内。这对于强制函数返回不同的值,或者确保特定的条件语句在代码流中执行非常有用。

无论使用何种浏览器开发工具,当在断点处暂停时,右侧的面板会显示执行调用堆栈的详细信息,包括引入当前暂停的函数的调用函数名、全局变量和局部范围变量的列表以及它们的当前值。例如,当在一个函数中暂停时,您可以访问该函数中声明的所有变量,以及特殊变量,如arguments,显示执行时传递给该函数的所有参数。这为您(开发人员)提供了应用中变量状态的一目了然的视图,使您能够确定它们是否符合预期,从而允许您在需要时进一步调试。

暂停后,您会注意到在工具栏中显示的 JavaScript 代码上方的工具栏中有一个 continue 按钮,让人想起视频播放按钮。当按下此按钮时,代码将继续执行,直到遇到下一个断点。还要注意在工具栏中的继续按钮旁边有一组三步操作按钮。这允许您一次一条语句地继续执行代码,而不需要设置另一个断点,允许您查看对变量执行的操作以及浏览器的 JavaScript 解释器在应用中执行的流程,而不需要预先知道将会执行什么过程。这些步骤按钮中的一个将执行当前文件中的下一条语句,而不需要输入任何调用的函数(如果遇到函数的话)。如果您对被调用函数的返回值比对这些函数中的代码更感兴趣,这将非常有用。这些按钮中的另一个将执行下一条语句,如果存在被调用的函数,则进入该函数,并在该函数中的第一条语句处再次暂停。final 按钮允许您执行当前正在执行的函数的其余部分,并在将程序流带入该函数的函数调用之后的下一条语句处再次暂停。使用这些按钮,您可以跟踪代码流,沿途观察存储在局部和全局变量中的值,以帮助您在当前浏览器中运行的网页的上下文中定位和调试代码中的问题。

图 16-6 显示了一个 Safari 开发者工具的例子,在到达中间区域代码旁边的行号列中指示的断点时暂停。左侧面板显示 continue 和 step 按钮、执行调用堆栈(包括到达当前断点之前执行的函数名)以及所有正在运行的文件中所有断点的列表。右侧面板显示了在局部作用域、当前函数闭包和全局作用域中声明的当前变量。其他浏览器开发工具以类似的布局显示相同的信息。

A978-1-4302-6269-5_16_Fig6_HTML.jpg

图 16-6。

Using breakpoints to pause code execution in order to observe the values in local variables

剖析 JavaScript 代码

浏览器开发工具允许您分析 web 应用的内存使用情况,以及各个 JavaScript 函数的运行时性能。使用这些数据,您可以更新您的代码,使其对您的用户更有效,消除内存泄漏并释放任何潜在的性能瓶颈。

定位内存泄漏

当您的代码在函数中创建和初始化变量时,这些值会消耗浏览器中一定量的内存,具体取决于它们的类型和值。当其作用域的执行结束时,变量在内部被标记为删除。浏览器中的垃圾收集器进程处理这些变量,以及它认为不再需要的任何其他变量,因为它们没有来自运行代码中任何其他对象的活动引用,从而释放它们的内存。内存泄漏是指随着时间的推移,不再需要的某些变量没有被释放,这意味着浏览器剩余的可用内存会慢慢减少,直到没有剩余内存,浏览器进程被迫崩溃。在基于 JavaScript 的 web 应用中,内存泄漏有三个主要原因。

首先,使用console对象将一个对象的值记录到浏览器开发人员工具中的 JavaScript 控制台会导致内存泄漏。这使得对该对象的引用在内存中保持“活动”,尽管代码库的其余部分可能不再需要访问它。这可能会导致开发中的内存泄漏问题,一旦这些日志记录方法调用被删除,这些问题将不会出现在您的最终代码中,因此要对此保持警惕。

其次,对 JavaScript 函数闭包的引用是 web 应用中内存泄漏的另一个常见来源。假设一个事件处理程序闭包引用了代码中某个地方的一个对象的属性。即使在代码中的某一点之后不再需要或使用该对象,闭包可能会被执行并引用该对象的事实意味着,只要该事件处理程序仍处于活动状态,该对象就会一直保留在内存中。因此,一定要对 DOM 元素使用removeEventListener()方法,以确保不再需要的对象的引用被删除,它们的内存被释放。

最后,内存泄漏可能是由于两个或多个对象之间的存储引用使其中一个对象分配的内存被保留,尽管应用不再需要它。这可能看起来违反直觉,但通常减少内存泄漏的最佳方法是将从其他对象引用的数据作为该数据的副本存储在单独的局部变量中。

Chrome、Opera 和 Internet Explorer 11 中的浏览器开发工具能够分析 JavaScript 代码在 web 应用环境中运行时的内存使用情况。

Chrome 和 Opera 中的内存分析

要查看 JavaScript 在 Chrome 浏览器中消耗了多少内存,通过窗口\任务管理器菜单栏选项打开任务管理器窗口。右键单击打开选项卡列表标题,并选择在表中显示 JavaScript 内存。然后,您将看到 web 应用的 JavaScript 部分消耗了多少内存。如果你有多个打开的标签页,你也会看到这些应用的内存消耗,允许你与其他 Web 应用进行比较。

如果您需要在任何时候观察应用的确切内存使用情况,您可以使用 Chrome 或 Opera 开发工具的 Profiles 选项卡中的堆快照功能。要按对象获取内存使用情况的即时快照,请选择获取堆快照单选按钮,然后单击开始按钮。拍摄的快照将在左侧面板中可见,您可以选择该面板向您显示由构造函数(或内置类型)组织的对象的摘要,这些对象用于实例化它,并显示每个对象消耗的内存大小。然后,您可以深入查看哪些对象消耗的内存比预期的多,以便修复这些对象中可能存在的特定问题。

如果您想比较 web 应用的整个内存使用量在特定时间段内的变化情况,请使用开发工具的 Profiles 选项卡中的 Record Heap Allocations 单选选项,然后单击 Start 按钮。一个红色的指示器将出现在开发者工具的左上方,表示内存使用情况正在被记录。准备就绪后,单击此指示器停止录制。从开始录制到停止录制之间的内存使用情况将绘制在 developer tools 主面板的图表上,峰值表示内存使用情况的变化。图表下方是在录制期间内存使用情况发生变化的对象列表。图表上方的范围选择器允许您将内存使用事件过滤到一个狭窄的范围内,帮助您准确地关注哪些对象更改引起了较大的内存更改,以便您可以研究在应用中改善过度内存使用的方法。

浏览器的开发人员工具中的时间轴选项卡在左侧面板中显示内存时间轴工具。要运行内存时间线检查,只需在刷新页面开始测量时选择时间线选项卡。应该会出现一个图形,显示正在运行的页面消耗的内存量,直到达到页面加载事件。图表下方是一个记录列表,显示了发生的每个影响应用内存使用的事件,以及事件类型、文件名和影响内存使用的操作行号的详细信息。使用图表上方的滑块选择一个时间范围,可以将记录列表过滤为特定范围内的记录(可能是您注意到内存使用量出现较大峰值但随后没有释放的记录),这将有助于调试哪些操作导致应用在初始化和运行时消耗内存。图 16-7 显示了运行中的内存时间线工具,包括一个随时间变化的内存使用图,一个导致内存分配变化的事件列表,以及一个活动 DOM 节点和事件监听器的计数器,这通常是 web 应用中内存泄漏的原因。

A978-1-4302-6269-5_16_Fig7_HTML.jpg

图 16-7。

The Memory timeline tool active in Chrome and Opera browser developer tools

要了解更多关于 Chrome 浏览器开发工具的内存配置文件功能,以及 Opera 的开发工具,请查看以下在线资源:http:// bit. ly/ chrome-memory 。

Internet Explorer 11 中的内存分析

受 Chrome 浏览器开发工具的内存分析功能的启发,微软在他们的 Internet Explorer 浏览器版本 11 中添加了一个类似但更完善的相同功能版本。单击 IE11 开发人员工具中的 Memory 选项卡会弹出一个面板,其中有一个开始进行内存分析的选项。选择工具栏中的 play 按钮,或点击面板中间的链接,开始分析浏览器中的内存使用情况。将出现一个图表,详细说明久而久之的内存使用情况。要获取任意给定点的内存使用情况的快照,请选择图形下方的获取堆快照按钮。此时内存使用情况的详细信息将出现在图表下方的一个框中。单击框中显示的内存大小将显示内存中对象的列表及其各自的内存大小,让您可以看到哪些对象可能会导致应用中的内存问题。创建第二个快照将显示新快照与前一个快照之间内存大小和对象数量变化的详细信息。选择任何一条信息上的链接都会显示两个快照之间的内存使用情况和对象列表之间的比较。

图 16-8 显示了在 Internet Explorer 浏览器开发工具中运行的内存工具,显示了一段时间内存使用情况的图表,以及在几秒钟内拍摄的两个内存快照,在第二个快照的详细信息框中突出显示了两个快照在内存大小和对象数方面的差异。

A978-1-4302-6269-5_16_Fig8_HTML.jpg

图 16-8。

The Memory tab in IE11 developer tools provides a simple UI for investigating memory usage

Internet Explorer 中的内存分析工具在很大程度上与 Chrome 和 Opera 开发工具相匹配;但是,它们包含在一个选项卡中,在我看来,这是一个更易于使用的界面,用于调试 web 应用中的内存使用情况。

要了解关于 IE11 内存工具的更多信息,请查看以下关于该主题的在线资源:http://bit . ly/ie-Memory。

识别性能瓶颈

有时候,在测试 web 应用时,您会注意到,在 JavaScript 执行的某些时候,浏览器似乎会在一瞬间锁定或冻结。当浏览器被迫将 JavaScript 解释器的优先权给予其渲染器时,会发生这种情况,因为发生了一系列操作,没有给渲染器留下空间来赶上自己。这通常是由于forwhile循环执行了太多的迭代,淹没了浏览器。在这种情况下,或者为了确保这种情况不会在您的代码中发生,请利用浏览器开发人员工具的性能分析功能来测量和改进您的 JavaScript 代码。

在 Chrome 和 Opera 开发工具中,Profiles 选项卡允许您使用 Collect JavaScript CPU Profile 单选选项来分析您的 JavaScript。单击 Start 开始分析,如果不想再收集任何数据,则停止分析。您将看到一个命名函数调用的列表,按照调用时间的顺序排列,并提供了指向文件的链接和执行它们的行号。顶部工具栏中的“%”切换按钮允许您在显示执行每个功能所用的绝对时间(以毫秒为单位)和该功能所用的总分析时间的百分比之间切换。如果任何特定的函数在内部执行了其他函数,这些函数旁边会显示一个箭头,允许您过滤每个单独的子函数花费的时间。这个视图本身应该允许您定位代码运行中出现性能瓶颈的位置,但是一个单独的图表视图(通过工具栏中的选择框可以访问)显示了一段时间内性能的图形视图,峰值表示活动。下图还允许您通过将鼠标悬停在峰值上来查看每个文件和函数所用的时间。图表上方的范围滑块允许您将显示的数据缩小到所分析的时间段内的某个时间范围,使您可以专注于特定的活动区域,以跟踪代码中的任何性能问题。要了解更多关于 Chrome 开发者工具的 JavaScript 分析功能,请在bit . ly/Chrome-profile上在线阅读更多详细信息。

Safari 开发者工具的时间线标签中有一个非常相似的功能。配置文件标题旁边左侧面板中的按钮允许 JavaScript 配置文件按需启动和停止,方法是在弹出的选择框中选择启动 JavaScript 配置文件选项。工具栏中的“%”按钮可以在显示的时间单位之间切换,类似于 Chrome 开发工具中的功能调用和每次调用所用的时间,尽管这里没有图表功能。要更详细地了解 Safari 的档案功能,请在线查看bit . ly/Safari-profile

Firefox 中的浏览器开发工具包含相同的配置特性,实现方式非常相似。在 developer tools 中选择 Profiles 选项卡,并选择左侧面板左上角的 time 图标开始分析,再次单击停止收集数据。类似的按执行持续时间顺序的分层函数调用视图显示在主面板中,上面的图表显示了性能峰值和谷值,如图 16-9 所示。选择顶部图形中的一个区域,可以过滤函数调用列表,只显示在所选时间范围内发生的函数调用,从而确定哪些函数可能会导致性能问题并需要解决。要了解更多关于 Firefox 开发工具的 JavaScript 分析能力,请查看bit . ly/Firefox-profile。请注意,Firebug 中没有内置的类似工具,因此对于该功能,请使用内置的 Firefox 开发人员工具。

A978-1-4302-6269-5_16_Fig9_HTML.jpg

图 16-9。

The Profiler tab in Firefox developer tools allow you to drill down to locate poor performing functions

Internet Explorer 11 开发人员工具通过其 Profiler 选项卡提供了类似的分析功能。要开始分析 JavaScript,单击顶部工具栏中绿色的 play 按钮,单击红色的 stop 按钮以停止收集数据。然后,已执行函数的列表显示在主面板区域中,通过切换可以在函数和调用树视图之间切换,前者显示每个被调用函数的平面视图,执行时间最长的函数位于列表顶部,后者显示被调用函数的分层视图,基于内部调用的其他函数。和 Safari 开发者工具一样,没有允许你按时间过滤的图表视图;但是,该列表本身包含了足够的信息来调试性能问题,因为它指向了执行时间最长的函数的文件和行号,向您显示了调试工作的确切重点。要了解更多关于 IE11 开发者工具的 JavaScript 剖析功能,请查看 bit. ly/ ie-profile

使用浏览器开发人员工具的分析功能,您可以深入到正在运行的代码中,发现哪些函数执行时间最长,这样您就可以检查这些函数的代码,试图找到更有效的方法来执行相同的操作并获得相同的结果。

摘要

在本章中,我们已经了解了如何使用浏览器的内置开发工具在真实、实时、运行的应用环境中调试和监控我们的 JavaScript 代码,使用这些工具提供的数据来改进我们的代码,以造福我们的用户,使其性能更高、内存效率更高、更不容易出现运行时错误。要了解更多关于浏览器开发工具的特性,包括哪个浏览器有哪些特性,请查看 Andi Smith 精彩的在线资源“开发工具秘密”。com 。

在本书的整个过程中,我解释了专业 JavaScript 开发人员如何使用高级编码技术、利用语言和浏览器功能,以及将工具应用到他们的工作流和代码中,以便生成高质量、可维护的 JavaScript 代码,使他们的应用的最终用户和其他开发人员受益。我相信您已经学到了一些想法、技术和技能,现在可以应用到您自己的项目中,让您获得更多的经验和更多的信心,成为一名优秀的专业 JavaScript 开发人员。感谢您的阅读,祝您编码愉快!

posted @ 2024-08-19 17:13  绝不原创的飞龙  阅读(1)  评论(0编辑  收藏  举报