浏览器和 JavaScript 的一些新特性

CookieStore API

目前, 浏览器可用的存储方式有 cookie/sessionStorage/localStorage/IndexedDB, 后三者都暴露了十分友好的 API 供开发者访问, 只有 cookie 例外. 回想一下, 平时我们都是怎么操作 cookie 的. 比如想要获取某个 cookie, 因为 document.cookie 返回所有 cookie 联合的字符串, 所以必须要手动解析才能获取到某个 cookie 的值.

from https://medium.com/nmc-techblog/introducing-the-async-cookie-store-api-89cbecf401f
from https://medium.com/nmc-techblog/introducing-the-async-cookie-store-api-89cbecf401f

如果想要添加一个 cookie, 那么需要像下面一样将 cookie 转换成字符串然后赋值给 document.cookie.

from https://medium.com/nmc-techblog/introducing-the-async-cookie-store-api-89cbecf401f
from https://medium.com/nmc-techblog/introducing-the-async-cookie-store-api-89cbecf401f

是不是觉得很奇怪, 明明 document.cookie 是所有 cookie 的集合, 但是添加一个 cookie 却是直接给 document.cookie 赋值. 还有另一个奇怪的点, 有些时候添加一个 cookie 根本不生效, 但是浏览器并不会给出任何的错误信息, 所以只能在 setCookie 之后通过 getCookie 判断是否生效.

setCookie('name', 'value', 1);
if (getCookie('name')) {
  console.log('success to setCookie');
} else {
  console.log('fail to setCookie');
}

然而, 删除一个 cookie 更奇怪, 我们无法直接删除, 而是让 cookie 过期.

from https://medium.com/nmc-techblog/introducing-the-async-cookie-store-api-89cbecf401f
from https://medium.com/nmc-techblog/introducing-the-async-cookie-store-api-89cbecf401f

基于以上的问题推出了新的 CookieStore API.

首先, 在 window 上添加了一个名为 cookieStore 的对象, 然后 cookieStore 对象上挂载 get/set/delete 三个方法分别对应单个 cookie 的 获取/添加/删除 操作. 需要注意的是, get/set/delete 返回的都是 Promise, 所以需要处理异常.

/** 获取一个 cookie */
try {
  // 根据 name 获取
  const cookie = await window.cookieStore.get('name');
  // 获取根据条件获取
  const cookie = await window.cookieStore.get({
    value: 'xxx',
  });
  if (cookie === null) {
    console.log('name is a emtpy cookie');
  } else {
    console.log(cookie);
    /**
     * cookie 包含以下字段
     * { domain, expires, name, path, sameSite, secure, value }
     * 如果某些字段未设置则为 null
     */
  }
} catch (error) {
  // do with error
}
/** 添加一个 cookie, 修改一个 cookie 跟之前一样通过覆盖实现 */
try {
  /**
   * 可配置的字段如下
   * { domain, expires, name, path, sameSite, secure, value }
   */
  await window.cookieStore.set({
    name: 'name',
    value: 'value',
    expires: Date.now() + 1000 * 60 * 60 * 24, // 一天后过期
  });
} catch (error) {
  // do with error
}
/** 删除一个 cookie */
try {
  await window.cookieStore.delete('name');
} catch (error) {
  // do with error
}

同时, cookieStore 还提供了 getAll 的方法, 用于获取 cookie 列表.

try {
  // 根据 name 获取, 因为 cookie 可以存在同名的 cookie
  const cookieList = await window.cookieStore.getAll('name');
  // 或者根据条件获取
  const cookieList = await window.cookieStore.getAll({
    value: 'xxx',
  });
  // 如果没有条件, 则返回所有 cookie
  const cookieList = await window.cookieStore.getAll();
  // ...
} catch (error) {
  // do with error
}

以前想要监听 cookie 变化, 只能通过定时器定时检查 cookie, 而 cookieStore 直接提供了监听 cookie 变化的能力.

cookieStore.addEventlistener('change', (event) => {
  const {
    changed, // 发生变化的 cookie 数组
    deleted, // 删除的 cookie 数组
  } = event;
  // ...
});

兼容性及参考


Media Session API

如果想要控制页面上的 audio/video, 只能通过浏览器自带控制组件或者由开发者自己实现控制组件, 而且当页面处于无法点击状态时(比如切换到其他 Tab 或最小化浏览器窗口), 那么将无法实现控制 audio/video.

Media Session API 可以暴露页面 audio/video 的控制, 实现系统媒体中心控制页面的 audio/video, 包括正在播放媒体的基本信息(标题/作者/封面)以及操作(播放/暂停/快进/快退/下一个媒体/上一个媒体).

media_session

