SpliceTree:一个“只管逻辑不管 UI”的无头树,让树形交互更好写
最近我在做各种树:文件树、权限树、级联选择、目录大纲……越写越发现一个问题:树最难的不是“怎么渲染”,而是“怎么交互”。
展开/收起、勾选/半选、拖拽移动、搜索高亮、键盘上下左右、单选多选、Shift 范围选……这些逻辑写在组件里,时间久了就会变成一坨:改一个交互牵一发动全身。
所以我做了一个项目:SpliceTree。
它是一个 Headless(无头) 的树运行时——
- 只负责:树结构、状态、行为、事件
- 不负责:渲染、样式、DOM 结构
你可以把它当作“树的逻辑内核”:UI 想用 Vue/React/Svelte,或者你自己手写 DOM,都可以。
项目地址:
- GitHub:github.com/michaelcoco…
- 文档站:www.splicetree.dev
下面这篇文章我按 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-click 和 input: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。
相关链接:
- 文档与示例:www.splicetree.dev
- 仓库:Splice Tree