最近刚完成了一个电影院在线选座系统的开发项目,这个系统采用了Vue.js + Node.js + Element UI的技术栈组合。作为一个经常需要抢热门电影票的影迷,我深知一个好用的选座系统对用户体验有多重要。这个项目从零开始搭建,前后花了近两个月时间,期间踩了不少坑,也积累了不少实战经验。
系统主要实现了以下核心功能:
特别值得一提的是选座模块,我们采用了SVG动态渲染技术,相比传统的图片拼接方案,这种实现方式更加灵活,可以轻松应对不同影厅的座位布局变化。在开发过程中,最大的挑战是如何处理高并发下的选座冲突问题,这个我们后面会详细讨论。
选择Vue 3作为前端框架有几个重要考虑:
Element UI Plus作为UI组件库的选择理由:
提示:Element UI的主题定制功能非常实用,我们通过SCSS变量覆盖的方式快速实现了与品牌色一致的UI风格。
Node.js + Express的组合主要基于以下考虑:
数据库选型上,我们最终选择了MySQL而非MongoDB,原因包括:
整个系统采用前后端分离架构:
code复制前端(Vue) <-- HTTP --> 后端(Node) <-- ORM --> MySQL
↑
WebSocket(可选)
前端使用Vite构建,带来了:
选座是系统的核心功能,我们采用了SVG方案而非Canvas,主要因为:
座位数据结构设计:
javascript复制{
id: 'A1',
row: 'A',
col: 1,
status: 'available', // available/locked/sold
type: 'normal' // normal/vip/disabled
}
关键实现代码:
vue复制<template>
<div class="seat-map">
<svg :viewBox="`0 0 ${cols * 40} ${rows * 40}`">
<g v-for="(row, i) in seatMap" :key="i">
<text :x="20" :y="i * 40 + 25">{{ String.fromCharCode(65 + i) }}</text>
<rect
v-for="(seat, j) in row"
:key="j"
:x="j * 40 + 40"
:y="i * 40 + 5"
width="30"
height="30"
:class="['seat', seat.status, seat.type]"
@click="handleSeatClick(seat)"
/>
<text :x="j * 40 + 55" :y="rows * 40 + 20">{{ j + 1 }}</text>
</g>
</svg>
</div>
</template>
<script setup>
const props = defineProps({
seatMap: Array,
readonly: Boolean
});
const emit = defineEmits(['select']);
const handleSeatClick = (seat) => {
if (props.readonly || seat.status !== 'available') return;
emit('select', seat);
};
</script>
<style>
.seat {
stroke: #ccc;
stroke-width: 1;
cursor: pointer;
transition: all 0.2s;
}
.seat.available {
fill: #fff;
}
.seat.selected {
fill: #67c23a;
}
.seat.locked, .seat.sold {
cursor: not-allowed;
}
.seat.vip {
fill: #f0f;
}
</style>
高并发下的选座冲突是这类系统最大的技术挑战。我们最终采用了"乐观锁+队列"的混合方案:
javascript复制router.post('/lock-seat', async (req, res) => {
const { seatId, userId } = req.body;
try {
await sequelize.transaction(async (t) => {
const seat = await Seat.findByPk(seatId, {
lock: t.LOCK.UPDATE,
transaction: t
});
if (seat.status !== 'available') {
throw new Error('Seat not available');
}
seat.status = 'locked';
seat.lockedAt = new Date();
seat.userId = userId;
await seat.save({ transaction: t });
// 设置15分钟的锁定过期时间
await setRedisLock(`seat:${seatId}`, userId, 900);
});
res.json({ success: true });
} catch (error) {
res.status(409).json({
success: false,
message: error.message
});
}
});
javascript复制cron.schedule('* * * * *', async () => {
const expiredLocks = await Seat.findAll({
where: {
status: 'locked',
lockedAt: { [Op.lt]: new Date(Date.now() - 15 * 60 * 1000) }
}
});
await Seat.update(
{ status: 'available', userId: null, lockedAt: null },
{ where: { id: expiredLocks.map(s => s.id) } }
);
});
管理端需要支持不同影厅的座位模板配置。我们设计了一个灵活的JSON配置方案:
javascript复制// 影厅模板示例
{
"name": "IMAX厅",
"rows": 12,
"cols": 20,
"map": [
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],
[0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0],
// ...更多行配置
],
"types": {
"normal": { "price": 45 },
"vip": { "price": 65, "rows": [1,2] },
"disabled": { "rows": [0], "cols": [0,1,18,19] }
}
}
管理界面使用Element UI的ElTable实现:
vue复制<template>
<el-table :data="halls" style="width: 100%">
<el-table-column prop="name" label="影厅名称" />
<el-table-column label="座位布局">
<template #default="{row}">
<el-popover placement="right" width="auto" trigger="hover">
<template #reference>
<el-tag>{{ row.rows }}行 × {{ row.cols }}列</el-tag>
</template>
<seat-preview :config="row.config" />
</el-popover>
</template>
</el-table-column>
<el-table-column label="操作" width="180">
<template #default="{row}">
<el-button size="small" @click="editHall(row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteHall(row.id)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</template>
为了确保不同用户看到的座位状态一致,我们实现了两种同步机制:
javascript复制// 前端
const socket = new WebSocket(`wss://api.example.com/updates?scheduleId=${scheduleId}`);
socket.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'seat-update') {
updateSeatStatus(data.seatId, data.status);
}
};
// 后端
wss.on('connection', (ws, req) => {
const scheduleId = new URL(req.url, 'http://localhost').searchParams.get('scheduleId');
ws.on('message', (message) => {
// 处理消息
});
// 当座位状态变化时广播
broadcastSeatUpdate = (seat) => {
wss.clients.forEach((client) => {
if (client.scheduleId === seat.scheduleId) {
client.send(JSON.stringify({
type: 'seat-update',
seatId: seat.id,
status: seat.status
}));
}
});
};
});
javascript复制// 只在需要时加载选座模块
const SeatMap = defineAsyncComponent(() => import('./SeatMap.vue'));
javascript复制// 使用Redis缓存热门场次的座位状态
router.get('/schedule/:id/seats', async (req, res) => {
const cacheKey = `schedule:${req.params.id}:seats`;
const cached = await redis.get(cacheKey);
if (cached) {
return res.json(JSON.parse(cached));
}
const seats = await Seat.findAll({
where: { scheduleId: req.params.id }
});
await redis.setex(cacheKey, 30, JSON.stringify(seats));
res.json(seats);
});
sql复制ALTER TABLE seats ADD INDEX idx_schedule_status (schedule_id, status);
我们使用Docker进行容器化部署:
dockerfile复制# 前端Dockerfile
FROM nginx:alpine
COPY dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
# 后端Dockerfile
FROM node:16
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "start"]
Nginx配置示例:
nginx复制server {
listen 80;
server_name cinema.example.com;
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://backend:3000;
proxy_set_header Host $host;
}
location /socket.io {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
使用PM2管理Node进程:
bash复制pm2 start ecosystem.config.js --env production
ecosystem.config.js配置:
javascript复制module.exports = {
apps: [{
name: 'cinema-api',
script: 'server.js',
instances: 'max',
exec_mode: 'cluster',
env: {
NODE_ENV: 'production'
},
error_file: '/var/log/pm2/cinema-api-err.log',
out_file: '/var/log/pm2/cinema-api-out.log',
merge_logs: true,
log_date_format: 'YYYY-MM-DD HH:mm Z'
}]
};
Element UI表单验证陷阱:
Vue性能优化:
Node.js内存泄漏排查:
MySQL连接池配置:
javascript复制const sequelize = new Sequelize(/* ... */, {
pool: {
max: 20,
min: 5,
acquire: 30000,
idle: 10000
}
});
跨域问题解决:
这个项目从技术选型到最终上线,整个过程让我对全栈开发有了更深入的理解。特别是高并发场景下的数据一致性问题,让我意识到分布式系统设计的复杂性。