本章介绍一些尚未进入标准、但很有希望的最新提案。

do 表达式

本质上,块级作用域是一个语句,将多个操作封装在一起,没有返回值。

  1. {
  2. let t = f();
  3. t = t * t + 1;
  4. }

上面代码中,块级作用域将两个语句封装在一起。但是,在块级作用域以外,没有办法得到t的值,因为块级作用域不返回值,除非t是全局变量。

现在有一个提案,使得块级作用域可以变为表达式,也就是说可以返回值,办法就是在块级作用域之前加上do,使它变为do表达式,然后就会返回内部最后执行的表达式的值。

  1. let x = do {
  2. let t = f();
  3. t * t + 1;
  4. };

上面代码中,变量x会得到整个块级作用域的返回值(t * t + 1)。

do表达式的逻辑非常简单:封装的是什么,就会返回什么。

  1. // 等同于 <表达式>
  2. do { <表达式>; }
  3. // 等同于 <语句>
  4. do { <语句> }

do表达式的好处是可以封装多个语句,让程序更加模块化,就像乐高积木那样一块块拼装起来。

  1. let x = do {
  2. if (foo()) { f() }
  3. else if (bar()) { g() }
  4. else { h() }
  5. };

上面代码的本质,就是根据函数foo的执行结果,调用不同的函数,将返回结果赋给变量x。使用do表达式,就将这个操作的意图表达得非常简洁清晰。而且,do块级作用域提供了单独的作用域,内部操作可以与全局作用域隔绝。

值得一提的是,do表达式在 JSX 语法中非常好用。

  1. return (
  2. <nav>
  3. <Home />
  4. {
  5. do {
  6. if (loggedIn) {
  7. <LogoutButton />
  8. } else {
  9. <LoginButton />
  10. }
  11. }
  12. }
  13. </nav>
  14. )

上面代码中,如果不用do表达式,就只能用三元判断运算符(?:)。那样的话,一旦判断逻辑复杂,代码就会变得很不易读。

throw 表达式

JavaScript 语法规定throw是一个命令,用来抛出错误,不能用于表达式之中。

  1. // 报错
  2. console.log(throw new Error());

上面代码中,console.log的参数必须是一个表达式,如果是一个throw语句就会报错。

现在有一个提案,允许throw用于表达式。

  1. // 参数的默认值
  2. function save(filename = throw new TypeError("Argument required")) {
  3. }
  4. // 箭头函数的返回值
  5. lint(ast, {
  6. with: () => throw new Error("avoid using 'with' statements.")
  7. });
  8. // 条件表达式
  9. function getEncoder(encoding) {
  10. const encoder = encoding === "utf8" ?
  11. new UTF8Encoder() :
  12. encoding === "utf16le" ?
  13. new UTF16Encoder(false) :
  14. encoding === "utf16be" ?
  15. new UTF16Encoder(true) :
  16. throw new Error("Unsupported encoding");
  17. }
  18. // 逻辑表达式
  19. class Product {
  20. get id() {
  21. return this._id;
  22. }
  23. set id(value) {
  24. this._id = value || throw new Error("Invalid value");
  25. }
  26. }

上面代码中,throw都出现在表达式里面。

语法上,throw表达式里面的throw不再是一个命令,而是一个运算符。为了避免与throw命令混淆,规定throw出现在行首,一律解释为throw语句,而不是throw表达式。

函数的部分执行

语法

多参数的函数有时需要绑定其中的一个或多个参数,然后返回一个新函数。

  1. function add(x, y) { return x + y; }
  2. function add7(x) { return x + 7; }

上面代码中,add7函数其实是add函数的一个特殊版本,通过将一个参数绑定为7,就可以从add得到add7

  1. // bind 方法
  2. const add7 = add.bind(null, 7);
  3. // 箭头函数
  4. const add7 = x => add(x, 7);

上面两种写法都有些冗余。其中,bind方法的局限更加明显,它必须提供this,并且只能从前到后一个个绑定参数,无法只绑定非头部的参数。

