前端计算文件摘要

背景

在进行大文件上传的时候,例如: 一个 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)

参考资料

相关推荐
web Rookie23 分钟前
JS类型检测大全:从零基础到高级应用
开发语言·前端·javascript
Au_ust31 分钟前
css:基础
前端·css
帅帅哥的兜兜31 分钟前
css基础:底部固定,导航栏浮动在顶部
前端·css·css3
工业甲酰苯胺34 分钟前
C# 单例模式的多种实现
javascript·单例模式·c#
yi碗汤园35 分钟前
【一文了解】C#基础-集合
开发语言·前端·unity·c#
就是个名称35 分钟前
购物车-多元素组合动画css
前端·css
编程一生1 小时前
回调数据丢了?
运维·服务器·前端
丶21361 小时前
【鉴权】深入了解 Cookie:Web 开发中的客户端存储小数据
前端·安全·web
Missmiaomiao2 小时前
npm install慢
前端·npm·node.js
程序员爱技术4 小时前
Vue 2 + JavaScript + vue-count-to 集成案例
前端·javascript·vue.js