JavaScript 的未来:它还少些什么?
翻译:疯狂的技术宅
原文:http://2ality.com/2019/01/future-js.html
近年来,JavaScript 的功能得到了大幅度的增加,本文探讨了其仍然缺失的东西。
说明:
- 我只列出了我所发现的最重要的功能缺失。当然还有很多其它有用的功能,但同时也会增加太多的风险。
- 我的选择是主观的。
- 本文中提及的几乎所有内容都在 TC39 的技术雷达上。 也就是说,它还可以作为未来可能的 JavaScript 的预览。
有关前两个问题的更多想法,请参阅本文第8节:语言设计部分。
1. 值
1.1 按值比较对象
目前,JavaScript 只能对原始值(value)进行比较,例如字符串的值(通过查看其内容):
> 'abc' === 'abc'
true
相反,对象则通过身份ID(identity)进行比较(对象仅严格等于自身):
> {x: 1, y: 4} === {x: 1, y: 4}
false
如果有一种能够创建按值进行比较对象的方法,那将是很不错的:
> #{x: 1, y: 4} === #{x: 1, y: 4}
true
另一种可能性是引入一种新的类(确切的细节还有待确定):
@[ValueType]
class Point {
// ···
}
旁注:这种类似装饰器的将类标记为值类型的的语法基于草案提案。
1.2 将对象放入数据结构中
如果对象通过身份ID进行比较,将它们放入 ECMAScript 数据结构(如Maps)中并没有太大意义:
const m = new Map();
m.set({x: 1, y: 4}, 1);
m.set({x: 1, y: 4}, 2);
assert.equal(m.size, 2);
可以通过自定义值类型修复此问题。 或者通过自定义 Set 元素和 Map keys 的管理。 例如:
-
通过哈希表映射:需要一个操作来检查值是否相等,另一个操作用于创建哈希码。 如果使用哈希码,则对象应该是不可变的。 否则破坏数据结构就太容易了。
-
通过排序树映射:需要一个比较值的操作用来管理它存储的值。
1.3 大整数
JavaScript 的数字总是64位的(双精度),它能为整数提供53位二进制宽度。这意味着如果超过53位,就不好使了:
> 2 ** 53
9007199254740992
> (2 ** 53) + 1 // can’t be represented
9007199254740992
> (2 ** 53) + 2
9007199254740994
对于某些场景,这是一个相当大的限制。现在有BigInts提案,这是真正的整数,其精度可以随着需要而增长:
> 2n ** 53n
9007199254740992n
> (2n ** 53n) + 1n
9007199254740993n
BigInts还支持 casting,它为你提供固定位数的值:
const int64a = BigInt.asUintN(64, 12345n);
const int64b = BigInt.asUintN(64, 67890n);
const result = BigInt.asUintN(64, int64a * int64b);
1.4 小数计算
JavaScript 的数字是基于 IEEE 754 标准的64位浮点数(双精度数)。鉴于它们的表示形式是基于二进制的,在处理小数部分时可能会出现舍入误差:
> 0.1 + 0.2
0.30000000000000004
这在科学计算和金融技术(金融科技)中尤其成问题。基于十进制运算的提案目前处于阶段0。它们可能最终被这样使用(注意十进制数的后缀 m
):
> 0.1m + 0.2m
0.3m
1.5 对值进行分类
目前,在 JavaScript 中对值进行分类非常麻烦:
-
首先,你必须决定是否使用
typeof
或instanceof
。 -
其次,
typeof
有一个众所周知的的怪癖,就是把null
归类为“对象”。我还认为函数被归类为'function'
同样是奇怪的。
> typeof null
'object'
> typeof function () {}
'function'
> typeof []
'object'
- 第三,
instanceof
不适用于来自其他realm(框架等)的对象。
也许可能通过库来解决这个问题(如果我有时间,就会实现一个概念性的验证)。
2. 函数式编程
2.1 更多表达式
不幸的是C风格的语言在表达式和语句之间做出了区分:
// 条件表达式
let str1 = someBool ? 'yes' : 'no';
// 条件声明
let str2;
if (someBool) {
str2 = 'yes';
} else {
str2 = 'no';
}
特别是在函数式语言中,一切都是表达式。 Do-expressions 允许你在所有表达式上下文中使用语句:
let str3 = do {
if (someBool) {
'yes'
} else {
'no'
}
};
下面的代码是一个更加现实的例子。如果没有 do-expression,你需要一个立即调用的箭头函数来隐藏范围内的变量 result
:
const func = (() => {
let result; // cache
return () => {
if (result === undefined) {
result = someComputation();
}
return result;
}
})();
使用 do-expression,你可以更优雅地编写这段代码:
const func = do {
let result;
() => {
if (result === undefined) {
result = someComputation();
}
return result;
};
};
2.2 匹配:解构 switch
JavaScript 使直接使用对象变得容易。但是根据对象的结构,没有内置的切换 case 分支的方法。看起来是这样的(来自提案的例子):
const resource = await fetch(jsonService);
case (resource) {
when {status: 200, headers: {'Content-Length': s}} -> {
console.log(`size is ${s}`);
}
when {status: 404} -> {
console.log('JSON not found');
}
when {status} if (status >= 400) -> {
throw new RequestError(res);
}
}
正如你所看到的那样,新的 case
语句在某些方面类似于 switch
,不过它使用解构来挑选分支。当人们使用嵌套数据结构(例如在编译器中)时,这种功能非常有用。 模式匹配提案目前处于第1阶段。
2.3 管道操作
管道操作目前有两个竞争提案 。在本文,我们研究其中的 智能管道(另一个提议被称为 F# Pipelines)。
管道操作的基本思想如下。请考虑代码中的嵌套函数调用。
const y = h(g(f(x)));
但是,这种表示方法通常不能体现我们对计算步骤的看法。在直觉上,我们将它们描述为:
- 从值
x
开始。 - 然后把
f()
作用在x
上。 - 然后将
g()
作用于结果。 - 然后将
h()
应用于结果。 - 最后将结果赋值给
y
。
管道运算符能让我们更好地表达这种直觉:
const y = x |> f |> g |> h;
换句话说,以下两个表达式是等价的。
f(123)
123 |> f
另外,管道运算符支持部分应用程序(类似函数的 .bind()
方法):以下两个表达式是等价的。
123 |> f(#)
123 |> (x => f(x))
使用管道运算符一个最大的好处是,你可以像使用方法一样使用函数——而无需更改任何原型:
import {map} from 'array-tools';
const result = arr |> map(#, x => x * 2);
最后,让我们看一个长一点的例子(取自提案并稍作编辑):
promise
|> await #
|> # || throw new TypeError(
`Invalid value from ${promise}`)
|> capitalize // function call
|> # + '!'
|> new User.Message(#)
|> await stream.write(#)
|> console.log // method call
;
3 并发
一直以来 JavaScript 对并发性的支持很有限。并发进程的事实标准是 Worker API,可以在 web browsers 和 Node.js (在 v11.7 及更高版本中没有标记)中找到。
在Node.js中的使用方法它如下所示:
const {
Worker, isMainThread, parentPort, workerData
} = require('worker_threads');
if (isMainThread) {
const worker = new Worker(__filename, {
workerData: 'the-data.json'
});
worker.on('message', result => console.log(result));
worker.on('error', err => console.error(err));
worker.on('exit', code => {
if (code !== 0) {
console.error('ERROR: ' + code);
}
});
} else {
const {readFileSync} = require('fs');
const fileName = workerData;
const text = readFileSync(fileName, {encoding: 'utf8'});
const json = JSON.parse(text);
parentPort.postMessage(json);
}
唉,相对来说 Workers 是重量级的 —— 每个都有自己的 realm(全局变量等)。我想在未来看到一个更加轻量级的构造。
4. 标准库
JavaScript 仍然明显落后于其他语言的一个领域是它的标准库。当然保持最小化是有意义的,因为外部库更容易进化和适应。但是有一些核心功能也是有必要的。
4.1 用模块替代命名空间对象
JavaScript 标准库是在其语言具有模块之前创建的。因此函数被放在命名空间对象中,例如Object
,Reflect
,Math
和JSON
:
Object.keys()
Reflect.ownKeys()
Math.sign()
JSON.parse()
如果将这个功能放在模块中会更好。它必须通过特殊的URL访问,例如使用伪协议 std
:
// Old:
assert.deepEqual(
Object.keys({a: 1, b: 2}),
['a', 'b']);
// New:
import {keys} from 'std:object';
assert.deepEqual(
keys({a: 1, b: 2}),
['a', 'b']);
好处是:
- JavaScript 将变得更加模块化(这可以加快启动时间并减少内存消耗)。
- 调用导入的函数比调用存储在对象中的函数更快。
4.2 可迭代工具 (sync 与 async)
迭代 的好处包括按需计算和支持许多数据源。但是目前 JavaScript 只提供了很少的工具来处理 iterables。例如,如果要 过滤、映射或消除重复,则必须将其转换为数组:
const iterable = new Set([-1, 0, -2, 3]);
const filteredArray = [...iterable].filter(x => x >= 0);
assert.deepEqual(filteredArray, [0, 3]);
如果 JavaScript 具有可迭代的工具函数,你可以直接过滤迭代:
const filteredIterable = filter(iterable, x => x >= 0);
assert.deepEqual(
// We only convert the iterable to an Array, so we can
// check what’s in it:
[...filteredIterable], [0, 3]);
以下是迭代工具函数的一些示例:
// Count elements in an iterable
assert.equal(count(iterable), 4);
// Create an iterable over a part of an existing iterable
assert.deepEqual(
[...slice(iterable, 2)],
[-1, 0]);
// Number the elements of an iterable
// (producing another – possibly infinite – iterable)
for (const [i,x] of zip(range(0), iterable)) {
console.log(i, x);
}
// Output:
// 0, -1
// 1, 0
// 2, -2
// 3, 3
笔记:
- 有关迭代器的工具函数示例,请参阅 Python 的 itertools (https://docs.python.org/3/library/itertools.html)。
- 对于 JavaScript,迭代的每个工具函数应该有两个版本:一个用于同步迭代,一个用于异步迭代。
4.3 不可变数据
很高兴能看到对数据的非破坏性转换有更多的支持。两个相关的库是:
- Immer 相对轻量,适用于普通对象和数组。
- Immutable.js 更强大,更重量级,并附带自己的数据结构。
4.4 更好地支持日期和时间
JavaScript 对日期和时间的内置支持有许多奇怪的地方。这就是为什么目前建议用库来完成除了最基本任务之外的其它所有工作。
值得庆幸的是 temporal
是一个更好的时间 API:
const dateTime = new CivilDateTime(2000, 12, 31, 23, 59);
const instantInChicago = dateTime.withZone('America/Chicago');
5. 可能不需要的功能
5.1 optional chaining 的优缺点
一个相对流行的提议功能是 optional chaining。以下两个表达式是等效的。
obj?.prop
(obj === undefined || obj === null) ? undefined : obj.prop
此功能对于属性链特别方便:
obj?.foo?.bar?.baz
但是,仍然存在缺点:
- 深层嵌套的结构更难管理。
- 由于在访问数据时过于宽容,隐藏了以后可能会出现的问题,更难以调试。
optional chaining 的替代方法是在单个位置提取一次信息:
- 你可以编写一个提取数据的辅助函数。
- 或者你可以编写一个函数,使其输入是深层嵌套数据,但是输出更简单、标准化的数据。
无论采用哪种方法,都可以执行检查并在出现问题时尽早抛出异常。
进一步阅读:
5.2 我们需要运算符重载吗?
目前正在为 运算符重载 进行早期工作,但是 infix 函数可能就足够了(目前还没有提案):
import {BigDecimal, plus} from 'big-decimal';
const bd1 = new BigDecimal('0.1');
const bd2 = new BigDecimal('0.2');
const bd3 = bd1 @plus bd2; // plus(bd1, bd2)
infix 函数的好处是:
- 你可以创建 JavaScript 以外的运算符。
- 与普通函数相比,嵌套表达式的可读性仍然很好。
下面是嵌套表达式的例子:
a @plus b @minus c @times d
times(minus(plus(a, b), c), d)
有趣的是,管道操作符还有助于提高可读性:
plus(a, b)
|> minus(#, c)
|> times(#, d)
6. 各种小东西
以下是我偶尔会遗漏的一些东西,但我认为不如前面提到的那些重要:
- 链式异常:使你能够捕获错误,能够包含其他信息并再次抛出。
new ChainedError(msg, origError)
re`/^${RE_YEAR}-${RE_MONTH}-${RE_DAY}$/u`
- 正则表达式的转义文本(对于
.replace()
很重要):
> const re = new RegExp(RegExp.escape(':-)'), 'ug');
> ':-) :-) :-)'.replace(re, '🙂')
'🙂 🙂 🙂'
- 支持负索引的
Array.prototype.get()
:
> ['a', 'b'].get(-1)
'b'
- 匹配和解构的模式(KatMarchán 的提案):
function f(...[x, y] as args) {
if (args.length !== 2) {
throw new Error();
}
// ···
}
- 检查对象的深度相等性(有可能:可选地使用谓词进行参数化,以支持自定义数据结构):
assert.equal(
{foo: ['a', 'b']} === {foo: ['a', 'b']},
false);
assert.equal(
deepEqual({foo: ['a', 'b']}, {foo: ['a', 'b']}),
true);
- 枚举:向 JavaScript 添加枚举的一个好处是可以缩小与 TypeScript 的差距。目前有两份提案草案(尚未处于正式阶段)。 一个是Rick Waldron,另一个是Ron Buckton。在两个提案中,最简单的语法如下所示:
enum WeekendDay {
Saturday, Sunday
}
const day = WeekendDay.Sunday;
- Tagged collection literals (由KatMayán提议 - 并撤回):允许你创建 Map 和 Set,如下所示:
const myMap = Map!{1: 2, three: 4, [[5]]: 6}
// new Map([1,2], ['three',4], [[5],6])
const mySet = Set!['a', 'b', 'c'];
// new Set(['a', 'b', 'c'])
7. FAQ:未来的JavaScript
7.1 JavaScript 会不会支持静态类型?
不会很快!当前开发时的静态类型(通过 TypeScript 或 Flow)和运行时的纯 JavaScript 之间的分离效果很好。所以没有什么合理的理由改变它。
7.2 为什么我们不能通过删除怪异和过时的功能来清理 JavaScript?
Web 的一个关键要求是:永远不要破坏向后兼容性:
- 缺点是语言会有许多遗留功能。
- 但好处大于缺点:大型代码库仍然是同质的,迁移到新版本很简单,引擎仍然较小(不需要支持多个版本)等等
通过引入当前功能的更好版本,仍然可以修复一些错误。
有关此主题的更多信息,请参阅“针对不耐烦的程序员的 JavaScript ”。
8. 关于语言设计的思考
作为一名语言设计师,无论你做什么,都会使一些人开心,而另一些人会伤心。因此,设计未来 JavaScript 功能的主要挑战不是让每个人都满意,而是让语言尽可能保持一致。
但是对于“一致”的含义,也存在分歧。因此,我们可以做到的最好的事情就是建立一致的“风格”,由一小群人(最多三人)构思和执行。不过这并不排除他们接受许多其他人的建议和帮助,但他们应该设定一个基调。
引用 Fred Brooks:
稍微回顾一下,尽管许多优秀实用的软件系统都是由委员会设计的,并且是作为一些项目的一部分而构建的,但是从本质上说,那些拥有大量激情粉丝的软件就是一个或几个设计思想的产品,——致伟大的设计师。
这些核心设计师的一个重要职责是对功能说“不”,以防止 JavaScript 变得太大。
他们还需要一个强大的支持系统,因为语言设计者往往会遭到严重的滥用(因为人们关心并且不喜欢听到“不”)。 最近的一个例子是 Guido van Rossum 辞去了首席 Python 语言设计师的工作,因为他受到了虐待。
8.1 其他想法
这些想法可能也有助于设计和见证 JavaScript:
- 创建描述 JavaScript 未来前景的路线图。这样的路线图可以用讲故事的方式并将许多单独的部分连接成一个连贯的整体。我所知的最后一个这样的路线图是 Brendan Eich 的“和谐的梦想”。
- 记录设计理念。现在,ECMAScript 规范只记录了 怎样 做,而没有 为什么 。举个例子:可枚举性的目的是什么?
- 规范的解释者。半正式的规范部分几乎已经可执行。如果能够像编程语言一样对待和运行它们会很棒。 (你可能需要一个约定来区分规范代码和非规范辅助函数。)
鸣谢:感谢Daniel Ehrenberg对本博文的反馈!
本文首发微信公众号:jingchengyideng
关注微信公众号,每天推送最新前端趋势和技术文章