我们在 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 规则会捕获任何试图跨越的导入。当某个团队准备就绪时,它只需从该列表中删除自己的路径,然后切换回默认导入,其周围的团队继续正常发布。
RTK v2 于 2023 年底发布。这意味着在 JavaScript 生态中最广泛使用的状态管理库之一中,Kibana 已经落后了将近三个主要版本。这反映了在 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,还有用于代码分割的 combineSlices API(支持切片 reducer 注入)。对于以懒加载为常态的 Kibana 插件架构来说,最后一个功能尤其有趣。
在深入解决方案之前,有必要了解 Redux 在 Kibana 中的使用有多么多样化。对代码库的全面审计(记录在 #239863 中)揭示了几个不同的阵营:
模式 | 插件和包 | 迁移需求 |
|---|---|---|
Redux Toolkit v1 | Discover, Lens, Synthetics, Security Solution | 完整的 v1 到 v2 迁移 |
纯 Redux v4 | Canvas, Maps, Index Management, Cross-Cluster Replication | 仅需 |
Kea | Enterprise Search (150+ 文件), Content Connectors | 仅需 |
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 和遗留的纯 Redux 模式。
Canvas、Maps、Index Management、Cross-Cluster Replication 以及其他几个插件仍然通过 createStore、combineReducers、applyMiddleware 和 connect 使用纯 Redux v4,这些都是前 RTK 时代的经典模式。它们根本不需要 RTK 迁移,因为它们一开始就没有使用 RTK,但它们确实需要 redux-v4 别名,因为默认的 redux 包现在是 v5。
Enterprise Search 和 Content Connectors 使用了 kea,这是一个 Redux 抽象层,拥有自己的逻辑构建器(kea()、useValues、useActions)。仅 Enterprise Search 就有超过 150 个文件。RTK 迁移不适用于这里,因为 kea 是它自己的世界。但它确实在底层依赖 react-redux v7,这正是 bundler 技巧发挥作用的地方。
Synthetics、Graph 和 Uptime 使用 redux-saga 处理副作用。Saga 集成实际上与 RTK 版本无关,但这些插件需要迁移其 Store 设置。
Security Solution 的 data-table 包完全使用 typescript-fsa 和 typescript-fsa-reducers 而不是 RTK,其 reducer 嵌入到 Security Solution 的主 Store 中,完全不在 RTK 迁移范围内。
Expressions 插件只从 react-redux 导入 shallowEqual,Monitoring 只导入类型。它们只需要别名交换。
要求每个团队同时迁移是不可行的。RTK v2 的破坏性变更包括更严格的类型检查和已移除的 API,例如 enableES5() 从 immer 中移除,getDefaultMiddleware 和 PreloadedState 完全消失,AnyAction 被 UnknownAction 替换,以及中间件配置的行为变化。
解决方案是将典型的迁移模式完全颠倒过来。不是将默认导入保留在 v1 上,并在别名下引入 v2,而是将默认包名(例如 @reduxjs/toolkit、react-redux 和 redux)现在指向 v2。旧版本位于版本化别名之下:
redux-toolkit-v1react-redux-v7redux-v4immer-v9reselect-v4redux-thunk-v2{
"@reduxjs/toolkit": "2.12.0",
"redux-toolkit-v1": "npm:@reduxjs/toolkit@1.9.7",
"react-redux": "9.2.0",
"react-redux-v7": "npm:react-redux@7.2.8"
}这是 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 externals。这需要同时提供新的 v2 包和 v1 别名,以便两者在运行时都可用。
然后是 @elastic/charts。它在内部依赖 RTK v1,并且不能独立升级,因为它是一个上游包。yarn resolutions 将其嵌套依赖锁定到 v1 版本:
{
"@elastic/charts/@reduxjs/toolkit": "npm:@reduxjs/toolkit@1.9.7"
}共享依赖的 webpack 配置中的 NormalModuleReplacementPlugin 会检测何时从 @elastic/charts 内部导入 immer、@reduxjs/toolkit、redux、react-redux 或 reselect,并将解析重定向到嵌套的 v1 副本。这确保了 @elastic/charts 解析到其兼容的 v1 依赖集。
kea 库是另一个有趣的案例。它将 react-redux 声明为对等依赖(>= 7),因此如果没有特殊处理,它的导入会解析到 Kibana 默认的 v9 包。迁移使 Kea 消费者保持在 react-redux-v7 上,因此 Kea 必须使用相同的 React 上下文。修复方案使用了基于函数的 webpack/rspack externals,当导入来自 node_modules/kea 时,它会跳过对 react-redux 的 externalize,并结合一个 NormalModuleReplacementPlugin 将其重写为 react-redux-v7。这确保了 kea 使用与包裹其消费者的 <Provider> 匹配的 v7 React 上下文。
webpack(kbn-optimizer)和 rspack(kbn-rspack-optimizer)配置都需要这些更改,并提取了一个共享的 isKeaReactReduxImport 辅助函数以保持逻辑一致性。
当两个版本都可用时,意外的跨版本导入是最大的风险。一条新的 @kbn/imports/no_redux_toolkit_v2_imports ESLint 规则会捕获任何尚未迁移的代码中导入 v2 默认包(例如 @reduxjs/toolkit、react-redux 或 redux 等)的行为。它甚至会自动修复文件导入和 Jest mock 路径,将其改为 v1 别名。
该规则通过 .eslintrc.js 中的 override 作用域限定到当前使用 v1 的约 36 个插件和包路径。当某个团队迁移时,他们只需从 override 列表中删除自己的路径。这种干净、自助的方式无需任何协调。
// .eslintrc.js (简化版)overrides: [{ files: ['src/platform/plugins/shared/discover/**/*.{ts,tsx}','src/platform/plugins/shared/workflows_management/**/*.{ts,tsx}',// ... 另外 34 个路径], rules: {'@kbn/imports/no_redux_toolkit_v2_imports': 'error', },}]这一点值得指出,因为在升级过程中很容易忽略这种失败模式。react-redux v9 和 v7 创建了不同的 React 上下文。如果组件树顶部有一个 v9 的 <Provider>,但子组件调用了 v7 的 useSelector(反之亦然),React-Redux 将无法找到匹配的上下文。在开发环境中,它会抛出一个错误,说明组件必须包裹在匹配的 <Provider> 中;在生产环境中,缺失的上下文会导致 hook 访问 store 时发生运行时错误。
Error: could not find react-redux context value; please ensure the component is wrapped in a <Provider>这意味着每个插件需要明确地固定在一个版本上。使用 react-redux 的共享包只能被同一版本的代码消费,因为混用是不可能的。这一约束使得迁移本质上是按插件进行,而不是按文件进行。
双版本设置为每个团队提供了清晰的路径,而跟踪问题中的依赖图分析确定了自然的迁移批次:
kbn-coloring、transform、timelines 和 expandable-flyout 这样的包拥有完全内部的 Redux Store,其类型不会通过公共 API 泄露。这些可以由其所属团队独立迁移,风险最小。@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,同时自身又被 40 多个包和插件消费,涵盖图表表达式、可视化、Maps、Canvas 和可观测性插件。Redux 类型是否通过包的公共 API 泄露决定了它是可以独立迁移还是需要协调。kbn-coloring 的 Store 对其 React 组件是内部的,因此单独迁移是安全的,但其他耦合点需要仔细分析。typescript-fsa 的混合使其成为最大的工作,但不同的模式可以独立处理。Lens 在中间件配置方面有最棘手的 v2 破坏性变更;独立的 getDefaultMiddleware 和 PreloadedState 在 v2 中都被移除了,并且它有四个自定义中间件文件,类型复杂。除了分批迁移:
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 预览、测试辅助函数和全局类型声明也是如此。多次运行 node scripts/eslint_all_files --no-cache --fix 捕获了机械性案例;剩余案例需要手动修复。
如果你在大型单体仓库中面临类似的主要依赖升级,那么将新版本赋予默认名称、旧版本赋予显式别名的模式值得考虑。新代码自然使用当前版本,而旧使用方式保持可见和可追踪,直到归零。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。