大型网站前端优化之路

meShell · 2019-07-08

我们最近将Universe.com主页的性能提高了十倍以上。让我们探索一下我们用来实现这个结果的技术.

首先,让我们找出为什么网站性能很重要:

  • 用户体验: 性能不佳导致无响应,从UI和UX的角度来看,这可能会让用户感到沮丧。
  • 转化和收入: 通常网站速度缓慢会导致客户流失,并对转化率和收入产生负面影响。
  • 搜索引擎优化: 从2019年7月1日开始,谷歌将默认为所有新网站启用移动优先索引。如果网站在移动设备上运行缓慢,并且没有适合移动设备的内容,那么它们的排名将会降低。

在这篇博文中,我们将简要介绍这些主要领域,这些领域有助于我们提高页面性能:

  • 性能测量: 实验室和现场仪器。
  • 渲染: 客户端和服务器端渲染,预渲染和混合渲染方法。
  • 网络: CDN,缓存,GraphQL缓存,编码,HTTP / 2和服务器推送。
  • 浏览器中的JavaScript: 包大小预算,代码拆分,asyncdefer脚本,图像优化(WebP,延迟加载,渐进)和资源提示(预加载,预取,预连接)。

在某些情况下,我们的主页是使用React(TypeScript),Phoenix(Elixir),Puppeteer(headless Chrome)和GraphQL API(Ruby on Rails)构建的。这就是它在移动设备上的样子:

Universe homepage and explore

性能测量

没有数据,你只是另一个有意见的人。 - W. Edwards Deming

实验室仪器

实验室仪器允许使用预定义的设备和网络设置在受控环境中收集数据。使用这些仪器,调试任何性能问题并具有良好可重复的测试要简单得多。

Lighthouse是在本地计算机上审核Chrome网页的绝佳工具。它还提供了一些有关如何提高性能,可访问性,搜索引擎优化等的有用提示。以下是一些使用模拟快速3G和4倍CPU减速的Lighthouse性能审计报告:

Before and after: 10x improvement for the First Contentful Paint (FCP)

然而,仅使用实验室仪器存在缺点:它们不一定捕获可能取决于最终用户的设备,网络,位置和许多其他因素的现实瓶颈。这就是使用现场仪器也很重要的原因。

现场仪器

现场仪器允许模拟和测量真实的用户页面负载。有多种服务可以帮助从实际设备获取真实的性能数据:

  • WebPageTest - 允许在不同位置的实际设备上执行来自不同浏览器的测试。
  • Test My Site - 使用基于Chrome使用情况统计信息的Chrome用户体验报告(CrUX);它是公开的,每月更新一次。
  • PageSpeed Insights - 结合了实验室(灯塔)和现场(CrUX)数据。

WebPageTest report

渲染

渲染内容有多种方法,每种方法都有其优点和缺点:

  • Server-side rendering (SSR)是在服务器端为浏览器获取最终HTML文档的过程。优点:搜索引擎可以抓取网站而不执行JavaScript(SEO),快速初始页面加载,代码仅存在于服务器端。缺点:非富网站交互,整页重新加载,浏览器功能受限。
  • Client-side rendering 是使用JavaScript在浏览器中呈现内容的过程。优点:丰富的网站交互,在初始加载后快速呈现路线更改,访问现代浏览器功能(例如,Service Workers离线支持)。缺点:不是SEO友好,初始页面加载缓慢,通常需要在服务器端实现单页面应用程序(SPA)和API。
  • Pre-rendering 类似于服务器端呈现,但是在构建期间提前而不是运行时发生。优点:服务构建的静态文件通常比运行服务器,SEO友好,快速初始页面加载更简单。缺点:要求在任何代码更改,完整页面重新加载,非富网站交互,有限访问浏览器功能之前预先呈现所有可能的页面。

Client-side rendering

以前,我们将我们的主页与Ember.js框架一起实现为具有客户端渲染的SPA。我们遇到的一个问题是我们的Ember.js应用程序的大包大小。这意味着当浏览器下载JavaScript文件,解析,编译和执行时,用户只看到一个空白屏幕:

