由Umi升级到Next方案
Umi Max 比较适合做中后台管理系统,尤其是结合 Ant Design Pro,用起来确实很方便,开发效率也挺高。但我们后来发现一个问题,Umi4 开始貌似已经不支持服务端渲染(SSR)了,这在做后台的时候没啥影响,但如果前台页面还用它,就很难做好 SEO 了,尤其是需要被搜索引擎收录的内容页面。
为了改善这个问题,我们决定把前端架构做一个分离:后台部分继续用 Umi Max,走纯 CSR 的方式,这样原有代码改动也不大;而用户端我们选用了 Next.js 来开发,利用它自带的 SSR 能力,提升首屏加载速度和 SEO 效果。
在这个重构过程中,确实遇到了很多坑,一方面是因为我们之前没接触过 Next.js,算是第一次上手就直接重构生产项目,很多东西一开始都是摸着文档走的。好在 Next.js 的文档还是挺不错的,绝大多数问题都能在里面找到答案:nextjs.org
请求库更换
在 Ant Design Pro(基于 Umi Max)中,默认封装的请求库是 umi-request
,它对请求和响应做了一些统一处理,用起来也比较顺手。不过在迁移到 Next.js 后,我们决定不再继续用 umi-request
,而是换成更常见、更灵活的 axios
来处理前后端通信。
这里有一个技巧,就是如果访问某个页面需要调用很多接口,这些接口又都没有登录,那么可能会多次弹出未登录的消息,想要只显示一个消息,可以设置一个上次弹出时间,保证再一定时间内只弹出一个消息。
import axios, { AxiosRequestConfig } from "axios";
import { message } from "antd";
// 创建 Axios 示例
const myAxios = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000/api",
timeout: 100000,
withCredentials: true,
});
// 创建请求拦截器
myAxios.interceptors.request.use(
function (config) {
return config;
},
function (error) {
// 处理请求错误
return Promise.reject(error);
},
);
let lastErrorTime = 0; // 上次弹出提示的时间
// 创建响应拦截器
myAxios.interceptors.response.use(
function (response) {
const data = response.data;
const currentTime = Date.now();
if (data.code === 11002) {
if (currentTime - lastErrorTime > 2000) {
message.error("未登录,请先登录");
lastErrorTime = currentTime;
}
if (!localStorage.getItem("redirectUrl")) {
// 如果 localStorage 中没有保存过 URL,则直接存储当前的 URL
localStorage.setItem("redirectUrl", window.location.href); // 直接存储完整的当前 URL
}
setTimeout(() => {
if (!localStorage.getItem("redirectUrl")) {
localStorage.setItem("redirectUrl", window.location.href);
}
window.location.href = "/user/login";
}, 1000); // 1秒后跳转
return Promise.reject(data.message ?? "未登录");
} else if (data.code !== 0) {
return Promise.reject(data.message ?? "请求失败");
}
return data;
},
// 非 2xx 响应触发
function (error) {
// 处理响应错误
return Promise.reject(error);
},
);
export default async function request<T = any>(
url: string,
config?: AxiosRequestConfig,
): Promise<T> {
return myAxios.request<any, T>({
url,
...config,
});
}
这时候如果还需要在请求头里加上token,需要在请求的时候设置:
myAxios.interceptors.request.use(
function (config) {
const token=xxx
config.headers.token = token;
return config;
},
function (error) {
// 处理请求错误
return Promise.reject(error);
},
);
如果这里的token从localStorage中去取出,那么这样会带来一个问题,Error: localStorage is not defined
,这个报错是因为你在 服务端运行时环境中调用了 localStorage
,而 localStorage
是 浏览器(客户端)环境特有的 API,Node.js(服务端)中并不存在。
为什么会这样?
这个 Axios 实例是在模块加载时就创建并设置好了拦截器 —— 所以即使你最终只在客户端调用 request()
方法,这段代码也有可能在 SSR(服务端渲染)阶段运行,从而触发:
正确做法:
可以把拦截器中的 localStorage
读取逻辑包裹在一个运行时检查中,只在客户端环境执行:
myAxios.interceptors.request.use(
function (config) {
if (typeof window !== "undefined") {
const token = xxx
if (token !== "") {
config.headers.token = token;
}
}
return config;
},
function (error) {
return Promise.reject(error);
},
);
或者,把拦截器的注册逻辑放在 if (typeof window !== "undefined")
中,只在客户端注册拦截器,也就是我最终选择的方案
if (typeof window !== "undefined") {
// 创建请求拦截器
myAxios.interceptors.request.use(
function (config) {
// 请求执行前执行
const token = xxx
config.headers.token = token;
return config;
},
function (error) {
// 处理请求错误
return Promise.reject(error);
},
);
}
状态管理更换
在 Umi Max 中,我们习惯使用 model
来做状态管理,不管是全局的还是页面级的,配合起来用非常方便,逻辑也比较集中。但到了 Next.js,默认是没有内建状态管理方案的,一开始我们也考虑过用 React 生态中比较经典的 Redux,不过用了一下之后发现实在太繁琐了,特别是我们这种状态不多的项目,搞一堆 action、reducer、dispatch,实在太重了,最后我们就放弃了这个方案。
后来我们选择了 zustand
,它的 API 非常简单,语法也很现代化,基本就是一个函数搞定创建、读取和修改状态,写起来清爽很多。对于像侧边栏这种比较简单的全局 UI 状态,zustand
真的非常合适。
比如下面这个例子就是我们用 zustand
管理侧边栏伸缩状态的方式:
import { create } from "zustand/react";
const useSidebarStore = create((set) => {
return {
collapsed: false,
toggleCollapsed: () =>
set((state) => {
return { collapsed: !state.collapsed };
}),
};
});
export default useSidebarStore;
然后在组件中直接使用::
const collapsed = useSidebarStore((state:any) => state.collapsed);
const toggleCollapsed = useSidebarStore((state: any) => state.toggleCollapsed);
整套写下来非常简洁,不需要额外的 Provider 包裹,甚至可以和服务端渲染一起用(官方也支持),目前我们项目中已经逐步把 UI 相关的状态都切到 zustand
上了。
图片组件更换
在 Umi Max 中,我们主要用的是 Ant Design 提供的 Image
组件,功能也挺全的,比如加载占位图、预览大图这些在中后台场景里都挺实用的。
不过迁移到 Next.js 后,我们发现其实可以直接使用它自带的 next/image
组件。这个组件是专门为服务端渲染和性能优化做过处理的,像图片懒加载、自动压缩、响应式处理、格式优化(比如 WebP)这些都是开箱即用的,对首屏加载和整体性能提升挺有帮助。
当然,next/image
也有些限制,比如必须要配置域名白名单(不然加载外部图会报错),默认是用 layout 机制做图片自适应的,和普通 HTML 的 <img>
有点不一样,一开始用的时候可能需要适应一下。
但整体来说,既然我们前台是做 SSR 的,next/image
就是更合适的选择,尤其是在对性能和 SEO 有要求的场景里。
路由API更换
在 Umi 项目里,路由操作非常统一,基本上直接用 history
就能搞定,无论跳转页面还是获取当前路径都挺方便的。例如我们以前经常这样写:
import { history } from '@umijs/max';
history.push('/');
不过到了 Next.js,路由的用法就有些不同了,尤其是在区分服务端和客户端这块。Next.js 默认是支持服务端渲染的,所以如果要在客户端使用路由相关的 hooks,比如跳转或者获取 query 参数,组件必须是客户端组件,也就是文件或模块顶部要加上 'use client'
声明。
获取查询参数
在客户端组件中,可以使用 useSearchParams
来获取 URL 上的查询参数,写法大概是这样:
'use client';
import { useSearchParams } from 'next/navigation';
export default function MyClientComponent() {
const searchParams = useSearchParams();
const code = searchParams.get('code');
return <p>Code from URL: {code}</p>;
}
需要注意的是,这个 hook 只能在客户端用,如果你写在服务端组件里是会报错的。
路由跳转
如果要在客户端做路由跳转,使用的是 useRouter()
这个 hook:
'use client';
import { useRouter } from 'next/navigation';
const router = useRouter();
router.push('/');
这就有点类似于以前的 history.push()
,但同样地,必须放在客户端组件里用才行。
客户端跳转的方式:next/link
除了用 router.push
手动跳转,Next.js 推荐在页面跳转上使用 next/link
组件。它能自动优化跳转行为,比如预加载目标页面等等:
import Link from 'next/link';
<Link href="/about">Go to About</Link>
这个方式更适合用于 JSX 里的跳转按钮、菜单、Tab 等场景,性能也更好一些。
总的来说,Next.js 在路由这块还是比较现代的,只是服务端和客户端的概念得先搞清楚,不然一不小心就会遇到 “hook 只能在客户端用” 的报错 😅。
适应 Next.js 的目录结构
在使用 Umi Max 的时候,我们是手动在一个route.ts
中配置路由和对应的组件配置。在 Next.js 的 app
目录中,目录结构 就是路由结构,每一个文件夹就代表一个路由路径:
-
app/page.tsx
→ /
路由
-
app/about/page.tsx
→ /about
路由
-
app/blog/[slug]/page.tsx
→ 动态路由,例如 /blog/123
或 /blog/hello-world
每个文件夹下面可以包含多个特殊的文件,最常用的是:
-
page.tsx
:这个就是对应路由的页面组件;
-
layout.tsx
:用来包裹当前路由以及子路由的布局,适合放 Header、Sidebar、Footer 等公共区域;
-
loading.tsx
:用于当前路由懒加载时的 Loading 状态展示;
-
error.tsx
:这个页面的报错处理;
-
template.tsx
:与 layout
类似,但每次进入都会重新渲染,不会缓存。
动态路由
Next.js 使用方括号 [slug]
的形式来定义动态路由,举个例子:
app/blog/[slug]/page.tsx
这就代表了一个动态路径,比如:
/blog/a
/blog/nextjs-routing
/blog/123
在 page.tsx
文件里,我们可以通过 params
来获取这个动态值,例如:
export default function BlogDetail({ params }: { params: { slug: string } }) {
return <div>当前访问的博客 slug 是:{params.slug}</div>;
}
这样就不需要额外定义路由表了,所有的路由都可以通过目录结构直接体现出来,简洁而清晰。
多级嵌套路由和布局继承
一个很强大的地方是,Next.js 的 layout.tsx
是可以嵌套的。比如你有一个后台管理页面 /admin
,你可以这样组织:
app/
admin/
layout.tsx // 后台通用布局
users/
page.tsx // /admin/users 页面
settings/
page.tsx // /admin/settings 页面
这样所有 /admin/*
路由下的页面都会自动套上 admin/layout.tsx
的布局,而且嵌套非常灵活,不需要像以前那样手动包一堆 Layout
组件。
Next.js 的 app/
路由模式一开始可能不太直观,但用习惯了以后真的非常爽,目录就是路由,配合 layout
还能优雅地解决嵌套、公共区域复用的问题。
具体文档可以参考这里 👉 nextjs.org/docs/app/ge…
Docker部署
需要部署到测试服务器,生产服务器两种环境,next提供了dev,test,prod三种环境,但是感觉更换环境配置异常繁琐,在stackoverflow
找到了一个比较简单暴力的方式:
stackoverflow.com/questions/5…
nextjs会优先使用.env.local
中的配置,那么我们可以在build的时候,把.dev
,.test
,.prod
的配置文件复制一份到.env.local
,从而实现多环境配置:
"build:dev": "cp .dev .env.local && next build && npm run afterbuild",
"build:test": "cp .test .env.local && next build && npm run afterbuild",
"build:prod": "cp .prod .env.local&& next build && npm run afterbuild",
nextjs构建完成之后还需要拷贝一些文件:
"afterbuild": "cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/",
我这里使用的是standalone
模式,可以在next.config.mjs
中进行配置:
nextjs.org/docs/app/ap…
const nextConfig = {
eslint: {
dirs: ['src'],
},
reactStrictMode: false,
typescript: {
ignoreBuildErrors: true,
},
output: 'standalone',
swcMinify: true,
// 简单的webpack配置,避免Monaco Editor工作器加载问题
webpack: (config) => {
config.resolve.fallback = {
...config.resolve.fallback,
fs: false,
path: false,
os: false
};
return config;
}
};
export default nextConfig;
Docker构建脚本:
FROM node:20.19.0-slim
WORKDIR /app
COPY .next/standalone ./
# 暴露端口
EXPOSE 3000
CMD ["node", "server.js"]
之后构建镜像:
docker build -t xxx:latest .
构建部署会遇到很多问题,如下所示:
问题1
⨯ The requested resource isn't a valid image for /campus/image/2025/02/1739962254476-微信截图_20250219185026.png received text/html; charset=utf-8
⨯ Error: 'sharp' is required to be installed in standalone mode for the image optimization to function correctly. Read more at: https://nextjs.org/docs/messages/sharp-missing-in-production
^C%
解决办法:按照sharp
npm i sharp
问题2
Error occurred prerendering page "/tools/mindmap". Read more: https://nextjs.org/docs/messages/prerender-error
ReferenceError: document is not defined
解决办法
这种就属于:
5. Disable server-side rendering for components using browser APIs
If a component relies on browser-only APIs like window
, you can disable server-side rendering for that component:
问题3
⨯ useSearchParams() should be wrapped in a suspense boundary at page "/user/login". Read more: https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout
Reading search parameters through useSearchParams()
without a Suspense boundary will opt the entire page into client-side rendering. This could cause your page to be blank until the client-side JavaScript has loaded.
参考地址:
就是要我们使用Suspense组件把需要用到useSearchParams
的地方包起来,同时,尽可能力度小
<Suspense fallback={<div>Loading...</div>}>
</Suspense>
问题4
Error occurred prerendering page "/tools/coderunning". Read more: https://nextjs.org/docs/messages/prerender-error
ReferenceError: window is not defined
nextjs.org/docs/messag…
"use client"
import React from 'react';
import Editor, {loader} from '@monaco-editor/react';
import * as monaco from 'monaco-editor';
const MonacoEditor = (props) => {
loader.config({monaco})
return <Editor {...props} />;
};
export default MonacoEditor;
这个组件是需要客户端API的,所以可以使用动态导入:
import dynamic from "next/dynamic";
const DynamicComponentWithNoSSR = dynamic(() => import("../MonacoEditor"), {
ssr: false,
});
const MonacoEditorNoSSR = (props) => {
return <DynamicComponentWithNoSSR {...props} />;
};
export default MonacoEditorNoSSR;
其他问题
使用最新版的next之后,发现tailwindcss
不提示了,IDEA和Vscode都是,发现新版的少了文件tailwind.config.js
,可以在根目录里加一个文件,这样就可以有提示了。
/** @type {import('tailwindcss').Config} */
//todo 这个文件只是为了让IDEA可以有代码提示,最新版的tailwindcss已经不需要这个文件了
module.exports = {
content: ["./src/**/*.{html,js,ts,jsx,tsx}"],
theme: {
extend: {},
},
plugins: [],
}
参考资料
一次构建多处部署 - Next.js Runtime Env
Prerender Error with Next.js
Missing Suspense boundary with useSearchParams
如何优雅地部署一个 Next.js 应用
nextjs.org/docs/app/ap…