allFiles = glob.sync(`**/*`, {
...
});
const allBuildNeededFiles: string[] = [];
await Promise.all(
allFiles.map(async (f) => {
f = path.resolve(f); // this is necessary since glob.sync() returns paths with / on windows. path.resolve() will switch them to the native path separator.
...
return fs.copyFile(f, outPath);
}),
);
近期,随着 vue3 的各种曝光,vite 的热度上升,与 vite 类似的 snowpack 的关注度也逐渐增加了。目前(2020.06.18)snowpack 在 Github 上已经有了将近 1w stars。
snowpack 的代码很轻量,本文会从实现原理的角度介绍 snowpack 的特点。同时,带大家一起看看,作为一个以原生 JavaScript 模块化为核心的年轻的构建工具,它是如何实现“老牌”构建工具所提供的那些特性的。
1. 初识 snowpack
近期,随着 vue3 的各种曝光,vite 的热度上升,与 vite 类似的 snowpack 的关注度也逐渐增加了。目前(2020.06.18)snowpack 在 Github 上已经有了将近 1w stars。
时间拨回到 2019 年上半年,一天中午我百无聊赖地读到了 A Future Without Webpack 这篇文章。通过它了解到了 pika/snowpack 这个项目(当时还叫 pika/web)。
文章的核心观点如下:
在如今(2019年),我们完全可以抛弃打包工具,而直接在浏览器中使用浏览器原生的 JavaScript 模块功能。这主要基于三点考虑:
由于我认为 webpack 之类的打包工具,“发家”后转型做构建工具并非最优解,实是一种阴差阳错的阶段性成果。所以当时对这个项目提到的观点也很赞同,其中印象最深的当属它提到的:
同时,我也认为,打包工具(Bundler) ≠ 构建工具(Build Tools) ≠ 工程化。
2. 初窥 snowpack
看到这片文章后(大概是19年6、7月?),抱着好奇立刻去 Github 上读了这个项目。当时看这个项目的时候大概是 0.4.x 版本,其源码和功能都非常简单。
snowpack 的最初版核心目标就是方便开发者使用浏览器原生的 JavaScript Module 能力。所以从它的处理流程上来看,对业务代码的模块,基本只需要把 ESM 发布(拷贝)到发布目录,再将模块导入路径从源码路径换为发布路径即可。
而对 node_modules 则通过遍历 package.json 中的依赖,按该依赖列表为粒度将 node_modules 中的依赖打包。以 node_modules 中每个包的入口作为打包 entry,使用 rollup 生成对应的 ESM 模块文件,放到 web_modules 目录中,最后替换源码的 import 路径,是得可以通过原生 JavaScript Module 来加载 node_modules 中的包。
从 v0.4.0 版本的源码 可以看出,其初期功能确实非常简单,甚至有些简陋,以至于缺乏很多现代前端开发所需的特性,明显是不能用于生产环境的。
直观感受来说,它当时就欠缺以下能力:
3. snowpack 的进化
时间回到 2020 年上半年,随着 vue3 的不断曝光,与其有一定关联的另一个项目 vite 也逐渐吸引了人们的目光。而其介绍中提到的 snowpack 也突然吸引到了更多的热度与讨论。当时我只是对 pika 感到熟悉,好奇的点开 snowpack 项目主页的时候,才发现这个一年前初识的项目(pika/web)已经升级到了 pika/snowpack v2。而项目源码也不再是之前那唯一而简单的 index.ts,在核心代码外,还包含了诸多官方插件。
看着已经完全变样的 Readme,我的第一直觉是,之前我想到的那些问题,应该已经有了解决方案。
抱着学习的态度,对它进行重新了解之后,发现果然如此。好奇心趋势我对它的解决方案去一探究竟。
3.1. import CSS
import CSS 的问题还有一个更大的范围,就是非 JavaScript 资源的加载,包括图片、JSON 文件、文本等。
先说说 CSS。
上面这种语法目前浏览是不支持的。所以 snowpack 用了一个和之前 webpack 很类似的方式,将 CSS 文件变为用于注入样式的 JS 模块。如果你熟悉 webpack,肯定知道如果你只是在 loader 中处理 CSS,那么并不会生成单独的 CSS 文件(这就是为什么会有
mini-css-extract-plugin
),而是加载一个 JS 模块,然后在 JS 模块中通过 DOM API 将 CSS 文本作为 style 标签的内容插入到页面中。为此,snowpack 自己写了一个简单的模板方法,生成将 CSS 样式注入页面的 JS 模块。下面这段代码可以实现样式注入的功能:
可以看到,除了第一行式子的右值,其他都是不变的,因此可以很容易生成一个符合需求的 JS 模块:
snowpack 中的实现代码比我们上面多了一些东西,不过与样式注入无关,这个放到后面再说。
通过将 CSS 文件的内容保存到 JS 变量,然后再使用 JS 调用 DOM API 在页面注入 CSS 内容即可使用 JavaScript Modules 的能力加载 CSS。而源码中的
index.css
也会被替换为index.css.proxy.js
:proxy 这个名词之后会多次出现,因为为了能够以模块化方式导入非 JS 资源,snowpack 把生成的中间 JavaScript 模块都叫做 proxy。这种实现方式也几乎和 webpack 一脉相承。
3.2. 图片的 import
在目前的前端开发场景中,还有一类非常典型的资源就是图片。
上面代码的书写方式已经普遍应用在很多项目代码中了。那么 snowpack 是怎么处理的呢?
太阳底下没有新鲜事,snowpack 和 webpack 一样,对于代码中导入的
avatar
变量,最后其实都是该静态资源的 URI。我们以 snowpack 提供的官方 React 模版为例来看看图片资源的引入处理。
初始化模版运行后,可以看到源码与构建后的代码差异如下:
与 CSS 类似,也为图片(svg)生成了一个 JS 模块 logo.svg.proxy.js,其模块内容为:
套路与 webpack 如出一辙。以 build 命令为例,我们来看一下 snowpack 的处理方式。
首先是将源码中的静态文件(logo.svg)拷贝到发布目录:
然后,我们可以看到 snowpack 中的一个叫
transformEsmImports
的关键方法调用。这个方法可以将源码 JS 中 import 的模块路径进行转换。例如对 node_modules 中的导入都替换为 web_modules。在这里对 svg 文件的导入名也会被加上.proxy.js
:此时,我们的 svg 文件和源码的导入语法(
import logo from './logo.svg.proxy.js'
)均已就绪,最后剩下的就是生成 proxy 文件了。也非常简单:wrapEsmProxyResponse
是一个生成 proxy 模块的方法,目前只处理包括 JSON、image 和其他类型的文件,对于其他类型(包括了图片),就是非常简单的导出 url:所以,对于 CSS 与图片,由于浏览器模块规范均不支持该类型,所以都会转换为 JS 模块,这块 snowpack 和 webpack 实现很类似。
3.3. HMR(热更新)
如果你刚才仔细去看了
wrapEsmProxyResponse
方法,会发现对于 CSS “模块”,它除了有注入 CSS 的功能代码外,还多着这么几行:这些代码就是用来实现热更新的,也就是 HMR(Hot Module Reload)。它使得当一个模块更新时,应用会在前端自动替换该模块,而不需要 reload 整个页面。这对于依赖状态构建的单页应用开发非常友好。
import.meta
是一个包含模块元信息的对象,例如模块自身的 url 就可以在这里面取到。而 HMR 其实和import.meta
没太大关系,snowpack 只是借用这块地方存储了 HMR 相关功能对象。所以不必过分纠结于它。我们再来仔细看看上面这段 HMR 的功能代码,API 是不是很熟悉?可下面这段对比一下
上面的代码取自 webpack 官网上 HMR 功能的使用说明,可见,snowpack 站在“巨人”的肩膀上,沿袭了 webpack 的 API,其原理也及其相似。网上关于 webpack HMR 的讲解文档很多,这里就不细说了,基本的实现原理就是:
accept
和dispose
中的方法因此,
wrapEsmProxyResponse
里构造出的这段代码其实就是表示,当该 CSS 更新并要被替换时,需要移除之前注入的样式。而执行顺序是:远程模块 --> 加载完毕 --> 执行旧模块的 accept 回调 --> 执行旧模块的 dispose 回调。
snowpack 中 HMR 前端核心代码放在了
assets/hmr.js
。代码也非常简短,其中值得一提的是,不像 webpack 使用向页面添加 script 标签来加载新模块,snowpack 直接使用了原生的 dynamic import 来加载新模块:也是秉承了使用浏览器原生 JavaScript Modules 能力的理念。
小憩一下。看完上面的内容,你是不是发现,这些技术方案都和 webpack 的实现非常类似。snowpack 正是借鉴了这些前端开发的优秀实践,而其一开始的理念也很明确:为前端开发提供一个不需要打包器(Bundler)的构建工具。
webpack 的一大知识点就是优化,既包括构建速度的优化,也包括构建产物的优化。其中一个点就是如何拆包。webpack v3 之前有 CommonChunkPlugin,v4 之后通过 SplitChunk 进行配置。使用声明式的配置,比我们人工合包拆包更加“智能”。合并与拆分是为了减少重复代码,同时增加缓存利用率。但如果本身就不打包,自然这两个问题就不再存在。而如果都是直接加载 ESM,那么 Tree-Shaking 的所解决的问题也在一定程度上也被缓解了(当然并未根治)。
再结合最开始提到的性能与兼容性,如果这两个坎确实迈了过去,那我们何必要用一个内部流程复杂、上万行代码的工具来解决一个不再存在的问题呢?
好了,让我们回来继续聊聊 snowpack 里其他特性的实现。
3.4. 环境变量
通过环境来判断是否关闭调试功能是一个非常常见的需求。
snowpack 中也实现了环境变量的功能。从使用文档上来看,你可以在模块中的
import.meta.env
上取到变量。像下面这么使用:那么环境变量是如何被注入进去的呢?
还是以 build 的源码为例,在代码生成的阶段上,通过
wrapImportMeta
方法的调用生成了新的代码段,那么经过
wrapImportMeta
处理后的代码和之前有什么区别呢?答案从源码里就能知晓:对于包含
import.meta
调用的代码,snowpack 都会在里面注入对env.js
模块的导入,并将导入值赋在import.meta.env
上。因此构建后的代码会变为:如果是在开发环境下,还会加上
env.js
的 HMR。而env.js
的内容也很简单,就是直接将 env 中的键值作为对象的键值,通过export default
导出。默认情况下
env.js
只包含 MODE 和 NODE_ENV 两个值,你可以通过 @snowpack/plugin-dotenv 插件来直接读取.env
相关文件。3.5. CSS Modules 的支持
CSS 的模块化一直是一个难题,其一个重要的目的就是做 CSS 样式的隔离。常用的解决方案包括:
我之前的文章详细介绍了这几类方案。snowpack 也提供了类似 webpack 中的 CSS Modules 功能。
而在 snowpack 中启用 CSS Module 必须要以
.module.css
结尾,只有这样才会将文件特殊处理:而所有 CSS Module 都会经过
wrapCssModuleResponse
方法的包装,其主要作用就是将生成的唯一 class 名的 token 注入到文件内,并作为 default 导出:这里我将 HMR 和样式注入的代码省去了,只保留了 CSS Module 功能的部分。可以看到,它其实是借力了 css-modules-loader-core 来实现的 CSS Module 中 token 生成这一核心能力。
以创建的 React 模版为例,将 App.css 改为 App.module.css 使用后,代码中会多处如下部分:
对于导出的默认对象,键为 CSS 源码中的 classname,而值则是构建后实际的 classname。
3.6. 性能问题
还记得雅虎性能优化 35 条军规么?其中就提到了通过合并文件来减少请求数。这既是因为 TCP 的慢启动特点,也是因为浏览器的并发限制。而伴随这前端富应用需求的增多,前端页面再也不是手工引入几个 script 脚本就可以了。同时,浏览器中 JS 原生的模块化能力缺失也让算是火上浇油,到后来再加上 npm 的加持,打包工具呼之欲出。webpack 也是那个时代走过来的产物。
随着近年来 HTTP/2 的普及,5G 的发展落地,浏览器中 JS 模块化的不断发展,这个合并请求的“真理”也许值得我们再重新审视一下。去年 PHILIP WALTON 在博客上发的「Using Native JavaScript Modules in Production Today」就推荐大家可以在生产环境中尝试使用浏览器原生的 JS 模块功能。
「Using Native JavaScript Modules in Production Today」 这片文章提到,根据之前的测试,非打包代码的性能较打包代码要差很多。但该实验有偏差,同时随着近期的优化,非打包的性能也有了很大提升。其中推荐的实践方式和 snowpack 对 node_modules 的处理基本如出一辙。保证了加载不会超过 100 个模块和 5 层的深度。
同时,由于业务技术形态的原因,我所在的业务线经历了一次构建工具迁移,对于模块的处理上也用了类似的策略:业务代码模块不合并,只打包 node_modules 中的模块,都走 HTTP/2。但是没有使用原生模块功能,只是模块的分布状态与 snowpack 和该文中提到的类似。从上线后的性能数据来看,性能并未下降。当然,由于并非使用原生模块功能来加载依赖,所以并不全完相同。但也算有些参考价值。
3.7. JSX / Typescript / Vue / Less …
对于非标准的 JavaScript 和 CSS 代码,在 webpack 中我们一般会用 babel、less 等工具加上对应的 loader 来处理。最初版的 snowpack 并没有对这些语法的处理能力,而是推荐将相关的功能外接到 snowpack 前,先把代码转换完,再交给 snowpack 构建。
而新版本下,snowpack 已经内置了 JSX 和 Typescript 文件的处理。对于 typescript,snowpack 其实用了 typescript 官方提供的 tsc 来编译。对于 JSX 则是通过 @snowpack/plugin-babel 进行编译,其实际上只是对 @babel/core 的一层简单包装,机上 babel 相关配置即可完成 JSX 的编译。
从上面可以看到,核心就是调用了
babel.transformAsync
方法。而使用 @snowpack/app-template-react-typescript 模板生成的项目,依赖了一个叫 @snowpack/app-scripts-react 的包,它里面就使用了 @snowpack/plugin-babel,且相关的 babel.config.json 如下:对于 Vue 项目 snowpack 也提供了一个对应的插件 @snowpack/plugin-vue 来打通构建流程,如果去看下该插件,核心是使用的 @vue/compiler-sfc 来进行 vue 组件的编译。
此外,对于 Sass(Less 也类似),snowpack 则推荐使用者添加相应的 script 命令:
所以实际上对于 Sass 的编译直接使用了 sass 命令,snowpack 只是按其约定语法对后面的指令进行执行。这有点类似 gulp / grunt,你在 scripts 中定义的是一个简单的“工作流”。
综合 ts、jsx、vue、sass 这些语法处理的方式可以发现,snowpack 在这块自己实现的不多,主要依靠“桥接”已有的各种工具,用一种方式将其融入到自己的系统中。与此类似的,webpack 的 loader 也是这一思想,例如 babel-loader 就是 webpack 和 babel 的桥。说到底,还是指责边界的问题。如果目标是成为前端开发的构建工具,你可以不去实现已有的这些子构建过程,但需要将其融入到自己的体系里。
也正是因为近年来前端构建工具的繁荣,让 snowpack 可以找到各类借力的工具,轻量级地实现了构建流程。
4. 最后聊聊
snowpack 的一大特点是快 —— 全量构建快,增量构建也快。因为不需要打包,所以它不需要像 webpack 那样构筑一个巨大的依赖图谱,并根据依赖关系进行各种合并、拆分计算。snowpack 的增量构建基本就是改动一个文件就处理这个文件即可,模块之间算是“松散”的耦合。
而 webpack 还有一大痛点就是“外部“依赖的处理,“外部”依赖是指:
这时候 B 就像是“外部”依赖。在之前典型的一个解决方式就是 external,当然还可以通过使用前端加载器加载 UMD、AMD 包。或者更进一步,在 webpack 5 中使用 Module Federation 来实现。这一需求的可能场景就是微前端。各个前端微服务如果要统一一起构建,必然会随着项目的膨胀构建越来越慢,所以独立构建,动态加载运行的需求也就出现了。
对于打包器来说,
import 'B.js'
默认其实就是需要将 B 模块打包进来,所以我们才需要那么多“反向”的配置将这种默认行为禁止掉,同时提供一个预期的运行时方案。而如果站在原生 JavaScript Module 的工作方式上来说,import '/dist/B.js'
并不需要在构建的时候获取 B 模块,而只是在运行时才有耦合关系。其天生就是构建时非依赖,运行时依赖的。当然,目前 snowpack 在构建时如果缺少的依赖模块仍然会抛出错误,但上面所说的本质上是可实现,难度较打包器会低很多,而且会更符合使用者的直觉。那么 snowpack 是 bundleless 的么?我们可以从这几个方面来看:
最后,还是那句话:
In 2019, you should use a bundler because you want to, not because you need to.