什么是 Next.js

Next.js 是一个用于构建全栈 Web 应用程序的 React 框架。您可以使用 React Components 来构建用户界面,并使用 Next.js 来实现附加功能和优化。

安装项目

请确保你的操作系统上安装了 Node.js 18.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
# What import alias would you like configured? @/*

这样就得到了一个 Next.js 的项目

安装其他的插件

pnpm add zod // 数据校验工具,通常在表单提交的时候使用
pnpm add bcrypt // 加密工具,通常是用户密码的校验

项目简介

这里提醒一下 next.js 是默认在服务端渲染的框架,对于需要在浏览器中渲染的内容,当然这也就说明你需要浏览器的 api 或者对应的 react hooks。那么你需要在文件的最顶部添加 use client 。记住 use server 使用在函数里面的,而不是 tsx 的样式组件中,表明该函数在服务端使用。

app

由于我们采用的 APP 路由模式,在 Next.js 中文件既是路由。也就是说这是约定熟成的规则。

app\\layout.tsx # 这里是布局页面内容
app\\page.tsx # 这里的内容就是 localhost:3000/ 网页展示的内容

除了这 2 个必要的 tsx 文件,还有其他几个常用的文件,剩余的可以查询官方文档

layout .js .jsx .tsx Layout 这里是布局页面内容
page .js .jsx .tsx Page 对应路由页面展示的内容
loading .js .jsx .tsx Loading UI 页面加载中的样式页面
not-found .js .jsx .tsx Not found UI 404 页面内容
error .js .jsx .tsx Error UI 当页面上出现了错误的时候可以在此页面中处理
global-error .js .jsx .tsx Global error UI
route .js .ts API endpoint 你可以在这里编写接口用来处理 get post 这样的接口内容
template .js .jsx .tsx Re-rendered layout
default .js .jsx .tsx Parallel route fallback page

这里提供我遇到的文件写法案例。

// layout.tsx
import TitleHeader from "@/app/ui/mainLayout/title-header";
import SideNav from "@/app/ui/mainLayout/side-nav";
import RightContainer from "@/app/ui/mainLayout/right-container";

export default function Layout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <main className="flex h-screen">
      <SideNav></SideNav>
      <RightContainer>
        <TitleHeader></TitleHeader>
        <div className="w-full px-8">{children}</div>
      </RightContainer>
    </main>
  );
}
// page.tsx

// localhost:3000/front/2?name=david 以这个例子来说 searchParams拿到的就是{name:david}
// params 拿到的就是2 当然你的page.tsx必须是front/[id]/page.tsx 这样的文件目录下
export default function Page({
  searchParams,
  params,
}: {
  searchParams?: {
    query?: string;
    page?: string;
  };
  params?: {
    id: number;
  };
}) {
  const query = searchParams?.query || "";
  const currentPage = Number(searchParams?.page || 1);

  return <> {query} </>;
}
// loading.tsx
// 加载的时候显示的页面
// 你可以使用如下语句在page.tsx中测试
//await new Promise((resolve) => {
//  setTimeout(() => {
//    resolve(true);
//  }, 3000);
//});

export default function Loading() {
  return <>加载中...</>;
}
// error.tsx

"use client";
import ConversationFallback from "@/components/shared/conversation/ConversationFallback";
import { useRouter } from "next/navigation";
import { useEffect } from "react";

export default function Error({ error }: { error: Error }) {
  const router = useRouter();

  useEffect(() => {
    router.push("/conversations");
  }, [error, router]);

  return <ConversationFallback />;
}
// app/dashboard/api.tsx

export async function POST() {
  try {
    // 使用 Response 对象返回
    return new Response(JSON.stringify({ code: 200, msg: "传输成功" }), {
      headers: {
        "Content-Type": "application/json",
      },
    });
  } catch (error) {
    // 处理可能发生的错误
    return new Response(JSON.stringify({ code: 500, msg: "服务器内部错误" }), {
      status: 500,
      headers: {
        "Content-Type": "application/json",
      },
    });
  }
}

// 之后可以通过 localhost:3000/dashboard/api POST请求来获取内容
const response = await fetch("/dashboard/api", { method: "POST" });

// 或者GET请求
import { type NextRequest } from "next/server";
import { prisma } from "@/app/lib/prisma";

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  const coserId = searchParams.get("coserId");
  const query = searchParams.get("query");
  const limit = parseInt(searchParams.get("limit") || "10", 10);
  const offset = parseInt(searchParams.get("offset") || "0", 10);

  let results = [];

  return new Response(
    JSON.stringify({
      results: results,
      ok: true,
    }),
    {
      headers: { "Content-Type": "application/json" },
    }
  );
}

