博客 / 詳情

返回

走進前端二進制

由於格式和圖片解析問題,可以前往博客 閲讀原文

在實際的開發過程中經常會遇到二進制數據,常見的就有文件的上傳、下載等等,還有比較重要的圖片裁剪、灰度處理等等,這些場景都會涉及到二進制。相信很多開發者對這方面可能一知半解或者就是久而忘之,本人剛開始也是對這方面空白,通過全方位的學習後其實也挺簡單,整體總結可以直奔文中

前端二進制是一種關鍵的數據表示和處理技術,它在前端開發中具有廣泛的應用。瞭解和掌握二進制數據有助於優化性能、原生數據的處理等等

掃碼關注公眾號,查看更多優質文章

ArrayBuffer

ArrayBuffer 對象用來表示通用的、固定長度的原始二進制數據緩衝區;它是一個字節數組,通常在其他語言中稱為“byte array”。你不能直接操作 ArrayBuffer 中的內容;而是要通過 類型化數組對象 或 DataView 對象來操作

構造函數:

new ArrayBuffer(bytelength: number); // 創建指定字節的buffer緩衝區

實例屬性方法:

  • byteLength:獲取buffer的字節長度和構造函數傳入的值相等
  • slice:拷貝指定位置的內容並返回新的buffer

視圖對象

arraybuffer只是創建了一塊連續的內存地址引用,裏面是什麼內容不能直接讀取,如果要操作buffer對象需要使用視圖對象,這些視圖對象只是用來解析buffer中的內容實際並不會儲存任何內容

TypeArray

這些視圖對象看上去更像數組Array,但他們並不是數組而是在ArrayBuffer上統稱的類型術語,JS提供了多種視圖對象:Uint8Array、Int8Array、Uint16Array、Int16Array、Uint32Array、Int32Array、Float32Array、Float64Array、Uint8ClampedArray、BigInt64Array、BigUint64Array等等

這些視圖對象享有共有的方法和屬性,所以搞懂某一個通用方法就可以了, 唯一不同的是不同的視圖對象對buffer的解析不同如:Uint8Array中是以1字節8位為一個基本單位,Uint16Array則是以2字節16位為一個基本單位,如果buffer的長度為2字節,那麼在uint8長度也為2,而uint16的長度則為1

Uint8Array

Uint8Array 數組類型表示一個 8 位無符號整型數組,8位為一個字節用來表示每個位置上的數字,也就是説Uint8的每一位的數字範圍為:0 - 2^8-1(255),只需要記住每位是由1字節8位組成就可以推到出來(後面會有其他類型的數組都是同一個道理)

構造函數

類型定義:

new Uint8Array(); // 創建空長度
new Uint8Array(len: number); // 創建用0太填滿的指定長度
new Uint8Array(array | arraylike); // 創建時指定值
new Uint8Array(buffer, byteOffset?, len?); // 從存在的buffer中的指定位置截取指定長度

列子:

// 傳入存在的buffer
const buffer = new ArrayBuffer(10);
new Uint8Array(buffer);

// 確定值
new Uint8Array([1,2,3])

// 創建指定長度
new Uint8Array(10); // 長度為10

實例化Uint8Array底層都會創建相應的ArrayBuffer,對實例的操作都是作用到ArrayBuffer上

屬性

  • BYTES_PER_ELEMENT:每個元素的字節數,uint8為1字節8位,uint16為2字節16位,以此類推
  • buffer:所引用的ArrayBuffer
  • byteLength:所引用的ArrayBuffer的長度
  • byteOffset:返回具體其引用ArrayBuffer的起始位置偏移量
  • length:數組的長度

方法

Uint8Array等擁有數組Array的所有方法, 初次之外還有set方法常用來合併多個類型數組

一個將很大的圖片分段請求最後合併下載的例子:

const buffers: ArrayBuffer[] = []; // 儲存請求的所有的圖片buffer
const bufferTotalLen = buffers.reduce((p, c) => (p += c.byteLength), 0); // buffer總長度
const allBuffer = new Uint8Array(bufferTotalLen); // 合併成最後的buffer
let position = 0, begin = 0;
while (begin < buffers.length) {
  const subBuffer = new Uint8Array(buffers[begin]);
  allBuffer.set(subBuffer, position);
  position += subBuffer.length;
  begin++;
}
const blob = new Blob([allBuffer.buffer], { type: "image/png" }); // 構造blob對象
const url = URL.createObjectURL(blob);
// ...

