八月,我们对整个网站进行了重大改造:当我们最终于 2023 年 8 月 15 日推出时,我们的 Google Pagespeed 几乎完美!
对于主页,下载大小从 500k 减少到 80k。这样一来,需要下载的 JavaScript 就少了很多,但运行的JavaScript 也少了很多。
此更新不仅关注用户体验,还关注使精装版变得更快。我的目标是在 Google 上实现 100% PageSpeed,无布局变化,以及即时初始页面加载,并在靠近用户的 CDN 上缓存尽可能多的内容。
为什么速度很重要?
速度对于任何网站的表现都起着重要作用。除了为用户提供更好的体验之外,谷歌和其他搜索引擎在决定对您的网站进行排名时非常重视速度。
今年早些时候,我们看到了一个令人不安的趋势:我们的搜索点击量大幅下降。事实证明,由于我(无意中)做出的一些技术更改以及 Google 排名算法的一些更改,我们的 PageSpeed 指数有所下降。
速度对搜索引擎结果的影响有多大是一个复杂的话题。在这篇文章的 Reddit 讨论中,加快网站速度可能并不是提高搜索流量的唯一方法:
2023 年 5 月,我们开始将应用程序从页面路由器升级到应用程序路由器。整个更新花费了大约 4 个月的时间,但与此同时,我们还重新设计了一堆页面,以便更好、更快地工作。
尽管可以选择增量改进,但获取主要布局、导航和用户状态仍然是困难的部分。我们大多只是复制各个页面并继续在客户端上呈现(一开始),然后一次移动一个页面。
结果是美好的,但过程却充满了坎坷和坎坷。其中最重要的是了解缓存、本地开发速度、不断需要重新启动的本地缓存问题、重新验证缓存,我是否提到了缓存?
网站的速度得到了突飞猛进的提高,从客户端完全水合的静态 HTML,到仅包含少量客户端组件的服务器端渲染。
最初的努力得到了回报,现在我无法想象回到页面路由器。我最兴奋的是流式 HTML,它允许网站变得更快,同时将其中一些内容从客户端移回服务器。
我们还使用新数据重组了一些页面,这可能会增加内容和结构。我们还继续看到更多的引用域——谷歌将其视为信任票,并有助于搜索。
SEO 是一个复杂的话题,众所周知,很难理解任何单一原因如何转化为效果。两个方向的流量和页面大小之间似乎确实存在相关性。在 App Router 更新之前的几个月,我向网站添加了更多复杂性和 JavaScript。在那段时间里,我们的搜索流量下降了——尽管我们的推荐域名上升了。除了页面速度之外,还有不同的因素。
这种下降的相关性让我意识到:也许我们需要更多地关注我们的网站速度。
下面是我们如何加快速度的
当您请求 Hardcovers 主页(以及网站上越来越多的页面)时,您看到的内容完全由 Next.js 13 14 App Router 在服务器上呈现。
情况并非总是如此。直到今年八月,我们都在使用 Pages 路由器 - 通常没有任何服务器道具。
最初的想法是该网站将 100% 静态生成,所有数据都来自客户端的 API。这将使我们能够使用相同的设置轻松过渡到使用 React Native 的移动应用程序。
一旦我们意识到Capacitor.js可以封装我们的网站,这种优势就变得毫无意义。我们可以开发一个网站并用 Capacitor 包装它。我们于 2023 年 3 月在 Android 和 iOS 上发布了移动应用,此后一直专注于在这两个平台上打造可靠的体验。
以下是我们最近更新之前的典型页面请求:
- 用户:加载一个页面,例如《王者之路》。
- 精装本:将图书页面的相同 HTML 发送给客户端。
- 用户:从 Hardcover API 请求他们的 API 令牌。
- 用户:请求有关当前用户的信息(以在导航中显示他们的头像)。
- 用户:请求有关王者之路的信息。
- 用户:请求有关其本书状态的信息。
在这种情况下,Next.js 应用程序没有做太多事情。发送给用户的图书页面 HTML/JS 对于每个页面都是相同的,然后在客户端我们会发出 API 请求来获取要显示的数据。它确实有效,但这意味着在用户看到任何内容之前需要进行大量 API 调用。
如果您今天在未登录的情况下加载此页面,您将看到API 请求为零。您看到的所有内容都是由服务器以初始 HTML 形式发送的!
这是新的流程:
- 用户:加载一个页面,比如《王者之路》。
- Next.js:分两部分处理此页面
- 对于页面的布局和包装,Next.js 确定用户是否已登录,如果登录则显示不同的标题。
- 该布局还包括用户 API 令牌(访客令牌或与其用户绑定的令牌)。
- 客户端:没什么可做的了!来自服务器的初始 HTML 包含所有内容
- 已登录:返回的 HTML 包含仅在登录时显示的部分(您的本书状态、好友活动、类似读者、匹配百分比等)。
由于图书路由缓存了一个小时,因此进一步加快了速度。目前,这是用于路由的缓存export const revalidate = 3600;,但是我们希望完全缓存整个路由。
尽管此页面是在服务器上生成的,但它包含许多使用岛屿架构的客户端组件
现在,最终用户需要减少了 4 个 API 请求。这也意味着谷歌和其他搜索引擎的故障点减少了 4 个。
这对下面指标帮助:
- 累积布局转变、、
- 最大的内容绘制、
- 避免大的布局转变、
- 最小化主线程工作、
- 减少 JavaScript 执行时间、
- 避免长时间的主线程任务。
您可能会问:“但是这个页面上有动态数据!怎么才能缓存呢? ”对此有一些解决方案。
2. 获取服务器端,Hydrate 客户端
如果您登录精装版,您将在每个页面的右上角看到您的头像。一些导航链接也是根据您的用户名动态的图标。
我们可以在服务器端为已登录的用户显示该内容,这样就可以了。我们甚至可以在用户更改头像或用户名时进行全页面重载(window.location = window.location.href)。
我们最初是这么做的,但 Capacitor 出了问题。如果你在 Capacitor 中设置 window.location,它不会重新加载页面,而是会退出应用并在网页浏览器中打开当前页面。这个解决方案被淘汰了。
那么,我们该如何使用这些链接启动页面,同时又允许更改和加载这些链接而无需重新加载整个页面呢?
解决方案来自 Apollo Client 库(我们用来获取数据的库)中的一项新功能,名为 useFragment。为了解决这个问题,我花了几个星期的时间反复试验,但我对这个解决方案很满意。
我们的解决方案从布局文件开始。下面是该模板的样子。请注意,<CurrentUserLoader /> 正在进行大量工作。
app/layout.tsx
<html> |
components/background/CurrentUserLoader.tsx
import { Suspense } from "react"; |
到目前为止,一切都完全发生在服务器上。
最后一个文件(CurrentUserLoader.tsx)的职责只有一个:加载当前用户并将其传递给客户端组件。 loadCurrentSession(未显示)将从用户的 cookie 中获取用户信息,并点击我们的 GraphQL API 获取用户所需的所有数据。
这包括他们的用户名和头像,还包括他们阅读过的每本书的状态。稍后将详细介绍我们为什么需要这些数据。
这些数据将传入 CurrentUserClientLoader 组件。这是连接服务器端和客户端的桥梁。
这个文件有很多功能:
components/background/CurrentUserClientLoader.tsx
"use client"; |
在该文件中,我们将数据从服务器移交给了客户端。这涉及三个重要步骤:
- 将用户数据载入 Apollo 缓存
- 将当前用户加载到 Redux
- 加载客户端组件,使 Apollo 缓存和 Redux 保持同步。
这里有很多事情要做,但这些都是重要的部分。我们使用 Suspense 尽可能多地延迟这些操作,这样就不会阻塞初始页面加载,我们就能在运行过程中加载更重要的 JavaScript。这也意味着,除非用户已登录,否则不会下载 CurrentUserClientManager 和 NotificationsUpdater。
最后一部分(代码如下所示)是客户端组件,它将使 Redux 与 Apollo 的缓存保持同步。这意味着当用户更改用户名或头像时,我们将在这里进行更新。
用户可以在很多地方更改自己的用户信息。我们曾考虑在每个地方都进行更新。将其放在一个地方,我们就不太可能漏掉一个地方,从而导致整个用户状态失灵。
其中的 "奥妙 "在于 useFragment 调用。因为我们已经在前一个组件中设置了缓存,所以该调用将获取该片段,而无需调用 API。
但是,如果您正在使用网站并登录,我们将使用此调用进行初始调用并填充缓存。它的速度快得惊人,甚至不需要重新加载页面。
components/background/CurrentUserClientManager.tsx
"use client"; |
我不敢说这是处理这种情况的最佳方法,但这是我们找到的最好的方法。它还有一个额外的好处:为你读过的每本书保存状态(这对接下来的第 3 条很重要)。
有什么帮助?累积布局偏移、最大内容涂抹、避免大的布局偏移、最小化主线程工作、减少 JavaScript 执行时间、避免长的主线程任务。
3.引导最重要的数据
Hardcover 最核心的功能是允许读者追踪他们已经阅读和想要阅读的书籍。这是我们的 "杀手锏 "功能,我们希望尽可能完善它。
在我们的 PostgreSQL 数据库中有一个名为 user_books 的表。该表有 user_id、book_id 和 status_id 三列。我们会显示你的状态,如果你之前没有与这本书进行过互动,则会显示一个灰色按钮。
但最大的问题是 "我们在哪里加载这些数据?最初,我们会在载入图书数据的同一查询中载入读者与图书的状态。当我们在客户端加载所有内容时,这样做是行得通的。现在我们在服务器端加载这些数据,如果我们使用同样的方法,就无法缓存任何内容。
解决这个问题的方法是使用一种叫做引导数据bootstrapping data的技术。在初始用户加载时,我们也会加载他们每本书的状态。即使对于保存了 10,000 本图书的读者来说,这也只需要不到 100 毫秒,因为这只是 3 个整数。
详细点击标题
4. 创建 Ghost 组件
我们到处使用的一个库是HeadlessUI。我们将Menu和用于Popover下拉菜单、Combobox自动完成、Dialog搜索Modal等。
但是,在每个页面上加载此库会额外增加 50kb 或更多的 JavaScript 以及需要在每个页面请求上编译的其他代码。这看起来可能不是很多,但足以让我们的移动 Google PageSpeed 得分降低 10 分。
当您现在加载页面时,我们不会下载 HeadlessUI,直到您与需要它的组件进行交互。
5.优化字体加载
我有一段时间忽略了这个问题,但解决方案非常简单。
我们在精装本上使用两种字体:Inter(来自 Google Fonts 的无衬线字体)和 New Spirit(来自 Adobe 的衬线字体)。
最初,我们将加载我们的global.css文件,该文件将从 Adobe 加载另一个 CSS 文件,然后加载字体。
谷歌为这个问题起了一个名字:“避免链接关键请求”。为了加载页面,我们需要等待4 个链式请求完成!
Next.js 来救援!他们有两个库可以帮助解决这个问题:next/font。这些将完成加载这些字体并将其值注入到 body 标记中的 CSS 变量中的所有工作。我们可以在 TailwindCSS 配置中使用这些变量。
这意味着您的浏览器将立即开始加载这些字体,而不是在 4个 链系列结束时开始加载。
其次,Next.js 会将这些字体的 CSS 添加到您加载的第一个 CSS 中。这意味着当解析第一个 CSS 文件时,它应该已经开始预加载字体,并且可以在读取 CSS 的同时开始使用它们。现在字体加载速度如此之快,我什至没有注意到字体交换。
这对下面指标帮助:
- 避免链接关键请求、
- 最大的内容绘制元素、
- 总阻塞时间。
6. 删除多余的提供商
React 中的提供程序是您可以将整个应用程序包装在其中的组件。它们的功能可以从嵌套在其中的任何组件访问——无论深度如何。
在精装本的最初版本中,我们滥用了这个概念。我们有十几个提供商,当我们需要某些东西时,我们会随意添加它们。每当其中任何一个重新渲染时,整个页面都会重新渲染。有时这甚至会导致页面无法使用。
在从客户端渲染到服务器端渲染的迁移中,我们将提供程序缩小到只有三个:
- BugSnag – 我们用它来跟踪错误。
- Apollo – 我们的网络层和网络缓存
- Redux – 我们的全局状态管理器。
我什至考虑过用Zustand替换 Redux ,但到目前为止我们还不需要这样做。除了当前用户、Book Drawer 的状态和 UI 的状态(例如:搜索模式是否打开?)之外,我们几乎不使用 Redux。
如果有一个地方是您应该集中注意力的,那就是您的提供商。根据我的绩效分析(接下来),这是我们需要(并且仍然需要)工作的最大领域之一。
旁注:我曾考虑过删除 Apollo 并通过手动配置来使用它。然而,这个评论清楚地表明 Apollo 在服务器端和客户端所做的工作比我意识到的要多得多。
这对下面指标帮助:
- 减少未使用的 JavaScript、
- 最小化主线程工作、
- 减少 JavaScript 执行时间。
7. 使用 Chrome 开发者工具分析应用程序的性能
如果您像我一样,最终您会遇到来自 Google Pagespeed 的可怕的“最小化主线程工作”诊断。这是最难减少的之一。
创建 Ghost 组件会在一定程度上有所帮助,但您可能想要做更多。
我建议您学习的 Chrome 功能之一是如何测试应用程序的性能。
您可以通过导航到要检查的页面,打开 Chrome 开发人员工具,然后单击“开始分析并重新加载页面”按钮(左上角的第二个按钮,看起来像重新加载/刷新)图标来完成此操作。
几秒钟后,页面完全重新加载后,您可以单击“停止”。
接下来,您将非常详细地了解应用程序的运行方式。
- X 轴是时间 , Y 轴显示每个函数调用的函数。您可以深入了解应用程序的哪些功能需要花费最多时间才能完成。
- 条形越长,该函数的执行时间就越长。
- 首先要查找的部分是上面覆盖有红色警告的部分,表明这些任务背Chrome 认为是一项“漫长的任务”。
这些长时间任务是提高绩效的理想起点。当我在精装本上进行此练习时,发现三个区域的运行时间最长:
- Providers 提供商——精装版有很多提供商。其中包括主题(深色模式与浅色模式)、错误缓存、当前用户、下一个身份验证、Apollo 客户端、Redux 等。
- 无头 UI 组件– 我们在导航以及每个页面的其他部分中使用了这些组件。
- FontAwesome Icons – 我们随处展示的图标。
其中每一个都有其自己的解决方案,这些解决方案非常适合我们的应用程序。
对于提供商,我们将需求缩小到了三个(错误捕捉、Apollo 和 Redux)。其他所有组件我们都移到了 BackgroundProcesses 组件中,该组件在布局中最后加载。
该文件以异步方式处理工作,不会降低页面渲染速度。其中包括主题管理、移动管理、保存推荐人、Plausible Analytics、预加载资源等。
对于无头 UI 组件,我们转而使用 Ghost 组件(如上所述的 #4)。这将渲染时间从 50ms 缩短到了 12ms,同时下载的 JavaScript 也减少了 50kb 以上。
对于FontAwesome Icons,我有点太过分了。我无法找到一个好的解决方案(我很好奇对此的反馈)。我最终将所有 FontAwesome 图标复制到我们的存储库中,将它们加载为 SVG 并将其传递到新的自定义组件中。现在,FontAwesome 库不再产生任何开销,并且每个 SVG 都包含在传递下来的 HTML 中。
SEO 和性能的后续步骤
我们还有很多需要改进的地方。最大的问题之一是我们的列表在客户端加载所有内容。我们正在努力重组这些内容以在服务器端进行渲染。我对这个开关感到很兴奋,因为它还允许我们做更多的排序和过滤选项。