// 可以在页面中这样请求接口
let res = await fetch(
  `/dashboard/cosers/filter?query=${filterText}&offset=0&limit=20&coserId=${coserId}`,
  {
    signal,
  }
);
// not-found.tsx

export default function NotFound() {
  return <main>404咯</main>;
}

public

公共资源文件夹,这儿通常存放图片或一些公共文件。例如public\\next.svg 在组件上使用的地址就是/next.svg

next.config.mjs

next.js 的配置文件,这里的具体情况可以查看官网文档。我这里提供我用的一个案例,就是重定向。

/** @type {import('next').NextConfig} */
const nextConfig = {
    async redirects() {
        return [
            {
                source: "/",
                destination: "/conversations",
                permanent: true
            },
        ]
    }
};

export default nextConfig;

数据库连接(可选)

说明以及安装

在连接数据库之前,我们需要了解数据库和 orm 之间的关系,orm 是一种数据关系映射的工具,它用作编写纯 SQL 或使用其他数据库访问工具。这样的话,我们不用关心多种数据库的连接,改变数据库版本之后要修改代码等等问题。

pnpm install prisma --save-dev
npx prisma // 获取命令
npx prisma init // 初始化prisma

初始化之后会得到 prisma 的文件夹(如果你初始化失败,八成是你的网络连不上国外的网络)。

新建.env 文件,这个是本地的环境变量设置

DATABASE_URL="file:./dev.db" // SQLite 数据库是简单的文件,所以你建立一个dev.db的文件就好了
DATABASE_URL="postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public" // postgresql的数据库连接
DATABASE_URL="mysql://USER:PASSWORD@HOST:PORT/DATABASE" // mysql的数据库连接
// 上面的3个选择你自己的数据库就可以了

prisma 配置

上面新建的环境变量文件,那么就需要 prisma 来连接数据库了。

打开prisma\\schema.prisma 文件

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql" // 如果你是MySQL/SQLite 对应的字段为"mysql"、"sqlite"
  url      = env("DATABASE_URL")
}
// 上面的就是postgresql的连接
// 如果你使用了第三方的postgresql数据库,那么配置上的修改安装他们的提示

创建数据库

添加如下的模型到prisma\\schema.prisma 文件中

// ... 其他的配置
model User {
  id    Int     @id @default(autoincrement())
  email String  @unique
  name  String?
}

之后做如下操作,同步本地的表结构到数据库中。

npx prisma migrate dev --name init

当然你可能会修改数据库的表结构什么,为了方便可以在package.json把启动命令配置一下。更新表结构至线上数据库中使用npx prisma db push 如果是一个已经有数据的项目,就不能使用这个命令了,转而使用 prisma migrate 迁移。本文先不涉及。

{
.....
  "scripts": {
    "dev": "npm run prisma:generate && next dev",
    "build": "npm run prisma:generate && next build",
    "start": "next start",
    "lint": "next lint",
    "prisma:generate": "prisma generate",
  },
  .......
}

Next.js 客户端上使用

安装客户端

pnpm add @prisma/client

然后在 lib/prisma.ts 中写入

import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();

之后你可以在任意地方引入然后编写数据库操作语句。例如

import { prisma } from "@/app/lib/prisma";
export async function fetchCosplayPagesByCoserId({
  coserId,
  itemsPrePage = ITEMS_PER_PAGE,
}: {
  coserId: number | string;
  itemsPrePage?: number;
}) {
  try {
    const count = await prisma.posts.count({
      where: {
        coser_id: Number(coserId),
        status: {
          not: 2,
        },
      },
    });
    const totalPages = Math.ceil(count / itemsPrePage);
    return totalPages;
  } catch (error) {
    console.error("数据库错误", error);
    throw new Error(`获取指定Coser的作品数量错误${error}`);
  }
}

你也可以使用npx prisma --help查看更多的语法

Next-auth(可选)

这是 Next.js 出品的用户验证插件,主要是完成像用户登录、会话管理、校验流程。具体查看官网

pnpm add next-auth

首选正常我们在页面中登录。

import { authenticate } from "@/app/lib/actions";

export default function Page() {
  return (
    <form action={authenticate}>
      <input type="email" name="email" placeholder="Email" required />
      <input type="password" name="password" placeholder="Password" required />
      <button type="submit">Login</button>
    </form>
  );
}

我们在app/lib/actions.tsx

"use server";

import { signIn } from "@/auth";
import { authorror } from "next-auth";

