Skip to content

开发首页的业务与接口与文档(Day7 2/2) ​

本页对应 day7,共 5 段视频,约 1 小时 31 分。

学习目标 ​

  • 能够依据接口文档在 api/index 中统一管理首页与课程接口,并通过继承请求基类的实例调用 get 方法完成带参请求,正确传入分页参数并获取返回数据。
  • 能够在首页 onLoad 生命周期中触发数据请求,将课程与教师数据分别存入响应式变量,并通过父子组件 props 与插槽完成首页课程、教师列表渲染。
  • 能够通过数据标识区分课程与教师数据,在复用组件中按字段差异正确渲染 cover/avatar 等数据,并使用条件渲染区分不同展示结构。
  • 能够封装并接入首页置顶组件,监听页面滚动位置控制组件显示与隐藏,点击后调用滚动 API 返回页面顶部。
  • 能够完成课程列表静态结构与样式搭建,接入 uni-ui 图标与 easycom 配置,并按接口字段渲染课程封面、标题、价格、购买人数。
  • 能够实现课程分页加载业务,维护页码、每页条数、加载状态与“是否还有更多”状态,在触底生命周期中判断并追加下一页数据,正确展示“没有更多”提示。

知识点 ​

先搭静态,再接数据 ​

这一节的主线是开发首页业务。转写稿反复强调“先搭建静态”,因为首页结构没定下来就接接口,后面字段一改、布局一调,数据渲染会反复返工。怎么想:先把首页拆成“热门课程/名师大咖列表 + 课程卡片 + 置顶按钮”,静态结构跑通后再用接口数据替换。怎么做:在首页中先写列表容器和卡片结构,再通过 onLoad 获取首页数据。易错点:不要一开始就把请求、分页、组件通信全混在一起;先确认页面能显示,再逐项接数据。

uniapp 中组件的使用差异 ​

笔记 2.1 要区分 Vue2、Vue3 和 uniapp 的组件使用。为什么需要:不同版本注册方式不同,写错会直接不渲染。怎么想:Vue2 是“引入、注册、使用”;Vue3 是“引入、使用”;uniapp 中内置组件和扩展组件按平台规则使用。怎么做:Vue2 在 components 中注册;Vue3 组合式写法引入后直接使用;uniapp 页面中可直接使用内置组件,扩展组件如 uni-ui 配好 easycom 后也可直接用。易错点:不要把小程序的 {{}} 语法和 Vue 指令混用;转写稿中特别提醒“这不是小程序,不能加双括号”,应按 Vue 模板语法写。

请求统一管理:类、继承与 API 文件 ​

为什么需要:项目接口多,地址散落在页面里,改一个地址要全项目搜。怎么想:先封装请求类,再建 api 目录统一管理接口。转写稿中的做法是让 Request 继承已封装好的 Service,这样 Request 的实例能复用父类的 get/post/delete 方法;API 文件对外暴露实例或方法,页面只调 API。怎么做:在 api/index 中引入请求服务,定义 reqHome 之类方法;页面引入后调用。易错点:继承后页面调的是实例方法,不要重复写一套请求逻辑;实例化时是否需要传参要看封装,转写稿中“不加小括号”是在讨论初始化参数,不能机械照抄。调试时先打印返回结果,确认是 Promise 结果再取 data。

首页 onLoad 获取数据 ​

为什么需要:首页一挂载就要拿到课程和数据。怎么想:用 uniapp 页面生命周期 onLoad,不要直接在页面顶层写 await。怎么做:onLoad 中调用 getHome(),函数内 await reqHome(),先 console.log 看结构,再分别存入课程数组和数组。易错点:转写稿中先打印 123 验证 onLoad 是否执行,再发请求;字段名不要手写猜,必须看接口文档,例如课程封面是 cover,头像是 avatar,写错就渲染不出来。

复用卡片组件与插槽区分课程/ ​

为什么需要:热门课程和名师大咖结构相似,重复写两套维护成本高。怎么想:复用同一个 card 组件,标题通过 props 传入;中间内容用插槽。难点是课程和字段不同,同一个组件拿两个数组会混。怎么做:给数据加 type 标识,课程用 course,用 teacher;模板中按 type 判断取 cover 还是 avatar。转写稿还通过 type 判断课程才显示“多少人购买”等底部结构。易错点:不要以为同一个组件给两个数组就能自动适配;字段不同必须区分。调试时先刷新看图片是否出来,发现图不出,就是字段名不对。

置顶组件与滚动监听 ​

