Next.js 13 App Router 在业务中大量实践总结的 15 条经验

3,973 阅读14分钟

643d2c0393559968614657advikX4rZN01.png

最近业务中用到 Next.js 13 开发项目,遇到不少问题,这里总结一下。之前写过一篇,不太完善,这里重新发一篇。

另外,如果对 Next.js 13 App Router 不了解,推荐先看这篇:Next.js 13 新特性汇总

1、如何理解 Next.js,全栈框架,服务端渲染 + 静态资源服务 + Node BFF。因此在 Next.js 中存在两种路由,1)页面路由,即用于返回 HTML 页面的路由(HTML 可以由 SSR 渲染生成,也可以由 SSG 渲染生成),2)API 路由,即 Node BFF 路由。以上两种路由都是约定式路由。与传统前后端不分离项目的区别是啥,Next.js 是同构渲染,即前后端都复用同一套代码。怎么理解 Edge Function?Next.js 的 SSR 渲染基于 Node 服务,如何提升并发性能,解法就是用 Edge Function,实际上就是边缘渲染,类似 CDN,利用距离用户最近的节点渲染,顺便实现了负载均衡。

2、本地开发首屏加载慢:实际上是 Next.js 内部的懒编译(基于请求的按需编译),运行 next dev 启动服务会先编译 initial chunk,用浏览器请求页面路由再按需编译 async chunk。另外,线上静态资源请求过一次就会永久缓存(后续不再发请求),而本地开发则没有配置缓存,每次刷新页面都会重新请求静态文件。

3、Next.js API 路由作用:1)作为 Node BFF,封装业务逻辑,减小 client bundle 体积,2)作为网关代理,隐藏真实后端接口,3)解决接口请求跨域问题。需要注意,Node BFF 做代理会增加系统复杂度,而且 Node 接口请求报错通常难以排查,不如浏览器开发者工具方便。

4、basePathassetPrefix:1)basePath 一般用于非根路径部署,Next.js 的页面路由、API 路由、静态资源服务都会基于此作为路径前缀,2)assetPrefix 是静态资源前缀,一般用于静态资源上传 CDN。

传统 CSR 应用部署常用的方式,一般会在网关层配置域名前缀,比如 /nextChat,当请求转发给应用的时候,会把域名前缀干掉,好处是对应用来说就是根路径部署,无需给应用配置域名前缀。而且路由跳转都在客户端,域名前缀对服务端没啥影响(只负责下发静态资源)。

需要注意,这个部署方式对 Next.js 项目行不通(除非你没用到路由跳转)。Next.js 部署是一个 Node 服务,因此路由关系不仅存在于客户端,也存在于服务端。如果实际部署 /nextChat 路径,请求转发给应用的时候,域名前缀改成 / 路径,这种情况就需要 next.config.js 配置 basePath="/",但是这会导致 Next.js 认为应用就是根路径部署的,客户端路由跳转 router.push("/chat") 会直接跳转到 /chat 而不是 /nextChat/chat,服务端 redirect("/chat") 也会重定向到 /chat 而不是 /nextChat/chat。解法就是网关层请求转发,不能把路径前缀干掉,然后 next.config.js 需要配置实际部署的前缀,比如 basePath="/nextChat"

另外还发现,配置了 basePath 之后,Next.js 自动注入首屏 html 的 public 目录的资源,仍然还是根路径,比如是 /favicon.ico 而不是 /nextChat/favicon.ico,这种情况会导致图标挂掉,还需要同步配置 assetPrefix="/nextChat"

nextjs.org/docs/api-re…

nextjs.org/docs/api-re…

5、Next.js 环境变量:与 CRA 基本一致,区别在于默认只支持 build time 或者 API 路由(Node BFF)中访问,如果需要在浏览器环境访问,需要加上 NEXT_PUBLIC_ 前缀。另外还要注意的是,Next.js 会在两个时机加载环境变量,1)运行 next build 构建,2)运行 next start 启动 Node Server。

