R·ex / Zeng


音游狗、安全狗、攻城狮、业余设计师、段子手、苦学日语的少年。

多版本共存——巨型项目组件库升级的必经之路

注意:本文发布于 452 天前,文章中的一些内容可能已经过时。

Update 2023-07-19

目前已经有更简单的方式:PNPM 已经支持带有 alias 的 peer dependencies 查找,不需要用 hooks 了;通过实现跟 Ant Design 类似的 prefixClsmodifyVars 功能,也可以控制组件库选择器的前缀以避免样式冲突。

组件库升级是一个令开发者头痛的事情,即使像 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 界面。主应用负责提供全部的通用依赖(如 reactreact-router、基座库、组件库),子应用只需要打包自己的业务代码,以及一些特殊的库。

主应用与子应用共用基座库里面的全部能力,也就是说,子应用在 NODE_ENV === 'development' 时也可以调用 bootstrap() 自行启动,这极大方便了各业务的开发与调试。

基于微前端的共存方案

先看大方向,如果要同时支持两套组件库,有两种方案:

  1. 主应用同时提供两套组件库,升级后的子应用使用新的组件库。
  2. 主应用不动,新的组件库由子应用自行打包。

经过考虑,我选择了第一种方案,因为主应用额外提供新的组件库不会影响现有页面,而且可以避免若干子应用分别打包组件库导致的重复打包、后续版本升级工作量大等问题。

确定了大方向,就要考虑具体的实现了。在实现的过程中,我先后遇到了很多问题,这里举两个最棘手的问题跟大家分享。

意料之外的技术问题

PNPM 如何安装多个版本的包?

我刚确定大方向就遇到了一个问题:我们的项目使用了 PNPM,它要如何同时安装两个版本的同一个包?比如,我想同时安装 v4v3,但是 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 组合单独创建一个文件夹。

举个例子,假如 [email protected] 依赖了 bar@^1,另外两个包 xxxyyy 都依赖 [email protected] 但依赖了不同的 bar 版本,那么 node_modules 的结构应当是这样的:

node_modules
├── xxx/node_modules
│   ├── [email protected]
│   └── [email protected]
└── yyy/node_modules
    ├── [email protected]
    └── [email protected]

PNPM 为了处理这种情况,在 .pnpm 目录中创建了这样几个目录:

.pnpm
├── [email protected][email protected]/node_modules
│   ├── foo
│   └── bar -> ../../[email protected]/node_modules/bar
├── [email protected][email protected]/node_modules
│   ├── foo
│   └── bar -> ../../[email protected]/node_modules/bar
├── [email protected]
└── [email protected]

其中第一个目录 [email protected][email protected] 会被硬连接到 xxx/node_modules,第二个目录 [email protected][email protected] 会被硬连接到 yyy/node_modules,这样就能保证 xxxyyy 中的 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-4ui-3className 即可:header 和 sidebar 肯定是 ui-4,content 则可以通过路由来判断,如果是升级完的页面,就加上 ui-4

想法很美好,但执行起来发现了一个问题——很多 popup 类组件是通过 portalrender 直接挂载到 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.createPortalReactDOM.render,于是我全局搜了一下关键字,有几类组件:

  • 封装较好、基于底层 Popup 实现的组件,如 CascaderPopoverTooltip
  • 基于 rc 库封装的组件,如 Select
  • 使用了 createPortal 的组件,如 DrawerModal
  • 使用了 render 的组件,只有 messagemessage.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 模块自带隔离,因此旧组件库会读取 ConfigProvider3containerClassName,新组件库则会读取 ConfigProvider4containerClassName,问题解决。

但对于第四种情况,由于 message.success()Modal.confirm() 这类方法可以在任意地方调用,很可能不处于 React 的组件生命周期中,无法使用 context 来做隔离,只能通过 ES 模块来隔离。我首先在 messageModal 中各添加了一个方法 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。

参考资料

Disqus 加载中……如未能加载,请将 disqus.com 和 disquscdn.com 加入白名单。

这是我们共同度过的

第 3068 天