第 1 章 为什么需要同构 JavaScript

第 1 章 为什么需要同构 JavaScript

Jason Strimpel、Maxime Najim

2010 年,Twitter 对其网站进行了一次重构,并发布了新的版本。这个称为“#NewTwitter”的新版本将 UI 渲染和业务逻辑放在了 JavaScript 中,并在用户的浏览器中运行。这种架构在当时是开创性的。然而,不到两年的时间,Twitter 再次进行了重构,将渲染功能移回了服务器端。Twitter 的这次改版将页面的初始渲染时间缩短到了原来的五分之一(https://blog.twitter.com/2012/improving-performance-on-twittercom)。Twitter 的做法在 JavaScript 社区中引起了轰动。开发者和其他许多人很快意识到,客户端渲染对性能有着非常明显的影响。

 构建客户端 Web 应用的最大劣势在于,首次加载需要付出高昂的代价下载一个 JavaScript 大文件。互联网中的主要传输协议是 TCP(Transmission Control Protocol,传输控制协议),该协议定义了一种被称为慢启动(slow start)的拥塞控制机制,这意味着数据是以逐渐增加数据块的方式进行发送的。Ilya Grigorik 在《Web 性能权威指南》1 一书中解释了 TCP 协议如何经过“客户端与服务器端之间的 4 次往返……以及几百毫秒的延迟,才能达到 64KB 的吞吐量”。显然,发送给用户的前几千字节的数据对良好的用户体验和页面响应性至关重要。

1此书已由人民邮电出版社出版,http://www.ituring.com.cn/book/1194。——编者注

