Skip to content

JavaScript API

Web API 数量之多令人难以置信,这里我们了解开发中常用的一些

Atomics 与 SharedArrayBuffer

多个上下文访问 SharedArrayBuffer 时,如果同时对缓冲区进行操作,可能出现资源争用问题

Atomic API 通过强制同一时刻只能对缓冲区执行一个操作,让多个上下文安全地读写一个 SharedArrayBuffer;Atomic API 是 ES2017 中定义(类似于操作系统的原语)

SharedArrayBuffer

SharedArrayBuffer 与 ArrayBuffer 具有相同的 API,二者区别是 ArrayBuffer 必须在不同执行上下文切换,SharedArrayBuffer 则可以被任意多个执行上下文同时使用

SharedArrayBuffer API 和 ArrayBuffer API 相同,后者详情请看红宝书第六章,前者详情请看红宝书 27 章

原子操作基础

任何全局上下文都有 Atomic 对象,这个对象暴露了一系列静态方法用于执行线程安全操作

其中多数方法以一个 TypedArray 实例(一个 SharedArrayBuffer 的引用)作为第一个参数,以相关操作数作为后续参数

1、算数及位操作方法

Atomic API 提供了一套简单的方法执行就地修改操作,在 ECMA 规范中,这些方法被定义为 AtomicReadModifyWrite 操作

在底层,这些方法会从 SharedArrayBuffer 中某个位置读取值,然后执行算数或位操作,然后把计算结果写回相同位置;这一套方法会按顺序执行,不会被其他线程阻断

算数方法:

javascript
//创建大小为1的缓冲区
let sab = new SharedArrayBuffer(1);
//基于缓冲区创建Uint8Array
let ta = new Uint8Array(sab);
//所有ArrayBuffer初始化为0

const index = 0;
const increment = 5;

//对索引0处的值执行原子加5
Atomics.add(ta, index, increment); //5
//减5
Atomics.sub(ta, index, increment); //0

位方法:

javascript
//创建缓冲区、Uint8Array,初始化为0

const index = 0;

//对索引0处的值执行原子或0b1111
Atomics.or(ta, index, 0b1111); //15
//原子与0b1111
Atomics.and(ta, index, 0b1111); //12
//异或0b1111
Atomics.xor(ta, index, 0b1111); //3
2、原子读写

浏览器的 js 编译器和 CPU 架构本身都有权限重排指令以提升程序执行效率;正常情况下 js 单线程环境可以随时进行这种优化,但多线程下的指令重排可能导致资源争用,极难排错

Atomics API 通过两种方式解决了这个问题:

​ 所有原子指令相互间永远不会重排

​ 使用原子读或原子写保证所有指令都不会对原子读写重新排序

Atomics.load()和 Atomics.store()还可以构建“代码围栏”

3、原子交换

为保证连续、不间断的先后读写,Atomics API 提供了两种方法:exchange()和 compareExchange(),前者执行简单的交换,以保证其它线程不会中断值的交换;后者可以对自己想要的操作进行匹配,如果不匹配则不会进行写操作

代码请看红宝书 p614

4、原子 Futex 操作与加锁

Atomics API 提供了模仿 Linux Futex(快速用户空间互斥量)的方法

所有的 Futex 操作只能用于 Int32Array 数组,而且只能用于工作线程内部

Atomics.wait()和 Atomics.notify()类似于 P 和 V 操作

代码看红宝书 615

跨上下文消息

跨文档消息,有时候也简称为 XDM,例如在跨源的内嵌窗格中进行页面通信

XDM 的核心是 postMessage()方法

这个方法接收三个参数:消息、表示目标接收源的字符串和可选的可传输对象的数组;第二个参数对于安全非常重要,可以限制浏览器交付数据的目标,如果想要不限制目标则可以传“*”

接收到 XDM 消息后,window 对象上会触发 message 事件;这个事件是异步触发的;传给 onmessage 事件处理程序的 event 对象包含以下 3 方面重要信息:

​ data:作为一个参数传递给 postMessage()的字符串

​ origin:发送消息的文档源

​ source:发送消息的文档中 window 对象的代理

通常我们在接收到消息后的 onmessage 事件处理程序中检查发送窗口的源可以保证数据来自正确的地方

大多数情况下,event.source 是某个 window 对象的代理,而非实际的 window 对象,因此不能通过它访问所有窗口下的信息,最好只用 postMessage()方法

Encoding API

这个 API 主要用于实现字符串与定型数组之间的转换,有四个用于执行转换的全局类:TextEncoder、TextEncoderStream、TextDecoder 和 TextDecoderStream

文本编码

Encoding API 提供了将字符串转换为定型数组二进制格式的方法:批量编码和流编码;转换时,编码器始终使用 UTF-8

1、批量编码

通过 TextEncoder 实例完成的,实例上有一个 encode()方法,接收一个字符串,并以 Uint8Array 格式返回每个字符 UTF-8 编码

有些字符(表情)在最终返回数组可能占很多个索引

编码实例还有 encodeInto()方法,接收一个字符串和目标 Uint8Array,返回一个字典,该字典包含 read 和 written 属性,分别从源字符串读取了多少字符和向目标数组写入了多少字符;如果空间不够,则会提前终止

使用其他类型数组会导致 encodeInto()抛出错误

2、流编码

TextEncoderStream 其实就是 TransformStream 形式的 TextEncoder

相关代码查看红宝书 p619

文本编码

同样可以使用批量解码和流解码,但是解码可以指定非常多的字符串编码,默认是 UTF-8

在定义 TextDecoder 时,向构造函数传入解码格式

1、批量解码

TextDecoder 实例有一个 decode()方法,该方法接收一个定型数组参数,返回解码后的字符串,数组类型没有限制,使用 UTF-8 格式

得到的字符串填充了空格

2、流解码

TextDecoderStream 其实就是 TransformStream 形式的 TextDecoder

相关代码查看红宝书 p620

流解码器经常与 fetch()一起使用,因为响应体可以作为 ReadableStream 来处理

File API 与 Blob API

Web 应用程序一个痛点是无法操作用户计算机上的文件;2000 年以前,处理文件的唯一方法是把<input type="file">放到一个表单里

File API 与 Blob API 是为了让 Web 开发者能以更加安全的方式访问客户端机器上的文件,从而更好地与这些文件交互而设计的

File 类型

File API 仍然以表单中文件输入字段为基础,但是增加了直接访问文件信息的能力,HTML5 在 DOM 上为文件输入元素添加了 files 集合,这个集合会包含一组 file 对象,表示被选中的文件

每个 File 对象都有一些只读属性:

​ name:本地系统中的文件名

​ size:以字节计的文字大小

​ type:包含文件 MIME 类型的字符串

​ lastModifiedDate:表示文件最后修改事件字符串(只有 Chrome 实现了)

FIle API 还提供了 FileReader 类型,让我们从实际文件中读取数据

FileReader 类型

该类型表示异步文件读取机制;可以把它想象成 XMLHttpRequest,只不过是变成了从文件系统读取文件

该类提供了几个读取文件数据的方法:

​ readAsText(file, encoding):从文件中读取纯文本内容并保存在 result 属性中,第二个编码参数是可选的

​ readAsDataURL(file):读取文件并将内容的数据 URI 保存在 result 属性中

​ readAsBinaryString(file):读取文件并将每个字符的二进制数据保存在 result 属性中

​ readAsArrayBuffer(file):读取文件并将文件以 ArrayBuffer 形式存在 result 属性中

因为读取方法是异步的,所以每个 FileReader 会发布几个事件,三个最有用的事件是 progress、error、load,表示还有数据、发生错误、读取完成

progress 事件 50 毫秒触发一次,与 XHR 的 progress 事件有相同信息:lengthComputable、loaded、total,此外还可以在事件中获取 FileReader 的 result,即使尚未包含全部数据

error 会在某种原因无法读取文件时触发,触发事件时,FileReader 的 error 属性会包含错误信息,这个属性是一个对象,包含 code 属性;code 的值可能是:1(未找到文件)、2(安全错误)、3(读取被中断)、4(文件不可读)、5(编码错误)

load 事件会在文件成功加载后触发,如果 error 事件被触发,就不会触发 load 事件

如果想提前结束文件读取,可以在过程中调用 abort()方法,从而触发 abort 事件

在 load、error、abort 事件触发后,还会触发 loaded,该事件表示在上述 3 种情况下,所有读取操作都已经结束

FileReaderSync 类型

该类型是 FileReader 的同步版本,这个类型拥有与 FileReader 相同的方法,只有在整个文件都加载到内存后才继续执行,该类型只在工作线程中可用,因为如果整个文件耗时太长则会影响全局

Blob 与部分读取

某些情况下需要读取部分文件,为此,File 对象提供了一个 slice()方法,接收两个参数:起始字节和要读取的字节数,返回一个 Blob 实例,而 Blob 实际上是 File 的超类

blob 表示二进制大对象,是 js 对不可修改二进制数据的封装类型;包含字符串的数组、ArrayBuffers、ArrayBufferViews,甚至其它 Blob 都可以用来创建 blob;Blob 构造函数接收一个 options 参数,并在其中指定 MIME 类型

javascript
new Blob(["foo"]); //Blob {size: 3, type: ""}new Blob(['{"a": "b"}'], { type: 'application/json' });	//Blob {size: 10, type: "application/json"}

Blob 对象有一个 size 属性和 type 属性,还有 slice()方法用于切分数据,也可以使用 FileReader 从 Blob 中读取数据

对象 URL 与 Blob

对象 URL 有时也称作 Blob URL,是指引用储存在 File 和 Blob 中数据的 URL,对象 URL 优点是不用将文件内容读取到 js 也可以使用文件

要创建对象 URL,可以使用 window.URL.createObjectURL()方法并传入 File 或 Blob 对象,这个函数返回一个指向内存中地址的字符串,这个字符串是 URL,可以在 DOM 中直接使用

用完数据后最好能够释放与之相关的内存,如果不想使用某个对象 URL,最好把它传给 window.URL.revokeObjectURL();当然,页面卸载时,所有对象的 URL 占用内存都会被释放

读取拖放文件

组合使用 HTML5 拖放 API 与 FIleAPI 可以创建读取文件信息的有趣功能,将文件拖动到页面上创建的放置目标上就能触发 drop 事件,可以通过 event.dataTransfer.files 属性读到,这个属性保存着一组 File 对象

必须取消 dragenter、dragover、drop 的默认行为

媒体元素

HTML5 新增了两个与媒体相关的元素:audio 和 video

html
<vedio src="xxx.mpg" id="myVedio">Vedio player not available.</vedio>
<audio src="xx.mp3" id="myAudio">Audio player not available</audio>

每个元素至少要有一个 src 属性,表示要加载的媒体文件,也可以指定视频播放器的大小:width 和 height 属性,以及视频在加载期间显示图片 URI 的 poster 属性;如果 controls 属性如果存在,则表示浏览器应该显示播放界面,让用户可以直接控制媒体;标签里的内容是在媒体播放器不可用时显式地替代内容

由于浏览器支持的媒体格式不同,所以可以指定多个不同的媒体源;这需要从元素中删除 src 属性,使用一个或多个 source 属性代替

