開始在web中使用JS Modules
本文由雲+社群發表
作者:
原文:《Using JavaScript modules on the web》 https://developers.google.com...
譯者序
JS modules,即ES6的模組化特性,通過 <scripttype="modules">
可以實現不經過打包直接在瀏覽器中import/export,此玩法確實讓人眼前一亮。
先看看 <scripttype="modules">
的相容性。目前只有較新版本的chrome/firefox/safari/edge支援此特性,看來要普及使用還任重道遠。下面跟著這篇文章深入瞭解一下漲漲姿勢。
本文將介紹JS模組化;怎樣在不經過打包的情況下直接在瀏覽器中使用模組化;以及Chrome團隊在JS模組化的優化和普及上正在做的一些事情。
JS模組化
你可能用過名稱空間、CommonJS或者AMD規範進行JS模組化,但所有的這些模組解決方案萬變不離其宗:引入(import)其他模組,作為一個模組輸出(export)。如果說名稱空間、CommonJS、AMD都是野路子,那ES6的JS modules則是正規軍,將模組化語法統一起來(一統江湖,千秋萬代)。
在JS modules中,你可以使用 export
關鍵字輸出任何東西: const
、 function
等。
// lib.mjsexport const repeat = (string) => `${string} ${string}`;export function shout(string) {return `${string.toUpperCase()}!`;}
然後你可以用 import
關鍵字從另一個模組中引進來。下面程式碼將lib模組中的 repeat
和 shout
函式引到了我們的主模組main中。
// main.mjsimport {repeat, shout} from './lib.mjs';repeat('hello');// → 'hello hello'shout('Modules in action');// → 'MODULES IN ACTION!'
你也可以通過 default
關鍵字,輸出一個預設值。
// lib.mjsexport default function(string) {return `${string.toUpperCase()}!`;}
而通過上面的 default
輸出的模組,在引入時可以用其他任何變數名。
// main.mjsimport shout from './lib.mjs';//^^^^^
模組指令碼與常規指令碼有所區別:
- 模組指令碼預設開啟了嚴格模式
- 不支援HTML風格的註釋
<!-- comment -->
- 模組具有詞法頂級作用域。也就是說在模組中
varfoo=42;
並不會像傳統指令碼一樣,建立一個全域性變數foo
,可以通過window.foo
訪問。 - 新的
import
和export
語法僅限於在模組指令碼中使用,不能用在常規指令碼中。
正因為這些差異,模組指令碼和傳統指令碼顯然需要各自不同的解析方式。因此JS解析器需要標識出哪些指令碼屬於是模組型別的。
瀏覽器如何識別模組指令碼
你可以通過設定 <script>
元素的 type
屬性為 module
,以此告訴瀏覽器這段script需要以模組進行處理。
<script type="module" src="index.mjs"></script> <!--下文稱作模組指令碼--><script nomodule src="fallback.js"></script> <!--下文稱作傳統指令碼-->
那些支援 type=module
的瀏覽器會忽略掉 nomodule
的指令碼,而不相容也會優雅降級,執行fallback.js。
譯者注:親測在IE7+到edge,oppo手機自帶的瀏覽器都能夠降級而執行fallback.js。不過載入fallback的同時,也會把index.mjs一併載入,而支援module的瀏覽器則不會載入fallback。
IE系列均會執行fallback.js
載入fallback的同時,也會把index.mjs一併載入
而支援module的瀏覽器則只會載入模組
有沒想過另外一個好處:既然瀏覽器能夠識別module,那它必然也能夠支援ES67的其他特性,如箭頭函式、async-await。你不需要為這些特性進行babel編譯,現代瀏覽器跑著更小和最大部分未編譯的模組化程式碼,而不相容的則使用nomodule的降級程式碼。
瀏覽器載入方面的異同:模組指令碼vs傳統指令碼
上面介紹了模組指令碼和傳統指令碼在語言層面的異同,除此之外,在瀏覽器載入過程中也有所不同。
同樣的模組指令碼只會執行一次,而傳統指令碼會宣告多次。
<script src="classic.js"></script><script src="classic.js"></script><!-- classic.js executes multiple times. --><script type="module" src="module.mjs"></script><script type="module" src="module.mjs"></script><script type="module">import './module.mjs';</script><!-- module.mjs executes only once. -->
模組指令碼跨域需要加跨域頭
模組指令碼及其依賴是通過CORS來獲取的,也就是說模組指令碼一旦跨域就需要加上適當的返回頭,比如 Access-Control-Allow-Origin:*
。而眾所周知,傳統指令碼則不需要(譯者注:還記得傳說中的JSONP嗎)。
async屬性對內聯指令碼有效
<script async>var test = 1;</script><!-- async無效 --><script async type="module">import {a} from './a.mjs'</script><!-- async有效 -->
加了async屬性會使得指令碼在下載過程中不阻塞DOM渲染,而下載完成後立即執行,兩個async指令碼之間的執行時序不確定,執行時機也不確定,有可能在domContentLoaded之前或者之後。但這一屬性對傳統的內聯指令碼是無效的,而對模組的內聯指令碼卻是有效的。
關於 .mjs
檔案字尾
你可能會對前面的 .mjs
字尾感到好奇,但是在網際網路的世界裡,檔案字尾並不重要,只要伺服器下發的MIME型別( Content-Type:text/javascript
)正確就可以。瀏覽器是通過script標籤上的type屬性來識別模組指令碼的,而不是字尾名。
所以無論使用 .js
還是 .mjs
都是可以的。但是我們還是建議使用 .mjs
,原因有兩個:
.mjs
模組資源識別符號 - module specifier
在import一個模組時,後面的相對或絕對路徑字串稱為module specifier或import specifier,也就是模組資源路徑。
import {shout} from './lib.mjs';//^^^^^^^^^^^
瀏覽器對於模組資源路徑做了一些限制。不支援類似下面這種只有模組名或部分檔名的資源路徑(稱之為bare module specifiers)。這樣的限制是為了以後瀏覽器在支援自定義模組載入器之後,載入器能夠自行決定bare module specifiers的解析方式。
// Not supported (yet):import {shout} from 'jquery';import {shout} from 'lib.mjs';import {shout} from 'modules/lib.mjs';
目前,模組資源路徑必須是完整的URL,或者以 /
, ./
, ../
開頭的相對URL
// Supported:import {shout} from './lib.mjs';import {shout} from '../lib.mjs';import {shout} from '/modules/lib.mjs';import {shout} from 'https://simple.example/modules/lib.mjs';
模組script預設是defer
傳統指令碼的載入和解析會阻塞html的解析,可以通過新增 defer
屬性解決(讓指令碼載入和html解析並行)
但這裡想告訴你的是,模組指令碼預設具備defer的並行功能,因此無需畫蛇添足加上defer屬性。還有不僅僅只有主模組與html解析並行,其他子模組也一樣。
JS模組化的其他特性
動態引入: import()
我們之前僅僅用到了靜態的 import
,它需要在首屏就把全部模組資源都下載下來。但有時候按需載入或非同步載入會更為合理,這有助於提高首次載入時間,而 import()
可以用來解決這個問題。
<script type="module">(async () => {const moduleSpecifier = './lib.mjs';const {repeat, shout} = await import(moduleSpecifier); // lib會在主模組及其依賴都載入並執行完畢之後才會importrepeat('hello');// → 'hello hello'shout('Dynamic import in action');// → 'DYNAMIC IMPORT IN ACTION!'})();</script>
不像靜態 import
只能用在 <scripttype="module>"
一樣,動態 import()
也可以用在普通的script。具體可以看下我們關於動態import的文章。
NOTE: Webapck自己實現了一套 import()
方案,可以動態將import()進去的模組抽離出來,生成單獨的檔案。
import.meta
另一個和JS modules相關的新特性是 import.meta
,它能提供關於當前模組的meta資訊。準確的meta資訊並不是ECMAScript規範指定的部分,它取決於宿主環境。在瀏覽器拿到的meta資訊和在nodejs裡面拿到的是有區別的。
下面的例子中,圖片的相對路徑預設是基於HTML所在位置來解析的,但通過 import.meta.url
可以實現基於當前模組來解析。
function loadThumbnail(relativePath) {const url = new URL(relativePath, import.meta.url);const image = new Image();image.src = url;return image;}const thumbnail = loadThumbnail('../img/thumbnail.png');container.append(thumbnail);
效能優化建議
繼續使用打包工具
通過模組指令碼,開發時我們可以無需再用webpack、Rollup、Parcel等打包工具就可以享受原生的模組化福利,在以下場景建議可以直接使用原生的模組指令碼:
- 開發環境下
- 不超過100個模組且相對較淺的依賴層級關係(小於5)的小型web應用
然而,我們在效能瓶頸分析中發現,載入一個模組化庫(大約300個模組),經過打包的效能資料要比未經過打包直接使用原生模組指令碼的好。
其中一個原因是 import
/ export
語法是可以靜態分析的,因此打包工具在打包過程中就可以進行靜態分析並移除冗餘未使用的模組。從這可以看出,靜態的 import
/ export
不僅僅只是語法特性,還具備關鍵的工具屬性(可靜態分析)!
我們的總體建議是繼續使用打包工具進行上線前的模組打包處理。畢竟從某種程度上,打包可以幫助你儘可能減少程式碼體積,使用者不必要載入無用的指令碼,更有利於頁面效能。
開發者工具的程式碼覆蓋率檢查能幫助你檢測原始碼中是否存在無用程式碼。我們同時也建議通過程式碼分割對模組進行合理拆分,以及延遲載入非首屏關鍵路徑的指令碼。
打包與使用模組指令碼的權衡取捨
通常在web開發領域,所有方案都有利弊,需要權衡取捨。與載入一個未經過程式碼拆分的打包指令碼相比,使用模組指令碼也許會降低首次載入效能(cold cache),但是可以提升使用者再次載入(warm cache)的速度。比如對於總大小200KB的程式碼,在修改一個細顆粒化的模組之後,那麼使用者只需要更新有變更的程式碼,這總比重新載入所有程式碼(打包指令碼)要強。
如果相對於首次訪問體驗來說,你更關注使用者再次訪問體驗,並且你的應用不超過數百個細顆粒化模組的話,你不妨嘗試下使用模組指令碼,通過效能資料對比之後再做出最後的選擇。
瀏覽器工程師們正努力提升模組指令碼的效能,我們希望模組指令碼以後能夠適用於更多的應用場景。
使用細顆粒化的模組
儘可能讓你的程式碼以細顆粒化的模組進行組織。當在開發時,每個模組最好不要輸出過多的內容。
下面的 ./util.mjs
模組,輸出了 drop
pluck
和 zip
三個函式。
export function drop() { /* … */ }export function pluck() { /* … */ }export function zip() { /* … */ }
如果你的程式碼僅僅只需要 pluck
,你也許會這樣引入:
import { pluck } from './util.mjs';
在這種情況下,如果沒有構建打包編譯,瀏覽器會還是會下載、解析和編譯整個 ./util.js
模組,即使只僅僅需要其中一個export。
如果 pluck
不與 drop
和 zip
有引用或依賴關係的話,最好還是將它獨立成一個模組 ./pluck.mjs
。以達到無需載入其他無用函式的目的。
export function pluck() { /* … */ }
這不僅能夠讓你的原始碼簡潔,還能夠減少對打包工具(移除冗餘程式碼)的依賴。如果在你的應用中其中一個模組從未被 import
過,那麼瀏覽器就不會去下載。而那些真正有用的模組則會被瀏覽器快取起來。
此外,使用細顆粒化的模組也有助於對接未來的瀏覽器原生打包功能。
預載入模組
通過 <linkrel="modulepreload">
你可以進一步優化模組載入。瀏覽器會預載入甚至預解析和編譯這些模組及其依賴。
<link rel="modulepreload" href="lib.mjs"><link rel="modulepreload" href="main.mjs"><script type="module" src="main.mjs"></script><script nomodule src="fallback.js"></script>
這對於有複雜依賴關係模組的應用尤為重要。沒有 rel="modulepreload"
,瀏覽器需要發出多個HTTP請求來計算出整個依賴關係。而如果你把所有依賴模組通過 rel="modulepreload"
提前告訴瀏覽器,那麼瀏覽器則無需再漸進式地去計算。
採用HTTP/2協議
HTTP/2支援多路複用,多個請求及響應資訊可以同時進行傳輸,這有助於提高模組樹的載入效率。
Chrome團隊還預研了伺服器推送——另一個HTTP/2特性,是否能夠作為部署高度模組化應用的一個可行方案。但結局令人失望,HTTP/2的伺服器推送比想象中要難以應用,並且web伺服器及瀏覽器的對其實現目前並沒有針對高度模組化web應用進行優化。另一方面,伺服器很難只推送未被快取的資源。如果通過告知伺服器完整的使用者快取狀態來解決這個問題的話,又存在隱私洩露風險。
無論如何,採用HTTP/2協議吧!只要記住目前HTTP/2的伺服器推送目前還不能作為一個好的解決方案。
目前的使用率
JS modules正在緩慢地被接納使用。我們的使用統計顯示只有0.08%(不包括動態 import()
或者worklets)的頁面目前使用了 <scripttype="module">
。
JS Modules未來的發展
Chrome團隊正在通過不同的方式,致力於提高基於JS modules的開發體驗。下面列舉其中的幾種。
更高效、確定性更高的模組解析演算法
我們提交了一版對於目前模組解析演算法的優化。新演算法目前已經被同時列入了HTML規範和ECMASciprt規範,並且已在Chrome 63版本中實現。希望這項優化能夠在更多的瀏覽器中落地。
新演算法更快更高效,舊演算法在計算依賴圖譜(dependency graph)大小的時間複雜度為O(n²),在Chrome中的實現也是一樣。而新演算法則提升至O(n)。
此外,新演算法在報解析錯誤時更加準確。如果一個依賴圖譜中有多個錯誤,那麼基於舊演算法,每次執行都會報不同的解析錯誤。這給開發除錯帶來不必要的困難。新演算法則保證每次執行都會報相同的解析錯誤。
Worklets 和 web workers
Chrome實現了worklets,允許web開發者自定義那些在瀏覽器底層的硬編碼邏輯。目前開發者可以將一個JS模組引入到渲染管道(rendering pipeline)或者音訊處理管道。
Chrome65版本支援了 PaintWorklet
,也稱為CSS繪製API(the CSS Paint API),用於控制如何繪製一個DOM元素。
const result = await css.paintWorklet.addModule('paint-worklet.mjs');
Chrome66版本支援了 AudioWorklet
,允許開發者注入自定義的音訊處理程式碼。同時這個版本開始了 AnimationWorklet
的公測,開發者可以創造視差滾動效果(scroll-linked)以及其他高效能程式動畫(procedural animations)。
最後, LayoutWorklet
,又稱為CSS佈局API(the CSS Layout API)已在Chrome67版本中實現。
我們正在對Chrome中的web workers支援傳入模組指令碼。你可以通過輸入 chrome://flags/#enable-experimental-web-platform-features
開啟這個特性。
const worker = new Worker('worker.mjs', { type: 'module' });
在shared workers和service workers傳入模組指令碼也即將支援。
const worker = new SharedWorker('worker.mjs', { type: 'module' });const registration = await navigator.serviceWorker.register('worker.mjs', { type: 'module' });
包名對映表 - Package name maps
在nodejs/npm中,我們經常會通過它們的包名引入模組,比如:
import moment from 'moment';import { pluck } from 'lodash-es';
根據現行的HTML規範,類似上述的包名寫法(bare import specifiers)會丟擲異常。我們提交的“包名對映表”提案將會支援上述寫法(包括在生產環境)。該對映表(JSON格式)將幫助瀏覽器將包名轉換為完整資源路徑(full URLs)。
包名對映表目前仍處於提案階段(proposal stage)。
Web packaging:瀏覽器原生打包
Chrome loading團隊正在探索一種原生的web打包格式(下稱為web packaging),作為一種新模式來分發web應用。web packaging的主要特性如下:
- Signed HTTP Exchanges:可以讓瀏覽器信任某個HTTP請求對(request/response)確實是來自於所宣告的源伺服器。
- Bundled HTTP Exchanges:是多個請求對的集合,不要求當中的每個請求都進行簽名(signed),只要攜帶某些元資料(metadata)用於描述如何將請求束作為一個整體來解析。
兩者結合起來,這種web打包格式就能夠將多個同源資源安全地整合到一個HTTP GET相應中。
市面上的打包工具如webpack、Rollup、Parcel,都會將多個模組最終打包成一個或少數幾個bundle,這會導致原始碼中進行的模組拆分在上線後就喪失了它的意義。那麼通過原生打包,瀏覽器可以將bundle反解成原樣。
簡單來說,你可以把一個HTTP請求對包(Bundled HTTP Exchange)理解為一個資原始檔包,它可以通過目錄表(manifest)隨意訪問,並且裡面的資源能夠被高效地快取以及根據相對優先順序的高低來標記。有了這個機制,原生模組能夠提升開發除錯的體驗。當你在Chrome開發者工具檢視資源時,瀏覽器會精準定位到原生的模組程式碼中,而不需要複雜的source-map。
Chrome已經實現了一部分提案(SignedExchanges),但是打包格式(bundling format)以及在高度模組化app中的應用仍在探索階段。
Layered APIs
移植新的功能和API到瀏覽器中無可避免會帶來持續性的維護成本以及執行成本。每一個新特性都會汙染瀏覽器的名稱空間,增加啟動開銷,並且也增大引入bug的可能性。Layered APIs的目的是以一種更具擴充套件性的方式通過瀏覽器來實現或移植一些高階API。而模組指令碼是實現Layered APIs的一項關鍵技術。
- 由於模組是顯式引入的,所以通過模組來引入layered APIs可實現按需使用(不會預設內建)。
- 模組的載入源可自定義,因此layered APIs實現了一套自動載入polyfill(當不支援時)的機制。
模組指令碼和layered APIs如何協同運作,具體細節仍在制定中,但目前的協議如下:
<!-- src中豎槓後面是指定polyfill的路徑,瀏覽器不支援時可自動載入,不錯的降級方式 --><scripttype="module"src="std:virtual-scroller|https://example.com/virtual-scroller.mjs"></script><virtual-scroller><!-- Content goes here. --></virtual-scroller>
這個模組指令碼引入了 virtual-scroller
API,如果瀏覽器支援則會直接讀取內建layered APIs集合(std:virtual-scroller),反之則網路載入對應的polyfill。
譯者:對於Layered APIs更多的中文介紹 https://zhuanlan.zhihu.com/p/...
此文已由騰訊雲+社群在各渠道釋出
獲取更多新鮮技術乾貨,可以關注我們 騰訊雲技術社群-雲加社群官方號及知乎機構號