vite6迁移H5脚手架(三) —— 动态mock插件

125 阅读5分钟

这是啥

先说说背景,vite 下的项目,有很多 entry 有独立的 mock 数据需求

为啥要做

正常实现实现 mock 方式有三种

  • mock 网站,比如 yapi 开源的平台,在后台配置 mock 数据,可以在控制台 network 看到请求,请求一个规则只能命中一个,多人开发时候无法实现各调各的
  • 拦截请求,不走到 ajax 阶段,比如通过装饰器拦截请求的调用,直接把 mock 数据返回,这种就是开发者本地自己改了,可以实现各调各的,但是控制台 network 看不到请求,这样我们就没法知道真实的请求是什么
  • 通过 devServer 的中间件,拦截所有请求,发现命中了规则,直接返回 mock 文件里的数据,好处就是可以在控制台 network 看到请求,也可以各调各的

怎么去做

通过 vite plugin 形式,先拿到 devServer, 然后实现一个中间件,注入就行,具体步骤

  • 通过 vite plugin 拿到 devServer
  • 实现一个中间件,拦截所有请求
  • 拿到各个 enrty 下的所有 mock 文件,存储到 map 中, key 就是接口路径比如/api/user/login
  • 拦截请求, 根据请求里的路径,拿到 map 中查找,命中就返回 mock 数据
  • 为所有 map 中的文件加入监听,发现改变了,就重新 import 文件进来拿到最新的内容,更新到 map 中,然后重启服务

根据上面的路径,我门开始一一实现

新建一个 vite plugin,拿到 devServer, 注入自定义的中间件

// vite-plugin-mock.ts
function devServerPlugin(pages): Plugin {
  return {
    name: 'dev-server',
    apply: 'serve',
    enforce: 'post',
    async configureServer(server: ViteDevServer) {
      server.middlewares.use(mockMiddleware({ server, pages }))
    }
  }
}

export default devServerPlugin

实现一个中间件,拦截所有请求

function mockMiddleware(mockOptions) {
  const options = {
    ...mockOptions,
    // 初始化中间件,监听mock文件目录或文件
    // 默认要监听的文件或路径
    filename: mockOptions.filename || '/mock',
    // mock文件与html文件的映射
    mapMock: {}
  }
  requireCache.clear()
  // 建立webpack.entry和mock配置文件的映射关系
  entryToMapMock(options)

  return async function proxyMock(req, res, next) {
    // 拦截情趣
    const reqUrl = req.url || '' // 请求的url
    if (
      path.parse(reqUrl.split('?')[0]).ext ||
      !req.headers.referer ||
      /@vite|@react|node_modules|src|@|css|js|ts|vue/g.test(reqUrl)
    ) {
      // 不是ajax请求 || req.headers.referer为undefied,表示直接在浏览器访问接口,不走mock
      return next()
    } else {
      const refererUrl = new URL(req.headers.referer)
      // 拿到请求的路径,比如 /apis/v1/list
      let pathname = refererUrl.pathname
      pathname = ['.html', '.htm'].includes(path.parse(pathname).ext) ? pathname : 'index.html'
      if (options.mapMock[pathname]) {
        // 有mock配置文件映射
        // 请求路径对应的mock文件路径
        // mock文件获取和处理
      } else {
        // 没有mock配置文件
        return next()
      }
    }
  }
}

export default mockMiddleware

拿到各个 enrty 下的所有 mock 文件,存储到 map 中, key 就是接口路径比如/api/user/login

async function importFile(modName) {
  const mod = await import('file://' + modName + '?tt=' + Date.now())
  return mod.default || mod
}

const requireCache = new Map()
async function importCache(modName) {
  if (requireCache.has(modName)) {
    return requireCache.get(modName)
  }
  const mod = await importFile(modName)

  requireCache.set(modName, mod)
  return mod
}

function setMapMock(key, options, watchTarget) {
  // 设置index.html和mock文件映射关系
  if (!fs.existsSync(watchTarget)) return
  const stat = fs.statSync(watchTarget)
  options.mapMock[key] = options.mapMock[key] || []

  if (stat.isFile()) {
    options.mapMock[key].push(watchTarget)
  } else {
    options.mapMock[key] = options.mapMock[key].concat(
      fs.readdirSync(watchTarget).map((file) => path.join(watchTarget, file))
    )
  }
}

/**
 * 将entry位置和mock配置文件进行映射
 */
function entryToMapMock(options) {
  const filename =
    options.filename.indexOf('/') === 0 ? options.filename.slice(1) : options.filename

  // 字符串类型entry
  options.pages.forEach((p) => {
    const watchTarget = path.join(path.parse(path.resolve(p.entry)).dir, filename)
    setMapMock(p, options, watchTarget)
  })
}

其中 entryToMapMock 就是我们处理的入口函数,把 entrys 配置拿到各个 entry 的路径去扫描下面的 mock 文件

拦截请求, 根据请求里的路径,拿到 map 中查找,命中就返回 mock 数据

/**
 * 返回mock数据给客户端
 */
function responseMockData(
  res: http.ServerResponse,
  mockdata: Record<string, any>,
  mockFile: string,
  pathname: string,
  refererUrl: URL
) {
  delete mockdata.enable
  const runResponse = () => {
    res.setHeader('service-mock-middleware', 'This is a mock data.')
    res.setHeader('service-mock-middleware-file', mockFile)
    res.setHeader('service-mock-middleware-match', pathname)
    res.setHeader('Access-Control-Allow-Origin', `${refererUrl.protocol}//${refererUrl.host}`)
    res.setHeader('Access-Control-Allow-Credentials', 'true')
    res.setHeader('Access-Control-Allow-Headers', 'X-Requested-With,Content-Type')
    res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS')
    delete mockdata.delaytime
    res.writeHead(200, { 'Content-Type': 'application/json' })
    res.end(JSON.stringify(mockdata))
  }
  if (mockdata.delaytime) {
    setTimeout(runResponse, mockdata.delaytime)
  } else {
    runResponse()
  }
}