上面的例子实现了一个基本的 MediaSession. 基本信息通过全局对象 MediaMetadata 实例化, 其中 artwork 可以设置多个值, 浏览器根据出现的场景自动选择最优尺寸, 然后赋值给 navigator.mediaSession.metadata 实现设置. 媒体的控制通过 navigator.mediaSession.setActionHandler 方法设置, play/pause/seekbackward/seekforward/previoustrack/nexttrack 分别对应 播放/暂停/快退/快进/上一个媒体/下一个媒体 操作. 当媒体播放后, 浏览器会将基本信息和操作与系统映射, 并在系统提供对应的操作菜单.

比如在 Windows10 下, 音量控制附近会出现媒体控制面板.

mediaSession on Windows10
mediaSession on Windows10

在 Android 系统下, 状态栏将会出现媒体控制面板.

mediaSession on Android, picture from https://github.com/mebtte/react-media-session
mediaSession on Android, picture from https://github.com/mebtte/react-media-session

某些浏览器头部也会出现媒体控制面板.

mediaSession on Chrome
mediaSession on Chrome

兼容性及参考


Shape Detection API

现在随处可见二维码, 但是在网页上识别二维码不是一件容易的事, 要么上传到后端解析, 要么使用复杂的 JS 库. 新的 BarcodeDetector 特性提供了友好的 API, 能够脱离后端在本地识别二维码.

使用 BarcodeDetector 首先需要实例化, 然后将图片数据传给实例的 detect 方法, detect 方法是个异步操作, 所以返回值是一个 Promise. 同时, 一张图片可能包含多个二维码, 所以识别结果是个数组.

barcode_detector

BarcodeDetector 不仅能够识别二维码, 还支持各种格式的条形码, 支持的格式包括 aztec / code_128 / code_39 / code_93 / codabar / data_matrix / ean_13 / ean_8 / itf / pdf417 / qr_code / upc_a / upc_e. BarcodeDetector 默认识别所有格式的条形码, 如果你只想识别其中的某几种格式, 可以在实例化的时候指定:

const detector = new BarcodeDetector({
  formats: ['qr_code', 'codabar'], // 只识别图片中的 qr_code 和 codebar
});

BarcodeDetector 属于 Shape Detection API 的一部分, 除此之外, Shape Detection API 还有 TextDetectorFaceDetector, 分别对应文本识别和人脸识别, 以下是一个文本识别的例子:

TextDetector 目前尚未稳定, 所以不一定能够识别画布上的文字, 上传的图片识别结果也可能不准确.

text_detector

兼容性及参考


Top-level await

以前 await 关键字只允许在 async function 的内部使用, top-level await 可以让我们直接在 async function 外使用 await 关键字.

// module-a.js
(async function() {
  const { default: axios } = await import('axios');
  const response = await axios.request('https://registry.npm.taobao.org/react');
  console.log(response.data.name); // react
})();

使用 top-level await 上面的脚本可以直接移除 async function.

// module-a.js
const { default: axios } = await import('axios');
const response = await axios.request('https://registry.npm.taobao.org/react');
console.log(response.data.name); // react

如果一个模块使用了 top-level await, 那么引用这个模块的其他模块将会等待这个模块 resolve.

// a.js
console.log(1);
const { default: axios } = await import('axios');
const response = await axios.request('https://registry.npm.taobao.org/react');
console.log(2);

export default response.data.name;

// b.js
import name from './a.js';

console.log(name); // react

上面的代码, 输出顺序是 1 2 react, 也就是说, b 模块将会等待 a 模块 resolve 才会继续执行. 同理, 当 a 模块 reject, b 模块也会无法正常工作. 要想 b 模块正常工作, 那么需要对 a 模块添加错误处理.

// a.js
let name = 'default name';

try {
  const { default: axios } = await import('axios');
  const response = await axios.request('https://registry.npm.taobao.org/react');
} catch (error) {
  // do with error
}

export default name;

// b.js
import name from './a.js';

console.log(name); // 没有发生错误输出 react, 发生错误输出 default name

top-level await 非常适合某些场景.

条件引入模块

我们知道 static import 是无法实现根据条件引入, 比如下面的代码是不合法的.

if (process.env.NODE_ENV === 'production') {
  import a from 'a.js';
} else {
  import a from 'a_development.js';
}

通过 top-level await 配合 dynamic import 可以模拟条件静态引入.

let a;

if (process.env.NODE_ENV === 'production') {
  a = await import('a.js');
} else {
  a = await import('a_development.js');
}

依赖回退

当我们引入一个静态模块时, 如果模块加载失败, 那么引用这个模块的其他模块都无法正常工作.

import $ from 'https://cdn.example.com/jquery.js';

// 如果 https://cdn.example.com/jquery.js 加载失败, 那么下面的代码都无法正常工作
// do with $

通过 top-level await 配合 dynamic import 可以实现依赖的回退操作.

