原生小程序封装请求-使用alova.js

0.需求

原生小程序的请求api仅仅提供了最原始请求功能,而附加功能呢如拦截器、缓存、持久化等等功能需要开发者封装。由于web开发的惯性,希望能在小程序里面直接无缝使用如axios这种第三方库,由于小程序运行环境的不同,基本上是不能直接使用这些web端的库。

很早之前就看过一眼这个请求库:alova.js,当时觉得这个跟其他请求库也没太大区别嘛就没这么关注。直到开始写小程序才想起看看。

对于我来说,这个库最大的优点是与ui框架无关,本文的封装适配都是基于alova自定义能力实现的。

其实axios也有适配器的概念,但是并没有体现在文档上,没有指引很难适配。

1.主要概念

alova的介绍请移步官网,可以说相当完善。

本文主要实现自定义alova。所以要了解的主要三部分是:请求适配器存储适配器states hook

1.1. 请求适配器

主要是处理请求发送、响应、上传、下载等一系列的逻辑。官网有一个封装xhr的例子。

1.2. 存储适配器

持久化操作相关,主要是缓存占位模式使用。

1.3. states hook

这个概念很有意思,在react的一些request hook中常常会这样写:

jsx 复制代码
const {data, loading, run} = useRequest(...)
useEffect(() => {
    // 对data操作
}, [data])
useEffect(() => {
    // 发送请求
    run()
}, [])
return loading ? <div>正在加载</div> : <div>渲染{data}</div>

这里主要关注一点,请求hook返回的是可以被ui框架识别的状态,而每一中框架的状态实现却不一样。reactuseStatevueref、reactive等等。

这也是alova的强项,因为它和ui框架无关,我们可以自己实现alova状态和ui框架状态的连接。

在官网有一个使用hook的例子,可以看到和其他库没有什么区别。

1.4. 其他

这个库还有一个官方宣传的亮点:请求策略。就是将一些逻辑封装成一种策略,比如token拦截器分页请求等等。

还有请求中间件,类似koa那种。

等等功能,基本上常用的功能能在上面找到或者自定义。

2. 实现

2.1. 实现请求适配器

大概的逻辑代码是这样,请注意代码是删减过的,并不能直接运行,只是提供思路。

代码里面的类型定义请忽略,有删减。

ts 复制代码
// Deferred就是返回一个promise
// 更方便的对这个promise做操作
export class Deferred<T, E = any> {
  isResolved: boolean = false;

  isRejected: boolean = false;

  resolve!: (value: T | PromiseLike<T>) => void;

  reject!: (err?: E) => void;

  promise: Promise<T>;