除此之外的其他有關的ArrayBuffer的視圖api雷同,可自行查看文檔

以下是幾個不同類型的array對同一段buffer的處理不同點:

  • Uint8Array —— 將 ArrayBuffer 中的每個字節視為 0 到 255 之間的單個數字(每個字節是 8 位,因此只能容納那麼多)。這稱為 “8 位無符號整數”。
  • Uint16Array —— 將每 2 個字節視為一個 0 到 65535 之間的整數。這稱為 “16 位無符號整數”。
  • Uint32Array —— 將每 4 個字節視為一個 0 到 4294967295 之間的整數。這稱為 “32 位無符號整數”。
  • Float64Array —— 將每 8 個字節視為一個 5.0x10-3241.8x10308 之間的浮點數。

DataView

DataView 是在 ArrayBuffer 上的一種特殊的超靈活“未類型化”視圖。它允許以任何格式訪問任何偏移量(offset)的數據

上面列出的TypeArray由於固定了格式所以每個索引的格式都是相同的,所以只能使用索引方式獲取;而DataView沒有固定格式直接作用於ArrayBuffer上,可以使用任何類型進行讀取,所以更加靈活

構造方法

其構造方法需要傳入buffer實例

new DataView(buffer, offset?, len?)

屬性

  • byteLength:字節長度
  • byteOffset:首位在ArrayBuffer中的偏移量
  • buffer:ArrayBuffer引用對象

方法

  • getUint8:以Uint8格式獲取指定索引的數據
  • setUint8:setUint8(idx, value)以Uint8格式設置某個索引位置的值,這些值需滿足uint8的值範圍。如setUint8(0, 256)將不會滿足0~255的範圍限制,其值將會變為0

除了Uint8格式外還有其它TypeArray的格式相同方法,請參考MDN

視圖對象總結

字符二進制

除了一些二進制數據外,還可以將一些字符進行二進制的相互轉換,js中提供了TextEncoder和TextDecoder分別將字符轉為二進制、將二進制轉為字符

TextEncoder例子:

// 將字符轉換成Uint8Array
const encoder = new TextEncoder();
const uint8 = encoder.encode("測試");
console.log(uint8); // Uint8Array(6) [230, 181, 139, 232, 175, 149, buffer: ArrayBuffer(6), byteLength: 6, byteOffset: 0, length: 6]

TextDecoder例子:

// 將ArrayBuffer轉換成字符
const decoder = new TextDecoder("utf8");
console.log(decoder.decode(uint8));  // 測試
console.log(decoder.decode(uint8.buffer)); // 測試
console.log(decoder.decode(uint8.slice(0, 3)));  // 測
console.log(decoder.decode(uint8.buffer.slice(0, 3)));  // 測

因為TypeArray位Uint8Array所以和ArrayBuffer的字節長度一致,所以對buffer或array的截取一致獲取的結果也一樣。從上面可以看到Uint8Array中的[230, 181, 139]表示一個字符測

const uint8 = new Uint8Array([230, 181, 139]);
const decoder = new TextDecoder("utf8");
decoder.decode(uint8); // 測

字符二進制流

字符二進制通常用來處理比較大的數據字符流,而TextEncoder這種通常是一次性進行轉換;二進制流也包含TextEncoderStream和TextDecoderStream兩種方法

Blob

Blob 對象表示一個不可變、原始數據的類文件對象。它的數據可以按文本或二進制的格式進行讀取,也可以轉換成 ReadableStream 來用於數據操作

前面我們講了ArrayBuffer和TypeArray等相關二進制的方法, 但這些都是操作比較低級的數據,而blob則是有類型的二進制數據,相對於比較低級的數據更容易大家所理解

構造器

new Blob(blobParts, options);
  • blobParts:由blob、buffersource、string類型組成的數組值
  • options:

    • type:表示blob類型,通常都是mime類型
    • endings:是否轉換換行符,使 Blob 對應於當前操作系統的換行符(\r\n\n)。默認為 "transparent"(啥也不做),不過也可以是 "native"(轉換)

例子:

// 將字符轉為blob,並指定類型為 text/plain文本類型
new Blob(['測試'], { type: 'text/plain' });

