一文徹底搞定es6模組化

2023-02-17 14:01:20

前情回顧

  • 在上篇文章中我們講到了 CommonJs,如果還沒有看,可以查詢本文章所在的專欄進行學習。
  • CommonJs 有很多優秀的特性,下面我們再簡單的回顧一下:
  • 模組程式碼只在載入後執行;

  • 模組只能載入一次;

  • 模組可以請求載入其他模組;

  • 支援迴圈依賴;

  • 模組可以定義公共介面,其他模組可以基於這個公共介面觀察和互動;

天下苦 CommonJs 久矣

  • Es Module 的獨特之處在於,既可以通過瀏覽器原生載入,也可以與第三方載入器和構建工具一起載入。
  • 支援 Es module 模組的瀏覽器可以從頂級模組載入整個依賴圖,且是非同步完成。瀏覽器會解析入口模組,確定依賴,並行送對依賴模組的請求。這些檔案通過網路返回後,瀏覽器就會解析它們的依賴,,如果這些二級依賴還沒有載入,則會傳送更多請求。
  • 這個非同步遞迴載入過程會持續到整個應用程式的依賴圖都解析完成。解析完成依賴圖,參照程式就可以正式載入模組了。
  • Es Module 不僅借用了 CommonJsAMD 的很多優秀特性,還增加了一些新行為:
  • Es Module 預設在嚴格模式下執行;

  • Es Module 不共用全域性命名空;

  • Es Module 頂級的 this 的值是 undefined(常規指令碼是window);

  • 模組中的 var 宣告不會新增到 window 物件;

  • Es Module 是非同步載入和執行的;

export 和 import

  • 模組功能主要由兩個命令構成: exportsimport
  • export命令用於規定模組的對外介面,import命令用於輸入其他模組提供的功能。

export的基本使用

  • 匯出的基本形式:
export const nickname = "moment";
export const address = "廣州";
export const age = 18;
登入後複製
  • 當然了,你也可以寫成以下的形式:
const nickname = "moment";
const address = "廣州";
const age = 18;

export { nickname, address, age };
登入後複製
  • 對外匯出一個物件和函數
export function foo(x, y) {
  return x + y;
}

export const obj = {
  nickname: "moment",
  address: "廣州",
  age: 18,
};

// 也可以寫成這樣的方式
function foo(x, y) {
  return x + y;
}

const obj = {
  nickname: "moment",
  address: "廣州",
  age: 18,
};

export { foo, obj };
登入後複製
  • 通常情況下,export輸出的變數就是本來的名字,但是可以使用as關鍵字重新命名。
const address = "廣州";
const age = 18;

export { nickname as name, address as where, age as old };
登入後複製
  • 預設匯出,值得注意的是,一個模組只能有一個預設匯出:
export default "foo";

export default { name: 'moment' }

export default function foo(x,y) {
  return x+y
}

export { bar, foo as default };
登入後複製

export 的錯誤使用

  • 匯出語句必須在模組頂級,不能巢狀在某個塊中:
if(true){
export {...};
}
登入後複製
  • export 必須提供對外的介面:
// 1只是一個值,不是一個介面export 1// moment只是一個值為1的變數const moment = 1export moment// function和class的輸出,也必須遵守這樣的寫法function foo(x, y) {    return x+y
}export foo複製程式碼
登入後複製

import的基本使用

  • 使用 export 命令定義了模組的對外介面以後,其他js檔案就可以通過 import 命令載入整個模組
import {foo,age,nickname} from '模組識別符號'
登入後複製
  • 模組識別符號可以是當前模組的相對路徑,也可以是絕對路徑,也可以是純字串,但不能是動態計算的結果,例如憑藉的字串。
  • import 命令後面接受一個花括弧,裡面指定要從其他模組匯入的變數名,而且變數名必須與被匯入模組的對外介面的名稱相同。
  • 對於匯入的變數不能對其重新賦值,因為它是一個唯讀介面,如果是一個物件,可以對這個物件的屬性重新賦值。匯出的模組可以修改值,匯入的變數也會跟著改變。

Snipaste_2022-11-13_06-58-51.png

  • 從上圖可以看得出來,物件的屬性被重新賦值了,而變數的則報了 Assignment to constant variable 的型別錯誤。
  • 如果模組同時匯出了命名匯出和預設匯出,則可以在 import 語句中同時取得它們。可以依次列出特定的識別符號來取得,也可以使用 * 來取得:
// foo.js
export default function foo(x, y) {
  return x + y;
}

export const bar = 777;

export const baz = "moment";