为什么需要:移动端页面长,置顶按钮方便回顶部。怎么想:封装 back-top 组件,固定到右下角;监听页面滚动,超过一定距离显示,点击滚回顶部。怎么做:用 onPageScroll 拿 scrollTop,用响应式变量控制显示;点击时调 uni.pageScrollTo({ scrollTop: 0, duration: 200 })。易错点:Vue3 中不要写 this;pageScrollTo 参数是配置对象;转写稿调试时先打印滚动值,再设 200 像素阈值。

课程静态搭建与分页加载 ​

为什么需要:课程列表要支持首屏 10 条和触底加载更多。怎么想:先搭左右结构:左图、右标题/价格/购买人数;再接分页接口。怎么做:onLoad 拿第一页,onReachBottom 判断状态为 more 时页码加一并再次请求;用 status 控制“没有更多”。易错点:转写稿中第一次请求 10 条、总数 12 条,触底后只返回 2 条;必须根据返回条数判断是否还有下一页,不能无脑加页码。uni-ui 图标可按文档使用,如 uni-icons 配 type、color、size;两行省略用 display: -webkit-box、-webkit-line-clamp: 2、-webkit-box-orient: vertical,记不住可查,不要死背。

接口与文档:先确定请求边界 ​

本节服务器地址是 https://gmall-prod.atguigu.cn/skb,接口文档是 https://www.yuque.com/xiumubai/fe/xc573qhbzqepug47#aU4qO。做接口开发前,先看文档确认三件事:请求地址、请求方式、返回数据结构。转写里反复强调“首页接口返回包含课程和两个数组”,所以页面挂载后要先拿首页数据,再分别存课程数据和数据。

为什么需要:不看文档就写字段,最容易把 cover、avatar、title 这类字段写错。
怎么想:文档是接口契约,页面字段必须从文档和打印结果里来。
怎么做:先打开文档找“首页数据”接口,再在代码里打印 result 看结构。
易错点:小程序开发者工具里不一定能直接看到接口数据时,不要猜字段,先打印、再看文档;data 不要写成 date。

3.1 网络请求二次封装:用类复用 get/post ​

转写里说,公司内部封装经常喜欢用“类”,看起来类似 axios 的 get、post 套路。这里的思路是:底层 Service 类提供请求方法,Request 类继承 Service,业务侧导出 Request 的实例,页面调用实例方法发请求。

为什么需要:每个页面都写一遍 uni.request,地址、参数、错误处理会重复;统一封装后,页面只关心“调哪个接口”。
怎么想:Service 负责“怎么发请求”,Request 负责“业务请求类”,API 文件负责“接口地址统一管理”。
怎么做:按转写整理成如下结构(接口地址按文档填写,不在这里编造具体路径):

js
// utils/service.js
export default class Service {
  get(url, options = {}) {
    return uni.request({
      url,
      method: 'GET',
      ...options
    })
  }
  post(url, data, options = {}) {
    return uni.request({
      url,
      method: 'POST',
      data,
      ...options
    })
  }
}
js
// utils/request.js
import Service from './service'

class Request extends Service {}

// 导出实例,页面直接 request.get / request.post
export default new Request()

易错点:导出时不要只导出类却让页面自己 new,转写里明确说“对外暴露类的实例”;new Request() 的小括号要写,表示初始化实例;继承后 Request 实例才能借用 Service 的 get、post、delete 方法。

API 层统一管理接口 ​

转写里说“API 说白了还是要统一管理整个项目当中所有的接口”,这样某个接口变化时只改一处。

为什么需要:页面里散落接口地址,后期改地址会到处找。
怎么想:页面不直接写请求地址,只调 API 函数。
怎么做:

js
// api/index.js
import request from '@/utils/request'

// 首页接口地址按接口文档填写
export const reqHome = () => request.get('文档中的首页接口地址')

页面里引入 API:

js
import { onLoad } from '@dcloudio/uni-app'
import { reqHome } from '@/api/index'

onLoad(() => {
  getHome()
})

const getHome = async () => {
  const result = await reqHome()
  console.log('首页数据', result)
  // 再按文档字段取课程数组、数组
}

易错点:onLoad 是 uni-app 页面生命周期,转写里特别说从 @dcloudio/uni-app 引入;await 外层函数要 async;onLoad 里不要堆大段业务逻辑,转写里建议抽 getHome。

调试顺序:先确认钩子,再确认数据 ​

转写里的调试过程是:先在 onLoad 打印 123,确认页面挂载钩子执行;再发请求打印首页数据,确认拿到课程和两个数组;最后把数据存到响应式变量并渲染。这个顺序不要反。

