|>
) (本文档使用%
作为主题参考的占位符标记。这几乎肯定不是最终选择;有关详细信息,请参阅标记 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')
将抛出语法错误。
没有其他特殊规则。
这些规则的一个自然结果是,如果我们需要在管道表达式链的中间插入副作用,而不修改正在通过管道传输的数据,那么我们可以使用逗号表达式,例如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。