解锁-JavaScript-全-

解锁 JavaScript(全)

原文:zh.annas-archive.org/md5/A343D1C7BB9FB1F5BEAC75A7F1CFB40B

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

JavaScript 是在最不恰当的时候——浏览器大战时期——作为脚本语言诞生的。它被忽视和误解了十年,经历了六个版本。现在看看它!JavaScript 已经成为一种主流编程语言。它在各个方面都有先进的使用:在大型客户端开发、服务器脚本、桌面应用、原生移动编程、游戏开发、数据库查询、硬件控制和操作系统自动化中。JavaScript 获得了许多子集,如 Objective-J、CoffeeScript、TypeScript 等。JavaScript 非常简洁,是一种表达性语言。它具有基于原型的面向对象编程、对象组合和继承、可变参函数、事件驱动编程和非阻塞 I/O 等特点。然而,为了发挥 JavaScript 的真正威力,我们需要对其语言特性有深入的理解。此外,在 JavaScript 开发过程中,我们会注意到它众多的陷阱,我们需要一些技巧来避免它们。以前被称为 EcmaScript Harmony 的项目,最近在名为 EcmaScript 2015 的规范中最终确定,通常被称为 ES6。这不仅将语言提升到下一个层次,还引入了许多需要关注的新技术。

本书旨在引导读者了解 JavaScript 即将推出和现有的特性。它充满了针对常见编程任务的代码食谱。这些任务提供了针对经典 JavaScript(ES5)以及下一代语言(ES6-7)的解决方案。本书关注的不仅仅是浏览器中的语言,还提供了编写高效 JavaScript 的基本知识,用于桌面应用、服务器端软件和原生模块应用。作者的最终目标是不仅描述语言,还要帮助读者改进他们的代码,以提高可维护性、可读性和性能。

本书内容涵盖

第一章,深入 JavaScript 核心,讨论了提高代码表达性的技术,掌握多行字符串和模板化,以及操作数组和类数组对象的方法。这一章解释了如何利用 JavaScript 原型而不损害代码的可读性。此外,这一章介绍了 JavaScript 的“魔法方法”,并给出了它们的实际使用示例。

第二章,使用 JavaScript 的模块化编程,描述了 JavaScript 中的模块化:模块是什么,为什么它们很重要,异步和同步加载模块的标准方法,以及 ES6 模块是什么。这一章展示了如何在服务器端 JavaScript 中使用 CommonJS 模块,以及如何为浏览器预编译它们。它详细介绍了如何将异步和同步方法结合起来,以实现更好的应用程序性能。它还解释了如何使用 Babel.js 为生产环境填充 ES6 模块。

第三章,DOM 脚本编程与 AJAX,介绍了文档对象模型(DOM),展示了最小化浏览器重绘的最佳实践,并在操作 DOM 时提高应用程序性能。这一章还比较了两种客户端服务器通信模型:XHR 和 Fetch API。

第四章,HTML5 APIs,考虑了浏览器持久化 API,如 Web 存储、IndexDB 和文件系统。它介绍了 Web 组件,并概述了创建自定义组件的过程。这一章描述了服务器到浏览器通信 API,如 SSE 和 WebSockets。

第五章,异步 JavaScript,解释了 JavaScript 的非阻塞性质,阐述了事件循环和调用栈。这一章考虑了异步调用链的流行风格以及错误处理。它介绍了 ES7 的 async/await 技术,并给出了使用 Promise API 和 Async.js 库并行和顺序运行任务的例子。它描述了节流和防抖的概念。

第六章,大型 JavaScript 应用程序架构,重点是代码可维护性和架构。这一章介绍了 MVC 范式及其变体,MVP 和 MVVM。它还通过 Backbone.js、AngularJS 和 ReactJS 等流行框架的示例,展示了如何实现关注分离。

第七章,JavaScript 浏览器之外的应用,解释了如何在 JavaScript 中编写命令行程序以及如何使用 Node.js 构建 Web 服务器。它还涵盖了使用 NW.js 创建桌面 HTML5 应用程序和指导使用 Phongap 开发原生移动应用程序的内容。

第八章,调试和剖析,深入探讨了 bug 的检测和隔离。它检查了 DevTools 的容量和 JavaScript 控制台 API 的一些不太知名的功能。

您需要什么

只要你有一个现代浏览器和一个文本编辑器,就可以运行书中的示例。然而,使用类似 Firefox Scratchpad 的浏览器工具可能会有所帮助,以直接在浏览器中编辑示例代码。(developer.mozilla.org/en-US/docs/Tools/Scratchpad)书中还包含了一些依赖于浏览器尚未支持的 ES6/ES7 特性的代码示例。你可以在babeljs.io/repl/上使用 Babel.js 的在线沙盒运行这些示例。

你将在涉及 Node.js、NW.js、PhoneGap、JavaScript 框架和 NPM 包的章节中找到详细的设置开发环境和安装所需工具和依赖项的说明。

本书适合谁

这本书适合那些已经熟悉 JavaScript 并且想要提高技能以充分利用这门语言的开发者。本书以实践为导向,对于那些习惯于“边做边学”方法的人来说会有帮助,因为主题通过真实示例和教程进行了彻底的讲解。

约定

在这本书中,你会发现有许多文本样式用来区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、假 URL、用户输入和 Twitter 处理方式如下所示:"我们可以通过使用include指令来包含其他上下文。"

代码块如下所示设置:

var res = [ 1, 2, 3, 4 ].filter(function( v ){
 return v > 2;
})
console.log( res ); // [3,4]

当我们希望吸引您对代码块的特定部分注意时,相关行或项目以粗体显示:

/**
* @param {Function} [cb] - callback
*/
function fn( cb ) {
 cb && cb();
};

任何命令行输入或输出如下所示:

npm install fs-walk cli-color

新术语重要词汇以粗体显示。例如,在菜单或对话框中出现的屏幕上的词,在文本中如下所示:"一旦按下Enter,控制台输出I'm running。"

注意

警告或重要说明如下所示的盒子:

技巧

技巧和窍门就像这样出现。

读者反馈

读者反馈对我们来说总是受欢迎的。让我们知道你对这本书的看法——你喜欢或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发出你能真正从中受益的标题。

要给我们发送一般性反馈,只需发送电子邮件<feedback@packtpub.com>,并在消息主题中提到书名。

如果您在某个主题上有专业知识,并且有兴趣撰写或贡献一本书,请查看我们的作者指南www.packtpub.com/authors

客户支持

您现在拥有了一本 Packt 图书,我们有很多方法可以帮助您充分利用您的购买。

下载示例代码

您可以从您在www.packtpub.com的账户上下载您购买的所有 Packt Publishing 图书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

勘误

尽管我们已经尽一切努力确保我们的内容的准确性,但错误仍然会发生。如果您在我们的书中发现了一个错误——可能是文本或代码中的错误——我们将非常感谢您能向我们报告。这样做可以节省其他读者的挫折感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站,或添加到该标题下的现有勘误列表中。

要查看以前提交的勘误,请前往www.packtpub.com/books/content/support并在搜索字段中输入书籍的名称。所需信息将在勘误部分下出现。

盗版

互联网上版权材料的盗版是一个持续存在的问题,涵盖所有媒体。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上以任何形式发现我们作品的非法副本,请立即提供给我们位置地址或网站名称,以便我们可以寻求解决方案。

如有怀疑的侵权材料,请联系我们<copyright@packtpub.com>

我们感谢您在保护我们的作者和我们提供有价值内容的能力方面所提供的帮助。

问题

如果您在阅读本书时遇到任何问题,可以通过<questions@packtpub.com>联系我们,我们会尽力解决问题。

第一章: 深入 JavaScript 核心

你可能用了几年的 iPhone,自认为是个有经验的用户。同时,你在打字时按删除键逐个删除不需要的字符。然而,有一天你发现只需快速摇晃就能一次性删除整条信息。然后你可能会想为什么之前不知道这个技巧。编程也是一样。我们可能会对自己的代码相当满意,直到突然间遇到一个技巧或不太知名的语法特性,让我们重新考虑过去几年所做的全部工作。结果是我们本可以用更简洁、更可读、更可测试、更易维护的方式完成这些工作。所以假设你已经有一定的 JavaScript 经验;然而,这一章将为你提供改进代码的最佳实践。我们将涵盖以下主题:

  • 使你的代码可读且具有表现力

  • 掌握 JavaScript 中的多行字符串

  • 以 ES5 的方式操作数组

  • 以一种优雅、可靠、安全和快速的方式遍历对象

  • 声明对象的最有效方式

  • 了解 JavaScript 中的魔法方法

让你的代码可读且具有表现力

有许多实践和启发式方法可以使代码更具可读性、表现力和整洁性。我们稍后讨论这个话题,但在这里我们谈谈语法糖。这个术语意味着一种替代语法,使代码更具表现力和可读性。实际上,我们从一开始就有一些这样的东西在 JavaScript 中。例如,自增/自减和加法/减法赋值运算符继承自 C 语言。*foo++**foo = foo + 1*的语法糖,*foo += bar**foo = foo + bar*的简写形式。此外,还有一些同样的目的的小技巧。

JavaScript 对所谓的短路表达式应用逻辑运算。这意味着表达式是从左到右阅读的,但一旦在早期阶段确定了条件结果,表达式的尾部就不会被评估。如果我们有true || false || false,解释器会从第一个测试中知道结果无论如何都是true。所以false || false部分不会被评估,这就为创意开启了道路。

函数参数默认值

当我们需要为参数指定默认值时,可以这样操作:

function stub( foo ) {
 return foo || "Default value";
}

console.log( stub( "My value" ) ); // My value
console.log( stub() ); // Default value

这里发生了什么?当footruenot undefinedNaNnullfalse0"")时,逻辑表达式的结果就是foo,否则会评估到Default value,这就是最终结果。

从 ECMAScript 的第六版(JavaScript 语言的规格)开始,我们可以使用更优美的语法:

function stub( foo = "Default value" ) {
 return foo;
}

条件调用

在编写代码时,根据条件缩短它:

var age = 20;
age >= 18 && console.log( "You are allowed to play this game" );
age >= 18 || console.log( "The game is restricted to 18 and over" );

在前一个示例中,我们使用 AND(&&)操作符在左条件为真时调用console.log。OR(||)操作符相反,如果条件为,则调用console.log

我认为实践中最常见的情况是简写条件,只有在提供时函数才被调用:

/**
* @param {Function} [cb] - callback
*/
function fn( cb ) {
 cb && cb();
};

以下是在此的一个更多示例:

/**
* @class AbstractFoo
*/
AbstractFoo = function(){
 // call this.init if the subclass has init method
 this.init && this.init();
};

语法糖直到 CoffeeScript 的进步才完全进入 JavaScript 世界,CoffeeScript 是这种语言的一个子集,它源码编译(源码到源码编译)为 JavaScript。实际上,受 Ruby,Python 和 Haskell 启发的 CoffeeScript 为 JavaScript 开发者解锁了箭头函数,展开和其他语法。2011 年,Brendan Eich(JavaScript 的作者)承认 CoffeeScript 影响了他的 EcmaScript Harmony 的工作,该工作在今年夏天的 ECMA-262 6th edition specification 中最终完成。从市场营销的角度来看,规格编写者同意使用新的命名约定,将第 6 版称为 EcmaScript 2015,第 7 版称为 EcmaScript 2016。然而,社区已经习惯了缩写如 ES6 和 ES7。为了进一步避免混淆,在本书中,我们将用这些名称来指代规格。现在我们可以看看这对新的 JavaScript 有什么影响。

箭头函数

传统的函数表达式可能如下所示:

function( param1, param2 ){ /* function body */ }

当使用箭头函数(也称为胖箭头函数)语法声明表达式时,我们将以更简洁的形式拥有这个 this,如下所示:

( param1, param2 ) => { /* function body */ }

在我看来,这样做我们并没有得到太多。但是如果我们需要,比如说,一个数组方法的回调,传统形式如下:

function( param1, param2 ){ return expression; }

现在等效的箭头函数变得更短了,如下所示:

( param1, param2 ) => expression

我们可能这样在数组中进行过滤:

// filter all the array elements greater than 2
var res = [ 1, 2, 3, 4 ].filter(function( v ){
 return v > 2;
})
console.log( res ); // [3,4]

使用数组函数,我们可以以更简洁的形式进行过滤:

var res  = [ 1, 2, 3, 4 ].filter( v => v > 2 );
console.log( res ); // [3,4]

除了更短的方法声明语法外,箭头函数还带来了所谓的词法this。而不是创建自己的上下文,它使用周围对象的上下文,如下所示:

"use strict";
/**
* @class View
*/   
let View = function(){
 let button = document.querySelector( "[data-bind=\"btn\"]" );
 /**
  * Handle button clicked event
  * @private 
  */
 this.onClick = function(){
   console.log( "Button clicked" );
 };
 button.addEventListener( "click", () => {
   // we can safely refer surrounding object members
   this.onClick(); 
 }, false );
}

在前一个示例中,我们为 DOM 事件(click)订阅了一个处理函数。在处理器的范围内,我们仍然可以访问视图上下文(this),因此我们不需要将处理函数绑定到外部作用域或通过闭包作为变量传递:

var that = this;
button.addEventListener( "click", function(){
  // cross-cutting concerns
  that.onClick(); 
}, false );

方法定义

如前一部分所述,当声明小型的内联回调函数时,箭头函数非常方便,但总是为了更短的语法而使用它是有争议的。然而,ES6 除了箭头函数之外,还提供了新的替代方法定义语法。老式的方法声明可能如下所示:

var foo = {
 bar: function( param1, param2 ) {
 }
}

在 ES6 中,我们可以摆脱函数关键字和冒号。所以前一条代码可以这样做:

let foo = {
 bar ( param1, param2 ) {
 }
}

剩余操作符

另一种从 CoffeeScript 借用的语法结构作为剩余操作符(尽管在 CoffeeScript 中,这种方法被称为splats)来到了 JavaScript。

当我们有几个必需的函数参数和一个未知数量的剩余参数时,我们通常会这样做:

"use strict";
var cb = function() {
 // all available parameters into an array
 var args = [].slice.call( arguments ),
     // the first array element to foo and shift
     foo = args.shift(),
     // the new first array element to bar and shift
     bar = args.shift();
 console.log( foo, bar, args );
};
cb( "foo", "bar", 1, 2, 3 ); // foo bar [1, 2, 3]

现在看看这段代码在 ES6 中变得多么有表现力:

let cb = function( foo, bar, ...args ) {
 console.log( foo, bar, args );
}
cb( "foo", "bar", 1, 2, 3 ); // foo bar [1, 2, 3]

函数参数不是剩余操作符的唯一应用。例如,我们也可以在解构中使用它,如下所示:

let [ bar, ...others ] = [ "bar", "foo", "baz", "qux" ];
console.log([ bar, others ]); // ["bar",["foo","baz","qux"]]

展开操作符

同样,我们也可以将数组元素展开为参数:

let args = [ 2015, 6, 17 ],
   relDate = new Date( ...args );
console.log( relDate.toString() );  // Fri Jul 17 2015 00:00:00 GMT+0200 (CEST)

ES6 还提供了创建对象和继承的有表现力的语法糖,但我们将稍后在声明对象的最有效方式部分中 examine this。

掌握 JavaScript 中的多行字符串

多行字符串不是 JavaScript 的一个好部分。虽然它们在其他语言中很容易声明(例如,NOWDOC),但你不能只是将单引号或双引号的字符串保持在多行中。这会导致语法错误,因为 JavaScript 中的每一行都被认为是可能的命令。你可以用反斜杠来表示你的意图:

var str = "Lorem ipsum dolor sit amet, \n\
consectetur adipiscing elit. Nunc ornare, \n\
diam ultricies vehicula aliquam, mauris \n\
ipsum dapibus dolor, quis fringilla leo ligula non neque";

这种方法基本有效。然而,一旦你漏掉了一个尾随空格,你就会得到一个语法错误,这不容易被发现。虽然大多数脚本代理支持这种语法,但它并不是 EcmaScript 规范的一部分。

EcmaScript for XMLE4X)的时代,我们可以将纯 XML 赋值给一个字符串,这为这些声明打开了一条道路:

var str = <>Lorem ipsum dolor sit amet, 
consectetur adipiscing 
elit. Nunc ornare </>.toString();

现在 E4X 已经被弃用,不再被支持。

字符串连接与数组连接

我们也可以使用字符串连接。它可能感觉笨拙,但它是安全的:

var str = "Lorem ipsum dolor sit amet, \n" +
 "consectetur adipiscing elit. Nunc ornare,\n" +
 "diam ultricies vehicula aliquam, mauris \n" +
 "ipsum dapibus dolor, quis fringilla leo ligula non neque";

你可能会感到惊讶,但字符串连接比数组连接慢。所以以下技术会更快地工作:

var str = [ "Lorem ipsum dolor sit amet, \n",
 "consectetur adipiscing elit. Nunc ornare,\n",
 "diam ultricies vehicula aliquam, mauris \n",
 "ipsum dapibus dolor, quis fringilla leo ligula non neque"].join( "" );

模板字面量

那么 ES6 呢?最新的 EcmaScript 规范引入了一种新的字符串字面量,模板字面量:

var str = `Lorem ipsum dolor sit amet, \n
consectetur adipiscing elit. Nunc ornare, \n
diam ultricies vehicula aliquam, mauris \n
ipsum dapibus dolor, quis fringilla leo ligula non neque`;

现在这个语法看起来很优雅。但还有更多。模板字面量真的让我们想起了 NOWDOC。你可以在字符串中引用作用域内声明的任何变量:

"use strict";
var title = "Some title",
   text = "Some text",
   str = `<div class="message">
<h2>${title}</h2>
<article>${text}</article>
</div>`;
console.log( str );

输出如下:

<div class="message">
<h2>Some title</h2>
<article>Some text</article>
</div>

如果你想知道何时可以安全地使用这种语法,我有一个好消息告诉你——这个特性已经得到了(几乎)所有主要脚本代理的支持(kangax.github.io/compat-table/es6/)。

通过转译器实现多行字符串

随着 ReactJS 的发展,Facebook 的 EcmaScript 语言扩展 JSX(facebook.github.io/jsx/)现在已经真正获得了动力。显然受到之前提到的 E4X 的影响,他们提出了一种没有任何筛选的 XML 样内容的字符串字面量。这种类型支持类似于 ES6 模板的模板插值:

"use strict";
var Hello = React.createClass({
 render: function() {
 return <div class="message">
<h2>{this.props.title}</h2>
<article>{this.props.text}</article>
</div>;
 }
});

React.render(<Hello title="Some title" text="Some text" />, node);

另一种声明多行字符串的方法是使用 CommonJS 编译器(dsheiko.github.io/cjsc/)。在解析'require'依赖关系时,编译器将任何非.js/.json内容转换为单行字符串:

foo.txt

Lorem ipsum dolor sit amet,
consectetur adipiscing elit. Nunc ornare,
diam ultricies vehicula aliquam, mauris
ipsum dapibus dolor, quis fringilla leo ligula non neque

consumer.js

var str = require( "./foo.txt" );
console.log( str );

您可以在第六章中找到 JSX 使用的示例,大规模 JavaScript 应用程序架构

以 ES5 方式操作数组

几年前,当 ES5 特性的支持较差(ECMAScript 第五版于 2009 年最终确定)时,像 Underscore 和 Lo-Dash 这样的库变得非常流行,因为它们提供了一套全面的工具来处理数组/集合。今天,许多开发者仍然使用第三方库(包括 jQuery/Zepro)来处理诸如mapfiltereverysomereduceindexOf等方法,而这些方法在 JavaScript 的本地形式中是可用的。是否需要这些库还取决于您的使用方式,但很可能您不再需要它们。让我们看看现在 JavaScript 中有什么。

ES5 中的数组方法

Array.prototype.forEach可能是数组中最常用的方法。也就是说,它是_.each的本地实现,或者是例如$.each实用程序的实现。作为参数,forEach期望一个iteratee回调函数,可选的是您希望执行回调的上下文。它将元素值、索引和整个数组传递给回调函数。大多数数组操作方法都使用相同的参数语法。注意 jQuery 的$.each将回调参数顺序颠倒:

"use strict";
var data = [ "bar", "foo", "baz", "qux" ];
data.forEach(function( val, inx ){
  console.log( val, inx ); 
});

Array.prototype.map通过转换给定数组的元素来生成一个新的数组:

"use strict";
var data = { bar: "bar bar", foo: "foo foo" },
   // convert key-value array into url-encoded string
   urlEncStr = Object.keys( data ).map(function( key ){
     return key + "=" + window.encodeURIComponent( data[ key ] );
   }).join( "&" );

console.log( urlEncStr ); // bar=bar%20bar&foo=foo%20foo

Array.prototype.filter返回一个数组,该数组由满足回调条件的给定数组值组成:

"use strict";
var data = [ "bar", "foo", "", 0 ],
   // remove all falsy elements
   filtered = data.filter(function( item ){
     return !!item;
   });

console.log( filtered ); // ["bar", "foo"]

Array.prototype.reduce/Array.prototype.reduceRight检索数组中值的产品。该方法期望一个回调函数和可选的初始值作为参数。回调函数接收四个参数:累积值、当前值、索引和原始数组。因此,我们可以通过当前值增加累积值(返回 acc += cur;)来实例化,从而得到数组值的和。

除了使用这些方法进行计算外,我们还可以连接字符串值或数组:

"use strict";
var data = [[ 0, 1 ], [ 2, 3 ], [ 4, 5 ]],
   arr = data.reduce(function( prev, cur ) {
     return prev.concat( cur );
   }),
   arrReverse = data.reduceRight(function( prev, cur ) {
     return prev.concat( cur );
   });

console.log( arr ); //  [0, 1, 2, 3, 4, 5]
console.log( arrReverse ); // [4, 5, 2, 3, 0, 1]

Array.prototype.some测试给定数组中的任何一个(或一些)值是否满足回调条件:

"use strict";
var bar = [ "bar", "baz", "qux" ],
   foo = [ "foo", "baz", "qux" ],
   /**
    * Check if a given context (this) contains the value
    * @param {*} val
    * @return {Boolean}
    */
   compare = function( val ){
     return this.indexOf( val ) !== -1; 
   };

console.log( bar.some( compare, foo ) ); // true

在这个例子中,我们检查foo数组中是否有任何一个柱状数组值是可用的。为了可测试性,我们需要将foo数组的引用传递给回调函数。这里我们将其作为上下文注入。如果我们需要传递更多的引用,我们会将它们推入一个键值对象中。

正如您可能注意到的,在这个例子中我们使用了Array.prototype.indexOf。这个方法的工作方式与String.prototype.indexOf相同。如果找到匹配项,则返回匹配项的索引,否则返回-1。

Array.prototype.every测试给定数组的每一个值是否满足回调条件:

"use strict";
var bar = [ "bar", "baz" ],
   foo = [ "bar", "baz", "qux" ],
   /**
    * Check if a given context (this) contains the value
    * @param {*} val
    * @return {Boolean}
    */
   compare = function( val ){
     return this.indexOf( val ) !== -1; 
   };

console.log( bar.every( compare, foo ) ); // true

如果你仍然关心这些方法在像 IE6-7 这样老旧的浏览器中的支持情况,你可以简单地使用 github.com/es-shims/es5-shim 来补丁它们。

es6 中的数组方法

在 ES6 中,我们只获得了一些看起来像是现有功能快捷方式的新方法。

Array.prototype.fill 用给定值填充数组,如下所示:

"use strict";
var data = Array( 5 );
console.log( data.fill( "bar" ) ); // ["bar", "bar", "bar", "bar", "bar"]

Array.prototype.includes 明确检查给定值是否存在于数组中。嗯,它和 arr.indexOf( val ) !== -1 是一样的,如下所示:

"use strict";
var data = [ "bar", "foo", "baz", "qux" ];
console.log( data.includes( "foo" ) );

Array.prototype.find 过滤出符合回调条件的单个值。再次说明,这和 Array.prototype.filter 能获得的是一样的。唯一的区别是 filter 方法返回一个数组或者一个 null 值。在这种情况下,它返回一个包含单个元素的数组,如下所示:

"use strict";
var data = [ "bar", "fo", "baz", "qux" ],
   match = function( val ){
     return val.length < 3;
   };
console.log( data.find( match ) ); // fo

优雅、可靠、安全、快速地遍历对象

当我们有一个键值对象(比如说选项)并且需要遍历它时,这是一个常见的情况。下面代码中展示了一种学术上的做法:

"use strict";
var options = {
    bar: "bar",
    foo: "foo"
   },
   key;
for( key in options ) {
 console.log( key, options[ key] );
}

上述代码输出如下:

bar bar
foo foo

现在让我们想象一下,你文档中加载的任何第三方库都增强了内置的 Object

Object.prototype.baz = "baz";

现在当我们运行我们的示例代码时,我们将得到一个额外的不需要的条目:

bar bar
foo foo
baz baz

这个问题解决方案是众所周知的,我们必须使用 Object.prototype.hasOwnProperty 方法测试键:

//…
for( key in options ) {
 if ( options.hasOwnProperty( key ) ) {
   console.log( key, options[ key] );
 }
}

安全快速地遍历键值对象

让我们面对现实吧——这个结构是笨拙的,需要优化(我们必须对每个给定的键执行 hasOwnProperty 测试)。幸运的是,JavaScript 有 Object.keys 方法,它可以获取所有枚举的自身(非继承)属性的字符串值。这让我们得到了一个数组,里面是我们期望的键,我们可以用 Array.prototype.forEach 等方式进行迭代:

"use strict";
var options = {
    bar: "bar",
    foo: "foo"
   };
Object.keys( options ).forEach(function( key ){
 console.log( key, options[ key] );
});

除了优雅,我们这种方式还能得到更好的性能。为了看看我们获得了多少性能提升,你可以在不同的浏览器上运行这个在线测试,比如:codepen.io/dsheiko/pen/JdrqXa

枚举数组对象

