编者按:本文作者李松峰,资深技术图书译者,翻译出版过40余部技术及交互设计专著,现任360奇舞团Web前端开发资深专家,360前端技术委员会委员、W3C AC代表。
本文源自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也有帮助。
关于奇舞周刊
《奇舞周刊》是360公司专业前端团队「奇舞团」运营的前端技术社区。关注公众号后,直接发送链接到后台即可给我们投稿。