Next.js入门指北

297 阅读14分钟

起因

凡事皆有因,有因才有果。一切还要从起因说起,2024年初,我开始做一个海外电商网站的二次开发工作,它的前端基于React技术栈,后端是基于PHP技术栈。这个网站主要是面对C端用户的,但是它的技术选型好像并不适合。下面我以产品列表页面来举例说明一下

当用户点击进入产品列表页面时,整体流程如下

  1. 浏览器通过前端路由导航到产品页面
  2. 等待DOM树渲染完毕后,发出XHR请求获取产品列表
  3. 使用js将产品列表渲染到DOM上

看起来好像没什么问题,但实际体验却有点糟糕

  1. 首先进入页面时渲染一次DOM,由于没有数据,因此页面是一片空白。
  2. 其次发送XHR请求,花费了一定的时间。
  3. 最后将XHR返回的数据再次渲染到DOM,这个过程也花费了一定的时间。

我们总结一下,一共渲染了两次DOM,发送一次XHR请求,并且这个初始时渲染DOM-发送XHR-再次渲染DOM过程无法并行。

graph 
  浏览器发送请求 --> 服务端返回内容 ---> 浏览器执行JS进行渲染 
  ---> 发送XHR ---> 返回数据 ---> 再次渲染
  

那么如果说用户点击这个页面的时候,服务器直接返回已经渲染好数据的页面,浏览器只做一次渲染那体验会好很多。如果我们采用服务端服务端渲染技术,那么只需要在每次请求时生成一遍HTML页面,这个即是SSR(服务端渲染)技术。

graph 
  浏览器发送请求 --> 服务端渲染 ---> 返回内容 ---> 浏览器呈现

根据实际业务来看,由于产品并不是经常更新(几天或者几周才可以更新一个产品),所以最好的方案就是将这个页面静态化,然后设置下这个页面的有效期,过期则重新生成,这个即是SSG(服务端生成)技术。这样不仅节省了服务器生成HTML的时间,还可以充分利用CDN来缓存页面。

graph 
  浏览器发送请求 --> 服务端并返预先生成的内容 ---> 浏览器呈现

为了解决这些问题,我将目光投向了两个技术框架Next.js和Astro。我用两者分别写了一个demo之后,感觉Astro更倾向于SSG,编写交互性代码多有不便,而Next.js我做的网站交互性还是比较多的,两者性能方面所差无几,因为决定上Next.js这条船。

准备工作

在学习Next.js之前,我准备了供外网访问的预览环境,这样其他人也可以方便的查到我的网页。如果你需要的话,以下这些内容将会给你带来巨大的帮助

  1. 代码托管:github,这个不用多说,你也可以根据自己的爱好选择主流的代码托管平台
  2. 网站托管:netlify/vercel ,由于我很早就使用netlify了,所以这次还是选择的netlify
  3. 云数据库:cloud.mongodb.com 免费500M容量,自用足够了
  4. 域名:godaddy约10元1年,这个不是必须的,netlify的二级域名也不影响使用
  5. 云存储:我使用的腾讯云存储,这个也不是必须,具体看你的需求,按量收费,价格非常便宜
  6. 邮件服务:resend.com 免费版限量每天100封,每月3000封邮件

React vs Next.js

回想一下,当使用React开发的时候,你需要做什么?首先你需要选择一个打包工具:vite/webpack,其次安装React和React Router,接下来开始意大利面条,哦不,应该是苏格兰打卤面一样的路由配置文件,说真的我实在受够了,每个项目都要带上这个狗皮膏药一样的东西。当然你可以选择一个开发模板进行开发,但是寻找一个合适的模板也会花费时间。现在你明白了,React仅仅是一个渲染库而已,要完成一个基础的项目开发工作,往往需要配合其他工具。

和React不同,Next.js是一个框架,所需要的基本工具它已经帮你配置完成,你需要做的仅仅是安装Next.js,一切便已就绪。

Hello World

毫无疑问,学习一门新技术,最重要的先run起来。

前提条件:你已经安装了nodejs18.17版本以上

只需要运行以下命令

npx create-next-app@latest

代码执行后会有以下提示

