天天看點

Node.js子產品化你所需要知道的事

Node.js子產品化你所需要知道的事

前言

我們知道,Node.js是基于Commonjs規範進行子產品化管理的,子產品化是面對複雜的業務場景不可或缺的工具,或許你經常使用它,但卻從沒有系統的了解過,是以今天我們來聊一聊Node.js子產品化你所需要知道的一些事兒,一探Node.js子產品化的面貌。

正文

在Node.js中,内置了兩個子產品來進行子產品化管理,這兩個子產品也是兩個我們非常熟悉的關鍵字:require和module。内置意味着我們可以在全局範圍内使用這兩個子產品,而無需像其他子產品一樣,需要先引用再使用。

無需 require('require') or require('module')      

在Node.js中引用一個子產品并不是什麼難事兒,很簡單:​

const config = require('/path/to/file')      

但實際上,這句簡單的代碼執行了一共五個步驟: 

Node.js子產品化你所需要知道的事

了解這五個步驟有助于我們了解Node.js子產品化的基本原理,也能讓我們甄别一些陷阱,讓我們簡單概括下這五個步驟都做了什麼:

Resolving: 找到待引用的目标子產品,并生成絕對路徑。

Loading: 判斷待引用的子產品内容是什麼類型,它可能是.json檔案、.js檔案或者.node檔案。

Wrapping: 顧名思義,包裝被引用的子產品。通過包裝,讓子產品具有私有作用域。

Evaluating: 被加載的子產品被真正的解析和處理執行。

Caching: 緩存子產品,這讓我們在引入相同子產品時,不用再重複上述步驟。

有些同學看完這五個步驟可能已經心知肚明,對這些原理輕車熟路,有些同學心中可能産生了更多疑惑,無論如何,接下來的内容會詳細解析上述的執行步驟,希望能幫助大家答疑解惑 or 鞏固知識、查缺補漏。

By the way,如果有需要,可以和我一樣,建構一個實驗目錄,跟着Demo進行實驗。

什麼是子產品

想要了解子產品化,需要先直覺地看看子產品是什麼。

我們知道在Node.js中,檔案即子產品,剛剛提到了子產品可以是.js、.json或者.node檔案,通過引用它們,可以擷取工具函數、變量、配置等等,但是它的具體結構是怎樣呢?在指令行中簡單執行下面的指令就可以看到子產品,也就是module對象的結構:

~/learn-node $ node
> module
Module {
  id: '<repl>',
  exports: {},
  parent: undefined,
  filename: null,
  loaded: false,
  children: [],
  paths: [ ... ] }      

可以看到子產品也就是一個普通對象,隻不過結構中有幾個特殊的屬性值,需要我們一一去了解,有些屬性,例如id、parent、filename、children甚至都無需解釋,通過字面意思就可以了解。

後續的内容會幫助大家了解這些字段的意義和作用。

Resolving

大緻了解了什麼是子產品後,我們從第一個步驟Resolving開始,了解子產品化原理,也就是Node.js如何尋找目标子產品,并生成目标子產品的絕對路徑。

為什麼我們剛剛要先列印module對象,先讓大家了解module的結構呢?因為這裡有兩個字段值id、paths和這個步驟息息相關。一起來看看吧。

首先是id屬性:

每個module都有id屬性,通常這個屬性值是子產品的完整路徑,通過這個值Node.js可以辨別和定位子產品的所在位置。但是在這兒并沒有具體的子產品,我們隻是在指令行中輸出了module的結構,是以為預設的<repl>值(repl表示互動式解釋器)。

其次是paths屬性:

這個paths屬性有什麼作用呢?Node.js允許我們用多種方式來引用子產品,比如相對路徑、絕對路徑、預置路徑(馬上會解釋),假設我們需要引用一個叫做find-me的子產品,require如何幫助我們找到這個子產品呢?​

require('find-me')      

我們先列印看看paths中是什麼内容:

~/learn-node $ node
> module.paths
[ '/Users/samer/learn-node/repl/node_modules',
  '/Users/samer/learn-node/node_modules',
  '/Users/samer/node_modules',
  '/Users/node_modules',
  '/node_modules',
  '/Users/samer/.node_modules',
  '/Users/samer/.node_libraries',
  '/usr/local/Cellar/node/7.7.1/lib/node' ]      

ok,其實就是一堆系統絕對路徑,這些路徑表示了所有目标子產品可能出現的位置,并且它們是有序的,這意味着Node.js會按序查找paths中列出的所有路徑,如果找到這個子產品,就輸出該子產品的絕對路徑供後續使用。

現在我們知道Node.js會在這一堆目錄中查找module,嘗試執行require('find-me')來查找find-me子產品,由于我們并沒有在任何目錄放置find-me子產品,是以Node.js在周遊所有目錄之後并不能找到目标子產品,是以報錯Cannot find module 'find-me',這個錯誤大家也許經常看到:

~/learn-node $ node
> require('find-me')
Error: Cannot find module 'find-me'
    at Function.Module._resolveFilename (module.js:470:15)
    at Function.Module._load (module.js:418:25)
    at Module.require (module.js:498:17)
    at require (internal/module.js:20:19)
    at repl:1:1
    at ContextifyScript.Script.runInThisContext (vm.js:23:33)
    at REPLServer.defaultEval (repl.js:336:29)
    at bound (domain.js:280:14)
    at REPLServer.runBound [as eval] (domain.js:293:12)
    at REPLServer.onLine (repl.js:533:10)      

現在,可以嘗試把需要引用的find-me子產品放在上述的任意一個目錄下,在這裡我們建立一個node_modules目錄,并建立find-me.js檔案,讓Node.js能夠找到它:

~/learn-node $ mkdir node_modules 


~/learn-node $ echo "console.log('I am not lost');" > node_modules/find-me.js


~/learn-node $ node
> require('find-me');
I am not lost
{}
>      

手動建立了find-me.js檔案後,Node.js果然找到了目标子產品。當然,當Node.js本地的node_modules目錄中找到了find-me子產品,就不會再去後續的目錄中繼續尋找了。

有Node.js開發經驗的同學會發現在引用子產品時,不一定非得指定到準确的檔案,也可以通過引用目錄來完成對目标子產品的引用,例如:

~/learn-node $ mkdir -p node_modules/find-me


~/learn-node $ echo "console.log('Found again.');" > node_modules/find-me/index.js


~/learn-node $ node
> require('find-me');
Found again.
{}
>      

find-me目錄下的index.js檔案會被自動引入。

當然,這是有規則限制的,Node.js之是以能夠找到find-me目錄下的index.js檔案,是因為預設的子產品引入規則是當具體的檔案名缺失時尋找index.js檔案。我們也可以更改引入規則(通過修改package.json),比如把index -> main:

~/learn-node $ echo "console.log('I rule');" > node_modules/find-me/main.js


~/learn-node $ echo '{ "name": "find-me-folder", "main": "main.js" }' > node_modules/find-me/package.json


~/learn-node $ node
> require('find-me');
I rule
{}
>      

require.resolve

如果你隻想要在項目中引入某個子產品,而不想立即執行它,可以使用require.resolve方法,它和require方法功能相似,隻是并不會執行被引入的子產品方法:

> require.resolve('find-me');
'/Users/samer/learn-node/node_modules/find-me/start.js'
> require.resolve('not-there');
Error: Cannot find module 'not-there'
    at Function.Module._resolveFilename (module.js:470:15)
    at Function.resolve (internal/module.js:27:19)
    at repl:1:9
    at ContextifyScript.Script.runInThisContext (vm.js:23:33)
    at REPLServer.defaultEval (repl.js:336:29)
    at bound (domain.js:280:14)
    at REPLServer.runBound [as eval] (domain.js:293:12)
    at REPLServer.onLine (repl.js:533:10)
    at emitOne (events.js:101:20)
    at REPLServer.emit (events.js:191:7)
>      

可以看到,如果該子產品被找到了,Node.js會列印子產品的完整路徑,如果未找到,就報錯。

了解了Node.js是如何尋找子產品之後,來看看Node.js是如何加載子產品的。

子產品間的父子依賴關系

我們把子產品間引用關系,表示為父子依賴關系。

簡單建立一個lib/util.js檔案,添加一行console.log語句,辨別這是一個被引用的子子產品。

~/learn-node $ mkdir lib
~/learn-node $ echo "console.log('In util');" > lib/util.js      

在index.js也輸入一行console.log語句,辨別這是一個父子產品,并引用剛剛建立的lib/util.js作為子子產品。

~/learn-node $ echo "require('./lib/util'); console.log('In index, parent', module);" > index.js      

執行index.js,看看它們間的依賴關系:

~/learn-node $ node index.js
In util
In index <ref *1> Module {
  id: '.',
  path: '/Users/samer/',
  exports: {},
  parent: null,
  filename: '/Users/samer/index.js',
  loaded: false,
  children: [
    Module {
      id: '/Users/samer/lib/util.js',
      path: '/Users/samer/lib',
      exports: {},
      parent: [Circular *1],
      filename: '/Users/samer/lib/util.js',
      loaded: true,
      children: [],
      paths: [Array]
    }
  ],
  paths: [...]
}      

在這裡我們關注與依賴關系相關的兩個屬性:children和parent。

在列印的結果中,children字段包含了被引入的util.js子產品,這表明了util.js是index.js所依賴的子子產品。

但仔細觀察util.js子產品的parent屬性,發現這裡出現了Circular這個值,原因是當我們列印子產品資訊時,産生了循環的依賴關系,在子子產品資訊中列印父子產品資訊,又要在父子產品資訊中列印子子產品資訊,是以Node.js簡單地将它處理标記為Circular。

為什麼需要了解父子依賴關系呢?因為這關系到Node.js是如何處理循環依賴關系的,後續會較長的描述。

在看循環依賴關系的處理問題之前,我們需要先了解兩個關鍵的概念:exports和module.exports。

exports, module.exports

exports:

exports是一個特殊的對象,它在Node.js中可以無需聲明,作為全局變量直接使用。它實際上是module.exports的引用,通過修改exports可以達到修改module.exports的目的。

exports也是剛剛列印的module結構中的一個屬性值,但是剛剛列印出來的值都是空對象,因為我們并沒有在檔案中對它進行操作,現在我們可以嘗試簡單地為它指派:​

// 在lib/util.js的開頭新增一行
exports.id = 'lib/util';


// 在index.js的開頭新增一行
exports.id = 'index';      

執行index.js:

~/learn-node $ node index.js
In index Module {
  id: '.',
  exports: { id: 'index' },
  loaded: false,
  ... }
In util Module {
  id: '/Users/samer/learn-node/lib/util.js',
  exports: { id: 'lib/util' },
  parent:
   Module {
     id: '.',
     exports: { id: 'index' },
     loaded: false,
     ... },
  loaded: false,
  ... }      

可以看到剛剛添加的兩個id屬性被成功添加到exports對象中。我們也可以添加除id以外的任意屬性,就像操作普通對象一樣,當然也可以把exports變成一個function,例如:

exports = function() {}      

module.exports:

module.exports對象其實就是我們最終通過require所得到的東西。我們在編寫一個子產品時,最終給module.exports賦什麼值,其他人引用該子產品時就能得到什麼值。例如,結合剛剛對lib/util的操作:​

const util = require('./lib/util');


console.log('UTIL:', util);


// 輸出結果


UTIL: { id: 'lib/util' }      

由于我們剛剛通過exports對象為module.exports指派{id: 'lib/util'},是以require的結果就相應地發生了變化。

現在我們大緻了解了exports和module.exports都是什麼,但是有一個小細節需要注意,那就是Node.js的子產品加載是個同步的過程。

我們回過頭來看看module結構中的loaded屬性,這個屬性辨別這個子產品是否被加載完成,通過這個屬性就能簡單驗證Node.js子產品加載的同步性。

當子產品被加載完成後,loaded值應該為true。但到目前為止每次我們列印module時,它的狀态都是false,這其實正是因為在Node.js中,子產品的加載是同步的,當我們還未完成加載的動作(加載的動作包括對module進行标記,包括标記loaded屬性),是以列印出的結果就是預設的loaded: false。

我們用setImmediate來幫助我們驗證這個資訊:​

// In index.js
setImmediate(() => {
  console.log('The index.js module object is now loaded!', module)
});      
The index.js module object is now loaded! Module {
  id: '.',
  exports: [Function],
  parent: null,
  filename: '/Users/samer/learn-node/index.js',
  loaded: true,
  children:
   [ Module {
       id: '/Users/samer/learn-node/lib/util.js',
       exports: [Object],
       parent: [Circular],
       filename: '/Users/samer/learn-node/lib/util.js',
       loaded: true,
       children: [],
       paths: [Object] } ],
  paths:
   [ '/Users/samer/learn-node/node_modules',
     '/Users/samer/node_modules',
     '/Users/node_modules',
     '/node_modules' ] }      

ok,由于console.log被後置到加載完成(打完标記)之後,是以現在加載狀态變成了loaded: true。這充分驗證了Node.js子產品加載是一個同步過程。

