# 文件上传原理

原理: 完成消息体封装和消息体的额解析,然后将二进制内容保存到文件。

人话: 把 form 标签的 enctype 设置为 multipart/form-data,同时 method 必须为 post 方法

# 单文件上传和上传进度

使用 form 表单上传文件,原来都是用 form 表单,但是现在一般使用 input + xhr 来实现比较好

HTML

<form method="post" action="http://localhost:8100" enctype="multipart/form-data">
  选择文件:
  <input type="file" name="f1" /> input 必须设置 name 属性,否则数据无法发送<br />
  <br />
  标题:<input type="text" name="title" /><br /><br /><br />

  <button type="submit" id="btn-0">上 传</button>
</form>

# 多文件上传和上传进度

html5 只需要一个标签加个属性就搞定了,file 标签开启 multiple

HTML

<input type="file" name="f1" multiple />

NODE

//二次处理文件,修改名称
app.use(ctx => {
  var files = ctx.request.files.f1; // 多文件, 得到上传文件的数组
  var result = [];

  //遍历处理
  files &&
    files.forEach(item => {
      var path = item.path;
      var fname = item.name; //原文件名称
      var nextPath = path + fname;
      if (item.size > 0 && path) {
        //得到扩展名
        var extArr = fname.split(".");
        var ext = extArr[extArr.length - 1];
        var nextPath = path + "." + ext;
        //重命名文件
        fs.renameSync(path, nextPath);

        //文件可访问路径放入数组
        result.push(uploadHost + nextPath.slice(nextPath.lastIndexOf("/") + 1));
      }
    });

  //输出 json 结果
  ctx.body = `{
        "fileUrl":${JSON.stringify(result)}
    }`;
});

局部刷新的做法:

页面内放一个隐藏的 iframe,或者使用 js 动态创建,指定 form 表单的 target 属性值为 iframe 标签 的 name 属性值,这样 form 表单的 submit 行为的跳转就会在 iframe 内完成,整体页面不会刷新。

然后为 iframe 添加 load 事件,得到 iframe 的页面内容,将结果转换为 JSON 对象,这样就拿到了接口的数据

<iframe id="temp-iframe" name="temp-iframe" src="" style="display:none;"></iframe>
<form method="post" target="temp-iframe" action="http://localhost:8100" enctype="multipart/form-data">
  选择文件(可多选):
  <input type="file" name="f1" id="f1" multiple /><br />
  input 必须设置 name 属性,否则数据无法发送<br />
  <br />
  标题:<input type="text" name="title" /><br /><br /><br />

  <button type="submit" id="btn-0">上 传</button>
</form>

<script>
  var iframe = document.getElementById("temp-iframe");
  iframe.addEventListener("load", function() {
    var result = iframe.contentWindow.document.body.innerText;
    //接口数据转换为 JSON 对象
    var obj = JSON.parse(result);
    if (obj && obj.fileUrl.length) {
      alert("上传成功");
    }
    console.log(obj);
  });
</script>

无刷新上传,借助 XHR

<div>
  选择文件(可多选):
  <input type="file" id="f1" multiple /><br /><br />
  <button type="button" id="btn-submit">上 传</button>
</div>
function submitUpload() {
  //获得文件列表,注意这里不是数组,而是对象
  var fileList = document.getElementById("f1").files;
  if (!fileList.length) {
    alert("请选择文件");
    return;
  }
  var fd = new FormData(); //构造FormData对象
  fd.append("title", document.getElementById("title").value);

  //多文件上传需要遍历添加到 fromdata 对象
  for (var i = 0; i < fileList.length; i++) {
    fd.append("f1", fileList[i]); //支持多文件上传
  }

  var xhr = new XMLHttpRequest(); //创建对象
  xhr.open("POST", "http://localhost:8100/", true);

  xhr.send(fd); //发送时  Content-Type默认就是: multipart/form-data;
  xhr.onreadystatechange = function() {
    console.log("state change", xhr.readyState);
    if (this.readyState == 4 && this.status == 200) {
      var obj = JSON.parse(xhr.responseText); //返回值
      if (obj.fileUrl.length) {
        alert("上传成功");
      }
    }
  };
}

