普通视图

发现新文章,点击刷新页面。
昨天 — 2025年11月4日首页

实现无缝滚动无滚动条的 Element UI 表格(附完整代码)

作者 胖虎265
2025年11月4日 11:04

实现无缝滚动无滚动条的 Element UI 表格(附完整代码)

在后台管理系统或数据监控场景中,经常需要实现表格无缝滚动展示数据,同时希望隐藏滚动条保持界面整洁。本文将基于 Element UI 实现一个 无滚动条、无缝循环、hover 暂停、状态高亮 的高性能滚动表格,全程流畅无卡顿,适配多浏览器。

1.gif

最终效果

  • 🚀 无缝循环滚动,无停顿、无跳跃
  • 🚫 视觉上完全隐藏滚动条,保留滚动功能
  • 🛑 鼠标悬浮自动暂停,离开恢复滚动
  • 🌈 支持状态字段高亮(如不同状态显示不同颜色)
  • 🎨 美观的表格样式,hover 行高亮反馈
  • 🛠 高度可配置(行高、滚动速度、表格高度等)

技术栈

  • Vue 2 + Element UI(适配 Vue 2 项目,Vue 3 可快速迁移)
  • SCSS(样式模块化,便于维护)

实现思路

  1. 无缝滚动核心:通过「数据拼接」(原数据 + 原数据副本)实现视觉上的无限循环,滚动到原数据末尾时瞬间重置滚动位置,无感知切换
  2. 隐藏滚动条:多浏览器兼容 CSS 屏蔽滚动条样式,同时预留滚动条宽度避免内容裁剪
  3. 流畅滚动优化:避免 DOM 频繁重绘,用 scrollTop 控制滚动,关闭平滑滚动避免停顿
  4. 交互增强:hover 暂停滚动、行 hover 高亮、状态字段颜色区分

配置说明

参数名 类型 默认值 说明
tableData Array [] 表格数据源(必传)
columns Array [] 列配置(必传,支持 statusConfig 状态样式)
rowHeight Number 36 行高(单位:px)
scrollSpeed Number 20 滚动速度(毫秒 / 像素),值越小越快
scrollPauseOnHover Boolean true 鼠标悬浮是否暂停滚动
tableHeight Number 300 表格高度(父组件配置)

完整代码实现

1. 滚动表格组件(SeamlessScrollTable.vue)

<template>
  <div class="tableView">
    <el-table
      :data="combinedData"
      ref="scrollTable"
      style="width: 100%"
      height="100%"
      @cell-mouse-enter="handleMouseEnter"
      @cell-mouse-leave="handleMouseLeave"
      :cell-style="handleCellStyle"
      :show-header="true"
    >
      <el-table-column
        v-for="(column, index) in columns"
        v-bind="column"
        :key="index + (column.prop || index)"
        :min-width="column.minWidth || '100px'"
      >
        <template slot-scope="scope">
          <span v-if="column.statusConfig" :class="getColumnStatusClass(column, scope.row)">
            {{ scope.row[column.prop] }}
          </span>
          <span v-else>
            {{ scope.row[column.prop] }}
          </span>
        </template>
      </el-table-column>
    </el-table>
  </div>
</template>