// 將buffersource轉為blob
new Blob([new Uint8Array([1,2,3])]);

屬性

  • size:blob的數據大小
  • type:blob的類型

方法

  • arrayBuffer:異步返回blob的二進制格式的ArrayBuffer

    blob = new Blob(['一段文本'], { type: 'text/plain' });
    buffer = await blob.arrayBuffer(); // ArrayBuffer
  • slice:劃分指定範圍的blob,類似於array的slice
  • stream:返回blob的可讀流ReadableStream,通常流用來處理比較大的內容

    const readableStream = blob.stream();
    const reader = readableStream.getReader();
    while(true) {
      const { done, value } = await reader.read();
      if (done) break;
    }
  • text:異步返回blob的所有內容的UTF8格式的字符串

    blob = new Blob(['一段文本'], { type: 'text/plain' });
    text = await blob.text(); // 一段文本

除了使用自身的方法外, 還可以使用FileReader讀取內容並轉換

reader = new FileReader();
reader.onload = e => console.log(e.target.result);
reader.readAsText(blob);

用途

  1. 用blob對象構造url使用,使用URL的createObjectURL方法生成一個唯一映射此blob的url

    // 一個圖片blob
    blob = new Blob([], { type: 'image/png' });
    url = URL.createObjectURL(blob);
    img.src = url;
    // 使用後銷燬
    URL.revokeObjectURL();
  2. 文件分片上傳

    const blob = new Blob([]);
    const chunkSize = 1024 * 1024;
    blobs = [];
    offset = 0;
    while(offset < blob.size) {
      blobs.push(blob.slice(offset, chunkSize));
      offset += chunkSize;
    }
    blobs.map(blob => fetch('xx', data: blob))

除了以上外對於canvas也可以轉換為blob

const img = new Image();
const canvas = document.querySelector("canvas");
const ctx = canvas?.getContext("2d");
ctx?.drawImage(img, 0, 0);
canvas?.toBlob(e => console.log(e)); // 轉為為 blob
canvas?.toDataURL("text/plain"); // 轉為為 base64字符串

File

文件(File)接口提供有關文件的信息,File 對象繼承了 Blob,並擴展了與文件系統相關的功能,且可以用在任意的 Blob 類型的 context 中。比如説, FileReader, URL.createObjectURL, createImageBitmap, 及 XMLHttpRequest.send()) 都能處理 Blob 和 File

File對象

通常有兩種方式獲取File對象:構造函數<input type="file">

構造函數:

new File(bits: Array<ArrayBuffer | ArrayBufferView | String | Blob>, name, { type, lastModified })

構造函數方式類似於blob的構造函數

例子:

const file = new File(['我是一段文本信息'], 'text.txt', { type: 'text/plain', lastModified: Date.now() })

輸入框獲取:

// 用户點擊選擇文件後,可以通過屬性獲取
<input type="file">
console.log(e.files[0])

屬性

  • lastModified:當前file的最後修改時間,毫秒數
  • lastModifiedDate:最後修改時間date對象
  • name:文件名字
  • size:文件大小
  • type:文件的mime類型

由於File繼承於Blob對象所以也用了Blob的相關屬性

方法

由於File繼承於Blob對象所以也用了Blob的相關屬性,如slice方法,通常用來對大文件做切片處理,參考以上blob的切片操作這裏就不演示了

FileReader

FileReader 對象允許 Web 應用程序異步讀取存儲在用户計算機上的文件(或原始數據緩衝區)的內容,主要目的就是從 File 或 Blob 對象中讀取的文件或數據

構造函數

Filereader通過構造函數生成一個對象

const reader = new FileReader();

屬性

  • readyState:當前的讀取狀態,其中包含以下幾個常量

    常量名 描述
    EMPTY 0 還沒有加載任何數據。
    LOADING 1 數據正在被加載。
    DONE 2 已完成全部的讀取請求。
  • result:讀取完後的內容,讀取操作後效,文件內容的格式取決於哪種讀取方式
  • error:讀取錯誤的對象

事件

  • onabort:讀取時中斷時觸發
  • onerror:讀取時發生錯誤時觸發
  • onload:所有內容的讀取都是異步的,需要通過此事件獲取讀取的數據,讀取成功後觸發