White screen of death

我们决定使用React重建应用程序的某些部分。

  • 我们的开发人员已经熟悉构建React应用程序(例如嵌入式小部件)。
  • 我们已经有一些React组件库可以在多个项目中共享。
  • 新页面具有一些交互式UI元素。
  • 有一个庞大的React生态系统,有很多工具。
  • 使用浏览器中的JavaScript,可以构建具有许多不错功能的Progressive Web App

Pre-rendering and server-side rendering

例如,使用React Router DOM构建的客户端呈现应用程序的问题仍然与Ember.js相同。 JavaScript很昂贵,在浏览器中看到第一个画面需要一段时间。

一旦我们决定使用React,我们就开始尝试其他潜在的渲染选项,以允许浏览器更快地渲染内容。

Conventional rendering options with React

  • Gatsby.js允许使用React和GraphQL预呈现页面。 Gatsby.js是一个很好的工具,可以支持许多开箱即用的性能优化。但是,使用预呈现对我们不起作用,因为我们可能有无限数量的页面包含用户生成的内容。
  • Next.js是一个流行的Node.js框架,它允许使用React进行服务器端渲染。但是,Next.js非常自以为是,需要使用其路由器,CSS解决方案等。我们现有的组件库是为浏览器构建的,与Node.js不兼容。

这就是为什么我们决定尝试一些混合方法,尝试从每个渲染选项中获得最佳效果。

Runtime pre-rendering

Puppeteer是一个Node.js库,允许使用无头Chrome。我们想让Puppeteer尝试在运行时进行预渲染。这使得可以使用一种有趣的混合方法:使用Puppeteer进行服务器端渲染,使用水合作用进行客户端渲染。以下是Google提供的有关如何使用无头浏览器进行服务器端呈现的一些有用提示。

Puppeteer for runtime pre-rendering a React application

使用这种方法有一些优点:

  • 允许SSR,这对SEO有好处。爬虫不需要执行JavaScript就能看到内容。
  • 允许构建一个简单的浏览器React应用程序一次,并在服务器端和浏览器中使用它。更快地使浏览器应用程序更快,使SSR更快,更双赢。
  • 在服务器上使用Puppeteer渲染页面通常比最终用户的移动设备更快(连接更好,硬件更好)。
  • 混合可以构建丰富的SPA,并可以访问JavaScript浏览器功能。
  • 我们不需要提前知道所有可能的页面以便预先渲染它们。

但是,我们遇到了这种方法的一些挑战:

  • 吞吐量 是主要问题。在单独的无头浏览器进程中执行每个请求会占用大量资源。可以使用单个无头浏览器进程并在单独的选项卡中运行多个请求。但是,使用多个选项卡会降低整个过程的性能。

The architecture of server-side rendering with Puppeteer

  • 稳定性 扩展或缩小许多无头浏览器,保持流程“温暖”并平衡工作负载是一项挑战。我们尝试了不同的托管方法:从Kubernetes集群中自托管到使用AWS Lambda和Google Cloud Functions的无服务器。我们注意到后者与Puppeteer有一些性能问题

Puppeteer response time on AWS Lambdas and GCP Functions

随着我们对Puppeteer越来越熟悉,我们已经迭代了我们的初始方法(如下所示)。我们还有一些有趣的正在进行的实验,通过无头浏览器渲染PDF。即使不编写任何代码,也可以使用Puppeteer进行自动端到端测试。除Chrome之外,它现在支持Firefox。

Hybrid rendering approach

在运行时使用Puppeteer非常具有挑战性。这就是我们决定在构建时使用它的原因,它可以在服务器端从运行时返回实际的用户生成内容。比Puppeteer更稳定,吞吐量更高的东西。

我们决定尝试使用Elixir编程语言。 Elixir看起来像Ruby,但运行在BEAM(Erlang VM)之上,它是为了构建容错和稳定的系统而创建的。

