背景
最近在做项目依赖升级。因为我们的 package.json
里使用的是固定版本号(不知道大家是使用什么方式的),这两年一直在推进业务,项目依赖已经比较陈旧了,所以这次开了一个大项目去做依赖升级。而我有(dao)幸(mei)领到了 reselect
。整个过程比较崎岖,跟大家分享一下。
行动
我看了下我们项目依赖的是 reselect@4.0.0
(5 年前发布的),然后执行 npm outdated
,结果告诉我 reselect
最新版本是 5.0.1
。
那么,接下来执行 npm i reselect@5.0.1
。安装成功,万事大吉。
安装完成之后,执行 npm run develop
尝试本地运行,然后依次检查 UT 和 E2E,确保没有问题,最后建 PR ,合代码。
结果
结果第一步 npm run develop
就报错了,命令行里全是异常信息,向上翻已经找不到命令了。粗略扫了一遍,发现是 TS 类型不兼容。那好,执行 npm run check:types
检查下类型。好家伙,error 600+,files 300+。看样子事情没有那么简单了。
降低版本再试试
于是,我打开 GitHub,找到 reselect
,点开 release 面板,开始翻阅版本变更的记录。 V4 版本最高是 4.1.8
,然后直接是 V5。
行,那我再试试 4.1.8
。
上述步骤再次执行一遍,可是类型错误的数量级没有发生变化。偷懒的念头就此打住,我开始详细阅读 changelog,尝试理解 reselect
版本升级到底发生了什么变化。
Changelog V4
Changelog 的细节就不细说了,简单概括一下。
- 4.0.0 的下一个版本是 4.1.0,用 TS 重写了整个代码,对 TS 的支持从 v2.x 提高到 4.2。
- 从 4.1.0 到 4.1.8,基本上每个版本都包含对类型的修复和优化
那好,既然 reselect
升级的是类型,项目报错也是类型错误,那就开始改吧。
修复类型错误
我把终端里的输出重定向到一个 log 文件中,对错误信息分类汇总。我发现绝大多数都是类型不兼容问题,reselect
自动类型推断出来的 state 类型,与实际调用 selector
时传入的 state 的实际类型不一致。
下面看一个简单的例子:
typescript
import { createSelector } from 'reselect';
const selectA = (state: { a: string }) => state.a;
const selectB = (state: { b: string }) => state.b;
const selectAB = createSelector(selectA, selectB, (a, b) => ({a, b}));
// 使用 selectAB
const selectData = state => {
// ...
const ab = selectAB(state);
// ...
}
按照 TS 类型推断,OutputSelector
的入参类型是所有 InputSelector
的入参进行 &
操作。在这个例子里,selectAB 的入参类型是 { a: string } & { b: string }
。而在实际的项目中,我们有几十上百个 InputSelector
,经过各种导出和导入,不同的 OutputSelector
的入参类型变得十分复杂。因此,最终的解决方案是在调用 OutputSelector
的地方,更新入参 state 的类型,加上类型声明。
另外一类错误是范型不正确,因为 reselect
重写了类型声明,导致之前在 OutputSelector
上加的范型不对。既然 reselect
可以自行推断出正确的类型,那我就直接把类型范型删除即可。事实证明这种方式是有效的。
再次升级 V5
修完升级 4.1.8
带来的类型错误之后,我走了一遍后续的流程,一切正常。于是创建 PR,顺利完成升级。然后,开始尝试升级 5.0.1
。
我还是尝试直接升级,想着既然类型问题已经解决,应该不会再有新问题。
安装完成。
这次先进行类型检查。执行 npm run check:types
没有报错。
再进行 UT 检查。执行 npm run test
。终端再次被报错输出占满。(脏话省略...)
查看报错信息,发现是 reselect
提示说 InputSelector
不能返回一个新引用。比如 state ⇒ state.todos.map(todo ⇒ todo.id)
。看完一头雾水,我再次回到 Changelog。
Changelog V5
reselect
V5 新增了 Stability Checks
,仅在 development
环境开启。文档解释说,如果 InputSelector
每次都返回一个新的引用,会导致缓存失效。同时,InputSelector
应该只负责 extraction
,也就是从 state 上取数据;而 ResultFunction
只负责 transformation
,也就是转化从 InputSelector
返回的数据。只有同时满足这些条件,reselect
才能正确地工作。然而,这些条件很容易被打破,因此 reselect
V5 增加了 development check
。
另一方面,reselect
V5 更新了 package.json
,新增了 exports
字段,对现代打包工具更友好。
如何处理 development check
先考虑 development check
,只有在 Selector
执行的时候才会报错,而且改动本身涉及到业务逻辑代码的调整,不再是类型错误了。我们采用的微前端架构,多个团队的业务在一个仓库里,我不能随便修改别的团队负责的代码。
保险起见,我选择先把 development check
关闭掉,然后把变更同步到所有团队,各自修改各自的代码。
改完后再次执行 npm run test
, UT 全部通过。
新的问题来了
接下来就是本地跑起来,运行 E2E 测试了。
执行 npm run develop
,编译报错,找不到 reselect 导出的 createSelector。(更大声的脏话省略...)
为什么
很显然,这次是打包出了问题,大概率跟 reselect
里的 package.json
的改动有关。难道是 webpack
不能识别 exports
字段?是的,我们还在用 webpack
,版本是,5.88.2
。肯定支持 exports
字段的。
那么问题出在哪里?先缩小问题范围。我们的项目支持 SSR,执行 npm run develop
会打包客户端和服务端的代码。那么先确定是哪里打包的问题。
测试发现,服务端打包正常,错误是客户端打包产生的。可是为什么呢,还是没有思路。
既然如此,大胆猜测,小心求证。
有没有可能是打包过程中,打包工具选择了错误的文件,导致无法识别错误的模块格式?如果是这样,那客户端打包过程中,到底是什么模块格式呢?
代码是用 TS 写的,先看 tsconfig.json
, "module": "commonjs"
, 那编译产物就是 commonjs
了。
等等,TS 负责项目的编译吗?得看看 webpack
配置文件。
项目中所有的业务代码文件后缀统一是 .tsx
, 处理 .tsx
的是 babel-loader
。那么,难道故事是这样的:webpack
找到文件,发现是 .tsx
,于是交给 babel-loader
, 然后 babel-loader
使用 @babel/preset-typescript
,@babel/preset-typescript
内部调用 TS 编译器 tsc
,基于tsconfig.json
完成 TS 到 JS 的转换?
打开 babel
官网,找到 @babel/preset-typescript
,发现它并没有使用 tsc
。所以其实 TS 仅仅做了类型检查,不参与项目编译,故事并不是这样的。
到底是谁决定了编译的模块格式
既然 .tsx
是由 babel-loader
处理的,那么还是得回到 babel
的配置文件。于是我找到了这样一段配置:
ini
if (isClient) {
presetEnvConfing.modules = false; // 保留 ES modules 格式,不做转化
presetEnvConfing.targets = { esmodules: false }; // 目标是兼容 ES modules 的浏览器
} else {
presetEnvConfing.targets = { node: true }; // 目标是兼容当前 node 版本
}
这段代码是在配置 @babel/preset-env
,作用已经在注释里说明了。我们只关注 client 端的配置。所以,babel-loader
的产物是 es6 的模块。
但是,如何验证这一点呢?可以写个 loader 试试,放在 babel-loader
前面,这样就能刚好接收 babel-loader
的输出。
查看 babel-loader
的输出,我发现模块格式的确是 es6 module,前面的猜测是正确的。.tsx
里的 import { createSelector } from 'reselect'
完整地保留了下来。
调查再次陷入僵局。
webpack 如何处理 import
babel
的产物最终是要交给 webpack
处理的。那么,webpack
在看到 import { createSelector } from 'reselect'
后会做什么呢?它要打包,那就知道到哪里去找 reselect
。所以关键还是 module resolution
。
仔细阅读 webpack
的 module resolution
和 exports
相关的文档,结合 reselect@5.0.1
的 exports
字段:
json
"exports": {
"./package.json": "./package.json",
".": {
"types": "./dist/reselect.d.ts",
"import": "./dist/reselect.mjs",
"default": "./dist/cjs/reselect.cjs"
}
}
我猜测 webpack
会将 from 'reselect'
转换成 from './node_modules/reselect/dist/reselect.mjs'
,然后打包进最终产物里面。
这时,我终于发现了一点不对劲,扩展名是 .mjs
。我们的业务代码扩展名是 .tsx
,webpack
认识 .mjs
吗,.mjs
文件是由谁来处理呢?
再次查看 loaders 配置,发现我们还使用了 file-loader
,用来处理非 [/**\.**tsx$/, /**\.**js$/, /**\.**html$/, /**\.**json$/]
。那 .mjs
是不是也被 file-loader
处理了呢?
立刻把 /**\.**mjs$/
加入到 exclude
列表里,再次执行 npm run build
。
编译打包成功。
E2E 测试全部通过。
升级 reselect@5.0.1
成功。
总结
虽然只是一个升级依赖的任务,但是在这个过程中,我也是学到了很多东西。首先,我对 reselect
的理解更深了。其次,我对编译打包这一套在当前项目中的应用也熟悉了。最后,找到问题的核心,小步推进,大胆假设求证,最终解决问题。这种解决问题的方法更是我最大的收获。