---
title: "树"
description: "用 makeSortableTree 实现大纲、菜单、组织架构图和待办列表——以带深度的扁平形式渲染，通过横向拖动或悬停在某行上来嵌套，支持规则、折叠节点、插入线，以及同一分组中的列表。"
canonical_url: "https://zizigy.github.io/vue-dnd-kit/zh/docs/guides/trees"
---

> Index of every page on this site: [llms.txt](https://zizigy.github.io/vue-dnd-kit/llms.txt)

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

<example height="45.5rem" hint="横向拖动一个章节，或切换嵌套模式" name="tree-outline">



</example>

## 以扁平形式渲染

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

```vue [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>
```

```vue [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`：

<table>
<thead>
  <tr>
    <th>
      <code>
        FlatNode
      </code>
      
       字段
    </th>
    
    <th>
      内容
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        key
      </code>
    </td>
    
    <td>
      节点的 key（<code>
        getKey
      </code>
      
      ，默认 <code>
        node.id
      </code>
      
      ）。用于 <code>
        :key
      </code>
      
       和行的 <code>
        id
      </code>
      
      。
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        item
      </code>
    </td>
    
    <td>
      你的节点。
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        depth
      </code>
    </td>
    
    <td>
      根节点为 <code>
        0
      </code>
      
      。被拖拽节点的深度是它<strong>
        将要
      </strong>
      
      落到的位置，所以它的空位会实时缩进。
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        parentKey
      </code>
    </td>
    
    <td>
      父节点的 key，根节点为 <code>
        null
      </code>
      
      。
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        index
      </code>
    </td>
    
    <td>
      在兄弟节点中的索引。
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        hasChildren
      </code>
      
      、<code>
        collapsed
      </code>
    </td>
    
    <td>
      用于展开按钮。
    </td>
  </tr>
</tbody>
</table>

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

子节点从 `node.children` 读取并写回那里。其他结构的数据使用 `getChildren` 和 `setChildren`；下面的[组织架构图](#rules)把子节点放在 `members` 中：

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

## 嵌套

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

<table>
<thead>
  <tr>
    <th>
      <code>
        nesting
      </code>
    </th>
    
    <th>
      深度来自……
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        'offset'
      </code>
    </td>
    
    <td>
      横向拖动：每向左或向右移动 <code>
        indentation
      </code>
      
       像素就上升或下降一级，在空位周围的行允许的范围内。
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        'hover'
      </code>
    </td>
    
    <td>
      把拖拽停在某一行的中间：放置会进入该行，成为它的第一个子节点。
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        'both'
      </code>
    </td>
    
    <td>
      两者皆可。默认值。
    </td>
  </tr>
</tbody>
</table>

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

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

## 规则

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

<example height="41rem" hint="在团队之间移动人员；试试 Leadership 和第三层" name="tree-org">



</example>

```ts [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,
});
```

```ts [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 });
```

<table>
<thead>
  <tr>
    <th>
      选项
    </th>
    
    <th>
      限制
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        canNest(node, parent)
      </code>
    </td>
    
    <td>
      <code>
        node
      </code>
      
       能否放到 <code>
        parent
      </code>
      
       之下。多项拖拽会对每个被拖拽的节点逐一询问。根节点总是允许的。
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        maxDepth
      </code>
    </td>
    
    <td>
      允许的最大深度，根节点为 <code>
        0
      </code>
      
      。被拖拽节点的后代也计算在内：有成员的团队最深只能到 <code>
        maxDepth - 1
      </code>
      
      。
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        disabled
      </code>
    </td>
    
    <td>
      在行上：该行不能拖拽。在树上：不能从中拖出，也不能放入其中。
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        group.put
      </code>
    </td>
    
    <td>
      树是否接收来自其他列表和树的项。
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        onBeforeDrop
      </code>
    </td>
    
    <td>
      其他任何规则，在放置时决定：见<a href="/docs/guides/drop-decisions">
        决定放置
      </a>
      
      。
    </td>
  </tr>
</tbody>
</table>

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

### 折叠的节点

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

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

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

## 插入线

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

<example height="31rem" hint="把一个模板拖进菜单，或横向移动一个菜单项" name="tree-menu">



</example>

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

```vue [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` 把它们变成新节点，放在插入线所在的位置和深度：

```ts [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 会保留它的任务，只是看不见，移回路线图时又会把它们带回来。从路线图拖出时按住 <kbd value="Alt">



</kbd>

 则改为复制。

<example height="29.5rem" hint="把 Saved cards 移到待办列表再移回来；Alt 复制" name="tree-backlog">



</example>

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

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

```ts [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` 见[共享项的列表](https://zizigy.github.io/docs/guides/shared-lists)。

## 放置

树的放置事件就是列表的放置事件，只是 `from` 和 `to` 多了 `parentKey`：

```ts
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`（见[决定放置](https://zizigy.github.io/docs/guides/drop-decisions#stores-and-servers-controlled-lists)）。副本由 `withChildren(node, children)` 创建，默认是带有新子节点的 `{ ...node }`；对于类实例，传入你自己的函数。

## 键盘与多项拖拽

让一行获得焦点，按 <kbd value="Space">



</kbd>

，然后：

<table>
<thead>
  <tr>
    <th>
      按键
    </th>
    
    <th>
      节点移动到
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <kbd value="ArrowUp">
        
      </kbd>
      
       / <kbd value="ArrowDown">
        
      </kbd>
    </td>
    
    <td>
      上方或下方的位置，每次一行。
    </td>
  </tr>
  
  <tr>
    <td>
      <kbd value="ArrowRight">
        
      </kbd>
    </td>
    
    <td>
      深一级，放到上一行之下，前提是规则允许。
    </td>
  </tr>
  
  <tr>
    <td>
      <kbd value="ArrowLeft">
        
      </kbd>
    </td>
    
    <td>
      上一级，移出它的父节点。
    </td>
  </tr>
  
  <tr>
    <td>
      <kbd value="Space">
        
      </kbd>
      
       / <kbd value="Escape">
        
      </kbd>
    </td>
    
    <td>
      放下它 / 放回原处。
    </td>
  </tr>
</tbody>
</table>

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

选中的节点作为一个整体一起移动，每个都带着自己的子树，按文档顺序排列。位于另一个选中节点之内的选中节点，会随父节点的子树一起移动。见[多项拖拽](https://zizigy.github.io/docs/guides/multi-drag)。

所有选项见 [makeSortableTree API](https://zizigy.github.io/docs/api/make-sortable-tree)。对于有成千上万行的树，见[虚拟列表](https://zizigy.github.io/docs/guides/virtual-lists)。

## Sitemap

See the full [sitemap](https://zizigy.github.io/vue-dnd-kit/sitemap.md) for all pages.
