優(yōu)雅代碼指北 -- 巧用 Ramda[轉(zhuǎn)]

不久前我在掘金發(fā)表了文章如何在 JS 代碼中消滅 for 循環(huán)。文中我寫了很多工具函數(shù),那些工具函數(shù)都能達(dá)到設(shè)計(jì)的目的。但是,很重要一點(diǎn)我還沒(méi)告訴讀者,其實(shí)我是幾乎不用自己寫的那些工具函數(shù)的?? 不是因?yàn)榕伦约簩戝e(cuò)了,而是有更強(qiáng)大好用的替代方案。我今天就介紹一個(gè)我用的最多的工具函數(shù)庫(kù) Ramda,展示怎樣用 Ramda 寫出既簡(jiǎn)潔易讀,又方便擴(kuò)展復(fù)用的代碼。由于時(shí)間和精力有限,我就不解釋我用到的每一個(gè) Ramda 函數(shù)的用法了,大家感興趣的話可以去查官方文檔。

Ramda 有兩個(gè)特性讓它從其它工具庫(kù)中脫穎而出:

  1. 所有 Ramda 函數(shù)都已經(jīng)被柯里化。
  2. 所有 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)格。通過(guò)做到 point free,我們做到了行為和數(shù)據(jù)的分離,這利于我們寫出更安全(組合行為時(shí)沒(méi)有副作用),更易擴(kuò)展(脫離數(shù)據(jù)的邏輯容易復(fù)用),和更易理解(讀高階函數(shù)的組合就像讀普通英文一樣)的代碼。

一,第一個(gè) point free 例子

如果你寫過(guò) 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']);

是不是簡(jiǎn)潔了很多?

二,函數(shù)組合

問(wèn)題: 有這樣一個(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ù)之前一直在組合行為,沒(méi)有改變數(shù)據(jù),沒(méi)有任何副作用。注意 compose 是從后往前組合函數(shù),如果習(xí)慣從前往后組合函數(shù),用 pipe。

再來(lái)一個(gè):

問(wèn)題: 把下面這個(gè)查詢字符串轉(zhuǎn)成對(duì)象:

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ù)制代碼

你可能會(huì)問(wèn),JS 原生都提供 map 方法了,為什么還有用 Ramda 的? 原因是文章開頭提到的,Ramda 函數(shù)有兩個(gè)特厲害的屬性。這個(gè)例子里,給 map 傳一個(gè)回調(diào)函數(shù),它會(huì)返回一個(gè)新函數(shù),等你給它傳數(shù)據(jù)。

三,一個(gè)數(shù)據(jù)用兩次,怎么 point free?

有時(shí)候會(huì)遇到這種情景,根據(jù)一個(gè)數(shù)據(jù)算出結(jié)果,再根據(jù)相同數(shù)據(jù)算出另一個(gè)結(jié)果,然后把兩個(gè)結(jié)果進(jìn)行某種運(yùn)算。比如這個(gè)簡(jiǎn)單例子:

問(wèn)題: 給定一個(gè)用戶對(duì)象,根據(jù)用戶 id 生成頭像地址,并把地址合并到用戶對(duì)象上。

// 合并前
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)足夠簡(jiǎn)潔,但是并沒(méi)有達(dá)到 point free 的要求。數(shù)據(jù) user 提前出現(xiàn)了,而我們期待的是在函數(shù)組合時(shí)不關(guān)心數(shù)據(jù),哪怕是作為參數(shù)。但是,數(shù)據(jù)在計(jì)算過(guò)程中需要多次用到,怎樣在沒(méi)有數(shù)據(jù)(連代表數(shù)據(jù)的參數(shù)都沒(méi)有)的情況下表達(dá)對(duì)未來(lái)數(shù)據(jù)的多次操作?Ramda 提供的 converge 函數(shù)可以解決這個(gè)問(wèn)題:

答案二:

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ù)組先分別傳給 identitygetUrlFromUser 函數(shù),然后把這兩個(gè)函數(shù)的計(jì)算結(jié)果分別傳給 assoc("avatar")。identity 可能是最無(wú)聊的函數(shù),它長(zhǎng)這樣:

const identity = x => x;