// jquery_wrapper.js
let $;
try {
  $ = await import('https://cdn_a.example_a.com/jquery.js');
} catch (error) {
  $ = await import('https://cdn_b.example_b.com/jquery.js');
}

export default $;

// example.js
import $ from 'path_to/jquery_wrapper.js';

// do with $

资源初始化

以前, 当一个资源需要异步操作才能初始化时, 通常会有以下写法.

// ws.js
let ws;

async function getWs() {
  if (!ws) {
    const url = await getUrl();
    ws = new Websocket(url);
  }
  return ws;
}

export default {
  sendMessage: async (message) => {
    const ws = await getWs();
    return ws.sendMessage(message);
  },
};

注意, 上面的代码只是实例, 实际应用中还需要大量的错误处理

上面的代码中, 同步的 sendMessage 方法因为异步的 getWs 方法被迫变成异步方法, 使用 top-level await 可以避免这种问题.

const url = await getUrl();
const ws = new Websocket(url);

export default ws;

兼容性及参考


BigInt

在 JavaScript 中, 整数的范围是 [Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER], 也就是负 2 的 53 次方减 1 到 2 的 53 次方减 1, 如果超出这个范围将不能保证其精度, 比如:

out of max safe integer
out of max safe integer

如果想要精确地表示超出安全整数, 通常做法是转换成字符串, 然后实现各种字符串运算的方法(相信不少人都做过数字字符串相加的一道算法题, 比如 '123' + '789' = '912').

BigInt 可以表示任意大的整数, 即使超出 SAFE_INTEGER 的范围依然能够保证精度. 声明一个 BigInt 可以在 number 后面添加 n 或者通过 BigInt 方法:

const a = 123n;
const b = BigInt(123);
const c = BigInt('123');

上面的 a / b / c 都表示 123n. BigInt 支持 + / - / * / / / ** / % 运算符, 但是不能与其他类型混合运算:

const a = 1n + 2n; // 3n
const b = 2n - 1n; // 1n
const c = 3n * 3n; // 9n
const d = 4n / 2n; // 2n
const e = 2n ** 2n; // 4n
const f = 5n % 3n; // 2n

/** 除数不能为 0n */
const g = 2n / 0n; // Uncaught RangeError: Division by zero

/** 不能与其他类型运算 */
const h = 2n + 1; // Uncaught TypeError: Cannot mix BigInt and other types

/** 运算结果将会忽略小数 */
const i = 3n / 2n; // 1n
const k = -3n / 2n; // -1n

BigInt 可以与 number 进行转换, 如果 BigInt 超过 SAFE_INTER 的范围, 那么转换后的 number 将会丢失精度:

const a = BigInt(Number.MAX_SAFE_INTEGER);
const b = a + a;
const c = Number(b); // c 不能保证准确

BigInt 同样支持比较操作, 并且能够与 number 进行比较:

/** 与 number 不严格相等 */
1n == 1; // true
1n === 1; // false

2n > 1; // true
2 > 1n; // true
2n >= 2; // true

BigInt 也有一些局限性, 首先不支持调用 Math 对象上的方法, 其次不支持 JSON.stringify, 如果想要序列化, 可以实现 BigInttoJSON 方法:

BigInt.prototype.toJSON = function() {
  // BigInt toString 不会带上 n, 例如 2n.toString() === '2'
  return this.toString();
};

JSON.stringify({ value: 2n }); // { "value": "2" }

需要注意的是, BigIntSymbol 一样是个普通方法, 不是一个构造方法, 所以无法通过 new 实例化:

const a = new BigInt(1); // Uncaught TypeError: BigInt is not a constructor

JavaScript 一共有 7 种数据类型 undefined / null / boolean / number / string / symbol / object, BigInt 是第 8 种数据类型, typeof 1n 的值是 bigint, 而且属于基本类型.

兼容性及参考


数字分隔符

通常情况下, 如果一个数字特别大, 我们会在显示界面添加分隔符显得更有可读性:

number on Google Sheets
number on Google Sheets

