Skip to content

滚动容器与页面交互(一) ​

本页对应 滚动容器与页面交互,共 8 段视频,约 1 小时 28 分。

学习目标 ​

  • 能够说明登录相关接口为何无需携带 token,并在请求拦截器中通过“白名单”数组判断请求路径,实现按需携带 token
  • 能够使用 uni-app 的 uni-countdown 组件配合 v-if/v-else 与响应式变量,完成点击“获取验证码”后按钮与倒计时的切换显示
  • 能够将原生 uni-countdown 组件复制到自定义组件目录并二次封装,通过新增 showHour、showMinute 等属性控制小时、分钟及其冒号的显隐,实现仅显示秒级倒计时
  • 能够监听倒计时的 timeup 事件,在倒计时结束时恢复“获取验证码”按钮、更新按钮文字为“再次获取”,并通过 color 属性调整倒计时文字颜色
  • 能够按“目录分模块”的方式封装接口调用:在 service/user.js 中基于 uni.request 封装获取验证码与验证码登录 API,并遵循统一的 xxxAPI 命名规范
  • 能够为表单绑定 v-model 与 name,配置校验规则并通过 ref 获取表单实例调用 validate(),结合 try/catch 捕获校验结果并将校验返回值直接作为登录接口参数提交

知识点 ​

学习反馈:先厘清两个技术点 ​

本节反馈先处理两个技术点:请求白名单与 scroll-view 配置;随后把倒计时、验证码接口、登录接口和表单验证串起来。整体思路是:先明确业务前提,再决定代码放在哪、如何封装、如何调试。

白名单与请求拦截器 ​

为什么需要:不是所有接口都需要 token。登录相关接口的作用就是获取 token,此时用户本来就没有 token,或 token 已失效,再传 token 没有意义。转写稿反复强调:跟登录相关的接口不需要 token。

怎么想:拦截器必须知道“哪些接口不需要 token”。前端和后端要沟通清楚,把不需要 token 的接口路径放进一个数组,这个数组被形象地称为“白名单”。它本质上只是数组,不是神秘机制。

怎么做:按转写稿整理,核心判断是“不在白名单里才加 token”:

js
// 白名单只放“不需要 token”的接口路径,具体路径与后端确认
const whiteList = [/* 免 token 接口路径 */]

// 请求拦截器
if (!whiteList.includes(config.url)) {
  config.header.Authorization = token
}

易错点:不要把所有接口都统一加 token;不要写死假 token。转写稿调试时曾写死“一对腰”,后端就把它当 token,结果返回 token 错误。正确做法是先注掉假 token,等登录成功后再补正确 token。

scroll-view 二次封装 ​

为什么需要:不封装 scroll-view,页面也能滚动、下拉刷新,但 tabbar 线条颜色只有黑白,不灵活;底部安全区、滚动条隐藏等适配要在多个页面重复处理。

怎么想:做一个“假装是页面最大盒子”的组件,内容在盒子里滚动,视觉上仍像整页滚动。这样可统一加 tabbar 线、隐藏滚动条、适配底部小条。

怎么做:内部仍是 scroll-view,高度与页面一致,把 refresher-enabled、下拉动画等做成可配置变量,事件名也由外部传入,避免写死。

vue
<scroll-view
  :style="{ height: pageHeight }"
  :refresher-enabled="refreshEnable"
  :refresher-triggered="triggered"
>
  <slot />
</scroll-view>

易错点:属性不要固定写死,否则组件失去复用价值;组件文件夹名与组件名必须对应,否则页面会出现空白、组件不加载。

倒计时组件改造 ​

为什么需要:uni-ui 的 countdown 默认会显示天、小时、分钟、秒,但业务只想显示秒;其可配置性不足。

怎么想:不直接改原始组件库,而是复制到自定义组件目录再改,避免以后重新下载组件库覆盖代码。

怎么做:给组件补 showDay、showHour、showMinute 属性,在小时、分钟及冒号处加 v-if 控制。页面使用自定义组件:

vue
<custom-countdown
  :show-day="false"
  :show-hour="false"
  :show-minute="false"
  :time="10"
  @timeup="onCountDownTimeUp"
/>

倒计时结束触发 timeup,把 showCountDown 设为 false,让“获取验证码/再次获取”按钮重新显示。