//绑定提交事件
document.getElementById("btn-submit").addEventListener("click", submitUpload);

监控上传进度

  1. 添加显示进度的标签 div.progress
  2. 编写处理进度的监听函数,xhr.onprogress 还有 xhr.upload.onprogress
  3. 通过监控 event.lengthComputable 是否变化,event.loaded 表示发送了多少字节,event.total 表示文件总大小计算出进度,然后来控制样式。
xhr.onprogress = updateProgress;
xhr.upload.onprogress = updateProgress;
function updateProgress(event) {
  console.log(event);
  if (event.lengthComputable) {
    var completedPercent = ((event.loaded / event.total) * 100).toFixed(2);
    progressSpan.style.width = completedPercent + "%";
    progressSpan.innerHTML = completedPercent + "%";
    if (completedPercent > 90) {
      //进度条变色
      progressSpan.classList.add("green");
    }
    console.log("已上传", completedPercent);
  }
}

PS: xhr.upload.onprogress 要写在 xhr.send 方法前面

# 拖拽上传

  1. 首先定义一个允许拖放文件的区域
  2. drop 事件一定要阻止事件的默认行为 e.preventDefault() ,不然浏览器会直接打开文件
  3. 为拖拽区域绑定事件,鼠标在拖拽区域上 dragover,鼠标离开拖拽区域 dragleave ,在拖拽区域上释放文件 drop
  4. drop 事件内获取文件信息 e.dataTransfer.files
  5. 组装 formData,xhr 发送 ajax
<div class="drop-box" id="drop-box">
  拖动文件到这里,开始上传
</div>

<button type="button" id="btn-submit">上 传</button>
<script>
  var box = document.getElementById("drop-box");

  //禁用浏览器的拖放默认行为
  document.addEventListener("drop", function(e) {
    console.log("document drog");
    e.preventDefault();
  });

  //设置拖拽事件
  function openDropEvent() {
    box.addEventListener("dragover", function(e) {
      console.log("elemenet dragover");
      box.classList.add("over");
      e.preventDefault();
    });
    box.addEventListener("dragleave", function(e) {
      console.log("elemenet dragleave");
      box.classList.remove("over");
      e.preventDefault();
    });

    box.addEventListener(
      "drop",
      function(e) {
        e.preventDefault(); //取消浏览器默认拖拽效果

        var fileList = e.dataTransfer.files; //获取拖拽中的文件对象
        var len = fileList.length; //用来获取文件的长度(其实是获得文件数量)

        //检测是否是拖拽文件到页面的操作
        if (!len) {
          box.classList.remove("over");
          return;
        }

        box.classList.add("over");

        window.willUploadFileList = fileList;
      },
      false
    );
  }

  openDropEvent();

  function submitUpload() {
    var fileList = window.willUploadFileList || [];
    if (!fileList.length) {
      alert("请选择文件");
      return;
    }

    var fd = new FormData(); //构造FormData对象

    for (var i = 0; i < fileList.length; i++) {
      fd.append("f1", fileList[i]); //支持多文件上传
    }

    var xhr = new XMLHttpRequest(); //创建对象
    xhr.open("POST", "http://localhost:8100/", true);
    xhr.onreadystatechange = function() {
      if (xhr.readyState == 4) {
        var obj = JSON.parse(xhr.responseText); //返回值
        if (obj.fileUrl.length) {
          alert("上传成功");
        }
      }
    };
    xhr.send(fd); //发送
  }
  //绑定提交事件
  document.getElementById("btn-submit").addEventListener("click", submitUpload);
</script>

# 大文件分片上传

file 继承了 blob ,表示原始数据和二进制数据,提供 slice 方法进行截取

大概是一个这样的过程

  1. 把大文件进行分段 比如 2M 一片,发送到服务器携带一个标志,可以暂时用当前的时间戳,用于标识一个完整的文件
  2. 服务端保存各段文件
  3. 浏览器端所有分片上传完成,发送给服务端一个合并文件的请求
  4. 服务端根据文件标识、类型、各分片顺序进行文件合并
  5. 删除分片文件

