接手这个需求前,我正好在给一个 Vue3 项目做登录模块改造,同事把网上拷下来的 crypto-js.js 直接丢进 src/utils 里,然后用import * as CryptoJS from './crypto-js.js'引入。结果npm run dev一启动,控制台就飘红,加解密功能全线崩掉。这类报错在 Vue3 + Vite 项目里出现得相当频繁,而且报错信息五花八门——有说是模块导出的问题,有说是文件解析失败,甚至有人编译能过但运行时报CryptoJS is not defined。这篇文章就把这个报错的来龙去脉、底层原因和几种靠谱的解法一次讲清楚,适合所有在 Vue3 / Vite 项目里使用 CryptoJS(或类似 CJS/UMD 库)的前端开发者,尤其是刚把项目从 Vue2 迁移过来或者习惯直接本地引 JS 文件的同学。
1. 先还原一下现场:你看到的报错大概率是这几种
1.1 典型报错信息长什么样
先说最常见的几类报错。如果你用的是 Vite 作为开发服务器,把 crypto-js.js 放在项目里用import * as CryptoJS from './crypto-js.js'引入,大概率会撞上以下情况:
| 报错场景 | 典型提示 | 发生阶段 |
|---|---|---|
| 浏览器原生 ESM 加载本地 UMD 文件 | Uncaught TypeError: CryptoJS.AES is not a function | 运行时报错 |
| Vite 构建时找不到导出 | Module "./crypto-js.js" does not provide an export named "default" | build 阶段 |
| Vue3 + Vite 对非 ESM 文件告警 | DeprecationWarning: The legacy JS API is deprecated或Module is not exported | dev / build 均可能 |
| TypeScript 项目补声明文件报错 | Could not find a declaration file for module './crypto-js.js' | 类型检查阶段 |
很多人在这一步就懵了,因为同样的写法放在 Vue2 + Webpack 项目里是能跑的,为什么到了 Vue3 + Vite 就翻车?这就要从项目底层的模块机制说起。
1.2 为什么偏偏是 Vue3 项目容易踩这个坑
Vue3 的官方脚手架默认使用 Vite 作为构建工具,而 Vite 在开发环境下走的是浏览器原生的 ES Module 加载链路,生产构建走的是 Rollup。这两条链路对 CommonJS(CJS)和 UMD 格式的第三方文件支持情况,和 Webpack 时代完全不同。
Webpack 在打包时会把所有模块统统经过一层垫片处理(比如commonjs插件),不管文件是 CJS、UMD 还是 AMD,webpack 都能帮你抹平差异,所以你在 Webpack 里写import * as CryptoJS from './crypto-js.js'往往没问题。但 Vite 为了追求极致的启动速度,开发阶段不会做全量预编译,它把模块解析的活儿交给了浏览器。浏览器原生 ESM 对 CJS/UMD 文件的理解能力非常有限,一旦文件里没有export语句,import * as Research拿到的就是一个空命名空间对象,你说能不报错吗?
这个认知是解决整个问题的钥匙:不是 CryptoJS 库本身坏了,也不是 Vue3 不能加密了,而是你的引入方式和 Vite 的模块处理机制不匹配。
2. 揪出根因:ESM、CJS、UMD 之间的“跨服聊天”
2.1 三种模块格式的区别,用一个外卖例子讲明白
ES Module(ESM)、CommonJS(CJS)和 UMD 是前端世界里最常见的三种模块规范。它们的关系可以拿点外卖来类比:
- CJS 是“到店自取”:文件里通过
module.exports = xxx把东西交给 Node.js 环境,调用方用require()去拿。这种方式在 Node.js 服务端很好使,但浏览器原生不认识。 - ESM 是“外卖配送”:文件里通过
export default xxx/export { xxx }显式声明对外提供的商品,调用方用import接收。浏览器原生支持,也是 Vite 主推的格式。 - UMD 是“多平台通用券”:它同时兼容 CJS、AMD 和全局变量挂载三种场景,代码开头一般是一段判断环境的
if / else,最后把库挂到一个全局对象上。
CryptoJS 的源码(尤其是网上流传的本地单文件版本)绝大多数是 UMD 格式。UMD 文件在 ESM 环境下导入时,浏览器会把它当做一个没有任何导出的模块来处理——因为文件里根本没有export关键字。而import * as CryptoJS from './crypto-js.js'这种写法要求目标模块必须有明确的命名导出,否则得到的命名空间就是一个空壳。
2.2 import * as 到底拿回了什么
import * as CryptoJS的语义是:把目标模块的所有命名导出聚合成一个命名空间对象。如果模块里只有export default,那你会拿到{ default: xxx };如果模块一个导出都没有,拿到的就是{}。
真相是,本地 UMD 文件里写的是module.exports = factory()这种 CJS 导出方式。在原生 ESM 环境下,浏览器不认识module这个全局变量,于是这个文件会被判定为“无导出模块”,import * as CryptoJS最终得到空对象。你在代码里写CryptoJS.AES.encrypt(...),本质上是给undefined取属性,不报TypeError才怪。
2.3 Vite 不是没努力,是只帮到一半
有人会问:Vite 不是有预构建功能吗,它会把 CJS 依赖自动转成 ESM 啊?没错,但这里有两个前提:第一,预构建只针对node_modules里的依赖,也就是通过import CryptoJS from 'crypto-js'这种包名导入的模块;第二,预构建基于依赖扫描,本地相对路径引入的./crypto-js.js不在默认扫描范围里。
所以你在本地import * as CryptoJS from './crypto-js.js',Vite 基本就是原样把文件交给浏览器,浏览器读不懂 UMD,于是报错。理解了这一层,后面的解决方案栏位就顺理成章了。
3. 三个可落地的解决方案,按推荐程度排序
3.1 方案一:放弃本地文件,改用 npm 包导入(最推荐)
最简单的做法就是用 npm 安装 crypto-js,然后用默认导入方式引入:
npm install crypto-jsimport CryptoJS from 'crypto-js' const secretKey = '0123456789abcdef0123456789abcdef' const encrypted = CryptoJS.AES.encrypt('hello world', CryptoJS.enc.Utf8.parse(secretKey), { mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.Pkcs7 }).toString()为什么这个能行?因为crypto-js在 npm 上是 CJS 格式,Vite 的依赖预构建机制会把它自动转换为浏览器可识别的 ESM 模块,转换后模块会有default导出,所以import CryptoJS from 'crypto-js'拿到的就是整个库对象。这种写法在任何 Vue3 + Vite 项目里都稳定可用。
这里有个细节值得注意:import * as CryptoJS from 'crypto-js'和import CryptoJS from 'crypto-js'是完全不同的两种语义。前者拿命名空间,后者拿 default 导出。crypto-js 这个包在 Vite 预构建后实际提供的是 default 导出,你用import * as拿到的命名空间对象里往往只有一个default属性,直接调用CryptoJS.AES还是不行。所以要么用默认导入,要么用命名空间包一层:
import * as CryptoJSModule from 'crypto-js' const CryptoJS = CryptoJSModule.default || CryptoJSModule这一步“兼容垫底”的做法,是处理那些默认导出不规范的 npm 包时常用的保底方案。
3.2 方案二:按需引入子模块,进一步瘦身
如果觉得整个 crypto-js 包体积太大(压缩前接近数百 KB),可以按模块路径引入。crypto-js 的 npm 包结构里每个算法都是独立文件,比如crypto-js/aes、crypto-js/md5、crypto-js/sha256。
import AES from 'crypto-js/aes' import encUtf8 from 'crypto-js/enc-utf8' import padPkcs7 from 'crypto-js/pad-pkcs7' import modeEcb from 'crypto-js/mode-ecb'注意这里也必须用 default 导入,而且每个子模块自己又依赖了核心文件(core、cipher-core 等),Vite 在预构建时会把整条依赖链处理好。这种方式的好处是最终打出来的包只包含你用到的算法,坏处是对导入路径的准确性要求高,漏了任何配套模块都会导致运行时报错。
一个典型场景:你只引入 AES,但忘记引入crypto-js/mode-ecb和crypto-js/pad-pkcs7,默认情况会走 CBC 模式和 Pkcs7 padding,可一旦你显式传了mode: CryptoJS.mode.ECB,就会因为CryptoJS.mode不存在而挂掉。所以用按需引入的话,建议把需要用的 mode、pad、enc 模块一次性配齐。
3.3 方案三:如果你非得用本地 js 文件,这样改才能救
有些项目因为内网环境装不了 npm 包,或者历史代码已经引了本地 crypto-js.js,这时候可以尝试改造文件本身。核心思路是给这个 UMD 文件补一个 ESM 导出,把它的内容导入后重新导出为默认导出。
具体操作:在crypto-js.js同目录下新建一个crypto-js-wrapper.js,内容如下:
import * as CryptoJSModule from './crypto-js.js' export default CryptoJSModule.default || CryptoJSModule然后业务代码里改为:
import CryptoJS from './crypto-js-wrapper.js'这种包装方式利用了浏览器/打包器对 CJS 模块的兼容处理,在很多场景下能绕过“无导出”的问题。但这个方法不是百分百可靠,因为本地 UMD 文件在原生 ESM 环境下,module对象本身就不存在,module.exports这段逻辑压根不会执行。如果你试了包装还是报错,那就只能走下一招。
3.4 方案四:script 标签全局挂载(最暴力但最稳)
本地文件实在转不了 ESM,直接在 HTML 里用script标签引入,让它挂到全局对象上,业务代码从window.CryptoJS取。
在index.html的<head>里加一行:
<script src="/src/vendor/crypto-js.js"></script>然后业务代码里:
const CryptoJS = window.CryptoJS const encrypted = CryptoJS.AES.encrypt('hello', 'secret').toString()这招的本质是绕开模块系统,让 UMD 文件走它最熟悉的“全局变量挂载”分支。缺点很明显:没有模块依赖关系,类型提示缺失,代码复用也差。适合作为临时救火方案,不建议作为长期架构。
4. 实操代码:在 Vue3 里正确封装 AES 加解密工具
4.1 一个可以直接抄的加解密模块
下面我给你一个我在实际项目中常用的封装,基于方案一的 npm 包写法,适用于与后端 Java / Go / Python 进行 AES 加密数据对接的场景:
// src/utils/crypto.js import CryptoJS from 'crypto-js' const AES_KEY = 'your-16-char-key' // 后端约定的 16/24/32 字节密钥 const AES_IV = 'your-16-char-iv' // 初始化向量,通常也是 16 字节 function encrypt(plainText) { const key = CryptoJS.enc.Utf8.parse(AES_KEY) const iv = CryptoJS.enc.Utf8.parse(AES_IV) const encrypted = CryptoJS.AES.encrypt(plainText, key, { iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }) return encrypted.toString() } function decrypt(cipherText) { const key = CryptoJS.enc.Utf8.parse(AES_KEY) const iv = CryptoJS.enc.Utf8.parse(AES_IV) const decrypted = CryptoJS.AES.decrypt(cipherText, key, { iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }) return CryptoJS.enc.Utf8.stringify(decrypted).toString() } export { encrypt, decrypt }使用起来很简单:
<template> <div> <input v-model="rawText" placeholder="待加密内容" /> <button @click="doEncrypt">加密</button> <p>密文:{{ cipherText }}</p> <button @click="doDecrypt">解密</button> <p>明文:{{ recoveredText }}</p> </div> </template> <script setup> import { ref } from 'vue' import { encrypt, decrypt } from '@/utils/crypto' const rawText = ref('hello crypto-js') const cipherText = ref('') const recoveredText = ref('') function doEncrypt() { cipherText.value = encrypt(rawText.value) } function doDecrypt() { recoveredText.value = decrypt(cipherText.value) } </script>4.2 参数选择的背后逻辑
很多人直接抄 CryptoJS 示例时会有疑惑:为什么用了CryptoJS.enc.Utf8.parse(key),而不是直接把字符串传进去?原因在于 CryptoJS 处理字符串时默认会做一次 OpenSSL 兼容的密码散列(基于 MD5 的 Key Derivation),最终拿到的密钥和你传入的字符串不是同一回事。
如果你希望把密钥当作真正的字节密钥来用(绝大多数后端语言都是严格按字节处理密钥的),就必须先用CryptoJS.enc.Utf8.parse把字符串转成 WordArray。不这么做的话,加密结果会变成“随机盐 + 散列推导密钥”的 OpenSSL 格式,后端拿到后没办法直接用原始密钥解密。
隐私和安全方面需要提醒:AES 密钥和初始化向量不要硬编码在前端代码里,尤其是在生产环境。前端加密本质上只能防“不经意的查看”,防不了专业抓包分析。如果你做的是强安全需求的业务(比如支付、隐私数据),建议密钥由服务端下发、HTTPS 传输,并定期轮换。前端只承担临时加解密职责,不要把核心密钥写死在仓库里。
4.3 与后端联调时最常见的三个坑
联调过程中我遇到最多的三类问题,这里一并列出来:
| 坑点 | 现象 | 解决办法 |
|---|---|---|
| 密钥编码不一致 | 后端解密乱码 | 统一用 Utf8.parse,且 key/iv 长度必须匹配算法要求(AES-128 对应 16 字节) |
| 密文格式不匹配 | 后端 Base64 解析失败 | CryptoJS 默认toString()输出的是 Base64 字符串,确认后端按 Base64 解码;Hex 格式用CryptoJS.enc.Hex.stringify |
| padding 模式不一致 | 解密成功但末尾有乱码 | 大多数场景用 Pkcs7,后端常用 PKCS5Padding(AES 下等价),注意不要一端 Pkcs7 一端 ZeroPadding |
这些坑和“import 报错”看起来无关,但实际是换用正确引入方式后紧接着会出现的问题,提前知道能省不少联调时间。
5. 问题排查速查表与避坑经验
5.1 遇到不同报错按这个表处理
| 报错信息 | 可能原因 | 处理动作 |
|---|---|---|
CryptoJS.AES is not a function | import * as拿到空命名空间 | 改为默认导入,或走 npm 包方案 |
Module does not provide an export named 'default' | 引入的 CJS 文件没有 default 导出 | 使用import * as再取.default,或改用crypto-jsnpm 包 |
SyntaxError: The requested module does not provide an export named... | 本地文件未做 ESM 改造 | 参考方案三做包装模块 |
crypto-js’ is not defined | script 标签还没加载完成就执行了代码 | 把<script>放在 body 末尾,或使用window.addEventListener('load', ...) |
| 类型声明缺失报错 | TS 找不到声明文件 | npm install @types/crypto-js |
上面表格中的处理动作大多可以在 5 分钟内验证效果,我建议按“方案一 → 方案四”的顺序逐个尝试,而不是一上来就改文件内容,那样往往越改越乱。
5.2 几个真实踩坑的记录
我在实际项目里确实折腾过不少次,分享几个典型的细节:
第一次折腾本地 crypto-js.js 时,我试着在文件末尾手动补了一行export default CryptoJS。结果文件内部顶层作用域里并没有CryptoJS这个变量——它是 UMD 工厂函数里的局部变量。所以这种“手动补导出”的做法大概率会得到CryptoJS is not defined。正确的改造方式是:要么把整个 UMD 工厂函数改成const CryptoJS = (function...)(...)再导出,要么干脆放弃本地文件。
还有一次在 Vite 构建时遇到警告:Module "crypto-js" has been externalized for browser compatibility。这个警告的意思是因为某些配置把 crypto-js 当成了外部模块,这通常和resolve.alias或optimizeDeps.exclude的配置有关。如果你在 vite.config.js 里加了optimizeDeps.exclude: ['crypto-js'],把它去掉就好了,因为排除预构建会让浏览器直接加载 CJS 源码,同样会触发报错。
再一个容易被忽略的点:import * as CryptoJS在较新版本的 Vite 里可能并不会直接报错,而是给你一个空空如也的对象。等你在onMounted里调用时才发现问题,这种问题排查起来反而更费劲。所以写完代码后,建议先console.log(CryptoJS)看一眼是不是拿到了真实对象,再往下写业务逻辑。
5.3 要不要干脆换成 Web Crypto API
既然 CryptoJS 在 Vue3 里这么折腾,有人会问:为什么不直接用浏览器原生的 Web Crypto API?我的看法是:新项目如果能接受非对称加密、需要追求性能或极简依赖,Web Crypto API 是个不错的选择,它不需要安装任何依赖,原生支持 AES-GCM、AES-CBC、SHA 系列等。但它的 API 风格非常底层,全是 Promise 和 BufferSource,前端封装成本不小,而且和“老后端约定”的对接往往要额外适配。
如果项目里已经有一堆用 CryptoJS 写好的加密逻辑,后端也已按这个格式对接完成,我个人建议还是用 CryptoJS 方案,把精力放在正确引入和参数统一上。这种“已有大量历史代码和联调约定”的情况,迁移成本远大于保留成本。
根据我的实际经验,最简单稳妥的组合是:npm 安装 crypto-js + 默认导入 +@types/crypto-js类型声明。你可以在入口文件里先试一下console.log(CryptoJS)是否输出对象,确认没问题再写加密逻辑。以前我在项目里统一把加解密封装到一个独立模块,业务代码只管调函数,不关心 CryptoJS 的引入细节。这样即使以后要切换到 Web Crypto API,改动范围也只在封装层,不会影响业务调用侧。
最后再分享一个小技巧:排查这类模块导入问题时,先看文件格式,再确认构建工具版本,最后再检查调用方式。把这三层问题分开定位,比盯着报错信息本身瞎猜要高效得多。希望这篇经验能帮你少走几步弯路。