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