Elixir使用Actor并发模型。每个“Actor”(Elixir进程)的内存占用量都很小,约为1-2KB。这允许同时运行数千个孤立的进程。 Phoenix是一个Elixir Web框架,它支持高吞吐量,并允许在单独的Elixir进程中处理每个HTTP请求。

我们结合了这些方法,充分利用了每个世界,满足了我们的需求:

Puppeteer for pre-rendering and Phoenix for server-side rendering

  • Puppeteer在构建期间以我们想要的方式预渲染React页面并将它们保存在HTML文件中(来自PRPL模式的app shell)。

我们可以继续构建一个简单的浏览器React应用程序,并且无需等待最终用户设备上的JavaScript即可快速初始加载页面。

  • 我们的Phoenix应用程序为这些预渲染页面提供服务,并动态地将实际内容注入HTML。

这使得内容SEO友好,允许按需处理大量各种页面并更容易扩展。

  • 客户端立即接收并开始显示HT​​ML,然后将React DOM状态保持为常规SPA。

这样,我们就可以构建高度交互的应用程序并可以访问JavaScript浏览器功能。

The architecture of pre-rendering with Puppeteer, server-side rendering with Phoenix, and hydration on the client-side with React

网络

Content delivery network (CDN)

使用CDN可以实现内容缓存,并可以加速其在全球的交付。我们使用Fastly.com,它服务于超过10%的互联网请求,并被GitHub,Stripe,Airbnb,Twitter等许多公司使用。

快速允许我们使用名为VCL的配置语言编写自定义缓存和路由逻辑。以下是基本请求流的工作原理,可根据路由,请求标头等自定义每个步骤:

VCL request flow

提高性能的另一个选择是使用Fastly在边缘使用WebAssembly(WASM)。可以把它想象成使用无服务器但在边缘使用C,Rust,Go,TypeScript等编程语言.Cloudflare有一个类似的项目来支持WASM on Workers

Caching

尽可能多地缓存请求以提高性能非常重要。在CDN级别进行缓存可以更快地为新用户提供响应。通过发送Cache-Control标头进行缓存可以加快浏览器中重复请求的响应时间。

大多数构建工具(如Webpack)允许向文件名添加哈希值。可以安全地缓存这些文件,因为更改文件将创建新的输出文件名。

GraphQL caching

发送GraphQL请求的最常见方法之一是使用POST HTTP方法。我们使用的一种方法是在Fastly级别缓存一些GraphQL请求:

  • 我们的React应用程序注释了可以缓存的GraphQL查询。
  • 在发送HTTP请求之前,我们通过从请求主体构建哈希来附加URL参数,该请求主体包括GraphQL查询和变量(我们使用Apollo Client自定义提取)。
  • 默认情况下,Varnish(和Fastly)使用完整的URL作为缓存键的一部分。
  • 这允许我们继续在请求正文中使用GraphQL查询发送POST请求,并在边缘缓存而不会访问我们的服务器。

以下是一些其他潜在的GraphQL缓存策略:

  • 在服务器端缓存:整个GraphQL请求,在解析程序级别上或通过注释模式声明性地。
  • 使用持久化的GraphQL查询并发送GET /graphql/:queryId以便能够依赖HTTP缓存。
  • 通过使用自动化工具(例如Apollo Server 2.0)或使用GraphQL特定的CDN(例如FastQL)与CDN集成。

Encoding

所有主流浏览器都支持带有Content-Encoding标头的gzip来压缩数据。这允许向浏览器发送更少的字节,这通常意味着更快的内容传递。在支持的浏览器中也可以使用更有效的brotli压缩算法。

HTTP/2 protocol

HTTP/2是HTTP网络协议的新版本(DevConsole中的h2)。由于与HTTP/1.x相比存在这些差异,切换到HTTP/2可能会提高性能:

  • HTTP/2是二进制的,而不是文本的。解析更高效,更紧凑。
  • HTTP/2是多路复用的,这意味着HTTP/2可以通过单个TCP连接并行发送多个请求。它允许我们不用担心每个主机限制和域分片的浏览器连接。
  • 它使用头压缩​​来减少请求/响应大小开销。
  • 允许服务器主动推送响应。这个功能特别有趣。