html
<vedio id="myVedio">
  <source src="xxx.webm" type="video/webm; codecs='vp8, vorbis'" />
  <source src="xxx.ogv" type="video/ogg; codecs='theora, vorbis'" />
  <source src="xxx.mpg" />
  Vedio player not available.
</vedio>
<audio id="myAudio">
  <source src="xxx.ogg" type="audio/ogg" />
  <source src="xxx.mp3" type="audio/mpeg" />
  Audio player not available
</audio>
属性

video 和 audio 元素提供了稳健的 js 接口,这两个属性有很多属性,可以用于明确媒体的当前状态

相关属性查看红宝书 p627,这些属性也可以在元素标签上设定

事件

媒体元素还有很多事件,查看红宝书 p628

自定义媒体播放器

audio 和 video 的 play()和 pause()方法,可以手动控制媒体文件播放

检测编解码器

并不是所有的都支持所有的编解码器,所以我们通常提供多个媒体源;所以 js API 可以用来检测浏览器是否支持给定格式和编解码器

这两个媒体元素都有一个名为 canPlayType()的方法,该方法接收一个格式/编解码器字符串,返回一个字符串值:”probably“、“maybe”、“”,其中空字符串就是假值

只给 canPlayType()提供一个 MIME 类型的情况下,最可能返回的值时“maybe”和空字符串,文件只是一个包装音频和视频数据的容器,而真正决定文件是否可以播放的是编码,在同时提供 MIME 类型和编解码器的情况下,返回值可能性会提高到“probably”

编解码器必须放到引号中,同时可以在视频元素上使用 canPlayType()检测视频格式

音频类型

audio 元素有一个 Audio 的原生 js 构造函数,支持在任何时候播放音频;类似于 Image,但是不需要插入文档即可工作

要使 Audio 播放音频,只需要创建一个新实例并传入音频文件

javascript
let audio = new Audio("xxx.mp3");
EventUtil.addHandler(audio, "canplaythrough", function (event) {
  audio.play();
});

创建 Audio 实例就会开始下载指定文件,下载完成后可以用 play()播放音频

原生拖放

拖放事件

有的事件在被拖放的元素上触发,有的在放置目标上触发;在某个元素被拖动时,会按顺序触发事件:

​ dragstart

​ drag

​ dragend

把元素拖动到一个有效的放置目标上,会依次触发以下事件:

​ dragenter

​ dragover

​ dragleave 或 drop

dataTransfer 对象

除非数据受影响,否则简单的拖放并没有实际意义;event 对象中 dataTransfer 对象用于传递数据,该对象有两个主要方法:getData()和 setData()

setData()的第一个参数,getData()的唯一参数是一个字符串,表示要设置的数据类型:允许任何 MIME 类型,而且会继续支持“text”和“URL”(IE 规范“text”或“URL”)

dropEffect 与 effectAllowed

这两个属性可以确定能够对被拖动元素和放置目标执行什么操作

dropEffect 属性高可以告诉浏览器允许哪种放置行为,这个属性有四个可能值:

​ none:被拖动元素不能放到这里,除文本框之外所有元素的默认值

​ move:被拖动元素应该移动到放置目标

​ copy:被拖动元素应该复制到放置目标

​ link:放置目标会导航到被拖动元素(仅在它是 URL 的情况下)

dropEffect 属性必须在 ondragenter 事件处理程序中使用它

最重要的的一点是 effectAllowed,因为没有设置它,dropEffect 也没用;该属性表示被拖动元素是否允许 dropEffect,该属性有几个值:

​ uninitialized:没有给被拖动元素设置动作

​ none:被拖动元素上没有允许的动作

​ copy:只允许 copy 这种 dropEffect

​ link、move

​ copyLink:允许 copy 和 link 两种 dropEffect

​ copyMove、linkMove

​ all:允许所有 dropEffect

必须在 ondragstart 事件处理程序中设置

可拖动能力

默认情况下,图片、链接、文本是可拖动的,无需额外代码用户便可以拖动他们,文本只有被选中的时候才能拖动

HTML5 在 HTML 元素上规定了一个 draggable 属性,表示元素是否可拖动;图片和链接的 draggable 属性自动被设置为 true,其他的元素默认为 false

其他成员

HTML5 规范为 dataTransfer 对象定义了如下方法:

​ addElement(elem):为拖动操作添加元素;只传输元素,不会影响拖动操作外观

​ clearData(format):清除以特定格式储存的数据

​ setDragImage(elem, x, y):允许指定拖动发生时显示在光标下面的图片

​ types:当前储存的数据类型列表;这个集合类似数组,以字符串形式保存

Notification API

向用户显示通知,类似于 alert()对话框,但是通知提供更灵活的自定义能力

通知权限

为了防止被滥用,默认会开启两项安全措施:

​ 通知只能运行在安全的上下文的代码中被触发

​ 通知必须按照每个源的原则明确得到用户允许

用户授权显示通知是通过浏览器内部的一个对话框完成的,除非用户没有明确给出允许或拒绝的答复,否则这个权限对每个域只会出现一次;浏览器会记住用户的请求,如果拒绝则无法重来

页面可以使用 Notification 向用户请求通知权限,每个对象都有一个 requestPermission()方法,该方法返回一个期约,用户在授权对话框上执行操作后这个期约会被解决

javascript
Notification.requestPermission().then((permission) => {
  console.log("user response:", permission);
});

“granted”值代表用户明确授权了显示通知的权限,除此之外的值意味着显示通知会静默失败;如果用户拒绝授权,这个值就是“denied”;一旦拒绝,就无法通过编程的方式挽回,因为不可能再触发授权提示

显示和隐藏通知

Notification 构造函数用于创建和显示通知;最简单的通知形式只是显示一个标体,这个标题内容可以作为第一个参数传给 Notification 构造函数

javascript
new Notification("Title text!");
//这样会立即显示通知

