如何優(yōu)雅地實(shí)現(xiàn)網(wǎng)頁(yè)文件上傳與下載

之前公司里面要做視頻播放伪煤,總結(jié)開(kāi)發(fā)過(guò)程坊夫,寫(xiě)了一篇文章《如何優(yōu)雅地實(shí)現(xiàn)網(wǎng)頁(yè)播放視頻》宾袜。近期狰右,項(xiàng)目中又有在網(wǎng)頁(yè)上傳、下載文件的需求秸讹,并且要求能夠支持大文件和“高并發(fā)”,與此前的視頻播放在技術(shù)上存在一些聯(lián)系雅倒,經(jīng)過(guò)研究和動(dòng)手實(shí)踐后璃诀,整理如下,方便有需要的人蔑匣。

1.文件下載

1.1 簡(jiǎn)單實(shí)現(xiàn)

如果是單純實(shí)現(xiàn)功能劣欢,不考慮文件大小、服務(wù)器負(fù)載等各種情況裁良,幾行代碼就能實(shí)現(xiàn)一個(gè)文件下載接口凿将。這里以Go語(yǔ)言為例,下載windows系統(tǒng)D盤(pán)下的test.pdf文件价脾。

package main

import (
    "net/http"
    "os"
)

func main() {
    server := http.Server{Addr: ":8080"}
    http.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) {
        bytes, _ := os.ReadFile("D:\\test.pdf")
        header := w.Header()
        header.Set("Content-type", "application/octet-stream")
        header.Set("Content-Disposition", "attachment;filename=test.pdf")
        w.Write(bytes)
    })
    server.ListenAndServe()
}

以上代碼直接讀取文件內(nèi)容牧抵,并將內(nèi)容寫(xiě)入HTTP response,真的是非常簡(jiǎn)單侨把、粗暴犀变。各種編程語(yǔ)言的web庫(kù)通常會(huì)在此基礎(chǔ)上做一些包裝,內(nèi)置一些響應(yīng)頭設(shè)置秋柄、HTTP Status設(shè)置获枝、異常處理等操作,能夠更簡(jiǎn)單地實(shí)現(xiàn)文件下載骇笔。

Fiber (go)

import "github.com/gofiber/fiber/v2"

func main() {
    app := fiber.New()
    app.Get("/download",  Download)
    app.Listen(fmt.Sprintf(":%v", 8080))
}

func Download(c *fiber.Ctx) error {
    c.SendFile("D:\\test.pdf")
}

Sanic (python)

from sanic.response import file

@app.route("/")
async def handler(request):
    return await file("/path/to/whatever.png")
1.2 問(wèn)題分析

以上辦法實(shí)現(xiàn)雖然簡(jiǎn)單省店,但同時(shí)也存在問(wèn)題。將文件從磁盤(pán)或其它存儲(chǔ)(如OSS)寫(xiě)入http response的時(shí)笨触,文件內(nèi)容首先要讀取到內(nèi)存懦傍,如果文件特別大,甚至請(qǐng)求頻率特別高時(shí)旭旭,服務(wù)器的內(nèi)存必然會(huì)消耗殆盡谎脯,最終引發(fā)程序崩潰。

1.3 解決方案

為了解決此類問(wèn)題持寄,計(jì)算機(jī)領(lǐng)域的大佬們?cè)缇拖牒昧私鉀Q方案源梭,在制定http協(xié)議時(shí)娱俺,規(guī)定了可以在request header中攜帶Range(例如Range值為"bytes=0-1023",表示只獲取文件起始的1kb內(nèi)容)废麻,告知服務(wù)端只獲取部分內(nèi)容荠卷,這種辦法叫做分片傳輸(chunked transfer)。將一個(gè)體積較大的文件切割成若干體積較小的塊烛愧,將原來(lái)的一次性獲取變成多次獲取油宜,最終再將獲取到的所有內(nèi)容在客戶端組裝成一個(gè)完整的問(wèn)題。

1.4 服務(wù)端實(shí)現(xiàn)

