最近,我们把 Universe.com 的主页性能提高了 10 几倍。让我们一起来探索一下我们是如何实现这个结果的,涉及到了哪些技术。
一开始,我们先来看看,为什么网站性能如此重要(在本文末尾附有本案例研究的链接):
用户体验:糟糕的性能导致无响应,从 UI 和 UX 角度来看,这可能会让用户感到沮丧。转化率和收入:通常,响应缓慢的网站会导致客户流失,并对转化率和收入产生不好的影响。SEO:从2019 年 7 月 1 日开始,谷歌将对所有新网站默认启用移动优先索引。如果网站在移动设备上的响应缓慢,并且没有适合移动设备的内容,那么它们的排名会降低。在本文中,我们将简要介绍帮助我们提高页面性能的以下几个主要方面:
性能测量:实验室和现场测试工具。渲染:客户端和服务器端渲染,预渲染和混合渲染方法。网络:CDN、缓存、GraphQL 缓存、编码、HTTP/2 和服务器推送(Server Push)。浏览器中的 JavaScript:包大小预算、代码拆分、异步和延迟脚本、图像优化(WebP、延迟加载、渐进)和资源提示(预加载、预取、预连接)。针对某些情况,我们的主页是用 React(TypeScript)、Phoenix(Elixir)、Puppeteer(无头 Chrome)和GraphQL API(Ruby on Rails )构建的。在移动设备上的界面如下所示:
Universe homepage 和 explore
目录
性能测量
没有数据,只不过是空谈。—— W. Edwards Deming实验室测试工具(Lab instruments)
实验室测试工具允许在受控环境中,用预定义设备和网络设置采集数据。借助这些工具,调试任何性能问题和具有良好重现性的测试就变得更加简单。
Lighthouse是在本地计算机上审核 Chrome 页面的出色工具。它还提供一些关于如何提高性能、可访问性、SEO 等有用技巧。下面是一些模拟 Fast 3G 和 4 倍 CPU 减速的 Lighthouse 性能审核报告:
用 First Contentful Paint (FCP) 提高 10 倍性能的前后对照
然而,只使用实验室测试工具的缺点是:它们不一定能发现真实世界的瓶颈问题,这些问题可能取决于终端用户的设备、网络、位置和很多其他因素。这就是为什么使用现场测试工具也很重要的原因。
现场测试工具(Field instrument)
现场测试工具使我们可以模拟和测量真实的用户页面负载。有很多有助于从实际设备中获取真实性能数据的服务:
WebPageTest——允许在不同位置的实际设备上执行来自不同浏览器的测试。Test My Site——使用基于 Chrome 使用情况统计的 Chrome 用户体验报告(Chrome User Experience Report,简称CrUX);它对公众开放,并每月更新一次。PageSpeed Insights——结合了实验室(Lighthouse)和现场(CrUX)数据。WebPageTest 报告
渲染
渲染内容的方法有很多,每种方法都有其优缺点:
服务器端渲染(Server-side rendering,简称 SSR)是在服务器端为浏览器获取最终 HTML 文档的过程。优点:搜索引擎可以爬取网站而不执行 JavaScript(SEO)、快速初始页面加载、代码只存在于服务器端。缺点:没有丰富的网站交互、重新下载整个页面,对浏览器功能的访问有限制。客户端渲染是使用 JavaScript 在浏览器中渲染内容的过程。优点:丰富的网站交互、在初始下载后根据路由变化快速渲染、可以访问现代浏览器功能(如,Service Workers的离线支持)。缺点:不支持 SEQ、初始页面加载速度慢、通常需要在服务器端执行单页面应用程序(Single Page Application、简称 SPA)和 API。预渲染和服务器端渲染类似,但是它是在构建时期提前发生的,而不是在运行时发生的。优点:服务构建静态文件通常比运行服务器简单、支持 SEO、初始页面加载快。缺点:如果代码有任何变化,需要预先预渲染所有可能的页面、重新加载整个页面、站点交互不够丰富、访问浏览器功能受限。客户端渲染
之前,我们把我们的主页和 Ember.js 框架一起实现为具有客户端渲染的 SPA。我们遇到的一个问题是,Ember.js 应用程序包太大。这意味着,在浏览器下载、解析、编译和执行 JavaScript 文件时,用户只能看到一个空白的屏幕。
白屏
我们决定用React重建该应用程序的某些部分。
我们的开发人员已经熟悉构建 React 应用程序(如,嵌入式小部件)。我们已经有了一些 React 组件库,可以在多个项目之间共享它们。新页面有一些交互式 UI 元素。有一个拥有大量工具的庞大的 React 生态系统。借助浏览器中的 JavaScript,可以构建具有大量良好功能的渐进式 web 应用程序(Progressive Web App)。预渲染和服务器端渲染
例如,用React Router DOM构建的客户端渲染应用程序的问题, 仍然和 Ember.js 的相同。JavaScript 开销大,并且需要一些时间才能看到浏览器中的首次内容绘制(First Contentful Paint)。
当我们决定使用 React 后,我们马上就用其它潜在的渲染选项进行试验,以让浏览器更快地渲染内容。
使用 React 的常规渲染选项
Gatsby.js使我们可以用 React 和 GraphQL 预渲染页面。Gatsby.js 是个很棒的工具,它支持很多开箱即用的性能优化。然而,对我们来说,预渲染没有用,因为我们可能有无数个页面,它们包含用户生成的内容。Next.js是流行的 Node.js 框架,它允许服务器端用 React 渲染。然而,Next.js 很自我,需要使用其路由、CSS 解决方案等等。我们现有的组件库是为浏览器而构建的,与 Node.js 不兼容。这就是我们为什么决定尝试一些混合方法的原因,尝试从每个渲染选项中获得最佳效果。
运行时预渲染
Puppeteer是个 Node.js 库,它允许使用无头 Chrome。我们希望让 Puppeteer 试试在运行时进行预渲染。这支持使用一种有趣的混合方法:服务器端用 Puppeteer 渲染,客户端用激活渲染。这里有一些谷歌提供的有用窍门,关于如何使用无头浏览器来进行服务器端渲染。
用于运行时预渲染 React 应用程序的 Puppeteer
使用这种方法有如下优点:
可以使用 SSR,对 SEO 来说,这很棒。爬虫程序不需要执行 JavaScript 就能看到内容。允许构建简单浏览器 React 应用程序一次,然后把它用在服务器端和浏览器中。让浏览器应用程序更快地自动让 SSR 更快,这是双赢。在服务器上用 Puppeteer 渲染页面通常比在终端用户的移动设备上更快(连接更好, 硬件更好)。激活允许用对 JavaScript 浏览器功能的访问来构建丰富的 SPA。我们无需事先知道所有可能的页面来预渲染它们。然而,我们在使用这个方法时遇到了一些挑战:
吞吐量是主要问题。在单独的无头浏览器进程中执行每个请求消耗了大量资源。你可以使用单个无标题浏览器进程,并在单独的选项卡中运行多个请求。然而,使用多个选项卡将会使整个进程的性能下降了。使用 Puppeteer 进行服务器端渲染的体系结构
稳定性。扩展或缩小很多无头浏览器,让流程保持“热度”及平衡工作负载是个挑战。我们尝试了不同的托管方法:从 Kubernetes 集群自托管到用 AWS Lambda 和 Google Cloud Functions 的无服务器。我们注意到,后者在用到 Puppeteer 时有一些性能问题:在 AWS Lambdas 和 GCP 函数上的 Puppeteer 响应时间
随着我们越来越熟悉 Puppeteer,我们已经迭代了我们的初始方法(如下所示)。我们还进行着一些有趣实验,通过一个无头浏览器来渲染 PDF。还可以使用 Puppeteer 来进行自动端到端测试,甚至都不用写任何代码。现在,除了 Chrome,它还支持 Firefox。
混合渲染方法
在运行时使用 Puppeteer 很具挑战性。这是我们为什么决定在构建时使用它,并借助一个在运行时可以从服务器端返回实际用户生成内容的工具。与 Puppeteer 相比,它更稳定,并且吞吐量更大。
我们决定尝试一下 Elixir 编程语言。Elixir 看起来像 Ruby,但是运行于 BEAM(Erlang VM)之上,旨在构建容错且稳定的系统。
Elixir 使用Actor 并发模型。每个“Actor”(Elixir process)只占用很少的内存,约为 1-2KB。这样允许同时运行数千个独立进程。Phoenix是一个 Elixir web 框架,支持高吞吐量,并在独立的 Elixir 过程中处理每个 HTTP 请求。
我们结合了这些方法,充分利用了它们各自的优点,满足了我们的需要:
Puppeteer 用于预渲染,而 Phoenix 用于服务器端渲染
Puppeteer在构建时用我们希望的方式预渲染 React 页面,并以 HTML 文件形式保存它们(App Shell 来自PRPL 模式)。我们可以继续构建一个简单的浏览器 React 应用程序,不需要在终端用户设备上等待 JavaScript 就可以快速加载初始页面。
我们的Phoenix应用程序服务于这些预渲染页面,并动态地把实际内容注入到 HTML 中。这让内容 SEO 变得很友好,允许根据需要处理大量不同的页面,并且更容易扩展。
客户端接收并立即显示 HTML,然后激发Recat DOM状态以继续作为常规 SPA。这样,我们可以构建高度交互的应用程序,和访问 JavaScript 浏览器功能。
使用 Puppeteer 进行预渲染、使用 Phoenix 进行服务器端渲染和激发使用 React
网络
内容分发网络(CDN)
使用 CDN 可以实现内容缓存,并可以加速其在世界范围内的分发。我们使用Fastly.com,它为超过 10% 的互联网请求提供服务,并为各种公司使用,如 GitHub、Stripe、Airbnb、Twitter 等等。
Fastly 允许我们通过使用名为VCL的配置语言编写自定义缓存和路由逻辑。下图显示了一个基本请求流的工作原理,根据路由、请求标头等等来自定制每个步骤:
VCL 请求流
另一个提高性能的选择是在边缘使用 WebAssembly(WASM)和 Fastly。把它想象成使用无服务器,但是在边缘使用这些编程语言,如 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 集成。编码
所有主流浏览器都支持带有Content-Encoding头的 gzip 来压缩数据。这可以让我们给浏览器发送的字节更少,这通常意味着内容传递会更快。如果浏览器支持的话,你还可以使用更有效的 brotli 压缩算法。
HTTP/2 协议
HTTP/2是 HTTP 网络协议(在 DevConsole 中是 h2)的新版本。切换到 HTTP/2 可以提升性能,这归结于它和 HTTP/1.x 的这些不同之处:
HTTP/2 是二进制的,不是文本。解析更高效,更紧凑。HTTP/2 是多路复用的,这意味着 HTTP/2 可以通过单个 TCP 连接并行发送多个请求。它让我们不用担心每个主机限制和域分片的浏览器连接。它使用头压缩来减少请求 / 响应大小开销。允许服务器主动推送响应。该功能相当有趣。HTTP/2 服务器推送
有很多编程语言和库并不完全支持所有 HTTP/2 功能,原因是它们为现有工具和生态系统(如,rack)引入了破坏性更改。但是,即使在这种情况下,仍然可以使用 HTTP/2,至少可以部分使用。如:
在常规 HTTP/1.x 服务器前使用 HTTP/2 设置代理服务器,如h2o或nginx。例如 Puma 和 Rails 上的 Ruby 可以发送Early Hints,这可以启用 HTTP/2 服务器推送,但受到一些限制。使用支持 HTTP/2 的 CDN 提供静态资产。例如,我们用这种方法给客户端推送字体和一些 JavaScript 文件。HTTP/2 推送字体
推送关键的 JavaScript 和 CSS 也可以很有用。只是不要过度推送,并提防某些陷阱。
浏览器中的 JavaScript
包大小的预算
第一条 JavaScript 性能规则是不要使用 JavaScript。我这么认为。如果我们已经有现成的 JavaScript 应用程序,那么设置预算可以改进包大小的可见性,并让所有人都停留在同一个页面上。超预算迫使开发人员三思而后行,并把规模的增加控制在最小程度。关于如何设置预算,在此举几个例子:
根据我们的需要或一些推荐值使用数字。例如,小于 170KB的缩小和压缩的 JavaScrip。把当前的包大小作为基准,或尝试把它减少,例如 10%。试试我们的竞争对手中最快的网站,并相应地设置预算。我们可以使用 bundlesize 包或 Webpack 性能提示和限制来追踪预算:
Webpack 性能提示和限制
删除依赖项
这是由 Sidekiq 的作者所写的一篇热门博文的标题
没有代码能比没代码运行得更快。没有代码能比没代码有更少的错误。没有代码能比没代码使用更少的内存。没有代码能比没代码更容易让人理解。不幸的是,JavaScript 依赖项的现实是,我们的项目很有可能使用数百个依赖项。试试 Is node_modules | wc -l。
在某些情况下,添加依赖项是必须的。在这种情况下,依赖项包的大小应该是在多个包之间进行选择时的标准之一。我强烈推荐使用BundlePhobia:
BundlePhobia 发现向包中添加 npm 包的成本
代码拆分
使用代码拆分可能是显著提高 JavaScript 性能的最佳方法。它允许拆分代码,并只传递用户当前需要的那部分。以下是一些代码拆分的例子:
在单独的 JavaScript 块中分别加载路由页面上可以不立即显示的组件,例如,在页面下方的模态、页脚。在所有主流浏览器中,polyfills和ponyfills都支持最新的浏览器功能。通过使用 Webpack 的 SplitChunksPlungin,避免代码重复。根据需要定位文件,以避免一次性发送所有我们支持的语言。借助 Webpack动态导入和具有Suspense的React.lazy,我们可以使用代码拆分。
借助动态引入和具有 Suspense 的 React.lazy 的代码拆分
我们构建了一个取代 React.lazy 的函数来支持命名导出,而不是默认导出。
异步和延迟脚本
所有主流浏览器支持脚本标签上的异步和延迟属性
加载 JavaScript 的不同方法
内联脚本对于加载小型关键 JavaScript 代码非常有用。当用户或任何其他脚本(例如,分析脚本)不需要该脚本,要获取 JavaScript 而不妨碍 HTML 解析时,使用带async的脚本非常有用。从性能的角度看,要获取和执行非关键 JavaScript,并且不阻碍 HTML 解析,那么,使用带defer的脚本可能是最佳方法。此外,它确保调用脚本时的执行顺序,如果一个脚本依赖另一个脚本,那么这个方法会很有用。以下显示了在头标签中这些脚本之间的差异:
脚本获取和执行的不同方法
图像优化
尽管 JavaScript 的 100KB 与图像的 100KB 相比,性能成本有很大的不同,但是,通常来说,尽量让图像保持比较小的文件大小很重要。
一种减小图像大小的方法是,在受支持的浏览器中使用更轻量级的WebP图像格式。对于那些不支持 WebP 的浏览器来说,可以使用以下策略:
退回到常规 JPEG 或 PNG 格式(一些 CDN 根据浏览器的 Accept 请求头自动执行)在检测到浏览器支持后,加载并使用WebP polyfill。使用 Service Workers 来侦听以获取请求,如果 WebP 受到支持,那么就更改实际的 URL 以使用 WebP。WebP 图像
仅当图像在位于或接近视图端口时才延迟加载图像,对于具有大量图像的初始页面加载来说,这是最显著的性能改进之一。我们可以在支持的浏览器中使用 IntersectionObserver功能,或使用一些可替换的工具来实现同样的结果,例如,react-lazyload。
在滚动期间延迟加载图像
其他一些图像优化可能包括:
降低图像的质量以减少图像的尺寸。调整大小并尽可能加载最小的图像。使用srcset图像属性为高分辨率视网膜显示器自动加载高质量图像。使用渐进式图像,先立即显示出模糊的图像加载常规图像和渐进图像的对比
我们可以考虑使用一些通用 CDN 或专用图像 CDN,它们通常实现了这些图像优化的大部分工作。
资源提示
资源提示让我们可以优化资源的交付,减少往返次数,以及资源的获取,以便在用户浏览页面时更快地传递内容。
带有 link 标记的资源提示
预加载(preload)在当前页面加载的后台下载资源,并会实际用于当前页面(高优先级)。预取(prefetch)的工作原理和预加载类似,都是获取资源并缓存它们,但用于未来用户的导航(低优先级)。预连接(preconnect)允许在 HTTP 请求在实际发送到服务器之前,设置早期连接。提前预连接以避免 DNS、TCP 和 TLS 往返延迟
还有其他一些资源提示,如预渲染或DNS 预取。其中有一些可以在响应头上指定。在使用资源提示时,请小心行事。很容易一开始就造成太多不必要的请求和下载太多数据,特别是如果用户在使用蜂窝连接。
结论
在不断增长的应用中,性能是永无止境的过程,该过程通常需要在整个栈中不断更改。
这个视频提醒我,大家希望减少应用程序包的大小——我的同事把一切你现在不需要的东西都扔出飞机!——电影《珍珠港》
以下是一个列表,表中是我们在使用或计划尝试的其他未提及的潜在性能改进:
使用 Service Workers 进行缓存、脱机支持及卸载主线程。内联关键 CSS 或使用功能性 CSS,以便长期减小尺寸大小。使用如 WOFF2 而不是 WOFF 的字体格式(最高可压缩一半大小)。浏览器列表保持更新。使用webpack-bundle-analyzer进行构建块的可视化分析。优选较小的包(例如,date-fns)和允许减小尺寸大小的插件(如,lodash-webpack-plugin)。试试preact、lit-html或svelte。在CI 中运行 Lighthouse。渐进激发和用React进行流处理。令人兴奋的想法无穷无尽,我们都可以拿来尝试。我希望这些信息和这些案例研究可以启发大家去思考应用程序中的性能。
据亚马逊计算,页面下载速度每下降 1 秒就可能造成年销售额减少 13 亿美元。沃尔玛发现,加载时间每减少 1 秒,将使转换量增加 2%。每 100ms 的改进还会带来高达 1% 的收入增加。据谷歌计算,搜索结果每放慢 0.4 秒,那么每天的搜索次数有可能减少 8 百万次。重构 Pinterest 页面的性能使等待时间减少了 40%,而 SEO 流量增加了 15%,注册转化率增加了 15%。BBC 发现,其网站加载时间每增加一秒,就会多流失 10% 的用户。对新的更快的 FT.com 的测试表明,用户参与度提高了 30%,这意味着更多的访问次数和更多的内容消费。Instagram 通过减少显示评论所需 JSON 的响应大小,将展示次数和用户个人资料滚动互动量增加了 33%。点击“了解更多”,获取更多优质阅读