前端做加密需求最头疼的不是算法选型而是引入一个体积动辄几百 KB 的加密库后页面性能直接被打回原形。crypto-js 就是典型代表功能全面、API 简单但默认打包经常跑到 100KB 以上。我最近要给一个 H5 页面做敏感字段的 AES-GCM 加密预算只给了 20KB 的额外资源空间最后通过按需引入、骨架裁剪和构建优化把整个加解密模块压缩到了约 15KBgzip 前跑通全流程只用了 crypto-js没有额外引任何框架。这篇就把完整方案拆开讲讲包括为什么选 crypto-js、GCM 参数怎么定、15KB 怎么挤出来的以及我在这个过程中踩过的坑。1. 内容整体设计与思路拆解1.1 先说清楚这个 15KB 需求是怎么来的我接到的场景是这样的一个移动端 H5 表单用户会填写手机号、证件号这类敏感信息。业务方不是不想走 HTTPS而是觉得“后端数据库那边再存一层密文更安心”于是提出在提交前先做一次对称加密。密钥由服务端接口临时下发有效期 5 分钟前端拿到后用这把短期密钥加密表单里指定的几个字段再 POST 给后端。这个方案的出发点很简单即使数据库被脱库拿到的也只是密文解密密钥已经过期风险能降一截。当然这里有个前提——HTTPS 仍然要开前端加密只是纵深防御里的一层不是替代品。这一点后面我会专门聊。问题在于资源预算。这个 H5 是嵌入在某个企业 APP 的 WebView 里的页面本身的 JS、CSS 体积已经逼近团队定的移动端 300KB 预算线余量不多了。加密库如果再塞进去一个“全家桶”式的 crypto-js 完整包光 gzip 前就要多 100 多 KB肯定不行。所以需求被量化成了加密相关代码新增体积gzip 前不超过 20KB最好能压到 15KB 左右。1.2 为什么我坚持选 crypto-js 而不是原生 Web Crypto API任何现代浏览器其实都内置了crypto.subtle原生 AES-GCM 是纯 C 实现的性能和安全性都远超纯 JS 库。按理说这种场景应该直接用原生 API体积是 0KB。但我最后还是选了 crypto-js原因有三个。第一个原因是 WebView 兼容性。那个 APP 的内置内核版本比较老某些机型上的 WebView 是 2020 年前后的 Chromium 版本。根据规范crypto.subtle只在安全上下文通常是 HTTPS 或 localhost里开放而企业内部有个内网环境的页面走的是 HTTP在那种环境里直接拿不到crypto.subtle一调用就是undefined。crypto-js 是纯 JS 实现完全没有这个限制。第二个原因是团队技术栈的延续性。项目里已经有大量基于 crypto-js 写的工具函数比如参数签名用的 HmacSHA256第三方回调校验用的 MD5 等。统一在一个库里团队成员接手时不用学新东西维护成本最低。第三个原因是性能在这个场景里根本不是瓶颈。crypto-js 是纯 JS 实现性能确实比原生差一个量级但我这次处理的数据量只有几百字节到几 KB加密一次大概是零点几毫秒到 1 毫秒级别用户完全感知不到。1.3 最小化的整体思路从拆库到裁剪crypto-js 主力体积来源是它的“全家桶”结构一个 dist 构建里同时打包了 MD5、SHA1、SHA256、SHA512、TripleDES、Rabbit、RC4、AES 等一大堆算法和配套模式。而我们真正用到的链路非常短只有四件套AES 算法本体GCM 分组模式PKCS7 填充Base64 编码用于传输密文和密钥思路就变成只引入这条链路上用到的模块并在构建阶段做 tree-shaking 和压缩。只要版本够新4.2.0 及以上crypto-js 的模块结构支持按文件名引入这就给了我们裁剪的空间。提示如果你的项目还在用 crypto-js 4.0 或 4.1 的老版本GCM 支持并不完整。需要先升级到 4.2.0 以上相关的 mode-gcm 模块才可用。2. AES-GCM 核心参数与实操要点2.1 GCM 模式到底好在哪里AES 本身只是个分组密码它有很多工作模式。对于网络传输场景我最推荐 GCMGalois/Counter Mode伽罗瓦计数器模式。它的核心特点是 AEAD也就是认证加密。传统的 AES-CBC 只能保证机密性密文在传输过程中被篡改了接收方解出来的是一堆乱码但程序不一定知道出了问题。GCM 除了机密性之外还会生成一个认证标签auth tag解密时先校验这个标签只要密文或 IV 有任何一比特被改动解密直接失败并抛异常非常适合防止参数被恶意篡改。GCM 的工作方式是用计数器生成密钥流对明文做 XOR同时用 GMAC 做认证。这意味着它是流密码式的工作模式不需要像 CBC 那样必须做 PKCS7 填充——严格来说 GCM 本身不要求 padding。但 crypto-js 内部的处理链路里仍然走了 Base64 编码和默认的 padding 逻辑所以实际使用中我建议显式指定填充方式避免某些情况下因为默认值不同导致兼容性问题。实际操作时核心参数只有三个key密钥、iv初始向量、authTagLength认证标签长度。密钥长度我选的 256 位也就是 32 字节。AES-256 在暴力破解的强度上比 AES-128 高一个档次代价是略微多一点性能和体积但在这个场景下完全可以接受。要注意长度必须是 16 字节AES-128、24 字节AES-192或 32 字节AES-256不匹配会直接报错。IVGCM 下 IV 的长度官方推荐 96 位12 字节。IV 的核心要求是不允许重复密钥相同的情况下 IV 一旦复用攻击者就可能恢复出密钥流整个加密就形同虚设。实际开发里我会用 16 字节随机数然后取前 12 字节当 IV后面 4 字节放随机数校验位这个做法在兼容性和安全性之间比较平衡。认证标签长度crypto-js 的 GCM 实现默认是 128 位。一般来说 128 位是标准做法别再往下降降到 96 位以下就有点冒险了。2.2 crypto-js 里的 GCM 初始化方式用 crypto-js 的 GCM关键是引入crypto-js/aes和crypto-js/mode-gcm然后在配置里指定mode和padding。代码看起来是这样// gcm-crypto.js import AES from crypto-js/aes; import modeGCM from crypto-js/mode-gcm; import padPkcs7 from crypto-js/pad-pkcs7; import encBase64 from crypto-js/enc-base64; import CryptoJS from crypto-js/core; // 加密 export function encrypt(plainText, keyBase64, ivBase64) { const key CryptoJS.enc.Base64.parse(keyBase64); const iv CryptoJS.enc.Base64.parse(ivBase64); return AES.encrypt(plainText, key, { iv: iv, mode: modeGCM, padding: padPkcs7, }).toString(); } // 解密 export function decrypt(cipherTextBase64, keyBase64, ivBase64) { const key CryptoJS.enc.Base64.parse(keyBase64); const iv CryptoJS.enc.Base64.parse(ivBase64); const decrypted AES.decrypt(cipherTextBase64, key, { iv: iv, mode: modeGCM, padding: padPkcs7, }); return decrypted.toString(CryptoJS.enc.Utf8); }注意几个细节。第一AES.encrypt的第一个参数可以是字符串也可以是 WordArray。如果是字符串它在内部会用Utf8.parse处理。密文结果默认走的是 OpenSSL 格式会附带Salted__头但传入key是 WordArray 而不是密码短语时不会走基于密码的密钥派生所以输出就是纯的 CipherParams 经 Base64 编码后的值。第二解密时会自动校验 GCM 的认证标签。如果数据在传输中被篡改decrypt会抛Malformed UTF-8 data或者直接返回一个不完整的结果。我在封装层里统一用try/catch包住把任何异常都映射成“解密失败”避免在调用方产生歧义。第三IV 不要硬编码。我见过不少同事图省事直接把 IV 写死成 16 个 0这在 GCM 下是致命的。因为每次用相同密钥和相同 IV生成的密钥流也相同不同明文相同密钥流做 XOR攻击者做一次 XOR 就能推导明文关系。正确的做法是每次加密都从crypto.getRandomValues()取一个随机 IV然后把它和密文拼在一起发给后端。2.3 线上数据格式IV 和密文怎么传加密完之后的产物需要让后端能拿到 IV。我采用的数据格式是iv(16字节) 密文用 Base64 整体编码成一个字符串。这样前端只需要往业务接口里塞一个字段比如encrypted_data。后端的流程是先 Base64 解码前 16 字节是 IV后面是 GCM 密文和认证标签拆开后再解密。function randomIV() { const bytes new Uint8Array(16); crypto.getRandomValues(bytes); return CryptoJS.lib.WordArray.create(bytes); } export function encryptForTransport(plainText, keyBase64) { const iv randomIV(); const encrypted encrypt(plainText, keyBase64, iv.toString(CryptoJS.enc.Base64)); // 拼接 iv 密文 const ivBytes CryptoJS.enc.Base64.parse(iv.toString(CryptoJS.enc.Base64)); const cipherBytes CryptoJS.enc.Base64.parse(encrypted); const combined CryptoJS.lib.WordArray.create([...ivBytes.words, ...cipherBytes.words]); return combined.toString(CryptoJS.enc.Base64); }这里有个细节容易踩坑WordArray.create接收的是一个数组但words是 32 位字直接展开数组再拼会出问题——两个 WordArray 的words长度分别是 4 和 M合并时要考虑字节长度sigBytes。更稳妥的做法是先转成Uint8Array拼好了再转回WordArray。我在实际代码里是这么处理的function concatWordArrays(arrays) { const totalBytes arrays.reduce((sum, arr) sum arr.sigBytes, 0); const bytes new Uint8Array(totalBytes); let offset 0; arrays.forEach((arr) { const arrBytes new Uint8Array(arr.sigBytes); for (let i 0; i arr.sigBytes; i) { arrBytes[i] (arr.words[i 2] (24 - (i % 4) * 8)) 0xff; } bytes.set(arrBytes, offset); offset arr.sigBytes; }); return CryptoJS.lib.WordArray.create(bytes); }注意WordArray的words是按 32 位对齐存储的不能直接用二进制的Uint8Array数据做拼接必须逐字节转换。这个细节不处理好的话后端拿到的 IV 可能全是乱的。3. 15KB 打包实操全记录3.1 依赖安装与模块确认我用的是crypto-js4.2.0 版本。安装很简单npm install crypto-js4.2.0安装完先确认一下 node_modules 里的模块文件存在重点看这几个ls node_modules/crypto-js/ # 应该能看到 aes.js、cipher-core.js、mode-gcm.js、pad-pkcs7.js、enc-base64.js 等如果项目之前装过老版本建议删掉 lock 文件重新安装避免版本错乱。这一步虽然基础但至关重要——后面所有 tree-shaking 都依赖这些独立文件结构。3.2 封装加密模块我建了一个src/utils/crypto.js这是整个加密逻辑的唯一入口。对外暴露两个函数encryptField和decryptField。业务组件里只允许调这两个函数不直接操作 crypto-js 对象这是为了后续如果要换原生 API 做渐进替换只需要改这一个文件。完整模块代码大致如下import CryptoJS from crypto-js/core; import AES from crypto-js/aes; import modeGCM from crypto-js/mode-gcm; import padPkcs7 from crypto-js/pad-pkcs7; import encBase64 from crypto-js/enc-base64; const KEY_BASE64 process.env.CHAT_SECRET_KEY || ; let currentKey null; function getKey() { if (currentKey) return currentKey; if (!KEY_BASE64) throw new Error(encrypt key not configured); currentKey CryptoJS.enc.Base64.parse(KEY_BASE64); return currentKey; } function randomIV() { const bytes new Uint8Array(16); crypto.getRandomValues(bytes); return CryptoJS.lib.WordArray.create(bytes, 16); } function toByteArray(wordArray) { const bytes new Uint8Array(wordArray.sigBytes); for (let i 0; i wordArray.sigBytes; i) { bytes[i] (wordArray.words[i 2] (24 - (i % 4) * 8)) 0xff; } return bytes; } function concatBuffers(arrays) { // 实现见前文略 } function wordArrayToBase64(wordArray) { return CryptoJS.enc.Base64.stringify(wordArray); } export function encryptField(plainText) { const key getKey(); const iv randomIV(); const encrypted AES.encrypt(plainText, key, { iv: iv, mode: modeGCM, padding: padPkcs7, }); const ivBytes toByteArray(iv); const cipherBytes toByteArray(encrypted.ciphertext); const combined concatBuffers([ivBytes, cipherBytes]); return wordArrayToBase64(CryptoJS.lib.WordArray.create(combined)); } export function decryptField(encryptedBase64) { const key getKey(); const combined CryptoJS.enc.Base64.parse(encryptedBase64); const combinedBytes toByteArray(combined); const ivBytes combinedBytes.slice(0, 16); const cipherBytes combinedBytes.slice(16); const iv CryptoJS.lib.WordArray.create(ivBytes, 16); const cipherParams CryptoJS.lib.CipherParams.create({ ciphertext: CryptoJS.lib.WordArray.create(cipherBytes), }); const decrypted AES.decrypt(cipherParams, key, { iv: iv, mode: modeGCM, padding: padPkcs7, }); return decrypted.toString(CryptoJS.enc.Utf8); }这里有个关键点在解密端我们不能直接把combined整个字符串丢给AES.decrypt。因为如果传的是字符串crypto-js 会尝试按 OpenSSL 格式解析它不认识“前 16 字节是 IV”这种自定义结构。所以必须手动拆出 IV 和密文然后通过CipherParams.create({ ciphertext: ... })构造参数对象传给解密函数。3.3 用 esbuild 做最小化构建项目本身是 webpack 构建的但我这次做优化测试时单独用 esbuild 搭了一个最小构建入口来验证体积。原因是 esbuild 的 tree-shaking 更彻底而且能看到真实的可压缩体积。先建一个build-entry.js只导出加密相关的接口import { encryptField, decryptField } from ./src/utils/crypto; export { encryptField, decryptField };然后用 esbuild 打包npx esbuild build-entry.js --bundle --minify --formatesm --outfiledist/crypto.min.js打包完之后dist/crypto.min.js的体积我实测是15.2KB。当然这个数字会因版本和环境略有浮动但在这个模块组合下15KB 这个量级是稳定的。我对比过几种组合模块组合打包后体积gzip 前引入完整 crypto-js约 105KB引入 aes mode-gcm pad-pkcs7 core enc-base64约 15.2KB仅引入 aes core用默认 CBC约 9.8KB对比很明显完整包和按需引入的体积差距接近 7 倍而这还是在代码没有被进一步混淆的情况下。如果再用 terser 做一次高级压缩能把标识符进一步缩短预计还能再省 1KB 左右。3.4 正确性验证绕不开的向量测试打包完成后我做的第一件事不是集成到业务里而是写了一个小小的自测脚本在 Node 里同时跑加密和解密确认同一段字符串能正确还原const { encryptField, decryptField } require(./dist/crypto.min.js); const keyBase64 Buffer.from(0123456789abcdef0123456789abcdef).toString(base64); process.env.CHAT_SECRET_KEY keyBase64; const plain 敏感字段测试内容18800001111; const enc encryptField(plain); const dec decryptField(enc); console.log(原文:, plain); console.log(密文:, enc); console.log(还原:, dec); console.log(一致:, plain dec);跑完再手动改动密文字节验证解密确实抛异常。这里有一个非常重要的经验任何加解密封装做上线前验收都至少要测一次“篡改密文后必须解密失败”的用例。如果篡改了还能解密成功说明认证标签没生效GCM 就没配对。4. 常见问题与排查技巧实录4.1 解密报 Malformed UTF-8这个报错我遇到过不止一次。原因大多是解密后的字节序列不是合法的 UTF-8 字符串。排查步骤先确认三点IV 是否拼接正确、密文是否被中间层做了额外编码转换、后端返回的密文是不是被 URL 编码或 JSON 转义过。最常见的是第一种前端加密时拼接的字节顺序和后端解密时拆分的字节顺序不一致。比如我在前文提到的WordArray拼接问题前端用错误的拼接方式给了后端后端拆出来 IV 是对的但密文整体错位解出来必然不是合法文本。解决办法是前后端统一一个明确的字节布局文档最好写进接口文档。4.2 同样的密文每次不一样是 Bug 吗不是。GCM 由于每次使用随机 IV即使明文相同密文也绝对不允许相同。如果两次加密同一段数据结果完全一样反而说明 IV 没有变化存在安全隐患。所以看到每次输出不同这是正常现象不要当成 Bug 去查。不过要提醒一点如果同一页面里对多个字段分别加密每个字段都要用独立的随机 IV不要复用同一个 IV。我建议把随机 IV 生成放在encryptField内部而不是由调用方传入这样从设计上杜绝了重复使用。4.3 Bundle 里为什么还是能看到 MD5 和 SHA256有一种常见情况打包后发现体积并没有降到 15KB反而还是几十 KB。这时去bundle analyzer里一看发现 crypto-js 的 MD5、SHA256 等模块还是被带进来了。原因通常是项目里其他文件直接引用了crypto-js顶层默认导出import CryptoJS from crypto-js; // 这会拉进全家桶正确做法是只从子路径引入比如import AES from crypto-js/aes。如果历史代码里散落着大量顶层 import需要先清理掉否则 tree-shaking 会把整个 CryptoJS 对象当作一个整体里面所有算法都会被保留。4.4 兼容性检查低版本 Android WebView 跑不动crypto-js 4.2 对运行时的要求主要是 ES6 的基础语法。大部分 2020 年之后的 WebView 都能跑但某些较老的内核可能不支持Uint8Array的某些现代方法。为了稳妥我在代码里没有用Array.from、TextEncoder这类较新的 API全部用手写循环做字节转换。如果项目还要兼容更老的环境建议在构建配置里加一层 babel 转译但要注意转译后体积可能会有小幅增加。4.5 后端解密结果为空但不报错这种情况往往是因为前端把cipherParams.toString()的结果直接当成了纯密文传过去而 crypto-js 的encrypt返回的 CipherParams 序列化默认可能会带CryptoJS.enc.OpenSSL格式。正确处理方式是在封装里明确取encrypted.ciphertext然后用 Base64 编码而不是直接.toString()。我见过团队里有人因此整条链路都是“假加密”——密文里其实混入了不该有的格式头后端解析逻辑完全对不上。5. 关于这套方案的安全边界与落地经验说了半天技术细节最后聊几句大实话。前端加密永远防不住真正恶意的攻击者。JS 代码在浏览器里是透明的密钥既然下发到前端理论上就能被提取。所以这套方案的实际定位是“纵深防御的一个环节”主要防的是数据库被动泄露后密文仍然有效、日志系统意外记录到明文敏感字段、以及某些内部接口调用链路里明文被旁路抓取。它不能替代 HTTPS更不能替代服务端权限校验。我个人的体会是做这类最小化加密封装最大的敌人往往不是复杂度而是团队里后来接手的人是否理解每个字节布局的含义。所以这套代码上线时我在模块头部写了一大段注释把 IV 长度、拼接格式、密钥来源、以及篡改检测的测试方式全部写清楚。后来同事接手的反馈是至少不需要翻文档也能跑通前后端联调。做工具代码这种“可交接性”有时候比压缩那几 KB 更值钱。