【js进阶】前端文件上传大全

我们在平时工作中常常会遇到文件上传的需求。但许久以来大多数人都是直接使用一些框架自带的组件去实现,对于一些复杂的上传场景、原理一直是云里雾里,不得其解。本文旨在对2020年最popular的面试技术点——前端文件上传做详尽的解读,包含前端、node、http等涉及文件上传的完整流程。

这是一个非常好的切入全栈思维的点。


formData

enctype 属性规定在发送到服务器之前应该如何对表单数据进行编码。

application/x-www-form-urlencoded:在发送前编码所有字符(默认)

multipart/form-data:不对字符编码。在使用包含文件上传控件的表单时,必须使用该值。

text/plain:空格转换为 "+" 加号,但不对特殊字符编码。

1. vue

<input type="file" @change="handleFileChange" />

<el-button type="primary" @click="handleUpload">上传</el-button>

2.使用form表单上传文件

① 请求使用post方法

② 设置enctype="multipart/form-data"

multipart代表多部件请求体,就是把每一个表单项分隔为一个部件。

表单项分为普通表单项和文件表单项。

multipart/form-data类型的body为多部请求体。

multipart/form-data既可以上传二进制数据,也可以上传表单键值对,只是最后会转化为一条信息

http请求中的multipart/form-data,会将表单的数据处理为一条消息,以标签为单元,用分隔符分开。

既可以上传键值对,也可以上传文件

当上传的字段是文件,会使用content-type表明文件类型;content-disposition说明字段的一些信息。

由于有boundary隔离,所以multipart/form-data既可以上传文件,也可以上传键值对。

html代码示例:

<body>

    <form action="http://127.0.0.1:8001/file_upload" method="POST" enctype="multipart/form-data">

        <fieldset>

            <input type="file" name="handleFileChange">

            <input type="submit" value="上传">

        </fieldset>

    </form>

</body>

3.使用FormData()上传

可以将表单中的信息添加到formData()对象中,再使用ajax进行数据传递。

FormData传输的数据格式和表单通过submit()方法传输的数据格式相同

通过formData.append('key',value)向formData()对象添加数据

注意:

(1)不要产生跨域,要阻止表单的默认事件。

(2)要设置两个属性为false:contentType/processData

processData:要求为Boolean类型的参数,默认为true。默认情况下,发送的数据将被转换为对象(从技术角度来讲并非字符串)以配合默认内容类型application/x-www-form-urlencoded。如果要发送DOM树信息或者其他不希望转换的信息,请设置为false。

比如var formData = new FormData();

formData.append("xxx","xxxx");

异步发送这个数据的时候必须设置processData:false

<body style="padding:30px;">

    <form action="">

        <fieldset>

            <input type="text" id="title" required placeholder="输入图片名称">

            <input type="file" id="fileUpload" accept="image/*">

            <!-- 替换成普通的按钮 -->

            <button onclick="fileUpload(event)">使用ajax提交数据</button>

        </fieldset>

    </form>

</body>

content-type显示结果

let formData=newFormData();


handleFileChange(e) {

    const [file] = e.target.files;

    if (!file) return;

    form.append("filename", this.container.file.name);

    form.append("file", this.container.file);

    request({ url: '/upload', data: form })

}

4.使用FileReader()上传图片

HTML5的新api,兼容性也不是特别好,只兼容到了IE10。

通过将图片转换成base64(字符串格式)发送给后台,不过这样我们前面的后台代码就无法直接获取到上传的文件了

readAsDataURL 方法会读取指定的 Blob 或 File 对象。读取操作完成的时候,readyState 会变成已完成DONE,并触发 loadend 事件,同时 result 属性将包含一个data:URL格式的字符串(base64编码)以表示所读取文件的内容。

//定义函数

function uploadFile(event) {

    //阻止默认事件

    event.preventDefault()

    //获取输入的内容

    var title = document.querySelector("#title").value

    var file = document.querySelector("#fileUpload").files[0]

    //创建reader对象

    var reader = new FileReader()

    //读取选择的文件

    reader.readAsDataURL(file)

    //监听读取事件

    reader.onload = function(){

        //将输入的内容添加到要发送的对象中

        var data = {}

        data.title = title

        data.file = reader.result

        //使用jquey的ajax发送post请求

        $.ajax({

            url:"http://127.0.0.1:8001/file_upload",

            type:"POST",

            data:data,

            success:function(res){

                alert(res)

            }})}}

Node服务

Main.js

const http = require("http")

const path = require('path')

const Controller = require('./controller')

const schedule = require('./schedule')

const server = http.createServer()

const UPLOAD_DIR = path.resolve(__dirname, "..", "target");  // 大文件存储目录

// schedule.start(UPLOAD_DIR)

const ctrl = new Controller(UPLOAD_DIR)

server.on("request", async (req, res)

    => {

    res.setHeader("Access-Control-AllowOrigin", "*")

    res.setHeader("Access-Control-AllowHeaders", "*")

    if (req.method === "OPTIONS") {

        res.status = 200

        res.end()

        return

    }

    if (req.method === "POST") {

        if (req.url == '/upload') {

            await ctrl.handleUpload(req,res)

            return

        }

    }

})

server.listen(3000, () =>console.log("正在监听 3000 端⼝"))

Controller.js

async handleUpload(req, res) {

    const multipart = new multiparty.Form()

    multipart.parse(req, async (err, field, file) => {

        if (err) {

            console.log(err)

            return

        }

        const [chunk] = file.file

        const [filename] = field.filename

        const filePath = path.resolve(this.UPLOAD_DIR, `${fileHash}${extractExt(filename)}`

    )

    const chunkDir = path.resolve(this.UPLOAD_DIR, fileHash)

    // ⽂件存在直接返回

    if (fse.existsSync(filePath)) {

        res.end("file exist")

        return

    }

    if (!fse.existsSync(chunkDir)) {

        await fse.mkdirs(chunkDir)

    }

    await fse.move(chunk.path, `${chunkDir}/${hash}`)

    res.end("received file chunk")

    })

}

总结:

1. formData

2. httpServer

3. fs⽂件处理

4. multiparty解析post数据

拖拽

考点: 拖拽事件drop,clipboardData

<div class="drop-box" id="drop-box">

box.addEventListener("drop", function(e) {

    e.preventDefault(); //取消浏览器默认拖拽效果

    var fileList = e.dataTransfer.files; //获取拖拽中的⽂件对象

    var len=fileList.length;//⽤来获取⽂件的⻓度(其实是获得⽂件数量)

    const [file] = e.target.files;

    if (!file) return;

    ...上传

}, false);

粘贴

box.addEventListener('paste',function(event) {

    var data = (event.clipboardData)

        ....

});

大文件上传

分片上传

整个流程大致如下:

(1) 将需要上传的文件按照一定的分割规则,分割成相同大小的数据块;

(2) 向服务端发送上传请求,上传时携带完整文件的唯一标识(建议使用MD5加密值就行),服务端判断该文件是否存在,不存在是返回同意上传信息

(3) 接收到服务端同意上传后,按照一定的策略(串行或并行)发送各个分片数据块每个分片携带分片信息(分片总数,分片索引,分片唯一索引,分片数据等),采用并行可实现分片秒传的功能,采用串行更好实现断点续传的功能。

(4) 发送完成后,服务端根据判断数据上传是否完整,如果完整,则进行数据块合成得到原始文件。

blob.slice分片

这里要j简单介绍下Blob对象。

Blob(Binary Large Object)对象代表了一段二进制数据,提供了一系列操作接口。其他操作二进制数据的 API(比如 File 对象),都是建立在 Blob 对象基础上的,继承了它的属性和方法。

Blob 对象是一个不可变、原始数据的类文件对象。Blob 表示的不一定是JavaScript原生格式的数据。File接口基于Blob,继承了 blob 的功能并将其扩展使其支持用户系统上的文件。

要从其他非blob对象和数据构造一个 Blob,请使用Blob() 构造函数。要创建一个 blob 数据的子集 blob,请使用slice() 方法:

var blob = instanceOfBlob.slice([start [, end [, contentType]]]};

start 可选,起始位置下标,默认值是0

end 可选,结束位置下标

contentType 可选,设置文档类型type 属性,默认值为 ‘’

const chunks = this.createFileChunk(this.container.file);

createFileChunk(file, size = SIZE) {

    // ⽣成⽂件块

    const chunks = [];

    let cur = 0;

    while (cur < file.size) {

        chunks.push({ file: file.slice(cur, cur + size) });

        cur += size;

    }

    return chunks;

},

terminal

切片merge

所有切片挨个发请求,然后merge

接收服务端的返回结果判断上传文件的方式,服务端可返回已上传、未上传、上传一部分三种状态。

如当我们接收到未上传状态后。对文件进行分片(每片建议不超过10M)。依次将分片上传,上传信息有(分片MD5值,分片总数,当前分片索引,分片数据等)。当一片上传完成后再回调函数上传下一片。

我向后台的某个地址发送一个请求,传递文件名和文件的最后修改时间为参数,后台根据这两个参数来找到与前台所选择的文件对应的服务器上的文件,将服务器返回的文件大小return出去,来被插件使用。为什么要传递这两个参数呢?我们在前台无法知道服务器上的这个文件的名称,所以使用原始文件名作为一个辅助标识。为了防止用户在两次上传间隔修改了文件,我们把文件的最后修改时间也传给服务端,让服务端进行比较,若时间不对应则返回已上传大小为0,重新上传此文件。

再来看后台都要做哪些工作。数据库中需要有一张表来记录每个已文件的情况,包含的字段大致有:

包含的字段

const shardSize = 5 * 1024 * 1024; // 以5MB为一个分片 // 计算每一片的起始与结束位置

const start = this.state.i * shardSize;

const end = Math.min(size, start + shardSize); form.append("uuid", uuid);  //文件ID

form.append("action", "upload"); // 直接上传分片

form.append("filemd5", filemd5); // 文件MD5form.append("md5", md5); // 分片MD5

form.append("name", name); //文件名

form.append("size", size); // 文件大小

form.append("total", shardCount); // 总片数

form.append("index", this.state.i + 1); // 当前是第几片

form.append("data", file.slice(start, end)); // 使用slice方法用于切出文件的一部分

 const fileImport = (file, index, chunkSize, md5) => { // 获取MD5

    const chunks = Math.ceil(file.size / chunkSize); // 防止文件截取不完整 所以向上取整

    if (file) {

      let upSize = index * chunkSize;

      if (index > chunks - 1) {

        return;

      }

      let blob = file.slice(upSize, upSize + chunkSize); // 截取文件

      try {

        let formData = new FormData();

        formData.append('file', blob);

        formData.append('md5', md5);

        formData.append('chunk', index);

        formData.append('chunks', chunks);

        sliceImportVisualization(formData).then(async res => {

          if (res?.isSuccess) {

            await fileImport(file, ++index, chunkSize, md5); // 在上一片传输完成后再调用 切记 切记

            const { data = {} } = res;

            const { file_name } = data;

            if (file_name) {

              importVisualization({ file_name }).then(res => {

                setSpin({spinning: false});

              });

            }

          }

        });

      } catch (err) {

        console.log('分片上传错误...', err);

      }

    }

  };

async handleMerge(req, res) {

    const data = await resolvePost(req)

    const { fileHash, filename, size } = data

    const ext = extractExt(filename)

    const filePath = path.resolve(this.UPLOAD_DIR, `${fileHash}${ext}`)

    await this.mergeFileChunk(filePath, fileHash, size)

    res.end( JSON.stringify({

        code: 0,

         message: "file merged success"

     })

)}

断点续传 + 秒传

md5计算,缓存思想 ⽂件⽤md5计算⼀个指纹,上传之前,先问后端,这个⽂件的hash在不在,在的话就不⽤传了,就是所谓的断点续传,如果整个⽂件都存在了 就是秒传

async handleVerify(req, res) {

    const data = await resolvePost(req)

    const { filename, hash } = data

    const ext = extractExt(filename)

    const filePath = path.resolve(this.UPLOAD_DIR, `${hash}${ext}`)

    // ⽂件是否存在

    let uploaded = false

    let uploadedList = []

    if (fse.existsSync(filePath)) {

        uploaded = true

    } else {    // ⽂件没有完全上传完毕,但是可能存在部分切⽚上传完毕了

        uploadedList = await getUploadedList(path.resolve(this.UPLOAD_DIR, hash))

    }

    res.end(

        JSON.stringify({ uploaded, uploadedList })   // 过滤诡异的隐藏⽂件

)}

计算hash优化

web-worker

大文件的md5太慢了,启⽤webworker计算

JavaScript 语言采用的是单线程模型,也就是说,所有任务只能在一个线程上完成,一次只能做一件事。前面的任务没做完,后面的任务只能等着。随着电脑计算能力的增强,尤其是多核 CPU 的出现,单线程带来很大的不便,无法充分发挥计算机的计算能力。

Web Worker的作用,就是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行。在主线程运行的同时,Worker 线程在后台运行,两者互不干扰。等到 Worker 线程完成计算任务,再把结果返回给主线程。这样的好处是,一些计算密集型或高延迟的任务,被 Worker 线程负担了,主线程(通常负责UI交互)就会很流畅,不会被阻塞或拖慢。

Worker 线程一旦新建成功,就会始终运行,不会被主线程上的活动(比如用户点击按钮、提交表单)打断。这样有利于随时响应主线程的通信。但是,这也造成了 Worker 比较耗费资源,不应该过度使用,而且一旦使用完毕,就应该关闭。

具体链接:http://www.ruanyifeng.com/blog/2018/07/web-worker.html

// web-worker

self.importScripts('spark-md5.min.js')

self.onmessage = e => {  // 接受主线程的通知

    const {chunks} = e.data

    const spark = new self.SparkMD5.ArrayBuffer()

    let progress = 0; let count = 0

    const loadNext = index => {

        const reader = new FileReader()

        reader.readAsArrayBuffer(chunks[index].file)

            reader.onload = e=> {

                count++                // 累加器 不能依赖index

                spark.append(e.target.result)       // 增量计算md5

                if(count===chunks.length) {

                // 通知主线程,计算结束

                    self.postMessage({

                        progress:100,

                        hash:spark.end()

                    })

                } else {

                    // 每个区块计算结束,通知进度即可

                    progress += 100/chunks.length

                    self.postMessage({ progress })

                    // 计算下⼀个

                    loadNext(count)

                }

            }

        }

    // 启动

    loadNext(0)

}

time-slice

react fiber架构学习,利⽤浏览器空闲时间

requestIdleCallback


相关链接:

https://www.cnblogs.com/songsu/p/12017431.html

https://www.jianshu.com/p/54703c462982

http://www.mamicode.com/info-detail-2861375.html

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345