这是啥
先说说背景,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)
// ...
}
后续
这里主要是提供实现的思路和一些关键代码,动动手,你也可以实现