易错点:复制组件后忘了改组件名与文件夹名;属性名与模板变量名不一致;time 不加冒号会被当字符串,出现“期望 number,却得到 string”的警告。

获取验证码接口封装 ​

为什么需要:页面直接散写请求不利于维护。按模块封装 API,登录相关放在 user.js,方法统一以 API 结尾,便于识别。

怎么想:先通过 v-model 拿到手机号,再调获取验证码接口;GET 参数要用 params 传递。

怎么做:

js
export const verifyCodeAPI = (data) => {
  return http.get('/code', { params: data })
}

调用时传 mobile 和 type,登录场景 type 为 login。

易错点:GET 不要直接写 data,要用 params;参数必须是对象;调试时先检查 token 是否被写死;接口返回通常固定包含 code、data、message。

登录接口与表单验证整合 ​

为什么需要:点登录要把手机号和验证码提交给后端,提交前必须校验数据,否则“传了 ABC”也会请求失败。

怎么想:表单加 model、rules,给表单项加 name,通过 formRef 获取组件实例,再调用 validate()。验证成功后的返回值恰好是表单数据,可直接作为接口参数。

怎么做:

js
const formRef = ref(null)

async function onSubmit() {
  try {
    const result = await formRef.value.validate()
    // result 即表单数据,可传给登录接口
  } catch (e) {
    console.log(e)
  }
}

易错点:form 容易写成 from,会导致后续变量全错;validate() 前要 await,否则 catch 捕获不到;规则名、name、数据字段名要三者对应;验证码不正确时要检查是否用了注册场景的验证码,而当前是登录场景。

先明确:哪些接口不需要 token ​

为什么需要:项目里不是所有接口都要带 token。登录相关接口的目的就是获取 token,或者因为 token 不存在、已失效才需要登录;此时再传 token 没有意义。
怎么想:先把“哪些接口不需要 token”明确出来,再在请求拦截器里判断当前请求路径是否属于这些接口。
怎么做:把不需要 token 的接口路径放进一个数组,也就是“白名单”;请求拦截器判断请求路径是否在白名单中,不在白名单里才加 token。
易错点:白名单本质就是数组,不要被名字吓住;哪些接口需要 token 要和后端确认,不能自己猜。

js
// 白名单:不需要传 token 的接口
const whiteList = ['/login', '/register', '/code']

// 请求拦截器中:不在白名单里才加 token
if (!whiteList.includes(config.url)) {
  config.header.Authorization = token
}

倒计时组件的显示切换 ​

为什么需要:点击“获取验证码”后,按钮要变成倒计时提示,避免用户连续点击。
怎么想:页面上有两个元素——获取验证码按钮和倒计时组件,用 v-if / v-else 控制显示,用一个响应式变量切换。
怎么做:给按钮加点击事件,点击后把 showCountDown 设为 true;倒计时组件用 v-if="showCountDown",按钮用 v-else。
易错点:ref 要记得导入;变量名不要写错;修改值要写 .value。

vue
<button v-else @click="onGetCode">获取验证码</button>
<uni-countdown v-if="showCountDown" :second="10" />

<script setup>
import { ref } from 'vue'
const showCountDown = ref(false)

const onGetCode = () => {
  showCountDown.value = true
}
</script>

二次封装倒计时:只保留秒 ​

为什么需要:uni-countdown 默认会显示天、小时、分钟、秒,但登录验证码场景通常只想要“多少秒后再次获取”。
怎么想:不改原始组件库,复制 uni-countdown 到自己的 components 目录,改造成 custom-countdown,增加 showHour、showMinute 等属性。
怎么做:在组件里找到小时变量 H、分钟变量 I,用 v-if 控制它们以及后面的冒号;在 props 里补上 showHour、showMinute。页面里使用改造后的组件,并传 :showDay="false"、:showHour="false"、:showMinute="false"。
易错点:复制组件后,组件名和文件夹名要一致,否则页面会空白;不要直接改 uni_modules 原始组件,否则重新下载会被覆盖。

vue
<custom-countdown
  :second="10"
  :showDay="false"
  :showHour="false"
  :showMinute="false"
/>

倒计时结束后的“再次获取” ​

