英文:Lea Verou,翻译:前端大全 / 1bite
今天早上,我在构思写一个库来包装及加强 querySelectorAll 时突然想到,相比直接引入 Parsel,更好的做法是先检测它是否已经加载,如果已经加载,就用它来做解析;如果没有加载,就用自己手撸的正则表达式做解析(反正根据我这个库要做的事情来看,这个方案足以覆盖大部分场景)。
以前,由于每个库都会加载到全局名字空间中,所以用以下代码就能搞定:
if (window.Parsel) {let ast = Parsel.parse();// 根据AST可以正确的重写选择器}else {// 正则表达式方案}
然而,在 ESM 模块系统里,似乎没有办法检测某个库是否已经导入过了,除非你自己的代码显式导入过。
为这事我还专门发过一条推:
ESM 模块系统(及其它模块系统)的一个缺点是,你无法指定可有可无的依赖项。
使用全局名字空间,你可以先检查某个全局变量是否存在,然后走相应的分支。而使用 ESM 模块,就无法检测某个库是否已经导入过,除非你已经在某处导入过。
— Lea Verou (@LeaVerou) 2020/11/19
我以为这个情形很常见,大家都应该能理解这种做法的好处。然而,当我发现网上有不少人并不理解我要干啥时,我还是很惊讶的。他们中大部分人以为我想做的是模块条件导入或者模块导入失败后的错误恢复。
我想了一下,可能是因为我是从库作者的角度思考如何写 JS 的。作为一个库作者,我是无法控制宿主环境的。但对于大多数开发者而言,他们是以开发某个具体的 APP 或者网站的角度去思考如何写 JS 的。
在 Kyle Simpson 要求我详细阐述使用场景后,就有了这篇文章。
我描述的情形本质上是一种 “渐进式增强”(实际上,我曾经还想过把这篇博文取名为 “JS 之渐进式增强”)。若库 X 已经被其它代码加载了,则用它完成更复杂但覆盖了所有边界情况的功能;否则只保留一些基本功能。这套方案针对的是那些自己的代码不真正“依赖”的依赖项,也就是那些锦上添花的依赖项。
我们经常会看到这样的现象,有些模块功能虽然非常完备,但就是使用了一堆依赖项。把这样的库添加到即使是最简单的项目中,也会让项目突然变得很庞大。究其原因,是这些库需要考虑各种意外情况,而我们的项目可能并不关心这些边界情况,甚至这些边界情况都不会出现在我们项目中。我们也经常看到另外一种现象,有些模块虽然是零依赖的,但又重造了很多现有的轮子,或者干脆缺失某些功能。
而我提出的这种范式,则兼具上述两个优点:零依赖(或者少依赖),又能利用系统中已经导入的模块加强自己还没有副作用。
使用这种范式,依赖项的大小就不再是个问题,因为它们现在是可有可无的同版本依赖,从此你可以尽情挑选合适的库,再也不用被包体积束手束脚。而且,同时用多个也可以!不止一个库能助你实现需求,如果系统中已经存在一些更庞大、更完备的库,就直接用它们;而如果它们还没有加载,就回退至那些微型库。
再举几个场景:
-
如果系统中已存在 Prism , Markdown 转 HTML 的转换器就可以支持代码语法高亮,甚至支持多个语法高亮器。
-
如果系统中已存在 Icrementable ,代码编辑器就可以用它实现箭头键控制数值自增。
-
如果系统中已存在 Dragula ,模板库就可以用它来实现列表条目拖拽排序。
-
如果系统中已存在 Tippy ,测试框架就可以用它来实现更友好的提示信息弹出框。
-
如果已经加载了某个能计算代码体积的库,代码编辑器就可以用它来显示代码体积(以 KB 为单位);如果 gzip 库能用,则这个代码编辑器可以用它来显示经 gzip 压缩过后的代码体积。
-
如果能用某自定义元素,UI 库就可以先尝试使用该自定义元素,否则,就使用功能相近的原生元素(比如某个超炫酷日期选择器 vs <input type="date">)。又比如,系统中已存在 Awesomplete ,则可以用它来实现自动补全,否则就使用简单的 。
- 如果系统中已存在某日期格式化库,那么我们的代码就可以用它来格式化日期;否则就使用 Intl.DateTimeFormat。
这个模式甚至可以与条件加载相结合,例如:我们可以检查所有已知的语法高亮器,如果都没有加载,再加载 Prism。
回顾一下,这个模式优势主要体现在以下方面:
* 效率方面:比如使用网络加载模块,而 HTTP 请求代价很高的时候;比如直接打包到包里,又会增加包体积的时候。就算包体积不是问题,如果在不需要的时候走虽然周全但相对较慢的路径,也会影响运行时性能,因为这时简单的逻辑就够用了。
* 选择性方面:比起一个功能就选用一个库,现在可以支持多库备用。例如:多款语法高亮器,多款 Markdown 语法解析器等等。如果你要完成的功能一定得要某个库,也可以在其它能支持的库都没有加载的情况下再加载它。
弱依赖是反模式吗?
这篇文章发布后,我收到了一些这样的反馈:“弱依赖是一种反模式,因为你无法预测使用了这种模式的模块的行为。如果你引入了某个库,又不想其它库使用它,这时该怎么办?这种情况下,使用参数注入来显式提供这些库的引用会更好。”
对此,我有几点不同的看法。
首先,如果弱依赖项运用得当,它们只会被用来加强缺省/基础行为,所以不太可能不使用弱依赖项而回退到缺省行为。
其次,弱依赖项与参数注入的方式并不冲突。它们可以一起使用,并互相完善。比如弱依赖项可以用来选择更合理的缺省库,然后再使用参数注入方式做进一步调整(或者完全禁用)。只保留参数注入会给使用库带来高昂的前期认知成本(参考 约定优于配置)。好的 API 让复杂的事变简单,让简单的事变容易。 常见的例子是,如果加载了某语法高亮模块,你肯定希望用它来做语法高亮;如果加载了某解析器模块,你肯定是首选它做解析,而不会选择正则表达式。而那些不太常见的边界情形,比如你不想做语法高亮或者想用另外一个解析器模块,仍然能用参数注入方式实现,但并不代表其它方式就不能实现。
最后,最终开发者可能并不知道已经加载了所有库,也就是说,开发者完全有可能因为别的原因引入了某个库却浑然不知。而弱依赖项模式是有能用的库就用,没有就不用,所以不存在引入多余库或者需要提前准备好相关库的这类问题。
这种模式如何与 ESM 兼容?
还是有人(大部分为库作者)非常理解我提的问题,他们也提出了一些方案。
方案1: 在底层实现一个全局模块缓存,而 CJS 就自带这种东西。
CommonJS 就暴露了缓存 … 也许 ESM 也可以做到,缓存失效也容易做,代码覆盖率测试也不难 … 不过我的测试代码全是 CJS 的,不太想改