Vuex调用接口的三个阶段
本文源自3月11日作者在公司内部的一次“泛前端分享”,是作者在开发IoT智能设备联动场景项目过程中的一些经验总结。文中代码可以视作伪代码,不包含任何涉及...
本文源自3月11日作者在公司内部的一次“泛前端分享”,是作者在开发IoT智能设备联动场景项目过程中的一些经验总结。文中代码可以视作伪代码,不包含任何涉及真实项目的内容。
Vuex是开发复杂Vue应用的必备工具,为跨组件共享数据提供了适合Vue自身的解决方案。关于Vuex的详细介绍,推荐阅读官网文档:https://vuex.vuejs.org/。
Vuex调用接口的三个阶段,总体上体现了项目在迭代中不断优化调用逻辑、重新组织代码和抽象实现细节的过程。
关注点分离及可维护代码:关注点分离(SoC,Separation of Concerns)是软件架构设计的一个重要原则,体现为以单一职责为目标来划分模块,通过将逻辑归类、分组,创建出相互独立但又有机统一的代码实体。关注点分离的代码,其模块职责清晰、关系明确,便于排错和维护,是代码整体可维护性的基础。
橄榄形接口与同构映射器:橄榄形接口比喻调用服务从整体上入口和出口小,但内涵逻辑丰富。这种接口对外部收敛,简单、直接,但将主要逻辑封装在了内部,便于集中处理请求和响应。同构映射(isomorphism mapping)是一个数学概念,在这里借用于形容自定义的、与Vuex辅助方法一致的服务映射。
响应归一及三级错误处理:响应的归一化(normaliztion)的目的是统一不同服务端接口返回数据的格式,以及请求错误的响应格式。三级错误处理分别是网络错误、服务错误和接口错误,这些错误可以作为响应以归一化的形式返回,也可以同步设置到Vuex的状态对象,成为响应式数据实时体现在界面组件上。
创建演示环境
qvk是一个集成现代前端工程化最佳实践的通用Web开发环境,可用于开发传统C/S架构的Web应用、SPA(单页应用)、H5(App内嵌页)等。
qvk初始版集成以下Web框架和打包工具。
ThinkJS:基于MVC模式的简单易用、功能强大的Node.js开发框架。
Vue.js:渐进式JavaScript框架,前端组件式开发主流选择。
Webpack:目前使用最广泛的前端资源模块打包工具。
用法
1. 拷贝代码
git clone git@github.com:qqvk/qvk.git
2. 安装依赖、初始化及启动服务
cd qvk // 进入项目目录 npm install // 安装依赖 npm run init // 初始化 npm start // 启动项目
第一阶段:关注点分离及可维护代码
上图左侧为Vuex架构,右侧是演示环境代码模块的依赖关系。以下是相应文件的代码,主要是lib/service1.js和store/store1.js,代表第一阶段:
lib/endpoints.js
/** * 默认导出API配置 */ export default { // 1. 取得用户设备列表 getUserDeviceList: { method: 'GET', endpoint: '/getUserDeviceList'}, // 2. 取得用户场景列表 getUserSceneList: { method: 'GET', endpoint: '/getUserSeceneList' }, // 3. 查询场景 getUserScene: { method: 'GET', endpoint: '/getUserScene' } } /** * 命名导出全局环境 */ export const ENV = 'https://the-service-address'
lib/factory.js
import API, { ENV } from './endpoints' /** * 默认导出工厂方法:根据服务名称和服务参数返回请求的url及options */ export default ({ serviceName = '', serviceArguments = {} }) => { const { method, endpoint } = API[serviceName] const urlBase = `${ENV}${endpoint}` const { queryString, headers } = getQueryStringAndHeaders(serviceArguments) let url, body, options if (method == 'POST') { url = urlBase; body = queryString options = { headers, method, body } } if (method == 'GET') { url = `${urlBase}?${queryString}` options = { headers, method } } return { url, options } } /** * getQueryStringAndHeaders()及其他Helpers(略) */
lib/service1.js
import factory from './factory' export default { // 取得用户场景列表 async getUserSceneList() { const { url, options } = factory({ serviceName: 'getUserSceneList'}) const res = await fetch(url, { ...options }).then(res => res.json()) return res }, // 取得用户设备列表 async getUserDeviceList() { const { url, options } = factory({ serviceName: 'getUserSceneList'}) const res = await fetch(url, { ...options }).then(res => res.json()) return res }, // 取得用户场景详情 async getUserScene({ scene_id }) { const { url, options } = factory({ serviceName: 'getUserScene', serviceArguments: { scene_id } }) const res = await fetch(url, { ...options }).then(res => res.json()) return res } }
store/store1.js
import SERVICE from '../lib/service1' const store = { state: {}, actions: { getUserSceneList() { return SERVICE['getUserSceneList']() }, getUserScene(store, {scene_id}) { return SERVICE['getUserScene']({scene_id}) } } } export default new Vuex.Store(store)
第一阶段的service1.js和store1.js实现了关注点分离。前者负责请求后端API,后者负责在Vue组件和服务之间映射接口。这一阶段的问题是代码逻辑重复:service1.js导出的3个接口调用的内部逻辑几乎完全一样(除了getUserScene()需要接收一个参数),而store1.js中actions中映射的逻辑也是重复的。
第二阶段:橄榄形接口与同构映射器
第二阶段要解决第一阶段的问题。首先,把重复逻辑提炼出来,构造“橄榄形”接口。
提炼重复逻辑的第一步是新建一个serve()函数,然后在每个接口中调用serve()。结果当然也是重复的:每个接口都是重复调用serve()。第二步是把所有接口调用整合起来,通过动态生成每个接口的方式达到“收敛”接口的目的。
收敛接口的实现方式有两种:第一种是动态生成导出对象的方法,第二种使用代理动态拦截请求,详见代码:
lib/service2.js
import factory from './factory' // import API from '../lib/endpoints' function serve({ serviceName = '', serviceArguments = {} }) { const { url, options } = factory({ serviceName, serviceArguments}) return fetch(url, { ...options }).then(res => res.json()) } // 第二种实现方式 export default new Proxy({}, { get(target, serviceName) { return serviceArguments => serve({ serviceName, serviceArguments}) } }) // 第一种实现方式 // export default Object.keys(API).reduce((pre, serviceName) => { // pre[serviceName] = serviceArguments => serve({ serviceName, serviceArguments}) // return pre // }, {})
如上所示,service2.js解决了service1.js的问题,消除了重复代码,把所有接口收敛为只有4-5行代码。这几行代码就是所有请求的总入口和总出口。这就是“橄榄形”尖尖的两头儿。
接下来通过自定义同构映射器来改造store1.js(参见上一节)。所谓同构映射器,就是与Vuex内置的mapActions和mapMutations辅助方法构造相同的映射函数。通过自定义这些映射函数,可以把原本重复的代码抽离出来,并实现在Vuex中以函数声明方式注册自定义服务,这与在Vue组件中使用Vuex的方式是一样的:
store/store2.js
import { mapActions, mapMutations } from './store2.mapper' const store = { state: {}, mutations: { ...mapMutations({ getUserSceneList: 'scenes', getUserScene: 'scene', getUserDeviceList: 'devices' }) }, actions: { ...mapActions([ 'getUserSceneList', 'getUserScene', 'getUserDeviceList' ]) } } export default new Vuex.Store(store)
为了实现以上store2.js的调用,需要增加一个模块store2.mapper.js:
store/store2.mapper.js
import SERVICE from '../lib/service2' // 命名导出同构action映射器 export const mapActions = API => API.reduce((pre, serviceName) => { pre[serviceName] = ({ commit }, serviceArguments) => { return SERVICE[serviceName](serviceArguments).then(res => { // 把异步响应数据提交到mutation commit(serviceName, res.data) return res }) } return pre }, {}) // 命名导出同构mutation映射器 export const mapMutations = MAP => Object.keys(MAP).reduce((pre, serviceName) => { pre[serviceName] = (state, data) => { // mutation根据配置把数据添加到state state[MAP[serviceName]] = data } return pre }, {})
第二阶段通过重构在service2.js中以几行代码实现了“橄榄形”接口的两个端点,而“橄榄”内部的逻辑将在第三阶段进行充实。此外,第二阶段通过自定义同构映射器简化了Vuex核心代码,而新增的store2.mapper.js则为第三阶段实现响应归一化提供了关口。
第三阶段:响应归一及三级错误处理
如前所述,第二阶段新增的store2.mapper.js为第三阶段实现响应归一化处理提供了关口,也就是写代码的地方。第三阶段的store3.js与store2.js没有区别,只是引用了新的store3.mapper.js:
store/store3.js
// 除改为引用store/store3.mapper.js,其余同store/store2.js
对后端请求返回的响应有正常响应,也有非正常响应和错误。另外,如果项目中要调用不同的服务端接口,这些接口返回的数据格式可能或多或少会有一些差异。为了在前端较为一致地实现响应与错误处理,有必对这些“响应”进行归一化处理,即自定义一个标准的响应格式。如下所示,store3.mapper.js借鉴了fetch请求的响应格式,以{ ok: true/false, payload: data/error }作为归一化格式:
store/store3.mapper.js
import SERVICE from '../lib/service3' export const mapActions = API => API.reduce((pre, serviceName) => { pre[serviceName] = ({ commit }, serviceArguments) => { return SERVICE[serviceName](serviceArguments).then(res => { commit(serviceName, res.data) // 响应归一化:正常响应 if(res.code === 0) { return { ok: true, payload: res } } // 响应归一化:非正常响应和各种错误 return { ok: false, payload: res } }) } return pre }, {}) export const mapMutations = MAP => Object.keys(MAP).reduce((pre, serviceName) => { pre[serviceName] = (state, data) => { state[MAP[serviceName]] = data } return pre }, {})
响应的归一化之所以放在store3.mapper.js这一层处理,是因为归一化不仅要涵盖正常和非正常响应,还要涵盖错误。而我们说的错误大致可以分三类或三级:
网络错误,包含断网、弱网等,断网会导致请求立即失败,弱网会导致请求超时;
系统错误,通常由于后端服务不能正常提供响应导致,如服务下线;
接口错误,指的是由于请求本身问题导致接口返回了错误响应。
以下是service3.js实现三级错误处理的代码,其中包括两种实现超时的方式:使用AbortController超时中断请求和使用包装约定(promise)接管fetch响应,然后超时拒绝约定(reject promise)。
lib/service3.js
import factory from './factory' function serve({ serviceName = '', serviceArguments = {} }) { let { url, options } = factory({ serviceName, serviceArguments}) const controller = new AbortController() const signal = controller.signal // 超时实现方式一:AbortController // setTimeout(() => { // controller.abort() // }, 10) // return fetch(url, { ...options, signal }).then(/*...*/).catch(/*...*/) // 超时实现方式二:包装Promise(及AbortController中断请求) return new Promise((resolve, reject) => { TIMEOUT_GUARD({ reject, controller }) // url = '/index/s500' 模拟服务器内部错误响应 fetch(url, { ...options, signal }).then(resolve, reject) }) .then(res => { const { ok, status, statusText } = res if(ok) { return res.json().then(res => { const { code, msg, reqid } = res if (code === 0) { return res } else { // 第三级错误:接口返回错误 return { code: 9001, error: res } } }) } else { // 第二级错误:远程服务错误 return { code: 8001, error: { status, statusText } } } }) .catch(error => { // 第一级错误:网络错误(超时或断网) // 如果使用第一种超时实现方式,则会有这个错误 if(error.name === 'AbortError') { return { code: 7001, error } } // 如果使用第二种超时实现方式,则直接返回错误,不再包装 if(error.code === 7002) return error // 其他请求错误,比如网络中断,统一为一种编码 return { code: 7000, error } }) // 第二种超时实现方式,定义超时拒绝约定(promise)的辅助函数 function TIMEOUT_GUARD({ reject, controller }) { setTimeout(() => { reject({ code: 7002, error: new Error('请求超时') }) controller.abort() }, 10) } } export default new Proxy({}, { get(target, serviceName) { return serviceArguments => serve({ serviceName, serviceArguments}) } })
如上代码所示,service3.js通过在serve()函数内部实现分级处理错误,适配了响应归一化所要求的数据格式(所有错误都返回code大于0的错误对象),同时也丰富了“橄榄”形接口的内部逻辑,让“橄榄”真正成形。
结束语
本文以Vuex调用接口为例,逐步递进地展示了获取后端数据逻辑不断优化、组织、抽象、提炼的过程。这些过程本质上是为了写出“性价比”最高的代码,即以尽量少的代码实现尽量复杂的功能:代码少,维护就容易;组织好,调试就方便;抽象准,理解就简单。正如作者翻译的一本畅销交互设计专著《简约至上》中所提到的“简约设计四策略”:组织、隐藏、删除、转移。这四个策略同样适用于代码、逻辑的抽象和简化。本文描述的“三个阶段”总结起来,也可以大致归入其中某个策略。
最后,本文虽然是以Vuex为例来演示,但背后的原则和道理是相通的。因此,本文应该对在React开发中使用Redux也有帮助。
本文作者李松峰,资深技术图书译者,翻译出版过40余部技术及交互设计专著,现任360奇舞团Web前端开发资深专家,360前端技术委员会委员、W3C AC代表。
本文转载自奇舞周刊
往期精彩回顾
2019年度精选文章
Linux中断一网打尽(2) - IDT及中断处理的实现
为什么我们要从MySQL迁移到TiDB?
360技术公众号
技术干货|一手资讯|精彩活动
扫码关注我们
更多推荐
所有评论(0)