现在有一个提案,使得绑定参数并返回一个新函数更加容易。这叫做函数的部分执行(partial application)。

  1. const add = (x, y) => x + y;
  2. const addOne = add(1, ?);
  3. const maxGreaterThanZero = Math.max(0, ...);

根据新提案,?是单个参数的占位符,...是多个参数的占位符。以下的形式都属于函数的部分执行。

  1. f(x, ?)
  2. f(x, ...)
  3. f(?, x)
  4. f(..., x)
  5. f(?, x, ?)
  6. f(..., x, ...)

?...只能出现在函数的调用之中,并且会返回一个新函数。

  1. const g = f(?, 1, ...);
  2. // 等同于
  3. const g = (x, ...y) => f(x, 1, ...y);

函数的部分执行,也可以用于对象的方法。

  1. let obj = {
  2. f(x, y) { return x + y; },
  3. };
  4. const g = obj.f(?, 3);
  5. g(1) // 4

注意点

函数的部分执行有一些特别注意的地方。

(1)函数的部分执行是基于原函数的。如果原函数发生变化,部分执行生成的新函数也会立即反映这种变化。

  1. let f = (x, y) => x + y;
  2. const g = f(?, 3);
  3. g(1); // 4
  4. // 替换函数 f
  5. f = (x, y) => x * y;
  6. g(1); // 3

上面代码中,定义了函数的部分执行以后,更换原函数会立即影响到新函数。

(2)如果预先提供的那个值是一个表达式,那么这个表达式并不会在定义时求值,而是在每次调用时求值。

  1. let a = 3;
  2. const f = (x, y) => x + y;
  3. const g = f(?, a);
  4. g(1); // 4
  5. // 改变 a 的值
  6. a = 10;
  7. g(1); // 11

上面代码中,预先提供的参数是变量a,那么每次调用函数g的时候,才会对a进行求值。

(3)如果新函数的参数多于占位符的数量,那么多余的参数将被忽略。

  1. const f = (x, ...y) => [x, ...y];
  2. const g = f(?, 1);
  3. g(2, 3, 4); // [2, 1]

上面代码中,函数g只有一个占位符,也就意味着它只能接受一个参数,多余的参数都会被忽略。

写成下面这样,多余的参数就没有问题。

  1. const f = (x, ...y) => [x, ...y];
  2. const g = f(?, 1, ...);
  3. g(2, 3, 4); // [2, 1, 3, 4];

(4)...只会被采集一次,如果函数的部分执行使用了多个...,那么每个...的值都将相同。

  1. const f = (...x) => x;
  2. const g = f(..., 9, ...);
  3. g(1, 2, 3); // [1, 2, 3, 9, 1, 2, 3]

上面代码中,g定义了两个...占位符,真正执行的时候,它们的值是一样的。

管道运算符

Unix 操作系统有一个管道机制(pipeline),可以把前一个操作的值传给后一个操作。这个机制非常有用,使得简单的操作可以组合成为复杂的操作。许多语言都有管道的实现,现在有一个提案,让 JavaScript 也拥有管道机制。

JavaScript 的管道是一个运算符,写作|>。它的左边是一个表达式,右边是一个函数。管道运算符把左边表达式的值,传入右边的函数进行求值。

  1. x |> f
  2. // 等同于
  3. f(x)

管道运算符最大的好处,就是可以把嵌套的函数,写成从左到右的链式表达式。

  1. function doubleSay (str) {
  2. return str + ", " + str;
  3. }
  4. function capitalize (str) {
  5. return str[0].toUpperCase() + str.substring(1);
  6. }
  7. function exclaim (str) {
  8. return str + '!';
  9. }

上面是三个简单的函数。如果要嵌套执行,传统的写法和管道的写法分别如下。

  1. // 传统的写法
  2. exclaim(capitalize(doubleSay('hello')))
  3. // "Hello, hello!"
  4. // 管道的写法
  5. 'hello'
  6. |> doubleSay
  7. |> capitalize
  8. |> exclaim
  9. // "Hello, hello!"

