翻车?!我只是升级一个依赖而已

背景

最近在做项目依赖升级。因为我们的 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 的细节就不细说了,简单概括一下。

  1. 4.0.0 的下一个版本是 4.1.0,用 TS 重写了整个代码,对 TS 的支持从 v2.x 提高到 4.2。
  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

仔细阅读 webpackmodule resolutionexports 相关的文档,结合 reselect@5.0.1exports 字段:

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。我们的业务代码扩展名是 .tsxwebpack 认识 .mjs 吗,.mjs 文件是由谁来处理呢?

再次查看 loaders 配置,发现我们还使用了 file-loader,用来处理非 [/**\.**tsx$/, /**\.**js$/, /**\.**html$/, /**\.**json$/] 。那 .mjs 是不是也被 file-loader 处理了呢?

立刻把 /**\.**mjs$/ 加入到 exclude 列表里,再次执行 npm run build

编译打包成功。

E2E 测试全部通过。

升级 reselect@5.0.1 成功。

总结

虽然只是一个升级依赖的任务,但是在这个过程中,我也是学到了很多东西。首先,我对 reselect 的理解更深了。其次,我对编译打包这一套在当前项目中的应用也熟悉了。最后,找到问题的核心,小步推进,大胆假设求证,最终解决问题。这种解决问题的方法更是我最大的收获。

相关推荐
A XMan.2 小时前
JSON结构快捷转XML结构API集成指南
xml·java·前端·json·php
小林爱2 小时前
【Compose multiplatform教程06】用IDEA编译Compose Multiplatform常见问题
android·java·前端·kotlin·intellij-idea·compose·多平台
蜗牛快跑2135 小时前
前端正在被“锈”化
前端·代码规范
Jet_closer_burning7 小时前
微信小程序中遇到过的问题
前端·微信小程序·小程序
掘金酱8 小时前
稀土掘金社区2024年度影响力榜单正式公布
android·前端·后端
Keven__Java8 小时前
Java开发-后端请求成功,前端显示失败
java·开发语言·前端
轻口味8 小时前
【每日学点鸿蒙知识】渐变效果、Web组件注册对象报错、深拷贝list、loadContent数据共享、半屏弹窗
前端·list·harmonyos
老K(郭云开)8 小时前
最新版Chrome浏览器加载ActiveX控件技术——alWebPlugin中间件V2.0.28-迎春版发布
前端·chrome·中间件
轻口味8 小时前
【每日学点鸿蒙知识】子窗口方向、RichEdit不居中、本地资源缓存给web、Json转对象丢失方法、监听状态变量数组中内容改变
前端·缓存·harmonyos
我是苏苏8 小时前
Web开发:ORM框架之使用Freesql的分表分页写法
前端·数据库·sql