FlexUI 自定义组件命名插槽(Named Slots)设计方案

目标读者:需要为 FlexUI 自定义组件(原生渲染路径)实现 slot="button" 命名插槽能力的开发者。 关联文档:flexui-component-extension-guide.md(自定义组件扩展指南)、flexui-engine.md、flexui-core.md


1. 背景与现状

1.1 场景

自定义组件(如 collapsible-view)需要把调用方传入的子节点按插槽名分发到组件内部的不同位置:

<collapsible-view>
  <view slot="button">
    <!-- 自定义展开按钮 -->
  </view>
  <view>
    <!-- 默认内容 -->
  </view>
</collapsible-view>

1.2 现状:不支持命名插槽(结论)

对 slot="button" 按数据流逐层排查,原生渲染链路只支持单默认插槽,不支持命名插槽分发:

层 位置 支持 说明
模板编译 toolkit/flexui-template-compiler/src/parse/tag.rs、stringify/tag.rs ✅ 解析 slot 属性,生成 elem.slot = 'button'(proc_gen_wrapper.ts:1103)
核心框架 packages/flexui/src/element.ts:265-315 ✅ slot setter 运行完整分配逻辑(SlotMode Single/Multiple/Dynamic、containingSlot、slotNodes),回调后端 setSlot(element.ts:275)
原生 JS 后端 flexui-backend/.../NativeBackendElement.ts:1028-1031 ❌ setSlot/setSlotName/setSlotElement/setInheritSlots 全部是 no-op 空实现;NativeBackendShadowRoot.createComponent 忽略 _slotMode 参数(NativeBackendShadowRoot.ts:80)
C++ DOM/渲染 flexui-engine/dom/(无 slot 代码)、custom_ts_view.cc ❌ CustomTsView 只有一个 contentHandle_ + contentNode_,子节点按序 InsertChild(custom_ts_view.cc:105),无 slot 概念
ArkTS 渲染 FlexUICustomComponentView.ets:9 ❌ childSlot 是单一 NodeContent;struct 里 ContentSlot(childSlot) 或 ForEach(children) 全量渲染(FRBadgeView.ets:80-87)

根因:glass-easel 在 JS 层已经把插槽分配算好了(containingSlot/slotNodes/_$slotName 都在维护),但原生后端拒绝消费 —— 四个回调接口被置空,且 elem.slot = 'button' 走属性 setter、不写 attributes(element.ts:265-315 vs 2511-2545),所以 slot 名在原生链路中完全不可见(buildProps 里不会有 slot prop,NativeBackendElement.ts:1557)。

实际表现:<view slot="button"> 会作为普通子节点进入唯一的 childSlot,只能出现在"默认内容区",组件内部无法区分插槽。

1.3 设计目标

在不改核心框架(packages/flexui)、不改模板编译器的前提下,为自定义组件(CApi 原生渲染路径)提供:

  1. 命名插槽:<view slot="button"> 的内容可被自定义组件按名取用,渲染到指定位置。
  2. 默认插槽:无 slot 属性(或 slot="")的子节点进默认内容区,行为与现状一致。
  3. 向后兼容:未声明插槽的旧组件(BadgeView 等)零改动,行为不变。
  4. 动态重分配:子节点插槽变化(运行期)时,渲染层跟随重路由。

1.4 非目标(第一版)

  • 动态 slot(SlotMode.Dynamic):运行期通过 setSlotElement 绑定任意 slot 名。第一版只支持模板写死的静态命名插槽。
  • <slot> 标签 + slotValues(<slot name="x" value="..."> 作用域插槽):涉及 slot 值传递,后续单独评估。
  • 非 CApi 路径(enableArkCApi=false 或 Android):列为可选增强(见 §9 里程碑 M5)。
  • 组件间数据读取("从子组件获取信息"若指读取子组件状态):属于组件通信范畴(事件 + properties),与内容分发语义不同,不在此方案内。

2. 核心设计决策