//可以通过options参数对通知进行自定义,包括设置通知主体、图片、振动
new Notification("Title text", {
  body: "body text",
  image: "path/to/image.png",
  vibrate: true,
});

调用构造函数返回的 Notification 对象 close()方法可以返回关闭显示的通知

通知声明周期回调

通知并非只用于显示文本字符串,也可以用于实现交互,有四个用于添加回调的声明周期的方法:

​ onshow:在通知显示时触发

​ onclick:在通知被点击时触发

​ onclose:在通知消失或通过 close()关闭时触发

​ onerror:在发生错误时阻止通知显示时触发

Page Visibility API

该 API 旨在为开发者提供页面对用户是否可见的信息,该 API 由三部分构成:

​ document.visibilityState 值,表示下面 4 中状态之一:

​ 页面在后台标签页或浏览器中最小化

​ 页面在前台标签页中

​ 实际页面隐藏了,但对页面的预览是可见的

​ 页面在屏外预渲染

​ visibilitychange 事件,该事件会在文档从隐藏变可见时触发

​ document.hidden 布尔值,表示页面是否隐藏;这个值是为了向后兼容才继续被浏览器支持的

页面的状态,需要监听 visibilitychange 事件,document.visibilityState 的值为下列字符串:hidden、visible、prerender

Stream API

这个 API 是为了消费有序的小信息而不是大块信息,有两种场景需要使用:

​ 大块数据可能不会一次性都可用;例如网络请求,网络负载是以连续信息包形式交付的,而流式处理可以让应用在数据一达到就能使用,而不必等到所有数据都加载完毕

​ 大块数据可能需要分小部分处理;视频处理、数据压缩、图像编码和 JSON 解析都是可以分成小部分进行处理,而不必等到所有数据都在内存中时在处理

Stream API 则没有那么快得到支持

理解流

这些 API 实际是为映射低级 I/O 原语而设计,包括适当时候对字节流的规范化;Stream API 直接解决的问题是处理网络请求和读写磁盘

​ 可读流:可以通过某个公共接口读取数据块的流,数据在内部从底层源入流,然后由消费者进行处理

​ 可写流:可以通过某个公共接口写入数据块的流,生产者将数据写入流,数据在内部传入底层数据槽

​ 转换流:有两种流组成,可写流用于接收数据(可写端);可读流用于输出数据(可读端);这两个流之间是转换程序,可以根据需要检查和修改流的内容

块、内部队列和反压

流的基本单位是块;块是可任意数据类型,但通常是定型数组;每个块都是离散的流片段,可以作为一个整体来处理;块不是固定大小,也不一定按固定时间间隔到达;在理想的流当中,块的大小通常近似相同,到达间隔也近似相等;不过好的流实现要考虑边界的情况

前面提到各种类型的流都有入口和出口的概念;有时候由于数据进出速率不同,可能会出现不匹配的情况,所以可能出现三种情形:

​ 流出口处理数据的速度比入口提供数据的速度快;流出口经常空闲,但是只会浪费一点内存或计算机资源,因此这种流的不平衡是可以接受的

​ 流入和流出均衡;这是理想状态

​ 流入口提供数据的速度比出口处理数据的速度快;这种流不平衡是固有的问题,所以会在某个地方出现数据积压,流必须相应做出处理

流不平衡是常见的问题,但流也提供解决这个问题的工具;所有流都会为已进入流但尚未离开流的块提供一个内部队列;对于均衡流,这个内部队列中会有零个或少量排队的块,因为流出口块的出列的速度与流入口块入列的速度近似相等,这种流内部队列所占用的内存相对比较小

如果块入列速度快于出列速度,则内部队列会不断增大;流不能允许其内部队列无线增大,因此它会使用反压通知流入口停止发送数据,直到队列大小降到某个既定阈值之下;这个阈值由排列策略决定,这个策略定义了内部队列可以占用的最大内存,即高水位线

可读流

可读流是对底层数据源的封装;底层数据源可以将数据填充到流中,允许消费者通过流的公共接口读取数据

1、ReadableStreamDefaultController
javascript
async function* ints() {
  for (let i = 0; i < 5; ++i) {
    yield await new Promise((resolve) => setTimeout(resolve, 1000, i));
  }
}
//这个生成器的值可以通过可读流的控制器传入可读流;访问这个控制器最简单的方式是创建ReadableStream的一个实例,并在这个构造函数的underlyingSource参数(第一个参数)中定义start()方法,然后在这个方法中使用作为参数传入的controller;这个控制器参数是ReadableStreamDefaultController的一个实例
const readableStream = new ReadableStream({
  start(controller) {
    console.log(controller);
  },
});
//调用控制器的enqueue()方法可以把值传入控制器,所有值传完后,调用close()关闭流
async function* ints() {
  for (let i = 0; i < 5; ++i) {
    yield await new Promise((resolve) => setTimeout(resolve, 1000, i));
  }
}

const readableStream = new ReadableStream({
  async start(controller) {
    for await (let chunk of ints()) {
      controller.enqueue(chunk);
    }

    controller.close();
  },
});
2、ReadableStreamDefaultReader

该实例通过流的 getReader()方式获取,调用这个方法会获得流的锁,保证只有这个读取器可以从流中读取值:

javascript
console.log(readableStream.locked); //false
const readableStreamDefaultReader = readableStream.getReader();
console.log(readableStream.locked); //true

//消费者
(async function () {
  while (true) {
    const { done, value } = await readableStreamDefaultReader.read();
    if (done) {
      break;
    } else {
      console.log(value);
    }
  }
})();

//0
//1
//2
//3
//4
可写流
1、创建 WriteableStream
javascript
async function* ints() {
  for (let i = 0; i < 5; ++i) {
    yield await new Promise((resolve) => setTimeout(resolve, 1000, i));
  }
}
//这些值通过可写流的公共接口可以写入流,再传给WriteableStream构造函数的underlyingSink参数中,通过write()方法可以获得写入的数据
const readableStream = new ReadableStream({
  write(value) {
    console.log(value);
  },
});
2、WriteableStreamDefaultWriter

可以通过 getWriter()方法获取 WriteableStreamDefaultWriter 的实例;这样可以获得流的锁,确保只有一个写入器的可以向流中写入数据:

javascript
console.log(writableStream.locked); //false
const writableStreamDefaultWriter = writableStream.getWriter();
console.log(writableStream.locked); //true

//生产者
(async function () {
  for await (let chunk of ints()) {
    await writableStreamDefaultWriter.ready;
    writableStreamDefaultWriter.write(chunk);
  }

  writableStreamDefaultWriter.close();
})();
转换流

转换流用于组合可读流和可写流;数据块在两个流之间的转换是通过 transform()方法完成的

javascript
async function* ints() {
  for (let i = 0; i < 5; ++i) {
    yield await new Promise((resolve) => setTimeout(resolve, 1000, i));
  }
}
const { writable, readable } = new TransformStream({
  transform(chunk, controller) {
    controller.enqueue(chunk * 2);
  },
})(
  //消费者
  async function () {
    while (true) {
      const { done, value } = await readableStreamDefaultReader.read();

      if (done) {
        break;
      } else {
        console.log(value);
      }
    }
  }
)();
//生产者
(async function () {
  for await (let chunk of ints()) {
    await writableStreamDefaultWriter.ready;
    writableStreamDefaultWriter.write(chunk);
  }

  writableStreamDefaultWriter.close();
});
通过管道连接流

pipeThrough()方法把 ReadableStream 接入 TransformStream。从内部看,ReadableStream 先把自己的值传给 TransformStream 内部的 WritableStream,然后执行转换,接着转换后的值又在新的 ReadableStream 上出现

javascript
const doublingStream = new TransformSteam({
  transform(chunk, controller) {
    constroller.enqueue(chunk * 2);
  },
});
//通过管道连接流
const pipedStream = integerStream.pipeThrough(doublingStream);
//从连接流的输出获得读取器
const pipedStreamDefaultReader = pipedStream.getReader();

//消费者
(async function () {
  while (true) {
    const { done, value } = await pipedStreamDefaultReader.read();

    if (done) {
      break;
    } else {
      console.log(value);
      s;
    }
  }
})();

//0
//2
//4
//6
//8

使用 pipeTo()方法也可以将 ReadableStream 连接到 WritableStream

javascript
const writableStream = new WritableStream({
  write(value) {
    console.log(value);
  },
});
const pipedStream = integerStream.pipeTo(writableStream);
//0
//1
//2
//3
//4

计时 API

Performance 接口通过 Javascript API 暴露了浏览器内部的度量指标,允许开发者直接访问这些信息并基于这些信息实现自己想要的功能;所有与页面相关的指标,包括已经定义和将来会定义的,都会存在于这个对象上

Performance 接口由多个 API 构成

High Resolution Time API

Date.now()方法只适用于日期时间相关操作,而且是不要求计时精度的操作;但是有一些方法会导致意外的情况出现(连续执行返回相同值等)

所以出现了 window.performance.now(),这个方法返回一个微秒精度的浮点值,连续使用这个方法不可能返回相同值,而且时间单调增长

performance.now()计时器采用相对度量

performance.timeOrigin 属性返回计时器初始化时全局系统时钟的值

通过使用 performance.now()测量 L1 缓存与主内存的延迟差,幽灵漏洞(Spectre)可以执行缓存推断攻击;为了弥补这个安全漏洞,所有的主流浏览器选择降低该方法的精度,有的选择在时间戳里混入一些随机性

Performance Timeline API

这是一套用于度量客户端延迟的工具扩展了 Performance 接口;性能度量将会使用计算结束与开始时间差的形式;这些开始时间和结束时间会被记录为 DOMHighResTimeStamp 值,而封装这个时间戳的对象是 PerformanceEntry 的实例

浏览器会自动记录各种 PerformanceEntry 对象,performance.mark()也可以记录自定义 PerformanceEntry 对象;一个执行上下文中被记录的所有性能条目可以通过 performance.getEntries()获取

返回的集合代表浏览器的性能时间线;每个 PerformanceEntry 对象都有 name、entryType、startTime 和 duration 属性

PerformanceEntry 实际上是一个抽象基类,所有记录条目其实都是其他类的具体实现(红宝书 p646),相关其他类都有大量属性,用于描述与相应条目有关的元数据

1、User Timing API

这个 API 用于记录和分析自定义性能条目;如前所述,记录自定义性能条目要使用 performance.mark()方法

javascript
performance.mark("foo");
performance.getEntriesByType("mark")[0];
//PerformanceMark {
//    name: 'foo',
//    entryType: 'mark',
//    startTime: 296.0000000....,
//    duration: 0
//}

//计算开始和结束的时间差,最新的标记会被推到getEntriesByType()返回的数组的开始位置
performance.mark("foo");
//...
performance.mark("bar");
let marks = performance.getEntriesByType("mark");
marks[0].startTime - marks[1].startTime;

可以生成 PerformanceMeasure(性能度量)条目,由对应名字作为两个标记之间的持续时间;由 performance.measure()方法生成

javascript
performance.mark("foo");
//...
performance.mark("bar");
performance.measure("baz", "foo", "bar");

const dura = performance.getEntriesByType("measure");
console.log(dura);
/**
PerformanceMeasure {
	name: 'baz',
	entryType: 'measure',
	startTime: 298.0000...,
	duration: 1.34.....
}
**/
2、Navigation Timing API

这个 API 提供了高精度时间戳,用于度量当前页面加载速度;浏览器会在导航事件发生时自动记录 PerformanceNavigationTiming 条目;这个对象会捕获大量时间戳,用于描述页面是何时以及如何加载的

performance.getEntriesByType('navigation');

3、Resource Timing API

这个 API 提供了高精度时间戳,用于度量当前页面加载时请求资源的速度,浏览器会在加载资源时自动记录 PerformanceResourceTiming;这个对象会捕获大量时间戳,用于描述资源加载速度

performance.getEntriesByType('resource')[0];

Web 组件

一套用于增强 DOM 行为的工具,包括影子 DOM、自定义 HTML 元素和模板;这一套 API 特别混乱:

​ 没有统一的“Web Component”规范:每个 Web 组件都在一个不同的规范中定义

​ 有些 Web 组件如影子 DOM 和自定义元素,已经出现了向后不兼容的版本问题

​ 浏览器实现极其不一致

由于这些问题,浏览器在使用 Web 组件通常要引入一个 Web 组件库,作为腻子脚本,模拟在浏览器钟缺失的 Web 组件

本章只介绍最新 Web 组件

HTML 模板

Web 组件之前,一直缺少基于 HTML 解析构建 DOM 子树,然后在需要时再把这个子树渲染出来的机制,一种间接方案是使用 innerHTML,但这种方式存在严重的安全隐患;另一种方式是使用 document.createElement()构建每个元素,然后添加到孤儿根节点,但是这样很麻烦

更好的方式是在页面中写出特殊标记,让浏览器自动将其解析为 DOM 子树,但跳过渲染;这是 HTML 模板的核心思想,而<template>标签正是为这个目标而生的

html
<template id="foo">
  <p>balabala</p>
</template>
1、使用 DocumentFragment

上面的例子不会被渲染,因为 template 不属于活动文档,DOM 查询方法不会发现其钟 p 标签,因为它存在于一个包含在 HTML 模板中 DocumentFragment 节点内

在浏览器中通过开发者工具检查网页内容时,可以看到 template 中的 DocumentFragment

通过 template 元素的 content 属性可以取得这个 DocumentFragment 的引用

javascript
document.querySelector("#foo").content; //document-fragment

此时的 DocumentFragment 就像一个对应的最小化 document 对象;换句话说,DocumentFragment 上 DOM 匹配方法可以查询其子树中的节点

javascript
const fragment = document.querySelector("#foo").content;
document.querySelector("p"); //null
fragment.querySelector("p"); //<p>...</p>

使用 DocumentFragment 可以一次性添加所有子节点,最多只会触发一次布局重排

2、使用 template 标签

可以将内容放置到 template 标签内,然后将其转移到对应功能区

3、模板脚本

添加的内容需要进行某些初始化

以上三者相关案例查阅红宝书 p649

影子 DOM

通过它可以将一个完整的 DOM 树作为节点添加到父 DOM 树

不过影子 DOM 内容会实际渲染到页面上,而 HTML 模板不会

1、理解影子 DOM

最初使用场景:有一些相同结构的 DOM 结构需要应用不同 CSS 样式,需要给每个 DOM 结构添加唯一的类名

2、创建影子 DOM

为了安全和避免影子 DOM 冲突,不是所有的元素都能包含影子 DOM,尝试给无效元素或已经有了影子 DOM 的元素添加影子 DOM 会导致抛出错误

以下元素可以容纳影子 DOM:任何以有效名称创建的自定义元素、article、aside、blockquote、body、div、footer、h1-h6、header、main、nav、p、section、span

影子 DOM 是通过 attachShadow()方法创建并添加给有效的 HTML 元素的;容纳影子 DOM 的元素被称为影子宿主(shadow host);影子 DOM 的根节点称为影子根(shadow root)

attachShadow()方法需要一个 shadowRootInit 对象,返回影子 DOM 的实例;shadowRootInit 对象必须包含一个 mode 属性,值为”open“或”closed“;对“open”影子 DOM 的引用可以通过 shadowRoot 属性在 HTML 元素上获得,对“closed”影子 DOM 的引用无法这样获取

javascript
//foo - HTML元素、bar - HTML元素
const openShadowDOM = foo.attachShadow({ mode: "open" });
const closedShadowDOM = bar.attachShadow({ mode: "closed" });

openShadowDOM; //#shadow-root (open)
closedShadowDOM; //#shadow-root (closed)

foo.shadowRoot; //#shadow-root (open)
bar.shadowRoot; //null

创建保密(closed)影子 DOM 的场景很少;这虽然可以限制通过宿主访问影子 DOM,但是恶意代码有很多方法可以绕过这个限制,恢复对影子 DOM 的访问;不能为了安全而使用保密影子 DOM

如果想保护独立的 DOM 树不受未信任代码的影响,对 iframe 施加的跨源限制更加可靠

3、使用影子 DOM

把影子 DOM 添加到元素后,可以像使用常规 DOM 一样使用影子 DOM

示例代码查阅红宝书 p653

影子 DOM 并非铁板一块,HTML 元素可以在 DOM 树间无限值移动

4、合成与影子 DOM 槽位

影子 DOM 是为自定义 Web 组件设计的,为此需要支持嵌套 DOM 片段

影子 DOM 一添加到元素中,浏览器就会赋予它最高优先级,优先渲染它的内容而不是原来的文本;为了显示原来的内容需要使用<slot>标签指示浏览器在哪里放置原来的 HTML

javascript
document.body.innerHTML = `<div id = "foo"><p>Foo</p></div>`;
document.querySelector("div").attachShadow({ mode: "open" }).innerHTML = `<div id = "bar"><slot></slot></div>`;

//html
//<div id="foo">
//    #shadow-root (open)
//		<div id="bar">
//            <p>Foo</p>
//		</div>
//</div>

虽然检查窗口中看到内容子在影子 DOM 中,但是实际上这只是 DOM 内容的投射,实际元素仍然在外部 DOM 中

除了默认槽位,还可以使用命名槽位实现多个投射,这是通过匹配的 slot/name 属性实现的;带有 slot=“foo”的属性会被投射到带有 name=“foo”的 slot 上

详细代码查阅红宝书 p655

5、事件重定向

如果影子中发生浏览器事件(比如 click),浏览器需要一种方式让父 DOM 处理事件;为此事件会逃出影子 DOM 并经过事件重定向在外部被处理

重定向事件只会发生在影子 DOM 中实际存在的元素上;使用 slot 标签从外部投射进来的元素不会发生事件重定向

例子查阅红宝书 p657

自定义元素
1、创建自定义元素

浏览器会尝试将无法识别的元素作为通用元素整合进 DOM,这些元素不会做任何 HTML 元素之外的事;这类元素会变成一个 HTMLElement 实例

可以在自定义标签出现时为它定义复杂的行为,也可以在 DOM 中将其纳入元素声明周期管理;自定义元素要使用全局属性 customElements,这个属性可以返回 CustomElementRegistry 对象

调用 customElements.define()方法可以创建自定义元素,这个元素继承 HTMLElement:

javascript
class FooElement extends HTMLElement {}
customElements.define("x-foo", FooElement);

自定义元素名必须至少包含一个不在头和尾的连字符,而且元素标签不能自关闭

在自定义构造函数中必须先调用 super();如果元素继承了 HTMLElement 或相似类型而不会覆盖构造函数,则没有必要调用 super(),因为原型构造函数会默认做这件事

2、添加 Web 组件内容

每次将自定义元素添加到 DOM 中都会调用其类构造函数,所以很容易给自定义元素添加子 DOM 内容;虽然不能在构造函数中添加子 DOM(会抛出 DOMException),但是可以为自定义元素添加影子 DOM,并将内容添加到这个影子 DOM 中

相关例子在红宝书 p659

3、使用自定义元素生命周期方法

可以在自定义元素不同的生命周期执行代码,自定义元素有 5 个生命周期方法:

​ constructor():在创建元素实例或将已有 DOM 元素升级为自定义元素时调用

​ connectedCallback():在每次将这个自定义元素实例添加到 DOM 时调用

​ disconnectedCallback():在每次将这个自定义元素移除的时候调用

​ attributeChangedCallback():在每次可观察属性的值发生变化时调用,在元素实例初始化时,初始值的定义也算一次变化

​ adoptedCallback():在通过 document.adoptNode()将这个自定义元素实例移动到新文档对象时调用

4、反射自定义元素属性

自定义元素既是 DOM 实体又是 JavaScript 对象,因此两者之间应该同步变化;对 DOM 的修改应该反映到 JavaScript 对象,反之亦然

要从 JavaScript 对象反射到 DOM,常见的方式是使用获取函数和设置函数

另一个方向的反射(从 DOM 到 JavaScript)需要给相应的属性添加监听器;可以使用 observedAttributes()获取函数让自定义元素的属性值每次改变时都调用 attributeChangedCallback()

样例代码请看红宝书 p661

5、升级自定义元素

并非始终可以先定义自定义元素,再在 DOM 中使用相应的元素标签;Web 组件在 CustomElementRegistry 上额外暴露一些方法,可以用来检测自定义元素是否定义完成,然后可以用它来升级已有元素

如果自定义元素已经有定义,那么 CustomElementRegistry.get()方法会返回相应的自定义元素的类;CustomElementRegistry.whenDefined()方法会返回一个期约,当自定义元素有定义后解决

javascript
customElements.whenDefined("x-foo").then(() => console.log("defined"));
customElements.get("x-foo");
//undefined
customElments.define("x-foo", class {});
//defined!
customElements.get("x-foo");
//class FooElement {}

连接到 DOM 的元素在自定义元素有定义时会自动升级,如果想在元素连接到 DOM 之前强制升级,可以使用 CustomElementRegistry.upgrade()方法

javascript
//在自定义元素有定义之前会创建HTMLUnknownElement对象
const fooElement = document.createElement("x-foo");

//创建自定义元素
class FooElement extends HTMLElement {}
customElements.define("x-foo", FooElement);

fooElement instanceof FooElement; //false
customElements.upgrade(fooElement);
fooElement instanceof FooElement; //true

Web Cryptography API

该 API 描述了一套密码学工具,规范了 JavaScript 如何以安全和符合惯例的方式实现加密;这些工具包括生成、使用、应用加密密钥对,加密和解密消息,以及可靠的生成随机数

加密接口的组织方式有点奇怪,其外部是一个 Crypto 对象,内部是一个 SubtleCrypto 对象;在 Web Cryptography API 标准化之前,window.crypto 属性在不同浏览器上实现差异很大;为实现跨浏览器兼容,标准 API 都暴露在 SubtleCrypto 对象上

生成随机数

很多人会使用 Math.random(),这个方法在浏览器中是以伪随机数生成器(PRNG)方式实现的;所以不是真的随机,PRNG 生成的值只是模拟了随机的特性;浏览器 PRNG 并未使用正真的随机源,只是对一个内部状态应用了固定的算法;每次调用 Math.random(),这个内部状态就会被一个算法修改,而结果会被转换为一个新的随机值;V8 引擎使用了一个名为 xorshift128+的算法

为了解决这个问题,密码学安全伪随机数生成器(CSPRNG)额外增加了一个熵作为输入,例如测试硬件时间或其它无法预计行为的系统特性;这样计算速度会比 PRNG 慢很多,但是安全系数会高很多,可以用于加密了

