Javascript 模塊 (Module) 簡介

2023-02-17 10:53 更新

隨著我們的應用越來越大,我們想要將其拆分成多個文件,即所謂的“模塊(module)”。一個模塊可以包含用于特定目的的類或函數(shù)庫。

很長一段時間,JavaScript 都沒有語言級(language-level)的模塊語法。這不是一個問題,因為最初的腳本又小又簡單,所以沒必要將其模塊化。

但是最終腳本變得越來越復雜,因此社區(qū)發(fā)明了許多種方法來將代碼組織到模塊中,使用特殊的庫按需加載模塊。

列舉一些(出于歷史原因):

  • AMD —— 最古老的模塊系統(tǒng)之一,最初由 require.js 庫實現(xiàn)。
  • CommonJS —— 為 Node.js 服務器創(chuàng)建的模塊系統(tǒng)。
  • UMD —— 另外一個模塊系統(tǒng),建議作為通用的模塊系統(tǒng),它與 AMD 和 CommonJS 都兼容。

現(xiàn)在,它們都在慢慢成為歷史的一部分,但我們?nèi)匀豢梢栽谂f腳本中找到它們。

語言級的模塊系統(tǒng)在 2015 年的時候出現(xiàn)在了標準(ES6)中,此后逐漸發(fā)展,現(xiàn)在已經(jīng)得到了所有主流瀏覽器和 Node.js 的支持。因此,我們將從現(xiàn)在開始學習現(xiàn)代 JavaScript 模塊(module)。

什么是模塊?

一個模塊(module)就是一個文件。一個腳本就是一個模塊。就這么簡單。

模塊可以相互加載,并可以使用特殊的指令 export 和 import 來交換功能,從另一個模塊調(diào)用一個模塊的函數(shù):

  • ?export? 關鍵字標記了可以從當前模塊外部訪問的變量和函數(shù)。
  • ?import? 關鍵字允許從其他模塊導入功能。

例如,我們有一個 sayHi.js 文件導出了一個函數(shù):

//  sayHi.js
export function sayHi(user) {
  alert(`Hello, ${user}!`);
}

……然后另一個文件可能導入并使用了這個函數(shù):

//  main.js
import { sayHi } from './sayHi.js';

alert(sayHi); // function...
sayHi('John'); // Hello, John!

import 指令通過相對于當前文件的路徑 ./sayHi.js 加載模塊,并將導入的函數(shù) sayHi 分配(assign)給相應的變量。

讓我們在瀏覽器中運行一下這個示例。

由于模塊支持特殊的關鍵字和功能,因此我們必須通過使用 <script type="module"> 特性(attribute)來告訴瀏覽器,此腳本應該被當作模塊(module)來對待。

像這樣:

  • say.js
  • export function sayHi(user) {
      return `Hello, ${user}!`;
    }
  • index.html
  • <!doctype html>
    <script type="module">
      import {sayHi} from './say.js';
    
      document.body.innerHTML = sayHi('John');
    </script>

瀏覽器會自動獲取并解析(evaluate)導入的模塊(如果需要,還可以分析該模塊的導入),然后運行該腳本。

模塊只通過 HTTP(s) 工作,而非本地

如果你嘗試通過 file:// 協(xié)議在本地打開一個網(wǎng)頁,你會發(fā)現(xiàn) import/export 指令不起作用。你可以使用本地 Web 服務器,例如 static-server,或者使用編輯器的“實時服務器”功能,例如 VS Code 的  Live Server Extension 來測試模塊。

模塊核心功能

與“常規(guī)”腳本相比,模塊有什么不同呢?

下面是一些核心的功能,對瀏覽器和服務端的 JavaScript 來說都有效。

始終使用 “use strict”

模塊始終在嚴格模式下運行。例如,對一個未聲明的變量賦值將產(chǎn)生錯誤(譯注:在瀏覽器控制臺可以看到 error 信息)。

<script type="module">
  a = 5; // error
</script>

模塊級作用域

每個模塊都有自己的頂級作用域(top-level scope)。換句話說,一個模塊中的頂級作用域變量和函數(shù)在其他腳本中是不可見的。

在下面這個例子中,我們導入了兩個腳本,hello.js 嘗試使用在 user.js 中聲明的變量 user。它失敗了,因為它是一個單獨的模塊:

  • hello.js
  • alert(user); // no such variable (each module has independent variables)
    
  • user.js
  • let user = "John";
    
  • index.html
  • <!doctype html>
    <script type="module" src="user.js"></script>
    <script type="module" src="hello.js"></script>

模塊應該 export 它們想要被外部訪問的內(nèi)容,并 import 它們所需要的內(nèi)容。

  • ?user.js? 應該導出 ?user? 變量。
  • ?hello.js? 應該從 ?user.js? 模塊中導入它。

