Electron + Vue3 数据持久化方案:Pinia 与 electron-store 的结合
前言
在开发 Electron + Vue3 应用时,数据持久化是一个常见需求。很多开发者习惯在 Vue 项目中使用 pinia-plugin-persistedstate 插件来实现状态持久化,但在 Electron 应用中,这个方案存在一些问题。
为什么不能直接使用 pinia-plugin-persistedstate?
pinia-plugin-persistedstate 的默认实现是基于浏览器的 localStorage 或 sessionStorage。在 Electron 应用中,这种方案存在以下问题:
进程隔离问题:Electron 采用多进程架构,渲染进程和主进程是隔离的。
localStorage只能在渲染进程中访问,主进程无法直接读取这些数据。数据安全性问题:
localStorage以明文形式存储数据,敏感信息(如用户 token)存在安全隐患。虽然可以对数据进行加密,但增加了复杂度。数据存储位置不可控:
localStorage的存储位置由 Chromium 决定,通常在用户目录下的特定位置。如果应用需要自定义存储路径,或者需要在不同机器间迁移数据,这种方式不够灵活。多用户数据隔离困难:如果应用需要支持多用户登录,并且每个用户的数据需要隔离存储,使用
localStorage实现起来比较麻烦。应用更新可能导致数据丢失:在某些情况下(如应用重装、版本更新),
localStorage中的数据可能会丢失。
为了解决这些问题,我们可以使用 electron-store 配合 pinia 来实现更可靠的数据持久化方案。
方案概述
本方案的核心思路是:
- Pinia:负责管理应用的响应式状态
- electron-store:负责在主进程中进行数据持久化
- IPC 通信:通过 Electron 的 IPC 机制,让渲染进程能够访问 electron-store
同时,本方案还支持多用户数据隔离,每个用户的数据存储在独立的命名空间中。
实现步骤
1. 安装依赖
yarn add pinia electron-store
2. 主进程:初始化 electron-store 并注册 IPC 处理器
在主进程中,我们需要初始化 electron-store 并注册 IPC 处理器,让渲染进程可以通过 IPC 调用来访问存储。
// src/main/ipc/storeHandlers.js
import { ipcMain } from 'electron'
import StoreModule from 'electron-store'
import crypto from 'crypto'
const Store = StoreModule.default || StoreModule
let store = null
let encryptionKey = null
// 获取或创建加密密钥
function getEncryptionKey() {
if (encryptionKey) {
return encryptionKey
}
// 创建一个临时 store 来存储加密密钥
const keyStore = new Store({
name: 'app-key-store'
})
let savedKey = keyStore.get('encryption_key')
if (savedKey) {
encryptionKey = savedKey
return encryptionKey
}
// 生成 256 位随机密钥
const key = crypto.randomBytes(32).toString('hex')
keyStore.set('encryption_key', key)
encryptionKey = key
return encryptionKey
}
// 初始化 store
function initStore() {
if (store) return store
store = new Store({
name: 'app-config',
encryptionKey: getEncryptionKey(), // 启用加密
defaults: {
currentUserId: '',
users: {}
}
})
return store
}
// 获取当前用户的数据命名空间
function getCurrentUserData() {
const s = initStore()
const currentUserId = s.get('currentUserId')
if (!currentUserId) {
return null
}
const users = s.get('users') || {}
return users[currentUserId] || {}
}
// 设置当前用户的数据
function setCurrentUserData(userData) {
const s = initStore()
const currentUserId = s.get('currentUserId')
if (!currentUserId) {
return false
}
const users = s.get('users') || {}
users[currentUserId] = userData
s.set('users', users)
return true
}
export function registerStoreHandlers() {
initStore()
// 设置当前用户(登录时调用)
ipcMain.handle('store-set-current-user', (_, userId) => {
if (!userId) return false
const s = initStore()
s.set('currentUserId', userId)
return true
})
// 清除当前用户(退出登录时调用)
ipcMain.handle('store-clear-current-user', () => {
const s = initStore()
s.set('currentUserId', '')
return true
})
// 获取当前用户ID
ipcMain.handle('store-get-current-user-id', () => {
const s = initStore()
return s.get('currentUserId')
})
// 获取值
ipcMain.handle('store-get', (_, key) => {
const userData = getCurrentUserData()
if (!userData) return undefined
return userData[key]
})
// 设置值
ipcMain.handle('store-set', (_, key, value) => {
const userData = getCurrentUserData()
if (!userData) return false
userData[key] = value
return setCurrentUserData(userData)
})
// 删除值
ipcMain.handle('store-delete', (_, key) => {
const userData = getCurrentUserData()
if (!userData) return false
delete userData[key]
return setCurrentUserData(userData)
})
// 获取当前用户的所有数据
ipcMain.handle('store-get-all', () => {
return getCurrentUserData() || {}
})
}
3. 预加载脚本:暴露 Store API
在预加载脚本中,我们需要将 store 相关的 API 暴露给渲染进程。
// src/preload/index.js
import { contextBridge, ipcRenderer } from 'electron'
const api = {
// electron-store API - 支持多用户数据隔离
store: {
// 设置当前用户(登录时调用)
setCurrentUser: userId => ipcRenderer.invoke('store-set-current-user', userId),
// 清除当前用户(退出登录时调用)
clearCurrentUser: () => ipcRenderer.invoke('store-clear-current-user'),
// 获取当前用户ID
getCurrentUserId: () => ipcRenderer.invoke('store-get-current-user-id'),
// 数据操作(自动关联到当前用户)
get: key => ipcRenderer.invoke('store-get', key),
set: (key, value) => ipcRenderer.invoke('store-set', key, value),
delete: key => ipcRenderer.invoke('store-delete', key),
getAll: () => ipcRenderer.invoke('store-get-all')
}
}
if (process.contextIsolated) {
contextBridge.exposeInMainWorld('api', api)
}
4. 渲染进程:封装 useElectronStore 组合式函数
为了方便在 Vue 组件和 Pinia Store 中使用,我们封装一个组合式函数。
// src/renderer/src/composables/useElectronStore.js
import { ref, readonly } from 'vue'
export const ELECTRON_STORE_TOKEN = 'token'
export const ELECTRON_STORE_USER = 'user'
export const ELECTRON_STORE_SETTING = 'setting'
export function useElectronStore() {
const isReady = ref(false)
const data = ref({})
const currentUserId = ref('')
// 检查是否在 Electron 环境
const isElectron = typeof window !== 'undefined' && window.api && window.api.store
// 设置当前用户
async function setCurrentUser(userId) {
if (!isElectron) return false
try {
await window.api.store.setCurrentUser(userId)
currentUserId.value = userId
await getAll()
return true
} catch (error) {
console.error('store.setCurrentUser 失败:', error)
return false
}
}
// 清除当前用户
async function clearCurrentUser() {
if (!isElectron) return false
try {
await window.api.store.clearCurrentUser()
currentUserId.value = ''
data.value = {}
return true
} catch (error) {
console.error('store.clearCurrentUser 失败:', error)
return false
}
}
// 获取当前用户ID
async function getCurrentUser() {
if (!isElectron) return ''
try {
const userId = await window.api.store.getCurrentUserId()
currentUserId.value = userId
return userId
} catch (error) {
console.error('store.getCurrentUserId 失败:', error)
return ''
}
}
// 获取值
async function get(key, defaultValue = undefined) {
if (!isElectron) return defaultValue
try {
const value = await window.api.store.get(key)
return value !== undefined ? value : defaultValue
} catch (error) {
console.error('store.get 失败:', error)
return defaultValue
}
}
// 将值转换为可序列化的普通对象(处理 Vue 的 Proxy 对象)
function toSerializable(value) {
if (value === null || value === undefined) return value
if (typeof value === 'function' || value instanceof Date || value instanceof RegExp) {
return value
}
if (Array.isArray(value)) {
return value.map(toSerializable)
}
if (typeof value === 'object') {
const plain = {}
for (const key of Object.keys(value)) {
plain[key] = toSerializable(value[key])
}
return plain
}
return value
}
// 设置值
async function set(key, value) {
if (!isElectron) return false
try {
const serializableValue = toSerializable(value)
await window.api.store.set(key, serializableValue)
return true
} catch (error) {
console.error('store.set 失败:', error)
return false
}
}
// 获取当前用户的所有数据
async function getAll() {
if (!isElectron) return {}
try {
const allData = await window.api.store.getAll()
data.value = allData
isReady.value = true
return allData
} catch (error) {
console.error('store.getAll 失败:', error)
return {}
}
}
return {
isReady: readonly(isReady),
data: readonly(data),
currentUserId: readonly(currentUserId),
setCurrentUser,
clearCurrentUser,
getCurrentUser,
get,
set,
getAll,
isElectron
}
}
5. 在 Pinia Store 中使用
现在我们可以在 Pinia Store 中使用 useElectronStore 来实现数据持久化。
用户信息 Store 示例
// src/renderer/src/store/modules/user.js
import {
useElectronStore,
ELECTRON_STORE_TOKEN,
ELECTRON_STORE_USER
} from '../../composables/useElectronStore.js'
import useSettingStore from './setting.js'
const useUserStore = defineStore('user', () => {
const electronStore = useElectronStore()
const token = ref('')
const userInfo = ref({})
const id = ref('')
const name = ref('')
// 登录成功处理
async function handleLoginSuccess(accessToken) {
token.value = accessToken
// 先获取用户信息以获取用户ID
await getUserInfo()
// 设置当前用户(按用户ID隔离数据)
if (id.value) {
await electronStore.setCurrentUser(id.value)
}
// 保存 token 到 electron-store
await electronStore.set(ELECTRON_STORE_TOKEN, accessToken)
// 初始化设置
await useSettingStore().init()
}
// 从 electron-store 加载用户数据
async function loadUserFromStore() {
try {
const currentUserId = await electronStore.getCurrentUser()
if (!currentUserId) return
const storedToken = await electronStore.get(ELECTRON_STORE_TOKEN)
if (storedToken) {
token.value = storedToken
}
const storedUser = await electronStore.get(ELECTRON_STORE_USER)
if (storedUser) {
userInfo.value = storedUser.userInfo || {}
id.value = storedUser.id || ''
name.value = storedUser.name || ''
}
} catch (error) {
console.error('从 store 加载用户数据失败:', error)
}
}
// 保存用户数据到 electron-store
async function saveUserToStore() {
try {
await electronStore.set(ELECTRON_STORE_USER, {
userInfo: { ...userInfo.value },
id: id.value,
name: name.value
})
} catch (error) {
console.error('保存用户数据到 store 失败:', error)
}
}
// 获取用户信息
function getUserInfo() {
return new Promise(async (resolve, reject) => {
try {
// 调用后端 API getInfo 获取用户信息
const infoRes = await getInfo()
console.log('获取用户信息成功', infoRes)
name.value = infoRes.data.userName
id.value = infoRes.data.id
userInfo.value = infoRes.data
// 保存用户信息到 electron-store
await saveUserToStore()
resolve()
} catch (error) {
reject(error)
}
})
}
// 退出登录
async function logout() {
token.value = ''
id.value = ''
name.value = ''
userInfo.value = {}
// 清除当前用户(退出数据隔离)
await electronStore.clearCurrentUser()
}
// 初始化时加载数据
async function init() {
await loadUserFromStore()
}
return {
token,
userInfo,
id,
name,
handleLoginSuccess,
loadUserFromStore,
saveUserToStore,
logout,
init
}
})
export default useUserStore
设置 Store 示例
// src/renderer/src/store/modules/setting.js
import { useElectronStore, ELECTRON_STORE_SETTING } from '../../composables/useElectronStore.js'
const useSettingStore = defineStore('setting', () => {
const electronStore = useElectronStore()
const themeMode = ref('dark')
const isDark = ref(false)
// 切换主题模式
async function toggleThemeMode(mode) {
themeMode.value = mode
isDark.value = mode === 'dark'
await saveSettingToStore()
setThemeClass()
}
// 设置主题类名
function setThemeClass() {
if (isDark.value) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
}
// 从 electron-store 加载设置数据
async function loadSettingFromStore() {
try {
const storedSetting = await electronStore.get(ELECTRON_STORE_SETTING)
if (storedSetting) {
themeMode.value = storedSetting.themeMode || 'light'
isDark.value = storedSetting.isDark || false
}
} catch (error) {
console.error('从 store 加载设置数据失败:', error)
}
}
// 保存设置数据到 electron-store
async function saveSettingToStore() {
try {
await electronStore.set(ELECTRON_STORE_SETTING, {
themeMode: themeMode.value,
isDark: isDark.value
})
} catch (error) {
console.error('保存设置数据到 store 失败:', error)
}
}
// 初始化
async function init() {
await loadSettingFromStore()
await toggleThemeMode(themeMode.value)
}
return {
themeMode,
isDark,
toggleThemeMode,
loadSettingFromStore,
saveSettingToStore,
init
}
})
export default useSettingStore
6. 应用启动时初始化
在应用启动时,我们需要初始化 Pinia 并加载持久化的数据。
// src/renderer/src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import useUserStore from './store/modules/user.js'
async function bootstrap() {
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
// 初始化 user store(从 electron-store 加载用户数据)
const userStore = useUserStore()
await userStore.init()
app.mount('#app')
}
bootstrap()
架构图
┌─────────────────────────────────────────────────────────────────┐
│ 渲染进程 (Renderer) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Pinia Store │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ UserStore │ │ SettingStore│ │ HistoryStore│ │ │
│ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │
│ │ │ │ │ │ │
│ │ └────────────────┼────────────────┘ │ │
│ │ ▼ │ │
│ │ ┌───────────────────────┐ │ │
│ │ │ useElectronStore │ │ │
│ │ │ (Composable) │ │ │
│ │ └───────────┬───────────┘ │ │
│ └──────────────────────────┼──────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ window.api.store │ │
│ │ (Preload 暴露的 API) │ │
│ └──────────────────────────┬───────────────────────────────┘ │
└─────────────────────────────┼───────────────────────────────────┘
│
│ IPC 通信
▼
┌─────────────────────────────────────────────────────────────────┐
│ 主进程 (Main) │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ IPC Handlers │ │
│ │ (storeHandlers.js) │ │
│ └──────────────────────────┬───────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ electron-store │ │
│ │ (数据持久化存储) │ │
│ │ │ │
│ │ { │ │
│ │ currentUserId: "user123", │ │
│ │ users: { │ │
│ │ "user123": { │ │
│ │ token: "...", │ │
│ │ user: { ... }, │ │
│ │ setting: { ... }, │ │
│ │ history: [ ... ] │ │
│ │ } │ │
│ │ } │ │
│ │ } │ │
│ └──────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
方案优势
数据安全性:使用
electron-store的加密功能,敏感数据得到保护。多用户数据隔离:每个用户的数据存储在独立的命名空间中,切换用户时自动加载对应用户的数据。
主进程可访问:数据存储在主进程中,主进程代码可以直接访问,方便实现自动登录等功能。
存储位置可控:可以自定义数据存储路径,便于数据迁移和备份。
响应式状态管理:Pinia 提供了完善的响应式状态管理,与 Vue 3 完美集成。
类型安全:可以配合 TypeScript 使用,获得更好的类型提示。
总结
通过 Pinia + electron-store 的组合,可实现一个安全、可靠、支持多用户数据隔离的持久化方案。这个方案充分利用了 Electron 的多进程架构优势,同时保持了 Vue 3 响应式状态管理的便利性。
相比直接使用 pinia-plugin-persistedstate,这个方案更适合 Electron 应用的场景,提供了更好的数据安全性和灵活性。