開始在web中使用JS Modules

本文由雲+社區發表javascript

做者:css

原文:《Using JavaScript modules on the web》 https://developers.google.com/web/fundamentals/primers/moduleshtml

譯者序

JS modules,即ES6的模塊化特性,經過 <scripttype="modules">能夠實現不通過打包直接在瀏覽器中import/export,此玩法確實讓人眼前一亮。java

先看看 <scripttype="modules">的兼容性。目前只有較新版本的chrome/firefox/safari/edge支持此特性,看來要普及使用還任重道遠。下面跟着這篇文章深刻了解一下漲漲姿式。node

img

本文將介紹JS模塊化;怎樣在不通過打包的狀況下直接在瀏覽器中使用模塊化;以及Chrome團隊在JS模塊化的優化和普及上正在作的一些事情。jquery

JS模塊化

你可能用過命名空間、CommonJS或者AMD規範進行JS模塊化,但全部的這些模塊解決方案萬變不離其宗:引入(import)其餘模塊,做爲一個模塊輸出(export)。若是說命名空間、CommonJS、AMD都是野路子,那ES6的JS modules則是正規軍,將模塊化語法統一塊兒來(一統江湖,千秋萬代)。webpack

在JS modules中,你可使用 export關鍵字輸出任何東西: constfunction等。web

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

而後你能夠用 import關鍵字從另外一個模塊中引進來。下面代碼將lib模塊中的 repeatshout函數引到了咱們的主模塊main中。算法

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

你也能夠經過 default關鍵字,輸出一個默認值。chrome

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

而經過上面的 default輸出的模塊,在引入時能夠用其餘任何變量名。

// main.mjsimport shout from './lib.mjs';//     ^^^^^

模塊腳本與常規腳本有所區別:

  • 模塊腳本默認開啓了嚴格模式
  • 不支持HTML風格的註釋 <!-- comment -->
  • 模塊具備詞法頂級做用域。也就是說在模塊中 varfoo=42;並不會像傳統腳本同樣,建立一個全局變量 foo,能夠經過 window.foo訪問。
  • 新的 importexport語法僅限於在模塊腳本中使用,不能用在常規腳本中。

正由於這些差別,模塊腳本和傳統腳本顯然須要各自不一樣的解析方式。所以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。

img

IE系列均會執行fallback.js

img

加載fallback的同時,也會把index.mjs一併加載

img

而支持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,緣由有兩個:

  1. 在開發的時候,能夠不須要看代碼,經過後綴名很是直觀地看出哪些是模塊腳本。
  2. nodejs中,ES6的模塊化特性仍在實驗性階段,而該特性只支持 .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解析並行)

img

但這裏想告訴你的是,模塊腳本默認具有defer的並行功能,所以無需多此一舉加上defer屬性。還有不只僅只有主模塊與html解析並行,其餘子模塊也同樣。

JS模塊化的其餘特性

動態引入: import()

咱們以前僅僅用到了靜態的 import,它須要在首屏就把所有模塊資源都下載下來。但有時候按需加載或異步加載會更爲合理,這有助於提升首次加載時間,而 import()能夠用來解決這個問題。

<script type="module">  (async () => {    const moduleSpecifier = './lib.mjs';    const {repeat, shout} = await import(moduleSpecifier); // lib會在主模塊及其依賴都加載並執行完畢以後纔會import    repeat('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等打包工具就能夠享受原生的模塊化福利,在如下場景建議能夠直接使用原生的模塊腳本:

  1. 開發環境下
  2. 不超過100個模塊且相對較淺的依賴層級關係(小於5)的小型web應用

然而,咱們在性能瓶頸分析中發現,加載一個模塊化庫(大約300個模塊),通過打包的性能數據要比未通過打包直接使用原生模塊腳本的好。

img

其中一個緣由是 import/ export語法是能夠靜態分析的,所以打包工具在打包過程當中就能夠進行靜態分析並移除冗餘未使用的模塊。從這能夠看出,靜態的 import/ export不只僅只是語法特性,還具有關鍵的工具屬性(可靜態分析)!

咱們的整體建議是繼續使用打包工具進行上線前的模塊打包處理。畢竟從某種程度上,打包能夠幫助你儘量減小代碼體積,用戶沒必要要加載無用的腳本,更有利於頁面性能。

開發者工具的代碼覆蓋率檢查能幫助你檢測源碼中是否存在無用代碼。咱們同時也建議經過代碼分割對模塊進行合理拆分,以及延遲加載非首屏關鍵路徑的腳本。

打包與使用模塊腳本的權衡取捨

一般在web開發領域,全部方案都有利弊,須要權衡取捨。與加載一個未通過代碼拆分的打包腳本相比,使用模塊腳本也許會下降首次加載性能(cold cache),可是能夠提高用戶再次加載(warm cache)的速度。好比對於總大小200KB的代碼,在修改一個細顆粒化的模塊以後,那麼用戶只須要更新有變動的代碼,這總比從新加載全部代碼(打包腳本)要強。

若是相對於首次訪問體驗來講,你更關注用戶再次訪問體驗,而且你的應用不超過數百個細顆粒化模塊的話,你不妨嘗試下使用模塊腳本,經過性能數據對比以後再作出最後的選擇。

瀏覽器工程師們正努力提高模塊腳本的性能,咱們但願模塊腳本之後可以適用於更多的應用場景。

使用細顆粒化的模塊

儘量讓你的代碼以細顆粒化的模塊進行組織。當在開發時,每一個模塊最好不要輸出過多的內容。

下面的 ./util.mjs模塊,輸出了 drop pluckzip三個函數。

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

若是你的代碼僅僅只須要 pluck,你也許會這樣引入:

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

在這種狀況下,若是沒有構建打包編譯,瀏覽器會仍是會下載、解析和編譯整個 ./util.js模塊,即便只僅僅須要其中一個export。

若是 pluck不與 dropzip有引用或依賴關係的話,最好仍是將它獨立成一個模塊 ./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的主要特性以下:

  1. Signed HTTP Exchanges:可讓瀏覽器信任某個HTTP請求對(request/response)確實是來自於所聲明的源服務器。
  2. 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的路徑,瀏覽器不支持時可自動加載,不錯的降級方式 --><script  type="module"  src="std:virtual-scroller|https://example.com/virtual-scroller.mjs"></script><virtual-scroller>  <!-- Content goes here. --></virtual-scroller>

這個模塊腳本引入了 virtual-scrollerAPI,若是瀏覽器支持則會直接讀取內置layered APIs集合(std:virtual-scroller),反之則網絡加載對應的polyfill。

譯者:對於Layered APIs更多的中文介紹 https://zhuanlan.zhihu.com/p/37008246

此文已由騰訊雲+社區在各渠道發佈

獲取更多新鮮技術乾貨,能夠關注咱們騰訊雲技術社區-雲加社區官方號及知乎機構號

相關文章
相關標籤/搜索