Next.js教程一:使用Next.js国际化站点项目(脚手架)

2,439 阅读13分钟

前言

先说一下为什么要写一篇关于react生态下nextjs的相关文章。

其实也发现了,目前互联网整个行业的生态和环境已经不再是曾经的那种“风口”处了。
我这里不止说的前端,也指后端,包括整个行业 只是个人认为,就做技术这一点来论,还是纯粹一点比较好。

所以不想多谈行业发展和流行趋势、下一个风口是什么这样比较容易引起争议的话题。
我只是单纯的觉得,对于技术人来说, 打铁还是自身硬,窝里狠的不如去外面争凶斗狠,放眼多看看世界,多学点新东西总是好的,何况对于nextjs来说,它已经不是那么的新了。

本文适用于掌握react基础,了解node、了解Css原子样式(tailwindcss),了解软件工程的初中级前端开发人员。
阅读时间大概10~25分钟。

为什么是Nextjs

Nextjs本身是一种框架,其基于react,和vue生态下的 Nuxtjs 一样,它专用于服务端渲染(ssr),当然也可以作为传统web建站的方式去生成静态页面,通过cdn部署

  • Next.js 是由 Vercel(前身为 Zeit)在 2016 年推出的一款开源的 React 框架。
  • 它旨在提供一种简单的方式来构建 服务端渲染 的 React 应用程序,同时支持静态站点生成和客户端路由等特性。

Next.js 是一个轻量级的 React 服务端渲染应用框架。官网传送门

安装Nextjs

截止至目前(2024.06)官方文档最新版本为v14,beat预览版为v15

其中v15因为尚未发布的缘故,本文不做讲述和讲解,只简单介绍一下当前需要注意的几个特性:

  • v14中,默认所有组件、页面都是服务端渲染,如果要指定特定组件、页面需要通过客户端渲染,需要显示的再文件最上面添加标识:
use client;

image.png

  • Nextjs框架体系中,路由分为app routerpages router两种,其中app router相对pages而言,特性会更新一些,因为其是v14后新支持的一种路由方式,只是在这里尤为需要注意的是,同一框架体系下,appRouter 和 pagesRouter 两者不可混用,并且其各自的所属包和调用方式、用途都有比较大的区别,相关生态也有异同,所以在选用技术路线的时候需要慎重考虑。

因为我两种方式都用过,我的建议是,如果没有历史项目负担的话,这里更建议选择appRouter,其对服务端渲染的支持会更加优秀。

  • 以下是 appRouter 和 pagesRouter 在文件目录上面的区别:

image.png image.png
  • 访问官网文档也要注意 appRouter 和 pagesRouter 区分,这是两条不同的技术路线

image.png

v14开始,如果要使用Nextjs,必须让nodejs环境升级支持到 v18.17 以后

通过官方提供的cli方式(脚手架)创建项目

npx create-next-app@latest

image.png

image.png 如上图所示,通过官网脚手架脚本安装,会一次提示我们如何对项目进行初始化操作,以我的初始化方式为例:

  • 创建一个NextJs项目
  • 项目名叫nextjs-demo
  • 使用 TypeScript
  • 开启 ESLint 代码检测
  • 使用 Css原子类 (Tailwindcss)
  • 不使用 src 作为根目录
  • 使用 appRouter 的方式构建应用
  • 使用 alias 别名来优化 import 文件引入
  • 根目录 alias 默认匹配 @* 符号(可自定义输入)
