最近嘗試将bundleless的建構結果直接用到了線上生産環境,因為bundleless隻會編譯代碼,不會打包,是以建構速度極快,同比bundle模式時間縮短了90%以上。得益于大部分浏覽器都已經支援了http2和浏覽器的es module,對于我們沒有強相容場景的中背景系統,将bundleless建構結果應用于線上是有可能的。本文主要介紹一下,本人在使用bundleless建構工具實踐中遇到的問題。
- 起源
- 結合snowpack實踐
- snowpack的Streaming Imports
- 性能比較
- 總結
- 附錄snowpack和vite的對比
本文原文來自我的部落格: github.com/fortheallli…
一、起源
1.1 從http2談起
以前因為http1.x不支援多路服用, HTTP 1.x 中,如果想并發多個請求,必須使用多個 TCP 連結,且浏覽器為了控制資源,還會對單個域名有 6-8個的TCP連結請求限制.是以我們需要做的就是将同域的一些靜态資源比如js等,做一個資源合并,将多次請求不同的js檔案,合并成單次請求一個合并後的大js檔案。這就是webpack等bundle工具的由來。
而從http2開始,實作了TCP連結的多路複用,是以同域名下不再有請求并發數的限制,我們可以同時請求同域名的多個資源,這個并發數可以很大,比如并發10,50,100個請求同時去請求同一個服務下的多個資源。
因為http2實作了多路複用,是以一定程度上,将多個靜态檔案打包到一起,進而減少請求次數,就不是必須的
主流浏覽器對http2的支援情況如下:
![](https://img.laitimes.com/img/9ZDMuAjOiMmIsIjOiQnIsICMyYTMvw1dvwlMvwlM3VWaWV2Zh1Wa-AnYldnLjBjMlhTMkVWZyYGMwUTNjFGO3QDNxQ2MmhDN4QmNwUzLchzMzcjMygzLcVmdhNXLwRHdo9CXt92YucWbpRWdvx2Yx5yazF2Lc9CX6MHc0RHaiojIsJye.webp)
除了IE以外,大部分浏覽器對http2的支援性都很好,因為我的項目不需要相容IE,同時也不需要相容低版本浏覽器,不需要考慮不支援http2的場景。(這也是我們能将不bundle的代碼用于線上生産環境的前提之一)
1.2 浏覽器esm
對于es modules,我們并不陌生,什麼是es modules也不是本文的重點,一些流行的打包建構工具比如babel、webpack等早就支援es modules。
我們來看一個最簡單的es modules的寫法:
//main.js
import a from 'a.js'
console.log(a)
//a.js
export let a = 1
複制代碼
複制
上述的es modules就是我們經常在項目中使用的es modules,這種es modules,在支援es6的浏覽器中是可以直接使用的。
我們來舉一個例子,直接在浏覽器中使用es modules
<html lang="en">
<body>
<div id="container">my name is {name}</div>
<script type="module">
import Vue from 'https://cdn.jsdelivr.net/npm/[email protected]/dist/vue.esm.browser.js'
new Vue({
el: '#container',
data:{
name: 'Bob'
}
})
</script>
</body>
</html>
複制代碼
複制
上述的代碼中我們直接可以運作,我們根據script的type="module"可以判斷浏覽器支不支援es modules,如果不支援,該script裡面的内容就不會運作。
首先我們來看主流浏覽器對于ES modules的支援情況:
從上圖可以看出來,主流的Edge, Chrome, Safari, and Firefox (+60)等浏覽器都已經開始支援es modules。
同樣的因為我們的中背景項目不需要強相容,是以不需要相容不支援esm的浏覽器(這也是我們能将不bundle的代碼用于線上生産環境的前提之二)。
1.3 小結
浏覽器對于http2和esm的支援,使得我們可以減少子產品的合并,以及減少對于js子產品化的處理。
- 如果浏覽器支援http2,那麼一定程度上,我們不需要合并靜态資源
- 如果浏覽器支援esm,那麼我們就不需要通過建構工具去維護複雜的子產品依賴和加載關系。
這兩點正是webpack等打包工具在bundle的時候所做的事情。浏覽器對于http2和esm的支援使得我們減少bundle代碼的場景。
二、結合snowpack實踐
我們比較了snowpack和vite,最後選擇采用了snowpack(選型的原因以及snowpack和vite的對比看最後附錄),本章節講講如何結合snowpack建構工具,建構出不打包形式的線上代碼。
2.1 snowpack的基礎用法
我們的中背景項目是react和typescript編寫的,我們可以直接使用snowpack相應的模版:
npx create-snowpack-app myproject --template @snowpack/app-template-react-typescript
複制代碼
複制
snowpack建構工具内置了tsc,可以處理tsx等字尾的檔案。上述就完成了項目初始化。
2.2 前端路由處理
前端路由我們直接使用react-router或者vue-router等,需要注意的時,如果是在開發環境,那麼必須要指定在snowpack.config.mjs配置檔案,在重新整理時讓比對到前端路由:
snowpack.config.mjs
...
routes: [{ match: 'routes', src: '.*', dest: '/index.html' }],
...
複制代碼
複制
類似的配置跟webpack devserver等一樣,使其在後端路由404的時候,擷取前端靜态檔案,進而執行前端路由比對。
2.3 css、jpg等子產品的處理
在snowpack中同樣也自帶了對css和image等檔案的處理。
- css
以sass為例,
snowpack.config.mjs
plugins: [
'@snowpack/plugin-sass',
{
/* see options below */
},
],
複制代碼
複制
隻需要在配置中增加一個sass插件就能讓snowpack支援sass檔案,此外,snowpack也同樣支援css module。.module.css或者.module.scss命名的檔案就預設開啟了css module。此外,css最後的結果都是通過編譯成js子產品,通過動态建立style标簽,插入到body中的。
//index.module.css檔案
.container{
padding: 20px;
}
複制代碼
複制
snowpack建構處理後的css.proxy.js檔案為:
export let code = "._container_24xje_1 {\n padding: 20px;\n}";
let json = {"container":"_container_24xje_1"};
export default json;
// [snowpack] add styles to the page (skip if no document exists)
if (typeof document !== 'undefined') {
const styleEl = document.createElement("style");
const codeEl = document.createTextNode(code);
styleEl.type = 'text/css';
styleEl.appendChild(codeEl);
document.head.appendChild(styleEl);
}
複制代碼
複制
上述的例子中我們可以看到。最後css的建構結果是一段js代碼。在body中動态插入了style标簽,就可以讓原始的css樣式在系統中生效。
- jpg,png,svg等
如果處理的是圖檔類型,那麼snowpack同樣會将圖檔編譯成js.
//logo.svg.proxy.js
export default "../dist/assets/logo.svg";
複制代碼
複制
snowpack沒有對圖檔做任何的處理,隻是把圖檔的位址,包含到了一個js子產品檔案導出位址中。值得注意的是在浏覽器es module中,import 動作類似一個get請求,import from可以是一個圖檔位址,浏覽器es module自身可以處理圖檔等形式。是以在.js檔案結尾的子產品中,export 的可以是一個圖檔。
snowpack3.5.0以下的版本在使用css module的時候會丢失hash,需要更新到最新版本。
2.4 按需加載處理
snowpack預設是不打包的。隻對每一個檔案都做一些簡單的子產品處理(将非js子產品轉化成js子產品)和文法處理,是以天然支援按需加載,snowpack支援React.lazy的寫法,在react的項目中,隻要正常使用React.Lazy就能實作按需加載。
2.5 檔案hash處理
在最後建構完成後,在釋出建構結果的時候,為了處理緩存,常見的就是跟靜态檔案增加hash,snowpack也提供了插件機制,插件會處理snowpack建構前的所有檔案的内容,做為content轉入到插件中,經過插件的處理轉換後得到新的content.
可以通過[snowpack-files-hash][1]插件來實作給檔案增加hash。
2.6 公用esm子產品托管
snowpack對于項目建構的bundleless的代碼可以直接跑線上上,在bundless的建構結果中,我們想進一步減少建構結果檔案大小。以bundleless的方式建構的代碼,預設在處理三方npm包依賴的時候,雖然不會打包,snowpack對項目中node_modules中的依賴重新編譯成esm形式,然後放在一個新的靜态目錄下。是以最後建構的代碼包含了兩個部分:
項目本身的代碼,将node_modules中的依賴處理成esm後的靜态檔案。
其中node_modules中的依賴處理成esm後的靜态檔案,可以以cdn或者其他服務形式來托管。這樣我們每次都不需要在建構的時候處理node_modules中的依賴。在項目本身的代碼中,如果引用了npm包,隻需要将其指向一個cdn位址即可。這樣處理後的,建構的代碼就變成:
隻有項目本身的代碼(項目中對于三方插件的引入,直接使用三方插件的cdn位址)。
進一步想,如果我們使用了托管所有npm包(es module形式)的cdn位址之後,那麼在本地開發或者線上建構的過程中,我們甚至不需要去維護本地的node_modules目錄,以及yarn-lock或者package-lock檔案。我們需要做的,僅僅是一個map檔案進行版本管理。儲存項目中的npm包名和該包相對應的cdn位址。
比如:
//config.map.json
{
"react": "https://cdn.skypack.dev/[email protected]",
"react-dom": "https://cdn.skypack.dev/[email protected]",
}
複制代碼
複制
通過這個map檔案,不管是在開發還是線上,隻要把:
import React from 'react'
複制代碼
複制
替換成
import React from "https://cdn.skypack.dev/[email protected]"
複制代碼
複制
就能讓代碼在開發環境或者生産環境中跑起來。如此簡化之後,我們不論在開發環境還是生産環境都不需要在本地維護node_modules相關的檔案,進一步可以減少打包時間。同時包管理也更加清晰,僅僅是一個簡單的json檔案,一對固定意義的key/value,簡單純粹。
我們提到了一個托管了的npm包的有es module形式的cdn服務,上述以skypack為例,這對比托管了npm包cjs形式的cdn服務unpkg,兩者的差別就是,unpkg所托管的npm包,大部分是cjs形式的,cjs形式的npm包,是不能直接用于浏覽器的esm中的。skypack所做的事情就是将大部分npm包從cjs形式轉化成esm的形式,然後存儲和托管esm形式的結果。
三、snowpack的Streaming Imports
在2.7中我們提到了在dev開發環境使用了skypack,那麼本地不需要node_modules,甚至不需要yarn-lock和package-lock等檔案,隻需要一個json檔案,簡單的、純粹的,隻有一對固定意義的key/value。在snowpack3.x就提供了這麼一個功能,稱之為Streaming Imports。
3.1 snowpack和skypack
在snowpack3.x在dev環境支援skypack:
// snowpack.config.mjs
export default {
packageOptions: {
source: 'remote',
},
};
複制代碼
複制
如此,在dev的webserver過程中,就是直接下載下傳skypack中相應的esm形式的npm包,放在最後的結果中,而不需要在本地做一個cjs到esm的轉換。這樣做有幾點好處:
- 速度快: 不需要npm install一個npm包,然後在對其進行build轉化成esm,Streaming Imports可以直接從一個cdn位址直接下載下傳esm形式的依賴
- 安全:業務代碼中不需要處理公共npm包cjs到esm的轉化,業務代碼和三方依賴分離,三方依賴交給skypack處理
3.2 依賴控制
Streaming Imports自身也實作了一套簡單的依賴管理,有點類似go mod。是通過一個叫snowpack.deps.json檔案來實作的。跟我們在2.7中提到的一樣,如果使用托管cdn,那麼本地的pack-lock和yarn-lock,甚至node_modules是不需要存在的,隻需要一個簡單純粹的json檔案,而snowpack中就是通過snowpack.deps.json來實作包的依賴管理的。
我們安裝一個npm包時,我們以安裝ramda為例:
npx snowpack ramda
複制代碼
複制
在snowpack.deps.json中會生成:
{
"dependencies": {
"ramda": "^0.27.1",
},
"lock": {
"ramda#^0.27.1": "[email protected]",
}
}
複制代碼
複制
安裝過程的指令行如下所示:
從上圖可以看出來,通過npx snowpack安裝的依賴是從skypack cdn直接請求的。
特别的,如果項目需要支援typescript,那麼我們需要将相應的npm包的聲明檔案types下載下傳到本地,skypack同樣也支援聲明檔案的下載下傳,隻需要在snowpack的配置檔案中增加:
// snowpack.config.mjs
export default {
packageOptions: {
source: 'remote',
types:true //增加type=true
},
};
複制代碼
複制
snowpack會把types檔案下載下傳到本地的.snowpack目錄下,是以在tsc編譯的時候需要指定types的查找路徑,在tsconfig.json中增加:
//tsconfig.json
"paths": {
"*":[".snowpack/types/*"]
},
複制代碼
複制
3.3 build環境
snowpack的Streaming Imports,在dev可以正常工作,dev的webserver中在請求npm包的時候會将請求代理到skypack,但是在build環境的時候,還是需要其他處理的,在我們的項目中,在build的時候可以用一個插件[snowpack-plugin-skypack-replacer][2],将build後的代碼引入npm包的時候,指向skypack。
build後的線上代碼舉例如下:
import * as __SNOWPACK_ENV__ from '../_snowpack/env.271340c8a413.js';
import.meta.env = __SNOWPACK_ENV__;
import ReactDOM from "https://cdn.skypack.dev/react-dom@^17.0.2";
import App from "./App.e1841499eb35.js";
import React from "https://cdn.skypack.dev/react@^17.0.2";
import "./index.css.proxy.9c7da16f4b6e.js";
const start = async () => {
await ReactDOM.render(/* @__PURE__ */ React.createElement(App, null), document.getElementById("root"));
};
start();
if (undefined /* [snowpack] import.meta.hot */ ) {
undefined /* [snowpack] import.meta.hot */ .accept();
}
複制代碼
複制
從上述可以看出,build之後的代碼,通過插件将:
import React from 'react'
//替換成了
import React from "https://cdn.skypack.dev/react@^17.0.2";
複制代碼
複制
四、性能比較
4.1 lighthouse對比
簡單的使用lighthouse來對比bundleless和bundle兩種不同建構方式網頁的性能。
- bundleless的前端簡單性能測試:
- bundle的前端性能測試:
對比發現,這裡兩個網站都是同一套代碼,相同的部署環境,一套是建構的時候是bundleless,利用浏覽器的esm,另一個是傳統的bundle模式,發現性能上并沒有明顯的差別,至少bundleless簡單的性能測試方面沒有明顯差距。
4.2建構時間對比
bundleless建構用于線上,主要是減少了建構的時間,我們傳統的bundle的代碼,一次編譯打包等可能需要幾分鐘甚至十幾分鐘。在我的項目中,bundleless的建構隻需要4秒。
同一個項目,用webpack建構bundle的情況下需要60秒左右。
4.3建構産物體積對比
bundleless建構出的産物,一般來說也隻有bundle情況下的1/10.這裡不一一舉例。
五、總結
在沒有強相容性的場景,特别是中背景系統,bundleless的代碼直接跑線上上,是一種可以嘗試的方案,上線的時間會縮短90%,不過也有一些問題需要解決,首先需要保證托管esm資源的CDN服務的穩定性,且要保障被托管的esm資源在浏覽器運作不會出現異常。我們運作了一些常見的npm包,發現并沒有異常情況,不過後續需要更多的測試。
六、附錄:snowpack和vite的對比
6.1 相同點
snowpack和vite都是bundleless的建構工具,都利用了浏覽器的es module來減少對靜态檔案的打包,進而減少熱更新的時間,進而提高開發體驗。原理都是将本地安裝的依賴重新編譯成esm形式,然後放在本地服務的靜态目錄下。snowpack和vite有很多相似點
- 在dev環境都将本地的依賴進行二次處理,對于本地node_module目錄下的npm包,通過其他建構工具轉換成esm。然後将所有轉換後的esm檔案放在本地服務的靜态目錄下
- 都支援css、png等等靜态檔案,不需要安裝其他插件。特别對于css,都預設支援css module
- 預設都支援jsx,tsx,ts等擴充名的檔案
- 架構無關,都支援react、vue等主流前端架構,不過vite對于vue的支援性是最好的。
6.2 不同點
dev建構: snowpack和vite其實大同小異,在dev環境都可以将本地node_modules中npm包,通過esinstall等編譯到本地server的靜态目錄。不同的是在dev環境
- snowpack是通過rollup來将node_modules的包,重新進行esm形式的編譯
- vite則是通過esbuild來将node_modules的包,重新進行esm形式的編譯
是以dev開發環境來看,vite的速度要相對快一些,因為一個npm包隻會重新編譯一次,是以dev環境速度影響不大,隻是在初始化項目冷啟動的時候時間有一些誤差,此外snowpack支援Streaming Imports,可以在dev環境直接用托管在cdn上的esm形式的npm包,是以dev環境性能差别不大。
build建構:
在生産環境build的時候,vite是不支援unbundle的,在bundle模式下,vite選擇采用的是rollup,通過rollup來打包出線上環境運作的靜态檔案。vite官方支援且僅支援rollup,這樣一定程度上可以保持一緻性,但是不容易解耦,進而結合非rollup建構工具來打包。而snowpack預設就是unbundle的,這種unbundle的預設形式,對建構工具就沒有要求,對于線上環境,即可以使用rollup,也可以使用webpack,甚至可以選擇不打包,直接使用unbundle。
可以用兩個表格來總結如上的結論:
dev開發環境:
産品 | dev環境建構工具 |
---|---|
snowpack | rollup(或者使用Streaming imports) |
vite | esbuild |
build生産環境:
産品 | build建構工具 |
---|---|
snowpack | 1.unbundle(esbuild) 2.rollup 3.webpack... |
vite | rollup(且不支援unbundle) |
6.3 snowpack支援Streaming Imports
Streaming Imports是一個新特性,他允許使用者,不管是生産環境還是開發環境,都不需要在本地使用npm/yarn來維護一個lock檔案,進而下載下傳應用中所使用的npm包到本地的node_module目錄下。通過使用Streaming Imports,可以維護一個map檔案,該map檔案中的key是包名,value直接指向托管該npm包esm檔案形式的cdn伺服器的位址。
6.4 vite的一些優點
vite相對于snowpack有一下幾個優點,不過個人以為都不算特别有用的一些優點。
- 多頁面支援,除了根目錄的root/index.html外,還支援其他根目錄以外的頁面,比如nest/index.html
- 對于css預處理器支援更好(這點個人沒發現)
- 支援css代碼的code-splitting
- 優化了異步請求多個chunk檔案(不分場景可以同步進行,進而一定程度下減少請求總時間)
6.5 總結
如果想在生産環境使用unbundle,那麼vite是不行的,vite對于線上環境的build,是必須要打包的。vite優化的隻是開發環境。而snowpack預設就是unbundle的,是以可以作為前提在生産環境使用unbundle.此外,snowpack的Streaming Imports提供了一套完整的本地map的包管理,不需要将npm包安裝到本地,很友善我們線上上和線下使用cdn托管公共庫。