回顾2022Javascript变化

lxf2023-03-13 10:30:02

前言

最近看了Hax关于2022年Javascript变化的演讲,不单单介绍新特性的使用,还说明了部分提案的来龙去脉,以及客观评价了每个新特性的优缺点。收获颇多,在此做份总结,加上自己的理解补充,废话不多说,现在开始吧。

Error cause

缘由

try {
  const rawResource = await fetch("/source");
  const newResource = doComputationalJob(rawResource);
  await fetch("/upload", { method: "POST", body: newResource });
} catch (err) {
  console.log(err); // TypeError: Failed to fetch
}

在上面代码中,我们捕捉了错误,我们没法知道是/source接口还是/upload接口报错,提示不够详细。一般我们会自定义下error

try {
  const rawResource = await fetch("/source").catch((err) => {
    throw new Error("获取资源失败");
  });
  const newResource = doComputationalJob(rawResource);
  await fetch("/upload", { method: "POST", body: newResource }).catch(
    (err) => {
      throw new Error("上传资源失败");
    }
  );
} catch (err) {
  console.log(err);
}

但是这样又把最初的err丢失了,我们自定义Error时可以把它补回来,例如

try {
  const rawResource = await fetch("/source").catch((err) => {
    const customError = new Error("获取资源失败");
    customError.cause = err;
    throw customError;
  });
  // ...
} catch (err) {
  console.log(err); // Error: 获取资源失败
  console.log(err.cause); // TypeError: Failed to fetch
}

其他一些编程语言,例如Java,早有链式错误的模式,也就是给每个错误提供一个上下文信息,可以找到更低层的错误,并且可以顺着链式找到最后一层。

对于Javascript虽然开发者自行可以做到链式错误的效果,例如上面的例子。但这并不是语言本身内置支持的。

解决方案

现在Error构造函数支持第二个参数传入可选对象,可以保留原始的错误。

try {
  const rawResource = await fetch("/source").catch((err) => {
    throw new Error("获取资源失败", { cause: err });
  });
 	// ...
} catch (err) {
  console.log(err); // Error: 获取资源失败
  console.log(err.cause); // TypeError: Failed to fetch
}

当然cause并不是必须传入Error实例,我们也可以记录详细的错误信息

try {
  const rawResource = await fetch("/source?year=2023").catch((err) => {
    throw new Error("获取资源失败", {
      cause: {
        url: "/source",
        params: {
          year: 2023,
        },
        err,
      },
    });
  });
  // ...
} catch (err) {
  console.log(err); // Error: 获取资源失败
  console.log(err.cause);
  /**
   {
      params: {year: 2023},
      url: "/source",
      err: TypeError: Failed to fetch
    }
  */
}

注意

我们写的时候是new Error("获取资源失败", { cause: err }),第二个参数是个可选对象(为了向前兼容、向后扩展考虑),传入cause。而不是new Error("获取资源失败", err)

findLast()/findLastIndex()

缘由

假设我们要找数组中倒数最后一个满足条件的元素,一般我们只能自己写for循环,或者数组先reverse

const array = [5, 12, 50, 130, 44];
const found = [...array].reverse().find((element) => element > 45); // 130

要是找索引呢,那得像下面一样。

const foundIndex = array.length - 1 - [...array].reverse().findIndex((element) => element > 45); // 3

但是这里可能会有人踩坑,如果找不到满足条件,索引应该返回-1,但是按上面的公式就会出问题

// 值为5,我们应该想要的是-1
let foundIndex = array.length - 1 - [...array].reverse().findIndex((element) => element > 135);

// 所以又得加个处理
foundIndex = foundIndex >= array.length ? -1 : foundIndex; // -1

解决方案

所以现在数组支持从后往前找的方法,这挺实用的。

const found = array.findLast((element) => element > 45); // 130
const foundIndex = array.findLastIndex((element) => element > 45); // 3

