js實現(xiàn)文件流式下載文件方法詳解及完整代碼
JS實現(xiàn)流式打包下載說明
瀏覽器中的流式操作可以節(jié)省內(nèi)存,擴大 JS 的應(yīng)用邊界,比如我們可以在瀏覽器里進行視頻剪輯,而不用擔心視頻文件將內(nèi)存撐爆。
瀏覽器雖然有流式處理數(shù)據(jù)的 API,并沒有直接提供給 JS 進行流式下載的能力,也就是說即使我們可以流式的處理數(shù)據(jù),但想將其下載到磁盤上時,依然會對內(nèi)存提出挑戰(zhàn)。
這也是我們討論的前提:
- 流式的操作,必須整個鏈路都是流式的才有意義,一旦某個環(huán)節(jié)是非流式(阻塞)的,就無法起到節(jié)省內(nèi)存的作用。
本篇文章分析了如何在 JS中流式的處理數(shù)據(jù) ,流式的進行下載,主要參考了 StreamSaver.js 的實現(xiàn)方案。
分為如下部分:
- 流在計算機中的作用
- 服務(wù)器流式響應(yīng)
JS下載文件的方式JS持有數(shù)據(jù)并下載文件的場景- 非流式處理、下載的問題
- 瀏覽器流式
API JS流式的實現(xiàn)方案- 實現(xiàn)
JS讀取本地文件并打包下載
流在計算機中的作用
流這個概念在前端領(lǐng)域中提及的并不多,但是在計算機領(lǐng)域中,流式一個非常常見且重要的概念。
當流這個字出現(xiàn)在 IO 的上下文中,常指的得就是分段的讀取和處理文件,這樣在處理文件時(轉(zhuǎn)換、傳輸),就不必把整個文件加載到內(nèi)存中,大大的節(jié)省了內(nèi)存空間的占用。
在實際點說就是,當你用著 4G 內(nèi)存的 iPhone 13看電影時,并不需要擔心視頻文件數(shù)據(jù)把你的手機搞爆掉。
服務(wù)器流式響應(yīng)
在談下載之前,先提一下流式響應(yīng)。
如上可知,當我們從服務(wù)器下載一個文件時,服務(wù)器也不可能把整個文件讀取到內(nèi)存中再進行響應(yīng),而是會邊讀邊響應(yīng)。
那如何進行流式響應(yīng)呢?
只需要設(shè)置一個響應(yīng)頭 Transfer-Encoding: chunked,表明我們的響應(yīng)體是分塊傳輸?shù)木涂梢粤恕?/p>
以下是一個 nodejs 的極簡示例,這個服務(wù)每隔一秒就會向瀏覽器進行一次響應(yīng),永不停歇。
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 中下載文件的方式,有如下兩類:
// 第一類:頁面跳轉(zhuǎn)、打開
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 下載文件,最終還是要借助第一類方法才可以實現(xiàn)下載。
而第一類的操作都會導致一個行為:頁面級導航跳轉(zhuǎn)
所以我們可以總結(jié)得出瀏覽器的下載行為:
- 在頁面級的跳轉(zhuǎn)請求中,檢查響應(yīng)頭是否包含
Content-Disposition: attachment。對于a[download]和createObjectURL的url跳轉(zhuǎn),可以理解為瀏覽器幫忙加上了這個響應(yīng)頭。 Ajax發(fā)出的請求并不是頁面級跳轉(zhuǎn)請求,所以即使擁有下載響應(yīng)頭也不會觸發(fā)下載行為。
兩類下載方式的區(qū)別
這兩種下載文件的方式有何區(qū)別呢?
第一類請求的響應(yīng)數(shù)據(jù)直接由下載線程接管,可以進行流式下載,一邊接收數(shù)據(jù)一邊往本地寫文件。
第二類由 JS 線程接管響應(yīng)數(shù)據(jù),使用 API 將文件數(shù)據(jù)創(chuàng)建成 url 觸發(fā)下載。
但是相應(yīng)的 API createObjectURL、readAsDataURL必須傳入整個文件數(shù)據(jù)才能進行下載,是不支持流的。也就是說一旦文件數(shù)據(jù)到了 JS 手中,想要下載,就必須把數(shù)據(jù)堆在內(nèi)存中,直到拿到完整數(shù)據(jù)才能開始下載。
所以當我們從服務(wù)器下載文件時,應(yīng)該盡量避免使用 Ajax ,直接使用 頁面跳轉(zhuǎn)類的 API 讓下載線程進行流式下載。
但是有些場景下,我們需要在 JS 中處理數(shù)據(jù),此時數(shù)據(jù)在 JS 線程中,就不得不面對內(nèi)存的問題。
JS 持有數(shù)據(jù)并下載文件的場景
以下場景,我們需要在 JS 中處理數(shù)據(jù)并進行文件下載。
- 純前端處理文件流:在線格式轉(zhuǎn)換、解壓縮等
- 整個數(shù)據(jù)都在前端轉(zhuǎn)換處理,壓根沒有服務(wù)端的事
- 文章所要討論的情況
- 接口鑒權(quán):鑒權(quán)方案導致請求必須由
JS發(fā)起,如cookie + csrfToken、JWT - 使用
ajax:簡單但是數(shù)據(jù)都在內(nèi)存中 - (推薦)使用
iframe + form實現(xiàn):麻煩但是可以由下載線程流式下載 - 服務(wù)端返回文件數(shù)據(jù),前端轉(zhuǎn)換處理后下載
- 如服務(wù)端返回多個文件,前端打包下載
- (推薦)去找后端 ~~聊一聊~~
可以看到第一種情況是必須用 JS 處理的,我們來看一下如果不使用流式處理的話,會有什么問題。
非流式處理、下載的問題
去網(wǎng)上搜索「前端打包」,99% 的內(nèi)容都會告訴你使用 JSZip ,談起文件下載也都會提起一個 file-saver的庫(JSZip 官網(wǎng)也推薦使用這個庫下載文件)。
那我們就看一下這些流行庫的的問題。
<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 的官方實例構(gòu)建的 Vue 應(yīng)用,功能很簡單,從本地上傳一個文件,通過 JSZip打包,然后使用 file-saver 將其下載到本地。
我們來直接試一下,上傳一個 1G+ 的文件會怎么樣?
通過 Chrome 的任務(wù)管理器可以看到,當前的頁面內(nèi)存直接跳到了 1G+。
當然不排除有人的電腦內(nèi)存比我們硬盤的都大的情況,豪不在乎內(nèi)存消耗。
OK,即使你的電腦足以支撐在內(nèi)存中進行隨意的數(shù)據(jù)轉(zhuǎn)換,但瀏覽器對 Blob 對象是有大小限制的。
官網(wǎng)的第一句話就是
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的大小限制更大的文件,或者沒有足夠的內(nèi)存,那么可以查看更高級的 StreamSaver.js
然后給出了不同瀏覽器所支持的 Max Blob Size,可以看到 Chrome 是 2G。
所以不管是出于內(nèi)存考慮,還是 Max Blob Size的限制,我們都有必要去探究一下流式的處理方案。
順便說一下這個庫并沒有什么黑科技,它的下載方式和我們上面寫的是一樣的,只不過處理了一些兼容性問題。
瀏覽器流式 API
Streams API 是瀏覽器提供給 JS 的流式操作數(shù)據(jù)的接口。
其中包含有兩個主要的接口:可讀流、可寫流
WritableStream
創(chuàng)建一個可寫流對象,這個對象帶有內(nèi)置的背壓和排隊。
// 創(chuàng)建
const writableStream = new WritableStream({
write(chunk) {
console.log(chunk)
}
})
// 使用
const writer = writableStream.getWriter()
writer.write(1).then(() => {
// 應(yīng)當在 then 再寫入下一個數(shù)據(jù)
writer.write(2)
})
- 創(chuàng)建時傳入
write函數(shù),在其中處理具體的寫入邏輯(寫入可讀流)。 - 使用時調(diào)用
getWriter()獲取流的寫入器,之后調(diào)用write方法進行數(shù)據(jù)寫入。 - 此時的
write方法是被包裝后的,其會返回Promise用來控制背壓,當允許寫入數(shù)據(jù)時才會resolve。 - 背壓控制策略參考
CountQueuingStrategy,這里不細說。
ReadableStream
創(chuàng)建一個可讀的二進制操作,controller.enqueue向流中放入數(shù)據(jù),controller.close表明數(shù)據(jù)發(fā)送完畢。
下面的流每隔一秒就會產(chǎn)生一次數(shù)據(jù):
const readableStream = new ReadableStream({
start(controller) {
setInterval(() => {
// 向流中放入數(shù)據(jù)
controller.enqueue(value);
// controller.close(); 表明數(shù)據(jù)已發(fā)完
}, 1000)
}
});
從可讀流中讀取數(shù)據(jù):
const reader = readableStream.getReader()
while (true) {
const {value, done} = await reader.read()
console.log(value)
if (done) break
}
調(diào)用 getReader() 可以獲取流的讀取器,之后調(diào)用 read() 便會開始讀取數(shù)據(jù),返回 Promise
- 如果流中沒有數(shù)據(jù),便會阻塞(
Promise penging)。 - 當調(diào)用了
controller.enqueue或controller.close后,Promise就會resolve。 done:數(shù)據(jù)發(fā)送完畢,表示調(diào)用了controller.close。value:數(shù)據(jù)本身,表示調(diào)用了controller.enqueue。
while (true) 的寫法在其他語言中是非常常見的,如果數(shù)據(jù)沒有讀完,我們就重復調(diào)用 read() ,直到 done 為true。
fetch 請求的響應(yīng)體和 Blob 都已經(jīng)實現(xiàn)了 ReadableStream。
Fetch ReadableStream
Fetch API 通過Response的屬性body提供了一個具體的ReadableStream對象。
流式的讀取服務(wù)端響應(yīng)數(shù)據(jù):
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
有了可讀、可寫流,我們就可以組合實現(xiàn)一個轉(zhuǎn)換流,一端轉(zhuǎn)換寫入數(shù)據(jù)、一端讀取數(shù)據(jù)。
我們利用 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) // 寫入數(shù)據(jù)
reader.read() // 讀出數(shù)據(jù) 123
在很多場景下我們都會這么去使用讀寫流,所以瀏覽器幫我們實現(xiàn)了一個標準的轉(zhuǎn)換流:TransformStream
使用如下:
const {readable, writable} = new TransformStream()
writable.getWriter().write(123) // 寫入數(shù)據(jù)
readable.getReader().read() // 讀出數(shù)據(jù) 123
以上就是我們需要知道的流式 API 的知識,接下來進入正題。
前端流式下載
ok,終于到了流式下載的部分。
這里我并不會推翻自己前面所說:
- 只有頁面級跳轉(zhuǎn)會觸發(fā)下載。
- 這意味著響應(yīng)數(shù)據(jù)直接被下載線程接管。
createObjectURL、readAsDataURL只能接收整個文件數(shù)據(jù)。- 這意味當數(shù)據(jù)在前端時,只能整體下載。
所以應(yīng)該怎么做呢?
Service worker
是的,黑科技主角Service worker,熟悉 PWA 的人對它一定不陌生,它可以攔截瀏覽器的請求并提供離線緩存。
Service Worker APIService workers 本質(zhì)上充當 Web 應(yīng)用程序、瀏覽器與網(wǎng)絡(luò)(可用時)之間的代理服務(wù)器。這個 API 旨在創(chuàng)建有效的離線體驗,它會攔截網(wǎng)絡(luò)請求并根據(jù)網(wǎng)絡(luò)是否可用來采取適當?shù)膭幼?、更新來自服?wù)器的的資源。
—— MDN
這里有兩個關(guān)鍵點:
- 攔截請求
- 構(gòu)建響應(yīng)
也就是說,通過 Service worker 前端完全可以自己充當服務(wù)器給下載線程傳輸數(shù)據(jù)。
讓我們看看這是如何工作的。
攔截請求
請求的攔截非常簡單,在Service worker中注冊 onfetch 事件,所有的請求發(fā)送都會觸發(fā)其回調(diào)。
通過 event.request 對象拿到 Request 對象,進而檢查 url 決定是否要攔截。
如果確定要攔截,就調(diào)用 event.respondWith 并傳入 Response 對象,既可完成攔截。
self.onfetch = event => {
const url = event.request.url
if (url === '攔截') {
event.respondWith(new Response())
}
}
new Response
Response就是 fetch()返回的 response 的構(gòu)造函數(shù)。
直接看函數(shù)簽名:
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 接收兩個參數(shù)
- 第一個是響應(yīng)體
Body,其類型可以是Blob、string等等,其中可以看到熟悉的ReadableStream可讀流 - 第二個是響應(yīng)頭、狀態(tài)碼等
這意味著:
- 在響應(yīng)頭中寫入
Content-Disposition:attachment,瀏覽器就會讓下載線程接管響應(yīng)。 - 將
Body構(gòu)建成ReadableStream,就可以流式的向下載線程傳輸數(shù)據(jù)。
也意味著前端自己就可以進行流式下載!
極簡實現(xiàn)
我們構(gòu)建一個最簡的例子來將所有知識點串起來:從本地上傳文件,流式的讀取,流式的下載到本地。
是的這看似毫無意義,但這可以跑通流程,對學習來說足夠了。
關(guān)鍵點代碼分析
- 通知
service worker準備下載文件,等待worker返回url和writable
const createDownloadStrean = async(filename) = >{ // 通過 channel 接受數(shù)據(jù)
const {
port1,
port2
} = new MessageChannel();
// 傳遞 channel,這樣 worker 就可以往回發(fā)送消息了
serviceworker.postMessage({
filename
},
[port2]);
return new Promise((resolve) = >{
port1.onmessage = ({
data
}) = >{
// 拿到url, 發(fā)起請求
iframe.src = data.url;
document.body.appendChild(iframe);
// 返回可寫流
resolve(data.writable)
};
});
}Service worker接受到消息,創(chuàng)建url、ReadableStream、WritableStream,將url、WritableStream通過channel發(fā)送回去。
js self.onmessage = (event) = >{
const filename = event.data.filename // 拿到 channel
const port2 = event.ports[0] // 隨機一個 url
const downloadUrl = self.registration.scope + Math.random() + '/' + filename // 創(chuàng)建轉(zhuǎn)換流
const {
readable,
writable
} = new TransformStream() // 記錄 url 和可讀流,用于后續(xù)攔截和響應(yīng)構(gòu)建
map.set(downloadUrl, readable) // 傳回 url 和可寫流
port2.postMessage({
download: downloadUrl,
writable
},
[writable])
}- 主線程拿到
url發(fā)起請求(第 1 步onmessage中),Service worker攔截請求 ,使用上一步的ReadableStream創(chuàng)建Response并響應(yīng)。
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'
})
// 構(gòu)建返回響應(yīng)
event.respondWith(
new Response(readableStream, { headers })
)
}- 下載線程拿到響應(yīng),開啟流式下載(但是此時根本沒有數(shù)據(jù)寫入,所以在此就阻塞了)
- 主線程拿到上傳的
File對象,獲取其ReadableStream并讀取,將讀取到的數(shù)據(jù)通過WritableStream(第 1 步中返回的)發(fā)送出去。
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) // 遞歸調(diào)用,直到讀取完成
return pump()
};
pump();
})- 當
WritableStream寫入數(shù)據(jù)時,下載線程中的ReadableStream就會接收到數(shù)據(jù),文件就會開始下載直到完成。
完整代碼
// 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 () => {
// 監(jiān)聽文件上傳
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 }))
}流式壓縮下載
跑通了流程之后,壓縮也只不過是在傳輸流之前進行一層轉(zhuǎn)換的事情。
首先我們尋找一個可以流式處理數(shù)據(jù)的壓縮庫(你肯定不會想自己寫一遍壓縮算法),fflate 就很符合我們的需求。
然后我們只需要在寫入數(shù)據(jù)前,讓 fflate先處理一遍數(shù)據(jù)就可以了。
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)
}
});
});
是的,就是這么簡單。
參考資料
更多關(guān)于用js實現(xiàn)文件流式下載文件方法請查看下面的相關(guān)鏈接
相關(guān)文章
layui table動態(tài)表頭 改變表格頭部 重新加載表格的方法
今天小編就為大家分享一篇layui table動態(tài)表頭 改變表格頭部 重新加載表格的方法,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2019-09-09
JavaScript新手必看之var在for循環(huán)中的坑
var這個關(guān)鍵字在JS當中是相當常用的,但同時配合到for循環(huán)的話會出現(xiàn)不符合預期的運行結(jié)果,所以本文就來為大家講講如何避免這種情況的出現(xiàn)2023-05-05

