作为开发者,我们经常要面对会影响应用程序整体架构的决策。Web开发者必须作出的一个重要决策是,在应用程序的何处实现逻辑和渲染。这会是一个难题,因为有非常多不同的方式去构建一个网站。

过去几年,通过我们和大型网站的交流,对这一方面有了更深的了解。广泛地讲,我们鼓励开发者在rehydration方法上考虑服务端渲染或者静态渲染。

为了更好的理解我们作出决定时可以选择的架构,我们需要对每一种方法和一致性的术语有扎实的理解。这些方法之间的差异有助于说明Web渲染在性能方面的权衡。

术语(terminology)

渲染(Rendering)

  • SSR:Server-Side Rendering - 在服务器上将客户端或通用应用渲染成HTML。
  • CSR:Client-Side Rendering - 在浏览器中,通常使用DOM渲染应用。
  • Rehydration:在客户端渲染Javascript视图,可以重新使用服务端渲染的HTML的DOM树和数据。
  • Prerendering:运行一个客户端应用,在构建时捕获它的初始状态作为静态HTML。

性能(Performance)

  • TTFB:Time to First Byte - 从点击一个链接到第一比特内容返回之间的时间。
  • FP:First Paint - 用户第一次看到页面像素所用的时间。
  • FCP:First Contentful Paint - 可以看到请求的内容所花费的时间(文章正文等)。
  • TTI:Time To Interactive - 页面可交互所花费的时间(事件等)。

服务端渲染

服务端渲染在服务端生成了完整的HTML页面作为对浏览器的响应。这避免了额外的请求往返用于客户端数据获取和模板解析,因为在浏览器获取响应之前已经处理完毕。

服务端渲染通常会生成快速的First Paint和First Contentful Paint。在服务端运行页面逻辑和渲染避免了向客户端发送大量JavaScript代码,这样可以有利于实现快速的Time to Interactive(TTI)。这非常有意义,因为使用服务端渲染,你只需要发送文本和链接到用户的浏览器。这种方法可以在大范围的设备和网络条件下很好地工作,并且带来很有趣的浏览器优化,例如流文本解析。

通过服务端渲染,用户不会在能够使用网站前,还要等待JavaScript文件执行。即使无法避免第三方JS文件,服务单渲染减少自己的JavaScript代码的开销,可以给你更多的”budget”。然而,这个方法还是有一个主要的缺陷:服务端生成页面,会花费时间,这会导致Time to First Byte 更慢。

服务端渲染是否满足你的应用程序的需要,很大程度上取决于你要构建那种类型的用户体验。关于服务端渲染与客户端渲染的正确应用存在长期的争论,但是请务必记住,你可以针对某些页面使用服务端渲染,其他的页面则不能使用。一些站点已经成功采用了混合渲染技术。Netflix服务端渲染相对静态的登录页,同事对于交互繁重的页面预先获取JS,从而为这些较重的客户端渲染页面提供了更好的快速加载机会。

很多现代框架、库和架构都可以在客户端和服务端渲染相同的应用。这些技术可以用于服务端渲染,但是需要特别注意,在服务端和客户端同时渲染的不同架构都有自己的解决方案,这些架构在性能特征和权衡方面差异很大。React用户可以使用renderToString() 或在其之上构建的解决方案(例如Next.js)进行服务端渲染。Vue用户可以参看Vue’s server rendering guideNuxt。Angular有Universal。大多数流行的解决方案都采用了某种形式的hydration。所以在选择工具前,需要了解这个方法的使用。

静态渲染

静态渲染发生在构建时,并且提供了快速的First Paint,First Contentful Paint 和 Time to Interaction–假设客户端JS的数量有限。与服务端渲染不同,由于不必动态生成HTML页面,他可以实现始终如一的快速Time To First Byte。通常,静态渲染意味着提前为每个URL生成独立的HTML文件。通过预先生成的HTML响应,静态渲染可以部署到多个CDN,以利用边缘缓存的优势。

静态渲染的解决方案有各种形状和尺寸。像Gatsby这样的工具,旨在让开发者感觉他们的应用是动态生成的,而不是按照构建步骤生成的。其他的像Jekyll 和 Metalsmith 拥抱他们的静态本质,提供了更多模板驱动方法。

