由Umi升级到Next方案

由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 来处理前后端通信。

这里有一个技巧,就是如果访问某个页面需要调用很多接口,这些接口又都没有登录,那么可能会多次弹出未登录的消息,想要只显示一个消息,可以设置一个上次弹出时间,保证再一定时间内只弹出一个消息。

javascript 复制代码
 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,需要在请求的时候设置:

javascript 复制代码
 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 读取逻辑包裹在一个运行时检查中,只在客户端环境执行:

javascript 复制代码
 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") 中,只在客户端注册拦截器,也就是我最终选择的方案

javascript 复制代码
 ​
 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 管理侧边栏伸缩状态的方式:

javascript 复制代码
 import { create } from "zustand/react";
 ​
 const useSidebarStore = create((set) => {
   return {
     collapsed: false,
     toggleCollapsed: () =>
       set((state) => {
         return { collapsed: !state.collapsed };
      }),
  };
 });
 ​
 export default useSidebarStore;

然后在组件中直接使用::

typescript 复制代码
 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 就能搞定,无论跳转页面还是获取当前路径都挺方便的。例如我们以前经常这样写:

javascript 复制代码
 import { history } from '@umijs/max';
 ​
 history.push('/');

不过到了 Next.js,路由的用法就有些不同了,尤其是在区分服务端和客户端这块。Next.js 默认是支持服务端渲染的,所以如果要在客户端使用路由相关的 hooks,比如跳转或者获取 query 参数,组件必须是客户端组件 ,也就是文件或模块顶部要加上 'use client' 声明。

获取查询参数

在客户端组件中,可以使用 useSearchParams 来获取 URL 上的查询参数,写法大概是这样:

javascript 复制代码
 '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:

ini 复制代码
 'use client';
 import { useRouter } from 'next/navigation';
 ​
 const router = useRouter();
 router.push('/');

这就有点类似于以前的 history.push(),但同样地,必须放在客户端组件里用才行。

除了用 router.push 手动跳转,Next.js 推荐在页面跳转上使用 next/link 组件。它能自动优化跳转行为,比如预加载目标页面等等:

ini 复制代码
 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] 的形式来定义动态路由,举个例子:

bash 复制代码
 app/blog/[slug]/page.tsx

这就代表了一个动态路径,比如:

  • /blog/a
  • /blog/nextjs-routing
  • /blog/123

page.tsx 文件里,我们可以通过 params 来获取这个动态值,例如:

javascript 复制代码
 export default function BlogDetail({ params }: { params: { slug: string } }) {
   return <div>当前访问的博客 slug 是:{params.slug}</div>;
 }

这样就不需要额外定义路由表了,所有的路由都可以通过目录结构直接体现出来,简洁而清晰。

多级嵌套路由和布局继承

一个很强大的地方是,Next.js 的 layout.tsx 是可以嵌套的。比如你有一个后台管理页面 /admin,你可以这样组织:

ruby 复制代码
 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,从而实现多环境配置:

json 复制代码
     "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构建完成之后还需要拷贝一些文件:

json 复制代码
     "afterbuild": "cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/",

我这里使用的是standalone模式,可以在next.config.mjs中进行配置:

nextjs.org/docs/app/ap...

arduino 复制代码
 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构建脚本:

bash 复制代码
 FROM node:20.19.0-slim
 WORKDIR /app
 COPY .next/standalone ./
 # 暴露端口
 EXPOSE 3000
 CMD ["node", "server.js"]

之后构建镜像:

erlang 复制代码
 docker build -t xxx:latest .

构建部署会遇到很多问题,如下所示:

问题1

arduino 复制代码
  ⨯ 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

css 复制代码
 npm i sharp

问题2

javascript 复制代码
 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

ruby 复制代码
  ⨯ 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的地方包起来,同时,尽可能力度小

xml 复制代码
 <Suspense fallback={<div>Loading...</div>}>
 </Suspense>

问题4

javascript 复制代码
 Error occurred prerendering page "/tools/coderunning". Read more: https://nextjs.org/docs/messages/prerender-error
 ReferenceError: window is not defined

nextjs.org/docs/messag...

javascript 复制代码
 "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的,所以可以使用动态导入:

javascript 复制代码
 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,可以在根目录里加一个文件,这样就可以有提示了。

css 复制代码
 /** @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...

相关推荐
火星思想3 分钟前
React为何选择宏任务而非微任务进行任务调度?
前端
前端服务区4 分钟前
React内置Hooks
前端·react.js
前端花园5 分钟前
前端开发AI Agent之Memory理论篇
前端·aigc·trae
一只小风华~5 分钟前
web前端开发:CSS的常用选择器
前端·css·html·html5
啊吧啊吧曾小白5 分钟前
聊一聊前端日常使用的try...catch...finally
前端·javascript·面试
工呈士7 分钟前
HTML语义化与无障碍设计
前端·html
海底火旺8 分钟前
前端面试必考!== 和 === 的区别及最佳实践全解析
前端·javascript
几何心凉9 分钟前
企业数据采集新实践:提升工作效率的秘籍
前端·javascript
zayyo11 分钟前
前端性能优化:图片懒加载全攻略
前端·面试·性能优化
用户800052697756913 分钟前
思维导图前端实现
前端