argumentsnodeListnode.querySelectorAlldocument.forms)这样的对象看起来像数组,实际上它们并不是。和数组一样,它们有 length 属性,可以在 for 循环中进行迭代。以对象的形式,它们可以以前面提到的相同方式进行遍历。但它们没有任何数组操作方法(forEachmapfiltersome 等等)。事实是,我们可以很容易地将它们转换为数组,如下所示:

"use strict";
var nodes = document.querySelectorAll( "div" ),
   arr = Array.prototype.slice.call( nodes );

arr.forEach(function(i){
 console.log(i);
});

上述代码甚至可以更短:

arr = [].slice.call( nodes )

这是一个非常方便的解决方案,但看起来像是一个技巧。在 ES6 中,我们可以用一个专用方法进行相同的转换:

arr = Array.from( nodes );

es6 集合

ES6 引入了一种新类型的对象——可迭代对象。这些对象可以一次获取一个元素。它们与其他语言中的迭代器非常相似。除了数组,JavaScript 还接收了两个新的可迭代数据结构,SetMapSet是一个包含唯一值的集合:

"use strict";
let foo = new Set();
foo.add( 1 );
foo.add( 1 );
foo.add( 2 );
console.log( Array.from( foo ) ); // [ 1, 2 ]

let foo = new Set(), 
   bar = function(){ return "bar"; };
foo.add( bar );
console.log( foo.has( bar ) ); // true

映射类似于键值对象,但键可以是任意值。这造成了区别。想象一下,我们需要编写一个元素包装器,提供类似 jQuery 的事件 API。通过使用on方法,我们不仅可以传递一个处理回调函数,还可以传递一个上下文(this)。我们通过cb.bind(context)将给定的回调绑定到上下文。这意味着addEventListener接收一个与回调不同的函数引用。那么我们如何取消订阅处理程序呢?我们可以通过一个由事件名称和callback函数引用组成的键将新引用存储在Map中:

"use strict";
/**
* @class
* @param {Node} el
*/
let El = function( el ){
 this.el = el;
 this.map = new Map();
};
/**
* Subscribe a handler on event
* @param {String} event
* @param {Function} cb
* @param {Object} context
*/
El.prototype.on = function( event, cb, context ){
 let handler = cb.bind( context || this );
 this.map.set( [ event, cb ], handler );
 this.el.addEventListener( event, handler, false );
};
/**
* Unsubscribe a handler on event
* @param {String} event
* @param {Function} cb
*/

El.prototype.off = function( event, cb ){
 let handler = cb.bind( context ),
     key = [ event, handler ];
 if ( this.map.has( key ) ) {
 this.el.removeEventListener( event, this.map.get( key ) );
 this.map.delete( key );
 }
};

任何可迭代的对象都有方法,keysvaluesentries,其中键与Object.keys相同,其他方法分别返回数组值和键值对数组。现在让我们看看我们如何遍历可迭代的对象:

"use strict";
let map = new Map()
 .set( "bar", "bar" )
 .set( "foo", "foo" ),
   pair;
for ( pair of map ) {
 console.log( pair );
}

// OR 
let map = new Map([
   [ "bar", "bar" ],
   [ "foo", "foo" ],
]);
map.forEach(function( value, key ){
 console.log( key, value );
});

可迭代的对象有数组类似的操作方法。因此我们可以使用forEach。此外,它们可以通过for...infor...of循环进行迭代。第一个获取索引,第二个获取值。

声明对象最有效的方法

我们在 JavaScript 中如何声明一个对象?如果我们需要一个命名空间,我们可以简单地使用一个对象字面量。但当我们需要一个对象类型时,我们需要三思采取什么方法,因为这会影响我们面向对象代码的可维护性。

古典方法

我们可以创建一个构造函数并将成员链接到其上下文:

"use strict"; 
/**
 * @class
 */
var Constructor = function(){
   /**
   * @type {String}
   * @public
   */
   this.bar = "bar";
   /**
   * @public
   * @returns {String}
   */
   this.foo = function() {
    return this.bar;
   };
 },
 /** @type Constructor */
 instance = new Constructor();

console.log( instance.foo() ); // bar
console.log( instance instanceof Constructor ); // true

我们还可以将成员分配给构造函数原型。结果将与以下相同:

"use strict";
/**
* @class
*/
var Constructor = function(){},
   instance;
/**
* @type {String}
* @public
*/
Constructor.prototype.bar = "bar";
/**
* @public
* @returns {String}
*/
Constructor.prototype.foo = function() {
 return this.bar;
};
/** @type Constructor */
instance = new Constructor();

console.log( instance.foo() ); // bar
console.log( instance instanceof Constructor ); // true

在第一种情况下,我们在构造函数体中混合了对象结构和构造逻辑。在第二种情况下,通过重复Constructor.prototype,我们违反了不要重复自己DRY)原则。

私有状态的方法

那么我们还可以用其他方式做什么呢?我们可以通过构造函数函数返回一个对象字面量:

"use strict";
/**
 * @class
 */
var Constructor = function(){
     /**
     * @type {String}
     * @private
     */
     var baz = "baz";
     return {
       /**
       * @type {String}
       * @public
       */
       bar: "bar",
       /**
       * @public
       * @returns {String}
       */
       foo: function() {
        return this.bar + " " + baz;
       }
     };
   },
   /** @type Constructor */
   instance = new Constructor();

console.log( instance.foo() ); // bar baz
console.log( instance.hasOwnProperty( "baz") ); // false
console.log( Constructor.prototype.hasOwnProperty( "baz") ); // false
console.log( instance instanceof Constructor ); // false

这种方法的优势在于,构造函数作用域内声明的任何变量都与返回的对象在同一个闭包中,因此,可以通过对象访问。我们可以将这些变量视为私有成员。坏消息是我们将失去构造函数原型。当构造函数在实例化过程中返回一个对象时,这个对象成为整个新表达式的结果。

原型链的继承

那么继承呢?古典方法会让子类型原型成为超类型实例:

"use strict";
 /**
 * @class
 */
var SuperType = function(){
       /**
       * @type {String}
       * @public
       */
       this.foo = "foo";
     },
     /**
      * @class
      */
     Constructor = function(){
       /**
       * @type {String}
       * @public
       */
       this.bar = "bar";
     },
     /** @type Constructor */
     instance;

 Constructor.prototype = new SuperType();
 Constructor.prototype.constructor = Constructor;

 instance = new Constructor();
 console.log( instance.bar ); // bar
 console.log( instance.foo ); // foo
 console.log( instance instanceof Constructor ); // true
 console.log( instance instanceof SuperType ); // true  

你可能会遇到一些代码,其中实例化时使用Object.create而不是新操作符。在这里,你需要知道两者的区别。Object.create接受一个对象作为参数,并创建一个以传递的对象为原型的新对象。在某种意义上,这使我们想起了克隆。检查这个,你声明一个对象字面量(proto)并基于第一个对象使用Object.create创建一个新的对象(实例)。无论你现在对新生成对象做何更改,它们都不会反映在原始(proto)上。但是,如果你更改原始对象的属性,你会在派生对象(实例)中发现该属性已更改:

"use strict";
var proto = {
 bar: "bar",
 foo: "foo"
}, 
instance = Object.create( proto );
proto.bar = "qux",
instance.foo = "baz";
console.log( instance ); // { foo="baz",  bar="qux"}
console.log( proto ); // { bar="qux",  foo="foo"}

通过Object.create继承原型

与新操作符相比,Object.create不调用构造函数。因此,当我们使用它来填充子类型的原型时,我们失去了位于supertype构造函数中的所有逻辑。这样,supertype构造函数永远不会被调用:

// ...
SuperType.prototype.baz = "baz";
Constructor.prototype = Object.create( SuperType.prototype );
Constructor.prototype.constructor = Constructor;

instance = new Constructor();

console.log( instance.bar ); // bar
console.log( instance.baz ); // baz
console.log( instance.hasOwnProperty( "foo" ) ); // false
console.log( instance instanceof Constructor ); // true
console.log( instance instanceof SuperType ); // true

通过Object.assign继承原型

当寻找最优结构时,我希望通过对象字面量声明成员,但仍保留到原型的链接。许多第三方项目利用自定义函数(extend)将结构对象字面量合并到构造函数原型中。实际上,ES6 提供了Object.assign本地方法。我们可以像这样使用它:

"use strict";
   /**
    * @class
    */
var SuperType = function(){
     /**
     * @type {String}
     * @public
     */
     this.foo = "foo";
   },
   /**
    * @class
    */
   Constructor = function(){
     /**
     * @type {String}
     * @public
     */
     this.bar = "bar";
   },
   /** @type Constructor */
   instance;

Object.assign( Constructor.prototype = new SuperType(), {
 baz: "baz"
});
instance = new Constructor();
console.log( instance.bar ); // bar
console.log( instance.foo ); // foo
console.log( instance.baz ); // baz
console.log( instance instanceof Constructor ); // true
console.log( instance instanceof SuperType ); // true

这看起来几乎就是所需的,除了有一点不便。Object.assign简单地将源对象的价值分配给目标对象,而不管它们的类型如何。所以如果你有一个源属性是一个对象(例如,一个ObjectArray实例),目标对象接收一个引用而不是一个值。所以你必须在初始化时手动重置任何对象属性。

使用 ExtendClass 的方法

由 Simon Boudrias 提出的ExtendClass似乎是一个无懈可击的解决方案(github.com/SBoudrias/class-extend)。他的小型库暴露了带有extend静态方法的Base构造函数。我们使用这个方法来扩展这个伪类及其任何派生类:

"use strict";
   /**
    * @class
    */
var SuperType = Base.extend({
     /**
      * @pulic
      * @returns {String}
      */
     foo: function(){ return "foo public"; },
     /**
      * @constructs SuperType
      */
     constructor: function () {}
   }),
   /**
    * @class
    */
   Constructor = SuperType.extend({
     /**
      * @pulic
      * @returns {String}
      */      
     bar: function(){ return "bar public"; }
   }, {
     /**
      * @static
      * @returns {String}
      */      
     bar: function(){ return "bar static"; }
   }),
   /** @type Constructor */
   instance = new Constructor();

console.log( instance.foo() ); // foo public
console.log( instance.bar() ); // bar public
console.log( Constructor.bar() ); // bar static
console.log( instance instanceof Constructor ); // true
console.log( instance instanceof SuperType ); // true

es6 中的类

tc39(ECMAScript 工作组)对这个问题非常清楚,所以新的语言规范提供了额外的语法来结构对象类型:

"use strict";
class AbstractClass {
 constructor() {
   this.foo = "foo";
 }
}
class ConcreteClass extends AbstractClass {
 constructor() {
   super();
   this.bar = "bar";
 }
 baz() {
   return "baz";
 }
}

let instance = new ConcreteClass();
console.log( instance.bar ); // bar
console.log( instance.foo ); // foo
console.log( instance.baz() ); // baz
console.log( instance instanceof ConcreteClass ); // true
console.log( instance instanceof AbstractClass ); // true

这个语法看起来是基于类的,但实际上这只是现有原型的语法糖。你可以检查ConcreteClass的类型,它会给你function,因为ConcreteClass是一个典型的构造器。所以我们在扩展supertypes时不需要任何技巧,不需要从子类型中引用supertype构造函数,并且我们有一个清晰可读的结构。然而,我们无法以现在的方法相同的 C 语言方式分配属性。这仍在 ES7 的讨论中(esdiscuss.org/topic/es7-property-initializers)。此外,我们可以在类的正文中直接声明类的静态方法:

class Bar {
 static foo() {
   return "static method";
 }
 baz() {
   return "prototype method";
 }
}
let instance = new Bar();
console.log( instance.baz() ); // prototype method
console.log( Bar.foo()) ); // static method

实际上,有很多在 JavaScript 社区的人认为新的语法是从原型面向对象方法的一种偏离。另一方面,ES6 类与大多数现有代码向后兼容。子类现在由语言支持,不需要额外的库来实现继承。我个人最喜欢的是,这种语法允许我们使代码更简洁、更易于维护。

如何——JavaScript 中的魔术方法

在 PHP 世界中,有诸如重载方法这样的概念,它们也被称为魔术方法(www.php.net/manual/en/language.oop5.overloading.php)。这些方法允许我们在访问或修改一个方法的不存在属性时触发一个逻辑。在 JavaScript 中,我们控制对属性(值成员)的访问。想象我们有一个自定义的集合对象。为了保持 API 的一致性,我们想要有一个length属性,它包含集合的大小。所以我们就声明一个getter(获取长度),每当属性被访问时就会执行所需的计算。在尝试修改属性值时,设置器将抛出一个异常:

"use strict";
var bar = {
 /** @type {[Number]} */
 arr: [ 1, 2 ],
 /**
  * Getter
  * @returns {Number}
  */
 get length () {
   return this.arr.length;
 },
 /**
  * Setter
  * @param {*} val
  */
 set length ( val ) {
   throw new SyntaxError( "Cannot assign to read only property 'length'" );
 }
};
console.log ( bar.length ); // 2
bar.arr.push( 3 );
console.log ( bar.length ); // 3
bar.length = 10; // SyntaxError: Cannot assign to read only property 'length'

如果我们想在现有对象上声明 getters/setters,我们可以使用以下方式:

Object.defineProperty:
"use strict";
var bar = {
 /** @type {[Number]} */
 arr: [ 1, 2 ]
};

Object.defineProperty( bar, "length", {
 /**
  * Getter
  * @returns {Number}
  */
 get: function() {
   return this.arr.length;
 },
 /**
  * Setter
  */
 set: function() {
   throw new SyntaxError( "Cannot assign to read only property 'length'" );
 }
});

console.log ( bar.length ); // 2
bar.arr.push( 3 );
console.log ( bar.length ); // 3
bar.length = 10; // SyntaxError: Cannot assign to read only property 'length'

Object.defineProperty以及Object.create的第二个参数指定了属性配置(是否可枚举、可配置、不可变,以及如何访问或修改)。因此,我们可以通过将属性设置为只读来达到类似的效果:

"use strict";
var bar = {};

Object.defineProperty( bar, "length", {
 /**
  * Data descriptor
  * @type {*}
  */
 value: 0,
 /**
  * Data descriptor
  * @type {Boolean}
  */
 writable: false
});

bar.length = 10; // TypeError: "length" is read-only

顺便说一下,如果你想要摆脱对象中的属性访问器,你可以简单地移除该属性:

delete bar.length;

ES6 类中的访问器

声明访问器的另一种方式是使用 ES6 类:

"use strict";
/** @class */
class Bar {
 /** @constructs Bar */
 constructor() {
   /** @type {[Number]} */
   this.arr = [ 1, 2 ];
 }
 /**
  * Getter
  * @returns {Number}
  */
 get length() {
   return this.arr.length;
 }
 /**
  * Setter
  * @param {Number} val
  */
 set length( val ) {
    throw new SyntaxError( "Cannot assign to read only property 'length'" );
 }
}

let bar = new Bar();
console.log ( bar.length ); // 2
bar.arr.push( 3 );
console.log ( bar.length ); // 3
bar.length = 10; // SyntaxError: Cannot assign to read only property 'length'

除了公共属性,我们还可以控制对静态属性的访问:

"use strict";

class Bar {
   /**
    * @static
    * @returns {String}
    */
   static get baz() {
       return "baz";
   }
}

console.log( Bar.baz ); // baz

控制对任意属性的访问

所有这些示例都展示了对已知属性的访问控制。然而,可能有一个情况,我想要一个具有类似于localStorage的变长接口的自定义存储。这必须是一个具有getItem方法以检索存储的值和setItem方法以设置它们的存储。此外,这必须与直接访问或设置伪属性(val = storage.aKeystorage.aKey = "value")的方式相同。这可以通过使用 ES6 代理实现:

"use strict";
/**
* Custom storage
*/
var myStorage = {
     /** @type {Object} key-value object */
     data: {},
     /**
      * Getter
      * @param {String} key
      * @returns {*}
      */
     getItem: function( key ){
       return this.data[ key ];
     },
     /**
      * Setter
      * @param {String} key
      * @param {*} val
      */
     setItem: function( key, val ){
       this.data[ key ] = val;
     }
   },
   /**
    * Storage proxy
    * @type {Proxy}
    */
   storage = new Proxy( myStorage, {
     /**
      * Proxy getter
      * @param {myStorage} storage
      * @param {String} key
      * @returns {*}
      */
     get: function ( storage, key ) {
       return storage.getItem( key );
     },
     /**
      * Proxy setter
      * @param {myStorage} storage
      * @param {String} key
      * @param {*} val
      * @returns {void}
      */
     set: function ( storage, key, val ) {
       return storage.setItem( key, val );
   }});

storage.bar = "bar";
console.log( myStorage.getItem( "bar" ) ); // bar
myStorage.setItem( "bar", "baz" );
console.log( storage.bar ); // baz

摘要

本章介绍了如何使用 JavaScript 核心特性达到最大效果的最佳实践和技巧。在下一章中,我们将讨论模块概念,并详细介绍作用域和闭包。下一章将解释作用域上下文及其操作方法。

第二章:使用 JavaScript 的模块化编程

总的来说,工程学就是将大型任务分解为小型任务,并在系统中组合这些任务的解决方案。在软件工程中,我们遵循低耦合和高内聚的原则,将代码库分解为模块。在本章中,我们将讨论在 JavaScript 中创建模块的方法,并涵盖以下主题:

  • 如何使用模块化的 JavaScript 摆脱困境

  • 如何在浏览器中使用异步模块

  • 如何在服务器上使用同步模块

  • JavaScript 内置模块系统

  • 将 CommonJS 转换为浏览器使用

如何使用模块化的 JavaScript 摆脱困境

你有多少数码照片,可能成千上万,或者更多?想象一下,如果你的图片查看器没有分类能力。没有相册,没有书籍,没有分类,什么都没有。它将毫无用处,不是吗?现在假设你有一个 JavaScript 应用程序,它包含在一个文件中,并且它不断增长。当它接近一千行或超过一千行代码时,无论你的代码设计有多好,从可维护性的角度来看,它仍然会变成像那堆未分类照片的巨大列表一样的无用。我们不应该构建一个单块应用程序,而应该编写几个独立的模块,它们组合在一起形成一个应用程序。因此,我们将复杂问题分解为简单任务。

模块

那么,模块是什么呢?模块封装了为特定功能设计的代码。模块还提供了一个接口,声明了模块公开和需要的元素。模块通常打包在一个文件中,这使得它容易定位和部署。一个设计良好的模块意味着低耦合(模块之间的相互依赖程度)和高内聚(模块元素彼此属于的程度)。

模块在 JavaScript 中给我们带来了哪些优势?

更清晰的全局作用域

你在 JavaScript 中知道任何在我们任何函数作用域之外进行的赋值都会在全局作用域中创建一个新的成员(在浏览器中是一个内置对象 window,在 Node.js/Io.js 中是 global)。因此,我们总是有意外覆盖已经定义属性的风险。相反,模块中声明的任何内容除非我们明确导出它,否则它将留在这里。

将代码打包成文件

在服务器端语言中,应用程序由许多文件组成。这里的一个最佳实践是,一个文件只包含一个类,并且只负责一件事情。此外,完全限定的类名必须反映其文件位置。所以当我们遇到对象问题时,我们可以很容易地推断出在哪里可以找到其源代码。我们可以将 JavaScript 应用程序代码分成不同的脚本,但这些将共享同一个作用域,并且不会给我们任何封装。此外,当脚本异步加载时,内部依赖关系必须解决,这并不容易。但是,如果我们使用模块,每个模块都有一个专门的文件和自己的作用域。模块加载器负责异步依赖关系。

重用

想象一下,在项目工作中,你写了一段代码,解决了一个任务——比如提供了一个方便的 API 来管理 cookie。当切换到另一个项目时,你意识到你的 cookie 管理器在那里会很合适。在意大利面条代码的情况下,你必须提取组件代码,解耦它,并将其绑定到新位置。如果你将组件作为设计得体的模块编写,你只需拿过来并插入即可。

模块模式

嗯,我们知道模块有帮助,并且我们想使用它们。那么,我们在 JavaScript 中如何实现一个模块呢?首先,我们需要将模块代码从全局作用域中分离出来。我们只能通过用函数包装模块代码来实现这一点。这里的一个常见做法是使用立即执行函数表达式IIFE):

IIFE
(function () {
  "use strict";
   // variable defined inside this scope cannot be accessed from outside
}());

模块还必须具有与周围环境交互的接口。就像我们通常处理函数一样,我们可以将对象引用作为 IIFE 的参数传递。

Import
(function ( $, Backbone ) {
   "use strict";
  // module body
}( jQuery, Backbone ));

你可能也看到过一种模式,即全局对象(window)通过参数传递。这种方式我们不是直接访问全局对象,而是通过引用。有一种观点认为通过局部引用访问更快。这并不完全正确。我准备了一个 Codepen,里面有一些测试,在codepen.io/dsheiko/pen/yNjEar。它显示,在 Chrome(v45)中,局部引用确实快了 20%;然而,在 Firefox(v39)中,这并没有造成任何显著的差异。

你也可以在参数列表中运行模式变体 with undefined。没有通过参数传递的参数有一个undefined值。所以,我们这样做是为了确保即使在全局undefined对象被覆盖的情况下,我们也能在作用域中获得真实的undefined对象。

Local References
(function ( window, undefined ) {
   "use strict";
  // module body
}( window ));

为了在模块的作用域外暴露模块元素,我们可以简单地返回一个对象。函数调用的结果可以赋值给外部变量,如下所示:

Export
/** @module foo */
var foo = (function () {
  "use strict";
       /**
        * @private
        * @type String
        */
   var bar = "bar",
       /**
        * @type {Object}
        */
       foo = {
         /**
          * @public
          * @type {String}
          */
         baz: "baz",
         /**
          * @public
          * @returns {String}
          */
         qux: function() {
           return "qux";
         }
       };
   return foo;
}());

console.log( foo.baz ); // baz
console.log( foo.qux() ); // qux

增强

有时我们需要在模块中混合事物。例如,我们有一个提供核心功能的模块,我们希望根据使用上下文插入扩展。假设,我有一个基于伪类声明创建对象的模块。

基本上,在实例化时它自动继承自指定的对象并调用构造方法。在特定的应用程序中,我还希望这也验证对象接口是否符合给定的规范。所以,我在基础模块中插入了这个扩展。是如何做到的?我们将基础模块的引用传递给插件。将保留对原始模块的链接,因此我们可以在插件的作用域中修改它:

/** @module foo */
var foo = (function () {
      "use strict";
           /**
            * @type {Object}
            */
         var foo = {
             /**
              * @public
              * @type {String}
              */
             baz: "baz"
           };
       return foo;
    }()),
    /** @module bar */
    bar = (function( foo ){
      "use strict";
      foo.qux = "qux";
    }( foo || {} ));

console.log( foo.baz ); // baz
console.log( foo.qux ); // qux

模块标准

我们已经回顾了实现模块的几种方法。然而,在实践中,我们更倾向于遵循一个标准化的 API。这些已经被一个庞大的社区证明,被实际世界的项目采用,并被其他开发者所认可。我们需要牢记的两个最重要的标准是AMDCommonJS 1.1,现在我们更愿意看看 ES6 模块 API,这将是下一件大事。

CommonJS 1.1 以同步方式加载模块。模块体在第一次加载后执行一次,导出的对象被缓存。它为服务器端 JavaScript 设计,主要用于 Node.js/Io.js。

AMD 以异步方式加载模块。模块体在第一次加载后执行一次,导出的对象也被缓存。这为浏览器使用而设计。AMD 需要一个脚本加载器。最受欢迎的有 RequireJS、curl、lsjs 和 Dojo。

很快,我们可以期待脚本引擎获得对 JavaScript 内置模块的原生支持。ES6 模块结合了两者的优点。与 CommonJS 类似,它们有紧凑的语法和支持循环依赖,与 AMD 类似,模块异步加载,加载可配置。

如何在浏览器中使用异步模块

为了掌握 AMD,我们将做一些例子。我们将需要脚本加载器 RequireJS(requirejs.org/docs/download.html).所以你可以下载它,然后在 HTML 中指定本地版本,或者给它一个外部链接到 CDN。

首先,让我们看看我们如何创建一个模块并请求它。我们把模块放在foo.js文件里。我们使用define()调用声明模块作用域。如果我们传递一个对象给这个调用,对象简单地被导出:

foo.js

define({
  bar: "bar",
  baz: "baz"
});

当我们传递一个函数时,它被调用,其返回值被导出:

foo.js

define(function () {
  "use strict";
  // Construction
  return {
    bar: "bar",
    baz: "baz"
  };
});

foo.js旁边放置main.js。这段代码可以如下描述:当第一个参数(这里只有foo,即./foo.js)提供的所有模块都被加载并可用时,调用给定的回调。

main.js

require( [ "foo" ], function( foo ) {
  "use strict";
  document.writeln( foo.bar );
  document.writeln( foo.baz );
});

从 HTML(index.html)开始,我们首先加载RequireJS,然后是main.js

index.html

<script src="img/require.min.js"></script>
<script src="img/main.js" ></script>

当我们有一个加载器时,同步加载脚本感觉不对。然而,我们可以用仅有的脚本元素来实现,此外,还可以强制它异步加载:

index.html

<script data-main="./main" async 
  src="img/require.min.js"></script>

使用data-main属性,我们告诉加载器首先加载哪个模块,无论何时模块准备就绪。当我们启动index.html时,我们将在main.js中导入的foo模块属性值。

index.html输出异步加载模块的导出内容:

如何在浏览器中使用异步模块

现在我们处理更多的依赖关系。所以我们创建了bar.jsbaz.js模块:

bar.js

define({
  value: "bar"
});

baz.js

define({
  value: "baz"
});

我们必须修改foo.js以访问这些模块:

foo.js

define([ "./bar", "./baz" ], function ( bar, baz ) {
  "use strict";
  // Construction
  return {
    bar: bar.value,
    baz: baz.value
  };
});

正如您可能注意到的,require/define依赖列表由模块标识符组成。在我们的案例中,所有模块和 HTML 位于同一目录中。否则,我们需要根据相对路径构建标识符(可以省略.js文件扩展名)。如果您路径出错,RequireJS 无法解析依赖,它会发出Error: Script error for:<module-id>。这有很大帮助吗?您可以自己改进错误处理。传递给模块作用域回调的函数表达式接收一个异常对象作为参数。这个对象具有特殊属性,如requireType(一个包含错误类型的字符串,如timeoutnodefinescripterror)和requireModules(受错误影响的模块 ID 数组)。