静态渲染的一个缺陷:必须为每一个可能的链接生成独立的HTML文件。当你无法预知这些URLs或者站点具有大量独特页面时,静态渲染会面临巨大挑战,甚至不可行。

React用户可能熟悉Gatsby,Next.js static export 或 Navi。这些工具使组件编写更加方便。但是,最重要的是了解静态渲染和预渲染制之间的区别:静态渲染的页面时交互式的,无需执行大量客户端JS;而预渲染则改善了单页面应用的 First Paint 或 First Contentful Paint,该应用必须在客户端启动才能让页面真正可交互。

如果你不确定一个解决方案是静态渲染还是预渲染,可以尝试一下测试:禁用JavaScript并加载创建的页面。对于静态渲染页面,大多数功能在不启用JavaScript的情况下仍然存在。对于预渲染页面,仍然可能存在一些基础功能,像链接,但是大多数页面都是无效的。

另一个有用的测试是使用Chrome DevTools减低网络速度,然后观察在页面可交互前,下载了多少JavaScript。通常预加载需要更多的JavaScript才能进行交互,并且JavaScript相比静态渲染使用的渐进式增强方法往往更加复杂。

服务端渲染 vs 静态渲染

服务端渲染不是灵丹妙药——它的动态特性可能带来巨大的计算开销成本。许多服务端渲染解决方案不会提早刷新,可能会延迟TTFB或者发送双倍的数据(例如客户端js使用内联方式)。在React中,renderToString() 由于它的同步和单线程会很慢。让服务器渲染更有效,需要寻找或构件组件缓存解决方案,管理内存消耗,应用缓存技术和其他许多问题。通常你需要多次处理、重建同一个应用——一次在客户端,一次在服务端。仅仅因为服务端渲染可以是某些事情更快地展现,并不意味着你要做更少的工作。

服务端渲染会按每个URL的须有生成HTML,但要比只提供静态渲染内容慢。如果你可以进行其他工作,则服务端渲染+HTML缓存可以大大减少服务端渲染时间。服务端渲染的优势相比静态渲染在于,可以提取更多的实时数据并响应更完整的请求。请求个性化的页面是一个请求类型的具体实例,静态渲染就无法很好地处理这个例子。

服务器渲染还可以在构建PWA时呈现有趣的决定。受用全页面service worker缓存 还是 仅通过服务端渲染个别内容?

客户端渲染(CSR)

客户端渲染意味着直接在浏览器中使用JavaScript渲染页面。所有的逻辑,数据获取,模板解析和路由都在客户端处理,而不是服务端。

客户端渲染对于移动端很难保证可快速响应。如果项目很小、保持很小的JavaScript预算、尽可能少的RTTs,可以达到纯服务端渲染的性能。重要的脚本和数据可以使用HTTP/2 Server Push 或者 <link rel='preload'> 更快地交付,这可以更快的完成解析工作。诸如PRPL的模式值得评估,以确保初始和后续的导航是即时的。

客户端渲染主要的不足是随着应用程序的增长,所需JavaScript的数量也会增长。通过添加新的JavaScript库、polyfills和第三方代码,将变得尤其困难,这些第三方代码会竞争计算能力,通常要在执行完成才能渲染页面内容。基于客户端渲染并且依赖大量JavaScript包的用户体验,需要考虑主动的代码拆分,确保可以懒加载JavaScript——仅在需要时提供你所需的服务。针对交互很少或没有的体验,服务端渲染针对这些问题提供了更具拓展性的解决方案。

对于构建SPA应用的人们,识别大多数页面共享的用户界面核心部分意味着你可以应用Application Shell Caching。结合service workers,可以显著提升重复访问的感知性能。

通过rehydration组合SSR和CSR

通常被称为Universal Rendering 或者 SSR,这个方法尝试同时采用客户端渲染和服务端渲染来达到一种平衡。浏览器请求(例如加载整个页面或重新加载)由服务器处理,服务器将应用渲染成HTML,然后将用于渲染的JavaScript和数据嵌入到生成的文档中。如果仔细实施,可以向服务端渲染一样实现快速的First Contentful Paint,然后使用rehydration技术在客户端再次渲染,从而提升页面响应速度。这是一个新颖的解决方案,但是具有一些相当大的性能缺陷。

