一杯茶的時間,上手 Node.js

Node.js 太火了,火到幾乎所有前端工程師都想學,幾乎所有後端工程師也想學。一說到 Node.js,我們馬上就會想到「異步」、「事件驅動」、「非阻塞」、「性能優良」這幾個特點,但是你真的理解這些詞的含義嗎?這篇教程將帶你快速入門 Node.js,為後續的前端學習或是 Node.js 進階打下堅實的基礎。

此教程屬於Node.js 後端工程師學習路線[1]的一部分,點擊可查看全部內容。

起步

什麼是 Node?

簡單地說,Node(或者說 Node.js,兩者是等價的)是 JavaScript 的一種運行環境。在此之前,我們知道 JavaScript 都是在瀏覽器中執行的,用於給網頁添加各種動態效果,那麼可以說瀏覽器也是 JavaScript 的運行環境。那麼這兩個運行環境有哪些差異呢?請看下圖:

兩個運行環境共同包含了 ECMAScript,也就是剝離了所有運行環境的 JavaScript 語言標準本身。現在 ECMAScript 的發展速度非常驚人,幾乎能夠做到每年發展一個版本。

提示 ECMAScript 和 JavaScript 的關係是,前者是後者的規格,後者是前者的一種實現。在日常場合,這兩個詞是可以互換的。更多背景知識可參考阮一峰的《JavaScript語言的歷史》[2]。

另一方面,瀏覽器端 JavaScript 還包括了:

•瀏覽器對象模型(Browser Object Model,簡稱 BOM),也就是 window對象•文檔對象模型(Document Object Model,簡稱 DOM),也就是 document 對象

而 Node.js 則是包括 V8 引擎。V8 是 Chrome 瀏覽器中的 JavaScript 引擎,經過多年的發展和優化,性能和安全性都已經達到了相當的高度。而 Node.js 則進一步將 V8 引擎加工成可以在任何操作系統中運行 JavaScript 的平台。

預備知識

在正式開始這篇教程之前,我們希望你已經做好了以下準備:

•了解 JavaScript 語言的基礎知識,如果有過瀏覽器 JS 開發經驗就更好了•已經安裝了 Node.js,配置好了適合自己的編輯器或 IDE•了解相對路徑和絕對路徑

學習目標

這篇教程將會讓你學到:

•瀏覽器 JavaScript 與 Node.js 的關係與區別•了解 Node.js 有哪些全局對象•掌握 Node.js 如何導入和導出模塊,以及模塊機制的原理•了解如何用 Node.js 開發簡單的命令行應用•學會利用 npm 社區的力量解決開發中遇到的難題,避免「重複造輪子」•了解 npm scripts 的基本概念和使用•初步了解 Node.js 的事件機制

運行 Node 代碼

運行 Node 代碼通常有兩種方式:1)在 REPL 中交互式輸入和運行;2)將代碼寫入 JS 文件,並用 Node 執行。

提示 REPL 的全稱是 Read Eval Print Loop(讀取-執行-輸出-循環),通常可以理解為交互式解釋器,你可以輸入任何表達式或語句,然後就會立刻執行並返回結果。如果你用過 Python 的 REPL 一定會覺得很熟悉。

使用 REPL 快速體驗

如果你已經安裝好了 Node,那麼運行以下命令就可以輸出 Node.js 的版本:

$ node -v  v12.10.0

然後,我們還可以進入 Node REPL(直接輸入 node),然後輸入任何合法的 JavaScript 表達式或語句:

$ node  Welcome to Node.js v12.10.0.  Type ".help" for more information.  > 1 + 2  3  > var x = 10;  undefined  > x + 20  30  > console.log('Hello World');  Hello World  undefined

有些行的開頭是 >,代表輸入提示符,因此 > 後面的都是我們要輸入的命令,其他行則是表達式的返回值或標準輸出(Standard Output,stdout)。運行的效果如下:

編寫 Node 腳本

REPL 通常用來進行一些代碼的試驗。在搭建具體應用時,更多的還是創建 Node 文件。我們先創建一個最簡單的 Node.js 腳本文件,叫做 timer.js,代碼如下:

console.log('Hello World!');

然後用 Node 解釋器執行這個文件:

$ node timer.js  Hello World!

看上去非常平淡無奇,但是這一行代碼卻凝聚了 Node.js 團隊背後的心血。我們來對比一下,在瀏覽器和 Node 環境中執行這行代碼有什麼區別:

