前言
先说一下为什么要写一篇关于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;
- 在Nextjs框架体系中,路由分为
app router和pages router两种,其中app router相对pages而言,特性会更新一些,因为其是v14后新支持的一种路由方式,只是在这里尤为需要注意的是,同一框架体系下,appRouter 和 pagesRouter 两者不可混用,并且其各自的所属包和调用方式、用途都有比较大的区别,相关生态也有异同,所以在选用技术路线的时候需要慎重考虑。
因为我两种方式都用过,我的建议是,如果没有历史项目负担的话,这里更建议选择appRouter,其对服务端渲染的支持会更加优秀。
-
以下是 appRouter 和 pagesRouter 在文件目录上面的区别:
- 访问官网文档也要注意 appRouter 和 pagesRouter 区分,
这是两条不同的技术路线
从
v14开始,如果要使用Nextjs,必须让nodejs环境升级支持到 v18.17 以后
- Node.js 18.17 or later.
通过官方提供的cli方式(脚手架)创建项目
npx create-next-app@latest
如上图所示,通过官网脚手架脚本安装,会一次提示我们如何对项目进行初始化操作,以我的初始化方式为例:
- 创建一个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
浏览器打开:http://localhost:3001/
项目结构
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 文件
比如:
关于脚手架的部分,文章到这里就结束了,如果你的项目有国际化的需求(即多语言翻译和切换),可以继续往下了解,同时,你也可以你在一下的内容中,通过国际化方案,了解
Nextjs中关于动态路由的特性实践部分
开启国际化i18n
在NextJs中,开启国际化非常简单,其实官方文档上有非常详尽的解释和引导, 只是因为NextJs作为服务端渲染框架,相对静态部署的站点来说,在显示和切换时机上会稍显复杂,在代码理解和逻辑层上也有出现一些新的变化,完美的情况下,需要你在做国际化前掌握国际化、多语言替换的实现原理,理解服务端部署渲染和客户端渲染差异以及执行时机的不同点。
这里我使用的国际化方案主要来自于NextJs官网原生提供的方案,主要用于演示该方案如何通过Nextjs提供的动态路由来实现多语言切换的
感兴趣的也建议查看一下在官网上推荐的方案资源
中间件 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的重定向跳转
其中如果用户请求的路径没有包含语言环境,则尝试从请求头中获取语言环境,并重定向到带有正确语言环境的路径
举个例子:
- 根据我们的国际化方案,如果要显示中文,正确的网站路由地址是 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;
}
- 尝试从请求头中获取语言环境,并重定向到带有正确语言环境的路径
- 刷新浏览器报错,缺少扩展支持文件@formatjs/intl-localematcher和negotiator
npm i -S @formatjs/intl-localematcher
npm i -S negotiator
-
再次执行
npm run dev -
正确通过中间件自动跳转 http://localhost:3001/zh
此时页面报错404,通过修改 app 目录下页面组件解决,此时页面路由情况:
通过Nextjs动态路由特性优化
细心的朋友发现了,此时我们的国际化方案虽然说初步实践通过了,并且探索了可能性,但是该方案在实践中非常的臃肿和不优雅
目前我们支持两种语言首选行配置,【‘en’, ‘zn’】,即中文和英文,同时在app目录下需要建立对应app/en/* , app/zh/*两个目录,并且其中的代码都是cv复制粘贴的
这时候需要用到Nextjs的动态路由特性来妥善支持
即,你只要修改app目录下的文件名,使其满足[*]格式,框架就可以帮你构建可以用于预渲染的动态路由
例如:原本的app/en/*|app/zh/* 你可以优化成:app/[lang]/*
- 优化以后
更详细的动态路由解释介绍: Dynamic Routes
图片资源报错
重新访问url: http://localhost:3001/, 如预期跳转 http://localhost:3001/zh/
此时会发现图片显示异常:
通过控制台不难发现,中间件把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).*)"],
};
注意这段配置,这里定义了中间件应该匹配的路径模式,通过这段正则,此时我们已经知道,images和fonts文件下的资源都不会被重定向,即调整public下的资源路径即可
- 修改
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>
);
}
- 访问 http://localhost:3001/zh
- 打开控制台查看html页面 Element 内容
语言首选项对应修改
根目录创建字典文件 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']}
<code className="font-mono font-bold">app/page.tsx</code>
</p>
完成国际化!