ES11來了,還學得動嗎?

寫在前面

ES2020(即 ES11)上周(2020 年 6 月)已經正式發布,在此之前進入 Stage 4 的 10 項提案均已納入規範,成為 JavaScript 語言的新特性

 

一.特性一覽

ES Module 迎來了一些增強:

正式支援了安全的鏈式操作:

提供了大數運算的原生支援:

一些基礎 API 也有了新的變化:

  • Promise.allSettled:一個新的 Promise 組合器,不像allrace一樣具有短路特性

  • String.prototype.matchAll:以迭代器的形式返回全局匹配模式下的正則表達式匹配到的所有結果(indexgroups等)

  • globalThis:訪問全局作用域this的通用方法

  • for-in mechanics:規範for-in循環的某些行為

 

二.ES Module 增強

 

動態 import

我們知道ES Module是一套靜態的模組系統

The existing syntactic forms for importing modules are static declarations.

靜態體現在:

They accept a string literal as the module specifier, and introduce bindings into the local scope via a pre-runtime “linking” process.

  • 靜態載入:import/export聲明只能出現在頂層作用域,不支援按需載入、懶載入

  • 靜態標識:模組標識只能是字元串字面量,不支援運行時動態計算而來的模組名

例如:

if (Math.random()) {
    import 'foo'; // SyntaxError
}

// You can』t even nest `import` and `export`
// inside a simple block:
{
    import 'foo'; // SyntaxError
}

這種嚴格的靜態模組機制讓基於源碼的靜態分析、編譯優化有了更大的發揮空間:

This is a great design for the 90% case, and supports important use cases such as static analysis, bundling tools, and tree shaking.

但對另一些場景很不友好,比如:

  • 苛求首屏性能的場景:通過import聲明引用的所有模組(包括初始化暫時用不到的模組)都會在初始化階段前置載入,影響首屏性能

  • 難以提前確定目標模組標識的場景:例如根據用戶的語言選項動態載入不同的模組(module-enmodule-zh等)

  • 僅在特殊情況下才需要載入某些模組的場景:例如異常情況下載入降級模組

為了滿足這些需要動態載入模組的場景,ES2020 推出了動態 import 特性(import()):

import(specifier)

import()「函數」輸入模組標識specifier(其解析規則與import聲明相同),輸出Promise,例如:

// 目標模組  ./lib/my-math.js
function times(a, b) {
  return a * b;
}
export function square(x) {
  return times(x, x);
}
export const LIGHTSPEED = 299792458;

// 當前模組 index.js
const dir = './lib/';
const moduleSpecifier = dir + 'my-math.mjs';

async function loadConstant() {
  const myMath = await import(moduleSpecifier);
  const result = myMath.LIGHTSPEED;
  assert.equal(result, 299792458);
  return result;
}
// 或者不用 async & await
function loadConstant() {
  return import(moduleSpecifier)
  .then(myMath => {
    const result = myMath.LIGHTSPEED;
    assert.equal(result, 299792458);
    return result;
  });
}

import聲明相比,import()特點如下:

  • 能夠在函數、分支等非頂層作用域使用,按需載入、懶載入都不是問題

  • 模組標識支援變數傳入,可動態計算確定模組標識

  • 不僅限於module,在普通的script中也能使用

注意,雖然長的像函數,但**import()實際上是個操作符**,因為操作符能夠攜帶當前模組相關資訊(用來解析模組表示),而函數不能:

Even though it works much like a function, import() is an operator: in order to resolve module specifiers relatively to the current module, it needs to know from which module it is invoked. A normal function cannot receive this information as implicitly as an operator can. It would need, for example, a parameter.

 

import.meta

另一個 ES Module 新特性是import.meta,用來透出模組特定的元資訊:

import.meta, a host-populated object available in Modules that may contain contextual information about the Module.

比如:

  • 模組的 URL 或文件名:例如 Node.js 里的__dirname__filename

  • 所處的script標籤:例如瀏覽器支援的document.currentScript

  • 入口模組:例如 Node.js 里的process.mainModule

諸如此類的元資訊都可以掛到import.meta屬性上,例如:

// 模組的 URL(瀏覽器環境)
import.meta.url
// 當前模組所處的 script 標籤
import.meta.scriptElement

需要注意的是,規範並沒有明確定義具體的屬性名和含義,都由具體實現來定,所以特性提案里的希望瀏覽器支援的這兩個屬性將來可能支援也可能不支援

P.S.import.meta本身是個對象,原型為null

 

export-ns-from

第三個 ES Module 相關的新特性是另一種模組導出語法:

export * as ns from "mod";

同屬於export ... from ...形式的聚合導出,作用上類似於:

import * as ns from "mod";
export {ns};

但不會在當前模組作用域引入目標模組的各個 API 變數

