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
框架识别的状态
,而每一中框架的状态实现却不一样。react
的useState
,vue
的ref、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
)。
- 请求适配器接收两个参数,
requestElements
表示本次请求的配置,methodInstance
表示本次的请求method
实例,这里面包含了所有的alova
的状态、配置信息。 - 请求适配器返回,一个对象,成员是对各种数据的处理函数。都需要返回一个
Promise
,其中抛出的错误会触发全局和局部的onError
事件。 - 请求适配器的其他参数和返回请看官网对应章节
- 首先,我们通过
methodInstance.config
中的requestAPI
判断应该使用哪种微信请求api
,如上传、下载。这里的requestAPI
默认是request
,通过alovaInstance.Post(地址, 参数, {requestAPI: 'request'})
传入 config
除了默认的一些配置,你可以传递自定义的一些配置。- 然后调用微信对应的
api
,将配置参数全部传入,个别名字不一样的参数单独处理,如header
、method
。 - 在
wx.request
中,我直接只用的complete
回调是因为方便将逻辑写在一起。这里判断请求成功就只是简单判断:ok
字符串,不知道有没有更好的办法。 res.data
中的数据结合个人项目实际情况,我的项目中响应都遵循一个格式
ts
{
data: any,
message: string,
success: boolean
}
在后端响应返回错误和微信请求发生错误时reject
。
- 获取响应头一样的原理
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);
});
},
}
filePath
表示的是微信的临时存储地址temp://
开头的地址name
是后端接收文件要取的字段名,根据实际情况修改formData
就是额外的参数- 如果后端有响应,在
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 });
}
};
- 我们以
useRequest
的使用为例,来解释这几个方法是干什么的
ts
// vue3
import { useRequest } from 'alova';
import { alovaInstance } from './api';
const { loading, data, error } = useRequest(
alovaInstance.Get('https://jsonplaceholder.typicode.com/todos/1')
);
create
:如何创建alova
的状态,如loading
、data
等export
:如何导出状态dehydrate
:脱水就是定义如何将状态真实值取到,这个实际上是alova
内部使用。类似vue3
中Ref()
后需要.value
update
:如何更新状态,如loading -> false
,需要定义如何更新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);
},
);
}
});
},
};
-
create
中:我使用observable.box
包装初始状态,因为如loading: boolean
这种状态是原始值,不能用observable
,所以使用box
类似于vue
的ref
-
export
中:导出的状态保持boxed
,这是因为后续需要监听状态变化,下一节中会讲 -
dehydrate
中:boxed
变量获取值需要调用get
方法 -
update
中:mobx
更新值需要在action
中,这里没有单独定义action
。直接runInAction
将新状态set
-
effectRequest
中:- 页面卸载时调用
removeStates
清除内部状态防止内存泄露。 immediate=true
时立即执行handler
,也就是请求- 传入
watchingStates
时,需要监听变化重新发出请求
- 页面卸载时调用
-
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 {};
}
}
官网使用了一个mapAlovaHook
将useRequest
返回的状态混入到了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. 未完全测试
基本的请求和上传我在项目中使用没什么问题,但是不排除一些功能可能存在问题。本篇文章也只是提供一下我的思路,以及看看大家有没有更好的思路。