组件库升级是一个令开发者头痛的事情,即使像 Ant Design 这种经历了多年发展的组件库,升级也不是一件容易的事情——轻则 API 不兼容,重则影响自定义样式和用户体验。(为什么 API 不兼容反而影响小呢?因为 Ant Design 提供了 codemod
工具辅助解决 API 的迁移,并且如果使用了 TypeScript,类型不匹配是不会编译成功的。)
我们部门的项目,为了符合部门设计规范,用的是自己开发的组件库,而我是组件库的主负责人。在去年 Q3、Q4 我们做了大量的走查问题修复和功能开发,其中有些修改无法向下兼容,这就导致了我们的组件库升级成了一个大问题。经过简单的测试,暴力升级伴随的最显著问题是行高变小和表单错位,这对于一个 to B 的项目来说是致命的。
组件库升级的难点
我们的项目从两年前开始使用自己的组件库,当时组件库才开发了半年,功能不是很完善,但是业务需求又很紧急,因此很多功能是先在业务里实现,UI 的走查问题也是从业务中覆盖 CSS 来解决。随着业务规模的扩大,我们经历了一段人力紧缺、需求百花缭乱的时期,因此业务项目中留下了大量的样式覆盖,这些样式覆盖在组件库升级时就成了我们的大敌。目前来说,除了通过逐页面走查、修复的方式,还没有找到更好的解决方案。
在两年半以前,为了避免项目成为一个巨石应用,我们做了微前端拆分,组件库这类公共库由基座提供,每个子应用共享。这样做的好处是可以避免重复打包,但是也带来了一个问题,就是组件库的升级需要所有子应用一起升级。我们的项目有 30+ 个子应用、上百个页面,一次性升级难如登天。
因此,必须找到一个方式,让新旧两套组件库共存,这样就可以每个子应用单独升级了。我们的组件库有 基础组件库
和 业务组件库
(仅发布在内网,下文就用这两个中文作为它们的包名吧),可以类比于 Ant Design 和 Ant Design Pro,前者是后者的基础,升级的时候必须同时考虑到这两者。
为业务定制的解决方案
项目的微前端架构
我们的微前端方案基于 import-html-entry
,有一个主应用、若干子应用、一个自己封装的基座库。其中绝大部分功能都在基座库里,包括:
- 通用数据获取(用户信息、当前市场等)
- 添加自定义内容(路由、store、系统/市场切换菜单、枚举值)
- 自定义界面(Layout、首页、菜单图标、导航栏按钮)
- 子应用弹窗(这个之后再写文章讲)
- 在必要数据加载完后启动应用(
bootstrap()
) - 其它功能(数据上报、带时区的时间库、数据权限检查等)
主应用在启动时,会根据当前用户所处的系统,使用 import-html-entry
加载子应用,并合并子应用导出的路由、Store 等信息,最后执行 bootstrap()
结束 loading 界面。主应用负责提供全部的通用依赖(如 react
、react-router
、基座库、组件库),子应用只需要打包自己的业务代码,以及一些特殊的库。
主应用与子应用共用基座库里面的全部能力,也就是说,子应用在 NODE_ENV === 'development'
时也可以调用 bootstrap()
自行启动,这极大方便了各业务的开发与调试。
基于微前端的共存方案
先看大方向,如果要同时支持两套组件库,有两种方案:
- 主应用同时提供两套组件库,升级后的子应用使用新的组件库。
- 主应用不动,新的组件库由子应用自行打包。
经过考虑,我选择了第一种方案,因为主应用额外提供新的组件库不会影响现有页面,而且可以避免若干子应用分别打包组件库导致的重复打包、后续版本升级工作量大等问题。
确定了大方向,就要考虑具体的实现了。在实现的过程中,我先后遇到了很多问题,这里举两个最棘手的问题跟大家分享。
意料之外的技术问题
PNPM 如何安装多个版本的包?
我刚确定大方向就遇到了一个问题:我们的项目使用了 PNPM,它要如何同时安装两个版本的同一个包?比如,我想同时安装 v4
和 v3
,但是 pnpm i 基础组件库@4
之后会把 v3
的包给删掉。
经过简单的搜索,我发现 NPM、Yarn、PNPM 都提供了 alias 功能,可以为另一个版本的包指定另一个名字,例如:pnpm i 基础组件库-next@npm:基础组件库@4
,这样不会把 v3
的包给删掉,项目中导入的时候也只需要使用新名字:
import xxxV3 from '基础组件库';
import xxxV4 from '基础组件库-next';
去看了一下 node_modules
,发现 alias 的原理非常简单,就是把里面的目录名给改了,其它 package.json
等内容完全没动。这就导致了一个问题:我的业务组件库依赖了基础组件库(类似于 Ant Design Pro 依赖了 Ant Design),使用 alias 只能把这两个组件库的名字换掉,但在业务组件库代码中引入基础组件库还是使用的旧名字,因此版本是错误的。
这个问题在 NPM 和 Yarn 中似乎不太好解决,但 PNPM 是树状的 node_modules
,能否通过这个特性来解决呢?我翻阅了 PNPM 的文档,发现它处理 peerDependencies
的方式不太一样(peers 是如何被处理的):NPM 和 Yarn 对于 peerDependencies
只是做检查、报警告,但 PNPM 会为每一种可能的 peerDependencies
组合单独创建一个文件夹。
举个例子,假如 foo@1.0.0
依赖了 bar@^1
,另外两个包 xxx
和 yyy
都依赖 foo@1.0.0
但依赖了不同的 bar
版本,那么 node_modules
的结构应当是这样的:
node_modules
├── xxx/node_modules
│ ├── foo@1.0.0
│ └── bar@1.0.0
└── yyy/node_modules
├── foo@1.0.0
└── bar@1.1.0
PNPM 为了处理这种情况,在 .pnpm
目录中创建了这样几个目录:
.pnpm
├── foo@1.0.0_bar@1.0.0/node_modules
│ ├── foo
│ └── bar -> ../../bar@1.0.0/node_modules/bar
├── foo@1.0.0_bar@1.1.0/node_modules
│ ├── foo
│ └── bar -> ../../bar@1.1.0/node_modules/bar
├── bar@1.0.0
└── bar@1.1.0
其中第一个目录 foo@1.0.0_bar@1.0.0
会被硬连接到 xxx/node_modules
,第二个目录 foo@1.0.0_bar@1.1.0
会被硬连接到 yyy/node_modules
,这样就能保证 xxx
和 yyy
中的 foo
依赖的 bar
版本是正确的。这看起来很完美,因为我们新版业务组件库的 peerDependencies
中,基础组件库的版本也是新的,似乎 PNPM 可以正确处理这个事情。
但 PNPM 报了 missing peer 的警告,并且生成的文件夹是 基础组件库@3_业务组件库@4
,这显然是不合逻辑的。经过追踪 PNPM 代码,发现它为 peerDependencies
组合生成文件夹的前提是这个 peer 对应的版本必须已经被解析出来了,但我用了 alias 导致新版基础组件库的名字已经改变了,因此 PNPM 找不到它,就报了警告。PNPM 项目中有一个 issue 反映了此问题:An aliased package causes an unsolvable peer dependency warning。
那是否有方法可以绕过这个 issue 呢?我想起 PNPM 提供了 hooks,可以让我们控制依赖解析的过程,那我们只需要将业务组件库的 peerDependencies
改掉就可以了:
// .pnpmfile.cjs
module.exports = {
hooks: {
readPackage: pkg => {
if (pkg.name === '业务组件库' && pkg.version.startsWith('4')) {
pkg.peerDependencies['基础组件库-next'] = '~4.0';
delete pkg.peerDependencies['基础组件库'];
}
return pkg;
},
},
};
这样 PNPM 虽然不会报警告了,但最终生成的 node_modules
变成了这样:
.pnpm
└── 基础组件库@4_业务组件库@4/node_modules
├── 基础组件库-next -> ../../基础组件库@4/node_modules/基础组件库-next
└── 业务组件库 -> ../../业务组件库@4/node_modules/业务组件库
这会导致业务组件库代码中的 import 找不到模块。不过这个解决起来很简单,写一段 Shell 代码,将指定目录(.pnpm/*业务组件库@4*/node_modules
)下的 基础组件库-next
文件夹重命名:
function replace_next() {
local node_modules_dirs=$1
local package=$2
for item in $node_modules_dirs; do
local prefix="node_modules/.pnpm/$item/node_modules"
if [ -d $prefix/$package-next ]; then
mv $prefix/$package-next $prefix/$package
echo "Strip 'next' for $prefix/$package-next"
fi
done
}
业务组件库_dir=$(ls node_modules/.pnpm | grep 业务组件库@4)
replace_next "${业务组件库_dir[*]}" 基础组件库
把这段代码加到 NPM 的 prepare
脚本中,就可以在安装依赖后自动执行了。至此,多版本的包和依赖问题完美解决。
组件库的样式隔离该如何实现?
我们的页面是标准的后台管理页面,包括了 header、sidebar、content 区域。其中前两者处于基座库的 layout 组件中,content 则是 react-router
匹配到的页面级组件。基于这样的结构,我们可以将样式隔离的粒度定为页面级,即每个页面都有自己的样式,不会影响到其他页面,至于 header、sidebar 则是基座库的样式,应当与页面样式隔离。
对于样式隔离,一个最简单的方法就是给 CSS 选择器加上前缀(带空格)。例如我可以通过 PostCSS 的插件 postcss-prefix-selector 来实现,思路是先给所有选择器加上前缀 .ui-isolate
(一个不会被用到的 className
),然后根据 CSS 的文件路径将其替换成 .ui-4
或 .ui-3
:
{
loader: 'postcss-loader',
options: {
postcssOptions: {
plugins: [
['postcss-prefix-selector', {
// use this string to identify prefixed selectors, which will be
// replaced to .ui-4/3 according to the file path.
prefix: '.ui-isolate',
transform: (prefix, selector, prefixedSelector, filePath, rule) => {
// v4 prefix
if (filePath.includes('基础组件库@4') || filePath.includes('业务组件库@4')) {
// a possible result might be ".ui-4 .ui-button"
return prefixedSelector.replace('.ui-isolate', '.ui-4');
}
// v3 prefix
if (filePath.includes('基础组件库@3') || filePath.includes('业务组件库@3')) {
// a possible result might be ".ui-3 .ui-button"
return prefixedSelector.replace('.ui-isolate', '.ui-3');
}
// default not prefix
return selector;
},
}],
],
},
},
}
然后在 HTML 中给合适的容器加上 ui-4
或 ui-3
的 className
即可:header 和 sidebar 肯定是 ui-4
,content 则可以通过路由来判断,如果是升级完的页面,就加上 ui-4
。
想法很美好,但执行起来发现了一个问题——很多 popup 类组件是通过 portal
或 render
直接挂载到 body
上的:
const popupContent = (
<div className="ui-popup-container">...</div>
);
// 通过 portal 挂载到 body 上
ReactDOM.createPortal(popupContent, document.body);
// 通过 render 挂载到 body 上
const container = document.createElement('div');
container.className = 'ui-popups';
document.body.appendChild(container);
ReactDOM.render(popupContent, container);
如果只给 content 添加 className
,那弹窗就不会有样式了,但如果给 body
添加 className
,那么选择器就无法区分组件库版本了,这显然是不行的。似乎只剩下了一个方法:给弹窗的容器添加 className
。例如只要能给 .ui-popup-container
添加上 ui-4
,就可以在 PostCSS 插件中特判一下,给 .ui-popup-container
的所有选择器都加上前缀(不带空格)。此外我也发现了有一些特殊的选择器如 body
、:global
,也不能加前缀,虽然这可能会有样式覆盖的问题,但最终发现没有明显的影响。添加的部分代码如下:
+ const isOuterScopeSelector = selector => {
+ return selector.startsWith(':global')
+ || selector.startsWith(':local')
+ || /(^|\s|,)body(\s|,|.|$)/.test(selector);
+ };
+ const containerClassNames = [/* regexps of a huge amout of selectors */];
+ const hasContainerClassName = selector => {
+ return containerClassNames.some(re => re.test(selector));
+ };
if (filePath.includes('基础组件库@4') || filePath.includes('业务组件库@4')) {
+ if (isOuterScopeSelector(selector)) {
+ return selector;
+ }
+ if (hasContainerClassName(selector)) {
+ // a possible result might be ".ui-4.ui-button"
+ return `.ui-4${selector}`;
+ }
// a possible result might be ".ui-4 .ui-button"
return prefixedSelector.replace('.ui-isolate', '.ui-4');;
}
凭借对组件库代码的了解,我知道,想脱离 React root 只有两种方法(我们最多支持到 React 17):ReactDOM.createPortal
和 ReactDOM.render
,于是我全局搜了一下关键字,有几类组件:
- 封装较好、基于底层
Popup
实现的组件,如Cascader
、Popover
、Tooltip
; - 基于 rc 库封装的组件,如
Select
; - 使用了
createPortal
的组件,如Drawer
、Modal
; - 使用了
render
的组件,只有message
(message.success()
),以及Modal
的快捷弹窗(Modal.confirm()
)。
对于第一种情况,只需要修改 Popup
即可,就变成了第三种情况;对于第二种情况,想办法把 className
传进 popupClassName
即可,也变成了第三种情况。这三种情况有一个通用的解决办法,就是利用 React 的 context
来传递 className
。刚好组件库有一个 ConfigProvider
组件,可以在其中添加一个 prop 叫 containerClassName
,这样在业务代码里面只需要这样:
import { ConfigProvider as ConfigProvider3 } from '基础组件库';
import { ConfigProvider as ConfigProvider4 } from '基础组件库-next';
export default () => (
<ConfigProvider4 containerClassName="ui-4">
<ConfigProvider3 containerClassName="ui-3">
<App />
</ConfigProvider3>
</ConfigProvider4>
);
组件库代码中可以这样读取:
import classnames from 'classnames';
import { ConfigProvider } from '@src/components/config-provider';
export default () => {
const { containerClassName } = React.useContext(ConfigProvider.Context);
const classes = classnames('ui-button', containerClassName);
return <div className={classes} />;
};
由于 ES 模块自带隔离,因此旧组件库会读取 ConfigProvider3
的 containerClassName
,新组件库则会读取 ConfigProvider4
的 containerClassName
,问题解决。
但对于第四种情况,由于 message.success()
和 Modal.confirm()
这类方法可以在任意地方调用,很可能不处于 React 的组件生命周期中,无法使用 context 来做隔离,只能通过 ES 模块来隔离。我首先在 message
和 Modal
中各添加了一个方法 setContainerClassName
用于设置它们的弹窗 className
,将其存放至闭包中,然后在 ConfigProvider
里面添加了一个 useEffect
,当 containerClassName
发生变化时,就调用这两个方法:
React.useEffect(() => {
if (props.containerClassName) {
message.setContainerClassName(props.containerClassName);
Modal.setContainerClassName(props.containerClassName);
}
}, [props.containerClassName]);
至此,本问题得以解决。经过一段时间的调试,我把页面快速测试了一遍,所有组件/弹窗都能正常显示了。
后记
其实在实现过程中还有很多其它问题,例如:
- 如何根据路由判断是否需要使用新组件库:我们自行实现过路由守卫,可以在守卫中读取 meta 信息,在路由切换时修改 content 的
className
。 - 如何将添加
className
的操作隔离在业务项目而非组件库/基座库中:基座库提供addRootElementProps
功能,在主应用中判断并传入className
属性,再由基座库添加到页面各区域。这样一旦全部页面升级完成,只需要删除主应用的 PostCSS 代码以及addRootElementProps
调用即可。 - 升级完成后如何快速去除子应用的兼容代码:将兼容代码放到部门统一的构建器(类似于
react-scripts
,但封装了部门项目的很多配置)中处理,构建器会在 Jenkins 执行时先被更新,因此全部页面迁移完后只需要移除构建器的兼容代码即可。
顺便一提,我在迁移一个子应用的过程中,还发现了 import-html-entry
的一个边界场景:当子应用有做 split chunks,且 html-webpack-plugin
被设置为 deferred
模式(默认配置)时,会为 <script>
标签添加 defer
属性,并将它们都放置到文档的 <head>
标签里。虽然正常情况下被 defer 的脚本加载顺序不影响执行结果,但 import-html-entry
解析 exports 的原理是依次执行 HTML 文件中所有的代码,并读取最后一个挂到 window
上的属性,如果 <script>
标签的顺序出错,最后一个挂到 window
上的属性就不再是我们期望的 exports 了。解决方法也很简单,只需要给 html-webpack-plugin
设置 scriptLoading: 'blocking'
即可,或删除 split chunks 的配置。这虽然不是 import-html-entry
的问题,但我觉得开启 split chunks 且 html-webpack-plugin
只用默认配置的人应该不少,因此我提了个 这个 PR,希望在 import-html-entry
的 README 中添加解决方法。(不过截至目前这个 PR 还没有新的动态,可能是作者还没有返工吧。)
没想到 OKR 中的一句话“升级组件库版本”会带出这么多问题,我也从中学到了很多知识点。条条大路通罗马,这次遇到的问题虽然看起来很棘手,但通过搜索资料、追踪代码的方式,基本可以搞清楚问题的成因——如果是个通用问题,一定有一个解决或绕过的方式,否则就结合自己的业务场景,找到解决方案,并将解决的方法尽可能隔离在业务项目中,保持技术项目的纯粹性。发现并解决问题,就是身为工程师永远的 OKR。
参考资料
- 别名(Aliases) | pnpm
- peers 是如何被处理的 | pnpm
- An aliased package causes an unsolvable peer dependency warning · Issue #4301 · pnpm/pnpm
- RadValentin/postcss-prefix-selector: Prefix all CSS rules with a selector
- docs: add FAQ chapter to explain the issue caused by deferred scripts by RexSkz · Pull Request #84 · kuitos/import-html-entry