如何将 Web 主页性能提升十倍以上?

最近,我们将 Universe.com 主页的性能提升了十倍以上。在本文中,我们将解析实现这一重大改进的具体技术手段。

但在开始之前,让我们先对网络性能的重要意义进行一番论证(博文末尾提供相关案例研究链接):

  • 用户体验: 糟糕的性能可能导致响应失败,从 UI 与 UX 的角度来看,这可能会引发用户的沮丧情绪。

  • 客户转化与收入: 网站速度缓慢通常会导致客户流失,并对转化率与收入产生负面影响。

  • SEO: 从 2019 年 7 月 1 日开始,谷歌公司开始在全部新网站上默认启用移动优先索引。如果网站在移动设备上运行缓慢,且没有针对移动设备进行内容格式调整,那么网站的搜索排名将会降低。

在本篇文章中,我们将简要介绍以下几大有助于我们提高页面性能的主要领域:

  • 性能测量: 实验室与现场工具测量。

  • 渲染: 客户端与服务器端渲染、预渲染以及混合渲染方法。

  • 网络: CDN、缓存、GraphQL 缓存、编码、HTTP/2 以及 Server Push。

  • 浏览器中的 JavaScript: 数据包大小预算、代码拆分、async 与 defer 脚本、图像优化(WebP、延迟加载、渐进式设计)以及资源提示(preload、prefetch 与 preconnect)。

这里再介绍一点我们的情况:我们的主页由 React(TypeScript)、Phoenix(Elixir)、Puppeteer(headless Chrome)以及 GraphQL API(Ruby on Rails)构建而成。以下为主页在移动设备上显示的效果:

Universe 主页与浏览效果

性能测量

没有数据作为支持,一切意见都将毫无意义。— W. Edwards Deming

实验室工具

实验室工具能够立足受控环境从预定义的设备及网络设置中收集数据。利用这些工具,我们能够轻松调试任何性能问题并实现良好的可重复测试。

Lighthouse 就是一款立足本地计算机对 Chrome 内网页进行审计的出色工具。其能够提供一系列关于如何提高性能、可访问性以及搜索引擎优化的实用性提示。下面,我们来看模拟高速 3G 加 4x CPU 场景下的 Lighthouse 性能审计报告:

之前与之后:首屏内容填充(简称 FCP)性能实现 10 倍提升

然而,单纯使用实验室工具也会带来不少弊端:这类工具不一定能准确反映出最终用户所面临的设备、网络、位置以及多种其它现实因素造成的性能瓶颈。正因为如此,我们才需要配合现场工具进行补充。

现场工具

现场工具允许我们模拟并测量用户的真实页面负载。目前有多种服务可帮助大家从实际设备当中获取真实性能数据:

  • WebPageTest — 允许用户立足不同位置上的实际设备对不同浏览器进行性能测试。

  • Test My Site — 使用 Chrome 用户体验报告 (CrUX) 功能,并以 Chrome 使用情况统计为基础;这款工具公开可用且每月更新一次。

  • PageSpeed Insights — 将实验室(Lighthouse)与现场(CrUX)数据加以结合。

    错误! 未指定文件名。

WebPageTest 报告

渲染

内容的渲染可通过多种方法实现,其中每一种都拥有独特的优势与缺点:

  • 服务器端渲染 (SSR) 是指在服务器端为浏览器提供最终 HTML 文档的过程。优势:搜索引擎可以直接抓取网站而无需执行 JavaScript(SEO)、快速初始页面加载、代码仅存在于服务器端。短板:非富网站交互、整页重新加载、浏览器功能受限。

  • 客户端渲染是指利用 JavaScript 在浏览器当中进行内容渲染的过程。优势:富网站交互、在初始加载后可快速呈现路由变更内容、支持现代浏览器功能(例如配合 Service Workers 实现离线支持)。短板:SEO 友好性差、初始页面加载缓慢、通常需要在服务器端实现单页面应用程序(SPA)与 API。

  • 预渲染类似于服务器端渲染方法,但渲染会提前发生在构建时而非运行时。优势:built 静态支持文件通常比服务器运行方法更简单、SEO 友好性高、快速初始页面加载。短板:需要在执行任何代码变更时提前进行完整页面重新加载、非富网站交互、浏览器功能访问限制。

客户端渲染

以前,我们将自己的主页与 Ember.js 框架一同实现为采用客户端渲染方法的单页面应用。但这种作法的一大问题在于,我们的 Ember.js 应用程序包过大。这意味着在浏览器下载 JavaScript 文件并对其进行解析、编译与执行的过程中,用户只能对着空白屏幕发呆:

最要命的空白屏幕

因此,我们决定利用 React 重构应用当中的某些部分。

  • 我们的开发人员已经非常熟悉 React 应用程序的构建方法(例如嵌入式功能部件)。

  • 我们已经拥有多个 React 组件库可在多个项目间随意共享。

  • 新的页面中将可包含一些交互式 UI 元素。

  • 庞大的 React 生态系统能够提供多种工具方案。

  • 利用浏览器中的 JavaScript,我们可以通过多项强大功能构建起渐进式 Web 应用。

预渲染与服务器端渲染

客户端渲染应用程序的具体构建——例如采用 React Router DOM,仍然会带来与 Ember.js 相同的问题。JavaScript 需要占用大量资源,而且访问者需要经历一段首屏内容填充周期才能看到实际内容。

因此在决定使用 React 之后,我们开始尝试其它潜在的渲染选项,以确保浏览器能够更快地完成内容渲染。

使用 React 时的常规渲染选项

  • Gatsby.js 允许我们利用 React 与 GraphQL 构建预渲染页面。Gatsby.js 是一款强大的工具,能够直接提供多种性能优化方案。然而,预渲染方法并不适合我们的需求,因为我们的网站中可能存在无数包含用户生成内容的页面。

  • Next.js 是一套高人气 Node.js 框架,允许用户通过 React 实现服务器端渲染。然而,Next.js 设定了太多条条框框,要求用户使用它提供的路由机制以及 CSS 解决方案等等。另外,我们的现有组件库是专为浏览器构建的,与 Node.js 并不兼容。

因此,我们打算尝试一下混合方法,即发挥每一种渲染选项中的独特优势。

运行时预渲染

Puppeteer 是一套 Node.js 库,允许用户使用 headless Chrome。我们希望尝试利用 Puppeteer 在运行时当中实现预渲染。这代表着一种有趣的混合方法:利用 Puppeteer 进行服务器端渲染,同时利用 hydration 进行客户端渲染。感兴趣的朋友可以点击此处查看谷歌提供的关于如何利用 headless 浏览器进行服务器端渲染的相关提示。

利用 Puppeteer 对 React 应用程序进行运行时预渲染

这种方法具备以下优势:

  • 允许 SSR,因此有利于 SEO 优化。抓取程序不需要执行 JavaScript 即可看到网页内容。

  • 允许一次性构建起简单的浏览器 React 应用程序,而后将其同时用于服务器端与浏览器内。这将同时提高浏览器应用与 SSR 的速度表现,一举两得。

  • 利用 Puppeteer 在服务器端渲染页面,在速度上一般快于在最终用户的移动设备上进行渲染(前者网络连接更强、硬件配置也更高)。

  • Hydration 允许我们构建起富 SPA,并可访问 JavaScript 的浏览器功能。

  • 我们不再需要预先了解所有可能被调用的页面,也不需要预先进行渲染。

但在采用这种方法的过程中,我们也遇到了一些挑战:

  • 吞吐量是最主要的问题。每项请求都会在单独的 headless 浏览器进程当中占用大量资源。虽然我们可以使用单一 headless 浏览器进程并在其中的各个选项卡内运行多项请求,但使用多个选项卡仍会降低整个进程的性能水平。

利用 Puppeteer 的服务器端渲染架构

• 稳定性。对众多 headless 浏览器进行规模伸缩,同时保持进程不致过热并实现负载均衡绝对是一项高难挑战。我们尝试了不同的托管方法,包括在 Kubernetes 集群内进行自托管,以及利用 AWS Lambda 与 Google Cloud Functions 实现无服务器计算。我们注意到,后一种方法在配合 Puppeteer 时存在一些性能问题:

在配合 AWS Lambdas 与 GCP Functions 时,Puppeteer 的响应时间结果

随着我们对 Puppeteer 熟悉程度的逐步提升,我们开始对初始方法进行迭代(后文将具体说明)。我们还进行了其它一系列有趣的实验,希望通过 headless 浏览器渲染 PDF。再有,即使不编写任何代码,我们也能够利用 Puppeteer 自动进行端到端测试。而且除了 Chrome 之外,Puppeteer 现在还支持 Firefox 浏览器。

混合渲染方法

在运行时中使用 Puppeteer 并非易事。正因为如此,我们才决定在构建时中加以使用,同时配合一款工具用于在运行时内从服务器端获取用户生成的实际内容。很明显,这款工具必须拥有比 Puppeteer 更强大的稳定性与吞吐能力。