基于rehydration的SSR主要缺陷是:会对Time to Interactive产生重大的负面影响,即使提升了First Paint。SSR页面通常看起来已加载完成并且可以进行交互,但是实际上在执行完客户端脚本并添加事件处理前,是无法响应用户输入的。这在移动设备上会花费数秒,甚至几分钟。

或许你已经亲身体验过——在看起来页面已经加载完毕之后的一段时间内,单击或点击没有反应。这很快就会让人沮丧…为什么没有效果?为什么不能滑动?

Rehydration的问题:一个应用,双倍的代价

Rehydration问题常常比js导致的延迟交互更加糟糕。为了使客户端JavaScript可以更准确的获取服务端用于渲染HTML的数据而不必重新向服务端请求,当前的SSR解决方案通常会将UI的依赖数据进行序列化放进文档中的script标签内。这样生成的HTML文本包含大量的重复内容

此处省略一张图。。。可看原文

正如你看到的,服务端返回了应用UI的描述作为浏览器请求的响应,但是服务端也返回了生成UI的源数据,以及随后在客户端启动的UI执行的完整副本。只有bundle.js完成加载和执行,UI才能变得可交互。

来自基于SSR Rehydration的真实网站的性能指标表明,强烈建议不要使用它。最终,原因归结于用户体验:非常容易使用户陷入一种不可思议的境地。

不过,基于rehydration的SSR还是有希望的。在短期内,只对高度可缓存的内容使用SSR可以降低TTFB延时,从而产生与预渲染相似的效果。渐进式的Rehydration可能是未来这项技术更可行的关键。

流式服务端渲染 和 Progressive Rehydration(渐进式)

过去几年,服务端渲染已经获得了很大的发展。

流式服务端渲染允许你分块发送HTML,浏览器可以在接收时逐步进行渲染。这可以提供快速的First Paint和First Contentful Paint,随着markup更快地到达用户端。在React中,相比同步的renderToString(),在renderToNodeStream()中异步流意味着可以更好的处理背压。

Progressive rehydration 值得关注,React一直在探索一些东西。使用这种方法,服务端渲染应用的各个部分会随着时间逐步启动,而不像当前通用方法一次性初始化整个应用。这可以帮助减少页面实现可交互所需的JavaScript数量。因为页面低优先级的部分被推迟加载,避免阻塞主线程。它还可以帮助避免最常见的SSR Rehydration陷阱,服务端渲染的DOM树被破坏后立即重建——通常是因为初始化同步的客户端渲染所请求的数据还没有准备好,可能要等待Promise的解决方案。

Partial Rehydration
Partial Rehydration已经被证明很难实施。这个方法时Progressive Rehydration思想的一个拓展,在其中各个部分需要被分析,确定哪些部分是交互性少的或者没有交互性的。对于每个静态部分,将相应的JavaScript代码转化为惰性引用和装饰性功能,从而减少客户端脚本减小到0。Partial Rehydration方案提出了问题和折中方案。他给缓存带了挑战,客户端浏览器意味着我们不能在没有整个页面加载的情况下,假设为应用惰性部分服务端渲染的HTML不可用。

Trisomorphic Rendering
如果service workers 作为一个选项,trisomorhoic rendering或许会是有趣的。它可以让你使用流式服务端渲染初始/非JS导航,完成之后,让你的service workers渲染HTML。这可以使缓存的组件和模板保持更新,并在启用SPA-style导航为了在同一个会话中渲染新的视图。这个方法非常有效,当你可以分享相同的模板和路由代码在服务器、客户端页和service worker之间。

SEO注意事项

选择Web渲染策略时,团队通常会考虑SEO的影响。通常选择服务端渲染来提供爬虫可以轻松获取的完整页面。爬虫或许理解JavaScript,但是在渲染方式上的局限性值得我们注意。客户端渲染可以起作用,但需要额外的测试和leg-work。最近,如果你的架构受客户端JavaScript驱动,动态渲染也成为值得考虑的选项。

参考