在瀏覽器中使用原生 JavaScript 模塊 (譯)

上週在四個不一樣的地方看到了推薦 Using JavaScript modules on the web 這篇文章,以前一直沒有去了解過原生模塊在web瀏覽器中該如何使用,週末把這篇文章大體翻譯了一下。

JS 模塊 目前已獲得全部主流瀏覽器的支持,本文將講述什麼是 JS 模塊,如何使用 JS 模塊,以及 Chrome 團隊將來計劃如何優化 JS 模塊。javascript

什麼是 JavaScript 模塊

JS modules 其實是一系列功能的集合。以前你可能聽過說 Common JSAMD 等模塊標準,不一樣標準的模塊功能都是相似的,都容許你 import 或者 export 一些東西。css

JavaScript 模塊目前有標準的語法,在模塊中,你能夠經過 export 關鍵字,導出一切東西(變量,函數,其它聲明等等)html

// 📁 lib.mjs
export const repeat = (string) => `${string} ${string}`;
export function shout(string) {
  return `${string.toUpperCase()}!`;
}

而想要導入該模塊,只須要在其它文件中使用import 關鍵字引入便可java

// 📁 main.mjs
import {repeat, shout} from './lib.mjs';
repeat('hello');
// → 'hello hello'
shout('Modules in action');
// → 'MODULES IN ACTION!'

模塊中還能夠導出默認值node

// 📁 lib.mjs
export default function(string) {
  return `${string.toUpperCase()}!`;
}

具備默認值的模塊能夠以任意名字導入到其它模塊中jquery

// 📁 main.mjs
import shout from './lib.mjs';
//     ^^^^^

模塊和傳統的script 標籤引入腳本有一些區別,以下:webpack

  • JS模塊默認使用嚴格模式
  • 模塊中不支持使用 html 格式的註釋,即<!-- TODO: Rename x to y. -->
  • 模塊會建立本身的頂級詞義上下文,這意味着,當在模塊中使用var foo = 42; 語句時,並不會建立一個全局變量foo, 所以也不能經過window.foo在瀏覽器中訪問該變量。
  • importexport 關鍵字只在模塊中有效。

因爲存在上述不一樣,經過傳統方式引入的腳本 和 以模塊方式引入的腳本,就會有相同的代碼,也會產生不一樣的行爲,於是 JS 執行環節須要知道那些腳本是模塊。git

在 瀏覽器中使用模塊

在 瀏覽器中,經過設置 <script> 元素的type 屬性爲 module 能夠聲明其實一個模塊。github

<script type="module" src="main.mjs"></script>
<script nomodule src="fallback.js"></script>

支持type="module"的瀏覽器會忽略帶有nomudule屬性的的<script>元素,這樣就提供了降級處理的空間。其意義不只如此,支持type="module"的環境意味着其也支持箭頭函數,async-await等新語法功能,這樣引入的腳本無須再作轉義處理了。web

瀏覽器會區別對待 JS模塊 和傳統方式引入的腳本

若是模塊引入了屢次,瀏覽器只會執行一次相同模塊中的代碼,而對傳統的方式引入的腳本引入了多少次,瀏覽器就會執行多少次。

<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. -->

此外,JS 模塊對於的腳本存在跨域限制,傳統的腳本引入則不存在。

對於async屬性,瀏覽器對兩者也會區別對待,async屬性被用來告知瀏覽器下載腳本但不要阻塞 HTML 渲染,而且但願一旦下載完成,就當即執行,不用考慮順序,不用考慮HTML渲染是否完成,async 屬性在傳統的行內<script>元素引入時是無效,可是在行內<script type="module">倒是有效的。

關於擴展名的說明

上文中,咱們一直在使用.mjs做爲模塊的拓展名,實際上,在web 上,拓展名自己並不重要,重要的是該文件的MIME type 須要設置爲 text/javascript ,瀏覽器僅經過<script>元素上的type屬性來識別其是不是一個模塊。

不過咱們仍是推薦使用.mjs拓展名 ,有以下兩個緣由:

  1. 開發階段,這個拓展名能夠充分說明它是一個模塊,畢竟模塊和普通的腳本仍是有區別的。
  2. .mjs和node兼容;

Module specifiers

當引入模塊時,指明模塊位置的部分被稱爲 Module specifiers,也叫作 import specifier 。

import {shout} from './lib.mjs';
//                  ^^^^^^^^^^^

瀏覽器對模塊的引入有一些嚴格的限制,裸模塊目前是不支持的,這樣是爲了在未來爲裸模塊添加特定的意義,以下面這些作法是不行的:

// Not supported (yet):
import {shout} from 'jquery';
import {shout} from 'lib.mjs';
import {shout} from 'modules/lib.mjs';

下面這些的用法則都是支持的

// 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';

總的來講,目前模塊引入路徑要求必須是完整的URLs,或者是以/,./,../開頭的相對URLs。

模塊默認會deferred

傳統的<script> 的下載默認會阻塞 HTML 渲染。不過能夠經過添加defer屬性,使得其下載與 HTML 渲染同步進行。
下圖說明了不一樣的屬性,腳本下載與執行對 HTML 渲染的影響