我们决定使用 Elixir 编程语言。Elixir 看起来与 Ruby 非常相似,但运行在 BEAM(Erlang VM)之上。顺带一提,BEAM 专门为构建高容错、高稳定性系统而生。

Elixir 采用 Actor 并发模型。每个“Actor”(即 Elixir 进程)的内存占用量都非常有限,仅为 1 到 2 KB。这意味着系统将能够同时运行成千上万个独立的进程。Phoenix 则是一套 Elixir Web 框架,能够支持高吞吐量,并允许开发者在各个独立的 Exlixir 进程当中处理各项 HTTP 请求。

我们将上述方法结合起来,充分利用其各自优势,希望能够切实满足自身需求:

Puppeteer 用于实现预渲染,Phoenix 则用于实现服务器端渲染

  • Puppeteer 在构建时中按照我们预期的方式对 React 页面进行预渲染,并将结果保存为 HTML 文件(来自 PRPL 模式的 app shell)。

我们可以继续构建一款简单的浏览器 React 应用程序,并在无需等待最终用户设备 JavaScript 处理过程的同时获得快速初始页面加载效果。

  • 我们的 Phoenix 应用负责实现页面预渲染,并以动态方式将实际内容注入至 HTML。

    这就使得内容的 SEO 友好性大幅提升,让按需处理大量多种页面成为可能,并显著降低了扩展难度。

  • 客户端接收并立即开始显示 HTML,而后由 Hydration 将 React DOM 状态持续作为常规 SPA。

    如此一来,我们就构建起了高度交互的应用程序,并可访问各项 JavaScript 浏览器功能。

利用 Puppeteer 建立预渲染架构,利用 Phoenix 进行服务器端渲染,React 则在客户端上实现 hydration

网络

内容交付网络 (CDN)

利用 CDN 可帮助我们实现内容缓存,并加速其在全球范围内的交付速度。 我们选择了 Fastly.com ,其目前处理着全球超过 10% 的请求总量,并得到 GitHub、Stripe、Airbnb 以及 Twitter 等诸多厂商的青睐。

Fastly 允许我们编写定制化缓存,并可利用 VCL 配置语言建立路由逻辑。下面,我们将具体聊聊基础请求流如何根据路由、请求头等因素分步起效:

VCL 请求流

提高性能的另一个选项是配合 Fastly 在边缘位置使用 WebAssembly(WASM)。大家可以将其视为一种无服务器模式,只是处于边缘位置;所使用的语言则包括 C、Rust、Go 以及 TypeScript 等等。Cloudflare 就拥有一个类似的项目,用于在 Workers 上支持 WASM。

缓存

尽可能多地利用缓存处理请求是改善性能水平的关键所在。立足 CDN 层级进行缓存,将能够更快地为新用户提供响应。而通过发送 Cache-Control 头进行缓存,则可加快浏览器中重复请求的响应速度。

大多数构建工具(例如 Webpack)允许用户向文件名当中添加哈希值。由于指向这些文件的任何变更都会产生新的输出文件名,因此大家可以安心将文件添加至缓存当中。

通过 HTTP/2 进行文件缓存与编码

GraphQL 缓存

发送 GraphQL 请求的一种常见方法,就是利用 POST HTTP 方法。而我们选择了立足 Fastly 层级对部分 GraphQL 请求进行缓存:

  • 我们的 React 应用会标注出那些可进行缓存的 GraphQL 查询。

  • 在发送 HTTP 请求之前,我们以请求本体为基础构建一条附加 URL 参数,其中包含 GraphQL 查询与变量(我们配合 Apollo Client 使用自定义 fetch)。

  • 在默认情况下 ,Varnish(与 Fastly)会使用完整的 URL 作为缓存密钥的一部分。

  • 这意味着我们可以通过请求本体当中的 GraphQL 查询不断发送 POST 请求,并在无需接触服务器的前提下立足边缘位置完成缓存。

利用一条 SHA256 URL 参数发送 POST GraphQL 请求

以下是其它一些值得参考的潜在 GraphQL 缓存策略:

  • 服务器端缓存:立足解析器层级或者通过模式标注对全部 GraphQL 请求进行缓存。

  • 利用持久化 GraphQL 查询并发送 GET /graphql/:queryId 以使用 HTTP 缓存机制。

  • 利用自动化工具(例如 Apollo Server 2.0)或者 GraphQL 专用型 CDN(例如 FastQL)实现不同 CDN 的整合。

