前端计算文件摘要

背景

在进行大文件上传的时候,例如: 一个 1G的文件,可能会被客户反复上传, 这样会造成以下的问题

  • 客户在等待上传时间比较长
  • 带宽被消耗(流量)
  • 服务器资源被消耗(文件处理)

我们可以采用 文件分片的形式上传, 但是只解决了大文件上传中的等待时长问题, 我们还要判断文件是否已经上传过了,如果文件已经上传过了,就直接返回服务端的 url 即可

今天我们主要讨论的是,怎么知道 某个文件已经被用户上传了?

例如: 同一个视频文件,客户可能从 a 文件名改为 b文件名, 然后上传,服务器还需要从零开始处理这个文件的上传吗?

答: 不需要重复上传, 但是【服务器】怎么知道 a.mp4 文件 其实就是 b.mp4文件呢 ?

例如: 2个一样的视频文件, 一样的大小, 都叫 "真假美猴王.mp4" , 由不同的用户上传, 它们真的是一样的吗 ?

答: 可能不是同一个东西, 要怎么甄别?

由此, 我们引出一个词: 文件摘要

文件摘要

【文件摘要】 是指通过特定的算法对文件内容进行计算,生成一个固定长度的摘要值,用于表示文件的完整性。常见的文件摘要算法包括MD5和SHA-256等。

MD5算法是一种被广泛使用的密码散列函数,可以产生出一个128位(16字节)的散列值(哈希值),用于确保信息传输完整一致。对于任意长度的消息,MD5算法可以将其压缩成固定长度的消息摘要,通常用一个长度为32的十六进制字符串来表示。

SHA-256算法也是一种常见的密码散列函数,它可以产生出一个256位(32字节)的散列值。与MD5相比,SHA-256具有更高的安全性,但它的输出长度也更长。

当一个文件被修改时,其文件摘要值会发生相应的变化。因此,文件摘要可以用于检测文件的完整性,确保文件在传输或存储过程中没有被篡改。同时,文件摘要也可以用于验证文件的来源和身份,例如通过数字签名等技术实现。

从上面的文案上得出的结论: 文件摘要,可以验证文件身份, 由此解决上面的2个问题

代码实现

基础环境

json 复制代码
window 10
node: 20.10.0
json 复制代码
"vite": "^5.0.8"
"vue": "^3.3.11"
"@vitejs/plugin-vue": "^4.5.2"
"spark-md5": "^3.0.2"

截止 2024-02-05 ,以上环境均为最新包版本

使用 vite 创建一个空的 vue3项目做测试

本质与vue无任何关系, 所有实现均为js代码, 避免与框架捆绑, 可轻松移植到 react, ag, 原生环境等

spark-md5 实现

我们先使用 md5的方式实现

我们使用 spark-md5 github地址 这个库实现一下

css 复制代码
npm i spark-md5 -S

src/useSparkMd5.js

js 复制代码
import SparkMD5 from 'spark-md5'

/**
 * 使用 spark-md5 库对文件计算 md5 hash
 * @param {File} file
 */
export const useSparkMD5 = (file) => {
  return new Promise((resolve, reject) => {
    const spark = new SparkMD5()

    // 以增量的方式对文件做hash处理

    const chunkSize = 2 * 1024 * 1024

    const chunks = Math.ceil(file.size / chunkSize)

    const chunkList = []

    let currentChunk = 0

    let fileReader = new FileReader()

    fileReader.addEventListener('load', (e) => {
      // ArrayBuffer 读取到的是 这样一个数据类型
      /**
       * @type {ArrayBuffer}
       */
      let chunk = e.target.result

      spark.append(chunk)
      chunkList.push(chunk)

      currentChunk++

      // 继续读取下一块
      if (currentChunk < chunks) {
        loadNext()
      } else {
        console.log('文件全部读取完成')

        // 形成文件hash: 7ac2c2a9ff60a52d1552b347009dd51e

        // 注意 spark.end 不要调用多次,调用几次会计算几次hash,导致结果非期望, console也不要输出 console.log(spark.end())
        resolve({
          // 调用end方法,返回十六进制计算结果
          hash: spark.end(),
          size: file.size,
          originName: file.name,
          type: file.type,
          chunkList
        })
      }
    })

    fileReader.addEventListener('error', (e) => {
      console.log('文件读取失败')
      reject(e)
    })

    const loadNext = () => {
      let start = currentChunk * chunkSize

      let end = 0

      if (start + chunkSize >= file.size) {
        end = file.size
      } else {
        end = start + chunkSize
      }

      // 切片
      let chunk = file.slice(start, end)
      fileReader.readAsArrayBuffer(chunk)
    }

    loadNext()
  })
}