为什么需要:如果直接渲染,字段错、钩子没执行、请求没回来会混在一起。
怎么想:先验证“钩子执行”,再验证“请求返回”,最后验证“字段渲染”。
怎么做:先 console.log(123),再 console.log(result),确认结构后再写 result.data 下的课程、数据。
易错点:转写里提到同一个组件接收课程和两类数据时字段不一样,要用类型标识区分;字段名最好从文档复制,别自己拼写。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

代码示例 ​

示例一:网络请求二次封装 + 接口统一管理(utils/request.js + api/index.js) ​

js
/* ============ utils/request.js ============ */
class Service {
  constructor() {
    this.baseURL = 'https://gmall-prod.atguigu.cn/skb'
  }
  // 真正发请求的地方:uni.request 的二次封装,返回 Promise
  request({ url, method = 'GET', data = {} }) {
    return new Promise((resolve, reject) => {
      uni.request({
        url: this.baseURL + url,
        method,
        data,
        success: (res) => {
          if (res.statusCode === 200) resolve(res.data)
          else reject(res)
        },
        fail: reject
      })
    })
  }
  get(options) { return this.request({ ...options, method: 'GET' }) }
  post(options) { return this.request({ ...options, method: 'POST' }) }
  delete(options) { return this.request({ ...options, method: 'DELETE' }) }
}

export default Service
js
/* ============ api/index.js ============ */
import Service from '@/utils/request.js'

// 继承:Request 的实例可以直接用父类原型上的 get / post / delete
class Request extends Service {
  // 首页数据(课程 + )
  getHome() {
    return this.get({ url: '/index/index' })
  }
  // 课程列表:page / limit 拼在路径上
  getCourse(page, limit) {
    return this.get({ url: `/course/list?page=${page}&limit=${limit}` })
  }
}

// 对外暴露的是【实例】,不是类
export default new Request()

关键行与易错点

  • class Request extends Service:这就是“类 + 继承”的套路,Request 的实例能直接调 this.get(...),写法和 ajax.get / ajax.post 一模一样。
  • export default new Request():注意后面要加小括号。漏了括号暴露的就是“类”,页面里还得 new 一次才能发请求,这是最常见的坑。
  • api/index.js 是接口统一出口:将来某个接口地址变了,只改这一个文件,页面不用动。
  • get({ url }) 传的是对象,page / limit 直接拼在路径上,不要再塞进 data。

示例二:首页数据获取渲染 + 卡片组件复用(pages/index/index.vue + components/card/card.vue) ​

vue
<!-- ============ pages/index/index.vue ============ -->
<template>
  <view class="home">
    <Card title="热门课程" :list="courseArr" type="course" />
    <Card title="名师大咖" :list="teacherArr" type="teacher" />
  </view>
</template>

<script setup>
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import ajax from '@/api/index.js'
import Card from '@/components/card/card.vue'

// 两个数组分别存课程、
const courseArr = ref([])
const teacherArr = ref([])

const getHome = async () => {
  const result = await ajax.getHome()
  console.log('首页数据', result)
  courseArr.value = result.data.course
  teacherArr.value = result.data.teacher
}

// 页面一挂载就发请求
onLoad(() => {
  getHome()
})
</script>