✔ What is your project named? … nextjs-demo
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias (@/*)? … No / Yes
✔ What import alias would you like configured? … @/*

更详细的配置项可以通过next.config.mjs文件修改,具体查看官方文档配置

启动项目查看效果

npm run dev

image.png

浏览器打开:http://localhost:3001/

image.png

项目结构

Nextjs主要目录结构:

- .nextjs
  |
  -  * nextjs框架编译、部署后产生的文件,主要用于dev开发阶段和项目部署
- app
  |
  _ globals.css 站点全局css样式,顾名思义,全站生效
  |
  - layout.tsx 可以理解为页面布局文件,文件入口,用于定义各种html标记、头信息,layout布局等
  |
  - page.tsx 页面组件,在 appRouter 模式下,文件名只能叫 page 且不可修改
- public
  |
  - * 静态资源存放目录
// 各种配置文件
- next.config.mjs
- postcss.config.mjs
- tailwind.config.ts
- tsconfig.json

这里建议再创建一个components 组件目录与app页面组件目录同级,用于专门存放页面级以及全局组件,

同时,因为从v14开始,Nextjs不但支持CssModule的方式引入css文件,通知支持非CssModule的方式在这之前Nextjs是不支持的,所以不管你目前是否有同时在一个站点下有多样化、差异化样式需求的情况,我都建议你再创建一个styles目录,用来存放你差异化的 Css 文件

比如:

image.png

关于脚手架的部分,文章到这里就结束了,如果你的项目有国际化的需求(即多语言翻译和切换),可以继续往下了解,同时,你也可以你在一下的内容中,通过国际化方案,了解Nextjs中关于动态路由的特性实践部分

开启国际化i18n

在NextJs中,开启国际化非常简单,其实官方文档上有非常详尽的解释和引导, 只是因为NextJs作为服务端渲染框架,相对静态部署的站点来说,在显示和切换时机上会稍显复杂,在代码理解和逻辑层上也有出现一些新的变化,完美的情况下,需要你在做国际化前掌握国际化、多语言替换的实现原理,理解服务端部署渲染和客户端渲染差异以及执行时机的不同点。

Nextjs 国际化

这里我使用的国际化方案主要来自于NextJs官网原生提供的方案,主要用于演示该方案如何通过Nextjs提供的动态路由来实现多语言切换的

感兴趣的也建议查看一下在官网上推荐的方案资源

Resources

中间件 Middleware

在Nextjs中,中间件 middleware 你可以理解为框架给你提供的一个钩子,它允许你在一次客户端请求完成之前运行你的代码。

你可以根据你传入的请求,去通过类似重写、重定向、修改请求或者响应标头等等方式去修改response

最重要的是,在服务端渲染款过程中,因为涉及到服务器缓存的概念,所以 middleware 这个中间件钩子执行的时机,是在缓存内容之前和路由匹配之前执行的。

所以这就给了我们在这里通过路由去匹配语言首选项提供了条件

  • 在根目录下,创建中间件文件 middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

import { i18n } from "./i18n-config";

import { match as matchLocale } from "@formatjs/intl-localematcher";
import Negotiator from "negotiator";

/**
 * 从请求中获取最合适的语言环境。
 * 
 * 使用 Negotiator 库和 FormatJS 的国际化语言匹配库来确定请求中最合适的语言环境。
 * 这考虑了客户端请求头中的 Accept-Language 值,并将其与配置中支持的语言环境进行匹配。
 * 
 * @param request Next.js 的请求对象,包含请求头等信息。
 * @returns 返回匹配的语言环境字符串,如果没有匹配则返回 undefined。
 */
function getLocale(request: NextRequest): string | undefined {
  // Negotiator expects plain object so we need to transform headersz
  const negotiatorHeaders: Record<string, string> = {};
  request.headers.forEach((value, key) => (negotiatorHeaders[key] = value));

  // @ts-ignore locales are readonly
  const locales: string[] = i18n.locales;

  // Use negotiator and intl-localematcher to get best locale
  let languages = new Negotiator({ headers: negotiatorHeaders }).languages(
    locales,
  );

  const locale = matchLocale(languages, locales, i18n.defaultLocale);

  return locale;
}

/**
 * 中间件函数,用于处理请求的路径以确保包含正确的语言环境。
 * 
 * 如果请求的路径没有包含语言环境,则尝试从请求头中获取语言环境,并重定向到带有正确语言环境的路径。
 * 这确保了所有请求都以明确的语言环境进行处理,提高了国际化处理的一致性。
 * 
 * @param request Next.js 的请求对象,包含请求的 URL 和头信息。
 * @returns 如果需要重定向,则返回重定向的响应;否则不返回任何内容,让请求继续处理。
 */
export function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname;
  
  // // `/_next/` and `/api/` are ignored by the watcher, but we need to ignore files in `public` manually.
  // // If you have one
  // if (
  //   [
  //     '/manifest.json',
  //     '/favicon.ico',
  //     // Your other files in `public`
  //   ].includes(pathname)
  // )
  //   return

  // Check if there is any supported locale in the pathname
  // 检查路径名是否缺少语言环境
  const pathnameIsMissingLocale = i18n.locales.every(
    (locale) =>
      !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`,
  );

  // Redirect if there is no locale
  if (pathnameIsMissingLocale) {
    const locale = getLocale(request);

    // e.g. incoming request is /products
    // The new URL is now /en-US/products
    // 如果请求的路径没有语言环境,重定向到带有正确语言环境的路径
    return NextResponse.redirect(
      new URL(
        `/${locale}${pathname.startsWith("/") ? "" : "/"}${pathname}`,
        request.url,
      ),
    );
  }
}

/**
 * 中间件的配置对象。
 * 
 * 这定义了中间件应该匹配的路径模式。通过这种方式,可以排除不需要进行语言环境处理的特定路径,
 * 如 API 路径、静态文件路径等。
 */
export const config = {
    // Matcher ignoring `/_next/` and `/api/`
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico|images|fonts).*)"],
};
  • 同样在根目录下,创建 18n config 配置文件 i18n-config
export const i18n = {
    defaultLocale: "en",
    locales: ["en", "zh"],
  } as const;
  
  export type Locale = (typeof i18n)["locales"][number];

这里我只默认声明了支持中文、和英文两种首选项,其中默认使用en, 即英文

这段代码主要的作用是通过middleware中间件特性,帮我们在浏览器输入url后,根据特定的条件(请求头中的语言环境信息)去做304的重定向跳转

image.png

其中如果用户请求的路径没有包含语言环境,则尝试从请求头中获取语言环境,并重定向到带有正确语言环境的路径

举个例子:

  • 根据我们的国际化方案,如果要显示中文,正确的网站路由地址是 http://localhost:3001/zh/
  • 如果要显示英文,则路由地址为:http://localhost:3001/en/
  • 用户正常想要访问的网址是 http://localhost:3001/
  • 这个时候因为用户并没有输入正确的语言首选项,即请求路径没有包含语言环境
  • middleware.ts 就 开始干活了
/**
 * 从请求中获取最合适的语言环境。
 * 
 * 使用 Negotiator 库和 FormatJS 的国际化语言匹配库来确定请求中最合适的语言环境。
 * 这考虑了客户端请求头中的 Accept-Language 值,并将其与配置中支持的语言环境进行匹配。
 * 
 * @param request Next.js 的请求对象,包含请求头等信息。
 * @returns 返回匹配的语言环境字符串,如果没有匹配则返回 undefined。
 */
function getLocale(request: NextRequest): string | undefined {
  // Negotiator expects plain object so we need to transform headersz
  const negotiatorHeaders: Record<string, string> = {};
  request.headers.forEach((value, key) => (negotiatorHeaders[key] = value));

  // @ts-ignore locales are readonly
  const locales: string[] = i18n.locales;

  // Use negotiator and intl-localematcher to get best locale
  let languages = new Negotiator({ headers: negotiatorHeaders }).languages(
    locales,
  );

  const locale = matchLocale(languages, locales, i18n.defaultLocale);

  return locale;
}
  • 尝试从请求头中获取语言环境,并重定向到带有正确语言环境的路径

image.png

  • 刷新浏览器报错,缺少扩展支持文件@formatjs/intl-localematcher和negotiator
npm i -S @formatjs/intl-localematcher
npm i -S negotiator

此时页面报错404,通过修改 app 目录下页面组件解决,此时页面路由情况:

image.png

通过Nextjs动态路由特性优化

细心的朋友发现了,此时我们的国际化方案虽然说初步实践通过了,并且探索了可能性,但是该方案在实践中非常的臃肿和不优雅

目前我们支持两种语言首选行配置,【‘en’, ‘zn’】,即中文和英文,同时在app目录下需要建立对应app/en/* , app/zh/*两个目录,并且其中的代码都是cv复制粘贴的

这时候需要用到Nextjs的动态路由特性来妥善支持

即,你只要修改app目录下的文件名,使其满足[*]格式,框架就可以帮你构建可以用于预渲染的动态路由

例如:原本的app/en/*|app/zh/* 你可以优化成:app/[lang]/*

  • 优化以后
image.png

更详细的动态路由解释介绍: Dynamic Routes

图片资源报错

重新访问url: http://localhost:3001/, 如预期跳转 http://localhost:3001/zh/

此时会发现图片显示异常:

image.png

通过控制台不难发现,中间件把public目录下静态资源访问地址,也做了转发,此时你只要在public下建立对应的语言文件资源目录就行,比如public/en/*

  • 这是合理的,因为语首选项的切换不但涉及页面文案的翻译和替换,同时有可能存在图片等资源的替换
  • 但是大多情况下,我们并不会去切换图片资源,只对语言文字上有国际化的需求,因为不如此做的话,整个公司和业务的工作量是巨大的

解决这个问题很好办,回到middleware.ts文件中来

/**
 * 中间件的配置对象。
 * 
 * 这定义了中间件应该匹配的路径模式。通过这种方式,可以排除不需要进行语言环境处理的特定路径,
 * 如 API 路径、静态文件路径等。
 */
export const config = {
    // Matcher ignoring `/_next/` and `/api/`
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico|images|fonts).*)"],
};

注意这段配置,这里定义了中间件应该匹配的路径模式,通过这段正则,此时我们已经知道,imagesfonts文件下的资源都不会被重定向,即调整public下的资源路径即可

image.png
  • 修改 app/[lang]/page.tsx 关于图片的引用部分
<div className="relative z-[-1] flex place-items-center before:absolute before:h-[300px] before:w-full before:-translate-x-1/2 before:rounded-full before:bg-gradient-radial before:from-white before:to-transparent before:blur-2xl before:content-[''] after:absolute after:-z-20 after:h-[180px] after:w-full after:translate-x-1/3 after:bg-gradient-conic after:from-sky-200 after:via-blue-200 after:blur-2xl after:content-[''] before:dark:bg-gradient-to-br before:dark:from-transparent before:dark:to-blue-700 before:dark:opacity-10 after:dark:from-sky-900 after:dark:via-[#0141ff] after:dark:opacity-40 sm:before:w-[480px] sm:after:w-[240px] before:lg:h-[360px]">
        <Image
          className="relative dark:drop-shadow-[0_0_0.3rem_#ffffff70] dark:invert"
          src="/images/next.svg"
          alt="Next.js Logo"
          width={180}
          height={37}
          priority
        />
      </div>

在根目录下,创建国际化首选项语言资源文件目录dictionaries

在根目录新增目录dictionaries,同时在此目录新增两个语言配置文件, en.tsx , zh.tsx

// dictionaries/en.tsx
const EN_JSON = {
  "index.desc":"Get started by editing",
};

export default EN_JSON;


// dictionaries/zh.tsx
const ZH_JSON = {
  "index.desc":"起飞🛫,修改:",
};

export default ZH_JSON;

修改layout.tsx文件

import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { i18n, type Locale } from "../../i18n-config";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};



export async function generateStaticParams() {
  // 使用i18n配置中的locales属性,映射生成包含语言代码的对象数组
  return i18n.locales.map((locale) => ({ lang: locale }));
}

export default function RootLayout({
  children,
  params
}: Readonly<{
  children: React.ReactNode;
  params: { lang: Locale };
}>) {
  return (
    <html lang={params.lang}>
      <body className={inter.className}>{children}</body>
    </html>
  );
}

image.png

语言首选项对应修改

根目录创建字典文件 get-dictionary.ts

import "server-only";
import type { Locale } from "./i18n-config";

// We enumerate all dictionaries here for better linting and typescript support
// We also get the default import for cleaner types
const dictionaries = {
  en: () => import("./dictionaries/en").then((module) => module.default),
  zh: () => import("./dictionaries/zh").then((module) => module.default),
};

export const getDictionary = async (locale: Locale) =>
  dictionaries[locale]?.() ?? dictionaries.en();

使用字典实现国际化

修改page.tsx

import Image from "next/image";
import { getDictionary } from "@/get-dictionary";

export default async function Home({params: { lang }}) {
  const dictionary  = await getDictionary(lang);
  return (
    <main className="flex min-h-screen flex-col items-center justify-between p-24">
      <div className="z-10 w-full max-w-5xl items-center justify-between font-mono text-sm lg:flex">
        <p className="fixed left-0 top-0 flex w-full justify-center border-b border-gray-300 bg-gradient-to-b from-zinc-200 pb-6 pt-8 backdrop-blur-2xl dark:border-neutral-800 dark:bg-zinc-800/30 dark:from-inherit lg:static lg:w-auto  lg:rounded-xl lg:border lg:bg-gray-200 lg:p-4 lg:dark:bg-zinc-800/30">
          {dictionary['index.desc']}&nbsp;
          <code className="font-mono font-bold">app/page.tsx</code>
        </p>

完成国际化!

image.png

参考文献

next.org