天天看點

JavaScript進階 - 實作檔案上傳大檔案切片上傳斷點續傳前後端完整代碼

js大檔案切片上傳斷點續傳完整代碼

在前面兩篇文章JavaScript進階 - 純js實作檔案上傳大檔案切片上傳斷點續傳(用戶端)和JavaScript進階 - nodejs+koa2實作檔案上傳大檔案切片上傳斷點續傳(伺服器端)中我們分别介紹了檔案上傳的服務端接口開發和用戶端頁面功能開發,接下來将為大家附上前後端完整代碼
  • 服務端完整代碼
// server.js
const Koa = require('koa');
const Router = require('koa-router');
const koastatic = require('koa-static');
const fs = require('fs');
const formidable = require('formidable');
const multiparty = require('multiparty');
const SparkMD5 = require('spark-md5');
const path = require('path');
const app = new Koa();
let router = new Router();

//中間件:設定允許跨域
app.use(async (ctx, next) => {
    ctx.set('Access-Control-Allow-Origin', '*');
    //處理OPTIONS請求
    ctx.request.methods === 'OPTIONS' ? ctx.body = '請求測試成功' : await next();
});

const host = '127.0.0.1',
    port = 3000;
const HOSTNAME = `${host}:${port}`;
const SERVER_PATH = `${__dirname}/upload`;
app.listen(port, function () {
    console.log('======================================================================');
    console.log(`The server started at port: ${port}, you can access it by ${HOSTNAME}`);
    console.log('======================================================================');
});

//定義延遲函數
const delay = function delay(interval) {
    typeof interval !== 'number' ? interval = 1000 : null;
    return new Promise((resolve, reject) => {
        setTimeout(function () {
            resolve();
        }, interval);
    });
}

//檢測檔案是否已經存在
const exists = function exists(path) {
    return new Promise((resolve, reject) => {
        fs.access(path, fs.constants.F_OK, err => {
            if (err) {
                resolve(false);
                return;
            }
            resolve(true);
        });
    });
}

//利用multiparty插件解析前端傳來的form-data格式的資料,并上傳至伺服器
const multipartyUpload = function multipartyUpload(req, autoUpload) {
    let config = {
        maxFieldsSize: 200 * 1024 * 1024
    }
    if (autoUpload) config.uploadDir = SERVER_PATH;
    return new Promise((resolve, reject) => {
        new multiparty.Form(config).parse(req, (err, fields, files) => {
            if (err) {
                reject(err);
                return;
            }
            resolve({
                fields,
                files
            });
        });
    });
}

//将傳進來的檔案資料寫入伺服器
//form-data格式的資料将以流的形式寫入
//BASE64格式資料則直接将内容寫入
const writeFile = function writeFile(serverPath, file, isStream) {
    console.log(serverPath);
    return new Promise((resolve, reject) => {
        if (isStream) {
            try {
                let readStream = fs.createReadStream(file.path);
                let writeStream = fs.createWriteStream(serverPath);
                readStream.pipe(writeStream);
                readStream.on('end', () => {
                    resolve({
                        result: true,
                        message: '上傳成功!'
                    });
                });
            } catch (err) {
                resolve({
                    result: false,
                    message: err
                })
            }
        } else {
            fs.writeFile(serverPath, file.path, err => {
                if (err) {
                    resolve({
                        result: false,
                        message: err
                    })
                    return;
                }
                resolve({
                    result: true,
                    message: '上傳成功!'
                });
            });
        }
    });
}
//上傳單個檔案(form-data),利用第三方插件multipary解析并上傳
router.post('/upload_single_file', async (ctx, next) => {
    try {
        let {
            files
        } = await multipartyUpload(ctx.req, true);
        let file = (files && files.file.length) ? files.file[0] : {};
        ctx.body = {
            code: 0,
            message: '檔案上傳成功',
            originalFilename: file.originalFilename,
            serverPath: file.path.replace(__dirname, HOSTNAME)
        }
    } catch (err) {
        ctx.body = {
            code: 1,
            message: '檔案上傳失敗'
        }
    }
});

