uni-app 小兔鲜小程序(一)

9,027 阅读24分钟

黑马程序员前端项目uniapp小兔鲜儿微信小程序项目视频教程,基于Vue3+Ts+Pinia+uni-app的最新组合技术栈开发的电商业务全流程

一、uni-app 基础

创建 uni-app 项目

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

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

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

通过 HBuilderX 创建

创建步骤

1.下载安装 HbuilderX 编辑器

uniapp_picture_1.png

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

uniapp_picture_2.png

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

uniapp_picture_3.png

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

uniapp_picture_4.png

5.开启服务端口

uniapp_picture_5.png

小技巧分享:模拟器窗口分离和置顶

uniapp_picture_6.png

HBuildeX 和 微信开发者工具 关系(vscode跟微信开发者工具的关系也是同理)

uniapp_picture_7.png

HBuildeXuni-app 都属于 DCloud 公司的产品。

通过命令行创建

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

vue3 + ts 版

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

vue3 + Js 版

npx degit dcloudio/uni-preset-vue#vite 项目名称

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

下载失败时的常见问题

编译和运行 uni-app 项目

  1. manifase.json 添加 appid
  2. 安装依赖 pnpm install
  3. 编译成微信小程序 pnpm dev:mp-weixin(编译成 H5 端的话,可运行 npm run dev:h5 来通过浏览器预览项目)
  4. 导入微信开发者工具

项目结构

我们先来认识 uni-app 项目的目录结构和pages.json等文件的用处,后面会详细介绍pages.json这几个文件的用处

目录

├─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内置的常用样式变量
└─tsconfig.json	   TypeScript项目的配置文件,用于配置类型声明文件

pages.json

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

image-20240408135805198.png

案例练习

效果预览

uniapp_case_1.png

参考代码

{
  // 页面路由
  "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"
      }
    ]
  }
}

tsconfig.json

作用:TypeScript配置文件,用于Vue.js项目,同时包含一些针对uni-app和微信小程序的配置

{
  "extends": "@vue/tsconfig/tsconfig.json", // Vue CLI 默认的 TypeScript 扩展配置文件
  "compilerOptions": { // 编译器配置选项
    "allowJs": true, // 允许编译 JavaScript 文件
    "sourceMap": true, // 生成 source map 文件,用于调试
    "baseUrl": ".", // 用于解析非相对模块名称的基本目录
    "paths": { // 指定模块名的基于 baseUrl 路径映射
      "@/*": [
        "./src/*"
      ]
    },
    "lib": [ // 编译过程中包含的库文件列表
      "esnext",
      "dom"
    ],
    "types": [ // 要包含的类型声明文件列表
      "@dcloudio/types", // uni-app API 类型
      "miniprogram-api-typings", // 原生微信小程序类型
      "@uni-helper/uni-app-types", // uni-app 组件类型
      "@uni-helper/uni-ui-types" // uni-ui 组件类型
    ]
  },
  "vueCompilerOptions": { // Vue 模板编译器选项
    // experimentalRuntimeMode 已废弃,现调整为 nativeTags,请升级 Volar 插件至最新版本
    "nativeTags": [
      "block",
      "component",
      "template",
      "slot"
    ]
  },
  "include": [ // TypeScript 编译器应该包含的文件列表,通常包括 TypeScript 文件、类型声明文件和 Vue 文件
    "src/**/*.ts",
    "src/**/*.d.ts",
    "src/**/*.tsx",
    "src/**/*.vue"
  ]
}

manifest.json

作用:移动应用的配置文件,基于 uni-app 跨平台框架开发,包含多个平台的基本应用信息、版本信息、权限信息等

在hbuilder中打开可通过键值填空的形式进行配置

{
  "name": "小兔鲜儿", // 应用名
  "appid": "", // 移动应用的AppID
  "description": "", // 说明
  "versionName": "1.0.0", // 版本名
  "versionCode": 1, // 版本号
  "transformPx": false, // 是否将像素转换为视口单位
  /* 5+App配置 */
  "app-plus": {
    "usingComponents": true, // 是否组件化
    "nvueStyleCompiler": "uni-app", // nvue样式编译器
    "compilerVersion": 3, // 编译器版本
    "splashscreen": { // 启动画面配置
      "alwaysShowBeforeRender": true,
      "waiting": true,
      "autoclose": true,
      "delay": 0
    },
    /* 模块配置 */
    "modules": {},
    /* 应用发布信息 */
    "distribute": {
      /* android打包配置 */
      "android": {
        "permissions": [ // 权限配置
          "<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
          "<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
          "<uses-permission android:name=\"android.permission.VIBRATE\"/>",
          "<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
          "<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
          "<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
          "<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
          "<uses-permission android:name=\"android.permission.CAMERA\"/>",
          "<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
          "<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
          "<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
          "<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
          "<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
          "<uses-feature android:name=\"android.hardware.camera\"/>",
          "<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
        ]
      },
      /* ios打包配置 */
      "ios": {},
      /* SDK配置 */
      "sdkConfigs": {}
    }
  },
  /* 快应用 */
  "quickapp": {},
  /* 微信小程序 */
  "mp-weixin": {
    "appid": "", // 微信公众平台的AppId
    "setting": {
      "urlCheck": false // 不进行路径检查配置
    },
    "usingComponents": true // 使用组件
  },
  /* 支付宝小程序 */
  "mp-alipay": {
    "usingComponents": true
  },
  /* 百度小程序 */
  "mp-baidu": {
    "usingComponents": true
  },
  /* 字节跳动小程序 */
  "mp-toutiao": {
    "usingComponents": true
  },
  "uniStatistics": {
    "enable": false // 不授权uni数据统计
  },
  "vueVersion": "3" // 启动vue3版本
}

与原生开发的区别

开发区别

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

  1. 属性绑定 src="{ { url }}" 升级成 :src="url"

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

  3. 支持 Vue 常用指令 v-forv-ifv-showv-model

其他区别补充

  1. 调用接口能力,建议前缀 wx 替换为 uni ,养成好习惯,支持多端开发
  2. <style> 页面样式不需要写 scoped,小程序是多页面应用,页面样式自动隔离
  3. 生命周期分三部分:应用生命周期(小程序),页面生命周期(小程序),组件生命周期(Vue)

案例练习

主要功能

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

效果预览

uniapp_case_2.png

参考代码

<template>
  <swiper class="banner" indicator-dots circular :autoplay="false">
    <swiper-item v-for="item in pictures" :key="item.id">
      <image @tap="onPreviewImage(item.url)" :src="item.url"></image>
    </swiper-item>
  </swiper>
</template>

<script>
export default {
  data() {
    return {
      // 轮播图数据
      pictures: [
        {
          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',
        },
      ],
    }
  },
  methods: {
    onPreviewImage(url) {
      // 大图预览
      uni.previewImage({
        urls: this.pictures.map((v) => v.url),
        current: url,
      })
    },
  },
}
</script>

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

开发工具 vscode、安装插件

开发工具上有vscode、hbuilder、idea等,通常选用vscode,而为什么选择 VS Code?

  • VS Code 对 TS 类型支持友好,前端开发者主流的编辑器
  • HbuilderX 对 TS 类型支持暂不完善,使用它会有很多ts方面的报错,期待官方完善 👀

image-20240408140430692.png

用 VS Code 开发配置

  • 👉 前置工作:安装 Vue3 插件,点击查看官方文档
    • 安装 Vue Language Features (Volar) :Vue3 语法提示插件
    • 安装 TypeScript Vue Plugin (Volar) :Vue3+TS 插件(已弃用,改成安装 Vue-Official,装完才有ts类型鼠标悬停提示)
    • 工作区禁用 Vue2 的 Vetur 插件(Vue3 插件和 Vue2 冲突)
    • 工作区禁用 @builtin typescript 插件(禁用后开启 Vue3 的 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
// 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": {
    // 原配置 `experimentalRuntimeMode` 现调整为 `nativeTags`
    "nativeTags": ["block", "component", "template", "slot"], // [!code ++]
    "experimentalRuntimeMode": "runtime-uni-app" // [!code --]
  },
  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
}
  • 👉 JSON 注释问题
    • 使用vscode时,manifest.json注释会报错,需设置文件关联,把 manifest.jsonpages.json 设置为 jsonc
// .vscode/settings.json
{
  // 在保存时格式化文件
  "editor.formatOnSave": true,
  // 文件格式化配置
  "[json]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  // 配置语言的文件关联
  "files.associations": {
    "pages.json": "jsonc", // pages.json 可以写注释
    "manifest.json": "jsonc" // manifest.json 可以写注释
  }
}

如果以上设置不生效,可以手动到文件 - 首选项 - 设置 - 输入“files.associations” 里进行这两个注释选项的设置

image-20240409093354293.png

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

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

二、项目架构

资料说明

📀 视频学习 www.bilibili.com/video/BV1Bp…

📗 接口文档 www.apifox.cn/apidoc/shar…

✏️ 在线笔记 megasu.gitee.io/uni-app-sho…

📦 项目源码 gitee.com/Megasu/unia…

项目架构图

image-20240408114656011.png

效果预览

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

拉取项目模板代码

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

首先打开放置项目的目录,然后cmd打开命令窗口,再输入以下命令:

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

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

运行项目

hbuilder运行

一键运行到微信开发者工具,无需额外操作

image-20240408172549485.png

vscode运行

  1. 通过package.json文件里的scripts,选择有关的命令,运行其脚本

或者手动在命令行窗口输入 npm run dev:mp-weixin跑起来

image-20240408173346999.png

  1. 然后手动打开微信开发者工具,将编译生成的dist\dev\mp-weixin导入运行(选择mp-weixin该路径即可)

    注意需要填写AppID,没有的话可暂时用测试号代替

    如果有绑定了小程序的账号也可用该账号的AppID(登录微信公众平台 - 设置 - 基本设置 - 账号信息 - 第一行)

    因为该项目用的老师教程后端接口,所以后端无需使用云服务

image-20240409095953173.png

引入 uni-ui 组件库

安装 uni-ui 组件库,如果pnpm不行就用npm,另外如果node版本过低,也可用nvm来切换node版本

pnpm i @dcloudio/uni-ui

装完的插件在package.json里均有体现,查找可得

配置自动导入组件

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

安装类型声明文件

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

配置类型声明文件

// tsconfig.json
{
  "compilerOptions": {
    // ...
    // 指定了 TypeScript 编译器应该包含哪些类型声明文件列表
    "types": [
      "@dcloudio/types", // uni-app API 类型
      "miniprogram-api-typings", // 原生微信小程序类型
      "@uni-helper/uni-app-types", // uni-app 组件类型
      "@uni-helper/uni-ui-types" // uni-ui 组件类型  // [!code ++]
    ]
  },
  // vue 编译器类型,校验标签类型
  "vueCompilerOptions": {
    "nativeTags": ["block", "component", "template", "slot"]
  }
}

小程序端 Pinia 持久化

说明:Pinia 用法同vue2的vuex一样都是为了持久化存储,与 Vue3 项目完全一致,uni-app 项目仅需解决持久化插件兼容性问题。

持久化存储插件

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

pnpm i pinia-plugin-persistedstate

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

基本用法

// src/stores/modules/member.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
    }

    // 记得 return
    return {
      profile,
      setProfile,
      clearProfile,
    }
  },
  // TODO: 持久化,这部分代码在后面有(TODO表示未完待续)
  {
    persist: true,
  },
)
// src/stores/index.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'
// main.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,
  }
}

多端兼容

image-20240408160256821.png

网页端持久化 API

// 网页端API
localStorage.setItem()
localStorage.getItem()

多端持久化 API

// 兼容多端API
uni.setStorageSync()
uni.getStorageSync()

参考代码

// stores/modules/member.ts

export const useMemberStore = defineStore(
  'member',
  () => {
    //…省略
  },
  {
    // 配置持久化
    persist: {
      // 调整为兼容多端的API
      storage: {
        setItem(key, value) {
          uni.setStorageSync(key, value) // [!code warning]
        },
        getItem(key) {
          return uni.getStorageSync(key) // [!code warning]
        },
      },
    },
  },
)

uni.request 请求封装

请求和上传文件拦截器

image-20240408160600253.png

uniapp 拦截器uni.addInterceptor

接口说明接口文档

实现步骤

  1. 拼接基础地址
  2. 设置超时时间
  3. 添加请求头标识
  4. 添加 token

参考代码

// src/utils/http.ts

import { useMemberStore } from '@/stores'

// 请求基地址
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)

测试请求:点击http请求测试按钮后,在Network里即可看到该网络请求

<script setup lang="ts">
	import '@/utils/http'
    const getData = () => {
        uni.request({ // 这样写每次请求都得写上uni.request,不太好,后面会把这部分封装到 Promise 请求函数里
            method: 'GET',
            url: '/home/banner',
        })
    }
</script>
<template>
	<view>
    	<button @tap="getData" size="mini">http请求测试</button>
    </view>
</template>

image-20240410150534513.png

注意:

问:为什么用手机预览没有数据?

答:微信小程序端,需登录 微信公众平台 配置以下地址为合法域名 👇

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

封装 Promise 请求函数

image-20240410154123122.png

实现步骤

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

参考代码

// src/utils/http.ts (继续写在拦截器下面即可)