require([ "unexisting-path/foo" ], function ( foo ) {
  "use strict";
  console.log( foo.bar );
  console.log( foo.baz );
}, function (err) {
  console.log( err.requireType );
  console.log( err.requireModules );
});

在一个良好的设计中,模块众多,并且分配给一个目录树。为了避免每次都进行相对路径计算,您可以一次性配置脚本加载器。因此,加载器将通过指定的别名知道如何找到依赖文件:

main.js

require.config({
    paths: {
        foo: "../../module/foo"
    }
});
require( [ "foo" ], function( foo ) {
  "use strict";
  console.log( foo.bar );
  console.log( foo.baz );
});

这带来了一个好处。现在如果我们决定更改一个模块文件名,我们不需要修改每个需要它的其他模块。我们只需要更改配置:

main.js

require.config({
  paths: {
    foo: "../../module/foo-v0_1_1"
  }
});
require( [ "foo" ], function( foo ) {
  "use strict";
  console.log( foo.bar );
  console.log( foo.baz );
});

通过配置,我们也可以解决远程模块。例如,这里我们引用 jQuery,但 RequireJS 从配置中知道模块的端点,因此,从 CDN 加载模块:

require.config({

  paths: {
    jquery: "https://code.jquery.com/jquery-2.1.4.min.js"
  }
});

require([ "jquery" ], function ( $ ) {
  // use jQuery
});

优点和缺点

AMD 方法的优点之一是模块异步加载。这也意味着在部署时,我们不需要上传整个代码库,而只需上传一个模块。由于浏览器可以同时处理多个 HTTP 请求,这种方式可以提高性能。然而,这里有一个巨大的陷阱。并行加载几段代码确实很快。但是实际项目中的模块要多的多。使用目前仍占主导地位的 HTTP/1.1 协议,加载所有这些模块将需要很长时间。与新的 SPDY 和 HTTP/2 标准不同,HTTP/1.1 在下载页面时的并发性处理并不好,如果队列很长,这将导致头阻塞(http2.github.io/faq/)。RequreJS 提供了一个工具(requirejs.org/docs/optimization.html)来合并多个模块。这样我们不需要加载每个单独的模块,而只需要几个包。一起打包的依赖关系是同步解析的。因此,可以说在一定程度上我们放弃了 AMD 的主要优点——异步加载。同时,我们仍然需要加载一个通常相当重的脚本加载器,并用define()回调包装每个模块。

从我的经验来看,我更倾向于建议你使用与 Common JS 模块同步编译的包,这些包可以在浏览器中使用。

如何在服务器上使用同步模块

以下示例需要 Node.js。使用预编译安装器在nodejs.org/download/安装 Node.js 只需几分钟,甚至通过包管理器在github.com/joyent/node/wiki/Installing-Node.js-via-package-manager安装更快。

我们将从在模块中放入一个简单的逻辑开始:

foo.js

console.log( "I'm running" );

现在我们可以调用模块:

main.js

require( "./foo" );

为了运行示例,我们将打开控制台(在 Windows 上,你可以直接运行CMD.EXE,但我建议使用像 CMDER 这样的增强工具,可在cmder.net/获得)。在控制台中,我们输入以下内容:

node main.js

如何在服务器上使用同步模块

按下Enter键后,控制台输出I'm running。所以当请求一个模块时,其主体代码被执行。但如果我们多次请求该模块呢?

main.js

require( "./foo" );
require( "./foo" );
require( "./foo" );

结果是一样的。只输出了一次I'm running。这是因为模块主体代码只在模块首次请求时执行一次。导出的对象(可能由主体代码生成)被缓存,类似于单例:

foo.js

var foo = new Date();

main.js

var first = require( "./foo" ),
    second = require( "./foo" );

console.log( first === second ); // true

正如你可能会注意到的,与 AMD 不同,我们模块中不需要任何包装器。但它仍然与全局作用域隔离吗?

foo.js

var foo = "foo";

main.js

require( "./foo" );
console.log( typeof foo ); // undefined

模块作用域中定义的任何变量在作用域外不可用。然而,如果你真的希望在暴露的接口后面的模块变量之间共享任何东西,你可以通过一个全局对象来实现(Node.js 类似于浏览器中的 Windows 对象)。

那么关于导出有什么要注意的呢?CommonJS 更倾向于单个导出。我们将 module.exports 赋值为一个类型或值的引用,这将是所需函数的缓存返回。如果我们想要多个导出,我们只需导出一个对象:

foo.js

// module logic
module.exports = {
  bar: "bar",
  baz: "baz"
};

main.js

var foo = require("./foo");
console.log( foo.bar ); // bar
console.log( foo.baz ); // baz

以下是在 Node.js 中最常见的情况,导出一个对象构造函数:

foo.js

var Foo = function(){
  this.bar = "bar";
}

module.exports = Foo;

因此,通过一个必需的调用,我们可以获得带有原型的构造函数,并可以创建实例:

main.js

var Foo = require("./foo"),
    foo = new Foo();

console.log( foo.bar ); // bar

正如我们从 main 模块请求 foo 模块一样,我们也可以从其他模块请求:

bar.js

// module logic
module.exports = "bar";

baz.js

// module logic
module.exports = "baz";

foo.js

// module logic
module.exports = {
  bar: require( "./bar" ),
  baz: require( "./baz" )
};

main.js

var foo = require( "./foo" );
console.log( foo.bar ); // bar
console.log( foo.baz ); // baz

但是,如果 Node.js 遇到循环依赖呢?如果我们从被调用模块中请求回调用者,会发生什么?并没有什么戏剧性的事情发生。正如您可能记得的,模块代码只执行一次。所以,如果在 main.js 已经执行后,我们还是从 foo.js 请求 main.js,那么它的主体代码将不再被调用:

foo.js

console.log("Runnnig foo.js");
require("./main");

main.js

console.log("Runnnig main.js");
require("./foo");

当我们用 Node.js 运行 main.js 时,我们得到以下输出:

Runnnig main.js
Runnnig foo.js

优点和缺点

CommonJS 拥有简洁而富有表现力的语法。它非常容易使用。单元测试通常编写成在命令行运行,最好是持续集成的一部分。一个设计良好的 CommonJS 模块是一个完美的测试单元,您可以直接从 Node.js 驱动的测试框架(例如,Mocha)中访问,完全脱离应用程序上下文。然而,CommonJS 暗示了同步加载,这不适合在浏览器中使用。如果我们想绕过这个限制,我们必须将模块源代码编译成一个脚本,内部解决模块依赖关系而不加载(参见 "为浏览器使用编译 CommonJS")。

UMD

如果你希望你的模块既能在浏览器中作为 AMD 使用,又能在服务器上作为 CommonJS 使用,有一个技巧(github.com/umdjs/umd)。通过添加一个包装函数,你可以根据运行时环境动态构建所需的格式的导出。

JavaScript 的内置模块系统

嗯,AMD 和 CommonJS 都是社区标准,并不是语言规范的一部分。然而,随着 EcmaScript 第六版的推出,JavaScript 拥有了它自己的模块系统。目前,还没有浏览器支持这一特性,因此我们必须安装 Babel.js 编译器来处理例子。

由于 Node.js 已经随 NPM 分发(NPM 是 Node.js 的包管理器),我们现在可以运行以下命令:

npm install babel -g

命名导出

现在我们可以像下面这样编写一个模块:

foo.es6

export let bar = "bar";
export let baz = "baz";

在 ES6 中,我们可以导出多个元素。任何用关键字 export 声明的变量或函数都可以被导入:

main.es6

import { bar, baz } from "./foo";
console.log( bar ); // bar
console.log( baz ); // baz

由于我们目前还没有在浏览器中支持 ES6 模块,我们将将它们转换为 CommonJS 或 AMD。在这里,Babel.js 帮助我们:

babel --modules common *.es6 --out-dir .

通过这个命令,我们让 Babel.js 将当前目录下的所有 *.es6 文件翻译成 CommonJS 模块。因此,我们可以用 Node.js 运行派生的 main.js 模块:

node main.js

命名导出

同样,我们将 ES6 模块转换为 AMD:

babel --modules amd *.es6 --out-dir .

index.html

<script data-main="./main" 
  src="img/require.min.js"></script>

在前一个示例中,我们在导入语句中列出了我们的命名导出。我们也可以导入整个模块,并将命名导出作为属性引用:

main.es6

import * as foo from "./foo"; 
console.log( foo.bar ); // bar
console.log( foo.baz ); // baz

默认导出

除了默认导出,我们还可以这样做。这是在 Node.js 中通常是如何进行导出的:

foo.es6

export default function foo(){ return "foo"; }

main.es6

import foo from "./foo";
console.log( foo() ); // foo

我们导出了一个函数,并在导入时带来了它。这也可以是一个类或一个对象。

在 AMD 中,我们将导出作为回调参数接收,而在 CommonJS 中,作为局部变量。尽管 ES6 没有导出值,但它导出了所谓的绑定(引用),这些引用是不可变的。您可以读取它们的值,但如果您尝试更改它们,您会得到一个类型错误。Babel.js 在编译时触发这个错误:

foo.es6

export let bar = "bar";
export function setBar( val ) {
   bar = val;
};

main.es6

import { bar, setBar } from "./foo";
console.log( bar ); // bar
setBar( "baz" );
console.log( bar ); // baz
bar = "qux"; // TypeError

模块加载器 API

除了在单独的规范中声明性语法(github.com/whatwg/loader/),ES6 还为我们提供了一个程序化 API。它允许我们以编程方式处理模块并配置模块加载:

System.import( "./foo" ).then( foo => {
  console.log( foo );
})
.catch( err => {
  console.error( err );
});

与 Node.js 不同,由于 ES6 模块的声明性特性,需要在顶层引入和导出。所以,这不能是条件性的。然而,有了实用的加载器 API,我们可以采取其他方式:

Promise.all([ "foo", "bar", "baz" ]
    .map( mod => System.import( mod ) )
  )
  .then(([ foo, bar, baz ]) => {
     console.log( foo, bar, baz );
  });

在这里,我们定义了一个回调函数,当三个指定的模块都加载完成后才会被调用。

结论

AMD 和 CommonJS 都是过渡性标准。一旦 JavaScript 内置模块系统在脚本引擎中获得更广泛的支持,我们实际上就不再需要它们了。ES6 模块异步加载,加载方式可以配置成类似于 AMD。它们还有紧凑且表达性强的语法,并支持类似于 CommonJS 的循环依赖。此外,ES 提供静态模块结构的声明性语法。这种结构可以被静态分析(静态检查、校验、优化等)。ES6 还提供了一个程序化加载器 API。因此,您可以配置模块如何加载以及如何条件性加载模块。另外,ES6 模块可以与宏和静态类型扩展。

虽然一切看起来都很明朗,但仍有一只苍蝇在瓶中。ES6 模块可以预先以同步方式加载(使用<script type="module"></script>),但通常会有异步加载,这让我们陷入了与 AMD 相同的陷阱中。HTTP/1.1 上的多次 HTTP 请求对用户响应时间产生了有害影响 (developer.yahoo.com/performance/rules.html)。另一方面,SPDY 和 HTTP/2 允许每个 TCP 连接发送多个请求,得到了更广泛的支持,并最终会取代可疑的 HTTP/1.x。此外,W3C 正在制定一个名为Web 上的打包的标准 (w3ctag.github.io/packaging-on-the-web/),描述了如何从 URL(哈希)接收归档文件(脚本)。因此,我们将能够将整个目录与模块一起打包成一个归档文件,部署它们,并以与将它们放在目录中相同的方式引用它们。

为浏览器环境转换 CommonJS

虽然 HTTP/2 和Web 上的打包还在路上,我们需要快速的模块化应用程序。如前所述,我们可以将应用程序代码划分为 CommonJS 模块,并将它们转换为供浏览器使用。最受欢迎的 CommonJS 转换器无疑是 Browserify (browserify.org)。这个工具的最初任务是使 Node.js 模块可重用。他们在这一点上做得相当成功。这可能看起来像魔法,但你可以真正地在客户端使用EventEmitter和其他一些 Node.js 核心模块。然而,由于主要关注 Node.js 兼容性,该工具为 CommonJS 编译提供的选项太少。例如,如果你想进行依赖项配置,你必须使用一个插件。在实际项目中,你可能会最终使用多个插件,每个插件都有特定的配置语法。因此,总体设置变得过于复杂。相反,我们将在此处探讨另一个名为 CommonJS Compiler (github.com/dsheiko/cjsc)的工具。这是一个相当小的实用程序,旨在将 CommonJS 模块带入浏览器。这个工具非常容易配置和使用,这使得它成为一个很好的选择来阐述这个概念。

首先,我们安装cjsc

npm install cjsc -g

现在我们可以从如何在服务器上同步模块部分中取一个例子,并为浏览器环境转换它:

bar.js

// module logic
module.exports = "bar";

foo.js

// module logic
module.exports = {
  bar: require( "./bar" )};

main.js

var foo = require( "./foo" );
document.writeln( foo.bar ); // bar

起点是main.js。因此,我们告诉cjsc将这个模块与所有必需的依赖递归地打包到bundle.js中:

cjsc main.js -o bundle.js

为浏览器环境转换 CommonJS

让我们来看看生成的文件。cjsccustom _require替换了所有的require调用,并将其放在了开头的_require函数定义中。这个小技巧让你可以在像 NW.js 这样的 Node.js/Io.js 友好环境中运行编译后的代码,在那里require函数仍然需要用于本地包。每个模块都被一个提供模块相关对象(exports 和 modules)以及全局对象的函数作用域(window)所包裹。

Compiled Code
_require.def( "main.js", function( _require, exports, module, global )
{
  var foo = _require( "foo.js" );
  console.log( foo.bar ); // bar
  console.log( foo.baz ); // baz
    return module;
  });

生成的代码是通用 JavaScript,我们肯定可以从 HTML 中对其进行定位:

index.html

<script src="img/bundle.js"></script>

我们的源代码仍然是 CommonJS 模块。这意味着我们可以在基于 Node.js 的框架中直接访问它们进行单元测试。Mocha.js 测试的官方网站是mochajs.org/

var expect = require( "chai" ).expect;
describe( "Foo module", function(){
  it( "should bypass the export of bar", function(){
      var foo = require( "./foo" );
      expect( foo ).to.have.property( "bar" );
      expect( foo.bar ).to.eql( "bar" );
  });
});

cjsc有许多选项。但在实际项目中,每次构建都输入一个长命令行会令人厌烦且效率低下:

cjsc main-module.js -o build.js  --source-map=build/*.map \
 --source-map-root=../src -M --banner="/*! pkg v.0.0.1 */"

我们使用像GruntGulpCakeBroccoli这样的任务运行器的原因就在于此。目前最受欢迎的任务运行器是Grunt(gruntjs.com),它拥有大量的插件可供选择(参见sixrevisions.com/web-development/grunt-vs-gulp/上的 Grunt 与 Gulp 对比信息图)。因此,我们需要将grunt命令行界面全局安装:

npm install -g grunt-cli

为了设置一个Grunt项目,我们需要两个配置文件,package.json(docs.npmjs.com/files/package.json)和Gruntfile.js文件。第一个包含有关运行Grunt任务的 NPM 包的元数据。第二个用于定义和配置任务。

我们可以从一个非常简洁的package.json开始,其中只包含一个任意项目名及其版本,采用语义版本控制(semver.org/)格式:

package.json

{
  "name": "project-name",
  "version": "0.0.1"
}

现在我们可以安装所需 NPM 包:

npm install --save-dev grunt
npm install --save-dev grunt-cjsc

这样我们就得到了一个本地的 Grunt 和一个 CommonJs 编译器的 Grunt 插件。特殊的--save-dev选项在package.json部分创建devDependencies(如果不存在),并将其填充为已安装的依赖项。例如,当我们从版本控制系统拉取项目源代码时,我们可以通过简单地运行npm install来恢复所有依赖项。

Gruntfile.js中,我们必须加载已经安装的grunt-cjsc插件,并配置一个名为cjsc的任务。实际上,我们将需要至少两个目标,为这个任务提供不同的配置。第一个,cjsc:debug,运行cjsc以生成未压缩的代码,并提供源映射。第二个,cjsc:build,用于准备部署资产。所以我们得到了bundle.js中的压缩代码:

Gruntfile.js

module.exports = function( grunt ) {
  // Project configuration.
  grunt.initConfig({
    pkg: grunt.file.readJSON( "package.json" ),
    cjsc: {
      // A target to generate uncompressed code with sources maps
      debug: {
        options: {
          sourceMap: "js/*.map",
          sourceMapRoot: "src/",
          minify: false
        },
        files: { "js/bundle.js": "js/src/main.js" }
      },
      // A target to build project for production
      build: {
        options: {
          minify: true,
          banner: "/*! <%= pkg.name %> - v<%= pkg.version %> - " +
          "<%= grunt.template.today(\"yyyy-mm-dd\") %> */"
        },
        files: { "js/bundle.js": "js/src/main.js" }
      }
    }
  });

  // Load the plugin that provides the task.
  grunt.loadNpmTasks( "grunt-cjsc" );

  // Make it default task
  grunt.registerTask( "default", [ "cjsc:build" ] );

};

从配置中,我们可以看到cjsc旨在将js/src/main.js``transpilejs/bundle.js。因此,我们可以将前面示例中的模块复制到./js/src

现在,当一切准备就绪后,我们将运行一个任务。例如,请看以下内容:

grunt cjsc:debug

将 CommonJS 转译以供浏览器使用

如前所述,我们可以使用 cjsc 配置依赖映射。我们只需在一个对象字面量中描述依赖项,该字面量可以作为 JSON 文件通过命令行界面提供给 cjsc,或注入到 Grunt 配置中:

{
  "jquery": {
    "path": "./vendors/jQuery/jquery.js"
  },
  "underscore": {
    "globalProperty": "_"
  },
  "foo": {
    "path": "./vendors/3rdpartyLib/not-a-module.js",
    "exports": [ "notAModule" ],
    "imports": [ "jquery" ]
  }
}

在这里,我们声明了位于 ./vendors/jQuery/jqueiry.js 的模块的 jquery 别名(快捷方式)。我们还说明了一个全局暴露的 "_"(Underscore.js)库必须被视为一个模块。最后,我们指定了第三方组件的路径、导出和导入。因此,我们得到了这个在应用(不干预其代码)中作为一个模块的 this,尽管它不是一个模块:

cjsc main.js -o bundle.js --config=cjsc-conig.json

或者我们可以使用以下 Grunt 配置:

 grunt.initConfig({
cjsc main.js -o bundle.js --config=cjsc-conig.json
Grunt configuration
 grunt.initConfig({
    cjsc: {
      build: {
        options: {
          minify: true,
          config: require( "fs" ).readFileSync( "./cjsc-conig.json" )
        }
      },
        files: { "js/bundle.js": "js/src/main.js" }
      }
  });

将 ES6 模块捆绑以实现同步加载

嗯,正如我们在JavaScript 内置模块系统部分提到的,ES6 模块将会取代 AMD 和 CommonJS 标准。而且,我们现在就可以写 ES6 代码并将其转译为 ES5。一旦支持 ES6 的脚本代理足够好,我们从理论上可以使用我们的代码。然而,性能呢?实际上,我们可以将 ES6 模块编译成 CommonJS 并然后用 cjsc 捆绑它们以供浏览器使用:

foo.es6

export let bar = "bar";
export let baz = "baz";

main.es6

import { bar, baz } from "./foo";
document.writeln( bar ); // bar
document.writeln( baz ); // baz

首先,我们将 ES6 编译成 CommonJS 模块:

babel --modules common *.es6 --out-dir .

然后,我们将 CommonJS 模块捆绑成一个适合浏览器使用的脚本:

cjsc main.js -o bundle.js -M

摘要

模块化编程是与面向对象编程紧密相关的一个概念,它鼓励我们为更好的可维护性来结构化代码。特别是,JavaScript 模块保护全局作用域免受污染,将应用程序代码分成多个文件,并允许重用应用程序组件。

目前大多数使用的两个模块 API 标准是 AMD 和 CommonJS。第一个是为浏览器使用而设计的,假设异步加载。第二个是同步的,适用于服务器端 JavaScript。然而,你应该知道 AMD 有的重大缺陷。一个细粒度的应用程序设计,拥有大量的通过 HTTP/1.1 的模块,可能会在应用程序性能方面造成灾难。这是最近将 CommonJS 模块转译为浏览器使用实践日益增多的主要原因。

这两个 API 都应被视为过渡性标准,因为即将到来的 ES6 模块标准旨在取代它们。目前,没有脚本引擎支持这一功能,但有一些转译器(例如,Babel.js)允许将 ES6 模块转译成 CommonJS 或 AMD。

第三章:DOM 脚本和 AJAX

当涉及到文档对象模型DOM)操作和 AJAX 时,第一反应可能是使用 jQuery 或 Zepto。但是,这难道不让你烦恼吗?你为了一些普通的任务,却加载了一个沉重的第三方库,而浏览器已经为你提供了所需的一切?有些人引入 jQuery 是为了跨浏览器兼容性。好吧,这个库是用来修复损坏的 DOM API。这在我们要支持像 IE7 这样老旧浏览器的时候真的很有帮助。然而,今天,当我们支持的浏览器使用率不到 0.1%时,我们几乎不需要关心遗留浏览器(www.w3schools.com/browsers/browsers_explorer.asp)。现代浏览器在支持 Web API 方面相当一致。总的来说,跨浏览器兼容性不再是问题。

第二个,也是最常见的借口是,这个库简化了你需要编写的查询和操作 DOM 的代码量。它在某种程度上简化了代码,但缺点是,现在我们有一代开发者不知道 JavaScript 和 Web API,只知道 jQuery。其中许多人没有这个库就无法解决一个简单的任务,也不知道当他们调用库方法时实际发生了什么。良好的代码意味着可移植性和高性能。没有对原生 API 的了解,很难实现这一点。

因此,在本章中,我们将探讨原生处理 DOM 和 AJAX 的方式,重点关注高性能。

本章将涵盖以下主题:

  • 高速 DOM 操作

  • 与服务器的通信

高速 DOM 操作

为了高效地处理 DOM,我们需要了解它的本质。DOM 是一个表示在浏览器中打开的文档的树结构。DOM 中的每个元素都称为节点。

高速 DOM 操作

每个节点作为一个对象都有属性和方法(developer.mozilla.org/en/docs/Web/API/Node)。节点有不同的类型。在前面的图片中,你可以看到一个文档节点、元素节点和文本节点。实际上,树也可能包含特定类型的节点,如注释节点、文档类型节点等。为了说明树内的关系,我们可以认为 HTML 有两个子节点HEADBODY,它们作为兄弟姐妹相互关联。显然,HTML 是 HEAD 和 BODY 的父节点。我们可以使用这些通过节点属性可访问的关系来导航树:

var html = document.documentElement;
console.log( html.nodeName ); // HTML

var head = html.childNodes[0];
console.log( head.nodeName );  // HEAD
console.log( head.parentNode === html );  // true

这部分很清楚,但如果我们请求下一个兄弟节点是 HEAD 而不是 BODY,我们将得到一个内容中包含空白符的文本节点(nodeValue):

var sibling = head.nextSibling;
// the same as html.childNodes[1]
console.log( sibling.nodeName ); // #text
console.dir( sibling.nodeValue ); // "\n  "

在 HTML 中,我们通常用空格、TAB 和换行符来分隔元素,以提高可读性,这些也构成了 DOM 的一部分。因此,为了访问元素,我们最好使用文档和元素方法。

遍历 DOM

当然,你知道如何通过 ID(document.getElementById)或标签名(document.getElementsByTagName)找到一个元素。你也可以通过 CSS 选择器(document.querySelector)查找一个元素:

<article id="bar">
  <h2>Lorem ipsum</h2>
</article>
var article = document.querySelector( "#bar" ),
      heading = article.querySelector( "h2" );

选择器由一个或多个类型(标签)选择器、类选择器、ID 选择器、属性选择器或伪类/元素选择器组合而成(www.w3.org/TR/CSS21/selector.html%23id-selectors)。考虑到组合(匹配一组、后代或兄弟姐妹),这给了我们相当多的可能选项。所以选择一个将 HTML 元素从 JavaScript 绑定的策略可能会很难。我的建议是始终使用data-*属性选择器:

<article data-bind="bar">
  <h2 data-bind="heading">Lorem ipsum</h2>
</article>

var article = document.querySelector( "[data-bind=\"bar\"]" ),
      heading = article.querySelector( "[data-bind=\"heading\"]" );

这样我们就独立于 HTML 结构了。如果我们改变标签,例如为了更好的语义,JavaScript 方面不会出错。我们独立于 CSS 类,这意味着我们可以安全地重构 CSS。我们不受 ID 的限制,ID 在每个文档中应该是唯一的。

querySelector取 DOM 中匹配选择器的第一个元素,而querySelectorAll检索所有它们:

<ul data-bind="bar">
  <li data-bind="item">Lorem ipsum</li>
  <li data-bind="item">Lorem ipsum</li>
  <li data-bind="item">Lorem ipsum</li>
</ul>

var ul = document.querySelector( "[data-bind=\"bar\"]" ),
      lis = ul.querySelectorAll( "[data-bind=\"item\"]" );
console.log( lis.length );

找到的元素被表示为一个NodeList。它看起来像一个数组,但它不是。它是一个实时集合,在每次 DOM 重排时都会被更新。考虑以下示例:

var divs = document.querySelectorAll( "div" ), i; 
for ( i = 0; i < divs.length; i++ ) { 
  document.appendChild( document.createElement( "div" ) ); 
}

前面的代码会导致一个无限循环,因为无论我们访问集合的下一个元素,都会向集合中添加一个新元素,divs.length递增,我们永远满足不了循环条件。

重要的是要知道,遍历一个实时集合(NodeListHTMLCollection)是慢的,并且资源消耗很大。如果你不需要它是实时的,只需将集合转换为一个数组,例如[].slice.call( nodeList ),正如在第一章,深入 JavaScript 核心中提到的那样。在 ES6 中,这可以用[...nodeList]spread操作符完成:

var ul = document.querySelector( "[data-bind=\"bar\"]" ),
      lis = ul.querySelectorAll( "[data-bind=\"item\"]" );
console.log( [].slice.call( lis ) ); // into array ES5 way
console.log( [ ...lis ] ); // into array ES6 way

除了查询,我们还可以测试找到的元素是否与给定的选择器匹配:

console.log( el.matches( ".foo > .bar" ) );
console.log( input.matches( ":checked" ) );

改变 DOM

嗯,现在我们知道如何在 DOM 中找到元素了。那么我们来看看如何将新元素动态插入到 DOM 树中。有多种方法。我们可以简单地使用el.innerHTML方法设置新的 HTML 内容:

var target = document.getElementById( "target" );
target.innerHTML = "<div></div>";

否则,我们可以创建一个节点(document.createElement)并将其注入到 DOM 中(el.appendChild):

var target = document.getElementById( "target" ),
      div = document.createElement( "div" ),
target.appendChild( div );

在这里你应该记得,每次我们改变el.innerHTML或者向一个元素中添加一个子元素,我们都会引起 DOM 重排。当这种情况在循环中反复发生时,它可能会减慢应用程序的速度。

当我们通过el.innerHTML传递 HTML 时,浏览器首先必须解析字符串。这是一个耗资源的操作。然而,如果我们明确地创建元素,这个操作会快得多。如果我们生产出一系列相似的元素,流程还可以进一步优化。我们可以在循环中创建每一个元素,也可以创建一个原始创建的元素副本(el.cloneNode),这样可以快得多:

var target = document.getElementById( "target" ),
    /**
     * Create a complex element
     * @returns {Node}
     */
    createNewElement = function(){
      var div = document.createElement( "div" ),
          span = document.createElement( "span" );
      span.appendChild( document.createTextNode( "Bar" ) );
      div.appendChild( span );
      return div;
    },
    el;

el = createNewElement();
// loop begins
target.appendChild( el.cloneNode( true ) );
// loop ends

另一方面,我们可以创建一个文档片段(document.createDocumentFragment)并在循环中向片段添加创建的节点。文档片段是一种虚拟 DOM,我们对其进行操作而不是真实的 DOM。一旦我们完成,我们可以将文档片段作为分支注入到真实的 DOM 中。通过结合这种技术和克隆技术,我们预计在性能上会有所收获。实际上,这并不确定(codepen.io/dsheiko/pen/vObVOR)。例如,在 WebKit 浏览器中,虚拟 DOM(document.createDocumentFragment)比真实 DOM 运行得慢。

正如我们在性能方面所做的那样,让我们关注准确性。如果我们需要将一个元素注入到确切的位置(例如,在foobar节点之间),el.appendChild并不是正确的方法。我们必须使用el.insertBefore

parent.insertBefore(el, parent.firstChild);

要从 DOM 中删除一个特定的元素,我们这样做:

el.parentNode.removeChild(el);

此外,我们还可以重新加载元素,例如,重置所有订阅的事件监听器:

function reload( el ) {
    var elClone = el.cloneNode( true );
    el.parentNode && el.parentNode.replaceChild( elClone, el );
 }

样式化 DOM

谈到样式,我们 wherever possible 使用 CSS 类。这提供了更好的可维护性——继承、组合和关注分离。你当然知道如何通过el.className属性为元素分配预期的类。然而,在现实世界中,el.classList对象要实用得多:

el.classList.add( "is-hidden" );
el.classList.remove( "is-hidden" );
var isAvailable = true;
el.classList.toggle("is-hidden", !isAvailable );
if ( el.classList.contains( "is-hidden" ) ){}

在这里,除了明显的添加/删除/包含方法,我们还使用toggle。这个方法根据作为第二个参数传递的布尔值,要么添加,要么删除指定的类。

有时我们需要显式地操作样式。DOM 的一个部分叫做CSS 对象模型CSSOM),它提供了一个接口来操作 CSS。因此,我们可以使用el.style属性读取或设置元素的动态样式信息:

el.style.color = "red";
el.style.fontFamily = "Arial";
el.style.fontSize = "1.2rem";

一个较少为人所知的技巧是改变样式规则的实际文本:

el.style.cssText = "color:red;font-family: Arial;font-size: 1.2rem;";

正如你所看到的,第二种方法并不那么灵活。你不能改变或访问一个声明,而只能访问整个规则。然而,这种样式的速度显著更快(codepen.io/dsheiko/pen/qdvWZj)。

虽然el.style包含了元素的显式样式,但window.getComputedStyle返回的是继承(计算)样式:

var el = document.querySelector( "h1" ),
    /**
     * window.getComputedStyle
     * @param {HTMLElement} el
     * @param {String} pseudo - pseudo-element selector or null 
     * for regular elements
     * @return {CSSStyleDeclaration}
     */
    css = window.getComputedStyle( el, null );
console.log( css.getPropertyValue( "font-family" ) );

我们刚刚检查的情况指的是内联样式。实际上,我们也可以访问外部或内部样式表:

<style type="text/css">
.foo {
 color: red;
}
</style>
<div class="foo">foo</div>
<script type="text/javascript">
var stylesheet = document.styleSheets[ 0 ];
stylesheet.cssRules[ 0 ].style.color = "red";
// or
// stylesheet.cssRules[ 0 ].style.cssText = "color: red;";
</script>

为什么我们要这样做呢?因为有特殊情况。例如,如果我们想要修改,比如说,伪元素的样式,我们必须涉及到样式表:

var stylesheet = document.styleSheets[ 0 ];
stylesheet.addRule( ".foo::before", "color: green" );
// or
stylesheet.insertRule( ".foo::before { color: green }", 0 );

利用属性和属性

HTML 元素有属性,我们可以从 JavaScript 访问它们:

el.setAttribute( "tabindex", "-1" );
if ( el.hasAttribute( "tabindex" ) ) {}
el.getAttribute( "tabindex" );
el.removeAttribute( "tabindex" );

虽然 HTML 定义了元素属性,但属性是由 DOM 定义的。这造成了区别。例如,如果你有一个输入元素,最初属性和属性(el.value)有相同的值。然而,当用户或脚本改变值时,属性不会受到影响,但属性会:

// attribute
console.log( input.getAttribute( "value" ) );
// property
console.log( input.value );

正如你可能很可能知道的那样,除了全局属性之外,还有一种特殊类型——自定义数据属性。这些属性旨在提供 HTML 及其 DOM 表示之间交换专有信息,由脚本使用。基本想法是,你定义一个自定义属性,如data-foo,并为其设置一个值。然后,从脚本中,我们使用el.dataset对象访问和改变属性:

console.log( el.dataset.foo ); 
el.dataset.foo = "foo";

如果你定义了一个多部分属性,如data-foo-bar-baz,相应的dataset属性将是fooBarBaz

console.log( el.dataset.fooBarBaz ); 
el.dataset.fooBarBaz = "foo-bar-baz";

处理 DOM 事件

在浏览器中发生了许多事件。这可以是设备事件(例如,设备改变位置或方向),窗口事件(例如,窗口大小),一个过程(例如,页面加载),媒体事件(例如,视频暂停),网络事件(连接状态改变),当然,还有用户交互事件(点击,键盘,鼠标和触摸)。我们可以使我们的代码监听这些事件,并在事件发生时调用订阅的处理函数。要订阅 DOM 元素的某个事件,我们使用addEventListener方法:

EventTarget.addEventListener( <event-name>, <callback>, <useCapture> );

在前面的代码中,EventTarget可以是窗口、文档、元素或其他对象,如XMLHttpRequest

useCapture是一个布尔值,你可以指定事件传播的方式。例如,用户点击一个按钮,这个按钮在一个表单中,我们为这个点击事件订阅了两个元素的处理程序。当useCapturetrue时,表单元素的处理程序(ancestor)将首先被调用(capturing flow)。否则,表单的处理程序将在按钮的处理程序之后被调用(bubbling flow)。

callback是一个在事件触发时调用的函数。它接收一个Event对象作为参数,该对象具有以下属性:

  • Event.type:这是事件的名字

  • Event.target:这是事件发生的事件目标

  • Event.currentTarget:这是事件目标,监听器附加到该目标(targetcurrentTarget可能在我们为多个元素附加相同的事件处理程序时有所不同,如developer.mozilla.org/en-US/docs/Web/API/Event/currentTarget所述)

  • Event.eventPhase:这指示事件流程的哪个阶段正在被评估(无、捕获、目标或冒泡)

  • Event.bubbles:这表明事件是否是冒泡事件

  • Event.cancelable:这表明是否可以防止事件的默认动作

  • Event.timeStamp:这指定了事件时间

事件还有以下方法:

  • Event.stopPropagation():这阻止事件进一步传播。

  • Event.stopImmediatePropagation():如果我们有多个监听器订阅了同一个事件目标,在调用这个方法后,剩下的监听器将不会被调用。

  • Event.preventDefault():这阻止默认行为。例如,如果它是一个提交类型的按钮的点击事件,通过调用这个方法,我们可以阻止它自动提交表单。

让我们在实践中试试看:

<form action="/">
<button type="submit">Click me</button>
</form>
<script>
var btn = document.querySelector( "button" )
    onClick = function( e ){
      e.preventDefault(); 
      console.log( e.target );
    };
btn.addEventListener( "click", onClick, false );
</script>

在这里,我们为按钮元素的一个点击事件订阅了一个onClick监听器。当按钮被点击时,它会在 JavaScript 控制台中显示表单没有被提交的事实。

如果我们想要订阅键盘事件,我们可以这样做:

addEventListener( "keydown", function( e ){
    var key = parseInt( e.key || e.keyCode, 10 );
     // Ctrl-Shift-i
    if ( e.ctrlKey && e.shiftKey && key === 73 ) {
      e.preventDefault();
      alert( "Ctrl-Shift-L pressed" );
    }
  }, false );

过程事件的最常见例子是文档就绪状态的改变。我们可以监听DOMContentLoadedload事件。第一个事件在文档完全加载和解析后触发。第二个事件还等待样式表、图像和子框架加载完成。在这里,有一个怪癖。我们必须检查readyState,因为如果在事件可能已经触发后注册一个监听器,回调将永远不会被调用:

function ready( cb ) {
  if ( document.readyState !== "loading" ){
    cb();
  } else {
    document.addEventListener( "DOMContentLoaded", cb );
  }
}

嗯,我们知道如何使用EventTarget.addEventListener方法订阅 DOM 事件。EventTarget对象还有一个方法来取消订阅监听器。例如,请看以下内容:

btn.removeEventListener( "click", onClick );

如果我们想要触发一个 DOM 事件,例如模拟一个按钮点击,我们必须创建一个新的Event对象,设置它,并在我们想要事件触发时在元素上分派:

var btn = document.querySelector( "button" ),
    // Create Event object
    event = document.createEvent( "HTMLEvents" );
// Initialize a custom event that bubbles up and cannot be canceled 

event.initEvent( "click", true, false );
// Dispatch the event
btn.dispatchEvent( event );

同样,我们也可以创建我们自己的自定义事件:

var btn = document.querySelector( "button" ),
    // Create Event object
    event = document.createEvent( "CustomEvent" );
// Subscribe to the event 
btn.addEventListener("my-event", function( e ){
  console.dir( e );
});
// Initialize a custom event that bubbles up and cannot be canceled 
event.initEvent( "my-event", true, false );
// Dispatch the event
btn.dispatchEvent( event );

与服务器通信

许多人使用第三方库来向服务器发送任何请求。但我们真的需要这些库吗?让我们在下面的内容中看看如何使用原生的 AJAX,以及下一个通信 API 将是什么。

XHR

XMLHttpRequestXHR)是 JavaScript 中用于在客户端和服务器之间交换数据的主要 API。XHR 最初由微软在 IE5 中通过 ActiveX 呈现(1999 年),并且在 IE 浏览器直到版本 7(2006 年)中都有一种专有的语法。这导致了兼容性问题,促成了AJAX 库(如 Prototype 和 jQuery)的出现。如今,XHR 在所有主流浏览器中的支持都是一致的。通常,要执行一个 HTML 或 HTTPS 请求,我们需要完成许多任务。我们创建一个 XHR 的实例,通过 open 方法初始化一个请求,为与请求相关的事件订阅监听器,设置请求头(setRequestHeader),最后调用 send 方法:

var xhr = new XMLHttpRequest();
xhr.open( "GET", "http://www.telize.com/jsonip?callback=0", true );
xhr.onload = function() {
      if ( this.status === 200 ) {
        return console.log( this.response );
      }
    };

xhr.responseType = "json";
xhr.setRequestHeader( "Content-Type", "application/x-www-form-urlencoded" );
xhr.send( null );

还有更多选项可用。例如,我们可以利用progressabort事件来控制文件上传(developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/Using_XMLHttpRequest)。

我突然想到,对于一个简单的调用,这个接口过于复杂了。互联网上有大量的 XHR 包装器实现。最流行的实现之一可以在github.com/Raynos/xhr找到。它使得 XHR 的使用如此简单:

xhr({
  uri: "http://www.telize.com/jsonip",
  headers: {
    "Content-Type": "application/json"
  }
}, function ( err, resp ) {
  console.log( resp );
})

此外,该库还提供了一个模拟对象,可用于在单元测试中替换真实的 XHR。

Fetch API

我们刚刚检查了 XHR API。这在 15 年前看起来不错,但现在看起来很笨拙。我们必须使用包装器来使其更友好。幸运的是,语言已经演进,现在我们有一个新的内置方法叫做 Fetch API。想想使用它进行调用的容易程度:

fetch( "/rest/foo" ).then(function( response ) {
  // Convert to JSON
  return response.json();
}).catch(function( err ) {
  console.error( err );
});

尽管表面上很简单,这个 API 还是很强大的。fetch方法期望在第一个强制参数中是一个带有远程方法 URL 的字符串或者一个Request对象。请求选项可以在第二个可选参数中传递:

fetch( "/rest/foo", {
  headers: {
    "Accept": "application/json",
    "Content-Type": "application/json"
  }
});

与我们的上一个片段类似,fetch 方法返回Promise。Promise 对于异步或延时操作已经成为一种常见的实践。在 Promise 实现时调用的函数(参见 then)接收一个Response对象。这个函数有许多属性和方法(developer.mozilla.org/en-US/docs/Web/API/Response)。因此,我们可以使用相应的转换方法将响应转换为 JSON、文本、blob 或流,并且我们可以获得与请求相关的信息:

console.log( response.text() );
console.log( response.status );
console.log( response.statusText );
console.log( response.headers.get( "Content-Type" ) );

那么POST请求呢?Fetch 有一个名为body的混合插件,它代表了Response/Request的正文。我们可以通过这个传递POST数据:

var form = document.querySelector( "form[data-bind=foo]" ),
    inputEmail = form.querySelector( "[name=email]" ),
    inputPassword = form.querySelector( "[name=pwd]" );

fetch( "/feedback/submit", {
  method: "post",
  body: JSON.stringify({
    email: inputEmail.value,
    answer: inputPassword.value
  })
});

它不仅接受键值对,还可以接受例如FormData,所以你可以像这样提交整个表单以及附带的文件:

var form = document.querySelector( "form[data-bind=foo]" );
fetch( "/feedback/submit", {
  method: "post",
  body: new FormData( form )
});

目前,一些主流浏览器(例如,IE/Edge、Safari)不支持这个 API。然而,如果你打算使用 Fetch API,你可以使用 Fetch 的 polyfill(github.com/github/fetch)。

总结

在过去,每个浏览器的制造商都有自己定制的 DOM 实现,这些实现之间几乎不兼容。然而,这种情况已经改变,W3C DOM 至少在浏览器中得到了十年的支持。今天,我们可以安全地使用 JavaScript 原生 API 来访问、操作和样式化 DOM。

在 JavaScript 中,XHR 仍然是客户端与服务器之间通信的主要 API。不过它对开发者并不太友好。因此,我们通常为其编写自定义包装器。

然而,一个新的 API,名为 Fetch,已经被提出并已经在 Chrome、Firefox 和 Opera 中得到实现。这个新 API 的使用要简单得多,与 XHR 相比,它提供了更加令人印象深刻且灵活的功能。

第四章:HTML5 API

尽管语言规范(ECMA-262)每几年变化一次,但新的 HTML5 API 几乎在每次浏览器更新时都会潜入语言中。已经可用的 API 数量相当多。然而,在本章中,我们将重点关注那些重新考虑整个开发过程的 API。我们将学习如何利用 web workers 进行多线程,如何从可重用的独立 web 组件构建应用程序,如何在客户端存储和搜索大量数据,以及如何与服务器建立双向通信。

在本章中,我们将介绍以下主题:

  • 在 web 浏览器中存储数据

  • 使用 JavaScript workers 提高性能

  • 创建我们的第一个 web 组件

  • 学习使用服务器到浏览器通信通道

在 web 浏览器中存储数据

在 HTML5 特性中,有几个是为了在客户端存储数据而设计的:Web 存储、IndexedDB 和 FileSystem API。当以下情况发生时,我们才能从这些技术中受益:

  • 我们希望缓存客户端数据,以便在没有额外 HTTP 请求的情况下进行检索。

  • 在 web 应用程序中,我们有大量的本地数据,我们希望我们的应用程序离线工作

让我们来看看这些技术。

Web 存储 API

过去,我们只有保持应用程序状态的机制,而且它是使用HTTP cookies。除了不友好的 API 之外,cookie 还有几个缺点。它们的最大大小通常约为 4 KB。所以我们根本不能存储任何像样的数据。当在不同标签页中更改应用程序状态时,cookie 并不真正适用。cookie 容易受到跨站脚本攻击

现在我们有一个高级 API,称为Web 存储。它提供了更大的存储容量(取决于浏览器,5-25 MB)并且不会将任何数据附加到 HTTP 请求头中。实现此接口的两个 JavaScript 内置对象是:localStoragesessionStorage。第一个用于持久数据存储,第二个用于会话期间保持数据。

存储 API 非常易于使用,如下所示:

var storage = isPersistent ? localStorage : sessionStorage;
storage.setItem( "foo", "Foo" );
console.log( storage.getItem( "foo" ) );
storage.removeItem( "foo" );

另外,我们可以为了方便使用 getters/setters,如下所示:

storage.foo = "Foo";
console.log( storage.foo );
delete storage.foo;

如果我们想要遍历存储,我们可以使用storage.lengthstorage.key()

var i = 0, len = storage.length, key;
for( ; i < len; i++ ) {
  key = storage.key( i );
  storage.getItem( key );
}

正如你所见,与 cookies 相比,Web 存储 API 对开发者更加友好,也更加强大。最常见的实际例子之一是我们需要存储的情况是购物车。在设计应用程序时,我们必须记住,用户在做出选择时通常会在多个标签页或窗口中打开产品详细页。因此,我们必须照顾到所有打开的页面之间的存储同步。

幸运的是,无论何时我们更新 localStorage,都会在 window 对象上触发 storage 事件。因此,我们可以为这个事件订阅一个处理程序来用实际数据更新购物车。这个例子简单的代码可能看起来像这样:

<html>
  <head>
    <title>Web Storage</title>
  </head>
  <body>
    <div>
      <button data-bind="btn">Add to cart</button>
      <button data-bind="reset">Reset</button>
    </div>
    <output data-bind="output">

    </output>
    <script>

    var output = document.querySelector( "[data-bind=\"output\"]" ),
        btn = document.querySelector( "[data-bind=\"btn\"]" ),
        reset = document.querySelector( "[data-bind=\"reset\"]" ),
        storage = localStorage,
       /**
        * Read from the storage
        * @return {Arrays}
        */
        get = function(){
           // From the storage we receive either JSON string or null
           return JSON.parse( storage.getItem( "cart" ) ) || [];
        },
        /**
         * Append an item to the cart
         * @param {Object} product
         */
        append = function( product ) {
          var data = get();
          data.push( product );
          // WebStorage accepts simple objects, so we pack the object into JSON string         storage.setItem( "cart", JSON.stringify( data ) );
        },
        /** Re-render list of items */
        updateView = function(){
          var data = get();
          output.innerHTML = "";
          data && data.forEach(function( item ){
            output.innerHTML += [ "id: ", item.id, "<br />" ].join( "" );
          });
        };

    this.btn.addEventListener( "click", function(){
      append({ id: Math.floor(( Math.random() * 100 ) + 1 ) });
      updateView();
    }, false );

    this.reset.addEventListener( "click", function(){
      storage.clear();
      updateView();
    }, false );

    // Update item list when a new item is added in another window/tab
    window.addEventListener( "storage", updateView, false );

    updateView();

    </script>
  </body>
</html>

为了看到这个功能实际运行的情况,我们必须在两个或更多标签页中打开代码 HTML。现在,当我们点击加入购物车按钮时,每个标签页都会更新已订购商品的列表。正如您可能注意到的,我们还可以通过点击重置按钮来清理购物车。这会调用storage.clear方法,清空列表。如果您想在这里使用 sessionStorage 而不是 localStorage,我必须警告您这样做是不行的。sessionStorage 对每个标签页或窗口都是隔离的,所以我们不能用这种方法跨它们进行通信。

然而,如果我们能在不同的框架中加载同一窗口中的页面运行这个例子,那么我们本可以使用 sessionStorage 的。下方的截图是一个购物车应用实际运行的示例:

Web Storage API

第五章:索引数据库(IndexedDB)

当我们需要存储相当小的数据量(兆字节)时,Web Storage 表现很好。然而,如果我们需要大量结构化数据,并且我们希望通过索引进行性能搜索,我们将使用 IndexedDB API。在浏览器中存储数据的 API 的想法并不新鲜。几年前,谷歌及其合作伙伴积极推广一个名为Web SQL Database的标准候选。尽管如此,这个规范还是未能通过 W3C 推荐。现在,我们有了 IndexedDB API,它已经得到广泛支持,并提供了显著的性能提升(异步 API 以及由于索引键而强大的搜索功能)。

然而,IndexedDB 的 API 相当复杂。由于大量的嵌套回调,它也很难阅读:

/**
 * @type {IDBOpenDBRequest}
 * Syntax: indexedDB.open( DB name, DB version );
 */
var request = indexedDB.open( "Cem", 2 );

/** Report error */
request.onerror = function() {
  alert( "Opps, something went wrong" );
};
/**
 * Create DB
 * @param {Event} e
 */
request.onupgradeneeded = function ( e ) {
  var objectStore;
  if ( e.oldVersion ) {
    return;
  }
  // define schema
  objectStore = e.currentTarget.result.createObjectStore( "employees", { keyPath: "email" });
  objectStore.createIndex( "name", "name", { unique: false } );
   // Populate objectStore with test data
  objectStore.add({ name: "John Dow", email: "john@company.com" });
  objectStore.add({ name: "Don Dow", email: "don@company.com" });
};
/**
 * Find a row from the DB
 * @param {Event} e
 */
request.onsuccess = function( e ) {
  var db = e.target.result,
      req = db.transaction([ "employees" ]).objectStore( "employees" ).get( "don@company.com" );

  req.onsuccess = function() {
    console.log( "Employee matching `don@company.com` is `" + req.result.name + "`" );
  };
};

在这个示例中,我们创建了一个打开数据库的请求。如果数据库不存在或其版本已更改,将触发upgradeneeded事件。在监听这个事件的函数中,我们可以通过声明对象存储及其索引来定义模式。因此,如果我们需要更新现有数据库的模式,我们可以增加版本号,upgradeneeded将再次触发,监听器将被调用以更新模式。一旦我们定义了模式,我们就可以用示例数据填充对象存储。当打开数据库的请求完成后,我们请求与电子邮件 ID don@company.com匹配的记录。请求完成后,我们进入控制台:

Employee matching 'don@company.com` is `Don Dow'

相当复杂,不是吗?这个 API 让我想到了一个包装器。我所知道最好的一个叫做Dexie (www.dexie.org)。只需比较一下它暴露的接口如何轻松地解决同一个任务:

<script src="img/Dexie.js"></script>
<script>
var db = new Dexie( "Cem" );
// Define DB
db.version( 3 )
  .stores({ employees: "name, email" });

// Open the database
db.open().catch(function( err ){
  alert( "Opps, something went wrong: " + err );
});

// Populate objectStore with test data
db.employees.add({ name: "John Dow", email: "john@company.com" });
db.employees.add({ name: "Don Dow", email: "don@company.com" });

// Find an employee by email
db.employees
  .where( "email" )
  .equals( "don@company.com" )
  .each(function( employee ){
    console.log( "Employee matching `don@company.com` is `" + employee.name + "`" );
  });

</script>

文件系统 API

好吧,在 Web 应用程序中,我们可以使用 Web Storage 存储键值对,我们也可以创建和使用 IndexedDB。还有一件事 missing。桌面应用程序可以读写文件和目录。这是我们经常在能够离线运行的 Web 应用程序中需要的东西。FileSystem API 允许我们在应用程序范围内创建、读取和写入用户的本地文件系统。让我们举一个例子:

window.requestFileSystem  = window.requestFileSystem || window.webkitRequestFileSystem;
    /**
     * Read file from a given FileSystem
     * @param {DOMFileSystem} fs
     * @param {String} file
     */
var readFile = function( fs, file ) {
      console.log( "Reading file " + file );
      // Obtain FileEntry object
      fs.root.getFile( file, {}, function( fileEntry ) {
        fileEntry.file(function( file ){
           // Create FileReader
           var reader = new FileReader();
           reader.onloadend = function() {
             console.log( "Fetched content: ", this.result );
           };
           // Read file
           reader.readAsText( file );
        }, console.error );
      }, console.error );
    },
    /**
     * Save file into a given FileSystem and run onDone when ready
     * @param {DOMFileSystem} fs
     * @param {String} file
     * @param {Function} onDone
     */
    saveFile = function( fs, file, onDone ) {
      console.log( "Writing file " + file );
      // Obtain FileEntry object
      fs.root.getFile( file, { create: true }, function( fileEntry ) {
        // Create a FileWriter object for the FileEntry
        fileEntry.createWriter(function( fileWriter ) {
          var blob;
          fileWriter.onwriteend = onDone;
          fileWriter.onerror = function(e) {
            console.error( "Writing error: " + e.toString() );
          };
          // Create a new Blob out of the text we want into the file.
          blob = new Blob([ "Lorem Ipsum" ], { type: "text/plain" });
          // Write into the file
          fileWriter.write( blob );
        }, console.error );
      }, console.error );
    },
    /**
     * Run when FileSystem initialized
     * @param {DOMFileSystem} fs
     */
    onInitFs = function ( fs ) {
      const FILENAME = "log.txt";
      console.log( "Opening file system: " + fs.name );
      saveFile( fs, FILENAME, function(){
        readFile( fs, FILENAME );
      });
    };

