用代码实现哈希计算器:Web Crypto API 实战

网上哈希工具很多,但核心逻辑其实不到 100 行代码。本文从零实现一个哈希计算器:Web Crypto API 的 digest 用法、ArrayBuffer 转十六进制、UTF-8 编码一致性、MD5 的动态加载方案、文件哈希计算、五种算法并发优化。这是 盘子工具站哈希计算工具 的实现原理剖析,代码已在 GitHub 开源。

整体架构

一个完整的哈希计算器需要三个核心函数:

函数 作用
computeTextHash() 对文本计算指定算法的哈希值(UTF-8 安全)
computeFileHash() 对浏览器 File 对象计算哈希值
bufferToHex() 将 ArrayBuffer 转为小写十六进制字符串

设计原则:

  • 纯函数,无副作用,可在 SSR 和测试环境中运行
  • UTF-8 安全,中文文本与各语言结果一致
  • SHA 系列用浏览器原生 Web Crypto API(性能最优)
  • MD5 动态加载第三方库(Web Crypto 不支持 MD5)
  • 支持文本和文件两种输入模式

先定义类型:

typescript 复制代码
export type HashAlgorithm = 'MD5' | 'SHA-1' | 'SHA-256' | 'SHA-384' | 'SHA-512'

export interface HashResult {
  algorithm: HashAlgorithm
  value: string
}

一、bufferToHex:ArrayBuffer 转十六进制

Web Crypto API 的 digest() 返回 ArrayBuffer------原始字节缓冲区。需要转成人类可读的十六进制字符串。

typescript 复制代码
export function bufferToHex(buffer: ArrayBuffer): string {
  const bytes = new Uint8Array(buffer)
  let hex = ''
  for (let i = 0; i < bytes.length; i++) {
    const h = bytes[i].toString(16)
    // 不足两位补前导零
    hex += h.length === 1 ? '0' + h : h
  }
  return hex
}

为什么补前导零? 每个字节 0-255,转十六进制后可能是 1 位(0-15)或 2 位(16-255)。如果不补零,[10, 20] 会变成 "a14" 而不是 "0a14",丢失了字节边界信息。

复制代码
字节 [10, 20]:
补零: 0a 14 → "0a14"(4 字符,正确)
不补: a 14 → "a14"(3 字符,错误,无法还原)

二、computeTextHash:文本哈希计算

typescript 复制代码
/**
 * 计算文本哈希值。
 *
 * 文本先通过 TextEncoder 编码为 UTF-8 字节,再计算哈希,
 * 确保与各语言(Python hashlib / Node crypto)的 UTF-8 结果一致。
 */
export async function computeTextHash(
  text: string,
  algorithm: HashAlgorithm
): Promise<string> {
  // 1. 文本 → UTF-8 字节
  const data = new TextEncoder().encode(text)
  // 2. 字节 → 哈希 → 十六进制
  return computeHashFromBytes(data, algorithm)
}

关键一步:TextEncoder().encode(text)。它把 JavaScript 的 UTF-16 字符串转为 UTF-8 字节数组。没有这一步,直接把字符串当字节处理(btoa 的思路),中文会乱码,且和其他语言结果不一致。

三、computeHashFromBytes:核心计算逻辑

SHA 系列用 Web Crypto,MD5 用第三方库。两者统一入口:

typescript 复制代码
async function computeHashFromBytes(
  data: Uint8Array,
  algorithm: HashAlgorithm
): Promise<string> {
  if (algorithm === 'MD5') {
    return computeMD5(data)
  }
  // SHA-1 / SHA-256 / SHA-384 / SHA-512 由 Web Crypto API 提供
  const digestBuffer = await crypto.subtle.digest(algorithm, data)
  return bufferToHex(digestBuffer)
}

Web Crypto API 的 digest 方法

crypto.subtle.digest(algorithm, data) 是浏览器原生 API,性能极高:

  • 参数 algorithm :'SHA-1' | 'SHA-256' | 'SHA-384' | 'SHA-512'
  • 参数 data :BufferSource(Uint8Array / ArrayBuffer 等)
  • 返回值 :Promise<ArrayBuffer>------哈希结果的原始字节

注意命名规范:Web Crypto 用连字符(SHA-256),Node.js 用小写(sha256),Python 用下划线风格的模块名。跨环境时要注意名称转换。

MD5 的动态加载方案

Web Crypto API 不支持 MD5(因为 MD5 不安全,浏览器厂商不想推广)。但作为工具站,用户有 MD5 计算需求,所以需要引入第三方库。

typescript 复制代码
/**
 * 使用 js-md5 计算 MD5(动态加载,避免打包进首屏 chunk)。
 * js-md5 接受 string | number[] | ArrayBuffer | Uint8Array,此处统一传 Uint8Array。
 */