# 决策 理由
D1 slot 分配在 JS 层完成(复用 glass-easel 现有逻辑),原生层只消费"分配结果" element.ts 的 slot 机制已在运行,零新增分配逻辑;避免在 C++/ArkTS 重实现一套
D2 分配结果以 __slotMap prop 挂在父节点(自定义组件节点) 下发:{ slotName: [childDomId...] } 信息聚合一处;slot 重分配只更新父节点一个 prop;与现有 props 通道(createNodeWithId / updateNode)天然兼容
D3 C++ CustomTsView 按 slot 名维护多个 contentNode_(默认 '' = 现有 contentNode_),子节点挂载时按 childTag → slotName 路由 渲染通道改动最小;每个命名槽对应一个 ArkUI 容器节点
D4 ArkTS 侧为命名槽创建多个 NodeContent,注入 FlexUICustomComponentView.slotContents: Map<string, NodeContent> ContentSlot 是 ArkTS 声明式挂载点,组件 struct 按名取用,等价于 <slot name="button"> 占位
D5 插槽声明驱动:ComponentAdapter.slots(JS)+ FlexUIComponentExtension.slotDeclarations(ArkTS)声明组件支持的 slot 名 ArkTS 侧据此预创建 NodeContent;struct 里 ContentSlot 数量编译期确定(ArkTS 声明式限制)
D6 __slotMap 走 UpdateCustomTsProps 的 C++ 白名单通道(fr_view_manager.cc:694-696) 该通道已是"CustomTsView 收到 C++ 侧 props"的唯一路径,仅需在条件里追加一个 key

3. 总体架构

HXML: <collapsible-view><view slot="button">X</view><view>Y</view></collapsible-view>
  │
  ▼  [模板编译] 已支持:elem.slot = 'button'(proc_gen_wrapper.ts:1103)
  ▼  [核心框架] 已支持:element.slot setter 完成分配,回调 backend.setSlot(element.ts:275)
  │
  ▼  [JS 层 flexui-backend] —— 本次新增(§4.1)
  │    setSlot(name) 记录 slotName(原 no-op)
  │    父节点聚合 __slotMap = { 'button': [3], '': [4] }
  │    FlexUIBridge.updateNode(parentId, pId, { __slotMap: JSON })
  │
  ▼  [C++ 层] —— 本次新增(§4.3)
  │    UpdateCustomTsProps 白名单放行 __slotMap(fr_view_manager.cc:694-696)
  │    CustomTsView::SetViewProp 解析 → childSlotMap_(childTag → slotName)
  │    OnChildInsertedImpl 按 slotName 路由到对应 contentNode_(多槽容器)
  │
  ▼  [ArkTS 层] —— 本次新增(§4.4)
  │    createRenderViewForCApi 按 slotDeclarations 预建多个 NodeContent
  │    FlexUICustomComponentView.slotContents: Map<string, NodeContent>
  │    struct: ContentSlot(slotContents.get('button'))  ← 命名槽占位
  └─ 上屏渲染

4. 分层设计

4.1 JS 层(flexui-backend)

文件:flexui-engine/driver/js/packages/flexui-backend/src/native-backend/NativeBackendElement.ts

4.1.1 setSlot 实现(原 no-op,NativeBackendElement.ts:1028)

/** slot 名。由 glass-easel 的 element.slot setter 在插槽分配/变化时回调(element.ts:275) */
slotName: string = ''

setSlot(name: string): void {
  if (this.slotName === name) return
  const old = this.slotName
  this.slotName = name
  // 已挂载(有父节点)时,通知父节点重算 slotMap 并下发
  if (this.parentDomNodeId !== undefined && this._isMounted) {
    this._notifyParentSlotMapChanged()
  }
}

_isMounted / parentDomNodeId 是现有字段(appendChild 时维护,NativeBackendElement.ts:93-121)。setSlot 可能在节点挂载之前被调用(模板创建节点时即设置 slot 属性),此时只记录 slotName,挂载时由父节点统一重算。

4.1.2 父节点聚合 slotMap

在父节点(NativeBackendElement)上新增:

/** 是否声明了插槽的自定义组件(ComponentAdapter.slots 非空) */
private get _isSlotHost(): boolean {
  const adapter = getComponentAdapter(this.tagName)
  return !!(adapter && adapter.slots && adapter.slots.length > 0)
}

/** 重算本节点的 __slotMap:slotName → 子节点 domNodeId 数组(保序) */
private _computeSlotMap(): Record<string, number[]> {
  const map: Record<string, number[]> = {}
  for (const child of this._childOrder) {   // 现有保序子节点数组
    const name = child.slotName || ''
    ;(map[name] ??= []).push(child.domNodeId)
  }
  return map
}

/** 子节点集合/slot 变化后调用:仅对插槽宿主下发 */
private _syncSlotMap(): void {
  if (!this._isSlotHost || this.domNodeId === undefined) return
  FlexUIBridge.updateNode(this.domNodeId, this.parentDomNodeId ?? 0, {
    __slotMap: JSON.stringify(this._computeSlotMap()),
  }, this.nativeName, this.tagName, this._context?.rootViewId)
}