<script>
  export default {
    name: 'SeamlessScrollTable',
    props: {
      tableData: {
        type: Array,
        required: true,
        default: () => [],
      },
      columns: {
        type: Array,
        required: true,
        default: () => [],
      },
      rowHeight: {
        type: Number,
        default: 36,
      },
      scrollSpeed: {
        type: Number,
        default: 20, // 滚动速度(毫秒/像素),20-40ms
      },
      scrollPauseOnHover: {
        type: Boolean,
        default: true,
      },
    },
    data() {
      return {
        autoPlay: true,
        timer: null,
        offset: 0,
        combinedData: [], // 拼接后的数据,用于实现无缝滚动
      }
    },
    computed: {
      // 计算表格可滚动的总高度(仅当数据足够多时才滚动)
      scrollableHeight() {
        return this.tableData.length * this.rowHeight
      },
      // 表格容器可视高度
      viewportHeight() {
        return this.$refs.scrollTable?.$el.clientHeight || 0
      },
    },
    watch: {
      tableData: {
        handler(newVal) {
          // 数据变化时,重新拼接数据
          this.combinedData = [...newVal, ...newVal]
          this.offset = 0
          this.restartScroll()
        },
        immediate: true,
        deep: true,
      },
      autoPlay(newVal) {
        newVal ? this.startScroll() : this.pauseScroll()
      },
    },
    mounted() {
      this.$nextTick(() => {
        // 只有当数据总高度 > 可视高度时,才启动滚动
        if (this.scrollableHeight > this.viewportHeight) {
          this.startScroll()
        }
      })
    },
    beforeDestroy() {
      this.pauseScroll()
    },
    methods: {
      handleMouseEnter() {
        this.scrollPauseOnHover && (this.autoPlay = false)
      },
      handleMouseLeave() {
        this.scrollPauseOnHover && (this.autoPlay = true)
      },
      startScroll() {
        this.pauseScroll()

        const tableBody = this.$refs.scrollTable?.bodyWrapper
        if (!tableBody || this.tableData.length === 0) return

        this.timer = setInterval(() => {
          if (!this.autoPlay) return

          this.offset += 1
          tableBody.scrollTop = this.offset

          // 关键:当滚动到原数据末尾时,瞬间重置滚动位置到开头
          if (this.offset >= this.scrollableHeight) {
            this.offset = 0
            tableBody.scrollTop = 0
          }
        }, this.scrollSpeed)
      },
      pauseScroll() {
        this.timer && clearInterval(this.timer)
        this.timer = null
      },
      restartScroll() {
        this.pauseScroll()
        if (this.scrollableHeight > this.viewportHeight) {
          this.startScroll()
        }
      },
      getColumnStatusClass(column, row) {
        const statusKey = column.statusField || column.prop
        const statusValue = row[statusKey]
        return typeof column.statusConfig === 'function'
          ? column.statusConfig(statusValue, row)
          : column.statusConfig[statusValue] || ''
      },
      handleCellStyle() {
        return {
          padding: '4px 0',
          height: `${this.rowHeight}px`,
          lineHeight: `${this.rowHeight}px`,
        }
      },
    },
  }
</script>

<style scoped lang="scss">
  .tableView {
    width: 100%;
    height: 100%;
    overflow: hidden;

    ::v-deep .el-table {
      background-color: transparent;
      color: #303133;
      border-collapse: separate;
      border-spacing: 0;

      &::before {
        display: none;
      }

      th.el-table__cell.is-leaf {
        border-bottom: 1px solid rgba(0, 0, 0, 0.1);
        background: transparent !important;
        font-weight: 500;
        color: rgba(0, 0, 0, 0.6);
        padding: 8px 0;
      }

      tr.el-table__row {
        background-color: transparent;
        transition: background-color 0.2s ease;

        &:hover td {
          background-color: rgba(0, 0, 0, 0.02) !important;
        }
      }

      .el-table__cell {
        border: none;
        padding: 4px 0;

        .cell {
          white-space: nowrap;
          overflow: hidden;
          text-overflow: ellipsis;
          padding: 0 8px;
        }
      }

      .el-table__body-wrapper {
        height: 100%;
        scroll-behavior: auto;
        &::-webkit-scrollbar {
          display: none !important;
          width: 0 !important;
          height: 0 !important;
        }
        scrollbar-width: none !important;
        -ms-overflow-style: none !important;
      }
    }

    ::v-deep .status-warning {
      color: #e6a23c;
      font-weight: 500;
    }

    ::v-deep .status-danger {
      color: #f56c6c;
      font-weight: 500;
    }

    ::v-deep .status-success {
      color: #67c23a;
      font-weight: 500;
    }

    ::v-deep .status-info {
      color: #409eff;
      font-weight: 500;
    }
  }
</style>

2. 父组件使用示例(TableIndex.vue)

<template>
  <div class="table-container">
    <h2 class="table-title">设备状态监控表格</h2>
    <div class="table-wrapper" :style="{ height: tableHeight + 'px' }">
      <!-- 配置滚动参数 -->
      <seamless-scroll-table
        :table-data="tableData"
        :columns="columns"
        :row-height="36"
        :scroll-speed="30"
      />
    </div>
  </div>
</template>