为什么需要:倒计时结束后要恢复可点击状态,并提示“再次获取”,交互更自然。
怎么想:倒计时组件有 timeup 事件,结束后把 showCountDown 改回 false,按钮重新显示;同时把按钮文字从“获取验证码”改成“再次获取”。
怎么做:监听 timeup,在回调里修改显示变量和按钮文字变量。
易错点:timeup 事件名不要写错;按钮文字建议用变量控制,不要写死。

vue
<custom-countdown
  v-if="showCountDown"
  :second="10"
  @timeup="onCountDownTimeUp"
/>

<script setup>
const btnText = ref('获取验证码')
const onCountDownTimeUp = () => {
  showCountDown.value = false
  btnText.value = '再次获取'
}
</script>

获取验证码接口的封装与调用 ​

为什么需要:页面里直接写请求会越来越乱,接口要按模块统一管理。
怎么想:新建 service/user.js,登录相关接口都放这里;方法名统一以 API 结尾,方便识别。
怎么做:导入封装好的 http;获取验证码一般用 get,get 参数放到 params 里;调用时传手机号和 type: 'login'。
易错点:get 参数不要直接塞在 data 里,要用 params;调试时如果还带着假 token,后端可能返回 token 错误,要先去掉;手机号没传会提示 mobile 必填。

js
import http from '@/utils/http'

export const verifyCodeAPI = (data) => {
  return http.get('/code', {
    params: data
  })
}

登录接口与表单验证整合 ​

为什么需要:用户填完手机号和验证码后,要先校验数据格式,再提交登录接口。
怎么想:表单需要 v-model、name、rules,再通过 ref 拿到表单实例,调用 validate 验证;验证通过后把结果传给登录接口。
怎么做:给表单加 :model="formData"、:rules="rules"、ref="formRef";按钮点击时 await formRef.value.validate(),成功后再调 loginByMobileAPI(result)。
易错点:form 不要写成 from;validate 要写 .value;验证失败要用 try/catch 捕获;登录接口用 post,参数直接放对象里。

js
export const loginByMobileAPI = (data) => {
  return http.post('/login', data)
}
js
try {
  const result = await formRef.value.validate()
  await loginByMobileAPI(result)
} catch (err) {
  console.log(err)
}

调试时还遇到:倒计时只显示秒需要改组件;组件名和文件夹名不一致会导致组件不加载;验证码接口返回 901 要检查 token;mobile 必填要检查参数;form/from 写错会连带 code 报错。把这些点逐个排掉,倒计时和验证码登录流程就能跑通。

代码示例 ​

1. 请求封装 + 白名单(utils/request.js) ​

js
// utils/request.js
// 基地址以接口文档「调试」里给出的完整地址为准,不要写死测试地址
const BASE_URL = 'http://xxx.xx.xx.xx:8000'

// 白名单:存放「不需要携带 token」的接口路径
// 具体有哪些,需要和 后端 沟通确认
const whiteList = ['/login', '/register', '/password']

const http = (options) => {
  const config = { ...options }
  config.header = { ...(config.header || {}) }

  // 关键:取反 —— 没有在白名单里的接口(没有“特权”),才需要携带 token
  if (!whiteList.includes(config.url)) {
    const token = uni.getStorageSync('token')
    if (token) {
      config.header.Authorization = token
    }
  }

  return new Promise((resolve, reject) => {
    uni.request({
      url: BASE_URL + config.url,
      method: config.method || 'GET',
      // GET 的参数走 params,POST 的参数走 data
      data: (config.method || 'GET').toUpperCase() === 'GET' ? config.params : config.data,
      header: config.header,
      success: (res) => resolve(res.data), // 后端固定返回 { code, data, message }
      fail: (err) => reject(err)
    })
  })
}

export default http

要点 / 易错点

  • whiteList 本质就是一个数组,只是名字形象(有特权 = 走后门)。不要被名词唬住。
  • 与登录相关的接口一律不放 token:登录的目的就是获取 token,此时 token 要么不存在、要么已失效,带了没有任何意义。
  • 判断一定要取反:!whiteList.includes(config.url) —— 没在白名单里才加 token。
  • 调试时如果之前手写过 Authorization: 'xxx' 的假 token,一定要注释掉,否则后端会返回 token 错误。

2. 二次封装倒计时组件(components/custom-countdown/custom-countdown.vue) ​

