Skip to content

构建小程序 - 异常、通讯、技巧

发布于:

experience

Table of contents

展开目录

目录

异常

联调接口时,通常后端会有一套通用的 ResponseDTO:

interface IBaseResponse<T = any> {
  /**
   * 业务状态码
   */
  code: number;
  /**
   * 响应数据
   */
  data: T;
  /**
   * 响应消息
   */
  message: string;
  /**
   * 响应时间戳
   */
  timestamp: number;
  /**
   * 自定义属性,如: path: string
   */
  [propName: string]: any;
}

在开发过程中,可能会有某些场景,期望 Promise 或者 async function 抛出一个异常:

throw new Error("请求失败");
// 或
return Promise.reject("请求失败");

但是这样会导致我们自定义的异常和 API 的返回数据结构不一致,那么,我们就封装一个自定义异常进行统一:

CustomException

const EXCEPTION_ERROR_MESSAGE = "网络开小差~";
 
/**
 * 自定义异常, 数据结构与 IBaseResponse 相似
 * {
 *  "code": => 可选字段,非 200 的整数,默认为 5000
 *  "data": => 可选字段,用于自定义数据
 *  "message" => 必填字段,用于错误原因说明
 *  "timestamp" => 默认填充 当前时间戳(毫秒)
 *  "name" => 默认填充 'CustomException'
 * }
 */
class CustomException {
  constructor({ code = 5000, data, message }) {
    this.code = code;
    this.data = data;
    this.message = message || EXCEPTION_ERROR_MESSAGE;
    this.timestamp = +new Date();
    this.name = "CustomException";
  }
}
 
// 
throw new CustomException({ message: "自定义错误" });
// 
throw new Error("请求失败");

SDKException

使用小程序 SDK 时,SDK 所给的 error 是这样的:

{
  "errMsg": "openBluetoothAdapter:fail:not available",
  "errCode": 10001,
  "errno": 1500102
}

IBaseResponse 又有很大的差异,那么,我们就封装一个SDK 异常进行统一:

const EXCEPTION_ERROR_MESSAGE = "网络开小差~";
 
/**
 * 自定义微信或企微 SDK 异常
 * https://developers.weixin.qq.com/miniprogram/dev/framework/usability/PublicErrno.html
 * {
 *  "errMsg": "openBluetoothAdapter:fail:not available",
 *  "errCode": 10001,
 *  "errno": 1500102,
 *  "code": => 可选字段,非 200 的整数,若设置了值,"errno" 和 "errCode" 会失效
 *  "data": => 可选字段,用于自定义数据
 *  "message": => 可选字段,用于捕获其他 js 错误,或自定义错误信息
 *  "timestamp" => 默认填 充当前时间戳(毫秒)
 *  "name" => 默认填充 'SDKException'
 * }
 */
class SDKException {
  constructor({ errMsg, errCode, errno, code, message, data }) {
    this.code = code || errno || errCode || 5000;
    this.data = data;
    this.message = message || errMsg || EXCEPTION_ERROR_MESSAGE;
    this.timestamp = +new Date();
    this.name = "SDKException";
  }
}

异常来源

通过instanceof判断(👍 推荐)

async function foo() {
  try {
    await bar();
  } catch (exception) {
    if (exception instanceof CustomException) {
      // 是我的自定义异常
    } else {
      // 不是我的自定义异常 <= Uncaught ReferenceError: bar is not defined
    }
  }
}

通过name判断

async function foo() {
  try {
    await bar();
  } catch (exception) {
    if (exception.name === "CustomException") {
      // 是我的自定义异常
    } else {
      // 不是我的自定义异常 <= Uncaught ReferenceError: bar is not defined
    }
  }
}

额外小心

小程序的 SDK 会返回所有的错误,包括:取消、拒绝等,在捕获 SDKException 需要过滤掉这类场景:

/**
 * 拨打电话
 * @param {string} phoneNumber 电话号码
 */
export async function makePhoneCall(phoneNumber) {
  if (typeof phoneNumber !== "string")
    throw new SDKException({ message: "phoneNumber must be string" });
 
  try {
    await wx.makePhoneCall({ phoneNumber });
  } catch (error) {
    // exclude "取消" 的场景
    if (!error.errMsg || error.errMsg.indexOf("fail cancel") === -1) {
      wx.showToast({ title: error.errMsg, icon: "none" });
 
      throw new SDKException(error);
    }
  }
}

通讯

queryParams

pageA navigateTo pageB (最基本的通讯方式)

pageA.js
Page({
  methods: {
    handleTap() {
      wx.navigateTo({ url: `pageB?foo=bar` })
    }
  }
})
pageB.js
Page({
  onLoad(options) {
    const { foo } = options // <== foo is 'bar'
  }
})

variableStorage

orderList set data, orderDetail get data(适合一次性通讯复杂的数据结构)

定义 storage class

_shared/helpers/storage.js
/**
 * Storage
 *
 * @desc      内存存储,主要用于跨页面参数的传递
 */
 
import { CustomException } from './utils'
 
class Storage {
  constructor(options) {
    const { snapchat = false } = options || {}
 
    this.snapchat = snapchat
    this.data = {}
  }
 
  set(key, value) {
    if (!key) {
      throw new CustomException({ message: 'key 不能为空' })
    }
 
    this.data[String(key)] = value
  }
 
  get(key) {
    const value = key ? this.data[String(key)] : this.data
 
    if (this.snapchat) {
      this.removeItem(key)
    }
 
    return value
  }
 
  removeItem(key) {
    delete this.data[String(key)]
  }
 
  clear() {
    this.data = {}
  }
}
 
export default Storage

定制专属 store

_shared/stores/order.js
import Storage from '../helpers/storage'
 
// 保证每个 store module 都是全新的 Storage 实例
const storage = new Storage({ snapchat: true })
 
class Store {
  get orderSourceData() {
    return storage.get('data')
  }
 
  set orderSourceData(val) {
    storage.set('data', val)
  }
}
 
// 导出 Order Store 实例
export default new Store()
orderList.js
import OrderStore from '@/_shared/stores/order'
import { useRequest } from '@/_shared/helpers/request'
 
Page({
  methods: {
    async handleDetail({ currentTarget: { dataset: { orderNo }}}) {
      const [err, res] = await useRequest('api/v1/order/detail', { orderNo })
 
      if(!err) {
        // 写入 orderSourceData
        OrderStore.orderSourceData = this.data.orders[index]
        wx.navigateTo({ url: `orderDetail` })
      } else {
        // error handler
      }
    }
  }
})
orderDetail.js
import OrderStore from '@/_shared/stores/order'
 
Page({
  onLoad() {
    // 读取 orderSourceData
    const data = OrderStore.orderSourceData
  }
})

发布订阅

pageA navigateTo pageB (先订阅后发布

eventBus

有很多实现方式,这里简单展示项目中使用的模块示例:

_shared/helpers/eventBus.js
const notices = []
 
// 注册通知
export function on(name, observer, selector) {
  if (name && observer && selector) {
    const newNotice = {
      name,
      observer,
      selector
    }
 
    add(newNotice)
  }
}
 
// 移除通知(observer按name)
export function off(name, observer) {
  wx.nextTick(() => {
    // 以防post过程中同时remove
    for (let i = notices.length - 1; i >= 0; i--) {
      const inNotice = notices[i]
 
      if (inNotice.name == name && inNotice.observer == observer) {
        notices.splice(i, 1)
      }
    }
  })
}
 
// 移除通知(observer所有)
export function clean(observer) {
  wx.nextTick(() => {
    // 以防post过程中同时remove
    for (let i = notices.length - 1; i >= 0; i--) {
      const inNotice = notices[i]
 
      if (inNotice.observer == observer) {
        notices.splice(i, 1)
      }
    }
  })
}
 
// 发送通知
export function emit(name, info) {
  for (let i = 0; i < notices.length; i++) {
    const inNotice = notices[i]
 
    if (inNotice.name == name) {
      inNotice.selector(info)
    }
  }
}
 
// 加入通知数据
function add(newNotice) {
  if (notices.length > 0) {
    for (let i = 0; i < notices.length; i++) {
      const inNotice = notices[i]
 
      if (
        inNotice.name == newNotice.name &&
        inNotice.selector == newNotice.selector &&
        inNotice.observer == newNotice.observer
      ) {
        return
      }
    }
  }
 
  notices.push(newNotice)
}

eventKeys

不要使用魔法字符,使用常量

@/_shared/constants/eventKeys.js
/**
 * 订单已操作,例如:取消、支付、申请售后等
 */
export const ORDER_OPERATED = 'orderOperated'

订阅事件

先订阅(orderList 在 orderDetail 之前)

orderList.js
import * as eventBus from '@/_shared/helpers/eventBus'
import * as eventKeys from '@/_shared/constants/eventKeys'
 
Page({
  // 若在 Component 中使用,生命周期函数为 attached
  onLoad() {
    eventBus.on(eventKeys.ORDER_OPERATED, this, (payload) => {
      console.log(payload) // <= { foo: 'bar' }
 
      this.reloadData()
    })
  },
 
  // 若在 Component 中使用,生命周期函数为 detached
  unUnload() {
    eventBus.clean(this)
  }
})

后发布(orderDetail 在 orderList 之后)

orderDetail.js
import * as eventBus from '@/_shared/helpers/eventBus'
import * as eventKeys from '@/_shared/constants/eventKeys'
 
Page({
  methods: {
    async handleCancel() {
      const { orderNo } = this.data
 
      const [err, res] = await useRequest('api/order/cancel', { orderNo })
 
      if(!err) {
        eventBus.emit(
          eventKeys.ORDER_OPERATED,
          // ↓↓↓↓ any paylod you want to pass ↓↓↓↓
          { foo: 'bar' }
        )
      }
    }
  }
})

技巧

CSS Reset

@/_shared/styles/reset.scss
page,
view,
scroll-view,
swiper,
swiper-item,
movable-area,
movable-view,
cover-view,
cover-image,
icon,
text,
rich-text,
progress,
button,
checkbox-group,
checkbox,
form,
input,
label,
picker,
picker-view,
radio-group,
radio,
slider,
switch,
textarea,
navigator,
functional-page-navigator,
image,
video,
camera,
live-player,
live-pusher,
map,
canvas,
open-data,
web-view,
ad {
  box-sizing: border-box;
 
  &::after {
    box-sizing: border-box;
  }
 
  &::before {
    box-sizing: border-box;
  }
}
 
::-webkit-scrollbar {
  width: 0;
  height: 0;
  color: transparent;
}
 
button {
  outline: 0;
  border: none;
 
  &::after {
    border: none !important;
  }
}
@/src/app.scss
@import '@/_shared/styles/reset.scss';
 
page {
  background-color: $bg-color-default;
  width: 100vw;
  min-height: 100vh;
}

安全区适配

@/src/app.scss
/* other stylesheet...  */
 
@supports (bottom: constant(safe-area-inset-bottom)) {
  .safe-area {
    padding-bottom: env(safe-area-inset-bottom);
  }
}
@supports (bottom: env(safe-area-inset-bottom)) {
  .safe-area {
    padding-bottom: env(safe-area-inset-bottom);
  }
}

页面中使用

<view class="page">
  <view class="safe-area" />
</view>

组件中使用

引用页面或父组件的样式

<view class="component">
  <view class="~safe-area" />
</view>

Page Enhancer

某些场景,我们可能需要去增强 Page,例如:

还记得 React 中的 HOC 吗?

const EnhancedComponent = higherOrderComponent(WrappedComponent);

参考 HOC,我们可以编写一个 Enhancer 来增强小程序的 Page (Component 同理):

@/_shared/helpers/pageEnhancer.js
import * as eventBus from '@/_shared/helpers/eventBus'
import * as eventKeys from '@/_shared/constants/eventKeys'
 
const PageEnhancer = props => {
  // 重写生命周期钩子,并订阅某个事件
  const { onLoad, onUnload } = props
 
  onLoad && delete props.onLoad
  onUnload && delete props.onUnload
 
  props.onLoad = function() {
    eventBus.on(eventKeys.EVENT_KEY, this, (payload) => {
      // do something...
    })
 
    // 执行原本的 onLoad() 逻辑
    onLoad && onLoad.apply(this, arguments);
  };
 
  props.unUnload = function() {
    eventBus.off(eventKeys.EVENT_KEY, this)
 
    // 执行原本的 onUnload() 逻辑
    onUnload && onUnload.apply(this, arguments);
  }
 
  // 填充 data
  props.data.foo = 'bar'
 
  return props
}
 
export default PageEnhancer
page.js
import PageEnhancer from '@/_shared/helpers/pageEnhancer'
 
const props = PageEnhancer({
  // 全部的 Page 属性
  data: { id: 1 },
  onLoad(options) {},
  onUnload() {},
  methods: {}
})
 
Page(props)

SDK Promisify

虽然现在大部分 SDK 支持异步,但仍然有少量不支持,例如:wx.login 等,只能使用回调函数的形式处理 SDK 的成功与失败:

wx.login({
  success(res) {
    if (res.code) {
      //发起网络请求
      wx.request({
        url: "https://example.com/onLogin",
        data: {
          code: res.code,
        },
      });
    } else {
      console.log("登录失败!" + res.errMsg);
    }
  },
});

现在对 wx.login 做一个 Promise 的异步封装:

const wxLogin = () =>
  new Promise((resolve, reject) => {
    wx.login({
      success: res => {
        resolve(res);
      },
      fail: err => {
        reject(err);
      },
    });
  });

如果项目中使用了 10 个 SDK 的调用,代码会变成… 😕

const sdk1 = () => new Promise((resolve, reject) => {
  sdk1({
    success: () => resolve(),
    fail: () => reject()
  })
}
 
const sdk2 = () => new Promise((resolve, reject) => {
  sdk2({
    success: () => resolve(),
    fail: () => reject()
  })
}
 
const sdk3 = () => new Promise((resolve, reject) => {
  sdk3({
    success: () => resolve(),
    fail: () => reject()
  })
}
 
// sdk 4-10...

参照 Nodejs 中的 util.promisify(original),封装一个简易的 promisify()

@/_shared/helpers/utils.js
/**
 * 将回调形式的函数做一个 promise 封装
 * @param {*} func
 * @returns {Promise}
 */
export const promisify = fn => options =>
  new Promise((resolve, reject) => {
    fn({
      ...options,
      success: resolve,
      fail: reject
    })
  })
@/_shared/helpers/wechat.js
import { promisify } from '@/_shared/helpers/utils'
 
export const wxLogin = promisify(wx.login)
export const wxCheckSession = promisify(wx.checkSession)
export const wxGetUserProfile = promisify(wx.getUserProfile)
export const wxGetSetting = promisify(wx.getSetting)
export const wxOpenSetting = promisify(wx.openSetting)
 
// usage example:
// async func() {
//   const { authSetting } = await wxOpenSetting()
// }

SDK ApplyPermission

在小程序中,很多针对客户端的 SDK,都需要申请权限,例如:获取地理位置、选择微信地址、添加联系人到通讯录、保存图片至相册 等…

某些 SDK 一旦用户拒绝,再次调用时会直接报错而不会再执行后面的逻辑,

此时,我们需要引导用户打开小程序设置页,开启对应权限。

完整的 SDK ApplyPermission 示例:

/**
 * 保存图片至手机相册
 * !!!延用 success fail 回调函数,是为了覆盖 sdk 默认的 toast 交互提示!!!
 * @param {function} success 接口调用成功的回调函数
 * @param {function} fail 接口调用失败的回调函数
 * @returns {Promise}
 */
export async function saveImageToPhotosAlbum({ success, fail, ...options }) {
  const scope = "scope.writePhotosAlbum";
 
  const applyPermissionMsg =
    "您未开启保存图片到相册的权限,请点击确定去开启权限!";
 
  const noPermissionMsg = "未开启保存图片到相册的权限";
 
  let canIUse = false;
 
  try {
    const { authSetting } = await wxGetSetting();
 
    // 用户已拒绝权限,则为 false
    // 首次申请权限,则为 undefined,微信会自动发起一次授权
    if (authSetting[`${scope}`] === false) {
      // wx.openSetting() 必须由用户 tap 触发,必须写为同步函数
      wx.showModal({
        title: "提示",
        content: applyPermissionMsg,
        success: async res => {
          if (res.confirm) {
            const { authSetting } = await wxOpenSetting();
 
            if (authSetting[`${scope}`]) {
              canIUse = true;
            } else {
              fail
                ? fail(new SDKException({ message: noPermissionMsg }))
                : wx.showToast({ title: noPermissionMsg, icon: "none" });
            }
          }
        },
      });
    } else {
      canIUse = true;
    }
 
    if (canIUse) {
      const result = await promisify(wx.saveImageToPhotosAlbum)(options);
 
      success
        ? success(result)
        : wx.showToast({
            title: "图片已保存相册,请在手机相册查看",
            icon: "none",
          });
    }
  } catch (error) {
    // exclude "取消保存" 的场景
    if (!error.errMsg || error.errMsg.indexOf("fail cancel") === -1) {
      fail
        ? fail(new SDKException(error))
        : wx.showToast({ title: error.errMsg, icon: "none" });
    }
  }
}

以上是我在小程序实际开发过程中,为数不多但又较为常见的经验总结。

欢迎你提交 Pull Request 和我一起完善。🎉 🎉 🎉