6、Next.js 缓存目录:如果是 CRA 项目,Webpack、Babel、swc、ESLint 等编译缓存默认都在 node_modules/.cache 目录,在 Next.js 中改为 .next/cache 目录,因此如果需要 CI 缓存提升构建效率,则需要缓存该目录。

7、Next.js 用 Docker 部署,推荐用 standalone 模式打包,node_modules 里面的依赖包都是独立目录,同时自动移除用于打包构建的依赖,有助于减小 docker 镜像体积。有一个 server.js 入口文件,其中 next.config.js 配置和 Next 默认配置在打包构建的时候已经全部内联了(反之如果没有选择 standalone 模式,则在启动服务的时候动态加载配置)。另外还包括运行需要的配置文件、dotfile,可直接部署。Next.js 工程 Docker 部署推荐看下这篇:

浅谈 Next.js 工程 Docker 部署最佳实践

nextjs.org/docs/advanc…

8、Next.js 最终部署是 Node Server,但是默认没有请求日志,我们部署又需要经过一个 nginx 网关,经过 nginx 网关转发的时候,出现很多 308、404 的报错,由于没有日志,无法看到最终转发给 Node Server 的 URL,难以排查问题。用过 Koa 或者 Express 的同学应该都熟悉中间件,在 Next.js 里面也有中间件,因此可以配置中间件打印日志,方便本地开发、线上部署排查问题:

// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

// This function can be marked `async` if using `await` inside
export function middleware(request: NextRequest) {
  console.log("===request", req.method, req.url);
  return NextResponse.next();
}

nextjs.org/docs/advanc…

nextjs.org/docs/api-re…

9、Next.js 13 启用 app 目录如何在服务端鉴权重定向。启用 app 目录后,不支持原先 pages 的 getServerSideProps() 方法,但是可以用 Next.js 提供的 redirect() 函数:

import { redirect } from 'next/navigation';

async function fetchTeam(id) {
  const res = await fetch('https://...');
  if (!res.ok) return undefined;
  return res.json();
}

export default async function Profile({ params }) {
  const team = await fetchTeam(params.id);
  if (!team) {
    redirect('/login');
  }

  // ...
}

redirect() 函数会抛一个 NEXT_REDIRECT 异常,同时中断当前路由渲染。如果需要重定向到 404 页面,则可以用 notFound() 函数。

Invoking the redirect() function throws a NEXT_REDIRECT error and terminates rendering of the route segment in which it was thrown. redirect() can be called with a relative or an absolute URL.

有时候前端页面 url 会带有 query 参数,在重定向的时候希望保留这些参数,需要怎么做?经过尝试,直接用 redirect("/login") 会丢掉页面 url 的 query 参数,这种情况下只能重定向带上这些参数。于是问题就变成,在 Server Component 如何获取 query 参数?

在 Next.js 文档中,看到一些 API,比如 useSearchParams,文档中明确提到这个是 Client Component hook,不能在 Server Component 中使用。找了很久一直没有找到可以在 Server Component 中获取 query 参数的 API。后来想到,Page 组件应该可以接收 props,看了下文档确实可以,其中 params 是路径参数,searchParams 是 query 参数,会被解析为一个对象:

export default function Page({
  params,
  searchParams,
}: {
  params: { slug: string };
  searchParams: { [key: string]: string | string[] | undefined };
}) {
  return <h1>My Page</h1>;
}

nextjs.org/docs/app/ap…

nextjs.org/docs/app/ap…

10、Next.js BFF 注意事项,为啥前端页面调 BFF 的接口带不上 Cookie,直接调后端的接口可以带上 Cookie。因为有些线上 Cookie 设置了 secure 选项,即只有 https 请求能携带此 cookie,同时本地 node server 又是 http 的,所以导致本地请求 BFF 无法携带 cookie。解法就是本地开发用 https,但是 Next.js 并没有提供 https 选项,可以用自定义 node server 启动 node 服务,同时配置 https、自签名证书,解决该问题。

生成自签名证书:

$ openssl req -x509 -newkey rsa:4096 -keyout ./certificates/localhost.key -out ./certificates/localhost.crt -days 365 -nodes -sha256 \
  -subj '/CN=localhost'

自定义 node server 代码示例:

const { createServer } = require("https");
const { parse } = require("url");
const next = require("next");
const fs = require("fs");

// 自定义 server 仅用于本地开发
const dev = true;
const hostname = "0.0.0.0";
const port = 3000;
// when using middleware `hostname` and `port` must be provided below
const app = next({ dev, hostname, port });
const handle = app.getRequestHandler();

const httpsOptions = {
  key: fs.readFileSync("./certificates/localhost.key"),
  cert: fs.readFileSync("./certificates/localhost.crt"),
};

app.prepare().then(() => {
  createServer(httpsOptions, async (req, res) => {
    try {
      // Be sure to pass `true` as the second argument to `url.parse`.
      // This tells it to parse the query portion of the URL.
      const parsedUrl = parse(req.url, true);
      const { pathname, query } = parsedUrl;

      // 可添加自定义路由
      // if (pathname === "/a") {
      //   await app.render(req, res, "/a", query);
      // } else if (pathname === "/b") {
      //   await app.render(req, res, "/b", query);
      // } else {
      //   await handle(req, res, parsedUrl);
      // }
      await handle(req, res, parsedUrl);
    } catch (err) {
      console.error("Error occurred handling", req.url, err);
      res.statusCode = 500;
      res.end("internal server error");
    }
  })
    .once("error", (err) => {
      console.error(err);
      process.exit(1);
    })
    .listen(port, () => {
      console.log(`> Ready on https://${hostname}:${port}`);
    });
});

为啥线上部署用 http 没有这个问题?Cookie 带不上完全是因为浏览器策略的问题,比如在 Docker 容器中部署 Next.js 服务,客户端请求不是直接打到 Docker 容器,而是先打到 K8S 集群的网关(比如 Ingress),此时只需要网关层是 https 就行,Docker 容器的服务就算 http 也没问题

此外还需要注意一个问题,测试环境需要在 Docker 容器中配置穿梭环境 host,否则 Node BFF 发的请求会直接打到线上。

medium.com/@greg.farro…

nextjs.org/docs/pages/…

11、业务中遇到一个问题,getUserChatInfo 接口的数据比较容易过期,需要频繁获取,同时这个数据需要被多个页面的组件消费。Next.js 13 提供的 fetch 方法只能在首屏渲染的时候获取数据,且 cache: 'no-store' 策略无法跨页面共享(即使数据没有过期,仍会发送请求)。常规解法是状态管理库 + 客户端定时轮询,但是需要手动处理很多逻辑,比如去除重复请求、超时处理、错误重试等,比较麻烦(本人之前造过很多类似的轮子)。

主动调研 useSWR 解服务端状态管理,1)传统状态管理库无法 data fetch,需要开发者自行获取数据、处理错误重试、数据过期问题,使用 useSWR ,组件将会不断地自动获得最新数据流,UI 也会一直保持快速响应,2)可以用服务端状态作为初始的 fallbackData,3)核心功能就是数据缓存,默认支持自动 revalidate、轮询逻辑、错误重试,4)useSWR 可以实现 request deduping,去除重复请求,意味着可以在同一个页面多个组件同时用 useSWR,5)如果数据过期,可以调用 useSWRConfigmutate 方法手动更新,同时广播给所有 useSWR 更新组建状态。6)useSWR 除了可以在组件层面拿到 dataerrorisLoadingisValidating 等状态用于渲染(这里的 isLoading 表示目前暂无缓存,正在进行初次加载,isValidating 则表示已经有缓存,正在进行数据重新验证的加载),也可以监听 onSuccessonError 等事件回调用于在 JS 层面处理请求异常。

github.com/vercel/swr/…

# 都什么时代还在发传统请求?来看看 SWR 如何用 React Hook 实现优雅请求

12、Next.js 13 非跟路径部署 API 路由报错问题。最近在维护一个 Next.js 开源项目,项目在非跟路径下部署,访问 API 路由 node server 会报错,但是根路径部署是正常的:

error - node_modules/.pnpm/next@13.2.4_@babel+core@7.21.3_react-dom@18.2.0_react@18.2.0_sass@1.60.0/node_modules/next/dist/esm/server/future/route-handlers/app-route-route-handler.js (271:20) @ AppRouteRouteHandler.execute
error - Cannot destructure property 'params' of 'object null' as it is null.

从报错信息来看,应该是 Next.js 源码报错,不是业务代码。根据日志,定位到源码,大致过了一下但没有定位到任何有用的信息。换了一种思路,非跟路径部署访问其他 API 路由是正常的,只有一个 send-stream 接口会报错。现在问题就比较明确了,于是对比了 send-stream 接口和其他接口的代码,看看有没有啥不同点,果然发现 send-stream 接口有一个 Edge API Routes 配置:

export const config = {
  runtime: 'edge',
};

将以上配置注释掉就正常了。该配置的作用是启用 Vercel 提供的 Edge Runtime 利用边缘计算提升 API 的性能,但前提是只有部署到 Vercel 生效。

这里总结一下经验,很多时候直接看源码容易陷入细节,很难定位根本原因,可以先用二分法、排除法,缩小问题范围(比如只有非跟路径部署会报错,再看是否所有接口请求都报错,还是只有个别接口),这样就比较容易定位了。

nextjs.org/docs/pages/…

13、解决 Next.js 13 Node BFF 缓存问题。测试反馈 gpt 接口重试回复的内容是一样的,同时可用次数并没有减少,判断是 Node Server 缓存问题,因为重试回复非常快、可用次数没有减少、页面刷新历史记录丢失,说明请求很可能没到后端。注意到 Next.js 13 提供了新的 fetch API,且默认是 cache: 'force-cache',对页面路由来说相当于 getStaticProps,也可配置 cache: 'no-store',对页面路由来说相当于 getServerSideProps。由于这些缓存行为是在 Node 环境,因此很有可能对 API 路由也生效。尝试增加 cache: "no-store",结果发现报了个错:

TypeError: RequestInit: duplex option is required when sending a body.

查了下文档,fetch 发送 stream 请求的时候,还需要配置 duplex: 'half'。增加配置之后,缓存问题解决了,顺便还解决了本地 AI 回复无法流式响应的问题(之前只有线上是流式响应)。

这说明 Next.js 工程除了 SSG,其他所有场景(比如借助 Node BFF 做请求转发)最好都给 fetch 配置 cache: "no-store",否则 Next.js 的缓存可能会导致一些 bug,比如请求没有打到后端(从测试反馈看,POST 请求同样会缓存)、或者 BFF 错误返回其他用户的数据。其实更好的做法,除了 pre-render 在 Node 环境获取数据,其他场景在客户端发接口请求更好,这样就算有缓存,也只是客户端本地缓存了用户自己的数据,最坏也只是数据过期,不存在访问到别人数据的情况。

Next.js 13 文档对 fetch 有一段描述:

In the browser, the cache option indicates how a fetch request will interact with the browser's HTTP cache. With this extension, cache indicates how a server-side fetch request will interact with the framework's persistent HTTP cache.

从以上描述可以看出,Next.js 希望抹平浏览器和 Node 环境差异,比如 fetch API 在浏览器环境有缓存,于是给 Node 环境也搞一套缓存。但是需要注意的是,客户端的 API 和服务端 API 是不一样的,客户端代码跑在每个用户的设备上,只需面向单一用户,处理缓存非常简单,而服务端代码部署在服务器上,需要面向多个用户,缓存处理非常复杂。

  • 缓存放到 Node 环境,对逻辑要求严密,按照 Next.js 对 fetch 默认 cache: 'force-cache' 缓存策略,极有可能出现用户访问到别人数据的情况;
  • 另外,线上部署一般都是 K8S 集群部署,如果要在 DB 前面加一个缓存,一般都要分布式缓存,fetch 显然只能单个节点缓存,无法在多个服务实例之间同步,意义不大。