App.vue

html 复制代码
<template>
    <input type="file" id="test-file" />
</template>

<script setup>
import { onMounted } from 'vue'

import { useSparkMD5 } from './useSparkMD5'

const init = () => {
    let inputEl = document.querySelector('#test-file')

    inputEl.addEventListener('change', async () => {
        const file = inputEl.files[0]

        console.time('t2')
        let r = await useSparkMD5(file)
        // 文件大小:342M 耗时 t1: 1074.14111328125 ms
        console.timeEnd('t2')
        console.log(r)
    })
}

onMounted(() => {
    init()
})
</script>

web Worker

现在我们把切片这块比较耗时的任务放下 web worker 里面做

具体是否使用 worker , 要取决于文件是否比较大(场景是否必须), 比如100M的文件,跟10G的文件, 那可能10G的文件有必要放在 worker 中, 因为新开 worker, 也是有资源损耗的, 通讯也有成本。

src/sliceFileWorker.js

js 复制代码
import { useSparkMD5 } from './useSparkMd5.js'

self.onmessage = async (e) => {
  let data = e.data

  const file = data.file

  let result = await useSparkMD5(file)

  // 把计算内容返回给主线程

  self.postMessage(result)
}

src/App.vue

js 复制代码
const init = () => {
    let inputEl = document.querySelector('#test-file')

    inputEl.addEventListener('change', async () => {
        const file = inputEl.files[0]
        // 使用 Web Worker
        useWorker(file)
    })
}

/**
 * 使用worker 实现计算,切片
 * @param {File} file
 */
const useWorker = (file) => {
    console.time('t2')
    let url = new URL('./sliceFileWorker.js', import.meta.url)
    console.log('worker 文件的地址')
    console.log(url)
    
    const worker = new Worker(url, {
        type: 'module',
    })

    worker.postMessage({
        file,
    })

    worker.onmessage = async (e) => {
        console.log(e.data)
        // t2: 2107.10107421875 ms
        console.timeEnd('t2')
    }
}

这里完全复用上面 spark-md5 实现中的 useSparkMD5 方法, 把 init方法 里面的内容改造一下, 调用 useWorker 方法即可

SHA-256 浏览器原生实现

现在我们看看一个比较偏门的实现, 这个方法兼容性还可以, 但是不是经常使用的API crypto

js 复制代码
const init = () => {
    let inputEl = document.querySelector('#test-file')

    inputEl.addEventListener('change', async () => {
        const file = inputEl.files[0]

        // 使用浏览器原生实现
        useBrowserCrypto(file)
    })
}

/**
 * 使用浏览器原生实现
 * @param {File} file
 */
const useBrowserCrypto = async (file) => {
	console.time('t3')
	const res = await culculateDigest(file)
	// 文件大小:342M 耗时 984.02099609375 ms (多次测试,跟上面使用 spark-md5 在相同 342M文件大小下,速度差不多)
	console.timeEnd('t3')
	console.log(res)
}


/**
 * 生成文件摘要
 * @param {File} file
 *
 * 1. 只要文件本身内容没有发生变化,同一个文件计算出来的hash是一样的
 * 2. 文件名从a变成b, hash也是一样的
 */
async function culculateDigest(file) {
    // 读取文件buffer
    const arrayBuffer = await file.arrayBuffer()

    // https://developer.mozilla.org/zh-CN/docs/Web/API/SubtleCrypto/digest
    // 计算摘要buffer
    const digestBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer)

    // console.log(digestBuffer);

    const digestArray = Array.from(new Uint8Array(digestBuffer))

    // 转换为16进制字符串
    const digestHex = digestArray
            .map((b) => b.toString(16).padStart(2, '0'))
            .join('')

    return {
        hash: digestHex,
        size: file.size,
        originName: file.name,
        type: file.type,
    }
}

兼容性

兼容性还不错, ✿✿ヽ(°▽°)ノ✿, 可以放心食用

完整代码

这里所有代码,跟上面的代码均为一模一样,就是下面有一个 type 判断用哪种方式, 我好分节介绍不同的用法

所有代码,均可以单独copy, 拿走即可使用, 无耦合, ✿✿ヽ(°▽°)ノ✿ 😊😊😊

src/App.vue

html 复制代码
<template>
    <input type="file" id="test-file" />
</template>

<script setup>
import { onMounted } from 'vue'

import { useSparkMD5 } from './useSparkMD5'

