Nextjs13+ 企业级项目架构与实践

4,847 阅读13分钟

大家好,我是byte,最近公司需要启动一个新web3项目,综合考虑之后决定采用Next.js 13+、TypeScript、Turbopack、Tailwind CSS的技术栈。

什么是 Next.js

有些朋友可能对Next.js了解比较少,这里简单聊聊什么是Next.js。

Next.js是一个非常全面的现代全栈应用构建方案,它包含了TypeScriptReact的支持,同时提供了常见的需求解决方案,例如:路由、API、代码分割、路由预加载、SEO优化等。

不过上面那些都不是最重要的,最重要的是它支持静态站点生成,支持SSG(静态生成)、SSR(服务端渲染)。这对于一些对SEO、首屏加载、可访问性有要求的项目来说,简直不要太香。当然SSGSSR也有其存在的缺点,不过与本文主题没啥关联,所以不再做过多赘叙,以后有空的话再写篇文章专门说下这块知识点。

由于Next.js官网已经做的足够好了,如果想对Next.js了解更多的话,本文不在做过多赘叙。官网传送门Next.js官网

项目创建

我们先使用官网推荐的创建方式来创建一个默认的Next.js应用,官网说前置条件为node版本大于等于16.8,我用的是18.16.1

npx create-next-app@latest

安装的时候,会有以下提示需要我们选择操作

