Umi 项目核心库升级踩坑(Umi 3→4、React 16→18、Antd 3→4、涉及 Qiankun、MicroApp 微前端)

1 阅读21分钟

本文记录了擎天跨境电商数据分析平台前端核心库升级的完整历程,涵盖 React 16→18、Ant Design 3→4、UmiJS 3→4 等核心技术栈的升级实践,以及 Qiankun、micro-app 微前端架构的兼容处理,希望能为面临类似问题的团队提供参考。

背景

擎天是一个服务于跨境电商的数据分析平台,支持 Amazon、eBay、Walmart 等多平台数据分析。技术栈为 UmiJS + React + Ant Design + DVA,同时作为 Qiankun 和 micro-app 微前端子应用运行。前端代码量 34 万+行,包含 135 个公共组件2283 个源文件,属于大型项目。

问题

项目从 19 年上线至今,核心依赖(React、Umi、Antd)一直没有做过大版本升级。随着业务不断迭代,我们陆续收到了一些性能相关的问题反馈:

  1. 页面长时间操作或停留会明显感到卡顿,甚至导致卡死页面崩溃
  2. 列表的一些操作卡顿,比如 checkbox 点击、行内按钮点击、滚动时 sticky 的部分有明显掉帧情况,用户体验不佳
  3. 首屏加载时间长,用户体验不佳

