好 pipe 不用嗎?藉由 remeda 讓程式碼更簡潔彈性吧!

remeda-pipe

甚麼是 pipe

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

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

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

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

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

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

例 1:

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

例 2:

const result = pipe(
  [1, 2, 3],
  filter(value => value > 1),
  join('-'),
);
我知道你很想問例 1 明明可以一行解決。範例而已啦,讓我們慢慢來。(´・ω・`)

可以注意到:

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

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

ramda、remeda?

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

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

  • data-first:
const data = {
  name: 'cod',
  age: '18',
}

const result = pick(data, ['name']);
  • data-last:
const data = {
  name: 'cod',
  age: '18',
}

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

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

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

Remeda

所以 pipe 有甚麼好處?

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

讓邏輯結構清晰

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

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 寫會更清晰。

讓我們改寫一下。

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
    ),
    (texture) => {
      /** 方塊基準尺寸 */
      const baseSize = 80;
      texture.uScale = data.width / baseSize;
      texture.vScale = data.height / baseSize;

      return texture;
    }
  )

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

      return material;
    },
  );

  const hole = pipe(
    MeshBuilder.CreateBox(data.id, {
      width: 1, height: 1, depth,
      sideOrientation: Mesh.BACKSIDE,
    }, scene.value),
    (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;
    },
  );

  return hole;
}

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


路人:「看起來好像差不多?(´・ω・`)」

鱈魚:「看起來的確差不多,現在讓我們配合編輯器的功能。ლ(´∀`ლ)」


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

快捷鍵如下:

  • Ctrl + K -> Ctrl + J:展開全部區塊
  • Ctrl + K -> Ctrl + 0:摺疊全部區塊
  • Ctrl + K -> Ctrl + 1:摺疊第 1 層區塊
  • Ctrl + K -> Ctrl + 2:摺疊第 2 層區塊

... 以此類推

所以我們就可以將剛剛的程式摺疊成這樣:

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

  const texture = pipe(
    ...
  )

  const material = pipe(
    ...
  );

  const hole = pipe(
    ...
  );

  return hole;
}

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

處理資料

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

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

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 的人一定可以很快寫出以下程式:

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

用 pipe 寫則會像這樣:

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

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

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

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

import { trim } from 'ramda';

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

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

將設備依照 type 分類

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

取得平均溫度與平均濕度

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

取得 otherSensorData type 種類清單

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

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

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 可以讓程式碼結構更加清晰、模組化,並且易於維護