P.S.對照import * as ns from "mod";語法,看起來像是 ES6 模組設計中排列組合的一個疏漏;)

 

三.鏈式操作支援

 

Optional Chaining

相當實用的一個特性,用來替代諸如此類冗長的安全鏈式操作:

const street = user && user.address && user.address.street;

可換用新特性(?.):

const street = user?.address?.street;

語法格式如下:

obj?.prop     // 訪問可選的靜態屬性
// 等價於
(obj !== undefined && obj !== null) ? obj.prop : undefined

obj?.[«expr»] // 訪問可選的動態屬性
// 等價於
(obj !== undefined && obj !== null) ? obj[«expr»] : undefined

func?.(«arg0», «arg1») // 調用可選的函數或方法
// 等價於
(func !== undefined && func !== null) ? func(arg0, arg1) : undefined

P.S.注意操作符是?.而不是單?,在函數調用中有些奇怪alert?.(),這是為了與三目運算符中的?區分開

機制非常簡單,如果出現在問號前的值不是undefinednull,才執行問號後的操作,否則返回undefined

同樣具有短路特性:

// 在 .b?.m 時短路返回了 undefined,而不會 alert 'here'
({a: 1})?.a?.b?.m?.(alert('here'))

&&相比,新的?.操作符更適合安全進行鏈式操作的場景,因為:

  • 語義更明確:?.遇到屬性/方法不存在就返回undefined,而不像&&一樣返回左側的值(幾乎沒什麼用)

  • 存在性判斷更準確:?.只針對nullundefined,而&&遇到任意假值都會返回,有時無法滿足需要

例如常用的正則提取目標串,語法描述相當簡潔:

'string'.match(/(sing)/)?.[1] // undefined
// 之前需要這樣做
('string'.match(/(sing)/) || [])[1] // undefined

還可以配合 Nullish coalescing Operator 特性填充默認值:

'string'.match(/(sing)/)?.[1] ?? '' // ''
// 之前需要這樣做
('string'.match(/(sing)/) || [])[1] || '' // ''
// 或者
('string'.match(/(sing)/) || [, ''])[1] // ''

 

Nullish coalescing Operator

同樣引入了一種新的語法結構(??):

actualValue ?? defaultValue
// 等價於
actualValue !== undefined && actualValue !== null ? actualValue : defaultValue

用來提供默認值,當左側的actualValueundefinednull時,返回右側的defaultValue,否則返回左側actualValue

類似於||,主要區別在於??只針對nullundefined,而||遇到任一假值都會返回右側的默認值

 

四.大數運算

新增了一種基礎類型,叫BigInt,提供大整數運算支援:

BigInt is a new primitive that provides a way to represent whole numbers larger than 2^53, which is the largest number Javascript can reliably represent with the Number primitive.

 

BigInt

JavaScript 中Number類型所能準確表示的最大整數是2^53,不支援對更大的數進行運算:

const x = Number.MAX_SAFE_INTEGER;
// 9007199254740991 即 2^53 - 1
const y = x + 1;
// 9007199254740992 正確
const z = x + 2
// 9007199254740992 錯了,沒變

P.S.至於為什麼是 2 的 53 次方,是因為 JS 中數值都以 64 位浮點數形式存放,刨去 1 個符號位,11 個指數位(科學計數法中的指數),剩餘的 52 位用來存放數值,2 的 53 次方對應的這 52 位全部為 0,能表示的下一個數是2^53 + 2,中間的2^53 + 1無法表示:

JavaScript Max Safe Integer

 JavaScript Max Safe Integer

具體解釋見BigInts in JavaScript: A case study in TC39

BigInt類型的出現正是為了解決此類問題:

9007199254740991n + 2n
// 9007199254740993n 正確

