opcdaclient 对com组件的调用返回了错误hresult_Vuex调用接口的三个阶段

编者按:本文作者李松峰,资深技术图书译者,翻译出版过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的状态对象,成为响应式数据实时体现在界面组件上。

创建演示环境

1d16cf6867274c3b1a20869e61ea4cfa.png

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 // 启动项目

第一阶段:关注点分离及可维护代码

8c9a12ae5ae4d926208d3c742a539684.png

上图左侧为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中映射的逻辑也是重复的。

第二阶段:橄榄形接口与同构映射器

a8f223f2087618f18bbed2b70f2d4d12.png

第二阶段要解决第一阶段的问题。首先,把重复逻辑提炼出来,构造“橄榄形”接口。

提炼重复逻辑的第一步是新建一个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则为第三阶段实现响应归一化提供了关口。

第三阶段:响应归一及三级错误处理

7f8a6e77fed43e0e2d63d0a1055c66f3.png

如前所述,第二阶段新增的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公司专业前端团队「奇舞团」运营的前端技术社区。关注公众号后,直接发送链接到后台即可给我们投稿。

c4595449fbe12e5ddfd240732b6585d5.png

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值