Next.js 14 开发环境搭建指南:从安装到项目结构解析
什么是 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 种方式,一种基于路由地址,一种是不基于路由地址。我这里就选择路由地址。
同时这里我改用自定义的路径,以便项目更加好管理。并这里使用静态渲染,因为我喜欢在文件中使用异步函数。非静态渲染不能使用异步函数也就是它不在服务端渲染的。
-
在
i18n/messages/en.json
中写入下面的内容{ "HomePage": { "title": "Hello world" } }
同理在
i18n\\messages\\zh.json
写入{ "HomePage": { "title": "你好" } }
-
现在,设置插件,该插件创建别名以向服务器组件提供 i18n 配置(在下一步中指定)。
next.config.mjsimport 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);
-
在
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, }; });
-
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*"], };
-
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> ); }
-
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 框架继续完善这个项目