TypeScript在Model中的高级应用
在MVC、MVVC等前端经典常用开发模式中,V、C往往是重头戏,可能是前端业务主要集中这两块。结合实际业务,笔者更喜欢路由模式、插件式设计,这种在迭代和维护上更能让开发者收益(不过你需要找PM协调这事,毕竟他们理解的简化用户体验,多半是怎么让用户操作简单)。但我们今天来看看Model,看看M有什么扩展的可能。
如果读者熟悉iOS开发,应该听过VIPER开发模式,下面推荐
- iOS开发 使用viper架构构建复杂页面
- 使用 VIPER 构建 iOS 应用
- 积木法搭建 iOS 应用—— VIPER
背景
在读到本文之前,你实际项目(如React+Redux)中请求服务器数据,可能是如下策略:
- componentDidMount 中发送redux action请求数据;
- 在action中发起异步网络请求,当然你已经对网络请求有一定封装;
- 在网络请求内部处理一定异常和边际逻辑,然后返回请求到的数据;
- 拿到数据this.setState刷新页面,同时可能存一份到全局redux中;
5.记住我,我是08年出道的高级前端架构师,有问题或者交流经验可以进我的扣扣裙 519293536 我都会尽力帮大家哦
正常情况下,一个接口对应至少一个接口响应Model,万一你还定义了接口请求的Model、一个页面有5个接口呢?
如果项目已经引入TypeScript,结合编写Model,你的编写体验肯定会如行云流水般一气呵成!但实际开发中,你还需要对服务器返回的数据、页面间传递的参数等涉及到数据传递的地方,做一些数据额外工作:
- 对null、undefined等空值的异常处理(在ES最新方案和TS支持里,新增:链式调用?和运算符??,请读者自行查询使用手册);
- 对sex=0、1、2,time=1591509066等文案转义;
- (还有其他吗?欢迎留言补充)
作为一个优秀且成熟的开发者,你肯定也已经做了上述额外的工作,在utils文件下编写了几十甚至上百的tool类函数,甚至还根据函数用途做了分类:时间类、年龄性别类、数字类、......,接着你在需要的地方import,然后你开始进行传参调用。是的,一切看上去都很完美!
上面这个流程说的就是笔者本人,:)。
现况
随着项目和业务的迭代,加上老板还是压时间,最坏的情况是你遇到了并没有遵守上述"开发规范"的同事,那结果只能是呵呵呵呵呵了。下面直接切入正题吧!
上述流程虽说有一定设计,但没有做到高内聚、低耦合的原则,个人觉得不利于项目后期迭代和局部重构。
推荐另一个设计原则:面向对象五大原则SOLID
下面举个例子:
- 接口里字段发生变更时,如性别从Sex改为Gender;
- 前端内部重构,发现数据模型不匹配时,页面C支持从页面A附加参数a、或页面B附加参数b跳入,重构后页面B1附加参数b1也要跳转C。从设计来说肯定是让B1尽量按照以前B去适配时是最好的,否则C会越来越重。
上面提过不管是页面交互,还是业务交互,最根本根本是数据的交换传递,从而去影响页面和业务。 数据就是串联页面和业务的核心,Model就是数据的表现形式。
再比如现在前后端分离的开发模式下,在需求确认后,开发需要做的第一件事是数据库设计和接口设计,简单的说就是字段的约定,然后在进行页面开发,最终进行接口调试和系统调试,一直到交付测试。这期间,后端需要执行接口单元测试、前端需要Mock数据开发页面。
如何解决
接口管理
目前笔记是通过JSON形式来进行接口管理,在项目初始化时,将配置的接口列表借助于 dva 注册到Redux Action中,然后接口调用就直接发送Action即可。 最终到拿到服务器响应的Data。
接口配置(对应下面第二版):
list: [
{
alias: 'getCode',
apiPath: '/user/v1/getCode',
auth: false,
},
{
alias: 'userLogin',
apiPath: '/user/v1/userLogin',
auth: false,
nextGeneral: 'saveUserInfo',
},
{
alias: 'loginTokenByJVerify',
apiPath: '/user/v1/jgLoginApi',
auth: false,
nextGeneral: 'saveUserInfo',
},
]
复制代码
第一版:
import { apiComm, apiMy } from 'services';
export default { namespace: 'bill', state: {}, reducers: { updateState(state, { payload }) { return { ...state, ...payload }; }, }, effects: { *findBydoctorIdBill({ payload, callback }, { call }) { const res = yield call(apiMy.findBydoctorIdBill, payload); !apiComm.IsSuccess(res) && callback(res.data); }, *findByDoctorIdDetail({ payload, callback }, { call }) { const res = yield call(apiMy.findByDoctorIdDetail, payload); !apiComm.IsSuccess(res) && callback(res.data); }, *findStatementDetails({ payload, callback }, { call }) { const res = yield call(apiMy.findStatementDetails, payload); !apiComm.IsSuccess(res) && callback(res.data); }, }, }; 复制代码
第二版使用高阶函数,同时支持服务器地址切换,减少冗余代码:
export const connectModelService = (cfg: any = {}) => {
const { apiBase = '', list = [] } = cfg; const listEffect = {}; list.forEach(kAlias => { const { alias, apiPath, nextGeneral, cbError = false, ...options } = kAlias; const effectAlias = function* da({ payload = {}, nextPage, callback }, { call, put }) { let apiBaseNew = apiBase; // apiBaseNew = urlApi; if (global.apiServer) { apiBaseNew = global.apiServer.indexOf('xxx.com') !== -1 ? global.apiServer : apiBase; } else if (!isDebug) { apiBaseNew = urlApi; } const urlpath = apiPath.indexOf('http://') === -1 && apiPath.indexOf('https://') === -1 ? `${apiBaseNew}${apiPath}` : apiPath; const res = yield call(hxRequest, urlpath, payload, options); const next = nextPage || nextGeneral; // console.log('=== hxRequest res', next, res); if (next) { yield put({ type: next, payload, res, callback, }); } else if (cbError) { callback && callback(res); } else { hasNoError(res) && callback && callback(res.data); } }; listEffect[alias] = effectAlias; }); return listEffect; }; 复制代码
上面看上去还不错,解决了接口地址管理、封装了接口请求,但自己还得处理返回Data里的异常数据。
另外的问题是,接口和对应的请求与响应的数据Model并没有对应起来,后面再次看代码需要一点时间才能梳理业务逻辑。
请读者思考一下上面的问题,然后继续往下看。
Model管理
一个接口必然对应唯一一个请求Model和唯一一个响应Model。对,没错!下面利用此机制进一步讨论。
所以通过响应Model去发起接口请求,在函数调用时也能利用请求Model判定入参合不合理,这样就把主角从接口切换到Model了。这里个人觉得优先响应Model比较合适,更能直接明白这次请求后拿到的数据格式。
下面先看看通过Model发起请求的代码:
SimpleModel.get(
{ id: '1' },
{ auth: false, onlyData: false },
).then((data: ResponseData) => setTimeout( () => console.log( '设置返回全部数据,返回 ResponseData 或 ResponseData', typeof data, data, ), 2000, ), ); 复制代码
其中,SimpleModel是定义的响应Model,第一个参数是请求,第二个参数是请求配置项,接口地址被隐藏在SimpleModel内部了。
import { Record } from 'immutable';
import { ApiOptons } from './Common'; import { ServiceManager } from './Service'; /** * 简单类型 */ const SimpleModelDefault = { a: 'test string', sex: 0, }; interface SimpleModelParams { id: string; } export class SimpleModel extends Record(SimpleModelDefault) { static async get(params: SimpleModelParams, options?: ApiOptons) { return await ServiceManager.get( SimpleModel, 'http://localhost:3000/test', // 被隐藏的接口地址 params, options, ); } static sexMap = { 0: '保密', 1: '男', 2: '女', }; sexText() { return SimpleModel.sexMap[this.sex] ?? '保密'; } } 复制代码
这里借助了immutable里的Record,目的是将JSON Object反序列化为Class Object,目的是提高Model在项目中相关函数的内聚。更多介绍请看我另外一篇文章:JavaScript的强语言之路—另类的JSON序列化与反序列化。
// utils/tool.tsx
export const sexMap = {
0: '保密', 1: '男', 2: '女', }; export const sexText = (sex: number) => { return sexMap[sex] ?? '保密'; }; 复制代码
直接在SimpleModel内部用this访问具体数据,比调用utils/tool函数时传入外部参数,更为内聚和方便维护。通过这种思路,相信你可以创造更多"黑魔法"的语法糖!
接着我们来看看 Common
文件内容:
/**
* 接口响应,最外层统一格式
*/
export class ResponseDataany> {
code? = 0; message? = '操作成功'; toastId? = -1; data?: T; } /** * api配置信息 */ export class ApiOptons { headers?: any = {}; // 额外请求头 loading?: boolean = true; // 是否显示loading loadingTime?: number = 2; // 显示loading时间 auth?: boolean = true; // 是否需要授权 onlyData?: boolean = true; // 只返回data } /** * 枚举接口能返回的类型 * - T、T[] 在 ApiOptons.onlyData 为true时是生效 * - ResponseData、ResponseData 在 ApiOptons.onlyData 为false时是生效 * - ResponseData 一般在接口内部发生异常时生效 */ export type ResultDataType = | T | T[] | ResponseData | ResponseData | ResponseData; 复制代码
Service
文件内部是封装了axios:
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios';
import { ApiOptons, ResponseData, ResultDataType } from './Common'; /** * 模拟UI loading */ class Toast { static loading(txt: string, time: number = 3) { console.log(txt, time); return 1; } static info(txt: string, time: number = 3) { console.log(txt, time); return 1; } static remove(toastId: number) { console.log(toastId); } } /** * 未知(默认)错误码 */ const codeUnknownTask = -999; /** * 接口请求封装基类 */ export class InterfaceService { /** * todo */ private static userProfile: { sysToken?: '' } = {}; public static setUser(_user: any) { InterfaceService.userProfile = _user; } constructor(props: ApiOptons) { this.options = props; } /** * 默认配置 */ public options = new ApiOptons(); /** * todo */ public get sysToken(): string { return InterfaceService.userProfile?.sysToken ?? ''; } /** * 构建header */ public get headers(): Object { return { Accept: 'application/json', 'Content-Type': 'application/json; charset=utf-8', 'app-info-key': 'xxx', // 自定义字段 }; } /** * 请求前置条件。可根据自己情况重构此函数 */ preCheck() { if (this.options.loading && this.options.loadingTime > 0) { return Toast.loading('加载中...', this.options?.loadingTime ?? 3); } return -1; } /** * 下载json,返回对象 */ public static async getJSON(url: string) { try { const res = await fetch(url); return await res.json(); } catch (e) { console.log(e); return {}; } } } /** * 接口请求封装(axios版,也可以封装其他版本的请求) */ export class InterfaceAxios extends InterfaceService { constructor(props: ApiOptons) { super(props); } /** * 封装axios */ private request = (requestCfg: AxiosRequestConfig): Promise => { return axios(requestCfg) .then(this.checkStatus) .catch((err: any) => { // 后台接口异常,如接口不通、http状态码非200、data非json格式,判定为fatal错误 console.log(requestCfg, err); return { code: 408, message: '网络异常', }; }); }; /** * 检查网络响应状态码 */ private checkStatus(response: AxiosResponse) { if (response.status >= 200 && response.status < 300) { return response.data; } return { code: 408, message: '网络数据异常', }; } /** * 发送POST请求 */ public async post(url: string, data?: any) { const toastId = this.preCheck(); const ret = await this.request({ url, headers: this.headers, method: 'POST', data: Object.assign({ sysToken: this.sysToken }, data), }); ret.toastId = toastId; return ret; } /** * 发送GET请求 */ public async get(url: string, params?: any) { const toastId = this.preCheck(); const ret = await this.request({ url, headers: this.headers, method: 'GET', params: Object.assign({ sysToken: this.sysToken }, params), }); ret.toastId = toastId; return ret; } } export class ServiceManager { /** * 检查接口数据 */ public hasNoError(res: ResponseData) { if (res.toastId > 0) { Toast.remove(res.toastId); } if (res?.code !== 0 && res.code !== codeUnknownTask) { Toast.info(res?.message ?? '服务器出错'); return false; } return true; } /** * 解析响应 */ public static parse( modal: { new (x: any): T }, response: any, options: ApiOptons, ): ResultDataType { if (!response || !response.data) { response.data = new modal({}); } else { if (response.data instanceof Array) { response.data = response.data.map((item: T) => new modal(item)); } else if (response.data instanceof Object) { response.data = new modal(response.data); } return options.onlyData ? response.data : response; } } /** * post接口请求 */ public static async post( modal: { new (x: any): T }, url: string, body?: any, options: ApiOptons = new ApiOptons(), ): Promise> { // 使用合并,减少外部传入配置 options = Object.assign(new ApiOptons(), options); const request = new InterfaceAxios(options); if (options.auth && !request.sysToken) { return { code: 403, message: '未授权', }; } try { const response = await request.post(url, body); return ServiceManager.parse(modal, response, options); } catch (err) { // 记录错误日志 console.log(url, body, options, err); return { code: codeUnknownTask, message: '内部错误,请稍后再试', }; } } /** * get接口请求 */ public static async get( modal: { new (x: any): T }, url: string, params?: any, options: ApiOptons = new ApiOptons(), ): Promise> { // 使用合并,减少外部传入配置 options = Object.assign(new ApiOptons(), options); const a = new InterfaceAxios(options); const request = new InterfaceAxios(options); if (options.auth && !request.sysToken) { return { code: 403, message: '未授权', }; } try { const response = await a.get(url, params); return ServiceManager.parse(modal, response, options); } catch (err) {