const init = () => {
    let inputEl = document.querySelector('#test-file')

    inputEl.addEventListener('change', async () => {
        const file = inputEl.files[0]

        let type = 1

        if (type === 0) {
            console.time('t1')
            let r = await useSparkMD5(file)

            // 文件大小:342M 耗时 t1: 1074.14111328125 ms
            console.timeEnd('t1')
            console.log(r)
        } else if (type === 1) {
            // 使用 Web Worker
            useWorker(file)
        } else {
            // 使用浏览器原生实现
            useBrowserCrypto(file)
        }
    })
}

/**
 * 使用浏览器原生实现
 * @param {File} file
 */
const useBrowserCrypto = async (file) => {
    console.time('t3')
    const res = await culculateDigest(file)
    // 文件大小:342M 耗时 984.02099609375 ms (多次测试,跟上面使用 spark-md5 在相同 342M文件大小下,速度差不多)
    console.timeEnd('t3')
    console.log(res)
}

/**
 * 生成文件摘要
 * @param {File} file
 *
 * 1. 只要文件本身内容没有发生变化,同一个文件计算出来的hash是一样的
 * 2. 文件名从a变成b, hash也是一样的
 */
async function culculateDigest(file) {
    // 读取文件buffer
    const arrayBuffer = await file.arrayBuffer()

    // https://developer.mozilla.org/zh-CN/docs/Web/API/SubtleCrypto/digest
    // 计算摘要buffer
    const digestBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer)

    // console.log(digestBuffer);

    const digestArray = Array.from(new Uint8Array(digestBuffer))

    // 转换为16进制字符串
    const digestHex = digestArray
            .map((b) => b.toString(16).padStart(2, '0'))
            .join('')

    return {
        hash: digestHex,
        size: file.size,
        originName: file.name,
        type: file.type,
    }
}

/**
 * 使用worker 实现计算,切片
 * @param {File} file
 */
const useWorker = (file) => {
    console.time('t2')
    let url = new URL('./sliceFileWorker.js', import.meta.url)
    console.log('worker 文件的地址')
    console.log(url)
    const worker = new Worker(url, {
        type: 'module',
    })

    worker.postMessage({
            file,
    })

    worker.onmessage = async (e) => {
        console.log(e.data)
        // t2: 2107.10107421875 ms
        console.timeEnd('t2')
    }
}

onMounted(() => {
	init()
})
</script>

总结

从大文件上传, 我们学习了 文件摘要这样一个概念, 从代码实现, 对文件身份做了区分;

我们再来看看,最开始的 背景 中提到的

  • 文件已经上传过了, 我们前端再次拿到原始文件, 计算一下hash, 把hash 通过一个验证接口传给后台,如果hash已经在后台,就不需要上传了,直接返回服务器的文件地址即可(文件秒传); 同时也节约了带宽,也节约了服务器的资源
  • 当文件遇到: 真假美猴王.mp4 文件的时候, 由于 hash 不同, 已经知道是否要再次上传
  • 另外, 所有我们上传到服务器的文件,都应该对文件名做重命名, 然后再返回给前端, 避免相同的文件,后上传的覆盖先上传的情况, 避免数据丢失

可以学到

  • 文件摘要-概念, 实现
  • 原生浏览器API对文件做文件摘要
  • spark-md5 包的使用
  • worker 使用

如果喜欢,希望 可以给一个点赞, 谢谢。

强烈推荐大家阅读 (大文件上传实现 - 小江大浪的专栏 - 掘金 (juejin.cn)

参考资料

相关推荐
腾讯TNTWeb前端团队4 小时前
helux v5 发布了,像pinia一样优雅地管理你的react状态吧
前端·javascript·react.js
范文杰7 小时前
AI 时代如何更高效开发前端组件?21st.dev 给了一种答案
前端·ai编程
拉不动的猪7 小时前
刷刷题50(常见的js数据通信与渲染问题)
前端·javascript·面试
拉不动的猪7 小时前
JS多线程Webworks中的几种实战场景演示
前端·javascript·面试
FreeCultureBoy8 小时前
macOS 命令行 原生挂载 webdav 方法
前端
uhakadotcom8 小时前
Astro 框架:快速构建内容驱动型网站的利器
前端·javascript·面试
uhakadotcom9 小时前
了解Nest.js和Next.js:如何选择合适的框架
前端·javascript·面试
uhakadotcom9 小时前
React与Next.js:基础知识及应用场景
前端·面试·github
uhakadotcom9 小时前
Remix 框架:性能与易用性的完美结合
前端·javascript·面试
uhakadotcom9 小时前
Node.js 包管理器:npm vs pnpm
前端·javascript·面试