ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从零实现S-Bahn座位选择工具:数据驱动渲染与交互实践

从零实现S-Bahn座位选择工具:数据驱动渲染与交互实践 本文从零实现一个 S-Bahn 座位选择交互小工具涉及车厢布局建模、座位偏好筛选、方向翻转与本地存储适合前端入门或想在通勤场景中做工具练手的开发者。全文附完整 HTML/CSS/JavaScript 代码可本地直接运行。1. 背景为什么需要一个“S-Bahn Seat Picker”1.1 S-Bahn 与日常出行场景S-Bahn 是德国城市通勤铁路系统覆盖柏林、慕尼黑、汉堡等主要城市。和国内地铁不同S-Bahn 的线路更长通常会跨城市区域单程通勤时间往往在 20 到 60 分钟之间。既然乘车时间不短选一个合适的座位就成了很多通勤者关心的细节有人喜欢靠窗看书有人需要电源插座办公有人想坐在安静区也有人希望离门近一点换乘时可以快速出站。很多铁路官网或购票 App 都提供“座位选择”功能但大多针对长途列车 IC/ICE。对于短途的 S-Bahn乘客通常是到站台上后自由上车并没有提前选座的过程。不过如果我们要做一个“S-Bahn Seat Picker”可以把语义理解成一个帮助用户快速选择理想座位的交互工具用户可以切换线路方向、查看车厢座位分布、按偏好筛选座位并保存自己常用的座位偏好。1.2 这个工具要解决什么问题在实际用 Web 技术实现时S-Bahn Seat Picker 更像是一个典型的“交互式数据渲染”前端项目需要把列车车厢抽象成结构化数据。需要根据座位位置关系计算靠窗、靠过道、朝向等信息。需要根据用户偏好做实时筛选。需要处理方向变化因为列车换向后座位朝向也会改变。需要持久化用户选择避免刷新页面后丢失。所以这篇文章选择用原生 HTML/CSS/JavaScript 来实现没有引入框架目的是把核心原理讲清楚。以后你想迁移到 Vue、React 或小程序只需要改渲染层数据和逻辑基本可以复用。1.3 难度与适用读者本文适合以下读者刚学完 JavaScript DOM 操作想做一个完整小项目的人。想了解“座位布局如何抽象成数据模型”的前端初学者。平时坐 S-Bahn 或城际通勤想做个实用小工具的人。准备面试前端岗位需要一个交互项目的同学。读完本文后你将掌握用二维数组表示车厢座位布局。座位信息的动态计算与筛选。事件委托与页面渲染分离。localStorage 保存偏好设置。方向切换对座位朝向的影响。2. 环境准备与项目结构这个项目不需要安装编译工具也不需要 Node.js只需要一个现代浏览器即可。建议使用 Chrome 或 Edge 最新版方便查看控制台输出。2.1 版本说明由于是完全原生实现版本依赖很少依赖项版本说明HTML5现代浏览器均支持CSS3使用 Flexbox/Grid 布局JavaScriptES6 语法不需要额外转译浏览器Chrome / Edge / Firefox 最新版如果你后面想把代码迁移到 TypeScript 或 Vue 3也不影响本文的逻辑设计。2.2 项目目录我们采用分离式目录结构方便后续扩展s-bahn-seat-picker/ ├── index.html ├── css/ │ └── style.css ├── js/ │ ├── data.js │ └── app.jsindex.html页面骨架。css/style.css所有样式包括车厢、座位、筛选面板。js/data.js线路、车厢、座位的基础数据。js/app.js渲染逻辑、筛选逻辑、本地存储逻辑。如果你只是想快速实验也可以把全部代码写在一个 HTML 文件中。但为了培养工程习惯本文按目录拆分。3. 核心设计数据模型与座位布局算法3.1 座位布局的三层结构要绘制一列车比较自然的思路是线路包含多节车厢每节车厢包含多个座位。因此在data.js中我定义一个三层嵌套结构// js/data.js const SBAHN_DATA { lines: [ { id: s7, name: S7, direction: Potsdam → Ahrensfelde, cars: [ { id: car-1, type: S-Bahn Berlin 480, seatRows: [ // 每一行是一个座位组数组 ] } ] } ] };这里的关键是seatRows它表示车厢内的一排座位。S-Bahn 车厢常见的座位布局是 22即中间一条过道左右各两个座位。为了在页面中清晰显示我设计一个二维数组其中每个元素对应一个座位编号过道用字符串aisle表示const seatRows [ [A1, B1, aisle, C1, D1], [A2, B2, aisle, C2, D2] ];这样渲染时遇到aisle就留出过道间隙。但只有座位编号还不够还需要描述每个座位的属性比如是否靠窗、是否有电源、是否桌子座。接着我把座位信息提升为对象const seatRows [ [ { id: A1, window: true, power: false, table: false }, { id: B1, window: false, power: true, table: false }, aisle, { id: C1, window: false, power: true, table: false }, { id: D1, window: true, power: false, table: false } ] ];3.2 方向与座位朝向乘坐 S-Bahn 时座位朝向是一个常见又容易忽略的问题。有人喜欢“正坐”也就是面朝列车行驶方向有人觉得无所谓。但在代码里列车方向的切换会影响座位的正向与反向。如果座位只有一排要判断方向很简单座位有一个direction属性值为forward表示面朝行驶方向backward表示背朝行驶方向。当列车折返运行后原本正向的座位会变成反向。此时我们只需要整体翻转座位方向即可不需要修改每个座位的物理位置。function reverseDirection(seat) { if (typeof seat string || !seat) { return seat; } return { ...seat, direction: seat.direction forward ? backward : forward }; }需要注意在实际列车中座位可能是固定朝向也可能可以旋转。本文为了简化假设座位方向会随列车方向翻转。3.3 座位筛选维度为了让 Seat Picker 更实用我加入几个筛选条件靠窗适合看风景、减少干扰。电源插座适合办公和长时间刷手机。单人座有些 S-Bahn 车厢里会有面朝过道的单人折叠座。近门换乘方便但高峰期会拥挤。在代码中我们可以用一组Set保存当前选择的过滤条件然后遍历所有座位当座位满足所有被激活的条件时才把它显示为可选。3.4 为什么用数组而不是直接写死按钮有人可能会问如果我只需要展示 10 个座位为什么不直接在 HTML 里写死 10 个按钮在示例规模下确实可以但要考虑可维护性如果车次从 1 节车厢变成 4 节车厢直接写死按钮会非常痛苦。如果后期要接入真实列车型号数据数据驱动渲染几乎是唯一选择。通过数组数据我们可以轻松生成座位编号、计算筛选结果、批量绑定事件。因此本文选择“数据驱动 DOM”而不是“静态 DOM”。4. 完整实战案例下面从零开始实现一个可运行的 S-Bahn Seat Picker。最终页面包括顶部线路信息。车厢选择区。车厢座位可视化区。筛选条件面板。当前选中的座位信息。4.1 创建项目结构先创建目录结构mkdir -p s-bahn-seat-picker/css s-bahn-seat-picker/js然后进入目录创建index.html、css/style.css、js/data.js、js/app.js。4.2 编写 HTML 骨架文件路径index.html!DOCTYPE html html langzh-CN head meta charsetUTF-8 / meta nameviewport contentwidthdevice-width, initial-scale1.0 / titleS-Bahn Seat Picker/title link relstylesheet hrefcss/style.css / /head body main classapp header classheader h1S-Bahn Seat Picker/h1 p idlineInfo当前线路S7/p /header section classdirection-panel span行驶方向/span span iddirectionTextPotsdam → Ahrensfelde/span button idreverseBtn classbtn切换方向/button /section section classfilters h2座位偏好筛选/h2 labelinput typecheckbox>// js/data.js // 线路和车厢数据 const SBAHN_DATA { lines: [ { id: s7, name: S7, start: Potsdam, end: Ahrensfelde, cars: [ generateCar(car-1, 5), generateCar(car-2, 5), generateCar(car-3, 4) ] } ] }; // 生成车厢座位数据 function generateCar(carId, rowCount) { const seatRows []; for (let row 1; row rowCount; row) { const rowData [ { id: ${carId}-A${row}, window: true, power: row % 2 0, single: false, nearDoor: row 1 || row rowCount, direction: forward, row: row, col: A }, { id: ${carId}-B${row}, window: false, power: true, single: false, nearDoor: false, direction: forward, row: row, col: B }, aisle, { id: ${carId}-C${row}, window: false, power: true, single: false, nearDoor: false, direction: forward, row: row, col: C }, { id: ${carId}-D${row}, window: true, power: row % 3 0, single: false, nearDoor: row 1 || row rowCount, direction: forward, row: row, col: D } ]; seatRows.push(rowData); } return { id: carId, type: S-Bahn Berlin 480, seatRows: seatRows }; }这个数据函数里nearDoor被简化成“靠近首尾排”实际列车里车门位置不一定是第一排和最后一排但思路一致每个座位增加一个布尔值标记是否离门近。为了演示筛选“单人座”我再手动修改一个座位// 让 car-2 的第3排 B 座位变成单人座 const car2 SBAHN_DATA.lines[0].cars[1]; const manualRow car2.seatRows[2]; manualRow[1].single true; manualRow[1].id ${car2.id}-B3-single;这样筛选“单人座”时页面中会出现一个特殊标记的座位。4.4 编写页面样式文件路径css/style.css* { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, PingFang SC, Microsoft YaHei, sans-serif; background: #f5f6f8; color: #222; line-height: 1.6; } .app { max-width: 960px; margin: 0 auto; padding: 24px 16px; } .header h1 { font-size: 28px; margin-bottom: 4px; } .header p, .direction-panel, .selected-info p { color: #555; } .direction-panel { background: #eef2f7; padding: 12px 16px; border-radius: 8px; margin: 16px 0; display: flex; align-items: center; flex-wrap: wrap; gap: 12px; } .btn { background: #2563eb; color: #fff; border: none; padding: 8px 16px; border-radius: 6px; cursor: pointer; font-size: 14px; } .btn:hover { background: #1d4ed8; } .filters, .car-select, .car-layout, .selected-info { background: #fff; border-radius: 10px; padding: 16px; margin: 16px 0; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); } .filters h2, .car-select h2, .car-layout h2, .selected-info h2 { font-size: 18px; margin-bottom: 12px; } .filters label { margin-right: 16px; cursor: pointer; } .tabs { display: flex; gap: 8px; flex-wrap: wrap; } .tab-item { padding: 8px 16px; border: 1px solid #d1d5db; border-radius: 6px; background: #f9fafb; cursor: pointer; user-select: none; } .tab-item.active { background: #2563eb; color: #fff; border-color: #2563eb; } .carriage { border: 3px solid #333; border-radius: 12px; padding: 20px 12px; background: #ffffff; position: relative; } .carriage::before, .carriage::after { content: ; position: absolute; top: -12px; width: 12px; height: 24px; background: #333; } .carriage::before { left: 60px; } .carriage::after { right: 60px; } .seat-row { display: flex; justify-content: center; align-items: center; gap: 6px; margin-bottom: 10px; } .aisle-space { width: 28px; flex-shrink: 0; } .seat { width: 54px; height: 42px; border: 2px solid #94a3b8; border-radius: 8px; display: flex; flex-direction: column; align-items: center; justify-content: center; font-size: 12px; cursor: pointer; background: #f1f5f9; transition: background 0.2s, border-color 0.2s, transform 0.2s; } .seat:hover { border-color: #2563eb; transform: translateY(-2px); } .seat.selected { background: #2563eb; color: #fff; border-color: #2563eb; } .seat.disabled { opacity: 0.35; cursor: not-allowed; } .seat.single { border-style: dashed; } .seat .seat-icons { font-size: 10px; line-height: 1.2; }4.5 编写核心逻辑文件路径js/app.js这是项目核心我按功能拆成几部分。4.5.1 全局状态// js/app.js (function () { // 当前线路 const currentLine SBAHN_DATA.lines[0]; // 当前车厢索引 let currentCarIndex 0; // 当前行驶方向是否反向 let isReversed false; // 已选座位 let selectedSeat null; // 筛选条件集合 const filters new Set(); // 保存在 localStorage 中的座位偏好 let savedSeatId localStorage.getItem(savedSbahSeat) || ; // DOM 元素 const lineInfoEl document.getElementById(lineInfo); const directionTextEl document.getElementById(directionText); const reverseBtn document.getElementById(reverseBtn); const carTabsEl document.getElementById(carTabs); const carriageViewEl document.getElementById(carriageView); const selectedSeatTextEl document.getElementById(selectedSeatText); const saveBtn document.getElementById(saveBtn); const filterCheckboxes document.querySelectorAll([data-filter]); })();这里把所有状态集中在一个 IIFE 中避免污染全局。使用Set保存筛选条件因为增删和判断都非常方便。4.5.2 初始化数据方向列车车厢数据在导入后实际上每节车厢的座位方向都是forward。当用户点击“切换方向”时我们需要将所有车座的方向反转。为了避免每次切换都直接修改原始数据导致反复修改出问题我采用“先存原始朝向反转时更新”的方式。function getCurrentCar() { return currentLine.cars[currentCarIndex]; } function getCarDataWithDirection() { const car getCurrentCar(); const rows car.seatRows.map((row) row.map((seat) { if (seat aisle) { return aisle; } return { ...seat, direction: isReversed ? seat.direction forward ? backward : forward : seat.direction }; }) ); return { ...car, seatRows: rows }; }这里要注意原始数据里的座位方向都是forward假设用户切换了方向isReversed 变为 true我们就把每个座位渲染成backward。如果连续点击两次isReversed 变回 false方向也恢复。4.5.3 渲染车厢标签页function renderCarTabs() { const cars currentLine.cars; carTabsEl.innerHTML ; cars.forEach((car, index) { const tab document.createElement(div); tab.className tab-item (index currentCarIndex ? active : ); tab.textContent ${index 1} 号车厢; tab.addEventListener(click, () { currentCarIndex index; selectedSeat null; renderCarTabs(); renderCarriage(); updateSelectedInfo(); }); carTabsEl.appendChild(tab); }); }点击车厢标签后重新渲染标签页和车厢图同时清空当前选中的座位。4.5.4 渲染车厢座位图function renderCarriage() { const car getCarDataWithDirection(); carriageViewEl.innerHTML ; car.seatRows.forEach((row, rowIndex) { const rowDiv document.createElement(div); rowDiv.className seat-row; row.forEach((seat) { if (seat aisle) { const aisle document.createElement(div); aisle.className aisle-space; rowDiv.appendChild(aisle); return; } const seatDiv document.createElement(div); seatDiv.className seat; seatDiv.dataset.seatId seat.id; // 根据筛选条件判断是否可用 const matchFilter checkFilter(seat); if (!matchFilter) { seatDiv.classList.add(disabled); seatDiv.title 不符合当前筛选条件; } else if (savedSeatId seat.id) { seatDiv.classList.add(saved); seatDiv.title 已保存偏好座位; } if (selectedSeat selectedSeat.id seat.id) { seatDiv.classList.add(selected); } if (seat.single) { seatDiv.classList.add(single); } const text document.createElement(div); text.textContent ${seat.row}${seat.col}; seatDiv.appendChild(text); const icons document.createElement(div); icons.className seat-icons; const iconParts []; if (seat.window) iconParts.push(窗); if (seat.power) iconParts.push(电源); if (seat.single) iconParts.push(单人); if (seat.nearDoor) iconParts.push(近门); if (isReversed) iconParts.push(seat.direction backward ? 反向 : 正向); icons.textContent iconParts.join(·); seatDiv.appendChild(icons); rowDiv.appendChild(seatDiv); }); carriageViewEl.appendChild(rowDiv); }); }在这段代码里每个座位都渲染成一个div.seat内部显示座位行列号和属性图标。这里只使用了文本标识后续可以替换成小图图标。4.5.5 筛选逻辑function checkFilter(seat) { if (filters.size 0) { return true; } for (const filter of filters) { if (filter window !seat.window) return false; if (filter power !seat.power) return false; if (filter single !seat.single) return false; if (filter nearDoor !seat.nearDoor) return false; } return true; }筛选逻辑是“与”关系所有被激活的筛选条件都必须满足。如果没有任何筛选条件则座位全部可用。4.5.6 座位点击事件因为车厢图是动态生成的所以最合适的方式是事件委托。我在carriageView容器上绑定点击事件通过closest(.seat)找到被点击的座位然后读取dataset.seatId。carriageViewEl.addEventListener(click, (e) { const seatEl e.target.closest(.seat); if (!seatEl) return; if (seatEl.classList.contains(disabled)) return; const seatId seatEl.dataset.seatId; selectedSeat findSeatById(seatId); renderCarriage(); updateSelectedInfo(); }); function findSeatById(seatId) { for (const car of currentLine.cars) { for (const row of car.seatRows) { for (const seat of row) { if (seat ! aisle seat.id seatId) { return { ...seat, carId: car.id }; } } } } return null; }4.5.7 更新已选信息function updateSelectedInfo() { if (!selectedSeat) { selectedSeatTextEl.textContent 尚未选择座位; return; } const directionText isReversed ? (selectedSeat.direction backward ? 背向 : 正向) : (selectedSeat.direction forward ? 正向 : 背向); selectedSeatTextEl.textContent 已选择${selectedSeat.carId} - ${selectedSeat.row}${selectedSeat.col} 方向${directionText} (selectedSeat.window ? 靠窗 : ) (selectedSeat.power ? 有电源 : ) (selectedSeat.single ? 单人座 : ); saveBtn.disabled false; }4.5.8 反向切换reverseBtn.addEventListener(click, () { isReversed !isReversed; const from currentLine.start; const to currentLine.end; directionTextEl.textContent isReversed ? ${to} → ${from} : ${from} → ${to}; // 清空已选座位避免方向变化后状态冲突 selectedSeat null; renderCarriage(); updateSelectedInfo(); });4.5.9 筛选事件绑定filterCheckboxes.forEach((checkbox) { checkbox.addEventListener(change, () { const key checkbox.dataset.filter; if (checkbox.checked) { filters.add(key); } else { filters.delete(key); } selectedSeat null; renderCarriage(); updateSelectedInfo(); }); });4.5.10 保存偏好saveBtn.addEventListener(click, () { if (!selectedSeat) return; localStorage.setItem(savedSbahSeat, selectedSeat.id); savedSeatId selectedSeat.id; renderCarriage(); updateSavedBadge(); }); function updateSavedBadge() { if (!savedSeatId) return; const savedBadge document.getElementById(savedBadge); if (savedBadge) { savedBadge.textContent 已保存偏好${savedSeatId}; } }为了在页面中显示保存状态我在index.html中补一个提示标签并增加savedBadge的 DOM 引用。打开index.html在selected-info区块中增加p idsavedBadge stylecolor: #16a34a; margin-top: 8px;/p在app.js顶部补上const savedBadgeEl document.getElementById(savedBadge);在updateSavedBadge中改用savedBadgeEl.textContent。4.5.11 初始化入口function init() { lineInfoEl.textContent 当前线路${currentLine.name}; directionTextEl.textContent ${currentLine.start} → ${currentLine.end}; renderCarTabs(); renderCarriage(); updateSelectedInfo(); updateSavedBadge(); } init();4.6 运行与验证在浏览器中直接打开index.html页面会展示当前线路 S7。默认站点方向是 Potsdam → Ahrensfelde。三节车厢标签默认选中 1 号车厢。车厢座位图显示 22 座位布局过道在中间。点击某个座位座位会高亮底部显示座位详情。勾选“靠窗”后不靠窗的座位变成灰色不可点击。点击“切换方向”站点方向反转座位方向文本也会改变。点击“保存座位偏好”后刷新页面依然能保留偏好座位。5. 常见问题与排查思路5.1 页面没有显示座位图问题现象常见原因解决思路打开页面后车厢区域空白data.js中generateCar函数报错打开浏览器控制台查看是否有语法错误确认seatRows是否正常生成只显示标签不显示座位app.js初始化顺序不对确认data.js在app.js之前加载点击标签页无反应事件绑定漏掉renderCarTabs检查renderCarTabs中的addEventListener是否正确绑定5.2 筛选条件没有生效如果你勾选“靠窗”后页面没有变化先看是否在app.js中完成了checkFilter逻辑。另一个常见问题是座位数据没有window属性。请检查data.js中生成的每个座位对象是否包含window: true/false。5.3 座位方向显示不对默认情况下所有座位方向都是forward。点击“切换方向”后isReversed变为true渲染时会把方向改成backward。如果你发现方向没有变化优先检查getCarDataWithDirection函数是否返回了新的car对象并且renderCarriage是否读取的是getCarDataWithDirection()的结果。5.4 localStorage 偏好无法保存浏览器隐私模式下localStorage 可能不可用。请在普通模式下测试。另外确认你保存的是座位 ID而不是座位对象。5.5 控制台报错Cannot read property forEach of undefined通常是SBAHN_DATA.lines[0]没有正确初始化。你可以先在控制台输入SBAHN_DATA查看结构确认cars数组存在。5.6 移动端点击区域太小座位div默认只有 54px × 42px在手机上会偏小。可以增加media querymedia (max-width: 600px) { .seat { width: 40px; height: 34px; font-size: 10px; } .aisle-space { width: 16px; } }6. 最佳实践与工程建议6.1 数据与渲染分离这个项目最核心的收获是“数据驱动渲染”。以后做更复杂的座位图只需要修改data.js中的结构渲染逻辑基本不用动。如果将来要接入真实列车数据也可以从接口拿 JSON 数据再映射成项目中的seatRows结构。6.2 多车厢状态管理当前实现里currentCarIndex、isReversed、selectedSeat都是全局状态放在 IIFE 内部已经避免了对window的污染。如果项目继续扩大建议用类或函数式状态管理进行封装。例如class SeatPickerApp { constructor(data) { this.data data; this.currentCarIndex 0; this.isReversed false; this.selectedSeat null; } }6.3 事件委托优于逐个绑定如果每节车厢有 20 个座位三节车厢就是 60 个座位节点。逐个绑定不会出问题但事件委托更统一只需要在父容器上绑定一次后续重新渲染不需要重复绑定。这也是前端性能优化的常见手段之一。6.4 筛选逻辑建议做防抖目前筛选条件是即时渲染数据量不大没有关系。如果未来从接口加载大量座位数据建议在复选框变化时做“防抖”或者合并渲染避免每勾选一个都触发一次完整 DOM 重建。let filterTimer; filterCheckboxes.forEach((checkbox) { checkbox.addEventListener(change, () { clearTimeout(filterTimer); filterTimer setTimeout(() { // 更新筛选并渲染 applyFilters(); }, 150); }); });6.5 考虑无障碍可访问性座位div不天然具备键盘操作能力。为了让工具更易用可以加上rolebutton、tabindex0并监听Enter键。这是工程化项目里容易被忽略但很重要的一点。seatDiv.setAttribute(role, button); seatDiv.setAttribute(tabindex, 0); seatDiv.addEventListener(keydown, (e) { if (e.key Enter) { seatDiv.click(); } });6.6 生产环境建议如果座位数据量大建议在后端做筛选前端只负责展示。如果涉及真实购票系统必须加入用户信息和座位锁定的后端逻辑不能只在前端处理。关于用户座位偏好应区分“本设备偏好”和“账号云端同步”。localStorage 只适合前者。7. 总结与学习路线这篇文章实现了一个轻量级的 S-Bahn 座位选择工具核心不在于代码量而在于你把“车厢座位”抽象成二维数组后如何围绕数据进行渲染、筛选、方向变化和本地存储。通过这个小项目你应该掌握了用二维数组建模现实场景。数据驱动 DOM 渲染。事件委托处理动态节点。Set管理筛选条件。localStorage 实现简单持久化。如果想继续深入可以尝试以下方向用 Vue 或 React 重写本项目体验声明式渲染带来的变化。给座位增加更多真实属性比如桌子座位、双层车厢、自行车区域。把数据源改成从 JSON 文件或真实列车 API 加载加入异步处理。加入座位锁定的倒计时逻辑实现类似于影院选座的抢座效果。S-Bahn Seat Picker 听起来是一个很小众的工具但它背后其实串起了前端开发中非常核心的知识点数据建模、状态切换、条件渲染、用户偏好存储。如果你能把它做得完整、干净、易扩展放在简历项目里也是一个很有说服力的作品。
返回列表