1.4 Mock.js的基本原理
作为前端工程师,熟练使用某个第三方工具库只是基本要求,你需要对相关技术的基本原理、优缺点甚至未来的迭代优化方向有自己的理解,建议先尝试手动实现一些核心特征,看看自己能不能做到,然后再来阅读源码,看看工具库的作者是如何设计整体代码架构的,反思一下自己实现的那一部分与作者的实现思路是否一致,再仔细研究一下自己写不出来的那一部分,相较于阅读相关的技术博客,这种方式更能促进你快速成长。本节就来尝试手动复现Mock.js的主要功能,并学习如何以不同的方式将Mock数据作为请求的响应数据返回给调用者。
1.4.1 从模板到数据
Mock.js的核心功能是将特殊标记的语法转换为对应的模拟数据,也可以将其看作一种简易的DSL(Domain-Specific Language,领域特定语言)转换器。DSL并不是一种具体的语言,而是泛指任何针对指定领域的语言,它并不一定会有严格的标准,也可能仅仅是一种约定规范。在未来的前端开发中还会出现很多类似的任务,比如当下非常热门的“可视化搭建”技术,就是围绕DSL解析来实现的。Mock.js使用了一种非常简单的标记语法来描述数据的类型和结构,对于提供模拟业务数据这样的需求而言它已经够用了。阿里妈妈前端团队出品的开源接口管理工具Rap2[1]在实现Mock功能时也使用了这样的语法。如果想要描述更复杂的数据结构,比如对表单项或是前端UI进行抽象描述,简易的模板语法可能会显得力不从心,此时就可以考虑使用更为通用的JSON Schema[2]格式,如果希望构造出更加强大的模板语法,则还需要学习编译原理方面的知识,当然,这并不是本章的重点。
从前文中我们已经看到了Mock.js模板语法的格式比较固定,所以解析难度并不大。下面就以常见类型的语法解析为例来讲解从模板到数据的转换过程,示例代码如下:
//转换策略单例 Strategies = { 'String':(rule, value)=>{//...字符串类型的转换处理函数}, 'Number':(rule, value)=>{//...数字类型的转换处理函数}, 'Boolean':(rule, value)=>{//...布尔类型的转换处理函数}, 'Array':(rule, value)=>{//...数组类型的转换处理函数}, 'Placeholder':(rule, value)=>{//...占位符类型的转换处理函数} //...其他类型的转换策略 } //模板转换函数 function parseTemplate(schema = {}){ let result = {}; for ( let prop of Object.keys(schema)){ let [name, rule] = prop.split('|'); let value = tplObj[prop]; let type = value.startsWith('@')? 'Placeholder': Object.prototype.toString.call(value).slice(8,-1); result[name] = Strategies[type](rule, value); } return result; }
上面的代码并不难理解,首先使用一个对象来封装模板中不同类型所对应的模板转换函数,模板转换函数的返回结果即所生成的虚拟数据,这是一个典型的策略模式应用场景。当你想要增加新的类型时,只需要将新的解析函数以键值对的形式加入策略(Strategies)对象中即可,不必修改旧的代码。这样我们只需要遍历模板对象中的每条规则并拆分出关键信息即可,如果初始值是以@开头的字符串,则需要映射为占位符,否则就直接根据值的类型来找到对应的转换函数,最后将拆分出的信息作为参数传入对应的转换函数中就可以得到模拟数据。
拓展知识
“策略模式”是前端领域使用率较高的经典设计模式之一,使用该模式需要先定义一系列的算法,把它们封装起来,然后在使用过程中根据参数来动态选择算法,其目的是将算法的使用和算法的实现隔离开来,这样一来,使用者对于可变部分的修改和扩展就不会影响到不可变的部分,这是让代码保持“开放封闭原则”的一种有效手段。
例如在一个结算系统中,代码中编写了多个函数用于计算不同类别的会员可以享受的满减优惠,对于程序的逻辑来说,不变的部分就是结算时需要根据会员类别计算的满减规则,而变化的部分就是不同的会员类别对应的满减策略是不同的,那么对满减策略进行修改或扩充的代码就不应该影响结算流程的代码,同样,当向结算流程中加入其他环节时,也不应该影响满减策略的相关代码,这就是开发中常说的“解耦”。在JavaScript编程中使用“策略模式”时,通常会将各种策略函数以“键值对”的形式收集到一个对象上,这个对象称为“策略集”。当然,为了避免全局污染,也可以使用闭包将策略集对象包裹起来形成一个全局单例,然后对外暴露相应的扩展和修改方法,最后只要在结算流程中通过动态传入参数调用对应的满减策略就可以了。从代码风格上来看,策略模式的使用可以在一定程度上避免多重条件选择,相关代码的聚合度和程序的可维护性也更高。
1.4.2 为Ajax请求提供Mock数据
使用Node.js很容易创建出一个简易的Web服务器来响应请求,在项目开发中,使用Express或Koa2,可以很方便地通过路由来分发请求,本节将以使用express-generator脚手架工具生成工程为例,对应路由的示例代码如下(完整的实现代码可在本章代码仓库中获得):
var express = require('express'); var router = express.Router(); var Mock = require('mockjs'); //获取订单 router.get('/v1/query_orders', function (req, res) { var result = Mock.mock({ //此处为数据模板 }); res.send({ status:1, state:'success', message:'', result }); }); module.exports = router;
在本地Mock服务的支持下,我们可以在服务端代码中随时添加新的接口并生成需要的数据。测试的数据并不只是为了辅助UI开发,也可以通过在Mock服务中为某些字段赋予特别长的字符串,或是诸如null、undefined等特殊值来测试前端样式和逻辑的健壮性。更重要的是,所有这些操作都是在前端工程以外实现的,上线时只需要在打包工具中将请求的基准地址替换为生产环境的地址即可。
当Mock.js从服务端为Ajax请求提供数据时,它只承担了生成模拟数据的任务,而Web服务的功能是直接借助其他库来实现的,前文已经介绍过,Mock.js也可以直接在浏览器环境中运行,并为请求提供虚拟数据,那么它是如何做到的呢?在前端应用中,开发者通常不会直接使用底层的Ajax对象,而是更多地使用jQuery或axios这样的第三方库来管理网络请求,所以想要实现对网络请求的拦截和修改,只能对底层Ajax对象本身的表现进行修改,毕竟你无法知道应用层的代码到底使用了哪个请求库,又不希望为了使用Mock数据而修改应用层的逻辑代码。客户端的Ajax请求是通过XMLHttpRequest的全局对象(IE浏览器除外)来实现的,下面的代码演示了如何使用XMLHttpRequest的全局对象发送一个GET请求并处理它的响应:
let xmlhttp = new XMLHttpRequest(); xmlhttp.onreadystatechange = function(){ If (xmlhttp.readyState == 4 && xmlhttp.status == 200){ //虚拟的响应处理函数 handleResponse(xmlhttp.responseText); } } xmlhttp.open('GET','/try/Ajax',true); xmlhttp.send();
为了改变JavaScript中原生对象的默认行为,这里需要用到另一个经典设计模式——代理模式。
我们先从一个简单的场景入手,假设你在代码中编写了一个通用的A方法,其他同事在他们编写的B、C、D方法中都调用了这个方法。现在因为业务升级,你希望在A方法中添加一些新的扩展逻辑,如果不允许修改A方法的现有代码,那么你要如何来实现呢?我们最容易想到的方法就是新建一个A2方法,在它的函数体中先调用一次A方法,然后再加入新增的逻辑,这样的确可以达到上述要求,但你也不得不将B、C、D方法中调用的A方法逐个修改为A2方法,所以在代码实现上最好能够对调一下A和A2的函数名,这样对于上层的B、C、D而言,它们调用的仍然是A方法,而A方法的实现其实已经更换成了新的函数,新函数通过调用A2方法(即原来的A方法逻辑)保留了之前的逻辑,然后执行自己函数体中新增加的逻辑,这样就完成了A方法的功能扩展。其实,JavaScript中代理模式的实现也是这样一个过程,有趣的是,很多认为自己无法理解代理模式的开发人员,都可以依靠自己的思维完成上面的推演过程。
拓展知识
代理模式也称为Proxy模式,有时还被称为“劫持”模式,是前端使用率较高的经典设计模式中的一种,其目的是为其他对象提供一种代理机制,以控制对这个对象的访问。代理模式使用代理对象来控制具体对象的引用,代理对象几乎可以是任何对象:文件、资源、内存中的对象,或者是一些难以复制的东西。例如生活中的房屋中介,可以代表卖家把房子卖给买家,这中间卖家提出期望的价钱,买家也可以提出自己心仪的户型和预算,房产中介可以帮忙处理各类中间环节,从而促成交易,双方都只面对中介开展业务。从买家的视角来看,房产中介就相当于卖家的代理,尽管他不是卖家本身,但是在交易活动中可以代表卖家。
下面我们来看一段经典的应用“代理模式”的实例代码,Vue2中为了能够让数组具备“响应式”的特点,对典型的数组变异方法(指方法运行后会影响原数组的方法)进行了代理,代码如下(引用自Vue官方代码仓库src/core/observer/array.js[3]):
const arrayProto = Array.prototype export const arrayMethods = Object.create(arrayProto) const methodsToPatch = [ 'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse' ] /** * 插入代理方法并发送事件通知 */ methodsToPatch.forEach(function (method) { // 缓存原方法 const original = arrayProto[method] def(arrayMethods, method, function mutator (...args) { const result = original.apply(this, args) const ob = this.__ob__ let inserted switch (method) { case 'push': case 'unshift': inserted = args break case 'splice': inserted = args.slice(2) break } if (inserted) ob.observeArray(inserted) // 将变化通知给监听者 ob.dep.notify() return result }) })
下面就来简化一下上述代码,只保留它的核心结构,简化后的代码如下:
methodsToPatch.forEach(function (method) { //保存旧方法 const original = arrayProto[method] //定义新方法 def(arrayMethods, method, function mutator (...args) { //执行旧方法 const result = original.apply(this, args) //新增逻辑,用于增加响应式特性 //... return result; }) })
由示例代码可以看出,原生的方法被保存在original上(也就是将原生方法更为original),而原生方法名则在def方法中指向了新的函数,它的实现逻辑与我们之前的猜想是一致的。
了解了“代理模式”之后,再来看看如何在客户端得到Mock数据。当开发人员在程序中使用第三方库来处理Ajax请求时,实际上已经对原生XMLHttpRequest进行了代理,如果只是希望达到修改响应数据的目的,则完全可以利用第三方库提供的拦截器或是类似的机制(例如,axios库提供的interceptors机制)。在响应拦截器中,可以使用Mock.js生成模拟数据,但这样做的同时,你的库也会与其他库产生耦合,这并不是我们希望的结果。为了让自己的Mock代理程序能够“无侵入”地与其他请求库进行协作,我们需要从底层对整个XMLHttpRequest对象进行代理,这样无论应用层使用哪个库来进行网络请求,只要在请求过程中使用XMLHttpRequest,就会用到Mock程序。对于应用层方法来说,并不需要区分响应数据的来源,直观上来看其实就是进行了两次代理。具体的代码实现可以通过阅读Mock.js源码[4]来学习,它对整个XMLHttpRequest对象进行了模拟,并在注释中详细描述了标准规范、实现思路和参考资料,本章就不对此展开讲解了。
[1]官网地址:http://rap2.taobao.org/。
[2]官网地址:http://json-schema.org/。
[3]代码仓库地址:https://github.com/vuejs/vue/blob/dev/src/core/observer/array.js。
[4]参考地址:https://github.com/nuysoft/Mock/blob/refactoring/src/mock/xhr/xhr.js。