What is your project named? my-app
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
  1. 输入项目名称
  2. 选择是否使用TypeScript,请选择No,暂时不需要TypeScript
  3. 选择是否使用ESLint,请选择No,除了代码错误检查外,ESLint对代码格式还有一些要求,不要让格式错误阻断你的学习进度,当然如果你有使用ESLint的经验,可以选择Yes后自定义配置
  4. 选择是否使用Tailwind CSS,请选择Yes,即使你不了解Tailwind,选择Yes也不会影响你学习后续的内容
  5. 选择是否使用src文件夹,这个无关紧要,看你个人的爱好即可,我选择是No,这样文件夹更扁平一些
  6. 选择是否使用App Router模式,请选择Yes,App Router是新版本支持的模式,后续所内容也是基于App Router进行开发
  7. 是否自定义默认别名引用文件,请选择No,使用默认配置即可

选择完之后等待几秒创建成功后,进入项目文件夹执行安装依赖命令

npm install

之后执行启动命令即可

npm run dev

按下Ctrl + 鼠标左键点击http://localhost:3000即可在浏览器打开

OK,没有问题的话,现在项目就已经可以运行起来了

下面对代码进行简单的清理工作,进入app文件夹

  1. 打开globals.css,仅保留以下代码,其余的删掉
@tailwind base;
@tailwind components;
@tailwind utilities;
  1. 打开page.js,用以下代码替换掉
export default function Home() {
  return (
    <h1 className="text-xl font-semibold">
      Hello World
    </h1>
  );
}

这个时候再看一眼http://localhost:3000,你会发现久违的Hello World又出来了

路由系统

在Next.js中,文件即路由。通过定义文件夹的名称和路径,便可以定义路由信息的基本信息。

首先要了解一下Next.js中的特殊文件 page,它是Next.js内定的用来专门展示页面的文件, 其后缀可以是.js,.jsx,.ts,.tsx,由于我们并没有使用TypeScript,因此使用.js或者.jsx后缀即可,我建议使用.jsx,这样可以享受到VS Code里插件的代码提示。

最简示例

来举一个最简单的例子,在app下面创建一个文件夹main,在main下面创建一个page.jsx文件写入

export default function Page() {
    return (
        <div>
            Main
        </div>
    );
}

访问http://localhost:3000/main就可以看到写的内容了

嵌套路由

嵌套路由和嵌套文件夹形式是一模一样的,在main下面创建一个sub1 和sub2 文件夹,并在两者下面创建page.jsx文件写入

/main/sub1/page.jsx

export default function Page() {
    return (
        <div>
            Sub1
        </div>
    );
}

/main/sub2/page.jsx

export default function Page() {
    return (
        <div>
            Sub2
        </div>
    );
}

ok现在打开对应的地址就可以看到Sub1和Sub2,但是问题来了,main作为父页面路由,如何在/main/sub1和/main/sub2都显示父页面的某个内容呢

layout文件

和page文件一样,layout文件也是一个特殊类型文件,本级以及所有子级路由页面都将共享layout文件,来上代码,在main文件夹下面创建layout.jsx

export default function Layout({ children }) {
    return (
        <div>
            <p>布局</p>
            {children}
        </div>
    );
}

其中children代表路由对应的page文件,分别访问/main 、/main/sub1 、/main/sub2可以看到以下内容

Untitled.png

Untitled 1.png

Untitled 2.png

需要注意的一点是,当访问完/main地址后,再访问/main/sub1和/main/sub2,layout只会在初始时加载一次,这个行为和react router里的父路由是一致的

ok,聪明的你可能会想到,在layout页面上添加一个导航栏切换sub1、sub2是一个不错的主意,那么开始吧

导航

在Next.js中有多种导航方式,我们来说最基本的一种,使用Link组件进行导航

打开main/layout.jsx文件进行修改,删掉其中的”布局“内容,替换为导航组件

import Link from "next/link";
export default function Layout({ children }) {
    return (
        <div>
            <div>
                <Link href="/main/sub1" className="text-blue-500 mr-2">sub1</Link>
                <Link href="/main/sub2" className="text-blue-500">sub2</Link>
            </div>
            {children}
        </div>
    );
}

Untitled 3.png

点击蓝色的导航,即可实现sub1和sub2的切换

路由参数

从路由地址从url上获取参数是非常简单的,只需要读取一个特殊变量searchParams即可


export default function Page({searchParams}) {
    console.log(searchParams);
    return (
        <div>
            sub1
        </div>
    );
}

