js實現文件流式下載文件方法詳解及完整代碼

JS實現流式打包下載說明

瀏覽器中的流式操作可以節省內存,擴大 JS 的應用邊界,比如我們可以在瀏覽器裡進行視頻剪輯,而不用擔心視頻文件將內存撐爆。

瀏覽器雖然有流式處理數據的 API,並沒有直接提供給 JS 進行流式下載的能力,也就是說即使我們可以流式的處理數據,但想將其下載到磁盤上時,依然會對內存提出挑戰。

這也是我們討論的前提:

  • 流式的操作,必須整個鏈路都是流式的才有意義,一旦某個環節是非流式(阻塞)的,就無法起到節省內存的作用。

本篇文章分析瞭如何在 JS中流式的處理數據 ,流式的進行下載,主要參考瞭 StreamSaver.js 的實現方案。

分為如下部分:

  • 流在計算機中的作用
  • 服務器流式響應
  • JS 下載文件的方式
  • JS 持有數據並下載文件的場景
  • 非流式處理、下載的問題
  • 瀏覽器流式 API
  • JS 流式的實現方案
  • 實現JS讀取本地文件並打包下載

流在計算機中的作用

流這個概念在前端領域中提及的並不多,但是在計算機領域中,流式一個非常常見且重要的概念。

當流這個字出現在 IO 的上下文中,常指的得就是分段的讀取和處理文件,這樣在處理文件時(轉換、傳輸),就不必把整個文件加載到內存中,大大的節省瞭內存空間的占用。

在實際點說就是,當你用著 4G 內存的 iPhone 13看電影時,並不需要擔心視頻文件數據把你的手機搞爆掉。

服務器流式響應

在談下載之前,先提一下流式響應。

如上可知,當我們從服務器下載一個文件時,服務器也不可能把整個文件讀取到內存中再進行響應,而是會邊讀邊響應。

那如何進行流式響應呢?

隻需要設置一個響應頭 Transfer-Encoding: chunked,表明我們的響應體是分塊傳輸的就可以瞭。

以下是一個 nodejs 的極簡示例,這個服務每隔一秒就會向瀏覽器進行一次響應,永不停歇。

require('http').createServer((request, response) => {
    response.writeHead(200, {
        'Content-Type': 'text/html',
        'Transfer-Encoding': 'chunked'
    })

    setInterval(() => {
        response.write('chunked\r\n')
    }, 1000)
}).listen(8000);

JS 下載文件的方式

在 js 中下載文件的方式,有如下兩類:

// 第一類:頁面跳轉、打開
location.href
window.open
iframe.src
a[download].click()

// 第二類:Ajax
fetch('/api/download')
    .then(res => res.blob())
    .then(blob => {
    // FileReader.readAsDataURL()
    const url = URL.createObjectURL(blob)
    // 借助第一類方式:location.href、iframe.src、a[download].click()
    window.open(url)
  })

不難看出,使用 Ajax 下載文件,最終還是要借助第一類方法才可以實現下載。

而第一類的操作都會導致一個行為:頁面級導航跳轉

所以我們可以總結得出瀏覽器的下載行為:

  • 在頁面級的跳轉請求中,檢查響應頭是否包含 Content-Disposition: attachment。對於 a[download] 和 createObjectURL的 url 跳轉,可以理解為瀏覽器幫忙加上瞭這個響應頭。
  • Ajax 發出的請求並不是頁面級跳轉請求,所以即使擁有下載響應頭也不會觸發下載行為。

兩類下載方式的區別

這兩種下載文件的方式有何區別呢?

第一類請求的響應數據直接由下載線程接管,可以進行流式下載,一邊接收數據一邊往本地寫文件。

第二類由 JS 線程接管響應數據,使用 API 將文件數據創建成 url 觸發下載。

但是相應的 API createObjectURLreadAsDataURL必須傳入整個文件數據才能進行下載,是不支持流的。也就是說一旦文件數據到瞭 JS 手中,想要下載,就必須把數據堆在內存中,直到拿到完整數據才能開始下載。

所以當我們從服務器下載文件時,應該盡量避免使用 Ajax ,直接使用 頁面跳轉類的 API 讓下載線程進行流式下載。

但是有些場景下,我們需要在 JS 中處理數據,此時數據在 JS 線程中,就不得不面對內存的問題。