分片如何做到,其实就像是操作字符串一样

var start = 0,
  end = 0;
while (true) {
  end += chunkSize;
  var blob = file.slice(start, end);
  start += chunkSize;

  if (!blob.size) {
    //截取的数据为空 则结束
    //拆分结束
    break;
  }

  chunks.push(blob); //保存分段数据
}

前端过程,尽量会手写最好

function submitUpload() {
  var chunkSize = 2 * 1024 * 1024; //分片大小 2M
  var file = document.getElementById("f1").files[0];
  var chunks = [], //保存分片数据
    token = +new Date(), //时间戳
    name = file.name,
    chunkCount = 0,
    sendChunkCount = 0;

  //拆分文件 像操作字符串一样
  if (file.size > chunkSize) {
    //拆分文件
    var start = 0,
      end = 0;
    while (true) {
      end += chunkSize;
      var blob = file.slice(start, end);
      start += chunkSize;

      if (!blob.size) {
        //截取的数据为空 则结束
        //拆分结束
        break;
      }

      chunks.push(blob); //保存分段数据
    }
  } else {
    chunks.push(file.slice(0));
  }

  chunkCount = chunks.length; //分片的个数

  //没有做并发限制,较大文件导致并发过多,tcp 链接被占光 ,需要做下并发控制,比如只有4个在请求在发送

  for (var i = 0; i < chunkCount; i++) {
    var fd = new FormData(); //构造FormData对象
    fd.append("token", token);
    fd.append("f1", chunks[i]);
    fd.append("index", i);
    xhrSend(fd, function() {
      sendChunkCount += 1;
      if (sendChunkCount === chunkCount) {
        //上传完成,发送合并请求
        console.log("上传完成,发送合并请求");
        var formD = new FormData();
        formD.append("type", "merge");
        formD.append("token", token);
        formD.append("chunkCount", chunkCount);
        formD.append("filename", name);
        xhrSend(formD);
      }
    });
  }
}

function xhrSend(fd, cb) {
  var xhr = new XMLHttpRequest(); //创建对象
  xhr.open("POST", "http://localhost:8100/", true);
  xhr.onreadystatechange = function() {
    console.log("state change", xhr.readyState);
    if (xhr.readyState == 4) {
      console.log(xhr.responseText);
      cb && cb();
    }
  };
  xhr.send(fd); //发送
}

//绑定提交事件
document.getElementById("btn-submit").addEventListener("click", submitUpload);

# 大文件断点上传

大致过程:

在分片上传前提下

  1. 为每个分段生成 hash 值,使用 spark-md5 库为每个文件分片打一个哈希
  2. 将上传成功的分段信息保存到本地,(为了简单,其实要用 hash,比如我这里记录文件索引的形式记录已上传还是未上传)
  3. 重新上传时,都会拿当前上传的分段和本地分段 hash 值的对比,如果相同的话则跳过,继续下一个分段的上传
//获得本地缓存的数据
function getUploadedFromStorage() {
  return JSON.parse(localStorage.getItem(saveChunkKey) || "{}");
}

//写入缓存
function setUploadedToStorage(index) {
  var obj = getUploadedFromStorage();
  obj[index] = true;
  localStorage.setItem(saveChunkKey, JSON.stringify(obj));
}

//分段对比

var uploadedInfo = getUploadedFromStorage(); //获得已上传的分段信息

for (var i = 0; i < chunkCount; i++) {
  console.log("index", i, uploadedInfo[i] ? "已上传过" : "未上传");

  if (uploadedInfo[i]) {
    //对比分段
    sendChunkCount = i + 1; //记录已上传的索引
    continue; //如果已上传则跳过
  }
  var fd = new FormData(); //构造FormData对象
  fd.append("token", token);
  fd.append("f1", chunks[i]);
  fd.append("index", i);

  (function(index) {
    xhrSend(fd, function() {
      sendChunkCount += 1;
      //将成功信息保存到本地
      setUploadedToStorage(index);
      if (sendChunkCount === chunkCount) {
        console.log("上传完成,发送合并请求");
        var formD = new FormData();
        formD.append("type", "merge");
        formD.append("token", token);
        formD.append("chunkCount", chunkCount);
        formD.append("filename", name);
        xhrSend(formD);
      }
    });
  })(i);
}

