ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用CSS给el-tree添加VS Code风格虚线连接线完整方案

用CSS给el-tree添加VS Code风格虚线连接线完整方案 最近有个后台项目改版UI 在评审时提了一句树形菜单能不能长出 VS Code 那种虚线连接线我第一反应是找现成的树组件或插件翻了几个库发现要么太重要么样式跟项目完全不搭最后仔细看了一圈 el-tree 渲染出来的 DOM心里基本有底了——它本身的结构已经足够规则只需要一段 CSS 就能画出完整的连接线。这篇文章就把这段可以直接复制的方案完整记录下来顺便把底层的挂载原理、为什么这几行选择器要这么写、以及实测中踩过的动画和懒加载的坑一并讲清楚。如果你正在用 Element UI 或 Element Plus也想给 tree 加一套干净清爽的连接线跟着做基本十分钟就能落地。1. 先拆解 VS Code 连接线的视觉构成别把树画成表格1.1 竖线、横线、缩进三个必须同时满足的细节动手写代码之前我对着 VS Code 的资源管理器仔细观察了一会儿。它的树连接线并不是一条简单的贯穿线而是由三个相互独立又彼此关联的视觉元素组成的。第一个元素是竖线。从每个可展开节点的图标正下方出发垂直向下延伸直到该节点的最后一个子节点结束。这条线表达的是“父子关系在垂直方向上的延续”所以它只出现在有子节点的区域内部不会画到节点的标题行上。第二个元素是横线。每个子节点的图标左侧都有一小段水平短线和左侧的竖线相接长度很短大概十来个像素不会贯穿整行。这就是树连接线和表格分隔线最本质的区别表格线是水平贯穿的树连接线则是局部断开的。第三个元素是缩进。每一层的缩进量是固定且等距的VS Code 的缩进节奏非常规律竖线、横线、图标的中心点全都严格对齐。视觉上看起来整齐本质上就是这个“等距缩进”在起作用。这三者缺一不可。只画竖线没有横线节点之间缺少衔接只画横线没有竖线父子关系表达不出来缩进不均匀线再好看也是一团乱麻。1.2 为什么 border-bottom 式的方案一眼假我在网上看过不少“给 el-tree 加线”的实现最常见的做法是给.el-tree-node__content加一个border-bottom把每个节点底部都画上一条横线。这种方案能看出“有很多行”但完全表达不了树形结构视觉上更像 Excel 表格或者表格类的列表。树连接线要求的是“局部连线”不是“整行框线”。真正耐看的效果一定来自竖线和横线的组合而且线条本身要克制1px 粗细、浅色、不抢内容的风头。很多人画完线之后觉得“脏”十有八九是因为线太深太粗、位置又没有对齐。所以我在设计这套样式时给自己定了几条原则线宽固定 1px颜色用浅灰或半透明白横线长度固定竖线只挂在展开且有子节点的区域上。下面所有代码都是围绕这几点展开的。2. 看透 el-tree 的三层结构才知道线该挂在哪2.1 el-tree-node / content / children 的分工在 Element UI 2.x 里任意一个非叶子节点渲染出来的 DOM 结构大致是这样div classel-tree-node is-expanded div classel-tree-node__content stylepadding-left: 16px; span classel-tree-node__expand-icon el-icon-caret-right/span span classel-tree-node__label父节点/span /div div classel-tree-node__children !-- 子节点继续以 .el-tree-node 嵌套 -- /div /div这里面有三层是画线的关键.el-tree-node是每个节点的外层容器专门用来挂 is-expanded、is-leaf、is-current 这些状态类。.el-tree-node__content是可见的节点行展开箭头、图标、文字都在这一行里节点的高度也由它决定。.el-tree-node__children是子节点的集合容器展开时展示折叠时display: none。这三层结构对画线特别友好因为__children天然就是“父节点下方的一块纵向区域”竖线画在它的伪元素上再合适不过而横线要跟着每一行走画在__content的伪元素上是唯一合理的选择。两者互不干扰也不需要额外包裹新 DOM。2.2 竖线绝不能画在 el-tree-node 上很多人第一次写的时候会顺手把竖线画在.el-tree-node::before上结果发现线把整个节点区域都罩住了。原因很简单.el-tree-node的高度等于__content加上__children两部分竖线画在它上面会把父节点自己的标题行也覆盖掉视觉上就是从节点头一直穿到尾根本没法收住。正确的做法是只画在.el-tree-node.is-expanded:not(.is-leaf) .el-tree-node__children::before上。用直接子代选择器确保只命中当前节点的子容器不会波及到更深层的嵌套节点。同时配合is-expanded和:not(.is-leaf)两个状态保证只有在“已展开且不是叶子节点”的情况下才会出现竖线。用伪元素而不是真实 DOM 还有个额外的好处折叠和展开都不需要 JS 介入节点状态类一变化CSS 自动响应性能开销几乎为零。2.3 内联 padding第一步必须接管el-tree 的层级缩进是通过padding-left写进内联 style 的每层递增 16px这是组件库自己计算出来的。这个内联样式的问题是它让层级缩进变得不可预测而且内联样式优先级很高普通 CSS 选择器覆盖不掉必须用!important。如果不去接管这个 padding连接线就很难稳定对齐。不同层级的节点图标起始的 x 坐标不一样横线和竖线的 left 值无法统一预期写出来的样式换一棵树可能就乱了。所以我的方案第一步是统一接管所有 content 的 padding-left让层级缩进完全由自己的 CSS 变量控制。这样缩进节奏一致横线竖线的位置也全都在掌控之内。这也解释了为什么很多只看效果就抄代码的方案会“放到深层嵌套马上乱套”——因为那个方案的线是碰巧对齐的不是真正算好位置画出来的。3. 即贴即用的主代码一段 CSS 复刻虚线连接线3.1 复制这段 CSS直接上代码。用的时候给 el-tree 加一个vscode-lines类名el-tree classvscode-lines :datatreeData default-expand-all /样式如下。默认是虚线效果颜色是偏冷的浅灰如果你想完全复刻 VS Code 默认的实线后面我会告诉你改哪一个变量。.el-tree.vscode-lines { --line-color: #c8d0da; /* 线条颜色暗色主题可换成 rgba(255,255,255,.18) */ --line-width: 1px; /* 线条宽度 */ --line-style: dashed; /* dashed虚线 solid实线 */ --indent: 16px; /* 每层缩进增量 */ --icon-space: 24px; /* 节点内容左侧留给图标的距离 */ --row-height: 30px; /* 节点行高跟 el-tree-node__content 的实际高度保持一致 */ color: inherit; } /* 1. 统一节点行高、覆盖内联缩进 */ .el-tree.vscode-lines .el-tree-node__content { position: relative; height: var(--row-height); line-height: var(--row-height); padding-left: var(--icon-space) !important; } .el-tree.vscode-lines .el-tree-node__children .el-tree-node__content { padding-left: calc(var(--icon-space) var(--indent)) !important; } .el-tree.vscode-lines .el-tree-node__children .el-tree-node__children .el-tree-node__content { padding-left: calc(var(--icon-space) var(--indent) * 2) !important; } .el-tree.vscode-lines .el-tree-node__children .el-tree-node__children .el-tree-node__children .el-tree-node__content { padding-left: calc(var(--icon-space) var(--indent) * 3) !important; } /* 层级更深时按此规律继续追加 */ /* 2. 每个节点内容前的横向短线 */ .el-tree.vscode-lines .el-tree-node__content::before { content: ; position: absolute; top: 50%; transform: translateY(-50%); left: calc(var(--icon-space) - 8px); width: 12px; border-top: var(--line-width) var(--line-style) var(--line-color); } .el-tree.vscode-lines .el-tree-node__children .el-tree-node__content::before { left: calc(var(--icon-space) var(--indent) - 8px); } .el-tree.vscode-lines .el-tree-node__children .el-tree-node__children .el-tree-node__content::before { left: calc(var(--icon-space) var(--indent) * 2 - 8px); } .el-tree.vscode-lines .el-tree-node__children .el-tree-node__children .el-tree-node__children .el-tree-node__content::before { left: calc(var(--icon-space) var(--indent) * 3 - 8px); } /* 根节点前不画横线 */ .el-tree.vscode-lines .el-tree-node .el-tree-node__content::before { display: none; } /* 3. 展开节点子区域左侧的竖直主干线 */ .el-tree.vscode-lines .el-tree-node.is-expanded:not(.is-leaf) .el-tree-node__children { position: relative; } .el-tree.vscode-lines .el-tree-node.is-expanded:not(.is-leaf) .el-tree-node__children::before { content: ; position: absolute; left: calc(var(--icon-space) - 3px); top: calc(-1 * var(--row-height)); /* 从父节点 content 的底部开始 */ bottom: calc(var(--row-height) / 2); /* 给最后一个子节点留出半行不穿底 */ border-left: var(--line-width) var(--line-style) var(--line-color); } .el-tree.vscode-lines .el-tree-node__children .el-tree-node.is-expanded:not(.is-leaf) .el-tree-node__children::before { left: calc(var(--icon-space) var(--indent) - 3px); } .el-tree.vscode-lines .el-tree-node__children .el-tree-node__children .el-tree-node.is-expanded:not(.is-leaf) .el-tree-node__children::before { left: calc(var(--icon-space) var(--indent) * 2 - 3px); }3.2 这段代码为什么要这么写先说行高。height: var(--row-height)和line-height: var(--row-height)的作用是让所有节点行保持相同高度这样横线才能用top: 50%稳定垂直居中。树形组件的视觉整齐度很大程度上取决于行高是否统一这段代码里的所有竖向定位都依赖这个值。再说几个关键的偏移量。left: calc(var(--icon-space) - 8px)里的-8px是为了让横线从图标左侧开始伸向图标width: 12px控制横线长度。竖线的left: calc(var(--icon-space) - 3px)比横线略靠右一点目的是让竖线和横线在视觉上“接上”。这几个像素值是按常见图标宽度微调出来的不同主题、不同字体下可能会有一两像素偏差你落地时如果发现横线没有正好接上竖线调整这里的数值就行不用改结构。竖线的top: calc(-1 * var(--row-height))是一个很关键的设计。因为竖线画在 children 的伪元素上children 区域的顶部正好是父节点 content 的底部所以 top 取负一个行高线就能从父节点标题行的图标下方开始向下画。bottom: calc(var(--row-height) / 2)则让竖线在最后一个子节点行内提前收住不会穿过最后一行符合 VS Code 的视觉习惯。3.3 如何切换实线 / 虚线、亮色 / 暗色配色这一段用 CSS 变量控制基本不用动结构线和样式--line-style: dashed是标题要的虚线想完全复刻 VS Code 默认效果改成solid。线颜色亮色主题用#c8d0da或#d4d4d4暗色主题用rgba(255,255,255,0.18)透明度很低才不会发白刺眼。缩进节奏--indent: 16px控制每层缩进增量想更宽松就调成 20px 或 24px。图标留位--icon-space: 24px是节点内容左侧留给图标的区域如果你的树里没有展开箭头或图标可以适当调小。如果你用的是 Element Plus并且项目里开了 scoped 样式需要把选择器包一层:deep()比如.el-tree.vscode-lines :deep(.el-tree-node__content) { ... }。不过我更推荐把这段样式直接放到全局样式文件里毕竟带独立类名不会污染其他树。4. 实测排坑动画、叶子节点、懒加载与行高变化代码一跑就出效果的情况当然是有的但我在真实项目中还是遇到了几个一眼看不出来的问题这里逐个复盘。4.1 展开动画期间竖线会“闪”一下Element 的树节点展开默认是有 height 过渡动画的动画过程中 children 容器的高度从 0 撑到实际高度。问题来了我的竖线是用top和bottom固定定位的动画进行到一半时children 的实际高度还在增长竖线却已经按最终状态定位好了于是你会看到一条线从中间突然冒出来非常突兀。排查的时候我先把 transition 关掉发现一切正常基本能确定是动画不同步的问题。这不是代码 bug而是 CSS 画线配合 JS 动画时必然存在的不同步。解决方式有两条路。如果产品对动画不敏感直接把树节点的展开动画关掉或者把过渡时间缩短到 0.1s 以内竖线的闪动基本肉眼不可见。如果产品一定要保留顺滑动画那只能接受这个微小的视觉瑕疵因为纯 CSS 方案很难让伪元素的定位跟 height 过渡产生联动。4.2 根节点横线悬空的问题叶子节点的处理其实比想象中的要简单VS Code 里文件图标的左侧本来就有横线所以叶子节点保留横线是符合预期的。真正一眼假的是根节点上的横线——它上面没有竖线来源会孤零零悬在最左边看起来非常傻。解决方案就是代码里那句 .el-tree-node .el-tree-node__content::before { display: none; }把树最顶层的根节点横线隐藏掉。这里用是万无一失的它只命中树根下的第一层节点不会误伤深层的首个子节点。如果你希望空目录完全不出现任何连接线也可以把横线的选择器统一加上:not(.is-leaf)这样所有叶子节点的横线都会消失树看起来会更简洁代价是“叶子节点和展开节点”的视觉联系变弱了看产品需求取舍。4.3 懒加载节点展开后出现悬空竖线这是所有坑里最隐蔽的一个。懒加载场景下某个节点没有 children 数组也没有设置 isLeaf用户点开时 el-tree 会把它当成可展开节点渲染一个空的 children 容器。从 CSS 角度看它完全符合is-expanded:not(.is-leaf)于是竖线照画不误但下面没有任何子节点线直接悬在半空视觉效果非常奇怪。我排查时先看 devtools确认 children 容器是空的才意识到问题不在 CSS而在数据层。懒加载节点在加载后要正确处理 isLeaf如果接口返回空数组可以把 isLeaf 设为 true节点就不会再出现展开状态如果确实还会有子节点就要保证 children 在加载完成后被填充。如果数据不可控CSS 这边可以加一条兜底.el-tree-node__children:empty::before { display: none; }空容器直接不画线。el-tree 的空 children 容器一般是真的空:empty实测能命中但需要注意如果容器里有注释节点或空白字符:empty 就不生效了所以数据层修好才是最稳的。4.4 自定义内容不定高时行高变量怎么跟着走用 render-content 自定义节点是很容易踩的坑。我在某个版本里放了两行文字节点行瞬间被撑高到了 46px但--row-height: 30px没变竖线的top: calc(-1 * 30px)直接对不上实际高度线从错误位置开始往下画整个结构看起来像是错位了。排查之后我确认横线由于用了top: 50%还能保持垂直居中竖线因为依赖固定行高一旦行高变了就会错位。最稳妥的解决办法是给所有节点内容设置固定高度限制自定义内容必须在这个高度内布局。树形结构保持行高一致连接线才能稳定这也是树组件视觉整齐的大前提。如果你实在需要不定高可以给内容区设min-height而不是height同时把竖线上移起点改成基于top: 0; bottom: 0挂到.el-tree-node上再配合 JS 动态设置行高变量。但这会明显增加复杂度不建议普通项目尝试。5. 更深层级的懒人办法SCSS 生成与项目落地5.1 CSS 为什么不能自动适应任意层级看到这里你可能会问树有七八层怎么办难道要把选择器从第 1 层写到第 7 层答案是确实要写因为纯 CSS 没有“读取父元素 padding-left 再回传给子元素”的能力。连接线要精确对齐就必须知道每一层节点的缩进起点而唯一可靠的方式是让每一层的 padding 和连线 left 值都明确写出来。好在实际业务里树形结构很少超过 5 层手写 4 组选择器基本够用超过 5 层的场景可以考虑用下面这个 SCSS 写法。5.2 用 SCSS 递归生成多层选择器如果你项目里用 SCSS可以用一个递归 mixin 自动生成多层选择器省去大量重复劳动。我自己在项目里的写法大概是这样的mixin tree-line($layer: 0, $max: 6) { .el-tree-node__content { padding-left: calc(var(--icon-space) var(--indent) * #{$layer}) !important; ::before { left: calc(var(--icon-space) var(--indent) * #{$layer} - 8px); } } .el-tree-node__children .el-tree-node.is-expanded:not(.is-leaf) .el-tree-node__children { ::before { left: calc(var(--icon-space) var(--indent) * #{$layer} - 3px); } } if $layer $max { .el-tree-node__children { include tree-line($layer 1, $max); } } } .el-tree.vscode-lines { --line-color: #c8d0da; --line-width: 1px; --line-style: dashed; --indent: 16px; --icon-space: 24px; --row-height: 30px; include tree-line(0, 6); }这个 mixin 实测能生成 6 层选择器并且不会重复选中当前层以外的东西。核心思路是每次递归时给选择器多包一层.el-tree-node__children同时 layer 加 1这样生成的样式和手写完全等价。想支持更多层把$max调大就行。5.3 主题适配与组件内 :deep() 的落地在 Element Plus 项目里如果这段样式写在带 scoped 的组件里直接写.el-tree-node__content是命中不了的组件库内部的类名对 scoped 样式是隔离的。两种做法一种是把这段样式放到全局样式文件里。我推荐这种因为连接线本身属于组件基础样式逻辑单一放全局也不会影响其他树只要类名带vscode-lines就不会误伤。另一种是在组件里用:deep()包裹例如.el-tree.vscode-lines :deep(.el-tree-node__content) { /* 样式 */ }注意第一层.el-tree.vscode-lines不需要放在 :deep 里因为它本身就是你在组件模板里写的类名属于当前作用域能命中的部分。主题适配方面我的习惯是把--line-color这类的变量跟项目主题变量绑定。项目是亮色主题时给浅灰暗色主题时切换成半透明白一行 CSS 的事。连接线这种东西颜色一定要克制太深的线会盖过内容本身的视觉权重得不偿失。最后分享一个我的落地经验。这套样式我抽成了独立的tree-lines.scss已经跟着三个后台项目上线Chrome、Safari、Edge 表现一致Firefox 也正常没有出现兼容性问题。如果你的树层级少、行高固定这段 CSS 属于复制即用如果层级很深或者有复杂自定义节点优先把行高固定下来再谈连线效果——树形组件好看的第一前提永远是整齐。
返回列表