访问一下 /main/sub1?name=张三,在控制台输出可以看到如下信息

 GET /main/sub1?name=%E5%BC%A0%E4%B8%89 200 in 44ms
 { name: '张三' }

动态路由

截止到目前,我们定义的这几个路由 /main、/main/sub1、/main/sub2 都是静态的。但是有时候我们需要将user id作为路由段的内容:/user/1、/user/2、/user/3,然后展示user详情信息,这样不可能定义1 2 3等等全部用户id的静态路由,因此动态路由就派上用场了。

使用方括号包裹一个参数名称来创建文件夹,即可定义动态路由,我们在app下面创建user/[id]文件夹以及user/[id]/page.jsx

/user/[id]/page.jsx

Untitled 4.png


export default function Page({params}) {
    console.log(params);
    return (
        <div>
            当前id是:{params.id}
        </div>
    );
}

Untitled 5.png

除了这个应用场景之外,在Next.js中多语言切换也是使用的动态路由方案。

重定向

重定向的方法有很多,这里介绍最基本的一种,在user/[id]/page.jsx里试验一下

import { redirect } from "next/navigation";
export default function Page({params}) {
    console.log(params);
    redirect("/main");
    return (
        <div>
            当前id是:{params.id}
        </div>
    );
}

API

Next.js使用特殊文件route来表示这是一个API,支持GET、POST、PUT、DELETE方法,我们写一个简单示例,创建/api/test/route.js文件


import { NextResponse } from "next/server";
export function GET(request) {
    const searchParams = request.nextUrl.searchParams;
    console.log(searchParams);
    return NextResponse.json({
        message: "Hello World"
    })
}

其中request.nextUrl.searchParams是url上参数,在浏览器的控制台使用GET方法调用/api/test接口

fetch("/api/test?name=xiaoxiao")

可以看到返回信息是

Untitled 6.png

同时可以在VS Code的控制台里输出了参数信息

Untitled 7.png

服务端组件和客户端组件

在Next.js中代码默认在服务端执行,如果要想在客户端执行代码,需要在文件头部添加”use client“来说明。

服务端组件在服务端进行渲染(SSR)返回HTML结果,由于SSR可以有效减轻客户端的负担,因此我们推荐尽可能的使用服务端组件进行开发。

客户端组件的子组件也会被视为一个整体在客户端执行,因此,尽可能的将客户端组件放置于文件组织的末梢。

以下情况建议使用服务端组件

  1. 获取数据

    Next.js在服务端实现了和浏览器一样的fetch方法,因此可以使用fetch获取远程数据

    更改main/sub1/page.jsx

    const getData = async () => {
        const res = await fetch('<http://localhost:3000/api/test>');
        return await res.json();
    };
    export default async function Page() {
        const data = await getData();
        return (
            <div>
                {JSON.stringify(data)}
            </div>
        );
    }
    

    Untitled.png

    也可以直接使用nodejs获取任意数据,例如获取当前文件夹路径

    
    const getData = async () => {
        const res = await fetch('<http://localhost:3000/api/test>');
        return await res.json();
    };
    export default async function Page() {
        const data = await getData();
        return (
            <div>
                {JSON.stringify(data)}
                <br />
                服务端路径{process.cwd()}
            </div>
        );
    }
    

    Untitled 1.png

  2. 权限认证,这个在后续的用户认证里写

  3. 重定向,在前面路由系统已经写了

必须要交互的时候,再使用客户端组件,例如编辑用户数据,可以使用服务端组件先获取到用户数据,然后将数据传递个子组件客户端,画个图比较直观

Untitled 2.png

连接数据库

在nodejs环境下连接数据库还是很简单的,根据不同的数据库会使用对应的ORM进行操作。这里介绍一下如使用nodejs操作mongodb。

申请免费数据库

首先在cloud.mongodb.com申请一个免费的云数据库

  1. 点击创建Project Untitled.png

  2. 随便起一个名字,然后点击Next

    Untitled 1.png

  3. 继续下一步

    Untitled 2.png

  4. 这个时候会提示你创建集群,点击创建

    Untitled 3.png

  5. 选择M0,自定义集群名称(默认Cluster0),供应商不用变,选择地区之后创建即可

    Untitled 4.png

  6. 自定义账号密码,建议不用管,使用默认即可,然后点击创建用户

    Untitled 5.png

  7. 复制数据库连接地址,使用默认项,点击复制后先保存起来后面要用到

    Untitled 6.png

  8. 点击前往设置访问ip

    Untitled 7.png

  9. 设置ip

    Untitled 8.png

    Untitled 9.png

  10. 稍等一会设置完毕后就可以访问了,使用Mongdb Compass测试连接一切正常 Untitled 10.png

  11. 创建一个数据库和users集合,然后添加一条数据 Untitled 11.png Untitled 12.png

