dxd-weapp-helpers 这个包的主要功能包括了小程序 API Promise 化,提供一些通用工具函数,在项目的技术栈上和工具使用上也有非常多新的尝试,本篇文章就 API Promise 化这一个核心功能展开,深入代码一探究竟。

注:本篇文章讨论的是 1.02 版本

weapp-helpers 的 Promise 化能力主要有以下特点:

  • 提供了跨平台的能力(目前支持微信和支付宝小程序)
  • 支持传入默认参数
  • 可以使用高阶函数对 API 的调用进行包装
  • 部分 API 支持简化调用

在深入代码之前先来看一看功能的使用。

Promise 化功能使用

  1. // 安装并执行小程序构建 npm
  2. npm install -S @dxy/dxd-weapp-helpers
  1. // constants/index.js
  2. const { getPlatformContext, debounce } = require('@dxy/dxd-weapp-helpers');
  3. // 此处做一个防抖,防止短时间内重复调用
  4. // 这里传入的 (callback, arg) => callback(arg) 为包内部执行调用微信 API 的函数签名,必须按照这个格式来传递
  5. const debouncedApi = () => debounce((callback, arg) => callback(arg), 300, true);
  6. const debouncedShowToast = () => debounce((callback, arg) => callback({ icon: 'none', ...arg }), 300, true);
  7. // 与 Promise 化相关的 API 就是 `getPlatformContext`,这个函数会根据判断当前的运行环境(微信/支付宝),返回一个对象,对象上面有对应运行环境 API 的 Promise 形式
  8. export const ap = getPlatformContext({
  9. // 传入默认参数
  10. showModal: {
  11. confirmColor: '#00c792',
  12. },
  13. // 使用入高阶函数对 API 进行包装
  14. navigateTo: debouncedApi(),
  15. redirectTo: debouncedApi(),
  16. // 使用高阶函数对 API 进行包装,同时传入默认参数
  17. showToast: debouncedShowToast()
  18. });
  19. // pages/xxx/index
  20. import { ap } from 'path/to/constants/index';
  21. // Promise 化调用
  22. ap.showModal({
  23. content: '这是一个 Model',
  24. }).then(res => {
  25. console.log(res);
  26. })
  27. // 同时支持 API 简化调用,部分 API 可以不用传入对象,如 showToast 和 navigateTo,可以简化的 API 查看可简化列表
  28. ap.showToast('让我们显示个 Toast')
  29. .then(() => {
  30. console.log('toast')
  31. });
  32. ap.navigateTo('/pages/index/index');

看完了功能使用,就有了如下几个问题:

  • 如何进行跨平台的判断
  • Promise 化是如何实现的
  • 如何支持传入默认参数
  • 如何做到 API 简化调用
  • 高阶函数的包装是如何生效的

让我们到代码层面寻找答案

代码实现

首先是相关文件的目录结构

  1. .
  2. ├── config.ts // 方法列表和简化参数的配置
  3. ├── index.ts // 暴露 getPlatformContext 方法
  4. ├── my.ts // 支付宝小程序的 API 处理逻辑
  5. └── weapp.ts // 微信小程序的 API 处理逻辑

对外暴露的方法 getPlatformContext:

  1. export function getPlatformContext(...arg: any[]): WeXinApiPromise {
  2. // !(有缓存 && (无参数 || 参数一致)) 更新缓存
  3. if (!(cacheCtx && (deepEqual(arg, cacheCtx.arg) || arg.length === 0))) {
  4. let platformInit = null;
  5. if (typeof wx === 'object') {
  6. // 微信平台
  7. platformInit = initWeappApi;
  8. } else if (typeof my === 'object') {
  9. // 支付宝平台
  10. platformInit = initMyApi;
  11. } else {
  12. throw new Error('不兼容的平台');
  13. }
  14. cacheCtx = {
  15. arg,
  16. ctx: platformInit(...arg),
  17. };
  18. }
  19. return cacheCtx.ctx;
  20. }

首先看输入和输出,输入是我们调用时候传入的参数,输出是 platformInit 函数的执行结果。

函数中对当前的运行环境做了一个判断,对全局变量 wxmy 进行存在性判断,这也是实现跨平台判断的原理。

