本篇文章給大家?guī)砹岁P(guān)于JavaScript中模塊化的相關(guān)知識,希望對大家有幫助。
眾所周知,js在前端開發(fā)中的地位。學(xué)好它,真的很重要。
下面這篇文章,介紹一下模塊化。
什么是模塊化?
到底什么是模塊化、模塊化開發(fā)呢?
-
事實上模塊化開發(fā)最終的目的是將程序劃分成一個個小的結(jié)構(gòu)。
-
這個結(jié)構(gòu)中編寫屬于自己的邏輯代碼,有自己的作用域,不會影響到其他的結(jié)構(gòu)。
-
這個結(jié)構(gòu)可以將自己希望暴露的變量、函數(shù)、對象等導(dǎo)出給其結(jié)構(gòu)使用。
-
也可以通過某種方式,導(dǎo)入另外結(jié)構(gòu)中的變量、函數(shù)、對象等。
上面說提到的結(jié)構(gòu),就是模塊;按照這種結(jié)構(gòu)劃分開發(fā)程序的過程,就是模塊化開發(fā)的過程。
模塊化的歷史
在網(wǎng)頁開發(fā)的早期,Brendan Eich開發(fā)JavaScript僅僅作為一種腳本語言,做一些簡單的表單驗證或動畫實現(xiàn)等,那個時候代碼還是很少的:
-
這個時候我們只需要講JavaScript代碼寫到
-
并沒有必要放到多個文件中來編寫。
但是隨著前端和JavaScript的快速發(fā)展,JavaScript代碼變得越來越復(fù)雜了:
-
ajax的出現(xiàn),前后端開發(fā)分離,意味著后端返回數(shù)據(jù)后,我們需要通過JavaScript進行前端頁面的渲染。
-
SPA的出現(xiàn),前端頁面變得更加復(fù)雜:包括前端路由、狀態(tài)管理等等一系列復(fù)雜的需求需要通過JavaScript來實現(xiàn)。
-
包括Node的實現(xiàn),JavaScript編寫復(fù)雜的后端程序,沒有模塊化是致命的硬傷。
所以,模塊化已經(jīng)是JavaScript一個非常迫切的需求。所以ES6(2015)才推出了自己的模塊化方案。
在此之前,為了讓JavaScript支持模塊化,涌現(xiàn)出了很多不同的模塊化規(guī)范:AMD、CMD、CommonJS等。
沒有模塊化帶來的問題
比如命名沖突的問題。
通過立即函數(shù)調(diào)用表達式(IIFE)來解決上面的問題。因為函數(shù)有自己的作用域,不會造成不同文件命名沖突。
// a.js var moduleA = (function() { var name = "llm" var age = 22 var isFlag = true return { name: name, isFlag: isFlag } })()
// b.js var moduleB = (function() { var name = "zh" var isFlag = false return { name: name, isFlag: isFlag } })()
// 使用 moduleA.name moduleB.name
但是,我們其實帶來了新的問題:
-
我必須記得每一個模塊中返回對象的命名,才能在其他模塊使用過程中正確的使用。
-
代碼寫起來混亂不堪,每個文件中的代碼都需要包裹在一個匿名函數(shù)中來編寫。
-
在沒有合適的規(guī)范情況下,每個人、每個公司都可能會任意命名、甚至出現(xiàn)模塊名稱相同的情況。
所以,我們會發(fā)現(xiàn),雖然實現(xiàn)了模塊化,但是我們的實現(xiàn)過于簡單,并且是沒有規(guī)范的。
我們需要制定一定的規(guī)范來約束每個人都按照這個規(guī)范去編寫模塊化的代碼。這個規(guī)范中應(yīng)該包括核心功能:模塊本身可以導(dǎo)出暴露的屬性,模塊又可以導(dǎo)入自己需要的屬性。JavaScript社區(qū)為了解決上面的問題,涌現(xiàn)出一系列好用的規(guī)范,接下來我們就學(xué)習(xí)具有代表性的一些規(guī)范。
CommonJS規(guī)范和Node
我們需要知道CommonJS是一個規(guī)范,最初提出來是在瀏覽器以外的地方使用,并且當(dāng)時被命名為ServerJS,后來為了體現(xiàn)它的廣泛性,修改為CommonJS,平時我們也會簡稱為CJS。
-
Node是CommonJS在服務(wù)器端一個具有代表性的實現(xiàn)。
-
Browserify是CommonJS在瀏覽器中的一種實現(xiàn)。
-
webpack打包工具具備對CommonJS的支持和轉(zhuǎn)換。
所以,Node中對CommonJS進行了支持和實現(xiàn),讓我們在開發(fā)node的過程中可以方便的進行模塊化開發(fā)。
-
在Node中每一個js文件都是一個單獨的模塊。
-
這個模塊中包括CommonJS規(guī)范的核心變量:exports、module.exports、require。
-
我們可以使用這些變量來方便的進行模塊化開發(fā)。
前面我們提到過模塊化的核心是導(dǎo)出和導(dǎo)入,Node中對其進行了實現(xiàn):
-
exports和module.exports可以負(fù)責(zé)對模塊中的內(nèi)容進行導(dǎo)出。
-
require函數(shù)可以幫助我們導(dǎo)入其他模塊(自定義模塊、系統(tǒng)模塊、第三方庫模塊)中的內(nèi)容。
Node.js模塊化
Node中對CommonJS進行了支持和實現(xiàn),讓我們在開發(fā)node的過程中可以方便的進行模塊化開發(fā):
-
在Node中每一個js文件都是一個單獨的模塊。
-
這個模塊中包括CommonJS規(guī)范的核心變量:exports、module.exports、require。
-
exports和module.exports可以負(fù)責(zé)對模塊中的內(nèi)容進行導(dǎo)出。
-
require函數(shù)可以幫助我們導(dǎo)入其他模塊(自定義模塊、系統(tǒng)模塊、第三方庫模塊)中的內(nèi)容。
下面我們將來介紹exports、module.exports、require的使用。
-
exports是一個對象,我們可以在這個對象中添加很多個屬性,添加的屬性會導(dǎo)出。
-
我們也可以通過module.exports直接導(dǎo)出一個對象。
-
我們通過require()函數(shù)導(dǎo)入一個文件。并且該文件導(dǎo)出的變量。
下面來詳細(xì)介紹一個module.exports。
CommonJS中是沒有module.exports的概念的。
但是為了實現(xiàn)模塊的導(dǎo)出,Node中使用的是Module的類,每一個模塊都是Module的一個實例,也就是module。
所以在Node中真正用于導(dǎo)出的其實根本不是exports,而是module.exports。
因為module才是導(dǎo)出的真正實現(xiàn)者。
并且內(nèi)部將exports賦值給module.exports。
該方式的導(dǎo)入導(dǎo)出有以下特點:
Node中的文件都運行在一個函數(shù)中??梢酝ㄟ^打印console.log(arguments.callee + "")來驗證。
導(dǎo)入導(dǎo)出是值的引用,如果導(dǎo)出的是一個基本數(shù)據(jù)類型值,那么導(dǎo)出文件改變該值,然后導(dǎo)入文件該變量的值也不會變。
// a.js const obj = require("./b.js") console.log(obj) setTimeout(() => { obj.name = "llm" }, 1000)
// b.js const info = { name: "zh", age: 22, foo: function() { console.log("foo函數(shù)~") } } setTimeout(() => { console.log(info.name) // llm }, 2000) module.exports = info
他是通過require 函數(shù)來導(dǎo)入的,只有在執(zhí)行js代碼才會知道模塊的依賴關(guān)系。
代碼是同步執(zhí)行的。
模塊多次引入,只會加載一次。每個module內(nèi)部會存在一個loaded來確定是否被加載過。
代碼循環(huán)引入的時候,深度優(yōu)先來加載模塊。然后再廣度優(yōu)先。
下面來詳細(xì)介紹一個require的導(dǎo)入細(xì)節(jié)
我們現(xiàn)在已經(jīng)知道,require是一個函數(shù),可以幫助我們引入一個文件(模塊)中導(dǎo)出的對象。
那么,require的查找規(guī)則是怎么樣的呢?
詳細(xì)查找規(guī)則,請訪問這里
這里我總結(jié)比較常見的查找規(guī)則:導(dǎo)入格式如下:require(X)
模塊的加載細(xì)節(jié)
模塊在被第一次引入時,模塊中的js代碼會被運行一次
模塊被多次引入時,會緩存,最終只加載(運行)一次
為什么只會加載運行一次呢?
這是因為每個模塊對象module都有一個屬性:loaded。為false表示還沒有加載,為true表示已經(jīng)加載。
如果有循環(huán)引入,那么加載順序是什么?
如上圖,Node采用的是深度優(yōu)先算法:main -> aaa -> ccc -> ddd -> eee ->bbb
CommonJS規(guī)范缺點
CommonJS加載模塊是同步的:
同步的意味著只有等到對應(yīng)的模塊加載完畢,當(dāng)前模塊中的內(nèi)容才能被運行。
這個在服務(wù)器不會有什么問題,因為服務(wù)器加載的js文件都是本地文件,加載速度非??臁?/p>
如果將它應(yīng)用于瀏覽器呢?
瀏覽器加載js文件需要先從服務(wù)器將文件下載下來,之后再加載運行。
那么采用同步的就意味著后續(xù)的js代碼都無法正常運行,即使是一些簡單的DOM操作。所以在瀏覽器中,我們通常不使用CommonJS規(guī)范。當(dāng)然在webpack中使用CommonJS是另外一回事。因為它會將我們的代碼轉(zhuǎn)成瀏覽器可以直接執(zhí)行的代碼。
AMD規(guī)范
在早期為了可以在瀏覽器中使用模塊化,通常會采用AMD或CMD。但是目前一方面現(xiàn)代的瀏覽器已經(jīng)支持ES Modules,另一方面借助于webpack等工具可以實現(xiàn)對CommonJS或者ES Module代碼的轉(zhuǎn)換。AMD和CMD已經(jīng)使用非常少了,所以這里我們進行簡單的演練。
AMD主要是應(yīng)用于瀏覽器的一種模塊化規(guī)范:
AMD是Asynchronous Module Definition(異步模塊定義)的縮寫。它采用的是異步加載模塊。
我們提到過,規(guī)范只是定義代碼的應(yīng)該如何去編寫,只有有了具體的實現(xiàn)才能被應(yīng)用。
AMD實現(xiàn)的比較常用的庫是require.js和curl.js。
require.js的使用
定義HTML的script標(biāo)簽引入require.js和定義入口文件。data-main屬性的作用是在加載完src的文件后會加載執(zhí)行該文件
// index.html <script src="./require.js" data-main="./index.js"></script>
//main.js require.config({ baseUrl: '', // 默認(rèn)是main.js的文件夾路徑 paths: { foo: "./foo" } }) require(["foo"], function(foo) { console.log("main:", foo) })
// foo.js define(function() { const name = "zh" const age = 22 function sum(num1, num2) { return num1 + num2 } return { name, age, sum } })
CMD規(guī)范
CMD規(guī)范也是應(yīng)用于瀏覽器的一種模塊化規(guī)范:
CMD 是Common Module Definition(通用模塊定義)的縮寫。它也采用了異步加載模塊,但是它將CommonJS的優(yōu)點吸收了過來。
AMD實現(xiàn)的比較常用的庫是SeaJS。
SeaJS的使用
引入sea.js和使用主入口文件。
// index.html <script src="./sea.js"></script> <script> seajs.use("./main.js") </script>
//main.js define(function(require, exports, module) { const foo = require("./foo") console.log("main:", foo) })
// foo.js define(function(require, exports, module) { const name = "zh" const age = 22 function sum(num1, num2) { return num1 + num2 } // exports.name = name // exports.age = age module.exports = { name, age, sum } });
ES Module
ES Module和CommonJS的模塊化有一些不同之處:
-
一方面它使用了import和export關(guān)鍵字來實現(xiàn)模塊化。
-
另一方面它采用編譯期的靜態(tài)分析,并且也加入了動態(tài)引用的方式。
-
export負(fù)責(zé)將模塊內(nèi)的內(nèi)容導(dǎo)出。
-
import負(fù)責(zé)從其他模塊導(dǎo)入內(nèi)容。
-
采用ES Module將自動采用嚴(yán)格模式:use strict。
基本使用
// index.html <script src="./main.js" type="module"></script>
// foo.js let obj = { name: "zh", age: 22 } export default sum
// main.js import foo from './foo.js' console.log(foo)
在html文件加載入口文件的時候,需要指定type為module。
在打開html文件時,需要開啟本地服務(wù),而不能直接打開運行在瀏覽器上。
這個在MDN上面有給出解釋:
你需要注意本地測試 — 如果你通過本地加載Html 文件 (比如一個 file:// 路徑的文件), 你將會遇到 CORS 錯誤,因為Javascript 模塊安全性需要。
你需要通過一個服務(wù)器來測試。
exports關(guān)鍵字
export關(guān)鍵字將一個模塊中的變量、函數(shù)、類等導(dǎo)出。
我們希望將其他中內(nèi)容全部導(dǎo)出,它可以有如下的方式:
方式一:在語句聲明的前面直接加上export關(guān)鍵字。
export const name = "zh" export const age = 22
方式二:將所有需要導(dǎo)出的標(biāo)識符,放到export后面的 {} 中。注意:這里的 {}里面不是ES6的對象字面量的增強寫法,{}也不是表示一個對象的。所以: export {name: name},是錯誤的寫法。
const name = "zh" const age = 22 function foo() { console.log("foo function") } export { name, age, foo }
方式三:導(dǎo)出時給標(biāo)識符起一個別名。(基本沒用,一般在導(dǎo)入文件中起別名)。然后在導(dǎo)入文件中就只能使用別名來獲取。
export { name as fName, age as fAge, foo as fFoo }
import關(guān)鍵字
import關(guān)鍵字負(fù)責(zé)從另外一個模塊中導(dǎo)入內(nèi)容。
導(dǎo)入內(nèi)容的方式也有多種:
方式一:import {標(biāo)識符列表} from '模塊'。注意:這里的{}也不是一個對象,里面只是存放導(dǎo)入的標(biāo)識符列表內(nèi)容。
import { name, age } from "./foo.js"
方式二:導(dǎo)入時給標(biāo)識符起別名。
import { name as fName, age as fAge } from './foo.js'
方式三:通過 * 將模塊功能放到一個模塊功能對象(a module object)上。然后通過起別名來使用。
import * as foo from './foo.js'
export和import結(jié)合使用
表示導(dǎo)入導(dǎo)出。
import { add, sub } from './math.js' import {otherProperty} from './other.js' export { add, sub, otherProperty }
等價于
// 導(dǎo)入的所有文件會統(tǒng)一被導(dǎo)出 export { add, sub } from './math.js' export {otherProperty} from './other.js'
等價于
export * from './math.js' export * from './other.js'
為什么要這樣做呢?
在開發(fā)和封裝一個功能庫時,通常我們希望將暴露的所有接口放到一個文件中。 這樣方便指定統(tǒng)一的接口規(guī)范,也方便閱讀。這個時候,我們就可以使用export和import結(jié)合使用。
default用法
前面我們學(xué)習(xí)的導(dǎo)出功能都是有名字的導(dǎo)出(named exports):
在導(dǎo)出export時指定了名字。
在導(dǎo)入import時需要知道具體的名字。
還有一種導(dǎo)出叫做默認(rèn)導(dǎo)出(default export)
// foo.js const name = "zh" cconst age = 22 export { name, // 或者這樣的默認(rèn)導(dǎo)出 // age as default } export default age
// 導(dǎo)入語句: 導(dǎo)入的默認(rèn)的導(dǎo)出 import foo, {name} from './foo.js' console.log(foo, name) // 22 zh
默認(rèn)導(dǎo)出export時可以不需要指定名字。
在導(dǎo)入時不需要使用 {},并且可以自己來指定名字。
它也方便我們和現(xiàn)有的CommonJS等規(guī)范相互操作。
注意:在一個模塊中,只能有一個默認(rèn)導(dǎo)出(default export)。
import函數(shù)
通過import加載一個模塊,是不可以在其放到邏輯代碼中的,比如:
if(true) { import foo from './foo.js' }
為什么會出現(xiàn)這個情況呢?
這是因為ES Module在被JS引擎解析時,就必須知道它的依賴關(guān)系。
由于這個時候js代碼沒有任何的運行,所以無法在進行類似于if判斷中根據(jù)代碼的執(zhí)行情況。
但是某些情況下,我們確確實實希望動態(tài)的來加載某一個模塊:
如果根據(jù)不同的條件,動態(tài)來選擇加載模塊的路徑。
這個時候我們需要使用 import() 函數(shù)來動態(tài)加載。import函數(shù)返回的結(jié)果是一個Promise。
import("./foo.js").then(res => { console.log("res:", res.name) })
es11新增了一個屬性。meta屬性本身也是一個對象: { url: "當(dāng)前模塊所在的路徑" }
console.log(import.meta)
ES Module的解析流程
ES Module是如何被瀏覽器解析并且讓模塊之間可以相互引用的呢?
ES Module的解析過程可以劃分為三個階段:
階段一:構(gòu)建(Construction),根據(jù)地址查找js文件,并且下載,將其解析成模塊記錄(Module Record)。
階段二:實例化(Instantiation),對模塊記錄進行實例化,并且分配內(nèi)存空間,解析模塊的導(dǎo)入和導(dǎo)出語句,把模塊指向?qū)?yīng)的內(nèi)存地址。
階段三:運行(Evaluation),運行代碼,計算值,并且將值填充到內(nèi)存地址中。
階段一:
階段二和三:
所以,從上面可以看出在導(dǎo)出文件中,修改變量的值會影響到導(dǎo)入文件中的值。而且導(dǎo)入文件被限制修改導(dǎo)出文件的值。