11 KiB
最新提案
本章介绍一些尚未进入标准、但很有希望的最新提案。
do 表达式
本质上,块级作用域是一个语句,将多个操作封装在一起,没有返回值。
{
let t = f();
t = t * t + 1;
}
上面代码中,块级作用域将两个语句封装在一起。但是,在块级作用域以外,没有办法得到t
的值,因为块级作用域不返回值,除非t
是全局变量。
现在有一个提案,使得块级作用域可以变为表达式,也就是说可以返回值,办法就是在块级作用域之前加上do
,使它变为do
表达式,然后就会返回内部最后执行的表达式的值。
let x = do {
let t = f();
t * t + 1;
};
上面代码中,变量x
会得到整个块级作用域的返回值(t * t + 1
)。
do
表达式的逻辑非常简单:封装的是什么,就会返回什么。
// 等同于 <表达式>
do { <表达式>; }
// 等同于 <语句>
do { <语句> }
do
表达式的好处是可以封装多个语句,让程序更加模块化,就像乐高积木那样一块块拼装起来。
let x = do {
if (foo()) { f() }
else if (bar()) { g() }
else { h() }
};
上面代码的本质,就是根据函数foo
的执行结果,调用不同的函数,将返回结果赋给变量x
。使用do
表达式,就将这个操作的意图表达得非常简洁清晰。而且,do
块级作用域提供了单独的作用域,内部操作可以与全局作用域隔绝。
值得一提的是,do
表达式在 JSX 语法中非常好用。
return (
<nav>
<Home />
{
do {
if (loggedIn) {
<LogoutButton />
} else {
<LoginButton />
}
}
}
</nav>
)
上面代码中,如果不用do
表达式,就只能用三元判断运算符(?:
)。那样的话,一旦判断逻辑复杂,代码就会变得很不易读。
throw 表达式
JavaScript 语法规定throw
是一个命令,用来抛出错误,不能用于表达式之中。
// 报错
console.log(throw new Error());
上面代码中,console.log
的参数必须是一个表达式,如果是一个throw
语句就会报错。
现在有一个提案,允许throw
用于表达式。
// 参数的默认值
function save(filename = throw new TypeError("Argument required")) {
}
// 箭头函数的返回值
lint(ast, {
with: () => throw new Error("avoid using 'with' statements.")
});
// 条件表达式
function getEncoder(encoding) {
const encoder = encoding === "utf8" ?
new UTF8Encoder() :
encoding === "utf16le" ?
new UTF16Encoder(false) :
encoding === "utf16be" ?
new UTF16Encoder(true) :
throw new Error("Unsupported encoding");
}
// 逻辑表达式
class Product {
get id() {
return this._id;
}
set id(value) {
this._id = value || throw new Error("Invalid value");
}
}
上面代码中,throw
都出现在表达式里面。
语法上,throw
表达式里面的throw
不再是一个命令,而是一个运算符。为了避免与throw
命令混淆,规定throw
出现在行首,一律解释为throw
语句,而不是throw
表达式。
Null 传导运算符
编程实务中,如果读取对象内部的某个属性,往往需要判断一下该对象是否存在。比如,要读取message.body.user.firstName
,安全的写法是写成下面这样。
const firstName = (message
&& message.body
&& message.body.user
&& message.body.user.firstName) || 'default';
这样的层层判断非常麻烦,因此现在有一个提案,引入了“Null 传导运算符”(null propagation operator)?.
,简化上面的写法。
const firstName = message?.body?.user?.firstName || 'default';
上面代码有三个?.
运算符,只要其中一个返回null
或undefined
,就不再往下运算,而是返回undefined
。
“Null 传导运算符”有四种用法。
obj?.prop
// 读取对象属性obj?.[expr]
// 同上func?.(...args)
// 函数或对象方法的调用new C?.(...args)
// 构造函数的调用
传导运算符之所以写成obj?.prop
,而不是obj?prop
,是为了方便编译器能够区分三元运算符?:
(比如obj?prop:123
)。
下面是更多的例子。
// 如果 a 是 null 或 undefined, 返回 undefined
// 否则返回 a.b.c().d
a?.b.c().d
// 如果 a 是 null 或 undefined,下面的语句不产生任何效果
// 否则执行 a.b = 42
a?.b = 42
// 如果 a 是 null 或 undefined,下面的语句不产生任何效果
delete a?.b
直接输入 U+2028 和 U+2029
JavaScript 字符串允许直接输入字符,以及输入字符的转义形式。举例来说,“中”的 Unicode 码点是 U+4e2d,你可以直接在字符串里面输入这个汉字,也可以输入它的转义形式\u4e2d
,两者是等价的。
'中' === '\u4e2d' // true
但是,JavaScript 规定有5个字符,不能在字符串里面直接使用,只能使用转义形式。
- U+005C:反斜杠(reverse solidus)
- U+000D:回车(carriage return)
- U+2028:行分隔符(line separator)
- U+2029:段分隔符(paragraph separator)
- U+000A:换行符(line feed)
举例来说,字符串里面不能直接包含反斜杠,一定要转义写成\\
或者\u005c
。
这个规定本身没有问题,麻烦在于 JSON 格式允许字符串里面直接使用 U+2028(行分隔符)和 U+2029(段分隔符)。这样一来,服务器输出的 JSON 被JSON.parse
解析,就有可能直接报错。
JSON 格式已经冻结(RFC 7159),没法修改了。为了消除这个报错,现在有一个提案,允许 JavaScript 字符串直接输入 U+2028(行分隔符)和 U+2029(段分隔符)。
const PS = eval("'\u2029'");
根据这个提案,上面的代码不会报错。
注意,模板字符串现在就允许直接输入这两个字符。另外,正则表达式依然不允许直接输入这两个字符,这是没有问题的,因为 JSON 本来就不允许直接包含正则表达式。
函数的部分执行
语法
多参数的函数有时需要绑定其中的一个或多个函数,然后返回一个新函数。
function add(x, y) { return x + y; }
function add7(x) { return x + 7; }
上面代码中,add7
函数其实是add
函数的一个特殊版本,通过将一个参数绑定为7
,就可以从add
得到add7
。
// bind 方法
const add7 = add.bind(null, 7);
// 箭头函数
const add7 = x => add(x, 7);
上面两种写法都有些冗余。其中,bind
方法的局限更加明显,它必须提供this
,并且只能从前到后一个个绑定参数,无法只绑定非头部的参数。
现在有一个提案,使用绑定参数然后返回一个新函数更加容器。这叫做函数的部分执行(partial application)。
const add = (x, y) => x + y;
const addOne = add(1, ?);
const maxGreaterThanZero = Math.max(0, ...);
根据新提案,?
是单个参数的占位符,...
是多个参数的占位符。以下的形式都属于函数的部分执行。
f(x, ?)
f(x, ...)
f(?, x)
f(..., x)
f(?, x, ?)
f(..., x, ...)
?
和...
只能出现在函数的调用之中,并且会返回一个新函数。
const g = f(?, 1, ...);
// 等同于
const g = (x, ...y) => f(x, 1, ...y);
注意点
函数的部分执行有一些特别注意的地方。
(1)函数的部分执行是基于原函数的。如果原函数发生变化,部分执行生成的新函数也会立即反映这种变化。
let f = (x, y) => x + y;
const g = f(?, 3);
g(1); // 4
// 替换函数 f
f = (x, y) => x * y;
g(1); // 3
上面代码中,定义了函数的部分执行以后,更换原函数会立即影响到新函数。
(2)如果预先提供的那个值是一个表达式,那么这个表达式并不会在定义时求值,而是在每次调用时求值。
let a = 3;
const f = (x, y) => x + y;
const g = f(?, a);
g(1); // 4
// 改变 a 的值
a = 10;
g(1); // 11
上面代码中,预先提供的参数是变量a
,那么每次调用函数g
的时候,才会对a
进行求值。
(3)如果新函数的参数多于占位符的数量,那么多余的参数将被忽略。
const f = (x, ...y) => [x, ...y];
const g = f(?, 1);
g(2, 3, 4); // [2, 1]
上面代码中,函数g
只有一个占位符,也就意味着它只能接受一个参数,多余的参数都会被忽略。
写成下面这样,多余的参数就没有问题。
const f = (x, ...y) => [x, ...y];
const g = f(?, 1, ...);
g(2, 3, 4); // [2, 1, 3, 4];
(4)...
只会被采集一次,如果函数的部分执行使用了多个...
,那么每个...
的值都将相同。
const f = (...x) => x;
const g = f(..., 9, ...);
g(1, 2, 3); // [1, 2, 3, 9, 1, 2, 3]
上面代码中,g
定义了两个...
占位符,真正执行的时候,它们的值是一样的。
管道运算符
Unix 操作系统有一个管道机制(pipeline),可以把前一个操作的值传给后一个操作。这个机制非常有用,使得简单的操作可以组合成为复杂的操作。许多语言都有管道的实现,现在有一个提案,让 JavaScript 也拥有管道机制。
JavaScript 的管道是一个运算符,写作|>
。它的左边是一个表达式,右边是一个函数。管道运算符把左边表达式的值,传入右边的函数进行求职。
x |> f
// 等同于
f(x)
管道运算符最大的好处,就是可以把嵌套的函数,写成从左到右的链式表达式。
function doubleSay (str) {
return str + ", " + str;
}
function capitalize (str) {
return str[0].toUpperCase() + str.substring(1);
}
function exclaim (str) {
return str + '!';
}
上面是三个简单的函数。如果要嵌套执行,传统的写法和管道的写法分别如下。
// 传统的写法
exclaim(capitalize(doubleSay('hello')))
// "Hello, hello!"
// 管道的写法
'hello'
|> doubleSay
|> capitalize
|> exclaim
// "Hello, hello!"
管道运算符只能传递一个值,这意味着它右边的函数必须是一个单参数函数。如果是多参数函数,就必须进行柯里化,改成单参数的版本。
function double (x) { return x + x; }
function add (x, y) { return x + y; }
let person = { score: 25 };
person.score
|> double
|> (_ => add(7, _))
// 57
上面代码中,add
函数需要两个参数。但是,管道运算符只能传入一个值,因此需要事先提供另一个参数,并将其改成单参数的箭头函数_ => add(7, _)
。这个函数里面的下划线并没有特别的含义,可以用其他符号代替,使用下划线只是因为,它能够形象地表示这里是占位符。
管道运算符对于await
函数也适用。
x |> await f
// 等同于
await f(x)
const userAge = userId |> await fetchUserById |> getAgeFromUser;
// 等同于
const userAge = getAgeFromUser(await fetchUserById(userId));