mockjs 实现前端非侵入式 mock 解决方案

3,435

mockjs 实现前端非侵入式 mock 解决方案

背景

项目开发过程中,通常先定义接口格式,前后端并行开发。如果前端先开发完成,因为接口尚未实现,只能在代码中写一些测试数据来测试页面效果。这导致了测试数据侵入业务代码,后期上线时还得删去。现在的中大型应用通常会使用 vuexredux 等状态管理仓库,这种情况下测试数据写起来麻烦,因为可能需要修改部分逻辑来配合测试数据,后期去除时极易误删或遗漏改正测试逻辑。同时,这种测试方式,部分与请求相关的 bug 难以检测出 。这导致后期联调时前端压力重。

mock 方案需解决的问题

  • mock 数据不能侵入业务代码
  • mock 数据和工具不能打包到生产环境的代码中
  • mock 数据要实现热加载,方便调试
  • mock 工具不能对联调造成影响
  • mock 工具和数据对项目造成的侵彻要尽可能的小,要实现模块化可插拔

解决思路

  • 前端开发环境应进一步划分为如下两个
    • 前端联调环境:不携带 mock 工具及数据
    • 前端 mock 环境:携带 mock 工具及数据,对于注册了 mock 规则的接口返回设定好的 mock 数据,对未设定 mock 规则的接口请求正常发出
  • 可通过 node 命令行参数,动态地给 webpack 添加入口来实现是否打包 mock 工具及数据

mockjs 介绍

mockjs 是一个前端本地 mock 工具,其原理是对 XMLHttpRequest 对象进行改写,在请求发出前如果检测到请求的接口已注册 mock 规则,则返回设定好的测试数据,实际并没有发出 AJAX 请求。对于未注册 mock 规则的请求接口,则正常发出 AJAX 请求。因为原生 mockjs 不是很方便使用,对于注册的 mock 规则的请求接口,因为没有发出 AJAX 请求,无法在控制台检测到其网络请求,造成调试困难。除此之外,原生 mockjs 对于以字符串注册 mock 规则的接口是严格匹配的,这导致了 URL 带查询字符串的请求无法匹配到,需要编写正则来实现匹配带查询字符串的 URL 。因此对原生 mockjs 进行改写,使其返回测试数据时先打印到控制台,方便调试,同时将注册 mock 规则的接口字符串转成能匹配查询字符串的正则。注册 mock 规则时应使用改写后的 mock 对象

mockjs 与项目的结合

  • 通过 node 命令行参数动态给 webpack 添加入口的代码如下,也可单独配一个配置文件来实现相同的效果
// config 为 webpack 配置对象
// 获取命令行参数
const processArgvs = process.argv.slice(2)
// 判断是否有 mock 参数,有则在原入口的基础上带上 mock 工具与数据
if (processArgvs.includes('mock')) {
  let entry = config.entry
  if (Array.isArray(entry)) {
    entry.push('./src/mock')
  } else if (typeof entry === 'object') {
    Object.keys(entry).forEach(name => {
      if (Array.isArray(entry[name])) {
        entry[name].psuh('./src/mock')
      } else {
        entry[name] = [entry[name], './src/mock']
      }
    })
  } else {
    config.entry = [entry, './src/mock']
  }
}

// 以上代码加在启动 dev 环境的 webpack 配置文件中
// 通过 npm run dev mock 来启动前端 mock 环境
// npm run dev 启动前端联调环境
  • mock 工具及数据在项目文件夹中位置
src
  |__ mock
  	|__ index.js // 入口文件,注册 mock 规则的文件全部 import 到这里
  	|__ utils
  	|     |__ mock.js // 改写后的 mockjs,注册 mock 规则应使用该对象
  	|     |__ formatOptions.js // 格式化注册 mock 时的回调函数的参数的函数,在 mock.js 中使用
  	|__ user.js // 按业务划分的 mock 规则注册文件
        |__ business.js // 按业务划分的 mock 规则注册文件
  • mock.js 代码如下
import Mock from 'mockjs'
import formatOptions from './formatOptions'

