原文地址:justinfagnani.com/2019/11/01/…
原文作者:justinfagnani.com/
发布时间:2019年11月01日
将JavaScript发布到npm是一件有争议的事情。在我看来,这本该是一个简单的过程,但却充满了大量相关的选择,这些选择不仅取决于技术因素,还取决于社会因素,比如用户习惯于什么,以及他们的观点是如何以网络为中心的。
Web组件作为JavaScript的超集,在此之上又带来了自己的一系列选择。
下面是我个人关于将Web组件发布到npm的检查单。这个检查表试图最大限度地提高兼容性、标准合规性、灵活性和对用户的有用性。
这些都是我的个人观点,是的,但它们是来之不易的,是经过努力的,希望是有理有据的观点,所以我(有点明显)认为它们确实是正确的方法。
Justin向NPM™发布Web组件的检查表
- 发布标准ES2017
- 发布标准JavaScript模块
- 不要使用.mjs文件扩展名
- 只发布一次构建
- 重要的package.json字段。
- 将 "type "设置为 "module"
- 将 "main "设置为主入口模块。
- 将 "模块 "设置为与 "主 "相同的文件。
- 在devDependencies中包含polyfills,而不是依赖关系。
- 不要捆绑
- 不要将其最小化
- 总是自我定义元素
- 输出元素类
- 不要将polyfills导入模块中
- 使用 "裸露 "或 "命名 "的导入指定器导入依赖关系。
- 始终在导入指定符中包含文件扩展名。
- 发布一个记录你的元素的自定义元素.json文件。
- 包括良好的TypeScript输入法
现在让我们来了解一下每个建议的原因。
发布标准ES2017
现代的JavaScript比同样的代码移植到ES5等老版本的JS上更小、更快、更有能力,而且绝大多数用户的浏览器都支持它。
所以非常希望能把最现代的JavaScript发送给用户。但是如果你一开始就没有现代的JS,你就不能把现代的JS发送给浏览器。如果需要的话,你的包的消费者可以将代码编译到一个较低的语言级别,但是他们不能将你的代码解压缩到一个更新的语言版本。
另一种方式是,只有应用程序才知道他们确切的浏览器支持级别。有些应用程序需要支持非常老的浏览器。有些,也许在Electron中运行,只需要支持最新的Chrome。可重用库无法知道浏览器的要求是什么,应该发布现代的JS,给应用提供最大的灵活性。
但如何现代?这是棘手的部分。我选择ES2017,因为它在Chrome、Safari、Firefox和Edge上的支持非常广泛。这意味着对于大多数浏览器来说,你根本不需要编译。
我曾经试图想出一个约定,来描述发布什么语言版本,比如 "ESY-1",意思是在2019年发布ES2018,但Edge有点下滑,不支持对象传播/休息,所以这个不成立。我想等Edge与Chromium后台一起发货的时候,情况会更清楚一些,希望很快就能看到。
需要支持IE11的用户该怎么办?他们需要在node_modules/里面编译依赖关系。这应该是比较常见的事情,因为它解决了很多JS分发的问题。如果这在某些工具上很慢,我的意见是,这些工具需要更好地缓存中间构建结果,因为你应该只在依赖关系变化时重新编译。
发布标准的JavaScript模块
所有现代浏览器和工具链都支持标准的JavaScript模块,而浏览器并不原生支持任何其他模块格式。这意味着,如果你发布模块,它们可以在未编译的情况下加载。
这对于开发来说特别好,你在代码上做的变换越少,调试体验就越好。原生模块在开发过程中也很好用,不用捆绑。一个静态的文件服务器,对未修改的文件正确发送304响应代码,会最大限度的利用浏览器的缓存,只发送改变的文件。
大多数应用程序还没有向浏览器提供原生模块服务,但应用程序的工具链肯定可以处理模块作为输入,并将其转换和捆绑为应用程序使用的任何格式。
不要使用.mjs文件扩展名
.mjs文件扩展名对浏览器来说是无用的,因为它是有争议的。使用它真的一点好处都没有。浏览器只关心文件的mime类型,而不是文件的扩展名,所以.mjs在那里没有任何作用。而且工具会通过package.json来确定一个包是否包含模块,所以在那里没有任何好处。
不过.mjs也有缺点:不是世界上所有的工具都能理解它。一些静态文件服务器可能不会发送正确的mime-type头,这意味着该文件在浏览器中不会作为一个模块加载。
最好的办法就是完全避免,总是编写和发布模块,并且总是使用.js扩展名。
只发布单个构建
在npm上,发布多个build是很常见的。这是个糟糕且过时的做法,会导致应用捆绑包的臃肿。原因是,多个库可能共享一个共同的依赖关系,但如果他们导入不同的构建,那么这些捆绑包最终会有稍微不同的依赖关系重复。
在web组件的情况下,这尤其危险,因为我们确实需要有一个组件的单一定义。这里的多个版本比单纯的臃肿更让人头疼。
再说一遍,如果应用程序在其构建管道中消耗标准的JS模块,它可以将其转换为任何它需要的单一格式。真的没有必要发布多个构建永远。
我真的建议守住这条线,以防不可避免的问题和PR会要求你添加ES5 UMD构建。
重要的package.json字段
将 "类型 "设置为 "模块"
"type"字段是表示npm包中的JavaScript文件是模块的标准方式。像CDN和bundlers这样的工具可以使用这个来正确解析具有模块解析目标的文件。
有了这个字段,你不需要使用.mjs扩展名,即使在Node中也是如此。
将 "main "设置为主要的入口点模块
这只是标准做法,略有不同的是,现在大多数软件包都会发布一些非模块的构建,并将 "main "指向该模块。将这个字段指向一个模块会让一些人感到不习惯,但它在语义上是正确的,与 "type": "module",而npm需要一个 "main "条目。
将 "模块 "设置为与 "main "相同的文件。
"type": "module"是指定一个包包含模块的最正确的方式 但是工具支持 "module" 字段已经有一段时间了 在所有工具支持 "type" 字段之前,我们还得使用一段时间。
在devDependencies中包含polyfills,而不是依赖关系。
Polyfills是一个应用程序的问题,所以应用程序应该直接依赖它们。包可能需要依赖polyfills来进行测试和demo,所以如果需要它们,它们应该只放在 "devDependencies" 中。
不要捆绑
这个有一点回旋余地,取决于你的包的结构。
这里最重要的建议是不要捆绑依赖关系。这可以防止你通过将依赖关系重复到多个软件包捆绑中而造成臃肿。
只要你不捆绑依赖关系,你可能会决定捆绑你的包以隐藏实现模块。如果你这样做了,请确保你把你的包的所有有效的入口点作为单独的入口点文件保存到一组具有共享块的捆绑包中。
也就是说,如果你支持导入'my-library/element-a.js'和'my-library/element-b.js'就不要把它们捆绑在一起。浏览器的模块加载器就像一个天然的树状结构,它只加载它需要的模块。那么,保持模块相对较小、用途单一是很重要的。让消费者只导入他们需要的部分。捆绑者也会更容易创建小型的捆绑包。所以不要让每个包的捆绑导致应用的臃肿。
我发现更简单的做法是完全不捆绑库。应用程序的构建流水线会根据自己的需要来处理。
不要对库进行最小化
就像建议不要发布小于ES2017的构建和不要捆绑一样,迷你化应该是一个应用关注的问题。调试非迷你化的代码要容易得多,而且迷你化器会随着时间的推移而变得更好,所以不要把这个烘焙到你发布的文件中。
总是自定义元素
声明web组件类的模块应该始终包含对customElements.define()的调用来定义元素。
这是一个有争议的问题,但目前没有其他实际的选择。有些人希望允许组件的消费者选择标签名称,所以他们主张不包含customElements.define()的调用,或者把它放在一个单独的模块中,但实际上这样做并不好。
自定义元素目前要求标签名和类之间有严格的一对一关系。这就意味着,如果一个自定义元素没有自我定义,就会给多个消费者尝试注册该元素,只有第一个消费者会成功。
如果消费者真的想选择不同的标签名,他们可以创建一个元素的微不足道的子类,然后注册该元素。
import {SomeElement} from 'some-element';
customElements.define('my-some-element', class extends SomeElement{});
当我们最终获得范围化的自定义元素注册时,这一切都将改变。然后,消费者将能够将元素注册到一个他们完全控制的范围内,并可以选择任何他们想要的名字。
导出元素类
为了支持上述琐碎的子类模式和一般的子类,你应该导出元素类。
export class MyElement extends LitElement {
// ...
}
customElements.define('my-element', MyElement);
不要将polyfills导入模块中
这是重要的一般性建议,但对于web组件来说,值得重复。
为了配合 "只有应用程序知道 "的口号,只有应用程序知道什么是它的目标环境所需要的polyfills。大多数用户不需要web组件的polyfills,所以一个结构良好的应用程序不会为这些用户提供polyfills,或者会使用webcomponentsjs polyfill loader只在必要时动态加载它们。
如果你的库直接导入polyfills,那么对于应用程序来说,当他们不需要polyfills的时候,把它们拉出来就非常棘手了。
使用 "裸露 "或 "命名 "的导入指定器导入依赖关系。
在Node和npm上,按包名导入外部依赖是一种常见的做法。
Node通过require()和node模块解析在CommonJS模块中原生支持这一点。
const otherLib = require('other-lib');
而现在的开发人员已经很习惯于使用节点模块解析来编写标准的JS模块语法。
import * as otherLib from 'other-lib';
这些被称为 "裸导入指定器 "或 "命名导入",而浏览器实际上并不支持它们!至少目前还不支持。至少目前还不支持。浏览器只支持通过URL导入。因此,所有的导入必须是一个完整的URL(http://),或者是一个以/、./或../开头的相对URL。
让裸指定器在浏览器中工作的情况是,像Rollup和Webpack这样的工具在构建时执行节点模块解析,并在构建整个应用程序时转换这些路径。
所以,如果我们想把标准模块发布到npm上,让它们无需编译就能加载,而且我们有需要导入的依赖关系,我们该怎么办?
我们可以尝试使用相对路径来指向依赖项,就像这样。
import * as otherLib from '../other-lib/index.js';
但这需要我们知道相对于导入模块,other-lib在文件系统中的确切位置。问题是,npm可以在很多不同的位置安装模块,所以我们不可能知道正确的路径。当你的包是顶级包时,路径会有所不同,你的依赖关系可能在./node_modules/,而当你的包是作为依赖关系安装时,路径可能在../node_modules。服务器和构建系统也可以移动文件。
更大的问题是,大多数工具都不理解这种超出包边界的相对路径。根据我的经验,VS Code和TypeScript会弄得很混乱。
所以现在最好的选择是按名称导入依赖关系,让工具在它们到达浏览器之前重写导入说明。这在实践中效果非常好。因为基本上所有的工具都支持命名的指定符,所以那里的兼容性很好,而且已经出现了很多只重写导入指定符的工具,而让各个模块在其他方面不受影响,被浏览器原生加载。
在我的团队中,我们率先使用了Polymer CLI,它在其polymer serve命令中即时重写了指定符,其他工具如es-dev-server和带有?module query参数的unpkg.com CDN也能做到这一点。
在不久的将来,浏览器将支持 "导入地图",它将告诉浏览器如何将名称翻译成 URL,因此它们也将支持命名导入。耶!
始终在导入指定器中包含文件扩展名。
经典的Node模块解析不需要文件扩展名,因为如果没有给定的文件扩展名,它就会在文件系统中搜索寻找其中一个。当你导入some-package/foo时,如果some-package/foo.js存在,Node会导入它。这在网络上是不可行的,所以浏览器不会进行这种搜索。
导入地图将允许将名称映射到URL,但它们只有两种类型的映射:精确和前缀。下面是一个关于lodash的例子。
{
"imports": {
"lodash": "/node_modules/lodash-es/lodash.js",
"lodash/": "/node_modules/lodash-es/"
}
}
这意味着我们可以很容易地映射一个裸露的指定符,比如lodash,或者前缀+完整的文件路径,比如lodash/forEach.js等,但如果要支持无扩展名的导入,比如lodash/forEach,我们就必须把每一个都映射到完整的路径上,比如。
{
"imports": {
"lodash": "/node_modules/lodash-es/lodash.js",
"lodash/": "/node_modules/lodash-es/",
"lodash/forEach": "/node_modules/lodash-es/forEach.js"
}
}
lodash-es有341个模块可以导入。为每一个没有扩展名的导入创建条目会使导入图变得臃肿,所以在导入中只使用扩展名并只有一个前缀导入图条目会更好。
发布一个custom-elements.json文件,记录你的元素
Web组件工具,如IDE插件和目录,开始趋向于用一种通用的格式来描述发布在npm包中的自定义元素。
custom-elements.json文件描述了一个元素所支持的标签名、属性、属性、事件等。有了这些信息,IDE可以提供自动补全、悬停文档等功能;linters可以检查你是否使用了定义的属性;类型检查器可以确保属性绑定的类型正确;文档查看器可以显示信息供人使用;Storybook等目录可以自动生成组件的 "旋钮"。
Rune Mehlsen是一位了不起的开发者,他维护了lit-plugin VS Code插件,还维护了输出这种格式的web-component-analyzer。
包括这个文件将大大提升你的用户的开发者体验。
包含良好的TypeScript输入法
我是TypeScript的忠实粉丝,关于它的类型系统,我最喜欢的事情之一是它如何能够对使用字符串键的API进行类型化。
其中一个例子是document.createElement()方法,它的返回类型取决于传递给它的字符串参数的值。它的返回类型取决于传递给它的字符串参数的值,所以document.createElement('div')返回一个HTMLDivElement,document.createElement('img')返回一个HTMLImageElement。这就让我们这样的代码进行类型检查。
document.createElement('div').src = './image.jpg'; // error!
document.createElement('img').src = './image.jpg'; // fine :)
最好的部分是它基于一个你可以扩展的从标签名到类的映射。它叫做HTMLElementTagNameMap。要扩展它来添加你的元素,使用TypeScript的接口增强。
在TypeScript中,一个完整的自定义元素定义应该是这样的。
export class MyElement extends LitElement {
// ...
}
customElements.define('my-element', MyElement);
declare global {
interface HTMLElementTagNameMap {
"my-element": MyElement,
}
}
当然,如果你不是在TypeScript中写你的元素,你也可以把这个添加到你的输入中。对你的TypeScript用户的好处将是巨大的。
不同意吗?评论和问题?
我知道这些建议中的一些建议是有争议的,而且更细微的地方可以进行解释和辩论,但我坚信这是2019年底发布Web组件的最佳方式。
由于充分的原因,我没有在这个博客上启用评论,但你可以在Twitter上找到我,地址是@justinfagnani。我不能保证我有时间或倾向于辩论这些建议,但我试图回答问题。当然,如果你严重不同意,你也可以发表自己的推荐! 😎