我們要保留 user 數(shù)據(jù)不動(dòng),然后傳給 assoc("avatar") 作為第二個(gè)參數(shù),所以用了 identity。

四,方法和數(shù)據(jù)耦合在一起,怎么 point free ?

有些時(shí)候方法就在數(shù)據(jù)上。比如用 jQuery 選中 DOM 元素后,對(duì) DOM 元素進(jìn)行操作的方法。假設(shè) DOM 上有個(gè) <div id = "el1"></div>,用 jQuery 選中元素后,執(zhí)行某個(gè)動(dòng)畫效果:

 $('#el1')
   .animate({left:'250px'})
   .animate({left:'10px'})
   .slideUp()

jQuery 的方法全在選中 DOM 元素后生成的對(duì)象上,方法是沒(méi)法離開數(shù)據(jù)的。但這并不影響我們?cè)跀?shù)據(jù)還沒(méi)給到之前組合行為。Ramda 提供了 invoker 函數(shù)解決類似問(wèn)題:

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ù)表示要在對(duì)象上執(zhí)行的函數(shù)接受多少個(gè)參數(shù),第二個(gè)參數(shù)表示要在對(duì)象上執(zhí)行的函數(shù)的名字,第三個(gè)參數(shù)是目標(biāo)對(duì)象。constructN 是用來(lái)實(shí)例化一個(gè)構(gòu)造函數(shù)或者類。

五,強(qiáng)大的 lens

lens 是從函數(shù)式編程語(yǔ)言借來(lái)的一個(gè)概念,它相當(dāng)于對(duì)某個(gè)屬性的聚焦。比如 lensProp('a'),就是對(duì) a 屬性的聚焦,不管這個(gè) a 屬性由哪個(gè)對(duì)象提供。聚焦之后,我們可以很方便的讀取屬性(view)和改變屬性(over)注意,Ramda 中所有改變值的操作都不是真的在原數(shù)據(jù)基礎(chǔ)上改,而是返回改了指定屬性的新值。

舉個(gè)很簡(jiǎn)單的例子。

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 有什么用。來(lái)看下實(shí)際使用場(chǎng)景:

問(wèn)題:

用 React 寫一個(gè)簡(jiǎn)單 counter demo,點(diǎn)擊 + 和 — 按鈕時(shí),計(jì)數(shù)器對(duì)應(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ù)語(yǔ)了,如果有時(shí)間,未來(lái)我可能會(huì)解釋什么是 monad。大家常用的 Promise 就是個(gè) monad,通過(guò)運(yùn)行 Promise 得到值之后,你并不能在 Promise 外面操作值,而是必須在 then 方法里面處理。這就是 monad 的最大特征(它里層會(huì)返回同樣的 Type,一層層嵌套,必須通過(guò)某種 flatMap 機(jī)制將里層的值取出)。

首先,最常見的是組合 Promise。來(lái)看例子。

假設(shè)我們先根據(jù)用戶 email 地址請(qǐng)求得到用戶信息,然后再根據(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)勢(shì)更明顯。

高能預(yù)警:

上面的內(nèi)容已經(jīng)足以覆蓋大部分函數(shù)組合的需求。接下來(lái)要講的一種函數(shù)組合算是比較硬核的函數(shù)式編程了,感興趣的可以接著看,沒(méi)興趣的可以跳過(guò)了。

除了對(duì) Promise 的組合,Ramda 還提供更泛的 monad 組合,叫 Kleisli Composition。暫時(shí)不用知道這玩意是什么,知道它是對(duì)各種 monad 的組合就行了。我們以 Maybe Monad 為例來(lái)看 Kleisli Composition:

可能讀者在看到函數(shù)組合時(shí)會(huì)有疑問(wèn),如果某個(gè)函數(shù)有可能返回空值,還怎么組合?在每個(gè)后續(xù)函數(shù)前都做空值判斷?那就真不優(yōu)雅了。函數(shù)式編程提供了 Maybe Monad 進(jìn)行空值處理,Maybe 可以和其它 monad 正常組合。

來(lái)看例子:

假設(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 屬性不存在,程序會(huì)返回 'Error ocurred'

此篇文章轉(zhuǎn)自優(yōu)秀的leihuang同學(xué)

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容