你真的懂模組化嗎?教你CommonJS實現

上帝的眼發表於2018-08-07

你真的懂模組化嗎

加緊學習,抓住中心,寧精勿雜,寧專勿多。 —— 周恩來

模組簡史

  • 早期的 JavaScript 往往作為嵌入到 HTML 頁面中的用於控制動畫與簡單的使用者互動的指令碼語言,我們習慣這樣寫。
<!--html-->
<script type="application/javascript">
    // module1 code
    // module2 code
</script>
複製程式碼
  • 所有的嵌入到網頁內的 JavaScript 物件都會使用全域性的 window 物件來存放未使用 var 定義的變數。這就會導致一個問題,那就是,最後呼叫的函式或變數取決於我們引入的先後順序。

  • 模組化時代。隨著單頁應用與富客戶端的流行,不斷增長的程式碼庫也急需合理的程式碼分割與依賴管理的解決方案,這也就是我們在軟體工程領域所熟悉的模組化(Modularity)

  • 直接宣告依賴(Directly Defined Dependences)、名稱空間(Namespace Pattern)、模組模式(Module Pattern)、依賴分離定義(Detached Dependency Definitions)、沙盒(Sandbox)、依賴注入(Dependency Injection)、CommonJS、AMD、UMD、標籤化模組(Labeled Modules)、YModules、ES 2015 Modules。這些都是模組化時代的產物。

  • 問題來了,過度碎片化的模組同樣會帶來效能的損耗與包體尺寸的增大,這包括了模組載入、模組解析、因為 Webpack 等打包工具包裹模組時封裝的過多IIFE 函式導致的 JavaScript 引擎優化失敗等。

你真的懂模組化嗎?教你CommonJS實現

那麼到底什麼是模組化?

簡而言之,模組化就是將一個大的功能拆分為多個塊,每一個塊都是獨立的,你不需要去擔心汙染全域性變數,命名衝突什麼的。

好處

  • 封裝功能
  • 封閉作用域
  • 可能解決依賴問題
  • 工作效率更高,重構方便
  • 解決命名衝突
  • ...

js有模組化嗎?

  • JS沒有模組系統,不支援封閉的作用域和依賴管理
  • 沒有標準庫,沒有檔案系統和IO流API
  • 也沒有包管理系統

那怎麼實現js的模組化?

  • CommonJS規範,node是在v8引擎上的javascript執行時,作為服務端的,不能沒有模組化的功能,於是就創造CommonJS規範,現在的node用的是CommonJS2。CommonJS2和CommonJS1的區別也在下面。屬於動態同步載入
// CommonJS2也可以通過這種方式匯出
module.exports = {
    a: 1
}
// CommonJS1只能通過這種方式
exports.a = 1

// b.js
var module = require('./a.js')
module.a // -> log 1
複製程式碼
  • AMD && CMD。AMD是RequireJS提出的,主要是依賴前置。CMD是SeaJS提出的,主要是就近依賴(只要用到才會匯入),兩者用法接近。屬於非同步載入
// file lib/greeting.js
define(function() {
    var helloInLang = {
        en: 'Hello world!',
        es: '¡Hola mundo!',
        ru: 'Привет мир!'
    };

    return {
        sayHello: function (lang) {
            return helloInLang[lang];
        }
    };
});

// file hello.js
define(['./lib/greeting'], function(greeting) {
    var phrase = greeting.sayHello('en');
    document.write(phrase);
});
複製程式碼
  • UMD。因為AMD中無法使用CommonJS,所以出來了一個UMD,可在UMD中同時使用AMD和CommonJS。
(function(define) {
    define(function () {
        var helloInLang = 'hello';

        return {
            sayHello: function (lang) {
                return helloInLang[lang];
            }
        };
    });
}(
    typeof module === 'object' && module.exports && typeof define !== 'function' ?
    function (factory) { module.exports = factory(); } :
    define
));
複製程式碼

CommonJS實現

  • 首先我們這裡說的CommonJS是CommonJS2,我們需要了解到它的特性。
  • 模組引用時會找到絕對路徑
  • 模組載入過會有快取,把檔名作為key,module作為value
  • node實現模組化就是增加了一個閉包,並且自執行這個閉包(runInThisContext)
  • 模組載入時是同步操作
  • 預設會加字尾js,json,...
  • 不同模組下的變數不會相互衝突

閉包實現(其實CommonJS中每個模組都是一個閉包,所以裡面的變數互不影響)

  • 我們可以在vscode中建立一個arguments.js專案
//arguments就是引數列表
console.log(arguments)
複製程式碼
  • 此時在node環境下執行該檔案,就會輸出如下
{ '0': {},
  '1': 
   { [Function: require]
     resolve: { [Function: resolve] paths: [Function: paths] },
     main: 
      Module {
        id: '.',
        exports: {},
        parent: null,
        filename: '/Users/chenxufeng/Desktop/筆記/node/arguments.js',
        loaded: false,
        children: [],
        paths: [Array] },
     extensions: { '.js': [Function], '.json': [Function], '.node': [Function] },
     cache: { '/Users/chenxufeng/Desktop/筆記/node/arguments.js': [Object] } },
  '2': 
   Module {
     id: '.',
     exports: {},
     parent: null,
     filename: '/Users/chenxufeng/Desktop/筆記/node/arguments.js',
     loaded: false,
     children: [],
     paths: 
      [ '/Users/chenxufeng/Desktop/筆記/node/node_modules',
        '/Users/chenxufeng/Desktop/筆記/node_modules',
        '/Users/chenxufeng/Desktop/node_modules',
        '/Users/chenxufeng/node_modules',
        '/Users/node_modules',
        '/node_modules' ] },
  '3': '/Users/chenxufeng/Desktop/筆記/node/arguments.js',
  '4': '/Users/chenxufeng/Desktop/筆記/node' }
複製程式碼
  • 其實每個模組外面都包了這麼一層閉包,所以外面的require才能獲取到module.exports的值
//exports記憶體中指向的就是module.exports指向的那塊空間
//require一個方法
//Module模組類
//__filename該檔案絕對路徑
//__dirname該檔案父資料夾的絕對路徑
(function(exports,require,Module,__filename,__dirname){
  module.exports = exports = this = {}
  //檔案中的所有程式碼
  

  //不能改變exports指向,因為返回的是module.exports,所以是個{}
  return module.exports
})
複製程式碼

所以我們require的時候其實就相當於執行了這麼一個閉包,然後返回的就是我們的module.exports

require是怎麼樣的?

  • 每個模組都會帶一個require方法
  • 動態載入(v8執行到這一步才會去載入此模組)
  • 不同模組的類別,有不同的載入方式,一般有三種常用字尾
    • 字尾名為.js的JavaScript指令碼檔案,需要先讀入記憶體再執行
    • 字尾名為.json的JSON檔案,fs 讀入記憶體 轉化成JSON物件
    • 字尾名為.node的經過編譯後的二進位制C/C++擴充套件模組檔案,可以直接使用
  • 查詢第三方模組
    • 如果require函式只指定名稱則視為從node_modules下面載入檔案,這樣的話你可以移動模組而不需要修改引用的模組路徑。
    • 第三方模組的查詢路徑包括module.paths和全域性目錄。

流程圖

你真的懂模組化嗎?教你CommonJS實現

程式碼實現

下面我通過步驟講解require整個的一個實現

根據路徑找是否有快取

//require方法
function req(moduleId){
  //解析絕對路徑的方法,返回一個絕對路徑
  let p = Module._resolveFileName(moduleId)
  //檢視是否有快取
  if(Module._catcheModule[p]){
    //有快取直接返回對應模組的exports
    return Module._catcheModule[p].exports
  }
  //沒有快取就生成一個
  let module = new Module(p)
  //把他放入快取中
  Module._catcheModule[p] = module
  //載入模組
  module.exports = module.load(p)
  return module.exports
}
複製程式碼

上面有很多方法都還沒有,不急,我們慢慢實現

建立Module類,並新增_resolveFileName_catcheModule

//node原生的模組,用來讀寫檔案(fileSystem)
let fs = require('fs')
//node原生的模組,用來解析檔案路徑
let path = require('path')
//Module類,就相當於我們的模組(因為node環境不支援es6的class,這裡用function)
function Module(p){
  //當前模組的標識
  this.id = p
  //沒個模組都有一個exports屬性
  this.exports = {}
  //這個模組預設沒有載入完
  this.loaded = false
  //模組載入方法(這個我們到時候再實現)
  this.load = function(filepath){
    //判斷檔案是json還是 node還是js
    let ext = path.extname(filepath)
    //返回一個exports
    return Module._extensions[ext](this)
  }
}

//以絕對路徑為key儲存一個module
Module._catcheModule = {}
// 解析絕對路徑的方法,返回一個絕對路徑
Module._resolveFileName = function(moduleId){
  //獲取moduleId的絕對路徑
  let p = path.resolve(moduleId)
  try{
    //同步地測試 path 指定的檔案或目錄的使用者許可權
    fs.accessSync(p)      
    return p
  }catch(e){
    console.log(e)
  }
}
複製程式碼

此時會有一個問題,如果我們沒有傳檔案字尾,就會讀取不到

給Module新增一個載入策略,並且在_resolveFileName中再加點東西

//所有的載入策略
Module._extensions = {
  '.js': function(module){
    //每個檔案的載入邏輯不一樣,這個我們後面再寫
  },
  '.json': function(module){
  },
  '.node': 'xxx',
}
Module._resolveFileName = function(moduleId){
  //物件中所有的key做成一個陣列[]
  let arr = Object.keys(Module._extensions)
  for(let i=0;i<arr.length;i++){
    let file = p+arr[i]
    //因為整個模組讀取是個同步過程,所以得用sync,這裡判斷有沒有這個檔案存在
    try{
      fs.accessSync(file)      
      return p
    }catch(e){
      console.log(e)
    }
  }
}
複製程式碼

此時,我們能夠找到檔案的絕對路徑,並把他丟給Module例項上的load方法

load方法實現

//node原生的模組,用來讀寫檔案(fileSystem)
let fs = require('fs')
//node原生的模組,用來解析檔案路徑
let path = require('path')
//提供了一系列 API 用於在 V8 虛擬機器環境中編譯和執行程式碼。
let vm = require('vm')
//Module類,就相當於我們的模組(因為node環境不支援es6的class,這裡用function)
function Module(p){
  //當前模組的標識
  this.id = p
  //沒個模組都有一個exports屬性
  this.exports = {}
  //這個模組預設沒有載入完
  this.loaded = false
  //模組載入方法
  this.load = function(filepath){
    //判斷檔案字尾是json還是 node還是js
    let ext = path.extname(filepath)
    return Module._extensions[ext](this)
  }
}

//js檔案載入的包裝類
Module._wrapper = ['(function(exports,require,module,__dirname,__filename){','\n})']
//所有的載入策略
Module._extensions = {
   //這裡的module引數是就是Module的例項
  '.js': function(module){
    let fn = Module._wrapper[0] + fs.readFileSync(module.id,'utf8') + Module._wrapper[1]
    //執行包裝後的方法 把js檔案中的匯出引入module的exports中
    //模組中的this === module.exports === {} exports也只是module.exports的別名
    //runInThisContext:虛擬機器會產生一個乾淨的作用域來跑其中的程式碼,類似於沙箱sandbox
    vm.runInThisContext(fn).call(module.exports,module.exports,req,module)
    return module.exports
  },
  '.json': function(module){
    //同步讀取檔案中的內容並把它轉為JSON物件
    return JSON.parse(fs.readFileSync(module.id,'utf8'))
  },
  '.node': 'xxx',
}
複製程式碼

此時我們的程式碼已經全部完成

  • 我們隨便找個檔案試一下,當然如果是vscode下的話,req的路徑引數需要在根目錄下,這是一個坑。
  • 如果是vscode,就可以下一個外掛Code Runner,可在vscode右鍵直接執行js檔案,在node環境中。
  • 我們拿之前的arguments.js來實驗

你真的懂模組化嗎?教你CommonJS實現

  • 成功輸出!!

完整程式碼

//node原生的模組,用來讀寫檔案(fileSystem)
let fs = require('fs')
//node原生的模組,用來解析檔案路徑
let path = require('path')
//提供了一系列 API 用於在 V8 虛擬機器環境中編譯和執行程式碼。
let vm = require('vm')
//Module類,就相當於我們的模組(因為node環境不支援es6的class,這裡用function)
function Module(p){
  //當前模組的標識
  this.id = p
  //沒個模組都有一個exports屬性
  this.exports = {}
  //這個模組預設沒有載入完
  this.loaded = false
  //模組載入方法
  this.load = function(filepath){
    //判斷檔案是json還是 node還是js
    let ext = path.extname(filepath)
    return Module._extensions[ext](this)
  }
}
//js檔案載入的包裝類
Module._wrapper = ['(function(exports,require,module,__dirname,__filename){','\n})']
//所有的載入策略
Module._extensions = {
  '.js': function(module){
    let fn = Module._wrapper[0] + fs.readFileSync(module.id,'utf8') + Module._wrapper[1]
    //執行包裝後的方法 把js檔案中的匯出引入module的exports中
    //模組中的this === module.exports === {}  exports也只是module.exports的別名
    vm.runInThisContext(fn).call(module.exports,module.exports,req,module)
    return module.exports
  },
  '.json': function(module){
    return JSON.parse(fs.readFileSync(module.id,'utf8'))
  },
  '.node': 'xxx',
}
//以絕對路徑為key儲存一個module
Module._catcheModule = {}
// 解析絕對路徑的方法,返回一個絕對路徑
Module._resolveFileName = function(moduleId){
  let p = path.resolve(moduleId)
  try{
    fs.accessSync(p)      
    return p
  }catch(e){
    console.log(e)
  }
  //物件中所有的key做成一個陣列[]
  let arr = Object.keys(Module._extensions)
  for(let i=0;i<arr.length;i++){
    let file = p+arr[i]
    //因為整個模組讀取是個同步過程,所以得用sync,這裡判斷有沒有這個檔案存在
    try{
      fs.accessSync(file)      
      return file
    }catch(e){
      console.log(e)
    }
  }
}
//require方法
function req(moduleId){
  let p = Module._resolveFileName(moduleId)
  if(Module._catcheModule[p]){
    //模組已存在
    return Module._catcheModule[p].exports
  }
  //沒有快取就生成一個
  let module = new Module(p)
  Module._catcheModule[p] = module
  //載入模組
  module.exports = module.load(p)
  return module.exports
}
複製程式碼

相關文章