一個有味道的函數

  • 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實現方案

大致的思路為:

  1. 獲取所有的參數
  2. 調用最後一個函數,並接收返回值
  3. 如果沒有後續的函數,返回數據,如果有,將返回值放入下一個函數中執行

所以這種情況用遞歸來實現會比較清晰一些

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代碼的改動主要是集中在這幾處:

  1. compose的返回值改為了Promise對象,這個是必然的,因為內部可能會包含Promise參數,所以我們一定要返回一個Promise對象
  2. 將各個函數執行的返回值包裝為了Promise對象,為了統一返回值。
  3. 處理函數返回值,監聽thencatch、並將resolvereject傳遞了過去。

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%

拿到需求後,陷入沉思。。。 好好地順序執行代碼,突然就變成了這個鳥樣,隨時可能會跳到後邊的函數去。 所以我們分析這個新需求的效果:

我們在函數執行到一半時,執行了nextnext的返回值為後續函數的執行返回值。 也就是說,我們在next中處理,直接調用隊列中的下一個函數即可; 然後監聽thencatch回調,即可在當前函數中獲取到返回值; 拿到返回值後就可以執行我們後續的代碼。

然後他的實現呢,也是非常的簡單,我們只需要修改如下代碼即可完成操作:

// 在這裡會強行調用`exec`並傳入參數  // 而`exec`的執行,則意味着`funcs`集合中又一個函數被從隊列中取出來  promiseify(func(arg, arg => exec(arg)))

也就是說,我們會提前執行下一個函數,而且下一個函數的then事件註冊是在我們當前函數內部的,當我們拿到返回值後,就可以進行後續的處理了。 而我們所有的函數是存放在一個隊列里的,在我們提前執行完畢該函數後,後續的執行也就不會再出現了。避免了一個函數被重複執行的問題。

如果看到這裡已經很明白了,那麼恭喜,你已經了解了實現koajs最核心的代碼: 中間件的實現方式洋蔥模型

想必現在整個函數周遭散發著洋蔥的味道。

參考資料

koa-compose

相關示例代碼倉庫

1.0,普通函數 2.0,Promise函數 3.0,支持洋蔥模型