文档/指南

树

用 makeSortableTree 实现大纲、菜单、组织架构图和待办列表——以带深度的扁平形式渲染,通过横向拖动或悬停在某行上来嵌套,支持规则、折叠节点、插入线,以及同一分组中的列表。

编辑此页

本页由 AI 翻译,可能存在不准确之处。 阅读英文原文

树就是行带有深度的可排序列表。把一个章节向左或向右拖动可以改变它的层级,停在另一个章节的中间则会把它放进去。编号随拖拽更新。

Live

正在加载演示…

<script setup lang="ts">
import { ref } from 'vue';
import { DnDProvider, type TreeNesting } from '@vue-dnd-kit/core';
import Outline from './Outline.vue';

const nesting = ref<TreeNesting>('both');
const indentation = ref(24);
</script>

<template>
  <DnDProvider>
    <div class="demo">
      <div class="switches">
        <fieldset>
          <legend>nesting</legend>
          <label v-for="value in ['both', 'offset', 'hover'] as const" :key="value" :class="{ on: nesting === value }">
            <input v-model="nesting" type="radio" :value="value" />
            {{ value }}
          </label>
        </fieldset>
        <fieldset>
          <legend>indentation</legend>
          <label v-for="value in [16, 24, 40]" :key="value" :class="{ on: indentation === value }">
            <input v-model="indentation" type="radio" :value="value" />
            {{ value }}
          </label>
        </fieldset>
      </div>
      <code class="call">makeSortableTree(el, { nesting: '{{ nesting }}', indentation: {{ indentation }}, maxDepth: 2 })</code>
      <Outline :nesting="nesting" :indentation="indentation" />
    </div>
  </DnDProvider>
</template>

<style scoped>
.demo {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
}

.switches {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 10px 16px;
}

fieldset {
  display: inline-flex;
  margin: 0;
  padding: 3px;
  border: 0;
  border-radius: 9px;
  background: color-mix(in srgb, currentColor 7%, transparent);
}

legend {
  float: left;
  align-self: center;
  padding: 0 8px 0 6px;
  font: 10px/1 ui-monospace, Menlo, Consolas, monospace;
  opacity: 0.55;
}

label {
  padding: 5px 9px;
  border-radius: 6px;
  font: 12px/1.2 ui-monospace, Menlo, Consolas, monospace;
  cursor: pointer;
}

label.on {
  background: Canvas;
  box-shadow: 0 1px 3px rgb(0 0 0 / 0.25);
  font-weight: 600;
}

label:has(input:focus-visible) {
  outline: 2px solid #42b883;
}

input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}

.call {
  max-width: 100%;
  padding: 6px 10px;
  overflow-x: auto;
  border-radius: 8px;
  background: color-mix(in srgb, currentColor 6%, transparent);
  font: 11px/1.4 ui-monospace, Menlo, Consolas, monospace;
  white-space: nowrap;
  color: #42b883;
}
</style>

以扁平形式渲染

makeSortableTree 接收你的嵌套数据,按顺序以扁平列表的形式返回可见节点,每个节点带有它的 depth。由你渲染这个列表并自行缩进各行。每一行都是同一个容器中的兄弟元素,因此各行像列表的行一样滑动并产生动画。

Outline.vue
<script setup lang="ts">
import { ref, useTemplateRef } from 'vue';
import { makeSortableTree } from '@vue-dnd-kit/core';
import Heading from './Heading.vue';

interface Section { id: string; title: string; children?: Section[] }

const outline = ref<Section[]>([
  { id: 'start', title: 'Getting started', children: [{ id: 'install', title: 'Installation' }] },
  { id: 'faq', title: 'FAQ' },
]);

const root = useTemplateRef<HTMLElement>('root');
const { items, projected } = makeSortableTree(root, {
  items: outline,     // the root nodes: a writable ref takes every drop
  indentation: 24,    // px per level: match the rows' indentation
  maxDepth: 2,
});
</script>

