主题
首页列表与云开发环境(二)
本页对应 首页列表与云开发环境,共 6 段视频,约 56 分钟。
学习目标
- 能够将接口返回的文章列表数据传入列表组件,并通过遍历渲染到页面中,验证每条数据正确展示。
- 能够使用正则表达式去除内容中的 HTML 标签,并通过多行省略样式将文字限制为三行且溢出显示省略号。
- 能够监听页面滚动触底事件,触发分页请求,并在请求成功后追加新数据、同步递增当前页码。
- 能够根据接口返回的总页数与
hasMore状态判断是否还有下一页,并在无更多数据时阻止无效请求。 - 能够创建医生列表组件,按组件命名与导入规范接入首页,并使用条件渲染保证仅在“推荐”分类下显示。
- 能够封装医生列表接口,在页面加载时请求数据,将返回的列表数据传入医生列表组件并完成遍历渲染。
知识点
本节目标与整体思路
这一节要完成“优医咨询”首页文章列表的完整渲染:先把接口数据传给文章组件,再清理内容里的 HTML 标签并做三行省略;随后实现滚动分页、页码累加和 hasMore 判断,最后补上医生列表的布局与数据。转写稿里的“非 types”“类似”“cont turn”“气分成”分别对应 feedTypes、list、content、替换成。
把列表数据传入文章组件
为什么需要: 接口拿到的是多个分类下的文章列表,页面通过遍历 feedTypes 渲染多个 feed-list 组件。每个分类对象里都有 list,必须把对应 list 传给组件,组件才能展示文章。
怎么想: 先确认组件是否支持接收外部数据。转写稿中回到组件检查,发现组件已定义 list 属性,因此父组件用 :list 传入即可。
怎么做: 在遍历 feedTypes 时给组件绑定 list,类似:
html
<feed-list :list="feed.list" />写完后要测试:打开组件,点开每个 feed 对应的 feed-list,确认每个 list 都有 5 条数据。转写稿中逐个点击 4 个 feed,都能看到 5 条,说明数据已传入。
易错点: 不要传错层级;不是传整个 feed,而是传 feed.list。测试时不要只看页面,要在组件数据里确认 list 是否真的有值。
清理 HTML 标签与三行省略
为什么需要: 后端返回的是爬取数据,content 里可能带 <div> 等标签,直接展示会把标签显示出来;同时整篇文章太长,页面只需要简介。
怎么想: 标签有开始和结束符号,可以用正则匹配标签并替换为空字符串;文字溢出则复用之前封装的处理文字溢出方法,传入行数 3。
怎么做: 在请求成功后的 rows 遍历中处理 content:
js
rows.forEach(item => {
item.content = item.content.replace(/<[^>]+>/g, '')
})转写稿中说明正则可用 GPT 生成,核心是“小于号到大于号之间的内容都匹配掉”,再 replace 成空字符串。文字溢出在文章组件内给内容盒子加上之前封装的样式,参数传 3,只显示三行,多余用省略号。
易错点: 替换要遍历多条 rows,不是只处理第一条;替换位置要在“请求成功”之后。样式处理在组件内,正则处理在页面请求处,代码位置不要搞混。
滚动分页与页码累加
为什么需要: 首页数据不能一次全部加载,用户快滚动到底部时要请求下一页。
怎么想: 监听滚动到底部事件。自己封装的滚动组件内部封装的是 scroll-view,因此可以把 scrolltolower 事件直接监听到封装组件上。
怎么做: 模板上绑定事件,方法里调用封装好的 getFeedList:
html
<scroll-page @scrolltolower="onScrollToLower" />js
function onScrollToLower() {
getFeedList()
}调试时先打印日志,滚动快到底部能看到触发;再看网络面板,确认请求发出。转写稿中发现数据重复,原因是每次请求都是第一页,于是请求后让当前分类的 current 加一:
js
feedTypes[typeIndex].value.current++注意按索引改对应分类的页码,推荐、关注、剪纸互不影响。
易错点: 不是所有分类共用一个页码;加一要按当前 typeIndex 取对应项;测试时刷新页面从头看网络面板页码是否变成 1、2、3。
hasMore 判断与避免无效请求
为什么需要: 数据取完后如果还滚动,仍会发请求,但返回空数组,属于无效请求。
怎么想: 是否有更多数据不能前端猜,要看接口返回的 total。用自己的页码和 total 比较,判断 hasMore;请求前先看 hasMore 是否为 true。
怎么做: 转写稿中把当前页码先取出再比较,判断后更新对应分类的 hasMore;滚动方法里加条件:
js
if (!feedTypes[typeIndex].value.hasMore) return
getFeedList()用“饮食”只有 4 页的数据测试:请求到第 4 页后,对应 hasMore 变为 false,再滚动不再发请求。
易错点: 要按索引更新对应分类的 hasMore;比较时注意“先取当前页码、再加一”的顺序;不要把所有分类的 hasMore 一起改掉。
医生列表布局与数据
为什么需要: “关注”分类要先有关注的医生,因此需要先展示医生列表。该列表只在“推荐”顶部显示。
怎么想: 医生列表做成独立组件便于管理;它不在 easycom 规范目录下,必须手动导入注册。接口封装到医生相关 service 中,页面加载时请求,数据传给组件。
怎么做: 新建 doctor-list 组件并导入注册,模板中用 v-if 控制只在推荐显示:
html
<doctor-list v-if="feedType === 'recommend'" :list="doctorItems" />接口封装 getDoctorListAPI(current = 1, pageSize = 5),页面加载调用,取 date.rows 赋给 doctorItems。转写稿中变量名与组件名冲突,把 doctorList 改成 doctorItems。组件内用 v-for 遍历 list 渲染医生项。
易错点: 非 easycom 组件必须导入注册;组件名和变量名不要重复;数据在 rows 里,不是直接取 date;v-if 条件不要写错。
先确认列表数据怎样进入组件
这一节先要解决的是:首页里每个 tab 下都有文章列表,列表数据要渲染到页面中。转写稿里先通过遍历 feedTypes 来加载组件,每个单元里都有 list,所以渲染时只需要把当前单元的 list 传给文章列表组件。组件内部已经定义了 list 属性,因此模板里用 :list="item.list" 这样的方式接收即可。
为什么需要先做这一步?因为后面要处理的 content 字段就在列表数据里。如果数据没有真正进入组件,后面的标签替换和文字溢出处理就没有验证对象。怎么想?先不要急着改内容,先确认“组件有没有收到数据”。怎么做?在开发者工具里找到 feed-list 组件,逐个点开 tab,确认每个组件里的 list 都有 5 条数据。易错点是:只看页面有没有文字还不够,要确认组件属性里确实拿到了数组;另外 list 是组件对外接收的属性,传参时不要漏掉冒号。
为什么会出现标签和超长文字
数据进入组件后,页面会出现两个明显问题:一是 content 里带着 <div> 之类的标签,直接展示时标签也会被当成文字显示出来;二是内容特别长,一屏放不下。转写稿解释得很清楚:这批数据不是后端人工一条条录入的,而是从网上抓取来的,抓取时把文章里的 HTML 标签也一起带过来了;同时后端没有做简介截断,直接把整篇文章给了前端。
为什么需要前端处理?因为前端要让页面好看、可读,不能把整篇文章和标签原样铺在列表里。怎么想?分两件事:第一,标签属于脏数据,展示前要清掉;第二,列表只需要简介,不需要全文,所以用样式限制行数,多出来的用省略号。怎么做?标签用正则替换,超长文字用项目里已经封装好的文字溢出样式处理。易错点是:不要以为后端会永远给干净数据;也不要在模板里临时写复杂逻辑,应该先处理数据再渲染。
正则替换标签:从“标签长什么样”推导表达式
转写稿里没有直接背正则,而是先分析标签结构:一个标签由小于号开始、大于号结束,中间写什么都可以,比如 <div>、<a>。所以匹配思路是:找到“小于号开头、大于号结尾、中间不是大于号”的片段。中括号表示集合,[^>] 里的 ^ 是取反,意思是“只要不是大于号都可以”;后面的 + 表示中间至少有一个字符。于是核心写法就是:
js
content = content.replace(/<[^>]+>/g, '')这里的 replace 把匹配到的标签替换成空字符串,相当于“把标签干掉”。为什么需要这样写?因为不能只替换某一个固定标签,文章里可能有 div、a 等多种标签。怎么想?先描述标签的共同结构,再把它翻译成正则。怎么做?用 replace 配合全局匹配 g,把一条内容里的所有标签都替换掉。易错点是:不要只写一个标签名;不要漏掉全局匹配;正则不会时可以用 GPT 生成这种“有标准答案”的表达式,但报错调试不能直接贴给 GPT 等答案,要自己看控制台和网络。
替换要放在请求成功之后,并且遍历列表
正则本身只处理一条字符串,但列表里有多条数据,所以不能只替换第一条。转写稿强调位置:在页面发请求成功之后,拿到 rows,遍历每一条,再取 content 做替换。也就是说,替换发生在“数据已经回来、还没渲染或即将渲染”的阶段,而不是在组件模板里对每个字段临时写正则。
为什么需要遍历?因为 rows 是数组,每篇文章都有自己的 content。怎么想?先找到数据入口:请求成功后的 rows;再对每条数据改 content。怎么做?可以按下面思路写:
js
rows.forEach(item => {
item.content = item.content.replace(/<[^>]+>/g, '')
})保存后回到页面观察,原来 content 位置上的标签应该消失。易错点是:替换位置不要放错页面,转写稿明确说这段代码在首页面的请求成功逻辑里;不要漏掉遍历;替换后要等接口回来再看效果,不能只看静态模板。
用样式控制三行溢出
标签解决后,剩下的是文字太长。转写稿说项目之前封装过处理文字溢出的函数或混合,初始值等于 2 时只显示两行,多出来用点点点代替;现在列表需要三行,所以调用时把参数从 2 改成 3。具体做法是在组件里找到控制文字盒子的类名 feed-content,在这个盒子上使用已封装的文字溢出处理,并传入 3。
为什么需要这样做?因为后端给的是整篇文章,前端列表只需要简介。怎么想?不截断数据本身,而是用 CSS 限制显示行数,这样既不破坏原数据,又能让页面整齐。怎么做?在 feed-content 对应样式里调用项目已有的溢出处理,参数传 3。易错点是:不要自己另写一套不兼容的样式;不要把参数还留在 2;这段样式代码在组件里,而正则替换在首页面请求逻辑里,两个地方不要搞混。
调试与本节小结
调试时按顺序看:先确认组件 list 有 5 条数据;再看页面 content 是否还有标签;替换后标签应消失;最后看文字是否变成三行、超出部分是否出现省略号。转写稿中页面卡顿、数据量大,属于项目数据本身的问题,不影响本节逻辑。本节就做两件事:一是在请求成功后遍历 rows,用正则把 content 里的标签替换为空;二是在文章组件里给 feed-content 使用文字溢出处理并传 3。易错点再强调一次:正则要全局替换,替换要遍历列表,样式参数要改成三行,代码位置不要放反。
先把列表数据渲染到组件里
为什么需要:接口拿到的是数据,页面要的是组件里的列表项;只有把数据传给列表组件,组件才能遍历渲染。
怎么想:转写稿里页面通过遍历 feed types 生成多个 feed 区域,每个区域里都有 list。既然组件已经定义了 list 属性,就可以把当前 feed 的 list 传进去。
怎么做:在遍历组件时写 :list="feed.list",把数据交给组件。写完后不要假设一定正确,打开 Vue 开发者工具检查组件,确认每个 feed 组件里确实有 5 条数据。
易错点:不要传错层级,传的是 feed.list,不是整个 feed;组件属性名要和外层传参一致。
清洗后端脏数据:正则去标签与文字溢出
为什么需要:后端数据是爬取来的,content 里可能带 div、a 等标签,直接展示会把标签显示出来;文章全文太长,页面只需要简介。
怎么想:先用正则把标签匹配掉,再替换为空字符串;文字溢出用已有样式函数控制行数。转写稿里说“大于号是结束符号、小于号是开始符号”,实际是用 <...> 匹配标签,中括号里的 ^ 表示取反。
怎么做:请求成功后遍历 rows,对每条数据的 content 执行 replace,把标签替换成空串。文字溢出使用封装好的样式函数,传入 3 表示最多显示三行,多余部分用省略号。
易错点:替换要放在“请求成功”之后、遍历每条数据时做;不要只处理第一条。样式处理放在组件里的内容盒子上,不要放错位置。
滚动到底部触发分页请求
为什么需要:手机端列表不能一次请求全部数据,用户快滚动到底部时再请求下一页。
怎么想:先监听滚动事件。项目里封装的滚动组件本质封装了 scroll-view,所以可以给自定义组件绑定 @scrolltolower,事件最终会传给内部滚动容器。
怎么做:在页面组件上写 @scrolltolower="onScrollToLower",在方法里先打印测试,确认快到底时能触发;再把打印换成调用已封装的 getFeedList。
易错点:不是“完全到底”才触发,而是接近底部触发;事件名不要写错;要先验证事件能触发,再发请求。
页码累加与数据追加
为什么需要:如果每次请求都传第一页,数据会重复;分页必须让页码递增,并把新数据追加到原列表。
怎么想:当前 tab 对应一个 current,请求成功后让当前 tab 的 current 加一;下一页请求自然变成第 2、3 页。数据要追加,不是覆盖。
怎么做:请求成功后执行 current++ 一类操作;注意根据当前索引取对应 tab 的 current。调试时打开网络面板,看第一次请求页码是 1,滚动后是否变成 2、3;再看列表是否从 5 条变 10 条、15 条。
易错点:不要把 current 写死;不要丢掉 value 或索引;先取当前页数据再自增,顺序不能乱。
判断是否还有更多数据并避免无效请求
为什么需要:接口有总页数,取完后继续滚动还会发空请求,浪费性能。
怎么想:接口返回的 total 表示总页数,前端自己的页码小于等于总页数时才可能有下一页;同时接口还会给 hasMore,取完后变为 false。
怎么做:请求成功后保存 total,判断当前页码与总页数;滚动回调里先看当前 tab 的 hasMore,为 true 才调用请求,为 false 直接返回。转写中用“饮食”分类只有 4 页做验证:取完第 4 页后 hasMore 变成 false,再滚动不再发请求。
易错点:hasMore 要按当前索引取,不能所有 tab 共用一个;判断顺序是先判断再请求;不要等请求完才判断,否则无效请求已经发出。
医生列表布局与数据接入
为什么需要:关注医生前要先展示医生列表,医生列表只在“推荐”tab 显示。
怎么想:医生列表是独立组件,放在首页面目录下;它不是 easycom 规范目录,必须手动导入注册。显示条件用当前 tab 类型判断。
怎么做:新建 doctor-list 组件并导入;模板里用 v-if 控制只在推荐时显示。接口封装成 getDoctorListAPI,页面加载时调用,把返回的 rows 存到 doctorItems,再通过 :list="doctorItems" 传给组件,组件内用 v-for 渲染。
易错点:组件名和变量名不要重名,转写中就出现过 doctorList 重复声明报错,后改为 doctorItems;导入组件时注意驼峰与短横线命名规范;数据在 rows 里,不要直接拿整个 data。
代码示例
代码段一:渲染列表数据(首页遍历 feedTypes → 把 list 传给组件)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>优医咨询 - 渲染列表数据</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #f5f6f8; font-size: 14px; color: #333; }
.tabs { display: flex; background: #fff; height: 44px; }
.tab { flex: 1; text-align: center; line-height: 44px; color: #666; }
.tab.active { color: #16a085; font-weight: bold; }
.feed-item { background: #fff; margin: 10px; padding: 12px; border-radius: 8px; }
.feed-title { font-size: 16px; margin-bottom: 8px; }
.feed-doctor { color: #16a085; margin-bottom: 8px; }
.feed-content { color: #666; line-height: 1.6; }
</style>
</head>
<body>
<div id="app">
<div class="tabs">
<span
v-for="(item, index) in feedTypes"
:key="item.type"
:class="['tab', { active: index === tabIndex }]"
@click="tabIndex = index"
>{{ item.name }}</span>
</div>
<!-- 遍历 feedTypes,把每个分类自己的 list 传给子组件 -->
<feed-list
v-for="(item, index) in feedTypes"
:key="item.type"
v-show="index === tabIndex"
:list="item.list"
></feed-list>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
const { createApp, ref, onMounted } = Vue;
// 子组件:通过 list 属性接收外部传入的数据
const FeedList = {
props: {
list: { type: Array, default: () => [] }
},
template: `
<div class="feed-list">
<div class="feed-item" v-for="row in list" :key="row.id">
<h3 class="feed-title">{{ row.title }}</h3>
<p class="feed-doctor">{{ row.name }}</p>
<p class="feed-content">{{ row.content }}</p>
</div>
</div>
`
};
// 模拟接口:返回 { code, data: { rows } }
function mockFeedListApi() {
return new Promise((resolve) => {
setTimeout(() => {
const rows = [];
for (let i = 1; i <= 5; i++) {
rows.push({
id: i,
title: `标题${i}`,
name: `王医生${i}`,
content: `<div>这是第 ${i} 条文章内容</div>`
});
}
resolve({ code: 10000, data: { rows } });
}, 300);
});
}
createApp({
components: { FeedList },
setup() {
const tabIndex = ref(0);
// 每个分类都带一份自己的 list,数据互不影响
const feedTypes = ref([
{ type: 'recommend', name: '推荐', list: [] },
{ type: 'follow', name: '关注', list: [] },
{ type: 'food', name: '饮食', list: [] },
{ type: 'knowledge', name: '知识', list: [] }
]);
async function getFeedList() {
const res = await mockFeedListApi();
if (res.code !== 10000) return;
feedTypes.value[tabIndex.value].list = res.data.rows;
}
onMounted(getFeedList);
return { tabIndex, feedTypes, getFeedList };
}
}).mount('#app');
</script>
</body>
</html>要点与易错点:
- 子组件里必须先声明
props: { list: ... },外部才能用:list="item.list"把数据传进去;不声明就接收不到。 :list前面的冒号是v-bind,传的是数组表达式;写成list="item.list"传的是字符串"item.list"。- 数据挂在
feedTypes每一项的list上,所以v-for遍历feedTypes时直接:list="item.list"即可,不需要再写一层循环。 - 用
v-show而不是v-if,切换 tab 时组件不会被销毁重建,滚动位置和已加载的数据都能保留。 - 此时
{{ row.content }}插值默认会转义,页面上会直接看到<div>...</div>字面文本——这正是下一节要修的问题。
代码段二:正则替换标签 + 文字溢出三行省略
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>优医咨询 - 正则替换标签和处理文字溢出</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #f5f6f8; font-size: 14px; color: #333; }
.tabs { display: flex; background: #fff; height: 44px; }
.tab { flex: 1; text-align: center; line-height: 44px; color: #666; }
.tab.active { color: #16a085; font-weight: bold; }
.feed-item { background: #fff; margin: 10px; padding: 12px; border-radius: 8px; }
.feed-title { font-size: 16px; margin-bottom: 8px; }
.feed-doctor { color: #16a085; margin-bottom: 8px; }
/* 三行溢出省略,相当于 scss 里的 @include ellipsis(3) */
.feed-content {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
text-overflow: ellipsis;
color: #666;
line-height: 1.6;
}
</style>
</head>
<body>
<div id="app">
<div class="tabs">
<span
v-for="(item, index) in feedTypes"
:key="item.type"
:class="['tab', { active: index === tabIndex }]"
@click="tabIndex = index"
>{{ item.name }}</span>
</div>
<feed-list
v-for="(item, index) in feedTypes"
:key="item.type"
v-show="index === tabIndex"
:list="item.list"
></feed-list>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
const { createApp, ref, onMounted } = Vue;
const FeedList = {
props: { list: { type: Array, default: () => [] } },
template: `
<div class="feed-list">
<div class="feed-item" v-for="row in list" :key="row.id">
<h3 class="feed-title">{{ row.title }}</h3>
<p class="feed-doctor">{{ row.name }}</p>
<p class="feed-content">{{ row.content }}</p>
</div>
</div>
`
};
function mockFeedListApi() {
return new Promise((resolve) => {
setTimeout(() => {
const rows = [];
for (let i = 1; i <= 5; i++) {
rows.push({
id: i,
title: `标题${i}`,
name: `王医生${i}`,
content: `<div>这是第 ${i} 条文章内容,后端把整篇文章都返回了,特别特别长,前端只展示前三行,多出来的用省略号代替。</div>`
});
}
resolve({ code: 10000, data: { rows } });
}, 300);
});
}
createApp({
components: { FeedList },
setup() {
const tabIndex = ref(0);
const feedTypes = ref([
{ type: 'recommend', name: '推荐', list: [] },
{ type: 'follow', name: '关注', list: [] },
{ type: 'food', name: '饮食', list: [] },
{ type: 'knowledge', name: '知识', list: [] }
]);
async function getFeedList() {
const res = await mockFeedListApi();
if (res.code !== 10000) return;
// 请求成功后,逐条把 content 里的 HTML 标签替换成空字符串
res.data.rows.forEach((row) => {
row.content = row.content.replace(/<[^>]+>/g, '');
});
feedTypes.value[tabIndex.value].list = res.data.rows;
}
onMounted(getFeedList);
return { tabIndex, feedTypes, getFeedList };
}
}).mount('#app');
</script>
</body>
</html>要点与易错点:
/<[^>]+>/g:[^>]是取反集合,表示“只要不是>的任意字符”;+表示一个或多个;g表示全局替换。所以<div>、<a>、</p>都会被匹配到。- 替换成空字符串
'',标签就没了;数据是多条,所以必须forEach遍历rows逐条替换,不能只处理第一条。 - 替换时机要放在请求成功之后、赋值给 list 之前,并且写在页面(首页面)里,不是写在组件里。
- 三行省略的样式四件套缺一不可:
display: -webkit-box+-webkit-box-orient: vertical+-webkit-line-clamp: 3+overflow: hidden。 - 易错点:
-webkit-line-clamp只对多行盒模型生效,父容器高度写死或写成 flex 居中会导致省略号不出现;正则别漏写g,否则只替换第一处标签。
代码段三:分页请求列表数据(滚动到底加载 + hasMore 拦截无效请求)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>优医咨询 - 分页请求列表数据</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #f5f6f8; font-size: 14px; color: #333; }
.tabs { display: flex; background: #fff; height: 44px; }
.tab { flex: 1; text-align: center; line-height: 44px; color: #666; }
.tab.active { color: #16a085; font-weight: bold; }
.scroll-wrap { height: calc(100vh - 44px); overflow-y: auto; }
.feed-item { background: #fff; margin: 10px; padding: 12px; border-radius: 8px; }
.feed-title { font-size: 16px; margin-bottom: 8px; }
.feed-content { color: #666; line-height: 1.6; }
.tip { text-align: center; color: #999; padding: 12px; }
</style>
</head>
<body>
<div id="app">
<div class="tabs">
<span
v-for="(item, index) in feedTypes"
:key="item.type"
:class="['tab', { active: index === tabIndex }]"
@click="tabIndex = index"
>{{ item.name }}</span>
</div>
<!-- 滚动容器:滚动到底触发加载更多 -->
<div class="scroll-wrap" @scroll="onScroll">
<feed-list :list="currentItem.list"></feed-list>
<p class="tip" v-if="!currentItem.hasMore">没有更多数据了</p>
</div>
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script>
const { createApp, ref, computed, onMounted } = Vue;
const PAGE_SIZE = 5;
const TOTAL_PAGES = 3; // 模拟后端返回的 config.total
const FeedList = {
props: { list: { type: Array, default: () => [] } },
template: `
<div class="feed-list">
<div class="feed-item" v-for="row in list" :key="row.id">
<h3 class="feed-title">{{ row.title }}</h3>
<p class="feed-content">{{ row.content }}</p>
</div>
</div>
`
};
// 模拟分页接口:返回 { code, data: { rows, config: { total } } }
function mockFeedListApi(current, pageSize) {
return new Promise((resolve) => {
setTimeout(() => {
const rows = [];
for (let i = 0; i < pageSize; i++) {
const id = (current - 1) * pageSize + i + 1;
rows.push({
id,
title: `第 ${current} 页 - 标题${id}`,
content: `第 ${current} 页第 ${id} 条内容`
});
}
resolve({ code: 10000, data: { rows, config: { total: TOTAL_PAGES } } });
}, 300);
});
}
createApp({
components: { FeedList },
setup() {
const tabIndex = ref(0);
const loading = ref(false);
// 每个分类各自维护 current(页码)和 hasMore(是否还有更多)
const feedTypes = ref([
{ type: 'recommend', name: '推荐', list: [], current: 1, hasMore: true },
{ type: 'follow', name: '关注', list: [], current: 1, hasMore: true },
{ type: 'food', name: '饮食', list: [], current: 1, hasMore: true }
]);
const currentItem = computed(() => feedTypes.value[tabIndex.value]);
async function getFeedList() {
const item = currentItem.value;
// 没有更多数据,或正在加载中,都不再发请求
if (!item.hasMore || loading.value) return;
loading.value = true;
try {
const res = await mockFeedListApi(item.current, PAGE_SIZE);
if (res.code !== 10000) return;
const { rows, config } = res.data;
const total = config.total; // 先把总页数取出来
item.list = [...item.list, ...rows]; // 追加,不是覆盖
item.current++; // 页码 +1,加在当前 tab 上
// 页码超过总页数,说明没有下一页了
if (item.current > total) item.hasMore = false;
} finally {
loading.value = false;
}
}
function onScroll(e) {
const el = e.target;
// 快滚动到底部时触发(留一点阈值,不等完全到底)
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 50) {
getFeedList();
}
}
onMounted(getFeedList);
return { tabIndex, feedTypes, currentItem, onScroll };
}
}).mount('#app');
</script>
</body>
</html>要点与易错点:
- 滚动到底的判断:
scrollTop + clientHeight >= scrollHeight - 50,留 50px 阈值实现“快到底就请求”,而不是完全到底。 - 页码
current必须加在当前 tab 上:feedTypes.value[tabIndex.value].current++,写死某一个 tab 会导致切换分类后页码错乱、数据互相影响。 - 顺序很关键:先取出
config.total,再current++,最后用加一后的current和total比较,顺序反了判断就不准。 item.list = [...item.list, ...rows]是追加,写成= rows会把之前的数据覆盖掉,列表永远不会变长。hasMore为false时直接return,避免用户继续滚动时发出无意义的空请求;loading锁防止滚动事件连续触发造成重复请求。- 易错点:
item是computed返回的对象,直接改item.current、item.hasMore是响应式的,但不要整体替换feedTypes.value[tabIndex.value]丢失引用。
常见错误
现象:列表组件能显示,但组件内
list为undefined或空。
原因:父组件传参漏写:,或属性名与子组件props不一致。
修法:改成:list="item.list",并在组件面板确认list已收到对应 tab 的数据。现象:页面仍出现
<div>等标签,或只有部分内容被清理。
原因:在模板里临时处理,或请求成功后只替换了单条数据。
修法:在请求成功后遍历当前 tab 的rows,逐条执行content = content.replace(/<[^>]+>/g, ''),再写入状态。现象:文字没有截断,或显示行数不是 3 行。
原因:未使用已封装的文字溢出混合,或传参仍为默认 2 行,或加错到非内容盒子。
修法:在feed-content对应盒子使用混合并传3,检查类名和作用域是否正确。现象:滚动加载后数据重复,页码一直是第 1 页。
原因:请求成功后没有对当前 tab 的current自增,或没有按当前 tab 索引更新页码。
修法:根据当前 tab 索引取current,请求成功后仅对该 tab 执行current++,并用网络面板确认页码递增。现象:已经没有更多数据,继续滚动仍发请求。
原因:只监听了触底,没有先判断当前 tab 的hasMore。
修法:在onScrollToLower中先查当前 tab 的hasMore,为true才请求;请求成功后用当前页与后端total比较并更新hasMore。现象:医生列表组件不显示,或控制台提示标识符已声明。
原因:组件未按规范导入注册,或组件名与数据变量重名。
修法:按 PascalCase 导入注册组件,模板可用短横线;数据变量改为doctorItems,传参写:list="doctorItems"。现象:医生列表接口有返回,但页面列表为空。
原因:直接取date,没有取接口实际列表层级date.rows。
修法:用 Network 面板核对返回结构,按date.rows接收,再传给医生列表组件。
练习
- 列表传参验证:在 4 个 feed tab 中给
feed-list传入各自list,通过组件面板确认每个 tab 都能收到 5 条数据;故意写错一次属性名并修复。 - 内容清洗与三行截断:请求成功后遍历当前 tab 的
rows清理content标签;在feed-content上使用文字溢出混合并传 3。验收:无标签、最多 3 行、超出省略。 - 分页加载:实现
onScrollToLower,按当前 tab 更新current并追加rows;用 Network 验证第 2、3 页递增且数据不重复;切到饮食 tab 翻到最后一页,确认hasMore=false后不再请求。 - 医生列表接入:封装
doctorListAPI(current=1, pageSize=5),首页加载调用,doctorItems接收date.rows并传给doctor-list;仅推荐 tab 显示。验收:请求一次、组件收到 5 条、切关注/剪纸隐藏。 - 排错练习:人为制造“组件未导入”和“组件名与变量重名”两个错误,记录控制台报错并修复,说明修复依据。
课堂画面






原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 优医咨询-渲染列表数据 | 03:59 |
| 2 | 优医咨询-正则替换标签和处理文字溢出 | 08:53 |
| 3 | 优医咨询-分页请求列表数据 | 15:59 |
| 4 | 优医咨询-避免列表数据无效请求 | 07:55 |
| 5 | 优医咨询-医生列表布局 | 08:40 |
| 6 | 优医咨询-医生列表数据 | 10:52 |
小结
- 列表渲染先验证“数据是否到达组件”,再写模板,能快速定位是传参问题还是渲染问题。
- 后端脏数据应在进入状态前统一清洗,且清洗必须覆盖当前列表的全部项。
- 文本截断优先复用已封装样式混合,参数按设计传,避免散写重复 CSS。
- 分页状态要按 tab 隔离:列表数据、
current、hasMore都不能混用。 - 滚动加载的触发条件是“触底事件 + 当前 tab 的
hasMore为真”,缺一不可。 - 页码自增与
hasMore判断要注意先后顺序,避免重复请求同一页或越界请求。 - 医生列表是独立组件,命名、导入、注册要规范,同时避免组件名与数据变量重名。
- 验收看三处:Network 的请求页码、组件面板的 props 数据、页面最终渲染结果。