不久前我在掘金發(fā)表了文章如何在 JS 代碼中消滅 for 循環(huán)。文中我寫了很多工具函數(shù),那些工具函數(shù)都能達(dá)到設(shè)計(jì)的目的复罐。但是,很重要一點(diǎn)我還沒告訴讀者雄家,其實(shí)我是幾乎不用自己寫的那些工具函數(shù)的?? 不是因?yàn)榕伦约簩戝e(cuò)了市栗,而是有更強(qiáng)大好用的替代方案。我今天就介紹一個(gè)我用的最多的工具函數(shù)庫 Ramda咳短,展示怎樣用 Ramda 寫出既簡潔易讀,又方便擴(kuò)展復(fù)用的代碼蛛淋。由于時(shí)間和精力有限咙好,我就不解釋我用到的每一個(gè) Ramda 函數(shù)的用法了,大家感興趣的話可以去查官方文檔褐荷。
Ramda 有兩個(gè)特性讓它從其它工具庫中脫穎而出:
- 所有 Ramda 函數(shù)都已經(jīng)被柯里化勾效。
- 所有 Ramda 函數(shù)都把數(shù)據(jù)作為最后一個(gè)參數(shù)傳入。
這兩個(gè)特征讓我們可以很輕松利用 Ramda 寫出 "point free" 風(fēng)格的代碼叛甫。所謂 point 就是指作為參數(shù)傳進(jìn)函數(shù)的數(shù)據(jù)层宫。point free 就是脫離數(shù)據(jù)的代碼風(fēng)格。通過做到 point free其监,我們做到了行為和數(shù)據(jù)的分離萌腿,這利于我們寫出更安全(組合行為時(shí)沒有副作用),更易擴(kuò)展(脫離數(shù)據(jù)的邏輯容易復(fù)用)抖苦,和更易理解(讀高階函數(shù)的組合就像讀普通英文一樣)的代碼毁菱。
一,第一個(gè) point free 例子
如果你寫過 React + Redux 項(xiàng)目锌历,你可能經(jīng)常寫這種代碼:
function mapStateToProps(state) {
return {
board: state.board,
nextToken: state.nextToken
}
}
我們可以用 Ramda 的 pick
函數(shù)改寫一下:
import { pick } from 'ramda';
function mapStateToProps(state) {
return pick(['board', 'nextToken'], state)
}
繼續(xù)改寫成 point free 風(fēng)格贮庞,可以寫成這樣:
const mapStateToProps = pick(['board', 'nextToken']);
是不是簡潔了很多?
二究西,函數(shù)組合
問題: 有這樣一個(gè)數(shù)組:
const teams = [
{name: 'Lions', score: 5},
{name: 'Tigers', score: 4},
{name: 'Bears', score: 6},
{name: 'Monkeys', score: 2},
]
復(fù)制代碼
要求找出分?jǐn)?shù)最高的小組窗慎,并取到名字。
答案:
import { compose, head, sort, descend, prop } from "ramda";
const teams = [
{name: 'Lions', score: 5},
{name: 'Tigers', score: 4},
{name: 'Bears', score: 6},
{name: 'Monkeys', score: 2},
];
const sortByScoreDesc = sort(descend(prop("score")));
const getName = prop("name");
const findTheBestTeam = compose(
getName,
head,
sortByScoreDesc
);
findTheBestTeam(teams) // => Bears
復(fù)制代碼
稍微感受一下卤材。數(shù)據(jù)是在最后一步才提供的遮斥,提供目標(biāo)數(shù)據(jù)之前一直在組合行為,沒有改變數(shù)據(jù)商膊,沒有任何副作用伏伐。注意 compose
是從后往前組合函數(shù),如果習(xí)慣從前往后組合函數(shù)晕拆,用 pipe
岛杀。
再來一個(gè):
問題: 把下面這個(gè)查詢字符串轉(zhuǎn)成對象:
const queryString = "?page=2&pageSize=10&total=203";
復(fù)制代碼
答案:
import { compose, fromPairs, map, split, tail } from "ramda";
const queryString = "?page=2&pageSize=10&total=203";
const parseQs = compose(
fromPairs,
map(split("=")),
split("&"),
tail
);
const result = parseQs(queryString); // => { page: '2', pageSize: '10', total: '203' }
復(fù)制代碼
你可能會問,JS 原生都提供 map
方法了尽爆,為什么還有用 Ramda 的兼贸? 原因是文章開頭提到的,Ramda 函數(shù)有兩個(gè)特厲害的屬性谨胞。這個(gè)例子里,給 map
傳一個(gè)回調(diào)函數(shù),它會返回一個(gè)新函數(shù)闸溃,等你給它傳數(shù)據(jù)。
三拱撵,一個(gè)數(shù)據(jù)用兩次辉川,怎么 point free?
有時(shí)候會遇到這種情景拴测,根據(jù)一個(gè)數(shù)據(jù)算出結(jié)果乓旗,再根據(jù)相同數(shù)據(jù)算出另一個(gè)結(jié)果,然后把兩個(gè)結(jié)果進(jìn)行某種運(yùn)算集索。比如這個(gè)簡單例子:
問題: 給定一個(gè)用戶對象屿愚,根據(jù)用戶 id 生成頭像地址,并把地址合并到用戶對象上务荆。
// 合并前
const user = {
id: 1,
name: 'Joe'
}
// 合并后
{
id: 1,
name: 'Joe',
avatar: 'https://img.socialnetwork.com/avatar/1.png'
}
答案一:
const generateUrl = id => `https://img.socialnetwork.com/avatar/${id || 'default'}.png`;
const getUpdatedUser = user => ({ ...user, avatar: generateUrl(user.id) });
getUpdatedUser(user);
這個(gè)方案已經(jīng)足夠簡潔妆距,但是并沒有達(dá)到 point free 的要求。數(shù)據(jù) user 提前出現(xiàn)了函匕,而我們期待的是在函數(shù)組合時(shí)不關(guān)心數(shù)據(jù)娱据,哪怕是作為參數(shù)。但是盅惜,數(shù)據(jù)在計(jì)算過程中需要多次用到吸耿,怎樣在沒有數(shù)據(jù)(連代表數(shù)據(jù)的參數(shù)都沒有)的情況下表達(dá)對未來數(shù)據(jù)的多次操作?Ramda 提供的 converge
函數(shù)可以解決這個(gè)問題:
答案二:
import { compose, converge, propOr, assoc, identity } from "ramda";
const user = {
id: 1,
name: "Joe"
};
const generateUrl = id => `https://img.socialnetwork.com/avatar/${id}.png`;
const getUrlFromUser = compose(
generateUrl,
propOr("default", "id")
);
const getUpdatedUser = converge(assoc("avatar"), [
getUrlFromUser,
identity
]);
getUpdatedUser(user)
converge
函數(shù)接受兩個(gè)參數(shù)酷窥,第一個(gè)參數(shù)是最終執(zhí)行的函數(shù)咽安,第二個(gè)參數(shù)是由作用于傳入數(shù)據(jù)的變形函數(shù)組成的數(shù)組。在這個(gè)例子里蓬推,user
數(shù)組先分別傳給 identity
和 getUrlFromUser
函數(shù)妆棒,然后把這兩個(gè)函數(shù)的計(jì)算結(jié)果分別傳給 assoc("avatar")
。identity
可能是最無聊的函數(shù)沸伏,它長這樣:
const identity = x => x;
我們要保留 user
數(shù)據(jù)不動糕珊,然后傳給 assoc("avatar")
作為第二個(gè)參數(shù),所以用了 identity
毅糟。
四红选,方法和數(shù)據(jù)耦合在一起,怎么 point free 姆另?
有些時(shí)候方法就在數(shù)據(jù)上喇肋。比如用 jQuery 選中 DOM 元素后坟乾,對 DOM 元素進(jìn)行操作的方法。假設(shè) DOM 上有個(gè) <div id = "el1"></div>
蝶防,用 jQuery 選中元素后甚侣,執(zhí)行某個(gè)動畫效果:
$('#el1')
.animate({left:'250px'})
.animate({left:'10px'})
.slideUp()
jQuery 的方法全在選中 DOM 元素后生成的對象上,方法是沒法離開數(shù)據(jù)的间学。但這并不影響我們在數(shù)據(jù)還沒給到之前組合行為殷费。Ramda 提供了 invoker
函數(shù)解決類似問題:
import { invoker, compose, constructN } from "ramda";
const animate = invoker(1, "animate");
const slide = invoker(0, "slideUp");
const jq = constructN(1, $);
const animateDiv = compose(
slide,
animate({ left: "10px" }),
animate({ left: "250px" }),
jq
);
animateDiv("#el1");
animateDiv("#el2");
invoker
函數(shù)接受3個(gè)參數(shù)。第一個(gè)參數(shù)表示要在對象上執(zhí)行的函數(shù)接受多少個(gè)參數(shù)低葫,第二個(gè)參數(shù)表示要在對象上執(zhí)行的函數(shù)的名字详羡,第三個(gè)參數(shù)是目標(biāo)對象。constructN
是用來實(shí)例化一個(gè)構(gòu)造函數(shù)或者類嘿悬。
五殷绍,強(qiáng)大的 lens
lens
是從函數(shù)式編程語言借來的一個(gè)概念,它相當(dāng)于對某個(gè)屬性的聚焦鹊漠。比如 lensProp('a')
,就是對 a 屬性的聚焦茶行,不管這個(gè) a 屬性由哪個(gè)對象提供躯概。聚焦之后,我們可以很方便的讀取屬性(view
)和改變屬性(over
)注意畔师,Ramda 中所有改變值的操作都不是真的在原數(shù)據(jù)基礎(chǔ)上改娶靡,而是返回改了指定屬性的新值。
舉個(gè)很簡單的例子看锉。
import {lensProp, view, over, toUpper} from 'ramda';
const person = {
firstName: 'Fred',
lastName: 'Flintstone'
}
const fLens = lensProp('firstName')
const firstName = view(fLens, person) // => 'Fred'
const result = over(fLens, toUpper, person)
// => {firstName: 'FRED', lastName: 'Flintstone'}
上面例子還不能看出 lens
有什么用姿锭。來看下實(shí)際使用場景:
問題:
用 React 寫一個(gè)簡單 counter demo,點(diǎn)擊 + 和 — 按鈕時(shí)伯铣,計(jì)數(shù)器對應(yīng)加 1 和減 1呻此。
lens
用在 React 的 setState
里非常方便:
import {inc, dec, lensProp, over} from 'ramda'
const countL = lensProp('count')
const transformCount = over(countL)
const incCount = transformCount(inc)
const decCount = transformCount(dec)
// ... 其它細(xì)節(jié)
state = {
count: 0
}
increase = () => {
this.setState(incCount);
};
decrease = () => {
this.setState(decCount);
};
// ... 其它細(xì)節(jié)
lens
與 React 的配合,最能發(fā)揮作用的情景是在寫函數(shù)式組件的時(shí)候腔寡。有興趣可以參考這個(gè) Demo
六焚鲜,更高階的函數(shù)組合
前面提到的內(nèi)容都是常規(guī)的函數(shù)組合,Ramda 還提供了 monad 的組合放前。抱歉要扔術(shù)語了忿磅,如果有時(shí)間,未來我可能會解釋什么是 monad凭语。大家常用的 Promise 就是個(gè) monad葱她,通過運(yùn)行 Promise 得到值之后,你并不能在 Promise 外面操作值似扔,而是必須在 then
方法里面處理吨些。這就是 monad 的最大特征(它里層會返回同樣的 Type搓谆,一層層嵌套,必須通過某種 flatMap 機(jī)制將里層的值取出)锤灿。
首先挽拔,最常見的是組合 Promise。來看例子但校。
假設(shè)我們先根據(jù)用戶 email 地址請求得到用戶信息螃诅,然后再根據(jù)用戶 ID 得到用戶粉絲數(shù)。
// 獲取用戶信息的異步函數(shù)
const ajaxForUserInfo = userEmail => fetch(/* post request */);
// 獲取用戶粉絲的異步函數(shù)
const ajaxForUserFollowers = id => fetch(/* post request*/);
const fetchUserFollowers = async userEmail => {
const userInfo = await ajaxForUserInfo(userEmail);
const userFollowers = await ajaxForUserFollowers(userInfo.id);
return userFollowers;
};
用 Ramda 提供的 composeP
状囱,可以組合上面兩個(gè) Promise:
import {composeP} from 'ramda';
const ajaxForUserFollowers = composeP(
ajaxForUserFollowers,
ajaxForUserInfo
);
const fetchUserFollowers = async email => {
const userFollowers = await ajaxUserFollowers(email);
return userFollowers;
};
上面例子只有兩個(gè) Promise 需要組合术裸。如果是多個(gè)的話,組合的優(yōu)勢更明顯亭枷。
高能預(yù)警:
上面的內(nèi)容已經(jīng)足以覆蓋大部分函數(shù)組合的需求袭艺。接下來要講的一種函數(shù)組合算是比較硬核的函數(shù)式編程了,感興趣的可以接著看叨粘,沒興趣的可以跳過了猾编。
除了對 Promise 的組合,Ramda 還提供更泛的 monad 組合升敲,叫 Kleisli Composition答倡。暫時(shí)不用知道這玩意是什么,知道它是對各種 monad 的組合就行了驴党。我們以 Maybe Monad 為例來看 Kleisli Composition:
可能讀者在看到函數(shù)組合時(shí)會有疑問瘪撇,如果某個(gè)函數(shù)有可能返回空值,還怎么組合港庄?在每個(gè)后續(xù)函數(shù)前都做空值判斷倔既?那就真不優(yōu)雅了。函數(shù)式編程提供了 Maybe Monad 進(jìn)行空值處理鹏氧,Maybe 可以和其它 monad 正常組合渤涌。
來看例子:
假設(shè)我們有這樣一個(gè) JSON 字符串需要解析
'{"user": {"address": {"state": "in"}}}'
我們需要取到用戶的任何一個(gè)深度的地址,而每一層獲取地址都可能失敗把还。所以歼捏,每一次取值,我們都要做空值處理笨篷。
// 解析 JSON 的函數(shù)瞳秽,做了錯(cuò)誤處理
function parse(s) {
try {
return JSON.parse(s);
} catch (e) {
return null;
}
}
import Maybe from "folktale/maybe";
import { compose, composeK, toUpper } from "ramda";
// 解析 JSON 可能返回 null,所以把結(jié)果放到 Maybe 里面
const maybeParseJson = json => Maybe.fromNullable(parse(json));
// 獲取屬性也可能返回空值率翅,把結(jié)果放到 Maybe 里面
const maybeProp = prop => obj => Maybe.fromNullable(obj[prop]);
// 傳給 toUpper 的值可能不是字符串练俐,也要把結(jié)果放到 Maybe
const maybeUpper = compose(
Maybe.of,
toUpper
);
// composeK 代表 Kleisli composition
const getStateCode = composeK(
maybeUpper,
maybeProp("city"),
maybeProp("state"),
maybeProp("address"),
maybeProp("user"),
maybeParseJson
);
const s = '{"user": {"address": {"state": "in"}}}';
getStateCode(s).getOrElse("Error ocurred");
// city 屬性不存在,程序會返回 'Error ocurred'
此篇文章轉(zhuǎn)自優(yōu)秀的leihuang同學(xué)