/**
 * 请求函数
 * @param  UniApp.RequestOptions
 * @returns Promise
 *  1. 返回 Promise 对象,用于处理返回值类型
 *  2. 获取数据成功
 *    2.1 提取核心数据 res.data
 *    2.2 添加类型,支持泛型
 *  3. 获取数据失败
 *    3.1 401错误  -> 清理用户信息,跳转到登录页
 *    3.2 其他错误 -> 根据后端错误信息轻提示
 *    3.3 网络错误 -> 提示用户换网络
 */
// 使用 TypeScript 中的 interface 关键字来定义接口
interface Data<T> {
  code: string
  msg: string
  result: T
}
// 使用了 TypeScript 中的类型别名(Type Aliases)来定义接口,功能同上面的一样,只是语法不同
// type Data<T> = {
//   code: string
//   msg: string
//   result: T
}
// 2.2 添加类型,支持泛型
export const http = <T>(options: UniApp.RequestOptions) => {
  // 1. 返回 Promise 对象
  return new Promise<Data<T>>((resolve, reject) => {
    uni.request({ // 把uni.request封装到 promise 里,这样每次调用接口,直接调用http即可
      ...options,
      // 响应成功
      success(res) {
        // 状态码 2xx,参考 axios 的设计
        if (res.statusCode >= 200 && res.statusCode < 300) {
          // 2.1 提取核心数据 res.data
          resolve(res.data as Data<T>)
        } else if (res.statusCode === 401) {
          // 401错误  -> 清理用户信息,跳转到登录页
          const memberStore = useMemberStore()
          memberStore.clearProfile()
          uni.navigateTo({ url: '/pages/login/login' })
          reject(res)
        } else {
          // 其他错误 -> 根据后端错误信息轻提示
          uni.showToast({
            icon: 'none',
            title: (res.data as Data<T>).msg || '请求错误',
          })
          reject(res)
        }
      },
      // 响应失败
      fail(err) {
        uni.showToast({
          icon: 'none',
          title: '网络错误,换个网络试试',
        })
        reject(err)
      },
    })
  })
}

测试请求:

// my.vue
import { http } from '@/utils/http'
const getData = async () => {
    const res = await http<BannerItem[]>({
        methods: 'GET',
        url: '/home/banner',
        header: {},
    })
    console.log(res.result) // 除了这里可以看到打印结果,也可以到网络请求里查看回传的请求接口数据
}

代码规范【拓展】

为什么需要代码规范

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

index_picture_2.png

统一代码风格

  • 安装 eslint + prettier
pnpm i -D eslint prettier eslint-plugin-vue @vue/eslint-config-prettier @vue/eslint-config-typescript @rushstack/eslint-patch @vue/tsconfig
  • 新建 .eslintrc.cjs 文件,添加以下 eslint 配置
/* 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
{
  "script": {
    // ... 省略 ...
    "lint": "eslint . --ext .vue,.js,.ts --fix --ignore-path .gitignore"
  }
}
  • 运行
pnpm lint

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

Git 工作流规范

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

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

三、首页模块

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

自定义导航栏

参考效果:自定义导航栏的样式需要适配不同的机型。

home_picture_1.png

实现步骤

  1. 修改首页配置:隐藏默认导航栏,修改文字颜色

  2. 准备好导航栏的静态结构组件

  3. 对导航栏组件安全区进行样式适配:因为不同手机的安全区域不同,适配安全区域能防止页面重要内容被刘海屏的摄像头所遮挡。

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

home_picture_2.png

  1. 将准备好的组件应用到首页即可

参考代码

修改首页配置:隐藏默认导航栏,修改文字颜色

// src/pages.json
{
  "path": "pages/index/index",
  "style": {
    "navigationStyle": "custom", // 隐藏默认导航
    "navigationBarTextStyle": "white", // 修改文字颜色
    "navigationBarTitleText": "首页"
  }
}

准备好导航栏的静态结构组件

<!-- 新建 src/componets/CustomNavbar.vue -->
<script setup lang="ts">
//...
</script>

<template>
  <view class="navbar">
    <!-- logo文字 -->
    <view class="logo">
      <image class="logo-image" src="@/static/images/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>

组件安全距离适配

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

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

应用到首页组件

<!-- src/pages/index -->
<script setup lang="ts">
import CustomNavbar from '@/components/CustomNavbar.vue'
</script>

<template>
  <CustomNavbar />
  <view class="index">index</view>
</template>

<style lang="scss">
//
</style>

通用轮播组件

参考效果

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

轮播图组件需要在这两处地方使用,因此需要封装成通用组件 XtxSwiper

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

home_picture_3.png

实现步骤

  1. 准备组件:写好轮播图通用组件的静态结构

  2. 自动导入:使用uni-app提供的自动化配置后,将通用的全局组件导入使用时,则无需再额外写import

  3. 类型声明:给导入的全局组件提供鼠标悬停的数据类型提示说明

image-20240411175912099.png

image-20240412094313862.png

  1. 获取数据:定义轮播图数据类型(类型声明),封装请求接口API

image-20240412093816794.png

  1. 渲染数据:在首页父组件页面初始化调用轮播图API,将数据传给子组件轮播图,并渲染出轮播图数据

image-20240412114547448.png

参考代码

准备组件

<!-- 新建 src/components/XtxSwiper.vue -->
<script setup lang="ts">
import { ref } from 'vue'

const activeIndex = ref(0)
</script>

<template>
  <view class="carousel">
    <swiper :circular="true" :autoplay="false" :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">
/* 轮播图 */
.carousel {
  height: 280rpx;
  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>

自动导入

// pages.json
{
  // 组件自动引入规则
  "easycom": {
    ...
    "custom": {
      ...
      // 以 Xtx 开头的组件,在 components 目录中查找,实现自动导入(注意配置完需要重启前端npm)
      "^Xtx(.*)": "@/components/Xtx$1.vue"
    }
  }
}

全局组件类型声明

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

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

获取数据

先进行类型声明,存放路径:src/types/home.d.ts

/** 首页-广告区域数据类型 */
export type BannerItem = {
    /** 跳转链接 */
    hrefUrl: string
    /** id */
    id: string
    /** 图片链接 */
    imgUrl: string
    /** 跳转类型 */
    type: number
}

然后调用接口:该业务功能对于前端来说比较简单,只需调用后端提供的接口将获得的数据展现,并跳转到对应链接地址即可。

接口地址:/home/banner

请求方式:GET

请求参数:

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

请求封装

// 存放路径: src/services/home.ts
import type { BannerItem } from '@/types/home'
import { http } from '@/utils/http'

/**
 * 首页-广告区域-小程序
 * @param distributionSite 广告区域展示位置(投放位置 投放位置,1为首页,2为分类商品页) 默认是1
 */
export const getHomeBannerAPI = (distributionSite = 1) => {
    return http<BannerItem[]>({
        method: 'GET',
        url: '/home/banner',
        data: {
            distributionSite,
        },
    })
}

渲染数据

因为每一处使用到轮播图组件的数据可能都不一样,轮播图作为通用组件,不应该存放请求到的数据,所以:

最好在首页父组件页面初始化调用轮播图API,然后再将数据通过bannerList传给子组件轮播图

<!-- src/pages/index/index.vue -->
<script setup lang="ts">
    import { getHomeBannerAPI } from '@/services/home'
    import { onLoad } from '@dcloudio/uni-app'
    import type { BannerItem } from '@/types/home' // 注意因为是导入的类型声明,所以前面要加上type
    import { ref } from 'vue'

    const bannerList = ref<BannerItem[]>([])
    const getHomeBannerData = async () => {
      const res = await getHomeBannerAPI()
      bannerList.value = res.result
    }
    onLoad(() => {
      getHomeBannerData()
    })
</script>
<template>
...
<XtxSwiper :list="bannerList" />
...
</template>

子组件轮播图通过props接收并渲染出list数据,注意要将前面的XtxSwiper静态组件改成下面这种动态接收

<!-- src\components\XtxSwiper.vue -->
<script setup lang="ts">
    import type { BannerItem } from '@/types/home'
    import { ref } from 'vue'

    const activeIndex = ref(0)

    // 当 swiper 下标发生变化时触发
    // UniHelper: 类型声明文件,为Uni-app提供事件类型
    const onChange: UniHelper.SwiperOnChange = (ev) => {
        // ! 非空断言,主观上排除掉空值情况
        activeIndex.value = ev.detail!.current
    }
    // 定义 props 接收
    defineProps<{
        list: BannerItem[]
    }>()
</script>

<template>
	<view class="carousel">
    	<swiper :circular="true" :autoplay="false" :interval="3000" @change="onChange">
        	<swiper-item v-for="item in list" :key="item.id">
            	<navigator url="/pages/index/index" hover-class="none" class="navigator">
                	<image mode="aspectFill" class="image" :src="item.imgUrl"></image>
    			</navigator>
    		</swiper-item>
    	</swiper>
    	<!-- 指示点 -->
    	<view class="indicator">
        	<text
                  v-for="(item, index) in list"
                  :key="item.id"
                  class="dot"
                  :class="{ active: index === activeIndex }"
                  >
    		</text>
    	</view>
    </view>
</template>

小知识点:

  1. UniHelper 提供事件类型
  2. (可选链) 允许前面表达式为空值
  3. (非空断言) 主观上排除掉空值情况

总结

image-20240412115844695.png

首页分类

参考效果

home_picture_4.png

实现步骤

  1. 准备组件,只有首页使用
  2. 获取数据:定义分类数据类型(类型声明),封装请求接口API
  3. 渲染数据:在首页父组件页面初始化调用API,将数据传给子组件,并渲染出数据

image-20240413150547390.png

参考代码

静态结构

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

<!-- src/pages/index/CategoryPanel.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>

获取数据

类型声明

// src/services/home.d.ts
/** 首页-前台类目数据类型 */
export type CategoryItem = {
    /** 图标路径 */
    icon: string
    /** id */
    id: string
    /** 分类名称 */
    name: string
}

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

接口地址:/home/category/mutli

请求方式:GET

请求参数:无

请求封装

// services/home.ts
import type { CategoryItem } from '@/types/home'
/**
 * 首页-前台分类-小程序
 */
export const getHomeCategoryAPI = () => {
    return http<CategoryItem[]>({
        method: 'GET',
        url: '/home/category/mutli',
    })
}

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

渲染数据

父组件调用数据,并将其传给子组件CategoryPanel

<!-- src/pages/index/index.vue -->
<script setup lang="ts">
    import { getHomeCategoryAPI } from '@/services/home'
    import { onLoad } from '@dcloudio/uni-app'
    import type { CategoryItem } from '@/types/home'
    import { ref } from 'vue'
    import CategoryPanel from './CategoryPanel.vue'
        ...
        const categoryList = ref<CategoryItem[]>([])
    const getHomeCategoryData = async () => {
        const res = await getHomeCategoryAPI()
        categoryList.value = res.result
    }
    onLoad(() => {
        getHomeCategoryData()
    })
</script>

<template>
    ...
    <CategoryPanel :list="categoryList" />
</template>

子组件接收并渲染数据

<!-- src/pages/index/CategoryPanel.vue -->
<script setup lang="ts">
    import type { CategoryItem } from '@/types/home'

    // 定义 props 接收数据
    defineProps<{
        list: CategoryItem[]
    }>()
</script>

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

热门推荐

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

参考效果

home_picture_5.png

实现步骤

  1. 准备组件,只有首页使用
  2. 获取数据:定义分类数据类型(类型声明),封装请求接口API
  3. 渲染数据:在首页父组件页面初始化调用API,将数据传给子组件,并渲染出数据

image-20240413145832653.png

参考代码

静态结构

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

<!-- src/pages/index/HotPanel.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>

获取数据

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

接口地址:/home/hot/mutli

请求方式:GET

请求参数:

Headers:

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

成功响应结果:

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

类型声明

// src/services/home.d.ts
/** 首页-热门推荐数据类型 */
export type HotItem = {
  /** 说明 */
  alt: string
  /** id */
  id: string
  /** 图片集合[ 图片路径 ] */
  pictures: string[]
  /** 跳转地址 */
  target: string
  /** 标题 */
  title: string
  /** 推荐类型 */
  type: string
}

接口封装

// services/home.ts
/**
 * 首页-热门推荐-小程序
 */
export const getHomeHotAPI = () => {
  return http<HotItem[]>({
    method: 'GET',
    url: '/home/hot/mutli',
  })
}

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

渲染数据

父组件调用数据,并将其传给子组件

<!-- src/pages/index/index.vue -->
<script setup lang="ts">
    import { getHomeHotAPI } from '@/services/home'
    import { onLoad } from '@dcloudio/uni-app'
    import type { HotItem } from '@/types/home'
    import { ref } from 'vue'
    import HotPanel from './HotPanel.vue'

    const hotList = ref<HotItem[]>([])
    const getHomeHotData = async () => {
        const res = await getHomeHotAPI()
        hotList.value = res.result
    }
    onLoad(() => {
        getHomeHotData()
    })
</script>

<template>
    <CustomNavbar />
    <XtxSwiper :list="bannerList" />
    <CategoryPanel :list="categoryList" />
    <HotPanel :list="hotList" />
    <view class="index">index</view>
</template>

子组件接收并渲染数据

<!-- src/pages/index/HotPanel.vue -->
<script setup lang="ts">
    import type { HotItem } from '@/types/home'

    // 定义 props 接收数据
    defineProps<{
        list: HotItem[]
    }>()
</script>

<template>
	<!-- 推荐专区 -->
	<view class="panel hot">
    	<view class="item" v-for="item in list" :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" class="cards">
            	<image
                       v-for="src in item.pictures"
                       :key="src"
                       class="image"
                       mode="aspectFit"
                       :src="src"
                       >
    			</image>
    		</navigator>
    	</view>
    </view>
</template>

猜你喜欢(*)

参考效果

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

home_picture_6.png

实现步骤

  • 准备组件 (通用组件,多页面使用,因此可封装到components里)

  • 配置自动导入(轮播图组件里已经定义,这里只强调下)、定义全局组件类型

  • 获取数据:定义分类数据类型(类型声明),封装请求接口API

image-20240412171638396.png

  • 准备scroll-view滚动容器,设置page和scroll-view样式,完成除了顶部+底部导航栏之外可视窗口的滚动

image-20240412171210345.png

  • 渲染数据:因为数据跟组件一样都是通用的,所以数据的调用也一并放到子组件《猜你喜欢》里即可

image-20240412175057813.png

  • 滚动加载分页数据:滚动到第一页数据触底时,父组件调用子组件暴露的数据请求,实现下一页数据的加载

image-20240413144342050.png

image-20240413150754991.png

参考代码

静态结构:猜你喜欢是一个通用组件 XtxGuess,多个页面会用到该组件,存放到 src/components 目录中。

<!-- src/components/XtxGuess.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>

全局组件类型声明

// src/types/components.d.ts
import XtxGuess from '@/components/XtxGuess.vue'

declare module 'vue' {
    export interface GlobalComponents {
        XtxGuess: typeof XtxGuess
    }
}

// 组件实例类型
export type XtxGuessInstance = InstanceType<typeof XtxGuess>

获取数据

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

接口地址:/home/goods/guessLike

请求方式:GET

请求参数:

Query:

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

类型声明:新建通用的分页结果类型、分页参数类型

// src/types/global.d.ts
/** 通用分页结果类型 */
export type PageResult<T> = {
    /** 列表数据 */
    items: T[]
    /** 总条数 */
    counts: number
    /** 当前页数 */
    page: number
    /** 总页数 */
    pages: number
    /** 每页条数 */
    pageSize: number
}
/** 通用分页参数类型,加?表示可选参数 */
export type PageParams = {
    /** 页码:默认值为 1 */
    page?: number
    /** 页大小:默认值为 10 */
    pageSize?: number
}

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

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

接口封装

// src/services/home.ts
import type { PageParams, PageResult } from '@/types/global'
import type { GuessItem } from '@/types/home'
/**
 * 猜你喜欢-小程序
 */
export const getHomeGoodsGuessLikeAPI = (data?: PageParams) => {
    return http<PageResult<GuessItem>>({
        method: 'GET',
        url: '/home/goods/guessLike',
        data,
    })
}

准备scroll-view滚动容器,设置page和scroll-view样式

<!-- pages/index/index.vue -->
<script setup lang="ts">
    import type { XtxGuessInstance } from '@/types/components'
    import { ref } from 'vue'

    // 获取猜你喜欢组件实例
    const guessRef = ref<XtxGuessInstance>()

    // 滚动触底事件
    const onScrolltolower = () => {
        guessRef.value?.getMore()
    }
</script>

<template>
	<CustomNavbar />
	<!-- 滚动容器 -->
	<scroll-view scroll-y @scrolltolower="onScrolltolower">
    	...
    	<!-- 猜你喜欢,注意这里要绑定ref -->
    	<XtxGuess ref="guessRef" />
    </scroll-view>
</template>

<style lang="scss">
    /* 设置样式 */
    page {
        background-color: #f7f7f7;
        height: 100%;
        display: flex;
        flex-direction: column;
    }
    .scroll-view {
        flex: 1;
    }
</style>

渲染数据、滚动加载分页数据

<!-- src/components/XtxGuess.vue -->
<script setup lang="ts">
    import { getHomeGoodsGuessLikeAPI } from '@/services/home'
    import type { PageParams } from '@/types/global'
    import type { GuessItem } from '@/types/home'
    import { onMounted, ref } from 'vue'

    // 分页参数(Required:防止在下面页码累加时传undefined报错,把number/undefined可选转为number必选)
    const pageParams: Required<PageParams> = {
        page: 1,
        pageSize: 10,
    }
    // 猜你喜欢的列表
    const guessList = ref<GuessItem[]>([])
    // 已结束标记
    const finish = ref(false)
    // 获取猜你喜欢数据
    const getHomeGoodsGuessLikeData = async () => {
        // 退出分页判断
        if (finish.value === true) {
            return uni.showToast({ icon: 'none', title: '没有更多数据~' })
        }
        const res = await getHomeGoodsGuessLikeAPI(pageParams)
        // 数组追加
        guessList.value.push(...res.result.items)
        // 分页条件
        if (pageParams.page < res.result.pages) {
            // 页码累加
            pageParams.page++
        } else {
            finish.value = true
        }
    }
    // 重置数据(这个用于后面下拉刷新数据用的)
    const resetData = () => {
        pageParams.page = 1
        guessList.value = []
        finish.value = false
    }
    // 组件挂载完毕
    onMounted(() => {
        getHomeGoodsGuessLikeData()
    })
    // 暴露方法
    defineExpose({
        resetData,
        getMore: getHomeGoodsGuessLikeData,
    })
</script>

<template>
    <!-- 猜你喜欢 -->
    <view class="caption">
    	<text class="text">猜你喜欢</text>
    </view>
	<view class="guess">
    	<navigator
                   class="guess-item"
                   v-for="item in guessList"
                   :key="item.id"
                   :url="`/pages/goods/goods?id=${item.id}`"
        >
            <image class="image" mode="aspectFill" :src="item.picture"></image>
            <view class="name"> {{ item.name }} </view>
            <view class="price">
                <text class="small">¥</text>
                <text>{{ item.price }}</text>
            </view>
    	</navigator>
    </view>
	<view class="loading-text">
    	{{ finish ? '没有更多数据~' : '正在加载...' }}
    </view>
</template>

<style lang="scss">
    ... // 前面的静态组件里已经写过一遍
</style>

下拉刷新

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

这里主要分为:猜你喜欢组件的数据重置、其他组件的数据加载这2部分

image-20240413115905404.png

实现步骤

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

  • 配置 refresher-enabled 属性,开启下拉刷新交互
  • 监听 @refresherrefresh 事件,判断用户是否执行了下拉操作
  • 配置 refresher-triggered 属性,关闭下拉状态

参考代码

猜你喜欢组件定义重置数据的方法

// src/components/XtxGuess.vue
// 重置数据
const resetData = () => {
  pageParams.page = 1 // 重置页码
  guessList.value = [] // 重置列表
  finish.value = false // 重置结束标记
}
// 暴露方法
defineExpose({
  resetData,
})

首页触发下拉刷新

<!-- src/pages/index/index.vue -->
<script setup lang="ts">
// 下拉刷新状态
const isTriggered = ref(false)
// 自定义下拉刷新被触发
const onRefresherrefresh = async () => {
  isTriggered.value = true // 开启动画
  guessRef.value?.resetData() // 重置猜你喜欢组件数据
  // 加载数据
  await Promise.all([
      getHomeBannerData(),
      getHomeCategoryData(),
      getHomeHotData(),
      guessRef.value?.getMore(),
  ])
  isTriggered.value = false // 关闭动画
}
</script>

<!-- 滚动容器 -->
<scroll-view
  refresher-enabled
  @refresherrefresh="onRefresherrefresh"
  :refresher-triggered="isTriggered"
  class="scroll-view"
  scroll-y
>
  …省略
</scroll-view>

骨架屏

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

参考效果

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

home_picture_7.png

生成骨架屏

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

实现步骤

  • 快捷生成:点击分离出来的页面信息,或者点击下面页面路径右边三个点;即可看到生成骨架屏,点击后生成在dist目录里。

image-20240413114151649.png

  • 封装组件:把自动生成的 xxx.skeleton.vuexxx.skeleton.wxss 封装成一个 vue 组件,放到首页目录里。
  • 使用组件:把封装好的骨架屏组件导入首页,然后在首页滚动容器的置顶位使用该组件,使用v-if/v-else来展示骨架屏跟正常屏

image-20240413161457981.png

  • 保留以下关键代码:去掉不需要生成骨架屏的顶部查询跟,然后给报错的代码加上v-bind动态传参解决报错

image-20240318160128387.png

<!-- 如果轮播图没显示出来,则加上高度即可 -->
<view class="carousel XtxSwiper--carousel" style="height: 280rpx">
<!-- v-bind动态传参 -->
<swiper :circular="true" :interval="3000" :current="0" :autoplay="false">

参考代码

封装骨架屏

注意:如果生成的骨架屏文件里没有包含《猜你喜欢》组件的代码,可将预览手机型号调整为iPhoneX等大一点的型号再点击生成

<!-- src/pages/index/PageSkeleton.vue -->
<template name="skeleton">
    <view is="components/XtxSwiper">
      <view class="carousel XtxSwiper--carousel" style="height: 280rpx">
        <swiper :circular="true" :interval="3000" :current="0" :autoplay="false">
          <swiper-item style="position: absolute; width: 100%; height: 100%; transform: translate(0%, 0px) translateZ(0px);">
            <navigator class="navigator XtxSwiper--navigator" hover-class="none">
              <image class="image XtxSwiper--image sk-image" mode="aspectFill"></image>
            </navigator>
          </swiper-item>
        </swiper>
        <view class="indicator XtxSwiper--indicator">
          <text class="dot XtxSwiper--dot active XtxSwiper--active"></text>
          <text class="dot XtxSwiper--dot"></text>
          <text class="dot XtxSwiper--dot"></text>
          <text class="dot XtxSwiper--dot"></text>
          <text class="dot XtxSwiper--dot"></text>
        </view>
      </view>
    </view>
    <view is="pages/index/CategoryPanel">
      ...
    </view>
    <view is="pages/index/HotPanel">
      ...
    </view>
    <view is="components/XtxGuess" class="r r">
      ...
    </view>
</template>
<style>
.sk-transparent {
  color: transparent !important;
}
...
</style>

使用骨架屏

<!-- src/pages/index/index.vue -->
<script setup lang="ts">
import PageSkeletion from '@/components/PageSkeleton.vue'
// 加载中标记
const isLoading = ref(false)
onLoad(async()=>{
    isLoading.value = true
    await Promise.all([
        getHomeBannerData(),
        getHomecategoryData(),
        getHomeHotData()
    ])
    isLoading.value = false
})
</script>

<!-- 滚动容器 -->
<scroll-view
  refresher-enabled
  @refresherrefresh="onRefresherrefresh"
  :refresher-triggered="isTriggered"
  class="scroll-view"
  scroll-y>
	<PageSkeletion v-if="isLoading" />
    <template v-else>
    	<!-- 自定义轮播图 -->
		<XtxSwiper :list="bannerList" />
		<!-- 分类面板 -->
		<CategoryPanel :list="categoryList" />
		<!-- 热门推荐 -->
		<HotPanel :list="hotList" />
		<!-- 猜你喜欢 -->
		<XtxGuess ref="guessRef" />
    </template>
</scroll-view>

测试

将网速从Online调成3G网,然后重新编译(刷新)就可以看见骨架屏的生成了

image-20240413161111497.png

四、推荐模块

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

参考效果

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

hot_picture_1.png

实现步骤

  • 新建热门推荐页面组件,并在 pages.json 中手动添加路由(如果是微信小程序创建则会自动完成路由添加)
  • 动态标题:热门推荐页要根据页面参数区分需要获取的是哪种类型的推荐列表,然后再去调用相应的接口,来获取不同的数据,再渲染到页面当中;利用页面传参,来实现动态标题的设置。

image-20240415104743145.png

  • 获取数据:定义数据类型,封装请求接口API(注意 subType 跟 subTypeItem 这俩参数)

    subType:请求参数,用来区分从首页打开的热门推荐里每个模块的类型

    subTypeItem:返回结果,用来区分热门推荐里单个模块下的具体item模块

image-20240415150009922.png

image-20240415140341051.png

  • 渲染数据:因为数据跟组件一样都是通用的,所以数据的调用也一并放到子组件《猜你喜欢》里即可

image-20240415142820964.png

  • 分页加载(滚动触底事件时触发)
    • 根据高亮下标,获取对应列表数据
    • 然后判断分页条件(根据页码和总数的大小判断,对当前页面进行累加或者退出标记、轻提示、底部提示)
    • 提取列表的分页参数,用于发送请求,并且将返回的添加到新数组中

image-20240416103646264.png

image-20240416103725360.png

  • 骨架屏:前面已经详细介绍过过程,这里就只放代码参考

参考代码

静态结构

新建热门推荐页面文件,并在 pages.json 中添加路由(VS Code 插件自动完成)。

<!-- src/pages/hot/hot.vue -->
<script setup lang="ts">
// 热门推荐页 标题和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>

pages.json 中手动添加路由

"pages": [
    {
      "path": "pages/hot/hot",
      "style": {
        "navigationBarTitleText": "热门推荐"
      }
    },
]

动态标题

热门推荐页要根据页面参数区分需要获取的是哪种类型的推荐列表,调对应接口来获取不同的数据,再渲染到页面当中,从而实现同个推荐页,根据不同的参数,展示不同的页面标题。

项目首页(传递参数)

<!-- src/pages/index/components/HotPanel.vue -->
<navigator :url="`/pages/hot/hot?type=${item.type}`">
  …省略  
</navigator>

热门推荐页(获取参数)

<!-- src/pages/hot/hot.vue -->
<script setup lang="ts">
    // 热门推荐页 标题和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' },
    ]
    // uniapp 获取页面参数
    const query = defineProps<{
        type: string
    }>()
    // 传递不同的页面参数,动态设置推荐页标题
    const currHot = hotMap.find((v) => v.type === query.type)
    uni.setNavigationBarTitle({ title: currHot!.title })
</script>

获取数据

地址参数

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

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

接口调用

调用接口获取推荐商品列表的数据,然后再将这些数据渲染出来。

接口地址:见上表

请求方式:GET

请求参数:

Query:

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

类型声明

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

// src/types/global.d.ts
/** 通用商品类型 */
export type GoodsItem = {
    /** 商品描述 */
    desc: string
    /** 商品折扣 */
    discount: number
    /** id */
    id: string
    /** 商品名称 */
    name: string
    /** 商品已下单数量 */
    orderNum: number
    /** 商品图片 */
    picture: string
    /** 商品价格 */
    price: number
}

其实猜你喜欢的商品类型也相同,可复用通用商品类型,封装到 src/services/home.d.ts 文件中:

// src/services/home.d.ts
import type { GoodsItem } from '@/types/global'

// GuessItem 和 GoodsItem 类型相同
export type GuessItem = GoodsItem

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

// src/types/hot.d.ts
import type { PageResult, GoodsItem } from './global'

/** 热门推荐-子类选项 */
export type SubTypeItem = {
    /** 子类id */
    id: string
    /** 子类标题 */
    title: string
    /** 子类对应的商品集合 */
    goodsItems: PageResult<GoodsItem>
}

/** 热门推荐 */
export type HotResult = {
    /** id信息 */
    id: string
    /** 活动图片 */
    bannerPicture: string
    /** 活动标题 */
    title: string
    /** 子类选项 */
    subTypes: SubTypeItem[]
}

请求封装

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

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

// &:交叉类型,作用是类型扩展,加入额外的参数
type HotParams = PageParams & {
    /** Tab 项的 id,默认查询全部 Tab 项的第 1 页数据 */
    subType?: string
}
/**
 * 通用热门推荐类型
 * @param url 请求地址
 * @param data 请求参数
 */
export const getHotRecommendAPI = (url: string, data?: HotParams) => {
    return http<HotResult>({
        method: 'GET',
        url,
        data,
    })
}

渲染数据

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

hot_picture_2.png

<!-- src/pages/hot/hot.vue -->
<script setup lang="ts">
    import { getHotRecommendAPI } from '@/services/hot'
    import { ref } from 'vue'
    
    // 热门推荐页 标题和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' },
    ]
    // uniapp 获取页面参数
    const query = defineProps<{
        type: string
    }>()
    const currHot = hotMap.find((v) => v.type === query.type)
    // 动态设置标题
    uni.setNavigationBarTitle({ title: currHot!.title })

    // 推荐封面图
    const bannerPicture = ref('')
    // 推荐选项
    const subTypes = ref<SubTypeItem[]>([])
    // 高亮下标
    const activeIndex = ref(0)
    // 获取热门推荐数据
    const getHotRecommendData = async () => {
        const res = await getHotRecommendAPI(currHot!.url)
        // 保存封面图
        bannerPicture.value = res.result.bannerPicture
        // 保存推荐选项
        subTypes.value = res.result.subTypes
    }
</script>

<template>
<view class="viewport">
    <!-- 推荐封面图 -->
    <view class="cover">
        <image :src="bannerPicture"></image>
    </view>
    <!-- 推荐选项 -->
    <view class="tabs">
        <text
              v-for="(item,index) in subTypes"
              :key="item.id"
              class="text"
              :class="{ active: index === activeIndex }"
              @tap="activeIndex = index">
            {{ item.title }}
    </text>
    </view>
    <!-- 推荐列表 -->
    <scroll-view 
                 v-for="(item,index) in subTypes"
                 :key="item.id"
                 v-show="activeIndex === index"
                 scroll-y
                 class="scroll-view">
        <view class="goods">
            <navigator
                       hover-class="none"
                       class="navigator"
                       v-for="goods in item.goodsItems.items"
                       :key="goods.id"
                       :url="`/pages/goods/goods?id=${goods.id}`"
                       >
                <image class="thumb" src="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">正在加载...</view>
    </scroll-view>
</view>
</template>

分页加载

根据当前用户选中的 Tabs 加载对应的列表数据,并进行分页加载

<!-- src/pages/hot/hot.vue -->
<script setup lang="ts">
import { getHotRecommendAPI } from '@/services/hot'
import type { SubTypeItem } from '@/types/hot'
import { onLoad } from '@dcloudio/uni-app'
import { ref } from 'vue'

// 热门推荐页 标题和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' },
]

// uniapp 获取页面参数
const query = defineProps<{
  type: string
}>()
// 获取当前推荐信息
const currHot = hotMap.find((v) => v.type === query.type)
// 动态设置标题
uni.setNavigationBarTitle({ title: currHot!.title })

// 推荐封面图
const bannerPicture = ref('')
// 推荐选项
const subTypes = ref<(SubTypeItem & { finish?: boolean })[]>([])
// 高亮的下标
const activeIndex = ref(0)
// 获取热门推荐数据
const getHotRecommendData = async () => {
  const res = await getHotRecommendAPI(currHot!.url, {
        // 技巧:环境变量,开发环境,修改初始页面方便测试分页结束
        page: import.meta.env.DEV ? 30 : 1,
        pageSize: 10,
  })
  // 保存封面
  bannerPicture.value = res.result.bannerPicture
  // 保存列表
  subTypes.value = res.result.subTypes
}

// 页面加载
onLoad(() => {
  getHotRecommendData()
})

// 滚动触底
const onScrolltolower = async () => {
  // 获取当前选项
  const currsubTypes = subTypes.value[activeIndex.value]
  // 分页条件
  if (currsubTypes.goodsItems.page < currsubTypes.goodsItems.pages) {
    // 当前页码累加
    currsubTypes.goodsItems.page++
  } else {
    // 标记已结束
    currsubTypes.finish = true
    // 退出并轻提示
    return uni.showToast({ icon: 'none', title: '没有更多数据了~' })
  }

  // 调用API传参
  const res = await getHotRecommendAPI(currHot!.url, {
    subType: currsubTypes.id,
    page: currsubTypes.goodsItems.page,
    pageSize: currsubTypes.goodsItems.pageSize,
  })
  // 新的列表选项
  const newSubTypes = res.result.subTypes[activeIndex.value]
  // 数组追加
  currsubTypes.goodsItems.items.push(...newSubTypes.goodsItems.items)
}
</script>

<template>
  <view class="viewport">
    <!-- 推荐封面图 -->
    <view class="cover">
      <image :src="bannerPicture"></image>
    </view>
    <!-- 推荐选项 -->
    <view class="tabs">
      <text
        v-for="(item, index) in subTypes"
        :key="item.id"
        class="text"
        :class="{ active: index === activeIndex }"
        @tap="activeIndex = index"
        >{{ item.title }}</text
      >
    </view>
    <!-- 推荐列表 -->
    <scroll-view
      v-for="(item, index) in subTypes"
      :key="item.id"
      v-show="activeIndex === index"
      scroll-y
      class="scroll-view"
      @scrolltolower="onScrolltolower"
    >
      <view class="goods">
        <navigator
          hover-class="none"
          class="navigator"
          v-for="goods in item.goodsItems.items"
          :key="goods.id"
          :url="`/pages/goods/goods?id=${goods.id}`"
        >
          <image class="thumb" :src="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">
        {{ item.finish ? '没有更多数据了~' : '正在加载...' }}
      </view>
    </scroll-view>
  </view>
</template>

骨架屏

<script setup lang="ts">
// 数据是否加载完毕
const isFinish = ref(false)
// 页面加载
onLoad(async() => {
  // getHotRecommendData()
  await Promise.all([getHotRecommendData()])
  isFinish.value = true
})
</script>

<template>
	<view class="viewport" v-if="isFinish">
    </view>
	<!-- 商品骨架屏 -->
	<HotSkeleton v-else />
</template>

五、分类模块

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

参考效果

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

category_picture_1.png

实现步骤

  • 写好分类页静态结构
  • 渲染轮播图:定义轮播图数据类型、数据接口(这两步在首页里已经完成),然后获取并渲染轮播图数据即可

image-20240416112430769.png

  • 一级分类:定义数据类型 → 封装请求接口API → 初始化调用获取数据 → 渲染一级分类 → Tab交互

    因为该组件+数据都是通用的,所以无需将数据的获取放到父组件中,而是直接放到分类组件中即可

image-20240417092708673.png

  • 二级分类:基于高亮下标和computed属性提取二级分类数据,渲染出二级分类商品

image-20240417101525068.png

骨架屏:实现步骤可参考首页的骨架屏,基本一致;记得将封装好的骨架屏文件 CategorySkeleton 放到分类模块目录下

image-20240417113530365.png

参考代码

静态结构

<!-- src/pages/category/category.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>
        <!-- 焦点图 -->
        <XtxSwiper 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>

渲染轮播图

接口调用

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

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

接口地址:/home/banner

请求方式:GET

请求参数:

Query:

字段名称是否必须默认值备注
distributionSite1活动 banner 位置,1 代表首页,2 代表商品分类页,默认为 1
<!-- src/pages/category/category.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { getHomeBannerAPI } from '@/services/home'
import type { BannerItem } from '@/types/home'
import { onLoad } from '@dcloudio/uni-app'

// 获取轮播图数据
const bannerList = ref<BannerItem[]>([])
const getHomeBannerData = async () => {
  const res = await getHomeBannerAPI(2)
  bannerList.value = res.result
}

// 加载数据
onLoad(() => {
  getHomeBannerData()
})
</script>
<template>
...
	<!-- 焦点图 -->
    <XtxSwiper class="banner" :list="bannerList" />
	...
    </XtxSwiper>
...
</template>

一级分类

获取数据

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

接口调用

接口地址:/category/top

请求方式:GET

请求参数:无

类型声明

// src/types/category.d.ts
import type { GoodsItem } from './global'

/** 一级分类项 */
export type CategoryTopItem = {
  /** 二级分类集合[ 二级分类项 ] */
  children: CategoryChildItem[]
  /** 一级分类id */
  id: string
  /** 一级分类图片集[ 一级分类图片项 ] */
  imageBanners: string[]
  /** 一级分类名称 */
  name: string
  /** 一级分类图片 */
  picture: string
}

/** 二级分类项 */
export type CategoryChildItem = {
  /** 商品集合[ 商品项 ] */
  goods: GoodsItem[]
  /** 二级分类id */
  id: string
  /** 二级分类名称 */
  name: string
  /** 二级分类图片 */
  picture: string
}

请求封装

// src/services/category.ts
import { http } from '@/utils/http'
import type { CategoryTopItem } from '@/types/category'
/**
 * 分类列表-小程序
 */
export const getCategoryTopAPI = () => {
  return http<CategoryTopItem[]>({
    method: 'GET',
    url: '/category/top',
  })
}

一级分类:接下来,在分类组件里获取数据,再把一级分类数据结合模板语法渲染到页面中。

Tab交互:当用户点击一级分类时,需要高亮显示,即给它添加 .active 类名即可。

<!-- src/pages/category/category.vue -->
<script setup lang="ts">
import { getCategoryTopAPI } from '@/services/category'
import type { CategoryTopItem } from '@/types/category'
import { onLoad } from '@dcloudio/uni-app'
import { ref } from 'vue'

// 获取分类列表数据
const categoryList = ref<CategoryTopItem[]>([])
const getCategoryTopData = async () => {
  const res = await getCategoryTopAPI()
  categoryList.value = res.result
}

// 高亮下标
const activeIndex = ref(0)

// 页面加载
onLoad(() => {
  getCategoryTopData()
})
</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>

二级分类

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

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

<!-- src/pages/category/category.vue -->
<script setup lang="ts">
import { computed } from 'vue'

// ...省略

// 提取当前二级分类数据
const subCategoryList = computed(() => {
  return categoryList.value[activeIndex.value]?.children || []
})
</script>

<template>
  <view class="viewport">
      <!-- ... -->
      <!-- 右侧:二级分类 -->
      <scroll-view class="secondary" scroll-y>
        <!-- 焦点图 -->
        <XtxSwiper 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="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>

骨架屏

骨架屏文件的封装这里就省略不写,参考首页的即可。下面简单介绍下骨架屏组件的使用

<!-- src/pages/category/category.vue -->
<script setup lang="ts">
import { getCategoryTopAPI } from '@/services/category'
import { getHomeBannerAPI } from '@/services/home'
import { onLoad } from '@dcloudio/uni-app'
import PageSkeleton from './PageSkeleton.vue'
...
// 是否数据加载完毕
const isFinish = ref(false)
...
// 页面加载
onLoad(async () => {
  await Promise.all([getBannerData(), getCategoryTopData()])
  isFinish.value = true
})
</script>

<template>
  <view class="viewport" v-if="isFinish">
    ...
  </view>
  <!-- 骨架屏 -->
  <PageSkeleton v-else />
</template>

未完待续...