  constructor() {
    this.promise = new Promise<T>((resolve, reject) => {
      this.resolve = (...args) => {
        this.isResolved = true;
        resolve(...args);
      };
      this.reject = (...args) => {
        this.isRejected = true;
        reject(...args);
      };
    });
  }
}
ts 复制代码
const wXRequestAdapter = (requestElements, methodInstance) => {
  const { url, type, headers, data } = requestElements;
  const { baseURL, config } = methodInstance;
  if (type === 'PATCH') {
    throw new Error(`不支持的请求方法:${type}`);
  }
  const responseDeferred = new Deferred();
  const headerDeferred = new Deferred();
  const { requestAPI = 'request' } = config;
  let requestTask;
  if (requestAPI === 'request') {
    // 普通请求
    requestTask = wx.request({
        url,
        method: type,
        header: headers,
        data,
        ...config,
        complete(res) {
          if (res.errMsg.indexOf(':ok') >= 0) {
            if (res.data.success) {
              // 服务器响应成功
              responseDeferred.resolve(res.data);
            } else {
              // 服务器返回的错误
              responseDeferred.reject(res.data);
            }
          } else {
            // 发生错误,一般是超时、网络错误等微信内部错误
            responseDeferred.reject({
              success: false,
              message: res.errMsg,
            });
          }
        },
    });
    // 获取响应头
    requestTask?.onHeadersReceived(res => {
      headerDeferred.resolve(res);
    });
  return {
    // 有响应时的处理函数
    response() {
      return responseDeferred.promise;
    },
    headers() {
      // 返回响应头的异步函数
      return headerDeferred.promise;
    },
    // 中断请求,当外部调用abort时将触发此函数
    abort() {
      requestTask?.abort();
    },
  };
};
ts 复制代码
// 使用
alovaInstance.Post('/xxxx/xxx', 参数, 请求配置);

上面就最简单的一个请求适配器(只实现了wx.request)。

  1. 请求适配器接收两个参数,requestElements表示本次请求的配置,methodInstance表示本次的请求method实例,这里面包含了所有的alova的状态、配置信息。
  2. 请求适配器返回,一个对象,成员是对各种数据的处理函数。都需要返回一个Promise,其中抛出的错误会触发全局和局部的onError事件。
  3. 请求适配器的其他参数和返回请看官网对应章节
  4. 首先,我们通过methodInstance.config中的requestAPI判断应该使用哪种微信请求api,如上传、下载。这里的requestAPI默认是request,通过alovaInstance.Post(地址, 参数, {requestAPI: 'request'})传入
  5. config除了默认的一些配置,你可以传递自定义的一些配置。
  6. 然后调用微信对应的api,将配置参数全部传入,个别名字不一样的参数单独处理,如headermethod
  7. wx.request中,我直接只用的complete回调是因为方便将逻辑写在一起。这里判断请求成功就只是简单判断:ok字符串,不知道有没有更好的办法。
  8. res.data中的数据结合个人项目实际情况,我的项目中响应都遵循一个格式
ts 复制代码
{
    data: any,
    message: string,
    success: boolean
}

在后端响应返回错误和微信请求发生错误时reject

  1. 获取响应头一样的原理

2.1.1. 弱网、离线情况

在微信小程序中,离线的时候,如果你需要单独处理或者提示可以监听网络状态。

我的思路是在发送请求前检查是否离线,如果离线直接reject

ts 复制代码
// 改进过后
// 模拟Ref,防止闭包获取不到最新值
const networkStatus = {current: {}};
wx.onNetworkStatusChange(function (res) {
  networkStatus.current = res;
});
// ........wXRequestAdapter中
const responseDeferred = new Deferred(); 
const headerDeferred = new Deferred();
const { requestAPI = 'request' } = config;
let requestTask;
if (networkStatus.current.isConnected) {
    // 有网络情况
    if (requestAPI === 'request') {
        // ....
    }
}else {
    // 无网络情况
    responseDeferred.reject({ success: false, message: '无网络连接' });
}
//........

2.1.2. 上传下载

上传下载几乎是一样的思路,这里只讲上传。

ts 复制代码
if (requestAPI === 'uploadFile') {
  // 上传文件
  const { file, ...rest} = data as Service.UploadFileParameter;
  requestTask = wx.uploadFile({
    url,
    ...config,
    filePath: file,
    // 根据实际情况修改
    name: 'file',
    header: headers,
    formData: {...rest},
    complete(res) {
      if (res.errMsg.indexOf(':ok') >= 0) {
        // 微信上传接口返回的res.data是字符串
        const jsonRes = JSON.parse(res.data);
        if (jsonRes.success) {
          // 服务器响应成功
          responseDeferred.resolve(jsonRes);
        } else {
          // 服务器返回的错误
          responseDeferred.reject(jsonRes);
        }
      } else {
        // 发生错误,一般是超时、网络错误等微信内部错误
        responseDeferred.reject({
          success: false,
          message: res.errMsg,
        });
      }
    },
  });
}

return {
    // ....
    onUpload(updateUploadProgress) {
      // 上传进度信息,内部持续调用updateUploadProgress来更新上传进度
      requestTask.onProgressUpdate(res => {
        const { totalBytesExpectedToWrite, totalBytesWritten } = res;
        updateUploadProgress(totalBytesWritten, totalBytesExpectedToWrite);
      });
    },
}
  1. filePath表示的是微信的临时存储地址temp://开头的地址
  2. name是后端接收文件要取的字段名,根据实际情况修改
  3. formData就是额外的参数
  4. 如果后端有响应,在complete回调里接收到的res.data是字符串,需要自行处理

2.2. 实现存储适配器

这个很简单,只需要实现存储的增删查。

ts 复制代码
import { AlovaGlobalStorage } from 'alova';
const StorageAdapter: AlovaGlobalStorage = {
  get(key) {
    return wx.getStorageSync(key);
  },
  set(key, value) {
    wx.setStorageSync(key, value);
  },
  remove(key) {
    wx.removeStorageSync(key);
  },
};
export default StorageAdapter;

2.3. 实现states hook

这个适配器可以说是alova的精华所在,所有的内置hook都需要这个适配器,才能实现如缓存控制、状态管理等功能。 这里给大家提供一种思路,不一定是最好的。

2.3.1. 结构

官网中vueHook的例子

ts 复制代码
import { ref, watch, onUnmounted } from 'vue';

const VueHook = {
  // 状态创建函数
  create: rawData => ref(data),

  // 状态导出函数
  export: state => state,

  // 脱水函数
  dehydrate: state => state.value,

  // 响应式状态更新函数
  update: (newVal, states) => {
    Object.keys(newVal).forEach(key => {
      states[key].value = newVal[key];
    });
  },

  // 请求发送控制函数
  effectRequest({ handler, removeStates, saveStates, immediate, frontStates, watchingStates }) {
    // 组件卸载时移除对应状态
    onUnmounted(removeStates);

    // 调用useRequest和useFetcher时,watchingStates为undefined
    if (!watchingStates) {
      handler();
      return;
    }

    // 调用useWatcher时,watchingStates为需要监听的状态数组
    // immediate为true时,表示需要立即发送请求
    watch(watchingStates, handler, { immediate });
  }
};
  1. 我们以useRequest的使用为例,来解释这几个方法是干什么的
ts 复制代码
// vue3
import { useRequest } from 'alova';
import { alovaInstance } from './api';
const { loading, data, error } = useRequest(
  alovaInstance.Get('https://jsonplaceholder.typicode.com/todos/1')
);
  1. create:如何创建alova的状态,如loadingdata
  2. export:如何导出状态
  3. dehydrate:脱水就是定义如何将状态真实值取到,这个实际上是alova内部使用。类似vue3Ref()后需要.value
  4. update:如何更新状态,如loading -> false,需要定义如何更新
  5. effectRequest:定义如何触发请求,主要是给useWatch这种能监听外部状态变化的hook使用。定义怎样对watchingStates作出响应。

2.3.2. 使用mobx

useRequest返回的状态如果不能在外部监听变化,那就没有使用的意义了。而微信小程序只能监听data或者properties中定义的属性,我们需要一个桥梁连接alova状态和小程序的状态。

我这里的思路是引入mobx,因为它和框架无关,而且够简单,没有redux那种复杂概念。

还有其他思路欢迎讨论,比如想办法将setData引入等等。

2.3.3. 实现

ts 复制代码
const wXRerquestStatesHook = {
  //创建状态
  create: data => {
    return observable.box(data, { deep: false }) as WxRequestState;
  },
  // hook返回给用户使用的状态
  export: state => state,
  // 脱水操作,ref.value之类的
  dehydrate: <D>(state: IObservableValue<D>) => state.get(),
  // 更新状态操作
  update(newVal, state) {
    runInAction(() => {
      Object.keys(newVal).forEach(key => {
        state[key].set(newVal[key]);
      });
    });
  },
  // 触发请求操作
  effectRequest({
    handler,
    removeStates,
    immediate,
    watchingStates,
  }: EffectRequestParams<IObservableValue<any>>) {
    // 组件卸载时移除对应状态
    onUnload(removeStates);
    // 立即执行
    immediate && handler();
    let timer: any;
    // 如果传入了请求依赖,useWatcher
    (watchingStates || []).forEach((state, index) => {
      if (isBoxedObservable(state) || isComputed(state)) {
        // 只有Boxed数据 才能监听
        reaction(
          () => state.get(),
          () => {
            // 这里可以优化:判断新旧状态是否真的发生变化,深比较
            // 防抖,防止多次触发
            timer && clearTimeout(timer);
            timer = setTimeout(() => {
              handler(index);
              timer = undefined;
            }, 0);
          },
        );
      }
    });
  },
};
  1. create中:我使用observable.box包装初始状态,因为如loading: boolean这种状态是原始值,不能用observable,所以使用box类似于vueref

  2. export中:导出的状态保持boxed,这是因为后续需要监听状态变化,下一节中会讲

  3. dehydrate中: boxed变量获取值需要调用get方法

  4. update中:mobx更新值需要在action中,这里没有单独定义action。直接runInAction将新状态set

  5. effectRequest中:

    • 页面卸载时调用removeStates清除内部状态防止内存泄露。
    • immediate=true时立即执行handler,也就是请求
    • 传入watchingStates时,需要监听变化重新发出请求
  6. effectRequest中的onUnload 是我模仿vue写的组合式api

ts 复制代码
 // 大概原理就是使用getCurrentPages获取当前页面实例
 // 然后覆写页面的声明周期函数
 // 这里要注意一下执行的时机
 // 像onUnload这种方法,只要在页面卸载前调用就行了
 // 如果你要注册一个onLoad,可能要注意执行时机了
 // 因为调用的时候可能page已经执行过了这个声明周期了
function createLifeCycle(lifeCyclehook: keyof WechatMiniprogram.Page.ILifetime) {
  return (callback: Function) => {
    const pages = getCurrentPages();
      const currentPageInstance = pages[pages.length - 1];
    if (currentPageInstance) {
      const originalLifeCycleFn = currentPageInstance[lifeCyclehook];
      currentPageInstance[lifeCyclehook] = (...args: any[]) => {
        const orginRes = originalLifeCycleFn?.(...args);
        const res = callback?.(...args);
        return res !== (undefined || null) ? res : orginRes;
      };
    }
  };
}
/**
 * 页面卸载
 */
export const onUnload = createLifeCycle('onUnload');

2.4. 使用适配器

主要的三个适配器现在已经完成,剩下的就是使用和小程序状态的连接

2.4.1. 创建alova实例

ts 复制代码
export const alovaInstance = createAlova({
// 将前面的适配器传入
  requestAdapter: wXRequestAdapter,
  storageAdapter: wXStorageAdapter,
  statesHook: wXRerquestStatesHook,
  baseURL: '/api/xxx',
  // 全局请求前hook
  beforeRequest(method) {
    // 携带token之类的
  },
  // 响应拦截器
  responded: {
    async onSuccess(response, method) {
       // 判断token失效之类的
      return response;
    },
    onError(err: any, method) {
      // requestAdapter中抛出的错误或者reject
      // 做些处理,比如弹窗
       // 这里我继续抛出了错误,方便外面try catch
       // 你也可以不继续抛出,把错误统一处理
      return Promise.reject(err);
    },
    onComplete(method) {
      // 比如无论成功与否关闭加载弹窗
    },
  },
});

2.4.2. 连接状态至小程序的data

连接小程序状态的思路是借鉴的alova对于vue2的处理。

ts 复制代码
// 官网例子
import { mapAlovaHook } from '@alova/vue-options';
import { useRequest } from 'alova';
import { alovaInstance } from './api';

export default {
  mixins: mapAlovaHook(function() {
    return {
      // 使用alova实例创建method并传给useRequest即可发送请求
      todo: useRequest(
        alovaInstance.Get('https://jsonplaceholder.typicode.com/todos/1')
      )
    }
  }),
  data() {
    return {}; 
  }
}

官网使用了一个mapAlovaHookuseRequest返回的状态混入到了data中。

碰巧小程序有behaviors这个概念,和mixin差不多。

ts 复制代码
/**
 * 将请求状态连接至小程序的data
 */
export const mapRequestHook = (configFuc: MapRequestHookConfigFunc) => {
  /**
   * _config: {
   *    name1: {
   *      loading,
   *      data,
   *      ...
   *    }
   *    name2: {
   *      loading,
   *      data,
   *      ...
   *    }
   * }
   */
  return Behavior({
    lifetimes: {
      attached() {
        const config = configFuc?.() || {};
        const _this = this;
        // 之前的state hook适配器中
        // 导出的状态被observerble.box包装过,需要value.get()获取值
        // 所以config中的值要先解封装
        // 内部是浅层封装,所以不需要递归
        const jsState: any = {};
        Object.keys(config || {}).forEach(key => {
          const requestState = config[key] as any;
          jsState[key] = {};
          Object.keys(requestState || {}).forEach(stateKey => {
            const state = requestState[stateKey];
            const isMobxState = isBoxedObservable(state) || isComputed(state);
            jsState[key][stateKey] = isMobxState ? state.get() : state;
            // 监听状态变化
            // 重新设置data
            if (isMobxState) {
              reaction(
                () => state.get(),
                () => {
                  _this.setData({
                    [`${key}.${stateKey}`]: state.get(),
                  });
                },
              );
            }
          });
        });
        // 初始化设置状态进data
        this.setData({
          ...jsState,
        });
      },
    },
  });
};
ts 复制代码
// 使用mapRequestHook
Component({
  behaviors: [
    mapRequestHook(() => ({
      todo: useRequest(alovaInstance.Get('https://jsonplaceholder.typicode.com/todos/1') )),
    })),
  ],
  ...//
  observers: {
      'todo.loading': console.log
  },
  methods: {
      test() {
          console.log(this.data.todo.data)
      }
  }
})
  • mapRequestHook接收一个函数,返回config。键表示混入data的变量名,值表示hook返回的状态。

  • 参数为什么是函数?

    因为首次加载这个组件或者页面后配置项会被缓存,如果直接传递对象,第二次进入组件的时候不会重新执行mapRequestHook

    因此这里传递函数,然后在生命周期中确保每次都能重新执行useRequest返回正确的状态。

  • mapRequestHook主要逻辑就是两部分

    • alova状态混入小程序的data。由于小程序并不知道需要调用get方法获取值,所以首先需要解包状态
    ts 复制代码
    // 这里判断是否是包装过的状态
    // 因为方法是不会被包装的,如send,update
    const isMobxState = isBoxedObservable(state) || isComputed(state);
    jsState[key][stateKey] = isMobxState ? state.get() : state;
    • 监听alova状态变化并响应到小程序。
    ts 复制代码
    // mobx中用reaction监听变化
    // 
    reaction( 
        () => state.get(), 
        () => { 
            // 设置对应data
            _this.setData({ 
                [`${key}.${stateKey}`]: state.get()
            }); 
        },
    );

3.问题

3.1. 安装mobx

我使用的版本是6.12.0,直接安装然后微信开发者构建后是会报错的,提示process不存在。这其实是很多第三方库的通病,默认情况下第三方库肯定需要类似webpack这样的打包工具引入到我们的最终代码中,所以他们内部有些判断node环境变量的地方,webpack在打包的时候会替换环境变量,但是小程序没有这个功能。 我的解决方法是直接改miniprogram_npm中的源码,在miniprogram_npm/mobx/index.js的顶部增加一个虚拟的对象:

ts 复制代码
//已插入环境变量
const process = {
  env: {
    NODE_ENV: 'development'
  }
};
module.exports = (function () {....})()

当然,手动写太麻烦了。可以自己实现一个脚本,将这个变量插入到文件头部。根目录的project.config.json配置scripts.beforeCompile=npm run 你的脚本,这样每次编译前就会执行这个脚本。

具体脚本实现就展开了,大概就是使用babel解析js,然后插入代码写回。

3.2. 安装alova

同样内部使用了环境变量,但是这里不能在头部直接插入环境变量。因为源码中有一个判断是不是SSR环境的变量。 源码1 源码2

ts 复制代码
  isSSR = typeof window === 'undefined' && typeof process !== 'undefined',

如果插入了环境变量,isSSR=true,会导致请求发不出去。

我这里的解决办法是,在上面的脚本中单独判断一下是不是alova.js,如果是就直接把代码中的process.env.NODE_ENV替换为development或者production

当然这里依然需要babel来解析代码。

3.3. 未完全测试

基本的请求和上传我在项目中使用没什么问题,但是不排除一些功能可能存在问题。本篇文章也只是提供一下我的思路,以及看看大家有没有更好的思路。

相关推荐
小飞哥liac7 小时前
微信小程序的组件
微信小程序
stormjun8 小时前
Java基于微信小程序的私家车位共享系统(附源码,文档)
java·微信小程序·共享停车位·私家车共享停车位小程序·停车位共享
Bessie23411 小时前
微信小程序eval无法使用的替代方案
微信小程序·小程序·uni-app
shenweihong11 小时前
javascript实现md5算法(支持微信小程序),可分多次计算
javascript·算法·微信小程序
前端李易安11 小时前
手写一个axios方法
前端·vue.js·axios
17 小时前
微信小程序运营日记(第四天)
微信小程序·小程序
qq229511650219 小时前
小程序Android系统 校园二手物品交换平台APP
微信小程序·uni-app
一只不会编程的猫1 天前
微信小程序配置
微信小程序·小程序
程序员入门进阶1 天前
基于微信小程序的移动学习平台的设计与实现+ssm(lw+演示+源码+运行)
学习·微信小程序·小程序
黑子Kuroko1 天前
写一个 EventBus 实现微信小程序的发布订阅,支持全局消息通知、跨页面通信,高效好用!
微信小程序·eventbus·消息订阅·消息发布订阅·跨页面