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 createObjectURL
、readAsDataURL
必須傳入整個文件數據才能進行下載,是不支持流的。也就是說一旦文件數據到瞭 JS
手中,想要下載,就必須把數據堆在內存中,直到拿到完整數據才能開始下載。
所以當我們從服務器下載文件時,應該盡量避免使用 Ajax
,直接使用 頁面跳轉類
的 API 讓下載線程進行流式下載。
但是有些場景下,我們需要在 JS
中處理數據,此時數據在 JS
線程中,就不得不面對內存的問題。
JS
持有數據並下載文件的場景
以下場景,我們需要在 JS
中處理數據並進行文件下載。
- 純前端處理文件流:在線格式轉換、解壓縮等
- 整個數據都在前端轉換處理,壓根沒有服務端的事
- 文章所要討論的情況
- 接口鑒權:鑒權方案導致請求必須由
JS
發起,如cookie + csrfToken
、JWT
- 使用
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.enqueue
或controller.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,終於到瞭流式下載的部分。
這裡我並不會推翻自己前面所說:
- 隻有頁面級跳轉會觸發下載。
- 這意味著響應數據直接被下載線程接管。
createObjectURL
、readAsDataURL
隻能接收整個文件數據。- 這意味當數據在前端時,隻能整體下載。
所以應該怎麼做呢?
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
,其類型可以是Blob
、string
等等,其中可以看到熟悉的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
接受到消息,創建url
、ReadableStream
、WritableStream
,將url
、WritableStream
通過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實現文件流式下載文件方法請查看下面的相關鏈接
推薦閱讀:
- Javascript File和Blob詳解
- JavaScript 沙箱探索
- Vue中接收二進制文件流實現pdf預覽的方法
- JS如何使用剪貼板操作Clipboard API
- JS實現單個或多個文件批量下載的方法詳解