核心问题:

  1. React v16/v17 版本下的内存泄露问题,导致游离的 Node 无法销毁,页面长时间操作停留后占用内存可增长至 1G+(react#18066
  2. Antd 低版本的性能问题,Table 组件没有提供 sticky 功能,项目中手动实现的 FixedHeader 有大量不完善的 DOM 操作导致卡顿等等
  3. dva 状态设计问题导致的 layout 层 rerender
  4. 太过依赖 currentUser 这类前端初始化数据请求(5s+)导致的首屏加载时间过长

image.png

因为项目比较大,涉及到的东西比较多,贸然升级和改动核心代码怕产生一些不必要的线上事故,所以这些问题一直搁置。 直到最近也是在 AI 的帮助下,终于把几个核心库升级,解决了一些性能问题,用户体验也好了很多。

方案

在升级前我阅读了 React、Umi、Antd 官方升级文档和社区踩坑文章,整理了核心功能测试清单。考虑到项目规模和业务复杂性,我们采用了分阶段渐进式升级策略,切出 v1 → v2 → v3 三个分支分别对应三个阶段;同时对于涉及大量业务代码的 API 变更,通过兼容层的方式让旧代码尽量不需要修改,降低升级风险。

渐进式升级路径

flowchart TB
    subgraph 当前状态
        A[React 16 + UmiJS 3 + Antd 3]
    end

    subgraph 阶段一
        B[React 16 + UmiJS 3 + Antd 4]
    end

    subgraph 阶段二
        C[React 17 + UmiJS 4 + Antd 4]
    end

    subgraph 阶段三
        D[React 18 + UmiJS 4 + Antd 4]
    end

    subgraph 后续规划
        E[React 18 + UmiJS 4 + Antd 5]
    end

    A -->|"升级至 Antd 4"| B
    B -->|"升级至 Umi 4/React 17"| C
    C -->|"升级至 React 18"| D
    D -.->|"升级至 Antd 5"| E

    style A fill:#ffcccc
    style B fill:#ffe6cc
    style C fill:#fff2cc
    style D fill:#ccffcc
    style E fill:#cce5ff

为什么选择渐进式升级?

  1. 风险隔离:每个阶段只升级一到两个核心库,出问题容易定位
  2. 独立验证:每个阶段完成后可独立发布测试,确认无问题再进入下一阶段
  3. 快速回滚:单阶段变更小,回滚成本低

各阶段目标与预期问题

阶段核心目标版本变化预期问题
阶段一
Antd 3 → 4
解决 Table 性能问题,使用 sticky API 替换 FixedHeaderantd 3.26.16 → 4.24.15
react 16.9.0 → 16.14.0
Icon 改为按需导入
Form 改用 Form.useForm() 或安装兼容包
Form.Item 使用 name 属性替代 getFieldDecorator
Modal 中使用 form 时需设置 forceRender
更新 less 主题变量名称
Button.Group 改为 Space 组件
移除 LocaleProviderConfigProvider 替换
阶段二
UmiJS 3 → 4
React 16 → 17
提升构建性能(MFSU),为 React 18 并发渲染铺路umi 3.0.0 → 4.x
react 16.14.0 → 17.0.2
react-router 升级 v6 导致代码层变更
dynamicImport 改为 codeSplitting
删除废弃配置(devServer、esbuild 等)
删除 mfsuwebpack5 配置(默认开启)
fastRefresh 从对象改为布尔值
dva 配置中移除 hmr 选项
删除 @umijs/preset-*
_layout.tsdocument.ejs 不再支持
升级 @umijs/plugin-qiankun 到 2.50+
事件委托从 document 变为 #root
onScroll 事件不再冒泡
useEffect 清理函数异步执行
阶段三
React 17 → 18
解决内存泄漏问题,引入并发渲染提升性能react 17.0.2 → 18.2.0createRoot().render() 替换 ReactDOM.render()
并发渲染
setState 默认批处理

兼容层策略

部分 API 变更涉及大量业务代码,逐个修改容易遗漏且风险较高。对于这类变更,我们在上层做一层抽象,将新老 API 的差异在兼容层中处理。优点是业务层无感知、改动量小;缺点是新成员如果不了解兼容层的存在,可能会困惑为什么按官方文档使用却得到不一致的结果。

  • Antd:优先使用 @ant-design/compatible 兼容包快速过渡,再逐步迁移到新 API;Icon 图标动态 type 使用 LegacyIcon 兼容,静态图标通过 codemod 自动转换
  • react-dom:React 18 废弃了 ReactDOM.render(),需改用 createRoot()。兼容层封装了新的 render 方法,内部使用 createRoot() 实现,对外保持旧的调用方式
  • react-router-dom:react-router v6 移除了部分 API,兼容层重新实现:
    • Prompt 组件:基于 useBlocker 实现离开页面确认功能
    • matchPath:兼容 v5 版本的路径匹配 API
    • Link 组件:兼容 to 对象中包含 state 的旧写法
  • umi:Umi 4 中大量 API 变更,通过 Webpack alias 将 umi 指向 src/compatible/umi,导出兼容后的 API:
    • useLocation:自动从 location.search 解析并注入 query 属性
    • useHistory:返回兼容后的 history 对象
    • history 对象:代理 goBackback 等已更名的方法,location.pathname 自动去除 basename
    • withLayoutProps HOC:为 Layout 组件注入 locationmatchhistoryroutechildren 等 props
    • Link 组件:复用 react-router-dom 兼容层的实现

实际遇到的问题

Ant Design 3 → 4

Antd 3 到 4 是代码改动量最大的部分,涉及 950+ 个文件,大部分可以参考官方的迁移指南来做迁移。通过 @ant-design/codemod-v4 自动迁移和手动优化,已完成大部分改造,目前仍有少量使用 @ant-design/compatible 兼容包过渡。

Icon

Antd 4 将 Icon 从内置组件改为按需导入,项目中数百处图标使用需要迁移。

// ❌ Antd 3 写法
import { Icon } from 'antd';
<Icon type="user" />

// ✅ Antd 4 写法
import { UserOutlined } from '@ant-design/icons';
<UserOutlined />

使用官方 codemod 工具自动转换:

npx @ant-design/codemod-v4 app/web/src

对于动态图标(type 为变量的情况),使用 @ant-design/compatible 兼容:

import { Icon as LegacyIcon } from '@ant-design/compatible';
// 动态 type 继续使用兼容方式
<LegacyIcon type={dynamicIconType} />

项目自定义的 QtIcon 组件适配:

// 迁移前
import { Icon } from 'antd';
const QtIcon = Icon.createFromIconfontCN({ scriptUrl: '...' });

// 迁移后
import { createFromIconfontCN } from '@ant-design/icons';
const QtIcon = createFromIconfontCN({ scriptUrl: '...' });

Form

Antd 4 完全重写了 Form,Form.create()getFieldDecorator 被弃用,项目中大量表单组件需要迁移处理。

// ❌ Antd 3 写法
const MyForm = ({ form }) => {
  const { getFieldDecorator } = form;
  return (
    <Form>
      <Form.Item label="用户名">
        {getFieldDecorator('username', {
          rules: [{ required: true }],
        })(<Input />)}
      </Form.Item>
    </Form>
  );
};
export default Form.create()(MyForm);

// ✅ Antd 4 写法
const MyForm = () => {
  const [form] = Form.useForm();
  return (
    <Form form={form}>
      <Form.Item label="用户名" name="username" rules={[{ required: true }]}>
        <Input />
      </Form.Item>
    </Form>
  );
};

@ant-design/compatible 兼容,后面新功能开发再使用新版 Form 组件:

// 从 @ant-design/compatible 包导入
import { Form } from '@ant-design/compatible';
import '@ant-design/compatible/assets/index.css';

// 业务代码不需要修改
const WrappedForm = Form.create()(MyForm);

Menu

  • .ant-menu-item 下新增了 span.ant-menu-title-content,导致原有样式失效 → 更新样式选择器适配新结构
  • SubMenuonOpenChange 方法被移除(rc-menu 的改动,文档未提及)→ 替换为其他 API
  • 传递 eventKey prop 会导致 key 无法正确传给 SubMenu 组件(rc-menu#833
  • Dropdown 的 overlay 如果是 Menu,类名会变成 ant-dropdown-menu-* 而不是 ant-menu-* → 同时处理两种选择器

Tabs

  • .ant-tabs-bar 变为 .ant-tabs-nav → 更新样式选择器
  • .ant-tabs-card-bar 被移除

Tree

  • DOM 结构从 ul/li 变成 div.ant-tree-node-content-wrapper::before 被移除 → 重写相关样式
  • 事件回调中 node.props 被移除 → 改为直接访问 node
  • node.eventKey 变为 node.key
  • node.onExpandnode.onCheck 等方法不再可用 → 改用受控方式或通过 ref 调用
  • v3 事件里的 key 会强制转成 string,v4 里可能是 string 或 number → 注意类型判断

Table

  • .ant-table-column-has-actions 类名被移除 → 调整相关样式选择器
  • onRowClick 被废弃 → 使用 onRow 返回 onClick 事件替代

Modal/Drawer

  • visible 属性改为 open(codemod 自动转换)
  • Modal 变为异步渲染,同步获取 DOM 会失效 → 改用 ref 或 useEffect
  • iconType 属性改为 icon

其他组件变更

大部分可通过 codemod 自动转换,属性映射关系:

原属性/组件新属性/组件
Alert.iconTypeicon
Form.Item.idhtmlFor
Typography.setContentRefref
TimePicker.allowEmptyallowClear
Tag.afterCloseonClose
Card.noHoveringhoverable
Carousel.verticaldotPosition
Drawer.wrapClassNameclassName
TextArea.autosizeautoSize
Affix.offsetoffsetTop
Transfer.onSearchChangeonSearch
Select combobox 模式AutoComplete
LocaleProviderConfigProvider
MentionMentions
Button.GroupSpace

其他注意点:

  • Typography.Paragraph:配置 ellipsis 时,子元素如果有 span 标签,超长情况下只会显示 ...
  • CascaderonChange 空值从 [] 变为 undefined → 添加默认值处理

UmiJS 3 → 4

UmiJS 4 带来了构建性能的大幅提升(MFSU 默认开启、Webpack 5),由于历史原因,我们采用 umi + @umijs/plugins 的方式升级,主要遇到配置变更、语法/编译错误、API 变更等等。

配置这里基本就按照官方文档来迁移就可以,比较简单。

依赖变动

image.png

删除老的 Umi 插件依赖,使用 @umijs/plugins。

端口配置

image.png

# ❌ UmiJS 3:命令行参数
umi dev --port 3000

# ✅ UmiJS 4:环境变量
PORT=3000 umi dev

runtimeHistory

image.png

image.png

modifyContextOpts 替代废弃的 runtimeHistory 配置。

其他有变动的配置

image.png

image.png

image.png

导出语法问题

image.png

image.png

Less 导入问题

image.png

image.png

JSX 中多出来的 >

image.png

image.png

引入了未知的三方库

image.png

image.png

React 未导入

image.png

image.png

UmiJS 4 默认开启新的 JSX Transform,不再需要 import React,但如果代码中直接使用了 React 变量(如 React.memo),仍需导入。

这里最开始的方案是直接在 global.js 中把 React 挂到 window 作为全局变量,但这样不太好。

后面用了 ProvidePlugin 在编译期注入,不过需要注意的是,在开发环境中直接这么配会导致多个 React 实例会报错,后面在配置里需要把 mfsu 的 react 和 react-dom 设置为单例可以解决。

  {
    mfsu: {
      // 确保 React/ReactDOM 始终是单例,避免出现多份实例导致 hooks 报错
      shared: {
        'react': { singleton: true },
        'react-dom': { singleton: true },
      },
    },
  }

国际化模块自引用导致栈溢出

image.png

image.png

image.png

UmiJS 4 的国际化模块在遍历时如果存在自引用会导致栈溢出,这里也是之前业务代码不规范,因为升级才暴露出来。

require 语法报错

image.png

ES 模块中不应该使用 require。

props 为空对象

image.png

image.png

Umi 4 中 props 默认为空对象,这些属性都不能直接从 props 中取出,这些数据在业务代码中大量使用。

在兼容层新写一个 withLayoutProps 模拟 Umi 3 中的注入 props 的行为并在 layout 层包裹所有 layout 组件,这样代码改动最小。

location.query 不存在

image.png

image.png

UmiJS 4 中 location 的 query 属性被干掉了,在业务代码中有大量使用。 在兼容层重写 useLocation,拦截注入 query 属性。

location.pathname 和之前不一致

image.png

image.png

底层库 history v5 的破坏性变动。UmiJS 3 依赖 history@4 会有去除 basename 的逻辑,而 UmiJS 4 依赖的 history@5 干掉了这段逻辑,在兼容层模拟 history@4 的 stripBasename 的行为。

参考 issue:history#810

history.block 行为变更

image.png

新版 react-router 变动导致使用 history.block 的页面重新加载会意外弹出离开确认窗。

模拟实现 react-router-dom useBlocker Hook。参考:history#811history#921

Link 组件 state 传递

image.png

// ❌ UmiJS 3:state 可以放在 to 对象中
<Link to={{ pathname: '/detail', state: { id: 1 } }}>详情</Link>

// ✅ UmiJS 4:state 需要单独传
<Link to="/detail" state={{ id: 1 }}>详情</Link>

在兼容层做了处理,支持之前的写法。

image.png

image.png

移除 react-router-dom 依赖

image.png

之前代码中 Link 组件有从 react-router-dom 和 umi 两个包导入的情况。为了统一依赖,移除了 react-router-dom,后续统一从 umi 中引入。

路由定义规则变更

image.png

项目的 navMenu.js 中大量使用了 UmiJS 4 不再支持的路由写法

// 可选参数(50+ 处)
path: '/report/fba-overview/:platformAccountMap?'

// 正则匹配
path: '/report/:reportName(ads-[^/]+)/:platformAccountMap?'

// 多路径匹配
path: '/(report|performance|kanban)/:reportName/:platformAccountMap?'

这些 path 不只是给 UmiJS 做路由匹配,还会在 ReportLayout 中解析 reportName 动态加载组件。改造需要同时满足:

  1. 外部访问路径不变,这里主要涉及到已有书签、分享链接、包括一些微前端场景下父应用写死的 path,如果对 path 发生变化风险很大
  2. 传给 UmiJS 的 routes 能正常解析渲染
  3. 业务层代码(ReportLayout)正常工作
  4. 改动的代码范围要尽可能缩小,风险控制

解决方案

实现 convertUmiV4Routes 方法,在传给 UmiJS 前做兼容转换:

  • 非叶子节点:不支持的语法统一改为 / 放行,因为叶子节点的 path 更具体,所以父级放行不影响最终匹配
  • 叶子节点:带 :xxx? 可选参数的展开为两条路由(带参数 + 不带参数)
  • originalPath:保存原始 path 给业务层使用,之前的解析逻辑只需把 .path 改成 .originalPath,无需改动业务逻辑

完整示例

// 转换前(navMenu.js 原始结构)
{
  path: '/(report|performance)/:reportName/:platformAccountMap?',
  component: './Report',
  routes: [
    {
      path: '/report/fba-:any/:platformAccountMap?',
      routes: [
        { path: '/report/fba-overview/:platformAccountMap?' },
        { path: '/report/fba-stock/:platformAccountMap?' },
      ]
    }
  ]
}

// 转换后(传给 UmiJS 4)
{
  // 不支持的语法都改为 /,一律放行,叶子节点的 path 是更具体的 path,所以这里放行对整体不会有太多影响
  path: '/',
  // originalPath 给业务层代码使用,之前的解析逻辑不用变,只需要把 .path 改成.originalPath 而不需要改业务逻辑
  originalPath: '/(report|performance)/:reportName/:platformAccountMap?',
  component: './Report',
  routes: [
    {
      path: '/',
      originalPath: '/report/fba-:any/:platformAccountMap?',
      routes: [
        // 叶子节点带 :xxx? 这种参数写法的展开为两条路由
        { path: '/report/fba-overview/:platformAccountMap', originalPath: '/report/fba-overview/:platformAccountMap?' },
        { path: '/report/fba-overview', originalPath: '/report/fba-overview/:platformAccountMap?' },
        { path: '/report/fba-stock/:platformAccountMap', originalPath: '/report/fba-stock/:platformAccountMap?' },
        { path: '/report/fba-stock', originalPath: '/report/fba-stock/:platformAccountMap?' },
      ]
    }
  ]
}

routerRedux 失效

image.png 目前不确定这是否是框架 bug,文档中没有相关说明,暂时用 umi 的 history 做跳转替代。参考:umi#13240

下钻跳概览页问题(Outlet 影响)

image.png

报表下钻出现异常,这里下钻实现方式实际上是通过路由 state 把当前页面的 location 带过去,由于 <Outlet /> 替代了原来的 props.children 渲染子路由。之前通过 props 直接传递 perLocation 等参数给子组件的方式不再生效,因为 Outlet 不支持直接注入 props。需要改用 React Context 来传递这些参数。

React 17 → 18

React 的升级不会有太多的代码改动,主要是相关依赖包的同步升级和因为并发渲染和批处理导致的奇怪问题。

相关依赖同步升级

核心依赖

依赖包当前版本React 18 兼容性建议升级版本备注
react17.0.2-18.3.1核心升级
react-dom17.0.2-18.3.1核心升级
antd4.24.16✅ 完全支持保持4.24.0+ 已支持 React 18
umi4.6.7✅ 完全支持保持-
dva2.4.1⚠️ 需测试保持重点测试
react-redux8.1.3✅ 完全支持保持-
@ant-design/icons4.8.1✅ 完全支持保持或升级到 5.x-
@ant-design/compatible1.1.0✅ 兼容保持配合 antd 4.x 使用

React 相关依赖

依赖包当前版本React 18 兼容性建议操作
react-dnd11.1.3⚠️ 需升级升级到 16.x
react-dnd-html5-backend11.1.3⚠️ 需升级升级到 16.x
react-test-renderer17.0.2-升级到 18.3.1
@testing-library/react-hooks3.4.1⚠️ 需升级升级到 8.x
react-color2.17.3✅ 兼容保持
react-copy-to-clipboard5.0.1✅ 兼容保持
react-document-title2.0.3✅ 兼容保持
react-flip-move3.0.3⚠️ 需测试测试后决定
react-grid-layout1.4.1⚠️ 需升级升级到 1.4.4+
react-infinite-scroller1.2.6⚠️ 需测试测试后决定
react-resizable3.0.5✅ 兼容保持
react-sticky6.0.3⚠️ 需测试测试后决定
react-use14.3.0⚠️ 需升级升级到 17.x

一些没用到的依赖直接干掉了。

render 方法变更

image.png

这里主项目的不需要关心,Umi 4 已经兼容,不过有一些用老 API 的地方需要做一下兼容。

react-dnd API 变动

image.png

React 相关依赖包升级的 API 变动,使用 AI 工具辅助迁移即可。

废弃生命周期处理

image.png

image.png

使用 AI 工具批量重构即可。

微前端相关问题

擎天作为其他两个项目的子应用,分别使用了 qiankun 和 micro-app 两种不同的框架。这两个框架的相关文档都比较少,问题比较难以排查,大部分都是通过源码定位到的

Qiankun 中页面渲染问题

image.png

image.png

替换 setCreateHistoryOptions 为在 qiankun 生命周期的 mount 中修改 props 值来等同。

需要手动加 basename 前缀,这个之前不需要,是 history@5 的破坏性变更导致的。

需要手动拼接主应用传入的 url 和 baseUrl。history 的那三个参数文档也没找到,后面看源码才知道要这样配置。

connectMaster 注入的 props 为空

image.png

image.png

image.png

image.png

需要 @umijs/plugins/dist/model 插件依赖,普通 UmiJS 需要手动引入(@umijs/max 默认包含)。但没有任何报错或警告,非常难定位,这里也提了一个 issue 和加上警告的 PR。

参考 issue:umi#13234

// 确保引入
export default {
  plugins: [
    '@umijs/plugins/dist/model', // qiankun-plugin 依赖
    '@umijs/plugins/dist/qiankun',
  ],
};

micro-app 路由跳转失效

image.png

micro-app 微前端路由跳转失效,必须手动加 basename 前缀才可以。

history@5 原生 history 方法路由跳转失效

image.png

在 micro-app 场景下,底层通过原生 history 方法来做子应用路由跳转,但 UmiJS 4 依赖的 history@5 + react-router@6 内部维护了自己的路由索引(index),外部直接调用 pushState 不会被感知到,导致主应用做子应用路由切换失效。由于这个项目是把擎天 build 后加载子应用(非开发模式),相关警告也不会在控制台输出,增加了排查难度

micro-app 路由跳转 state 带不过去

image.png

image.png

CI/线上环境 bug

升级合并到主分支后,在 CI 和线上环境中陆续暴露了一些开发环境未覆盖到的问题

Table 横向滚动列宽问题

Antd v3 下 scroll.x 传 'max-content' 可以自适应列宽并横向滚动,但是 v4 如果在 td 宽度小于 th 或者空数据的情况下,th 会出现挤压的情况:

image.png

这个问题也是很多列没有指定具体的 width 导致的,如果要重新指定 width,那工作量就太大了而且测试也比较困难无法保证能全部覆盖到。

最开始想到的方案就是在项目基础组件 QtTable 中给每个列设置一个 150 的宽度(如果没传)再计算出 x 的具体值渲染。

这样做会导致一个问题,虽然不会有挤压的情况了但会出现留白过大,明明数据没有占那么宽但渲染出来有留白,上线后用户的反应很强烈。

后面又做了优化,先用一个稍大的列宽(150px)计算出一个 x 的具体值渲染出来,然后再取每个 th 的实际渲染宽度得出 minWidth,二次更新组件使其列宽自适应不会出现挤压的问题,由于 minWidth 在 v4 版本中还没支持,暂时用 onCell 设置 style 模拟实现。

具体 HOC 实现:

import React, { useState, useCallback, useRef, useEffect, useMemo, isValidElement } from 'react';
import useMeasure from 'react-use-measure';
import { isFunction } from 'lodash';
import FieldExplain from '@/components/FieldExplain';
import { parseWidth } from '@/components/QtTable/utils';

/** 列 padding 补偿宽度 */
const COLUMN_PADDING = 20;
/** 排序图标占位宽度 */
const SORTER_ICON_WIDTH = 12;

/**
 * @typedef {Object} ColumnConfig
 * @property {string} [key]
 * @property {string} [dataIndex]
 * @property {boolean} [sorter]
 * @property {React.ReactNode | Function | FieldExplain} [title]
 * @property {number} [width]
 * @property {number} [minWidth]
 * @property {ColumnConfig[]} [children]
 */

/**
 * @typedef {(colKey: string, width: number) => void} MeasureCallback
 */

/** 从列配置中获取唯一标识 */
const getColKey = (/** @type {ColumnConfig} */ col) => col.key || col.dataIndex;

/**
 * @typedef {Object} TitleMeasureProps
 * @property {React.ReactNode} children
 * @property {string} colKey
 * @property {MeasureCallback} onMeasure
 */

/** @type {React.NamedExoticComponent<TitleMeasureProps>} 纯测量组件,上报标题的真实渲染宽度 */
const TitleMeasure = React.memo(({ children, colKey, onMeasure }) => {
  const [ref, bounds] = useMeasure();

  useEffect(() => {
    const width = Math.ceil(bounds.width);
    if (colKey && width > 0) {
      onMeasure(colKey, width);
    }
  }, [bounds.width, colKey, onMeasure]);

  return (
    <span ref={ref} style={{ whiteSpace: 'nowrap' }}>
      {children}
    </span>
  );
});

/**
 * 包装列标题,将 TitleMeasure 注入到每个需要测量的列中
 * @param {ColumnConfig} col
 * @param {string} colKey
 * @param {MeasureCallback} onMeasure
 */
const wrapColumnTitle = (col, colKey, onMeasure) => {
  let title = col.title;
  if (title == null) {
    return title;
  }

  if (title instanceof FieldExplain) {
    title = title.getComponent();
  }

  if (isFunction(title) && !isValidElement(title)) {
    return (/** @type {any[]} */ ...args) => (
      <TitleMeasure colKey={colKey} onMeasure={onMeasure}>
        {title(...args)}
      </TitleMeasure>
    );
  }

  return (
    <TitleMeasure colKey={colKey} onMeasure={onMeasure}>
      {/** @type {React.ReactNode} */ (title)}
    </TitleMeasure>
  );
};

/**
 * 递归处理 columns,对没有显式 width/minWidth 的叶子列注入 TitleMeasure 组件
 * 测量完成后将实际宽度写入 column.minWidth
 * @param {ColumnConfig[]} columns
 * @param {Record<string, number>} measuredWidths
 * @param {MeasureCallback} onMeasure
 * @returns {ColumnConfig[]}
 */
const processColumns = (columns, measuredWidths, onMeasure) => {
  if (!columns) {
    return columns;
  }

  return columns.map(col => {
    const colKey = getColKey(col);

    // 有 children 的分组列,递归处理子列
    if (Array.isArray(col.children) && col.children.length) {
      return { ...col, children: processColumns(col.children, measuredWidths, onMeasure) };
    }

    // 已有明确的数值型 width 或 minWidth 的列不需要测量(width: 'auto' 等非数值会被忽略)
    if (parseWidth(col.minWidth) || parseWidth(col.width)) {
      return col;
    }

    const result = { ...col };
    const measuredWidth = colKey ? measuredWidths[colKey] : undefined;

    if (measuredWidth) {
      // 在原始测量值基础上补偿列 padding 和排序图标宽度
      result.minWidth = measuredWidth + COLUMN_PADDING + (col.sorter ? SORTER_ICON_WIDTH : 0);
    }

    if (colKey) {
      result.title = wrapColumnTitle(col, colKey, onMeasure);
    }

    return result;
  });
};

/**
 * HOC:代理 columns,用 TitleMeasure 包装标题以测量真实渲染宽度
 * 第一次渲染使用 DEFAULT_MIN_SCROLL_COLUMN_WIDTH 作为默认最小宽度
 * 渲染完成后从 DOM 获取实际宽度,二次更新设置精确的 minWidth
 * @param {React.ComponentType<{ columns: ColumnConfig[] } & Record<string, any>>} WrappedComponent
 * @returns {React.ForwardRefExoticComponent<React.PropsWithRef<{ columns: ColumnConfig[] } & Record<string, any>>>}
 */
const withAutoColumnMinWidth = (WrappedComponent) => {
  return React.forwardRef(
    (/** @type {{ columns: ColumnConfig[] } & Record<string, any>} */ props, ref) => {
      const { columns, ...restProps } = props;
      const [measuredWidths, setMeasuredWidths] = useState(/** @type {Record<string, number>} */ ({}));
      const pendingRef = useRef({});
      const rafRef = useRef(null);

      /** @type {MeasureCallback} */
      const handleMeasure = useCallback((colKey, width) => {
        pendingRef.current[colKey] = width;
        if (!rafRef.current) {
          rafRef.current = requestAnimationFrame(() => {
            rafRef.current = null;
            const batch = pendingRef.current;
            pendingRef.current = {};
            setMeasuredWidths(prev => {
              const next = { ...prev };
              let changed = false;
              for (const [k, w] of Object.entries(batch)) {
                const prevWidth = prev[k];
                // 已有测量值时,过滤不必要的更新:
                // 1. 宽度缩小时忽略,避免因内容折行导致的反复抖动
                // 2. 宽度增大不超过 5px 时忽略,过滤微小波动减少重渲染
                if (prevWidth > 0 && (w <= prevWidth || w - prevWidth <= 5)) {
                  continue;
                }
                next[k] = w;
                changed = true;
              }
              return changed ? next : prev;
            });
          });
        }
      }, []);

      useEffect(() => () => {
        if (rafRef.current) {
          cancelAnimationFrame(rafRef.current);
        }
      }, []);

      const processedColumns = useMemo(
        () => processColumns(columns, measuredWidths, handleMeasure),
        [columns, measuredWidths, handleMeasure]
      );

      return <WrappedComponent ref={ref} {...restProps} columns={processedColumns} />;
    }
  );
};

export default withAutoColumnMinWidth;

菜单未展开

image.png

image.png

React 18 并发渲染与原有状态管理逻辑冲突。也是之前代码实现有问题,通过升级才暴露出来。

图表都变成一样颜色了

image.png

image.png

image.png

image.png

利润表页面 CPU 跑满

image.png

image.png

image.png

业务代码本身存在不合理的 setState 调用链。React 17 下由于同步渲染的调度方式,setState 在某些场景会被合并或短路,问题被掩盖了。React 18 并发渲染改变了调度时序,暴露出递归 setState 问题:组件更新触发 setStatesetState 又触发新一轮更新,最终栈溢出、CPU 跑满。

手机网页版排版异常

image.png

样式兼容问题。

竞品监控添加链接点下一步报错

image.png

React 18 并发渲染改变了 setState 的调度时序,导致原有的表单步骤切换逻辑中状态更新顺序与预期不一致,触发运行时报错,没测到。

有权限但看不到店铺利润表

image.png

代码实现问题,React 升级后才暴露出来。

关键词竞价输入不进去

image.png

实际上是样式问题,padding 把输入的部分挡住了。

升级后的注意事项

本次升级后,团队成员的日常开发中需要注意:

  1. 相关开发文档需要切换到对应版本:antd@4、umi@4、react@18
  2. navMenu.js 路由配置的写法还是和 Umi 3 中的一样,不过需要知道是中间做了处理才可以这么写的
  3. 页面组件的 props.location、props.match、props.history 不再自动注入,Layout 层做了兼容但新代码建议用 hooks
  4. umi 引入的大部分 API 都做了兼容,所以可能会存在和 Umi 4 文档不一致的情况,如果需要 Umi 4 原本的 API 需要引入 umi-origin(大部分场景下都不需要)
  5. Antd 相关之前已经存在的代码我们就用兼容包可以不动了,但新开发的代码需要按照 v4 的用法来(Icon、Form 等)
  6. QtTable 组件的 fixedHeader 参数就是 antd table 的 sticky,QtTable/components/FixedHeader 组件因为历史原因代码没有被删除,但是不要再继续使用,使用 antd table 的 sticky 替代
  7. QtTable 原 fixedHeader 相关参数都被干掉了(fixedHeaderCellSyncStopIndex、alwaysSyncRightFixedRowsHeightInUpdate、syncRightFixedRowsAfterSeconds 等)
  8. react-router-dom 依赖已移除,Link 等组件统一从 umi 包引入
  9. 尽量不要再使用 dva 做状态管理,dva 已经没有在维护了,后面可以使用 useModel 的方式更轻量更符合函数式编程
  10. 开发环境新引入的工具:code-inspector-pluginagentation

优化空间

  1. currentUser 接口优化:当前耗时 5s+,每次有新业务都往这个接口里堆 RPC 调用,导致越来越慢。优化思路:
    • 拆分接口,缩小单次请求的数据粒度
    • 首屏数据通过 HTML 直接注入,后续更新通过前端 model 管理
  2. dva 状态拆解:早期没有在实际用到数据的组件上 connect,而是在 layout 层一股脑把所有 model 都注入了进去,导致任意 model 更新都会触发 layout 层级的无效 rerender。后续需要将 connect 下沉到实际消费数据的组件,逐步用 useModel 替代 dva connect。
  3. 构建速度优化:全量构建耗时较长,后续可从 codeSplitting 策略和 MFSU 缓存命中率方面入手优化。

总结

本次升级将前端技术栈从 React 16 + UmiJS 3 + Antd 3 升级到 React 18 + UmiJS 4 + Antd 4,解决了 React 16/17 内存泄漏、Antd Table 性能、开发构建等核心问题。

image.png

React@16:

image.png

image.png

React@18:

image.png

image.png

整体采用渐进式升级 + 兼容层的策略,业务代码改动量控制在较低水平,大部分业务代码无需修改。线上出现的问题(React 底层变动导致的较多)均在短时间内修复,影响范围有限,属于中低风险可控的升级。官方文档未覆盖的问题(微前端兼容、路由等)主要通过源码和社区 issue 定位解决,AI 工具在批量代码迁移中也起到了较大帮助

相关引用