//上傳單個檔案(form-data),利用第三方插件解析但不直接上傳,而是将檔案重命名後再單獨上傳
router.post('/upload_single_formdata_rename', async (ctx, next) => {
    try {
        let {
            files,
            fields
        } = await multipartyUpload(ctx.req, false);
        let file = (files && files.file.length) ? files.file[0] : {};
        let filename = (fields && fields.filename.length) ? fields.filename[0] : '';
        const filePath = `${SERVER_PATH}/${filename}`;
        let isExist = await exists(filePath);
        if (isExist) {
            ctx.body = {
                code: 0,
                message: '檔案已經存在',
                originalFilename: filename,
                serverPath: file.path.replace(__dirname, HOSTNAME)
            }
            return;
        }
        let obj = await writeFile(filePath, file, true);
        if (obj.result) {
            ctx.body = {
                code: 0,
                message: '檔案上傳成功',
                originalFilename: filename,
                serverPath: filePath.replace(__dirname, HOSTNAME)
            }
        } else {
            ctx.body = {
                code: 0,
                message: '檔案上傳失敗'
            }
        }
    } catch (ex) {
        ctx.body = {
            code: 0,
            message: ex
        }
    }
});

//解析post請求參數,content-type為application/x-www-form-urlencoded 或 application/josn
const parsePostParams = function parsePostParams(req) {
    return new Promise((resolve, reject) => {
        let form = new formidable.IncomingForm();
        form.parse(req, (err, fields) => {
            if (err) {
                reject(err);
                return;
            }
            resolve(fields);
        });
    });
}

//BASE64上傳,該方式隻能上傳小圖檔,大圖檔不建議使用這種方式會造成程式卡死,大圖檔使用form-data上傳
router.post('/upload_base64', async (ctx, next) => {
    try {
        let {
            file,
            filename
        } = await parsePostParams(ctx.req);
        file = decodeURIComponent(file);
        const suffix = /\.([0-9a-zA-Z]+)$/.exec(filename)[1];
        let spark = new SparkMD5.ArrayBuffer();
        file = file.replace(/^data:image\/\w+;base64,/, "");
        file = Buffer.from(file, 'base64');
        spark.append(file);
        let filepath = `${SERVER_PATH}/${spark.end()}.${suffix}`;
        await delay();
        const isExists = await exists(filepath);
        if (isExists) {
            ctx.body = {
                code: 0,
                message: '檔案已經存在',
                originalFilename: filename,
                serverPath: file.path.replace(__dirname, HOSTNAME)
            }
            return;
        }
        let obj = await writeFile(filepath, file, false);
        if (obj.result) {
            ctx.body = {
                code: 0,
                message: '檔案上傳成功',
                originalFilename: filename,
                serverPath: filepath.replace(__dirname, HOSTNAME)
            }
        } else {
            ctx.body = {
                code: 0,
                message: '檔案上傳失敗'
            }
        }
    } catch (err) {
        console.log(err);
        ctx.body = {
            code: 0,
            message: err
        }
    }
});

const mergeFiles = function mergeFiles(hash, count) {
    return new Promise(async (resolve, reject) => {
        const dirPath = `${SERVER_PATH}/${hash}`;
        if (!fs.existsSync(dirPath)) {
            reject('還沒上傳檔案,請先上傳檔案');
            return;
        }
        const filelist = fs.readdirSync(dirPath);
        if (filelist.length < count) {
            reject('檔案還未上傳完成,請稍後再試');
            return;
        }
        let suffix;
        filelist.sort((a, b) => {
            const reg = /_(\d+)/;
            return reg.exec(a)[1] - reg.exec(b)[1];
        }).forEach(item => {
            !suffix ? suffix = /\.([0-9a-zA-Z]+)$/.exec(item)[1] : null;
            //将每個檔案讀取出來并append到以hash命名的新檔案中
            fs.appendFileSync(`${SERVER_PATH}/${hash}.${suffix}`, fs.readFileSync(`${dirPath}/${item}`));
            fs.unlinkSync(`${dirPath}/${item}`); //删除切片檔案
        });

        await delay(1000); //等待1秒後删除新産生的檔案夾
        fs.rmdirSync(dirPath);
        resolve({
            path: `${HOSTNAME}/upload/${hash}.${suffix}`,
            filename: `${hash}.${suffix}`
        })
    });
}

