NPM 与前端包管理

oats0300 8年前

来自: http://web.jobbole.com/85067/

我们很清楚,前端资源及其依赖管理一直是 npm 的重度使用场景,同时这也一直是 Node.js 普及的重要推动力。但这类应用场景到底有多重度?这是一个很难回答的问题。这份 “ npm 最常下载的包的清单 ” 并不能提供有效的证据:因为像 async、minimist 和 request 这样的包就像是 “生活必需品”,它们会被数以千计的其它包所  依赖 ,这样一来它们当然会随着那些依赖它们的包一起被不停地下载。

更有意义也更接近真相的问题是:哪些包是人们 主动 安装的?所谓 “主动安装”,就是指某个人(或某个机器人)以实际运行  npm install thispackage  命令的方式来安装一个包。不久前,我们开始把日志数据加入到 Jut  中,随后我们终于可以方便而快速地给出这个问题的答案了。最终,我们得到了 “ 最常主动安装的 npm 包五十强榜单 ”,这份榜单画风突变,很有意思。在五十强中有 32% 的包(它们产生了 50% 的实际下载量)都是前端的工具或框架,携 Grunt、Bower 和 Gulp 一起遥遥领先(当然移动端也是一大重度应用,这里暂且不表)。此外,这些包的使用量也在稳步增长:

(客户端工具的增长,2014 年 1 月~10 月)

另一个渠道也佐证了前端是重度使用场景的这一事实——我们从 npm 用户和 web 开发者那里收到了大量关于如何用 npm 来管理好客户端依赖的提问(和故障反馈)。这些问题通常都伴有极其主观的偏见,令我们感到相当诧异。好吧,那就让我们严肃认真地来澄清一下:

1. “npm 只是为 CommonJS 服务的!”

不对。npm 希望成为 JavaScript 的包管理器,因此,只要是跟 JavaScript 相关的,都适合放入 npm 的包仓库(registry)。虽然 Node.js 提供了一个 “CommonJS 式” 的模块环境,但 npm 对此并不关心。

2. “npm 只是为服务器端的 JavaScript 服务的!”

同样不对。你的包可以包含任何内容,不论是 ES6、客户端 JS,还是 HTML 和 CSS。有很多东西天生就是跟 JavaScript 绑在一起的,那就把它们都放进来吧。

npm 的  《行为准则》  总结了一份非常简短的列表,列出了我们认为不适合放进包里的东西(简单来说:不要把 npm 当作你的数据库或多媒体服务器来用)。对此如有疑问,请通过  推ter  或  Email  询问,我们乐于讨论。

npm 的哲学

npm 的愿景是帮助开发者减少摩擦。我们倾向于通过  “循踪辟径”  的方式来实现这一点。这句话的意思是说:我们不希望告诉用户该怎么做;我们希望观察用户是怎么做的,然后把障碍扫清。如果很多人都是在以各自不同的方式在行事,那我们不会轻易地从中挑出一个胜者,除非最佳实践已经昭然若揭。

那么,在前端包管理的领域中,用户遇到的阻力究竟在哪里?用户踩出的 “踪” 又是怎样的?

前端痛点

除了 GitHub issue 以及 IRC、推ter、技术会议和线下聚会中的用户以外,我们还会跟一些大型前端包的开发者们直接对话——这其中包括 Angular 和 Ember 的开发者(这两者都位列五十强)。他们在解决方案上并不完全一致,但他们的痛点却是大体相同的。接下来我们会一一展开,并讨论如何攻克这些难题:

1.  node_modules  目录并不是按照前端包所需要的方式来组织的

这是一个非常明显的问题。 node_modules  目录是默认情况下 npm 存放包的地方,它得名于 Node.js 的模块加载行为。根据你安装的包的具体情况,所有包最终会被存放在目录树的不同位置。这对于 Node 来说一切良好,但对于 HTML 和 CSS 来说,不管怎样,我们通常都期望所有东西可以汇总在同一个地方,比如 /static/mypackage  这样的目录下。肯定有一些变通方法可以绕过这个问题,但还算不上是最佳方案。

2. 前端依赖在解决冲突方面具有截然不同的需求

Node 模块加载器的一个有意思的地方在于,它允许你同时使用同一个模块的多个不兼容版本;而 npm 的一大有意思的地方在于,它可以将包的这些不同版本放置在合适的地方,从而做到在想要的地方加载想要的版本。这种方式对于避免 “依赖地狱” 有很大帮助,同时这也是 Node 的 “大量小模块” 的实践模式如此实用且流行的原因之一。

但前端依赖却是无法以这样的方式来运作的。如果你在网页中同时加载两个版本的 jQuery,那其中只有一个会 “胜出”。如果你同时加载了两个版本的 Bootstrap CSS 框架,它们会同时起作用,然后把页面样式搞得一团糟。在未来,HTML 将获得新的特性(比如 web components 和  Shadow DOM ),也许有助于解决这类问题;但在眼下,前端依赖会发生冲突。那我们如何优雅地判别并解决这个难题呢?

3. 同时维护多个包清单是很烦人的

前两个问题其实已经有了一种解决方案,就是为前端包额外配备其它的包管理方案。但这会产生这样一种局面——单个项目可能会同时包含一个  package.json  文件、一个  bower.json 、一个  component.json  等等。每当遇到哪怕是一丁点儿更新时,你都要把所有这些配置文件通通编辑一遍。跟所有的数据冗余一样,这种情形不仅烦人,而且容易产生错误。

4. 找到兼容浏览器的包很痛苦

npm 是为 JavaScript 服务的包仓库,但目前库中绝大多数的包都是 Node.js 包。在采用  Browserify  等工具做过适配之后,某些模块是可以在客户端运行的,但还有很多仍然是不行的。目前,如果要判断某个包是否在浏览器端可用,除了实测,似乎还没有一种简单易行的方法。

前端解决方案

在找出了以上四个难题之后,让我们来逐一讨论如何解决。

上面提到的最后一个难题是最容易克服的,我们已经开始为解决方案奠定基础了。这个解决方案就是: 生态圈

生态圈是指包仓库的一些可搜索的子集,这些子集是通过程序化地筛选库中的所有包而产生的,筛选条件是诸如 “可在浏览器中运行” 或 “可在 Windows 上运行” 或 “兼容 Express” 等数以百万计种可能性。此功能一旦上线,必将会有一个叫作 “兼容 Browserify” 的生态圈,而其它名称比如 “对客户端友好” 也肯定会出现。这将是一个非常棒的解决方案,我们对此非常乐观。接下来,让我们着手处理剩下的三个难题。

客户端的包安装与依赖解析

第三个问题——多套包管理系统——实际上是前两个问题的副作用。现在已经有一些第三方工具试图缓解客户端的包安装和依赖解析问题,它们通常需要建立各自独立的包仓库和配置文件格式。这类解决方案层出不穷,每一种解决方案都有其长处和短处。不过,从上面的统计数据中可以看出,目前为止,在这方面最流行的解决方案是 Bower。那么接下来,请允许我们暂时忽略其它优秀的包管理器,重点关注一下 Bower 是如何工作的。

Bower 的解决方案

Bower 可以通过名称来安装包,也可以通过 Git URL 或任意 HTTP URL 来安装,这些都跟 npm 是一样的。但跟 npm 不同的是,Bower 会把每个包都安装到  bower_components  目录下的独立目录中,整个目录结构是扁平的。举例来说,如果  backbone  依赖  underscore ,那么  bower install backbone  将会把 backbone 和 underscore 这两者都放置在  bower_components  目录下。这意味着,从一个 web 应用中引用一个组件是非常简单的,因为它总是会被安装在相同的地方——这跟 npm 不同,因为 npm 包的实际安装路径并不固定。

扁平的目录结构存在一个问题,如果你试图安装同一个库的两个不兼容版本(比如 jQuery 的 1.11.1 版和 2.1.1 版)时,它们将会被安装到相同的位置,并发生冲突。如果发生了这种情况,Bower 会要求你手工选择哪个版本是你想要的,并且可以决定是否把这次选择的结果保存到  bower.json  文件中。这个过程存在不确定因素,它依赖人工干预,因此两个人在安装相同的依赖包时可能会得出不同的安装结果。不过一旦你把你的选择结果保存到了  bower.json  中,就不存在变数了——任何人在安装你的项目时都会得到相同的安装结果。

这种体验没有 Node 环境那么好,因为后者遇到的版本冲突可以在无需人工干预的情况下自动解决。总的来说,它照顾到了前端开发者的关注点,而且它确实也干得挺不错的。

现在还无法选出胜者,但我们还是想减少摩擦

我们并不想操之过急。尽管 Bower 已经十分流行了,但眼下仍然还有不少其它的包管理方案可用。同时,浏览器也在持续地快速演进,因此我们认为,现在就对前端包管理方案下结论还为时过早。正是基于这种考量,我们不久前在 《npm 命令行界面(CLI)线路图》 一文中提出了以下重要策略。

我们计划把 npm CLI 模块化,将其设计为各个分离的部件。这些部件不仅作为 npm 客户端的一部分而存在,还可以独立地被程序所调用。底层的目标是令其他人可以在 npm 这个基础之上编写工具——如果 npm 中已有对他们有用的部件,那他们就可以重用;如果没有,他们也可以自行实现自己的解决方案。实现这个目标的方法,并不是把 npm 改造成配置选项、开关、生命周期钩子所组成的一坨大杂烩,而是将其模块化。

模块化 CLI 的完整设计还未定稿,但显然会包含以下几大部件:

  1. 一个用来从包仓库中下载包的 API
  2. 一个可以在本地存储、读取并且解压缩的 “缓存” API
  3. 一个安装器 API,可以把包放置到你的项目中的合适位置

我们应该已经说得非常清楚了,相信任何前端包管理器都想用上第 1 和第 2 条,然后重新实现第 3 条。

使用 npm 来构建你自己的前端包管理系统

如果你打算在今天构建一个理想的前端包管理系统,那它会是什么样子的呢?

中期来看,我们所能想像到的官户端包管理系统将是这个样子的:

1. 别去运营你自己的包仓库了,直接用我们的

这并不仅是自私自利:除了我们之外,还有一些人在运营着自己的包仓库,但他们给我们的反馈都是再也不想继续下去了。维持包仓库的稳定、高效、以及必要的客户支持都是十分昂贵、困难和耗费时间的。而且从任何意义上来说,“托管包” 都不是客户端包管理器想要解决的问题。如果包是跟 JavaScript 相关的,那就托管到 npm 吧。一旦生态圈功能上线之后,就可以通过它来在全局库中创建 “微型库”,通过自定义搜索的索引来充实其内容,并显示其特征。(译注:我其实不确定后半句在说什么。)

2. 采用 package.json 作为配置文件

如果你的工具需要一些配置信息才能工作,那就把它放进 package.json 文件中吧。似乎未经询问就这样做稍显粗鲁,但我们在此发出邀请:但做无妨。npm 的包仓库是一个无模式限制的(schemaless)存储空间,因此你添加的每个字段都具有和其它字段一样的地位,我们既不会清除这些新字段,也不会因为存在新字段而报错(只要新字段没有跟现有的字段冲突就行)。

我们也意识到这可能会带来一种风险,产生一堆互不兼容的配置信息,因此,请适度使用:千万要抵御住诱惑,不要试图抢占一些通用的字段名,比如  "assets"  或  "frontend"  等等。用一个特定的、代表你的应用的标签就好,比如  "mymanager-assets"  或  "mymanager-scripts" 。在未来,如果我们决定更加明确地支持你的功能,并为你分配一个通用字段,那也是很容易实现对旧字段名的向后兼容的。

3. 采用我们的缓存模块

在规模化的情况下,解压缩、存储并缓存包其实是一个非常复杂的问题。因此,如果你是在使用我们的包仓库的话,那么一旦缓存模块可用,你就应该立即用上它。它将会节省你的精力、时间和带宽。

4. 编写你自己的前端包行为

你的使用场景肯定跟 npm 以 Node 为中心的行为大相径庭,因此这是唯一一块你需要自己搞定的部分。即便如此,我们还是会提供一些顺手的模块来帮助你。你可以做到和 Bower 一样的效果,比如把前端包下载并安装到一个完全不同的目录中,然后自行处理依赖关系。或者你可以让 npm 把所有东西都安装到 node_modules  目录中,然后利用一个 post-install 脚本或一个运行时钩子来解析依赖,或以上策略的某种组合。我们不确定哪条路是最佳选择,这也是我们鼓励大家在此深入探索的原因。

我什么时候可以开始动手?

一旦我们讲清楚了这个计划之后,接下来每个人都会问出这个问题。我们只能说:可能是明年(译注:2015 年)的某个时候。将 npm 改造成上述效果所需要的工作早已启动了,但 npm 公司的首要任务是得先让自己成为一个自给自足的实体,这也是为什么我们会在 2015 年早期专注于发布  私有包  服务。在此之后,我们的下一个专注点应该就是扩展包仓库自身的实用性了,届时将是客户端包管理功能的登场之时。

我现在可以做什么?

我们将对此提供支持,这确实没错,但这个问题现在就横在你的面前啊!那你眼下可以做些什么呢?

1. 使用我们的包仓库