管道运算符只能传递一个值,这意味着它右边的函数必须是一个单参数函数。如果是多参数函数,就必须进行柯里化,改成单参数的版本。

  1. function double (x) { return x + x; }
  2. function add (x, y) { return x + y; }
  3. let person = { score: 25 };
  4. person.score
  5. |> double
  6. |> (_ => add(7, _))
  7. // 57

上面代码中,add函数需要两个参数。但是,管道运算符只能传入一个值,因此需要事先提供另一个参数,并将其改成单参数的箭头函数_ => add(7, _)。这个函数里面的下划线并没有特别的含义,可以用其他符号代替,使用下划线只是因为,它能够形象地表示这里是占位符。

管道运算符对于await函数也适用。

  1. x |> await f
  2. // 等同于
  3. await f(x)
  4. const userAge = userId |> await fetchUserById |> getAgeFromUser;
  5. // 等同于
  6. const userAge = getAgeFromUser(await fetchUserById(userId));

Math.signbit()

JavaScript 内部使用64位浮点数(国际标准 IEEE 754)表示数值。IEEE 754 规定,64位浮点数的第一位是符号位,0表示正数,1表示负数。所以会有两种零,+0是符号位为0时的零,-0是符号位为1时的零。实际编程中,判断一个值是+0还是-0非常麻烦,因为它们是相等的。

  1. +0 === -0 // true

ES6 新增的Math.sign()方法,只能用来判断数值的正负,对于判断数值的符号位用处不大。因为如果参数是-0,它会返回-0,还是不能直接知道符号位是1还是0

  1. Math.sign(-0) // -0

目前,有一个提案,引入了Math.signbit()方法判断一个数的符号位是否设置了。

  1. Math.signbit(2) //false
  2. Math.signbit(-2) //true
  3. Math.signbit(0) //false
  4. Math.signbit(-0) //true

可以看到,该方法正确返回了-0的符号位是设置了的。

该方法的算法如下。

  • 如果参数是NaN,返回false
  • 如果参数是-0,返回true
  • 如果参数是负值,返回true
  • 其他情况返回false

双冒号运算符

箭头函数可以绑定this对象,大大减少了显式绑定this对象的写法(call()apply()bind())。但是,箭头函数并不适用于所有场合,所以现在有一个提案,提出了“函数绑定”(function bind)运算符,用来取代call()apply()bind()调用。

函数绑定运算符是并排的两个冒号(::),双冒号左边是一个对象,右边是一个函数。该运算符会自动将左边的对象,作为上下文环境(即this对象),绑定到右边的函数上面。

  1. foo::bar;
  2. // 等同于
  3. bar.bind(foo);
  4. foo::bar(...arguments);
  5. // 等同于
  6. bar.apply(foo, arguments);
  7. const hasOwnProperty = Object.prototype.hasOwnProperty;
  8. function hasOwn(obj, key) {
  9. return obj::hasOwnProperty(key);
  10. }

如果双冒号左边为空,右边是一个对象的方法,则等于将该方法绑定在该对象上面。

  1. var method = obj::obj.foo;
  2. // 等同于
  3. var method = ::obj.foo;
  4. let log = ::console.log;
  5. // 等同于
  6. var log = console.log.bind(console);

如果双冒号运算符的运算结果,还是一个对象,就可以采用链式写法。

  1. import { map, takeWhile, forEach } from "iterlib";
  2. getPlayers()
  3. ::map(x => x.character())
  4. ::takeWhile(x => x.strength > 100)
  5. ::forEach(x => console.log(x));

Realm API

Realm API 提供沙箱功能(sandbox),允许隔离代码,防止那些被隔离的代码拿到全局对象。

以前,经常使用<iframe>作为沙箱。

  1. const globalOne = window;
  2. let iframe = document.createElement('iframe');
  3. document.body.appendChild(iframe);
  4. const globalTwo = iframe.contentWindow;

上面代码中,<iframe>的全局对象是独立的(iframe.contentWindow)。Realm API 可以取代这个功能。

  1. const globalOne = window;
  2. const globalTwo = new Realm().global;

