基于Vue.js与Socket.IO的实时投票系统:从架构设计到部署实践
2026/5/16 2:31:01 网站建设 项目流程

1. 项目概述:一个实时投票系统的诞生

最近在做一个社区活动,需要快速收集现场观众的意见,比如“大家觉得哪个方案更好?”或者“下一首歌唱什么?”。用传统的问卷工具吧,太慢,结果不直观;用现成的商业投票平台,又担心数据隐私和定制化问题。于是,我决定自己动手,基于一个叫alfredang/livepoll的项目,搭建一个轻量、实时、可控的在线投票系统。

alfredang/livepoll这个项目,从名字就能看出它的核心:live(实时)和poll(投票)。它本质上是一个前后端分离的 Web 应用,允许主持人快速创建投票问题,参与者通过手机或电脑实时投票,结果以动态图表的形式即时展示在大屏幕上。这解决了传统投票反馈慢、参与感弱、数据整理繁琐的痛点。无论是小型团队会议、课堂互动、线下沙龙还是线上直播,它都能派上用场。

这个项目适合两类人:一是像我这样有基础开发能力,想快速实现一个定制化投票功能的开发者;二是活动组织者或讲师,需要一个简单、免费、可私有部署的互动工具。接下来,我会从设计思路到代码实现,再到部署踩坑,完整复盘一遍我的搭建和优化过程。

2. 核心架构与技术选型解析

2.1 为什么选择这个技术栈?

原项目alfredang/livepoll采用了非常经典且高效的现代 Web 开发技术栈:Vue.js 3作为前端框架,Node.js搭配Express作为后端服务,Socket.IO处理实时通信,数据存储则使用了轻量级的SQLite

选择这个组合,背后有清晰的逻辑:

  1. Vue.js 3 + Composition API:对于实时数据展示频繁更新的场景(如票数跳动),Vue 的响应式系统是天然优势。Composition API 让逻辑组织更清晰,尤其是管理投票状态、Socket 连接这类复杂交互时,比 Options API 更灵活。
  2. Node.js + Express:对于实时投票这种 I/O 密集型(大量短连接、消息推送)而非计算密集型的应用,Node.js 的事件驱动、非阻塞模型性能表现很好。Express 则提供了最小化、灵活的 Web 框架,足以支撑 RESTful API 和静态文件服务。
  3. Socket.IO:这是实现“实时”的关键。普通的 HTTP 轮询(不断问服务器“有结果了吗?”)效率低下且延迟高。Socket.IO 基于 WebSocket,提供了双向、低延迟的通信通道。当参与者提交投票,服务器能瞬间将更新推送给所有连接的客户端(特别是主持人的结果展示页),实现真正的“Live”效果。
  4. SQLite:对于轻量级应用,SQLite 是绝佳选择。它无需单独部署数据库服务,数据以单个文件形式存储,备份和迁移极其简单。虽然在高并发写入场景下可能成为瓶颈,但对于中小规模的实时投票(通常几百人同时在线),完全够用。

注意:这个技术栈并非唯一解。例如,前端可以用 React 或 Svelte,后端可以用 Fastify 替代 Express,数据库用 PostgreSQL 以获得更强的事务支持。但当前组合在开发效率、学习成本和社区资源上取得了很好的平衡,非常适合快速原型和中小型应用。

2.2 系统工作流程与数据流

