搭建前端體系的模塊依賴關係與 import maps
我是淘系前端搭建服務團隊的步天,目前負責建設天馬搭建服務和斑馬搭建平臺,既提供通用的搭建服務,也提供直接可用的搭建產品。在開始之前,先介紹一些下面會提到的名詞,有助於更好的理解:
-
搭建:可以給到非技術同學,通過拖拽、配置的方式,產出頁面來運作自己的業務
-
模塊:搭建操作的最小單位
-
天馬:淘系搭建服務團隊建設的,面向阿里內部多個 BU 提供服務的搭建中臺,可以協助業務快速構建一個業務搭建系統
我們在去年 4 月分享過《淘寶前端在搭建服務上的探索》,介紹很多淘系搭建在過去發生的變化,以及爲什麼會有天馬這樣的搭建服務。
今年的 4 月分享了《淘系前端搭建服務在 2020 年有哪些變化》,介紹了在搭建體系比較完整的情況下,我們在 2020 年又做了哪些事情來提升用戶的體驗。
在閱讀本文之前,推薦先閱讀以上兩篇文章,對搭建體系有一些更多的瞭解。本篇文章會和前面兩篇文章不同,會專注於討論搭建體系下,模塊依賴關係的處理以及未來如何標準化的思考上。
老生常談的 Web 資源引用問題
雖然現代的瀏覽器性能已經基本不輸原生客戶端,但真正跑在瀏覽器上的頁面卻依然有比較多的體驗問題,其中加載性能長時間處於最大問題之列。其中很大一部分原因是渲染頁面時依賴的文件都需要遠程下載,這個也是 Web 區分於 Native 最大的差異。
爲了提升加載性能,從社區規範上,經歷了幾個階段:
-
從純 script 標籤組織加載順序到開始使用 CDN combo 功能。
-
從沒有模塊規範到出現了 CommonJS 模塊規範,可以同步加載模塊。
-
由於瀏覽器遠程下載文件的特性,出現了 AMD 模塊規範,可以聲明異步的依賴關係。
-
異步加載性能依然受限於瀏覽器併發請求數、調試困難等問題,開始出現了打包成單文件的方案(Dojo、Webpack)
-
隨着 Web 複雜度的上升,以及 ES Module 標準、import maps 規範出現,開始出現 bundless 的方案,解決開發時構建效率以及面向標準運行的問題。
而天馬 seed 模塊規範源自 KISSY,本身沿用了 AMD 的思路,最後採用了 CMD 的規範(因爲產物生成邏輯更簡單),在搭建的動態化背景下,保留了模塊化開發和運行的方式。天馬模塊和市面上的大部分搭建、低代碼體系不一樣的地方:
-
模塊化開發,統一併隱藏了基礎的構建邏輯,公共依賴自動去重,開發者基本上不需要關注構建過程。
-
頁面的發佈沒有構建過程,用戶訪問時渲染,支持大批量頁面同時修改。
-
頁面之間發佈獨立,模塊版本更新可以細粒度到單個頁面生效。
但這套規範運行 5 年下來,也遇到了比較多的問題,我們在接下來與 import maps 規範的對比中,來講講天馬模塊規範和社區標準衍進的差異。
seed 規範與 import maps
seed 是天馬模塊中描述依賴關係的文件,類似 Webpack Dependency、SystemJS depcache,在天馬體系裏,這個文件會給到渲染引擎以及瀏覽器端直接執行。seed 這個詞來源於 YUI 體系,seed 規範本身沿用了很多 KISSY seed 的描述方式。我們內部自研了 feloader 加載器(這部分也繼承了 KISSY loader 的實現)來支持 seed 格式的解析和模塊的加載與執行。
引入和使用
seed
-
引入 feloader 加載器,會在全局環境下注冊 require 和 define 方法。
-
配置依賴關係。
-
加載 seed 裏配置過的模塊。
<script src="feloader.js"><script/>
<script>
require.config({
"packages": {
"example-1": {
"path": "/example-1/"
}
}
});
require(['example-1/index'], function(Example) {
// do something.
});
</script>
import maps
-
聲明 importmap 配置
-
用瀏覽器提供的 import 方法加載配置過的模塊
注意:下面只是一個範例,展示了 import maps 的使用方式。目前規範下,因爲暫無合適的配置合併策略,所以一個頁面只能設置一次 importmap。
<script type="importmap">
{
"imports": { ... },
"scopes": { ... }
}
</script>
<script type="importmap" src="import-map.importmap"></script>
<script>
const im = document.createElement('script');
im.type = 'importmap';
im.textContent = JSON.stringify({
imports: {
'my-library': Math.random() > 0.5 ? '/my-awesome-library.mjs' : '/my-rad-library.mjs';
}
});
document.currentScript.after(im);
</script>
<script type="module">
import 'my-library'; // will fetch the randomly-chosen URL
</script>
方案對比
設置方式
首先,seed 是一個相對靈活的設置方式,在腳本執行的任何階段都可以動態修改模塊的配置,模塊在加載完成後,依然可以重置模塊狀態,並重新加載。
而 import maps 會更嚴格:
-
importmap 文件需要用 script + type="importmap" 的方式加載,雖然是 json 文件,但是瀏覽器會自動解析,所以 importmap 文件需要在調用 import 的腳本之前加載,並且加載過程是阻塞性的。
-
一個頁面只能加載一次 importmap,多 importmap 還在討論中。在開始加載腳本之後,增加新的 importmap 文件會直接報錯,並且無法生效。
-
只能用 type="importmap" 來加載 importmap 文件,MIME 類型需要是 application/importmap+json(需要支持 CSP,普通 JSON 文件不需要)。
在這些限制下,我們就無法和 Node.js 環境一樣,每個外部庫都可以自己管理自己的依賴。而是需要從完整頁面的視角,統一管理頁面上用到的所有依賴,外部庫應該只負責 import 就可以了。也就是如果頁面上加載了一個遠程的 CDN 外部庫,開發者還需要關注這個外部庫依賴了哪些其他外部庫,並在 importmap 文件裏聲明清楚。這部分對於開發者實操來說很難,畢竟大部分開發者還是喜歡拿來即用,並不關注外部庫做了什麼。
理論上,無論用 seed 還是 import maps,開發者都應當知道頁面有哪些外部依賴,併合理升級。npm 模式提供了一個較爲寬鬆的版本化依賴方式,但在 Web 上並不一定適用。其中最大的差異是,Node.js 應用是在打包部署的時候,就把版本確定下來了,在下一次重新部署前,依賴版本是不會變化的,而 Web 頁面是在用戶側,每次訪問的時候重新組織,語義化版本帶到 Web 頁面渲染的時候並不合適,帶來了太多不確定性,所以個人覺得 Airpack 是很好的方案,但用起來可能比 npm 會有更多的問題。大部分同學還是習慣了,拿來即用
目前標準推薦用行內的方式加載 importmap 文件,性能最佳,如果用外鏈的方式,由於頁面上的腳本都需要等待 import maps 配置完成才能執行,那麼只能用 HTTP/2 Push 或者 Web Bundle 來節省下載耗時。其實從 Web 標準上,css 樣式表也是推薦用行內的方式加載的,雖然 css 文件不阻塞資源加載,但依然會阻塞元素的展示,畢竟瀏覽器總不能先展示沒有樣式的 HTML 元素,然後等 css 文件加載完了再把樣式重新渲染上去。
支持 Package
爲了縮減依賴配置的體積,減少重複的路徑聲明,seed 格式是由 packages 和 modules 組成的。packages 用於聲明模塊的目錄,modules 用於聲明模塊間的依賴關係。同時 packages 上可以配置更多的信息,比如 CDN combine 的時候,是否需要獨立的 script 標籤,這樣可以控制 script src 地址的組合,實現跨頁面的腳本緩存共享。
{
"modules": {
"example-1/index": {
"requires": [
"example-1/utils"
]
}
},
"packages": {
"example-1": {
"path": "/example-1/"
}
}
}
import maps 規範也有 packages 的概念,用 “/” 結尾與模塊進行區分。配置 packages 的方式可以支持子文件方式的 import。
<script type="importmap">
{
"imports": {
"moment": "/node_modules/moment/src/moment.js",
"moment/": "/node_modules/moment/src/",
"lodash": "/node_modules/lodash-es/lodash.js",
"lodash/": "/node_modules/lodash-es/"
}
}
</script>
<script type="module">
import moment from "moment";
import _ from "lodash";
import localeData from "moment/locale/zh-cn.js";
import fp from "lodash/fp.js";
</script>
作用域和大版本不兼容設置
由於前面 import maps 要求頁面統一管理依賴,但是實際情況下,同一個外部庫在頁面上只有一個版本還是比較理想的情況。那麼 import maps 是支持了通過設置作用域的方式,讓不同文件可以加載的外部庫的不同版本。
{
"imports": {
"querystringify": "/node_modules/querystringify/index.js"
},
"scopes": {
"/node_modules/socksjs-client/": {
"querystringify": "/node_modules/socksjs-client/querystringify/index.js"
}
}
}
seed 規範下目前還沒有支持作用域,一方面是同個外部庫不同版本加載兩遍帶來的體驗問題,另一方面也是對開發者做好依賴管理的要求。目前對於 npm 上允許大版本(x 位)不兼容的情況,我們的解決方案是把大版本號放到模塊名上,實際使用的時候就是兩個模塊了。比作用域的方式更加簡單,不過依賴工程鏈路來做模塊名的轉換,帶來了一定的複雜度。
{
"packages": {
"example-1@1": {
"version": "1.0.1",
"path": "/example-1/1.0.1/"
},
"example-1@2": {
"version": "2.0.2",
"path": "/example-1/2.0.2/"
}
}
}
依賴模塊的下載
seed 機制下,最重要的其實是依賴包的加載能力。一個模塊可以聲明依賴,類似 npm 的 dependencies,然後在加載模塊的同時,會併發下載所有的依賴,包括依賴的依賴,確保模塊可以正常執行。
比如在下面的例子裏,加載 index 模塊的同時會加載和執行 utils 和 tools,最後執行 index。
{
"modules": {
"example-1/index": {
"requires": [
"example-1/utils",
"example-1/tools"
]
}
}
}
seed 機制支持了 CDN combo,多個依賴的下載合併到一個 script 請求後,大部分情況下速度會比多個併發更快一些。具體還是要看場景,畢竟不做 combo 的話,跨頁面之間可以自然共享腳本緩存。
import maps 目前還沒有支持類似的依賴下載方式,這樣就意味着,存在串行下載依賴的情況,目前 Webpack Module Federation 也有類似的問題,不過至少不是一個一個串行加載的。
SystemJS 內部有增加類似 requires 的實現,目前還在討論階段,用法如下:
<script type="systemjs-importmap">
{
"imports": {
"dep": "/path/to/dep.js"
},
"depcache": {
"/path/to/dep.js": ["./dep2.js"],
"/path/to/dep2.js": ["./dep3.js"],
"/path/to/dep3.js": ["./dep4.js"],
"/path/to/dep4.js": ["./dep5.js"]
}
}
</script>
<script>
setTimeout(() => {
System.import('dep');
}, 10000);
</script>
因爲提前聲明瞭 depcache (requires),就可以提前並行加載所有依賴的文件,省去了串行等待環節,只要加載器支持就好了。
關於 seed next
回想前面幾年,我們嘗試了很多方案想把 seed 模塊化的體系改造到更貼近 Bundle 的方式,包括合併多個頁面等等偏模板的方式,讓模塊搭建的頁面接近源碼開發的頁面。但有一些問題始終很難解決:
-
如果每次發佈都需要打包,生效速度和頁面數量上存在比較大的問題。
-
如果合併多個頁面作爲一個源碼模板,那麼首屏資源無法精準計算,影響用戶體驗。
繞了一圈之後,發現 import maps 還是帶來了不一樣的思路,模塊化的方式還是可以長期在 Web 下推進下去的。
面向未來的話,一方面需要考慮將 CMD 規範升級到 ES Module 上,需要考慮好降級方案和 CommonJS 依賴的處理。另一方面更重要的是需要推進一套更加規範的依賴加載方式,這個加載方式需要結合 CDN combo,異步併發下載依賴,客戶端 native cache,PWA,Server Push,甚至瀏覽器實驗功能預測加載 JavaScript 文件等等,針對的不同的場景,給到不同的加載方案。漸進增強,平穩退化應該是前端的強項。目前這部分還是會繼續在內部的 feloader 上拓展,未來會考慮和 SystemJS 有更多的結合。
資料參考
-
《github/import-maps》
-
《W3C import maps 草案》
-
《SystemJS 支持的 import-maps》
-
《import maps 需要支持 CSP》
-
《import maps 支持 depcache 提案》
-
《KISSY config》
-
《YUI Loader》
關注「Alibaba F2E」微信公衆號把握阿里巴巴前端新動向
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/UOIrQeQA06FyUHxHOMlhzQ