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 仍然是合理的选择。

tech-trendsnextjsnuxtfrontendspring-boot