开发小程序时,大家可能都纠结过数据存储的问题,到底要不要把大量数据存进 storage 里呢?我在做卡片学习小程序的时候,把所有卡片数据一股脑都存到本地了。因为用的是 uniapp 开发,所以在全局状态管理这块我选了 pinia,再配上pinia-plugin-persistedstate
插件,很轻松就把全局数据从内存同步到本地存储里了。
但最近我在使用的过程中发现了一些问题,我的核心数据卡盒和卡片是全部存储在 storage 中的,结构就是两层嵌套的对象,外层是所有卡盒,每个卡盒里面又有一个卡片对象。也就是说我的所有用户数据都存储在一个 storage 的一个 key 中。
export type CardBox = {
name: string
folderId?: string
index: number
color?: number
cardItems?: {
[cardItemId: string]: CardItem
}
}
export type CardBoxes = Record<string, CardBox>
最初这么实现还挺方便的,而且将数据同步到云端的时候也只需要直接同步这个 key 就好了。但是最近有用户反馈卡片数量比较多的时候,使用学习卡片会卡顿。我在排查问题的时候就发现,这个用户一个卡盒中有两三千张卡片,所以初步判断就是这个卡片数量多导致的问题,于是就开始了排查,最终有了今天这篇文章,介绍一下我排查过程中的发现的问题和优化的方式。
小程序 Storage 的介绍
小程序中的 Storage
是用于本地数据存储的 API,允许开发者在小程序中保存、读取和删除数据。数据以键值对的形式存储,且存储容量有限(通常为 10MB)。
-
wx.setStorageSync(key, data)
存储数据。key
为键名,data
为要存储的数据(支持字符串、对象等)。wx.setStorageSync('key', 'value');
-
wx.getStorageSync(key)
读取数据。key
为键名,返回对应的数据。const value = wx.getStorageSync('key'); console.log(value); // 输出: value
-
wx.removeStorageSync(key)
删除指定键名的数据。wx.removeStorageSync('key');
-
wx.clearStorageSync()
清空所有存储数据。wx.clearStorageSync();
持久化存储通常适用于存储以下这些内容:
- 用户偏好设置:如主题、语言等。
- 缓存数据:如网络请求结果,减少重复请求。
- 临时数据:如表单数据,防止页面刷新后丢失。
注意点 💡
- 存储限制:单个小程序的存储容量通常为 10MB,超出会触发错误。
- 数据隔离:不同小程序之间的数据相互隔离,无法共享。
- 生命周期:存储的数据除非手动删除或用户清除缓存,否则会一直保留。
性能优化
前文中有提到,我使用了 pinia 用于全局状态管理,并使用 pinia-plugin-persistedstate
插件将其状态持久化。
import { defineStore } from 'pinia';
// 定义一个 Store
export const useCounterStore = defineStore('counter', {
// 定义状态
state: () => ({
count: 0, // 初始状态
}),
// 定义操作
actions: {
increment() {
this.count++;
},
decrement() {
this.count--;
},
},
// 配置持久化
persist: {
enabled: true, // 启用持久化
strategies: [
{
key: 'counter', // 存储的键名(默认为 Store 的 id,即 'counter')
storage: localStorage, // 存储方式,默认为 localStorage
},
],
},
});
虽然这个插件的引入很简单,但是在小程序中使用 pinia-plugin-persistedstate
时,如果数据量较大,我们可能会遇到一些问题。首先,小程序的 Storage
容量通常限制为 10MB,如果存储的数据量过大,很容易超出这个限制,导致存储失败。当我们调用 wx.setStorage
或 wx.setStorageSync
时,可能会直接抛出错误,无法完成存储操作。
其次,数据量较大时,频繁读写 Storage
的操作会阻塞主线程,导致页面卡顿或性能下降。尤其是在数据量较大的情况下,页面响应会明显变慢,用户的操作也可能出现延迟,影响整体体验。
此外,pinia-plugin-persistedstate
在存储和读取数据时,会对数据进行序列化(JSON.stringify
)和反序列化(JSON.parse
)。当数据量较大时,这些操作会消耗较多的 CPU 资源,导致页面加载或数据更新时出现卡顿现象。尤其是当数据结构嵌套层级较深或包含冗余字段时,这种开销会更加明显。
这些都是我在实现小程序的时候,遇到的一些问题,我是将 storage 当作数据库使用了,这虽然不是一个推荐的方式,但是我认为如果处理得当,我的小程序还是可以继续使用这套方案的,因此针对以上问题,我做了一些优化,下面给大家介绍一下。
性能优化
减少读写
既然数据量比较大的时候,频繁读写 Storage
的操作会阻塞主线程,那么我们就应该尽量减少读写操作,只在必要的位置进行读写。但如果我们使用的是 pinia-plugin-persistedstate
插件实现持久化,那么这个 store 的状态发生任何改变,都会触发 Storage
的读写操作,如果你想简单的避免短时间内重复写入,可以自定义 persist
的配置,自定义一个 setItem
操作:
// src/stores/counterStore.js
import { defineStore } from 'pinia';
// 自定义防抖函数
function debounce(func, wait) {
let timeout;
return function (...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
}),
actions: {
increment() {
this.count++;
},
},
persist: {
{
key: 'counter',
storage: {
// 自定义 getItem 方法
getItem: (key) => {
return wx.getStorageSync(key);
},
// 自定义 setItem 方法,加入防抖逻辑
setItem: debounce((key, value) => {
wx.setStorageSync(key, value);
}, 1000), // 防抖时间设置为 1 秒
},
},
},
});
优化的读操作后,如果你的数据仅在需要在进入小程序时从本地读取一次,那么你可以直接将 getItem
留空,然后自己在进入小程序 onLauch
时从 Storage
读取。以下是一个简单的例子,在小程序的 app.js
文件中,onLaunch
生命周期函数中读取数据:
// app.js
App({
onLaunch() {
// 从本地存储读取数据
const data = wx.getStorageSync('myData');
// 将数据写入 Pinia 或全局状态管理
if (data) {
// 假设你有一个名为 useStore 的 Pinia store
const store = useStore();
store.setData(data);
}
}
});
使用异步 API
在微信小程序中,storage
提供了同步和异步两种方法,它们的核心区别在于执行方式和适用场景。简单来说,同步方法会阻塞代码执行,直到操作完成;而异步方法不会阻塞代码,操作完成后通过回调函数通知结果。
同步方法
同步方法,比如 wx.setStorageSync
和 wx.getStorageSync
,会直接执行存储或读取操作,并立即返回结果。如果操作耗时,它会阻塞后续代码的执行,直到操作完成。这种方式适合需要立即获取结果的场景,但要注意避免在主线程中频繁使用,以免影响性能。
try {
wx.setStorageSync('key', 'value'); // 存储数据
const data = wx.getStorageSync('key'); // 读取数据
console.log('获取的数据:', data);
} catch (e) {
console.error('操作失败:', e);
}
异步方法
异步方法,比如 wx.setStorage
和 wx.getStorage
,则是将存储或读取操作放入任务队列,不会阻塞后续代码的执行。操作完成后,通过回调函数返回结果。这种方式适合不需要立即获取结果的场景,能够更好地保持程序的流畅性。
wx.setStorage({
key: 'key',
data: 'value',
success: function() {
console.log('数据存储成功');
}
});
wx.getStorage({
key: 'key',
success: function(res) {
console.log('获取的数据:', res.data);
}
});
关于小程序 storage 中异步方法,我也尝试搜索了一下其实现原理,大致是依赖于 JavaScript 的 事件循环 和 回调机制。当你调用 wx.setStorage
时,微信小程序会将这个操作交给底层系统(可能是 Native 层)处理,同时继续执行后续代码。底层系统完成操作后,会将结果通过回调函数返回给 JavaScript 层。
举个例子,假设你调用 wx.setStorage
存储数据:
- 小程序将存储任务交给底层系统处理。
- 底层系统异步执行存储操作(比如写入文件或数据库)。
- 存储完成后,底层系统通知 JavaScript 层,触发你传入的
success
回调函数。
整个过程不会阻塞主线程,因此你的程序可以继续执行其他任务,等到存储完成后再处理结果。后续我还会分享一下小程序中如何使用 worker 异步处理复杂运算逻辑。
空间优化
分 key 存储
在微信小程序中,storage
对每个 key
的存储限制是 1MB,而总存储容量是 10MB。如果你直接使用 Pinia 的持久化插件(比如 pinia-plugin-persistedstate
),默认会将整个 store 的数据存储在一个 key
中。这意味着,如果你的 store 数据量较大,很容易就触碰到 1MB 的限制。
为了解决这个问题,可以采用 分 key 存储 的思路。简单来说,就是将 store 中的数据按模块或功能拆分成多个部分,分别存储在不同的 key
中。这样,每个 key
只存储一部分数据,既能避免单个 key
超限,又能充分利用 10MB 的总容量。
-
按模块拆分
如果你的 store 中有多个模块(比如用户信息、配置设置、购物车等),可以将每个模块的数据存储到不同的key
中。例如:userStore
存储到user
这个key
。settingsStore
存储到settings
这个key
。cartStore
存储到cart
这个key
。
-
按功能拆分
如果某个模块的数据量较大,可以进一步按功能拆分。比如,用户信息模块中,可以将基本信息、权限信息、历史记录等分别存储到不同的key
中。 -
动态生成 key
如果需要存储的数据是动态的(比如多个用户的数据),可以根据唯一标识动态生成key
。例如,用户 ID 为123
的数据可以存储到user_123
这个key
中。
以 Pinia 为例,可以通过自定义持久化插件或手动实现分 key 存储。以下是一个简单的实现思路:
import { defineStore } from 'pinia';
export const useUserStore = defineStore('user', {
state: () => ({
info: null,
settings: {},
}),
actions: {
saveUserInfo(info) {
this.info = info;
wx.setStorageSync('user_info', info); // 存储用户信息
},
saveUserSettings(settings) {
this.settings = settings;
wx.setStorageSync('user_settings', settings); // 存储用户设置
},
loadUserData() {
this.info = wx.getStorageSync('user_info') || null; // 加载用户信息
this.settings = wx.getStorageSync('user_settings') || {}; // 加载用户设置
},
},
});
在这个例子中,user_info
和 user_settings
分别存储用户信息和设置,避免了将所有数据都塞到一个 key
中。
在我的小程序中,我不再使用 pinia 的持久化存储插件进行统一存储,而是自行在每个 pinia 的 action 中进行持久化存储,不同的卡盒使用不同的 key 存储,如下图所示:
// 初始化加载所有卡盒数据
const loadCardBoxes = () => {
const keys = uni.getStorageInfoSync().keys
const cardBoxKeys = keys.filter((key) => key.startsWith('cardBox_'))
cardBoxKeys.forEach((key) => {
const id = key.replace('cardBox_', '')
const cardBoxData = parse(uni.getStorageSync(key))
state.cardBoxes[id] = cardBoxData
})
}
这样每个卡盒都可以存储 1m 的数据,1m 可以存储大概 35 万个中文字符,如果每张卡片正反面加起来共 800 个中文字符,一个卡盒也可以存储 437 张,最多十个卡盒。虽然这是极限的情况,但对我来说还不太够,我得考虑一个卡盒存储 1000 张以上的卡片。
数据压缩
除了尽量充分利用存储空间,我们还可以压缩一下将要存储在 storage 中的数据,这里我用了 zipson
这个库进行压缩,因为我们本身在将数据存储到 stoage 时就需要进行 json 序列化,pinia-plugin-persistedstate
中默认使用的是 JSON.parse
和 JSON.stringify
,而 zipson
除了可以进行 JSON 序列化,还会在序列化的过程中将数据进行高效压缩, zipson 的压缩算法针对 JSON 数据结构进行了优化,能够大幅减少数据体积,而且性能比较高,内存占用也比较小。我是这么使用的:
import { parse, stringify } from 'zipson'
// 创建持久的防抖函数实例
const debouncedStorageUpdate = _.debounce((cardBoxId: string, cardBox: any) => {
const key = `cardBox_${cardBoxId}`
console.time(`更新卡盒 ${cardBoxId}`)
uni.setStorage({ key, data: stringify(cardBox) })
console.timeEnd(`更新卡盒 ${cardBoxId}`)
}, 300)
如果你想与 pinia-plugin-persistedstate
搭配使用,你可以这么写:
import { parse, stringify } from 'zipson'
export const useUserStore = defineStore('user', {
// ... state 和 actions
persist: {
serializer: {
serialize: stringify,
deserialize: parse
}
}
})
在简单尝试了一下,一个 152 张卡片的英语对话的卡盒,每张卡片一句话,压缩前大概 100 kb:
压缩后大概 50 kb,这只是粗略估算了一下,不同的卡片内容压缩效率也不太一样。有的卡片内容文字较多、格式较为单一,压缩比例就会高些,最终占用的空间也会更小。
总结
针对这些问题,我从两方面优化。性能优化上,减少读写次数,用防抖函数避免短时间重复写入,还改用异步 API,让程序不卡顿。空间优化上,把数据按模块或功能拆分,用不同 key 存储,避免单个 key 超限;还引入 zipson 库压缩数据,减少占用空间。希望这些优化能让小程序在数据量大时也能流畅运行,给大家更好的使用体验。
这个优化后的版本我还在测试中,欢迎大家搜索体验学习卡盒小程序,我的各种小程序优化实践都会在这上面有所体现,如果文章对你有帮助欢迎点个赞 respect~