客户端 JavaScript 应用在初始化时只包含一个 <script> 标签和一个空的 <body> 标签,这类应用的崛起产生了一些问题:初始化加载速度慢、需要对 URL 进行 hashbang(#!)的特殊处理(随后将对此进行详细介绍),以及糟糕的搜索引擎检索性。通过将客户端和服务器端代码合二为一,同构 JavaScript 解决了这些问题。同构 JavaScript 提供了整合两种架构的能力,可以创建易于维护的、用户体验良好的应用。

1.1 定义同构JavaScript

简单来说,同构 JavaScript 应用就是在浏览器客户端和 Web 应用服务器端间共享同一套 JavaScript 代码的应用。从某种意义上讲,之所以称为同构,是因为无论在客户端还是在服务器端运行,应用都具有相同的形式或形态。同构 JavaScript 是 JavaScript 发展进程中的革命性一步。但就像钟摆一样,软件开发中的进步通常不稳定,来来回回。如果从事软件开发已有一段时间,那么你可能已经了解过一些时隐时现的设计方法。在某些情况下,我们似乎永远无法找到正确的平衡点。

近 20 年来,Web 应用的发展方式非常符合这一规律。我们见证了 Web 的演进——从最初简陋的蓝色超链接静态页到如今用户体验丰富、可以媲美成熟原生应用的平台。之所以能做到这一点,是因为 Web 的客户端 - 服务器模型迅速从重服务器端轻客户端的方式转变为轻服务器端重客户端的方式。然而,这种方式的转变导致了大量问题,我们将在本章后面具体讨论。就目前而言,可以简单地概括为我们需要在重客户端重服务器端之间取得平衡。为了真正了解这种平衡的意义,我们必须先后退一步,看看 Web 应用在过去几十年里是如何发展的。

1.2 评价其他的Web应用架构方案

要想理解同构 JavaScript 方案的由来,必须先了解这个方案出现时的状况。首先要确认主要的使用场景。

 第 2 章中介绍了两种类型的同构 JavaScript 应用并分析了其架构。本书探讨的同构 JavaScript 应用场景是电子商务相关的 Web 应用。

1.2.1 状况的改变

万维网(World Wide Web)的出现要归功于 Tim Berners Lee(https://www.w3.org/People/Berners-Lee/)。当时他在一个核研究机构中工作,并在一个名为 Enquire(https://en.wikipedia.org/wiki/ENQUIRE)的项目中尝试使用了超链接技术。1989 年,Tim 整理了超链接的概念,提议在一个提供文档链接的中央数据库中应用这项技术。随着时间推移,数据库变得越发庞大,对我们的日常生活(如通过社交媒体)和商业(电子商务)产生了巨大影响。我们这些青少年都深陷到这个虚拟的大商场中。丰富多样的内容和购物选项能够帮助我们在购买时作出明智的决定。在意识到消费者的选择多如牛毛后,企业非常关心我们能否找到并查看他们的内容与商品,因为他们的最终目标是提高转换率(让我们购物)。为此,甚至还出现了专门负责搜索引擎优化(search engine optimization,SEO)的专家,这些专家唯一的工作就是让企业的内容和商品出现在搜索结果前列。然而,有关转换率的斗争并没有到此结束。一旦消费者找到商品,页面必须能够快速加载并响应用户的交互操作,否则企业可能会将消费者拱手让给竞争对手。这正是我们身为工程师应当发挥作用的地方,而除了企业的关注点外,我们还有自己的一系列关注点。

1.2.2 工程上的关注点

作为工程师,我们也有自己的一些担忧,主要关于可维护性和效率,但这并不是说我们在权衡技术决策时不会考虑企业的关注点。事实上,优秀工程师的做法恰恰相反:他们会基于手头的业务问题权衡短期和长期的利弊,为每个可能发生的业务问题寻找最优解。

1.2.3 可选架构

考虑到我们的主要业务场景是电商应用,我们来看看在 Web 发展史中适用于该场景的几种架构。但在此之前,我们先要明确一些关键的评判标准,以便公正地评估不同的架构。以下标准是按照重要性排序的。

(1) 应用要能够收录在搜索引擎中。

(2) 应用的首屏加载速度应该是优化过的,也就是说,关键渲染路径(critical rendering path)应该属于初始响应的一部分。

(3) 应用要能够响应用户的交互操作(比如优化后的网页切换)。

 关键渲染路径指的是页面上与用户的主要操作相关的内容。在电子商务应用中,关键渲染路径是对商品的描述。对新闻网站来说,关键渲染路径则是一篇文章的内容。

在整个评估过程中,必须权衡这些业务标准和工程的主要关注点(可维护性和效率)。

  1. 传统的 Web 应用

    前面提到过,设计和创造 Web 的最初目的是共享信息。由于万维网的提出是以 Enquire 项目的成功作为前提的,因此 Web 在起步阶段仅仅用于多页文档的相互连接不足为奇。20 世纪 90 年代初,大部分的 Web 内容都是以完整的 HTML 页面的形式渲染的。当时支持这种方式的机制是 HTML、URI 和 HTTP(现在也依然如此)。HTML(Hypertext Markup Language,超文本标记语言)是一种标记规范,当浏览器解析标记时,会将其转换为文档对象模型。URI(Uniform Resource Identifier,统一资源标识符)用于标识资源的名称,即应该响应请求的服务器的名称。HTTP(Hypertext Transfer Protocol,超文本传输协议)是负责连接一切的传输协议。这三种机制为互联网提供了动力,并形成了传统 Web 应用的架构。

    传统的 Web 应用指的是:所有的标记——至少是关键渲染路径的标记——是通过服务器使用某种服务器端语言(如 PHP、Ruby、Java 等)进行渲染的,如图 1-1 所示。浏览器解析文档后,用于丰富用户体验的 JavaScript 代码会被初始化。

    {%}

    图 1-1:传统 Web 应用的流程

    简而言之,上图展示了传统 Web 应用的架构。我们来研究一下这个架构是否符合我们的评估标准和工程上的关注点。

    首先,它很容易被搜索引擎收录,因为当爬虫遍历应用时,所有的内容都是可爬取的,所以消费者是可以搜索到应用内容的。其次,页面加载也经过了优化,因为关键渲染路径的标记是通过服务器端进行渲染的,从而提高了感知的渲染速度,降低了用户跳出应用的可能性。然而,传统的 Web 应用只能满足上述三点要求中的两点。

     我们所说的“感知的渲染速度”是什么意思呢? Ilya Grigorik 在《Web 性能权威指南》一书中是这样解释的:“时间测量是客观的,而时间感知是主观的。我们可以通过设计来改善感知性能。”

    在传统的 Web 应用中,页面导航和数据传输都遵循 Web 原本设计的方式进行。当用户导航到一个新页面或者提交表单数据时,浏览器会发送请求、取得响应并解析完整的文档流,即使页面中只有部分信息改变了。这种方式在实现前两个评判标准时极为有效,但这种全页面安装和拆卸的生命周期的代价非常高,因此在响应性方面这只是一个次优解。因为有幸生活在拥有 Ajax 的年代,所以我们都已经知道还有比整页刷新更加高效的方法。但引入 Ajax 也会带来成本,我们将会在下一节中具体探讨。但在进入下一节之前,我们应该先看看在传统 Web 应用的背景下是如何应用 Ajax 的。

    Ajax 时代。星星之火可以燎原,而 XMLHttpRequesthttps://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest)正是点亮 Web 平台的星火。然而,这项技术在集成到传统 Web 应用中时并没有给人留下太深刻的印象,这并不是因为设计或者技术本身的原因,而是因为负责集成该技术到传统 Web 应用中的那些人缺乏使用经验。在大多数情况下,负责该工作的人都是刚开始专攻视图层的设计师。我自己是从行政助理转为设计师和开发者的。当时,我的这两项技能都不足。不用说,我对过去参与过的应用造成了很大的破坏(不过我认为这是我对平台演进的贡献)。不幸的是,在这段演进期,我和那些缺乏适当培训与指导的人们接触的所有应用都受尽了苦头——它们的过程重复、关注点混乱。有一个很好的例子可以突出这些问题,即相关商品的轮播组件(如图 1-2 所示)。

    {%}

    图 1-2:商品轮播组件示例

    (相关)商品轮播组件可以分页浏览产品。在某些情况下,所有产品都是预先加载的,但有时会因为商品数量太多而不能采用预加载。在第二种情况下,需要发起网络请求以获取下一页的商品信息。由于刷新整个页面的效率极低,因此典型的解决方案是在翻页时使用 Ajax 获取新的商品页面集。接下来可优化的是,只获取渲染页面集所需要的数据,这意味着你需要创建用于重复生成的模板、模型和静态资源,并在客户端进行渲染(如图 1-3 所示)。这种做法需要编写更多单元测试。这个例子非常简单,但如果将这种思想推广到大型应用中,你将发现应用会变得难以跟踪与维护——你不能轻易地推断出应用是如何结束在某个特定状态的。此外,重复编写渲染逻辑是一种资源浪费,而且在添加或者修改功能时,同时操作两份 UI 代码会导致应用出现 bug 的概率增高。

    {%}

    图 1-3:使用 Ajax 的传统 Web 应用流程

    由于启用了 Ajax,再加上看似美好的初衷,导致了 UI/ 视图层的分裂与复制,一个看似精心构造的应用就此化作瓦砾,从而让无数工程师遭受了挫折。好在工程师在沮丧的时候通常是最有创造力的。正是这种挫折推动了创新,再结合工程师扎实的工程技能,便造就了下一代应用架构。

  2. 单页面 Web 应用

    一切事物都有自己的循环周期。在 Web 开始阶段时流行的轻客户端可能给了 Sun Microsystems 的 NetWork Terminal(NeWT,https://en.wikipedia.org/wiki/Sun_Ray)以启发。但到了 2011 年,Web 应用开始放弃轻客户端模型,并过渡到重客户端模型,而操作系统领域在多年以前就发生过这样的变化。巨石已经浮出水面。这就是单页面应用架构的黎明。

    通过将渲染工作完全转移到客户端来进行,SPA 解决了一直以来困扰着传统 Web 应用的问题。该模型将应用逻辑从数据检索中抽离出来,并将 UI 代码整合为单一语言和运行时,因此可以有效地减少服务器端的压力(如图 1-4 所示)。

    {%}

    图 1-4:单页面应用的流程

    之所以能减少服务器端的压力,是因为服务器先将一份包含了静态资源、JavaScript 和模板的静荷数据(payload)发送到了客户端。之后,客户端只需要获取渲染页面或视图所需要的数据即可。这种行为显著提高了页面的渲染效果,因为避免了在用户请求新页面或提交数据时重新请求并解析页面的性能开销。除了性能收益外,这种模型还解决了将 Ajax 引入传统 Web 应用中所产生的工程问题。

    现在回到之前讨论的产品轮播组件示例,第一页(相关)产品的信息在以前是由应用服务器渲染的。在翻页时,客户端负责发起随后的请求并进行渲染。在现代 Web 平台中,这种职责的模糊界限和工作的重叠正是传统 Web 应用面临的主要问题。但这些问题在 SPA 中将不复存在。

    在 SPA 中,服务器端和客户端之间存在明确的界限。API 服务器响应数据请求,应用服务器提供静态资源,而客户端则负责展示。在这个产品轮播的例子中,应用服务器会向浏览器发送一份仅包含 JavaScript 静荷数据和模板资源的空文档。客户端应用在浏览器中进行初始化并向服务器请求渲染视图所需要的数据,视图中包含了轮播组件。收到数据后,客户端应用将会为这次轮播渲染产品的第一组集合。在翻页时,请求数据和渲染的生命周期会再次重复,并且复用同一段代码路径。这确实是一种优秀的工程解决方案,但问题是,这种方案并不能在任何时候都提供最佳的用户体验。

    在 SPA 中,终端用户感知到的初始页面加载速度可能会非常缓慢,因为用户必须等到数据请求完成才能看见页面渲染。因此,在页面加载时,用户最多只能看到加载指示器动画,而不能立即看到内容。针对渲染延迟的问题,一种常见的折中方案是,为初始页面的数据提供专门的数据优化服务。但这样做就需要编写额外的服务器端应用逻辑,从而导致两端职责范围再次变得模糊,还需要额外维护另外一层代码。

    SPA 面临的第二个问题关系到用户体验和企业利益。在默认情况下,SPA 对 SEO 不友好,这意味着用户不能通过搜索引擎找到与应用相关的内容。这个问题源于 SPA 利用了 hash 片段实现路由。在分析这种方式为什么会影响 SEO 之前,我们先看看 SPA 路由的机制。

    SPA 依赖 hash 片段将人造的 URI 路径映射到路由处理器中,该处理器会渲染对应的视图。举个例子,在传统的 Web 应用中,“关于我们”的页面 URI 可能是 http://domain.com/about,但在 SPA 中则可能是 http://domain.com/#about。SPA 在 URL 的末尾添加了一个 # 号和一个片段标识符。SPA 路由之所以要利用 hash 片段,是因为片段的内容发生变化时,浏览器不会像 URI 发生变化时那样发起新的网络请求。这一点至关重要,因为 SPA 的整个大前提就是只请求页面或视图渲染所需要的数据,而不是为每一个页面获取并解析整份文档。

    SPA 片段对 SEO 不友好的原因是,hash 片段不会作为 HTTP 请求中的一部分发送给服务器(按照规范定义)。对于 Web 爬虫而言,http://domain.com/#abouthttp://domain.com/#faqs 是同一个页面。好在谷歌定义了一种变通方案,为 hash 片段提供了 SEO 支持,这个方案就是使用“#!”(hashbang)。

     大多数的 SPA 库目前已经支持 History API(https://developer.mozilla.org/en-US/docs/Web/API/History),并且谷歌的爬虫最近对于索引 JavaScript 应用提供了更好的支持——在此之前,JavaScript 代码甚至不会被 Web 爬虫所执行。

    按照谷歌的规定,其基本前提是将 SPA 路由片段中的“#”替换为“#!”,因此 http://domain.com/#about 需要更改为 http://domain.com/#!about。这样一来,谷歌的爬虫才能确定这个页面的内容需要被索引,而不仅仅是简单地锚点。

     锚点标签用于在文档内部创建内容链接。

    随后,爬虫将这个链接转换为完全合格的 URI 版本,因此 http://domain.com/#!about 会变成 http://domain.com/?query&_escaped_fragment=about。然后,服务器端负责将 SPA 对应的屏幕快照提供给爬虫。图 1-5 展示了该过程。

    {%}

    图 1-5:爬虫收录 SPA URI 的过程

    此时,SPA 的价值主张愈发下降了。从工程角度来说,需要在下列方案中二选一。

    (1) 在服务器中运行一个无界面的浏览器,如 PhantomJS(http://phantomjs.org/),用于在服务器中运行 SPA 并响应爬虫请求。

    (2) 将这个问题外包给第三方供应商解决,如 BromBone(http://www.brombone.com/)。

    这两种修复方案都需要成本,而且还不包括前面提及的首屏渲染不理想的成本。好在工程师都善于解决问题。正如从传统 Web 应用到 SPA 的改进,新一代架构诞生了,也就是同构 JavaScript。

  3. 同构 JavaScript 应用

    同构 JavaScript 应用是传统 Web 应用和 SPA 架构的完美结合。同构应用具备以下优势。

    • SEO 默认支持使用完全合法的 URL——不再需要“#!”的变通方案了——通过 History API 进行跳转,在不支持 History API 的浏览器中可以优雅地回退到服务器端渲染模式。
    • 在支持 History API 的浏览器中,后续的页面请求使用了 SPA 模型的分布式渲染。这种实现还可以减轻服务器的负载。
    • 对于同一个渲染周期,客户端和服务器端可以重用同一套代码。这意味着我们不需要重复劳动,也不会让界限变得模糊。这可以在降低 UI 开发成本与 bug 数量的同时,提高团队的开发速度。
    • 通过在服务器端渲染首屏页面提高加载速度。用户不再需要在首屏渲染之前等待网络请求完成和一直看着加载指示器动画了。
    • 纯 JavaScript 技术栈,这意味着应用界面的代码(https://www.nczonline.net/blog/2013/10/07/node-js-and-the-new-web-front-end/)可以由前端工程师单独维护,而无须经过后端工程师。关注点和责任更清晰地分离,这使得每个人都可以只在自己擅长的领域贡献代码,从而做到术业有专攻。

    同构 JavaScript 架构可以同时满足本节前面提到的三个评判标准。同构 JavaScript 应用可以轻松地被所有的搜索引擎收录,并能优化网页加载速度和页面之间的过渡(适用于支持 History API 的浏览器,而在老版本浏览器中可以优雅地降级,不会对应用架构产生影响)。

1.3 附加说明:何时不使用同构

像 Yahoo!、Facebook、Netflix 和 Airbnb 这些公司已经接受了同构 JavaScript。然而,同构 JavaScript 架构可能仅仅适用于某些类型的应用。正如我们将在本书中探索的那样,同构 JavaScript 应用需要更多架构上的考虑,实现上也存在一定的复杂度。对于 SPA 来说,如果性能要求不高或者没有 SEO 需求(比如需要登录后才能使用),同构 JavaScript 带来的麻烦似乎远大于收益。

此外,很多公司和组织可能还没准备在服务器上操作和维护一个 JavaScript 的执行引擎。例如,大量使用 Java、Ruby、Python、PHP 的组织可能并不知道如何在生产环境中对一个 JavaScript 应用服务器(如 Node.js)进行监控与故障诊断。在这些情况下,同构 JavaScript 可能会引起难以承受的额外操作成本。

 Node.js 提供了一个出色的服务器端 JavaScript 运行时。对于使用了 Java、 Ruby、Python 或者 PHP 的服务器来说,有两种主要的候选方案:一是在正常的服务器之外再运行一个 Node.js 进程,将后者作为本地或者远程的“渲染服务”;二是使用嵌入式 JavaScript 引擎(比如集成在 Java 8 中的 Nashorn)。然而,这两种方案都有明显的缺点。运行 Node.js 作为渲染服务需要在进行 socket 通信时序列化数据,这带来了额外的开销。同样,在其他语言中使用的嵌入式 JavaScript 引擎通常是没有经过优化的,可能会导致额外的性能问题(尽管这会随着时间的推移得到改善)。

如果你的项目或者公司不需要借助同构 JavaScript 架构提供的便利(如本章所述),请务必针对具体工作选择合适的技术。然而,当服务器端渲染不在你的选择范围之内,而你又需要关注首屏加载速度和搜索引擎优化时,别担心,本书可以帮到你。

1.4 小结

我们在本章中定义了同构 JavaScript 应用——在浏览器客户端以及 Web 应用服务器端共享同一套 JavaScript 代码的应用——并确定了本书中主要讨论的同构 JavaScript 应用类型是电商应用。随后,我们回顾了 Web 的发展历史并研究了其他架构的发展历程,通过 SEO 支持、首屏加载速度优化和页面过渡效果优化这三个关键的验收标准评估了这些架构。我们看到,同构 JavaScript 出现之前的架构不能满足所有的验收标准。在本章最后,我们将传统 Web 应用和 SPA 的优势结合起来,得到了同构 JavaScript 应用架构。

目录