精讀《pipe operator for JavaScript》

黃子毅發表於2022-02-07

Pipe Operator (|>) for JavaScript 提案給 js 增加了 Pipe 語法,這次結合 A pipe operator for JavaScript: introduction and use cases 文章一起深入瞭解這個提案。

概述

Pipe 語法可以將函式呼叫按順序打平。如下方函式,存在三層巢狀,但我們解讀時需要由內而外閱讀,因為呼叫順序是由內而外的:

const y = h(g(f(x)))

Pipe 可以將其轉化為正常順序:

const y = x |> f(%) |> g(%) |> h(%)

Pipe 語法有兩種風格,分別來自 Microsoft 的 F#) 與 Facebook 的 Hack)。

之所以介紹這兩個,是因為 js 提案首先要決定 “借鑑” 哪種風格。js 提案最終採用了 Hack 風格,因此我們最好把 F# 與 Hack 的風格都瞭解一下,並對其優劣做一個對比,才能知其所以然。

Hack Pipe 語法

Hack 語法相對冗餘,在 Pipe 時使用 % 傳遞結果:

'123.45' |> Number(%)

這個 % 可以用在任何地方,基本上原生 js 語法都支援:

value |> someFunction(1, %, 3) // function calls
value |> %.someMethod() // method call
value |> % + 1 // operator
value |> [%, 'b', 'c'] // Array literal
value |> {someProp: %} // object literal
value |> await % // awaiting a Promise
value |> (yield %) // yielding a generator value

F# Pipe 語法

F# 語法相對精簡,預設不使用額外符號:

'123.45' |> Number

但在需要顯式宣告引數時,為了解決上一個 Pipe 結果符號從哪來的問題,寫起來反而更為複雜:

2 |> $ => add2(1, $)

await 關鍵字 - Hack 優

F# 在 await yield 時需要特殊語法支援,而 Hack 可以自然的使用 js 內建關鍵字。

// Hack
value |> await %
// F#
value |> await

F# 程式碼看上去很精簡,但實際上付出了高昂的代價 - await 是一個僅在 Pipe 語法存在的關鍵字,而非普通 await 關鍵字。如果不作為關鍵字處理,執行邏輯就變成了 await(value) 而不是 await value

解構 - F# 優

正因為 F# 繁瑣的變數宣告,反而使得在應對解構場景時得心應手:

// F#
value |> ({ a, b }) => someFunction(a, b)
// Hack
value |> someFunction(%.a, %.b)

Hack 也不是沒有解構手段,只是比較繁瑣。要麼使用立即呼叫函式表示式 IIFE:

value |> (({ a, b }) => someFunction(a, b))(%)

要麼使用 do 關鍵字:

value |> do { const { a, b } = %; someFunction(a, b) }

但 Hack 雖敗猶榮,因為解決方法都使用了 js 原生提供的語法,所以反而體現出與 js 已有生態親和性更強,而 F# 之所以能優雅解決,全都歸功於自創的語法,這些語法雖然甜,但割裂了 js 生態,這是 F# like 提案被放棄的重要原因之一。

潛在改進方案

雖然選擇了 Hack 風格,但 F# 與 Hack 各有優劣,所以列了幾點優化方案。

利用 Partial Application Syntax 提案降低 F# 傳參複雜度

F# 被詬病的一個原因是傳參不如 Hack 簡單:

// Hack
2 |> add2(1, %)
// F#
2 |> $ => add2(1, $)

但如果利用處於 stage1 的提案 Partial Application Syntax 可以很好的解決問題。

這裡就要做一個小插曲了。js 對柯里化沒有原生支援,但 Partial Application Syntax 提案解決了這個問題,語法如下:

const add = (x, y) => x + y;
const addOne = add~(1, ?);
addOne(2); // 3

即利用 fn~(?, arg) 的語法,將任意函式柯里化。這個特性解決 F# 傳參複雜問題簡直絕配,因為 F# 的每一個 Pipe 都要求是一個函式,我們可以將要傳參的地方記為 ?,這樣返回值還是一個函式,完美符合 F# 的語法:

// F#
2 |> add~(1, ?)

上面的例子拆開看就是:

const addOne = add~(1, ?)
2 |> addOne

想法很美好,但 Partial Application Syntax 得先落地。

融合 F# 與 Hack 語法

在簡單情況下使用 F#,需要利用 % 傳參時使用 Hack 語法,兩者混合在一起寫就是:

const resultArray = inputArray
  |> filter(%, str => str.length >= 0) // Hack
  |> map(%, str => '['+str+']') // Hack
  |> console.log // F#

不過這個 提案 被廢棄了。

創造一個新的操作符

如果用 |> 表示 Hack 語法,用 |>> 表示 F# 語法呢?

const resultArray = inputArray
  |> filter(%, str => str.length >= 0) // Hack
  |> map(%, str => '['+str+']') // Hack
  |>> console.log // F#

也是看上去很美好,但這個特性連提案都還沒有。

如何用現有語法模擬 Pipe

即便沒有 Pipe Operator (|>) for JavaScript 提案,也可以利用 js 現有語法模擬 Pipe 效果,以下是幾種方案。

Function.pipe()

利用自定義函式構造 pipe 方法,該語法與 F# 比較像:

const resultSet = Function.pipe(
  inputSet,
  $ => filter($, x => x >= 0)
  $ => map($, x => x * 2)
  $ => new Set($)
)

缺點是不支援 await,且存在額外函式呼叫。

使用中間變數

說白了就是把 Pipe 過程拆開,一步步來寫:

const filtered = filter(inputSet, x => x >= 0)
const mapped = map(filtered, x => x * 2)
const resultSet = new Set(mapped)

沒什麼大問題,就是比較冗餘,本來可能一行能解決的問題變成了三行,而且還宣告瞭三個中間變數。

複用變數

改造一下,將中間變數變成複用的:

let $ = inputSet
$ = filter($, x => x >= 0)
$ = map($, x => x * 2)
const resultSet = new Set($)

這樣做可能存在變數汙染,可使用 IIFE 解決。

精讀

Pipe Operator 語義價值非常明顯,甚至可以改變程式設計的思維方式,在序列處理資料時非常重要,因此命令列場景非常常見,如:

cat "somefile.txt" | echo

因為命令列就是典型的輸入輸出場景,而且大部分都是單輸入、單輸出。

在普通程式碼場景,特別是處理資料時也需要這個特性,大部分具有抽象思維的程式碼都進行了各種型別的管道抽象,比如:

const newValue = pipe(
  value,
  doSomething1,
  doSomething2,
  doSomething3
)

如果 Pipe Operator (|>) for JavaScript 提案通過,我們就不需要任何庫實現 pipe 動作,可以直接寫成:

const newValue = value |> doSomething1(%) |> doSomething2(%) |> doSomething3(%)

這等價於:

const newValue = doSomething3(doSomething2(doSomething1(value)))

顯然,利用 pipe 特性書寫處理流程更為直觀,執行邏輯與閱讀邏輯是一致的。

實現 pipe 函式

即便沒有 Pipe Operator (|>) for JavaScript 提案,我們也可以一行實現 pipe 函式:

const pipe = (...args) => args.reduce((acc, el) => el(acc))

但要實現 Hack 引數風格是不可能的,頂多實現 F# 引數風格。

js 實現 pipe 語法的考慮

提案 記錄來看,F# 失敗有三個原因:

  • 記憶體效能問題。
  • await 特殊語法。
  • 割裂 js 生態。

其中割裂 js 生態是指因 F# 語法的特殊性,如果有太多庫按照其語法實現功能,可能導致無法被非 Pipe 語法場景所複用。

甚至還有部分成員反對 隱性程式設計(Tacit programming),以及柯里化提案 Partial Application Syntax,這些會使 js 支援的程式設計風格與現在差異過大。

看來處於鄙視鏈頂端的程式設計風格在 js 是否支援不是能不能的問題,而是想不想的問題。

pipe 語法的弊端

下面是普通 setState 語法:

setState(state => ({
  ...state,
  value: 123
}))

如果改為 immer 寫法如下:

setState(produce(draft => draft.value = 123))

得益於 ts 型別自動推導,在內層 produce 裡就已經知道 value 是字串型別,此時如果輸入字串會報錯,而如果其在另一個上下文的 setState 內,型別也會隨著上下文的變化而變化。

但如果寫成 pipe 模式:

produce(draft => draft.value = 123) |> setState

因為先考慮的是如何修改資料,此時還不知道後面的 pipe 流程是什麼,所以 draft 的型別無法確定。所以 pipe 語法僅適用於固定型別的資料處理流程。

總結

pipe 直譯為管道,潛在含義是 “資料像流水線一樣被處理”,也可以形象理解為每個函式就是一個不同的管道,顯然下一個管道要處理上一個管道的資料,並將結果輸出到下一個管道作為輸入。

合適的管道數量與體積決定了一條生產線是否高效,過多的管道型別反而會使流水線零散而雜亂,過少的管道會讓流水線笨重不易擴充,這是工作中最大的考驗。

討論地址是:精讀《pipe operator for JavaScript》· Issue #395 · dt-fe/weekly

如果你想參與討論,請 點選這裡,每週都有新的主題,週末或週一釋出。前端精讀 - 幫你篩選靠譜的內容。

關注 前端精讀微信公眾號

<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">

版權宣告:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證

相關文章