---
url: /blog-program/remeda-pipe.md
description: pipe 代表函數式程式設計中的概念，利用多個功能結合在一起，資料依序通過每個功能進行處理
---

![remeda-pipe](/remeda-pipe.webp){.cover}

# 好 pipe 不用嗎？藉由 remeda 讓程式碼更簡潔彈性吧！

## 甚麼是 pipe

顧名思是就是水管，但是不會像瑪利歐由人來鑽，而是讓資料通過。

pipe 是一種函數式程式設計中的概念，用來表示一條資料處理的管道。

可以將多個 function 組合在一起，資料會依序經過每個 function 進行處理。

這種方法使程式碼更加清晰、模組化，並且易於維護。

個人覺得和以往指令式的寫法最大差別在於關注點不同。

怎麼說呢？讓我們看看以下兩種等效的程式。

例 1：

```ts
const data = [1, 2, 3]
const filteredData = data.filter((value) => value > 1)
const result = filteredData.join('-')
```

例 2：

```ts
const result = pipe(
  [1, 2, 3],
  filter((value) => value > 1),
  join('-'),
)
```

::: warning
我知道你很想問例 1 明明可以一行解決。範例而已啦，讓我們慢慢來。(´・ω・\`)
:::

可以注意到：

* 例 1 的專注點在「結果」
* 例 2 的寫法專注點在「過程」

了解差異後學習轉換上就會相對容易了。（應該吧 ლ(╹ε╹ლ)）

## ramda、remeda？

這兩個都是基於 FP 概念設計的優秀套件，其實選一個喜歡的都行。

不過我主要用 remeda，因為 remeda 可以完美配合 TypeScript，而且同時支援 data-first 與 data-last 的寫法，兩個範例如下：

* data-first：

```ts
const data = {
  name: 'cod',
  age: '18',
}

const result = pick(data, ['name'])
```

* data-last：

```ts
const data = {
  name: 'cod',
  age: '18',
}

const pickName = pick(['name'])
const result = pickName(data)
```

個人覺得使用上更為直覺。

此外 remeda 還有其他特性，詳細說明就不再此贅述，可以參考以下文件。

[Remeda](https://remedajs.com/)

## 所以 pipe 有甚麼好處？

個人覺得最大的好處是不用一直想變數名稱 XD，而且 function 方便抽換，整體來說增加了程式的彈性。

### 減少臨時變數

你有一個物件陣列代表員工資料：

```ts
const employees = [
  { name: '鱈魚', age: 30, active: true, department: 'HR' },
  { name: '阿貓', age: 25, active: false, department: 'IT' },
  { name: '阿狗', age: 28, active: true, department: 'HR' },
  { name: '章魚', age: 35, active: true, department: 'IT' },
  { name: '鯊魚', age: 26, active: true, department: 'Sales' },
]

type Employee = (typeof employees)[0]
```

要處理以下流程：

1. 挑出 active 員工

2. 根據部門分組

3. 每個部門挑出年紀最小的員工

4. 最後依年紀排序顯示這些員工

一般可能會這樣寫：

```ts
// 1. 篩選 active
const activeEmployees = employees.filter((e) => e.active)

// 2. 分組（用物件表示）
const grouped = activeEmployees.reduce((acc, e) => {
  (acc[e.department] ||= []).push(e)
  return acc
}, {} as Record<string, Employee[]>)

// 3. 每組取年紀最小的員工
const youngestPerDept = Object.values(grouped).map((group) =>
  group.reduce((min, item) => (item.age < min.age ? item : min))
)

// 4. 排序
const result = youngestPerDept.sort((a, b) => a.age - b.age)
```

使用 remeda 就可以這樣寫：

```ts
const result = pipe(
  employees,
  filter((e) => e.active),
  groupBy((e) => e.department),
  mapValues((group) => group.reduce(
    (min, item) => (item.age < min.age ? item : min)
  )),
  values(),
  sortBy((e) => e.age)
)
```

可以看到不但不需要臨時變數，要追加邏輯也很方便。

### 更容易使用 Early Return

使用 Early Return 可以讓程式碼更簡潔，避免過多的巢狀結構。

但是抽離太多 function 反而會讓程式碼變得冗長、難以閱讀。

remeda 的 `pipe` 可以讓我們在一個區塊內使用 Early Return，這樣就可以避免過多的巢狀結構。

例如：提取 `MaybeElement` 型別內部的 `HTMLElement`，若不存在則取得目前元件的 `el`，若都不存在則使用 `document.documentElement`。

一般可能會這樣寫：

```ts
function getRootElement(target: MaybeElement) {
  let rootElement: HTMLElement | undefined
  /** target 為 MaybeElement */
  let value = toValue(target)

  if (value && '$el' in value) {
    value = value.$el
  }
  else {
    const instance = getCurrentInstance()

    value = value
      ?? instance?.vnode?.el
      ?? instance?.proxy?.$el
      ?? document.documentElement
  }

  if (!value) {
    rootElement = undefined
  }
  else if (value instanceof HTMLElement) {
    rootElement = value
  }
  else {
    throw new TypeError(
      '[useImagesReady] 取得目標元素異常，請確認 target 是否為 HTMLElement 或 Vue 元件',
    )
  }

  return rootElement
}

const rootElement = getRootElement(target)
```

如果想要改為 Early Return，可能要把部分邏輯拆成 function：

```ts
function resolveEl(_value: MaybeElement) {
  const value = toValue(_value)

  if (value && '$el' in value) {
    return value.$el
  }
  const instance = getCurrentInstance()

  return value
    ?? instance?.vnode?.el
    ?? instance?.proxy?.$el
    ?? document.documentElement
}

function resolveRootElement(value: unknown) {
  if (!value) {
    return undefined
  }

  if (value instanceof HTMLElement) {
    return value
  }

  throw new Error(
    '[useImagesReady] 取得目標元素異常，請確認 target 是否為 HTMLElement 或 Vue 元件',
  )
}

function getRootElement(target: MaybeElement) {
  const value = resolveEl(target)
  return resolveRootElement(value)
}

const rootElement = getRootElement(target)
```

但是這種只用一次的 function 反而讓程式碼變得冗長。

使用 remeda 的 `pipe`，則可以這樣寫：

```ts
const rootElement = pipe(
  toValue(target),
  /** 提取 el 部分 */
  (value) => {
    if (value && '$el' in value) {
      return value.$el
    }
    const instance = getCurrentInstance()

    return value
      ?? instance?.vnode?.el
      ?? instance?.proxy?.$el
      ?? document.documentElement
  },
  /** 收束型別 */
  (value) => {
    if (!value) {
      return undefined
    }

    if (value instanceof HTMLElement) {
      return value
    }

    throw new Error(
      '[useImagesReady] 取得目標元素異常，請確認 target 是否為 HTMLElement 或 Vue 元件',
    )
  },
)
```

這樣就可以避免過多的巢狀結構，不需定義多餘的 function，讓程式碼更清晰。

### 內建 Lazy Evaluation

remeda 的 `pipe` 會自動使用 lazy evaluation，在某些情境下有奇效，詳情請見[這篇文章](/blog-program/remeda-lazy-evaluator)

### 讓邏輯結構清晰

使用像是 babylon.js 這類有大量物件的套件時，常常會有這種改寫物件資料的部分。

```ts
function createHole(data: ElData) {
  const depth = Math.max(data.width, data.height)

  const texture = new Texture(
    '/minecraft/textures/block/dirt.png',
    scene.value,
    true,
    false,
    Texture.NEAREST_NEAREST
  )
  /** 方塊基準尺寸 */
  const baseSize = 80
  texture.uScale = data.width / baseSize
  texture.vScale = data.height / baseSize

  const material = new StandardMaterial('hole', scene.value)
  material.emissiveColor = new Color3(0.1, 0.1, 0.1)
  material.diffuseTexture = texture

  const hole = MeshBuilder.CreateBox(data.id, {
    width: 1,
    height: 1,
    depth,
    sideOrientation: Mesh.BACKSIDE,
  }, scene.value)

  // 使用縮放對應寬高，這樣就可以自由調整尺寸，而不用變更 mesh
  hole.scaling.x = data.width
  hole.scaling.y = data.height
  hole.renderingGroupId = 1

  hole.material = material

  hole.position.x = data.x + data.width / 2 - windowSize.width / 2
  hole.position.y = -data.y - data.height / 2 + windowSize.height / 2
  hole.position.z = depth / 2

  hole.isVisible = !data.visible
  hole.metadata = {
    ...data,
    position: hole.position,
  }

  return hole
}
```

其實仔細一看就會知道每個區塊的功能，段落也相當明確，但是如果用 pipe 寫會更清晰。

讓我們改寫一下。

```ts
function createHole(data: ElData) {
  const depth = Math.max(data.width, data.height)

  const texture = pipe(
    new Texture(
      '/minecraft/textures/block/dirt.png',
      scene.value,
      true,
      false,
      Texture.NEAREST_NEAREST
    ),
    tap((texture) => {
      /** 方塊基準尺寸 */
      const baseSize = 80
      texture.uScale = data.width / baseSize
      texture.vScale = data.height / baseSize
    })
  )

  const material = pipe(
    new StandardMaterial('hole', scene.value),
    tap((material) => {
      material.emissiveColor = new Color3(0.1, 0.1, 0.1)
      material.diffuseTexture = texture
    }),
  )

  const hole = pipe(
    MeshBuilder.CreateBox(data.id, {
      width: 1,
      height: 1,
      depth,
      sideOrientation: Mesh.BACKSIDE,
    }, scene.value),
    tap((hole) => {
      // 使用縮放對應寬高，這樣就可以自由調整尺寸，而不用變更 mesh
      hole.scaling.x = data.width
      hole.scaling.y = data.height

      hole.renderingGroupId = 1
      hole.material = material

      hole.position.x = data.x + data.width / 2 - windowSize.width / 2
      hole.position.y = -data.y - data.height / 2 + windowSize.height / 2
      hole.position.z = depth / 2

      hole.isVisible = !data.visible
      hole.metadata = {
        ...data,
        position: hole.position,
      }
    }),
  )

  return hole
}
```

::: tip 那個 tap 是甚麼？
[`tap`](https://remedajs.com/docs/#tap) 是 remeda 提供的 function，可以讓我們在 pipe 中執行副作用，並且回傳原本的值。
:::

這樣每個區塊做的事情更加清晰。

甚至 Vue 的 `watch`、`useEventListener` 等等額外副作用也可以放在同一個區塊內。

以下是 Matter.js 的例子：

```ts
const mouse = pipe(
  Mouse.create(document.body),
  tap((item) => {
    // 移除 wheel listener 否則葉面無法使用滾輪捲動
    // @ts-expect-error 存在 mousewheel
    item.element.removeEventListener('wheel', item.mousewheel)

    // 同步滾動量，以免滾動後拉不到 ball
    useEventListener('scroll', () => {
      Mouse.setOffset(item, {
        x: window.scrollX,
        y: -window.scrollY,
      })
    })

    watch(pixelRatio, (value) => {
      item.pixelRatio = value
    }, { immediate: true })
  }),
)
```

可以清楚看到相關副作用都集中在同一個區塊內。

路人：「看起來好像差不多？(´・ω・\`)」

鱈魚：「看起來的確差不多，現在讓我們配合編輯器的功能。ლ（´∀\`ლ）」

VSCode 可以摺疊指定區塊，這樣就可以乾淨的分開每個段落。

快捷鍵如下：

* `Ctrl + K` -> `Ctrl + J`：展開全部區塊
* `Ctrl + K` -> `Ctrl + 0`：摺疊全部區塊
* `Ctrl + K` -> `Ctrl + 1`：摺疊第 1 層區塊
* `Ctrl + K` -> `Ctrl + 2`：摺疊第 2 層區塊

... 以此類推

所以我們就可以將剛剛的程式摺疊第 2 層區塊，會變成這樣：

```ts
function createHole(data: ElData) {
  const depth = Math.max(data.width, data.height)

  const texture = pipe(
    // ...
  )

  const material = pipe(
    // ...
  )

  const hole = pipe(
    // ...
  )

  return hole
}
```

Matter.js 的例子則可以摺疊第 1 層，變為：

```ts
const mouse = pipe(
  // ...
)
```

這樣不管是在閱讀或是維護上都會更加方便。( •̀ ω •́ )✧

### 處理資料

接著看看資料處理的例子。

假設我們有多個 IoT 設備回傳資料，網頁需要彙整並顯示內容，資料為：

```ts
interface Datum {
  deviceId: string;
  type: string;
  temperature: number;
  humidity: number;
  otherSensorData: Array<{
    type: string;
    value: number;
  }>;
}

const data: Datum[] = [
  {
    deviceId: 'device_1',
    type: 'A',
    temperature: 24.5,
    humidity: 50.0,
    otherSensorData: [
      { type: 'A', value: 10 },
      { type: 'A', value: 20 }
    ]
  },
  {
    deviceId: 'device_2',
    type: 'B',
    temperature: 22.3,
    humidity: 45.5,
    otherSensorData: [
      { type: 'B', value: 15 },
      { type: 'C', value: 25 }
    ]
  }
]
```

以下讓我們來實際撰寫程式。

#### 列出所有設備 ID 並用頓號分隔

熟悉 JS 的人一定可以很快寫出以下程式：

```ts
const result01 = data
  .map(({ deviceId }) => deviceId)
  .join('、')
```

用 pipe 寫則會像這樣：

```ts
const result02 = pipe(
  data,
  map(prop('deviceId')),
  join('、')
)
```

看起來好像沒比較好捏？但是用 remeda 可以更簡單抽離與複用：

```ts
const getDeviceIdListString = piped(
  map<Datum, string>(prop('deviceId')),
  join('、')
)
```

也方便加入新的處理邏輯：

```ts
import { trim } from 'ramda'

const getDeviceIdListString = piped(
  /** 將每個 deviceId 去除頭尾空白 */
  map<Datum, string>(
    piped(prop('deviceId'), trim),
  ),
  join('、'),
)
```

邏輯越複雜效果會越明顯，來看看其他例子。

#### 將設備依照 type 分類

```ts
const groupByType = pipe(
  data,
  groupBy(prop('type')),
  values,
)
```

#### 取得平均溫度與平均濕度

```ts
const meanData = {
  temperature: pipe(data, meanBy(prop('temperature'))),
  humidity: pipe(data, meanBy(prop('humidity'))),
}
```

#### 取得 otherSensorData type 種類清單

```ts
const typeList = pipe(
  data,
  /** 將所有 otherSensorData 攤平、組成新矩陣 */
  flatMap(prop('otherSensorData')),
  /** 依照 type 數值去除重複項目 */
  uniqBy(prop('type')),
  /** 取出 type 數值產生新矩陣 */
  map(prop('type'))
)
```

#### 取得所有溫溼度不在舒適範圍內的設備

```ts
function isComfortableTemperature(value: number) {
  return value >= 22 && value <= 28
}
function isComfortableHumidity(value: number) {
  return value >= 40 && value <= 60
}

const isComfortable = piped(
  allPass<Datum>([
    piped(prop('temperature'), isComfortableTemperature),
    piped(prop('humidity'), isComfortableHumidity),
  ])
)

const uncomfortableList = pipe(data, reject(isComfortable))

/**
 * 因為只有一個參數，所以也可以用 data-first 的方式寫
 * const result = reject(data, isComfortable);
 */
```

從以上例子來看，其實就算沒有註解，從 function 的名稱我們看得出來此程式在做甚麼。( •̀ ω •́ )✧

這樣可讀性是不是提升了許多呢？如果沒有，就再多看幾次（？

如果錯誤或更好的做法，歡迎大家多多指教。(´▽｀)

## 總結 🐟

* pipe 可將多個功能串聯，資料依序通過每個功能進行處理
* remeda 可以完美配合 TypeScript，同時支援 data-first 與 data-last 的寫法
* 使用 pipe 可以讓程式碼結構更加清晰、模組化，並且易於維護
