Instatic 持久化键位完全指南:从 localStorage 到 user_preferences 的数据存放规范
本指南以 Instatic 仓库的 docs/reference/persistence-keys.md 为骨架,系统梳理 Admin 控制台写入的每一个 localStorage / sessionStorage 键、唯一的会话 Cookie,以及服务端跨设备同步的 user_preferences 偏好表。读完你将掌握:所有客户端键位的命名约定与来源文件、服务端偏好接口的请求语义、parseJsonWithFallback 与 TypeBox 校验的读写模式,以及新增一个持久化偏好(客户端或服务端)的完整实操路径。
一页回答"数据存在哪":持久化全景
Instatic 将管理端状态划分为三层,各自有不同的生命周期与同步范围:
| 持久化层 | 典型用途 | 生命周期 | 跨设备同步 |
|---|---|---|---|
localStorage |
编辑器偏好、布局、剪贴板、UI 视图模式等 | 持久,直到用户清理或显式删除 | 否(同设备跨标签页通过 storage 事件同步) |
sessionStorage |
跨页面跳转的在途状态(如 Spotlight 待执行动作) | 会话级,刷新跳转后仍存活 | 否 |
Cookie(HttpOnly) |
管理端会话令牌 | 会话 / 服务端控制 | 否(随请求携带) |
user_preferences 表 |
仪表盘布局、模块插入器收藏等用户偏好 | 服务端持久化 | 是 |
三条核心约定(原文档 TL;DR 的完整版):
- 所有客户端持久化键统一前缀:
instatic-(Spotlight 专属键用spotlight:),且与站点/模块的 CSS 类名互不冲突; - 所有服务端用户偏好存放在
user_preferences表中,以(user_id, key)为主键; - 所有读取都经过
parseJsonWithFallback(...)——数据损坏时静默回退到默认值,而不是让编辑器白屏。该辅助函数定义在 src/core/utils/jsonValidate.ts。
命名约定公式:instatic-<feature>[-v<version>]。当存储结构发生不兼容变更时,递增 -vN 后缀使旧形状自动失效;schema 上的 additionalProperties: true 保证读取端始终宽容(详见下文"版本化"小节)。
客户端持久化键位清单(localStorage)
以下为 Admin 应用当前写入的全部 localStorage 键,每项附 Owner(语义所有者)与 source-of-truth 文件,方便逆向定位"这个状态到底存在哪":
| Key | 所有者 | 权威来源文件 |
|---|---|---|
instatic-editor-prefs |
全部编辑器偏好(自动保存、hover 预览、Admin 主题、UI 字号、密度、layers 选项) | src/admin/pages/site/preferences/editorPreferences.ts → EDITOR_PREFS_KEY |
instatic-editor-layout-v2 |
各工作区(site / content / data / media)侧边栏宽度 + 开合状态 + 浮动面板位置 | src/admin/state/workspaceLayoutStorage.ts → EDITOR_LAYOUT_STORAGE_KEY |
instatic-clipboard-v1 |
编辑器剪贴板(图层子树复制 / 剪切 / 粘贴) | src/admin/pages/site/store/clipboard/clipboardStorage.ts → CLIPBOARD_STORAGE_KEY |
instatic-class-usage |
ClassPicker 自动补全中最近使用的类 | src/admin/pages/site/preferences/classUsage.ts → CLASS_USAGE_STORAGE_KEY |
instatic-data-grid-primary-widths-v1 |
Data 工作区网格中各表主列的宽度 | src/admin/pages/data/components/DataGrid/usePrimaryColumnWidth.ts |
instatic-media-page-view-mode |
共享 Media 工作区与媒体库的视图模式(grid / list) | src/admin/pages/media/utils/viewMode.ts |
instatic-media-explorer-view-mode |
站点工作区 Media Explorer 面板的视图模式 | src/admin/pages/site/panels/MediaExplorerPanel/mediaExplorerUtils.ts → VIEW_MODE_STORAGE_KEY |
instatic-module-inserter-v1 |
模块插入器视图模式与最近插入记录 | src/admin/pages/site/module-picker/moduleInserterPrefs.ts |
instatic-onboarding-dismissed |
仪表盘引导面板:已关闭 / 展开(按设备记录) | src/admin/pages/dashboard/hooks/useOnboardingState.ts |
spotlight:recent-commands |
Spotlight 最近执行命令 id(去重、上限 8 条) | src/admin/spotlight/recentStore.ts |
spotlight:telemetry:v1 |
本地 Spotlight 遥测(命令使用频率) | src/admin/spotlight/telemetry.ts |
几个值得展开的键位
instatic-editor-prefs 是所有键中最"重量级"的一个。它不是手写 schema,而是由声明式的 PREFERENCE_CATALOG(见 src/admin/pages/site/preferences/catalog.ts)自动派生:每个 catalog 条目贡献一个可选字段到 EditorPrefsSchema,同时贡献一条默认值到 DEFAULT_EDITOR_PREFS。新增一个编辑器偏好只需在 catalog 中加一条记录,editorPreferences.ts 无需改动——Settings UI 会自动渲染对应开关。
实现上它还维护了一个"原始字符串比对"缓存:每次读取都先 localStorage.getItem 并比较 raw 字符串,命中缓存直接返回已解析对象(免去 JSON.parse + TypeBox 校验,单次解析约 0.5ms 的代价被摊平);localStorage.clear()、测试注入或跨标签页 storage 事件都会导致 raw 串变化从而触发重新解析。跨标签页同步通过监听原生 storage 事件实现:先失效缓存、再通知订阅者,保证回调中读到的一定是新值。React 侧推荐使用 useEditorPreference(id) / useEditorSelectPreference(id) 钩子,组件只订阅单个偏好,依赖追踪保持简单。
instatic-editor-layout-v2 按工作区命名空间存储(site / content / data / media 各自记忆自己的侧栏宽度与开合状态),浮动面板位置(panelPositions)与尺寸(panelSizes)放在顶层——每个 FloatingPanelId 唯一属于一个工作区,不存在跨工作区冲突。注意:Plugins、Users、Account 等页面走 AdminPageLayout,不参与该布局持久化。
instatic-clipboard-v1 是"键名 v1、载荷版本 2"的特例:CLIPBOARD_STORAGE_KEY = 'instatic-clipboard-v1' 但 CLIPBOARD_VERSION = 2,载荷结构为 { version: 2, rootNodeIds, nodes, classes, copiedAt },支持多根节点复制。旧 v1 载荷(单 rootNodeId)在读取时故意不支持,safeParseJson 会静默丢弃——剪贴板数据是"可丢弃的",不值得迁移。任何读取失败(JSON 缺失、schema 不匹配、版本不支持)一律视为"无剪贴板",绝不向 UI 抛错;写入是 best-effort,配额超限或隐私模式下静默吞掉异常,内存中的 slice 状态仍可正常使用当前会话。
spotlight:recent-commands 保存最近执行的命令 id,MAX_RECENT = 8 条,写入前去重并置顶,读取用 parseJsonWithFallback(raw, RecentSchema, []) 兜底(schema 上限 maxItems: 20),任何失败(含隐私模式 localStorage 抛异常)都回退为空数组。
sessionStorage:跨页面跳转的在途状态
| Key | 所有者 | 权威来源文件 |
|---|---|---|
instatic-spotlight-pending-action |
Spotlight 命令等待的跨页面重载动作(如 step-up 认证后恢复执行) | src/admin/spotlight/pendingAction.ts |
与 localStorage 不同,sessionStorage 只用于在途的跨跳转状态:用户触发一个需要二次认证(step-up)的 Spotlight 命令,页面跳转到认证流程,回来后必须恢复执行原动作——这个"待恢复"标记就放在 session 级存储中,页面正常重载后依然存在,但关闭标签页即丢弃。原文档的禁止模式明确警告:不要用 sessionStorage 存放需要跨页面重载存活的常规状态,那是 localStorage 的职责。
会话 Cookie:唯一允许存放秘密的地方
| Cookie | 所有者 | 权威来源文件 |
|---|---|---|
instatic_admin_session |
Admin 会话令牌(原始值,查找时先哈希) | server/auth/tokens.ts → SESSION_COOKIE_NAME |
属性组合:HttpOnly、Secure(生产环境走 TLS 时)、SameSite=Lax、Path=/admin。客户端 JavaScript 永远无法直接读取它——这正是 Instatic 的安全边界设计:token 只经 Cookie 自动携带,前端代码层不存在任何可窃取会话令牌的读取路径。对应地,原文档禁止模式明确"不要在 localStorage 存放 secrets(token、密码)"。
服务端用户偏好:user_preferences 表
客户端偏好按设备隔离,而需要跨设备同步的用户偏好存放在服务端 user_preferences 表中,每行对应一个 (user_id, key):
| Key | 所有者 | 权威来源文件 |
|---|---|---|
dashboard-layout |
仪表盘 widget 的位置 / 尺寸(含 onboarding 面板状态、底部 Block 库面板高度 libraryHeight) |
src/admin/pages/dashboard/hooks/useDashboardLayout.ts |
module-inserter |
模块插入器收藏(notch favorites):有序 { kind, id } 引用,kind 为 module / savedLayout / component,上限 12 条 |
src/admin/pages/site/module-picker/useModuleInserterPreference.ts |
表结构(两份迁移文件完全镜像)
Postgres 版本(server/db/migrations-pg.ts):
create table if not exists user_preferences (
user_id text not null references users(id) on delete cascade,
key text not null,
value_json jsonb not null,
updated_at timestamptz not null default now(),
primary key (user_id, key)
);
SQLite 版本(server/db/migrations-sqlite.ts)与 PG 保持镜像。三个设计要点(迁移注释中写明):
key列接受任意字符串,加键不需要迁移——服务端 handler 的键白名单(USER_PREFERENCE_KEYS)才是真正的强制边界;value_json对 DB 层不透明——形状校验完全由服务端 per-key TypeBox schema 在 HTTP 边界(读写两条路径)完成;on delete cascade——管理员删除用户时,其偏好随用户记录原子删除。
value_json 列后缀 _json 触发 SQLite 适配器写入时自动 stringify、读取时自动 parse,因此仓库层(server/repositories/userPreferences.ts)直接传递普通 JS 对象,序列化由方言适配器透明处理。
HTTP 接口与白名单校验
GET /admin/api/cms/me/preferences/:key → { value } | { value: null }(未设置时)
PUT /admin/api/cms/me/preferences/:key → 保存 value
DELETE /admin/api/cms/me/preferences/:key → 重置
Handler 为 server/handlers/cms/userPreferences.ts,能力边界:任何已认证用户只能管理自己的偏好。语义细节(源码注释明确):
- 未设置返回
200 { value: null }而非 404——这些偏好全部可选,客户端本就回退默认值,404 只会变成"看起来像真实失败"的控制台噪音;null是唯一的"未设置"信号(每个偏好的值 schema 都是对象,绝不可能是 null); :key必须命中白名单USER_PREFERENCE_KEYS(定义于 src/core/persistence/userPreferences.ts),未知键返回 400——插件或第三方代码无法借该接口在用户记录中"抢占"任意键(插件有自己的存储面cms.storage);- PUT 采用两层校验:外层
{ value: Type.Unknown() }信封校验,内层 value 再按 per-key schema 校验,规避 TypeScript 泛型推断问题,同时让"信封畸形 → 400"与"value 畸形 → 400 + 具体 TypeBox 错误路径"边界干净; - GET 时对存储值重新校验——DB 行被手工改动或 schema 重命名导致的版本错位,会以 500 暴露而不是静默把垃圾数据发给客户端;
- DELETE 无论行是否存在都返回 204(仓库层返回
rowCount > 0布尔值,handler 不区分,两者都视为"已回到默认")。
服务端 schema 单一来源是 src/core/persistence/userPreferences.ts:USER_PREFERENCE_SCHEMAS 同时供客户端类型推导与服务端校验使用,getUserPreference('dashboard-layout') 能推断出 DashboardLayoutPreference | null 的精确类型,全程无 as Foo 强转。客户端的 getUserPreference / setUserPreference 辅助函数封装了 /admin/api/cms/me/preferences 基路径下的 GET / PUT 往返。
读取模式:safeParseJson 与 parseJsonWithFallback
标准读取模式(原文档核心代码,含实现细节扩充):
import { safeParseJson, parseJsonWithFallback } from '@core/utils/jsonValidate'
// 硬失败:损坏视为错误
const result = safeParseJson(localStorage.getItem('instatic-...') ?? '', Schema)
if (!result.ok) throw result.error
// 软失败(典型场景):损坏回退默认值
const value = parseJsonWithFallback(
localStorage.getItem('instatic-...') ?? '',
Schema,
DEFAULTS,
)
底层实现(src/core/utils/jsonValidate.ts)说明了两者的差异:
safeParseJson返回可辨识联合{ ok: true; value } | { ok: false; error },内部把"不是合法 JSON"与"是 JSON 但形状不符"统一为失败——调用方无需区分,两者都意味着"丢弃并回退默认"或"返回 400";parseJsonWithFallback是便捷包装:raw为null/undefined/ 空串时直接返回默认值,否则走safeParseJson,失败即回退;- 另有
parseJsonResponse用于解析 HTTP 响应体——那是"畸形响应是真实错误"的场景,失败直接抛出,让上层错误边界接管。
parseJsonWithFallback 是默认选择:用户不应该因为 localStorage 被截断就看到一个坏掉的编辑器。TypeBox 的 schema 验证细节可参考 docs/reference/typebox-patterns.md。
写入模式:TypeBox schema + additionalProperties
import { Type } from '@core/utils/typeboxHelpers'
const Schema = Type.Object({
view: Type.Union([Type.Literal('grid'), Type.Literal('list')]),
}, { additionalProperties: true })
const next = { view: 'grid' as const }
localStorage.setItem('instatic-...', JSON.stringify(next))
schema 上的 additionalProperties: true 让旧客户端能读取新数据:未知键在往返(读-改-写)时被原样保留。这一点在"某个功能发布了新键,而旧代码标签页仍在运行"的窗口期至关重要——新键不会因为旧代码的重写而丢失。这也是为什么编辑器偏好的 schema 从 catalog 派生时,每个条目都声明为 Type.Optional(...):缺失字段可接受,旧快照不会让新读取器崩溃。
版本化约定:何时、如何 bump -vN
当存储形状发生不兼容变更时,递增后缀:
instatic-editor-layout-v2 → instatic-editor-layout-v3
旧键留在 localStorage 中供未升级用户继续使用,新键从零开始。不要做数据迁移——让旧数据随用户代理的 GC 自然回收即可。选用标准(原文档的精确表述):
additionalProperties: true覆盖常见场景(新增一个可选字段);- 只有字段形状变化(对象变数组、枚举值被移除)时才使用
-vNbump。
以剪贴板为实例:v2 载荷把单根 rootNodeId 改为 rootNodeIds 数组,属于形状级变更,因此读取端对 v1 载荷"故意不兼容"(safeParseJson 直接丢弃)——剪贴板是可丢弃数据,不值得为它写迁移逻辑。
Cookbook:完整实操
新增一个客户端持久化偏好
// src/admin/pages/site/preferences/myFeature.ts
import { Type, type Static } from '@core/utils/typeboxHelpers'
import { parseJsonWithFallback } from '@core/utils/jsonValidate'
const KEY = 'instatic-my-feature-v1'
const Schema = Type.Object({
enabled: Type.Boolean(),
threshold: Type.Number(),
}, { additionalProperties: true })
type Prefs = Static<typeof Schema>
const DEFAULTS: Prefs = { enabled: true, threshold: 5 }
export function readMyFeaturePrefs(): Prefs {
return parseJsonWithFallback(localStorage.getItem(KEY) ?? '', Schema, DEFAULTS)
}
export function writeMyFeaturePrefs(prefs: Prefs): void {
localStorage.setItem(KEY, JSON.stringify(prefs))
}
如果该功能是编辑器全局的,优先加入 editorPreferences.ts 的 PREFERENCE_CATALOG——Settings UI 会自动渲染开关,还能免费获得跨标签页同步、缓存与 useEditorPreference 钩子。
新增一个服务端持久化偏好
// server/repositories/userPreferences.ts(扩展)
const MY_FEATURE_KEY = 'my-feature'
export async function getMyFeature(db, userId): Promise<MyFeaturePrefs> {
const row = await getUserPreferenceRow(db, userId, MY_FEATURE_KEY)
return row ? parseValue(MyFeatureSchema, row) : MY_FEATURE_DEFAULTS
}
完整流程还包含:在 src/core/persistence/userPreferences.ts 的 USER_PREFERENCE_KEYS 白名单加键名、在 USER_PREFERENCE_SCHEMAS 加对应 schema(服务端 handler 靠它做读写两侧校验),再写一个客户端 hook 请求 GET /me/preferences/my-feature。更完整的偏好目录模式见 docs/features/editor-preferences.md。
测试时清空某个键
localStorage.removeItem('instatic-...') // 下次读取自动回退默认值
端到端测试的规范重置是清空所有 instatic- 与 spotlight: 键:
for (const key of Object.keys(localStorage)) {
if (key.startsWith('instatic-') || key.startsWith('spotlight:')) {
localStorage.removeItem(key)
}
}
禁止模式一览
| 禁止模式 | 正确做法 |
|---|---|
键名不带 instatic- 前缀 |
一律加 instatic- 前缀(Spotlight 专属用 spotlight:) |
JSON.parse(localStorage.getItem('instatic-...') ?? '{}') |
parseJsonWithFallback(raw, Schema, DEFAULTS) |
手动静默捕获 JSON.parse 错误 |
交给辅助函数处理 |
| 在 localStorage 存 secrets(token、密码) | secrets 只存在于 Cookie(HttpOnly) |
| 用 setTimeout 轮询做跨标签页广播 | 原生 storage 事件(跨标签页)或 CustomEvent(同标签页),参考 editorPreferences.ts |
| 在 localStorage 存大 blob(>1MB) | 用 IndexedDB(罕见——绝大多数 CMS 状态在服务端) |
| 用 sessionStorage 存放需要跨页面重载存活的状态 | 用 localStorage;session 只用于在途的跨跳转状态 |
| 原地改 schema 而不 bump 键名 | 形状不兼容变更时 bump -vN |
相关文档
- docs/features/editor-preferences.md —— 规范化的偏好目录(PREFERENCE_CATALOG)
- docs/features/dashboard.md —— 仪表盘布局持久化(
dashboard-layout) - docs/features/spotlight.md —— Spotlight 最近命令 + 遥测(
spotlight:*键) - docs/reference/typebox-patterns.md ——
parseJsonWithFallback、safeParseJson与 TypeBox 模式 - 权威来源文件(精选):
- src/admin/pages/site/preferences/editorPreferences.ts ——
EDITOR_PREFS_KEY - src/admin/state/workspaceLayoutStorage.ts ——
EDITOR_LAYOUT_STORAGE_KEY - src/admin/pages/site/store/clipboard/clipboardStorage.ts ——
CLIPBOARD_STORAGE_KEY - src/admin/spotlight/recentStore.ts —— Spotlight 最近命令
- src/core/utils/jsonValidate.ts ——
safeParseJson/parseJsonWithFallback实现 - src/core/persistence/userPreferences.ts —— 键白名单 + per-key schema 单一来源
- server/repositories/userPreferences.ts ——
user_preferences行 CRUD - server/handlers/cms/userPreferences.ts ——
/admin/api/cms/me/preferences/:key
- src/admin/pages/site/preferences/editorPreferences.ts ——