近期,隨着 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.