为什么需要持久化
数据持久化的作用,是确保用户的重要数据在浏览器关闭、扩展重启、系统重启等情况下不会丢失,下次打开还能恢复之前的状态,例如:
KeyringController(密钥管理器)
- 助记词加密后的 vault
- 账户列表
- 加密私钥
PreferencesController
- 用户设置(如界面主题、语言、常用地址等)
NetworkController
- 自定义网络配置
- 当前选中的网络
TokensController
- 用户添加的自定义代币列表
等等这些数据需要持久化。
持久化的实现
在每个 controller 的 metadata 字段里,会用 persist: true/false 或函数来标记哪些字段需要持久化。例如:
js
const metadata = {
vault: { persist: true}, // 需要持久化
sessionToken: { persist: false}, // 不持久化
uiPopupOpen: { persist: false}, // 不持久化
};
持久化的安全性实现
每当控制器的状态发生变化,如果是需要持久化的状态,都会触发持久化
js
//background.js
const { update, requestSafeReload } = getRequestSafeReload(persistenceManager);
// 控制器状态变化时调用update方法持久化状态
controller.store.on('update', update);
update
方法是getRequestSafeReload
方法返回的,我们来看一下getRequestSafeReload
的实现:
js
export function getRequestSafeReload(persistenceManager: PersistenceManager) {
// 安全操作类
const operationSafener = new OperationSafener({
op: async (state: MetaMaskStateType) => {
// 真正的更新存储操作
await persistenceManager.set(state);
},
wait: 1000,
});
return {
// 更新存储数据函数
update: async (...params: Parameters<PersistenceManager['set']>) => {
return operationSafener.execute(...params);
}
};
}
operationSafener
对真正的更新存储函数做了包装,主要是为了防抖与并发安全机制,确保写入安全。具体实现如下:
js
// 任意参数类型
type AnyParams = any[];
// 通用操作类型,支持同步或异步
export type Op = (...params: AnyParams) => unknown | Promise<unknown>;
// 配置类型,包含操作函数、等待时间、防抖选项
type Config<O extends Op> = {
op: O; // 需要被安全执行的操作
wait?: number; // 防抖等待时间,单位ms,默认0
options?: DebounceSettings; // lodash防抖配置
};
// 操作安全器,保证高频操作安全、串行、防抖
export class OperationSafener<O extends Op = Op> {
// 防抖包装后的操作函数,确保不会频繁执行
#bouncer: DebouncedFunc<(...params: Parameters<O>) => Promise<ReturnType<O>>>;
// 标记是否正在疏散(即不再接受新操作)
#evacuating: Promise<void> | null = null;
// 构造函数,初始化防抖操作和锁
constructor({ op, wait, options }: Config<O>) {
const opts: LockOptions = { mode: 'exclusive' };
// 包装操作,确保同一时刻只有一个操作在执行
const func = (...params: Parameters<O>) =>
navigator.locks.request('operation-safener-lock', opts, () =>
op(...params),
);
// 使用lodash防抖,合并高频操作
this.#bouncer = debounce(func, wait, options);
}
// 执行操作(防抖),如果正在疏散则忽略
execute = (...params: Parameters<O>) => {
if (this.#evacuating) {
log.warn('evacuating, ignoring call to `execute`');
return false;
}
this.#bouncer(...params);
return true;
};
}
-
利用
debounce
防抖机制,可以把频繁的数据更新操作合并,会在操作"停下来
"一定时间(默认1s)后写入一次,避免了高频的数据更新操作对性能的影响,因为每次更新都会涉及到写入本地存储(localStorage、IndexedDB)会严重影响性能,甚至导致浏览器卡顿。 -
还利用
navigator.locks
锁机制保证写操作的原子性,比如在恢复备份或做数据迁移时,必须保证迁移过程中的写入是独占的,不能被其他写入打断,否则可能导致迁移失败或数据不一致。
持久化的高可用实现
使用主(浏览器本地存储browser.storage.local)+ 备(indexedDB)数据库的方式保证数据的高可用:
1. 浏览器本地存储并不绝对可靠
- 浏览器升级、异常关闭、扩展崩溃、用户清理缓存等操作,可能导致本地存储数据丢失或损坏。
- 某些浏览器策略或第三方清理工具也可能误删本地存储。
2. IndexedDB 更适合做持久化备份
- IndexedDB 是浏览器内置的高容量、结构化数据存储方案,适合存储较大或结构化的数据。
- 它不容易被普通的"清理缓存"操作误删,且有更好的事务和数据一致性保障。
3. 主备结合,极大提升了数据恢复能力
- 主库(localStore):用于日常读写,速度快,兼容性好。
- 备库(IndexedDB):定期或每次关键数据变更时同步备份,只保存最关键的数据(如密钥和元数据)。
- 当主库数据丢失或损坏时,可以检测到并提示用户用备份恢复,最大程度保障用户资产安全。
js
/**
* 备份对象类型定义,只包含需要持久化的关键控制器和元数据
*/
export type Backup = {
KeyringController?: unknown;
AppMetadataController?: unknown;
meta?: MetaData;
};
/**
* 需要备份的状态键名
*/
export const backedUpStateKeys = [
'KeyringController',
'AppMetadataController',
] as const;
/**
* 持久化相关错误类型,包含错误发生时的备份数据
*/
export class PersistenceError extends Error {
backup: object | null;
constructor(message: string, backup: object | null) {
super(message);
this.name = 'PersistenceError';
this.backup = backup;
}
}
/**
* 从 MetaMask 状态对象中提取需要备份的部分
*/
function makeBackup(state: MetaMaskStateType, meta: MetaData): Backup {
return {
KeyringController: state?.KeyringController,
AppMetadataController: state?.AppMetadataController,
meta,
};
}
/**
* 判断 state 是否包含 vault 字段,用于判断是否有有效的金库
*/
function hasVault(
state?: MetaMaskStateType,
): state is { KeyringController: RuntimeObject & Record<'vault', unknown> } {
const keyringController = state?.KeyringController;
return (
isObject(keyringController) &&
hasProperty(keyringController, 'vault') &&
Boolean(keyringController?.vault)
);
}
const STATE_LOCK = 'state-lock';
/**
* 持久化管理器,仅保留 get 和 set 方法及其相关依赖
*/
export class PersistenceManager {
// 标记数据持久化是否失败
#dataPersistenceFailing: boolean = false;
// 当前元数据对象
#metadata?: MetaData;
// 扩展是否初始化
#isExtensionInitialized: boolean = false;
// 本地存储实例
#localStore: BaseStore;
// 备份数据库实例
#backupDb: IndexedDBStore;
// 备份内容字符串
#backup?: string;
// 数据库是否已打开
#open: boolean = false;
// 当前挂起的写入操作
#pendingState: void | AbortController = undefined;
constructor({ localStore }: { localStore: BaseStore }) {
this.#localStore = localStore;
this.#backupDb = new IndexedDBStore();
}
/**
* 打开备份数据库
*/
async open() {
if (!this.#open) {
await this.#backupDb.open('metamask-backup', 1);
this.#open = true;
}
}
/**
* 设置元数据
*/
setMetadata(metadata: MetaData) {
this.#metadata = metadata;
}
function hasVault(
state?: MetaMaskStateType,
): state is { KeyringController: RuntimeObject & Record<'vault', unknown> } {
const keyringController = state?.KeyringController;
return (
isObject(keyringController) &&
hasProperty(keyringController, 'vault') &&
Boolean(keyringController?.vault)
);
}
/**
* 设置本地存储状态,并自动备份
*/
async set(state: MetaMaskStateType) {
await this.open();
const abortController = new AbortController();
// 如果已有挂起写入,则中止,保证只写入最新状态
this.#pendingState?.abort();
this.#pendingState = abortController;
await navigator.locks.request(
STATE_LOCK,
{ mode: 'exclusive', signal: abortController.signal },
async () => {
this.#pendingState = undefined;
try {
// 原子性写入所有键
await this.#localStore.set({
data: state,
meta,
});
const backup = makeBackup(state, meta);
// 有 vault 时才备份
if (hasVault(backup)) {
const stringifiedBackup = JSON.stringify(backup);
if (this.#backup !== stringifiedBackup) {
await this.#backupDb.set(backup);
this.#backup = stringifiedBackup;
}
}
if (this.#dataPersistenceFailing) {
this.#dataPersistenceFailing = false;
}
} catch (err) {
if (!this.#dataPersistenceFailing) {
this.#dataPersistenceFailing = true;
captureException(err);
}
log.error('error setting state in local store:', err);
} finally {
this.#isExtensionInitialized = true;
}
},
);
}
/**
* 获取当前本地存储状态
* @param options.validateVault 是否校验 vault
* @returns 当前状态或 undefined
* @throws PersistenceError 如果缺少 vault 但备份中存在
*/
async get(): Promise<MetaMaskStorageStructure | undefined> {
await this.open();
return await navigator.locks.request(
STATE_LOCK,
{ mode: 'shared' },
async () => {
const result = await this.#localStore.get();
if (!hasVault(result?.data)) {
const backup = await this.getBackup();
if (Object.values(backup).some((value) => value !== undefined)) {
throw new PersistenceError(MISSING_VAULT_ERROR, backup);
}
}
return result;
},
);
}
/**
* 获取备份对象
*/
async getBackup(): Promise<Backup> {
const [KeyringController, AppMetadataController, meta] =
await this.#backupDb.get([...backedUpStateKeys, `meta`]);
return {
KeyringController,
AppMetadataController,
meta: meta as MetaData | undefined,
};
}
}
set方法
锁 + AbortController
这里我们使用了非常巧妙的方法既保证了写入的原子性,又保证了写入队列长度不超过1,只保留最新的写入请求在队列即可,因为中间的历史状态没有意义。这样做避免了频繁写入对性能的影响。
-
正在进行的写入(已获得锁,正在执行)------无法被中断
一旦进入
navigator.locks.request
的回调,已经获得锁,这个写入会一直执行到结束(除非代码内部主动检测 signal 并中断,但本例没有)。 此时调用abort()
只会让后续等待锁的请求被取消,不会影响当前已在执行的写入。 -
正在排队的写入(等待获得锁)------可以被取消
如果有一个写入请求正在等待锁(即排队中),这时又有新的写入请求到来,旧的排队请求会被
abort()
取消掉。 新的写入请求会用自己的 AbortController 进入队列,成为唯一排队的写入。 -
只保证最多有一个"最新的"写入请求在队列中
任何时候,除了正在执行的写入,队列里最多只会有一个最新的写入请求。这样可以避免无意义的多次写入排队,始终只保留最新的状态写入。
场景举例
- A 写入:正在执行(已获得锁)
- B 写入:正在排队(等待锁)
- C 写入:新到来
此时,B 会被取消,C 进入队列。A 执行完后,C 会获得锁并执行,B 永远不会执行。
主备结合
我们会把所有需要持久化的数据写入到浏览器的本地存储(storage.local),对于备份存储(IndexedDB),我们进行条件备份:
- 只备份核心钱包密钥数据KeyringController和应用元数据AppMetadataController
- 并且具有有效的用户密钥(vault)数据,如果没有则表示用户还没有创建钱包或数据损坏,这种情况不需要备份
get方法
如果在localstorage获取的状态没有有效的 vault:
-
会去 IndexedDB 备份库查找是否有备份数据。
-
如果备份库有数据,说明用户曾经创建过钱包但主库丢失了,这时抛出 PersistenceError,并带上备份数据,提示前端可以引导用户恢复。
-
如果备份库也没有数据,则说明用户本来就没创建过钱包,流程继续。
源码
本章涉及到的源码:
学习交流请添加vx: gh313061
本教程配套视频教程:space.bilibili.com/382494787/l...
下期预告:前后端状态同步机制