//大檔案切片上傳
router.post('/upload_chunk', async (ctx, next) => {
    try {
        let {
            files,
            fields
        } = await multipartyUpload(ctx.req, false);
        let file = (files && files.file[0]) || {};
        let filename = (fields && fields.filename[0]) || '';
        let [, hash] = /^([^_]+)_(\d+)/.exec(filename);
        const dirPath = `${SERVER_PATH}/${hash}`;
        if (!fs.existsSync(dirPath)) {
            fs.mkdirSync(dirPath);
        }
        const filePath = `${dirPath}/${filename}`;
        const isExists = await exists(filePath);
        if (isExists) {
            ctx.body = {
                code: 0,
                message: '檔案已經存在',
                originalFilename: filename,
                serverPath: filePath.replace(__dirname, HOSTNAME)
            }
            return;
        }
        let obj = await writeFile(filePath, file, true);
        if (obj.result) {
            ctx.body = {
                code: 0,
                message: '檔案上傳成功',
                serverPath: filePath.replace(__dirname, HOSTNAME)
            }
        } else {
            ctx.body = {
                code: 1,
                message: 'caolouyaqian',
                serverPath: filePath.replace(__dirname, HOSTNAME)
            }
        }

    } catch (err) {
        ctx.body = {
            code: 1,
            message: err
        }
    }
});

//合并切片檔案
router.post('/upload_merge', async (ctx, next) => {
    const {
        hash,
        count
    } = await parsePostParams(ctx.req);
    const {
        path,
        filename
    } = await mergeFiles(hash, count);
    ctx.body = {
        code: 0,
        message: '檔案上傳成功',
        path,
        filename
    }
});

//擷取已上傳的切片
router.get('/uploaded', async (ctx, next) => {
    try {
        const {
            hash
        } = ctx.request.query;
        const dirPath = `${SERVER_PATH}/${hash}`;
        const filelist = fs.readdirSync(dirPath);
        filelist.sort((a, b) => {
            const reg = /_([\d+])/;
            return reg.exec(a)[1] - reg.exec(b)[1];
        });
        ctx.body = {
            code: 0,
            message: '擷取成功',
            filelist: filelist || []
        }
    } catch (err) {
        ctx.body = {
            code: 0,
            message: '擷取失敗',
            filelist: []
        }
    }
});

app.use(koastatic('./'));
app.use(router.routes());
app.use(router.allowedMethods());
           
//package.json
{
  "name": "server-koa",
  "version": "1.0.0",
  "description": "",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "Alvin",
  "license": "MIT",
  "dependencies": {
    "formidable": "^1.2.2",
    "koa": "^2.13.1",
    "koa-router": "^10.0.0",
    "koa-static": "^5.0.0",
    "multiparty": "^4.2.2",
    "nodemon": "^2.0.7",
    "spark-md5": "^3.0.1"
  }
}
           
  • 用戶端完整代碼

scripts/axios.min.js

scripts/qs.js

scripts/spark-md5.min.js

以上這三個庫可以到對應的官網下載下傳

// scripts/axios2.js
let request = axios.create();
request.defaults.baseURL = 'http://127.0.0.1:3000';
request.defaults.headers['Content-Type'] = 'mutipart/form-data';
request.defaults.transformRequest = (data, headers) => {
    let contentType = headers['Content-Type'];
    if (contentType === 'application/x-www-form-urlencoded') return Qs.stringify(data);
    return data;
}

request.interceptors.response.use(response => {
    return response.data;
});
           
