第3章 3.5 综合实战:仿掘金首页 + 列表
🎯 开场:为什么你需要一个「列表页」技能?
上一章我们学会了用 uni-ui 官方组件库快速搭界面,是不是感觉「搭页面」这件事变得超简单了?
但你有没有遇到过这种情况:
- 想做个新闻列表页,数据从哪来?
- 写好了列表,但下拉刷新、上拉加载更多怎么做?
- 几百条数据一次性加载,页面卡成 PPT 怎么办?
如果你点头了,那今天这章就是为你量身定制的。
学完这章,你将掌握:如何从 API 拉数据 → 渲染成列表 → 加上刷新生效。这个「列表页三件套」是所有 App 最核心的技能,没有之一。
🧱 基础:把「列表页」拆解成 3 个积木
想象你要开一家奶茶店,需要:原料仓库(数据源)、操作台(处理逻辑)、出餐口(渲染展示)。列表页也是一样的道理。
3.5.1 数据从哪来?—— 认识 API 请求
是什么:API 就是「数据快递柜」,你发个请求,它返回你要的数据。
生活类比:点外卖 = 你向餐\n\n
\n\n
\n\n厅发请求 → 餐厅准备好 → 骑手送到你手里。API 请求就是这个流程的「手机版」。
uni-app 中请求接口用的是 uni.request(),一句话就能调:
uni.request({
url: 'https://jsonplaceholder.typicode.com/posts', // 假装这是掘金的接口
success: (res) => {
console.log('数据拿到了:', res.data)
}
})
这行代码干了啥:向这个 URL 发送请求,成功后把返回的数据打印出来。
3.5.2 数据怎么存?—— 状态管理
是什么:页面加载的数据要「存」起来,不能每次用完就丢。
生活类比:你的购物车——加进去的商品会一直保存在车里,不会刷新一下页面就空了。
uni-app 用 data() 函数存放数据,用 this.setData() 更新数据:
export default {
data() {
return {
articleList: [], // 文章列表,空数组等着装数据
page: 1 // 当前第几页,用于分页加载
}
},
methods: {
loadMore() {
// 模拟加载数据
const newData = [
{ id: 1, title: '深入理解 JavaScript 闭包', author: '李明', likes: 520 },
{ id: 2, title: 'Vue3 响应式原理剖析', author: '王芳', likes: 320 }
]
// 关键:新数据要追加,不是覆盖
this.articleList = [...this.articleList, ...newData]
this.page++
}
}
}
这里 articleList 就是你的「购物车」,... 是展开运算符,意思是「把原来的数据和新数据合并」。
3.5.3 列表怎么画?—— v-for 循环渲染
是什么:拿到一组数据,要一个个展示出来,就需要循环渲染。
生活类比:食堂阿姨打饭——一锅饭(数据数组)要一勺一勺盛到每个碗里(每个列表项)。
用 v-for 指令可以轻松实现:
<view v-for="article in articleList" :key="article.id" class="article-item">
<text class="title">{{ article.title }}</text>
<view class="meta">
<text>{{ article.author }}</text>
<text>👍 {{ article.likes }}</text>
</view>
</view>
v-for="article in articleList" 意思是:遍历 articleList,每次拿出一个叫 article 的元素来渲染。:key="article.id" 是给每个元素一个唯一标识,让渲染更高效。
🔥 实战:3 个项目带你从「会看」到「会做」
📦 项目 1:最基础的列表页(5 分钟)
目标:显示一个固定的文章列表,理解数据 → 渲染的完整流程。
完整代码如下,复制到 HBuilderX 新建的页面就能跑:
<template>
<view class="container">
<view class="header">
<text class="logo">🏆 掘金热榜</text>
</view>
<!-- 列表区域 -->
<view v-for="item in articleList" :key="item.id" class="article-card">
<text class="article-title">{{ item.title }}</text>
<view class="article-info">
<text class="author">👤 {{ item.author }}</text>
<text class="likes">❤️ {{ item.likes }}</text>
</view>
</view>
<view class="loading-tip">
<text v-if="loading">加载中...</text>
<text v-else>上拉加载更多</text>
</view>
</view>
</template>
<script>
export default {
data() {
return {
articleList: [
{ id: 1, title: '为什么你学的编程教程都白学了?', author: '程序员小李', likes: 1234 },
{ id: 2, title: '我用 Python 自动化了所有重复工作', author: '效率达人', likes: 892 },
{ id: 3, title: '30 岁转行做程序员,来得及吗?', author: '职场老王', likes: 567 },
{ id: 4, title: 'Vue3 深入浅出系列(一)', author: '前端小张', likes: 445 },
{ id: 5, title: 'uni-app 跨平台开发实战', author: '全栈小刘', likes: 321 }
],
loading: false
}
}
}
</script>
<style>
.container {
padding: 20rpx;
}
.header {
padding: 30rpx 0;
border-bottom: 1px solid #eee;
margin-bottom: 20rpx;
}
.logo {
font-size: 36rpx;
font-weight: bold;
color: #007AFF;
}
.article-card {
background: #fff;
border-radius: 16rpx;
padding: 30rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.08);
}
.article-title {
font-size: 32rpx;
color: #333;
display: block;
margin-bottom: 16rpx;
}
.article-info {
display: flex;
justify-content: space-between;
color: #999;
font-size: 24rpx;
}
.loading-tip {
text-align: center;
padding: 30rpx;
color: #999;
font-size: 24rpx;
}
</style>
预期效果:页面上显示 5 篇文章的卡片列表,包含标题、作者、点赞数。
一句话解释:我们在 data() 里写死了 5 篇文章数据,然后用 v-for 循环渲染出来。这就是列表页的「最小原型」。
📦 项目 2:从「假接口」拉取真实数据(15 分钟)
目标:用 uni.request 替代硬编码数据,让列表页从「静态」变成「动态」。
这回我们用 JSONPlaceholder 这个免费的测试接口:
<template>
<view class="container">
<view class="header">
<text class="logo">📚 文章列表</text>
<text class="count">共 {{ articleList.length }} 篇</text>
</view>
<view v-for="item in articleList" :key="item.id" class="article-card">
<text class="article-id">#{{ item.id }}</text>
<text class="article-title">{{ item.title }}</text>
<text class="article-body">{{ item.body }}</text>
</view>
<view class="loading-tip">
<text v-if="isLoading">正在加载...</text>
<text v-else-if="hasMore" @tap="loadMore">点击加载更多</text>
<text v-else>没有更多了</text>
</view>
</view>
</template>
<script>
export default {
data() {
return {
articleList: [],
page: 1,
limit: 10,
isLoading: false,
hasMore: true
}
},
onLoad() {
this.fetchArticles()
},
methods: {
async fetchArticles() {
if (this.isLoading || !this.hasMore) return
this.isLoading = true
try {
const response = await uni.request({
url: `https://jsonplaceholder.typicode.com/posts?_page=${this.page}&_limit=${this.limit}`
})
const newArticles = response.data
if (newArticles.length === 0) {
this.hasMore = false
} else {
// 关键:新数据要追加,不是覆盖
this.articleList = [...this.articleList, ...newArticles]
this.page++
// 如果返回的数据少于一页,说明到底了
if (newArticles.length < this.limit) {
this.hasMore = false
}
}
} catch (e) {
console.error('加载失败:', e)
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
this.isLoading = false
}
},
loadMore() {
this.fetchArticles()
}
}
}
</script>
<style>
.container {
padding: 20rpx;
background: #f5f5f5;
min-height: 100vh;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 0;
}
.logo {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.count {
font-size: 24rpx;
color: #999;
}
.article-card {
background: #fff;
border-radius: 12rpx;
padding: 30rpx;
margin-bottom: 20rpx;
}
.article-id {
font-size: 24rpx;
color: #007AFF;
margin-bottom: 10rpx;
display: block;
}
.article-title {
font-size: 30rpx;
font-weight: bold;
color: #222;
display: block;
margin-bottom: 16rpx;
}
.article-body {
font-size: 26rpx;
color: #666;
display: block;
line-height: 1.6;
}
.loading-tip {
text-align: center;
padding: 40rpx;
color: #999;
font-size: 26rpx;
}
</style>
预期效果:
- 首次加载显示 10 篇文章
- 滚动到底部点击「点击加载更多」会追加新数据
- 文章 ID 依次递增(模拟真实分页)
关键点解释:
1. _page=${this.page}&_limit=${this.limit} —— 这是查询参数,告诉接口「我要第几页、每页多少条」
2. try...catch —— 捕获请求错误,避免页面崩溃
3. finally —— 不管成功失败,最后都要把 isLoading 重置为 false
📦 项目 3:加上「下拉刷新 + 上拉加载」的真·实战(15 分钟)
目标:让列表页具备「真·App」体验——下拉刷新更新数据,上拉到底自动加载更多。
这是今天的重头戏!代码有点长,但每段都有注释:
<template>
<view class="container">
<!-- 状态栏 -->
<view class="status-bar">
<text class="logo">🔥 掘金热榜</text>
<text class="last-time">更新于 {{ lastUpdateTime }}</text>
</view>
<!-- 文章列表 -->
<view v-for="item in articleList" :key="item.id" class="article-card">
<view class="article-header">
<text class="rank" :class="'rank-' + getRankClass(item.id)">TOP{{ item.id }}</text>
<text class="category">{{ item.category || '技术' }}</text>
</view>
<text class="article-title">{{ item.title }}</text>
<view class="article-footer">
<text class="author">👤 {{ item.author }}</text>
<view class="stats">
<text class="likes">❤️ {{ item.likes }}</text>
<text class="comments">💬 {{ item.comments }}</text>
</view>
</view>
</view>
<!-- 底部状态 -->
<view class="footer-tip">
<text v-if="isRefreshing">🔄 刷新中...</text>
<text v-else-if="isLoadingMore">⏳ 加载更多...</text>
<text v-else-if="!hasMore">✅ 已加载全部</text>
<text v-else>⬇️ 上拉加载更多</text>
</view>
</view>
</template>
<script>
export default {
data() {
return {
articleList: [],
page: 1,
limit: 10,
isRefreshing: false, // 是否正在下拉刷新
isLoadingMore: false, // 是否正在加载更多
hasMore: true,
lastUpdateTime: '刚刚'
}
},
onLoad() {
this.fetchArticles()
},
// 小程序端的下拉刷新配置
onPullDownRefresh() {
this.refreshList()
},
// 监听滚动到底
onReachBottom() {
this.loadMoreIfNeeded()
},
methods: {
// 获取文章列表
async fetchArticles(isRefresh = false) {
if (isRefresh) {
this.isRefreshing = true
} else {
if (this.isLoadingMore || !this.hasMore) return
this.isLoadingMore = true
}
try {
const response = await uni.request({
url: `https://jsonplaceholder.typicode.com/posts?_page=${this.page}&_limit=${this.limit}`
})
let newArticles = response.data.map((item, index) => ({
...item,
// 模拟真实数据:加一些字段
author: ['李明', '王芳', '张三', '赵六'][index % 4],
likes: Math.floor(Math.random() * 1000) + 100,
comments: Math.floor(Math.random() * 100) + 10,
category: ['前端', '后端', '架构', '运维'][index % 4]
}))
if (isRefresh) {
// 刷新:新数据覆盖旧数据
this.articleList = newArticles
this.page = 2
this.lastUpdateTime = this.getTimeString()
} else {
// 加载更多:追加数据
this.articleList = [...this.articleList, ...newArticles]
this.page++
}
if (newArticles.length < this.limit) {
this.hasMore = false
}
} catch (e) {
console.error('请求失败:', e)
uni.showToast({ title: '网络开小差了', icon: 'none' })
} finally {
this.isRefreshing = false
this.isLoadingMore = false
// 停止下拉刷新的动画
uni.stopPullDownRefresh()
}
},
// 下拉刷新
async refreshList() {
this.page = 1
this.hasMore = true
await this.fetchArticles(true)
},
// 上拉加载更多
loadMoreIfNeeded() {
if (!this.isLoadingMore && this.hasMore) {
this.fetchArticles(false)
}
},
// 根据 ID 判断排名样式
getRankClass(id) {
if (id <= 3) return 'top'
if (id <= 10) return 'middle'
return 'normal'
},
// 获取当前时间字符串
getTimeString() {
const now = new Date()
return `${now.getHours()}:${String(now.getMinutes()).padStart(2, '0')}`
}
}
}
</script>
<style>
.container {
padding: 20rpx;
background: #f8f8f8;
min-height: 100vh;
}
.status-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 0 30rpx;
}
.logo {
font-size: 36rpx;
font-weight: bold;
color: #333;
}
.last-time {
font-size: 22rpx;
color: #999;
}
.article-card {
background: #fff;
border-radius: 16rpx;
padding: 30rpx;
margin-bottom: 24rpx;
box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.06);
}
.article-header {
display: flex;
justify-content: space-between;
margin-bottom: 16rpx;
}
.rank {
font-size: 24rpx;
font-weight: bold;
padding: 4rpx 12rpx;
border-radius: 6rpx;
}
.rank-top { background: #ffd700; color: #333; }
.rank-middle { background: #e0e0e0; color: #666; }
.rank-normal { background: #f5f5f5; color: #999; }
.category {
font-size: 22rpx;
color: #007AFF;
background: #e8f4ff;
padding: 4rpx 12rpx;
border-radius: 6rpx;
}
.article-title {
font-size: 32rpx;
font-weight: 600;
color: #222;
line-height: 1.5;
display: block;
margin-bottom: 20rpx;
}
.article-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.author {
font-size: 24rpx;
color: #666;
}
.stats {
display: flex;
gap: 20rpx;
}
.likes, .comments {
font-size: 24rpx;
color: #999;
}
.footer-tip {
text-align: center;
padding: 40rpx;
color: #999;
font-size: 26rpx;
}
</style>
预期效果:
- 打开页面自动加载第一页数据
- 顶部显示「更新于 XX:XX」
- 下拉(触发 onPullDownRefresh)会刷新列表
- 滚动到底(触发
onReachBottom)会自动加载更多 - 文章有排名标签(前3名金色,第4-10名灰色)
核心机制解释:
- onPullDownRefresh —— 小程序/APP 端的下拉刷新钩子
- onReachBottom —— 滚动到底部时的钩子
- uni.stopPullDownRefresh() —— 手动停止下拉刷新的动画,避免它一直转
💪 进阶:新手最容易踩的 4 个坑
❌ 坑 1:数据覆盖 vs 数据追加
// ❌ 错误:每次加载都把旧数据清掉了
this.articleList = newArticles
// ✅ 正确:用展开运算符合并数组
this.articleList = [...this.articleList, ...newArticles]
原理:前者是「替换」,后者是「拼接」。列表加载更多必须用后者,否则用户每次加载都只能看到最新一页,旧数据全没了。
❌ 坑 2:请求时没加「锁」
// ❌ 错误:快速滚动会触发多次请求
async fetchArticles() {
const response = await uni.request({ url: '...' })
this.articleList = [...this.articleList, ...response.data]
}
// ✅ 正确:加个 loading 状态锁
async fetchArticles() {
if (this.isLoading) return // 已经在加载中,就不重复请求
this.isLoading = true
const response = await uni.request({ url: '...' })
this.articleList = [...this.articleList, ...response.data]
this.isLoading = false
}
原因:用户手速快的话,滚动事件可能 1 秒触发 10 次请求。没加锁会导致数据乱序、重复加载。
❌ 坑 3:没判断「到底了」
// ❌ 错误:永远显示「加载更多」,哪怕数据已经空了
this.articleList = [...this.articleList, ...newArticles]
// ✅ 正确:判断返回数据是否少于一页
if (newArticles.length < this.limit) {
this.hasMore = false
}
原因:接口返回数据少于每页数量时,说明数据库里没更多数据了。不做这个判断,页面会一直尝试加载。
❌ 坑 4:下拉刷新没配配置
// ❌ 错误:页面有下拉刷新,但没配 enabled
onPullDownRefresh() {
this.refreshList()
}
// ✅ 正确:在 pages.json 中启用当前页面的下拉刷新
// {
// "path": "pages/index/index",
// "style": {
// "enablePullDownRefresh": true,
// "backgroundTextStyle": "dark"
// }
// }
原因:下拉刷新是需要在页面配置中手动开启的,只写 JS 代码没用。
💡 调试技巧:用 console.log 定位问题
列表页出问题最多的就是「数据不对」。教你一个万能调试法:
// 在数据更新后打印出来看看
this.articleList = [...this.articleList, ...newArticles]
console.log('当前列表:', JSON.stringify(this.articleList, null, 2))
JSON.stringify(数据, null, 2) 能让打印出来的 JSON 格式化缩进,方便阅读。
✏️ 练习题
练习 1(2 分钟):改个标题
- 输入:把「掘金热榜」改成「我的收藏」
- 预期输出:页面上 logo 显示「我的收藏」
- 提示:在 template 的 status-bar 区域找
练习 2(3 分钟):加一个判断
- 输入:在 fetchArticles 中,如果 response.data 是空数组,弹出提示「没有数据」
- 预期输出:接口返回空时,页面弹出 Toast 提示
- 提示:用 uni.showToast 配合 if (newArticles.length === 0)
练习 3(5 分钟):换个数据源
- 输入:把接口地址改成 https://jsonplaceholder.typicode.com/users,显示用户列表
- 预期输出:页面显示用户名称和邮箱
- 提示:用户数据的字段是 name 和 email,不是 title 和 body
练习 4(5 分钟):串起来
- 输入:在项目 2 的基础上,加一个「点击文章跳转到详情页」的功能
- 预期输出:点击任意文章卡片,控制台打印出该文章的 ID
- 提示:用 @tap="goDetail(item.id)" 绑定点击事件
练习 5(5 分钟):报错分析
- 输入:以下代码运行时报错 Cannot read property 'id' of undefined,怎么修?
v-for="item in articleList" :key="item.id" // articleList 初始是 undefined
- 预期输出:页面正常渲染,不报错
- 提示:初始值设为
[]而不是undefined
📚 作业:做一个「天气预报」列表页
需求描述:
用 30 分钟做一个天气城市列表,可以查看最近一周的天气。
功能点:
1. 页面加载时自动请求「杭州、北京、上海、广州、深圳」5 个城市的天气
2. 每个城市显示:城市名、今天温度、天气状况(晴/雨/多云)
3. 支持下拉刷新更新所有城市的天气数据
4. 底部显示「最后更新时间」
加分项:
1. 每个城市用不同颜色的图标表示天气(☀️ 晴天黄色、🌧️ 雨天蓝色、⛅ 多云灰色)
2. 温度超过 30° 显示红色,低于 10° 显示蓝色
验收标准:
- 页面能正常打开,不报错
- 显示 5 个城市的数据
- 下拉能触发刷新(真机/模拟器测试)
提交方式:把代码截图或 GitHub 链接发到评论区,老粉优先回复!
总结
这一章我们学了 3 个核心知识点:
- API 请求:
uni.request帮你把数据从服务器拉过来 - 列表渲染:
v-for让数据变成页面上一个个卡片 - 下拉刷新 + 上拉加载:
onPullDownRefresh和onReachBottom让你的列表像真 App 一样流畅
推荐资源:
- uni-app 官方文档 - 请求部分:https://uniapp.dcloud.net.cn/api/request/request.html
- JSONPlaceholder 测试接口:https://jsonplaceholder.typicode.com/
互动钩子:
你在做列表页的时候,遇到过什么奇葩 bug?比如「数据刷出来了但页面不更新」「下拉刷新一直转不停」?评论区聊聊,老粉优先回复!
📌 预告:下一章「第 4 章 4.1 条件编译:#ifdef MP-WEIXIN」我们要解决一个灵魂问题——同一套代码,如何同时跑在微信小程序、APP、H5 三个平台? 每个平台有不同的 API,总不能写三套代码吧?下章教你用条件编译实现「一次开发,多端运行」。敬请期待!

评论(0)