当前位置: 代码网 > it编程>编程语言>Javascript > 使用纯原生JS实现大文件分片上传

使用纯原生JS实现大文件分片上传

2024年07月05日 Javascript 我要评论
写在前面前段时间在工作中接触到了文件上传的内容,但业务中实现的功能比较简单,于是我想着能不能使用纯原生的方式实现一个大文件的上传demo,从而在本质上学习大文件上传的思路。本教程使用纯原生的html+

写在前面

前段时间在工作中接触到了文件上传的内容,但业务中实现的功能比较简单,于是我想着能不能使用纯原生的方式实现一个大文件的上传demo,从而在本质上学习大文件上传的思路。本教程使用纯原生的html+node.js实现,能快速上手一个简单的大文件上传,深入理解其内部的原理,也能方便在后续的工作中对demo进行快速扩展,非常适合想入门学习大文件上传的同学。

效果展示

首先来看看最后的效果。

实现思路

上图是大文件上传的整体流程图,显示了客户端和服务端的交互逻辑,方便大家从宏观上理解大文件上传的过程,但如果按照上面的流程讲解大文件上传入门,很容易被劝退。

下面我们将按照功能点逐步迭代的方式讲解大文件上传,每个功能点都很简单,每实现一个功能点都会极大的增涨我们的信心。大文件上传一共分为分片上传、分片合并、文件秒传、断点续传、上传进度这五个功能点,后面的功能都是在前面的功能基础上迭代完成。如果能实现一个分片上传功能就算是入门了大文件上传了,后面都是在此基础上增加功能而已。

具体实现

分片上传

首先我们来实现一个最简单也最核心的分片上传,这个功能点分为客户端的文件分片、计算hash值、上传分片文件和服务端的创建分片目录并存储分片。客户端和服务端源代码分别存放在bigfileupload.htmlserver.js文件中。

客户端

为了方便后面能够处理取消上传和上传进度,我们首先对fetch 请求做一个简单的封装。

/**
 * @description: 封装fetch
 * @param {object} fetchconfig fetch config
 * @return {promise} fetch result
 */
const requestapi = ({
  url,
  method = "get",
  ...fetchprops
}) => {
  return new promise(async (resolve, reject) => {
    const res = await fetch(url, {
      method,
      ...fetchprops,
    });
    resolve(res.json());
  });
};

下面是分片功能需要的标签元素。

<input type="file" name="file" id="file" multiple />
<button id="upload" onclick="handleupload()">上传</button>
<p id="hash-progress"></p>
<p id="total-slice"></p>

首先,我们需要使用slice() 方法对大文件进行分片,并把分片的内容、大小等信息都放入到分片列表中,最后在页面上显示一下分片数量。

// 文件分片
const createfilechunk = (file) => {
	const chunklist = [];
	//计算文件切片总数
	const slicesize = 5 * 1024 * 1024; // 每个文件切片大小定为5mb
	const totalslice = math.ceil(filesize / slicesize);
	for (let i = 1; i <= totalslice; i++) {
	  let chunk;
	  if (i == totalslice) {
		// 最后一片
		chunk = file.slice((i - 1) * slicesize, filesize - 1); //切割文件
	  } else {
		chunk = file.slice((i - 1) * slicesize, i * slicesize);
	  }
	  chunklist.push({
		file: chunk,
		filesize,
		size: math.min(slicesize, file.size),
	  });
	}
	const slicetext = `一共分片:${totalslice}`;
	document.getelementbyid("total-slice").innerhtml = slicetext;
	console.log(slicetext);
	return chunklist;
};

然后, 使用spark-md5 分别计算每个分片的hash值,最后得到整个文件hash值。计算hash值需要比较长的时间,可以在页面上输出计算hash值的进度。

// 根据分片生成hash
const calculatehash = (filechunklist) => {
  return new promise((resolve) => {
    const spark = new sparkmd5.arraybuffer();
    let count = 0;
    // 计算出hash
    const loadnext = (index) => {
      const reader = new filereader(); // 文件阅读对象
      reader.readasarraybuffer(filechunklist[index].file);
      reader.onload = (e) => {
        count++;
        spark.append(e.target.result);
        if (count === filechunklist.length) {
          resolve(spark.end());
        } else {
          // 还没读完
          const percentage = parseint(
            ((count + 1) / filechunklist.length) * 100
          );
          const progresstext = `计算hash值:${percentage}%`;
          document.getelementbyid("hash-progress").innerhtml =
            progresstext;
          console.log(progresstext);
          loadnext(count);
        }
      };
    };
    loadnext(0);
  });
};

紧接着,需要将分片数据全部上传到服务器,这里需要注意是的分片的hash值是 ${filehash}-${index}, 服务端会根据这个hash值创建分片文件。

let filename = "",
  filehash = "",
  filesize = 0,
  filechunklistdata = [];
const host = "http://localhost:3000";

// ...

const handleupload = async () => {
  const file = document.getelementbyid("file").files[0];
  if (!file) return alert("请选择文件!");
  filename = file.name; // 文件名
  filesize = file.size; // 文件大小
  const filechunklist = createfilechunk(file);
  filehash = await calculatehash(filechunklist); // 文件hash
  filechunklistdata = filechunklist.map(({ file, size }, index) => {
    const hash = `${filehash}-${index}`;
    return {
      file,
      size,
      filename,
      filehash,
      hash,
    };
  });
  await uploadchunks();
};

//上传分片
const uploadchunks = async () => {
  const requestlist = filechunklistdata
    .map(({ file, filehash, filename, hash }, index) => {
      const formdata = new formdata();
      formdata.append("file", file);
      formdata.append("filehash", filehash);
      formdata.append("name", filename);
      formdata.append("hash", hash);
      return { formdata };
    })
    .map(async ({ formdata }) => {
      return requestapi({
        url: `${host}`,
        method: "post",
        body: formdata,
      });
    });
  await promise.all(requestlist);
};

服务端

首先,我们使用原生node.js启动一个后端服务。

import * as http from "http"; //es 6
import path from "path";

const server = http.createserver();

server.on("request", async (req, res) => {
  res.setheader("access-control-allow-origin", "*");
  res.setheader("access-control-allow-headers", "*");
  if (req.method === "options") {
    res.status = 200;
    res.end();
    return;
  }
});

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

接下来,我们就可以在里面添加上传分片的接口。使用multiparty读取到客户端提交的表单数据后,判断切片目录是否存在,不存在就使用 filehash 值创建一个临时的分片目录,并使用fs-extra 的move 方法存储文件分片到对应的分片目录下。

import * as http from "http"; //es 6
import path from "path";
import fse from "fs-extra";
import multiparty from "multiparty";

const server = http.createserver();
const upload_dir = path.resolve("/users/sxg/downloads/", "target"); // 大文件存储目录

server.on("request", async (req, res) => {
  res.setheader("access-control-allow-origin", "*");
  res.setheader("access-control-allow-headers", "*");
  if (req.method === "options") {
    res.status = 200;
    res.end();
    return;
  }

  if (req.url === "/") {
    const multipart = new multiparty.form();

    multipart.parse(req, async (err, fields, files) => {
      if (err) {
        console.error(err);
        res.status = 500;
        res.end(
          json.stringify({
            messaage: "process file chunk failed",
          })
        );
        return;
      }

      const [chunk] = files.file;
      const [hash] = fields.hash;
      const [filename] = fields.name;
      const [filehash] = fields.filehash;
      const chunkdir = `${upload_dir}/${filehash}`;

      const filepath = path.resolve(
        upload_dir,
        `${filehash}${extractext(filename)}`
      );
      // 文件存在直接返回
      if (fse.existssync(filepath)) {
        res.end(
          json.stringify({
            messaage: "file exist",
          })
        );
        return;
      }

      // 切片目录不存在,创建切片目录
      if (!fse.existssync(chunkdir)) {
        await fse.mkdirs(chunkdir);
      }

      // fs-extra 专用方法,类似 fs.rename 并且跨平台
      // fs-extra 的 rename 方法 windows 平台会有权限问题
      // https://github.com/meteor/meteor/issues/7852#issuecomment-255767835
      await fse.move(chunk.path, `${chunkdir}/${hash}`);
      res.status = 200;
      res.end(
        json.stringify({
          messaage: "received file chunk",
        })
      );
    });
  }
});

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

到这里为止,我们就已经实现了文件上传最基本的功能,后续只是在此基础上进行迭代。

合并分片

客户端

在上传完文件分片之后,我们就可以对所有文件分片进行合并,这里需要请求一个合并分片的接口,需要传递文件的filehash 和 filename 。

//上传分片
const uploadchunks = async () => {
  //...
  await mergerequest(filename, filehash);
};

// 合并分片
const mergerequest = async (filename, filehash) => {
  await requestapi({
    url: `${host}/merge`,
    method: "post",
    headers: {
      "content-type": "application/json;charset=utf-8",
    },
    body: json.stringify({
      filename: filename,
      filehash,
    }),
  });
};

服务端

合并切片功能最核心的功能就是根据filehash读取对应分片目录下的分片文件列表,并按照分片下标进行排序,避免后面合并时顺序错乱。然后,使用 writefile 方法创建一个空文件,再使用appendfilesync 依次向文件中添加分片数据,最后删除临时的分片目录。

// 合并切片
const mergefilechunk = async (filepath, filehash) => {
  const chunkdir = `${upload_dir}/${filehash}`;
  const chunkpaths = await fse.readdir(chunkdir);
  // 根据切片下标进行排序,否则直接读取目录的获得的顺序可能会错乱
  chunkpaths.sort((a, b) => a.split("-")[1] - b.split("-")[1]);
  await fse.writefile(filepath, "");
  chunkpaths.foreach((chunkpath) => {
    fse.appendfilesync(filepath, fse.readfilesync(`${chunkdir}/${chunkpath}`));
    fse.unlinksync(`${chunkdir}/${chunkpath}`);
  });
  fse.rmdirsync(chunkdir); // 合并后删除保存切片的目录
};

这里实现一下合并分片的接口,首先需要读取请求中的数据,然后拼接出合并后的文件名称 ${upload_dir}/${filehash}${ext},最后调用合并分片方法。

import * as http from "http"; //es 6
import path from "path";
import fse from "fs-extra";
import multiparty from "multiparty";
const server = http.createserver();
const extractext = (filename) =>
  filename.slice(filename.lastindexof("."), filename.length); // 提取后缀名
  
//...

const resolvepost = (req) =>
  new promise((resolve) => {
    let chunk = "";
    req.on("data", (data) => {
      chunk += data;
    });
    req.on("end", () => {
      resolve(json.parse(chunk));
    });
  });

server.on("request", async (req, res) => {
  //...

  if (req.url === "/merge") {
    const data = await resolvepost(req);
    const { filename, filehash } = data;
    const ext = extractext(filename);
    const filepath = `${upload_dir}/${filehash}${ext}`;
    await mergefilechunk(filepath, filehash);
    res.status = 200;
    res.end(json.stringify("file merged success"));
  }
});

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

秒传

客户端

实现秒传只需要在文件上传之前请求接口验证一下文件是否存在。

const handleupload = async () => {
  //...
  const { shouldupload } = await verifyupload(
    filename,
    filehash
  );
  if (!shouldupload) {
    alert("秒传:上传成功");
    return;
  }
  //...
};

//文件秒传
const verifyupload = async (filename, filehash) => {
  const data = await requestapi({
    url: `${host}/verify`,
    method: "post",
    headers: {
      "content-type": "application/json;charset=utf-8",
    },
    body: json.stringify({
      filename,
      filehash,
    }),
  });
  return data;
};

服务端

如果文件存在shouldupload 就返回 false,否则就返回 true 。

import * as http from "http"; //es 6
import path from "path";
import fse from "fs-extra";
import multiparty from "multiparty";
const server = http.createserver();

//...

server.on("request", async (req, res) => {
  //...

  if (req.url === "/verify") {
    const data = await resolvepost(req);
    const { filehash, filename } = data;
    const ext = extractext(filename);
    const filepath = `${upload_dir}/${filehash}${ext}`;
    if (fse.existssync(filepath)) {
      res.end(
        json.stringify({
          shouldupload: false,
        })
      );
    } else {
      res.end(
        json.stringify({
          shouldupload: true,
        })
      );
    }
  }
});

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

断点续传

客户端

断点续传新增了两个按钮,来控制文件上传进度。

/* ... */
<button id="pause" onclick="handlepause()" style="display: none">
  暂停
</button>
<button id="resume" onclick="handleresume()" style="display: none">
  恢复
</button>
/* ... */

这里需要对requestapi 进行一些改造,添加 abortcontrollerlist 用于存储需要被取消的请求,如果接口请求成功,则将fetch从 abortcontrollerlist 中移除。

/**
 * @description: 封装fetch
 * @param {object} fetchconfig fetch config
 * @return {promise} fetch result
 */
const requestapi = ({
  url,
  method = "get",
  onprogress,
  ...fetchprops
}) => {
  const controller = new abortcontroller();
  abortcontrollerlist.push(controller);
  return new promise(async (resolve, reject) => {
    const res = await fetch(url, {
      method,
      ...fetchprops,
      signal: controller.signal,
    });

    // 将请求成功的 fetch 从列表中删除
    const acindex = abortcontrollerlist.findindex(
      (c) => c.signal === controller.signal
    );
    abortcontrollerlist.splice(acindex, 1);
    //...
  });
};

在分片上传也需要做一些改造,将接口中获取到的uploadedlist ,从所有分片列表中过滤出去,当已上传的uploadedlist 数量加 requestlist 的数量等于分片列表filechunklistdata 的数量时才进行分片合并。

let filename = "",
  filehash = "",
  filesize = 0,
  filechunklistdata = [],
  abortcontrollerlist = [];
const host = "http://localhost:3000";

//...

const handleupload = async () => {
  //...
  const { shouldupload, uploadedlist } = await verifyupload(
    filename,
    filehash
  );
  if (!shouldupload) {
    alert("秒传:上传成功");
    return;
  }
  //...
  await uploadchunks(uploadedlist);
};

//上传分片
const uploadchunks = async (uploadedlist) => {
  const requestlist = filechunklistdata
    .filter(({ hash }) => !uploadedlist.includes(hash))
    .map(({ file, filehash, filename, hash }, index) =>     {
     //...
    })
    .map(async ({ formdata, hash }) => {
  .   //...
    });
  //...
  // 之前上传的切片数量 + 本次上传的切片数量 = 所有切片数量时
  //合并分片
  if (
    uploadedlist.length + requestlist.length ===
    filechunklistdata.length
  ) {
    await mergerequest(filename, filehash);
  }
};

然后,实现一下暂停和恢复的事件处理,暂停是通过调用 abortcontroller 的 abort() 方法实现。恢复则是重新获取uploadedlist 后再进行分片上传实现。

//暂停
const handlepause = () => {
  abortcontrollerlist.foreach((controller) => controller?.abort());
  abortcontrollerlist = [];
};
// 恢复
const handleresume = async () => {
  const { uploadedlist } = await verifyupload(filename, filehash);
  await uploadchunks(uploadedlist);
};

服务端

断点续传是在秒传接口的基础上实现的,只是需要新增已上传分片列表uploadedlist 。

import * as http from "http"; //es 6
import path from "path";
import fse from "fs-extra";
import multiparty from "multiparty";
const server = http.createserver();

//...

// 返回已经上传切片名列表
const createuploadedlist = async (filehash) =>
  fse.existssync(`${upload_dir}/${filehash}`)
    ? await fse.readdir(`${upload_dir}/${filehash}`)
    : [];

