作者:来自 Elastic Walter Rafelsberger

Kibana 为 Redux Toolkit v2 设置了默认的软件包名称,并将 v1 放到了一个显式别名上,这颠倒了通常的迁移顺序。Webpack 外部依赖、yarn resolutions 和一条 ESLint 规则确保 React Redux v7 和 v9 互不干扰。
测试 Elastic 最前沿的开箱即用功能。深入了解我们在 Elasticsearch Labs 仓库 中的示例 notebooks,开始免费云试用,或者现在就在你的本地机器上试用 Elastic。
我们将 Kibana 单体仓库中大约 1,100 个文件迁移到了 Redux Toolkit(RTK)v2 别名上,而且没有要求任何一个插件团队暂停功能开发。通常的迁移模式正好相反。现在,默认的软件包名称(@reduxjs/toolkit、react-redux、redux)解析到 v2,而现有的 v1 代码则位于显式别名之后,例如 redux-toolkit-v1 和 react-redux-v7。两个版本同时存在于 node_modules 中,并通过 npm 别名和 webpack 模块替换在运行时彼此隔离。一条作用于 36 个插件路径的 ESLint 规则会捕获任何试图跨越边界的代码。当某个团队准备好之后,只需从该列表中删除自己的路径,并切换回默认导入,周围的团队则可以继续发布功能。
为什么升级到 Redux Toolkit v2?
RTK v2 于 2023 年底发布。这意味着在 JavaScript 生态系统中使用最广泛的状态管理库之一中,我们已经在一个落后了主要版本近三年的版本上运行。这也反映了在 Kibana 这样规模的代码库中,这次升级有多么困难。一次之前的尝试采用了大爆炸式的方法,但当实际范围变得更加清晰后便陷入停滞。
那么,v2 实际上带来了什么?它与 Redux core 5.0、React-Redux 9.0、Reselect 5.0 和 Redux Thunk 3.0 一起发布。React-Redux 9.0 要求 React 18,并移除了 v8 为 React 16/17 携带的 useSyncExternalStore shim。由于 Kibana 已经运行在 React 18 上,升级可以去掉遗留的兼容代码,并让 Kibana 保持在积极维护的 Redux 主版本上。
RTK v2 还带来了真正有用的新功能,包括 createSlice 中的内联选择器,以及通过定制的 buildCreateSlice 设置选择性启用内联异步 thunk,同时还提供了带有切片 reducer 注入功能的 combineSlices API,用于代码拆分。最后这一点对于 Kibana 的插件架构尤其有趣,因为延迟加载是其常态。
Redux 在 Kibana 单体仓库中的使用方式
在深入了解解决方案之前,有必要先了解 Redux 在 Kibana 中的使用方式到底有多么多样化。对整个代码库进行的完整审计(记录在 #239863 中)发现了几个明显不同的阵营:
| 模式 | 插件和软件包 | 迁移需求 |
|---|---|---|
| Redux Toolkit v1 | Discover、Lens、Synthetics、Security Solution | 完整的 v1 到 v2 迁移 |
| Plain Redux v4 | Canvas、Maps、Index Management、Cross-Cluster Replication | 仅使用 redux-v4 别名,不进行 RTK 迁移 |
| Kea | Enterprise Search(150+ 个文件)、Content Connectors | 使用 react-redux-v7 别名,不进行 RTK 迁移 |
redux-saga |
Synthetics、Graph、Uptime | 仅迁移 store 设置,saga 与版本无关 |
typescript-fsa |
Security Solution data-table 软件包 | 不在范围内 |
| 类型和单次导入 | Expressions、Monitoring | 仅交换别名 |
Discover、Lens、Synthetics 和 Security Solution 等插件使用 RTK v1 API,包括 createSlice、configureStore、createAsyncThunk 和 createSelector。这些才是真正需要进行 v1 到 v2 迁移的部分。但即便如此,复杂程度也存在很大差异。Lens 使用独立的 getDefaultMiddleware(在 v2 中已移除)和 PreloadedState(同样已移除)。Security Solution 是最大的使用者,拥有 300+ 个文件,将现代 RTK 与遗留的 Plain Redux 模式混合使用。
Canvas、Maps、Index Management、Cross-Cluster Replication 以及其他几个插件仍然通过 createStore、combineReducers、applyMiddleware 和 connect 使用 Plain Redux v4,这些都是 RTK 时代之前的经典模式。它们根本不需要进行 RTK 迁移,因为它们一开始就没有使用 RTK,但由于默认的 redux 软件包现在是 v5,因此它们确实需要 redux-v4 别名。
Enterprise Search 和 Content Connectors 使用 kea,这是一个带有自己逻辑构建器(kea()、useValues、useActions)的 Redux 抽象层。仅 Enterprise Search 就有超过 150 个文件。这里不适用 RTK 迁移,因为 kea 有自己独立的体系。但它在底层确实依赖 react-redux v7,这正是打包器技巧发挥作用的地方。
Synthetics、Graph 和 Uptime 使用 redux-saga 处理副作用。Saga 集成实际上与 RTK 版本无关,但这些插件需要迁移其 store 设置。
Security Solution data-table 软件包完全没有使用 RTK,而是使用 typescript-fsa 和 typescript-fsa-reducers,其 reducer 嵌入 Security Solution 的主 store 中,因此也完全不属于 RTK 迁移范围。
Expressions 插件只从 react-redux 导入 shallowEqual,而 Monitoring 只导入类型。这些只需要交换别名即可。
要求每个团队同时进行迁移是完全不可行的。RTK v2 中的破坏性变更包括更严格的类型检查以及被移除的 API,例如 immer 中的 enableES5()、已经完全移除的 getDefaultMiddleware 和 PreloadedState、由 UnknownAction 替代的 AnyAction,以及 middleware 配置方式上的行为变化。
同时运行 Redux Toolkit v1 和 v2
解决方案是彻底颠倒典型的迁移模式。与其让默认导入继续使用 v1,再通过别名引入 v2,不如现在让默认的软件包名称(例如 @reduxjs/toolkit、react-redux 和 redux)指向 v2。旧版本则使用带版本号的别名:
-
redux-toolkit-v1 -
react-redux-v7 -
redux-v4 -
immer-v9 -
reselect-v4 -
redux-thunk-v2
scss
`
1. {
2. "@reduxjs/toolkit": "2.12.0",
3. "redux-toolkit-v1": "npm:@reduxjs/toolkit@1.9.7",
4. "react-redux": "9.2.0",
5. "react-redux-v7": "npm:react-redux@7.2.8"
6. }
`Lobster AI
这是 npm 的别名语法。"react-redux-v7": "npm:react-redux@7.2.8" 会将旧版本安装到一个不同的名称下。两个版本可以同时存在于 node_modules 中而不会发生冲突。
这里的关键洞察是,此拉取请求(PR)中的所有现有代码都被迁移到了 v1 别名。每一个 import { useSelector } from 'react-redux' 都变成了 import { useSelector } from 'react-redux-v7'。这涉及约 1,100 个文件,但其中绝大多数(约 1,000 个)只是机械地进行单行导入替换。当某个团队准备迁移到 v2 时,只需切换回默认的导入名称即可。一旦代码库中所有 v1 别名都消失,就可以彻底移除旧软件包。
这避免了另一种方案,即 v2 导入最终会永久使用非标准名称,从长远来看导致代码库中一直存在非标准导入。
通过打包器提供两个版本
让同一个库的两个版本在运行时共存,这才是真正有趣的地方。Kibana 使用 kbn-ui-shared-deps-npm 将公共依赖打包为共享的 webpack 外部依赖。这需要同时提供新的 v2 软件包和 v1 别名,以便两个版本都能在运行时使用。
使用 yarn resolutions 固定 @elastic/ charts
然后是 @elastic/charts。它内部依赖 RTK v1,而且由于它是一个上游软件包,不能单独直接升级。yarn resolutions 将其嵌套依赖固定到 v1 版本:
scss
`
1. {
2. "@elastic/charts/@reduxjs/toolkit": "npm:@reduxjs/toolkit@1.9.7"
3. }
`Lobster AI
共享依赖 webpack 配置中的 NormalModuleReplacementPlugin 会检测 immer、@reduxjs/toolkit、redux、react-redux 或 reselect 的导入是否来自 @elastic/charts 内部,如果是,则将解析重定向到嵌套的 v1 副本。这确保 @elastic/charts 解析到与其兼容的 v1 依赖集合。
使用 webpack 外部依赖让 Kea 保持在 React Redux v7
kea 库是另一个有趣的案例。它将 react-redux 声明为 peer dependency(>= 7),因此如果没有特殊处理,它的导入会解析到 Kibana 默认的 v9 软件包。此次迁移让 Kea 的使用者继续使用 react-redux-v7,因此 Kea 必须使用相同的 React 上下文。解决方案使用基于函数的 webpack/rspack 外部依赖,当导入来自 node_modules/kea 时跳过对 react-redux 的外部化,同时结合 NormalModuleReplacementPlugin 将其重写为 react-redux-v7。这样可以确保 kea 使用与其使用者外层 <Provider> 相匹配的 v7 React 上下文。
webpack(kbn-optimizer)和 rspack(kbn-rspack-optimizer)配置都需要进行这些修改,同时提取了一个共享的 isKeaReactReduxImport helper,以保持逻辑一致。
使用 ESLint 规则防止跨版本导入
既然两个版本都可用,意外的跨版本导入就是最大的风险。新的 @kbn/imports/no_redux_toolkit_v2_imports ESLint 规则会捕获尚未完成迁移的代码中对 v2 默认软件包(例如 @reduxjs/toolkit、react-redux 或 redux 等)的任何导入。它甚至可以自动将文件导入和 Jest mock 路径修复为 v1 别名。
该规则通过 .eslintrc.js 中的 override 限定在目前使用 v1 的约 36 个插件和软件包路径上。当某个团队完成迁移后,只需从 override 列表中删除自己的路径即可。这种简洁的自助式方式不需要任何协调。
rust
`
1. // .eslintrc.js (simplified)
2. overrides: [{
3. files: [
4. 'src/platform/plugins/shared/discover/**/*.{ts,tsx}',
5. 'src/platform/plugins/shared/workflows_management/**/*.{ts,tsx}',
6. // ... 34 more paths
7. ],
8. rules: {
9. '@kbn/imports/no_redux_toolkit_v2_imports': 'error',
10. },
11. }]
`Lobster AI
为什么混用 React Redux v7 和 v9 会破坏上下文
这一点值得特别指出,因为这是升级过程中一个很容易被忽略的故障模式。react-redux v9 和 v7 会创建独立的 React 上下文。如果一个组件树顶部使用 v9 的 <Provider>,但子组件调用的是来自 v7 的 useSelector(反之亦然),React-Redux 就无法找到匹配的上下文。在开发环境中,它会抛出错误,说明该组件必须使用匹配的 <Provider> 进行包装;在生产环境中,当 hook 访问 store 时,缺失的上下文会导致运行时错误。
ini
`Error: could not find react-redux context value; please ensure the component is wrapped in a <Provider>`Lobster AI
这意味着每个插件都需要明确固定到其中一个版本。使用 react-redux 的共享软件包只能被相同版本的代码使用,因为两个版本无法混用。这一约束使得迁移天然需要以插件为单位,而不是以文件为单位。
迁移批次:哪些内容可以独立迁移
双版本设置为每个团队提供了清晰的前进路径,而跟踪 issue 中的依赖关系图分析也确定了自然的迁移批次:
-
批次 1:独立、自包含的 store。
kbn-coloring、transform、timelines和expandable-flyout等软件包拥有完全内部的 Redux store,其类型不会通过公共 API 泄漏。这些软件包可以由负责它们的团队独立迁移,风险很低。 -
批次 2:相互耦合的软件包。 一些软件包跨边界共享 RTK 类型,必须 一起迁移。机器学习(ML)/IT 运维人工智能(AIOps)链就是一个例子:
@kbn/ml-response-stream导出一个streamSlice(一个createSlice返回值),而@kbn/aiops-log-rate-analysis直接将其嵌入自己的configureStore中。如果只迁移其中一个,就会导致 v1 和 v2 切片类型之间出现类型不匹配。Lens 生态系统中也存在类似的耦合。Lens 插件依赖@kbn/coloring(它拥有自己的 RTK store)、@kbn/lens-embeddable-utils和@kbn/lens-common,同时它自身又被图表表达式、可视化、Maps、Canvas 和可观测性插件中的 40+ 个软件包和插件使用。Redux 类型是否通过软件包的公共 API 泄漏,决定了它能否独立迁移,还是需要协调迁移。kbn-coloring的 store 位于其 React 组件内部,因此可以安全地单独迁移,但其他耦合点则需要仔细分析。 -
批次 3+:大型项目。 Discover、Security Solution 和 Lens 都有各自的迁移时间表。Security Solution 拥有 300+ 个文件,并且混用了 RTK、Plain Redux v4 和
typescript-fsa,因此是规模最大的工作,但这些不同模式可以分别处理。Lens 在 v2 中关于 middleware 配置的破坏性变更最为棘手;独立的getDefaultMiddleware和PreloadedState在 v2 中都已移除,而且它还有四个类型定义复杂的自定义 middleware 文件。
除了分批迁移之外:
-
已弃用的功能可以继续使用 v1 别名。当功能被移除时,v1 导入会随着代码删除而消失,无需进行任何迁移工作。
-
Plain Redux v4 插件(Canvas、Maps 和其他插件)完全不属于 RTK 迁移范围。它们确实可以从现代化中受益,但这是另一个独立的工作。
-
Kea 插件 最终需要将
react-redux-v7更新为react-redux别名,但不需要进行 RTK 迁移。更长期的问题,即是否继续使用 Kea,还是迁移到 RTK v2,是一个独立的决策。 -
双版本方案会在迁移期间增加可量化的软件包体积开销。这是一种权衡,但在迁移期间是可以接受的。
对其他大型单体仓库升级的经验
ESLint 规则最终成为关键环节。如果没有自动化强制措施,带别名的导入会在几周内逐渐重新变回默认名称。有了它,迁移状态可以通过 override 中列出的路径清晰可见。截至最初的 PR,没有任何文件从 @reduxjs/toolkit v2 导入。所有 RTK 使用都通过 redux-toolkit-v1 别名进行。这就是迁移的起点。
准备工作也不仅限于导入路径。引用 react-redux 的 Jest mock 需要更新为 react-redux-v7,Storybook 预览、测试 helper 和环境类型声明也需要更新。多轮运行 node scripts/eslint_all_files --no-cache --fix 捕获了机械性的修改;剩余情况则需要手动修复。
如果你正在大型单体仓库中面对类似的主要依赖升级,那么可以考虑采用这种模式:让新版本使用默认名称,让旧版本使用显式别名。新代码自然会使用当前版本,而旧代码则会一直保持可见且可追踪,直到其数量降为零。
原文:Redux Toolkit v2 migration: 1,100 files, no code freeze | Elasticsearch Labs