调用点(3 处,全部在现有方法内追加一行):

调用点 现有方法 时机
子节点挂载后 appendChild(~line 465 createNodeWithId 之后) 子节点进入 C++ 树后下发
子节点移除后 removeChild 从 slotMap 剔除
子节点 slot 变化 setSlot → _notifyParentSlotMapChanged 运行时重分配
private _notifyParentSlotMapChanged(): void {
  const parent = this._context?.findElementByDomNodeId(this.parentDomNodeId)
  if (parent) parent._syncSlotMap()
}

findElementByDomNodeId 为 NativeBackendContext 现有接口(NativeBackendContext.ts:253)。

仅当 adapter 声明了 slots 才计算下发,避免给普通 view/text 节点产生 __slotMap 噪音。未声明时 slotName 恒为 '',等价现状。

4.1.3 组件声明扩展

flexui-engine/driver/js/packages/flexui-backend/src/adapters/ComponentAdapter.ts:

export interface ComponentAdapter {
  // ...现有字段
  /** 声明的命名插槽名列表(不含默认槽 '')。声明后该组件启用 __slotMap 下发 */
  slots?: string[];
}

注册示例(flexui-component-ext/.../components.js):

registerComponentAdapter('collapsible-view', {
  name: 'CollapsibleView',
  slots: ['button', 'header'],
  // ...
});

4.2 协议:__slotMap

形态:父节点 props 里的一个 JSON 字符串 key。

// __slotMap 值(JSON 序列化后的字符串)
{"button":[3,7],"header":[9],"":[4,5,6]}
  • key = slot 名,"" 表示默认槽;
  • value = 该槽位下的子节点 domNodeId 数组,保持子节点顺序;
  • 整体 JSON.stringify 后作为单个 prop 传递(与现有 props 通道兼容,C++ 侧 FRValueUtils::GetString 可直接读取)。

下发通道:FlexUIBridge.updateNode(id, pId, props, name, tagName, rootId)(FlexUIBridge.ts:492-508),复用现有 queue + flushQueue 批量机制。

4.3 C++ 层

文件:flexui-engine/framework/ohos/src/main/cpp/impl/renderer/native/

  • include/renderer/components/custom_ts_view.h
  • src/components/custom_ts_view.cc
  • src/uimanager/fr_view_manager.cc

4.3.1 props 白名单放行(fr_view_manager.cc:694-696)

UpdateCustomTsProps 中把 __slotMap 加入 C++ 侧 SetProp 白名单:

if (key == FRNodeProps::VISIBILITY || key == FRNodeProps::TRANSFORM || key == FRNodeProps::OVERFLOW ||
    key == "native-scroll-ohos" || key == "attachedtowindow" || key == "detachedfromwindow" ||
    key == "__slotMap") {           // ← 新增
  customTsView->SetProp(key, prop_it->second);
}

该函数是 CustomTsView 所有 props(初始创建 + 增量更新)通往 C++ 自身处理的唯一通道,追加白名单即可全覆盖。ArkTS 侧 updatePropsForCApi 收到的 props 里也会带 __slotMap,FlexUIRenderBaseView.setProp 未识别时走 super 忽略,无害。

4.3.2 CustomTsView 多槽容器

custom_ts_view.h 新增成员:

// childTag → slotName 路由表("__slotMap" 解析结果)
std::unordered_map<uint32_t, std::string> childSlotMap_;
// slotName → 内容容器节点;"" 键指向现有 contentNode_(默认槽)
std::unordered_map<std::string, std::shared_ptr<ArkUINode>> slotContentNodes_;
// slotName → NodeContent handle(ArkTS 预建,CreateArkUINodeImpl 时注册)
std::unordered_map<std::string, ArkUI_NodeContentHandle> slotContentHandles_;

custom_ts_view.cc 改动:

  1. CreateArkUINodeImpl:注册默认槽后,遍历 slotContentHandles_ 为每个命名槽创建 StackNode 容器并 OH_ArkUI_NodeContent_AddNode(复用现有 contentNode_ 的创建参数:SetPosition(0,0)、SetAlignment(TOP_START)、SetHitTestMode(NONE))。

  2. SetViewProp(现在处理 native-scroll-ohos)新增分支:

if (propKey == "__slotMap") {
  const std::string &json = FRValueUtils::GetString(propValue);
  // 解析 {"button":[3,7],"":[4,5,6]} → childSlotMap_[3]="button" ...
  ParseSlotMap(json, childSlotMap_);
  // 对 __slotMap 里出现、但 slotContentHandles_ 缺失的 slot 名,回调 ArkTS 动态创建
  EnsureSlotContents(tag_);
  // 已挂载子节点按新表重路由
  ReRouteChildren();
  return true;
}
  1. OnChildInsertedImpl(custom_ts_view.cc:100-106)按 slot 路由:
void CustomTsView::OnChildInsertedImpl(std::shared_ptr<BaseView> const &childView, int32_t index) {
  BaseView::OnChildInsertedImpl(childView, index);
  const std::string &slotName = LookupSlotName(childView->GetTag());  // childSlotMap_,缺省 ""
  auto it = slotContentNodes_.find(slotName);
  if (it == slotContentNodes_.end()) {
    it = slotContentNodes_.find("");  // 兜底默认槽
  }
  it->second->InsertChild(childView->GetLocalRootArkUINode(), index);
}
  1. OnChildRemovedImpl(custom_ts_view.cc:108-111)同理按 slot 路由 RemoveChild。

  2. ReRouteChildren():__slotMap 更新时,对 children_ 里已挂载的子节点做 diff —— 旧槽移除、新槽按序插入。slot 变化低频,第一版全量重挂即可(先 RemoveChild 全部,再按 childSlotMap_ 顺序 InsertChild),后续可优化为增量 diff。

  3. EnsureSlotContents(uint32_t tag):对 childSlotMap_ 中出现但 slotContentHandles_ 缺失的 slot 名,回调 ArkTS getSlotContentForCApi(rootTag, tag, slotName) 获取 ArkUI_NodeContentHandle 并缓存。第一版可只支持声明内的 slot 名(slotDeclarations),未声明名一律落入默认槽并打警告日志。

4.3.3 默认回退

__slotMap 缺失 / 子节点 slot 名未知时,一律路由到默认槽(""),与现状行为完全一致。

4.4 ArkTS 层

文件:flexui-engine/framework/ohos/src/main/ets/renderer_native/

  • NativeRenderBuilderForCApi.ets(接口类型)
  • NativeRenderImpl.ets(createRenderViewForCApi)
  • uimanager/FRViewManager.ets(createRenderViewForCApi)
  • components/custom/FlexUICustomComponentView.ets

4.4.1 接口扩展(NativeRenderBuilderForCApi.ets:12)

export interface CreateRenderViewResultForCApi {
  frameNode: FrameNode | null
  childSlot: NodeContent
  /** 命名插槽 → NodeContent;"" 键对应 childSlot。旧组件可为空对象 */
  slotContents?: Record<string, NodeContent>
}

4.4.2 FlexUICustomComponentView 增加 slotContents

export class FlexUICustomComponentView extends FlexUIRenderBaseView {
  childSlot: NodeContent | null = null
  /** 命名插槽内容挂载点:slot 名 → NodeContent("" 键 = childSlot,兼容旧代码) */
  slotContents: Map<string, NodeContent> = new Map()
  // ...
}

4.4.3 创建流程(NativeRenderImpl.ets:659-685 / FRViewManager.ets:290-317)

// NativeRenderImpl.createRenderViewForCApi
let childSlot: NodeContent = new NodeContent()
let slotContents: Record<string, NodeContent> = { '': childSlot }
// 按扩展声明的 slots 预建 NodeContent(slotDeclarations 来自 FlexUIComponentExtension)
for (const slotName of this.getSlotDeclarations(params.viewName)) {
  slotContents[slotName] = new NodeContent()
}
let view = viewManager.createRenderViewForCApi(params.tag, params.viewName, childSlot, slotContents)
// ...
return { frameNode: builderNode.getFrameNode()!, childSlot: childSlot, slotContents: slotContents }
// FRViewManager.createRenderViewForCApi
createRenderViewForCApi(tag: number, viewName: string, childSlot: NodeContent,
                        slotContents?: Record<string, NodeContent>): FlexUIRenderBaseView | null {
  // ...
  if (renderView) {
    const custom = renderView as FlexUICustomComponentView
    custom.childSlot = childSlot
    if (slotContents) {
      custom.slotContents = new Map(Object.entries(slotContents))   // 兼容 ArkTS 限制,转 Map
    }
  }
}

4.4.4 组件 struct 使用方式(以 collapsible-view 为例)

@Component
export struct FRCollapsibleView {
  @ObjectLink renderView: FRCollapsibleViewView   // extends FlexUICustomComponentView
  @ObjectLink children: FlexUIObservedArray<FlexUIRenderBaseView>