HTTP/2 Server Push

有许多编程语言和库不完全支持所有HTTP/2功能,因为它们为现有工具和生态系统(例如机架)引入了重大变化。但即使在这种情况下,仍然可以使用HTTP/2,至少部分使用。例如:

  • 在常规HTTP/1.x服务器前面使用HTTP/2设置代理服务器,如h2onginx。例如。 Puma和Ruby on Rails可以发送Early Hints,它可以启用HTTP2 Server Push,但有一些限制。
  • 使用支持HTTP/2的CDN来提供静态资产。例如,我们使用这种方法将字体和一些JavaScript文件推送到客户端。

HTTP/2 Push fonts

推出关键的JavaScriptCSS也非常有用。只是不要过度推动并注意一些问题。

浏览器中的JavaScript

文件大小预算

JavaScript性能规则不是使用JavaScript

如果您已有现有的JavaScript应用程序,则设置预算可以提高捆绑包大小的可见性,并使每个人都保持在同一页面上。超出预算迫使开发人员三思而后行,并尽量减少规模增长。以下是如何设置预算的一些示例:

  • 根据您的需要或一些推荐值使用数字。例如,<170KB缩小和压缩JavaScript。
  • 使用当前捆绑包大小作为基准或尝试将其减少,例如,减少10%。
  • 尝试在竞争对手中拥有最快的网站并相应地设置预算。

您可以使用bundlesize包或Webpack性能提示和限制来跟踪预算:

Webpack performance hints and limits

杀死你的依赖

这是Sidekiq作者撰写的热门博客文章的标题。

没有代码比没有代码运行得更快。没有代码比没有代码的bug少。没有代码使用比没有代码更少的内存。没有代码比没有代码更容易理解。

不幸的是,JavaScript依赖的现实是你的项目很可能使用了数百个依赖项。试试ls node_modules | wc -l

在某些情况下,添加依赖项是必要的。在这种情况下,依赖包大小应该是在多个包之间进行选择时的标准之一。我强烈推荐使用BundlePhobia

BundlePhobia finds the cost of adding an npm package to your bundle

代码分割

使用代码分割可能是显着提高JavaScript性能的最佳方法。它允许拆分代码并仅运送用户当前需要的部分。以下是代码拆分的一些示例:

  • 路由分别加载在单独的JavaScript块中。
  • Polyfillsponyfill支持所有主流浏览器中的最新浏览器功能。
  • 使用WebpackSplitChunksPlugin防止代码重复。
  • 根据需要定位文件,以避免一次性运送所有支持的语言。

您可以使用Webpack动态导入进行代码分割,使用带有SuspenseReact.lazy

const OtherComponent = React.lazy(() => import('./OtherComponent'))

const MyComponent = () => (
    <div>
        <Suspense fallback={<div>Loading..</div>}>
            <OtherComponent />
        </Suspense>
    </div>
)

我们构建了一个函数而不是React.lazy来支持命名导出而不是默认导出。

异步和延迟脚本

所有主流浏览器都支持脚本标记上的asyncdefer属性:

<script>inlineJS()</script>

<script src="my.js"></script>

<script async src="my.js"></script>

<script defer src="my.js"></script>
  • 内联脚本对于加载小型关键JavaScript代码非常有用。
  • 当用户或任何其他脚本(例如分析脚本)不需要脚本时,使用带异步的脚本对于获取JavaScript而不阻止HTML解析非常有用。
  • 从性能的角度来看,使用带延迟的脚本可能是获取和执行非关键JavaScript而不阻止HTML解析的最佳方式。此外,它还可以在调用脚本时保证执行顺序,如果一个脚本依赖另一个脚本,这将非常有用。

以下是head标记中脚本之间的可视化差异:

