R·ex / Zeng


音游狗、安全狗、攻城狮、业余设计师、段子手、苦学日语的少年。 MUGer, hacker, developer, amateur UI designer, punster, Japanese learner.

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

组件库升级是一个令开发者头痛的事情,即使像 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 基础组件库[email protected]:基础组件库@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] 依赖了 [email protected]^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。

参考资料

版权声明:除文章开头有特殊声明的情况外,所有文章均可在遵从 CC BY 4.0 协议的情况下转载。
上一篇: 由 Use Zoom For DSF 导致的幽灵 Bug
下一篇: 追根究底:不打开 DevTools 时,console.log 会不会内存泄漏?

这是我们共同度过的

第 2686 天