image.png

模塊腳本默認爲defer , 其依賴的全部其它模塊也會以 defer 模式加載。

其它的模塊特性

動態 import()

前面咱們一直在使用靜態import, 靜態import 意味着全部的模塊須要在主代碼執行前下載完,有時候有些模塊並不須要你提早加載,更合適的方案是按需加載,好比說用戶點擊了某個按鈕的時候再加載。這樣作能有效提高初始頁面加載效率,Dynamic import()就是用來知足這種需求的。

<script type="module">
  (async () => {
    const moduleSpecifier = './lib.mjs';
    const {repeat, shout} = await import(moduleSpecifier);
    repeat('hello');
    // → 'hello hello'
    shout('Dynamic import in action');
    // → 'DYNAMIC IMPORT IN ACTION!'
  })();
</script>

不像靜態import(), 動態import() 能夠還在常規的腳本中使用,更多細節能夠參考Dynamic import()

注:這和 webpack 提供的動態加載有所不一樣,webpack 有其獨特的作法進行代碼分割以知足按需加載。

import.meta

import.meta是模塊相關的另外一個特性,此特性包含關於當前模塊的metadata,準確的metadata 並未定義爲 ECMAScript 標準的一部分。import.meta的值其實依賴於宿主環境,在瀏覽器和 NodeJS 中可能就會獲得不一樣的值。

如下是一個import.meta的使用示例,默認狀況下,圖片是基於當前 HTML 的 URL 的相對地址,import.meta.url使得基於當前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 , Roolup 或者 Parcel 之類的構建工具成爲可能。在如下狀況下直接使用原生的 JS module 是可行的:

  • 在本地開發環境中
  • 小型項目(所依賴模塊不超過100個,依賴樹淺,好比依賴層級不超過5層)

參考Chrome 加載瓶頸一文,當加載模塊數量爲300個時,打包過的 app 的加載性能比未打包的好得多。

image.png

產生這種現象的緣由在於,靜態的import/export 會執行靜態分析,用以幫助打包工具去除未使用的exports以優化代碼,可見靜態的importexport 不只僅是起到語法做用,它們還起到工具的做用。

咱們推薦在部署代碼到生產環境以前繼續使用構建工具,構建工具也會經過優化來減小你的代碼,並由此帶來運行性能的提高。

谷歌開發者工具中的 Code Coverage 功能能夠幫你識別,那些是沒必要要的代碼,咱們推薦使用代碼分割延遲加載非首屏須要的代碼。

對使用打包文件和使用未打包的模塊的權衡

在 web 上,不少事情都須要權衡,加載未打包的組件可能會下降初次加載的效率(cold cache),可是比起沒有代碼分割的打包,能夠明顯提升二次訪問(warm cache)時的性能。好比說大小爲 200kb 的代碼,若是後期又改變了一個細粒度的模塊,二次訪問時,未打包的代碼的性能會比打包的好得多。

這是矛盾所在,若是你不知道 二次訪問的體驗 和 首次加載的性能那個更重要,能夠AB測試一下,用數據來看那種效果更好。

瀏覽器工程師們正在努力改進模塊的性能。但願在不久的未來,未打包的模塊能夠在更多的場景中使用。

使用細粒度的模塊

咱們應該養成使用細粒度模塊的習慣。在開發過程當中,一般來講,一個文件只有少數幾個export比包含大量export的要好。

好比說在./utils.mjs模塊中,export了三個方法,drop,pluck,zip:

export function drop() { /* … */ }
export function pluck() { /* … */ }
export function zip() { /* … */ }

若是你的函數只須要pluck方法,你會如下面的方法引入:

import { pluck } from './util.mjs';

這種狀況下,若是沒有不經過構建過程,瀏覽器依舊會下載並解析整個./utils.mjs文件,這樣明顯有些浪費。

若是pluck()zip(),drop()沒有什麼共用的代碼,更好的實現是將其移動到本身獨立的細粒度模塊中:

export function pluck() { /* … */ }

這樣再導入 pluck 時就無需解析沒有用到的模塊了。

這樣作不只保持了你的源碼的簡潔乾淨,同時還能減輕了構建工具的壓力,若是你的源代碼中某個模塊從未被import過,瀏覽器就永遠不會下載它,而那些用到了的模塊則會被瀏覽器緩存。

使用細粒度的模塊,也使得在未來原生的打包方案到來時,你現有的代碼能更好的進行適配。

預加載模塊

你能夠經過使用<link rel="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>

這在處理依賴複雜的app時效果尤其明顯,若是不使用rel="modulepreload",瀏覽器須要執行多個 HTTP 請求來得到完成的依賴,若是你使用上述方法指明瞭依賴,瀏覽器則不須要漸進的來查找相關依賴。

使用HTTP/2

若是可能,儘可能使用HTTP/2 ,這對性能的提高也是顯而易見的, multiplexing support

容許多請求和多響應能夠同時進行,若是模塊數量很大,這一點尤其有用。

