第 3 章 同构 JavaScript 分类

第 3 章 同构 JavaScript 分类

Maxime Najim

同构 JavaScript(isomorphic JavaScript)这一术语公认的出处是 Charlie Robbins 在 2011 年发表的博文“Scaling Isomorphic Javascript Code”(https://blog.nodejitsu.com/scaling-isomorphic-javascript-code/)。随后,这个术语在 Spike Brehm 于 2013 年发表的博文“Isomorphic JavaScript: The Future of Web Apps”(http://nerds.airbnb.com/isomorphic-javascript-future-web-apps/)及随后的一些文章和会议演讲中多次出现,并因此开始流行起来。然而,在 JavaScript 社区(https://www.oreilly.com/ideas/renaming-isomorphic-javascript),关于同构的用词“isomorphic”曾存在一些争论。Michael Jackson(React.js 讲师、react-router 项目作者之一)认为,应该将“同构 JavaScript”称为“universal JavaScript”(https://medium.com/@mjackson/universal-javascript-4761051b7ae9#.h655sp39b)。Jackson 认为 universal 这个词可以突出“JavaScript 代码不仅可以在服务器端和客户端上运行,还可以在原生设备和嵌入式架构上运行”的特点。

而另一方面,isomorphism 是一个数学术语:对于两个数学对象来说,如果我们简单地忽略它们的个体差异,则当它们具有相似的属性和操作时,就是同构的。当我们将这个概念应用到图论中时,一切就变得很好理解了。图 3-1 中的这两个图就是很好的例子。

图 3-1:同构的图

尽管这两个图看起来差别很大,但它们却是同构的。这两个图具有相同的结点数,而且每个结点拥有相同的边数。但它们是同构图的真正原因是,左图中的每个结点都能映射到右图中对应的结点,并且同时保留某些属性。比如,结点 A 可以映射到结点 1,并且右图中结点 1 的相邻关系和结点 A 是一致的。事实上,左图中的每个结点映射到右图后都保留了原有的相邻关系。

这就是“同构”这个类比有意思的地方。要想让 JavaScript 代码可以同时在客户端和服务器端环境中运行,这些环境就必须是同构的;这也就是说,应该存在一种映射,能够将客户端的功能映射到服务器端的环境中,反之亦然。正如图 3-1 中的两个同构图中的映射关系那样,同构 JavaScript 环境也需要有映射关系。

在 JavaScript 中,不依赖于特定环境属性的代码可以轻松地在不同的环境中同时运行,比如那些避免使用 windowrequest 对象的代码。但对于 req.pathwindow.location.pathname 这样使用了特定环境属性的代码,则需要提供一种映射关系(有时被称为 shim)来抽象或“填充”到某个特定环境属性中。这使得同构 JavaScript 分成了两大类:与环境无关的,和为每个特定环境提供 shim 的。

命名与分类

同构 JavaScript 是一个不断演化的主题,其命名与分类也在定型的过程中。一般来说,应用代码可以分为两种类别:使用了环境 API(如 window 对象的API)的代码以及无须使用特定环境 API 的代码,后者无须额外修改即可“到处”运行。针对使用了环境 API 的代码,我们一般有两种方案:一是修改环境,让代码可以同时在浏览器端和服务器端运行;二是根据环境 API 创建一个抽象层,并在应用代码中使用这些抽象方法。这两者的区别很小,还有人建议再添加第三种类别,即第二种做法外加“保留原有语义”。在本章中,我们将这两种方案统称为“为每个特定环境提供 shim”。关于命名的进一步讨论,请参见 16.1.2 节。

3.1 与环境无关的代码

与环境无关的 Node 模块只能使用纯 JavaScript 的功能,并且不能使用环境特定的 API 或者 window(浏览器端)和 process(服务器端)这样的属性。例如,Lodash.js、Async.js、Moment.js、Numeral.js、Math.js 和 Handlebars.js 都是与环境无关的。事实上,很多模块都属于这一类别,且这些模块都能够很好地在同构应用中运行。

唯一需要解决的问题是,这些 Node 模块是使用 Node 环境中的 require(module_id) 模块装载器进行加载的,但浏览器本身不支持 Node 环境中的 require(...) 方法。要想处理这个问题,我们需要一个负责在浏览器中编译 Node 模块的构建工具。目前有两个主流的构建工具可以完成这项工作,它们分别被称为 Browserify 和 Webpack。

在例 3-1 中,我们使用 Moment.js 定义了一个日期格式化方法,这个方法将同时在服务器端和客户端运行。

例 3-1 定义一个同构的日期格式化函数

'use strict';

var moment = require('moment'); //Node环境特有的require语句

var formatDate = function(date) {
    return moment(date).format('MMMM Do YYYY, h:mm:ss a');
};

module.exports = formatDate

我们还有一个简单的 main.js 文件,该文件会调用 formatDate(..) 方法,以格式化当前时间:

var formatDate = require('./dateFormatter.js');
console.log(formatDate(Date.now()));

当在服务器端运行 main.js 时(使用 Node.js),我们会得到如下输出:

$ node main.js
July 25th 2015, 11:27:27 pm

Browserify(http://browserify.org/)是一个编译 CommonJS 模块的工具,该工具可以将所有引入的 Node 模块打包起来,在浏览器中运行。借助 Browserify,我们可以输出一个对浏览器友好的 JavaScript 文件:

$ browserify main.js > bundle.js

当在浏览器中打开 bundle.js 文件时,我们可以在浏览器控制台中看到相同的日期信息(如图 3-2 所示)。

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

{%}

图 3-2:运行 bundle.js 时的浏览器控制台输出

在继续介绍之前,我们先暂停一下,回想一下刚才发生的事情。尽管这只是一个简单的例子,但有着深远的影响。有了简单的构建工具后,我们就可以轻松实现从服务器端到客户端的逻辑共享。这就带来了许多可能性,我们将在本书的第二部分深入探讨。

3.2 为每个特定环境提供shim

客户端和服务器端的 JavaScript 环境存在许多区别。在客户端,我们拥有全局对象(如 window)以及各种 API,其中包括 localStorage、History API 以及 WebGL。而在服务器端,我们在一个请求 / 响应生命周期的上下文环境中工作,而且服务器端还拥有自身的全局对象。

如果在浏览器中运行以下代码,那么将会返回浏览器当前的 URL 地址。改变这个属性的值将会导致页面重定向:

console.log(window.location.href);
window.location.href = 'http://www.oreilly.com'

在服务器端运行同样的代码则会返回一个错误:

> console.log(window.location.href);
ReferenceError: window is not defined

这是因为 window 在服务器端不是一个全局对象。为了在服务器端实现相同的重定向功能,我们必须在响应对象中写入头部信息,包括一个指明 URL 重定向的状态码(如 302)以及客户端将要跳转的地址 location

var http = require('http');
http.createServer(function (req, res) {
   console.log(req.path);
   res.writeHead(302, {'Location': 'http://www.oreilly.com'});
   res.end();
}).listen(1337, '127.0.0.1');

正如我们看见的那样,服务器端的代码看起来和客户端的差异很大。那么如何让同一份代码在两端都能运行呢?

我们有两种可选方案。第一种方案是将重定向的逻辑分离到一个独立的模块中,这个模块需要知道当前的运行环境。应用的剩余代码只需要调用该模块即可,从而实现具体环境的完全隔离:

var redirect = require('shared-redirect');

// 执行一些有趣的应用逻辑,判断是否需要进行重定向

if(isRedirectRequired){
  redirect('http://www.oreilly.com');
}

// 继续执行有趣的应用逻辑

这种方式使得应用逻辑变得与环境无关,可以同时在客户端和服务器端运行。虽然 redirect(..) 函数的实现需要考虑到特定环境,但其逻辑是独立的,不会影响到应用的其他方面。以下是 redirect(..) 函数的一种实现方式:

if (typeof window !== 'undefined') {
  window.location.href = 'http://www.oreilly.com'
}else{
  this._res.writeHead(302, {'Location': 'http://www.oreilly.com'});
}

注意,这个函数必须判断 window 对象是否存在,并根据情况判断是否使用它。

另一种方法是,在客户端使用服务器端的响应对象接口,但需要进行 shim,实质上还是调用了 window 属性。通过这种方式,应用代码只需要调用 res.writeHead(..) 即可,但是这在浏览器中会转为调用 window.location.href 属性。我们将在本书的第二部分中更详细地分析这种实现方式。

3.3 小结

在本章中,我们探讨了两种不同的同构 JavaScript 代码。我们研究了如何简单地使用 Browserify 这样的工具,将与环境无关的 Node 模块代码转换到浏览器中。此外,还探讨了与环境相关的代码是如何为特定环境实现 shim 的,以允许代码在客户端和服务器端重用。现在是时候进行更深层次的讨论了。下一章将超越服务器端渲染,研究如何在不同的解决方案中使用同构 JavaScript。我们将探索创新的、前瞻性的应用架构,这些架构可以使用 JavaScript 来完成一些新奇的事情。

目录