我眼中的JavaScript函數(shù)式編程
JavaScript 函數(shù)式編程是一個(gè)存在了很久的話題,但似乎從 2016 年開始,它變得越來越火熱。這可能是因?yàn)?ES6 語法對(duì)于函數(shù)式編程更為友好,也可能是因?yàn)橹T如 RxJS (ReactiveX) 等函數(shù)式框架的流行。
看過許多關(guān)于函數(shù)式編程的講解,但是其中大部分是停留在理論層面,還有一些是僅針對(duì) Haskell 等純函數(shù)式編程語言的。而本文旨在聊一聊我眼中的函數(shù)式編程在 JavaScript 中的具體實(shí)踐,之所以是 “我眼中的” 即我所說的僅代表個(gè)人觀點(diǎn),可能和部分 嚴(yán)格概念 是有沖突的。
本文將略去一大堆形式化的概念介紹,重點(diǎn)展示在 JavaScript 中到底什么是函數(shù)式的代碼、函數(shù)式代碼與一般寫法有什么區(qū)別、函數(shù)式的代碼能給我們帶來什么好處以及常見的一些函數(shù)式模型都有哪些。
我理解的函數(shù)式編程
我認(rèn)為函數(shù)式編程可以理解為,以函數(shù)作為主要載體的編程方式,用函數(shù)去拆解、抽象一般的表達(dá)式
與命令式相比,這樣做的好處在哪?主要有以下幾點(diǎn):
- 語義更加清晰
- 可復(fù)用性更高
- 可維護(hù)性更好
- 作用域局限,副作用少
基本的函數(shù)式編程
下面例子是一個(gè)具體的函數(shù)式體現(xiàn)
// 數(shù)組中每個(gè)單詞,首字母大寫
// 一般寫法
const arr = ['apple', 'pen', 'apple-pen'];
for(const i in arr){
const c = arr[i][0];
arr[i] = c.toUpperCase() + arr[i].slice(1);
}
console.log(arr);
// 函數(shù)式寫法一
function upperFirst(word) {
return word[0].toUpperCase() + word.slice(1);
}
function wordToUpperCase(arr) {
return arr.map(upperFirst);
}
console.log(wordToUpperCase(['apple', 'pen', 'apple-pen']));
// 函數(shù)式寫法二
console.log(arr.map(['apple', 'pen', 'apple-pen'], word => word[0].toUpperCase() + word.slice(1)));
當(dāng)情況變得更加復(fù)雜時(shí),表達(dá)式的寫法會(huì)遇到幾個(gè)問題:
- 表意不明顯,逐漸變得難以維護(hù)
- 復(fù)用性差,會(huì)產(chǎn)生更多的代碼量
- 會(huì)產(chǎn)生很多中間變量
函數(shù)式編程很好的解決了上述問題。首先參看 函數(shù)式寫法一,它利用了函數(shù)封裝性將功能做拆解(粒度不***),并封裝為不同的函數(shù),而再利用組合的調(diào)用達(dá)到目的。這樣做使得表意清晰,易于維護(hù)、復(fù)用以及擴(kuò)展。其次利用 高階函數(shù),Array.map
代替 for…of
做數(shù)組遍歷,減少了中間變量和操作。
而 函數(shù)式寫法一 和 函數(shù)式寫法二 之間的主要差別在于,可以考慮函數(shù)是否后續(xù)有復(fù)用的可能,如果沒有,則后者更優(yōu)。
鏈?zhǔn)絻?yōu)化
從上面 函數(shù)式寫法二 中我們可以看出,函數(shù)式代碼在寫的過程中,很容易造成 橫向延展,即產(chǎn)生多層嵌套,下面我們舉個(gè)比較極端點(diǎn)的例子。
// 計(jì)算數(shù)字之和
// 一般寫法
console.log(1 + 2 + 3 - 4)
// 函數(shù)式寫法
function sum(a, b) {
return a + b;
}
function sub(a, b) {
return a - b;
}
console.log(sub(sum(sum(1, 2), 3), 4);
本例僅為展示 橫向延展 的比較極端的情況,隨著函數(shù)的嵌套層數(shù)不斷增多,導(dǎo)致代碼的可讀性大幅下降,還很容易產(chǎn)生錯(cuò)誤。
在這種情況下,我們可以考慮多種優(yōu)化方式,比如下面的 鏈?zhǔn)絻?yōu)化 。
// 優(yōu)化寫法 (嗯,你沒看錯(cuò),這就是 lodash 的鏈?zhǔn)綄懛?
const utils = {
chain(a) {
this._temp = a;
return this;
},
sum(b) {
this._temp += b;
return this;
},
sub(b) {
this._temp -= b;
return this;
},
value() {
const _temp = this._temp;
this._temp = undefined;
return _temp;
}
};
console.log(utils.chain(1).sum(2).sum(3).sub(4).value());
這樣改寫后,結(jié)構(gòu)會(huì)整體變得比較清晰,而且鏈的每一環(huán)在做什么也可以很容易的展現(xiàn)出來。函數(shù)的嵌套和鏈?zhǔn)降膶?duì)比還有一個(gè)很好的例子,那就是 回調(diào)函數(shù) 和 Promise 模式。
// 順序請(qǐng)求兩個(gè)接口
// 回調(diào)函數(shù)
import $ from 'jquery';
$.post('a/url/to/target', (rs) => {
if(rs){
$.post('a/url/to/another/target', (rs2) => {
if(rs2){
$.post('a/url/to/third/target');
}
});
}
});
// Promise
import request from 'catta'; // catta 是一個(gè)輕量級(jí)請(qǐng)求工具,支持 fetch,jsonp,ajax,無依賴
request('a/url/to/target')
.then(rs => rs ? $.post('a/url/to/another/target') : Promise.reject())
.then(rs2 => rs2 ? $.post('a/url/to/third/target') : Promise.reject());
隨著回調(diào)函數(shù)嵌套層級(jí)和單層復(fù)雜度增加,它將會(huì)變得臃腫且難以維護(hù),而 Promise
的鏈?zhǔn)浇Y(jié)構(gòu),在高復(fù)雜度時(shí),仍能縱向擴(kuò)展,而且層次隔離很清晰。
常見的函數(shù)式編程模型
閉包(Closure)
可以保留局部變量不被釋放的代碼塊,被稱為一個(gè)閉包
閉包的概念比較抽象,相信大家都或多或少知道、用到這個(gè)特性
那么閉包到底能給我們帶來什么好處?
先來看一下如何創(chuàng)建一個(gè)閉包:
// 創(chuàng)建一個(gè)閉包
function makeCounter() {
let k = 0;
return function() {
return ++k;
};
}
const counter = makeCounter();
console.log(counter()); // 1
console.log(counter()); // 2
makeCounter
這個(gè)函數(shù)的代碼塊,在返回的函數(shù)中,對(duì)局部變量 k
,進(jìn)行了引用,導(dǎo)致局部變量無法在函數(shù)執(zhí)行結(jié)束后,被系統(tǒng)回收掉,從而產(chǎn)生了閉包。而這個(gè)閉包的作用就是,“保留住“ 了局部變量,使內(nèi)層函數(shù)調(diào)用時(shí),可以重復(fù)使用該變量;而不同于全局變量,該變量只能在函數(shù)內(nèi)部被引用。
換句話說,閉包其實(shí)就是創(chuàng)造出了一些函數(shù)私有的 ”持久化變量“。
所以從這個(gè)例子,我們可以總結(jié)出,閉包的創(chuàng)造條件是:
- 存在內(nèi)、外兩層函數(shù)
- 內(nèi)層函數(shù)對(duì)外層函數(shù)的局部變量進(jìn)行了引用
閉包的用途
閉包的主要用途就是可以定義一些作用域局限的持久化變量,這些變量可以用來做緩存或者計(jì)算的中間量等等。
// 簡單的緩存工具
// 匿名函數(shù)創(chuàng)造了一個(gè)閉包
const cache = (function() {
const store = {};
return {
get(key) {
return store[key];
},
set(key, val) {
store[key] = val;
}
}
}());
cache.set('a', 1);
cache.get('a'); // 1
上面例子是一個(gè)簡單的緩存工具的實(shí)現(xiàn),匿名函數(shù)創(chuàng)造了一個(gè)閉包,使得 store
對(duì)象 ,一直可以被引用,不會(huì)被回收。
閉包的弊端
持久化變量不會(huì)被正常釋放,持續(xù)占用內(nèi)存空間,很容易造成內(nèi)存浪費(fèi),所以一般需要一些額外手動(dòng)的清理機(jī)制。
高階函數(shù)
接受或者返回一個(gè)函數(shù)的函數(shù)稱為高階函數(shù)
聽上去很高冷的一個(gè)詞匯,但是其實(shí)我們經(jīng)常用到,只是原來不知道他們的名字而已。JavaScript 語言是原生支持高階函數(shù)的,因?yàn)?JavaScript 的函數(shù)是一等公民,它既可以作為參數(shù)又可以作為另一個(gè)函數(shù)的返回值使用。
我們經(jīng)??梢栽?JavaScript 中見到許多原生的高階函數(shù),例如 Array.map
, Array.reduce
, Array.filter
下面以 map
為例,我們看看他是如何使用的
map (映射)
映射是對(duì)集合而言的,即把集合的每一項(xiàng)都做相同的變換,產(chǎn)生一個(gè)新的集合
map
作為一個(gè)高階函數(shù),他接受一個(gè)函數(shù)參數(shù)作為映射的邏輯
// 數(shù)組中每一項(xiàng)加一,組成一個(gè)新數(shù)組
// 一般寫法
const arr = [1,2,3];
const rs = [];
for(const n of arr){
rs.push(++n);
}
console.log(rs)
// map改寫
const arr = [1,2,3];
const rs = arr.map(n => ++n);
上面一般寫法,利用 for...of
循環(huán)的方式遍歷數(shù)組會(huì)產(chǎn)生額外的操作,而且有改變?cè)瓟?shù)組的風(fēng)險(xiǎn)
而 map
函數(shù)封裝了必要的操作,使我們僅需要關(guān)心映射邏輯的函數(shù)實(shí)現(xiàn)即可,減少了代碼量,也降低了副作用產(chǎn)生的風(fēng)險(xiǎn)。
柯里化(Currying)
給定一個(gè)函數(shù)的部分參數(shù),生成一個(gè)接受其他參數(shù)的新函數(shù)
可能不常聽到這個(gè)名詞,但是用過 undescore
或 lodash
的人都見過他。
有一個(gè)神奇的 _.partial
函數(shù),它就是柯里化的實(shí)現(xiàn)
// 獲取目標(biāo)文件對(duì)基礎(chǔ)路徑的相對(duì)路徑
// 一般寫法
const BASE = '/path/to/base';
const relativePath = path.relative(BASE, '/some/path');
// _.parical 改寫
const BASE = '/path/to/base';
const relativeFromBase = _.partial(path.relative, BASE);
const relativePath = relativeFromBase('/some/path');
通過 _.partial
,我們得到了新的函數(shù) relativeFromBase
,這個(gè)函數(shù)在調(diào)用時(shí)就相當(dāng)于調(diào)用 path.relative
,并默認(rèn)將***個(gè)參數(shù)傳入 BASE
,后續(xù)傳入的參數(shù)順序后置。
本例中,我們真正想完成的操作是每次獲得相對(duì)于 BASE
的路徑,而非相對(duì)于任何路徑??吕锘梢允刮覀冎魂P(guān)心函數(shù)的部分參數(shù),使函數(shù)的用途更加清晰,調(diào)用更加簡單。
組合(Composing)
將多個(gè)函數(shù)的能力合并,創(chuàng)造一個(gè)新的函數(shù)
同樣你***次見到他可能還是在 lodash
中,compose
方法(現(xiàn)在叫 flow
)
// 數(shù)組中每個(gè)單詞大寫,做 Base64
// 一般寫法 (其中一種)
const arr = ['pen', 'apple', 'applypen'];
const rs = [];
for(const w of arr){
rs.push(btoa(w.toUpperCase()));
}
console.log(rs);
// _.flow 改寫
const arr = ['pen', 'apple', 'applypen'];
const upperAndBase64 = _.partialRight(_.map, _.flow(_.upperCase, btoa));
console.log(upperAndBase64(arr));
_.flow
將轉(zhuǎn)大寫和轉(zhuǎn) Base64 的函數(shù)的能力合并,生成一個(gè)新的函數(shù)。方便作為參數(shù)函數(shù)或后續(xù)復(fù)用。
自己的觀點(diǎn)
我理解的 JavaScript 函數(shù)式編程,可能和許多傳統(tǒng)概念不同。我并不只認(rèn)為 高階函數(shù) 算函數(shù)式編程,其他的諸如普通函數(shù)結(jié)合調(diào)用、鏈?zhǔn)浇Y(jié)構(gòu)等,我都認(rèn)為屬于函數(shù)式編程的范疇,只要他們是以函數(shù)作為主要載體的。
而我認(rèn)為函數(shù)式編程并不是必須的,它也不應(yīng)該是一個(gè)強(qiáng)制的規(guī)定或要求。與面向?qū)ο蠡蚱渌枷胍粯?,它也是其中一種方式。我們更多情況下,應(yīng)該是幾者的結(jié)合,而不是局限于概念。