  build() {
    Column() {
      // 命名槽:按钮区(等价 <slot name="button">)
      if (this.renderView.slotContents.get('button')) {
        ContentSlot(this.renderView.slotContents.get('button')!)
      }
      // 默认槽:正文区(等价 <slot>)
      if (this.renderView.childSlot) {
        ContentSlot(this.renderView.childSlot)
      }
      // 非 CApi 兜底:children 数组全量渲染(与 FRBadge 现状一致,FRBadgeView.ets:83-87)
      ForEach(this.children, (item: FlexUIRenderBaseView) => {
        buildFlexUIRenderView(item, null)
      }, (item: FlexUIRenderBaseView) => item.tag + '')
    }
    .applyRenderViewBaseAttr(this.renderView)
  }
}

slotContents 用 Map + .get() 规避 arkts-no-props-by-index(10605029)与对象字面量规则。struct 里 ContentSlot 数量由声明驱动、编译期确定 —— 这正是"插槽声明"必要性的来源。

4.4.5 扩展声明(FlexUIComponentExtensions.ets)

// flexui-engine/.../flexui/FlexUIComponentExtension.ets 增加可选字段
export interface FlexUIComponentExtension {
  // ...现有字段
  /** viewName → 命名插槽名列表;用于 ArkTS 预建 NodeContent */
  slotDeclarations?: Map<string, string[]>;
}

// flexui-component-ext/.../FlexUIComponentExtensions.ets
function createCollapsibleViewExtension(): FlexUIComponentExtension {
  const renderViews = new Map<string, FRRenderViewCreator>();
  renderViews.set('CollapsibleView', (ctx): FRCollapsibleViewView => new FRCollapsibleViewView(ctx));
  return {
    name: 'flexui-component-ext:collapsibleview',
    jsBundlePath: 'flexui_component_ext/components.js',
    renderViews: renderViews,
    slotDeclarations: new Map([['CollapsibleView', ['button']]]),
    renderBuilder: getCollapsibleViewWrappedBuilder(),
  };
}

5. 数据流时序

5.1 首次渲染(静态插槽)

1. 模板执行:创建 <view slot="button"> 节点 → elem.slot = 'button'
     └─ element.ts:275 → backend.setSlot('button') → 记录 slotName(节点可能未挂载)
2. appendChild 挂载子节点 → createNodeWithId 进 C++ 树
     └─ 父节点 _syncSlotMap() → updateNode(parent, { __slotMap: '{"button":[3],"":[4]}' })
3. C++ UpdateCustomTsProps → SetProp('__slotMap') 白名单放行
     └─ CustomTsView::SetViewProp 解析 → childSlotMap_ → EnsureSlotContents + ReRouteChildren
4. 子节点 OnChildInsertedImpl → LookupSlotName(childTag) → 路由到对应 contentNode_
5. ArkTS:slotContents['button'] ContentSlot 渲染按钮区,childSlot 渲染默认区

5.2 运行期 slot 变化(静态模板下不触发,动态场景预留)

elem.slot = 'new' → setSlot('new') → _notifyParentSlotMapChanged → 父节点 _syncSlotMap
  → updateNode(__slotMap 新值) → C++ 解析 → ReRouteChildren() 重挂载

6. 兼容性

场景 行为
旧自定义组件(BadgeView,不声明 slots) JS 不计算 __slotMap;C++ childSlotMap_ 空;全部子节点进默认槽。零改动、行为不变
声明了 slots 但调用方没写 slot 属性 __slotMap = {"":[全部子节点]},等价现状
声明了 slots 且写了未声明槽名(如 slot="footer") 第一版:落入默认槽 + C++ 警告日志;M5 后支持动态创建
非 CApi 路径(ForEach(children)) 第一版与现状一致(全量渲染);M5 按 slotName 分组(见 §9)
嵌套自定义组件 各自独立 __slotMap,无冲突