//scripts/upload.js
//單檔案上傳form-data
(function () {
    let upload1 = document.querySelector("#upload1"),
        upload_inp = upload1.querySelector('.upload-inp'),
        upload_select = upload1.querySelector('.upload-btn.select'),
        upload_upload = upload1.querySelector('.upload-btn.upload'),
        sel_files = upload1.querySelector('.files'),
        file1 = upload1.querySelector('.abbr'),
        cur_pro = upload1.querySelector('.cur-pro'),
        pro_val = upload1.querySelector('.pro-val'),
        progress = upload1.querySelector('.progress'),
        _file;

    upload_select.addEventListener('click', () => {
        upload_inp.click();
    });
    upload_inp.addEventListener('change', function () {
        let file = this.files[0];
        _file = file;
        // if (file.size > 200 * 1024 * 1024) {
        //     alert('圖檔必須小于200M');
        //     // return;
        // }

        // if (!/(jpg|jpeg|png)/.test(file.type)) {
        //     alert('隻能上傳png或jpg或jpeg格式的圖檔');
        //     // return;
        // }
        sel_files.innerHTML = file.name;
        progress.style.display = 'inline-block';
        pro_val.innerHTML = '';
    })

    upload_upload.addEventListener('click', function () {
        let formData = new FormData();
        formData.append('file', _file);
        formData.append('filename', _file.name);
        request.post('/upload_single_file', formData, {
            onUploadProgress: function (ev) {
                let pro = ((ev.loaded / ev.total) * 100).toFixed(0) + '%';
                cur_pro.style.width = pro;
                pro_val.innerHTML = pro;
            }
        }).then(res => {
            console.log(res);
            file1.src = `http://${res.serverPath}`;
            file1.style.display = 'block';
        }).catch(err => {
            console.log(err);
        });
    });
})();

//單檔案base64上傳
(function () {
    let upload2 = document.querySelector("#upload2"),
        upload_inp = upload2.querySelector('.upload-inp'),
        upload_upload = upload2.querySelector('.upload-btn.upload'),
        sel_files = upload2.querySelector('.files'),
        file2 = upload2.querySelector('.abbr'),
        progress = upload2.querySelector('.progress'),
        cur_pro = upload2.querySelector('.cur-pro'),
        pro_val = upload2.querySelector('.pro-val'),
        _file;

    upload_upload.addEventListener('click', () => {
        upload_inp.click();
    });
    upload_inp.addEventListener('change', function () {
        progress.style.display = 'inline-block';
        pro_val.innerHTML = '';
        let file = this.files[0];
        _file = file;
        if (file.size > 200 * 1024) {
            alert('圖檔必須小于200k');
            return;
        }

        if (!/(jpg|jpeg|png)/.test(file.type)) {
            alert('隻能上傳png或jpg或jpeg格式的圖檔');
            return;
        }
        sel_files.innerHTML = file.name;
        let fr = new FileReader();
        fr.readAsDataURL(file);
        fr.onload = ev => {
            file2.src = ev.target.result;
            file2.style.display = 'block';
            console.log(file.name);
            request.post('/upload_base64', {
                file: encodeURIComponent(ev.target.result),
                filename: file.name
            }, {
                headers: {
                    "Content-Type": "application/x-www-form-urlencoded"
                },
                onUploadProgress: function (ev) {
                    let pro = ((ev.loaded / ev.total) * 100) + '%';
                    pro_val.innerHTML = pro;
                    cur_pro.style.width = pro;
                }
            }).then(res => {
                console.log(res);
                alert('上傳成功了');
                return;
            }).catch(err => {
                console.log(err);
                alert('失敗了?')
            });
        };

    })
})();

//多檔案上傳form-data
(function () {
    let upload3 = document.querySelector("#upload3"),
        upload_inp = upload3.querySelector('.upload-inp'),
        upload_upload = upload3.querySelector('.upload-btn.upload'),
        sel_files = upload3.querySelector('.list');


    upload_upload.addEventListener('click', () => {
        upload_inp.click();
    });
    upload_inp.addEventListener('change', function () {
        let files = this.files;
        sel_files.innerHTML = '';
        [].forEach.call(files, (file, index) => {
            sel_files.innerHTML += `<div><span class="files" style="margin-right:8px;font-size:12px">${file.name}</span><span class="pro-val" id="myfile${index}"></span></div>`
            let formData = new FormData();
            formData.append('file', file);
            formData.append('filename', file.name);
            request.post('/upload_single_file', formData, {
                onUploadProgress: function (ev) {
                    let pro = ((ev.loaded / ev.total) * 100).toFixed(0) + '%';
                    document.querySelector(`#myfile${index}`).innerHTML = pro;
                    // sel_files.innerHTML += `<span class="files">${file.name}</span> <span class="pro-val" >${pro}</span>`
                }
            }).then(res => {
                console.log(res);
                // alert('上傳成功了');
            }).catch(err => {
                console.log(err);
            });
        });
    });
})();

