二十、JavaScript API
-
JavaScript API
- 隨著 Web 瀏覽器能力的增加,其復雜性也在迅速增加。從很多方面看,現代 Web 瀏覽器已經成為構建于諸多規范之上、集不同 API 于一身的“瑞士軍刀”。瀏覽器規范的生態在某種程度上是混亂而無序的。一些規范如 HTML5,定義了一批增強已有標準的 API 和瀏覽器特性。而另一些規范如 Web Cryptography API 和 Notifications API,只為一個特性定義了一個 API。不同瀏覽器實現這些新 API 的情況也不同,有的會實現其中一部分,有的則干脆尚未實現。
- 最終,是否使用這些比較新的 API 還要看項目是支持更多瀏覽器,還是要采用更多現代特性。有些API 可以通過膩子腳本來模擬,但膩子腳本通常會帶來性能問題,此外也會增加網站 JavaScript 代碼的體積。
- 注意,Web API 的數量之多令人難以置信(參見 MDN 文檔的 Web APIs 詞條)。本章要介紹的 API 僅限于與大多數開發者有關、已經得到多個瀏覽器支持,且本書其他章節沒有涵蓋的部分。
-
Atomics 與 SharedArrayBuffer
- 多個上下文訪問 SharedArrayBuffer 時,如果同時對緩沖區執行操作,就可能出現資源爭用問題。Atomics API 通過強制同一時刻只能對緩沖區執行一個操作,可以讓多個上下文安全地讀寫一個SharedArrayBuffer。Atomics API 是 ES2017 中定義的。
- 仔細研究會發現 Atomics API 非常像一個簡化版的指令集架構(ISA),這并非意外。原子操作的本質會排斥操作系統或計算機硬件通常會自動執行的優化(比如指令重新排序)。原子操作也讓并發訪問內存變得不可能,如果應用不當就可能導致程序執行變慢。為此,Atomics API 的設計初衷是在最少但很穩定的原子行為基礎之上,構建復雜的多線程 JavaScript 程序。
-
SharedArrayBuffer
- SharedArrayBuffer 與 ArrayBuffer 具有同樣的 API。二者的主要區別是 ArrayBuffer 必須在不同執行上下文間切換,SharedArrayBuffer 則可以被任意多個執行上下文同時使用。
- 在多個執行上下文間共享內存意味著并發線程操作成為了可能。傳統 JavaScript 操作對于并發內存訪問導致的資源爭用沒有提供保護。下面的例子演示了 4 個專用工作線程訪問同一個SharedArrayBuffer 導致的資源爭用問題:
const workerScript = ` self.onmessage = ({data}) => { const view = new Uint32Array(data); // 執行 1 000 000 次加操作for (let i = 0; i < 1E6; ++i) { // 線程不安全加操作會導致資源爭用view[0] += 1; } self.postMessage(null); }; `; const workerScriptBlobUrl = URL.createObjectURL(new Blob([workerScript])); // 創建容量為 4 的工作線程池 const workers = []; for (let i = 0; i < 4; ++i) { workers.push(new Worker(workerScriptBlobUrl)); } // 在最后一個工作線程完成后打印出最終值 let responseCount = 0; for (const worker of workers) { worker.onmessage = () => { if (++responseCount == workers.length) { console.log(`Final buffer value: ${view[0]}`); } }; } // 初始化 SharedArrayBuffer const sharedArrayBuffer = new SharedArrayBuffer(4); const view = new Uint32Array(sharedArrayBuffer); view[0] = 1; // 把 SharedArrayBuffer 發送到每個工作線程 for (const worker of workers) { worker.postMessage(sharedArrayBuffer); } //(期待結果為 4000001。實際輸出可能類似這樣:) // Final buffer value: 2145106
- 為解決這個問題,Atomics API 應運而生。Atomics API 可以保證 SharedArrayBuffer 上的JavaScript 操作是線程安全的。
- 注意,SharedArrayBuffer API 等同于 ArrayBuffer API,后者在第 6 章介紹過。關于如何在多個上下文中使用 SharedArrayBuffer,可以參考第 27 章。
-
原子操作基礎
- 任何全局上下文中都有 Atomics 對象,這個對象上暴露了用于執行線程安全操作的一套靜態方法,其中多數方法以一個 TypedArray 實例(一個 SharedArrayBuffer 的引用)作為第一個參數,以相關操作數作為后續參數。
-
算術及位操作方法
- Atomics API 提供了一套簡單的方法用以執行就地修改操作。在 ECMA 規范中,這些方法被定義為AtomicReadModifyWrite 操作。在底層,這些方法都會從 SharedArrayBuffer 中某個位置讀取值,然后執行算術或位操作,最后再把計算結果寫回相同的位置。這些操作的原子本質意味著上述讀取、修改、寫回操作會按照順序執行,不會被其他線程中斷。
- 以下代碼演示了所有算術方法:
// 創建大小為 1 的緩沖區 let sharedArrayBuffer = new SharedArrayBuffer(1); // 基于緩沖創建 Uint8Array let typedArray = new Uint8Array(sharedArrayBuffer); // 所有 ArrayBuffer 全部初始化為 0 console.log(typedArray); // Uint8Array[0] const index = 0; const increment = 5; // 對索引 0 處的值執行原子加 5 Atomics.add(typedArray, index, increment); console.log(typedArray); // Uint8Array[5] // 對索引 0 處的值執行原子減 5 Atomics.sub(typedArray, index, increment); console.log(typedArray); // Uint8Array[0]
- 以下代碼演示了所有位方法:
// 創建大小為 1 的緩沖區 let sharedArrayBuffer = new SharedArrayBuffer(1); // 基于緩沖創建 Uint8Array let typedArray = new Uint8Array(sharedArrayBuffer); // 所有 ArrayBuffer 全部初始化為 0 console.log(typedArray); // Uint8Array[0] const index = 0; // 對索引 0 處的值執行原子或 0b1111 Atomics.or(typedArray, index, 0b1111); console.log(typedArray); // Uint8Array[15] // 對索引 0 處的值執行原子與 0b1111 Atomics.and(typedArray, index, 0b1100); console.log(typedArray); // Uint8Array[12] // 對索引 0 處的值執行原子異或 0b1111 Atomics.xor(typedArray, index, 0b1111); console.log(typedArray); // Uint8Array[3]
- 前面線程不安全的例子可以改寫為下面這樣:
const workerScript = ` self.onmessage = ({data}) => { const view = new Uint32Array(data); // 執行 1 000 000 次加操作for (let i = 0; i < 1E6; ++i) { // 線程安全的加操作Atomics.add(view, 0, 1); } self.postMessage(null); }; `; const workerScriptBlobUrl = URL.createObjectURL(new Blob([workerScript])); // 創建容量為 4 的工作線程池 const workers = []; for (let i = 0; i < 4; ++i) { workers.push(new Worker(workerScriptBlobUrl)); } // 在最后一個工作線程完成后打印出最終值 let responseCount = 0; for (const worker of workers) { worker.onmessage = () => { if (++responseCount == workers.length) { console.log(`Final buffer value: ${view[0]}`); } }; } // 初始化 SharedArrayBuffer const sharedArrayBuffer = new SharedArrayBuffer(4); const view = new Uint32Array(sharedArrayBuffer); view[0] = 1; // 把 SharedArrayBuffer 發送到每個工作線程 for (const worker of workers) {worker.postMessage(sharedArrayBuffer); } //(期待結果為 4000001) // Final buffer value: 4000001
-
原子讀和寫
- 瀏覽器的 JavaScript 編譯器和 CPU 架構本身都有權限重排指令以提升程序執行效率。正常情況下,JavaScript 的單線程環境是可以隨時進行這種優化的。但多線程下的指令重排可能導致資源爭用,而且極難排錯。
- Atomics API 通過兩種主要方式解決了這個問題。
- 所有原子指令相互之間的順序永遠不會重排。
- 使用原子讀或原子寫保證所有指令(包括原子和非原子指令)都不會相對原子讀/寫重新排序。這意味著位于原子讀/寫之前的所有指令會在原子讀/寫發生前完成,而位于原子讀/寫之后的所有指令會在原子讀/寫完成后才會開始。
- 除了讀寫緩沖區的值,Atomics.load()和 Atomics.store()還可以構建“代碼圍欄”。JavaScript引擎保證非原子指令可以相對于 load()或 store()本地重排,但這個重排不會侵犯原子讀/寫的邊界。以下代碼演示了這種行為:
const sharedArrayBuffer = new SharedArrayBuffer(4); const view = new Uint32Array(sharedArrayBuffer); // 執行非原子寫 view[0] = 1; // 非原子寫可以保證在這個讀操作之前完成,因此這里一定會讀到 1 console.log(Atomics.load(view, 0)); // 1 // 執行原子寫 Atomics.store(view, 0, 2); // 非原子讀可以保證在原子寫完成后發生,因此這里一定會讀到 2 console.log(view[0]); // 2
-
原子交換
- 為了保證連續、不間斷的先讀后寫, Atomics API 提供了兩種方法: exchange() 和compareExchange()。Atomics.exchange()執行簡單的交換,以保證其他線程不會中斷值的交換:
const sharedArrayBuffer = new SharedArrayBuffer(4); const view = new Uint32Array(sharedArrayBuffer); // 在索引 0 處寫入 3 Atomics.store(view, 0, 3); // 從索引 0 處讀取值,然后在索引 0 處寫入 4 console.log(Atomics.exchange(view, 0, 4)); // 3 // 從索引 0 處讀取值 console.log(Atomics.load(view, 0)); // 4
- 在多線程程序中,一個線程可能只希望在上次讀取某個值之后沒有其他線程修改該值的情況下才對共享緩沖區執行寫操作。如果這個值沒有被修改,這個線程就可以安全地寫入更新后的值;如果這個值被修改了,那么執行寫操作將會破壞其他線程計算的值。對于這種任務,Atomics API 提供了 compareExchange()方法。這個方法只在目標索引處的值與預期值匹配時才會執行寫操作。來看下面這個例子:
const sharedArrayBuffer = new SharedArrayBuffer(4); const view = new Uint32Array(sharedArrayBuffer); // 在索引 0 處寫入 5 Atomics.store(view, 0, 5); // 從緩沖區讀取值 let initial = Atomics.load(view, 0); // 對這個值執行非原子操作 let result = initial ** 2; // 只在緩沖區未被修改的情況下才會向緩沖區寫入新值 Atomics.compareExchange(view, 0, initial, result); // 檢查寫入成功 console.log(Atomics.load(view, 0)); // 25
- 如果值不匹配,compareExchange()調用則什么也不做:
const sharedArrayBuffer = new SharedArrayBuffer(4); const view = new Uint32Array(sharedArrayBuffer); // 在索引 0 處寫入 5 Atomics.store(view, 0, 5); // 從緩沖區讀取值 let initial = Atomics.load(view, 0); // 對這個值執行非原子操作 let result = initial ** 2; // 只在緩沖區未被修改的情況下才會向緩沖區寫入新值 Atomics.compareExchange(view, 0, -1, result); // 檢查寫入失敗 console.log(Atomics.load(view, 0)); // 5
- 為了保證連續、不間斷的先讀后寫, Atomics API 提供了兩種方法: exchange() 和compareExchange()。Atomics.exchange()執行簡單的交換,以保證其他線程不會中斷值的交換:
-
原子 Futex 操作與加鎖
- 如果沒有某種鎖機制,多線程程序就無法支持復雜需求。為此,Atomics API 提供了模仿 Linux Futex(快速用戶空間互斥量,fast user-space mutex)的方法。這些方法本身雖然非常簡單,但可以作為更復雜鎖機制的基本組件。
- 注意,所有原子 Futex 操作只能用于 Int32Array 視圖。而且,也只能用在工作線程內部。
- Atomics.wait()和 Atomics.notify()通過示例很容易理解。下面這個簡單的例子創建了 4 個工作線程,用于對長度為 1 的 Int32Array 進行操作。這些工作線程會依次取得鎖并執行自己的加操作:
const workerScript = ` self.onmessage = ({data}) => { const view = new Int32Array(data); console.log('Waiting to obtain lock'); // 遇到初始值則停止,10 000 毫秒超時Atomics.wait(view, 0, 0, 1E5); console.log('Obtained lock'); // 在索引 0 處加 1 Atomics.add(view, 0, 1); console.log('Releasing lock'); // 只允許 1 個工作線程繼續執行Atomics.notify(view, 0, 1); self.postMessage(null); }; `;const workerScriptBlobUrl = URL.createObjectURL(new Blob([workerScript])); const workers = []; for (let i = 0; i < 4; ++i) { workers.push(new Worker(workerScriptBlobUrl)); } // 在最后一個工作線程完成后打印出最終值 let responseCount = 0; for (const worker of workers) { worker.onmessage = () => { if (++responseCount == workers.length) { console.log(`Final buffer value: ${view[0]}`); } }; } // 初始化 SharedArrayBuffer const sharedArrayBuffer = new SharedArrayBuffer(8); const view = new Int32Array(sharedArrayBuffer); // 把 SharedArrayBuffer 發送到每個工作線程 for (const worker of workers) { worker.postMessage(sharedArrayBuffer); } // 1000 毫秒后釋放第一個鎖 setTimeout(() => Atomics.notify(view, 0, 1), 1000); // Waiting to obtain lock // Waiting to obtain lock // Waiting to obtain lock // Waiting to obtain lock // Obtained lock // Releasing lock // Obtained lock // Releasing lock // Obtained lock // Releasing lock // Obtained lock // Releasing lock // Final buffer value: 4
- 因為是使用 0 來初始化 SharedArrayBuffer,所以每個工作線程都會到達 Atomics.wait()并停止執行。在停止狀態下,執行線程存在于一個等待隊列中,在經過指定時間或在相應索引上調用Atomics.notify() 之前,一直保持暫停狀態。 1000 毫秒之后,頂部執行上下文會調用Atomics.notify()釋放其中一個等待的線程。這個線程執行完畢后會再次調用 Atomics.notify()釋放另一個線程。這個過程會持續到所有線程都執行完畢并通過 postMessage()傳出最終的值。
- Atomics API 還提供了 Atomics.isLockFree()方法。不過我們基本上應該不會用到。這個方法在高性能算法中可以用來確定是否有必要獲取鎖。規范中的介紹如下:
- Atomics.isLockFree()是一個優化原語。基本上,如果一個原子原語(compareExchange、load、store、add、sub、and、or、xor 或 exchange)在 n 字節大小的數據上的原子步驟在不調用代理在組成數據的n字節之外獲得鎖的情況下可以執行,則Atomics.isLockFree(n)會返回 true。高性能算法會使用 Atomics.isLockFree 確定是否在關鍵部分使用鎖或原子操作。如果原子原語需要加鎖,則算法提供自己的鎖會更高效。
- Atomics.isLockFree(4)始終返回 true,因為在所有已知的相關硬件上都是支持的。能夠如此假設通常可以簡化程序。
-
跨上下文消息
- 跨文檔消息,有時候也簡稱為 XDM(cross-document messaging),是一種在不同執行上下文(如不同工作線程或不同源的頁面)間傳遞信息的能力。例如,www.wrox.com 上的頁面想要與包含在內嵌窗格中的 p2p.wrox.com 上面的頁面通信。在 XDM 之前,要以安全方式實現這種通信需要很多工作。XDM以安全易用的方式規范化了這個功能。
- 注意,跨上下文消息用于窗口之間通信或工作線程之間通信。本節主要介紹使用postMessage()與其他窗口通信 。關于工作線程之間通信、MessageChannel 和BroadcastChannel,可以參考第 27 章。
- XDM 的核心是 postMessage()方法。除了 XDM,這個方法名還在 HTML5 中很多地方用到過,但目的都一樣,都是把數據傳送到另一個位置。
- postMessage()方法接收 3 個參數:消息、表示目標接收源的字符串和可選的可傳輸對象的數組(只與工作線程相關)。第二個參數對于安全非常重要,其可以限制瀏覽器交付數據的目標。下面來看一個例子:
let iframeWindow = document.getElementById("myframe").contentWindow; iframeWindow.postMessage("A secret", "http://www.wrox.com");
- 最后一行代碼嘗試向內嵌窗格中發送一條消息,而且指定了源必須是"http://www.wrox.com"。如果源匹配,那么消息將會交付到內嵌窗格;否則,postMessage()什么也不做。這個限制可以保護信息不會因地址改變而泄露。如果不想限制接收目標,則可以給 postMessage()的第二個參數傳"*",但不推薦這么做。
- 接收到 XDM 消息后,window 對象上會觸發 message 事件。這個事件是異步觸發的,因此從消息發出到接收到消息(接收窗口觸發 message 事件)可能有延遲。傳給 onmessage 事件處理程序的 event對象包含以下 3 方面重要信息。
- data:作為第一個參數傳遞給 postMessage()的字符串數據。
- origin:發送消息的文檔源,例如"http://www.wrox.com"。
- source:發送消息的文檔中 window 對象的代理。這個代理對象主要用于在發送上一條消息的窗口中執行 postMessage()方法。如果發送窗口有相同的源,那么這個對象應該就是 window對象。
- 接收消息之后驗證發送窗口的源是非常重要的。與 postMessage()的第二個參數可以保證數據不會意外傳給未知頁面一樣,在 onmessage 事件處理程序中檢查發送窗口的源可以保證數據來自正確的地方。基本的使用方式如下所示:
window.addEventListener("message", (event) => { // 確保來自預期發送者if (event.origin == "http://www.wrox.com") { // 對數據進行一些處理processMessage(event.data); // 可選:向來源窗口發送一條消息event.source.postMessage("Received!", "http://p2p.wrox.com"); } });
- 大多數情況下,event.source 是某個 window 對象的代理,而非實際的 window 對象。因此不能通過它訪問所有窗口下的信息。最好只使用 postMessage(),這個方法永遠存在而且可以調用。
- XDM 有一些怪異之處。首先,postMessage()的第一個參數的最初實現始終是一個字符串。后來,第一個參數改為允許任何結構的數據傳入,不過并非所有瀏覽器都實現了這個改變。為此,最好就是只通過 postMessage() 發送字符串。如果需要傳遞結構化數據,那么最好先對該數據調用JSON.stringify(),通過 postMessage()傳過去之后,再在 onmessage 事件處理程序中調用JSON.parse()。
- 在通過內嵌窗格加載不同域時,使用 XDM 是非常方便的。這種方法在混搭(mashup)和社交應用中非常常用。通過使用 XDM 與內嵌窗格中的網頁通信,可以保證包含頁面的安全。XDM 也可以用于同源頁面之間通信。
-
Encoding API
- Encoding API 主要用于實現字符串與定型數組之間的轉換。規范新增了 4 個用于執行轉換的全局類:TextEncoder、TextEncoderStream、TextDecoder 和 TextDecoderStream。
- 注意,相比于批量(bulk)的編解碼,對流(stream)編解碼的支持很有限。
-
文本編碼
- Encoding API 提供了兩種將字符串轉換為定型數組二進制格式的方法:批量編碼和流編碼。把字符串轉換為定型數組時,編碼器始終使用 UTF-8。
-
批量編碼
- 所謂批量,指的是 JavaScript 引擎會同步編碼整個字符串。對于非常長的字符串,可能會花較長時間。批量編碼是通過 TextEncoder 的實例完成的:
const textEncoder = new TextEncoder();
- 這個實例上有一個 encode()方法,該方法接收一個字符串參數,并以 Uint8Array 格式返回每個字符的 UTF-8 編碼:
const textEncoder = new TextEncoder(); const decodedText = 'foo'; const encodedText = textEncoder.encode(decodedText); // f 的 UTF-8 編碼是 0x66(即十進制 102) // o 的 UTF-8 編碼是 0x6F(即二進制 111) console.log(encodedText); // Uint8Array(3) [102, 111, 111]
- 編碼器是用于處理字符的,有些字符(如表情符號)在最終返回的數組中可能會占多個索引:
const textEncoder = new TextEncoder(); const decodedText = '?'; const encodedText = textEncoder.encode(decodedText); // ?的 UTF-8 編碼是 0xF0 0x9F 0x98 0x8A(即十進制 240、159、152、138) console.log(encodedText); // Uint8Array(4) [240, 159, 152, 138]
- 編碼器實例還有一個 encodeInto()方法,該方法接收一個字符串和目標 Unit8Array,返回一個字典,該字典包含 read 和 written 屬性,分別表示成功從源字符串讀取了多少字符和向目標數組寫入了多少字符。如果定型數組的空間不夠,編碼就會提前終止,返回的字典會體現這個結果:
const textEncoder = new TextEncoder(); const fooArr = new Uint8Array(3); const barArr = new Uint8Array(2); const fooResult = textEncoder.encodeInto('foo', fooArr); const barResult = textEncoder.encodeInto('bar', barArr); console.log(fooArr); // Uint8Array(3) [102, 111, 111] console.log(fooResult); // { read: 3, written: 3 } console.log(barArr); // Uint8Array(2) [98, 97] console.log(barResult); // { read: 2, written: 2 }
- encode()要求分配一個新的 Unit8Array,encodeInto()則不需要。對于追求性能的應用,這個差別可能會帶來顯著不同。
- 注意,文本編碼會始終使用 UTF-8 格式,而且必須寫入 Unit8Array 實例。使用其他類型數組會導致 encodeInto()拋出錯誤。
- 所謂批量,指的是 JavaScript 引擎會同步編碼整個字符串。對于非常長的字符串,可能會花較長時間。批量編碼是通過 TextEncoder 的實例完成的:
-
流編碼
- TextEncoderStream 其實就是 TransformStream 形式的 TextEncoder。將解碼后的文本流通過管道輸入流編碼器會得到編碼后文本塊的流:
async function* chars() { const decodedText = 'foo'; for (let char of decodedText) { yield await new Promise((resolve) => setTimeout(resolve, 1000, char));} } const decodedTextStream = new ReadableStream({ async start(controller) { for await (let chunk of chars()) { controller.enqueue(chunk); } controller.close(); } }); const encodedTextStream = decodedTextStream.pipeThrough(new TextEncoderStream()); const readableStreamDefaultReader = encodedTextStream.getReader(); (async function() { while(true) { const { done, value } = await readableStreamDefaultReader.read(); if (done) { break; } else { console.log(value); } } })(); // Uint8Array[102] // Uint8Array[111] // Uint8Array[111]
- TextEncoderStream 其實就是 TransformStream 形式的 TextEncoder。將解碼后的文本流通過管道輸入流編碼器會得到編碼后文本塊的流:
-
文本解碼
- Encoding API 提供了兩種將定型數組轉換為字符串的方式:批量解碼和流解碼。與編碼器類不同,在將定型數組轉換為字符串時,解碼器支持非常多的字符串編碼,可以參考 Encoding Standard 規范的“Names and labels”一節。
- 默認字符編碼格式是 UTF-8。
-
批量解碼
- 所謂批量,指的是 JavaScript 引擎會同步解碼整個字符串。對于非常長的字符串,可能會花較長時間。批量解碼是通過 TextDecoder 的實例完成的:
const textDecoder = new TextDecoder();
- 這個實例上有一個 decode()方法,該方法接收一個定型數組參數,返回解碼后的字符串:
const textDecoder = new TextDecoder(); // f 的 UTF-8 編碼是 0x66(即十進制 102) // o 的 UTF-8 編碼是 0x6F(即二進制 111) const encodedText = Uint8Array.of(102, 111, 111); const decodedText = textDecoder.decode(encodedText); console.log(decodedText); // foo
- 解碼器不關心傳入的是哪種定型數組,它只會專心解碼整個二進制表示。在下面這個例子中,只包含 8 位字符的 32 位值被解碼為 UTF-8 格式,解碼得到的字符串中填充了空格:
const textDecoder = new TextDecoder(); // f 的 UTF-8 編碼是 0x66(即十進制 102) // o 的 UTF-8 編碼是 0x6F(即二進制 111) const encodedText = Uint32Array.of(102, 111, 111); const decodedText = textDecoder.decode(encodedText); console.log(decodedText); // "f o o "
- 解碼器是用于處理定型數組中分散在多個索引上的字符的,包括表情符號:
const textDecoder = new TextDecoder(); // ?的 UTF-8 編碼是 0xF0 0x9F 0x98 0x8A(即十進制 240、159、152、138) const encodedText = Uint8Array.of(240, 159, 152, 138); const decodedText = textDecoder.decode(encodedText); console.log(decodedText); // ?
- 與 TextEncoder 不同,TextDecoder 可以兼容很多字符編碼。比如下面的例子就使用了 UTF-16而非默認的 UTF-8:
const textDecoder = new TextDecoder('utf-16'); // f 的 UTF-8 編碼是 0x0066(即十進制 102) // o 的 UTF-8 編碼是 0x006F(即二進制 111) const encodedText = Uint16Array.of(102, 111, 111); const decodedText = textDecoder.decode(encodedText); console.log(decodedText); // foo
- 所謂批量,指的是 JavaScript 引擎會同步解碼整個字符串。對于非常長的字符串,可能會花較長時間。批量解碼是通過 TextDecoder 的實例完成的:
-
流解碼
- TextDecoderStream 其實就是 TransformStream 形式的 TextDecoder。將編碼后的文本流通過管道輸入流解碼器會得到解碼后文本塊的流:
async function* chars() { // 每個塊必須是一個定型數組const encodedText = [102, 111, 111].map((x) => Uint8Array.of(x)); for (let char of encodedText) { yield await new Promise((resolve) => setTimeout(resolve, 1000, char)); } } const encodedTextStream = new ReadableStream({ async start(controller) { for await (let chunk of chars()) { controller.enqueue(chunk); } controller.close(); } }); const decodedTextStream = encodedTextStream.pipeThrough(new TextDecoderStream()); const readableStreamDefaultReader = decodedTextStream.getReader(); (async function() { while(true) { const { done, value } = await readableStreamDefaultReader.read(); if (done) { break; } else { console.log(value); } } })(); // f // o // o
- 文本解碼器流能夠識別可能分散在不同塊上的代理對。解碼器流會保持塊片段直到取得完整的字符。比如在下面的例子中,流解碼器在解碼流并輸出字符之前會等待傳入 4 個塊:
async function* chars() { // ?的 UTF-8 編碼是 0xF0 0x9F 0x98 0x8A(即十進制 240、159、152、138)const encodedText = [240, 159, 152, 138].map((x) => Uint8Array.of(x)); for (let char of encodedText) { yield await new Promise((resolve) => setTimeout(resolve, 1000, char)); } } const encodedTextStream = new ReadableStream({ async start(controller) { for await (let chunk of chars()) { controller.enqueue(chunk); } controller.close(); } }); const decodedTextStream = encodedTextStream.pipeThrough(new TextDecoderStream()); const readableStreamDefaultReader = decodedTextStream.getReader(); (async function() { while(true) { const { done, value } = await readableStreamDefaultReader.read(); if (done) { break; } else { console.log(value); } } })(); // ?
- 文本解碼器流經常與 fetch()一起使用,因為響應體可以作為 ReadableStream 來處理。比如:
const response = await fetch(url); const stream = response.body.pipeThrough(new TextDecoderStream()); const decodedStream = stream.getReader() for await (let decodedChunk of decodedStream) { console.log(decodedChunk); }
- TextDecoderStream 其實就是 TransformStream 形式的 TextDecoder。將編碼后的文本流通過管道輸入流解碼器會得到解碼后文本塊的流:
-
File API 與 Blob API
- Web 應用程序的一個主要的痛點是無法操作用戶計算機上的文件。2000 年之前,處理文件的唯一方式是把
<input type="file">
放到一個表單里,僅此而已。File API 與 Blob API 是為了讓 Web 開發者能以安全的方式訪問客戶端機器上的文件,從而更好地與這些文件交互而設計的。
- Web 應用程序的一個主要的痛點是無法操作用戶計算機上的文件。2000 年之前,處理文件的唯一方式是把
-
File 類型
- File API 仍然以表單中的文件輸入字段為基礎,但是增加了直接訪問文件信息的能力。HTML5 在DOM 上為文件輸入元素添加了 files 集合。當用戶在文件字段中選擇一個或多個文件時,這個 files集合中會包含一組 File 對象,表示被選中的文件。每個 File 對象都有一些只讀屬性。
- name:本地系統中的文件名。
- size:以字節計的文件大小。
- type:包含文件 MIME 類型的字符串。
- lastModifiedDate:表示文件最后修改時間的字符串。這個屬性只有 Chome 實現了。
- 例如,通過監聽 change 事件然后遍歷 files 集合可以取得每個選中文件的信息:
let filesList = document.getElementById("files-list"); filesList.addEventListener("change", (event) => { let files = event.target.files, i = 0, len = files.length; while (i < len) { const f = files[i]; console.log(`${f.name} (${f.type}, ${f.size} bytes)`); i++; } });
- 這個例子簡單地在控制臺輸出了每個文件的信息。僅就這個能力而言,已經可以說是 Web 應用向前邁進的一大步了。不過,File API 還提供了 FileReader 類型,讓我們可以實際從文件中讀取數據。
- File API 仍然以表單中的文件輸入字段為基礎,但是增加了直接訪問文件信息的能力。HTML5 在DOM 上為文件輸入元素添加了 files 集合。當用戶在文件字段中選擇一個或多個文件時,這個 files集合中會包含一組 File 對象,表示被選中的文件。每個 File 對象都有一些只讀屬性。
-
FileReader 類型
- FileReader類型表示一種異步文件讀取機制。可以把FileReader 想象成類似于XMLHttpRequest,只不過是用于從文件系統讀取文件,而不是從服務器讀取數據。FileReader 類型提供了幾個讀取文件數據的方法。
- readAsText(file, encoding):從文件中讀取純文本內容并保存在 result 屬性中。第二個參數表示編碼,是可選的。
- readAsDataURL(file):讀取文件并將內容的數據 URI 保存在 result 屬性中。
- readAsBinaryString(file):讀取文件并將每個字符的二進制數據保存在 result 屬性中。
- readAsArrayBuffer(file):讀取文件并將文件內容以 ArrayBuffer 形式保存在 result 屬性。
- 這些讀取數據的方法為處理文件數據提供了極大的靈活性。例如,為了向用戶顯示圖片,可以將圖片讀取為數據 URI,而為了解析文件內容,可以將文件讀取為文本。
- 因為這些讀取方法是異步的,所以每個 FileReader 會發布幾個事件,其中 3 個最有用的事件是progress、error 和 load,分別表示還有更多數據、發生了錯誤和讀取完成。
- progress 事件每 50 毫秒就會觸發一次,其與 XHR 的 progress 事件具有相同的信息:lengthComputable、loaded 和 total。此外,在 progress 事件中可以讀取 FileReader 的 result屬性,即使其中尚未包含全部數據。
- error 事件會在由于某種原因無法讀取文件時觸發。觸發 error 事件時,FileReader 的 error屬性會包含錯誤信息。這個屬性是一個對象,只包含一個屬性:code。這個錯誤碼的值可能是 1(未找到文件)、2(安全錯誤)、3(讀取被中斷)、4(文件不可讀)或 5(編碼錯誤)。
- load 事件會在文件成功加載后觸發。如果 error 事件被觸發,則不會再觸發 load 事件。下面的例子演示了所有這 3 個事件:
let filesList = document.getElementById("files-list"); filesList.addEventListener("change", (event) => { let info = "", output = document.getElementById("output"), progress = document.getElementById("progress"), files = event.target.files, type = "default", reader = new FileReader(); if (/image/.test(files[0].type)) { reader.readAsDataURL(files[0]); type = "image"; } else { reader.readAsText(files[0]); type = "text"; } reader.onerror = function() { output.innerHTML = "Could not read file, error code is " + reader.error.code; }; reader.onprogress = function(event) { if (event.lengthComputable) { progress.innerHTML = `${event.loaded}/${event.total}`; } }; reader.onload = function() { let html = ""; switch(type) { case "image": html = `<img src="${reader.result}">`; break;case "text": html = reader.result; break; } output.innerHTML = html; }; });
- 以上代碼從表單字段中讀取一個文件,并將其內容顯示在了網頁上。如果文件的 MIME 類型表示它是一個圖片,那么就將其讀取后保存為數據 URI,在 load 事件觸發時將數據 URI 作為圖片插入頁面中。如果文件不是圖片,則讀取后將其保存為文本并原樣輸出到網頁上。progress 事件用于跟蹤和顯示讀取文件的進度,而 error 事件用于監控錯誤。
- 如果想提前結束文件讀取,則可以在過程中調用 abort()方法,從而觸發 abort 事件。在 load、error 和 abort 事件觸發后,還會觸發 loadend 事件。loadend 事件表示在上述 3 種情況下,所有讀取操作都已經結束。readAsText()和 readAsDataURL()方法已經得到了所有主流瀏覽器支持。
- FileReader類型表示一種異步文件讀取機制。可以把FileReader 想象成類似于XMLHttpRequest,只不過是用于從文件系統讀取文件,而不是從服務器讀取數據。FileReader 類型提供了幾個讀取文件數據的方法。
-
FileReaderSync 類型
- 顧名思義,FileReaderSync 類型就是 FileReader 的同步版本。這個類型擁有與 FileReader相同的方法,只有在整個文件都加載到內存之后才會繼續執行。FileReaderSync 只在工作線程中可用,因為如果讀取整個文件耗時太長則會影響全局。
- 假設通過 postMessage()向工作線程發送了一個 File 對象。以下代碼會讓工作線程同步將文件讀取到內存中,然后將文件的數據 URL 發回來:
// worker.js self.omessage = (messageEvent) => { const syncReader = new FileReaderSync();console.log(syncReader); // FileReaderSync {} // 讀取文件時阻塞工作線程const result = syncReader.readAsDataUrl(messageEvent.data);// PDF 文件的示例響應console.log(result); // data:application/pdf;base64,JVBERi0xLjQK... // 把 URL 發回去self.postMessage(result); };
-
Blob 與部分讀取
- 某些情況下,可能需要讀取部分文件而不是整個文件。為此,File 對象提供了一個名為 slice()的方法。slice()方法接收兩個參數:起始字節和要讀取的字節數。這個方法返回一個 Blob 的實例,而 Blob 實際上是 File 的超類。
- blob 表示二進制大對象(binary larget object),是 JavaScript 對不可修改二進制數據的封裝類型。包含字符串的數組、ArrayBuffers、ArrayBufferViews,甚至其他 Blob 都可以用來創建 blob。Blob構造函數可以接收一個 options 參數,并在其中指定 MIME 類型:
console.log(new Blob(['foo'])); // Blob {size: 3, type: ""} console.log(new Blob(['{"a": "b"}'], { type: 'application/json' })); // {size: 10, type: "application/json"} console.log(new Blob(['<p>Foo</p>', '<p>Bar</p>'], { type: 'text/html' })); // {size: 20, type: "text/html"}
- Blob 對象有一個 size 屬性和一個 type 屬性,還有一個 slice()方法用于進一步切分數據。另外也可以使用 FileReader 從 Blob 中讀取數據。下面的例子只會讀取文件的前 32 字節:
let filesList = document.getElementById("files-list"); filesList.addEventListener("change", (event) => { let info = "", output = document.getElementById("output"), progress = document.getElementById("progress"), files = event.target.files, reader = new FileReader(), blob = blobSlice(files[0], 0, 32); if (blob) { reader.readAsText(blob); reader.onerror = function() { output.innerHTML = "Could not read file, error code is " + reader.error.code; }; reader.onload = function() { output.innerHTML = reader.result; }; } else { console.log("Your browser doesn't support slice()."); } });
- 只讀取部分文件可以節省時間,特別是在只需要數據特定部分比如文件頭的時候。
-
對象 URL 與 Blob
- 對象 URL 有時候也稱作 Blob URL,是指引用存儲在 File 或 Blob 中數據的 URL。對象 URL 的優點是不用把文件內容讀取到 JavaScript 也可以使用文件。只要在適當位置提供對象 URL 即可。要創建對象 URL,可以使用 window.URL.createObjectURL()方法并傳入 File 或 Blob 對象。這個函數返回的值是一個指向內存中地址的字符串。因為這個字符串是 URL,所以可以在 DOM 中直接使用。例如,以下代碼使用對象 URL 在頁面中顯示了一張圖片:
let filesList = document.getElementById("files-list"); filesList.addEventListener("change", (event) => { let info = "", output = document.getElementById("output"), progress = document.getElementById("progress"), files = event.target.files, reader = new FileReader(), url = window.URL.createObjectURL(files[0]); if (url) { if (/image/.test(files[0].type)) { output.innerHTML = `<img src="${url}">`;} else { output.innerHTML = "Not an image."; } } else { output.innerHTML = "Your browser doesn't support object URLs."; } });
- 如果把對象 URL 直接放到
<img>
標簽,就不需要把數據先讀到 JavaScript 中了。<img>
標簽可以直接從相應的內存位置把數據讀取到頁面上。 - 使用完數據之后,最好能釋放與之關聯的內存。只要對象 URL 在使用中,就不能釋放內存。如果想表明不再使用某個對象 URL,則可以把它傳給 window.URL.revokeObjectURL()。頁面卸載時,所有對象 URL 占用的內存都會被釋放。不過,最好在不使用時就立即釋放內存,以便盡可能保持頁面占用最少資源。
- 對象 URL 有時候也稱作 Blob URL,是指引用存儲在 File 或 Blob 中數據的 URL。對象 URL 的優點是不用把文件內容讀取到 JavaScript 也可以使用文件。只要在適當位置提供對象 URL 即可。要創建對象 URL,可以使用 window.URL.createObjectURL()方法并傳入 File 或 Blob 對象。這個函數返回的值是一個指向內存中地址的字符串。因為這個字符串是 URL,所以可以在 DOM 中直接使用。例如,以下代碼使用對象 URL 在頁面中顯示了一張圖片:
-
讀取拖放文件
- 組合使用 HTML5 拖放 API 與 File API 可以創建讀取文件信息的有趣功能。在頁面上創建放置目標后,可以從桌面上把文件拖動并放到放置目標。這樣會像拖放圖片或鏈接一樣觸發 drop 事件。被放置的文件可以通過事件的 event.dataTransfer.files 屬性讀到,這個屬性保存著一組 File 對象,就像文本輸入字段一樣。
- 下面的例子會把拖放到頁面放置目標上的文件信息打印出來:
let droptarget = document.getElementById("droptarget"); function handleEvent(event) { let info = "", output = document.getElementById("output"), files, i, len; event.preventDefault(); if (event.type == "drop") { files = event.dataTransfer.files; i = 0; len = files.length; while (i < len) { info += `${files[i].name} (${files[i].type}, ${files[i].size} bytes)<br>`; i++; } output.innerHTML = info; } } droptarget.addEventListener("dragenter", handleEvent); droptarget.addEventListener("dragover", handleEvent); droptarget.addEventListener("drop", handleEvent);
- 與后面要介紹的拖放的例子一樣,必須取消 dragenter、dragover 和 drop 的默認行為。在drop 事件處理程序中,可以通過 event.dataTransfer.files 讀到文件,此時可以獲取文件的相關信息。
-
媒體元素
- 隨著嵌入音頻和視頻元素在 Web 應用上的流行,大多數內容提供商會強迫使用 Flash 以便達到最佳的跨瀏覽器兼容性。HTML5 新增了兩個與媒體相關的元素,即
<audio>
和<video>
,從而為瀏覽器提供了嵌入音頻和視頻的統一解決方案。 - 這兩個元素既支持 Web 開發者在頁面中嵌入媒體文件,也支持 JavaScript 實現對媒體的自定義控制。以下是它們的用法:
<!-- 嵌入視頻 --> <video src="conference.mpg" id="myVideo">Video player not available.</video> <!-- 嵌入音頻 --> <audio src="song.mp3" id="myAudio">Audio player not available.</audio>
- 每個元素至少要求有一個 src 屬性,以表示要加載的媒體文件。我們也可以指定表示視頻播放器大小的 width 和 height 屬性,以及在視頻加載期間顯示圖片 URI 的 poster 屬性。另外,controls屬性如果存在,則表示瀏覽器應該顯示播放界面,讓用戶可以直接控制媒體。開始和結束標簽之間的內容是在媒體播放器不可用時顯示的替代內容。
- 由于瀏覽器支持的媒體格式不同,因此可以指定多個不同的媒體源。為此,需要從元素中刪除 src屬性,使用一個或多個
<source>
元素代替,如下面的例子所示:<!-- 嵌入視頻 --> <video id="myVideo"> <source src="conference.webm" type="video/webm; codecs='vp8, vorbis'"> <source src="conference.ogv" type="video/ogg; codecs='theora, vorbis'"> <source src="conference.mpg"> Video player not available. </video> <!-- 嵌入音頻 --> <audio id="myAudio"> <source src="song.ogg" type="audio/ogg"> <source src="song.mp3" type="audio/mpeg"> Audio player not available. </audio>
- 討論不同音頻和視頻的編解碼器超出了本書范疇,但瀏覽器支持的編解碼器確實可能有所不同,因此指定多個源文件通常是必需的。
- 隨著嵌入音頻和視頻元素在 Web 應用上的流行,大多數內容提供商會強迫使用 Flash 以便達到最佳的跨瀏覽器兼容性。HTML5 新增了兩個與媒體相關的元素,即
-
屬性
-
<video>
和<audio>
元素提供了穩健的 JavaScript 接口。這兩個元素有很多共有屬性,可以用于確定媒體的當前狀態,如下表所示。屬性 數據類型 說明 autoplay Boolean 取得或設置 autoplay 標簽 buffered TimeRanges 對象,表示已下載緩沖的時間范圍 bufferedBytes ByteRanges 對象,表示已下載緩沖的字節范圍 bufferingRate Integer 平均每秒下載的位數 bufferingThrottled Boolean 表示緩沖是否被瀏覽器截流 controls Boolean 取得或設置 controls 屬性,用于顯示或隱藏瀏覽器內置控件 currentLoop Integer 媒體已經播放的循環次數 currentSrc String 當前播放媒體的 URL currentTime Float 已經播放的秒數 defaultPlaybackRate Float 取得或設置默認回放速率。默認為 1.0 秒 duration Float 媒體的總秒數 ended Boolean 表示媒體是否播放完成 loop Boolean 取得或設置媒體是否應該在播放完再循環開始 muted Boolean 取得或設置媒體是否靜音 networkState Integer 表示媒體當前網絡連接狀態。0 表示空,1 表示加載中,2 表示加載元數據,3 表示加載了第一幀,4 表示加載完成 paused Boolean 表示播放器是否暫停 playbackRate Float 取得或設置當前播放速率。用戶可能會讓媒體播放快一些或慢一些。與defaultPlaybackRate 不同,該屬性會保持不變,除非開發者修改 played TimeRanges 到目前為止已經播放的時間范圍 readyState Integer 表示媒體是否已經準備就緒。0 表示媒體不可用,1 表示可以顯示當前幀,2 表示媒體可以開始播放,3 表示媒體可以從頭播到尾 seekable TimeRanges 可以跳轉的時間范圍 seeking Boolean 表示播放器是否正移動到媒體文件的新位置 src String 媒體文件源。可以在任何時候重寫 start Float 取得或設置媒體文件中的位置,以秒為單位,從該處開始播放 totalBytes Integer 資源需要的字節總數(如果知道的話) videoHeight Integer 返回視頻(不一定是元素)的高度。只適用于 <video>
videoWidth Integer 返回視頻(不一定是元素)的寬度。只適用于 <video>
volume Float 取得或設置當前音量,值為 0.0 到 1.0 -
上述很多屬性也可以在
<audio>
或<video>
標簽上設置。
-
-
事件
-
除了有很多屬性,媒體元素還有很多事件。這些事件會監控由于媒體回放或用戶交互導致的不同屬性的變化。下表列出了這些事件。
事件 何時觸發 abort 下載被中斷 canplay 回放可以開始,readyState 為 2 canplaythrough 回放可以繼續,不應該中斷,readState 為 3 canshowcurrentframe 已經下載當前幀,readyState 為 1 dataunavailable 不能回放,因為沒有數據,readyState 為 0 durationchange duration 屬性的值發生變化 emptied 網絡連接關閉了 empty 發生了錯誤,阻止媒體下載 ended 媒體已經播放完一遍,且停止了 error 下載期間發生了網絡錯誤 load 所有媒體已經下載完畢。這個事件已被廢棄,使用 canplaythrough 代替 loadeddata 媒體的第一幀已經下載 loadedmetadata 媒體的元數據已經下載 loadstart 下載已經開始 pause 回放已經暫停 play 媒體已經收到開始播放的請求 playing 媒體已經實際開始播放了 progress 下載中 ratechange 媒體播放速率發生變化 seeked 跳轉已結束 seeking 回放已移動到新位置 stalled 瀏覽器嘗試下載,但尚未收到數據 timeupdate currentTime 被非常規或意外地更改了 volumechange volume 或 muted 屬性值發生了變化 waiting 回放暫停,以下載更多數據 -
這些事件被設計得盡可能具體,以便 Web 開發者能夠使用較少的 HTML 和 JavaScript 創建自定義的音頻/視頻播放器(而不是創建新 Flash 影片)。
-
-
自定義媒體播放器
- 使用
<audio>
和<video>
的 play()和 pause()方法,可以手動控制媒體文件的播放。綜合使用屬性、事件和這些方法,可以方便地創建自定義的媒體播放器,如下面的例子所示:<div class="mediaplayer"> <div class="video"> <video id="player" src="movie.mov" poster="mymovie.jpg" width="300" height="200"> Video player not available. </video> </div> <div class="controls"> <input type="button" value="Play" id="video-btn"> <span id="curtime">0</span>/<span id="duration">0</span> </div> </div>
- 通過使用 JavaScript 創建一個簡單的視頻播放器,上面這個基本的 HTML 就可以被激活了,如下所示:
// 取得元素的引用 let player = document.getElementById("player"), btn = document.getElementById("video-btn"), curtime = document.getElementById("curtime"), duration = document.getElementById("duration"); // 更新時長 duration.innerHTML = player.duration; // 為按鈕添加事件處理程序 btn.addEventListener( "click", (event) => { if (player.paused) { player.play(); btn.value = "Pause"; } else { player.pause(); btn.value = "Play"; } }); // 周期性更新當前時間 setInterval(() => { curtime.innerHTML = player.currentTime; }, 250);
- 這里的 JavaScript 代碼簡單地為按鈕添加了事件處理程序,可以根據當前狀態播放和暫停視頻。此外,還給
<video>
元素的 load 事件添加了事件處理程序,以便顯示視頻的時長。最后,重復的計時器用于更新當前時間。通過監聽更多事件以及使用更多屬性,可以進一步擴展這個自定義的視頻播放器。同樣的代碼也可以用于<audio>
元素以創建自定義的音頻播放器。
- 使用
-
檢測編解碼器
- 如前所述,并不是所有瀏覽器都支持
<video>
和<audio>
的所有編解碼器,這通常意味著必須提供多個媒體源。為此,也有 JavaScript API 可以用來檢測瀏覽器是否支持給定格式和編解碼器。這兩個媒體元素都有一個名為 canPlayType()的方法,該方法接收一個格式/編解碼器字符串,返回一個字符串值:“probably”、“maybe"或”"(空字符串),其中空字符串就是假值,意味著可以在 if 語句中像這樣使用 canPlayType():if (audio.canPlayType("audio/mpeg")) { // 執行某些操作 }
- "probably"和"maybe"都是真值,在 if 語句的上下文中可以轉型為 true。
- 在只給 canPlayType()提供一個 MIME 類型的情況下,最可能返回的值是"maybe"和空字符串。這是因為文件實際上只是一個包裝音頻和視頻數據的容器,而真正決定文件是否可以播放的是編碼。在同時提供 MIME 類型和編解碼器的情況下,返回值的可能性會提高到"probably"。下面是幾個例子:
let audio = document.getElementById("audio-player"); // 很可能是"maybe" if (audio.canPlayType("audio/mpeg")) { // 執行某些操作 } // 可能是"probably" if (audio.canPlayType("audio/ogg; codecs=\"vorbis\"")) { // 執行某些操作 }
- 注意,編解碼器必須放到引號中。同樣,也可以在視頻元素上使用 canPlayType()檢測視頻格式。
- 如前所述,并不是所有瀏覽器都支持
-
音頻類型
<audio>
元素還有一個名為 Audio 的原生 JavaScript 構造函數,支持在任何時候播放音頻。Audio類型與 Image 類似,都是 DOM 元素的對等體,只是不需插入文檔即可工作。要通過 Audio 播放音頻,只需創建一個新實例并傳入音頻源文件:let audio = new Audio("sound.mp3"); EventUtil.addHandler(audio, "canplaythrough", function(event) { audio.play(); });
- 創建 Audio 的新實例就會開始下載指定的文件。下載完畢后,可以調用 play()來播放音頻。在 iOS 中調用 play()方法會彈出一個對話框,請求用戶授權播放聲音。為了連續播放,必須在onfinish 事件處理程序中立即調用 play()。
-
原生拖放
- IE4 最早在網頁中為 JavaScript 引入了對拖放功能的支持。當時,網頁中只有兩樣東西可以觸發拖放:圖片和文本。拖動圖片就是簡單地在圖片上按住鼠標不放然后移動鼠標。而對于文本,必須先選中,然后再以同樣的方式拖動。在 IE4 中,唯一有效的放置目標是文本框。IE5 擴展了拖放能力,添加了新的事件,讓網頁中幾乎一切都可以成為放置目標。IE5.5 又進一步,允許幾乎一切都可以拖動(IE6 也支持這個功能)。HTML5 在 IE 的拖放實現基礎上標準化了拖放功能。所有主流瀏覽器都根據 HTML5 規范實現了原生的拖放。
- 關于拖放最有意思的可能就是可以跨窗格、跨瀏覽器容器,有時候甚至可以跨應用程序拖動元素。瀏覽器對拖放的支持可以讓我們實現這些功能。
-
拖放事件
- 拖放事件幾乎可以讓開發者控制拖放操作的方方面面。關鍵的部分是確定每個事件是在哪里觸發的。有的事件在被拖放元素上觸發,有的事件則在放置目標上觸發。在某個元素被拖動時,會(按順序)觸發以下事件:
- (1) dragstart
- (2) drag
- (3) dragend
- 在按住鼠標鍵不放并開始移動鼠標的那一刻,被拖動元素上會觸發 dragstart 事件。此時光標會變成非放置符號(圓環中間一條斜杠),表示元素不能放到自身上。拖動開始時,可以在 ondragstart事件處理程序中通過 JavaScript 執行某些操作。
- dragstart 事件觸發后,只要目標還被拖動就會持續觸發 drag 事件。這個事件類似于 mousemove,即隨著鼠標移動而不斷觸發。當拖動停止時(把元素放到有效或無效的放置目標上),會觸發 dragend事件。
- 所有這 3 個事件的目標都是被拖動的元素。默認情況下,瀏覽器在拖動開始后不會改變被拖動元素的外觀,因此是否改變外觀由你來決定。不過,大多數瀏覽器此時會創建元素的一個半透明副本,始終跟隨在光標下方。
- 在把元素拖動到一個有效的放置目標上時,會依次觸發以下事件:
- (1) dragenter
- (2) dragover
- (3) dragleave 或 drop
- 只要一把元素拖動到放置目標上,dragenter 事件(類似于 mouseover 事件)就會觸發。dragenter事件觸發之后,會立即觸發 dragover 事件,并且元素在放置目標范圍內被拖動期間此事件會持續觸發。當元素被拖動到放置目標之外,dragover 事件停止觸發,dragleave 事件觸發(類似于 mouseout事件)。如果被拖動元素被放到了目標上,則會觸發 drop 事件而不是 dragleave 事件。這些事件的目標是放置目標元素。
- 拖放事件幾乎可以讓開發者控制拖放操作的方方面面。關鍵的部分是確定每個事件是在哪里觸發的。有的事件在被拖放元素上觸發,有的事件則在放置目標上觸發。在某個元素被拖動時,會(按順序)觸發以下事件:
-
自定義放置目標
- 在把某個元素拖動到無效放置目標上時,會看到一個特殊光標(圓環中間一條斜杠)表示不能放下。即使所有元素都支持放置目標事件,這些元素默認也是不允許放置的。如果把元素拖動到不允許放置的目標上,無論用戶動作是什么都不會觸發 drop 事件。不過,通過覆蓋 dragenter 和 dragover 事件的默認行為,可以把任何元素轉換為有效的放置目標。例如,如果有一個 ID 為"droptarget"的
<div>
元素,那么可以使用以下代碼把它轉換成一個放置目標:let droptarget = document.getElementById("droptarget"); droptarget.addEventListener("dragover", (event) => { event.preventDefault(); }); droptarget.addEventListener("dragenter", (event) => { event.preventDefault(); });
- 執行上面的代碼之后,把元素拖動到這個
<div>
上應該可以看到光標變成了允許放置的樣子。另外,drop 事件也會觸發。 - 在 Firefox 中,放置事件的默認行為是導航到放在放置目標上的 URL。這意味著把圖片拖動到放置目標上會導致頁面導航到圖片文件,把文本拖動到放置目標上會導致無效 URL 錯誤。為阻止這個行為,在 Firefox 中必須取消 drop 事件的默認行為:
droptarget.addEventListener("drop", (event) => { event.preventDefault(); });
- 在把某個元素拖動到無效放置目標上時,會看到一個特殊光標(圓環中間一條斜杠)表示不能放下。即使所有元素都支持放置目標事件,這些元素默認也是不允許放置的。如果把元素拖動到不允許放置的目標上,無論用戶動作是什么都不會觸發 drop 事件。不過,通過覆蓋 dragenter 和 dragover 事件的默認行為,可以把任何元素轉換為有效的放置目標。例如,如果有一個 ID 為"droptarget"的
-
dataTransfer 對象
- 除非數據受影響,否則簡單的拖放并沒有實際意義。為實現拖動操作中的數據傳輸,IE5 在 event對象上暴露了 dataTransfer 對象,用于從被拖動元素向放置目標傳遞字符串數據。因為這個對象是event 的屬性,所以在拖放事件的事件處理程序外部無法訪問 dataTransfer。在事件處理程序內部,可以使用這個對象的屬性和方法實現拖放功能。dataTransfer 對象現在已經納入了 HTML5 工作草案。
- dataTransfer 對象有兩個主要方法:getData()和 setData()。顧名思義,getData()用于獲取 setData()存儲的值。setData()的第一個參數以及 getData()的唯一參數是一個字符串,表示要設置的數據類型:“text"或"URL”,如下所示:
// 傳遞文本 event.dataTransfer.setData("text", "some text"); let text = event.dataTransfer.getData("text"); // 傳遞 URL event.dataTransfer.setData("URL", "http://www.wrox.com/"); let url = event.dataTransfer.getData("URL");
- 雖然這兩種數據類型是 IE 最初引入的,但 HTML5 已經將其擴展為允許任何 MIME 類型。為向后兼容,HTML5還會繼續支持"text"和"URL",但它們會分別被映射到"text/plain"和"text/uri-list"。
- dataTransfer 對象實際上可以包含每種 MIME 類型的一個值,也就是說可以同時保存文本和URL,兩者不會相互覆蓋。存儲在 dataTransfer 對象中的數據只能在放置事件中讀取。如果沒有在ondrop 事件處理程序中取得這些數據,dataTransfer 對象就會被銷毀,數據也會丟失。
- 在從文本框拖動文本時,瀏覽器會調用 setData()并將拖動的文本以"text"格式存儲起來。類似地,在拖動鏈接或圖片時,瀏覽器會調用 setData()并把 URL 存儲起來。當數據被放置在目標上時,可以使用 getData()獲取這些數據。當然,可以在 dragstart 事件中手動調用 setData()存儲自定義數據,以便將來使用。
- 作為文本的數據和作為 URL 的數據有一個區別。當把數據作為文本存儲時,數據不會被特殊對待。而當把數據作為 URL 存儲時,數據會被作為網頁中的一個鏈接,意味著如果把它放到另一個瀏覽器窗口,瀏覽器會導航到該 URL。
- 直到版本 5,Firefox都不能正確地把"url"映射為"text/uri-list"或把"text"映射為"text/plain"。不過,它可以把"Text"(第一個字母大寫)正確映射為"text/plain"。在通過 dataTransfer 獲取數據時,為保持最大兼容性,需要對 URL 檢測兩個值并對文本使用"Text":
let dataTransfer = event.dataTransfer; // 讀取 URL let url = dataTransfer.getData("url") || dataTransfer.getData("text/uri-list"); // 讀取文本 let text = dataTransfer.getData("Text");
- 這里要注意,首先應該嘗試短數據名。這是因為直到版本 10,IE 都不支持擴展的類型名,而且會在遇到無法識別的類型名時拋出錯誤。
-
dropEffect 與 effectAllowed
- dataTransfer 對象不僅可以用于實現簡單的數據傳輸,還可以用于確定能夠對被拖動元素和放置目標執行什么操作。為此,可以使用兩個屬性:dropEffect 與 effectAllowed。
- dropEffect 屬性可以告訴瀏覽器允許哪種放置行為。這個屬性有以下 4 種可能的值。
- “none”:被拖動元素不能放到這里。這是除文本框之外所有元素的默認值。
- “move”:被拖動元素應該移動到放置目標。
- “copy”:被拖動元素應該復制到放置目標。
- “link”:表示放置目標會導航到被拖動元素(僅在它是 URL 的情況下)。
- 在把元素拖動到放置目標上時,上述每種值都會導致顯示一種不同的光標。不過,是否導致光標示意的動作還要取決于開發者。換句話說,如果沒有代碼參與,則沒有什么會自動移動、復制或鏈接。唯一不用考慮的就是光標自己會變。為了使用 dropEffect 屬性,必須在放置目標的 ondragenter 事件處理程序中設置它。
- 除非同時設置 effectAllowed,否則 dropEffect 屬性也沒有用。effectAllowed 屬性表示對被拖動元素是否允許 dropEffect。這個屬性有如下幾個可能的值。
- “uninitialized”:沒有給被拖動元素設置動作。
- “none”:被拖動元素上沒有允許的操作。
- “copy”:只允許"copy"這種 dropEffect。
- “link”:只允許"link"這種 dropEffect。
- “move”:只允許"move"這種 dropEffect。
- “copyLink”:允許"copy"和"link"兩種 dropEffect。
- “copyMove”:允許"copy"和"move"兩種 dropEffect。
- “linkMove”:允許"link"和"move"兩種 dropEffect。
- “all”:允許所有 dropEffect。
- 必須在 ondragstart 事件處理程序中設置這個屬性。
- 假設我們想允許用戶把文本從一個文本框拖動到一個
<div>
元素。那么必須同時把 dropEffect 和effectAllowed 屬性設置為"move"。因為<div>
元素上放置事件的默認行為是什么也不做,所以文本不會自動地移動自己。如果覆蓋這個默認行為,文本就會自動從文本框中被移除。然后是否把文本插入<div>
元素就取決于你了。如果是把 dropEffect 和 effectAllowed 屬性設置為"copy",那么文本框中的文本不會自動被移除。
-
可拖動能力
- 默認情況下,圖片、鏈接和文本是可拖動的,這意味著無須額外代碼用戶便可以拖動它們。文本只有在被選中后才可以拖動,而圖片和鏈接在任意時候都是可以拖動的。
- 我們也可以讓其他元素變得可以拖動。HTML5 在所有 HTML 元素上規定了一個 draggable 屬性,表示元素是否可以拖動。圖片和鏈接的 draggable 屬性自動被設置為 true,而其他所有元素此屬性的默認值為 false。如果想讓其他元素可拖動,或者不允許圖片和鏈接被拖動,都可以設置這個屬性。例如:
<!-- 禁止拖動圖片 --> <img src="smile.gif" draggable="false" alt="Smiley face"> <!-- 讓元素可以拖動 --> <div draggable="true">...</div>
-
其他成員
- HTML5 規范還為 dataTransfer 對象定義了下列方法。
- addElement(element):為拖動操作添加元素。這純粹是為了傳輸數據,不會影響拖動操作的外觀。在本書寫作時,還沒有瀏覽器實現這個方法。
- clearData(format):清除以特定格式存儲的數據。
- setDragImage(element, x, y):允許指定拖動發生時顯示在光標下面的圖片。這個方法接收 3 個參數:要顯示的 HTML 元素及標識光標位置的圖片上的 x 和 y 坐標。這里的 HTML 元素可以是一張圖片,此時顯示圖片;也可以是其他任何元素,此時顯示渲染后的元素。
- types:當前存儲的數據類型列表。這個集合類似數組,以字符串形式保存數據類型,比如"text"。
- HTML5 規范還為 dataTransfer 對象定義了下列方法。
-
Notifications API
- Notifications API 用于向用戶顯示通知。無論從哪個角度看,這里的通知都很類似 alert()對話框:都使用 JavaScript API 觸發頁面外部的瀏覽器行為,而且都允許頁面處理用戶與對話框或通知彈層的交互。不過,通知提供更靈活的自定義能力。
- Notifications API 在 Service Worker 中非常有用。漸進 Web 應用(PWA,Progressive Web Application)通過觸發通知可以在頁面不活躍時向用戶顯示消息,看起來就像原生應用。
-
通知權限
- Notifications API 有被濫用的可能,因此默認會開啟兩項安全措施:
- 通知只能在運行在安全上下文的代碼中被觸發;
- 通知必須按照每個源的原則明確得到用戶允許。
- 用戶授權顯示通知是通過瀏覽器內部的一個對話框完成的。除非用戶沒有明確給出允許或拒絕的答復,否則這個權限請求對每個域只會出現一次。瀏覽器會記住用戶的選擇,如果被拒絕則無法重來。
- 頁面可以使用全局對象 Notification 向用戶請求通知權限。這個對象有一個 requestPemission()方法,該方法返回一個期約,用戶在授權對話框上執行操作后這個期約會解決。
Notification.requestPermission() .then((permission) => { console.log('User responded to permission request:', permission); });
- “granted"值意味著用戶明確授權了顯示通知的權限。除此之外的其他值意味著顯示通知會靜默失敗。如果用戶拒絕授權,這個值就是"denied”。一旦拒絕,就無法通過編程方式挽回,因為不可能再觸發授權提示。
- Notifications API 有被濫用的可能,因此默認會開啟兩項安全措施:
-
顯示和隱藏通知
- Notification 構造函數用于創建和顯示通知。最簡單的通知形式是只顯示一個標題,這個標題內容可以作為第一個參數傳給 Notification 構造函數。以下面這種方式調用 Notification,應該會立即顯示通知:
new Notification('Title text!');
- 可以通過 options 參數對通知進行自定義,包括設置通知的主體、圖片和振動等:
new Notification('Title text!', {body: 'Body text!', image: 'path/to/image.png', vibrate: true });
- 調用這個構造函數返回的 Notification 對象的 close()方法可以關閉顯示的通知。下面的例子展示了顯示通知后 1000 毫秒再關閉它:
const n = new Notification('I will close in 1000ms'); setTimeout(() => n.close(), 1000);
- Notification 構造函數用于創建和顯示通知。最簡單的通知形式是只顯示一個標題,這個標題內容可以作為第一個參數傳給 Notification 構造函數。以下面這種方式調用 Notification,應該會立即顯示通知:
-
通知生命周期回調
- 通知并非只用于顯示文本字符串,也可用于實現交互。Notifications API 提供了 4 個用于添加回調的生命周期方法:
- onshow 在通知顯示時觸發;
- onclick 在通知被點擊時觸發;
- onclose 在通知消失或通過 close()關閉時觸發;
- onerror 在發生錯誤阻止通知顯示時觸發。
- 下面的代碼將每個生命周期事件都通過日志打印了出來:
const n = new Notification('foo'); n.onshow = () => console.log('Notification was shown!'); n.onclick = () => console.log('Notification was clicked!'); n.onclose = () => console.log('Notification was closed!'); n.onerror = () => console.log('Notification experienced an error!');
- 通知并非只用于顯示文本字符串,也可用于實現交互。Notifications API 提供了 4 個用于添加回調的生命周期方法:
-
Page Visibility API
- Web 開發中一個常見的問題是開發者不知道用戶什么時候真正在使用頁面。如果頁面被最小化或隱藏在其他標簽頁后面,那么輪詢服務器或更新動畫等功能可能就沒有必要了。Page Visibility API 旨在為開發者提供頁面對用戶是否可見的信息。
- 這個 API 本身非常簡單,由 3 部分構成。
- document.visibilityState 值,表示下面 4 種狀態之一。
- 頁面在后臺標簽頁或瀏覽器中最小化了。
- 頁面在前臺標簽頁中。
- 實際頁面隱藏了,但對頁面的預覽是可見的(例如在 Windows 7 上,用戶鼠標移到任務欄圖標上會顯示網頁預覽)。
- 頁面在屏外預渲染。
- visibilitychange 事件,該事件會在文檔從隱藏變可見(或反之)時觸發。
- document.hidden 布爾值,表示頁面是否隱藏。這可能意味著頁面在后臺標簽頁或瀏覽器中被最小化了。這個值是為了向后兼容才繼續被瀏覽器支持的,應該優先使用 document.visibilityState檢測頁面可見性。
- 要想在頁面從可見變為隱藏或從隱藏變為可見時得到通知,需要監聽 visibilitychange 事件。
- document.visibilityState 的值是以下三個字符串之一:
- “hidden”
- “visible”
- “prerender”
-
Streams API
- Streams API 是為了解決一個簡單但又基礎的問題而生的:Web 應用如何消費有序的小信息塊而不是大塊信息?這種能力主要有兩種應用場景。
- 大塊數據可能不會一次性都可用。網絡請求的響應就是一個典型的例子。網絡負載是以連續信息包形式交付的,而流式處理可以讓應用在數據一到達就能使用,而不必等到所有數據都加載完畢。
- 大塊數據可能需要分小部分處理。視頻處理、數據壓縮、圖像編碼和 JSON 解析都是可以分成小部分進行處理,而不必等到所有數據都在內存中時再處理的例子。
- 第 24 章在討論網絡請求和遠程資源時會介紹 Streams API 在 fetch()中的應用,不過 Streams API本身是通用的。實現 Observable 接口的 JavaScript 庫共享了很多流的基礎概念。
- 注意,雖然 Fetch API已經得到所有主流瀏覽器支持,但 Streams API則沒有那么快得到支持。
-
理解流
- 提到流,可以把數據想像成某種通過管道輸送的液體。JavaScript 中的流借用了管道相關的概念,因為原理是相通的。根據規范,“這些 API 實際是為映射低級 I/O 原語而設計,包括適當時候對字節流的規范化”。Stream API 直接解決的問題是處理網絡請求和讀寫磁盤。
- Stream API 定義了三種流。
- 可讀流:可以通過某個公共接口讀取數據塊的流。數據在內部從底層源進入流,然后由消費者(consumer)進行處理。
- 可寫流:可以通過某個公共接口寫入數據塊的流。生產者(producer)將數據寫入流,數據在內部傳入底層數據槽(sink)。
- 轉換流:由兩種流組成,可寫流用于接收數據(可寫端),可讀流用于輸出數據(可讀端)。這兩個流之間是轉換程序(transformer),可以根據需要檢查和修改流內容。
- 塊、內部隊列和反壓
- 流的基本單位是塊(chunk)。塊可是任意數據類型,但通常是定型數組。每個塊都是離散的流片段,可以作為一個整體來處理。更重要的是,塊不是固定大小的,也不一定按固定間隔到達。在理想的流當中,塊的大小通常近似相同,到達間隔也近似相等。不過好的流實現需要考慮邊界情況。
- 前面提到的各種類型的流都有入口和出口的概念。有時候,由于數據進出速率不同,可能會出現不匹配的情況。為此流平衡可能出現如下三種情形。
- 流出口處理數據的速度比入口提供數據的速度快。流出口經常空閑(可能意味著流入口效率較低),但只會浪費一點內存或計算資源,因此這種流的不平衡是可以接受的。
- 流入和流出均衡。這是理想狀態。
- 流入口提供數據的速度比出口處理數據的速度快。這種流不平衡是固有的問題。此時一定會在某個地方出現數據積壓,流必須相應做出處理。
- 流不平衡是常見問題,但流也提供了解決這個問題的工具。所有流都會為已進入流但尚未離開流的塊提供一個內部隊列。對于均衡流,這個內部隊列中會有零個或少量排隊的塊,因為流出口塊出列的速度與流入口塊入列的速度近似相等。這種流的內部隊列所占用的內存相對比較小。
- 如果塊入列速度快于出列速度,則內部隊列會不斷增大。流不能允許其內部隊列無限增大,因此它會使用反壓(backpressure)通知流入口停止發送數據,直到隊列大小降到某個既定的閾值之下。這個閾值由排列策略決定,這個策略定義了內部隊列可以占用的最大內存,即高水位線(high water mark)。
-
可讀流
- 可讀流是對底層數據源的封裝。底層數據源可以將數據填充到流中,允許消費者通過流的公共接口讀取數據。
-
ReadableStreamDefaultController
- 來看下面的生成器,它每 1000 毫秒就會生成一個遞增的整數:
async function* ints() { // 每 1000 毫秒生成一個遞增的整數for (let i = 0; i < 5; ++i) { yield await new Promise((resolve) => setTimeout(resolve, 1000, i)); } }
- 這個生成器的值可以通過可讀流的控制器傳入可讀流。訪問這個控制器最簡單的方式就是創建ReadableStream 的一個實例,并在這個構造函數的 underlyingSource 參數(第一個參數)中定義start()方法,然后在這個方法中使用作為參數傳入的 controller。默認情況下,這個控制器參數是ReadableStreamDefaultController 的一個實例:
const readableStream = new ReadableStream({ start(controller) { console.log(controller); // ReadableStreamDefaultController {} } });
- 調用控制器的 enqueue()方法可以把值傳入控制器。所有值都傳完之后,調用 close()關閉流:
async function* ints() { // 每 1000 毫秒生成一個遞增的整數for (let i = 0; i < 5; ++i) { yield await new Promise((resolve) => setTimeout(resolve, 1000, i)); } } const readableStream = new ReadableStream({ async start(controller) { for await (let chunk of ints()) { controller.enqueue(chunk); } controller.close(); } });
- 來看下面的生成器,它每 1000 毫秒就會生成一個遞增的整數:
-
ReadableStreamDefaultReader
- 前面的例子把 5 個值加入了流的隊列,但沒有把它們從隊列中讀出來。為此,需要一個 ReadableStreamDefaultReader 的實例,該實例可以通過流的 getReader()方法獲取。調用這個方法會獲得流的鎖,保證只有這個讀取器可以從流中讀取值:
async function* ints() { // 每 1000 毫秒生成一個遞增的整數for (let i = 0; i < 5; ++i) { yield await new Promise((resolve) => setTimeout(resolve, 1000, i)); } } const readableStream = new ReadableStream({ async start(controller) { for await (let chunk of ints()) { controller.enqueue(chunk); } controller.close(); } }); console.log(readableStream.locked); // false const readableStreamDefaultReader = readableStream.getReader(); console.log(readableStream.locked); // true
- 消費者使用這個讀取器實例的 read()方法可以讀出值:
async function* ints() { // 每 1000 毫秒生成一個遞增的整數for (let i = 0; i < 5; ++i) { yield await new Promise((resolve) => setTimeout(resolve, 1000, i)); } } const readableStream = new ReadableStream({ async start(controller) { for await (let chunk of ints()) { controller.enqueue(chunk); } controller.close(); } }); console.log(readableStream.locked); // false const readableStreamDefaultReader = readableStream.getReader(); console.log(readableStream.locked); // true // 消費者 (async function() { while(true) { const { done, value } = await readableStreamDefaultReader.read(); if (done) { break; } else { console.log(value); } } })(); // 0 // 1 // 2 // 3 // 4
- 前面的例子把 5 個值加入了流的隊列,但沒有把它們從隊列中讀出來。為此,需要一個 ReadableStreamDefaultReader 的實例,該實例可以通過流的 getReader()方法獲取。調用這個方法會獲得流的鎖,保證只有這個讀取器可以從流中讀取值:
-
可寫流
- 可寫流是底層數據槽的封裝。底層數據槽處理通過流的公共接口寫入的數據。
-
創建 WritableStream
- 來看下面的生成器,它每 1000 毫秒就會生成一個遞增的整數:
async function* ints() { // 每 1000 毫秒生成一個遞增的整數for (let i = 0; i < 5; ++i) { yield await new Promise((resolve) => setTimeout(resolve, 1000, i)); } }
- 這些值通過可寫流的公共接口可以寫入流。在傳給 WritableStream 構造函數的 underlyingSink參數中,通過實現 write()方法可以獲得寫入的數據:
const readableStream = new ReadableStream({ write(value) { console.log(value); } });
- 來看下面的生成器,它每 1000 毫秒就會生成一個遞增的整數:
-
WritableStreamDefaultWriter
- 要把獲得的數據寫入流,可以通過流的 getWriter()方法獲取 WritableStreamDefaultWriter的實例。這樣會獲得流的鎖,確保只有一個寫入器可以向流中寫入數據:
async function* ints() { // 每 1000 毫秒生成一個遞增的整數for (let i = 0; i < 5; ++i) { yield await new Promise((resolve) => setTimeout(resolve, 1000, i)); } } const writableStream = new WritableStream({ write(value) { console.log(value); } }); console.log(writableStream.locked); // false const writableStreamDefaultWriter = writableStream.getWriter(); console.log(writableStream.locked); // true
- 在向流中寫入數據前,生產者必須確保寫入器可以接收值。writableStreamDefaultWriter.ready返回一個期約,此期約會在能夠向流中寫入數據時解決。然后,就可以把值傳給 writableStreamDefaultWriter.write()方法。寫入數據之后,調用 writableStreamDefaultWriter.close()將流關閉:
async function* ints() { // 每 1000 毫秒生成一個遞增的整數for (let i = 0; i < 5; ++i) { yield await new Promise((resolve) => setTimeout(resolve, 1000, i)); } } const writableStream = new WritableStream({write(value) { console.log(value); } }); console.log(writableStream.locked); // false const writableStreamDefaultWriter = writableStream.getWriter(); console.log(writableStream.locked); // true // 生產者 (async function() { for await (let chunk of ints()) { await writableStreamDefaultWriter.ready; writableStreamDefaultWriter.write(chunk); } writableStreamDefaultWriter.close(); })();
- 要把獲得的數據寫入流,可以通過流的 getWriter()方法獲取 WritableStreamDefaultWriter的實例。這樣會獲得流的鎖,確保只有一個寫入器可以向流中寫入數據:
-
轉換流
- 轉換流用于組合可讀流和可寫流。數據塊在兩個流之間的轉換是通過 transform()方法完成的。
- 來看下面的生成器,它每 1000 毫秒就會生成一個遞增的整數:
async function* ints() { // 每 1000 毫秒生成一個遞增的整數for (let i = 0; i < 5; ++i) { yield await new Promise((resolve) => setTimeout(resolve, 1000, i)); } }
- 下面的代碼創建了一個 TransformStream 的實例,通過 transform()方法將每個值翻倍:
async function* ints() { // 每 1000 毫秒生成一個遞增的整數for (let i = 0; i < 5; ++i) { yield await new Promise((resolve) => setTimeout(resolve, 1000, i)); } } const { writable, readable } = new TransformStream({ transform(chunk, controller) { controller.enqueue(chunk * 2); } });
- 向轉換流的組件流(可讀流和可寫流)傳入數據和從中獲取數據,與本章前面介紹的方法相同:
async function* ints() { // 每 1000 毫秒生成一個遞增的整數for (let i = 0; i < 5; ++i) { yield await new Promise((resolve) => setTimeout(resolve, 1000, i)); } } const { writable, readable } = new TransformStream({ transform(chunk, controller) { controller.enqueue(chunk * 2);} }); const readableStreamDefaultReader = readable.getReader(); const writableStreamDefaultWriter = writable.getWriter(); // 消費者 (async function() { while (true) { const { done, value } = await readableStreamDefaultReader.read(); if (done) { break; } else { console.log(value); } } })(); // 生產者 (async function() { for await (let chunk of ints()) { await writableStreamDefaultWriter.ready; writableStreamDefaultWriter.write(chunk); } writableStreamDefaultWriter.close(); })();
-
通過管道連接流
- 流可以通過管道連接成一串。最常見的用例是使用 pipeThrough()方法把 ReadableStream 接入TransformStream。從內部看,ReadableStream 先把自己的值傳給 TransformStream 內部的WritableStream,然后執行轉換,接著轉換后的值又在新的 ReadableStream 上出現。下面的例子將一個整數的 ReadableStream 傳入 TransformStream,TransformStream 對每個值做加倍處理:
async function* ints() { // 每 1000 毫秒生成一個遞增的整數for (let i = 0; i < 5; ++i) { yield await new Promise((resolve) => setTimeout(resolve, 1000, i)); } } const integerStream = new ReadableStream({ async start(controller) { for await (let chunk of ints()) { controller.enqueue(chunk); } controller.close(); } }); const doublingStream = new TransformStream({ transform(chunk, controller) { controller.enqueue(chunk * 2);} }); // 通過管道連接流 const pipedStream = integerStream.pipeThrough(doublingStream); // 從連接流的輸出獲得讀取器 const pipedStreamDefaultReader = pipedStream.getReader(); // 消費者 (async function() { while(true) { const { done, value } = await pipedStreamDefaultReader.read(); if (done) { break; } else { console.log(value); } } })(); // 0 // 2 // 4 // 6 // 8
- 另外,使用 pipeTo()方法也可以將 ReadableStream 連接到 WritableStream。整個過程與使用 pipeThrough()類似:
async function* ints() { // 每 1000 毫秒生成一個遞增的整數for (let i = 0; i < 5; ++i) { yield await new Promise((resolve) => setTimeout(resolve, 1000, i)); } } const integerStream = new ReadableStream({ async start(controller) { for await (let chunk of ints()) { controller.enqueue(chunk); } controller.close(); } }); const writableStream = new WritableStream({ write(value) { console.log(value); } }); const pipedStream = integerStream.pipeTo(writableStream); // 0 // 1 // 2 // 3 // 4
- 注意,這里的管道連接操作隱式從 ReadableStream 獲得了一個讀取器,并把產生的值填充到WritableStream。
- 流可以通過管道連接成一串。最常見的用例是使用 pipeThrough()方法把 ReadableStream 接入TransformStream。從內部看,ReadableStream 先把自己的值傳給 TransformStream 內部的WritableStream,然后執行轉換,接著轉換后的值又在新的 ReadableStream 上出現。下面的例子將一個整數的 ReadableStream 傳入 TransformStream,TransformStream 對每個值做加倍處理:
-
計時 API
- 頁面性能始終是 Web 開發者關心的話題。Performance 接口通過 JavaScript API 暴露了瀏覽器內部的度量指標,允許開發者直接訪問這些信息并基于這些信息實現自己想要的功能。這個接口暴露在window.performance 對象上。所有與頁面相關的指標,包括已經定義和將來會定義的,都會存在于這個對象上。
- Performance 接口由多個 API 構成:
- High Resolution Time API
- Performance Timeline API
- Navigation Timing API
- User Timing API
- Resource Timing API
- Paint Timing API
- 有關這些規范的更多信息以及新增的性能相關規范,可以關注 W3C 性能工作組的 GitHub 項目頁面。
- 注意,瀏覽器通常支持被廢棄的 Level 1 和作為替代的 Level 2。本節盡量介紹 Level 2 級規范。
-
High Resolution Time API
- Date.now()方法只適用于日期時間相關操作,而且是不要求計時精度的操作。在下面的例子中,函數 foo()調用前后分別記錄了一個時間戳:
const t0 = Date.now(); foo(); const t1 = Date.now(); const duration = t1 – t0; console.log(duration);
- 考慮如下 duration 會包含意外值的情況。
- duration 是 0。Date.now()只有毫秒級精度,如果 foo()執行足夠快,則兩個時間戳的值會相等。
- duration 是負值或極大值。如果在 foo()執行時,系統時鐘被向后或向前調整了(如切換到夏令時),則捕獲的時間戳不會考慮這種情況,因此時間差中會包含這些調整。
- 為此,必須使用不同的計時 API 來精確且準確地度量時間的流逝。High Resolution Time API 定義了window.performance.now(),這個方法返回一個微秒精度的浮點值。因此,使用這個方法先后捕獲的時間戳更不可能出現相等的情況。而且這個方法可以保證時間戳單調增長。
const t0 = performance.now(); const t1 = performance.now(); console.log(t0); // 1768.625000026077 console.log(t1); // 1768.6300000059418 const duration = t1 – t0; console.log(duration); // 0.004999979864805937
- performance.now()計時器采用相對度量。這個計時器在執行上下文創建時從 0 開始計時。例如,打開頁面或創建工作線程時,performance.now()就會從 0 開始計時。由于這個計時器在不同上下文中初始化時可能存在時間差,因此不同上下文之間如果沒有共享參照點則不可能直接比較 performance.now()。performance.timeOrigin 屬性返回計時器初始化時全局系統時鐘的值。
const relativeTimestamp = performance.now(); const absoluteTimestamp = performance.timeOrigin + relativeTimestamp; console.log(relativeTimestamp); // 244.43500000052154 console.log(absoluteTimestamp); // 1561926208892.4001
- 注意,通過使用 performance.now()測量 L1 緩存與主內存的延遲差,幽靈漏洞(Spectre)可以執行緩存推斷攻擊。為彌補這個安全漏洞,所有的主流瀏覽器有的選擇降低performance.now()的精度,有的選擇在時間戳里混入一些隨機性。WebKit 博客上有一篇相關主題的不錯的文章“What Spectre and Meltdown Mean For WebKit”,作者是 Filip Pizlo。
- Date.now()方法只適用于日期時間相關操作,而且是不要求計時精度的操作。在下面的例子中,函數 foo()調用前后分別記錄了一個時間戳:
-
Performance Timeline API
- Performance Timeline API 使用一套用于度量客戶端延遲的工具擴展了 Performance 接口。性能度量將會采用計算結束與開始時間差的形式。這些開始和結束時間會被記錄為 DOMHighResTimeStamp值,而封裝這個時間戳的對象是 PerformanceEntry 的實例。
- 瀏覽器會自動記錄各種 PerformanceEntry 對象,而使用 performance.mark()也可以記錄自定義的 PerformanceEntry 對象。在一個執行上下文中被記錄的所有性能條目可以通過 performance.getEntries()獲取:
console.log(performance.getEntries()); // [PerformanceNavigationTiming, PerformanceResourceTiming, ... ]
- 這個返回的集合代表瀏覽器的性能時間線(performance timeline)。每個 PerformanceEntry 對象都有 name、entryType、startTime 和 duration 屬性:
const entry = performance.getEntries()[0]; console.log(entry.name); // "https://foo.com" console.log(entry.entryType); // navigation console.log(entry.startTime); // 0 console.log(entry.duration); // 182.36500001512468
- 不過,PerformanceEntry 實際上是一個抽象基類。所有記錄條目雖然都繼承 PerformanceEntry,但最終還是如下某個具體類的實例:
- PerformanceMark
- PerformanceMeasure
- PerformanceFrameTiming
- PerformanceNavigationTiming
- PerformanceResourceTiming
- PerformancePaintTiming
- 上面每個類都會增加大量屬性,用于描述與相應條目有關的元數據。每個實例的 name 和 entryType屬性會因為各自的類不同而不同。
-
User Timing API
- User Timing API 用于記錄和分析自定義性能條目。如前所述,記錄自定義性能條目要使用performance.mark()方法:
performance.mark('foo'); console.log(performance.getEntriesByType('mark')[0]); // PerformanceMark { // name: "foo", // entryType: "mark", // startTime: 269.8800000362098, // duration: 0 // }
- 在計算開始前和結束后各創建一個自定義性能條目可以計算時間差。最新的標記(mark)會被推到getEntriesByType()返回數組的開始:
performance.mark('foo'); for (let i = 0; i < 1E6; ++i) {} performance.mark('bar'); const [endMark, startMark] = performance.getEntriesByType('mark'); console.log(startMark.startTime - endMark.startTime); // 1.3299999991431832
- 除了自定義性能條目,還可以生成 PerformanceMeasure(性能度量)條目,對應由名字作為標識的兩個標記之間的持續時間。PerformanceMeasure 的實例由 performance.measure()方法生成:
performance.mark('foo'); for (let i = 0; i < 1E6; ++i) {} performance.mark('bar'); performance.measure('baz', 'foo', 'bar'); const [differenceMark] = performance.getEntriesByType('measure'); console.log(differenceMark); // PerformanceMeasure { // name: "baz", // entryType: "measure", // startTime: 298.9800000214018, // duration: 1.349999976810068 // }
- User Timing API 用于記錄和分析自定義性能條目。如前所述,記錄自定義性能條目要使用performance.mark()方法:
-
Navigation Timing API
- Navigation Timing API 提供了高精度時間戳,用于度量當前頁面加載速度。瀏覽器會在導航事件發生時自動記錄 PerformanceNavigationTiming 條目。這個對象會捕獲大量時間戳,用于描述頁面是何時以及如何加載的。
- 下面的例子計算了 loadEventStart 和 loadEventEnd 時間戳之間的差:
const [performanceNavigationTimingEntry] = performance.getEntriesByType('navigation'); console.log(performanceNavigationTimingEntry); // PerformanceNavigationTiming { // connectEnd: 2.259999979287386 // connectStart: 2.259999979287386 // decodedBodySize: 122314 // domComplete: 631.9899999652989 // domContentLoadedEventEnd: 300.92499998863786 // domContentLoadedEventStart: 298.8950000144541 // domInteractive: 298.88499999651685 // domainLookupEnd: 2.259999979287386 // domainLookupStart: 2.259999979287386 // duration: 632.819999998901 // encodedBodySize: 21107 // entryType: "navigation" // fetchStart: 2.259999979287386 // initiatorType: "navigation" // loadEventEnd: 632.819999998901 // loadEventStart: 632.0149999810383 // name: " https://foo.com " // nextHopProtocol: "h2" // redirectCount: 0 // redirectEnd: 0 // redirectStart: 0 // requestStart: 7.7099999762140214 // responseEnd: 130.50999998813495 // responseStart: 127.16999999247491 // secureConnectionStart: 0 // serverTiming: [] // startTime: 0 // transferSize: 21806 // type: "navigate" // unloadEventEnd: 132.73999997181818 // unloadEventStart: 132.41999997990206 // workerStart: 0 // } console.log(performanceNavigationTimingEntry.loadEventEnd – performanceNavigationTimingEntry.loadEventStart); // 0.805000017862767
-
Resource Timing API
- Resource Timing API 提供了高精度時間戳,用于度量當前頁面加載時請求資源的速度。瀏覽器會在加載資源時自動記錄 PerformanceResourceTiming。這個對象會捕獲大量時間戳,用于描述資源加載的速度。
- 下面的例子計算了加載一個特定資源所花的時間:
const performanceResourceTimingEntry = performance.getEntriesByType('resource')[0]; console.log(performanceResourceTimingEntry); // PerformanceResourceTiming { // connectEnd: 138.11499997973442 // connectStart: 138.11499997973442 // decodedBodySize: 33808 // domainLookupEnd: 138.11499997973442 // domainLookupStart: 138.11499997973442 // duration: 0 // encodedBodySize: 33808 // entryType: "resource" // fetchStart: 138.11499997973442 // initiatorType: "link" // name: "https://static.foo.com/bar.png", // nextHopProtocol: "h2" // redirectEnd: 0 // redirectStart: 0 // requestStart: 138.11499997973442 // responseEnd: 138.11499997973442 // responseStart: 138.11499997973442 // secureConnectionStart: 0 // serverTiming: [] // startTime: 138.11499997973442 // transferSize: 0 // workerStart: 0 // } console.log(performanceResourceTimingEntry.responseEnd – performanceResourceTimingEntry.requestStart); // 493.9600000507198
- 通過計算并分析不同時間的差,可以更全面地審視瀏覽器加載頁面的過程,發現可能存在的性能瓶頸。
-
Web 組件
- 這里所說的 Web 組件指的是一套用于增強 DOM 行為的工具,包括影子 DOM、自定義元素和 HTML 模板。這一套瀏覽器 API 特別混亂。
- 并沒有統一的“Web Components”規范:每個 Web 組件都在一個不同的規范中定義。
- 有些 Web 組件如影子 DOM 和自定義元素,已經出現了向后不兼容的版本問題。
- 瀏覽器實現極其不一致。
- 由于存在這些問題,因此使用 Web 組件通常需要引入一個 Web 組件庫,比如 Polymer。這種庫可以作為膩子腳本,模擬瀏覽器中缺失的 Web 組件。
- 注意,本章只介紹 Web 組件的最新版本。
- 這里所說的 Web 組件指的是一套用于增強 DOM 行為的工具,包括影子 DOM、自定義元素和 HTML 模板。這一套瀏覽器 API 特別混亂。
-
HTML 模板
- 在 Web 組件之前,一直缺少基于 HTML 解析構建 DOM 子樹,然后在需要時再把這個子樹渲染出來的機制。一種間接方案是使用 innerHTML 把標記字符串轉換為 DOM 元素,但這種方式存在嚴重的安全隱患。另一種間接方案是使用 document.createElement()構建每個元素,然后逐個把它們添加到孤兒根節點(不是添加到 DOM),但這樣做特別麻煩,完全與標記無關。
- 相反,更好的方式是提前在頁面中寫出特殊標記,讓瀏覽器自動將其解析為 DOM 子樹,但跳過渲染。這正是 HTML 模板的核心思想,而
<template>
標簽正是為這個目的而生的。下面是一個簡單的HTML 模板的例子:<template id="foo"> <p>I'm inside a template!</p> </template>
-
使用 DocumentFragment
- 在瀏覽器中渲染時,上面例子中的文本不會被渲染到頁面上。因為
<template>
的內容不屬于活動文檔,所以 document.querySelector()等 DOM 查詢方法不會發現其中的<p>
標簽。這是因為<p>
存在于一個包含在 HTML 模板中的 DocumentFragment 節點內。 - 在瀏覽器中通過開發者工具檢查網頁內容時,可以看到
<template>
中的 DocumentFragment:<template id="foo"> #document-fragment <p>I'm inside a template!</p> </template>
- 通過
<template>
元素的 content 屬性可以取得這個 DocumentFragment 的引用:console.log(document.querySelector('#foo').content); // #document-fragment
- 此時的 DocumentFragment 就像一個對應子樹的最小化 document 對象。換句話說,DocumentFragment 上的 DOM 匹配方法可以查詢其子樹中的節點:
const fragment = document.querySelector('#foo').content; console.log(document.querySelector('p')); // null console.log(fragment.querySelector('p')); // <p>...<p>
- DocumentFragment 也是批量向 HTML 中添加元素的高效工具。比如,我們想以最快的方式給某個 HTML 元素添加多個子元素。如果連續調用 document.appendChild(),則不僅費事,還會導致多次布局重排。而使用 DocumentFragment 可以一次性添加所有子節點,最多只會有一次布局重排:
// 開始狀態: // <div id="foo"></div> // // 期待的最終狀態: // <div id="foo"> // <p></p> // <p></p> // <p></p> // </div> // 也可以使用 document.createDocumentFragment() const fragment = new DocumentFragment(); const foo = document.querySelector('#foo'); // 為 DocumentFragment 添加子元素不會導致布局重排 fragment.appendChild(document.createElement('p')); fragment.appendChild(document.createElement('p')); fragment.appendChild(document.createElement('p')); console.log(fragment.children.length); // 3 foo.appendChild(fragment); console.log(fragment.children.length); // 0 console.log(document.body.innerHTML); // <div id="foo"> // <p></p> // <p></p> // <p></p> // </div>
- 在瀏覽器中渲染時,上面例子中的文本不會被渲染到頁面上。因為
-
使用
<template>
標簽- 注意,在前面的例子中,DocumentFragment 的所有子節點都高效地轉移到了 foo 元素上,轉移之后 DocumentFragment 變空了。同樣的過程也可以使用
<template>
標簽重現:const fooElement = document.querySelector('#foo'); const barTemplate = document.querySelector('#bar'); const barFragment = barTemplate.content; console.log(document.body.innerHTML); // <div id="foo"> // </div> // <template id="bar"> // <p></p> // <p></p> // <p></p> // </template> fooElement.appendChild(barFragment); console.log(document.body.innerHTML); // <div id="foo"> // <p></p> // <p></p> // <p></p> // </div> // <tempate id="bar"></template>
- 如果想要復制模板,可以使用 importNode()方法克隆 DocumentFragment:
const fooElement = document.querySelector('#foo'); const barTemplate = document.querySelector('#bar'); const barFragment = barTemplate.content; console.log(document.body.innerHTML); // <div id="foo"> // </div> // <template id="bar"> // <p></p> // <p></p> // <p></p> // </template> fooElement.appendChild(document.importNode(barFragment, true)); console.log(document.body.innerHTML); // <div id="foo"> // <p></p> // <p></p> // <p></p> // </div> // <template id="bar"> // <p></p> // <p></p> // <p></p> // </template>
- 注意,在前面的例子中,DocumentFragment 的所有子節點都高效地轉移到了 foo 元素上,轉移之后 DocumentFragment 變空了。同樣的過程也可以使用
-
模板腳本
- 腳本執行可以推遲到將 DocumentFragment 的內容實際添加到 DOM 樹。下面的例子演示了這個過程:
// 頁面 HTML: // // <div id="foo"></div> // <template id="bar"> // <script>console.log('Template script executed');</script> // </template> const fooElement = document.querySelector('#foo'); const barTemplate = document.querySelector('#bar'); const barFragment = barTemplate.content; console.log('About to add template'); fooElement.appendChild(barFragment); console.log('Added template'); // About to add template // Template script executed // Added template
- 如果新添加的元素需要進行某些初始化,這種延遲執行是有用的。
- 腳本執行可以推遲到將 DocumentFragment 的內容實際添加到 DOM 樹。下面的例子演示了這個過程:
-
影子 DOM
- 概念上講,影子 DOM(shadow DOM) Web 組件相當直觀,通過它可以將一個完整的 DOM 樹作為節點添加到父 DOM 樹。這樣可以實現 DOM 封裝,意味著 CSS 樣式和 CSS 選擇符可以限制在影子 DOM子樹而不是整個頂級 DOM 樹中。
- 影子 DOM 與 HTML 模板很相似,因為它們都是類似 document 的結構,并允許與頂級 DOM 有一定程度的分離。不過,影子 DOM 與 HTML 模板還是有區別的,主要表現在影子 DOM 的內容會實際渲染到頁面上,而 HTML 模板的內容不會。
-
理解影子 DOM
- 假設有以下 HTML 標記,其中包含多個類似的 DOM 子樹:
<div> <p>Make me red!</p> </div> <div> <p>Make me blue!</p> </div> <div> <p>Make me green!</p> </div>
- 從其中的文本節點可以推斷出,這 3 個 DOM 子樹會分別渲染為不同的顏色。常規情況下,為了給每個子樹應用唯一的樣式,又不使用 style 屬性,就需要給每個子樹添加一個唯一的類名,然后通過相應的選擇符為它們添加樣式:
<div class="red-text"> <p>Make me red!</p> </div> <div class="green-text"> <p>Make me green!</p> </div> <div class="blue-text"> <p>Make me blue!</p> </div> <style> .red-text { color: red; } .green-text { color: green; } .blue-text { color: blue; } </style>
- 當然,這個方案也不是十分理想,因為這跟在全局命名空間中定義變量沒有太大區別。盡管知道這些樣式與其他地方無關,所有 CSS 樣式還會應用到整個 DOM。為此,就要保持 CSS 選擇符足夠特別,以防這些樣式滲透到其他地方。但這也僅是一個折中的辦法而已。理想情況下,應該能夠把 CSS 限制在使用它們的 DOM 上:這正是影子 DOM 最初的使用場景。
- 假設有以下 HTML 標記,其中包含多個類似的 DOM 子樹:
-
創建影子 DOM
- 考慮到安全及避免影子 DOM 沖突,并非所有元素都可以包含影子 DOM。嘗試給無效元素或者已經有了影子 DOM 的元素添加影子 DOM 會導致拋出錯誤。
- 以下是可以容納影子 DOM 的元素。
- 任何以有效名稱創建的自定義元素(參見 HTML 規范中相關的定義)
<article>
<aside>
<blockquote>
<body>
<div>
<footer>
<h1>
<h2>
<h3>
<h4>
<h5>
<h6>
<header>
<main>
<nav>
<p>
<section>
<span>
- 影子 DOM 是通過 attachShadow()方法創建并添加給有效 HTML 元素的。容納影子 DOM 的元素被稱為影子宿主(shadow host)。影子 DOM 的根節點被稱為影子根(shadow root)。
- attachShadow()方法需要一個shadowRootInit 對象,返回影子DOM的實例。shadowRootInit對象必須包含一個 mode 屬性,值為"open"或"closed"。對"open"影子 DOM的引用可以通過 shadowRoot屬性在 HTML 元素上獲得,而對"closed"影子 DOM 的引用無法這樣獲取。
- 下面的代碼演示了不同 mode 的區別:
document.body.innerHTML = ` <div id="foo"></div> <div id="bar"></div> `; const foo = document.querySelector('#foo'); const bar = document.querySelector('#bar'); const openShadowDOM = foo.attachShadow({ mode: 'open' }); const closedShadowDOM = bar.attachShadow({ mode: 'closed' }); console.log(openShadowDOM); // #shadow-root (open) console.log(closedShadowDOM); // #shadow-root (closed) console.log(foo.shadowRoot); // #shadow-root (open) console.log(bar.shadowRoot); // null
- 一般來說,需要創建保密(closed)影子 DOM 的場景很少。雖然這可以限制通過影子宿主訪問影子 DOM,但惡意代碼有很多方法繞過這個限制,恢復對影子 DOM 的訪問。簡言之,不能為了安全而創建保密影子 DOM。
- 注意,如果想保護獨立的 DOM 樹不受未信任代碼影響,影子 DOM 并不適合這個需求。對
<iframe>
施加的跨源限制更可靠。
-
使用影子 DOM
- 把影子 DOM 添加到元素之后,可以像使用常規 DOM 一樣使用影子 DOM。來看下面的例子,這里重新創建了前面紅/綠/藍子樹的示例:
for (let color of ['red', 'green', 'blue']) { const div = document.createElement('div'); const shadowDOM = div.attachShadow({ mode: 'open' }); document.body.appendChild(div); shadowDOM.innerHTML = ` <p>Make me ${color}</p> <style> p { color: ${color}; } </style> `; }
- 雖然這里使用相同的選擇符應用了 3 種不同的顏色,但每個選擇符只會把樣式應用到它們所在的影子 DOM 上。為此,3 個
<p>
元素會出現 3 種不同的顏色。可以這樣驗證這些元素分別位于它們自己的影子 DOM 中:for (let color of ['red', 'green', 'blue']) { const div = document.createElement('div'); const shadowDOM = div.attachShadow({ mode: 'open' }); document.body.appendChild(div); shadowDOM.innerHTML = ` <p>Make me ${color}</p> <style> p { color: ${color}; } </style> `; } function countP(node) { console.log(node.querySelectorAll('p').length); } countP(document); // 0 for (let element of document.querySelectorAll('div')) { countP(element.shadowRoot); } // 1 // 1 // 1
- 在瀏覽器開發者工具中可以更清楚地看到影子 DOM。例如,前面的例子在瀏覽器檢查窗口中會顯示成這樣:
<body> <div> #shadow-root (open) <p>Make me red!</p> <style> p { color: red; } </style> </div> <div> #shadow-root (open) <p>Make me green!</p> <style> p { color: green; } </style> </div> <div> #shadow-root (open) <p>Make me blue!</p> <style> p { color: blue; } </style> </div> </body>
- 影子 DOM 并非鐵板一塊。HTML 元素可以在 DOM 樹間無限制移動:
document.body.innerHTML = ` <div></div> <p id="foo">Move me</p> `; const divElement = document.querySelector('div'); const pElement = document.querySelector('p'); const shadowDOM = divElement.attachShadow({ mode: 'open' }); // 從父 DOM 中移除元素 divElement.parentElement.removeChild(pElement); // 把元素添加到影子 DOM shadowDOM.appendChild(pElement); // 檢查元素是否移動到了影子 DOM 中 console.log(shadowDOM.innerHTML); // <p id="foo">Move me</p>
- 把影子 DOM 添加到元素之后,可以像使用常規 DOM 一樣使用影子 DOM。來看下面的例子,這里重新創建了前面紅/綠/藍子樹的示例:
-
合成與影子 DOM 槽位
- 影子 DOM 是為自定義 Web 組件設計的,為此需要支持嵌套 DOM 片段。從概念上講,可以這么說:位于影子宿主中的 HTML需要一種機制以渲染到影子 DOM中去,但這些 HTML又不必屬于影子 DOM樹。
- 默認情況下,嵌套內容會隱藏。來看下面的例子,其中的文本在 1000 毫秒后會被隱藏:
document.body.innerHTML = ` <div> <p>Foo</p> </div> `; setTimeout(() => document.querySelector('div').attachShadow({ mode: 'open' }), 1000);
- 影子 DOM 一添加到元素中,瀏覽器就會賦予它最高優先級,優先渲染它的內容而不是原來的文本。在這個例子中,由于影子 DOM 是空的,因此
<div>
會在 1000 毫秒后變成空的。 - 為了顯示文本內容,需要使用
<slot>
標簽指示瀏覽器在哪里放置原來的 HTML。下面的代碼修改了前面的例子,讓影子宿主中的文本出現在了影子 DOM 中:document.body.innerHTML = ` <div id="foo"> <p>Foo</p> </div> `; document.querySelector('div') .attachShadow({ mode: 'open' }) .innerHTML = `<div id="bar"> <slot></slot> <div>`
- 現在,投射進去的內容就像自己存在于影子 DOM 中一樣。檢查頁面會發現原來的內容實際上替代了
<slot>
:<body> <div id="foo"> #shadow-root (open) <div id="bar"> <p>Foo</p> </div> </div> </body>
- 注意,雖然在頁面檢查窗口中看到內容在影子 DOM中,但這實際上只是 DOM內容的投射(projection)。實際的元素仍然處于外部 DOM 中:
document.body.innerHTML = ` <div id="foo"> <p>Foo</p> </div> `; document.querySelector('div') .attachShadow({ mode: 'open' }) .innerHTML = ` <div id="bar"> <slot></slot> </div>` console.log(document.querySelector('p').parentElement); // <div id="foo"></div>
- 下面是使用槽位(slot)改寫的前面紅/綠/藍子樹的例子:
for (let color of ['red', 'green', 'blue']) { const divElement = document.createElement('div'); divElement.innerText = `Make me ${color}`; document.body.appendChild(divElement) divElement .attachShadow({ mode: 'open' }) .innerHTML = ` <p><slot></slot></p> <style> p { color: ${color}; } </style> `; }
- 除了默認槽位,還可以使用命名槽位(named slot)實現多個投射。這是通過匹配的 slot/name 屬性對實現的。帶有 slot="foo"屬性的元素會被投射到帶有 name="foo"的
<slot>
上。下面的例子演示了如何改變影子宿主子元素的渲染順序:document.body.innerHTML = ` <div> <p slot="foo">Foo</p> <p slot="bar">Bar</p> </div> `; document.querySelector('div') .attachShadow({ mode: 'open' }) .innerHTML = ` <slot name="bar"></slot> <slot name="foo"></slot> `; // Renders: // Bar // Foo
-
事件重定向
- 如果影子 DOM 中發生了瀏覽器事件(如 click),那么瀏覽器需要一種方式以讓父 DOM 處理事件。不過,實現也必須考慮影子 DOM 的邊界。為此,事件會逃出影子 DOM 并經過事件重定向(event retarget)在外部被處理。逃出后,事件就好像是由影子宿主本身而非真正的包裝元素觸發的一樣。下面的代碼演示了這個過程:
// 創建一個元素作為影子宿主 document.body.innerHTML = ` <div onclick="console.log('Handled outside:', event.target)"></div> `; // 添加影子 DOM 并向其中插入 HTML document.querySelector('div') .attachShadow({ mode: 'open' }) .innerHTML = ` <button onclick="console.log('Handled inside:', event.target)">Foo</button> `; // 點擊按鈕時: // Handled inside: <button onclick="..."></button> // Handled outside: <div onclick="..."></div>
- 注意,事件重定向只會發生在影子 DOM 中實際存在的元素上。使用
<slot>
標簽從外部投射進來的元素不會發生事件重定向,因為從技術上講,這些元素仍然存在于影子 DOM 外部。
- 如果影子 DOM 中發生了瀏覽器事件(如 click),那么瀏覽器需要一種方式以讓父 DOM 處理事件。不過,實現也必須考慮影子 DOM 的邊界。為此,事件會逃出影子 DOM 并經過事件重定向(event retarget)在外部被處理。逃出后,事件就好像是由影子宿主本身而非真正的包裝元素觸發的一樣。下面的代碼演示了這個過程:
-
自定義元素
- 如果你使用 JavaScript 框架,那么很可能熟悉自定義元素的概念。這是因為所有主流框架都以某種形式提供了這個特性。自定義元素為 HTML 元素引入了面向對象編程的風格。基于這種風格,可以創建自定義的、復雜的和可重用的元素,而且只要使用簡單的 HTML 標簽或屬性就可以創建相應的實例。
-
創建自定義元素
- 瀏覽器會嘗試將無法識別的元素作為通用元素整合進 DOM。當然,這些元素默認也不會做任何通用 HTML 元素不能做的事。來看下面的例子,其中胡亂編的 HTML 標簽會變成一個 HTMLElement 實例:
document.body.innerHTML = ` <x-foo >I'm inside a nonsense element.</x-foo > `; console.log(document.querySelector('x-foo') instanceof HTMLElement); // true
- 自定義元素在此基礎上更進一步。利用自定義元素,可以在
<x-foo>
標簽出現時為它定義復雜的行為,同樣也可以在 DOM 中將其納入元素生命周期管理。自定義元素要使用全局屬性 customElements,這個屬性會返回 CustomElementRegistry 對象。console.log(customElements); // CustomElementRegistry {}
- 調用 customElements.define()方法可以創建自定義元素。下面的代碼創建了一個簡單的自定義元素,這個元素繼承 HTMLElement:
class FooElement extends HTMLElement {} customElements.define('x-foo', FooElement); document.body.innerHTML = ` <x-foo >I'm inside a nonsense element.</x-foo > `; console.log(document.querySelector('x-foo') instanceof FooElement); // true
- 注意,自定義元素名必須至少包含一個不在名稱開頭和末尾的連字符,而且元素標簽不能自關閉。
- 自定義元素的威力源自類定義。例如,可以通過調用自定義元素的構造函數來控制這個類在 DOM中每個實例的行為:
class FooElement extends HTMLElement { constructor() { super(); console.log('x-foo') } } customElements.define('x-foo', FooElement); document.body.innerHTML = ` <x-foo></x-foo> <x-foo></x-foo> <x-foo></x-foo> `; // x-foo // x-foo // x-foo
- 注意,在自定義元素的構造函數中必須始終先調用 super()。如果元素繼承了 HTMLElement或相似類型而不會覆蓋構造函數,則沒有必要調用 super(),因為原型構造函數默認會做這件事。很少有創建自定義元素而不繼承 HTMLElement 的。
- 如果自定義元素繼承了一個元素類,那么可以使用 is 屬性和 extends 選項將標簽指定為該自定義元素的實例:
class FooElement extends HTMLDivElement { constructor() { super(); console.log('x-foo') } } customElements.define('x-foo', FooElement, { extends: 'div' }); document.body.innerHTML = ` <div is="x-foo"></div> <div is="x-foo"></div> <div is="x-foo"></div> `; // x-foo // x-foo // x-foo
- 瀏覽器會嘗試將無法識別的元素作為通用元素整合進 DOM。當然,這些元素默認也不會做任何通用 HTML 元素不能做的事。來看下面的例子,其中胡亂編的 HTML 標簽會變成一個 HTMLElement 實例:
-
添加 Web 組件內容
- 因為每次將自定義元素添加到 DOM 中都會調用其類構造函數,所以很容易自動給自定義元素添加子 DOM 內容。雖然不能在構造函數中添加子 DOM(會拋出 DOMException),但可以為自定義元素添加影子 DOM 并將內容添加到這個影子 DOM 中:
class FooElement extends HTMLElement { constructor() { super(); // this 引用 Web 組件節點this.attachShadow({ mode: 'open' }); this.shadowRoot.innerHTML = ` <p>I'm inside a custom element!</p> `; } } customElements.define('x-foo', FooElement); document.body.innerHTML += `<x-foo></x-foo`; // 結果 DOM: // <body> // <x-foo> // #shadow-root (open) // <p>I'm inside a custom element!</p> // <x-foo> // </body>
- 為避免字符串模板和 innerHTML 不干凈,可以使用 HTML 模板和 document.createElement()重構這個例子:
//(初始的 HTML) // <template id="x-foo-tpl"> // <p>I'm inside a custom element template!</p> // </template> const template = document.querySelector('#x-foo-tpl'); class FooElement extends HTMLElement { constructor() { super(); this.attachShadow({ mode: 'open' }); this.shadowRoot.appendChild(template.content.cloneNode(true)); } } customElements.define('x-foo', FooElement); document.body.innerHTML += `<x-foo></x-foo`; // 結果 DOM: // <body> // <template id="x-foo-tpl"> // <p>I'm inside a custom element template!</p> // </template> // <x-foo> // #shadow-root (open) // <p>I'm inside a custom element template!</p> // <x-foo> // </body>
- 這樣可以在自定義元素中實現高度的 HTML 和代碼重用,以及 DOM 封裝。使用這種模式能夠自由創建可重用的組件而不必擔心外部 CSS 污染組件的樣式。
- 因為每次將自定義元素添加到 DOM 中都會調用其類構造函數,所以很容易自動給自定義元素添加子 DOM 內容。雖然不能在構造函數中添加子 DOM(會拋出 DOMException),但可以為自定義元素添加影子 DOM 并將內容添加到這個影子 DOM 中:
-
使用自定義元素生命周期方法
- 可以在自定義元素的不同生命周期執行代碼。帶有相應名稱的自定義元素類的實例方法會在不同生命周期階段被調用。自定義元素有以下 5 個生命周期方法。
- constructor():在創建元素實例或將已有 DOM 元素升級為自定義元素時調用。
- connectedCallback():在每次將這個自定義元素實例添加到 DOM 中時調用。
- disconnectedCallback():在每次將這個自定義元素實例從 DOM 中移除時調用。
- attributeChangedCallback():在每次可觀察屬性的值發生變化時調用。在元素實例初始化時,初始值的定義也算一次變化。
- adoptedCallback():在通過 document.adoptNode()將這個自定義元素實例移動到新文檔對象時調用。
- 下面的例子演示了這些構建、連接和斷開連接的回調:
class FooElement extends HTMLElement { constructor() { super(); console.log('ctor'); } connectedCallback() { console.log('connected'); } disconnectedCallback() { console.log('disconnected'); } } customElements.define('x-foo', FooElement); const fooElement = document.createElement('x-foo'); // ctor document.body.appendChild(fooElement); // connected document.body.removeChild(fooElement); // disconnected
- 可以在自定義元素的不同生命周期執行代碼。帶有相應名稱的自定義元素類的實例方法會在不同生命周期階段被調用。自定義元素有以下 5 個生命周期方法。
-
反射自定義元素屬性
- 自定義元素既是 DOM 實體又是 JavaScript 對象,因此兩者之間應該同步變化。換句話說,對 DOM的修改應該反映到 JavaScript 對象,反之亦然。要從 JavaScript 對象反射到 DOM,常見的方式是使用獲取函數和設置函數。下面的例子演示了在 JavaScript 對象和 DOM 之間反射 bar 屬性的過程:
document.body.innerHTML = `<x-foo></x-foo>`; class FooElement extends HTMLElement { constructor() { super(); this.bar = true; } get bar() { return this.getAttribute('bar'); } set bar(value) { this.setAttribute('bar', value) } } customElements.define('x-foo', FooElement); console.log(document.body.innerHTML); // <x-foo bar="true"></x-foo>
- 另一個方向的反射(從 DOM 到 JavaScript 對象)需要給相應的屬性添加監聽器。為此,可以使用observedAttributes()獲取函數讓自定義元素的屬性值每次改變時都調用 attributeChangedCallback():
class FooElement extends HTMLElement { static get observedAttributes() { // 返回應該觸發 attributeChangedCallback()執行的屬性return ['bar']; } get bar() { return this.getAttribute('bar'); } set bar(value) { this.setAttribute('bar', value) }attributeChangedCallback(name, oldValue, newValue) { if (oldValue !== newValue) { console.log(`${oldValue} -> ${newValue}`); this[name] = newValue; } } } customElements.define('x-foo', FooElement); document.body.innerHTML = `<x-foo bar="false"></x-foo>`; // null -> false document.querySelector('x-foo').setAttribute('bar', true); // false -> true
- 自定義元素既是 DOM 實體又是 JavaScript 對象,因此兩者之間應該同步變化。換句話說,對 DOM的修改應該反映到 JavaScript 對象,反之亦然。要從 JavaScript 對象反射到 DOM,常見的方式是使用獲取函數和設置函數。下面的例子演示了在 JavaScript 對象和 DOM 之間反射 bar 屬性的過程:
-
升級自定義元素
- 并非始終可以先定義自定義元素,然后再在 DOM 中使用相應的元素標簽。為解決這個先后次序問題,Web 組件在 CustomElementRegistry 上額外暴露了一些方法。這些方法可以用來檢測自定義元素是否定義完成,然后可以用它來升級已有元素。
- 如果自定義元素已經有定義,那么 CustomElementRegistry.get()方法會返回相應自定義元素的類。類似地,CustomElementRegistry.whenDefined()方法會返回一個期約,當相應自定義元素有定義之后解決:
customElements.whenDefined('x-foo').then(() => console.log('defined!')); console.log(customElements.get('x-foo')); // undefined customElements.define('x-foo', class {}); // defined! console.log(customElements.get('x-foo')); // class FooElement {}
- 連接到 DOM 的元素在自定義元素有定義時會自動升級。如果想在元素連接到 DOM 之前強制升級,可以使用 CustomElementRegistry.upgrade()方法:
// 在自定義元素有定義之前會創建 HTMLUnknownElement 對象 const fooElement = document.createElement('x-foo'); // 創建自定義元素 class FooElement extends HTMLElement {} customElements.define('x-foo', FooElement); console.log(fooElement instanceof FooElement); // false // 強制升級 customElements.upgrade(fooElement); console.log(fooElement instanceof FooElement); // true
- 注意,還有一個 HTML Imports Web 組件,但這個規范目前還是草案,沒有主要瀏覽器支持。瀏覽器最終是否會支持這個規范目前還是未知數。
-
Web Cryptography API
- Web Cryptography API 描述了一套密碼學工具,規范了 JavaScript 如何以安全和符合慣例的方式實現加密。這些工具包括生成、使用和應用加密密鑰對,加密和解密消息,以及可靠地生成隨機數。
- 注意,加密接口的組織方式有點奇怪,其外部是一個Crypto對象,內部是一個SubtleCrypto對象。在 Web Cryptography API 標準化之前,window.crypto 屬性在不同瀏覽器中的實現差異非常大。為實現跨瀏覽器兼容,標準 API 都暴露在 SubtleCrypto 對象上。
-
生成隨機數
- 在需要生成隨機值時,很多人會使用 Math.random()。這個方法在瀏覽器中是以偽隨機數生成器(PRNG,PseudoRandom Number Generator)方式實現的。所謂“偽”指的是生成值的過程不是真的隨機。PRNG 生成的值只是模擬了隨機的特性。瀏覽器的 PRNG 并未使用真正的隨機源,只是對一個內部狀態應用了固定的算法。每次調用 Math.random(),這個內部狀態都會被一個算法修改,而結果會被轉換為一個新的隨機值。例如,V8 引擎使用了一個名為 xorshift128+的算法來執行這種修改。
- 由于算法本身是固定的,其輸入只是之前的狀態,因此隨機數順序也是確定的。xorshift128+使用128 位內部狀態,而算法的設計讓任何初始狀態在重復自身之前都會產生 2128–1 個偽隨機值。這種循環被稱為置換循環(permutation cycle),而這個循環的長度被稱為一個周期(period)。很明顯,如果攻擊者知道 PRNG 的內部狀態,就可以預測后續生成的偽隨機值。如果開發者無意中使用 PRNG 生成了私有密鑰用于加密,則攻擊者就可以利用 PRNG 的這個特性算出私有密鑰。
- 偽隨機數生成器主要用于快速計算出看起來隨機的值。不過并不適合用于加密計算。為解決這個問題,密碼學安全偽隨機數生成器(CSPRNG,Cryptographically Secure PseudoRandom Number Generator)額外增加了一個熵作為輸入,例如測試硬件時間或其他無法預計行為的系統特性。這樣一來,計算速度明顯比常規 PRNG 慢很多,但 CSPRNG 生成的值就很難預測,可以用于加密了。
- Web Cryptography API 引入了 CSPRNG,這個 CSPRNG 可以通過 crypto.getRandomValues()在全局 Crypto 對象上訪問。與 Math.random()返回一個介于 0和 1之間的浮點數不同,getRandomValues()會把隨機值寫入作為參數傳給它的定型數組。定型數組的類不重要,因為底層緩沖區會被隨機的二進制位填充。
- 下面的例子展示了生成 5 個 8 位隨機值:
const array = new Uint8Array(1); for (let i=0; i<5; ++i) { console.log(crypto.getRandomValues(array)); } // Uint8Array [41] // Uint8Array [250] // Uint8Array [51] // Uint8Array [129] // Uint8Array [35]
- getRandomValues()最多可以生成 216(65 536)字節,超出則會拋出錯誤:
const fooArray = new Uint8Array(2 ** 16); console.log(window.crypto.getRandomValues(fooArray)); // Uint32Array(16384) [...] const barArray = new Uint8Array((2 ** 16) + 1); console.log(window.crypto.getRandomValues(barArray)); // Error
- 要使用 CSPRNG 重新實現 Math.random(),可以通過生成一個隨機的 32 位數值,然后用它去除最大的可能值 0xFFFFFFFF。這樣就會得到一個介于 0 和 1 之間的值:
function randomFloat() { // 生成 32 位隨機值const fooArray = new Uint32Array(1); // 最大值是 2^32 –1const maxUint32 = 0xFFFFFFFF; // 用最大可能的值來除return crypto.getRandomValues(fooArray)[0] / maxUint32; } console.log(randomFloat()); // 0.5033651619458955
-
使用 SubtleCrypto 對象
- Web Cryptography API 重頭特性都暴露在了 SubtleCrypto 對象上,可以通過 window.crypto.subtle 訪問:
console.log(crypto.subtle); // SubtleCrypto {}
- 這個對象包含一組方法,用于執行常見的密碼學功能,如加密、散列、簽名和生成密鑰。因為所有密碼學操作都在原始二進制數據上執行,所以 SubtleCrypto 的每個方法都要用到 ArrayBuffer 和ArrayBufferView 類型。由于字符串是密碼學操作的重要應用場景,因此 TextEncoder 和TextDecoder 是經常與 SubtleCrypto 一起使用的類,用于實現二進制數據與字符串之間的相互轉換。
- 注意,SubtleCrypto 對象只能在安全上下文(https)中使用。在不安全的上下文中,subtle 屬性是 undefined。
- Web Cryptography API 重頭特性都暴露在了 SubtleCrypto 對象上,可以通過 window.crypto.subtle 訪問:
-
生成密碼學摘要
- 計算數據的密碼學摘要是非常常用的密碼學操作。這個規范支持 4 種摘要算法:SHA-1 和 3 種SHA-2。
- SHA-1(Secure Hash Algorithm 1):架構類似 MD5 的散列函數。接收任意大小的輸入,生成160 位消息散列。由于容易受到碰撞攻擊,這個算法已經不再安全。
- SHA-2(Secure Hash Algorithm 2):構建于相同耐碰撞單向壓縮函數之上的一套散列函數。規范支持其中 3 種:SHA-256、SHA-384 和 SHA-512。生成的消息摘要可以是 256 位(SHA-256)、384 位(SHA-384)或 512 位(SHA-512)。這個算法被認為是安全的,廣泛應用于很多領域和協議,包括 TLS、PGP 和加密貨幣(如比特幣)。
- SubtleCrypto.digest()方法用于生成消息摘要。要使用的散列算法通過字符串"SHA-1"、“SHA-256”、"SHA-384"或"SHA-512"指定。下面的代碼展示了一個使用 SHA-256 為字符串"foo"生成消息摘要的例子:
(async function() { const textEncoder = new TextEncoder(); const message = textEncoder.encode('foo'); const messageDigest = await crypto.subtle.digest('SHA-256', message);console.log(new Uint32Array(messageDigest)); })(); // Uint32Array(8) [1806968364, 2412183400, 1011194873, 876687389, // 1882014227, 2696905572, 2287897337, 2934400610]
- 通常,在使用時,二進制的消息摘要會轉換為十六進制字符串格式。通過將二進制數據按 8 位進行分割,然后再調用 toString(16)就可以把任何數組緩沖區轉換為十六進制字符串:
(async function() { const textEncoder = new TextEncoder(); const message = textEncoder.encode('foo'); const messageDigest = await crypto.subtle.digest('SHA-256', message); const hexDigest = Array.from(new Uint8Array(messageDigest)) .map((x) => x.toString(16).padStart(2, '0')) .join(''); console.log(hexDigest); })(); // 2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae
- 軟件公司通常會公開自己軟件二進制安裝包的摘要,以便用戶驗證自己下載到的確實是該公司發布的版本(而不是被惡意軟件篡改過的版本)。下面的例子演示了下載 Firefox v67.0,通過 SHA-512 計算其散列,再下載其 SHA-512 二進制驗證摘要,最后檢查兩個十六進制字符串匹配:
(async function() { const mozillaCdnUrl = '// downloadorigin.cdn.mozilla.net/pub/firefox/releases/67.0 /'; const firefoxBinaryFilename = 'linux-x86_64/en-US/firefox-67.0.tar.bz2'; const firefoxShaFilename = 'SHA512SUMS'; console.log('Fetching Firefox binary...'); const fileArrayBuffer = await (await fetch(mozillaCdnUrl + firefoxBinaryFilename)) .arrayBuffer(); console.log('Calculating Firefox digest...'); const firefoxBinaryDigest = await crypto.subtle.digest('SHA-512', fileArrayBuffer);const firefoxHexDigest = Array.from(new Uint8Array(firefoxBinaryDigest)) .map((x) => x.toString(16).padStart(2, '0')) .join(''); console.log('Fetching published binary digests...'); // SHA 文件包含此次發布的所有 Firefox 二進制文件的摘要,// 因此要根據其格式進制拆分const shaPairs = (await (await fetch(mozillaCdnUrl + firefoxShaFilename)).text()) .split(/\n/).map((x) => x.split(/\s+/)); let verified = false;console.log('Checking calculated digest against published digests...'); for (const [sha, filename] of shaPairs) { if (filename === firefoxBinaryFilename) { if (sha === firefoxHexDigest) { verified = true; break; } } } console.log('Verified:', verified); })(); // Fetching Firefox binary... // Calculating Firefox digest... // Fetching published binary digests... // Checking calculated digest against published digests... // Verified: true
- 計算數據的密碼學摘要是非常常用的密碼學操作。這個規范支持 4 種摘要算法:SHA-1 和 3 種SHA-2。
-
CryptoKey 與算法
- 如果沒了密鑰,那密碼學也就沒什么意義了。SubtleCrypto 對象使用 CryptoKey 類的實例來生成密鑰。CryptoKey 類支持多種加密算法,允許控制密鑰抽取和使用。
- CryptoKey 類支持以下算法,按各自的父密碼系統歸類。
- RSA(Rivest-Shamir-Adleman):公鑰密碼系統,使用兩個大素數獲得一對公鑰和私鑰,可用于簽名/驗證或加密/解密消息。RSA 的陷門函數被稱為分解難題(factoring problem)。
- RSASSA-PKCS1-v1_5:RSA 的一個應用,用于使用私鑰給消息簽名,允許使用公鑰驗證簽名。
- SSA(Signature Schemes with Appendix),表示算法支持簽名生成和驗證操作。
- PKCS1(Public-Key Cryptography Standards #1),表示算法展示出的 RSA 密鑰必需的數學特性。
- RSASSA-PKCS1-v1_5 是確定性的,意味著同樣的消息和密鑰每次都會生成相同的簽名。
- RSA-PSS:RSA 的另一個應用,用于簽名和驗證消息。
- PSS(Probabilistic Signature Scheme),表示生成簽名時會加鹽以得到隨機簽名。
- 與 RSASSA-PKCS1-v1_5 不同,同樣的消息和密鑰每次都會生成不同的簽名。
- 與 RSASSA-PKCS1-v1_5 不同,RSA-PSS 有可能約簡到 RSA 分解難題的難度。
- 通常,雖然 RSASSA-PKCS1-v1_5 仍被認為是安全的,但 RSA-PSS 應該用于代替RSASSA-PKCS1-v1_5。
- RSA-OAEP:RSA 的一個應用,用于使用公鑰加密消息,用私鑰來解密。
- OAEP(Optimal Asymmetric Encryption Padding),表示算法利用了 Feistel 網絡在加密前處理未加密的消息。
- OAEP 主要將確定性 RSA 加密模式轉換為概率性加密模式。
- ECC(Elliptic-Curve Cryptography):公鑰密碼系統,使用一個素數和一個橢圓曲線獲得一對公鑰和私鑰,可用于簽名/驗證消息。ECC 的陷門函數被稱為橢圓曲線離散對數問題(elliptic curve discrete logarithm problem)。ECC 被認為優于 RSA。雖然 RSA 和 ECC 在密碼學意義上都很強,但 ECC 密鑰比 RSA 密鑰短,而且 ECC 密碼學操作比 RSA 操作快。
- ECDSA(Elliptic Curve Digital Signature Algorithm):ECC 的一個應用,用于簽名和驗證消息。這個算法是數字簽名算法(DSA,Digital Signature Algorithm)的一個橢圓曲線風格的變體。
- ECDH(Elliptic Curve Diffie-Hellman):ECC 的密鑰生成和密鑰協商應用,允許兩方通過公開通信渠道建立共享的機密。這個算法是 Diffie-Hellman 密鑰交換(DH,Diffie-Hellman key exchange)協議的一個橢圓曲線風格的變體。
- AES(Advanced Encryption Standard):對稱密鑰密碼系統,使用派生自置換組合網絡的分組密碼加密和解密數據。AES 在不同模式下使用,不同模式算法的特性也不同。
- AES-CTR:AES 的計數器模式(counter mode)。這個模式使用遞增計數器生成其密鑰流,其行為類似密文流。使用時必須為其提供一個隨機數,用作初始化向量。AES-CTR 加密/解密可以并行。
- AES-CBC:AES 的密碼分組鏈模式(cipher block chaining mode)。在加密純文本的每個分組之前,先使用之前密文分組求 XOR,也就是名字中的“鏈”。使用一個初始化向量作為第一個分組的 XOR 輸入。
- AES-GCM:AES 的伽羅瓦/計數器模式(Galois/Counter mode)。這個模式使用計數器和初始化向量生成一個值,這個值會與每個分組的純文本計算 XOR。與 CBC 不同,這個模式的 XOR 輸入不依賴之前分組密文。因此 GCM 模式可以并行。由于其卓越的性能,AES-GCM 在很多網絡安全協議中得到了應用。
- AES-KW:AES 的密鑰包裝模式(key wrapping mode)。這個算法將加密密鑰包裝為一個可移植且加密的格式,可以在不信任的渠道中傳輸。傳輸之后,接收方可以解包密鑰。與其他 AES 模式不同,AES-KW 不需要初始化向量。
- HMAC(Hash-Based Message Authentication Code):用于生成消息認證碼的算法,用于驗證通過不可信網絡接收的消息沒有被修改過。兩方使用散列函數和共享私鑰來簽名和驗證消息。
- KDF(Key Derivation Functions):可以使用散列函數從主密鑰獲得一個或多個密鑰的算法。KDF能夠生成不同長度的密鑰,也能把密鑰轉換為不同格式。
- HKDF(HMAC-Based Key Derivation Function):密鑰推導函數,與高熵輸入(如已有密鑰)一起使用。
- PBKDF2(Password-Based Key Derivation Function 2):密鑰推導函數,與低熵輸入(如密鑰字符串)一起使用。
- 注意,CryptoKey 支持很多算法,但其中只有部分算法能夠用于 SubtleCrypto 的方法。要了解哪個方法支持什么算法,可以參考 W3C 網站上 Web Cryptography API 規范的“Algorithm Overview”。
-
生成 CryptoKey
- 使用 SubtleCrypto.generateKey()方法可以生成隨機 CryptoKey,這個方法返回一個期約,解決為一個或多個 CryptoKey 實例。使用時需要給這個方法傳入一個指定目標算法的參數對象、一個表示密鑰是否可以從 CryptoKey 對象中提取出來的布爾值,以及一個表示這個密鑰可以與哪個SubtleCrypto 方法一起使用的字符串數組(keyUsages)。
- 由于不同的密碼系統需要不同的輸入來生成密鑰,上述參數對象為每種密碼系統都規定了必需的輸入:
- RSA 密碼系統使用 RsaHashedKeyGenParams 對象;
- ECC 密碼系統使用 EcKeyGenParams 對象;
- HMAC 密碼系統使用 HmacKeyGenParams 對象;
- AES 密碼系統使用 AesKeyGenParams 對象。
- keyUsages 對象用于說明密鑰可以與哪個算法一起使用。至少要包含下列中的一個字符串:
- encrypt
- decrypt
- sign
- verify
- deriveKey
- deriveBits
- wrapKey
- unwrapKey
- 假設要生成一個滿足如下條件的對稱密鑰:
- 支持 AES-CTR 算法;
- 密鑰長度 128 位;
- 不能從 CryptoKey 對象中提取;
- 可以跟 encrypt()和 decrypt()方法一起使用。
- 那么可以參考如下代碼:
(async function() { const params = { name: 'AES-CTR', length: 128 }; const keyUsages = ['encrypt', 'decrypt']; const key = await crypto.subtle.generateKey(params, false, keyUsages); console.log(key); // CryptoKey {type: "secret", extractable: true, algorithm: {...}, usages: Array(2)} })();
- 假設要生成一個滿足如下條件的非對稱密鑰:
- 支持 ECDSA 算法;
- 使用 P-256 橢圓曲線;
- 可以從 CryptoKey 中提取;
- 可以跟 sign()和 verify()方法一起使用。
- 那么可以參考如下代碼:
(async function() { const params = { name: 'ECDSA', namedCurve: 'P-256' }; const keyUsages = ['sign', 'verify']; const {publicKey, privateKey} = await crypto.subtle.generateKey(params, true, keyUsages);console.log(publicKey); // CryptoKey {type: "public", extractable: true, algorithm: {...}, usages: Array(1)} console.log(privateKey); // CryptoKey {type: "private", extractable: true, algorithm: {...}, usages: Array(1)} })();
-
導出和導入密鑰
- 如果密鑰是可提取的,那么就可以在 CryptoKey 對象內部暴露密鑰原始的二進制內容。使用exportKey()方法并指定目標格式(“raw”、“pkcs8”、“spki"或"jwk”)就可以取得密鑰。這個方法返回一個期約,解決后的 ArrayBuffer 中包含密鑰:
(async function() { const params = { name: 'AES-CTR', length: 128 }; const keyUsages = ['encrypt', 'decrypt']; const key = await crypto.subtle.generateKey(params, true, keyUsages); const rawKey = await crypto.subtle.exportKey('raw', key); console.log(new Uint8Array(rawKey)); // Uint8Array[93, 122, 66, 135, 144, 182, 119, 196, 234, 73, 84, 7, 139, 43, 238, // 110] })();
- 與 exportKey()相反的操作要使用 importKey()方法實現。importKey()方法的簽名實際上是generateKey()和 exportKey()的組合。下面的方法會生成密鑰、導出密鑰,然后再導入密鑰:
(async function() { const params = { name: 'AES-CTR', length: 128 }; const keyUsages = ['encrypt', 'decrypt']; const keyFormat = 'raw'; const isExtractable = true; const key = await crypto.subtle.generateKey(params, isExtractable, keyUsages); const rawKey = await crypto.subtle.exportKey(keyFormat, key); const importedKey = await crypto.subtle.importKey(keyFormat, rawKey, params.name, isExtractable, keyUsages); console.log(importedKey); // CryptoKey {type: "secret", extractable: true, algorithm: {...}, usages: Array(2)} })();
- 如果密鑰是可提取的,那么就可以在 CryptoKey 對象內部暴露密鑰原始的二進制內容。使用exportKey()方法并指定目標格式(“raw”、“pkcs8”、“spki"或"jwk”)就可以取得密鑰。這個方法返回一個期約,解決后的 ArrayBuffer 中包含密鑰:
-
從主密鑰派生密鑰
- 使用 SubtleCrypto 對象可以通過可配置的屬性從已有密鑰獲得新密鑰。SubtleCrypto 支持一個 deriveKey()方法和一個 deriveBits()方法,前者返回一個解決為 CryptoKey 的期約,后者返回一個解決為 ArrayBuffer 的期約。
- 注意,deriveKey()與 deriveBits()的區別很微妙,因為調用 deriveKey()實際上與調用 deriveBits()之后再把結果傳給 importKey()相同。deriveBits()方法接收一個算法參數對象、主密鑰和輸出的位長作為參數。當兩個人分別擁有自己的密鑰對,但希望獲得共享的加密密鑰時可以使用這個方法。下面的例子使用 ECDH 算法基于兩個密鑰對生成了對等密鑰,并確保它們派生相同的密鑰位:
(async function() { const ellipticCurve = 'P-256'; const algoIdentifier = 'ECDH'; const derivedKeySize = 128; const params = { name: algoIdentifier, namedCurve: ellipticCurve }; const keyUsages = ['deriveBits']; const keyPairA = await crypto.subtle.generateKey(params, true, keyUsages); const keyPairB = await crypto.subtle.generateKey(params, true, keyUsages); // 從 A 的公鑰和 B 的私鑰派生密鑰位const derivedBitsAB = await crypto.subtle.deriveBits(Object.assign({ public: keyPairA.publicKey }, params), keyPairB.privateKey, derivedKeySize); // 從 B 的公鑰和 A 的私鑰派生密鑰位const derivedBitsBA = await crypto.subtle.deriveBits(Object.assign({ public: keyPairB.publicKey }, params), keyPairA.privateKey, derivedKeySize); const arrayAB = new Uint32Array(derivedBitsAB); const arrayBA = new Uint32Array(derivedBitsBA); // 確保密鑰數組相等console.log( arrayAB.length === arrayBA.length && arrayAB.every((val, i) => val === arrayBA[i])); // true })();
- deriveKey()方法是類似的,只不過返回的是 CryptoKey 的實例而不是 ArrayBuffer。下面的例子基于一個原始字符串,應用 PBKDF2 算法將其導入一個原始主密鑰,然后派生了一個 AES-GCM 格式的新密鑰:
(async function() { const password = 'foobar'; const salt = crypto.getRandomValues(new Uint8Array(16)); const algoIdentifier = 'PBKDF2'; const keyFormat = 'raw'; const isExtractable = false; const params = {name: algoIdentifier }; const masterKey = await window.crypto.subtle.importKey( keyFormat, (new TextEncoder()).encode(password), params, isExtractable, ['deriveKey'] ); const deriveParams = { name: 'AES-GCM', length: 128 }; const derivedKey = await window.crypto.subtle.deriveKey( Object.assign({salt, iterations: 1E5, hash: 'SHA-256'}, params), masterKey, deriveParams, isExtractable, ['encrypt']); console.log(derivedKey); // CryptoKey {type: "secret", extractable: false, algorithm: {...}, usages: Array(1)} })();
-
使用非對稱密鑰簽名和驗證消息
- 通過 SubtleCrypto 對象可以使用公鑰算法用私鑰生成簽名,或者用公鑰驗證簽名。這兩種操作分別通過 SubtleCrypto.sign()和 SubtleCrypto.verify()方法完成。
- 簽名消息需要傳入參數對象以指定算法和必要的值、CryptoKey 和要簽名的 ArrayBuffer 或ArrayBufferView。下面的例子會生成一個橢圓曲線密鑰對,并使用私鑰簽名消息:
(async function() { const keyParams = { name: 'ECDSA', namedCurve: 'P-256' }; const keyUsages = ['sign', 'verify']; const {publicKey, privateKey} = await crypto.subtle.generateKey(keyParams, true, keyUsages); const message = (new TextEncoder()).encode('I am Satoshi Nakamoto'); const signParams = { name: 'ECDSA', hash: 'SHA-256' }; const signature = await crypto.subtle.sign(signParams, privateKey, message); console.log(new Uint32Array(signature)); // Uint32Array(16) [2202267297, 698413658, 1501924384, 691450316, 778757775, ... ] })();
- 希望通過這個簽名驗證消息的人可以使用公鑰和 SubtleCrypto.verify()方法。這個方法的簽名幾乎與 sign()相同,只是必須提供公鑰以及簽名。下面的例子通過驗證生成的簽名擴展了前面的例子:
(async function() { const keyParams = { name: 'ECDSA', namedCurve: 'P-256' }; const keyUsages = ['sign', 'verify']; const {publicKey, privateKey} = await crypto.subtle.generateKey(keyParams, true, keyUsages); const message = (new TextEncoder()).encode('I am Satoshi Nakamoto'); const signParams = { name: 'ECDSA', hash: 'SHA-256' }; const signature = await crypto.subtle.sign(signParams, privateKey, message); const verified = await crypto.subtle.verify(signParams, publicKey, signature, message); console.log(verified); // true })();
-
使用對稱密鑰加密和解密
- SubtleCrypto 對象支持使用公鑰和對稱算法加密和解密消息。這兩種操作分別通過 SubtleCrypto. encrypt()和 SubtleCrypto.decrypt()方法完成。
- 加密消息需要傳入參數對象以指定算法和必要的值、加密密鑰和要加密的數據。下面的例子會生成對稱 AES-CBC 密鑰,用它加密消息,最后解密消息:
(async function() { const algoIdentifier = 'AES-CBC'; const keyParams = { name: algoIdentifier, length: 256 }; const keyUsages = ['encrypt', 'decrypt']; const key = await crypto.subtle.generateKey(keyParams, true, keyUsages); const originalPlaintext = (new TextEncoder()).encode('I am Satoshi Nakamoto'); const encryptDecryptParams = { name: algoIdentifier, iv: crypto.getRandomValues(new Uint8Array(16)) }; const ciphertext = await crypto.subtle.encrypt(encryptDecryptParams, key, originalPlaintext);console.log(ciphertext); // ArrayBuffer(32) {} const decryptedPlaintext = await crypto.subtle.decrypt(encryptDecryptParams, key, ciphertext); console.log((new TextDecoder()).decode(decryptedPlaintext)); // I am Satoshi Nakamoto })();
-
包裝和解包密鑰
- SubtleCrypto 對象支持包裝和解包密鑰,以便在非信任渠道傳輸。這兩種操作分別通過 SubtleCrypto.wrapKey()和 SubtleCrypto.unwrapKey()方法完成。包裝密鑰需要傳入一個格式字符串、要包裝的 CryptoKey 實例、要執行包裝的 CryptoKey,以及
- 一個參數對象用于指定算法和必要的值。下面的例子生成了一個對稱 AES-GCM 密鑰,用 AES-KW 來包裝這個密鑰,最后又將包裝的密鑰解包:
(async function() { const keyFormat = 'raw'; const extractable = true; const wrappingKeyAlgoIdentifier = 'AES-KW'; const wrappingKeyUsages = ['wrapKey', 'unwrapKey']; const wrappingKeyParams = { name: wrappingKeyAlgoIdentifier, length: 256 }; const keyAlgoIdentifier = 'AES-GCM'; const keyUsages = ['encrypt']; const keyParams = { name: keyAlgoIdentifier, length: 256 }; const wrappingKey = await crypto.subtle.generateKey(wrappingKeyParams, extractable, wrappingKeyUsages); console.log(wrappingKey); // CryptoKey {type: "secret", extractable: true, algorithm: {...}, usages: Array(2)} const key = await crypto.subtle.generateKey(keyParams, extractable, keyUsages); console.log(key); // CryptoKey {type: "secret", extractable: true, algorithm: {...}, usages: Array(1)} const wrappedKey = await crypto.subtle.wrapKey(keyFormat, key, wrappingKey, wrappingKeyAlgoIdentifier); console.log(wrappedKey); // ArrayBuffer(40) {} const unwrappedKey = await crypto.subtle.unwrapKey(keyFormat, wrappedKey, wrappingKey, wrappingKeyParams, keyParams, extractable, keyUsages); console.log(unwrappedKey); // CryptoKey {type: "secret", extractable: true, algorithm: {...}, usages: Array(1)} })()
-
小結
- 除了定義新標簽,HTML5 還定義了一些 JavaScript API。這些 API 可以為開發者提供更便捷的 Web接口,暴露堪比桌面應用的能力。本章主要介紹了以下 API。
- Atomics API 用于保護代碼在多線程內存訪問模式下不發生資源爭用。
- postMessage() API 支持從不同源跨文檔發送消息,同時保證安全和遵循同源策略。
- Encoding API 用于實現字符串與緩沖區之間的無縫轉換(越來越常見的操作)。
- File API 提供了發送、接收和讀取大型二進制對象的可靠工具。
- 媒體元素
<audio>
和<video>
擁有自己的 API,用于操作音頻和視頻。并不是每個瀏覽器都會支持所有媒體格式,使用 canPlayType()方法可以檢測瀏覽器支持情況。 - 拖放 API 支持方便地將元素標識為可拖動,并在操作系統完成放置時給出回應。可以利用它創建自定義可拖動元素和放置目標。
- Notifications API 提供了一種瀏覽器中立的方式,以此向用戶展示消通知彈層。
- Streams API 支持以全新的方式讀取、寫入和處理數據。
- Timing API 提供了一組度量數據進出瀏覽器時間的可靠工具。
- Web Components API 為元素重用和封裝技術向前邁進提供了有力支撐。
- Web Cryptography API 讓生成隨機數、加密和簽名消息成為一類特性。
- 除了定義新標簽,HTML5 還定義了一些 JavaScript API。這些 API 可以為開發者提供更便捷的 Web接口,暴露堪比桌面應用的能力。本章主要介紹了以下 API。