7. 边界情况

  1. 子节点 slot 属性在挂载前设置:setSlot 只记录,挂载时父节点统一重算(顺序无关)。
  2. 多个子节点同名插槽:__slotMap value 为数组,保序路由。
  3. 文本节点 / 虚拟节点:slotName 恒 '',进默认槽(虚拟节点不产生 C++ 节点,天然跳过)。
  4. 子节点被 Move2RenderView 移动:走 removeChild + appendChild,父节点 slotMap 随之重算。
  5. __slotMap 到达晚于子节点挂载:子节点暂进默认槽,SetViewProp('__slotMap') 后 ReRouteChildren() 重路由。
  6. deleteProps 携带 __slotMap(slotMap 清空):SetProp('__slotMap', Null) → 清空 childSlotMap_,全部回默认槽。
  7. 自定义组件无子节点:__slotMap = {},各 ContentSlot 为空,组件仍正常渲染自身内容。
  8. 宿主带任意属性(如 collapsed-height):__slotMap 是 JS 侧仅结构性变化(子节点增删/slot 重分配)才下发的粘性 ext 属性,而普通属性随每次 flush() 全量下发且不含 __slotMap。曾因 UpdateDomNodes 的 ext diff 把缺失的 __slotMap 判为 DELETE 并 SetExtStyleMap 覆盖抹掉,导致 ArkTS 只收到 setProp('__slotMap', null)、onSlotMapParsed 不再执行(且 C++ 槽路由丢失)。修复:UpdateDomNodes 保留 ext 中已有的 __slotMap(update_ext 缺失时回填),并从 ext_delete 中剔除 __slotMap(只响应 JS 显式 deleteProps);ArkTS _parseSlotMapProp 空值分支也调用 onSlotMapParsed(空Map) 保持钩子契约。真机回归场景见 demos/ascf-ai-demo/.../collapsibleview-demo/index.hxml 场景 1(宿主带 collapsed-height + <text slot="button">)。

8. 验证方案

8.1 JS 单测(flexui-backend,Jest)

flexui-engine/driver/js/packages/flexui-backend/src/native-backend/__tests__/slot.test.ts:

  • setSlot 记录 slotName;未挂载时不崩溃;
  • 父节点 _computeSlotMap 分组保序、JSON 序列化正确;
  • 挂载/移除/slot 变化时 FlexUIBridge.updateNode 被调用且 __slotMap 参数正确;
  • 未声明 slots 的组件不产生 __slotMap。

8.2 C++ 编译验证(强制)

改完 custom_ts_view.cc/.h、fr_view_manager.cc 后必须执行:

./build.sh build-engine   # 输出 BUILD SUCCESSFUL 即通过

8.3 真机端到端(collapsible-view demo)

  1. entry 页加 <collapsible-view><view slot="button">X</view><view>Y</view></collapsible-view>(注意 Scroll 下只能一个子组件,多卡片需包 Column);
  2. ./build.sh dev 安装启动;
  3. hilog 快照抓取(-t app | grep flexui),确认:
    • JS:updateNode ... __slotMap 日志;
    • C++:SetViewProp __slotMap / ReRouteChildren / InsertChild slot=button 日志;
    • ArkTS:slotContents size=2 注入日志;
    • 画面:按钮区与默认区分别渲染在预期位置。

8.4 回归

  • BadgeView 旧 demo 行为不变(无 __slotMap 日志、子节点进默认槽)。

9. 实施步骤(里程碑)

里程碑 内容 涉及文件 验证
M1 JS 层:setSlot 实现、slotName 字段、父节点 _computeSlotMap/_syncSlotMap、ComponentAdapter.slots NativeBackendElement.ts、ComponentAdapter.ts Jest 单测
M2 ArkTS 层:slotContents 注入、CreateRenderViewResultForCApi 扩展、FlexUIComponentExtension.slotDeclarations、struct 示例 FlexUICustomComponentView.ets、NativeRenderImpl.ets、FRViewManager.ets、FlexUIComponentExtension.ets build-hap 编译
M3 C++ 层:CustomTsView 多槽容器 + __slotMap 解析 + 路由 + 白名单 custom_ts_view.cc/.h、fr_view_manager.cc build-engine + 真机
M4 collapsible-view demo + 端到端验证 + 本设计落地文档更新 flexui-component-ext/、entry/ 真机 hilog + 画面
M5(可选) 非 CApi 路径按 slot 分组(onChildInsertedForCApi 携带 childSlotName,ArkTS 侧 children 分组);未声明 slot 名动态创建 NodeContent NativeRenderImpl.ets、FRViewManager.ets、FlexUIRenderBaseView.ets 真机回归

10. 风险与权衡

