这些ES2022新特性的坑你了解吗?

4,069 阅读11分钟

一、前言

2022 年 6 月 22 日 Ecma 正式通过了 ECMAScript 2022 语言规范,这也就意味着它现在已经成为标准,详细信息可以点击这里,下面我们会一一介绍这些新特性,对于一些 API 我们也会通过 hack 的方式自己去实现,同时也会讲解在使用这些新特性的时候要注意的一些坑。

二、类

1、自定义属性

类的自定义属性允许我们使用赋值运算符 = 直接将实例属性添加到类的定义中去,下面是官网使用的计数器样例,在 ES2015 中我们会这么去写:

class Counter {
    clicked() {
        this.x++;
    }
    constructor() {
        this.x = 0;
        // 当使用解构语法时 onClick 中的 this 仍然指向当前实例
        this.onClick = this.clicked.bind(this)
    }
}

在最新的标准中我们可以这么写:

class Counter {
    x = 0;
    clicked() {
        this.x++;
    }
    // 这里使用箭头函数将 this 强绑定在实例上
    onClick = () => this.clicked()
    constructor() {
        this.x = 0;
    }
}

这个功能其实很早就在用了,但是他一直没有作为标准,之前我们都是需要使用 babel 进行编译转换,下面使用babel 来对下面这个例子进行编译,看看之前定义属性和方法的方式和现在新增的属性定义方式有什么区别?

class Counter {
    name = 'xl'
    addAge() {
        this.age++;
    }
    changeName = () => {this.name = 'xll'}
    constructor() {
        this.age = 12
    }
}

babel 编译后的结果如下:

"use strict";

var _interopRequireDefault = require("@babel/runtime-corejs3/helpers/interopRequireDefault");

var _classCallCheck2 = _interopRequireDefault(require("@babel/runtime-corejs3/helpers/classCallCheck"));

var _createClass2 = _interopRequireDefault(require("@babel/runtime-corejs3/helpers/createClass"));

var _defineProperty2 = _interopRequireDefault(require("@babel/runtime-corejs3/helpers/defineProperty"));

var Counter = /*#__PURE__*/function () {
  function Counter() {
    var _this = this;

    (0, _classCallCheck2["default"])(this, Counter);
    // 这里是用了 _defineProperty2 方法将 name 属性和 changeName 方法绑定在创建的实例/类的原型上
    // 因为在 _defineProperty2 方法中做了一层判断,判断当前属性是否在原型上出现过,
    // 如果出现就直接覆盖掉
    (0, _defineProperty2["default"])(this, "name", 'xl');
    (0, _defineProperty2["default"])(this, "changeName", function () {
      _this.name = 'xll';
    });
    // 将 age 属性直接绑定在创建的实例上
    this.age = 12;
  }
  // _createClass2 方法会将 addAge 方法添加到类的原型上,这里要提一点,
  // 只有原型上的方法才能被子类覆盖,像 changeName 方法就不行,因为它只属于当前实例
  (0, _createClass2["default"])(Counter, [{
    key: "addAge",
    value: function addAge() {
      this.age++;
    }
  }]);
  return Counter;
}();

这里我们看到其实最新的写法和之前还是有一些区别的,比如我们在构造函数中定义的属性/方法会直接绑定在创建的实例上,而使用自定义属性的方式添加的属性/方法会做一层判断,判断该属性/方法是否在原型上出现过。

2、私有属性

在默认情况下类的所有属性都是公共的,也就是说我们可以直接通过实例对属性进行更改,比如下面这个例子:

class Person {
    name = 'xl';
    age = 12;
    set setAge(age) {
        this.age = age;
    }
}

const person = new Person()
person.name = 'xllll';
person.age = 14; // 直接修改 age 属性
person.setAge = 14; // 通过 setter 方式进行更改

从上面可以看到,一般情况下是没有任何措施可以防止在不调用 setter 的情况下对属性进行更改,而在最新的特性中我们可以使用 # 前缀去定义私有属性,私有属性只能在类方法中被更改:

class Person {
    name = 'xl';
    #age = 12;
    set setAge(age) {
        this.#age = age;
    }
}

const person = new Person()
person.#age = 14; // error: Private name #age is not defined
person.setAge = 14; // 通过 setter 方式进行更改

这里有同学可能就会有疑问了,我们通过 . 的方式去访问属性会先从当前实例上去查找,查找不到再去原型链上找,但是 person.#age 却拿不到 #age 属性,那么也就说明私有属性是没有绑定在实例或者原型上的,我们使用 babel 编译一下下面这个例子:

class Person {
    name = 'xl';
    #age = 12;
    fun = () => {
        const age = this.#age
    }
}

编译后的结果如下:

function _classPrivateFieldInitSpec(obj, privateMap, value) { 
    privateMap.set(obj, value); 
}

var _age = /*#__PURE__*/new _weakMap["default"]();

var Person = /*#__PURE__*/(0, _createClass2["default"])(function Person() {
  var _this = this;

  (0, _defineProperty2["default"])(this, "name", 'xl');
  // 将this作为key,{ writable: true, value: 12} 作为value存放在_age这个map里面
  _classPrivateFieldInitSpec(this, _age, {
    writable: true,
    value: 12
  });

  (0, _defineProperty2["default"])(this, "fun", function () {
    // 调用_classPrivateFieldGet2方法从_age这个map中取出当前实例的#age的值
    var age = (0, _classPrivateFieldGet2["default"])(_this, _age);
  });
});

查看编译后的结果我们看到,我们定义的私有属性其实就是个 weakMap ,map 中的 key 是当前的实例,value 保存的是当前实例中存放的私有属性 #age 的值,当我们通过 this.#age 去获取值时就变成了从 map 结构中去获取对应的值,下面简单实现了 _classPrivateFieldGet2 函数:

function _classPrivateFieldGet2(receiver, privateMap) {
    if(!privateMap.has(receiver)) {
        throw new TypeError()
    }
    // descriptor = {value: 12,  writable: true,}
    const descriptor = privateMap.get(receiver);
    // 如果当前私有属性是 getter 就直接调用 get 方法
    if(descriptor.get) {
        return descriptor.get.call(receiver);
    }
    return descriptor.value;
}

但是私有属性就真的这么完美吗?其实不然,正如刚刚说的,私有属性是单独用一个 map 去保存其实例上的值,那么对于像 vue、mobx 这样的响应式框架来说这就会带来新的问题,我们知道不管是 vue 还是 mobx 都是通过 Object.defineProperty 或者 Proxy 对属性的 settet 和 getter 方法进行一个代理,而我们访问私有属性并不会触发其 setter 和 getter 方法,自然就不能达到响应式的目的。

3、类的静态初始块

为了在类的实例上创建实例数据我们有两种方式:

  • 在类初始化的时候添加数据
  • 在类的构造函数中向实例中添加数据

而对于静态属性我们也有两种:

  • 在类初始化的时候添加静态数据
  • 在静态初始块中向类中添加静态数据

下面先接单介绍下静态初始块的使用,在静态初始块中我们可以通过 this 访问类的静态公共属性和静态私有属性,也就是说此时的 this 就是当前这个类。

class Person {
    static data = {
      width: 12,
      height: 13,
      top: 10
    };
    static #name = 'xl';
    static keys = [];
    static values = [];
    static {
      console.log(this.#name)
      for (const [key, value] of Object.entries(this.data)) {
        this.keys.push(key);
        this.values.push(value);
      }
    }
}
console.log(Person.keys, Person.values)
// keys: ['width', 'height', 'top']
// values: [12,13,10]

对于静态初始块它有以下几条规则:

  • 在类中可以定义多个静态初始块
  • 类中的静态属性的初始化和静态初始块的执行是交错的,也就是说会按照在类中出现的顺序依次执行,比如下面这个例子:
class Person {

    static #name = 'xl';
    static keys = [];
    static values = [];
    static {
      console.log(this.#name)
      try {
        for (const [key, value] of Object.entries(this.data)) {
          this.keys.push(key);
          this.values.push(value);
        }
      } catch (error) {
        console.log(error) // TypeError: Cannot convert undefined or null to object
      }
    }
    static data = {
      width: 12,
      height: 13,
      top: 10
    };
}
console.log(Person.keys, Person.values) // [] []

静态属性 data 的初始化操作在静态初始块之后执行,那门在静态初始块中进行访问就会报错。

  • 父类和子类中的静态初始块执行顺序为先父后子。

三、Object.hasOwn()

Object.hasOwn 方法其实就是 Object 上的一个静态方法,用来检测对象上是否存在某个属性,但是检测对象上是否有某个属性不是可以使用 Object.hasOwnProperty 方法吗,为啥还要再增加一个新的方法,下面通过一个例子来解答:

const obj = Object.create({});
obj.name = "xl";
console.log(obj.hasOwnProperty("name")); // true

const obj1 = Object.create(null);
obj1.name = "xl";
try {
  console.log(obj1.hasOwnProperty("name"));
} catch (error) {
  console.log(error); // error: obj1.hasOwnProperty is not a function
}
const obj2 = Object.create(null);
obj2.name = "xl";
console.log(Object.hasOwn(obj2, "name")); // true

因为实例的 hasOwnProperty 方法是从 Object 的原型上拿到的,如果我们使用 Object.create(null) 的方式创建一个对象那么就拿不到 hasOwnProperty 这个方法,而 hasOwn 作为 Object 的静态方法是可以直接通过 Object 来进行调用。

四、at()

at 是数组新增的一个方法,通过传递给定索引来获取数组元素,这个索引可以是正数也可以是负数,当索引是正数时效果和 [] 是一样的,当索引是负数时就会从数组的最后一项开始。

const arr = [1,2,3,4,5]
console.log(arr[arr.length - 1]) // 5
console.log(arr.at(-1)) // 5
console.log(arr.at(1)) // 2

五、Top-level Await

顶层 await 允许我们在 async 外面使用 await关键字,它允许模块充当大型异步函数,这使得我们在 ESM 模块中可以等待资源的加载,只有资源加载完毕之后才会去执行当前模块的代码。

// main.js
export const val = await new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve('xl')
    }, 4000)
})