Different ways of script fetching and execution

图像优化

虽然与100 KB的图像相比,100 KB的JavaScript具有非常不同的性能成本,但保持图像尽可能轻,这一点非常重要。

减小图像大小的一种方法是在支持的浏览器中使用更轻量级的WebP图像格式。对于不支持WebP的浏览器,可以使用以下策略之一:

  • 回退到常规JPEG或PNG格式(某些CDN根据浏览器的Accept请求标头自动执行)。
  • 检测到浏览器支持后加载和使用WebP polyfill
  • 使用Service Workers监听获取请求和更改实际URL以使用WebP(如果支持)。

WebP images

仅当视图位于视口中或附近时才懒惰地加载图像是对具有大量图像的初始页面加载最显着的性能改进之一。您可以在支持的浏览器中使用IntersectionObserver功能,也可以使用一些替代工具来实现相同的结果,例如react-lazyload

其他一些图像优化可能包括:

  • 降低图像质量以减小尺寸。
  • 调整大小并加载最小的图像。
  • 使用srcset图像属性自动加载高分辨率视网膜显示器的高质量图像
  • 使用渐进式图像立即显示模糊图像。

Loading regular vs progressive images

您可以考虑使用一些通用CDN或专用图像CDN,它们通常实现大多数这些图像优化。

Resource hints

Resource hints允许我们优化资源交付,减少往返次数,并获取资源以在用户浏览页面时更快地传递内容。


<link rel="preload" href="image.png" as="image">
<link rel="prefetch" href="image.png">
<link rel="preconnect" href="https://example.com">
  • preload在当前页面加载的后台下载资源,然后在当前页面上实际使用(高优先级)。
  • prefetch的工作方式类似于预加载以获取资源并缓存它们,但用于将来用户的导航(低优先级)。
  • Preconnect允许在HTTP请求实际发送到服务器之前设置早期连接。

Preconnect in advance to avoid DNS, TCP, TLS roundtrip latencies

还有一些其他资源提示,例如prerenderdns-prefetch。可以在响应头中指定其中一些资源提示。使用资源提示时要小心。开始制作太多不必要的请求并下载太多数据非常简单,特别是如果用户使用蜂窝连接。

总结

不断增长的应用程序中的性能是一个无休止的过程,通常需要在整个堆栈中进行不断的更改。

以下列出了我们使用或计划尝试的其他未提及的潜在性能改进:

  • 使用Service Workers进行缓存,脱机支持和卸载主线程。
  • 内联关键CSS或使用功能CSS来减少长期的大小。
  • 使用字体格式,如WOFF2而不是WOFF(高达50%+压缩)。
  • 使浏览器列表保持最新。
  • 使用webpack-bundle-analyzer直观地分析构建块。
  • 优选较小的包(例如date-fns)和允许减小尺寸的插件(例如lodash-webpack-plugin)。
  • 尝试preactlit-htmlsvelte
  • Running Lighthouse in CI.
  • Progressive hydration and streaming with React.

有许多令人兴奋的想法可供尝试。我希望这些信息和一些案例研究能够激发您思考应用程序的性能:

据亚马逊计算,每年仅1秒的页面加载速度下降可能会花费16亿美元。
沃尔玛每加载1秒钟的转换次数增加了2%。每100毫秒的改进也使收入增加了1%。
谷歌计算出,通过将搜索结果放慢0.4秒,他们每天可能会损失800万次搜索。
重建Pinterest页面的性能导致等待时间减少40%,SEO流量增加15%,注册转换率增加15%。
英国广播公司已经看到,他们在网站加载所需的每一秒钟内就会失去额外10%的用户。
对新的更快的FT.com的测试表明,用户的参与度提高了30% - 这意味着更多的访问次数和更多的内容被消费。
Instagram通过降低显示评论所需的JSON响应大小,将展示次数和用户个人资料滚动互动增加了33%。

以上文章翻译于https://engineering.universe.com/improving-browser-performance-10x-f9551927dcff