Skip to content

uniapp小程序1

一、uni-app 基础

1. 创建 uni-app 项目方式

uni-app 支持两种方式创建项目:

  1. 通过 HBuilderX 创建(需安装 HBuilderX 编辑器)

  2. 通过命令行创建(需安装 NodeJS 环境)

2. HBuilderX 创建 uni-app 项目

  1. 下载安装 HbuilderX 编辑器

[Missing Image: uniapp_picture_1.png]

  1. 通过 HbuilderX 创建 uni-app vue3 项目

[Missing Image: uniapp_picture_2.png]

  1. 安装 uni-app vue3 编译器插件

[Missing Image: uniapp_picture_3.png]

  1. 编译成微信小程序端代码

[Missing Image: uniapp_picture_4.png]

  1. 开启服务端口

[Missing Image: uniapp_picture_5.png]

  1. 模拟器窗口分离和置顶

[Missing Image: uniapp_picture_6.png]

HBuildeX 和 微信开发者工具关系

[Missing Image: uniapp_picture_7.png]HBuildeXuni-app 都属于 DCloud 公司的产品。

3. pages.json 和 tabBar 案例

目录结构

我们先来认识 uni-app 项目的目录结构。

sh
├─pages            业务页面文件存放的目录
  └─index
     └─index.vue  index页面
├─static           存放应用引用的本地静态资源的目录(注意:静态资源只能存放于此)
├─unpackage        非工程代码,一般存放运行或发行的编译结果
├─index.html       H5端页面
├─main.js          Vue初始化入口文件
├─App.vue          配置App全局样式、监听应用生命周期
├─pages.json       配置页面路由、导航栏、tabBar等页面类信息
├─manifest.json    配置appid、应用名称、logo、版本等打包信息
└─uni.scss         uni-app内置的常用样式变量

解读 pages.json

用于配置页面路由、导航栏、tabBar 等页面类信息

案例练习

效果预览 案例练习

参考代码

json
{
  // 页面路由
  "pages": [
    {
      "path": "pages/index/index",
      // 页面样式配置
      "style": {
        "navigationBarTitleText": "首页"
      }
    },
    {
      "path": "pages/my/my",
      "style": {
        "navigationBarTitleText": "我的"
      }
    }
  ],
  // 全局样式配置
  "globalStyle": {
    "navigationBarTextStyle": "white",
    "navigationBarTitleText": "uni-app",
    "navigationBarBackgroundColor": "#27BA9B",
    "backgroundColor": "#F8F8F8"
  },
  // tabBar 配置
  "tabBar": {
    "selectedColor": "#27BA9B", // 选中时字体颜色
    "list": [
      {
        "pagePath": "pages/index/index",
        "text": "首页",
        "iconPath": "static/tabs/home_default.png",
        "selectedIconPath": "static/tabs/home_selected.png"
      },
      {
        "pagePath": "pages/my/my",
        "text": "我的",
        "iconPath": "static/tabs/user_default.png",
        "selectedIconPath": "static/tabs/user_selected.png"
      }
    ]
  }
}

注意:

​ 静态文件要存放在 static 文件夹下

4. uni-app 和原生小程序开发区别

开发区别:

uni-app 项目每个页面是一个 .vue 文件,数据绑定及事件处理同 Vue.js 规范:

  1. 属性绑定 src="" data-missing="" 升级成 :src="" data-missing="url"

  2. 事件绑定 bindtap="eventName" 升级成 @tap="eventName"支持()传参

  3. 支持 Vue 常用指令 v-forv-ifv-showv-modelv-bindv-on

其他区别补充:

  1. 调用接口能力,建议前缀 wx 替换为 uni ,养成好习惯,支持多端开发

    js
    wx.previewImage --> uni.previewImage
  2. <style> 标签内不需要写 scoped,小程序是多页面应用页面样式自动隔离(编译成H5端会自动添加 scoped

    vue
    <style scoped></style>  -->  <style></style>
  3. 生命周期分三部分:应用生命周期(小程序),页面生命周期(小程序),组件生命周期(Vue)

    小程序应用生命周期:

    • onLaunch
    • onShow
    • onHide

    小程序页面生命周期:

    • onLoad
    • onShow
    • onReady
    • onHide
    • onUnload

    Vue组件生命周期

    • setup
    • onBeforeMount
    • onMounted
    • onBeforeUpdate
    • onUpdated
    • onBeforeUnmount
    • onUnmounted

案例练习

主要功能:

  1. 滑动轮播图
  2. 点击大图预览

效果预览: 案例练习

参考代码:

vue
<template>
  <swiper
    class="banner"
    autoplay
    circular
    interval="3000"
    indicator-dots
    indicator-color="#eefff9"
    indicator-active-color="#57ba88"
  >
    <swiper-item v-for="item in pictures" :key="item.id">
      <image :src="" data-missing="item.url" @tap="onPreviewImage(item.url)"></image>
    </swiper-item>
  </swiper>
</template>

<script setup>
// 组合式API
import { reactive } from "vue";
const pictures = reactive([
  {
    id: "1",
    url: "https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/uploads/goods_preview_1.jpg",
  },
  {
    id: "2",
    url: "https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/uploads/goods_preview_2.jpg",
  },
  {
    id: "3",
    url: "https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/uploads/goods_preview_3.jpg",
  },
  {
    id: "4",
    url: "https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/uploads/goods_preview_4.jpg",
  },
  {
    id: "5",
    url: "https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/uploads/goods_preview_5.jpg",
  },
]);
// 大图预览
const onPreviewImage = (url) => {
  // uni. 会根据打包成的环境自动更改为 wx.或其他格式
  uni.previewImage({
    urls: pictures.map((item) => item.url),
    current: url,
  });
};
</script>

<style>
.banner,
.banner image {
  width: 750rpx;
  height: 750rpx;
}
</style>

5. 命令行创建 uni-app 项目

优势:

通过命令行创建 uni-app 项目,不必依赖 HBuilderX,TypeScript 类型支持友好。

命令行创建 uni-app 项目:

vue3 + ts 版

sh
# 通过 npx 从 github 下载
npx degit dcloudio/uni-preset-vue#vite-ts 项目名称
sh
# 通过 git 从 gitee 克隆下载 (👉备用地址)
git clone -b vite-ts https://gitee.com/dcloud/uni-preset-vue.git

创建其他版本可查看:uni-app 官网

常见问题

编译和运行 uni-app 项目:

  1. 安装依赖 pnpm install
  2. 编译成微信小程序 pnpm dev:mp-weixin
  3. 将项目根目录下的 /dist/dev/mp-weixn 导入微信开发者工具中进行预览
tips:编译成H5端

tips:编译成H5端 编译成 H5 端可运行 pnpm dev:h5 通过浏览器预览项目。

6. 用 VS Code 开发 uni-app 项目

为什么选择 VS Code?

  • VS Code 对 TS 类型支持友好,前端开发者主流的编辑器
  • HbuilderX 对 TS 类型支持暂不完善

用 VS Code 开发配置

  • 👉 前置工作:安装 Vue3 插件,点击查看官方文档
    • 安装 Vue Language Features (Volar) :Vue3 语法提示插件
    • 安装 TypeScript Vue Plugin (Volar) :Vue3+TS 插件
    • 工作区禁用 Vue2 的 Vetur 插件(Vue3 插件和 Vue2 冲突)
    • 工作区禁用 @builtin typescript 插件(禁用后开启 Vue3 的 TS 托管模式)
    • TS版本 使用工作区版本
  • 👉 安装 uni-app 开发插件
    • uni-create-view :快速创建 uni-app 页面
    • uni-helper uni-app :代码提示
    • uniapp 小程序扩展 :鼠标悬停查文档
  • 👉 TS 类型校验
    • 安装 类型声明文件 pnpm i -D miniprogram-api-typings @uni-helper/uni-app-types
    • 配置 tsconfig.json
  • 👉 JSON 注释问题
    • 设置文件关联,把 manifest.jsonpages.json 设置为 jsonc(即可在json文件中添加注释)

tsconfig.json 参考

json
// tsconfig.json
{
  "extends": "@vue/tsconfig/tsconfig.json",
  "compilerOptions": {
    "sourceMap": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    },
    "lib": ["esnext", "dom"],
    // 类型声明文件
    "types": [
      "@dcloudio/types", // uni-app API 类型
      "miniprogram-api-typings", // 原生微信小程序类型
      "@uni-helper/uni-app-types" // uni-app 组件类型
    ]
  },
  // vue 编译器类型,校验标签类型
  "vueCompilerOptions": {
    "nativeTags": ["block", "component", "template", "slot"], 
  },
  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
}

工作区设置参考

[Missing Image: image-20240722111543563.png]

版本升级

  • 原依赖 @types/wechat-miniprogram 现调整为 miniprogram-api-typings
  • 原配置 experimentalRuntimeMode 现调整为 nativeTags

这一步处理很关键,否则 TS 项目无法校验组件属性类型

注意:

​ 在tsconfig.json中配置完毕TS 类型校验后如果不生效:

  1. 请安装 Vue - Official 插件的 2.0.12版本

  2. typescript修改为使用工作区版本

image-20240721154120226image-20240721154215324

  1. 重启项目

配置成功后,重启项目 view、text等标签的颜色会发生变化且会检验标签属性类型。

[Missing Image: image-20240721154732927.png]

7. 开发工具回顾

选择自己习惯的编辑器开发 uni-app 项目即可。

HbuilderX 和 微信开发者工具 关系

[Missing Image: uniapp_picture_7-1721482650591.png]

VS Code 和 微信开发者工具 关系

[Missing Image: uniapp_picture_8.png]

二、小兔鲜儿 - 项目初始化

1. 效果预览

体验小程序端体验 H5 端体验 App 端(安卓)

📗 接口文档

https://www.apifox.cn/apidoc/shared-0e6ee326-d646-41bd-9214-29dbf47648fa/

📦 项目源码

https://gitee.com/Megasu/uniapp-shop-vue3-ts/

项目架构

项目架构图

[Missing Image: index_picture_1.png]

2. 拉取项目模板代码

项目模板包含:目录结构,项目素材,代码风格。

模板地址:

sh
git clone http://git.itcast.cn/heimaqianduan/erabbit-uni-app-vue3-ts.git heima-shop

注意: 小程序真机预览需在 manifest.json 中添加微信小程序的 appid

3. 引入 uni-ui 组件库

操作步骤

安装 uni-ui 组件库

sh
pnpm i @dcloudio/uni-ui

配置自动导入组件

json
// pages.json
{
  // 组件自动导入
  "easycom": {
    "autoscan": true,
    "custom": {
      // uni-ui 规则如下配置
      "^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue"
    }
  },
  "pages": [
    // …省略
  ]
}

安装类型声明文件

uni-ui组件库未提供 ts 类型声明,需要使用第三方提供的类型声明文件,可以提供提示和类型检测

sh
pnpm i -D @uni-helper/uni-ui-types

作用:可以检测到组件的类型,并对组件的属性进行类型校验,避免错误书写属性值

配置类型声明文件

json
// tsconfig.json
{
  "compilerOptions": {
    // ...
    "types": [
      "@dcloudio/types", // uni-app API 类型
      "miniprogram-api-typings", // 原生微信小程序类型
      "@uni-helper/uni-app-types", // uni-app 组件类型
      "@uni-helper/uni-ui-types" // uni-ui 组件类型
    ]
  },
  // vue 编译器类型,校验标签类型
  "vueCompilerOptions": {
    "nativeTags": ["block", "component", "template", "slot"]
  }
}
Tips:为 uni-ui 组件添加类型

Tips:为 uni-ui 组件添加类型

配置uni-ui 组件类型后 uni-ui 的组件标签颜色会发生变化且鼠标悬停后会有类型检测

image-20240721154517531

4. 小程序端 Pinia 持久化

说明:Pinia 用法与 Vue3 项目完全一致,uni-app 项目仅需解决持久化插件兼容性问题。

持久化存储插件

安装自动持久化存储插件: pinia-plugin-persistedstate

sh
pnpm i pinia-plugin-persistedstate

插件默认使用 localStorage 实现持久化,小程序端不兼容,需要替换持久化 API。

基本用法

  1. 在项目中使用持久化存储插件:
ts
import { createPinia } from 'pinia'
import persist from 'pinia-plugin-persistedstate'

// 创建 pinia 实例
const pinia = createPinia()
// 使用持久化存储插件
pinia.use(persist)

// 默认导出,给 main.ts 使用
export default pinia

// 模块统一导出
export * from './modules/member'
  1. mian.ts 安装使用 pinia
ts
import { createSSRApp } from 'vue'
import pinia from './stores'

import App from './App.vue'
export function createApp() {
  const app = createSSRApp(App)

  app.use(pinia)
  return {
    app,
  }
}
  1. 浏览器端自动持久化存储:
ts
import { defineStore } from 'pinia'
import { ref } from 'vue'

// 定义 Store
export const useMemberStore = defineStore(
  'member',
  () => {
    // 会员信息
    const profile = ref<any>()

    // 保存会员信息,登录时使用
    const setProfile = (val: any) => {
      profile.value = val
    }

    // 清理会员信息,退出时使用
    const clearProfile = () => {
      profile.value = undefined  // 将pinia中的数据设置为undefined即可清除本存储的数据
    }

    // 记得 return
    return {
      profile,
      setProfile,
      clearProfile,
    }
  },
  // TODO: 持久化
  {
    persist: true,
  },
)

多端兼容

网页端持久化 API

ts
localStorage.setItem()
localStorage.getItem()

小程序端持久化 API

wx.setStorageSync()
wx.getStorageSync()

多端持久化 API(兼容多端API)

ts
uni.setStorageSync()
uni.getStorageSync()

修改 persist 配置,调整为多端兼容的 API,既可以实现浏览器本地存储,也可以实现小程序端本地存储

ts
// stores/modules/member.ts
export const useMemberStore = defineStore(
  'member',
  () => {
    //…省略
  },
  {
    // 配置自动持久化存储
    // 网页端配置持久化存储
    // persist: true,
    // 调整为兼容多端的API
    persist: {
      storage: {
        setItem(key, value) {
          uni.setStorageSync(key, value) 
        },
        getItem(key) {
          return uni.getStorageSync(key) 
        },
      },
    },
  },
)

5. uni.request 请求拦截器

请求和上传文件拦截器

uniapp 拦截器uni.addInterceptor

接口说明接口文档

实现需求

  1. 拼接基础地址
  2. 设置超时时间
  3. 添加请求头标识(识别请求来自小程序端,还是App端)
  4. 添加 token

参考代码

ts
// src/utils/http.ts

// 请求基地址
const baseURL = 'https://pcapi-xiaotuxian-front-devtest.itheima.net'

// 拦截器配置
const httpInterceptor = {
  // 拦截前触发
  invoke(options: UniApp.RequestOptions) {
    // 1. 非 http 开头需拼接地址
    if (!options.url.startsWith('http')) {
      options.url = baseURL + options.url
    }
    // 2. 请求超时
    options.timeout = 10000
    // 3. 添加小程序端请求头标识
    options.header = {
      'source-client': 'miniapp',
      ...options.header, // 保留调用接口时传递的请求头设置
    }
    // 4. 添加 token 请求头标识
    const memberStore = useMemberStore()
    const token = memberStore.profile?.token
    if (token) {
      options.header.Authorization = token
    }
  },
}

// 拦截 request 请求
uni.addInterceptor('request', httpInterceptor)
// 拦截 uploadFile 文件上传
uni.addInterceptor('uploadFile', httpInterceptor)

注意事项:

微信小程序端,需登录 微信公众平台 配置合法域名 👇(或开启不校验合法域名)

https://pcapi-xiaotuxian-front-devtest.itheima.net

6. 封装请求函数

实现需求:

  1. 返回 Promise 对象,用于处理返回值类型 (借鉴 axios)
  2. 成功 resolve
    1. 提取数据
    2. 添加泛型
  3. 失败 reject
    1. 401 错误(Token过期)
    2. 其他错误
    3. 网络错误

参考代码

ts
/* 添加请求拦截器
拦截 request 请求
拦截 uploadFile 文件上传
需要实现功能:
 1. URL地址拼接
 2. 设置请求超时时间
 3. 添加小程序端请求头标识
 4. 添加 token 请求头标识
*/
import { useMemberStore } from '@/stores'
// 获取用户仓库对象
const memberStore = useMemberStore()