Ok to proceed? (y)
What is your project named? my-app(输入你的项目名)
Would you like to use TypeScript? 选择Yes
Would you like to use ESLint? 选择Yes
Would you like to use Tailwind CSS? 选择Yes
Would you like to use `src/` directory? 这边默认推荐选No,我这边无所谓就选个No
Would you like to use App Router? (recommended) 选择Yes
Would you like to customize the default import alias? 这边默认选择否,我喜欢给项目根目录设置个别名,这样方便等项目目录层级深的时候引入,所以选择Yes
What import alias would you like configured? 就用它的默认推荐@/*,后续可以根据自己喜好去tsconfig.json中修改设置多个

接下来就是等待安装依赖了,如果安装失败或者速度过慢,大家可以自行搜索淘宝镜像cnpm等解决方案。

安装成功之后,我们cd到项目内,然后yarn dev 启动试试这个项目是否能够正常运行。

cd my-app
yarn dev

启动成功之后,打开 http://localhost:3000/,你可以看到这个demo已经成功运行。

启动成功效果

锁定一下Node和Yarn的版本

我在本项目中使用的是v18.16.1的node版本,1.22.19的yarn版本。不知道自己nodeyarn版本的同学,可以在控制台中输入以下命令查看。

node --version
yarn --version

然后在项目的根目录下的package.json添加engines,限制一下nodeyarn版本。都写到这了,顺带在scripts中加个export命令,到时候部署用得上

{
  "name": "my-app",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
     "export": "next export",
    "start": "next start",
    "lint": "next lint" 
  },
  "dependencies": {
    "@types/node": "20.4.0",
    "@types/react": "18.2.14",
    "@types/react-dom": "18.2.6",
    "autoprefixer": "10.4.14",
    "ESLint": "8.44.0",
    "ESLint-config-next": "13.4.9",
    "next": "13.4.9",
    "postcss": "8.4.25",
    "react": "18.2.0",
    "react-dom": "18.2.0",
    "tailwindcss": "3.3.2",
    "typescript": "5.1.6"
  },
  "engines": {
    "node": ">=18.0.0",
    "yarn": ">=1.22.0"
  }
}

Git配置

根目录下添加一个.gitignore文件,在git提交的时候对特定文件进行忽略,暂时先这样,后续可以根据自己的需要去进行修改

node_modules

# next.js
/.next/

# production
/build

# misc
.DS_Store

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# local env files
.env

# vercel
.vercel

# typescript
next-env.d.ts

然后git init初始化git仓库,add改动内容,commit对改动进行描述

git init
git add .
git commit -m "feat: project init"

项目规范

有多人协作项目开发经验的人都知道,如果项目不做规范的话,团队成员全部freestyle,如果大家技术水平不是特别强的话,项目稍微大一点之后无论是扩展还是维护都会变得很困难,所以,项目在架构阶段,就应该尽可能做好这方面的工作。

  • TypeScript:虽然它有点笨重,使用起来会增加额外的心智负担,甚至有些人直接将其用作anyscript,所以受到很多人的诟病。但是不可否认的是,在TypeScript不允许用any之后,对于代码提示、低级错误预防、项目的可维护性贡献还是比较大的。

  • ESLint:可以用来保证代码风格的统一、低级错误的预防。

  • Prettier:格式化文件,对代码风格统一有好处,使用得当可以避免因为后续格式化导致以前的commit被覆盖的问题。

  • Git Hooks (Husky):可以用来简单的校验commit的内容是否合规,commit的时候执行代码格式化,push之前进行ESLint校验甚至打包校验。

  • 单元测试:由于本项目人手不够,而且也不是组件库之类的基建项目,就不用了。如果大家有这方面的需求的话推荐使用Storybook、Jest等方案。

  • 统一编辑器、项目node版本、文件换行格式(LF、CRLF)等:最近看到几次将webstormvscode进行对比,然后各种互喷的,说实话,我觉得挺无语的。不过对于项目团队来说,统一编辑器还是有优点的,遇到点问题也好互相帮助(说到这,想起以前带实习生的时候,我们都用vscode,他一个人用webstorm,结果老是出些奇奇怪怪的问题,比如代码格式化之类的,还得去帮他调工具),还可以在项目内置一些默认配置等。不过这点没啥,不强求,有条件的话还是统一一下比较好点,无论是用webstorm还是vscode

  • Git分支规范和权限管理:保证项目分支的稳定和安全

  • Code Review:有条件的话可以建立代码评审机制,撰写开发规范文档等。

ESLint

我们在创建Next.js项目的时候已经选择安装了ESLint并有了默认配置。

如果我们有额外需求的话,可以去项目根目录下的.eslintrc.json文件中修改配置,修改完之后,如果想测试一下,可以输入yarn lint,如果成功的话,会得到以下提示。

yarn lint

 No ESLint warnings or errors
  Done in 2.06s.

自定义的规则在.eslintrc.json文件的rules中设置,0忽略、1告警、2报错,例如:

"rules": {
  "no-unused-vars": 0, 
}

改完之后最好是提交一下

git add .
git commit -m "build: modify eslint configuration"

Prettier

首先,确保自己的vscode安装了prettier插件,这样的话使用起来更方便。prettier只需要在开发中使用,所以添加到devDependency

yarn add -D prettier

ps:插播一条知识点,devDependency是开发时的依赖,如huskyprettier等,引入的时候yarn add -D 包名dependencies是生产时需要用到的依赖,如reactreact-dom等,引入的时候yarn add 包名

安装完之后,我们在根目录下新建两个文件:

.prettierrc

{
  "$schema": "https://json.schemastore.org/prettierrc.json",
  "trailingComma": "all",
  "tabWidth": 2,
  "semi": true,
  "singleQuote": false,
  "printWidth": 100,
  "bracketSpacing": false,
  "endOfLine": "auto",
  "arrowParens": "always"
}

.prettierignore

.next
dist
node_modules

我这边目前用的是这样的,大家可以随自己的偏好及实际情况去配置,.prettierrc是用来配置格式化规则的,.prettierignore是忽略哪些文件的格式化。

配置完之后,我们在package.json中添加一条script命令,后面可以用到

...
  "scripts: {
    ...
    "prettier": "prettier --write ."
  }

然后提交一次

git add .
git commit -m "build: add prettier"

Husky

安装并创建.husky目录

yarn add -D husky

npx husky install

安装完之后,我们在package.json中添加一条script命令

...
  "scripts: {
    ...
    "prepare": "husky install"
  }

创建hooks

提交之前对代码进行格式化和暂存

npx husky add .husky/pre-commit "yarn prettier"
npx husky add .husky/pre-commit "git add ."

提交之前对代码进行eslint校验,也可以改为npx husky add .husky/pre-push "yarn build"对代码进行打包,因为yarn devyarn build效果有些不太一样,有时候会出现yarn build失败但是yarn dev成功的问题,所以为了保险起见,尤其是配置了CI/CD的情况下可以添加yarn build对代码进行校验。不过要是某些页面使用了SSG的话,且数据量比较大的情况下yarn build时间过长,反而会对提交代码造成困扰,所以这块可以因项目适当进行调整。

npx husky add .husky/pre-push "yarn lint"

完成之后提交一下

git add .
git commit -m "build: add husky"

添加commit校验

添加commit校验工具

yarn add -D @commitlint/config-conventional @commitlint/cli

配置校验规则,以下是个简单示例,大家可以结合自己的实际情况自定义修改

在项目根目录下创建commitlint.config.js文件并填入以下内容

module.exports = {
  extends: ["@commitlint/config-conventional"],
  rules: {
    "subject-case": [0],
    "type-enum": [
      2,
      "always",
      ["feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore"],
    ],
  },
};

配置项说明

 * feat:新增功能
 * fix:bug 修复
 * docs:文档更新
 * style:不影响程序逻辑的代码修改(修改空白字符,格式缩进,补全缺失的分号等,没有改变代码逻辑)
 * refactor:重构代码(既没有新增功能,也没有修复 bug)
 * perf:性能, 体验优化
 * test:新增测试用例或是更新现有测试
 * build:主要目的是修改项目构建系统(例如 glup,webpack,rollup 的配置等)的提交
 * ci:主要目的是修改项目继续集成流程(例如 Travis,Jenkins,GitLab CI,Circle等)的提交
 * chore:不属于以上类型的其他类型,比如构建流程, 依赖管理
 * revert:回滚某个更早之前的提交

添加husky钩子,让提交的时候校验commit内容

npx husky add .husky/commit-msg "npx --no -- commitlint --edit $1"

完成之后提交一下

git add .
git commit -m "build: add commit rule"

项目vscode配置

在项目根目录下创建.vscode/settings.json,以下是简单示例,你可以根据自己的情况进行修改

{
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "editor.formatOnSave": true,
  "editor.codeActionsOnSave": {
    "source.fixAll": true,
    "source.organizeImports": true
  }
}

其他(我们都是用的mac):

还可以安装EditorConfig for VS Code插件,然后在项目根目录下创建.editorconfig文件配置规则,用来统一换行符等。

[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true[*.{js,jsx,ts,tsx,vue}]
indent_style = space
indent_size = 2
quote_type = single

完成之后提交一下

git add .
git commit -m "build: add editor config"

目录结构

- .husky: git hooks
- api: 项目 api 相关文件、请求封装
- app: 项目页面入口
- coponents: 项目公用组件,纯粹一点的组件,无强制性的业务关联逻辑和代码
- features: 项目公用业务模块,可内置接口请求、type 等
- hooks: 项目公用 hooks
- public: 公共静态资源
- styles: 项目公用样式、动画、主题、变量等
- types: 项目公用的一些 type
- utils: 项目公用工具类、格式化、常量等
- web3: 项目公用 web3 相关类和方法

Git 分支规则

- master 分支为最新稳定版本的代码,负责人才有权限
- dev 分支负责人才有合并权限
- 其他开发成员分支命名规则为 dev-feat-xxx,xxx 代表开发者名称拼音小写首字母
- 开发版本分支名称 v1.0-dev
- 发布版本分支名称 v1.0-release
- 每个版本封版后需要打一个 tag

接口请求

在Axios、React Query、SWR几种方案中纠结了挺久,最后方案定为基于fetch去封装请求。

选择fetch主要是有以下考虑:

1、nextjs13对原生fetch进行了包装处理,可以支持缓存、请求重复数据删除等

2、nextjs13推荐新项目使用API路由构建整个API,支持边缘计算、流式传输等

3、如果是客户端请求的话,官方更推荐使用SWR或者React Query

image.png

封装客户端http请求并解析jwtToken对token进行无感刷新

  1. 目录结构概览

image.png

  1. request公用type类型定义
export type IAuthType = "noToken" | "default" | "ai";

export interface IOptions {
  headers?: {[key: string]: string};
  body?: any;
  authType?: IAuthType;
  requestUrl: string;
}

export interface IResponse<T> {
  code: number;
  data: T;
  message: string;
}

export type IQueryParams = {
  [key: string]: any;
};
  1. request中客户端请求client使用到的一些工具类方法
import {IAuthType, IQueryParams, IResponse} from "@/request/type";
import qs from "qs";
// 不要用 import {cloneDeep} from "lodash"; 的方式引入,会影响tree shaking的效果
import cloneDeep from "lodash/cloneDeep";
import {getSession} from "@/utils/storage";
import jwtDecode from "jwt-decode";

const defaultHost = process.env.NEXT_PUBLIC_HOST;
const aiHost = process.env.NEXT_PUBLIC_AI_HOST;
export const hostMap = {
  noToken: defaultHost,
  default: defaultHost,
  ai: aiHost,
};

export const isExpToken = (expTime: number) => {
  // get now time stamp
  const expTimeStamp = parseInt(`${expTime}000`);
  const nowTime = Date.parse(new Date().toString()) + 3 * 60 * 1000;
  return nowTime > expTimeStamp;
};

// format jwt token
export const parseJWT = (
  token: string,
): {exp: number; iat: number; sub: number; userAddr: string} => {
  try {
    return jwtDecode(token);
  } catch (err) {
    return {exp: 0, iat: 0, sub: 0, userAddr: ""};
  }
};

export async function handleResponse<T>(response: globalThis.Response): Promise<IResponse<T>> {
  // 可以把浏览器状态码错误直接抛出错误,也可以配置一个map文件,状态码直接匹配自定义的文本并且弹消息提醒
  // if (!response.ok) throw new Error(response.statusText);
  // 这里直接将浏览器抛出的异常信息处理成和后端抛出的信息格式一致
  if (!response.ok) {
    const resData = await response.json();
    return {
      message: resData.message,
      data: null,
      code: resData.statusCode,
    } as IResponse<T>;
  }
  const contentType = response.headers.get("content-type");
  // 如果是json格式调用json解析
  if (contentType && contentType.includes("application/json")) {
    return response.json();
  }
  return {
    code: response.status,
    data: response.text(),
    message: response.statusText,
  } as IResponse<T>;
}

export const getStringParams = (params: IQueryParams) => {
  // 深克隆一下,以免传来的参数是redux等里面的数据,修改会报错
  const paramsCopy = cloneDeep(params);
  for (let key in paramsCopy) {
    if (paramsCopy[key] === "" || paramsCopy[key] === undefined) {
      paramsCopy[key] = null;
    }
  }
  return qs.stringify(paramsCopy, {skipNulls: true});
};

export const getAuthorization = async (authType: IAuthType) => {
  if (authType === "noToken") {
    return "";
  }
  const tokenKey = `${authType}Token`;
  // 封装的获取localstorage等数据的方法
  const accessToken = getSession("local", tokenKey);
  const tokenInfo = parseJWT(accessToken);
  let authorization = "";
  if (accessToken && !isExpToken(tokenInfo.exp)) {
    authorization = `Bearer ${accessToken}`;
  } else {
    // token 过期了,重新登录请求然后给请求头设置好token
    // 获取token的方法等也可以根据authType的不同进行自定义设置
    // const res = await getToken("账号密码等");
    // setSession("local", tokenKey, res?.data);
    // authorization = `Bearer ${res?.data}`;
  }
  return authorization;
};
  1. 自己简单封装的一些获取和设置localStorage、sessionStorage的方法
export const getSession = (type: "session" | "local", key: string) => {
  if (!key) {
    return;
  }
  const isBrowser: boolean = ((): boolean => typeof window !== "undefined")();
  const value = isBrowser ? window[`${type}Storage`][key] : "";
  // console.log(value);
  return value ? JSON.parse(value) : "";
};
export const setSession = (type: "session" | "local", key: string, value: any) => {
  value = JSON.stringify(value);
  if (!key || !value) {
    return;
  }
  const isBrowser: boolean = ((): boolean => typeof window !== "undefined")();
  if (isBrowser) {
    window[`${type}Storage`].setItem(key, value);
  }
};
export const removeSession = (type: "session" | "local", key: string) => {
  const isBrowser: boolean = ((): boolean => typeof window !== "undefined")();
  if (isBrowser) {
    window[`${type}Storage`].removeItem(key);
  }
};

  1. 核心封装代码
import {IAuthType, IResponse} from "@/request/type";
import {getAuthorization, getStringParams, handleResponse, hostMap} from "@/request/utils";

export const post = async <T>(
  url: string,
  data: any,
  authType: IAuthType = "default",
  revalidate = 20,
): Promise<IResponse<T>> => {
  const token = await getAuthorization(authType);
  const host = hostMap[authType];
  const finallyUrl = `${host}${url}`;
  const response = await fetch(finallyUrl, {
    headers: {
      Authorization: token,
    },
    method: "POST",
    body: data && JSON.stringify(data),
    next: {
      revalidate: revalidate,
    },
  });
  return await handleResponse(response);
};

export const get = async <T>(
  url: string,
  data: any = null,
  authType: IAuthType = "noToken",
  revalidate = 20,
): Promise<IResponse<T>> => {
  const token = await getAuthorization(authType);
  const host = hostMap[authType];
  const formatUrl = data ? `${url}?${getStringParams(data)}` : url;
  const finallyUrl = `${host}${formatUrl}`;
  const response = await fetch(finallyUrl, {
    headers: {
      Authorization: token,
    },
    method: "GET",
    next: {
      revalidate: revalidate,
    },
  });
  return await handleResponse(response);
};

export const remove = async <T>(
  url: string,
  data: any = null,
  authType: IAuthType = "default",
  revalidate = 20,
): Promise<IResponse<T>> => {
  const token = await getAuthorization(authType);
  const host = hostMap[authType];
  const formatUrl = data ? `${url}?${getStringParams(data)}` : url;
  const finallyUrl = `${host}${formatUrl}`;
  const response = await fetch(finallyUrl, {
    headers: {
      Authorization: token,
    },
    method: "DELETE",
    next: {
      revalidate: revalidate,
    },
  });
  return await handleResponse(response);
};

export const uploadFile = async <T>(url: string, file: File): Promise<IResponse<T>> => {
  const token = await getAuthorization("default");
  const formData = new FormData();
  formData.append("file", file);
  const response = await fetch(url, {
    headers: {
      authorization: token,
    },
    method: "POST",
    body: formData,
  });
  return await handleResponse(response);
};

const clientHttp = {
  get,
  remove,
  post,
  uploadFile,
};
export default clientHttp;
  1. 使用示例
import {get} from "@/request/client";
import {IUserParams, IUserResponse} from "@/types/api/user";

export const getUserInfo = (params: IUserParams) => {
  return get<IUserResponse>("/api/v1/user/info", params, "default");
};

封装app模式的api路由对后端server进行数据请求

pages 模式下的 api 是nextjs13版本之前的特性,无论是网上还是官网,例子都比较多了,就不再详述了。

  1. get 由于会在build的时候提前请求,所以在创建get相关的api路由方法的时候需要能够直接请求成功,而且不让用动态传url请求地址的写法否则会报错(DynamicServerError: Dynamic server usage: request.url)
  2. post 方法的话倒是无所谓,可以直接封装成一个
  3. 核心代码
import {NextResponse} from "next/server";
import {formatBody} from "./utils";

const host = process.env.NEXT_PUBLIC_HOST;
const get = async (request: Request) => {
  try {
    const {search} = new URL(request.url);
    const token = request.headers.get("Authorization") as string;
    const contentType = request.headers.get("Content-Type") as string;
    // 可以封装成一个get路由或者每个get都创建不同的api route
    const requestUrl = request.headers.get("requestUrl") as string;
    const res = await fetch(`${host}${requestUrl}${search}`, {
      headers: {
        "Content-Type": contentType,
        Authorization: token,
      },
      method: "GET",
      // cache: "force-cache",
    });
    // return NextResponse.json(res);
    return res;
  } catch (error) {
    console.log("error: ", error);
    return NextResponse.error();
  }
};

const post = async (request: Request) => {
  try {
    const {search} = new URL(request.url);
    const token = request.headers.get("Authorization") as string;
    const contentType = request.headers.get("Content-Type") as string;
    const requestUrl = request.headers.get("requestUrl") as string;
    const data = await formatBody(request.body, contentType);
    const res = await fetch(`${host}${requestUrl}${search}`, {
      headers: {
        "Content-Type": contentType,
        Authorization: token,
      },
      method: "POST",
      // body: JSON.stringify(jsonBody),
      body: data,
      // cache: "force-cache",
    });
    return res;
  } catch (error) {
    console.log("error: ", error);
    return NextResponse.error();
  }
};

const serverHttp = {
  get,
  post,
};

export default serverHttp;

  1. 工具类方法
export const streamToJson = async (stream: ReadableStream<Uint8Array> | null) => {
  if (!stream) {
    return "";
  }
  const reader = stream.getReader();
  let total = "";

  return reader.read().then(function processResult(result): any {
    if (result.done) {
      return JSON.parse(total);
    }

    total += new TextDecoder("utf-8").decode(result.value);
    return reader.read().then(processResult);
  });
};
export const formatBody = async (body: any, contentType: string) => {
  const jsonBody = await streamToJson(body);
  if (contentType.includes("application/x-www-form-urlencoded") && jsonBody instanceof Object) {
    const keys = Object.keys(jsonBody);
    const values = Object.values(jsonBody) as string[];
    const postData = new URLSearchParams();
    for (let i = 0; i < keys.length; i++) {
      postData.append(keys[i], values[i]);
    }
    return postData.toString();
  }

  return JSON.stringify(jsonBody);
};
  1. get 示例(由于get route会在build的时候调用,所以需要确保该api route能访问请求成功)

image.png

import serverHttp from "@/request/server";

export async function GET(request: Request) {
  request.headers.set("requestUrl", "/api/v1/banner/list");
  const res = await serverHttp.get(request);
  return res;
}
  1. post 封装(为了调用的时候比较语意化的话也可以创建多个不同的api route文件)
import serverHttp from "@/request/server";

export async function POST(request: Request) {
  const res = await serverHttp.post(request);
  return res;
}

使用nextjs13的新特性layout

  1. 这是我们平时一般的布局layout

image.png

  1. 当各个页面有不同的layout需求的时候,我们就需要各种变量去控制各个部分的显示和隐藏
<Layout showNav={false} showHeader={false} showFooter={false} showAside={false}>
    <>{page}</>
</Layout>
  1. 或者自行创建多个类型的layout,然后在不同的页面中引入
<HeaderLayout>
    <Header />
    <>{page}</>
</HeaderLayout>
<FullLayout>
    <Header />
    <>{page}</>
    <Footer />
</FullLayout>
  1. nextjs13为我们提供了一个更优雅的方案,可以直接在不同的文件夹下创建一个layout,这个layout定义了该文件夹下的所有layout,这样的话更清晰灵活一些。

使用Layout

  • 我们可以在项目根目录下创建一个Layout,分别创建Header、Aside、Footer等组件 image.png

  • app下面新建如下目录结构 image.png

  • app根目录设置一个layout,用来放全局Providers等

import Providers from "@/components/Providers";

import {Inter} from "next/font/google";

import "./globals.css";
import {Metadata} from "next";
import {Toaster} from "react-hot-toast";

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

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

export default function RootLayout({children}: {children: React.ReactNode}) {
  return (
    <html lang="en" className="min-h-full w-full">
      <body className={`${inter.className} min-h-screen w-full bg-globalBg overflow-y-auto`}>
        <Providers>{children}</Providers>
        <Toaster />
      </body>
    </html>
  );
}

  • (asideLayout) 及目录下的page效果
import Header from "@/components/Layout/Header";
import Footer from "@/components/Layout/Footer";
import Aside from "@/components/Layout/Aside";

export default function AsideLayout({children}: {children: React.ReactNode}) {
  return (
    <>
      <Header />
      <main className="flex">
        <Aside />
        {children}
      </main>
      <Footer />
    </>
  );
}

image.png

  • (fullLayout) 及目录下的page效果
import Header from "@/components/Layout/Header";
import Footer from "@/components/Layout/Footer";

export default function FullLayout({children}: {children: React.ReactNode}) {
  return (
    <>
      <Header />
      <main>{children}</main>
      <Footer />
    </>
  );
}

image.png

  • (headerLayout) 及目录下的page效果
import React from 'react'

const Profile = () => {
  return (
    <div>Profile</div>
  )
}

export default Profile

image.png

  • 注意事项1: 需要自定义的layout话,需要建一个由小括号包裹的文件夹(layout name),然后在下面新建一个layout文件
  • 注意事项2: 下级layout会继承上层layout,所以如果根部layout已经定义了html等元素,下级layout不能重复定义html等元素,否则水合的时候会出错。

引入本地的js脚本

  1. 用useEffect引入
useEffect(() => {
    const script1 = document.createElement("script");
    script1.src = "/assets/aliUploadJdk/aliyun-oss-sdk-6.17.1.min.js";
    const script2 = document.createElement("script");
    script2.src = "/assets/aliUploadJdk/aliyun-upload-sdk-1.5.5.min.js";
    script1.async = true;
    script2.async = true;
    document.body.appendChild(script1);
    document.body.appendChild(script2);
    script2.onload = () => {
      // to do something
    }
  }, []);
  1. 用nextjs中的Script标签引入
import Script from "next/script";
export default function ImportScript() {
  // 这些回调目前都暂时不支持服务端组件
  const handleLoad = () => {
    // 加载完成的回调,onLoad不能与beforeInteractive– 一起使用
  };
  const handleReady = () => {
    // 加载执行的回调
  };
  const handleError = () => {
    // 加载错误的回调
  };
  return (
    <>
      <Script
        strategy="lazyOnload"
        onLoad={handleLoad}
        onReady={handleReady}
        onError={handleError}
        src="https://example.com/script.js"
      />
    </>
  );
}

  1. 注意:引入的时候不要用相对路径,否则再一些动态路由页面中刷新页面的时候,该js路径会被匹配为动态路由页面从而导致程序异常

Tailwind 双主题配置与切换

我们这个项目设计的时候就是基本按照tailwind的一些内置样式作为基本规范的,所以我们设置双主题时,也得实现基于tailwind自己的主题色去设计。

  1. 引入next-themes
yarn add next-themes
  1. 由于nextjs13中根layout默认为server组件,不能设置为client组件,所以我们将theme provider等封装成client组件,后续我们将会在这里把一些全局的provider进行挂载(感谢@icodeview提的issues) components/Providers
"use client";
import {ReactNode} from "react";
import {ThemeProvider} from "next-themes";

const Providers = ({children}: {children: ReactNode}) => {
  return (
    <ThemeProvider enableSystem={true} attribute="class">
      {children}
    </ThemeProvider>
  );
};
export default Providers;
  1. 根layout下引入
import {Inter} from "next/font/google";
import {Metadata} from "next";
import Providers from "@/components/Providers";
import "./globals.css";

const inter = Inter({subsets: ["latin"]});
export const metadata: Metadata = {
  icons: "/favicon.ico",
  title: "nextjs13-template",
  description: "nextjs13-template: 基于nextjs13+ 和 tailwindcss 创建的一个项目脚手架",
};

export default function RootLayout({children}: {children: React.ReactNode}) {
  return (
    <html lang="en" className="min-h-full w-full">
      <body className={`${inter.className} min-h-screen w-full bg-globalBg overflow-y-auto`}>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}
  1. 在需要切换主题的地方引入setTheme对主题进行修改
"use client";
import React from "react";
import {useTheme} from "next-themes";
const Home = () => {
  const {systemTheme, theme, setTheme} = useTheme();
  const currentTheme = theme === "system" ? systemTheme : theme;
  return (
    <>
      {currentTheme === "dark" ? (
        <div
          className="cursor-pointer text-yellow-400"
          onClick={() => {
            setTheme("light");
          }}
        >
          set light
        </div>
      ) : (
        <div
          className="cursor-pointer text-slate-700"
          onClick={() => {
            setTheme("dark");
          }}
        >
          set dark
        </div>
      )}
    </>
  );
};
export default Home;
  1. 在globals.css中定义我们的全局变量(记得在app根目录下的layout中引入, 注意:以下定义的var变量需要html标签上有light或者dark类名才会生效)
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  .light {
    --global-bg: theme("colors.white");
    --global-bg-invert: #111827;
    --badges-purple-border: rgba(126, 34, 206, 0.1);
  }
  .dark {
    --global-bg: #111827;
    --global-bg-invert: theme("colors.white");
    --badges-purple-border: rgba(192, 132, 250, 0.3);
  }
}
  1. 在tailwind.config.js中使用我们定义的主题变量
/** @type {import('tailwindcss').Config} */
function withOpacity(variableName) {
  return `var(${variableName})`;
}

module.exports = {
  mode: "jit",
  content: [
    "./pages/**/*.{js,ts,jsx,tsx,mdx}",
    "./components/**/*.{js,ts,jsx,tsx,mdx}",
    "./app/**/*.{js,ts,jsx,tsx,mdx}",
  ],
  theme: {
    extend: {
      backgroundColor: {
        globalBg: withOpacity("--global-bg"),
        globalBgInvert: withOpacity("--global-bg-invert"),
      },
      colors: {
        globalBgInvert: withOpacity("--global-bg-invert"),
      },
      borderColor: {
        badgesPurpleBorder: withOpacity("--badges-purple-border"),
      },
    },
  },
};

  1. 在页面中使用我们的主题变量
<div className="bg-globalBg">globalBg</div>
<div className="bg-globalBgInvert text-globalBgInvert">globalBgInvert</div>
<div className="border-badgesPurpleBorder border">badgesPurpleBorder</div>
  1. 效果展示

image.png

image.png

添加reduxjs/toolkit进行全局状态管理

  1. 执行 yarn add @reduxjs/toolkityarn add react-redux 安装依赖
  2. 在根目录下创建store文件夹,并新建hooksstore两个文件,再创建defaultValuemodules文件夹并创建common文件用来测试使用,目录结构如下:

image.png

  1. store.ts
import {configureStore} from "@reduxjs/toolkit";
import CommonSlice from "./modules/common";

export const store = configureStore({
  reducer: {
    common: CommonSlice,
  },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
  1. hooks.ts
import {TypedUseSelectorHook, useDispatch, useSelector} from "react-redux";
import type {RootState, AppDispatch} from "./store";

export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
  1. modules/common.ts
import {createSlice} from "@reduxjs/toolkit";
import {IGlobalData} from "@/types/store/common";
import {defaultGlobalData} from "../defaultValue/common";
interface IinitialState {
  globalData: IGlobalData;
}

const initialState: IinitialState = {
  globalData: defaultGlobalData,
};
const CommonSlice = createSlice({
  name: "common",
  initialState,
  reducers: {
    setGlobalData: (state, {payload}: {payload: Partial<IGlobalData>}) => {
      state.globalData = {...state.globalData, ...payload};
    },
    resetAll: () => initialState,
  },
});
export const {setGlobalData, resetAll} = CommonSlice.actions;
export default CommonSlice.reducer;
  1. defaultValue/common.ts
import {IGlobalData} from "@/types/store/common";
export const defaultGlobalData: IGlobalData = {
  userName: "",
  token: "",
  role: "user",
};
  1. types/store/common.ts
export interface IGlobalData {
  userName: string;
  token: string;
  role: "user" | "admin";
}
  1. 在components/Providers中全局注册
"use client";
import {ReactNode} from "react";
// store
import {Provider} from "react-redux";
import {store} from "@/store/store";
import {ThemeProvider} from "next-themes";

const Providers = ({children}: {children: ReactNode}) => {
  return (
    <Provider store={store}>
      <ThemeProvider enableSystem={true} attribute="class">
        {children}
      </ThemeProvider>
    </Provider>
  );
};
export default Providers;
  1. 在页面中使用
"use client";
import {useAppSelector, useAppDispatch} from "@/store/hooks";
import {setGlobalData} from "@/store/modules/common";
const Home = () => {
const dispatch = useAppDispatch();
  const globalData = useAppSelector((state) => state.common.globalData);
  return (
    <>
      <div
        onClick={() => {
          dispatch(setGlobalData({role: "admin"}));
        }}
      >
        click change role: {globalData.role}
      </div>
    </>
  );
};
export default Home;

路由跳转进度条

  1. 引入依赖 yarn add next-nprogress-bar
  2. 在components/Providers中全局注册
"use client";
import {ReactNode} from "react";
// store
import {Provider} from "react-redux";
import {store} from "@/store/store";
import {ThemeProvider} from "next-themes";
// progress
import {AppProgressBar as ProgressBar} from "next-nprogress-bar";

const Providers = ({children}: {children: ReactNode}) => {
  return (
    <Provider store={store}>
      <ThemeProvider enableSystem={true} attribute="class">
        {children}
        <ProgressBar height="2px" color="#4F46E5" options={{showSpinner: false}} />
      </ThemeProvider>
    </Provider>
  );
};
export default Providers;
  1. 页面中使用
"use client";
import {useRouter} from "next-nprogress-bar";
const Home = () => {
  const router = useRouter();
  return (
    <>
      <div
        onClick={() => {
          router.push("/userCenter");
        }}
      >
        click to user center
      </div>
    </>
  );
};
export default Home;

nextjs13项目国际化

更新中,有空的时候更新...

基于Tailwind和一些基础逻辑库快速封装属于自己项目的基础组件库

推荐的一些tailwind ui库

  1. chakra ui
  2. next ui

更新中,有空的时候更新...

项目地址

nextjs13-tailwind-template

结尾

你们的支持就是我更新的动力,如果本文对你有所帮助,请一键三连,后续还有更多关于Nextjs的精彩内容。

未完待续。。。