使用 node,非同步處理是無論如何都規避不了的點,如果只是為了實現功能大可以使用層層回撥(回撥地獄),但我們是有追求的程式設計師...
本文以一個簡單的檔案讀寫為例,講解了非同步的不同寫法,包括 普通的 callback、ES2016中的Promise和Generator、 Node 用於解決回撥的co 模組、ES2017中的async/await。適合初步接觸 Node.js以及少量 ES6語法的同學閱讀。
一個範例
以一個範例做為例,我們要實現的功能如下:
- 讀取 a.md 檔案,得到內容
- 把內容轉換成 HTML 字串
- 把HTML 字串寫入 b.html
一、callback回撥地獄
var fs = require('fs')
var markdown = require( "markdown" ).markdown
fs.readFile('a.md','utf-8', function(err, str){
if(err){
return console.log(err)
}
var html = markdown.toHTML(str)
fs.writeFile('b.html', html, function(err){
if(err){
return console.log(err)
}
console.log('write success')
})
})
複製程式碼
既然在 Node 環境下執行,那我們就儘量多使用 ES6的語法,比如let
、const
、箭頭函式
,上述程式碼改寫如下
const fs = require('fs')
const markdown = require( "markdown" ).markdown
fs.readFile('a.md','utf-8', (err, str)=>{
if(err){
return console.log(err)
}
let html = markdown.toHTML(str)
fs.writeFile('b.html', html, (err)=>{
if(err){
return console.log(err)
}
console.log('write success')
})
})
複製程式碼
看起來還不錯哦,那是因為我們的回撥只有兩層,如果是七層、十層呢?這不是開玩笑。
二、Promise 處理回撥
關於 Promise 規範大家可以參考阮一峰老師的教程ECMAScript 6入門,這裡不作贅述。 這裡我們把上述程式碼改寫為 Promise 規範的呼叫方式,其中檔案的讀寫需要進行包裝,呼叫後返回 Promise 物件
const fs = require('fs')
const markdown = require( "markdown" ).markdown
readFile("a.md")
.then((mdStr)=>{
return markdown.toHTML(mdStr) //返回的結果作為下個回撥的引數
}).then(html=>{
writeFile('b.html', html)
}).catch((e)=>{
console.log(e)
});
function readFile(url) {
var promise = new Promise((resolve, reject)=>{
fs.readFile(url,'utf-8', (err, str)=>{
if(err){
reject(new Error('readFile error'))
}else{
resolve(str)
}
})
})
return promise
}
function writeFile(url, data) {
var promise = new Promise((resolve, reject)=>{
fs.writeFile(url, data, (err, str)=>{
if(err){
reject(new Error('writeFile error'))
}else{
resolve()
}
})
})
return promise
}
複製程式碼
上述程式碼把 callback 的巢狀執行改為 then 的串聯執行,看起來舒服了一些。程式碼中我們對檔案的讀寫函式進行了 Promise 化包裝,其實可以使用一些現成的模組來做這個事情,繼續改寫程式碼
const markdown = require('markdown').markdown
const fsp = require('fs-promise') //用於把 fs 變為 promise 化,內部處理邏輯和上面的例子類似
let onerror = err=>{
console.error('something wrong...')
}
fsp.readFile('a.md', 'utf-8')
.then((mdStr)=>{
return markdown.toHTML(mdStr) //返回的結果作為下個回撥的引數
}).then(html=>{
fsp.writeFile('b.html', html)
}).catch(onerror);
複製程式碼
程式碼一下子少了很多,結構清晰,但一堆的 then 看著還是礙眼... ###三、Generator Generator 函式是 ES6 提供的一種非同步程式設計解決方案,也是剛剛接觸的同學難以理解的點之一,在看下面的程式碼之前可以參考阮老師的教程ECMAScript 6入門, 當然這裡也會先用一些簡單的範例做引導便於大家去理解.
先看一個範例:
function fn(a,b){
console.log('fn..')
return a + b
}
function* gen(x) {
console.log(x)
let y = yield fn(x,100) + 3
console.log(y)
return 200
}
複製程式碼
上述宣告瞭一個普通函式 fn,和一個 Generator 函式 gen,先執行如下程式碼
let g = gen(1)
複製程式碼
呼叫Generator 函式,返回一個儲存狀態物件的引用,這個時候 gen 這個函式是沒執行的,所以當你執行上面這行程式碼不會有任何輸出
console.log( g.next() )
複製程式碼
當呼叫g.next()
時,gen 函式開始執行,執行到第一個yield 為止,並把 yield 表示式的值作為狀態物件的值。更具體一點,上例先輸出x
也就是1
,然後執行 fn(x, 100)
輸出 fn..
並返回101, 然後加3。這時候停止執行,把結果103賦值給狀態物件 g,g 的結果變 {value: 103, done: false}。需要注意,yied表示式的優先順序極其低,yield fn(x,100) + 3
相當於 yield (fn(x,100) + 3)
console.log( g.next() )
複製程式碼
這次執行g.next()
的時候,程式碼由上次暫停處開始執行,但此時 yield 表示式的值並不是使用剛剛計算的結果,而是使用 g.next
的引數undefined
, 所以 y的值變為undefined
,輸出undeined
。執行到return 200
時,狀態物件知道執行結束了,會把return的200賦值到狀態物件,結果為 { value: 200, done: true }
有同學會問,如何把剛剛計算的中間值103給下個yield來用呢?好問題,我們可以這樣
g.next(g.next().value)
複製程式碼
想想為什麼。現在可以回到我們的主題了,看看實現程式碼
const fs = require('fs')
const markdown = require("markdown").markdown
function readFile(url) {
fs.readFile(url, 'utf8', (err, str)=>{
if(err){
g.throw('read error');
}else{
g.next(str) //line4
}
})
}
function writeFile(url, data) {
fs.writeFile(url, data, (err, str)=>{
if(err){
g.throw('write error');
}else{
g.next() //line5
}
})
}
let gen = function* () {
try{
let mdStr = yield readFile('aa.md', 'utf-8') //line3
console.log(mdStr)
let html = markdown.toHTML(mdStr)
yield fs.writeFile('b.html', html)
}catch(e){
console.log('error occur...') //line6
}
}
let g = gen() //line1
let result = g.next() //line2
複製程式碼
為了便於描述,我們在程式碼的關鍵行加了行號標記,程式碼執行流程如下:
- line1: 執行Generator,建立一個狀態物件,此時函式內部並沒有執行
- line2: 呼叫g.next(),gen函式開始執行,此時會執行line3的readFile函式,而 gen 函式的控制權交出程式碼暫停
- line4: 當檔案讀取後會呼叫 g.next(str), 此時會把控制權再次交給 gen,並把檔案結果str做為引數交給Generator狀態物件g
- line3: 此時yield的結果就是剛剛傳遞的str,賦值給mdStr
- ... ,寫檔案的邏輯類似
- line6: 當中間出現錯誤時,g會丟擲異常,控制權交給gen後會捕獲異常,處理報錯
如果能看懂上面的程式碼,說明對 Generator函式就理解了
但雖然感覺用了更“高階”的技術,但與前面兩種方法相比這種寫法反而更醜陋難用。狀態物件竟然在 readFile 和 writeFile 這兩個普通函式裡面呼叫...
我們可以先做一些優化
function readFile(url) {
return (callback)=>{
fs.readFile(url, 'utf-8', (err, str)=>{
if(err) throw err
callback(str)
})
}
}
//readFile('a.md')( (err, str)=>{ console.log(str)} )
//將多個引數的呼叫轉換成單個引數的呼叫,回想想那些常常提到的概念,如閉包、函式柯里化
function writeFile(url, data){
return (callback)=>{
fs.writeFile(url, data, (err, str)=>{
if(err) throw err
callback()
})
}
}
// writeFile('b.html')( (err)=>{console.log('write ok')} )
let gen = function* () {
try{
let mdStr = yield readFile('a.md', 'utf-8') //line4
let html = markdown.toHTML(mdStr)
yield writeFile('b.html', html)
}catch(e){
console.log('error occur...')
}
}
let g = gen() //line1
g.next().value(str=>{ //line2
g.next(str).value(()=>{ //line3
console.log('write success')
})
})
複製程式碼
- line1: 執行Generator,建立一個狀態物件,此時函式內部並沒有執行,此時狀態物件{value:undefined, done: false}
- line2: 執行g.next()的時候開始執行gen函式,此時會執行readFile(), 而這個函式的執行會返回一個匿名函式。遇到yield 後gen函式暫停,把readFile()返回的匿名函式儲存到狀態物件的value裡。所以g.next().value() 其實就是執行那個匿名函式,即 呼叫fs.readFile。當檔案讀取後,會呼叫fs.readFile裡的 callback,而這個 callback 就是剛剛 g.next().value()的引數
- line3: 呼叫g.next(str)讓 gen 函式繼續執行,同時把yield語句的結果用 str 來替換,程式碼繼續往下走,到writeFile停止執行... 同步驟2
真的是很繞,頭都繞暈了。上面的寫法除了稍微解耦以為,仍然很醜陋,主功能非同步的執行需要 Generator不斷的回撥呼叫next才可以,如果有七層十層...
下面做個個簡單的優化,讓Generator自動呼叫,知道狀態變為done,原理大家自己好好想想
function run(fn) {
let gen = fn()
function next(data) {
let result = gen.next(data)
if (result.done) return
console.log(result.value)
result.value(next)
}
next()
}
run(gen)
複製程式碼
再也不想用 Generator 了!
四、co 模組
co 模組是用於處理非同步的一個node包,用於 Generator 函式的自動執行。NPM 地址co,模組內部原理可參考這裡ECMAScript 6入門-模組, 本質上就是 Promise 和 Generator 的結合,和我們上個範例還是很像的。 類似處理非同步的比較出名的模組還有 async模組(注意不是ES2017的async語法)、bluebird
const fs = require('fs')
const markdown = require('markdown').markdown
const co = require('co')
const thunkify = require('thunkify')
let readFile = thunkify(fs.readFile)
let writeFile = thunkify(fs.writeFile)
let onerror = err=>{
console.error('something wrong...')
}
let gen = function* () {
let mdStr = yield readFile('a.md', 'utf-8')
let html = markdown.toHTML(mdStr)
yield writeFile('b.html', html)
}
co(gen).catch(onerror)
複製程式碼
例子中 thunkify模組用於把一個函式thunk化,也就是我們上例中如下形式對非同步函式進行包裝。gen 的啟動由 co(gen)
來開啟,和我們上一個範例類似
function writeFile(url, data){
return (callback)=>{
fs.writeFile(url, data, (err, str)=>{
if(err) throw err
callback()
})
}
}
複製程式碼
就像回到了男耕女織的田園生活,感覺世界一下子清爽了許多。
五、async/await
ES2017 標準引入了 async 函式,用於更方便的處理非同步。 這個特性太新了,真要用需要babel來轉碼。
const markdown = require('markdown').markdown
const fsp = require('fs-promise')
let onerror = err=>{
console.error('something wrong...')
}
async function start () {
let mdStr = await fsp.readFile('a.md', 'utf-8')
let html = markdown.toHTML(mdStr)
await fsp.writeFile('b.html', html)
}
start().catch(onerror)
複製程式碼
async函式是對 Generator 函式的改進,實際上就是把Generator自動執行給封裝起來,同時返回的是 Promise 物件更便於操作。
用的時候需要注意await命令後面是一個 Promise 物件。
上例中 fsp的作用是把內建的fs模組Promise 化,這個其實剛剛做過。
var readFile = function (fileName) {
return new Promise(function (resolve, reject) {
fs.readFile(fileName,'utf-8', function(error, data) {
if (error) reject(error);
resolve(data);
});
});
}
複製程式碼
總結
上面幾個例子實際上是非同步處理的發展過程,從醜陋到精美,從引入各種亂七八糟的無關程式碼到精簡到只保留核心業務功能,這也是任何框架和標準發展的趨勢。
有什麼預見和期待?
可以預見的是async/await慢慢會變成主流,現階段用 co 也挺方便的,因為它們都很美。
期待node內建的涉及非同步操作的模組都逐步提供對Promise的規範的支援,期待 ES2017的快速普及,那世界就美好了。
上面我們的功能不需要任何『外掛』將簡化成
let mdStr = await fs.readFile('a.md', 'utf-8')
let html = markdown.toHTML(mdStr)
await fs.writeFile('b.html', html)
fs.onerror = ()=>{console.log('error')}
複製程式碼
加微訊號: astak10或者長按識別下方二維碼進入前端技術交流群 ,暗號:寫程式碼啦
每日一題,每週資源推薦,精彩部落格推薦,工作、筆試、面試經驗交流解答,免費直播課,群友輕分享... ,數不盡的福利免費送