在大屏可视化项目里,横向柱状图是展示「排名类数据」的 “刚需组件”—— 比如 “各地区业务量 TOP10”“部门业绩排行榜”,但普通柱状图组件在大屏场景下总显得 “水土不服”:数据多了超出容器滚不动、前 3 名和其他数据长得一样没重点、进度条样式和大屏科技风不搭、大数值看久了眼睛花……
今天就基于实战代码,手把手教你封装一套「大屏专属横向柱状图组件」,集成排名高亮、无缝滚动、动态进度条、数值格式化四大核心能力,代码可直接复用,看完就能解决 80% 的大屏排名数据展示问题。
一、先吐槽:普通柱状图在大屏的 3 大痛点
在写代码前,先聊聊那些让人大屏开发时 “抓头发” 的问题,看看你是不是也遇到过:
- 数据多了 “装不下”:当排名数据超过 10 条,组件高度不够,要么截断显示,要么出现丑陋的原生滚动条,破坏大屏整体美感;
- 排名重点 “看不见”:所有数据长得一模一样,领导想快速找到第 1、2、3 名,还得逐行看数字,效率极低;
- 样式 “融不进” 大屏:默认的灰色进度条 + 黑色文字,在深色科技风大屏上显得格格不入,像是 “外来插件”。
而我们今天封装的组件,就是针对性解决这 3 个问题,让排名数据在大屏上既 “好看” 又 “好用”。
二、组件核心能力拆解:能解决什么问题?
先明确组件的核心功能,避免无意义的代码堆砌,每一个功能都对应大屏的实际需求:
三、完整实现:从 0 到 1 封装组件
组件分为两部分:横向柱状图核心(数据渲染 + 排名样式) 和 无缝滚动组件(解决数据溢出),我们一步步来写。
1. 第一步:封装柱状图核心组件(LineJtNumDark.vue)
先实现 “排名展示 + 进度条 + 数值格式化” 的基础功能,这是组件的核心。
模板结构(template)
重点关注 4 个模块:排名框、名称标签、进度条、数值单位,每个模块都做了大屏适配:
<template>
<!-- 外层容器:支持自定义宽高和类名,适配不同大屏模块 -->
<ul class="line-data n-f f-a-c f-wrap" :class="className" :style="{ width: width }">
<!-- 无缝滚动组件:后面会讲,先集成进来 -->
<n-scroll
class="scroll"
:className="className"
:delay="1" <!-- 滚动间隔1秒 -->
:duration="1" <!-- 滚动时长1秒 -->
:style="{ height: height }" <!-- 组件高度,控制滚动区域 -->
>
<template #list>
<!-- 单个排名项:循环渲染,key用index(实际项目建议用唯一ID) -->
<li class="line n-f f-a-c gap-6" v-for="(item, index) in data" :key="index">
<!-- 1. 排名框:前3名有特殊图标 -->
<div class="index-box n-f f-a-c f-j-c">{{ index + 1 }}</div>
<!-- 2. 名称标签:超出省略,hover显示完整名称(避免长名称换行) -->
<div class="label n-f f-a-c f-j-b" :title="item.name">
<span>{{ item.name }}</span>
</div>
<!-- 3. 进度条:按数据比例动态计算宽度,前3名颜色不同 -->
<div class="num n-f f-a-c gap-8">
<div class="num-con n-f f-a-c">
<span
class="num-line"
:class="index < 3 ? `line_${index}` : 'line_def'" <!-- 前3名特殊类名 -->
:style="{ width: compute(item.value) }" <!-- 动态宽度 -->
></span>
</div>
</div>
<!-- 4. 数值+单位:千分位格式化,支持开关显示单位 -->
<div class="num-right n-f f-a-c gap-10">
<span class="value">{{ addCommas(item.value) }}</span>
<span class="unit" v-if="showUnit">{{ unit }}</span>
</div>
</li>
</template>
</n-scroll>
</ul>
</template>
逻辑部分(script)
处理数据排序、进度条宽度计算、数值格式化,核心是init(初始化数据)和compute(计算进度条宽度)方法:
<script>
// 引入千分位格式化工具函数(后面会给代码)
import { addCommas } from '@/utils';
// 引入无缝滚动组件(后面会实现)
import NScroll from "@/components/List/Scroll.vue";
export default {
name: "LineDark",
components: { NScroll },
// 对外暴露的配置项,支持父组件自定义
props: {
height: { type: String, default: "200px" }, // 组件高度(控制滚动区域)
showUnit: { type: Boolean, default: false }, // 是否显示单位
width: { type: String, default: "100%" }, // 组件宽度
unit: { type: String, default: "家" }, // 单位文本(如“家”“万元”)
className: { type: String, default: "gap-6" }, // 自定义类名(扩展样式)
},
data() {
return {
data: [], // 存储排序后的排名数据
total: 0, // 数据总和(用于计算“总占比”)
};
},
methods: {
// 千分位格式化:123456 → 123,456
addCommas,
// 初始化数据:接收父组件传入的原始数据,排序+计算总和
init(rawData) {
// 1. 数据降序排序(确保排名正确:第1名在最上面)
const sortedData = rawData.sort((a, b) => b.value - a.value);
this.data = sortedData;
// 2. 计算数据总和(用于“总占比”模式)
this.total = sortedData.reduce((acc, cur) => acc + parseFloat(cur.value), 0);
},
// 计算进度条宽度(核心方法):按“总占比”计算
compute(num) {
if (this.total === 0) return "0%"; // 避免除以0报错
return `${(num / this.total) * 100}%`; // 转百分比
},
// 可选:按“最大值占比”计算(适合强调相对排名)
computePercent(num) {
if (this.data.length === 0) return "0%";
const maxNum = Math.max(...this.data.map(item => item.value)); // 找最大值
if (maxNum === 0) return "0%";
return `${(num / maxNum).toFixed(2) * 100}%`; // 保留2位小数
}
}
};
</script>
样式部分(style)
1、组件内定义的 class {.gap-数字} 可以看我的这篇文章:通过less混合自动生成间距类
2、组件样式:这是大屏组件的 “灵魂”,重点实现排名高亮和科技风样式,用 Less 写更灵活:
<style lang="less" scoped>
// 外层容器:基础样式,处理溢出
.line-data {
box-sizing: border-box;
overflow-y: auto;
}
// 单个排名项:核心样式,前3名差异化
.line {
width: 100%;
// 默认背景渐变(科技风蓝色,贴合深色大屏)
background-image: linear-gradient(270deg, rgba(8, 132, 255, 0.00) 0%, rgba(8, 132, 255, 0.20) 100%);
margin-bottom: 8px; // 项之间的间距
// 1. 排名框样式
.index-box {
font-family: SourceHanSansCN-Regular;
font-size: 16px;
color: #FFFFFF;
width: 30px;
height: 24px;
// 默认排名图标(普通名次)
background: no-repeat url("@/assets/p/element/lab.svg")/100%;
}
// 第1名特殊样式:红色渐变+红色图标
&:nth-child(1) {
background-image: linear-gradient(270deg, rgba(178, 48, 90, 0.00) 0%, rgba(178, 48, 90, 0.50) 100%);
.index-box {
background: no-repeat url("@/assets/p/element/lab_red.svg") left/100% !important;
}
}
// 第2名特殊样式:橙色渐变+橙色图标
&:nth-child(2) {
background-image: linear-gradient(270deg, rgba(166, 105, 32, 0.00) 0%, rgba(166, 105, 32, 0.50) 100%);
.index-box {
background: no-repeat url("@/assets/p/element/lab_orange.svg") left/100% !important;
}
}
// 第3名特殊样式:黄色渐变+黄色图标
&:nth-child(3) {
background-image: linear-gradient(270deg, rgba(167, 159, 42, 0.00) 0%, rgba(167, 159, 42, 0.50) 99%);
.index-box {
background: no-repeat url("@/assets/p/element/lab_yellow.svg") left/100% !important;
}
}
// 2. 名称标签:超出省略,避免换行
.label {
width: 70px;
height: 24px;
line-height: 24px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis; // 超出显示“...”
font-family: PingFangSC-Regular;
font-size: 16px;
color: rgba(255, 255, 255, 0.85); // 浅色文本,贴合深色背景
}
// 3. 进度条容器:基础样式
.num {
width: 310px; // 进度条宽度(可根据大屏模块调整)
height: 8px;
background: rgba(1, 201, 237, 0.30); // 浅色背景,突出进度条
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 2px;
padding: 2px;
box-sizing: border-box;
.num-con { width: 100%;
height: 8px;
// 进度条核心:带倾斜末端,增强科技感
.num-line { border-radius: 4px;
position: relative;
height: 4px;
display: inline-block;
background: rgba(1, 201, 237, 1); // 默认蓝色
// 倾斜末端:用伪元素实现,比普通进度条更有设计感
&::after {
content: "";
width: 3px;
height: 8px;
position: absolute;
right: -1px;
top: 50%;
transform: translateY(-50%) rotate(15deg); // 倾斜15度
}
// 第1名进度条:黄色→橙色渐变
&.line_0 {
background-image: linear-gradient(270deg, #FFF18A 0%, #FFBF04 39%, rgba(255, 60, 0, 0.00) 100%);
}
// 第2名进度条:橙色
&.line_1 { background: rgba(255, 159, 0, 1);
}
// 第3名进度条:黄色
&.line_2 { background: rgba(22, 184, 0, 1);
}
// 普通名次进度条:默认蓝色
&.line_def { background: rgba(0, 103, 255, 1);
}
}
}
}
// 4. 数值与单位:视觉区分,突出数值
.num-right {
margin-left: 13px;
// 数值:高亮青色,在深色大屏上很醒目
.value {
font-family: SourceHanSansCN-Medium;
font-size: 16px;
color: #10FDFC;
}
// 单位:浅色文本,避免抢数值风头
.unit {
font-family: PingFangSC-Medium;
font-size: 14px;
color: rgba(255, 255, 255, 0.65);
}
}
}
// 扩展样式:适配数据项较多的场景(可选)
.long {
.line {
height: 32px;
.num { width: 100%; } // 进度条占满宽度
.label { min-width: 120px; } // 加宽名称标签
}
}
</style>
3、千分位工具函数(utils.js)
单独抽离addCommas函数,方便其他组件复用:
export function addCommas(nStr) {
// 处理非数字情况
if (nStr === null || nStr === undefined || isNaN(Number(nStr))) return '0';
nStr += '';
const x = nStr.split('.'); // 拆分整数和小数部分
let x1 = x[0]; // 整数部分
const x2 = x.length > 1 ? '.' + x[1] : ''; // 小数部分(可选)
// 正则:每3位加一个逗号(从右往左)
const rgx = /(\d+)(\d{3})/;
while (rgx.test(x1)) {
x1 = x1.replace(rgx, '$1' + ',' + '$2');
}
return x1 + x2;
}
2. 第二步:封装无缝滚动组件(NScroll.vue)
解决 “数据多了超出容器” 的问题,核心是 “两个相同内容区无缝衔接”,配合 GSAP 实现平滑滚动。
<template>
<div class="seamless-scroll">
<!-- 滚动容器:溢出隐藏,鼠标交互 -->
<div
ref="wrapperRef"
class="seamless-scroll__wrapper"
@mouseover="onMouseOver" <!-- 鼠标悬停暂停 -->
@mouseout="onMouseOut" <!-- 鼠标离开继续 -->
>
<!-- 滚动内容:上下两个相同区域,实现“无缝” -->
<div ref="boxRef" class="seamless-scroll__box">
<!-- 上半区:实际渲染的内容 -->
<div class="seamless-scroll__box-top n-f f-c" ref="topRef" :class="className">
<slot name="list"></slot> <!-- 插槽:接收柱状图的排名项 -->
</div>
<!-- 下半区:复制上半区内容,滚动到末尾时衔接 -->
<div v-if="showShadowDiv" class="seamless-scroll__box-bottom n-f f-c" :class="className">
<slot name="list"></slot>
</div>
</div>
</div>
</div>
</template>
<script>
// 引入GSAP:用于平滑滚动动画(比原生scrollTo更流畅)
import gsap from 'gsap';
export default {
name: 'NScroll',
props: {
duration: { type: Number, default: 2 }, // 滚动时长(秒)
delay: { type: Number, default: 1 }, // 滚动间隔(秒)
className: { type: String, default: 'gap-16' } // 自定义类名
},
data() {
return {
wrapperHeight: 0, // 滚动容器高度
topBoxHeight: 0, // 上半区内容高度
timeLine: gsap.timeline(), // GSAP时间线,控制动画
scrollingElIndex: 0 // 当前滚动的项索引
};
},
computed: {
// 是否显示下半区:内容高度超过容器高度才显示
showShadowDiv() {
return this.topBoxHeight > this.wrapperHeight;
}
},
methods: {
// 生成滚动动画:逐行滚动
genAnimates() {
// 过滤非元素节点(如文本节点)
const nodeArr = Array.from(this.$refs.topRef.childNodes)
.filter(t => t.nodeType === Node.ELEMENT_NODE);
if (nodeArr.length === 0) return;
// 当前要滚动的项
const currentNode = nodeArr[this.scrollingElIndex];
// 索引循环:滚到最后一项后回到第0项
this.scrollingElIndex = (this.scrollingElIndex + 1) % nodeArr.length;
// 计算滚动目标位置:当前项的底部
const elHeight = currentNode.getBoundingClientRect().height;
const scrollTarget = currentNode.offsetTop + elHeight;
// 滚动逻辑:没到末尾就继续滚,到末尾就重置到顶部
if (scrollTarget < this.topBoxHeight) {
this.timeLine.to(
this.$refs.wrapperRef,
{ scrollTop: scrollTarget, duration: this.duration },
`+=${this.delay}` // 间隔delay秒后执行
);
} else {
// 滚到末尾,重置到顶部
this.timeLine.to(
this.$refs.wrapperRef,
{ scrollTop: 0, duration: 0 }, // 瞬间重置
`+=${this.delay}`
);
this.scrollingElIndex = 0; // 重置索引
}
},
// 鼠标悬停:暂停动画
onMouseOver() {
this.timeLine.pause();
},
// 鼠标离开:恢复动画
onMouseOut() {
this.timeLine.resume();
},
// 计算容器和内容高度(初始化+尺寸变化时调用)
handleResize() {
this.wrapperHeight = this.$refs.wrapperRef.clientHeight;
this.topBoxHeight = this.$refs.topRef.clientHeight;
}
},
mounted() {
// 初始化高度
this.handleResize();
// 监听容器尺寸变化(比如大屏缩放时)
const resizeObserver = new ResizeObserver(() => this.handleResize());
resizeObserver.observe(this.$refs.wrapperRef);
resizeObserver.observe(this.$refs.topRef);
// 初始化动画:完成后循环调用
this.timeLine.eventCallback('onComplete', () => this.genAnimates());
this.genAnimates();
// 组件销毁时清理监听
this.$once('hook:beforeDestroy', () => {
resizeObserver.disconnect();
this.timeLine.kill(); // 销毁动画
});
}
};
</script>
<style lang="less">
.seamless-scroll {
width: 100%;
.seamless-scroll__wrapper {
width: 100%;
height: 100%;
position: relative;
overflow: hidden; // 关键:隐藏溢出内容
}
.seamless-scroll__box {
.seamless-scroll__box-bottom {
margin-top: 8px; // 上下区间距,避免衔接生硬
}
}
}
</style>
四、实战使用:3 步接入大屏页面
组件封装好了,在大屏页面中使用只需 3 步,非常简单:
1. 引入组件
<template>
<!-- 大屏模块容器:深色背景,与组件风格统一 -->
<div class="rank-module">
<h3 class="module-title">2024年各地区业务量排名</h3>
<!-- 横向柱状图组件:配置高度、单位等 -->
<line-dark
ref="RankChart" height="300px" <!-- 组件高度,控制滚动区域 -->
:show-unit="true" <!-- 显示单位 -->
unit="家" <!-- 单位文本 -->
className="custom-list" <!-- 自定义类名 -->
/>
</div>
</template>
<script>
// 引入组件
import LineDark from '@/component/LineDark.vue';
export default {
components: { LineJtNumDark },
mounted() {
// 2. 模拟排名数据(实际项目中从接口获取)
const rankData = [
{ name: "北京", value: 12345 },
{ name: "上海", value: 9876 },
{ name: "广州", value: 8765 },
{ name: "深圳", value: 7654 },
{ name: "杭州", value: 6543 },
{ name: "成都", value: 5432 },
{ name: "武汉", value: 4321 },
{ name: "西安", value: 3210 },
{ name: "南京", value: 2109 },
{ name: "重庆", value: 1098 }
];
// 3. 初始化组件:传入数据
this.$refs.RankChart.init(rankData);
}
};
</script>
<style scoped>
.rank-module {
padding: 20px;
background: rgba(0, 30, 60, 0.5); // 深色背景,贴合大屏
border-radius: 8px;
margin: 0 20px 20px 0;
width: 600px; // 模块宽度,适配大屏网格布局
}
.module-title {
font-size: 18px;
color: #fff;
margin-bottom: 16px;
font-family: SourceHanSansCN-Medium;
}
</style>
效果描述
- 页面加载后,数据按降序排列,前 3 名分别用红、橙、黄三色区分;
- 数据超过组件高度(300px),自动逐行滚动,鼠标悬停时暂停;
- 数值显示为千分位格式(如 12,345),末尾带 “家” 字单位;
- 进度条按 “总占比” 计算宽度,北京的进度条最长,重庆最短。
五、大屏适配与性能优化技巧
组件在大屏上使用时,还有几个细节需要注意,避免出现 “适配问题” 或 “性能瓶颈”:
1. 适配 autofit 大屏缩放
如果你的大屏用autofit.js做整体缩放(比如 1920*1080 适配 4K 屏),需要将进度条宽度改为 “百分比” 或 “vw” 单位,避免固定像素失真:
// 修改进度条容器宽度
.num {
// width: 310px; // 注释掉固定像素
width: 60%; // 改为百分比,随父容器缩放
// 或 width: 30vw; // 按视口宽度计算
}
2. 性能优化:避免频繁重绘
如果数据是实时更新的(比如每秒刷新),给init方法加防抖,避免频繁重绘:
// 在methods中添加防抖方法
methods: {
// 防抖:500ms内只执行一次
debouncedInit: _.debounce(function(rawData) {
this.init(rawData);
}, 500),
// 调用时用防抖方法
updateRankData(newData) {
this.debouncedInit(newData);
}
}
3. 主题切换支持
如果大屏需要 “深色 / 亮色” 切换,用 CSS 变量动态修改颜色:
// 在组件样式中用CSS变量
@color-primary: var(--color-primary, #008cff);
@color-value: var(--color-value, #10FDFC);
// 在大屏根组件中切换主题
.dark-mode {
--color-primary: #008cff;
--color-value: #10FDFC;
}
.light-mode {
--color-primary: #0066cc;
--color-value: #0099ff;
}
六、总结与扩展方向
这套横向柱状图组件已经能满足大部分大屏排名数据的展示需求,但实际项目中还可以根据业务扩展:
- 添加点击事件:点击排名项弹出详情弹窗,展示该地区的具体数据;
- 支持百分比显示:在数值后加百分比(如 12,345 (25%)),需要在
computePercent基础上扩展; - 筛选功能:添加下拉框,支持按 “季度”“月份” 筛选排名数据;
- 动画效果:进度条加载时添加渐入动画,让数据展示更生动。
大屏开发的核心是 “细节”—— 一个好的组件不仅要能实现功能,还要贴合大屏的视觉风格和交互习惯。希望这篇文章能帮你解决大屏排名数据展示的痛点,如果你有其他需求,欢迎在评论区交流~