以Go語(yǔ)言為例怜姿,在Fiber庫(kù)的基礎(chǔ)上慎冤,實(shí)現(xiàn)分片下載邏輯,以下代碼演示的是本地文件下載沧卢,如果是從OSS等其它存儲(chǔ)下載蚁堤,原理大致相同。

func Download(c *fiber.Ctx) error {
    const filename = "D:\\test.pdf"
    f, err := os.Open(filename)
    if err != nil {
        return errors.WithStack(err)
    }

    defer f.Close()

    fileinfo, _ := f.Stat()

    rangeData, err := c.Range(int(fileinfo.Size()))
    if err != nil {
        return errors.WithStack(err)
    }
    // TODO disallow multirange request
    if _, err := f.Seek(int64(rangeData.Ranges[0].Start), 0); err != nil {
        return errors.WithStack(err)
    }

    start := rangeData.Ranges[0].Start
    end := rangeData.Ranges[0].End
    length := end - start + 1
    // TODO check chunk size

    b := make([]byte, length)
    if _, err := f.Read(b); err != nil {
        return errors.WithStack(err)
    }

    // TODO You should read the md5 value from database where the file metadata stored, rather than calculating it every time you download the file.
    hash, _ := cryptor.Md5File(filename)

    c.Response().Header.Add("x-file-hash", hash)
    c.Response().Header.Add("Accept-Ranges", "bytes")
    c.Response().Header.Add("Content-Type", "application/octet-stream")
    c.Response().Header.Add("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, url.QueryEscape(fileinfo.Name())))
    c.Response().Header.Add("Content-Range", fmt.Sprintf("bytes %v-%v/%v", start, end, fileinfo.Size()))
    return c.Status(206).SendStream(bytes.NewReader(b), length)
}

其中c.Range()這個(gè)函數(shù)根據(jù)http request header中的Range值以及文件體積但狭,進(jìn)行一些校驗(yàn)和修正披诗,以防客戶端沒(méi)有正確傳遞Range值導(dǎo)致各種異常情況。同時(shí)立磁,Range是支持多范圍(multirange)請(qǐng)求的呈队,例如"bytes=0-1023,1024-2047"表示獲取文件起始的第1kb和第2kb內(nèi)容,為了保證整個(gè)系統(tǒng)簡(jiǎn)單可控唱歧,直接限定不允許多范圍請(qǐng)求宪摧,杜絕客戶端一次請(qǐng)求特別多個(gè)chunk的情況。此外迈喉,服務(wù)端還要檢查請(qǐng)求的每一塊內(nèi)容大小是否超過(guò)允許的范圍绍刮,這應(yīng)該由服務(wù)端控制,不能交給客戶端自由選擇挨摸,否則依然會(huì)存在前文提及的內(nèi)存問(wèn)題孩革。

1.5 客戶端實(shí)現(xiàn)

服務(wù)端的下載接口準(zhǔn)備完畢后,客戶端還有一些必須的工作要完成得运,才能完整地實(shí)現(xiàn)分片下載膝蜈。

1.5.1 獲取文件大小

為了將文件按照與服務(wù)端約定的chunk size分片請(qǐng)求文件內(nèi)容,首先需要獲取文件大小熔掺,此時(shí)可以提供一個(gè)獲取文件元信息的接口饱搏,也可以與下載文件公用一個(gè)接口,只不過(guò)先用一個(gè)bytes=0-0或者bytes=0-1的Range置逻,“試探”性地獲取文件內(nèi)容推沸,從response header中獲取Content-Range值(例如bytes 0-0/1024,其中的1024即文件大小,單位為byte)鬓催,從而得到文件大小肺素。

1.5.2 下載所有文件塊

獲取到文件大小后,客戶端根據(jù)與服務(wù)端約定好的chunk size(例如1024 * 1024宇驾,即1MB)對(duì)文件分割倍靡,將原本一次性獲取文件內(nèi)容轉(zhuǎn)變?yōu)槎啻握?qǐng)求,每一次只請(qǐng)求部分內(nèi)容课舍。

