你应该知道的三个改变游戏规则的 JavaScript 提案

JavaScript 正在不断发展,未来令人兴奋!当您每天编写 `if` 语句并与 Date 对象搏斗时,TC39 成员正在酝酿一些改变游戏规则的功能,这些功能可能会彻底改变我们编写 JavaScript 的方式。从优雅的模式匹配到直观的日期处理,让我们深入了解三项可能让您的开发人员生活变得更好的提案。

JS meme

ECMAScript

ECMAScript 是 JavaScript 的标准化规范 - 它本质上是定义该语言应如何工作的蓝图。JavaScript 是实现,而 ECMAScript 是描述该语言规则和特性的标准。

每年(更准确地说是每年六月),ECMAScript 委员会都会发布一份规范文档,描述“最新的年度快照以及自拍摄快照以来所有完成的提案”。

今天,我们将重点介绍 TC39(技术委员会 39),该委员会负责发展 ECMAScript。它由各种利益相关者组成,包括浏览器供应商、学术界和其他科技公司(IBM、Meta、Shopify 等)。他们遵循特定的流程,通过提案向标准添加新功能。

提案流程

要将新功能添加到标准中,必须经过多个阶段。事实上,JavaScript 被数百万(或数十亿)人使用,因此确保新功能稳定且经过深思熟虑非常重要。

目前,全球 98.3% 的网站都使用 JavaScript,这强调了确保主流浏览器在实现新功能之前新功能稳定的重要性。

为此,TC39 委员会建立了 6 个阶段的流程。

第 0 阶段:稻草人

一切从这里开始。任何 TC39 成员或已注册为 TC39 贡献者的外部贡献者都可以提交提案。

然后,该提案必须在委员会会议上提出。此后,它将正式成为第 0 阶段提案并在此处列出。

第一阶段:提案

这时提案就变得更加严肃了。需要确定所谓的“倡导者”和/或“共同倡导者”。他们是提案的作者和编辑,并负责推动提案的推进。

支持者必须写下提案解决的问题、解决方案的高层概述(通过示例)以及新功能的详细规范(API 规范和语义细节)。这也是识别潜在挑战的地方。

第二阶段:草案

开始吧!委员会已经选定了一个首选解决方案。现在,委员会希望该解决方案能够得到实施。由于它仍处于草案阶段,因此规范尚未最终确定,仍可能更改,但该功能很有可能被添加到标准中。

在此阶段,鼓励使用实验性实现(例如 polyfill)来帮助验证设计,但它们还不需要成为可用于生产的实现。

第 2.7 阶段(2023 年正式添加新阶段)

由于这是一个相对较新的阶段,我将让 Rob Palmer(TC39 联合主席)更好地解释它:

阶段 2.7 相当于我们过去所说的阶段 3。这意味着设计被认为是完整的,我们有完整的规范,我们需要编写代码(测试和非 polyfill 实现)来获得反馈并取得进展。这是一个强烈的信号 💪

@robpalmer22024 年 2 月 7 日

🤔 我们为什么要这样做?我们将“原则上批准:规范就绪”阶段与后来的“建议实施:测试就绪”阶段分开,以减少在规范稳定之前编写测试所浪费的精力,同时也向引擎阐明测试就绪信息。

@robpalmer22024 年 2 月 7 日

第三阶段:候选阶段

至此,提案基本完成。在前一阶段,规范中可能还留有一些待办事项和占位符。现在,规范需要完善。必须至少有两个符合规范的实现。

处于此阶段是向 JS 引擎发送的信号,表明该提案已准备好实施。

第四阶段:完成

该规范现在将被纳入年度 ECMAScript 规范文档。这是一段相当漫长的旅程,不是吗?

探讨一些建议

模式匹配(第一阶段)

如果您已经使用过“Scala”、“Rust”或“Elixir”等语言,那么您可能知道模式匹配。使用模式匹配,我们可以根据传入值的类型应用自定义逻辑。

目前,JavaScript 的模式匹配功能仅限于通过正则表达式进行字符串操作。提议的功能将扩展模式匹配以处理各种数据类型和结构。

让我们从提案的文件中举一个例子:

match (res) {
  when { status: 200, let body, ...let rest }: handleData(body, rest);
  when { const status, destination: let url } and if (300 <= status && status < 400):
    handleRedirect(url);
  when { status: 500 } and if (!this.hasRetried): do {
    retry(req);
    this.hasRetried = true;
  };
  default: throwSomething();
}

代码使用“match”表达式检查“res”对象,该对象代表服务器的响应。每个“when”子句定义一个与“res”对象匹配的模式,如果满足该模式,则执行相应的代码块。

  • 第一个模式 { status: 200, let body, ...let rest } 匹配成功响应(状态代码 200)。它提取响应的主体和其余属性(rest)并将它们传递给 handleData 函数。
  • 第二个模式 { const status, destination: let url } 和 if (300 <= status && status < 400) 匹配重定向响应(状态代码 300-399)。它提取状态代码和 url 目标(重命名为 url),然后在调用 handleRedirect 函数之前检查状态是否在重定向范围内。
  • 第三个模式 { status: 500 } 和 if (!this.hasRetried) 匹配服务器错误(状态代码 500)。如果请求尚未重试,它会调用重试函数并设置 hasRetried 标志以防止无限重试。
  • 如果前面的模式均不匹配,则执行默认子句,该子句调用 throwSomething 函数。
  • 我发现它的阅读方式比传统方式更加优雅,不是吗?

    function handleResponse(res) {
      if (res.status === 200) {
        const { body, ...rest } = res;
        handleData(body, rest);
      } else if (300 <= res.status && res.status < 400 && res.destination) {
        handleRedirect(res.destination);
      } else if (res.status === 500 && !this.hasRetried) {
        retry(req);
        this.hasRetried = true;
      } else {
        throwSomething();
      }
    }

    模式匹配除了更加优雅之外,还可以让我们写出更简洁的代码。我们再举一个提案中的例子。

    我们有这样的代码:

    var json = {
      user: ["Lily", 13],
    };
    var {
      user: [name, age],
    } = json;
    print(`User ${name} is ${age} years old.`);

    这个例子很简单,但我们在这里没有做任何检查,我们只是希望一切都能按预期工作。如果 `user` 数组只有一个元素怎么办?或者如果它根本不是一个数组怎么办?

    因此让我们添加一些检查:

    if (json.user !== undefined) {
      var user = json.user;
      if (
        Array.isArray(user) &&
        user.length == 2 &&
        typeof user[0] == "string" &&
        typeof user[1] == "number"
      ) {
        var [name, age] = user;
        print(`User ${name} is ${age} years old.`);
      }
    }

    现在我们确信一切都正确无误,并且符合预期。不错,但对于应该很简单的事情来说,代码太多了,你不觉得吗?

    使用模式匹配可以完成完全相同的事情:

    if (json is {user: [String and let name, Number and let age]}) {
      print(`User ${name} is ${age} years old.`);
    }

    这里,if 语句检查 `json` 对象是否具有 `user` 属性,该属性是一个包含两个元素的数组。

    数组内部的模式匹配检查第一个元素是否是字符串并将其绑定到 name 变量,第二个元素是否是数字并将其绑定到 age 变量。

    如果模式匹配成功,我们就打印消息。

    如果您是模式匹配的新手,我希望您能够看到它的强大功能。

    管道操作员(第 2 阶段)

    在处理 JavaScript 时,我们经常会发现自己编写的代码将多个函数调用链接在一起以执行复杂的操作。这会导致代码很难理解和维护。

    让我们举个例子:

    const email = "john@example.com";
    const extractedDomain = email.toUpperCase().split("@")[1].split(".")[0];
    // Output: "EXAMPLE"

    但是我们也可以使用嵌套函数调用来链接操作:

    const email = "john@example.com";
    
    const capitalize = (value) => value.toUpperCase();
    const split = (value, separator) => value.split(separator);
    const getDomain = (value) => value.split(".")[0];
    
    const extractedDomain = getDomain(split(capitalize(email), "@")[1]);
    // Output: "EXAMPLE"

    这给了我们两种可能的语法:方法链`value.one().two().three()`和嵌套函数调用`three(two(one(value)))`。

    管道运算符(“|>”)提出了一种新语法,允许我们从左到右链接操作,从而使代码更具可读性。以下是我们的示例:

    const email = "john@example.com";
    
    const capitalize = (value) => value.toUpperCase();
    const split = (value, separator) => value.split(separator);
    const getDomain = (value) => value.split(".")[0];
    
    const extractedDomain =
      email |> capitalize(%) |> split(%, "@") |> %[1] |> getDomain(%);
    
    // Output: "EXAMPLE"

    管道运算符获取左侧的值并将其传递给右侧的函数。这创建了一个清晰的数据转换流程,更易于阅读和理解。

    您可能注意到我们在示例中使用了“%”。这是传递的值的占位符。实际上,由于该提案仍处于第 2 阶段,因此提出了两种方法来处理此占位符。

    破解管道

    第一个就是我们上面例子中所做的。它被称为“Hack pipes”。

    在 Hack 语言的管道语法中,管道的右侧是一个包含特殊占位符的表达式,该表达式的求值结果与占位符绑定在一起。也就是说,我们将 value |> one(%) |> two(%) |> three(%) 写入管道,通过三个函数将 value 管道化。

    这种方法的唯一缺点是,通过一元函数进行管道传输并不像它应该的那样简单。事实上,我们不能只写“value |> capitalize |> getDomain”之类的东西。

    F# 管道

    在 F# 语言的管道语法中,管道的右侧是一个表达式,必须将其求值成一个一元函数,然后默认使用左侧的值作为其唯一参数来调用该函数。也就是说,我们写入 value |> one |> two |> three 以将 value 传输到三个函数中。

    让我们使用此方法重写上面的例子来看看它是如何工作的:

    const email = "john@example.com";
    
    const capitalize = (value) => value.toUpperCase();
    const split = (separator) => (value) => value.split(separator);
    const nth = (n) => (arr) => arr[n];
    const getDomain = (value) => value.split(".")[0];
    
    const extractedDomain =
      email |> capitalize |> split("@") |> nth(1) |> getDomain;
    
    // Output: "EXAMPLE"

    您可以看到我们不再需要使用“%”占位符。我们还使用了柯里化函数,这使代码更加优雅。以“split”函数为例:

    const split = (separator) => (value) => value.split(separator);

    如果你不熟悉柯里化函数,让我给你简单解释一下。柯里化是将一个接受多个参数的函数转换为每个接受单个参数的函数序列的过程。

    这在这里很有用,因为它允许我们调用“split('@')”并获取一个以要拆分的值作为参数的函数。

    这种方法的唯一缺点是在某些情况下可能会有点冗长。假设我们想要调用一个接受多个参数的函数(所以不是一个柯里化函数)。我们必须这样写:

    value |> (x) => foo(1, x);

    而不是:

    value |> foo(1, %);

    目前,尽管 TC39 委员会出于各种原因(内存性能问题等)多次拒绝了 F# 管道方法,但尚未达成共识。

    如果您想了解更多信息,请随时阅读该提案的文档。如果您想使用它,您可以使用 Babel 插件。

    如果不考虑任何技术问题,我个人认为 F# 管道方法更加优雅,并且在大多数情况下会产生更少的冗长代码。

    颞叶(第 3 阶段)

    Temporal 的提案提供了用于处理日期和时间的标准对象和函数。如果您曾经在 JavaScript 中使用过日期,那么您就会知道当前的“Date”API 有很多限制和怪癖:

  • 它是可变的(日期可以修改)
  • 月份数字从零开始(一月为 0)
  • 不支持不同的日历系统
  • 令人困惑的解析行为
  • Temporal API 旨在通过提供一种现代、更直观的方式来处理日期和时间来解决这些问题。

    // Current Date API
    const date = new Date("2024-11-13");
    date.setMonth(date.getMonth() + 1); // Mutates the date
    console.log(date.getMonth()); // Returns 11 (December)
    
    // Temporal API
    const date = Temporal.PlainDate.from({ year: 2024, month: 11, day: 13 });
    const nextMonth = date.add({ months: 1 }); // Returns new instance
    console.log(nextMonth.toString()); // '2024-12-13'

    让我们探索一些主要特征。

    针对不同用例使用不同的类型

    // Just a date (no time)
    const date = Temporal.PlainDate.from({ year: 2024, month: 5, day: 10 });
    
    // Just a time (no date)
    const time = Temporal.PlainTime.from({
      hour: 9,
      minute: 30,
      second: 0,
      millisecond: 68,
      microsecond: 346,
      nanosecond: 205,
    });
    
    // Date and time without timezone
    const dateTime = Temporal.PlainDateTime.from("2024-11-13T09:30:00");
    
    // Exact moment in time (with timezone)
    const zonedDateTime = Temporal.ZonedDateTime.from({
      timeZone: "America/Los_Angeles",
      year: 1995,
      month: 12,
      day: 7,
      hour: 3,
      minute: 24,
      second: 30,
      millisecond: 0,
      microsecond: 3,
      nanosecond: 500,
    }); // => 1995-12-07T03:24:30.0000035-08:00[America/Los_Angeles]

    时区支持

    最强大的功能之一是对时区的一流支持:

    // Create a zoned date time in Los Angeles
    const la = Temporal.ZonedDateTime.from({
      timeZone: "America/Los_Angeles",
      year: 2024,
      month: 3,
      day: 15,
      hour: 9,
      minute: 30,
    });
    
    // Convert to Tokyo time
    const tokyo = la.withTimeZone("Asia/Tokyo");
    console.log(tokyo.toString());
    // Output: 2024-03-16T01:30:00+09:00[Asia/Tokyo]

    持续时间计算

    Temporal 可以轻松处理持续时间并执行日期运算:

    const duration = Temporal.Duration.from({
      hours: 2,
      minutes: 30,
    });
    
    const start = Temporal.Now.plainTimeISO();
    const end = start.add(duration);
    
    console.log(`Meeting will end at ${end.toString()}`);

    日历支持

    与当前的 Date API 不同,Temporal 支持非公历:

    // Create a date using the Hebrew calendar
    Temporal.PlainDate.from({
      calendar: "hebrew",
      year: 5779,
      monthCode: "M05L",
      day: 23,
    });
    
    // Convert to Gregorian
    const gregorianDate = hebrewDate.withCalendar("iso8601");
    console.log(gregorianDate.toString());

    如您所见,Temporal API 比当前的 Date API 有了显著改进,提供了一种更直观、更强大的日期和时间处理方式。其不可变的设计、一流的时区支持和清晰的关注点分离使其成为标准的受欢迎补充。

    如果您想了解更多信息,可以阅读该提案的文档。如果您想试用它,可以使用此 polyfill。

    结论

    虽然我们只探讨了三个提案,但还有更多令人兴奋的提案正在开发中!如果你想看看,我推荐你这个网站,虽然不是官方网站,但列出了所有提案及其状态。这是官方链接。

    JavaScript 生态系统不断发展,推出了许多激动人心的新提案,旨在使该语言更强大、更具表现力、更方便开发人员使用。从简化复杂条件逻辑的模式匹配,到使函数组合更具可读性的管道运算符,再到最终为 JavaScript 带来强大日期/时间处理功能的 Temporal API - 这些提案代表了该语言的重大改进。

    虽然 TC39 流程看起来很漫长,但它可以确保新功能在成为语言规范的一部分之前经过彻底审查和精心设计。

    随着这些提案在各个阶段的进展,开发人员已经可以使用 polyfill 或转译器开始尝试其中的许多提案。这不仅有助于验证提案,还允许社区提供宝贵的反馈意见,塑造 JavaScript 的未来。