// 服务器基础地址
const baseURL = 'https://pcapi-xiaotuxian-front-devtest.itheima.net' // 开发服务器
// 添加请求拦截器
const httpInterceptor = {
  // 拦截前触发invoke函数
  invoke(options: UniApp.RequestOptions) {
    // 1. URL地址拼接
    if (!options.url.startsWith('http')) {
      options.url = baseURL + options.url
    }
    // 2. 设置请求超时时间 默认 60s
    options.timeout = 10000 // 毫秒
    // 3. 添加小程序端请求头标识
    options.header = {
      ...options.header,
      'source-client': 'miniapp',
    }
    // 4. 添加 token 请求头标识
    // 从用户仓库中获取token
    const token = memberStore.profile?.token
    if (token) {
      options.header.Authorization = token
    }
  },
}
// 为 request,uploadFile 添加拦截器
uni.addInterceptor('request', httpInterceptor)
uni.addInterceptor('uploadFile', httpInterceptor)

/**
 * 请求函数
 * @param  UniApp.RequestOptions
 * @returns Promise
 *  1. 返回 Promise 对象,用于处理返回值类型
 *  2. 获取数据成功
 *    2.1 提取核心数据 res.data
 *    2.2 添加类型,支持泛型
 *  3. 获取数据失败
 *    3.1 401错误  -> 清理用户信息,跳转到登录页
 *    3.2 其他错误 -> 根据后端错误信息轻提示
 *    3.3 网络错误 -> 提示用户换网络
 */
// 定义返回数据的类型 返回数据的类型由接口方法调用时传递的泛型决定
interface Data<T> {
  code: string
  msg: string
  result: T
}
// 2.2 添加类型,支持泛型
// 简化请求函数
export const http = {
  request: <T>(options: UniApp.RequestOptions) => {
    // 1. 返回 Promise 对象
    return new Promise<Data<T>>((resolve, reject) => {
      uni.request({
        ...options,
        // 2. 请求成功
        async success(res: UniApp.RequestSuccessCallbackResult) {
          // 判断响应状态码
          if (res.statusCode >= 200 && res.statusCode < 300) {
            // 状态码2xx
            // 判断响应状态码 res.data.code==='1' 表示请求成功
            const code = (res.data as Data<T>).code
            if (code === '1') {
              resolve(res.data as Data<T>) // 返回简化后的数据
            } else {
              // 请求错误未返回数据
              uni.showToast({
                title: (res.data as Data<T>).msg || '请求错误',
                icon: 'none',
              })
            }
          } else if (res.statusCode === 401) {
            // 提示用户登录过期重新登录
            const modalRes = await uni.showModal({
              title: '提示',
              content: '登录过期,请重新登录',
              showCancel: false,  // z不展示取消按钮
              mask: true, // 设置蒙层(防止触摸穿透)
            })
            // 用户点击确定按钮
            if (modalRes.confirm) {
              // 状态码401: token失效
              // 清除用户信息
              memberStore.clearProfile()
              // 跳转到微信登录页面
              uni.navigateTo({
                url: '/pages/login/login',
              })
              reject(res)
            }
          } else {
            // 其他错误
            uni.showToast({
              title: (res.data as Data<T>).msg || '请求错误',
              icon: 'none',
            })
            reject(res)
          }
        },
        // 请求失败(网络错误)
        fail(err) {
          uni.showToast({
            title: '请检查网络连接',
            icon: 'none',
            mask: true,
          })
          reject(err)
        },
      })
    })
  },
  //  简化请求方式
  get: <T>(url: string, data = {}, config = {}) => {
    return http.request<T>({
      url,
      method: 'GET',
      data,
      ...config,
    })
  },
  post: <T>(url: string, data = {}, config = {}) => {
    return http.request<T>({
      url,
      method: 'POST',
      data,
      ...config,
    })
  },
  put: <T>(url: string, data = {}, config = {}) => {
    return http.request<T>({
      url,
      method: 'PUT',
      data,
      ...config,
    })
  },
  delete: <T>(url: string, data = {}, config = {}) => {
    return http.request<T>({
      url,
      method: 'DELETE',
      data,
      ...config,
    })
  },
 // 上传文件
  upload: <T>({ url = '', filePath = '', name = 'file', config = {} }) => {
    return new Promise<Data<T>>((resolve, reject) => {
      uni.uploadFile({
        ...config,
        url,
        filePath,
        name,
        // 上传成功
        async success(res: UniApp.UploadFileSuccessCallbackResult) {
          // 判断响应状态码
          if (res.statusCode >= 200 && res.statusCode < 300) {
            // 状态码2xx
            // 2.1 提取核心数据 res.data
            resolve(JSON.parse(res.data as string))
          } else {
            // 其他错误
            uni.showToast({
              title: JSON.parse(res.data as string).msg || '请求错误',
              icon: 'none',
            })
            reject(res)
          }
        },
        // 请求失败(网络错误)
        fail(err) {
          uni.showToast({
            title: '请检查网络连接',
            icon: 'none',
            mask: true,
          })
          reject(err)
        },
      })
    })
  },
}

调用封装好的请求函数

js
http.get|post|delete|put<返回数据的ts类型>(url[,data,config])
示例:
const result = await http.get<string[]>('/home/banner')

泛型的使用:

[Missing Image: image-20240721174312938.png]

【拓展】代码规范

为什么需要代码规范

如果没有统一代码风格,团队协作不便于查看代码提交时所做的修改。

[Missing Image: index_picture_2.png]

统一代码风格

  • 安装 eslint + prettier
sh
pnpm i -D eslint prettier eslint-plugin-vue @vue/eslint-config-prettier @vue/eslint-config-typescript @rushstack/eslint-patch @vue/tsconfig
  • 新建 .eslintrc.cjs 文件,添加以下 eslint 配置
js
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')

module.exports = {
  root: true,
  extends: [
    'plugin:vue/vue3-essential',
    'eslint:recommended',
    '@vue/eslint-config-typescript',
    '@vue/eslint-config-prettier',
  ],
  // 小程序全局变量
  globals: {
    uni: true,
    wx: true,
    WechatMiniprogram: true,
    getCurrentPages: true,
    getApp: true,
    UniApp: true,
    UniHelper: true,
    App: true,
    Page: true,
    Component: true,
    AnyObject: true,
  },
  parserOptions: {
    ecmaVersion: 'latest',
  },
  rules: {
    'prettier/prettier': [
      'warn',
      {
        singleQuote: true,
        semi: false,
        printWidth: 100,
        trailingComma: 'all',
        endOfLine: 'auto',
      },
    ],
    'vue/multi-word-component-names': ['off'],
    'vue/no-setup-props-destructure': ['off'],
    'vue/no-deprecated-html-element-is': ['off'],
    '@typescript-eslint/no-unused-vars': ['off'],
  },
}
  • 配置 package.json
json
{
  "script": {
    // ... 省略 ...
    "lint": "eslint . --ext .vue,.js,.ts --fix --ignore-path .gitignore"
  }
}
  • 运行
sh
pnpm lint

到此,已完成 eslint + prettier 的配置。

Git 工作流规范

  • 安装并初始化 husky
sh
pnpm dlx husky-init
sh
npx husky-init
  • 安装 lint-staged
sh
pnpm i -D lint-staged
  • 配置 package.json
json
{
  "script": {
    // ... 省略 ...
  },
  "lint-staged": {
    "*.{vue,ts,js}": ["eslint --fix"]
  }
}
  • 修改 .husky/pre-commit 文件
diff
npm test   // [!code --]
npm run lint-staged     // [!code ++]

到此,已完成 husky + lint-staged 的配置。

三、小兔鲜儿 - 首页模块

涉及知识点:组件通信、组件自动导入、数据渲染、触底分页加载、下拉刷新等。

1. 自定义导航栏

1.1 参考效果

自定义导航栏的样式需要适配不同的机型。

[Missing Image: home_picture_1.png]

操作步骤:

  1. 准备组件静态结构
  2. 修改页面配置,隐藏默认导航栏,修改文字颜色
  3. 样式适配 -> 安全区域

1.2 静态结构

新建业务组件:src/pages/index/componets/CustomNavbar.vue

vue
<script setup lang="ts">
//
</script>

<template>
  <view class="navbar">
    <!-- logo文字 -->
    <view class="logo">
      <image class="logo-image" src="" data-missing="logo.png"></image>
      <text class="logo-text">新鲜 · 亲民 · 快捷</text>
    </view>
    <!-- 搜索条 -->
    <view class="search">
      <text class="icon-search">搜索商品</text>
      <text class="icon-scan"></text>
    </view>
  </view>
</template>

<style lang="scss">
/* 自定义导航条 */
.navbar {
  background-image: url(@/static/images/navigator_bg.png);
  background-size: cover;
  position: relative;
  display: flex;
  flex-direction: column;
  padding-top: 20px;
  .logo {
    display: flex;
    align-items: center;
    height: 64rpx;
    padding-left: 30rpx;
    padding-top: 20rpx;
    .logo-image {
      width: 166rpx;
      height: 39rpx;
    }
    .logo-text {
      flex: 1;
      line-height: 28rpx;
      color: #fff;
      margin: 2rpx 0 0 20rpx;
      padding-left: 20rpx;
      border-left: 1rpx solid #fff;
      font-size: 26rpx;
    }
  }
  .search {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 0 10rpx 0 26rpx;
    height: 64rpx;
    margin: 16rpx 20rpx;
    color: #fff;
    font-size: 28rpx;
    border-radius: 32rpx;
    background-color: rgba(255, 255, 255, 0.5);
  }
  .icon-search {
    &::before {
      margin-right: 10rpx;
    }
  }
  .icon-scan {
    font-size: 30rpx;
    padding: 15rpx;
  }
}
</style>

安全区域

不同手机的安全区域不同,适配安全区域能防止页面重要内容被遮挡。

可通过 uni.getSystemInfoSync() 获取屏幕边界到安全区的距离。

安全区域

1.3 核心代码

自定义导航配置

json
// src/pages.json
{
  "path": "pages/index/index",
  "style": {
    "navigationStyle": "custom", // 隐藏默认导航
    "navigationBarTextStyle": "white"
  }
}

组件安全区适配

vue
<!-- src/pages/index/componets/CustomNavbar.vue -->
<script>
// 获取屏幕边界到安全区域距离
const { safeAreaInsets } = uni.getSystemInfoSync()
</script>

<template>
  <!-- 顶部占位 -->
  <view class="navbar" :style="{ paddingTop: safeAreaInsets?.top + 'px' }">
    <!-- ...省略 -->
  </view>
</template>

2.通用轮播组件

2.1 参考效果

小兔鲜儿项目中总共有两处广告位,分别位于【首页】和【商品分类页】

轮播图组件需要在首页和分类页使用,需要封装成通用(全局)组件> [Missing Image: home_picture_3.png]

2.2 静态结构

首页广告布局为独立的组件 CustomSwiper ,位于的 src/components 目录中

该组件定义了 swiperData 属性接收外部传入的数据,内部通过小程序内置组件 swiper 展示首页广告的数据

轮播图组件

静态结构:src/components/CustomSwiper.vue

vue
<script setup lang="ts">
import { ref } from 'vue'

const activeIndex = ref(0)
</script>

<template>
  <view class="carousel">
    <swiper :circular="true" :autoplay="true" :interval="3000">
      <swiper-item>
        <navigator url="/pages/index/index" hover-class="none" class="navigator">
          <image
            mode="aspectFill"
            class="image"
            src="https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/uploads/slider_1.jpg"
          ></image>
        </navigator>
      </swiper-item>
      <swiper-item>
        <navigator url="/pages/index/index" hover-class="none" class="navigator">
          <image
            mode="aspectFill"
            class="image"
            src="https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/uploads/slider_2.jpg"
          ></image>
        </navigator>
      </swiper-item>
      <swiper-item>
        <navigator url="/pages/index/index" hover-class="none" class="navigator">
          <image
            mode="aspectFill"
            class="image"
            src="https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/uploads/slider_3.jpg"
          ></image>
        </navigator>
      </swiper-item>
    </swiper>
    <!-- 指示点 -->
    <view class="indicator">
      <text
        v-for="(item, index) in 3"
        :key="item"
        class="dot"
        :class="{ active: index === activeIndex }"
      ></text>
    </view>
  </view>
</template>

<style lang="scss">
:host {
  display: block;
  height: 280rpx;
}
/* 轮播图 */
.carousel {
  height: 100%;
  position: relative;
  overflow: hidden;
  transform: translateY(0);
  background-color: #efefef;
  .indicator {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 16rpx;
    display: flex;
    justify-content: center;
    .dot {
      width: 30rpx;
      height: 6rpx;
      margin: 0 8rpx;
      border-radius: 6rpx;
      background-color: rgba(255, 255, 255, 0.4);
    }
    .active {
      background-color: #fff;
    }
  }
  .navigator,
  .image {
    width: 100%;
    height: 100%;
  }
}
</style>

2.3 自动导入全局组件并声明类型

自动导入全局组件

利用 uni-ui 的组件自动引入规则,配置自定义全局组件自动引入

json
// pages.json
{
  // 组件自动引入规则
  "easycom": {
    // 是否开启自动扫描 @/components/$1/$1.vue 组件自动引入
    "autoscan": true,
    // 以正则方式自定义组件匹配规则
    "custom": {
      // uni-ui 规则如下配置
      "^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue",
      // 自定义全局组件以 Custom 开头 在components文件夹中查找引入
      "^Custom(.*)": "@/components/Custom$1.vue"
    }
  }
}

配置完毕后可以直接在页面模板中使用全局组件无需手动导入

pages.json 修改完毕后需要重新启动项目

全局组件类型声明

利用Volar插件为自定义组件添加类型声明

ts
// src/types/components.d.ts
import CustomSwiper from './CustomSwiper.vue'
declare module 'vue' {
  export interface GlobalComponents {
    CustomSwiper: typeof CustomSwiper
  }
}

注意: 新版 Volardeclare module '@vue/runtime-core' 调整为 declare module 'vue'

2.4 实现轮播图指示点联动

声明一个变量 activeIndex 用于存储当前被激活的图片的下标,为指示器标签动态绑定类名 :class="{ active: index === activeIndex }",在 swiper组件的 @change事件回调中获取当前激活图片的下标对 activeIndex进行赋值

vue
<script>
.....
const activeIndex = ref(0)
// 当轮播图切换时触发,获取当前图片的索引
// onChange:UniHelper.SwiperOnChange 使用UniHelper标注事件回调参数对象类型
const onChange: UniHelper.SwiperOnChange = (event) => {
  // 更改activeIndex当前激活图片的索引,用于实现指示点联动
  activeIndex.value = event.detail.current
}
....
</script>
<template>
...
    <!-- 自定义指示点 -->
    <view class="indicator">
      <!-- 动态绑定类名实现指示点联动 -->
      <text
        v-for="(item, index) in 3"
        :key="item"
        class="dot"
        :class="{ active: index === activeIndex }"
      ></text>
    </view>
...
</template>
Tips: UniHelper标注类型

Tips: UniHelper标注类型

  1. 使用UniHelper为组件变量标注类型 ,格式为:UniHepper.xxxx

    ts
    // 当触发点击事件时的回调 @click 使用event时就会有类型提示
    const onClick: UniHelper.UniBadgeOnClick = (event) => {...}
    
    // 当二级分类的scroll-view滚动时触发 @scroll
    const onScroll: UniHelper.ScrollViewOnScroll = (event) => {
      scrollTop.value = event.detail.scrollTop
    }
    // 轮播图切换事件回调 @change
    const onSwiperChange: UniHelper.SwiperOnChange = (event) => {
      // 存储当前展示图片的下标
      currImgIndex.value = event.detail.current + 1
    }
  2. ?. (可选链) 允许前面的表达式为空值,返回 undefined且不报错

  3. !. (非空断言)主观上排除掉空值的情况 谨慎使用!!!

2.5 获取轮播图数据

  • 接口地址:/home/banner

  • 请求方式:GET

  • 请求参数:

  • Query:

字段名必须默认值备注
distributionSite1活动 banner 位置,1 代表首页,2 代表商品分类页,默认为 1

请求封装

项目中封装的所有请求全部存放在:src/services 文件夹下

存放路径:src/services/home.ts

ts
import { http } from '@/utils/http'

/**
 * 获取轮播图数据的接口方法
 * @param distributionSite 广告区域展示位置 1为首页(默认值)2为商品分类页
 */
export const getSwiperDataAPI = (distributionSite = 1) =>
  http.get<SwiperItem[]>('/home/banner', { distributionSite })

类型声明文件

项目中的所有类型声明文件全部存储到 src/types 文件夹下

Tips:xxx.d.ts 类型声明文件,只存储类型声明不包含可执行代码,类型名首字母全大写

存放路径:src/types/home.d.ts

ts
/**轮播图数据类型*/
export interface SwiperItem {
  /**跳转链接 */
  hrefUrl: string
  /**id */
  id: string
  /**图片路径 */
  imgUrl: string
  /**跳转类型1、页面2、H5 3、小程序(小程序使用 */
  type: string
}
Tips: JSDoc 注释

Tips: JSDoc 注释