方法

  • abort:中斷讀取
  • readAsArrayBuffer:將文件內容讀取為arraybuffer形式的數據
  • readAsDataURL:將文件內容讀取為base64字符串
  • readAsText:將文件內容讀取為文本字符串
  • readAsBinaryString:將文件內容讀取為原始二進制數據
讀取數據都是異步的,需要通過onload事件獲取讀取內容

FileReaderSync

FileReaderSync接口允許以同步的方式讀取 File 或 Blob 對象中的內容,該接口只能在webwoker中使用,由於讀取文件是非常耗時的過程,在主線程使用會造成頁面卡頓現象,因此對於文件的操作在webwoker不會影響主線程

FileReaderSync和FileReader擁有相同的方法和屬性,只不過前者的讀取是同步的

// webwoker
function readFileSync(file) {
  const reader = new FileReaderSync();
  const buffer = reader.readAsArrayBuffer(file);
  return buffer;
}

Stream

Stream API 允許 JavaScript 以編程方式訪問從網絡接收的數據流,並且允許開發人員根據需要處理它們

流可以讓程序不需要接受全部的內容後才可以展示操作,使用流可以將大型數據拆分成小塊並逐步處理,如視頻播放不需要加載全部減小延遲、提高內存的吞吐量

除此之外可以檢測流何時開始或結束,將流鏈接在一起,根據需要處理錯誤和取消流,並對流的讀取速度做出反應

流的基礎應用圍繞着使響應可以被流處理展開。例如,一個成功的 fetch 請求返回的響應體可以暴露為 ReadableStream,之後你可以使用 ReadableStream.getReader 創建一個 reader 讀取它,使用 ReadableStream.cancel 取消它等等。

更復雜的應用包括使用 ReadableStream 構造函數創建你自己的流,例如進入 service worker 去處理流

ReadableStream

ReadableStream 可以構造一個可讀流,在前端領域通常fetch的 Response的body屬性 就是一個ReadableStream對象

構造函數

new ReadableStream(underlyingSource?, queuingStrategy?)

underlyingSource包括以下屬性:

  • start(controller):對象在創建時會執行,controller是個 ReadableStreamDefaultController ,通常在自己構造可讀流時在此方法中 使用 controller.equeue 方法往可讀流中添加數據;可以返回promise,則下一次的必須等待上一次結束後才會執行
  • pull(controller):流內部隊列不滿時會重複調用這個方法,根據流的背壓來判斷流有沒有滿,通常這裏不做任何事
  • cancel(reason):當流被取消時觸發,如:取消、出錯等等
  • type:表示流的內容類型,通常都是bytes

queuingStrategy 定義流的隊列策略 背壓

  • highWaterMark:在背壓前內部隊列可以容納的總塊數
  • size:每個chunk的大小

例子:

const chunks = [...];
let offset = 0;
new ReadableStream(
  {
    start(controller) {
      console.log("開始讀取");
      async function read() {
        if (offset < chunks.length) {
          const chunk = chunks[offset];
          // 往可讀流中寫入數據
          controller.enqueue(chunk);
          read();
        } else {
          console.log('讀取結束');
          // 關閉
          controller.close();
        }
      }
      read();
    },
    type: "bytes",
  },
  { highWaterMark: 100 } // 定義背壓
);
這個例子自定義了一個可讀流,在構建ReadableStream時不斷地往可讀流中寫入數據,以便可讀流可以讀取到數據

ReadableStream構造函數返回一個可讀流實例,其包含多個方法和屬性

實例屬性

  • locked:返回改可讀流是否被鎖定到一個reader,也就是説當被鎖定時,同時只能被一個可讀流使用

