作者:rebareba
来源:https://segmentfault.com/a/1190000038320901
写在前面,本文阅读需要一定Nodejs的相关知识,因为会扩展webpack的相关功能,并且实现需要遵守一定约定和Ajax封装。沉淀的脚手架也放到Github上供给同学参考React-Starter, 使用手册还没写完善, 整体思路和React还是Vue无关,如果对大家有收获记得Star下。
它有这些功能:
开发打包有不同配置
eslint 验证
代码风格统一
commit 规范验证
接口mock
热更新
异步组件
市面上讲前端mock怎么做的文章很多,整体上阅读下来的没有一个真正站在前端角度上让我觉得强大和易用的。下面就说下我期望的前端mock要有哪些功能:
mock功能和前端代码解耦
一个接口支持多种mock情况
无需依赖另外的后端服务和第三方库
能在network看到mock接口的请求且能区分
mock数据、接口配置和页面在同一个目录下
mock配置改变无需重启前端dev
生产打包可以把mock数据注入到打包的js中走前端mock
对于后端已有的接口也能快速把Response数据转化为mock数据
上面的这些功能我讲其中几点的作用:
对于第7点的作用是后续项目开发完成,在完全没有开发后端服务的情况下,也可以进行演示。这对于一些ToB定制的项目来沉淀项目地图(案例)很有作用。
对于第8点在开发环境后端服务经常不稳定下,不依赖后端也能做页面开发,核心是能实现一键生成mock数据。
什么是前端配置解耦,首先让我们看下平时配置耦合情况有哪些:
webpack-dev后端测试环境变了需要改git跟踪的代码
dev和build的时候 需要改git跟踪的代码
开发的时候想这个接口mock 需要改git跟踪的代码 mockUrl ,mock?
前端依赖的配置解耦的思路是配置文件conf.json是在dev或build的时候动态生成的,然后该文件在前端项目引用:
├── config
│ ├── conf.json # git 不跟踪
│ ├── config.js # git 不跟踪
│ ├── config_default.js
│ ├── index.js
│ └── webpack.config.js
├── jsconfig.json
├── mock.json # git 不跟踪
webpack配置文件引入js的配置,生成conf.json
// config/index.js
const _ = require("lodash");
let config = _.cloneDeep(require("./config_default"))
try {
const envConfig = require( ./config ) // eslint-disable-line
config = _.merge(config, envConfig);
} catch (e) {
//
}
module.exports = config;
默认使用config_default.js 的内容,如果有config.js 则覆盖,开发的时候复制config_default.js 为config.js 后续相关配置可以修改config.js即可。
// config/config_default.js
const pkg = require("../package.json");
module.exports = {
projectName: pkg.name,
version: pkg.version,
port: 8888,
proxy: {
"/render-server/api/*": {
target: `http://192.168.1.8:8888`,
changeOrigin: true, // 支持跨域请求
secure: true, // 支持 https
},
},
...
conf: {
dev: {
title: "前端模板",
pathPrefix: "/react-starter", // 统一前端路径前缀
apiPrefix: "/api/react-starter", //
debug: true,
delay: 500, // mock数据模拟延迟
mock: {
// "global.login": "success",
// "global.loginInfo": "success",
}
},
build: {
title: "前端模板",
pathPrefix: "/react-starter",
apiPrefix: "/api/react-starter",
debug: false,
mock: {}
}
}
};
在开发或打包的时候根据环境变量使用conf.dev或conf.build 生成conf.json文件内容
// package.json
{
"name": "react-starter",
"version": "1.0.0",
"description": "react前端开发脚手架",
"main": "index.js",
"scripts": {
"start": "webpack-dev-server --config ./config/webpack.config.js --open --mode development",
"build": "cross-env BUILD_ENV=VERSION webpack --config ./config/webpack.config.js --mode production --progress --display-modules && npm run tar",
"build-mock": "node ./scripts/build-mock.js "
},
...
}
指定webpack路径是./config/webpack.config.js
然后在webpack.config.js中引入配置并生成conf.json文件
// config/webpack.config.js
const config = require( . )
const env = process.env.BUILD_ENV ? build : dev
const confJson = env === build ? config.conf.build : config.conf.dev
fs.writeFileSync(path.join(__dirname, ./conf.json ), JSON.stringify(confGlobal, null, ))
在src/common/utils.jsx文件中暴露出配置项,配置也可以通过window.conf来覆盖
// src/common/utils.jsx
import conf from @/config/conf.json
export const config = Object.assign(conf, window.conf)
然后就可以在各个页面中使用
import {config} from @src/common/utils
class App extends Component {
render() {
return (
)
}
}
ReactDOM.render(
,
document.getElementById( root ),
)
为了实现我们想要的mock的相关功能,首先是否开启mock的配置解耦可以通过上面说的方式来实现,我们一般在页面异步请求的时候都会目录定义一个io.js的文件, 里面定义了当前页面需要调用的相关后端接口:
// src/pages/login/login-io.js
import {createIo} from @src/io
const apis = {
// 登录
login: {
method: POST ,
url: /auth/login ,
},
// 登出
logout: {
method: POST ,
url: /auth/logout ,
},
}
export default createIo(apis, login ) // 对应login-mock.json
上面定义了登录和登出接口,我们希望对应开启的mock请求能使用当前目录下的login-mock.json文件的内容
// src/pages/login/login-mock.json
{
"login": {
"failed": {
"success": false,
"code": "ERROR_PASS_ERROR",
"content": null,
"message": "账号或密码错误!"
},
"success": {
"success": true,
"code": 0,
"content": {
"name": "admin",
"nickname": "超级管理员",
"permission": 15
},
"message": ""
}
},
"logout": {
"success": {
"success": true,
"code": 0,
"content": null,
"message": ""
}
}
}
在调用logout登出这个Ajax请求的时候且我们的conf.json中配置的是"login.logout": "success" 就返回login-mock.json中的login.success 的内容,配置没有匹配到就请求转发到后端服务。
// config/conf.json
{
"title": "前端后台模板",
"pathPrefix": "/react-starter",
"apiPrefix": "/api/react-starter",
"debug": true,
"delay": 500,
"mock": {
"login.logout": "success"
}
}
这是我们最终要实现的效果,这里有一个约定:项目目录下所有以-mock.jsom文件结尾的文件为mock文件,且文件名不能重复。
在webpack配置项中devServer的proxy配置接口的转发设置,接口转发使用了功能强大的
http-proxy-middleware (https://github.com/chimurai/http-proxy-middleware)
软件包, 我们约定proxy的配置格式是:
proxy: {
"/api/react-starter/*": {
target: `http://192.168.90.68:8888`,
changeOrigin: true,
secure: true,
// onError: (),
// onProxyRes,
// onProxyReq
},
},
它有几个事件触发的配置:
option.onError 出现错误
option.onProxyRes 后端响应后
option.onProxyReq 请求转发前
option.onProxyReqWs
option.onOpen
option.onClose
所以我们需要定制这几个事情的处理,主要是请求转发前和请求处理后
想在这里来实现mock的处理, 如果匹配到了mock数据我们就直接响应,就不转发请求到后端。怎么做呢:思路是依赖请求头,dev情况下前端在调用的时候能否注入约定好的请求头 告诉我需要寻找哪个mock数据项, 我们约定Header:
mock-key 来匹配mock文件如login-mock.json的内容, 如login
mock-method 来匹配对应文件内容的方法项 如logout
然后conf.json中mock配置寻找到具体的响应项目如:"login.logout": "success/failed"的内容
如果调用了真实的后端请求,就把请求的响应数据缓存下来,缓存到api-cache目录下文件格式mock-key.mock-method.json
├── api-cache # git 不跟踪
│ ├── login.login.json
│ └── login.logout.json
// api-cache/global.logout.json
{
"success": {
"date": "2020-11-17 05:32:17",
"method": "POST",
"path": "/render-server/api/logout",
"url": "/render-server/api/logout",
"resHeader": {
"content-type": "application/json; charset=utf-8",
...
},
"reqHeader": {
"host": "127.0.0.1:8888",
"mock-key": "login",
"mock-method": "logout"
...
},
"query": {},
"reqBody": {},
"resBody": {
"success": true,
"code": 0,
"content": null,
"message": ""
}
}
}
这样做的目的是为了后续实现一键生成mock文件。
上面我们看到定义了接口的io配置:
// src/pages/login/login-io.js
import {createIo} from @src/io
const apis = {
// 登录
login: {
method: POST ,
url: /auth/login ,
},
// 登出
logout: {
method: POST ,
url: /auth/logout ,
},
}
export default createIo(apis, login ) // login注册到header的mock-key
我们在store中使用
// src/pages/login/login-store.js
import {observable, action, runInAction} from mobx
import io from ./login-io
// import {config, log} from ./utils
export class LoginStore {
// 用户信息
@observable userInfo
// 登陆操作
@action.bound
async login(params) {
const {success, content} = await io.login(params)
if (!success) return
runInAction(() => {
this.userInfo = content
})
}
}
export default LoginStore
通过 createIo(apis, login ) 的封装在调用的时候就可以非常简单的来传递请求参数,简单模式下会判断参数是到body还是到query中。复杂的也可以支持比如可以header,query, body等这里不演示了。
这个是前端接口封装的关键地方,也是mock请求头注入的地方
// src/io/index.jsx
import {message, Modal} from antd
import {config, log, history} from @src/common/utils
import {ERROR_CODE} from @src/common/constant
import creatRequest from @src/common/request
let mockData = {}
try {
// eslint-disable-next-line global-require, import/no-unresolved
mockData = require( @/mock.json )
} catch (e) {
log(e)
}
let reloginFlag = false
// 创建一个request
export const request = creatRequest({
// 自定义的请求头
headers: { Content-Type : application/json },
// 配置默认返回数据处理
action: (data) => {
// 统一处理未登录的弹框
if (data.success === false && data.code === ERROR_CODE.UN_LOGIN && !reloginFlag) {
reloginFlag = true
// TODO 这里可能统一跳转到 也可以是弹窗点击跳转
Modal.confirm({
title: 重新登录 ,
content: ,
onOk: () => {
// location.reload()
history.push(`${config.pathPrefix}/login?redirect=${window.location.pathname}${window.location.search}`)
reloginFlag = false
},
})
}
},
// 是否错误显示message
showError: true,
message,
// 是否以抛出异常的方式 默认false {success: boolean判断}
throwError: false,
// mock 数据请求的等待时间
delay: config.delay,
// 日志打印
log,
})
// 标识是否是简单传参数, 值为true标识复杂封装
export const rejectToData = Symbol( flag )
/**
* 创建请求IO的封装
* @param ioContent {any { url: string method?: string mock?: any apiPrefix?: string}}
}
* @param name mock数据的对应文件去除-mock.json后的
*/
export const createIo = (ioContent, name = ) => {
const content = {}
Object.keys(ioContent).forEach((key) => {
/**
* @param {baseURL?: string, rejectToData?: boolean, params?: {}, query?: {}, timeout?: number, action?(data: any): any, headers?: {}, body?: any, data?: any, mock?: any}
* @returns {message, content, code,success: boolean}
*/
content[key] = async (options = {}) => {
// 这里判断简单请求封装 rejectToData=true 表示复杂封装
if (!options[rejectToData]) {
options = {
data: options,
}
}
delete options[rejectToData]
if (
config.debug === false &&
name &&
config.mock &&
config.mock[`${name}.${key}`] &&
mockData[name] &&
mockData[name][key]
) { // 判断是否是生产打包 mock注入到代码中
ioContent[key].mock = JSON.parse(JSON.stringify(mockData[name][key][config.mock[`${name}.${key}`]]))
} else if (name && config.debug === true) { //注入 mock请求头
if (options.headers) {
options.headers[ mock-key ] = name
options.headers[ mock-method ] = key
} else {
options.headers = { mock-key : name, mock-method : key}
}
}
const option = {...ioContent[key], ...options}
option.url = ((option.apiPrefix ? option.apiPrefix : config.apiPrefix) || ) + option.url
return request(option)
}
})
return content
}
这里对request也做进一步的封装,配置项设置了一些默认的处理设置。比如通用的请求响应失败的是否有一个message, 未登录的情况是否有一个弹窗提示点击跳转登陆页。如果你想定义多个通用处理可以再创建一个request2和createIo2。
是基于axios的二次封装, 并不是非常通用,主要是在约定的请求失败和成功的处理有定制,如果需要可以自己修改使用。
import axios from axios
// 配置接口参数
// declare interface Options {
// url: string
// baseURL?: string
// // 默认GET
// method?: Method
// // 标识是否注入到data参数
// rejectToData?: boolean
// // 是否直接弹出message 默认是
// showError?: boolean
// // 指定 回调操作 默认登录处理
// action?(data: any): any
// headers?: {
// [index: string]: string
// }
// timeout?: number
// // 指定路由参数
// params?: {
// [index: string]: string
// }
// // 指定url参数
// query?: any
// // 指定body 参数
// body?: any
// // 混合处理 Get到url, delete post 到body, 也替换路由参数 在createIo封装
// data?: any
// mock?: any
// }
// ajax 请求的统一封装
// TODO 1. 对jsonp请求的封装 2. 重复请求
/**
* 返回ajax 请求的统一封装
* @param Object option 请求配置
* @param {boolean} opts.showError 是否错误调用message的error方法
* @param {object} opts.message 包含 .error方法 showError true的时候调用
* @param {boolean} opts.throwError 是否出错抛出异常
* @param {function} opts.action 包含 自定义默认处理 比如未登录的处理
* @param {object} opts.headers 请求头默认content-type: application/json
* @param {number} opts.timeout 超时 默认60秒
* @param {number} opts.delay mock请求延迟
* @returns {function} {params, url, headers, query, data, mock} data混合处理 Get到url, delete post 到body, 也替换路由参数 在createIo封装
*/
export default function request(option = {}) {
return async (optionData) => {
const options = {
url: ,
method: GET ,
showError: option.showError !== false,
timeout: option.timeout || 60 * 1000,
action: option.action,
...optionData,
headers: { X-Requested-With : XMLHttpRequest , ...option.headers, ...optionData.headers},
}
// 简单请求处理
if (options.data) {
if (typeof options.data === object ) {
Object.keys(options.data).forEach((key) => {
if (key[0] === : && options.data) {
options.url = options.url.replace(key, encodeURIComponent(options.data[key]))
delete options.data[key]
}
})
}
if ((options.method || ).toLowerCase() === get || (options.method || ).toLowerCase() === head ) {
options.query = Object.assign(options.data, options.query)
} else {
options.body = Object.assign(options.data, options.body)
}
}
// 路由参数处理
if (typeof options.params === object ) {
Object.keys(options.params).forEach((key) => {
if (key[0] === : && options.params) {
options.url = options.url.replace(key, encodeURIComponent(options.params[key]))
}
})
}
// query 参数处理
if (options.query) {
const paramsArray = []
Object.keys(options.query).forEach((key) => {
if (options.query[key] !== undefined) {
paramsArray.push(`${key}=${encodeURIComponent(options.query[key])}`)
}
})
if (paramsArray.length > 0 && options.url.search(/?/) === -1) {
options.url += `?${paramsArray.join( & )}`
} else if (paramsArray.length > 0) {
options.url += `&${paramsArray.join( & )}`
}
}
if (option.log) {
option.log( request options , options.method, options.url)
option.log(options)
}
if (options.headers[ Content-Type ] === application/json && options.body && typeof options.body !== string ) {
options.body = JSON.stringify(options.body)
}
let retData = {success: false}
// mock 处理
if (options.mock) {
retData = await new Promise((resolve) =>
setTimeout(() => {
resolve(options.mock)
}, option.delay || 500),
)
} else {
try {
const opts = {
url: options.url,
baseURL: options.baseURL,
params: options.params,
method: options.method,
headers: options.headers,
data: options.body,
timeout: options.timeout,
}
const {data} = await axios(opts)
retData = data
} catch (err) {
retData.success = false
retData.message = err.message
if (err.response) {
retData.status = err.response.status
retData.content = err.response.data
retData.message = `浏览器请求非正常返回: 状态码 ${retData.status}`
}
}
}
// 自动处理错误消息
if (options.showError && retData.success === false && retData.message && option.message) {
option.message.error(retData.message)
}
// 处理Action
if (options.action) {
options.action(retData)
}
if (option.log && options.mock) {
option.log( request response: , JSON.stringify(retData))
}
if (option.throwError && !retData.success) {
const err = new Error(retData.message)
err.code = retData.code
err.content = retData.content
err.status = retData.status
throw err
}
return retData
}
}
根据api-cache下的接口缓存和定义的xxx-mock.json文件来生成。
# "build-mock": "node ./scripts/build-mock.js"
# 所有:
npm run build-mock mockAll
# 单个mock文件:
npm run build-mock login
# 单个mock接口:
npm run build-mock login.logout
# 复杂
npm run build-mock login.logout user
具体代码参考build-mock.js
https://github.com/rebareba/react-starter/blob/main/scripts/build-mock.js
为了在build打包的时候把mock数据注入到前端代码中去,使得mock.json文件内容尽可能的小,会根据conf.json的配置项来动态生成mock.json的内容,如果build里面没有开启mock项,内容就会是一个空json数据。当然后端接口代理处理内存中也映射了一份该mock.json的内容。这里需要做几个事情:
根据配置动态生成mock.json的内容
监听config文件夹 判断关于mock的配置项是否有改变重新生成mock.json
// scripts/webpack-init.js 在wenpack配置文件中初始化
const path = require( path )
const fs = require( fs )
const {syncWalkDir} = require( ./util )
let confGlobal = {}
let mockJsonData = {}
exports.getConf = () => confGlobal
exports.getMockJson =() => mockJsonData
/**
* 初始化项目的配置 动态生成mock.json和config/conf.json
* @param {string} env dev|build
*/
exports.init = (env = process.env.BUILD_ENV ? build : dev ) => {
delete require.cache[require.resolve( ../config )]
const config = require( ../config )
const confJson = env === build ? config.conf.build : config.conf.dev
confGlobal = confJson
// 1.根据环境变量来生成
fs.writeFileSync(path.join(__dirname, ../config/conf.json ), JSON.stringify(confGlobal, null, ))
buildMock(confJson)
}
// 生成mock文件数据
const buildMock = (conf) => {
// 2.动态生成mock数据 读取src文件夹下面所有以 -mock.json结尾的文件 存储到io/index.json文件当中
let mockJson = {}
const mockFiles = syncWalkDir(path.join(__dirname, ../src ), (file) => /-mock.json$/.test(file))
console.log( build mocks: ->>>>>>>>>>>>>>>>>>>>>>> )
mockFiles.forEach((filePath) => {
const p = path.parse(filePath)
const mockKey = p.name.substr(0, p.name.length - 5)
console.log(mockKey, filePath)
if (mockJson[mockKey]) {
console.error(`有相同的mock文件名称${p.name} 存在`, filePath)
}
delete require.cache[require.resolve(filePath)]
mockJson[mockKey] = require(filePath)
})
// 如果是打包环境, 最小化mock资源数据
const mockMap = conf.mock || {}
const buildMockJson = {}
Object.keys(mockMap).forEach((key) => {
const [name, method] = key.split( . )
if (mockJson[name][method] && mockJson[name][method][mockMap[key]]) {
if (!buildMockJson[name]) buildMockJson[name] = {}
if (!buildMockJson[name][method]) buildMockJson[name][method] = {}
buildMockJson[name][method][mockMap[key]] = mockJson[name][method][mockMap[key]]
}
})
mockJsonData = buildMockJson
fs.writeFileSync(path.join(__dirname, ../mock.json ), JSON.stringify(buildMockJson, null, ))
}
// 监听配置文件目录下的config.js和config_default.js
const confPath = path.join(__dirname, ../config )
if ((env = process.env.BUILD_ENV ? build : dev ) === dev ) {
fs.watch(confPath, async (event, filename) => {
if (filename === config.js || filename === config_default.js ) {
delete require.cache[path.join(confPath, filename)]
delete require.cache[require.resolve( ../config )]
const config = require( ../config )
// console.log( config , JSON.stringify(config))
const env = process.env.BUILD_ENV ? build : dev
const confJson = env === build ? config.conf.build : config.conf.dev
if (JSON.stringify(confJson) !== JSON.stringify(confGlobal)) {
this.init()
}
}
});
}
实现上面思路里面说的onProxyReq和onProxyRes 响应处理
util.js
https://github.com/rebareba/react-starter/blob/main/scripts/util.js
// scripts/api-proxy-cache
const fs = require( fs )
const path = require( path )
const moment = require( moment )
const {getConf, getMockJson} = require( ./webpack-init )
const API_CACHE_DIR = path.join(__dirname, ../api-cache )
const {jsonParse, getBody} = require( ./util )
fs.mkdirSync(API_CACHE_DIR,{recursive: true})
module.exports = {
// 代理前处理
onProxyReq: async (_, req, res) => {
req.reqBody = await getBody(req)
const { mock-method : mockMethod, mock-key : mockKey} = req.headers
// eslint-disable-next-line no-console
console.log(`Ajax 请求: ${mockKey}.${mockMethod}`,req.method, req.url)
// eslint-disable-next-line no-console
req.reqBody && console.log(JSON.stringify(req.reqBody, null, ))
if (mockKey && mockMethod) {
req.mockKey = mockKey
req.mockMethod = mockMethod
const conf = getConf()
const mockJson = getMockJson()
if (conf.mock && conf.mock[`${mockKey}.${mockMethod}`] && mockJson[mockKey] && mockJson[mockKey][mockMethod]) {
// eslint-disable-next-line no-console
console.log(`use mock data ${mockKey}.${mockMethod}:`, conf.mock[`${mockKey}.${mockMethod}`], color: green )
res.mock = true
res.append( isMock , yes )
res.send(mockJson[mockKey][mockMethod][conf.mock[`${mockKey}.${mockMethod}`]])
}
}
},
// 响应缓存接口
onProxyRes: async (res, req) => {
const {method, url, query, path: reqPath, mockKey, mockMethod} = req
if (mockKey && mockMethod && res.statusCode === 200) {
let resBody = await getBody(res)
resBody = jsonParse(resBody)
const filePath = path.join(API_CACHE_DIR, `${mockKey}.${mockMethod}.json`)
let data = {}
if (fs.existsSync(filePath)) {
data = jsonParse(fs.readFileSync(filePath).toString())
}
const cacheObj = {
date: moment().format( YYYY-MM-DD hh:mm:ss ),
method,
path: reqPath,
url,
resHeader: res.headers,
reqHeader: req.headers,
query,
reqBody: await jsonParse(req.reqBody),
resBody: resBody
}
if (resBody.success === false) {
data.failed = cacheObj
} else {
data.success = cacheObj
}
// eslint-disable-next-line no-console
fs.writeFile(filePath, JSON.stringify(data, , ), (err) => { err && console.log( writeFile , err)})
}
},
// 后端服务没启的异常处理
onError(err, req, res) {
setTimeout(() => {
if (!res.mock) {
res.writeHead(500, {
Content-Type : text/plain ,
});
res.end( Something went wrong. And we are reporting a custom error message. );
}
}, 10)
}
}
在webpack配置中引入使用
const config = require( . )
// config/webpack.config.js
const {init} = require( ../scripts/webpack-init );
init();
// 接口请求本地缓存
const apiProxyCache = require( ../scripts/api-proxy-cache )
for(let key in config.proxy) {
config.proxy[key] = Object.assign(config.proxy[key], apiProxyCache);
}
const webpackConf = {
devServer: {
contentBase: path.join(__dirname, .. ), // 本地服务器所加载的页面所在的目录
inline: true,
port: config.port,
publicPath: / ,
historyApiFallback: {
disableDotRule: true,
// 指明哪些路径映射到哪个html
rewrites: config.rewrites,
},
host: 127.0.0.1 ,
hot: true,
proxy: config.proxy,
},
}
mock做好其实在我们前端实际中还是很有必要的,做过的项目如果后端被铲除了想要回忆就可以使用mock让项目跑起来,可以寻找一些实现的效果来进行代码复用。当前介绍的mock流程实现有很多定制的开发,但是真正完成后,团队中的成员只是使用还是比较简单配置即可。
关于前端项目部署我也分享了一个BFF 层,当前做的还不是很完善,也分享给大家参考
Render-Server
https://github.com/rebareba/render-server
主要功能包含:
一键部署 npm run deploy
支持集群部署配置
是一个文件服务
是一个静态资源服务
在线可视化部署前端项目
配置热更新
在线Postman及接口文档
支持前端路由渲染, 支持模板
接口代理及路径替换
Web安全支持 Ajax请求验证,Referer 校验
支持插件开发和在线配置 可实现:前端模板参数注入、请求头注入、IP白名单、接口mock、会话、第三方登陆等等
- END -