JavaScript 权威指南第七版(GPT 重译)(六)(3)https://developer.aliyun.com/article/1485427
15.10.3 使用 hashchange 事件进行历史管理
一种历史管理技术涉及 location.hash
和“hashchange”事件。以下是您需要了解的关键事实,以理解这种技术:
location.hash
属性设置 URL 的片段标识符,传统上用于指定要滚动到的文档部分的 ID。但location.hash
不一定要是元素 ID:你可以将其设置为任何字符串。只要没有元素恰好将该字符串作为其 ID,当你像这样设置hash
属性时,浏览器不会滚动。- 设置
location.hash
属性会更新在位置栏中显示的 URL,并且非常重要的是,会向浏览器的历史记录中添加一个条目。 - 每当文档的片段标识符发生变化时,浏览器会在 Window 对象上触发“hashchange”事件。如果你明确设置了
location.hash
,就会触发“hashchange”事件。正如我们所提到的,对 Location 对象的更改会在浏览器的浏览历史中创建一个新条目。因此,如果用户现在点击“后退”按钮,浏览器将返回到在设置location.hash
之前的上一个 URL。但这意味着片段标识符再次发生了变化,因此在这种情况下会再次触发“hashchange”事件。这意味着只要你能为应用程序的每种可能状态创建一个唯一的片段标识符,“hashchange”事件将通知你用户在浏览历史中前进和后退的情况。
要使用这种历史管理机制,您需要能够将渲染应用程序“页面”所需的状态信息编码为相对较短的文本字符串,适合用作片段标识符。您还需要编写一个将页面状态转换为字符串的函数,以及另一个函数来解析字符串并重新创建它表示的页面状态。
一旦你编写了这些函数,剩下的就很容易了。定义一个window.onhashchange
函数(或使用addEventListener()
注册一个“hashchange”监听器),读取location.hash
,将该字符串转换为你的应用程序状态的表示,并采取必要的操作来显示新的应用程序状态。
当用户与您的应用程序交互(例如点击链接)以导致应用程序进入新状态时,不要直接呈现新状态。相反,将所需的新状态编码为字符串,并将location.hash
设置为该字符串。这将触发“hashchange”事件,您对该事件的处理程序将显示新状态。使用这种迂回的技术确保新状态被插入到浏览历史中,以便“后退”和“前进”按钮继续工作。
15.10.4 使用 pushState() 进行历史管理
管理历史的第二种技术略微复杂,但比“hashchange”事件更不像是一种黑客技巧。这种更健壮的历史管理技术基于history.pushState()
方法和“popstate”事件。当 Web 应用程序进入新状态时,它调用history.pushState()
将表示状态的对象添加到浏览器的历史记录中。如果用户然后点击“后退”按钮,浏览器将触发一个带有保存的状态对象副本的“popstate”事件,应用程序使用该对象重新创建其先前的状态。除了保存的状态对象外,应用程序还可以保存每个状态的 URL,如果您希望用户能够将应用程序的内部状态添加到书签并共享链接,则这一点很重要。
pushState()
的第一个参数是一个包含恢复文档当前状态所需的所有状态信息的对象。这个对象使用 HTML 的结构化克隆算法保存,比JSON.stringify()
更灵活,可以支持 Map、Set 和 Date 对象以及类型化数组和 ArrayBuffers。
第二个参数原本是状态的标题字符串,但大多数浏览器不支持,你应该只传递一个空字符串。第三个参数是一个可选的 URL,将立即显示在位置栏中,用户通过“后退”和“前进”按钮返回到该状态时也会显示。相对 URL 会相对于文档的当前位置解析。将每个状态与 URL 关联起来允许用户将应用程序的内部状态添加到书签。但请记住,如果用户保存了书签,然后一天后访问它,你将不会收到关于该访问的“popstate”事件:你将需要通过解析 URL 来恢复应用程序状态。
除了pushState()
方法外,History 对象还定义了replaceState()
,它接受相同的参数,但是替换当前历史状态而不是向浏览历史添加新状态。当首次加载使用pushState()
的应用程序时,通常最好调用replaceState()
来为应用程序的初始状态定义一个状态对象。
当用户使用“后退”或“前进”按钮导航到保存的历史状态时,浏览器在 Window 对象上触发“popstate”事件。与事件相关联的事件对象有一个名为state
的属性,其中包含您传递给pushState()
的状态对象的副本(另一个结构化克隆)。
示例 15-9 是一个简单的 Web 应用程序——在图 15-15 中显示的猜数字游戏——它使用pushState()
保存其历史记录,允许用户“返回”以查看或重新做出他们的猜测。
图 15-15。一个猜数字游戏
示例 15-9。使用 pushState()进行历史管理
<html><head><title>I'm thinking of a number...</title> <style> body { height: 250px; display: flex; flex-direction: column; align-items: center; justify-content: space-evenly; } #heading { font: bold 36px sans-serif; margin: 0; } #container { border: solid black 1px; height: 1em; width: 80%; } #range { background-color: green; margin-left: 0%; height: 1em; width: 100%; } #input { display: block; font-size: 24px; width: 60%; padding: 5px; } #playagain { font-size: 24px; padding: 10px; border-radius: 5px; } </style> </head> <body> <h1 id="heading">I'm thinking of a number...</h1> <!-- A visual representation of the numbers that have not been ruled out --> <div id="container"><div id="range"></div></div> <!-- Where the user enters their guess --> <input id="input" type="text"> <!-- A button that reloads with no search string. Hidden until game ends. --> <button id="playagain" hidden onclick="location.search='';">Play Again</button> <script> /** * An instance of this GameState class represents the internal state of * our number guessing game. The class defines static factory methods for * initializing the game state from different sources, a method for * updating the state based on a new guess, and a method for modifying the * document based on the current state. */ class GameState { // This is a factory function to create a new game static newGame() { let s = new GameState(); s.secret = s.randomInt(0, 100); // An integer: 0 < n < 100 s.low = 0; // Guesses must be greater than this s.high = 100; // Guesses must be less than this s.numGuesses = 0; // How many guesses have been made s.guess = null; // What the last guess was return s; } // When we save the state of the game with history.pushState(), it is just // a plain JavaScript object that gets saved, not an instance of GameState. // So this factory function re-creates a GameState object based on the // plain object that we get from a popstate event. static fromStateObject(stateObject) { let s = new GameState(); for(let key of Object.keys(stateObject)) { s[key] = stateObject[key]; } return s; } // In order to enable bookmarking, we need to be able to encode the // state of any game as a URL. This is easy to do with URLSearchParams. toURL() { let url = new URL(window.location); url.searchParams.set("l", this.low); url.searchParams.set("h", this.high); url.searchParams.set("n", this.numGuesses); url.searchParams.set("g", this.guess); // Note that we can't encode the secret number in the url or it // will give away the secret. If the user bookmarks the page with // these parameters and then returns to it, we will simply pick a // new random number between low and high. return url.href; } // This is a factory function that creates a new GameState object and // initializes it from the specified URL. If the URL does not contain the // expected parameters or if they are malformed it just returns null. static fromURL(url) { let s = new GameState(); let params = new URL(url).searchParams; s.low = parseInt(params.get("l")); s.high = parseInt(params.get("h")); s.numGuesses = parseInt(params.get("n")); s.guess = parseInt(params.get("g")); // If the URL is missing any of the parameters we need or if // they did not parse as integers, then return null; if (isNaN(s.low) || isNaN(s.high) || isNaN(s.numGuesses) || isNaN(s.guess)) { return null; } // Pick a new secret number in the right range each time we // restore a game from a URL. s.secret = s.randomInt(s.low, s.high); return s; } // Return an integer n, min < n < max randomInt(min, max) { return min + Math.ceil(Math.random() * (max - min - 1)); } // Modify the document to display the current state of the game. render() { let heading = document.querySelector("#heading"); // The <h1> at the top let range = document.querySelector("#range"); // Display guess range let input = document.querySelector("#input"); // Guess input field let playagain = document.querySelector("#playagain"); // Update the document heading and title heading.textContent = document.title = `I'm thinking of a number between ${this.low} and ${this.high}.`; // Update the visual range of numbers range.style.marginLeft = `${this.low}%`; range.style.width = `${(this.high-this.low)}%`; // Make sure the input field is empty and focused. input.value = ""; input.focus(); // Display feedback based on the user's last guess. The input // placeholder will show because we made the input field empty. if (this.guess === null) { input.placeholder = "Type your guess and hit Enter"; } else if (this.guess < this.secret) { input.placeholder = `${this.guess} is too low. Guess again`; } else if (this.guess > this.secret) { input.placeholder = `${this.guess} is too high. Guess again`; } else { input.placeholder = document.title = `${this.guess} is correct!`; heading.textContent = `You win in ${this.numGuesses} guesses!`; playagain.hidden = false; } } // Update the state of the game based on what the user guessed. // Returns true if the state was updated, and false otherwise. updateForGuess(guess) { // If it is a number and is in the right range if ((guess > this.low) && (guess < this.high)) { // Update state object based on this guess if (guess < this.secret) this.low = guess; else if (guess > this.secret) this.high = guess; this.guess = guess; this.numGuesses++; return true; } else { // An invalid guess: notify user but don't update state alert(`Please enter a number greater than ${ this.low} and less than ${this.high}`); return false; } } } // With the GameState class defined, making the game work is just a matter // of initializing, updating, saving and rendering the state object at // the appropriate times. // When we are first loaded, we try get the state of the game from the URL // and if that fails we instead begin a new game. So if the user bookmarks a // game that game can be restored from the URL. But if we load a page with // no query parameters we'll just get a new game. let gamestate = GameState.fromURL(window.location) || GameState.newGame(); // Save this initial state of the game into the browser history, but use // replaceState instead of pushState() for this initial page history.replaceState(gamestate, "", gamestate.toURL()); // Display this initial state gamestate.render(); // When the user guesses, update the state of the game based on their guess // then save the new state to browser history and render the new state document.querySelector("#input").onchange = (event) => { if (gamestate.updateForGuess(parseInt(event.target.value))) { history.pushState(gamestate, "", gamestate.toURL()); } gamestate.render(); }; // If the user goes back or forward in history, we'll get a popstate event // on the window object with a copy of the state object we saved with // pushState. When that happens, render the new state. window.onpopstate = (event) => { gamestate = GameState.fromStateObject(event.state); // Restore the state gamestate.render(); // and display it }; </script> </body></html>
15.11 网络
每次加载网页时,浏览器都会使用 HTTP 和 HTTPS 协议进行网络请求,获取 HTML 文件以及文件依赖的图像、字体、脚本和样式表。但除了能够响应用户操作进行网络请求外,Web 浏览器还公开了用于网络的 JavaScript API。
本节涵盖了三个网络 API:
fetch()
方法为进行 HTTP 和 HTTPS 请求定义了基于 Promise 的 API。fetch()
API 使基本的 GET 请求变得简单,但也具有全面的功能集,支持几乎任何可能的 HTTP 用例。- 服务器发送事件(Server-Sent Events,简称 SSE)API 是 HTTP“长轮询”技术的一种方便的基于事件的接口,其中 Web 服务器保持网络连接打开,以便在需要时向客户端发送数据。
- WebSockets 是一种不是 HTTP 的网络协议,但设计用于与 HTTP 互操作。它定义了一个异步消息传递 API,客户端和服务器可以相互发送和接收消息,类似于 TCP 网络套接字。
15.11.1 fetch()
对于基本的 HTTP 请求,使用fetch()
是一个三步过程:
- 调用
fetch()
,传递要检索内容的 URL。 - 获取由第 1 步异步返回的响应对象,当 HTTP 响应开始到达时调用此响应对象的方法来请求响应的主体。
- 获取由第 2 步异步返回的主体对象,并根据需要进行处理。
fetch()
API 完全基于 Promise,并且这里有两个异步步骤,因此当使用fetch()
时,通常期望有两个then()
调用或两个await
表达式。(如果你忘记了它们是什么,可能需要重新阅读第十三章后再继续本节。)
如果你使用then()
并期望服务器响应你的请求是 JSON 格式的,fetch()
请求看起来是这样的:
fetch("/api/users/current") // Make an HTTP (or HTTPS) GET request .then(response => response.json()) // Parse its body as a JSON object .then(currentUser => { // Then process that parsed object displayUserInfo(currentUser); });
使用async
和await
关键字向返回普通字符串而不是 JSON 对象的 API 发出类似请求:
async function isServiceReady() { let response = await fetch("/api/service/status"); let body = await response.text(); return body === "ready"; }
如果你理解了这两个代码示例,那么你就知道了使用fetch()
API 所需了解的 80%。接下来的小节将演示如何进行比这里显示的更复杂的请求和接收响应。
HTTP 状态码、响应头和网络错误
在§15.11.1 中显示的三步fetch()
过程省略了所有错误处理代码。这里是一个更现实的版本:
fetch("/api/users/current") // Make an HTTP (or HTTPS) GET request. .then(response => { // When we get a response, first check it if (response.ok && // for a success code and the expected type. response.headers.get("Content-Type") === "application/json") { return response.json(); // Return a Promise for the body. } else { throw new Error( // Or throw an error. `Unexpected response status ${response.status} or content type` ); } }) .then(currentUser => { // When the response.json() Promise resolves displayUserInfo(currentUser); // do something with the parsed body. }) .catch(error => { // Or if anything went wrong, just log the error. // If the user's browser is offline, fetch() itself will reject. // If the server returns a bad response then we throw an error above. console.log("Error while fetching current user:", error); });
fetch()
返回的 Promise 解析为一个 Response 对象。这个对象的 status
属性是 HTTP 状态码,比如成功请求的 200 或“未找找”响应的 404。(statusText
给出与数字状态码相对应的标准英文文本。)方便的是,Response 的 ok
属性在 status
为 200 或 200 到 299 之间的任何代码时为 true
,对于其他任何代码都为 false
。
fetch()
在服务器响应开始到达时解析其 Promise,通常在完整响应体到达之前。即使响应体还不可用,你也可以在 fetch 过程的第二步检查头部信息。Response 对象的 headers
属性是一个 Headers 对象。使用它的 has()
方法测试头部是否存在,或使用 get()
方法获取头部的值。HTTP 头部名称不区分大小写,因此你可以向这些函数传递小写或混合大小写的头部名称。
Headers 对象也是可迭代的,如果你需要的话:
fetch(url).then(response => { for(let [name,value] of response.headers) { console.log(`${name}: ${value}`); } });
如果 Web 服务器响应你的 fetch()
请求,那么返回的 Promise 将以 Response 对象实现,即使服务器的响应是 404 Not Found 错误或 500 Internal Server Error。fetch()
仅在无法完全联系到 Web 服务器时拒绝其返回的 Promise。如果用户的计算机离线,服务器无响应,或 URL 指定的主机名不存在,就会发生这种情况。因为这些情况可能发生在任何网络请求上,所以每次进行 fetch()
调用时都包含一个 .catch()
子句总是一个好主意。
设置请求参数
有时候在发起请求时,你可能想要传递额外的参数。这可以通过在 URL 后面添加 ?
后的名称/值对来实现。URL 和 URLSearchParams 类(在 §11.9 中介绍)使得构造这种形式的 URL 变得容易,fetch()
函数接受 URL 对象作为其第一个参数,因此你可以像这样在 fetch()
请求中包含请求参数:
async function search(term) { let url = new URL("/api/search"); url.searchParams.set("q", term); let response = await fetch(url); if (!response.ok) throw new Error(response.statusText); let resultsArray = await response.json(); return resultsArray; }
设置请求头部
有时候你需要在 fetch()
请求中设置头部。例如,如果你正在进行需要凭据的 Web API 请求,那么可能需要包含包含这些凭据的 Authorization 头部。为了做到这一点,你可以使用 fetch()
的两个参数版本。与之前一样,第一个参数是指定要获取的 URL 的字符串或 URL 对象。第二个参数是一个对象,可以提供额外的选项,包括请求头部:
let authHeaders = new Headers(); // Don't use Basic auth unless it is over an HTTPS connection. authHeaders.set("Authorization", `Basic ${btoa(`${username}:${password}`)}`); fetch("/api/users/", { headers: authHeaders }) .then(response => response.json()) // Error handling omitted... .then(usersList => displayAllUsers(usersList));
在 fetch()
的第二个参数中可以指定许多其他选项,我们稍后会再次看到它。将两个参数传递给 fetch()
的替代方法是将相同的两个参数传递给 Request()
构造函数,然后将生成的 Request 对象传递给 fetch()
:
let request = new Request(url, { headers }); fetch(request).then(response => ...);
解析响应体
在我们演示的三步 fetch()
过程中,第二步通过调用 Response 对象的 json()
或 text()
方法结束,并返回这些方法返回的 Promise 对象。然后,第三步开始,当该 Promise 解析为响应体解析为 JSON 对象或简单文本字符串时。
这可能是两种最常见的情况,但并不是获取 Web 服务器响应体的唯一方式。除了 json()
和 text()
,Response 对象还有这些方法:
arrayBuffer()
这个方法返回一个解析为 ArrayBuffer 的 Promise。当响应包含二进制数据时,这是很有用的。你可以使用 ArrayBuffer 创建一个类型化数组(§11.2)或一个 DataView 对象(§11.2.5),从中读取二进制数据。
blob()
此方法返回一个解析为 Blob 对象的 Promise。本书未详细介绍 Blob,但其名称代表“二进制大对象”,在期望大量二进制数据时非常有用。如果要求响应主体为 Blob,则浏览器实现可能会将响应数据流式传输到临时文件,然后返回表示该临时文件的 Blob 对象。因此,Blob 对象不允许像 ArrayBuffer 一样随机访问响应主体。一旦有了 Blob,你可以使用 URL.createObjectURL()
创建一个引用它的 URL,或者你可以使用基于事件的 FileReader API 异步获取 Blob 的内容作为字符串或 ArrayBuffer。在撰写本文时,一些浏览器还定义了基于 Promise 的 text()
和 arrayBuffer()
方法,提供了更直接的方式来获取 Blob 的内容。
formData()
此方法返回一个解析为 FormData 对象的 Promise。如果你期望 Response 的主体以“multipart/form-data”格式编码,则应使用此方法。这种格式在向服务器发出 POST 请求时很常见,但在服务器响应中不常见,因此此方法不经常使用。
流式传输响应主体
除了异步返回完整响应主体的五种响应方法之外,还有一种选项可以流式传输响应主体,这在网络上到达响应主体的块时可以进行某种处理时非常有用。但是,如果你想要显示进度条,让用户看到下载的进度,流式传输响应也很有用。
Response 对象的 body
属性是一个 ReadableStream 对象。如果你已经调用了像 text()
或 json()
这样读取、解析和返回主体的响应方法,那么 bodyUsed
将为 true
,表示 body
流已经被读取。但是,如果 bodyUsed
为 false
,则表示流尚未被读取。在这种情况下,你可以调用 response.body
上的 getReader()
来获取一个流读取器对象,然后使用该读取器对象的 read()
方法异步从流中读取文本块。read()
方法返回一个解析为具有 done
和 value
属性的对象的 Promise。如果整个主体已被读取或流已关闭,则 done
将为 true
。而 value
将是下一个块,作为 Uint8Array,如果没有更多块,则为 undefined
。
如果使用 async
和 await
,则此流式 API 相对简单,但如果尝试使用原始 Promise,则会出乎意料地复杂。示例 15-10 通过定义一个 streamBody()
函数来演示该 API。假设你想要下载一个大型 JSON 文件并向用户报告下载进度。你无法使用 Response 对象的 json()
方法来实现,但可以使用 streamBody()
函数,如下所示(假设已定义了一个 updateProgress()
函数来设置 HTML <progress>
元素的 value
属性):
fetch('big.json') .then(response => streamBody(response, updateProgress)) .then(bodyText => JSON.parse(bodyText)) .then(handleBigJSONObject);
可以按照 示例 15-10 中所示实现 streamBody()
函数。
示例 15-10. 从 fetch() 请求中流式传输响应主体
/** * An asynchronous function for streaming the body of a Response object * obtained from a fetch() request. Pass the Response object as the first * argument followed by two optional callbacks. * * If you specify a function as the second argument, that reportProgress * callback will be called once for each chunk that is received. The first * argument passed is the total number of bytes received so far. The second * argument is a number between 0 and 1 specifying how complete the download * is. If the Response object has no "Content-Length" header, however, then * this second argument will always be NaN. * * If you want to process the data in chunks as they arrive, specify a * function as the third argument. The chunks will be passed, as Uint8Array * objects, to this processChunk callback. * * streamBody() returns a Promise that resolves to a string. If a processChunk * callback was supplied then this string is the concatenation of the values * returned by that callback. Otherwise the string is the concatenation of * the chunk values converted to UTF-8 strings. */ async function streamBody(response, reportProgress, processChunk) { // How many bytes are we expecting, or NaN if no header let expectedBytes = parseInt(response.headers.get("Content-Length")); let bytesRead = 0; // How many bytes received so far let reader = response.body.getReader(); // Read bytes with this function let decoder = new TextDecoder("utf-8"); // For converting bytes to text let body = ""; // Text read so far while(true) { // Loop until we exit below let {done, value} = await reader.read(); // Read a chunk if (value) { // If we got a byte array: if (processChunk) { // Process the bytes if let processed = processChunk(value); // a callback was passed. if (processed) { body += processed; } } else { // Otherwise, convert bytes body += decoder.decode(value, {stream: true}); // to text. } if (reportProgress) { // If a progress callback was bytesRead += value.length; // passed, then call it reportProgress(bytesRead, bytesRead / expectedBytes); } } if (done) { // If this is the last chunk, break; // exit the loop } } return body; // Return the body text we accumulated }
此流式 API 在撰写本文时是新的,并预计会发展。特别是,计划使 ReadableStream 对象异步可迭代,以便可以与 for/await
循环一起使用(§13.4.1)。
指定请求方法和请求体
到目前为止,我们展示的每个 fetch()
示例都是进行了 HTTP(或 HTTPS)GET 请求。如果你想要使用不同的请求方法(如 POST、PUT 或 DELETE),只需使用 fetch()
的两个参数版本,传递一个带有 method
参数的 Options 对象:
fetch(url, { method: "POST" }).then(r => r.json()).then(handleResponse);
POST 和 PUT 请求通常具有包含要发送到服务器的数据的请求体。只要 method
属性未设置为 "GET"
或 "HEAD"
(不支持请求体),您可以通过设置 Options 对象的 body
属性来指定请求体:
fetch(url, { method: "POST", body: "hello world" })
当您指定请求体时,浏览器会自动向请求添加适当的 “Content-Length” 头。当请求体是字符串时,如前面的示例中,浏览器将默认将 “Content-Type” 头设置为 “text/plain;charset=UTF-8”。如果您指定了更具体类型的字符串体,如 “text/html” 或 “application/json”,则可能需要覆盖此默认值:
fetch(url, { method: "POST", headers: new Headers({"Content-Type": "application/json"}), body: JSON.stringify(requestBody) })
fetch()
选项对象的 body
属性不一定要是字符串。如果您有二进制数据在类型化数组、DataView 对象或 ArrayBuffer 中,可以将 body
属性设置为该值,并指定适当的 “Content-Type” 头。如果您有 Blob 形式的二进制数据,只需将 body
设置为 Blob。Blob 具有指定其内容类型的 type
属性,该属性的值用作 “Content-Type” 头的默认值。
使用 POST 请求时,将一组名称/值参数传递到请求体中(而不是将它们编码到 URL 的查询部分)是相当常见的。有两种方法可以实现这一点:
- 您可以使用 URLSearchParams 指定参数名称和值(我们在本节前面看到过,并在 §11.9 中有文档),然后将 URLSearchParams 对象作为
body
属性的值传递。如果这样做,请求体将设置为类似 URL 查询部分的字符串,并且“Content-Type” 头将自动设置为 “application/x-www-form-urlencoded;charset=UTF-8”。 - 如果您使用 FormData 对象指定参数名称和值,请求体将使用更详细的多部分编码,并且“Content-Type” 将设置为 “multipart/form-data; boundary=…”,其中包含一个与请求体匹配的唯一边界字符串。当您要上传的值很长,或者是每个都可能具有自己的“Content-Type”的文件或 Blob 对象时,使用 FormData 对象特别有用。可以通过将
<form>
元素传递给FormData()
构造函数来创建和初始化 FormData 对象的值。但也可以通过调用不带参数的FormData()
构造函数并使用set()
和append()
方法初始化它表示的名称/值对来创建“multipart/form-data” 请求体。
使用 fetch() 上传文件
从用户计算机上传文件到 Web 服务器是一项常见任务,可以使用 FormData 对象作为请求体。获取 File 对象的常见方法是在 Web 页面上显示一个 <input type="file">
元素,并侦听该元素上的 “change” 事件。当发生 “change” 事件时,输入元素的 files
数组应至少包含一个 File 对象。还可以通过 HTML 拖放 API 获取 File 对象。该 API 不在本书中介绍,但您可以从传递给 “drop” 事件的事件对象的 dataTransfer.files
数组中获取文件。
还要记住,File 对象是 Blob 的一种,有时上传 Blob 可能很有用。假设您编写了一个允许用户在 <canvas>
元素中创建绘图的 Web 应用程序。您可以使用以下代码将用户的绘图上传为 PNG 文件:
// The canvas.toBlob() function is callback-based. // This is a Promise-based wrapper for it. async function getCanvasBlob(canvas) { return new Promise((resolve, reject) => { canvas.toBlob(resolve); }); } // Here is how we upload a PNG file from a canvas async function uploadCanvasImage(canvas) { let pngblob = await getCanvasBlob(canvas); let formdata = new FormData(); formdata.set("canvasimage", pngblob); let response = await fetch("/upload", { method: "POST", body: formdata }); let body = await response.json(); }
跨域请求
大多数情况下,fetch()
被 Web 应用程序用于从自己的 Web 服务器请求数据。这些请求被称为同源请求,因为传递给 fetch()
的 URL 与包含发出请求的脚本的文档具有相同的源(协议加主机名加端口)。
出于安全原因,Web 浏览器通常禁止(尽管对于图像和脚本有例外)跨域网络请求。然而,跨域资源共享(CORS)使安全的跨域请求成为可能。当fetch()
与跨域 URL 一起使用时,浏览器会向请求添加一个“Origin”头部(并且不允许通过headers
属性覆盖它)以通知 Web 服务器请求来自具有不同来源的文档。如果服务器用适当的“Access-Control-Allow-Origin”头部响应请求,那么请求会继续。否则,如果服务器没有明确允许请求,那么fetch()
返回的 Promise 将被拒绝。
中止请求
有时候你可能想要中止一个已经发出的fetch()
请求,也许是因为用户点击了取消按钮或请求花费的时间太长。fetch API 允许使用 AbortController 和 AbortSignal 类来中止请求。(这些类定义了一个通用的中止机制,适用于其他 API 的使用。)
如果你想要中止一个fetch()
请求的选项,那么在开始请求之前创建一个 AbortController 对象。控制器对象的signal
属性是一个 AbortSignal 对象。将这个信号对象作为你传递给fetch()
的选项对象的signal
属性的值。这样做后,你可以调用控制器对象的abort()
方法来中止请求,这将导致与 fetch 请求相关的任何 Promise 对象拒绝并抛出异常。
这里是使用 AbortController 机制强制执行 fetch 请求超时的示例:
// This function is like fetch(), but it adds support for a timeout // property in the options object and aborts the fetch if it is not complete // within the number of milliseconds specified by that property. function fetchWithTimeout(url, options={}) { if (options.timeout) { // If the timeout property exists and is nonzero let controller = new AbortController(); // Create a controller options.signal = controller.signal; // Set the signal property // Start a timer that will send the abort signal after the specified // number of milliseconds have passed. Note that we never cancel // this timer. Calling abort() after the fetch is complete has // no effect. setTimeout(() => { controller.abort(); }, options.timeout); } // Now just perform a normal fetch return fetch(url, options); }
杂项请求选项
我们已经看到 Options 对象可以作为fetch()
的第二个参数(或Request()
构造函数的第二个参数)传递,以指定请求方法、请求头和请求体。它还支持许多其他选项,包括这些:
cache
使用这个属性来覆盖浏览器的默认缓存行为。HTTP 缓存是一个复杂的主题,超出了本书的范围,但如果你了解它的工作原理,你可以使用以下cache
的合法值:
"default"
这个值指定了默认的缓存行为。缓存中的新鲜响应直接从缓存中提供,而陈旧响应在提供之前会被重新验证。
"no-store"
这个值使浏览器忽略其缓存。当请求发出时,不会检查缓存是否匹配,并且当响应到达时也不会更新缓存。
"reload"
这个值告诉浏览器始终进行正常的网络请求,忽略缓存。然而,当响应到达时,它会被存储在缓存中。
"no-cache"
这个(误导性命名的)值告诉浏览器不要从缓存中提供新鲜值。在返回之前,新鲜或陈旧的缓存值会被重新验证。
"force-cache"
这个值告诉浏览器即使缓存中的响应是陈旧的也要提供。
redirect
这个属性控制浏览器如何处理来自服务器的重定向响应。三个合法的值是:
"follow"
这是默认值,它使浏览器自动跟随重定向。如果使用这个默认值,通过fetch()
获取的 Response 对象不应该有status
在 300 到 399 范围内。
"error"
这个值使fetch()
在服务器返回重定向响应时拒绝其返回的 Promise。
"manual"
这个值意味着你想要手动处理重定向响应,并且fetch()
返回的 Promise 可能会解析为一个带有status
在 300 到 399 范围内的 Response 对象。在这种情况下,你将不得不使用 Response 的“Location”头部来手动跟随重定向。
referrer
您可以将此属性设置为包含相对 URL 的字符串,以指定 HTTP“Referer”标头的值(历史上错误拼写为三个 R 而不是四个)。如果将此属性设置为空字符串,则“Referer”标头将从请求中省略。
15.11.2 服务器发送事件
HTTP 协议构建在其上的 Web 的一个基本特性是客户端发起请求,服务器响应这些请求。然而,一些 Web 应用程序发现,当事件发生时,让服务器向它们发送通知很有用。这对 HTTP 来说并不是自然的,但已经设计了一种技术,即客户端向服务器发出请求,然后客户端和服务器都不关闭连接。当服务器有事情要告诉客户端时,它会向连接写入数据但保持连接打开。效果就好像客户端发出网络请求,服务器以缓慢且突发的方式做出响应,活动之间有显著的暂停。这样的网络连接通常不会永远保持打开,但如果客户端检测到连接已关闭,它可以简单地发出另一个请求以重新打开连接。
允许服务器向客户端发送消息的这种技术非常有效(尽管在服务器端可能会很昂贵,因为服务器必须维护与所有客户端的活动连接)。由于这是一种有用的编程模式,客户端 JavaScript 通过 EventSource API 支持它。要创建这种长连接到 Web 服务器的请求连接,只需将 URL 传递给EventSource()
构造函数。当服务器向连接写入(格式正确的)数据时,EventSource 对象将这些数据转换为您可以监听的事件:
let ticker = new EventSource("stockprices.php"); ticker.addEventListener("bid", (event) => { displayNewBid(event.data); }
与消息事件相关联的事件对象具有一个data
属性,其中保存服务器作为此事件的有效负载发送的任何字符串。事件对象还具有一个type
属性,就像所有事件对象一样,指定事件的名称。服务器确定生成的事件的类型。如果服务器在写入的数据中省略了事件名称,则事件类型默认为“message”。
服务器发送事件协议很简单。客户端启动与服务器的连接(当创建EventSource
对象时),服务器保持此连接处于打开状态。当事件发生时,服务器向连接写入文本行。如果省略了注释,通过网络传输的事件可能如下所示:
event: bid // sets the type of the event object data: GOOG // sets the data property data: 999 // appends a newline and more data // a blank line marks the end of the event
协议的一些附加细节允许事件被赋予 ID,并允许重新连接的客户端告诉服务器它收到的最后一个事件的 ID,以便服务器可以重新发送任何错过的事件。然而,这些细节在客户端端是不可见的,并且这里不讨论。
服务器发送事件的一个明显应用是用于多用户协作,如在线聊天。聊天客户端可能使用fetch()
来向聊天室发布消息,并使用 EventSource 对象订阅聊天内容的流。示例 15-11 演示了如何使用 EventSource 轻松编写这样的聊天客户端。
示例 15-11. 使用 EventSource 的简单聊天客户端
<html> <head><title>SSE Chat</title></head> <body> <!-- The chat UI is just a single text input field --> <!-- New chat messages will be inserted before this input field --> <input id="input" style="width:100%; padding:10px; border:solid black 2px"/> <script> // Take care of some UI details let nick = prompt("Enter your nickname"); // Get user's nickname let input = document.getElementById("input"); // Find the input field input.focus(); // Set keyboard focus // Register for notification of new messages using EventSource let chat = new EventSource("/chat"); chat.addEventListener("chat", event => { // When a chat message arrives let div = document.createElement("div"); // Create a <div> div.append(event.data); // Add text from the message input.before(div); // And add div before input input.scrollIntoView(); // Ensure input elt is visible }); // Post the user's messages to the server using fetch input.addEventListener("change", ()=>{ // When the user strikes return fetch("/chat", { // Start an HTTP request to this url. method: "POST", // Make it a POST request with body body: nick + ": " + input.value // set to the user's nick and input. }) .catch(e => console.error); // Ignore response, but log any errors. input.value = ""; // Clear the input }); </script> </body> </html>
这个聊天程序的服务器端代码并不比客户端代码复杂多少。示例 15-12 是一个简单的 Node HTTP 服务器。当客户端请求根 URL“/”时,它发送了示例 15-11 中显示的聊天客户端代码。当客户端请求 URL“/chat”时,它保存响应对象并保持该连接打开。当客户端向“/chat”发出 POST 请求时,它使用请求的主体作为聊天消息,并使用“text/event-stream”格式将其写入到每个保存的响应对象中。服务器代码监听端口 8080,因此在使用 Node 运行后,将浏览器指向http://localhost:8080
以连接并开始与自己聊天。
示例 15-12. 一个服务器发送事件聊天服务器
// This is server-side JavaScript, intended to be run with NodeJS. // It implements a very simple, completely anonymous chat room. // POST new messages to /chat, or GET a text/event-stream of messages // from the same URL. Making a GET request to / returns a simple HTML file // that contains the client-side chat UI. const http = require("http"); const fs = require("fs"); const url = require("url"); // The HTML file for the chat client. Used below. const clientHTML = fs.readFileSync("chatClient.html"); // An array of ServerResponse objects that we're going to send events to let clients = []; // Create a new server, and listen on port 8080. // Connect to http://localhost:8080/ to use it. let server = new http.Server(); server.listen(8080); // When the server gets a new request, run this function server.on("request", (request, response) => { // Parse the requested URL let pathname = url.parse(request.url).pathname; // If the request was for "/", send the client-side chat UI. if (pathname === "/") { // A request for the chat UI response.writeHead(200, {"Content-Type": "text/html"}).end(clientHTML); } // Otherwise send a 404 error for any path other than "/chat" or for // any method other than "GET" and "POST" else if (pathname !== "/chat" || (request.method !== "GET" && request.method !== "POST")) { response.writeHead(404).end(); } // If the /chat request was a GET, then a client is connecting. else if (request.method === "GET") { acceptNewClient(request, response); } // Otherwise the /chat request is a POST of a new message else { broadcastNewMessage(request, response); } }); // This handles GET requests for the /chat endpoint which are generated when // the client creates a new EventSource object (or when the EventSource // reconnects automatically). function acceptNewClient(request, response) { // Remember the response object so we can send future messages to it clients.push(response); // If the client closes the connection, remove the corresponding // response object from the array of active clients request.connection.on("end", () => { clients.splice(clients.indexOf(response), 1); response.end(); }); // Set headers and send an initial chat event to just this one client response.writeHead(200, { "Content-Type": "text/event-stream", "Connection": "keep-alive", "Cache-Control": "no-cache" }); response.write("event: chat\ndata: Connected\n\n"); // Note that we intentionally do not call response.end() here. // Keeping the connection open is what makes Server-Sent Events work. } // This function is called in response to POST requests to the /chat endpoint // which clients send when users type a new message. async function broadcastNewMessage(request, response) { // First, read the body of the request to get the user's message request.setEncoding("utf8"); let body = ""; for await (let chunk of request) { body += chunk; } // Once we've read the body send an empty response and close the connection response.writeHead(200).end(); // Format the message in text/event-stream format, prefixing each // line with "data: " let message = "data: " + body.replace("\n", "\ndata: "); // Give the message data a prefix that defines it as a "chat" event // and give it a double newline suffix that marks the end of the event. let event = `event: chat\n${message}\n\n`; // Now send this event to all listening clients clients.forEach(client => client.write(event)); }
15.11.3 WebSockets
WebSocket API 是一个简单接口,用于复杂和强大的网络协议。WebSockets 允许浏览器中的 JavaScript 代码与服务器轻松交换文本和二进制消息。与服务器发送事件一样,客户端必须建立连接,但一旦连接建立,服务器可以异步向客户端发送消息。与 SSE 不同,支持二进制消息,并且消息可以在双向发送,不仅仅是从服务器到客户端。
使 WebSockets 能够连接的网络协议是 HTTP 的一种扩展。虽然 WebSocket API 让人想起低级网络套接字。但连接端点并不是通过 IP 地址和端口来标识的。相反,当你想使用 WebSocket 协议连接到一个服务时,你使用 URL 指定服务,就像你为 Web 服务所做的那样。然而,WebSocket URL 以wss://
开头,而不是https://
。 (浏览器通常限制 WebSockets 仅在通过安全的https://
连接加载的页面中工作)。
要建立 WebSocket 连接,浏览器首先建立一个 HTTP 连接,并发送一个Upgrade: websocket
头部到服务器,请求将连接从 HTTP 协议切换到 WebSocket 协议。这意味着为了在客户端 JavaScript 中使用 WebSockets,你需要与一个也支持 WebSocket 协议的 Web 服务器一起工作,并且需要编写服务器端代码来使用该协议发送和接收数据。如果你的服务器是这样设置的,那么本节将解释一切你需要了解的来处理连接的客户端端。如果你的服务器不支持 WebSocket 协议,考虑使用服务器发送事件(§15.11.2)。
创建、连接和断开 WebSocket
如果你想与支持 WebSocket 的服务器通信,创建一个 WebSocket 对象,指定wss://
URL 来标识你想使用的服务器和服务:
let socket = new WebSocket("wss://example.com/stockticker");
当你创建一个 WebSocket 时,连接过程会自动开始。但是当首次返回时,新创建的 WebSocket 不会连接。
socket 的readyState
属性指定连接所处的状态。该属性可以有以下值:
WebSocket.CONNECTING
这个 WebSocket 正在连接。
WebSocket.OPEN
这个 WebSocket 已连接并准备好通信。
WebSocket.CLOSING
这个 WebSocket 连接正在关闭。
WebSocket.CLOSED
这个 WebSocket 已经关闭;无法进行进一步的通信。当初始连接尝试失败时,也会出现这种状态。
当 WebSocket 从连接状态转换到打开状态时,它会触发一个“open”事件,你可以通过设置 WebSocket 的onopen
属性或在该对象上调用addEventListener()
来监听此事件。
如果 WebSocket 连接发生协议或其他错误,WebSocket 对象会触发一个“error”事件。你可以设置onerror
来定义一个处理程序,或者使用addEventListener()
。
当您完成一个 WebSocket 时,可以通过调用 WebSocket 对象的 close()
方法来关闭连接。当 WebSocket 变为 CLOSED 状态时,它会触发一个“close”事件,您可以设置 onclose
属性来监听此事件。
通过 WebSocket 发送消息
要向 WebSocket 连接的另一端的服务器发送消息,只需调用 WebSocket 对象的 send()
方法。send()
需要一个消息参数,可以是字符串、Blob、ArrayBuffer、类型化数组或 DataView 对象。
send()
方法缓冲要传输的指定消息,并在消息实际发送之前返回。WebSocket 对象的 bufferedAmount
属性指定已缓冲但尚未发送的字节数。(令人惊讶的是,当此值达到 0 时,WebSocket 不会触发任何事件。)
从 WebSocket 接收消息
要从服务器通过 WebSocket 接收消息,请为“message”事件注册事件处理程序,可以通过设置 WebSocket 对象的 onmessage
属性或调用 addEventListener()
来实现。与“message”事件关联的对象是一个具有包含服务器消息的 data
属性的 MessageEvent 实例。如果服务器发送 UTF-8 编码的文本,则 event.data
将是包含该文本的字符串。
如果服务器发送的消息由二进制数据而不是文本组成,则 data
属性(默认情况下)将是表示该数据的 Blob 对象。如果您希望将二进制消息接收为 ArrayBuffer 而不是 Blob,请将 WebSocket 对象的 binaryType
属性设置为字符串“arraybuffer”。
有许多 Web API 使用 MessageEvent 对象交换消息。其中一些 API 使用结构化克隆算法(参见“结构化克隆算法”)允许复杂的数据结构作为消息负载。WebSocket 不是这些 API 之一:通过 WebSocket 交换的消息要么是单个 Unicode 字符串,要么是单个字节字符串(表示为 Blob 或 ArrayBuffer)。
协议协商
WebSocket 协议允许交换文本和二进制消息,但对这些消息的结构或含义一无所知。使用 WebSocket 的应用程序必须在这种简单的消息交换机制之上构建自己的通信协议。使用 wss://
URL 有助于此:每个 URL 通常都有自己的消息交换规则。如果您编写代码连接到 wss://example.com/stockticker
,那么您可能知道您将收到有关股票价格的消息。
然而,协议往往会发展。如果一个假设的股票行情协议被更新,你可以定义一个新的 URL 并连接到更新的服务,如 wss://example.com/stockticker/v2
。基于 URL 的版本控制并不总是足够的。对于随时间演变的复杂协议,您可能会遇到支持多个协议版本的部署服务器和支持不同协议版本集的部署客户端。
为了预料到这种情况,WebSocket 协议和 API 包括一个应用级协议协商功能。当你调用 WebSocket()
构造函数时,wss://
URL 是第一个参数,但你也可以将字符串数组作为第二个参数传递。如果这样做,你正在指定一个你知道如何处理的应用程序协议列表,并要求服务器选择一个。在连接过程中,服务器将选择一个协议(或者如果不支持客户端的任何选项,则会失败并显示错误)。一旦建立连接,WebSocket 对象的 protocol
属性指定服务器选择的协议版本。
存储
Web 应用程序可以使用浏览器 API 在用户计算机上本地存储数据。这种客户端存储用于给 Web 浏览器提供内存。Web 应用程序可以存储用户偏好设置,例如,甚至可以存储它们的完整状态,以便它们可以在上次访问结束时恢复到离开的地方。客户端存储按来源分隔,因此来自一个站点的页面无法读取另一个站点的页面存储的数据。但来自同一站点的两个页面可以共享存储并将其用作通信机制。例如,一个页面上的表单中输入的数据可以在另一个页面上的表格中显示。Web 应用程序可以选择存储数据的生命周期:数据可以临时存储,以便仅在窗口关闭或浏览器退出时保留,或者可以保存在用户计算机上并永久存储,以便在几个月或几年后可用。
客户端存储有许多形式:
Web 存储
Web 存储 API 由localStorage
和sessionStorage
对象组成,它们本质上是将字符串键映射到字符串值的持久对象。Web 存储非常易于使用,适用于存储大量(但不是巨大量)的数据。
Cookies
Cookies 是一种旧的客户端存储机制,设计用于服务器端脚本使用。一个笨拙的 JavaScript API 使得 cookies 在客户端端可脚本化,但它们很难使用,只适用于存储少量文本数据。此外,任何存储为 cookie 的数据都会随着每个 HTTP 请求传输到服务器,即使数据只对客户端感兴趣。
IndexedDB
IndexedDB 是支持索引的对象数据库的异步 API。
15.12.1 localStorage 和 sessionStorage
Window 对象的localStorage
和sessionStorage
属性指向 Storage 对象。Storage 对象的行为类似于常规的 JavaScript 对象,只是:
- Storage 对象的属性值必须是字符串。
- 存储在 Storage 对象中的属性是持久的。如果你设置了 localStorage 对象的一个属性,然后用户重新加载页面,你保存在该属性中的值仍然对你的程序可用。
你可以像这样使用 localStorage 对象,例如:
let name = localStorage.username; // Query a stored value. if (!name) { name = prompt("What is your name?"); // Ask the user a question. localStorage.username = name; // Store the user's response. }
你可以使用delete
运算符从localStorage
和sessionStorage
中删除属性,并且可以使用for/in
循环或Object.keys()
来枚举 Storage 对象的属性。如果你想要移除存储对象的所有属性,调用clear()
方法:
localStorage.clear();
Storage 对象还定义了getItem()
、setItem()
和deleteItem()
方法,如果你想要的话,可以使用这些方法代替直接属性访问和delete
运算符。
请记住,Storage 对象的属性只能存储字符串。如果你想要存储和检索其他类型的数据,你必须自己进行编码和解码。
例如:
// If you store a number, it is automatically converted to a string. // Don't forget to parse it when retrieving it from storage. localStorage.x = 10; let x = parseInt(localStorage.x); // Convert a Date to a string when setting, and parse it when getting localStorage.lastRead = (new Date()).toUTCString(); let lastRead = new Date(Date.parse(localStorage.lastRead)); // JSON makes a convenient encoding for any primitive or data structure localStorage.data = JSON.stringify(data); // Encode and store let data = JSON.parse(localStorage.data); // Retrieve and decode.
存储的生命周期和范围
localStorage
和sessionStorage
之间的区别涉及存储的生命周期和范围。通过localStorage
存储的数据是永久的:它不会过期,并且会一直存储在用户设备上,直到 Web 应用将其删除或用户要求浏览器(通过某些特定于浏览器的 UI)将其删除。
localStorage
的范围是文档来源。正如在“同源策略”中解释的那样,文档的来源由其协议、主机名和端口定义。具有相同来源的所有文档共享相同的localStorage
数据(无论实际访问localStorage
的脚本的来源如何)。它们可以读取彼此的数据,并且可以覆盖彼此的数据。但具有不同来源的文档永远无法读取或覆盖彼此的数据(即使它们都从同一个第三方服务器运行脚本)。
请注意,localStorage
也受浏览器实现的范围限制。如果您使用 Firefox 访问网站,然后再次使用 Chrome 访问(例如),则在第一次访问期间存储的任何数据在第二次访问期间将无法访问。
通过sessionStorage
存储的数据的生存期与存储它的脚本所在的顶级窗口或浏览器标签页的生存期相同。当窗口或标签页永久关闭时,通过sessionStorage
存储的任何数据都将被删除。(但是,请注意,现代浏览器具有重新打开最近关闭的标签页并恢复上次浏览会话的功能,因此这些标签页及其关联的sessionStorage
的生存期可能比看起来更长。)
与localStorage
类似,sessionStorage
也受文档源的范围限制,因此具有不同源的文档永远不会共享sessionStorage
。但是,sessionStorage
还受每个窗口的范围限制。如果用户有两个显示来自相同源的浏览器标签页,这两个标签页具有单独的sessionStorage
数据:运行在一个标签页中的脚本无法读取或覆盖另一个标签页中的脚本写入的数据,即使这两个标签页正在访问完全相同的页面并运行完全相同的脚本。
存储事件
每当存储在localStorage
中的数据发生变化时,浏览器会在任何其他可见该数据的窗口对象上触发“storage”事件(但不会在进行更改的窗口上触发)。如果浏览器有两个打开到具有相同源的页面的标签页,并且其中一个页面在localStorage
中存储一个值,另一个标签页将接收到“storage”事件。
通过设置window.onstorage
或调用window.addEventListener()
并设置事件类型为“storage”来为“storage”事件注册处理程序。
与“storage”事件关联的事件对象具有一些重要属性:
key
设置或删除的项目的名称或键。如果调用了clear()
方法,则此属性将为null
。
newValue
如果存在新项目的新值,则保存该值。如果调用了removeItem()
,则此属性将不存在。
oldValue
保存更改或删除的现有项目的旧值。如果调用了removeItem()
,则此属性将不存在。
storageArea
发生更改的 Storage 对象。这通常是localStorage
对象。
url
使此存储更改的文档的 URL(作为字符串)。
请注意,localStorage
和“storage”事件可以作为浏览器向当前访问同一网站的所有窗口发送消息的广播机制。例如,如果用户要求网站停止执行动画,网站可能会将该偏好存储在localStorage
中,以便在将来的访问中遵守该偏好。通过存储偏好,它生成一个事件,允许显示相同网站的其他窗口也遵守请求。
另一个例子是,想象一个基于 Web 的图像编辑应用程序,允许用户在单独的窗口中显示工具面板。当用户选择工具时,应用程序使用localStorage
保存当前状态,并向其他窗口生成通知,表示已选择新工具。
15.12.2 Cookies
Cookie是由 Web 浏览器存储的一小部分命名数据,并与特定网页或网站相关联。 Cookie 是为服务器端编程设计的,在最低级别上,它们是作为 HTTP 协议的扩展实现的。 Cookie 数据会在 Web 浏览器和 Web 服务器之间自动传输,因此服务器端脚本可以读取和写入存储在客户端上的 Cookie 值。 本节演示了客户端脚本如何使用 Document 对象的cookie
属性来操作 Cookie。
操纵 cookie 的 API 是一个古老而神秘的 API。没有涉及方法:通过读取和写入 Document 对象的cookie
属性,使用特殊格式的字符串来查询、设置和删除 cookie。每个 cookie 的生存期和范围可以通过 cookie 属性单独指定。这些属性也是通过设置在同一cookie
属性上的特殊格式的字符串来指定的。
接下来的小节将解释如何查询和设置 cookie 的值和属性。
读取 cookie
当你读取document.cookie
属性时,它会返回一个包含当前文档适用的所有 cookie 的字符串。该字符串是一个由分号和空格分隔的名称/值对列表。cookie 值只是值本身,不包括与该 cookie 相关的任何属性。(我们将在下面讨论属性。)为了利用document.cookie
属性,你通常需要调用split()
方法将其分割成单独的名称/值对。
一旦从cookie
属性中提取了 cookie 的值,你必须根据 cookie 创建者使用的格式或编码来解释该值。例如,你可以将 cookie 值传递给decodeURIComponent()
,然后再传递给JSON.parse()
。
接下来的代码定义了一个getCookie()
函数,该函数解析document.cookie
属性并返回一个对象,该对象的属性指定了文档的 cookie 的名称和值:
// Return the document's cookies as a Map object. // Assume that cookie values are encoded with encodeURIComponent(). function getCookies() { let cookies = new Map(); // The object we will return let all = document.cookie; // Get all cookies in one big string let list = all.split("; "); // Split into individual name/value pairs for(let cookie of list) { // For each cookie in that list if (!cookie.includes("=")) continue; // Skip if there is no = sign let p = cookie.indexOf("="); // Find the first = sign let name = cookie.substring(0, p); // Get cookie name let value = cookie.substring(p+1); // Get cookie value value = decodeURIComponent(value); // Decode the value cookies.set(name, value); // Remember cookie name and value } return cookies; }
Cookie 属性:生存期和范围
除了名称和值之外,每个 cookie 还有可选属性来控制其生存期和范围。在我们描述如何使用 JavaScript 设置 cookie 之前,我们需要解释 cookie 属性。
默认情况下,cookie 是短暂的;它们存储的值在 Web 浏览器会话期间持续,但当用户退出浏览器时会丢失。如果你希望 cookie 在单个浏览会话之外持续存在,你必须告诉浏览器你希望它保留 cookie 的时间(以秒为单位),通过指定max-age
属性。如果指定了生存期,浏览器将把 cookie 存储在一个文件中,并在它们过期时才删除。
Cookie 的可见性受文档来源的范围限制,就像localStorage
和sessionStorage
一样,但还受文档路径的限制。这个范围可以通过 cookie 属性path
和domain
进行配置。默认情况下,一个 cookie 与创建它的网页以及该目录或该目录的任何子目录中的任何其他网页相关联并可访问。例如,如果网页example.com/catalog/index.html创建了一个 cookie,那么该 cookie 也对example.com/catalog/order.html和example.com/catalog/widgets/index.html可见,但对example.com/about.html不可见。
默认的可见性行为通常是你想要的。但有时,你可能希望在整个网站中使用 cookie 值,无论哪个页面创建了 cookie。例如,如果用户在一个页面的表单中输入了他们的邮寄地址,你可能希望保存该地址以便在他们下次返回该页面时作为默认地址,并且在另一个页面的一个完全不相关的表单中也作为默认地址。为了允许这种用法,你需要为 cookie 指定一个path
。然后,任何以你指定的路径前缀开头的同一 Web 服务器的网页都可以共享该 cookie。例如,如果由example.com/catalog/widgets/index.html设置的 cookie 的路径设置为“/catalog”,那么该 cookie 也对example.com/catalog/order.html可见。或者,如果路径设置为“/”,则该 cookie 对example.com域中的任何页面都可见,使得该 cookie 的范围类似于localStorage
。
默认情况下,cookie 由文档来源限定。然而,大型网站可能希望 cookie 在子域之间共享。例如,order.example.com服务器可能需要读取从catalog.example.com设置的 cookie 值。这就是domain
属性发挥作用的地方。如果由catalog.example.com页面创建的 cookie 将其path
属性设置为“/”并将其domain
属性设置为“.example.com”,那么该 cookie 对catalog.example.com、orders.example.com和example.com域中的所有网页都可用。请注意,您不能将 cookie 的域设置为服务器的父域之外的域。
最后一个 cookie 属性是一个名为secure
的布尔属性,指定 cookie 值在网络上传输的方式。默认情况下,cookie 是不安全的,这意味着它们通过普通的不安全 HTTP 连接传输。但是,如果标记了安全的 cookie,则只有在浏览器和服务器通过 HTTPS 或其他安全协议连接时才会传输。
存储 cookie
要将瞬态 cookie 值与当前文档关联起来,只需将cookie
属性设置为name=value
字符串。例如:
document.cookie = `version=${encodeURIComponent(document.lastModified)}`;
下次读取cookie
属性时,您存储的名称/值对将包含在文档的 cookie 列表中。Cookie 值不能包含分号、逗号或空格。因此,您可能希望在将其存储在 cookie 中之前使用核心 JavaScript 全局函数encodeURIComponent()
对值进行编码。如果这样做,读取 cookie 值时必须使用相应的decodeURIComponent()
函数。
使用简单的名称/值对编写的 cookie 在当前的 Web 浏览会话中持续存在,但当用户退出浏览器时会丢失。要创建一个可以跨浏览器会话持续存在的 cookie,请使用max-age
属性指定其生存期(以秒为单位)。您可以通过将cookie
属性设置为形式为name=value; max-age=seconds
的字符串来实现。以下函数设置了一个带有可选max-age
属性的 cookie:
// Store the name/value pair as a cookie, encoding the value with // encodeURIComponent() in order to escape semicolons, commas, and spaces. // If daysToLive is a number, set the max-age attribute so that the cookie // expires after the specified number of days. Pass 0 to delete a cookie. function setCookie(name, value, daysToLive=null) { let cookie = `${name}=${encodeURIComponent(value)}`; if (daysToLive !== null) { cookie += `; max-age=${daysToLive*60*60*24}`; } document.cookie = cookie; }
类似地,您可以通过在document.cookie
属性上附加形式为;path=value
或;domain=value
的字符串来设置 cookie 的path
和domain
属性。要设置secure
属性,只需附加;secure
。
要更改 cookie 的值,只需再次使用相同的名称、路径和域以及新值设置其值。通过指定新的max-age
属性,您可以在更改其值时更改 cookie 的生存期。
要删除 cookie,只需再次使用相同的名称、路径和域设置它,指定任意(或空)值,并将max-age
属性设置为 0。
15.12.3 IndexedDB
传统上,Web 应用程序架构在客户端使用 HTML、CSS 和 JavaScript,在服务器上使用数据库。因此,您可能会惊讶地发现,Web 平台包括一个简单的对象数据库,具有 JavaScript API,用于在用户计算机上持久存储 JavaScript 对象并根据需要检索它们。
IndexedDB 是一个对象数据库,而不是关系数据库,比支持 SQL 查询的数据库简单得多。然而,它比localStorage
提供的键/值存储更强大、高效和健壮。与localStorage
一样,IndexedDB 数据库的作用域限定在包含文档的来源:具有相同来源的两个网页可以访问彼此的数据,但来自不同来源的网页则不能。
每个源可以拥有任意数量的 IndexedDB 数据库。每个数据库都有一个在源内必须是唯一的名称。在 IndexedDB API 中,数据库只是一组命名的对象存储。顾名思义,对象存储存储对象。对象使用结构化克隆算法(参见“结构化克隆算法”)序列化到对象存储中,这意味着您存储的对象可以具有值为 Maps、Sets 或类型化数组的属性。每个对象必须有一个键,通过该键可以对其进行排序并从存储中检索。键必须是唯一的——同一存储中的两个对象不能具有相同的键——并且它们必须具有自然排序,以便对其进行排序。JavaScript 字符串、数字和日期对象是有效的键。IndexedDB 数据库可以自动生成每个插入到数据库中的对象的唯一键。不过,通常情况下,您插入到对象存储中的对象已经具有适合用作键的属性。在这种情况下,您在创建对象存储时指定该属性的“键路径”。在概念上,键路径是一个值,告诉数据库如何从对象中提取对象的键。
除了通过其主键值从对象存储中检索对象之外,您可能希望能够根据对象中其他属性的值进行搜索。为了能够做到这一点,您可以在对象存储上定义任意数量的索引。(对对象存储进行索引的能力解释了“IndexedDB”这个名称。)每个索引为存储的对象定义了一个次要键。这些索引通常不是唯一的,多个对象可能匹配单个键值。
IndexedDB 提供原子性保证:对数据库的查询和更新被分组在一个事务中,以便它们一起成功或一起失败,并且永远不会使数据库处于未定义的、部分更新的状态。IndexedDB 中的事务比许多数据库 API 中的事务更简单;我们稍后会再次提到它们。
从概念上讲,IndexedDB API 相当简单。要查询或更新数据库,首先打开您想要的数据库(通过名称指定)。接下来,创建一个事务对象,并使用该对象按名称查找数据库中所需的对象存储。最后,通过调用对象存储的get()
方法查找对象,或通过调用put()
(或通过调用add()
,如果要避免覆盖现有对象)存储新对象。
如果要查找一系列键的对象,可以创建一个指定范围的 IDBRange 对象,并将其传递给对象存储的getAll()
或openCursor()
方法。
如果要使用次要键进行查询,可以查找对象存储的命名索引,然后调用索引对象的get()
、getAll()
或openCursor()
方法,传递单个键或 IDBRange 对象。
然而,IndexedDB API 的概念简单性受到了其异步性的影响(以便 Web 应用程序可以在不阻塞浏览器主 UI 线程的情况下使用它)。IndexedDB 在 Promises 广泛支持之前就已定义,因此 API 是基于事件而不是基于 Promise 的,这意味着它不支持async
和await
。
创建事务、查找对象存储和索引都是同步操作。但打开数据库、更新对象存储和查询存储或索引都是异步操作。这些异步方法都会立即返回一个请求对象。当请求成功或失败时,浏览器会在请求对象上触发成功或错误事件,并且你可以使用 onsuccess
和 onerror
属性定义处理程序。在 onsuccess
处理程序中,操作的结果可以作为请求对象的 result
属性获得。另一个有用的事件是当事务成功完成时在事务对象上分派的“complete”事件。
这个异步 API 的一个便利特性是它简化了事务管理。IndexedDB API 强制你创建一个事务对象,以便获取可以执行查询和更新的对象存储。在同步 API 中,你期望通过调用 commit()
方法显式标记事务的结束。但是在 IndexedDB 中,当所有 onsuccess
事件处理程序运行并且没有更多引用该事务的待处理异步请求时,事务会自动提交(如果你没有显式中止它们)。
IndexedDB API 中还有一个重要的事件。当你首次打开数据库,或者增加现有数据库的版本号时,IndexedDB 会在由 indexedDB.open()
调用返回的请求对象上触发一个“upgradeneeded”事件。对于“upgradeneeded”事件的事件处理程序的工作是定义或更新新数据库(或现有数据库的新版本)的模式。对于 IndexedDB 数据库,这意味着创建对象存储并在这些对象存储上定义索引。实际上,IndexedDB API 允许你创建对象存储或索引的唯一时机就是响应“upgradeneeded”事件。
有了对 IndexedDB 的高级概述,你现在应该能够理解 示例 15-13。该示例使用 IndexedDB 创建和查询一个将美国邮政编码(邮政编码)映射到美国城市的数据库。它展示了 IndexedDB 的许多基本特性,但并非全部。示例 15-13 非常长,但有很好的注释。
示例 15-13。一个美国邮政编码的 IndexedDB 数据库
// This utility function asynchronously obtains the database object (creating // and initializing the DB if necessary) and passes it to the callback. function withDB(callback) { let request = indexedDB.open("zipcodes", 1); // Request v1 of the database request.onerror = console.error; // Log any errors request.onsuccess = () => { // Or call this when done let db = request.result; // The result of the request is the database callback(db); // Invoke the callback with the database }; // If version 1 of the database does not yet exist, then this event // handler will be triggered. This is used to create and initialize // object stores and indexes when the DB is first created or to modify // them when we switch from one version of the DB schema to another. request.onupgradeneeded = () => { initdb(request.result, callback); }; } // withDB() calls this function if the database has not been initialized yet. // We set up the database and populate it with data, then pass the database to // the callback function. // // Our zip code database includes one object store that holds objects like this: // // { // zipcode: "02134", // city: "Allston", // state: "MA", // } // // We use the "zipcode" property as the database key and create an index for // the city name. function initdb(db, callback) { // Create the object store, specifying a name for the store and // an options object that includes the "key path" specifying the // property name of the key field for this store. let store = db.createObjectStore("zipcodes", // store name { keyPath: "zipcode" }); // Now index the object store by city name as well as by zip code. // With this method the key path string is passed directly as a // required argument rather than as part of an options object. store.createIndex("cities", "city"); // Now get the data we are going to initialize the database with. // The zipcodes.json data file was generated from CC-licensed data from // www.geonames.org: https://download.geonames.org/export/zip/US.zip fetch("zipcodes.json") // Make an HTTP GET request .then(response => response.json()) // Parse the body as JSON .then(zipcodes => { // Get 40K zip code records // In order to insert zip code data into the database we need a // transaction object. To create our transaction object, we need // to specify which object stores we'll be using (we only have // one) and we need to tell it that we'll be doing writes to the // database, not just reads: let transaction = db.transaction(["zipcodes"], "readwrite"); transaction.onerror = console.error; // Get our object store from the transaction let store = transaction.objectStore("zipcodes"); // The best part about the IndexedDB API is that object stores // are *really* simple. Here's how we add (or update) our records: for(let record of zipcodes) { store.put(record); } // When the transaction completes successfully, the database // is initialized and ready for use, so we can call the // callback function that was originally passed to withDB() transaction.oncomplete = () => { callback(db); }; }); } // Given a zip code, use the IndexedDB API to asynchronously look up the city // with that zip code, and pass it to the specified callback, or pass null if // no city is found. function lookupCity(zip, callback) { withDB(db => { // Create a read-only transaction object for this query. The // argument is an array of object stores we will need to use. let transaction = db.transaction(["zipcodes"]); // Get the object store from the transaction let zipcodes = transaction.objectStore("zipcodes"); // Now request the object that matches the specified zipcode key. // The lines above were synchronous, but this one is async. let request = zipcodes.get(zip); request.onerror = console.error; // Log errors request.onsuccess = () => { // Or call this function on success let record = request.result; // This is the query result if (record) { // If we found a match, pass it to the callback callback(`${record.city}, ${record.state}`); } else { // Otherwise, tell the callback that we failed callback(null); } }; }); } // Given the name of a city, use the IndexedDB API to asynchronously // look up all zip code records for all cities (in any state) that have // that (case-sensitive) name. function lookupZipcodes(city, callback) { withDB(db => { // As above, we create a transaction and get the object store let transaction = db.transaction(["zipcodes"]); let store = transaction.objectStore("zipcodes"); // This time we also get the city index of the object store let index = store.index("cities"); // Ask for all matching records in the index with the specified // city name, and when we get them we pass them to the callback. // If we expected more results, we might use openCursor() instead. let request = index.getAll(city); request.onerror = console.error; request.onsuccess = () => { callback(request.result); }; }); }
15.13 工作线程和消息传递
JavaScript 的一个基本特性是它是单线程的:浏览器永远不会同时运行两个事件处理程序,也永远不会在事件处理程序运行时触发计时器,例如。对应用程序状态或文档的并发更新根本不可能,客户端程序员不需要考虑或甚至理解并发编程。一个推论是客户端 JavaScript 函数不能运行太长时间;否则,它们将占用事件循环,并且 Web 浏览器将对用户输入无响应。这就是例如 fetch()
是异步函数的原因。
Web 浏览器非常谨慎地通过 Worker 类放宽了单线程要求:这个类的实例代表与主线程和事件循环并发运行的线程。工作者生存在一个独立的执行环境中,具有完全独立的全局对象,没有访问 Window 或 Document 对象的权限。工作者只能通过异步消息传递与主线程通信。这意味着 DOM 的并发修改仍然是不可能的,但也意味着你可以编写不会阻塞事件循环并使浏览器挂起的长时间运行函数。创建一个新的工作者不像打开一个新的浏览器窗口那样消耗资源,但工作者也不是轻量级的“纤程”,创建新的工作者来执行琐碎的操作是没有意义的。复杂的 Web 应用程序可能会发现创建数十个工作者很有用,但是一个具有数百或数千个工作者的应用程序可能并不实用。
当你的应用程序需要执行计算密集型任务时,工作者非常有用,比如图像处理。使用工作者将这样的任务移出主线程,以免浏览器变得无响应。而且工作者还提供了将工作分配给多个线程的可能性。但是当你需要执行频繁的中等强度计算时,工作者也非常有用。例如,假设你正在实现一个简单的浏览器内代码编辑器,并且想要包含语法高亮显示。为了正确高亮显示,你需要在每次按键时解析代码。但如果你在主线程上这样做,很可能解析代码会阻止响应用户按键的事件处理程序及时运行,用户的输入体验将会变得迟缓。
与任何线程 API 一样,Worker API 有两个部分。第一个是 Worker 对象:这是一个工作者从外部看起来的样子,对于创建它的线程来说。第二个是 WorkerGlobalScope:这是一个新工作者的全局对象,对于工作者线程来说,它是内部的样子。
以下部分涵盖了 Worker 和 WorkerGlobalScope,并解释了允许工作者与主线程和彼此通信的消息传递 API。相同的通信 API 也用于在文档和包含在文档中的<iframe>
元素之间交换消息,这也在以下部分中进行了介绍。
15.13.1 工作者对象
要创建一个新的工作者,调用Worker()
构造函数,传递一个指定要运行的 JavaScript 代码的 URL:
let dataCruncher = new Worker("utils/cruncher.js");
如果你指定一个相对 URL,它将相对于包含调用Worker()
构造函数的脚本的文档的 URL 进行解析。如果你指定一个绝对 URL,它必须与包含文档的原点(相同的协议、主机和端口)相同。
一旦你有了一个 Worker 对象,你可以使用postMessage()
向其发送数据。你传递给postMessage()
的值将使用结构化克隆算法进行复制(参见“结构化克隆算法”),并且生成的副本将通过消息事件传递给工作者:
dataCruncher.postMessage("/api/data/to/crunch");
在这里我们只是传递了一个单个字符串消息,但你也可以使用对象、数组、类型化数组、Map、Set 等等。你可以通过在 Worker 对象上监听“message”事件来接收来自工作者的消息:
dataCruncher.onmessage = function(e) { let stats = e.data; // The message is the data property of the event console.log(`Average: ${stats.mean}`); }
像所有事件目标一样,Worker 对象定义了标准的addEventListener()
和removeEventListener()
方法,你可以在这些方法中使用onmessage
。
除了postMessage()
之外,Worker 对象只有另一个方法,terminate()
,它可以强制停止一个工作者线程的运行。
15.13.2 工作者中的全局对象
当使用 Worker()
构造函数创建一个新的 worker 时,您需要指定一个 JavaScript 代码文件的 URL。该代码在一个新的、干净的 JavaScript 执行环境中执行,与创建 worker 的脚本隔离。该新执行环境的全局对象是一个 WorkerGlobalScope 对象。WorkerGlobalScope 不仅仅是核心 JavaScript 全局对象,但也不是完整的客户端 Window 对象。
WorkerGlobalScope 对象具有一个 postMessage()
方法和一个 onmessage
事件处理程序属性,与 Worker 对象的类似,但工作方向相反:在 worker 内部调用 postMessage()
会在 worker 外部生成一个消息事件,而从 worker 外部发送的消息会被转换为事件并传递给 onmessage
处理程序。因为 WorkerGlobalScope 是 worker 的全局对象,对于 worker 代码来说,postMessage()
和 onmessage
看起来像是全局函数和全局变量。
如果将对象作为 Worker()
构造函数的第二个参数传递,并且该对象具有一个 name
属性,则该属性的值将成为 worker 全局对象中 name
属性的值。worker 可能会在使用 console.warn()
或 console.error()
打印的任何消息中包含此名称。
close()
函数允许 worker 终止自身,其效果类似于 Worker 对象的 terminate()
方法。
由于 WorkerGlobalScope 是 worker 的全局对象,它具有核心 JavaScript 全局对象的所有属性,例如 JSON 对象、isNaN()
函数和 Date()
构造函数。但是,WorkerGlobalScope 还具有客户端 Window 对象的以下属性:
self
是全局对象本身的引用。WorkerGlobalScope 不是 Window 对象,也不定义window
属性。- 定时器方法
setTimeout()
、clearTimeout()
、setInterval()
和clearInterval()
。 - 一个描述传递给
Worker()
构造函数的 URL 的location
属性。该属性引用一个 Location 对象,就像 Window 的location
属性一样。然而,在 worker 中,这些属性是只读的。 - 一个
navigator
属性,指向一个具有类似于窗口 Navigator 对象的属性的对象。worker 的 Navigator 对象具有appName
、appVersion
、platform
、userAgent
和onLine
属性。 - 常见的事件目标方法
addEventListener()
和removeEventListener()
。
最后,WorkerGlobalScope 对象包括重要的客户端 JavaScript API,包括 Console 对象、fetch()
函数和 IndexedDB API。WorkerGlobalScope 还包括 Worker()
构造函数,这意味着 worker 线程可以创建自己的 workers。
15.13.3 将代码导入到 Worker 中
在 JavaScript 没有模块系统之前,Web 浏览器中定义了 workers,因此 workers 具有一个独特的包含额外代码的系统。WorkerGlobalScope 将 importScripts()
定义为所有 workers 都可以访问的全局函数:
// Before we start working, load the classes and utilities we'll need importScripts("utils/Histogram.js", "utils/BitSet.js");
importScripts()
接受一个或多个 URL 参数,每个参数应该指向一个 JavaScript 代码文件。相对 URL 是相对于传递给 Worker()
构造函数的 URL 解析的(而不是相对于包含文档)。importScripts()
同步加载并依次执行这些文件,按照指定的顺序。如果加载脚本导致网络错误,或者执行引发任何错误,那么后续的脚本都不会加载或执行。使用 importScripts()
加载的脚本本身可以调用 importScripts()
来加载其依赖的文件。但请注意,importScripts()
不会尝试跟踪已加载的脚本,并且不会阻止依赖循环。
importScripts()
是一个同步函数:直到所有脚本都加载并执行完毕后才会返回。一旦importScripts()
返回,你就可以立即开始使用加载的脚本:不需要回调、事件处理程序、then()
方法或await
。一旦你内化了客户端 JavaScript 的异步特性,再回到简单的同步编程会感到奇怪。但这就是线程的美妙之处:你可以在工作线程中使用阻塞函数调用,而不会阻塞主线程中的事件循环,也不会阻塞其他工作线程同时执行的计算。
15.13.4 工作线程执行模型
工作线程从上到下同步运行其代码(以及所有导入的脚本或模块),然后进入一个异步阶段,响应事件和定时器。如果工作线程注册了“message”事件处理程序,只要仍有可能到达消息事件,它就永远不会退出。但如果工作线程不监听消息,它将一直运行,直到没有进一步的待处理任务(如fetch()
承诺和定时器)并且所有与任务相关的回调都已调用。一旦所有注册的回调都被调用,工作线程就无法开始新任务,因此线程可以安全退出,它会自动执行。工作线程还可以通过调用全局的close()
函数显式关闭自身。请注意,工作线程对象上没有指定工作线程是否仍在运行的属性或方法,因此工作线程不应在没有与父线程协调的情况下关闭自身。
工作线程中的错误
如果工作线程中发生异常并且没有被任何catch
子句捕获,那么将在工作线程的全局对象上触发一个“error”事件。如果处理了此事件并且处理程序调用了事件对象的preventDefault()
方法,则错误传播结束。否则,“error”事件将在 Worker 对象上触发。如果在那里调用了preventDefault()
,则传播结束。否则,将在开发者控制台中打印错误消息,并调用 Window 对象的 onerror 处理程序(§15.1.7)。
// Handle uncaught worker errors with a handler inside the worker. self.onerror = function(e) { console.log(`Error in worker at ${e.filename}:${e.lineno}: ${e.message}`); e.preventDefault(); }; // Or, handle uncaught worker errors with a handler outside the worker. worker.onerror = function(e) { console.log(`Error in worker at ${e.filename}:${e.lineno}: ${e.message}`); e.preventDefault(); };
与窗口类似,工作线程可以注册一个处理程序,在 Promise 被拒绝且没有.catch()
函数处理时调用。在工作线程中,你可以通过定义一个self.onunhandledrejection
函数或使用addEventListener()
注册一个全局处理程序来检测这种情况。传递给此处理程序的事件对象将具有一个promise
属性,其值是被拒绝的 Promise 对象,以及一个reason
属性,其值是将传递给.catch()
函数的值。
15.13.5 postMessage()、MessagePorts 和 MessageChannels
Worker 对象的postMessage()
方法和工作线程内部定义的全局postMesage()
函数都通过调用一对自动与工作线程一起创建的 MessagePort 对象的postMessage()
方法来工作。客户端 JavaScript 无法直接访问这些自动创建的 MessagePort 对象,但可以使用MessageChannel()
构造函数创建新的连接端口对:
let channel = new MessageChannel; // Create a new channel. let myPort = channel.port1; // It has two ports let yourPort = channel.port2; // connected to each other. myPort.postMessage("Can you hear me?"); // A message posted to one will yourPort.onmessage = (e) => console.log(e.data); // be received on the other.
MessageChannel 是一个具有port1
和port2
属性的对象,这些属性指向一对连接的 MessagePort 对象。MessagePort 是一个具有postMessage()
方法和onmessage
事件处理程序属性的对象。当在连接的一对端口上调用postMessage()
时,另一端口将触发“message”事件。您可以通过设置onmessage
属性或使用addEventListener()
注册“message”事件的监听器来接收这些“message”事件。
发送到端口的消息将排队,直到定义了 onmessage
属性或直到在端口上调用了 start()
方法。这可以防止一个通道的一端发送的消息被另一端错过。如果您在 MessagePort 上使用 addEventListener()
,不要忘记调用 start()
,否则您可能永远看不到消息被传递。
到目前为止,我们看到的所有 postMessage()
调用都接受一个单一的消息参数。但该方法还接受一个可选的第二个参数。这个第二个参数是一个要传输到通道另一端的项目数组,而不是在通道上发送一个副本。可以传输而不是复制的值包括 MessagePorts 和 ArrayBuffers。 (一些浏览器还实现了其他可传输类型,如 ImageBitmap 和 OffscreenCanvas。然而,并非所有浏览器都支持,本书不涵盖这些内容。)如果 postMessage()
的第一个参数包含一个 MessagePort(在消息对象的任何地方嵌套),那么该 MessagePort 也必须出现在第二个参数中。如果这样做,那么 MessagePort 将在通道的另一端变为可用,并且在您的端口上立即变为不可用。假设您创建了一个 worker 并希望有两个用于与其通信的通道:一个用于普通数据交换,一个用于高优先级消息。在主线程中,您可以创建一个 MessageChannel,然后在 worker 上调用 postMessage()
以将其中一个 MessagePorts 传递给它:
let worker = new Worker("worker.js"); let urgentChannel = new MessageChannel(); let urgentPort = urgentChannel.port1; worker.postMessage({ command: "setUrgentPort", value: urgentChannel.port2 }, [ urgentChannel.port2 ]); // Now we can receive urgent messages from the worker like this urgentPort.addEventListener("message", handleUrgentMessage); urgentPort.start(); // Start receiving messages // And send urgent messages like this urgentPort.postMessage("test");
如果您创建了两个 worker 并希望它们直接进行通信而不需要主线程上的代码来中继消息,则 MessageChannels 也非常有用。
postMessage()
的第二个参数的另一个用途是在 worker 之间传递 ArrayBuffers 而无需复制它们。对于像保存图像数据的大型 ArrayBuffers 这样的情况,这是一个重要的性能增强。当一个 ArrayBuffer 被传输到一个 MessagePort 上时,该 ArrayBuffer 在原始线程中变得不可用,因此不可能同时访问其内容。如果 postMessage()
的第一个参数包含一个 ArrayBuffer,或者包含一个具有 ArrayBuffer 的任何值(例如一个 typed array),那么该缓冲区可能会出现在第二个 postMessage()
参数中作为一个数组元素。如果出现了,那么它将被传输而不是复制。如果没有出现,那么该 ArrayBuffer 将被复制而不是传输。示例 15-14 将演示如何使用这种传输技术与 ArrayBuffers。
15.13.6 使用 postMessage() 进行跨源消息传递
在客户端 JavaScript 中,postMessage()
方法还有另一个用例。它涉及窗口而不是 worker,但两种情况之间有足够的相似之处,我们将在这里描述 Window 对象的 postMessage()
方法。
当文档包含一个 <iframe>
元素时,该元素充当一个嵌入但独立的窗口。代表 <iframe>
的 Element 对象具有一个 contentWindow
属性,该属性是嵌入文档的 Window 对象。对于在嵌套 iframe 中运行的脚本,window.parent
属性指的是包含的 Window 对象。当两个窗口显示具有相同源的文档时,那么每个窗口中的脚本都可以访问另一个窗口的内容。但是当文档具有不同的源时,浏览器的同源策略会阻止一个窗口中的 JavaScript 访问另一个窗口的内容。
对于 worker,postMessage()
提供了两个独立线程之间进行通信而不共享内存的安全方式。对于窗口,postMessage()
提供了两个独立来源之间安全交换消息的受控方式。即使同源策略阻止你的脚本查看另一个窗口的内容,你仍然可以在该窗口上调用postMessage()
,这将导致该窗口上触发“message”事件,可以被该窗口脚本中的事件处理程序看到。
然而,Window 的postMessage()
方法与 Worker 的postMessage()
方法有些不同。第一个参数仍然是将通过结构化克隆算法复制的任意消息。但是,列出要传输而不是复制的对象的可选第二个参数变成了可选的第三个参数。窗口的postMessage()
方法将字符串作为其必需的第二个参数。这第二个参数应该是一个指定你期望接收消息的来源(协议、主机名和可选端口)的来源。如果你将字符串“https://good.example.com”作为第二个参数传递,但你要发送消息的窗口实际上包含来自“https://malware.example.com”的内容,那么你发送的消息将不会被传递。如果你愿意将消息发送给任何来源的内容,那么可以将通配符“*”作为第二个参数传递。
在窗口或<iframe>
中运行的 JavaScript 代码可以通过定义该窗口的onmessage
属性或调用addEventListener()
来接收发送到该窗口或帧的消息。与 worker 一样,当你接收到窗口的“message”事件时,事件对象的data
属性就是发送的消息。此外,传递给窗口的“message”事件还定义了source
和origin
属性。source
属性指定发送事件的 Window 对象,你可以使用event.source.postMessage()
来发送回复。origin
属性指定源窗口中内容的来源。这不是消息发送者可以伪造的内容,当你接收到“message”事件时,通常会希望验证它来自你期望的来源。
15.14 示例:曼德勃罗特集
这一章关于客户端 JavaScript 的内容以一个长篇示例告终,演示了如何使用 worker 和消息传递来并行化计算密集型任务。但它被写成一个引人入胜的、真实的 Web 应用程序,还演示了本章中展示的其他 API,包括历史管理;使用带有<canvas>
的 ImageData 类;以及键盘、指针和调整大小事件的使用。它还演示了重要的核心 JavaScript 功能,包括生成器和对 Promise 的复杂使用。
该示例是一个用于显示和探索曼德勃罗特集的程序,这是一个包含美丽图像的复杂分形,如图 15-16 所示。
图 15-16. 曼德勃罗特集的一部分
Mandelbrot 集合被定义为复平面上的点集,当通过复数乘法和加法的重复过程产生一个值,其大小保持有界时。集合的轮廓非常复杂,计算哪些点是集合的成员,哪些不是,是计算密集型的:要生成一个 500×500 的 Mandelbrot 集合图像,您必须分别计算图像中的 250,000 个像素中的每一个的成员资格。为了验证与每个像素关联的值保持有界,您可能需要重复进行复数乘法的过程 1,000 次或更多。 (更多的迭代会产生更清晰定义的集合边界;更少的迭代会产生模糊的边界。)要生成一个高质量的 Mandelbrot 集合图像,需要进行高达 2.5 亿步的复数运算,您可以理解为什么使用 worker 是一种有价值的技术。示例 15-14 显示了我们将使用的 worker 代码。这个文件相对紧凑:它只是更大程序的原始计算力量。但是,关于它有两件值得注意的事情:
- Worker 创建一个 ImageData 对象来表示它正在计算 Mandelbrot 集合成员资格的像素的矩形网格。但是,它不是在 ImageData 中存储实际的像素值,而是使用自定义类型的数组将每个像素视为 32 位整数。它在此数组中存储每个像素所需的迭代次数。如果为每个像素计算的复数的大小超过四,则从那时起它在数学上保证会无限增长,我们称之为“逃逸”。因此,该 worker 为每个像素返回的值是逃逸前的迭代次数。我们告诉 worker 它应该为每个值尝试的最大迭代次数,并且达到此最大次数的像素被视为在集合中。
- Worker 将与 ImageData 关联的 ArrayBuffer 传回主线程,因此不需要复制与之关联的内存。
示例 15-14. 计算 Mandelbrot 集合区域的 Worker 代码
// This is a simple worker that receives a message from its parent thread, // performs the computation described by that message and then posts the // result of that computation back to the parent thread. onmessage = function(message) { // First, we unpack the message we received: // - tile is an object with width and height properties. It specifies the // size of the rectangle of pixels for which we will be computing // Mandelbrot set membership. // - (x0, y0) is the point in the complex plane that corresponds to the // upper-left pixel in the tile. // - perPixel is the pixel size in both the real and imaginary dimensions. // - maxIterations specifies the maximum number of iterations we will // perform before deciding that a pixel is in the set. const {tile, x0, y0, perPixel, maxIterations} = message.data; const {width, height} = tile; // Next, we create an ImageData object to represent the rectangular array // of pixels, get its internal ArrayBuffer, and create a typed array view // of that buffer so we can treat each pixel as a single integer instead of // four individual bytes. We'll store the number of iterations for each // pixel in this iterations array. (The iterations will be transformed into // actual pixel colors in the parent thread.) const imageData = new ImageData(width, height); const iterations = new Uint32Array(imageData.data.buffer); // Now we begin the computation. There are three nested for loops here. // The outer two loop over the rows and columns of pixels, and the inner // loop iterates each pixel to see if it "escapes" or not. The various // loop variables are the following: // - row and column are integers representing the pixel coordinate. // - x and y represent the complex point for each pixel: x + yi. // - index is the index in the iterations array for the current pixel. // - n tracks the number of iterations for each pixel. // - max and min track the largest and smallest number of iterations // we've seen so far for any pixel in the rectangle. let index = 0, max = 0, min=maxIterations; for(let row = 0, y = y0; row < height; row++, y += perPixel) { for(let column = 0, x = x0; column < width; column++, x += perPixel) { // For each pixel we start with the complex number c = x+yi. // Then we repeatedly compute the complex number z(n+1) based on // this recursive formula: // z(0) = c // z(n+1) = z(n)² + c // If |z(n)| (the magnitude of z(n)) is > 2, then the // pixel is not part of the set and we stop after n iterations. let n; // The number of iterations so far let r = x, i = y; // Start with z(0) set to c for(n = 0; n < maxIterations; n++) { let rr = r*r, ii = i*i; // Square the two parts of z(n). if (rr + ii > 4) { // If |z(n)|² is > 4 then break; // we've escaped and can stop iterating. } i = 2*r*i + y; // Compute imaginary part of z(n+1). r = rr - ii + x; // And the real part of z(n+1). } iterations[index++] = n; // Remember # iterations for each pixel. if (n > max) max = n; // Track the maximum number we've seen. if (n < min) min = n; // And the minimum as well. } } // When the computation is complete, send the results back to the parent // thread. The imageData object will be copied, but the giant ArrayBuffer // it contains will be transferred for a nice performance boost. postMessage({tile, imageData, min, max}, [imageData.data.buffer]); };
使用该 worker 代码的 Mandelbrot 集合查看器应用程序显示在 示例 15-15 中。现在您几乎已经到达本章的末尾,这个长示例是一个汇总体验,汇集了许多重要的核心和客户端 JavaScript 功能和 API。代码有详细的注释,我鼓励您仔细阅读。
示例 15-15. 用于显示和探索 Mandelbrot 集合的 Web 应用程序
/* * This class represents a subrectangle of a canvas or image. We use Tiles to * divide a canvas into regions that can be processed independently by Workers. */ class Tile { constructor(x, y, width, height) { this.x = x; // The properties of a Tile object this.y = y; // represent the position and size this.width = width; // of the tile within a larger this.height = height; // rectangle. } // This static method is a generator that divides a rectangle of the // specified width and height into the specified number of rows and // columns and yields numRows*numCols Tile objects to cover the rectangle. static *tiles(width, height, numRows, numCols) { let columnWidth = Math.ceil(width / numCols); let rowHeight = Math.ceil(height / numRows); for(let row = 0; row < numRows; row++) { let tileHeight = (row < numRows-1) ? rowHeight // height of most rows : height - rowHeight * (numRows-1); // height of last row for(let col = 0; col < numCols; col++) { let tileWidth = (col < numCols-1) ? columnWidth // width of most columns : width - columnWidth * (numCols-1); // and last column yield new Tile(col * columnWidth, row * rowHeight, tileWidth, tileHeight); } } } } /* * This class represents a pool of workers, all running the same code. The * worker code you specify must respond to each message it receives by * performing some kind of computation and then posting a single message with * the result of that computation. * * Given a WorkerPool and message that represents work to be performed, simply * call addWork(), with the message as an argument. If there is a Worker * object that is currently idle, the message will be posted to that worker * immediately. If there are no idle Worker objects, the message will be * queued and will be posted to a Worker when one becomes available. * * addWork() returns a Promise, which will resolve with the message recieved * from the work, or will reject if the worker throws an unhandled error. */ class WorkerPool { constructor(numWorkers, workerSource) { this.idleWorkers = []; // Workers that are not currently working this.workQueue = []; // Work not currently being processed this.workerMap = new Map(); // Map workers to resolve and reject funcs // Create the specified number of workers, add message and error // handlers and save them in the idleWorkers array. for(let i = 0; i < numWorkers; i++) { let worker = new Worker(workerSource); worker.onmessage = message => { this._workerDone(worker, null, message.data); }; worker.onerror = error => { this._workerDone(worker, error, null); }; this.idleWorkers[i] = worker; } } // This internal method is called when a worker finishes working, either // by sending a message or by throwing an error. _workerDone(worker, error, response) { // Look up the resolve() and reject() functions for this worker // and then remove the worker's entry from the map. let [resolver, rejector] = this.workerMap.get(worker); this.workerMap.delete(worker); // If there is no queued work, put this worker back in // the list of idle workers. Otherwise, take work from the queue // and send it to this worker. if (this.workQueue.length === 0) { this.idleWorkers.push(worker); } else { let [work, resolver, rejector] = this.workQueue.shift(); this.workerMap.set(worker, [resolver, rejector]); worker.postMessage(work); } // Finally, resolve or reject the promise associated with the worker. error === null ? resolver(response) : rejector(error); } // This method adds work to the worker pool and returns a Promise that // will resolve with a worker's response when the work is done. The work // is a value to be passed to a worker with postMessage(). If there is an // idle worker, the work message will be sent immediately. Otherwise it // will be queued until a worker is available. addWork(work) { return new Promise((resolve, reject) => { if (this.idleWorkers.length > 0) { let worker = this.idleWorkers.pop(); this.workerMap.set(worker, [resolve, reject]); worker.postMessage(work); } else { this.workQueue.push([work, resolve, reject]); } }); } } /* * This class holds the state information necessary to render a Mandelbrot set. * The cx and cy properties give the point in the complex plane that is the * center of the image. The perPixel property specifies how much the real and * imaginary parts of that complex number changes for each pixel of the image. * The maxIterations property specifies how hard we work to compute the set. * Larger numbers require more computation but produce crisper images. * Note that the size of the canvas is not part of the state. Given cx, cy, and * perPixel we simply render whatever portion of the Mandelbrot set fits in * the canvas at its current size. * * Objects of this type are used with history.pushState() and are used to read * the desired state from a bookmarked or shared URL. */ class PageState { // This factory method returns an initial state to display the entire set. static initialState() { let s = new PageState(); s.cx = -0.5; s.cy = 0; s.perPixel = 3/window.innerHeight; s.maxIterations = 500; return s; } // This factory method obtains state from a URL, or returns null if // a valid state could not be read from the URL. static fromURL(url) { let s = new PageState(); let u = new URL(url); // Initialize state from the url's search params. s.cx = parseFloat(u.searchParams.get("cx")); s.cy = parseFloat(u.searchParams.get("cy")); s.perPixel = parseFloat(u.searchParams.get("pp")); s.maxIterations = parseInt(u.searchParams.get("it")); // If we got valid values, return the PageState object, otherwise null. return (isNaN(s.cx) || isNaN(s.cy) || isNaN(s.perPixel) || isNaN(s.maxIterations)) ? null : s; } // This instance method encodes the current state into the search // parameters of the browser's current location. toURL() { let u = new URL(window.location); u.searchParams.set("cx", this.cx); u.searchParams.set("cy", this.cy); u.searchParams.set("pp", this.perPixel); u.searchParams.set("it", this.maxIterations); return u.href; } } // These constants control the parallelism of the Mandelbrot set computation. // You may need to adjust them to get optimum performance on your computer. const ROWS = 3, COLS = 4, NUMWORKERS = navigator.hardwareConcurrency || 2; // This is the main class of our Mandelbrot set program. Simply invoke the // constructor function with the <canvas> element to render into. The program // assumes that this <canvas> element is styled so that it is always as big // as the browser window. class MandelbrotCanvas { constructor(canvas) { // Store the canvas, get its context object, and initialize a WorkerPool this.canvas = canvas; this.context = canvas.getContext("2d"); this.workerPool = new WorkerPool(NUMWORKERS, "mandelbrotWorker.js"); // Define some properties that we'll use later this.tiles = null; // Subregions of the canvas this.pendingRender = null; // We're not currently rendering this.wantsRerender = false; // No render is currently requested this.resizeTimer = null; // Prevents us from resizing too frequently this.colorTable = null; // For converting raw data to pixel values. // Set up our event handlers this.canvas.addEventListener("pointerdown", e => this.handlePointer(e)); window.addEventListener("keydown", e => this.handleKey(e)); window.addEventListener("resize", e => this.handleResize(e)); window.addEventListener("popstate", e => this.setState(e.state, false)); // Initialize our state from the URL or start with the initial state. this.state = PageState.fromURL(window.location) || PageState.initialState(); // Save this state with the history mechanism. history.replaceState(this.state, "", this.state.toURL()); // Set the canvas size and get an array of tiles that cover it. this.setSize(); // And render the Mandelbrot set into the canvas. this.render(); } // Set the canvas size and initialize an array of Tile objects. This // method is called from the constructor and also by the handleResize() // method when the browser window is resized. setSize() { this.width = this.canvas.width = window.innerWidth; this.height = this.canvas.height = window.innerHeight; this.tiles = [...Tile.tiles(this.width, this.height, ROWS, COLS)]; } // This function makes a change to the PageState, then re-renders the // Mandelbrot set using that new state, and also saves the new state with // history.pushState(). If the first argument is a function that function // will be called with the state object as its argument and should make // changes to the state. If the first argument is an object, then we simply // copy the properties of that object into the state object. If the optional // second argument is false, then the new state will not be saved. (We // do this when calling setState in response to a popstate event.) setState(f, save=true) { // If the argument is a function, call it to update the state. // Otherwise, copy its properties into the current state. if (typeof f === "function") { f(this.state); } else { for(let property in f) { this.state[property] = f[property]; } } // In either case, start rendering the new state ASAP. this.render(); // Normally we save the new state. Except when we're called with // a second argument of false which we do when we get a popstate event. if (save) { history.pushState(this.state, "", this.state.toURL()); } } // This method asynchronously draws the portion of the Mandelbrot set // specified by the PageState object into the canvas. It is called by // the constructor, by setState() when the state changes, and by the // resize event handler when the size of the canvas changes. render() { // Sometimes the user may use the keyboard or mouse to request renders // more quickly than we can perform them. We don't want to submit all // the renders to the worker pool. Instead if we're rendering, we'll // just make a note that a new render is needed, and when the current // render completes, we'll render the current state, possibly skipping // multiple intermediate states. if (this.pendingRender) { // If we're already rendering, this.wantsRerender = true; // make a note to rerender later return; // and don't do anything more now. } // Get our state variables and compute the complex number for the // upper left corner of the canvas. let {cx, cy, perPixel, maxIterations} = this.state; let x0 = cx - perPixel * this.width/2; let y0 = cy - perPixel * this.height/2; // For each of our ROWS*COLS tiles, call addWork() with a message // for the code in mandelbrotWorker.js. Collect the resulting Promise // objects into an array. let promises = this.tiles.map(tile => this.workerPool.addWork({ tile: tile, x0: x0 + tile.x * perPixel, y0: y0 + tile.y * perPixel, perPixel: perPixel, maxIterations: maxIterations })); // Use Promise.all() to get an array of responses from the array of // promises. Each response is the computation for one of our tiles. // Recall from mandelbrotWorker.js that each response includes the // Tile object, an ImageData object that includes iteration counts // instead of pixel values, and the minimum and maximum iterations // for that tile. this.pendingRender = Promise.all(promises).then(responses => { // First, find the overall max and min iterations over all tiles. // We need these numbers so we can assign colors to the pixels. let min = maxIterations, max = 0; for(let r of responses) { if (r.min < min) min = r.min; if (r.max > max) max = r.max; } // Now we need a way to convert the raw iteration counts from the // workers into pixel colors that will be displayed in the canvas. // We know that all the pixels have between min and max iterations // so we precompute the colors for each iteration count and store // them in the colorTable array. // If we haven't allocated a color table yet, or if it is no longer // the right size, then allocate a new one. if (!this.colorTable || this.colorTable.length !== maxIterations+1){ this.colorTable = new Uint32Array(maxIterations+1); } // Given the max and the min, compute appropriate values in the // color table. Pixels in the set will be colored fully opaque // black. Pixels outside the set will be translucent black with higher // iteration counts resulting in higher opacity. Pixels with // minimum iteration counts will be transparent and the white // background will show through, resulting in a grayscale image. if (min === max) { // If all the pixels are the same, if (min === maxIterations) { // Then make them all black this.colorTable[min] = 0xFF000000; } else { // Or all transparent. this.colorTable[min] = 0; } } else { // In the normal case where min and max are different, use a // logarithic scale to assign each possible iteration count an // opacity between 0 and 255, and then use the shift left // operator to turn that into a pixel value. let maxlog = Math.log(1+max-min); for(let i = min; i <= max; i++) { this.colorTable[i] = (Math.ceil(Math.log(1+i-min)/maxlog * 255) << 24); } } // Now translate the iteration numbers in each response's // ImageData to colors from the colorTable. for(let r of responses) { let iterations = new Uint32Array(r.imageData.data.buffer); for(let i = 0; i < iterations.length; i++) { iterations[i] = this.colorTable[iterations[i]]; } } // Finally, render all the imageData objects into their // corresponding tiles of the canvas using putImageData(). // (First, though, remove any CSS transforms on the canvas that may // have been set by the pointerdown event handler.) this.canvas.style.transform = ""; for(let r of responses) { this.context.putImageData(r.imageData, r.tile.x, r.tile.y); } }) .catch((reason) => { // If anything went wrong in any of our Promises, we'll log // an error here. This shouldn't happen, but this will help with // debugging if it does. console.error("Promise rejected in render():", reason); }) .finally(() => { // When we are done rendering, clear the pendingRender flags this.pendingRender = null; // And if render requests came in while we were busy, rerender now. if (this.wantsRerender) { this.wantsRerender = false; this.render(); } }); } // If the user resizes the window, this function will be called repeatedly. // Resizing a canvas and rerendering the Mandlebrot set is an expensive // operation that we can't do multiple times a second, so we use a timer // to defer handling the resize until 200ms have elapsed since the last // resize event was received. handleResize(event) { // If we were already deferring a resize, clear it. if (this.resizeTimer) clearTimeout(this.resizeTimer); // And defer this resize instead. this.resizeTimer = setTimeout(() => { this.resizeTimer = null; // Note that resize has been handled this.setSize(); // Resize canvas and tiles this.render(); // Rerender at the new size }, 200); } // If the user presses a key, this event handler will be called. // We call setState() in response to various keys, and setState() renders // the new state, updates the URL, and saves the state in browser history. handleKey(event) { switch(event.key) { case "Escape": // Type Escape to go back to the initial state this.setState(PageState.initialState()); break; case "+": // Type + to increase the number of iterations this.setState(s => { s.maxIterations = Math.round(s.maxIterations*1.5); }); break; case "-": // Type - to decrease the number of iterations this.setState(s => { s.maxIterations = Math.round(s.maxIterations/1.5); if (s.maxIterations < 1) s.maxIterations = 1; }); break; case "o": // Type o to zoom out this.setState(s => s.perPixel *= 2); break; case "ArrowUp": // Up arrow to scroll up this.setState(s => s.cy -= this.height/10 * s.perPixel); break; case "ArrowDown": // Down arrow to scroll down this.setState(s => s.cy += this.height/10 * s.perPixel); break; case "ArrowLeft": // Left arrow to scroll left this.setState(s => s.cx -= this.width/10 * s.perPixel); break; case "ArrowRight": // Right arrow to scroll right this.setState(s => s.cx += this.width/10 * s.perPixel); break; } } // This method is called when we get a pointerdown event on the canvas. // The pointerdown event might be the start of a zoom gesture (a click or // tap) or a pan gesture (a drag). This handler registers handlers for // the pointermove and pointerup events in order to respond to the rest // of the gesture. (These two extra handlers are removed when the gesture // ends with a pointerup.) handlePointer(event) { // The pixel coordinates and time of the initial pointer down. // Because the canvas is as big as the window, these event coordinates // are also canvas coordinates. const x0 = event.clientX, y0 = event.clientY, t0 = Date.now(); // This is the handler for move events. const pointerMoveHandler = event => { // How much have we moved, and how much time has passed? let dx=event.clientX-x0, dy=event.clientY-y0, dt=Date.now()-t0; // If the pointer has moved enough or enough time has passed that // this is not a regular click, then use CSS to pan the display. // (We will rerender it for real when we get the pointerup event.) if (dx > 10 || dy > 10 || dt > 500) { this.canvas.style.transform = `translate(${dx}px, ${dy}px)`; } }; // This is the handler for pointerup events const pointerUpHandler = event => { // When the pointer goes up, the gesture is over, so remove // the move and up handlers until the next gesture. this.canvas.removeEventListener("pointermove", pointerMoveHandler); this.canvas.removeEventListener("pointerup", pointerUpHandler); // How much did the pointer move, and how much time passed? const dx = event.clientX-x0, dy=event.clientY-y0, dt=Date.now()-t0; // Unpack the state object into individual constants. const {cx, cy, perPixel} = this.state; // If the pointer moved far enough or if enough time passed, then // this was a pan gesture, and we need to change state to change // the center point. Otherwise, the user clicked or tapped on a // point and we need to center and zoom in on that point. if (dx > 10 || dy > 10 || dt > 500) { // The user panned the image by (dx, dy) pixels. // Convert those values to offsets in the complex plane. this.setState({cx: cx - dx*perPixel, cy: cy - dy*perPixel}); } else { // The user clicked. Compute how many pixels the center moves. let cdx = x0 - this.width/2; let cdy = y0 - this.height/2; // Use CSS to quickly and temporarily zoom in this.canvas.style.transform = `translate(${-cdx*2}px, ${-cdy*2}px) scale(2)`; // Set the complex coordinates of the new center point and // zoom in by a factor of 2. this.setState(s => { s.cx += cdx * s.perPixel; s.cy += cdy * s.perPixel; s.perPixel /= 2; }); } }; // When the user begins a gesture we register handlers for the // pointermove and pointerup events that follow. this.canvas.addEventListener("pointermove", pointerMoveHandler); this.canvas.addEventListener("pointerup", pointerUpHandler); } } // Finally, here's how we set up the canvas. Note that this JavaScript file // is self-sufficient. The HTML file only needs to include this one <script>. let canvas = document.createElement("canvas"); // Create a canvas element document.body.append(canvas); // Insert it into the body document.body.style = "margin:0"; // No margin for the <body> canvas.style.width = "100%"; // Make canvas as wide as body canvas.style.height = "100%"; // and as high as the body. new MandelbrotCanvas(canvas); // And start rendering into it!
15.15 总结和进一步阅读建议
这一长章节涵盖了客户端 JavaScript 编程的基础知识:
- 脚本和 JavaScript 模块如何包含在网页中以及它们何时以及如何执行。
- 客户端 JavaScript 的异步、事件驱动的编程模型。
- 允许 JavaScript 代码检查和修改其嵌入的文档的 HTML 内容的文档对象模型(DOM)。这个 DOM API 是所有客户端 JavaScript 编程的核心。
- JavaScript 代码如何操作应用于文档内容的 CSS 样式。
- JavaScript 代码如何获取浏览器窗口中和文档内部的文档元素的坐标。
- 如何使用 JavaScript、HTML 和 CSS 利用自定义元素和影子 DOM API 创建可重用的 UI “Web 组件”。
- 如何使用 SVG 和 HTML
<canvas>
元素显示和动态生成图形。 - 如何向您的网页添加脚本化的声音效果(录制和合成的)。
- JavaScript 如何使浏览器加载新页面,在用户的浏览历史记录中前进和后退,甚至向浏览历史记录添加新条目。
- JavaScript 程序如何使用 HTTP 和 WebSocket 协议与 Web 服务器交换数据。
- JavaScript 程序如何在用户的浏览器中存储数据。
- JavaScript 程序如何使用工作线程实现一种安全的并发形式。
这是本书迄今为止最长的一章。但它远远不能涵盖 Web 浏览器可用的所有 API。Web 平台庞大且不断发展,我这一章的目标是介绍最重要的核心 API。有了本书中的知识,你已经具备了学习和使用新 API 的能力。但如果你不知道某个新 API 的存在,就无法学习它,因此接下来的简短部分以一个快速列表结束本章,列出了未来可能想要调查的 Web 平台功能。
15.15.1 HTML 和 CSS
Web 是建立在三个关键技术上的:HTML、CSS 和 JavaScript,只有掌握 JavaScript 的知识,作为 Web 开发者,你的能力是有限的,除非你还提升自己在 HTML 和 CSS 方面的专业知识。重要的是要知道如何使用 JavaScript 操纵 HTML 元素和 CSS 样式,但只有当你知道使用哪些 HTML 元素和 CSS 样式时,这些知识才更有用。
在你开始探索更多 JavaScript API 之前,我建议你花一些时间掌握 Web 开发者工具包中的其他工具。例如,HTML 表单和输入元素具有复杂的行为,很重要理解,而 CSS 中的 flexbox 和 grid 布局模式非常强大。
在这个领域值得特别关注的两个主题是可访问性(包括 ARIA 属性)和国际化(包括支持从右到左的书写方向)。
15.15.2 性能
一旦你编写了一个 Web 应用并发布到世界上,不断优化使其变得更快的任务就开始了。然而,优化你无法测量的东西是困难的,因此值得熟悉性能 API。window 对象的performance
属性是这个 API 的主要入口点。它包括一个高分辨率时间源performance.now()
,以及用于标记代码中关键点和测量它们之间经过的时间的方法performance.mark()
和performance.measure()
。调用这些方法会创建 PerformanceEntry 对象,你可以通过performance.getEntries()
访问。浏览器在加载新页面或通过网络获取文件时会添加自己的 PerformanceEntry 对象,这些自动创建的 PerformanceEntry 对象包含应用程序网络性能的细粒度计时详细信息。相关的 PerformanceObserver 类允许你指定一个函数,在创建新的 PerformanceEntry 对象时调用。
15.15.3 安全
本章介绍了如何防御网站中的跨站脚本(XSS)安全漏洞的一般思路,但没有详细展开。网络安全是一个重要的话题,你可能想花一些时间了解更多。除了 XSS 外,值得学习的还有Content-Security-Policy
HTTP 头部,了解 CSP 如何让你要求网络浏览器限制它授予 JavaScript 代码的能力。理解跨域资源共享(CORS)也很重要。
15.15.4 WebAssembly
WebAssembly(或“wasm”)是一种低级虚拟机字节码格式,旨在与 Web 浏览器中的 JavaScript 解释器很好地集成。有编译器可以让你将 C、C++ 和 Rust 程序编译为 WebAssembly 字节码,并在 Web 浏览器中以接近本机速度运行这些程序,而不会破坏浏览器的沙箱或安全模型。WebAssembly 可以导出函数,供 JavaScript 程序调用。WebAssembly 的一个典型用例是将标准的 C 语言 zlib 压缩库编译,以便 JavaScript 代码可以访问高速压缩和解压缩算法。了解更多请访问https://webassembly.org。
15.15.5 更多文档和窗口功能
Window 和 Document 对象具有许多本章未涵盖的功能:
- Window 对象定义了
alert()
、confirm()
和prompt()
方法,用于向用户显示简单的模态对话框。这些方法会阻塞主线程。confirm()
方法同步返回一个布尔值,而prompt()
同步返回用户输入的字符串。这些方法不适合生产使用,但对于简单项目和原型设计可能会有用。 - Window 对象的
navigator
和screen
属性在本章开头简要提到过,但它们引用的 Navigator 和 Screen 对象具有一些这里未描述的功能,您可能会发现它们有用。 - 任何 Element 对象的
requestFullscreen()
方法请求该元素(例如<video>
或<canvas>
元素)以全屏模式显示。Document 的exitFullscreen()
方法返回正常显示模式。 requestAnimationFrame()
方法是 Window 对象的一个方法,它接受一个函数作为参数,并在浏览器准备渲染下一帧时执行该函数。当您进行视觉变化(特别是重复或动画变化)时,将您的代码包装在requestAnimationFrame()
调用中可以帮助确保变化平滑地呈现,并以浏览器优化的方式呈现。- 如果用户在您的文档中选择文本,您可以使用 Window 方法
getSelection()
获取该选择的详细信息,并使用getSelection().toString()
获取所选文本。在某些浏览器中,navigator.clipboard
是一个具有异步 API 的对象,用于读取和设置系统剪贴板的内容,以便与浏览器外的应用程序进行复制和粘贴交互。 - Web 浏览器的一个鲜为人知的功能是具有
contenteditable="true"
属性的 HTML 元素允许编辑其内容。document.execCommand()
方法为可编辑内容启用富文本编辑功能。 - MutationObserver 允许 JavaScript 监视文档中指定元素的更改或下方的更改。使用
MutationObserver()
构造函数创建 MutationObserver,传递应在进行更改时调用的回调函数。然后调用 MutationObserver 的observe()
方法指定要监视的哪些元素的哪些部分。 - IntersectionObserver 允许 JavaScript 确定哪些文档元素在屏幕上,哪些接近屏幕。对于希望根据用户滚动动态加载内容的应用程序,它特别有用。
15.15.6 事件
Web 平台支持的事件数量和多样性令人生畏。本章讨论了各种事件类型,但以下是一些您可能会发现有用的其他事件:
- 当浏览器获得或失去互联网连接时,浏览器会在 Window 对象上触发“online”和“offline”事件。
- 当文档变得可见或不可见(通常是因为用户切换选项卡)时,浏览器会在 Document 对象上触发“visiblitychange”事件。JavaScript 可以检查
document.visibilityState
以确定其文档当前是“可见”还是“隐藏”。 - 浏览器支持复杂的 API 以支持拖放 UI 和与浏览器外的应用程序进行数据交换。该 API 涉及许多事件,包括“dragstart”、“dragover”、“dragend”和“drop”。正确使用此 API 可能有些棘手,但在需要时非常有用。如果您想要使用户能够从其桌面拖动文件到您的 Web 应用程序中,则了解此重要 API 是很重要的。
- 指针锁定 API 使 JavaScript 能够隐藏鼠标指针,并获取原始鼠标事件作为相对移动量,而不是屏幕上的绝对位置。这通常对游戏很有用。在您希望所有鼠标事件指向的元素上调用
requestPointerLock()
。这样做后,传递给该元素的“mousemove”事件将具有movementX
和movementY
属性。 - 游戏手柄 API 添加了对游戏手柄的支持。使用
navigator.getGamepads()
来获取连接的游戏手柄对象,并在 Window 对象上监听“gamepadconnected”事件,以便在插入新控制器时收到通知。游戏手柄对象定义了一个用于查询控制器按钮当前状态的 API。
15.15.7 渐进式网络应用和服务工作者
渐进式网络应用(Progressive Web Apps,PWAs)是一个流行词,用来描述使用一些关键技术构建的网络应用程序。对这些关键技术进行仔细的文档记录需要一本专门的书,我在本章中没有涵盖它们,但你应该了解所有这些 API。值得注意的是,像这样强大的现代 API 通常只设计用于安全的 HTTPS 连接。仍在使用http://
URL 的网站将无法利用这些:
- 服务工作者是一种具有拦截、检查和响应来自其“服务”的网络应用程序的网络请求能力的工作者线程。当一个网络应用程序注册一个服务工作者时,该工作者的代码将持久保存在浏览器的本地存储中,当用户再次访问相关网站时,服务工作者将被重新激活。服务工作者可以缓存网络响应(包括 JavaScript 代码文件),这意味着使用服务工作者的网络应用程序可以有效地安装到用户的计算机上,以实现快速启动和离线使用。Service Worker Cookbook 是一个了解服务工作者及其相关技术的宝贵资源。
- 缓存 API 设计用于服务工作者(但也可用于工作者之外的常规 JavaScript 代码)。它与
fetch()
API 定义的 Request 和 Response 对象一起工作,并实现了 Request/Response 对的缓存。缓存 API 使服务工作者能够缓存其提供的网络应用程序的脚本和其他资产,并且还可以帮助实现网络应用程序的离线使用(这对移动设备尤为重要)。 - Web 清单是一个 JSON 格式的文件,描述了一个网络应用程序,包括名称、URL 和各种尺寸的图标链接。如果您的网络应用程序使用服务工作者,并包含一个引用
.webmanifest
文件的<link rel="manifest">
标签,则浏览器(尤其是移动设备上的浏览器)可能会给您添加一个图标的选项,以便将网络应用程序添加到您的桌面或主屏幕上。 - 通知 API 允许网络应用程序在移动设备和桌面设备上使用本机操作系统通知系统显示通知。通知可以包括图像和文本,如果用户点击通知,您的代码可以接收到事件。使用此 API 的复杂之处在于您必须首先请求用户的权限来显示通知。
- 推送 API 允许具有服务工作者(并且获得用户许可)的网络应用程序订阅来自服务器的通知,并在应用程序本身未运行时显示这些通知。推送通知在移动设备上很常见,推送 API 使网络应用程序更接近移动设备上本机应用程序的功能。
15.15.8 移动设备 API
有许多网络 API 主要适用于在移动设备上运行的网络应用程序。(不幸的是,其中一些 API 仅适用于 Android 设备,而不适用于 iOS 设备。)
- 地理位置 API 允许 JavaScript(在用户许可的情况下)确定用户的物理位置。它在桌面和移动设备上得到很好的支持,包括 iOS 设备。使用
navigator.geolocation.getCurrentPosition()
请求用户当前位置,并使用navigator.geolocation.watchPosition()
注册一个回调函数,当用户位置发生变化时调用该函数。 navigator.vibrate()
方法会使移动设备(但不包括 iOS)震动。通常只允许在响应用户手势时使用,但调用此方法将允许您的应用程序提供无声反馈,表示已识别到手势。- ScreenOrientation API 允许 Web 应用程序查询移动设备屏幕的当前方向,并锁定自身为横向或纵向方向。
- 窗口对象上的 “devicemotion” 和 “deviceorientation” 事件报告设备的加速计和磁力计数据,使您能够确定设备如何加速以及用户如何在空间中定位设备。(这些事件在 iOS 上也有效。)
- Sensor API 在 Chrome on Android 设备之外尚未得到广泛支持,但它使 JavaScript 能够访问完整套移动设备传感器,包括加速计、陀螺仪、磁力计和环境光传感器。这些传感器使 JavaScript 能够确定用户面向的方向或检测用户何时摇动他们的手机,例如。
15.15.9 二进制 API
Typed arrays、ArrayBuffers 和 DataView 类(在 §11.2 中有介绍)使 JavaScript 能够处理二进制数据。正如本章前面所述,fetch()
API 使 JavaScript 程序能够通过网络加载二进制数据。另一个二进制数据的来源是用户本地文件系统中的文件。出于安全原因,JavaScript 不能直接读取本地文件。但是如果用户选择上传文件(使用 <input type="file>
表单元素)或使用拖放将文件拖放到您的 Web 应用程序中,那么 JavaScript 就可以访问该文件作为一个 File 对象。
File 是 Blob 的一个子类,因此它是一个数据块的不透明表示。您可以使用 FileReader 类异步地将文件内容获取为 ArrayBuffer 或字符串。(在某些浏览器中,您可以跳过 FileReader,而是使用 Blob 类定义的基于 Promise 的 text()
和 arrayBuffer()
方法,或者用于对文件内容进行流式访问的 stream()
方法。)
在处理二进制数据,特别是流式二进制数据时,您可能需要将字节解码为文本或将文本编码为字节。TextEncoder 和 TextDecoder 类有助于完成这项任务。
15.15.10 媒体 API
navigator.mediaDevices.getUserMedia()
函数允许 JavaScript 请求访问用户的麦克风和/或摄像头。成功的请求会返回一个 MediaStream 对象。视频流可以在 <video>
标签中显示(通过将 srcObject
属性设置为该流)。视频的静止帧可以通过在一个离屏 <canvas>
中使用 canvas 的 drawImage()
函数捕获,从而得到一个相对低分辨率的照片。由 getUserMedia()
返回的音频和视频流可以被记录并编码为一个 Blob 对象。
更复杂的 WebRTC API 允许在网络上传输和接收 MediaStreams,例如实现点对点视频会议。
15.15.11 加密和相关 API
Window 对象的 crypto
属性公开了一个用于生成密码安全伪随机数的 getRandomValues()
方法。通过 crypto.subtle
还可以使用其他加密、解密、密钥生成、数字签名等方法。这个属性的名称是对所有使用这些方法的人的警告,即正确使用加密算法是困难的,除非你真正知道自己在做什么,否则不应该使用这些方法。此外,crypto.subtle
的方法仅对通过安全的 HTTPS 连接加载的文档中运行的 JavaScript 代码可用。
凭据管理 API 和 Web 认证 API 允许 JavaScript 生成、存储和检索公钥(以及其他类型的)凭据,并实现无需密码的帐户创建和登录。JavaScript API 主要由函数 navigator.credentials.create()
和 navigator.credentials.get()
组成,但在服务器端需要大量基础设施来使这些方法工作。这些 API 尚未得到普遍支持,但有潜力彻底改变我们登录网站的方式。
支付请求 API 为网页上的信用卡支付添加了浏览器支持。它允许用户在浏览器中安全存储他们的支付详细信息,这样他们每次购买时就不必输入信用卡号码。想要请求支付的网络应用程序会创建一个 PaymentRequest 对象,并调用其 show()
方法来向用户显示请求。
¹ 本书的早期版本包含了一个广泛的参考部分,涵盖了 JavaScript 标准库和 Web API。第七版中将其删除,因为 MDN 已经使其过时:今天,在 MDN 上查找信息比翻书更快,而我在 MDN 的前同事比这本书更擅长保持在线文档的更新。
² 一些来源,包括 HTML 规范,根据它们的注册方式在处理程序和监听器之间做了技术区分。在本书中,我们将这两个术语视为同义词。
³ 如果你使用 React 框架创建客户端用户界面,这可能会让你感到惊讶。React 对客户端事件模型进行了一些微小的更改,其中之一是在 React 中,事件处理程序属性名称采用驼峰式写法:onClick
、onMouseOver
等。然而,在原生的 Web 平台上工作时,事件处理程序属性完全采用小写形式。
⁴ 自定义元素规范允许对 <button>
和其他特定元素类进行子类化,但 Safari 不支持这一点,使用扩展除 HTMLElement 之外的自定义元素需要不同的语法。