这部分工作告一段落,开始下一阶段的任务

使用ORM:mongoose

npm install mongoose

创建.env文件,写入数据库连接变量

MONGO_URI="刚才保存的连接地址"

创建文件夹lib,在下面创建connect-mongo.js

import mongoose from "mongoose";

const MONGO_URI = process.env.MONGO_URI;
const cached = {};
async function connectMongo() {
    if (!MONGO_URI) {
        throw new Error("Please define the MONGO_URI environment variable inside .env.local");
    }
    if (cached.connection) {
        return cached.connection;
    }
    if (!cached.promise) {
        const opts = {
            bufferCommands: false,
        };
        cached.promise = mongoose.connect(MONGO_URI, opts);
    }
    try {
        cached.connection = await cached.promise;
    } catch (e) {
        cached.promise = undefined;
        throw e;
    }
    return cached.connection;
}
export default connectMongo;

创建models文件夹,在下面创建user.js,创建对应的model

import { model, models, Schema } from 'mongoose';

const UserSchema = new Schema(
    {
       name:String
    }
);
const User = models.User || model('User', UserSchema, 'users');
export default User;

下面写一个查询用户示例,在app/user下面创建page.jsx页面,使用mongoose查询user

import User from '@/models/user';
import connectMongo from '@/lib/connect-mongo';
export default async function Page() {
    await connectMongo();
    let users = await User.find();
    return (
        <div>
            {users.map(user => {
                return <div key={user._id}>{user.name}</div>;
            })}
        </div>
    );
}

打开对应的页面就可以看到

Untitled 13.png

是不是非常简单

用户认证

在Next.js中,我们不需要手动实现认证功能,有非常多的第三方工具可供选择,我选择了最流行的NextAuth.js