其实这早已有库给我们提供了方法,例如lodash。但是为啥标准库还要去实现这个呢,主要由下

  • 性能考虑(js内置方法肯定是性能最好的)
  • 库被引用比较频繁(说明很多开发者都有这方面的需求)
  • API的完整性(比如String的padStart和padEnd方法都是成对的)

没啥缺点需要注意的。

/pattern/d和.indices

缘由

const str = `
  GIAC
    date: 2022-11-18
  IDEA
    date: 2022-11-22
`;
const matchedDate = [...str.matchAll(/date:\s*(.+)/g)];
console.log(matchedDate);
/**
  [
    ['date: 2022-11-18', '2022-11-18', index: 11, ...]
    ['date: 2022-11-22', '2022-11-22', index: 38, ...]
  ]
*/

用过matchAll方法的同学知道,该方法会匹配所有满足条件的字符串并返回成一个数组。举例数组第一个元素中,date: 2022-11-18代表第一个满足正则匹配的字符串,2022-11-18代表子匹配(捕获组)满足的字符串,即(.+)匹配的字符串。index: 11代表满足正则的字符串开始的索引,即date: 2022-11-18字符串从str[11]开始。

但是我们不能直接获取结束的索引,以及子匹配的开始索引和结束索引。这需要我们手动计算。有些场景我们可能需要用的上这些索引值,例如在编辑器中,用户输入框搜索关键字,可以高亮显示匹配的字符串,那么需要知道匹配的字符串在整个文本中开始和结束位置。

解决方案

基于上面需求,我们在正则上增加个标记d,便可获取匹配字符串的开始和结束索引。

const matchedDate = [...str.matchAll(/date:\s*(.+)/gd)];
console.log(matchedDate);
/**
  [
    ['date: 2022-11-18', '2022-11-18', index: 11, indices: [[11, 27], [17, 27]], ...]
    ['date: 2022-11-22', '2022-11-22', index: 38, indices: [[38, 54], [44, 54]], ...]
  ]
*/

在上面代码中,indices: [[11, 27], [17, 27]]。表示date: 2022-11-18在文本中位置为11到27,子匹配2022-11-18在文本中位置为17到27。

我们需要手动添加了d标记,才会有indices值。这是因为出于性能考虑,查找匹配字符串的索引,以及更多子匹配的索引是需要额外的性能消耗。

为什么是d标记?而不是i?因为i以前被占用了,它的作用是不区分大小写搜索。所以取inDices中的d。

Object.hasOwn()

缘由

我们判断指对象自身属性中是否具有指定的属性,一般会使用Object.prototype.hasOwnProperty,例如

const object1 = {};
object1.property1 = 42;
console.log(object1.hasOwnProperty('property1')); // true

但是hasOwnProperty存在着一些问题。

Object.create(null)创建的对象都不继承自 Object.prototype,因此 hasOwnProperty() 无法访问。

const foo = Object.create(null);
foo.prop = 'exists';
foo.hasOwnProperty('prop'); // Uncaught TypeError: foo.hasOwnProperty is not a function

我们必须要使用另外健壮但繁琐的写法。

// 方式
Object.prototype.hasOwnProperty.call(foo, 'prop'); // true
// 方式二
!!Object.getOwnPropertyDescriptor(foo, 'prop'); // true

另外hasOwnProperty方法是不受保护的,我们知道调用对象的方法,js会先从对象自身找,没有才会从原型链上找。那么开发者创建的对象,自行定义了hasOwnProperty属性,将会出现问题。

var foo = {
  hasOwnProperty: function() {
    return false;
  },
  bar: 'Here be dragons'
};

foo.hasOwnProperty('bar'); // 始终返回 false

解决方案

那么现在Object上增加静态方法hasOwn。

const o = {
  prop: 'exists'
};

Object.hasOwn(o, 'prop'); // true

为什么不是命名Object.hasProperty?因为已经有Object.hasProperty方法了,更精确的说是已经有Object.prototype.hasOwnProperty了,调用Object.hasProperty其实就是调用Object.prototype.hasOwnProperty

补充个原型链图

回顾2022Javascript变化

at()

缘由

我们如果想得到数组的最后一项,那么需要

