☰
Vue + Element UI 机票列表渲染实战:数据请求、筛选排序与分页全解析
2026/10/13 11:57:05 网站建设 项目流程

闲云旅游网这个系列写到第04篇,终于轮到机票数据列表渲染了。前面的章节已经把Vue + Element UI的整体骨架搭起来了,首页、搜索框都弄得像模像样,但说实话,那种页面还没有太多业务逻辑。机票列表不一样,它直接面对用户的决策场景:航线信息清不清楚、价格有没有吸引力、筛选好不好用,基本决定了一个旅游网站的转化率。这篇就把这个核心模块彻底拆开,从接口数据到页面渲染,再到筛选排序分页,一步一步走一遍。

我说这道题值得单独写一篇,是因为“列表渲染”四个字说出来简单,真正动手时会碰到一堆细节:loading什么时候出现,空数据怎么展示,接口报错怎么兜底,筛选之后分页要不要重置,日期时间在手机上为什么会显示成NaN。这篇文章适合刚学完Vue基础、准备做真实前端项目的人,也适合那些已经把Element UI组件用过一遍、但总觉得列表页面做得不够顺手的人。

1. 需求拆解与技术选型:“列表渲染”到底在渲染什么

1.1 一个机票列表页的完整状态清单

很多人听到“机票数据列表渲染”,第一反应就是把后端返回的数组用v-for循环出来,往模板里一放就完事。实际做过业务的人都知道,这种想法太乐观了。一个真正能对上线的列表页,起码要包含以下状态:

  • 数据请求中:用户不能对着空白页面干等,要有loading反馈。
  • 数据加载成功:列表正常展示,航班信息、价格、时间都要清晰可读。
  • 数据为空:比如用户选了某个极端筛选条件,页面得提示“没有符合条件的航班”,不能直接空荡荡。
  • 接口请求失败:可能是后端崩了,也可能是网络断了,这时候要告诉用户“加载失败”,而不是让页面一直转圈。
  • 筛选条件变化后:列表要实时刷新,分页页码要重置到第一页。
  • 排序操作:价格升序、降序、时间排序,列表顺序要正确更新。

说白了,一个列表页的核心工作不是“渲染”,而是“数据流的正确管理”。从接口拿到原始数据之后,先过滤、再排序、再分页,最后渲染出来。每一步都不能乱,顺序一旦搞错,结果就会出问题。

我经常把这个过程类比成“餐厅出餐”:后厨采购回来的食材是原始数据,客人点餐时选了“不要辣”就是过滤,要求“先上凉菜”是排序,一桌坐不下再分批上菜是分页。前端做的事情就是把这一整套流程串起来,让客人(用户)拿到他想要的菜(列表结果)。

1.2 为什么是Vue + Element UI这套组合

这个项目用的是Vue 2 + Element UI,一个是国内前端圈普及率极高的框架,一个是配合Vue 2生态使用最广泛的桌面端组件库。这两者搭配的优势可以概括成一句话:常用功能基本不用自己造轮子。

列表页涉及的el-card、el-row、el-col、el-button、el-tag、el-pagination、el-empty,甚至el-table等组件,Element UI全部内置。哪怕你不想用组件库自带的结构,组件库提供的样式变量、尺寸体系、交互规范也能让页面风格保持统一,省掉了大量CSS细节的调试时间。

可能有人会问,现在Vue 3 + Element Plus都普及了,为什么还在聊Vue 2?一个是存量项目确实多,很多团队线上业务还在维护Vue 2;另一个是Element UI的API设计和列表页开发思路,和Element Plus几乎是一脉相承的,你把这里讲清楚的东西迁移到Vue 3项目里,成本很低。所以这篇文章的思路和代码,放在Element Plus版本里同样有参考价值。

1.3 表达形态之争:el-table还是自定义卡片

机票列表有两种常见的渲染形态。

第一种是用el-table:表格天生适合展示结构化数据,字段多的时候整整齐齐,分页、排序也有配套方案。但它更适合后台管理类页面,比如运营查看所有航班、修改价格、上下架,这种场景用户需要的是“信息密度高、操作方便”。

第二种是自定义卡片:一张卡片承载一个航班的信息,顶部放航空公司和航班号,中间放出发到达城市和时间,下面放价格和预订按钮。这是旅游网站C端页面的主流设计,视觉层次分明,用户能一眼看到重点。