•在瀏覽器運行 console.log 調用了 BOM,實際上執行的是 window.console.log('Hello World!')•Node 首先在所處的操作系統中創建一個新的進程,然後向標準輸出打印了指定的字符串, 實際上執行的是 process.stdout.write('Hello World!n')

簡而言之,Node 為我們提供了一個無需依賴瀏覽器、能夠直接與操作系統進行交互的 JavaScript 代碼運行環境!

Node 全局對象初探

如果你有過編寫 JavaScript 的經驗,那麼你一定對全局對象不陌生。在瀏覽器中,我們有 documentwindow 等全局對象;而 Node 只包含 ECMAScript 和 V8,不包含 BOM 和 DOM,因此 Node 中不存在 documentwindow;取而代之,Node 專屬的全局對象是 process。在這一節中,我們將初步探索一番 Node 全局對象。

JavaScript 全局對象的分類

在此之前,我們先看一下 JavaScript 各個運行環境的全局對象的比較,如下圖所示:

可以看到 JavaScript 全局對象可以分為四類:

1.瀏覽器專屬,例如 windowalert 等等;2.Node 專屬,例如 processBuffer__dirname__filename 等等;3.瀏覽器和 Node 共有,但是實現方式不同,例如 console(第一節中已提到)、setTimeoutsetInterval 等;4.瀏覽器和 Node 共有,並且屬於 ECMAScript 語言定義的一部分,例如 DateStringPromise 等;

Node 專屬全局對象解析

process

process 全局對象可以說是 Node.js 的靈魂,它是管理當前 Node.js 進程狀態的對象,提供了與操作系統的簡單接口。

首先我們探索一下 process 對象的重要屬性。打開 Node REPL,然後我們查看一下 process 對象的一些屬性:

pid:進程編號•env:系統環境變量•argv:命令行執行此腳本時的輸入參數•platform:當前操作系統的平台

提示

可以在 Node REPL 中嘗試一下這些對象。像上面說的那樣進入 REPL(你的輸出很有可能跟我的不一樣):

$ node  Welcome to Node.js v12.10.0.  Type ".help" for more information.  > process.pid  3  > process.platform  'darwin'

Buffer

Buffer 全局對象讓 JavaScript 也能夠輕鬆地處理二進制數據流,結合 Node 的流接口(Stream),能夠實現高效的二進制文件處理。這篇教程不會涉及 Buffer

__filename__dirname

分別代表當前所運行 Node 腳本的文件路徑和所在目錄路徑。

警告 __filename__dirname 只能在 Node 腳本文件中使用,在 REPL 中是沒有定義的。

使用 Node 全局對象

接下來我們將在剛才寫的腳本文件中使用 Node 全局對象,分別涵蓋上面的三類:

•Node 專屬:process•實現方式不同的共有全局對象:consolesetTimeout•ECMAScript 語言定義的全局對象:Date

提示

setTimeout 用於在一定時間後執行特定的邏輯,第一個參數為時間到了之後要執行的函數(回調函數),第二個參數是等待時間。例如:

setTimeout(someFunction, 1000);

就會在 1000 毫秒後執行 someFunction 函數。

代碼如下:

setTimeout(() => {    console.log('Hello World!');  }, 3000);    console.log('當前進程 ID', process.pid);  console.log('當前腳本路徑', __filename);    const time = new Date();  console.log('當前時間', time.toLocaleString());

運行以上腳本,在我機器上的輸出如下(Hello World! 會延遲三秒輸出):

$ node timer.js  當前進程 ID 7310  當前腳本路徑 /Users/mRc/Tutorials/nodejs-quickstart/timer.js  當前時間 12/4/2019, 9:49:28 AM  Hello World!

從上面的代碼中也可以一瞥 Node.js 異步的魅力:在 setTimeout 等待的 3 秒內,程序並沒有阻塞,而是繼續向下執行,這就是 Node.js 的異步非阻塞!

提示 在實際的應用環境中,往往有很多 I/O 操作(例如網絡請求、數據庫查詢等等)需要耗費相當多的時間,而 Node.js 能夠在等待的同時繼續處理新的請求,大大提高了系統的吞吐率。

在後續教程中,我們會出一篇深入講解 Node.js 異步編程的教程,敬請期待!

理解 Node 模塊機制

Node.js 相比之前的瀏覽器 JavaScript 的另一個重點改變就是:模塊機制的引入。這一節內容很長,但卻是入門 Node.js 最為關鍵的一步,加油吧?!

JavaScript 的模塊化之路

Eric Raymond 在《UNIX編程藝術》中定義了模塊性(Modularity)的規則:

開發人員應使用通過定義明確的接口連接的簡單零件來構建程序,因此問題是局部的,可以在將來的版本中替換程序的某些部分以支持新功能。該規則旨在節省調試複雜、冗長且不可讀的複雜代碼的時間。

「分而治之」的思想在計算機的世界非常普遍,但是在 ES2015 標準出現以前(不了解沒關係,後面會講到), JavaScript 語言定義本身並沒有模塊化的機制,構建複雜應用也沒有統一的接口標準。人們通常使用一系列的 <script> 標籤來導入相應的模塊(依賴):

<head>    <script src="fileA.js"></script>    <script src="fileB.js"></script>  </head>

這種組織 JS 代碼的方式有很多問題,其中最顯著的包括:

•導入的多個 JS 文件直接作用於全局命名空間,很容易產生命名衝突•導入的 JS 文件之間不能相互訪問,例如 fileB.js 中無法訪問 fileA.js 中的內容,很不方便•導入的 <script> 無法被輕易去除或修改

人們漸漸認識到了 JavaScript 模塊化機制的缺失帶來的問題,於是兩大模塊化規範被提出:

1.AMD(Asynchronous Module Definition)規範[3],在瀏覽器中使用較為普遍,最經典的實現包括 RequireJS[4];2.CommonJS 規範[5],致力於為 JavaScript 生態圈提供統一的接口 API,Node.js 所實現的正是這一模塊標準。

提示 ECMAScript 2015(也就是大家常說的 ES6)標準為 JavaScript 語言引入了全新的模塊機制(稱為 ES 模塊,全稱 ECMAScript Modules),並提供了 importexport 關鍵詞,如果感興趣可參考這篇文章[6]。但是截止目前,Node.js 對 ES 模塊的支持還處於試驗階段,因此這篇文章不會講解、也不提倡使用。

什麼是 Node 模塊

在正式分析 Node 模塊機制之前,我們需要明確定義什麼是 Node 模塊。通常來說,Node 模塊可分為兩大類:

核心模塊:Node 提供的內置模塊,在安裝 Node 時已經被編譯成二進制可執行文件文件模塊:用戶編寫的模塊,可以是自己寫的,也可以是通過 npm 安裝的(後面會講到)。

其中,文件模塊可以是一個單獨的文件(以 .js.node.json 結尾),或者是一個目錄。當這個模塊是一個目錄時,模塊名就是目錄名,有兩種情況:

1.目錄中有一個 package.json 文件,則這個 Node 模塊的入口就是其中 main 字段指向的文件;2.目錄中有一個名為 index 的文件,擴展名為 .js.node.json,此文件則為模塊入口文件。

一下子消化不了沒關係,可以先閱讀後面的內容,忘記了模塊的定義可以再回過來看看哦。

Node 模塊機制淺析

知道了 Node 模塊的具體定義後,我們來了解一下 Node 具體是怎樣實現模塊機制的。具體而言,Node 引入了三個新的全局對象(還是 Node 專屬哦):1)require;2) exports 和 3)module。下面我們逐一講解。

require

require 用於導入其他 Node 模塊,其參數接受一個字符串代表模塊的名稱或路徑,通常被稱為模塊標識符。具體有以下三種形式:

•直接寫模塊名稱,通常是核心模塊或第三方文件模塊,例如 osexpress 等•模塊的相對路徑,指向項目中其他 Node 模塊,例如 ./utils•模塊的絕對路徑(不推薦!),例如 /home/xxx/MyProject/utils

提示 在通過路徑導入模塊時,通常省略文件名中的 .js 後綴。

代碼示例如下:

// 導入內置庫或第三方模塊  const os = require('os');  const express = require('express');    // 通過相對路徑導入其他模塊  const utils = require('./utils');    // 通過絕對路徑導入其他模塊  const utils = require('/home/xxx/MyProject/utils');

你也許會好奇,通過名稱導入 Node 模塊的時候(例如 express),是從哪裡找到這個模塊的?實際上每個模塊都有個路徑搜索列表 module.paths,在後面講解 module 對象的時候就會一清二楚了。

exports

