一、引言
在 Taro / Uni-app 等一码多端框架盛行的今天,“Write Once, Run Everywhere” 往往是一个美好的愿景。现实情况是,虽然框架解决了 UI 渲染的跨端问题,但在原生能力层面,不同平台的差异依然巨大。
其中,本地存储(Storage) 是最基础但也最容易被忽视的痛点。本文将讲述如何通过设计一个基于适配器模式的 Storage Bridge SDK,来解决多端差异,提升研发效能。
二、为什么要封装?
直接用 Taro.setStorage 不行吗?为什么非要造轮子?从可维护性、一致性和扩展性三个维度来回答这个问题。
1. 消除代码碎片式分布
现状: H5 使用 localStorage,微信小程序使用 wx.setStorage,React Native 使用 AsyncStorage。
痛点: 如果不封装,业务代码中会充斥着大量的 process.env.TARO_ENV 判断。一旦需要新增一个平台(比如鸿蒙),所有业务文件都要修改。
价值: 封装 SDK 后,业务层只需要调用 Storage.set('key', val),业务逻辑与底层平台彻底解耦。
2. 统一数据契约
现状: localStorage 只能存 String,存对象需要手动 JSON.stringify。小程序和 RN 的某些 API 支持直接存 Object,但解析规则不一。
价值: SDK 负责统一的序列化和反序列化规则。业务层永远面向 Object 编程,无需关心底层存储的是字符串还是二进制。
3. 类型安全与中间件能力
现状: 原生 API 返回的通常是 any,且无法拦截。
价值:
-
TypeScript 支持: 可以在 SDK 层定义泛型,确保读取出的数据类型是可预测的。
-
扩展能力: 可以在 SDK 内部统一实现 数据加密(Encryption)、日志上报、存储空间监控,而无需侵入业务代码。
三、核心难点与架构设计
难点 1:同步与异步的冲突
比较坑的是:
H5: localStorage 是同步阻塞的。
RN/小程序: AsyncStorage / wx.getStorage 是异步的。
这里存在一个问题:业务逻辑到底该写 const val = get() 还是 await get()?如果为了兼容 H5 写成同步,RN 端直接报错;如果为了兼容 RN 写成异步,H5 端的调用显得多余。
在项目实践过车过程中,我们实行:强制异步化。主要是这么考虑的:
根据“木桶效应”,跨端 API 的设计必须向下兼容能力最弱(或限制最大)的一端。既然 RN 必须异步,那么 H5 即使支持同步,也必须在 SDK 层通过 Promise.resolve() 包装成异步。
代码示例:
// 统一返回 Promise
get(key: string): Promise<any> {
if (isH5) {
return Promise.resolve(JSON.parse(localStorage.getItem(key)));
}
if (isRN) {
return AsyncStorage.getItem(key).then(res => JSON.parse(res));
}
}
难点 2:存储容量与 LRU 缓存策略
遇到存储,必定要考虑到容量问题:
Localstorage 限制约 5MB。
小程序限制 10MB。
RN 理论上无限但读写大文件慢。
当业务数据(如埋点、历史记录、数据缓存)存满时,原生 API 会直接报错(Quota Exceeded),导致业务崩坏。
解决方案:
在 SDK 内部维护一个 LRU (Least Recently Used) 索引。
当捕获到“存储空间不足”的 Error 时,SDK 自动按“最近最少使用”原则清理旧数据,腾出空间后再写入。这一步对业务层是透明的。
难点 3:数据时效性
原生 Storage API 是永久存储,除非用户手动清理。业务中很多数据(如首页缓存、广告配置、用户定位信息)是需要过期的。
所以需要封装“过期时间”
在写入时,不只存 value,而是存一个包装对象:{ value: data, expireAt: timestamp }。
在读取时,先判断 Date.now() > expireAt。如果过期,SDK 自动删除该 key 并返回 null。
四、代码实现
1. 完整代码
import Taro from '@tarojs/taro';
// 环境变量判断
const isH5 = process.env.TARO_ENV === 'h5';
/**
* 存储数据结构定义
*/
interface StorageData<T> {
data: T;
expire: number | null;
}
/**
* 增强型 Storage SDK
*/
class StorageAdapter {
/**
* 存数据
*/
async set<T>(key: string, value: T, expireSeconds?: number): Promise<void> {
// 1. 构建包装对象
const wrapper: StorageData<T> = {
data: value,
expire: expireSeconds ? Date.now() + expireSeconds * 1000 : null
};
let jsonString = '';
// 增加序列化安全保护
try {
jsonString = JSON.stringify(wrapper);
} catch (e) {
console.error(`[Storage Error] 序列化失败, Key: ${key}`, e);
// 序列化失败属于严重错误,直接 reject
return Promise.reject(e);
}
// 多端适配
if (isH5) {
// H5 特殊处理:虽然 Taro 也有 H5 实现,但直接操作 localStorage 更可控(同步转 Promise)
try {
localStorage.setItem(key, jsonString);
return Promise.resolve();
} catch (e) {
// H5 可能会报 QuotaExceededError (空间存满)
return Promise.reject(e);
}
} else {
return Taro.setStorage({
key,
data: jsonString
}).then(() => {
// 成功回调
return;
}).catch(err => {
// 失败回调
throw err;
});
}
}
/**
* 取数据
*/
async get<T>(key: string): Promise<T | null> {
try {
let resultString: string | null = null;
if (isH5) {
resultString = localStorage.getItem(key);
} else {
const res = await Taro.getStorage({ key }).catch(() => null); // 捕获 Key 不存在的报错
if (res) {
resultString = res.data;
}
}
if (!resultString) return null;
// 反序列化
const wrapper: StorageData<T> = JSON.parse(resultString);
// 检查过期
if (wrapper.expire && Date.now() > wrapper.expire) {
// 过期删除
this.remove(key);
return null;
}
return wrapper.data;
} catch (e) {
// JSON.parse 失败或其他错误,视为无数据
return null;
}
}
async remove(key: string): Promise<void> {
if (isH5) {
localStorage.removeItem(key);
return Promise.resolve();
}
return Taro.removeStorage({ key });
}
}
export const storage = new StorageAdapter();
2. 几点说明
-
泛型支持 (
<T>): 调用时可以使用 storage.get('user'),这样代码编辑器会自动提示 UserInfo 的结构,极大提升了开发体验和类型安全。 -
惰性删除: 在 get 方法中,在读取的缓存的时候检查时间戳。如果过期了,悄悄删掉并告诉业务层“no data”。
-
H5 的 Promise 伪装: 在
if (isH5)分支中,我们使用了Promise.resolve()。这是为了配合接口定义的Promise<void>。这样上层业务逻辑在 await storage.set(...) 时,不需要关心底层到底是同步还是异步,真正实现了架构层面的解耦。 -
利用Taro编译抹平的核心价值: 为什么在 else 分支里我们依然使用 Taro.setStorage?这是因为 Taro 的核心价值就在于编译时的平台映射。在 React Native 环境下,Taro 会自动引入 @react-native-async-storage;在小程序环境下,它会编译为 wx.setStorage。使用 Taro 命名空间可以让我们的一套代码在 RN 和小程序中无缝运行,而无需手动编写 if (isRN) ... else if (isWeapp) ... 的繁琐判断。
五、总结
通过封装统一的 Storage Bridge SDK,我们不仅抹平了 H5、小程序和 React Native 的差异,更重要的是建立了一套数据存储的标准。
看似简单的“存”和“取”,背后包含了对异步编程、异常处理、LRU (Least Recently Used) 算法以及设计模式的综合考量。在架构设计中,“统一”比“灵活”更重要,因为它是维护性的基石。