Mock._mock = Mock.mock
Mock.mock = function (url, method, resFunc) {
  if (arguments.length === 1) {
    return this._mock(url)
  }
  if (arguments.length === 2) {
    console.error('Function Mock.mock require three params: url, method, resFunc!!!')
    return
  }
  if (arguments.length === 3) {
    let methods = ['get', 'post', 'put', 'delete']
    if (!methods.includes(method.toLowerCase())) {
      console.error('Function Mock.mock\'s second param should be get, post, put, delete!!!')
      return
    }
    if (typeof resFunc !== 'function') {
      console.error('Function Mock.mock\'s third param should be a function!!!')
      return
    }
  }
  // 将注册的 url 转成能匹配查询字符串的正则
  if (typeof url === 'string') {
    url = url.replace(/\//g, '\\/')
    url += '(|\\?.*)$'
    url = new RegExp(url)
  } else if (!(url instanceof RegExp)) {
    console.error('Function Mock.mock\'s first param should be a string or regexp!!!')
    return
  }
  this._mock(url, method, function (options) {
    // 格式化 options 对象
    options = formatOptions(options)
    let res = null
    try {
      res = resFunc(options)
    } catch (err) {
      res = err
    }
    // 将返回的测试数据打印到控制台
    console.groupCollapsed(`%c${options.type.toLowerCase()} | ${options.url}`, 'color: green;')
    console.log('%cparams: ', 'color: #38f')
    console.log(options.params)
    console.log('%cresponseData: ', 'color: #38f')
    console.log(res)
    console.groupEnd()
    console.log('---------------')
    return res
  })
}

export default Mock

  • formatOptions.js 代码如下
// qs 用于序列化表单对象
import qs from 'qs'

export default function formatOptions (options) {
  let { url, type, body } = options
  let params = null
  if (type === 'GET' || type === 'DELETE') {
    let index = url.indexOf('?')
    let paramsString = index > -1 ? url.slice(index + 1) : ''
    if (paramsString !== '') {
      params = qs.parse(paramsString)
    }
  } else {
    params = {}
    if (body instanceof FormData) {
      for (let [key, value] of body.entries()) {
        params[decodeURIComponent(key)] = decodeURIComponent(value)
      }
    } else {
      try {
        params = JSON.parse(body)
      } catch (e) {
        params = qs.parse(body)
      }
    }
  }
  if (params !== null && Object.keys(params).length === 0) {
    params = null
  }
  return { url, type, params }
}

改写后的 mockjs 用法

  • Mock.mock(url, method, resFunc)
    • url (String):需要进行 mock 的接口路径,也支持传入正则(但要自己考虑匹配带查询字符串的情况)
    • method (String): 请求的类型: get , post , put , delete ,忽略大小写
    • resFunc (Function): 生产测试数据的函数,回调参数为一个与请求有关的 options 对象,如下
{
  url: String, // 请求的路径
  type: String, // 请求的类型,GET, POST, PUT, DELETE
  params: Object // 请求的参数,如果是 post 和 put 请求为 body 的内容,get 和 delete 为查询字符串解析出的对象,没有则为 null
}

注:Mock.mock() 方法也支持传入一个模板来生成随机的测试数据,具体使用与原生 mockjs 一致,详见文档

示例

  • mock 文件夹下的 user.js 文件
// 当前文件为 src/mock/user.js
import Mock from './utils/mock'

// 注册 post 请求
Mock.mock('/api/user/login', 'post', options => {
  let { params } = options // options对象包含请求的 url,类型和携带的参数
  if (params.username && params.password) {
    return {
      data: '',
      code: 200,
      message: '登录成功'
    }
  } else {
    return {
      data: '',
      code: 300,
      message: '账号或密码未输入'
    }
  }
})

// 注册 get 请求
Mock.mock('/api/user/logout', 'get', options => {
  return {
    data: '',
    code: 200,
    message: '注销成功'
  }
})

// 注册带查询参数的 get 请求
Mock.mock('/api/user/query', 'get', options => {
  return {
    data: options.params,
    code: 200,
    message: 'ok'
  }
})

  • src/mock/index.js 文件夹
import './user'

console.log('%c前端 mock 环境启动成功', 'color: #38f;font-weight: bold')

  • 以上配置好后,开启 mock 环境可在控制台看到如下信息

  • 注册了 mock 规则的请求发出后可在控制台看到

具体操作可查看 demo