我們已經學會了用 require 導入其他模塊中的內容,那麼怎麼寫一個 Node 模塊,並導出其中內容呢?答案就是用 exports 對象。

例如我們寫一個 Node 模塊 myModule.js:

// myModule.js  function add(a, b) {    return a + b;  }    // 導出函數 add  exports.add = add;

通過將 add 函數添加到 exports 對象中,外面的模塊就可以通過以下代碼使用這個函數。在 myModule.js 旁邊創建一個 main.js,代碼如下:

// main.js  const myModule = require('./myModule');    // 調用 myModule.js 中的 add 函數  myModule.add(1, 2);

提示

如果你熟悉 ECMAScript 6 中的解構賦值[7],那麼可以用更優雅的方式獲取 add 函數:

const { add } = require('./myModule');

module

通過 requireexports,我們已經知道了如何導入、導出 Node 模塊中的內容,但是你可能還是覺得 Node 模塊機制有一絲絲神秘的感覺。接下來,我們將掀開這神秘的面紗,了解一下背後的主角——module 模塊對象。

我們可以在剛才的 myModule.js 文件的最後加上這一行代碼:

console.log('module myModule:', module);

在 main.js 最後加上:

console.log('module main:', module);

運行後會打印出來這樣的內容(左邊是 myModule,右邊是 module):

可以看到 module 對象有以下字段:

id:模塊的唯一標識符,如果是被運行的主程序(例如 main.js)則為 .,如果是被導入的模塊(例如 myModule.js)則等同於此文件名(即下面的 filename 字段)•pathfilename:模塊所在路徑和文件名,沒啥好說的•exports:模塊所導出的內容,實際上之前的 exports 對象是指向 module.exports 的引用。例如對於 myModule.js,剛才我們導出了 add 函數,因此出現在了這個 exports 字段裏面;而 main.js 沒有導出任何內容,因此 exports 字段為空•parentchildren:用於記錄模塊之間的導入關係,例如 main.js 中 require 了 myModule.js,那麼 main 就是 myModule 的 parent,myModule 就是 main 的 childrenloaded:模塊是否被加載,從上圖中可以看出只有 children 中列出的模塊才會被加載•paths:這個就是 Node 搜索文件模塊的路徑列表,Node 會從第一個路徑到最後一個路徑依次搜索指定的 Node 模塊,找到了則導入,找不到就會報錯

提示 如果你仔細觀察,會發現 Node 文件模塊查找路徑(module.paths)的方式其實是這樣的:先找當前目錄下的 node_modules,沒有的話再找上一級目錄的 node_modules,還沒找到的話就一直向上找,直到根目錄下的 node_modules。

深入理解 module.exports

之前我們提到,exports 對象本質上是 module.exports 的引用。也就是說,下面兩行代碼是等價的:

// 導出 add 函數  exports.add = add;    // 和上面一行代碼是一樣的  module.exports.add = add;

實際上還有第二種導出方式,直接把 add 函數賦給 module.exports 對象:

module.exports = add;

這樣寫和第一種導出方式有什麼區別呢?第一種方式,在 exports 對象上添加一個屬性名為 add,該屬性的值為 add 函數;第二種方式,直接令 exports 對象為 add 函數。可能有點繞,但是請一定要理解這兩者的重大區別!

require 時,兩者的區別就很明顯了:

// 第一種導出方式,需要訪問 add 屬性獲取到 add 函數  const myModule = require('myModule');  myModule.add(1, 2);    // 第二種導出方式,可以直接使用 add 函數  const add = require('myModule');  add(1, 2);

警告

直接寫 exports = add; 無法導出 add 函數,因為 exports 本質上是指向 moduleexports 屬性的引用,直接對 exports 賦值只會改變 exports,對 module.exports 沒有影響。如果你覺得難以理解,那我們用 appleprice 類比 moduleexports

apple = { price: 1 };   // 想像 apple 就是 module  price = apple.price;    // 想像 price 就是 exports  apple.price = 3;        // 改變了 apple.price  price = 3;              // 只改變了 price,沒有改變 apple.price

我們只能通過 apple.price = 1 設置 price 屬性,而直接對 price 賦值並不能修改 apple.price

重構 timer 腳本

在聊了這麼多關於 Node 模塊機制的內容後,是時候回到我們之前的定時器腳本 timer.js 了。我們首先創建一個新的 Node 模塊 info.js,用於打印系統信息,代碼如下:

const os = require('os');    function printProgramInfo() {    console.log('當前用戶', os.userInfo().username);    console.log('當前進程 ID', process.pid);    console.log('當前腳本路徑', __filename);  }    module.exports = printProgramInfo;

這裡我們導入了 Node 內置模塊 os,並通過 os.userInfo() 查詢到了系統用戶名,接着通過 module.exports 導出了 printProgramInfo 函數。

然後創建第二個 Node 模塊 datetime.js,用於返回當前的時間,代碼如下:

function getCurrentTime() {    const time = new Date();    return time.toLocaleString();  }    exports.getCurrentTime = getCurrentTime;

上面的模塊中,我們選擇了通過 exports 導出 getCurrentTime 函數。

最後,我們在 timer.js 中通過 require 導入剛才兩個模塊,並分別調用模塊中的函數 printProgramInfogetCurrentTime,代碼如下:

const printProgramInfo = require('./info');  const datetime = require('./datetime');    setTimeout(() => {    console.log('Hello World!');  }, 3000);    printProgramInfo();  console.log('當前時間', datetime.getCurrentTime());

再運行一下 timer.js,輸出內容應該與之前完全一致。

讀到這裡,我想先恭喜你渡過了 Node.js 入門最難的一關!如果你已經真正地理解了 Node 模塊機制,那麼我相信接下來的學習會無比輕鬆哦。

命令行開發:接受輸入參數

Node.js 作為可以在操作系統中直接運行 JavaScript 代碼的平台,為前端開發者開啟了無限可能,其中就包括一系列用於實現前端自動化工作流的命令行工具,例如 Grunt[8]、Gulp[9] 還有大名鼎鼎的 Webpack[10]。

從這一步開始,我們將把 timer.js 改造成一個命令行應用。具體地,我們希望 timer.js 可以通過命令行參數指定等待的時間(time 選項)和最終輸出的信息(message 選項):

$ node timer.js --time 5 --message "Hello Tuture"

通過 process.argv 讀取命令行參數

之前在講全局對象 process 時提到一個 argv 屬性,能夠獲取命令行參數的數組。創建一個 args.js 文件,代碼如下:

console.log(process.argv);

然後運行以下命令:

$ node args.js --time 5 --message "Hello Tuture"

輸出一個數組:

[    '/Users/mRc/.nvm/versions/node/v12.10.0/bin/node',    '/Users/mRc/Tutorials/nodejs-quickstart/args.js',    '--time',    '5',    '--message',    'Hello Tuture'  ]

可以看到,process.argv 數組的第 0 個元素是 node 的實際路徑,第 1 個元素是 args.js 的路徑,後面則是輸入的所有參數。

實現命令行應用

根據剛才的分析,我們可以非常簡單粗暴地獲取 process.argv 的第 3 個和第 5 個元素,分別可以得到 timemessage 參數。於是修改 timer.js 的代碼如下:

const printProgramInfo = require('./info');  const datetime = require('./datetime');    const waitTime = Number(process.argv[3]);  const message = process.argv[5];    setTimeout(() => {    console.log(message);  }, waitTime * 1000);    printProgramInfo();  console.log('當前時間', datetime.getCurrentTime());

提醒一下,setTimeout 中時間的單位是毫秒,而我們指定的時間參數單位是秒,因此要乘 1000。

運行 timer.js,加上剛才說的所有參數:

$ node timer.js --time 5 --message "Hello Tuture"

等待 5 秒鐘後,你就看到了 Hello Tuture 的提示文本!

不過很顯然,目前這個版本有很大的問題:輸入參數的格式是固定的,很不靈活,比如說調換 timemessage 的輸入順序就會出錯,也不能檢查用戶是否輸入了指定的參數,格式是否正確等等。如果要親自實現上面所說的功能,那可得花很大的力氣,說不定還會有不少 Bug。有沒有更好的方案呢?

npm:洪荒之力,都賜予你

從這一節開始,你將不再是一個人寫代碼。你的背後將擁有百萬名 JavaScript 開發者的支持,而這一切僅需要 npm 就可以實現。npm 包括:

•npm 命令行工具(安裝 node 時也會附帶安裝)•npm 集中式依賴倉庫(registry),存放了其他 JavaScript 開發者分享的 npm 包•npm 網站[11],可以搜索需要的 npm 包、管理 npm 帳戶等

npm 初探

我們首先打開終端(命令行),檢查一下 npm 命令是否可用:

$ npm -v  6.10.3

然後在當前目錄(也就是剛才編輯的 timer.js 所在的文件夾)運行以下命令,把當前項目初始化為 npm 項目:

$ npm init

這時候 npm 會提一系列問題,你可以一路回車下去,也可以仔細回答,最終會創建一個 package.json 文件。package.json 文件是一個 npm 項目的核心,記錄了這個項目所有的關鍵信息,內容如下:

{    "name": "timer",    "version": "1.0.0",    "description": "A cool timer",    "main": "timer.js",    "scripts": {      "test": "echo "Error: no test specified" && exit 1"    },    "repository": {      "type": "git",      "url": "git+https://github.com/mRcfps/nodejs-quickstart.git"    },    "author": "mRcfps",    "license": "ISC",    "bugs": {      "url": "https://github.com/mRcfps/nodejs-quickstart/issues"    },    "homepage": "https://github.com/mRcfps/nodejs-quickstart#readme"  }  

其中大部分字段的含義都很明確,例如 name 項目名稱、 version 版本號、description 描述、author 作者等等。不過這個 scripts 字段你可能會比較困惑,我們會在下一節中詳細介紹。

安裝 npm 包

接下來我們將講解 npm 最最最常用的命令—— install。沒錯,毫不誇張地說,一個 JavaScript 程序員用的最多的 npm 命令就是 npm install

在安裝我們需要的 npm 包之前,我們需要去探索一下有哪些包可以為我們所用。通常,我們可以在 npm 官方網站[12] 上進行關鍵詞搜索(記得用英文哦),比如說我們搜 command line:

出來的第一個結果 commander 就很符合我們的需要,點進去就是安裝的說明和使用文檔[13]。我們還想要一個「加載中」的動畫效果,提高用戶的使用體驗,試着搜一下 loading 關鍵詞:

第二個結果 ora 也符合我們的需要。那我們現在就安裝這兩個 npm 包:

$ npm install commander ora

少許等待後,可以看到 package.json 多了一個非常重要的 dependencies 字段:

"dependencies": {    "commander": "^4.0.1",    "ora": "^4.0.3"  }

這個字段中就記錄了我們這個項目的直接依賴。與直接依賴相對的就是間接依賴,例如 commander 和 ora 的依賴,我們通常不用關心。所有的 npm 包(直接依賴和間接依賴)全部都存放在項目的 node_modules 目錄中。

提示 node_modules 通常有很多的文件,因此不會加入到 Git 版本控制系統中,你從網上下載的 npm 項目一般也只會有 package.json,這時候只需運行 npm install(後面不跟任何內容),就可以下載並安裝所有依賴了。

整個 package.json 代碼如下所示:

{    "name": "timer",    "version": "1.0.0",    "description": "A cool timer",    "main": "timer.js",    "scripts": {      "test": "echo "Error: no test specified" && exit 1"    },    "repository": {      "type": "git",      "url": "git+https://github.com/mRcfps/nodejs-quickstart.git"    },    "author": "mRcfps",    "license": "ISC",    "bugs": {      "url": "https://github.com/mRcfps/nodejs-quickstart/issues"    },    "homepage": "https://github.com/mRcfps/nodejs-quickstart#readme",    "dependencies": {      "commander": "^4.0.1",      "ora": "^4.0.3"    }  }

關於版本號

在軟件開發中,版本號是一個非常重要的概念,不同版本的軟件存在或大或小的差異。npm 採用了語義版本號(Semantic Versioning,簡稱 semver[14]),具體規定如下:

•版本格式為:主版本號.次版本號.修訂號•主版本號的改變意味着不兼容的 API 修改•次版本號的改變意味着做了向下兼容的功能性新增•修訂號的改變意味着做了向下兼容的問題修正

提示 向下兼容的簡單理解就是功能只增不減

因此在 package.json 的 dependencies 字段中,可以通過以下方式指定版本:

精確版本:例如 1.0.0,一定只會安裝版本為 1.0.0 的依賴•鎖定主版本和次版本:可以寫成 1.01.0.x~1.0.0npm install 默認採用的形式),那麼可能會安裝例如 1.0.8 的依賴•僅鎖定主版本:可以寫成 11.x1^1.0.0,那麼可能會安裝例如 1.1.0 的依賴•最新版本:可以寫成 *x,那麼直接安裝最新版本(不推薦)

你也許注意到了 npm 還創建了一個 package-lock.json,這個文件就是用來鎖定全部直接依賴和間接依賴的精確版本號,或者說提供了關於 node_modules 目錄的精確描述,從而確保在這個項目中開發的所有人都能有完全一致的 npm 依賴。

站在巨人的肩膀上

我們在大致讀了一下 commander 和 ora 的文檔之後,就可以開始用起來了,修改 timer.js 代碼如下:

const program = require('commander');  const ora = require('ora');  const printProgramInfo = require('./info');  const datetime = require('./datetime');    program    .option('-t, --time <number>', '等待時間 (秒)', 3)    .option('-m, --message <string>', '要輸出的信息', 'Hello World')    .parse(process.argv);    setTimeout(() => {    spinner.stop();    console.log(program.message);  }, program.time * 1000);    printProgramInfo();  console.log('當前時間', datetime.getCurrentTime());  const spinner = ora('正在加載中,請稍後 ...').start();

這次,我們再次運行 timer.js:

$ node timer.js --message "洪荒之力!" --time 5

轉起來了!

嘗鮮 npm scripts

在本教程的最後一節中,我們將簡單地介紹一下 npm scripts,也就是 npm 腳本。之前在 package.json 中提到,有個字段叫 scripts,這個字段就定義了全部的 npm scripts。我們發現在用 npm init 時創建的 package.json 文件默認就添加了一個 test 腳本:

"test": "echo "Error: no test specified" && exit 1"

那一串命令就是 test 腳本將要執行的內容,我們可以通過 npm test 命令執行該腳本:

$ npm test    > [email protected] test /Users/mRc/Tutorials/nodejs-quickstart  > echo "Error: no test specified" && exit 1    Error: no test specified  npm ERR! Test failed.  See above for more details.

在初步體驗了 npm scripts 之後,我們有必要了解一下 npm scripts 分為兩大類:

預定義腳本:例如 teststartinstallpublish 等等,直接通過 npm <scriptName> 運行,例如 npm test,所有預定義的腳本可查看文檔[15]•自定義腳本:除了以上自帶腳本的其他腳本,需要通過 npm run <scriptName> 運行,例如 npm run custom

現在就讓我們開始為 timer 項目添加兩個 npm scripts,分別是 startlint。第一個是預定義的,用於啟動我們的 timer.js;第二個是靜態代碼檢查,用於在開發時檢查我們的代碼。首先安裝 ESLint[16] npm 包:

$ npm install eslint --save-dev  $ # 或者  $ npm install eslint -D

注意到我們加了一個 -D--save-dev 選項,代表 eslint 是一個開發依賴,在實際項目發佈或部署時不需要用到。npm 會把所有開發依賴添加到 devDependencies 字段中。然後分別添加 startlint 腳本,代碼如下:

{    "name": "timer",    "version": "1.0.0",    "description": "A cool timer",    "main": "timer.js",    "scripts": {      "lint": "eslint **/*.js",      "start": "node timer.js -m '上手了' -t 3",      "test": "echo "Error: no test specified" && exit 1"    },    "repository": {      "type": "git",      "url": "git+https://github.com/mRcfps/nodejs-quickstart.git"    },    "author": "mRcfps",    "license": "ISC",    "bugs": {      "url": "https://github.com/mRcfps/nodejs-quickstart/issues"    },    "homepage": "https://github.com/mRcfps/nodejs-quickstart#readme",    "dependencies": {      "commander": "^4.0.1",      "ora": "^4.0.3"    },    "devDependencies": {      "eslint": "^6.7.2"    }  }

ESLint 的使用需要一個配置文件,創建 .eslintrc.js 文件(注意最前面有一個點),代碼如下:

module.exports = {      "env": {          "es6": true,          "node": true,      },      "extends": "eslint:recommended",  };

運行 npm start,可以看到成功地運行了我們的 timer.js 腳本;而運行 npm run lint,沒有輸出任何結果(代表靜態檢查通過)。

npm scripts 看上去平淡無奇,但是卻能為項目開發提供非常便利的工作流。例如,之前構建一個項目需要非常複雜的命令,但是如果你實現了一個 build npm 腳本,那麼當你的同事拿到這份代碼時,只需簡單地執行 npm run build 就可以開始構建,而無需關心背後的技術細節。在後續的 Node.js 或是前端學習中,我們會在實際項目中使用各種 npm scripts 來定義我們的工作流,大家慢慢就會領會到它的強大了。

