一個有味道的函數
- 2019 年 12 月 5 日
- 筆記
一個有味道的函數
最近想到了一個自認為很有意思的面試題 如何實現一個
compose函數。 函數接收數個參數,參數均為Function類型,右側函數的執行結果將作為左側函數執行的參數來調用。
compose(arg => `${arg}%`, arg => arg.toFixed(2), arg => arg + 10)(5) // 15.00% compose(arg => arg.toFixed(2), arg => arg + 10)(5) // 15.00 compose(arg => arg + 10)(5) // 15執行結果如上述程式碼,有興趣的同學可以先自己實現一下再來看後續的。
1.0實現方案
大致的思路為:
- 獲取所有的參數
- 調用最後一個函數,並接收返回值
- 如果沒有後續的函數,返回數據,如果有,將返回值放入下一個函數中執行
所以這種情況用遞歸來實現會比較清晰一些
function compose (...funcs) { return function exec (arg) { let func = funcs.pop() let result = func(arg) // 執行函數,獲取返回值 // 如果後續還有函數,將返回值放入下一個函數執行 // 如果後續沒有了,直接返回 return funcs.length ? exec(result) : result } }這樣,我們就實現了上述的compose函數。 真是可喜可賀,可喜可賀。
本文完。
好了,如果現實生活中開發做需求也是如此爽快不做作就好了,但是,產品總是會來的,需求總是會改的。
2.0需求變更
我們現在有如下要求,函數需要支援Promise對象,而且要兼容普通函數的方式。 示例程式碼如下:
// 為方便閱讀修改的排版 compose( arg => new Promise((resolve, reject) => setTimeout(_ => resolve(arg.toFixed(2)), 1000 ) ), arg => arg + 10 )(5).then(data => { console.log(data) // 15.00 })我們有如下程式碼調用,對toFixed函數的調用添加1000ms的延遲。讓用戶覺得這個函數執行很慢,方便下次優化
所以,我們就需要去修改compose函數了。 我們之前的程式碼只能支援普通函數的處理,現在因為添加了Promise對象的原因,所以我們要進行如下修改:
首先,非同步函數改為同步函數是不存在的readFile/readFileSync這類除外。 所以,最簡單的方式就是,我們將普通函數改為非同步函數,也就是在普通函數外包一層Promise。
function compose (...funcs) { return function exec (arg) { return new Promise((resolve, reject) => { let func = funcs.pop() let result = promiseify(func(arg)) // 執行函數,獲取返回值,並將返回值轉換為`Promise`對象 // 註冊`Promise`的`then`事件,並在裡邊進行下一次函數執行的準備 // 判斷後續是否還存在函數,如果有,繼續執行 // 如果沒有,直接返回結果 result.then(data => funcs.length ? exec(data).then(resolve).catch(reject) : resolve(data) ).catch(reject) }) } } // 判斷參數是否為`Promise` function isPromise (pro) { return pro instanceof Promise } // 將參數轉換為`Promise` function promiseify (pro) { // 如果結果為`Promise`,直接返回 if (isPromise(pro)) return pro // 如果結果為這些基本類型,說明是普通函數 // 我們給他包一層`Promise.resolve` if (['string', 'number', 'regexp', 'object'].includes(typeof pro)) return Promise.resolve(pro) }我們針對compose程式碼的改動主要是集中在這幾處:
- 將
compose的返回值改為了Promise對象,這個是必然的,因為內部可能會包含Promise參數,所以我們一定要返回一個Promise對象 - 將各個函數執行的返回值包裝為了
Promise對象,為了統一返回值。 - 處理函數返回值,監聽
then和catch、並將resolve和reject傳遞了過去。
3.0終極版
現在,我們又得到了一個新的需求,我們想要在其中某些函數執行中跳過部分程式碼,先執行後續的函數,等到後續函數執行完後,再拿到返回值執行剩餘的程式碼:
compose( data => new Promise((resolve, reject) => resolve(data + 2.5)), data => new Promise((resolve, reject) => resolve(data + 2.5)), async function c (data, next) { // async/await為Promise語法糖,不贅述 data += 10 // 數值 + 10 let result = await next(data) // 先執行後續的程式碼 result -= 5 // 數值 - 5 return result }, (data, next) => new Promise((resolve, reject) => { next(data).then(data => { data = data / 100 // 將數值除以100限制百分比 resolve(`${data}%`) }).catch(reject) // 先執行後續的程式碼 }), function d (data) { return data + 20 } )(15).then(console.log) // 0.45%拿到需求後,陷入沉思。。。 好好地順序執行程式碼,突然就變成了這個鳥樣,隨時可能會跳到後邊的函數去。 所以我們分析這個新需求的效果:
我們在函數執行到一半時,執行了next,next的返回值為後續函數的執行返回值。 也就是說,我們在next中處理,直接調用隊列中的下一個函數即可; 然後監聽then和catch回調,即可在當前函數中獲取到返回值; 拿到返回值後就可以執行我們後續的程式碼。
然後他的實現呢,也是非常的簡單,我們只需要修改如下程式碼即可完成操作:
// 在這裡會強行調用`exec`並傳入參數 // 而`exec`的執行,則意味著`funcs`集合中又一個函數被從隊列中取出來 promiseify(func(arg, arg => exec(arg)))
也就是說,我們會提前執行下一個函數,而且下一個函數的then事件註冊是在我們當前函數內部的,當我們拿到返回值後,就可以進行後續的處理了。 而我們所有的函數是存放在一個隊列里的,在我們提前執行完畢該函數後,後續的執行也就不會再出現了。避免了一個函數被重複執行的問題。
如果看到這裡已經很明白了,那麼恭喜,你已經了解了實現koajs最核心的程式碼: 中間件的實現方式、洋蔥模型

想必現在整個函數周遭散發著洋蔥的味道。
參考資料
相關示例程式碼倉庫
1.0,普通函數 2.0,Promise函數 3.0,支援洋蔥模型