JS 持有數據並下載文件的場景

以下場景,我們需要在 JS 中處理數據並進行文件下載。

  • 純前端處理文件流:在線格式轉換、解壓縮等
  • 整個數據都在前端轉換處理,壓根沒有服務端的事
  • 文章所要討論的情況
  • 接口鑒權:鑒權方案導致請求必須由 JS 發起,如 cookie + csrfTokenJWT
  • 使用 ajax :簡單但是數據都在內存中
  • (推薦)使用 iframe + form 實現:麻煩但是可以由下載線程流式下載
  • 服務端返回文件數據,前端轉換處理後下載
  • 如服務端返回多個文件,前端打包下載
  • (推薦)去找後端 ~~聊一聊~~

可以看到第一種情況是必須用 JS 處理的,我們來看一下如果不使用流式處理的話,會有什麼問題。

非流式處理、下載的問題

去網上搜索「前端打包」,99% 的內容都會告訴你使用 JSZip ,談起文件下載也都會提起一個 file-saver的庫(JSZip 官網也推薦使用這個庫下載文件)。

那我們就看一下這些流行庫的的問題。

<script setup lang="ts">
import { onMounted, ref } from "@vue/runtime-core";
import JSZip from 'jszip'
import { saveAs } from 'file-saver'

const inputRef = ref<HTMLInputElement | null>(null);
onMounted(() => {
  inputRef.value?.addEventListener("change", async (e: any) => {
    const file = e.target!.files[0]!
    const zip = new JSZip();
    zip.file(file.name, file);
    const blob = await zip.generateAsync({type:"blob"})
    saveAs(blob, "example.zip");
  });
});
</script>

<template>
  <button @click="inputRef?.click()">JSZip 文件打包下載</button>
  <input ref="inputRef" type="file" hidden />
</template>

以上是一個用 JSZip 的官方實例構建的 Vue 應用,功能很簡單,從本地上傳一個文件,通過 JSZip打包,然後使用 file-saver 將其下載到本地。

我們來直接試一下,上傳一個 1G+ 的文件會怎麼樣?

通過 Chrome 的任務管理器可以看到,當前的頁面內存直接跳到瞭 1G+

當然不排除有人的電腦內存比我們硬盤的都大的情況,豪不在乎內存消耗。

OK,即使你的電腦足以支撐在內存中進行隨意的數據轉換,但瀏覽器對 Blob 對象是有大小限制的。

官網的第一句話就是

If you need to save really large files bigger than the blob's size limitation or don't have enough RAM, then have a look at the more advanced StreamSaver.js
如果您需要保存比blob的大小限制更大的文件,或者沒有足夠的內存,那麼可以查看更高級的 StreamSaver.js

然後給出瞭不同瀏覽器所支持的 Max Blob Size,可以看到 Chrome 是 2G

所以不管是出於內存考慮,還是 Max Blob Size的限制,我們都有必要去探究一下流式的處理方案。

順便說一下這個庫並沒有什麼黑科技,它的下載方式和我們上面寫的是一樣的,隻不過處理瞭一些兼容性問題。

瀏覽器流式 API

Streams API 是瀏覽器提供給 JS 的流式操作數據的接口。

其中包含有兩個主要的接口:可讀流、可寫流

WritableStream

創建一個可寫流對象,這個對象帶有內置的背壓和排隊。

// 創建
const writableStream = new WritableStream({
  write(chunk) {
    console.log(chunk)
  }
})
// 使用
const writer = writableStream.getWriter()
writer.write(1).then(() => {
  // 應當在 then 再寫入下一個數據
    writer.write(2)
})
  • 創建時傳入 write 函數,在其中處理具體的寫入邏輯(寫入可讀流)。
  • 使用時調用 getWriter() 獲取流的寫入器,之後調用write 方法進行數據寫入。
  • 此時的 write 方法是被包裝後的,其會返回 Promise 用來控制背壓,當允許寫入數據時才會 resolve
  • 背壓控制策略參考 CountQueuingStrategy,這裡不細說。

ReadableStream

創建一個可讀的二進制操作,controller.enqueue向流中放入數據,controller.close表明數據發送完畢。

下面的流每隔一秒就會產生一次數據:

const readableStream = new ReadableStream({
  start(controller) {
        setInterval(() => {
            // 向流中放入數據
            controller.enqueue(value);
        // controller.close(); 表明數據已發完
        }, 1000)
  }
});

從可讀流中讀取數據:

const reader = readableStream.getReader()
while (true) {
  const {value, done} = await reader.read()
  console.log(value)
  if (done) break
}

調用 getReader() 可以獲取流的讀取器,之後調用 read() 便會開始讀取數據,返回 Promise

  • 如果流中沒有數據,便會阻塞(Promise penging)。
  • 當調用瞭controller.enqueuecontroller.close後,Promise就會resolve
  • done:數據發送完畢,表示調用瞭 controller.close
  • value:數據本身,表示調用瞭controller.enqueue

while (true) 的寫法在其他語言中是非常常見的,如果數據沒有讀完,我們就重復調用 read() ,直到 done 為true

fetch 請求的響應體和 Blob 都已經實現瞭 ReadableStream

Fetch ReadableStream

Fetch API 通過 Response 的屬性 body 提供瞭一個具體的 ReadableStream 對象。

流式的讀取服務端響應數據:

const response = await fetch('/api/download')
// response.body === ReadableStream
const reader = response.body.getReader()

while(true) {
  const {done, value} = await reader.read()
  console.log(value)
  if (done) break
}

Blob ReadableStream

Blob 對象的 stream 方法,會返回一個 ReadableStream

當我們從本地上傳文件時,文件對象 File 就是繼承自Blob

流式的讀取本地文件:

<input type="file" id="file">

document.getElementById("file")
  .addEventListener("change", async (e) => {
    const file: File = e.target.files[0];

    const reader = file.stream().getReader();
    while (true) {
      const { done, value } = await reader.read();
      console.log(value);
      if (done) break;
    }
    });

TransformStream

有瞭可讀、可寫流,我們就可以組合實現一個轉換流,一端轉換寫入數據、一端讀取數據。

我們利用 MessageChannel在兩方進行通信

const { port1, port2 } = new MessageChannel()

const writableStream = new WritableStream({
    write(chunk) {
        port1.postMessage(chunk)
    }
})

const readableStream = new ReadableStream({
    start(controller) {
        port2.onmessage = ({ data }) => {
            controller.enqueue(data)
        }
    }
});

const writer = writableStream.getWriter()
const reader = readableStream.getReader()

writer.write(123) // 寫入數據

reader.read() // 讀出數據 123

在很多場景下我們都會這麼去使用讀寫流,所以瀏覽器幫我們實現瞭一個標準的轉換流:TransformStream

使用如下:

const {readable, writable} = new TransformStream()

writable.getWriter().write(123) // 寫入數據

readable.getReader().read() // 讀出數據 123

以上就是我們需要知道的流式 API 的知識,接下來進入正題。

前端流式下載

ok,終於到瞭流式下載的部分。

這裡我並不會推翻自己前面所說:

  • 隻有頁面級跳轉會觸發下載。
  • 這意味著響應數據直接被下載線程接管。
  • createObjectURLreadAsDataURL 隻能接收整個文件數據。
  • 這意味當數據在前端時,隻能整體下載。

所以應該怎麼做呢?

Service worker

是的,黑科技主角Service worker,熟悉 PWA 的人對它一定不陌生,它可以攔截瀏覽器的請求並提供離線緩存。

Service Worker APIService workers 本質上充當 Web 應用程序、瀏覽器與網絡(可用時)之間的代理服務器。這個 API 旨在創建有效的離線體驗,它會攔截網絡請求並根據網絡是否可用來采取適當的動作、更新來自服務器的的資源。
—— MDN

這裡有兩個關鍵點:

  • 攔截請求
  • 構建響應

也就是說,通過 Service worker 前端完全可以自己充當服務器給下載線程傳輸數據。

讓我們看看這是如何工作的。

攔截請求

請求的攔截非常簡單,在Service worker中註冊 onfetch 事件,所有的請求發送都會觸發其回調。

通過 event.request 對象拿到 Request 對象,進而檢查 url 決定是否要攔截。

如果確定要攔截,就調用 event.respondWith 並傳入 Response 對象,既可完成攔截。

self.onfetch = event => {
    const url = event.request.url
    if (url === '攔截') {
      event.respondWith(new Response())
  }
}

new Response

