查看原文
其他

JavaScript 弱依赖项的使用场景

1bite 前端大全 2021-02-08

(给前端大全加星标,提升前端技能

英文: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 的,不太想改😔

— Andrea Giammarchi 🍥 (@WebReflection) 2020/11/19

方案2: 全局注册中心,模块可以自己注册,用 ID 或者 SHA 哈希都行。

方案3: 引入一个 API:import.whenDefined(moduleURL), 这个 API 返回一个 Promise。不过,这个方案无法处理那些根本不会被加载到的模块。

一个不优雅的方案:

globalThis[Symbol.for(moduleName)]=true;

把这段代码加到主模块导出/文件即可

我用 uce 的时候见过一种传递 uce 的办法,customElements.whenDefined('uce-lib').then(…), 不过如果没人去导入 uce 就什么也不会发生

import.whenDefined(module).then(…) 了解下?

— Andrea Giammarchi 🍥 (@WebReflection) 2020/11/19

如果模块有导出 SHA 就好办了

— James Campbell (@jcampbell_05) 2020/11/19

方案4: 监控 <link rel="modulepreload"> 。不过问题是并非所有的模块都会用这种方法加载。

有些不成形的想法:

> 如果网页里面有相应标签,那么表示要用的库不是已经加载好了,就是快要加载好了(已经加载中了),这应该是个非常安全的信号。

> 还可以在相应标签上加个 load 事件处理器,监听加载完成事件。

— getify (@getify) 2020/11/19

方案5: 我觉得可以搞一个类似 import() 的函数,这个函数返回一个 Promise,模块加载好了就 resolve 模块的导出(和正常导入一样),失败就 reject。我们还可以给这个函数加一个参数:

import("https://cool-library", {weaktrue});

以上几乎全部的方案都无法完全解决以下问题。

那些 基于 URL 的方案意味着只有从相同 URL 加载的模块才被视为同一模块,而相同的模块从本地加载的和从 CDN 加载的就会视为不同的模块。

一个解法是暴露一个 URL 列表,就像方案1一样,然后监听这个列表的更改。再逐一检查这些 URL,找到疑似属于目标模块的 URL 后,再做进一步检查,这时我们可以动态导入这个模块并检查它的导出(导入一个已经导入的模块是非常廉价的操作,浏览器会避免重复请求)。

那些 基于 ID 的方案则依赖模块自己用 ID 注册,因此只有那些想暴露的模块才会这么做(译注:意思是如果有模块不想注册或者忘了注册,方案就失效了)。这跟以前使用全局名字空间的时代的感觉差不多,不过应该只在过渡期有这个问题,等大多数库适配后情况就会好转。还有个潜在的问题:名字冲突。虽然 API 可以采用解决哈希冲突的办法解决这个问题,但是从注册中心拿数据的代码就需要再做一次筛选。

译注:

作者 Lea Verou 提出的方案,确实可以一定程度上解决 JS 依赖项膨胀问题,但是确实也有质疑声中提到的行为不可预测的问题。


比如你依赖了 A 模块,A 又依赖 B 模块,这两个模块都使用了这种技术,只有基本功能,这时你的代码可能会因为 A、B 两个模块不够健壮而遭遇 BUG。为了让你的代码健壮一点,你需要仔细阅读这两个模块的使用文档,得知需要引入 C 模块才能解决。一旦依赖路径非常深,事情就困难了。


另外,A、B 两个模块可能会为了让自己健壮一点而强引入 C 模块,这时就跟没有采用文中的技术没有差别。或许,应该要支持强行排除一些模块?



- EOF -


推荐阅读  点击标题可跳转

1、JavaScript ES12 新特性抢先体验

2、你需要了解的几种 JavaScript 异常类型

3、由浅入深,66 条 JavaScript 面试知识点


觉得本文对你有帮助?请分享给更多人

推荐关注「前端大全」,提升前端技能

点赞和在看就是最大的支持❤️

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存