vue
<template>
  <view class="uni-countdown">
    <text v-if="showDay" class="uni-countdown__number">{{ d }}</text>
    <text v-if="showDay" class="uni-countdown__sperator">天</text>

    <!-- 小时:原来只有 showDay 能控制,这里补上 showHour -->
    <text v-if="showHour" class="uni-countdown__number">{{ h }}</text>
    <text v-if="showHour" class="uni-countdown__sperator">:</text>

    <!-- 分钟:补上 showMinute -->
    <text v-if="showMinute" class="uni-countdown__number">{{ i }}</text>
    <text v-if="showMinute" class="uni-countdown__sperator">:</text>

    <!-- 秒:一定要显示 -->
    <text class="uni-countdown__number">{{ s }}</text>
  </view>
</template>

<script>
export default {
  name: 'CustomCountdown',
  props: {
    // 原组件自带的属性
    showDay: { type: Boolean, default: true },
    color: { type: String, default: '#000000' },
    second: { type: Number, default: 0 },
    // 我们自己新增的两个属性,必须先在这里声明,模板里才能用
    showHour: { type: Boolean, default: true },
    showMinute: { type: Boolean, default: true }
  },
  data() {
    return { d: 0, h: 0, i: 0, s: 0, timer: null }
  },
  created() {
    this.start()
  },
  beforeUnmount() {
    clearInterval(this.timer)
  },
  methods: {
    format(total) {
      this.d = Math.floor(total / 86400)
      this.h = Math.floor((total % 86400) / 3600)
      this.i = Math.floor((total % 3600) / 60)
      this.s = total % 60
    },
    start() {
      let total = this.second
      this.format(total)
      this.timer = setInterval(() => {
        if (total <= 0) {
          clearInterval(this.timer)
          this.$emit('time-up') // 倒计时结束,通知父组件
          return
        }
        total--
        this.format(total)
      }, 1000)
    }
  }
}
</script>