實例方法

  • cancel:取消流的讀取,取消後會觸發內部的cancel屬性
  • getReader:創建一個讀取器並將流鎖定於其上,其他讀取器將不能讀取它直到它被釋放;這個讀取器是一個 ReadableStreamDefaultReader 實例,其包含 read、cancel等方法,通常都是使用 read來讀取 ReadableStream 內部的數據

    // 一個簡單的例子
    const readstream = new ReadableStream(); // 假如自定義的可讀流內部有數據
    const reader = readstream.getReader();  // 獲取 reader對象
    while(true) {
      const { done, value } = await reader.read(); // 不斷讀取數據
      if (done) {
        console.log('讀取完畢');
      } else {
        console.log('當前數據塊:', value);
      }
    }
    上面演示了下從自己創建的可讀流中讀取,通常都是從fetch的response的body屬性中獲取reader,然後不斷讀取接受的數據 (注:response的body屬性是一個 ReadableStream)
  • pipeThrough:提供將當前流管道輸出到一個轉換(transform)流或可寫/可讀流對的鏈式方法。簡單來説就是一個管道對原始數據做點什麼,比如修改、壓縮啥的,對於平時的需求一般用不上,不過使用此功能可以很方便的做到一些有趣的效果

    pipeThrough類型定義為:

    pipeThrough(transformStream, options?)

    transformStream是由可讀流和可寫流組成的 TransformStream(或者結構為 {writable, readable} 的對象),writable 流寫入的數據在某些狀態下可以被 readable 流讀取。詳細使用請看WriableStream

  • pipeTo:將當前 ReadableStream 管道輸出到給定的 WritableStream,此方法是個異步方法,當所有的寫入操作結束後表示結束

    pipeTo類型定義:

    pipeTo(destination, options?)

    destination表示一個可寫流WriableStream對象

  • tee:拷貝當前可讀流,返回包含兩個 ReadableStream 實例分支的數組

WritableStream

WritableStream 接口將流數據寫入目的地,該對象帶有內置的背壓和隊列,一般是將可讀流的數據寫入

構造函數

new WritableStream(underlyingSink, queuingStrategy)

underlyingSink包括以下屬性:

  • start(controller):對象被構建時立刻執行,controller是一個 WritableStreamDefaultController 對象
  • write(chunk, controller):當一個新的數據準備好寫入底層接收器時調用此方法,chunk表示當前要寫入的數據塊,controller同上。一般可以在這裏對數據的進行進度條計算
  • close(controller):所有數據寫入完畢後將會調用此方法
  • abort(reason):可寫流取消或出現錯誤時觸發

queuingStrategy 定義流的隊列策略 背壓

  • highWaterMark:在背壓前內部隊列可以容納的總塊數

例子:

// 模擬請求
const res = await fetch();
const totalLength = xxx;
let offset = 0;
const uint8 = new Uint8Array(totalLength);

// 定義背壓
const highWaterMark = new CountQueuingStrategy({ highWaterMark: 100 });
const writer = new WritableStream(
  {
    write(chunk: Uint8Array, controller) {
      return new Promise(resolve => {
        uint8.set(chunk, offset); // 將讀取的數據儲存
        offset += chunk.byteLength;  // 計算已經接受到的數據大小
        progress.textContent =
          ((offset / totalLength) * 100).toFixed(2) + "%";  // 計算進度
        setTimeout(resolve, 0);
      });
    },
  },
  highWaterMark // 設置背壓,避免大文件讀到內存中
);
await res.body?.pipeTo(writer); // 記住 fetch的Response的body是個可讀流,使用pipeTo方法
以上通過將fetch的Response的body可讀流通過自定義的WriableStream寫入,在write方法中對每塊數據進行獲取,並計算進度

WritableStream構造函數返回一個可寫流實例,其包含多個方法和屬性

實例屬性

  • locked:表示 WritableStream 是否鎖定到一個 writer

實例方法

  • abort:取消流的寫入,會觸發內部的abort屬性方法
  • close:關閉可寫流,會觸發內部的close屬性方法
  • getWriter:獲取返回一個新的 WritableStreamDefaultWriter 實例並且將流鎖定到該實例,該實例對象包含abort、close、write方法;還包含一個ready屬性返回一個promise,當流填充內部隊列的所需大小從非正數變為正數時兑現,表明它不再應用背壓

    const chunks: Uint8Array[] = [];
    const highWaterMark = new CountQueuingStrategy({ highWaterMark: 100 });
    const writeStream = new WritableStream(
      {
        write(chunk) {
          return new Promise(resolve => {
            // chunk為讀取的數據,用它做點啥...
            resolve();
          });
        },
      },
      highWaterMark
    );
    const writer = writeStream.getWriter(); // 獲取writer對象
    chunks.forEach(chunk => writer.ready.then(() => writer.write(chunk))); // 循環讀取數據

TransformStream

TransformStream 接口表示鏈式管道傳輸(pipe chain)轉換流(transform stream)概念的具體實現。他可以用於將編碼或解碼視頻幀、壓縮或解壓縮數據或以其他的方式從一種數據轉換成另一種數據

以下是一個將小寫字母變成大寫字母例子:

const strs = ["a", "b", "c", "d"];
let offset = 0;
const reader = new ReadableStream({
  start(controller) {
    function read() {
      if (offset < strs.length) {
        controller.enqueue(strs[offset]);
        offset++;
        read();
      } else {
        controller.close();
      }
    }
    read();
  },
});
const transfer = new TransformStream({
  transform(chunk: string, controller) {
    console.log("transform:", chunk);
    controller.enqueue(chunk.toUpperCase());
  },
});
const writer = new WritableStream({
  write(chunk) {
    console.log("write:", chunk);
  },
});
reader.pipeThrough(transfer).pipeTo(writer);

Response.body

一個 ReadableStream,或者對於使用空的 body 屬性構建的任意的 Response 對象,或沒有任何主體的實際 HTTP 響應,則為null

實踐

視頻流

頁面上添加視頻播放器:

// 視頻播放器
<video src="/api/video/range" controls="controls" muted style="max-width: 100%;"></video>

node端進行返回請求數據,默認在沒有結束範圍時只返回1M的數據,如果有結束返回就返回指定範圍的數據:

// 對視頻進行範圍請求
@Get('/api/video/range')
async getVideoRanges(@Req() req: Request, @Res() res: Response) {
  // 直接打開鏈接時禁止訪問
  const referer = req.headers.referer;
  const host = req.headers.host;
  const url = referer && new URL(referer);
  if (!referer || url?.host !== host) {
    throw new ForbiddenException('禁止訪問');
  }
  const requestRange = req.headers['range'];
  const parts = requestRange?.replace(/bytes=/, '').split('-');
  const filePath = resolve(__dirname, '../data/video.mp4');
  const fileStat = await stat(filePath);
  const fileSize = fileStat.size;
  const start = Math.min(parseInt(parts?.[0], 10) || 0, fileSize - 1); // 這裏的大小值需減1
  const end = Math.min(
    parseInt(parts?.[1]) || start + 1024 * 1024, // 每次沒有結束值,只返回1MB的數據
    fileSize - 1,
  );
  const chunkSize = end - start + 1;
  const head = {
    'Content-Range': `bytes ${start}-${end}/${fileSize}`,
    'Accept-Ranges': 'bytes',
    'Content-Length': chunkSize,
    'Content-Type': 'video/mp4',
  };

  // range請求返回部分內容
  if (requestRange) {
    const stream = createReadStream(filePath, { start, end });
    res.writeHead(206, head);
    stream.pipe(res);
  } else {
    // 非range請求返回整個視頻
    res.writeHead(200, {
      'Content-Length': fileSize,
      'Content-Type': 'video/mp4',
    });
    createReadStream(filePath).pipe(res);
  }
}

效果演示:

文件下載

生活中會遇到文件的下載這種場景,主要還是讀取文件流寫入到blob中,這樣也可以獲取到指定的進度。需要注意的是這種方式不適合大文件的下載,容易撐爆內存,具體的大文件下載可以查看我的「如何實現大文件下載」 一文

// 請求資源
async function fetchBigImage() {
  progress.setAttribute('style', 'transform: translate3d(-100%, 0, 0)');
  progressNum.textContent = 0.00;
  const res = await fetch('/big-size.png');
  const fileSize = res.headers.get('content-length');
  const filename = res.headers.get('Content-Disposition')?.split(";")[1]?.split('=')[1] || res.url.match(/\/([^\/]\.[^\/]*)/i)?.[1] || 'download.txt';
  const blobs = [];
  let downloaded = 0;
  const writer = new WritableStream({
    write(chunk) {
      blobs.push(chunk);
      downloaded += chunk.length;
      const percentComplete = (downloaded / fileSize) * 100;
      progress.setAttribute('style', `transform: translate3d(-${(100 - percentComplete).toFixed(2)}%, 0, 0)`);
      progressNum.textContent = percentComplete.toFixed(2);
    },
    close() {
      useDownload(filename, ...blobs);
    }
  });
  res.body.pipeTo(writer);
}

// 下載
function useDownload(filename, ...blobs) {
  const blob = new Blob([...blobs]);
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.setAttribute('href', url);
  a.download = filename || 'file.txt';
  a.click();
  a.remove();
  URL.revokeObjectURL(url);
}
user avatar openresty_xray 頭像 trophawk 頭像
2 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.