前言
距离上次更新已经过去好久了,之前我在 StarBlog 博客2023年底更新一览的文章里说要使用 Next.js 来重构博客前端,最近也确实用 next.js 做了两个小项目,一个是单点认证项目,另一个是网站的新主页,都还处于开发中,本文记录一下 next.js 使用过程遇到的一些问题和感受。
对了,还有标题里提到的 tailwind
,我去年开发 AIHub 的时候就用上了,因为它和 next.js 这俩组合经常一起出现,本文也一起写了。
PS:本文的篇幅较长,所以拆分了网络请求封装的部分作为独立的文章先发布了;
Next.js 的学习和开发才刚刚开始,后续还有很多需要研究和记录的,本文也许会成为一个新系列?
关于 Next.js
以下是官方的介绍:
Next.js is a React framework for building full-stack web applications. You use React Components to build user interfaces, and Next.js for additional features and optimizations.
Under the hood, Next.js also abstracts and automatically configures tooling needed for React, like bundling, compiling, and more. This allows you to focus on building your application instead of spending time with configuration.
Whether you're an individual developer or part of a larger team, Next.js can help you build interactive, dynamic, and fast React applications.
以下是我的看法:
Next.js 是一套 React 体系的 SSR (服务端渲染)方案,现在很多前端网站实际上是 SPA (单页应用),就只有一个 index.html ,然后附带一个很大的 js 来实现页面渲染和交互,这种小规模的网站还好,网站越大速度就越慢,所以说技术这个车轮又滚回去了,当初被「前后端分离」那帮人嫌弃的后端渲染又回来了,React 有 next.js ,隔壁 vue 有 nuxt.js ,做的事情就是用 node.js 这个后端来渲染页面。
不过 next.js 也不完全是这么简单粗暴,除了后端渲染,它还能像 hexo / hugo 这类静态网站生成器一样生成网页,区别在于 hugo 是根据 md 生成网页,而 next.js 是把 jsx 渲染HTML网页,这样就可以无需依赖 node.js 后端,当成普通的静态网页随意部署。
当然交互也是没问题的,next.js 的组件分成两种,前面说的后端渲染或者生成静态网页的是 server 组件,这种是实现点击按钮就数字加一这类 react 经典操作的;另一种是 client 客户端组件,就跟普通的 react 应用一样了,可以使用 hooks 来操作 DOM。
对我来说,next.js 更大的意义是一个好用的 react 脚手架,React 的生态总让我感觉有点碎片化,原本的 CRA(create react app)实在是有些简陋,很多功能都没有,如果从零搭建一个网站的话,用 CRA 要折腾的东西比 vue-cli 多一些。
next.js 就完美解决了这个问题,自带路由啥的乱七八糟的东西,开箱即用,而且目录结构给限制得明明白白,直接按它的规范写就完事了,再也不用纠结什么 ComponectA/index.tsx
和 Components/A.tsx
之类的东西了,拒绝精神内耗!😃
关于 tailwind
官方的介绍
A utility-first CSS framework packed with classes like
flex
,pt-4
,text-center
androtate-90
that can be composed to build any design, directly in your markup.
Tailwind CSS 的工作原理是扫描所有 HTML 文件、JavaScript 组件和任何其他模板的类名,生成相应的样式,然后将它们写入静态 CSS 文件。
它快速、灵活、可靠,且运行时间为零。
对我来说是 Bootstrap 不错的替代品,之前在 bootstrap 里用得很熟悉的 grid 布局、各种 margin
和 padding
都可以用得上,而且生态很丰富,有一堆设计方案可以借鉴(copy)😃
组件库
tailwind 只是代替了 CSS ,虽然也可以做出好看的组件,但对于我这种小白前端来说是不太可能的,而且每个组件都自己做速度也太慢了,所以肯定是要使用组件库的。
这几个项目中我使用了以下组件库:
- Ant Design - 著名国产 React 组件库,Github 上的 star 数量不输下面的 MUI
- Material UI - 著名 React 组件库
- NextUI - 基于 tailwind ,专为 Next.js 设计的
- Daisy - 基于 tailwind 的普通组件库
图标库
本来想直接上 FontAwesome 但发现有点折腾,前期以自行引入各类图标为主,同时做了一些封装;后期开始使用 React Icons ,打开了新世界的大门~
使用 SVG 自行封装
很多时候图标库里的图标不能完全满足开发所需,得自行设计或者搜集图标(如iconfont等网站),这时候直接使用 SVG ,为了统一管理,做了一下封装。
在 src\components\icons.tsx
文件中
tsx
export const Icons = {
edit: (props: any) => <svg>...</svg>,
delete: (props: any) => <svg>...</svg>,
}
使用的时候
tsx
import {Icons} from "@/components/icons";
<Icons.edit/>
这样既简化了代码,又便于统一管理图标,后续如果更好图标库的话也方便。
使用图标库
推荐使用 react-icons
,里面包含一堆常见的图标库,Ant Design Icons 、Font Awesome 之类的一应俱全。
官网地址: https://react-icons.github.io/react-icons/
Include popular icons in your React projects easily with react-icons, which utilizes ES6 imports that allows you to include only the icons that your project is using.
直接安装即可 yarn add react-icons
在网页上选择想要的图标直接 copy 引入,这里以支付宝图标为例
tsx
import { AiFillAlipayCircle } from "react-icons/ai";
<AiFillAlipayCircle />
简单粗暴
参考资料: https://daily-dev-tips.com/posts/how-to-use-react-icons-in-nextjs/
上手!
虽然 next.js 是开箱即用的框架,不过对我这样的前端小白来说,刚开始上手还是遇到了不少坑的。
这次我做了两个项目,两个都使用了 tailwind
- 单点认证 - 搭配 antd / mui 组件库
- 个人网站主页 - 搭配 NextUI 组件库(辅以 daisyUI 等基于 tailwind 的组件库)
第一个单点认证我由于之前的路径依赖,再加上有一些表单,还是使用了更适合中国宝宝体质的 antd ,然后 mui 作为补充。
第二个项目个人网站就随意发挥了,这时候我就用上之前发现的基于 Next.js + tailwind buff叠满了的 NextUI,这个组件库真的好看,深色模式下更精美,甚至直接把它的项目模板拿来用了,结果发现还挺好用的,只不过有点小坑,后面填上😂
单点认证
本来想用 IdentityServer4 来做,我边学 oauth2.0 边看 IdentityServer4 的代码和文档,发现 ids4 有点重,于是萌生了自己造轮子的想法,现在基本搞好了,已经部署使用,后端是 C# 开发的。
个人网站
虽然已经有博客了,但我还是想做个网站,作为一个总的导航,可以展示一些项目啥的,博客就专门写文章。
其实我本来是使用 vuepress 来搭建的,但后面发现样式限制太多,索性还是自己写了。
目前来说还是比较简陋的,只做了几个简单的页面。
工具箱这块结合最近的需求,把我设计的几套装机方案放在网站上
还有之前用 python 实现的一个编解码,也先加上了。
该项目还处于积极开发中,后面会继续加东西进去。
关于 pnpm 和 yarn
这是我第一次用 pnpm ,感觉很不错嘛,黑科技,占用空间小
但等到后面 next.js 要 build 的时候就开始出各种问题了,可能是 Windows 文件系统对符号链接的支持比较有限?总之各种乱七八糟的问题都来了,什么权限异常巴拉巴拉的,最终换回来 yarn 就一切正常了。
PS:后面不会再碰 pnpm 了...... 😭
NextUI 的导航菜单点击后不会自动关闭
与 Bootstrap 只要写一次导航栏即可适配多种设备不同,基于 tailwind 的组件库好像都是这个思路,大屏写一个菜单,小屏写另一个菜单,导航菜单是 NextUI 的小屏导航菜单。
下图的顶部叫导航栏,对应 NextUINavbar
组件
下图这种就是导航菜单了
这个问题的表现是在小屏状态下点击某个菜单连接,页面可以跳转,但是这个菜单不会自动关闭。
这个算是 NextUI 的文档和模板的小坑了,不过对于熟练使用 Stack Overflow 和 Github issues 的人来说是小菜一碟了,网友给的解决方案也很容易。
在 NextUINavbar
组件设置 isMenuOpen
和 onMenuOpenChange
属性,并且菜单点击后执行 setIsMenuOpen()
方法即可。
tsx
const [isMenuOpen, setIsMenuOpen] = React.useReducer((current) => !current, false)
<NextUINavbar isMenuOpen={isMenuOpen} onMenuOpenChange={setIsMenuOpen}>
// ...
<NavbarMenu>
<NavbarMenuItem key={`${item}-${index}`}>
<Link
href={item.href}
onPress={() => setIsMenuOpen()} >
{item.label}
</Link>
</NavbarMenuItem>
</NavbarMenu>
</NextUINavbar>
详情见: https://stackoverflow.com/questions/76859164/next-ui-navbar-menu-wont-close-after-selecting-an-item
使用 localStorage 的问题
把我之前封装好的 Auth
工具类复制过来 next.js 项目,结果报错
localStorage is not defined
https://developer.school/snippets/react/localstorage-is-not-defined-nextjs
原因是 next.js 先进行服务端渲染,得等网页加载到浏览器才能使用浏览器的 API
改造一下
原本 storage
是一个静态字段
typescript
export default abstract class Auth {
private static storage = localStorage
}
改成属性
typescript
/**
* 认证授权工具类
*/
export default abstract class Auth {
static get storage(): Storage | null {
if (typeof window !== 'undefined') {
return window.localStorage
}
return null
}
/**
* 检查是否已登录
* @return boolean
*/
public static isLogin() {
let token = this.storage?.getItem('token')
let userName = this.storage?.getItem('user')
if (!token || token.length === 0) return false
if (!userName || userName.length === 0) return false
return !this.isExpired();
}
}
https://www.typescriptlang.org/docs/handbook/2/classes.html#getters--setters
App Router
Next.js 有两套路由模式,一个是 App Router ,另一个是 Pages Router
我用的是 App Router ,Next.js 会根据页面层级自动生成路由,感觉很方便直观,如下面这张官网的图。
一个文件夹下面可以是 page.tsx
作为页面,也可以是 router.ts
作为一个接口来处理请求,虽然听起来很奇怪,但一想到 Next.js 是一个后端框架,也就说得通了~ 😃
URL 参数
可以很方便地在页面路由里添加参数,这对于写后端熟悉 RESTFul 的同学来说十分亲切。
比如要实现博客文章列表和文章详情两个页面,可以这样设计路由:
/article/
- 文章列表/article/12345
- 文章详情,12345
为文章的 id
文章列表根据上文,只需要创建 article/page.tsx
文件即可;
文章详情则是在 article
下新建 [id]
文件夹,然后把页面放在 article/[id]/page.tsx
里。
我第一次看到这文档就觉得很妙,就这?这么容易?参数名称用中括号括起来作为目录名称。
然后在文章详情页面里面就这样获取文章id
tsx
export default function Page({ params }: { params: { id: string } }) {
}
实在是... 极简
使用 router handler 写接口
以单点认证这个项目为例
我在 login/route.ts
里写了一个接口,用于处理登录的信息,以下代码有删减,主要是展示一下 Next.js 写接口是什么样子。
ts
import {redirect} from 'next/navigation'
import SessionService from "@/services/session";
export const dynamic = 'force-dynamic' // defaults to auto
export async function GET(request: Request) {
const {searchParams} = new URL(request.url);
const sessionId = searchParams.get('session_id')
if (!sessionId) {
return Response.json({
'statusCode': 400,
'message': `session_id cant be null`
})
}
const resp = await SessionService.getSession(sessionId, securityKey)
const session = resp.data
redirect(
`/login/by-sms` +
`?sessionId=${session.sessionId}`
)
}
在这个文件里面可以写不同的方法,对应不同的 HTTP 请求类型,我这里使用的是 GET
拿到 id 之后使用我封装的 SessionService
拿到数据,这个可以是从其他接口获取的,也可以使用 Prisma
之类的 ORM 来直接访问数据库。
最后是重定向到另一个页面,就跟普通的后端没区别,不过如果用了 router handler 就不能导出静态网站了,必须用 nodejs 来部署。
Router Handler 从 URL 里提取 query params
新建一个 JS 内置的 URL 对象,其中的 searchParams
就是 URL 里的请求参数。
typescript
export async function GET(request: Request) {
const {searchParams} = new URL(request.url);
const sessionId = searchParams.get('session_id')
const securityKey = searchParams.get('security_key')
}
Route Segment Config
route.ts
文件里的配置,详情见文档: https://nextjs.org/docs/app/api-reference/file-conventions/route-segment-config
ts
export const dynamic = 'auto'
export const dynamicParams = true
export const revalidate = false
export const fetchCache = 'auto'
export const runtime = 'nodejs'
export const preferredRegion = 'auto'
检测IE浏览器
tailwind 好像不支持IE浏览器,反正我现在做网页就没考虑过IE的适配。
后端渲染版本
tsx
export default function IndexPage({ isIE }) {
return <div>Hello {isIE ? "IE" : "Modern Browsers"}!</div>;
}
export async function getServerSideProps({ req }) {
return {
props: {
isIE: /MSIE|Trident/.test(req.headers["user-agent"])
}
};
}
前端版
tsx
import { useEffect, useState } from "react";
export default function IndexPage() {
const [isIE, setIsIE] = useState(false);
useEffect(() => {
setIsIE(/MSIE|Trident/.test(window.navigator.userAgent));
return () => {};
}, []);
return <div>Hello {isIE ? "IE" : "Modern Browsers"}!</div>;
}
不过实际用起来好像有点小坑,不管了,人和代码一个能跑就行。
参考资料: https://stackoverflow.com/questions/68266035/how-to-detect-internet-explorer-on-next-js-ssr
杂项
'Component' cannot be used as a JSX component.
报错差不多是下面这样
'Component' cannot be used as a JSX component. Its element type 'ReactElement | Component<{}, any, any>' is not a valid JSX element
这个困扰了我好久,经过搜索才知道是最新版 react 和 TypeScript 的兼容问题
需要在 .tsconfig
文件里加上配置
ts
"paths": {
"react": [ "./node_modules/@types/react" ]
}
其他
- JSON 转换 TypeScript - https://transform.tools/json-to-typescript
Error: getaddrinfo EAI_AGAIN
- 莫名其妙的,重启容器就好了 - https://stackoverflow.com/questions/40182121/whats-the-cause-of-the-error-getaddrinfo-eai-again
构建&部署
next.js 有两种部署方式,单点认证项目我使用了 standalone 模式,需要搭配nodejs后端运行;个人网站使用 export 导出静态网站。
build
使用 pnpm 的时候执行 next build 老是报错
Failed to copy traced files for path-to-project\.next\server\pages\_app.js
[Error: EPERM: operation not permitted]
https://github.com/vercel/next.js/issues/50803
需要使用管理员权限执行
bash
scoop install gsudo
sudo next build
搞定
PS:
- 虽然能解决但后面好像又出了其他问题,我还是推荐使用 yarn ,不折腾。
- 根据经验,next build 的时候最好把 dev 停掉
静态导出部署
把 output
模式设置为 export
,然后执行 next build
即可。
这里注意必须配置 trailingSlash: true
参数
没有配置的情况下,访问 /hello/world/page.html
这个页面的时候,对应的地址是 domain.com/hello/world
,直接打开这个地址是不会自动跳转的
设置了这个参数之后会自动在地址最后添加一个斜杠,上面的地址就变成了 domain.com/hello/world/
,可以自动索引到 page.html
,这样就不用修改 nginx 的配置。
ts
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
// Optional: Change links `/me` -> `/me/` and emit `/me.html` -> `/me/index.html`
// 根据 nextjs 的文档,设置为 true 就不需要修改 nginx 配置了
// https://nextjs.org/docs/app/building-your-application/deploying/static-exports#deploying
trailingSlash: true,
// Optional: Prevent automatic `/me` -> `/me/`, instead preserve `href`
// skipTrailingSlashRedirect: true,
// Optional: Change the output directory `out` -> `dist`
// distDir: 'dist',
}
module.exports = nextConfig
standalone 部署
这种方式是将 Next.js 作为一个后端部署,需要依赖 node.js 环境运行。
node.js 模式
直接在服务器上安装 node.js 环境,然后把代码上传上去一把梭直接 next build
,然后 next start
,就散部署完成了。接着使用 nginx 之类做一下反向代理就行。
docker 模式
上面这种模式虽然简单粗暴,但是需要在服务器安装特定版本的 node.js ,如果服务器上有多个项目,每个项目使用的 node.js 版本都不同,管理环境就很麻烦,因此我使用的是 docker 镜像来部署。
首先官方提供了一个例子: https://github.com/vercel/next.js/tree/canary/examples/with-docker
然后附上我的配置方案,在官方的基础上,删除了 dockerfile 里的 CMD
等命令,把运行放到了 docker-compose.yaml
里,我比较喜欢使用 compose 来管理容器。
以下是修改后的 Dockerfile
dockerfile
FROM node:18-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Install dependencies based on the preferred package manager
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
RUN \
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \
else echo "Lockfile not found." && exit 1; \
fi
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN yarn build
# If using npm comment out above and use below instead
# RUN npm run build
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
# 把以下内容注释掉了
#EXPOSE 3000
#
#ENV PORT 3000
## set hostname to localhost
#ENV HOSTNAME "0.0.0.0"
#
## server.js is created by next build from the standalone output
## https://nextjs.org/docs/pages/api-reference/next-config-js/output
#CMD ["node", "server.js"]
可以看到我把最后面的几行注释掉了
以下是 docker-compose.yaml
文件,依然是搭配 swag 的反向代理+HTTPS 来部署。
yaml
version: "3.6"
services:
web:
build: .
restart: always
container_name: ids-lite-ui
environment:
- PORT=3000
- HOSTNAME=0.0.0.0
command: node server.js
networks:
- swag
networks:
swag:
name: swag
external: true
而且我还有一个 .env.local
文件
内容是
ini
NEXT_PUBLIC_BASE_URL=https://sso-api.dealiaxy.com
这个是作为环境变量传给 Next.js 的,可以在代码里使用 process.env.NEXT_PUBLIC_BASE_URL
获取到,注意必须有 NEXT_PUBLIC_
前缀。
同时这个环境变量不能写在 docker-compose.yaml
文件里面,因为 process.env.NEXT_PUBLIC_BASE_URL
不是真正的变量,而是类似C语言宏的东西,在镜像的构建阶段,也就是 Next.js 的 build 阶段就会把它替换为真实值,而 compose
里的变量是镜像运行之后再传入的。
小结
就先写到这吧,篇幅已经太长了,Typora 上统计了一下本文都快接近2万字了,这么长的文章连我自己都没有耐性看完。
并且因为是边开发边记录,本文撰写的时间跨度也较长,可能会出现一些语句不通顺,逻辑不清的问题,在发布之前我粗略扫了几遍,做了一些修改,不过实在是没时间和精力去仔细推敲了,就这样吧~😂
总结: Next.js 对于我来说,是一套非常棒的 React 前端解决方案,尽管开发速度比不上之前介绍过的 Blazor ,但架不住它庞大的生态以及流畅的开发体验~ 其实熟悉了之后开发速度也会继续提升的,推荐使用 React 的同学们有兴趣可以试试看😃
PS: 我的 Jetbrains 全家桶授权到期了,最近一周开始使用 vscode ,下一篇文章大概率会介绍从 Jetbrains 转到 vscode 的不同体验。