在 Vue 中实现后端传输逐帧图像数据,通常涉及 WebSocket 或 Server-Sent Events (SSE) 技术。这里我将提供一个完整的解决方案,包括前后端实现。
方案一:使用 WebSocket (推荐)
前端实现 (Vue 3)
<template>
<div>
<div>
<button @click="startStream">开始接收视频流</button>
<button @click="stopStream">停止接收</button>
</div>
<img :src="currentFrame" alt="实时视频帧" ref="videoFrame" />
</div>
</template>
<script setup>
import { ref, onUnmounted } from 'vue'
const currentFrame = ref('')
const ws = ref(null)
const videoFrame = ref(null)
const isStreaming = ref(false)
// 开始接收视频流
const startStream = () => {
if (isStreaming.value) return
// 连接到 WebSocket 服务器
ws.value = new WebSocket('ws://localhost:8080/ws/video')
ws.value.onopen = () => {
console.log('WebSocket 连接已建立')
isStreaming.value = true
}
ws.value.onmessage = (event) => {
if (typeof event.data === 'string') {
const data = JSON.parse(event.data)
if (data.type === 'frame') {
// 将 base64 数据转换为图片
currentFrame.value = `data:image/jpeg;base64,${data.frame}`
}
} else if (event.data instanceof Blob) {
// 如果传输的是二进制数据
const reader = new FileReader()
reader.onload = () => {
currentFrame.value = reader.result
}
reader.readAsDataURL(event.data)
}
}
ws.value.onerror = (error) => {
console.error('WebSocket 错误:', error)
isStreaming.value = false
}
ws.value.onclose = () => {
console.log('WebSocket 连接已关闭')
isStreaming.value = false
}
}
// 停止接收
const stopStream = () => {
if (ws.value) {
ws.value.close()
}
isStreaming.value = false
}
// 组件卸载时关闭连接
onUnmounted(() => {
stopStream()
})
</script>
<style scoped>
img {
max-width: 100%;
border: 1px solid #ddd;
margin-top: 20px;
}
</style>
方案二:使用 Server-Sent Events (SSE)
<template>
<div>
<div>
<button @click="startSSEStream">开始SSE流</button>
<button @click="stopSSEStream">停止SSE流</button>
</div>
<img :src="currentFrame" alt="实时视频帧" />
</div>
</template>
<script setup>
import { ref, onUnmounted } from 'vue'
const currentFrame = ref('')
const eventSource = ref(null)
const isSSEConnected = ref(false)
// 开始 SSE 连接
const startSSEStream = async () => {
if (isSSEConnected.value) return
try {
// 首先请求流式连接
const response = await fetch('http://localhost:3000/api/start-stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ clientId: 'vue-client' })
})
const data = await response.json()
// 建立 SSE 连接
eventSource.value = new EventSource(`http://localhost:3000/api/video-stream/${data.streamId}`)
eventSource.value.onmessage = (event) => {
const data = JSON.parse(event.data)
if (data.type === 'frame') {
currentFrame.value = `data:image/${data.format || 'jpeg'};base64,${data.frame}`
}
}
eventSource.value.onerror = (error) => {
console.error('SSE 连接错误:', error)
stopSSEStream()
}
isSSEConnected.value = true
} catch (error) {
console.error('启动流失败:', error)
}
}
// 停止 SSE 连接
const stopSSEStream = async () => {
if (eventSource.value) {
eventSource.value.close()
eventSource.value = null
}
// 通知服务器停止发送
await fetch('http://localhost:3000/api/stop-stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ clientId: 'vue-client' })
})
isSSEConnected.value = false
}
onUnmounted(() => {
stopSSEStream()
})
</script>
后端实现示例 (Node.js + Express)
WebSocket 服务器
// server/websocket-server.js
const WebSocket = require('ws')
const { createCanvas } = require('canvas')
const wss = new WebSocket.Server({ port: 8080 })
wss.on('connection', (ws) => {
console.log('新的 WebSocket 连接')
// 模拟生成视频帧
let frameInterval = null
const sendFrame = () => {
// 创建测试帧
const canvas = createCanvas(640, 480)
const ctx = canvas.getContext('2d')
// 绘制动态内容
ctx.fillStyle = `rgb(${Math.random()*255}, ${Math.random()*255}, ${Math.random()*255})`
ctx.fillRect(0, 0, 640, 480)
// 绘制文本
ctx.fillStyle = 'white'
ctx.font = '30px Arial'
ctx.fillText(`时间: ${new Date().toLocaleTimeString()}`, 50, 100)
// 转换为 base64
const base64Image = canvas.toDataURL('image/jpeg').split(',')[1]
// 发送帧数据
const frameData = {
type: 'frame',
frame: base64Image,
timestamp: Date.now(),
width: 640,
height: 480
}
ws.send(JSON.stringify(frameData))
}
// 每秒发送 30 帧
frameInterval = setInterval(sendFrame, 1000 / 30)
ws.on('message', (message) => {
console.log('收到消息:', message.toString())
})
ws.on('close', () => {
console.log('连接关闭')
if (frameInterval) {
clearInterval(frameInterval)
}
})
ws.on('error', (error) => {
console.error('WebSocket 错误:', error)
})
})
console.log('WebSocket 服务器运行在 ws://localhost:8080')
使用 SSE 的后端
// server/sse-server.js
const express = require('express')
const { createCanvas } = require('canvas')
const cors = require('cors')
const app = express()
app.use(cors())
app.use(express.json())
const clients = new Map()
// 启动视频流
app.post('/api/start-stream', (req, res) => {
const { clientId } = req.body
const streamId = `stream_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
clients.set(streamId, {
clientId,
active: true
})
res.json({ streamId, message: 'Stream started' })
})
// SSE 视频流端点
app.get('/api/video-stream/:streamId', (req, res) => {
const { streamId } = req.params
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*'
})
const clientInfo = clients.get(streamId)
if (!clientInfo) {
res.write('data: {"error": "Invalid stream ID"}\n\n')
res.end()
return
}
let frameCount = 0
const sendInterval = setInterval(() => {
if (!clientInfo.active) {
clearInterval(sendInterval)
res.end()
return
}
// 创建测试帧
const canvas = createCanvas(640, 480)
const ctx = canvas.getContext('2d')
ctx.fillStyle = `rgb(${Math.random()*255}, ${Math.random()*255}, ${Math.random()*255})`
ctx.fillRect(0, 0, 640, 480)
ctx.fillStyle = 'white'
ctx.font = '30px Arial'
ctx.fillText(`帧: ${++frameCount}`, 50, 100)
ctx.fillText(`流ID: ${streamId}`, 50, 150)
const base64Image = canvas.toDataURL('image/jpeg').split(',')[1]
const frameData = {
type: 'frame',
frame: base64Image,
frameNumber: frameCount,
timestamp: Date.now()
}
res.write(`data: ${JSON.stringify(frameData)}\n\n`)
}, 1000 / 30) // 30 FPS
req.on('close', () => {
clearInterval(sendInterval)
clientInfo.active = false
})
})
// 停止流
app.post('/api/stop-stream', (req, res) => {
const { clientId } = req.body
for (const [streamId, info] of clients) {
if (info.clientId === clientId) {
clients.delete(streamId)
}
}
res.json({ message: 'Stream stopped' })
})
app.listen(3000, () => {
console.log('SSE 服务器运行在 http://localhost:3000')
})
高级功能:带控制的视频流
<template>
<div>
<div class="controls">
<button @click="connect" :disabled="isConnected">连接</button>
<button @click="disconnect" :disabled="!isConnected">断开</button>
<div class="fps-control">
<label>帧率控制:</label>
<input
type="range"
min="1"
max="60"
v-model="targetFPS"
@change="adjustFPS"
>
<span>{{ targetFPS }} FPS</span>
</div>
<div class="quality-control">
<label>质量:</label>
<select v-model="quality" @change="adjustQuality">
<option value="high">高质量</option>
<option value="medium">中等</option>
<option value="low">低质量</option>
</select>
</div>
</div>
<div class="stats">
<p>连接状态: {{ connectionStatus }}</p>
<p>帧率: {{ actualFPS.toFixed(1) }} FPS</p>
<p>延迟: {{ latency }}ms</p>
<p>已接收帧数: {{ frameCount }}</p>
</div>
<img
:src="currentFrame"
alt="视频流"
class="video-frame"
:style="{ filter: imageFilter }"
/>
</div>
</template>
<script setup>
import { ref, computed, onUnmounted } from 'vue'
const ws = ref(null)
const currentFrame = ref('')
const isConnected = ref(false)
const targetFPS = ref(30)
const quality = ref('medium')
const frameTimes = ref([])
const frameCount = ref(0)
const lastFrameTime = ref(0)
const imageFilter = ref('')
const connectionStatus = computed(() => {
if (!ws.value) return '未连接'
switch (ws.value.readyState) {
case WebSocket.CONNECTING: return '连接中...'
case WebSocket.OPEN: return '已连接'
case WebSocket.CLOSING: return '关闭中...'
case WebSocket.CLOSED: return '已断开'
default: return '未知'
}
})
const actualFPS = computed(() => {
if (frameTimes.value.length < 2) return 0
const recentTimes = frameTimes.value.slice(-10)
const avgInterval = recentTimes.reduce((a, b) => a + b, 0) / (recentTimes.length - 1)
return 1000 / avgInterval
})
const latency = ref(0)
const connect = () => {
ws.value = new WebSocket(`ws://localhost:8080/ws/video?fps=${targetFPS.value}&quality=${quality.value}`)
ws.value.onopen = () => {
isConnected.value = true
console.log('连接成功')
}
ws.value.onmessage = (event) => {
const now = Date.now()
const data = JSON.parse(event.data)
if (data.type === 'frame') {
// 计算延迟
if (data.timestamp) {
latency.value = now - data.timestamp
}
// 更新帧率统计
if (lastFrameTime.value > 0) {
const interval = now - lastFrameTime.value
frameTimes.value.push(interval)
if (frameTimes.value.length > 100) {
frameTimes.value.shift()
}
}
lastFrameTime.value = now
// 显示帧
currentFrame.value = `data:image/jpeg;base64,${data.frame}`
frameCount.value++
}
}
ws.value.onclose = () => {
isConnected.value = false
console.log('连接关闭')
}
ws.value.onerror = (error) => {
console.error('连接错误:', error)
isConnected.value = false
}
}
const disconnect = () => {
if (ws.value) {
ws.value.close()
}
}
const adjustFPS = () => {
if (ws.value && ws.value.readyState === WebSocket.OPEN) {
ws.value.send(JSON.stringify({
type: 'control',
action: 'setFPS',
value: targetFPS.value
}))
}
}
const adjustQuality = () => {
if (ws.value && ws.value.readyState === WebSocket.OPEN) {
ws.value.send(JSON.stringify({
type: 'control',
action: 'setQuality',
value: quality.value
}))
}
// 应用视觉滤镜
switch (quality.value) {
case 'high':
imageFilter.value = 'none'
break
case 'medium':
imageFilter.value = 'blur(0.5px)'
break
case 'low':
imageFilter.value = 'blur(1px) grayscale(20%)'
break
}
}
onUnmounted(() => {
disconnect()
})
</script>
<style scoped>
.controls {
margin-bottom: 20px;
padding: 15px;
background: #f5f5f5;
border-radius: 8px;
}
.controls button {
margin-right: 10px;
padding: 8px 16px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.controls button:disabled {
background: #ccc;
cursor: not-allowed;
}
.fps-control, .quality-control {
margin-top: 10px;
display: flex;
align-items: center;
gap: 10px;
}
.stats {
margin: 15px 0;
padding: 10px;
background: #e9ecef;
border-radius: 4px;
font-family: monospace;
}
.video-frame {
max-width: 100%;
border: 2px solid #333;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
</style>
使用建议
-
性能优化:
-
使用
requestAnimationFrame进行渲染同步 -
实现帧缓冲区防止丢帧
-
使用 Web Workers 处理解码
-
-
错误处理:
-
添加重连机制
-
监控网络状态
-
实现优雅降级
-
-
扩展功能:
-
添加视频录制功能
-
实现帧捕获和保存
-
添加视频滤镜和特效
-
支持多摄像头切换
-