《Building Isomorphic JavaScript Apps》 翻译笔记(2)——分类

Posted by Zhangjd on March 28, 2017

《同构JavaScript应用》(原书Building Isomorphic JavaScript Apps:From Concept to Implementation to Real-World Solutions)是我翻译的第二本书,整本书分为三个部分:介绍与关键概念、构建一个同构应用(附带代码示例)、现实解决方案。总的来说,整本书除了 PART II 的代码示例部分小瑕疵比较多,感受还是比较有指导意义的。我将会分开几篇笔记,来总结书中的关键观点。

第二篇笔记,概括同构 JavaScript 的图谱;实现共享视图、路由、模型时的难点;API实现的分类。

2.1 图谱

根据客户端和服务器端代码共享程度的不同,可以将同构 JavaScript 划分出一个图谱。

图:Isomorphic JavaScript as a spectrum

在图谱的左侧,客户端和服务器共用最低限度的视图渲染(比如 Handlebars.js 的模板),以及共用辅助函数(某些格式化名字、日期或URL的代码),或者是应用逻辑的某些部分。这些应用需要的抽象程度不高,因为在 JavaScript 中已经有一些流行的库支持在客户端和服务器端之间共用代码,比如Underscore.jsLodash.js

图:Sharing the view layer

在图谱的右侧,客户端和服务器端共享整个应用(如图所示)。共享内容包括整个视图层、应用程序流、用户访问限制、表单验证、路由逻辑、模型和状态。这些应用需要的抽象程度比较高,因为客户端代码的执行上下文是 DOM(document object model,文档对象模型)和 window,而服务器端代码的执行上下文是一个 request/response 对象。

图:Sharing the entire application

2.2 共享视图

在单页面应用(SPA)中,通过减少用户在跳转页面时重新加载完整页面的次数,并在用户交互时采用部分渲染页面的方式取而代之,从而提供了更加流畅的用户体验。SPA 利用客户端模板引擎技术,通过接收模板(模板中包含了一些简单的变量占位符)、传递模板上下文对象、执行并输出 DOM 结构,最终把结果插入到 DOM 树中。客户端模板把视图标签从视图逻辑中分离出来,有助于创建更加易于维护的代码。

而同构应用中的共享视图意味着:模板和相对应的视图逻辑都需要共享

2.2.1 共享模板

为了获得更快的(感知)性能和更佳的 SEO 效果,我们希望服务器端可以和客户端一样能够渲染任意的视图。在客户端,模板渲染很简单,只需要对一个模板进行求值,并把输出插入到某个 DOM 元素即可。但在服务器端,同一个模板会被渲染成字符串并在响应中作为结果返回。

同构的视图渲染棘手的地方在于:客户端需要接手完成服务器端未完成的事情。这通常被称为从客户端到服务器端的过渡(client/server transition),也就是说,在浏览器加载完成后,客户端需要进行适当的转换,以免“破坏”了服务器端生成的DOM字符串。服务器需要把应用状态提取到一个对象中(称为dehydrate),并发送给客户端,随后客户端需要使用同一份的状态初始化应用,并把视图还原为服务器原本的状态(称为rehydrate)。

例2-1展示了一种典型的服务器响应,在页面的body部分渲染了某些标签,并且使用<script>标签输出经过序列化的状态。服务器把序列化状态放入到渲染的视图中,客户端需要对状态进行反序列化,并把状态和预先渲染的标签关联起来。

例2-1 引入服务器端渲染的标签与状态

<html>
  <body>
    <div>[[server_side_rendered_markup]]</div>
    <script>window.__state__=[[serialized_state]]</script>
    ...
  </body>
</html>

2.3 共享路由

大部分现代SPA框架都支持路由的概念,路由负责在用户跳转页面时跟踪用户状态。在SPA中,路由就是控制跳转事件、改变状态和页面视图、以及更新浏览器跳转历史的主要机制。而在同构应用中,我们同样需要一套路由配置(例如把URI的模式映射到路由控制器中),而且这套配置能够在服务器端和客户端之间方便地进行共享。

共享路由的难点在于路由控制器自身,因其经常需要访问环境相关的API来获取 URL 信息、HTTP 头部和 cookies 等。在服务器端,这些信息可以通过 request 对象的 API 取得,而在客户端则需要通过浏览器的 API 取得。

2.4 共享模型

模型通常被称为业务对象、域对象或者实体。模型通过移除状态存储并从恢复到 DOM 中,为数据建立了一种抽象。

在最简单的实现中,同构应用可以使用服务器端返回首屏响应之前一模一样的状态,对客户端应用进行初始化。(例如上述例子中的window.__state__

而在同构 JavaScript 图谱的一个极端中,服务器端和客户端共享状态与模型的定义规范,包括双向同步(对于这种实现,第4章将会进行更详细的探讨)。

2.5 API分类

2.5.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。

2.5.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 不是一个全局对象。为了在服务器端实现相同的重定向功能,我们必须在 response 对象中写入头部信息,包括一个指明 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');

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

我们有两种可选方案。(分别称为 shimmed for each environmentshimmed semantics

第一种是把重定向的逻辑分离到一个独立的模块中,而且这个模块需要关注当前的运行环境。应用的剩余代码只需要简单地调用该模块即可,从而实现具体环境的完全隔离:

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 对象是否存在,并根据情况判断是否使用它。

还有另一种方法是:在客户端简单地使用服务器端的 response 对象接口,但是需要进行 shim,实质上还是调用了 window 属性。通过这种方式,应用代码只需要永远调用 res.writeHead(...) 即可,但在浏览器中会把这个调用转为调用 window.location.href 属性。在本书的第二部分将会更详细地分析这种实现方式。(抽象程度比第一种方法稍微低一点。)