<script>
  import SeamlessScrollTable from './SeamlessScrollTable.vue'

  export default {
    name: 'DeviceStatusTable',
    components: { SeamlessScrollTable },
    data() {
      return {
        tableHeight: 300, // 表格高度可配置
        // 表格数据
        tableData: [
          { id: '1001', name: '设备A', type: '温度', state: '待检查' },
          { id: '1002', name: '设备B', type: '压力', state: '已超期' },
          { id: '1003', name: '设备C', type: '湿度', state: '已完成' },
          { id: '1004', name: '设备D', type: '电压', state: '超期完成' },
          { id: '1005', name: '设备E', type: '电流', state: '待检查' },
          { id: '1006', name: '设备F', type: '电阻', state: '已超期' },
          { id: '1007', name: '设备G', type: '功率', state: '已完成' },
        ],
        // 列配置
        columns: [
          { prop: 'id', label: '编号', minWidth: '140px' },
          { prop: 'name', label: '名称', width: '100px' },
          { prop: 'type', label: '设备类型', width: '120px' },
          {
            prop: 'state',
            label: '状态',
            width: '100px',
            statusField: 'state',
            // 状态样式配置(支持对象/函数)
            statusConfig: {
              待检查: 'status-warning',
              已超期: 'status-danger',
              已完成: 'status-success',
              超期完成: 'status-info',
            },
          },
        ],
      }
    },
    methods: {
      getStatusClass(state) {
        const statusMap = {
          待检查: 'status-warning',
          已超期: 'status-danger',
          已完成: 'status-success',
          超期完成: 'status-info',
        }
        return statusMap[state] || ''
      },
    },
  }
</script>

<style scoped lang="scss">
  .table-container {
    width: 100%;
    max-width: 500px;
    margin: 0 auto;
    padding: 20px;
    box-sizing: border-box;
  }

  .table-title {
    color: #303133;
    margin-bottom: 16px;
    font-size: 18px;
    font-weight: 500;
    text-align: center;
    position: relative;
  }

  .table-wrapper {
    background-color: #ffffff;
    border-radius: 8px;
    padding: 16px;
    box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.08);
    box-sizing: border-box;
  }
</style>

昨天以前首页

打造梦幻粒子动画效果:基于 Vue 的 Canvas 实现方案

作者 胖虎265
2025年10月28日 15:30

粒子动画效果在现代网页设计中越来越受欢迎,它能为页面增添动态感和视觉吸引力。本文将分享一个基于 Vue 和 Canvas 实现的粒子动画组件,该组件具有高度可定制性,可轻松集成到各种 Web 项目中。

我们实现的粒子动画具有以下特点:

  • 粒子从底部向上飘动,模拟轻盈上升的效果
  • 粒子带有呼吸式发光效果,增强视觉层次感
  • 每个粒子都有随机的大小、速度和颜色
  • 支持响应式布局,自动适应容器大小变化
  • 所有参数均可通过 props 灵活配置

技术选择

为什么选择 Canvas 而非 DOM 元素来实现粒子效果?

  1. 性能优势:Canvas 在处理大量粒子时性能远优于 DOM 操作
  2. 绘制灵活性:Canvas 提供丰富的绘图 API,便于实现复杂的视觉效果
  3. 资源占用低:相比创建大量 DOM 节点,Canvas 渲染更高效

核心实现步骤

  1. 初始化 Canvas 并设置合适的尺寸
  2. 创建粒子类,定义粒子的属性和行为
  3. 实现粒子的绘制逻辑,包括发光效果
  4. 构建动画循环,更新粒子状态
  5. 添加响应式处理和组件生命周期管理

组件结构

<template>
  <div class="particle-container">
    <canvas ref="particleCanvas" class="particle-canvas"></canvas>
  </div>
</template>

模板部分非常简洁,只包含一个容器和 canvas 元素,canvas 将作为我们绘制粒子的画布。

可配置参数

props: {
  // 粒子数量
  particleCount: {
    type: Number,
    default: 50,
    validator: (value) => value >= 0
  },
  // 粒子颜色数组
  particleColors: {
    type: Array,
    default: () => [
      'rgba(255, 255, 255,',    // 白色
      'rgba(153, 204, 255,',   // 淡蓝
      'rgba(255, 204, 255,',   // 淡粉
      'rgba(204, 255, 255,'    // 淡青
    ]
  },
  // 发光强度
  glowIntensity: {
    type: Number,
    default: 1.5
  },
  // 粒子大小控制参数
  minParticleSize: {
    type: Number,
    default: 0.5  // 最小粒子半径
  },
  maxParticleSize: {
    type: Number,
    default: 1.5  // 最大粒子半径
  }
}

这些参数允许开发者根据需求调整粒子效果的密度、颜色、大小和发光强度。

粒子创建与初始化

