Skip to content

项目介绍

01. 项目概述

[慕尚花坊] 是一款 同城鲜花订购 的小程序,专业提供各地鲜花速递、鲜花预定、网上订花、包月鲜花等服务。最快3小时送花上门,保证花材新鲜和鲜花质量,可先送花后付款,专业花艺师傅精美包扎,品质保证,至诚服务。

02. 项目演示

[慕尚花坊] 项目涵盖电商项目常见功能模块,包含:

  1. 项目首页
  2. 商品分类
  3. 商品列表
  4. 商品详情
  5. 用户管理
  6. 收货地址
  7. 购物车
  8. 结算支付
  9. 订单管理
  10. 等……

03. 项目技术栈

[慕尚花坊] 项目使用原生小程序进行搭建开发,项目涵盖小程序开发所有常用的知识点

  1. 小程序内置组件:采用小程序内置组件 结合 Vant 组件库实现页面结构的搭建

  2. 项目中使用了 css 拓展语言 Scss 绘制页面的结构

  3. 小程序内置API:交互、支付、文件上传、地图定位、网络请求、预览图片、本地存储等

  4. 小程序分包加载:降低小程序的启动时间、包的体积,提升用户体验度

  5. 小程序组件开发:将页面内的功能模块抽象成自定义组件,实现代码的复用

  6. 网络请求封装:request 方法封装、快捷方式封装、响应拦截器、请求拦截器

  7. 骨架屏组件:利用开发者工具提供了自动生成骨架屏代码的能力,提高了整体使用体验和用户满意度。

  8. UI 组件库:使用 Vant 组件库实现小程序 结构的绘制

  9. LBS:使用腾讯地图服务进行 LBS逆地址解析,实现选择收货地址功能

  10. miniprogram-licia:使用 licia 进行函数的防抖节流

  11. async-validator:使用 async-validator 实现表单验证

  12. miniprogram-computed: 使用 miniprogram-computed 进行计算属性功能

  13. mobx-miniprogram:使用 mobx-miniprogram 进行项目状态的管理

04. 接口文档

慕尚花坊系统-API文档

申请开发权限

在开始开发一个小程序项目之前,需要先申请开发权限。

需要将自己的微信号发送给对应小程序账号的管理员,在小程序微信公众后台添加我们自己为开发者。

📌:注意事项:

  1. 需要将自己的 微信号 发送给对应小程序账号的管理员
  2. 手机号不是微信号 (除非将手机号设置为了微信号)

如何查看自己的微信号:

在将微信号发送给管理以后,管理员会登录微信公众后台,进行添加:

在管理员将自己设置为项目成员以后,开发者自己也可以登录当前小程序管理后台,获取 AppId

在获取到小程序 AppId 以后,就可以使用 AppId 新建小程序项目 或者 切换小程序项目的 AppId

项目初始化

01. 创建项目与项目初始化

创建项目

在微信开发者工具的开始界面左侧检查项目类型,需要为 [小程序]

然后在右侧点击 [+] 开始新建项目

最后在弹出的界面中输入项目相关的信息,点击确定即可

📌 注意

在新建项目的时候,[填写的 AppId 需要是自己的 AppId]

不能填写老师的 AppId,因为同学们不是当前小程序的开发成员

项目初始化

  1. 重置 app.js 中的代码
  2. 删除 app.jsonpages 下的 "pages/logs/logs" 路径,同时删除 pages/logs 文件夹
  3. 删除 app.jsonpages 下的 "rendererOptions" 以及 "componentFramework" 字段
  4. 重置 app.wxss 中的代码
  5. 删除 components 中的自定义组件
  6. 重置 pages/index 文件夹下的 index.jsindex.wxssindex.html 以及 index.json 文件
  7. 更新 utilsutil.js 的文件名为 formatTime.js

02. 自定义构建 npm + 集成Sass

随着项目的功能越来越多、项目越来越复杂,文件目录也变的很繁琐,为了方便进行项目的开发,开发人员通常会对目录结构进行调整优化,在慕尚花坊项目中,我们就需要将小程序源码放到 miniprogram 目录下

自定义构建

  1. 首先在project.config.json 配置 miniprogramRoot 选项,指定小程序源码的目录

  2. 然后配置 project.config.jsonsetting.packNpmManuallytrue,开启自定义 node_modules 和 miniprogram_npm 位置的构建 npm 方式

  3. 最后配置 project.config.json 的 setting.packNpmRelationList 项,指定 packageJsonPathminiprogramNpmDistDir 的位置

    • packageJsonPath 表示 node_modules 源对应的 package.json
    • miniprogramNpmDistDir 表示 node_modules 的构建结果目标位置
  4. 安装 vant ,然后进行 npm 构建,测试是否能够正常 vant 构建成功

    npm i @vant/weapp

📌 注意

​ 配置后如果没有生效,需要 [ 重启微信开发者工具 ] ❗ ❗

集成 Sass

project.config.json 文件中,修改 setting 下的 useCompilerPlugins 字段为 ["sass"],即可开启工具内置的 sass 编译插件。

03. 集成项目页面文件

思路分析:

打开 [慕尚花坊项目素材] 中的 [模板文件] 文件夹

复制该文件中全部的文件和文件夹,在项目的 miniprogram 目录下进行粘贴

代码分析:

  1. app.json 中配置了 pageswindowtabBar
  2. app.json 中对项目中会使用到的 Vant 组件进行了全部的注册
  3. app.wxss 文件中导入了阿里巴巴使用图标库
  4. components 文件夹中定义了两个公共的组件
  5. pages 目录下存放了项目中所有页面的文件,后续我们会进行分包的处理

04. VsCode 开发小程序项目

知识点:

在进行项目开发的时候,部分同学可能不习惯微信开发者工具进行开发,而是习惯使用 VSCode 等编辑器

但是 VsCode 对小程序开发支持的不是非常友好,如果想通过 VSCode 开发小程序项目,需要安装以下插件:

  1. WXML - Language Service
  2. prettier
  3. 微信小程序开发工具
  4. 微信小程序助手-Y
  5. 小程序开发助手(可选)
  6. 其他......

💡 Tip:

使用 VsCode 开发小程序项目时,如果需要预览、调试小程序,依然需要借助微信开发者工具

安装插件:

配置详细插件:

  1. 在【项目的根目录】下创建 .vscode 文件夹,注意:文件夹名字前面带 . 点❗

  2. .vscode 文件夹下,创建 settings.json,用来对安装的插件属性进行设置,具体属性设置从下面复制即可

    • 注意:.vscode 文件夹下的 settings.json 文件只对当前一个项目生效
  3. 在【项目的根目录】下创建 .prettierrc 文件,进行 Prettier 代码规则的配置,规则从下面复制即可

  4. 为了让 Prettier 配置项在微信开发者工具生效,需要在微信开发者工具中也安装 Prettier 扩展插件。

➡️ .vscode/settings.json

json
{
  // 保存文件时是否自动格式化
  "editor.formatOnSave": true,

  // ---------------- 以下是 [ prettier ] 插件配置 ----------------

  // 指定 javascript、wxss、scss、less、json、jsonc 等类型文件使用 prettier 进行格式化
  "[javascript]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },

  "[wxss]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },

  "[scss]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },

  "[less]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },

  "[json]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },

  "[jsonc]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },

  // Prettier 的一个配置项,用于指定哪些文件类型需要使用 Prettier 进行格式化
  "prettier.documentSelectors": ["**/*.wxml", "**/*.wxss", "**/*.wxs"],

  // ---------------- 以下是 [ WXML - Language Service ] 插件配置 ----------------

  // wxml 文件使用 prettier 进行格式化
  "[wxml]": {
    // "qiu8310.minapp-vscode" 是 WXML - Language Service 插件提供的配置项
    // 此插件主要是针对小程序的 wxml 模板语言,可以自动补全所有的组件、组件属性、组件属性值等等

    // 如果是 VsCode 需要开启这个配置
    "editor.defaultFormatter": "qiu8310.minapp-vscode"

    // 如果是微信小程序,需要开启这个配置,通过 esbenp.prettier-vscode 对代码进行格式化
    // "editor.defaultFormatter": "esbenp.prettier-vscode"
  },

  // 创建组件时使用的 css 后缀
  "minapp-vscode.cssExtname": "scss", // 默认 wxss,支持 styl sass scss less css

  // 指定 WXML 格式化工具
  "minapp-vscode.wxmlFormatter": "prettier",
  // 配置 prettier 代码规范
  "minapp-vscode.prettier": {
    "useTabs": false,
    "tabWidth": 2,
    "printWidth": 80
  },

  // ---------------- 以下是 [ 微信小程序助手-Y ] 插件配置 ----------------

  // 新增、删除小程序页面时,是否自动同步 app.json pages 路径配置,默认为 false
  "wechat-miniapp.sync.delete": true,
  // 设置小程序页面 wxss 样式文件的扩展名
  "wechat-miniapp.ext.style": "scss",

  // ---------------- 其他配置项 ----------------

  // 配置语言的文件关联,运行 .json 文件时写注释
  // 但在 app.json 和 page.json 中无法使用
  "files.associations": {
    "*.json": "jsonc"
  }
}

➡️ .prettierrc

json
{
  "semi": false,
  "singleQuote": true,
  "useTabs": false,
  "tabWidth": 2,
  "printWidth": 180,
  "trailingComma": "none",
  "overrides": [
    {
      "files": "*.wxml",
      "options": { "parser": "html" }
    },
    {
      "files": "*.wxss",
      "options": { "parser": "css" }
    },
    {
      "files": "*.wxs",
      "options": { "parser": "babel" }
    }
  ]
}
配置项配置项含义
"semi": false不要有分号
"singleQuote": true使用单引号
"useTabs": false缩进不使用 tab,使用空格
"tabWidth": 2tab缩进为4个空格字符
"printWidth": 80一行的字符数,如果超过会进行换行,默认为80
"trailingComma": "none"尾随逗号问题,设置为none 不显示 逗号
"overrides": []overrides 解析器:默认情况下,Prettier 会根据文件文件拓展名推断要使用的解析器

📌:注意事项:

项目根目录 .vscode 文件夹中 settings.json 文件只对当前项目生效❗

如果想配置项生效,还需要注意:

在 VsCode 中只能打开当前一个小程序项目,不能同时打开多个小程序项目❗ 且项目目录请勿嵌套打开 ❗

通用模块封装

01. 为什么进行模块封装

在进行项目开发的时候,我们经常的会频繁的使用到一些 API,

例如:wx.showToast()wx.showModal()等消息提示 API ,这些 API 的使用方法如下:

js
wx.showToast({
  title: "消息提示框", // 提示的内容
  icon: "success", // 提示图标
  duration: 2000, // 提示的延迟时间
  mask: true, // 是否显示透明蒙层,防止触摸穿透
});

wx.showModal({
  title: "提示", // 提示的标题
  content: "您确定执行该操作吗?", // 提示的内容
  confirmColor: "#f3514f", // 确定按钮的样式
  // 接口调用结束的回调函数(调用成功、失败都会执行)
  complete({ confirm, cancel }) {
    if (confirm) {
      console.log("用户点击了确定");
      return;
    }

    if (cancel) {
      console.log("用户点击了取消");
    }
  },
});

如果每次使用的时候,都直接调用这些 API,会导致代码很冗余,为了减少了代码冗余,我们需要将这些 API 封装成公共方法,封装后的使用方式如下:

js
// wx.showToast() 封装后的调用方式
toast()
toast({ title: '数据加载失败....', mask: true })

// wx.showModal() 封装后的调用方式
const res = await modal({
  title: '提示',
  content: '鉴权失败,请重新登录 ?'
})

// 用户点击了确定
if (res) { ... } else { ... }

可以看到封装后方法,极大简化 API 的调用,

同时,我们在后续还会进行网络通用模块的封装,如果直接进行封装难度比较大,

进行通过模块的封装,也是为后续 [网络请求封装] 做铺垫。

02. 消息提示模块封装

基本使用:

wx.showToast() 消息提示框是在项目中频繁使用的一个小程序 API,常用来给用户进行消息提示反馈。使用方式如下:

js
wx.showToast({
  title: "消息提示框", // 提示的内容
  icon: "success", // 提示的图标,success(成功)、error(失败)、loading(加载)、none(不显示图标)
  duration: 2000, // 提示的延迟时间
  mask: true, // 是否显示透明蒙层,防止触摸穿透
});

封装思路:

  1. 创建一个 toast 方法对 wx.showToast() 方法进行封装
  2. 调用该方法时,传递对象作为参数
    • 如果没有传递任何参数,设置一个空对象 {} 作为默认参数
    • 从对象中包含 titleicondurationmask 参数,并给参数设置默认值
  3. 在需要显示弹出框的时候调用 toast 方法,并传入相关的参数,有两种参数方式:
    • 不传递参数,使用默认参值
    • 传入部分参数,覆盖默认的参数

调用方式:

新封装的模块,我们希望有两种调用的方式:

  1. 模块化的方式导入.js文件使用

    js
    import { toast } from "./extendApi";
    
    toast();
    toast({ title: "数据加载失败....", mask: true });
  2. 将封装的模块挂载到 wx 全局对象身上

    js
    wx.toast();
    wx.toast({ title: "数据加载失败....", mask: true });

实现步骤:

  1. utils 目录下新建 extendApi.js 文件
  2. wx.showToast() 方法进行封装

落地代码:

➡️ utils/extendApi.js

js
/**
 * @description 封装消息提示组件
 * @param {*} title 提示的内容
 * @param {*} icon 图标
 * @param {*} duration 提示的延迟时间
 * @param {*} mask 是否显示透明蒙层,防止触摸穿透
 */
const toast = ({ title = '数据加载中', icon = 'none', mask = true, duration = 3000 } = {}) => {
  wx.showToast({
    title,
    icon,
    mask,
    duration
  })
}

当有很多js文件都要调用时,总是导入导出很麻烦,所以把它挂载到wx全局上
// 在 wx 全局对象上封装 toast 方法
// 调用 API 方式:
// 1. 在入口文件 app.js 导入封装的模块  import './utils/extendApi'
// 2. 调用封装的方法:wx.toast('')
wx.toast = toast  挂载,写在toast定义的文件即可

// 模块化的方式使用
// 调用 API 方式:
// 1. 导入该文件:import { toast } from '../utils/extendApi'
// 2. 调用封装的方法:toast('')
export { toast }

➡️ app.js

js
import { toast } from "./utils/extendApi";

App({
  onLaunch() {
    // 第一种调用方式:不传入任何参数
    toast();

    // 第二种调用方式:传入部分参数。传入的参数会覆盖默认的参数
    toast({ title: "数据加载失败....", mask: true });

    // 第三种调用方式:传入全部的参数
    toast({ title: "数据加载失败....", mask: true });
  },
});

03. 模态对话框封装

基本使用:

wx.showModal() 模态对话框也是在项目中频繁使用的一个小程序 API,通常用于向用户询问是否执行一些操作,例如:询问用户是否真的需要退出、是否确认删除等等

js
wx.showModal({
  title: "提示", // 提示的标题
  content: "您确定执行该操作吗?", // 提示的内容
  confirmColor: "#f3514f",
  // 接口调用结束的回调函数(调用成功、失败都会执行)
  complete({ confirm, cancel }) {
    confirm && console.log("点击了确定");
    cancel && console.log("点击了取消");
  },
});

封装思路:

  1. wx.showModal() 方法进行封装, 封装后的新方法叫 modal
  2. 调用该方法时,传递对象作为参数,对象的参数同 wx.showModal() 参数一致
  3. 封装的 modal 方法的内部通过 Promise 返回用户执行的操作(确定和取消,都通过 resolve 返回)
  4. 在需要显示模态对话框的时候调用 modal 方法,并传入相关的参数,有三种调用方式:
    • 不传递参数,使用默认参数
    • 传递参数,覆盖默认的参数

调用方式:

新封装的本地存储模块,我们依然希望有两种调用的方式:

  1. 模块化的方式导入使用
  2. 将封装的模块挂载到 wx 全局对象身上

实现步骤:

  1. extendApi.js 文件中新建 modal 方法,方法内部
  2. modal 方法,方法内部用来处理封装的逻辑

落地代码:

➡️ utils/extendApi.js

js
// coding...

/**
 * @description 封装 wx.showModal  方法
 * @param {*} options 同 wx.showModal 配置项
 */
export const modal = (options = {}) => {
  // 使用 Promise 处理 wx.showModal 的返回结果
  return new Promise((resolve) => {

    // 默认的参数
    const defaultOpt = {
      title: '提示',
      content: '您确定执行该操作吗?',
      confirmColor: '#f3514f',
    }

    // 将传入的参数和默认的参数进行合并
    //传入的参数options会覆盖defaulltOpt,然后再放到第一个参数空对象里
    const opts = Object.assign({}, defaultOpt, options)

    wx.showModal({
      // 将合并的参数赋值传递给 showModal 方法
      ...opts,
        //不论点击取消还是确定都会执行的函数:
      complete({ confirm, cancel }) {
        // 如果用户点击了确定,通过 resolve 抛出 true
        // 如果用户点击了取消,通过 resolve 抛出 false
        confirm && resolve(true)
        cancel && resolve(false)
      }
    })
  })
}

// 在 wx 全局对象上封装 myToast 方法
// 调用 API 方式:
// 1. 在入口文件 app.js 导入封装的模块  import './utils/extendApi'
// 2. 调用封装的方法:wx.toast('')
wx.toast = toast
+ wx.modal = modal

// 模块化的方式使用
// 调用 API 方式:
// 1. 导入该文件:import { toast } from '../utils/extendApi'
// 2. 调用封装的方法:toast('')
+ export { toast, modal }

➡️ app.js

js
import { modal } from "./utils/extendApi";

App({
  async onLaunch() {
    // 第一种调用方式:不传入任何参数
    // 不使用任何参数,使用默认值
    const res = await modal();
    console.log(res);

    // 第二种调用方式:更改默认配置
    const res = await modal({
      content: "鉴权失败,请重新登录",
      showCancel: false,
    });
    console.log(res);
  },
});

测试代码:

04. 封装本地存储 API

思路分析:

在小程序中,经常需要将一些数据存储到本地,方便多个页面的读取使用,例如:将用户的登录状态、用户的个人信息存储到本地。

小程序提供了同步、异步两类 API 来实现本地存储操作。例如: wx.setStorageSyncwx.setStorage 等方法

js
try {
  wx.setStorageSync(key, value)
} catch (err) {
  console.error(`存储指定 ${key} 数据发生错误:`, err)
}

wx.setStorage({
  key: 'key',
  data: 'data',
  success (res) => {},
  fail (err) => {}
})

如果直接使用这些 API,会比较麻烦,通常情况下,我们需要对本地存储的方法进行封装。

实现步骤:

  1. utils 目录下新建 storage.js 文件
  2. 在该文件中,封装对本地数据进行 存储、获取、删除、清除的方法

落地代码:

➡️ utils/storage.js

ts
/**
 * @description 存储数据
 * @param {*} key 本地缓存中指定的 key
 * @param {*} value 需要缓存的数据
 */
export const setStorage = (key, value) => {
  try {
    wx.setStorageSync(key, value);
  } catch (e) {
    console.error(`存储指定 ${key} 数据发生错误:`, e);
  }
};

/**
 * @description 从本地读取对应 key 的数据
 * @param {*} key
 */
export const getStorage = (key) => {
  try {
    const value = wx.getStorageSync(key);
    if (value) {
      return value;
    }
  } catch (e) {
    console.error(`获取指定 ${key} 数据发生错误:`, e);
  }
};

/**
 * @description 从本地移除指定 key 数据
 * @param {*} key
 */
export const removeStorage = (key) => {
  try {
    wx.removeStorageSync(key);
  } catch (err) {
    console.error(`移除指定 ${key} 数据发生错误:`, e);
  }
};

/**
 * @description 从本地清空全部的数据
 */
export const clearStorage = () => {
  try {
    wx.clearStorageSync();
  } catch (e) {
    console.error("清空本地存储时发生错误:", e);
  }
};

测试代码:

05. 拓展:封装异步存储API+优化代码

思路分析:

  1. 使用 Promise 封装异步存储 API

    js
    wx.setStorage({
      key: "key",
      data: "data",
      success(res) {},
      fail(err) {},
      complete(res) {},
    });
  2. 给 toast 以及 modal 方法添加注释

使用方式:

js
// 异步将数据存储到本地
asyncSetStorage(key, data);

// 异步从本地读取指定 key 的数据
asyncGetStorage(key);

// 异步从本地移除指定 key 的数据
asyncRemoveStorage(key);

// 异步从本地移除、清空全部的数据
asyncClearStorage();

落地代码:

➡️ utils/storage.js

js
/**
 * @description 将数据存储到本地 - 异步方法
 * @param {*} key 本地缓存中指定的 key
 * @param {*} data 需要缓存的数据
 */
export const asyncSetStorage = (key, data) => {
  return new Promise((resolve) => {
    wx.setStorage({
      key,
      data,
      complete(res) {
        resolve(res);
      },
    });
  });
};

/**
 * @description 从本地读取指定 key 的数据 - 异步方法
 * @param {*} key
 */
export const asyncGetStorage = (key) => {
  return new Promise((resolve) => {
    wx.getStorage({
      key,
      complete(res) {
        resolve(res);
      },
    });
  });
};

/**
 * @description 从本地移除指定 key 的数据 - 异步方法
 * @param {*} key
 */
export const asyncRemoveStorage = (key) => {
  return new Promise((resolve) => {
    wx.removeStorage({
      key,
      complete(res) {
        resolve(res);
      },
    });
  });
};

/**
 * @description 从本地移除、清空全部的数据 - 异步方法
 */
export const asyncClearStorage = () => {
  return new Promise((resolve) => {
    wx.clearStorage({
      complete(res) {
        resolve(res);
      },
    });
  });
};

➡️ utils/extendApi.js

js
// coding...

/**
 * @description 消息提示框
 * @param { Object } options 参数和 wx.showToast 参数保持一致
 */
const toast = ({ title = "数据加载中...", icon = "none", duration = 2000, mask = true } = {}) => {
  // coding...
};

/**
 * @description 模态对话框
 * @param { Object } options 参数和 wx.showModal 参数保持一致
 */
const modal = (options = {}) => {
  // coding...
};

// coding...

网络请求封装

网络请求模块难度较大,如果学习起来感觉吃力,可以直接学习 [请求封装-使用 npm 包发送请求] 以后的模块

01. 为什么要封装 wx.request

小程序大多数 API 都是异步 API,如 wx.request()wx.login() 等。这类 API 接口通常都接收一个 Object 对象类型的参数,参数中可以按需指定以下字段来接收接口调用结果:

参数名类型必填说明
successfunction调用成功的回调函数
failfunction调用失败的回调函数
completefunction调用结束的回调函数(调用成功、失败都会执行)
js
wx.request({
  // 接口调用成功的回调函数
  success() {
    wx.request({
      success() {
        wx.request({
          success() {
            wx.request({
              success() {
                wx.request({
                  success() {
                    wx.request({
                      success() {
                        wx.request({
                          success() {
                            wx.request({
                              success() {},
                            });
                          },
                        });
                      },
                    });
                  },
                });
              },
            });
          },
        });
      },
    });
  },

  // 接口调用失败的回调函数
  fail() {},

  // 接口调用结束的回调函数(调用成功、失败都会执行)
  complete() {},
});

如果采用这种回调函数的方法接收返回的值,可能会出现多层 success 套用的情况,容易出现回调地狱问题,

为了解决这个问题,小程序基础库从 2.10.2 版本起,异步 API 支持 callback & promise 两种调用方式。

当接口参数 Object 对象中不包含 success/fail/complete 时,将默认返回 promise,否则仍按回调方式执行,无返回值。

但是部分接口如 downloadFile, request, uploadFile 等本身就有返回值,因此不支持 promise 调用方式,它们的 promisify 需要开发者自行封装。

Axios 是我们日常开发中常用的一个基于 promise 的网络请求库

我们可以参考 Axios 的 [使用方式] 来封装自己的网络请求模块,咱们看一下使用的方式:

网络请求模块封装

js
import WxRequest from 'mina-request'