// index.js
import {val} from './store';
console.log(val) // 4s之后打印 xl

如上面这个例子,在 main.js 中我们导出的是一个异步模块,4s之后将 val 的值赋值为 xl,此时我们在 index.js 通过 import 去导入,此时他会先去执行 main.js 中的代码,等里面的异步代码执行完成之后将结果到出,这样我们拿到的 val 值就是最终的值。

这里可能也有同学会好奇,顶层 await 到底是怎么实现的,我们将上面这个例子转换一下大家可能就明白了:

// main.js
export let val;
export const promise = (async () => { 
  const response = await new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve('xl')
    }, 4000)
  })
  val = response;
})();


// index.js
import {promise, val} from './store.js';
export const promise = (async () => {
  await Promise.all([promise]); 
  console.log(val) // 4s之后打印 xl
})();

这样就比较明了了,其实就是将每个模块都封装在一个自执行函数里面,并且这个自执行函数是一个 async 函数,在执行 index.js 中的代码执行前会先使用 Promise.all 将所有导入的模块执行一遍。

注意点: 当前顶层 await 只支持 ESM 模块化规范,因为 await 的实现依赖于 ESM 模块化的特性——import导入的是值的引用,那上面的例子来说,一开始我们拿到的 val 值其实是 undefined,当 main.js 中的异步代码执行完之后才会变成 xl,如果我们使用的是 Commonjs,那么我们就拿不到更新之后的值了。

现阶段 babel 暂时不支持转换 顶层 await,但是现在可以使用 webpack@5 中的 experiments.topLevelAwait 配置项去实现转换。

但是顶层 await 就没有什么缺陷吗?其实还真的有,看下面这个例子:

// index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <script src="./test.js" type="module"></script>
</head>
<body>
</body>
</html>

// test.js
const name = await new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve('xl')
    }, 4000)
})

window.addEventListener('load', () => {
    console.log(name);
})

在 test.js 中我们还是定一个了顶层 await,4s之后执行 resolve 函数,但是在 test.js 中我们给 window 添加了一个事件监听器,当页面加载完毕之后执行他的回调函数,但是最终意外发生了,回调函数没有执行。这是因为我们使用了顶层 await 之后当代码执行到 await 时会跳过下面的代码去执行其他的代码,在我们这里解析器就会去解析我们的 DOM,4s 之后此时页面已经加载完毕了,即 load 事件以及发生了,那当然就不去执行他的回调函数。

我们可以把场景在想复杂一点,我们的顶层 await 可能存放在我们项目中很深的一个模块当中,因为顶层 await 有传染性,这就会导致他上层的所有 import 的模块都会暂停执行,这对我们开发者排查问题来说无疑是一个巨大的心理负担。

六、error.cause

error.cause 允许我们在 new Error() 中指定造成错误的原因,这个功能对我们来说是很有帮助的,比如说我们有的时候使用 try-catch 捕捉到的错误是一个非常底层的错误,而我们又对那块场景比较了解,我们想给外部暴露出一些有用的错误提示,此时就可以使用 error.cause 了,下面通过一个例子来了解下:

async function fetchData(url) {
    try{
           await fetch(url)
    } catch(error) {
        if(!url.includes(':')) {
            throw new Error('fail: url格式不对,缺少 :', {cause: error})
        }
        throw new Error('fail', {cause: error})
    }
}

try {
    fetchData('http//xxx')
} catch(error) {
    console.log(error, error.cause);
}

上面我们定义了一个 fetchData 请求方法,当接收到的 url 不符合规范时我们除了将 catch 到的底层的错误抛出去外,还会去提用户是因为 url 缺少 : 导致 url 不符合规范。

七、生态环境

  • babel babel 现阶段已经基本支持 ES2022 的新特性,在官网中我们也能查看其对应的插件。

image.png

  • typescript 从 typescript@3.8 开始已经陆续支持 ESMA 的最新特性,比如类的私有属性、顶层 await 等,但是目前也还是实验性的。

image.png

  • webpack webpack@5 现在的实验性特性中只支持顶层 await 的转换。

image.png

ES2022 的特性今天就暂时讲到这里,如果有哪里写的有问题欢迎大家在评论区指出。

往期好文

1、最通俗易懂的Mobx批量更新

2、试试S3+minio进行大文件上传

3、最通俗易懂的Mobx响应式机制