根据平台走不同的处理逻辑,这里我们先只关心微信端的处理逻辑,来看 initWeappApi 的实现,在调用这个 initWeappApi 的时候,将调用 getPlatformContext 的参数也传了进去。

接下来看 initWeappApi 的实现:

  1. export default function(options: DefaultOptions = {}): WeXinApiPromise {
  2. // 临时输出对象
  3. const dxd: any = {
  4. PLATFORM_TYPE,
  5. CURRENT_PLATFORM: PLATFORM_TYPE.WEAPP,
  6. };
  7. // 初始化请求方法
  8. requestQueue.setRequest(wx.request);
  9. // 遍历所有 api
  10. Object.keys(ALL_API).forEach((key: string) => {
  11. if (!(key in wx)) {
  12. dxd[key] = () => {
  13. throw new Error(`微信小程序暂不支持 ${key}`);
  14. };
  15. return;
  16. }
  17. // 需要 promisify 的 api
  18. if (!isNoPromiseApi(key)) {
  19. // 重写 api
  20. dxd[key] = (...args: any[]) => {
  21. return new Promise((resolve, reject) => {
  22. // 取出参数 可能是对象,也可能是字符串等 使用简化或默认配置
  23. const fixArgs = configArg({ args, key, options });
  24. // 挂载成功回调
  25. fixArgs.success = resolve;
  26. // 挂载失败回调
  27. fixArgs.fail = reject;
  28. // 如果默认配置是方法,则传入源方法和参数
  29. if (isFunc(options[key])) {
  30. return options[key]((opt: any) => (wx as any)[key].call(wx, opt), fixArgs);
  31. }
  32. return (wx as any)[key].call(wx, fixArgs);
  33. });
  34. };
  35. } else {
  36. // 特殊化处理非 promise api
  37. dxd[key] = (...args: any[]) => (wx as any)[key].apply(wx, args);
  38. }
  39. });
  40. dxd.request = request;
  41. dxd.getCurrentPages = getCurrentPages;
  42. dxd.getApp = getApp;
  43. return dxd as WeXinApiPromise;
  44. }

这个方法非常的庞大,一屏幕装不下,但是研究的方法不变,第一步仍旧是搞明白函数的输入和输出。

输入就是调用 getPlatformContext 传进来的参数,输出是一个在方法内部定义的名为 dxd 的对象,从 TS 的函数类型上我们就可以很轻松的看到输入输出的类型,比如这里 dxd 对象的类型就是 WeXinApiPromise,点进类型一看就明白了是啥东西了,就是一个挂了 Promise 化方法的 wx 对象。

接下来看方法的逻辑,首先对 ALL_API 进行了一个循环,ALL_API 是定义在 config.ts 中的一个对象,所有的 API 都需要在这里列出来,支持 Promise 化的方法列表也在里面维护(目前这个列表需要手动维护)。

对于可以支持 Promise 的 API,我们就走到了 Promise 化实现的核心逻辑:

这里的 keyALL_API 中的方法名称,options 是我们调用 getPlatformContext 时传入的参数。

  1. dxd[key] = (...args: any[]) => {
  2. return new Promise((resolve, reject) => {
  3. // 取出参数 可能是对象,也可能是字符串等 使用简化或默认配置
  4. const fixArgs = configArg({ args, key, options });
  5. // 挂载成功回调
  6. fixArgs.success = resolve;
  7. // 挂载失败回调
  8. fixArgs.fail = reject;
  9. // 如果默认配置是方法,则传入源方法和参数
  10. if (isFunc(options[key])) {
  11. return options[key]((opt: any) => (wx as any)[key].call(wx, opt), fixArgs);
  12. }
  13. return (wx as any)[key].call(wx, fixArgs);
  14. });
  15. };

函数签名(或者类型签名,抑或方法签名)定义了函数或方法的输入与输出。 —- MDN

dxd[key] 是一个函数表达式,来看一下方法签名,接受参数,返回一个 Promise,将 resolvereject 赋值给 successfail,并且通过 call 的方式调用最终的方法,实现 Promise 的包装。