const getPostBody = (req) => {
  return new Promise((resolve, reject) => {
    if (req.method === 'POST') {
      getRawBody(
        req,
        {
          length: req.headers['content-length'],
          limit: '10mb',
          encoding: 'utf-8'
        },
        (err, raw) => {
          if (err) reject(err)
          try {
            resolve(JSON.parse(raw))
          } catch (e) {
            resolve({})
          }
        }
      )
    } else {
      resolve({})
    }
  })
}

function mockMiddleware(mockOptions) {
  // ...
  // entry和mock配置文件的映射关系
  entryToMapMock(options)

  return async function proxyMock(req, res, next) {
      // 接回上面的pathname后面的命中map的处理
      if (options.mapMock[pathname]) {
        // 有mock配置文件映射
        // 请求路径对应的mock文件路径
        const mapUrlByFile = {}
        // 获取mock文件配置,如果有多个mock配置文件,则合并mock配置文件
        const mockjson = await options.mapMock[pathname].reduce(
          async (previousValue, currentValue) => {
            const mockfile = currentValue
            // console.log(mockfile);
            if (fs.existsSync(mockfile)) {
              try {
                const mockjsonRes = await importCache(mockfile)
                const mockjson = mockjsonRes || {}
                if (!Object.keys(mockjson).length) {
                  return previousValue
                }
                table.push([
                  mockfile + ' 文件mock总开关',
                  `${mockjson.enable === false ? 'false' : 'true'}`
                ])
                if (mockjson.enable === false) {
                  return previousValue
                } else {
                  // 记录请求url对应的mock文件
                  Object.keys(mockjson).forEach((key) => {
                    mapUrlByFile[key] = mockfile
                  })
                  return { ...previousValue, ...mockjson }
                }
              } catch (e) {
                if (e.message.indexOf('Unexpected') !== -1)
                  console.error(chalk.red('语法错误:', mockfile + '有错误,请检查您的语法'))
                console.error(e.stack)
                return previousValue
              }
            }
          },
          {}
        )

        if (!mockjson || mockjson.enable === false) {
          return next()
        } else {
          const urlObj = new URL(`${/^http/.test(reqUrl) ? '' : refererUrl.origin}` + reqUrl)
          let mockdata = mockjson[urlObj.pathname]
          if (typeof mockdata === 'function') {
            // 如果是一个函数,则执行函数,并传入请求参数和req,res对象
            try {
              const body: any = await getPostBody(req)
              mockdata = mockdata({ query: getUrlQuery(urlObj), body }, req, res)
            } catch (e: any) {
              console.error(
                chalk.red(pathname, '函数语法错误,请检测您的mock文件:', mapUrlByFile[pathname])
              )
              console.error(e.message)
              // console.error(e.trace());
            }
            if (mockdata instanceof Promise) {
              mockdata = await mockdata
            }
            if (!mockdata) {
              console.error(pathname + '函数没有返回值,返回内容为:' + mockdata)
              return next()
            } else if (mockdata.enable || mockdata.enable === void 0) {
              responseMockData(
                res,
                mockdata,
                mapUrlByFile[urlObj.pathname],
                pathname,
                refererUrl
              )
            } else {
              return next()
            }
          } else if (typeof mockdata === 'object') {
            if (mockdata.enable === false) {
              return next()
            } else {
              responseMockData(
                res,
                mockdata,
                mapUrlByFile[urlObj.pathname],
                pathname,
                refererUrl
              )
            }
          } else {
            return next()
          }
        }
    }
  }
}

export default mockMiddleware

到此,一个 mock 功能就实现了,拦截请求,命中规则后,去加载 mock 文件,为了保证不加载错,根据页面的 url 上的 pathname 来判断是哪一个 entry,找到这个 entry 下的 mock 文件,然后执行 mock 文件中的内容,返回给前端。

为所有 map 中的文件加入监听,发现改变了,就重新 import 文件进来拿到最新的内容,更新到 map 中,然后重启服务

function watchMockFile(options) {
  // 监听回调函数
  const watchCallback = (watchTarget: string) => {
    // 让浏览器刷新,如果没传server对象,则不主动触发浏览器刷新!
    if (options.server) {
      if (requireCache.has(watchTarget)) {
        console.log(chalk.bgYellowBright('mock文件更新,重新加载mock数据 => ', watchTarget))
        requireCache.delete(watchTarget)
        importCache(watchTarget)
      }
      setTimeout(() => {
        // 重启devServcer
        options.server.restart()
      }, 500)
    }
  }
  const list: string[] = [
    ...new Set(
      Object.values(options.mapMock).reduce(
        (previousValue: string[], currentValue: string[]) => [...previousValue, ...currentValue],
        []
      )
    )
  ]
  const watcher = chokidar.watch(list, {
    persistent: true
  })
  watcher.on('all', async (event, path) => {
    if (['unlinkDir', 'addDir', 'ready', 'error', 'raw', 'add'].includes(event)) {
      return
    }

    // 无视.js以外的任何文件
    if (path.includes('.js')) {
      watchCallback(path)
    }
  })
}

function mockMiddleware(mockOptions) {
  const options = {
    ...mockOptions,
    filename: mockOptions.filename || '/mock',
    mapMock: {}
  }
  watchMockFile(options)
  // ...
}

后续

这里主要是提供实现的思路和一些关键代码,动动手,你也可以实现