这篇文章介绍了一些使前端应用程序加载更快并提供良好用户体验的技术。
我们将研究前端的总体架构,如何首先加载必需的资源,并最大化资源缓存的概率。
无论你的页面是否需要成为客户端应用程序,还是如何优化应用程序的渲染时间,我都不会说太多后端如何传递资源。
总览
我将把应用程序加载分为三个不同的阶段:
- 初始渲染 – 用户看到任何东西之前需要多长时间?
- 应用程序加载 – 用户可以使用该应用程序需要多长时间?
- 下一页 – 导航到下一页需要多长时间?
初始渲染
在浏览器的初始渲染之前,用户看不到任何东西。渲染页面至少需要加载 HTML 文件,但是大多数时候需要加载其他资源,例如 CSS 和 JavaScript 文件。一旦这些都加载完毕,浏览器就可以开始在屏幕上渲染。
在本文中,我将使用 WebPageTest 瀑布图。你网站的请求瀑布可能看起来像这样。
HTML 文档将加载一堆其他文件,并在这些文件加载后渲染页面。请注意, CSS 文件是并行加载的,因此每个其他请求不会增加明显的延迟。
(备注:gov.uk 启用了 HTTP/2,因此资产域可以重新使用与 www.gov.uk 的现有连接!我将在下面详细讨论服务器连接。)
减少渲染阻塞的请求
css 和(默认情况下) script 文件会阻止其下方的任何内容渲染。
你可以通过以下几种方法来解决此问题:
- 将脚本标签放在 body 标签的底部
- 使用 async 异步加载 script
- 内联使用小型的 JS 或 CSS 代码段(如果需要同步加载)
避免顺序渲染阻塞请求链
让你的网站变慢的不一定是阻止渲染的请求数量。更重要的是每种资源的下载大小,以及浏览器发现需要加载资源的时间。
如果浏览器仅在另一个请求完成后才发现需要加载文件,则可以获取同步请求链。发生这种情况可能有多种原因:
- CSS 中的 @import 规则
- CSS 文件中引用的 Webfonts
- JavaScript 注入链接或脚本标签
看一下这个例子:
这个网站在它的某个 CSS 文件中使用 @import 加载 Google Fonts。这意味着浏览器需要一个接一个地发出这些请求:
- 文件 HTML
- 应用程序的 CSS
- Google 字体 CSS
- Google Font Woff文件(在瀑布图中未显示)
要解决这个问题,首先需要将 Google Fonts 的 CSS 请求从 @import 移动到 HTML 中的 link 标记,这就切断了请求链条上的一个环节。
为了进一步加快速度,建议直接在 HTML 或 CSS 文件中内联 Google Fonts CSS 文件。
(记住,来自 Google Fonts 的 CSS 响应取决于用户代理。如果你用 IE8 发出请求,CSS会引用一个 EOT 文件,IE11 会得到一个 woff 文件,而现在的浏览器会得到一个 woff2 文件。但是如果你不介意旧的浏览器使用系统字体,那么你可以复制粘贴 CSS 文件的内容。)
即使页面开始呈现后,用户仍可能无法对该页面执行任何操作,因为在加载字体之前,不会显示任何文本。可以通过 font-display swap 来避免这种情况,现在 Google Fonts 默认情况下已经开始支持。
有时,消除请求链是不可行的。在这些情况下,可以考虑使用 preload 或 preconnect 标记。例如,在实际的 CSS 请求发出之前,上面的网站可以连接到 fonts.googleapis.com。
重复使用服务器连接以加快请求
建立新的服务器连接通常需要在服务器的浏览器之间进行3次往返:
- DNS 查询
- 建立 TCP 连接
- 建立 SSL 连接
连接就绪后,至少需要再进行一次往返来发送请求并下载响应。
下面的瀑布显示连接已启动到四个不同的服务器:hostgator.com,optimize.com,googletagmanager.com 和 googelapis.com。
但是,对同一服务器的后续请求可以重新使用现有连接。因此,加载 base.css或 index1.css 的速度很快,因为它们也托管在 hostgator.com 上。
减小文件大小并使用CDN
除了文件大小之外,还有两个其他因素会影响请求时间,这些因素都在你的控制范围内:资源大小和服务器位置。
向用户发送尽可能少的数据,并确保将其压缩(例如,使用 brotli 或 gzip )。
内容交付网络在大量位置提供服务器,因此其中之一可能位于你的用户附近。用户可以连接到与其附近的 CDN 服务器,而不必连接到中央应用程序服务器。这意味着服务器的往返时间将大大缩短。这对于诸如 CSS,JavaScript和 Image 之类的静态资产特别方便,因为它们易于分发。
使用 service workers 跳过网络
service workers 允许你在请求进入网络之前拦截它们。这意味着你可以实现瞬时首屏渲染!
当然,这只在你不需要网络发送响应时才有效。你需要已经缓存了响应,所以用户只有在第二次加载你的应用时才会受益。
下面的 service workers 缓存呈现页面所需的HTML和CSS。当再次加载应用程序时,它会尝试为缓存的资源提供服务,如果资源不可用,则会返回到网络。
- self.addEventListener("install", async e => {
- caches.open("v1").then(function (cache) {
- return cache.addAll(["/app", "/app.css"]);
- });
- });
- self.addEventListener("fetch", event => {
- event.respondWith(
- caches.match(event.request).then(cachedResponse => {
- return cachedResponse || fetch(event.request);
- })
- );
- });
应用加载
好的,现在用户已经可以看到一些东西,然后他们在可以使用你的应用程序之前还需要什么?
- 加载应用程序代码(JS和CSS)
- 加载页面的基本数据
- 加载其他数据和图像
请注意,不仅仅是延迟从网络加载数据会延迟渲染。加载代码后,浏览器将需要解析,编译和执行它。
Bundle split:仅加载必要的代码,并最大化缓存命中率
Bundle split 允许只加载当前页面所需的代码,而不是加载整个应用程序。Bundle split 还意味着可以缓存其中的一部分,即使其他部分已经更改,需要重新加载。
通常,代码被分成三种不同类型的文件:
- 网页本身专用代码
- 共享应用程序代码
- 很少更改的第三方模块(非常适合缓存!)
Webpack 可以使用 optimization.splitChunks 自动拆分共享代码以减少总下载量。确保启用运行时块,以使 chunk 哈希稳定,并从长期缓存中受益。
分离页面特定的代码不能自动完成,你需要识别可以单独加载的位。通常这是一个特定的路径或一组页面。使用动态导入来延迟加载代码。
Bundle split 会导致更多的请求被发送来加载你的应用程序。但是只要请求是并行发送的,这就不是什么大问题,特别是当你的站点开启了 HTTP/2 服务的时候。你可以看到在这个瀑布的前三个请求:
然而,这个瀑布图还显示了两个按顺序发出的请求。这些块只在这个页面中需要,并通过 import() 调用动态加载。
如果你知道需要这些块,你可以通过插入预加载链接标记来解决这个问题。
但是,你会看到,与总页面加载时间相比,这样做的好处可能很小。
另外,使用预加载有时会适得其反,因为加载其他更重要的文件时可能会延迟。
加载页面数据
你的应用程序可能是用来显示一些数据的。下面是一些提示,你可以使用这些提示尽早加载数据并避免呈现延迟。
在开始加载数据之前不要等待包
这是一个顺序请求链的特殊情况:你加载应用程序包,然后代码请求页面数据。
有两种方法可以避免这种情况:
- 将页面数据嵌入HTML文档中
- 通过文档中的内联脚本启动数据请求
将数据嵌入HTML可以确保你的应用程序不必等待数据加载。这也降低了应用程序的复杂性,因为你不必处理加载状态。
但是,如果获取数据会大大延迟你的文档响应,那将不是一个好主意,因为这会延迟你的初始渲染。
在这种情况下,或者如果你通过服务工作者提供缓存的HTML文档,则可以将内联脚本嵌入到HTML中以加载此数据。你可以将其作为全局 promise 提供,如下所示:
- window.userDataPromise = fetch("/me")
然后,如果数据准备就绪,你的应用程序可以立即开始渲染,或者等到准备就绪。
对于这两种技术,你都需要知道在应用开始呈现之前页面必须加载哪些数据。对于与用户相关的数据(用户名,通知 ...),这往往很容易,但是对于特定于页面的内容,则比较棘手。考虑确定最重要的页面并为这些页面编写自定义逻辑。
等待非必需数据时不要阻塞渲染
有时生成页面数据需要缓慢的复杂后端逻辑。在这些情况下,如果足以使你的应用程序具有功能性和交互性,则可以首先加载较简单的数据版本。
例如,分析工具可以在加载图表数据之前首先加载所有图表的列表。这使用户可以立即查找他们感兴趣的图表,还可以帮助将后端请求分散到不同的服务器上。
避免顺序数据请求链
这可能与我先前关于在第二个请求中加载非必需数据的观点相冲突,但是如果每个完成的请求都不会导致向用户显示更多信息,则避免顺序请求链。
与其先发出关于用户登录身份的请求,然后再请求其所属团队的列表,不如在用户信息旁边返回团队列表。你可以使用 GraphQL ,但自定义用户呢? includeTeams=true endpoint 也很有用。
与其首先请求用户登录为谁,然后请求他们所属的团队列表,
服务端端渲染
服务端端渲染意味着在服务器上预渲染你的应用程序,并使用整页HTML响应文档请求。这意味着客户端可以看到完全呈现的页面,而不必等待加载其他代码或数据!
由于服务器只是将静态HTML发送给客户端,因此你的应用尚无法进行交互。需要加载应用程序,它需要重新运行呈现逻辑,然后将必要的事件侦听器附加到DOM。
如果看到非交互式内容很有价值,请使用服务器呈现。如果你能够将呈现的HTML缓存在服务器上并将其提供给所有用户而又不会延迟初始文档请求,那么它也将有所帮助。例如,如果你使用 React 来渲染博客文章,则服务器渲染非常合适。
下一页
在某个时候,用户将与你的应用进行交互并转到下一页。打开初始页面后,你可以控制浏览器中发生的事情,因此你可以准备进行下一次交互。
预取资源
如果你预加载了下一页所需的代码,则可以消除用户启动导航时的延迟。使用 prefetch 标记,或 webpackPrefetch 用于动态导入:
- import(
- /* webpackPrefetch: true, webpackChunkName: "todo-list" */"./TodoList"
- )
注意你使用了多少用户数据和带宽,特别是当他们使用移动连接时。如果他们使用的是你网站的移动版本,或者他们启用了保存数据模式,你可以减少预加载。
对于用户最可能需要的应用程序部分,要有策略。
重用已经加载的数据
在应用程序中本地缓存 Ajax 数据,并使用它来避免未来的请求。如果用户从团队列表导航到“编辑团队”页面,你可以通过重用已经获取的数据来立即进行转换。
请注意,如果你的实体经常被其他用户编辑,并且你下载的数据可能已经过期,那么这种方法将不起作用。在这些情况下,在获取最新数据时,请首先考虑以只读方式显示现有数据。
结论
本文介绍了许多因素,这些因素可能会在加载过程的不同时刻使你的页面速度减慢。使用 Chrome DevTools,WebPageTest和Lighthouse之类的工具来确定其中哪些适用于你的应用程序。
实际上,你几乎不可能在所有方面进行优化。找出对用户有最大影响的因素,并专注于此。
我在写这篇文章时意识到的一件事是,我根深蒂固地相信,发出许多单独的请求对性能不利。过去,当每个请求都需要一个单独的连接时,Thas就是这样,而浏览器每个域只允许几个连接。但是,使用 HTTP/2 和现代浏览器已不再是这种情况。
并且有强烈的理由支持拆分请求。它允许仅加载必要的资源,并可以更好地利用缓存的内容,因为仅需要重新加载已更改的文件。