侧边栏

基于msw的前端接口mock工具

发布于 | 分类于 前端/前端工程

在以往的项目中,数据mock比较简单,只需要mockjsMock.mock拦截一下请求,或者直接使用Chrome扩展程序来覆盖某些url接口的响应。

我之前写过一个@shymean/mock-server的命令行工具,可以读取一个mock文件,然后快速启动一个mock服务器,用于支持App、小程序等非web开发环境下的接口mock。

但是在目前的系统中,业务使用websocket收发消息的场景较多,而ws造数据是一个比较复杂的事情,常规的mock工具没有办法满足需求。

经过一番调研,最终选择了mswjs,并成功在项目中成功应用起来。本文整理一下整个过程以及一些心得体会。

mock目标

支持多种资源的mock

首先是可以代理的接口能力,我希望前端应用中的网络请求都能够被mock代理,比如常规的Ajax、新一点的fetch,以及websocket和老接口的JSONP,尤其是websocket,目前项目中的构造ws消息需要进行很麻烦的操作步骤。

看起来,mock server和 service worker mock是比较好的方案,而传统的代理XHR对象的方式就比较局限。

mock server就是搭建一个代理服务器,真实服务器可以发送的消息,都可以由这个mock服务器发送。

service worker mock就是通过service worker拦截网络请求,然后返回代理的mock接口数据。

明显区分mock和真实接口

mockjs是通过修改原生的XHR对象,直接返回了对应的响应内容,对应的网络请求实际上并不会发送,因此无法在devtools的network面板看到,不方便查看请求参数和响应内容

js
Mock.mock(/posts\/list/, {
  msg: 'ok',
  code: 0,
  data: {
  	name: "@cname"
  },
})

由于目前我们的业务比较复杂,一个页面甚至需要请求10来个接口进行初始化,所以我希望可以在进入页面的时候,可以清晰地看到哪些接口是被mock的,哪些是直接走原始的服务器请求;同时,那些被mock的接口还是可以在开发者工具查看,统一调试体验。

基于代码,可以版本控制

这个mock工具最好是可以完全由js代码来控制,而不是扩展程序或者桌面应用之类的工具。

应用工具需要有一定的学习成本,甚至需要付费;还需要面对功能扩展和版本迭代等问题。

而代码的灵活性很高,很方便造数据和压力测试,且源文件是可以通过git版本管理,方便团队共享。我认为有了mock,可以快速查看某个接口返回的数据,对后续接手该业务的同事是更友好的

可以接入API文档

手动编写需要mock的接口,需要花费一定的时间精力。

理想情况下,mock工具应该是可以读取后端的接口定义文档如YAPI、Swagger等,这样可以自动生成接口mock代码。

不过目前AI编码也比较成熟了,我觉得这里的开发工作应该是比较低的。

mswjs

mswjs基于 JavaScript 的运行时请求拦截库,能拦截和处理浏览器Node.js 环境中的 HTTP 和 WebSocket 请求。

它可模拟各种网络请求和响应场景,提供灵活的 API 来定义请求处理逻辑,使开发者能方便地模拟 Ajax、Fetch 和 WebSocket 的不同响应状态与数据。

在浏览器端,msw通过service worker的方式拦截网络请求并返回mock数据,这样可以满足我上面的几点需求

  • 支持多种资源的mock
  • 可以在调试面板看到对应的网络请求,只是响应变成了(from serviceWorker)
  • 通过提供的api,编写代码进行mock,同时与mockjs等测试数据构建工具兼容良好

具体的使用可以参考官方文档,本文并不会有过多涉及。

vite接入msw演示

接下来演示一下在前端项目中接入msw进行mock的步骤

首先创建一个前端测试应用,这里使用vite

pnpm create vite
pnpm install

然后安装msw

pnpm install msw --save-dev

然后生成对应的worker文件,该命令会在public目录下生成一个mockServiceWorkder.js的文件

npx msw init public/ --save

msw会在浏览器端自动注册这个worker

然后编写mock相关代码

js
// mocks/browser.js
import { setupWorker } from 'msw/browser'
import { http, HttpResponse } from 'msw'
import {Random} from 'mockjs'

export const handlers = [
  http.get('https://example.com/user', () => {
    return HttpResponse.json({
      id: 'c7b3d8e0-5e0b-4b0f-8b3a-3b9f4b3d3b3d',
      firstName: Random.cname(3),
      lastName: 'Maverick',
    })
  }),
]
export const worker = setupWorker(...handlers)

然后在应用入口文件加载这个mock文件

js
async function enableMocking() {
  // 通过环境变量区分,避免mock工具在非开发环境生效
  if (process.env.NODE_ENV !== 'development') {
    return
  }
 
  const ans = await import('../mocks/browser')
 
  const { worker } = ans
  return worker.start()
}
 
enableMocking().then(() => {
  // react应用初始化
  const app = (<App/>)
  createRoot(document.getElementById('root')).render(
    app
  )
})

之后在应用中编写一个网络请求接口

js
async function fetchUser(){
  // 这里的url就是上面handlers里面注册的那个url
  const response = await fetch('https://example.com/user')
  const user = await response.json()
  console.log(user)
}
fetchUser()

然后就可以看见对应的响应被代理成功了

处理未mock的请求

默认没有被代理的接口,会在控制台通过console.warn的方式提示(如上面截图所示)。

可以在handlers添加一个新的handler,监听通配符*,这样可以在控制台直接看到哪些未被mock的请求

js
// 添加一个通用的处理器来捕获所有未被 mock 的请求
http.all('*', async ({ request }) => {
  console.log('⚠️ 未 mock 的请求:', request.url)
  // 
})

由于项目没有历史mock的数据,如果需要实现某个页面的所有接口都支持mock

  • 方案一,进入页面,进行相关操作,打开控制台,查看network,挨个整理用到的网络请求,手动编写mock代码
  • 方案二,进入页面,进行相关操作,通过一个自动化的工具在后台收集到所有没有被mock的工具,获取url和响应的映射表,自动生成mock代码

毫无疑问方案二的自动化操作可以节省很多人力

js
export class UnMockCollect {
  record = {}

  addUnMockRequest(url, method, response) {
    console.log({ url, method, response })
    this.record[url] = { url, method: method.toLowerCase(), response }
  }

  createHandlerByUrl(url) {
    const row = this.record[url]
    if (!row) {
      console.warn(`url:${url}对应的请求未被收集,请检查`)
      return ''
    }
    const tpl = `
http.${row.method}('${row.url}', () => {
  return HttpResponse.json(${JSON.stringify(row.response, null, 4)})
})
`
    return tpl
  }

  printHandlerByUrl(url) {
    const code = this.createHandlerByUrl(url)
    console.log(code)
  }

  printAllUrls() {
    const keys = Object.keys(this.record)
    console.log(keys)
  }

  printAllHandlers() {
    const codes = []
    for (const key of Object.keys(this.record)) {
      const code = this.createHandlerByUrl(key)
      codes.push(code)
    }
    console.log(codes.join(','))
  }
}
export default new UnMockCollect()

然后创建一个通配符处理器

js
import UnMockCollect from '../utils/UnMockCollect'

if (needMockCollect) {
  window.__un_mock_record = UnMockCollect
}

// 添加一个通用的处理器来捕获所有未被 mock 的请求
needMockCollect && http.all('*', async ({ request }) => {
  // 定义需要跳过的后缀正则
  console.log('debug ⚠️ 未 mock 的请求:', request.url)

  if (request.headers.get('x-msw-bypass')) return

  // 添加特殊标记,避免死循环
  const headers = new Headers(request.headers)
  headers.set('x-msw-bypass', 'true')

  // 获取原始响应
  const response = await fetch(request, { headers })

  // 克隆响应以便多次读取
  const clonedResponse = response.clone()

  // 获取响应数据
  const responseData = await clonedResponse.json()
  UnMockCollect.addUnMockRequest(request.url, request.method, responseData)

  // 返回原始响应
  return new HttpResponse(JSON.stringify(responseData), {
    status: response.status,
    statusText: response.statusText,
    headers: response.headers,
  })
})

在进入页面操作完成之后,就可以通过window.__un_mock_record.printAllHandlers()来展示所有的mock代码,然后复制到msw中,再按照自己的需求修改响应内容就可以了。

mock websocket

msw可以充当ws客户端和服务端之间的代理,因此可以很灵活的mock

js
import { ws } from 'msw'
const url = `wss://ws.xxx.com`
const chat = ws.link(url)

