搭建前端體系的模塊依賴關係與 import maps

我是淘系前端搭建服務團隊的步天,目前負責建設天馬搭建服務和斑馬搭建平臺,既提供通用的搭建服務,也提供直接可用的搭建產品。在開始之前,先介紹一些下面會提到的名詞,有助於更好的理解:

  1. 搭建:可以給到非技術同學,通過拖拽、配置的方式,產出頁面來運作自己的業務

  2. 模塊:搭建操作的最小單位

  3. 天馬:淘系搭建服務團隊建設的,面向阿里內部多個 BU 提供服務的搭建中臺,可以協助業務快速構建一個業務搭建系統

我們在去年 4 月分享過《淘寶前端在搭建服務上的探索》,介紹很多淘系搭建在過去發生的變化,以及爲什麼會有天馬這樣的搭建服務。

今年的 4 月分享了《淘系前端搭建服務在 2020 年有哪些變化》,介紹了在搭建體系比較完整的情況下,我們在 2020 年又做了哪些事情來提升用戶的體驗。

在閱讀本文之前,推薦先閱讀以上兩篇文章,對搭建體系有一些更多的瞭解。本篇文章會和前面兩篇文章不同,會專注於討論搭建體系下,模塊依賴關係的處理以及未來如何標準化的思考上。

老生常談的 Web 資源引用問題

雖然現代的瀏覽器性能已經基本不輸原生客戶端,但真正跑在瀏覽器上的頁面卻依然有比較多的體驗問題,其中加載性能長時間處於最大問題之列。其中很大一部分原因是渲染頁面時依賴的文件都需要遠程下載,這個也是 Web 區分於 Native 最大的差異。

爲了提升加載性能,從社區規範上,經歷了幾個階段:

  1. 從純 script 標籤組織加載順序到開始使用 CDN combo 功能。

  2. 從沒有模塊規範到出現了 CommonJS 模塊規範,可以同步加載模塊。

  3. 由於瀏覽器遠程下載文件的特性,出現了 AMD 模塊規範,可以聲明異步的依賴關係。

  4. 異步加載性能依然受限於瀏覽器併發請求數、調試困難等問題,開始出現了打包成單文件的方案(Dojo、Webpack)

  5. 隨着 Web 複雜度的上升,以及 ES Module 標準、import maps 規範出現,開始出現 bundless 的方案,解決開發時構建效率以及面向標準運行的問題。

而天馬 seed 模塊規範源自 KISSY,本身沿用了 AMD 的思路,最後採用了 CMD 的規範(因爲產物生成邏輯更簡單),在搭建的動態化背景下,保留了模塊化開發和運行的方式。天馬模塊和市面上的大部分搭建、低代碼體系不一樣的地方:

  1. 模塊化開發,統一併隱藏了基礎的構建邏輯,公共依賴自動去重,開發者基本上不需要關注構建過程。

  2. 頁面的發佈沒有構建過程,用戶訪問時渲染,支持大批量頁面同時修改。

  3. 頁面之間發佈獨立,模塊版本更新可以細粒度到單個頁面生效。

但這套規範運行 5 年下來,也遇到了比較多的問題,我們在接下來與 import maps 規範的對比中,來講講天馬模塊規範和社區標準衍進的差異。

seed 規範與 import maps

seed 是天馬模塊中描述依賴關係的文件,類似 Webpack Dependency、SystemJS depcache,在天馬體系裏,這個文件會給到渲染引擎以及瀏覽器端直接執行。seed 這個詞來源於 YUI 體系,seed 規範本身沿用了很多 KISSY seed 的描述方式。我們內部自研了 feloader 加載器(這部分也繼承了 KISSY loader 的實現)來支持 seed 格式的解析和模塊的加載與執行。

引入和使用

seed

  1. 引入 feloader 加載器,會在全局環境下注冊 require 和 define 方法。

  2. 配置依賴關係。

  3. 加載 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

  1. 聲明 importmap 配置

  2. 用瀏覽器提供的 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 會更嚴格:

  1. importmap 文件需要用 script + type="importmap" 的方式加載,雖然是 json 文件,但是瀏覽器會自動解析,所以 importmap 文件需要在調用 import 的腳本之前加載,並且加載過程是阻塞性的。

  2. 一個頁面只能加載一次 importmap,多 importmap 還在討論中。在開始加載腳本之後,增加新的 importmap 文件會直接報錯,並且無法生效。

  3. 只能用 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 的方式,包括合併多個頁面等等偏模板的方式,讓模塊搭建的頁面接近源碼開發的頁面。但有一些問題始終很難解決:

  1. 如果每次發佈都需要打包,生效速度和頁面數量上存在比較大的問題。

  2. 如果合併多個頁面作爲一個源碼模板,那麼首屏資源無法精準計算,影響用戶體驗。

繞了一圈之後,發現 import maps 還是帶來了不一樣的思路,模塊化的方式還是可以長期在 Web 下推進下去的。

面向未來的話,一方面需要考慮將 CMD 規範升級到 ES Module 上,需要考慮好降級方案和 CommonJS 依賴的處理。另一方面更重要的是需要推進一套更加規範的依賴加載方式,這個加載方式需要結合 CDN combo,異步併發下載依賴,客戶端 native cache,PWA,Server Push,甚至瀏覽器實驗功能預測加載 JavaScript 文件等等,針對的不同的場景,給到不同的加載方案。漸進增強,平穩退化應該是前端的強項。目前這部分還是會繼續在內部的 feloader 上拓展,未來會考慮和 SystemJS 有更多的結合。

資料參考

關注「Alibaba F2E」微信公衆號把握阿里巴巴前端新動向

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/UOIrQeQA06FyUHxHOMlhzQ