前端工程通常会依赖大量的 npm package。一般来说,工程师除了被 node_modules 吞噬了巨量的硬盘空间,偶尔也会面临一些小小的故障:某个依赖包出了问题。
背景
如果是非常严重的问题且依赖包下载量很高,那么开发者社区会很快推出新的版本来修复错误。
但是,如果依赖包流行度很低,或者是细节上尚且有些模糊的问题,或者是为了解决某个错误而更新依赖的版本后引发了更复杂的兼容性问题,前端工程师就亟需一个临时方案从而期待社区统一意见后的方案了。
比如笔者碰到过一个实际的问题,在开发微信小程序时,采用的框架 Remax.js 对微信原生接口统一做了 promisify 处理后打包暴露给了调用方,方便开发者用更现代的 async/await 机制代替原生的 callback 方案。但是,在微信 2021 年 10 月更新了隐私政策要求后,会检查提交的代码包中使用的 API,这样就会检测出大量涉及到用户隐私的 API(但实际上开发者未申请权限调用)的情况。由于 promisify 处理的接口是直接暴露的,webpack 无法简单地通过 tree-shake 方式移除未实际调用的 API。该问题预计在 Remax 2.0 这个大版本依旧会存在,详见 issue。
从 issue 的历史回复中也能看出来,由于社区的维护力量不足,从问题提出到一个有效的方案持续了近两个月(该方案又花了两个月时间迭代才算差强人意)。值得注意的是,期间也有人提出了用 webpack 的 ignorePlugin 指定删除某些原生接口,笔者采用的方案则是更进一步:直接给依赖包打补丁。
临时解决方案
根据依赖包的特性,我们先讨论如下的方案:
- 直接把依赖包抽离出来,不再在 package.json 中引用,而是添加到项目文件中。
- 修改依赖包的文件,考虑到代码的版本控制,又有两种思路:
- 添加项目初始化的脚本来覆盖 node_modules 安装后的文件。
- 把修改的依赖包重新发布到私有仓库,然后修改相应的依赖。
- 依赖包不作处理,通过 webpack 或劫持 js 对象的方式修改依赖包的属性。
这三种方案都存在一定的问题,要么对项目工程破坏性太大,要么流程太复杂、限制太严格。而一个更为严重的,或者说不雅的问题是:补丁代码无法自描述
,后续的项目维护者无法识别一系列的操作对工程的变更,补丁本身的内容被复杂的信息遮盖住了。
从 Linux 的 patch 说起
让我们视角移动到人类历史上最知名的开源项目之一 Linux
上,我们会发现,有无数的开发者会 hack 部分代码来提交 bugfix 或者满足特定的场景需求。
而打补丁的操作在 Linux 中就是两条命令。
第一步,比较两个文件(夹),记录二者的差别。
$ diff [OPTION]... FILES
一个 diff 的示例如下:
--- test0 2006-08-18 09:12:01.000000000 +0800
+++ test1 2006-08-18 09:13:09.000000000 +0800
@@ -1,3 +1,4 @@
+222222
111111
-111111
+222222
111111
第二步,将 diff 后的结果应用到相应的文件(夹)上。
$ patch -pnum <patchfile
就是这么简洁、优雅。
patch-package 的使用流程
在茫茫 npm package 中,有一个名为 patch-package 的包,其思路与 Linux 中的 patch 如出一辙。而且更进一步的,它还提供了一套相应的版本控制方案。
参照 patch-package 的项目主页,本文简单概述一下流程:
-
在 package.json 中添加,
"scripts": { + "postinstall": "patch-package" }
-
安装依赖,
$ yarn add -D patch-package postinstall-postinstall
-
确认依赖包修改完成后,运行命令,
$ yarn patch-package [package-name]
大功告成!
如果一切顺利的话,你会在项目根目录下发现类似 patches/package-name+0.44.2.patch
的文件。将该补丁提交到 git 中,后续初始化项目会安装依赖包后自动打上该补丁。
👉 一点延伸
不仅限于 npm 的 patch-package,知名编程语言的包管理器大多存在类似于 patch
功能的插件。
例如 Maven 的 maven-patch-plugin、Composer 的 composer-patches、RubyGems 的 gem-patch、CocoaPods 的 cocoapods-patch。
从逻辑上来说,在编程语言设计依赖管理时,如果依赖作为 vendor
在项目根目录下管理,那么 patch
是非常简单的,按照 patch-pacakge 同样的思路改造初始化过程就好了。
不过,如果包管理的设计是以 Mogorepo 为前提(比如 Go 和 Python),事情会稍微有点麻烦,patch 的控制范围就变得难以限制,容易引起全局冲突。好在 Python 中有 virtualenv
这样的隔离工具,就避免了工程之间的冲突(这里顺便提一下,推荐 paver 来构建 Python 工程);而 Go 则可以借助 Go Modules 的能力指定依赖位置,从而在本地利用 patch 解决问题。