资讯动态

splitpanes实战:上方面板自适应高度,下方面板填满剩余空间

发布时间:2026/9/14 22:32:03 来源:尧图企业网站定制
做了几年后台管理系统上下分栏是一种特别常见的布局。筛选区、工具栏、列表区、日志预览区几个模块要挤在一个屏里还要求它们之间能拖拽调节高度。最近一个项目里正好碰到一个细化需求上方面板的高度要跟着内容走比如上面筛选条件区展开货折叠、日志列表条数有变化分隔条位置得能自动跟着内容的高度走下方面板则始终填满剩余空间。这个需求听起来不复杂但用splitpanes插件实现的时候里面有几个隐藏的坑正好把踩坑过程完整记录下来。1. 先说结论splitpanes到底怎么决定两块面板的高度1.1 它和传统flex布局的核心差异splitpanes从本质上讲是一个封装好的双向拖拽分隔容器。它的结构可以理解成外层容器是flex布局内部的Pane组件通过flex-basis分配空间。你在模板里写了两个Pane默认情况下它们会各占50%的宽度或高度你拖中间的分隔条实际是在修改两个Pane的flex-basis。这里最关键的差异在于splitpanes对Pane尺寸的控制是通过size属性来表达的而不是内容自动撑开的。size的默认单位是百分比范围是0到100。也就是说跟传统flex布局不一样Pane并不会因为它内部不内容变高就自动变高它只认你给它的百分比。哪怕上面那个Panel里放了一万个元素如果size是50它依然只占容器总高度的50%多余内容要么滚动、要么被裁掉但绝不会把下面那个Panel顶下去。那“上方面板高度由内容撑开”这个需求本质上就意味着需要动态计算上方内容到底占容器总高度的百分之多少然后把这个百分比实时塞给上面的Pane。下面那个Pane不需要给具体百分比让它去争剩余空间就行。splitpanes这点处理得比较好只要上面Pane的size定了下面Pane会自动填充剩余的高度不需要额外设置。1.2 “高度由内容撑开”这个需求为什么棘手很多刚接触splitpanes的同学会直观地想“我只要把上方的Pane里内容的高度设为auto然后splitpanes不就能自动感知了吗”事实上不会。splitpanes的实现是把Pane默认设了overflow相关的样式它更像一个绝对定位的容器而不是一个随内容自动长高的普通div。所以我第一次上手时也掉进了这个坑把上面Panel的内容塞进去发现上下还是对半分分隔条纹丝不动。原因很简单——splitpanes根本不关心内容的高度它只认size。改造的核心思路就是忽略“自动撑开”这种CSS层面的预期改为在逻辑层动态计算内容实际高度再换算成splitpanes能理解的百分比实时更新size。这个思路一旦确定后面所有代码都围绕一个目标转怎么稳定获取上方内容的实际高度并把它转换为百分比。只要这个转换准确上下两块面板的联动就是顺理成章的事。1.3 适用场景与前置条件这套方案最适合的使用场景是上方内容可变高比如折叠面板的展开和收起、动态列表条数变化、文本内容追加要求分隔条随之移动而下方区域必须始终撑满剩余空间。典型如运营后台的“筛选条件区数据表格”、部署平台的“日志预览操作详情”、代码编辑工具的“输入区输出区”都属于这类。前置条件其实只有一个splitpanes的外层容器必须有明确高度。你可以给它设置height: 100%或者通过calc计算出的固定高度再或者是你自己写死的px值。如果外层容器的高度是auto整个计算比例会失真上方面板内容多高、容器就有多高splitpanes完全找不到参照系下方面板也不可能感知“剩余空间”这个概念。项目环境我用的是Vue3.4 TypeScript Vitesplitpanes版本是3.1.5。如果是Vue2项目要用splitpanes的2.x版本但整体思路是一样的Vue3版本在API上更简洁推荐直接用v3。2. 方案选型三种思路我为什么只用动态size2.1 方案一动态计算size百分比这是最终采用的方案。核心逻辑很简单监听上方内容的高度变化用内容实际高度除以splitpanes容器总高度算出百分比再赋值给上面Pane的size。它的最大优势是通用性强不管内容是文本、列表、自定义组件还是异步加载出来的图片只要内容高度变了通过上方内容容器的高度变化就能感知并更新分隔条位置。缺点是需要写一点点逻辑比如高度获取要在渲染完成后执行还要处理窗口resize的情况。2.2 方案二纯CSS配合flex-grow有朋友会说“我直接把上面Panel的高度设为auto下面Panel设flex-grow: 1不就行了”这在普通flex布局里确实可行但splitpanes的Pane组件不会把内部的flex-grow属性传出去。你可以通过deep样式强行改但改完会发现拖拽功能失效或者行为变得很怪异因为splitpanes拖拽时会直接修改flex-basis和你的grow规则互相冲突。所以这个方案只适合“完全不需要拖拽”的场景。如果你只是想要一个静态分栏倒不如不用splitpanes直接用flex布局反而更干净。既然引入了splitpanes绝大多数场景都是要保留拖拽能力的动态size方案才是正解。2.3 方案三固定pixel值直接绑定splitpanes的Pane在最新版本里size是可以接收一个带px的字符串的比如size320px它会识别并作为固定像素值处理。这样可以实现“上方固定高度、下方占满剩余空间”但它不符合“由内容撑开”这个需求因为内容是变化的320px不可能动态适应内容。不过这个方案有一个变体可以做如果上方面板的内容高度相对固定只是偶尔变化你可以用JavaScript动态获取内容高度再给size赋类似360px这样的字符串。本质上和方案一做的事情一样只是单位选择不同。实测下来用px字符串绑定方式在拖拽后会有一点小问题splitpanes拖拽时会把它改写成百分比下次内容变化你重新赋值px它又能换回来逻辑上有些割裂感所以我更建议直接用百分比。2.4 各方案对比方案是否支持内容自动撑开拖拽分隔条实现难度推荐度动态size百分比支持支持中低推荐纯CSS flex-grow部分不支持或异常低不推荐固定px值绑定不支持需JS辅助支持中过渡方案从表格一眼就能看出来动态size百分比方案是综合表现最好的。它保留了拖拽能力又实现了内容变化时分隔条自动跟随而且代码量并不大。3. 核心代码实操上方自适应、下方占满剩余空间3.1 完整代码实现Vue3 script setup TypeScript先看完整的组件示例这是一个典型的“日志查询条件日志详情预览”上下布局template div classpage-container Splitpanes classsplitpanes-container refsplitpanesRef :dbl-click-splitterfalse Pane :sizetopPaneSize classtop-pane div reftopContentRef classtop-content div classfilter-box !-- 筛选条件区域内容可展开/折叠 -- button clickexpanded !expanded {{ expanded ? 收起筛选 : 展开筛选 }} /button div v-ifexpanded classfilter-fields input placeholder订单号 / input placeholder用户ID / input placeholder商品名称 / input placeholder下单时间 / /div /div div classlog-list div v-foritem in logs :keyitem.id classlog-item span{{ item.time }}/span span{{ item.level }}/span span{{ item.message }}/span /div /div /div /Pane Pane classbottom-pane div classdetail-view h3日志详情/h3 pre{{ detailContent }}/pre /div /Pane /Splitpanes /div /template script setup langts import { ref, reactive, onMounted, onBeforeUnmount, nextTick, watch } from vue import { Splitpanes, Pane } from splitpanes import splitpanes/dist/splitpanes.css const splitpanesRef refInstanceTypetypeof Splitpanes | null(null) const topContentRef refHTMLDivElement | null(null) const expanded ref(false) const logs reactive([ { id: 1, time: 2025-01-10 10:00:01, level: INFO, message: 用户登录成功 }, { id: 2, time: 2025-01-10 10:00:05, level: WARN, message: 接口响应超过500ms }, { id: 3, time: 2025-01-10 10:00:12, level: ERROR, message: 数据库连接池占用过高 } ]) const detailContent ref(这里展示选中日志的完整堆栈信息\n\n可以滚动查看) // 上面板初始按50%展示后续会根据内容真实高度覆盖 const topPaneSize ref(50) // 是否开启自动适配用户一旦手动拖拽分隔条就关闭自动适配 let autoFit ref(true) // 防止重复计算的标记 let calcTimer: number | null null // 核心方法计算上方内容高度占容器总高度的百分比 function updateTopPaneSize() { if (!autoFit.value) return const containerEl splitpanesRef.value?.$el as HTMLElement | undefined const topContentEl topContentRef.value if (!containerEl || !topContentEl) return const containerHeight containerEl.clientHeight const contentHeight topContentEl.offsetHeight if (!containerHeight) return // 留一点边距避免分隔条贴到顶 const percent Math.min(90, Math.max(10, (contentHeight / containerHeight) * 100)) topPaneSize.value Number(percent.toFixed(2)) } // 内容变化时重新计算 function recomputeTopSize() { if (calcTimer) { window.clearTimeout(calcTimer) } calcTimer window.setTimeout(() { nextTick(() { updateTopPaneSize() }) }, 60) } // 用户拖拽结束后触发 function onResized(event: any) { // 如果用户手动拖拽了说明用户想要自定义分隔条位置 // 此时关闭自动适配避免每次内容更新都跟用户操作抢高度 autoFit.value false } // 双击分隔条恢复自动适配 function onDbClick() { autoFit.value true nextTick(() { updateTopPaneSize() }) } // 监听窗口尺寸变化 function handleWindowResize() { recomputeTopSize() } let resizeObserver: ResizeObserver | null null onMounted(() { // 初次渲染完成后计算一次 nextTick(() { updateTopPaneSize() }) // 用ResizeObserver监听上方内容高度变化 if (topContentRef.value) { resizeObserver new ResizeObserver(() { recomputeTopSize() }) resizeObserver.observe(topContentRef.value) } window.addEventListener(resize, handleWindowResize) }) onBeforeUnmount(() { resizeObserver?.disconnect() window.removeEventListener(resize, handleWindowResize) }) /script style scoped .page-container { height: 100vh; padding: 16px; box-sizing: border-box; } .splitpanes-container { height: 100%; border: 1px solid #eee; border-radius: 8px; overflow: hidden; } .top-content { padding: 16px; box-sizing: border-box; height: auto; overflow: visible; } .filter-box { margin-bottom: 12px; } .filter-fields { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 12px; padding: 12px; background: #f8f9fa; border-radius: 6px; } .log-list { border-top: 1px solid #e5e5e5; } .log-item { display: flex; gap: 12px; padding: 6px 0; border-bottom: 1px solid #f0f0f0; font-size: 13px; } .bottom-pane { background: #fafbfc; } .detail-view { padding: 16px; height: 100%; box-sizing: border-box; overflow: auto; } /style3.2 关键逻辑拆解计算上面板高度占百分比代码里最核心的是updateTopPaneSize这个函数。它做的事情听起来很简单遍历取到splitpanes容器的高度再取到上方内容元素的高度两个一除乘以100得百分比然后赋给topPaneSize。但有几个细节必须处理到位第一获取内容高度的时机。内容元素如果在v-if里切换或者列表数据是异步请求返回后渲染的那么拿到的高度可能是旧的甚至是0。我的做法是在数据变化后调用recomputeTopSize函数内部先做一个60ms的轻量防抖再通过nextTick保证DOM已经更新完毕最后才计算。这60ms看起来不严谨但在实际项目中能避免很多无意义的重复计算算是性价比很高的一行代码。第二上下限控制。当上方内容很少时比如日志列表一条都没有内容高度可能只有几十像素此时如果直接把百分比赋给size上面板会压缩到几乎看不见而且分隔条也没法拖了。所以我加了Math.min(90, Math.max(10, ...))的边界限制强行把上面板的size锁在10%到90%之间。这个10%的下限很重要它保证了即使用户内容极少也能拖到比如40%而不至于面板完全消失。第三容器高度的获取方式。用splitpanesRef.value.$el.clientHeight$el是splitpanes组件根元素clientHeight是最真实的渲染高度比offsetHeight更可靠因为offsetHeight会包border算百分比时容易出现几十px的偏差。这里算错了分隔条位置就会肉眼可见地偏斜所以细节上我比较较真。3.3 如何让内容变化后自动重新撑开内容变化主要分三类用户操作引起的DOM变化、异步请求渲染新数据、窗口尺寸变化。三类都必须在代码里覆盖到。第一类用户操作引起的DOM变化比如展开或收起筛选条件。这时点击按钮后上方内容的height会变。如果你只用简单的watch(expanded, ...)去触发计算可能因为子组件的调整而拿不准高度。更稳妥的手段是引入ResizeObserver直接观察上方内容元素只要它的尺寸变化无论原因是什么回调都会触发一次计算到位。ResizeObserver在代码里注册的时机是onMounted里观察对象是topContentRef.value。需要注意如果上方内容里还有图片图片在加载前和加载后高度完全不同这种情况下你需要在图片的load事件里再触发一次recomputeTopSize不能只依赖ResizeObserver在元素初始渲染那一刻的通知。第二类异步请求渲染新数据。最常见的是分包加载列表、日志实时追加等场景。以日志为例websocket推送新日志DOM节点可能一次加了几十行甚至上百行容器高度猛增几百像素。ResizeObserver能感知到这个变化但如果DOM更新是异步的、夹杂在浏览器渲染队列里那么你必须在nextTick之后重新读高度。我的recomputeTopSize里面已经处理了这个时序所以数据层更新后直接调用它即可。第三类窗口尺寸变化。很多同学容易漏掉这一步。比如浏览器窗口拉高splitpanes容器总高度从600px变成900px但上面板内容高度依然是300px此时如果不重算上面板的百分比还是旧的50%那300px就变成了450px内容反而撑不满看起来上面板空荡荡。监听window.resize事件在回调里重新计算一次这个坑就自然填平了。3.4 给splitpanes加一层“拖拽锁定”的交互细节splitpanes里的拖拽事件有个特点用户拖拽分隔条后Pane组件内部的size会被更新但这个更新不会同步回你在父组件里定义的响应式topPaneSize。也就是说如果用户拖到上面30%然后你触发一次recomputeTopSize你会发现面板又跳回内容撑开的那个百分比用户会明显感觉到自己拖了跟没拖似的。处理办法是定义一个autoFit变量。初始是true用户拖拽时把它置为false这样后续即便内容高度变化也不会再自动重算。同时在分隔条上加上双击监听双击时恢复autoFit为true并把上方高度重新拉回到内容撑开的位置。代码里已经写了resizedonResized和dbl-click-splitteronDbClick两个事件对应这两个逻辑。这样整个交互就完整了默认状态下上方内容怎么变分隔条怎么跟随用户在特定场景下想手动调整只需要拖一下自动跟随立刻关闭拖出来的结果持久保留想恢复自动跟随双击分隔条就行。这个设计我在多个项目里用下来体验是最好的强烈建议直接抄过去。4. 进阶窗口resize与内容动态变化时的重算策略4.1 ResizeObserver监听容器尺寸变化的两个关键对象实际项目中需要监听的不止是上方内容一个元素。splitpanes外层容器的高度也可能因为页面布局调整而变化比如顶部导航栏塌陷、侧边栏展开收窄都会让splitpanes的高度改变。这种变化发生时内容的像素高度没变但容器总高度变了百分比自然要跟着变。我的建议是同时观察两个对象上方内容容器和splitpanes外层容器。代码示例如下let containerObserver: ResizeObserver | null null function observeContainer() { const containerEl splitpanesRef.value?.$el as HTMLElement | undefined if (!containerEl) return containerObserver new ResizeObserver(() { recomputeTopSize() }) containerObserver.observe(containerEl) }在onMounted里把observeContainer()和原来的resizeObserver.observe(topContentRef.value)一起执行。这样无论哪个方向的高度变化都能触发重算。注意两个observer的回调都要做防抖否则浏览器高频触发resize时会把计算挤爆。4.2 使用nextTick与requestAnimationFrame避免闪烁splitpanes内部是用CSS transform和flex-basis来切换面板尺寸的如果你在DOM更新前后给size赋了新值浏览器会在同一帧内先渲染旧尺寸下一帧再渲染新尺寸肉眼看起来就是分隔条闪了一下。解决这个闪烁问题我习惯在赋值size之前用requestAnimationFrame包一层function updateTopPaneSizeSmooth() { requestAnimationFrame(() { updateTopPaneSize() }) }这样赋值操作会等到下一帧开始再执行与浏览器的渲染节奏对齐基本不会出现闪烁。如果项目里对动画帧率敏感还可以在splitpanes外层加个transition: all 0.1s ease但注意这个transition会拖慢拖拽手感不建议加在Pane上加在splitpanes容器上效果会更好一些。4.3 在页面滚动、头部折叠等场景下的联动处理有一种情况容易被忽略splitpanes容器本身并不占满整个视口它嵌套在一个可滚动的布局里。比如页面结构是顶部导航栏高度50px 主体内容区可滚动。此时splitpanes容器只是页面的一部分滚动条滚动时内容区的顶部会被滚出视口但splitpanes容器的高度和宽度没有变。这种情况其实不需要特殊处理因为splitpanes容器尺寸没有变内容高度也没有变百分比不需要重算。但如果滚动条滚动时顶部导航栏高度发生了变化比如吸顶效果让导航栏从100px变成60pxsplitpanes容器的可用高度就会变此时需要联动重算一次。做法是监听导航栏高度变化的时机调用recomputeTopSize()。另一个场景是头部折叠面板。比如页面上方有一个可以折叠的搜索区域折叠后splitpanes容器的高度增加了几十甚至上百像素。如果不重算上面板的百分比保持旧值折算成像素后会变大内容上方会多出一大块空白。此时最稳妥的方式是在折叠面板完全过渡结束后触发重算同时为了不让计算拿到过渡中间态的尺寸可以用transitionend事件或在setTimeout里延迟计算。我通常延迟300ms等CSS过渡走完再计算实测很准。5. 常见问题与排查技巧5.1 上面板高度不变化始终是50%这多半是因为内容高度在计算时读的还是旧值或者splitpanes容器高度拿的是0。排查时先看两个值容器高度containerEl.clientHeight和内容高度topContentEl.offsetHeight。在浏览器控制台打个断点如果容器高度是0说明外层父容器没设置明确高度splitpanes容器自身撑不起来。如果内容高度是0说明topContentRef拿错了元素或者内容还没来得及渲染。另一种可能性是ResizeObserver没有注册成功。Vue3里ref在script setup中默认是auto-unwrapped但如果你用了v-if或:key动态切换DOM旧的ref可能被替换掉。建议在onMounted里确认topContentRef.value不为空再加个容错处理。5.2 拖拽分隔条后内容一变化又跳回去了这是没有处理“用户拖拽与自动适配冲突”导致的。如果你在代码里没有任何autoFit开关只要内容高度一变新计算出的size就会覆盖用户拖拽的结果。解决方式就是我前面提到的在resized事件里关闭自动适配只有双击分隔条时才恢复。5.3 面板初始计算错误出现明显跳动初次打开页面时如果上方内容里有图片图片加载前高度很小加载后高度很大。此时第一波计算用的是图片加载前的高度百分比偏小图片加载完成后ResizeObserver会重新触发计算面板就会明显跳一下。解决方式有两种第一种给图片容器设置一个固定高度或者min-height避免它在加载过程中撑高内容第二种在图片的load事件里触发重算结构上更兼容动态图片。用完下来第二种更通用。注意处理好加载完成后回调的清理不然每次重算都会增加一次图片加载监听。5.4 展开/折叠子组件时高度跳动明显折叠面板的展开收起通常有过渡动画高度是渐变的如果你在动画过程中触发重算内容高度会变但splitpanes的百分比如果也被同步更新就会出现上面板随着动画一帧一帧往上蹿或往下掉。解决办法是用防抖把重算延迟到过渡动画结束后执行我这里的recomputeTopSize防抖设置成60ms但如果是动画时长明显超过60ms的场景建议把防抖时间加大到300ms左右或者监听动画结束事件。还要注意一个点v-if切换内容时topContentRef指向的DOM节点可能在切换过程中被销毁和重建。销毁时ResizeObserver会自动断开重建后需要重新调用resizeObserver.observe(topContentRef.value)。如果你发现某些情况下重算不触发先查是不是观察对象掉了。5.5 问题速查表现象根本原因解决建议上面板总有50%容器高度/内容高度读取错误断点检查两个height值拖拽后内容变化又跳回autoFit没关掉在resized事件里置false初次加载面板跳动图片异步加载改变内容高度在图片load事件触发重算折叠动画中面板抖动重算时机太早增大防抖或监听transitionend窗口拉高后上方空白百分比未联动容器高度重算监听window.resize或容器ResizeObserver6. 最后再分享两个我踩过的坑第一个坑是关于splitpanes的样式覆盖。千万别用scoped样式直接去改.splitpanes__pane的padding因为splitpanes内部对Pane的布局用了绝对定位于flex混排你加了padding后分隔条的拖拽热区会偏移看起来面板错位拖拽也不跟手。正确做法是在Pane内部包一层内容div所有padding、margin、背景色都放在内容div上Pane本身保持零填充分隔条位置才精准。第二个坑是关于尺寸过度约束。最初我为了追求像素完美给上方内容加了一个精确的固定宽度结果内容一多出现了内部横向滚动条导致内容高度计算总是偏大百分之几。排查了很久才意识到是滚动条占用了高度。解决办法是给上方内容容器设置overflow-x: hidden让纵向高度计算不受横向滚动条干扰。这种细节问题如果不是实际运行遇到过光看文档完全发现不了。从整体来看splitpanes的API设计虽然简洁但要做到“上方面板高度由内容撑开、下方面板占满剩余空间”核心不是依赖组件自身能力而是把内容高度的变化实时换算成size的百分比。一旦这个换算逻辑动起来再加上用户拖拽的交互策略、窗口resize的联动整个分隔面板就达到了生产可用的状态。这套代码我已经在两个中后台项目里跑了大半年没再出过面板跳动、比例错乱的问题如果你也遇到类似需求可以直接拿过去改改用。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价