server.on("request", async (req, res) => { 
  //...
  
  if (req.url === "/verify") {
    const data = await resolvepost(req);
    const { filehash, filename } = data;
    const ext = extractext(filename);
    const filepath = `${upload_dir}/${filehash}${ext}`;
    if (fse.existssync(filepath)) {
      res.end(
        json.stringify({
          shouldupload: false,
        })
      );
    } else {
      res.end(
        json.stringify({
          shouldupload: true,
          uploadedlist: await createuploadedlist(filehash),
        })
      );
    }
  }
});

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

上传进度

上传进度只需要改造客户端,首先,新增显示进度的标签。

<p id="progress"></p>

上传进度需要对fetch请求再做一点改造,这里需要使用getreader() 手动读取数据流,获取到当前上传进度,并添加onprogress 回调。

/**
 * @description: 封装fetch
 * @param {object} fetchconfig fetch config
 * @return {promise} fetch result
 */
const requestapi = ({
  url,
  method = "get",
  onprogress,
  ...fetchprops
}) => {
  //...
  return new promise(async (resolve, reject) => {
    const res = await fetch(url, {
      method,
      ...fetchprops,
    });
    const total = res.headers.get("content-length");
    const reader = res.body.getreader(); //创建可读流
    const decoder = new textdecoder();
    let loaded = 0;
    let data = "";
    while (true) {
      const { done, value } = await reader.read();
      loaded += value?.length || 0;
      data += decoder.decode(value);
      onprogress && onprogress({ loaded, total });
      if (done) {
        break;
      }
    }
    //...
    resolve(json.parse(data));
  });
};

然后,在上传的时候将已上传进度设置成100,并添加onprogress回调处理,累计每个分片的进度,得到整体的上传进度。

let filename = "",
  filehash = "",
  filesize = 0,
  filechunklistdata = [],
  abortcontrollerlist = [];
const host = "http://localhost:3000";

//...

const handleupload = async () => {
  //...
  filechunklistdata = filechunklist.map(({ file, size }, index) => {
    //...
    return {
      percentage: uploadedlist.includes(hash) ? 100 : 0,
    };
  });
  //...
};

//上传分片
const uploadchunks = async (uploadedlist) => {
  const requestlist = filechunklistdata
    .filter(({ hash }) => !uploadedlist.includes(hash))
    .map(({ file, filehash, filename, hash }, index) => {
    //...
    })
    .map(async ({ formdata, hash }) => {
      return requestapi({
        url: `${host}`,
        method: "post",
        body: formdata,
        onprogress: ({ loaded, total }) => {
          const percentage = parseint((loaded / total) * 100);
          // console.log("分片上传百分比:", percentage);
          const curindex = filechunklistdata.findindex(
            ({ hash: h }) => h === hash
          );
          filechunklistdata[curindex].percentage = percentage;
          const totalloaded = filechunklistdata
            .map((item) => item.size * item.percentage)
            .reduce((acc, cur) => acc + cur);
          const totalpercentage = parseint(
            (totalloaded / filesize).tofixed(2)
          );
          const progresstext = `上传进度:${totalpercentage}%`;
          document.getelementbyid("progress").innerhtml = progresstext;
          console.log(progresstext);
        },
      });
    });
   //...
};

总结

大文件上传其实很多时候不需要我们自己去实现,因为已经有很多成熟的解决方案。

但深入理解大文件上传背后的原理,更加有利于我们对已有的大文件上传方案进行个性化改造。

在线实现大文件上传的过程中使用到了三个插件,multipartyfs-extraspark-md5,如果大家不太理解,需要自己去补充一下相关知识。

以上就是使用纯原生js实现大文件分片上传的详细内容,更多关于js大文件分片上传的资料请关注代码网其它相关文章!

(0)

相关文章:

版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。

发表评论

验证码:
Copyright © 2017-2025  代码网 保留所有权利. 粤ICP备2024248653号
站长QQ:2386932994 | 联系邮箱:2386932994@qq.com