第3章 3.5 综合实战:仿掘金首页 + 列表

🎯 开场:为什么你需要一个「列表页」技能?

上一章我们学会了用 uni-ui 官方组件库快速搭界面,是不是感觉「搭页面」这件事变得超简单了?

但你有没有遇到过这种情况:

  • 想做个新闻列表页,数据从哪来
  • 写好了列表,但下拉刷新、上拉加载更多怎么做?
  • 几百条数据一次性加载,页面卡成 PPT 怎么办?

如果你点头了,那今天这章就是为你量身定制的。

学完这章,你将掌握:如何从 API 拉数据 → 渲染成列表 → 加上刷新生效。这个「列表页三件套」是所有 App 最核心的技能,没有之一。


🧱 基础:把「列表页」拆解成 3 个积木

想象你要开一家奶茶店,需要:原料仓库(数据源)操作台(处理逻辑)出餐口(渲染展示)。列表页也是一样的道理。

3.5.1 数据从哪来?—— 认识 API 请求

是什么:API 就是「数据快递柜」,你发个请求,它返回你要的数据。

生活类比:点外卖 = 你向餐\n\nSimple tech illustration expla\n\nAI comic creation scene, creat\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,显示用户列表
- 预期输出:页面显示用户名称和邮箱
- 提示:用户数据的字段是 nameemail,不是 titlebody

练习 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 个核心知识点:

  1. API 请求uni.request 帮你把数据从服务器拉过来
  2. 列表渲染v-for 让数据变成页面上一个个卡片
  3. 下拉刷新 + 上拉加载onPullDownRefreshonReachBottom 让你的列表像真 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,总不能写三套代码吧?下章教你用条件编译实现「一次开发,多端运行」。敬请期待!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。