用 Three.js 打造炫酷波浪粒子背景动画:从原理到实现
2025年11月4日 16:46
用 Three.js 打造炫酷波浪粒子背景动画:从原理到实现 在现代 Web 开发中,动态背景是提升页面视觉冲击力的关键元素。波浪粒子动画以其流畅的运动轨迹、立体的空间感和可定制性,成为很多高端网站的
在后台管理系统或数据监控场景中,经常需要实现表格无缝滚动展示数据,同时希望隐藏滚动条保持界面整洁。本文将基于 Element UI 实现一个 无滚动条、无缝循环、hover 暂停、状态高亮 的高性能滚动表格,全程流畅无卡顿,适配多浏览器。
![]()
scrollTop 控制滚动,关闭平滑滚动避免停顿| 参数名 | 类型 | 默认值 | 说明 |
|---|---|---|---|
tableData |
Array | [] | 表格数据源(必传) |
columns |
Array | [] | 列配置(必传,支持 statusConfig 状态样式) |
rowHeight |
Number | 36 | 行高(单位:px) |
scrollSpeed |
Number | 20 | 滚动速度(毫秒 / 像素),值越小越快 |
scrollPauseOnHover |
Boolean | true | 鼠标悬浮是否暂停滚动 |
tableHeight |
Number | 300 | 表格高度(父组件配置) |
<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>
<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 实现的粒子动画组件,该组件具有高度可定制性,可轻松集成到各种 Web 项目中。
我们实现的粒子动画具有以下特点:
为什么选择 Canvas 而非 DOM 元素来实现粒子效果?
<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>