编码

目前,所有主流浏览器都支持利用 gzip 加 Content-Encoding 标头进行数据压缩。这意味着面向浏览器的发送数据量更低,从而带来更快的内容传递速度。此外,如果浏览器支持,大家也可以尝试使用效率更高的 brotli 压缩算法。

HTTP/2 协议

HTTP/2 是 HTTP 网络协议的新版本(DevConsole 中简称为 h2)。由于存在着以下几项与 HTTP/1.x 版本间的显著差别,切换至 HTTP/2 能够带来性能提升:

  • HTTP/2 为二进制,而非文本式。因此其解析效率更高,也更加紧凑。

  • HTTP/2 具有多路复用属性,这意味着 HTTP/2 可以通过单一 TCP 连接发送多项请求。如此一来,我们就不必担心每主机浏览器连接限制以及域名分片等问题。

  • 其利用标头压缩机制减少请求 / 响应的实际体积。

  • 允许服务器主动推送响应。这项功能拥有诸多有趣的实际应用方式。

HTTP/2 Server Push

由于给现有工具及生态系统(例如 rack)引入了一系列颠覆性的变更,很多编程语言与库并不能完全支持 HTTP/2 的全部功能。但即便如此,我们仍然可以在部分合适的场景中使用 HTTP/2。举例来说:

  • 利用 HTTP/2 在常规 HTTP/1.x 服务器之前设置一套 h2o 或者 nginx 代理服务器。Puma 与 Ruby on Rails 能够发送 Early Hints,从而在一定的限制条件下启用 HTTP/2 Server Push。

  • 利用支持 HTTP/2 的 CDN 交付静态资产。例如,我们可以使用这种方法将字体以及一部分 JavaScript 文件推送至客户端。

HTTP/2 推送字体

对 JavaScript 以及 CSS 的推送功能同样非常实用。但请注意不要过度推送,您可点击此处了解一些相关问题: https://jakearchibald.com/2017/h2-push-tougher-than-i-thought/

浏览器中的 JavaScript

包大小预算

JavaScript 性能优化中的头号规则就是,不要使用 JavaScript。— 我自己

如果您已经拥有现成的 JavaScript 应用程序,那么设置预算规则能够提高包大小的可见性,同时确保全部内容都可容纳于同一页面当中。超出预算后,开发人员则需要谨慎考虑并尽量防止规模进一步增长。以下是预算设置方面的相关示例:

  • 根据您的实际需求或推荐值设定数值。例如,不得大于 170 KB 否则压缩 JavaScript。

  • 利用现有包大小作为基准,或者尝试对其进行削减——例如下调 10%。

  • 尝试让网站拥有高于竞争对手的速度,并以此为依据设定预算。

您可以使用 bundlesize 工具包或者 Webpack 性能提示与限制进行预算跟踪:

Webpack 性能提示与限制

消除依赖性

Sidekiq 曾在一篇博文中提到:

“代码越少,运行速度越快。代码越少,bug 就越少。代码越少,占用的内存量就越低。代码越少,理解起来就越轻松。”

遗憾的是,实际 JavaScript 场景中往往存在着不计其数的依赖关系。您可以试试:ls node_modules | wc -l。

在某些情况下,添加依赖性是种必然的选择。在这种情况下,依赖性的包大小应该被视为决定您实际工具包选择的重要依据。我强烈建议大家使用 BundlePhobia:

BundlePhobia 能够提示将 npm 工具包添加至您数据包中带来的实际成本

代码拆分

使用代码拆分是另一种能够显著提高 JavaScript 性能的好办法。其本质在于分解代码片段并仅向用户交付当前所需要的部分。以下是关于代码拆分的相关示例:

  • 在不同的 JavaScript 代码块间分别加载路由机制。

  • 拆分那些在页面中无法立即显示的部分,例如弹出框以及页面下方的页脚。

  • Polyfills 与 ponyfills 可支持全部主流浏览器当中的各最新浏览器功能。

  • 利用 Webpack 的 SplitChunksPlugin 防止代码重复。

  • 按需定位文件,以避免一次性发送所有受支持的语言。

您可以利用 Webpack 动态导入以及 React.lazy 配合 Suspense 实现代码拆分。

利用动态导入以及 React.lazy 配合 Suspense 实现代码拆分。

相较于默认导出,我们构建的函数可取代 React.lazy 以支持点名导出。

Async 与 defer 脚本