理解数据如何流动,是后续开发和调试的基础。整个系统的核心流程可以概括为以下几步:

  1. 创建投票:主持人在管理后台(通常是一个特定页面)输入问题、设置选项(如 A/B/C/D),点击创建。前端通过 HTTP POST 请求将数据发送到后端/api/polls接口。
  2. 生成投票链接:后端在 SQLite 的polls表中插入新记录,生成唯一的poll_id和用于管理的admin_token。前端收到响应,展示两个链接:一个是给参与者的投票页链接(含poll_id),一个是给主持人的结果页/管理页链接(含poll_idadmin_token)。
  3. 参与者投票:参与者打开投票页链接,前端通过poll_id/api/polls/:id接口获取投票问题与选项,并渲染页面。参与者选择选项并提交。
  4. 实时投票与广播:参与者提交时,前端通过 Socket.IO 发送一个vote事件到服务器,事件内容包含poll_id和选择的option_id。服务器端 Socket.IO 监听器收到后,在votes表中记录这一票。紧接着,服务器通过 Socket.IO 向所有订阅了该poll_id房间(Room)的客户端(主要是主持人的结果页)广播一个updateResults事件,并附带最新的票数统计。
  5. 结果展示:主持人的结果页前端,在连接 Socket.IO 后就加入了对应poll_id的房间。当收到updateResults事件后,利用 Vue 的响应式特性,立即更新图表(如使用 Chart.js 或 ECharts)和数据列表,实现毫秒级的结果刷新。

这个流程确保了从投票到展示的端到端延迟极低,体验流畅。

3. 关键功能模块的深度实现

3.1 后端核心:Express 与 Socket.IO 的集成

后端的核心文件通常是server.jsindex.js。首先,需要建立 Express 应用和 Socket.IO 服务器。

