與代碼全解)
最近在給一個文件上傳模塊做完整性校驗到方案選型時才發(fā)現(xiàn)前端圈子對哈希計算的討論遠(yuǎn)比后端少。網(wǎng)上搜“SHA-256生成器”跳出來的大多是調(diào)一下后端接口、或者一句話封裝完事。這周我干脆自己動手把SHA-256從前端角度完整做了一遍從算法原理到可運(yùn)行代碼全部走通輸出一個能直接用的哈希生成器工具。這篇記錄一下整個實戰(zhàn)過程給同樣需要在瀏覽器里算哈希的開發(fā)者一個可復(fù)現(xiàn)的參考。這里的思路簡單說就是字符串輸入后實時算出SHA-256結(jié)果文件上傳后也直接在前端計算出文件哈希全程不經(jīng)過服務(wù)器。整個過程要解決三個問題SHA-256到底是怎么把任意數(shù)據(jù)變成固定64位十六進(jìn)制的、瀏覽器環(huán)境下有哪些實現(xiàn)路徑、哪些坑是真正開發(fā)中繞不過去的。適合對前端加密、文件處理、數(shù)據(jù)校驗感興趣的同學(xué)參考也適合想從零手寫一遍加密算法的朋友對照閱讀。1. 做哈希生成器之前先想清楚它到底能解決什么1.1 瀏覽器里為什么需要哈希計算很多人一聽前端口算哈希就搖頭覺得這種計算應(yīng)該在服務(wù)端完成。但實際開發(fā)中前端做哈希計算的場景一點(diǎn)也不少。最典型的就是文件完整性校驗。把一個大文件從服務(wù)器下載到本地傳輸過程可能出錯但用戶不知道。如果在瀏覽器里把下載后的文件計算出SHA-256值和服務(wù)端下發(fā)的期望值比對就能確認(rèn)文件是否完好。還有一個場景是內(nèi)容去重。比如一個圖片上傳系統(tǒng)如果用戶在A頁面已經(jīng)傳過某張圖片B頁面又要傳同一張前端可以先算出文件哈希交給后端做比對能省下大量不必要的帶寬。再比如接口簽名部分開放平臺要求客戶端對請求參數(shù)做哈希摘要前端拿到后再放進(jìn)請求頭里。還有一個非常容易被誤解的場景是“前端密碼哈?!?。很多同學(xué)習(xí)慣把用戶輸入的密碼在前端做SHA-256再傳給后端。這里我必須多說一句前端哈希密碼能防止明文傳輸被截獲但并不能代替HTTPS也不能作為服務(wù)端加鹽哈希的替代品。如果真要存密碼服務(wù)端仍然要用專用算法做加鹽處理。前端的哈希值只能算一層額外的防泄露手段不能把這個場景當(dāng)成哈希生成器的核心用途但它確實是很多人學(xué)習(xí)哈希的起因。1.2 從標(biāo)題拆解這個項目的具體需求把“【前端實戰(zhàn)】SHA-256哈希生成器算法原理前端實現(xiàn)代碼全解”這個標(biāo)題拆開實際要做的東西很清晰。第一算法原理必須講透徹。不是調(diào)一句API然后說“完事了”而是要把SHA-256內(nèi)部的填充規(guī)則、壓縮函數(shù)、狀態(tài)變量變化講明白讓讀者知道哈希結(jié)果不是魔法是一步步算出來的。第二前端實現(xiàn)要覆蓋至少兩種輸入文本和文件。文本是最簡單的調(diào)用場景文件則要處理二進(jìn)制讀取和大型文件的內(nèi)存控制這是和純調(diào)API完全不同的復(fù)雜度。第三代碼全解意味著源代碼要能直接復(fù)制運(yùn)行。我決定做一個單頁工具包含輸入?yún)^(qū)、輸出區(qū)和計算耗時而不是寫一堆零散代碼片段。這樣讀者拿到就能用也能把每個函數(shù)和UI對應(yīng)起來理解。在這樣的項目定位下技術(shù)方案其實有挺多選擇。你可以直接用瀏覽器內(nèi)置的Web Crypto API也可以用第三方加密庫更可以手寫算法實現(xiàn)。這三種路線我這次全部測試過了各自的優(yōu)缺點(diǎn)在后面詳細(xì)展開。2. SHA-256算法原理從消息到64位十六進(jìn)制中間經(jīng)歷了什么2.1 哈希函數(shù)的幾個基礎(chǔ)性質(zhì)在寫代碼之前我建議先把SHA-256當(dāng)做一個黑盒觀察一輪。輸入任意長度的數(shù)據(jù)輸出永遠(yuǎn)是固定的256位也就是32個字節(jié)用十六進(jìn)制表示就是64個字符。不管你輸入一個字母還是一部電影輸出的長度都一樣。哈希函數(shù)有三個性質(zhì)對前端開發(fā)者影響最大。第一個是單向性從輸入算哈希很容易但從哈希反推輸入幾乎不可能。就好比把雞蛋打碎做成煎蛋容易想把煎蛋變回雞蛋就太難了。第二個是雪崩效應(yīng)輸入的任意一個bit變化輸出大約有一半bit發(fā)生變化看起來像完全變了個人。第三個是固定長度輸出長度和輸入長度無關(guān)。這三個性質(zhì)決定了SHA-256的實際用途。文件校驗靠的是雪崩效應(yīng)能發(fā)現(xiàn)極細(xì)微的改動密碼摘要靠的是單向性不可逆推原始值內(nèi)容尋址靠的是固定長度同一份內(nèi)容在任何位置算出的哈希都相同。但SHA-256并不是憑空產(chǎn)生這些性質(zhì)的它內(nèi)部是一套非常機(jī)械的數(shù)學(xué)運(yùn)算。接下來我按處理順序拆解。2.2 數(shù)據(jù)填充為什么要在末尾加“1”、補(bǔ)零和長度SHA-256處理數(shù)據(jù)的基本單位是64字節(jié)的塊。如果輸入數(shù)據(jù)長度不是64的整數(shù)倍就必須填充。填充規(guī)則是這樣的在消息末尾先添加一個bit的1也就是字節(jié)0x80然后補(bǔ)若干0字節(jié)直到剩余長度正好是56字節(jié)最后再用8字節(jié)表示原始消息的比特長度。這里有兩個細(xì)節(jié)值得解釋。為什么要填到“64字節(jié)塊還剩56字節(jié)”而不是直接填滿因為最后的8字節(jié)要用來放長度信息。原始消息的長度以bit為單位用64位大端序表示。對于長度不超過2的64次方bit的數(shù)據(jù)這個表示方式是足夠的。為什么要強(qiáng)行加入長度因為如果不記錄長度僅靠尾部補(bǔ)零消息“abc”和消息“abc0”在填充后會變成一樣的塊序列哈希自然也一樣。加上真實長度后能區(qū)分出這類差異。這就是所謂的梅克爾-達(dá)姆加德結(jié)構(gòu)增強(qiáng)核心是讓填充后的數(shù)據(jù)完整表達(dá)出原始數(shù)據(jù)的信息包括內(nèi)容和長度。填充后數(shù)據(jù)一定可以被切成整整若干塊每塊64字節(jié)。塊數(shù)不足時自動補(bǔ)塊數(shù)超了也沒問題因為后續(xù)的壓縮函數(shù)像傳送帶一樣逐塊處理。2.3 壓縮函數(shù)八個狀態(tài)變量如何翻滾六十四輪數(shù)據(jù)準(zhǔn)備好之后SHA-256核心算法才真正開始。它維護(hù)8個32位的狀態(tài)變量也就是初始哈希值H0到H7。這些常量來自前8個質(zhì)數(shù)的平方根的小數(shù)部分比如2的平方根取小數(shù)前32位就是0x6a09e667。為什么用質(zhì)數(shù)平方根因為這些數(shù)字沒有規(guī)律能避免初始狀態(tài)被構(gòu)造出特殊結(jié)構(gòu)。對于每一個64字節(jié)塊算法要執(zhí)行一輪消息擴(kuò)展和64輪壓縮循環(huán)。消息擴(kuò)展的意思是把一個64字節(jié)的塊拆成16個32位大端序的字W[0]到W[15]然后用特定公式再生成48個字W[16]到W[63]。這個過程把數(shù)據(jù)充分?jǐn)嚢琛=又?4輪循環(huán)。每一輪基于當(dāng)前8個狀態(tài)變量a到h做一系列位運(yùn)算再把結(jié)果移位替換。核心的運(yùn)算包括邏輯函數(shù)Ch(x, y, z) (x y) ^ (~x z)中文常叫“選擇函數(shù)”按x的bit位選擇y或z。Maj(x, y, z) (x y) ^ (x z) ^ (y z)三數(shù)中取多數(shù)。Σ0(x) 和 Σ1(x) 分別是循環(huán)右移和異或組合。σ0 和 σ1 用于消息擴(kuò)展。每一輪還會混入一個常量K[i]這些常量來自前64個質(zhì)數(shù)的立方根的小數(shù)部分。這64個常量相當(dāng)于給整個算法注入了外部隨機(jī)性避免輪與輪之間出現(xiàn)簡單循環(huán)。輪循環(huán)的過程是先算T1 h Σ1(e) Ch(e, f, g) K[i] W[i]再算T2 Σ0(a) Maj(a, b, c)然后所有變量整體后移一位a變成T1 T2e變成d T1。這一步看起來像流水線但實際上是把狀態(tài)值充分交叉混合。塊處理完后把初始狀態(tài)變量和塊處理后的a到h分別相加作為下一塊輸入的初始狀態(tài)。最后一個塊處理完成后8個狀態(tài)變量按大端序拼成十六進(jìn)制字符串就是最終的SHA-256值。2.4 從公式到代碼所有運(yùn)算都能映射成JavaScript把上面這些流程翻譯成JavaScript其實不需要特別高深的知識只需要掌握幾個關(guān)鍵點(diǎn)。第一JavaScript的位運(yùn)算符只能作用于32位整數(shù)而且是有符號32位。為了模擬無符號32位運(yùn)算要用 0把負(fù)值轉(zhuǎn)回?zé)o符號。第二循環(huán)右移要自己實現(xiàn)比如循環(huán)右移7位就是(x 7) | (x 25)因為x 25在32位語義下自然完成了高位回填。第三加法隨時可能溢出所以要每一步加法都做無符號歸一化。我最初手寫代碼時最容易漏掉的就是這些細(xì)節(jié)。一個加法沒做 0結(jié)果算到后面就徹底不對了。這也解釋了為什么很多初學(xué)者看在線教程抄代碼時結(jié)果對不上——不是在抄錯而是中間某個中間值已經(jīng)是帶符號整數(shù)了。后續(xù)第4部分我會給出完整可運(yùn)行的手寫實現(xiàn)這里先記住一個概念SHA-256本質(zhì)上是大量32位異或、移位和加法疊加起來的沒有復(fù)雜的浮點(diǎn)運(yùn)算也沒有分支跳轉(zhuǎn)所以它在任何語言里都能高效實現(xiàn)。3. 前端實現(xiàn)路線選型三條路走哪條最合適3.1 瀏覽器原生Web Crypto API最省事的方案是直接使用crypto.subtle.digest(SHA-256, data)。這個方法返回一個Promiseresolve后得到ArrayBuffer再轉(zhuǎn)換成十六進(jìn)制字符串即可。它的優(yōu)點(diǎn)是使用系統(tǒng)級加密模塊在絕大多數(shù)瀏覽器中帶硬件加速性能極好。但它有一個限制只能在安全上下文中使用。所謂安全上下文指的是HTTPS或者localhost。如果你在本地用file://協(xié)議或者局域網(wǎng)IP上打開頁面crypto.subtle會是undefined。我在本地開發(fā)時第一次遇到這個問題還以為是瀏覽器版本問題后來才意識到是協(xié)議的問題。另一個限制是它不支持增量輸入。你必須一次性把完整數(shù)據(jù)傳進(jìn)去它才能計算。這意味著你不方便用這個API計算超大文件的流式哈希除非把整個文件都讀進(jìn)內(nèi)存。對大文件來說這可能導(dǎo)致內(nèi)存峰值過高性能和體驗都會打折扣。3.2 第三方加密庫拿crypto-js做對比很多項目選擇引入第三方庫最常見的如crypto-js。它能處理字符串哈希也能通過CryptoJS.algo.SHA256.create()的方式做增量哈希所以在老項目中很受歡迎。但第三方庫有個繞不開的問題它是以純JavaScript運(yùn)行在解釋器上性能無法和瀏覽器原生實現(xiàn)相比。處理幾十MB的文件時crypto-js的耗時明顯高于crypto.subtle。如果腳本體積敏感或者項目本身只是需要一次性哈希引入一個完整加密庫也略顯笨重。不過第三方庫的兼容性有優(yōu)勢支持Web Worker環(huán)境跑也支持Node.js環(huán)境。如果代碼需要在瀏覽器和Node.js兩側(cè)復(fù)用選crypto-js這類庫就很方便。我用它做過桌面端工具的插件體驗還算穩(wěn)定。3.3 到底要不要手寫SHA-256手寫SHA-256是這次項目里最有意思的部分也是標(biāo)題里“算法原理代碼全解”最值得展開的一部分。手寫實現(xiàn)不需要任何外部依賴性能介于原生和crypto-js之間而且能天然支持增量計算和流式處理。但手寫SHA-256的風(fēng)險也很明顯如果沒有吃透算法代碼極易出現(xiàn)隱蔽的bug。最典型的就是加法溢出問題還有填充時長度表示與字節(jié)序問題。如果你只是想做一個功能我會推薦優(yōu)先用crypto.subtle因為它簡單、快、近乎0出錯率。如果你想理解哈希原理或者需要流式處理能力那么手寫實現(xiàn)就是一個不錯的練習(xí)和備用方案。我把三個方案整理成了一個對比表方便快速決策方案性能增量計算安全上下文要求體積適用場景crypto.subtle極高硬件加速不支持必須HTTPS或localhost0普通字符串/小文件哈希crypto-js中支持無約40KB兼容性優(yōu)先的老項目手寫實現(xiàn)中上支持無自定義學(xué)習(xí)原理、大文件流式處理對這次的項目來說我決定兩個都做界面工具用crypto.subtle做默認(rèn)計算因為用戶大概率在HTTPS環(huán)境打開同時準(zhǔn)備一個手寫的流式實現(xiàn)專門用來處理大文件的分片計算。這樣兩種場景都能覆蓋。4. 完整代碼實現(xiàn)頁面結(jié)構(gòu)、核心函數(shù)、手寫版流式SHA-2564.1 頁面骨架設(shè)計這個工具頁面的核心元素特別簡單就三個交互區(qū)域一個文本輸入框、一個文件選擇框、一個結(jié)果展示區(qū)。文本輸入框?qū)崟r監(jiān)聽input事件每次變化都重新計算哈希。文件選擇支持分片讀取計算完成后顯示十六進(jìn)制結(jié)果和處理耗時。我不想在這個頁面上堆太多UI庫直接用原生HTML和一點(diǎn)CSS就能達(dá)到效果。結(jié)構(gòu)上保持一個整潔的單列布局輸入?yún)^(qū)域在上方結(jié)果區(qū)域在下方再加上一個計算耗時的狀態(tài)提示整體信息很清晰。4.2 字符串哈希的核心實現(xiàn)字符串哈希的思路很直觀把字符串編碼為Uint8Array交給crypto.subtle計算然后把ArrayBuffer轉(zhuǎn)成十六進(jìn)制字符串。代碼如下async function sha256Text(text) { const encoder new TextEncoder(); const data encoder.encode(text); const hashBuffer await crypto.subtle.digest(SHA-256, data); return bufToHex(hashBuffer); } function bufToHex(buffer) { const bytes new Uint8Array(buffer); let result ; for (let i 0; i bytes.length; i) { result bytes[i].toString(16).padStart(2, 0); } return result; }這個代碼看起來簡單但有一個細(xì)節(jié)值得強(qiáng)調(diào)文本編碼必須用TextEncoder。之前我看到過有人用unescape(encodeURIComponent(str))這樣的寫法把字符串轉(zhuǎn)字節(jié)也能用但TextEncoder是現(xiàn)代瀏覽器標(biāo)準(zhǔn)API處理UTF-8編碼更可靠。如果直接對字符串調(diào)用crypto.subtle.digest而不做類型轉(zhuǎn)換會因為類型不對直接拋錯。還應(yīng)注意crypto.subtle.digest返回的是Promise所以需要await。在事件監(jiān)聽里調(diào)用時建議用async函數(shù)包裹避免出現(xiàn)未捕獲的Promise異常。4.3 文件哈希小文件和大文件的處理差異對小文件比如幾MB的圖片直接用FileReader或file.arrayBuffer()讀取整個文件然后交給crypto.subtle計算即可代碼量很少。async function sha256FileSmall(file) { const buffer await file.arrayBuffer(); const hashBuffer await crypto.subtle.digest(SHA-256, buffer); return bufToHex(hashBuffer); }但如果文件超過100MB一次性讀取整個文件進(jìn)內(nèi)存會讓頁面卡頓甚至觸碰到瀏覽器內(nèi)存限制。這時應(yīng)該用分片方式加上手寫流式SHA-256類來實現(xiàn)。const CHUNK_SIZE 1024 * 1024; // 1MB一片 async function sha256FileLarge(file, onProgress) { const sha new StreamingSha256(); let offset 0; while (offset file.size) { const end Math.min(offset CHUNK_SIZE, file.size); const chunk await file.slice(offset, end).arrayBuffer(); sha.update(new Uint8Array(chunk)); offset end; if (onProgress) { onProgress(offset / file.size); } } return sha.finalize(); }這里的優(yōu)勢在于內(nèi)存占用始終是固定大小只取決于CHUNK_SIZE設(shè)置而不是整個文件體積。用戶選擇1GB文件時也只是逐片讀取而已實際體驗比一次性讀取順暢得多。StreamingSha256類就是手寫SHA-256的封裝它在內(nèi)部維護(hù)8個狀態(tài)變量和緩沖區(qū)每收滿64字節(jié)就壓縮一次同時記錄總長度最終在finalize時完成填充和最后一輪壓縮。這個類我放在下一小節(jié)詳細(xì)展開。4.4 手寫StreamingSha256不依賴Web Crypto的流式哈希手寫SHA-256的完整實現(xiàn)需要寫壓縮函數(shù)、消息擴(kuò)展和填充邏輯。這里直接給出我整理后的核心代碼。const K [ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 ]; function rotr(x, n) { return ((x n) | (x (32 - n))) 0; } class StreamingSha256 { constructor() { this.state [ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19 ]; this.buffer new Uint8Array(64); this.bufferLen 0; this.byteLen 0; } update(bytes) { this.byteLen bytes.length; let pos 0; if (this.bufferLen 0) { const need 64 - this.bufferLen; const take Math.min(need, bytes.length); this.buffer.set(bytes.subarray(0, take), this.bufferLen); this.bufferLen take; pos take; if (this.bufferLen 64) { this._compress(this.buffer); this.bufferLen 0; } } while (pos 64 bytes.length) { this._compress(bytes.subarray(pos, pos 64)); pos 64; } if (pos bytes.length) { const rest bytes.length - pos; this.buffer.set(bytes.subarray(pos), 0); this.bufferLen rest; } return this; } finalize() { const bitLen BigInt(this.byteLen) * 8n; const bitLenHi Number(bitLen 32n); const bitLenLo Number(bitLen 0xffffffffn); const pad new Uint8Array(64); pad.set(this.buffer.subarray(0, this.bufferLen), 0); pad[this.bufferLen] 0x80; if (this.bufferLen 56) { this._compress(pad); pad.fill(0); } pad[56] (bitLenHi 24) 0xff; pad[57] (bitLenHi 16) 0xff; pad[58] (bitLenHi 8) 0xff; pad[59] bitLenHi 0xff; pad[60] (bitLenLo 24) 0xff; pad[61] (bitLenLo 16) 0xff; pad[62] (bitLenLo 8) 0xff; pad[63] bitLenLo 0xff; this._compress(pad); let result ; for (let i 0; i 8; i) { result this.state[i].toString(16).padStart(8, 0); } return result; } _compress(block) { const w new Array(64); for (let i 0; i 16; i) { w[i] ( (block[i * 4] 24) | (block[i * 4 1] 16) | (block[i * 4 2] 8) | block[i * 4 3] ) 0; } for (let i 16; i 64; i) { const s0 rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] 3); const s1 rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] 10); w[i] (w[i - 16] s0 w[i - 7] s1) 0; } let [a, b, c, d, e, f, g, h] this.state; for (let i 0; i 64; i) { const S1 rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25); const ch (e f) ^ (~e g); const temp1 (h S1 ch K[i] w[i]) 0; const S0 rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22); const maj (a b) ^ (a c) ^ (b c); const temp2 (S0 maj) 0; h g; g f; f e; e (d temp1) 0; d c; c b; b a; a (temp1 temp2) 0; } this.state[0] (this.state[0] a) 0; this.state[1] (this.state[1] b) 0; this.state[2] (this.state[2] c) 0; this.state[3] (this.state[3] d) 0; this.state[4] (this.state[4] e) 0; this.state[5] (this.state[5] f) 0; this.state[6] (this.state[6] g) 0; this.state[7] (this.state[7] h) 0; } }這個類是完整的可運(yùn)行實現(xiàn)參考了SHA-256標(biāo)準(zhǔn)定義和常見實現(xiàn)思路。你如果拿到StreamingSha256這個類可以配合任意File分片讀取邏輯實現(xiàn)流式哈希。它也支持直接傳字符串轉(zhuǎn)換的Uint8Array計算小數(shù)據(jù)也沒問題。這里有一個容易踩的坑就是finalize填充部分。SHA-256要求最后8字節(jié)表示原始消息的bit長度不是字節(jié)長度。所以代碼里用byteLen * 8n轉(zhuǎn)換成bit長度。當(dāng)填充后本塊剩余不足8字節(jié)時需要額外補(bǔ)一個64字節(jié)的零塊來容納長度這就是if (this.bufferLen 56)分支的作用。如果漏掉這個分支處理恰好處于邊界的數(shù)據(jù)時會得到錯誤結(jié)果。為了驗證這個手寫實現(xiàn)的正確性我用在線工具算了一組測試向量。輸入空字符串標(biāo)準(zhǔn)結(jié)果是e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855手寫實現(xiàn)得到的完全一致。輸入“hello world”標(biāo)準(zhǔn)結(jié)果是b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9結(jié)果也一致。確認(rèn)無誤后我才放心把它用于大文件分片邏輯。4.5 完整的頁面調(diào)用與事件綁定有了核心函數(shù)頁面調(diào)用就很直接了。文本輸入框監(jiān)聽input事件文件輸入監(jiān)聽change事件各自調(diào)用對應(yīng)的哈希函數(shù)然后把結(jié)果寫到頁面里。const textInput document.getElementById(textInput); const textHash document.getElementById(textHash); const fileInput document.getElementById(fileInput); const fileHash document.getElementById(fileHash); const hashTime document.getElementById(hashTime); textInput.addEventListener(input, async () { const start performance.now(); const hex await sha256Text(textInput.value); textHash.textContent hex; hashTime.textContent 耗時 ${(performance.now() - start).toFixed(2)}ms; }); fileInput.addEventListener(change, async (e) { const file e.target.files[0]; if (!file) return; const start performance.now(); let hex; if (file.size 30 * 1024 * 1024) { hex await sha256FileSmall(file); } else { hex await sha256FileLarge(file, (p) { console.log(已處理 ${(p * 100).toFixed(1)}%); }); } fileHash.textContent hex; hashTime.textContent 耗時 ${(performance.now() - start).toFixed(2)}ms; });這里對文件大小做了一個判斷小于30MB走簡單的一次性讀取大于30MB走流式分片。這個閾值可以根據(jù)項目實際情況調(diào)整。比如移動端設(shè)備內(nèi)存緊張建議把閾值調(diào)低到5MB甚至更低。5. 實戰(zhàn)中踩過的坑與排查經(jīng)驗5.1 中文字符切成哈希后和在線工具對不上這是我第一次做文本哈希時遇到的第一個問題。同樣的“你好世界”我算出來的結(jié)果和某個在線網(wǎng)站對不上查了半天發(fā)現(xiàn)是編碼問題。在線網(wǎng)站在計算前會把字符串按UTF-8編碼轉(zhuǎn)成字節(jié)而某些老式教程使用charCodeAt直接拿Unicode碼點(diǎn)當(dāng)字節(jié)兩種方式的結(jié)果自然不同。正確的做法是統(tǒng)一使用TextEncoder編碼。如果你遇到哈希值不一致優(yōu)先檢查輸入字符串到字節(jié)的轉(zhuǎn)換方式是否一致。這個坑在英文文本下完全暴露不出來因為ASCII字符的UTF-8字節(jié)和Unicode碼點(diǎn)一致中文場景才明顯。5.2 crypto.subtle在本地文件頁面下不可用瀏覽器出于安全考慮把crypto.subtle限制在了安全上下文里。如果你直接雙擊一個HTML文件在file://協(xié)議下打開頁面里crypto.subtle是未定義的。在局域網(wǎng)內(nèi)部測試時如果用IP地址而不是localhost訪問同樣可能被攔。解決方案有兩個本地開發(fā)時用localhost域名訪問或者直接起一個本地靜態(tài)服務(wù)器。最簡單的做法是用npx serve或者寫一個Node靜態(tài)文件服務(wù)器通過http://localhost訪問頁面。這個問題排查起來不算難但第一次遇到確實容易懵。5.3 Web Crypto API不支持增量計算如何應(yīng)對大文件前面提過crypto.subtle.digest必須一次性傳入完整數(shù)據(jù)。對大文件來說這意味著內(nèi)存要一次性裝下整個文件GPU加速帶來的性能優(yōu)勢會被內(nèi)存瓶頸抵消。我的方案是把大文件哈希全部交給手寫的StreamingSha256類處理。它天然是增量式的每收到64字節(jié)就完成壓縮更新內(nèi)部狀態(tài)。與此同時這個類不依賴任何瀏覽器API所以在Web Worker里也能用可以把計算放到后臺線程避免阻塞主線程UI。不過在工程里我更建議的方案是分層處理小文件直接用Web Crypto大文件拆進(jìn)Web Worker里跑手寫實現(xiàn)。這樣既快又不會卡界面。如果你擔(dān)心手寫實現(xiàn)不夠信任也可以找經(jīng)過審計的第三方流式哈希庫。5.4 常見問題速查表問題原因解決辦法中文哈希結(jié)果不一致字符串編碼方式不同統(tǒng)一使用TextEncodercrypto.subtle未定義非安全上下文使用localhost或HTTPS大文件頁面卡頓一次性讀入內(nèi)存分片流式哈希手寫哈希結(jié)果錯誤加法溢出未處理每步加法加 0傳入null或空值時類型報錯參數(shù)校驗缺失調(diào)用前檢查輸入類型計算大文件耗時過長主線程阻塞移到Web Worker輸出少了一位0字節(jié)轉(zhuǎn)十六進(jìn)制未補(bǔ)前導(dǎo)零使用padStart(2, 0)這個速查表基本上覆蓋了我實際開發(fā)中遇到的大部分問題。值得強(qiáng)調(diào)的是最后一條字節(jié)轉(zhuǎn)十六進(jìn)制時如果某個字節(jié)小于16直接toString(16)只會得到一個字符比如0x0d會輸出d不補(bǔ)零的話整個哈希長度會少一位和標(biāo)準(zhǔn)結(jié)果完全對不上。這個細(xì)節(jié)非常隱蔽但對結(jié)果正確性影響很大。5.5 Web Worker與主線程協(xié)作的擴(kuò)展思路再說一個值得推薦的優(yōu)化方向把SHA-256計算放到Web Worker里。當(dāng)前頁面在計算大文件時雖然采用了流式分片但是如果計算邏輯和UI在同一線程分片讀取和壓縮計算仍然會占用主線程用戶滾動頁面時能感覺到卡頓。把StreamingSha256類打包成一個worker腳本主線程通過postMessage把分片的ArrayBuffer傳給workerworker壓縮完再返回進(jìn)度消息和結(jié)果。這樣UI線程只負(fù)責(zé)讀文件和更新顯示計算密集任務(wù)都在后臺完成。const worker new Worker(sha256-worker.js); worker.onmessage (e) { if (e.data.type progress) { // 更新進(jìn)度條 } else if (e.data.type result) { // 顯示最終哈希 } }; // 主線程分片讀取并發(fā)送給worker file.arrayBuffer().then((buf) { worker.postMessage({ type: chunk, data: buf }, [buf]); });把計算移出主線程頁面始終保持60fps的流暢體驗這個改進(jìn)在大文件場景下感知非常明顯。如果你要做的不是一個學(xué)習(xí)Demo而是一個真實產(chǎn)品功能我非常建議加上這一層。6. 從實現(xiàn)到產(chǎn)品化還能繼續(xù)擴(kuò)展的細(xì)節(jié)哈希生成器做到這個程度已經(jīng)能直接用作內(nèi)部工具了。但如果你要把它接入真實項目還有幾個細(xì)節(jié)可以繼續(xù)打磨。第一個是輸出格式多樣化。目前只輸出十六進(jìn)制但很多場景需要Base64格式的哈希值。Base64用4個字節(jié)表示3個字節(jié)的二進(jìn)制數(shù)據(jù)長度更短在URL參數(shù)和JSON字段里更省空間。把ArrayBuffer轉(zhuǎn)Base64可以用btoa配合二進(jìn)制拼接但要處理好大Buffer的分段問題避免參數(shù)過長導(dǎo)致瀏覽器報錯。第二個是復(fù)制功能與展示優(yōu)化。大多數(shù)用戶拿到哈希值后要立刻粘貼到別的地方所以頁面里加一個“復(fù)制結(jié)果”按鈕是比較自然的體驗。再往前一步可以給每條記錄加上時間戳和歷史記錄做成一個小的哈希工具箱。第三個是校驗?zāi)芰?。如果用戶手上有一段下載時拿到的期望哈??梢宰屗迟N到頁面里做自動比對比對一致就顯示綠色標(biāo)簽“校驗通過”。這個功能會讓工具從單純的計算器升級成校驗器實用性更強(qiáng)。第四個是批量文件處理。當(dāng)前實現(xiàn)一次只能處理一個文件如果用戶要校驗一個目錄下多個文件最好支持多文件選擇和批量哈希列表。這個擴(kuò)展在瀏覽器端的難點(diǎn)主要是文件選擇控件的多選支持以及大數(shù)據(jù)量下頁面的渲染性能。這些擴(kuò)展每一樣都不難但真正落地時會有一些交互細(xì)節(jié)問題。我這里不展開全部代碼先把思路列出來對你有參考價值。最后再分享一個小經(jīng)驗寫哈希工具時最好在頁面上留一個大小合適的測試大文件比如100MB左右方便隨時驗證性能。我在開發(fā)過程中反復(fù)用同一個文件測不同方案的耗時最后才確定了“小文件走Web Crypto大文件走Worker加流式實現(xiàn)”的決策。沒有實測數(shù)據(jù)方案選型就只能是空談這一點(diǎn)比任何理論都重要。