window.requestFileSystem( window.TEMPORARY, 5*1024*1024 /*5MB*/, onInitFs, console.error );

首先,我们请求一个沙盒化的本地文件系统(requestFileSystem),该文件系统对应用程序来说是持久的。通过将 window.TEMPORARY 作为第一个参数传递,我们允许浏览器自动删除数据(例如,当需要更多空间时)。如果我们选择 window.PERSISTENT,我们确定数据在没有明确用户确认的情况下无法清除。第二个参数指定了我们可以为文件系统分配多少空间。然后,还有 onSuccessonError 回调。当创建文件系统时,我们收到一个对 FileSystem 对象的引用。这个对象有一个 fs.root 属性,其中对象保持对根文件系统目录的 DirectoryEntry 绑定。DirectoryEntry 对象有 DirectoryEntry.getDirectoryDirectoryEntry.getFileDirectoryEntry.removeRecursevlyDirectoryEntry.createReader 方法。在前一个示例中,我们在当前(root)目录中写入,所以我们只需使用 DirectoryEntry.getFile 打开一个给定名称的文件。成功打开文件后,我们收到一个代表打开文件的 FileEntry 对象。该对象有几个属性,如:FileEntry.fullPathFileEntry.isDirectoryFileEntry.isFileFileEntry.name,以及方法如 FileEntry.fileFileEntry.createWriter。第一个方法返回一个 File 对象,该对象可用于读取文件内容,第二个用于写入文件。当操作完成时,我们从文件中读取。为此,我们创建一个 FileReader 对象,并让它读取我们的 File 对象作为文本。

使用 JavaScript workers 提高性能

JavaScript 是单线程环境。所以,多个脚本实际上并不能真的同时运行。是的,我们使用 setTimeout()setInterval()XMLHttpRequest 以及事件处理程序来异步运行任务。因此我们获得了非阻塞执行,但这并不意味着并发。然而,通过使用 web workers,我们可以在与 UI 脚本无关的后台独立运行一个或多个脚本。Web workers 是长期运行的脚本,不会被阻塞的 UI 事件中断。Web workers 利用多线程,因此我们可以从多核 CPU 中受益。

那么,我们可以在哪些地方使用 web workers 呢?任何我们需要进行处理器密集型计算而不希望它们阻塞 UI 线程的地方。这可以是图形、网络游戏、加密和 Web I/O。我们从 web worker 直接操作 DOM 是不可能的,但我们有访问 XMLHttpRequest、Web Storage、IndexedDB、FileSystem API、Web Sockets 等特性的权限。

那么,让我们来看看实践中这些 web workers 是什么。总的来说,我们在主脚本中注册一个现有的 web worker 并通过 PostMessage API 与 web worker 进行通信(developer.mozilla.org/en-US/docs/Web/API/Window/postMessage):

index.html
<html>
  <body>
<script>
"use strict";
// Register worker
var worker = new Worker( "./foo-worker.js" );
// Subscribe for worker messages
worker.addEventListener( "message", function( e ) {
  console.log( "Result: ", e.data );
}, false );
console.log( "Starting the task..." );
// Send a message to worker
worker.postMessage({
  command: "loadCpu",
  value: 2000
});
</script>
  </body>
</html>
foo-worker.js
"use strict";
var commands = {
  /**
   * Emulate resource-consuming operation
   * @param {Number} delay in ms
   */
  loadCpu: function( delay ) {
    var start = Date.now();
    while (( Date.now() - start ) < delay );
    return "done";
  }
};
// Workers don't have access to the window object. // To access global object we have to use self object instead.
self.addEventListener( "message", function( e ) {
  var command;
  if ( commands.hasOwnProperty( e.data.command ) ) {
    command = commands[ e.data.command ];
    return self.postMessage( command( e.data.value ) );
  }
  self.postMessage( "Error: Command not found" );

}, false );

在这里的index.html中,我们请求网络工作者(foo-worker.js)订阅工作者消息,并要求它加载 CPU 2,000 毫秒,这代表了一个消耗资源的进程。工作者接收到消息并检查command属性中指定的函数。如果存在,工作者会将消息值传递给函数,并返回返回值。

请注意,尽管通过启动index.html启动了如此昂贵的进程,主线程仍然是非阻塞的。然而,当进程完成后,它还是会向控制台报告。但是,如果你尝试在主脚本内运行loadCpu函数,UI 将会冻结,很可能会导致脚本超时错误。现在考虑这个:如果你异步调用loadCpu(例如,使用setTimeout),UI 仍然会挂起。处理 CPU 敏感操作的唯一安全方法是将它们交给网络工作者。

网络工作者可以是专用的,也可以是共享的。专用的网络工作者只能通过一个脚本访问,该脚本是我们调用工作者的地方。共享工作者可以从多个脚本中访问,甚至包括在不同窗口中运行的脚本。这使得这个 API 有些不同:

index.html

<script>
"use strict";
var worker = new SharedWorker( "bar-worker.js" );
worker.port.onmessage = function( e ) {
  console.log( "Worker echoes: ", e.data );
};
worker.onerror = function( e ){
  console.error( "Error:", e.message );
};
worker.port.postMessage( "Hello worker" );
</script>
bar-worker.js
"use strict";
onconnect = function( e ) {
  var port = e.ports[ 0 ];
  port.onmessage = function( e ) {
    port.postMessage( e.data );
  };
  port.start();
};

前面的例子中的工作线程只是简单地回显了接收到的消息。如果工作线程进行了有效的计算,我们就可以从不同页面上的不同脚本中指挥它。

这些例子展示了并发计算中网络工作者的使用。那么,将一些网络 I/O 操作从主线程中卸载又会怎样呢?例如,我们被要求将特定的 UI 事件报告给远程商业智能服务器(在这里BI 服务器用于接收统计数据)。这不是核心功能,因此最好是将这些请求产生的任何负载都保持在主线程之外。因此,我们可以使用一个网络工作者。然而,工作者只有在加载后才可用。通常,这非常快,但我还是想确保由于工作者不可用而没有丢失任何 BI 事件。我可以做的是将网络工作者代码嵌入 HTML 中,并通过数据 URI 注册网络工作者:

<script data-bind="biTracker" type="text/js-worker">
  "use strict";

  // Here shall go you BI endpoint
  const REST_METHOD = "http://www.telize.com/jsonip";
  /**
   * @param {Map} data - BI request params
   * @param {Function} resolve
   */
  var call = function( data, resolve ) {
    var xhr = new XMLHttpRequest(),
        params = data ? Object.keys( data ).map(function( key ){
            return key + "=" + encodeURIComponent( data[ key ] );
          }).join( "&" ) : "";

    xhr.open( "POST", REST_METHOD, true );
    xhr.addEventListener( "load", function() {
        if ( this.status >= 200 && this.status < 400 ) {
          return resolve( this.response );
        }
        console.error( "BI tracker - bad request " + this.status );
      }, false );
    xhr.addEventListener( "error", console.error, false );
    xhr.responseType = "json";
    xhr.setRequestHeader( "Content-Type", "application/x-www-form-urlencoded" );
    xhr.send( params );
  };
  /**
   * Subscribe to window.onmessage event
   */
  onmessage = function ( e ) {
    call( e.data, function( data ){
      // respond back
      postMessage( data );
    })
  };
</script>

<script type="text/javascript">
  "use strict";
  window.biTracker = (function(){
    var blob = new Blob([ document.querySelector( "[data-bind=\"biTracker\"]" ).textContent ], {
          type: "text/javascript"
        }),
        worker = new Worker( window.URL.createObjectURL( blob ) );

    worker.onmessage = function ( oEvent ) {
      console.info( "Bi-Tracker responds: ", oEvent.data );
    };
    return worker;
  }());
  // Let's test it
  window.biTracker.postMessage({ page: "#main" });
</script>

通过将网络 I/O 交给工作者,我们还可以对其进行额外的控制。例如,在网络状态发生变化时(ononlineonoffline事件,以及工作者可以访问的navigator.online属性),我们可以要么返回实际的调用结果,要么返回缓存的结果。换句话说,我们可以使我们的应用程序离线工作。实际上,还有特殊类型的 JavaScript 工作者,称为服务工作者。服务工作者继承自共享工作者,充当网页应用程序和网络之间的代理(developer.mozilla.org/en-US/docs/Mozilla/Projects/Social_API/Service_worker_API_reference)。

创建第一个网络组件

你可能熟悉 HTML5 视频元素(www.w3.org/TR/html5/embedded-content-0.html#the-video-element).通过在 HTML 中放置一个元素,你将得到一个运行视频的小工具。这个元素接受多个属性来设置播放器。如果你想要增强这个功能,你可以使用它的公共 API 并在其事件上订阅监听器(www.w3.org/2010/05/video/mediaevents.html).因此,每当我们需要播放器时,我们都会重用这个元素,并且只针对与项目相关的外观和感觉进行自定义。如果每次我们都需要页面上的小工具时,都有足够多的这样的元素就好了。然而,这并不是在 HTML 规范中包含我们可能需要的任何小工具的正确方法。然而,创建自定义元素的 API,比如视频,已经存在。我们确实可以定义一个元素,打包化合物(JavaScript,HTML,CSS,图片等),然后只需从消费 HTML 中链接它。换句话说,我们可以创建一个独立且可重用的 Web 组件,然后通过在 HTML 中放置相应的自定义元素(<my-widget />)来使用它。我们可以重新样式化该元素,如果需要,我们可以利用元素 API 和事件。例如,如果你需要一个日期选择器,你可以取一个现有的 Web 组件,比如说在component.kitchen/components/x-tag/datepicker可用的那个。我们只需要下载组件源(例如,使用浏览器包管理器)并在我们的 HTML 代码中链接到该组件:

<link rel="import" href="bower_components/x-tag-datepicker/src/datepicker.js"> 

在 HTML 代码中声明组件:

<x-datepicker name="2012-02-02"></x-datepicker>

这应该在最新版本的 Chrome 中顺利运行,但在其他浏览器中可能不会工作。运行 Web 组件需要在客户端浏览器中解锁多项新技术,如自定义元素HTML 导入Shadow DOM和模板。模板包括我们在第一章中研究的 JavaScript 模板(Diving into JavaScript core)。自定义元素 API 允许我们定义新的 HTML 元素、它们的行为和属性。Shadow DOM 封装了一个由自定义元素所需的 DOM 子树。而 HTML 导入的支持意味着通过给定的链接,用户代理通过在页面上包含其 HTML 来启用 Web 组件。我们可以使用 polyfill(webcomponents.org/)确保所有主要浏览器都支持所需的技术:

<script src="img/webcomponents.min.js"></script>

你想写自己的 Web 组件吗?我们一起做。我们的组件类似于 HTML 的details/summary。当点击summary时,详细信息显示出来。因此,我们创建x-details.html,在其中我们放置组件样式和 JavaScript 以及组件 API:

x-details.html

<style>
  .x-details-summary {
    font-weight: bold;
    cursor: pointer;
  }
  .x-details-details {
    transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out;
    transform-origin: top left;
  }
  .x-details-hidden {
    opacity: 0;
    transform: scaleY(0);
  }
</style>
<script>
"use strict";
    /**
     * Object constructor representing x-details element
     * @param {Node} el
     */
var DetailsView = function( el ){
      this.el = el;
      this.initialize();
    },
    // Creates an object based in the HTML Element prototype
    element = Object.create( HTMLElement.prototype );
/** @lend DetailsView.prototype */
Object.assign( DetailsView.prototype, {
  /**
   * @constracts DetailsView
   */
  initialize: function(){
    this.summary = this.renderSummary();
    this.details = this.renderDetails();
    this.summary.addEventListener( "click", this.onClick.bind( this ), false );
    this.el.textContent = "";
    this.el.appendChild( this.summary );
    this.el.appendChild( this.details );
  },
  /**
   * Render summary element
   */
  renderSummary: function(){
    var div = document.createElement( "a" );
    div.className = "x-details-summary";
    div.textContent = this.el.dataset.summary;
    return div;
  },
  /**
   * Render details element
   */
  renderDetails: function(){
    var div = document.createElement( "div" );
    div.className = "x-details-details x-details-hidden";
    div.textContent = this.el.textContent;
    return div;
  },
  /**
   * Handle summary on click
   * @param {Event} e
   */
  onClick: function( e ){
    e.preventDefault();
    if ( this.details.classList.contains( "x-details-hidden" ) ) {
      return this.open();
    }
    this.close();
  },
  /**
   * Open details
   */
  open: function(){
    this.details.classList.toggle( "x-details-hidden", false );
  },
  /**
   * Close details
   */
  close: function(){
    this.details.classList.toggle( "x-details-hidden", true );
  }
});

// Fires when an instance of the element is created
element.createdCallback = function() {
  this.detailsView = new DetailsView( this );
};
// Expose method open
element.open = function(){
  this.detailsView.open();
};
// Expose method close
element.close = function(){
  this.detailsView.close();
};
// Register the custom element
document.registerElement( "x-details", {
  prototype: element
});
</script>

在 JavaScript 代码的进一步部分,我们基于一个通用 HTML 元素(Object.create( HTMLElement.prototype ))创建了一个元素。如果需要,我们这里可以继承一个复杂元素(例如,视频)。我们使用前面创建的作为原型的元素注册了一个x-details自定义元素。通过element.createdCallback,我们在自定义元素创建时订阅了一个处理程序。在这里,我们将我们的视图附加到元素上,以通过为其提供我们打算的功能来增强它。现在我们可以在 HTML 中使用该组件,如下所示:

<!DOCTYPE html>
<html>
  <head>
    <title>X-DETAILS</title>
    <!-- Importing Web Component's Polyfill -->
    <!-- uncomment for non-Chrome browsers
    script src="img/webcomponents.min.js"></script-->
    <!-- Importing Custom Elements -->
 <link rel="import" href="./x-details.html">
  </head>
  <body>
    <x-details data-summary="Click me">
      Nunc iaculis ac erat eu porttitor. Curabitur facilisis ligula et urna egestas mollis. Aliquam eget consequat tellus. Sed ullamcorper ante est. In tortor lectus, ultrices vel ipsum eget, ultricies facilisis nisl. Suspendisse porttitor blandit arcu et imperdiet.
    </x-details>
  </body>
</html>

下面屏幕截图展示了 X-details web-组件在行动中的情况:

创建第一个 web 组件

学习使用服务器到浏览器的通信通道

使用 XHR 或 Fetch API,我们可以从服务器请求一个状态。这是一条单向通信。如果我们想要实时通信,我们同样也需要反方向也这样做。例如,我们可能希望在数据库中相应记录发生变化时,用户通知(你的帖子被点赞了,新评论,或者新私信)能够立即弹出。服务器端有连接到数据库,所以期望服务器能通知客户端。在过去,要在客户端接收这些事件,我们使用了被称为COMET(隐藏 iframe,长轮询,标签长轮询等)的技巧。现在我们可以使用原生的 JavaScript API。

服务器发送事件

提供了一种订阅服务器端事件的技术是服务器发送事件SSE)API。在客户端,我们注册一个服务器流(EventSource)并订阅来自它的事件:

var src = new EventSource( "./sse-server.php" );

src.addEventListener( "open", function() {
   console.log( "Connection opened" );
}, false);

src.addEventListener( "error", function( e ) {
  if ( e.readyState === EventSource.CLOSED ) {
    console.error( "Connection closed" );
  }
}, false );

src.addEventListener( "foo", function( e ) {
  var data = JSON.parse( e.data );
  console.log( "Received from the server:", data );
}, false);

在这里,我们为特定事件"foo"订阅了一个监听器。如果你想让回调在每次服务器事件上被调用,只需使用src.onmessage。至于服务器端,我们只需要设置 MIME 类型text/event-stream,并发送由换行符成对分隔的事件负载块:

event: foo\n
data: { time: "date" }\n\n

SSE 通过 HTTP 连接工作,因此我们需要一个 Web 服务器来创建一个流。PHP 要简单得多,并且是一个广泛使用的服务器端语言。很可能你已经熟悉其语法。另一方面,PHP 并不适合持久连接的长久维持。然而,我们可以通过声明一个循环让我们的 PHP 脚本永不结束来欺骗它:

<?PHP
set_time_limit( 0 );
header("Content-Type: text/event-stream");
header("Cache-Control: no-cache");
date_default_timezone_set("Europe/Berlin");

function postMessage($event, $data){
  echo "event: {$event}", PHP_EOL;
  echo "data: ", json_encode($data, true), PHP_EOL, PHP_EOL;
  ob_end_flush();
  flush();
}
while (true) {
  postMessage("foo", array("time" => date("r")) );
  sleep(1);
}

你可能看到过 SSE 示例,其中服务器脚本一次性输出数据并终止进程(例如,www.html5rocks.com/en/tutorials/eventsource/basics/)。那也是一个工作示例,因为每次服务器通过服务器终止连接时,浏览器都会重新建立连接。然而,这种方法并没有 SSE 的任何好处,它像轮询一样工作。

现在一切看起来都准备好了,所以我们可以运行 HTML 代码。这样做时,我们在控制台得到以下输出:

Connection opened
Received from the server: Object { time="Tue, 25 Aug 2015 10:31:54 +0200"}
Received from the server: Object { time="Tue, 25 Aug 2015 10:31:55 +0200"}
Received from the server: Object { time="Tue, 25 Aug 2015 10:31:56 +0200"}
Received from the server: Object { time="Tue, 25 Aug 2015 10:31:57 +0200"}
Received from the server: Object { time="Tue, 25 Aug 2015 10:31:58 +0200"}
Received from the server: Object { time="Tue, 25 Aug 2015 10:31:59 +0200"}
Received from the server: Object { time="Tue, 25 Aug 2015 10:32:00 +0200"}
Received from the server: Object { time="Tue, 25 Aug 2015 10:32:01 +0200"}
Received from the server: Object { time="Tue, 25 Aug 2015 10:32:02 +0200"}
...

Web Sockets

好吧,使用 XHR/Fetch 我们从客户端到服务器进行通信。使用 SSE,我们这样做是反向的。但是我们可以同时进行双向通信吗?另一个 HTML5 好东西叫做 Web Sockets,它提供了双向、全双工的客户端服务器通信。

客户端看起来类似于 SSE。我们只需注册 WebSocket 服务器,订阅其事件,并向其发送我们的事件:

var rtm = new WebSocket("ws://echo.websocket.org");
rtm.onopen = function(){
  console.log( "Connection established" );
  rtm.send("hello");
};
rtm.onclose = function(){
  console.log( "Connection closed" );
};
rtm.onmessage = function( e ){
  console.log( "Received:", e.data );
};
rtm.onerror = function( e ){
  console.error( "Error: " + e.message );
};

这个在ws://echo.websocket.org的演示源简单地回显发送给它的任何消息:

Connection established
Received: hello

需要更实际的东西吗?我相信最说明问题的例子将是一个聊天室:

demo.html

<style>
  input {
    border-radius: 5px;
    display: block;
    font-size: 14px;
    border: 1px solid grey;
    margin: 3px 0;
  }
  button {
    border-radius: 5px;
    font-size: 14px;
    background: #189ac4;
    color: white;
    border: none;
    padding: 3px 14px;
  }
</style>

<form data-bind="chat">
  <input data-bind="whoami" placeholder="Enter your name">
  <input data-bind="text" placeholder="Enter your msg" />
  <button type="submit">Send</button>
</form>
<h3>Chat:</h3>
<output data-bind="output">
</output>
<script>

var whoami = document.querySelector( "[data-bind=\"whoami\"]" ),
    text = document.querySelector( "[data-bind=\"text\"]" ),
    chat = document.querySelector( "[data-bind=\"chat\"]" ),
    output = document.querySelector( "[data-bind=\"output\"]" ),
    // create ws connection
    rtm = new WebSocket("ws://localhost:8001");

rtm.onmessage = function( e ){
  var data = JSON.parse( e.data );
  output.innerHTML += data.whoami + " says: " + data.text + "<br />";
};
rtm.onerror = function( e ){
  console.error( "Error: " + e.message );
};

chat.addEventListener( "submit", function( e ){
  e.preventDefault();
  if ( !whoami.value ) {
    return alert( "You have enter your name" );
  }
  if ( !text.value ) {
    return alert( "You have enter some text" );
  }
  rtm.send(JSON.stringify({
    whoami: whoami.value,
    text: text.value
  }));
});

</script>

这里有一个带有两个输入字段的表单。第一个期望输入一个人的名字,第二个是聊天信息。当表单提交时,将两个输入字段的值发送到 WebSocket 服务器。服务器的响应显示在输出元素中。与 SSE 不同,WebSocket 需要特殊的协议和服务器实现才能工作。为了运行示例,我们将使用一个简单的基于 nodejs 的服务器实现,nodejs-websocketgithub.com/sitegui/nodejs-websocket):

ws.js

    /** @type {module:nodejs-websocket} */
var ws = require( "nodejs-websocket" ),
    /** @type {Server} */
    server = ws.createServer(function( conn ) {
        conn.on( "text", function ( str ) {
          console.log( "Received " + str );
          broadcast( str );
        });
    }).listen( 8001 ),
    /**
     * Broadcast message
     * @param {String} msg
     */
    broadcast = function ( msg ) {
      server.connections.forEach(function ( conn ) {
        conn.sendText( msg );
      });
    };

脚本创建了一个在端口 8001 上监听 WebSocket 消息的服务器,当接收到任何消息时,端口将其广播给所有可用的连接。我们可以这样启动服务器:

node ws.js

现在我们在两个不同的浏览器中打开我们的聊天室演示。当我们从一个浏览器中发送消息时,消息会在两个浏览器中显示出来。下面的截图显示了在 Firefox 中的 WebSocket 驱动的聊天:

Web Sockets

下面的截图显示了在 Chrome 中的 WebSocket 驱动的聊天:

Web Sockets

注意客户端对事件反应有多快。通过套接字进行的通信具有无可争辩的优势。

有许多针对不同语言的 WebSocket 服务器实现,例如,Socket.IO(socket.io)用于 Node.js,Jetty(www.eclipse.org/jetty)用于 Java,Faye(faye.jcoglan.com)用于 Ruby,Tornado(www.tornadoweb.org)用于 Python,甚至还有一个名为 Ratchet 的 PHP 实现(socketo.me)。然而,我想向您介绍一个与语言无关的 WebSocket 守护进程——Websocketd(websocketd.com/)。它就像公共网关接口CGI),但是用于 Web Sockets。所以您可以使用您喜欢的语言编写服务器登录脚本,然后将脚本附加到守护进程:

websocketd --port=8001 my-script

总结

HTML5 提供了一些很棒的 API,我们刚才检查了一些。在浏览器存储 API 中,有 localStorage 和 sessionStorage,它们扩展了 cookie 遗留问题。两者都 capable of storing megabytes of data and can be easily synchronized across different browser windows/tabs. IndexedDB 允许我们存储更多的数据,并提供了一个用于使用索引的高性能搜索的接口。我们还可以使用 FileSystem API 来创建和操作与网络应用程序绑定的本地文件系统。

虽然 JavaScript 是一个单线程环境,我们仍然可以在多个线程中运行脚本。我们可以注册专用或共享的 Web Workers,并将任何耗处理器操作交给它们,从而不会影响主线程和 UI。我们还可以利用一种特殊的 JavaScript 工作者---服务工作者---作为网络应用程序和网络之间的代理。这可以在浏览器在线/离线模式之间切换时控制网络 I/O。

现在我们可以创建自己的自定义高级元素,这些元素可以轻松地被重复使用、重新设计并增强。渲染此类元素所需的资源包括 HTML、CSS、JavaScript 和图片,它们被作为 Web 组件捆绑在一起。因此,我们实际上可以从类似建筑物的组件开始构建网页。

在过去,我们使用被称为 COMET 的技巧来在服务器和客户端之间交换事件。现在我们可以使用 SSE API 来订阅通过 HTTP 发送的服务器事件。我们还可以使用 Web Sockets 进行双向、全双工的客户端-服务器通信。

第五章:异步 JavaScript

如今,互联网用户变得没有耐心,页面加载或导航过程中的 2-3 秒延迟,他们就会失去兴趣,并且可能会离开服务,转而使用其他东西。我们最高优先级的是减少用户响应时间。这里的主要方法被称为芥末切割www.creativebloq.com/web-design/responsive-web-design-tips-bbc-news-9134667)。我们提取应用程序的核心体验所需的组件并首先加载它们。然后,我们逐步添加增强的体验。至于 JavaScript,我们需要最关心的是非阻塞流程。因此,我们必须避免在 HTML 渲染之前同步加载脚本,并将所有长时间运行的任务包装到异步回调中。这可能是你已经知道的事情。但你是高效地这样做吗?

在本章中,我们将介绍以下主题:

  • 非阻塞 JavaScript

  • 错误优先回调

  • 延续传递风格

  • 使用 ES7 方式处理异步函数

  • 使用 Async.js 库进行并行任务和任务系列

  • 事件处理优化

非阻塞 JavaScript

首先,让我们看看当我们异步做事情时实际发生的情况。无论何时在 JavaScript 中调用一个函数,它都会创建一个新的栈帧(执行对象)。每个内部调用都会进入这个帧。这里帧是从调用堆栈的顶部以LIFO后进先出)的方式推入和弹出。换句话说,在代码中,我们调用foo函数,然后调用bar函数;然而,在执行过程中,foo调用baz函数。在这种情况下,在call堆栈中,我们有以下顺序:foobaz,然后才是bar。所以bar是在foo的栈帧清空后才被调用。如果任何一个函数执行一个 CPU 密集型任务,所有后续的调用都会等待它完成。然而,JavaScript 引擎具有事件队列(或任务队列)。