<style lang="scss">
.home { background-color: #ccc; }
</style>
vue
<!-- ============ components/card/card.vue ============ -->
<template>
  <view class="card">
    <!-- 标题通过 props 传进来,组件才能复用 -->
    <view class="title">{{ title }}</view>

    <view class="item" v-for="item in list" :key="item.id">
      <!-- 同一个组件给两个数组,字段不一样:靠 type 区分 -->
      <image class="cover" :src="type === 'course' ? item.cover : item.avatar" />

      <!-- 课程结构 -->
      <view class="right" v-if="type === 'course'">
        <view class="name">{{ item.title }}</view>
        <view class="bottom">
          <text class="price">¥{{ item.price }}</text>
          <text class="buy">已有{{ item.buyCount }}人购买</text>
        </view>
      </view>

      <!-- 结构 -->
      <view class="right" v-else>
        <view class="intro">{{ item.intro }}</view>
        <view class="name">{{ item.name }}</view>
      </view>
    </view>
  </view>
</template>

<script setup>
defineProps({
  title: { type: String, default: '' },
  list: { type: Array, default: () => [] },
  type: { type: String, default: 'course' }
})
</script>

<style lang="scss">
.card { background: #fff; margin-bottom: 20rpx; padding: 20rpx; }
.title { font-weight: 700; font-size: 32rpx; margin-bottom: 20rpx; }

.item { display: flex; padding: 20rpx 0; }
.cover { width: 220rpx; height: 160rpx; }

.right {
  flex: 1;               /* 右侧占满剩余空间 */
  margin-left: 20rpx;
  display: flex;
  flex-direction: column; /* 一上一下 */
  justify-content: space-between;
}
.name { font-weight: 700; }
.bottom { display: flex; justify-content: space-between; }
.price { color: #f00; }

/* 介绍:两行超出隐藏,不用背,抄一次就会了 */
.intro {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  overflow: hidden;
}
</style>

关键行与易错点

  • onLoad 从 @dcloudio/uni-app 引入(不是 vue),这是页面生命周期;页面挂载后发请求,不要直接把 await 写在 onLoad 里。
  • 父子通信:父组件 :list="courseArr" + type="course",子组件用 defineProps 接收;title 由父组件传入,标题就不再写死,组件才能复用两次。
  • 同一个组件给两个数组,字段不一样(课程 cover、 avatar),必须打 type 标识做区分,否则图片渲染不出来。
  • 结构不同就用 v-if="type === 'course'" / v-else 分两套结构,没必要为此再拆两个组件。
  • 两行省略三件套:display: -webkit-box + -webkit-box-orient: vertical + -webkit-line-clamp: 2,再配 overflow: hidden。

示例三:置顶组件 + 课程列表分页加载(components/back-top/back-top.vue + pages/course/course.vue) ​

vue
<!-- ============ components/back-top/back-top.vue ============ -->
<template>
  <image class="btn" src="/static/backTop.png" @click="toTop" />
</template>

<script setup>
const toTop = () => {
  // 点击后页面滚回顶部
  uni.pageScrollTo({ scrollTop: 0, duration: 200 })
}
</script>

<style lang="scss">
.btn {
  position: fixed;   /* 固定定位 */
  right: 12rpx;
  bottom: 100rpx;
  width: 100rpx;
  height: 100rpx;
}
</style>
vue
<!-- ============ 在首页 pages/index/index.vue 中追加 ============ -->
<template>
  <view class="home">
    <!-- 其它内容 -->
    <BackTop v-if="backFlag" />
  </view>
</template>

<script setup>
import { ref } from 'vue'
import { onPageScroll } from '@dcloudio/uni-app'
import BackTop from '@/components/back-top/back-top.vue'

const backFlag = ref(false)

// 监听页面滚动,滚动距离超过 200 再显示
onPageScroll((e) => {
  backFlag.value = e.scrollTop >= 200
})
</script>
vue
<!-- ============ pages/course/course.vue ============ -->
<template>
  <view class="course">
    <view class="item" v-for="item in courseList" :key="item.id">
      <image class="cover" :src="item.cover" />
      <view class="right">
        <view class="title">{{ item.title }}</view>
        <view class="bottom">
          <text class="price">¥{{ item.price }}</text>
          <text class="buy">已有{{ item.buyCount }}人购买</text>
        </view>
      </view>
    </view>

    <!-- 没有更多了:左右两条线用两个 view 画 -->
    <view class="no-more" v-if="status === 'noMore'">
      <view class="line"></view>
      <text>没有更多了</text>
      <view class="line"></view>
    </view>
  </view>
</template>

<script setup>
import { ref } from 'vue'
import { onLoad, onReachBottom } from '@dcloudio/uni-app'
import ajax from '@/api/index.js'

const page = ref(1)      // 默认第一页
const limit = ref(10)    // 每页 10 条
const status = ref('more')   // more / noMore
const courseList = ref([])

const getCourse = async () => {
  const result = await ajax.getCourse(page.value, limit.value)
  console.log('课程数据', result)
  // 追加,不是覆盖
  courseList.value = [...courseList.value, ...result.data.items]
  // 返回条数等于 10 说明还有下一页
  status.value = result.data.items.length === limit.value ? 'more' : 'noMore'
}

onLoad(() => {
  getCourse()
})

// 触底:有下一页才继续请求
onReachBottom(() => {
  if (status.value === 'more') {
    page.value++
    getCourse()
  }
})
</script>

<style lang="scss">
.course { background-color: #ccc; }
.item {
  display: flex;
  background: #fff;
  padding: 20rpx;
  margin: 20rpx 0;
}
.cover { width: 220rpx; height: 160rpx; }
.right {
  flex: 1;
  margin-left: 20rpx;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
}
.bottom { display: flex; justify-content: space-between; }
.price { color: #f00; }

.no-more {
  display: flex;
  align-items: center;
  justify-content: center;
  color: #999;
}
.line {
  width: 100rpx;
  height: 2rpx;
  background-color: #ccc;
  margin: 0 20rpx;
}
</style>

关键行与易错点

  • onPageScroll 也是页面生命周期,从 @dcloudio/uni-app 引入;滚动距离在事件对象 e.scrollTop 上,backFlag 为 ref,赋值必须写 .value。
  • 置顶组件用 position: fixed 固定在右下角,显示隐藏用 v-if 控制即可,别在组件里写死 display。
  • uni.pageScrollTo({ scrollTop: 0, duration: 200 }) 传的是配置对象,duration 单位是毫秒,写太大回弹会很慢。
  • 分页两个坑:① 新数据要 [...老数据, ...新数据] 追加,直接赋值会把前面的覆盖掉;② 触底时先判断 status === 'more',page.value++ 之后再请求,否则会无限重复请求同一页。
  • “没有更多了”的左右横线就是两个小 view 设固定宽高加背景色,用 flex 居中排开。

常见错误 ​

  • 现象:页面里直接写 uni.request,或每次请求都手写 URL。
    原因:绕过了网络请求二次封装和 API 统一管理。
    修法:页面只调用 API 层暴露的实例方法,如 req.getHome();URL 统一放 api/index.ts。

  • 现象:API 层导出类本身,页面调用时报“方法不存在”或需要反复 new。
    原因:对外暴露的是类,不是可直接使用的实例。
    修法:统一导出实例,如 export const req = new Request();页面引入后直接 req.xxx()。

  • 现象:onLoad 里直接写 await,或把大量请求逻辑堆在生命周期里。
    原因:生命周期函数不适合承载复杂异步业务,且容易写成非 async。
    修法:onLoad 只调用方法,如 getHomeData();方法内部再 async/await。

  • 现象:首页课程和复用同一个 Card 组件后,图片、标题、介绍显示空白或错位。
    原因:两类数据字段不同,如课程用 cover,用 avatar,组件未区分数据类型。
    修法:父组件传 type 标识;子组件按 type 取对应字段,并用 v-if 控制不同结构。

  • 现象:置顶按钮滚动后显示/隐藏逻辑反了,或滚动监听不生效。
    原因:阈值判断写反;Vue3 中直接改 this.xxx;生命周期未正确引入。
    修法:用 ref 控制显隐并 .value 赋值;从 @dcloudio/uni-app 引入 onPageScroll;点击回顶用 uni.pageScrollTo({ scrollTop: 0, duration: 200 })。

  • 现象:课程列表触底后重复请求、页码不增加,或没有更多数据仍继续请求。
    原因:未维护分页状态,未判断 status/more,也未做请求防重。
    修法:维护 page、status;触底先判断是否还有更多;请求前页码自增,返回后合并数组并更新状态。

  • 现象:uni-icons 不显示,或 easycom 配置后仍报组件不存在。
    原因:未安装依赖、未配置 easycom,或组件名/图标名写错。
    修法:按文档安装依赖并配置 easycom;组件直接使用;图标名、颜色、大小对照文档检查。

  • 现象:价格、购买人数、课程标题等渲染不出来。
    原因:字段名拼写错误或未对照接口文档。
    修法:以接口文档返回字段为准,先打印结果确认结构,再绑定字段。

练习 ​

  1. 完成网络请求二次封装:创建请求类并继承基础服务类,对外导出可直接调用的实例。
  2. 在首页 onLoad 中拉取首页数据,分别存储课程和数据,并渲染到页面。
  3. 改造 Card 组件:通过 type 区分课程和,分别处理图片、标题、介绍和底部结构。
  4. 实现置顶组件:页面滚动超过阈值显示,点击后平滑回到顶部。
  5. 完成课程列表分页:首屏加载第一页,触底加载下一页,无更多数据时展示“没有更多”。

原视频对照 ​

#视频时长
1热门课程的数据获取与渲染22:14
2完成首页课程数据的渲染16:52
3首页的置顶组件11:52
4课程的静态搭建12:23
5课程业务28:25

小结 ​

  • 首页业务先搭静态结构,再接接口和动态数据。
  • 请求必须走二次封装,接口地址统一放 API 层管理。
  • 页面生命周期只做调度,复杂异步逻辑抽成独立方法。
  • 同一组件复用于多类数据时,用 type 明确数据来源和渲染分支。
  • 置顶、触底加载等交互要绑定对应页面生命周期,并控制好状态。
  • 分页核心是页码、请求状态、数据合并和“没有更多”提示。
  • 字段名以接口文档为准,渲染前先打印确认数据结构。
  • uni-ui 组件优先按文档安装和配置,避免手写重复组件。