const express = require('express'); const http = require('http'); const socketIo = require('socket.io'); const sqlite3 = require('sqlite3').verbose(); const path = require('path'); const app = express(); const server = http.createServer(app); const io = socketIo(server, { cors: { origin: "http://localhost:8080", // 前端开发服务器地址,生产环境需修改 methods: ["GET", "POST"] } }); // 连接 SQLite 数据库 const db = new sqlite3.Database('./database/polls.db', (err) => { if (err) console.error('Database connection error:', err); else console.log('Connected to SQLite database.'); }); // 初始化数据库表(如果不存在) const initDb = `CREATE TABLE IF NOT EXISTS polls ( id INTEGER PRIMARY KEY AUTOINCREMENT, question TEXT NOT NULL, options TEXT NOT NULL, -- 存储为 JSON 字符串,如 ["选项A", "选项B"] admin_token TEXT UNIQUE NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS votes ( id INTEGER PRIMARY KEY AUTOINCREMENT, poll_id INTEGER NOT NULL, option_index INTEGER NOT NULL, -- 对应 options 数组的下标 voted_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (poll_id) REFERENCES polls (id) );`; db.exec(initDb);

接下来是 Socket.IO 的核心逻辑。关键在于“房间(Room)”的概念,它用来隔离不同投票的数据流。

io.on('connection', (socket) => { console.log('A user connected:', socket.id); // 监听“加入投票房间”事件 socket.on('joinPoll', (pollId) => { socket.join(`poll_${pollId}`); console.log(`Socket ${socket.id} joined room poll_${pollId}`); // 可选:当主持人加入时,立即发送一次当前结果 // emitCurrentResults(pollId); }); // 监听“投票”事件 socket.on('vote', async (data) => { const { pollId, optionIndex } = data; console.log(`Vote received for poll ${pollId}: option ${optionIndex}`); // 1. 将投票存入数据库 const stmt = db.prepare('INSERT INTO votes (poll_id, option_index) VALUES (?, ?)'); stmt.run(pollId, optionIndex, async function(err) { if (err) { console.error('Error saving vote:', err); socket.emit('voteError', { message: 'Failed to save vote.' }); return; } stmt.finalize(); // 2. 计算该投票的最新结果 const results = await getPollResults(pollId); // 3. 向该投票房间内的所有客户端广播更新 io.to(`poll_${pollId}`).emit('updateResults', results); }); }); socket.on('disconnect', () => { console.log('User disconnected:', socket.id); }); }); // 辅助函数:获取投票结果 function getPollResults(pollId) { return new Promise((resolve, reject) => { // 先获取该投票的选项定义 db.get('SELECT options FROM polls WHERE id = ?', [pollId], (err, row) => { if (err || !row) { reject(err || new Error('Poll not found')); return; } const options = JSON.parse(row.options); const resultTemplate = options.map((opt, idx) => ({ option: opt, count: 0, index: idx })); // 再统计每个选项的票数 const sql = ` SELECT option_index, COUNT(*) as count FROM votes WHERE poll_id = ? GROUP BY option_index `; db.all(sql, [pollId], (err, rows) => { if (err) { reject(err); return; } // 将统计结果合并到模板中 rows.forEach(r => { const target = resultTemplate.find(rt => rt.index === r.option_index); if (target) target.count = r.count; }); resolve({ pollId, results: resultTemplate }); }); }); }); }

实操心得:在vote事件处理中,一定要先完成数据库写入 (stmt.run),再计算和广播结果。这个顺序不能错,否则可能广播旧数据。另外,使用io.to(room).emit()进行房间内广播,效率远高于向所有连接 (io.emit()) 或单个 socket (socket.emit()) 发送消息。

3.2 前端核心:Vue 3 与 Socket.IO 客户端的联动

前端我们使用 Vue 3 的 Composition API 来组织代码,逻辑会更清晰。首先在参与者投票页面 (Voter.vue):

<template> <div class="voter"> <h2>{{ poll.question }}</h2> <div v-if="!hasVoted"> <button v-for="(option, index) in poll.options" :key="index" @click="submitVote(index)" :disabled="isSubmitting" > {{ option }} </button> <p v-if="isSubmitting">提交中...</p> </div> <div v-else> <p>感谢您的投票!已投给:{{ poll.options[selectedOption] }}</p> </div> </div> </template> <script setup> import { ref, onMounted, onUnmounted } from 'vue'; import { useRoute } from 'vue-router'; import io from 'socket.io-client'; const route = useRoute(); const pollId = route.params.id; const socket = io('http://localhost:3000'); // 后端地址 const poll = ref({ question: '', options: [] }); const hasVoted = ref(false); const selectedOption = ref(null); const isSubmitting = ref(false); // 获取投票信息 onMounted(async () => { const response = await fetch(`http://localhost:3000/api/polls/${pollId}`); const data = await response.json(); poll.value = data; // 加入该投票的Socket房间,为未来可能的实时功能扩展(如看到总参与人数)做准备 socket.emit('joinPoll', pollId); // 检查本地是否已投票(防止重复投票) const votedKey = `voted_${pollId}`; if (localStorage.getItem(votedKey)) { hasVoted.value = true; selectedOption.value = parseInt(localStorage.getItem(votedKey), 10); } }); // 提交投票 const submitVote = async (optionIndex) => { if (hasVoted.value || isSubmitting.value) return; isSubmitting.value = true; const voteData = { pollId, optionIndex }; socket.emit('vote', voteData); // 通过Socket发送投票数据 // 可选:也可以同时发一个HTTP POST作为备份或验证 // await fetch(`http://localhost:3000/api/polls/${pollId}/vote`, { method: 'POST', body: JSON.stringify(voteData) }); // 本地记录已投票 localStorage.setItem(`voted_${pollId}`, optionIndex); selectedOption.value = optionIndex; hasVoted.value = true; isSubmitting.value = false; }; onUnmounted(() => { if (socket) socket.disconnect(); }); </script>

而在主持人的结果展示页面 (AdminResults.vue),核心是监听结果更新并渲染图表。

<template> <div class="results"> <h2>实时投票结果:{{ poll.question }}</h2> <div v-if="chartRef" class="chart-container"> <canvas ref="chartRef"></canvas> </div> <ul class="results-list"> <li v-for="item in results" :key="item.index"> {{ item.option }}: {{ item.count }} 票 ({{ percentage(item) }}%) </li> </ul> <p>总票数:{{ totalVotes }}</p> </div> </template> <script setup> import { ref, onMounted, onUnmounted, computed, watch, nextTick } from 'vue'; import { useRoute } from 'vue-router'; import io from 'socket.io-client'; import Chart from 'chart.js/auto'; const route = useRoute(); const { id: pollId, token: adminToken } = route.params; // 从URL获取管理token const socket = io('http://localhost:3000'); const poll = ref({}); const results = ref([]); const chartRef = ref(null); let chartInstance = null; // 计算总票数和百分比 const totalVotes = computed(() => results.value.reduce((sum, item) => sum + item.count, 0)); const percentage = (item) => totalVotes.value ? ((item.count / totalVotes.value) * 100).toFixed(1) : '0.0'; onMounted(async () => { // 1. 验证管理员token并获取投票信息 const response = await fetch(`http://localhost:3000/api/polls/${pollId}?token=${adminToken}`); if (!response.ok) throw new Error('无权查看或投票不存在'); poll.value = await response.json(); // 2. 加入Socket房间,接收实时更新 socket.emit('joinPoll', pollId); socket.on('updateResults', (data) => { if (data.pollId == pollId) { results.value = data.results; updateChart(); } }); // 3. 初始化图表 await nextTick(); // 等待DOM渲染 if (chartRef.value) { initChart(); } }); function initChart() { const ctx = chartRef.value.getContext('2d'); chartInstance = new Chart(ctx, { type: 'bar', // 柱状图适合展示票数对比 data: { labels: results.value.map(r => r.option), datasets: [{ label: '票数', data: results.value.map(r => r.count), backgroundColor: 'rgba(54, 162, 235, 0.5)', borderColor: 'rgba(54, 162, 235, 1)', borderWidth: 1 }] }, options: { responsive: true, scales: { y: { beginAtZero: true, ticks: { stepSize: 1 // 票数为整数,刻度间隔为1 } } } } }); } function updateChart() { if (chartInstance) { chartInstance.data.labels = results.value.map(r => r.option); chartInstance.data.datasets[0].data = results.value.map(r => r.count); chartInstance.update(); // 平滑更新图表 } } onUnmounted(() => { if (chartInstance) chartInstance.destroy(); if (socket) socket.disconnect(); }); </script>

注意事项:前端两个页面都使用了 Socket.IO,但目的不同。投票页是为了发送vote事件,结果页是为了接收updateResults事件。务必在组件卸载时 (onUnmounted) 断开 Socket 连接,避免内存泄漏和重复连接。

3.3 数据库设计与优化考虑

虽然 SQLite 很轻量,但表结构设计直接影响性能和逻辑清晰度。上面给出的初始化 SQL 是一个基础版本。在实际使用中,我做了以下几点优化:

  1. 添加索引votes表的poll_idoption_index是查询最频繁的字段,尤其是按poll_id分组统计时。添加索引能大幅提升查询速度。

    CREATE INDEX idx_votes_poll_id ON votes (poll_id); CREATE INDEX idx_votes_option_index ON votes (option_index); -- 甚至可以考虑复合索引,取决于查询模式 -- CREATE INDEX idx_votes_poll_option ON votes (poll_id, option_index);
  2. 防止重复投票:基础版本仅用前端localStorage防止同一浏览器重复投票,这并不安全。更严谨的做法是在后端结合一些标识。一个简单方案是在votes表中增加一个voter_token字段(可由前端生成一个 UUID 并存入 Cookie),并在(poll_id, voter_token)上创建唯一索引。但这仍无法完全防止同一用户换设备投票,更复杂的方案需要用户系统,这超出了轻量级投票的范畴,需要权衡。

  3. 数据清理:投票活动有时效性。可以增加一个is_active字段到polls表,或者定期清理非常旧的投票数据,避免数据库无限制增长。

4. 部署实践与性能调优

开发完成,最终要部署到服务器上供真实用户访问。我选择了常见的Nginx + PM2方案。

4.1 使用 PM2 管理 Node.js 进程

在服务器上,直接运行node server.js不够健壮,进程崩溃后不会自动重启。PM2 是一个强大的进程管理器。

# 全局安装 PM2 npm install -g pm2 # 在项目根目录,用 PM2 启动应用。给进程起个名字,如 `livepoll` pm2 start server.js --name livepoll # 设置开机自启 (根据系统生成配置) pm2 startup # 执行它输出的命令,然后保存当前进程列表 pm2 save # 常用命令 pm2 status # 查看进程状态 pm2 logs livepoll # 查看实时日志 pm2 restart livepoll # 重启应用 pm2 stop livepoll # 停止应用 pm2 delete livepoll # 删除应用

实操心得:在server.js中,务必通过process.env.PORT来读取端口,而不是写死3000。这样可以在 PM2 或部署平台(如 Heroku, Railway)中灵活配置。例如:const PORT = process.env.PORT || 3000; server.listen(PORT, ...)

4.2 配置 Nginx 反向代理

不建议让用户直接访问 Node.js 服务的端口(如3000)。使用 Nginx 作为反向代理,可以处理静态文件、SSL 加密、负载均衡等。

假设你的前端构建文件在dist/目录,后端运行在http://localhost:3000。一个基本的 Nginx 配置 (/etc/nginx/sites-available/livepoll) 如下:

server { listen 80; server_name your-domain.com; # 你的域名 root /path/to/your/livepoll-project/dist; # 前端静态文件路径 index index.html; # 前端路由(如Vue Router的history模式)支持 location / { try_files $uri $uri/ /index.html; } # 反向代理到后端 API 和 Socket.IO location /api/ { proxy_pass http://localhost:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # Socket.IO 需要特殊的代理配置 location /socket.io/ { proxy_pass http://localhost:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }

配置完成后,创建软链接并测试、重载 Nginx:

sudo ln -s /etc/nginx/sites-available/livepoll /etc/nginx/sites-enabled/ sudo nginx -t # 测试配置语法 sudo systemctl reload nginx # 重载配置

4.3 启用 HTTPS (SSL)

线上服务必须使用 HTTPS。可以使用 Let‘s Encrypt 的 Certbot 免费获取 SSL 证书。

# 安装 Certbot (以Ubuntu为例) sudo apt update sudo apt install certbot python3-certbot-nginx # 获取并自动配置证书 sudo certbot --nginx -d your-domain.com # 按照提示操作,Certbot 会自动修改 Nginx 配置,重定向 HTTP 到 HTTPS。

4.4 性能与可扩展性思考

对于一个小型实时投票系统,上述架构足以应对数百并发。但如果预期有数千甚至更高并发,需要考虑以下几点:

  1. Node.js 进程扩展:可以使用 PM2 的集群模式,启动多个 Node.js 实例。

    pm2 start server.js -i max --name livepoll # 根据CPU核心数启动多个实例

    但需要注意,Socket.IO 默认的内存适配器 (socket.io-adapter-memory) 在不同进程间无法共享连接和房间信息。必须使用Redis 适配器

    npm install socket.io-redis

    然后在服务端代码中配置:

    const redisAdapter = require('socket.io-redis'); io.adapter(redisAdapter({ host: 'localhost', port: 6379 }));

    这样,所有 Node.js 实例都能通过 Redis 共享 Socket.IO 的状态。

  2. 数据库瓶颈:SQLite 在极高并发写入时可能锁库。如果投票提交极其频繁,可以考虑迁移到 PostgreSQL 或 MySQL,它们对高并发写入的支持更好。或者,引入一个消息队列(如 Redis Streams 或 RabbitMQ),将投票请求先存入队列,再由后台 worker 批量写入数据库,削峰填谷。

  3. 前端优化:对于结果页,当票数变化非常频繁时,频繁更新图表可能导致浏览器卡顿。可以引入一个简单的防抖(debounce)机制,比如每 200 毫秒最多更新一次图表视图,而不是每次updateResults事件都更新。

5. 常见问题排查与安全加固

在实际搭建和运行过程中,你肯定会遇到一些问题。以下是我踩过的一些坑和解决方案。

5.1 Socket.IO 连接失败

症状:前端控制台报错WebSocket connection to ‘ws://...’ failed或一直处于polling状态。

排查步骤

  1. 检查服务端是否运行curl http://localhost:3000/socket.io/?EIO=4&transport=polling应该返回一段包含sid的字符串。
  2. 检查跨域 (CORS):确保服务端 Socket.IO 实例的 CORS 配置包含了前端的实际访问域名(生产环境),开发环境可能是http://localhost:8080
  3. 检查反向代理配置:这是最常见的问题。确保 Nginx 配置中包含了正确的/socket.io/location块,并且proxy_set_header UpgradeConnection头部设置正确(详见上文 Nginx 配置)。
  4. 检查防火墙/安全组:确保服务器开放了 80/443 端口(HTTP/HTTPS),并且后端服务的端口(如 3000)在服务器内部可访问。

5.2 投票结果不同步或延迟

症状:A 用户投票后,主持人的结果页没有立即更新,或者更新了但数据不对。

排查步骤

  1. 检查 Socket 事件监听:在主持人的浏览器控制台,检查是否收到了updateResults事件,以及事件数据是否正确。可以在服务端io.to(...).emit()前后加日志,确认广播已执行。
  2. 检查房间加入逻辑:确保主持人的前端在加载后,确实发送了joinPoll事件,并且携带了正确的pollId。服务端socket.join是否成功。
  3. 检查数据库写入:在vote事件处理函数中,确认stmt.run回调函数被成功执行,没有数据库错误。
  4. 检查多实例问题:如果使用了 PM2 集群但没有配置 Redis 适配器,那么用户可能连接到不同的 Node.js 实例,导致广播无法覆盖所有主持人客户端。必须配置 Redis 适配器

5.3 安全性考量与加固

一个公开的投票系统,必须考虑基本的安全问题:

  1. SQL 注入防护:我们使用了db.prepare和参数化查询 (?占位符),这能有效防止 SQL 注入。绝对不要用字符串拼接的方式构造 SQL。
  2. XSS 攻击防护:投票问题和选项是用户输入的,直接渲染到前端有 XSS 风险。在存储和渲染前,应对内容进行过滤或转义。可以使用DOMPurify这样的库在前端进行净化,或者在服务端存储时进行过滤。
  3. 管理员端点保护/api/polls/:id接口通过admin_token查询参数来验证管理员身份。这只是一个基础方案。更安全的做法是使用 HTTP Bearer Token 或 Session Cookie,并在服务端进行严格的校验。确保任何涉及删除投票、查看详细投票记录(如IP)的接口都受到保护。
  4. 限流与防刷:为了防止恶意用户刷票,可以实施简单的限流。例如,使用express-rate-limit中间件,针对/api/polls/:id/vote接口(如果存在)或 Socket.IO 的连接事件,限制单个 IP 在一定时间内的请求次数。
    const rateLimit = require('express-rate-limit'); const apiLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15分钟 max: 100 // 每个IP最多100次请求 }); // 应用到API路由 app.use('/api/', apiLimiter);
    对于 Socket.IO,可以在connection事件中检查连接频率。
  5. HTTPS 强制:如前所述,一定要使用 HTTPS,防止中间人攻击窃听投票数据或管理员令牌。

搭建这样一个系统,从技术选型到细节实现,再到部署上线和安全加固,每一步都需要仔细考量。alfredang/livepoll提供了一个优秀且清晰的起点,但真正让它稳定、可靠、安全地服务于你的场景,还需要根据上述的实践经验和避坑指南进行填充和打磨。希望这份详细的复盘能帮助你少走弯路,快速构建出属于自己的实时互动工具。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询