1

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

 7 months ago
source link: https://www.jdon.com/72401.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK