欧美bbbwbbbw肥妇,免费乱码人妻系列日韩,一级黄片

使用純?cè)鶭S實(shí)現(xiàn)大文件分片上傳

 更新時(shí)間:2024年06月18日 09:22:02   作者:宋玉的世界  
前段時(shí)間在工作中接觸到了文件上傳的內(nèi)容,但業(yè)務(wù)中實(shí)現(xiàn)的功能比較簡(jiǎn)單,于是我想著能不能使用純?cè)姆绞綄?shí)現(xiàn)一個(gè)大文件的上傳DEMO,從而在本質(zhì)上學(xué)習(xí)大文件上傳的思路,本教程使用純?cè)膆tml+node.js實(shí)現(xiàn),需要的朋友可以參考下

寫在前面

前段時(shí)間在工作中接觸到了文件上傳的內(nèi)容,但業(yè)務(wù)中實(shí)現(xiàn)的功能比較簡(jiǎn)單,于是我想著能不能使用純?cè)姆绞綄?shí)現(xiàn)一個(gè)大文件的上傳DEMO,從而在本質(zhì)上學(xué)習(xí)大文件上傳的思路。本教程使用純?cè)膆tml+node.js實(shí)現(xiàn),能快速上手一個(gè)簡(jiǎn)單的大文件上傳,深入理解其內(nèi)部的原理,也能方便在后續(xù)的工作中對(duì)DEMO進(jìn)行快速擴(kuò)展,非常適合想入門學(xué)習(xí)大文件上傳的同學(xué)。

效果展示

首先來看看最后的效果。

實(shí)現(xiàn)思路

上圖是大文件上傳的整體流程圖,顯示了客戶端和服務(wù)端的交互邏輯,方便大家從宏觀上理解大文件上傳的過程,但如果按照上面的流程講解大文件上傳入門,很容易被勸退。

下面我們將按照功能點(diǎn)逐步迭代的方式講解大文件上傳,每個(gè)功能點(diǎn)都很簡(jiǎn)單,每實(shí)現(xiàn)一個(gè)功能點(diǎn)都會(huì)極大的增漲我們的信心。大文件上傳一共分為分片上傳、分片合并、文件秒傳、斷點(diǎn)續(xù)傳、上傳進(jìn)度這五個(gè)功能點(diǎn),后面的功能都是在前面的功能基礎(chǔ)上迭代完成。如果能實(shí)現(xiàn)一個(gè)分片上傳功能就算是入門了大文件上傳了,后面都是在此基礎(chǔ)上增加功能而已。

具體實(shí)現(xiàn)

分片上傳

首先我們來實(shí)現(xiàn)一個(gè)最簡(jiǎn)單也最核心的分片上傳,這個(gè)功能點(diǎn)分為客戶端的文件分片、計(jì)算hash值、上傳分片文件和服務(wù)端的創(chuàng)建分片目錄并存儲(chǔ)分片。客戶端和服務(wù)端源代碼分別存放在BigFileUpload.htmlserver.js文件中。

客戶端

為了方便后面能夠處理取消上傳和上傳進(jìn)度,我們首先對(duì)fetch 請(qǐng)求做一個(gè)簡(jiǎn)單的封裝。

/**
 * @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());
  });
};

下面是分片功能需要的標(biāo)簽元素。

<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() 方法對(duì)大文件進(jìn)行分片,并把分片的內(nèi)容、大小等信息都放入到分片列表中,最后在頁面上顯示一下分片數(shù)量。

// 文件分片
const createFileChunk = (file) => {
	const chunkList = [];
	//計(jì)算文件切片總數(shù)
	const sliceSize = 5 * 1024 * 1024; // 每個(gè)文件切片大小定為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 分別計(jì)算每個(gè)分片的hash值,最后得到整個(gè)文件hash值。計(jì)算hash值需要比較長的時(shí)間,可以在頁面上輸出計(jì)算hash值的進(jìn)度。

// 根據(jù)分片生成hash
const calculateHash = (fileChunkList) => {
  return new Promise((resolve) => {
    const spark = new SparkMD5.ArrayBuffer();
    let count = 0;
    // 計(jì)算出hash
    const loadNext = (index) => {
      const reader = new FileReader(); // 文件閱讀對(duì)象
      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 = `計(jì)算hash值:${percentage}%`;
          document.getElementById("hash-progress").innerHTML =
            progressText;
          console.log(progressText);
          loadNext(count);
        }
      };
    };
    loadNext(0);
  });
};

緊接著,需要將分片數(shù)據(jù)全部上傳到服務(wù)器,這里需要注意是的分片的hash值是 ${fileHash}-${index}, 服務(wù)端會(huì)根據(jù)這個(gè)hash值創(chuàng)建分片文件。

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("請(qǐng)選擇文件!");
  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);
};

服務(wù)端

首先,我們使用原生node.js啟動(dòng)一個(gè)后端服務(wù)。

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("正在監(jiān)聽 3000 端口"));

接下來,我們就可以在里面添加上傳分片的接口。使用multiparty讀取到客戶端提交的表單數(shù)據(jù)后,判斷切片目錄是否存在,不存在就使用 fileHash 值創(chuàng)建一個(gè)臨時(shí)的分片目錄,并使用fs-extra 的move 方法存儲(chǔ)文件分片到對(duì)應(yīng)的分片目錄下。

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"); // 大文件存儲(chǔ)目錄

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;
      }

      // 切片目錄不存在,創(chuàng)建切片目錄
      if (!fse.existsSync(chunkDir)) {
        await fse.mkdirs(chunkDir);
      }

      // fs-extra 專用方法,類似 fs.rename 并且跨平臺(tái)
      // fs-extra 的 rename 方法 windows 平臺(tái)會(huì)有權(quán)限問題
      // 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("正在監(jiān)聽 3000 端口"));

到這里為止,我們就已經(jīng)實(shí)現(xiàn)了文件上傳最基本的功能,后續(xù)只是在此基礎(chǔ)上進(jìn)行迭代。

合并分片

客戶端

在上傳完文件分片之后,我們就可以對(duì)所有文件分片進(jìn)行合并,這里需要請(qǐng)求一個(gè)合并分片的接口,需要傳遞文件的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,
    }),
  });
};

服務(wù)端

合并切片功能最核心的功能就是根據(jù)fileHash讀取對(duì)應(yīng)分片目錄下的分片文件列表,并按照分片下標(biāo)進(jìn)行排序,避免后面合并時(shí)順序錯(cuò)亂。然后,使用 writeFile 方法創(chuàng)建一個(gè)空文件,再使用appendFileSync 依次向文件中添加分片數(shù)據(jù),最后刪除臨時(shí)的分片目錄。

// 合并切片
const mergeFileChunk = async (filePath, fileHash) => {
  const chunkDir = `${UPLOAD_DIR}/${fileHash}`;
  const chunkPaths = await fse.readdir(chunkDir);
  // 根據(jù)切片下標(biāo)進(jìn)行排序,否則直接讀取目錄的獲得的順序可能會(huì)錯(cuò)亂
  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); // 合并后刪除保存切片的目錄
};

這里實(shí)現(xiàn)一下合并分片的接口,首先需要讀取請(qǐng)求中的數(shù)據(jù),然后拼接出合并后的文件名稱 ${UPLOAD_DIR}/${fileHash}${ext},最后調(diào)用合并分片方法。

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("正在監(jiān)聽 3000 端口"));

秒傳

客戶端

實(shí)現(xiàn)秒傳只需要在文件上傳之前請(qǐng)求接口驗(yàn)證一下文件是否存在。

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;
};

服務(wù)端

如果文件存在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("正在監(jiān)聽 3000 端口"));

斷點(diǎn)續(xù)傳

客戶端

斷點(diǎn)續(xù)傳新增了兩個(gè)按鈕,來控制文件上傳進(jìn)度。

/* ... */
<button id="pause" onClick="handlePause()" style="display: none">
  暫停
