近期,随着 vue3 的各种曝光,vite 的热度上升,与 vite 类似的 snowpack 的关注度也逐渐增加了。目前(2020.06.18)snowpack 在 Github 上已经有了将近 1w stars。
snowpack 的代码很轻量,本文会从实现原理的角度介绍 snowpack 的特点。同时,带大家一起看看,作为一个以原生 JavaScript 模块化为核心的年轻的构建工具,它是如何实现“老牌”构建工具所提供的那些特性的。
近期,随着 vue3 的各种曝光,vite 的热度上升,与 vite 类似的 snowpack 的关注度也逐渐增加了。目前(2020.06.18)snowpack 在 Github 上已经有了将近 1w stars。
时间拨回到 2019 年上半年,一天中午我百无聊赖地读到了 A Future Without Webpack 这篇文章。通过它了解到了 pika/snowpack 这个项目(当时还叫 pika/web)。
文章的核心观点如下:
在如今(2019年),我们完全可以抛弃打包工具,而直接在浏览器中使用浏览器原生的 JavaScript 模块功能。这主要基于三点考虑:
由于我认为 webpack 之类的打包工具,“发家”后转型做构建工具并非最优解,实是一种阴差阳错的阶段性成果。所以当时对这个项目提到的观点也很赞同,其中印象最深的当属它提到的:
In 2019, you should use a bundler because you want to, not because you need to.
同时,我也认为,打包工具(Bundler) ≠ 构建工具(Build Tools) ≠ 工程化。
看到这片文章后(大概是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 中的包。
- import { createElement, Component } from "preact"; - import htm from "htm"; + import { createElement, Component } from "/web_modules/preact.js"; + import htm from "/web_modules/htm.js";
从 v0.4.0 版本的源码 可以看出,其初期功能确实非常简单,甚至有些简陋,以至于缺乏很多现代前端开发所需的特性,明显是不能用于生产环境的。
直观感受来说,它当时就欠缺以下能力:
时间回到 2020 年上半年,随着 vue3 的不断曝光,与其有一定关联的另一个项目 vite 也逐渐吸引了人们的目光。而其介绍中提到的 snowpack 也突然吸引到了更多的热度与讨论。当时我只是对 pika 感到熟悉,好奇的点开 snowpack 项目主页的时候,才发现这个一年前初识的项目(pika/web)已经升级到了 pika/snowpack v2。而项目源码也不再是之前那唯一而简单的 index.ts,在核心代码外,还包含了诸多官方插件。
看着已经完全变样的 Readme,我的第一直觉是,之前我想到的那些问题,应该已经有了解决方案。
抱着学习的态度,对它进行重新了解之后,发现果然如此。好奇心趋势我对它的解决方案去一探究竟。
本文写于 2020.06.18,源码基于 snowpack@2.5.1
import CSS 的问题还有一个更大的范围,就是非 JavaScript 资源的加载,包括图片、JSON 文件、文本等。
先说说 CSS。
import './index.css';
上面这种语法目前浏览是不支持的。所以 snowpack 用了一个和之前 webpack 很类似的方式,将 CSS 文件变为用于注入样式的 JS 模块。如果你熟悉 webpack,肯定知道如果你只是在 loader 中处理 CSS,那么并不会生成单独的 CSS 文件(这就是为什么会有 mini-css-extract-plugin
),而是加载一个 JS 模块,然后在 JS 模块中通过 DOM API 将 CSS 文本作为 style 标签的内容插入到页面中。
为此,snowpack 自己写了一个简单的模板方法,生成将 CSS 样式注入页面的 JS 模块。下面这段代码可以实现样式注入的功能:
const code = '.test { height: 100px }'; const styleEl = document.createElement("style"); const codeEl = document.createTextNode(code); styleEl.type = 'text/css'; styleEl.appendChild(codeEl); document.head.appendChild(styleEl);
可以看到,除了第一行式子的右值,其他都是不变的,因此可以很容易生成一个符合需求的 JS 模块:
const jsContent = ` const code = ${JSON.stringify(code)}; const styleEl = document.createElement("style"); const codeEl = document.createTextNode(code); styleEl.type = 'text/css'; styleEl.appendChild(codeEl); document.head.appendChild(styleEl); `; fs.writeFileSync(filename, jsContent);
snowpack 中的实现代码比我们上面多了一些东西,不过与样式注入无关,这个放到后面再说。
通过将 CSS 文件的内容保存到 JS 变量,然后再使用 JS 调用 DOM API 在页面注入 CSS 内容即可使用 JavaScript Modules 的能力加载 CSS。而源码中的 index.css
也会被替换为 index.css.proxy.js
:
- import './index.css'; + import './index.css.proxy.js';
proxy 这个名词之后会多次出现,因为为了能够以模块化方式导入非 JS 资源,snowpack 把生成的中间 JavaScript 模块都叫做 proxy。这种实现方式也几乎和 webpack 一脉相承。
在目前的前端开发场景中,还有一类非常典型的资源就是图片。
import avatar from './avatar.png'; function render() { return ( <div class="user"> <img src={avatar} /> </div> ); }
上面代码的书写方式已经普遍应用在很多项目代码中了。那么 snowpack 是怎么处理的呢?
太阳底下没有新鲜事,snowpack 和 webpack 一样,对于代码中导入的 avatar
变量,最后其实都是该静态资源的 URI。
我们以 snowpack 提供的官方 React 模版为例来看看图片资源的引入处理。
npx create-snowpack-app snowpack-test --template @snowpack/app-template-react
初始化模版运行后,可以看到源码与构建后的代码差异如下:
- import React, { useState } from 'react'; - import logo from './logo.svg'; - import './App.css'; + import React, { useState } from '/web_modules/react.js'; + import logo from './logo.svg.proxy.js'; + import './App.css.proxy.js';
与 CSS 类似,也为图片(svg)生成了一个 JS 模块 logo.svg.proxy.js,其模块内容为:
// logo.svg.proxy.js export default "/_dist_/logo.svg";
套路与 webpack 如出一辙。以 build 命令为例,我们来看一下 snowpack 的处理方式。
首先是将源码中的静态文件(logo.svg)拷贝到发布目录:
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); }), );
然后,我们可以看到 snowpack 中的一个叫 transformEsmImports
的关键方法调用。这个方法可以将源码 JS 中 import 的模块路径进行转换。例如对 node_modules 中的导入都替换为 web_modules。在这里对 svg 文件的导入名也会被加上 .proxy.js
:
code = await transformEsmImports(code, (spec) => { …… if (spec.startsWith('/') || spec.startsWith('./') || spec.startsWith('../')) { const ext = path.extname(spec).substr(1); if (!ext) { …… } const extToReplace = srcFileExtensionMapping[ext]; if (extToReplace) { …… } if (spec.endsWith('.module.css')) { …… } else if (!isBundled && (extToReplace || ext) !== 'js') { const resolvedUrl = path.resolve(path.dirname(outPath), spec); allProxiedFiles.add(resolvedUrl); spec = spec + '.proxy.js'; } return spec; } …… });
此时,我们的 svg 文件和源码的导入语法(import logo from './logo.svg.proxy.js'
)均已就绪,最后剩下的就是生成 proxy 文件了。也非常简单:
for (const proxiedFileLoc of allProxiedFiles) { const proxiedCode = await fs.readFile(proxiedFileLoc, {encoding: 'utf8'}); const proxiedExt = path.extname(proxiedFileLoc); const proxiedUrl = proxiedFileLoc.substr(buildDirectoryLoc.length); const proxyCode = wrapEsmProxyResponse({ url: proxiedUrl, code: proxiedCode, ext: proxiedExt, config, }); const proxyFileLoc = proxiedFileLoc + '.proxy.js'; await fs.writeFile(proxyFileLoc, proxyCode, {encoding: 'utf8'}); }
wrapEsmProxyResponse
是一个生成 proxy 模块的方法,目前只处理包括 JSON、image 和其他类型的文件,对于其他类型(包括了图片),就是非常简单的导出 url:
return `export default ${JSON.stringify(url)};`;
所以,对于 CSS 与图片,由于浏览器模块规范均不支持该类型,所以都会转换为 JS 模块,这块 snowpack 和 webpack 实现很类似。
如果你刚才仔细去看了 wrapEsmProxyResponse
方法,会发现对于 CSS “模块”,它除了有注入 CSS 的功能代码外,还多着这么几行:
import * as __SNOWPACK_HMR_API__ from '/${buildOptions.metaDir}/hmr.js'; import.meta.hot = __SNOWPACK_HMR_API__.createHotContext(import.meta.url); import.meta.hot.accept(); import.meta.hot.dispose(() => { document.head.removeChild(styleEl); });
这些代码就是用来实现热更新的,也就是 HMR(Hot Module Replacement)。它使得当一个模块更新时,应用会在前端自动替换该模块,而不需要 reload 整个页面。这对于依赖状态构建的单页应用开发非常友好。
import.meta
是一个包含模块元信息的对象,例如模块自身的 url 就可以在这里面取到。而 HMR 其实和 import.meta
没太大关系,snowpack 只是借用这块地方存储了 HMR 相关功能对象。所以不必过分纠结于它。
我们再来仔细看看上面这段 HMR 的功能代码,API 是不是很熟悉?可下面这段对比一下
import _ from 'lodash'; import printMe from './print.js'; function component() { const element = document.createElement('div'); const btn = document.createElement('button'); element.innerHTML = _.join(['Hello', 'webpack'], ' '); btn.innerHTML = 'Click me and check the console!'; btn.onclick = printMe; element.appendChild(btn); return element; } document.body.appendChild(component()); + + if (module.hot) { + module.hot.accept('./print.js', function() { + console.log('Accepting the updated printMe module!'); + printMe(); + }) + }
上面的代码取自 webpack 官网上 HMR 功能的使用说明,可见,snowpack 站在“巨人”的肩膀上,沿袭了 webpack 的 API,其原理也及其相似。网上关于 webpack HMR 的讲解文档很多,这里就不细说了,基本的实现原理就是:
accept
和 dispose
中的方法因此,wrapEsmProxyResponse
里构造出的这段代码
import.meta.hot.dispose(() => { document.head.removeChild(styleEl); });
其实就是表示,当该 CSS 更新并要被替换时,需要移除之前注入的样式。而执行顺序是:远程模块 --> 加载完毕 --> 执行旧模块的 accept 回调 --> 执行旧模块的 dispose 回调。
snowpack 中 HMR 前端核心代码放在了 assets/hmr.js
。代码也非常简短,其中值得一提的是,不像 webpack 使用向页面添加 script 标签来加载新模块,snowpack 直接使用了原生的 dynamic import 来加载新模块:
const [module, ...depModules] = await Promise.all([ import(id + `?mtime=${updateID}`), ...deps.map((d) => import(d + `?mtime=${updateID}`)), ]);
也是秉承了使用浏览器原生 JavaScript Modules 能力的理念。
小憩一下。看完上面的内容,你是不是发现,这些技术方案都和 webpack 的实现非常类似。snowpack 正是借鉴了这些前端开发的优秀实践,而其一开始的理念也很明确:为前端开发提供一个不需要打包器(Bundler)的构建工具。
webpack 的一大知识点就是优化,既包括构建速度的优化,也包括构建产物的优化。其中一个点就是如何拆包。webpack v3 之前有 CommonChunkPlugin,v4 之后通过 SplitChunk 进行配置。使用声明式的配置,比我们人工合包拆包更加“智能”。合并与拆分是为了减少重复代码,同时增加缓存利用率。但如果本身就不打包,自然这两个问题就不再存在。而如果都是直接加载 ESM,那么 Tree-Shaking 的所解决的问题也在一定程度上也被缓解了(当然并未根治)。
再结合最开始提到的性能与兼容性,如果这两个坎确实迈了过去,那我们何必要用一个内部流程复杂、上万行代码的工具来解决一个不再存在的问题呢?
好了,让我们回来继续聊聊 snowpack 里其他特性的实现。
通过环境来判断是否关闭调试功能是一个非常常见的需求。
if (process.env.NODE_ENV === 'production') { disableDebug(); }
snowpack 中也实现了环境变量的功能。从使用文档上来看,你可以在模块中的 import.meta.env
上取到变量。像下面这么使用:
if (import.meta.env.NODE_ENV === 'production') { disableDebug(); }
那么环境变量是如何被注入进去的呢?
还是以 build 的源码为例,在代码生成的阶段上,通过 wrapImportMeta
方法的调用生成了新的代码段,
code = wrapImportMeta({code, env: true, hmr: false, config});
那么经过 wrapImportMeta
处理后的代码和之前有什么区别呢?答案从源码里就能知晓:
export function wrapImportMeta({ code, hmr, env, config: {buildOptions}, }: { code: string; hmr: boolean; env: boolean; config: SnowpackConfig; }) { if (!code.includes('import.meta')) { return code; } return ( (hmr ? `import * as __SNOWPACK_HMR__ from '/${buildOptions.metaDir}/hmr.js';\nimport.meta.hot = __SNOWPACK_HMR__.createHotContext(import.meta.url);\n` : ``) + (env ? `import __SNOWPACK_ENV__ from '/${buildOptions.metaDir}/env.js';\nimport.meta.env = __SNOWPACK_ENV__;\n` : ``) + '\n' + code ); }
对于包含 import.meta
调用的代码,snowpack 都会在里面注入对 env.js
模块的导入,并将导入值赋在 import.meta.env
上。因此构建后的代码会变为:
+ import __SNOWPACK_ENV__ from '/__snowpack__/env.js'; + import.meta.env = __SNOWPACK_ENV__; if (import.meta.env.NODE_ENV === 'production') { disableDebug(); }
如果是在开发环境下,还会加上 env.js
的 HMR。而 env.js
的内容也很简单,就是直接将 env 中的键值作为对象的键值,通过 export default
导出。
默认情况下 env.js
只包含 MODE 和 NODE_ENV 两个值,你可以通过 @snowpack/plugin-dotenv 插件来直接读取 .env
相关文件。
CSS 的模块化一直是一个难题,其一个重要的目的就是做 CSS 样式的隔离。常用的解决方案包括:
我之前的文章详细介绍了这几类方案。snowpack 也提供了类似 webpack 中的 CSS Modules 功能。
import styles from './index.module.css' function render() { return <div className={styles.main}>Hello world!</div>; }
而在 snowpack 中启用 CSS Module 必须要以 .module.css
结尾,只有这样才会将文件特殊处理:
if (spec.endsWith('.module.css')) { const resolvedUrl = path.resolve(path.dirname(outPath), spec); allCssModules.add(resolvedUrl); spec = spec.replace('.module.css', '.css.module.js'); }
而所有 CSS Module 都会经过 wrapCssModuleResponse
方法的包装,其主要作用就是将生成的唯一 class 名的 token 注入到文件内,并作为 default 导出:
_cssModuleLoader = _cssModuleLoader || new (require('css-modules-loader-core'))(); const {injectableSource, exportTokens} = await _cssModuleLoader.load(code, url, undefined, () => { throw new Error('Imports in CSS Modules are not yet supported.'); }); return ` …… export let code = ${JSON.stringify(injectableSource)}; let json = ${JSON.stringify(exportTokens)}; export default json; …… `;
这里我将 HMR 和样式注入的代码省去了,只保留了 CSS Module 功能的部分。可以看到,它其实是借力了 css-modules-loader-core 来实现的 CSS Module 中 token 生成这一核心能力。
以创建的 React 模版为例,将 App.css 改为 App.module.css 使用后,代码中会多处如下部分:
+ let json = {"App":"_dist_App_module__App","App-logo":"_dist_App_module__App-logo","App-logo-spin":"_dist_App_module__App-logo-spin","App-header":"_dist_App_module__App-header","App-link":"_dist_App_module__App-link"}; + export default json;
对于导出的默认对象,键为 CSS 源码中的 classname,而值则是构建后实际的 classname。
还记得雅虎性能优化 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 和该文中提到的类似。从上线后的性能数据来看,性能并未下降。当然,由于并非使用原生模块功能来加载依赖,所以并不全完相同。但也算有些参考价值。
对于非标准的 JavaScript 和 CSS 代码,在 webpack 中我们一般会用 babel、less 等工具加上对应的 loader 来处理。最初版的 snowpack 并没有对这些语法的处理能力,而是推荐将相关的功能外接到 snowpack 前,先把代码转换完,再交给 snowpack 构建。
而新版本下,snowpack 已经内置了 JSX 和 Typescript 文件的处理。对于 typescript,snowpack 其实用了 typescript 官方提供的 tsc 来编译。
对于 JSX 则是通过 @snowpack/plugin-babel 进行编译,其实际上只是对 @babel/core 的一层简单包装,机上 babel 相关配置即可完成 JSX 的编译。
const babel = require("@babel/core"); module.exports = function plugin(config, options) { return { defaultBuildScript: "build:js,jsx,ts,tsx", async build({ contents, filePath, fileContents }) { const result = await babel.transformAsync(contents || fileContents, { filename: filePath, cwd: process.cwd(), ast: false, }); return { result: result.code }; }, }; };
从上面可以看到,核心就是调用了 babel.transformAsync
方法。而使用 @snowpack/app-template-react-typescript 模板生成的项目,依赖了一个叫 @snowpack/app-scripts-react 的包,它里面就使用了 @snowpack/plugin-babel,且相关的 babel.config.json 如下:
{ "presets": [["@babel/preset-react"], "@babel/preset-typescript"], "plugins": ["@babel/plugin-syntax-import-meta"] }
对于 Vue 项目 snowpack 也提供了一个对应的插件 @snowpack/plugin-vue 来打通构建流程,如果去看下该插件,核心是使用的 @vue/compiler-sfc 来进行 vue 组件的编译。
此外,对于 Sass(Less 也类似),snowpack 则推荐使用者添加相应的 script 命令:
"scripts": { "run:sass": "sass src/css:public/css --no-source-map", "run:sass::watch": "$1 --watch" }
所以实际上对于 Sass 的编译直接使用了 sass 命令,snowpack 只是按其约定语法对后面的指令进行执行。这有点类似 gulp / grunt,你在 scripts 中定义的是一个简单的“工作流”。
综合 ts、jsx、vue、sass 这些语法处理的方式可以发现,snowpack 在这块自己实现的不多,主要依靠“桥接”已有的各种工具,用一种方式将其融入到自己的系统中。与此类似的,webpack 的 loader 也是这一思想,例如 babel-loader 就是 webpack 和 babel 的桥。说到底,还是指责边界的问题。如果目标是成为前端开发的构建工具,你可以不去实现已有的这些子构建过程,但需要将其融入到自己的体系里。
也正是因为近年来前端构建工具的繁荣,让 snowpack 可以找到各类借力的工具,轻量级地实现了构建流程。
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 的么?我们可以从这几个方面来看:
snowpack 会成为下一代构建工具么?
In 2019, you should use a bundler because you want to, not because you need to.