上面代码中,Realm API单独提供了一个全局对象new Realm().global

Realm API 提供一个Realm()构造函数,用来生成一个 Realm 对象。该对象的global属性指向一个新的顶层对象,这个顶层对象跟原始的顶层对象类似。

  1. const globalOne = window;
  2. const globalTwo = new Realm().global;
  3. globalOne.evaluate('1 + 2') // 3
  4. globalTwo.evaluate('1 + 2') // 3

上面代码中,Realm 生成的顶层对象的evaluate()方法,可以运行代码。

下面的代码可以证明,Realm 顶层对象与原始顶层对象是两个对象。

  1. let a1 = globalOne.evaluate('[1,2,3]');
  2. let a2 = globalTwo.evaluate('[1,2,3]');
  3. a1.prototype === a2.prototype; // false
  4. a1 instanceof globalTwo.Array; // false
  5. a2 instanceof globalOne.Array; // false

上面代码中,Realm 沙箱里面的数组的原型对象,跟原始环境里面的数组是不一样的。

Realm 沙箱里面只能运行 ECMAScript 语法提供的 API,不能运行宿主环境提供的 API。

  1. globalTwo.evaluate('console.log(1)')
  2. // throw an error: console is undefined

上面代码中,Realm 沙箱里面没有console对象,导致报错。因为console不是语法标准,是宿主环境提供的。

如果要解决这个问题,可以使用下面的代码。

  1. globalTwo.console = globalOne.console;

Realm()构造函数可以接受一个参数对象,该参数对象的intrinsics属性可以指定 Realm 沙箱继承原始顶层对象的方法。

  1. const r1 = new Realm();
  2. r1.global === this;
  3. r1.global.JSON === JSON; // false
  4. const r2 = new Realm({ intrinsics: 'inherit' });
  5. r2.global === this; // false
  6. r2.global.JSON === JSON; // true

上面代码中,正常情况下,沙箱的JSON方法不同于原始的JSON对象。但是,Realm()构造函数接受{ intrinsics: 'inherit' }作为参数以后,就会继承原始顶层对象的方法。

用户可以自己定义Realm的子类,用来定制自己的沙箱。

  1. class FakeWindow extends Realm {
  2. init() {
  3. super.init();
  4. let global = this.global;
  5. global.document = new FakeDocument(...);
  6. global.alert = new Proxy(fakeAlert, { ... });
  7. // ...
  8. }
  9. }

上面代码中,FakeWindow模拟了一个假的顶层对象window

JSON 模块

import 命令目前只能用于加载 ES 模块,现在有一个提案,允许加载 JSON 模块。

假定有一个 JSON 模块文件config.json

  1. {
  2. "appName": "My App"
  3. }

目前,只能使用fetch()加载 JSON 模块。

  1. const response = await fetch('./config.json');
  2. const json = await response.json();

import 命令能够直接加载 JSON 模块以后,就可以像下面这样写。

  1. import configData from './config.json' assert { type: "json" };
  2. console.log(configData.appName);

上面示例中,整个 JSON 对象被导入为configData对象,然后就可以从该对象获取 JSON 数据。

import命令导入 JSON 模块时,命令结尾的assert {type: "json"}不可缺少。这叫做导入断言,用来告诉 JavaScript 引擎,现在加载的是 JSON 模块。你可能会问,为什么不通过.json后缀名判断呢?因为浏览器的传统是不通过后缀名判断文件类型,标准委员会希望遵循这种做法,这样也可以避免一些安全问题。

导入断言是 JavaScript 导入其他格式模块的标准写法,JSON 模块将是第一个使用这种语法导入的模块。以后,还会支持导入 CSS 模块、HTML 模块等等。

动态加载模块的import()函数也支持加载 JSON 模块。

  1. import('./config.json', { assert: { type: 'json' } })

脚本加载 JSON 模块以后,还可以再用 export 命令输出。这时,可以将 export 和 import 结合成一个语句。

  1. export { config } from './config.json' assert { type: 'json' };