半熟前端

軟體工程師 / 台灣人 / 在前端的路上一邊探索其他領域的可能性

前端

淺談 ramda 中的幾個 API

前言

ramda 是個相當好用的函式庫,如果聽過 lodash 或是 underscore 的話,可以將 ramda 想成 functional programming 的 lodash,他們的 API 有許多相似性,差別在於 ramda 本身有 FP 的功能,任何的 API 只要你沒有傳入參數,ramda 就會自動幫你做 curry,這提供了相當大的彈性。

比如說在 lodash 當中,API 常見的使用方式為:

_.map([1, 2, 3], n => n * 2) // [2, 4, 6]

而在 ramda 當中則是:

R.map(n => n * 2, [1, 2, 3]) // [2, 4, 6]

或者你可以這樣寫:

const times2 = R.map(n => n * 2) // return function
times2([1, 2, 3]) // [2, 4, 6]

這種方式讓我們不用被資料綁死,可以提高復用性。

如果你想要瞭解更多有關於 FP 的資訊,我推薦這篇文章:函數式程序設計為什麼至關重要

直觀 functional programming

  • 一樣的 input 會輸出一樣的 output,不會受到外在狀態而改變結果
  • 沒有副作用

為什麼選擇 Ramda

ramda 的 API 相當多,大部分的 API 也都非常直觀,所以我們也不需要一一細談。不過 ramda 中有許多優秀的 API 或許可以幫助你減少開發上的複雜度,以下介紹幾個我覺得值得一看的 API

propEq

接受字串當作屬性,比對傳入的物件屬性值是否相等。

const obj = {
  name: "kalan",
}

propEq("name", "kalan")(obj) // true

// 等價於
const propEq = (name, value) => obj => {
  return obj[name] === value
}

zipObj

將傳入的參數壓縮為 object

R.zipObj(["id", "title"], ["2", "mytitle"])
/*
{
  id: '2',
  title: 'mytitle'
}
*/

ifElse

在做 branch 的邏輯時相當好用,你可能會問,幹嘛不直接用 if…else 就好?ramda 當中的 API 都是回傳函數,這代表你可以用 compose 來組裝其他 API。

compose

將函式組合,執行的順序是由內到外。可以把它聯想為國高中很常見的函數題 f(g(h(x)))。所以要先計算 h(x) 的值,再依序下去。

const a = compose(
  toInteger,
  toCurrency("TWD"),
  toUppercase
)("125000")

useWith

接收一個函數跟函數陣列。將資料丟入函數陣列運算出來的結果,再丟入第一個函數當中。

善用 useWith 可以幫助我們達到 point free 的效果。

const currencies = [
  { name: "TWD", shape: "$" },
  { name: "USD", shape: "$" },
  { name: "JPY", shape: "¥" },
  { name: "CAD", shape: "$" },
]

// without useWith
const getCurrency = (name, dic) => R.find(R.propEq("name", name), dic)

getCurrency("TWD", currencies) // $

// with useWith
const getCurrency = R.useWith(R.find, [R.propEq("name"), R.identity]) // 將第一個參數傳入 R.propEq,第二個參數傳入 R.identity,運算後的結果分別丟給 R.find 的第一與第二個參數。

getCurrency("TWD", currencies)

使用 useWith 後消除了 name dic 這兩個參數。

fn1

converge

這個函數跟上面的 useWith 有點類似,不過 converge 只接收一個參數。下面這張圖可以很直觀地看出這兩者的差別。

const numbers = [1, 2, 5, 8, 10]

const getRange = R.converge(substract, [getFirst, getLast])(numbers) // return 9

fn2

identity

有點太直觀了......,反而不知道怎麼解釋。直接用程式碼解釋比較快。

const identify = arg => arg

至於為什麼要這樣做呢?有時你可能需要組織你的函數達到 chainable,這時 identity 這個函數就能夠派上用場。

tap

傳入參數給指定的函數,然後回傳值。這在 debug 或是串接其他第三方的時候相當好用。

tap(console.log)("hello world") // 傳入 hello world 給 console.log,並且回傳 hello world 這個值

上面的例子可能看不出來 tap 的用處,我們用搭配 compose 一起使用

const uploadToMedium = article => API.postArticle(article)
const notifyAdmin = article => API.notify(article, subscribers)
const log = article => Logger.log(article)
const preprocessArticle = article => article.toLowerCase()

const publishPostFlow = article =>
  compose(
    preprocessArticle,
    R.tap(uploadToMedium),
    R.tap(notifyAdmin),
    R.tap(log)
  )

publishPostFlow(article)

這樣一來我們可以很方便地串接其他服務,而不必再撰寫類似 return article 這樣的程式碼,一方面可以減少樣板代碼的麻煩,一方面可以減少出錯的機率。

pluck

跟字面上的意思差不多,將指定參數的 value 摘取下來變成新的值。在巢狀物件取值時很有幫助,例如:

const data = [
  {
    id: "1",
    content: "content...",
  },
  {
    id: "2",
    content: "content...",
  },
  {
    id: "3",
    content: "content...",
  },
]

const getIds = R.pluck("id", data) // return ['1','2','3']

pick, pickBy, pickAll

實務上我們可能不會用到物件當中所有的屬性,可能只要取出一些屬性來使用而已,這三個 pick 系列的函數可以很方便做到這件事:

const data = {
  url: "https://api.github.com/repos/example/magazine/issues/1",
  repository_url: "https://api.github.com/repos/example/magazine",
  labels_url:
    "https://api.github.com/repos/example/magazine/issues/1/labels{/name}",
  comments_url:
    "https://api.github.com/repos/example/magazine/issues/1/comments",
  events_url: "https://api.github.com/repos/example/magazine/issues/1/events",
  html_url: "https://github.com/example/magazine/issues/1",
  id: 252372781,
  number: 1,
  title: "test issue",
}

R.pick(["url", "repository_url", "id", "name"], data) // 回傳這三個屬性的值,如果找不到此屬性直接忽略

R.pickAll(["url", "repository_url", "name"], data) // 回傳值如果屬性存在,沒有的話會回傳 undefined

const isURL = (value, key) => key.indexOf("_url") !== -1
R.pickBy(isURL, data) // 回傳任何屬性含有 _url

pathOr

前端在呼叫後端 API 時,回傳的 JSON 有時會有相當深層的結構,如果用一般的方式 a && a.b && a.b.c 的方式取值,不僅程式碼相當雜亂,而且一旦結構過深,就要寫更多的判斷式。

pathOr 接受陣列當作取值的順序,一旦取值回傳 undefined 就會回傳預設值。

const article = {
  id: "116208916",
  author: {
    information: {
      birthday: "1994-11-11",
      name: "kalan",
    },
    subscribers_count: 1239,
  },
  content: {
    title: "title",
    body: "body",
  },
}

如果後端回傳的欄位不齊全,很有可能造成錯誤發生,這個時候就能透過 pathOr 來做處理。這樣一來如果生日沒有值的話就會用「未提供生日」這個字串取代。

const getBirthday = R.pathOr(
  ["author", "information", "birthday"],
  "未提供生日"
)
getBirthday(article)

memoize

在計算質數、階層等運算量比較大情景,為了不在每次求值時重新運算一次,可以用 memoize 函數快取已經運算過的結果。

結論

ramda 是個相當好用函式庫,這篇文章提出一些在一般操作比較少見的 API,不過 ramda 本身的 API 相當豐富,搭配 compose 你可以自由組裝自己的 function,並且利用以上提到方法來簡化你的函數。

如果你也喜歡這種程式碼風格,歡迎踏入 functional programming 的世界。