☰
微信小程序在线选座开发:数据模型与canvas绘制的完整方案
2026/10/29 12:03:29 网站建设 项目流程

简介:微信小程序在线选座功能的核心代码包,面向需要为电影、演出、体育赛事等活动快速搭建场地选座模块的前端开发者。它采用原生小程序语法,实现了可拖拽选座、座位状态管理、二维矩阵布局以及选中座位与微信支付的联动逻辑。压缩包共四个文件,涵盖页面结构、样式、交互逻辑与配置文件,整体仅6KB,结构精简,便于直接套用或二次开发。目前已有5430人学习下载。拿到代码后可参照实现座位渲染、拖拽选择、已售锁定等关键交互,同时理解座位唯一标识与后台状态管理的对应关系,并借鉴其对渲染性能的优化思路;开发者还能依据其中的边界处理逻辑,快速应对连排选座、跨区锁定等场景,适合有一定小程序基础、希望高效落地选座需求的开发者。

1. 小程序在线选座:别急着画座位,先想清楚状态机

用户点进小程序里的“在线选座”页面,看到的是一张由绿点、灰点和橙点组成的座位图:绿的可选,灰的已售,橙的是自己刚点的。这个功能在微信小程序里落地的时候,最常见的翻车不是“画不出来”,而是把座位画出来了,但用户抢座、退座、超时释放这些状态一团乱。场地的座位图本质上不是 UI 问题,是数据模型问题——形状不规则的剧院、带包间的 KTV、按区域收费的球场,每一类都要先把座位转成一份结构化的 JSON,前端才有底气去画。

这篇文章面向的是要在微信小程序里做场地预约、活动报名、影剧选座的开发者,或者被业务方一句“就做一个选座页面嘛”推进坑里的前端。我会按数据模型、canvas 绘制、表单联动、真机避坑这条线,把一套能跑通的最小方案摊开讲。如果你只需要在矩形场地里选座,有更轻的矩阵方案;如果场地带弧形、过道、包间,canvas 是绕不开的答案。

2. 先定数据模型:把不规则场地压缩成一份 JSON

2.1 座位编码:行列号不是坐标,是座位 ID

很多第一次做选座的人会把座位坐标直接当 ID 用,比如seatX=120, seatY=340。这个做法在后端存订单、前端做状态同步时会很痛苦:用户换手机分辨率,坐标全变,订单里的“座位”也说不清了。正确做法是给每个座位一个与坐标无关的稳定编码,常见编码规则是区域-排-号,比如A-03-12表示 A 区第 3 排 12 号。

座位编码要满足三个约束:同一场次内唯一、解析后能还原排和号、字符串有序方便排序。我一般会把编码拆成三个字段存在对象里,而不是拼一个字符串:

{ "area": "A", "row": 3, "col": 12, "id": "A-03-12", "status": "available" }

这样前端排序时直接比 row 和 col 的数值,后端按 id 做锁座记录,两边都干净。实际项目里,前后端约定 id 由后端生成,前端只读;如果有本地生成的需求,也务必用稳定规则拼接,不要用Math.random()生成——用户刷新页面座位 ID 变了,订单就炸了。

2.2 场次与已售座位的同步:状态字段的三级优先级

选座页面最核心的状态不是“可点不可点”,而是状态同步。一个座位在一个场次里至少有五种状态:available(可售)、locked(被他人锁定)、sold(已售)、selected(当前用户已选)、disabled(结构性不可售,比如视野遮挡)。前端绘制时只关心颜色,但颜色的来源必须归一成一套优先级:

disabled > sold > locked > selected > available

同一座位不可能同时是 sold 和 selected,但网络延迟下有可能你本地显示 selected,后端已经把它标记为 sold。所以后端返回的座位快照是唯一准绳,前端本地状态只能做乐观更新。每次切换场次,前端必须重新拉一次座位列表,不能沿用上一个场次的缓存。

{ "sessionId": "SESSION-20240601-1900", "areas": [ { "areaId": "A", "name": "A区", "price": 128, "rows": [ { "row": 1, "seatCount": 12 }, { "row": 2, "seatCount": 14 } ] } ], "soldSeats": ["A-01-01", "A-01-02"], "lockedSeats": ["A-02-05"] }

这个快照结构里,前端需要自己把rows展开成每个座位的状态——先在soldSeats里查,再在lockedSeats里查,两处都没有就是可售。注意lockedSeats要区分“别人锁的”和“当前用户锁的”,否则用户自己刚选的座位会被画成灰色,体验上很像翻车。常见做法是后端额外返回myLockedSeats数组,前端优先把它标记为 selected。

3. 用 canvas 画出可点击的座位图:核心绘制与命中检测

3.1 初始化 canvas 2d 节点:新接口和像素比修正

微信小程序的 canvas 有两种用法:旧版wx.createCanvasContext和基础库 2.7.0 之后推荐的 canvas 2d 接口。新接口拿到的节点可以像网页 canvas 一样直接getContext('2d'),我在新项目里都用它,旧接口的问题是真机上draw()时机不稳定,复杂座位图偶尔会闪一下。

初始化画布时有个隐藏坑:node.width和node.height跟 CSS 尺寸不一致会发虚。WXML 里给 canvas 设定的宽高是逻辑像素,而底层绘制要考虑pixelRatio,否则在 iPhone 和部分安卓机上画面像蒙了一层雾。这一步是选座画质的分水岭:

<view class="seat-canvas-wrap"> <canvas id="seatCanvas" type="2d" bindtap="onCanvasTap" style="width: 100%; height: 600px;"></canvas> </view>
// pages/seat/seat.js Page({ data: { seatMap: null }, onReady() { this.initCanvas() }, initCanvas() { const query = this.createSelectorQuery() query.select('#seatCanvas').fields({ node: true, size: true }).exec((res) => { if (!res || !res[0] || !res[0].node) return const canvas = res[0].node const { width, height } = res[0] // 关键:按像素比放大画布物理尺寸,再缩放坐标系 const dpr = wx.getWindowInfo().pixelRatio canvas.width = width * dpr canvas.height = height * dpr const ctx = canvas.getContext('2d') ctx.scale(dpr, dpr) this.canvas = canvas this.ctx = ctx this.canvasWidth = width this.canvasHeight = height // 记录画布在页面中的位置,命中检测要用 query.select('#seatCanvas').boundingClientRect((rect) => { this.canvasRect = rect }).exec() this.fetchSeatMap() }) } })

这段代码里wx.getWindowInfo()是新版基础库的接口,如果你还在兼容很老的基础库,用wx.getSystemInfoSync()取pixelRatio效果一样。初始化里最容易被忽略的是boundingClientRect的缓存——画布如果不在页面顶部,用户点击的坐标是相对页面的,必须减去画布的left和top才能转成画布内坐标,这个在后面命中检测里展开。

3.2 座位绘制逻辑:从 seatMap 到屏幕坐标的换算

拿到seatMap后,绘制要做三件事:确定原点和座位间距、把逻辑坐标转成像素坐标、按状态填充颜色。场地形状的差异全在“原点和间距”这一步。矩形场地用等间距排列就够了;弧形或带过道的场地,需要在数据里给每行加一个offsetX偏移字段。

// 从服务端获取座位快照后组装绘制 async fetchSeatMap() { const res = await request('/api/seat/map', { sessionId: this.data.sessionId }) const seatMap = this.normalizeSeatMap(res.data) this.setData({ seatMap }) this.render() }, normalizeSeatMap(raw) { // 把后端快照展开成二维座位数组,补上每行偏移 const areas = raw.areas.map((area) => { const rows = area.rows.map((rowItem, rowIdx) => { const seats = [] for (let col = 1; col <= rowItem.seatCount; col++) { const id = `${area.areaId}-${String(rowItem.row).padStart(2, '0')}-${String(col).padStart(2, '0')}` seats.push({ id, row: rowItem.row, col, // 奇数排整体右移 1/3 个座位宽度,模拟剧院交错的视觉 offsetX: rowItem.row % 2 === 1 ? SEAT_GAP / 3 : 0, status: this.getSeatStatus(raw, id) }) } return { row: rowItem.row, seats } }) return { areaId: area.areaId, name: area.name, rows } }) return { areas, maxCol: this.getMaxCol(areas) } }

绘制本身不复杂,核心是别每帧重建座位坐标,把坐标存在内存里。我一般会在render()里先清屏再遍历所有座位,用arc()画圆点或roundRect()画方座。圆点适合影院,方座适合会议室——方座能塞下座位号,用户点起来目标更大。

render() { const ctx = this.ctx const seatMap = this.data.seatMap if (!seatMap) return const seatSize = 16 // 座位直径/边长,逻辑像素 const gap = 8 // 座位间距 const originX = 24 // 画布左边距 const originY = 40 // 画布上边距,给场次标题留空间 ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight) // 先画舞台/场地示意,让用户有方向感 ctx.fillStyle = '#f5f5f5' ctx.fillRect(originX, 8, this.canvasWidth - originX * 2, 18) ctx.fillStyle = '#999' ctx.font = '10px sans-serif' ctx.textAlign = 'center' ctx.fillText('舞台方向', this.canvasWidth / 2, 20) seatMap.areas.forEach((area) => { area.rows.forEach((rowItem) => { const rowIndex = rowItem.row - 1 rowItem.seats.forEach((seat) => { const colIndex = seat.col - 1 const x = originX + (seat.offsetX || 0) + colIndex * (seatSize + gap) const y = originY + rowIndex * (seatSize + gap) // 坐标缓存在座位对象上,命中检测直接用 seat.cx = x seat.cy = y ctx.beginPath() ctx.arc(x, y, seatSize / 2, 0, Math.PI * 2) ctx.fillStyle = SEAT_COLORS[seat.status] || SEAT_COLORS.available ctx.fill() // 选中的座位加一圈描边提示 if (seat.status === 'selected') { ctx.strokeStyle = '#e07020' ctx.lineWidth = 2 ctx.stroke() } }) }) }) }

注意我在遍历里把cx和cy写回了座位对象,这是为了让后面命中检测直接做距离比较,而不是再算一遍坐标。座位多的时候,这种空间换时间的做法能让点击响应快一倍以上。颜色映射表建议单独抽成常量,方便后续做“区域价差”的配色——A 区金色、B 区蓝色,用户一眼就知道贵宾席在哪。

3.3 点击命中检测:tap 坐标还原到座位坐标系

canvas 是一个整体节点,bindtap事件只会告诉你点击在画布上的 X/Y,不会告诉你点中了哪个座位,所以命中检测必须自己用距离计算。这里要处理两层换算:页面坐标到画布坐标(减canvasRect.left/top),画布坐标到座位逻辑坐标(遍历座位比对距离)。注意事件对象在不同端有差异,真机上和开发者工具里e.detail.x的基准点不一样——工具里是相对画布,真机上有些基础库是相对页面,保险做法是统一用e.touches[0].clientX配合canvasRect计算。

onCanvasTap(e) { if (!this.canvasRect) return const touch = e.touches && e.touches[0] ? e.touches[0] : e.detail const pageX = touch.clientX !== undefined ? touch.clientX : touch.x const pageY = touch.clientY !== undefined ? touch.clientY : touch.y // 相对页面坐标 - 画布左上角 = 画布内坐标 const canvasX = pageX - this.canvasRect.left const canvasY = pageY - this.canvasRect.top const seat = this.hitTest(canvasX, canvasY) if (seat) this.onSeatTap(seat) }, hitTest(x, y) { const seatMap = this.data.seatMap if (!seatMap) return null // 座位半径 8px,点击容差放大到 1.5 倍,避免手指粗点不中 const hitRadius = 8 * 1.5 for (const area of seatMap.areas) { for (const rowItem of area.rows) { for (const seat of rowItem.seats) { const dx = x - seat.cx const dy = y - seat.cy if (dx * dx + dy * dy <= hitRadius * hitRadius) { return seat } } } } return null }

命中检测这个实现写起来简单,但实际项目里性能要看座位总量——一场演唱会的小程序选座页面,座位数轻松破 2000,三层循环每次点击都跑一遍,在低端安卓机上会有肉眼可见的卡顿。常见做法是对座位坐标建一个网格索引,比如把画布按 20px 见方切块,每块塞入它覆盖的座位 ID 列表,命中检测时先定位点击在第几块,再只遍历该块里的座位,能把复杂度从 O(n) 降到接近 O(1)。

4. 场地选择与预约表单的联动:怎么让用户在三分钟内完成选座

4.1 场次选择:先锁场次再锁座位

标题里“场地选择”这个词,在真实业务里往往有两层含义:选场地(羽毛球馆 2 号场还是 3 号场),以及选场地内的座位(剧院 A 区 5 排 12 号)。这两种选法在一个页面里串行完成时,关键是把“场次”这个概念先立住——用户选好场次、加载座位图,再开始点座位。如果场地和座位两张图同时在页面上堆着,用户会迷失,而且接口请求会乱。

我一般把页面分成两段式:上半段是场地/场次选择器,下半段是座位画布。场地列表用scroll-view做滚动加载,每一屏拉 10 条场地,滚到底部再拉下一页,这个交互微信小程序里没有现成组件,得自己监听bindscrolltolower:

<scroll-view scroll-y style="height: 260px;" bindscrolltolower="loadMoreVenues"> <view wx:for="{{venues}}" wx:key="id" class="venue-item {{venue.id === selectedVenueId ? 'active' : ''}}" bindtap="onSelectVenue">loadMoreVenues() { if (this.data.loading) return this.data.loading = true request('/api/venue/list', { page: this.data.page + 1, pageSize: 10 }).then((res) => { this.setData({ venues: this.data.venues.concat(res.data.list) }) this.data.page += 1 }).finally(() => { this.data.loading = false }) }

这段代码里有个小细节:page和loading我放在this.data还是this上,看团队规范。我习惯把loading和页码这类瞬时状态挂在this.data外面,避免setData触发无谓的视图更新——页面列表一旦频繁 setData,真机上的滚动会掉帧,这就是列表“划不动”的常见原因。

场次选择器用的其实是另一层逻辑:同一场地可能有不同时段,时段决定剩余座位池。切换场次时必须清空已选座位、重新拉座位快照,而且要加一个确认弹窗——“切换场次会清空已选座位,确定继续?”,否则用户点错一场,之前选的十几分钟的心血全没了,这种体验留下的差评很难追回。

4.2 从 canvas 回到 WXML:已选座位的标签与确认栏

canvas 画了座位,但已选座位的反馈不能只画在 canvas 里——用户需要看到“已选 3 个座位,合计 384 元”这样的确认栏,还要能删除某个已选座位。确认栏放在页面底部固定位置,list 数据驱动,选座状态一变化就更新:

<view class="confirm-bar" wx:if="{{selectedSeats.length > 0}}"> <view class="selected-tags"> <view wx:for="{{selectedSeats}}" wx:key="id" class="tag"> {{item.id}} <text catchtap="onRemoveSeat"><view class="seat-matrix"> <view class="seat-row" wx:for="{{seatRows}}" wx:key="row"> <text class="row-label">{{item.label}}</text> <view wx:for="{{item.seats}}" wx:key="id" class="seat {{seat.status === 'sold' ? 'sold' : ''}} {{seat.status === 'selected' ? 'selected' : ''}}" bindtap="onMatrixSeatTap" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />

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

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

立即咨询