CSPRNG 可以使用 crypto.getRandomValues()在全局 crypto 对象上访问,传入一个定型数组,这个方法会将这个传入定型数组填满;这个方法最多生成 2^16 字节,超出则会抛出错误

使用 SubtleCrypto 对象

Web Cryptography API 重要特性都暴露在 SubtleCrypto 对象上,通过 window.crypto.subtle 访问

这个对象包含一组方法,用于常见的密码学功能,如加密、散列、签名、生成密钥;因为密码学操作都在原始二进制数据上执行,所以 SubtleCrypto 的每个方法都要用到 ArrayBuffer 和 ArrayBufferView 类型

对于字符串,TextEncoder 和 TextDecoder 是经常与 SubtleCrypto 一起使用的类,用于实现二进制数据与字符串之间的相互转换

SubtleCrypto 只能在安全的上下文(https)中使用

1、生成密码学摘要

计算数据的密码学摘要是非常常用的密码学操作,这个规范支持 4 种摘要算法:SHA-1 和 3 种 SHA-2

​ SHA-1(Secure Hash Algorithm 1):类似 MD5 的散列函数;接收任意大小的输入。生成 160 位消息散列;由于容易受到碰撞攻击,这个算法已经不再安全

​ SHA-2:构建于相同耐碰撞单向压缩函数之上的一套散列函数;规范支持其中 3 种:SHA-256、SHA-384、SHA-512;生成的消息摘要可以是 256 位、384 位、512 位;这个算法被认为最安全的,广泛应用于很多领域和协议,包括 TLS、PGP 和加密货币

SubtleCrypto.digest()方法用于生成消息摘要,要使用的散列算法通过字符串“SHA-1”、“SHA-256”......指定

2、CryptoKey 算法

SubtleCrypto 对象使用 CryptoKey 类的实例来生成密钥;CryptoKey 支持多种加密算法,允许控制密钥抽取和使用

CryptoKey 支持很多种算法,详情查阅红宝书 p666

3、生成 CryptoKey

使用 SubtleCrypto.generateKey()方法可以生成随机 CryptoKey,这个方法返回一个期约,解决为一个或多个 CryptoKey 实例

这个方法接收三个参数:指定目标算法的参数对象、表示密钥是否可以从 CryptoKey 对象中提取出来的布尔值、表示这个密钥可以与哪个 SubtleCrypto 方法一起使用的字符串数组(KeyUsages)

由于不同的密码系统需要不同的输入来生成密钥,上述参数对象为每种密码系统规定了必须的输入

​ RSA 密码系统使用 RsaHashedKeyGenParams 对象

​ ECC 密码系统使用 EcKeyGenParams 对象

​ HMAC 密码系统使用 HmacKeyGenParams 对象

​ AES 密码系统使用 AesKeyGenParams 对象

KeyUsages 对象用于说明密钥可以与哪一个算法一起使用,至少要包含下列中的一个字符串:encrypt、decrypt、sign、verify、deriveKey、deriveBits、wrapKey、unwrapKey

4、导出和导入密钥

如果密钥是可取的,就可以在 CryptoKey 对象内部暴露密钥原始的二进制内容,使用 exportKey()方法并指定目标格式(“raw”、“pkcs8”、“spki”、“jwk”)就可以取得这个密钥,这个方法返回一个期约,解决后的 ArrayBuffer 中包含密钥

与 exportKey()方法相反的操作要使用 importkey()方法实现;这个方法的签名实际上是 generateKey()和 exportKey()的组合

相关代码实例在红宝书 p669

5、从主密钥派生密钥

使用 SubtleCrypto 对象可以通过可配置的属性从已有密钥获得新密钥;该对象支持一个 derivekey()方法和一个 deriveBits()方法,前者返回一个解决为 CryptoKey 的期约,后者返回一个解决为 ArrayBuffer 的期约

调用 deriveKey()实际上与调用 deriveBits()之后再把结果传给 importkey()相同

deriveBits()方法接收一个算法发参数对象、主密钥、输出的位长度作为参数;当两个人分别拥有自己的密钥对,但希望获得共享的加密密钥时可以使用这个方法

deriveKey()方法是类似的,只不过返回的是 CryptoKey 实例而不是 ArrayBuffer

相关示例可以查阅红宝书 p670

6、使用非对称密钥签名和验证消息

通过 SubtleCrypto 对象可以使用公钥算法用私钥生成签名,或者用公钥验证签名;这两种操作分别通过 SubtleCrypto.sign()和 SubtleCrypto.verify()方法完成签名消息需要传入参数对象以指定算法和必要的值、CryptoKey、要签名的 ArrayBuffer 或 ArrayBufferView

希望通过这个签名验证消息的人可以使用公钥和 SubtleCrypto.verify()方法;这个方法的签名几乎 sign()相同,只是必须要提供公钥以及签名

示例查看红宝书 p671

7、使用对称密钥加密和解密

SubtleCrypto 对象支持使用公钥和对称算法加密和解密信息;这两种操作通过 SubtleCrypto.encrypt()和 SubtleCrypto.decrypt()方法完成

加密消息需要传入参数对象以指定算法和必要的值、加密密钥和要加密的数据

相关示例查看红宝书 p672

8、包装和解包密钥

SubtleCrypto 对象支持包装和解包密钥,以便在非信任渠道传输;这两种操作分别通过 SubtleCrypto.wrapKey()和 SubtleCrypto.unwrapKey()方法完成

包装密钥需要传入一个格式字符串、要包装的 CryptoKey 实例、要执行包装的 CryptoKey、一个参数对象用于指定算法和必要的值

相关示例参阅红宝书 p673