export async function authenticate(
  prevState: string | undefined,
  formData: FormData
) {
  try {
    await signIn("credentials", formData);
  } catch (error) {
    if (error instanceof authorror) {
      switch (error.type) {
        case "CredentialsSignin":
          return "Invalid credentials.";
        default:
          return "Something went wrong.";
      }
    }
    throw error;
  }
}

/auth.ts中写入。这里主要处理的就是账号密码验证

import NextAuth from "next-auth";
import { authConfig } from "./auth.config";
import Credentials from "next-auth/providers/credentials";
import { z } from "zod";
import bcrypt from "bcrypt";
import { prisma } from "./app/lib/prisma";

async function getUser(email: string) {
  try {
    const user = await prisma.users.findUnique({
      where: {
        email: email,
      },
    });
    return user;
  } catch (error) {
    console.error("Failed to fetch user:", error);
    throw new Error("Failed to fetch user.");
  }
}

export const { auth, signIn, signOut } = NextAuth({
  ...authConfig,
  providers: [
    Credentials({
      async authorize(credentials) {
        const parsedCredentials = z
          .object({ email: z.string().email(), password: z.string().min(6) })
          .safeParse(credentials);

        if (parsedCredentials.success) {
          const { email, password } = parsedCredentials.data;
          const user = await getUser(email);
          if (!user) return null;
          const passwordsMatch = await bcrypt.compare(password, user.password);
          if (passwordsMatch) {
            // 在这里转换你的 User 对象到 NextAuth 预期的格式
            const nextAuthUser = { ...user, id: user.id.toString() }; // 将 id 转换为字符串
            return nextAuthUser;
          }
        }
        console.log("Invalid credentials");
        return null;
      },
    }),
  ],
});

配置需要登录才能访问的路由。在auth.config.ts

import type { NextAuthConfig } from "next-auth";

export const authConfig = {
  pages: {
    signIn: "/login",
  },
  callbacks: {
    async redirect({ url, baseUrl }) {
      // 如果用户刚刚登录成功,则重定向到 /dashboard
      return `${baseUrl}/dashboard`;
    },
    async authorized({ auth, request: { nextUrl } }) {
      const isLoggedIn = !!auth?.user;
      const isOnDashboard = nextUrl.pathname.startsWith("/dashboard");
      if (!isLoggedIn && isOnDashboard) {
        return false;
      }
      return true;
    },
  },
  providers: [], // Add providers with an empty array for now
} satisfies NextAuthConfig;

然后用中间件保护路由 /middleware.ts

import NextAuth from "next-auth";
import { authConfig } from "./auth.config";

export default NextAuth(authConfig).auth;

export const config = {
  // <https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher>
  matcher: ["/((?!api|_next/static|_next/image|.*\\\\.png$).*)"],
};

这样就完成了用户的登录验证问题。

Fetch(可选)

这个主要是为了将 Next.js 纯作为一个 SSR 项目使用,只做前端页面开发。

值得注意的是,Next.js 毕竟是混合开发,也就是混合客户端和服务端的开发。所以你的接口请求虽然说是请求第三方,但是还是得考虑这个请求是发生在服务端还是客户端上。

对 fetch 做一个封装,让它能够自动对头部做参数添加。以及对于 401 的错误处理。

由于要考虑服务端所以,服务端上的 token 处理就采用 cookie 的方式。

/*
 * @Author: HideInMatrix
 * @Date: 2024-07-16
 * @LastEditors: HideInMatrix
 * @LastEditTime: 2024-07-18
 * @Description: 这是一则说明
 * @FilePath: /next.js-template/lib/setCookie.ts
 */
"use server";

import { cookies } from "next/headers";

export async function setCookies(name: string, data: any) {
  cookies().set(name, data);
}

export async function getCookies(name: string) {
  return cookies().get(name)?.value;
}
/*
 * @Author: HideInMatrix
 * @Date: 2024-07-16
 * @LastEditors: HideInMatrix
 * @LastEditTime: 2024-07-18
 * @Description: 这是一则说明
 * @FilePath: /next.js-template/lib/utils.ts
 */

/**
 * 判断是否客户端
 * @returns {boolean}
 */
export const isBrowser = typeof window !== "undefined";
/*
 * @Author: HideInMatrix
 * @Date: 2024-07-15
 * @LastEditors: HideInMatrix
 * @LastEditTime: 2024-07-17
 * @Description: 请求封装
 * @FilePath: /next.js-template/lib/customFetch.ts
 */

import { redirect } from "next/navigation";
import { isBrowser } from "@/lib/utils";
import { getCookies } from "./setCookie";

