树形(可展开的嵌套列表)
层级的形状:靠一个 Set<id> 记着开了哪些,深度决定缩进、箭头随开合旋转。数据天然嵌套、想一眼看结构又不愿一次摊开(文件目录、组织架构、菜单),就是它。
试这几下:
- 点箭头或整行 —— 展开/收起,▶ 旋转 90° 成 ▼;父节点右侧标「N 项」
- 展开 src / features / products/ —— 首次进入用骨架条撑 ~400ms 模拟远程懒加载,加载完缓存不再骨架
- 点全部展开 / 全部收起 —— 一次性铺开或折起所有目录(懒节点也顺带触发)
- Tab 聚焦到树容器,↑↓ 移动焦点,Enter/Space 触发当前行;叶子文件用 toast 报出完整路径
- 顶部信息条实时跟着状态走:「已展开 N 个目录 · 可见 M 行」,收起后可见行数一目了然
规矩
- 展开状态是一个
Set<id>:递归组件按稳定 id 键控,深度决定缩进(demo 用depth × 20px),箭头方向反映开合。demo 放useState,重进就丢;真实版可选择进 URL /localStorage让刷新保持形状 - 树是递归组件:每个节点自己判断「是文件夹?开了没?」,子节点共用同一份
expanded / onToggle,不要在父层特判层数 - 叶子和文件夹点击语义不同:文件夹只切换展开;叶子路由到详情或触发操作,demo 里 toast 出完整路径(
parent/child/leaf.tsx)代表这层动作 - 深层节点独立取数:真实版每个文件夹展开时按自己的 id 单查
Repository.list({ parentId })(照add-list-view的懒加载套路),避免一次拉整棵树;demo 把整棵树预置在内存,用LAZY集合 + 420ms 定时器 + 骨架条模拟远程延迟,loaded集当客户端缓存,展开过的懒节点再开就秒开
蓝本:
add-tree-view.md(open-dashboard @aa9815f,MIT,Invariants 已消化进上面「规矩」)
demo 源码:assets/demo-tree-view.html(自包含、未压缩)
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>形状 demo:树形</title>
<!--
demo-tree-view.html —— 「树形」形状的最小可玩实现(蓝本:open-dashboard 的 add-tree-view + tree.tsx 模板)。
复现的不变量:
- 展开状态是一个 Set<id>;渲染是按 id 键控的递归节点组件
- 按深度缩进(本 demo 20px × depth),箭头图标反映展开状态并随开合旋转 90°
- 叶子点击 = 打开/路由(demo 里 toast 完整路径),文件夹点击 = 切换展开
- 深层节点首次展开时用骨架屏占位(demo 用 LAZY 集合 + 定时器 400ms 模拟),加载后缓存于 loaded 集
- 键盘可达:容器 tabIndex=0,↑↓ 移动焦点,Enter/Space 触发当前行
运行时:/vendor 的 React 18 UMD + htm(免构建),样式共用同级 demo.css。
-->
<link rel="stylesheet" href="demo.css">
<style>
.tree { background: var(--card); border: 1px solid var(--border); border-radius: 10px;
padding: 6px 0; outline: none; }
.tree:focus-visible { border-color: var(--primary); }
.row { display: flex; align-items: center; gap: 8px; padding: 6px 12px;
cursor: pointer; user-select: none; font-size: 13px; }
.row:hover { background: var(--chip-off-bg); }
.row.focused { background: var(--chip-off-bg);
box-shadow: inset 2px 0 0 var(--primary); }
.caret { display: inline-block; width: 12px; color: var(--muted);
font-size: 10px; transition: transform .15s ease; }
.caret.open { transform: rotate(90deg); }
.caret.leaf { visibility: hidden; }
.icon { width: 18px; text-align: center; font-size: 14px; line-height: 1; }
.name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.count { color: var(--muted); font-size: 12px; }
.skel { height: 10px; margin: 6px 12px; }
.hint { color: var(--muted); font-size: 12px; }
</style>
</head>
<body>
<div id="root"></div>
<script src="/vendor/react.production.min.js"></script>
<script src="/vendor/react-dom.production.min.js"></script>
<script src="/vendor/htm.umd.js"></script>
<script>
"use strict";
const { useState, useMemo, useEffect } = React;
const html = htm.bind(React.createElement);
// 懒加载的节点:首次展开时用骨架屏撑 LAZY_MS,之后进 loaded 集缓存
const LAZY = new Set(["src-features-products"]);
const LAZY_MS = 420;
const TREE = [
{
id: "src", name: "src",
children: [
{
id: "src-components", name: "components",
children: [
{
id: "src-components-ui", name: "ui",
children: [
{ id: "ui-button", name: "button.tsx" },
{ id: "ui-card", name: "card.tsx" },
{ id: "ui-badge", name: "badge.tsx" },
],
},
{ id: "src-components-form", name: "form.tsx" },
],
},
{
id: "src-features", name: "features",
children: [
{
id: "src-features-products", name: "products",
children: [
{ id: "products-schema", name: "schema.ts" },
{ id: "products-server", name: "server.ts" },
{ id: "products-queries", name: "queries.ts" },
{ id: "products-readme", name: "README.md" },
],
},
],
},
{ id: "src-config", name: "app.ts" },
],
},
{
id: "drizzle", name: "drizzle",
children: [
{ id: "drizzle-0000", name: "0000_init.sql" },
{ id: "drizzle-meta", name: "meta.json" },
],
},
{ id: "readme", name: "README.md" },
];
// 把树按当前展开状态压平成可见行,供渲染与键盘导航共用
function flatten(nodes, depth, pathPrefix, expanded, loaded, out) {
for (const n of nodes) {
const p = pathPrefix ? pathPrefix + "/" + n.name : n.name;
out.push({ node: n, depth, path: p });
const isFolder = Array.isArray(n.children);
if (!isFolder || !expanded.has(n.id)) continue;
// 懒节点尚未加载:不下钻,由渲染时的骨架屏占位
if (LAZY.has(n.id) && !loaded.has(n.id)) continue;
flatten(n.children, depth + 1, p, expanded, loaded, out);
}
}
function collectFolderIds(nodes, out) {
for (const n of nodes) {
if (Array.isArray(n.children)) {
out.push(n.id);
collectFolderIds(n.children, out);
}
}
}
function App() {
// 展开集:瞬态 UI 状态,真实版可考虑进 URL / localStorage
const [expanded, setExpanded] = useState(new Set(["src", "src-components", "src-components-ui"]));
const [loading, setLoading] = useState(new Set());
const [loaded, setLoaded] = useState(new Set());
const [focusIdx, setFocusIdx] = useState(0);
const [toast, setToast] = useState("");
useEffect(() => {
if (!toast) return;
const t = setTimeout(() => setToast(""), 2000);
return () => clearTimeout(t);
}, [toast]);
const visible = useMemo(() => {
const out = [];
flatten(TREE, 0, "", expanded, loaded, out);
return out;
}, [expanded, loaded]);
// 收起后可见行会变少:把焦点夹到有效范围
useEffect(() => {
if (focusIdx > visible.length - 1) {
setFocusIdx(Math.max(0, visible.length - 1));
}
}, [visible.length, focusIdx]);
function beginLazy(id) {
setLoading(prev => { const next = new Set(prev); next.add(id); return next; });
setTimeout(() => {
setLoaded(prev => { const next = new Set(prev); next.add(id); return next; });
setLoading(prev => { const next = new Set(prev); next.delete(id); return next; });
}, LAZY_MS);
}
function toggle(entry) {
const n = entry.node;
const isFolder = Array.isArray(n.children);
if (!isFolder) { setToast("已打开:" + entry.path); return; }
const wasOpen = expanded.has(n.id);
setExpanded(prev => {
const next = new Set(prev);
if (wasOpen) next.delete(n.id); else next.add(n.id);
return next;
});
if (!wasOpen && LAZY.has(n.id) && !loaded.has(n.id) && !loading.has(n.id)) {
beginLazy(n.id);
}
}
function expandAll() {
const ids = [];
collectFolderIds(TREE, ids);
setExpanded(new Set(ids));
for (const id of ids) {
if (LAZY.has(id) && !loaded.has(id) && !loading.has(id)) beginLazy(id);
}
setToast("已展开全部 " + ids.length + " 个目录");
}
function collapseAll() {
setExpanded(new Set());
setFocusIdx(0);
setToast("已收起全部");
}
function onKeyDown(e) {
if (visible.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setFocusIdx(i => Math.min(visible.length - 1, i + 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setFocusIdx(i => Math.max(0, i - 1));
} else if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
const cur = visible[focusIdx];
if (cur) toggle(cur);
}
}
return html`
<div class="page">
<header class="head">
<div>
<h1>项目文件树</h1>
<p class="sub">点箭头或整行展开 · 叶子文件点击打开 · 键盘 ↑↓ 移动、Enter 触发</p>
</div>
</header>
<div class="toolbar">
<button class="btn sm" onClick=${expandAll}>全部展开</button>
<button class="btn sm" onClick=${collapseAll}>全部收起</button>
<span class="grow"></span>
<span class="hint">已展开 ${expanded.size} 个目录 · 可见 ${visible.length} 行</span>
</div>
<div class="tree" tabIndex=${0} role="tree" aria-label="项目文件" onKeyDown=${onKeyDown}>
${visible.map((v, i) => {
const isFolder = Array.isArray(v.node.children);
const isOpen = expanded.has(v.node.id);
const isLoading = loading.has(v.node.id);
const count = isFolder ? v.node.children.length : 0;
return html`
<div key=${v.node.id}
class=${"row" + (i === focusIdx ? " focused" : "")}
role="treeitem"
aria-level=${v.depth + 1}
aria-expanded=${isFolder ? isOpen : undefined}
style=${{ paddingLeft: (v.depth * 20 + 12) + "px" }}
onClick=${() => { setFocusIdx(i); toggle(v); }}>
<span class=${"caret" + (isFolder ? "" : " leaf") + (isOpen ? " open" : "")}>▶</span>
<span class="icon">${isFolder ? (isOpen ? "📂" : "📁") : "📄"}</span>
<span class="name">${v.node.name}</span>
${isFolder && html`<span class="count">${count} 项</span>`}
</div>
${isFolder && isOpen && isLoading && html`
<div key=${"skel-" + v.node.id}>
${[0, 1, 2].map(k => html`
<div key=${k} class="skel"
style=${{ marginLeft: ((v.depth + 1) * 20 + 12) + "px",
width: (58 - k * 12) + "%" }}></div>`)}
</div>`}
`;
})}
${visible.length === 0 && html`<div class="empty">空树</div>`}
</div>
${toast && html`<div class="toast">${toast}</div>`}
</div>`;
}
ReactDOM.createRoot(document.getElementById("root")).render(html`<${App} />`);
</script>
</body>
</html>