async function computeMD5(data: Uint8Array): Promise<string> {
  const { default: md5 } = await import('js-md5')
  return md5(data)
}

为什么动态 import?

  • MD5 不是所有用户都用,首屏加载没必要包含
  • await import('js-md5') 按需加载,打包时生成独立 chunk
  • 首屏体积更小,加载更快
  • 用户第一次使用 MD5 时有一次加载延迟(几十 KB),后续复用缓存

这是前端性能优化的常用模式------低频功能按需加载。

Web Crypto 的安全上下文限制

crypto.subtle 只在 HTTPS 和 localhost 下可用。HTTP 环境下 crypto.subtle 是 undefined。

生产环境必须 HTTPS,这个没问题。但开发时如果用 IP 访问而非 localhost,也会不可用。

如果需要兼容 HTTP 环境,可以对所有算法都做降级:

typescript 复制代码
async function computeHashFromBytes(
  data: Uint8Array,
  algorithm: HashAlgorithm
): Promise<string> {
  // 检查 Web Crypto 是否可用
  if (typeof crypto !== 'undefined' && crypto.subtle && algorithm !== 'MD5') {
    const digestBuffer = await crypto.subtle.digest(algorithm, data)
    return bufferToHex(digestBuffer)
  }

  // 降级:用第三方纯 JS 实现
  switch (algorithm) {
    case 'MD5': {
      const { default: md5 } = await import('js-md5')
      return md5(data)
    }
    case 'SHA-1': {
      const { sha1 } = await import('js-sha1')
      return sha1(data)
    }
    case 'SHA-256': {
      const { sha256 } = await import('js-sha256')
      return sha256(data)
    }
    // ... 其他算法同理
  }
}

盘子工具站部署在 HTTPS 上,所以只对 MD5 做了动态加载降级。

四、computeFileHash:文件哈希计算

typescript 复制代码
/**
 * 计算文件哈希值。
 *
 * 文件通过 File API 读取为 ArrayBuffer,所有计算在浏览器本地完成,
 * 文件不会上传到服务器。
 */
export async function computeFileHash(
  file: File,
  algorithm: HashAlgorithm
): Promise<string> {
  const buffer = await file.arrayBuffer()
  const data = new Uint8Array(buffer)
  return computeHashFromBytes(data, algorithm)
}

file.arrayBuffer() 是 File API 的方法,直接读取文件全部内容为 ArrayBuffer。小文件没问题,大文件可能占用较多内存。

大文件流式计算优化

对于几百 MB 甚至几 GB 的大文件,一次性读入内存可能导致浏览器卡顿。可以用 FileReader 分块读取 + 增量计算:

typescript 复制代码
async function computeLargeFileHash(
  file: File,
  algorithm: HashAlgorithm,
  chunkSize: number = 8 * 1024 * 1024  // 8MB 一块
): Promise<string> {
  if (algorithm === 'MD5') {
    // js-md5 支持增量计算
    const { default: MD5 } = await import('js-md5')
    const md5 = MD5.create()

    let offset = 0
    while (offset < file.size) {
      const chunk = file.slice(offset, offset + chunkSize)
      const buffer = await chunk.arrayBuffer()
      md5.update(new Uint8Array(buffer))
      offset += chunkSize
    }
    return md5.hex()
  }

  // Web Crypto 不支持增量 digest,需要用 SubtleCrypto 的替代方案
  // 或者用第三方纯 JS 实现(如 js-sha256)
  throw new Error('大文件流式计算建议用第三方库实现')
}

Web Crypto 的 digest() 方法是一次性的------传进去多少数据算多少。如果要增量计算,需要用 SubtleCrypto 的其他方案或纯 JS 实现。对于大多数场景(工具站上 MB 级文件),一次性读取完全够用。

五、五种算法并发计算

工具的交互设计是输入文本后同时显示五种算法的结果。串行计算慢,用 Promise.all 并发:

typescript 复制代码
const ALL_ALGORITHMS: HashAlgorithm[] = [
  'MD5', 'SHA-1', 'SHA-256', 'SHA-384', 'SHA-512'
]

async function computeAllHashes(text: string): Promise<HashResult[]> {
  const promises = ALL_ALGORITHMS.map(async (algo) => {
    const value = await computeTextHash(text, algo)
    return { algorithm: algo, value }
  })
  return Promise.all(promises)
}

Promise.all 让五个哈希计算同时进行。Web Crypto 的 digest 是异步操作(浏览器内部用 C++ 实现,可能走多线程),并发计算比串行快得多。

六、单元测试设计

typescript 复制代码
import { describe, it, expect } from 'vitest'
import { computeTextHash, bufferToHex } from './hash'

