Instatic 持久化键位完全指南:从 localStorage 到 user_preferences 的数据存放规范

原创2026-09-15 22:22:481,181 阅读
文章标签:CMS后端前端

本指南以 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 的完整版):

  1. 所有客户端持久化键统一前缀instatic-(Spotlight 专属键用 spotlight:),且与站点/模块的 CSS 类名互不冲突;
  2. 所有服务端用户偏好存放在 user_preferences 表中,以 (user_id, key) 为主键;
  3. 所有读取都经过 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.tsEDITOR_PREFS_KEY
instatic-editor-layout-v2 各工作区(site / content / data / media)侧边栏宽度 + 开合状态 + 浮动面板位置 src/admin/state/workspaceLayoutStorage.tsEDITOR_LAYOUT_STORAGE_KEY
instatic-clipboard-v1 编辑器剪贴板(图层子树复制 / 剪切 / 粘贴) src/admin/pages/site/store/clipboard/clipboardStorage.tsCLIPBOARD_STORAGE_KEY
instatic-class-usage ClassPicker 自动补全中最近使用的类 src/admin/pages/site/preferences/classUsage.tsCLASS_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.tsVIEW_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 所有者 权威来源文件
instatic_admin_session Admin 会话令牌(原始值,查找时先哈希) server/auth/tokens.tsSESSION_COOKIE_NAME

属性组合:HttpOnlySecure(生产环境走 TLS 时)、SameSite=LaxPath=/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 保持镜像。三个设计要点(迁移注释中写明):

  1. key 列接受任意字符串,加键不需要迁移——服务端 handler 的键白名单(USER_PREFERENCE_KEYS)才是真正的强制边界;
  2. value_json 对 DB 层不透明——形状校验完全由服务端 per-key TypeBox schema 在 HTTP 边界(读写两条路径)完成;
  3. 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.tsUSER_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 是便捷包装:rawnull / 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 覆盖常见场景(新增一个可选字段);
  • 只有字段形状变化(对象变数组、枚举值被移除)时才使用 -vN bump。

以剪贴板为实例: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.tsPREFERENCE_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.tsUSER_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

相关文档

Instatic