闲云旅游网面向的是普通游客,最终目的是让用户下单预订,所以我选了卡片式渲染。我用Element UI的el-row和el-col做栅格布局,里面的内容用div自由编排。这里我不打算用el-table,并不是说表格不好,而是要分场景:给用户看的页面,少一点表格感,多一点“选择感”,转化效果会更好。

2. 数据结构先行:机票字段设计与Mock方案

2.1 机票数据的字段设计:现在够用,留好扩展

开发列表页之前,先把数据字段定下来。机票信息字段很多,但如果一开始就把所有可能的字段全部塞进一个对象里,不仅Mock麻烦,后面联调时还容易跟前端对不上号。

我根据页面需要设计了这样一组字段:

字段名类型说明
idNumber唯一标识,作为循环渲染的key
airlineString航空公司名称
flightNoString航班号
departCityString出发城市
departAirportString出发机场及航站楼
arriveCityString到达城市
arriveAirportString到达机场及航站楼
departTimeString出发时间,格式YYYY-MM-DD HH:mm:ss
arriveTimeString到达时间
durationNumber飞行时长,单位分钟
cabinString舱位,例如经济舱、商务舱
priceNumber原价
discountPriceNumber折扣价,展示给用户
mealBoolean是否含餐
luggageString行李额,例如20kg
seatsNumber余票数量
scoreNumber航班评分

有人会问,为什么价格要分成原价和折扣价两个字段?因为列表页上为了吸引用户,经常要展示一个划线原价,再展示一个红色折扣价。这个是一眼看上去的促销逻辑。你可以在后端接口里直接把这两个字段返回,也可以只传原价,由前端计算折扣,但我建议接口返回时就给全,前端的职责是渲染和交互,不是业务计算。

另外还需要注意时间的字段格式。机票列表页展示时只需要“时分”,但排序时要按完整的日期时间来处理。所以我统一用YYYY-MM-DD HH:mm:ss这样的字符串,前端拿到后再做格式化。

2.2 本地Mock还是在线接口:先定接口再定联调方式

在实际开发中,后端接口不一定已经就绪。我习惯的做法是:先在public目录下建一个mock文件夹,放一个flights.json文件,里面的数据结构就按约定好的接口响应来写。这样做的好处是,后面联调时只需要把请求地址从/mock/flights.json换成真实接口地址,前端组件的逻辑一行都不用改。

这种Mock方式比在js文件里写死一个数组更接近真实场景。因为你是用axios发请求,走的是网络链路,loading、错误处理这些效果都能真实地模拟出来。如果直接在组件里 import 一个数组,很多网络层面的边界问题根本暴露不出来。

给你看一份Mock数据的片段:

[ { "id": 1, "airline": "云际航空", "flightNo": "YJ7101", "departCity": "北京", "departAirport": "首都T3", "arriveCity": "上海", "arriveAirport": "虹桥T2", "departTime": "2024-09-02 08:30:00", "arriveTime": "2024-09-02 10:45:00", "duration": 135, "cabin": "经济舱", "price": 1180, "discountPrice": 760, "meal": true, "luggage": "20kg", "seats": 9, "score": 4.6 }, { "id": 2, "airline": "晨曦航空", "flightNo": "CX2806", "departCity": "北京", "departAirport": "大兴", "arriveCity": "广州", "arriveAirport": "白云T2", "departTime": "2024-09-02 12:20:00", "arriveTime": "2024-09-02 15:40:00", "duration": 200, "cabin": "经济舱", "price": 1450, "discountPrice": 920, "meal": false, "luggage": "20kg", "seats": 15, "score": 4.2 } ]

字段按2.1的表来写,Mock数据不要只写两三条,至少准备10条左右,方便后面测试分页和筛选效果。

2.3 接口返回格式与axios封装

前后端联调时,接口返回格式最好在一开始就约定好。我惯用的格式是:

{ "code": 200, "data": { "list": [], "total": 0 }, "message": "success" }

code表示业务状态,data里放列表数据和总条数,message给后端返回错误信息。这种结构的好处是清晰、统一,前端封装请求实例后,可以在拦截器里统一处理业务错误,页面里只需要关心数据就行。

