☰
ASP.NET MVC 树形结构完整实现方案:从数据建模到实时同步
2026/10/15 16:23:41 网站建设 项目流程

简介:本资源是一套面向ASP.NET MVC开发者的树形结构(Tree)控件实现方案,专为解决Web应用中层级数据展示需求而设计,适用于组织架构、目录导航、分类管理等典型场景,适合具备MVC基础的中高级开发者快速集成可交互树控件。压缩包共130个文件,涵盖41个GIF动图(用于交互效果演示)、18个JS脚本(含jQuery Tree插件与动态加载逻辑)、10个C#核心类文件(如TreeHtmlHelper.cs、模型定义等)、8个DLL依赖库及配套CSHTML视图、CONFIG配置与SOLUTION工程文件,整体仅853KB,轻量易集成。已有568人学习下载,资源结构完整,包含可直接运行的MVC项目骨架(含Global.asax、.sln、.csproj)、树节点递归渲染逻辑、Ajax懒加载实现、CSS样式与响应式适配代码,以及多级节点展开/折叠的完整前后端协同示例,开箱即用并便于二次定制。

1. ASP.NET MVC 中实现树形结构:为什么原生没 TreeView,而你总在重复造轮子?

ASP.NET MVC 本身不提供类似 Web Forms 里TreeView控件那样的开箱即用树形 UI 组件——这不是疏漏,而是框架设计哲学的必然结果:MVC 强调职责分离与前端可控性,把 DOM 构建、状态管理、交互逻辑全交给开发者自己拿捏。但现实很骨感:权限菜单、组织架构、分类目录、文件资源管理……90% 的后台系统都绕不开树形数据展示与操作。于是大量团队陷入「每次新项目都要重写树组件」的循环:有人硬套 jQuery Treeview 插件却卡在异步加载父子节点联动,有人用 Bootstrap Treeview 但改不动右键菜单样式,更多人直接手写递归 HTML Helper,结果一加搜索、拖拽、懒加载就崩成黑匣子。本文讲的不是“怎么找一个现成控件”,而是如何在 ASP.NET MVC 环境下,从数据建模、后端接口、前端渲染到交互控制,完整落地一个可复用、可扩展、可调试的树形结构方案。适合正在做中后台系统、需要快速集成树形功能、又不想被第三方插件绑架的 .NET 开发者。核心不在于“炫技”,而在于每一步都经得起压测、改得了源码、查得清日志。


2. 从数据库到视图模型:树形数据的三层建模与序列化策略

树形结构的本质是父子关系嵌套,但在关系型数据库里它必须被扁平化存储。常见做法有四种:自引用外键(Adjacency List)、路径编码(Path Enumeration)、左右值(Nested Set)、闭包表(Closure Table)。对 ASP.NET MVC 场景而言,自引用外键是最务实的选择——它简单、易理解、增删改查语义清晰,ORM 支持成熟,且绝大多数业务树深度不超过 6 层,性能无明显瓶颈。我们以「系统菜单」为例,定义实体:

public class MenuNode { public int Id { get; set; } public string Name { get; set; } public string Icon { get; set; } public string Url { get; set; } public int? ParentId { get; set; } // 允许 null,根节点 public int SortOrder { get; set; } // 同级排序 public bool IsEnabled { get; set; } }

提示:ParentId必须为可空类型(int?),否则 EF Core 无法正确映射根节点(ParentId = null);若用 Dapper,需在 SQL 中显式LEFT JOIN自关联查询。

2.1 服务层:构建带层级信息的扁平列表

直接返回嵌套对象(如List<MenuNode>套Children属性)看似直观,但会引发 JSON 序列化循环引用异常(EF 默认开启延迟加载时更危险),且前端处理多级嵌套逻辑复杂。更健壮的做法是返回带层级深度和路径的扁平列表,由前端按Depth和Path渲染缩进或折叠状态:

public class MenuFlatDto { public int Id { get; set; } public string Name { get; set; } public string Icon { get; set; } public string Url { get; set; } public int? ParentId { get; set; } public int Depth { get; set; } // 从 0 开始,根节点 Depth=0 public string Path { get; set; } // 格式:"0.1.5.22",用于排序和定位 public bool IsEnabled { get; set; } } // 在 Service 中 public async Task<List<MenuFlatDto>> GetAllMenuFlatAsync() { var nodes = await _context.MenuNodes .Where(x => x.IsEnabled) .OrderBy(x => x.SortOrder) .ToListAsync(); // 使用栈模拟 DFS 构建 Path 和 Depth var result = new List<MenuFlatDto>(); var stack = new Stack<(MenuNode node, int depth, string path)>(); // 先推入所有根节点 foreach (var root in nodes.Where(x => x.ParentId == null)) { stack.Push((root, 0, root.Id.ToString())); } while (stack.Count > 0) { var (node, depth, path) = stack.Pop(); result.Add(new MenuFlatDto { Id = node.Id, Name = node.Name, Icon = node.Icon, Url = node.Url, ParentId = node.ParentId, Depth = depth, Path = path, IsEnabled = node.IsEnabled }); // 推入子节点(倒序保证同级按 SortOrder 正序输出) var children = nodes .Where(x => x.ParentId == node.Id) .OrderByDescending(x => x.SortOrder) // 倒序推入,正序弹出 .ToList(); foreach (var child in children) { stack.Push((child, depth + 1, $"{path}.{child.Id}")); } } return result.OrderBy(x => x.Path).ToList(); // 按 Path 字符串排序,保证树序 }

2.2 控制器:提供分页、搜索、懒加载三类接口

一个生产级树组件至少要支持三种后端能力:

  • 全量加载(适用于节点数 < 500,如菜单)
  • 懒加载(点击展开时请求子节点,适用于组织架构)
  • 关键词搜索(高亮匹配节点及其所有父级,适用于文件树)
[HttpGet("menu/tree")] public async Task<IActionResult> GetMenuTree() { var data = await _menuService.GetAllMenuFlatAsync(); return Ok(data); } [HttpGet("menu/children/{parentId:int}")] public async Task<IActionResult> GetMenuChildren(int parentId) { var data = await _menuService.GetChildrenByParentIdAsync(parentId); return Ok(data); } [HttpGet("menu/search")] public async Task<IActionResult> SearchMenu([FromQuery] string keyword) { if (string.IsNullOrWhiteSpace(keyword)) return BadRequest("Keyword is required"); var matched = await _menuService.SearchMenuNodesAsync(keyword); return Ok(matched); }

其中SearchMenuNodesAsync不仅返回匹配项,还必须向上追溯所有父节点(直到根),确保前端能完整展开路径。这是搜索树形结构最易忽略的细节——只返回叶子节点,用户根本看不到上下文。


3. 前端渲染:用原生 JavaScript + Razor Partial 实现零依赖树组件

放弃 jQuery 插件不是为了标新立异,而是解决三个真实痛点:

  1. 插件 CSS 与 Bootstrap 5 冲突,改样式要 hack 二十个.class;
  2. 异步加载回调嵌套深,调试时 console.log 打满屏幕;
  3. 右键菜单、拖拽排序、批量勾选等定制需求,插件源码改起来像考古。

我们采用「Razor Partial View + 原生 JS」组合:Partial 负责生成初始 HTML 结构(含>@model List<MenuFlatDto> <div id="menu-tree" class="tree-container"> <ul class="tree-root"> @foreach (var node in Model.Where(x => x.ParentId == null)) { <partial name="_TreeNode" model="node" /> } </ul> </div> <partial name="_TreeScript" />

3.2 递归 Partial:_TreeNode.cshtml

@model MenuFlatDto <li class="tree-node" ><script> // 全局树实例,避免多次初始化 window.MenuTree = window.MenuTree || (function () { const cache = new Map(); // 缓存已加载的子节点 const expandedNodes = new Set(); // 记录已展开的节点 ID function init(containerId = 'menu-tree') { const container = document.getElementById(containerId); if (!container) return; // 绑定点击事件委托 container.addEventListener('click', handleTreeClick); container.addEventListener('keydown', handleTreeKeydown); // 初始化根节点展开状态(可配置) const rootNodes = container.querySelectorAll('.tree-node[data-parent-id="null"]'); rootNodes.forEach(node => { const id = parseInt(node.dataset.id); expandedNodes.add(id); }); } function handleTreeClick(e) { const toggle = e.target.closest('.tree-toggle'); if (!toggle) return; e.preventDefault(); const nodeLi = toggle.closest('.tree-node'); const nodeId = parseInt(nodeLi.dataset.id); const hasChildren = toggle.dataset.hasChildren === 'true'; if (hasChildren) { if (expandedNodes.has(nodeId)) { collapseNode(nodeLi); expandedNodes.delete(nodeId); } else { expandNode(nodeLi, nodeId); expandedNodes.add(nodeId); } } } async function expandNode(nodeLi, nodeId) { const childrenUl = nodeLi.querySelector('.tree-children'); if (!childrenUl) return; // 检查缓存 if (cache.has(nodeId)) { renderChildren(childrenUl, cache.get(nodeId)); childrenUl.style.display = 'block'; return; } try { const res = await fetch(`/api/menu/children/${nodeId}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); cache.set(nodeId, data); renderChildren(childrenUl, data); childrenUl.style.display = 'block'; } catch (err) { console.error('Load children failed:', err); childrenUl.innerHTML = '<li class="tree-error">加载失败,请重试</li>'; } } function renderChildren(ulElement, childrenData) { ulElement.innerHTML = ''; childrenData.forEach(child => { const li = document.createElement('li'); li.className = 'tree-node'; li.dataset.id = child.id; li.dataset.parentId = child.parentId; li.dataset.path = child.path; li.dataset.depth = child.depth; li.innerHTML = ` <div class="tree-node-header"> <span class="tree-toggle ${child.depth < 4 ? 'has-children' : ''}" >public async Task<List<MenuFlatDto>> SearchMenuNodesAsync(string keyword) { var allNodes = await _context.MenuNodes.ToListAsync(); var keywordLower = keyword.ToLower(); // 找到所有匹配的叶子节点 var matchedLeaves = allNodes .Where(x => x.Name.ToLower().Contains(keywordLower) || (!string.IsNullOrEmpty(x.Url) && x.Url.Contains(keywordLower))) .ToList(); // 向上追溯所有父节点 var allRelevantIds = new HashSet<int>(); foreach (var leaf in matchedLeaves) { var current = leaf; while (current != null) { allRelevantIds.Add(current.Id); current = allNodes.FirstOrDefault(x => x.Id == current.ParentId); } } // 构建扁平树(同 2.1 节逻辑) var flatData = BuildFlatTree(allNodes.Where(x => allRelevantIds.Contains(x.Id)).ToList()); // 标记匹配节点用于前端高亮 foreach (var item in flatData) { if (matchedLeaves.Any(x => x.Id == item.Id)) { item.IsMatched = true; } } return flatData.OrderBy(x => x.Path).ToList(); }

前端搜索逻辑(追加到_TreeScript.cshtml):

async function performSearch(keyword) { if (!keyword.trim()) return; try { const res = await fetch(`/api/menu/search?keyword=${encodeURIComponent(keyword)}`); const data = await res.json(); // 清空当前树 const container = document.getElementById('menu-tree'); container.innerHTML = '<ul class="tree-root"></ul>'; // 重新渲染(复用 renderChildren 逻辑) const rootUl = container.querySelector('.tree-root'); renderSearchResults(rootUl, data); // 自动展开所有匹配路径 data.forEach(item => { if (item.IsMatched) { const nodeEl = document.querySelector(`.tree-node[data-id="${item.Id}"]`); if (nodeEl) { // 逐级向上找到所有父节点并展开 const pathParts = item.Path.split('.'); for (let i = 0; i < pathParts.length - 1; i++) { const parentId = parseInt(pathParts[i]); const parentEl = document.querySelector(`.tree-node[data-id="${parentId}"]`); if (parentEl) { const toggle = parentEl.querySelector('.tree-toggle'); if (toggle && !toggle.closest('.tree-node').querySelector('.tree-children[style*="block"]')) { toggle.click(); } } } // 高亮当前节点文本 const label = nodeEl.querySelector('.tree-label'); if (label) { label.innerHTML = highlightText(label.textContent, keyword); } } } }); } catch (err) { console.error('Search failed:', err); } } function highlightText(text, keyword) { const escapedKeyword = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const regex = new RegExp(`(${escapedKeyword})`, 'gi'); return text.replace(regex, '<mark class="search-highlight">$1</mark>'); }

4.2 右键菜单:纯 CSS + dataset 实现轻量级上下文菜单

不引入任何第三方库,用原生contextmenu事件 +position: absolute定位:

<!-- 在 _TreeScript.cshtml 底部追加 --> <style> .tree-context-menu { position: absolute; background: #fff; border: 1px solid #ddd; border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.15); min-width: 160px; z-index: 1000; display: none; } .tree-context-menu.show { display: block; } .tree-context-menu a { display: block; padding: 8px 12px; color: #333; text-decoration: none; font-size: 14px; } .tree-context-menu a:hover { background-color: #f5f5f5; } </style> <div id="tree-context-menu" class="tree-context-menu"> <a href="#">// 追加到 TreeScript function setupDragAndDrop() { const treeContainer = document.getElementById('menu-tree'); treeContainer.addEventListener('dragstart', handleDragStart); treeContainer.addEventListener('dragover', handleDragOver); treeContainer.addEventListener('drop', handleDrop); } function handleDragStart(e) { if (!e.target.closest('.tree-node')) return; e.dataTransfer.setData('text/plain', e.target.dataset.id); e.target.classList.add('dragging'); } function handleDragOver(e) { e.preventDefault(); // 必须阻止默认行为才能触发 drop const node = e.target.closest('.tree-node'); if (node) { e.dataTransfer.dropEffect = 'move'; node.classList.add('drop-target'); } } function handleDrop(e) { e.preventDefault(); const draggedId = parseInt(e.dataTransfer.getData('text/plain')); const targetNode = e.target.closest('.tree-node'); if (!targetNode) return; const targetId = parseInt(targetNode.dataset.id); const targetParentId = targetNode.dataset.parentId === 'null' ? null : parseInt(targetNode.dataset.parentId); // 判断插入位置:before / after / into const rect = targetNode.getBoundingClientRect(); const y = e.clientY - rect.top; let position = 'after'; // 默认插到后面 if (y < rect.height * 0.3) position = 'before'; else if (y > rect.height * 0.7) position = 'after'; else if (targetNode.querySelector('.tree-children')) position = 'into'; // 调用后端 API 更新顺序 updateNodeOrder(draggedId, targetId, position, targetParentId); targetNode.classList.remove('drop-target'); document.querySelectorAll('.tree-node').forEach(n => n.classList.remove('dragging')); } async function updateNodeOrder(draggedId, targetId, position, targetParentId) { try { const res = await fetch('/api/menu/sort', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ DraggedId: draggedId, TargetId: targetId, Position: position, TargetParentId: targetParentId }) }); if (!res.ok) throw new Error(`HTTP ${res.status}`); // 成功后可刷新当前层级或局部更新 DOM location.reload(); // 简单粗暴,生产环境建议局部刷新 } catch (err) { console.error('Sort update failed:', err); alert('排序更新失败,请重试'); } }

后端排序接口(Controller):

[HttpPost("menu/sort")] public async Task<IActionResult> UpdateMenuSort([FromBody] MenuSortRequest request) { var dragged = await _context.MenuNodes.FindAsync(request.DraggedId); if (dragged == null) return NotFound(); // 获取目标节点及其同级节点 IQueryable<MenuNode> siblings; if (request.Position == "into") { siblings = _context.MenuNodes.Where(x => x.ParentId == request.TargetId); } else { siblings = _context.MenuNodes.Where(x => x.ParentId == dragged.ParentId); } var siblingList = await siblings.OrderBy(x => x.SortOrder).ToListAsync(); // 计算新 SortOrder:取目标节点前/后节点的平均值 int? newOrder = null; var targetIndex = siblingList.FindIndex(x => x.Id == request.TargetId); if (targetIndex >= 0) { if (request.Position == "before") { newOrder = targetIndex == 0 ? 0 : (siblingList[targetIndex - 1].SortOrder + siblingList[targetIndex].SortOrder) / 2; } else if (request.Position == "after") { newOrder = targetIndex == siblingList.Count - 1 ? siblingList[targetIndex].SortOrder + 10 : (siblingList[targetIndex].SortOrder + siblingList[targetIndex + 1].SortOrder) / 2; } else // into { newOrder = 0; // 子节点默认排第一 dragged.ParentId = request.TargetId; } } if (newOrder.HasValue) { dragged.SortOrder = newOrder.Value; await _context.SaveChangesAsync(); } return Ok(); } public class MenuSortRequest { public int DraggedId { get; set; } public int TargetId { get; set; } public string Position { get; set; } // "before", "after", "into" public int? TargetParentId { get; set; } }

5. 避坑指南:ASP.NET MVC 树形组件开发中 5 个血泪经验

在多个项目中反复踩过坑后,我把最痛的五个问题整理成「现象 → 原因 → 解决」清单。它们不是理论陷阱,而是上线前夜让你头皮发麻的真实翻车现场。

5.1 现象:树节点展开后内容为空,F12 看 Network 显示 200 但 Response 是空数组

原因:后端GetChildrenByParentIdAsync方法未过滤IsEnabled == true,导致禁用节点被当作有效子节点返回,而前端渲染逻辑跳过IsEnabled == false的节点,最终ul里没li。
解决:所有树形数据接口(全量、懒加载、搜索)必须统一应用业务状态过滤。建议在 Service 基类中封装ApplyActiveFilter()方法,避免遗漏。

5.2 现象:搜索关键词 “admin” 时,菜单 “管理员管理” 被高亮,但其父节点 “系统设置” 未展开,用户看不到上下文

原因:前端只对匹配节点做高亮,未实现“向上追溯并展开所有祖先”的逻辑。
解决:后端搜索接口必须返回完整路径(Path字段),前端解析Path字符串(如"1.5.22"),逐级querySelector并调用toggle.click()展开。不要试图用offsetParent等 DOM 层级遍历,因为节点可能尚未加载。

5.3 现象:拖拽排序后,同级节点顺序错乱,比如 A、B、C 变成 C、A、B

原因:前端计算新SortOrder时用了整数除法(如(10 + 20) / 2 = 15),但数据库字段是int,导致精度丢失;更糟的是,多个拖拽并发时,SortOrder值挤在很小范围内,很快耗尽可用整数。
解决:SortOrder字段必须用decimal(10,2)或float类型;计算新顺序时用(prev + next) / 2.0保证小数;定期执行“重置排序”任务(如每天凌晨将所有SortOrder重设为10, 20, 30...)。

5.4 现象:右键菜单在 Chrome 正常,但在 Edge 中点击无反应

原因:Edge 对contextmenu事件的冒泡处理更严格,e.target可能是<i>或<span>,而closest('.tree-node')失败。
解决:在contextmenu事件处理器中,不用e.target.closest(),改用e.composedPath().find(el => el.classList?.contains('tree-node')),兼容 Shadow DOM 和旧版浏览器。

5.5 现象:树组件在 IE11 下白屏,控制台报SyntaxError: Unexpected token '

原因:前端 JS 使用了模板字符串(`${x}`)或箭头函数(x => x),IE11 不支持。
解决:Webpack/Babel 配置必须明确指定targets: { ie: '11' };Razor Partial 中的内联 JS 也要用 ES5 语法(var替代const/let,function替代箭头函数);CSS 用autoprefixer补全-ms-前缀。


6. 进阶技巧:用 SignalR 实现树节点实时同步与跨标签页状态保持

当系统支持多管理员同时操作同一棵树(如权限菜单),或用户在多个浏览器标签页打开同一树形页面时,就会遇到经典问题:A 标签页修改了节点名称,B 标签页仍显示旧值;A 用户删除了某菜单,B 用户点击后 404。传统轮询(Polling)效率低、延迟高、服务器压力大。SignalR 是 ASP.NET 生态下解决实时同步的最优解——它自动选择 WebSocket / Server-Sent Events / Long Polling 最佳传输方式,且与 MVC Controller 无缝集成。

6.1 后端:创建 Hub 并广播树变更事件

public class TreeHub : Hub { public async Task BroadcastNodeUpdate(MenuNode node, string operation) { // 广播给所有连接客户端(除发送者) await Clients.AllExcept(Context.ConnectionId).SendAsync("ReceiveNodeUpdate", node, operation); } public async Task BroadcastNodeDelete(int nodeId) { await Clients.All.SendAsync("ReceiveNodeDelete", nodeId); } }

在 Controller 的增删改方法末尾调用:

[HttpPost("menu")] public async Task<IActionResult> CreateMenu([FromBody] MenuNode node) { _context.MenuNodes.Add(node); await _context.SaveChangesAsync(); // 通知所有客户端 await _hubContext.Clients.All.SendAsync("ReceiveNodeUpdate", node, "create"); return Ok(node); }

6.2 前端:监听 SignalR 事件并局部更新 DOM

<!-- 在 _TreeScript.cshtml 底部追加 --> <script src="~/lib/signalr/dist/browser/signalr.js"></script> <script> // 初始化 SignalR 连接 const connection = new signalR.HubConnectionBuilder() .withUrl("/treeHub") .configureLogging(signalR.LogLevel.Information) .build(); connection.on("ReceiveNodeUpdate", function (node, operation) { const nodeEl = document.querySelector(`.tree-node[data-id="${node.id}"]`); if (operation === "create" && !nodeEl) { // 新增节点:找到父节点,插入到 children ul 末尾 const parentUl = document.querySelector(`.tree-children[data-parent-id="${node.parentId}"]`); if (parentUl) { const li = createTreeNodeElement(node); parentUl.appendChild(li); } } else if (operation === "update" && nodeEl) { // 更新节点:只改 label 和 url,不重绘整个节点 const label = nodeEl.querySelector('.tree-label'); const link = nodeEl.querySelector('.tree-link'); if (label) label.textContent = node.name; if (link && node.url) link.href = node.url; } }); connection.on("ReceiveNodeDelete", function (nodeId) { const nodeEl = document.querySelector(`.tree-node[data-id="${nodeId}"]`); if (nodeEl) { nodeEl.remove(); // 同时移除其所有子节点(递归) const children = nodeEl.querySelectorAll('.tree-node'); children.forEach(c => c.remove()); } }); // 启动连接 async function startConnection() { try { await connection.start(); console.log("SignalR Connected."); } catch (err) { console.error("SignalR Connection Error:", err); setTimeout(startConnection, 5000); // 重连 } } // 页面加载完成时启动 document.addEventListener('DOMContentLoaded', startConnection); </script>

6.3 跨标签页状态同步:用 localStorage + storage 事件

SignalR 解决了服务端到客户端的推送,但同域下多个标签页间的本地状态(如哪些节点已展开)仍需同步。利用localStorage的storage事件即可:

// 在 TreeScript 中添加 function syncExpandedState() { // 初始化:从 localStorage 读取 const expanded = JSON.parse(localStorage.getItem('tree-expanded') || '[]'); expanded.forEach(id => { const node = document.querySelector(`.tree-node[data-id="${id}"]`); if (node) { const toggle = node.querySelector('.tree-toggle'); if (toggle && !node.querySelector('.tree-children[style*="block"]')) { toggle.click(); } } }); // 监听 storage 事件 window.addEventListener('storage', (e) => { if (e.key === 'tree-expanded') { const newExpanded = JSON.parse(e.newValue || '[]'); // 关闭不再展开的节点 expanded.forEach(id => { if (!newExpanded.includes(id)) { const node = document.querySelector(`.tree-node[data-id="${id}"]`); if (node) { const toggle = node.querySelector('.tree-toggle'); if (toggle) toggle.click(); } } }); // 展开新节点 newExpanded.forEach(id => { if (!expanded.includes(id)) { const node = document.querySelector(`.tree-node[data-id="${id}"]`); if (node) { const toggle = node.querySelector('.tree-toggle'); if (toggle && !node.querySelector('.tree-children[style*="block"]')) { toggle.click(); } } } }); } }); } // 在 expandNode / collapseNode 中更新 localStorage function expandNode(nodeLi, nodeId) { // ...原有逻辑 const expanded = JSON.parse(localStorage.getItem('tree-expanded') || '[]'); if (!expanded.includes(nodeId)) { expanded.push(nodeId); localStorage.setItem('tree-expanded', JSON.stringify(expanded)); } } function collapseNode(nodeLi) { // ...原有逻辑 const expanded = JSON.parse(localStorage.getItem('tree-expanded') || '[]'); const index = expanded.indexOf(parseInt(nodeLi.dataset.id)); if (index > -1) { expanded.splice(index, 1); localStorage.setItem('tree-expanded', JSON.stringify(expanded)); } }

这套组合拳(SignalR + localStorage)让树组件真正具备“活”的特性:服务端变更秒级触达所有客户端,本地操作即时反映到其他标签页。我曾在某高校教

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询