现在通常是千位分隔符, 应该是源于英语中的 thousond/million/billion/trillion/…, 每增加 3 位都有一个专有名词(https://en.wikipedia.org/wiki/Namesoflarge_numbers). 对于我来说更偏向于万位分隔符, 可能是小学数学课养成的习惯.

但是在 JavaScript 层面, 无论一个数多大我们都只能连写, 比如 1032467823482.32134324, 需要认真数的情况下才能得知准确的值.

现在, Numeric separators 特性允许在数字字面量之间插入 _ 分隔符, 使数字字面量更具可读性.

const a = 123_456;
const b = 123.345_789;

_ 分隔符可以出现在整数部分, 也可以出现在小数部分. 除了十进制, 分隔符同样可以出现在其他进制:

const a = 0b101_101; // 二进制
const b = 0o765_432; // 八进制
const c = 0xfed_dba_987; // 十六进制

上面的代码是每隔 3 位添加分隔符, 其实分隔符是可以随意添加的, 但是需要注意的是, 分隔符只能在数字之间添加, 不能在数字开头/结尾/进制标志/小数点/科学计数法符号两边添加, 也不能连续两个分隔符, 下面分隔符的位置都是错误的:

_123; // 开头, 这其实是一个合法的变量名
123_; // 结尾

/** 进制标志 */
0_b101; // 进制中间
0x_fd9; // 进制后面

/** 科学计数法 */
1.23_e14; // 科学计数法前面
1.23e_14; // 科学计数法后面

123__456; // 连续两个分隔符

数字分隔符只是为了提高代码可读性, 并没有实际意义, 带有分隔符的数字在转换成字符串的时候不会带上分隔符. 同样地, 带有 _ 字符串也不能正确地转换成数字:

(123_456.123_456).toString(); // 123456.123456
Number('123_456.123_456'); // NaN
Number.parseInt('123_456', 10); // 123
Number.parseFloat('123_456.123_456', 10); // 123

此外, 数字分隔符同样适用于上面提到的 BigInt.

兼容性及参考


CSS 颜色方法新的语法

CSS 中提供了 4 个颜色方法, 分别是 rgb / rgba / hsl / hsla. 以前每个方法的参数都需要用逗号分隔, 现在 rgb / hsl 新的语法可以省略参数中的逗号而直接使用空格分隔.

color: rgb(1, 2, 3);
/* 等同于 */
color: rgb(1 2 3);

color: hsl(1, 2%, 3%);
/* 等同于 */
color: hsl(1 2% 3%);

省略逗号的同时, rgb / hsl 都支持第 4 个参数, 表示透明度, 从而替换 rgbahsla.

color: rgba(1, 2, 3, 0.4);
/* 等同于 */
color: rgb(1 2 3 / 0.4);

color: hsla(1, 2%, 3%, 0.4);
/* 等同于 */
color: hsl(1 2% 3% / 0.4);

其中, / 两侧的空格可有可无.

兼容性及参考


aspect-ratio

如果想要实现一个指定比例的矩形, 通常使用 padding, 利用其百分比根据父元素宽度计算的特点, 但是真正的内容往往需要放在一个额外的子元素, 并且需要设置绝对定位:

<!-- 4 / 1 的矩形 -->
<style>
  .container {
    padding-bottom: 25%;
    background-color: pink;
    position: relative;
  }
  .content {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
  }
</style>

<div class="container">
  <div class="content">内容内容内容</div>
</div>

aspect-ratio 属性可以让我们直接设置元素的宽高比. 上面的例子使用 aspect-ratio 可以写成这样:

<style>
  .content {
    background-color: pink;
    aspect-ratio: 4 / 1;
  }
</style>

<div class="content">内容内容内容</div>

兼容性及参考


gap

grid 布局中, 可以用 grid-gap 属性来设置行与行和列与列之间的间隙, 现在可以直接使用 gap 属性替代 grid-gap, 而且 gap 属性增加了对 flexcolumn-count 的支持.

gap

兼容性及参考


CSS math functions

在 CSS 中可以使用 calc 方法进行数学计算, 现在新增了三个新的方法 min / max / clamp.

min 方法接受一个或多个值, 返回其中最小值, 比如 width: min(1vw, 4rem, 80px);, 如果 viewport 的宽度等于 800px, 则 1vw === 8px, 4rem === 64px, 所以结果是 width: 1vw;.

max 方法接受一个或多个值, 返回其中最大值, 上面例子中, 结果是 width: 80px;.

clamp 方法接受 3 个值 clamp(MIN, VAL, MAX), 从左到右分别是最小值/首选值/最大值, 如果首选值小于最小值则返回最小值, 如果大于最大值则返回最大值, 如果首选值介于最小值和最大值之间则返回首选值, 具体逻辑可以这样用 JS 表示:

function clamp(min, val, max) {
  // 小于最小值的话返回最小值
  if (val < min) {
    return min;
  }
  // 大于最大值的话返回最大值
  if (val > max) {
    return max;
  }
  // 介于最小值和最大值, 返回首选值
  return val;
}

也就是说, clamp 限定了 VAL 的取值范围, 比如 width: clamp(1rem, 10vw, 2rem);viewport 的宽度等于 800px 的情况下, 结果是 width: 2rem;, 因为 (10vw = 80px) > (2rem = 32px).

有趣的是, min / max / clamp 还可以与其他方法嵌套, 比如 clamp(1rem, calc(10vw - 5px), min(2rem, 20vw)), 所以 clamp 可以写成 max(MIN, min(VAL, MAX))min(MAX, max(VAL, MIN)).

兼容性及参考

使用 Discussions 讨论 Github 上编辑 分享到 Twitter