下次再見:監聽 exit 事件

在這篇教程的最後一節中,我們將讓你簡單地感受 Node 的事件機制。Node 的事件機制是比較複雜的,足夠講半本書,但這篇教程希望能通過一個非常簡單的實例,讓你對 Node 事件有個初步的了解。

提示 如果你有過在網頁(或其他用戶界面)開發中編寫事件處理(例如鼠標點擊)的經驗,那麼你一定會覺得 Node 中處理事件的方式似曾相識而又符合直覺。

我們在前面簡單地提了一下回調函數。實際上,回調函數和事件機制共同組成了 Node 的異步世界。具體而言,Node 中的事件都是通過 events 核心模塊中的 EventEmitter 這個類實現的。EventEmitter 包括兩個最關鍵的方法:

on:用來監聽事件的發生•emit:用來觸發新的事件

請看下面這個代碼片段:

const EventEmitter = require('events').EventEmitter;  const emitter = new EventEmitter();    // 監聽 connect 事件,註冊回調函數  emitter.on('connect', function (username) {    console.log(username + '已連接');  });    // 觸發 connect 事件,並且加上一個參數(即上面的 username)  emitter.emit('connect', '一隻圖雀');

運行上面的代碼,就會輸出以下內容:

一隻圖雀已連接

可以說,Node 中很多對象都繼承自 EventEmitter,包括我們熟悉的 process 全局對象。在之前的 timer.js 腳本中,我們監聽 exit 事件(即 Node 進程結束),並添加一個自定義的回調函數打印「下次再見」的信息:

const program = require('commander');  const ora = require('ora');  const printProgramInfo = require('./info');  const datetime = require('./datetime');    program    .option('-t, --time <number>', '等待時間 (秒)', 3)    .option('-m, --message <string>', '要輸出的信息', 'Hello World')    .parse(process.argv);    setTimeout(() => {    spinner.stop();    console.log(program.message);  }, program.time * 1000);    process.on('exit', () => {    console.log('下次再見~');  });    printProgramInfo();  console.log('當前時間', datetime.getCurrentTime());  const spinner = ora('正在加載中,請稍後 ...').start();

運行後,會在程序退出後打印「下次再見~」的字符串。你可能會問,為啥不能在 setTimeout 的回調函數中添加程序退出的邏輯呢?因為除了正常運行結束(也就是等待了指定的時間),我們的程序很有可能會因為其他原因退出(例如拋出異常,或者用 process.exit 強制退出),這時候通過監聽 exit 事件,就可以在確保所有情況下都能執行 exit 事件的回調函數。如果你覺得還是不能理解的話,可以看下面這張示意圖:

提示 process 對象還支持其他常用的事件,例如 SIGINT(用戶按 Ctrl+C 時觸發)等等,可參考這篇文檔[17]。

這篇 Node.js 快速入門教程到這裡就結束了,希望能夠成為你進一步探索 Node.js 或是前端開發的基石。exit 事件已經觸發,那我們也下次再見啦~

想要學習更多精彩的實戰技術教程?來圖雀社區[18]逛逛吧。

References

[1] Node.js 後端工程師學習路線: https://github.com/tuture-dev/nodejs-roadmap [2] 《JavaScript語言的歷史》: http://javascript.ruanyifeng.com/introduction/history.html [3] AMD(Asynchronous Module Definition)規範: https://github.com/amdjs/amdjs-api/blob/master/AMD.md [4] RequireJS: https://requirejs.org/ [5] CommonJS 規範: http://wiki.commonjs.org/wiki/CommonJS [6] 這篇文章: https://zhuanlan.zhihu.com/p/36358695 [7] 解構賦值: http://es6.ruanyifeng.com/#docs/destructuring [8] Grunt: http://gruntjs.com/ [9] Gulp: https://gulpjs.com/ [10] Webpack: https://webpack.js.org/ [11] 網站: https://npmjs.com [12] npm 官方網站: https://npmjs.com [13] 使用文檔: https://github.com/tj/commander.js/blob/HEAD/Readme_zh-CN.md [14] semver: https://semver.org/lang/zh-CN/ [15] 文檔: https://docs.npmjs.com/misc/scripts#description [16] ESLint: http://eslint.cn/ [17] 文檔: https://javascript.ruanyifeng.com/nodejs/process.html#toc10 [18] 圖雀社區: https://tuture.co/?utm_source=juejin_zhuanlan