let last = arr[arr.length - 1];

解决方案

现在只需要

let last = arr.at(-1);

正数的话还是和方括号的使用一样。

其实这特性获取值的时候还有用,但赋值的时候其实更麻烦,比如需要数组最后一项加一

// 以前做法
++[arr.length - 1];

// 使用at
arr[arr.length - 1] = arr.at(-1) + 1;

注意

arr.at(i)和arr[i]在处理非索引时语义不同

我们平时使用数组下标,例如arr[2]。JavaScript 引擎通过隐式的 toString,将 arr[2] 中的 2 强制转换为字符串,也就是说arr[2]arr['2']值是一样的,下面来验证下

const arr = [1, 2, 3]
console.log(arr[2]); // 3
console.log(arr['2']); // 3

const obj = {
  toString: () => 2
}
console.log(arr[obj]); // 3

但是arr[2]arr['02']是不一样的,因为arr['02']是在查找数组中02字符串的属性

console.log(arr['02']); // undefined
arr['02'] = 66;
console.log(arr['02']); // 66
console.log(arr); // [1, 2, 3, 02: 66]

at()方法处理传入的参数是不一样的,它会尝试把参数转成数字,默认0

const arr = [1, 2, 3];
arr['02'] = 66;
console.log(arr.at('02')); // 3(相当于arr.at(2))
console.log(arr.at('-02')); // 2(相当于arr.at(-2))

console.log(arr.at(undefined)); // 1(相当于arr.at(0))
console.log(arr.at(null)); // 1(相当于arr.at(0))
console.log(arr.at(false)); // 1(相当于arr.at(0))
console.log(arr.at(true)); // 2(相当于arr.at(1))
// ...

at(-0)获取值是什么?

假设我们想使用一个变量idx作为计算值,并且使用arr.at(-idx)获取数组元素。当idx为0时,arr.at(-0)应该代表的是数组最后一位还是数组第一位?可能不同使用者的意图不一样。Javascript中,其实就相当于arr.at(0),代表的是数组第一位。

at()替代方括号写法时要留意

例如,假设以前的代码是正常运行的

const a = [1, 2, 3];
const i = a.findIndex((x) => x > 3); // -1
a[i]; // undefined

如果你直接使用at方法代替方括号写法,可能会使以前代码出现bug

a.at(i); // 3

另外,String也有at()方法

const str = 'Hello';
const lastChar = str.charAt(str.length - 1); // o

// 使用at()
const lastChar = str.at(-1); // o

Hashbang #!

缘由

以前我们在终端要用node跑index.js文件,必须使用node index.js来执行

解决方案

现在可以使用hashbang注释语法, 在index.js文件头指定该文件使用哪个解释器运行,如下

#!/usr/bin/env node
console.log(1);

那么你可直接使用./index.js来执行。

如果要使用Typescript运行,那么可以指定ts-node作为解释器

#!/usr/bin/env ts-node

还有一些好处的话,以前我们node执行脚本需要提供环境变量的话,例如在mac可以使用NODE_ENV=production node ./index.js,那么index.js可以获取变量

console.log(process.env.NODE_ENV); // production

现在使用hashbang注释语法的话,我们可以直接在文件头提供

#!/usr/bin/env -S NODE_ENV=production node
console.log(process.env.NODE_ENV); // production

然后./index.js执行即可。

注意

hashbang 注释语法以 #! 开头,并且只在脚本或模块的最开始处有效。注意,#! 标志之前不能有任何空白字符。

例如这样是不行的

   #!/usr/bin/env node
console.log(1);

hashbang原本只用于node入口脚本,例如cli。但是现在进入了标准,意味着任何脚本都可以这样做,服务端和浏览器端都可以。但其实浏览器端使用hashbang是没有任何意义的。有些人可能会滥用于同构脚本,即同一套代码,既可以运行在浏览器端,又可以运行在服务器端。比如index.js文件

#!/usr/bin/env node
console.log(1);

在node环境中使用./index.js是可以的

在浏览器端中,引入index.js也是没问题的