<template>
  <ol ref="root">
    <Heading v-for="node in items" :key="node.key" :node="node" />
  </ol>
</template>
Heading.vue
<script setup lang="ts">
import { useTemplateRef } from 'vue';
import { makeSortableItem, type FlatNode } from '@vue-dnd-kit/core';

const props = defineProps<{ node: FlatNode<{ id: string; title: string }> }>();
const el = useTemplateRef<HTMLElement>('el');
makeSortableItem(el, { id: () => props.node.key });
</script>

<template>
  <li ref="el" :style="{ marginInlineStart: `${node.depth * 24}px` }">{{ node.item.title }}</li>
</template>

行使用 makeSortableItem,与列表的行一样。用 margin 而不是 padding 来缩进:这样移动的是行本身,随它的深度而动。浮层采用空位的宽度并落到空位所在的层级,深度的变化也像其他任何移动一样滑动过去。padding 只会缩进行内的内容,而浮层是拿起时那一行的副本,会停留在拖拽开始时的层级。items 包含 FlatNode:

FlatNode 字段内容
key节点的 key(getKey,默认 node.id)。用于 :key 和行的 id。
item你的节点。
depth根节点为 0。被拖拽节点的深度是它将要落到的位置,所以它的空位会实时缩进。
parentKey父节点的 key,根节点为 null。
index在兄弟节点中的索引。
hasChildren、collapsed用于展开按钮。

由于 items 是草稿,你从它计算出的任何东西都会跟随拖拽:演示根据 items 给章节编号,所以移动时“2.1”会变成“3.1”。projected 是被拖拽节点将要落到的位置 { depth, parentKey },不在这棵树上方时为 null。

子节点从 node.children 读取并写回那里。其他结构的数据使用 getChildren 和 setChildren;下面的组织架构图把子节点放在 members 中:

makeSortableTree(root, {
  items: org,
  getChildren: (member) => member.members,
  setChildren: (member, members) => { member.members = members; },
});

嵌套

拖拽以两种方式改变深度,默认都开启:

nesting深度来自……
'offset'横向拖动:每向左或向右移动 indentation 像素就上升或下降一级,在空位周围的行允许的范围内。
'hover'把拖拽停在某一行的中间:放置会进入该行,成为它的第一个子节点。
'both'两者皆可。默认值。

把 indentation 设为你的行每级增加的缩进(默认 24),这样空位就会跟随指针:在演示中切换到 40 再横向拖动试试。只用 'hover' 时,横向拖动保持层级不变,只有行的中间才会嵌套。两个选项都接受 ref 或 getter,并在下一次拖拽时生效。

使用 axis: 'y'(或 restrictToVerticalAxis)时,拖拽无法横向移动,但行仍可以通过悬停来嵌套。

规则

Engineering 接收人员和团队;Leadership 被锁定;最深只能到团队中的团队。展开和折叠各个团队:它们显示和隐藏的行会滑入滑出。

Live

正在加载演示…

<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import OrgTree from './OrgTree.vue';
</script>

<template>
  <DnDProvider>
    <OrgTree />
  </DnDProvider>
</template>
OrgTree.vue
const { items } = makeSortableTree(root, {
  items: org,
  getChildren: (member) => member.members,
  setChildren: (member, members) => { member.members = members; },
  // Only teams take members, and a locked team takes no one.
  canNest: (_member, team) => team.kind === 'team' && !team.locked,
  maxDepth: 2,
  collapsed,
  animateChanges: true,
});
OrgRow.vue
// A locked row doesn't drag (and says so with `aria-disabled`).
makeSortableItem(el, { id: () => props.node.key, disabled: () => !!props.node.item.locked });
选项限制
canNest(node, parent)node 能否放到 parent 之下。多项拖拽会对每个被拖拽的节点逐一询问。根节点总是允许的。
maxDepth允许的最大深度,根节点为 0。被拖拽节点的后代也计算在内:有成员的团队最深只能到 maxDepth - 1。
disabled在行上:该行不能拖拽。在树上:不能从中拖出,也不能放入其中。
group.put树是否接收来自其他列表和树的项。
onBeforeDrop其他任何规则,在放置时决定:见决定放置。