配置用户认证功能要比连接数据难度稍微高一点,不过我已经替兄弟萌趟过雷了,放心

  1. 创建app/login/page.jsx

    export default function LoginPage() {
        return (
            <div>
                <form action="">
                    <label>邮箱</label>
                    <input type="email" name="email" />
                    <br />
    
                    <label>密码</label>
                    <input type="password" name="password" />
                </form>
            </div>
        );
    }
    

    创建好页面留着备用

  2. 安装设置next-auth

    先安装对应依赖

    npm i next-auth@beta
    

    然后生成一个key用于加密

    openssl rand -base64 32
    

    将key保存在.env文件中

    AUTH_SECRET=刚才生成的key
    
  3. 添加登录地址选项

    创建/auth-config.js,配置当未认证通过时要跳转的地址

    export const authConfig = {
      pages: {
        signIn: '/login',
      },
    };
    
  4. 还是在这个文件添加认证后的回调,根据认证结果和路由地址决定是否允许跳转

    export const authConfig = {
        pages: {
            signIn: '/login',
        },
        callbacks: {
            authorized({ auth, request: { nextUrl } }) {
                const isLoggedIn = !!auth?.user; // 是否登录
                const isOnUser = nextUrl.pathname.startsWith('/user'); // 地址是否为user开头
                // 如果访问的是user开头的地址,并且已登录,则允许继续跳转,否则重定向到上面配置的登录页面
                if (isOnUser) {
                    if (isLoggedIn) {
                        return true;
                    } else {
                        return false;
                    }
                }
                // 其他情况默认允许跳转
                return true;
            },
        },
        providers: []
    };
    
  5. 创建中间件middleware.js

    import NextAuth from 'next-auth';
    import { authConfig } from './auth.config';
    export default NextAuth(authConfig).auth;
    export const config = {
      matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
    };
    

    除以下内容之外的所有内容:

    1. api
    2. _next/static
    3. _next/image
    4. 以 .png 结尾的任何内容 简单来说就是仅拦截浏览器上的路由地址
  6. 添加认证逻辑,创建auth.js

    import NextAuth from "next-auth";
    import Credentials from "next-auth/providers/credentials";
    import { authConfig } from "./auth.config";
    
    export const {
        signIn,
        signOut,
        auth,
    } = NextAuth({
        ...authConfig,
        providers: [
            Credentials({
                credentials: {
                },
                authorize: async (credentials) => {
                    console.log(credentials);
                    let user = null;
                    if (credentials.email === "337634268@qq.com" && credentials.password === "password") {
                        user = {
                            id: 1,
                            name: "波罗丁的菠萝",
                            email: "337634268@qq.com",
                            image: "<https://avatars.githubusercontent.com/u/10150404?v=4>",
                        };
                    }
                    return user;
                },
            }),
        ],
    });
    

    credentials是浏览器传过来的数据,判断邮箱和密码是否正确,如果正确则返回用户信息,否则返回null,表示认证失败

  7. 创建/lib/actions.js用于调用登录和登出功能

    "use server";
    import { signIn, signOut } from "@/auth";
    import { AuthError } from "next-auth";
    export async function authenticate(
        prevState,
        formData,
    ) {
        try {
            await signIn("credentials", formData);
        } catch (error) {
            if (error instanceof AuthError) {
                switch (error.type) {
                    case "CallbackRouteError":
                        return "认证失败";
                    default:
                        return "服务器内部错误";
                }
            }
            throw error;
        }
    }
    
    export async function logOut() {
        try {
            await signOut();
        } catch (error) {
            if (error instanceof AuthError) {
                switch (error.type) {
                    case "CallbackRouteError":
                        return "认证失败";
                    default:
                        return "服务器内部错误";
                }
            }
            throw error;
        }
    }
    
  8. 更新登录页面代码 /app/login/page.jsx

    "use client";
    import { authenticate } from "@/lib/actions";
    import { useState } from "react";
    export default function LoginPage() {
        const [loginStatus, setLoginStatus] = useState({ success: false, error: null, loading: false });
        const onSubmit = async (e) => {
            e.preventDefault();
            const data = new FormData(e.target);
            setLoginStatus({ success: false, error: null, loading: true })
            const result = await authenticate(null, data);
            if (!result) {
                setLoginStatus({ success: true, error: null, loading: false });
            } else {
                setLoginStatus({ success: false, error: result, loading: false });
            }
        };
        return (
            <div>
                <form onSubmit={onSubmit}>
                    <label>邮箱</label>
                    <input type="email" name="email" />
                    <br />
    
                    <label>密码</label>
                    <input type="password" name="password" />
                    <br />
                    {loginStatus.loading && <div>正在提交</div>}
                    {
                        loginStatus.error &&
                        <p className="text-sm text-red-500">{loginStatus.error}</p>
                    }
                    <button type="submit" className="border bg-blue-400">提交</button>
                </form>
            </div >
        );
    }
    
  9. 配置完成后打开/user页面,由于现在还没有登录,会跳转到login页面。输入邮箱密码认证失败会提示失败信息

    Untitled.png

  10. 登录成功后重定向到原来的页面

    在/app/login/page.jsx添加以下代码,实现重定向

    //...省略...
    import { useState, useEffect } from "react";
    //...省略...
    	useEffect(() => {
            if (loginStatus.success) {
                location.href =  new URLSearchParams(location.search).get("callbackUrl");
            }
       }, [loginStatus]);
    
  11. 登出

    创建/app/logout/page.jsx,调用actions内的logout即可

    "use client";
    import { logOut } from "@/lib/actions";
    import { useEffect, useState } from "react";
    
    export default function Page() {
    
      const [logoutStatus, setLogoutStatus] = useState({
        success: false,
        error: null,
        loading: false,
      });
      const handleSubmit = async (event) => {
        event.preventDefault();
        setLogoutStatus({ success: false, error: null, loading: true });
        const result = await logOut();
        if (!result) {
          setLogoutStatus({ success: true, error: null, loading: false });
        } else {
          setLogoutStatus({ success: false, error: result, loading: false });
        }
      };
      useEffect(() => {
        if (logoutStatus.success) {
          location.href = "/main"; //改为你自己的地址即可
        }
      }, [logoutStatus]);
      return (
        <form onSubmit={handleSubmit}>
          <button type="submit" className="bg-blue-400">
            登出
          </button>
        </form>
    
      );
    }
    

到现在为止,整个用户认证结束了

