Open-Pencil Vue SDK 进阶:用 useVariables() 直接控制变量集合、模式与 CRUD
Open-Pencil Vue SDK 进阶:用 useVariables() 直接控制变量集合、模式与 CRUD
useVariables() 是 Open-Pencil Vue SDK 中位于 @open-pencil/vue 包底层的变量编辑组合式函数,它把「变量集合(collections)、活跃模式(modes)、过滤(filtering)与增删改查(CRUD)」这一整套状态和动作直接暴露给开发者,让你无需依赖预制好的表格或对话框 UI,就能为编辑器构建自定义的变量面板。读完本文,你将掌握 useVariables() 的完整返回值与用法、其背后的源码实现与撤销/重做机制,并能自行组装出符合业务需求的变量编辑界面。
useVariables 是什么:底层变量编辑状态
useVariables() 是 Open-Pencil 变量编辑体系中最底层的组合式函数。官方文档(法语版关联文档)如此定位:它「提供变量编辑器的低层级状态与动作」,适用于「希望在无需完整 table/dialog 抽象的情况下,直接控制 collections、active modes、filtering 以及 CRUD 操作」的场景。与之相对,上层还提供了 useVariablesEditor、useVariablesDialogState、useVariablesTable 三个更完整的抽象,后文会说明它们与 useVariables() 的层级关系。
在 Open-Pencil 的 Vue 组件树中,useVariables() 依赖 useEditor() 从编辑器上下文读取 Editor 实例,再通过 useSceneComputed() 将场景数据计算为响应式引用——也就是说,必须先在组件树上层调用 provideEditor() 注入编辑器上下文,useVariables() 才能正常工作。从 packages/vue/src/variables/use.ts 的实现可以看到,它从 useEditor() 开始,串联了 useSceneComputed 与两个 helpers 工厂:
// packages/vue/src/variables/use.ts
import { ref, computed, watch } from 'vue'
import { useEditor } from '#vue/editor/context'
import { useSceneComputed } from '#vue/internal/scene-computed/use'
import { createVariableCollectionActions, createVariableValueActions } from '#vue/variables/helpers'
export function useVariables() {
const editor = useEditor()
const searchTerm = ref('')
function setSearchTerm(term: string) { searchTerm.value = term }
const collections = useSceneComputed(() => editor.getCollections())
const activeCollectionId = ref(collections.value[0]?.id ?? '')
watch(collections, (cols) => {
if (!activeCollectionId.value && cols[0]) activeCollectionId.value = cols[0].id
})
const activeCollection = computed(() => editor.getCollection(activeCollectionId.value) ?? null)
const activeModes = computed(() => activeCollection.value?.modes ?? [])
const variables = useSceneComputed(() => {
if (!activeCollectionId.value) return [] as Variable[]
const all = editor.getVariablesForCollection(activeCollectionId.value)
if (!searchTerm.value) return all
const q = searchTerm.value.toLowerCase()
return all.filter((v) => v.name.toLowerCase().includes(q))
})
const collectionActions = createVariableCollectionActions(editor, activeCollectionId)
const variableActions = createVariableValueActions(editor, () => activeCollection.value)
return {
editor, collections, activeCollectionId, activeCollection,
activeModes, variables, searchTerm, setSearchTerm,
...collectionActions, ...variableActions
}
}
一个关键实现细节是:activeCollectionId 默认取第一个集合的 id(若存在),并通过 watch(collections) 在集合列表变化时自动补位——当集合被删除或场景初始为空时,它会回退到 '' 或重新指向第一个集合。这说明 useVariables() 是「跟随当前活跃集合」设计的,所有变量级操作都作用于 activeCollection。
返回值总览:状态与动作一览
useVariables() 的返回对象包含只读状态、过滤状态与大量 CRUD 动作。以下表格汇总全部返回值(依据官方英文文档 packages/docs/programmable/sdk/api/advanced/use-variables.md 与源码实现):
| 返回值 | 类型 | 说明 |
|---|---|---|
editor |
Editor |
底层编辑器实例,可继续调用其他编辑器 API |
collections |
响应式数组 | 全部变量集合(VariableCollection[]),基于 editor.getCollections() |
activeCollectionId |
Ref<string> |
当前活跃集合 id,默认取第一个集合 |
activeCollection |
计算属性 | 当前活跃集合对象(VariableCollection | null) |
activeModes |
计算属性 | 活跃集合下的全部模式(VariableCollectionMode[]) |
variables |
响应式数组 | 活跃集合内的变量,受 searchTerm 过滤 |
searchTerm / setSearchTerm() |
Ref<string> / (term: string) => void |
名称过滤状态及其写入函数 |
setActiveCollection(id) |
函数 | 切换活跃集合 |
addCollection() |
函数 | 新增集合并自动切换为活跃集合 |
renameCollection(id, newName) |
函数 | 重命名集合 |
removeCollection(id) |
函数 | 删除集合并回退活跃选择 |
addMode() |
函数 | 在活跃集合中新增模式,返回新模式 id |
removeMode(modeId) |
函数 | 删除模式(集合至少保留一个) |
renameMode(modeId, newName) |
函数 | 重命名模式 |
setDefaultMode(modeId) |
函数 | 设置默认模式 |
duplicateMode(modeId) |
函数 | 复制模式(含各变量的值) |
setActiveMode(modeId) |
函数 | 设置当前活跃模式 |
addVariable(type?) |
函数 | 新增变量(默认 'COLOR'),为每个模式填充默认值 |
removeVariable(id) / renameVariable(id, newName) |
函数 | 删除 / 重命名变量 |
updateVariableValue(id, modeId, value) |
函数 | 写入某个变量在某个模式下的值 |
formatModeValue(variable, modeId) |
函数 | 把模式值格式化为显示字符串 |
parseVariableValue(variable, raw) |
函数 | 把用户输入字符串解析为类型化值 |
shortName(variable) |
函数 | 提取变量名的最后一段(用于层级名展示) |
集合与模式操作详解
集合操作定义在 packages/vue/src/variables/helpers.ts 的 createVariableCollectionActions 中,全部基于编辑器实例执行并同步响应式状态:
addCollection()内部构造一个VariableCollection,id 采用col:${randomHex(8)}的格式,初始自带一个Mode 1模式(modeId: 'default')并把它设为默认模式,随后调用editor.addCollection(collection)并立即把activeCollectionId指向新集合——因此新增后界面会直接切换到新集合。removeCollection(id)删除集合后,会从editor.getCollections()重新取列表,把活跃集合回退到第一个(或''),保证后续操作不会指向不存在的集合。- 模式操作(
addMode/removeMode/renameMode/setDefaultMode/duplicateMode/setActiveMode)都会在helpers.ts中先校验活跃集合是否存在,再委托给编辑器;removeMode在packages/core/src/editor/variables.ts中还会做「至少保留一个模式」的保护,避免把集合删成空模式列表。
变量 CRUD 与类型化取值
变量操作定义在 packages/vue/src/variables/helpers.ts 的 createVariableValueActions 中。Open-Pencil 的变量类型由 VariableType 定义(packages/scene-graph/src/types.ts):
export type VariableType = 'COLOR' | 'FLOAT' | 'STRING' | 'BOOLEAN'
export type VariableValue = Color | number | string | boolean | { aliasId: string }
addVariable(type) 是其中最关键的工厂函数:它先读取活跃集合的全部模式,为每个模式生成一份默认值(COLOR → 黑色 { ...BLACK },FLOAT → 0,BOOLEAN → false,STRING → ''),再调用 editor.addVariable() 写入场景图:
function addVariable(type: VariableType = 'COLOR') {
const col = getActiveCollection()
if (!col) return
const id = `var:${randomHex(8)}`
const valuesByMode: Record<string, VariableValue> = {}
for (const mode of col.modes) {
valuesByMode[mode.modeId] = defaultVariableValue(type)
}
editor.addVariable({
id, name: defaultVariableName(type), type,
collectionId: col.id, valuesByMode,
description: '', hiddenFromPublishing: false
})
}
默认名称也按类型区分:New color / New number / New boolean / New text。Variable 数据模型(packages/scene-graph/src/types.ts)除了 id、name、type、collectionId、valuesByMode 外,还包含 description、hiddenFromPublishing 以及可选的 key / version(用于发布库的 assetRef 解析)。
formatModeValue() 与 parseVariableValue() 是编辑界面的「显示 ↔ 解析」双向桥:
formatModeValue:颜色值转成 hex 字符串(colorToHexRaw),别名引用显示为→ 别名变量名(找不到目标则显示→ ?),其余类型String(value)化;parseVariableValue:COLOR自动补#前缀并parseColor,FLOAT用Number.parseFloat并在 NaN 时返回undefined,BOOLEAN按'true'大小写不敏感解析,STRING原样返回。
快速上手:最小示例
在自定义变量面板组件中使用 useVariables() 的最简方式如下(导入路径以 @open-pencil/vue 公共入口为准,见 packages/vue/src/index.ts 的导出声明):
import { useVariables } from '@open-pencil/vue'
const variables = useVariables()
// 读取状态
variables.collections.value // 全部集合
variables.activeCollection.value // 当前集合
variables.activeModes.value // 当前集合的模式列表
variables.variables.value // 当前集合的变量(受搜索词过滤)
// 过滤
variables.setSearchTerm('primary')
// 集合 CRUD
variables.addCollection()
variables.renameCollection(colId, 'Design tokens')
variables.removeCollection(colId)
// 变量 CRUD
variables.addVariable('COLOR')
variables.renameVariable(varId, 'Brand/primary')
variables.updateVariableValue(varId, modeId, { r: 0.2, g: 0.4, b: 0.8, a: 1 })
// 模式操作
variables.addMode()
variables.setActiveMode(modeId)
variables.duplicateMode(modeId)
注意模板中使用响应式状态时需经过 .value 或解包;而 activeCollection 与 activeModes 是 computed,在模板中可直接使用。
实战:构建一个无 UI 依赖的变量编辑面板
「无表格/对话框抽象」意味着 UI 完全由你掌控。一个典型的面板结构可以是这样:
<script setup lang="ts">
import { useVariables } from '@open-pencil/vue'
const v = useVariables()
function onAddColor() {
const id = v.addVariable('COLOR')
// id 形如 var:xxxxxxxx,可用于聚焦新行
}
</script>
<template>
<div>
<select :value="v.activeCollectionId" @change="v.setActiveCollection(($event.target as HTMLSelectElement).value)">
<option v-for="c in v.collections" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
<div v-for="mode in v.activeModes" :key="mode.modeId">
{{ mode.name }}
<button @click="v.setActiveMode(mode.modeId)">激活</button>
</div>
<input :value="v.searchTerm" @input="v.setSearchTerm(($event.target as HTMLInputElement).value)" placeholder="搜索变量" />
<table>
<tr v-for="variable in v.variables" :key="variable.id">
<td>{{ v.shortName(variable) }}</td>
<td v-for="mode in v.activeModes" :key="mode.modeId">
<input
:value="v.formatModeValue(variable, mode.modeId)"
@change="v.updateVariableValue(variable.id, mode.modeId, v.parseVariableValue(variable, ($event.target as HTMLInputElement).value) ?? '')"
/>
</td>
<td><button @click="v.renameVariable(variable.id, prompt('新名称') ?? variable.name)">重命名</button></td>
<td><button @click="v.removeVariable(variable.id)">删除</button></td>
</tr>
</table>
<button @click="onAddColor">新增颜色变量</button>
<button @click="v.addMode()">新增模式</button>
</div>
</template>
这个示例完整演示了「集合下拉 → 模式切换 → 搜索过滤 → 按模式编辑值 → 增删改」的闭环:所有交互都只调用 useVariables() 暴露的底层动作,不依赖任何预制组件。编辑场景下,颜色输入建议用 formatModeValue 展示 hex、parseVariableValue 回写;而浮点/布尔类型则可用受控输入配合 parseVariableValue 的类型化解析。
底层原理:editor 动作、场景图与撤销/重做
useVariables() 本身不直接修改场景图,所有写操作都经由 Editor 的变量动作层完成,该层实现在 packages/core/src/editor/variables.ts(createVariableActions)。核心写路径遵循同一模式:修改 ctx.graph 中的集合/变量数据 → 向 ctx.undo.push 注册带 forward / inverse 的可撤销动作 → 调用 ctx.requestRender() 触发重渲染。
以 updateVariableValue 为例(packages/core/src/editor/variables.ts):
function updateVariableValue(id: string, modeId: string, value: VariableValue) {
const variable = ctx.graph.variables.get(id)
if (!variable) return
const prevValue = structuredClone(variable.valuesByMode[modeId])
const newValue = structuredClone(value)
variable.valuesByMode[modeId] = newValue
ctx.undo.push({
label: 'Update variable value',
forward: () => { /* 重放 newValue */ },
inverse: () => { /* 恢复 prevValue */ }
})
ctx.requestRender()
}
可以看出:所有变量编辑操作都自动进入编辑器的撤销/重做历史,且 forward/inverse 在重放时各自克隆值,避免历史栈与当前场景共享同一对象引用。removeCollection 会在删除前 structuredClone 整个集合及其下属变量,以便 inverse 完整还原;removeMode 也会快照每个变量在该模式下的值。
场景图层的模式/变量语义由 packages/scene-graph/src/variables.ts 提供,其中两点值得注意:
resolveVariable(同文件 L190-L219)按「活跃模式 → 默认模式 → 第一个可用值」的优先级取值,遇到{ aliasId }会递归解析别名变量(带visited防环保护),这正是activeModes与setActiveMode影响实际渲染值的根本原因;addMode(同文件 L140-L153)在新增模式时会从sourceMode ?? defaultModeId克隆每个变量的值,这与duplicateMode的「复制模式」语义一致。
因此,通过 useVariables().setActiveMode() 切换模式后,场景中引用该变量集合的颜色/数值会自动更新——这一响应链路由 useSceneComputed 保证,开发者无需手动订阅事件。
与上层 API 的关系:选择哪一层
useVariables() 是变量编辑能力的最底层入口,Open-Pencil 在其之上还有三个渐进封装(详见 advanced/index.md 的「Variables et langue」分组):
- useVariablesDialogState:在
useVariables()之上叠加「内联重命名」状态(useInlineRename),提供startRenameCollection/startRenameMode及collectionRename/modeRename,实现见 packages/vue/src/variables/dialog/use.ts; - useVariablesTable:基于
createVariableColumns生成 TanStack Table 列配置,返回{ columns },实现见 packages/vue/src/variables/table/use.ts; - useVariablesEditor:把 dialog 状态、table 列与 TanStack 表格(
useVueTable)组合成开箱即用的完整编辑器,还额外暴露hasCollections等派生状态,实现见 packages/vue/src/variables/editor/use.ts。
选择建议:需要完全定制 UI 与交互时用 useVariables();需要「表格 + 内联重命名」但想自绘单元格时用 useVariablesTable / useVariablesDialogState;需要直接嵌入一个完整变量编辑器(含颜色输入组件、类型图标)时用 useVariablesEditor。四层 API 都已在 packages/vue/src/index.ts 中从 @open-pencil/vue 公共导出。
小结
useVariables() 是 Open-Pencil Vue SDK 变量能力的地基:它把集合、模式、过滤和 CRUD 完整地暴露为响应式状态与动作,所有写操作均落入编辑器撤销历史,并通过 useSceneComputed 与场景图保持同步。掌握了它的返回值与底层实现(packages/vue/src/variables/use.ts、helpers.ts、packages/core/src/editor/variables.ts),你就可以在 provideEditor 上下文中自由搭建面向业务的自定义变量面板,或在需要预制表格/对话框能力时平滑升级到 useVariablesDialogState、useVariablesTable 与 useVariablesEditor。