|>
) (本文檔使用%
作為主題參考的佔位符標記。這幾乎肯定不是最終選擇;有關詳細信息,請參閱標記 bikeshedding 討論。)
在 2020 年 JS 現況調查中,「您認為 JavaScript 目前缺少什麼?」這個問題的第四個熱門答案是:是一名管道操作員。為什麼?
當我們在 JavaScript 中對一個值執行連續操作(例如函數呼叫)時,目前有兩種基本樣式:
也就是說, three(two(one(value)))
與value.one().two().three()
。然而,這些風格在可讀性、流暢性和適用性方面存在很大差異。
第一種樣式,嵌套,通常適用-它適用於任何操作序列:函數呼叫、算術、陣列/物件文字、 await
和yield
等。
然而,當嵌套變深時,就很難閱讀了:執行流程是從右到左,而不是普通程式碼從左到右的讀取方式。如果在某些等級有多個參數,閱讀甚至會來回跳動:我們的眼睛必須向左跳才能找到函數名稱,然後必須向右跳才能找到其他參數。此外,事後編輯程式碼可能會很麻煩:我們必須找到在許多嵌套括號中插入新參數的正確位置。
考慮一下來自 React 的真實程式碼。
console . log (
chalk . dim (
`$ ${ Object . keys ( envars )
. map ( envar =>
` ${ envar } = ${ envars [ envar ] } ` )
. join ( ' ' )
} ` ,
'node' ,
args . join ( ' ' ) ) ) ;
這個現實世界的程式碼是由深度嵌套的表達式組成的。為了讀取其資料流,人眼必須首先:
求初始資料(最裡面的表達式, envars
)。
然後從內到外重複地來回掃描每個資料轉換,每個轉換要么是左側容易錯過的前綴運算符,要么是右側的後綴運算符:
Object.keys()
(左側),.map()
(右側),.join()
(右側),chalk.dim()
(左側),然後console.log()
(左側)。由於深度嵌套了許多表達式(其中一些使用前綴運算符,一些使用後綴運算符,一些使用外接運算符),我們必須檢查左側和右側以找到每個表達式的頭部。
第二種樣式,方法鏈接,僅當值具有指定為其類別的方法的函數時才可用。這限制了它的適用性。但當它應用時,由於其後綴結構,它通常更可用並且更易於閱讀和編寫。程式碼執行從左到右流動。深度嵌套的表達式不再糾纏在一起。函數呼叫的所有參數都與函數名稱分組。稍後編輯程式碼以插入或刪除更多方法呼叫是微不足道的,因為我們只需將遊標放在一個位置,然後開始鍵入或刪除一組連續的字元。
事實上,方法連結的好處非常有吸引力,以至於一些流行的庫專門扭曲其程式碼結構以允許更多的方法連結。最突出的例子是jQuery ,它仍然是世界上最受歡迎的 JS 庫。 jQuery 的核心設計是一個單一的 uber-object,上面有幾十個方法,所有這些方法都傳回相同的物件類型,以便我們可以繼續連結。這種程式風格甚至有個名字:流暢的介面。
不幸的是,儘管方法鏈很流暢,但它本身無法適應 JavaScript 的其他語法:函數呼叫、算術、陣列/物件文字、 await
和yield
等。
管道運算子試圖將方法連結的方便性和易用性與表達式嵌套的廣泛適用性結合。
所有管道運算子的一般結構為value |>
e1 |>
e2 |>
e3 ,其中e1 、 e2 、 e3都是以連續值作為參數的表達式。然後|>
運算子執行某種程度的魔法,將value
從左側「管道」到右側。
繼續來自 React 的深度嵌套的現實世界程式碼:
console . log (
chalk . dim (
`$ ${ Object . keys ( envars )
. map ( envar =>
` ${ envar } = ${ envars [ envar ] } ` )
. join ( ' ' )
} ` ,
'node' ,
args . join ( ' ' ) ) ) ;
…我們可以使用管道運算子和代表前一個操作值的佔位符標記( %
)來理清它:
Object . keys ( envars )
. map ( envar => ` ${ envar } = ${ envars [ envar ] } ` )
. join ( ' ' )
| > `$ ${ % } `
| > chalk . dim ( % , 'node' , args . join ( ' ' ) )
| > console . log ( % ) ;
現在,人類讀者可以快速找到初始資料(最裡面的表達式envars
),然後從左到右線性讀取資料的每個轉換。
有人可能會說,使用臨時變數應該是解開深層嵌套程式碼的唯一方法。明確命名每個步驟的變數會導致類似於方法連結的情況發生,並且與讀取和編寫程式碼具有類似的好處。
例如,使用我們先前修改的 React 現實世界範例:
Object . keys ( envars )
. map ( envar => ` ${ envar } = ${ envars [ envar ] } ` )
. join ( ' ' )
| > `$ ${ % } `
| > chalk . dim ( % , 'node' , args . join ( ' ' ) )
| > console . log ( % ) ;
....使用臨時變數的版本將如下所示:
const envarString = Object . keys ( envars )
. map ( envar => ` ${ envar } = ${ envars [ envar ] } ` )
. join ( ' ' ) ;
const consoleText = `$ ${ envarString } ` ;
const coloredConsoleText = chalk . dim ( consoleText , 'node' , args . join ( ' ' ) ) ;
console . log ( coloredConsoleText ) ;
但在現實世界中,我們總是在彼此的程式碼中遇到深度嵌套的表達式,而不是一行行臨時變量,這是有原因的。 jQuery、Mocha 等基於方法鏈的流暢介面仍然流行也是有原因的。
使用一長串臨時的一次性變數編寫程式碼通常過於乏味和冗長。對於人類閱讀來說,它甚至可以說是乏味且視覺上嘈雜的。
如果命名是程式設計中最困難的任務之一,那麼當程式設計師認為命名變數的好處相對較小時,他們將不可避免地避免命名變數。
有人可能會爭辯說,使用具有短名稱的單一可變變數會減少臨時變數的冗長,從而達到與管道運算符類似的結果。
例如,我們先前修改的 React 現實世界範例可以像這樣重寫:
let _ ;
_ = Object . keys ( envars )
. map ( envar => ` ${ envar } = ${ envars [ envar ] } ` )
. join ( ' ' ) ;
_ = `$ ${ _ } ` ;
_ = chalk . dim ( _ , 'node' , args . join ( ' ' ) ) ;
_ = console . log ( _ ) ;
但這樣的程式碼在現實世界中並不常見。原因之一是可變變數可能會意外更改,從而導致難以發現的無聲錯誤。例如,變數可能會在閉包中意外引用。或者它可能在表達式中被錯誤地重新分配。
// setup
function one ( ) { return 1 ; }
function double ( x ) { return x * 2 ; }
let _ ;
_ = one ( ) ; // _ is now 1.
_ = double ( _ ) ; // _ is now 2.
_ = Promise . resolve ( ) . then ( ( ) =>
// This does *not* print 2!
// It prints 1, because `_` is reassigned downstream.
console . log ( _ ) ) ;
// _ becomes 1 before the promise callback.
_ = one ( _ ) ;
對於管道操作員來說,不會發生此問題。主題令牌無法重新分配,每個步驟以外的程式碼也無法變更其綁定。
let _ ;
_ = one ( )
| > double ( % )
| > Promise . resolve ( ) . then ( ( ) =>
// This prints 2, as intended.
console . log ( % ) ) ;
_ = one ( ) ;
因此,具有可變變數的程式碼也更難閱讀。要確定變數在任何給定點代表什麼,您必須在整個前面的範圍中搜尋它被重新分配的位置。
另一方面,管道的主題引用具有有限的詞法範圍,並且其綁定在其範圍內是不可變的。它不會被意外地重新分配,並且可以安全地在閉包中使用。
儘管主題值也會隨著管道的每個步驟而變化,但我們只需掃描管道的前一步即可理解它,從而使程式碼更易於閱讀。
管道運算子相對於賦值語句序列(無論是可變的還是不可變的臨時變數)的另一個好處是它們是表達式。
管道表達式是可以直接傳回、指派給變數或在上下文中使用的表達式,例如 JSX 表達式。
另一方面,使用臨時變數需要語句序列。
管道 | 臨時變數 |
---|---|
const envVarFormat = vars =>
Object . keys ( vars )
. map ( var => ` ${ var } = ${ vars [ var ] } ` )
. join ( ' ' )
| > chalk . dim ( % , 'node' , args . join ( ' ' ) ) ; | const envVarFormat = ( vars ) => {
let _ = Object . keys ( vars ) ;
_ = _ . map ( var => ` ${ var } = ${ vars [ var ] } ` ) ;
_ = _ . join ( ' ' ) ;
return chalk . dim ( _ , 'node' , args . join ( ' ' ) ) ;
} |
// This example uses JSX.
return (
< ul >
{
values
| > Object . keys ( % )
| > [ ... Array . from ( new Set ( % ) ) ]
| > % . map ( envar => (
< li onClick = {
( ) => doStuff ( values )
} > { envar } < / li >
) )
}
< / ul >
) ; | // This example uses JSX.
let _ = values ;
_ = Object . keys ( _ ) ;
_ = [ ... Array . from ( new Set ( _ ) ) ] ;
_ = _ . map ( envar => (
< li onClick = {
( ) => doStuff ( values )
} > { envar } < / li >
) ) ;
return (
< ul > { _ } < / ul >
) ; |
對於管道操作符有兩個相互競爭的提案:Hack 管道和 F# 管道。 (在此之前,還有第三個提案是前兩個提案的“智能組合”,但它已被撤回,因為它的語法嚴格來說是其中一個提案的超集。)
當我們在使用|>
拼字程式碼時,這兩個管道提案在「魔力」上略有不同。
兩個提案都重用了現有的語言概念:Hack 管道基於表達式的概念,而 F# 管道則是基於一元函數的概念。
管道表達式和管道一元函數相應地具有較小且幾乎對稱的權衡。
在Hack 語言的管道語法中,管道的右側是一個包含特殊佔位符的表達式,該佔位符使用綁定到左側表達式求值結果的佔位符進行求值。也就是說,我們寫value |> one(%) |> two(%) |> three(%)
來透過三個函數傳遞value
。
優點:右側可以是任何表達式,佔位符可以位於任何普通變數標識符可以到達的任何地方,因此我們可以透過管道傳輸到我們想要的任何程式碼,而無需任何特殊規則:
value |> foo(%)
用於一元函數調用,value |> foo(1, %)
用於 n 元函數調用,value |> %.foo()
用於方法調用,value |> % + 1
進行算術運算,value |> [%, 0]
對於陣列文字,value |> {foo: %}
對於物件文字,value |> `${%}`
用於模板文字,value |> new Foo(%)
用於建構對象,value |> await %
等待承諾,value |> (yield %)
用於產生生成器值,value |> import(%)
用於呼叫類似函數的關鍵字,缺點:使用 Hack 管道透過一元函數進行管道傳輸比使用 F# 管道稍微冗長一些。這包括由 Ramda 等函數柯里化函式庫建立的一元函數,以及對其參數執行複雜解構的一元箭頭函數:使用顯式函數呼叫後綴(%)
Hack 管道會稍微冗長一些。
(當表達式進行時,主題值的複雜解構將變得更加容易,因為您將能夠在管道體內進行變數分配/解構。)
在F# 語言的管道語法中,管道的右側是一個表達式,必須計算為一元函數,然後以左側的值作為唯一參數來預設呼叫函數。也就是說,我們寫value |> one |> two |> three
來透過三個函數傳遞value
。 left |> right
變為right(left)
。這稱為隱式程式設計或無點風格。
例如,使用我們先前修改的 React 現實世界範例:
Object . keys ( envars )
. map ( envar => ` ${ envar } = ${ envars [ envar ] } ` )
. join ( ' ' )
| > `$ ${ % } `
| > chalk . dim ( % , 'node' , args . join ( ' ' ) )
| > console . log ( % ) ;
…使用 F# 管道而不是 Hack 管道的版本將如下所示:
Object . keys ( envars )
. map ( envar => ` ${ envar } = ${ envars [ envar ] } ` )
. join ( ' ' )
| > x => `$ ${ x } `
| > x => chalk . dim ( x , 'node' , args . join ( ' ' ) )
| > console . log ;
優點:當我們要執行的操作是一元函數呼叫時,右側必須解析為一元函數的限制讓我們可以寫一個非常簡潔的管道:
value |> foo
用於一元函數呼叫。這包括由 Ramda 等函數柯里化庫創建的一元函數,以及對其參數執行複雜解構的一元箭頭函數:使用隱式函數呼叫(無(%)
),F# 管道會稍微不那麼冗長。
缺點:此限制意味著透過其他語法執行的任何操作都必須透過將操作包裝在一元箭頭函數中來變得稍微更詳細:
value |> x=> x.foo()
用於方法調用,value |> x=> x + 1
進行算術運算,value |> x=> [x, 0]
對於陣列文字,value |> x=> ({foo: x})
對於物件文字,value |> x=> `${x}`
對於模板文字,value |> x=> new Foo(x)
用於建構對象,value |> x=> import(x)
用來呼叫類似函數的關鍵字,當我們需要傳遞多個參數時,即使呼叫命名函數也需要包裝:
value |> x=> foo(1, x)
用於 n 元函數呼叫。缺點: await
和yield
操作的作用域為它們的包含函數,因此不能單獨由一元函數處理。如果我們想要將它們整合到管道表達式中,則await
和yield
必須作為特殊語法情況處理:
value |> await
等待承諾,並且value |> yield
用於產生生成器值。Hack 管道和 F# 管道分別對不同的表達式施加了少量的語法負擔:
Hack Pipes僅對一元函數呼叫稍微徵稅,並且
F# 管道對一元函數呼叫之外的所有表達式都會產生輕微的負擔。
在這兩個提案中,每個徵稅表達式的語法稅都很小( (%)
和x=>
都只有三個字元)。然而,稅收乘以其各自徵稅表達的普遍程度。因此,對不太常見的表達式徵稅並優化以支援更常見的表達式可能是有意義的。
一般來說,一元函數呼叫比除一元函數之外的所有表達式都不太常見。特別是方法呼叫和n元函數呼叫永遠會流行;一般來說,一元函數呼叫的頻率等於或超過這兩種情況——更不用說其他普遍存在的語法,如數組文字、物件文字和算術運算。本解釋包含了這種盛行率差異的幾個現實例子。
此外,其他幾種提出的新語法,例如擴展調用、 do 表達式和記錄/元組文字,也可能在將來變得普遍。同樣,如果 TC39 標準化運算子重載,算術運算也將變得更加常見。與 F# 管道相比,使用 Hack 管道可以更流暢地理清這些未來語法的表達式。
Hack 管道對一元函數呼叫的語法負擔(即調用右側一元函數的(%)
)並不是一個特殊情況:它只是明確地編寫普通程式碼,就像我們通常在沒有管道的情況下那樣。
另一方面, F# 管道要求我們區分「解析為一元函數的程式碼」與「任何其他表達式」 ,並記住在後一種情況周圍添加箭頭函數包裝器。
例如,對於 Hack 管道, value |> someFunction + 1
是無效語法,並且提前失敗。無需認識到someFunction + 1
不會計算為一元函數。但對於 F# 管道, value |> someFunction + 1
仍然是有效的語法- 它只會在運行時後期失敗,因為someFunction + 1
不可調用。
管子冠軍組已兩次向TC39展示第二階段的F#管。兩次都未能成功晉級第二階段。由於各種擔憂,兩個 F# 管道(和部分功能應用程式 (PFA))都遭到了其他多個 TC39 代表的強烈反對。其中包括:
await
文法問題。這種阻力是來自管道冠軍組之外的。請參閱 HISTORY.md 以了解更多資訊。
管道擁護者團體相信,任何管道運算符都比沒有好,以便輕鬆線性化深度嵌套的表達式,而無需求助於命名變數。冠軍組的許多成員認為 Hack 管道比 F# 管道稍好,冠軍組的一些成員認為 F# 管道比 Hack 管道稍好。但冠軍組中的每個人都同意 F# 管道遇到了太多阻力,無法在可預見的未來通過 TC39。
需要強調的是,嘗試從 Hack 管道切換回 F# 管道可能會導致 TC39 根本不會同意任何管道。 PFA 語法在 TC39 中同樣面臨一場艱苦的戰鬥(參見 HISTORY.md)。許多管道冠軍團體的成員認為這是不幸的,他們願意稍後再為F#-管道分割混合和PFA語法而戰。但是,Pipe Champion Group 之外有相當多的代表(包括瀏覽器引擎實現者)普遍反對鼓勵隱性編程(和 PFA 語法),無論 Hack Pipes 如何。
(有正式的規範草案。)
主題引用%
是一個空運算子。它充當主題值的佔位符,並且具有詞法範圍且不可變。
%
不是最終選擇(主題參考的精確標記不是最終的。 %
可以是^
或許多其他標記。我們計劃在進入第 3 階段之前對要使用的實際標記進行更換。但是, %
似乎是語法問題最少的,並且它也類似於printf 格式字串和Clojure的#(%)
函數文字的佔位符。
管道運算子|>
是形成管道表達式(也稱為pipeline )的中綴運算子。它評估其左側(管道頭或管道輸入),將結果值(主題值)一成不變地綁定到主題引用,然後使用該綁定評估其右側(管道體)。右側的結果值成為整個管道表達式的最終值(管道輸出)。
管道運算子的優先順序與以下相同:
=>
;=
、 +=
等;yield
和yield *
;它比僅逗號運算符,
更嚴格。
它比所有其他運算符都更寬鬆。
例如, v => v |> % == null |> foo(%, 0)
將分組為v => (v |> (% == null) |> foo(%, 0))
,
這又相當於v => foo(v == null, 0)
。
管道體必須至少使用一次其主題值。例如, value |> foo + 1
是無效語法,因為它的主體不包含主題引用。這種設計是因為從管道表達式主體中省略主題引用幾乎肯定是程式設計師的意外錯誤。
同樣,主題引用必須包含在管道主體中。在管道體之外使用主題引用也是無效語法。
為了防止分組混亂,使用具有相似優先權的其他運算子(即箭頭=>
、三元條件運算子?
:
、賦值運算子和yield
運算子)作為管道頭或管道體是無效的語法。當將|>
與這些運算子一起使用時,我們必須使用括號來明確指示哪種分組是正確的。例如, a |> b ? % : c |> %.d
是無效語法;應將其更正為a |> (b ? % : c) |> %.d
或a |> (b ? % : c |> %.d)
。
最後,動態編譯程式碼內的主題綁定(例如,使用eval
或new Function
)不能在該程式碼之外使用。例如,當執行時間評估eval
表達式時, v |> eval('% + 1')
將拋出語法錯誤。
沒有其他特殊規則。
A natural result of these rules is that, if we need to interpose a side effect in the middle of a chain of pipe expressions, without modifying the data being piped through, then we could use a comma expression , such as with value |> (sideEffect(), %)
.像往常一樣,逗號表達式將計算其右側%
,本質上是傳遞主題值而不修改它。這對於快速調試特別有用: value |> (console.log(%), %)
。
對原始範例的唯一變更是縮排和刪除註解。
來自 jquery/build/tasks/sourceMap.js:
// Status quo
var minLoc = Object . keys ( grunt . config ( "uglify.all.files" ) ) [ 0 ] ;
// With pipes
var minLoc = grunt . config ( 'uglify.all.files' ) | > Object . keys ( % ) [ 0 ] ;
來自node/deps/npm/lib/unpublish.js:
// Status quo
const json = await npmFetch . json ( npa ( pkgs [ 0 ] ) . escapedName , opts ) ;
// With pipes
const json = pkgs [ 0 ] | > npa ( % ) . escapedName | > await npmFetch . json ( % , opts ) ;
來自底線.js:
// Status quo
return filter ( obj , negate ( cb ( predicate ) ) , context ) ;
// With pipes
return cb ( predicate ) | > _ . negate ( % ) | > _ . filter ( obj , % , context ) ;
來自 ramda.js。