createParticle() {
  // 根据传入的范围计算粒子半径
  const radius = this.minParticleSize + Math.random() * (this.maxParticleSize - this.minParticleSize)

  return {
    x: Math.random() * this.canvasWidth,
    y: this.canvasHeight + Math.random() * 50,
    radius,  // 使用新的半径范围
    color: this.getRandomColor(),
    speedY: Math.random() * 1.5 + 0.5,  // 垂直速度
    speedX: (Math.random() - 0.5) * 0.3,  // 水平漂移
    alpha: Math.random() * 0.5 + 0.5,
    life: Math.random() * 150 + 150,  // 生命周期
    glow: Math.random() * 0.8 + 0.2,
    glowSpeed: (Math.random() - 0.5) * 0.02,
    shadowBlur: radius * 3 + 1  // 阴影模糊与粒子大小成比例
  }
}

每个粒子都有随机的初始位置(从底部进入)、大小、速度和发光属性,这确保了动画效果的自然和丰富性。

动画循环

动画的核心是animate方法,它使用requestAnimationFrame创建流畅的动画循环:

animate() {
  this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)

  this.particles.forEach((particle, index) => {
    // 更新粒子位置
    particle.y -= particle.speedY
    particle.x += particle.speedX
    particle.life--

    // 处理发光动画
    particle.glow += particle.glowSpeed
    if (particle.glow > 1.2) {
      particle.glow = 1.2
      particle.glowSpeed = -particle.glowSpeed
    } else if (particle.glow < 0.2) {
      particle.glow = 0.2
      particle.glowSpeed = -particle.glowSpeed
    }

    // 粒子生命周期结束,重新创建
    if (particle.y < -particle.radius || particle.life <= 0) {
      this.particles[index] = this.createParticle()
    }

    // 绘制粒子(包括发光效果、核心和高光)
    // ...绘制代码省略
  })

  this.animationId = requestAnimationFrame(this.animate)
}

在每次动画帧中,我们更新所有粒子的位置和状态,当粒子超出画布或生命周期结束时,会创建新的粒子替换它,从而实现循环不断的动画效果。

响应式处理

为了使粒子动画适应不同屏幕尺寸,我们添加了窗口大小变化的监听:

handleResize() {
  this.initCanvas()
  this.particles = this.particles.map(() => this.createParticle())
}

当窗口大小改变时,我们重新初始化 Canvas 尺寸并重新创建所有粒子,确保动画始终充满整个容器

完整代码

<template>
  <div class="particle-container">
    <canvas ref="particleCanvas" class="particle-canvas"></canvas>
  </div>
</template>

