好 pipe 不用嗎?藉由 remeda 讓程式碼更簡潔彈性吧!
甚麼是 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 的專注點在「結果」
- 例 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 還有其他特性,詳細說明就不再此贅述,可以參考以下文件。
所以 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 可以讓程式碼結構更加清晰、模組化,並且易於維護