Skip to content

首页列表与云开发环境(二) ​

本页对应 首页列表与云开发环境,共 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 时组件不会被销毁重建,滚动位置和已加载的数据都能保留。
  • 此时 &#123;&#123; row.content &#125;&#125; 插值默认会转义,页面上会直接看到 <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 接收,再传给医生列表组件。

练习 ​

  1. 列表传参验证:在 4 个 feed tab 中给 feed-list 传入各自 list,通过组件面板确认每个 tab 都能收到 5 条数据;故意写错一次属性名并修复。
  2. 内容清洗与三行截断:请求成功后遍历当前 tab 的 rows 清理 content 标签;在 feed-content 上使用文字溢出混合并传 3。验收:无标签、最多 3 行、超出省略。
  3. 分页加载:实现 onScrollToLower,按当前 tab 更新 current 并追加 rows;用 Network 验证第 2、3 页递增且数据不重复;切到饮食 tab 翻到最后一页,确认 hasMore=false 后不再请求。
  4. 医生列表接入:封装 doctorListAPI(current=1, pageSize=5),首页加载调用,doctorItems 接收 date.rows 并传给 doctor-list;仅推荐 tab 显示。验收:请求一次、组件收到 5 条、切关注/剪纸隐藏。
  5. 排错练习:人为制造“组件未导入”和“组件名与变量重名”两个错误,记录控制台报错并修复,说明修复依据。

课堂画面 ​

课堂画面 01

课堂画面 02

课堂画面 03

课堂画面 04

课堂画面 05

课堂画面 06

原视频对照 ​

#视频时长
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 数据、页面最终渲染结果。