// main.js
import { default as foo, bar, baz } from "./foo.js";

import foo, { bar, baz } from "./foo.js";

import foo, * as FOO from "./foo.js";
登入後複製

動態 import

  • 標準用法的 import 匯入的模組是靜態的,會使所有被匯入的模組,在載入時就被編譯(無法做到按需編譯,降低首頁載入速度)。有些場景中,你可能希望根據條件匯入模組或者按需匯入模組,這時你可以使用動態匯入代替靜態匯入。
  • 關鍵字 import 可以像呼叫函數一樣來動態的匯入模組。以這種方式呼叫,將返回一個 promise
import("./foo.js").then((module) => {  const { default: foo, bar, baz } = module;  console.log(foo); // [Function: foo]
  console.log(bar); // 777
  console.log(baz); // moment});複製程式碼
登入後複製

使用頂層 await

  • 在經典指令碼中使用 await 必須在帶有 async 的非同步函數中使用,否則會報錯:
import("./foo.js").then((module) => {
  const { default: foo, bar, baz } = module;
  console.log(foo); // [Function: foo]
  console.log(bar); // 777
  console.log(baz); // moment
});
登入後複製
  • 而在模組中,你可以直接使用 Top-level await:
const p = new Promise((resolve, reject) => {  resolve(777);
});const result = await p;console.log(result); 
// 777正常輸出
登入後複製

import 的錯誤使用

  • 由於import是靜態執行,所以不能使用表示式和變數,這些只有在執行時才能得到結果的語法結構。
// 錯誤
import { 'b' + 'ar' } from './foo.js';

// 錯誤
let module = './foo.js';
import { bar } from module;

// 錯誤
if (x === 1) {
  import { bar } from './foo.js';
} else {
  import { foo } from './foo.js';
}
登入後複製

在瀏覽器中使用 Es Module

  • 在瀏覽器上,你可以通過將 type 屬性設定為 module 用來告知瀏覽器將 script 標籤視為模組。
<script type="module" src="./main.mjs"></script><script type="module"></script>
登入後複製
  • 模組預設情況下是延遲的,因此你還可以使用 defer 的方式延遲你的 nomodule 指令碼:
  <script type="module">      
  console.log("模組情況下的");
    </script>    
    <script src="./main.js" type="module" defer></script>
    <script>
      console.log("正常 script標籤");    
      </script>
登入後複製

log.png

  • 在瀏覽器中,引入相同的 nomodule 指令碼會被執行多次,而模組只會被執行一次:
    <script src="./foo.js"></script>    <script src="./foo.js"></script>

    <script type="module" src="./main.js"></script>
    <script type="module" src="./main.js"></script>
    <script type="module" src="./main.js"></script>
登入後複製

once.png

模組的預設延遲

  • 預設情況下,nomodule 指令碼會阻塞 HTML 解析。你可以通過新增 defer 屬性來解決此問題,該屬性是等到 HTML 解析完成之後才執行。

result.png

  • deferasync 是一個可選屬性,他們只可以選擇其中一個,在 nomodule 指令碼下,defer 等到 HTML 解析完才會解析當前指令碼,而 async 會和 HTML 並行解析,不會阻塞 HTML 的解析,模組指令碼可以指定 async 屬性,但對於 defer 無效,因為模組預設就是延遲的。
  • 對於模組指令碼,如果存在 async 屬性,模組指令碼及其所有依賴項將於解析並行獲取,並且模組指令碼將在它可用時進行立即執行。

Es Module 和 Commonjs 的區別

  • 討論 Es Module 模組之前,必須先了解 Es ModuleCommonjs 完全不同,它們有三個完全不同:
  1. CommonJS 模組輸出的是一個值的拷貝,Es Module 輸出的是值的參照;
  2. CommonJS 模組是執行時載入,Es Module 是編譯時輸出介面。
  3. CommonJS 模組的 require() 是同步載入模組,ES6 模組的import命令是非同步載入,有一個獨立的模組依賴的解析階段。
  • 第二個差異是因為 CommonJS 載入的是一個物件(即module.exports屬性),該物件只有在指令碼執行完才會生成。而 Es Module 不是物件,它的對外介面只是一種靜態定義,在程式碼靜態解析階段就會生成。
  • Commonjs 輸出的是值的拷貝,也就是說,一旦輸出一個值,模組內部的變化就影響不到這個值。具體可以看上一篇寫的文章。
  • Es Module 的執行機制與 CommonJS 不一樣。JS引擎 對指令碼靜態分析的時候,遇到模組載入命令import,就會生成一個唯讀參照。等到指令碼真正執行時,再根據這個唯讀參照,到被載入的那個模組裡面去取值。換句話說,import就是一個連線管道,原始值變了,import 載入的值也會跟著變。因此,Es Module 是動態參照,並且不會快取值,模組裡面的變數繫結其所在的模組。

Es Module 工作原理的相關概念

  • 在學習工作原理之前,我們不妨來認識一下相關的概念。

Module Record

  • 模組記錄(Module Record) 封裝了關於單個模組(當前模組)的匯入和匯出的結構資訊。此資訊用於連結連線模組集的匯入和匯出。一個模組記錄包括四個欄位,它們只在執行模組時使用。其中這四個欄位分別是:
  1. Realm: 建立當前模組的作用域;
  2. Environment:模組的頂層繫結的環境記錄,該欄位在模組被連結時設定;
  3. Namespace:模組名稱空間物件是模組名稱空間外來物件,它提供對模組匯出繫結的基於執行時屬性的存取。模組名稱空間物件沒有建構函式;
  4. HostDefined:欄位保留,以按 host environments 使用,需要將附加資訊與模組關聯。

Module Environment Record

  • 模組環境記錄是一種宣告性環境記錄,用於表示ECMAScript模組的外部作用域。除了普通的可變和不可變繫結之外,模組環境記錄還提供了不可變的 import 繫結,這些繫結提供了對存在於另一個環境記錄中的目標繫結的間接存取。

不可變繫結就是當前的模組引入其他的模組,引入的變數不能修改,這就是模組獨特的不可變繫結。

Es Module 的解析流程

  • 在開始之前,我們先大概瞭解一下整個流程大概是怎麼樣的,先有一個大概的瞭解:
  1. 階段一:構建(Construction),根據地址查詢 js 檔案,通過網路下載,並且解析模組檔案為 Module Record;
  2. 階段二:範例化(Instantiation),對模組進行範例化,並且分配記憶體空間,解析模組的匯入和匯出語句,把模組指向對應的記憶體地址;
  3. 階段三:執行(Evaluation),執行程式碼,計算值,並且將值填充到記憶體地址中;

Construction 構建階段

  • loader 負責對模組進行定址及下載。首先我們修改一個入口檔案,這在 HTML 中通常是一個 <script type="module"></script> 的標籤來表示一個模組檔案。

entry.png

  • 模組繼續通過 import語句宣告,在 import宣告語句中有一個 模組宣告識別符號(ModuleSpecifier),這告訴 loader 怎麼查詢下一個模組的地址。

09_module_specifier.png

  • 每一個模組標識號對應一個 模組記錄(Module Record),而每一個 模組記錄 包含了 JavaScript程式碼執行上下文ImportEntriesLocalExportEntriesIndirectExportEntriesStarExportEntries。其中 ImportEntries 值是一個 ImportEntry Records 型別,而 LocalExportEntriesIndirectExportEntriesStarExportEntries 是一個 ExportEntry Records 型別。

ImportEntry Records

  • 一個 ImportEntry Records 包含三個欄位 ModuleRequestImportNameLocalName;
  1. ModuleRequest: 一個模組識別符號(ModuleSpecifier);
  2. ImportName: 由 ModuleRequest 模組識別符號的模組匯出所需繫結的名稱。值 namespace-object 表示匯入請求是針對目標模組的名稱空間物件的;
  3. LocalName: 用於從匯入模組中從當前模組中存取匯入值的變數;
  • 詳情可參考下圖:imp.png
  • 下面這張表記錄了使用 import 匯入的 ImportEntry Records 欄位的範例:
匯入宣告 (Import Statement From)模組識別符號 (ModuleRequest)匯入名 (ImportName)本地名 (LocalName)
import React from "react";"react""default""React"
import * as Moment from "react";"react"namespace-obj"Moment"
import {useEffect} from "react";"react""useEffect""useEffect"
import {useEffect as effect } from "react";"react""useEffect""effect"

ExportEntry Records

  • 一個 ExportEntry Records 包含四個欄位 ExportNameModuleRequestImportNameLocalName,和 ImportEntry Records不同的是多了一個 ExportName
  1. ExportName: 此模組用於匯出時繫結的名稱。
  • 下面這張表記錄了使用 export 匯出的 ExportEntry Records 欄位的範例:

    匯出宣告匯出名模組識別符號匯入名本地名
    export var v;"v"nullnull"v"
    export default function f() {}"default"nullnull"f"
    export default function () {}"default"nullnull"default"
    export default 42;"default"nullnull"default"
    export {x};"x"nullnull"x"
    export {v as x};"x"nullnull"v"
    export {x} from "mod";"x""mod""x"null
    export {v as x} from "mod";"x""mod""v"null
    export * from "mod";null"mod"all-but-defaultnull
    export * as ns from "mod";"ns"mod"allnull
  • 回到主題

  • 只有當解析完當前的 Module Record 之後,才能知道當前模組依賴的是那些子模組,然後你需要 resolve 子模組,獲取子模組,再解析子模組,不斷的迴圈這個流程 resolving -> fetching -> parsing,結果如下圖所示:

10_construction.png

  • 這個過程也稱為 靜態分析,不會執行JavaScript程式碼,只會識別 exportimport 關鍵字,所以說不能在非全域性作用域下使用 import,動態匯入除外。
  • 如果多個檔案同時依賴一個檔案呢,這會不會引起死迴圈,答案是不會的。
  • loader 使用 Module Map 對全域性的 MOdule Record 進行追蹤、快取這樣就可以保證模組只被 fetch 一次,每個全域性作用域中會有一個獨立的 Module Map。

MOdule Map 是由一個 URL 記錄和一個字串組成的key/value的對映物件。URL記錄是獲取模組的請求URL,字串指示模組的型別(例如。「javascript」)。模組對映的值要麼是模組指令碼,null(用於表示失敗的獲取),要麼是預留位置值「fetching(獲取中)」。

25_module_map.png

linking 連結階段

  • 在所有 Module Record 被解析完後,接下來 JS 引擎需要把所有模組進行連結。JS 引擎以入口檔案的 Module Record 作為起點,以深度優先的順序去遞迴連結模組,為每個 Module Record 建立一個 Module Environment Record,用於管理 Module Record 中的變數。

30_live_bindings_01.png

  • Module Environment Record 中有一個 Binding,這個是用來存放 Module Record 匯出的變數,如上圖所示,在該模組 main.js 處匯出了一個 count 的變數,在 Module Environment Record 中的 Binding 就會有一個 count,在這個時候,就相當於 V8 的編譯階段,建立一個模組範例物件,新增相對應的屬性和方法,此時值為 undefined 或者 null,為其分配記憶體空間。
  • 而在子模組 count.js 中使用了 import 關鍵字對 main.js 進行匯入,而 count.jsimportmain.jsexport 的變數指向的記憶體位置是一致的,這樣就把父子模組之間的關係連結起來了。如下圖所示:

no.png

  • 需要注意的是,我們稱 export 匯出的為父模組,import 引入的為子模組,父模組可以對變數進行修改,具有讀寫許可權,而子模組只有讀許可權。

Evaluation 求值階段

  • 在模組彼此連結完之後,執行對應模組檔案中頂層作用域的程式碼,確定連結階段中定義變數的值,放入記憶體中。

Es module 是如何解決迴圈參照的

  • Es Module 中有5種狀態,分別為 unlinkedlinkinglinkedevaluatingevaluated,用迴圈模組記錄(Cyclic Module Records)的 Status 欄位來表示,正是通過這個欄位來判斷模組是否被執行過,每個模組只執行一次。這也是為什麼會使用 Module Map 來進行全域性快取 Module Record 的原因了,如果一個模組的狀態為 evaluated,那麼下次執行則會自動跳過,從而包裝一個模組只會執行一次。 Es Module 採用 深度優先 的方法對模組圖進行遍歷,每個模組只執行一次,這也就避免了死迴圈的情況了。

深度優先搜尋演演算法(英語:Depth-First-Search,DFS)是一種用於遍歷或搜尋樹或圖的演演算法。這個演演算法會盡可能深地搜尋樹的分支。當節點v的所在邊都己被探尋過,搜尋將回溯到發現節點v的那條邊的起始節點。這一過程一直進行到已發現從源節點可達的所有節點為止。如果還存在未被發現的節點,則選擇其中一個作為源節點並重復以上過程,整個程序反覆進行直到所有節點都被存取為止。

41_cyclic_graph.png

  • 看下面的例子,所有的模組只會執行一次:
// main.js
import { bar } from "./bar.js";
export const main = "main";
console.log("main");

// foo.js
import { main } from "./main.js";
export const foo = "foo";
console.log("foo");

// bar.js
import { foo } from "./foo.js";
export const bar = "bar";
console.log("bar");
登入後複製
  • 通過 node 執行 main.js ,得出以下結果:

results.png

  • 好了,這篇文章到這也就結束了。《》

以上就是一文徹底搞定es6模組化的詳細內容,更多請關注TW511.COM其它相關文章!