首页 / 文章

使用 Next.js App Router 构建具备弹性的电影详情页

在Next.js App Router中使用异步服务器组件、参数等待处理以及真正的404错误处理,来获取并缓存OMDB数据,确保电影页面不会出错。

发布者:REACTAPP.TOP阅读约 7 分钟1556 词

来源: A Movie Lookup Page in Next.js That Doesn’t Fall Over

目前仍有许多使用 OMDB API 构建电影查询页面的教程在流传,其中的代码都是 2019 年的版本:使用 getServerSideProps、导入 axios、创建 pages/ 目录,以及一个只会将所有错误输出到 console.error 的 fetch 辅助函数。在现代开发环境中,这些方法都已不再适用。自 Next.js 16 版本起,数据获取功能被集成到异步 Server Components 中,而 params 现在是一个 Promise,必须先使用 await 才能使用。如果将那些旧代码放入新生成的 create-next-app 项目中,类型检查器会拒绝配合。

下面介绍的是真正适合上线的版本:一个基于 OMDB API 的动态电影详情页,采用 App Router 构建,无需 axios 依赖,且 404 页面也能真正实现 404 的功能。

数据层

OMDB 是一个简单的 REST API。只需传入电影标题,就能得到 JSON 数据。无需引入任何客户端库,axios 也毫无用处——因为浏览器和 Node 都已内置 fetch 函数,而 Next.js 更是在 fetch 之上添加了自己的缓存机制,这是 axios 无法实现的。

一个容易让人犯错的细节是:即使找不到对应的电影,OMDB 也会返回 HTTP 200 状态码。真正的失败提示其实存在于响应数据中,表现为 "Response": "False"。如果代码只检查 res.ok,那么拼写错误的标题也会被误认为是成功请求,从而导致页面中到处显示 undefined 值。

const BASE_URL = "https://www.omdbapi.com/";
export type Movie = {
  Title: string;
  Year: string;
  Plot: string;
  Poster: string;
  imdbRating: string;
  Response: "True" | "False";
  Error?: string;
};export async function fetchMovieByTitle(title: string): Promise<Movie | null> {
  const apiKey = process.env.OMDB_API_KEY;
  if (!apiKey) throw new Error("OMDB_API_KEY is not set");  const url = new URL(BASE_URL);
  url.searchParams.set("apikey", apiKey);
  url.searchParams.set("t", title);
  url.searchParams.set("plot", "full");  const res = await fetch(url, { next: { revalidate: 60 * 60 * 24 } });
  if (!res.ok) return null;  const data = (await res.json()) as Movie;
  return data.Response === "True" ? data : null;
}

fetch 方法中的 next: { revalidate: ... } 选项才是让该架构具备真正扩展性的关键。电影的剧情简介和海报本质上是静态的,因此将响应缓存一整天后,第二个访问 /movie/inception 的用户就无需再次向 OMDB 发起请求。这就是整个缓存策略的精髓——无需 Redis 实例,也无需设置重新验证的定时任务。想要更新的数据?可以降低该数值;希望数据永不过期?则可使用 cache: "force-cache"。直接向 OMDB 发起的 10 次查询平均每次耗时 57.8 毫秒(中位数 55.5 毫秒,最慢情况为 112.8 毫秒),因此使用缓存的意义并非因为其 API 速度慢,而是为了避免在每次请求时都付出如此微小的成本。

使用 URLSearchParams 构建请求 URL,而非手动进行字符串拼接,其重要性远超表面所见。电影标题通常包含空格、& 符号和冒号,像 t: "The Good, the Bad and the Ugly" 这样的标题若手动拼接就会出错,而 searchParams.set 能自动处理编码问题。

页面

正因如此,旧教程不仅过时,甚至完全错误。在 App Router 中,页面组件是一个异步函数,params 以 Promise 的形式出现,必须使用 await 处理。若跳过这一步,TypeScript 不仅会发出警告,next build 也会直接失败。

import Image from "next/image";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { fetchMovieByTitle } from "@/lib/omdb";
type PageProps = { params: Promise<{ title: string }> };export async function generateMetadata({
  params,
}: PageProps): Promise<Metadata> {
  const { title } = await params;
  const movie = await fetchMovieByTitle(decodeURIComponent(title));
  if (!movie) return { title: "Movie not found" };
  return { title: `${movie.Title} (${movie.Year})`, description: movie.Plot };
}export default async function MoviePage({ params }: PageProps) {
  const { title } = await params;
  const movie = await fetchMovieByTitle(decodeURIComponent(title));
  if (!movie) notFound();  return (
    <main>
      <h1>
        {movie.Title} <span>({movie.Year})</span>
      </h1>
      <p>IMDb {movie.imdbRating}</p>
      <p>{movie.Plot}</p>
      {movie.Poster !== "N/A" && (
        <Image
          src={movie.Poster}
          alt={`Poster for ${movie.Title}`}
          width={300}
          height={445}
          priority
        />
      )}
    </main>
  );
}

此处调用 notFound() 实际是在执行有意义的功能。它不会像 `

电影未找到

` 这样直接渲染 JSX 元素。

仍然会返回200状态码——这正是旧版本的做法,相当于告诉谷歌“此页面是真实的,可以将其加入索引”——而notFound()则会抛出异常,加载你的not-found.tsx文件,并返回真正的404状态码。由于在这种基于URL的查询中,大多数猜测的标题都无法对应到实际内容,正是这种区别避免了你的网站在搜索索引中出现大量无用页面。

generateMetadata函数使用的数据获取辅助工具与页面本身完全相同。你可能会认为这会导致API调用次数翻倍,但实际上并非如此:Next.js会自动去重在同一渲染过程中出现的重复fetch请求,因此元数据生成器与页面组件最终只会进行一次网络调用。这一特点充分说明了为何应将数据获取逻辑保留在服务器端组件中,而非移至客户端的useEffect中——因为客户端渲染的版本无法跨越元数据边界进行去重。

在处理海报时,OMDB有两个容易被人忽视的怪癖。首先,缺失的海报会以字符串 "N/A" 的形式返回,而非 null,因此在将值传递给 next/image 之前需要进行检查,比如使用 movie.Poster !== "N/A" —— 如果直接将 "N/A" 作为 src 参数传入,就会出现图片显示异常并伴随控制台警告。其次,next/image 不会加载未被明确列入允许列表的远程域名上的图片,因此OMDB的图片服务器需要在 next.config 中单独配置一条记录:

const nextConfig = {
  images: { remotePatterns: [{ protocol: "https", hostname: "m.media-amazon.com" }] },
};
export default nextConfig;

目前尚未实现的功能

此实现中没有搜索框——它仅从URL中解析一个完全匹配的标题,这是有意为之,因为OMDB的t=参数用于精确匹配,而其模糊搜索功能s=则位于另一个不可预测的端点上。此外也没有持久化层;每次唯一查询仍会直接发送到OMDB,只是先经过缓存处理。如果你的项目需要收藏列表或观看历史等功能,那就需要真正的数据库,而这属于本页面范围之外的另一话题。

在投入实际使用之前,首先需要考虑的是OMDB免费套餐每天仅允许1,000次请求的限制。虽然每日重新验证会大幅消耗这一配额,但一旦流量超出业余项目的水平,就需要在OMDB之前搭建自己的存储层——一种仅在数据未找到时才填充内容的直通缓存。上述所有内容本质上都属于该架构的前半部分。

关键在于,在此背景下,“可扩展性”并非指事后添加的某种框架,而是要正确设置revalidate值、返回恰当的404响应,并确保在单次调用即可完成工作时绝不重复调用API。