多语言

在Next.js中使用动态路由可以方便的实现多语言切换。

简单的来说思路如下:首先在app目录下面创建一个动态路由文件夹[lng],将原来所有的路由文件夹都放进去。然后判断动态路由的参数以获取不同语言的配置信息进行显示。其中显示不同配置的方法,在服务端和客户端分别是不同的实现,但是调用起来是一样的。

  1. 安装依赖

    npm install i18next react-i18next i18next-resources-to-backend i18next-browser-languagedetector react-cookie accept-language
    
  2. 创建[lng]文件夹,将所有路由文件夹放进去,再创建一个second路由仅用于演示多语言切换

    Untitled.png

    修改代码/app/[lng]/page.js

    import Link from 'next/link';
    
    export default function Page({ params: { lng } }) {
      return (
        <>
          <h1>Hi there!</h1>
          <Link href={`/${lng}/second`}>
            second page
          </Link>
        </>
      )
    }
    

    修改代码app/[lng]/layout.js

    import { Inter } from "next/font/google";
    import "@/app/globals.css";
    import { dir } from 'i18next';
    const languages = ['en', 'de'];
    export async function generateStaticParams() {
      return languages.map((lng) => ({ lng }));
    }
    const inter = Inter({ subsets: ["latin"] });
    
    export const metadata = {
      title: "Create Next App",
      description: "Generated by create next app",
    };
    
    export default function RootLayout({
      children,
      params: {
        lng
      }
    }) {
      return (
        <html lang={lng} dir={dir(lng)}>
          <body className={inter.className}>{children}</body>
        </html>
      );
    }
    

    修改代码app/[lng]/second/page.js

    import Link from 'next/link';
    
    export default function Page({ params: { lng } }) {
      return (
        <>
          <h1>Hi from second page!</h1>
          <Link href={`/${lng}`}>
            back
          </Link>
        </>
      )
    }
    
  3. 创建i18n文件夹,在下面创建配置文件settings.js、用于服务端翻译的index.js、用于客户端翻译的client.js,zhhk:繁体中文,zhcn:简体中文,en:英文,默认配置使用繁体。再创建每个语言对应的配置内容,文件结构如下

    Untitled 1.png

    setting.js

    export const fallbackLng = 'zhhk'
    export const languages = [fallbackLng, 'zhcn', 'en']
    export const defaultNS = 'translation'
    export const cookieName = 'i18next'
    
    export function getOptions(lng = fallbackLng, ns = defaultNS) {
        return {
            // debug: true,
            supportedLngs: languages,
            fallbackLng,
            lng,
            fallbackNS: defaultNS,
            defaultNS,
            ns
        }
    }
    

    index.js

    import { createInstance } from 'i18next'
    import resourcesToBackend from 'i18next-resources-to-backend'
    import { initReactI18next } from 'react-i18next/initReactI18next'
    import { getOptions } from './settings'
    
    const initI18next = async (lng, ns) => {
        const i18nInstance = createInstance()
        await i18nInstance
            .use(initReactI18next)
            .use(resourcesToBackend((language, namespace) => import(`./locales/${language}/${namespace}.json`)))
            .init(getOptions(lng, ns))
        return i18nInstance
    }
    
    export async function useTranslation(lng, ns, options = {}) {
        const i18nextInstance = await initI18next(lng, ns)
        return {
            t: i18nextInstance.getFixedT(lng, Array.isArray(ns) ? ns[0] : ns, options.keyPrefix),
            i18n: i18nextInstance
        }
    }
    

    client.js

    'use client'
    
    import { useEffect, useState } from 'react'
    import i18next from 'i18next'
    import { initReactI18next, useTranslation as useTranslationOrg } from 'react-i18next'
    import { useCookies } from 'react-cookie'
    import resourcesToBackend from 'i18next-resources-to-backend'
    import LanguageDetector from 'i18next-browser-languagedetector'
    import { getOptions, languages, cookieName } from './settings'
    
    const runsOnServerSide = typeof window === 'undefined'
    
    // 
    i18next
        .use(initReactI18next)
        .use(LanguageDetector)
        .use(resourcesToBackend((language, namespace) => import(`./locales/${language}/${namespace}.json`)))
        .init({
            ...getOptions(),
            lng: undefined, // let detect the language on client side
            detection: {
                order: ['path', 'htmlTag', 'cookie', 'navigator'],
            },
            preload: runsOnServerSide ? languages : []
        })
    
    export function useTranslation(lng, ns, options) {
        const [cookies, setCookie] = useCookies([cookieName])
        const ret = useTranslationOrg(ns, options)
        const { i18n } = ret
        if (runsOnServerSide && lng && i18n.resolvedLanguage !== lng) {
            i18n.changeLanguage(lng)
        } else {
            // eslint-disable-next-line react-hooks/rules-of-hooks
            const [activeLng, setActiveLng] = useState(i18n.resolvedLanguage)
            // eslint-disable-next-line react-hooks/rules-of-hooks
            useEffect(() => {
                if (activeLng === i18n.resolvedLanguage) return
                setActiveLng(i18n.resolvedLanguage)
            }, [activeLng, i18n.resolvedLanguage])
            // eslint-disable-next-line react-hooks/rules-of-hooks
            useEffect(() => {
                if (!lng || i18n.resolvedLanguage === lng) return
                i18n.changeLanguage(lng)
            }, [lng, i18n])
            // eslint-disable-next-line react-hooks/rules-of-hooks
            useEffect(() => {
                console.log('lng', lng);
                if (!lng) {
                    return;
                }
                if (cookies.i18next === lng) return
                setCookie(cookieName, lng, { path: '/' })
            }, [lng, cookies.i18next])
        }
        return ret
    }
    

    en/translation.json

    {
      "title": "title"
    }
    

    zhhk/translation.json

    {
      "title": "標題"
    }
    

    zhcn/translation.json

    {
      "title": "标题"
    }
    
  4. 配置middleware.js,修改为如下代码

    import NextAuth from 'next-auth';
    import { NextResponse } from 'next/server';
    import { authConfig } from './auth.config';
    import acceptLanguage from 'accept-language';
    import { fallbackLng, languages, cookieName } from './app/i18n/settings';
    
    export default NextAuth(authConfig).auth;
    
    acceptLanguage.languages(languages)
    export const config = {
        // <https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher>
        // matcher: [''],
        matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
    };
    export function middleware(req) {
        let lng
        if (req.cookies.has(cookieName)) lng = acceptLanguage.get(req.cookies.get(cookieName).value)
        if (!lng) lng = acceptLanguage.get(req.headers.get('Accept-Language'))
        if (!lng) lng = fallbackLng
    
        // Redirect if lng in path is not supported
        if (
            !languages.some(loc => req.nextUrl.pathname.startsWith(`/${loc}`)) &&
            !req.nextUrl.pathname.startsWith('/_next')
        ) {
            return NextResponse.redirect(new URL(`/${lng}${req.nextUrl.pathname}`, req.url))
        }
    
        if (req.headers.has('referer')) {
            const refererUrl = new URL(req.headers.get('referer'))
            const lngInReferer = languages.find((l) => refererUrl.pathname.startsWith(`/${l}`))
            const response = NextResponse.next()
            if (lngInReferer) response.cookies.set(cookieName, lngInReferer)
            return response
        }
    
        return NextResponse.next()
    }
    
  5. 调用服务端翻译方法,修改app/[lng]/page.jsx

    import Link from 'next/link';
    import { useTranslation } from '@/app/i18n';
    export default async function Page({ params }) {
      const { t } = await useTranslation(params.lng);
      return (
        <>
          <h1>Hi there!-----------{t('title')}</h1>
          <Link href={`/${params.lng}/second`}>
            second page
          </Link>
        </>
      )
    }
    

    可以看到下面两个不同的页面出现的内容也不一样

    Untitled 2.png

    Untitled 3.png

  6. 调用客户端翻译方法,修改app/[lng]/second/page.jsx

    'use client'
    import Link from 'next/link';
    import { useTranslation } from "@/app/i18n/client";
    
    export default function Page({ params }) {
      const { t } = useTranslation(params.lng);
    
      return (
        <>
          <h1>Hi from second page!----{t('title')}</h1>
          <Link href={`/${params.lng}`}>
            back
          </Link>
        </>
      )
    }
    
  7. 语言切换

    我一向认为简单粗暴是硬道理,所以只需在layout添加对应的a链接切换到不同的主页即可

    <a href="/zhhk">zhhk</a>
    <a href="/zhcn">zhcn</a>
    <a href="/en">en</a>