# url小知识

// http://localhost:8080/users?userName=张三
// 转译中文search
encodeURIComponent('张三') = '转移码'
// 反转译
decodeURIComponent('转移码') = '张三'
// 访问url客户端自动下载
  Headers: {
            'Content-Disposition': `attachment;filename=${encodeURI(file.name)}`
          },

# 对接cos云服务

export let resolveData: { time: number; list: UploadResolveType[] } = reactive({
  time: 0,
  list: []
})

let Bucket = ''
let Region = ''
let fileKey = ''
let filemd5 = ''
let fileType = ''
let realFileKey = ''

// fileReader 同步
function uploadFile(file: File) {
  return new Promise((resolve) => {
    const reader = new FileReader()
    reader.readAsBinaryString(file)
    reader.onload = (e: ProgressEvent<FileReader>) => {
      resolve(e.target!.result)
    }
  }).catch((e) => {
    console.error(e)
  })
}

const getCosInstance = async (
  file: File,
  filedata: UploadResolveType,
  source: number
) => {
  const spark = new SparkMD5()
  const result: any = await uploadFile(file)!

  spark.appendBinary(result)
  filemd5 = spark.end()
  fileType = getExtendName(file.name)
  const res = await $http.getUploadAuthorization({
    fileNames: JSON.stringify([filemd5 + '_' + file.size + '.' + fileType]),
    source
  })

  if (res.data) {
    const credentials: any = res.data?.fileList[0]
    fileKey =
      (source === SOURCE_TYPE.RESOURCE ? COS_SOURCE_PATH : EBOOK_SOURCE_PATH) +
      '/' +
      credentials.newFileName
    Bucket = credentials.bucket
    Region = credentials.region
    realFileKey = credentials.objectKey
    filedata.oid = credentials.oid
    filedata.fileKey = fileKey
    return new COS({
      getAuthorization: (options, callback) => {
        const action = options.Scope[0].action.replace('name/cos:', '')
        const sign = credentials.authMap[action]
        callback(sign)
      }
    })
  } else {
    Message.error('credentials invalid')
    throw new Error('credentials invalid')
  }
}

export async function upload(
  file: File,
  uid: number,
  source: number = SOURCE_TYPE.RESOURCE
): Promise<any> {
  const filedata: UploadResolveType = {
    file,
    uid: uid,
    data: {},
    options: {},
    precent: 0,
    taskId: '',
    isFinished: false,
    md5Name: '',
    oid: '',
    fileKey: ''
  }
  const cos = await getCosInstance(file, filedata, source)

  return new Promise((resolve) => {
    resolveData.time = Math.random() * Date.now()
    if (cos) {
      cos.uploadFile(
        {
          Bucket,
          Region,
          Key: realFileKey,
          Body: file,
          SliceSize: 5 * SIZE_COMPANY,
          Headers: {
            'Content-Disposition': `attachment;filename=${encodeURI(file.name)}`
          },
          onTaskReady: function (taskId) {
            /* 非必须 */
            filedata.taskId = taskId
          },
          onProgress: function (progressData) {
            /* 非必须 */
            if (progressData.percent !== filedata.precent) {
              const currentIndex = resolveData.list.findIndex(
                (item) => item.uid === filedata.uid
              )
              resolveData.list[currentIndex].precent = progressData.percent
              resolveData.list[currentIndex].isFinished =
                Number(filedata.precent) === 1
            }
          }
        },
        (err, data) => {
          console.log(err)
          filedata.data = data
          filedata.md5Name = filemd5 + '_' + file.size + '.' + fileType
        }
      )
      resolveData.list.push(filedata)
      resolve(resolveData)
    }
  })
}

export function resumeResolveData() {
  resolveData = reactive({
    time: 0,
    list: []
  })
}

Last update: 3/6/2022, 9:03:36 AM