換句話說,對于模塊,我們使用導入/導出而不是依賴全局變量。

這是正確的變體:

  • hello.js
  • import {user} from './user.js';
    
    document.body.innerHTML = user; // John
  • user.js
  • export let user = "John";
  • index.html
  • <!doctype html>
    <script type="module" src="hello.js"></script>

在瀏覽器中,對于 HTML 頁面,每個 <script type="module"> 都存在獨立的頂級作用域。

下面是同一頁面上的兩個腳本,都是 type="module"。它們看不到彼此的頂級變量:

<script type="module">
  // 變量僅在這個 module script 內(nèi)可見
  let user = "John";
</script>

<script type="module">
  alert(user); // Error: user is not defined
</script>

請注意:

在瀏覽器中,我們可以通過將變量顯式地分配給 window 的一個屬性,使其成為窗口級別的全局變量。例如 window.user = "John"。

這樣所有腳本都會看到它,無論腳本是否帶有 type="module"。

也就是說,創(chuàng)建這種全局變量并不是一個好的方式。請盡量避免這樣做。

模塊代碼僅在第一次導入時被解析

如果同一個模塊被導入到多個其他位置,那么它的代碼只會執(zhí)行一次,即在第一次被導入時。然后將其導出(export)的內(nèi)容提供給進一步的導入(importer)。

只執(zhí)行一次會產(chǎn)生很重要的影響,我們應該意識到這一點。

讓我們看幾個例子。

首先,如果執(zhí)行一個模塊中的代碼會帶來副作用(side-effect),例如顯示一條消息,那么多次導入它只會觸發(fā)一次顯示 —— 即第一次:

//  alert.js
alert("Module is evaluated!");
// 在不同的文件中導入相同的模塊

//  1.js
import `./alert.js`; // Module is evaluated!

//  2.js
import `./alert.js`; // (什么都不顯示)

第二次導入什么也沒顯示,因為模塊已經(jīng)執(zhí)行過了。

這里有一條規(guī)則:頂層模塊代碼應該用于初始化,創(chuàng)建模塊特定的內(nèi)部數(shù)據(jù)結(jié)構(gòu)。如果我們需要多次調(diào)用某些東西 —— 我們應該將其以函數(shù)的形式導出,就像我們在上面使用 sayHi 那樣。

現(xiàn)在,讓我們看一個更復雜的例子。

我們假設一個模塊導出了一個對象:

//  admin.js
export let admin = {
  name: "John"
};

如果這個模塊被導入到多個文件中,模塊僅在第一次被導入時被解析,并創(chuàng)建 admin 對象,然后將其傳入到所有的導入。

所有的導入都只獲得了一個唯一的 admin 對象:

//  1.js
import { admin } from './admin.js';
admin.name = "Pete";

//  2.js
import { admin } from './admin.js';
alert(admin.name); // Pete

// 1.js 和 2.js 引用的是同一個 admin 對象
// 在 1.js 中對對象做的更改,在 2.js 中也是可見的

正如你所看到的,當在 1.js 中修改了導入的 admin 中的 name 屬性時,我們在 2.js 中可以看到新的 admin.name。

這正是因為該模塊只執(zhí)行了一次。生成導出,然后這些導出在導入之間共享,因此如果更改了 admin 對象,在其他導入中也會看到。

這種行為實際上非常方便,因為它允許我們“配置”模塊。

換句話說,模塊可以提供需要配置的通用功能。例如身份驗證需要憑證。那么模塊可以導出一個配置對象,期望外部代碼可以對其進行賦值。

這是經(jīng)典的使用模式:

  1. 模塊導出一些配置方法,例如一個配置對象。
  2. 在第一次導入時,我們對其進行初始化,寫入其屬性??梢栽趹庙敿壞_本中進行此操作。
  3. 進一步地導入使用模塊。

例如,admin.js 模塊可能提供了某些功能(例如身份驗證),但希望憑證可以從模塊之外賦值到 config 對象:

//  admin.js
export let config = { };

export function sayHi() {
  alert(`Ready to serve, ${config.user}!`);
}

這里,admin.js 導出了 config 對象(最初是空的,但也可能有默認屬性)。

然后,在 init.js 中,我們應用的第一個腳本,我們從 init.js 導入了 config 并設置了 config.user

//  init.js
import { config } from './admin.js';
config.user = "Pete";

……現(xiàn)在模塊 admin.js 已經(jīng)是被配置過的了。

其他導入可以調(diào)用它,它會正確顯示當前用戶:

//  another.js
import { sayHi } from './admin.js';

sayHi(); // Ready to serve, Pete!

import.meta

import.meta 對象包含關于當前模塊的信息。