axios封装我一般会单独建一个request.js文件,基本配置包括基础路径、超时时间、请求拦截器和响应拦截器。响应拦截器里会做一层判断:如果code不是200,就弹出错误提示;如果确实是200,就直接把data部分返回给调用处。这样组件里调用接口时,拿到的东西就是列表数据本身,代码会清爽很多。

import axios from 'axios' const service = axios.create({ baseURL: '/', timeout: 10000 }) service.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { return Promise.reject(new Error(res.message || '请求失败')) } return res.data }, error => { return Promise.reject(error) } ) export default service

需要说明的是,baseURL在实际开发中通常会根据环境变量切换。本地开发时走Mock文件路径,测试环境走测试后端地址,生产环境走正式接口。这里先用最简单的方式,重点是让你理解封装思路。

3. 核心实现:从请求数据到页面渲染

3.1 请求封装与数据装载:created钩子里拉数据

我新建了一个views/flight/list.vue文件,把机票列表页的业务逻辑都收在这个组件里。组件初始化后,需要在第一时间去拉取数据,这时候要选一个生命周期钩子。

Vue 2里可以在created或者mounted里发请求。两者区别在于created在实例创建后立即调用,此时DOM还没渲染;mounted在DOM挂载完成后调用。列表页的逻辑是数据驱动渲染,不需要等DOM先出来,所以我放在created里,让请求更早发出,用户等待时间更短。

data() { return { flightList: [], loading: false, page: 1, pageSize: 9, priceRange: '', timeRange: '', sortBy: 'default' } }, created() { this.fetchFlightList() }, methods: { fetchFlightList() { this.loading = true service.get('/mock/flights.json') .then(res => { this.flightList = res.list }) .catch(() => { this.$message.error('航班数据加载失败,请稍后重试') }) .finally(() => { this.loading = false }) } }

loading这个状态很重要。请求发出时置为true,请求结束无论成功失败都要置回false。我建议用finally来收尾,而不是在then和catch里各写一遍,避免漏掉某个分支导致页面一直转圈。

3.2 卡片列表模板:用el-card搭出航班信息

卡片式的机票列表,我最终是这样设计模板的:外层用el-row和el-col做响应式栅格,让桌面端一行显示三个卡片,平板显示两个,手机端显示一个。每个航班信息放在一张el-card里,再结合el-tag、el-button这些组件丰富交互。

<div v-loading="loading" element-loading-text="拼命加载中..." class="flight-list"> <el-row :gutter="16"> <el-col v-for="flight in shownList" :key="flight.id" :xs="24" :sm="12" :md="8"> <el-card class="flight-card" shadow="hover"> <div class="flight-header"> <span class="flight-no">{{ flight.flightNo }}</span> <el-tag size="mini" type="primary">{{ flight.airline }}</el-tag> </div> <div class="route"> <div class="city"> <p class="time">{{ formatTime(flight.departTime) }}</p> <p class="airport">{{ flight.departCity }} {{ flight.departAirport }}</p> </div> <div class="line"> <span>{{ formatDuration(flight.duration) }}</span> </div> <div class="city right"> <p class="time">{{ formatTime(flight.arriveTime) }}</p> <p class="airport">{{ flight.arriveCity }} {{ flight.arriveAirport }}</p> </div> </div> <div class="meta"> <span>{{ flight.cabin }}</span> <span>{{ flight.meal ? '含餐' : '不含餐' }}</span> <span>行李{{ flight.luggage }}</span> <span>余票{{ flight.seats }}</span> </div> <div class="footer"> <div class="price"> <span class="symbol">¥</span> <span class="num">{{ formatPrice(flight.discountPrice) }}</span> <span class="origin">¥{{ formatPrice(flight.price) }}</span> </div> <el-button type="primary" size="small" @click="handleBook(flight)">预订</el-button> </div> </el-card> </el-col> </el-row> <el-empty v-if="!loading && shownList.length === 0" description="没有符合条件的航班" /> </div>

这里有个细节我特别想强调:key一定要用唯一且稳定的值。flight.id是首选,千万不要用数组下标当key,因为列表一旦发生增删或排序,虚拟DOM复用会出各种奇怪的问题。

模板里只负责展示,不要写复杂的表达式。像formatTime、formatPrice、formatDuration这些都应该抽到methods里,保持模板的整洁和可读性。

