4个月内优化Next.js增加搜索流量20倍的7个技巧


八月,我们对整个网站进行了重大改造:当我们最终于 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 上发布了移动应用,此后一直专注于在这两个平台上打造可靠的体验。

以下是我们最近更新之前的典型页面请求:

  1. 用户:加载一个页面,例如《王者之路》
  2. 精装本:将图书页面的相同 HTML 发送给客户端。
  3. 用户:从 Hardcover API 请求他们的 API 令牌。
  4. 用户:请求有关当前用户的信息(以在导航中显示他们的头像)。
  5. 用户:请求有关王者之路的信息。
  6. 用户:请求有关其本书状态的信息。

在这种情况下,Next.js 应用程序没有做太多事情。发送给用户的图书页面 HTML/JS 对于每个页面都是相同的,然后在客户端我们会发出 API 请求来获取要显示的数据。它确实有效,但这意味着在用户看到任何内容之前需要进行大量 API 调用。

如果您今天在未登录的情况下加载此页面,您将看到API 请求为零。您看到的所有内容都是由服务器以初始 HTML 形式发送的!
这是新的流程:

  1. 用户:加载一个页面,比如《王者之路》。
  2. Next.js:分两部分处理此页面
    • 对于页面的布局和包装,Next.js 确定用户是否已登录,如果登录则显示不同的标题。
      • 该布局还包括用户 API 令牌(访客令牌或与其用户绑定的令牌)。
  • 对于此路由,Next.js 会将此页面上的所有网络请求缓存一个小时,从而导致该时间段内的每个请求都为图书页面生成相同的 HTML。
  • 用户浏览器:浏览器读取初始 HTML(如果他们已登录,则可能有他们的头像,否则有登录链接)。
    • 客户端:没什么可做的了!来自服务器的初始 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>
      <body>
        <Providers>
          <CurrentUserLoader />

          <Nav />
          {children}
          <Footer />

          <SharedPlaceholders />
          <BackgroundManager />
        </Providers>
      </body>
    </html>


    components/background/CurrentUserLoader.tsx

    import { Suspense } from "react";
    import { loadCurrentSession } from
    "queries/users/loadCurrentSession";
    import CurrentUserClientLoader from
    "./CurrentUserClientLoader";

    // Loads everything about the logged in user on the client side
    export default async function CurrentUserLoader() {
      const { session, user } = await loadCurrentSession();

      return (
        <Suspense>
          <CurrentUserClientLoader session={session} user={user} />
        </Suspense>
      );
    }

    到目前为止,一切都完全发生在服务器上。

    最后一个文件(CurrentUserLoader.tsx)的职责只有一个:加载当前用户并将其传递给客户端组件。 loadCurrentSession(未显示)将从用户的 cookie 中获取用户信息,并点击我们的 GraphQL API 获取用户所需的所有数据。

    这包括他们的用户名和头像,还包括他们阅读过的每本书的状态。稍后将详细介绍我们为什么需要这些数据。

    这些数据将传入 CurrentUserClientLoader 组件。这是连接服务器端和客户端的桥梁。
    这个文件有很多功能:

    components/background/CurrentUserClientLoader.tsx

    "use client";

    import { Suspense, lazy, useEffect, useRef } from
    "react";
    import { useDispatch } from
    "react-redux";
    import { currentUserActions } from
    "features/currentUser/currentUserSlice";
    import { UserType } from
    "types";
    import { HardcoverSession } from
    "app/(api)/api/auth/[...nextauth]/options";
    import { bootstrapUserByUserId } from
    "queries/users/bootstrapUserById";
    import { getClient } from
    "lib/apollo/client";

    const NotificationsUpdater = lazy(() => import(
    "./NotificationsUpdater"));
    const CurrentUserClientManager = lazy(
      () => import(
    "./CurrentUserClientManager")
    );

    // 在客户端加载登录用户的所有信息
    interface Props {
      session: HardcoverSession;
      user?: UserType;
    }
    export default function CurrentUserClientLoader({ session, user }: Props) {
      const initialized = useRef(false);
    // Prevents duplicate loading for some reason
      const loaded = useRef(false);
      const dispatch = useDispatch();

     
    // 这将把所有引导数据加载到 Apollo 的片段缓存中
     
    // 题外话:
     
    //   我很想去掉这个,把服务器缓存
     
    //   移交给客户端缓存,但目前还不可能。
      function loadFragmentCache() {
        getClient().writeQuery({
          query: bootstrapUserByUserId,
          data: { user },
          variables: {
            userId: user.id,
          },
        });
      }

     
    // 在 Redux 中设置会话和用户
      useEffect(() => {
        if (!initialized?.current) {
          initialized.current = true;
          if (user) {
            loadFragmentCache();
          }

          dispatch(currentUserActions.setSession(session));
          dispatch(currentUserActions.setInitialUser(user as UserType));
          loaded.current = true;
        }
      }, []);

      if (!loaded) {
        return false;
      }

      return (
        <Suspense>
          <CurrentUserClientManager />
          <NotificationsUpdater />
        </Suspense>
      );
    }

    在该文件中,我们将数据从服务器移交给了客户端。这涉及三个重要步骤:

    • 将用户数据载入 Apollo 缓存
    • 将当前用户加载到 Redux
    • 加载客户端组件,使 Apollo 缓存和 Redux 保持同步。

    这里有很多事情要做,但这些都是重要的部分。我们使用 Suspense 尽可能多地延迟这些操作,这样就不会阻塞初始页面加载,我们就能在运行过程中加载更重要的 JavaScript。这也意味着,除非用户已登录,否则不会下载 CurrentUserClientManager 和 NotificationsUpdater。

    最后一部分(代码如下所示)是客户端组件,它将使 Redux 与 Apollo 的缓存保持同步。这意味着当用户更改用户名或头像时,我们将在这里进行更新。

    用户可以在很多地方更改自己的用户信息。我们曾考虑在每个地方都进行更新。将其放在一个地方,我们就不太可能漏掉一个地方,从而导致整个用户状态失灵。

    其中的 "奥妙 "在于 useFragment 调用。因为我们已经在前一个组件中设置了缓存,所以该调用将获取该片段,而无需调用 API。

    但是,如果您正在使用网站并登录,我们将使用此调用进行初始调用并填充缓存。它的速度快得惊人,甚至不需要重新加载页面。

    components/background/CurrentUserClientManager.tsx

    "use client";

    import { useEffect, useRef } from
    "react";
    import { useDispatch, useSelector } from
    "react-redux";
    import { useFragment, useQuery } from
    "@apollo/client";
    import {
      getReloadUser,
      getTokenSelector,
      getUserId,
    } from
    "features/currentUser/currentUserSelector";
    import { useCurrentSession } from
    "hooks/useCurrentSession";
    import { currentUserActions } from
    "features/currentUser/currentUserSlice";
    import OwnerFragmentCompiled from
    "queries/users/fragments/OwnerFragmentCompiled";
    import { UserType } from
    "types";
    import { bootstrapUserByUserId } from
    "queries/users/bootstrapUserById";

    // Loads everything about the logged in user on the client side
    export default function CurrentUserClientManager() {
      const dispatch = useDispatch();
      const userId = useSelector(getUserId);
      const token = useSelector(getTokenSelector);
      const { resetSession } = useCurrentSession();
      const refreshing = useSelector(getReloadUser);
      const startedRefresh = useRef(false);

      useEffect(() => {
        if (refreshing) {
          startedRefresh.current = true;
        }
        if (!refreshing && startedRefresh.current) {
          startedRefresh.current = false;
        }
      }, [refreshing]);

      const { data: currentUserData, complete } = useFragment({
        fragment: OwnerFragmentCompiled,
        fragmentName:
    "OwnerFragment",
        from: {
          __typename:
    "users",
          id: userId || 0,
        },
      });

      const { loading } = useQuery(bootstrapUserByUserId, {
        fetchPolicy:
    "cache-and-network",
        skip: !userId || (complete && !refreshing),
        variables: {
          userId,
        },
      });

     
    // Reset the session if the user logs out or logs back in
      useEffect(() => {
        if (loading) {
          return;
        }

        const currentUser = {
          ...currentUserData,
          notificationsCount: currentUserData.notifications?.aggregate?.count,
        };

        if (complete && userId !== currentUser?.id) {
          resetSession();
        } else if (token) {
         
    // Done loading user, or user cache changed
          if (userId && currentUser?.id) {
            dispatch(currentUserActions.setUser(currentUser as UserType));
          }
         
    // No current user, done loading
          if (!userId) {
            dispatch(currentUserActions.setUser(null));
          }
        }
      }, [token, userId, currentUserData, startedRefresh?.current]);

      return false;
    }


    我不敢说这是处理这种情况的最佳方法,但这是我们找到的最好的方法。它还有一个额外的好处:为你读过的每本书保存状态(这对接下来的第 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(来自 Adob​​e 的衬线字体)。

    最初,我们将加载我们的global.css文件,该文件将从 Adob​​e 加载另一个 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 和性能的后续步骤
    我们还有很多需要改进的地方。最大的问题之一是我们的列表在客户端加载所有内容。我们正在努力重组这些内容以在服务器端进行渲染。我对这个开关感到很兴奋,因为它还允许我们做更多的排序和过滤选项。