当规则拒绝某个深度时,空位取能通过的最深的深度:把 Noor 拖到 Raj 下方并向右,空位仍停在顶层,因为 Leadership 被锁定,而 Raj 是个人。没有任何深度能通过的地方,空位不会移过去。副本和来自 createItem 的节点也遵循 canNest 和 maxDepth。

折叠的节点

collapsed 是一个 key 的 Set,其中的节点的子节点会被隐藏,可以是 ref 或 getter。它们的子节点不在 items 中;node.collapsed 和 node.hasChildren 用于驱动按钮:

const collapsed = ref(new Set<Key>());
const toggle = (key: Key) => (collapsed.value.has(key) ? collapsed.value.delete(key) : collapsed.value.add(key));

被拖拽的节点会带上它的整棵子树,无论是否折叠。使用 animateChanges: true 时,节点展开或折叠时显示或隐藏的行会以动画进出,非拖拽引起的数据变化也一样。

插入线

把左侧的模板拖进菜单,或移动一个菜单项:菜单保持不动,一条线显示放置的位置,从它将落到的层级开始画。在页面或分组的中间上方时,该行会高亮。

Live

正在加载演示…

<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import MenuTree from './MenuTree.vue';
import Template from './Template.vue';
</script>

<template>
  <DnDProvider>
    <div class="editor">
      <aside class="templates">
        <h3>Add</h3>
        <Template title="Page" kind="page" />
        <Template title="Link" kind="link" />
        <Template title="Group" kind="group" />
      </aside>
      <MenuTree />
    </div>
  </DnDProvider>
</template>

<style scoped>
.editor {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  justify-content: center;
  gap: 20px;
}

.templates {
  display: flex;
  flex-direction: column;
  gap: 8px;
  width: 120px;
}

h3 {
  margin: 0 0 2px;
  font: 600 10px/1 ui-monospace, Menlo, Consolas, monospace;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  opacity: 0.55;
}
</style>

使用 placeholder: 'indicator' 时,拖拽期间树不会移动。每一行读取自己的 indicator 并画出插入线:

MenuRow.vue
<script setup lang="ts">
import { computed, useTemplateRef } from 'vue';
import { makeSortableItem, type FlatNode } from '@vue-dnd-kit/core';

const props = defineProps<{ node: FlatNode<MenuItem>; indent: number }>();
const el = useTemplateRef<HTMLElement>('el');
const { indicator } = makeSortableItem(el, { id: () => props.node.key });

// 'inside': the drop goes into this row. Otherwise a line before or after it.
const inside = computed(() => indicator.value?.side === 'inside');
const line = computed(() => {
  const value = indicator.value;
  if (!value || value.side === 'inside') return null;
  // From the level the drop lands at, measured from this row's own level.
  return { side: value.side, left: `${((value.depth ?? 0) - props.node.depth) * props.indent}px` };
});
</script>

<template>
  <div ref="el" class="item" :class="{ inside }" :style="{ marginLeft: `${node.depth * indent}px` }">
    {{ node.item.title }}
    <span v-if="line" class="line" :class="line.side" :style="{ left: line.left }" />
  </div>
</template>