Chrome 團隊還調查過 HTTP/2 的另外一個特性,server push 能不能也成爲開發高模塊化app的解決方案,可是不幸的是,HTTP/2 push is tougher than I thought - JakeArchibald.com,web 服務器和瀏覽器的實現目前尚未針對高模塊化的 web 應用程序用例進行優化, 所以很難實現推送用戶沒有緩存的內容,而若是要對比整個cache,對用戶來講存在隱私風險。

不過,無論怎麼樣,用 HTTP/2 仍是頗有好處的,不過 HTTP/2 server push 還不是一個有效的方案.

web 上目前JS 模塊的使用狀況

JS 模塊在逐步被 web 採用,據 usage counters 統計,大概有0.08%的網頁目前在使用<script type="module">, 不過須要注意,這類數據中包括動態import()worklets 相關的數據。

JS modules 將來會如何發展

Chrome 團隊致力於改進開發階段使用 JS modules 的體驗,如下是一些方向:

更快更準確的模塊解析算法

谷歌提出了一種更快更準確的模塊解析算法,目前這種算法已經存在於 HTML 規範ECMA 規範中,該算法在Chrome63 中已經開始使用,能夠預見在不久的未來將會應用於更多的瀏覽器中。

舊算法的時間複雜度爲O(n²),而新算法則爲O(n)

新算法還能夠針對錯誤給出更有效的提示,相比較而言,舊算法對錯誤的處理就沒那麼有效。

Worklets 和 workers

Chrome 如今能夠執行 worklets 了,worklets 容許 web 開發者在web瀏覽器的底層執行復雜的邏輯運算,經過 worklets ,web 開發人員能夠將 JS 模塊提供給渲染 pipeline 或音頻處理pipeline 使用,將來會有更多的pipeline 支持。

Chrome 65 支持 PaintWorklet (CSS 渲染API)來控制如何渲染一個DOM。

const result = await css.paintWorklet.addModule('paint-worklet.mjs');

Chrome66 支持 AudioWorklet 容許你在代碼中控制音頻的處理,該版本還開始試驗支持 AnimationWorklet,它容許建立滾動連接和其餘高性能的過程動畫。

layoutWorklet,(CSS 佈局 API) 已經開始在Chrome 67 中試用。

Chrome 團隊 還在努力 在 Chrome 中增長支持使用 JS 模塊的 web worker 。能夠經過 chrome://flags/#enable-experimental-web-platform-features 來啓用這一功能。

const worker = new Worker('worker.mjs', { type: 'module' });

支持共享worker 和 服務worker 的 JS 模塊也即將到來:

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 標準,此類裸引用會拋出錯誤,Package name maps 提議則容許在 web 和生產環境的 app 上支持此類用法,一個 package name map 其實是一個幫助瀏覽器轉換 specifiers 爲完整 URLs 的JSON。

package name map 還處於提議階段,儘管Chrome 團隊已經提出了多種使用示例, 可是目前還處於和社區的溝通中, 目前也尚未成文的規範。

Web package:原生打包

Chrome loading 團隊,目前正在探索一種原生的 web 構建模式來分發 web app。web packaging 的關鍵點在於:
Signed HTTP Exchanges 容許瀏覽器信任單個 HTTP 請求/響應對由它聲稱的來源生成;
Bundled HTTP Exchanges, 一系列交換的集合,能夠是簽名的或無簽名的, 其中包含一些元數據來描述瞭如何將包解釋爲一個總體。

有上述做爲基礎, web 打包就能夠把多個相同來源的資源安全地嵌入到單個 HTTP 獲取響應中.

現存的諸如 webpack, Rollup,Parcel 等打包工具目前都將文件打包爲一個單一的 JS 文件,這會致使原始模塊語義的丟失,而經過原生的打包,瀏覽器能夠解壓打包資源爲原始的狀態。這就保持了單個資源的獨立性。原生打包由此能夠改進調試的體驗,當在devtools 中查看資源時,瀏覽器能夠指明原始的模塊,而再也不須要使用複雜的 source-map 了。

原生打包還提供了其它優化的可能,好比說,若是瀏覽器已經緩存了部份內容在本地,瀏覽器能夠只在服務器下載缺失的部分。

Chrome 已經支持這個提議的一部分(SignedExchange),不過原生打包自己即其在高模塊化app中的應用還處於探索階段。

Layers APIs

每一個新功能均可能會污染瀏覽器命名空間, 增長啓動成本, 在整個代碼庫中引入 bug。Layers APIs 是在將更高層次的 api 與 web 瀏覽器結合在一塊兒所作的努力。JS 模塊是分層 api 的關鍵依賴技術:

  • 因爲模塊是顯式導入的, 所以須要經過模塊公開分層 api, 以確保開發人員只用管他們使用的Layers APIs;
  • 模塊加載是可配置的, 所以Layers APIs 也能夠有一個內置機制, 用於在不支持Layers APIs 的瀏覽器中自動加載 polyfills。

模塊與 Layers APIs 該如何協同使用目前尚未定論,目前的提議用法以下:

<script
  type="module"
  src="std:virtual-scroller|https://example.com/virtual-scroller.mjs"
></script>

瀏覽器按照上述方法在<script>標準中加載 Layers APIs。

相關文章
相關標籤/搜索