//拖拽上傳form-data
(function () {
    let upload5 = document.querySelector("#upload5"),
        upload_inp = upload5.querySelector('.upload-inp'),
        upload_upload = upload5.querySelector('.upload-btn'),
        sel_files = upload5.querySelector('.list');

    const uploadFiles = function uploadFiles(files) {
        sel_files.innerHTML = '';
        [].forEach.call(files, (file, index) => {
            sel_files.innerHTML += `<div><span class="files" style="margin-right:8px;font-size:12px">${file.name}</span><span class="pro-val" id="myfile${index}"></span></div>`
            let formData = new FormData();
            formData.append('file', file);
            formData.append('filename', file.name);
            request.post('/upload_single_file', formData, {
                onUploadProgress: function (ev) {
                    let pro = ((ev.loaded / ev.total) * 100).toFixed(0) + '%';
                    document.querySelector(`#myfile${index}`).innerHTML = pro;
                }
            }).then(res => {
                console.log(res);
                // alert('上傳成功了');
            }).catch(err => {
                console.log(err);
            });
        });
    }
    upload5.addEventListener('dragover', function (ev) {
        ev.preventDefault();
    });
    upload5.addEventListener('drop', (ev) => {
        ev.preventDefault();
        uploadFiles(ev.dataTransfer.files);
    });

    upload_inp.addEventListener('change', function () {
        uploadFiles(this.files);
    });
    upload5.addEventListener('click', (ev) => {
        upload_inp.click();
    });
})();

//大檔案切片上傳,斷點續傳
(function () {
    let upload4 = document.querySelector("#upload4"),
        upload_inp = upload4.querySelector('.upload-inp'),
        upload_upload = upload4.querySelector('.upload-btn'),
        sel_files = upload4.querySelector('.files'),
        cur_pro = upload4.querySelector('.cur-pro'),
        pro_val = upload4.querySelector('.pro-val'),
        progress = upload4.querySelector('.progress');

    const retriveHash = function retriveHash(file) {
        return new Promise((resolve, reject) => {
            let spark = new SparkMD5.ArrayBuffer();
            let fr = new FileReader();
            fr.readAsArrayBuffer(file);
            fr.onload = (ev) => {
                spark.append(ev.target.result);
                let hash = spark.end();
                let suffix = /\.([0-9a-zA-Z]+)$/.exec(file.name)[1];
                resolve({
                    hash,
                    suffix
                });
            };
        });


    }

    let complete = 0;
    const uploadComplete = function uploadComplete(hash, count) {
        complete++;
        let progerss = (complete / count * 100).toFixed(2) + '%';
        cur_pro.style.width = progerss;
        pro_val.innerHTML = progerss;
        if (complete < count) return;
        cur_pro.style.width = '100%';
        pro_val.innerHTML = '100%';
        setTimeout(() => {
            request.post('/upload_merge', {
                hash,
                count
            }, {
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded'
                }
            }).then(res => {
                console.log(res);
                // alert('上傳成功了');
            }).catch(err => {
                console.log(err);
            });
        }, 3000);
    }
    upload_upload.addEventListener('click', function () {
        upload_inp.click();
    });

    upload_inp.addEventListener('change', async function () {
        let file = this.files[0];
        progress.style.display = 'inline-block';
        cur_pro.style.width = '0%';
        pro_val.innerHTML = '0%';
        let chunks = [];
        let {
            hash,
            suffix
        } = await retriveHash(file);
        sel_files.innerHTML = `${hash}.${suffix}`;
        let {
            filelist
        } = await request.get('/uploaded', {
            params: {
                hash
            }
        });

        let maxSize = 100 * 1024; //100k
        let count = Math.ceil(file.size / maxSize);
        //限制切片的數量不能超過20個,并重新計算每個切片的大小
        if (count > 20) {
            maxSize = file.size / 20;
            count = 20;
        }

        let index = 0;
        while (index < count) {
            chunks.push({
                file: file.slice(index * maxSize, (index + 1) * maxSize),
                filename: `${hash}_${index+1}.${suffix}`
            });
            index++;
        }

        chunks.forEach((item, index) => {
            //如果已經上傳過就不再上傳了
            if (filelist && filelist.length > 0 && filelist.includes(item.filename)) {
                uploadComplete(hash, count);
                return;
            }
            let formData = new FormData();
            formData.append('file', item.file);
            formData.append('filename', item.filename);
            request.post('/upload_chunk', formData).then(res => {
                uploadComplete(hash, count);
                // console.log(res);
                // alert('上傳成功了');
            }).catch(err => {
                console.log(err);
            });
        });
    });


})()
           
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>檔案上傳</title>
    <link rel="stylesheet" href="./css/upload.css">