<style scoped>
.item { position: relative; }
.item.inside { box-shadow: 0 0 0 1.5px #42b883; }
.line { position: absolute; right: 0; height: 2px; background: #42b883; }
.line.before { top: -3px; }
.line.after { bottom: -3px; }
</style>

在树中,indicator 增加了 depth 和 parentKey:被放置的节点将获得的层级和父节点。横向拖动会在层级之间移动插入线,键盘也会移动插入线。在此模式下,放到树之外会取消。

模板是带有 data 的普通可拖拽元素(makeDraggable)。树用 createItem 把它们变成新节点,放在插入线所在的位置和深度:

MenuTree.vue
let created = 0;
makeSortableTree(root, {
  items: menu,
  placeholder: 'indicator',
  maxDepth: 1,
  canNest: (_item, parent) => parent.kind !== 'link',
  createItem: (active) => {
    const template = active.data as { title: string; kind: MenuItem['kind'] };
    return { id: `new-${++created}`, title: `New ${template.title.toLowerCase()}`, kind: template.kind };
  },
});

菜单上方的栏渲染的是 menu,即已保存的数据,而不是 items:它只在放置时变化。

同一分组中的树和列表

移到待办列表中的 story 会保留它的任务,只是看不见,移回路线图时又会把它们带回来。从路线图拖出时按住 Alt 则改为复制。

Live

正在加载演示…

<script setup lang="ts">
import { DnDProvider } from '@vue-dnd-kit/core';
import Roadmap from './Roadmap.vue';
import Backlog from './Backlog.vue';
</script>

<template>
  <DnDProvider>
    <div class="planner">
      <Roadmap />
      <Backlog />
    </div>
  </DnDProvider>
</template>

<style scoped>
.planner {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 260px));
  justify-content: center;
  gap: 14px;
  width: 100%;
}
</style>

group 相同的树和列表可以交换节点,就像两个列表一样。节点无论去到哪里都保留它的子树:列表只是不显示它。同一个行组件可以同时服务两者,因为 makeSortableItem({ item }) 会按拥有该行的列表或树来确定 key。

复制的方式与列表相同(clone、pull: 'clone'、复制键),但 clone 必须返回带有整棵子树的副本,其中每个节点都需要新的 key:

Roadmap.vue
let copies = 0;
const copy = (work: Work): Work => ({ ...work, id: `${work.id}-copy-${++copies}`, children: work.children?.map(copy) });

makeSortableTree(root, {
  items: roadmap,
  group: 'work',
  canNest: (work, parent) => RANK[parent.kind] < RANK[work.kind],
  maxDepth: 2,
  clone: copy,
});

分组、复制和 createItem 见共享项的列表。

放置

树的放置事件就是列表的放置事件,只是 from 和 to 多了 parentKey:

makeSortableTree(root, {
  items: outline,
  onDrop: (e) => {
    console.log(e.keys, e.from.parentKey, '→', e.to.parentKey, e.to.index);
  },
});

to.index 是在新的兄弟节点中的位置,在节点离开旧位置之后计算。放置时,库给发生变化的节点设置新的子节点(setChildren),并把新的根数组写入你的 ref。

e.result.get(treeId) 是以新根数组表示的新树:变化路径上的节点是副本,未改动的子树是共享的,你的节点保持原样。对于 store 或不可变数据,传入 getter,调用 e.preventDefault() 并保存 e.result(见决定放置)。副本由 withChildren(node, children) 创建,默认是带有新子节点的 { ...node };对于类实例,传入你自己的函数。

键盘与多项拖拽

让一行获得焦点,按 Space,然后:

按键节点移动到
ArrowUp / ArrowDown上方或下方的位置,每次一行。
ArrowRight深一级,放到上一行之下,前提是规则允许。
ArrowLeft上一级,移出它的父节点。
Space / Escape放下它 / 放回原处。

在指示器模式下,方向键改为移动插入线。从树旁边的列表或模板出发,朝树方向按方向键会把项接收进来,与它将要放在其前面的那一行平齐。

选中的节点作为一个整体一起移动,每个都带着自己的子树,按文档顺序排列。位于另一个选中节点之内的选中节点,会随父节点的子树一起移动。见多项拖拽。

所有选项见 makeSortableTree API。对于有成千上万行的树,见虚拟列表。