它的內(nèi)容取決于其所在的環(huán)境。在瀏覽器環(huán)境中,它包含當前腳本的 URL,或者如果它是在 HTML 中的話,則包含當前頁面的 URL。

<script type="module">
  alert(import.meta.url); // 腳本的 URL
  // 對于內(nèi)聯(lián)腳本來說,則是當前 HTML 頁面的 URL
</script>

在一個模塊中,“this” 是 undefined

這是一個小功能,但為了完整性,我們應該提到它。

在一個模塊中,頂級 this 是 undefined。

將其與非模塊腳本進行比較會發(fā)現(xiàn),非模塊腳本的頂級 this 是全局對象:

<script>
  alert(this); // window
</script>

<script type="module">
  alert(this); // undefined
</script>

瀏覽器特定功能

與常規(guī)腳本相比,擁有 type="module" 標識的腳本有一些特定于瀏覽器的差異。

如果你是第一次閱讀或者你不打算在瀏覽器中使用 JavaScript,那么你可以跳過本節(jié)內(nèi)容。

模塊腳本是延遲的

模塊腳本 總是 被延遲的,與 defer 特性(在 腳本:async,defer 一章中描述的)對外部腳本和內(nèi)聯(lián)腳本(inline script)的影響相同。

也就是說:

  • 下載外部模塊腳本 ?<script type="module" src="...">? 不會阻塞 HTML 的處理,它們會與其他資源并行加載。
  • 模塊腳本會等到 HTML 文檔完全準備就緒(即使它們很小并且比 HTML 加載速度更快),然后才會運行。
  • 保持腳本的相對順序:在文檔中排在前面的腳本先執(zhí)行。

它的一個副作用是,模塊腳本總是會“看到”已完全加載的 HTML 頁面,包括在它們下方的 HTML 元素。

例如:

<script type="module">
  alert(typeof button); // object:腳本可以“看見”下面的 button
  // 因為模塊是被延遲的(deferred,所以模塊腳本會在整個頁面加載完成后才運行
</script>

相較于下面這個常規(guī)腳本:

<script>
  alert(typeof button); // button 為 undefined,腳本看不到下面的元素
  // 常規(guī)腳本會立即運行,常規(guī)腳本的運行是在在處理頁面的其余部分之前進行的
</script>

<button id="button">Button</button>

請注意:上面的第二個腳本實際上要先于前一個腳本運行!所以我們會先看到 undefined,然后才是 object。

這是因為模塊腳本是被延遲的,所以要等到 HTML 文檔被處理完成才會執(zhí)行它。而常規(guī)腳本則會立即運行,所以我們會先看到常規(guī)腳本的輸出。

當使用模塊腳本時,我們應該知道 HTML 頁面在加載時就會顯示出來,在 HTML 頁面加載完成后才會執(zhí)行 JavaScript 模塊,因此用戶可能會在 JavaScript 應用程序準備好之前看到該頁面。某些功能那時可能還無法正使用。我們應該放置“加載指示器(loading indicator)”,否則,請確保不會使用戶感到困惑。

Async 適用于內(nèi)聯(lián)腳本(inline script)

對于非模塊腳本,async 特性(attribute)僅適用于外部腳本。異步腳本會在準備好后立即運行,獨立于其他腳本或 HTML 文檔。

對于模塊腳本,它也適用于內(nèi)聯(lián)腳本。

例如,下面的內(nèi)聯(lián)腳本具有 async 特性,因此它不會等待任何東西。

它執(zhí)行導入(fetch ./analytics.js),并在導入完成時運行,即使 HTML 文檔還未完成,或者其他腳本仍在等待處理中。

這對于不依賴任何其他東西的功能來說是非常棒的,例如計數(shù)器,廣告,文檔級事件監(jiān)聽器。

<!-- 所有依賴都獲取完成(analytics.js)然后腳本開始運行 -->
<!-- 不會等待 HTML 文檔或者其他 <script> 標簽 -->
<script async type="module">
  import {counter} from './analytics.js';

  counter.count();
</script>

外部腳本

具有 type="module" 的外部腳本(external script)在兩個方面有所不同:

  1. 具有相同 src 的外部腳本僅運行一次:
  2. <!-- 腳本 my.js 被加載完成(fetched)并只被運行一次 -->
    <script type="module" src="my.js"></script>
    <script type="module" src="my.js"></script>
  3. 從另一個源(例如另一個網(wǎng)站)獲取的外部腳本需要 CORS header,如我們在 Fetch:跨源請求 一章中所講的那樣。換句話說,如果一個模塊腳本是從另一個源獲取的,則遠程服務器必須提供表示允許獲取的 header Access-Control-Allow-Origin。
  4. <!-- another-site.com 必須提供 Access-Control-Allow-Origin -->
    <!-- 否則,腳本將無法執(zhí)行 -->
    <script type="module" src="http://another-site.com/their.js" rel="external nofollow" ></script>

    默認這樣做可以確保更好的安全性。