引入的新東西包括:

  • 大整數字面量:給數字後綴一個n表示大整數,例如9007199254740993n0xFFn(二進位、八進位、十進位、十六進位字面量通通可以後綴個n變成BigInt

  • bigint基礎類型:typeof 1n === 'bigint'

  • 類型構造函數:BigInt

  • 重載數學運算符(加減乘除等):支援大整數運算

例如:

// 創建一個 BigInt
9007199254740993n
// 或者
BigInt(9007199254740993)

// 乘法運算
9007199254740993n * 2n
// 冪運算
9007199254740993n ** 2n
// 比較運算
0n === 0  // false
0n === 0n // true
// toString
123n.toString() === '123'

P.S.關於 BigInt API 細節的更多資訊,見ECMAScript feature: BigInt – arbitrary precision integers

需要注意的是BigInt不能與Number混用進行運算

9007199254740993n * 2
// 報錯 Uncaught TypeError: Cannot mix BigInt and other types, use explicit conversions

並且BigInt只能表示整數,所以除法直接取整(相當於Math.trunc()):

3n / 2n === 1n

 

五.基礎 API

基礎 API 也有一些新的變化,包括 Promise、字元串正則匹配、for-in循環等

 

Promise.allSettled

Promise.allPromise.race之後,Promise新增了一個靜態方法叫allSettled

// 傳入的所有 promise 都有結果(從 pending 狀態變成 fulfilled 或 rejected)之後,觸發 onFulfilled
Promise.allSettled([promise1, promise2]).then(onFulfilled);

P.S.另外,any也在路上了,目前(2020/6/21)處於 Stage 3

類似於all,但不會因為某些項rejected而短路,也就是說,allSettled會等到所有項都有結果(無論成功失敗)後才進入Promise鏈的下一環(所以它一定會變成 Fulfilled 狀態):

A common use case for this combinator is wanting to take an action after multiple requests have completed, regardless of their success or failure.

例如:

Promise.allSettled([Promise.reject('No way'), Promise.resolve('Here')])
  .then(results => {
    console.log(results);
    // [
    //   {status: "rejected", reason: "No way"},
    //   {status: "fulfilled", value: "Here"}
    // ]
  }, error => {
    // No error can get here!
  })

 

String.prototype.matchAll

字元串處理的一個常見場景是想要匹配出字元串中的所有目標子串,例如:

const str = 'es2015/es6 es2016/es7 es2020/es11';
str.match(/(es\d+)\/es(\d+)/g)
// 順利得到 ["es2015/es6", "es2016/es7", "es2020/es11"]

match()方法中,正則表達式所匹配到的多個結果會被打包成數組返回,但無法得知每個匹配除結果之外的相關資訊,比如捕獲到的子串,匹配到的index位置等:

This is a bit of a messy way to obtain the desired information on all matches.

此時只能求助於最強大的exec

const str = 'es2015/es6 es2016/es7 es2020/es11';
const reg = /(es\d+)\/es(\d+)/g;
let matched;
let formatted = [];
while (matched = reg.exec(str)) {
  formatted.push(`${matched[1]} alias v${matched[2]}`);
}
console.log(formatted);
// 得到 ["es2015 alias v6", "es2016 alias v7", "es2020 alias v11"]

而 ES2020 新增的matchAll()方法就是針對此類種場景的補充:

const results = 'es2015/es6 es2016/es7 es2020/es11'.matchAll(/(es\d+)\/es(\d+)/g);
// 轉數組處理
Array.from(results).map(r => `${r[1]} alias v${r[2]}`);
// 或者從迭代器中取出直接處理
// for (const matched of results) {}
// 得到結果同上

注意,matchAll()不像match()一樣返回數組,而是返回一個迭代器,對大數據量的場景更友好

 

for-in 遍歷機制

JavaScript 中通過for-in遍歷對象時 key 的順序是不確定的,因為規範沒有明確定義,並且能夠遍歷原型屬性讓for-in的實現機制變得相當複雜,不同 JavaScript 引擎有各自根深蒂固的不同實現,很難統一

所以 ES2020 不要求統一屬性遍歷順序,而是對遍歷過程中的一些特殊 Case 明確定義了一些規則:

  • 遍歷不到 Symbol 類型的屬性

  • 遍歷過程中,目標對象的屬性能被刪除,忽略掉尚未遍歷到卻已經被刪掉的屬性

  • 遍歷過程中,如果有新增屬性,不保證新的屬性能被當次遍歷處理到

  • 屬性名不會重複出現(一個屬性名最多出現一次)

  • 目標對象整條原型鏈上的屬性都能遍歷到

具體見13.7.5.15 EnumerateObjectProperties

 

globalThis

最後一個新特性是globalThis,用來解決瀏覽器,Node.js 等不同環境下,全局對象名稱不統一,獲取全局對象比較麻煩的問題:

var getGlobal = function () {
  // the only reliable means to get the global object is
  // `Function('return this')()`
  // However, this causes CSP violations in Chrome apps.
  if (typeof self !== 'undefined') { return self; }
  if (typeof window !== 'undefined') { return window; }
  if (typeof global !== 'undefined') { return global; }
  throw new Error('unable to locate global object');
};

globalThis作為統一的全局對象訪問方式,總是指向全局作用域中的this值:

The global variable globalThis is the new standard way of accessing the global object. It got its name from the fact that it has the same value as this in global scope.

P.S.為什麼不叫global?是因為global可能會影響現有的一些程式碼,所以另起一個globalThis避免衝突

至此,ES2020 的所有新特性都清楚了

 

六.總結

比起ES2019,ES2020 算是一波大更新了,動態 import、安全的鏈式操作、大整數支援……全都加入了豪華午餐

 

參考資料