<script>
export default {
  name: 'ParticleAnimation',
  props: {
    // 粒子数量
    particleCount: {
      type: Number,
      default: 50,
      validator: (value) => value >= 0
    },
    // 粒子颜色数组
    particleColors: {
      type: Array,
      default: () => [
        'rgba(255, 255, 255,',    // 白色
        'rgba(153, 204, 255,',   // 淡蓝
        'rgba(255, 204, 255,',   // 淡粉
        'rgba(204, 255, 255,'    // 淡青
      ]
    },
    // 发光强度
    glowIntensity: {
      type: Number,
      default: 1.5
    },
    // 粒子大小控制参数
    minParticleSize: {
      type: Number,
      default: 0.5  // 最小粒子半径
    },
    maxParticleSize: {
      type: Number,
      default: 1.5  // 最大粒子半径
    }
  },
  data() {
    return {
      canvas: null,
      ctx: null,
      particles: [],
      animationId: null,
      canvasWidth: 0,
      canvasHeight: 0
    }
  },
  watch: {
    particleCount(newVal) {
      this.particles = []
      this.initParticles(newVal)
    },
    particleColors: {
      deep: true,
      handler() {
        this.particles.forEach((particle, index) => {
          this.particles[index].color = this.getRandomColor()
        })
      }
    },
    // 监听粒子大小变化
    minParticleSize() {
      this.resetParticles()
    },
    maxParticleSize() {
      this.resetParticles()
    }
  },
  methods: {
    initCanvas() {
      this.canvas = this.$refs.particleCanvas
      this.ctx = this.canvas.getContext('2d')

      const container = this.canvas.parentElement
      this.canvasWidth = container.clientWidth
      this.canvasHeight = container.clientHeight
      this.canvas.width = this.canvasWidth
      this.canvas.height = this.canvasHeight
    },

    initParticles(count) {
      for (let i = 0; i < count; i++) {
        this.particles.push(this.createParticle())
      }
    },

    createParticle() {
      // 根据传入的范围计算粒子半径
      const radius = this.minParticleSize + Math.random() * (this.maxParticleSize - this.minParticleSize)

      return {
        x: Math.random() * this.canvasWidth,
        y: this.canvasHeight + Math.random() * 50,
        radius,  // 使用新的半径范围
        color: this.getRandomColor(),
        speedY: Math.random() * 1.5 + 0.5,  // 降低速度,配合小粒子
        speedX: (Math.random() - 0.5) * 0.3,  // 减少漂移
        alpha: Math.random() * 0.5 + 0.5,
        life: Math.random() * 150 + 150,  // 延长生命周期,让小粒子存在更久
        glow: Math.random() * 0.8 + 0.2,
        glowSpeed: (Math.random() - 0.5) * 0.02,
        shadowBlur: radius * 3 + 1  // 阴影模糊与粒子大小成比例
      }
    },

    getRandomColor() {
      if (this.particleColors.length === 0) {
        return 'rgba(255, 255, 255,'
      }
      return this.particleColors[Math.floor(Math.random() * this.particleColors.length)]
    },

    animate() {
      this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight)

      this.particles.forEach((particle, index) => {
        particle.y -= particle.speedY
        particle.x += particle.speedX
        particle.life--

        // 闪亮动画
        particle.glow += particle.glowSpeed
        if (particle.glow > 1.2) {
          particle.glow = 1.2
          particle.glowSpeed = -particle.glowSpeed
        } else if (particle.glow < 0.2) {
          particle.glow = 0.2
          particle.glowSpeed = -particle.glowSpeed
        }

        if (particle.y < -particle.radius || particle.life <= 0) {
          this.particles[index] = this.createParticle()
        }

        // 绘制粒子(适配小粒子的比例)
        this.ctx.save()

        // 阴影效果
        this.ctx.shadowColor = `${particle.color}${particle.glow * this.glowIntensity})`
        this.ctx.shadowBlur = particle.shadowBlur * particle.glow
        this.ctx.shadowOffsetX = 0
        this.ctx.shadowOffsetY = 0

        // 外发光圈(按粒子大小比例缩放)
        this.ctx.beginPath()
        this.ctx.arc(particle.x, particle.y, particle.radius * (1 + particle.glow * 0.8), 0, Math.PI * 2)
        this.ctx.fillStyle = `${particle.color}${0.2 * particle.glow})`
        this.ctx.fill()

        // 粒子核心
        this.ctx.beginPath()
        this.ctx.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2)
        this.ctx.fillStyle = `${particle.color}${particle.alpha + (particle.glow * 0.3)})`
        this.ctx.fill()

        // 高光点(适配小粒子)
        if (particle.glow > 0.8) {
          this.ctx.beginPath()
          const highlightSize = particle.radius * 0.3 * particle.glow
          this.ctx.arc(
            particle.x - particle.radius * 0.2,
            particle.y - particle.radius * 0.2,
            highlightSize,
            0,
            Math.PI * 2
          )
          this.ctx.fillStyle = `rgba(255, 255, 255, ${0.6 * particle.glow})`
          this.ctx.fill()
        }

        this.ctx.restore()
      })

      this.animationId = requestAnimationFrame(this.animate)
    },

    handleResize() {
      this.initCanvas()
      this.particles = this.particles.map(() => this.createParticle())
    },

    // 重置粒子大小
    resetParticles() {
      this.particles = this.particles.map(() => this.createParticle())
    }
  },
  mounted() {
    this.initCanvas()
    this.initParticles(this.particleCount)
    this.animate()
    window.addEventListener('resize', this.handleResize)
  },
  beforeDestroy() {
    cancelAnimationFrame(this.animationId)
    window.removeEventListener('resize', this.handleResize)
  }
}
</script>

<style scoped>
.particle-container {
  position: relative;
  width: 100%;
  height: 100%;
  overflow: hidden;
}

.particle-canvas {
  display: block;
  width: 100%;
  height: 100%;
}
</style>

使用方法

<template>
  <div class="page-container">
    <ParticleAnimation 
      :particle-count="80"
      :glow-intensity="2"
      :min-particle-size="0.8"
      :max-particle-size="2"
    />
    <!-- 其他内容 -->
  </div>
</template>

<script>
import ParticleAnimation from '@/components/ParticleAnimation.vue'

export default {
  components: {
    ParticleAnimation
  }
}
</script>

<style>
.page-container {
  width: 100vw;
  height: 100vh;
}
</style>
❌
❌