Meta-framework(Next.js、Nuxt)成为默认起点
1. 概述
几年前,新的前端项目通常从纯 React 或 Vue 开始,然后自己添加 router、bundler、数据获取方式和服务器渲染。现在第一个问题通常是:“用 Next.js 还是 Nuxt?”。这些 meta-framework 正在成为默认起点。
本文解释 meta-framework 是什么以及为什么它被选为默认选项。你将看到如何用 Next.js 和 Nuxt 构建产品页面,调用后端 Spring Boot。文章最后是常见错误和不应使用的情况。
2. 什么是 Meta-framework?
React 和 Vue 是 UI 库。它们负责渲染组件和管理状态。它们不决定哪个 URL 对应哪个页面,数据从哪里加载,或 HTML 是在服务器还是在浏览器上生成。
Meta-framework 是一个位于 UI 库上方 的层。它预先做出这些决定:
- Next.js 构建在 React 之上。
- Nuxt 构建在 Vue 之上。
- 同类还有 SvelteKit、Remix/React Router、Astro、SolidStart 等。
2.1. 预装功能
典型的 meta-framework 提供:
- 基于文件的路由:目录结构决定 URL。你不需要手动声明路由。
- 多种渲染模式:SSR(每个请求在服务器上渲染)、SSG(在构建时预先渲染)以及混合模式,如定期重新验证或仅在客户端渲染。
- 与路由绑定的数据获取:在发送 HTML 之前,在服务器上加载数据。
- 服务器路由:在前端项目中直接编写小的 API 端点,通常用作 BFF(Backend for Frontend)。
- 构建和优化:代码拆分、图像优化、字体和资源都有预配置。
可以与 Spring Boot 类比。Spring Framework 给你所有砖块。Spring Boot 添加约定和自动配置让你立即运行。Meta-framework 对 React 和 Vue 的作用类似。
3. 为什么它成为默认选择?
有几个基础性的原因。
减少初期决策。 自己组装 router、SSR、配置 bundler 和代码分割需要很多工作。每个团队的做法都不一样。Meta-framework 提供一致的结构,所以新加入项目的人更容易理解代码。
SEO 和显示速度。 纯 SPA 应用发送一个几乎空的 HTML 文件,然后等待 JavaScript 加载完成才有内容。使用 SSR 或 SSG,浏览器和爬虫立即接收包含内容的 HTML。这对电商页面、博客和着陆页很重要。
按路由灵活渲染。 首页可以在构建时预先渲染。产品页可以缓存并定期刷新。管理页只在客户端渲染。一切都在同一个项目中。
服务器/客户端边界更清晰。 服务器上运行的代码可以读取秘密环境变量并调用内部 API,而不会暴露给浏览器。你也不需要为后端打开 CORS。
生态系统和文档倾向于此。 React 的官方文档建议从某个框架开始。许多托管平台都预先支持 Next.js 和 Nuxt 的部署方式。
4. Next.js 应用
假设你有一个 Spring Boot 后端提供 GET /api/products/{id}。你需要 /products/{id} 页面显示产品,具有良好的 SEO,并且不在每个请求时调用 API。
4.1. 初始化项目
npx create-next-app@latest shop-web --typescript --app
cd shop-web
npm run dev
上面的命令使用 App Router 创建项目。app/ 文件夹包含路由。应用运行在 http://localhost:3000。
在 .env.local 中声明后端地址:
API_URL=http://localhost:8080
这个变量 没有 NEXT_PUBLIC_ 前缀。因此它只存在于服务器端,不会被打包到发送给浏览器的 JavaScript 中。
4.2. 使用 Server Component 的产品页
// app/products/[id]/page.tsx
import { notFound } from "next/navigation";
import AddToCartButton from "./AddToCartButton";
type Product = { id: number; name: string; price: number };
async function getProduct(id: string): Promise<Product | null> {
const res = await fetch(`${process.env.API_URL}/api/products/${id}`, {
next: { revalidate: 60 },
});
if (res.status === 404) return null;
if (!res.ok) throw new Error("无法加载产品");
return res.json();
}
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await getProduct(id);
if (!product) notFound();
return (
<main>
<h1>{product.name}</h1>
<p>{product.price.toLocaleString("vi-VN")} ₫</p>
<AddToCartButton productId={product.id} />
</main>
);
}
这段代码做以下事情:
- 路径
app/products/[id]/page.tsx自动生成路由/products/:id。[id]是动态参数。 - 组件是
async并在 服务器 上运行。你可以直接await fetch,不需要useEffect或加载状态。 next: { revalidate: 60 }缓存结果并最多每 60 秒刷新一次。后端不会在每次访问时被调用。notFound()当后端报告产品不存在时返回标准 404 页面。
打开 http://localhost:3000/products/1 然后查看 HTML 源代码。你会看到内容已经在 HTML 中:
<main><h1>Nón lá Huế</h1><p>150.000 ₫</p>...
4.3. 使用 Client Component 的交互部分
“加入购物车”按钮需要处理点击事件,所以它必须在浏览器上运行:
// app/products/[id]/AddToCartButton.tsx
"use client";
import { useState } from "react";
export default function AddToCartButton({ productId }: { productId: number }) {
const [added, setAdded] = useState(false);
return (
<button onClick={() => setAdded(true)} disabled={added}>
{added ? "已添加" : `添加产品 #${productId} 到购物车`}
</button>
);
}
指令 "use client" 标记边界。只有这个组件及其导入的内容才会以 JavaScript 形式发送到浏览器。页面的其余部分只是 HTML。
5. Nuxt 的同样理念
Nuxt 按 Vue 的风格解决相同问题。这次我们添加一个服务器路由作为 BFF。浏览器只与 Nuxt 通信,Nuxt 再调用 Spring Boot。
初始化项目:
npm create nuxt@latest shop-nuxt
cd shop-nuxt
npm run dev
在 nuxt.config.ts 中配置:
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
apiUrl: "http://localhost:8080", // 仅在服务器上
},
routeRules: {
"/": { prerender: true }, // 在构建时预先渲染
"/products/**": { swr: 60 }, // 缓存,每 60 秒后台刷新
"/admin/**": { ssr: false }, // 仅在客户端渲染
},
});
routeRules 清楚地展示了“按路由渲染”的优势。三种不同的策略在几行配置中。apiUrl 键在 public 之外,所以不会暴露给浏览器。你可以在部署时用 NUXT_API_URL 环境变量覆盖这个值。
作为 BFF 的服务器路由:
// server/api/products/[id].get.ts
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, "id");
const config = useRuntimeConfig(event);
return await $fetch(`${config.apiUrl}/api/products/${id}`);
});
文件名 [id].get.ts 在 Nuxt 应用中直接生成端点 GET /api/products/:id。
产品显示页面:
<!-- pages/products/[id].vue -->
<script setup lang="ts">
type Product = { id: number; name: string; price: number };
const route = useRoute();
const { data: product, error } = await useFetch<Product>(
`/api/products/${route.params.id}`
);
</script>
<template>
<main>
<p v-if="error">未找到产品。</p>
<template v-else-if="product">
<h1>{{ product.name }}</h1>
<p>{{ product.price.toLocaleString("vi-VN") }} ₫</p>
</template>
</main>
</template>
useFetch 在首次加载时在服务器上运行。Nuxt 随后将数据传递给客户端,所以浏览器在 hydrate 时不会再次调用 API。当用户在客户端切换页面时,请求转到同源的 /api/products/:id,所以仍然不需要 CORS。
6. Spring Boot 后端
两个前端都调用同一个 API:
// Product.java
public record Product(long id, String name, long price) {}
// ProductCatalog.java
@Component
public class ProductCatalog {
private final Map<Long, Product> products = Map.of(
1L, new Product(1, "Nón lá Huế", 150_000),
2L, new Product(2, "Áo dài lụa", 1_200_000));
public Optional<Product> findById(long id) {
return Optional.ofNullable(products.get(id));
}
}
// ProductController.java
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductCatalog catalog;
public ProductController(ProductCatalog catalog) {
this.catalog = catalog;
}
@GetMapping("/{id}")
public ResponseEntity<Product> getProduct(@PathVariable long id) {
return ResponseEntity.of(catalog.findById(id));
}
}
ResponseEntity.of 当有产品时返回 200 和 body,当 Optional 为空时返回 404。4.2 节的 Next.js 页面依赖这个确切的 404 代码来调用 notFound()。
注意 controller 没有 @CrossOrigin。所有请求都来自 meta-framework 的服务器,而不是浏览器。因此后端可以位于内部网络。
这个 API 契约是两个前端都依赖的,所以应该有测试:
@WebMvcTest(ProductController.class)
@Import(ProductCatalog.class)
class ProductControllerTest {
@Autowired
MockMvc mockMvc;
@Test
void returnsProductWhenFound() throws Exception {
MvcResult result = mockMvc.perform(get("/api/products/1")).andReturn();
assertThat(result.getResponse().getStatus()).isEqualTo(200);
assertThat(result.getResponse().getContentAsString(StandardCharsets.UTF_8))
.contains("\"name\":\"Nón lá Huế\"")
.contains("\"price\":150000");
}
@Test
void returns404WhenMissing() throws Exception {
MvcResult result = mockMvc.perform(get("/api/products/99")).andReturn();
assertThat(result.getResponse().getStatus()).isEqualTo(404);
}
}
第一个测试检查前端页面读取的 JSON 结构(name、price)。第二个测试保持正确的 404 行为,因为前端的 notFound() 逻辑依赖于它。
7. 常见错误
将秘密变量暴露给客户端。 在 Next.js 中,任何具有 NEXT_PUBLIC_ 前缀的变量都会被嵌入到 bundle 中。在 Nuxt 中,runtimeConfig.public 中的所有内容也是如此。不要在那里放置 API 密钥。
注意: Server Component 错误导入到 Client Component 会将服务器代码拉到浏览器,或导致构建错误。清楚地分离哪些文件在哪里运行。仅在真正需要交互的叶级组件上标记
"use client"。
在服务器上渲染时使用浏览器 API。 在 SSR 代码中调用 window 或 localStorage 会导致 window is not defined 错误。将这些部分放入 Client Component 中,或 Vue 中的 onMounted。
Hydration 不匹配。 当服务器上渲染的 HTML 与客户端首次渲染不同时会发生此错误。常见原因是 new Date()、Math.random() 或服务器和浏览器之间的数字格式本地化不同。
不理解缓存机制。 “不更新”的数据通常是由框架缓存引起的,而不是后端。为每个路由有意识地声明 revalidate 或 swr。
将 meta-framework 变成主要后端。 服务器路由对 BFF 很方便。但如果在那里放置业务逻辑、事务和数据库访问,你会有两个需要维护的后端。将业务逻辑保留在 Spring Boot 中。
8. 何时不应使用
默认并不意味着总是正确。在以下情况下考虑其他方案:
- 应用完全位于登录后面,不需要 SEO。例如:内部仪表板、管理工具。用 Vite 构建的 SPA 可能更简单:部署为静态文件,不需要 Node.js 服务器。
- 基础设施无法运行 Node.js,或操作团队只熟悉部署 JAR。SSR 需要单独的运行时来运行、监控和扩展。
- 页面主要是静态内容,很少交互。 静态站点生成器或在 Spring Boot 中直接使用 Thymeleaf 可能就足够了。
- 嵌入式应用,如 widget、现有页面中的 micro-frontend 或桌面应用。此时 meta-framework 的路由和 SSR 会变成负担。
- 小团队,不熟悉服务器/客户端模型。 Server Component、hydration 和缓存的概念有自己的学习曲线。如果你不理解它们,你容易遇到难以调试的错误。
一个简单的问题可以帮助你决定:是否有任何页面需要服务器立即从 HTML 中获得内容? 如果答案是否定的,meta-framework 的最大优势几乎消失了。
9. 结论
Meta-framework 如 Next.js 和 Nuxt 预装了路由、渲染模式、数据获取和服务器路由。它们对 React 和 Vue 的作用类似于 Spring Boot 对 Spring 的作用。它们成为默认选择是因为减少初期决策、支持 SEO 和显示速度、允许按路由选择渲染策略,以及创建清晰的服务器/客户端边界。
在实际中,高效的模式是让 meta-framework 处理演示和 BFF,而 Spring Boot 保持业务逻辑和数据。你需要牢牢掌握服务器/客户端边界和缓存机制。对于不需要 SEO 的内部应用,纯 SPA 仍然是合理的选择。