前端模块化演进与bundleless的探讨
近两年来 bundleless 这个词频繁地出现在我的视野中,渐渐觉得 bundleless 也许是大势所趋(直觉),本文不打算去比较 bundle 和 bundleless 的优劣,而是从模块化的角度来谈 bundleless 的实现方案。
历史问题
不知道大家有没有想过前端需要打包(bundle)的本质问题,为什么以前前端是不需要打包的?什么时候开始需要打包了?
2009 年 Kevin Dangoor (CommonJS 作者)发了一篇文章what server side javascript needs,从 server 的角度去做了一些分析,表达了希望 JavaScript 在 server 端支持的东西。
下面摘抄了对现代 JavaScript 影响比较大的几段
JavaScript needs a standard way to include other modules and for those modules to live in discreet namespaces. There are easy ways to do namespaces, but there’s no standard programmatic way to load a module (once!). This is really important, because server side apps can include a lot of code and will likely mix and match parts that meet those standard interfaces.
There needs to be a way to package up code for deployment and distribution and further to install packages. Linux people will correctly point out that they can just type “apt get” (or yum, or whatever) and their work is done. But there are a lot of people using Macs and Windows who need a convenient way to get their development environments set up and to package up the code they write for deployment and for other people to use.
Part of the distribution and installation problem is a package repository. I don’t know if JSAN is the answer there, but I do know that an easy way to install a package and its dependencies makes a huge difference in how many libraries people will likely pull together in their apps.
Kevin Dangoor 希望 JavaScript 支持模块,希望有类似 Java、Python 的模块仓库,以及打包部署工具,这些东西在现在都被实现,Kevin Dangoor 还亲自带队实现 CommonJS,开启了 JavaScript 的模块化时代,但是在项目开始的时候,这个项目叫 ServerJS,因为开发重点和上面的愿景一致,在于支持 sever 端 JavaScript 的模块化。
2009 年 NodeJS 诞生,引起大量开发者的关注,2010 年 npm 诞生,能为 NodeJS 提供更好的模块分享发布类库的能力。可见,无论是最初的愿景还是历史的发展,模块更多的是为服务端(非浏览器)服务,所以对于服务端对模块的引用方式往往在前端不能实现,比如文件之间引用可能采用无扩展名或者前缀缩写等,这样浏览器无法识别,所以普遍前端项目使用模块都是需要经过打包的。
所以真正的答案出来了,本来模块化不是为前端(浏览器)设计的,而随着 npm 庞大的生态建立起来,前端也借助 npm 模块做一些事,所以打包是前端接入或者说继承 npm 生态的一种方式,使 npm 包能在前端发挥作用。这种情况一直到 es6 引入 ESModule 模块标准也没有改变,只是前端不用 require 而改用 import 了而已,本质上是前端习惯依赖庞大的 npm 生态而不得不妥协。所以因为前端没有文件系统,不能像 Node 那样直接引用模块,才导致如今需要打包。
来自 Deno 的启发
2020 年 Deno 1.0 的发布引起了广大开发者的关注讨论,不论最终如何发展,其模块引用方式无疑为大家开辟了一条新思路,在Deno Blog中提到:
With the changing JavaScript language, and new additions like TypeScript, building Node projects can become an arduous endeavor, involving managing build systems and other heavy handed tooling that takes away from the fun of dynamic language scripting. Furthermore the mechanism for linking to external libraries is fundamentally centralized through the NPM repository, which is not inline with the ideals of the web.
可见 Deno 的参与者认为,npm 的模块设计方式已经不适合现代 JavaScript,并且对浏览器端来说很不友好。2018 年 Ryan Dahl(NodeJS 和 Deno 作者) 在 JS 大会上做「Design Mistakes in Node」主题演讲也证实了这一点,Ryan Dahl 认为 node_module 和 package.json 是不好的设计,具体可以参看文末的视频和PPT。
那 Ryan Dahl 是怎么解决的呢,Deno 完全抛弃了 npm 的生态,每个模块的引入都是通过 http 链接,这是一个伟大的创举,正如前面所说的是一种对浏览器更友好的方式,因为这样做可以不依赖文件系统。
ESM.SH
前段时间无意间看到的一个有意思的项目,作者并没有阐明写这个项目的目的,能看出和 Deno 的思想如出一辙,或许是受到了 Deno 的启发,配上现代 JavaScript(ES6+),能够实现类似于 Deno 的 http-import,算是一种真正意义上的 bundleless 实现方案。
esm.sh 是一个模块包的的 CDN 站点,可以通过 url 来获取 npm 包的 bundle 文件。
以最常用的 React 为例:
1 |
|
这是一段完整的 HTML 代码,直接在浏览器中打开运行,成功地渲染出了「hello world」段落,完全不需要前端打包的环节:
实际上看似没有打包的代码也是经历了打包的,看看 import 指向的内容就知道了
它指向的内容已经是打包后的 React 库代码,这便是 esm.sh 所做的事情,可以将 esm.sh 看作是 npm 仓库,通过 url 来获取想要的 npm 包,它将始终返回打包后的单文件。在没有 esm.sh 之前,我们也可以将链接指向某个库的 bundle 文件,但是每个 bundle 都需要自己打包并且部署到服务器或者 CDN 上,非常麻烦,而通过 esm.sh,这一切都在无感知的情况下被完成了。
与 Deno 不同的是,同样是可以配合 import 语句实现 http-import,但是并没有像 Deno 那样完全抛弃 npm,而是站在了 npm 的肩膀上。当 esm.sh 收到请求之后,通过 url 来判断你需要的包和版本等信息,然后去找 CDN 上有没有对应的缓存,如果 CDN 缓存中有该包,则直接返回缓存内容,如果没有,再通过 yarn 下载 npm 包,然后通过 esbuild 进行打包、进行缓存,最终将你需要的包作为单文件返回。
使用 esm.sh 有如下优点:
- 能够获取几乎所有公共的 npm 包
- 简单,上面说的复杂流程完全不需要前端去参与
- 快速, esm.sh 项目将内容缓存到了 CDN,并且打包使用 esbuild,即使是在没有 CDN 缓存的情况下,也能很快地打包
- 支持 treeshaking,以减小体积
import map
引用import map 文档的一句话:
Import maps allow web pages to control the behavior of JavaScript imports.
import map 现在仍然是实验性功能,并不属于 W3C 标砖,2018 年 WICG(Web Incubator Community Group)开始着手建立 import map 标准,import map 能够为 NodeJS 和浏览器提供一致的 import 写法,例如可以在浏览器中这样写,达到和 NodeJS 一致的效果:
1 | import react from "react"; |
虽然没有正式进入 W3C 标准,但是积极的 Chrome 已经支持 import map 了,现阶段的实现可以在此查看:
在浏览器中,写法和 WICG 提案规范规范基本一致,import map 是一串 JSON 格式的文本,用script
标签包裹
1 | <script type="importmap"> |
在 Chrome 中配合 esm.sh,能够更优雅去实现 bundleless,想要了解更多关于 import map 的内容,可以查看文末的资料。
将 Vite 用于生产环境
bundleless 这个词常常和 Vite 一起出现,bundleless 的工具有很多,Vite 似乎成了这一类工具的代表,就以 Vite 为例,现在我们使用 Vite 主要是在开发阶段,我希望的的真正的 bundleless 是在生产环境也不需要进行 bundle,随着 http/2 和 htttp/3 的普及,是不是可以尝试将 Vite 用于生产环境,直接源码发布呢?
Vite 文档中其实提到在生产环境中仍然需要打包,是因为模块嵌套会导致大量的请求,因为没有进行打包,所以用到的所有文件都会被请求,性能降低,试想一下一个项目中用到模块都在庞大的node_modules
文件夹,一个项目的node_modules
文件夹可能包含上千个文件,意味着需要进行上千个请求,即使 HTTP 支持管道,网络开销也非常的大。
通过之前的铺垫,也许你能够想到,可以通过esm.sh
来解决node_nmodules
导致大量请求的问题,模块被替换为一个单 bundle 文件,请求数量从上千个减少为几个十几个,对于项目中的源码,其数量的体积还是网络能够接受的,这样的项目就省去了打包(甚至部署)环节,并且能获得更好的性能。
一点总结
从历史的角度上来说,模块化一开始就不是为浏览器而设计的,但是依托强大的 npm 生态,前端很快繁荣起来,出现了 React、Vue 等优秀的库,以及一套基于 webpack 等打包工具的研发链,但后来大家开始意识到模块打包的方式不是 web 第一,于是开始对 web 端进行更好的支持,包括 ES6 引入 ESModule、 import 可以指向 url 路径,和近两年来的 import map 提案,现在看来,似乎 web 已经具备了完全的模块化能力,不需要依赖打包。我们依然习惯打包进行发布,现在很难去判断未来会不会完全不需要打包,毕竟我们还有很多事没解决,比如代码混淆等问题,未来将会以什么方式进行前端开发我也无法想象,但是从发展上来看 bundleless 还是大势所趋,基于现代 JavaScript 的 Deno 就采取了大量的这种特性,这也是本文只讨论 bundleless 可以如何做而不去比较与 bundle 的优劣的原因。
阅读更多
- Ryan Dahl - Design Mistakes in Node 演讲视频:youtube.com
- Ryan Dahl - Design Mistakes in Node 演讲 PPT:tinyclouds.org
- JavaScript import map:jianshu.com
- Chrome import map status:chromestatus.com
- what server side javascript needs:web.archive.org
前端模块化演进与bundleless的探讨