衆成翻譯javascript
原文連接 html
關於做者前端
2018年6月21日出版
java
本指南面向瞭解Javascript但還沒有十分熟悉Node.js的前端開發人員。我這裏不專一於語言自己 -- Node.js 使用 V8 引擎,因此和Google Chrome的解釋器是同樣的,這點您或許已經瞭解(可是,它也能夠在不一樣的VM上運行,請參閱 node-chakracore)
咱們常常跟Node.js打交道,即便你是一名前端開發人員 -- npm腳本,webpack配置,gulp任務,程序打包 或 運行測試等。即便你真的不須要深刻理解這些任務,但有時候你會感到困惑,會由於缺乏Node.js的一些核心概念而以很是奇怪的方式來編碼。熟悉Node.js以後,您還可讓某些本來須要手動操做的東西自動執行,讓您能夠更自信地查看服務器端代碼,並編寫更復雜的腳本。
node
Node.js與客戶端代碼最大的區別在於您能夠根據運行環境來決定,而且能夠徹底清楚它支持哪些特性 -- 您能夠根據具體的需求和可用的服務器來選擇使用哪一個版本。webpack
Node.js有一個公開發布時間表,告訴咱們奇數版本沒有被長期支持。當前的LTS(long-term support)版本將被積極開發到2019年4月,而後2019年12月31日以前,經過更新關鍵代碼進行維護。Node.js新版本正在積極開發,它們帶來了許多新功能,以及安全性和性能方面的提高。這也許是使用當前活躍版本的一個好理由。然而,沒有人真正強迫你,若是你不想這樣作,使用舊版本也能夠,等到您以爲時機合適再更新就行。git
Node.js被普遍應用於現代前端工具鏈 - 咱們很難想象一個現代項目沒有使用Node工具進行任何處理。所以,您可能已經熟悉nvm(node版本管理器),它容許你同時安裝幾個Node版本,爲每一個項目選擇正確的版本。使用這種工具的緣由在於,不一樣項目常用不一樣的Node版本,而且你不想永遠保持它們同步,您只想保留編寫和測試它們的環境。其它語言也有不少這樣的工具,例如用於Python的virtualenv,用於Ruby的rbenv等等。github
因爲您能夠自由選擇任何Node.js版本,因此您頗有可能使用LTS版本。該版本在本文撰寫時爲8.11.3,幾乎支持全部ECMAScript 2015的規範,除了尾遞歸。web
這意味着咱們不須要Babel,除非您遇到一個很是舊的Node.js版本,須要轉換JSX,或者須要其它前沿的轉換器。在實踐中,Babel並非那麼重要,因此您運行的代碼能夠和編寫的代碼相同,不須要任何編譯器 -- 這個咱們已經遺忘的客戶端天才。shell
咱們也不須要webpack或browserify,那麼咱們就沒有工具來從新加載咱們的代碼 -- 若是您在開發相似Web服務器的東西,您可使用nodemon,在文件更改後來從新加載您的應用程序。
並且由於咱們不在任何地方傳送代碼,因此不須要縮小它 -- 省了一步:您只需原封不動地使用代碼,真的很神奇!
之前,Node.js中的異步函數接受帶有簽名(err,data)
的回調,其中第一個參數表明錯誤信息 - 若是它爲null,則所有正確,不然您必須處理錯誤。這些處理程序會在操做完成,咱們獲得響應後調用。例如,讓咱們讀取一個文件:
const fs = require('fs'); fs.readFile('myFile.js', (err, file) => { if (err) { console.error('There was an error reading file :('); // process is a global object in Node // https://nodejs.org/api/process.html#process_process_exit_code process.exit(1); } // do something with file content });
咱們很快就發現,這種風格很難編寫可讀和可維護的代碼,甚至形成回調地獄。後來,一種新的原生的異步處理方式 Promise
被引入了。它在ECMAScript 2015上標準化(是瀏覽器和Node.js運行時的全局對象)。近來,async / await
在ECMAScript 2017中標準化了,Node.js 7.6+ 都支持這個規範,因此您能夠在LTS版本中使用它。
有了 Promise
,咱們避免了「回調地獄」。可是,如今咱們遇到的問題是舊代碼和許多內置模塊仍然使用回調的方式。將它們轉換爲 Promise
並非很難 -- 爲了闡釋清楚,咱們將fs.readFile轉成Promise
:
const fs = require('fs'); function readFile(...arguments) { return new Promise((resolve, reject) => { fs.readFile(...arguments, (err, data) => { if (err) { reject(err); } else { resolve(data); } }); }); }
這種模式能夠很容易地擴展到任何函數,而且內置的utils模塊中有一個特殊的函數 - utils.promisify
。官方文檔中的示例:
const util = require('util'); const fs = require('fs'); const stat = util.promisify(fs.stat); stat('.').then((stats) => { // Do something with stats }).catch((error) => { // Handle the error. });
Node.js核心團隊明白咱們須要從舊風格中遷移出來,他們嘗試引入一個內置模塊的promisified
版本 - 已經有promisified
文件系統模塊了,雖然寫這篇文章時它還在處於試驗階段。
你仍然會遇到不少舊式的、帶回調的Node.js代碼,爲了保持一致性,建議使用 utils.promisify
把它們包裝一下。
事件循環幾乎與在瀏覽器環境下同樣,只是有一些擴展。然而,因爲這個主題比較高深,我將全面講解下,不只僅是差別(我會重點強調這部分,讓您知道哪些是Node.js特有的)。
JavaScript在構建時考慮了異步行爲,所以咱們一般不會立刻執行全部操做。如下列舉的方法,事件不會直接按順序執行:
microtasks
例如,當即處理Promises,如Promise.resolve。它意味着這段代碼會在同一個的事件循環中被執行,但得等到全部同步代碼執行完後。
process.nextTick
這是Node.js特有的方法,它不存在於任何瀏覽器(以及進程對象)中。它的行爲相似於微任務(microtask),但具備優先級。這意味着它將在全部同步代碼以後當即執行,即便以前引入了其餘微任務 - 這是很危險的,可能致使無限循環。從命名上講是不對的,由於它是在同一個事件循環中執行的,而不是在它的next tick
中執行。可是因爲兼容性緣由,它可能保持不變。
setImmediate
雖然它確實存在於某些瀏覽器中,但並未在全部瀏覽器中達到一致的行爲,所以在瀏覽器中使用時,您須要很是當心。它相似於 setTimeout(0)
代碼,但有時會優先於它。這裏的命名也不是最好的 - 咱們在談論下一個事件循環迭代,它並非真正的immidiate
。
setTimeout/setInterval
定時器在Node和瀏覽器中的表現形式是相同的。關於定時器的一個重要的事情是,咱們提供的延遲不表明在這個時間以後回調就會被執行。它的真正含義是,一旦主線程完成全部操做(包括微任務)而且沒有其它具備更高優先級的定時器,Node.js將在此時間以後執行回調。
讓咱們看看這個例子:
往下看我會給出腳本執行後正確的輸出,可是若是你願意,請嘗試本身完成它(當一回「JavaScript解釋器」):
const fs = require('fs'); console.log('beginning of the program'); const promise = new Promise(resolve => { // function, passed to the Promise constructor // is executed synchronously! console.log('I am in the promise function!'); resolve('resolved message'); }); promise.then(() => { console.log('I am in the first resolved promise'); }).then(() => { console.log('I am in the second resolved promise'); }); process.nextTick(() => { console.log('I am in the process next tick now'); }); fs.readFile('index.html', () => { console.log('=================='); setTimeout(() => { console.log('I am in the callback from setTimeout with 0ms delay'); }, 0); setImmediate(() => { console.log('I am from setImmediate callback'); }); }); setTimeout(() => { console.log('I am in the callback from setTimeout with 0ms delay'); }, 0); setImmediate(() => { console.log('I am from setImmediate callback'); });
正確的執行順序以下:
node event-loop.js beginning of the program I am in the promise function! I am in the process next tick now I am in the first resolved promise I am in the second resolved promise I am in the callback from setTimeout with 0ms delay I am from setImmediate callback ================== I am from setImmediate callback I am in the callback from setTimeout with 0ms delay
您能夠在Node.js官方文檔中獲取更多有關事件循環和process.nextTick的信息。
Node.js中的許多核心模塊派發或接收不一樣的事件。它有一個EventEmitter的實現,是一個發佈 - 訂閱模式。這與瀏覽器DOM事件很是類似,語法略有不一樣,理解它最好的方式就是親自來實現一下:
class EventEmitter { constructor() { this.events = {}; } checkExistence(event) { if (!this.events[event]) { this.events[event] = []; } } once(event, cb) { this.checkExistence(event); const cbWithRemove = (...args) => { cb(...args); this.off(event, cbWithRemove); }; this.events[event].push(cbWithRemove); } on(event, cb) { this.checkExistence(event); this.events[event].push(cb); } off(event, cb) { this.checkExistence(event); this.events[event] = this.events[event].filter( registeredCallback => registeredCallback !== cb ); } emit(event, ...args) { this.checkExistence(event); this.events[event].forEach(cb => cb(...args)); } }
以上代碼只顯示模式自己,並無針對確切的功能 - 請不要在您的代碼中使用它!
這是咱們須要的全部基礎代碼!它容許您訂閱事件,稍後取消訂閱,並派發不一樣的事件。例如,響應體,請求體,流 - 它們實際上都擴展或實現了EventEmitter!
正由於它是一個如此簡單的概念,因此被用於許多的NPM包。因此,若是你想在瀏覽器中使用相同的事件發射器,能夠隨時使用它們。
「Streams是Node.js最好用、最容易被誤解的概念。」
多米尼克塔爾(Dominic Tarr)
Streams容許您以塊的形式來處理數據,而不只僅是完整操做(如讀取文件)。爲了理解它們的做用,讓咱們來看個簡單的例子:假設咱們想要向用戶返回任意大小的請求文件。咱們的代碼可能以下所示:
function (req, res) { const filename = req.url.slice(1); fs.readFile(filename, (err, data) => { if (err) { res.statusCode = 500; res.end('Something went wrong'); } else { res.end(data); } }); }
這段代碼可使用,特別是在本地開發的機器上,但它可也能會失敗 - 您看出問題了嗎?若是文件太大,咱們讀取文件時就會遇到問題,咱們將全部內容放入內存中,若是沒有足夠的內存空間,這將沒法正常工做。若是咱們有不少併發請求,這段代碼也不會生效 - 咱們必須將數據對象保留在內存中,直到咱們發送了全部內容。
然而,咱們根本不須要這個文件 - 咱們只須要從文件系統返回它,咱們本身不會查看內容,因此咱們能夠讀取它的一部分,當即返回給客戶端來釋放咱們的內存,重複這樣一個過程,直到咱們完成了整個文件的發送。這是對 Streams
的簡短介紹 - 咱們有一種以塊的形式來接收數據的機制,而且 咱們 決定如何處理這些數據。例如,咱們一樣能夠這樣處理:
function (req, res) { const filename = req.url.slice(1); const filestream = fs.createReadStream(filename, { encoding: 'utf-8' }); let result = ''; filestream.on('data', chunk => { result += chunk; }); filestream.on('end', () => { res.end(result); }); // if file does not exist, error callback will be called filestream.on('error', () => { res.statusCode = 500; res.end('Something went wrong'); }); }
這裏咱們建立一個 流
來讀取文件 - 這個流執行EventEmitter這個類,在data
事件上咱們接收下一個塊,在end
事件中,咱們獲得一個信號,表示流已結束,而後讀取完整文件。這樣的實現跟前面的同樣 - 咱們等待整個文件被讀取,而後在響應中返回它。此外,它也有一樣的問題:咱們將整個文件保留在內存中,而後再發送回來。若是咱們知道響應對象自己實現了可寫流,咱們能夠解決這個問題,咱們能夠將信息寫入該流而不將其保留在內存中:
function (req, res) { const filename = req.uårl.slice(1); const filestream = fs.createReadStream(filename, { encoding: 'utf-8' }); filestream.on('data', chunk => { res.write(chunk); }); filestream.on('end', () => { res.end(); }); // if file does not exist, error callback will be called filestream.on('error', () => { res.statusCode = 500; res.end('Something went wrong'); }); }
響應體實現可寫流,
fs.createReadStream
建立可讀流,還有雙向和轉換流。它們之間的區別以及工做原理,不在本教程的範圍內,可是瞭解它們的存在仍是大有裨益的。
這樣咱們再也不須要結果變量了,只須要把已讀的 塊
當即寫入響應體,不將它保留在內存中!這意味着咱們甚至能夠讀取大文件,而沒必要擔憂高併發請求 - 由於文件沒有被保存在內存中,因此不會超出內存所能承載的數量。可是,存在一個問題。在咱們的解決方案中,咱們從一個流(文件系統讀取文件)中讀取文件,並將其寫入另外一個(網絡請求),這兩個事物具備不一樣的延遲。這裏強調是真的不一樣,通過一段時間後,咱們的響應流將不堪重負,由於它要慢得多。這個問題是對背壓的描述,Node有一個解決方案:每一個可讀流都有一個管道方法,它將全部數據重定向到與其負載相關的給定流中:若是它正忙,它將暫停原始流並恢復它。使用此方法,咱們能夠將代碼簡化爲:
function (req, res) { const filename = req.url.slice(1); const filestream = fs.createReadStream(filename, { encoding: 'utf-8' }); filestream.pipe(res); // if file does not exist, error callback will be called filestream.on('error', () => { res.statusCode = 500; res.end('Something went wrong'); }); }
在Node的歷史進程中,Streams改變了幾回,因此在閱讀舊手冊時要格外當心,並常常查看官方文檔!
Node.js使用commonjs模塊。您或許使用過 - 每次使用require來獲取webpack配置中的某個模塊時,您實際上就使用了commonjs模塊; 每次聲明 module.exports
時也在使用它。然而,您可能還會看到像 exports.some = {}
這樣的寫法,沒有 module
,在這一節中咱們將看下它到底是如何工做的。
首先,咱們來討論commonjs模塊,它們一般都有 .js
的擴展,而不是 .esm / .mjs
文件(ECMAScript模塊),它們容許您使用 import/export
的語法。另外,重要的是要明白,webpack和browserify(以及其它打包工具)使用本身的require
函數,因此請不要混淆 - 這裏不講解它們,只要明白它們是不一樣的東西就行(即便它們表現得很是類似)。
那麼,咱們其實是在哪裏得到這些「全局」對象,如 module
,requier
和 exports
?實際上,是Node.js在運行時添加的 - 它不是僅執行給定的javascript文件,其實是將它包含在具備全部這些變量的函數中:
function (exports, require, module, __filename, __dirname) { // your module }
您能夠在命令行中執行如下代碼段來查看這個包:
1node -e "console.log(require('module').wrapper)"
這些是注入到模塊中的變量,能夠做爲「全局」變量使用,即便它們不是真正的全局變量。我強烈建議你研究它們,尤爲是模塊變量。你能夠在javascript文件中調用 console.log(module)
,對比從 main
文件打印和從 required
的文件打印出來的結果。
接下來,讓咱們看一下 exports
對象 - 這裏有一個小例子,顯示一些與之相關的警告:
exports.name = 'our name'; // this works exports = { name: 'our name' }; // this doesn't work module.exports = { name: 'our name' }; // this works!
上面的例子可能會讓你感到困惑 爲何會這樣?答案是exports
對象的本質 - 它只是一個傳遞給函數的參數,因此在咱們給它指定一個新對象的狀況時,咱們只是重寫這個變量,舊的引用就不存在了。儘管它沒有徹底消失 - module.exports
是同一個對象 - 因此它們其實是對單個對象的相同引用:
module.exports === exports; // true
最後一部分是 require
- 它是一個獲取模塊名稱並返回該模塊的 exports對象
的函數。它到底是如何解析模塊的?有一個很是簡單的規則:
./
或 ../
開頭,則嘗試解析文件index.js
文件的目錄若是path
不以 ./
或 ../
開頭,請轉到node_modules /
並檢查文件夾/文件:
/ node_modules
還有其它一些位置,主要是爲了兼容性,您還能夠經過指定變量 NODE_PATH
來提供查找路徑,這也許頗有用。若是您要查看解析node_modules
的確切順序,只需在腳本中打印模塊對象並查找paths
屬性。我操做後,打印了以下內容:
➜ tmp node test.js Module { id: '.', exports: {}, parent: null, filename: '/Users/seva.zaikov/tmp/test.js', loaded: false, children: [], paths: [ '/Users/seva.zaikov/tmp/node_modules', '/Users/seva.zaikov/node_modules', '/Users/node_modules', '/node_modules' ] }
關於 require
的另外一個有趣的事情是,在第一個require調用模塊被緩存後,將不會再次執行,咱們將只返回緩存的export對象 - 這意味着你能夠作一些邏輯並確保它會在第一次require調用以後只執行一次(這不徹底正確 - 若是再次須要,你能夠從require.cache
中刪除模塊id ,而後從新加載模塊)
正如在十二因素應用程序
所述,將配置存儲在環境變量中是一種很好的作法。您能夠爲shell會話設置變量:
export MY_VARIABLE="some variable value"
Node是一個跨平臺引擎,理想狀況下,您的應用程序應該能夠在任何平臺上運行(例如,開發環境。您選擇生產環境來運行您的代碼,一般它是一些Linux分發版)。個人示例僅涵蓋MacOS / Linux,不適用於Windows。Windows中環境變量的語法跟這裏的不一樣,你可使用像cross-env這樣的東西,但在其它狀況下,你也應該記住這點。
您能夠把下面這行代碼添加到 bash / zsh
配置文件中,以便在任何新的終端會話中進行設置。然而,您一般只在運行應用程序時,爲這些實例提供特有的變量:
APP_DB_URI="....." SECRET_KEY="secret key value" node server.js
您可使用 process.env
對象來訪問 Node.js 應用程序中的這些變量:
const CONFIG = { db: process.env.APP_DB_URI, secret: process.env.SECRET_KEY }
在下面的例子中,咱們將建立一個簡單的http服務,它將返回一個文件,以url/
後面的字符串來命名。若是文件不存在,咱們將返回 404 Not Found
的錯誤信息,若是用戶試圖投機取巧,使用相對路徑或嵌套路徑,咱們則返回403錯誤。咱們以前使用過其中的一些函數,但沒有真正記錄它們 - 此次它將包含大量的信息:
// we require only built-in modules, so Node.js // does not traverse our node_modules folders // https://nodejs.org/api/http.html#http_http_createserver_options_requestlistener const { createServer } = require("http"); const fs = require("fs"); const url = require("url"); const path = require("path"); // we pass the folder name with files as an environment variable // so we can use a different folder locally const FOLDER_NAME = process.env.FOLDER_NAME; const PORT = process.env.PORT || 8080; const server = createServer((req, res) => { // req.url contains full url, with querystring // we ignored it before, but here we want to ensure // that we only get pathname, without querystring // https://nodejs.org/api/http.html#http_message_url const parsedURL = url.parse(req.url); // we don't need the first / symbol const pathname = parsedURL.pathname.slice(1); // in order to return a response, we have to call res.end() // https://nodejs.org/api/http.html#http_response_end_data_encoding_callback // // > The method, response.end(), MUST be called on each response. // if we don't call it, the connection won't close and a requester // will wait for it until the timeout // // by default, we return a response with [code 200](https://en.wikipedia.org/wiki/List_of_HTTP_status_codes) // in case something went wrong, we are supposed to return // a correct status code, using the res.statusCode = ... property: // https://nodejs.org/api/http.html#http_response_statuscode if (pathname.startsWith(".")) { res.statusCode = 403; res.end("Relative paths are not allowed"); } else if (pathname.includes("/")) { res.statusCode = 403; res.end("Nested paths are not allowed"); } else { // https://nodejs.org/en/docs/guides/working-with-different-filesystems/ // in order to stay cross-platform, we can't just create a path on our own // we have to use the platform-specific separator as a delimiter // path.join() does exactly that for us: // https://nodejs.org/api/path.html#path_path_join_paths const filePath = path.join(__dirname, FOLDER_NAME, pathname); const fileStream = fs.createReadStream(filePath); fileStream.pipe(res); fileStream.on("error", e => { // we handle only non-existant files, but there are plenty // of possible error codes. you can get all common codes from the docs: // https://nodejs.org/api/errors.html#errors_common_system_errors if (e.code === "ENOENT") { res.statusCode = 404; res.end("This file does not exist."); } else { res.statusCode = 500; res.end("Internal server error"); } });} }); server.listen(PORT, () => { console.log(application is listening at the port ${PORT}); });
在本指南中,咱們介紹了許多基本的Node.js原則。咱們沒有深刻研究特定的API,咱們確實錯過了一些東西。可是,本指南應該是一個很好的起點,讓您在閱讀API,編輯現有的代碼,或者建立新腳本時有信心。您如今可以理解錯誤,清楚內置模塊使用的接口,以及從典型的Node.js對象和接口中能獲取到哪些東西。
下一次,咱們將深刻介紹使用Node.js的Web服務,Node.js REPL,如何編寫CLI應用程序,以及如何使用Node.js編寫小腳本。您能夠訂閱以獲取有關這些新文章的通知。
2017年7月9日» Node.js REPL深度2018年6月5日» 不要使用縮略詞
2018 年 6月3日» 單元測試