目前,全部主流浏览器皆在 script 标签上支持 async 与 defer 属性:

几种不同的 JavaScript 加载方式:

  • 内联脚本适用于加载小体积、高关键度 JavaScript 代码。

  • 当您的用户或者任何其它脚本(例如分析脚本)不再需要某些特定脚本时,大家可以将 async 与这些脚本配合使用以避免 HTML 解析阻塞。

  • 从性能角度来看,将 defer 与脚本配合使用能够有效提升非关键 JavaScript 代码的抓取与执行效率,且避免发生 HTML 解析阻塞。此外,这种作法还能够在调用脚本时保证执行顺序,从而确保不同脚本间存在依赖性时实时与预期相符的执行效果。

下成来看 head 标签下不同脚本间的可视化差异:

几种不同的脚本抓取与执行方式

图像优化

虽然与 100 KB 的图像相比,100 KB 的 JavaScript 代码明确会带来更高的性能成本,但我们同样有必要重视对图像内容的优化调整。

削减图像大小的有效手段之一,是在适用的浏览器当中采用更加轻量化的 WebP 图像。对于那些无法支持 WebP 的浏览器,大家则可以采取以下几种策略:

  • 回退至常规的 JPEG 或者 PNG 格式(某些 CDN 会根据浏览器的 Accept 请求标头自动执行)。

  • 在检测浏览器的支持情况后,加载并使用 WebP polyfill。

  • 利用 Service Workers 监听 fetch 请求,并在支持时利用 WebP 变更实际 URL。

WebP 图像

仅当图像位于视图当中或者附近时才进行内容加载,堪称多图像初始页面加载过程中效果最显著的提速手段之一。您可以在受支持的浏览器当中使用 IntersectionObserver 功能,也可以利用其它一些替代性工具实现相同的结果——例如 react-lazyload。

在滚动过程中进行图像的延迟加载

其它一些图像优化策略还包括:

  • 降低图像质量以减小体积。

  • 调整大小并加载最小图像。

  • 利用 Srcset 图像属性自动在高分辨率显示器上加载高质量图像。

  • 利用渐进式图像快速显示图像的模糊版本。

常规图像与渐进图像之间的加载效果差异。

大家也可以考虑使用通用型 CDN 或者图像专用 CDN,其通常会直接提供与图像相关的优化功能。

资源提示

资源提示(Resource hints) 允许我们优化资源交付、降低往返次数,同时获取资源以实现页面浏览过程中的内容交付提速。

带有 link 标签的资源提示

  • Preload 会在当前页面实际使用之前,通过后台预先下载高优先级资源。

  • Prefetch 的功能与 preload 类似,用于抓取资源并进行缓存,但仅供用户后续导航使用(低优先级)。

  • Preconnect 允许 HTTP 请求被实际发送至服务器之前即设置预连接。

提前进行预连接以避免 DNS、TCP 以及 TLS 往返延迟

当然,prerender 以及 dns-prefetch 等其它一些资源提示同样非常重要。其中一部分资源提示可在响应标头中进行指定。需要提醒大家的是,请务必小心使用资源提示。一旦开始滥用,您的页面中可能包含大量不必要的请求并快速下载过量数据,这种情况显然不利于使用蜂窝数据的移动用户。

总结

应用程序的性能改善之路代表着一个永远尽头的过程,且通常要求我们在整个堆栈当中持续作出更改。

每次看到下面这段视频,我总会想起你们努力减少应用包大小的样子。—我的同事

马上把一切不需要的东西从飞机上扔下去! ——电影《珍珠港》

以下列出了我们已经使用或者计划尝试的其它一些潜在性能改进思路:

  • 使用 Service Workers 进行缓存、离线支持以及主线程分摊。

  • 通过关键 CSS 内联或者函数式 CSS 实现数据包的长效“瘦身”。

  • 使用 WOFF2 字体替代 WOFF 字体(仅举一例,字体变更最高可带来 50% 压缩效果)。

  • 确保 browserslist 的定期更新。

  • 利用 webpack-bundle-analyzer 直观分析构建块。

  • 优选较小的工具包(例如 date-fns)及插件(例如 lodash-webpack-plugin),从而缩小页面体积。

  • 尝试使用 preact、lit-html 或者 svelte。

  • 在 CI 中运行 Lighthouse。

  • 渐进式 hydration 与 React 流式设计。

另外还有更多令人兴奋的想法可供尝试。希望本文提出的信息及以下案例研究能够激发出大家改善应用程序性能的更多灵感:

我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章