企业级框架和锁版本

题图:dizzydizz @ unsplash.com

为什么锁?

umi 框架作为蚂蚁金服内部唯一的前端标准方案,其 umi build 已融在开发的流程中,每次产品发布都需要走一次 build,是流程中非常重要的一环。所以 umi 挂则流程挂,让几百上千号人卡在流程上,是很严重的一件事

umi 框架本身的代码可以通过单元测试和 e2e 测试保障,但 umi 依赖的三方库就不可控了。由三方库导致我们工具或者框架出问题的案例已经很多次了。

从影响面来看,框架划入故障体系我觉得是迟早的事。比如挂半小时算 P2,挂 1 小时算 P1 啥的,直接和框架维护者的 KPI 挂钩。

举一些例子,

  • babel-plugin-module-resolve@3.0.0-beta.2 把编译时机改成在 Program.enter 时处理,其引起的 顺序问题导致 babel-plugin-import 无效
  • react-router 发布 4.4,导致用户的 react-router 和 react-router-dom 版本不一致时会报错
  • babel 发布 7.3 时 @babel/runtime 有不兼容更新,我花了一天解决这个问题,其中包括锁定 umi 依赖的 @babel/runtime 版本
  • babel 发布 7.4 时,更新了二级依赖 core-js 为 3.0,导致 core-js 没有锁的项目报错(期望是 core-js@2,实际上拿到的是 core-js@3)。那天早上 7 点我就看到 babel 更新 7.4,我还庆幸我们锁版本了,肯定不会有问题。。结果,框架挂了一上午,所有构建流程跑不通,又折腾了一天,其中包括锁定 umi 依赖的 core-js 版本
  • 等等

其实我们已经有了类似 bug-versions 等的方案,如果发现一个错误的版本,提 PR,然后 cnpm 在安装依赖时就不会匹配这个版本。

这能解决 node 端的问题,但不能完全解决前端的问题,个人觉得前端的依赖比 node 端复杂。比如 babel 发布 7.4,好几十个依赖,总不能都认为是 bug version;另外一些问题可能只在某些场景下是问题,所以认定为 bug version 也不正确。

目前 umi 采取的方案是锁版本。通过锁定依赖的版本,来保障框架的稳定性。依赖的升级掌握在自己的手中,定期升级,跑过用例再发。无需随时担心某个依赖会给我们意外的惊吓。

如何锁?

锁直接依赖

这个很好理解,比如我们依赖 webpack,package.json 写成 4.9.1,而不是 ^4.9.1。然后 webpack 发布 4.9.2 或者 4.11 我们就不会受影响。

锁 lerna 多包内部依赖

现在通过 lerna 管理多包基本上是标配了,比如 dva、umi、next.js、nuxt.js、babel、react-router、vue-cli 等等,想找一个非多包的都很难。

多包之间会存在相互依赖,比如 umi 依赖 umi-core。

  1. umi@1.1 依赖 umi-core@^1.1
  2. 用户写死 umi@1.1 依赖,安装的 umi-core 版本则是 1.1
  3. 我们发布 umi@1.2 和 umi-core@1.2
  4. 用户重装依赖,得到的是 umi@1.1 和 umi-core@1.2

这显然不符合预期,带来的主要问题是不可回滚。假如 umi@1.2 出问题,用户希望退回到 umi@1.1,就做不到。并且,实际上并不是 umi 依赖 umi-core 这么简单,有 10 来个相互依赖关系。

所以,umi 内部依赖也需锁定,umi@1.1 依赖 umi-core@1.1,然后 umi-core 升级 1.2 或者 1.1.1,用户安装的仍然是 1.1 版本。

绝对路径更靠谱

webpack loader 的 resolve 是可以通过 resolveLoader.modules 配置的,但总觉得不可靠,啥都没有绝对路径靠谱。

下面的,

{
  test: /\.html/,
  loader: 'file-loader',
}

改成这样,

{
  test: /\.html/,
  loader: require.resolve('file-loader'),
}

babel 的 plugin 和 presets,eslint 的 config 和 plugin 等都是同一个道理。

锁 babel 的间接依赖

babel 是一个特例,我们需要借助 babel 做编译。babel 在编译后生成的文件出于尺寸的考虑,不会把所有内容都打到文件里,比如补丁和语言特性的 helper 方法。

比如:

const C = { ...A, ...B };

可能会被编译成,

const objectSpread = require('@babel/runtime/helper/objectSpread');
const C = objectSpread(A, B);

所以编译后的文件会有依赖,比如依赖 core-js 做补丁,依赖 @babel/runtime 做 helper 方法。

而由于 webpack 的 resolve.modules 第一项通常是 node_modules,所以会从当前的 node_modules 目录开始找起。所以如果是编译的项目文件,这些依赖会从项目目录的 node_modules 开始找起(通常会有 fallback,但这不是重点),而如果项目下没有直接依赖 @babel/runtime,则会有风险。

风险是现在的包管理工具,比如 npm、yarn、cnpm 等,都会做依赖的 hoist。所以不确定哪个版本的 @babel/runtime 会被提到最上面,然后被使用到。

那么怎么锁?

babel 说到底干了两件事,1 是语言特性,2 是补丁。语言特性会用到 @babel/runtime,补丁会用到 core-js,锁这两个就好了。

@babel/runtime

@babel/runtime 是通过 @babel/plugin-transform-runtime 编译后被使用的,而后者有一个未公开的接口 absoluteRuntime,可以通过他指定 @babel/runtime 的查找绝对路径,

{
  absoluteRuntime: dirname(require.resolve('./package')),
}

参考 umijs/umi!1893

core-js

core-js 是通过 @babel/preset-env 引入的,但后者没有提供接口定制 core-js 的查找路径。

用法是在入口文件引 @babel/polyfill,

import '@babel/polyfill';

然后 @babel/preset-env 会根据 targets 配置生成 core-js 的引用,

import 'core-js/modules/es6/array.includes';
import 'core-js/modules/es6/promise';
// and more

锁定的方式大家可能会想到 webpack 的 alias,但是不行,原因是 webpack 的 alias 会变更所有的 core-js 引用,但实际项目是可能会有多个 core-js 存在的,一些依赖库可能会用不同的 core-js 版本,并且不可统一。

所以方案是写一个 babel 插件,只编译指定入口文件的 core-js 引用,参考 umijs/umi!2136

终极方案?

做了这些之后,我觉得能解决 90% 的问题。但还有一个间接依赖的问题。比如 umi 依赖 foo,foo 依赖 bar,然后 bar 做了个有问题的发布,那 umi 还是会出问题。

要解决这个问题,只有彻底锁定版本。

有以下思路:

  1. 项目锁定版本,直接可行,但作为内部框架,并不希望这种事情发生,框架和技术栈的升级就是个问题了
  2. 借助包管理工具锁定框架的所有依赖,通过 npm-shrinkwrap.json,但 yarn 看起来不支持
  3. 借助 pkg 把框架打成二进制文件,连 node 都包进去
  4. 借助 ncc 把框架打成一个文件(可能还会有一个通过 fs 读的文件),基于 webpack

思路 3 有点激进,其中坑会比较多。思路 4 我觉得会是终极方案,会找时间再深入。