<script src="index.js">
</script>

但是我们要留意,假设我们在服务端把index.js的代码拼接内敛到html中,例如php

<script>
  <?php include 'index.js' ?>
</script>

然后最终html拼接后长这样

<script>
#!/usr/bin/env node
console.log(1);
</script>

这会导致浏览器端代码报语法错误,为什么?前面说了#!标志之前不能有任何空白字符

下面这样才能正常运行

<script>#!/usr/bin/env node
console.log(1);
</script>

所以我们尽量避免这种风险。

Top-level await

缘由

假设我们现在data.mjs导出的数据是要异步获取的,在index.mjs需要使用这数据的话,导入进来是没有值的,必须延时到异步获取到值才会有,这样做法是比较坑爹的。

// data.mjs
let output;
(async () => {
  output = await fetch('/source');
})();
export { output };

// index.mjs
import { output } from "./data.mjs";
console.log(output); // 还没有值,undefined
setTimeout(() => {
  console.log(output); // 获取到值
}, 1000);

解决方案

那么现在支持await直接模块中使用

// data.mjs
export const output = await fetch('/source');

// index.mjs
import { output } from "./data.mjs";
console.log(output); // 获取到值

这意味index.mjs引入data.mjs时,会先把data.mjs所有异步代码执行完成后,这才代表data.mjs模块初始化完,才会执行index.mjs的代码。

注意

这对Node项目还是ok的,但是在web中可能有坑

我们先来思考下,下面打印的顺序是什么?

html部分

<body>
  <script type="module" id="module_1">
    console.log(1);
  </script>
  
  <script>
    console.log(2);
    window.addEventListener("load", (event) => {
      console.log(3);
    });
  </script>
  
  <script type="module" id="module_2">
    console.log(4);
    window.addEventListener("load", (event) => {
      console.log(5);
    });
    import "./foo.js";
  </script>
  
  <script type="module" id="module_3">
    console.log(6);
  </script>
  
  <script>
    console.log(7);
  </script>
</body>

foo.js文件

// foo.js
console.log(8);

——

——

——

现在揭晓答案,顺序为27184635

为什么1不是最先打印?这是因为带有type="module"的script不需要使用 defer 属性,也会自动延迟加载。所以它会比标准脚本往后执行。

然后在id="module_2"模块中,会先把foo模块的代码先执行,初始化后才会执行本身模块的代码,所以打印的4会在8之后。

那么上面还好理解,并且能够把控。

现在Javascript支持顶层await后。我们把foo.js代码改变下,html保持不变,那么顺序将会发生大改变,思考一番下面揭晓。

// foo.js
await new Promise(resolve => {
  setTimeout(() => {
    resolve(8)
  }, 1000)
}).then(v => {
  console.log(v);
})

——

——

——

顺序为2716384

发现id="module_2"的模块相关代码是最后执行的,比id="module_3"模块还后,id="module_2"的模块其实相当于被阻塞了,必须等待foo.js异步结束。TLA(顶层await)会隐式传递,所有依赖TLA的模块都会变成异步模块

细心发现5根本没有打印出来。因为上面说了引入的模块foo.js必须得执行完,id="module_2"的模块代码才会执行,这时候load事件早已结束,那么再绑定load事件是没有意义的。

上面的操作可能导致结果出乎开发者意料。这是因为foo.js添加顶层await引起。假设以后我们不是import "./foo.js";,而是导入别人的模块,如果不清楚别人内部代码或者提前说明,那么我们根本没法保证自己代码能够按预想的情况执行。所以TLA可见性很差,很容易无意间引入包含TLA的模块,并且改变了代码的执行序

class相关

由于篇幅有限,关于class的贺老就暂时不展开讲。class相关进入标准的有实例属性的新写法私有方法和私有属性静态方法和静态属性静态的私有属性和私有方法静态块。其实这些很早有提案,只不过现在才正式纳入标准。详细阔以查看MDN。

最后

2023年,如果有必要,那就把新特性用起来,但是千万不要踩坑哦。