了解了exports、module.exports以及子產品加載的同步性後,來看看Node.js是如何處理子產品的循環依賴關系。

子產品循環依賴

在上述内容中,我們了解到了子產品之間是存在父子依賴關系的,那如果子產品之間産生了循環的依賴關系,Node.js會怎麼處理呢?假設有兩個子產品,分别為module1.js和modole2.js,并且它們互相引用了對方,如下:​

// lib/module1.js


exports.a = 1;


require('./module2'); // 在這兒引用


exports.b = 2;
exports.c = 3;


// lib/module2.js


const Module1 = require('./module1');
console.log('Module1 is partially loaded here', Module1); // 引用module1并列印它      

嘗試運作module1.js,可以看到輸出結果:

~/learn-node $ node lib/module1.js
Module1 is partially loaded here { a: 1 }      

結果中隻輸出了{a: 1},而{b: 2, c: 3}卻不見了。仔細觀察module1.js,發現我們在module1.js的中間位置添加了對module2.js的引用,也就是exports.b = 2和exports.c = 3還未執行之前的位置。

如果我們把這個位置稱作發生循環依賴的位置,那麼我們得到的結果就是在循環依賴發生前被導出的屬性,這也是基于我們上述驗證過的Node.js的子產品加載是同步過程的結論。

Node.js就是這樣簡單地處理循環依賴。在加載子產品的過程中,會逐漸建構exports對象,為exports指派。如果我們在子產品被完全加載前就引用這個子產品,那麼我們隻能得到部分的exports對象屬性。

.json和.node

在Node.js中,我們不僅能用require來引用JavaScript檔案,還能用于引用JSON或C++插件(.json和.node檔案)。我們甚至都不需要顯式地聲明對應的檔案字尾。

在指令行中也可以看到require所支援的檔案類型:

~ % node
> require.extensions
[Object: null prototype] {
  '.js': [Function (anonymous)],
  '.json': [Function (anonymous)],
  '.node': [Function (anonymous)]
}      

當我們用require引用一個子產品,首先Node.js會去比對是否有.js檔案,如果沒有找到,再去比對.json檔案,如果還沒找到,最後再嘗試比對.node檔案。但是通常情況下,為了避免混淆和引用意圖不明,可以遵循在引用.json或.node檔案時顯式地指定字尾,引用.js時省略字尾(可選,或都加上字尾)。

.json檔案:

引用.json檔案很常用,例如一些項目中的靜态配置,使用.json檔案來存儲更便于管理,例如:

{
  "host": "localhost",
  "port": 8080
}      

引用它或使用它都很簡單:​

const { host, port } = require('./config');
console.log(`Server will run at http://${host}:${port}`)      

輸出如下:

Server will run at http://localhost:8080      

.node檔案:

.node檔案是由C++檔案轉化而來,官網提供了一個簡單的由C++實作的 hello插件 ,它暴露了一個hello()方法,輸出字元串world。有需要的話,可以跳轉連結做更多了解并進行實驗。

我們可以通過node-gyp來将.cc檔案編譯和建構成.node檔案,過程也非常簡單,隻需要配置一個binding.gyp檔案即可。

這裡不詳細闡述,隻需要知道生成.node檔案後,就可以正常地引用該檔案,并使用其中的方法。

例如,将hello()轉化生成addon.node檔案後,引用并使用它:​

const addon = require('./addon');


console.log(addon.hello());      

Wrapping

其實在上述内容中,我們闡述了在Node.js中引用一個子產品的前兩個步驟Resolving和Loading,它們分别解決了子產品的路徑和加載的問題。接下來看看Wrapping都做了什麼。

Wrapping就是包裝,包裝的對象就是所有我們在子產品中寫的代碼。也就是我們引用子產品時,其實經曆了一層『透明』的包裝。

要了解這個包裝過程,首先要了解exports和module.exports之間的差別。

exports是對module.exports的引用,我們可以在子產品中使用exports來導出屬性,但是不能直接替換它。例如:

exports.id = 42; // ok,此時exports指向module.exports,相當于修改了module.exports.


exports = { id: 42 }; // 無用,隻是将它指向了{ id: 42 }對象而已,對module.exports不會産生實際改變.


module.exports = { id: 42 }; // ok,直接操作module.exports.      

大家也許會有疑惑,為什麼這個exports對象似乎對每個子產品來說都是一個全局對象,但是它又能夠區分導出的對象是來自于哪個子產品,這是怎麼做到的。

在了解包裝(Wrapping)過程之前,來看一個小例子:​

// In a.js
var value = 'global'


// In b.js
console.log(value)  // 輸出:global


// In c.js
console.log(value)  // 輸出:global


// In index.html
...
<script src="a.js"></script>
<script src="b.js"></script>
<script src="c.js"></script>      

當我們在a.js腳本中定義一個值value,這個值是全局可見的,後續引入的b.js和c.js都是可以通路該value值。

但是在Node.js子產品中卻并不是這樣,在一個子產品中定義的變量具有私有作用域,在其它子產品中無法直接通路。這個私有作用域如何産生的?

答案很簡單,是因為在編譯子產品之前,Node.js将子產品中的内容包裝在了一個function中,通過函數作用域實作了私有作用域。

通過require('module').wrapper可以列印出wrapper屬性:

~ $ node
> require('module').wrapper
[ '(function (exports, require, module, __filename, __dirname) { ',
  '\n});' ]
>      

Node.js不會直接執行檔案中的任何代碼,但它會通過這個包裝後的function來執行代碼,這讓我們的每個子產品都有了私有作用域,不會互相影響。

這個包裝函數有五個參數:exports, require, module, __filename, __dirname。我們可以通過arguments參數直接通路和列印這些參數:

~/learn-node $ echo "console.log(arguments)" > index.js


~/learn-node $ node index.js
{ '0': {},
  '1':
   { [Function: require]
     resolve: [Function: resolve],
     main:
      Module {
        id: '.',
        exports: {},
        parent: null,
        filename: '/Users/samer/index.js',
        loaded: false,
        children: [],
        paths: [Object] },
     extensions: { ... },
     cache: { '/Users/samer/index.js': [Object] } },
  '2':
   Module {
     id: '.',
     exports: {},
     parent: null,
     filename: '/Users/samer/index.js',
     loaded: false,
     children: [],
     paths: [ ... ] },
  '3': '/Users/samer/index.js',
  '4': '/Users/samer' }      

簡單了解一下這幾個參數,第一個參數exports初始時為空(未指派),第二、三個參數require和module是和我們引用的子產品相關的執行個體,它們倆不是全局的。第四、五個參數__filename和__dirname分别表示了檔案路徑和目錄。

整個包裝後的函數所做的事兒約等于:​

function (require, module, __filename, __dirname) {
  let exports = module.exports;
  
  // Your Code...
  
  return module.exports;
}      

總而言之,wrapping就是将我們的子產品作用域私有化,以module.exports作為傳回值将變量或方法暴露出來,以供使用。

Cache

緩存很容易了解,通過一個案例來看看吧:​

echo 'console.log(`log something.`)' > index.js
// In node repl
> require('./index.js')
log something.
{}
> require('./index.js')
{}
>      

可以看到,兩次引用同一個子產品,隻列印了一次資訊,這是因為第二次引用時取的是緩存,無需重新加載子產品。

列印require.cache可以看到目前的緩存資訊:

> require.cache
[Object: null prototype] {
  '/Users/samer/index.js': Module {
    id: '/Users/samer/index.js',
    path: '/Users/samer/',
    exports: {},
    parent: Module {
      id: '<repl>',
      path: '.',
      exports: {},
      parent: undefined,
      filename: null,
      loaded: false,
      children: [Array],
      paths: [Array]
    },
    filename: '/Users/samer/index.js',
    loaded: true,
    children: [],
    paths: [
      '/Users/samer/learn-node/repl/node_modules',
      '/Users/samer/learn-node/node_modules',
      '/Users/samer/node_modules',
      '/Users/node_modules',
      '/node_modules',
      '/Users/samer/.node_modules',
      '/Users/samer/.node_libraries',
      '/usr/local/Cellar/node/7.7.1/lib/node'
    ]
  }
}      

可以看到剛剛引用的index.js檔案處于緩存當中,是以不會重新加載子產品。當然我們也可以通過删除require.cache來清空緩存内容,達到重新加載的目的,這裡不再示範。

總結

本文概述了使用Node.js子產品化時需要了解到的一些基本原理和常識,希望幫助大家對Node.js子產品化有更清晰的認識。但更深入的細節并未在本文中闡述,例如wrapper函數内部的處理邏輯,CommonJS的同步加載的問題、與ES子產品的差別等等。這些未提到的内容大家可以在本文以外做更多探索。

本文完~