Response就是 fetch()返回的 response 的構造函數。

直接看函數簽名:

interface Response: {
    new(body?: BodyInit | null, init?: ResponseInit): Response
}

type BodyInit = ReadableStream | Blob | BufferSource | FormData | URLSearchParams | string

interface ResponseInit {
    headers?: HeadersInit
    status?: number
    statusText?: string
}

可以看到,Response 接收兩個參數

  • 第一個是響應體 Body,其類型可以是 Blobstring等等,其中可以看到熟悉的 ReadableStream可讀流
  • 第二個是響應頭、狀態碼等

這意味著:

  • 在響應頭中寫入Content-Disposition:attachment,瀏覽器就會讓下載線程接管響應。
  • Body 構建成 ReadableStream,就可以流式的向下載線程傳輸數據。

也意味著前端自己就可以進行流式下載!

極簡實現

我們構建一個最簡的例子來將所有知識點串起來:從本地上傳文件,流式的讀取,流式的下載到本地。

是的這看似毫無意義,但這可以跑通流程,對學習來說足夠瞭。

關鍵點代碼分析

  • 通知 service worker 準備下載文件,等待 worker 返回 url 和writable
const createDownloadStrean = async(filename) = >{ // 通過 channel 接受數據 
    const {
        port1,
        port2
    } = new MessageChannel();

    // 傳遞 channel,這樣 worker 就可以往回發送消息瞭
    serviceworker.postMessage({
        filename
    },
    [port2]);

    return new Promise((resolve) = >{
        port1.onmessage = ({
            data
        }) = >{
            // 拿到url, 發起請求
            iframe.src = data.url;
            document.body.appendChild(iframe);
            // 返回可寫流
            resolve(data.writable)
        };
    });
}
  • Service worker 接受到消息,創建 urlReadableStream 、WritableStream,將 urlWritableStream通過 channel 發送回去。
js self.onmessage = (event) = >{
    const filename = event.data.filename // 拿到 channel 
    const port2 = event.ports[0] // 隨機一個 url 
    const downloadUrl = self.registration.scope + Math.random() + '/' + filename // 創建轉換流 
    const {
        readable,
        writable
    } = new TransformStream() // 記錄 url 和可讀流,用於後續攔截和響應構建 
    map.set(downloadUrl, readable) // 傳回 url 和可寫流 
    port2.postMessage({
        download: downloadUrl,
        writable
    },
    [writable])
}
  • 主線程拿到 url 發起請求(第 1 步 onmessage中),Service worker 攔截請求 ,使用上一步的 ReadableStream創建Response並響應。
self.onfetch = event => { const url = event.request.url // 從 map 中取出流,存在表示這個請求是需要攔截的 
const readableStream = map.get(url) 
if (!readableStream) return null map.delete(url)

const headers = new Headers({
    'Content-Type': 'application/octet-stream; charset=utf-8',
   'Content-Disposition': 'attachment',
    'Transfer-Encoding': 'chunked'
})
// 構建返回響應
event.respondWith(
    new Response(readableStream, { headers })
 )
}
  • 下載線程拿到響應,開啟流式下載(但是此時根本沒有數據寫入,所以在此就阻塞瞭)
  • 主線程拿到上傳的 File對象,獲取其ReadableStream並讀取,將讀取到的數據通過 WritableStream(第 1 步中返回的)發送出去。
input.addEventListener("change", async(e: any) = >{
    const file = e.target ! .files[0];
    const reader = file.stream().getReader();
    const writableStream = createDownloadStrean() const writable = writableStream.getWriter() const pump = async() = >{
        const {
            done,
            value
        } = await reader.read();
        if (done) return writable.close() await writable.write(value) // 遞歸調用,直到讀取完成 
        return pump()
    };
    pump();
})
  • 當 WritableStream寫入數據時,下載線程中的 ReadableStream 就會接收到數據,文件就會開始下載直到完成。

完整代碼

// index.vue
<script setup lang="ts">
import { onMounted, ref } from "@vue/runtime-core";
import { createDownloadStream } from "../utils/common";

const inputRef = ref<HTMLInputElement | null>(null);

// 註冊 service worker
async function register() {
  const registed = await navigator.serviceWorker.getRegistration("./");
  if (registed?.active) return registed.active;

  const swRegistration = await navigator.serviceWorker.register("sw.js", {
    scope: "./",
  });

  const sw = swRegistration.installing! || swRegistration.waiting!;

  let listen: any;

  return new Promise<ServiceWorker>((resolve) => {
    sw.addEventListener(
      "statechange",
      (listen = () => {
        if (sw.state === "activated") {
          sw.removeEventListener("statechange", listen);
          resolve(swRegistration.active!);
        }
      })
    );
  });
}

// 向 service worker 申請下載資源
async function createDownloadStream(filename: string) {
  const { port1, port2 } = new MessageChannel();

  const sw = await register();

  sw.postMessage({ filename }, [port2]);

  return new Promise<WritableStream>((r) => {
    port1.onmessage = (e) => {
      const iframe = document.createElement("iframe");
      iframe.hidden = true;
      iframe.src = e.data.download;
      iframe.name = "iframe";
      document.body.appendChild(iframe);
      r(e.data.writable);
    };
  });
}

onMounted(async () => {
  // 監聽文件上傳
  inputRef.value?.addEventListener("change", async (e: any) => {
    const files: FileList = e.target!.files;
    const file = files.item(0)!;

    const reader = file.stream().getReader();
    const writableStream = await createDownloadStream(file.name);
    const writable = writableStream.getWriter();

    const pump = async () => {
      const { done, value } = await reader.read();
      if (done) return writable.close()
      await writable.write(value)
      pump()
    };

    pump();
  });
});
</script>

<template>
  <button @click="inputRef?.click()">本地流式文件下載</button>
  <input ref="inputRef" type="file" hidden />
</template>
// service-worker.js
self.addEventListener('install', () => {
    self.skipWaiting()
})

self.addEventListener('activate', event => {
    event.waitUntil(self.clients.claim())
})

const map = new Map()

self.onmessage = event => {
    const data = event.data

    const filename = encodeURIComponent(data.filename.replace(/\//g, ':'))
        .replace(/['()]/g, escape)
        .replace(/\*/g, '%2A')

    const downloadUrl = self.registration.scope + Math.random() + '/' + filename
    const port2 = event.ports[0]

    // [stream, data]
    const { readable, writable } = new TransformStream()

    const metadata = [readable, data]

    map.set(downloadUrl, metadata)
    port2.postMessage({ download: downloadUrl, writable }, [writable])
}

self.onfetch = event => {
    const url = event.request.url

    const hijacke = map.get(url)

    if (!hijacke) return null
    map.delete(url)

    const [stream, data] = hijacke
    // Make filename RFC5987 compatible
    const fileName = encodeURIComponent(data.filename).replace(/['()]/g, escape).replace(/\*/g, '%2A')

    const headers = new Headers({
        'Content-Type': 'application/octet-stream; charset=utf-8',
        'Transfer-Encoding': 'chunked',
        'response-content-disposition': 'attachment',
        'Content-Disposition': "attachment; filename*=UTF-8''" + fileName
    })

    event.respondWith(new Response(stream, { headers }))
}

流式壓縮下載

跑通瞭流程之後,壓縮也隻不過是在傳輸流之前進行一層轉換的事情。

首先我們尋找一個可以流式處理數據的壓縮庫(你肯定不會想自己寫一遍壓縮算法),fflate 就很符合我們的需求。

然後我們隻需要在寫入數據前,讓 fflate先處理一遍數據就可以瞭。

onMounted(async () => {
  const input = document.querySelector("#file")!;
  input.addEventListener("change", async (e: any) => {
    const stream = createDownloadStrean()
    const file = e.target!.files[0];
    const reader = file.stream().getReader();

    const zip = new fflate.Zip((err, dat, final) => {
      if (!err) {
        fileStream.write(dat);
        if (final) {
          fileStream.close();
        }
      } else {
        fileStream.close();
      }
    });

    const helloTxt = new fflate.ZipDeflate("hello.txt", { level: 9 });
    zip.add(helloTxt);

    while (true) {
      const { done, value } = await reader.read();
      if (done) {
        zip.end();
        break
      };
      helloTxt.push(value)
    }
  });
});

是的,就是這麼簡單。

參考資料

  • StreamSaver.js
  • MDN

 更多關於用js實現文件流式下載文件方法請查看下面的相關鏈接

推薦閱讀: