最近一直在搞基礎的東西,弄了一個持續更新的github筆記,可以去看看,誠意之作(本來就是寫給自己看的……)連結地址:Front-End-Basics
此篇文章的地址:JavaScript的模組
基礎筆記的github地址:https://github.com/qiqihaobenben/Front-End-Basics ,可以watch,也可以star。
正文開始
JavaScript的模組
介紹
模組通常是指程式語言所提供的程式碼組織機制,利用此機制可將程式拆解為獨立且通用的程式碼單元。所謂模組化主要是解決程式碼分割、作用域隔離、模組之間的依賴管理以及釋出到生產環境時的自動化打包與處理等多個方面。
模組的優點
- 可維護性。 因為模組是獨立的,一個設計良好的模組會讓外面的程式碼對自己的依賴越少越好,這樣自己就可以獨立去更新和改進。
- 名稱空間。 在 JavaScript 裡面,如果一個變數在最頂級的函式之外宣告,它就直接變成全域性可用。因此,常常不小心出現命名衝突的情況。使用模組化開發來封裝變數,可以避免汙染全域性環境。
- 重用程式碼。 我們有時候會喜歡從之前寫過的專案中拷貝程式碼到新的專案,這沒有問題,但是更好的方法是,通過模組引用的方式,來避免重複的程式碼庫。
CommonJS
CommonJS 最開始是 Mozilla 的工程師於 2009 年開始的一個專案,它的目的是讓瀏覽器之外的 JavaScript (比如伺服器端或者桌面端)能夠通過模組化的方式來開發和協作。
在 CommonJS 的規範中,每個 JavaScript 檔案就是一個獨立的模組上下文(module context),在這個上下文中預設建立的屬性都是私有的。也就是說,在一個檔案定義的變數(還包括函式和類),都是私有的,對其他檔案是不可見的。
需要注意的是,CommonJS 規範的主要適用場景是伺服器端程式設計,所以採用同步載入模組的策略。如果我們依賴3個模組,程式碼會一個一個依次載入它們。
該模組實現方案主要包含 require 與 module 這兩個關鍵字,其允許某個模組對外暴露部分介面並且由其他模組匯入使用。
//sayModule.js
function SayModule () {
this.hello = function () {
console.log(`hello`);
};
this.goodbye = function () {
console.log(`goodbye`);
};
}
module.exports = SayModule;
//main.js 引入sayModule.js
var Say = require(`./sayModule.js`);
var sayer = new Say();
sayer.hello(); //hello
作為一個伺服器端的解決方案,CommonJS 需要一個相容的指令碼載入器作為前提條件。該指令碼載入器必須支援名為 require 和 module.exports 的函式,它們將模組相互匯入匯出。
Node.js
Node 從 CommonJS 的一些創意中,創造出自己的模組化實現。由於Node 在服務端的流行,Node 的模組形式被(不正確地)稱為 CommonJS。
Node.js模組可以分為兩大類,一類是核心模組,另一類是檔案模組。
核心模組 就是Node.js標準的API中提供的模組,如fs、http、net等,這些都是由Node.js官方提供的模組,編譯成了二進位制程式碼,可以直接通過require獲取核心模組,例如require(`fs`),核心模組擁有最高的載入優先順序,如果有模組與核心模組命名衝突,Node.js總是會載入核心模組。
檔案模組 是儲存為單獨的檔案(或資料夾)的模組,可能是JavaScript程式碼、JSON或編譯好的C/C++程式碼。在不顯式指定檔案模組副檔名的時候,Node.js會分別試圖加上.js、.json、.node(編譯好的C/C++程式碼)。
載入方式
- 按路徑載入模組
如果require引數以”/”開頭,那麼就以絕對路徑的方式查詢模組名稱,如果引數以”./”、”../”開頭,那麼則是以相對路徑的方式來查詢模組。
- 通過查詢node_modules目錄載入模組
如果require引數不以”/”、”./”、”../”開頭,而該模組又不是核心模組,那麼就要通過查詢node_modules載入模組了。我們使用的npm獲取的包通常就是以這種方式載入的。
載入快取
Node.js模組不會被重複載入,這是因為Node.js通過檔名快取所有載入過的檔案模組,所以以後再訪問到時就不會重新載入了。
注意: Node.js是根據實際檔名快取的,而不是require()提供的引數快取的,也就是說即使你分別通過require(`express`)和require(`./node_modules/express`)載入兩次,也不會重複載入,因為儘管兩次引數不同,解析到的檔案卻是同一個。
Node.js 中的模組在載入之後是以單例化執行,並且遵循值傳遞原則:如果是一個物件,就相當於這個物件的引用。
模組載入過程
載入檔案模組的工作,主要由原生模組module來實現和完成,該原生模組在啟動時已經被載入,程式直接呼叫到runMain靜態方法。
例如執行: node app.js
Module.runMain = function () {
// Load the main module--the command line argument.
Module._load(process.argv[1], null, true);
};
//_load靜態方法在分析檔名之後執行
var module = new Module(id, parent);
//並根據檔案路徑快取當前模組物件,該模組例項物件則根據檔名載入。
module.load(filename);
具體說一下上文提到了檔案模組的三類模組,這三類檔案模組以字尾來區分,Node.js會根據字尾名來決定載入方法,具體的載入方法在下文require.extensions
中會介紹。
-
.js
通過fs模組同步讀取js檔案並編譯執行。 -
.node
通過C/C++進行編寫的Addon。通過dlopen方法進行載入。 -
.json
讀取檔案,呼叫JSON.parse解析載入。
接下來詳細描述js字尾的編譯過程。Node.js在編譯js檔案的過程中實際完成的步驟有對js檔案內容進行頭尾包裝。以app.js為例,包裝之後的app.js將會變成以下形式:
//circle.js
var PI = Math.PI;
exports.area = function (r) {
return PI * r * r;
};
exports.circumference = function (r) {
return 2 * PI * r;
};
//app.js
var circle = require(`./circle.js`);
console.log( `The area of a circle of radius 4 is ` + circle.area(4));
//app包裝後
(function (exports, require, module, __filename, __dirname) {
var circle = require(`./circle.js`);
console.log(`The area of a circle of radius 4 is ` + circle.area(4));
});
//這段程式碼會通過vm原生模組的runInThisContext方法執行(類似eval,只是具有明確上下文,不汙染全域性),返回為一個具體的function物件。最後傳入module物件的exports,require方法,module,檔名,目錄名作為實參並執行。
這就是為什麼require並沒有定義在app.js 檔案中,但是這個方法卻存在的原因。從Node.js的API文件中可以看到還有__filename
、__dirname
、module
、exports
幾個沒有定義但是卻存在的變數。其中__filename
和__dirname
在查詢檔案路徑的過程中分析得到後傳入的。module
變數是這個模組物件自身,exports
是在module的建構函式中初始化的一個空物件({},而不是null)。
在這個主檔案中,可以通過require方法去引入其餘的模組。而其實這個require方法實際呼叫的就是module._load方法。
load方法在載入、編譯、快取了module後,返回module的exports物件。這就是circle.js檔案中只有定義在exports物件上的方法才能被外部呼叫的原因。
以上所描述的模組載入機制均定義在lib/module.js中。
require 函式
require 引入的物件主要是函式。當 Node 呼叫 require() 函式,並且傳遞一個檔案路徑給它的時候,Node 會經歷如下幾個步驟:
- Resolving:找到檔案的絕對路徑;
- Loading:判斷檔案內容型別;
- Wrapping:打包,給這個檔案賦予一個私有作用範圍。這是使 require 和 module 模組在本地引用的一種方法;
- Evaluating:VM 對載入的程式碼進行處理的地方;
- Caching:當再次需要用這個檔案的時候,不需要重複一遍上面步驟。
require.extensions 來檢視對三種檔案的支援情況
可以清晰地看到 Node 對每種副檔名所使用的函式及其操作:對 .js 檔案使用 module._compile;對 .json 檔案使用 JSON.parse;對 .node 檔案使用 process.dlopen。
檔案查詢策略
- 從檔案模組快取中載入
儘管原生模組與檔案模組的優先順序不同,但是優先順序最高的是從檔案模組的快取中載入已經存在的模組。
- 從原生模組載入
原生模組的優先順序僅次於檔案模組快取的優先順序。require方法在解析檔名之後,優先檢查模組是否在原生模組列表中。以http模組為例,儘管在目錄下存在一個http
、http.js
、http.node
、http.json
檔案,require(“http”)
都不會從這些檔案中載入,而是從原生模組中載入。
原生模組也有一個快取區,同樣也是優先從快取區載入。如果快取區沒有被載入過,則呼叫原生模組的載入方式進行載入和執行。
- 從檔案載入
當檔案模組快取中不存在,而且不是原生模組的時候,Node.js會解析require方法傳入的引數,並從檔案系統中載入實際的檔案,載入過程中的包裝和編譯細節在前面說過是呼叫load方法。
··
當 Node 遇到 require(X) 時,按下面的順序處理。
(1)如果 X 是內建模組(比如 require(`http`))
a. 返回該模組。
b. 不再繼續執行。
(2)如果 X 以 "./" 或者 "/" 或者 "../" 開頭
a. 根據 X 所在的父模組,確定 X 的絕對路徑。
b. 將 X 當成檔案,依次查詢下面檔案,只要其中有一個存在,就返回該檔案,不再繼續執行。
X
X.js
X.json
X.node
c. 將 X 當成目錄,依次查詢下面檔案,只要其中有一個存在,就返回該檔案,不再繼續執行。
X/package.json(main欄位)
X/index.js
X/index.json
X/index.node
(3)如果 X 不帶路徑
a. 根據 X 所在的父模組,確定 X 可能的安裝目錄。
b. 依次在每個目錄中,將 X 當成檔名或目錄名載入。
(4) 丟擲 "not found"
模組迴圈依賴
//建立兩個檔案,module1.js 和 module2.js,並且讓它們相互引用
// module1.js
exports.a = 1;
require(`./module2`);
exports.b = 2;
exports.c = 3;
// module2.js
const Module1 = require(`./module1`);
console.log(`Module1 is partially loaded here`, Module1);
//執行 node module2.js 列印:Module1 is partially loaded here {a:1,b:2,c:3}
//執行 node module1.js 列印:Module1 is partially loaded here {a:1}
在 module1 完全載入之前需要先載入 module2,而 module2 的載入又需要 module1。這種狀態下,我們從 exports 物件中能得到的就是在發生迴圈依賴之前的這部分。上面程式碼中,只有 a 屬性被引入,因為 b 和 c 都需要在引入 module2 之後才能載入進來。
Node 使這個問題簡單化,在一個模組載入期間開始建立 exports 物件。如果它需要引入其他模組,並且有迴圈依賴,那麼只能部分引入,也就是隻能引入發生迴圈依賴之前所定義的這部分。
AMD
AMD 是 Asynchronous Module Definition 的簡稱,即“非同步模組定義”,是從 CommonJS 討論中誕生的。AMD 優先照顧瀏覽器的模組載入場景,使用了非同步載入和回撥的方式。
AMD 和 CommonJS 一樣需要指令碼載入器,儘管 AMD 只需要對 define 方法的支援。define 方法需要三個引數:模組名稱,模組執行的依賴陣列,所有依賴都可用之後執行的函式(該函式按照依賴宣告的順序,接收依賴作為引數)。只有函式引數是必須的。define 既是一種引用模組的方式,也是定義模組的方式。
// file lib/sayModule.js
define(function (){
return {
sayHello: function () {
console.log(`hello`);
}
};
});
//file main.js
define([`./lib/sayModule`], function (say){
say.sayHello(); //hello
})
main.js 作為整個應用的入口模組,我們使用 define 關鍵字宣告瞭該模組以及外部依賴(沒有生命模組名稱);當我們執行該模組程式碼時,也就是執行 define 函式的第二個引數中定義的函式功能,其會在框架將所有的其他依賴模組載入完畢後被執行。這種延遲程式碼執行的技術也就保證了依賴的併發載入。
RequireJS
RequireJS 是一個前端的模組化管理的工具庫,遵循AMD規範,通過一個函式來將所有所需要的或者說所依賴的模組實現裝載進來,然後返回一個新的函式(模組),我們所有的關於新模組的業務程式碼都在這個函式內部操作,其內部也可無限制的使用已經載入進來的以來的模組。
<script data-main=`scripts/main` src=`scripts/require.js`></script>
//scripts下的main.js則是指定的主程式碼指令碼檔案,所有的依賴模組程式碼檔案都將從該檔案開始非同步載入進入執行。
defined用於定義模組,RequireJS要求每個模組均放在獨立的檔案之中。按照是否有依賴其他模組的情況分為獨立模組和非獨立模組。
1、獨立模組 不依賴其他模組。直接定義
define({
methodOne: function (){},
methodTwo: function (){}
});
//等價於
define(function (){
return {
methodOne: function (){},
methodTwo: function (){}
};
});
2、非獨立模組,對其他模組有依賴
define([ `moduleOne`, `moduleTwo` ], function(mOne, mTwo){
...
});
//或者
define( function( require ){
var mOne = require( `moduleOne` ),
mTwo = require( `moduleTwo` );
...
});
如上程式碼, define中有依賴模組陣列的 和 沒有依賴模組陣列用require載入 這兩種定義模組,呼叫模組的方法合稱為AMD模式,定義模組清晰,不會汙染全域性變數,清楚的顯示依賴關係。AMD模式可以用於瀏覽器環境並且允許非同步載入模組,也可以按需動態載入模組。
CMD
CMD(Common Module Definition),在CMD中,一個模組就是一個檔案。
全域性函式define,用來定義模組。
引數 factory 可以是一個函式,也可以為物件或者字串。
當 factory 為物件、字串時,表示模組的介面就是該物件、字串。
定義JSON資料模組:
define({ "foo": "bar" });
factory 為函式的時候,表示模組的構造方法,執行構造方法便可以得到模組向外提供的介面。
define( function(require, exports, module) {
// 模組程式碼
});
SeaJS
sea.js 核心特徵:
- 遵循CMD規範,與NodeJS般的書寫模組程式碼。
- 依賴自動載入,配置清晰簡潔。
seajs.use
用來在頁面中載入一個或者多個模組
// 載入一個模組
seajs.use(`./a`);
// 載入模組,載入完成時執行回撥
seajs.use(`./a`,function(a){
a.doSomething();
});
// 載入多個模組執行回撥
seajs.use([`./a`,`./b`],function(a , b){
a.doSomething();
b.doSomething();
});
AMD和CMD最大的區別是對依賴模組的執行時機處理不同,注意不是載入的時機或者方式不同。
很多人說requireJS是非同步載入模組,SeaJS是同步載入模組,這麼理解實際上是不準確的,其實載入模組都是非同步的,只不過AMD依賴前置,js可以方便知道依賴模組是誰,立即載入,而CMD就近依賴,需要使用把模組變為字串解析一遍才知道依賴了那些模組,這也是很多人詬病CMD的一點,犧牲效能來帶來開發的便利性,實際上解析模組用的時間短到可以忽略。
為什麼說是執行時機處理不同?
同樣都是非同步載入模組,AMD在載入模組完成後就會執行該模組,所有模組都載入執行完後會進入回撥函式,執行主邏輯,這樣的效果就是依賴模組的執行順序和書寫順序不一定一致,看網路速度,哪個先下載下來,哪個先執行,但是主邏輯一定在所有依賴載入完成後才執行。
CMD載入完某個依賴模組後並不執行,只是下載而已,在所有依賴模組載入完成後進入主邏輯,遇到require語句的時候才執行對應的模組,這樣模組的執行順序和書寫順序是完全一致的。
UMD
統一模組定義(UMD:Universal Module Definition )就是將 AMD 和 CommonJS 合在一起的一種嘗試,常見的做法是將CommonJS 語法包裹在相容 AMD 的程式碼中。
(function(define) {
define(function () {
return {
sayHello: function () {
console.log(`hello`);
}
};
});
}(
typeof module === `object` && module.exports && typeof define !== `function` ?
function (factory) { module.exports = factory(); } :
define
));
該模式的核心思想在於所謂的 IIFE(Immediately Invoked Function Expression),該函式會根據環境來判斷需要的引數類別
ES6模組(module)
嚴格模式
ES6 的模組自動採用嚴格模式,不管有沒有在模組頭部加上”use strict”;。
嚴格模式主要有以下限制。
- 變數必須宣告後再使用
- 函式的引數不能有同名屬性,否則報錯
- 不能使用with語句
- 不能對只讀屬性賦值,否則報錯
- 不能使用字首0表示八進位制數,否則報錯
- 不能刪除不可刪除的屬性,否則報錯
- 不能刪除變數delete prop,會報錯,只能刪除屬性delete global[prop]
- eval不會在它的外層作用域引入變數
- eval和arguments不能被重新賦值
- arguments不會自動反映函式引數的變化
- 不能使用arguments.callee
- 不能使用arguments.caller
- 禁止this指向全域性物件
- 不能使用fn.caller和fn.arguments獲取函式呼叫的堆疊
- 增加了保留字(比如protected、static和interface)
模組Module
一個模組,就是一個對其他模組暴露自己的屬性或者方法的檔案。
匯出Export
作為一個模組,它可以選擇性地給其他模組暴露(提供)自己的屬性和方法,供其他模組使用。
// profile.js
export var firstName = `qiqi`;
export var lastName = `haobenben`;
export var year = 1992;
//等價於
var firstName = `qiqi`;
var lastName = `haobenben`;
var year = 1992;
export {firstName, lastName, year}
1、 通常情況下,export輸出的變數就是本來的名字,但是可以使用as關鍵字重新命名。
function v1() { ... }
function v2() { ... }
export {
v1 as streamV1,
v2 as streamV2,
v2 as streamLatestVersion
};
//上面程式碼使用as關鍵字,重新命名了函式v1和v2的對外介面。重新命名後,v2可以用不同的名字輸出兩次。
2、 需要特別注意的是,export命令規定的是對外的介面,必須與模組內部的變數建立一一對應關係。
// 報錯
export 1;
// 報錯
var m = 1;
export m;
//上面兩種寫法都會報錯,因為沒有提供對外的介面。第一種寫法直接輸出1,第二種寫法通過變數m,還是直接輸出1。1只是一個值,不是介面。
/ 寫法一
export var m = 1;
// 寫法二
var m = 1;
export {m};
// 寫法三
var n = 1;
export {n as m};
//上面三種寫法都是正確的,規定了對外的介面m。其他指令碼可以通過這個介面,取到值1。它們的實質是,在介面名與模組內部變數之間,建立了一一對應的關係。
3、最後,export命令可以出現在模組的任何位置,只要處於模組頂層就可以。如果處於塊級作用域內,就會報錯,接下來說的import命令也是如此。
function foo() {
export default `bar` // SyntaxError
}
foo()
匯入import
作為一個模組,可以根據需要,引入其他模組的提供的屬性或者方法,供自己模組使用。
1、 import命令接受一對大括號,裡面指定要從其他模組匯入的變數名。大括號裡面的變數名,必須與被匯入模組(profile.js)對外介面的名稱相同。如果想為輸入的變數重新取一個名字,import命令要使用as關鍵字,將輸入的變數重新命名。
import { lastName as surename } from `./profile`;
2、import後面的from指定模組檔案的位置,可以是相對路徑,也可以是絕對路徑,.js路徑可以省略。如果只是模組名,不帶有路徑,那麼必須有配置檔案,告訴 JavaScript 引擎該模組的位置。
3、注意,import命令具有提升效果,會提升到整個模組的頭部,首先執行。
foo();
import { foo } from `my_module`;
//上面的程式碼不會報錯,因為import的執行早於foo的呼叫。這種行為的本質是,import命令是編譯階段執行的,在程式碼執行之前。
4、由於import是靜態執行,所以不能使用表示式和變數,這些只有在執行時才能得到結果的語法結構。
/ 報錯
import { `f` + `oo` } from `my_module`;
// 報錯
let module = `my_module`;
import { foo } from module;
// 報錯
if (x === 1) {
import { foo } from `module1`;
} else {
import { foo } from `module2`;
}
5、最後,import語句會執行所載入的模組,因此可以有下面的寫法。
import `lodash`;
//上面程式碼僅僅執行lodash模組,但是不輸入任何值。
預設匯出(export default)
每個模組支援我們匯出一個
沒有名字的變數,使用關鍵語句export default來實現.
export default function(){
console.log("I am default Fn");
}
//使用export default關鍵字對外匯出一個匿名函式,匯入這個模組的時候,可以為這個匿名函式取任意的名字
//取任意名字均可
import sayDefault from "./module-B.js";
sayDefault();
//結果:I am default Fn
1、預設輸出和正常輸出的比較
// 第一組
export default function diff() { // 輸出
// ...
}
import diff from `diff`; // 輸入
// 第二組
export function diff() { // 輸出
// ...
};
import {diff} from `diff`; // 輸入
//上面程式碼的兩組寫法,第一組是使用export default時,對應的import語句不需要使用大括號;第二組是不使用export default時,對應的import語句需要使用大括號。
export default命令用於指定模組的預設輸出。顯然,一個模組只能有一個預設輸出,因此export default命令只能使用一次。所以,import命令後面才不用加大括號,因為只可能對應一個方法。
2、因為export default本質是將該命令後面的值,賦給default變數以後再預設,所以直接將一個值寫在export default之後。
/ 正確
export default 42;
// 報錯
export 42;
//上面程式碼中,後一句報錯是因為沒有指定對外的介面,而前一句指定外對介面為default。
3、如果想在一條import語句中,同時輸入預設方法和其他變數,可以寫成下面這樣。
import _, { each } from `lodash`;
//對應上面程式碼的export語句如下
export default function (){
//...
}
export function each (obj, iterator, context){
//...
}
export 與 import 的複合寫法
如果在一個模組之中,先輸入後輸出同一個模組,import語句可以與export語句寫在一起。
export { foo, bar } from `my_module`;
// 等同於
import { foo, bar } from `my_module`;
export { foo, bar };
/ 介面改名
export { foo as myFoo } from `my_module`;
// 整體輸出
export * from `my_module`;
注意事項
1、宣告的變數,對外都是隻讀的。但是匯出的是物件型別的值,就可修改。
2、匯入不存在的變數,值為undefined。
ES6 中的迴圈引用
ES6 中,imports 是 exports 的只讀檢視,直白一點就是,imports 都指向 exports 原本的資料,比如:
//------ lib.js ------
export let counter = 3;
export function incCounter() {
counter++;
}
//------ main.js ------
import { counter, incCounter } from `./lib`;
// The imported value `counter` is live
console.log(counter); // 3
incCounter();
console.log(counter); // 4
// The imported value can’t be changed
counter++; // TypeError
因此在 ES6 中處理迴圈引用特別簡單,看下面這段程式碼:
//------ a.js ------
import {bar} from `b`; // (1)
export function foo() {
bar(); // (2)
}
//------ b.js ------
import {foo} from `a`; // (3)
export function bar() {
if (Math.random()) {
foo(); // (4)
}
}
假設先載入模組 a,在模組 a 載入完成之後,bar 間接性地指向的是模組 b 中的 bar。無論是載入完成的 imports 還是未完成的 imports,imports 和 exports 之間都有一個間接的聯絡,所以總是可以正常工作。
例項
//---module-B.js檔案---
//匯出變數:name
export var name = "cfangxu";
moduleA模組程式碼:
//匯入 模組B的屬性 name
import { name } from "./module-B.js";
console.log(name)
//列印結果:cfangxu
批量匯出
//屬性name
var name = "cfangxu";
//屬性age
var age = 26;
//方法 say
var say = function(){
console.log("say hello");
}
//批量匯出
export {name,age,say}
批量匯入
//匯入 模組B的屬性
import { name,age,say } from "./module-B.js";
console.log(name)
//列印結果:cfangxu
console.log(age)
//列印結果:26
say()
//列印結果:say hello
重新命名匯入變數
import {name as myName} from `./module-B.js`;
console.log(myName) //cfangxu
整體匯入
/使用*實現整體匯入
import * as obj from "./module-B.js";
console.log(obj.name)
//結果:"cfangxu"
console.log(obj.age)
//結果:26
obj.say();
//結果:say hello