前言: 在实际的开发过程中,前、后端仍然存在过程式子的耦合,即前端人员过度的依赖服务端所提供的数据。这不仅会耽误前端开发的进度,也容易在联调过程中导致一些不必要的麻烦。所谓,前后端完全分离,就是要提供一种机制让前端开发人员不再依赖服务端的数据,而是接口。
情景再现
在实际的项目中,前端开发人员往往会遇到以下的问题而导致前端进度的缓慢:
- 服务端人员因为缓慢(启动服务耗时较长)或某些流程限制(例如所有人都写完了统一发布),导致发布服务数据的进度和时间变得缓慢;
- 在项目联调过程中,未能及时的处理服务端数据异常(例如timeout超时、404错误等)的某些情况,导致工作量又被追加;
- 某些特殊场景下,前后端开发人员的网络环境有差异(例如远程办公、离线开发模式等);
导致这种现象的根本原因就是前端过度依赖服务端,为了能解开这种高密度的耦合,我们首先是要建立公约制接口,然后按照接口进行离线开发。这样,我们可以先按照预先的接口进行模拟,待到双方条件具备时,按照接口进行联调测试,就方便许多。
实现方式
要想让前端隔离于服务端,就要在前端去做数据模拟——Mock。数据模拟的方式有很多,大致可以分为三类:
- 代码注入,即请求接口后,在fail或者catch中模拟数据进行操作;
- 拦截注入,即拦截ajax请求后,将模拟数据返回;
- 代理服务,即构建MockServer后,用代理方式将本地请求指向代理服务器获取模拟数据;
第一种方式是不值得提倡的,因为这样做将破坏ajax原有功能,也会形成许多垃圾代码,导致后期维护难度大,例如:
Axios.get('/api/users')
.then(/* ... */)
.catch(err => {
const data = { /* ...*/ };
/* 对data的处理 */
})
catch真正应该构建的不应该是数据模拟,而是对页面上交互流程的处理,例如在数据请求失败时,给予用户相应的提示。
对于第三种方式,也是比较容易实现的,在webpack中配置一下代理即可,然后请求时,将原有接口改为代理链接即可。现在,各种第三方MockServer已经非常成熟,但却依赖缓落环境,不适合离线开发的场景。如果自己有搭建服务器的能力,也可以搭建本地MockServer,但其使用成本没有第二种来的方便。
最后来说说第二种方式,这也是我个人最倾向的一种方式。在vue体系中,最常见的就是使用MockJS来拦截Axios的请求。但在实际使用过程中,语法上仍然是比较晦涩的,我们来看看使用原生的MockJS是如何构建拦截服务的:
import Mock from 'mockjs';
// 拦截请求-动态路由 get /api/user/:id
Mock.mock(/\/api\/user\/[0-9a-zA-Z]{8}-[0-9a-zA-Z]{4}-[0-9a-zA-Z]{4}-[0-9a-zA-Z]{4}-[0-9a-zA-Z]{12}/, 'GET', options => {
return { /* 返回的模拟数据 */};
});
// 拦截请求-参数化路由 get /api/users?
Mock.mock(/\/api\/user+(\?{0,1}(([A-Za-z0-9-~]+\={0,1})([A-Za-z0-9-~]*)\&{0,1})*)$/, GET, options => {
return { /* 返回的模拟数据 */};
});
看看这样的代码,是不是发现特别的晦涩难懂,而且还不太容易维护,开发人员要手写一段更复杂的mock也会很容易写错。架构的本质是让复杂的事情变得简单,因此,在此基础上,我们需要多Mock进行改进。
RouteMock的实现思路
我一直在思考一个问题,为什么Mock的写法不能和AJAX的写法一一对应呢?AJAX可以采用RESTFUL的方式来书写,Mock也应当按照这种方式来书写。构成这一想法的启蒙框架就是EXPRESS,EXPRESS提供了RESTFUL方式的路由,方便开发者将项目按照路由地址进行模块化。因此,我们就需要有如下的期望:
// AJAX请求
Axios.get('/api/user/10000')
.then(res => { /* 处理 res.data */});
// Mock模拟
Mock.get('/api/user/:id', id => {
return { /* 返回数据 data */ };
})
看看这样的期望,AJAX发送请求后,能拿到Mock返回的数据data。于此同时,Mock模拟的数据能拦截所有诸如/api/user/:id
的请求,这里占位符拿到的数据为id = 10000
,并且在Mock的回调函数中我们还可以做动态处理。
通过编码,我们对Mock进行相应的扩展,其最终功能如下:
// 按照数据模板返回模拟数据
Mock.mock({ /* MockJS template */ });
// 语义化RESTFUL请求模拟
Mock.get('/api/users', () => {});
Mock.post('/api/user', () => {});
// 动态路由模拟
Mock.get('/api/user/:id', id => {});
// get请求参数拦截
Mock.get('/api/user/:id', (id, params) => {});
// post请求参数拦截
Mock.post('/api/user', data => {});
// 同、异步返回
Mock.get('/api/user/:id', id => {
return { /* 返回数据 data */ };
});
Mock.get('/api/user/:id', id => {
return Promise.resolve({ /* 返回数据 data */ });
});
// 延时返回数据, 4秒后传回数据
Mock.get('/api/users', () => {
return { /* 返回数据 data */ };
}, {timeout: 4000});
// 异常code返回
Mock.get('/api/users', () => {
return { /* 返回数据 data */ };
}, {code: 500});
通过这样的封装,我们基本已经可以快速模拟客户端的所有请求,并模拟大部分可能性情况对客户端做出响应。在实现过程中,做了两个特别的处理:
- get请求和post请求的回调函数里有两种参数:占位符参数和请求参数。占位符参数即URL定义中的
:id
,请求参数分为data和params,data参数针对post请求,params针对get请求,每一种请求只能拦截一种。占位符参数永远优先与请求参数,且data参数和params不会共存。 - 增加了支持promise的返回,至于为什么要支持异步返回,是为了要配合前端数据库。因为仅仅依靠RouteMock模块只能模拟数据的状态,不能模拟数据的流向,业务是流动的,业务的流动必然会导致数据的变化。而对于客户端来说,没有什么是比数据库操作更加便捷的方式了,因此我们的架构体系中是拥有前端数据库的,以便于开发人员能快速的实现数据的
CRUD
。并且我们支持多种前端数据库,大部分可以链接到IndexDB、WebSQL,此时RouteMock对异步返回的数据有很好的支持的。
功能代码
啥也不说,我们上代码:
// EXPRESS所用到的路径转正则库
import pathTo from 'path-to-regexp';
// https://github.com/ctimmerm/axios-mock-adapter
import MockAdapter from 'axios-mock-adapter';
import Mock from 'mockjs';
// 封装的AJAX库
import { instance } from '@/lib/ajax';
import url from 'url';
import qs from 'qs';
// 实例化数据模拟器
const AdapterMock = new MockAdapter(instance);
const mock = {
mock: template => Mock.mock(template)
};
const httpMethods = ['get', 'post', 'patch', 'put', 'delete', 'head', 'options'];
httpMethods.forEach((type) => {
const mockMethod = 'on' + type.toLowerCase().replace(/( |^)[a-z]/g, (L) => L.toUpperCase());
mock[type] = function (urlReqExpString, callback, {code = 200, timeout = 0} = {}) {
// 将url匹配表达式进行转换, 例如: /path/:id
const rurl = pathTo(urlReqExpString, [], {
// 不忽略大小写
sensitive: true,
strict: true
});
// 对get请求进行甄别,因为get请求有`?`参数
if (type === 'get') {
// 将url正则两边的`/`去掉,并追加`?`参数的正则校验
const urlString = rurl
.toString()
.slice(1, -1)
.replace('$', '\\/{0,1}(\\?{0}|\\?{1}((\\S+\\={1})(\\S+)\\&{0,1})*)$');
// 重新编译正则规则
rurl.compile(urlString);
}
AdapterMock[mockMethod](rurl).reply(async config => {
const urlSchema = url.parse(config.url);
// 拦截的占位符参数,去除`?`校验所带的四组括号,以及第一项url
const argsArr = rurl.exec(urlSchema.pathname);
const args = argsArr.slice(1, type === 'get' ? argsArr.length - 4 : argsArr.length);
// get 提交参数
const params = config.params || qs.parse(urlSchema.query);
// post 提交参数
let datas;
try {
// 对报文进行JSON转换,如果转换失败,说明不是JSON格式,直接返回
datas = JSON.parse(config.data);
} catch (e) {
datas = config.data;
}
args.push(type === 'get' ? params : datas);
const result = Mock.mock(await callback.call(config, ...args));
// return result === undefined ? {} : result;
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve([code, result]);
}, timeout);
});
});
};
});
export default mock;
问题及思考
虽然模块的构建已经完成,然而在推广的时候遭遇到巨大的阻力,这也反映了Mock本身所带来的问题——功能鸡肋。因为开发过程中,某些接口并不是一开始就定义好的,即便定义好了也会有修改的可能。一旦有所变化,其代码修改的工作量也是挺大的,模拟数据的地方和接入数据的地方都要改动。
我们将开发过程分为五个模式:
- 本地开发模式,注入RouteMock模块,由该路由拦截本地请求响应数据;
- 本地联调模式,在dev生成的网页中更改配置,切换为联调模式,将通过代理方式访问测试服务器或其他服务器数据;
- 生产演示模式,该模式下mock部分依然注入,但优先连接生产测试库,若数据服务中断则自动切换为离线mock数据,在一些网络不好的发布、演示场景非常有用;
- 生产测试模式,无代理、无mock模式;
- 生产模式,同上;
对于本地开发模式,其实用性仍然比较强的,并且对于本地联调和生产演示都是可复用的,改动量不大的。而接口的改动的确在所难免,这在开发过程中需要明确以及协调。前端是一个工作量相对于服务端要大的多的工作环境,除了业务逻辑、界面样式、交互流程,还要等待服务端数据进行实时对接,降低这总服务间的耦合,可以让前端将关注点真正放在前端工程的构建上,而不是数据。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。