风险 影响 缓解
NAPI 传递 Map<string, NodeContent> 受限 ArkTS↔C++ 返回结构复杂化 用 Record<string, NodeContent>(napi object)传递,ArkTS 侧转 Map
ArkTS 声明式 ContentSlot 数量编译期确定 动态 slot 名无法预置挂载点 声明驱动(D5):struct 只为声明过的 slot 写 ContentSlot;未声明名回退默认槽
ReRouteChildren 全量重挂性能 大子节点树 + 高频 slot 变化时开销 slot 变化低频(静态插槽不触发);后续可改增量 diff
__slotMap 全量 JSON 下发 大子节点树时序列化开销 仅在子节点集合/slot 变化时触发,非每帧;可后续压缩为差量
需要 C++ 改动 编译/回归成本 改动集中(2 个文件 + 白名单 1 行),M3 独立验证

11. 参考文件

文件 作用
packages/flexui/src/element.ts(:265-315, :2511-2545) glass-easel slot 分配与 backend 回调契约
packages/flexui/src/slot_mode.ts、shadow_root.ts SlotMode 与 slot 树维护
flexui-engine/driver/js/packages/flexui-backend/src/native-backend/NativeBackendElement.ts(:1028-1031, :280-472, :1547-1586) 后端 no-op 现状、appendChild、buildProps
flexui-engine/driver/js/packages/flexui-backend/src/native-backend/FlexUIBridge.ts(:448-526) createNodeWithId / updateNode 协议
flexui-engine/driver/js/packages/flexui-backend/src/adapters/ComponentAdapter.ts 适配器声明(新增 slots)
flexui-engine/.../src/components/custom_ts_view.cc/.h CustomTsView 单槽现状与改造点
flexui-engine/.../src/uimanager/fr_view_manager.cc(:367-395, :662-702) UpdateProps / UpdateCustomTsProps 白名单
flexui-engine/.../NativeRenderImpl.ets(:659-685)、FRViewManager.ets(:290-317) CApi 视图创建与 childSlot 注入
flexui-component-ext/(FlexUIComponentExtensions.ets、FRBadgeView.ets、BadgeViewRenderBuilder.ets) 扩展机制示例与 struct 用法
docs/learn/flexui-component-extension-guide.md 自定义组件扩展端到端流程

12. 实施落地记录(M1-M4)

本节记录实际落地时与设计文档的差异、已修复的基线问题,以及后续维护注意事项。代码已按此实现,若后续修改请同步更新本节。

12.1 已实现(对照里程碑)

里程碑 落地状态 关键文件
M1 JS 层 ✅ setSlot 记录 slotName;父节点 _isSlotHost/_computeSlotMap/_syncSlotMap;挂载/移除/移动/insertBefore/slot 变化 5 个调用点;ComponentAdapter.slots NativeBackendElement.ts、ComponentAdapter.ts、__tests__/slot.test.ts(7 用例)
M2 ArkTS 层 ✅ slotContents 注入链(10 个文件);CreateRenderViewResultForCApi.slotContents;FlexUIComponentExtension.slotDeclarations FlexUICustomComponentView.ets、NativeRenderImpl.ets、FRViewManager.ets、NativeRenderContext.ets、FlexUIRootView.ets、NativeRenderProvider.ets、FlexUIEngine.ets、FlexUIEngineContextImpl.ets、FlexUIAPIProvider.ets、NativeRenderBuilderForCApi.ets
M3 C++ 层 ✅ CustomTsView 多槽容器 + __slotMap 解析 + 子节点路由 + 白名单放行;createRenderViewForCApi 读取 slotContents 传入 custom_ts_view.h/.cc、fr_view_manager.cc
M4 样例 ✅ collapsible-view 组件(命名插槽 button + 默认插槽 + 折叠交互)+ collapsibleview-demo 卡片 flexui-component-ext/src/main/ets/collapsibleview/、components.js、index.ets、EntryAbility.ets、demos/ascf-ai-demo/.../collapsibleview-demo/、entry/pages/Index.ets

