「Node.js系列」深入淺出Node模塊化開發——CommonJS規范
前言
本文將為大家透徹的介紹關于Node的模塊化——CommonJS的一切。
看完本文可以掌握,以下幾個方面:
- 什么是模塊化,以及沒有模塊化會帶來哪些問題,是如何解決的;
- JavaScript的設計缺陷;
- CommonJS規范;
- 它的規范特性;
- 如何配合Node完成模塊化開發;
- exports如何導出的;
- module.exports是如何導出變量的,值類型和引用類型導出間的差異;
- 從內存角度深度分析module.exports和exports又有怎樣的區別和聯系;
- require的細節,以及模塊的加載執行順序;
- CommonJS的加載過程;
- CommonJS規范的本質;
一.什么是模塊化?
在很多開發的情況下,我們都知道要使用模塊化開發,那為什么要使用它呢?
而事實上,模塊化開發最終的目的是將程序劃分成一個個小的結構;
- 在這個結構中編寫屬于自己的邏輯代碼,有自己的作用域,不會影響到其他的結構;
- 這個結構可以將自己希望暴露的變量、函數、對象等導出給其結構使用;
- 也可以通過某種方式,導入另外結構中的變量、函數、對象等;
上面說提到的結構,就是模塊;
按照這種結構劃分開發程序的過程,就是模塊化開發的過程;
二.JavaScript設計缺陷
在網頁開發的早期,由于JavaScript僅僅作為一種腳本語言,只能做一些簡單的表單驗證或動畫實現等,它還是具有很多的缺陷問題的,比如:
- var定義的變量作用域問題;
- JavaScript的面向對象并不能像常規面向對象語言一樣使用class;
- 在早期JavaScript并沒有模塊化的問題,所以也就沒有對應的模塊化解決方案;
但隨著前端和JavaScript的快速發展,JavaScript代碼變得越來越復雜了;
- ajax的出現,前后端開發分離,意味著后端返回數據后,我們需要通過JavaScript進行前端頁面的渲染;
- SPA的出現,前端頁面變得更加復雜:包括前端路由、狀態管理等等一系列復雜的需求需要通過JavaScript來實現;
- 包括Node的實現,JavaScript編寫復雜的后端程序,沒有模塊化是致命的硬傷;
所以,模塊化已經是JavaScript一個非常迫切的需求:
- 但是JavaScript本身,直到ES6(2015)才推出了自己的模塊化方案;
- 在此之前,為了讓JavaScript支持模塊化,涌現出了很多不同的模塊化規范:AMD、CMD、CommonJS等;
到此,我們明白了為什么要用模塊化開發?
那如果沒有模塊化會帶來什么問題呢?
三.沒有模塊化的問題
當我們在公司面對一個大型的前端項目時,通常是多人開發的,會把不同的業務邏輯分步在多個文件夾當中。
2.1 沒有模塊化給項目帶來的弊端
假設有兩個人,分別是小豪和小紅在開發一個項目
項目的目錄結構是這樣的
小豪開發的bar.js文件
- var name = "小豪";
- console.log("bar.js----", name);
小豪開發的baz.js文件
- console.log("baz.js----", name);
小紅開發的foo.js文件
- var name = "小紅";
- console.log("foo.js----", name);
引用路徑如下:
- <body>
- <script src="./bar.js"></script>
- <script src="./foo.js"></script>
- <script src="./baz.js"></script>
- </body>
最后當我去執行的時候,卻發現執行結果:
當我們看到這個結果,有的小伙伴可能就會驚訝,baz.js文件不是小豪寫的么?為什么會輸出小紅的名字呢?
究其原因,我們才發現,其實JavaScript是沒有模塊化的概念(至少到現在為止還沒有用到ES6規范),換句話說就是每個.js文件并不是一個獨立的模塊,沒有自己的作用域,所以在.js文件中定義的變量,都是可以被其他的地方共享的,所以小豪開發的baz.js里面的name,其實訪問的是小紅重新聲明的。
但是共享也有一點不好就是,項目的其他協作人員也可以隨意的改變它們,顯然這不是我們想要的。
2.2 IIFE解決早期的模塊化問題
所以,隨著前端的發展,模塊化變得必不可少,那么在早期是如何解決的呢?
在早期,因為函數是有自己的作用域,所以可以采用立即函數調用表達式(IIFE),也就是自執行函數,把要供外界使用的變量作為函數的返回結果。
小豪——bar.js
- var moduleBar = (function () {
- var name = "小豪";
- var age = "18";
- console.log("bar.js----", name, age);
- return {
- name,
- age,
- };
- })();
小豪——baz.js
- console.log("baz.js----", moduleBar.name);
- console.log("baz.js----", moduleBar.age);
小紅——foo.js
- (function () {
- var name = "小紅";
- var age = 20;
- console.log("foo.js----", name, age);
- })();
來看一下,解決之后的輸出結果,原調用順序不變;
但是,這又帶來了新的問題:
- 我必須記得每一個模塊中返回對象的命名,才能在其他模塊使用過程中正確的使用;
- 代碼寫起來雜亂無章,每個文件中的代碼都需要包裹在一個匿名函數中來編寫;
- 在沒有合適的規范情況下,每個人、每個公司都可能會任意命名、甚至出現模塊名稱相同的情況;
所以現在急需一個統一的規范,來解決這些缺陷問題,就此CommonJS規范問世了。
三.Node模塊化開發——CommonJS規范
3.1 CommonJS規范特性
CommonJS是一個規范,最初提出來是在瀏覽器以外的地方使用,并且當時被命名為ServerJS,后來為了體現它的廣泛性,修改為CommonJS規范。
- Node是CommonJS在服務器端一個具有代表性的實現;
- Browserify是CommonJS在瀏覽器中的一種實現;
- webpack打包工具具備對CommonJS的支持和轉換;
正是因為Node中對CommonJS進行了支持和實現,所以它具備以下幾個特點;
- 在Node中每一個js文件都是一個單獨的模塊;
- 該模塊中,包含CommonJS規范的核心變量: exports、module.exports、require;
- 使用核心變量,進行模塊化開發;
無疑,模塊化的核心是導出和導入,Node中對其進行了實現:
- exports和module.exports可以負責對模塊中的內容進行導出;
- require函數可以幫助我們導入其他模塊(自定義模塊、系統模塊、第三方庫模塊)中的內容;
3.2 CommonJS配合Node模塊化開發假設現在有兩個文件:
bar.js
- const name = "時光屋小豪";
- const age = 18;
- function sayHello(name) {
- console.log("hello" + name);
- }
main.js
- console.log(name);
- console.log(age);
執行node main.js之后,會看到
這是因為在當前main.js模塊內,沒有發現name這個變量;
這點與我們前面看到的明顯不同,因為Node中每個js文件都是一個單獨的模塊。
那么如果要在別的文件內訪問bar.js變量
- bar.js需要導出自己想要暴露的變量、函數、對象等等;
- main.js從bar.js引入想用的變量、函數、對象等等;
3.3 exports導出
exports是一個對象,我們可以在這個對象中添加很多個屬性,添加的屬性會導出。
bar.js文件導出:
- const name = "時光屋小豪";
- const age = 18;
- function sayHello(name) {
- console.log("hello" + name);
- }
- exports.name = name;
- exports.age = age;
- exports.sayHello = sayHello;
main.js文件導入:
- const bar = require('./bar');
- console.log(bar.name); // 時光屋小豪
- console.log(bar.age); // 18
其中要注意的點:
main.js中的bar變量等于exports對象;
- bar = exports
- 所以我們通過bar.xxx來使用導出文件內的變量,比如name,age;
- require其實是一個函數,返回值是一個對象,值為“導出文件”的exports對象;
3.4 從內存角度分析bar和exports是同一個對象
在Node中,有一個特殊的全局對象,其實exports就是其中之一。
如果在文件內,不再使用exports.xxx的形式導出某個變量的話,其實exports就是一個空對象。
模塊之間的引用關系
- 當我們在main.js中require導入的時候,它會去自動查找特殊的全局對象exports,并且把require函數的執行結果賦值給bar;
- bar和exports指向同一個引用(引用地址相同);
- 如果發現exports上有變量,則會放到bar對象上,正因為這樣我們才能從bar上讀取想用的變量;
為了進一步論證,bar和exports是同一個對象:
我們加入定時器看看
所以綜上所述,Node中實現CommonJS規范的本質就是對象的引用賦值(淺拷貝本質)。
把exports對象的引用賦值bar對象上。
- CommonJS規范的本質就是對象的引用賦值
3.5 module.exports又是什么?
但是Node中我們經常使用module.exports導出東西,也會遇到這樣的面試題:
module.exports和exports有什么關系或者區別呢?
3.6 require細節
require本質就是一個函數,可以幫助我們引入一個文件(模塊)中導入的對象。
require的查找規則https://nodejs.org/dist/latest-v14.x/docs/api/modules.html#modules_all_together
3.7 require模塊的加載順序
結論一: 模塊在被第一次引入時,模塊中的js代碼會被運行一次
- // aaa.js
- const name = 'coderwhy';
- console.log("Hello aaa");
- setTimeout(() => {
- console.log("setTimeout");
- }, 1000);
- // main.js
- const aaa = require('./aaa');
aaa.js中的代碼在引入時會被運行一次
結論二:模塊被多次引入時,會緩存,最終只加載(運行)一次
- // main.js
- const aaa = require('./aaa');
- const bbb = require('./bbb');
- /// aaa.js
- const ccc = require("./ccc");
- // bbb.js
- const ccc = require("./ccc");
- // ccc.js
- console.log('ccc被加載');
ccc中的代碼只會運行一次。
為什么只會加載運行一次呢?
- 每個模塊對象module都有一個屬性:loaded;
- 為false表示還沒有加載;
- 為true表示已經加載;
結論三:如果有循環引入,那么加載順序是什么?
如果出現下面模塊的引用關系,那么加載順序是什么呢?
- 這個其實是一種數據結構:圖結構;
- 圖結構在遍歷的過程中,有深度優先搜索(DFS, depth first search)和廣度優先搜索(BFS, breadth first search);
- Node采用的是深度優先算法:main -> aaa -> ccc -> ddd -> eee ->bbb;
多個模塊的引入關系
四.module.exports
4.1 真正導出的是module.exports
以下是通過維基百科對CommonJS規范的解析:
- CommonJS中是沒有module.exports的概念的;
- 但是為了實現模塊的導出,Node中使用的是Module的類,每一個模塊都是Module的一個實例module;
- 所以在Node中真正用于導出的其實根本不是exports,而是module.exports;
- exports只是module上的一個對象
但是,為什么exports也可以導出呢?
- 這是因為module對象的exports屬性是exports對象的一個引用;
- 等價于module.exports = exports = main中的bar(CommonJS內部封裝);
4.2 module.exports和exports有什么關系或者區別呢?
聯系:module.exports = exports
進一步論證module.exports = exports
- // bar.js
- const name = "時光屋小豪";
- exports.name = name;
- setTimeout(() => {
- module.exports.name = "哈哈哈";
- console.log("bar.js中1s之后", exports.name);
- }, 1000);
- // main.js
- const bar = require("./bar");
- console.log("main.js", bar.name);
- setTimeout((_) => {
- console.log("main.js中1s之后", bar.name);
- }, 2000);
在上面代碼中,只要在bar.js中修改exports對象里的屬性,導出的結果都會變,因為即使真正導出的是 module.exports,而module.exports和exports是都是相同的引用地址,改變了其中一個的屬性,另一個也會跟著改變。
注意:真正導出的模塊內容的核心其實是module.exports,只是為了實現CommonJS的規范,剛好module.exports對exports對象使用的是同一個引用而已
區別:有以下兩點
那么如果,代碼這樣修改了:
- module.exports 也就和 exports沒有任何關系了;
- 無論exports怎么改,都不會影響最終的導出結果;
- 因為module.exports = { xxx }這樣的形式,會在堆內存中新開辟出一塊內存空間,會生成一個新的對象,用它取代之前的exports對象的導出
- 那么也就意味著require導入的對象是新的對象;
圖解module.exports和exports的區別
講完它們兩個的區別,來看下面這兩個例子,看看自己是否真正掌握了module.exports的用法
4.3 關于module.exports的練習題
練習1:導出的變量為值類型
- // bar.js
- let name = "時光屋小豪";
- setTimeout(() => {
- name = "123123";
- }, 1000);
- module.exports = {
- name: name,
- age: "20",
- sayHello: function (name) {
- console.log("你好" + name);
- },
- };
- // main.js
- const bar = require("./bar");
- console.log("main.js", bar.name); // main.js 時光屋小豪
- setTimeout(() => {
- console.log("main.js中2s后", bar.name); // main.js中2s后 時光屋小豪
- }, 2000);
練習2:導出的變量為引用類型
- // bar.js
- let info = {
- name: "時光屋小豪",
- };
- setTimeout(() => {
- info.name = "123123";
- }, 1000);
- module.exports = {
- info: info,
- age: "20",
- sayHello: function (name) {
- console.log("你好" + name);
- },
- };
- // main.js
- const bar = require("./bar");
- console.log("main.js", bar.info.name); // main.js 時光屋小豪
- setTimeout(() => {
- console.log("main.js中2s后", bar.info.name); // main.js中2s后 123123
- }, 2000);
從main.js輸出結果來看,定時器修改的name變量的結果,并沒有影響main.js中導入的結果。
- 因為name為值類型,基本類型,一旦定義之后,就把其屬性值,放到了module.exports的內存里(練1)
- 因為info為引用類型,所以module.exports里存放的是info的引用地址,所以由定時器更改的變量,會影響main.js導入的結果(練2)
五.CommonJS的加載過程
CommonJS模塊加載js文件的過程是運行時加載的,并且是同步的:
- 運行時加載意味著是js引擎在執行js代碼的過程中加載模塊;
- 同步的就意味著一個文件沒有加載結束之前,后面的代碼都不會執行;
- const flag = true;
- if (flag) {
- const foo = require('./foo');
- console.log("等require函數執行完畢后,再輸出這句代碼");
- }
CommonJS通過module.exports導出的是一個對象:
- 導出的是一個對象意味著可以將這個對象的引用在其他模塊中賦值給其他變量;
- 但是最終他們指向的都是同一個對象,那么一個變量修改了對象的屬性,所有的地方都會被修改;
六.CommonJS規范的本質
CommonJS規范的本質就是對象的引用賦值
后續文章
《JavaScript模塊化——ES Module》
在下一篇文章中,
- 會重點講解ES Module規范的一切;
- 及CommonJS和ES Module是如何交互的;
- 類比CommonJS和ES Module優缺點,如何完美的回答這道面試題;
【編輯推薦】