3.3 格式化函数:时间、价格、舱位的显示规范

列表页的展示细节决定了用户的第一印象。我在项目里写了三个格式化方法:

methods: { formatTime(time) { if (!time) return '' const normalized = time.replace(/-/g, '/') const date = new Date(normalized) const hh = String(date.getHours()).padStart(2, '0') const mm = String(date.getMinutes()).padStart(2, '0') return `${hh}:${mm}` }, formatDuration(minutes) { if (!minutes) return '' const h = Math.floor(minutes / 60) const m = minutes % 60 return h > 0 ? `${h}小时${m}分` : `${m}分钟` }, formatPrice(value) { return new Intl.NumberFormat('zh-CN').format(value) } }

这里为什么要先replace(/-/g, '/')?这是一个非常关键的兼容性问题,我在后面的踩坑章节会展开讲。现在先告诉你结论:iOS系统的JavaScript引擎对YYYY-MM-DD HH:mm:ss这种日期字符串解析存在兼容问题,换成YYYY/MM/DD HH:mm:ss格式后解析就稳定了。

价格格式化用了Intl.NumberFormat,这是浏览器内置的国际化API,能自动处理千分位分隔。比如760会自动变成“760”,1450会变成“1,450”,9000会变成“9,000”,比手写正则简单方便,代码也更干净。

3.4 完整渲染逻辑脚本:筛选排序分页一条链

机票列表页最核心的计算逻辑,我全部放在computed里。computed的特点是依赖数据变化时会自动重新计算,并且有缓存,性能比在methods里每次手动调函数要好。

computed: { filteredList() { let list = this.flightList.slice() if (this.priceRange) { const [min, max] = this.priceRange.split('-').map(Number) list = list.filter(item => { if (max) { return item.discountPrice >= min && item.discountPrice < max } return item.discountPrice >= min }) } if (this.timeRange) { const rangeMap = { morning: [6, 12], afternoon: [12, 18], evening: [18, 24] } const [start, end] = rangeMap[this.timeRange] list = list.filter(item => { const hour = this.getHour(item.departTime) return hour >= start && hour < end }) } return list }, sortedList() { const list = this.filteredList.slice() if (this.sortBy === 'priceAsc') { list.sort((a, b) => a.discountPrice - b.discountPrice) } else if (this.sortBy === 'priceDesc') { list.sort((a, b) => b.discountPrice - a.discountPrice) } else if (this.sortBy === 'timeAsc') { list.sort((a, b) => { const timeA = new Date(a.departTime.replace(/-/g, '/')).getTime() const timeB = new Date(b.departTime.replace(/-/g, '/')).getTime() return timeA - timeB }) } return list }, total() { return this.filteredList.length }, shownList() { const start = (this.page - 1) * this.pageSize return this.sortedList.slice(start, start + this.pageSize) } }

这套链路的顺序是固定的:先在filteredList里做过滤,然后在sortedList里做排序,最后在shownList里做分页切片。三个computed层层依赖、各司其职,逻辑非常清晰。

我特别强调一点:flightList.slice()这个拷贝动作不能省。JavaScript的sort和filter不一样,filter返回新数组,但splice和sort会修改原数组。如果不先slice(),排序就会直接改掉flightList,下次筛选时原始数据就乱了。我一开始开发时偷懒没写,结果筛选一次后顺序彻底错乱,排查了半天才发现是这个问题。

4. 交互细节:筛选、排序、分页、状态反馈怎么配合

4.1 筛选条件改变后,分页必须回到第一页

列表页的筛选器通常放在列表上方。我用el-radio-group做价格区间选择,用el-select做时段筛选和排序选择。这些组件绑定到data里的字段后,用户每次切换,对应数据就会变化。

但有一个很容易漏掉的交互细节:筛选条件改变后,分页必须回到第一页。试想一个场景,用户当前翻到第3页,此时切换到“500元以下”的筛选条件,如果还停留在第3页,很可能这一页没有数据,用户就会看到一片空白,以为筛选出问题了。

我通过一个watch来重置页码:

watch: { priceRange() { this.page = 1 }, timeRange() { this.page = 1 }, sortBy() { this.page = 1 } }

筛选条件一变,页码归位,列表从第一页开始展示。这个细节很小,但对用户体验的提升非常明显。

4.2 排序逻辑放在computed里,不污染原始数据

排序看似简单,其实也有讲究。我见过不少项目把排序写在点击事件里,直接对this.flightList做sort,这样写会导致原始列表顺序被永久改变,后面要恢复默认顺序就得重新请求接口。

正确的姿势是把排序放在computed里,只对过滤后的数据副本做排序。点击排序按钮时,只需修改sortBy这个状态,页面会自动重新计算。默认排序时,列表维持接口返回的顺序;价格升序时按折扣价从低到高排;价格降序则反过来。整个过程完全不触碰原始数据。

4.3 分页组件与列表数据联动

Element UI的el-pagination组件用起来很顺手,但我第一次用时也踩了一个小坑:current-page和page-size的绑定方式,以及事件触发的时机。

我最终是这样写的:

<el-pagination v-if="!loading && total > 0" class="pagination" :current-page.sync="page" :page-size.sync="pageSize" :total="total" layout="total, sizes, prev, pager, next" @size-change="handleSizeChange" @current-change="handlePageChange" />

对应的methods:

methods: { handleSizeChange(val) { this.pageSize = val this.page = 1 }, handlePageChange(val) { this.page = val } }

这里current-page和page-size用了.sync修饰符,这样组件内部改变页码时,会同步到data里的page。page-size改变时,列表每页展示的条数变了,总页数也会变,所以必须把页码重置为1。

total我没有放在data里,而是放在computed里直接返回filteredList.length。这样筛选条件变化后,总条数会跟着自动更新,不需要手动维护。一开始我把total放在data里,每次筛选完还要手动去算一下长度再赋值,完全是自己给自己找麻烦。

4.4 加载中、空结果、请求失败的三种页面状态

页面不能只有“数据正常”这一种状态。三个高频场景必须覆盖:

第一是加载中。我在列表容器上加了v-loading指令,请求期间显示一个默认的加载遮罩,并且可以自定义加载文案。用指令的优点是省事,不需要自己写条件渲染的loading结构,Element UI都处理好了。

第二是空结果。筛选条件过于严格时,列表数据会为空。这时候用el-empty组件展示一个“没有符合条件的航班”的空状态。注意判断条件是!loading && shownList.length === 0,等loading结束后才显示,否则请求过程中也会闪一下空状态。

第三是接口失败。我在请求的catch里用this.$message.error弹了个错误提示,同时页面会显示空状态。这里还可以做一层兜底,比如在空状态区域展示一个“重新加载”按钮,点击后重新调用fetchFlightList。我建议加上这个按钮,因为用户遇到失败时,第一反应就是刷新页面,与其让他整个页面刷新,不如给他一个局部的重试入口。

5. 踩坑实录:机票列表渲染绕不开的5个问题

5.1 时间显示NaN:iOS对日期字符串的解析差异

这是我做机票列表时遇到的第一个大坑。在桌面浏览器上,new Date('2024-09-02 08:30:00')能正常解析出时间对象,但同样的代码在iOS的Safari上,会返回一个Invalid Date,导致后续getHours()全部变成NaN,页面上显示出一堆“NaN:NaN”。

原因是iOS的JavaScript引擎只支持YYYY/MM/DD HH:mm:ss这种斜杠分隔的日期格式,对横杠分隔符的解析规则和桌面浏览器不一样。解决方案也简单:解析之前先把字符串里的横杠替换成斜杠。

const normalized = time.replace(/-/g, '/') const date = new Date(normalized)

这个替换操作性能开销几乎可以忽略,但能避免一大类兼容性问题。凡是涉及日期解析的地方,我都建议统一做一次转换。

5.2 筛选和排序的顺序不能反

逻辑链路是“先筛选,后排序,再分页”。如果把这个顺序搞反了,比如先排序再筛选,结果不会报错,但会存在一些隐性Bug。

举个例子:原始数据里有10条航班,符合当前筛选条件的只有4条。如果先排序再筛选,排序可能会把这4条符合条件的记录排到数组末尾,筛选之后,你拿到的数据顺序可能依然是“乱”的,或者最终展示的数据集合不对。更重要的是,先筛选后,排序的数据量变小了,排序效率更高,逻辑也更贴近业务直觉。

还有个隐藏问题:分页是对排序后的结果进行的。如果先分页再排序,那每一页的数据集合就固定了,排序只能影响当前页内部顺序,翻页后顺序就不连贯了。所以链路的正确顺序特别重要,这也是我在computed里一层层拆分的原因。

5.3 数据明明请求回来了,页面就是空白的

这个问题排查起来最让人崩溃。接口返回正常,控制台看flightList也有值,但页面上一个卡片都没有。我遇到过的原因主要有这么几种:

第一种是数据赋值路径不对。比如接口返回的数据结构是res.data.list,但组件里用了res.data,把整个对象赋给了数组,v-for遍历一个对象时往往渲染不出来。

第二种是模板字段名不一致。接口里是flightNo,模板里写成了flightNumber,数据匹配不上,页面自然空白。这类问题在Mock阶段就要注意,用真实字段名,不用简写。

第三种是异步时序问题。如果请求完成后忘了把loading置为false,v-loading的遮罩会一直挡在页面上,看起来就像页面没渲染。

排查思路我总结成一个三步走:

  • 打开浏览器网络面板,确认接口返回了200状态码,并且响应体结构跟预期一致。
  • 打开Vue Devtools,看组件里的数据字段,确认flightList是一个数组并且长度大于0。
  • 看模板绑定和computed逻辑,确认shownList拿到数据没有返回空数组。

90%以上的列表渲染问题,走完这三步都能定位。

5.4 接口慢时用户反复点击分页

分页点击后,如果接口响应很慢,用户看到页面没变化,会忍不住再点几页。每一次点击都触发一次请求,后端被无效请求打满,前端也会出现数据错乱的风险:比如用户先点了第3页,又点了第5页,最后第3页的响应后返回,页面却停在第5页上。

我的处理方式是:请求期间通过loading状态锁住分页交互。一是在分页组件外面判断!loading才显示,二是在分页的点击事件里加一层保护,如果当前loading为true就直接return。更彻底的做法是使用axios的CancelToken,在发起新请求之前取消上一个未完成的请求。对于这个项目,先用loading锁住交互就够了,代码简单,效果也明显。

5.5 定位渲染问题的方法论:先看数据,再看模板

最后分享一个我自己的排查习惯。很多新手在页面渲染异常时,第一反应就是改模板,各种class、结构换一遍,最后发现是数据的问题,白费半天劲。

我的原则是:先确认数据对不对,再怀疑模板有没有问题。数据是渲染的源头,源头的结构不对,模板写得再漂亮也没用。你可以先在fetchFlightList的then里打印一下res.list,确认结构,再赋值给flightList;然后在computed最末尾的shownList里打印一下返回结果;最后才去看模板里的v-for、字段名和样式。从数据链路往下游查,每一步都能定位问题出在哪一层,比自己瞎猜高效得多。

6. 个人心得:做完机票列表后我对列表页的几点新认识

这个机票列表功能做完之后,我对列表页的态度有了明显变化。以前总觉得列表渲染是前端最基础的活儿,不值得花太多心思。真正动手做完才发现,一个列表页的设计质量,往往决定了整个项目的开发体验和用户转化。

我现在做列表页有个习惯,动手写代码之前,先默写一遍“状态清单”:加载中、有数据、无数据、请求失败、筛选无结果,每个状态都要有对应的UI呈现。这个清单看起来朴素,但能逼着你在开发的初期就把边界情况想清楚,而不是等到测试提Bug了才去补。

另一个体会是,数据流链路“取数-过滤-排序-分页”这套模式实在太常用了。机票列表是这样,酒店列表、景点门票列表、订单列表,哪怕换个业务域,逻辑骨架都一样。我后来把这些通用逻辑抽成了一个通用的列表子组件,父组件只需要传接口地址和字段配置,子组件内部统一处理loading、分页、筛选,后续再开发类似页面时效率提升了非常多。

最后分享一个我自己觉得特别实用的小技巧:在做机票列表Card布局时,价格部分不要只放一个数字,最好把“原价划线+折扣价红字”放在一起。用户对价格的敏感度远远高于其他信息,这种视觉对比能有效提高点击率和下单意愿。我从这个项目之后做所有电商类列表页,都保留了这条设计经验。

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

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

立即咨询