(给IT平头哥联盟加星标,提升前端技能)
作者 | exAspArk
译者 | 核子可乐
最近,我们将 Universe.com 主页的性能提升了十倍以上。在本文中,我们将解析实现这一重大改进的具体技术手段。
但在开始之前,让我们先对网络性能的重要意义进行一番论证(博文末尾提供相关案例研究链接):
在本篇文章中,我们将简要介绍以下几大有助于我们提高页面性能的主要领域:
这里再介绍一点我们的情况:我们的主页由 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 报告
渲染
内容的渲染可通过多种方法实现,其中每一种都拥有独特的优势与缺点:
客户端渲染
以前,我们将自己的主页与 Ember.js 框架一同实现为采用客户端渲染方法的单页面应用。但这种作法的一大问题在于,我们的 Ember.js 应用程序包过大。这意味着在浏览器下载 JavaScript 文件并对其进行解析、编译与执行的过程中,用户只能对着空白屏幕发呆:
最要命的空白屏幕
因此,我们决定利用 React 重构应用当中的某些部分。
预渲染与服务器端渲染
客户端渲染应用程序的具体构建——例如采用 React Router DOM,仍然会带来与 Ember.js 相同的问题。JavaScript 需要占用大量资源,而且访问者需要经历一段首屏内容填充周期才能看到实际内容。
因此在决定使用 React 之后,我们开始尝试其它潜在的渲染选项,以确保浏览器能够更快地完成内容渲染。
使用 React 时的常规渲染选项
因此,我们打算尝试一下混合方法,即发挥每一种渲染选项中的独特优势。
运行时预渲染
Puppeteer 是一套 Node.js 库,允许用户使用 headless Chrome。我们希望尝试利用 Puppeteer 在运行时当中实现预渲染。这代表着一种有趣的混合方法:利用 Puppeteer 进行服务器端渲染,同时利用 hydration 进行客户端渲染。感兴趣的朋友可以点击此处查看谷歌提供的关于如何利用 headless 浏览器进行服务器端渲染的相关提示。
利用 Puppeteer 对 React 应用程序进行运行时预渲染
这种方法具备以下优势:
但在采用这种方法的过程中,我们也遇到了一些挑战:
利用 Puppeteer 的服务器端渲染架构
• 稳定性。对众多 headless 浏览器进行规模伸缩,同时保持进程不致过热并实现负载均衡绝对是一项高难挑战。我们尝试了不同的托管方法,包括在 Kubernetes 集群内进行自托管,以及利用 AWS Lambda 与 Google Cloud Functions 实现无服务器计算。我们注意到,后一种方法在配合 Puppeteer 时存在一些性能问题:
AWS Lambdas和GCP函数的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 则用于实现服务器端渲染
我们可以继续构建一款简单的浏览器 React 应用程序,并在无需等待最终用户设备 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 请求进行缓存:
利用一条 SHA256 URL 参数发送 POST GraphQL 请求
以下是其它一些值得参考的潜在 GraphQL 缓存策略:
编码
目前,所有主流浏览器都支持利用 gzip 加 Content-Encoding 标头进行数据压缩。这意味着面向浏览器的发送数据量更低,从而带来更快的内容传递速度。此外,如果浏览器支持,大家也可以尝试使用效率更高的 brotli 压缩算法。
HTTP/2 协议
HTTP/2 是 HTTP 网络协议的新版本(DevConsole 中简称为 h2)。由于存在着以下几项与 HTTP/1.x 版本间的显著差别,切换至 HTTP/2 能够带来性能提升:
HTTP/2 Server Push
由于给现有工具及生态系统(例如 rack)引入了一系列颠覆性的变更,很多编程语言与库并不能完全支持 HTTP/2 的全部功能。但即便如此,我们仍然可以在部分合适的场景中使用 HTTP/2。举例来说:
HTTP/2 推送字体
对 JavaScript 以及 CSS 的推送功能同样非常实用。但请注意不要过度推送,您可点击此处了解一些相关问题: https://jakearchibald.com/2017/h2-push-tougher-than-i-thought/
浏览器中的 JavaScript
包大小预算
JavaScript 性能优化中的头号规则就是,不要使用 JavaScript。
—— 我自己
如果您已经拥有现成的 JavaScript 应用程序,那么设置预算规则能够提高包大小的可见性,同时确保全部内容都可容纳于同一页面当中。超出预算后,开发人员则需要谨慎考虑并尽量防止规模进一步增长。以下是预算设置方面的相关示例:
您可以使用 bundlesize 工具包或者 Webpack 性能提示与限制进行预算跟踪:
Webpack 性能提示与限制
消除依赖性
Sidekiq 曾在一篇博文中提到:“代码越少,运行速度越快。代码越少,bug 就越少。代码越少,占用的内存量就越低。代码越少,理解起来就越轻松。”
遗憾的是,实际 JavaScript 场景中往往存在着不计其数的依赖关系。您可以试试: ls node_modules | wc -l。
在某些情况下,添加依赖性是种必然的选择。在这种情况下,依赖性的包大小应该被视为决定您实际工具包选择的重要依据。我强烈建议大家使用 BundlePhobia:
BundlePhobia 能够提示将 npm 工具包添加至您数据包中带来的实际成本
代码拆分
使用代码拆分是另一种能够显著提高 JavaScript 性能的好办法。其本质在于分解代码片段并仅向用户交付当前所需要的部分。以下是关于代码拆分的相关示例:
您可以利用 Webpack 动态导入以及 React.lazy 配合 Suspense 实现代码拆分。
利用动态导入以及 React.lazy 配合 Suspense 实现代码拆分。
相较于默认导出,我们构建的函数可取代 React.lazy 以支持点名导出。
Async 与 defer 脚本
目前,全部主流浏览器皆在 script 标签上支持 async 与 defer 属性:
加载JavaScript的不同方式
几种不同的 JavaScript 加载方式:
下成来看 head 标签下不同脚本间的可视化差异:
几种不同的脚本抓取与执行方式
图像优化
虽然与 100 KB 的图像相比,100 KB 的 JavaScript 代码明确会带来更高的性能成本,但我们同样有必要重视对图像内容的优化调整。
削减图像大小的有效手段之一,是在适用的浏览器当中采用更加轻量化的 WebP 图像。对于那些无法支持 WebP 的浏览器,大家则可以采取以下几种策略:
WebP 图像
仅当图像位于视图当中或者附近时才进行内容加载,堪称多图像初始页面加载过程中效果最显著的提速手段之一。您可以在受支持的浏览器当中使用 IntersectionObserver 功能,也可以利用其它一些替代性工具实现相同的结果——例如 react-lazyload。
在滚动过程中进行图像的延迟加载
其它一些图像优化策略还包括:
常规图像与渐进图像之间的加载效果差异
大家也可以考虑使用通用型 CDN 或者图像专用 CDN,其通常会直接提供与图像相关的优化功能。
资源提示
资源提示(Resource hints) 允许我们优化资源交付、降低往返次数,同时获取资源以实现页面浏览过程中的内容交付提速。
带有 link 标签的资源提示
提前进行预连接以避免 DNS、TCP 以及 TLS 往返延迟
当然,prerender 以及 dns-prefetch 等其它一些资源提示同样非常重要。其中一部分资源提示可在响应标头中进行指定。需要提醒大家的是,请务必小心使用资源提示。一旦开始滥用,您的页面中可能包含大量不必要的请求并快速下载过量数据,这种情况显然不利于使用蜂窝数据的移动用户。
总结
应用程序的性能改善之路代表着一个永远尽头的过程,且通常要求我们在整个堆栈当中持续作出更改。
每次看到下面这段视频,我总会想起你们努力减少应用包大小的样子。
——我的同事
马上把一切不需要的东西从飞机上扔下去! ——电影《珍珠港》
以下列出了我们已经使用或者计划尝试的其它一些潜在性能改进思路:
另外还有更多令人兴奋的想法可供尝试。希望本文提出的信息及以下案例研究能够激发出大家改善应用程序性能的更多灵感:
英文原文: https://engineering.universe.com/improving-browser-performance-10x-f9551927dcff?gi=ef65642ac481
- end -
用心分享 一起成长 做有温度的攻城狮
每天记得对自己说:你是最棒的!