</head>

<body>
    <div class="container">
        <div class="item" id="upload1">
            <div class="title">單檔案上傳FORM-DATA,先選檔案再上傳</div>
            <input type="file" class="upload-inp">
            <button class="upload-btn select">選擇檔案</button>
            <button class="upload-btn upload">上傳到伺服器</button>
            <div class="list">
                <span class="files"></span>
                <div class="progress">
                    <div class="cur-pro">
                    </div>
                </div>
                <span class="pro-val"></span>
                <img class="abbr" src="">
            </div>
        </div>
        <div class="item" id='upload2'>
            <div class="title">單檔案上傳BASE64,隻能上傳100k以内的png或jpg圖檔檔案</div>
            <input type="file" class="upload-inp">
            <button class="upload-btn upload">選擇并上傳</button>
            <div class="list">
                <span class="files"></span>
                <div class="progress">
                    <div class="cur-pro">
                    </div>
                </div>
                <span class="pro-val"></span>
                <img class="abbr" src="">
            </div>
        </div>
        <div class="item" id='upload3'>
            <div class="title">多檔案上傳FORM-DATA</div>
            <input type="file" class="upload-inp" multiple>
            <!-- <button class="upload-btn select">選擇檔案</button> -->
            <button class="upload-btn upload">選擇并上傳</button>
            <div class="list">

            </div>
        </div>
        <div class="item" id="upload4">
            <div class="title">大檔案切片上傳,斷點續傳FORM-DATA</div>
            <input type="file" class="upload-inp">
            <!-- <button class="upload-btn select">選擇檔案</button> -->
            <button class="upload-btn upload">選擇并上傳</button>
            <div class="list">
                <span class="files"></span>
                <img class="abbr" src="">
                <div class="progress">
                    <div class="cur-pro">
                    </div>
                </div>
                <span class="pro-val"></span>
            </div>
        </div>
        <div class="item drag-upload" id='upload5'>
            <div class="title">多檔案拖拽上傳FORM-DATA</div>
            <input type="file" class="upload-inp" multiple>
            <div class="upload-btn">将檔案拖到此處,或<span style="color:cornflowerblue">點選上傳</span></div>
            <div class="list">
            </div>
        </div>
    </div>
</body>
<script src="./scripts/axios.min.js"></script>
<script src="./scripts/qs.js"></script>
<script src="./scripts/spark-md5.min.js"></script>
<script src="./axios2.js"></script>
<script src="./upload.js"></script>

</html>
           
.container {
    display: flex;
    flex-direction: row;
    flex-wrap: wrap;
}

.container .item {
    border: 1px dashed #b7b7b7;
    width: 350px;
    height: 200px;
    margin-right: 50px;
    margin-bottom: 20px;
}

.item .title {
    font-size: 12px;
    color: #b7b7b7;
}

.upload-inp {
    display: none;
}

.upload-btn {
    padding: 10px 15px;
    margin: 5px;
    border: none;
    cursor: pointer;
}

.upload-btn.select {
    background-color: lightblue;
}

.upload-btn.upload {
    background-color: lightgreen;
}

.disabled {
    background-color: #b7b7b7 !important;
    color: grey !important;
}

.progress {
    display: none;
    width: 280px;
    height: 10px;
    border: 1px solid green;
    margin-left: 5px;
    text-align: center;
    color: red;
}

.progress .cur-pro {
    width: 0%;
    height: 10px;
    background-color: green;
}

.list {
    font-size: 12px;
}

.list .abbr {
    display: none;
    width: 100px;
    height: 100px;
}

.list .files {
    margin-right: 8px;
}

.drag-upload {
    cursor: pointer;
    background: url('./upload.png') no-repeat 120px 50px;
}

.drag-upload .upload-btn {
    font-size: 12px;
    margin-left: 80px;
    margin-top: 100px;
}