没有理由不这么做。它很快,它的可用性高达 99.99%,而且它对开源项目是(并且永远都将是)免费的。

2. 采用  package.json  作为配置文件

同样,没有理由不这么做。它是你的包,就用你想要的方式来描述它吧。要注意避免数据重复(不要另外弄出一个你自己的  "name"  字段),并且避免通用的字段名,除此以外,你就放手去做吧。如果你发觉自己对 package.json  的使用方式有些怪异或复杂,随时可以通过 IRC、 推ter  和  Email  找到我们——如果你想先跟我们通个气的话。

3. 给你的包打标签

目前 npm 的  "keywords"  字段在某种程度上利用得还不够,其实它可以用来清晰地声明包与某个生态圈的从属关系或兼容性,即使这个生态圈还不存在也没关系。举个例子,如果我给一个包打上 “ ecosystem:hapi ” 的标签,那你就可以用这个标签搜到它了。这种方式明显不能像一个真正的生态圈那样好用,因为它不具备(将来生态圈功能将会提供的)自动的验证机制,但这总比模糊不清的关键字要好。

4. 使用生命周期脚本,以及 Browserify

使用  生命周期脚本  来管理那些通过 npm 安装的客户端资源,并不是一个完美的解决方案,但我们认为这个方向值得探索。比如说,你可以设置一个  "postinstall"  脚本,用来把 npm 安装的包移动到一个扁平的目录结构中,并处理依赖关系。这种方式肯定不够完美,但如果你把它作为救命稻草来用,我们会乐于关注你在这条路上能走多远,而你的痛点也将为我们接下来的行动带来启发。

我们还认为 Browserify 是非常棒的工具,但远没有得到充分利用。如果在安装时把它作为一个端到端的解决方案来使用,将是一个非常有创意的想法。(请查阅 Browserify 的  温馨手册 ,那里有非常棒的文档,会告诉你如何用好它。)

请再坚持一下

前端开发者希望不再同时使用多个包管理器。包仓库的运营者们也已经厌倦。目前 npm 对前端包管理的支持确实还不够好。我们知道、我们同意、我们承诺会让事情变得更好。前端开发者们,npm 爱你们,而且我们关心你们的使用场景。我们自己也使用 npm 来构建自己的网站,我们也有着同样的痛点。因此,请继续向我们提供反馈和建议。我们正在为之努力。

最终,胜者必现

我们的最终观点是有必要明确一下的:我们期望有一个解决方案能浮出水面,它是如此直观、如此易用,以致于我们可以 “仰慕” 它,甚至把它内建到 npm 中或将它绑定为 npm 的一部分。当我们这样做的时候,不希望人们认为我们是在偷换概念,因为我们曾许诺要维护一个良性的竞争生态,但结果我们又挑出了一位胜者(我们知道这个做法在其它公司身上曾出过问题)。但事实上 最终必将出现一位胜者 :我们只不过是到了那个时候才知道它是谁,而已。

如果你已经强烈地预感到那个终极方案是个什么样子,就去实现并推广它吧,这比在 GitHub issue 里写长篇评论要强一万倍;同样,对于每个处在 Node 社区的人来说,这也是极其受用的。因此,大步向前,去构建解决方案吧,我们会密切关注的!

译者后记

最初同事将这篇文章推荐给我时,我没有读下去。当江湖传闻 Bower “要完” 时,我再次翻出了这篇文章,并将它翻译了出来。

但译完之后,坦白地说,我有些失望。npm 在这篇文章中并没有提供任何有效的解决方案,只是期望 “美好的事情必将发生”。这篇文章发表于 2014 年底,但直到现在 npm 也拿出文中提到的 “生态圈” 功能;这一年多来,前端包管理领域也没有浮现任何真命天子般的终级解决方案。

不过,在前端开发者这一端,包管理的实践风向倒是发生了不小的转变。最明显的潮流就是 “放弃 Bower,直接采用 npm”。这背后的推力,一方面是越来越多的 npm 包采用 UMD 作为发布方式,网页直接使用也无压力(当然我们也可以认为这一点与上述潮流互为因果);另一方面,前端资源的构建过程已成常态,在页面中通过 <script> 标签直接引入脚本的情况越来越少了,Bower 的独有价值也就少了很多。此外,npm3 的扁平化目录结构也进一步瓦解了前端开发者的心理防线。

如此看来,npm 动作虽慢,但斗转星移,自己却被推到浪潮之巅。这篇文章已无时效,但读起来仍然很有意思,令我们有机会一窥这家公司的思维方式与价值观。