买火车票选择座位功能实现
需求描述
最近开发京东旅行的一个买票选择座位功能,当你订票时选择的是送票上门,可以在线选择座位位置,因为每个订单最多只能预订5位乘客(包括儿童)的票,因此每种座位类型的选择逻辑如下:
- 商务座/一等座
一排显示三个座位,乘客人数大于3时,需要显示两排座位
- 二等座
一排显示五个座位,可以任意选择
- 普通硬座
只允许乘客选择靠窗位置的座位数
- 硬卧/软卧
只允许乘客选择下铺的数目,并且乘客数大于等于2时,不能保证会在同一包厢(不是车厢)
这几种座位之间的共同点是:
- 对于高铁票,点击可以选择一个座位,再次点击就是取消已选择的座位
- 选择座位时,如果已选择的座位数超过了乘客数,则最早选择的那个座位会被自动取消掉,依次类推
- 删除乘客时,也是优先取消最早选择的座位
- 当座位显示了两排,然后删除乘客时,如果一排的座位数可以满足当前的乘客数,则自动取消掉最早选择的那一排座位
- 卧铺的逻辑很简单,只要已选择的铺位数不会大于乘客数即可
功能实现
需求已经写清楚了,我先模拟一个高铁在线选座的界面出来:
座位类型:
<select id="ticketType">
<option value="P">商务座/一等座</option>
<option value="M">二等座</option>
<option value="1">硬座</option>
<option value="2">硬卧/软卧</option>
</select>
乘客数:
<input type="number" step=0 min=0 max=5 step=1 value=0 id="passenger">
选择数:
<span id="ticketInfo"></span>
<div class="seat-box"></div>
#passenger{
width: 80px;
}
.seat-row{
padding: 10px;
}
.seat{
display: inline-block;
width: 42px;
height: 36px;
line-height: 33px;
text-align: center;
color: #666;
background-image: url(//img20.360buyimg.com/uba/jfs/t7282/74/1658256725/1195/7e270fe2/599e6afbN59d51b35.png);
background-repeat: no-repeat;
margin-left: 10px;
cursor: pointer;
}
.seat-chosen{
background-image: url(//img30.360buyimg.com/uba/jfs/t7588/281/1633648098/1268/b1d85178/599e6bbcNfb1b597a.png)
}

初始化代码结构
我首先创建了一个类ChooseSeat,init方法用来担任路由的角色,根据传入的seatType调用相应的功能方法,将计算完之后的座位模版插入到页面中;bind方法用来给页面中的所有的dom操作绑定事件;setTicketInfo方法用来更新页面中展示的已选座位数与乘客数。
class ChooseSeat{
constructor(seatType, totalNum){
this.seatType = seatType //座位类型
this.totalNum = totalNum //乘客人数
this.seatBox = document.querySelector('.seat-box')
this.initSeat()
this.bind()
this.setTicketInfo()
}
initSeat(){
var tpl = ''
switch(this.seatType){
case 'P':
tpl = this.shangWuZuo()
break
case 'M':
tpl = this.erDengZuo()
break
case '1':
tpl = this.yingZuo()
break
case '2':
tpl = this.woPu()
break
}
this.seatBox.innerHTML = tpl
}
bind(){
}
setTicketInfo(){
}
shangWuZuo(){
}
erDengZuo(){
}
yingZuo(){
}
woPu(){
}
}
new ChooseSeat('M', 0)
从共同点入手
通过需求描述可以发现,不管你预订的是高铁几等座,共同点都是:点击座位预订,再次点击就是取消。所以,我决定先从这个点入手。
class ChooseSeat{
constructor(seatType, totalNum){
this.hasChosen = []
this.seatBox = document.querySelector('.seat-box')
}
bind(){
var self = this
self.seatBox.onclick = (event) => {
var target = event.target
if(target.classList.contains('seat')){
let dataSeat = target.getAttribute('data-seat')
//如果当前选择的座位不在数组中,而且乘客数(非零)小于等于已选择座位数,则将最早选择的座位删掉,然后再把当前选择的座位push到数组中
if(!self.hasChosen.includes(dataSeat)){
if(self.totalNum <= self.hasChosen.length){
self.hasChosen.splice(0, 1)
}
if(self.totalNum != 0){
self.hasChosen.push(dataSeat)
}
}
//如果当前选择的座位存在于数组中,则找出来,将其删掉
else{
let index = self.hasChosen.findIndex(value => {
return value == dataSeat
})
self.hasChosen.splice(index, 1)
}
self.initSeat()
self.setTicketInfo()
}
}
}
}
new ChooseSeat('M', 0)
后选择的座位会顶替掉最早选择的座位;删除时,也是优先删除最早选择的座位。这明显是一个先进先出的队列操作,聪明的你一定想到了可以用数组来模拟这个数据结构。所以我在constructor中定义一个hasChosen数组,用来存放当前所有已经选择的座位。
利用事件委托在seat-box上监听onclick事件,保证每次座位类型改变之后,座位依然可以进行点击。每次触发click事件,我都使用classList对象的contains方法来确定点击的是否为seat,再进行下一步处理。
选择一个座位无非两种情况:
-
如果当前选择的座位不在
hasChosen中,而且乘客数(非零)小于等于已选择座位数,则将最早选择的座位删掉,然后再把当前选择的座位push到数组中。 -
如果当前选择的座位存在于
hasChosen中,使用findIndex方法定位其索引,再使用splice方法将其删除。
贯穿整个代码结构的思想逻辑是:每次根据用户的选择情况,处理完hasChosen中的数据之后,再去重新调用initSeat方法渲染页面上的模版。以达到数据处理与dom操作分离的目的。
高铁二等座位
第二个比较容易实现的功能是选择高铁二等座位,因为它只有一排,模版每次只需要根据hasChosen中的数据进行渲染即可。
row数组代表座位编号,每次erDengZuo方法被调用时,都会遍历此数组,判断每一个座位编号是否存在于hasChosen中。如果存在,就给其加上seat-chosen样式,否则,就按默认样式渲染。
class ChooseSeat{
constructor(seatType, totalNum){
this.hasChosen = []
this.seatBox = document.querySelector('.seat-box')
}
bind(){
self.seatBox.onclick = (event) => {
var target = event.target
if(target.classList.contains('seat')){
let dataSeat = target.getAttribute('data-seat')
//如果当前选择的座位不在数组中,而且乘客数(非零)小于等于已选择座位数,则将最早选择的座位删掉,然后再把当前选择的座位push到数组中
if(!self.hasChosen.includes(dataSeat)){
if(self.totalNum <= self.hasChosen.length){
self.hasChosen.splice(0, 1)
}
if(self.totalNum != 0){
self.hasChosen.push(dataSeat)
}
}
//如果当前选择的座位存在于数组中,则找出来,将其删掉
else{
let index = self.hasChosen.findIndex(value => {
return value == dataSeat
})
self.hasChosen.splice(index, 1)
}
self.initSeat()
self.setTicketInfo()
}
}
}
erDengZuo(){
var tpl = ''
var row = ['A', 'B', 'C', 'D', 'E']
for(let r of row){
if(this.hasChosen.includes(r)){
tpl += `<span class="seat seat-chosen" data-seat="${r}">${r}</span>`
}else{
tpl += `<span class="seat" data-seat="${r}">${r}</span>`
}
}
return `<div class="seat-row">${tpl}</div>`
}
}
new ChooseSeat('M', 0)

高铁商务座位/一等座位
SWRow1表示商务座位/一等座位的第一排,SWRow2表示商务座位/一等座位的第二排。ticketType表示选择的座位类型,passenger表示乘客数目。
ticketType的切换说明用户改变了座位的类型,需要重新订票选择,所以我需要把已经选择的座位清空、重新初始化座位模版、更新显示的乘客与已选择座位数目:
self.hasChosen = []
self.initSeat()
self.setTicketInfo()
passenger的切换说明用户改变了需要订票的乘客数。那首先应该处理的是,如果当前乘客数小于已选择座位数的时候,需要将最早选择的座位删掉。
这里需要解释一下为什么是“小于”,比如场景如下:
- 现在有3位乘客,用户已经选择了2个座位;突然用户后悔了,删掉了一位乘客,剩下2位乘客,2个预订的座位。这时候两者相等,不需要删除座位。
- 接下来用户又删掉一位乘客,现在是1位乘客,2个预订的座位,那你就得删掉最早预订的那个座位了。
接下来处理棘手的显示两排座位的场景,这又分两种情况:
- 一是乘客数从3增加到4的时候,需要将另一排显示出来。这涉及到的仅仅是模版的渲染,但我们现在讨论的是如何操作数据,所以暂时先忽略这种情况。
- 二是乘客数从4减少到3的时候,需要将其中一排隐藏。这里又引出两个问题:怎么知道用户是在减少乘客呢?怎么知道应该删掉哪一排呢?
我定义了一个totalNum变量用来存放上一次的乘客数,this.value存放的是当前乘客数,所以当totalNum=4,并且this.value=3的时候,用户肯定是减少了乘客数目。(ps:4和3是乘客数目的一个临界点,只有这个时候才会有一排和两排切换的问题)。
取出hasChosen[0]的值,也就是最早选择的那个座位和C进行比较,小于等于C的肯定是A, B, C,否则就是D, E, F。这样就能知道是需要删除哪一排了,这也是为什么上面会把两排座位分开存放。
class ChooseSeat{
constructor(seatType, totalNum){
this.seatType = seatType
this.totalNum = totalNum
this.hasChosen = []
this.SWRow1 = ['A', 'B', 'C'] //商务座位/一等座位的第一排
this.SWRow2 = ['D', 'E', 'F'] //商务座位/一等座位的第二排
this.seatBox = document.querySelector('.seat-box')
this.ticketInfo = document.getElementById('ticketInfo')
this.initSeat()
this.bind()
this.setTickeInfo()
}
initSeat(){
...
}
bind(){
var self = this
var seats = document.querySelectorAll('.seat')
var ticketType = document.getElementById('ticketType')
var passenger = document.getElementById('passenger')
ticketType.onchange = function(){
self.seatType = this.value
self.hasChosen = []
self.initSeat()
self.setTickeInfo()
}
passenger.onchange = function(){
var seatEl = ''
//当前乘客数小于已选择座位数的时候,将最早选择的座位删掉
if(this.value < self.hasChosen.length){
seatEl = self.hasChosen.splice(0, 1)
}
/**
* 因为只有商务座/一等座可能会显示两排座位,所以需要特殊处理
* 当前乘客数小于等于3时,并且座位显示了两排时,必须删除一排
*/
if(this.value == 3 && self.totalNum == 4 && self.seatType == 'P'){
if(!seatEl){
seatEl = self.hasChosen[0]
}
if(seatEl <= 'C'){
removeSeat(self.SWRow1)
}else{
removeSeat(self.SWRow2)
}
}
function removeSeat(seats){
for(let s of seats){
let index = self.hasChosen.findIndex(value => {
return value == s
})
if(index != -1){
self.hasChosen.splice(index, 1)
}
}
}
self.totalNum = this.value
self.initSeat()
self.setTicketInfo()
}
}
setTicketInfo(){
this.ticketInfo.innerHTML = `${this.hasChosen.length}/${this.totalNum}`
}
}
new ChooseSeat('P', 0)
处理完数据,接下来就是使用数据来渲染模版了。
- 当
hasChosen.length = 0,也就是用户没有选择座位的时候。如果乘客数目小于等于3,直接显示第一排;否则,就是把两排都显示出来。 - 当
hasChosen.length != 0,也就是用户选择了若干座位的时候。如果乘客数目小于等3,那肯定就是显示一排座位,但是,显示哪一排取决于hasChosen[0]取出来的值属于哪一排。否则,也是直接将两排都显示出来。
class ChooseSeat{
shangWuZuo(){
var self = this
var tpl = ''
function seatGen(row){
var seats = ''
for(let r of row){
if(self.hasChosen.includes(r)){
seats += `<span class="seat seat-chosen" data-seat="${r}">${r}</span>`
}else{
seats += `<span class="seat" data-seat="${r}">${r}</span>`
}
}
return `<div class="seat-row">${seats}</div>`
}
if(this.hasChosen.length == 0){
if(this.totalNum <= 3){
tpl = seatGen(this.SWRow1)
}
else{
tpl = seatGen(this.SWRow1)
tpl += seatGen(this.SWRow2)
}
}
else{
if(this.totalNum <= 3){
let seatEl = this.hasChosen[0]
if(this.SWRow1.includes(seatEl)){
tpl = seatGen(this.SWRow1)
}
else{
tpl = seatGen(this.SWRow2)
}
}
else{
tpl = seatGen(this.SWRow1)
tpl += seatGen(this.SWRow2)
}
}
return tpl
}
}
new ChooseSeat('P', 0)

硬座/卧铺
对于这两种座位类型,只能是选择一个数量:上限是不能大于乘客数目,也不能大于5;下限是不能小于0即可。所以,它的操作界面是这样的:

class ChooseSeat{
constructor(seatType, totalNum){
...
}
initSeat(){
...
}
bind(){
var self = this
var seats = document.querySelectorAll('.seat')
var ticketType = document.getElementById('ticketType')
var passenger = document.getElementById('passenger')
self.seatBox.onclick = (event) => {
var target = event.target
...
//处理硬座、硬卧、软卧
if(this.seatType == '1' || this.seatType == '2'){
let ticketNum = document.getElementById('ticketNum')
let n = parseInt(ticketNum.value)
if(target.classList.contains('increase')){
if(n < 5 && n < self.totalNum){
ticketNum.value = ++n
}
}
if(target.classList.contains('reduce')){
if(n > 0){
ticketNum.value = --n
}
}
}
}
...
}
yingZuo(){
var tpl = `
<button class="reduce">-</button> <input id="ticketNum" type="number" value=0 min=0 max=5 step=1 /> <button class="increase">+</button>
`
return tpl
}
}
new ChooseSeat('1', 0)
总结
好了,终于写完了。这几种座位类型的选择功能,最容易实现的是硬座/卧铺,其次是高铁二等座,最痛苦的就是高铁商务座/一等座。因为它涉及到了两排座位的各种增删,曾经思考这个逻辑感觉都要精神分裂了。
不过,任何复杂的逻辑都是由多个简单的小逻辑组成。所以,学会能够清晰的把各个小逻辑拆分出来、实现它,并组合到一起才是最重要的。
完整示例
https://codepen.io/sjzcxc/pen/MvXNrG