SpliceTree:一个“只管逻辑不管 UI”的无头树,让树形交互更好写

34 阅读7分钟

SpliceTree:一个“只管逻辑不管 UI”的无头树,让树形交互更好写

最近我在做各种树:文件树、权限树、级联选择、目录大纲……越写越发现一个问题:树最难的不是“怎么渲染”,而是“怎么交互”。

展开/收起、勾选/半选、拖拽移动、搜索高亮、键盘上下左右、单选多选、Shift 范围选……这些逻辑写在组件里,时间久了就会变成一坨:改一个交互牵一发动全身。

所以我做了一个项目:SpliceTree

它是一个 Headless(无头) 的树运行时——

  • 只负责:树结构、状态、行为、事件
  • 不负责:渲染、样式、DOM 结构

你可以把它当作“树的逻辑内核”:UI 想用 Vue/React/Svelte,或者你自己手写 DOM,都可以。

项目地址:


下面这篇文章我按 5 大块来讲,尽量用更平易近人的方式,把它能做什么、怎么用、插件怎么玩清楚。

  • 第一块:什么是无头树(Headless Tree)
  • 第二块:核心能力与“插件体系”到底帮了什么
  • 第三块:每个插件怎么用(含小代码)
  • 第四块:适配器(以 Vue 3 为例)
  • 第五块:总结与适用场景

第一块:什么是无头树(Headless Tree)?

无头树你可以理解为:它不长“脸”,只长“脑子”

很多树组件看起来很全:UI、样式、交互都给你包了。但一旦你想换一种渲染方式、改一点交互细节,就很容易掉进“改组件内部实现”的坑。

SpliceTree 反过来做:

  • 你给它数据(通常是扁平结构,比如 id/parent
  • 它给你“可见节点列表”、节点状态(层级、是否展开等)
  • 所有交互(勾选、拖拽、搜索、键盘…)交给插件来扩展
  • UI 层只管把它渲染出来、把用户输入传给它

这带来的好处非常直接:

  • UI 你想怎么写都行(组件库/自定义样式/虚拟列表…)
  • 逻辑可以复用(同一套 tree 逻辑,多个页面多个框架都能用)
  • 交互可以拆开(我今天只要搜索,明天再加拖拽,不用重写一遍)

第二块:核心能力 + 插件体系,能带来什么?

1)核心(@splicetree/core)做的事

核心其实就两件事:

  • 把“扁平数据”整理成一个可操作的树
  • 给你一组稳定、好预测的 API

比如:

  • items():拿到当前“可见节点列表”(UI 最关心的就是这个)
  • getNode(id):按 id 找节点
  • expand/collapse/toggleExpand:展开收起
  • appendChildren/moveNode:追加子节点、移动节点
  • events:事件总线(插件和 UI 都能监听)

2)插件体系解决的痛点

树的交互很容易越堆越多:勾选、拖拽、搜索、键盘……如果都写在 core 里,核心会膨胀得很快,也很难维护。

SpliceTree 的策略是:复杂功能一律插件化

你只装你要的能力:

  • 想要勾选:加 checkable
  • 想要拖拽:加 dnd
  • 想要搜索:加 search
  • 想要懒加载:加 lazy-load
  • 想要键盘导航:加 keyboard
  • 想要点击选择:加 pointer + selectable

而且插件配置是“集中式”的:统一写到 configuration 下,不会把核心 options 撑得很臃肿。


第三块:每个插件怎么用?(作用 + 用法)

这一块我把官方插件都过一遍,尽量用“你一看就能抄”的方式讲。

下面示例里我们默认有个 data,结构大概是:

const data = [
  { id: 'a', title: '节点 A' },
  { id: 'b', title: '节点 B', parent: 'a' },
]

1)勾选/半选:@splicetree/plugin-checkable

它适合权限树、分类勾选这类场景:支持勾选半选向下级联向上计算半选

import { createSpliceTree } from '@splicetree/core'
import checkable from '@splicetree/plugin-checkable'

const tree = createSpliceTree(data, {
  defaultExpanded: ['a'],
  plugins: [checkable],
  configuration: {
    checkable: {
      defaultChecked: ['a'],
      triggerByClick: true,
    },
  },
})

你在 UI 里可以通过节点方法做状态判断:

  • node.isChecked()
  • node.isIndeterminate()
  • node.toggleCheck()

2)拖拽移动:@splicetree/plugin-dnd

想做“文件管理器那种拖拽移动”,就用它:支持拖到目标前/后/作为子节点

import { createSpliceTree } from '@splicetree/core'
import dnd from '@splicetree/plugin-dnd'

const tree = createSpliceTree(data, {
  plugins: [dnd],
  configuration: {
    dnd: {
      autoUpdateParent: true,
      autoExpandOnDrop: true,
    },
  },
})

如果你不希望插件帮你改原始数据(比如你想自己落库),可以把 autoUpdateParent 关掉,然后监听 move 事件自行处理。

3)搜索匹配:@splicetree/plugin-search

搜索插件做的事很朴素:你输入一个关键词,它帮你标记哪些节点匹配,并提供查询方法。

import { createSpliceTree } from '@splicetree/core'
import search from '@splicetree/plugin-search'

const tree = createSpliceTree(data, {
  plugins: [search],
  configuration: {
    search: {
      matcher: (node, q) => String(node.original.title ?? '').toLowerCase().includes(q.toLowerCase()),
    },
  },
})