// 自定义配置新建一个实例
const instance = new WxRequest(({
  baseURL: 'https://some-domain.com/api/',
  timeout: 1000,
  headers: {'X-Custom-Header': 'foobar'}
})


// 通过 instance.request(config) 方式发起网络请求
instance.requst({
  method: 'post',
  url: '/user/12345',
  data: {
    firstName: 'Fred',
    lastName: 'Flintstone'
  }
})

// 通过 instance.get 方式发起网络请求
instance.get(url, data, config)

// 通过 instance.delete 方式发起网络请求
instance.delete(url, data, config)

// 通过 instance.post 方式发起网络请求
instance.post(url, data, config)

// 通过 instance.put 方式发起网络请求
instance.put(url, data, config)


// ----------------------------------------------


// 添加请求拦截器
instance.interceptors.request = (config) => {

  // 在发送请求之前做些什么
  return config
}

// 添加响应拦截器
instance.interceptors.response = (response) => {

  // response.isSuccess = true,代码执行了 wx.request 的 success 回调函数
  // response.isSuccess = false,代码执行了 wx.request 的 fail 回调函数

  // response.statusCode // http 响应状态码

  // response.config // 网络请求请求参数

  // response.data 服务器响应的真正数据

  // 对响应数据做点什么
  return response
}

封装后网络请求模块包含以下功能

  1. 包含 request 实例方法发送请求
  2. 包含 get、delete、put、post 等实例方法可以快捷的发送网络请求
  3. 包含 请求拦截器、响应拦截器
  4. 包含 uploadFile 将本地资源上传到服务器 API
  5. 包含 all 并发请求方法
  6. 同时优化了并发请求时 loading 显示效果

02. 请求封装-request 方法

思路分析:

在封装网络请求模块的时候,采用 Class 类来进行封装,采用类的方式封装代码更具可复用性,也方便地添加新的方法和属性,提高代码的扩展性

我们先创建一个 class 类,同时定义 constructor 构造函数

js
// 创建 WxRequest 类
class WxRequest {
  constructor() {}
}

我们在 WxRequest 类内部封装一个 request 实例方法

request 实例方法中需要使用 Promise 封装 wx.request,也就是使用 Promise 处理 wx.request 的返回结果

request 实例方法接收一个 options 对象作为形参,options 参数和调用 wx.request 时传递的请求配置项一致

  • 接口调用成功时,通过 resolve 返回响应数据
  • 接口调用失败时,通过 reject 返回错误原因
js
class WxRequest {
  // 定义 constructor 构造函数,用于创建和初始化类的属性和方法
  constructor() {}

  /**
   * @description 发起请求的方法
   * @param { Object} options 请求配置选项,同 wx.request 请求配置选项
   * @returns Promise
   */
  request(options) {
    // 使用 Promise 封装异步请求
    return new Promise((resolve, reject) => {
      // 使用 wx.request 发起请求
      wx.request({
        ...options,

        // 接口调用成功的回调函数
        success: (res) => {
          resolve(res);
        },

        // 接口调用失败的回调函数
        fail: (err) => {
          reject(err);
        },
      });
    });
  }
}

然后对 WxRequest 进行实例化,然后测试 request 实例方法是否封装成功!

注意:我们先将类 和 实例化的对象放到同一个文件中,这样方便进行调试,后面我们在拆分成两个文件

js
class WxRequest {
  // coding....
}

// ----------------- 实例化 ----------------------

// 对 WxRequest 进行实例化
const instance = new WxRequest();

// 将 WxRequest 的实例通过模块化的方式暴露出去
export default instance;

在其他模块中引入封装的文件后,我们期待通过 request() 方式发起请求,以 promise 的方式返回参数

js
// 导入创建的实例
import instance from "../../utils/wx-request";

Page({
  // 点击按钮触发 handler 方法
  async handler() {
    // 通过实例调用 request 方法发送请求
    const res = await instance.request({
      url: "https://gmall-prod.atguigu.cn/mall-api/index/findBanner",
      method: "GET",
    });

    console.log(res);
  },
});

落地代码:

➡️ /utils/request.js

js
// 创建 WxRequest 类,采用类的方式进行封装会让方法更具有复用性,也可以方便进行添加新的属性和方法

class WxRequest {
  // 定义 constructor 构造函数,用于创建和初始化类的属性和方法
  constructor() {}

  /**
   * @description 发起请求的方法
   * @param { Object} options 请求配置选项,同 wx.request 请求配置选项
   * @returns Promise
   */
  request(options) {
    // 使用 Promise 封装异步请求
    return new Promise((resolve, reject) => {
      // 使用 wx.request 发起请求
      wx.request({
        ...options,

        // 接口调用成功的回调函数
        success: (res) => {
          resolve(res);
        },

        // 接口调用失败的回调函数
        fail: (err) => {
          reject(err);
        },
      });
    });
  }
}

// ----------------- 实例化 ----------------------

// 对 WxRequest 进行实例化
const instance = new WxRequest();

// 将 WxRequest 的实例通过模块化的方式暴露出去
export default instance;

➡️ /pages/test/test.js

js
import instance from "../../utils/request";

Page({
  // 点击按钮触发 handler 方法
  async handler() {
    // 第一种调用方式:通过 then 和 catch 接收返回的值
    // instance
    //   .request({
    //     url: 'https://gmall-prod.atguigu.cn/mall-api/index/findBanner',
    //     method: 'GET'
    //   })
    //   .then((res) => {
    //     console.log(res)
    //   })
    //   .catch((err) => {
    //     console.log(err)
    //   })

    // 第二种调用方式:通过 await 和 async 接收返回的值
    const res = await instance.request({
      url: "https://gmall-prod.atguigu.cn/mall-api/index/findBanner",
      method: "GET",
    });

    console.log(res);
  },
});

03. 请求封装-设置请求参数

思路分析:

在发起网络请求时,需要配置一些请求参数,

其中有一些参数我们可以设置为默认参数,例如:请求方法、超时时长 等等,因此我们在封装时我们要定义一些默认的参数。

js
// 默认参数对象
defaults = {
  baseURL: "", // 请求基准地址
  url: "", // 开发者服务器接口地址
  data: null, // 请求参数
  method: "GET", // 默认请求方法
  // 请求头
  header: {
    "Content-type": "application/json", // 设置数据的交互格式
  },
  timeout: 60000, // 小程序默认超时时间是 60000,一分钟
  // 其他参数...
};

但是不同的项目,请求参数的设置是不同的,我们还需要允许在进行实例化的时候,传入参数,对默认的参数进行修改。例如:

js
// 对 WxRequest 进行实例化
const instance = new WxRequest({
  baseURL: "https://gmall-prod.atguigu.cn/mall-api", // 请求基准地址
  timeout: 10000, // 微信小程序 timeout 默认值为 60000
});

在通过实例,调用 request 实例方法时也会传入相关的请求参数

js
const res = await instance.request({
  url: "/index/findBanner",
  method: "GET",
});

从而得出结论:请求参数的设置有三种方式:

  1. 默认参数:在 WxRequest 类中添加 defaults 实例属性来设置默认值
  2. 实例化时参数:在对 WxRequest 类进行实例化时传入相关的参数,需要在 constructor 构造函数形参进行接收
  3. 调用实例方法时传入请求参数

默认参数和自定义参数的合并操作,通常会在constructor中进行。

因此我们就在 constructor 中将开发者传入的相关参数和defaults 默认值进行合并,需要传入的配置项覆盖默认配置项

js
class WxRequest {

+   // 默认参数对象
+   defaults = {
+     baseURL: '', // 请求基准地址
+     url: '', // 开发者服务器接口地址
+     data: null, // 请求参数
+     method: 'GET',// 默认请求方法
+     // 请求头
+     header: {
+       'Content-type': 'application/json' // 设置数据的交互格式
+     },
+     timeout: 60000 // 小程序默认超时时间是 60000,一分钟
+   }

  /**
   * @description 定义 constructor 构造函数,用于创建和初始化类的属性和方法
   * @param {*} params 用户传入的请求配置项
   */
+   constructor(params = {}) {
+     // 在实例化时传入的参数能够被 constructor 进行接收
+     console.log(params)

+     // 使用 Object.assign 合并默认参数以及传递的请求参数
+     this.defaults = Object.assign({}, this.defaults, params)
+   }

  // coding....
}

// ----------------- 实例化 ----------------------

// 对 WxRequest 进行实例化
+ const instance = new WxRequest({
+   baseURL: 'https://gmall-prod.atguigu.cn/mall-api',
+   timeout: 15000
+ })

// 将 WxRequest 的实例通过模块化的方式暴露出去
export default instance

在调用 request 实例时也会传入相关的参数,是发起请求真正的参数,

我们需要将调用 reqeust 实例方法时传入的参数,继续覆盖合并以后的参数,请求才能够发送成功

注意:让使用传入的参数覆盖默认的参数,同时拼接完整的请求地址。

js
// 创建 request 请求方法
request(options) {
+  // 拼接完整的请求地址
+  options.url = this.defaults.baseURL + options.url
+  // 合并请求参数
+  options = { ...this.defaults, ...options }

  return new Promise((resolve, reject) => {
    // coding...
  })

}

落地代码:

➡️ utils/request.js

js
// 创建 Request 类,用于封装 wx.request() 方法
class WxRequest {

+   // 默认参数对象
+   defaults = {
+     baseURL: '', // 请求基准地址
+     url: '', // 开发者服务器接口地址
+     data: null, // 请求参数
+     method: 'GET',// 默认请求方法
+     // 请求头
+     header: {
+       'Content-type': 'application/json' // 设置数据的交互格式
+     },
+     timeout: 60000 // 小程序默认超时时间是 60000,一分钟
+   }

+   /**
+    * @description 定义 constructor 构造函数,用于创建和初始化类的属性和方法
+    * @param {*} params 用户传入的请求配置项
+    */
+   constructor(params = {}) {
+     // 在实例化时传入的参数能够被 constructor 进行接收
+     console.log(params)

+     // 使用 Object.assign 合并默认参数以及传递的请求参数
+     this.defaults = Object.assign({}, this.defaults, params)
+   }

  /**
   * @description 发起请求的方法
   * @param { Object} options 请求配置选项,同 wx.request 请求配置选项
   * @returns Promise
   */
  request(options) {
+    // 拼接完整的请求地址
+    options.url = this.defaults.baseURL + options.url
+    // 合并请求参数
+    options = { ...this.defaults, ...options }

    // 方法返回一个 Promise 对象
    return new Promise((resolve, reject) => {
      // coding...
    })
  }
}

// ----------------- 实例化 ----------------------

// 对 WxRequest 进行实例化
const instance = new WxRequest({
  baseURL: 'https://gmall-prod.atguigu.cn/mall-api',
  timeout: 15000
})

// 将 WxRequest 的实例通过模块化的方式暴露出去
export default instance

04. 请求封装-封装请求快捷方法

思路分析:

目前已经完成了 request() 请求方法的封装,同时处理了请求参数。

每次发送请求时都使用 request() 方法即可,但是项目中的接口地址有很多,不是很简洁

js
const res = await instance.request({
  url: "/index/findBanner",
  method: "GET",
});

所以我们在 request() 基础上封装一些快捷方法,简化 request() 的调用。

需要封装 4 个快捷方法,分别是 getdeletepostput,他们的调用方式如下:

js
instance.get("请求地址", "请求参数", "请求配置");
instance.delete("请求地址", "请求参数", "请求配置");
instance.post("请求地址", "请求参数", "请求配置");
instance.put("请求地址", "请求参数", "请求配置");

这 4 个请求方法,都是通过实例化的方式进行调用,所以需要 Request 类中暴露出来 getdeletepostput 方法。每个方法接收三个参数,分别是:接口地址、请求参数以及其他参数。

这 4 个快捷方法,本质上其实还是调用 request 方法,我们只要在方法内部组织好参数,调用 request 发送请求即可

js
class WxRequest {

  // coding...

+   // 封装 GET 实例方法
+   get(url, data = {}, config = {}) {
+     return this.request(Object.assign({ url, data, method: 'GET' }, config))
+   }

+   // 封装 POST 实例方法
+   post(url, data = {}, config = {}) {
+     return this.request(Object.assign({ url, data, method: 'POST' }, config))
+   }

+   // 封装 PUT 实例方法
+   put(url, data = {}, config = {}) {
+     return this.request(Object.assign({ url, data, method: 'PUT' }, config))
+   }

+   // 封装 DELETE 实例方法
+   delete(url, data = {}, config = {}) {
+     return this.request(Object.assign({ url, data, method: 'DELETE' }, config))
+   }
}

// ----------------- 实例化 ----------------------

// 对 WxRequest 进行实例化
const instance = new WxRequest({
  baseURL: 'https://gmall-prod.atguigu.cn/mall-api',
  timeout: 15000
})

// 将 WxRequest 的实例通过模块化的方式暴露出去
export default instance

落地代码:

➡️ utils/request.js

js
class WxRequest {

  // coding...

+   // 封装 GET 实例方法
+   get(url, data = {}, config = {}) {
+     return this.request(Object.assign({ url, data, method: 'GET' }, config))
+   }

+   // 封装 POST 实例方法
+   post(url, data = {}, config = {}) {
+     return this.request(Object.assign({ url, data, method: 'POST' }, config))
+   }

+   // 封装 PUT 实例方法
+   put(url, data = {}, config = {}) {
+     return this.request(Object.assign({ url, data, method: 'PUT' }, config))
+   }

+   // 封装 DELETE 实例方法
+   delete(url, data = {}, config = {}) {
+     return this.request(Object.assign({ url, data, method: 'DELETE' }, config))
+   }
}

// ----------------- 实例化 ----------------------

// 对 WxRequest 进行实例化
const instance = new WxRequest({
  baseURL: 'https://gmall-prod.atguigu.cn/mall-api',
  timeout: 15000
})

// 将 WxRequest 的实例通过模块化的方式暴露出去
export default instance

➡️ /pages/test/test.js

js
// 导入创建的实例
import instance from "../../utils/wx-request";

Page({
  async handler() {
    // 第一种调用方式:通过 then 和 catch 接收返回的值
    // instance
    //   .request({
    //     url: 'https://gmall-prod.atguigu.cn/mall-api/index/findBanner',
    //     method: 'GET'
    //   })
    //   .then((res) => {
    //     console.log(res)
    //   })
    //   .catch((err) => {
    //     console.log(err)
    //   })

    // 第二种调用方式
    // 通过实例调用 request 方法发送请求
    // const res = await instance.request({
    //   url: '/index/findBanner',
    //   method: 'GET'
    // })
    // console.log(res)

    // 第三种调用方式:通过调用快捷方式接收返回的值
    const res = await instance.get("/index/findBanner");
    console.log(res);
  },
});

05. 请求封装-wx.request 注意事项

知识点:

在使用 wx.request 发送网络请求时。

只要成功接收到服务器返回,无论statusCode是多少,都会进入 success 回调

开发者根据业务逻辑对返回值进行判断。

什么时候会有 fail 回调函数 ?

一般只有网络出现异常、请求超时等时候,才会走 fail 回调

落地代码:

测试代码

js
request() {
  wx.request({
    url: 'https://gmall-prod.atguigu.cn/mall-api/index/findCategory',
    method: 'GET',
    // timeout: 100, 测试网络超时,需要调整网络
    success: (res) => {
      console.log('只要成功接收到服务器返回,不管状态是多少,都会进入 success 回调')
      console.log(res)
    },
    fail: (err) => {
      console.log(err)
    }
  })
}

06. 请求封装-定义请求/响应拦截器

思路分析:

为了方便统一处理请求参数以及服务器响应结果,为 WxRequest 添加拦截器功能,拦截器包括 请求拦截器响应拦截器

请求拦截器本质上是在请求之前调用的函数,用来对请求参数进行新增和修改

响应拦截器本质上是在响应之后调用的函数,用来对响应数据做点什么

注意:不管成功响应还是失败响应,都会执行响应拦截器

拦截器的使用方式:

js
// 请求拦截器
instance.interceptors.request = (config) => {
  // 在发送请求之前做些什么
  return config;
};

// 响应拦截器
instance.interceptors.response = (response) => {
  // 对响应数据做点什么
  return response;
};

通过使用方式,我们可以得出结论:

可以在 WxRequest 类内部定义 interceptors 实例属性,属性中需要包含 request 以及 response 方法

需要注意:在发送请求时,还需要区分是否通过实例调用了拦截器:

  1. 没有通过实例调用拦截器,需要定义默认拦截器,在默认拦截器中,需要将请求参数进行返回
  2. 通过实例调用拦截器,那么实例调用的拦截器会覆盖默认的拦截器方法,然后将新增或修改的请求参数进行返回

实现拦截器的思路:

  1. WxRequest 类内部定义 interceptors 实例属性,属性中需要包含 request 以及 response 方法
  2. 是否通过实例调用了拦截器
    • 是:定义默认拦截器
    • 否:实例调用的拦截器覆盖默认拦截器
  3. 在发送请求之前,调用请求拦截器
  4. 在服务器响应以后,调用响应拦截器
    • 不管成功、失败响应,都需要调用响应拦截器

WxRequest 类内部定义 interceptors 实例属性,属性中需要包含 request 以及 response 方法。

没有使用拦截器,定义默认拦截器,需要将默认的请求参数进行返回。

如果使用了拦截器,那么使用者的拦截器会覆盖默认的拦截器方法

js
class WxRequest {

  // coding...

+   // 定义拦截器对象,包含请求拦截器和响应拦截器方法,方便在请求或响应之前进行处理。
+   interceptors = {
+     // 请求拦截器
+     request: (config) => config,
+     // 响应拦截器
+     response: (response) => response
+   }

  // 用于创建和初始化类的属性以及方法
  // 在实例化时传入的参数,会被 constructor 形参进行接收
  constructor(options = {}) {
    // coding...
  }
}
// ----------------- 以下是实例化的代码 --------------------
// 目前写到同一个文件中,是为了方便进行测试,以后会提取成多个文件

// 对 WxRequest 进行实例化
const instance = new WxRequest({
  baseURL: 'https://gmall-prod.atguigu.cn/mall-api',
  timeout: 15000
})

+ // 配置请求拦截器
+ instance.interceptors.request = (config) => {
+   // 在发送请求之前做些什么
+   return config
+ }

+ // 响应拦截器
+ instance.interceptors.response = (response) => {
+   // 对响应数据做点什么
+   return response
+ }

// 将 WxRequest 实例进行暴露出去,方便在其他文件中进行使用
export default instance

在发送请求之前,调用请求拦截器,在服务器响应以后,调用响应拦截器

不管成功、失败,都需要调用响应拦截器

js
class WxRequest {
  // coding...

  // request 实例方法接收一个对象类型的参数
  // 属性值和 wx.request 方法调用时传递的参数保持一致
  request(options) {
    // 注意:需要先合并完整的请求地址 (baseURL + url)
    // https://gmall-prod.atguigu.cn/mall-api/index/findBanner
    options.url = this.defaults.baseURL + options.url

    // 合并请求参数
    options = { ...this.defaults, ...options }

+     // 在发送请求之前调用请求拦截器
+     options = this.interceptors.request(options)

    // 需要使用 Promise 封装 wx.request,处理异步请求
    return new Promise((resolve, reject) => {
      wx.request({
        ...options,

        // 当接口调用成功时会触发 success 回调函数
        success: (res) => {
+           // 不管接口成功还是失败,都需要调用响应拦截器
    //响应拦截器接收服务器返回的数据,对数据做一定的逻辑处理,处理好之后进行返回
    //再通过resolve将返回的数据抛出去

    //在给响应拦截器传递参数时,需要将请求参数也一起传递,方便进行代码的调试或者其他逻辑处理,需要先合并参数,再把合并的参数传递给响应拦截器
+           // 第一个参数:需要合并的目标对象
+           // 第二个参数:服务器响应的数据
+           // 第三个参数:请求配置以及自定义的属性
+           const mergetRes = Object.assign({}, res, { config: options })
+           resolve(this.interceptors.response(mergetRes))
        },

        // 当接口调用失败时会触发 fail 回调函数
        fail: (err) => {
+           // 不管接口成功还是失败,都需要调用响应拦截器
+            const mergetErr = Object.assign({}, err, { config: options })
+            reject(this.interceptors.response(mergetErr))
        }
      })
    })
  }

  // coding...
}

落地代码:

➡️ utils/request.js

js
// 创建 WxRequest 类
// 通过类的方式来进行封装,会让代码更加具有复用性
// 也可以方便添加新的属性和方法

class WxRequest {
  // 定义实例属性,用来设置默认请求参数
  defaults = {
    baseURL: '', // 请求基准地址
    url: '', // 接口的请求路径
    data: null, // 请求参数
    method: 'GET', // 默认的请求方法
    // 请求头
    header: {
      'Content-type': 'application/json' // 设置数据的交互格式
    },
    timeout: 60000 // 默认的超时时长,小程序默认的超时时长是 1 分钟
  }

+   // 定义拦截器对象,包含请求拦截器和响应拦截器方法,方便在请求或响应之前进行处理。
+   interceptors = {
+     // 请求拦截器
+     request: (config) => config,
+     // 响应拦截器
+     response: (response) => response
+   }

  // 用于创建和初始化类的属性以及方法
  // 在实例化时传入的参数,会被 constructor 形参进行接收
  constructor(params = {}) {
    // 通过 Object.assign 方法合并请求参数
    // 注意:需要传入的参数,覆盖默认的参数,因此传入的参数需要放到最后
    this.defaults = Object.assign({}, this.defaults, params)
  }

  // request 实例方法接收一个对象类型的参数
  // 属性值和 wx.request 方法调用时传递的参数保持一致
  request(options) {
    // 注意:需要先合并完整的请求地址 (baseURL + url)
    // https://gmall-prod.atguigu.cn/mall-api/index/findBanner
    options.url = this.defaults.baseURL + options.url

    // 合并请求参数
    options = { ...this.defaults, ...options }

+     // 在发送请求之前调用请求拦截器
+     options = this.interceptors.request(options)

    // 需要使用 Promise 封装 wx.request,处理异步请求
    return new Promise((resolve, reject) => {
      wx.request({
        ...options,

        // 当接口调用成功时会触发 success 回调函数
        success: (res) => {
+           // 不管接口成功还是失败,都需要调用响应拦截器
+           // 第一个参数:需要合并的目标对象
+           // 第二个参数:服务器响应的数据
+           // 第三个参数:请求配置以及自定义的属性
+           const mergeRes = Object.assign({}, res, { config: options })
+           resolve(this.interceptors.response(mergeRes))
        },

        // 当接口调用失败时会触发 fail 回调函数
        fail: (err) => {
+           // 不管接口成功还是失败,都需要调用响应拦截器
+           const mergeErr = Object.assign({}, err, { iconfig: options })
+           // 不管接口成功还是失败,都需要调用响应拦截器
+           err = this.interceptors.response(mergeErr)
+           reject(err)
        }
      })
    })
  }

  // 封装 GET 实例方法
  get(url, data = {}, config = {}) {
    // 需要调用 request 请求方法发送请求,只需要组织好参数,传递给 request 请求方法即可
    // 当调用 get 方法时,需要将 request 方法的返回值 return 出去
    return this.request(Object.assign({ url, data, method: 'GET' }, config))
  }

  // 封装 DELETE 实例方法
  delete(url, data = {}, config = {}) {
    return this.request(Object.assign({ url, data, method: 'DELETE' }, config))
  }

  // 封装 POST 实例方法
  post(url, data = {}, config = {}) {
    return this.request(Object.assign({ url, data, method: 'POST' }, config))
  }

  // 封装 PUT 实例方法
  put(url, data = {}, config = {}) {
    return this.request(Object.assign({ url, data, method: 'PUT' }, config))
  }
}

// ----------------- 以下是实例化的代码 --------------------
// 目前写到同一个文件中,是为了方便进行测试,以后会提取成多个文件

// 对 WxRequest 进行实例化
const instance = new WxRequest({
  baseURL: 'https://gmall-prod.atguigu.cn/mall-api',
  timeout: 15000
})

+ // 配置请求拦截器
+ instance.interceptors.request = (config) => {
+   // 在发送请求之前做些什么
+   return config
+ }

+ // 响应拦截器
+ instance.interceptors.response = (response) => {
+
+   // 对响应数据做点什么
+   return response.data
+ }

// 将 WxRequest 实例进行暴露出去,方便在其他文件中进行使用
export default instance

07. 请求封装-完善请求/响应拦截器

思路分析:

在响应拦截器,我们需要判断是请求成功,还是请求失败,然后进行不同的业务逻辑处理。

例如:请求成功以后将数据简化返回,网络出现异常则给用户进行网络异常提示。

目前不管请求成功 (success),还是请求失败(fail),都会执行响应拦截器

那么怎么判断是请求成功,还是请求失败呢 ?

封装需求:

  1. 如果请求成功,将响应成功的数据传递给响应拦截器,同时在传递的数据中新增 isSuccess: true 字段,表示请求成功
  2. 如果请求失败,将响应失败的数据传递给响应拦截器,同时在传递的数据中新增 isSuccess: false 字段,表示请求失败

在实例调用的响应拦截中,根据传递的数据进行以下的处理:

  • 如果isSuccess: true 表示服务器响应了结果,我们可以将服务器响应的数据简化以后进行返回
  • 如果isSuccess: false 表示是网络超时或其他网络问题,提示 网络异常,同时将返回即可

落地代码:

➡️ utils/request.js

js
class WxRequest {

  // coding....

   request(options) {
    // coding....

    // 使用 Promise 封装异步请求
    return new Promise((resolve, reject) => {
      // 使用 wx.request 发起请求
      wx.request({
        ...options,

        // 接口调用成功的回调函数
        success: (res) => {
          // 响应成功以后触发响应拦截器
          if (this.interceptors.response) {
+             // 调用响应拦截器方法,获取到响应拦截器内部返回数据
    这时候在合并参数的时候,追加一个属性:isSuccess
              如果属性值为true,说明执行了success
              如果属性值为false,说明执行了fail回调函数
+             // success: true 表示服务器成功响应了结果,我们需要对业务状态码进行判断
+             res = this.interceptors.response({ response: res, isSuccess: true })
          }

          // 将数据通过 resolve 进行返回即可
          resolve(res)
        },

        // 接口调用失败的回调函数
        fail: (err) => {
          // 响应失败以后也要执行响应拦截器
          if (this.interceptors.response) {
+             // isSuccess: false 表示是网络超时或其他问题
+             err = this.interceptors.response({ response: err, isSuccess: true })
          }

          // 当请求失败以后,通过 reject 返回错误原因
          reject(err)
        }

      })
    })
  }

  // coding......
}


// -----------------------------------------------------


// 对 WxRequest 进行实例化
const instance = new WxRequest({
  baseURL: 'https://gmall-prod.atguigu.cn/mall-api'
})

// 设置请求拦截器
instance.setRequestInterceptor((config) => {
  console.log('执行请求拦截器')

  return config
})

// 设置响应拦截器
+ instance.setResponseInterceptor((response) => {
+   const { response: res, isSuccess } = response

+   // isSuccess: false 表示是网络超时或其他问题,提示 网络异常,同时将返回即可
+   if (!isSuccess) {
+     wx.toast('网络异常,请稍后重试~')
+     // 如果请求错误,将错误的结果返回出去
+     return res
+   }

+  // 简化数据
+  return response.data
})

// 将 WxRequest 的实例通过模块化的方式暴露出去
export default instance

08. 请求封装-使用请求/响应拦截器

思路分析:

使用请求拦截器:

在发送请求时,购物车列表、收货地址、更新头像等接口,都需要进行权限验证,因此我们需要在请求拦截器中判断本地是否存在访问令牌 token ,如果存在就需要在请求头中添加 token 字段。

使用响应拦截器:

在使用 wx.request 发送网络请求时。只要成功接收到服务器返回,无论statusCode是多少,都会进入 success 回调。

因此开发者根据业务逻辑对返回值进行判断。

后端返回的业务状态码如下:

  1. 业务状态码 === 200, 说明接口请求成功,服务器成功返回了数据
  2. 业务状态码 === 208, 说明没有 token 或者 token 过期失效,需要登录或者重新登录
  3. 业务状态码 === 其他,说明请求或者响应出现了异常

其他测试接口:/cart/getCartList

落地代码:

➡️ utils/request.js

js
// 创建 WxRequest 类,采用类的方式进行封装会让方法更具有复用性,也可以方便进行添加新的属性和方法

class WxRequest {
  // coding...
}

// -----------------------------------------------------

// 对 WxRequest 进行实例化
const instance = new WxRequest({
  baseURL: 'https://gmall-prod.atguigu.cn/mall-api',
  timeout: 5000
})

// 设置请求拦截器
instance.setRequestInterceptor((config) => {
+   // 从本地获取 token
+   if (wx.getStorageSync('token')) {
+     // 如果存在 token ,则添加请求头
+     config.header['token'] = wx.getStorageSync('token')
+   }
+
+   // 返回请求参数
+   return config
})

// 设置响应拦截器
instance.setResponseInterceptor(async (response) => {
+   const { response: res, isSuccess } = response

+   // isSuccess: false 表示是网络超时或其他问题,提示 网络异常,同时将返回即可
+   if (!isSuccess) {
+     wx.toast('网络异常,请稍后重试~')
+     // 如果请求错误,将错误的结果返回出去
+     return res
+   }

+   switch (res.data.code) {
+     case 200:
+       return res.data

+     case 208:
+       // 判断用户是否点击了确定
+       const modalStatus = await wx.modal({
+         title: '提示',
+         content: '登录授权过期,请重新授权'
+       })

+       // 如果点击了确定,先清空本地的 token,然后跳转到登录页面
+       if (modalStatus) {
+         wx.clearStorageSync()
+         wx.navigateTo({
+           url: '/pages/login/login'
+         })
+       }
+       return

+     default:
+       wx.showToast({
+         title: '接口调用失败~~~~',
+         icon: 'none'
+       })

+       // 将错误继续向下传递
+       return Promise.reject(response)
+   }
})

// 将 WxRequest 的实例通过模块化的方式暴露出去
export default instance

09. 请求封装-添加并发请求

思路分析:

前端并发请求是指在前端页面同时向后端发起多个请求的情况。当一个页面需要请求多个接口获取数据时,为了提高页面的加载速度和用户体验,可以同时发起多个请求,这些请求之间就是并发的关系。

我们通过两种方式演示发起多个请求:

  1. 使用 asyncawait 方式
  2. 使用 Promise.all() 方式

首先使用asyncawait 方式发送请求,使用 asyncawait 能够控制异步任务以同步的流程执行,代码如下,这时候就会产生一个问题,当第一个请求执行完以后,才能执行第二个请求,这样就会造成请求的阻塞,影响渲染的速度,如下图

注意:

async和await方式发起多个请求,当第一个请求结束以后,才能发起第二个请求,会造成请求阻塞,从而影响页面的渲染速度

这时候我们需要使用 Promise.all() 方式同时发起多个异步请求,并在所有请求完成后再进行数据处理和渲染。使用Promise.all() 能够将多个请求同时发出,不会造成请求的阻塞。

通过两种方式演示,我们能够知道封装并发请求的必要性。在 WxRequest 实例中封装 all 方法,使用展开运算符将传入的参数转成数组,方法的内部,使用 Promise.all() 接收传递的多个异步请求,将处理的结果返回即可。

js
class WxRequest {
  // coding...

+   // 封装处理并发请求的 all 方法
+   all(...promise) {
+     return Promise.all(promise)
+   }

  // coding...
}

// coding...

promise.all能够将多个异步请求同时发送,不会造成请求的阻塞,从而不会影响页面的渲染速度

落地代码:

➡️ utils/request.js

js
class WxRequest {
  // coding...

+   // 封装处理并发请求的 all 方法
    通过展开运算符接收传递的参数
那么展开运算符会将传入的参数转成数组
+   all(...promise) {
+     return Promise.all(promise)
+   }

  // coding...
}

// coding...

➡️ /pages/test/test.js

js
import instance from "../../utils/http";

Page({
  async getData() {
    // 使用 Promise.all 同时处理多个异步请求
    const [res1, res2] = await instance.all([
      instance.get("/mall-api/index/findBanner"),
      instance.get("/mall-api/index/findCategory1"),
    ]);

    console.log(res1);
    console.log(res2);
  },
});

10. 请求封装-添加 loading

思路分析:

在封装时添加 loading 效果,从而提高用户使用体验

  1. 在请求发送之前,需要通过 wx.showLoading 展示 loading 效果
  2. 当服务器响应数据以后,需要调用 wx.hideLoading 隐藏 loading 效果

要不要加 loading 添加到 WxRequest 内部 ?

  1. 在类内部进行添加,方便多个项目直接使用类提供的 loading 效果,也方便统一优化 wx.showLoading 使用体验。

    但是不方便自己来进行 loading 个性化定制。

  2. 如果想自己来控制 loading 效果,带来更丰富的交互体验,就不需要将 loading 封装到类内部,但是需要开发者自己来优化 wx.showLoading 使用体验,每个项目都要写一份。

大伙可以按照自己的业务需求进行封装,

在项目中我们会选择第一种方式。折中

不过也会通过属性控制是否展示 loading,从而方便类使用者自己控制 loading 显示

落地代码:

➡️ utils/request.js

js
class WxRequest {

  // coding...

  constructor(options = {}) {
    // coding...
  }

  // 创建 request 请求方法
  request(options) {
    // 拼接完整的请求地址
    options.url = this.defaults.baseURL + options.url
    // 合并请求参数
    options = { ...this.defaults, ...options }

+     // 发送请求之前添加 loding
+     wx.showLoading()

    // 如果存在请求拦截器,我们则调用请求拦截器
    if (this.interceptors.request) {
      // 请求之前,触发请求拦截器
      options = this.interceptors.request(options)
    }

    // 方法返回一个 Promise 对象
    return new Promise((resolve, reject) => {
      wx.request({
        ...options,
        success: (res) => {
          // coding...
        },
        fail: (err) => {
          // coding...
        },
+         complete: () => {
+           // 接口调用完成后隐藏 loding
+           wx.hideLoading()
+         }
      })
    })
  }

  // coding...
}

11. 请求封装-完善 loading

思路分析:

目前在发送请求时,请求发送之前会展示 loading,响应以后会隐藏 loading

但是 loading 的展示和隐藏会存在以下问题:

  1. 每次请求都会执行 wx.showLoading(),但是页面中只会显示一个,后面的 loading会将前面的覆盖
  2. 同时发起多次请求,只要有一个请求成功响应就会调用 wx.hideLoading,导致其他请求还没完成,也不会 loading
  3. 请求过快 或 一个请求在另一个请求后立即触发,这时候会出现 loading 闪烁问题

我们通过 队列 的方式解决这三个问题:首先在类中新增一个实例属性 queue,初始值是一个空数组

  1. 发起请求之前,判断 queue 如果是空数组则显示 loading ,然后立即向queue新增请求标识
  2. complete 中每次请求成功结束,从 queue 中移除一个请求标识,queue 为空时隐藏 loading
  3. 为了解决网络请求过快产生loading 闪烁问题,可以使用定时器来做判断即可

落地代码:

➡️ utils/request.js

js
class WxRequest {

  // coding...

  constructor(options = {}) {
    // 使用 Object.assign 合并默认参数以及传递的请求参数
    this.defaults = Object.assign({}, this.defaults, options)

    // 定义拦截器对象,包含请求拦截器和响应拦截器方法,方便在请求或响应之前进行处理。
    this.interceptors = {
      // 请求拦截器
      request: null,
      // 响应拦截器
      response: null
    }

+     // 初始化 queue 数组,用于存储请求队列
+     this.queue = []
  }

  // 创建 request 请求方法
  request(options) {
+     // 如果有新的请求,则清空上一次的定时器
+     this.timerId && clearTimeout(this.timerId)

    // 拼接完整的请求地址
    options.url = this.defaults.baseURL + options.url
    // 合并请求参数
    options = { ...this.defaults, ...options }

    // 如果存在请求拦截器,我们则调用请求拦截器
    if (this.interceptors.request) {
      // 请求之前,触发请求拦截器
      options = this.interceptors.request(options)
    }

+     // 发送请求之前添加 loding
+     this.queue.length === 0 && wx.showLoading()
      //当 this.queue 中没有任何未完成的请求任务时(即 this.queue.length === 0),才调用 wx.showLoading() 方法,在页面上显示 loading 图标。

+     // 然后想队列中添加 request 标识,代表需要发送一次新请求
+     this.queue.push('request')

    // 方法返回一个 Promise 对象
    return new Promise((resolve, reject) => {
      wx.request({
        ...options,
        success: (res) => {
          // coding...
        },
        fail: (err) => {
          // coding...
        },
        complete: () => {
          // 接口调用完成后隐藏 loding
          // wx.hideLoading()

+           // 每次请求结束后,从队列中删除一个请求标识
+           this.queue.pop()
+
+           // 如果队列已经清空,在往队列中添加一个标识
+           this.queue.length === 0 && this.queue.push('request')

+           // 等所有的任务执行完以后,经过 100 毫秒
+           // 将最后一个 request 清除,然后隐藏 loading
+           this.timerId = setTimeout(() => {
+             this.queue.pop()
+             this.queue.length === 0 && wx.hideLoading()
+           }, 100)
        }
      })
    })
  }

  // 封装快捷请求方法
  // coding...

  // 封装拦截器
  // coding...
}

// coding...

export default instance

12. 请求封装-控制 loading 显示

思路分析:

在我们封装的网络请求文件中,通过 wx.showLoading 默认显示了 loading 效果

但是在实际开发中,有的接口可能不需要显示 loading 效果,或者开发者希望自己来控制 loading 的样式与交互,那么就需要关闭默认 loading 效果。

这时候我们就需要一个开关来控制 loading 显示。

  1. 类内部设置默认请求参数 isLoading 属性,默认值是 true,在类内部根据 isLoading 属性做判断即可
  2. 某个接口不需要显示 loading 效果,可以在发送请求的时候,可以新增请求配置 isLoading 设置为 false
  3. 整个项目都不需要显示loading 效果,可以在实例化的时候,传入 isLoading 配置为 false

实现步骤:

  1. 在 WxRequest 类的默认请求配置项中,设置 isLoading 默认值为 true,显示 loading

    js
    class WxRequest {
      // 初始化默认的请求属性
      defaults = {
        url: '', // 开发者服务器接口地址
        data: null, // 请求参数
        header: {}, // 设置请求的 header
        timeout: 60000, // 超时时间
        method: 'GET', // 请求方式
    +    isLoading: true // 是否显示 loading 提示框
      }
    
      // code...
    }
  2. 在进行实例化的时候,可以配置 isLoading 配置为 false,隐藏 loading

    js
    // 对 WxRequest 进行实例化
    const instance = new WxRequest({
      baseURL: 'https://gmall-prod.atguigu.cn/mall-api',
    +   isLoading: false // 隐藏 loading
    })
  3. 在发送网络请求时候,传入请求配置 isLoading 配置为 false,隐藏 loading

    js
    async func() {
    +  // 请求配置 isLoading 配置为 false,隐藏 loading
    +  await instance.get('/index/findCategory1', null, { isLoading: true })
    }
  4. wx-request 内部代码实现

    js
    // 创建 WxRequest 类,采用类的方式进行封装会让方法更具有复用性,也可以方便进行添加新的属性和方法
    
    class WxRequest {
      // 初始化默认的请求属性
      defaults = {
        url: '', // 开发者服务器接口地址
        data: null, // 请求参数
        header: {}, // 设置请求的 header
        timeout: 60000, // 超时时间
        method: 'GET', // 请求方式
    +     isLoading: true // 是否显示 loading 提示框
      }
    
      constructor(params = {}) {
        // coding...
      }
    
      request(options) {
        // coding...
    
    +     // 发送请求之前添加 loding
    +     if (options.isLoading) {
    +       this.queue.length === 0 && wx.showLoading()
    +       // 然后想队列中添加 request 标识,代表需要发送一次新请求
    +       this.queue.push('request')
    +     }
    
        // 请求之前,触发请求拦截器
        // 如果存在请求拦截器,则触发请求拦截器
        if (this.interceptors.request) {
          options = this.interceptors.request(options)
        }
    
        // 使用 Promise 封装异步请求
        return new Promise((resolve, reject) => {
          // 使用 wx.request 发起请求
          wx.request({
            ...options,
    
            // 接口调用成功的回调函数
            success: (res) => {
              // coding...
            },
    
            // 接口调用失败的回调函数
            fail: (err) => {
              // coding...
            },
    
            complete: () => {
              // 接口调用完成后隐藏 loding
              // wx.hideLoading()
    
     +          if (!options.isLoading) return
    
              // 每次请求结束后,从队列中删除一个请求标识
              this.queue.pop()
    
              // 如果队列已经清空,在往队列中添加一个标识
              this.queue.length === 0 && this.queue.push('request')
    
              // 等所有的任务执行完以后,经过 100 毫秒
              // 将最后一个 request 清除,然后隐藏 loading
              this.timerId = setTimeout(() => {
                this.queue.pop()
                this.queue.length === 0 && wx.hideLoading()
              }, 100)
            }
          })
        })
      }
    
      // coding...
    }

单个调用关闭loading

整个项目都不使用loading

其中有一个想用loading

13. 请求封装-封装 uploadFile

思路分析:

wx.uploadFile 也是我们在开发中常用的一个 API,用来将本地资源上传到服务器。

例如:在获取到微信头像以后,将微信头像上传到公司服务器。

js
wx.uploadFile({
  url: "", // 必填项,开发者服务器地址
  filePath: "", // 必填项,要上传文件资源的路径 (本地路径)
  name: "", // 必填项,文件对应的 key,开发者在服务端可以通过这个 key 获取文件的二进制内容
});

在了解了 API 以后,我们直接对 wx.uploadFile 进行封装即可。

首先在 WxRequest 类内部创建 upload 实例方法,实例方法接收四个属性:

js
/**
* @description 文件上传接口封装
* @param { string } url 文件上传地址
* @param { string } filePath 要上传文件资源的路径
* @param { string } name 文件对应的 key
* @param { string } config 其他配置项
* @returns
*/
upload(url, filePath, name, config = {}) {
  return this.request(
    Object.assign({ url, filePath, name, method: 'UPLOAD' }, config)
  )
}

这时候我们需要在 request 实例方法中,对 method 进行判断,如果是 UPLOAD,则调用 wx.uploadFile 上传API

js
// request 实例方法接收一个对象类型的参数
// 属性值和 wx.request 方法调用时传递的参数保持一致
request(options) {

  // coding...

  // 需要使用 Promise 封装 wx.request,处理异步请求
  return new Promise((resolve, reject) => {
+     if (options.method === 'UPLOAD') {
+       wx.uploadFile({
+         ...options,
+
+         success: (res) => {
+           // 将服务器响应的数据通过 JSON.parse 转换为 JS 对象
+           res.data = JSON.parse(res.data)
+
+           const mergeRes = Object.assign({}, res, {
+             config: options,
+             isSuccess: true
+           })
+
+           resolve(this.interceptors.response(mergeRes))
+         },
+
+         fail: (err) => {
+           const mergeErr = Object.assign({}, err, {
+             config: options,
+             isSuccess: true
+           })
+
+           reject(this.interceptors.response(mergeErr))
+         },
+
+         complete: () => {
+           this.queue.pop()
+
+           this.queue.length === 0 && wx.hideLoading()
+         }
+       })
    } else {
      wx.request({
        // coding...
      })
    }
  })
}

落地代码:

➡️ utils/request.js

js
// request 实例方法接收一个对象类型的参数
// 属性值和 wx.request 方法调用时传递的参数保持一致
request(options) {

  // coding...

  // 需要使用 Promise 封装 wx.request,处理异步请求
  return new Promise((resolve, reject) => {
+     if (options.method === 'UPLOAD') {
+       wx.uploadFile({
+         ...options,
+
+         success: (res) => {
+           // 将服务器响应的数据通过 JSON.parse 转换为 JS 对象
+           res.data = JSON.parse(res.data)
+
+           const mergeRes = Object.assign({}, res, {
+             config: options,
+             isSuccess: true
+           })
+
+           resolve(this.interceptors.response(mergeRes))
+         },
+
+         fail: (err) => {
+           const mergeErr = Object.assign({}, err, {
+             config: options,
+             isSuccess: true
+           })
+
+           reject(this.interceptors.response(mergeErr))
+         },
+
+         complete: () => {
+           this.queue.pop()
+
+           this.queue.length === 0 && wx.hideLoading()
+         }
+       })
    } else {
      wx.request({
        // coding...
      })
    }
  })
}

test/test.js

js
Page({
  /**
   * 页面的初始数据
   */
  data: {
    avatarUrl: '../../assets/Jerry.png'
  },

  // 获取微信头像
  async chooseavatar(event) {

    // 目前获取的微信头像是临时路径
    // 临时路径是有失效时间的,在实际开发中,需要将临时路径上传到公司的服务器
    const { avatarUrl } = event.detail

    // 调用  upload 方法发送请求,将临时路径上传到公司的服务器
    const res = await instance.upload(
      '/fileUpload',
      event.detail.avatarUrl,
      'file'
    )

    // 将返回的数据赋值给 data 中的数据
    this.setData({
      avatarUrl: res.data
    })
  },

  // coding...
}

不封装写法:

wx.uploadFile({
  //  要上传的文件的路径
   filePath: avatarUrl,
  //  文件对应的key,服务器需要根据key来获取文件的二进制信息
   name: 'file',
//开发者服务器地址,接口地址
   url: 'https://gmall-prod.atguigu.cn/mall-api//fileUpload',
   //成功的回调函数
   success:(res) =>{
    // 服务器返回的数据是json字符串,在使用的时候,需要进行转换  JSON.parse转换
    res.data = JSON.parse(res.data)
    // 将返回的数据赋值给 data 中的数据
  this.setData({
    avatarUrl:res.data.data
  })
 }
  // 调用  upload 方法发送请求,将临时路径上传到公司的服务器
  // const res = await instance.upload(
  //   '/fileUpload',
  //   event.detail.avatarUrl,
  //   'file'
  // )


  })

14. 请求封装-使用 npm 包发送请求

思路分析:

封装的网络请求模块发布到了 npm ,如果你在学习网络请求模块封装时感觉比较吃力,可以先使用 npm 包实现功能。

shell
npm install mina-request

📌 构建 npm:

​ 安装包后,需要在微信开发者工具中进行 npm 构建,点击 工具 ➡️ 构建 npm

其余步骤参考文档进行开发即可:

mina-request 地址

落地代码:

js
import WxRequest from "./request";
import { getStorage, clearStorage } from "./storage";
import { modal, toast } from "./extendApi";
import { env } from "./env";

// 对 WxRequest 进行实例化
const instance = new WxRequest({
  // baseURL: 'https://gmall-prod.atguigu.cn/mall-api',
  baseURL: env.baseURL,
  timeout: 15000,
  isLoading: false,
});

// 配置请求拦截器
instance.interceptors.request = (config) => {
  // 在请求发送之前做点什么……

  // 在发送请求之前,需要先判断本地是否存在访问令牌 token
  const token = getStorage("token");

  // 如果存在 token,就需要在请求头中添加 token 字段
  if (token) {
    config.header["token"] = token;
  }

  return config;
};

// 配置响应拦截器
instance.interceptors.response = async (response) => {
  // 从 response 中解构 isSuccess
  const { isSuccess, data } = response;

  // 如果 isSuccess 为 false,说明执行了 fail 回调函数
  // 这时候就说明网络异常,需要给用户提示网络异常
  if (!isSuccess) {
    wx.showToast({
      title: "网络异常请重试",
      icon: "error",
    });

    return response;
  }

  // 判断服务器响应的业务状态码
  switch (data.code) {
    // 如果后端返回的业务状态码等于 200,说请求成功,服务器成功响应了数据
    case 200:
      // 对服务器响应数据做点什么……
      return data;

    // 如果返回的业务状态码等于 208,说明 没有 token,或者 token 失效
    // 就需要让用户登录或者重新登录
    case 208:
      const res = await modal({
        content: "鉴权失败,请重新登录",
        showCancel: false, // 不显示取消按钮
      });

      if (res) {
        // 清除之前失效的 token ,同时要清除本地存储的全部信息
        clearStorage();

        wx.navigateTo({
          url: "/pages/login/login",
        });
      }

      return Promise.reject(response);

    default:
      toast({
        title: "程序出现异常,请联系客服或稍后重试",
      });

      return Promise.reject(response);
  }
};

// 将 WxRequest 实例进行暴露出去,方便在其他文件中进行使用
export default instance;

15. 环境变量-小程序设置环境变量

知识点:

在实际开发中,不同的开发环境,调用的接口地址是不一样的。

例如:开发环境需要调用开发版的接口地址,生产环境需要调用正式版的接口地址

这时候,我们就可以使用小程序提供了 wx.getAccountInfoSync() 接口,用来获取当前账号信息,在账号信息中包含着 小程序 当前环境版本。

环境版本合法值
开发版develop
体验版trial
正式版release

落地代码:

app.js

js
// 获取当前帐号信息
const accountInfo = wx.getAccountInfoSync();

// 获取小程序项目的 appId
console.log(accountInfo.miniProgram.appId);
// 获取小程序 当前环境版本
console.log(accountInfo.miniProgram.envVersion);

根据环境的不同,我们给 env 变量设置不同的请求基准路径 baseURL 然后将 env环境变量导出

js
// 获取 小程序帐号信息
const { miniProgram } = wx.getAccountInfoSync();

// 获取小程序当前开发环境
// develop 开发版, trial 体验版, release 正式版
const { envVersion } = miniProgram;

let env = {
  baseURL: "https://gmall-prod.atguigu.cn",
};

switch (envVersion) {
  case "develop":
    env.baseURL = "https://gmall-prod.atguigu.cn";
    break;

  case "trial":
    env.baseURL = "https://gmall-prod.atguigu.cn";
    break;

  case "release":
    env.baseURL = "https://gmall-prod.atguigu.cn";
    break;

    defaulr;
}

export { env };

16. 接口调用方式说明

思路分析:

在开发中,我们会将所有的网络请求方法放置在 api 目录下统一管理,然后按照模块功能来划分成对应的文件,在文件中将接口封装成一个个方法单独导出,例如:

js
// 导入封装的网络请求工具 http.js
import http from "../utils/http";

/**
 * @description 获取轮播图数据
 * @returns Promise
 */
export const reqBannerData = () => http.get("/index/findBanner");

这样做的有以下几点好处:

  1. 易于维护:一个文件就是一个模块,一个方法就是一个功能,清晰明了,查找方便
  2. 便于复用:哪里使用,哪里导入,可以在任何一个业务组件中导入需要的方法
  3. 团队合作:分工合作

落地代码:

js
// 导入封装的网络请求工具 http.js
import http from "../utils/http";

/**
 * @description 获取轮播图数据
 * @returns Promise
 */
export const reqSwiperData = () => http.get("/mall-api/index/findBanner");
js
// 导入接口 API
import { reqSwiperData } from '../../api/index'

Page({

  // 页面数据
  data: {
    swiperList: []
  },

  // 小程序页面加载时执行
  onLoad () {
    // 调用获取首页数据的方法
    getHomeList()
  }

  // 获取首页数据
  async getHomeList() {
    // 获取轮播图数据
    const res = await reqSwiperData()

    console.log(res)
  }
})

项目首页

到目前为止,我们已经完成了小程序项目的初始化的操作,例如:创建小程序项目、组织目录结构、封装了通用模块和网络请求模块,从这一节开始,我们开始做小程序的功能开发,首先要完成的是小程序的首页功能。

01. 获取首页数据

思路分析:

我们先来分析下在项目的首页我们需要完成的一些功能

  1. 轮播图区域
  2. 商品导航区域
  3. 活动宣传区域
  4. 猜你喜欢区域
  5. 人气推荐区域

在实现这些功能之前,我们需要先获取数据,在获取数据以后,然后进行页面的渲染,同时完成进行功能的开发。

因为需要同时获取 5 个接口的数据,所以我们使用并发请求来实现。这样能够提升页面的渲染速度。

实现步骤:

  1. 封装接口请求函数,可以一个个封装,也可以直接使用 all 方法进行封装
  2. 在页面 .js 文件中导入封装的接口 API 函数
  3. onLoad 钩子函数中调用方法,获取首页轮播图数据

落地代码:

➡️ api/index.js: 准备接口 API

js
import http from "../utils/http";

/**
 * 通过并发请求获取首页的数据
 */
export const reqIndexData = () => {
  return instance.all(
    http.get("/index/findBanner"),
    http.get("/index/findCategory1"),
    http.get("/index/advertisement"),
    http.get("/index/findListGoods"),
    http.get("/index/findRecommendGoods"),
  );
};

➡️ page/index/index.js

js
// 导入接口 API
import { reqIndexData } from "../../api/index";

Page({
  // 初始化数据
  data: {
    bannerList: [], // 轮播图数据
    categoryList: [], // 分类数据
    activeList: [], // 活动广告
    hotList: [], // 人气推荐
    guessList: [], // 猜你喜欢
  },

  // 获取首页数据
  async getIndexData() {
    // 调用接口,获取首页数据
    // 数组每一项是 Promise 产生的结果,并且是按照顺序返回。
    const res = await reqIndexData();

    // 在获取数据以后,对数据进行赋值
    this.setData({
      bannerList: res.data[0],
      categoryList: res.data[1],
      activeList: res.data[2],
      hotList: res.data[3],
      guessList: res.data[4],
    });
  },

  // 监听页面加载
  onLoad() {
    // 调用获取首页数据的回调
    this.getIndexData();
  },
});

image-20241001152358060

02. 分析轮播图区域并渲染

分析轮播图结构:

轮播图演示

轮播图区域采用组件化方式开发,我们在 index 目录下新建 banner 文件夹,里面存放轮播图组件。

index/index.json 文件中导入组件,然后将组件当成标签进行使用

json
{
  "usingComponents": {
    "banner": "./banner/banner"
  }
}
html
<!-- 轮播图区域 -->
<banner />
  1. swiperswiper-itemnavigatorimage 组件实现页面结构的搭建
  2. block 渲染数组,实现列表渲染
  3. 使用 flex 布局实现了页面样式的绘制

另外需要注意的是:轮播图面板指示点不支持自定义,所以只能页面结构的方式,实现轮播图的面板指示点功能

html
<!--pages/index/banner/banner.wxml-->

<!-- 轮播图 -->
<view class="swiper-box">
  <!-- swiper 滑块视图容器 -->
  <swiper
    autoplay
    class="swiper"
    indicator-active-color="#FF734C"
    interval="2000"
    duration="1000"
    indicator-color="rgba(0, 0, 0, .3)"
  >
    <!-- 使用 block 标签实现通过数组进行列表渲染 -->
    <block wx:for="{{ bannerList }}" wx:key="index">
      <!-- swiper-item 单个滑块视图容器 -->
      <swiper-item class="swiper-item">
        <!-- 通过 navigator 组件跳转的链接 -->
        <navigator class="navigator" url="/pages/goods/detail/detail?goodsId=id">
          <image class="img" src="{{ item }}"></image>
        </navigator>
      </swiper-item>
    </block>
  </swiper>

  <!-- 轮播图的面板指示点,因为面板指示点不支持,所以我们只能通过自定义结构的方式 -->
  <view class="indicator">
    <!-- active 类名:当前被激活的面板指示点颜色 -->
    <!-- circle 类名:默认的面板指示点颜色 -->
    <text wx:for="{{bannerList.length}}" wx:key="id" class="{{ 'active rectangle' }}"></text>
  </view>
</view>

渲染页面结构:

➡️ page/index/index.js

html
<!-- 轮播图区域 -->
+ <banner bannerList="{{ bannerList }}" />

➡️ page/index/banner/banner.wxml

html
<!-- 使用 block 标签实现通过数组进行列表渲染 -->
<block wx:for="{{ bannerList }}" wx:key="index">
  <!-- swiper-item 单个滑块视图容器 -->
  <swiper-item class="swiper-item">
    <!-- 通过 navigator 组件跳转的链接 -->
    <navigator class="navigator" + url="/pages/goods/detail/detail?goodsId={{item.id}}">
      + <image class="img" src="{{ item.imageUrl }}"></image>
    </navigator>
  </swiper-item>
</block>

03. 实现轮播图和指示点的联动

思路分析:

当轮播图进行切换时,面板指示点也要跟随着进行切换,如上图。

轮播图和指示点进行联动,当切换到第二张轮播图时,第二个面板指示点高亮,如果想实现这种一一对应的关系,需要借助索引

首先在 data 中初始化状态 activeIndex 默认为 0,代表第一个高亮,也是用来接收切换后的轮播图索引

然后使用 swiper 组件的 change 事件,监听轮播图是否发生改变,如果改变,则获取到轮播图的索引,赋值到data中,

通过 activeIndex 对小圆点进行动态的渲染

实现思路:

  1. data 中初始化状态 activeIndex 默认为 0
  2. swiper 绑定 bindchange 事件,监听轮播图是否切换,将切换后轮播图的索引赋值给activeIndex
  3. 利用 activeIndex 对小圆点进行动态的渲染

落地代码:

➡️ pages/index/banner/banner.js

js
// pages/index/banner/banner.js

Component({
  /**
   * 组件的属性列表
   */
  properties: {
    // 轮播图数据
    bannerList: {
      type: Array,
      value: []
    }
  },

  /**
   * 组件的初始数据
   */
  data: {
+     activeIndex: 0 // 被激活的轮播图索引,默认是 0
  },

  /**
   * 组件的方法列表
   */
  methods: {
+     // 获取被激活的轮播图索引
+     getSwiperIndex(event) {
+       // console.log(event)
+       const { current } = event.detail
+
+       this.setData({
+         activeIndex: current
+       })
+     }
  }
})

➡️ pages/index/banner/banner.wxml

html
<!-- 轮播图 -->
<view class="swiper-box">
  <!-- swiper 滑块视图容器,用来绘制轮播图 -->
  <swiper
    autoplay
    class="swiper"
    indicator-active-color="#FF734C"
    interval="2000"
    duration="1000"
    indicator-color="rgba(0, 0, 0, .3)"
    +
    bindchange="getSwiperIndex"
  >
    <!-- 通过 block 标签对 轮播图数据 进行渲染 -->
    <block wx:for="{{ bannerList }}" wx:key="index">
      <!-- coding... -->
    </block>
  </swiper>

  <!-- 轮播图的面板指示点,因为面板指示点不支持自定义,所以我们只能通过自定义结构的方式 -->
  <view class="indicator">
    <!-- active 类名:当前被激活的面板指示点颜色 -->
    <!-- rectangle 类名:默认的面板指示点颜色 -->
    <text
      wx:for="{{bannerList.length}}"
      wx:key="id"
      +
      class="{{  index === activeIndex ? 'active rectangle' : 'rectangle' }}"
    ></text>
  </view>
</view>

04. 分析商品导航区域并渲染

商品导航结构分析

商品导航区域采用组件化方式开发,我们在 index 目录下新建 entrance 文件夹,里面存放导航分类组件。

  1. 采用 viewnavigatorimagetext 组件实现了进行页面结构的搭建
  2. 使用 flex 布局实现了页面样式的绘制
html
<!-- 导航分类 -->
<view class="nav-list">
  <!-- 一级分类导航容器 -->
  <view wx:for="{{ cateList }}" wx:key="index" class="nav-item {{ index >= 5 ? 'small' : '' }}">
    <!-- 导航链接 -->
    <navigator class="navigator-nav" url="/pages/goods/list/list?category1Id=1">
      <image class="nav-img" src="导航路径" />
      <text class="nav-text">导航名字</text>
    </navigator>
  </view>
</view>

index/index.json 文件中导入组件,然后将组件当成标签进行使用

json
{
  "usingComponents": {
    // ...
    "entrance": "./entrance/entrance"
  },
  "navigationBarTitleText": "慕尚花坊"
}
html
<!-- 导航分类 -->
<entrance />

渲染导航分类结构:

➡️ page/index/index.wxml

html
<!-- 导航分类 -->
<entrance cateList="{{ categoryList }}" />

➡️ pages/index/entrance/entrance.js

js
Component({
  // 组件的属性列表
  properties: {
    cateList: {
      type: Array,
      value: []
    }
  },

  // coding...
}

➡️ pages/index/entrance/entrance.html

html
<view class="nav-list">
  <!-- 一级分类导航容器 -->
  <view + wx:for="{{ cateList }}" wx:key="index" class="nav-item {{ index >= 5 ? 'small' : '' }}">
    <!-- 导航链接 -->
    <navigator class="navigator-nav" + url="/pages/goods/list/list?category1Id={{item.id}}">
      + <image class="nav-img" src="{{ item.imageUrl }}" /> +
      <text class="nav-text">{{ item.name }}</text>
    </navigator>
  </view>
</view>

➡️ pages/index/index.html

html
<!-- 广告区域 -->
<view class="adver">
  <view class="adver-left">
    +
    <navigator url="/pages/goods/list/list?category2Id={{ activeList[0].category2Id }}">
      + <image src="{{ activeList[0].imageUrl }}" mode="widthFix" /> +
    </navigator>
  </view>

  <view class="adver-right">
    <view>
      +
      <navigator url="/pages/goods/list/list?category2Id={{ activeList[1].category2Id }}">
        + <image src="{{ activeList[1].imageUrl }}" mode="widthFix" /> +
      </navigator>
    </view>
    <view>
      +
      <navigator url="/pages/goods/list/list?category2Id={{ activeList[2].category2Id }}">
        + <image src="{{ activeList[2].imageUrl }}" mode="widthFix" /> +
      </navigator>
    </view>
  </view>
</view>

05. 分析猜你喜欢+人气推荐并渲染

猜你喜欢和人气推荐区域结构分析

猜你喜欢和人气推荐的布局是一致的,所以将两个模块放到一起实现

商品列表在项目中属于常见的结构,商品列表区域依然采用组件化方式开发,商品列表组件由两个组件进行构成:

  1. goods-list 商品列表组件,包含者商品卡片组件,用来展示商品列表
  2. goods-card 商品卡片组件,承载某个商品的结构,用来展示单个商品

goods-list 商品列表组件包含三个结构:标题(允许用户自定义)、商品列表(商品卡片)、查看更多

html
<!--components/goods-list/goods-list.wxml-->

<!-- 商品列表组件 -->
<view class="goods_container">
  <!-- 标题 -->
  <view class="goods_title">{{title}}</view>

  <!-- 列表区域 -->
  <view class="goods_card_list">
    <goods-card></goods-card>
    <goods-card></goods-card>
    <goods-card></goods-card>
  </view>

  <!-- 查看更多 -->
  <view class="goods_more">
    <navigator
      class="goods_more_btn"
      url="/pages/goods/list/index"
      hover-class="navigator-hover"
      open-type="navigate"
    >
      查看更多
    </navigator>
  </view>
</view>

goods-card 商品卡片组件由以下几部分组成

html
<!-- 列表分类卡片 -->
<view class="goods_cart_container">
  <navigator class="navigator_nav" url="/pages/goods/detail/detail?goodsId={{goodItem.id}}">
    <!-- 商品图片 -->
    <image class="good_img" src="/static/images/floor.jpg" mode="widthFix" />

    <!-- 商品详细信息 -->
    <view class="goods_item_info">
      <!-- 商品名称 -->
      <text class="goods_item_info_name">亲爱的/情人节网红款/19枝</text>
      <!-- 商品描述 -->
      <text class="goods_item_info_promo">情人节新品情人节新品情人节新品情人节新品</text>
      <!-- 商品价格 -->
      <view class="goods_item_info_bottom">
        <view class="goods_item_info_price"> <text class="text">¥</text>399 </view>
        <view class="goods_item_info_origin_price">
          <text class="text">¥</text> 1{{goodItem.marketPrice}}
        </view>
        <!-- 加入购物车图片 -->
        <view class="goods_item_info_btn">
          <image class="goods_image" src="/static/images/buybtn.png" mode="" />
        </view>
      </view>
    </view>
  </navigator>
</view>

渲染导航分类结构:

只不过在渲染数据的时候需要将数据传递给 goods-list 商品列表组件, goods-list组件内部传递给 goods-card 商品卡片组件,goods-card 组件内部渲染数据。

➡️ page/index/index.wxml:将数据传递给 goods-list 组件

html
<!-- 商品列表 -->
<goods-list title="猜你喜欢" list="{{ guessList }}"></goods-list>
<goods-list title="人气推荐" list="{{ hotList }}"></goods-list>

➡️ components/goods-list/goods-list.js:接收首页传递的 list 数据

js
// components/goods-list/index.js

Component({
  // 组件的属性列表
  properties: {
    // 列表标题
    title: {
      type: String,
      value: '',
    },

    // 传递的列表数据
    list: {
      type: Array,
      value: []
    }
  }

  // coding...
}

➡️ components/goods-list/goods-list.wxml:遍历 goods-item 组件,并将数据传递给 goods-item

html
<!-- 商品列表组件 -->
+
<view class="goods_container" wx:if="{{ list.length }}">
  <!-- 标题 -->
  + <view class="goods_title">{{title}}</view>

  <!-- 列表区域 -->
  <view class="goods_card_list">
    + <goods-card wx:for="{{ list }}" wx:key="id" goodItem="{{ item }}"></goods-card>
  </view>

  <!-- 查看更多 -->
  <!-- coding -->
</view>

➡️ components/goods-list/goods-item.js:将数据传递给 goods-item 组件

js
// components/goods-card/goods-card.js

Component({

  // 组件的属性列表
  properties: {
    // 每一项商品的数据
    goodItem: {
      type: Object,
      value: {}
    }
  }

  // coding...
}

➡️ components/goods-list/goods-item.wxml:将数据传递给 goods-item 组件

html
<!-- 列表分类卡片 -->
<view class="goods_cart_container">
  <navigator class="navigator_nav" url="/pages/goods/detail/detail?goodsId={{goodItem.id}}">
    <!-- 商品图片 -->
    <image class="good_img" src="{{ goodItem.imageUrl }}" mode="widthFix" />

    <!-- 商品详细信息 -->
    <view class="goods_item_info">
      <!-- 商品名称 -->
      <text class="goods_item_info_name">{{ goodItem.name }}</text>
      <!-- 商品描述 -->
      <text class="goods_item_info_promo">{{ goodItem.floralLanguage }}</text>
      <!-- 商品价格 -->
      <view class="goods_item_info_bottom">
        <view class="goods_item_info_price"> <text class="text">¥</text>{{ goodItem.price }} </view>
        <view class="goods_item_info_origin_price">
          <text class="text">¥</text> {{goodItem.marketPrice}}
        </view>
        <!-- 加入购物车图片 -->
        <view class="goods_item_info_btn">
          <image class="goods_image" src="/static/images/buybtn.png" mode="" />
        </view>
      </view>
    </view>
  </navigator>
</view>

06. 首页骨架屏组件

思路分析:

骨架屏是页面的一个空白版本,开发者会使用 CSS 绘制一些灰色的区块,将页面内容大致勾勒出轮廓。

通常会在页面完全渲染之前,将骨架屏代码进行展示,待数据加载完成后,再替换成真实的内容。

骨架屏的设计旨在优化用户体验。

在进行项目开发时,我们需要手工维护骨架屏的代码,当业务变更时,同样需要对骨架屏代码进行调整。

为了方便开发者进行骨架屏的绘制,开发者工具提供了自动生成骨架屏代码的能力。

使用步骤:

  1. 使用微信开发者工具为当前正在预览的页面生成骨架屏代码,工具入口位于模拟器面板右下角三点处

  2. 点击生成骨架屏,将有弹窗提示是否允许插入骨架屏代码。

    确定后将在当前页面同级目录下生成 page.skeleton.wxmlpage.skeleton.wxss 两个文件,分别为骨架屏代码的模板和样式。

  3. 骨架屏代码通过小程序模板(template)的方式引入 以 pages/index/index 页面为例,引入方式如下

    html
    <!-- pages/index/index.wxml 引入模板 -->
    
    <!-- 引入骨架屏 -->
    <import src="index.skeleton.wxml" />
    <!-- 使用骨架屏 -->
    <template is="skeleton" wx:if="{{loading}}" data="{{}}" />
    
    <!-- 页面模板 -->
    <view class="container">
      <!-- coding... -->
    </view>
    css
    /* pages/index/index.wxss 中引入样式 */
    @import "./skeleton/index.skeleton.wxss";
  4. data 中声明 loading 的状态为 true 当数据请求完以后将 loading 的状态为 false

    js
    // coding...
    
    Page({
      data: {
        bannerList: [], // 轮播图数据
        categoryList: [], // 一级分类数据
        activeIndex: 0, // 被激活的轮播图索引
        topList: [], // 人气推荐
        guessList: [], // 猜你喜欢
    +     loading: true // 数据是否在加载完毕
      },
    
      // 页面加载时执行
      async onLoad() {
        // coding...
      },
    
      // 获取页面的数据
      async getPageData() {
    
        // coding...
    
        // 将返回的数据赋值给 data 中的状态
        this.setData({
          bannerList,
          categoryList,
          topList,
          guessList,
    +       loading: false
        })
      },
    
      // coding...
    })
  5. 查看页面效果

商品分类

商品分类是一个单独的 tabBar 页面,当点击分类 tabBar 的时候就能够进入商品分类页面。

在商品分类页面我们主要实现三个功能:

  1. 一级分类的渲染
  2. 一级分类的切换
  3. 二级分类的渲染

01. 获取商品分类数据

思路分析:

在这一节,需要调用接口获取分类的数据

首先熟悉接口文档:获取分类层级数据

在熟悉了接口文档以后,根据接口文档封装接口 API 函数,然后在页面调用 API 函数获取分类的数据,在获取到数据以后,使用后端返回的数据对一级、二级分类进行渲染。

实现步骤:

  1. 在项目根目录下 api 目录下新建 category.js 文件,用来管理分类页面接口请求
  2. 在该文件中导入封装的网络请求模块,根据接口文档,创建获取分类数据的 API 函数 reqCategoryData
  3. /pages/category/category.js 中导入封装好的获取分类数据的 API 函数
  4. 页面数据需要在页面加载的时候进行调用,因此需要在 onLoad 钩子函数中调用 reqCategoryData 方法
  5. 在获取到数据以后,使用后端返回的数据对页面进行渲染

落地代码:

➡️ api/category.js

js
// 导入封装的网络请求模块实例
import http from "../utils/http";

/**
 * @description 获取商品分类的数据
 * @returns Promise
 */
export const reqCategoryData = () => {
  return http.get("/index/findCategoryTree");
};

➡️ page/category/category.js

js
// 导入封装的接口 API
import { reqCategoryData } from "../../api/category";

Page({
  /**
   * 页面的初始数据
   */
  data: {
    categoryList: [], // 分类数据列表
  },

  // 生命周期函数--监听页面加载
  onLoad(options) {
    // 获取页面中使用的
    this.getCategoryData();
  },

  // 获取页面初始化时,页面中使用的数据
  async getCategoryData() {
    // 调用接口获取分类的数据
    const res = await reqCategoryData();

    this.setData({
      categoryList: res.data,
    });
  },

  // 导航分类点击事件
  // coding...
});

02. 渲染一级分类并实现切换功能

思路分析:

产品需求:

当进入分类页面的时候,第一个一级分类默认是高亮选中的状态

当点击任意的一级分类以后,对应的一级分类需要高亮选中 (active 类名),其余的一级分类取消高亮选中

实现思路:

我们可以先初始化数据 activeIndex, 代表被激活的那一项的索引,默认值为 0

给一级分类绑定点击事件,当点击某个一级分类时,将对应分类的索引赋值给 activeIndex

这时候在模板中,可以拿遍历后一级分类的对应的索引 indexactiveIndex 来进行对比

如果值一致,说明是当前分类被激活,添加 active 类名即可

实现步骤:

  1. data 中初始化变量 activeIndex,初始值为 0
  2. 给一级分类绑定点击事件 updateActive,同时自定义一个自定义属性 data-index 值为索引 index
  3. 在事件处理程序updateActive中,将 index 赋值给 activeIndex
  4. 在模板中进行 indexactiveIndex 的对比

落地代码:

➡️ page/category/category.html

html
<!-- 左侧的滚动视图区域 -->
<scroll-view class="left-view" scroll-y>
  <view
    +
    wx:for="{{ categoryList }}"
    +
    wx:key="id"
    +
    bindtap="updateActive"
    +
    data-index="{{ index }}"
    +
    class="left-view-item {{ activeIndex === index ? 'active' : '' }}"
  >
    {{ item.name }}
  </view>
</scroll-view>

➡️ page/category/category.js

js
Page({

  // 页面的初始数据
  data: {
    categoryList: [], // 分类数据列表
+     activeIndex: 0, // 点击高亮导航id
  },

+   // 导航分类点击事件
+   updateActive(e) {
+     this.setData({
+       activeIndex: e.currentTarget.dataset.index
+     })
+   }


  // coding...
}

03. 获取 & 渲染二级分类数据

思路分析:

再次分析后端接口返回的数据 (如下图),我们可以发现,在一级分类下存在 children 字段, children 字段中的数据则是一级分类对应的二级分类的数据,当我们访问一级分类时,只需要将一级分类对应的二级分类拿出来进行渲染即可。

那么,当我们访问一级分类时,如何拿到一级分类对应的二级分类数据呢 ❓

答案:索引 ❗,点击一级分类时,已经获取到索引 activeIndex,利用 activeIndex 获取对应二级分类数据即可

例如:当点击 鲜花玫瑰 时,该一级分类索引为 1,对应着索引为 1 的数据,即可取出对应 children 中的数据

实现步骤:

  1. 在模板中利用 activeIndex 获取到对应二级分类的数据
  2. 对页面进行渲染即可

落地代码:

html
<!-- 右侧的滚动视图区域 -->
<scroll-view class="right-view" scroll-y enable-flex="true">
  <view class="right-view-item" wx:for="{{ categoryList[activeIndex].children }}" wx:key="id">
    <navigator class="navigator" url="/pages/goods/list/list?category2Id={{item.id}}">
      <image class="" src="{{ item.imageUrl }}"></image>
      <text class="goods_item_name">{{ item.name }}</text>
    </navigator>
  </view>
</scroll-view>

框架拓展

01. mobx-miniprogram

1.1 mobx-miniprogram 介绍

目前已经学习了 6 种小程序页面、组件间的数据通信方案,分别是:

  1. 数据绑定:properties
  2. 获取组件实例:this.selectComponent()
  3. 事件绑定:this.triggerEvent()
  4. 获取应用实例:getApp()
  5. 页面间通信:EventChannel
  6. 事件总线:pubsub-js

在中小型项目中,使用这些数据通信方式已经能够满足我们项目的需求。

但是随着项目的业务逻辑越来越复杂,组件和页面间通信就会变的非常复杂。例如:有些状态需要在多个页面间进行同步使用,一个地方发生变更,所有使用的地方都需要发生改变,这时候如果使用前面的数据通信方案进行传递数据,给管理和维护将存在很大的问题。

为了方便进行页面、组件之间数据的传递,小程序官方提供了一个扩展工具库: mobx-miniprogram

mobx-miniprogram 是针对微信小程序开发的一个简单、高效、轻量级状态管理库,它基于Mobx状态管理框架实现。

使用 mobx-miniprogram 定义管理的状态是响应式的,当状态一旦它改变,所有关联组件都会自动更新相对应的数据

通过该扩展工具库,开发者可以很方便地在小程序中全局共享的状态,并自动更新视图组件,从而提升小程序的开发效率

需要注意:在使用 mobx-miniprogram 需要安装两个包:mobx-miniprogrammobx-miniprogram-bindings

  1. mobx-miniprogram 的作用:创建 Store 对象,用于存储应用的数据
  2. mobx-miniprogram-bindings 的作用:将状态和组件、页面进行绑定关联,从而在组件和页面中操作数据
shell
npm install mobx-miniprogram mobx-miniprogram-bindings

官方文档:

  1. mobx-miniprogram 官方文档
  2. mobx-miniprogram-bindings 官方文档

1.2 创建 Store 对象

如果需要创建 Store 对象需要使用 mobx-miniprogram ,因此需要先熟悉 mobx-miniprogram 三个核心概念:

  1. observable:用于创建一个被监测的对象,对象的属性就是应用的状态(state),这些状态会被转换成响应式数据。
  2. action:用于修改状态(state)的方法,需要使用 action 函数显式的声明创建。
  3. computed:根据已有状态(state)生成的新值。计算属性是一个方法,在方法前面必须加上 get 修饰符

mobx-miniprogram 详细的使用步骤如下:

  1. 在项目的根目录下创建 store 文件夹,然后在该文件夹下新建 index.js

  2. /store/index.js 导入 observableaction 方法

    js
    import { observable, action } from "mobx-miniprogram";
  3. 使用 observable 方法需要接受一个 store 对象,存储应用的状态

    js
    // observable:用于创建一个被监测的对象,对象的属性就是应用的状态(state),这些状态会被转换成响应式数据。
    // action:用于显式的声明创建更新 state 状态的方法
    import { observable, action } from "mobx-miniprogram";
    
    // 使用 observable 创建一个被监测的对象
    export const numStore = observable({
      // 创建应用状态
      numA: 1,
      numB: 2,
    
      // 使用 action 更新 numA 以及 numB
      update: action(function () {
        this.numA += 1;
        this.numB += 1;
      }),
    
      // 计算属性,使用 get 修饰符,
      get sum() {
        return this.numA + this.numB;
      },
    });

1.3 在组件中使用数据

如果需要 Page 或者Component中对共享的数据进行读取、更新操作,需要使用 mobx-miniprogram-bindings

mobx-miniprogram-bindings 的作用就是将 Store 和 页面或组件进行绑定关联

如果需要在组件中使用状态,需要 mobx-miniprogram-bindings 库中导入 ComponentWithStore 方法

在使用时:,原本组件配置项也需要写到该方法中

在替换以后,就会新增一个 storeBindings 配置项,配置项常用的属性有以下三个:

  1. store: 指定要绑定的 Store 对象
  2. fields: 指定需要绑定的 data 字段
  3. actions: 指定需要映射的 actions 方法

📌 注意事项:

在store对象中引入数据和方法以后

导入的数据会同步到组件的 data 中

导入的方法会同步到组件的 methods 中

js
// components/custom01/custom01.js
import { ComponentWithStore } from "mobx-miniprogram-bindings";
import { numStore } from "../../stores/numstore";

ComponentWithStore({
  data: {
    someData: "...",
  },
  storeBindings: {
    store: numStore,
    fields: ["numA", "numB", "sum"],
    actions: ["update"],
  },
});

1.4 在页面中使用数据-方式1

Component 方法用于创建自定义组件。

**小程序的页面也可以视为自定义组件,**因此页面也可以使用 Component 方法进行构建,从而实现复杂的页面逻辑开发。

如果我们使用了 Component 方法来构建页面,那么页面中如果想使用 Store 中的数据,使用方式和组件的使用方式是一样的

  1. mobx-miniprogram-bindings 库中导入 ComponentWithStore 方法
  2. Component 方法替换成 ComponentWithStore 方法
  3. 然后配置 storeBindingsStore 中映射数据和方法即可
js
// index/index.js
import { ComponentWithStore } from "mobx-miniprogram-bindings";
import { numStore } from "../../stores/numstore";

ComponentWithStore({
  data: {
    someData: "...",
  },
  storeBindings: {
    store: numStore,
    fields: ["numA", "numB", "sum"],
    actions: ["update"],
  },
});

1.5 在页面中使用数据-方式2

在上一节,我们使用了 Component 方法构建页面,然后使用 ComponentWithStore 方法让页面和 Store 建立了关联

如果不想使用 Component 方法构建页面。这时候需要使用 mobx-miniprogram-bindings 提供的 BehaviorWithStore 方法来和 Store 建立关联。

小程序的 behavior 方法是一种代码复用的方式,可以将一些通用的逻辑和方法提取出来,然后在多个组件中复用,从而减少代码冗余,提高代码的可维护性。

使用方式如下:

  1. 新建 behavior 文件,从 mobx-miniprogram-bindings 库中导入 BehaviorWithStore 方法
  2. BehaviorWithStore 方法中配置 storeBindings 配置项从 Store 中映射数据和方法
  3. Page 方法中导入创建的 behavior ,然后配置 behavior 属性,并使用导入的 behavior
js
// behavior.js

import { BehaviorWithStore } from "mobx-miniprogram-bindings";
import { numStore } from "../../stores/numstore";

export const indexBehavior = BehaviorWithStore({
  storeBindings: {
    store: numStore,
    fields: ["numA", "numB", "sum"],
    actions: ["update"],
  },
});
js
// index.js

import { indexBehavior } from "./behavior";

Page({
  //注册后才能使用
  behaviors: [indexBehavior],

  // 其他配置项
});

1.6 fields、actions 对象写法

fieldsactions 有两种写法:数组 或者 对象。

如果 fields 写成对象方式,有两种写法:

  1. 映射形式:指定 data 中哪些字段来源于 store 以及它们在 store 中对应的名字。
    • 例如 { a: 'numA', b: 'numB' }
  2. 函数形式:指定 data 中每个字段的计算方法
    • 例如 { a: () => store.numA, b: () => anotherStore.numB }

如果 actions 写成对象方式,只有两种写法:

  1. 映射形式:指定模板中调用的哪些方法来源于 store 以及它们在 store 中对应的名字。
    • 例如 { buttonTap: 'update' }
js
import { ComponentWithStore } from "mobx-miniprogram-bindings";
import { numStore } from "../../stores/numstore";

ComponentWithStore({
  data: {
    someData: "...",
  },
  storeBindings: {
    store: numStore,
    fields: {
      // 使用函数方式获取 Store 中的数据
      a: () => store.numA,
      b: () => store.numB,

      // 使用映射形式获取 Store 中的数据,值为数据在 store 中对应的名字
      total: "sub",
    },

    // 使用映射形式获取 Store 中的 action 名字
    actions: {
      // key 自定义,为当前组件中调用的方法
      // 值为 store 中对应的 action 名字
      updateData: "update",
    },
  },
});

1.7 绑定多个 store 以及命名空间

在实际开发中,一个页面或者组件可能会绑定多个 Store ,这时候我们可以将 storeBindings 改造成数组。数组每一项就是一个个要绑定的 Store

如果多个 Store 中存在相同的数据,显示会出现异常。还可以通过 namespace 属性给当前 Store 开启命名空间,在开启命名空间以后,访问数据的时候,需要加上 namespace 的名字才可以。

js
// behavior.js

import { BehaviorWithStore } from "mobx-miniprogram-bindings";
import { numStore } from "../../stores/numstore";

export const indexBehavior = BehaviorWithStore({
  storeBindings: [
    {
      namespace: "numStore",
      store: numStore,
      fields: ["numA", "numB", "sum"],
      actions: ["update"],
    },
  ],
});
html
// index/index.wxml

<view>{{ numStore.numA }} + {{ numStore.numB }} = {{numStore.sum}}</view>

上面因为重名,会有问题

02. miniprogram-computed

小程序框架没有提供计算属性相关的 api ,但是官方为开发者提供了拓展工具库 miniprogram-computed

该工具库提供了两个功能:

  1. 计算属性 computed
  2. 监听器 watch

2.1 计算属性 computed

知识点:

如果需要在组件中使用计算属性功能,需要 miniprogram-computed 库中导入 ComponentWithComputed 方法

在使用时:,原本组件配置项也需要写到该方法中

在替换以后,就可以新增 computed 以及 watch 配置项。

安装 miniprogram-computed, 在安装以后,需要点击 构建 npm,进行本地构建

shell
npm install miniprogram-computed

📌 注意事项

computed 函数中不能访问 this ,但是提供了形参,代表 data 对象

​ 计算属性函数的返回值会被设置到 this.data.sum 字段中

官方文档:miniprogram-computed

落地代码:

计算属性 computed 的使用

js
// component.js

// 引入 miniprogram-computed
import { ComponentWithComputed } from "miniprogram-computed";

//需要用导入的ComponentWithComputed替换component方法
ComponentWithComputed({
  data: {
    a: 1,
    b: 1,
  },
  // 计算属是基于已有的数据产生新的数据
  //在使用ComponentWithComputed方法构建组件以后,就可以新增两个配置项 compted以及watch配置项

  computed: {
    total(data) {
      // 注意:
      //计算属性方法内部必须有返回值
      // computed 函数中不能访问 this ,只有 data 对象可供访问  如果想获取data中的数据,需要使用形参
      // 这个函数的返回值会被设置到 this.data.sum 字段中

      // 计算属性具有缓存,计算属性使用多次,但是计算属性方法只会执行一次,所以返回的是第一次执行的结果
      //只要依赖的数据没有发生变化,返回的就始终是第一次执行的结果
      console.log("~~~~~");

      return data.a + data.b;
    },
  },
});

2.2 监听器 watch

在使用时:,原本组件配置项也需要写到该方法中

在替换以后,就可以新增 computed 以及 watch 配置项。

js
// 引入 miniprogram-computed
import { ComponentWithComputed } from 'miniprogram-computed'

ComponentWithComputed({

  data: {
    a: 1,
    b: 1

  },

  computed: {
    total(data) {
      // 注意:
      // computed 函数中不能访问 this ,只有 data 对象可供访问
      // 这个函数的返回值会被设置到 this.data.sum 字段中
      return data.a + data.b
    }
  }

    //数据监听器,用来监听数据是否发生了变化,在数据变化以后执行相应的逻辑
  watch: {
    // 同时对 a 和 b 进行监听
    'a, b': function (a, b) {
      this.setData({
        total: a + b
      })
    }
  },

  methods: {
    updateData() {
      this.setData({
        a: this.data.a + 1,
        b: this.data.b + 1
      })
    }
  }
})

watch不简写:

拓展:Mobx 与 Computed 结合使用

两个框架扩展提供的 ComponentWithStoreComponentWithComputed 方法无法结合使用。

如果需要在一个组件中既想使用 mobx-miniprogram-bindings 又想使用 miniprogram-computed

解决方案是:

  1. 使用旧版 API

  2. 使用兼容写法

    • 即要么使用 ComponentWithStore 方法构建组件,要么使用 ComponentWithComputed 方法构建组件
    • 如果使用了 ComponentWithStore 方法构建组件,计算属性写法使用旧版 API
    • 如果使用了 ComponentWithComputed 方法构建组件,Mobx写法使用旧版 API

我们演示使用兼容写法:

  1. 如果使用了 ComponentWithStore 方法构建组件,计算属性写法使用旧版 API

    js
    import { ComponentWithComputed } from "miniprogram-computed";
    
    // component.js
    const computedBehavior = require("miniprogram-computed").behavior;
    
    ComponentWithStore({
      //注册behavior
      behaviors: [computedBehavior],
    
      data: {
        a: 1,
        b: 1,
        sum: 2,
      },
    
      watch: {
        "a, b": function (a, b) {
          this.setData({
            total: a + b,
          });
        },
      },
    
      computed: {
        total(data) {
          // 注意: computed 函数中不能访问 this ,只有 data 对象可供访问
          // 这个函数的返回值会被设置到 this.data.sum 字段中
          return data.a + data.b + data.sum; // data.c 为自定义 behavior 数据段
        },
      },
    
      // 实现组件和 Store 的关联
      storeBindings: {
        store: numStore,
    
        // fields 和 actions 有两种写法:数组写法 和 对象写法
    
        // 数组写法
        fields: ["numA", "numB", "sum"],
        actions: ["update"],
      },
    });
  2. 使用了 ComponentWithComputed 方法构建组件,Mobx写法使用旧版 API

    js
    import { ComponentWithComputed } from "miniprogram-computed";
    
    // 导入 storeBindingsBehavior 方法实现组件和 Store 的关联
    import { storeBindingsBehavior } from "mobx-miniprogram-bindings";
    // 导入 Store
    import { numStore } from "../../stores/numstore";
    
    ComponentWithComputed({
      behaviors: [storeBindingsBehavior],
    
      data: {
        a: 1,
        b: 1,
        sum: 2,
      },
      watch: {
        "a, b": function (a, b) {
          this.setData({
            total: a + b,
          });
        },
      },
      computed: {
        total(data) {
          // 注意: computed 函数中不能访问 this ,只有 data 对象可供访问
          // 这个函数的返回值会被设置到 this.data.sum 字段中
          return data.a + data.b + data.sum; // data.c 为自定义 behavior 数据段
        },
      },
    
      // 实现组件和 Store 的关联
      storeBindings: {
        store: numStore,
    
        // fields 和 actions 有两种写法:数组写法 和 对象写法
    
        // 数组写法
        fields: ["numA", "numB", "sum"],
        actions: ["update"],
      },
    });

01. mobx-miniprogram

1.1 mobx-miniprogram 介绍

目前已经学习了 6 种小程序页面、组件间的数据通信方案,分别是:

  1. 数据绑定:properties
  2. 获取组件实例:this.selectComponent()
  3. 事件绑定:this.triggerEvent()
  4. 获取应用实例:getApp()
  5. 页面间通信:EventChannel
  6. 事件总线:pubsub-js

在中小型项目中,使用这些数据通信方式已经能够满足我们项目的需求。

但是随着项目的业务逻辑越来越复杂,组件和页面间通信就会变的非常复杂。例如:有些状态需要在多个页面间进行同步使用,一个地方发生变更,所有使用的地方都需要发生改变,这时候如果使用前面的数据通信方案进行传递数据,给管理和维护将存在很大的问题。

为了方便进行页面、组件之间数据的传递,小程序官方提供了一个扩展工具库: mobx-miniprogram

mobx-miniprogram 是针对微信小程序开发的一个简单、高效、轻量级状态管理库,它基于Mobx状态管理框架实现。

使用 mobx-miniprogram 定义管理的状态是响应式的,当状态一旦它改变,所有关联组件都会自动更新相对应的数据

通过该扩展工具库,开发者可以很方便地在小程序中全局共享的状态,并自动更新视图组件,从而提升小程序的开发效率

需要注意:在使用 mobx-miniprogram 需要安装两个包:mobx-miniprogrammobx-miniprogram-bindings

  1. mobx-miniprogram 的作用:创建 Store 对象,用于存储应用的数据
  2. mobx-miniprogram-bindings 的作用:将状态和组件、页面进行绑定关联,从而在组件和页面中操作数据
shell
npm install mobx-miniprogram mobx-miniprogram-bindings

官方文档:

  1. mobx-miniprogram 官方文档
  2. mobx-miniprogram-bindings 官方文档

1.2 创建 Store 对象

如果需要创建 Store 对象需要使用 mobx-miniprogram ,因此需要先熟悉 mobx-miniprogram 三个核心概念:

  1. observable:用于创建一个被监测的对象,对象的属性就是应用的状态(state),这些状态会被转换成响应式数据。
  2. action:用于修改状态(state)的方法,需要使用 action 函数显式的声明创建。
  3. computed:根据已有状态(state)生成的新值。计算属性是一个方法,在方法前面必须加上 get 修饰符

mobx-miniprogram 详细的使用步骤如下:

  1. 在项目的根目录下创建 store 文件夹,然后在该文件夹下新建 index.js

  2. /store/index.js 导入 observableaction 方法

    js
    import { observable, action } from "mobx-miniprogram";
  3. 使用 observable 方法需要接受一个 store 对象,存储应用的状态

    js
    // observable:用于创建一个被监测的对象,对象的属性就是应用的状态(state),这些状态会被转换成响应式数据。
    // action:用于显式的声明创建更新 state 状态的方法
    import { observable, action } from "mobx-miniprogram";
    
    // 使用 observable 创建一个被监测的对象
    export const numStore = observable({
      // 创建应用状态
      numA: 1,
      numB: 2,
    
      // 使用 action 更新 numA 以及 numB
      update: action(function () {
        this.numA += 1;
        this.numB += 1;
      }),
    
      // 计算属性,使用 get 修饰符,
      get sum() {
        return this.numA + this.numB;
      },
    });

1.3 在组件中使用数据

如果需要 Page 或者Component中对共享的数据进行读取、更新操作,需要使用 mobx-miniprogram-bindings

mobx-miniprogram-bindings 的作用就是将 Store 和 页面或组件进行绑定关联

如果需要在组件中使用状态,需要 mobx-miniprogram-bindings 库中导入 ComponentWithStore 方法

在使用时:,原本组件配置项也需要写到该方法中

在替换以后,就会新增一个 storeBindings 配置项,配置项常用的属性有以下三个:

  1. store: 指定要绑定的 Store 对象
  2. fields: 指定需要绑定的 data 字段
  3. actions: 指定需要映射的 actions 方法

📌 注意事项:

导入的数据会同步到组件的 data 中

导入的方法会同步到组件的 methods 中

js
// components/custom01/custom01.js
import { ComponentWithStore } from "mobx-miniprogram-bindings";
import { numStore } from "../../stores/numstore";

ComponentWithStore({
  data: {
    someData: "...",
  },
  storeBindings: {
    store: numStore,
    fields: ["numA", "numB", "sum"],
    actions: ["update"],
  },
});

1.4 在页面中使用数据-方式1

Component 方法用于创建自定义组件。

小程序的页面也可以视为自定义组件,因此页面也可以使用 Component 方法进行构建,从而实现复杂的页面逻辑开发。

如果我们使用了 Component 方法来构建页面,那么页面中如果想使用 Store 中的数据,使用方式和组件的使用方式是一样的

  1. mobx-miniprogram-bindings 库中导入 ComponentWithStore 方法
  2. Component 方法替换成 ComponentWithStore 方法
  3. 然后配置 storeBindingsStore 中映射数据和方法即可
js
// index/index.js
import { ComponentWithStore } from "mobx-miniprogram-bindings";
import { numStore } from "../../stores/numstore";

ComponentWithStore({
  data: {
    someData: "...",
  },
  storeBindings: {
    store: numStore,
    fields: ["numA", "numB", "sum"],
    actions: ["update"],
  },
});

1.5 在页面中使用数据-方式2

在上一节,我们使用了 Component 方法构建页面,然后使用 ComponentWithStore 方法让页面和 Store 建立了关联

如果不想使用 Component 方法构建页面。这时候需要使用 mobx-miniprogram-bindings 提供的 BehaviorWithStore 方法来和 Store 建立关联。

小程序的 behavior 方法是一种代码复用的方式,可以将一些通用的逻辑和方法提取出来,然后在多个组件中复用,从而减少代码冗余,提高代码的可维护性。

使用方式如下:

  1. 新建 behavior 文件,从 mobx-miniprogram-bindings 库中导入 BehaviorWithStore 方法
  2. BehaviorWithStore 方法中配置 storeBindings 配置项从 Store 中映射数据和方法
  3. Page 方法中导入创建的 behavior ,然后配置 behavior 属性,并使用导入的 behavior
js
// behavior.js

import { BehaviorWithStore } from "mobx-miniprogram-bindings";
import { numStore } from "../../stores/numstore";

export const indexBehavior = BehaviorWithStore({
  storeBindings: {
    store: numStore,
    fields: ["numA", "numB", "sum"],
    actions: ["update"],
  },
});
js
// index.js

import { indexBehavior } from "./behavior";

Page({
  behaviors: [indexBehavior],

  // 其他配置项
});

1.6 fields、actions 对象写法

fieldsactions 有两种写法:数组 或者 对象。

如果 fields 写成对象方式,有两种写法:

  1. 映射形式:指定 data 中哪些字段来源于 store 以及它们在 store 中对应的名字。
    • 例如 { a: 'numA', b: 'numB' }
  2. 函数形式:指定 data 中每个字段的计算方法
    • 例如 { a: () => store.numA, b: () => anotherStore.numB }

如果 actions 写成对象方式,只有两种写法:

  1. 映射形式:指定模板中调用的哪些方法来源于 store 以及它们在 store 中对应的名字。
    • 例如 { buttonTap: 'update' }
js
import { ComponentWithStore } from "mobx-miniprogram-bindings";
import { numStore } from "../../stores/numstore";

ComponentWithStore({
  data: {
    someData: "...",
  },
  storeBindings: {
    store: numStore,
    fields: {
      // 使用函数方式获取 Store 中的数据
      a: () => store.numA,
      b: () => store.numB,

      // 使用映射形式获取 Store 中的数据,值为数据在 store 中对应的名字
      total: "sub",
    },

    // 使用映射形式获取 Store 中的 action 名字
    actions: {
      // key 自定义,为当前组件中调用的方法
      // 值为 store 中对应的 action 名字
      buttonTap: "update",
    },
  },
});

1.7 绑定多个 store 以及命名空间

在实际开发中,一个页面或者组件可能会绑定多个 Store ,这时候我们可以将 storeBindings 改造成数组。数组每一项就是一个个要绑定的 Store

如果多个 Store 中存在相同的数据,显示会出现异常。还可以通过 namespace 属性给当前 Store 开启命名空间,在开启命名空间以后,访问数据的时候,需要加上 namespace 的名字才可以。

js
// behavior.js

import { BehaviorWithStore } from "mobx-miniprogram-bindings";
import { numStore } from "../../stores/numstore";

export const indexBehavior = BehaviorWithStore({
  storeBindings: [
    {
      namespace: "numStore",
      store: numStore,
      fields: ["numA", "numB", "sum"],
      actions: ["update"],
    },
  ],
});
html
// index/index.wxml

<view>{{ numStore.numA }} + {{ numStore.numB }} = {{numStore.sum}}</view>

02. miniprogram-computed

小程序框架没有提供计算属性相关的 api ,但是官方为开发者提供了拓展工具库 miniprogram-computed

该工具库提供了两个功能:

  1. 计算属性 computed
  2. 监听器 watch

2.1 计算属性 computed

知识点:

如果需要在组件中使用计算属性功能,需要 miniprogram-computed 库中导入 ComponentWithComputed 方法

在使用时:,原本组件配置项也需要写到该方法中

在替换以后,就可以新增 computed 以及 watch 配置项。

安装 miniprogram-computed, 在安装以后,需要点击 构建 npm,进行本地构建

shell
npm install miniprogram-computed

📌 注意事项

computed 函数中不能访问 this ,但是提供了形参,代表 data 对象

​ 计算属性函数的返回值会被设置到 this.data.sum 字段中

官方文档:miniprogram-computed

落地代码:

计算属性 computed 的使用

js
// component.js

// 引入 miniprogram-computed
import { ComponentWithComputed } from "miniprogram-computed";

ComponentWithComputed({
  data: {
    a: 1,
    b: 1,
  },

  computed: {
    total(data) {
      // 注意:
      // computed 函数中不能访问 this ,只有 data 对象可供访问
      // 这个函数的返回值会被设置到 this.data.sum 字段中

      // 计算属性具有缓存,计算属性使用多次,但是计算属性方法只会执行一次
      console.log("~~~~~");

      return data.a + data.b;
    },
  },
});

2.2 监听器 watch

在使用时:,原本组件配置项也需要写到该方法中

在替换以后,就可以新增 computed 以及 watch 配置项。

js
// 引入 miniprogram-computed
import { ComponentWithComputed } from 'miniprogram-computed'

ComponentWithComputed({

  data: {
    a: 1,
    b: 1
  },

  computed: {
    total(data) {
      // 注意:
      // computed 函数中不能访问 this ,只有 data 对象可供访问
      // 这个函数的返回值会被设置到 this.data.sum 字段中
      return data.a + data.b
    }
  }

  watch: {
    // 同时对 a 和 b 进行监听
    'a, b': function (a, b) {
      this.setData({
        total: a + b
      })
    }
  },

  methods: {
    updateData() {
      this.setData({
        a: this.data.a + 1,
        b: this.data.b + 1
      })
    }
  }
})

拓展:Mobx 与 Computed 结合使用

两个框架扩展提供的 ComponentWithStoreComponentWithComputed 方法无法结合使用。

如果需要在一个组件中既想使用 mobx-miniprogram-bindings 又想使用 miniprogram-computed

解决方案是:

  1. 使用旧版 API

  2. 使用兼容写法

    • 即要么使用 ComponentWithStore 方法构建组件,要么使用 ComponentWithComputed 方法构建组件
    • 如果使用了 ComponentWithStore 方法构建组件,计算属性写法使用旧版 API
    • 如果使用了 ComponentWithComputed 方法构建组件,Mobx写法使用旧版 API

我们演示使用兼容写法:

  1. 如果使用了 ComponentWithStore 方法构建组件,计算属性写法使用旧版 API

    js
    import { ComponentWithComputed } from "miniprogram-computed";
    
    // component.js
    const computedBehavior = require("miniprogram-computed").behavior;
    
    ComponentWithStore({
      behaviors: [computedBehavior],
    
      data: {
        a: 1,
        b: 1,
        sum: 2,
      },
    
      watch: {
        "a, b": function (a, b) {
          this.setData({
            total: a + b,
          });
        },
      },
    
      computed: {
        total(data) {
          // 注意: computed 函数中不能访问 this ,只有 data 对象可供访问
          // 这个函数的返回值会被设置到 this.data.sum 字段中
          return data.a + data.b + data.sum; // data.c 为自定义 behavior 数据段
        },
      },
    
      // 实现组件和 Store 的关联
      storeBindings: {
        store: numStore,
    
        // fields 和 actions 有两种写法:数组写法 和 对象写法
    
        // 数组写法
        fields: ["numA", "numB", "sum"],
        actions: ["update"],
      },
    });
  2. 使用了 ComponentWithComputed 方法构建组件,Mobx写法使用旧版 API

    js
    import { ComponentWithComputed } from 'miniprogram-computed'
    
    // 导入 storeBindingsBehavior 方法实现组件和 Store 的关联
    import { storeBindingsBehavior } from "mobx-miniprogram-bindings"
    // 导入 Store
    import { numStore } from '../../stores/numstore'
    
    
    ComponentWithComputed({
      behaviors: [storeBindingsBehavior],
    
      data: {
        a: 1,
        b: 1,
        sum: 2
      },
      watch: {
        'a, b': function (a, b) {
          this.setData({
            total: a + b
          })
        }
      },
      computed: {
        total(data) {
          // 注意: computed 函数中不能访问 this ,只有 data 对象可供访问
          // 这个函数的返回值会被设置到 this.data.sum 字段中
          return data.a + data.b + data.sum // data.c 为自定义 behavior 数据段
        }
      },
    
      // 实现组件和 Store 的关联
      storeBindings: {
        store: numStore,
    
        // fields 和 actions 有两种写法:数组写法 和 对象写法
    
        // 数组写法
        fields: ['numA', 'numB', 'sum'],
        actions: ['update']
      }

用户管理

01. 用户登录-什么是 token

什么是 Token

Token 是服务器生成的一串字符串,用作客户端发起请求的一个身份令牌。当第一次登录成功后,服务器生成一个 Token 便将此 Token 返回给客户端,客户端在接收到 Token 以后,会使用某种方式将 Token 保存到本地。以后客户端发起请求,只需要在请求头上带上这个 Token ,服务器通过验证 Token 来确认用户的身份,而无需再次带上用户名和密码。

Token的具体流程

  1. 客户端向服务器发起登录请求,服务端验证用户名与密码
  2. 验证成功后,服务端会签发一个 Token,并将 Token 发送到客户端
  3. 客户端收到 token 以后,将其存储起来,比如放在 localStoragesessionStorage
  4. 客户端每次向服务器请求资源的时候需要带着服务端签发的 Token,服务端收到请求,然后去验证客户端请求里面带着的 Token ,如果验证成功,就向客户端返回请求的数据

Token交互流程图

02. 用户登录-小程序登录流程介绍

业务介绍:

传统的登录功能,需要用户先注册,注册完成以后,使用注册的账号、密码进行登录。

小程序的登录操作则比较简单,小程序可以通过微信提供的登录能力,便捷地获取微信提供的用户身份标识进行登录。

免去了注册和输入账号密码的步骤,从而提高了用户体验。

小程序登录图示:

登录流程时序图

登录流程说明:

  1. 用户访问小程序,点击 [登录] ,调用 wx.login() 方法获取 临时登录凭证code

    临时登录凭证 code,就像是一个会过期的临时身份证一样,有效时间仅为 5分钟

  2. 使用 wx.request() 方法将 临时登录凭证code 传递给开发者服务器,方便后续可以换取微信用户身份 id

  3. 开发者的后台接收 临时登录凭证code,同时在微信公众后台拿到 AppIdAppSecret ,向微信服务器发送请求, 请求参数合法的话,微信服务器会给开发者后台返回 openid(微信用户的唯一标识) 以及 session_key(会话密钥) 等

    openid 是微信用户的唯一标识,也就是微信用户身份 id,可以用这个 id 来区分不同的微信用户

    session_key 则是微信服务器给开发者服务器颁发的身份凭证,

    开发者可以用session_key请求微信服务器其他接口来获取一些其他信息

  4. 开发者后台在接收到微信服务器返回的数据以后,会执行一些业务逻辑的处理,例如:将用户标识和其他信息进行加密处理,生成自定义登录态,这个登录态可以理解为就是 Token ,然后让 Tokenopenidsession_key 进行关联

  5. 开发者后台处理好逻辑后,会将 自定义登录态 Token 返回给微信小程序客户端,客户端收到 token 以后,将其存储起来,比如放在 localStorage 中。

  6. 客户端每次向开发者后台发送请求的时候,需要携带自定义登录态 Token ,开发者后台收到请求后,对 Token 进行验证识别用户身份,同时拿自定义登录态 Token 查询 openidsession_key,从而获取用户请求的数据,进行返回。

03. 用户登录-实现小程序登录功能

思路分析:

当用户没有登录的时候,需要点击个人中心的头像,跳转到登录页面进行登录。在登录成功以后,需要再次返回到个人中心页面

在登录页面我们使用了 Vant 提供的两个组件来进行页面结构的绘制

  1. empty 组件:空状态时的占位提示
  2. button 组件:按钮组件

给登录按钮绑定点击事件,在事件处理程序中,调用 wx.login 获取 临时登录凭证code

然后调用后端接口,将 临时登录凭证code 传递给后端

熟悉接口文档:微信登录

根据接口文档封装接口 API 函数,当点击授权登录按钮的时候调用 API 函数,在获取到 token 以后,将 token 存储到本地,然后跳转到登录之前的页面。

实现步骤:

  1. /api/user.js 文件中根据接口文档,创建登录的 API 函数 login
  2. 给登录按钮绑定点击事件,对应 login 回调函数
  3. login 回调函数中调用 wx.login() 方法,获取临时登录凭证code
  4. /pages/login/login.js 中导入封装好的 API 函数,传入 临时登录凭证code 然后调用
  5. 在登录成功以后将 token 存储到本地

落地代码:

➡️ /api/user.js

js
import http from "../utils/http";

/**
 * @description 授权登录
 * @param {*} code 临时登录凭证code
 * @returns Promise
 */
export const reqLogin = (code) => {
  return http.get(`/mall-api/weixin/wxLogin/${code}`);
};
import { reqLogin } from '../../api/user'
import { toast } from '../../utils/extendApi'

Page({
  // 点击登录
  login() {
    // 调用 wx.login 获取用户信息
    //获取用户的临时登陆凭证code
    wx.login({
      success: async ({ code }) => {
        if (code) {
        //在获取到临时登录凭证code后,需要传递给开发者服务器
          // 调用接口 API,传入 code 进行登录
          const res = await reqLogin(code)

          // 登录成功以后将 token 存储到本地
          wx.setStorageSync('token', res.data.token)

          // 返回之前的页面
          wx.navigateBack()
        } else {
          // 登录失败后给用户进行提示
          toast({ title: '授权失败,请稍后再试~~~' })
        }
      }
    })
  }
})

➡️ /pages/login/login.js

04. 用户登录-token 存储到 Store

思路分析:

在上一节,我们已经将token存储到了本地,但是将 Token 直接存储到本地不方便对数据进行操作,要先从本地存储取出,然后在使用,关键的一点,存储到本地的数据不是响应式的,当本地存储里面的内容发生改变,页面不会发生改变。这时候我们就需要将 token 也存储到 Store 中,这一节,我们需要做的就是将 Token 存储到 Mobx 中。

Mobx允许开发人员在应用程序中统一管理所有组件之间的公共数据。通过使用 Mobx,开发人员可以轻松地将 token 存储到全局状态中,并实现在整个应用程序中的共享。并且,存储到Mobx中的数据是响应式的,数据发生了变化,使用的地方也会发生变化

首先我们先安装Mobx,然后进行实例化,在实例化的时候,创建共享的数据 Token,以及对 Token 修改的方法

然后使用 Component 构造页面,并导入ComponentWithStore 方法,并配置 storeBindings 方法让页面和 Store 对象关联

实现步骤:

  1. 安装Mobx两个包,在安装好包以后,对包进行构建,点击 构建 npm
  2. 在项目的根目录下创建 store 文件夹,然后在该文件夹下新建 userstore.js
  3. 导入核心的observableaction 方法,创建Store,同时声明数据和方法
  4. 在登录页面,导入ComponentWithStore 方法,并配置 storeBindings 方法让页面和 Store 对象关联

落地代码:

安装依赖,安装完成后构建 npm

shell
npm i mobx-miniprogram mobx-miniprogram-bindings

➡️ /store/index.js

js
// 导入 observable 函数用于创建可观察对象,对象中的属性会被转换为响应式
// 导入 action 修改 store 中的可观察状态,用来显式定义action方法
import { observable, action } from "mobx-miniprogram";
import { getStorage } from "../utils/storage";

// 创建 store 对象,存储应用的状态
export const userStore = observable({
  // 创建可观察状态 token
  token: getStorage("token") || "",

  //定义action
  // setToken用来对 token 进行修改,更新
  setToken: action(function (token) {
    //在调用setToken方法时,需要传入token数据进行赋值
    this.token = token;
  }),
});

➡️ /pages/login/login.js

js
import { reqLogin } from '../../api/user'
+ import { userStore } from '../../api/userstore'

+ import { ComponentWithStore } from 'mobx-miniprogram-bindings'

+ ComponentWithStore({

+   storeBindings: {
+     store: userStore,
+     fields: ['token'],
+     actions: ['setToken']
+   }

+   methods: {
    // 授权登录
    login() {
      // 使用 wx.login 获取用户的临时登录凭证 code
      wx.login({
        success: async ({ code }) => {
          if (code) {
            // 在获取到临时登录凭证 code 以后,需要传递给开发者服务器
            const { data } = await reqLogin(code)

            // 登录成功以后,需要将服务器响应的自定义登录态存储到本地
            setStorage('token', data.token)

+             // 将数据存储到 store 对象中
+             this.setToken(data.token)
          } else {
            toast({ title: '授权失败,请重新授权' })
          }
        }
      })
    }
+   }
})

05. 用户信息-用户信息存储到 Store

思路分析:

在这一节,我们需要调用接口获取用户的信息,在获取到数据以后,我们需要存储用户信息数据到本地,

用户信息可能会在多个地方使用到,为了方便对用户信息的获取和使用,我们依然将用户信息存储到store

我们首先在 store/index.js 中新增userInfo可观测字段,同时创建赋值和删除的action方法

然后熟悉接口文档:获取用户信息

在熟悉了接口文档以后,根据接口文档封装接口 API 函数

获取用户信息的接口需要使用 token,所以我们需要在登录成功以后,调用获取用户信息的接口

登录成功以后,将用户信息存储到本地,然后调用action方法,将用户信息存储到 Store

实现步骤:

  1. store/userstore.js 中新增userInfo字段,同时创建修改的action方法
  2. login.js 中使用映射 userInfo 数据和 setUserInfo 方法
  3. /api/user.js 文件中根据接口文档,创建获取用户信息的 API 函数 reqUserInfo
  4. /pages/login/login.js 中导入封装好的获取商品列表的 API 函数
  5. 创建 getUserInfo 方法,在 getUserInfo 方法中调用接口 API 函数 reqUserInfo
  6. 在登录成功以后,调用getUserInfo 方法获取用户,然后将用户信息存到本地以及 Store

落地代码:

➡️ /api/user.js

js
/**
 * @description 获取用户信息
 * @returns Promise
 */
export const reqUserInfo = () => {
  return http.get(`/mall-api/weixin/getuserInfo`);
};

➡️ /store/userstore.js

js
// 导入 observable 函数用于创建可观察对象
// 导入 action 修改 store 中的可观察状态
import { observable, action } from 'mobx-miniprogram'
import { getStorage } from '../utils/storage'

// 创建 store 对象,存储应用的状态
export const userStore = observable({
  // 创建可观察状态 token
  // token,登录令牌
  token: getStorage('token') || '',
+   // 用户信息
+   userInfo: wx.getStorageSync('userInfo') || {},

  // 对 token 进行修改
  setToken: action(function (token) {
    this.token = token
  }),

+   // 设置用户信息
+   setUserInfo: action(function (userInfo) {
+     this.userInfo = userInfo
+   })
})

➡️/pages/login/login.js

js
// pages/login/login.js

// 导入封装通用模块方法
import { toast } from '../../utils/extendApi'
// 导入本地存储 api
import { setStorage } from '../../utils/storage'
// 导入接口 API 函数
+ import { reqLogin, reqUserInfo } from '../../api/user'

// 导入 ComponentWithStore 方法
import { ComponentWithStore } from 'mobx-miniprogram-bindings'
// 导入 store 对象
import { userStore } from '../../stores/userstore'

// 使用 ComponentWithStore 方法替换 Component 方法构造页面
ComponentWithStore({
  // 让页面和 Store 对象建立关联
  storeBindings: {
    store: userStore,
+     fields: ['token', 'userInfo'],
+     actions: ['setToken', 'setUserInfo']
  },

  methods: {
    // 授权登录
    login() {
      // 使用 wx.login 获取用户的临时登录凭证 code
      wx.login({
        success: async ({ code }) => {
          if (code) {
            // 在获取到临时登录凭证 code 以后,需要传递给开发者服务器
            const { data } = await reqLogin(code)

            // 登录成功以后,需要将服务器响应的自定义登录态存储到本地
            setStorage('token', data.token)

            // 将自定义登录态 token 存储到 Store 对象
            this.setToken(data.token)

+           // 获取用户信息
+           this.getUserInfo()
          } else {
            toast({ title: '授权失败,请重新授权' })
          }
        }
      })
    },

    // 获取用户信息
    async getUserInfo() {
      const { data } = await reqUserInfo()
      // 将用户信息存储到本地
      setStorage('userInfo', data)
+     // 将用户信息存储到 Store
+     this.setUserInfo(data)
    }
  }
})

06. 用户信息-使用数据渲染用户信息

思路分析:

在获取到数据以后,我们已经将用户信息数据存储到本地和Store

这一节我们需要从 Store 中取出用户信息数据,并渲染到页面上

个人中心页面展示用于展示个人信息

如果用户没有登录的时候,展示没有登录的头像、提示用户登录的文案信息,不展示设置按钮

如果用户已经登录,展示用户的头像和昵称,并且展示设置按钮,方便用户对收货地址、头像、昵称进行更改

实现步骤:

  1. 在个人中心页面导入ComponentWithStore 方法构建页面
  2. 配置 storeBindings 让组件和 Store 建立关联
  3. 渲染页面

落地代码:

➡️/pages/my/my.js

js
+ import { ComponentWithStore } from 'mobx-miniprogram-bindings'

+ ComponentWithStore({

+   storeBindings: {
+     store: userStore,
+     fields: ['token', 'userInfo']
+   }

})

➡️/pages/info/info.wxml

html
<!--pages/info/info.wxml-->
<view class="container bg">
  <!-- 顶部展示图 -->
  <view class="top-show">
    <image mode="widthFix" class="top-show-img" src="/static/images/banner.jpg"></image>
  </view>
  <view class="wrap">
    <!-- 未登录面板 -->
    +
    <view class="user-container section" wx:if="{{ !token }}" bindtap="toLoginPage">
      <view class="avatar-container">
        <image src="/static/images/avatar.png"></image>
        <view class="no-login">
          <text class="ellipsis">未登录</text>
          <text>点击授权登录</text>
        </view>
      </view>
    </view>

    +
    <!-- 登录以后得面包 -->
    +
    <view wx:else class="user-container section">
      +
      <view class="avatar-container">
        + <image src="{{ userInfo.headimgurl }}"></image> +
        <view class="no-login"> + <text class="ellipsis">{{ userInfo.nickname }}</text> + </view> +
      </view>
      + <view class="setting"> + 设置 + </view> +
    </view>

    <!-- 订单面板 -->
    <view class="order section">
      <view class="order-title-wrap">
        <text class="title">我的订单</text>
        <text class="more">查看更多></text>
      </view>
      <view class="order-content-wrap">
        <view class="order-content-item">
          +
          <navigator wx:if="{{ !token }}" url="/pages/login/login">
            + <view class="iconfont icon-dingdan"></view> + <text>商品订单</text> +
          </navigator>
          +
          <navigator wx:else url="/pages/order/list/index">
            + <view class="iconfont icon-dingdan"></view> + <text>商品订单</text> +
          </navigator>
        </view>
        <view class="order-content-item">
          <view class="iconfont icon-lipinka"></view>
          <text>礼品卡订单</text>
        </view>
        <view class="order-content-item">
          <view class="iconfont icon-tuikuan"></view>
          <text>退款/售后</text>
        </view>
      </view>
    </view>

    <!-- 关于售前售后服务面板 -->
    <view class="after-scale section">
      <!-- coding... -->
    </view>

    <!-- 底部面板 -->
    <view class="info-footer"> 尚硅谷技术支持 </view>
  </view>
</view>

联动store,做替换

07. 分包处理-配置分包以及预下载

思路分析:

随着项目功能的增加,项目体积也随着增大,从而影响小程序的加载速度,影响用户的体验。

因此我们需要将 更新个人资料收货地址 功能配置成一个分包,

当用户在访问设置页面时,还预先加载 更新个人资料收货地址 所在的分包

在分包后,通过查看代码依赖查看是否分包完成

📌 注意事项

  1. 在配置好商品详情和商品列表的分包以后,需要更改页面中的跳转路径 !
  2. PS:可以利用项目全局搜索的功能,进行批量更改

实现步骤:

  1. app.json 新增 subpackages 进行分包配置,新增 preloadRule 进行分包预下载配置
  2. subpackages 设置分包的 根目录 root 、别名 name 、页面路径 pages
  3. preloadRule 设置预下载。

落地代码

➡️ app.json

json
"subpackages": [
  {
    "root": "modules/settingModule",
    "name": "settingModule",
    "pages": [
      "pages/address/add/index",
      "pages/address/list/index",
      "pages/profile/profile"
    ]
  }
],
//分包预下载
"preloadRule": {
  "pages/settings/settings": {
    "network": "all",
      //在什么网络下预下载
    "packages": ["settingModule"]
      //预下载哪个分包,写分包别名
  }
}

08. 更新用户信息-渲染用户信息

思路分析:

点击个人中心的设置,然后点击修改个人资料,就可以对用户的头像和昵称进行修改

在这个页面中,我们需要先渲染信息用户,用户信息目前是存储到 Store 中的,因此我们需要先从 Store 中取出用户信息的数据,进行渲染的渲染。

让页面和 Store 数据建立关联,可以使用 mobx-miniprogram-bindings 提供的 BehaviorWithStore 方法

实现步骤:

  1. 新建 behavior.js 文件,从 mobx-miniprogram-bindings 库中导入 BehaviorWithStore 方法
  2. BehaviorWithStore 方法中配置 storeBindings 配置项从 Store 中映射数据和方法
  3. Page 方法中导入创建的 behavior ,然后配置 behavior 属性,并使用导入的 behavior

落地代码:

js
// behavior.js

import { BehaviorWithStore } from "mobx-miniprogram-bindings";
// 导入 store 对象
import { userStore } from "../../stores/userstore";

export const userBehavior = BehaviorWithStore({
  storeBindings: {
    store: userStore,
    fields: ["userInfo"],
  },
});

➡️ modules/settingModule/pages/profile/profile.js

js
import { userBehavior } from "./behavior";

Page({
  behaviors: [userBehavior],

  // 页面的初始数据
  data: {
    isShowPopup: false, // 控制更新用户昵称的弹框显示与否
  },

  // 其他代码略...
});

➡️ modules/settingModules/pages/profile/profile.wxml

html
<view class="container">
  <view class="setting-list avatar-container">
    <text>头像</text>
    <view class="avatar">
      <button hover-class="none">
        <image src="{{ userInfo.headimgurl }}" mode="" />
      </button>
    </view>
  </view>

  <view class="setting-list nickname">
    <text>昵称</text>
    <text>{{ userInfo.nickname }}</text>
  </view>

  <!-- coding... -->
</view>

09. 更新用户信息-获取头像临时路径

思路分析:

当用户点击头像时,可以对头像进行更新操作,我们使用通过微信提供的头像昵称填写能力快速完善

如果需要使用小程序提供的头像填写能力,需要两步:

  1. button 组件 open-type 的值设置为 chooseAvatar
  2. 当用户选择需要使用的头像之后,可以通过 bindchooseavatar 事件回调获取到头像信息的临时路径

实现步骤:

  1. button 按钮绑定 open-type 属性,值为 chooseAvatar
  2. 用户点击了头像后,在 bindchooseavatar 事件回调获取到头像信息的临时路径

落地代码:

➡️ modules/settingModules/pages/profile/profile.wxml

html
<view class="avatar">
  <button
    class="avatar-btn"
    hover-class="none"
    +
    open-type="chooseAvatar"
    +
    bindchooseavatar="chooseAvatar"
  >
    <image src="{{ userInfo.headimgurl || '/assets/images/avatar.png' }}" />
  </button>
</view>

➡️ modules/settingModules/pages/profile/profile.js

js
// pages/profile/profile.js
import { userBehavior } from './behavior'

Page({
  // 注册 behavior
  behaviors: [userBehavior],

  // 页面的初始数据
  data: {
    isShowPopup: false // 控制更新用户昵称的弹框显示与否
  },

+   // 更新用户头像
+   chooseAvatar(event) {
+     // console.log(event)
+
+     // 获取头像的临时路径
+     // 临时路径具有失效时间,需要将临时路径上传到公司的服务器,获取永久的路径
+     // 在获取永久路径以后,需要使用永久路径更新 headimgurl
+     // 用户点击 保存按钮,才算真正的更新了头像和昵称
+     const { avatarUrl } = event.detail
+
+     this.setData({
+       'userInfo.headimgurl': avatarUrl
+     })
+   },

  // 略....
})

10. 更新用户信息-头像上传到服务器

思路分析:

通过 bindchooseavatar 事件回调获取到头像信息的临时路径。

当临时文件超过一定的容量的时候,小程序就会将临时文件清理掉,也就是说临时文件可能会随时失效,为了解决这个问题,我们需要将获取到头像信息的临时路径上传到自己的服务器。如果需要将本地资源上传到服务器,需要使用到小程序提供的 API 方法: wx.uploadFile ,语法如下:

js
wx.uploadFile({
  url: "开发者服务器地址",
  filePath: "要上传文件资源的路径 (本地路径)",
  name: "文件对应的 key",
  header: "HTTP 请求 Header",
  // 接口调用成功的回调函数
  success: (res) => {},
  // 接口调用失败的回调函数
  fail: (err) => {},
});

实现步骤:

  1. 在获取到用户的临时头像路径以后,调用 wx.uploadFile() 方法,同时设置好基本的参数,
  2. 在上传成功后,获取到服务器返回的永久地址
  3. 将地址赋值给 data 中的数据

落地代码:

➡️ modules/settingModules/pages/profile/profile.js

js
// 获取用户头像信息
getAvatar(e) {

  // 获取选中的头像
  const { avatarUrl } = e.detail

  //在获取到头像的临时路径以后,需要将临时路径通过wx.uploadFile上传到服务器
  wx.uploadFile({
    url: 'https://gmall-prod.atguigu.cn/mall-api/fileUpload',//开发者服务器地址
    filePath: avatarUrl,///要上传的文件资源路径
    name: 'file',
    header: {
      token: getStorage('token'),
    },
    success: (res) => {
      // 将获取到的头像赋值给 data 中变量同步给页面结构
        //调用uploadFile方法,返回的是JSON字符串,要进行转换
      const uploadRes = JSON.parse(res.data)
      this.setData({
        'userInfo.headimgurl': uploadRes.data
      })
    },
    fail(err) {
      wx.showToast({
        title: '头像更新失败,请稍后再试',
        icon: 'none'
      })
    }
  })

}

11. 更新用户信息-完成头像更新

思路分析:

当用户点击保存时,就需要实现头像的更新功能,既然需要同步到服务器,依然需要调用接口

首先熟悉接口文档:更新用户信息

熟悉了接口文档以后,根据接口文档封装接口 API 函数,点击保存的时候调用接口函数,然后将最新的用户信息同步到服务器。

在同步到服务器以后,我们需要将用户信息存储到本地同时同步到 Store

实现步骤:

  1. /api/user.js 文件中根据接口文档,创建获取用户信息的 API 函数 reqUpdateUserInfo
  2. 给修改个人资料的保存按钮绑定点击事件,触发 updateUserInfo 回调函数
  3. 在回调函数中调用接口 API 函数 reqUpdateUserInfo ,同时传入用户的信息
  4. 更新用户信息以后,将用户信息存储到本地同时同步到 Store

落地代码:

➡️/api/user.js

js
import http from "../utils/http";

/**
 * @description 更新用户信息
 * @param {*} updateUserVo 用户头像和用户昵称
 */
export const reqUpdateUserInfo = (userInfo) => {
  return http.post("/mall-api/weixin/updateUser", userInfo);
};

➡️ modules/settingModule/pages/profile/profile.js

js
// pages/profile/profile.js
import { reqUpdateUserInfo, reqUserInfo } from '../../../../api/user'

Page({

  // coding...

  // 更新用户信息
  async updateUserInfo() {
    // 调用 API,更新用户信息
    await reqUpdateUserInfo(this.data.userInfo)

    // 将用户信息存储到本地
   setStorage('userInfo', this.data.userInfo)

    // 将用户信息存储到 Store
    this.setUserInfo(this.data.userInfo)

    // 给用户提示头像更新成功
      //记得引入
  toast({
      title: '头像更新成功',
      icon: 'none'
    })
  }


  // coding...
}

12. 更新用户信息-更新用户昵称

思路分析:

在这一节,我们需要实现将用户昵称更改的功能

更新用户昵称的接口和更新用户头像的接口是同一个,因此不需要再次封装,直接复用即可

当点击更新用户昵称时,弹出弹框,当用户将在输入框光标聚焦到输入框时,可以通过两种方式更新用户昵称操作

  1. 使用微信昵称
  2. 用户输入最新的昵称

当用户提交表单时,我们将最新的昵称,同步到 userInfonickname字段中

当用户点击了确定以后,我们将新的的用户信息赋值给 data 中的 userInfo 字段

当用户点击保存时,更新用户信息

实现步骤:

  1. form 表单绑定 bindsubmit 事件,用来获取输入框最新的值
  2. input 组件绑定 type 属性,属性值为 nickname,获取微信昵称
  3. input 组件绑定 bindinput 事件,获取用户输入最新的昵称
  4. formType 设置为 submit 当用户点击确定后,触发 form 表单的 bindsubmit 事件
  5. form 表单的 bindsubmit 事件中进行赋值
  6. form 表单的取消按钮绑定事件,取消弹框

落地代码:

➡️ modules/settingModule/pages/profile/profile.wxml

html
<van-dialog
  custom-style="position: relative"
  use-slot
  title="修改昵称"
  show="{{ isShowPopup }}"
  showConfirmButton="{{ false }}"
  showCancelButton="{{ false }}"
  transition="fade"
>
  //需要用form组件包裹住input输入框以及按钮组件 +
  <form bindsubmit="getNickName">
    <!-- type 设置为 nickname 是为了获取微信昵称 -->
    <input
      class="input-name"
      +
      type="nickname"
      +
      bindinput="getNewName"
      name="nickname"
      value="{{ userInfo.nickname }}"
    />
    <view class="dialog-content">
      //给按钮添加form-type属性,如果属性值是reset,就是重置表单 +
      <button class="cancel" bindtap="cancelForm" formType="reset">取消</button> +
      <!-- 将 formType 设置为 submit 当用户点击确定后,触发 form 表单的 bindsubmit 事件 -->
      + <button class="confirm" type="primary" formType="submit">确定</button>
    </view>
  </form>
</van-dialog>

➡️ modules/settingModule/pages/profile/profile.js

js
import { reqUpdateUserInfo, reqUserInfo } from "../../../../api/user";
import { createStoreBindings } from "mobx-miniprogram-bindings";
import store from "../../../../stores/index";

Page({
  // 页面的初始数据
  data: {
    avatarUrl: "/static/images/avatar.png",
    isShowPopup: false,
    userInfo: {
      nickname: "",
      headimgurl: "",
    },
  },

  // 生命周期函数--监听页面加载
  onLoad(options) {
    createStoreBindings(this, {
      store,
      fields: ["userInfo"],
      actions: ["setUserInfo"],
    });
  },

  getAvatar(e) {
    // coding...
  },

  // 更新用户信息
  async updateUserInfo() {
    // coding...
  },

  // 显示修改昵称弹框
  onUpdateNickName() {
    this.setData({
      isShowPopup: true,
    });
  },

  // 获取最新的用户昵称
  getNewName(e) {
    // 解构获取用户输入的最新的昵称
    const { nickname } = e.detail.value;

    this.setData({
      "userInfo.nickname": nickname,
      isShowPopup: false,
    });
  },

  // 取消更新用户昵称
  cancelForm() {
    this.setData({
      isShowPopup: false,
    });
  },
});

做输入框昵称回显

收货地址

  1. 收货地址列表
  2. 新增收货地址
  3. 编辑收货地址
  4. 删除收货地址

01. 定义新增参数以及封装接口 API

思路分析:

点击新建地址按钮,需要跳转到新增地址页面

因为新增和编辑收货地址页面是同一个页面,我们需要在这个页面处理新增和编辑功能,为了做区分处理。

我们在后续做进行编辑的时候传递 id 属性,值为 收货地址的 id 值。

首先熟悉接口文档:获取用户信息

接收文档在这一节,我们先来收集添加收货地址的请求参数

参数名称参数说明是否必须
收货人nametrue
手机号phonetrue
provinceNametrue
省 编码provinceCodetrue
cityNametrue
市 编码cityCodetrue
districtNametrue
区 编码districtCodetrue
详细地址fullAddresstrue
设置默认地址isDefault (是否默认地址 → 0:否 1:是)false

实现步骤:

  1. 在新增收货地址页面 data 中声明所需要的字段
  2. 定义收货地址所需要的全部接口 API 函数

落地代码:

➡️ modules/settingModule/pages/address/add/index

js
Page{{

  // 页面的初始数据
  data: {
   name: '', // 收货人
      phone: '', // 手机号
      provinceName: '', // 省
      provinceCode: '', // 省 编码
      cityName: '', // 市
      cityCode: '', // 市 编码
      districtName: '', // 区
      districtCode: '', // 区 编码
      address: '',  // 详细地址
      fullAddress: '', // 完整地址 (省 + 市 + 区 + 详细地址)
      isDefault: 0 // 设置默认地址,是否默认地址 → 0:否  1:是
  }
}}

➡️ /api/address

js
import http from "../utils/http";

/**
 * @description 实现新增收货地址
 * @param {*} data
 * @returns Promise
 */
export const reqAddAddress = (data) => {
  return http.post("/userAddress/save", data);
};

/**
 * @description 获取收货地址列表
 * @returns Promise
 */
export const reqAddressList = () => {
  return http.get("/userAddress/findUserAddress");
};

/**
 * @description 获取收货地址详情
 * @param {*} id 收货地址id
 * @returns Promise
 */
export const reqAddressInfo = (id) => {
  return http.get(`/userAddress/${id}`);
};

/**
 * @description 编辑收货地址
 * @param {*} data
 * @returns Promise
 */
export const reqUpdateAddress = (data) => {
  return http.post("/userAddress/update", data);
};

/**
 * @description 删除收货地址
 * @param {*} id 收货地址 id
 * @returns Promise
 */
export const reqDelAddress = (id) => {
  return instance.get(`/userAddress/delete/${id}`);
};

02. 收集省市区数据

思路分析

省市区的结构使用了小程序本身自带的picker 件,并将组件的 mode 属性设置为了 region,从而变成省市区选择器

如果想获取省市区的数据,需要给 picker 选择组件添加change 事件来监听属性值的改变,获取选中的省市区

html
<!-- 省市县 -->
<view class="item">
  <text class="label">省/市/县 (区)</text>

  <!-- mode:给组件添加 mode 属性设置为了 region,从而变成省市区选择器 -->
  <!-- value:要求是一个数组,表示选中的省市区,默认选中每一列的第一个值 -->
  <!-- bindchange:来监听属性值的改变,也就是获取选中的省市区 -->
  //picker是小程序提供的组件,从底部弹起滚动选择器
  <picker
    mode="region"
    value="{{ [provinceName, cityName, districtName] }}"
    bindchange="onAddressChange"
  >
    <view wx:if="{{ provinceName }}" class="region">
      {{ provinceName + ' ' + cityName + ' ' + districtName }}
    </view>
    <view wx:else class="placeholder">请填写收货人所在城市</view>
  </picker>

  <view class="location" bindtap="onLocation">
    <van-icon name="location-o" color="#777" />
    <text>定位</text>
  </view>
</view>

实现步骤

  1. picker 选择组件添加change 事件来监听属性值的改变,获取选中的省市区
  2. 将获取到省市区标识和编码赋值给 data中的字段

落地代码

➡️ modules/settingModule/pages/address/add/index

js
Page({
  // coding...

  // 省市区选择
  //解构省市区以及编码
  onAddressChange(event) {
    const [provinceCode, cityCode, districtCode] = event.detail.code;
    const [provinceName, cityName, districtName] = event.detail.value;

    // 存储省市区对应的编码
    this.setData({
      provinceCode,
      provinceName,
      cityCode,
      cityName,
      districtName,
      districtCode,
    });
  },

  // coding...
});

03. 收集新增地址其他请求参数

思路分析:

使用简易双向数据 model:value 绑定来收集新增地址表单数据。

在将数据收集以后,需要组织两个数据:

  1. 是否是默认地址,0 不设置为默认地址,1 设置为默认地址
  2. 拼接完整的收货地址

实现步骤:

  1. 使用简易双向数据绑定来收集新增地址表单数据。
  2. 给按钮绑定点击事件,在事件处理函数中收集并整理数据

落地代码:

js
Page({
  // coding...

  // 获取表单元素的值
  saveAddrssForm(event) {
    //组织参数(完整地址,是否设置为默认地址)
    // 解构出省市区以及 是否是默认地址
    const { provinceName, cityName, districtName, address, isDefault } = this.data;

    // 拼接完整的地址
    const fullAddress = provinceName + cityName + districtName + address;

    // 合并接口请求参数
    //最终需要发送的请求参数
    const params = {
      ...this.data,
      fullAddress,
      isDefault: isDefault ? 1 : 0,
    };

    console.log(params);
  },
});

04. 地理定位功能介绍

地理定位介绍:

小程序地理定位是指通过小程序开发平台提供的 API,来获取用户的地理位置信息。用户在使用小程序时,可以授权小程序获取自己的地理位置信息

  1. wx.getLocation() :获取当前的地理位置
  2. wx.chooseLocation():打开地图选择位置

申请开通:

暂时只对部分类目的小程序开放,需要先通过类目审核,然后在小程序管理后台,「开发」-「开发管理」-「接口设置」中自助开通该接口权限。

使用方法:

  1. 在 app.json 中配置 requiredPrivateInfos 进行声明启用
  2. 在调用 wx.getLocation() 时需要在 app.json 配置 permission字段,同时使用 scope.userLocation 声明收集用户选择的位置信息的目的,wx.chooseLocation() 接口不需要配置该字段,可以直接进行调用
  3. 在配置好以后,调用 wx.getLocation()wx.chooseLocation() 接口

参考文档:

  1. 地理位置接口新增与相关流程调整
  2. permission 字段说明

app.json 中进行配置

json
{
  "requiredPrivateInfos": ["getLocation", "chooseLocation"],
  "permission": {
    "scope.userLocation": {
      "desc": "获取用户位置信息用于填写收货地址"
    }
  }
}

使用之前记得给定位按钮绑定相对应的事件

getLocation 使用:

js
// 地理定位
async onLocation() {
  // 获取 纬度 、精度
  const { latitude, longitude } = await wx.getLocation()
  console.log(location)
}

chooseLocation 使用:

js
// 地理定位
async onLocation() {
  // 打开地图选择位置,获取 纬度 、精度
  const { latitude, longitude }  = await wx.chooseLocation()
  console.log(res)
}

05. 拒绝授权后的解决方案

在调用 wx.getLocation() 获取用地理位置时,如果用户选择拒绝授权,代码会直接抛出错误。

在拒绝授权以后,再次调用 wx.getLocation() 时,就不会在弹窗询问用户是否允许授权。

接下来,就需要优化授权的流程:

  1. wx.getSetting():获取用户的当前设置。返回值中只会出现小程序已经向用户请求过的权限
  2. wx.openSetting(): 调起客户端小程序设置界面,返回用户设置的操作结果

📌 注意事项:

  1. 如果希望用户再次授权,就需要让用户进行 手动开启授权
  2. wx.openSetting() 必须用户发生点击行为后,才可以跳转到设置页进行授权信息管理。
js
// 获取用户地理位置信息
async onLocation() {
  // 调用 getSetting 方法获取用户所有的授权信息

  // 返回的 authSetting 包含小程序已向小程序申请过的权限和已经授权结果(true、false)
  const { authSetting } = await wx.getSetting()
  console.log(authSetting)

  // scope.userLocation 用户是否已经授权获取地理位置的信息
  // 如果之前没有申请过,则authSetting 中没有scope.userLocation属性,返回 undefined,需要调用 getLocation
  // 如果之前同意了授权,返回 true,需要调用 getLocation
  // 如果之前拒绝了授权,返回 false,需要用户手动进行授权
  // 等于 true,或者不等于 undefined,说明需要进行授权

  // const isAuth =
  //   authSetting['scope.userLocation'] ||
  //   authSetting['scope.userLocation'] === undefined

  // 为了避免冗余的条件判断,使用 !! 把代码进行优化
  const isAuth = !!authSetting['scope.userLocation']

  if (!isAuth) {
    // 弹窗询问用户是否进行授权
    const modalRes = await wx.modal({
      title: '授权提示',
      content: '需要需要您的地理位置信息,请确认授权'
    })

    // 如果用户点击了取消,说明用户拒绝了授权,给用户提示
    if (!modalRes) return wx.toast({ title: '您拒绝了授权' })

    // 如果用户点击了确定,调用 wx.openSetting 打开微信客户端小程序授权页面
    // 并返回授权以后的结果
    const { authSetting } = await wx.openSetting()

    // 如果用户没有更新授权信息,提示没有更新授权
    if (!authSetting['scope.userLocation'])
      return wx.toast({ title: '授权失败!' })

    try {
      // 如果用户更新授权信息,则调用 getLocation 获取用户地理位置信息
      const locationRes = await wx.getLocation()
      // 打印地理位置信息
      console.log(locationRes)
    } catch (err) {
      console.log(err)
    }
  } else {
    try {
      // 如果是第一次调用 getLocation 或者之前授权过
      // 直接调用 getLocation 获取用户信息即可
      const locationRes = await wx.getLocation()
      console.log(locationRes)
    } catch (error) {
      wx.toast({ title: '您拒绝授权获取地址位置' })
    }
  }
}

判断用户是否拒绝了授权

微信客户端小程序授权页面:

更简便方法:

06. 开通腾讯位置服务

腾讯位置服务简介:

使用wx.chooseLocation()能够很方便的让用户来选择地理位置,但是wx.chooseLocation()返回的数据并没有包含省市区、省市区编码数据。而新增收货地址接口,需要传递省市区、省市区编码数据。

这时候我们可以使用 腾讯位置服务 将返回的经度、纬度进行逆地址解析,转换成详细地址。

腾讯位置服务专为小程序开发提供了 JavaScript SDK,方便开发者在小程序中可以使用腾讯地图服务。

使用腾讯位置服务可以很方便的让开发者实现地址解析、逆地址解析等功能。

使用步骤:

  1. 申请开发者密钥(key):申请密钥
  2. 开通 webserviceAPI 服务:控制台 → 应用管理→我的应用 → 添加 key →勾选 WebServiceAPI →保存
  3. 下载微信小程序 JavaScriptSDK,微信小程序JavaScriptSDK v1.1 JavaScriptSDK v1.2
  4. 安全域名设置

详细步骤:

  1. 申请密钥:密钥申请,微信扫码进行登录,选择绑定已有账号、或者注册新账号 (需要绑定手机、验证邮箱)

  2. 控制台 → 应用管理→我的应用 → 创建应用 → 添加 key → 创建完成

  3. 下载微信小程序 JavaScriptSDK v1.2,下载将 .js 文件放到小程序的 libs 目录下

  4. 进行安全域名设置,或者点击微信开发者工具中的暂时不校验域名

07. LBS 逆地址解析

使用步骤:

  1. 在项目中引入 SDK 核心类
  2. onLoad 中实例化 API 核心类,同时配置创建的 key
  3. 使用实例方法 reverseGeocoder 方法进行逆地址解析,将提供的坐标转换为详细的地址位置信息

官方文档-基础示例:Hello World

官方文档-逆地址解析:reverseGeocoder

落地代码:

  1. 引入 SDK 核心类

    js
    // var QQMapWX = require('../../libs/qqmap-wx-jssdk.js');
    import QQMapWX from "../../../../../libs/qqmap-wx-jssdk.min";
  2. 实例化 API 核心类

    js
    // 引入SDK核心类,js文件根据自己业务,位置可自行放置
    import QQMapWX from '../../../../../libs/qqmap-wx-jssdk.min'
    
    Page({
    
      onLoad: function () {
    
        // 实例化API核心类
        this.qqmapsdk = new QQMapWX({
          key: '申请的key'
        })
    
      }
    
      // coding...
    }
  3. 使用 reverseGeocoder 方法进行逆地址解析,将提供的坐标转换为所在位置的文字描述的转换

    js
    // LBS 地址逆解析
    // 地理定位
    async onLocation() {
      // 获取 纬度 、精度
      // const { latitude, longitude } = await wx.getLocation()
      // console.log(location)
    
      // 获取经、纬度、位置名称
      let { latitude, longitude, name } = await wx.chooseLocation()
    
      // 使用 reverseGeocoder 方法进行逆地址解析
      this.qqmapsdk.reverseGeocoder({
        // 传入经、纬度
        location: {
          latitude,
          longitude
        },
    
        // 逆地址解析成功后执行
        success: (res) => {
    
            // 获取街道门牌(可能为空)
          const { streetstreet_number } = res.result.address_component
    
          // province 省  city 市  district 区
          const {
            province, // 省
            city, // 市
            district, // 区
            adcode, // 行政区划代码
            city_code, // 城市代码,由国家码+行政区划代码(提出城市级别)组合而来,总共为9位
            nation_code // 国家代码
          } = res.result.ad_info
    
          //获取标准地址
          const{ standard_address} = res.result.formatted_addresses
    
    //对获取的数据进行组织,格式化,然后赋值给data中的字段
          this.setData({
            // 省级: 前两位有值,后面4位是0,如,河北省: 130000
            provinceCode: adcode.replace(adcode.substring(2, 6), '0000'),
            provinceName: province,
    
            // 市前4位有值,后面2位是0
            cityCode: adcode.replace(adcode.substring(4, 6), '00'),
            cityName: city,
    
            // 东莞市、中山市、修州市、嘉关市 因其下无区县级,
            districtCode: district && adcode,
            districtName: district,
    
              //下面两个在以后的开发中根据产品需求进行处理
            // 详细地址
            address: street+street_number+name,
              //完整地址
            fullAddress: standard_address + name
          })
        }
      })
    }

查看格式化

记得配置额度:

在进行逆解析的时候,如果发现key只能使用一次,要到腾讯位置服务后台进行额度配置

08. async-validator 基本使用

知识点

async-validator是一个基于 JavaScript 的表单验证库,支持异步验证规则和自定义验证规则

主流的 UI 组件库 Ant-designElement中的表单验证都是基于 async-validator

使用 async-validator 可以方便地构建表单验证逻辑,使得错误提示信息更加友好和灵活。

使用步骤:

  1. 安装并在项目中导入 async-validator
  2. 创建验证规则
  3. 创建表单验证实例,将验证规则传递给构造函数,产生实例
  4. 调用实例方法 validate 对数据进行验证
    • 第一个参数:需要验证的数据
    • 第二个参数:回调函数,回调函数有两个参数 errors, fields
      • errors:如果验证成功,返回 null,验证错误,返回数组
      • fields:需要验证的字段,属性值错误数组

落地代码:

  1. 安装 async-validator

    shell
    npm i async-validator
  2. 开发者工具,点击构建 npm,对 async-validator 进行构建

  3. 在 js 文件中导入 async-validator

    js
    // 1️⃣ 引入 async-validator,async-validator 提供了一个构造函数
    import Schema from "async-validator";
    
    Page({
      // 2️⃣定义需要验证的数据
      data: {
        name: "你好",
      },
    
      // 验证数据
      onValidate() {
        // 3️⃣创建表单验证规则
        const rules = {
          // key 建议和 需要验证的数据字段名字保持一致
          name: [
            // required 指 是否是必填项
            //message 指 如果验证失败,提示错误内容
            { required: true, message: "name 不能为空" },
    
            // type 数据的类型
            // message 如果验证失败,提示的错误内容
            { type: "string", message: "name 不是字符串" },
    
            // min 最少位数,max 最大位数
            { min: 2, max: 5, message: "名字最少 2 个字,最多 5 个字" },
    
            // 正则表达式
            // { pattern: '', message: '' }
    
            // 自定义验证规则
            // { validator: () => {} }
          ],
        };
    
        // 4️⃣创建表单验证实例
        // 在创建实例时需要传入验证规则
        const validator = new Schema(rules);
    
        // 5️⃣ 调用 validate 实例方法对数据进行验证
        // validate 方法接收一个对象作为参数,对象是需要验证的数据  第二个参数是一个回调函数
        // 注意:validate 方法只会验证和验证规则同名的属性
        validator.validate(this.data, (errors, fields) => {
          // 如果验证失败,errors 是所有错误信息的数组
          // 如果验证成功,errors 是 null
          console.log(errors);
    
          // fields 是需要验证的属性,属性值是数组,数组中包含错误信息
          console.log(fields);
    
          if (errors) {
            console.log("验证没有通过");
            console.log(errors);
            return;
          }
    
          console.log("验证通过");
        });
      },
    });

09. 新增收货地址表单验证

思路分析:

在点击新增收货地址的时候,我们需要对用户输入的值进行验证。产品需求如下:

  1. 收货人不能为空,且不能输入特殊字符
  2. 手机号不能为空,且输入的手机号必须合法
  3. 省市区不能为空
  4. 详细地址不能为空

正则:

js
// 验证收货人,是否只包含大小写字母、数字和中文字符
const nameRegExp = "^[a-zA-Z\\d\\u4e00-\\u9fa5]+$";

// 验证手机号,是否符合中国大陆手机号码的格式
const phoneReg = "^1(?:3\\d|4[4-9]|5[0-35-9]|6[67]|7[0-8]|8\\d|9\\d)\\d{8}$";

实现步骤:

  1. 创建 validateForm 方法,使用 async-validator 对表单进行验证
  2. 在新增收货地址之前,调用 validateForm 方法,如果验证成功执行新增守护地址的逻辑

落地代码:

➡️ /modules/settingModule/pages/address/add/index

js
import Schema from 'async-validator'

Page({

  // coding....

    // 保存收货地址
  async saveAddrssForm() {
    // 组织参数 (完整地址、是否设置为默认地址)

    const {
      provinceName,
      cityName,
      districtName,
      address,
      isDefault
    } = this.data

    // 最终需要发送的请求参数
    const params = {
      ...this.data,
      fullAddress: provinceName + cityName + districtName + address,
      isDefault: isDefault ? 1 : 0
    }

    // 调用方法对最终的请求参数进行验证,验证通过之后,需要调用新增的接口实现新增收获地址功能
    const { valid } = await this.validateAddress(params)

    // 如果验证没有通过,不继续执行后续的逻辑
    if (!valid) return

    console.log(params)
  },

  // 验证新增收货地址请求参数
  // 形参 params 是需要验证的数据
  validateAddress(params) {
    // 验证收货人,是否只包含大小写字母、数字和中文字符
    const nameRegExp = '^[a-zA-Z\\d\\u4e00-\\u9fa5]+$'

    // 验证手机号
    const phoneReg = '^1(?:3\\d|4[4-9]|5[0-35-9]|6[67]|7[0-8]|8\\d|9\\d)\\d{8}$'

    // 创建验证规则,验证规则是一个对象
    // 每一项是一个验证规则,验证规则属性需要和验证的数据进行同名
    const rules = {
      name: [
        { required: true, message: '请输入收货人姓名' },
        { pattern: nameRegExp, message: '收货人姓名不合法' }
      ],
      phone: [
        { required: true, message: '请输入收货人手机号' },
        { pattern: phoneReg, message: '手机号不合法' }
      ],
      provinceName: { required: true, message: '请选择收货人所在地区' },
      address: { required: true, message: '请输入详细地址' }
    }

    // 创建验证实例,并传入验证规则
    const validator = new Schema(rules)

    // 调用实例方法对数据进行验证
    // 注意:我们希望将验证结果通过 Promsie 的形式返回给函数的调用者
    return new Promise((resolve) => {
      validator.validate(params, (errors, fields) => {
        if (errors) {
          // 如果验证失败,需要给用户进行提示
          wx.toast({
            title: errors[0].message
          })

          resolve({ valid: false })
        } else {
            若属性值是true,说明验证成功
          resolve({ valid: true })
        }
      })
    })
  },


  // coding...
})

10. 实现新增收货地址

思路分析:

在实现了新增收货地址的数据收集、表单验证以后,我们需要实现新增收货地址的功能,将用户的收货地址到服务器。我们直接根据接口文档,封装接口 API,然后在表单验证以后,进行收货地址的添加即可。

实现步骤:

  1. 在对新增收货地址请求参数验证以后,将封装好的新增收货地址的 API 函数调用
  2. 在新增收货地址成功以后,跳转到收货地址详情页面。

落地代码:

➡️ /pages/address/add/index.js

js
// 新增或修改地址
async saveAddrssForm(event) {
  // 组织参数 (完整地址、是否设置为默认地址)
  const {
    provinceName,
    cityName,
    districtName,
    address,
    isDefault
  } = this.data

  // 最终需要发送的请求参数
  const params = {
    ...this.data,
    fullAddress: provinceName + cityName + districtName + address,
    isDefault: isDefault ? 1 : 0
  }

  // 如果验证没有通过,不进行后续处理
  if (!valid) return

  // 发送请求,保存收货地址
  const res = await reqAddAddress(params)

  if (res.code === 200) {
    wx.navigateBack({
      success() {
        wx.toast({ title: '新增收货地址成功' })
      }
    })
  }

}

11. 收货地址列表渲染

思路分析:

渲染收货地址需要收货地址的数据,需要调用接口获取收货地址数据,使用返回的数据进行结构的渲染。

先熟悉接口文档:获取收货地址

在熟悉了接口文档以后,根据接口文档封装接口 API 函数,然后在页面调用 API 函数获取收货地址的数据,在获取到数据以后,使用后端返回的数据对页面进行渲染。

实现步骤:

  1. onShow 钩子函数中调用reqAddressList方法
  2. 在获取到数据以后,使用后端返回的数据对页面进行渲染

落地代码:

➡️ /modules/settingModule/pages/address/list/index.js

js
// pages/address/list/index.js
+ import { reqAddressList } from '../../../../../api/address'

Page({
  // 页面的初始数据
  data: {
+     addressList: [] // 收货地址列表
  },

+   // 获取收货地址
+   async getAddressList() {
+     // 调用 API,获取收货地址
+     const { data: addressList } = await reqAddressList()
+
+     this.setData({
+       addressList
+     })
+   },

  // 去编辑页面
  toEdit() {
    wx.navigateTo({
      url: '/modules/settingModule/pages/address/add/index'
    })
  },

      //onLoad()是在页面加载时触发,如果当前页面没有销毁,这个钩子函数只会执行一次
      //如果点击新增或编辑,不会销毁当前页面
      //所以不能用这个
+   onLoad() {
+     this.getAddressList()
+   }
//要用onshow
onShow() {
+     this.getAddressList()
+   }

})

➡️ /modules/settingModule/pages/address/list/index.wxml

html
<view class="list-warpper" wx:if="{{ addressList.length }}">
  <view wx:for="{{ addressList }}" wx:key="id" class="list-item">
    <van-swipe-cell right-width="{{ 65 }}">
      <view class="list-item-box">
        <view class="info">
          <view class="user-info">
            + <text>{{ item.name }}</text> + <text>{{ item.phone }}</text> +
            <text wx:if="{{ item.isDefault }}" class="default-tag">默认</text>
          </view>

          + <view class="address-info"> {{ item.fullAddress }} </view>
        </view>

        <view class="editBtn">
          <van-icon bindtap="toEdit" name="edit" size="22px" color="#999" />
        </view>
      </view>
      <!-- <van-icon name="delete" size="22px" color="#999" /> -->
      <view slot="right" class="van-swipe-cell__right">
        <text>删除</text>
      </view>
    </van-swipe-cell>
  </view>
</view>

12. 实现更新收货地址

思路分析:

新增和编辑收货地址页面是同一个页面,我们需要在这个页面处理新增和编辑功能

在收货地址列表页面,点击更新按钮时,需要跳转到新增/更新页面,同时需要将更新这一项的 id 传递给新增/更新页面。

onLoad 中获取 id,并且使用 id 区分用户是进行新增还是编辑的操作。

如果存在 id,在获取需要更新的收货地址的数据,并进行页面的回显用户的收货地址,并且需要更新导航栏标题

因为我们之前直接是将数据放到 data 中的,所以我们直接将数据使用 setData 赋值即可

首先熟悉接口文档:获取收货地址详情

实现步骤:

  1. 在从收货地址列表页面跳转到更新页面的时候,需要携带 id
  2. onLoad 中判断是否存在 id,如果存在 id,在获取数据进行回显

落地代码:

➡️ /modules/settingModule/pages/address/list/index.wxml

html
<!-- 编辑、删除按钮 -->
<van-icon bindtap="toEdit" data-id="{{ item.id }}" name="edit" size="22px" color="#999" />

➡️ /modules/settingModule/pages/address/list/index.js

js
// 去编辑页面
toEdit(event) {
  // 需要编辑的收货地址
    //获取要更新的收货地址id
  const { id } = event.currentTarget.dataset

  wx.navigateTo({
    url: `/modules/settingModule/pages/address/add/index?id=${id}`
  })
}

➡️ /modules/settingModule/pages/address/add/index.js

js
Page({

  // coding...

  // 保存收货地址
  async saveAddrssForm() {
    // 组织参数 (完整地址、是否设置为默认地址)
    const {
      provinceName,
      cityName,
      districtName,
      address,
      isDefault
    } = this.data

    // 最终需要发送的请求参数
    const params = {
      ...this.data,
      fullAddress: provinceName + cityName + districtName + address,
      isDefault: isDefault ? 1 : 0
    }

    // 调用方法对最终的请求参数进行验证
    const { valid } = await this.validateAddress(params)

    // 如果验证没有通过,不继续执行后续的逻辑
    if (!valid) return

+     // 发送请求,保存收货地址
+     const res = this.addressId
+       ? await reqUpdateAddress(params)
+       : await reqAddAddress(params)

+     if (res.code === 200) {
+       // 提示用户更新状态
+       wx.toast({
+         title: this.addressId ? '编辑收货地址成功' : '新增收货地址成功'
+       })
+
+       // 返回到收货地址列表页面
+       wx.navigateBack()
    }
  },

+   // 回显收货地址,处理更新相关的逻辑
+   showAddressInfo(id) {
+     // 判断是否存在 id,如果不存在 id,return 不执行后续的逻辑
+     if (!id) return
+
+     // 如果存在 id,将 id 挂载到 this 页面实例上,方便在多个功能使用
+     this.addressId = id
+
+     // 动态设置当前页面的标题
+     wx.setNavigationBarTitle({
+       title: '更新收货地址'
+     })
+
+     // 调用方法获取收货地址详细信息
+     const { data } = await reqAddressInfo(id)
+     // 将获取的数据进行赋值
    //赋值后,页面上就会回显
+     this.setData(data)
+   },

  onLoad(options) {
    // 对核心类 QQMapWX 进行实例化
    this.qqmapwx = new QQMapWX({
      // key 要使用自己申请的 key
      // 在进行逆解析的时候,如果发现 key 只能使用一次,需要在腾讯位置服务后台配置额度
      key: 'S5CBZ-TQXCB-L73UJ-J6VJA-FXS53-JNBY3'
    })

+     // 回显收货地址的逻辑
+     this.showAddressInfo(options.id)
  }

  // coding...
})

13. 实现删除收货地址

思路分析:

点击删除按钮的时候,需要将对应的地址进行删除

当点击删除按钮的时候,调用封装的接口 API 函数 ,同时传递需要删除的收货地址 id 即可

实现步骤:

  1. 给删除按钮绑定点击事件 delAddress,同时通过 data-id 传递需要删除的商品 id
  2. delAddress 事件处理程序后面,调用 API 函数 reqDelAddress,并传递 id
  3. 在删除收货地址成功以后,给用户提示

落地代码:

➡️ /modules/settingModule/pages/address/list/index.wxml

js
<van-icon
+   bindtap="delAddress"
+   data-id="{{ item.id }}"
  name="delete"
  size="22px"
  color="#999"
/>

➡️ /modules/settingModule/pages/address/list/index.js

js
// 删除收货地址
async delAddress(e) {
    //解构传递过来的id
  const { id } = e.currentTarget.dataset
//询问用户是否确认删除
  const modalRes = wx.modal({
      content:'您确定删除该收货地址吗?'
  })
  //如果用户确认删除,需要调用接口api
  //同时给用户提示,并且要重新获取地址列表
  if(modalRes){
       await reqDelAddress(id)
      wx.toast({title:'收货地址删除成功'})

  this.getAddressList()
  }

}

优化:SwipeCell 自动收起删除滑块

目前我们已经实现了滑块删除收货地址的功能,

但是我们会发现点击页面空白区域或者点击其他收货地址时,删除的滑块不会自动收起。

如果想实现点击空白区域自动收起滑块功能,需要在 点击空白区域 以及 其他收货地址时,获取要收起的滑块实例。

调用对应滑块的实例方法 close 即可。

实现思路:

  1. 给滑块绑定 id
  2. 在打开滑块时,获取当前滑块的实例,然后将实例存储到 data 的数组中。
  3. 给页面最外层的 view 同时给滑块区域绑定点击事件,在事件处理函数中对数据遍历,每一项调用 close 方法关掉滑块
  4. 将关掉的逻辑抽取成 behavior 文件,方便在其他文件中进行复用。

落地代码:

➡️ /behavior/swipeCellBahavior.js

js
export const swipeCellBehavior = Behavior({
  data: {
    swipeCelQueue: [], // 实例存储队列,用来存储滑动单元格实例
  },

  methods: {
    // 打开滑块时,将实例存储到队列中
    SwipeCellOpen(event) {
      const instance = this.selectComponent(`#${event.target.id}`);
      //把实例追加到数组中
      this.data.swipeCelQueue.push(instance);
    },

    // 给页面绑定的点击事件,点击其他滑块时,关掉开启的滑块
    onSwipeCellClick() {
      this.onSwipeCellCommonClick();
    },

    // 点击页面空白区域时,关掉开启的滑块
    onSwipeCellPageTap() {
      this.onSwipeCellCommonClick();
    },

    // 关掉滑块的统一方法
    onSwipeCellCommonClick() {
      // 循环关闭开启的滑块
      //需要对单元格实例数组进行遍历,遍历以后获取每一个实例,让每一个实例调用close方法
      this.data.swipeCelQueue.forEach((instance) => {
        instance.close();
      });

      // 将滑块进行清空
      this.data.swipeCelQueue = [];
    },
  },
});

➡️ /modules/settingModule/pages/address/list/index.wxml

html
<view class="container address-list" bindtap="onSwipeCellPageTap">
  <van-swipe-cell
    right-width="{{ 65 }}"
    +
    id="swipe-cell-{{ item.id }}"
    +
    bind:open="SwipeCellOpen"
    +
    bind:click="onSwipeCellClick"
  >
    <!-- 代码略... -->
  </van-swipe-cell>
</view>

商品管理

01. 配置商品管理分包

思路分析:

随着项目功能的增加,项目体积也随着增大,从而影响小程序的加载速度,影响用户的体验。

因此我们需要将 商品列表商品详情 功能配置成一个分包,

当用户在访问设置页面时,还预先加载 商品列表商品详情 所在的分包

在分包后,通过查看代码依赖查看是否分包完成

📌 注意事项

  1. 在配置好 商品列表商品详情 的分包以后,需要更改页面中的跳转路径
  2. PS:可以利用项目全局搜索的功能,进行批量更改

实现步骤:

  1. modules 目录下创建 goodModule 文件夹,用来存放商品管理分包
  2. app.jsonsubpackages 进行商品管理分包配置
  3. app.jsonpreloadRule 进行商品管理分包配置

落地代码:

➡️ app.json

json
{
  "subPackages": [
    {
      "root": "modules/settingModule",
      "name": "settingModule",
      "pages": [
        "pages/address/add/index",
        "pages/address/list/index",
        "pages/profile/profile"
      ]
    },
+     {
+       "root": "modules/goodModule",
+       "name": "goodModule",
+       "pages": ["pages/goods/list/list", "pages/goods/detail/detail"]
    }
  ],
  "preloadRule": {
    "pages/settings/settings": {
      "network": "all",
      "packages": ["settingModule"]
    },
+     "pages/category/category": {
+       "network": "all",
+       "packages": ["goodModule"]
+     }
  }
}

02. 封装商品模块接口 API

思路分析:

为了方便后续进行商品管理模块的开发,我们在这一节将商品管理所有的接口封装成接口 API 函数

落地代码:

➡️ api/goods.js

js
import http from "../utils/http";

/**
 * @description 获取商品列表
 * @return Promise
 */
export const reqGoodsList = ({ limit, page, ...data }) => {
  return http.get(`/goods/list/${page}/${limit}`, data);
};

/**
 * @description 获取商品详情
 * @param {*} goodsId 商品Id
 * @returns Promise
 */
export const reqGoodsInfo = (goodsId) => {
  return http.get(`/goods/${goodsId}`);
};

03. 商品列表-准备列表请求参数

思路分析:

当用户点击了商品分类以后,需要获取对应分类的商品列表信息,因此我们需要先获取到该分类的 id,只要获取到 id 以后,才能向服务器获取对应分类的商品列表信息。同时我们需要查看接口文档,查看是否需要使用其他参数,我们提前将参数准备好。

首先熟悉接口文档:获取商品分页列表

参数名称参数说明是否必须
limit每页记录数true
page当前页码true
category1Id一级分类的 Id (从首页导航分类区域点击进入)false
category2Id二级分类的 Id (从分类页面点击进入二级分类进入)false

通过接口文档得知,我们需要以上的参数,我们先将参数提前声明,然后在发起请求获取商品列表的数据

实现步骤:

  1. 在商品列表的 data 字段中,根据接口文档,定义商品列表接口需要使用的字段
  2. 在商品列表的 onLoad 钩子函数中接收请求的参数,并将请求参数进行合并

落地代码:

➡️ /modules/goodsModule/pages/list/list.js

js
Page({
  // 页面的初始数据
  data: {
    goodsList: [], // 商品列表数据
    isFinish: false, // 判断数据是否加载完毕

+     // shang请求参数
+     requestData: {
+       page: 1, // 页码
+       limit: 10, // 每页请求多少条数据
+       category1Id: '', // 一级分类 id
+       category2Id: '' // 二级分类 id
+     }
  },

+     // 生命周期函数--监听页面加载
+     onLoad(options) {
+       // 接收传递的参数
+       Object.assign(this.data.requestData, options)
+     }
})

04. 商品列表-获取商品列表数据并渲染

思路分析:

在准备商品列表的请求参数以后,

在页面调用 API 函数获取商品列表的数据,在获取到数据以后,使用后端返回的数据对页面进行渲染。

实现步骤:

  1. /pages/goods/list/list.js 中导入封装好的获取商品列表的 API 函数
  2. 页面数据在页面加载的时候进行调用,在 onLoad 钩子函数中调用 reqGoodsList 方法
  3. 在获取到数据以后,使用后端返回的数据对页面进行渲染

落地代码:

➡️ /modules/goodsModules/pages/list/list.js

js
+ import { reqGoodsList } from '../../../../../api/goods'

Page({
  // 页面的初始数据
  data: {
    goodsList: [], // 商品列表数据
+     total: 0, // 数据总条数
    isFinish: false, // 判断数据是否加载完毕
    // 接口请求参数
    requestData: {
      page: 1, // 页码
      limit: 10, // 每页请求多少条数据
      category1Id: '', // 一级分类 id
      category2Id: '' // 二级分类 id
    }
  },

+   // 获取商品列表的数据
+   async getGoodsList() {
+     // 调用 API 获取数据
+     const { data } = await reqGoodsList(this.data.requestData)
+
+     // 将返回的数据赋值给 data 中的变量
+     this.setData({
+       goodsList: data.records,
+       total: data.total
+     })
+   },

  // 生命周期函数--监听页面加载
  onLoad(options) {
    // 接收传递的参数
    Object.assign(this.data.requestData, options)

+    // 获取商品列表的数据
+    this.getGoodsList()
  }
})

➡️ /modules/goodsModule/pages/list/list.wxml

html
<view class="container">
  <!-- 商品列表功能 -->
  +
  <view class="goods-list" wx:if="{{ goodsList.length }}">
    +
    <block wx:for="{{ goodsList }}" wx:key="id">
      + <goods-card goodItem="{{ item }}"></goods-card> +
    </block>

    <!-- 数据是否加载完毕 -->
    <view class="finish" hidden="{{ !isFinish }}">数据加载完毕~~~</view>
  </view>

  +
  <!-- 商品为空的时候展示的结构 -->
  +
  <van-empty wx:else description="该分类下暂无商品,去看看其他商品吧~">
    +
    <van-button round type="danger" class="bottom-button" bindtap="gotoBack">
      + 查看其他商品 +
    </van-button>
    +
  </van-empty>
</view>

05. 商品列表-实现上拉加载更多功能

思路分析:

当用户从下向上滑动屏幕时,需要加载更多的商品数据。

首先需要在 .js 文件中声明 onReachBottom 方法监听用户是否进行了上拉

当用户上拉时,需要对 page 页码进行加 1,代表要请求下一页的数据

当参数发生改变后,需要重新发送请求,拿最新的 page 向服务器发送请求获取数据。

在下一页的商品数据返回以后,将最新的数据和之前的数据进行合并

实现步骤:

  1. list.js 文件中声明 onReachBottom 事件处理函数,监听用户的上拉行为
  2. onReachBottom 函数中加 page 进行加 1 的操作,同时发送请求获取下一页数据
  3. getGoodsList 函数中,实现参数的合并

落地代码:

➡️ /modules/goodsModule/pages/list/list.js

js
import { reqGoodsList } from '../../../api/goods'

Page({

  // coding...

  // 获取商品列表的数据
  async getGoodsList() {
    // 调用 API 获取数据
    const { data } = await reqGoodsList(this.data.params)

    // 将返回的数据赋值给 data 中的变量
    this.setData({
+       goodsList: [...this.data.goodsList, ...data.records],
      total: data.total
    })
  },

  // coding...

+   // 监听页面的上拉操作
+   onReachBottom() {
    // 解构数据
+     const { page } = this.data.requestData
+
+     // 页码 + 1
+     this.setData({
+       requestData: { ...this.data.requestData, page: page + 1 }
+     })
+
+     // 重新发送请求,获取商品列表
+     this.getGoodsList()
+   }

})

06. 商品列表-判断数据是否加载完毕

思路分析:

上一节我们实现了上拉加载功能。

在这一节,我们需要相关的优化:判断数据是否已经加载完,如果加载已经加载完毕,需要给用户进行提示。

如何判断数据是否加载完成 ❓

可以使用后端返回的 totalgoodsList 进行对比,如果 total 大于等于 goodsList ,说明商品列表数据没有加载完,可以继续上拉加载更多。

在模板中,我们通过 totalgoodsList 进行对比,决定是否展示对应的文案

实现步骤:

  1. 在数据返回以后,将数据中的 total 赋值给 data 中的变量 total
  2. onReachBottom 中进行 totalgoodsList 进行对比
  3. 模板中使用 totalgoodsList 进行对比

落地代码:

➡️ /modules/goodsModule/pages/list/list.js

js
import { reqGoodsList } from '../../../api/goods'

Page({

  // coding...

  // 监听页面的上拉操作
  onReachBottom() {
+     // 从 data 中解构数据
+     const { total, goodsList, requestData } = this.data
+     const { page } = requestData
+
+     // 判断数据是否加载完毕
+     if (total === goodsList.length) {
+       // 如果相等,数据数据加载完毕
+       // 如果数据加载完毕,需要给用户提示,同时不继续加载下一个数据
+       this.setData({
+         isFinish: true
+       })
+
+       return
+     }

    // 页码 + 1
    this.setData({
      requestData: { ...this.data.requestData, page: (page += 1) }
    })

    // 重新发送请求
    this.getGoodsList()
  }
})

07. 商品列表-节流阀进行列表节流

在用户网速很慢的情况下,如果用户在距离底部来回的进行多次滑动,可能会发送一些无意义的请求、造成请求浪费的情况,因此需要给上拉加载添加节流功能。

我们使用节流阀来给商品列表添加节流功能。

data 中定义节流阀状态 isLoading,默认值是 false

在请求发送之前,将 isLoading 设置为 true,表示请求正在发送。

在请求结束以后,将 isLoading 设置为 false,表示请求已经完成。

onReachBottom 事件监听函数中,对 isLoading 进行判断,如果数据正在请求中,不请求下一页的数据。

落地代码:

➡️ /modules/goodsModule/pages/list/list.js

js
import { reqGoodsList } from '../../../../../api/good'

Page({
  // 页面的初始数据
  data: {
    goodsList: [], // 商品列表数据
    isFinish: false, // 判断数据是否加载完毕
+     isLoading: false, // 判断数据是否记载完毕
    total: 0, // 列表总数据量
    // 接口请求参数
    requestData: {
      page: 1, // 页码
      limit: 10, // 每页请求多少条数据
      category1Id: '', // 一级分类 id
      category2Id: '' // 二级分类 id
    }
  },

  // 获取商品列表的数据
  async getGoodsList() {
+     // 在请求发送之前,需要将isLoading设置为true,表示数据正在请求中
+     this.data.isLoading = true

    // 调用 API 获取数据
    const { data } = await reqGoodsList(this.data.requestData)

+     // 数据加载完毕
+     this.data.isLoading = false

    // 将返回的数据赋值给 data 中的变量
    this.setData({
      goodsList: [...this.data.goodsList, ...data.records],
      total: data.total
    })
  },

  // 监听页面的上拉操作
  onReachBottom() {
    // 从 data 中解构数据
    const { total, goodsList, requestData, isLoading } = this.data
    let { page } = requestData

    //上面要把isLoading解构出来
+    // 判断是否加载完毕,如果 isLoading 等于 true
+    // 说明数据还没有加载完毕,不加载下一页数据
+    if (isLoading) return

    // 判断数据是否加载完毕
    // coding...
  }
})

08. 商品列表-实现下拉刷新功能

下拉刷新是小程序中常见的一种刷新方式,当用户下拉页面时,页面会自动刷新,以便用户获取最新的内容。

小程序中实现上拉加载更多的方式:

  1. 页面.json 中开启允许下拉,同时可以配置 窗口、loading 样式等
  2. 页面.js 中定义 onPullDownRefresh 事件监听用户下拉刷新

落地代码:

➡️ /modules/goodsModule/pages/list/list.json

json
{
  "usingComponents": {
    "goods-card": "/components/goods-card/goods-card"
  },

  "navigationBarTitleText": "商品列表",
  "enablePullDownRefresh": true,
  "backgroundColor": "#f7f4f8",
  "backgroundTextStyle": "dark"
}

➡️ /modules/goodsModule/pages/list/list.js

js
// 监听页面的下拉刷新
onPullDownRefresh() {
  // 将数据进行重置
  this.setData({
    goodsList: [],
    total: 0,
    isFinish: false,
    requestData: { ...this.data.requestData, page: 1 }
  })

  // 使用最新的参数发送请求,重新获取列表数据
  this.getGoodsList()

  //手动关闭下拉刷新的效果
    wx.stopPullDownRefresh()
}

下拉刷新体验感优化:

下拉之后,原本是会闪现没有商品,去别处看看吧的页面

加一个骨架屏,优化用户体验

具体操作:

按照骨架屏使用方法,进行构建,引入文件,设置loading属性值

在商品列表页先设置为false,即不显示骨架屏

当进行下拉刷新操作时,将loading属性值改为true,显示骨架屏

调用发送请求获取列表数据的函数

请求发送成功后,返回数据,将返回的数据赋值给data,改变页面loading值,把骨架屏关闭,显示正常的页面数据渲染

09. 商品详情-获取并渲染商品详情

思路分析:

点击首页轮播图以及点击商品列表商品的时候,需要跳转到商品详情页面

在跳转时将商品的id 传递到了商品详情页面,只需要使用 id 向后端服务器请求数据,获取对应商品的详情数据

在获取到数据以后,使用后端返回的数据对页面进行渲染。

实现步骤:

  1. /pages/goods/detail/detail.js 中导入封装好的获取商品列表的 API 函数
  2. 页面数据在页面加载的时候进行调用,在 onLoad 钩子函数中调用 reqGoodsInfo 方法
  3. 在获取到数据以后,使用后端返回的数据对页面进行渲染

落地代码:

➡️ /modules/goodsModule/pages/detail/detail.js

js
+ import { reqGoodsInfo } from '../../../api/goods'

Page({

  // 页面的初始数据
  data: {
    goodsInfo: {}, // 商品详情
    show: false, // 控制加入购物车和立即购买弹框的显示
    count: 1, // 商品购买数量,默认是 1
    blessing: '' // 祝福语
  },

+   // 获取商品的详情
+   async getGoodsInfo() {
+     // 调用接口、传入参数、获取商品详情
+     const { data: goodsInfo } = await reqGoodsInfo(this.goodsId)
+
+     // 将商品详情数据赋值给 data 中的变量
+     this.setData({
+       goodsInfo
+     })
+   },

+   // 生命周期函数--监听页面加载
+   onLoad(options) {
+     // 将商品 id 挂载到页面实例上
+     this.goodsId = options.goodsId ? options.goodsId : ''
+
+     // 获取商品详情的数据
+     this.getGoodsInfo()
+   }

  // coding...
})

➡️ /modules/goodsModule/pages/detail/detail.html

html
<view class="container goods-detail">
  <!-- 商品大图 -->
  <view class="banner-img"> + <image class="img" src="{{ goodsInfo.imageUrl }}" /> </view>

  <!-- 商品的基本信息 -->
  <view class="content">
    <view class="price">
      + <view class="price-num">¥{{ goodsInfo.price }}</view> +
      <view class="price-origin-num">¥{{ goodsInfo.marketPrice }}</view>
    </view>
    + <view class="title">{{ goodsInfo.name }}</view> +
    <view class="desc">{{ goodsInfo.material }}</view>
  </view>

  <!-- 商品的详细信息 -->
  <view class="detail">
    <image
      +
      wx:for="{{ goodsInfo.detailList}}"
      +
      wx:key="index"
      +
      src="{{ item }}"
      class="img"
      mode="widthFix"
    />
  </view>

  <!-- 商品的底部商品导航 -->
  <van-goods-action>
    <!-- 代码略... -->
  </van-goods-action>

  <!-- 加入购物车、立即购买弹框 -->
  <!-- show 控制弹框的隐藏和展示 -->
  <!-- bind:close 点击关闭弹框时触发的回调 -->
  <van-action-sheet show="{{ show }}" bind:close="onClose">
    <view class="sheet-wrapper">
      <view class="goods-item">
        <!-- 需要购买的商品图片 -->
        <view class="mid"> + <image class="img" src="{{ goodsInfo.imageUrl }}" /> </view>

        <!-- 商品基本信息 -->
        <view class="right">
          <!-- 商品名字 -->
          + <view class="title"> {{ goodsInfo.name }} </view>
          <!-- 商品价格 -->
          <view class="buy">
            <view class="price">
              <view class="symbol">¥</view>
              + <view class="num">{{ goodsInfo.price }}</view>
            </view>

            <!-- 购买数量弹框 -->
            <view class="buy-btn">
              <!-- Stepper 步进器,由增加按钮、减少按钮和输入框组成,控制购买数量 -->
              <van-stepper value="{{ count }}" bind:change="onChangeGoodsCount" />
            </view>
          </view>
        </view>
      </view>

      <!-- 祝福语输入框 -->
      <view class="time-wraper">
        <!-- 代码略... -->
      </view>

      <!-- 取消、确定弹框 -->
      <view class="sheet-footer-btn">
        <van-button block type="primary" round> 确定 </van-button>
      </view>
    </view>
  </van-action-sheet>
</view>

10. 商品详情-详情图片预览功能

思路分析:

当点击商品的图片时,需要将图片进行全屏预览

如果想实现该功能,需要使用小程序提供的 APIwx.previewImage(),用来在新页面中全屏预览图片。预览的过程中用户可以进行保存图片、发送给朋友等操作。语法如下:

js
wx.previewImage({
  current: "", // 当前显示图片的 http 链接
  urls: [], // 需要预览的图片 http 链接列表
});

实现步骤:

  1. 给展示大图的 image 组件绑定点击事件,同时通过自定义属性的方式,传递当前需要显示的图片http 链接
  2. 同时商品详情的数组数据传递给 urls 数组即可

落地代码:

➡️ /pages/goods/detail/detail.html

html
<!-- 商品大图 -->
<view class="banner-img">
  <image
     class="img"
     src="{{ goodsInfo.imageUrl }}"
     bindtap="previewImg"
         //给大图绑定点击事件
  />
</view>

➡️ /pages/goods/detail/detail.js

js
// 全屏预览商品图片
previewImg() {
  // 调用预览图片的 API
  wx.previewImage({
    urls: this.data.goodsInfo.detailList
  })
}

优化:配置 @ 路径别名优化访问路径

在对小程序进行分包时,如果访问小程序根目录下的文件,那么访问的路径就会很长。

在 Vue 中,可以使用 @ 符号指向源码目录,简化路径,小程序也给提供了配置的方式。

在小程序中可以在 app.json 中使用 resolveAlias 配置项用来自定义模块路径的映射规则。

官方文档: resolveAlias

json
{
  "resolveAlias": {
    "@/*": "/*"
  }
  //意思是@指向miniprogram
}

📌:注意事项:

  1. resolveAlias 进行的是路径匹配,其中的 key 和 value 须以 /* 结尾
  2. 如果在 project.config.json 中指定了 miniprogramRoot,则 /* 指代的根目录是 miniprogramRoot 对应的路径,而不是开发者工具项目的根目录

购物车

01. 购物车-封装购物车接口 API

思路分析:

为了方便后续进行购物车模块的开发,我们在这一节将购物车所有的接口封装成接口 API 函数

落地代码:

js
import http from '@/utils/http'

/**
 * @description 获取购物车列表数据
 * @returns Promise
 */
export const reqCartList = () => {
  return http.get('/cart/getCartList')
}

/**
 * @description 加入购物车
 * @param {*} data
 * @returns Promise
 */
/* export const reqAddCart = (data) => {
  return http.get(`/cart/addToCart/${data.goodsId}/${data.count}`, data)
}*/
export const reqAddCart = ({goodsId,count,...data}) => {
  return http.get(`/cart/addToCart/${goodsId}/${count}`, data)


/**
 * @description 更新商品的选中状态
 * @param {*} goodsId 商品 id
 * @param {*} isChecked 商品的选中状态 0为取消勾选
 * @returns Promise
 */
export const reqUpdateChecked = (goodsId, isChecked) => {
  return http.get(`/cart/checkCart/${goodsId}/${isChecked}`)
}

/**
 * @description 全选和全不选
 * @param {*} isChecked 商品的选中状态  0为取消全选
 * @returns Promise
 */
export const reqCheckAllStatus = (isChecked) => {
  return http.get(`/cart/checkAllCart/${isChecked}`)
}

/**
 * @description 删除购物车商品
 * @param {*} goodsId 商品 id
 * @returns Promise
 */
export const reqDelCartGoods = (goodsId) => {
  return http.get(`/cart/delete/${goodsId}`)
}

02. 加入购物车-模板分析和渲染

业务介绍

点击加入购物车和立即购买的时候,展示购物弹框,在弹框中需要用户选择购买数量和祝福语

点击加入购物车和立即购买,触发的是同一个弹框。

因此点击弹框中的确定按钮时,我们需要区分当前是加入购物车操作还是立即购买操作。

这时候定义一个状态 buyNow 做区分,buyNow 等于 1 代表是立即购买,否则是加入购物车

产品需求

  1. 如果点击的是加入购物车,需要将当前商品加入到购物车
  2. 如果点击的是立即购买,需要跳转到结算支付页面,立即购买该商品
  3. 如果是立即购买,不支持购买多个商品

结构分析

点击立即购买和加入购物车的时候,通过 show 属性,控制弹框的隐藏和展示

html
<!-- 商品的底部商品导航 -->
<van-goods-action>
  <!-- coding... -->
  + <van-goods-action-button text="加入购物车" type="warning" bindtap="handleAddcart" /> +
  <van-goods-action-button text="立即购买" bindtap="handeGotoBuy" />
</van-goods-action>

<!-- 加入购物车、立即购买弹框 -->
<!-- show 控制弹框的隐藏和展示 -->
<!-- bind:close 点击关闭弹框时触发的回调 -->
<van-action-sheet show="{{ show }}" bind:close="onClose">
  <view class="sheet-wrapper">
    <!-- 代码略... -->

    <!-- 购买数量弹框 -->
    +
    <view class="buy-btn" wx:if="{{ buyNow === 0 }}">
      <!-- Stepper 步进器,由增加按钮、减少按钮和输入框组成,控制购买数量 -->
      <van-stepper value="{{ count }}" bind:change="onChangeGoodsCount" />
    </view>

    <!-- 代码略... -->
  </view>
</van-action-sheet>

点击立即购买和加入购物车的时候,通过 buyNow 属性,来区分是进行的某种操作

js
Page({

  // 页面的初始数据
  data: {
    goodsInfo: {}, // 商品详情
    show: false, // 加入购物车和立即购买时显示的弹框
    count: 1, // 商品购买数量,默认是 1
    blessing: '', // 祝福语
+     buyNow: 0 // 是否立即购买 0是加入购物车
  },


  // 加入购物车
  handleAddcart() {
    this.setData({
      show: true,
+       buyNow: 0
    })
  },

  // 立即购买
  handeGotoBuy() {
    this.setData({
      show: true,
+       buyNow: 1
    })
  },


  // 代码略...
})

03. 加入购物车-关联 Store 对象

思路分析:

当用户点击加入购物车 或者 立即购买时,需要判断用户是否进行了登录。

我们需要使用 Token 进行判断,因此需要让页面和 Store 对象建立关联。

这时候可以使用 BehaviorWithStore 让页面 和 Store 对象建立关联。

落地代码:

➡️ /behaviors/userBehavior.js

js
// 导入 BehaviorWithStore 让页面和 Store 对象建立关联
import { BehaviorWithStore } from "mobx-miniprogram-bindings";
// 导入用户 Store
import { userStore } from "@/stores/userstore";

export const userBehavior = BehaviorWithStore({
  storeBindings: {
    store: userStore,
    fields: ["token"],
  },
});

➡️ /behaviors/userBehavior.js

js
import { reqGoodsInfo } from '@/api/goods'
import { reqAddCart } from '@/api/cart'
导入创建的behavior
+ import { userBehavior } from '@/behaviors/userBehavior'

Page({
    注册
+   behaviors: [userBehavior],

  // 代码略...
})

04. 加入购物车和立即购买区分处理

思路分析:

点击加入购物车以及立即购买以后,需要先判断是否进行了登录,如果用户没有登录过,需要先跳转到登录页面进行登录。

如果点击的是 加入购物车,我们只需要调用 加入购物车 接口即可 (需要获取商品的 ID 、购买数量、祝福语)

如果点击的是 立即购买,我们需要携带参数跳转到商品结算页面 (获取商品的 ID 以及 祝福语跳转到结算页面)

购买数量的限制有 4 个限制,这 4 个限制直接使用 Vant 组件提供的属性进行限制即可:

  1. 必须是正整数,最小是1,最大是200
  2. 若输入小于1,则重置为1
  3. 若输入大于200,则重置为200
  4. 若输入的是其他值,则重置为1

实现步骤:

  1. Stepper 步进器组件,通过value设置输入值,同时绑定change事件,并将值同步到 data
  2. 根据接口文档,导入封装的购物车的接口 API
  3. 点击弹框按钮的时候,判断点击的加入购物车还是立即购买,执行不同的操作

落地代码:

➡️ /modules/goodsModule/pages/detail/detail.html

html
<van-stepper value="{{ count }}" + interge + min="1" + max="200" bind:change="onChangeGoodsCount" />

➡️ /modules/goodsModule/pages/detail/detail.js

js
// 监听是否更改了购买数量
onChangeGoodsCount(event) {
  // 将最新的购买数量同步到 data
  this.setData({
    count: Number(event.detail)
  })
},

// 弹框的确定按钮触发的事件处理函数
async handlerSubmit() {
 // 解构获取数据
 const { token, count, blessing, buyNow } = this.data
 获取商品的id
 const goodsId = this.goodsId

  // 判断是否有登录,如果没有 token ,让用户重新登录
  if (!token) {
    wx.navigateTo({
      url: '/pages/login/login'
    })

    return
  }

  // 将用户输入的值转成 Number 类型
  const count = Number(event.detail)
  // 验证购买数量的正则
  const reg = /^([1-9]|[1-9]\d|1\d{2}|200)$/
  // 使用正则验证
  const res = reg.test(count)

  // 如果验证没有通过,直接返回,不执行后续的逻辑
  if (!res) return


  // 区分处理加入购物车和立即购买
  if (buyNow === 0) {
    // 加入购物车
    const res = await reqAddCart({ goodsId, count, blessing })

    if (res.code === 200) {

      wx.toast({
        title: '加入购物车成功'
      })
      隐藏
      this.setData({
        show: false
      })

    }
  } else {
    // 立即购买
    wx.navigateTo({
      url: `/pages/order/detail/detail?goodsId=${goodsId}&blessing=${blessing}`
    })
  }
}

05. 加入购物车-展示购物车购买数量

思路分析

判断用户是否进行了登录。

如果没有登录过,则不展示购物车商品的数量。

如果用户登录过,则需要展示购物车商品的数量,则获取购物车列表数据,通过累加计算得出商品购买数量

实现步骤

  1. 进入商品详情,调用方法,在方法中判断token是否存在
  2. 如何存在,则获取购物车列表数据,通过累加计算得出商品购买数量,展示购买的数量
  3. 不存在,不执行任何逻辑,

落地代码

➡️ /modules/goodsModule/pages/detail/detail.js

javascript
Page({

  data: {
    // coding...
+     allCount: '' // 购物车商品总数量
  },

      // 弹框的确定按钮
  async handleSubmit() {
    // 如果没有 token ,让用户新登录
    if (!this.data.token) {
      wx.navigateTo({
        url: '/pages/login/login'
      })

      return
    }

    // 解构获取数据
    const { count, blessing, allCount } = this.data
    const goodsId = this.goodsId

    // 加入购物车
    if (this.data.buyNow === 0) {
      // 加入购物车
      const res = await reqAddCart({ goodsId, count, blessing })

      if (res.code === 200) {
        wx.toast({
          title: '加入购物车成功',
          icon: 'success',
          mask: false
        })


+         // 购物车购买数量合计
+         this.getCartCount()

        this.setData({
          show: false
        })
      }
    } else {
      // 立即购买
      wx.navigateTo({
        url: `/pages/order/detail/detail?goodsId=${goodsId}&blessing=${blessing}`
      })
    }
  },

+   // 计算购物车商品数量
+   async getCartCount() {
+     // 如果没有 token ,说明用户是第一次访问小程序,没有进行登录过
+     if (!this.data.token) return
+
    //若存在token
+     // 获取购物的商品
+     const res = await reqCartList()
+
    //判断购物车中是否存在商品
+     if (res.data.length !== 0) {
+       // 购物车商品累加
+       let allCount = 0
+
+       // 获取购物车商品数量
+       res.data.forEach((item) => {
+         allCount += item.count
+       })
+
+       // 将购物车购买数量赋值
+       this.setData({
+         // 展示的数据要求是字符串
    //而且如果购买数量大于99,则需要显示99+
+         allCount: (allCount > 99 ? '99+' : allCount) + ''
    //字符串隐式转换
+       })
+     }
+   },

  onLoad(options) {
    // 接收传递的商品 ID,并且将 商品 ID 挂载到 this 上面
    this.goodsId = options.goodsId

    // 调用获取商品详情数据的方法
    this.getGoodsInfo()

+     // 计算购买数量
+     this.getCartCount()
  }

  // coding...
})

06. 购物车-购物车关联 Store 对象

思路分析:

当用户进入购物车页面时时,需要判断用户是否进行了登录来控制页面的展示效果

这时候我们就需要使用 Token 进行判断,因此需要让页面和 Store 对象建立关联。

因为购物车页面采用的 Component 方法进行构建

这时候可以使用 ComponentWithStore 让页面 和 Store 对象建立关联。

落地代码:

➡️/pages/cart/components/cart.js

js
+ import { ComponentWithStore } from 'mobx-miniprogram-bindings'
+ import { userStore } from '@/stores/userstore'
+ import { reqCartList } from '@/api/cart'

+ ComponentWithStore({
+   storeBindings: {
+     store: userStore,
+     fields: ['token']
+   },

  // 组件的初始数据
  data: {
    cartList: [],
    emptyDes: '还没有添加商品,快去添加吧~'
  },

+   // 组件的方法列表
+   methods: {
+     // 处理页面的展示
+     async showTipList() {
+       // 将 token 进行解构
+       const { token } = this.data
+
+   console.log(token)
+     },

    //如果使用component方法构建页面
    //生命周期函数要写到methods里面
    onShow() {
+       this.showTipList()
    }
  }
})

07. 购物车-获取并渲染购物车列表

思路分析:

  1. 如果没有进行登录,购物车页面需要展示文案:您尚未登录,点击登录获取更多权益
  2. 如果用户进行登录,获取购物车列表数据
    • 购物车没有商品,展示文案: 还没有添加商品,快去添加吧~
    • 购物车列表有数据,需要使用数据对页面进行渲染

实现步骤:

  1. 导入封装好的获取列表数据的 API 函数
  2. onShow 钩子中,根据产品的需求,处理页面的提示
  3. 在获取到数据以后,使用后端返回的数据对页面进行渲染

落地代码:

➡️/pages/cart/cart.js

js
import { ComponentWithStore } from 'mobx-miniprogram-bindings'
import { userStore } from '@/stores/userstore'
import { reqCartList } from '@/api/cart'

ComponentWithStore({
  storeBindings: {
    store: userStore,
    fields: ['token']
  },

  // 组件的初始数据
  data: {
    cartList: [],
    emptyDes: '还没有添加商品,快去添加吧~'
  },

  // 组件的方法列表
  methods: {
+     // 获取购物车列表数据 + 处理页面的展示,展示文案信息
+     async showTipGetList() {
+       // 将 token 进行解构
+       const { token } = this.data
+
+       // 1. 如果没有登录,购物车列表,展示文案:您尚未登录,点击登录获取更多权益
+       if (!token) {
+         this.setData({
+           emptyDes: '您尚未登录,点击登录获取更多权益',
+           cartList: []
+         })
+
+         return
+       }
+
    //如果进行了登录
+       // 获取商品列表数据
+       const { data: cartList, code } = await reqCartList()
+
+       if (code === 200) {
+         // 2. 如果用户登录,购物车列表为空,展示文案: 还没有添加商品,快去添加吧~
+         this.setData({
+           cartList,
+           emptyDes: cartList === 0 && '还没有添加商品,快去添加吧~'
+         })
+       }
+     },

    // 页面展示时触发
    onShow() {
+       this.showTipGetList()
    }
  }
})

➡️/pages/cart/components/cart.wxml

html
<view>
  <view
    存在token且列表不为空
    wx:if="{{ token && cartList.length }}"
    class="container goods-wrap"
    bindtap="onSwipeCellPageTap"
  >
    <view class="cart-wrap">
      +
      <view class="goods-item" wx:for="{{ cartList }}" wx:key="goodsId">
        <van-swipe-cell class="goods-swipe" right-width="{{ 65 }}">
          <view class="goods-info">
            <view class="left">
              <van-checkbox checked-color="#FA4126" + value="{{ item.checked }}"></van-checkbox>
            </view>
            <view class="mid"> + <image class="img" src="{{ item.imageUrl }}" /> </view>
            <view class="right">
              + <view class="title"> {{ item.name }} </view>
              <view class="buy">
                <view class="price">
                  <view class="symbol">¥</view>
                  + <view class="num">{{ item.price }}</view>
                </view>
                <view class="buy-btn"> + <van-stepper value="{{ item.count }}" /> </view>
              </view>
            </view>
          </view>
          <view slot="right" class="van-swipe-cell__right">删除</view>
        </van-swipe-cell>
      </view>
    </view>

    <!-- 底部工具栏 -->
    <van-submit-bar price="{{ 3050 }}" button-text="去结算" tip="{{ true }}">
      <van-checkbox value="{{ true }}" checked-color="#FA4126"> 全选 </van-checkbox>
    </van-submit-bar>
  </view>

  <van-empty wx:else description="{{ emptyDes }}">
    +
    <navigator
      url="/pages/index/index"
      +
      open-type="switchTab"
      wx:if="{{ token && cartList.length === 0 }}"
    >
      + <van-button round type="danger" class="bottom-button">去购物</van-button> +
    </navigator>
    + +
    <navigator url="/pages/login/login" wx:else>
      + <van-button round type="danger" class="bottom-button">去登录</van-button> +
    </navigator>
  </van-empty>
</view>

08. 购物车-更新商品的购买状态

思路分析:

点击商品的复选框时,更新商品的购买状态。

  1. 获取商品最新的购买状态,将最新的状态同步到服务器(需要调用封装的接口 API 函数,0 不购买,1 购买)
  2. 在服务器更新状态更新成功以后,将本地的数据一并改变。

实现步骤:

  1. 导入封装好的获取列表数据的 API 函数
  2. 当点击切换切换商品状态的时候,调用 reqUpdateGoodStatus,并传参
  3. 在更新成功,将本地的数据一并改变。

落地代码:

➡️ /pages/cart/cart.wxml

html
<van-checkbox
  checked-color="#FA4126"
  +
  value="{{ item.isChecked }}"
  +
  bind:change="updateChecked"
  +
  data-id="{{ item.goodsId }}"
  +
  data-index="{{ index }}"
></van-checkbox>

➡️ /pages/cart/cart.js

js
import { ComponentWithStore } from 'mobx-miniprogram-bindings'
import { userStore } from '@/stores/userstore'

+ import { reqCartList, reqUpdateChecked } from '@/api/cart'

Component({

  // coding...

  // 组件的方法列表
  methods: {

    // 切换商品的选中状态
    async updateChecked(event) {
      // 获取最新的选中状态
      const { detail } = event
      // 获取商品的索引和 id
      const { id, index } = event.target.dataset
      // 将最新的状态格式化成后端所需要的数据格式
      const isChecked = detail ? 1 : 0

      // 调用接口,传入参数,更新商品的状态
      const res = await reqUpdateChecked(id, isChecked)

      // 如果数据更新成功,需要将本地的数据一同改变
      if (res.code === 200) {
          //服务器更新购买状态成功以后,获取最新的购物车列表
          //this.showTipGetList()

        this.setData({
          [`cartList[${index}].isChecked`]: isChecked
        })
      }
    },

    // 获取购物车列表数据
    async getCartList() {
      // coding...
    }
  }
})

09. 购物车-控制全选按钮的选中状态

思路分析:

购物车列表中每个商品的状态 isCheckd 都是 1,说明每个商品都需要进行购买。

这时候就需要控制底部工具栏全选按钮的选中效果。

基于购物车列表中已有的数据,产生一个新的数据,来控制全选按钮的选中效果,可以使用 计算属性 来实现。

安装 框架拓展 computed

cmd
# 安装并构建 框架拓展 computed
<PageInfo />
npm install --save miniprogram-computed

实现步骤:

  1. cart 组件中引入 miniprogram-computed ,然后再 behaviors 中进行注册
  2. 新建 computed 配置项,新增 allStatus 函数用来判断是否是全选

落地代码:

➡️ /pages/cart/cart.js

js
import { ComponentWithStore } from 'mobx-miniprogram-bindings'
import { userStore } from '@/stores/userstore'
import { reqCartList, reqUpdateChecked } from '@/api/cart'

//导入 miniprogram-computed 提供的behavior
+ const computedBehavior = require('miniprogram-computed').behavior

ComponentWithStore({

+   // 注册计算属性
+   behaviors: [computedBehavior],

+   computed: {
  //计算属性会被挂载到data对象中
+     // 判断是否全选,控制全选按钮的选中效果
+
+     selectAllStatus(data) {
     // computed 函数中不能使用this来访问data中的数据
 //如果想访问 data 中的数据,需要使用形参
+       return (
+         data.cartList.length !== 0 && data.cartList.every((item) => item.isChecked === 1)
+       )
+     }
+   }

 // 其他代码略...
})

➡️ /pages/cart/cart.wxml

html
<!-- 底部工具栏 -->
<van-submit-bar price="{{ 3050 }}" button-text="去结算" tip="{{ true }}">
  + <van-checkbox value="{{ selectAllStatus }}" checked-color="#FA4126"> 全选 </van-checkbox>
</van-submit-bar>

10. 购物车-实现全选和全不选功能

思路分析:

点击全选,控制所有商品的选中与全不选效果

  1. 点击全选按钮,获取全选按钮的选中状态(true, false),同时控制所有商品的选中与全不选效果
  2. 在获取到全选按钮状态以后,同时需要将状态同步给服务器 (1 是全选,0 是全不选)
  3. 在服务器更新成功以后,需要将本地的购物车商品选中状态也进行改变

实现步骤:

  1. 导入封装好的全选的 API 函数
  2. 当点击全选和全不选按钮的时候,调用 reqCheckAll,并传参
  3. 在更新成功,将本地的数据一并改变。

落地代码:

➡️ /pages/cart/cart.wxml

html
<!-- 底部工具栏 -->
<van-submit-bar price="{{ 3050 }}" button-text="去结算" tip="{{ true }}">
  <van-checkbox value="{{ selectAllStatus }}" checked-color="#FA4126" bind:change="selectAllStatus">
    全选
  </van-checkbox>
</van-submit-bar>

➡️ /pages/cart/cart.js

js
ComponentWithStore({
  // coding...

  methods: {
    // coding...

    // 实现全选和全不选功能
    async selectAllStatus(event) {
      // 获取按钮全选和全不选的状态
      const { detail } = event;
      //需要将选中的状态转换成接口需要使用的数据
      const isChecked = event.detail ? 1 : 0;
      // 调用接口,更新服务器中商品的状态,实现全选和全不选功能
      const res = await reqCheckAllStatus(isChecked);

      // 如果更新成功,需要将本地的数据一同改变
      if (res.code === 200) {
        //第一种办法:       this.showTipGetList()
        //第二种办法:
        // 将购物车数据进行深拷贝
        const newCart = JSON.parse(JSON.stringify(this.data.cartList));
        // 将数据进行更改
        newCart.forEach((item) => (item.isChecked = isChecked));
        把全选按钮的状态同步给每个商品;

        // 进行赋值,驱动视图更新
        this.setData({
          cartList: newCart,
        });
      }
    },

    // coding...
  },
});

11. 购物车-更新商品购买数量思路分析

思路分析:

在输入框中输入购买的数量,并**不是直接将输入的数量同步给服务器,而是需要计算差值**,服务器端进行处理

差值的计算公式:

shell
差值 = 新值 - 旧值

例如:

1. 原来是 1,用户输入 11, 差值是:11 - 1 = 10,传递给服务器的是:10,服务器接收到 10 + 1 = 11
2. 原来是 11,用户输入 5, 差值是:5 - 11 = -6,传递给服务器的是:-6,服务器接收到 -6 + 11 = 5

📌 注意事项:

更新购买数量 和 加入购物车,使用的是同一个接口,为什么加入购物车没有计算差值,

这是因为在加入购物车以后,服务器对商品购买数量直接进行了累加。

例如:之前购物车添加了某个商品,购买数量是 1 个,商品详情又加入 1 个, 直接累加,在购物车显示购买 2 个

12. 购物车-更新商品的购买数量

思路分析:

  1. 必须是正整数,最小是1,最大是200
  2. 如果输入的值大于200,输入框购买数量需要重置为200
  3. 输入的值不合法或者小于1,还原为之前的购买数量
js
const reg = /^([1-9]|[1-9]\d|1\d{2}|200)$/;

实现步骤:

  1. 给输入框绑定监听值是否改变的事件,同时传递商品的 ID id 和 商品的购买之前的购买数量 num
  2. 在事件处理程序中获取到最新的数据,然后进行差值的运算
  3. 发送请求即可

落地代码:

➡️ /pages/cart/cart.wxml

html
<van-stepper
  +
  integer
  +
  min="1"
  +
  max="200"
  value="{{ item.count }}"
  传递参数
  +
  data-id="{{ item.goodsId }}"
  对谁更新
  +
  data-oldbuynum="{{ item.count }}"
  原先数量
  +
  data-index="{{ index }}"
  +
  当前商品索引bindchange="changeBuyNum"
/>

➡️ /pages/cart/cart.js

js
// 更新购买的数量
async changeBuyNum(event) {
  // 获取最新的购买数量,
  // 如果用户输入的值大于 200,购买数量需要重置为 200
  // 如果不大于 200,直接返回用户输入的值
  const newBuyNum = event.detail > 200 ? 200 : event.detail
  // 获取商品的 ID 和 索引
  const { id: goodsId, index, oldbuynum } = event.target.dataset

  // 验证用户输入的值,是否是 1 ~ 200 直接的正整数
  const reg = /^([1-9]|[1-9]\d|1\d{2}|200)$/

  // 对用户输入的值进行验证
  const regRes = reg.test(newBuyNum)
//验证通过,结果为true
  // 如果验证没有通过,需要重置为之前的购买数量
  if (!regRes) {
    this.setData({
      [`cartList[${index}].count`]: oldbuynum
    })
//如果验证未通过,需要阻止代码往下运行
    return
  }

  // 如果通过,需要计算差值,然后将差值发送给服务器,让服务器进行逻辑处理
  const disCount = newBuyNum - oldbuynum

  // 如果购买数量没有发生改变,不发送请求
  if (disCount === 0) return

  // 发送请求:购买的数量 和 差值
  const res = await reqAddCart({ goodsId:id, count: disCount })

  // 服务器更新购买数量成功以后,需要更新本地的数据
  if (res.code === 200) {
    this.setData({
      [`cartList[${index}].count`]: newBuyNum
        //如果购买数量发生了变化,要让当前商品选中
   [`cartList[${index}].isChecked`]:1
    })
  }
}

13. 购物车-更新商品购买数量防抖

思路分析:

每次改变购物车购买数量的时候,都会触发 changeBuyNum 事件处理程序,这会频繁的向后端发送请求,给服务器造成压力

我们希望用户在输入最终的购买数量,或者停止频繁点击加、减的以后在发送请求,在将购买数量同步到服务器。

这时候就需要使用 防抖 来进行代码优化。

Licia 是实用 JavaScript 工具库,该库目前拥有超过 400 个模块,同时支持浏览器、node 及小程序运行环境。可以极大地提高开发效率。

licia 官网

licia 中文使用文档

落地代码:

➡️ /pages/cart/cart.js

js
// 从 miniprogram-licia 导入防抖函数
import { debounce } from 'miniprogram-licia'

// 更新购买的数量
+ changeBuyNum: debounce(async function (event) {
+   // 代码略...
+ }, 500)

14. 购物车-购物车商品合计

思路分析:

在订单提交栏位置,展示要购买商品的总金额。

需要判断购物车中哪些商品被勾选,然后将勾选商品的价格进行累加。

当用户更新了商品的状态,或者更新了商品的购买数量,我们都需要重新计算订单总金额。

我们需要基于购物车列表的数据,产生订单总金额,在这里我们使用依然使用 computed 来实现商品合计的功能

实现步骤:

  1. computed 配置项,新增 totalPrice 函数用来计算商品价格总和

落地代码:

➡️ /pages/cart/cart.wxml

html
<!-- 底部工具栏 -->
<van-submit-bar
  wx:if="{{ cartList.length }}"
  price="{{ totalPrice}}"
  这个展示价格,默认以分的形式进行展示,如果以元的形式展示,要*100
  button-text="去结算"
  tip="{{ true }}"
>
  <van-checkbox value="{{ selectAllStatus }}" checked-color="#FA4126" bindchange="selectAllStatus">
    全选
  </van-checkbox>
</van-submit-bar>

➡️ /pages/cart/cart.js

js
ComponentWithStore({

  // coding...

  // 定义计算属性
  computed: {

    // coding...

    // 计算商品价格总和
 +   totalPrice(data) {
    //用来对订单总金额进行累加
      let totalPrice = 0

      data.cartList.forEach((item) => {
        // 判断是否选中,如果商品的 isChecked 属性等于1,说明该商品被选中
        if (item.isChecked === 1) {
          totalPrice += item.count * item.price
        }
      })

      return totalPrice
    }
  },


  // coding...
})

15. 购物车-删除购物车中的商品

思路分析:

点击删除按钮的时候,需要将对应的购物车商品进行删除

实现步骤:

  1. 导入封装的接口 API 函数,同时导入处理删除自动关闭效果的 behaviors 并进行注册
  2. 在点击删除以后,调用 API 函数,在删除购物车商品成功以后,给用户提示

落地代码:

➡️ /pages/cart/components/cart.wxml

html
+  <view
        // 类似收货地址
         bindtap="onSwipeCellPage">

  <!-- 代码略 -->

  <van-swipe-cell
    class="goods-swipe"
    right-width="{{ 65 }}"
+     id="swipe-cell-{{ item.goodsId }}"
+     bind:open="swipeCellOpen"
+     bind:click="onSwipeCellClick"
  >
    <van-cell-group border="{{ false }}">
      <view class="goods-info">
        <view class="left">
          <van-checkbox
            checked-color="#FA4126"
            value="{{ item.isChecked }}"
            bindchange="updateChecked"
            data-id="{{ item.goodsId }}"
            data-index="{{ index }}"
          ></van-checkbox>
        </view>
        <view class="mid">
          <image class="img" src="{{ item.imageUrl }}" />
        </view>
        <view class="right">
          <view class="title"> {{ item.name }} </view>
          <view class="buy">
            <view class="price">
              <view class="symbol">¥</view>
              <view class="num">{{ item.price }}</view>
            </view>
            <view class="buy-btn">
              <van-stepper
                min="1"
                max="200"
                integer
                value="{{ item.count }}"
                data-id="{{ item.goodsId }}"
                data-index="{{ index }}"
                data-oldbuynum="{{ item.count }}"
                bindchange="changeBuyNum"
              />
            </view>
          </view>
        </view>
      </view>
    </van-cell-group>
    <view
      slot="right"
      class="van-swipe-cell__right"
+       bindtap="delCartGoods"
+       data-id="{{ item.goodsId }}"
    >
      删除
    </view>
  </van-swipe-cell>


  <!-- 代码略 -->
</view>

➡️ /pages/cart/components/cart.js

js
// 导入接口 API 函数
import {
  reqCartList,
  reqUpdateChecked,
  reqCheckAllStatus,
  reqAddCart,
+   reqDelCartGoods
} from '@/api/cart'

+ // 导入让删除滑块自动弹回的 behavior
+ import { swipeCellBehavior } from '@/behaviors/swipeCell'


ComponentWithStore({
  // 注册 behavior
+   behaviors: [swipeCellBehavior, computedBehavior],

  // 组件的方法列表
  methods: {

    // coding...

+   // 删除购物车中的商品
+   async delCartGoods(event) {
+     // 获取需要删除商品的 id
+     const { id } = event.currentTarget.dataset
+
+     // 询问用户是否删除该商品
+     const modalRes = await wx.modal({
+       content: '您确认删除该商品吗 ?'
+     })
+ //返回为true,代表要删除
+     if (modalRes) {
+       await reqDelCartGoods(id)
+
+       //删除后,重新获取购物车数据
    this.showTipGetList()
+     }
+   },

+     onHide() {
+       // 在购物车页面隐藏的时候,需要让删除滑块自动弹回
+       this.onSwipeCellCommonClick()
+     }
  }
})

结算支付

01. 配置分包并跳转到结算页面

思路分析:

随着项目功能的增加,项目体积也随着增大,从而影响小程序的加载速度,影响用户的体验。

因此我们需要将 结算支付 功能配置成一个分包,

当用户在访问设置页面时,还预先加载 结算支付 所在的分包

落地代码:

➡️ app.json

json
"subPackages": [
  {
    "root": "modules/settingModule",
    "name": "settingModule",
    "pages": [
      "pages/address/add/index",
      "pages/address/list/index",
      "pages/profile/profile"
    ]
  },
  {
    "root": "modules/goodModule",
    "name": "goodModule",
    "pages": ["pages/goods/list/list", "pages/goods/detail/detail"]
  },
+   {
+     "root": "modules/orderPayModule",
+     "name": "orderPayModule",
+     "pages": [
+       "pages/order/detail/detail",
+       "pages/order/list/list"
+     ]
+   }
],
"preloadRule": {
  "pages/settings/settings": {
    "network": "all",
    "packages": ["settingModule"]
  },
  "pages/category/category": {
    "network": "all",
    "packages": ["goodModule"]
  },
+   "pages/cart/cart": {
+     "network": "all",
+     "packages": ["orderPayModule"]
+   }
}

➡️ pages/cart/cart.js

js
// 跳转到订单结算页面
toOrder() {
    判断用户是否勾选商品,如果没有勾选,就不跳转
  if (this.data.totalPrice === 0) {
    wx.toast({
      title: '请选择需要购买的商品'
    })

    return
  }

  // 跳转到订单的结算页面
  wx.navigateTo({
    url: '/modules/orderPayModule/pages/order/detail/detail'
  })
}

➡️ pages/cart/cart.wxml

html
<van-submit-bar
  wx:if="{{ cartList.length }}"
  price="{{ totalPrice * 100 }}"
  button-text="去结算"
  tip="{{ true }}"
+  bindsubmit="toOrder"
                //绑定点击事件,跳转结算页面
>
  <van-checkbox
    value="{{ selectAllStatus }}"
    checked-color="#FA4126"
    bindchange="selectAllStatus"
  >
    全选
  </van-checkbox>
</van-submit-bar>

02. 封装结算支付的接口 API

思路分析:

为了方便后续进行结算支付模块的开发,我们在这一节将结算支付所有的接口封装成接口 API 函数

落地代码:

➡️ /api/orderpay.js

js
import http from "@/utils/http";

/**
 * @description 获取订单详情
 * @returns Promise
 */
export const reqOrderInfo = () => {
  return http.get("/order/trade");
};

/**
 * @description 获取订单列表
 * @param {*} page 页码
 * @param {*} limit 每页展示的条数
 * @returns Promise
 */
export const reqOrderList = (page, limit) => {
  return http.get(`/order/order/${page}/${limit}`);
};

/**
 * @description 获取订单收货地址
 * @returns Promise
 */
export const reqOrderAddress = () => {
  return http.get("/userAddress/getOrderAddress");
};

/**
 * @description 获取立即购买商品的详情信息
 * @param { Object } params { goodsId: 商品 Id,  blessing:祝福语 }
 * @returns Promise
 */
export const reqBuyNowGoods = ({ goodsId, ...data }) => {
  return http.get(`/order/buy/${goodsId}`, data);
};

/**
 * @description 提交订单
 * @returns Promise
 */
export const reqSubmitOrder = () => {
  return http.post("/order/submitOrder");
};

/**
 * @description 获取微信预支付信息
 * @param {*} orderNo 订单 ID 订单提交,服务器给的id
 * @returns Promise
 */
export const reqPreBuyInfo = (orderNo) => {
  return http.get(`/webChat/createJsapi/${orderNo}`);
};

/**
 * @description 微信支付状态查询
 * @param {*} orderNo
 * @returns Promise
 */
export const reqPayStatus = (orderNo) => {
  return http.get(`/webChat/queryPayStatus/${orderNo}`);
};

03. 商品结算-获取收货地址

思路分析:

进入结算支付页面后,需要获取收货地址信息,在获取到收货地址以后,需要进行判断,

如果没有获取到收货地址,需要展示添加收货地址的结构,

如果获取到了收货地址,需要渲染收货地址。

实现步骤:

  1. 在进入结算页面的时候,调用接口 API 函数,获取数据
  2. 然后根据数据并渲染结构

落地代码:

➡️ /pages/order/detail/index.js

js
import { reqOrderAddress } from '@/api/orderpay'

Page({

  data: {
    // coding...
+     orderAddress: {} // 收货地址
  },

+   // 获取订单页面的收货地址
+   async getAddress() {
+     const { data: orderAddress } = await reqOrderAddress()
+
+     this.setData({
+       orderAddress
+     })
+   },

+   // 页面展示时触发的钩子函数
+   onShow() {
+     this.getAddress()
+   }
})

➡️ /pages/order/detail/index.wxml

html
<!--pages/order/index.wxml-->
<view class="container order">
  <view class="address-card">
    <!-- 添加收货地址 -->
    <view wx:if="{{ !orderAddress.id }}" class="add-address" bindtap="toAddress">
      <van-icon size="22px" name="add" />
      <view>添加收货地址</view>
    </view>

    <view wx:else class="order-address flex">
      <view class="address-content">
        <view class="title">{{ orderAddress.fullAddress }}</view>
        <view class="info flex">
          <text>{{ orderAddress.name }}</text>
          <text>{{ orderAddress.phone }}</text>
        </view>
      </view>

      <view class="select-address">
        <navigator class="navigator" url="/modules/settingModule/pages/address/list/index">
          <van-icon color="#bbb" name="arrow" size="22px" />
        </navigator>
      </view>
    </view>

    <view class="top-line"></view>
  </view>

  <view class="order-info">
    <!-- coding... -->
  </view>
</view>

04. 商品结算-更新收货地址功能

思路分析:

当用户需要更改收货地址时,我们需要跳转到收货地址页面,重新选择收货地址

当用户点击了某个地址以后,我们需要将该地址显示到商品结算页面中。

更新收货地址功能,采用 getApp() 全局共享数据的方式来实现。

实现步骤:

  1. app.js 中定义全局共享的数据 globalData.address
  2. 点击箭头,携带参数跳转到收货地址页面,标识是从订单结算页面进入
  3. 在选择收货地址成功以后,将数据存储到 globalData.address中,然后返回到订单结算页面。
  4. 在订单结算页面判断 globalData.address 是否存在收货地址数据,如果存在则渲染

落地代码:

➡️ app.js

js
App({

+   // 定义全局共享的数据
    //点击收货地址时,需要将点击的收货地址赋值给address,在结算支付,订单结算页面,需要判断address是否存在数据,如果存在数据,就展示address数据,如果没有数据,就从接口获取数据进行渲染
+   globalData: {
+    address: {}
+  }

  // coding...
})

➡️ /pages/address/list/index.html

html
<!-- 每一个收货地址 -->

<view class="info" + bindtap="changeAddress" + data-id="{{ item.id }}">
  <view class="user-info">
    <text>{{ item.name }}</text>
    <text>{{ item.phone }}</text>
    <text wx:if="{{ item.isDefault === 1 }}" class="default-tag">默认</text>
  </view>

  <view class="address-info"> {{ item.fullAddress }} </view>
</view>

➡️ /pages/address/list/index.js

js
// 导入接口 API 函数
import { reqAddressList, reqDelAddress } from '@/api/address'
import { swipeCellBehavior } from '@/behaviors/swipeCell'

+ // 获取全局的应用实例
+ const app = getApp()

Page({

  // coding...

+   // 更新收货地址
+   changeAddress(event) {
+     // 判断是否是从订单结算页面进入的收货地址列表界面
    //如果是,才能够获取点击的收货地址,否则,不执行后续代码
+     if (this.flag !== '1') return
+
+     // 获取到点击的收货地址 id
+     const addressId = event.currentTarget.dataset.id
+     // 从收货地址列表中 根据收货地址id 获取到获取到点击的收货地址详细信息
+     const address = this.data.addressList.find((item) => item.id === addressId)
+
+     // 如果获取成功,将数据存储到 globalData 中
+     if (address) {
+       app.globalData.address = address
+       wx.navigateBack()
+     }
+   },
//接收传递的参数,挂载到页面的实例上,方便在其他方法中使用
+   onLoad(options) {
+     this.flag = options.flag
+   }
})

➡️ /pages/order/detail/index.wxml

html
<view class="select-address">
  <navigator class="navigator" + url="/modules/settingModule/pages/address/list/index?flag=1">
    // 这里添加flag标识,是为了区分是否是从结算支付页面进入的收货列表页面
    //如果是,在点击收货地址时,就需要给全局共享的address赋值
    <van-icon color="#bbb" name="arrow" size="22px" />
  </navigator>
</view>

➡️ /pages/order/detail/index.js

js
   // 获取全局的应用实例
+ const app = getApp()

// 获取订单页面的收货地址
  async getAddress() {
      //判断全局共享的address中是否存在数据
+     // 如果 globalData 存在收货地址,就从全局共享的address中取出后或地址
+     if (app.globalData.address.id) {
+       this.setData({
+         orderAddress: app.globalData.address
+       })
+
+       // 在赋值以后需要将收货地址清空
+
+
+       return
+     }

    // 如果 globalData 中不存在收货地址,获取收货地址渲染即可
    const { data: orderAddress } = await reqOrderAddress()

    this.setData({
      orderAddress
    })
  },


  onUnload(){
      //页面销毁以后,需要将全局共享的address也进行销毁
      //如果用户再次进入结算支付页面,需要从接口地址获取默认的收货地址进行渲染
      app.globalData.address = {}
  }

05. 商品结算-获取订单详情数据

思路分析:

商品结算页面数据获取收货地址以及商品订单信息

实现步骤:

  1. 导入封装的接口 API 函数
  2. 在进入结算页面的时候,调用接口 API 函数,获取数据,然后根据数据并渲染结构即可

落地代码:

➡️ /pages/order/detail/index.js

js
+ import { reqOrderAddress, reqOrderInfo } from '@/api/orderpay'

Page({

  data: {
    // coding...
    orderAddress: {}, // 收货地址
+     orderInfo: {}, // 订单商品详情
  },

+   // 获取订单详情
+   async getOrderInfo() {
+     const { data: orderInfo } = await reqOrderInfo()
+
+     // 判断是否存在祝福语
+     // 如果需要购买多个商品,挑选第一个填写了祝福语的商品进行赋值
+     const orderGoods = orderInfo.cartVoList.find((item) => item.blessing !== '')
+
+     this.setData({
+       orderInfo,
+       blessing: orderGoods && orderGoods.blessing
+     })
+   },

  // 在页面展示的时候进行触发
  onShow() {
    // 获取收货地址
    this.getAddress()

+     // 获取订单结算页面的商品信息
+     this.getOrderInfo()
  },
})

➡️ /pages/order/detail/index.wxml

html
<!--pages/order/index.wxml-->
<view class="container order">
  <view class="address-card">
    <!-- 添加收货地址 -->
    <!-- coding... -->
  </view>

  <view class="goods-wraper">
    <!-- 商品清单 -->
    <view class="goods-list">
      +
      <view class="goods-item flex" wx:for="{{ tradeInfo.cartVoList }}" wx:key="goodsId">
        <view class="img"> + <image src="{{ item.imageUrl }}" /> </view>
        <view class="content">
          + <view class="goods-title">{{ item.name }}</view>
          <view class="goods-price">
            + <view class="price"> ¥ {{ item.price }}</view> + <view>x {{ item.count }}</view>
          </view>
        </view>
      </view>
    </view>
  </view>

  <view class="payment">
    <!-- 支付方式 -->
    <view class="time-wraper flex">
      <image src="/static/images/payment_wxzf.png" />
      <view class="title">支付方式</view>
      <van-checkbox value="{{true}}"></van-checkbox>
    </view>
  </view>

  <!-- 支付区域 -->
  <view class="footer flex">
    + <view class="left"> ¥ {{ orderInfo.totalAmount }} </view>
    <viwe class="right">结算</viwe>
  </view>

  <!-- 日期选择弹框 -->
  <van-popup
    show="{{ show }}"
    round
    position="bottom"
    custom-style="height: 50%"
    bind:close="onClose"
  >
    <van-datetime-picker
      type="date"
      min-date="{{ minDate }}"
      model:value="{{ currentDate }}"
      bind:confirm="onConfirmTimerPicker"
      bind:cancel="onCancelTimePicker"
    />
  </van-popup>
</view>

06. 商品结算-获取立即购买数据

思路分析:

当用户从商品详情点击立即购买进入商品结算页面的时候,我们需要在商品结算页面展示立即购买商品的基本信息。

在跳转到商品结算页面的时候,我们已经携带了商品的 id祝福语

在结算页面,只需要获取到传递的参数,然后根据传递的参数调用接口即可。

实现步骤:

  1. 在页面打开的时候,onShow 中接受传递的参数,并赋值给 data 中的状态
  2. getOrderInfo 函数中,判断立即购买商品的 id 是否存在,如果存在调用立即购买的接口
  3. 获取数据后,然后根据数据并渲染结构即可

落地代码:

➡️ /pages/order/detail/index.js

js
import {
  reqOrderAddress,
  reqOrderInfo,
+   reqBuyNowGoods
} from '@/api/orderpay'

Page({


  // 获取订单详情
  async getOrderInfo() {
+    // 从 data 中解构数据
+     const { goodsId, blessing } = this.data

+  // 判断是否存在商品 id,
     // 如果存在调用立即购买商品详情的接口
     // 不存在调用获取订单详情数据接口
+    const { data: orderInfo } = goodsId
      ? await reqBuyNowGoods({ goodsId, blessing })
      : await reqOrderInfo()

    // 判断是否存在祝福语
    // 如果需要购买多个商品,挑选第一个填写了祝福语的商品进行赋值
    const orderGoods = orderInfo.cartVoList.find((item) => item.blessing !== '')

    this.setData({
      orderInfo,
      orderGoods && orderGoods.blessing
    })
  }

 +  // 接收立即购买传递的参数
    //赋值给data中的状态
 +  onLoad (options) {
 +    this.setData({
 +       ...options
 +    })
 +  },

  // 在页面展示的时候进行触发
  onShow() {
    // 获取收货地址
    this.getAddress()

    // 获取订单结算页面的商品信息
    this.getOrderInfo()
  }
})

07. 商品结算-收集送达时间

思路分析:

当选择送达日期的时候,需要选择收货的时间,我们希望获取到的收货的时间格式是:年月日

但是我们使用的是小程序提供的 vant 组件,组件返回的时候并不是真正的时分秒,而是时间戳

这时候可以调用小程序项目初始化时,小程序封装的时间格式化工具

实现步骤:

  1. 在商品结算页面导入封装好的格式化时间的方法 formatTime
  2. 调用 formatTime ,传入需要格式化的时间戳

落地代码:

➡️ /pages/order/detail/index.js

js
import { formatTime } from '../../../utils/formatTime.js'

Page({

  // coding...

  // 期望送达日期确定按钮
  onConfirmTimerPicker(event) {
      //使用vant提供的时间选择组件,获取的时间是时间戳
    // 使用 new Date 将时间戳转换成 JS 中的日期对象
    const time = formatTime(new Date(event.detail))

    // 将转换以后的时间赋值给送到时间
    this.setData({
      show: false,
      deliveryDate: time
    })
  }

  // coding...
}

08. 商品结算-表单数据验证

思路分析:

使用 async-validator 对代码进行验证

  1. 收货地址不能为空
  2. 订购人姓名不能为空,且不能输入特殊字符
  3. 订购人手机号不能为空,且输入的手机号必须合法
  4. 送达日期不能为空

落地代码:

js
import { reqOrderAddress, reqOrderInfo, reqBuyNowGoods } from "@/api/orderpay";
// 导入 async-validator 对参数进行验证
import Schema from "async-validator";
// 导入格式化时间的方法
import { formatTime } from "@/utils/formatTime";

// 获取应用实例
const app = getApp();

Page({
  data: {
    buyName: "", // 订购人姓名
    buyPhone: "", // 订购人手机号
    deliveryDate: "", // 期望送达日期
    blessing: "", // 祝福语
    show: false, // 期望送达日期弹框
    orderAddress: {}, // 收货地址
    orderInfo: {}, // 订单商品详情
    minDate: new Date().getTime(),
    currentDate: new Date().getTime(),
  },

  //处理提交订单
  async submitOrder() {
    // 从 data 中结构数据
    //需要根据接口要求组织请求参数,对请求参数进行验证
    const { buyName, buyPhone, deliveryDate, blessing, orderInfo, orderAddress } = this.data;

    // 组织请求参数
    const params = {
      buyName,
      buyPhone,
      deliveryDate,
      remarks: blessing,
      cartList: orderInfo.cartVoList,
      userAddressId: orderAddress.id,
    };

    // 对请求参数进项验证
    const { valid } = await this.validatorPerson(params);

    // 打印验证结果
    console.log(valid);
  },

  // 对新增收货地址请求参数进行验证
  validatorPerson(params) {
    // 验证收货人,是否只包含大小写字母、数字和中文字符
    const nameRegExp = "^[a-zA-Z\\d\\u4e00-\\u9fa5]+$";

    // 验证手机号,是否符合中国大陆手机号码的格式
    const phoneReg = "^1(?:3\\d|4[4-9]|5[0-35-9]|6[67]|7[0-8]|8\\d|9\\d)\\d{8}$";

    // 创建验证规则
    const rules = {
      userAddressId: [{ required: true, message: "请选择收货地址" }],
      buyName: [
        { required: true, message: "请输入收货人姓名" },
        { pattern: nameRegExp, message: "收货人姓名不合法" },
      ],
      buyPhone: [
        { required: true, message: "请输入收货人手机号" },
        { pattern: phoneReg, message: "收货人手机号不合法" },
      ],
      deliveryDate: { required: true, message: "请选择送达时间" },
    };

    // 传入验证规则进行实例化
    const validator = new Schema(rules);

    // 调用实例方法对请求参数进行验证
    // 注意:我们希望将验证结果通过 Promise 的形式返回给函数的调用者
    return new Promise((resolve) => {
      validator.validate(params, (errors) => {
        if (errors) {
          // 如果验证失败,需要给用户进行提示
          wx.toast({ title: errors[0].message });
          // 如果属性值是 false,说明验证失败
          resolve({ valid: false });
        } else {
          // 如果属性值是 true,说明验证成功
          resolve({ valid: true });
        }
      });
    });
  },

  // coding....
});

完善代码:

09. 小程序支付-小程序支付流程

小程序支付图示:

前端需要做的事情:

  1. 生成平台订单:前端调用接口,向后端传递需要购买的商品信息、收货人信息,[后端生成平台订单,返回订单编号]
  2. 获取预付单信息:将订单编号发送给后端后, [后端向微信服务器获取预付单信息,后端会将微信服务器返回的预付单信息进行加密,然后将加密以后的预付单信息返回给前端]
  3. 发起微信支付:前端调用 wx.requestPayment() 发起微信支付
  4. 查询支付状态:调用接口查询支付状态

10. 小程序支付-创建平台订单

思路分析:

用户在完成选购流程,确认商品信息、订购人、收货人等信息无误后,

用户需要点击提交订单按钮,开始进行下单支付,这时候需要先创建平台订单。

实现步骤:

  1. 在提交订单的事件处理函数中调用封装的接口 API 函数
  2. 在接口调用成功以后,将服务器响应的订单编码挂载到页面实例上。

落地代码:

➡️ /pages/order/detail/index.js

js
import {
  reqOrderAddress,
  reqOrderInfo,
  reqBuyNowGoods,
+   reqSubmitOrder
} from '@/api/orderpay'

Page({

  // coding...

  // 提交订单
    // 处理提交订单
  async submitOrder() {
    // 需要从 data 中解构数据
    const {
      buyName,
      buyPhone,
      deliveryDate,
      blessing,
      orderAddress,
      orderInfo
    } = this.data

    // 需要根据接口要求组织请求参数
    const params = {
      buyName,
      buyPhone,
      cartList: orderInfo.cartVoList,
      deliveryDate,
      remarks: blessing,
      userAddressId: orderAddress.id
    }

    // 对请求参数进行验证
    const { valid } = await this.validatorPerson(params)

+     // 如果验证失败,直接 return,不执行后续的逻辑处理
+     if (!valid) return
+
+     // 调用接口,创建平台订单
+     const res = await reqSubmitOrder(params)
+
+     // 在平台订单创建成功以后,将订单编号挂载到页面实例上
+     if (res.code === 200) {
+       // 将订单编号挂载到页面实例上
+       this.orderNo = res.data
+     }
  }


  // coding...
})

11. 小程序支付-获取预付单信息

思路分析:

将订单编号发送给公司的后端,公司的后端会从数据库找到对应订单的信息。

然后调用微信服务器的 下单接口 进行创建订单,订单创建成功以后,微信服务器会给公司后端返回预付单信息。

公司后端对返回的预付单信息进行加密,返回给小程序客户端。

这一步,咱们需要做的就是:订单编号发送给公司的后端,其他逻辑时后端来完成的。

📌:注意事项:

​ 小程序支付后面的代码,大伙在实现的时候,会出现异常。

​ 这是因为没有小程序的开发权限,以后在实际开发中,只需要参考当前流程进行开发即可

落地代码:

➡️ /pages/order/detail/index.js

js
Page({
    // 处理提交订单
  async submitOrder() {
    // 需要从 data 中解构数据
    const {
      buyName,
      buyPhone,
      deliveryDate,
      blessing,
      orderAddress,
      orderInfo
    } = this.data

    // 需要根据接口要求组织请求参数
    const params = {
      buyName,
      buyPhone,
      cartList: orderInfo.cartVoList,
      deliveryDate,
      remarks: blessing,
      userAddressId: orderAddress.id
    }

    // 对请求参数进行验证
    const { valid } = await this.validatorPerson(params)

    // 如果请求参数验证失败,直接 return ,不执行后续的逻辑
    if (!valid) return

    // 调用接口,创建平台订单
    const res = await reqSubmitOrder(params)

    if (res.code === 200) {
      // 在平台订单创建成功以后,需要将服务器、后端返回的订单编号挂载到页面实例上
      this.orderNo = res.data

+       // 获取预付单信息、支付参数
+       this.advancePay()
    }
  },

+   // 获取预付单信息、支付参数
+   async advancePay() {
+     // 调用接口,获取预付单信息、支付参数
+     const payParams = await reqPrePayInfo(this.orderNo)
+
+     if (payParams.code === 200) {
+       console.log(res.data)
+     }
+   },

})

12. 小程序支付-发起微信支付

知识点:

小程序客户端在接收支付参数后,调用 wx.requestPayment() 发起微信支付,

唤醒支付弹窗,用户开输入支付密码或者进行指纹等操作,微信服务器会进行验证,如果验证成功,就会发起支付。

然后会将支付结果返回给公司后端,也会返回给 wx.requestPayment()

并且会微信通知用户支付结果

落地代码:

➡️ /pages/order/detail/index.js

js
// 获取预付单信息、支付参数
async advancePay() {
 try {
    const payParams = await reqPreBuyInfo(this.orderNo)

    if (payParams.code === 200) {
      // 进行微信支付
      const payInfo = await wx.requestPayment(payParams.data)

      console.log(payInfo)
    }
  }
  catch {
    wx.toast({ title: '支付遇到问题,请联系客服', icon: 'error' })
  }
}

13. 小程序支付-支付状态查询

思路分析:

通过调用后端接口获取支付状态,如果支付成功,需要给用户提示,同时跳转到订单列表页面。

公司后端开始向微信服务器发送请求,查询支付结果

公司服务器会将微信服务器返回的支付结果,返回到客户端

客户端根据查询结果跳转到订单列表页面

落地代码:

➡️ /pages/order/detail/index.js

js
// 获取预付单信息、支付参数
async advancePay() {
  try {
    const payParams = await reqPrePayInfo(this.orderNo)

    if (payParams.code === 200) {
      // payParams.data 就是获取的支付参数

      // 调用  wx.requestPayment 发起微信支付
      const payInfo = await wx.requestPayment(payParams.data)

     + // 获取支付结果
      if (payInfo.errMsg === 'requestPayment:ok') {
        // 查询订单的支付状态
        const payStatus = await reqPayStatus(this.orderNo)

        if (payStatus.code === 200) {
          wx.redirectTo({
            url: '/modules/orderPayModule/pages/order/list/list',
            success: () => {
              wx.toast({
                title: '支付成功',
                icon: 'success
              })
            }
          })
        }
      }

    }
  } catch (error) {
    wx.toast({
      title: '支付失败,请联系客服',
      icon: 'error'
    })
  }
},

订单列表

本章节为课堂作业

01. 封装订单列表接口 API

思路分析:

为了方便后续进行商品管理模块的开发,我们在这一节将商品管理所有的接口封装成接口 API 函数

落地代码:

➡️ api/orderpay.js

js
/**
 * @description 获取订单列表
 * @returns Promise
 */
export const reqOrderList = (page, limit) => {
  return http.get(`/order/order/${page}/${limit}`);
};

02. 获取订单列表数据并渲染

思路分析:

当用户从个人中心页面点击进入订单中心的时候,就需要获取到订单中心的数据。

在页面调用 API 函数获取订单列表的数据,

在获取到数据以后,使用后端返回的数据对页面进行渲染

落地代码:

➡️ modules/orderPayModule/pages/order/list/list.js

js
+ // 导入封装的接口 API 函数
+ import { reqOrderList } from '@/api/orderpay'

Page({
  // 页面的初始数据
  data: {
    orderList: [1, 2, 3], // 订单列表
+     page: 1, // 页码
+     limit: 10, // 每页展示的条数
+     total: 0 // 订单列表总条数
  },

+   // 获取订单列表
+   async getOrderList() {
+     // 解构获取数据
+     const { page, limit } = this.data
+     // 调用接口获取订单列表数据
+     const res = await reqOrderList(page, limit)
+
+     if (res.code === 200) {
+       this.setData({
+         orderList: res.data.records,
+         total: res.data.total
+       })
+     }
+   },

+   // 生命周期函数--监听页面加载
+   onLoad() {
+     this.getOrderList()
+   }

})

➡️ modules/orderPayModule/pages/order/list/list.wxml

html
<!--pages/order/list/index.wxml-->
<view class="order-container container">
  +
  <view class="order-list" wx:if="{{ orderList.length > 0 }}">
    +
    <view class="order-item" wx:for="{{ orderList }}" wx:key="index">
      +
      <view class="order-item-header list-flex">
        <view class="orderno">订单号<text class="no">{{ orderList.orderNo }}</text></view>
        +
        <view class="order-status {{ item.orderStatus === 1 ? 'order-active' : '' }}">
          + {{ item.orderStatus === 1 ? '已支付' : '未支付'}} +
        </view>
      </view>
      <view
        class="goods-item list-flex"
        +
        wx:for="{{ item.orderDetailList }}"
        +
        wx:key="id"
        +
        wx:for-item="goods"
        +
        wx:for-index="goodsIndex"
      >
        <view class="left">
          + <image src="{{ goods.imageUrl }}" mode="widthFix" class="img" />
        </view>
        <view class="mid">
          + <view class="goods-name">{{ goods.name }}</view> +
          <view class="goods-blessing">{{ goods.blessing }}</view>
        </view>
        <view class="right">
          + <view class="goods-price">¥{{ goods.price }}</view> +
          <view class="goods-count">x{{ goods.count }}</view>
        </view>
      </view>
      <view class="order-item-footer">
        <view class="total-amount list-flex">
          <text class="text">实付</text>
          + <text class="price"><text>¥</text>{{ item.totalAmount }}</text>
        </view>
      </view>
    </view>
  </view>
  <van-empty wx:else description="还没有购买商品,快去购买吧~" />
</view>

03. 订单列表上拉加载更多

思路分析:

当用户进行了上拉操作时,需要在 .js 文件中声明 onReachBottom 方法,用来监听页面的上拉触底行为

当用户上拉时,需要对 page 参数进行加 1 即可,

当参数发生改变后,需要重新发送请求,拿最新的 page 向服务器要数据

在下一页的商品数据返回以后,需要将下一页的数据和之前的数据进行合并

落地代码:

➡️ modules/orderPayModule/pages/order/list/list.js

js
// 导入封装的接口 API 函数
import { reqOrderList } from '@/api/orderpay'

Page({
  // 页面的初始数据
  data: {
    orderList: [1, 2, 3], // 订单列表
    page: 1, // 页码
    limit: 10, // 每页展示的条数
    total: 0 // 订单列表总条数
  },

  // 获取订单列表
  async getOrderList() {
    // 解构获取数据
    const { page, limit } = this.data
    // 调用接口获取订单列表数据
    const res = await reqOrderList(page, limit)

    if (res.code === 200) {
      this.setData({
+         orderList: [...this.data.orderList, ...res.data.records],
        total: res.data.total
      })
    }
  },

+   // 页面上拉触底事件的处理函数
+   onReachBottom() {
+     // 解构数据
+     const { page } = this.data
+
+     // 更新 page
+     this.setData({
+       page: page + 1
+     })
+
+     // 重新发送请求
+     this.getOrderList()
+   },

  // 生命周期函数--监听页面加载
  onLoad() {
    this.getOrderList()
  }
})

04. 判断数据是否加载完毕

思路分析:

如何判断数据是否加载完成 ❓

可以使用后端返回的 totalgoodsList 进行对比,如果 total 大于 goodsList ,说明订单中心数据没有加载完,可以继续上拉加载更多。

目前还没有接收 total,需要先将后台返回的 total 进行赋值到 data 中,然后使用 onReachBottom 中进行判断

落地代码:

➡️ modules/orderPayModule/pages/order/list/list.js

js
// 页面上拉触底事件的处理函数
onReachBottom() {
+   // 解构数据
+   const { page, total, orderList } = this.data
+
+   // 数据总条数 和 订单列表长度进行对比
+   if (total === orderList.length) {
+     return wx.toast({ title: '数据加载完毕' })
+   }

  // 更新 page
  this.setData({
    page: page + 1
  })

  // 重新发送请求
  this.getOrderList()
}

05. 节流阀进行列表节流

在用户网速很慢的情况下,如果用户在距离底部来回的进行多次滑动,可能会发送一些无意义的请求、造成请求浪费的情况,因此需要给上拉加载添加节流功能。

我们使用节流阀来给订单列表添加节流功能。

data 中定义节流阀状态 isLoading,默认值是 false

在请求发送之前,将 isLoading 设置为 true,表示请求正在发送。

在请求结束以后,将 isLoading 设置为 false,表示请求已经完成。

onReachBottom 事件监听函数中,对 isLoading 进行判断,如果数据正在请求中,不请求下一页的数据。

落地代码:

➡️ modules/orderPayModule/pages/order/list/list.js

js
// 导入封装的接口 API 函数
import { reqOrderList } from '@/api/orderpay'

Page({
  // 页面的初始数据
  data: {
    orderList: [1, 2, 3], // 订单列表
    page: 1, // 页码
    limit: 10, // 每页展示的条数
    total: 0, // 订单列表总条数
+     isLoading: false // 判断数据是否记载完毕
  },

  // 获取订单列表
  async getOrderList() {
    // 解构获取数据
    const { page, limit } = this.data

+     // 数据正在请求中
+     this.data.isLoading = true

    // 调用接口获取订单列表数据
    const res = await reqOrderList(page, limit)

+     // 数据加载完毕
+     this.data.isLoading = false

    if (res.code === 200) {
      this.setData({
        orderList: [...this.data.orderList, ...res.data.records],
        total: res.data.total
      })
    }
  },

  // 页面上拉触底事件的处理函数
  onReachBottom() {
+     // 解构数据
+     const { page, total, orderList, isLoading } = this.data

+     // 判断是否加载完毕,如果 isLoading 等于 true
+     // 说明数据还没有加载完毕,不加载下一页数据
+     if (isLoading) return

    // 数据总条数 和 订单列表长度进行对比
    if (total === orderList.length) {
      return wx.toast({ title: '数据加载完毕' })
    }

    // 更新 page
    this.setData({
      page: page + 1
    })

    // 重新发送请求
    this.getOrderList()
  },

  // 生命周期函数--监听页面加载
  onLoad() {
    this.getOrderList()
  }
})

代码优化

1. 分享功能

思路分析:

目前小程序页面都没有配置分享功能,需要给小程序页面设置分享功能。

但是并不是所有页面都需要设置分享功能,

具体哪些页面需要设置分享功能,可以和产品经理进行协商。

  1. 首页
  2. 商品列表
  3. 商品详情

落地代码:

js
// 转发功能:好友,群聊
onShareAppMessage() {
  return {
    title: '所有的怦然心动,都是你',
    path: '/pages/index/index',
    imageUrl: '../../assets/images/love.jpg'
  }
},

// 转发到朋友圈功能
onShareTimeline() {}

2. 优化-分包调整

思路分析:

  1. 将 [设置页面] 配置到 [设置模块分包],在访问个人中心页面时,提前预下载 [设置模块分包]
  2. 进入订单结算页面时,提前预下载 [设置模块分包]

落地代码:

➡️ app.json

json
{
    "subPackages": [
    {
      "root": "modules/settingModule",
      "name": "settingModule",
      "pages": [
+         "pages/settings/settings",
        "pages/address/add/index",
        "pages/address/list/index",
        "pages/profile/profile"
      ]
    },
    {
      "root": "modules/goodModule",
      "name": "goodModule",
      "pages": ["pages/goods/list/list", "pages/goods/detail/detail"]
    },
    {
      "root": "modules/orderPayModule",
      "name": "orderPayModule",
      "pages": ["pages/order/detail/detail", "pages/order/list/list"]
    }
  ],
  "preloadRule": {
+     "pages/my/my": {
      "network": "all",
      "packages": ["settingModule"]
    },
+     "modules/orderPayModule/pages/order/detail/detail": {
+       "network": "all",
+       "packages": ["settingModule"]
+     },
    "pages/category/category": {
      "network": "all",
      "packages": ["goodModule"]
    },
    "pages/cart/cart": {
      "network": "all",
      "packages": ["orderPayModule"]
    }
  },
}

3. 优化-关键按钮添加防抖函数

思路分析:

为了防止用户频繁点击按钮而导致的重复提交或者多次请求的问题,

我们需要给关键按钮添加防抖函数,这里可以使用 licia 提供的防抖函数

js
import { debounce } from "miniprogram-licia";
  1. 登录按钮
  2. 提交订单

落地代码:

js
import { debounce } from "miniprogram-licia";

debounce(function () {
  // coding...
}, 500);

4. 优化-代码质量检测

如何使用微信开发者工具进行代码质量检测

代码质量检测标准:

调整文件位置

hhh问题问题

部分导出模块记得加花括号!!!!!!!!!!

如果没有报错信息,记得不要hide系统信息

注意符号:

注意括号

函数执行,要带()

token一直清不掉!!!!!!!哈哈哈哈哈哈哈哈哈哈哈写在了全局啊大哥哈哈哈哈哈哈哈哈哈哈哈

为什么一输入就关闭弹窗了呢呵呵呵?因为绑定了一个函数,这个执行函数的结果就是关闭弹窗

behavior不是写在data里!!!!!!!!!!!!