大屏开发实战:封装支持排名高亮 + 无缝滚动的横向柱状图组件,告别数据展示痛点

122 阅读5分钟

在大屏可视化项目里,横向柱状图是展示「排名类数据」的 “刚需组件”—— 比如 “各地区业务量 TOP10”“部门业绩排行榜”,但普通柱状图组件在大屏场景下总显得 “水土不服”:数据多了超出容器滚不动、前 3 名和其他数据长得一样没重点、进度条样式和大屏科技风不搭、大数值看久了眼睛花……

今天就基于实战代码,手把手教你封装一套「大屏专属横向柱状图组件」,集成排名高亮、无缝滚动、动态进度条、数值格式化四大核心能力,代码可直接复用,看完就能解决 80% 的大屏排名数据展示问题。

一、先吐槽:普通柱状图在大屏的 3 大痛点

在写代码前,先聊聊那些让人大屏开发时 “抓头发” 的问题,看看你是不是也遇到过:

  1. 数据多了 “装不下”:当排名数据超过 10 条,组件高度不够,要么截断显示,要么出现丑陋的原生滚动条,破坏大屏整体美感;
  2. 排名重点 “看不见”:所有数据长得一模一样,领导想快速找到第 1、2、3 名,还得逐行看数字,效率极低;
  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;
}

六、总结与扩展方向

这套横向柱状图组件已经能满足大部分大屏排名数据的展示需求,但实际项目中还可以根据业务扩展:

  1. 添加点击事件:点击排名项弹出详情弹窗,展示该地区的具体数据;
  2. 支持百分比显示:在数值后加百分比(如 12,345 (25%)),需要在computePercent基础上扩展;
  3. 筛选功能:添加下拉框,支持按 “季度”“月份” 筛选排名数据;
  4. 动画效果:进度条加载时添加渐入动画,让数据展示更生动。

大屏开发的核心是 “细节”—— 一个好的组件不仅要能实现功能,还要贴合大屏的视觉风格和交互习惯。希望这篇文章能帮你解决大屏排名数据展示的痛点,如果你有其他需求,欢迎在评论区交流~