在包装 Promise 的过程中,调用了 configArg 这个函数,同样是看方法签名,接收 args(用户调用方法时传入的参数)、key(当前的方法名)、options(上文有提到,是调用 getPlatformContext 时传入的参数),返回的是最终调用 wx.xxx API 时候传入的参数。

进入这个函数内部看看到底进行了哪些参数处理:

  • 对简化的 API 调用,改写成对象传参的形式,其中用到的 SIMPLIFY_LIST 即简化 API 的配置,也是需要手动维护的
  • options 里指定的默认参数与最终调用时的 args 做一个合并

完整实现在这里:

  1. export function configArg({ args, key, options }: { args: any[]; key: string; options: any }): any {
  2. // 如果有已配置简化参数 && 配置项不是对象 则认为使用简化参数(简化参数一般是 String/Number/Array/ArrayBuffer)
  3. let arg: any = args[0];
  4. // 避免出现 undefined.success 出现
  5. if (checkOriginType(arg, 'Undefined')) {
  6. arg = {};
  7. }
  8. if (SIMPLIFY_LIST[key] && !isObj(arg)) {
  9. arg = {};
  10. const ps = SIMPLIFY_LIST[key];
  11. if (args.length) {
  12. ps.split(',').forEach((p: string, i: number) => {
  13. if (i in args) {
  14. arg[p] = args[i];
  15. }
  16. });
  17. }
  18. }
  19. // 具有对象类型默认配置项
  20. if (isObj(arg) && isObj(options[key])) {
  21. arg = {
  22. ...options[key],
  23. ...arg,
  24. };
  25. }
  26. return arg;
  27. }

至此,我们的疑问大都得到了解决

  • 如何进行跨平台的判断
  • Promise 化是如何实现的
  • 如何支持传入默认参数
  • 如何做到 API 简化调用
  • 高阶函数的包装是如何生效的

只剩下最后一个高阶函数包装的问题,我们给相关代码揪出来:

  1. if (isFunc(options[key])) {
  2. return options[key]((opt: any) => (wx as any)[key].call(wx, opt), fixArgs);
  3. }

当初为什么要去实现高阶函数包装这样一个功能?

曾经出现过这样一个问题:

短时间内反复点击一个按钮,多次触发 wx.navigateTo 方法,同一 url 同时开启多个页面栈。

我们在 wx.navitageTo 方法上做一层 debounce 处理,最初的做法是这个方法足够常用,给 debounce 包装完的 wx.navigateTo 方法挂载到了 app 上,使用的时候通过 app.navigateTo 来调用,并给项目中所有的跳转方法都做了替换。

这一次直接将高阶函数包装的能力通过参数传入的形式来实现,难点是在高阶函数包装的同时还需要保证默认参数的功能,我们把调用的地方和核心方法实现放在同一屏。

  1. // 方法的使用
  2. const { getPlatformContext, debounce } = require('@dxy/dxd-weapp-helpers');
  3. const debouncedApi = () => debounce((callback, arg) => callback(arg), 300, true);
  4. const debouncedShowToast = () => debounce((callback, arg) => callback({ icon: 'none', ...arg }), 300, true);
  5. export const ap = getPlatformContext({
  6. navigateTo: debouncedApi(),
  7. showToast: debouncedShowToast()
  8. });
  1. // 这里的 options[key] 就是上面传入的函数
  2. if (isFunc(options[key])) {
  3. return options[key]((opt: any) => (wx as any)[key].call(wx, opt), fixArgs);
  4. }

这里的操作非常秀,需要细细品。。。

同样的方式,我们来看方法签名,debouncedApi() 调用之后的签名是这样的 (callback, arg) => callback(arg),对应到 weapp-helpers 中的调用:

  • callback: (opt: any) => (wx as any)[key].call(wx, opt)
  • arg: fixArgs

通过这个操作,可以在包的外部拿到处理过的 fixArgs 的引用,我们的默认值操作就是这样做到的,见上文 debouncedShowToast 方法。

这样设计需要告知用户在使用的时候,进行包装的函数是 (callback, arg) => callback(arg) 这样的函数签名,我们可以用函数式编程来更灵活的设计此处的实现。

