过去几年来,Web 开发领域日新月异,丰富的 Web APIs 极大简化了现代 Web 应用的开发。这篇文章将介绍一些常用的 APIs,它们是现代 Web 应用的基石,尤其是 TypedArray 和 Web Streams,它们弥补了 JS 缺乏处理二进制数据的能力的缺陷,使得许多原本需要借助服务端程序才能实现的功能得以在浏览器中直接实现。
Fetch API
Fetch API 是现代 Web 应用中最常接触的 API,它包含 4 个相互关联的部分,fetch()
函数,Headers,Request 和 Response 对象。
fetch
要发送一个 HTTP 请求,使用 fetch 函数,它是一个完备的现代化 HTTP 客户端 API,并且非常简洁,我们不需要考虑创建客户端实例之类的事情,只需要调用这个函数即可,所有其他工作都交由运行时来完成。
const res = await fetch("https://example.com")
const html = await res.text()
TSXHeaders
Headers 对象用于设置和访问 HTTP 报文头部字段的数据,当我们需要设置 Request 或者 Response 的头部信息时,我们就创建一个 Headers 对象进行操作。
Request
Request 用于表示一个请求,它保存着请求的各种信息,例如 method
,url
,headers
,以及一些有用的方法用来读取请求体的数据。我们可以通过 new Request()
构造一个请求并将其传递给 fetch()
函数,然后在服务端的 fetch
回调中访问客户端传递过来的 Request 对象。
Response
Response 表示一个响应,它保存着响应的各种信息,如 status
,headers
,以及一些有用的方法来读取响应体的数据。我们可以通过 new Response()
构建一个响应并在服务端的 fetch
回调中返回,然后在客户端的 await fetch
中取回这个服务端传递过来的 Response 对象。
我们可以通过下面这个简单的客户端和服务器的示例来看 Fetch API 在现代 Web 应用中的使用。
// client.ts
const headers = new Headers()
headers.set("token", "<my-secret-identity>")
const req = new Request("http://localhost:8000", {
method: "POST",
headers,
body: "Hi, Server!",
})
const res = await fetch(req)
if (res.ok) {
console.log(await res.text())
} else {
console.error((res.statusText || "Fetch failed") + ` (${res.status})` )
}
TSX// server.ts
export default {
fetch(req: Request) {
console.log(new Date().toISOString(), req.method, req.url)
const { headers } = req
const token = headers.get("token")
console.log("Client sent:", await req.text())
return new Response(`Welcome, ${token}!`)
}
}
TSX注意我这里的 server.ts
使用的是现代化的基于 Fetch API 的声明式服务器程序,它在 Deno, Bun, Cloudflare Workers 中都原生支持,在 Node.js 中也可以通过 import 前置脚本来支持,具体请看我另一片文章《使用 Web API 构建现代化服务器应用程序》。
URL 和 URLSearchParams
URL
另一个与 HTTP 关系密切的 API 是 URL,它表示一个 URL 地址,同时它的构造函数是一个 URL 解析器,我们可以直接给 new URL()
传入一个 URL 地址字符串来获取一个 URL 实例,它存储着已经解析好的 URL 的各个部分信息。
const url = new URL("http://localhost:8000/api/collect?activity=click-link#anchor-id")
console.log(url.protocol) // http:
console.log(url.hostname) // localhost
console.log(url.port) // 8000
console.log(url.origin) // http://localhost:8000
console.log(url.pathname) // /api/collect
console.log(url.search) // ?activity=click-link
console.log(url.hash) // #anchor-id (这个信息仅客户端存在,不会传递到服务端)
TSX我们可以很轻松修改其中的数据,然后调用 toString()
方法或者访问 href
属性获取最新的地址。
const url = new URL("http://localhost:8000/api/collect?activity=click-link#anchor-id")
url.search = "?activity=click-link"
url.hash = "#button-id"
console.log(url.toString())
// http://localhost:8000/api/collect?activity=click-button#button-id
TSXURLSearchParams
URLSearchParams 是 URL 的子集,它为我们提供了面向对象的操作 URL 查询参数的方式。例如上面的例子可以修改为下面这样:
const url = new URL("http://localhost:8000/api/collect?activity=click-link#anchor-id")
// 使用 searchParams 属性获取当前 URL 的 URLSearchParams 实例
const { searchParams } = url
searchParams.set("activity", "click-link")
url.hash = "#button-id"
console.log(url.toString())
// http://localhost:8000/api/collect?activity=click-button#button-id
TSX和 URL 一样,URLSearchParams 是一个通用的对象,它的构造函数本身也是一个解析器,我们可以传递给它一个查询字符串来获取解析后的对象。
const searchParams = new URLSearchParams("?activity=click-link") // 起始的 ? 符号会被忽略
console.log(searchParams.get("activity")) // click-link
TSX同时,URLSearchParams 实例也用于存储 application/x-www-form-urlencoded
类型的数据,它可以作为 Request 的 body 用于向服务器传递结构化数据,并且 JS 程序会自动设置对应的请求头。
// client.ts
const searchParams = new URLSearchParams("activity=click-link")
const res = await fetch("http://localhost:8000/api/collect", {
method: "POST",
body: searchParams,
})
TSX在服务端,我们可以使用 Request 的 formData()
方法将 URLSearchParams 数据读取成一个 FormData 对象来访问其内容。
export default {
async fetch(req: Request) {
const formData = req.formData() // read as form-data
const activity = formData.get("activity") as string
console.log("Client activity:", activity)
return new Response(null)
}
}
TSXFormData
FormData 实例用于存储和访问 multipart/form-data
类型的数据,我们可以通过 new FormData()
创建实例,然后往里面写入数据:
// 创建并设置数据
const formData = new FormData()
// 设置文本
formData.set("text", "Hello, World!")
// 设置文件
formData.set("file", new File(["Hello, World!"], "hello.txt", { type: "text/plain" }))
TSX我们也可以在创建实例时传递一个 HTMLFormElement 对象(仅浏览器)来自动获取 form 表单中的数据。
// 绑定表单
function MyForm() {
return (
<form onSubmit={e => {
const formData = new FormData(e.target as HTMLFormElement)
}}>
<input name="text" />
<input name="file" type="file" />
</form>
)
}
TSXFormData 对象可以作为 Reqeust 的 body 来构造一个 Content-Type: multipart/form-data
请求,并且 JS 程序会自动设置对应的请求头。
function MyForm() {
return (
<form onSubmit={async e => {
const formData = new FormData(e.target as HTMLFormElement)
const req = new Request("/api", {
method: "POST",
body: formData,
})
const res = await fetch(req)
}}>
<input name="text" />
<input name="file" type="file" />
</form>
)
}
TSX在服务端,我们可以使用 Request 对象的 formData()
方法来取回客户端发送的 FormData 对象,从而获取客户端发送的数据:
export default {
async fetch(req: Request) {
const formData = await req.formData()
const text = formData.get("text") as string
const file = formData.get("file") as File
return new Response(`Client sent message '${text}' with file ${file.name}`)
}
}
TSXBlob 和 File
Blob
Blob 表示一个二进制大对象,它提供了一些有用的方法用来将二进制数据读取成不同的形式 ,我们可以从下面例子来看它所支持的 API:
const encoder = new TextEncoder()
const data = encoder.encode("Hello, World!")
const blob = new Blob([data], { type: "text/plain" })
console.log("blob size:", blob.size) // 13 in bytes
console.log("read as text:", await blob.text())
console.log("read as ArrayBuffer", await blob.arrayBuffer())
console.log("read as bytes (Uint8Array):", await blob.bytes())
console.log("convert to ReadableStream:", blob.stream())
// 我们还可以在原来 blob 的基础上使用 slice() 来切割出一个只包含部分数据的新 blob
const blob2 = blob.slice(7, 12) // 从第 7 个字节开始,到第 12 个字节结束
console.log("blob2 size:", blob2.size) // 5 in bytes
console.log(await blob2.text()) // "World"
TSXBlob 可以直接传递给 Request 构造用于上传文件的请求,并且 JS 程序会自动设置对应的 Content-Type
请求头:
// client.ts
const encoder = new TextEncoder()
const data = encoder.encode("Hello, World!")
const blob = new Blob([data], { type: "text/plain" })
const req = new Request("http://localhost:8000", {
method: "PUT",
body: blob,
})
const res = await fetch(req)
TSX服务端则可以通过 Request 的 blob()
方法读取上传的文件:
// server.ts
export default {
async (req: Reqeust) {
const blob = await req.blob()
return new Response(`Client sent ${blob.size} bytes of ${blob.type} data`)
}
}
TSXFile
我们已经在前面 FormData 的示例中见到过 File,它继承自 Blob,并在其基础上增加了一些和文件相关的属性:
const encoder = new TextEncoder()
const data = encoder.encode("Hello, World!")
const file = new File([data], "hello.txt", { type: "text/plain" })
console.log(file.name) // 文件名
console.log(file.lastModified) // 文件最后修改时间(戳)
console.log(file.webkitRelativePath)
// 文件的相对路径,在浏览器打开文件夹时才有,相对于选择的文件夹
// 这个属性时前端特有的,当将 File 对象存储到 FormData 中时,FormData
// 将忽略这个信息,服务端也将无法取到这个信息
TSXArrayBuffer 和 ArrayBufferView
ArrayBuffer 是 JS 中表示二进制数据的底层类型,它是现代 JS 中最重要的基本类型之一。我们可以直接通过 new ArrayBuffer()
来向操作系统申请内存。不过和其他编程语言不通,我们不能直接在 ArrayBuffer 实例上操作它的数据。ArrayBuffer 只表示一块底层的内存区域(类似指针),要操作它对应内存中的数据,我们需要使用 ArrayBufferView (TypedArray 或 DataView)。
Uint8Array 是最常用的 ArrayBufferView,因为一个字节(byte)刚好就是一个 uint8 类型数据。ArrayBufferView 保存了一个 buffer 指针,一个 byteOffset 记录当前视图在 buffer 内存的起始位置(以字节为单位),以及一个 byteLength 记录当前视图的字节长度。
const buffer = new ArrayBuffer(16) // 申请 16 个字节的内存
const view = new Uint8Array(buffer) // 构建一个试图
// 更新内存中的数据
view[0] = 72
view[1] = 101
view[2] = 108
view[3] = 108
view[4] = 111
view[5] = 44
view[6] = 32
view[7] = 87
view[8] = 111
view[9] = 114
view[10] = 108
view[11] = 100
view[12] = 33
view[13] = 33
const decoder = new TextDecoder()
console.log(decoder.decode(view)) // Hello, World!!
TSXPointer, offset 和 length 的组合是编程语言中普遍使用的用于描述数据视图的数据结构,除了 JS 中的 ArrayBufferView,还有如大多数语言中的字符串,Golang 中的切片等。同一份内存数据(底层数组)可以有任意多个视图,它们通过 pointer, offset 和 length 的组合来显示不通的内容,所以被称为视图。
const encoder = new TextEncoder()
const decoder = new TextDecoder()
const view1 = encoder.encode("Hello, World!")
const view2 = new Uint8Array(view1.buffer, 7, 5) // 新视图,从第 7 个字节开始,长度为 5
console.log(decoder.decode(view2)) // World
// 不同视图操作同一份数据
view2[0] = 67
view2[1] = 104
view2[2] = 105
view2[3] = 110
view2[4] = 97
console.log(decoder.decode(view1)) // Hello, China!
TSX存储二进制数据的的 API 都包含了一个 arrayBuffer()
方法用来将数据读取为一个 ArrayBuffer 对象以便操作数据,例如 Blob/File,Request, Response,它们也接受 ArrayBuffer 或者 ArrayBufferView 作为输入。此外 Crypto API 也以 ArrayBuffer 作为输入和输出,以及上面例子中的 TextEncoder 和 TextDecoder 用于在文本和二进制数据之间进行编码和解码。
除了 Uint8Array,还有其他诸如 Int8Arrat, Int16Array, Int32Array 等一众 TypedArray,以及 DataView,但是它们的使用并不像 Uint8Array 一样频繁。
与 Fetch API 结合,ArrayBuffer 或 ArrayBufferView 可以用于构建用于上传二进制数据的请求:
// client.ts
const encoder = new TextEncoder()
const binary = encoder.encode("Hello, World!")
const req = new Request("http://localhost:8000", {
method: "PUT",
body: binary,
})
const res = await fetch(req)
TSX在服务端,我们则可以使用 Request 的 arrayBuffer()
方法读取上传的二进制数据:
// server.ts
export default {
async fetch(req: Request) {
const buffer = await req.arrayBuffer()
const binary = new Uint8Array(buffer)
return new Response(`Client sent ${binary.length} bytes of data`)
}
}
TSXWeb Streams
流(Stream)是 JS 中处理流式数据的抽象接口,JS 最基本的两个流接口是 ReadableStream 和 WritableStream。
ReadableStream
ReadableStream 是一个只读流,也是我们最经常接触到的流,例如 Reqeust 和 Response 对象都把 ReadableStream 作为输入和输出。我们很少需要显式创建 ReadableStream 的实例, 因为 JS 中和 IO 相关的 API 都已经为我们提供了读取为 ReadableStream 的接口。例如最常用的 Response 对象:
const res = await fetch("https://example.com")
const { stream } = res
TSX我们可以调用 ReadableStream 的 getReader()
方法来获取一个 reader 对象来读取流中的数据,然后用 Blob 来承载读取到的数据。
const stream: ReadableStream = getStreamSomehow()
const reader = stream.getReader()
const chunks: Uint8Array[] = []
while (true) {
const { done, value } = await reader.read()
if (done) {
break
} else {
chunks.push(value)
}
}
console.log("read as text:", await new Blob(chunks).text())
TSX或者直接使用 for await...of
迭代器语法读取流:
const stream: ReadableStream = getStreamSomehow()
const chunks: Uint8Array[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
console.log("read as text:", await new Blob(chunks).text())
TSX我们还可以使用 new Response()
直接加载流,然后使用 Response 的 API 来将流的数据读取成不同的形式:
const stream: ReadableStream = getStreamSomehow()
const res = new Response(stream)
console.log("read as test:", await res.text())
TSXWritableStream
WritableStrem 是一个只写流,我们很少直接构建这个流,因为 JS 的各种以流作为输入的 API 都使用 ReadableStream 而不是 WritableStream。当然某些情况下我们需要自己构建 WritableStream 的实例,例如当我们需要将写入到流中的数据存储起来时,我们需要自己构建一个 WritableStream 的实例并设置 write 操作的回调函数。
const container: Uint8Array[] = []
const writable = new WritableStream<Uint8Array>({
write(chunk) {
container.push(chunk)
}
})
TSX我们可以调用 WritableStream 的 getWriter()
方法获取到一个 writer 对像用来往流中写入数据:
const container: Uint8Array[] = []
const writable = new WritableStream<Uint8Array>({
write(chunk) {
container.push(chunk)
}
})
const encoder = new TextEncoder()
const writer = writable.getWriter()
await writer.write(encoder.encode('Hello'))
await writer.write(encoder.encode(', '))
await writer.write(encoder.encode('World'))
await writer.write(encoder.encode('!'))
await writer.close()
TSXTransformStream
前面提到,我们很少会手动创建 ReadableStream 和 WritableStream 的实例,因为 JS 的各种 API 通常接受 ReadableStream 作为输入,而 ReadableStream 的实例通常是与 IO 相关的 API 为我们提供的。
而当我们真正需要进行流式计算时,我们通常使用 TransformStream 来构建一对关联的 ReadableStream 和 WritableStream,写入到这个 WritableStream 中的数据会被对应的 ReadableStream 读取到。例如下面的 HTTP 服务器的示例,服务端会每隔1秒向客户端写一段数据:
export default {
fetch(req: Request) {
const { readable, writable } = new TransformStream<Uint8Array, Uint8Array>()
const writer = writable.getWriter()
const encoder = new TextEncoder()
let counter = 0
const writeChunk = () => {
writer.write(encoder.encode(`Current count is: ${++counter}\n`))
}
writeChunk() // 写第一个片段
const timer = setInterval(writeChunk, 1_000) // 定时写新的片段
// 连接中断时清除定时器并关闭写入流
req.signal.addEventListener("abort", () => {
clearInterval(timer)
writer.close()
})
return new Response(readable, {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
},
})
}
}
TSXDuplex Streams
和 Node.js 不同,Web Streams API 严格区分只读和只写的流,并没有 Duplex 流。
当我们需要在一个上下文中同时使用读和写的能力时(与上面的 TransformStream 不同),例如 socket 连接中,对应的 API 通常会提供一个 readable
对象表示 socket 中用于读的一端,同时提供一个 writable
对象表示用于写的一端。例如下面的 WebSocketStream API 的示例:
const conn = new WebSocketStream("http://localhost:8000/ws")
const { readable, writable } = await conn.opened
;(async () => { // read from the server
for await (const chunk of readable) {
console.log("Server sent:", chunk)
}
})()
const writer = writable.getWriter()
// write to the server
await writer.write("Hello, Server!")
TSXAbortSignal
AbortSignal 是 JS 异步编程中极其强大又最容易忽视的特性。异步并发的本质是把原来一次性执行的耗时任务切割成多个小块来多次计算,典型的例子就是 Request 流。相比于一次性把数据打包发送给服务器,流将数据切割成多个片段来在事件循环中逐次发送,从而避免单一任务占用 CPU 过久阻塞线程中的其他任务。
在这些多次计算的过程中,AbortSignal 则可以用来控制下一次计算是否需要继续,当检测到信号的 aborted
属性为 true
时,程序可以直接跳出循环,放弃剩余的任务,从而避免计算资源的浪费。
很多基于流的 API 都支持 AbortSignal,例如 Reqeust,当请求被终止时(如点击浏览器的取消按钮),AbortSignal 将被触发,客户端将停止向服务器发送数据。在下面的 React 组件的例子中,如果请求尚未完成,而组件提前销毁或者因为传入的属性发生变化,则中断请求。
要使用 AbortSignal,需要通过 new AbortController()
来创建一个 AbortController 实例,然后通过 signal
属性获取 AbortSignal 的实例。中断操作不直接通过 signal 来进行,而是通过 controller 来进行。
// client.tsx
import { useState } from "react"
interface User {
email: string
name: string
}
export default function UserPanel(props: { email: string }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
useEffect(() => {
// 要使用 AbortSignal,需要通过 new AbortController() 来创建一个
// AbortController 实例,然后通过 signal 属性获取 AbortSignal 的实例。
// 中断操作不直接通过 signal 来进行,而是通过 controller 来控制。
const controller = new AbortController()
const { signal } = controller
;(async () => {
setUser(null)
setLoading(true)
setError("")
const res = await fetch(`http://localhost:8000/api/user/${props.email}`, {
signal,
})
if (res.ok) {
setUser(await res.json())
} else {
setError(res.statusText || "Failed to fetch user info")
}
})().catch(err => {
setError(String(err))
}).finally(() => {
setLoading(false)
})
return () => {
controller.abort() // 如果组件提前销毁或 email 发生改变,则中断请求
}
}, [props.email])
if (loading) {
return <div>Loading...</div>
} else if (error) {
return <div>Error loading user, reason: {error}</div>
}
return (
<div>
<div>Email: {user!.email}</div>
<div>Name: {user!.name}</div>
</div>
)
}
TSX在服务端,AbortSignal 可以用来检测连接是否被中断,服务可以在连接中断时终止正在执行的任务,例如可以回滚数据库事务从而保持数据一致性。或者像我们前面见过的定时向客户端写数据的例子,我们使用 AbortSignal 来检测客户端连接断开从而清除定时器。
export default {
fetch(req: Request) {
const { readable, writable } = new TransformStream<Uint8Array, Uint8Array>()
const writer = writable.getWriter()
const encoder = new TextEncoder()
let counter = 0
const writeChunk = () => {
writer.write(encoder.encode(`Current count is: ${++counter}\n`))
}
writeChunk() // 写第一个片段
const timer = setInterval(writeChunk, 1_000) // 定时写新的片段
// 连接中断时清除定时器并关闭写入流
req.signal.addEventListener("abort", () => {
clearInterval(timer)
writer.close()
})
return new Response(readable, {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
},
})
}
}
TSX扩展问答
为什么 Fetch API 需要 await 两次?
fetch 函数是一个标准的 HTTP 客户端,它是遵循标准的 HTTP 协议来设计的。HTTP Response 报文包含两个部分,header 和 body,第一个 await 会在读完 header 时返回,而第二个 await 会在读完 body 时返回。
为什么 Request 和 Response 的 body 不能被多次读取?
Request 和 Response 并不存储 body 数据,它们底层只有一个 ReadableStream,当调用某个读数据的方法如 text()
时,将会消费掉流中的数据。因为流的数据已经被消费掉并从所调用的方法中返回给用户,再次访问流将无法再次读到数据。Request 和 Response 也不会缓存所消费的数据,因为它们可能占用非常大的内存。
什么是 ArrayBuffer 的所有权?为什么要设计所有权转移?
ArrayBuffer 是内存中二进制数据的抽象接口,本质是一个 JS 对象,其所有权指的是这个 JS 对象对其所引用的底层内存数组的所有权。
Web Wroker 的引入使得 JS 具备多线程运算能力,在线程间交换二进制数据时(使用 postMessage
),如果进行拷贝,那效率将非常低效,因此 JS 引擎支持直接将 ArrayBuffer 对应的内存的所有权转移给接收线程中构建的 ArrayBuffer 对象,如果没有所有权机制,那么多个线程的 ArrayBuffer 对象将指向同一个内存地址,操作相同的数据,这是不安全的。因此 JS 的 ArrayBuffer 设计了所有权转移的机制,保证同一内存同一时刻只有一个线程能够访问。
除了 Fetch API,AbortSignal 是否还有其他应用场景?
如我们前面提到的,AbortSignal 的用途是在多次异步计算任务中决定下一个任务是否需要被执行,因此它的用途是非常广泛的,典型的例子如下面这个 for 循环,我们有一批任务需要处理,但每次都先检测操作是否已经被中断,从而决定是继续下一个任务还是中断任务并退出循环。
const controller = new AbortController()
// 可以把 controller 传给一个取消按钮来实现中断操作,例如
document.querySelector("abort-button")?.addEventListener("click", () => {
controller.abort()
})
const { signal } = controller
const ids: number[] = [1,2,3,4,5]
for (const id of ids) {
if (signal.aborted) {
console.error("批量任务被终止!")
break
}
await process(id)
}
TSX