很容易想到的辦法是塌西,將文件分片,計(jì)算得到每一片的Range范圍筝尾,依次下載每一塊內(nèi)容捡需,每次下載后如果還有未下載的塊,就繼續(xù)遞歸地下載接下來(lái)一塊內(nèi)容筹淫。

export const download_chunks = (path: string, size: number) => {
    const chunk_size = 1024 * 1024;  // 1M
    const chunks = Math.ceil(size / chunk_size);
    let chunk = 0;

    const download_chunk = () => {
        const start = chunk_size * chunk;
        const end = ((start + chunk_size) >= size) ? (size - 1) : (start + chunk_size - 1);
        const range = `bytes=${start}-${end}`;
        // 省略下載文件部分的代碼栖忠,用一行l(wèi)og代替
        console.log("download chunk:" + (chunk + 1), range);
        chunk++;
        if (chunk <= chunks - 1) {
            download_chunk();
        }
    };

    download_chunk();
};

但由于是遞歸調(diào)用,也很容易想到函數(shù)調(diào)用棧溢出的情況贸街,這在不同瀏覽器上也有不同表現(xiàn)。經(jīng)測(cè)試狸相,F(xiàn)irefox上的報(bào)錯(cuò)為Uncaught InternalError: too much recursion薛匪,而Chrome上的報(bào)錯(cuò)為Uncaught RangeError: Maximum call stack size exceeded,出現(xiàn)問(wèn)題的調(diào)用深度也不是固定的脓鹃。

為了解決遞歸調(diào)用的問(wèn)題逸尖,對(duì)上面的代碼進(jìn)行一些調(diào)整,同時(shí)補(bǔ)上了下載每個(gè)chunk后的數(shù)據(jù)存儲(chǔ)瘸右,這里使用的是LOCALFORAGE這個(gè)庫(kù)以簡(jiǎn)單方式操作IndexedDB 娇跟。

export const download_chunks = (path: string, params: any, size: number) => {
    const chunk_size = 1024 * 1024;
    const chunks = Math.ceil(size / chunk_size);

    const download_chunk = (current_chunk: number = 0, batch: number=1000) => {
        const start = chunk_size * current_chunk;
        const end = ((start + chunk_size) >= size) ? (size - 1) : (start + chunk_size - 1);
        const range = `bytes=${start}-${end}`;
        const url = `/api/${path.indexOf("/") === 0 ? path.substring(1) : path}`;
        const config = {
            method: "GET",
            headers: {
                "token": sessionStorage.getItem("token") || "",
                "Range": range
            }
        }
        window.fetch(url, config).then((res: any) => res.blob()).then(
            (res: any) => {
                localforage.setItem(`replace-with-your-file-id-${current_chunk}`, res);
                batch--;
                if (current_chunk < chunks - 1) {
                    if (batch > 0) {
                        download_chunk(current_chunk + 1, batch);
                    } else {
                        setTimeout(() => download_chunk(current_chunk + 1, 1000), 0);
                    }
                }
            }
        ).catch(reason => { console.error(reason) });
    };

    download_chunk(0, 1000);
};

這里用了setTimeout來(lái)取消遞歸函數(shù)無(wú)限制地增加調(diào)用棧深度,但由于setTimeout本身即使在設(shè)置延遲為0的時(shí)候太颤,還是有微小的延遲苞俘,調(diào)用次數(shù)非常大時(shí),累積的延遲時(shí)間就比較明顯了龄章。為了盡可能消除這種影響吃谣,我們將1000次遞歸作為一個(gè)批次,超過(guò)1000次后做裙,使用setTimeout來(lái)重置遞歸岗憋。

這里可能不是最佳方案,如果有更好的辦法锚贱,歡迎賜教仔戈!

1.5.3 組裝文件

經(jīng)過(guò)上面的步驟,文件會(huì)分片下載,最終保存到IndexedDB监徘,以測(cè)試代碼8.5M的pdf文件為例晋修,按照每個(gè)chunk大小為1M進(jìn)行分割成9個(gè)chunk,在IndexedDB中會(huì)存儲(chǔ)9條記錄耐量。