type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
const backPreUrl =
  process.env.NODE_ENV === "development"
    ? "<http://localhost:3000>"
    : process.env.NEXT_PUBLIC_BACK_PRE_URL;
const urlPreTag =
  process.env.NODE_ENV === "development"
    ? process.env.NEXT_PUBLIC_BACK_PRE_TAG
    : "";

interface FetchOptions extends RequestInit {
  headers?: Record<string, string>;
}

interface ApiResponse<T = any> {
  data?: T;
  error?: string;
  status?: number;
}

const apiClient = <T,>(method: HttpMethod) => {
  return async (
    url: string,
    data?: any,
    options: FetchOptions = {}
  ): Promise<ApiResponse<T>> => {
    const controller = new AbortController();
    const { signal } = controller;
    let token = "";
    let defaultLocale = "";
    token = (await getCookies("NEXT_TOKEN")) || "";
    defaultLocale = (await getCookies("NEXT_LOCAL")) || "";

    const config: FetchOptions = {
      method,
      signal,
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${token}`,
        ...options.headers,
      },
      ...options,
    };

    if (method !== "GET" && data) {
      config.body = JSON.stringify(data);
    }

    const response = await fetch(`${backPreUrl}${urlPreTag}${url}`, config);

    if (response.status === 401) {
      // 处理 401 状态码
      if (isBrowser) {
        location.href = `/login`;
      } else {
        redirect(`/login`);
      }
    }

    const result = await response.json();

    if (!response.ok) {
      return {
        error: result.message || "Request failed",
        status: response.status,
      };
    }

    return result;
  };
};

export const getRequest = apiClient("GET");
export const postRequest = apiClient("POST");
export const putRequest = apiClient("PUT");
export const deleteRequest = apiClient("DELETE");

使用案例

"use client";
import { getRequest } from "@/lib/customFetch";

import { useCallback, useEffect, useState } from "react";
export default function UserName() {
  // 客户端请求方式
  const [data, setData] = useState<{
    data?: any;
    error?: string;
    status?: number;
  }>();
  const loaderProfile = useCallback(async () => {
    const result = await getRequest(`/auth/profile`);
    if (!result.error) {
      setData(result);
    }
  }, []);

  useEffect(() => {
    loaderProfile();
  }, [loaderProfile]);

  return <>userName组件 {data?.data.name}</>;
}
import { getRequest } from "@/lib/customFetch";
export default async function UserName() {
  // 服务端请求方式
  const result = await getRequest(`/auth/profile`);
  if (!result.error) {
    console.log(result);
  }

  const data = result.data as { name: string };

  return <>userName组件 {data?.name || "为获取到数据"}</>;
}

设置开发代理

next.config.mjs

/** @type {import('next').NextConfig} */

const isProd = ["production"].includes(process.env.NODE_ENV);
// 转发
const rewrites = () => {
  if (!isProd) {
    return [
      {
        source: "/api/:slug*",
        destination: "<http://localhost:7000/api/:slug*>",
      },
    ];
  } else {
    return [];
  }
};

const nextConfig = {
  rewrites: rewrites,
};

export default nextConfig;

添加状态管理插件

本项目状态管理没有选择传统的 redux 而是选择了比较轻巧的 zsutand该状态管理对于一般的项目已经足够用了

pnpm install zustand

然后在store/user.ts 中写入如下内容

import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
import { defaultLocale, locales } from '@/static/locales';

// Custom types for theme
interface SettingState {
  defaultLocale: string;
  locales: string[];
  setDefaultLocale: (newVal: string) => void;
}

const useSettingStore = create<SettingState>()(
  persist(
    (set, get) => ({
      defaultLocale: get()?.defaultLocale ? get()?.defaultLocale : defaultLocale,
      locales: locales,
      setDefaultLocale: (newVal) => set((state: any) => ({
        defaultLocale: state.defaultLocale = newVal,
      })),
    }),
    {
      name: 'setting',
      storage: createJSONStorage(() => sessionStorage), // default localstorage    },
  ),
);

export default useSettingStore;

使用案例

import useSettingStore from "@/store/useSettingStore";
export default function CHangeLanguage() {
  const options = [
    { label: "EN", value: "en" },
    { label: "中", value: "zh" },
  ];
  const setDefaultLocale = useSettingStore((state) => state.setDefaultLocale); //设置函数
  const defaultLocale = useSettingStore((state) => state.defaultLocale); // 读取内存的值
  const [value, setValue] = useState(defaultLocale);

  const handleChange = ({ target: { value } }: RadioChangeEvent) => {
    setValue(value);
    setDefaultLocale(value);
  };
  return (
    <>
      <Group
        options={options}
        onChange={onLanguageChange}
        value={value}
        key={value}></Group>
    </>
  );
}

国际化

  • next-i18next​: 一款流行的 Next.js 国际化插件,它提供了丰富的功能,包括多语言路由、服务器端渲染和静态生成的支持,以及简单的翻译文件管理。
  • next-intl​: 用于 Next.js 的国际化插件,它提供了基于 React Intl 的国际化解决方案,支持多语言文本和格式化。
  • next-translate​: 这个插件为 Next.js 提供了简单的国际化解决方案,支持静态生成和服务器端渲染,并且易于配置和使用。

使用 next-intl 插件,主要是网上看了对比的文章,这款插件从扩展和使用灵活性上都非常不错。

pnpm install next-intl

官方文档中提供了 2 种方式,一种基于路由地址,一种是不基于路由地址。我这里就选择路由地址。

同时这里我改用自定义的路径,以便项目更加好管理。并这里使用静态渲染,因为我喜欢在文件中使用异步函数。非静态渲染不能使用异步函数也就是它不在服务端渲染的。

  1. i18n/messages/en.json 中写入下面的内容

    {
      "HomePage": {
        "title": "Hello world"
      }
    }
    

    同理在i18n\\messages\\zh.json 写入

    {
      "HomePage": {
        "title": "你好"
      }
    }
    
  2. 现在,设置插件,该插件创建别名以向服务器组件提供 i18n 配置(在下一步中指定)。
    next.config.mjs

    import createNextIntlPlugin from "next-intl/plugin";
    
    const withNextIntl = createNextIntlPlugin("./i18n/i18n.ts");
    
    /** @type {import('next').NextConfig} */
    const nextConfig = {};
    
    export default withNextIntl(nextConfig);
    

    next.config.js

    const createNextIntlPlugin = require("next-intl/plugin");
    
    const withNextIntl = createNextIntlPlugin("./i18n/i18n.ts");
    
    /** @type {import('next').NextConfig} */
    const nextConfig = {};
    
    module.exports = withNextIntl(nextConfig);
    
  3. i18n\\i18n.ts 中使用next-intl 创建一个请求范围的配置对象,可用于根据用户的区域设置提供消息和其他选项,以便在服务器组件中使用。

    import { notFound } from "next/navigation";
    import { getRequestConfig } from "next-intl/server";
    
    // Can be imported from a shared config
    const locales = ["en", "zh"];
    
    export default getRequestConfig(async ({ locale }) => {
      // Validate that the incoming `locale` parameter is valid
      if (!locales.includes(locale as any)) notFound();
    
      return {
        messages: (await import(`./messages/${locale}.json`)).default,
      };
    });
    
  4. middleware.ts
    中间件匹配请求的区域设置并相应地处理重定向和重写。

    import createMiddleware from "next-intl/middleware";
    
    export default createMiddleware({
      // A list of all locales that are supported
      locales: ["en", "zh"],
    
      // Used when no locale matches
      defaultLocale: "zh",
    });
    
    export const config = {
      // Match only internationalized pathnames
      matcher: ["/", "/(zh|en)/:path*"],
    };
    
  5. app\\[locale]\\layout.tsx
    这里需要调整一下自定义生成的文件,将 app 下面的约定文件例如 layout.tsx 移入到[locale]文件夹下

    import { NextIntlClientProvider } from "next-intl";
    import { getMessages } from "next-intl/server";
    
    export default async function LocaleLayout({
      children,
      params: { locale },
    }: {
      children: React.ReactNode;
      params: { locale: string };
    }) {
      // Providing all messages to the client
      // side is the easiest way to get started
      const messages = await getMessages();
    
      return (
        <html lang={locale}>
          <body>
            <NextIntlClientProvider messages={messages}>
              {children}
            </NextIntlClientProvider>
          </body>
        </html>
      );
    }
    
  6. app/[locale]/page.tsx
    在页面组件或其他任何地方使用翻译!

    import { useTranslations } from "next-intl"; //  非异步组件
    // 如果你想用异步组件也就是在下面的函数中使用async await
    // import {getTranslations} from 'next-intl/server';
    
    export default function HomePage() {
      const t = useTranslations("HomePage");
      // const t = await getTranslations('ProfilePage');
      return <div>{t("title")}</div>;
    }
    

到此国际化,axios 等设置就基本完成了,下面我使用一个 next.js 的 UI 框架继续完善这个项目