函數式編程 —— 將 JS 方法函數化
前言
JS 調用方法的風格為 obj.method(...)
,例如 str.indexOf(...)
,arr.slice(...)
。但有時出於某些目的,我們不希望這種風格。例如 Node.js 的源碼中有很多 類似這樣的代碼:
const {
ArrayPrototypeSlice,
StringPrototypeToLowerCase,
} = primordials
// ...
ArrayPrototypeSlice(arr, i)
為什麼不直接使用 arr.slice()
而要多此一舉?
因為 arr.slice()
實際調用的是 Array.prototype.slice
,假如用戶重寫了這個方法,就會出現無法預期的結果。所以出於慎重,通常先備份原生函數,運行時只用備份的函數,而不用暴露在外的函數。
調用
備份原生函數很簡單,但調用它時卻有很多值得注意的細節。例如:
// 備份
var rawFn = String.prototype.indexOf
// ...
// 調用
rawFn.call('hello', 'e') // 1
這種調用方式看起來沒什麼問題,但實際上並不嚴謹,因為 rawFn.call()
仍使用了 obj.method(...)
風格 —— 假如用戶修改了 Function.prototype.call
,那麼仍會出現無法預期的結果。
最簡單的解決辦法,就是用 ES6 中的 Reflect API:
Reflect.apply(rawFn, 'hello', ['e']) // 1
不過同樣值得注意,Reflect.apply
也未必是原生的,也有被用戶重寫的可能。因此該接口也需提前備份:
// 備份
var rawFn = String.prototype.indexOf
var rawApply = Reflect.apply
// ...
// 調用
rawApply(rawFn, 'hello', ['e']) // 1
只有這樣,才能做到完全無副作用。
簡化
有沒有更簡單的方案,無需用到 Reflect API 呢?
我們先實現一個包裝函數,可將 obj.method(...)
變成 method(obj, ...)
的風格:
function wrap(fn) {
return function(obj, ...args) {
return fn.call(obj, ...args)
}
}
const StringPrototypeIndexOf = wrap(String.prototype.indexOf)
StringPrototypeIndexOf('hello', 'e') // 1
運行沒問題,下面進入消消樂環節。
v1
即使沒有包裝函數,我們也可直接調用,只是稍顯累贅:
String.prototype.indexOf.call('hello', 'e') // 1
既然參數都相同,這樣是否可行:
const StringPrototypeIndexOf = String.prototype.indexOf.call
StringPrototypeIndexOf('hello', 'e') // ???
顯然不行!這相當於引用 Function.prototype.call
,丟失了 String.prototype.indexOf
這個上下文。
如果給 call 綁定上下文,這樣就正常了:
const call = Function.prototype.call
const StringPrototypeIndexOf = call.bind(String.prototype.indexOf)
StringPrototypeIndexOf('hello', 'e') // 1
整理可得:
const call = Function.prototype.call
function wrap(fn) {
return call.bind(fn)
}
const StringPrototypeIndexOf = wrap(String.prototype.indexOf)
StringPrototypeIndexOf('hello', 'e') // 1
v2
既然 wrap(fn)
和 call.bind(fn)
參數都相同,那麼是否可繼續簡化,直接消除 wrap 函數?
和之前一樣,直接引用顯然不行,而是要預先綁定上下文。由於會出現兩個 bind 容易搞暈,因此我們拆開分析。
回顧綁定公式:
-
綁定前
obj.method(...)
-
綁定後
method.bind(obj)
在 call.bind(fn)
中,obj 為 call
,method 為 bind
。套入公式可得:
bind.bind(call)
其中第一個 bind 為 Function.prototype.bind
。
整理可得:
const call = Function.prototype.call
const wrap = Function.prototype.bind.bind(call)
const StringPrototypeIndexOf = wrap(String.prototype.indexOf)
StringPrototypeIndexOf('hello', 'e') // 1
v3
到此已沒有可消除的了,但我們可以用更短的函數名代替 Function.prototype
,例如 Map、Set、URL 或者自定義的函數名。
出於兼容性,這裡選擇 Date 函數:
const wrap = Date.bind.bind(Date.call)
const StringPrototypeIndexOf = wrap(String.prototype.indexOf)
StringPrototypeIndexOf('hello', 'e') // 1
結尾
現在我們可用更簡單、兼容性更好的方式,將方法函數化,並且無副作用:
const wrap = Date.bind.bind(Date.call)
const StringPrototypeIndexOf = wrap(String.prototype.indexOf)
const StringPrototypeSubstr = wrap(String.prototype.substr)
StringPrototypeIndexOf('hello', 'e') // 1
StringPrototypeSubstr('hello', 2, 3) // "llo"