image.png

接下來(lái)的任務(wù)就是要從這些數(shù)據(jù)中獲取Blob飞蚓,并按順序合并成一個(gè)新的Blob,再將這個(gè)Blob轉(zhuǎn)成文件下載廊蜒。

首先準(zhǔn)備一個(gè)工具函數(shù)趴拧,從Blob生成文件,并進(jìn)行下載山叮。

export const blob_to_file = (blob: Blob, filename?: string) => {
    let url = window.URL.createObjectURL(blob);
    let a = document.createElement('a');
    a.style.display = 'none';
    a.href = url;
    a.download = filename ? filename : "unnamed";
    a.target = "_blank";
    document.body.appendChild(a);
    a.click();
    a.remove();
}

然后根據(jù)剛剛存儲(chǔ)的文件分片著榴,在已知一共有9個(gè)Blob的情況下進(jìn)行文件合并,實(shí)際的場(chǎng)景肯定要進(jìn)行優(yōu)化以適應(yīng)所有情況屁倔,這里先演示文件合并及下載過(guò)程脑又。

const download_file = () => {
    const data: Blob[] = [];
    for (let i = 0; i <= 8; i++) {
        const key = `replace-with-your-file-id-${i}`;
        localforage.getItem(key).then(
            (v: any) => {
                data.push(v);
                if (i === 8) {
                    // var blob = new Blob([...data], { type: 'application/pdf' });
                    var blob = new Blob([...data]);
                    blob_to_file(blob, "測(cè)試.pdf");
                }
            }
        );
    }
};

運(yùn)行后,按照給定的文件名下載得到了期望的pdf文件锐借,找到下載位置问麸,能夠正常打開(kāi),至此分片下載流程已經(jīng)走通了钞翔,但是還遺留一些問(wèn)題需要解決严卖。

1.5.4 下載優(yōu)化

剛剛下載文件分片存儲(chǔ)到IndexedDB時(shí),使用的Key為replace-with-your-file-id-0布轿,replace-with-your-file-id-1...并且合并文件時(shí)也是在已知一共多少個(gè)分片的情況下哮笆,實(shí)際項(xiàng)目中肯定不能這樣來(lái)讀取文件數(shù)據(jù),尤其在同時(shí)下載多個(gè)文件的情況下汰扭。

那么怎么以簡(jiǎn)單的方式來(lái)解決這個(gè)問(wèn)題呢稠肘?首先為了簡(jiǎn)單,IndexedDB的操作我們只使用localforage.setItem()localforage.getItem()萝毛,實(shí)際上除了這兩個(gè)操作只剩下遍歷所有數(shù)據(jù)了项阴。

為了知道一個(gè)文件在IndexedDB中對(duì)應(yīng)哪些數(shù)據(jù),我們除了正常的file chunk笆包,再額外存儲(chǔ)一條數(shù)據(jù)鲁冯,記錄文件信息,包括所有的分片在IndexedDB中對(duì)應(yīng)的數(shù)據(jù)的Key色查,其結(jié)構(gòu)如下, 這條數(shù)據(jù)存儲(chǔ)的Key可以考慮用文件ID薯演、文件ID+MD5、下載任務(wù)ID等等秧了,具體的還要結(jié)合業(yè)務(wù)需求進(jìn)行選擇跨扮。

const file_data = {
    fileid: "replace-with-your-file-id",
    filename: "test.pdf",
    type: "application/pdf",
    hash: "",
    keys: [
        "replace-with-your-file-id-0", 
        "replace-with-your-file-id-1", 
        "replace-with-your-file-id-2"
    ]
};

存儲(chǔ)了這條額外的數(shù)據(jù)后,我們就可以根據(jù)文件ID或者任務(wù)ID等唯一Key從IndexedDB獲取file_data,從而找到所有的file chunk衡创。

根據(jù)這個(gè)思路帝嗡,將分片下載的代碼進(jìn)一步調(diào)整如下

import qs from "qs";
import localforage from 'localforage';
import update from "immutability-helper";

export const download_chunks = (path: string, params: any, size: number) => {
    const chunk_size = 1024 * 1024;
    const chunks = Math.ceil(size / chunk_size);

    const file_data = {
        fileid: "replace-with-your-file-id",
        filename: "test.pdf",
        type: "application/pdf",
        hash: "",
        keys: new Array<string>()
    };

    const download_chunk = (current_chunk: number = 0, file_data: object, batch: number = 1000) => {
        const start = chunk_size * current_chunk;
        const end = ((start + chunk_size) >= size) ? (size - 1) : (start + chunk_size - 1);
        const range = `bytes=${start}-${end}`;
        console.log("download chunk:" + (current_chunk + 1), range);

        const url = `/api/${path.indexOf("/") === 0 ? path.substring(1) : path}`;
        if (params) {
            path += `?${qs.stringify(params)}`;
        }
        const config = {
            method: "GET",
            headers: {
                "token": sessionStorage.getItem("token") || "",
                "Range": range
            }
        };

        window.fetch(url, config).then((res: any) => res.blob()).then(
            (res: any) => {
                const file_chunk_key = `replace-with-your-file-id-${current_chunk}`;
                localforage.setItem(file_chunk_key, res, () => {
                    file_data = update(file_data, { keys: { $push: [file_chunk_key] } });
                    localforage.setItem("task_id/file_id/md5", file_data);
                    batch--;
                    if (current_chunk < chunks - 1) {
                        if (batch > 0) {
                            download_chunk(current_chunk + 1, file_data, batch);
                        } else {
                            setTimeout(() => download_chunk(current_chunk + 1, file_data, 1000), 0);
                        }
                    }
                });
            }
        ).catch(reason => { console.error(reason) });
    };

    download_chunk(0, file_data, 1000);
};

調(diào)整后,先清空IndexedDB璃氢,再嘗試下載哟玷,再觀察IndexedDB中存儲(chǔ)的內(nèi)容,除了之前的9個(gè)分片數(shù)據(jù)一也,多出了一條數(shù)據(jù)巢寡,記錄了文件的相關(guān)信息以及所有的file chunk key,后續(xù)就可以根據(jù)這些key查找具體的分片數(shù)據(jù)了椰苟。

image.png
1.5.6 斷點(diǎn)續(xù)傳(breakpoint transmission)

由于使用了分片下載抑月,并且已下載成功的分片數(shù)據(jù)已經(jīng)存儲(chǔ)到了瀏覽器的IndexedDB,即使下載中斷了舆蝴,已下載的部分也不會(huì)丟失谦絮、不需要重新下載,帶下次頁(yè)面重新打開(kāi)時(shí)洁仗,繼續(xù)從未下載的分片開(kāi)始下載即可层皱。

不過(guò)既然有繼續(xù)下載的概念,那么程序中必然要相應(yīng)地增加一個(gè)下載任務(wù)的概念赠潦,并且任務(wù)有已完成奶甘、未完成、暫停祭椰、下載中等狀態(tài)。因此疲陕,前文的用戶記錄文件信息的file_data可能也要做一些調(diào)整方淤。

此外,下載中斷后蹄殃,服務(wù)器上的文件可能已經(jīng)發(fā)生了變更携茂,如果不管三七二十一,無(wú)視這個(gè)變化就直接繼續(xù)下載诅岩,得到的文件很可能因?yàn)槎M(jìn)制數(shù)據(jù)錯(cuò)亂導(dǎo)致文件無(wú)法正常使用讳苦。所以繼續(xù)下載前,獲取文件的md5值與前次的進(jìn)行比較吩谦,如果一致才繼續(xù)下載鸳谜,如果不一致就清空已下載的數(shù)據(jù)并重新下載。

1.5.7 細(xì)節(jié)處理

優(yōu)化完IndexedDB相關(guān)的操作后式廷,還有些細(xì)節(jié)內(nèi)容沒(méi)有完成咐扭,如果想要給用戶完美的體驗(yàn),這些問(wèn)題還是需要解決的。

  • 下載提示
    下載完成后蝗肪,界面上需要給出提示袜爪,自動(dòng)彈出下載完成窗口,提示用戶下一步操作薛闪。
  • Indexed數(shù)據(jù)清理
    也可以說(shuō)成“緩存清理”辛馆,在合適的時(shí)機(jī)清除文件所有數(shù)據(jù),可以結(jié)合業(yè)務(wù)需要處理豁延,下載一次后清理昙篙,還是一定時(shí)間后清理都可以考慮。
1.5.8 下載進(jìn)度 progress

同樣的术浪,由于是分片下載瓢对,每下載完成一個(gè)分片后,就可以方便地計(jì)算得到已下載的部分占文件總體積的比例胰苏,下載進(jìn)度同樣需要按下載任務(wù)記錄到IndexedDB硕蛹,并且頁(yè)面上要提供相應(yīng)的UI展示。

當(dāng)然硕并,由于分片一般會(huì)固定大小法焰,當(dāng)文件本身比較小時(shí),下載進(jìn)度漸進(jìn)的幅度就會(huì)比較大(例如文件大小為4M倔毙,分片大小為1M埃仪,下載進(jìn)度只會(huì)按0% -> 25% -> 75 -> 100%變化),甚至當(dāng)文件體積比分片大小還小時(shí)陕赃,下載進(jìn)度只會(huì)直接從0%到100%變化了卵蛉。如果為了下載進(jìn)度看起來(lái)更合理,特地根據(jù)文件大小動(dòng)態(tài)調(diào)整分片大小也可以考慮么库,但分片分得太小也是不合適的傻丝。

2.文件上傳

文件上傳相對(duì)于文件下載,是一個(gè)逆向的過(guò)程诉儒。結(jié)合文件下載的經(jīng)驗(yàn)葡缰,如果要實(shí)現(xiàn)分片上傳文件,并且支持?jǐn)帱c(diǎn)續(xù)傳忱反,那么真正地開(kāi)始上傳動(dòng)作前泛释,將文件分割存儲(chǔ)到IndexedDB中,就成了比較容易想到的思路温算。

2.1 服務(wù)端實(shí)現(xiàn)

參考阿里云OSS的分片上傳,服務(wù)端邏輯分為三步:

  • 1.初始化分片上傳事件
    初始化的目的主要是為了先確定將要上傳的文件怜校,并得到一個(gè)唯一標(biāo)識(shí),后續(xù)上傳分片時(shí)注竿,都需要攜帶這個(gè)標(biāo)識(shí)韭畸,這樣才能判斷上傳的分片屬于哪個(gè)文件宇智。
  • 2.上傳分片
    按照約定的分片大小,將一個(gè)體積較大的文件胰丁,按照確定的分片大小分批將整個(gè)文件上傳完畢随橘。
  • 3.完成分片上傳
    待所有的分片上傳完成后,發(fā)送“通知”锦庸,將之前上傳的分片組裝成一個(gè)完整的文件机蔗。
2.2 客戶端實(shí)現(xiàn)
2.2.1 初始化 & 完成

照理說(shuō),客戶端實(shí)現(xiàn)應(yīng)該與服務(wù)端一致甘萧,首先要進(jìn)行初始化請(qǐng)求萝嘁,同時(shí)上傳完畢后應(yīng)再發(fā)送一個(gè)請(qǐng)求告訴服務(wù)端上傳完成,通知服務(wù)端將將之前上傳的分片合并成一個(gè)完整的文件扬卷。但為了簡(jiǎn)化操作牙言,服務(wù)端可以自行檢查,如果某個(gè)文件首次上傳分片怪得,就進(jìn)行初始化操作咱枉,如果是最后一次上傳分片,則將這些分片合并徒恋,同時(shí)刪除這些分片蚕断。

2.2.2 文件分割

分割文件時(shí),首先從前端頁(yè)面獲取到要上傳的File對(duì)象入挣,由于File(interface File extends Blob {})繼承了Blob亿乳,使用Blob的slice(start, end, contentType)方法按照與服務(wù)端約定的chunk size對(duì)此文件進(jìn)行分割。需要注意的是径筏,這里的[start, end)計(jì)算范圍時(shí)使用的是前閉后開(kāi)的規(guī)則葛假,http request header中的Range有所差異。

const blob = new Blob([file], { type: file.type });
2.2.3 文件分片存儲(chǔ)到IndexedDB

分片內(nèi)容存儲(chǔ)到IndexedDB的過(guò)程與下載文件分片后的操作基本一致滋恬,這里不多贅述聊训,文章的最后會(huì)放出代碼倉(cāng)庫(kù)鏈接。

2.2.4 記錄文件上傳任務(wù)到IndexedDB

同樣的夷恍,為了知道一個(gè)文件被分成了多少塊存儲(chǔ)在IndexedDB中,我們采用的思路依然是額外存儲(chǔ)一條上傳文件相關(guān)的記錄媳维,其中chunks字段記錄了這個(gè)文件被分割成了哪些塊酿雪,每一塊在IndexedDB中對(duì)應(yīng)的key,以及塊的序號(hào)侄刽、大小指黎、是否已經(jīng)上傳等信息。

2.2.5 從IndexedDB獲取文件分片發(fā)送至服務(wù)端

在所有的文件塊在IndexedDB中存儲(chǔ)完畢后州丹,接下來(lái)就需要將這些文件發(fā)送到服務(wù)器端醋安≡优恚客戶端上傳分片時(shí),需要傳遞分片序號(hào)吓揪,以便服務(wù)端按照正確的順序組裝文件亲怠。

每上傳完成一塊后,需要在額外記錄文件上傳數(shù)據(jù)的那條記錄中更新?tīng)顟B(tài)柠辞,標(biāo)記標(biāo)記哪些塊已經(jīng)上傳完成了团秽。一方面方便計(jì)算文件上傳進(jìn)度,另外還能在上傳任務(wù)中斷后叭首,下次恢復(fù)上傳時(shí)不至于重新上傳之前已經(jīng)上傳過(guò)的文件塊习勤,只需要繼續(xù)上傳還未上傳的部分即可。

2.2.6 上傳完成焙格,清理數(shù)據(jù)

上傳完成后图毕,一般不需要考慮特別的場(chǎng)景,將IndexedDB中存儲(chǔ)的文件數(shù)據(jù)清除就行眷唉。至于上傳任務(wù)那條記錄予颤,是否要一并刪除,問(wèn)題都不大厢破,頁(yè)面做好相應(yīng)的交互及提示荣瑟,給用戶完整的體驗(yàn)就可以。

3.代碼實(shí)現(xiàn)

后續(xù)在編寫(xiě)代碼的過(guò)程中摩泪,重新梳理了思路笆焰,尤其在IndexedDB存儲(chǔ)上改進(jìn)了原先的設(shè)計(jì),將上傳記錄见坑、下載記錄嚷掠、文件塊分別用一個(gè)store存儲(chǔ)。

import localforage from "localforage";

export const db_upload = localforage.createInstance({ name: "myapp", storeName: "upload" });
export const db_download = localforage.createInstance({ name: "myapp", storeName: "download" });
export const db_chunk = localforage.createInstance({ name: "myapp", storeName: "chunk" });

其中upload和download中存儲(chǔ)的數(shù)據(jù)結(jié)構(gòu)也進(jìn)行了改進(jìn)荞驴,

export interface FileData {
    id?: string;
    hash: string;
    name: string;
    type: string;
    size: number;
    chunks: FileChunk[];  // file chunks stored in IndexedDB
};

export interface FileChunk {
    index: number;
    key: string;
    size: number;
    uploaded?: boolean;
}

這樣上傳記錄和下載記錄直接遍歷upload和download這兩個(gè)store即可不皆,每個(gè)記錄相關(guān)的chunk,則從chunks字段獲取熊楼,上傳記錄的chunk還額外多了一個(gè)uploaded字段霹娄,表示該分片是否已經(jīng)上傳。

結(jié)合上面關(guān)于上傳和下載的過(guò)程和思路分析鲫骗,這里以上傳上傳文件到服務(wù)端本地為例犬耻,提供了完整的前后端代碼,供參考执泰。實(shí)際項(xiàng)目中需要結(jié)合業(yè)務(wù)場(chǎng)景枕磁,將本地存儲(chǔ)替換為OSS或其它存儲(chǔ)。

Github - file-upload-and-download-example

但是术吝,有一些細(xì)節(jié)處理是沒(méi)有實(shí)現(xiàn)的计济,代碼中標(biāo)記了TODO茸苇,比如:

  • 檢查分片是否已經(jīng)上傳過(guò),如果已經(jīng)上傳了則忽略沦寂;
  • 根據(jù)文件md5檢查文件是否已經(jīng)存在学密,如果存在直接進(jìn)行鏈接,無(wú)需上傳(有些地方將這種做法叫做秒傳)凑队;
  • ...

最后附上兩張界面截圖

upload
download
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末则果,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子漩氨,更是在濱河造成了極大的恐慌西壮,老刑警劉巖,帶你破解...
    沈念sama閱讀 216,402評(píng)論 6 499
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件叫惊,死亡現(xiàn)場(chǎng)離奇詭異款青,居然都是意外死亡,警方通過(guò)查閱死者的電腦和手機(jī)霍狰,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,377評(píng)論 3 392
  • 文/潘曉璐 我一進(jìn)店門(mén)抡草,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái),“玉大人蔗坯,你說(shuō)我怎么就攤上這事康震。” “怎么了宾濒?”我有些...
    開(kāi)封第一講書(shū)人閱讀 162,483評(píng)論 0 353
  • 文/不壞的土叔 我叫張陵腿短,是天一觀的道長(zhǎng)。 經(jīng)常有香客問(wèn)我绘梦,道長(zhǎng)橘忱,這世上最難降的妖魔是什么? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 58,165評(píng)論 1 292
  • 正文 為了忘掉前任卸奉,我火速辦了婚禮钝诚,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘榄棵。我一直安慰自己凝颇,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,176評(píng)論 6 388
  • 文/花漫 我一把揭開(kāi)白布疹鳄。 她就那樣靜靜地躺著拧略,像睡著了一般。 火紅的嫁衣襯著肌膚如雪尚辑。 梳的紋絲不亂的頭發(fā)上辑鲤,一...
    開(kāi)封第一講書(shū)人閱讀 51,146評(píng)論 1 297
  • 那天盔腔,我揣著相機(jī)與錄音杠茬,去河邊找鬼月褥。 笑死,一個(gè)胖子當(dāng)著我的面吹牛瓢喉,可吹牛的內(nèi)容都是我干的宁赤。 我是一名探鬼主播,決...
    沈念sama閱讀 40,032評(píng)論 3 417
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼栓票,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼决左!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起走贪,我...
    開(kāi)封第一講書(shū)人閱讀 38,896評(píng)論 0 274
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤佛猛,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后坠狡,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體继找,經(jīng)...
    沈念sama閱讀 45,311評(píng)論 1 310
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,536評(píng)論 2 332
  • 正文 我和宋清朗相戀三年逃沿,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了婴渡。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 39,696評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡凯亮,死狀恐怖边臼,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情假消,我是刑警寧澤柠并,帶...
    沈念sama閱讀 35,413評(píng)論 5 343
  • 正文 年R本政府宣布,位于F島的核電站置谦,受9級(jí)特大地震影響堂鲤,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜媒峡,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,008評(píng)論 3 325
  • 文/蒙蒙 一瘟栖、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧谅阿,春花似錦半哟、人聲如沸。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 31,659評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至氯檐,卻和暖如春戒良,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背冠摄。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 32,815評(píng)論 1 269
  • 我被黑心中介騙來(lái)泰國(guó)打工糯崎, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留几缭,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 47,698評(píng)論 2 368
  • 正文 我出身青樓沃呢,卻偏偏與公主長(zhǎng)得像年栓,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子薄霜,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,592評(píng)論 2 353

推薦閱讀更多精彩內(nèi)容