@scalar/workspace-store 深度指南:用分块加载与响应式工作区驾驭大型 OpenAPI 文档
【免费下载链接】scalarScalar is an open-source API platform: 🌐 Modern REST API Client 📖 Beautiful API References ✨ 1st-Class OpenAPI/Swagger Support项目地址: https://gitcode.com/GitHub_Trending/sc/scalar
导读
@scalar/workspace-store是 Scalar 开源 API 平台中负责管理 OpenAPI 文档的存储层,它同时提供**服务端(Server-Side)与客户端(Client-Side)**两套工作区存储实现:服务端将大型 OpenAPI 文档拆分为可按需解析的"分块"(chunk),显著降低初始加载开销;客户端则提供基于 Vue 响应式的内存工作区,支持多文档管理、变更追踪、保存/回滚、覆盖与 rebase。读完本文,你将掌握如何在 SSR 与静态站点两种模式下创建服务端工作区、如何按 JSON Pointer 按需取块,以及如何在浏览器端构建可持久化、可覆盖、可合并上游变更的响应式文档工作区。
一、为什么需要工作区存储:大型 OpenAPI 文档的加载难题
一个包含大量 schema、path operation 的 OpenAPI 文档可能高达数 MB。如果每次打开 API 文档页面都要把整份 JSON 一次性传输并解析,初始加载时间会随文档体积线性恶化。@scalar/workspace-store的解法是文档分块(document chunking):服务端在构建期把文档拆成"稀疏文档 + 独立分块",客户端只加载骨架与当前需要的块,从而在源头上削减首屏载荷。
从 package.json 可以看到,该包定位为 "Store interface for openapi documents",依赖了@scalar/openapi-upgrader(版本升级)、@scalar/schemas(schema 定义)、@scalar/validation(校验)、@scalar/json-magic(打包与 diff/merge)、vue(响应式)与yaml(序列化)等内部与公共库,包版本为 0.60.0,要求 Node.js >= 22,并以 ESM 方式发布。它对外暴露了./server、./client、./resolve、./schemas/*、./mutators、./persistence、./events等子路径,便于按需引入。
服务端与客户端分工明确:
| 能力 | 服务端存储 | 客户端存储 |
|---|---|---|
| 分块/懒加载 | ✅ 核心能力(SSR/static) | ✅ 消费分块并按需resolve |
| 响应式状态 | ❌ 只读工作区载荷 | ✅ Vuereactive工作区 |
| 变更追踪 | ❌ | ✅ dirty 标记 + 插件事件广播 |
| 持久化/导出 | ❌ | ✅ 双快照、JSON/YAML 导出 |
| 上游合并(rebase) | ❌ | ✅ 三方 diff/merge |
二、服务端工作区存储:SSR 与静态两种模式
服务端存储由createServerWorkspaceStore创建,入口实现位于 server.ts。它接受三种文档输入:document(内存对象)、url(远程地址)、path(本地文件路径),并根据mode分为两种行为:
ssr模式:需要提供baseUrl,生成的分块引用指向该 baseUrl 下的 API 端点(如https://example.com/document-name/operations/~1planets/get#),由服务端按需响应。static模式:需要提供directory(默认值为'assets',见 server.ts),生成的引用指向文件系统中的相对路径(如./chunks/document-name/operations/~1planets/get.json#),适合静态站点托管。
2.1 SSR 模式:创建与使用
// Create the store const store = await createServerWorkspaceStore({ baseUrl: 'example.com', mode: 'ssr', meta: { 'x-scalar-active-document': 'document-name' }, documents: [ { name: 'document-name', meta: {}, document: { openapi: '3.1.1', info: { title: 'Hello World', version: '1.0.0', }, components: { schemas: { Person: { type: 'object', properties: { name: { type: 'string' }, }, }, User: { $ref: '#/components/schemas/Person', }, }, }, }, }, ], }) // Add a new document to the store await store.addDocument( { openapi: '3.1.1', info: { title: 'Hello World', version: '1.0.0', }, components: { schemas: { Person: { type: 'object', properties: { name: { type: 'string' }, }, }, User: { $ref: '#/components/schemas/Person', }, }, }, }, { 'name': 'document-2', 'x-scalar-selected-server': 'server1', }, ) // Get the workspace // Workspace is going to keep all the sparse documents const workspace = store.getWorkspace() // Get chucks using json pointers const chunk = store.get('#/document-name/components/schemas/Person')getWorkspace()返回的 workspace 是稀疏文档集合:每个文档只保留元数据、导航信息,以及被外部化(externalized)为$ref的 components 与 operations。get(pointer)则按 JSON Pointer 从内存资产中取出对应分块,指针既可以是#/document-name/...这种以#开头的本地形式,也可以是绝对 URL——get内部会先剥离#前缀,再把路径段经escapeJsonPointer处理后从资产树取值(见 server.ts)。
2.2 static 模式:生成文件系统分块
// Create the store const store = await createServerWorkspaceStore({ directory: 'assets', mode: 'static', meta: { 'x-scalar-active-document': 'document-name' }, documents: [ { name: 'document-name', meta: {}, document: { openapi: '3.1.1', info: { title: 'Hello World', version: '1.0.0', }, components: { schemas: { Person: { type: 'object', properties: { name: { type: 'string' }, }, }, User: { $ref: '#/components/schemas/Person', }, }, }, }, }, ], }) // Add a new document to the store await store.addDocument( { openapi: '3.1.1', info: { title: 'Hello World', version: '1.0.0', }, components: { schemas: { Person: { type: 'object', properties: { name: { type: 'string' }, }, }, User: { $ref: '#/components/schemas/Person', }, }, }, }, { 'name': 'document-2', 'x-scalar-selected-server': 'server1', }, ) // Generate the workspace file system // This will write in the filesystem the workspace and all the chucks // which can be resolved by the consumer const workspace = await store.generateWorkspaceChunks()generateWorkspaceChunks()仅在mode: 'static'下可用,否则直接抛出'Mode has to be set to static to generate filesystem workspace chunks'(见 server.ts)。它在directory(默认assets)下生成如下文件结构:
assets/ ├── scalar-workspace.json # 整个稀疏 workspace(WORKSPACE_FILE_NAME) └── chunks/ └── document-name/ ├── components/ │ ├── schemas/ │ │ ├── Person.json │ │ └── User.json │ └── parameters/... └── operations/ └── ~1planets/ └── get.json这里有几个值得注意的源码级细节:
- 主文件名由常量
WORKSPACE_FILE_NAME = 'scalar-workspace.json'定义(server.ts)。 - 路径转义:OpenAPI 的 path 键(如
/users/{id})与 component 键都会经escapeJsonPointer转义后写入磁盘(/→~1)。这不只是为了可寻址,也是安全措施:CHANGELOG 0.60.0 明确指出,转义能防止文档里形如../../evil的键把分块文件写到 assets 目录之外(CHANGELOG.md)。 - 分块维度:components 按
type/name拆成独立 JSON 文件;operations 按path/method拆文件。filterHttpMethodsOnly只保留get/put/post/delete/options/head/patch/trace标准方法,并跳过x-开头的扩展键(server.ts);escapePaths再对 path 键做 JSON Pointer 转义(server.ts)。 - 引用外部化:
externalizeComponentReferences与externalizePathReferences把原文档中的 components 和 operations 替换为带$global: true的$ref。SSR 模式指向${baseUrl}/${name}/...,static 模式指向./chunks/${name}/...(server.ts)。
2.3 从外部源加载文档
服务端存储内置了fetchUrls(Node 环境 fetch)与readFiles两个加载插件(见 server.ts),因此可以直接从 URL 或文件系统初始化:
// Initialize the store with documents from external sources const store = await createServerWorkspaceStore({ mode: 'static', documents: [ { name: 'remoteFile', url: 'http://localhost/document.json', }, { name: 'fsFile', path: './document.json', }, ], }) // Output: { openapi: 'x.x.x', ... } console.log(store.getWorkspace().documents.remoteFile) // Output: { openapi: 'x.x.x', ... } console.log(store.getWorkspace().documents.fsFile)加载失败或处理失败的文档会被跳过而不是让整个工作区崩溃——初始文档是并发批量摄入的(Promise.all),一份畸形描述不应拖垮整次文档构建(server.ts)。此外,文档名还会经过preventPollution校验,像__proto__这样的危险名称会被直接拒绝,防止原型污染。
2.4 AsyncAPI 文档的特殊处理
从源码看,服务端存储对 AsyncAPI 文档走的是独立摄入路径:AsyncAPI 的内容位于channels与operations而非paths,因此不做分块外部化,而是整体保留;它会用@scalar/asyncapi-upgrader把 1.x/2.x 升级到 3.x 形态,并记录原始版本号到x-original-aas-version(server.ts)。这意味着该包同时是 OpenAPI 与 AsyncAPI 文档的统一工作区。
三、客户端工作区存储:响应式的 OpenAPI 文档工作区
客户端存储createWorkspaceStore是一个Vue 响应式工作区,入口实现在 client.ts。它与服务端存储天然配套:客户端消费服务端生成的稀疏文档与分块,按需resolve,并对用户的每一次编辑做出响应。与"构造时一次性灌入文档"的服务端不同,客户端 store以空状态启动,通过addDocument逐个加载文档。
3.1 基础用法
// Initialize a new (empty) workspace store const store = createWorkspaceStore({ meta: { 'x-scalar-active-document': 'default', }, }) // Add the default document await store.addDocument({ name: 'default', document: { openapi: '3.1.0', info: { title: 'OpenApi document', version: '1.0.0', }, }, }) // Add another OpenAPI document to the workspace await store.addDocument({ name: 'document', document: { openapi: '3.1.0', info: { title: 'Another document', version: '1.0.0', }, }, }) // Get the currently active document store.workspace.activeDocument // Retrieve a specific document by name store.workspace.documents['document'] // Update global workspace settings store.update('x-scalar-color-mode', true) // Update settings for the active document store.updateDocument('active', 'x-scalar-selected-server', 'production') // Resolve and load document chunks including any $ref references await store.resolve(['paths', '/users', 'get'])各 API 的行为要点(均有源码注释佐证,见 client.ts):
store.workspace:Vuereactive工作区对象,额外带一个activeDocumentgetter。活跃文档由x-scalar-active-document元数据决定,未指定时回退到工作区中的第一份文档。update(key, value):更新工作区级元数据,例如x-scalar-color-mode、x-scalar-active-document。updateDocument(name, key, value):更新指定文档的元数据;name传'active'即可作用于当前活跃文档。返回布尔值表示是否成功。resolve(path):按路径数组(如['paths', '/users', 'get'])在活跃文档中解析引用,遇到$ref会加载对应分块并在解析期间设置 loading 状态。
3.2 变更追踪与响应式底层
工作区对象的构建顺序很有讲究:先用createDetectChangesProxy包裹原始数据,再交给 Vue 的reactive——注释明确警告"外层必须是 Vue 的响应式代理,顺序颠倒会导致失去响应式"(client.ts)。变更检测代理在每次写入后触发onAfterChange钩子:
- 文档内容被修改时,自动置
x-scalar-is-dirty = true,并把变更事件广播给注册的插件(fireWorkspaceChange)。 x-scalar-is-dirty与x-scalar-registry-meta被列为metadata-only 键:对它们的写入属于程序化簿记(如提交哈希、冲突缓存),不会误标 dirty(client.ts)。
客户端 store 还支持传入verbose: true开启内部计时日志(默认关闭),以及plugins、fileLoader(非浏览器环境加载本地文件用)、fetch覆盖等构造参数。
3.3 从外部源加载文档
const store = createWorkspaceStore() // Load a document into the store from a remote url await store.addDocument({ name: 'default', url: 'http://localhost/document.json', }) // Output: { openapi: 'x.x.x', ... } console.log(store.workspace.documents.default)客户端addDocument同样支持url、path(需配置fileLoader)与document三种输入,返回布尔值表示是否添加成功(client.ts)。远程加载走@scalar/json-magic/bundle的fetchUrls插件,且受EXTERNAL_FETCH_CONCURRENCY_LIMIT = 10并发上限约束——防止大型文档引用成千上万个外部示例时一次性打开无界连接(client.ts)。
四、文档持久化与导出:original 与 active 双快照
客户端工作区在运行期对每份文档维护两份快照:
- original(原始基线):用户最近一次通过
saveDocument提交的已保存状态,也是文档刚载入工作区时的状态。 - active(活跃文档):响应式的内存状态,可能包含未保存的编辑。
Deprecated(已弃用):额外的
intermediateDocuments映射及其辅助方法getIntermediateDocument/promoteIntermediateToOriginal仅为向后兼容而保留,不再是权威数据。新代码应依赖getOriginalDocument与活跃文档;中间映射在保存/回滚/rebase 时同步维护,直到该层被彻底移除。
大部分持久化方法都以这两份快照为锚点。
4.1 导出文档(Export)
exportDocument按 JSON 或 YAML 导出指定文档。导出读取的是已保存基线(与revertDocumentChanges恢复的内容一致),因此永远反映用户最后一次保存,而不是未保存的编辑:
// Export the specified document as JSON const jsonString = store.exportDocument('documentName', 'json') // Export the specified document as YAML const yamlString = store.exportDocument('documentName', 'yaml') // Or export the currently active document directly const activeJson = store.exportActiveDocument('json')导出路径会先经过purgeInternalDocumentKeys清理:x-ext、x-ext-urls(打包器临时元数据)、x-scalar-navigation、x-scalar-is-dirty、x-original-oas-version、x-scalar-original-document-hash、x-scalar-original-source-url、x-scalar-registry-meta等内部键都会被剔除,确保导出的文档干净、可分发(client.ts)。
4.2 保存文档变更(Save)
saveDocument把当前内存文档提升为新的已保存基线:将响应式文档序列化回普通对象(剥离打包器内部键),写入 original 文档映射,并清除x-scalar-is-dirty标记:
// Save the specified document state const ok = await store.saveDocument('documentName') if (!ok) { console.warn('Document does not exist or could not be serialised') }saveDocument成功返回true;文档不存在或无法序列化回 original 映射时返回false。
4.3 回滚文档变更(Revert)
// Revert the specified document to its last saved state await store.revertDocumentChanges('documentName')revertDocumentChanges从 original 文档映射恢复活跃文档——即saveDocument最后一次写入的内容(若从未保存过,则是文档首次载入工作区时的状态)。它通过原地更新现有响应式对象来保留 Vue 响应性。
警告:该操作会丢弃指定文档的全部未保存更改。
4.4 完整示例
const store = createWorkspaceStore() await store.addDocument({ name: 'api', document: { openapi: '3.0.0', info: { title: 'My API', version: '1.0.0' }, paths: {}, }, }) // Make some changes to the document store.workspace.documents['api'].info.title = 'Updated API Title' // Restore the saved baseline since the changes were never saved await store.revertDocumentChanges('api')五、工作区状态持久化:导出与恢复整个工作区
exportWorkspace/loadWorkspace用于把完整工作区状态(全部文档、配置、元数据、original 与中间映射,以及移除 Vue 响应式后的文档对象)序列化后保存,或从序列化结果恢复。这是跨会话保存工作、分享工作区配置的基础:
const client = createWorkspaceStore() // Get the current workspace state const currentWorkspaceState = client.exportWorkspace() // Persist on some kind of storage // Reload the workspace state client.loadWorkspace(currentWorkspaceState)exportWorkspace返回的InMemoryWorkspace对象可直接JSON.stringify存储(对应源码中的InMemoryWorkspace类型,见 inmemory-workspace.ts);loadWorkspace则整体替换当前工作区的文档、元数据与配置。
六、整体替换文档:replaceDocument
当拿到一份全新或已更新的 OpenAPI 文档、需要覆盖既有文档时,replaceDocument会在原位置原子地更新整份文档:它先计算新旧内容的差异,再只应用必要变更,兼顾正确性与性能:
const client = createWorkspaceStore() await client.addDocument({ name: 'document-name', document: { openapi: '3.1.0', info: { title: 'Document Title', version: '1.0.0', }, paths: {}, components: { schemas: {}, }, servers: [], }, }) // Update the document with the new changes await client.replaceDocument('document-name', { openapi: '3.1.0', info: { title: 'Updated Document', version: '1.0.0', }, paths: {}, components: { schemas: {}, }, servers: [], })其 diff/apply 能力来自@scalar/json-magic/diff(diff、apply函数),与 rebase 的三方合并共用同一套差异引擎(client.ts)。
七、从工作区规范创建:importWorkspaceFromSpecification
可以用一份"工作区规范"对象一次性初始化工作区:规范里的documents通过$ref指向各文档来源,overrides可为每个文档注入定制配置,info与x-scalar-*键则作为工作区元数据:
await store.importWorkspaceFromSpecification({ 'workspace': 'draft', 'info': { title: 'My Workspace' }, 'documents': { api: { $ref: '/examples/api.yaml' }, petstore: { $ref: '/examples/petstore.yaml' }, }, 'overrides': { api: { servers: [ { url: 'http://localhost:9090', }, ], }, }, 'x-scalar-color-mode': true, })该方法为规范中的每个文档调用addDocument(使用各自的$ref与可选overrides),返回一个布尔数组表示各文档是否添加成功(client.ts)。
八、字段覆盖:overrides
覆盖(overrides)用于在不改动原始来源的前提下定制文档中的特定字段。所有覆盖都只存在于内存中,永远不会写回原始文档,原始来源保持不变,修改被隔离在当前会话内:
const store = createWorkspaceStore() await store.addDocument({ name: 'default', document: { openapi: '3.1.0', info: { title: 'Document Title', version: '1.0.0', }, paths: {}, components: { schemas: {}, }, servers: [], }, // Override the servers field overrides: { servers: [ { url: 'http://localhost:8080', description: 'Default dev server', }, ], }, })覆盖通过@scalar/json-magic的 magic proxy 与createOverridesProxy辅助函数在运行时层叠生效(overrides-proxy.ts),这也是WorkspaceDocumentMetaInput.overrides被类型化为PartialDeep<OpenApiDocument>的原因(client.ts)。
九、与上游同步:rebaseDocument 三方合并
rebaseDocument将工作区文档与新的上游来源(origin)对齐,执行三方合并,合入两路差异:
- incoming changes(上游变更):
diff(originalDocument, newOrigin) - local changes(本地变更):
diff(originalDocument, activeDocument)
调用返回一个可判别(discriminated)的结果:
ok: false时,type字段说明未执行原因:CORRUPTED_STATE、FETCH_FAILED或NO_CHANGES_DETECTED。ok: true时,返回可自动合并的changes、需要用户介入的conflicts,以及把合并结果写回工作区的applyChanges回调。
// Fetch the latest origin and start a rebase const result = await store.rebaseDocument({ name: 'api', // Any `WorkspaceDocumentInput` is accepted - inline document, url, or path url: 'https://example.com/api/openapi.json', }) if (!result.ok) { console.warn(`Rebase did not run: ${result.type}`) return } if (result.conflicts.length === 0) { // No conflicts - just apply with an empty resolution set await result.applyChanges({ resolvedConflicts: [] }) return } // Surface the conflicts to the user. Each conflict is a tuple of // [incomingDiffs, localDiffs] - resolve by picking either side, or by // providing a fully resolved document. const resolvedConflicts = result.conflicts.flatMap(([incoming]) => incoming) await result.applyChanges({ resolvedConflicts }) // Or, pass a complete document to use as-is (overrides the merge result): await result.applyChanges({ resolvedDocument: newDocument })关键语义:applyChanges返回后,合并结果会同时成为新的活跃文档与新的已保存基线——因此紧接着执行revertDocumentChanges会回滚到 rebase 后的状态,而不是 rebase 前的 original。这一行为来自合并引擎@scalar/json-magic/diff的merge函数,测试覆盖见 client.test.ts。
十、源码结构:继续深入的地图
如果希望进一步理解实现,以下文件是很好的起点:
- 服务端核心:server.ts——
createServerWorkspaceStore、分块生成、引用外部化、get/getWorkspace; - 客户端核心:client.ts——
createWorkspaceStore、响应式工作区、双快照持久化、rebase、导出导入; - 服务端测试:server.test.ts——SSR 与 static 模式的行为断言,例如验证生成的
$ref指向https://example.com/${name}/operations/~1planets/get#(server.test.ts); - 客户端测试:client.test.ts——覆盖插件事件、持久化、rebase 等场景;
- 引用解析工具:resolve.ts——
resolve.schema用于合并兄弟引用后解析 schema; - 打包器与插件:plugins/bundler、plugins/client;
- 变更记录:CHANGELOG.md——包含分块转义安全、AsyncAPI 导航等演进细节。
小结
@scalar/workspace-store用"服务端分块 + 客户端响应式"的组合,为大型 OpenAPI/AsyncAPI 文档提供了一套完整的生命周期管理方案:服务端在构建期把重内容拆成按需加载的分块并暴露统一工作区载荷,客户端则以双快照模型支撑保存、回滚、导出、整体替换、字段覆盖与上游 rebase。无论你是在构建自己的 API 文档站点、离线优先的 API 客户端,还是需要一套可持久化的多文档工作区,这个包的设计都值得直接借鉴——其全部实现、测试与类型定义都可在本仓库的 packages/workspace-store 目录中查阅。
【免费下载链接】scalarScalar is an open-source API platform: 🌐 Modern REST API Client 📖 Beautiful API References ✨ 1st-Class OpenAPI/Swagger Support项目地址: https://gitcode.com/GitHub_Trending/sc/scalar
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考