JSDoc 注释是JavaScript / TypeScript 代码中用于文档生成的注释规范,它使得开发者能够直接在源代码中添加文档注释,通过JSDoc工具扫描这些注释并生成HTML文档网站。

JSDoc注释通常紧接在要记录的代码之前,每个注释必须以 /** 序列开头才能被JSDoc解析器识别。这种注释方式不仅为代码提供了文档,还让开发者能够利用IDE的自动完成和提示功能,大大提升了编码效率。在VS Code中输入 /** 后就会触发语法提示,帮助开发者快速完成注释。

为接口方法和数据类型添加JSDoc注释,将鼠标悬停到方法名/属性名上即可查看注释信息

image-20240723111036991image-20240723111116082

最后,将获得的数据传递到轮播图组件结合模板语法渲染到页面中。

2.6 参考代码

轮播图组件:src\components\CustomSwiper.vue

vue
<script setup lang="ts">
import { ref } from 'vue'
import type { SwiperItem } from '@/types/home'

const activeIndex = ref(0)

// 接收父组件传递的轮播图数据
// defineProps(['swiperData']) 非ts写法 (不推荐) 使用时无类型提示
// ts写法 (推荐) 使用时有类型提示
defineProps<{
  swiperData: SwiperItem[]
}>()

// 当轮播图切换时触发,获取当前图片的索引
// onChange:UniHelper.SwiperOnChange 使用UniHelper标注事件回调参数对象类型
const onChange: UniHelper.SwiperOnChange = (event) => {
  // 更改activeIndex当前激活图片的索引,用于实现指示点联动
  activeIndex.value = event.detail.current
}
</script>

<template>
  <view class="carousel">
    <swiper @change="onChange" circular autoplay :interval="3000">
      <swiper-item v-for="item in swiperData" :key="item.id">
        <navigator
          :url="item.hrefUrl"
          hover-class="none"
          class="navigator"
        >
          <image
            mode="aspectFill"
            class="image"
            :src="" data-missing="item.imgUrl"
          ></image>
        </navigator>
      </swiper-item>
    </swiper>
    <!-- 自定义指示点 -->
    <view class="indicator">
      <!-- 动态绑定类名实现指示点联动 -->
      <text
        v-for="(item, index) in swiperData.length"
        :key="item"
        class="dot"
        :class="{ active: index === activeIndex }"
      ></text>
    </view>
  </view>
</template>
Tips: defineProps 的TS写法

Tips: defineProps 的TS写法

Vue3 项目中使用 defineProps的TS写法可以获取到数据的类型,方便了使用,同时也减少了错误

ts
// 非ts写法 (不推荐) 使用时无类型提示
defineProps(['swiperData']) 
// ts写法 (推荐) 使用时有类型提示
defineProps<{
  swiperData: SwiperItem[]
}>()

image-20240722214229006image-20240722214319479

3.首页分类

3.1 参考效果

[Missing Image: home_picture_4.png]

准备工作

  1. 准备组件,只有首页使用
  2. 导入并使用组件
  3. 设置首页底色为 #F7F7F7

3.2 静态结构

前台类目布局为独立的组件 CategoryPanel属于首页的业务组件,存放到首页的 components 目录中。

vue
<script setup lang="ts">
//
</script>

<template>
  <view class="category">
    <navigator
      class="category-item"
      hover-class="none"
      url="/pages/index/index"
      v-for="item in 10"
      :key="item"
    >
      <image
        class="icon"
        src="https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/images/nav_icon_1.png"
      ></image>
      <text class="text">居家</text>
    </navigator>
  </view>
</template>

<style lang="scss">
/* 前台类目 */
.category {
  margin: 20rpx 0 0;
  padding: 10rpx 0;
  display: flex;
  flex-wrap: wrap;
  min-height: 328rpx;
  .category-item {
    width: 150rpx;
    display: flex;
    justify-content: center;
    flex-direction: column;
    align-items: center;
    box-sizing: border-box;
    .icon {
      width: 100rpx;
      height: 100rpx;
    }
    .text {
      font-size: 26rpx;
      color: #666;
    }
  }
}
</style>

设置首页背景色

小程序页面的根标签为 page,页面样式会自动隔离,不会影响到其他页面

src/pages/index/index.vue

vue
<style lang="scss">
page {
  background-color: #f7f7f7;
}
</style>

3.3 获取数据

接口调用

  • 接口地址:/home/category/mutli

  • 请求方式:GET

  • 请求参数:无

请求封装

src/service/home.ts

ts
/** 获取首页分类数据的接口方法 */  // JSDoc注释
export const getCategoryDataAPI = () =>
  http.get<CategoryItem[]>('/home/category/mutli')

数据类型

src/types/home.d.ts

typescript
/** 商品分类数据类型*/
export interface CategoryItem {
  /**id */
  id: string
  /**图标路径 */
  icon: string
  /**分类名称 */
  name: string
}

最后,将获得的数据通过 Props 传递到子组件结合模板语法渲染到页面中

3.4 参考代码

src\pages\index\components\CategoryPanel.vue

vue
<script setup lang="ts">
import type { CategoryItem } from '@/types/home'

// 接收父组件传递的数据
defineProps<{
  categoryData: CategoryItem[]
}>()
</script>

<template>
  <view class="category">
    <navigator
      class="category-item"
      hover-class="none"
      url="/pages/index/index"
      v-for="item in categoryData"
      :key="item.id"
    >
      <image class="icon" :src="" data-missing="item.icon"></image>
      <text class="text">{{ item.name }}</text>
    </navigator>
  </view>
</template>

4. 热门推荐

热门推荐功能,后端根据用户的消费习惯等信息向用户推荐的一系列商品,前端负责展示这些商品展示给用户

4.1 参考效果

[Missing Image: home_picture_5.png]

4.2 静态结构

热门推荐布局为独立的组件 HotPanel,属于首页的业务组件,存放到首页的 components 目录中。

vue
<script setup lang="ts">
//
</script>

<template>
  <!-- 推荐专区 -->
  <view class="panel hot">
    <view class="item" v-for="item in 4" :key="item">
      <view class="title">
        <text class="title-text">特惠推荐</text>
        <text class="title-desc">精选全攻略</text>
      </view>
      <navigator hover-class="none" url="/pages/hot/hot" class="cards">
        <image
          class="image"
          mode="aspectFit"
          src="https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/uploads/goods_small_1.jpg"
        ></image>
        <image
          class="image"
          mode="aspectFit"
          src="https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/uploads/goods_small_2.jpg"
        ></image>
      </navigator>
    </view>
  </view>
</template>

<style lang="scss">
/* 热门推荐 */
.hot {
  display: flex;
  flex-wrap: wrap;
  min-height: 508rpx;
  margin: 20rpx 20rpx 0;
  border-radius: 10rpx;
  background-color: #fff;

  .title {
    display: flex;
    align-items: center;
    padding: 24rpx 24rpx 0;
    font-size: 32rpx;
    color: #262626;
    position: relative;
    .title-desc {
      font-size: 24rpx;
      color: #7f7f7f;
      margin-left: 18rpx;
    }
  }

  .item {
    display: flex;
    flex-direction: column;
    width: 50%;
    height: 254rpx;
    border-right: 1rpx solid #eee;
    border-top: 1rpx solid #eee;
    .title {
      justify-content: start;
    }
    &:nth-child(2n) {
      border-right: 0 none;
    }
    &:nth-child(-n + 2) {
      border-top: 0 none;
    }
    .image {
      width: 150rpx;
      height: 150rpx;
    }
  }
  .cards {
    flex: 1;
    padding: 15rpx 20rpx;
    display: flex;
    justify-content: space-between;
    align-items: center;
  }
}
</style>

4.3 获取数据

接口调用

该业务功能对于前端来说比较简单,只需调用后端提供的接口将获得的数据展现。

接口地址:/home/hot/mutli

请求方式:GET

请求参数:

Headers:

字段名称是否必须默认值备注
source-client后端程序区分接口调用者,miniapp 代表小程序端

成功响应结果:

字段名称数据类型备注
idstringID
titlestring推荐标题
typenumber推荐类型
altstring推荐说明
picturesarray[string]图片集合[ 图片路径 ]

类型声明

ts
/**热门推荐商品数据类型 */
export interface HotGoodsItem {
  /**id */
  id: string
  /**推荐说明 */
  alt: string
  /**图片集合 */
  pictures: string[]
  /**跳转地址 */
  target: string
  /**推荐标题 */
  title: string
  /**推荐类型 */
  type: string
}

接口封装

ts
// services/home.ts
/** 获取热门推荐数据的接口方法 */
export const getHotGoodsDataAPI = () =>
  http.get<HotGoodsItem[]>('/home/hot/mutli')

最后将获得的数据结合模板语法渲染到页面中。

4.4 参考代码

src\pages\index\components\HotPanel.vue

vue
<script setup lang="ts">
import type { HotGoodsItem } from '@/types/home'

// 接收父组件传递的数据
defineProps<{
  hotGoodsData: HotGoodsItem[]
}>()
</script>

<template>
  <!-- 推荐专区 -->
  <view class="panel hot">
    <view class="item" v-for="item in hotGoodsData" :key="item.id">
      <view class="title">
        <text class="title-text">{{ item.title }}</text>
        <text class="title-desc">{{ item.alt }}</text>
      </view>
      <navigator
        hover-class="none"
        url="/pages/hot/hot"
        class="cards"
      >
        <image
          class="image"
          mode="aspectFit"
          v-for="picture in item.pictures"
          :key="picture"
          :src="" data-missing="picture"
        ></image>
      </navigator>
    </view>
  </view>
</template>

5. 猜你喜欢

5.1 参考效果

猜你喜欢功能,后端根据用户的浏览记录等信息向用户随机推荐的一系列商品,前端负责把商品在多个页面中展示

[Missing Image: home_picture_6.png]

准备工作

  1. 准备组件 (通用组件,多页面使用)

  2. 定义组件类型

  3. 准备 scroll-view 滚动容器

  4. 设置 pagescroll-view 样式

5.2 静态结构

猜你喜欢是一个通用组件 CustomGuess,多个页面会用到该组件,存放到 src/components 目录中。

vue
<script setup lang="ts">
//
</script>

<template>
  <!-- 猜你喜欢 -->
  <view class="caption">
    <text class="text">猜你喜欢</text>
  </view>
  <view class="guess">
    <navigator
      class="guess-item"
      v-for="item in 10"
      :key="item"
      :url="`/pages/goods/goods?id=4007498`"
    >
      <image
        class="image"
        mode="aspectFill"
        src="https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/uploads/goods_big_1.jpg"
      ></image>
      <view class="name"> 德国THORE男表 超薄手表男士休闲简约夜光石英防水直径40毫米 </view>
      <view class="price">
        <text class="small">¥</text>
        <text>899.00</text>
      </view>
    </navigator>
  </view>
  <view class="loading-text"> 正在加载... </view>
</template>

<style lang="scss">
:host {
  display: block;
}
/* 分类标题 */
.caption {
  display: flex;
  justify-content: center;
  line-height: 1;
  padding: 36rpx 0 40rpx;
  font-size: 32rpx;
  color: #262626;
  .text {
    display: flex;
    justify-content: center;
    align-items: center;
    padding: 0 28rpx 0 30rpx;

    &::before,
    &::after {
      content: '';
      width: 20rpx;
      height: 20rpx;
      background-image: url(@/static/images/bubble.png);
      background-size: contain;
      margin: 0 10rpx;
    }
  }
}

/* 猜你喜欢 */
.guess {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  padding: 0 20rpx;
  .guess-item {
    width: 345rpx;
    padding: 24rpx 20rpx 20rpx;
    margin-bottom: 20rpx;
    border-radius: 10rpx;
    overflow: hidden;
    background-color: #fff;
  }
  .image {
    width: 304rpx;
    height: 304rpx;
  }
  .name {
    height: 75rpx;
    margin: 10rpx 0;
    font-size: 26rpx;
    color: #262626;
    overflow: hidden;
    text-overflow: ellipsis;
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
  }
  .price {
    line-height: 1;
    padding-top: 4rpx;
    color: #cf4444;
    font-size: 26rpx;
  }
  .small {
    font-size: 80%;
  }
}
// 加载提示文字
.loading-text {
  text-align: center;
  font-size: 28rpx;
  color: #666;
  padding: 20rpx 0;
}
</style>

5.3 全局组件类型

ts
// 为全局自定义组件添加组件类型
import CustomSwiper from '@/components/CustomSwiper.vue'
import CustomGuess from '@/components/CustomGuess.vue'
declare module 'vue' {
  export interface GlobalComponents {
    CustomSwiper: typeof CustomSwiper
    CustomGuess: typeof CustomGuess
  }
}
// CustomGuess自定义组件实例类型
export type CustomGuessInstance = InstanceType<typeof CustomGuess>

获取自定义组件类型

ts
export type Customxxx = typeof Customxxx

获取自定义组件实例类型

ts
export type CustomxxInstance = InstanceType<typeof CustomxxInstance>

5.4 实现滚动时导航栏固定

使用 scroll-view 组件 + 弹性布局 flex:1 实现

vue
<template>
  <!-- 自定义导航栏 -->
  <CustomNavbar></CustomNavbar>
  <!-- 使用scroll-view组件实现滚动效果 将自定义导航栏固定到页面顶部-->
  <scroll-view class="scroll-view" scroll-y>
    <!-- 轮播图 -->
    <CustomSwiper :swiperData="swiperData"></CustomSwiper>
    <!-- 分类面板 -->
    <CategoryPanel :categoryData="categoryData"></CategoryPanel>
    <!-- 热门推荐 -->
    <HotPanel :hotGoodsData="hotGoodsData"></HotPanel>
    <!-- 猜你喜欢 -->
    <CustomGuess></CustomGuess>
  </scroll-view>
</template>

<style lang="scss">
// 页面开启弹性盒子
page {
  background-color: #f7f7f7;
  height: 100vh; // 页面高度为视口高度
  display: flex;
  flex-direction: column;
  .scroll-view { 
    flex: 1;  // 滚动容器占满剩余高度 扩张比为1
  }
}
</style>

5.5 获取数据

由于猜你喜欢组件在不同的页面中都有使用且使用的数据相同,因此获取猜你喜欢的数据可以在全局自定义组件 CustomGuessonMounted 生命周期钩子中进行获取

接口调用

接口地址:/home/goods/guessLike

请求方式:GET

请求参数:

Query:

字段名称是否必须默认值备注
page1分页的页码
pageSize10每页数据的条数

请求封装

ts
/**
 * 获取猜你喜欢的商品数据
 * @param page  第几页(在加载更多时使用),默认第一页 可选
 * @param pageSize  一页多少条数据,默认为10  可选
 */
export const getGuessGoodsDataAPI = (page = 1, pageSize = 10) =>
  http.get<PageResult<GuessItem>>('/home/goods/guessLike', {
    page,
    pageSize,
  })

类型声明

其他组件也可能会返回分页相关的数据,因此将分页结果的类型定义到全局类型声明文件中

新建 src/types/global.d.ts 文件存储通用分页结果类型:

ts
/**通用分页结果类型 返回的数据的ts类型通过泛型传入*/
export interface PageResult<T> {
  /**返回数据 */
  items: T[]
  /**总条数 */
  counts: number
  /**总页数 */
  pages: number
  /**当前页 */
  page: number
  /**每页条数 */
  pageSize: number
}

猜你喜欢-商品类型如下,存放到 src/types/home.d.ts 文件:

typescript
/** 猜你喜欢-商品类型 */
export type GuessItem = {
  /** 商品描述 */
  desc: string
  /** 商品折扣 */
  discount: number
  /** id */
  id: string
  /** 商品名称 */
  name: string
  /** 商品已下单数量 */
  orderNum: number
  /** 商品图片 */
  picture: string
  /** 商品价格 */
  price: number
}

5.6 上拉加载更多

  1. 子组件内部封装获取数据的方法

    src/components/CustomGuess.vue

    vue
    <script setup lang="ts">
    import { getGuessGoodsDataAPI } from '@/services/home'
    import type { GuessItem } from '@/types/home'
    import { onMounted, ref } from 'vue'
    
    /**猜你喜欢的数据*/
    const guessList = ref<GuessItem[]>([])
    /**总页数*/
    let totalPages = ref(1)
    /**当前页码*/
    let currentPage = ref(0)
    /**上拉触底时提示显示的文字*/
    let loadingText = ref('正在加载...')
    // 获取猜你喜欢的数据 
    const getGuessGoodsData = async () => {
      // 判断当前页码是否大于等于总页码 数据获取完毕 修改底部提示文字 结束函数调用
      if (currentPage.value >= totalPages.value)
        return (loadingText.value = '没有更多数据了')
      // 数据还未获取完毕,调用接口获取下一页数据
      const result = await getGuessGoodsDataAPI(currentPage.value + 1)
      // 追加存储猜你喜欢的数据
      guessList.value = guessList.value.concat(result.result.items)
      // 存储总页数
      totalPages.value = result.result.pages
      // 存储当前页码
      currentPage.value = result.result.page
    }
    // 组件挂载完毕
    onMounted(() => {
      // 获取猜你喜欢的数据
      getGuessGoodsData()
    })
    // 暴露组件内部的方法
    defineExpose({ getMore: getGuessGoodsData })
    </script>
  2. 父组件 scroll-view组件滚动触底需加载分页

  3. 组件通讯,父组件调用子组件获取数据的方法

    src/pages/index/index.vue

    vue
    <script setup lang="ts">
    .....
    // 获取猜你喜欢子组件的实例对象,并传入子组件实例类型
    const guessRef = ref<CustomGuessInstance>()
    .....
    // scroll-view 组件滚动到底部时触发的事件(上拉获取更多数据)
    const onScrolltolower = () => {
      // 调用子组件的获取更多方法
      guessRef.value?.getMore()
    }
    .....
    </script>
    
    <template>
      <!-- 自定义导航栏 -->
      <CustomNavbar></CustomNavbar>
      <!-- 使用scroll-view组件实现滚动效果 将自定义导航栏固定到页面顶部-->
      <scroll-view @scrolltolower="onScrolltolower" class="scroll-view" scroll-y>
        .......
      </scroll-view>
    </template>

5.7 优化函数防抖

当用户频繁下拉刷新时,会多次触发事件回调从而发送多次无意义的请求,对回调函数进行防抖处理,当用户第一次触发事件回调后n毫秒内不再触发事件回调,才执行回调函数

可以使用 lodashlicia等第三方工具库实现

安装:

pnpm i lodash

使用:

js
+// 导入lodash插件对事件回调进行防抖处理
+// @ts-ignore  lodash为js库无类型声明文件
+import { debounce } from 'lodash'

+// 导入lodash插件对事件回调进行防抖处理
+// @ts-ignore  lodash为js库无类型声明文件
+import { debounce } from 'lodash'

// scroll-view 组件下拉刷新时触发的事件(下拉刷新)
// 对回调函数进行防抖处理 使用lodash插件提供的debounce方法
const onScrollRefresh = debounce(async () => {
  // 开启下拉刷新框
  refreshTrigger.value = true
  // 调用子组件的重置数据方法
  // guessRef.value?.resetData() 放入 Promise.all进行并发获取数据
  /* 重置当前组件中的数据
  三个获取数据的函数都为异步函数且返回值为Promise,可以使用Promise.all进行并发获取数据 */
  await Promise.all([
    getHotGoodsData(),
    getCategoryData(),
    getSwiperData(),
    guessRef.value?.resetData(),
  ])
  // 数据更新后 手动关闭下拉刷新框
  refreshTrigger.value = false
}, 500)

5.8 下拉刷新

下拉刷新实际上是在用户操作下拉交互时重新调用接口,然后将新获取的数据再次渲染到页面中。

操作步骤

基于 scroll-view 组件实现下拉刷新,需要通过以下方式来实现下拉刷新的功能。

  • 配置 refresher-enabled 属性,开启下拉刷新交互
  • 监听 @refresherrefresh 事件,判断用户是否执行了下拉操作,在事件回调中调用子组件的方法清空猜你喜欢的商品数据,并重新获取首页中的数据
  • 配置 refresher-triggered 属性,当数据重新获取完毕后关闭下拉状态

src/components/CustomGuess.vue

js
// 重置猜你喜欢的数据
const resetData = async() => { // async 函数返回一个Promise
  // 清空猜你喜欢的数据
  guessList.value = []
  // 修改底部提示文字
  loadingText.value = '正在加载...'
  // 重置当前页码为0
  currentPage.value = 0
  // 重新第一页的数据
  await getGuessGoodsData()
}

src/pages/index/index.vue

vue
<script setup lang="ts">
......
/**控制scroll-view组件的下拉刷新状态 */
let refreshTrigger = ref(false)
.....
// scroll-view 组件下拉刷新时触发的事件(下拉刷新)
const onScrollRefresh = async () => {
  // 开启下拉刷新框
  refreshTrigger.value = true
  // 调用子组件的重置数据方法
  // guessRef.value?.resetData() 放入 Promise.all进行并发获取数据
  /* 重置当前组件中的数据
  三个获取数据的函数都为异步函数且返回值为Promise,可以使用Promise.all进行并发获取数据 */
  await Promise.all([
    getHotGoodsData(),
    getCategoryData(),
    getSwiperData(),
    guessRef.value?.resetData(),
  ])
  // 数据更新后 手动关闭下拉刷新框
  refreshTrigger.value = false
}
.....
</script>

<template>
  <!-- 自定义导航栏 -->
  <CustomNavbar></CustomNavbar>
  <!-- 使用scroll-view组件实现滚动效果 将自定义导航栏固定到页面顶部-->
  <scroll-view
    refresher-enabled  // 开启下拉刷新
    refresher-background="#00c19e" // 下拉刷新窗口颜色
    refresher-default-style="white" // 下拉刷新默认样式
    :refresher-triggered="refreshTrigger" // 设置当前下拉刷新状态
    @refresherrefresh="onScrollRefresh"  // 触发下拉刷新的事件回调
    @scrolltolower="onScrolltolower"
    class="scroll-view"
    scroll-y
  >
    .....
  </scroll-view>
</template>
Tips:并发获取数据

Tips:并发获取数据

  1. 下拉刷新的动画不会自己关闭需要手动将 scroll-viewrefresher-triggered 属性设置为 false进行关闭

  2. Promise.all()并发获取数据,提高效率

  3. async函数会返回一个Promise

5.9 优化-函数节流

在用户第一次上拉加载中,在一定时间间隔内用户再次上拉将不会再次发请求获取数据(否则将会出现重复获取相同数据)

js
// @ts-ignore  lodash为js库无类型声明文件
import { debounce, throttle } from 'lodash'
......
// 对回调函数进行节流处理 使用lodash插件提供的throttle方法
const onScrolltolower = throttle(() => {
  // 调用子组件的获取更多方法
  guessRef.value?.getMore()
}, 1000)

5.10参考代码

猜你喜欢组件

vue
<script setup lang="ts">
import { getGuessGoodsDataAPI } from '@/services/home'
import type { GuessItem } from '@/types/home'
import { onMounted, ref } from 'vue'

/**猜你喜欢的数据*/
const guessList = ref<GuessItem[]>([])
/**总页数*/
let totalPages = ref(1)
/**当前页码*/
let currentPage = ref(0)
/**上拉触底时提示显示的文字*/
let loadingText = ref('正在加载...')

// 获取猜你喜欢的数据
const getGuessGoodsData = async () => {
  // 判断当前页码是否大于等于总页码 数据获取完毕 修改底部提示文字 结束函数调用
  if (currentPage.value >= totalPages.value)
    return (loadingText.value = '没有更多数据了')
  // 数据还未获取完毕,调用接口获取下一页数据
  const result = await getGuessGoodsDataAPI(currentPage.value + 1)
  // 追加存储猜你喜欢的数据
  guessList.value = guessList.value.concat(result.result.items)
  // 存储总页数
  totalPages.value = result.result.pages
  // 存储当前页码
  currentPage.value = result.result.page
}

// 重置猜你喜欢的数据
const resetData = async () => {
  // 清空猜你喜欢的数据
  guessList.value = []
  // 修改底部提示文字
  loadingText.value = '正在加载...'
  // 重置当前页码为0
  currentPage.value = 0
  // 重新第一页的数据
  await getGuessGoodsData()
}

// 组件挂载完毕
onMounted(() => {
  // 获取猜你喜欢的数据
  getGuessGoodsData()
})

// 暴露组件内部的方法
defineExpose({ getMore: getGuessGoodsData, resetData })
</script>

首页组件

vue
<script setup lang="ts">
import { onLoad } from '@dcloudio/uni-app'
import CustomNavbar from './components/CustomNavbar.vue'
import {
  getCategoryDataAPI,
  getHotGoodsDataAPI,
  getSwiperDataAPI,
} from '@/services/home'
import { ref } from 'vue'
import type { CategoryItem, HotGoodsItem, SwiperItem } from '@/types/home'
import CategoryPanel from './components/CategoryPanel.vue'
import HotPanel from './components/HotPanel.vue'
import type { CustomGuessInstance } from '@/types/components'
// 导入lodash插件对事件回调进行防抖处理
// @ts-ignore  lodash为js库无类型声明文件
import { debounce, throttle } from 'lodash'
// 导入骨架屏
import PageSkeleton from './components/PageSkeleton.vue'

/**轮播图的数据*/
const swiperData = ref<SwiperItem[]>([])
/**分类数据*/
const categoryData = ref<CategoryItem[]>([])
/**热门推荐商品数据*/
const hotGoodsData = ref<HotGoodsItem[]>([])
/**获取猜你喜欢子组件的实例对象,并传入子组件实例类型*/
const guessRef = ref<CustomGuessInstance>()
/**控制scroll-view组件的下拉刷新状态 */
let refreshTrigger = ref(false)
/**标记数据是否正在加载中 */
let isLoading = ref(false)

// 获取轮播图数据
const getSwiperData = async () => {
  const result = await getSwiperDataAPI()
  // 存储轮播图数据
  swiperData.value = result.result
}

// 获取分类数据
const getCategoryData = async () => {
  const result = await getCategoryDataAPI()
  // 存储分类数据
  categoryData.value = result.result
}

// 获取热门推荐商品数据
const getHotGoodsData = async () => {
  const result = await getHotGoodsDataAPI()
  // 存储热门推荐商品数据
  hotGoodsData.value = result.result
}

// scroll-view 组件滚动到底部时触发的事件(上拉获取更多数据)
// 对回调函数进行节流处理 使用lodash插件提供的throttle方法
const onScrolltolower = throttle(() => {
  // 调用子组件的获取更多方法
  guessRef.value?.getMore()
}, 1000)

// scroll-view 组件下拉刷新时触发的事件(下拉刷新)
// 对回调函数进行防抖处理 使用lodash插件提供的debounce方法
const onScrollRefresh = debounce(async () => {
  // 开启下拉刷新框
  refreshTrigger.value = true
  // 调用子组件的重置数据方法
  // guessRef.value?.resetData() 放入 Promise.all进行并发获取数据
  /* 重置当前组件中的数据
  三个获取数据的函数都为异步函数且返回值为Promise,可以使用Promise.all进行并发获取数据 */
  await Promise.all([
    getHotGoodsData(),
    getCategoryData(),
    getSwiperData(),
    guessRef.value?.resetData(),
  ])
  // 数据更新后 手动关闭下拉刷新框
  refreshTrigger.value = false
}, 500)

// 页面加载时的生命周期函数 组合式API需要将逻辑回调函数
onLoad(async () => {
  isLoading.value = true
  /* // 获取轮播图数据
  getSwiperData()
  // 获取分类数据
  getCategoryData()
  // 获取热门推荐商品数据
  getHotGoodsData() */
  // Promise.all进行并发获取数据
  await Promise.all([getSwiperData(), getHotGoodsData(), getCategoryData()])
  isLoading.value = false
})
</script>

<template>
  <!-- 自定义导航栏 -->
  <CustomNavbar></CustomNavbar>
  <!-- 使用scroll-view组件实现滚动效果 将自定义导航栏固定到页面顶部-->
  <scroll-view
    refresher-enabled
    refresher-background="#00c19e"
    refresher-default-style="white"
    :refresher-triggered="refreshTrigger"
    @refresherrefresh="onScrollRefresh"
    @scrolltolower="onScrolltolower"
    class="scroll-view"
    scroll-y
  >
    <!-- 骨架屏组件 -->
    <PageSkeleton v-if="isLoading"></PageSkeleton>
    <template v-else>
      <!-- 轮播图 -->
      <CustomSwiper :swiperData="swiperData"></CustomSwiper>
      <!-- 分类面板 -->
      <CategoryPanel :categoryData="categoryData"></CategoryPanel>
      <!-- 热门推荐 -->
      <HotPanel :hotGoodsData="hotGoodsData"></HotPanel>
      <!-- 猜你喜欢 -->
      <CustomGuess ref="guessRef"></CustomGuess>
    </template>
  </scroll-view>
</template>

<style lang="scss">
page {
  background-color: #f7f7f7;
  height: 100vh;
  display: flex;
  flex-direction: column;
  .scroll-view {
    flex: 1; // 占满剩余高度
  }
}
</style>

6. 骨架屏

骨架屏是页面的一个空白版本,通常会在页面完全渲染之前,通过一些灰色的区块大致勾勒出轮廓,待数据加载完成后,再替换成真实的内容。

参考效果

骨架屏作用是缓解用户等待时的焦虑情绪,属于用户体验优化方案。

[Missing Image: home_picture_7.png]

生成骨架屏

微信开发者工具提供了自动生成骨架屏代码的能力。

使用时需要把自动生成的 xxx.skeleton.vuexxx.skeleton.wxss 封装成 一个vue 组件。

[Missing Image: image-20240723210330154.png]

注意:

  1. 需要将生成的结构中的行内样式修改为 class 样式(添加类名进行提取)
  2. 将未用到动态数据的部分骨架屏删除
  3. 将骨架屏中的 ts语法错误进行修改

使用一个变量控制骨架屏的展示和隐藏(根据数据是否获取到)

vue
<script setup lang="ts">
....
// 导入骨架屏
+import PageSkeleton from './components/PageSkeleton.vue'
....
/**标记数据是否正在加载中 */
+let isLoading = ref(false)
....
// 页面加载时的生命周期函数 组合式API需要将逻辑回调函数
+onLoad(async () => {
+  isLoading.value = true
  /* // 获取轮播图数据
  getSwiperData()
  // 获取分类数据
  getCategoryData()
  // 获取热门推荐商品数据
  getHotGoodsData() */
  // Promise.all进行并发获取数据
+  await Promise.all([getSwiperData(), getHotGoodsData(), getCategoryData()])
+  isLoading.value = false
})
</script>

<template>
  <!-- 自定义导航栏 -->
  <CustomNavbar></CustomNavbar>
  <!-- 使用scroll-view组件实现滚动效果 将自定义导航栏固定到页面顶部-->
  <scroll-view
    refresher-enabled
    refresher-background="#00c19e"
    refresher-default-style="white"
    :refresher-triggered="refreshTrigger"
    @refresherrefresh="onScrollRefresh"
    @scrolltolower="onScrolltolower"
    class="scroll-view"
    scroll-y
  >
    <!-- 骨架屏组件 -->
+    <PageSkeleton v-if="isLoading"></PageSkeleton>
+    <template v-else>
      <!-- 轮播图 -->
      <CustomSwiper :swiperData="swiperData"></CustomSwiper>
      <!-- 分类面板 -->
      <CategoryPanel :categoryData="categoryData"></CategoryPanel>
      <!-- 热门推荐 -->
      <HotPanel :hotGoodsData="hotGoodsData"></HotPanel>
      <!-- 猜你喜欢 -->
      <CustomGuess ref="guessRef"></CustomGuess>
    </template>
  </scroll-view>
</template>

四、小兔鲜儿 - 推荐模块

主要实现 Tabs 交互、多 Tabs 列表分页加载数据。

1. 动态获取数据

1.1参考效果

推荐模块的布局结构是相同的,因此我们可以复用相同的页面及交互,只是所展示的数据不同。

[Missing Image: hot_picture_1.png]

1.2 静态结构

新建热门推荐页面文件 src/pages/hot/hot.vue

vue
<script setup lang="ts">
// type值通过路由参数进行获取 title:热门推荐页标题 url:接口地址
const hotMap = [
  { type: '1', title: '特惠推荐', url: '/hot/preference' },
  { type: '2', title: '爆款推荐', url: '/hot/inVogue' },
  { type: '3', title: '一站买全', url: '/hot/oneStop' },
  { type: '4', title: '新鲜好物', url: '/hot/new' },
]
</script>

<template>
  <view class="viewport">
    <!-- 推荐封面图 -->
    <view class="cover">
      <image
        src="http://yjy-xiaotuxian-dev.oss-cn-beijing.aliyuncs.com/picture/2021-05-20/84abb5b1-8344-49ae-afc1-9cb932f3d593.jpg"
      ></image>
    </view>
    <!-- 推荐选项 -->
    <view class="tabs">
      <text class="text active">抢先尝鲜</text>
      <text class="text">新品预告</text>
    </view>
    <!-- 推荐列表 -->
    <scroll-view scroll-y class="scroll-view">
      <view class="goods">
        <navigator
          hover-class="none"
          class="navigator"
          v-for="goods in 10"
          :key="goods"
          :url="`/pages/goods/goods?id=`"
        >
          <image
            class="thumb"
            src="https://yanxuan-item.nosdn.127.net/5e7864647286c7447eeee7f0025f8c11.png"
          ></image>
          <view class="name ellipsis">不含酒精,使用安心爽肤清洁湿巾</view>
          <view class="price">
            <text class="symbol">¥</text>
            <text class="number">29.90</text>
          </view>
        </navigator>
      </view>
      <view class="loading-text">正在加载...</view>
    </scroll-view>
  </view>
</template>

<style lang="scss">
page {
  height: 100%;
  background-color: #f4f4f4;
}
.viewport {
  display: flex;
  flex-direction: column;
  height: 100%;
  padding: 180rpx 0 0;
  position: relative;
}
.cover {
  width: 750rpx;
  height: 225rpx;
  border-radius: 0 0 40rpx 40rpx;
  overflow: hidden;
  position: absolute;
  left: 0;
  top: 0;
}
.scroll-view {
  flex: 1;
}
.tabs {
  display: flex;
  justify-content: space-evenly;
  height: 100rpx;
  line-height: 90rpx;
  margin: 0 20rpx;
  font-size: 28rpx;
  border-radius: 10rpx;
  box-shadow: 0 4rpx 5rpx rgba(200, 200, 200, 0.3);
  color: #333;
  background-color: #fff;
  position: relative;
  z-index: 9;
  .text {
    margin: 0 20rpx;
    position: relative;
  }
  .active {
    &::after {
      content: '';
      width: 40rpx;
      height: 4rpx;
      transform: translate(-50%);
      background-color: #27ba9b;
      position: absolute;
      left: 50%;
      bottom: 24rpx;
    }
  }
}
.goods {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  padding: 0 20rpx 20rpx;
  .navigator {
    width: 345rpx;
    padding: 20rpx;
    margin-top: 20rpx;
    border-radius: 10rpx;
    background-color: #fff;
  }
  .thumb {
    width: 305rpx;
    height: 305rpx;
  }
  .name {
    height: 88rpx;
    font-size: 26rpx;
  }
  .price {
    line-height: 1;
    color: #cf4444;
    font-size: 30rpx;
  }
  .symbol {
    font-size: 70%;
  }
  .decimal {
    font-size: 70%;
  }
}

.loading-text {
  text-align: center;
  font-size: 28rpx;
  color: #666;
  padding: 20rpx 0 50rpx;
}
</style>

1.3 获取页面参数

热门推荐页要根据页面参数区分需要获取的是哪种类型的推荐列表,然后再去调用相应的接口,来获取不同的数据,再渲染到页面当中。

项目首页 src/pages/index/components/HotPanel.vue(传递参数 type

vue
<navigator :url="`/pages/hot/hot?type=${item.type}`">
  …省略  
</navigator>

热门推荐页 src/pages/hot/hot.vue(获取路由参数,并根据不同的页面参数,动态设置推荐页标题)

vue
<script setup lang="ts">
import { onLoad } from '@dcloudio/uni-app'

// type值通过路由参数进行获取 title:热门推荐页标题 url:接口地址
const hotMap = [
  { type: '1', title: '特惠推荐', url: '/hot/preference' },
  { type: '2', title: '爆款推荐', url: '/hot/inVogue' },
  { type: '3', title: '一站买全', url: '/hot/oneStop' },
  { type: '4', title: '新鲜好物', url: '/hot/new' },
]

/* 原生微信小程序使用onLoad钩子函数获取页面参数 type
onLoad((event) => {
  console.log(event?.type) // 4
}) */
// 在uniapp项目中使用defineProps获取页面参数 type
const query = defineProps<{
  type: string
}>()
// console.log(query) Proxy {type: "4"}
// 通过路由传递的type获取当前页面的推荐标题和接口地址
const currentHotMap = hotMap.find((item) => item.type === query.type)
// 动态设置当前页面标题  currentHotMap可能为空 使用非空断言 !
uni.setNavigationBarTitle({ title: currentHotMap!.title })
</script>
Tips:在 uni-app项目中获取路由参数

Tips:在 uni-app项目中获取路由参数

  1. 原生微信小程序获取页面路由参数:

    使用onLoad钩子函数获取页面参数

    js
    onLoad((event) => {
      console.log(event?.type) // 4
    })
  2. uni-app项目中获取页面路由参数:

    使用defineProps获取页面参数

    js
    const query = defineProps<{
      type: string
    }>()
    // console.log(query) Proxy {type: "4"}

    也可以使用 onLoad生命周期钩子函数获取页面路由参数

设置页面标题 API uni.setNavigationBarTitle({ title: 'title' })

1.4 获取数据

地址参数

不同类型的推荐,需要调用不同的 API 接口:

type推荐类型接口路径
1特惠推荐/hot/preference
2爆款推荐/hot/inVogue
3一站买全/hot/oneStop
4新鲜好物/hot/new

接口调用

接口地址:见上表

请求方式:GET

请求参数:

Query:

字段名称是否必须默认值备注
subType推荐列表 Tab 项的 id
page1页码
pageSize10每页商品数量

请求封装

经过分析,尽管不同类型推荐的请求 url 不同,但请求参数及响应格式都具有一致性,因此可以将接口的调用进行封装,参考代码如下所示:

ts
import { http } from '@/utils/http'
import type { PageParams } from '@/types/global'
import type { HotResult } from '@/types/hot'

/**
 * 获取热门推荐页数据通用请求
 * @param url  请求地址
 * @param data 请求参数
 */
export const getHotRecommendAPI = (
  url: string,
  data?: PageParams & { subType?: string },
) => http.get<HotResult>(url, data)

类型声明

电商项目较为常见商品展示,商品的类型是可复用的,封装到 src/types/global.d.ts 文件中:

ts
/**通用分页参数类型 */
export interface PageParams {
  /**当前页:默认值为1 */
  page?: number
  /**每页条数:默认值为10 */
  pageSize?: number
}
/**通用分页结果类型 返回的数据的ts类型通过泛型传入*/
export interface PageResult<T> {
  /**返回数据 */
  items: T[]
  /**总条数 */
  counts: number
  /**总页数 */
  pages: number
  /**当前页 */
  page: number
  /**每页条数 */
  pageSize: number
}
/**通用商品类型 */
export interface GoodsItem {
  /**id */
  id: string
  /**商品名称 */
  name: string
  /**商品描述 */
  des: string
  /**商品价格 */
  price: number
  /**商品图片 */
  picture: string
  /**商品折扣 */
  discount: number
  /** 商品已下单数量 */
  orderNum: number
}

将可能会复用的类型定义到 src/types/global.d.ts中,在其他类型声明文件中引用使用,提高复用

猜你喜欢的商品类型与 GoodsItem 相同,可复用通用商品类型

热门推荐类型如下,新建 src/types/hot.d.ts 文件:

ts
import type { GoodsItem, PageResult } from './global'

/**热门推荐-子类选项数据类型 */
export interface SubTypeItem {
  /**子类id */
  id: string
  /**子类标题 */
  title: string
  /**子类对应的商品集合 */
  goodsItems: PageResult<GoodsItem>
}
/**热门推荐数据返回数据类型 */
export interface HotResult {
  /**id */
  id: string
  /**活动图片 */
  bannerPictures: string
  /**活动标题 */
  title: string
  /**子类选项 */
  subTypes: SubTypeItem[]
}
Tips:联合类型 交叉类型

Tips:联合类型 交叉类型

  • 联合类型 | (管道符)

    由两个或多个其他类型组成的类型,表示可以是这些类型中的任意一种

    a:sting|number 变量a的类型可以为字符串或数字类型

  • 交叉类型 &

    一个变量或对象需要同时满足多个类型

    b:{id:string}&{name:string} 对象b的类型为{id:string,name:string}

2. 多 Tabs 分页加载

需要根据当前用户选中的 Tabs 加载对应的列表数据。

[Missing Image: hot_picture_2.png]

2.1 Tabs 交互基础

当用户点击页面中的 Tab 后,切换展示相应的商品列表

参考代码:

vue
<script setup lang="ts">
// 高亮的下标
const activeIndex = ref(0)
</script>

<template>
  <view class="viewport">
    <!-- 推荐封面图 -->
    <view class="cover">
      <image :src="" data-missing="bannerPicture"></image>
    </view>
    <!-- 推荐选项 -->
    <view class="tabs">
      <text
        v-for="(item, index) in subTypes"
        :key="item.id"
        class="text"
        :class="{ active: activeTabIndex === index }"
        @tap="activeTabIndex = index"
        >{{ item.title }}</text
      >
    </view>
    <!-- 推荐列表 -->
    <!-- 将全部的子分类对应的商品列表进行渲染通过v-show进行控制 每个scroll-view相互独立 -->
    <!-- 使用v-show控制展示与隐藏,在隐藏时不会销毁组件,会保留其滚动记录 -->
    <scroll-view
      v-for="(subType, index) in subTypes"
      :key="subType.id"
      v-show="activeTabIndex === index"
      scroll-y
      class="scroll-view"
    >
     // 动态渲染数据 .....
    </scroll-view>
  </view>
</template>
Tips:页面内导航栏选项卡相互切换

Tips:页面内导航栏选项卡相互切换

  • 将全部的选项卡 scroll-view 对应的商品列表进行渲染通过每个 scroll-view 相互独立
  • 使用 v-show 控制 scroll-view 展示与隐藏,在隐藏时不会销毁组件,且会保留其滚动记录

2.2 选中 Tabs 分页

根据当前用户选中的 Tabs 加载对应的列表数据

操作流程

  1. 根据高亮下标,获取对应列表数据
  2. 提取列表的分页参数,用于发送请求
  3. 上拉加载更多事件,页码累加,数组追加,退出判断
  4. 下拉刷新事件,页码重置,清空数组,重新获取数据,刷新框手动回弹
  5. 防抖节流优化 使用lodash插件

2.3 参考代码(总)

热门推荐页

vue
<script setup lang="ts">
import { onLoad } from '@dcloudio/uni-app'
import { getHotRecommendAPI } from '@/services/hot'
import { ref, toRefs } from 'vue'
import type { SubTypeItem } from '@/types/hot'
// @ts-ignore
import { debounce, throttle } from 'lodash'

// type值通过路由参数进行获取 title:热门推荐页标题 url:接口地址
const hotMap = [
  { type: '1', title: '特惠推荐', url: '/hot/preference' },
  { type: '2', title: '爆款推荐', url: '/hot/inVogue' },
  { type: '3', title: '一站买全', url: '/hot/oneStop' },
  { type: '4', title: '新鲜好物', url: '/hot/new' },
]
// 推荐封面图
let bannerPicture = ref('')
// 子类选项列表
const subTypes = ref<SubTypeItem[]>([])
// 当前激活的选项卡的下标
let activeTabIndex = ref(0)
// 上拉加载更多底部提示文字
let loadingText = ref('正在加载...')
// 下拉刷新加载状态
let isLoading = ref(false)

/* 原生微信小程序使用onLoad钩子函数获取页面参数 type
onLoad((event) => {
  console.log(event?.type) // 4
}) */
// 在uniapp项目中使用defineProps获取页面参数 type
const query = defineProps<{
  type: string
}>()
// console.log(query) Proxy {type: "4"}
// 通过路由传递的type获取当前页面的推荐标题和接口地址
const currentHotMap = hotMap.find((item) => item.type === query.type)
// 动态设置当前页面标题  currentHotMap可能为空 使用非空断言 !
uni.setNavigationBarTitle({ title: currentHotMap!.title })

// 获取热门推荐数据
const getHotRecommend = async () => {
  const result = await getHotRecommendAPI(currentHotMap!.url)
  // 存储热门推荐页轮播图
  bannerPicture.value = result.result.bannerPicture
  // 存储子类选项列表
  subTypes.value = result.result.subTypes
}
// 上拉加载更多 获取当前分类的下一页数据(节流)  subTypeId:当前分类id
const onScrolltoLower = throttle(async (subTypeId: string) => {
  // 获取当前激活的分类数据的页码,总页码,商品列表 判断数据是否加载完毕
  /* 注意:此处必须使用toRefs进行解构,解构出的数据为响应式数据且修改时会影响源数据
  普通解构:修改解构后的数据不会影响源数据 */
  const { page, pages, items } = toRefs(
    subTypes.value[activeTabIndex.value].goodsItems,
  )
  if (page.value >= pages.value) return (loadingText.value = '数据加载完毕...')
  // 调用接口接续获取数据
  const result = await getHotRecommendAPI(currentHotMap!.url, {
    subType: subTypeId,
    page: page.value + 1,
    // Tips: 环境变量,开发环境,修改初始每页条数便于测试
    // import.meta.env.DEV 获取是否处于开发环境返回boolean
    pageSize: import.meta.env.DEV ? 50 : 1,
  })
  // 获取返回数据的页码,总页码,商品列表
  const {
    page: newPage,
    pages: newPages,
    items: newItems,
  } = result.result.subTypes[activeTabIndex.value].goodsItems
  // 追加数据
  items.value.push(...newItems)
  // 更新页码
  page.value = newPage
  pages.value = newPages
}, 500)
// 下拉刷新 重置当前分类数据  (防抖)
const onRefresh = debounce(async (subTypeId: string) => {
  isLoading.value = true
  // 获取当前激活分类的第一页数据
  const result = await getHotRecommendAPI(currentHotMap!.url, {
    subType: subTypeId,
    page: 1,
  })
  // 替换原本的数据
  subTypes.value[activeTabIndex.value].goodsItems =
    result.result.subTypes[activeTabIndex.value].goodsItems
  // 关闭下拉加载提示框
  isLoading.value = false
}, 1000)

// 页面加载完毕
onLoad(() => {
  // 获取热门推荐数据
  getHotRecommend()
})
</script>

<template>
  <view class="viewport">
    <!-- 推荐封面图 -->
    <view class="cover">
      <image :src="" data-missing="bannerPicture"></image>
    </view>
    <!-- 推荐选项 -->
    <view class="tabs">
      <text
        v-for="(item, index) in subTypes"
        :key="item.id"
        class="text"
        :class="{ active: activeTabIndex === index }"
        @tap="activeTabIndex = index"
        >{{ item.title }}</text
      >
    </view>
    <!-- 推荐列表 -->
    <!-- 将全部的子分类对应的商品列表进行渲染通过 每个scroll-view相互独立 -->
    <!-- 使用v-show控制展示与隐藏,在隐藏时不会销毁组件,会保留其滚动记录 -->
    <scroll-view
      v-for="(subType, index) in subTypes"
      :key="subType.id"
      v-show="activeTabIndex === index"
      scroll-y
      class="scroll-view"
      refresher-enabled
      refresher-background="#f4f4f4"
      refresher-default-style="white"
      :refresher-triggered="isLoading"
      @refresherrefresh="onRefresh(subType.id)"
      @scrolltolower="onScrolltoLower(subType.id)"
    >
      <view class="goods">
        <navigator
          hover-class="none"
          class="navigator"
          v-for="goods in subType.goodsItems.items"
          :key="goods.id"
          :url="`/pages/goods/goods?id=${goods.id}`"
        >
          <image class="thumb" :src="" data-missing="goods.picture"></image>
          <view class="name ellipsis">{{ goods.name }}</view>
          <view class="price">
            <text class="symbol">¥</text>
            <text class="number">{{ goods.price }}</text>
          </view>
        </navigator>
      </view>
      <view class="loading-text">{{ loadingText }}</view>
    </scroll-view>
  </view>
</template>
Tips:基于Vite创建的Vue3项目的环境变量与模式

Tips:基于Vite创建的Vue3项目的环境变量与模式

Vite 在一个特殊的 import.meta.env 对象上暴露环境变量,这些变量在构建时会被静态地替换掉

  1. 设置项目环境变量:

    在项目的根目录创建 .env.production(生产环境) .env.development(开发环境)

    设置环境变量,必须以 VITE_ 为前缀的变量才会暴露

    示例:

    .env.development

    cmd
    # 开发环境变量 (不同与模式)
    NODE_ENV=development
    # 环境变量名必须以VITE_为前缀才会暴露,通过import.meta.env.VITE_XXX获取
    VITE_SERVER_URL = 'https://pcapi-xiaotuxian-front-devtest.itheima.net'

    .env.production

    cmd
    NODE_ENV='production'
    VITE_SERVER_URL = 'https://pcapi-xiaotuxian-front.itheima.net'
  2. 获取项目的环境变量:

    通过 import.meta.env.VITE_XXX获取暴露的环境变量

    示例:

    js
    // import.meta.env.DEV 获取是否处于开发环境返回boolean
    pageSize: import.meta.env.DEV ? 50 : 1,
    // 获取当前环境的服务器URL
    options.url = import.meta.env.VITE_SERVER_URL + options.url
  3. 模式判断:

    默认情况下,开发服务器 (dev 命令) 运行在 development (开发) 模式,而 build 命令则运行在 production (生产) 模式

    这意味着当执行 vite build 时,它会自动加载 .env.production 中可能存在的环境变量

Tips:赋值与解构(浅拷贝与深拷贝)

Tips:赋值与解构(浅拷贝与深拷贝)

  1. 赋值

    js
    let person = {
      age: 18,
      name: "张三",
      friends: ["王麻子"],
    };
    // 将引用类型数据赋值给新的变量(浅拷贝) 修改会影响到源数据
    let shallowFriends = person.friends; // 浅拷贝
    shallowFriends.push("李四");
    console.log(person.friends); // [ '王麻子', '李四' ]
    // 通过JSON.parse(JSON.stringify())实现深拷贝 修改不会影响到源数据
    let deepFriends = JSON.parse(JSON.stringify(person.friends)); // 深拷贝
    deepFriends.push("王五");
    console.log(person.friends); // [ '王麻子', '李四' ]
    // 将基本类型数据赋值给新的变量(深拷贝)
    let newAge = person.age;
    newAge = 20;
    console.log(person.age); // 18
  2. 解构

    普通解构(不存在引用关系)

    js
    let { age } = person;
    age = 20; // 修改不会影响源数据
    console.log(person.age); // 18

    Vue中使用 Torefs 解构响应式数据(解构出的数据为响应式数据且修改时会影响源数据,源数据发生变化其值也会随之变化 ==> 解构出的变量等同于源数据中的变量)

    js
    const { page, pages, items } = toRefs(
        subTypes.value[activeTabIndex.value].goodsItems,
      )
    page.value = 2  // 会影响subTypes中的数据

五、小兔鲜儿 - 分类模块

用户点击左菜单的一级分类,切换右侧对应的二级分类和商品。

1. 准备工作

1.1 参考效果

商品分类页中的广告位,可复用之前定义的轮播图组件 CustomSwiper

分类页

1.2 静态结构

商品分类页静态结构: src/pages/category/category.vue

vue
<script setup lang="ts">
//
</script>

<template>
  <view class="viewport">
    <!-- 搜索框 -->
    <view class="search">
      <view class="input">
        <text class="icon-search">女靴</text>
      </view>
    </view>
    <!-- 分类 -->
    <view class="categories">
      <!-- 左侧:一级分类 -->
      <scroll-view class="primary" scroll-y>
        <view v-for="(item, index) in 10" :key="item" class="item" :class="{ active: index === 0 }">
          <text class="name"> 居家 </text>
        </view>
      </scroll-view>
      <!-- 右侧:二级分类 -->
      <scroll-view class="secondary" scroll-y>
        <!-- 焦点图 -->
        <CustomSwiper class="banner" :list="[]" />
        <!-- 内容区域 -->
        <view class="panel" v-for="item in 3" :key="item">
          <view class="title">
            <text class="name">宠物用品</text>
            <navigator class="more" hover-class="none">全部</navigator>
          </view>
          <view class="section">
            <navigator
              v-for="goods in 4"
              :key="goods"
              class="goods"
              hover-class="none"
              :url="`/pages/goods/goods?id=`"
            >
              <image
                class="image"
                src="https://yanxuan-item.nosdn.127.net/674ec7a88de58a026304983dd049ea69.jpg"
              ></image>
              <view class="name ellipsis">木天蓼逗猫棍</view>
              <view class="price">
                <text class="symbol">¥</text>
                <text class="number">16.00</text>
              </view>
            </navigator>
          </view>
        </view>
      </scroll-view>
    </view>
  </view>
</template>

<style lang="scss">
page {
  height: 100%;
  overflow: hidden;
}
.viewport {
  height: 100%;
  display: flex;
  flex-direction: column;
}
.search {
  padding: 0 30rpx 20rpx;
  background-color: #fff;
  .input {
    display: flex;
    align-items: center;
    justify-content: space-between;
    height: 64rpx;
    padding-left: 26rpx;
    color: #8b8b8b;
    font-size: 28rpx;
    border-radius: 32rpx;
    background-color: #f3f4f4;
  }
}
.icon-search {
  &::before {
    margin-right: 10rpx;
  }
}
/* 分类 */
.categories {
  flex: 1;
  min-height: 400rpx;
  display: flex;
}
/* 一级分类 */
.primary {
  overflow: hidden;
  width: 180rpx;
  flex: none;
  background-color: #f6f6f6;
  .item {
    display: flex;
    justify-content: center;
    align-items: center;
    height: 96rpx;
    font-size: 26rpx;
    color: #595c63;
    position: relative;
    &::after {
      content: '';
      position: absolute;
      left: 42rpx;
      bottom: 0;
      width: 96rpx;
      border-top: 1rpx solid #e3e4e7;
    }
  }
  .active {
    background-color: #fff;
    &::before {
      content: '';
      position: absolute;
      left: 0;
      top: 0;
      width: 8rpx;
      height: 100%;
      background-color: #27ba9b;
    }
  }
}
.primary .item:last-child::after,
.primary .active::after {
  display: none;
}
/* 二级分类 */
.secondary {
  background-color: #fff;
  .carousel {
    height: 200rpx;
    margin: 0 30rpx 20rpx;
    border-radius: 4rpx;
    overflow: hidden;
  }
  .panel {
    margin: 0 30rpx 0rpx;
  }
  .title {
    height: 60rpx;
    line-height: 60rpx;
    color: #333;
    font-size: 28rpx;
    border-bottom: 1rpx solid #f7f7f8;
    .more {
      float: right;
      padding-left: 20rpx;
      font-size: 24rpx;
      color: #999;
    }
  }
  .more {
    &::after {
      font-family: 'erabbit' !important;
      content: '\e6c2';
    }
  }
  .section {
    width: 100%;
    display: flex;
    flex-wrap: wrap;
    padding: 20rpx 0;
    .goods {
      width: 150rpx;
      margin: 0rpx 30rpx 20rpx 0;
      &:nth-child(3n) {
        margin-right: 0;
      }
      image {
        width: 150rpx;
        height: 150rpx;
      }
      .name {
        padding: 5rpx;
        font-size: 22rpx;
        color: #333;
      }
      .price {
        padding: 5rpx;
        font-size: 18rpx;
        color: #cf4444;
      }
      .number {
        font-size: 24rpx;
        margin-left: 2rpx;
      }
    }
  }
}
</style>

1.3 渲染轮播图

接口调用

注意:传递参数 2 标识获取商品分类页广告。

接口地址:/home/banner

请求方式:GET

请求参数:

Query:

字段名称是否必须默认值备注
distributionSite1活动 banner 位置,1 代表首页,2 代表商品分类页,默认为 1

2. 一级分类

2.1 获取数据

该接口同时包含一级分类和二级分类数据,二级分类数据需要先对数据进行处理,再进行渲染。

接口调用

接口地址:/category/top

请求方式:GET

请求参数:无

请求封装

src/services/category.ts

ts
import type { CategoryOneItem } from '@/types/category'
import { http } from '@/utils/http'

/**获取分类页数据一级分类+二级分类 */
export const getCategoryDataAPI = () =>
  http.get<CategoryOneItem[]>('/category/top')

类型声明

ts
import type { GoodsItem } from './global'

/**一级分类数据类型 */
export interface CategoryOneItem {
  /**一级分类id */
  id: string
  /**一级名称 */
  name: string
  /**一级分类图片 */
  picture: string
  /**一级分类图片集合 */
  imageBanners: string[]
  /**二级分类集合 */
  children: CategoryTwoItem[]
}
/** 二级分类数据类型 */
export interface CategoryTwoItem {
  /**二级分类id */
  id: string
  /**二级分类名称 */
  name: string
  /**二级分类图片 */
  picture: string
  /**二级分类商品集合 */
  goods: GoodsItem[]
}

接下来,先把一级分类数据结合模板语法渲染到页面中

2.2 Tab 交互

使用变量activeIndex存储当前激活一级分类的下标当用户点击一级分类切换时使用动态类名为其绑定.active类名

vue
<script setup lang="ts">
import { onLoad } from '@dcloudio/uni-app'
import { ref } from 'vue'
import type { SwiperItem } from '@/types/home'
import { getSwiperDataAPI } from '@/services/home'
import { getCategoryDataAPI } from '@/services/category'
import type { CategoryOneItem } from '@/types/category'

// 轮播图列表
const swiperList = ref<SwiperItem[]>([])
// 分类数据
const categoryList = ref<CategoryOneItem[]>([])
// 当前激活的一级分类的下标
const activeCategoryIndex = ref(0)

// 获取轮播图数据
const getSwiperData = async () => {
  const result = await getSwiperDataAPI(2)
  // 存储轮播图数据
  swiperList.value = result.result
}
// 获取商品分类数据
const getCategoryData = async () => {
  const result = await getCategoryDataAPI()
  // 存储商品分类数据
  categoryList.value = result.result
}

// 页面加载
onLoad(() => {
  getSwiperData()
  getCategoryData()
})
</script>

<template>
  <view class="viewport">
    <!-- 分类 -->
    <view class="categories">
      <!-- 左侧:一级分类 -->
      <scroll-view class="primary" scroll-y>
        <view
          class="item"
          v-for="(item, index) in categoryList"
          :key="item.id"
          :class="{ active: index === activeIndex }"
          @tap="activeIndex = index"
        >
          {{ item.name }}
        </view>
      </scroll-view>
    </view>
  </view>
</template>

3. 二级分类

商品二级分类是从属于某个一级分类的,通过 computed 配合高亮下标提取当前二级分类数据

当计算属性依赖计算的属性发生变化时,会重新进行计算

参考代码

vue
<script setup lang="ts">
import { computed } from 'vue'

// ...省略

// 获取当前二级分类的数据
/* computed() 会自动从其计算函数的返回值上推导出类型,也可以通过泛型参数显式指定类型
computed<type>(()=>xxxx)计算属性的TS写法为其标注类型 */
const subCategoryList = computed<CategoryTwoItem[]>(
  // categoryList可能为[] 使用可选链(?)访问其属性 使用或运算(||)当其结果为undefined时返回空数组
  () => categoryList.value[activeCategoryIndex.value]?.children || [],
)
</script>

<template>
  <view class="viewport">
      <!-- ...省略 -->
      <!-- 右侧:二级分类 -->
      <scroll-view class="secondary" scroll-y>
        <!-- 焦点图 -->
        <CustomSwiper class="banner" :list="bannerList" />
        <!-- 内容区域 -->
        <view class="panel" v-for="item in subCategoryList" :key="item.id">
          <view class="title">
            <text class="name">{{ item.name }}</text>
            <navigator class="more" hover-class="none">全部</navigator>
          </view>
          <view class="section">
            <navigator
              v-for="goods in item.goods"
              :key="goods.id"
              class="goods"
              hover-class="none"
              :url="`/pages/goods/goods?id=${goods.id}`"
            >
              <image class="image" :src="" data-missing="goods.picture"></image>
              <view class="name ellipsis">{{ goods.name }}</view>
              <view class="price">
                <text class="symbol">¥</text>
                <text class="number">{{ goods.price }}</text>
              </view>
            </navigator>
          </view>
        </view>
      </scroll-view>
    </view>
  </view>
</template>

提取当前二级分类数据后,剩下的就是列表渲染

Tips:为 computed() 显示标注类型

Tips:为 computed() 显示标注类型

computed() 会自动从其计算函数的返回值上推导出类型,也可以通过泛型参数显式指定类型

js
const double = computed<number>(() => {
  // 若返回值不是 number 类型则会报错
})

可选链+或运算

js
// categoryList可能为[] 使用可选链(?)访问其属性 使用或运算(||)当其结果为undefined时返回空数组     
categoryList.value[activeCategoryIndex.value]?.children || [],

4. 骨架屏

4.1 参考效果

当需要获取的数据较多时接口返回时间较长,页面会出现短暂白屏,此时需要使用骨架屏来优化用户体验

实现骨架屏步骤:

  1. 利用微信开发者工具生成骨架屏

  2. 将生成的 skeleton.wxmlskeleton.wxss 封装成一个组件(将行内样式改为 class 样式,删除不需要的结构)

  3. 定义变量控制骨架屏,页面的渲染和隐藏

  4. onLoad生命周期钩子中,可以使用 Promise.all([])并发处理请求提高效率,当数据全部返回后隐藏骨架屏

    js
    // 页面加载
    onLoad(async () => {
      // 显示骨架屏
      isFinish.value = false
      // 使用Promise.all() 并行执行多个异步操作
      await Promise.all([getSwiperData(), getCategoryData()])
      // 数据全部获取完毕 隐藏骨架屏
      isFinish.value = true
    })
分类页骨架屏

通过设置一个变量来控制骨架屏和页面的渲染,当请求数据时展示骨架屏,当数据返回时展示商品页面

4.2 代码参考(总)

商品分类页

vue
<script setup lang="ts">
import { onLoad } from '@dcloudio/uni-app'
import { computed, ref } from 'vue'
import type { SwiperItem } from '@/types/home'
import { getSwiperDataAPI } from '@/services/home'
import { getCategoryDataAPI } from '@/services/category'
import type { CategoryOneItem, CategoryTwoItem } from '@/types/category'
import Skeleton from './components/Skeleton.vue'

// 轮播图列表
const swiperList = ref<SwiperItem[]>([])
// 分类数据
const categoryList = ref<CategoryOneItem[]>([])
// 当前激活的一级分类的下标
const activeCategoryIndex = ref(0)
// 数据是否加载完毕
let isFinish = ref(false)
// scroll-view滚动到顶部
let scrollTop = ref(0)

// 获取当前二级分类的数据
/* computed() 会自动从其计算函数的返回值上推导出类型,也可以通过泛型参数显式指定类型
computed<type>(()=>xxxx)计算属性的TS写法为其标注类型 */
const subCategoryList = computed<CategoryTwoItem[]>(
  // categoryList可能为[] 使用可选链 ? 访问其属性 当其结果为undefined时返回空数组
  () => categoryList.value[activeCategoryIndex.value]?.children || [],
)

// 获取轮播图数据
const getSwiperData = async () => {
  const result = await getSwiperDataAPI(2)
  // 存储轮播图数据
  swiperList.value = result.result
}
// 获取商品分类数据
const getCategoryData = async () => {
  const result = await getCategoryDataAPI()
  // 存储商品分类数据
  categoryList.value = result.result
}
// 切换一级分类的回调
const onchangeCategory = (index: number) => {
  // 修改当前激活的一级分类的下标
  activeCategoryIndex.value = index
  // scroll-view滚动到顶部
  scrollTop.value = 0
}
// 当二级分类的scroll-view滚动时触发
const onScroll: UniHelper.ScrollViewOnScroll = (event) => {
  // 动态修改scrollTop的值
  scrollTop.value = event.detail.scrollTop
}

// 页面加载
onLoad(async () => {
  // 显示骨架屏
  isFinish.value = false
  // 使用Promise.all() 并行执行多个异步操作
  await Promise.all([getSwiperData(), getCategoryData()])
  // 数据全部获取完毕 隐藏骨架屏
  isFinish.value = true
})
</script>

<template>
  <Skeleton v-if="!isFinish" />
  <view v-else class="viewport">
    <!-- 搜索框 -->
    <view class="search">
      <view class="input">
        <text class="icon-search">女靴</text>
      </view>
    </view>
    <!-- 分类 -->
    <view class="categories">
      <!-- 左侧:一级分类 -->
      <scroll-view class="primary" scroll-y>
        <view
          v-for="(categoryOne, index) in categoryList"
          :key="categoryOne.id"
          class="item"
          :class="{ active: index === activeCategoryIndex }"
          @tap="onchangeCategory(index)"
        >
          <text class="name"> {{ categoryOne.name }} </text>
        </view>
      </scroll-view>
      <!-- 右侧:二级分类 -->
      <scroll-view
        class="secondary"
        scroll-y
        :scroll-top="scrollTop"
        @scroll="onScroll"
      >
        <!-- 轮播图 -->
        <CustomSwiper class="banner" :swiperData="swiperList" />
        <!-- 内容区域 -->
        <view class="panel" v-for="item in subCategoryList" :key="item.id">
          <view class="title">
            <text class="name">{{ item.name }}</text>
            <navigator class="more" hover-class="none">全部</navigator>
          </view>
          <view class="section">
            <navigator
              v-for="goods in item.goods"
              :key="goods.id"
              class="goods"
              hover-class="none"
              :url="`/pages/goods/goods?id=${goods.id}`"
            >
              <image class="image" :src="" data-missing="goods.picture"></image>
              <view class="name ellipsis">{{ goods.name }}</view>
              <view class="price">
                <text class="symbol">¥</text>
                <text class="number">{{ goods.price }}</text>
              </view>
            </navigator>
          </view>
        </view>
      </scroll-view>
    </view>
  </view>
</template>
Tips:scroll-view 回到顶部

Tips:scroll-view 回到顶部

大多数页面都使用 scroll-view组件来展示数据,当页面内导航切换时需要 scrolll-view 回到顶部,或上次浏览的地方

场景一:

不同页面使用独立的 scroll-view 之间互不影响(推荐)

使用 v-if 控制 scroll-view 的显示与隐藏,显示时会重新加载自动回到顶部

使用 v-show 控制 scroll-view 的显示与隐藏,每个 scroll-view的滚动记录相互独立且不会销毁

场景二:

多个页面使用同一个 scroll-view 动态替换数据

  • 第一步:为 scroll-view 组件添加 scroll-top(设置竖向滚动条位置)属性并动态绑定值为 scrollTop
  • 第二步:为 scroll-view组件添加 scroll(滚动时触发)事件在事件回调中动态修改 scrollTop 的值(对事件回调进行防抖优化,避免频繁修改scrollTop造成页面抖动)
  • 第三步:在切换导航栏的事件回调中将 scrollTop修改为 0
html
<!-- 右侧:二级分类 -->
  <scroll-view
   class="secondary"
   scroll-y
+  :scroll-top="scrollTop"
+  @scroll="onScroll"
>
js
// @ts-ignore
+import { debounce } from 'lodash' // 引入防抖函数
+// scroll-view滚动的top值
+let scrollTop = ref(0)

+// 当二级分类的scroll-view滚动时触发
+const onScroll = debounce((event: UniHelper.ScrollViewOnScrollEvent) => {
+  // 优化:防抖 当滚动行为结束0.5后再更新scrollTop的值,防止频繁修改scrollTop的值导致页面抖动
+  // 动态修改scrollTop的值
+  scrollTop.value = event.detail.scrollTop
+}, 500)

// 切换一级分类的回调
const onchangeCategory = (index: number) => {
  // 修改当前激活的一级分类的下标
  activeCategoryIndex.value = index
+  // scroll-view滚动到顶部
+  scrollTop.value = 0
}

六、小兔鲜儿 - 商品详情(登录前)

商品详情页分为两部分:

  1. 登录前:展示商品信息,轮播图交互(当前模块)
  2. 登录后:加入购物车,立即购买(SKU 模块)

1. 准备工作

1.1 参考效果

用户点击商品列表,跳转到对应的商品详情页。

商品详情

1.2 静态结构

新建商品详情页 src/pages/goods/goods.vue

vue
<script setup lang="ts">
// 获取屏幕边界到安全区域距离
const { safeAreaInsets } = uni.getSystemInfoSync()
</script>

<template>
  <scroll-view scroll-y class="viewport">
    <!-- 基本信息 -->
    <view class="goods">
      <!-- 商品主图 -->
      <view class="preview">
        <swiper circular>
          <swiper-item>
            <image
              mode="aspectFill"
              src="https://yanxuan-item.nosdn.127.net/99c83709ca5f9fd5c5bb35d207ad7822.png"
            />
          </swiper-item>
          <swiper-item>
            <image
              mode="aspectFill"
              src="https://yanxuan-item.nosdn.127.net/f9107d47c08f0b99c097e30055c39e1a.png"
            />
          </swiper-item>
          <swiper-item>
            <image
              mode="aspectFill"
              src="https://yanxuan-item.nosdn.127.net/754c56785cc8c39f7414752f62d79872.png"
            />
          </swiper-item>
          <swiper-item>
            <image
              mode="aspectFill"
              src="https://yanxuan-item.nosdn.127.net/ef16f8127610ef56a2a10466d6dae157.jpg"
            />
          </swiper-item>
          <swiper-item>
            <image
              mode="aspectFill"
              src="https://yanxuan-item.nosdn.127.net/1f0c3f5d32b0e804deb9b3d56ea6c3b2.png"
            />
          </swiper-item>
        </swiper>
        <view class="indicator">
          <text class="current">1</text>
          <text class="split">/</text>
          <text class="total">5</text>
        </view>
      </view>

      <!-- 商品简介 -->
      <view class="meta">
        <view class="price">
          <text class="symbol">¥</text>
          <text class="number">29.90</text>
        </view>
        <view class="name ellipsis">云珍·轻软旅行长绒棉方巾 </view>
        <view class="desc"> 轻巧无捻小方巾,旅行便携 </view>
      </view>

      <!-- 操作面板 -->
      <view class="action">
        <view class="item arrow">
          <text class="label">选择</text>
          <text class="text ellipsis"> 请选择商品规格 </text>
        </view>
        <view class="item arrow">
          <text class="label">送至</text>
          <text class="text ellipsis"> 请选择收获地址 </text>
        </view>
        <view class="item arrow">
          <text class="label">服务</text>
          <text class="text ellipsis"> 无忧退 快速退款 免费包邮 </text>
        </view>
      </view>
    </view>

    <!-- 商品详情 -->
    <view class="detail panel">
      <view class="title">
        <text>详情</text>
      </view>
      <view class="content">
        <view class="properties">
          <!-- 属性详情 -->
          <view class="item">
            <text class="label">属性名</text>
            <text class="value">属性值</text>
          </view>
          <view class="item">
            <text class="label">属性名</text>
            <text class="value">属性值</text>
          </view>
        </view>
        <!-- 图片详情 -->
        <image
          mode="widthFix"
          src="https://yanxuan-item.nosdn.127.net/a8d266886d31f6eb0d7333c815769305.jpg"
        ></image>
        <image
          mode="widthFix"
          src="https://yanxuan-item.nosdn.127.net/a9bee1cb53d72e6cdcda210071cbd46a.jpg"
        ></image>
      </view>
    </view>

    <!-- 同类推荐 -->
    <view class="similar panel">
      <view class="title">
        <text>同类推荐</text>
      </view>
      <view class="content">
        <navigator
          v-for="item in 4"
          :key="item"
          class="goods"
          hover-class="none"
          :url="`/pages/goods/goods?id=`"
        >
          <image
            class="image"
            mode="aspectFill"
            src="https://yanxuan-item.nosdn.127.net/e0cea368f41da1587b3b7fc523f169d7.png"
          ></image>
          <view class="name ellipsis">简约山形纹全棉提花毛巾</view>
          <view class="price">
            <text class="symbol">¥</text>
            <text class="number">18.50</text>
          </view>
        </navigator>
      </view>
    </view>
  </scroll-view>

  <!-- 用户操作 -->
  <view class="toolbar" :style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }">
    <view class="icons">
      <button class="icons-button"><text class="icon-heart"></text>收藏</button>
      <button class="icons-button" open-type="contact">
        <text class="icon-handset"></text>客服
      </button>
      <navigator class="icons-button" url="/pages/cart/cart" open-type="switchTab">
        <text class="icon-cart"></text>购物车
      </navigator>
    </view>
    <view class="buttons">
      <view class="addcart"> 加入购物车 </view>
      <view class="buynow"> 立即购买 </view>
    </view>
  </view>
</template>

<style lang="scss">
page {
  height: 100%;
  overflow: hidden;
  display: flex;
  flex-direction: column;
}

.viewport {
  background-color: #f4f4f4;
}

.panel {
  margin-top: 20rpx;
  background-color: #fff;
  .title {
    display: flex;
    justify-content: space-between;
    align-items: center;
    height: 90rpx;
    line-height: 1;
    padding: 30rpx 60rpx 30rpx 6rpx;
    position: relative;
    text {
      padding-left: 10rpx;
      font-size: 28rpx;
      color: #333;
      font-weight: 600;
      border-left: 4rpx solid #27ba9b;
    }
    navigator {
      font-size: 24rpx;
      color: #666;
    }
  }
}

.arrow {
  &::after {
    position: absolute;
    top: 50%;
    right: 30rpx;
    content: '\e6c2';
    color: #ccc;
    font-family: 'erabbit' !important;
    font-size: 32rpx;
    transform: translateY(-50%);
  }
}

/* 商品信息 */
.goods {
  background-color: #fff;
  .preview {
    height: 750rpx;
    position: relative;
    .image {
      width: 750rpx;
      height: 750rpx;
    }
    .indicator {
      height: 40rpx;
      padding: 0 24rpx;
      line-height: 40rpx;
      border-radius: 30rpx;
      color: #fff;
      font-family: Arial, Helvetica, sans-serif;
      background-color: rgba(0, 0, 0, 0.3);
      position: absolute;
      bottom: 30rpx;
      right: 30rpx;
      .current {
        font-size: 26rpx;
      }
      .split {
        font-size: 24rpx;
        margin: 0 1rpx 0 2rpx;
      }
      .total {
        font-size: 24rpx;
      }
    }
  }
  .meta {
    position: relative;
    border-bottom: 1rpx solid #eaeaea;
    .price {
      height: 130rpx;
      padding: 25rpx 30rpx 0;
      color: #fff;
      font-size: 34rpx;
      box-sizing: border-box;
      background-color: #35c8a9;
    }
    .number {
      font-size: 56rpx;
    }
    .brand {
      width: 160rpx;
      height: 80rpx;
      overflow: hidden;
      position: absolute;
      top: 26rpx;
      right: 30rpx;
    }
    .name {
      max-height: 88rpx;
      line-height: 1.4;
      margin: 20rpx;
      font-size: 32rpx;
      color: #333;
    }
    .desc {
      line-height: 1;
      padding: 0 20rpx 30rpx;
      font-size: 24rpx;
      color: #cf4444;
    }
  }
  .action {
    padding-left: 20rpx;
    .item {
      height: 90rpx;
      padding-right: 60rpx;
      border-bottom: 1rpx solid #eaeaea;
      font-size: 26rpx;
      color: #333;
      position: relative;
      display: flex;
      align-items: center;
      &:last-child {
        border-bottom: 0 none;
      }
    }
    .label {
      width: 60rpx;
      color: #898b94;
      margin: 0 16rpx 0 10rpx;
    }
    .text {
      flex: 1;
      -webkit-line-clamp: 1;
    }
  }
}

/* 商品详情 */
.detail {
  padding-left: 20rpx;
  .content {
    margin-left: -20rpx;
    .image {
      width: 100%;
    }
  }
  .properties {
    padding: 0 20rpx;
    margin-bottom: 30rpx;
    .item {
      display: flex;
      line-height: 2;
      padding: 10rpx;
      font-size: 26rpx;
      color: #333;
      border-bottom: 1rpx dashed #ccc;
    }
    .label {
      width: 200rpx;
    }
    .value {
      flex: 1;
    }
  }
}
/* 同类推荐 */
.similar {
  .content {
    padding: 0 20rpx 200rpx;
    background-color: #f4f4f4;
    display: flex;
    flex-wrap: wrap;
    .goods {
      width: 340rpx;
      padding: 24rpx 20rpx 20rpx;
      margin: 20rpx 7rpx;
      border-radius: 10rpx;
      background-color: #fff;
    }
    .image {
      width: 300rpx;
      height: 260rpx;
    }
    .name {
      height: 80rpx;
      margin: 10rpx 0;
      font-size: 26rpx;
      color: #262626;
    }
    .price {
      line-height: 1;
      font-size: 20rpx;
      color: #cf4444;
    }
    .number {
      font-size: 26rpx;
      margin-left: 2rpx;
    }
  }
  navigator {
    &:nth-child(even) {
      margin-right: 0;
    }
  }
}
/* 底部工具栏 */
.toolbar {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 1;
  background-color: #fff;
  height: 100rpx;
  padding: 0 20rpx var(--window-bottom);
  border-top: 1rpx solid #eaeaea;
  display: flex;
  justify-content: space-between;
  align-items: center;
  box-sizing: content-box;
  .buttons {
    display: flex;
    & > view {
      width: 220rpx;
      text-align: center;
      line-height: 72rpx;
      font-size: 26rpx;
      color: #fff;
      border-radius: 72rpx;
    }
    .addcart {
      background-color: #ffa868;
    }
    .buynow,
    .payment {
      background-color: #27ba9b;
      margin-left: 20rpx;
    }
  }
  .icons {
    padding-right: 10rpx;
    display: flex;
    align-items: center;
    flex: 1;
    .icons-button {
      flex: 1;
      text-align: center;
      line-height: 1.4;
      padding: 0;
      margin: 0;
      border-radius: 0;
      font-size: 20rpx;
      color: #333;
      background-color: #fff;
      &::after {
        border: none;
      }
    }
    text {
      display: block;
      font-size: 34rpx;
    }
  }
}
</style>

1.3 获取数据

获取页面参数

根据商品的 id 查询到某个商品的详细信息,如图片、价格、型号等展示给用户

ts
// 接收页面参数
const query = defineProps<{
  id: string
}>()

接口调用

接口信息如下:

接口地址:/goods

请求方式:GET

请求参数:

Query

字段名称是否必须默认值备注
id商品 id

请求封装

ts
/**
 * 获取商品详情的接口方法
 * @param id 商品id
 */
export const getGoodsInfoAPI = (id: string) =>
  http.get<GoodsResult>('/goods', { id: id })

类型声明

ts
import type { GoodsItem } from './global'

/** 商品信息 */
export type GoodsResult = {
  /** id */
  id: string
  /** 商品名称 */
  name: string
  /** 商品描述 */
  desc: string
  /** 当前价格 */
  price: number
  /** 原价 */
  oldPrice: number
  /** 商品详情: 包含详情属性 + 详情图片 */
  details: Details
  /** 主图图片集合[ 主图图片链接 ] */
  mainPictures: string[]
  /** 同类商品[ 商品信息 ] */
  similarProducts: GoodsItem[]
  /** sku集合[ sku信息 ] */
  skus: SkuItem[]
  /** 可选规格集合备注[ 可选规格信息 ] */
  specs: SpecItem[]
  /** 用户地址列表[ 地址信息 ] */
  userAddresses: AddressItem[]
}

/** 商品详情: 包含详情属性 + 详情图片 */
export type Details = {
  /** 商品属性集合[ 属性信息 ] */
  properties: DetailsPropertyItem[]
  /** 商品详情图片集合[ 图片链接 ] */
  pictures: string[]
}

/** 属性信息 */
export type DetailsPropertyItem = {
  /** 属性名称 */
  name: string
  /** 属性值 */
  value: string
}

/** sku信息 */
export type SkuItem = {
  /** id */
  id: string
  /** 库存 */
  inventory: number
  /** 原价 */
  oldPrice: number
  /** sku图片 */
  picture: string
  /** 当前价格 */
  price: number
  /** sku编码 */
  skuCode: string
  /** 规格集合[ 规格信息 ] */
  specs: SkuSpecItem[]
}

/** 规格信息 */
export type SkuSpecItem = {
  /** 规格名称 */
  name: string
  /** 可选值名称 */
  valueName: string
}

/** 可选规格信息 */
export type SpecItem = {
  /** 规格名称 */
  name: string
  /** 可选值集合[ 可选值信息 ] */
  values: SpecValueItem[]
}

/** 可选值信息 */
export type SpecValueItem = {
  /** 是否可售 */
  available: boolean
  /** 可选值备注 */
  desc: string
  /** 可选值名称 */
  name: string
  /** 可选值图片链接 */
  picture: string
}

/** 地址信息 */
export type AddressItem = {
  /** 收货人姓名 */
  receiver: string
  /** 联系方式 */
  contact: string
  /** 省份编码 */
  provinceCode: string
  /** 城市编码 */
  cityCode: string
  /** 区/县编码 */
  countyCode: string
  /** 详细地址 */
  address: string
  /** 默认地址,1为是,0为否 */
  isDefault: number
  /** 收货地址 id */
  id: string
  /** 省市区 */
  fullLocation: string
}

接下来,将获取到的数据结合模板语法渲染到页面中

2. 轮播图交互

2.1 参考效果

当轮播图滑动切换的时候更新自定义下标,当图片被点击的时候大图预览

[Missing Image: goods_picture_2.png]

2.2 实现步骤

实现轮播图切换时,更新自定义下标

  1. 定义一个变量 currImgIndex 存储当前轮播图展示图片的下标

    js
    // 当前轮播图展示图片的下标
    let currImgIndex = ref(0)
  2. 在轮播图切换的 change事件回调中获取当前图片的下标并更新 currImgIndex

    js
    // 轮播图切换事件回调 使用UniHelper提供的类型声明事件回调的类型 (UniHelper.SwiperOnChange)
    const onSwiperChange: UniHelper.SwiperOnChange = (event) => {
      // 更新当前展示图片的下标
      currImgIndex.value = event.detail.current
    }
  3. 使用时要对 currImgIndex 进行加一,图片下标从1开始

    js
    <text class="current">{{ currImgIndex + 1 }}</text>

实现点击图片全屏预览

  1. image/swiper-item 绑定 tap事件并传递当前图片的url

    html
    <swiper-item
      v-for="(item, index) in goodsInfo?.mainPictures"
      :key="index"
      @tap="onTapImage(item)"
    >
      <image mode="aspectFill" :src="" data-missing="item" />
    </swiper-item>
  2. tap 事件回调中调用 uni.previewImage传入当前展示图片的 url和全部商品图片的 url

    js
    // 点击图片进入全屏预览
    const onTapImage = (url: string) => {
      uni.previewImage({
        urls: goodsInfo.value?.mainPictures || [],
        current: url,
        showmenu: true,
      })
    }

2.3 参考代码

商品详情页轮播图交互

vue
<script setup lang="ts">
// 当前轮播图展示图片的下标
let currImgIndex = ref(0)
// 轮播图切换事件回调 使用UniHelper提供的类型声明事件回调的类型 (UniHelper.SwiperOnChange)
const onSwiperChange: UniHelper.SwiperOnChange = (event) => {
  // 更新当前展示图片的下标
  currImgIndex.value = event.detail.current
}

// 点击图片时
const onTapImage = (url: string) => {
  // 大图预览
  uni.previewImage({
    current: url,
    urls: goods.value!.mainPictures,
  })
}
</script>

<template>
  <!-- 商品主图 -->
  <view class="preview">
    <swiper @change="onChange" circular>
      <swiper-item v-for="item in goods?.mainPictures" :key="item">
        <image @tap="onTapImage(item)" mode="aspectFill" :src="" data-missing="item" />
      </swiper-item>
    </swiper>
    <view class="indicator">
      <text class="current">{{ currImgIndex + 1 }}</text>
      <text class="split">/</text>
      <text class="total">{{ goods?.mainPictures.length }}</text>
    </view>
  </view>
</template>

3. 弹出层交互

3.1 参考效果

uni-ui 弹出层组件:uni-popup

[Missing Image: goods_picture_3.png]

3.2 静态结构

提供 服务说明收获地址 两个组件的静态结构,实现弹出层交互。

组件 1:服务说明

vue
// ServicePanel.vue
<script setup lang="ts">
//
</script>

<template>
  <view class="service-panel">
    <!-- 关闭按钮 -->
    <text class="close icon-close"></text>
    <!-- 标题 -->
    <view class="title">服务说明</view>
    <!-- 内容 -->
    <view class="content">
      <view class="item">
        <view class="dt">无忧退货</view>
        <view class="dd">
          自收到商品之日起30天内,可在线申请无忧退货服务(食品等特殊商品除外)
        </view>
      </view>
      <view class="item">
        <view class="dt">快速退款</view>
        <view class="dd">
          收到退货包裹并确认无误后,将在48小时内办理退款,
          退款将原路返回,不同银行处理时间不同,预计1-5个工作日到账
        </view>
      </view>
      <view class="item">
        <view class="dt">满88元免邮费</view>
        <view class="dd">
          单笔订单金额(不含运费)满88元可免邮费,不满88元, 单笔订单收取10元邮费
        </view>
      </view>
    </view>
  </view>
</template>

<style lang="scss">
.service-panel {
  padding: 0 30rpx;
  border-radius: 10rpx 10rpx 0 0;
  position: relative;
  background-color: #fff;
}
.title {
  line-height: 1;
  padding: 40rpx 0;
  text-align: center;
  font-size: 32rpx;
  font-weight: normal;
  border-bottom: 1rpx solid #ddd;
  color: #444;
}
.close {
  position: absolute;
  right: 24rpx;
  top: 24rpx;
}
.content {
  padding: 20rpx 20rpx 100rpx 20rpx;
  .item {
    margin-top: 20rpx;
  }
  .dt {
    margin-bottom: 10rpx;
    font-size: 28rpx;
    color: #333;
    font-weight: 500;
    position: relative;
    &::before {
      content: '';
      width: 10rpx;
      height: 10rpx;
      border-radius: 50%;
      background-color: #eaeaea;
      transform: translateY(-50%);
      position: absolute;
      top: 50%;
      left: -20rpx;
    }
  }
  .dd {
    line-height: 1.6;
    font-size: 26rpx;
    color: #999;
  }
}
</style>

组件 2:收获地址组件

vue
// AddressPanel.vue
<script setup lang="ts">
//
</script>

<template>
  <view class="address-panel">
    <!-- 关闭按钮 -->
    <text class="close icon-close"></text>
    <!-- 标题 -->
    <view class="title">配送至</view>
    <!-- 内容 -->
    <view class="content">
      <view class="item">
        <view class="user">李明 13824686868</view>
        <view class="address">北京市顺义区后沙峪地区安平北街6号院</view>
        <text class="icon icon-checked"></text>
      </view>
      <view class="item">
        <view class="user">王东 13824686868</view>
        <view class="address">北京市顺义区后沙峪地区安平北街6号院</view>
        <text class="icon icon-ring"></text>
      </view>
      <view class="item">
        <view class="user">张三 13824686868</view>
        <view class="address">北京市朝阳区孙河安平北街6号院</view>
        <text class="icon icon-ring"></text>
      </view>
    </view>
    <view class="footer">
      <view class="button primary"> 新建地址 </view>
      <view v-if="false" class="button primary">确定</view>
    </view>
  </view>
</template>

<style lang="scss">
.address-panel {
  padding: 0 30rpx;
  border-radius: 10rpx 10rpx 0 0;
  position: relative;
  background-color: #fff;
}
.title {
  line-height: 1;
  padding: 40rpx 0;
  text-align: center;
  font-size: 32rpx;
  font-weight: normal;
  border-bottom: 1rpx solid #ddd;
  color: #444;
}
.close {
  position: absolute;
  right: 24rpx;
  top: 24rpx;
}
.content {
  min-height: 300rpx;
  max-height: 540rpx;
  overflow: auto;
  padding: 20rpx;
  .item {
    padding: 30rpx 50rpx 30rpx 60rpx;
    background-size: 40rpx;
    background-repeat: no-repeat;
    background-position: 0 center;
    background-image: url(https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/images/locate.png);
    position: relative;
  }
  .icon {
    color: #999;
    font-size: 40rpx;
    transform: translateY(-50%);
    position: absolute;
    top: 50%;
    right: 0;
  }
  .icon-checked {
    color: #27ba9b;
  }
  .icon-ring {
    color: #444;
  }
  .user {
    font-size: 28rpx;
    color: #444;
    font-weight: 500;
  }
  .address {
    font-size: 26rpx;
    color: #666;
  }
}
.footer {
  display: flex;
  justify-content: space-between;
  padding: 20rpx 0 40rpx;
  font-size: 28rpx;
  color: #444;
  .button {
    flex: 1;
    height: 72rpx;
    text-align: center;
    line-height: 72rpx;
    margin: 0 20rpx;
    color: #fff;
    border-radius: 72rpx;
  }
  .primary {
    color: #fff;
    background-color: #27ba9b;
  }
  .secondary {
    background-color: #ffa868;
  }
}
</style>

3.3 参考代码

商品详情页:通过组件 ref 获取弹出层组件实例,调用打开弹出层方法。

vue
<script setup lang="ts">
import AddressPanel from './components/AddressPanel.vue'
import ServicePanel from './components/ServicePanel.vue'

// 底部弹出层组件实例对象
const popup = ref<{
  open: (type?: UniHelper.UniPopupType) => void
  close: () => void
}>()
// 当前展示弹出层的类型
let currPopupType = ref<'address' | 'service'>()

// 显示弹出层
const openPopup = (type: typeof currPopupType.value) => {
  // 打开弹出层
  popup.value?.open()
  // 存储当前是哪个弹出层
  currPopupType.value = type
}
</script>

<template>
  <!-- 操作面板 -->
      <view class="action">
        <view class="item arrow">
          <text class="label">选择</text>
          <text class="text ellipsis"> 请选择商品规格 </text>
        </view>
        <view class="item arrow" @tap="openPopup('address')">
          <text class="label">送至</text>
          <text class="text ellipsis"> 请选择收获地址 </text>
        </view>
        <view class="item arrow" @tap="openPopup('service')">
          <text class="label">服务</text>
          <text class="text ellipsis"> 无忧退 快速退款 免费包邮 </text>
        </view>
      </view>
    </view>
    <!-- 底部弹层-->
    <uni-popup
      ref="popup"
      type="bottom"
      border-radius="10px 10px 0 0"
      background-color="#fff"
    >
      <AddressPanel
        v-show="currPopupType === 'address'"
        @close-popup="popup?.close()"
      />
      <ServicePanel
        v-show="currPopupType === 'service'"
        @close-popup="popup?.close()"
      />
    </uni-popup>
</template>

服务说明收获地址 组件 子 -> 父 通讯:自定义事件

vue
<script setup lang="ts">
// 声明接收父组件绑定的自定义事件
const emit = defineEmits<{
  (e: 'close-popup'): void
}>()
</script>

<template>
  <view class="service-panel">
    <!-- 关闭按钮 -->
    <text class="close icon-close" @tap="emit('close')"></text>
    ...省略
  </view>
</template>
Tips:为组件的 emits 标注类型

Tips:为组件的 emits 标注类型

js
const emit = defineEmits<{
  (e: 'change', id: number): void // 标注自定义事件的事件名,参数和返回值类型
  (e: 'update'): void // 标注自定义事件的事件名,返回值类型
}>()
Tips:typeOf获取变量类型

Tips:typeOf获取变量类型

js
type: typeof currPopupType.value  // type的类型和currPopupType.value的类型相同

4. 骨架屏

4.1 参考效果

[Missing Image: goods_picture_4.png]

4.2 参考代码(总)

商品详情页 src/pages/goods

vue
<script setup lang="ts">
import { onLoad } from '@dcloudio/uni-app'
import { getGoodsInfoAPI } from '@/services/goods'
import { ref } from 'vue'
import type { GoodsResult } from '@/types/goods'
import AddressPanel from './components/AddressPanel.vue'
import ServicePanel from './components/ServicePanel.vue'
import GoodsSkeleton from './components/GoodsSkeleton.vue'

// 获取屏幕边界到安全区域距离
const { safeAreaInsets } = uni.getSystemInfoSync()
// 商品详情数据
// 注意:对象类型的数据一般不给初始值(访问数据时使用可选链),如果指定初始值需要设置必填字段
const goodsInfo = ref<GoodsResult>()
// 当前轮播图展示图片的下标
let currImgIndex = ref(0)
// 底部弹出层组件实例对象
const popup = ref<{
  open: (type?: UniHelper.UniPopupType) => void
  close: () => void
}>()
// 当前展示弹出层的类型
let currPopupType = ref<'address' | 'service'>()
// 控制骨架屏展示与隐藏
let isFinish = ref(false)

// 接收页面参数(商品id)
const query = defineProps<{
  id: string
}>()

// 获取商品详情数据
const getGoodsInfo = async () => {
  const result = await getGoodsInfoAPI(query.id)
  // 存储商品详情数据
  goodsInfo.value = result.result
}
// 轮播图切换事件回调
const onSwiperChange: UniHelper.SwiperOnChange = (event) => {
  // 更新当前展示图片的下标
  currImgIndex.value = event.detail.current
}
// 点击图片进入全屏预览
const onTapImage = (url: string) => {
  uni.previewImage({
    // 当goodsInfo.value?.mainPictures为undefined时,url=[] 或是使用非空断言
    urls: goodsInfo.value?.mainPictures || [], // 需要预览的图片连接列表
    current: url, // 当前预览图片的URL
    showmenu: true, // 运行长按图片时显示菜单
  })
}
// 显示弹出层
const openPopup = (type: typeof currPopupType.value) => {
  // 打开弹出层
  popup.value?.open()
  // 存储当前是哪个弹出层
  currPopupType.value = type
}

// 页面加载
onLoad(async () => {
  // 展示骨架屏
  isFinish.value = false
  // 获取商品详情数据
  await getGoodsInfo()
  // 关闭骨架屏
  isFinish.value = true
})
</script>

<template>
  <!-- 骨架屏 -->
  <GoodsSkeleton v-if="!isFinish" />
  <!-- 商品展示区 -->
  <scroll-view v-else scroll-y class="viewport">
    <!-- 基本信息 -->
    <view class="goods">
      <!-- 商品轮播图 -->
      <view class="preview">
        <swiper circular @change="onSwiperChange">
          <swiper-item
            v-for="(item, index) in goodsInfo?.mainPictures"
            :key="index"
            @tap="onTapImage(item)"
          >
            <image mode="aspectFill" :src="" data-missing="item" />
          </swiper-item>
        </swiper>
        <view class="indicator">
          <text class="current">{{ currImgIndex + 1 }}</text>
          <text class="split">/</text>
          <text class="total">{{ goodsInfo?.mainPictures.length }}</text>
        </view>
      </view>
      <!-- 商品简介 -->
      <view class="meta">
        <view class="price">
          <text class="symbol">¥</text>
          <text class="number">{{ goodsInfo?.price }}</text>
          <text class="old-number">{{ goodsInfo?.oldPrice }}</text>
        </view>
        <view class="name ellipsis">{{ goodsInfo?.name }} </view>
        <view class="desc"> {{ goodsInfo?.desc }} </view>
      </view>
      <!-- 操作面板 -->
      <view class="action">
        <view class="item arrow">
          <text class="label">选择</text>
          <text class="text ellipsis"> 请选择商品规格 </text>
        </view>
        <view class="item arrow" @tap="openPopup('address')">
          <text class="label">送至</text>
          <text class="text ellipsis"> 请选择收获地址 </text>
        </view>
        <view class="item arrow" @tap="openPopup('service')">
          <text class="label">服务</text>
          <text class="text ellipsis"> 无忧退 快速退款 免费包邮 </text>
        </view>
      </view>
    </view>
    <!-- 底部弹层-->
    <uni-popup
      ref="popup"
      type="bottom"
      border-radius="10px 10px 0 0"
      background-color="#fff"
    >
      <AddressPanel
        v-show="currPopupType === 'address'"
        @close-popup="popup?.close()"
      />
      <ServicePanel
        v-show="currPopupType === 'service'"
        @close-popup="popup?.close()"
      />
    </uni-popup>
    <!-- 商品详情 -->
    <view class="detail panel">
      <view class="title">
        <text>详情</text>
      </view>
      <view class="content">
        <view class="properties">
          <!-- 属性详情 -->
          <view
            class="item"
            v-for="item in goodsInfo?.details.properties"
            :key="item.name"
          >
            <text class="label">{{ item.name }}</text>
            <text class="value">{{ item.value }}</text>
          </view>
        </view>
        <!-- 图片详情 -->
        <image
          v-for="item in goodsInfo?.details.pictures"
          :key="item"
          mode="widthFix"
          :src="" data-missing="item"
        ></image>
      </view>
    </view>
    <!-- 同类推荐 -->
    <view class="similar panel">
      <view class="title">
        <text>同类推荐</text>
      </view>
      <view class="content">
        <navigator
          v-for="item in goodsInfo?.similarProducts"
          :key="item.id"
          class="goods"
          hover-class="none"
          :url="`/pages/goods/goods?id=${item.id}`"
        >
          <image class="image" mode="aspectFill" :src="" data-missing="item.picture"></image>
          <view class="name ellipsis">{{ item.name }}</view>
          <view class="price">
            <text class="symbol">¥</text>
            <text class="number">{{ item.price }}</text>
          </view>
        </navigator>
      </view>
    </view>
    <!-- 24小时热销 -->
    <view class="similar panel">
      <view class="title">
        <text>24小时热销</text>
      </view>
      <view class="content">
        <navigator
          v-for="item in goodsInfo?.hotByDay"
          :key="item.id"
          class="goods"
          hover-class="none"
          :url="`/pages/goods/goods?id=${item.id}`"
        >
          <image class="image" mode="aspectFill" :src="" data-missing="item.picture"></image>
          <view class="name ellipsis">{{ item.name }}</view>
          <view class="price">
            <text class="symbol">¥</text>
            <text class="number">{{ item.price }}</text>
          </view>
        </navigator>
      </view>
    </view>
  </scroll-view>
  <!-- 底部结算导航栏 -->
  <view
    class="toolbar"
    :style="{ paddingBottom: safeAreaInsets?.bottom + 'px' }"
  >
    <view class="icons">
      <button class="icons-button">
        <text class="icon-heart"></text>收藏
      </button>
      <button class="icons-button" open-type="contact">
        <text class="icon-handset"></text>客服
      </button>
      <navigator
        class="icons-button"
        url="/pages/cart/cart"
        open-type="switchTab"
      >
        <text class="icon-cart"></text>购物车
      </navigator>
    </view>
    <view class="buttons">
      <view class="addcart"> 加入购物车 </view>
      <view class="buynow"> 立即购买 </view>
    </view>
  </view>
</template>