chat.addEventListener('connection', ({ server, client }) => {
  console.log('debug Intercepted a WebSocket connection:')
  server.connect()

 
  // 监听客户端发送的消息
  client.addEventListener('message', (event) => {
    event.preventDefault()
    // server.send(event.data)

    client.send(JSON.stringify({
        msg: 'hello from server!'
    }))
  })

  // 监听服务器返回的消息
  server.addEventListener('message', (event) => {
    event.preventDefault()
    client.send(event.data)
  })
}),

创建连接之后,可以通过编程的方式来模拟ws消息,比如定时推送、模拟消息并发等。

唯一的缺点是,msw的ws mock貌似也是通过修改WebSocket的实现来处理,对应的消息无法再network的ws面板看见消息发送的过程。

mock代码与应用代码分离

从上面的演示可以看出,实际上mock代码跟应用代码是在一起的,enableMocking开启之后初始化应用代码,这样mock代码更新之后,还会触发热更新操作。

在实际开发中,由于我们的业务比较复杂,

  • 项目本身有service worker,用于接口缓存
  • 项目规模比较大,不希望触发额外的热更新
  • 需要有一个机制快速开启和关闭全局mock

最终采用的方案是通过外部脚本的方式加载mock代码,这个思路参考了react scan的一键集成方案。

要在项目中使用react scan,只需要引入一个外链js文件

html
<script src="https://unpkg.com/react-scan/dist/auto.global.js"/>

参考这个思路,我们的mock代码也可以通过这个方式引入

构建iife mock脚本

首先将当前的mock代码打个包,因此需要构建一个入口文件

js
// src/entry.js
import {worker} from '../mocks/browser'
worker.start()

console.log('debug inject mock data')

直接用vite构建这个文件模式

js
export default defineConfig({
  plugins: [react()],
  build: {
    lib: {
      entry: './src/entry.js',
      name: 'mockWorker',
      formats: ['iife'],
      fileName: 'mock-worker'
    }
  }
})

然后执行npm run build,会在dist目录下生成mock-worker.iife.js,同时public下的mockServiceWorker.jsworker文件也会自动复制过去。

然后开启一个静态资源服务器,让外部可以访问到这两个文件

php -S localhost:3001

在修改mock代码后,需要重新构建这个iife的js文件,由于代码本身没有啥外部依赖,vite构建速度也比较快,基本上可以满足业务快速调试的需求。

项目接入

由于我们项目已有service worker脚本,名字为service-worker.js,为了减少接入mock对当前项目的改动,在项目自己的woker中,可以通过importScripts加载mockServiceWorker

// service-worker.js
needMock && importScripts("http://localhost:3001/mockServiceWorker.js")

msw在worker.start()的时候,会自动注册mockServiceWorker.js这个文件,现在我希望它注册service-worker.js这个文件,因此需要修改一下配置,然后重新构建

js
window.__init_mock_service__ = async function (test = true) {
  worker.start({
      serviceWorker: {
          url: '/service-worker.js'
      }
  })
}

最后,在项目文件的html中,通过环境变量的方式,加载上面构建好的mock 脚本文件即可,比如我们项目使用的umi,就可以配置headScripts

js
// .umirc.js
headScripts:[
    needMock && 'http://localhost:3001/mock-worker.iife.js'
],

正常情况下worker.start是一个异步的流程,因此需要对项目初始化的做一点改造,这也是不如react-scan的一点,毕竟还是对业务代码有一次性改动的侵入

js
if (process.env.NODE_ENV === 'development') { 
  if(window.__init_mock_service__) {
    await window.__init_mock_service__()
  }
}
// 原始的init逻辑

这样,在加载我们自己的项目代码时,就可以正常使用mock了。

小结

按照上面的思路,我在项目中实际使用了一段时间,确实还是能提升不少开发效率

  • 对业务本身不是很熟,构造数据需要浪费很多时间,直接修改响应会快很多
  • 通过编程式的mock,可以避免常规如chrome扩展程序等基于JSON的mock工具带来的不灵活问题,可以实现非常灵活的mock

目前使用下来感觉还是不错的,尤其是在websocket消息和海量数据性能压测方面,后续遇到新的问题或者有新的想法,会继续更新本文。

你要请我喝一杯奶茶?

版权声明:自由转载-非商用-保持署名和原文链接。

本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。