<style scoped>
.uni-countdown { display: flex; align-items: center; }
.uni-countdown__number { font-size: 26rpx; color: #00c250; }
.uni-countdown__sperator { font-size: 26rpx; margin: 0 4rpx; }
</style>

要点 / 易错点

  • 不要去改 uni_modules 里的原始组件:以后重新下载组件库,改动会被覆盖。拷一份到自己 components 目录再改。
  • 新增属性 showHour / showMinute 必须先在 props 里声明,否则模板里用不了(原组件是选项式 API)。
  • 小时和分钟后面的冒号也要一起加 v-if,否则会出现「:`」这种残缺显示。
  • 组件名必须和文件夹名一致(custom-countdown),否则页面里直接写标签不会自动加载,表现为一片空白。

3. 登录页:获取验证码 + 倒计时切换 + 表单验证(pages/login/mobile.vue) ​

vue
<template>
  <view class="mobile">
    <uni-forms ref="formRef" :model="formData" :rules="rules">
      <uni-forms-item label="手机号" name="mobile">
        <uni-easyinput v-model="formData.mobile" placeholder="请输入手机号" />
      </uni-forms-item>

      <uni-forms-item label="验证码" name="code">
        <uni-easyinput v-model="formData.code" placeholder="请输入验证码" />
        <view class="code-btn" @click="onGetCode">
          <!-- 未倒计时:显示按钮文字 -->
          <text v-if="!showCountDown">{{ btnText }}</text>
          <!-- 倒计时中:显示文字 + 自定义倒计时组件 -->
          <view v-else class="countdown-box">
            <text class="tip">多少秒之后再次获取</text>
            <custom-countdown
              :second="10"
              :show-day="false"
              :show-hour="false"
              :show-minute="false"
              color="#00c250"
              @time-up="onCountDownTimeUp"
            />
          </view>
        </view>
      </uni-forms-item>
    </uni-forms>

    <button type="primary" @click="onFormSubmit">登录</button>
  </view>
</template>

<script setup>
import { ref } from 'vue'
import { verifyCodeAPI, loginByMobileAPI } from '@/service/user.js'

const formRef = ref(null)
const formData = ref({ mobile: '', code: '' })
const showCountDown = ref(false) // 控制按钮 / 倒计时 的切换
const btnText = ref('获取验证码')

// 规则名 mobile、code 必须和 uni-forms-item 的 name、以及 formData 的字段一一对应
const rules = {
  mobile: {
    rules: [
      { required: true, errorMessage: '请输入手机号' },
      { pattern: '^1\\d{10}$', errorMessage: '手机号格式不正确' }
    ]
  },
  code: {
    rules: [
      { required: true, errorMessage: '请输入验证码' },
      { pattern: '^\\d{6}$', errorMessage: '验证码为 6 位数字' }
    ]
  }
}

// 点击获取验证码
const onGetCode = async () => {
  if (showCountDown.value) return
  showCountDown.value = true // 切换成倒计时组件
  try {
    // 两个参数:手机号 + 场景(登录场景传 login)
    const res = await verifyCodeAPI({ mobile: formData.value.mobile, type: 'login' })
    console.log(res) // { code, data, message }
  } catch (e) {
    console.log(e)
  }
}

// 倒计时结束:隐藏倒计时,显示按钮,并改文案
const onCountDownTimeUp = () => {
  showCountDown.value = false
  btnText.value = '再次获取'
}

// 点击登录:先校验,再调接口
const onFormSubmit = async () => {
  try {
    const result = await formRef.value.validate() // 校验通过,result 就是表单数据
    await loginByMobileAPI(result)
  } catch (e) {
    console.log(e) // 校验失败或接口失败都走这里
  }
}
</script>

<style scoped>
.mobile { padding: 40rpx; }
.code-btn { position: absolute; right: 20rpx; top: 0; line-height: 70rpx; }
.countdown-box { display: flex; align-items: center; }
.tip { font-size: 26rpx; color: #00c250; margin-right: 6rpx; }
</style>

要点 / 易错点

  • 切换用的是 v-if / v-else + 一个 ref 变量(showCountDown),点击时 showCountDown.value = true,.value 千万别丢。
  • :second="10" 要加冒号才是数值;不加冒号会被当成字符串,控制台报「期望 number,收到 string」的警告。
  • time-up 事件里把 showCountDown 改回 false,同时把按钮文字改成「再次获取」,交互才完整。
  • 校验三步走:① :model="formData" ② 每个 uni-forms-item 写 name(和字段名一致) ③ :rules="rules",最后用 formRef.value.validate() 触发。
  • 常见低级错误:把 form 写成 from(formData 写成 fromDate),会连带一片报错;validate() 属于组件实例,必须 formRef.value.validate()。
  • 校验失败会抛 Promise 异常,必须 try / catch 包起来,否则控制台一片红。
  • 接口封装统一放在 service/user.js,方法名以 API 结尾(verifyCodeAPI、loginByMobileAPI),看到名字就知道是在调接口;GET 参数用 params,POST 参数用 data。

常见错误 ​

  1. 登录接口也带上了 token,后端返回 901 现象:调获取验证码接口,控制台返回 token 错误 / 未登录。 原因:请求拦截器里无条件 config.header.Authorization = ...,且演示阶段还写死了一对占位引号;登录类接口本就没有或已失效 token,传了等于传垃圾。 修法:把登录相关接口路径放进白名单数组,用请求的 pathname 判断,未命中白名单才加 token;调试期把写死的 Authorization 注掉,登录成功后再补真 token。

  2. 白名单判断写了但没生效 现象:加进数组的接口仍然带 token,或反过来所有接口都不带。 原因:拿完整 URL(含 baseURL、query)去 includes,与数组里存的相对路径对不上;或者判断前多写了一个取反。 修法:统一用路径部分比对,打印一次实际参与比较的值确认;理清语义——"在数组里=不需要 token,不在=需要"。

  3. 复制倒计时组件后页面一片空白 现象:点获取验证码没反应,组件区域空白,控制台无报错。 原因:只改了文件内容,忘了把文件夹/文件名改成与组件名一致,easycom 自动加载匹配不到。 修法:自定义目录下文件夹名、组件名、模板标签三者保持一致,改完重启 dev 服务。

  4. 直接改 uni_modules 里的源码 现象:本地跑得好好的,重新下载/更新组件库后改动全没了。 原因:改的是依赖源码目录,被覆盖。 修法:把组件整体拷到 components 自定义目录再改造,页面引用自定义名。

  5. 给组件传了 showHour/showMinute 但不生效 现象:模板里 v-if 已写,页面仍显示小时/分钟。 原因:只改了模板,没在选项式 props 里声明这两个属性,值传不进来。 修法:在 props 中补声明(含默认值),再让 v-if 绑定对应变量;注意小时、分钟后面的冒号也要一并控制,否则会留下孤立的 :。

  6. 倒计时结束后按钮回不来 现象:倒计时走完停在 0,无法再次点击获取。 原因:没监听 time-up 事件,或监听了但没把控制变量置回 false。 修法:监听 time-up,将 showCountDown.value = false,同时把按钮文案切成"再次获取"。

  7. get 请求参数塞进 data,后端报 mobile 必填 现象:请求发出去了,返回参数缺失。 原因:get 参数要走 query,须用 params;data 是给 post 用的。 修法:http.get(url, { params: {...} }),并在页面调用时把 mobile 和 type(如 login/register)都传全。

  8. 表单验证不触发或直接抛红 现象:点登录没校验,或控制台出现未捕获的 Promise 拒绝。 原因:ref 名与模板不一致、form 写成 from、字段 name 与 rules 键名不匹配;validate() 失败会 reject 而没接 catch。 修法:三处命名对齐后 formRef.value.validate(),外层用 try/catch(await 场景),成功分支直接复用返回值当请求参数。

练习 ​

  1. 在请求拦截器中实现 token 白名单:定义存放"无需 token 接口路径"的数组,用请求路径判断,未命中才注入 Authorization。写完后验证登录接口不再返回 901,并说明为什么登录类接口天然不需要 token。

  2. 基于 uni_modules 中的 countdown 组件做二次封装:拷贝到自定义目录并改名,新增 showHour、showMinute 两个属性,控制小时/分钟及其后冒号的显隐;在页面中通过属性把倒计时裁剪成"只剩秒"。写完后自测:把组件名改错会有什么表现?

  3. 完成倒计时交互闭环:点击"获取验证码"→ 按钮消失、倒计时出现且前缀显示"XX 秒后再次获取"→ 结束后倒计时消失、按钮恢复且文案变为"再次获取"。要求用一个布尔变量驱动,并说明两个元素为什么必须互斥显示。

  4. 在 service/user.js 中封装两个接口:获取短信验证码(get,参数 mobile、type)与手机验证码登录(post,参数 mobile、code),命名统一以 API 结尾。在页面中分别调用,打印返回的三个字段,并解释 get 与 post 传参位置的差别。

  5. 给登录表单补齐校验:为手机号、验证码绑定 v-model 与 name,配置 rules,获取表单组件实例后调用 validate,用 try/catch 处理失败提示;成功后直接把校验返回值作为登录接口参数提交。完成后故意留空提交一次,观察并说明现象。

课堂画面 ​

课堂画面 01

课堂画面 02

课堂画面 03

课堂画面 04

课堂画面 05

课堂画面 06

原视频对照 ​

#视频时长
1学习反馈16:40
2优医咨询-登录倒计时组件-111:05
3优医咨询-倒计时组件-211:12
4优医咨询-倒计时组件-305:50
5优医咨询-获取验证码接口封装13:43
6优医咨询-获取验证码接口调用09:27
7优医咨询-验证码登录接口封装06:54
8优医咨询-验证码登录数据验证13:59

小结 ​

  1. token 不是所有接口都要传,"哪些不传"必须由前端显式声明成数组,拦截器只做判断,不做猜测。
  2. 登录类接口不带 token 是逻辑必然,不是偷懒:它的目的就是拿 token,此时本地要么没有、要么已失效。
  3. 白名单本质就是普通数组,叫这个名字只是因为它享有"免传 token"的特权,别被术语吓住。
  4. 公共组件的价值在于"只改一处":tabbar 下划线、底部安全区、滚动条隐藏这类增强,封装一次即可全站复用。
  5. 第三方组件不满足需求时,优先"拷贝出来二次封装",而不是直接改依赖源码——可配置性靠 props,可持续性靠不改原库。
  6. 组件改造有两处最容易漏:属性没在 props 里声明、复制后名字没对齐导致组件加载不到;这两处排查成本高,写的时候就该对上。
  7. 接口调用要成体系:按模块分文件、命名带统一后缀、请求参数位置(params/data)严格按方法区分,后续维护才不混乱。
  8. 表单校验是提交前的闸门,校验失败走 catch、成功直接复用返回值,能省掉一次手动取值的冗余代码。