describe('bufferToHex', () => {
  it('空 buffer 返回空字符串', () => {
    expect(bufferToHex(new ArrayBuffer(0))).toBe('')
  })

  it('单字节转换正确', () => {
    const buf = new Uint8Array([0x0A]).buffer
    expect(bufferToHex(buf)).toBe('0a')  // 补前导零
  })

  it('多字节转换正确', () => {
    const buf = new Uint8Array([0xDE, 0xAD, 0xBE, 0xEF]).buffer
    expect(bufferToHex(buf)).toBe('deadbeef')
  })

  it('输出为小写', () => {
    const buf = new Uint8Array([0xAB, 0xCD]).buffer
    expect(bufferToHex(buf)).toBe('abcd')
  })
})

describe('computeTextHash', () => {
  // 已知答案的测试用例(用 Python 或 Node.js 预计算)
  const testCases = [
    { input: '', md5: 'd41d8cd98f00b204e9800998ecf8427e' },
    { input: 'Hello World', md5: 'b10a8db164e0754105e766fea50c4316' },
    { input: 'Hello World', sha256: 'd2a84f4b8b650937ec8f73cd8be2c74add5a911ba64df27498ed22a0d624c945' },
  ]

  it('空字符串 MD5', async () => {
    expect(await computeTextHash('', 'MD5'))
      .toBe('d41d8cd98f00b204e9800998ecf8427e')
  })

  it('英文文本 SHA256', async () => {
    expect(await computeTextHash('Hello World', 'SHA-256'))
      .toBe('d2a84f4b8b650937ec8f73cd8be2c74add5a911ba64df27498ed22a0d624c945')
  })

  it('中文文本(UTF-8 一致)', async () => {
    // 和 Python hashlib.sha256('中文'.encode('utf-8')).hexdigest() 对比
    expect(await computeTextHash('中文', 'SHA-256'))
      .toBe('72726d8818f693066ceb69afa364218b692e62ea92b385782363780f47529c2e')
  })

  it('五种算法都能计算', async () => {
    const algos = ['MD5', 'SHA-1', 'SHA-256', 'SHA-384', 'SHA-512'] as const
    for (const algo of algos) {
      const result = await computeTextHash('test', algo)
      expect(typeof result).toBe('string')
      expect(result.length).toBeGreaterThan(0)
      expect(/^[0-9a-f]+$/.test(result)).toBe(true)  // 纯小写十六进制
    }
  })

  it('输出长度符合算法', async () => {
    expect((await computeTextHash('test', 'MD5')).length).toBe(32)
    expect((await computeTextHash('test', 'SHA-1')).length).toBe(40)
    expect((await computeTextHash('test', 'SHA-256')).length).toBe(64)
    expect((await computeTextHash('test', 'SHA-384')).length).toBe(96)
    expect((await computeTextHash('test', 'SHA-512')).length).toBe(128)
  })
})

最关键的测试用例是中文 UTF-8 一致性------确保工具的计算结果和 Python/Node.js 的输出完全相同。如果 UTF-8 处理有 Bug,这个测试会失败。

完整代码与在线体验

本文拆解的代码完整实现在 PanziTool GitHub 仓库 的 frontend/utils/tools/hash.ts 文件中,约 80 行 TypeScript 代码,核心依赖只有 js-md5(动态加载)。Vue 组件 HashTool.vue 负责交互层,调用这些纯函数完成文本和文件的多算法哈希计算。

如果你想体验最终效果,可以访问 盘子工具站哈希计算工具,输入任意文本即可看到:

  • 实时计算 MD5、SHA1、SHA256、SHA384、SHA512 五种结果
  • 支持文件上传计算哈希,所有运算在浏览器本地完成
  • 输出统一为小写十六进制,和各语言标准库结果一致
  • 中文文本基于 UTF-8 编码,跨平台结果一致

所有计算在浏览器本地完成,不上传服务器,适合处理敏感数据。

相关推荐
data analyse 4561 小时前
服务端访问日志聚合 PV 与 UV 怎么实现?日志采集与口径设计
大数据·前端·数据分析
Nil2081 小时前
leetcode 300最长递增子序列
算法·leetcode·职场和发展
JavaGuide1 小时前
DeepSeek Harness 重磅更新,Codex、Claude Code 子智能体进官方插件列表了,还有 Git 工作树和思考翻译!
前端·后端
小羊没烦恼!1 小时前
OS X 上使用.NET开发应用程序
开发语言·前端·数据库·人工智能·c#·策略模式
垆边人似月.2 小时前
华为机试题 :最大数
c++·算法·华为
Eiceblue2 小时前
使用 Python 实现 Excel 行列转置(保留单元格样式)
前端·python·excel
zhenaibo5212 小时前
如何与导师有效沟通选题?
算法
熊猫钓鱼>_>2 小时前
大学图论第 6 讲:网络流与二分图匹配——对偶性让瓶颈自己现形
开发语言·算法·面试·职场和发展·图论·校招·面经
西西学代码2 小时前
像素和颜色之间的转换(Flutter)
前端·javascript·flutter