</button>
<button id="resume" onClick="handleResume()" style="display: none">
  恢復(fù)
</button>
/* ... */

這里需要對(duì)requestApi 進(jìn)行一些改造,添加 abortControllerList 用于存儲(chǔ)需要被取消的請(qǐng)求,如果接口請(qǐng)求成功,則將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,
    });

    // 將請(qǐng)求成功的 fetch 從列表中刪除
    const aCIndex = abortControllerList.findIndex(
      (c) => c.signal === controller.signal
    );
    abortControllerList.splice(aCIndex, 1);
    //...
  });
};

在分片上傳也需要做一些改造,將接口中獲取到的uploadedList ,從所有分片列表中過濾出去,當(dāng)已上傳的uploadedList 數(shù)量加 requestList 的數(shù)量等于分片列表fileChunkListData 的數(shù)量時(shí)才進(jìn)行分片合并。

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 }) => {
  .   //...
    });
  //...
  // 之前上傳的切片數(shù)量 + 本次上傳的切片數(shù)量 = 所有切片數(shù)量時(shí)
  //合并分片
  if (
    uploadedList.length + requestList.length ===
    fileChunkListData.length
  ) {
    await mergeRequest(fileName, fileHash);
  }
};

然后,實(shí)現(xiàn)一下暫停和恢復(fù)的事件處理,暫停是通過調(diào)用 AbortController 的 abort() 方法實(shí)現(xiàn)?;謴?fù)則是重新獲取uploadedList 后再進(jìn)行分片上傳實(shí)現(xiàn)。

//暫停
const handlePause = () => {
  abortControllerList.forEach((controller) => controller?.abort());
  abortControllerList = [];
};
// 恢復(fù)
const handleResume = async () => {
  const { uploadedList } = await verifyUpload(fileName, fileHash);
  await uploadChunks(uploadedList);
};

服務(wù)端

斷點(diǎn)續(xù)傳是在秒傳接口的基礎(chǔ)上實(shí)現(xiàn)的,只是需要新增已上傳分片列表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();

//...

// 返回已經(jīng)上傳切片名列表
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("正在監(jiān)聽 3000 端口"));

上傳進(jìn)度

上傳進(jìn)度只需要改造客戶端,首先,新增顯示進(jìn)度的標(biāo)簽。

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

上傳進(jìn)度需要對(duì)fetch請(qǐng)求再做一點(diǎn)改造,這里需要使用getReader() 手動(dòng)讀取數(shù)據(jù)流,獲取到當(dāng)前上傳進(jìn)度,并添加onProgress 回調(diào)。

/**
 * @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(); //創(chuàng)建可讀流
    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));
  });
};

然后,在上傳的時(shí)候?qū)⒁焉蟼鬟M(jìn)度設(shè)置成100,并添加onProgress回調(diào)處理,累計(jì)每個(gè)分片的進(jìn)度,得到整體的上傳進(jìn)度。

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 = `上傳進(jìn)度:${totalPercentage}%`;
          document.getElementById("progress").innerHTML = progressText;
          console.log(progressText);
        },
      });
    });
   //...
};

總結(jié)

大文件上傳其實(shí)很多時(shí)候不需要我們自己去實(shí)現(xiàn),因?yàn)橐呀?jīng)有很多成熟的解決方案。

但深入理解大文件上傳背后的原理,更加有利于我們對(duì)已有的大文件上傳方案進(jìn)行個(gè)性化改造。

在線實(shí)現(xiàn)大文件上傳的過程中使用到了三個(gè)插件,multiparty、fs-extra、spark-md5,如果大家不太理解,需要自己去補(bǔ)充一下相關(guān)知識(shí)。

以上就是使用純?cè)鶭S實(shí)現(xiàn)大文件分片上傳的詳細(xì)內(nèi)容,更多關(guān)于JS大文件分片上傳的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!

相關(guān)文章

最新評(píng)論