函數式編程的理論知識我已經 闡(胡)述(謅) 完了,沒看過的小夥伴,能夠猛擊下面鏈接開啓穿越模式:html
下面我會從如何用 FP
編寫高質量的函數、分析源碼裏面的技巧,以及實際工做中如何編寫,來展現如何打通你的任督二脈。前端
話很少說,下面就開始實戰吧。 linux
FP
編寫高質量的函數這裏我經過簡單的 demo
來講明一些技巧。技巧點以下:git
那你就要注意了,這多是一個硬編碼,不夠靈活性,你可能須要進行處理了,如何處理呢?好比經過傳參來幹掉值類型的變量,下面舉一個簡單的例子。github
代碼以下:npm
document.querySelector('#msg').innerHTML = '<h1>Hello World'</h1>' 複製代碼
咱們來欣賞一下上面的代碼,我來吐槽幾句:編程
第一:硬編碼味道很重,代碼都是寫死的。設計模式
第二:擴展性不好,複用性很低,難道我要在其餘地方進行 crtl c
ctrl v
而後再手工改?數組
第三:若是我在 document.querySelector('#msg')
拿到對象後,不想 innerHTML
,我想作一些其餘的事情,怎麼辦?緩存
看了上面的三點,是否是感受很 DT
。OK
,下面我就先向你們展現一下,如何徹底重構這段代碼。這裏我只寫 JS
部分:
代碼以下:
// 使用到了組合函數,運用了函數的高階性等
const compose = (...fns) => value => fns.reverse().reduce((acc, fn) => fn(acc), value)
const documentWrite = document.write.bind(document)
const createNode = function(text) {
return '<h1>' + text + '</h1>'
}
const setText = msg => msg
const printMessage = compose(
documentWrite,
createNode,
setText
)
printMessage('hi~ godkun')
複製代碼
效果如圖所示:
完整代碼我放在了下面兩個地址上,小夥伴可自行查看。
codepen: codepen.io/godkun/pen/…
注意事項一:
compose
函數的執行順序是從右向左,也就是數據流是從右向左流,你能夠把
const printMessage = compose(
documentWrite,
createNode,
setText
)
複製代碼
當作是下面這種形式:
documentWrite(createNode(setText(value)))
複製代碼
注意事項二:
在 linux
世界裏,是遵循 pipe
(管道) 的思想,也就是數據從左向右流,那怎麼把上面的代碼變成 pipe
的形式呢?
很簡單,只須要把 const compose = (...fns) => value => fns.reverse().reduce((acc, fn) => fn(acc), value)
中的 reverse
幹掉就行了,寫成:
const compose = (...fns) => value => fns.reduce((acc, fn) => fn(acc), value)
複製代碼
總結
是否是發現經過用函數式編程進行重構後,這個代碼變得很是的靈活,好處大體有以下:
上來我就寫了個 簡單 的開胃菜?
並不簡單,你們好好想想,仔細體會一下。
思考題:這裏我甩貼一張小夥伴在羣裏分享的圖:
是否是感到頭皮發麻,這是我送個你們的禮物,你們能夠嘗試把上面圖片的代碼用函數式進行徹底重構,加油。
下面輕鬆點,代碼 demo
以下:
let arr = [1,3,2,4,5]
function fun(arr) {
let result = arr.sort()
console.log('result', result)
console.log('arr', arr)
}
fun(arr)
複製代碼
結果以下圖所示:
看上面,你會發現數組 arr
被修改了。因爲 fun(arr)
函數中的參數 arr
是引用類型,若是函數體內對此引用所指的數據進行直接操做的話,就會有潛在的反作用,好比原數組被修改了,這種狀況下,改怎麼辦呢?
很簡單,在函數體內對 arr
這個引用類型進行建立副本。以下面代碼:
let arr = [1,3,2,4,5]
function fun(arr) {
let arrNew = arr.slice()
let result = arrNew.sort()
console.log('result', result)
console.log('arr', arr)
}
fun(arr)
複製代碼
經過 slice
來建立一個新的數組,而後對新的數組進行操做,這樣就達到了消除反作用的目的。這裏我只是舉一個例子,可是核心思想我已經闡述出來了,這裏已經體現了理論卷中的數據不可變的思想了。
若是函數體內引用變量的變化,會形成超出其做用域的影響,好比上面代碼中對 arr
進行操做,影響到了數組 arr
自己 。那這個時候,咱們就須要思考一下,要不要採用不可變的思想,對引用類型進行處理。
注意函數裏面有沒有大量的
for
循環
爲何說這個呢,由於這個很好判斷。若是有的話,就要思考一下需不須要對 for
循環進行處理,下文有對 for
循環的專門介紹。
注意函數裏面有沒有過多的
if/else
也是同樣的思想,過多的 if/else
也要根據狀況去作相應的處理。
標題的意識其實能夠這樣理解,對函數進行高階化處理。當把函數當成參數的時候,也就是把代碼自己當成參數了。
什麼狀況下要考慮高階化呢。
當你優化到必定地步後,發現仍是不夠複用性,這個時候就要考慮將參數進行函數化,這樣能夠將參數變成能夠提供更多功能的函數。
函數的高階化,每每在其餘功能上得以體現,好比柯里化,組合。
經過上面例子的分析,我也向你們展現瞭如何將函數最小化。經過將大函數拆成多個具備單一職責的小函數,來提升複用性和靈活性。
FP
不是萬能的,你們不要認爲它很完美,它也有本身的缺點,下面我簡單的說兩點吧。
進行 FP
時, 若是你使用的不恰當,是會形成性能問題的。好比你遞歸用的不恰當,好比你柯里化嵌套的過多。
這裏我想說的是,在進行 FP
時,不要過分的抽象,過分的抽象會致使可讀性變差。
說到函數式編程,那必定要看看 Ramda.js
的源碼。ramda.js
的源碼搞懂後,函數式編程的思想也就基本沒什麼問題了。
關於 Ramda.js
能夠看一下阮大的博客:
看完了,那開始執行:
git clone git@github.com:ramda/ramda.git
複製代碼
而後咱們來分析源碼,首先按照常規套路,看一下 source/index.js
文件。
如圖所示:
嗯好,我大概知道了,咱們繼續分析。
看一下 add.js
import _curry2 from './internal/_curry2';
var add = _curry2(function add(a, b) {
return Number(a) + Number(b);
});
export default add;
複製代碼
看上面代碼,咱們發現,add
函數被包了一個 _curry2
函數。 下劃線表明這是一個內部方法,不暴露成 API
。這時,你再看其餘函數,會發現都被包了一個 _curry1/2/3/N
函數。
以下圖所示:
從代碼中,咱們能夠知道,1/2/3/N
表明掉參數個數爲 1/2/3/N
的函數的柯里化,並且會發現,全部的 ramda
函數都是通過柯里化的。
咱們思考一個問題,爲何
ramda.js
要對函數所有柯里化?
咱們看一下普通的函數 f(a, b, c)
。若是隻在調用的時候,傳遞 a
。會發現,JS
在運行調用時,會將 b
和 c
設置爲 undefined
。
從上面咱們能夠知道,JS
語言不能原生支持柯里化。非柯里化函數會致使缺乏參數的實參變成 undefined
。繼續想會發現,ramda.js
對函數所有柯里化的目的,就是爲了優化上面的場景。
下面,咱們看一下 _curry2
代碼,這裏爲了可讀性,我對代碼進行了改造,我把 _isPlaceholder
去掉了,假設沒有佔位符,同時把 _curry1
放在函數內,而且對過程進行了相應註釋。
二元參數的柯里化,代碼以下:
function _curry2(fn) {
return function f2(a, b) {
switch (arguments.length) {
case 0:
return f2;
case 1:
return _curry1(function (_b) {
// 將參數從右到左依次賦值 1 2
// 第一次執行時,是 fn(a, 1)
return fn(a, _b);
});
default:
// 參數長度是 2 時 直接進行計算
return fn(a, b);
}
};
}
function _curry1(fn) {
return function f1(a) {
// 對參數長度進行判斷
if (arguments.length === 0) {
return f1;
} else {
// 經過 apply 來返回函數 fn(a, 1)
return fn.apply(this, arguments);
}
};
}
const add = _curry2(function add(a, b) {
return Number(a) + Number(b);
});
// 第一次調用是 fn(a, 1)
let r1 = add(1)
// 第二次調用是 fn(2,1)
let r2 = r1(2)
console.log('sss', r2)
複製代碼
完整代碼地址以下:
codeopen:codepen.io/godkun/pen/…
上面的代碼在關鍵處已經作了註釋,這裏我就不過多解釋細節了,小夥伴自行領悟。
柯里化的好處
看了上面對 ramda.js
源碼中柯里化的分析,是否是有點收穫,就像上面說的,柯里化的目的是爲了優化在 JS
原生下的一些函數場景。好處以下:
第一:從上面 add
函數能夠知道,經過柯里化,可讓函數在真正須要計算的時候進行計算,起到了延遲的做用,也能夠說體現了惰性思想。
第二:經過對參數的處理,作到複用性,從上面的 add
函數能夠知道,柯里化把多元函數變成了一元函數,經過屢次調用,來實現須要的功能,這樣的話,咱們就能夠控制每個參數,好比提早設置好不變的參數,從而讓代碼更加靈活和簡潔。
PS: 柯里化命名的由來
ramda
中的 compose
和 pipe
-- 組合函數/管道函數本文一開始,我就以一個例子向你們展現了組合函數 compose
和 pipe
的用法。
關於 ramda
中,compose
和 pipe
的實現我這裏就再也不分析了,小夥伴本身看着源碼分析一下。這裏我就簡潔說一下組合函數的一些我的見解。
我的對組合(管道也是組合)函數的見解
在我看來,組合是函數式編程的核心,FP
的思想是要函數儘量的小,儘量的保證職責單一。這就直接肯定了組合函數在 FP
中的地位,玩好了組合函數,FP
也就基本上路了。
和前端的組件進行對比來深入的理解組合函數
函數的組合思想是面向過程的一種封裝,而前端的組件思想是面對對象的一種封裝。
實際工做中,你確定會遇到下面這種接收和處理數據的場景。
代碼以下:
// 僞代碼
res => {
// name 是字符串,age 是數字
if (res.data && res.data.name && res.data.age) {
// TODO:
}
}
複製代碼
上面這樣寫,看起來好像也沒什麼問題,可是經不起分析。好比 name
是數字,age
返回的不是數字。這樣的話, if
中的判斷是能經過的,可是實際結果並非你想要的。
那該怎麼辦呢?問題不大,跟着我一步步的優化就
OK
了。
res => {
if (res.data && typeof res.data.name === 'string' && typeof res.data.age === 'number') {
// TODO:
}
}
複製代碼
看起來是夠魯棒了,可是這段代碼過於命令式,沒法複用到其餘地方,在其餘的場景中,還要重寫一遍這些代碼,很煩。
// is 是一個對象函數 僞代碼
res => {
if (is.object(res.data) && is.string(res.data.name) && is.number(res.data.age)) {
// TODO:
}
}
複製代碼
可能有人要問,這是函數式編程麼。如今我告訴你,這是 FP
,將過程抽象掉的行爲也是一種函數式思想。上面代碼,提升了複用性,將判斷的過程抽象成了 is
的對象函數中,這樣在其餘地方均可以複用這個 is
。
可是,代碼仍是有問題,通常來講,各個接口的返回數據都是 res.data
這種類型的。因此若是按照上面的代碼,咱們會發現,每次都要寫 is.object(res.data)
這是不能容忍的一件事。咱們能不能作到不寫這個判斷呢?
固然能夠,你徹底能夠在 is
裏面加一層對 data
的判斷,固然這個須要你把 data
做爲參數 傳給 is
。
// is 是一個對象函數 僞代碼
res => {
if (is.string(res.data, data.name) && is.number(res.data, data.age)) {
// TODO:
}
}
複製代碼
按照上面的寫法,is
系列函數會對第一個參數進行 object
類型判斷,會再次提升複用性。
好像已經很不錯了,但其實還遠遠不夠。
爲何還遠遠不夠
第一:有 if
語句存在,可能會有人說,if
語句存在有什麼的啊。如今我來告訴你,這塊有 if
爲何很差。是由於 if
語句的 ()
裏面,最終的值都會表現成布爾值。因此這塊限制的很死,須要解決 if
語句的問題。
第二:is
函數功能單一,只能作到返回布爾值,沒法完成調試打印錯誤處理等功能,若是你想打印和調試,你又得在條件分支裏面各類 console.log
,而後這些代碼依舊過於命令式,沒法重用。其實,咱們想一下,能夠知道,這也是由於用了 if
語句形成的。
說完這些問題,那下面咱們來解決吧。
咱們想一下,若是要作到高度抽象和複用的話,首先咱們要把須要的功能羅列一下,大體以下:
第一個功能:檢查類型
第二個功能:調試功能,能夠自定義 console
的輸出形式
第三個功能:處理異常的功能(簡單版)
看到上面功能後,咱們想一下函數式思想中有哪些武器能夠被咱們使用到。首先怎麼把不一樣的函數組合在一塊兒。
PS:哈哈哈哈,你看你本身無心識間就說出了組合這個詞。
是的,你真聰明。如今,如何將小函數組合成一個完成特定功能的函數呢?想一下,你會發現,這裏須要用到函數的高階性,要將函數做爲參數傳入多功能函數中。ok
,如今咱們知道實現的大體方向了,下面咱們來嘗試一下吧。
這裏我直接把個人實現過程貼出來了,有相應的註釋,代碼以下:
/** * 多功能函數 * @param {Mixed} value 傳入的數據 * @param {Function} predicate 謂詞,用來進行斷言 * @param {Mixed} tip 默認值是 value */
function tap(value, predicate, tip = value) {
if(predicate(value)) {
log('log', `{type: ${typeof value}, value: ${value} }`, `額外信息:${tip}`)
}
}
const is = {
undef : v => v === null || v === undefined,
notUndef : v => v !== null && v !== undefined,
noString : f => typeof f !== 'string',
noFunc : f => typeof f !== 'function',
noNumber : n => typeof n !== 'number',
noArray : !Array.isArray,
};
function log(level, message, tip) {
console[level].call(console, message, tip)
}
const res1 = {data: {age: '', name: 'godkun'}}
const res2 = {data: {age: 66, name: 'godkun'}}
// 函數的組合,函數的高階
tap(res1.data.age, is.noNumber)
tap(res2.data.age, is.noNumber)
複製代碼
結果圖以下:
會發現當,age
不是 Number
類型的時候,就會打印對應的提示信息,當時 Number
類型的時候,就不會打印信息。
這樣的話,咱們在業務中,就能夠直接寫:
res => {
tap(res.data.age, is.noNumber)
// TODO: 處理 age
}
複製代碼
不用 if
語句,若是有異常,看一下打印信息,會一目瞭然的。
固然這樣寫確定不能放到生產上的,由於 tap
不會阻止後續操做,我這樣寫的緣由是:這個 tap
函數主要是用來開發調試的。
可是,若是須要保證不符合的數據須要直接在 tap
處終止,那能夠在 tap
函數裏面加下 return false
return true
。而後寫成下面代碼的形式:
res => {
// if 語句中的返回值是布爾值
if (tap(res.data.age, is.noNumber)) {
// TODO: 處理 age
}
}
複製代碼
可是這樣寫,會有個很差的地方。那就是用到了 if
語句,用 if
語句也沒什麼很差的。但退一步看 tap
函數,你會發現,仍是不夠複用,函數內,還存在硬編碼的行爲。
以下圖所示:
存在兩點問題:
第一點:把 console
的行爲固定死了,致使不能設置 console.error()
等行爲
第二點:不能拋出異常,就算類型不匹配,也阻止不了後續步驟的執行
怎麼解決呢?
簡單分析一下,這裏咱們通常但願,先採用惰性的思想,讓一個函數肯定好幾個參數,而後,咱們再讓這個函數去調用其餘不固定的參數。這樣作的好處是減小了相同參數的屢次 coding
,由於相同的參數已經內置了,不用我再去傳了。
分析到這,你應該有所感悟。你會發現,這樣的行爲其實就是柯里化,經過將多元函數變成能夠一元函數。同時,經過柯里化,能夠靈活設置好初始化須要提早肯定的參數,大大提升了函數的複用性和靈活性。
對於柯里化,因爲源碼分析篇,我已經分析了 ramda
的柯里化實現原理,這裏我爲了節省代碼,就直接使用 ramda
了。
代碼以下:
const R = require('ramda')
// 其實這裏你能夠站在一個高層去把它們想象成函數的重載
// 經過傳參的不一樣來實現不一樣的功能
const tapThrow = R.curry(_tap)('throw', 'log')
const tapLog = R.curry(_tap)(null, 'log')
function _tap(stop, level, value, predicate, error=value) {
if(predicate(value)) {
if (stop === 'throw') {
log(`${level}`, 'uncaught at check', error)
throw new Error(error)
}
log(`${level}`, `{type: ${typeof value}, value: ${value} }`, `額外信息:${error}`)
}
}
const is = {
undef : v => v === null || v === undefined,
notUndef : v => v !== null && v !== undefined,
noString : f => typeof f !== 'string',
noFunc : f => typeof f !== 'function',
noNumber : n => typeof n !== 'number',
noArray : !Array.isArray,
};
function log(level, message, error) {
console[level].call(console, message, error)
}
const res = {data: {age: '66', name: 'godkun'}}
function main() {
// 不開啓異常忽略,使用 console.log 的 tapLog 函數
// tapLog(res.data.age, is.noNumber)
// 開啓異常忽略,使用 console.log 的 tapThrow 函數
tapThrow(res.data.age, is.noNumber)
console.log('能不能走到這')
}
main()
複製代碼
代碼地址以下:
關鍵註釋,我已經在代碼中標註了。上面代碼在第一次進行函數式優化的時候,在組合和高階的基礎上,加入了柯里化,從而讓函數變得更有複用性。
PS: 具備柯里化的函數,在我看來,也是體現了函數的重載性。
執行結果以下圖所示:
會發現使用 tapThrow
函數時,當類型不匹配的時候,會阻止後續步驟的執行。
此次實踐的總結:
我經過屢次優化,向你們展現了,如何一步步的去優化一個函數。從開始的命令式優化,到後面的函數式優化,從開始的普通函數,到後面的逐步使用了高階、組合、柯里的特性。從開始的有 if/else
語句到後面的逐步幹掉它,來得到更高的複用性。經過這個實戰,小夥伴能夠知道,如何按部就班的使用函數式編程,讓代碼變得更加優秀。
思考題:上面的代碼還能夠繼續優化,這裏就再也不繼續分析了,有興趣的小夥伴能夠自行分享,也能夠和我私聊交流。在鄙人看來,前期先運用高階、組合、柯里化作到這樣,就已經很不錯了。
以前就有各類幹掉 for
循環的文章。各類討論,這裏我按照個人見解來解釋一下,爲何會存在幹掉 for
循環這一說。
代碼以下:
let arr = [1,2,3,4]
for (let i = 0; i < arr.length; i++) {
// TODO: ...
}
複製代碼
咱們看上面這段代碼,我來問一個問題:
上面這段代碼如何複用到其餘的函數中?
稍微想一下,你們確定能夠很快的想出來,那就是封裝成函數,而後在其餘函數中進行調用。
你們爲何會這樣想呢?
是由於 for
循環是一種命令控制結構,你發現它很難被插入到其餘操做中,也發現了 for
循環很難被複用的現實。
其實,當你說出這個答案的時候。這個關於爲何要幹掉 for
循環的討論就已經結束了。
由於你在封裝 for
循環時,就是在抽象 for
循環,就是在把 for
循環給隱藏掉,就是在把過程給隱藏掉,就是在告訴用戶,你只須要調我封裝的函數,而不須要關心內部實現。
因而乎,JS
就誕生了諸如 map
filter
reduce
等這種將循環過程隱藏掉的函數。底層本質上仍是用 for
實現的,只不過是把 for
循環隱藏了,若是按照業界內的說話逼格,就是把 for
循環幹掉了。這就是聲明式編程在前端中的應用之一。因此,其實你們天天都在寫函數式編程,只不過你意識不到而已。
三種方式:
第一種:傳統的循環結構 - 好比 for
循環
第二種:鏈式
第三種:函數式組合
這裏我就不具體舉例子了,很簡單,前面的例子基本都涵蓋了,小夥伴們自行實踐一下。
爲何在編寫函數時,要考慮緩存?
一句話,避免計算重複值。計算就意味着消耗各類資源,而作重複的計算,就是在浪費各類資源。
純潔性和緩存有什麼關係?
咱們想一下能夠知道,純函數老是爲給定的輸入返回相同的輸出,那既然如此,咱們固然要想到能夠緩存函數的輸出。
那如何作函數的緩存呢?
記住一句話:給計算結果賦予惟一的鍵值並持久化到緩存中。
大體 demo
代碼:
function mian(key) {
let cache = {}
cache.hasOwnProperty(key) ?
main(key) :
cache[key] = main(key)
}
複製代碼
上面代碼是一種最簡單的利用純函數來作緩存的例子。下面咱們來實現一個很是完美的緩存函數。
給原生
JS
函數加上自動記憶化的緩存機制
代碼以下:
Function.prototype.memorized = () => {
let key = JSON.stringify(arguments)
// 緩存實現
this._cache = this._cache || {}
this._cache[key] = this._cache[key] || this.apply(this, arguments)
return this._cache[key]
}
Function.prototype.memorize = () => {
let fn = this
// 只記憶一元函數
if (fn.length === 0 || fn.length > 1) return fn
return () => fn.memorized.apply(fn, arguments)
}
複製代碼
代碼地址以下:
經過擴展 Function
對象,咱們就能夠充分利用函數的記憶化來實現函數的緩存。
上面函數緩存實現的好處有如下兩點:
第一:消除了可能存在的全局共享的緩存
第二:將緩存機制抽象到了函數的內部,使其徹底與測試無關,只須要關係函數的行爲便可
如何編寫高質量函數系列文章以下(不包含本篇):
這個系列還在持續更新中,歡迎關注,下一篇是關於設計模式的。
能夠關注個人掘金博客或者 github
來獲取後續的系列文章更新通知。掘金系列技術文章彙總以下,以爲不錯的話,點個 star 鼓勵一下。
我是源碼終結者,歡迎技術交流。
也能夠進 前端狂想錄羣 你們一塊兒頭腦風暴。有想加的,由於人滿了,能夠先加我好友,我來邀請你進羣。
最後:尊重原創,轉載請註明出處哈😋