不允許裸模塊(“bare” module)

在瀏覽器中,import 必須給出相對或絕對的 URL 路徑。沒有任何路徑的模塊被稱為“裸(bare)”模塊。在 import 中不允許這種模塊。

例如,下面這個 import 是無效的:

import {sayHi} from 'sayHi'; // Error,“裸”模塊
// 模塊必須有一個路徑,例如 './sayHi.js' 或者其他任何路徑

某些環(huán)境,像 Node.js 或者打包工具(bundle tool)允許沒有任何路徑的裸模塊,因為它們有自己的查找模塊的方法和鉤子(hook)來對它們進行微調(diào)。但是瀏覽器尚不支持裸模塊。

兼容性,“nomodule”

舊時的瀏覽器不理解 type="module"。未知類型的腳本會被忽略。對此,我們可以使用 nomodule 特性來提供一個后備:

<script type="module">
  alert("Runs in modern browsers");
</script>

<script nomodule>
  alert("Modern browsers know both type=module and nomodule, so skip this")
  alert("Old browsers ignore script with unknown type=module, but execute this.");
</script>

構(gòu)建工具

在實際開發(fā)中,瀏覽器模塊很少被以“原始”形式進行使用。通常,我們會使用一些特殊工具,例如 Webpack,將它們打包在一起,然后部署到生產(chǎn)環(huán)境的服務器。

使用打包工具的一個好處是 —— 它們可以更好地控制模塊的解析方式,允許我們使用裸模塊和更多的功能,例如 CSS/HTML 模塊等。

構(gòu)建工具做以下這些事兒:

  1. 從一個打算放在 HTML 中的 ?<script type="module">? “主”模塊開始。
  2. 分析它的依賴:它的導入,以及它的導入的導入等。
  3. 使用所有模塊構(gòu)建一個文件(或者多個文件,這是可調(diào)的),并用打包函數(shù)(bundler function)替代原生的 ?import? 調(diào)用,以使其正常工作。還支持像 HTML/CSS 模塊等“特殊”的模塊類型。
  4. 在處理過程中,可能會應用其他轉(zhuǎn)換和優(yōu)化:
    • 刪除無法訪問的代碼。
    • 刪除未使用的導出(“tree-shaking”)。
    • 刪除特定于開發(fā)的像 ?console? 和 ?debugger? 這樣的語句。
    • 可以使用 Babel 將前沿的現(xiàn)代的 JavaScript 語法轉(zhuǎn)換為具有類似功能的舊的 JavaScript 語法。
    • 壓縮生成的文件(刪除空格,用短的名字替換變量等)。

如果我們使用打包工具,那么腳本會被打包進一個單一文件(或者幾個文件),在這些腳本中的 import/export 語句會被替換成特殊的打包函數(shù)(bundler function)。因此,最終打包好的腳本中不包含任何 import/export,它也不需要 type="module",我們可以將其放入常規(guī)的 <script>

<!-- 假設我們從諸如 Webpack 這類的打包工具中獲得了 "bundle.js" 腳本 -->
<script src="bundle.js"></script>

關于構(gòu)建工具說了這么多,但其實原生模塊也是可以用的。所以,我們在這兒將不會使用 Webpack:你可以稍后再配置它。

總結(jié)

下面總結(jié)一下模塊的核心概念:

  1. 一個模塊就是一個文件。瀏覽器需要使用 ?<script type="module">? 以使 ?import/export? 可以工作。模塊(譯注:相較于常規(guī)腳本)有幾點差別:
    • 默認是延遲解析的(deferred)。
    • Async 可用于內(nèi)聯(lián)腳本。
    • 要從另一個源(域/協(xié)議/端口)加載外部腳本,需要 CORS header。
    • 重復的外部腳本會被忽略
  2. 模塊具有自己的本地頂級作用域,并可以通過 ?import/export? 交換功能。
  3. 模塊始終使用 ?use strict?。
  4. 模塊代碼只執(zhí)行一次。導出僅創(chuàng)建一次,然后會在導入之間共享。

當我們使用模塊時,每個模塊都會實現(xiàn)特定功能并將其導出。然后我們使用 import 將其直接導入到需要的地方即可。瀏覽器會自動加載并解析腳本。

在生產(chǎn)環(huán)境中,出于性能和其他原因,開發(fā)者經(jīng)常使用諸如 Webpack 之類的打包工具將模塊打包到一起。

在下一章里,我們將會看到更多關于模塊的例子,以及如何進行導入/導出。


以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號