跳转到内容
flamo
开始使用

组合多个组件并封装为新组件

不能把几个已经运行的 FlamoInstance 对象序列化后直接变成新组件。Instance 包含 Runtime、DOM、ECS、事件和资源引用,只能存在于当前运行生命周期。

要封装成可保存、可导入和可发布的新组件,应在 定义层 组合它们对应的精确 Component Ref 或 Artifact:

Component Ref / Artifact
→ Composition IR
→ canonical Artifact + dependency lock + compiler receipt
→ verify / publish
→ 新的 Component Ref
→ Runtime 创建父子 Instance 图

运行时的 mountChild()、swapChild()、reconcileChildren() 只用于临时修改当前 Instance 图,不会自动生成新的 Artifact 或 Component Release。

下面的组合会生成这棵组件树:

SearchPanel
├─ query Input
├─ submit Button
│ └─ icon SearchIcon
└─ results ResultList

规则如下:

  • key 是组合内稳定且全局唯一的 child identity,用于复用、查询、路由和热更新。
  • parentKey: null 表示该 child 直接属于新组件边界。
  • parentKey: 'submit' 表示该 child 嵌套在 submit 之下。
  • mount 描述逻辑挂载位置,可以是 root、某个 node、slot 或 portal。
  • props、slots、layout、statePolicy 和 serviceAllowlist 属于每个 child 的创建合同。
  • 重复 key、未知 parentKey 或父子循环会在编译或 Runtime commit 前失败。

parentKey 表示组件所有权层级,mount 表示内容挂到父级的哪个位置;两者不能互相替代。

inputRef、buttonRef、iconRef 和 resultListRef 应是从 Component Registry / lockfile 获得的完整 FlamoComponentRefV1,包含精确版本、Artifact hash 和 ABI,不能只写 alias。

import {
compileResolvedFlamoComposition,
defineFlamoComponent,
} from '@flamo/tools';
const definition = defineFlamoComponent({
id: 'SearchPanel',
children: [
{
key: 'query',
ref: inputRef,
parentKey: null,
mount: { kind: 'root' },
props: { placeholder: '搜索内容' },
slots: {},
statePolicy: 'preserve',
serviceAllowlist: [],
},
{
key: 'submit',
ref: buttonRef,
parentKey: null,
mount: { kind: 'root' },
props: { label: '搜索' },
slots: {},
statePolicy: 'preserve',
serviceAllowlist: [],
},
{
key: 'icon',
ref: iconRef,
parentKey: 'submit',
mount: { kind: 'slot', slotId: 'icon', outletId: 'main' },
props: {},
slots: {},
statePolicy: 'preserve',
serviceAllowlist: [],
},
{
key: 'results',
ref: resultListRef,
parentKey: null,
mount: { kind: 'root' },
props: {},
slots: {},
statePolicy: 'preserve',
serviceAllowlist: [],
},
],
surface: {
props: [
{ name: 'query', type: 'string', required: false, attribute: 'query' },
],
events: [
{ name: 'submit', detailType: '{ query: string }' },
],
methods: [
{ name: 'focus', signature: '(): void' },
],
slots: [],
publicChildRefs: ['query', 'submit'],
},
routes: [
{
kind: 'prop',
from: { childKey: null, name: 'query' },
to: { childKey: 'query', name: 'value' },
},
{
kind: 'event',
from: { childKey: 'submit', name: 'click' },
to: { childKey: null, name: 'submit' },
},
{
kind: 'method',
from: { childKey: null, name: 'focus' },
to: { childKey: 'query', name: 'focus' },
},
],
});
const compiled = await compileResolvedFlamoComposition(definition, resolver);

compileResolvedFlamoComposition() 会先通过 resolver.resolveExact() 校验每个依赖,再生成:

  • compiled.ir:规范化 Composition IR;
  • compiled.artifact:带新 boundary、exports.Default 和 composition 的 canonical Artifact;
  • compiled.dependencyLock:全部 child 的精确依赖;
  • compiled.receipt:composition、Artifact 与 lock 的 hash 证据。

只有把这些产物通过验证与发布流程晋升为新的 Component Release,组合结果才获得新的稳定 Component Ref。单纯在 JavaScript 中 export 这个对象,不等于完成 Flamo 组件发布。

组合 Artifact 中保存的是精确 child ref,因此 Runtime 必须配置能解析这些依赖的 componentResolver:

