近期,随着 vue3 的各类曝光,vite 的热度上升,与 vite 相似的 snowpack 的关注度也逐渐增长了。目前(2020.06.18)snowpack 在 Github 上已经有了将近 1w stars。javascript
snowpack 的代码很轻量,本文会从实现原理的角度介绍 snowpack 的特色。同时,带你们一块儿看看,做为一个以原生 JavaScript 模块化为核心的年轻的构建工具,它是如何实现“老牌”构建工具所提供的那些特性的。css
近期,随着 vue3 的各类曝光,vite 的热度上升,与 vite 相似的 snowpack 的关注度也逐渐增长了。目前(2020.06.18)snowpack 在 Github 上已经有了将近 1w stars。前端
时间拨回到 2019 年上半年,一天中午我百无聊赖地读到了 A Future Without Webpack 这篇文章。经过它了解到了 pika/snowpack 这个项目(当时还叫 pika/web)。vue
文章的核心观点以下:java
在现在(2019年),咱们彻底能够抛弃打包工具,而直接在浏览器中使用浏览器原生的 JavaScript 模块功能。这主要基于三点考虑:node
因为我认为 webpack 之类的打包工具,“发家”后转型作构建工具并不是最优解,实是一种阴差阳错的阶段性成果。因此当时对这个项目提到的观点也很赞同,其中印象最深的当属它提到的:react
In 2019, you should use a bundler because you want to, not because you need to.
同时,我也认为,打包工具(Bundler) ≠ 构建工具(Build Tools) ≠ 工程化。webpack
看到这片文章后(大概是19年六、7月?),抱着好奇马上去 Github 上读了这个项目。当时看这个项目的时候大概是 0.4.x 版本,其源码和功能都很是简单。git
snowpack 的最第一版核心目标就是再也不打包业务代码,而是直接使用浏览器原生的 JavaScript Module 能力。github
因此从它的处理流程上来看,对业务代码的模块,基本只须要把 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.