前端架构性能优化策略
本文经原作者授权,由 InfoQ 中文站翻译并分享。
这篇文章介绍了一些能让前端应用加载更快,并能提供良好用户体验的技术。
我们要看待的是前端的总体架构。你该如何先加载必需的资源,并尽量提升资源在缓存中的命中概率?
本文不会过多涉及后端交付资源的方式,以及你的页面是否需要客户端应用,或者如何优化应用程序的渲染时间这些内容。
我把应用加载的过程分为三个阶段:
初始渲染——用户需要多长时间才能开始看到内容?
应用加载——用户需要多长时间才能开始使用应用?
-
下一页——转到下一页需要多长时间?
在浏览器的初始渲染完成之前,用户是看不到任何内容的。页面的渲染工作最起码要加载 HTML 文档,但大多数时候还需要加载其他资源,例如 CSS 和 JavaScript 文件。一旦这些内容处于可用状态,浏览器就可以开始在屏幕上绘制内容了。
(旁注:gov.uk 现已启用 HTTP/2,因此资产域可以复用已有的 www.gov.uk 连接!后文会详细讨论服务器连接。)
样式表和(默认情况下的)脚本元素会阻塞它们下面任何内容的渲染过程。
你可以通过以下几种方法来解决这个问题:
将脚本标签放在 body 标签的底部;
使用 async 异步加载脚本;
内联小型 JS 或 CSS 代码段(如果它们需要同步加载)。
拖累你站点速度的不一定是阻塞渲染的请求数量。更重要的是每种资源的下载体积过大,以及浏览器发现自己需要加载资源的情况太多了。
如果浏览器只有在一个请求完成后才发现自己需要加载一个文件,那么你可以使用同步请求链来提升效率。发生这种情况可能有多种原因:
CSS 中的 @import 规则;
CSS 文件中引用的 Webfonts;
JavaScript 注入链接或脚本标签。
看一下这个例子:
该网站在其中一个 CSS 文件中使用了 @import 来加载一个谷歌字体。这意味着浏览器需要一个接一个地发出下面这些请求:
Document HTML
应用程序 CSS
谷歌字体 CSS
谷歌字体 Woff 文件(在瀑布图中未显示)
要解决这个问题,首先将针对谷歌字体 CSS 的请求从 @import 移至 HTML 文档中的一个链接标签上。这样就从链条上拿掉了一个环节。
为了进一步加快速度,请直接在你的 HTML 或 CSS 文件中 内联谷歌字体 CSS 文件。
(请注意,来自谷歌字体的 CSS 响应取决于用户代理。如果你使用 IE8 发出请求,则 CSS 将引用一个 EOT 文件;IE11 将获得一个 woff 文件,现代浏览器将获得一个 woff2 文件。但是,如果你觉得旧版浏览器可以使用系统字体,那就可以简单地复制并粘贴 CSS 文件的内容。)
即使页面开始渲染之后,用户可能还是无法对该页面执行任何操作,因为在字体加载完毕之前不会显示任何文本。可以通过 font-display swap 来避免这种情况,这样就会默认使用谷歌字体。
有时,消除请求链是不可行的。在这些情况下,你可以考虑 预加载或预连接 标签。例如,在发出实际的 CSS 请求之前,上述网站可以连接到 fonts.googleapis.com。
建立一个新的服务器连接通常需要在服务器和浏览器之间进行 3 次往返:
DNS 查询;
建立一个 TCP 连接;
建立一个 SSL 连接。
连接就绪后,至少需要再进行一次往返,才能发送请求并下载响应。
下面的瀑布图显示了连接已经启动到了四个不同的服务器上:hostgator.com,optimize.com,googletagmanager.com 和 googelapis.com。
但是,对同一服务器的后续请求可以复用现有连接。因此加载 base.css 或 index1.css 的速度很快,因为它们也托管在 hostgator.com 上。
除了文件大小以外,还有另外两个因素会影响请求时间,并且都在你的控制范围内:那就是资源大小和服务器位置。
尽量减少要向用户发送的数据,并确保数据都是压缩过的(例如使用 brotli 或 gzip)。
内容交付网络在众多地理位置上提供了服务器,因此其中之一可能正好位于你的用户附近。用户可以连接到附近的 CDN 服务器,而不必连接到你的应用程序中央服务器上。这意味着服务器往返时间将大大缩短。这对于诸如 CSS、JavaScript 和图像之类的静态资产来说特别方便,因为它们很容易分发。
服务 workers 可以让你在请求进入网络之前拦截它们。这意味着你的 第一批绘制实际上是即时的!
当然,这只在不需要网络发送响应的情况下才有效。你需要有缓存好的响应,因此用户只会在第二次加载你的应用时受益。
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);
})
);
});
阅读这份指南以了解有关使用服务 workers 预加载和缓存资源的更多信息:
https://developers.google.com/web/ilt/pwa/caching-files-with-service-worker
好的,现在用户可以看到一些东西了。他们开始使用你的应用之前还需要什么内容?
加载应用程序代码(JS 和 CSS)
加载页面的基本数据
加载其他数据和图像
请注意,会延迟渲染工作的不仅是从网络加载数据的过程。代码加载完毕后,浏览器还需要解析、编译和执行它。
包拆分使你可以只加载当前页面所需的代码,而不是加载整个应用。包拆分也意味着你可以缓存其中的某些部分,就算其他部分已更改并且需要重新加载也不影响前者。
一般来说,代码会被拆分为三种不同类型的文件:
网页专属代码
共享应用程序代码
很少更改的第三方模块(非常适合缓存!)
Webpack 可以使用 optimization.splitChunks 自动拆分共享代码,以减少总的下载数据量。一定要启用运行时块(runtime chunk),以稳定块哈希,并从长期缓存中受益。Ivan Akulov 撰写了一份有关 Webpack 代码拆分和缓存的深度指南:
https://developers.google.com/web/fundamentals/performance/webpack/use-long-term-caching
专属代码的拆分是无法自动完成的,你需要确定可以单独加载的是哪些数据。通常这是一条特定的路径或一组页面。使用动态导入来延迟加载这些代码:
https://webpack.js.org/guides/code-splitting/#dynamic-imports
使用包拆分后,在加载你的应用时会发出更多请求。但只要能并行发出请求就不会有什么大问题,尤其是当你的站点通过 HTTP/2 提供服务时更不在话下。下面这张瀑布图的前三个请求就是一个例子:
不过,这张瀑布图里还显示了 2 个顺序提出的请求。这些块是只有这个页面需要的,并通过一个 import() 调用来动态加载。
如果你知道页面将需要这些块,则可以插入一个预加载链接标签来解决这个问题。
https://www.debugbear.com/blog/resource-hints-rel-preload-prefetch-preconnect#preload
但你会看到,这样的做法所减少的页面加载时间与总的加载时间相比可能是微不足道的。
另外,使用预加载策略有时会适得其反,因为在加载其他更重要的文件时可能会带来延迟。请查看 Andy Davies 撰写的关于预加载字体的文章,你就会知道在加载阻塞渲染的 CSS 之前加载字体,也是会影响初始加载过程的。
https://andydavies.me/blog/2019/02/12/preloading-fonts-and-the-puzzle-of-priorities/
你的应用程序可能是为了显示一些数据而存在的。这里有一些技巧,你可以用它们来尽早加载这些数据并避免渲染延迟。
这是顺序请求链的一种特例:先加载应用程序包,然后代码请求页面数据。
有两种方法可以避免这种情况:
将页面数据嵌入 HTML 文档中;
通过文档中的内联脚本启动数据请求。
将数据嵌入 HTML 可以确保你的应用无需等待数据加载。这也降低了应用程序的复杂性,因为你不必处理加载状态。
但如果获取数据的过程会显著延迟你的文档响应,这就不是一个好主意,因为这会延迟你的初始渲染。
window.userDataPromise = fetch("/me")
然后当数据准备就绪时,你的应用程序就可以立即开始渲染。对于这两种技术来说,你都需要知道在应用开始渲染之前页面必须加载哪些数据。对于与用户相关的数据(用户名、通知……)来说这往往很容易,但对于特定于页面的内容来说就比较麻烦了。可以考虑找出最重要的页面,并为这些页面编写自定义逻辑。
有时生成页面数据的过程需要缓慢的复杂后端逻辑。在这种情况下你可以首先加载较简单的数据版本,只要这些数据足以让你的应用程序运转起来即可。
例如,一个分析工具可以在加载图表数据之前首先加载所有图表的列表。这使用户可以立即查找他们感兴趣的图表,还有助于将后端请求分散到不同的服务器上。
这可能与我在前文中关于在第二个请求中加载非必需数据的观点相冲突,但是如果每个完成的请求都不会向用户显示更多信息,则应该避免顺序请求链。
与其先发出有关用户登录身份的请求,然后再请求其所属团队的列表,不如返回团队列表以及用户信息。你可以使用 GraphQL 来做,但是自定义的 user?includeTeams=true 端点也很好用。
服务端渲染意味着在服务器上预渲染你的应用,并使用整页 HTML 响应文档请求。这意味着客户端可以看到完整渲染的页面,而不必等待额外的代码或数据加载!
由于服务器只是将静态 HTML 发送给客户端,因此你的应用尚无法进行交互。你需要加载应用程序,然后重新运行渲染逻辑,接下来将必要的事件侦听器附加到 DOM 上。
如果想让用户及时看到非交互内容,请使用服务端渲染。如果你能够在服务器上缓存渲染完毕的 HTML 并将其提供给所有用户,而又不会延迟初始文档请求,那么也可以使用服务端渲染。例如,如果你使用 React 来渲染博客文章,则服务端渲染就非常合适了。
阅读 Michał Janaszek 的这篇文章,以了解如何将服务 worker 与服务端渲染结合在一起使用。
https://michaljanaszek.com/blog/combine-pwa-and-isomorphic-rendering
在某个时候,用户将与你的应用进行交互并转到下一页。初始页面被打开后,你就可以控制浏览器中发生的事情,因此你这时候就可以准备下一次交互了。
import(
/* webpackPrefetch: true, webpackChunkName: "todo-list" */ "./TodoList"
)
在应用本地缓存 Ajax 数据,并使用它来避免将来的请求。如果用户从团队列表页面跳转到“编辑团队”页面,则可以重用已经获取的数据来实现即时跳转。
请注意,如果你的实体被其他用户频繁编辑,那么这个方法就没用了,并且你下载的数据可能很快就会过期。在这些情况下,请考虑在获取最新数据时首先以只读方式显示现有数据。
本文介绍了许多因素,它们可能会在加载过程中的不同时刻拖慢你的页面速度。使用 ChromeDevTools、WebPageTest 和 Lighthouse 之类的工具来确定其中哪些因素出现在你的应用里。
实际上,你很难在所有方面都做好优化工作。找出对用户影响最大的因素,并专心解决它们即可。
我在写这篇文章时意识到的一件事是,我以前坚信一点,那就是发出许多单独的请求对性能不利。过去,当每个请求都需要一个单独的连接时就是这样的,那时候浏览器的每个域只允许少数几个连接。然而,使用 HTTP/2 的现代浏览器已不再是这种情况。
此外我们有充分的理由来拆分请求。这样就可以只加载必要的资源,并能更好地利用缓存的内容,因为我们只需重新加载已更改的文件即可。
点个在看少个 bug 👇