12.2 与设计文档的差异

  1. __slotMap 用 JSON 字符串传递(与 §4.2 一致),C++ 侧手写轻量解析器(ParseSlotMap,custom_ts_view.cc:183)—— 项目无 JSON 库,格式固定({"name":[id,id],"":[id]})故未引入依赖。
  2. slotDeclarations 传递链比设计文档更长:FlexUIComponentExtension → FlexUIAPIProviderBase.getSlotDeclarations()(基类默认 null)→ FlexUIEngine 内部 provider 合并 → FlexUIEngineContextImpl 收集 → NativeRenderProvider → NativeRenderImpl → NativeRenderContext(经 FlexUIRootView 构造与 checkAndGetViewManager 两处)→ createRenderViewForCApi 预建 NodeContent。均采用**可选构造参数(默认 null)**避免破坏既有调用点。
  3. ReRouteChildren 用 AddChild 追加而非 InsertChild(-1):ArkUINode::InsertChild 底层 insertChildAt 对负 index 行为未定义,AddChild 语义明确(追加到末尾)。
  4. CustomTsView 构造函数新增 slotContents 参数(unordered_map<string, ArkUI_NodeContentHandle>),命名槽容器在 CreateArkUINodeImpl 时统一创建(CreateSlotContentNode 抽取公共创建逻辑,默认槽与命名槽共用)。
  5. JS 侧 setSlot 通知条件为 parentDomNodeId !== 0(不检查 _isMounted):appendChild CREATE 路径只设 _justCreated 不设 _isMounted(batch splitting 设计),挂载后运行期 slot 重分配需要父节点立即收到通知。
  6. 命名插槽容器点击走 C++ 回调转发(TRANSPARENT + RegisterClickEvent),声明式 onClick 无法命中插槽内容:插槽内容是原生节点(NodeContent 承载),触摸事件被 ContentSlot 内部宿主视图消费,声明式父组件的 onClick(如 collapsible-view 头部折叠切换)收不到点击。实测 ContentSlot 组件不支持 hitTestBehavior(ContentSlotAttribute 无此属性),且仅靠容器 ARKUI_HIT_TEST_MODE_TRANSPARENT 也无法让事件穿透到声明式父组件(真机验证失败)。最终方案:命名插槽容器(CreateSlotContentNode(handle, namedSlot, slotName),命名槽传 true)设为 TRANSPARENT(自身参与命中测试)并注册点击手势(RegisterClickEvent + SlotClickDelegate),点击经 ts_render_provider_ref_ 调 ArkTS onSlotClickedForCApi → NativeRenderProvider → NativeRenderImpl → FRViewManager → FlexUICustomComponentView.onSlotClicked(slotName)(虚方法,组件覆写处理,如 collapsible-view 切换折叠)。默认槽保持 NONE 以零影响既有组件(BadgeView 等)。

12.3 修复的基线问题(非本方案引入,但阻塞了验证)

问题 位置 修复
new NativeBackendElement() 无参构造违反构造器签名,ts-jest 类型检查报 TS2554,全部 JS 单测无法运行 NativeBackendElement.ts:361(ScrollView wrapper 创建) 改为 new NativeBackendElement(FlexUIBridge.createDomNodeId()),删除重复的 domNodeId 赋值(行为不变)
splice.test.ts 9 个用例断言 _isMounted === true,但当前实现 appendChild 有意不设 _isMounted(batch splitting 注释说明) splice.test.ts:66-70 等 未修复(属测试与实现的既有脱节,与本方案无关;修复需同步更新测试意图或实现注释,建议单独提交)
CSSManager.test.ts 1 个 line-height 断言失败 CSSManager.test.ts 未修复(纯 CSS 解析断言,与本方案无关)

12.4 验证状态

  • ✅ M1:slot.test.ts 7/7 通过;全量回归 78/88(10 个失败均为 §12.3 基线问题)
  • ✅ M2:HarCompileArkTS 通过(build-engine 内)
  • ✅ M3:./build.sh build-engine → BUILD SUCCESSFUL in 1 min 10 s(ArkTS + C++ + HAR 打包全链路)
  • ✅ M4(组件侧):collapsible-view 组件 + 注册 + builder 合并已完成;collapsibleview-demo 卡片源码已交付
  • ⏳ M4(卡片产物):collapsibleview-demo 的 index.pack.js 未生成 —— skills(hxml → pack.js)编译链不在现有 build 命令中(build-demo 仅编译元服务页面 pages/packageDetail,pkg-skills.sh 仅打包部署)。真机验证前需先跑 skills 编译流程(flexui 模板编译链 + webpack),或按既有 skills(如 badgeview-demo)的产物生成方式补齐
  • ⏳ 真机验证:./build.sh dev 后 hilog 抓 __slotMap / InsertChild slot= / slotContents.size 日志

12.5 已知限制(与 §1.4 非目标一致)

  • 仅支持静态命名插槽(模板写死 slot="name");运行期动态 slot 重分配(setSlotElement)在 native 路径仍为 no-op。
  • 未声明 slotDeclarations 的命名槽名(__slotMap 中出现但 ArkTS 未预建 NodeContent)回退默认槽并打警告日志(EnsureSlotContentNode)。
  • 非 CApi 路径(enableArkCApi=false)的 ForEach(children) 仍全量渲染,未按 slot 分组(M5 范围)。