import {
createFlamoRuntime,
defineFlamoArtifact,
} from '@flamo/runtime';
const runtime = createFlamoRuntime({ componentResolver: resolver });
const artifact = defineFlamoArtifact(compiled.artifact);
const panel = await runtime.create({ artifact });
// 通过父组件的 prop route 更新 query child
panel.set({ query: 'Flamo' });
panel.children; // query、submit、results
panel.child('submit'); // 任意直接 child
panel.publicChild('query'); // 只允许 surface.publicChildRefs 中声明的 child

Runtime 读取 Artifact 中的 composition 后,会按父级优先顺序准备 children,再以一次 composition transaction 提交整棵树。父级销毁时,Runtime-owned children 会按层级递归释放。

Node Script 可以通过 ctx.components 只修改当前 Runtime 的 Instance 图。字符串只解析当前 owning Artifact 的一个 export 及其完整 definition closure;跨 Artifact 时必须传完整的 FlamoComponentRefV1,Runtime 会校验 Artifact revision、hash、Runtime ABI 与 component ABI:

import { defineNodeScript } from '@flamo/runtime';
export default defineNodeScript({
setup(_props, ctx) {
let badge;
ctx.onActivate('add-badge', async () => {
badge = await ctx.components.create('Badge', {
parent: ctx.scene.root,
props: { label: 'New' },
placement: { kind: 'append' },
key: 'dynamic-badge',
});
const off = badge.on('dismiss', () => badge.destroy());
ctx.dispose(off);
});
},
});

create() 返回生命周期受 Runtime 管理的 ComponentInstanceHandle<TEvents>:

  • id 与 root 提供当前实例身份和 live 根节点;
  • setProps() 原子提交公共属性;
  • on() 订阅业务事件并返回幂等 disposer;
  • inspect() 返回公共状态与资源生命周期计数;
  • destroy() 幂等销毁当前 child 及其 parent-owned 后代。

placement 支持 append、prepend、before 和 after;后两者接收同一 physical parent 下的 RuntimeNode 或组件句柄作为 sibling,并可带局部 gap。一次 owner 最多保留 1000 个动态 child,一次 createBatch() 最多接收 100 个请求。

批量创建共享一个可选 AbortSignal;单个 request 的 signal 优先。任何一个请求失败时,已经创建的实例会按逆序销毁,调用方不会看到半棵成功、半棵失败的组件树:

const controller = new AbortController();
const handles = await ctx.components.createBatch([
{ component: 'Avatar', props: { userId: 'a' } },
{ component: 'Avatar', props: { userId: 'b' } },
], { signal: controller.signal });

父实例销毁会递归释放这些 parent-owned children。脚本也可以显式调用 await ctx.self.destroy() 销毁自己的 owning instance;重复调用不会重复释放资源。

宿主 Instance 与动态组件句柄使用同一套序列化事务:

const receipt = await panel.setProps({
disabled: false,
variant: { Size: 'Large', State: 'Active' },
});
if (receipt.status !== 'committed') {
console.warn(receipt.error);
}

Runtime 先验证完整 patch,再完成 Variant 变更,最后才让普通绑定和公共更新事件可见。失败或取消会恢复之前的 Variant selection,不会发布部分 props。FlamoPropsCommitReceipt 可用于把这次提交与 interaction trace 对齐。

Component Set 根脚本继承到页面 INSTANCE 后,ctx.instance.setProps() 自动按当前实例的有效 surface 解析,不会把 state 等属性写到页面级 props:

const receipt = await ctx.instance.setProps({ state: 'hover' });
if (receipt.status !== 'committed' || receipt.variantReceipts[0]?.status !== 'applied') {
throw new Error(receipt.error?.message ?? 'Variant state was not applied');
}

未知、无绑定或跨作用域属性会得到 rejected 回执。Variant 轴成功提交时,variantReceipts 会包含当前具体 INSTANCE 的 applied 回执,可作为视觉重物化完成的门禁。

如果不需要保存或发布,可以直接修改某个父 Instance 的运行树:

await panel.reconcileChildren([
{
key: 'query',
artifact: Input({ placeholder: '搜索内容' }),
parentKey: null,
mount: { kind: 'root' },
props: {},
},
{
key: 'submit',
artifact: Button({ label: '搜索' }),
parentKey: null,
mount: { kind: 'root' },
props: {},
},
]);

这种调用只改变当前 Runtime:

  • 相同 key、相同 ref / Artifact identity 且 statePolicy 不是 reset 时可以复用已有 child;
  • swapChild() 用于替换一个 child 及其后代;
  • 传入现成 instance 时默认是 caller-owned,父级销毁只解除挂接,不替调用方销毁它;
  • 如果最终要封装成新组件,应把相同结构写回 defineFlamoComponent() / Composition IR,再编译和发布。