tree.search('节点')

UI 渲染时,你可以用 node.isMatched() 来做高亮,或者用 tree.matchedKeys 去控制展开逻辑。

4)懒加载:@splicetree/plugin-lazy-load

如果你的数据很多,不想一次性请求全部子节点,可以用懒加载:首次展开时去拉子节点,然后追加进树里。

import { createSpliceTree } from '@splicetree/core'
import lazyLoad from '@splicetree/plugin-lazy-load'

const tree = createSpliceTree(data, {
  plugins: [lazyLoad],
  configuration: {
    lazyLoad: {
      loadChildren: async (node) => {
        const resp = await fetch(`/api/children?parent=${node.id}`)
        return await resp.json()
      },
    },
  },
})

它还提供了 loadedKeys/isLoaded/load 这些方法,方便你在 UI 里展示“加载中/已加载”的状态。

5)键盘输入:@splicetree/plugin-keyboard

这个插件的定位是“输入采集器”:它主要做一件事——把方向键这样的输入,转成统一事件 input:direction

import { createSpliceTree } from '@splicetree/core'
import keyboard from '@splicetree/plugin-keyboard'
import selectable from '@splicetree/plugin-selectable'

const tree = createSpliceTree(data, {
  plugins: [keyboard, selectable],
  configuration: {
    keyboard: {
      autoListen: true,
      target: '.keyboard-area',
      keymap: { up: 'ArrowUp', down: 'ArrowDown', left: 'ArrowLeft', right: 'ArrowRight' },
    },
    selectable: { multiple: true },
  },
})

注意:真正的“导航行为”(上下移动选中、左右展开收起)通常交给 selectable 这类“行为插件”来实现。

6)点击输入:@splicetree/plugin-pointer

pointer 也是“输入采集器”:它把点击转换成语义事件 input:node-click

UI 层通常是这样接上去的:

import { createSpliceTree } from '@splicetree/core'
import pointer from '@splicetree/plugin-pointer'
import selectable from '@splicetree/plugin-selectable'

const tree = createSpliceTree(data, { plugins: [pointer, selectable] })

button.addEventListener('click', (e) => {
  tree.onClick(nodeId, e)
})

7)选择能力:@splicetree/plugin-selectable

selectable 是“行为插件”:它消费 input:node-clickinput:direction,实现:

  • 单选
  • 多选(Ctrl/Cmd 切换)
  • Shift 范围选择
  • 方向键导航(配合 keyboard)

一个常见组合是:keyboard + pointer + selectable

import { createSpliceTree } from '@splicetree/core'
import keyboard from '@splicetree/plugin-keyboard'
import pointer from '@splicetree/plugin-pointer'
import selectable from '@splicetree/plugin-selectable'

const tree = createSpliceTree(data, {
  plugins: [keyboard, pointer, selectable],
  configuration: {
    keyboard: { target: '.list' },
    selectable: { multiple: true, defaultSelected: ['a'] },
  },
})

UI 里渲染时,用 node.isSelected()tree.selectedKeys 控制样式就行。

8)自己写插件:用 Favorite(收藏)做个例子

文档里有个示例插件 Favorite,它做的事情很简单:给节点加“收藏/取消收藏”,同时提供 favorite 事件。

这块的核心思路是:插件可以扩展 4 件事:

  • 配置(configuration.favorite
  • 事件(events.on('favorite', ...)
  • 实例方法(tree.favorite(...)
  • 节点方法(node.toggleFavorite(...)

你可以把自己的业务逻辑(比如“置顶”“最近使用”“权限校验”)用同样方式抽成插件,树的核心仍然保持干净。


第四块:适配器(以 Vue 3 为例)

很多人看到 Headless 会担心:“那我渲染怎么办?”

其实很简单:核心只给你 items(),你渲染时把它循环出来就行。为了更方便在框架里用,SpliceTree 提供了适配器。

Vue 3 适配器:@splicetree/adapter-vue

它做的事情很明确:把核心的 items() 变成 Vue 的响应式数据,这样你不需要自己手动监听事件更新 UI。

import { useSpliceTree } from '@splicetree/adapter-vue'

const { items, expand, collapse, toggleExpand } = useSpliceTree(data, {
  defaultExpanded: ['a'],
})

然后在组件里渲染:

<template>
  <div v-for="n in items" :key="n.id">
    {{ n.original.title }}
  </div>
</template>

适配器的原则也很简单:

  • 不侵入核心
  • 能用所有插件
  • 只是“让你写 UI 更顺手”

文档里也留了 React/Svelte/WebComponents 的位置,后面也会逐步补齐。


第五块:总结(以及它适合谁)

如果你写过树组件,应该懂这种痛:越写越像在“做一套树系统”。

SpliceTree 的目标不是取代你的 UI,而是让你把注意力放回到真正重要的事情上:

  • 树的状态怎么管理
  • 交互怎么组合
  • 逻辑怎么复用

它比较适合这些场景:

  • 文件树 / 资源管理器(拖拽移动 + 键盘 + 多选)
  • 权限树(勾选/半选 + 搜索)
  • 级联选择(懒加载 + 勾选)
  • 大纲/目录(可见节点 + 键盘导航)

如果你也有“树逻辑写在组件里越来越乱”的感觉,可以试试把它拆出去:逻辑归逻辑、UI 归 UI

相关链接: