Lei Zhang

Lei Zhang

2022,三餐四季温素有趣。

经验

异常

联调接口时,通常后端会有一套通用的 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,例如:

  • 在所有 Page 重写生命周期钩子;
  • 在所有 Page 中填充 data 属性;
  • etc...

还记得 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 示例:

/**
 * 保存图片至手机相册
 * https://developers.weixin.qq.com/miniprogram/dev/api/media/image/wx.saveImageToPhotosAlbum.html
 * !!!延用 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 和我一起完善。🎉 🎉 🎉

编辑此页
最后更新于24 天之前