前言
看过我之前文章的应该知道我们学校为了降低成本选择自主开发考试系统,并对小编所在的实验室委以重任,因为其学习成本低且轻量,并且考虑到考试系统项目的规模选择 mobx 作为数据管理,众所周知 mobx 本身并不能对数据实现持久化,只能将数据存储到 localStorage、sessionStorage、indexDB 等,本文将介绍小编如何从 localstorage -> indexDB,并对试卷信息进行加密。
indexDB 存储
由于 mobx 本身的特性,所以最开始先将数据暂存于 localstorage,但是 localstorage 本身有内存限制,大约为 5MB,计算一套试卷在 localstorage 中存储的大小

可以看到计算的结果换算后大概 0.9MB ,已经占用了五分之一的存储空间。所以决定转为使用 indexDB 存储,优点:存储量提高,数据类型支持广泛、异步不阻塞同步任务,支持索引及查询。
初始化数据库:
ts
const DB_NAME = 'ExamDB'; // 定义数据库名称
const DB_VERSION = 1; // 数据库版本号
let db: IDBDatabase | null = null;
export const initDB = (storeNames: string[]): Promise<void> => {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
// 数据库版本升级监听
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
for (let i = 0; i < storeNames.length; i++) {
if (!db.objectStoreNames.contains(storeNames[i])) {
db.createObjectStore(storeNames[i], { keyPath: `id` });
}
}
};
// 数据库连接成功
request.onsuccess = (event) => {
db = (event.target as IDBOpenDBRequest).result;
resolve();
};
request.onerror = (event) => {
reject(`IndexedDB 打开失败`);
};
});
};
数据库建立成功后,还需要能够写入数据的函数:
ts
export const saveToIndexedDB = <T>(storeName: string, data: T): Promise<void> => {
return new Promise((resolve, reject) => {
if (!db) return reject('数据库未初始化');
try {
const serializableData = JSON.parse(JSON.stringify(data));
const transaction = db.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
const payload = { id: 'default', data: serializableData };
const request = store.put(payload);
request.onsuccess = () => resolve();
request.onerror = () => {
reject(`写入 ${storeName} 失败`);
};
} catch (error) {
reject('数据无法序列化');
}
});
};
既然需要写,那还需要能读取数据的函数:
ts
export const loadFromIndexedDB = async <T>(storeName: string): Promise<T | null> => {
return new Promise((resolve, reject) => {
if (!db) return reject('数据库未初始化');
const transaction = db.transaction([storeName], 'readonly');
const store = transaction.objectStore(storeName);
const request = store.get('default');
request.onsuccess = (event) => {
const result = (event.target as IDBRequest).result;
if (result?.data) {
resolve(result.data as T);
} else {
resolve(null);
}
};
request.onerror = () => {
reject(`读取 ${storeName} 失败`);
};
});
};
还需要提供一个可以重置数据库数据的函数:
ts
export const clearIndexedDBStore = (storeName: string): Promise<void> => {
return new Promise((resolve, reject) => {
if (!db) return reject('数据库未初始化');
const transaction = db.transaction([storeName], 'readwrite');
const store = transaction.objectStore(storeName);
const request = store.delete('default');
request.onsuccess = () => resolve();
request.onerror = () => reject(`清除 ${storeName} 失败`);
});
};
初始化 -> 写入数据 -> 读取数据 -> 清除数据的函数已经完成,接下来就是使用时机
ts
import { makeAutoObservable, reaction} from "mobx";
import { initDB,saveToIndexedDB, loadFromIndexedDB, clearIndexedDBStore }
class Store{
constructor() {
makeAutoObservable(this);
this.init();
// 数据发生变化时自动保存
reaction(
() => JSON.stringify(this),
async () => {
await saveToIndexedDB('examStore', data); // 向数据库写入当前store数据,实时更新
}
);
}
async init() {
await initDB(['Store']); // 确保数据库已经初始化并连接
const savedData = await loadFromIndexedDB('Store'); // 读取数据
if (savedData) {
this.restoreFrom(savedData); // 将数据填充到当前的store里
}
}
转移后效果如图:
这时甲方看到试卷信息明文展示之后暴跳如雷,要求试卷信息不公开展示,这可是摇钱树...咳咳,那应该如何对 indexDB 里的数据进行加密呢?
敏感内容加密
常见的加密方式:
一、使用 JavaScript 库进行加密
-
常见库: CryptoJS, Forge
-
优点:
- 易用性高: 这些库提供了丰富的 API,开发者可以方便地调用各种加密算法,例如 AES、RSA、MD5 等。
- 功能强大: 除了基本的加密解密功能,这些库还提供了其他安全功能,例如哈希、消息认证码 (MAC) 等。
-
缺点:
- 加密强度受限: 由于 JavaScript 运行在客户端浏览器中,其计算能力和安全性都受到限制,因此加密强度可能不如后端加密。
- 性能开销较大: 加密解密操作会消耗一定的 CPU 和内存资源,
可能会影响网页的性能
。 - 兼容性问题: 不同浏览器对 JavaScript 的支持程度不同,可能会导致兼容性问题。
二、使用 Web Crypto API 进行加密
-
优点:
- 安全性高: Web Crypto API 是浏览器原生支持的加密 API,其安全性得到了保证。
- 性能较好: 由于是
浏览器原生 API,其性能优于 JavaScript 库
。 - 兼容性较好: 主流浏览器都支持 Web Crypto API。
-
缺点:
- 学习成本较高: Web Crypto API 的 API 设计较为复杂,学习成本较高。
- 功能相对简单: 相比 JavaScript 库,Web Crypto API 提供的功能相对简单,例如不支持 MD5 等哈希算法。
三、使用 HTTPS 协议传输加密数据
-
优点:
- 安全性高: HTTPS 协议使用
TLS/SSL
加密传输数据,可以有效防止数据在传输过程中被窃取或篡改
。 - 部署简单: 只需要在服务器端配置 HTTPS 证书即可,无需修改前端代码。
- 兼容性好: 所有支持 HTTP 协议的浏览器都支持 HTTPS 协议。
- 安全性高: HTTPS 协议使用
-
缺点:
- 无法加密所有数据: HTTPS 协议只能加密传输过程中的数据,无法加密存储在客户端的数据。
- 性能开销较大: HTTPS 协议需要进行加密解密操作,会
增加一定的网络延迟
。
由于时间紧任务重,小编选择使用 CryptoJS 对试卷信息进行加密
常见的加密算法有以下几种:
- MD5算法:不可逆的哈希函数,无法直接解密。(浏览器缓存应该用的就是 MD5)
常用于完整性校验的场景,安全性较低(彩虹表攻击,可通过加盐算法防止)。
- SHA-256算法:同样是不可逆的,无法直接解密,无法防止数据被修改。
安全性较高、计算较快,但不可逆和无法防止数据被篡改仍是痛点。
- AES 对称加密:安全性高,计算快,但密钥管理复杂,无法验证数据来源。
- RSA 非对称加密:安全性高,计算速度慢,密钥长
这里不过多介绍,有兴趣的可移步:聊聊前端常见的数据加密你是否有过这样的经历?在注册一个新网站时,需要填写个人信息,例如姓名、邮箱、甚至身份证号码。你可能 - 掘金
如何选择正确的加密方式,虽然对称加密和非对称加密安全性高,但是需要维护密钥,mobx 本身特性无法对数据实现持久化,存储密钥 = 暴露密钥,将密钥缓存在内存(内存缓存策略),当用户每次进入系统时都需要进行一次密钥生成和数据加密,要知道加密数据是会影响性能的,如果每次都进行加密,造成的性能损耗大。
在用户注册后,是不是就存在一个保密固定值并且只有用户本人才知道的密码,可以利用这个特殊字段对密钥进行二次封装:
方案
- 用户设置密码时生成随机盐(Salt)
- 使用 PBKDF2 从密码派生出密钥加密密钥(KEK)并缓存
- 生成随机数据加密密钥(DEK)
- 用 KEK 加密 DEK 后存储
流程图如下:

为什么需要单独生成一个密钥(DEK)用来加密信息,而不用 PBKDF2 派生的密钥直接加密信息?
- 用户密码发生改变,只需用重新派生的 KEK 加密 DEK,无需将所有数据重新加密
- 可以定期更新 DEK,不影响用户密码
- 密钥分离,不同密钥分配给不同用途
实现如下:
生成盐值和派生密钥:
js
import CryptoJS from 'crypto-js'
let keyDataObject = null; // 内存缓存策略
// 生成随机盐
function generateSalt(length = 16) {
return CryptoJS.lib.WordArray.random(length).toString();
}
// 使用 PBKDF2 派生密钥
function deriveKey(password, salt, iterations = 100000) {
const key = CryptoJS.PBKDF2(password, salt, {
keySize: 256/32,
iterations: iterations
});
return key;
}
用户首次设置密码时(注册):
js
// 生成加密密钥(KEK)和数据加密密钥(DEK)
export function initKey(password) {
if (!password) return;
const salt = generateSalt();
const kek = deriveKey(password, salt).toString(); // 派生密钥加密密钥(KEK)
const dek = CryptoJS.lib.WordArray.random(256/8); // 生成数据加密密钥(DEK)
// 使用 KEK 加密 DEK
const encryptedDek = CryptoJS.AES.encrypt(dek, kek, {
mode: CryptoJS.mode.CBC, // 加密模式
padding: CryptoJS.pad.Pkcs7 // 填充模式
}).toString();
// 内存保存
keyDataObject = {
kek,
encryptedDek
}
addKeyData(kek, encryptedDek); // 保存密钥数据
}
使用 kek 解密 dek:
js
/**
* 解密 encryptedDek 得到原始 DEK
* @param {string} kek - 密钥加密密钥
* @param {string} encryptedDek - 加密后的 DEK(Base64 编码)
* @returns {CryptoJS.lib.WordArray | null} 返回原始 DEK(WordArray),失败返回 null
*/
export async function recoverDEKFromEncryptedDek(kek, encryptedDek) {
if (
(kek && typeof kek !== 'string') ||
(encryptedDek && typeof encryptedDek !== 'string')
) {
console.error('参数必须是字符串类型');
return null;
}
try {
// 解密dek
const decrypted = CryptoJS.AES.decrypt(
encryptedDek,
kek,
{
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
}
);
const dek = decrypted;
if (!dek || dek.sigBytes !== 32) {
throw new Error('解密失败,可能密码或盐不正确');
}
return dek;
} catch (error) {
console.error('解密 DEK 失败:', error.message);
return null;
}
}
加解密数据:
js
/**
* 加密数据
* @param {string} data - 需要加密的数据
* @returns {string | null} 返回 JSON 对象,失败返回 null
*/
export async function encryptData(data) {
// 如果数据库中没有密钥数据,说明没有注册,则重定向到注册页
let keyData = keyDataObject ?? (await getKeyData() || Appraisal()); // 获取密钥 / 重定向到注册页
let dek = recoverDEKFromEncryptedDek(keyData.kek, keyData.encryptedDek);
if (!dek || !data) {
console.error('加密失败 ---> 无效的 DEK 或数据');
return;
}
const iv = localStorage.getItem('iv') ?? CryptoJS.lib.WordArray.random(128/8); // 生成随机偏量(IV)
const encrypted = CryptoJS.AES.encrypt(
JSON.stringify(data),
dek,
{
iv: CryptoJS.enc.Hex.parse(iv),
padding: CryptoJS.pad.Pkcs7,
mode: CryptoJS.mode.CBC
});
// 存储偏量
localStorage.setItem('iv', iv.toString());
return encrypted.toString();
}
/**
* 解密数据
* @param {string} encryptedData - 被加密的数据
* @returns {JOSN | null} 返回 JSON 对象,失败返回 null
*/
export async function decryptData(encryptedData) {
// 如果数据库中没有密钥数据,说明没有注册,则重定向到注册页
let keyData = keyDataObject ?? (await getKeyData() || Appraisal()); // 获取密钥 / 重定向到注册页
let dek = recoverDEKFromEncryptedDek(keyData.kek, keyData.encryptedDek);
let iv = localStorage.getItem('iv');
if(!iv) return;
if (!dek || !encryptedData) {
console.error('解密失败 ---> 无效的 DEK 或加密数据');
return;
}
try {
const decrypted = CryptoJS.AES.decrypt(
encryptedData,
dek,
{
iv: CryptoJS.enc.Hex.parse(iv),
padding: CryptoJS.pad.Pkcs7,
mode: CryptoJS.mode.CBC
}
);
const plaintext = decrypted.toString(CryptoJS.enc.Utf8); // 将解密结果转换为 UTF-8 字符串
if (!plaintext) {
throw new Error("解密失败 - 可能密钥不正确");
}
return JSON.parse(plaintext); // 返回 JSON 对象
} catch (error) {
console.log(`解密错误: ${error.message}`, true);
}
}
实现后效果如图:

总结
最后还是有惊无险的实现了数据加密,过程看似复杂,但理清思路后并没有那么复杂,如果有什么更好的方式推荐还希望大佬们能够在评论区提点意见。