- CMS
- 后端
【免费下载链接】Umbraco-CMS
Umbraco is a free and open source .NET content management system helping you deliver delightful digital experiences.
本文以 Umbraco-CMS 仓库中的sorter-with-nested-containers示例包为主体,完整讲解如何在 Umbraco Backoffice 中配置并运行UmbSorterController,重点剖析“嵌套容器(nested containers)”场景下的接入方式、数据模型同步机制,以及控制器底层基于原生 HTML5 Drag and Drop 的跨容器移动原理。读完后你可以照示例搭建自己的多级拖拽排序界面,并理解每个配置项在源码中的作用。
示例定位:它演示什么
示例位于 examples/sorter-with-nested-containers 目录,其 README.md 对目标只有一句话:
This example demonstrates how to set up the Sorter and how it can be used in nested setups.
也就是说,它演示两件事:如何接入 Sorter(UmbSorterController),以及如何在“容器套容器”的嵌套结构中协同工作。示例目录包含 4 个文件,各自职责如下:
| 文件 | 职责 |
|---|---|
| index.ts | 声明 dashboard manifest,把示例注册进 Backoffice |
| sorter-dashboard.ts | 顶层 dashboard 元素,持有两组数据并渲染两个排序分组 |
| sorter-group.ts | 可复用的“分组”元素,内部实例化UmbSorterController,并渲染嵌套分组 |
| sorter-item.ts | 可拖拽的条目元素,带操作 slot 与占位样式 |
按照 examples 目录的说明,这些示例包既是扩展用法的演示,也用于验证各扩展点持续可用。仓库中还有一个平行的示例 sorter-with-two-containers,演示两个平级容器间的移动;两者 manifest 别名相同(example.dashboard.dataset),由于一次只会运行一个示例包,互不冲突。
运行示例包
示例由 devops/example-runner/index.js 驱动。在src/Umbraco.Web.UI.Client目录下执行:
# 交互式选择示例(列出 examples/ 下所有子目录,支持方向键/输入过滤) npm run example # 或直接指定本示例 npm run example sorter-with-nested-containers # 使用 MSW mock 数据运行(对应 runner 的 --mock 参数,底层执行 npm run dev:mock) npm run example:mock sorter-with-nested-containers从 runner 源码可以看到其工作方式:选中示例后设置环境变量VITE_EXAMPLE_PATH=examples/sorter-with-nested-containers,然后调用npm run dev(mock 模式为npm run dev:mock)启动 Vite 开发服务器,由前端构建流程把该目录作为扩展包加载。脚本还会校验工作目录,若找不到examples目录会提示 “Run this from src/Umbraco.Web.UI.Client”。
Manifest:把 dashboard 注册进 Backoffice
index.ts 声明了一个ManifestDashboard:
import type { ManifestDashboard } from '@umbraco-cms/backoffice/dashboard'; export const manifests: Array<ManifestDashboard> = [ { type: 'dashboard', name: 'Example Sorter Dashboard', alias: 'example.dashboard.dataset', // 懒加载 dashboard 组件 element: () => import('./sorter-dashboard.js'), weight: 900, meta: { label: 'Sorter example', pathname: 'sorter-example', }, }, ];各字段含义:
alias:扩展包全局唯一标识;element:返回 Web Component 模块的懒加载函数;meta.label/meta.pathname:决定该 dashboard 在 Backoffice 导航中的显示名称与路由路径(本例为sorter-example);weight:排序权重。
顶层结构:两个分组、可递归嵌套
sorter-dashboard.ts 定义了example-sorter-dashboard自定义元素,它维护两个ModelEntryType[]数组并渲染两个<example-sorter-group>:
@customElement('example-sorter-dashboard') export class ExampleSorterDashboard extends UmbElementMixin(LitElement) { groupOneItems: ModelEntryType[] = [ { name: 'Apple', children: [{ name: 'Juice' }, { name: 'Milk' }] }, { name: 'Banana', children: [] }, { name: 'Pear' }, { name: 'Pineapple' }, { name: 'Lemon', children: [{ name: 'Cola' }, { name: 'Pepsi' }] }, ]; groupTwoItems: ModelEntryType[] = [ { name: 'DXP' }, { name: 'H5YR' }, { name: 'UUI' }, ]; override render() { return html` <uui-box class="uui-text"> <div class="outer-wrapper"> <example-sorter-group .value=${this.groupOneItems} @change=${(e: Event) => { this.groupOneItems = (e.target as ExampleSorterGroup).value; }}></example-sorter-group> <example-sorter-group .value=${this.groupTwoItems} @change=${(e: Event) => { this.groupTwoItems = (e.target as ExampleSorterGroup).value; }}></example-sorter-group> </div> </uui-box> `; } }注意两点:
- 数据是自引用树形结构——每个条目可以带
children,这正是“嵌套容器”的数据基础; - dashboard 只负责“状态提升”:把
.value属性传给分组元素,并在change事件后把分组的新模型读回来赋给自身状态,触发 Lit 重渲染。
数据模型定义在 sorter-group.ts 中:
export type ModelEntryType = { name: string; children?: ModelEntryType[]; };核心接入:ExampleSorterGroup 与 UmbSorterController 配置
sorter-group.ts 是示例的关键——一个分组元素内部实例化一个控制器:
#sorter = new UmbSorterController<ModelEntryType, ExampleSorterItem>(this, { getUniqueOfElement: (element) => { return element.name; }, getUniqueOfModel: (modelEntry) => { return modelEntry.name; }, identifier: 'string-that-identifies-all-example-sorters', itemSelector: 'example-sorter-item', containerSelector: '.sorter-container', onChange: ({ model }) => { const oldValue = this._value; this._value = model; this.requestUpdate('value', oldValue); // Fire an event for the parent to know that the model has changed. this.dispatchEvent(new CustomEvent('change')); }, });各配置项结合 控制器源码 逐一说明:
| 配置项 | 示例取值 | 源码中的默认值/作用 |
|---|---|---|
getUniqueOfElement | element.name | 必选。由 DOM 元素提取唯一标识;返回undefined会取消本次移动 |
getUniqueOfModel | modelEntry.name | 必选。由模型条目提取唯一标识,用于元素 ↔ 模型互查 |
identifier | 'string-that-identifies-all-example-sorters' | 缺省为Symbol()(源码config.identifier ??= Symbol()),即每个控制器实例默认互不相通;显式共享同一字符串后,所有带该 identifier 的 Sorter 之间才允许跨容器拖放——这是本示例嵌套/跨组拖拽能成立的前提 |
itemSelector | 'example-sorter-item' | 必选。可拖拽条目元素的查询选择器 |
containerSelector | '.sorter-container' | 缺省为宿主元素自身;从host.shadowRoot中按选择器查找,找不到会抛出 “Sorter could not find the container element” 错误 |
ignorerSelector | 未设置 | 默认'a,img,iframe,input,textarea,select,option',这些元素及draggable="false"的元素不触发拖拽 |
placeholderClass/placeholderAttr | 未设置 | 二者都未设置时默认placeholderAttr = 'drag-placeholder',即拖拽占位符会被加上该属性,条目组件据此降低透明度 |
onChange | 更新模型并派发change | 模型发生 move/insert/remove 后的统一回调 |
除本例用到的字段外,UmbSorterConfig还支持:disabledItemSelector、draggableSelector(指定元素内部真正可拖拽的子元素)、handleSelector(指定唯一可交互的拖拽把手)、dataTransferResolver(向 DataTransfer 写入额外数据)、onStart/onEnd、onDisallowed/onAllowed/onRequestMove(允许/拒绝指示)、onRequestDrop(接收来自其他窗口/标签页的原生拖放)、requestExternalRemove/requestExternalInsert(跨 Sorter 感知范围的外部移除/插入)、resolvePlacement(自定义放置位置判定,返回true/false决定前后放置、返回null阻止移动)以及细粒度的performItemMove/performItemInsert/performItemRemove(可返回Promise<boolean>拦截具体操作)。
value属性是模型与控制器之间的桥梁:
@property({ type: Array, attribute: false }) public get value(): ModelEntryType[] { return this._value ?? []; } public set value(value: ModelEntryType[]) { const oldValue = this._value; this._value = value; this.#sorter.setModel(this._value); // 父级重渲染时,把最新模型同步进控制器 this.requestUpdate('value', oldValue); } removeItem = (item: ModelEntryType) => { this.value = this.value.filter((r) => r.name !== item.name); };渲染部分用 Lit 的repeat生成条目,并在每个条目内部再渲染一个嵌套分组:
override render() { return html` <div class="sorter-container"> ${repeat( this.value, // Note: ideally you have an unique identifier for each item, // but for this example we use the `name` as identifier. (item) => item.name, (item) => html`<example-sorter-item name=${item.name}> <button slot="action" @click=${() => this.removeItem(item)}>Delete</button> <example-sorter-group .value=${item.children ?? []} @change=${(e: Event) => { item.children = (e.target as ExampleSorterGroup).value; }}></example-sorter-group> </example-sorter-item>`, )} </div> `; }这里有三层含义:
.sorter-container就是containerSelector指向的容器,repeat渲染的每个<example-sorter-item>都是该控制器的拖拽条目;- 每个条目内部又嵌了一个
<example-sorter-group>,绑定item.children ?? []——这就是嵌套容器:父条目的子节点本身也是一个可排序、可拖拽的容器; - 源码注释明确提示:用
name当唯一标识只是示例方便,真实项目应使用真正的唯一 ID。
sorter-item.ts 则是条目外观:展示名称、操作 slot 与默认 slot(供嵌套分组使用),并定义了一个umbDragPlaceholder布尔属性(映射到drag-placeholder属性):
@property({ type: String, reflect: true }) name: string = ''; @property({ type: Boolean, reflect: true, attribute: 'drag-placeholder' }) umbDragPlaceholder = false; // 样式中: // :host([drag-placeholder]) { opacity: 0.2; }当控制器把占位属性加到原条目上时,整个条目淡出,形成“拖拽占位”的视觉反馈。这个drag-placeholder属性名与控制器源码中placeholderAttr的默认值正好对应,因此示例没有配置placeholderAttr也能自动生效。
嵌套容器为什么能工作:控制器源码机制
理解了UmbSorterController(sorter.controller.ts,共 1500 余行)的几个关键机制,示例的行为就都能解释:
1. 基于 dataTransfer 的“身份家族”识别
dragstart时,控制器会把两个键写入event.dataTransfer:
text/umb-sorter-identifier#<identifier> text/umb-sorter-item-unique#<条目的唯一标识>目标容器在dragover中执行#obtainIncomingItem:只有当dataTransfer.types包含与自身identifier匹配的键时才接收该拖拽(并顺带从text/umb-sorter-item-unique#…解析出条目唯一标识)。随后控制器会在自己的模型中查找该条目;若不存在则尝试onRequestDrop;若已存在(hasItem为真)则拒绝——因此同一个条目不会被重复放进同一容器。
这也解释了示例中identifier: 'string-that-identifies-all-example-sorters'的必要性:如果每个分组都用默认的Symbol(),父容器永远无法识别来自子容器(或兄弟容器)的拖拽,dragover直接被忽略;共享同一个字符串后,“组内移动”“组间移动”“父子层级间移动”在控制器看来都是同一套协议。
2. 事件停止冒泡,避免外层容器“抢事件”
#itemDraggedOver处理器无论“本容器即当前 drop 目标”还是“本容器成为新的 drop 目标”,都会执行e.stopPropagation()。在嵌套结构中这一点至关重要:鼠标划过内层容器中的条目时,内层容器先处理并阻止事件继续冒泡,外层容器不会同时响应,从而保证同一时刻只有一个“当前容器”在计算放置位置。
3. moveItemInModel:本地移动与跨容器移动的统一入口
所有放置计算(#updateDragMove)最终调用#moveElementTo(newIndex),进而调用moveItemInModel(newIndex, fromCtrl)(源码约 L1271–L1404)。其分支逻辑:
- 本地移动(
fromCtrl === this):默认实现是对模型数组做splice删除再插入,然后触发onChange;若配置了performItemMove则交给回调(返回false可取消); - 跨容器移动:先
fromCtrl.removeItem(item)从源容器模型中移除(会触发源容器的onChange),再在目标容器模型newIndex处插入,触发目标容器的onContainerChange(带from源控制器引用)与onChange,并更新静态状态activeSorter/dropSorter/activeIndex指向目标容器; - 纯外部拖放(
fromCtrl为undefined,例如来自其他窗口的原生 drop):要求配置requestExternalRemove/requestExternalInsert,否则控制台报错并放弃。
此外dragend时若dropEffect === 'none'(如按 ESC 取消),控制器会调用moveItemInModel(originalIndex, ...)把条目恢复到拖拽前的位置——这正是originalSorter/originalIndex两个静态字段存在的意义。
4. MutationObserver 自动接管动态渲染的条目
控制器构造时创建了一个MutationObserver,初始化后对容器以{ childList: true, subtree: false }观察:凡是新增且匹配itemSelector的节点自动setupItem(绑定mousedown/dragstart/dragend、应用ignorerSelector),被移除的节点自动destroyItem。因此示例中 Litrepeat随数据变化重新渲染条目时,无需手动重新初始化——这是 Web Component 数据驱动渲染与该控制器能配合的根本原因。
5. 状态如何沿“嵌套树”回传
示例中onChange的写法体现了事件回传链条:
- 分组内部模型变化 →
onChange更新_value并dispatchEvent(new CustomEvent('change')); change事件由分组元素自身派发,父级模板直接监听该元素上的@change(无需冒泡);- 顶层分组 → dashboard 把新数组赋回
groupOneItems/groupTwoItems,Lit 重渲染后通过.valuesetter 调#sorter.setModel()重新同步控制器; - 嵌套分组 → 父级模板把
(e.target as ExampleSorterGroup).value写回item.children,下次外层重渲染时同样经valuesetter 同步。
配合disable()/enable()、getModel()/hasItem()/getItem()/removeItem()等公开方法,控制器就构成了完整的可编程接口。
细节与注意事项
- 唯一标识:示例用
name作为 unique,仅适用于数据名称天然唯一的教学场景;生产代码务必使用稳定 ID,否则跨容器移动会因标识冲突而失败。 - identifier 的取舍:需要跨容器(含嵌套容器)拖放时才共享 identifier;相互隔离的独立列表应保持默认
Symbol(),可避免互相接收拖拽。 - 占位样式:默认
drag-placeholder属性即可用 CSS 做出淡出效果;如需 class 形式可改用placeholderClass。 - 自动滚动:指针靠近滚动容器边缘 50px(
autoScrollSensitivity)时以每帧 16px(autoScrollSpeed)的速度自动滚动,源码位于控制器文件头部常量定义(约 L5–L6),嵌套长列表拖拽时同样生效。 - 源码观察:三个示例文件末尾的
HTMLElementTagNameMap声明写的是example-sorter-dashboard-nested/example-sorter-group-nested/example-sorter-item-nested,与@customElement实际注册名不一致;从源码结构看这属于复制自嵌套场景时的历史遗留,不影响示例运行,阅读时以@customElement的注册名为准。 - 测试与相关实现:控制器的单元测试位于 sorter.controller.test.ts,控制器经由 sorter 包的 index.ts 从
@umbraco-cms/backoffice/sorter导出,即示例中import { UmbSorterController } from '@umbraco-cms/backoffice/sorter'的来源;示例包的运行方式见 package.json 中的example/example:mock脚本。
小结
这个 4 个文件的小示例,完整覆盖了 Umbraco Backoffice 接入UmbSorterController的标准姿势:manifest 注册 → 状态容器元素 → 控制器配置(unique 解析、identifier、item/container 选择器、onChange)→ 数据驱动的 repeat 渲染 → 占位样式。其嵌套能力的本质是:共享 identifier 让所有 Sorter 实例讲同一种 dataTransfer 协议,事件 stopPropagation 保证嵌套时层级间互不干扰,MutationObserver 保证动态 DOM 与控制器状态自动同步。把这三点理解清楚后,无论是内容类型属性里的组件排序、还是自定义 workspace 中的多级资源组织界面,都可以按同样的模式实现。
- CMS
- 后端
【免费下载链接】Umbraco-CMS
Umbraco is a free and open source .NET content management system helping you deliver delightful digital experiences.
相关推荐
Ant Design Table 拖拽排序进阶:基于 dnd-kit 实现带拖拽手柄(Handler)的行排序
Ant Design Table 拖拽排序进阶:基于 dnd kit 实现带拖拽手柄(Handler)的行排序 本篇文章围绕 drag sorting hand
前端UI组件设计系统React DnD 可排序列表实战:用 useDrag + useDrop 在 hover 中实现拖拽排序(Simple 示例深度解析)
React DnD 可排序列表实战:用 useDrag + useDrop 在 hover 中实现拖拽排序(Simple 示例深度解析) React DnD 为
前端UI组件rsuite List 组件 collection 分组排序实战:基于集合隔离的拖拽排序实现与源码解析
rsuite List 组件 collection 分组排序实战:基于集合隔离的拖拽排序实现与源码解析 本文基于 rsuite 官方文档 collection.
前端UI组件
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考