另外还需要注意两个问题,1)流式请求的 header 没有 Content-Length 字段,是一种新的请求类型,因此需要 CORS,这些请求都会触发预检请求(preflight),2)流式请求不支持 HTTP/1.x,因为根据 HTTP/1.x 规则,请求头和响应头都需要发送 Content-Length 字段(以便对方知道它将接收多少数据),或者更改消息格式以使用分块编码(Transfer-Encoding: chunked),注意这也是 React Server Component 的原理。使用分块编码,主体被分成各个部分,每个部分都有自己的内容长度。

总结一下,流式响应几个关键设置 Cache-Control: no-store 和 chunked encoding(或者用 HTTP/2),发送的响应体需要以 \n\n 结尾,不一定需要 Content-Type: text/event-stream

完整代码如下:

export async function requestOpenai(req: NextRequest) {
  const encryptInfo = req.headers.get("encryptInfo") || "";
  const cookie = req.headers.get("Cookie") || "";
  return fetch(
    "https://api.example.com/v2/send-stream",
    {
      headers: {
        "Content-Type": "application/json",
        Cookie: cookie,
        encryptInfo,
      },
      method: req.method,
      body: req.body,
      // 解决重试缓存问题
      cache: "no-store",
      // @ts-ignore
      duplex: "half",
    },
  );
}

参考:

nextjs.org/docs/app/ap…

# React Server Component: 混合式渲染

developer.chrome.com/articles/fe…

14、Next.js 308 永久重定向缓存问题

Next.js 内部使用 307 和 308 重定向,好处是可以保留原始的请求方法(比如一个 POST 请求响应 302,很多浏览器会改用 GET 发请求,但是用 307 就可以避免这个问题)。

module.exports = {
  async redirects() {
    return [
      {
        source: '/about',
        destination: '/',
        permanent: true, // true => 308,false => 307
      },
    ];
  },
};

另一种会出现 308 的场景,Next.js 默认会用重定向干掉页面 url 的 trailingSlash,比如 /about/ 会重定向为 /about。该特性可以在 next.config.js 中配置:

module.exports = {
  // 保留 trailingSlash,比如 `/about` 会重定位为 `/about/`
  trailingSlash: true,
};

需要注意的是,308 重定向是会被浏览器缓存的(因此称为永久重定向)。在开发过程中遇到一个问题,预发环境改了 nginx 配置,然后又回滚了,在这个过程中测试访问过页面,出现了 Next.js 308 重定向。然后测试切换到线上环境(预发和线上是同一个 url,只是 host 不一样),访问线上页面,结果发现线上页面 url 被干掉 trailingSlash,导致访问异常(线上环境未同步更新 nginx 配置)。经过排查,浏览器开发者工具 Network 面板可以看到一个本地缓存的 308 请求(勾选 Preserve log 便于分析),说明预发环境 308 重定向的缓存被带到了线上环境,导致访问线上页面,浏览器本地默认重定向了。清除缓存后,再次访问线上页面正常。

nextjs.org/docs/app/ap…

nextjs.org/docs/app/ap…

15、Next.js 13 如何解决 SEO 优化

微信小程序 SEO 遵循一些协议,比如 OpenGraph,这就需要做一些配置。Next.js 13 支持在 layout 组件中导出一个 metadata 对象进行配置:

import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "ChatGPT Next Web",
  description: "Your personal ChatGPT Chat Bot.",
  viewport: {
    width: "device-width",
    initialScale: 1,
    maximumScale: 1,
    userScalable: false,
  },
  openGraph: {
    title: "ChatGPT Next Web",
    images: "/nextChat/favicon.ico",
    description: "Your personal ChatGPT Chat Bot.",
  },
  twitter: {
    title: "ChatGPT Next Web",
    images: "/nextChat/favicon.ico",
    description: "Your personal ChatGPT Chat Bot.",
  },
  appleWebApp: {
    title: "ChatGPT Next Web",
    statusBarStyle: "default",
  },
  themeColor: "#fafafa",
};

nextjs.org/docs/app/ap…