非阻塞 JavaScript

如果我们为 DOM 事件订阅一个函数,或者将一个回调传递给定时器(setTimeoutsetInterval)或任何 Web I/O API(XHR、IndexedDB 和 FileSystem),它最终都会进入相应的队列。然后,浏览器的事件循环决定何时将哪个回调推入回调堆栈。以下是一个例子:

function foo(){
  console.log( "Calling Foo" );
}
function bar(){
  console.log( "Calling Bar" );
}
setTimeout(foo, 0 );
bar();

使用setTimeout( foo, 0 ),我们声明foo应立即被调用,然后我们调用bar。然而,foo进入一个队列,事件循环将其推入调用堆栈的更深位置:

Calling Bar
Calling Foo

这也意味着如果foo回调执行一个 CPU 密集型任务,它不会阻塞主线程的执行流程。同样,异步发起的 XHR/Fetch 请求在等待服务器响应时不会锁定交互:

function bar(){
  console.log( "Bar complete" );
}
fetch( "http://www.telize.com/jsonip" ).then(function( response ) {
  console.log( "Fetch complete" );
});
bar();

// Console:
// Bar complete
// Fetch complete

这如何适用于实际应用?以下是一个常见的流程:

"use strict";
// This statement loads imaginary AMD modules
// You can find details about AMD standard in 
// "Chapter 2: Modular programming with JavaScript" 
require([ "news", "Session", "User", "Ui" ], function ( News, Session, User, Ui ) {
  var session = new Session(),
      news = new News(),
      ui = new Ui({ el: document.querySelector( "[data-bind=ui]" ) });
  // load news
 news.load( ui.update );
 //  authorize user 
 session.authorize(function( token ){
   var user = new User( token );
   // load user data
   user.load(function(){
     ui.update();
     // load user profile picture
     user.loadProfilePicture( ui.update );
     // load user notifications  
     user.loadNotifications( ui.update );
   });
 });
});

JavaScript 依赖的加载是排队进行的,所以浏览器可以在不等待加载完成的情况下渲染并把 UI 交付给用户。一旦脚本完全加载,应用程序就会把两个新任务推入队列:加载新闻认证用户。再次强调,它们都不会阻塞主线程。只有在这些请求之一完成并涉及到主线程时,它才会根据新接收的数据增强 UI。一旦用户被认证并且会话令牌被检索到,我们可以加载用户数据。任务完成后,我们又会排队新的任务。

正如你所见,与同步代码相比,异步代码更难阅读。执行序列可能会相当复杂。此外,我们还需要特别注意错误控制。当处理同步代码时,我们可以用try/catch包围程序的一块,拦截执行期间抛出的任何错误:

function foo(){
  throw new Error( "Foo throws an error" );
}
try {
  foo();
} catch( err ) {
  console.log( "The error is caught" );
}

然而,如果调用被排队,它就会滑出try/catch的作用域:

function foo(){
  throw new Error( "Foo throws an error" );
}
try {
  setTimeout(foo, 0 );
} catch( err ) {
  console.log( "The error is caught" );
}

是的,异步编程有其怪癖。为了掌握这一点,我们将检查编写异步代码的现有实践。

因此,为了使代码异步,我们排队一个任务并订阅一个事件,当任务完成时触发该事件。实际上,我们采用的是事件驱动编程,特别是,我们应用了一个发布/订阅模式。例如,我们在第三章中提到的EventTarget接口,DOM 脚本和 AJAX,简而言之,就是关于为 DOM 元素的事件订阅监听器,并从 UI 或以编程方式触发这些事件:

var el = document.createElement( "div" );
    event = new CustomEvent( "foo", { detail: "foo data" });
el.addEventListener( "foo", function( e ){
  console.log( "Foo event captured: ", e.detail );
}, false );

el.dispatchEvent( event );

// Foo event captured: foo data

在 DOM 背后,我们使用了一个类似的原理,但实现可能会有所不同。最流行的接口可能是基于两个主要方法obj.on(用于订阅处理程序)和obj.trigger(用于触发事件):

obj.on( "foo", function( data ){
  console.log( "Foo event captured: ", data );
});
obj.trigger( "foo", "foo data" );

这是在抽象框架中实现发布/订阅的方式,例如,Backbone。jQuery 在 DOM 事件上也使用这个接口。这个接口因其简单性而获得了势头,但它实际上并不能帮助处理意大利面条代码,也没有涵盖错误处理。

错误优先的回调

在 Node.js 中所有异步方法使用的模式被称为错误优先的回调。以下是一个例子:

fs.readFile( "foo.txt", function ( err, data ) {
  if ( err ) {
    console.error( err );
  }
  console.log( data );
});

任何异步方法都期望有一个回调函数作为参数。完整的回调参数列表取决于调用方法,但第一个参数总是错误对象或 null。当我们使用异步方法时,函数执行期间抛出的异常不能在try/catch语句中检测到。事件发生在 JavaScript 引擎离开try块之后。在前面的例子中,如果在读取文件时抛出任何异常,它作为第一个和必需的参数落在回调函数上。尽管它的使用很普遍,但这种方法有其缺陷。在编写具有深层回调序列的实际代码时,很容易遇到所谓的回调地狱callbackhell.com/)。代码变得相当难以跟踪。

继续传递风格

我们经常需要一个异步调用的链,即一个任务在另一个任务完成后开始的任务序列。我们感兴趣的是异步调用链的最终结果。在这种情况下,我们可以从继续传递风格CPS)中受益。JavaScript 已经有了内置的Promise对象。我们用它来创建一个新的Promise对象。我们把异步任务放在Promise回调中,并调用参数列表的resolve函数,以通知Promise回调任务已解决:

"use strict";
    /**
     * Increment a given value
     * @param {Number} val
     * @returns {Promise}
     */
var foo = function( val ) {
      /**
       * Return a promise.
       * @param {Function} resolve
       */
      return new Promise(function( resolve ) {
        setTimeout(function(){
          resolve( val + 1 );
        }, 0 );
      });
    };

foo( 1 ).then(function( val ){
  console.log( "Result: ", val );
});

// Result: 5

在前面的例子中,我们调用foo,它返回Promise。使用这种方法,我们设置了一个处理器,当Promise被满足时调用。

那么关于错误控制呢?在创建Promise时,我们可以使用第二个参数(reject)中的函数来报告失败:

"use strict";
/**
 * Make GET request
 * @param {String} url
 * @returns {Promise}
 */
function ajaxGet( url ) {
  return new Promise(function( resolve, reject ) {
    var req = new XMLHttpRequest();
    req.open( "GET", url );
    req.onload = function() {
      // If response status isn't 200 something went wrong
      if ( req.status !== 200 ) {
        // Early exit
        return reject( new Error( req.statusText ) );
      }
      // Everything is ok, we can resolve the promise
      return resolve( JSON.parse( req.responseText ) );
    };
    // On network errors
    req.onerror = function() {
      reject( new Error( "Network Error" ) );
    };
    // Make the request
    req.send();
  });
};

ajaxGet("http://www.telize.com/jsonip").then(function( data ){
  console.log( "Your IP is ", data.ip );
}).catch(function( err ){
  console.error( err );
});
// Your IP is 127.0.0.1

关于Promises最令人兴奋的部分是它们可以被链式调用。我们可以把回调函数排队作为异步任务,或者进行值转换:

"use strict";
    /**
     * Increment a given value
     * @param {Number} val
     * @returns {Promise}
     */
var foo = function( val ) {
      /**
       * Return a promise.
       * @param {Function} resolve
       * @param {Function} reject
       */
      return new Promise(function( resolve, reject ) {
        if ( !val ) {
          return reject( new RangeError( "Value must be greater than zero" ) );
        }
        setTimeout(function(){
          resolve( val + 1 );
        }, 0 );
      });
    };

foo( 1 ).then(function( val ){
  // chaining async call
  return foo( val );
}).then(function( val ){
  // transforming output
  return val + 2;
}).then(function( val ){
  console.log( "Result: ", val );
}).catch(function( err ){
  console.error( "Error caught: ", err.message );
});

// Result: 5

注意,如果我们把0传给foo函数,入口条件会抛出一个异常,我们最终会进入catch方法的回调。如果在回调中抛出异常,它也会在catch回调中出现。

Promise链以类似于瀑布模型的方式解决——任务一个接一个地调用。我们也可以让Promise在几个并行处理任务完成后解决:

"use strict";
    /**
     * Increment a given value
     * @param {Number} val
     * @returns {Promise}
     */
var foo = function( val ) {
      return new Promise(function( resolve ) {
        setTimeout(function(){
          resolve( val + 1 );
        }, 100 );
      });
    },
    /**
     * Increment a given value
     * @param {Number} val
     * @returns {Promise}
     */
    bar = function( val ) {
      return new Promise(function( resolve ) {
        setTimeout(function(){
          resolve( val + 2 );
        }, 200 );
      });
    };

Promise.all([ foo( 1 ), bar( 2 ) ]).then(function( arr ){
  console.log( arr );
});
//  [2, 4]

Promise.all静态方法在所有最新浏览器中还得不到支持,但你可以通过github.com/jakearchibald/es6-promise的 polyfill 来获得。

另一种可能性是让Promise在任何一个并发运行的任务完成时解决或拒绝:

Promise.race([ foo( 1 ), bar( 2 ) ]).then(function( arr ){
  console.log( arr );
});
// 2

用 ES7 的方式处理异步函数

我们已经在 JavaScript 中有了 Promise API。即将到来的技术是 Async/Await API,它出现在 EcmaScript 第七版的提案中(tc39.github.io/ecmascript-asyncawait/)。这描述了我们如何可以声明非阻塞的异步函数并等待Promise的结果:

"use strict";

// Fetch a random joke
function fetchQuote() {
  return fetch( "http://api.icndb.com/jokes/random" )
  .then(function( resp ){
    return resp.json();
  }).then(function( data ){
    return data.value.joke;
  });
}
// Report either a fetched joke or error
async function sayJoke()
{
  try {
    let result = await fetchQuote();
    console.log( "Joke:", result );
  } catch( err ) {
    console.error( err );
  }
}
sayJoke();

目前,API 在任何一个浏览器中都不受支持;然而,你可以在运行时使用 Babel.js 转换器来运行它。你也可以在线尝试这个例子:codepen.io/dsheiko/pen/gaeqRO

这种新语法允许我们编写看起来是同步运行的异步代码。因此,我们可以使用诸如try/catch之类的常见构造来进行异步调用,这使得代码更加可读,更容易维护。

使用 Async.js 库的并行任务和任务系列

处理异步调用的另一种方法是一个名为Async.js的库(github.com/caolan/async)。使用这个库时,我们可以明确指定我们想要任务批次如何解析—作为瀑布(链)或并行。

在第一种情况下,我们可以向async.waterfall提供回调数组,假设当一个完成后,下一个会被调用。我们还可以将一个回调中解析的值传递给另一个,并在方法的on-done回调中接收累积值或抛出的异常:

/**
 * Concat given arguments
 * @returns {String}
 */
function concat(){
  var args = [].slice.call( arguments );
  return args.join( "," );
}

async.waterfall([
    function( cb ){
      setTimeout( function(){
        cb( null, concat( "foo" ) );
      }, 10 );
    },
    function( arg1, cb ){
      setTimeout( function(){
        cb( null, concat( arg1, "bar" ) );
      }, 0 );
    },
    function( arg1, cb ){
      setTimeout( function(){
        cb( null, concat( arg1, "baz" ) );
      }, 20 );
    }
], function( err, results ){
   if ( err ) {
     return console.error( err );
   }
   console.log( "All done:", results );
});

// All done: foo,bar,baz

同样,我们将回调数组传递给async.parallel。这次,它们全部并行运行,但当它们都解决时,我们在方法的on-done回调中接收结果或抛出的异常:

async.parallel([
    function( cb ){
      setTimeout( function(){
        console.log( "foo is complete" );
        cb( null, "foo" );
      }, 10 );
    },
    function( cb ){
      setTimeout( function(){
        console.log( "bar is complete" );
        cb( null, "bar" );
      }, 0 );
    },
    function( cb ){
      setTimeout( function(){
        console.log( "baz is complete" );
        cb( null, "baz" );
      }, 20 );
    }
], function( err, results ){
   if ( err ) {
     return console.error( err );
   }
   console.log( "All done:", results );
});

// bar is complete
// foo is complete
// baz is complete
// All done: [ 'foo', 'bar', 'baz' ]

当然,我们可以组合这些流程。此外,该库还提供了迭代方法,如mapfiltereach,适用于异步任务的数组。

Async.js 是这种类型的第一个项目。今天,有许多受此启发的库。如果你想要一个轻量级且健壮的与 Async.js 类似的解决方案,我建议你查看一下 Contra (github.com/bevacqua/contra)。

事件处理优化

编写内联表单验证器时,你可能会遇到一个问题。当你输入时,user-agent会不断向服务器发送验证请求。这样你可能会很快就会通过产生 XHR 来污染网络。另一个你可能熟悉的问题是一些 UI 事件(touchmovemousemovescrollresize)会频繁触发,订阅的事件处理程序可能会使主线程过载。这些问题可以通过两种已知的方法来解决,称为去抖节流。这两个函数都可以在第三方库(如 Underscore 和 Lodash)中找到(_.debounce_.throttle)。然而,它们可以用一点o代码实现,不需要依赖额外的库来实现这个功能。

去抖

通过去抖,我们确保在重复触发的事件中,处理函数只被调用一次:

  /**
   * Invoke a given callback only after this function stops being called `wait` milliseconds
   * usage:
   * debounce( cb, 500 )( ..arg );
   *
   * @param {Function} cb
   * @param {Number} wait
   * @param {Object} thisArg
   */
  function debounce ( cb, wait, thisArg ) {
    /**
     * @type {number}
     */
    var timer = null;
    return function() {
      var context = thisArg || this,
          args = arguments;
      window.clearTimeout( timer );
      timer = window.setTimeout(function(){
        timer = null;
        cb.apply( context, args );
      }, wait );
    };
  }

假设我们希望只有在组件进入视图时才进行延迟加载,在我们的案例中,这需要用户至少向下滚动 200 像素:

var TOP_OFFSET = 200;
// Lazy-loading
window.addEventListener( "scroll", debounce(function(){
  var scroll = window.scrollY || window.pageYOffset || document.documentElement.scrollTop;
  if ( scroll >= TOP_OFFSET ){
     console.log( "Load the deferred widget (if not yet loaded)" );
  }
}, 20 ));

如果我们简单地为滚动事件订阅一个监听器,它在用户开始和停止滚动的时间间隔内会被调用很多次。多亏了去抖代理,检查是否是加载小部件的时候的处理程序只调用一次,即当用户停止滚动时。

节流

通过节流,我们设置在事件触发时允许处理程序被调用的频率:

  /**
   * Invoke a given callback every `wait` ms until this function stops being called
   * usage:
   * throttle( cb, 500 )( ..arg );
   *
   * @param {Function} cb
   * @param {Number} wait
   * @param {Object} thisArg
   */
 function throttle( cb, wait, thisArg ) {
  var prevTime,
      timer;
  return function(){
    var context = thisArg || this,
        now = +new Date(),
        args = arguments;

    if ( !prevTime || now >= prevTime + wait ) {
      prevTime = now;
      return cb.apply( context, args );
    }
    // hold on to it
    clearTimeout( timer );
    timer = setTimeout(function(){
      prevTime = now;
      cb.apply( context, args );
    }, wait );
  };
}

所以如果我们通过节流在容器的mousemove事件上订阅一个处理程序,handler函数一次(在这里是每秒一次)直到鼠标光标离开容器边界:

document.body.addEventListener( "mousemove", throttle(function( e ){
  console.log( "The cursor is within the element at ", e.pageX, ",", e.pageY );
}, 1000 ), false );

// The cursor is within the element at 946 , 715
// The cursor is within the element at 467 , 78

编写不会影响延迟关键事件的回调

我们有些任务不属于核心功能,可能是在后台运行。例如,我们希望在不滚动页面时派发分析数据。我们不使用去抖或节流,以免加重 UI 线程的负担,可能导致应用无响应。在这里去抖不相关,节流也不会提供精确数据。然而,我们可以使用requestIdleCallback原生方法(w3c.github.io/requestidlecallback/)在user-agent空闲时安排任务。

总结

我们最优先的目标之一是减少用户响应时间,即,应用程序架构必须确保用户流程永远不会被阻塞。这可以通过将任何长时间运行的任务排队异步调用来实现。然而,如果您有许多异步调用,其中一些并行运行,一些顺序运行,不特别注意,很容易陷入所谓的回调地狱。恰当地使用诸如继续传递风格Promise API)、Async/Await API 或外部库如 Async.js 等方法可以显著改进您的异步代码。我们还需要记住,像scroll/touch/mousemove这样的某些事件,虽然被频繁触发,但频繁调用订阅的监听器可能会造成不必要的 CPU 负载。我们可以使用去抖和节流技术来避免这些问题。

通过学习异步编程的基础,我们可以编写非阻塞应用程序。在第六章,大规模 JavaScript 应用程序架构,我们将讨论如何使我们的应用程序可扩展,并总体上提高可维护性。

第六章:大型 JavaScript 应用程序架构

任何有经验的程序员都会努力使代码具有可重用性和可维护性。在这里,我们遵循面向对象编程的原则,如封装、抽象、继承、组合和多态。除了这些基本原则之外,我们还遵循 Robert C. Martin 定义的面向对象编程和设计的基本原则,即著名的SOLID原则(en.wikipedia.org/wiki/SOLID_(object-oriented_design))。在代码审查过程中,如果我们遇到任何这些原则的违反,都会被视为代码异味,并导致重构。我们每天在开发中解决的核心任务,通常都是我们一次又一次遇到的问题。在本章中,我们将介绍 JavaScript 开发中最常见的通用架构解决方案和概念:

  • JavaScript 中的设计模式

  • 使用 JavaScript MV* 框架了解 JavaScript 中的关注分离

JavaScript 中的设计模式

抽象的万无一失的解决方案早已为人所知,通常被称为设计模式。编程中的最初的 23 个设计模式首次收集在 1995 年出版的Erich GammaRichard HelmRalph JohnsonJohn Vlissides(GoF)合著的《设计模式:可复用面向对象软件的元素》一书中。这些模式与特定的编程语言无关。尽管如此,Addy Osmani在他的在线书籍《学习 JavaScript 设计模式》(addyosmani.com/resources/essentialjsdesignpatterns/book/)中展示了如何实现一些 GoF 的模式,特别是在 JavaScript 中。

在这里,我们不会重复他的工作;相反,我们将研究如何组合这些模式。JavaScript 开发中的一个常见问题是在动态创建的对象之间的通信。例如,我们有一个对象,并需要从对象foo调用对象barbaz方法。然而,我们无法知道bar是否已经可用。GoF 的模式中介者鼓励我们创建一个用于代理其他对象之间通信的对象。因此,通过避免对象之间的直接交互,我们促进了松耦合。在我们的案例中,尽管调用bar.baz,但我们告知中介者我们的意图。中介者在bar可用时会进行调用:

"use strict";

class EventEmitter {
  /** Initialize */
  constructor() {
    /**
    * @access private
    * @type {EventHandler[]}
    */
   this.handlers = [];
  }
 /**
  * Subscribe a cb handler for a given event in the object scope
  * @param {String} ev
  * @param {Function} cb
  * @param {Object} [context]
  * @returns {EventEmitter}
  */
  on( ev, cb, context ){
     this.handlers.push({
       event: ev,
       callback: cb,
       context: context
     });
     return this;
  }
/**
  * Emit a given event in the object
  * @param {String} ev
  * @param {...*} [arg]
  * @returns {EventEmitter}
  */
  trigger( ev, ...args ) {
    this.handlers.forEach(function( evObj ){
     if ( evObj.event !== ev || !evObj.callback.apply ) {
       return;
     }
     evObj.callback.apply( evObj.context || this, args );
   }, this );
   return this;
  }
}

window.mediator = new EventEmitter();

在这里,我们使用了 ES6 语法,它非常适合描述代码设计。借助 ES6,意图可以简洁明了地表达,而在 JavaScript 的 ES5 及更早版本中,要达到同样的效果需要编写额外的代码行。

在前面的示例中,我们通过实例化EventEmitter类创建了一个中介者对象。EventEmitter实现了一种称为 PubSub 的消息模式。这种模式描述了一种消息交换,其中一个对象向另一个对象发送事件,第二个对象调用订阅了该事件的手动函数(如果有的话)。换句话说,如果我们为foo对象的myevent中介者事件(mediator.on)订阅一个处理器函数,我们就可以通过在中介者上发布myevent事件来调用foo的处理器(mediator.trigger)。让我们看一个例子。我们的虚构应用程序是本地化的。它从登录屏幕开始。当用户登录时,屏幕会跳转到带有新闻的仪表板。用户可以在任意屏幕上更改语言。然而,在第一阶段,新闻视图对象甚至还没有被创建,而在第二阶段,登录视图对象已经被销毁。但是,如果我们使用中介者,我们可以触发translate事件,所有可用的订阅者都将收到消息:

class News {
  /** Initialize */
  constructor(){
    mediator.on( "translate", this.update, this );
  }
  /** @param {String} lang */
  update( lang ){
    // fetch news from remote host for a given lang
    console.log( "News loaded for", lang );
  }
}

class Language {
  /** @param {String} lang */
  change( lang ) {
    mediator.trigger( "translate", lang );
  }
}

let language = new Language();
new News()
language.change( "de" );

每当用户更改语言(language.change)时,相应的事件通过中介者广播出去。当 news 实例可用时,它会调用接收事件负载的update方法。在实际应用中,这个实例将为给定语言加载新闻并更新视图。

那么我们取得了什么成果呢?当我们使用中介者和基于事件驱动的方法(PubSub)时,我们的对象/模块是松耦合的,因此,整体架构更能接受需求变化。此外,我们在单元测试中获得了更多的灵活性。

在撰写这本书的时候,没有任何浏览器提供对 ES6 类语句的本地支持。然而,你可以使用 Babel.js 运行时(babeljs.io/docs/usage/browser/)或转译来运行给定的代码。

当应用程序增长,我们处理的事件太多时,将事件处理封装到一个单独的消息总线对象中是有意义的。这时,Facade模式就会浮现在脑海中,它为其他接口定义了一个统一的高层次接口:

class Facade {
  constructor(){
    mediator.on( "show-dashboard", function(){
      this.dashboard.show()
      this.userPanel.remove();
    }, this )
    .on( "show-userpanel", function(a){
      this.dashboard.hide()
      this.userPanel = new UserPanel( this.user );
    }, this )
    .on( "authorized", function( user ){
      this.user = user;
      this.topBar = new TopBar( user.name );
      this.dashboard = new Dashboard( user.lang );
      this.mainMenu = new MainMenu( user.lang );
    }, this )
    .on( "logout", function(){
      this.userPanel.remove();
      this.topBar.remove();
      this.dashboard.remove();
      this.mainMenu.remove();
      this.login = new Login();
    }, this );
  }
}

在初始化Facade类之后,我们可以通过在中介者上触发事件来启动一个涉及多个模块的复杂流程。这种方式将行为逻辑封装到一个专门的物体中;这使得代码更具可读性,整个系统更容易维护。

理解 JavaScript 中的关注点分离

编写 JavaScript(尤其是客户端)时,一个主要的挑战是避免意大利面条代码,在这种代码中,同一个模块渲染用户视图,处理用户交互,还做业务逻辑。这样的模块可能会迅速成长为一个源文件怪物,开发者在其中迷失方向,而不是发现问题并解决问题。

被称为模型-视图-控制器MVC)的编程范式将应用程序功能分为不同的层次,如表示层、数据层和用户输入层。简而言之,MVC 意味着用户与控制器模块中的视图交互,控制器模块操作模型,模型更新视图。在 JavaScript 中,控制器通常是一个观察者,它监听 UI 事件。用户点击一个按钮,事件被触发,控制器处理相应的模型。例如,控制器请求模型将提交的数据发送到服务器。视图得知模型状态变化,并相应地作出反应,比如说它显示一条消息,“数据已保存”。以下图片展示了 MVC 模式中组件的协作:

理解 JavaScript 中关注分离的原理

正如你所见,我们可以将所有用户输入处理器封装在单个模块(这里指的是控制器)中,我们可以将遵循领域驱动设计实践的数据层抽象为模型模块。最终,我们有一个负责更新 UI 的视图模块。所以,模型对组件的表示(HTML,CSS)一无所知,也不知道 DOM 事件——这只是纯粹的数据及其操作。控制器只知道视图的事件和视图 API。最后,视图不知道模型和控制器,但暴露出它的 API 并发送事件。因此,我们得到了一个易于维护和测试的高效架构。

然而,在由 JavaScript 构建的 UI 情况下,将视图逻辑和控制器逻辑分开并不那么容易。这里我们有了 MVC 的衍生版本:MVPMVVM.MVP

MVP模式中的P代表Presenter,它负责处理用户请求。Presenter 监听视图事件,检索数据,操作数据,并使用视图 API 更新展示。Presenter 可以与模型交互以持久化数据。正如您将在以下图表中看到的,Presenter 就像一个经理,它接收请求,使用可用资源处理它,并指导视图进行更改。下面的图片显示了 MVP 模式中组件的协作:

理解 JavaScript 中关注分离的原理

MVP 相比于 MVC 提供了更好的可测试性和关注分离。您可以在codepen.io/dsheiko/pen/WQymbG找到一个实现 MVP 的TODO应用的示例。

MVVM

被动的 MVP 观点主要涉及数据绑定和 UI 事件的代理。实际上,这些都是我们可以抽象的。在模型-视图-视图模型MVVM)方法中的视图可能根本不需要任何 JavaScript。通常,视图是使用视图模型知道的指令扩展的 HTML。模型表示特定领域的数据并暴露相应的诸如验证的方法。视图模型是视图和模型之间的中间人。它将模型的数据对象转换为视图所需的格式,例如,当模型属性包含原始日期时间时,视图模型将其转换为视图中所期望的格式如2016 年 1 月 1 日 00:01。下面的图片显示了 MVVM 模式中组件的协作:

MVVM

MVVM 模式的优势在于命令式和声明式编程两者之间。它可能通过将大部分通用视图逻辑抽象到一个公共绑定模块中来大大减少开发时间。随着像 Knockout,Angular 和 Meteor 这样的流行 JavaScript 框架的出现,这个模式得到了推动。你可以在msdn.microsoft.com/en-us/magazine/hh297451.aspx找到基于 MVVM 模式的 RSS 阅读器应用程序的示例。

使用 JavaScript MV* 框架

当你开始一个新的可扩展的网页应用时,你必须决定是否使用框架。现在很难找到任何不是建立在框架之上的大型项目。然而,使用框架也有缺点;只需看看零框架宣言bitworking.org/news/2014/05/zero_framework_manifesto)。然而,如果你决定支持框架,那么你将面临一个选择困境:选用哪一个。这确实不是一件易事。现在的 JavaScript 框架非常众多;只需看看 TodoMVC 提供的多样性(todomvc.com)。很难一一审查它们,但我们可以简要地检查一些最受欢迎的框架。根据最近的调查(例如,ashleynolan.co.uk/blog/frontend-tooling-survey-2015-results),目前最流行的是 Angular,React 和 Backbone。这三个给出了非常不同的开发范式。所以它们适合用来概述 JavaScript 框架的一般情况。

后端

Backbone (backbonejs.org) 非常轻量级且易于入门。这是唯一一个你可以在相对较短的时间内掌握整个代码库的流行框架(backbonejs.org/docs/backbone.html)。本质上,Backbone 为你提供了一致性的抽象,除此之外什么也没有。总的来说,我们将所有的 UI 相关逻辑封装到 Backbone.View 的子类型中。视图所需的所有数据,我们将其放入 Backbone.ModelBackbone.Collection 的派生类型中(当它是一个条目列表)。最后,我们通过 Backbone.Route 实现基于哈希的导航请求的路由。

让我们考虑一个例子。我们的虚构应用程序允许我们通过给定的电子邮件地址查找联系人。由于我们希望这个应用程序友好,所以期望在应用程序表单中输入时进行验证。为此,我们需要一点 HTML:

<form data-bind="fooForm">
      <label for="email">Email:</label>
      <input id="email" name="email" required />
      <span class="error-msg" data-bind="errorMsg"></span>
      <button data-bind="submitBtn" type="submit">Submit</button>
  </form>

这里有一个输入控件,一个提交按钮,以及一个可能错误信息的容器。为了管理这些,我们将使用以下 Backbone.View

ContactSearchView.js

"use strict";
/** @class {ContactSearchView}  */
var ContactSearchView = Backbone.View.extend(/** @lends ContactSearchView.prototype */{
  events: {
    "submit": "onSubmit"
  },
  /** @constructs {ContactSearchView} */
  initialize: function() {
    this.$email = this.$el.find( "[name=email]" );
    this.$errorMsg = this.$el.find( "[data-bind=errorMsg]" );
    this.$submitBtn = this.$el.find( "[data-bind=submitBtn]" );
    this.bindUi();
  },
  /** Bind handlers */
  bindUi: function(){
    this.$email.on( "input", this.onChange.bind( this ) );
    this.model.on( "invalid", this.onInvalid.bind( this ) );
    this.model.on( "change", this.onValid.bind( this ) );
  },
  /** Handle input onchange event */
  onChange: function(){
    this.model.set({
      email: this.$email.val(),
      // Hack to force model running validation on repeating payloads
      "model:state": ( 1 + Math.random() ) * 0x10000
    }, { validate: true });
  },
  /** Handle model in invalid state */
  onInvalid: function(){
    var error = arguments[ 1 ];
    this.$errorMsg.text( error );
    this.$submitBtn.prop( "disabled", "disabled" );
  },
  /** Handle model in valid state */
  onValid: function(){
    this.$errorMsg.empty();
    this.$submitBtn.removeProp( "disabled" );
  },
  /** Handle form submit */
  onSubmit: function( e ){
    e.preventDefault();
    alert( "Looking up for " + this.model.get( "email") );
  }
});

在构造函数(initialize 方法)中,我们将 HTML 的操作节点与视图的属性绑定,并订阅 UI 和模型事件的事件处理程序。然后,我们在 submit 表单和 input 表单上注册监听器方法。当我们输入时,第二个处理程序被调用,并更新模型。模型运行验证,根据结果,它以 invalidchange 模型事件作出响应。在 invalid 事件的情况下,视图显示错误信息,否则它被隐藏。

现在我们可以添加模型,如下所示:

ContactSearchModel.js

 "use strict";
/** @class {ContactSearchModel}  */
var ContactSearchModel = Backbone.Model.extend(/** @lends ContactSearchModel.prototype */{
  /** @type {Object} */
  defaults: {
    email: ""
  },
  /**
   * Validate email
  * @param {String} email
  */
  isEmailValid: function( email ) {
    var pattern = /^[a-zA-Z0-9\!\#\$\%\&\'\*\+\-\/\=\?\^\_\`\{\|\}\~\.]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,4}$/g;
    return email.length && pattern.test( email );
  },
  /**
   * Validate model
  * @param {Map} attrs
  */
  validate: function( attrs ) {
    if ( !attrs.email ) {
      return "Email is required.";
    }
    if ( !this.isEmailValid( attrs.email ) ) {
      return "Invalid email address.";
    }
  }
});

这个模型在defaults属性中定义了领域数据,并提供了validate方法,当我们将模型设置或保存时会自动调用该方法。

现在我们可以把所有东西结合起来并初始化视图:

<!DOCTYPE html>
<html>
  <script type="text/javascript" src="img/jquery.min.js"></script>
  <script type="text/javascript" src="img/underscore-min.js"></script>
  <script type="text/javascript" src="img/backbone-min.js"></script>
  <script type="text/javascript" src="img/ContactSearchView.js"></script>
  <script type="text/javascript" src="img/ContactSearchModel.js"></script>
  <style>
    fieldset { border: 0; }
    .error-msg{ color: red; }
  </style>
  <body>
   <form data-bind="fooForm">
    <fieldset>
      <label for="email">Email:</label>
      <input id="email" name="email" required />
      <span class="error-msg" data-bind="errorMsg"></span>
    </fieldset>
    <fieldset>
      <button data-bind="submitBtn" type="submit">Submit</button>
    </fieldset>
  </form>
<script>

// Render foo view
 new ContactSearchView({
   el: $( "[data-bind=fooForm]" ),
   model: new ContactSearchModel
 });

</script>
  </body>
</html> 

backbone 本身的大小令人惊讶地小(6.5 Kg 压缩),但是加上 jQuery 和 Underscore 的依赖关系,这使得整体捆绑包变得相当大。这两个依赖关系在过去至关重要,但现在值得怀疑——我们是否需要它们?因此,检查 Exoskeleton (exosjs.com/) 项目是有意义的,这是一个经过优化的 Backbone 版本,无需依赖关系即可完美工作。

安吉拉

Angular (Angular.org) 现在似乎是世界上最受欢迎的 JavaScript 框架。它由谷歌支持,被认为是一个解决你大部分日常任务的框架。特别是,Angular 有一个名为双向绑定的特性,这意味着 UI 变化传播到绑定的模型,反之亦然,模型变化(例如,通过 XHR)更新 UI。

在 AngularJS 中,我们直接在 HTML 中定义行为,使用指令。指令是自定义的元素和属性,它们假设与 Web 组件类似的 UI 逻辑。实际上,你可以在 AngularJS 中创建功能性小部件,而不需要写一行 JavaScript 代码。AngularJS 中的模型是简单数据容器,与 Backbone 不同,它们没有与外部来源的连接。当我们需要读取或写入数据时,我们使用服务。任何数据发送到视图时,我们可以使用过滤器来格式化输出。该框架利用依赖注入(DI)模式,允许将核心组件作为依赖项相互注入。这使得模块更容易满足需求变化和单元测试。让我们在实践中看看这个:

<!DOCTYPE html>
<html>
  <script src="img/angular.min.js"></script>
  <style>
    fieldset { border: 0; }
    .error-msg{ color: red; }
  </style>
  <body>
   <form ng-app="contactSearch" name="csForm" ng-submit="submit()" ng-controller="csController">
    <fieldset>
      <label for="email">Email:</label>
      <input id="email" name="email" ng-model="email" required
          ng-pattern="/^[a-zA-Z0-9\!\#\$\%\&\'\*\+\-\/\=\?\^\_\`\{\|\}\~\.]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,4}$/"  />
      <span class="error-msg" ng-show="csForm.email.$dirty && csForm.email.$invalid">
        <span ng-show="csForm.email.$error.required">Email is required.</span>
        <span ng-show="csForm.email.$error.pattern">Invalid email address.</span>
      </span>
    </fieldset>
    <fieldset>
      <button type="submit" ng-disabled="csForm.email.$dirty && csForm.email.$invalid">Submit</button>
    </fieldset>
  </form>
<script>
  "use strict";
  angular.module( "contactSearch", [] ).controller( "csController", [ "$scope", function ( $scope ){
    $scope.email = "";
    $scope.submit = function() {
      alert( "Looking up for " + $scope.email );
    };
  }]);
</script>
  </body>
</html>

在这个例子中,我们声明了一个输入字段,并将其绑定到一个模型邮箱上(ng-model 指令)。表单验证的工作方式与 HTML5 表单相同:如果我们声明了一个输入类型为邮箱的输入字段并进行相应的验证。这里我们使用默认的文本类型,并使用 ng-pattern(类似于 HTML5 的 pattern)属性来设置与 Backbone 案例相同的邮箱验证规则。接下来,我们依靠 ng-show 指令在输入状态为空(csForm.email.$dirty)或无效(csForm.email.$invalid)时显示错误信息块。在这种情况下,提交按钮相反是隐藏的。使用 ng-controllerng-submit 指令,我们将 csController 控制器和 on-submit 处理程序绑定到表单上。在 csController 的主体(JavaScript)中,$scope.submit 期望有一个处理表单提交事件的事件处理函数。

正如你所看到的,与 Angular 相比,实现相同任务所需的总代码量要少得多。然而,我们必须接受一个事实,那就是将应用逻辑保持在 HTML 中确实使得代码难以阅读。

此外,Angular 每个指令都会订阅许多观察者(意图处理器、自动脏检查等),在包含众多交互元素的页面中,这会使其变得缓慢且资源消耗大。如果你想调整你的应用性能,你最好去学习 Angular 的源代码,这对于有 ~11.2K 行代码(版本 1.4.6)来说将是一个具有挑战性的任务。

React

React (facebook.github.io) 是 Facebook 的一个项目,它不是一个框架,而是一个库。React 独特的 approach 暗示了基于组件的应用。本质上,React 通过所谓的虚拟 DOM 来定义组件的视图,这使得 UI 渲染和更新出奇地快。由于 React 专注于视图,因此它包含了一个模板引擎。可选地,React 组件可以用 JavaScript 的一个子集 JSX 来编写,其中你可以将 HTML 模板放在 JavaScript 中。JSX 可以根据以下示例动态解析,或者可以预编译。由于 React 只处理视图,并且不假设其他关注点,因此与其它框架一起使用是有意义的。因此,React 可以插入到框架中(例如,作为 Angular 的指令或 Backbone 的视图)。

在这次实现联系人搜索应用的过程中,我们将使用 React 来控制我们的示例视图,通过将其拆分为两个组件(FormViewEmailView)。第一个组件定义了搜索表单的视图:

   /** @class {FormView}  */
var FormView = React.createClass({
  /** Create an initial state with the model  */
  getInitialState: function () {
    return {
      email: new EmailModel()
    };
  },
  /**
   * Update state on input change event
   * @param {String} value - changed value of the input
   */
  onChange: function( value ){
    this.state.email.set( "email", value );
    this.forceUpdate();
  },
  /** Handle form submit */
  onSubmit: function( e ){
    e.preventDefault();
    alert( "Looking up for " + this.state.email.get( "email") );
  },
  /** Render form */
  render: function () {
    return <form onSubmit={this.onSubmit}>
      <fieldset>
      <label htmlFor="email">Email:</label>
      <EmailView model={this.state.email} onChange={this.onChange} />
      </fieldset>
      <fieldset>
        <button data-bind="submitBtn" type="submit">Submit</button>
      </fieldset>
    </form>;
  }
});

render 方法中,我们使用 JSX 表示法声明了组件的视图。这使得操作虚拟 DOM 变得容易得多。与 Angular 类似,我们可以在 HTML 中直接引用组件作用域。因此,我们可以通过引用 onSubmitonChange 属性中的相应处理程序来订阅表单提交事件和输入变更事件。由于 React 没有内置模型,我们复用了在探索 Backbone 时创建的 ContactSearchModel 模型。

你可能会注意到 JSX 中有一个 EmailView 自定义标签。这就是我们引用我们的第二个组件的方式,它代表了一个电子邮件输入控件:

    /** @class {EmailView}  */
var EmailView = React.createClass({
  /**
   * Delegate input on-changed event to the from view
   * @param {Event} e
   */
  onChanged: function( e ){
    this.props.onChange( e.target.value );
  },
  /** Render input */
  render: function () {
    var model = this.props.model;
    return <span>
      <input id="email" type="text" value={model.email} onChange={this.onChanged} />      
      <span className="error-msg" data-bind="errorMsg"> {model.isValid() ? "" : model.validationError}</span>
    </span>;
  }
});

在这里,我们将电子邮件输入绑定到模型,将错误消息容器绑定到模型状态。我们还把输入的 onChange 事件传递给了父组件。

好了,现在我们可以将组件添加到 HTML 中并渲染表单:

<!DOCTYPE html>
<html>
<head>
  <script src="img/react.js"></script>
  <script src="img/JSXTransformer.js"></script>
  <script type="text/javascript" src="img/underscore-min.js"></script>
  <script type="text/javascript" src="img/backbone-min.js"></script>
  <script type="text/javascript" src="img/ContactSearchModel.js"></script>
  <style>
    fieldset { border: 0; }
    .error-msg{ color: red; }
  </style>
</head>
<body>
  <div data-bind="app"></div>
<script type="text/jsx">
  /** @jsx React.DOM */

// Please insert here both components
// FormView and EmailView

// render app
React.render(
  <FormView />,
  document.querySelector( "[data-bind=app]" )
);
</script>
</body>
</html>

我们通过相应的自定义元素来在模板中引用组件,比如 web-components。不要让自己混淆于它们的相似性,React 组件是从浏览器中抽象出来的,而 web-components 类似于浏览器原生组件。React 的核心概念是虚拟 DOM 允许我们避免不必要的 DOM reflow 周期,这使得该库适用于高性能应用。React 在服务器上使用 Node.js 渲染静态页面非常出色。因此,我们可以在服务器和客户端之间复用应用程序组件。

总结

编写可维护的代码是一门艺术。或许在提供这方面指导方面最好的书籍是Robert C. Martin所著的《Clean Code: A Handbook of Agile Software Craftsmanship》。这本书讲述了如何命名函数、方法、类,注释,代码格式化,当然还有面向对象编程(OOP)和 SOLID 原则的正确使用。然而,当我们重复使用本书或设计模式系列中描述的解决方案时,我们必须将它们翻译成 JavaScript,这可能由于语言的特性而具有挑战性。在更高的层次上,我们必须将代码划分为表示层、业务逻辑层、数据访问层和持久化层,其中每一组代码都关注一个问题,并且与其他代码松耦合。在这里,我们可以选择一种方法。在 JavaScript 世界中,这通常是 MVC(MVP 或 MVVM 或其他)的派生。考虑到这一点,一个体面的编程设计需要大量的抽象。如今,我们可以使用许多框架。它们提供了多样的编程范式。

第七章:JavaScript 浏览器之外

最初,JavaScript 被设计为客户端脚本语言,但今天,它被用在实实在在的每个地方:在服务器脚本、移动和桌面软件编程、游戏开发、数据库查询、硬件控制和操作系统自动化。当你有客户端 JavaScript 的经验时,加上一些额外的知识,你也可以将你的技能应用到其他编程领域。在这里,我们将学习如何使用 JavaScript 编写命令行工具、web 服务器、桌面应用程序和移动软件。

在本章中,我们将学习以下内容:

  • 用 JavaScript 提升命令行程序的编程水平

  • 用 JavaScript 建立 web 服务器

  • 编写桌面 HTML5 应用程序

  • 使用 PhoneGap 制作移动原生应用

用 JavaScript 提升命令行程序的编程水平

你一定听说过 Node.js。这是一个开源的跨平台开发环境,它允许使用 JavaScript 创建 web 服务器、网络和其他工具。(https://nodejs.org/api/index.html)。Node.js 在经典的 JavaScript 上增加了一系列专门的模块。这些模块处理文件系统 I/O、网络、操作系统级操作、二进制数据、加密功能、数据流等。Node.js 使用事件驱动的 I/O 模型。与 JavaScript 类似,它在一个单线程上执行非阻塞调用。因此,耗时的函数可以通过在完成时调用回调来并发运行。

为了感受 Node.js,我们从一个简单地打印Hello world的示例开始:

hello.js

console.log( "Hello world!" );

现在让我们打开控制台(命令行界面:Windows 中的CMD,或 Linux 和 Mac OS 中的Terminal),导航到示例脚本位置,并运行以下命令:

node hello.js

好了,我们在输出中得到了Hello world!

下面的屏幕截图显示了 Windows CMD

用 JavaScript 提升命令行程序的编程水平

Node.js 模块遵循与我们在第二章 Modular Programming with JavaScript中考察过的相同的 CommonJS 规范:

foo.js

console.log( "Running foo.js" );
module.exports = "foo";
main.js
var foo = require( "./foo" );
console.log( "Running main.js" );
console.log( "Exported value:", foo );

当我们运行main.js时,我们预计会得到以下输出:

Running foo.js
Running main.js
Exported value: foo

Node.js 本地模块,如fsnodejs.org/api/index.html),不需要下载。我们只需在require()中引用它们,在运行时,它将知道在哪里找到它们:

"use strict";
var fs = require( "fs" );
fs.readFile( __filename, "UTF-8", function( err, data ){
  if ( err ) {
    throw new Error( err );
  }
  console.log( "Source of ", __filename, ":\n", data );
});

这里我们使用文件系统 I/O(fs)模块来读取一个文件。模块作用域中的__filename属性包含执行源文件的绝对路径。记住我们在第五章 Asynchronous JavaScript中考察过的错误优先回调方法。这是 Node.js 中异步函数的主要接口。

现在让我们尝试一些更实际的东西。我们将编写一个工具,递归地扫描给定目录中的所有源文件,以确保每个文件都有带有最新版权的块注释。首先,我们需要一个模块,它可以测试提供的块注释文本是否包含实际的版权行:

./Lib/BlockComment.js 
   /**
   * Block comment entity
   * @class
   * @param {String} code
   */
var BlockComment = function( code ){
  return {
    /**
     * Check a block comment
     * @returns {Boolean}
     */
    isValid: function(){
      var lines = code.split( "\n" );
      return lines.some(function( line ){
          var date = new Date();
          return line.indexOf( "@copyright " + date.getFullYear() ) !== -1;
        });
    }
  };
};

module.exports = BlockComment;

在这里,我们有一个构造函数,用于创建代表BlockComment的对象。该对象有一个方法(isValid),用于测试其有效性。因此,如果我们用块注释文本创建一个BlockComment实例,我们可以将其与我们的要求进行验证:

var comment = new BlockComment( "/**\n* @copyright 2015 \n*/" );
comment.isValid() // true 

现在,我们将编写一个模块,用于测试给定源代码中所有版权行是否包含实际年份:

./Lib/SourceFile.js
    /** @type {module:esprima} */
var esprima = require( "esprima" ),

/**
 * Source file entity
 * @class
 * @param {String} fileSrc
 * @param {module:Lib/BlockComment} BlockComment - dependency injection
 */
SourceFile = function( fileSrc, BlockComment ){
  return {
    /**
     * Test if source file has valid copyright
     */
    isValid: function() {
      var blockComments = this.parse( fileSrc );
      return Boolean( blockComments.filter(function( comment ){
        return comment.isValid();
      }).length );
    },
    /**
     * Extract all the block comments as array of BlockComment instances
     * @param {String} src
     * @returns {Array} - collection of BlockComment
     */
    parse: function( src ){
      return esprima.parse( src, {
        comment: true
      }).comments.filter(function( item ){
        return item.type === "Block";
      }).map(function( item ){
        return new BlockComment( item.value );
      });
    }

  };
};

module.exports = SourceFile;

在这个例子中,我们引入了一个SourceFile对象,它有两个方法,parseisValid。私有方法parse从给定的 JavaScript 源代码中提取所有块注释,并返回BlockComment对象的数组。isValid方法检查所有接收的BlockComment对象是否符合我们的要求。在这些方法中,为了操作数组,我们使用了我们在第一章中介绍的深入 JavaScript 核心Array.prototype.filterArray.prototype.map

那么,我们如何可靠地从 JavaScript 源代码中提取blockComments呢?最好的方法是使用一个叫做esprima解析器的解决方案(esprima.org/),它执行代码静态分析,并返回包括注释在内的完整语法树。然而,esprima 是一个第三方包,应该从应用程序中下载并链接。通常,一个包可能依赖于其他包,这些包也有依赖关系。看起来把所需的依赖项集合在一起可能是一项艰巨的工作。幸运的是,Node.js 随 NPM 包管理器一起分发。这个工具可以用来在 NPM 仓库(www.npmjs.com/)中安装和管理第三方模块。NPM 不仅下载请求的模块,还解析模块依赖项,允许在项目范围或全局范围内有一个细粒度的可重用组件结构。

所以,为了在我们的应用程序中使用esprima,我们只需使用这个命令请求它:npm install esprima

通过在控制台运行这个命令,我们自动得到一个包含esprima包的新node_modules子目录。如果该包需要任何依赖项,它们将被获取并在node_modules中分配。一旦通过 NPM 安装了包,Node.js 就可以通过名称找到它。例如,require( "esprima" )。现在我们有了SourceFile对象,我们只需要主脚本,它将读取给定目录中的文件并与SourceFile进行测试:

copyright-checker.js

        /** @type {module:cli-color} */
var clc = require( "cli-color" ),
    /** @type {module:fs-walk} */
    walk = require( "fs-walk" ),
    /** @type {module:path} */
    path = require( "path" ),
    /** @type {module:fs} */
    fs = require( "fs" ),
    /**
     * Source file entity
     * @type {module:Lib/SourceFile}
     */
    SourceFile = require( "./Lib/SourceFile" ),
    /** @type {module:Lib/BlockComment} */
    BlockComment = require( "./Lib/BlockComment" ),
    /**
     * Command-line first argument (if none given, go with ".")
     * @type {String}
     */
    dir = process.argv[ 2 ] || ".";

console.log( "Checking in " + clc.yellow( dir ) );

// Traverse directory tree recursively beginning from 'dir'
walk.files( dir, function( basedir, filename ) {
      /** @type {Function} */
  var next = arguments[ 3 ],
      /** @type {String} */
      fpath = path.join( basedir, filename ),
      /** @type {String} */
      fileSrc = fs.readFileSync( fpath, "UTF-8" ),
      /**
       * Get entity associated with the file located in fpath
       * @type {SourceFile}
       */
      file = new SourceFile( fileSrc, BlockComment );
  // ignore non-js files
  if ( !filename.match( /\.js$/i ) ) {
    return next();
  }
  if ( file.isValid() ) {
    console.log( fpath + ": " + clc.green( "valid" ) );
  } else {
    console.log( fpath + ": " + clc.red( "invalid" ) );
  }
  next();
}, function( err ) {
  err && console.log( err );
});

在这段代码中,我们依赖了一个第三方模块,cli-color,来为命令行输出着色。我们使用了fs-walk模块递归地遍历目录。而 Node.js 本地模块,path,允许我们通过给定的相对目录和文件名解析绝对路径,fs内置模块用于读取文件。

由于我们打算从控制台运行我们的应用程序,我们可以使用命令行选项来传递一个我们想要测试的目录:

node copyright-checker.js some-dir

我们可以从内置进程(process.argv)对象中提取脚本参数。对于这个命令,process.argv将包含一个数组,像这样:

[ "node", "/AbsolutePath/copyright-checker.js", "some-dir" ]

因此,在主脚本中,现在我们可以将这个数组的第三个元素传递给walk.files。该函数将遍历给定目录,为找到的每个文件运行回调函数。在回调函数中,如果文件名看起来像 JavaScript,我们就读取内容并使用SourceFile对象进行测试。

在我们能够运行主脚本之前,我们需要从 NPM 那里获取第三方包,这些包将在脚本中使用:

npm install fs-walk cli-color

现在我们可以运行了。当我们运行node copyright-checker.js fixtures时,我们得到了一个有关位于 fixtures 中的 JavaScript 文件有效性的报告。

下面的屏幕截图显示了 Mac OS X 终端:

用 JavaScript 提升命令行程序的编码水平

使用 JavaScript 构建网页服务器

我们刚刚学习了如何使用 Node.js 编写命令行脚本。然而,这种运行时通常被称为服务器端 JavaScript,意味着这是运行 HTTP 服务器的软件。实际上,Node.js 特别适合这类工作。如果我们基于 Node.js 启动一个服务器应用程序,它会持续运行,只初始化一次。例如,我们可能创建一个单一的数据库连接对象,并在有人请求应用程序时重复使用它。此外,它还赋予我们所有 JavaScript 的灵活性和力量,包括事件驱动、非阻塞 I/O。

那么我们如何利用这一点呢?多亏了 Node.js 的 HTTP 本地模块,一个简单的网页服务器可以像这样轻易实现:

simple-server.js
"use strict";
    /** @type {module:http}  */
var http = require( "http" ),
    /** @type {HttpServer}  */
    server = http.createServer(function( request, response ) {
      response.writeHead( 200, {"Content-Type": "text/html"} );
      response.write( "<h1>Requested: " + request.url + "</h1>" );
      response.end();
    });

server.listen( 80 );
console.log( "Server is listening..." );

在此我们创建了一个带有调度程序回调的服务器来处理 HTTP 请求。然后,让这个服务器监听 80 端口。现在从控制台运行node simple-server.js,然后在浏览器中访问http://localhost。我们会看到如下内容:

Requested: /

所以,我们只需要路由传入的请求,读取相应的 HTML 文件,并通过响应将它们发送出去,以创建一个简单的静态网页服务器。或者我们可以安装现有的模块,connectserve-static

npm install connect serve-static

使用以下方式实现服务器:

"use strict";
    /** @type {module:connect}  */
var connect = require( "connect" ),
    /** @type {module:serve-static}  */
    serveStatic = require( "serve-static" );

connect().use( serveStatic( __dirname ) ).listen( 80 );

在实际应用中,路由请求可能是一个具有挑战性的任务,因此我们更倾向于使用一个框架。例如,Express.js (expressjs.com)。然后,我们的路由可能如下所示:

"use strict";
    /** @type {module:express}  */
var express = require( "express" ),
    /** @type {module:http}  */
    http = require( "http" ),
    /** @type {Object}  */
    app = express();
// Send common HTTP header for every incoming request
app.all( "*", function( request, response, next ) {
  response.writeHead( 200, { "Content-Type": "text/plain" } );
  next();
});
// Say hello for the landing page
app.get( "/", function( request, response ) {
  response.end( "Welcome to the homepage!" );
});
// Show use if for requests like http://localhost/user/1
app.get( "/user/:id", function( request, response ) {
  response.end( "Requested ID: "  + req.params.id );
});
// Show `Page not found` for any other requests
app.get( "*", function( request, response ) {
  response.end( "Opps... Page not found!" );
});

http.createServer( app ).listen( 80 );

编写桌面 HTML5 应用程序

你是否曾经想过用 HTML5 和 JavaScript 编写桌面应用程序?现在,我们可以使用 NW.js 非常容易地做到这一点。这个项目是一个基于 Chromium 和 Node.js 的跨平台应用程序运行时。因此,它提供了一个无框架浏览器,其中既可以使用 DOM API,也可以使用 Node.js API。换句话说,我们可以运行 NW.js 经典网络应用程序,访问低级 API(文件系统,网络,进程等),并重用 NPM 仓库的模块。有趣吗?我们将开始一个教程,我们将创建一个简单的 HTML5 应用程序并使用 NW.js 运行它。它将是一个具有输入名字表单和已提交列表的阵容应用程序。名字将存储在 localStorage 中。让我们摇滚起来。

设置项目

首先,我们必须从nwjs.io下载与我们的平台(Mac OS X,Windows 或 Linux)相关的 NW.js 运行时。在 NW.js 可执行文件(nw.exenew.appnw.,取决于平台)旁边,我们将package.json文件放置在描述我们项目的位置:github.com/nwjs/nw.js/wiki/manifest-format

{
  "name": "roster",
  "main": "wwwroot/index.html",
  "window": {
    "title": "The Roster",
    "icon": "wwwroot/roaster.png",
    "position": "center",
    "resizable": false,
    "toolbar": false,
    "frame": false,
    "focus": true,
    "width": 800,
    "height": 600,
    "transparent": true
  }
}

我们的package.json文件有三个主要字段。name包含与项目关联的唯一名称。请注意,此值将是应用程序数据(sessionStorage,localStorage 等)存储的目录路径的一部分。main接受项目主要 HTML 页面的相对路径。最后,window描述了将显示 HTML 的浏览器窗口。

添加 HTML5 应用程序

根据package.json中的main字段,我们将把我们的index.html放入wwwroot子目录中。我们可以尝试用简单的 HTML 如下:

<html>
  <body>
    Hello world!
  </body>
</html>

NW.js 以与浏览器相同的方式处理 HTML,因此如果我们现在启动 NW.js 可执行文件,我们将看到Hello world!。为了给它外观和感觉,我们可以添加 CSS 和 JavaScript。因此,我们可以用与浏览器相同的方式编写 NW.js 的代码。在这里,我们有一个很好的机会来应用我们在第六章中学习到的原则,大规模 JavaScript 应用程序架构。为了使示例简洁但具有表现力,我们将采用 AngularJS 方法。首先,我们将创建 HTML。主体的标记将如下所示:

<main class="container">
  <form >
    <div class="form-group">
      <label for="name">Name</label>
      <input class="form-control">
    </div>
    <button class="btn btn-danger">Empty List</button>
    <button type="submit" class="btn btn-primary">Submit</button>
  </form>
  <table class="table table-condensed">
    <tr>
      <td></td>
    </tr>
  </table>
</main>

我们定义了一个表单来提交新名字和一个表格来显示已经存储的名字。为了使其更漂亮,我们使用了 Bootstrap(getbootstrap.com)样式。CSS 文件可以从 CDN 加载,如下所示:

<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css">

现在我们将通过添加 AngularJS 指令来使其生动起来:

<html>
<body ng-app="myApp" >
      <main ng-controller="RosterController" class="container">
        <form ng-submit="submit()">
          <div class="form-group">
            <label for="name">Name</label>
            <input class="form-control" id="name" name="name" ng-model="name" required placeholder="Name">
          </div>
          <button ng-click="empty()" class="btn btn-danger">Empty List</button>
          <button type="submit" class="btn btn-primary">Submit</button>
        </form>
        <table class="table table-condensed">
          <tr ng-repeat="person in persons">
            <td>{{person.value}}</td>
          </tr>
        </table>
      </main>
  </body>
</html>

在这里我们声明了一个myApp模块作用域(<body ng-app="myApp" >)。在此范围内,我们定义了一个RosterController控制器。在控制器的边界内,我们将输入字段绑定到模型名称(<input ng-model="name">)并为表单提交和“空列表”按钮点击事件(<form ng-submit="submit()"><button ng-click="empty()">)设置处理程序。最后,我们将一个模板从表格中绑定到$scope.persons集合。因此,每当集合发生变化时,表格就会更新:

<table class="table table-condensed">
  <tr ng-repeat="person in persons">
    <td>{{person.value}}</td>
  </tr>
</table>

现在是我们向我们的 HTML 添加一些 JavaScript 的时候了:

<script>
  var app = angular.module( "myApp", [ "ngStorage" ]);

  app.controller("RosterController", function( $scope, $localStorage ) {
    var sync = function() {
      $scope.persons = JSON.parse( $localStorage.persons || "[]" );
    };
    sync();
    $scope.name = "";
    $scope.submit = function() {
      sync();
      $scope.persons.push({ value: $scope.name });
      $localStorage.persons = JSON.stringify( $scope.persons );
    };
    $scope.empty = function() {
      $localStorage.persons = "[]";
      sync();
    };
  });
</script>

由于我们打算存储表单提交的数据,我们可以使用我们在第四章中讨论的HTML5 APIs中提到的 localStorage。为了以 AngularJS 的方式获取 localStorage,我们使用了ngStorage模块(github.com/gsklee/ngStorage)。因此,我们在模块初始化时指定插件,这使得插件在控制器中作为一个参数($localStorage)可用。在控制器主体中,我们有一个sync函数,它将$scope.persons设置为 localStorage 中的人数组。我们在表单提交处理程序($scope.submit)和“空列表”按钮单击处理程序($scope.empty)中调用sync函数。它每次都会更新人员表格。在处理提交事件时,我们将$scope.persons的值附加到$scope.persons并将其保存到 localStorage 中。

为了启用此功能,我们必须加载 AngularJS 和 ngStorage 插件:

<script src="img/angular.min.js"></script>

<script src="img/ngStorage.min.js"></script>

现在我们启动 NW.js 可执行文件并让应用程序运行起来。下面的截图展示了在 NW.js 中没有样式的 Roaster 示例应用:

添加 HTML5 应用程序

这很好,但是当我们以无框架方式运行 NW.js 时,我们甚至没有办法关闭应用程序。此外,我们不能在桌面上拖动应用程序窗口。这个问题很容易解决。我们可以在 HTML 正文中添加一个 HTML 片段,带有两个按钮来关闭和最小化应用程序:

<header ng-controller="ToolbarController">
  <a href="#" ng-click="minimize()">Minimize</a>
  <a href="#" ng-click="close()">Close</a>
</header>

现在我们为这些按钮订阅监听器,分别调用 NW.js 窗口 API(github.com/nwjs/nw.js/wiki/Window)的关闭和最小化方法:

var win = require( "nw.gui" ).Window.get();
app.controller("ToolbarController", function( $scope ) {
  $scope.close = function(){
    win.close();
  };
  $scope.minimize = function(){
    win.minimize();
  };
});

为了使我们的窗口可拖动(github.com/nwjs/nw.js/wiki/Frameless-window),我们可以使用-webkit-app-regionCSS 伪类。我们将此设置为在处理容器(头部)上具有拖动值,并在其中设置为不可拖动的值:

header {
  -webkit-app-region: drag;
}
header a {
   -webkit-app-region: no-drag;
}

此外,我们美化页面的外观和感觉。注意,在 NW.js 中,我们可以拥有一个透明的背景。因此,我们在html元素上设置border-radius,使窗口变得圆角:

html {
 height: 100%;
 border-radius: 20px;
 background-color: rgba(0,0,0,0);
}
body {
  min-height: 100%;
  background: linear-gradient(to bottom,  #deefff 0%,#98bede 100%);
  overflow: auto;
}
header {
  text-align: right;
  width: auto;
  padding: 12px;
  background: rgba(255,255,255, 0.5);
  border-radius: 20px 20px 0 0;
  -webkit-app-region: drag;
}
header a {
  margin: 12px;
  -webkit-app-region: no-drag;
}

现在我们可以再次启动我们的 NW.js 可执行文件。带有样式的 Roaster 示例应用在 NW.js 中的截图如下:

添加 HTML5 应用程序

请注意,在 Mac OS X/Linux 上,我们必须使用特殊参数(github.com/nwjs/nw.js/wiki/Transparency)才能获得透明效果。例如,在 Mac OS X 上我们必须这样做:

open -n ./nwjs.app --args --enable-transparent-visuals –disable-gpu

调试

还有一些东西缺失了。如果出了问题,我们如何调试和追踪错误?有以下几个选项可供选择:

  • 使用--enable-logging参数启动 NW.js 可执行文件,并在stdout中获取日志。

  • 使用--remote-debugging-port参数启动 NW.js 可执行文件,并在远程运行的 Chrome 中访问 DevTools 应用程序。例如,我们以nw --remote-debugging-port=9222的方式启动项目,并在 Chrome 中寻找http://localhost:9222页面。

  • package.json中为窗口启用工具栏和框架。

第一个选项在调试时并不太方便。第二个选项为您提供了一个 DevTools 的简化版,最后一个选项带来了框架,可能会使应用程序看起来很糟糕。幸运的是,我们可以从应用程序中以编程方式调用 DevTools。所以在DEVELOPMENT/TEST环境中,您可以添加这段按下Ctrl + Shift + I即可显示 DevTools 的代码:

console.info( "Here we go!" );

document.addEventListener( "keydown", function( e ){
  var key = parseInt( e.key || e.keyCode, 10 );
  // Ctrl-Shift-i
  if ( e.ctrlKey && e.shiftKey && key === 73 ) {
    e.preventDefault();
    win.showDevTools();
  }
}, false );

NW.JS 中以编程方式调用的 DevTools 在以下屏幕快照中显示:

调试

打包

为了拥有真正的桌面应用程序体验,我们可以将项目的资源和 NW.js 文件打包成一个可执行文件。首先使用 ZIP,我们将项目目录(wwwroot)和伴随文件(node_modules目录和NAPI插件)压缩成app.nw。然后,我们将该压缩文件与 NW.js 可执行文件结合。在 Windows 上,可以这样操作:

run copy /b nw.exe+app.nw app.exe

如果针对您平台的 NW.js 发行版包含任何组件(例如,Windows 发行版包括 DLLs),可以使用 Enigma 虚拟盒(enigmaprotector.com)将它们注入到新创建的应用程序可执行文件中。完成啦,现在我们可以将项目以单一文件的形式分发。

使用 PhoneGap 制作移动原生应用

好了,现在我们可以用 JavaScript 制作桌面应用程序,那原生移动应用程序呢?有许多基于 web 的框架可用于移动开发(en.wikipedia.org/wiki/Multiple_phone_web-based_application_framework)。最流行的解决方案之一称为 Adobe PhoneGap,它是在 Apache Cordova 项目之上构建的。总的来说,PhoneGap 应用程序由一个 web 堆栈(HTML5、CSS 和 JavaScript)组成。尽管现在 HTML5 可以访问一些原生功能(加速计、相机、联系人、振动、GPS 等),但不同设备的兼容性不一致且古怪,性能相对较差。所以 PhoneGap 在设备的本地 WebView 中运行 HTML5,并提供对设备资源和 API 的访问(en.wikipedia.org/wiki/Foreign_function_interface)。结果是,我们可以基于 HTML5 编写一个移动应用程序,并使用 PhoneGap 为我们支持(iPhone、Android、黑莓、Windows、Ubuntu、Firefox OS 等)的设备和操作系统构建它。这里的一个好处是,在为移动设备开发时,我们可以重用为 Web 创建的组件。事实上,我们可以将我们为 NW.js 制作的 roster 应用程序作为移动应用程序捆绑。那么让我们这样做。

设置项目

首先我们需要一个框架。最简单的方法是使用 NPM 工具进行安装:

npm install -g cordova

-g 选项意味着我们将在全局安装此软件,在设置任何新项目时无需再次安装。

现在我们可以使用以下命令创建一个新项目:

cordova create roster org.tempuri.roster Roster

roster 子目录中,工具为项目创建了一个名为 Roster 的项目文件结构,该项目注册在 org.tempuri.roster 命名空间中。

现在,我们需要通知 PhoneGap 我们想要支持哪些平台。所以,我们导航到 roster 子目录并输入以下内容:

cordova platform add ios
cordova platform add android

构建项目

www 子目录中,我们可以找到一个占位符 HTML5 应用程序。我们可以用为 NW.js 编写的 roster 应用程序替换它(当然,不包括环境特定的头部容器及其监听器代码)。为了检查项目是否正确初始化,我们运行以下内容:

cordova build ios
cordova emulate ios

或者,我们可以使用这个:

cordova build android
cordova emulate android

这会构建项目并在特定平台的模拟器中显示它。在 Mac 上,它看起来是这样的。PhoneGap 提供的 roster 示例应用程序如下屏幕截图所示:

构建项目

添加插件

如前所述,使用 PhoneGap,我们可以访问原生设备功能(phonegap.com/about/feature)。而且,我们还可以安装和使用在Cordova仓库中可用的原生插件(cordova.apache.org/plugins/)。让我们拿其中一个来说——cordova-plugin-vibration。我们可以像这样轻松地将其添加到项目中:

cordova plugin add cordova-plugin-vibration

既然我们有了插件,我们可以在我们的 JavaScript 代码中使用其 API:

// Vibrate for 3 seconds
navigator.vibrate(3000);

调试

至于调试移动应用程序,有多种选择(github.com/phonegap/phonegap/wiki/Debugging-in-PhoneGap)。主要思想是使用桌面检查工具来达到应用程序。在 iOS 的情况下,我们选择 Safari WebInspector 桌面。只需在开发菜单中找到iPhone Simulator选项,并按下与你应用程序 HTML 相对应的WebView。同样,我们可以在 Chrome DevTools 中访问 Android WebView(developer.chrome.com/devtools/docs/remote-debugging#debugging-webviews)。

总结

广泛使用的 Node.js 运行时通过低级 API 扩展 JavaScript,这为我们提供了创建命令行工具、网络服务器和专用服务器(例如 UDP-TCP/WebSocket/SSE 服务器)的方法。只需考虑使用 Node.js 构建的独立操作系统 NodeOS,看看我们可以在 Web 之外走多远。使用 HTML5 和 JavaScript,我们可以编写桌面软件,并轻松地在不同平台上分发。同样,我们可以使用 HTML5/JavaScript 和原生 API 组成移动应用程序。使用诸如 PhoneGap 之类的工具,我们可以为多种移动平台构建应用程序。

在本章中,我们学习了如何访问 DevTools 来调试 NW.js 和 PhoneGap 应用程序。在下一章中,我们将讨论如何高效地使用 DevTools。

第八章:调试和剖析

调试是编程的一个棘手部分。开发过程中的错误是不可避免的。无论我们的经验如何,我们都要花很多时间来寻找它们。这种情况发生了。通过查看代码,你可能找不到错误,应用程序可能没有问题,但开发者可能会花几个小时直到他们找到一个愚蠢的原因,比如拼写错误的属性名。如果更好地利用浏览器开发工具,可以节省很多时间。因此,在本章中,我们将考虑以下主题:

  • 如何发现错误

  • 充分利用控制台 API

  • 如何调整性能

寻找错误

调试是关于找到并解决阻止预期应用程序行为的缺陷。在这方面,关键是找到导致问题的代码。当我们遇到一个错误时通常会做什么呢?比如说,我们有一个表单,它被假设在提交事件上运行验证,但它没有。首先,我们需要满足许多假设。例如,如果表单元素的引用是有效的,如果在注册监听器时事件和方法名称拼写正确,如果对象上下文在监听器主体中丢失等等。

一些错误可以自动发现,例如通过验证方法入口和出口点的输入和输出(参见设计合同在:en.wikipedia.org/wiki/Design_by_contract)。然而,我们不得不手动查找其他错误,在这方面我们可以使用两种选择。从代码肯定正确的地方逐步走向问题点(自底向上的调试),或者相反,从断点退回到查找断裂源。在这里,浏览器开发工具可以派上用场。

最先进的是 Chrome DevTools。我们可以打开其中的源代码面板并在代码中设置断点。在达到断点时,浏览器停止执行并显示一个带有实际变量作用域和调用堆栈的面板。它还提供了控制,可以用来逐行前后单步执行代码。下面的屏幕截图显示了使用断点的调试帮助:

寻找错误

然而,这可能会在 DevTools 中导航代码库时变得棘手。幸运的是,你可以在 IDE 外直接设置断点。你只需要在想要浏览器中断的行上放置调试器语句。

有时,很难弄清楚 DOM 的情况。我们可以让 DevTools 在 DOM 事件上中断,如节点移除、节点修改和子树更改。只需在源代码面板中导航到 HTML 元素,右键点击,选择在...中断选项。

此外,在源代码面板中有一个名为XHR 断点的标签,我们可以在其中设置一个 URL 列表。然后,当浏览器请求这些 URL 中的任何一个时,它将中断。

你还可以在源代码面板侧边栏找到一个形似停车标志的图标。如果点击这个按钮,DevTools 将在任何捕获的异常处中断,并带你到源代码中的抛出位置。下面的截图展示了如何使用“在捕获异常时暂停”工具:

寻找 bug

注意

更多信息,请参阅developer.chrome.com/devtools/docs/javascript-debugging

从控制台 API 中获得最佳效果

尽管这不是 JavaScript 的一部分,但我们都在广泛使用控制台 API 来了解应用程序生命周期中实际发生了什么。这个 API 是由 Firebug 工具引入的,现在每个主要的 JavaScript 代理商都可以使用。大多数开发者只是使用 error、trace、log 等方法进行简单的日志记录,以及像 info 和 warn 这样的装饰器。嗯,当我们向console.log传递任何值时,它们都会显示在JavaScript 控制台面板上。通常,我们传递一个描述案例的字符串和一个我们想要检查的各种对象列表。然而,你知道我们可以直接从字符串中引用这些对象,就像 PHP 的sprintf一样吗?所以,作为第一个参数给出的字符串可以是一个包含其他参数的格式指定器的模板:

var node = document.body;
console.log( "Element %s has %d child nodes; JavaScript object %O, DOM element %o",
  node.tagName,
  node.childNodes.length,
  node,
  node );

从控制台 API 中获得最佳效果

可用的指定符有%s用于字符串,%d用于数字,%o用于 DOM 元素,%O用于 JavaScript 对象(与console.dir相同)。此外,有一个特殊的指定符允许我们样式化console.log报告。这非常有用。在实际应用中,控制台接收太多的日志记录。在成百上千条类似的消息中找出所需的消息变得困难。我们可以做的是对消息进行分类并相应地样式化:

console.log.user = function(){
  var args = [].slice.call( arguments );
  args.splice( 0, 0, "%c USER ",
    "background-color: #7DB4B5; border-radius: 3px; color: #fff; font-weight: bold; " );
  console.log.apply( console, args );
};

console.log.event = function(){
  var args = [].slice.call( arguments );
  args.splice( 0, 0, "%c EVENT ",
    "background-color: #f72; border-radius: 3px; color: #fff; font-weight: bold; " );
  console.log.apply( console, args );
};
console.log( "Generic log record" );
console.log.user( "User click button Foo" );
console.log.event( "Bar triggers `Baz` event on Qux" );

在这个例子中,我们定义了两个扩展console.log的方法。一个用青色前缀 console 消息为USER,用于用户动作事件。第二个用EVENT前缀报告,旨在突出中介事件。下面的截图解释了使用 console.log 的颜色化输出:

从控制台 API 中获得最佳效果

另一个不太为人所知的技巧是在代码逻辑中使用console.assert进行断言。所以,我们假设一个条件是正确的,直到它为止一切都很好,我们没有收到任何消息。但是一旦它失败,我们在控制台中获得一个记录:

console.assert( sessionId > 0, "Session is created" );

下面的截图展示了如何使用控制台断言:

从控制台 API 中获得最佳效果

有时我们需要知道一个事件发生多少次。这里我们可以使用console.count方法:

function factory( constr ){
  console.count( "Factory is called for " + constr );
  // return new window[ constr ]();
}
factory( "Foo" );
factory( "Bar" );
factory( "Foo" );

这会在控制台中显示指定的消息和一个自动更新的计数器旁边。下面的截图展示了如何使用console.count

从控制台 API 中获得最佳效果

注意

你可以在developer.chrome.com/devtools/docs/console找到更多关于控制台工作的信息。

优化性能

性能决定用户体验。如果页面加载时间过长或者界面响应迟缓,用户可能会离开应用程序且再也不回来。这在网页应用中尤为正确。在第三章,DOM 脚本和 AJAX,我们比较了操作 DOM 的不同方法。为了找出哪种方法速度更快,我们使用了一个内置的性能对象:

"use strict";
var cpuExpensiveOperation = function(){
      var i = 100000;
      while( --i ) {
        document.body.appendChild( document.createElement( "div" ) );
      }
    },
    // Start test time
    s = performance.now();

cpuExpensiveOperation();
console.log( "Process took", performance.now() - s, "ms" );

performance.now()返回一个高精度的毫秒时间戳,精确到微秒。这是为基准测试设计和广泛使用的。然而,time/timeEnd控制台对象也提供了测量时间的方法:

console.time( "cpuExpensiveOperation took" );
cpuExpensiveOperation();
console.timeEnd( "cpuExpensiveOperation took" );

下面的截图展示了如何使用控制台测量时间:

优化性能

如果我们需要知道操作执行期间确切发生了什么,我们可以请求该时段的配置文件:

console.profile( "cpuExpensiveOperation" );
cpuExpensiveOperation();
console.profileEnd( "cpuExpensiveOperation" );

下面的截图展示了如何使用控制台 API 进行配置文件:

优化性能

此外,我们可以在 DevTools 的时间线面板中精确标记事件的时间:

cpuExpensiveOperation(); 
console.timeStamp( "cpuExpensiveOperation finished" );

下面的截图展示了如何在记录会话期间在时间线上标记事件:

优化性能

当我们优化性能时,我们必须特别注意响应时间。有许多技术可以用来在启动过程中改善用户体验(非阻塞 JavaScript 和 CSS 加载、关键 CSS、将静态文件托管到 CDN 等)。好吧,假设你决定异步加载 CSS(www.npmjs.com/package/asynccss)并缓存到 localStorage。但你如何测试你从中获得了什么?幸运的是,DevTools 有一个电影胶片功能。我们只需要打开网络面板,启用屏幕截图捕获并重新加载页面。

DevTools 向我们展示了用户在加载过程中看到的页面每帧的加载进度。此外,我们可以手动为测试设置一个连接速度(节流),并找出它如何影响电影胶片。下面的截图展示了如何获取页面加载的电影胶片:

优化性能

总结

调试是 web 开发的一个重要组成部分。它也可能是一个相当缓慢和单调的任务。借助浏览器开发工具,我们可以减少捉虫的时间。我们可以在代码中设置断点,一步步走到问题的源头,就像程序一样。当使用 Chrome DevTools 时,我们可以监视 DOM 修改事件和特定的 URL 请求。在调整性能时,我们可以使用time/timeEnd测量时间,并用profile/profileEnd请求进程配置文件。借助电影胶片和节流等功能,我们可以查看不同连接上的页面加载情况。

我们这本书从复习 JavaScript 的核心特性开始。我们学会了如何通过语法糖使代码更具表现力,练习了对象迭代和集合规范化,比较了包括 ES6 类在内的各种声明对象的方法,并发现了如何使用 JavaScript 的魔法方法。然后,我们深入到了模块化编程。我们谈论了模块模式和模块的一般概念,并回顾了 JavaScript 模块化的三种主要方法:AMD,CommonJS 和 ES6 模块。下一个话题是保持高性能 DOM 操作。我们还研究了 Fetch API。我们也考虑了一些最激动人心的 HTML5 API,如存储、IndexedDB、工作者、SSE 和 WebSocket,以及 Web 组件背后的技术。我们考虑了利用 JavaScript 事件循环和构建非阻塞应用程序的技术。我们在 JavaScript 中实践了设计模式,并涵盖了关注分离。我们在三个框架中编写了一个简单的应用程序,分别是 Backbone、Angular 和 React。我们通过创建命令行工具和暴露 Web 服务器来尝试 Node.js。我们还使用 NW.js 创建了一个演示桌面应用程序以及其移动版本 PhoneGap。最后,我们谈论了捉虫。

posted @ 2024-05-23 14:41  绝不原创的飞龙  阅读(3)  评论(0编辑  收藏  举报