用CSS给el-tree添加VS Code风格虚线连接线完整方案
2026/9/15 16:22:58 网站建设 项目流程

最近有个后台项目改版,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 class="el-tree-node is-expanded"> <div class="el-tree-node__content" style="padding-left: 16px;"> <span class="el-tree-node__expand-icon el-icon-caret-right"></span> <span class="el-tree-node__label">父节点</span> </div> <div class="el-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 class="vscode-lines" :data="treeData" 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 撑到实际高度。问题来了:我的竖线是用topbottom固定定位的,动画进行到一半时,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 属于复制即用;如果层级很深或者有复杂自定义节点,优先把行高固定下来再谈连线效果——树形组件好看的第一前提永远是整齐。

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

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

立即咨询