函数式编程的实现

  1. // 首先需要准备一个支持占位符的 柯里化 函数
  2. const curry = function() {
  3. ...
  4. }
  5. // debounce 的模拟
  6. const debounce = function(fn, time, leading) {
  7. return function(...args) {
  8. return fn.apply(null, args);
  9. };
  10. };
  11. // 绑定对象的调用参数
  12. const partialObj = function(fn = () => {}, obj = {}) {
  13. return function(param = {}) {
  14. return fn.call(null, {
  15. ...obj,
  16. ...param
  17. });
  18. };
  19. }
  20. // 柯里化的占位符
  21. const _ = {};
  22. const curryPartialObj = curry(partialObj)(_, { icon: 'none' });
  23. const curryDebounce = debounce(curryPartialObj, 300, true);
  24. // 内部的执行逻辑需要修改,这里的 fn 就是外部传入的高阶函数
  25. const wxCall = (fn, args) => {
  26. // current
  27. const currentAPI = (finalArg) => {
  28. console.log(JSON.stringify(finalArg));
  29. }
  30. return fn(currentAPI)(args);
  31. }
  32. wxCall(curryDebounce, {
  33. showCancel: false
  34. })
  35. // {"icon":"none","showCancel":false}

微信官方的 API Promise 化

微信官方也提供了 API Promise 化的方案,API Promise化,让我们来对比对比跟 weapp-helpers 有何不同。

非常简陋的使用文档,一共就这么多:

  1. import { promisifyAll, promisify } from 'miniprogram-api-promise';
  2. const wxp = {}
  3. // promisify all wx's api
  4. promisifyAll(wx, wxp)
  5. console.log(wxp.getSystemInfoSync())
  6. wxp.getSystemInfo().then(console.log)
  7. wxp.showModal().then(wxp.openSetting())
  8. // compatible usage
  9. wxp.getSystemInfo({success(res) {console.log(res)}})
  10. // promisify single api
  11. promisify(wx.getSystemInfo)().then(console.log)

发现代码不多,核心代码一共 50 行,全部给拉下来看看,miniprogram-api-promise github

  1. ├── index.js
  2. ├── methods.js
  3. └── promise.js

index.js 中可以看到暴露的方法

  1. // index.js
  2. export {promisify, promisifyAll} from './promise'
  1. // methods.js
  2. export const asyncMethods = [
  3. 'showToast',
  4. 'showModal',
  5. ...
  6. ]
  1. import { asyncMethods } from './method'
  2. function hasCallback(args) {
  3. if (!args || typeof args !== 'object') return false
  4. const callback = ['success', 'fail', 'complete']
  5. for (const m of callback) {
  6. if (typeof args[m] === 'function') return true
  7. }
  8. return false
  9. }
  10. function _promisify(func) {
  11. if (typeof func !== 'function') return fn
  12. return (args = {}) =>
  13. new Promise((resolve, reject) => {
  14. func(
  15. Object.assign(args, {
  16. success: resolve,
  17. fail: reject
  18. })
  19. )
  20. })
  21. }
  22. export function promisifyAll(wx = {}, wxp = {}) {
  23. Object.keys(wx).forEach(key => {
  24. const fn = wx[key]
  25. if (typeof fn === 'function' && asyncMethods.indexOf(key) >= 0) {
  26. wxp[key] = args => {
  27. if (hasCallback(args)) {
  28. fn(args)
  29. } else {
  30. return _promisify(fn)(args)
  31. }
  32. }
  33. } else {
  34. wxp[key] = fn
  35. }
  36. })
  37. }
  38. export const promisify = _promisify

与 weapp-helpers 的区别:

  • 提供了 单个/全部 API 的 Promise 化
  • 同样支持 callback 调用方式,加入了这层判断之后,如果是 promise 的方式,会在每次使用的时候去调用 _promisify 函数临时包装
  • 官方的 promiseAll 是在调用的时候临时去包装
  • promiseAll 函数接收对象,对传入对象进行属性赋值

心得

在阅读代码的过程中,每一步都去关注函数签名,更有利于我们关注到核心逻辑,提高代码阅读效率。对于核心功能一句话能说清楚的函数即使嵌套了很多层阅读起来是非常的顺畅,这也许就是 一个方法只做一件事 的含义。

讨论🙋:对于第三方包的 API 或者是组件,是否有包一层再使用的必要?