前端 mock 完美解決方案實(shí)戰(zhàn)
加入我們一起學(xué)習(xí),天天進(jìn)步
開發(fā)打包有不同配置 eslint 驗(yàn)證 代碼風(fēng)格統(tǒng)一 commit 規(guī)范驗(yàn)證 接口mock 熱更新 異步組件
Mock功能介紹
mock功能和前端代碼解耦 一個(gè)接口支持多種mock情況 無需依賴另外的后端服務(wù)和第三方庫 能在network看到mock接口的請(qǐng)求且能區(qū)分 mock數(shù)據(jù)、接口配置和頁面在同一個(gè)目錄下 mock配置改變無需重啟前端dev 生產(chǎn)打包可以把mock數(shù)據(jù)注入到打包的js中走前端mock 對(duì)于后端已有的接口也能快速把Response數(shù)據(jù)轉(zhuǎn)化為mock數(shù)據(jù)
對(duì)于第8點(diǎn)在開發(fā)環(huán)境后端服務(wù)經(jīng)常不穩(wěn)定下,不依賴后端也能做頁面開發(fā),核心是能實(shí)現(xiàn)一鍵生成mock數(shù)據(jù)。
配置解耦
耦合情況
webpack-dev后端測(cè)試環(huán)境變了需要改git跟蹤的代碼 dev和build的時(shí)候 需要改git跟蹤的代碼 開發(fā)的時(shí)候想這個(gè)接口mock 需要改git跟蹤的代碼 mockUrl ,mock?
如何解決
├── config│ ├── conf.json # git 不跟蹤│ ├── config.js # git 不跟蹤│ ├── config_default.js│ ├── index.js│ └── webpack.config.js├── jsconfig.json├── mock.json # git 不跟蹤
// config/index.jsconst _ = require("lodash");let config = _.cloneDeep(require("./config_default"))try {const envConfig = require('./config') // eslint-disable-lineconfig = _.merge(config, envConfig);} catch (e) {//}module.exports = config;
// config/config_default.jsconst 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, // 支持跨域請(qǐng)求secure: true, // 支持 https},},...conf: {dev: {title: "前端模板",pathPrefix: "/react-starter", // 統(tǒng)一前端路徑前綴apiPrefix: "/api/react-starter", //debug: true,delay: 500, // mock數(shù)據(jù)模擬延遲mock: {// "global.login": "success",// "global.loginInfo": "success",}},build: {title: "前端模板",pathPrefix: "/react-starter",apiPrefix: "/api/react-starter",debug: false,mock: {}}}};
// package.json{ "name": "react-starter", "version": "1.0.0", "description": "react前端開發(fā)腳手架", "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 " }, ...}
// config/webpack.config.jsconst config = require('.')const env = process.env.BUILD_ENV ? 'build' : 'dev'const confJson = env === 'build' ? config.conf.build : config.conf.devfs.writeFileSync(path.join(__dirname, './conf.json'), JSON.stringify(confGlobal, null, '\t'))
引用配置
// src/common/utils.jsximport 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實(shí)現(xiàn)
效果
// src/pages/login/login-io.jsimport {createIo} from '@src/io'const apis = {// 登錄login: {method: 'POST',url: '/auth/login',},// 登出logout: {method: 'POST',url: '/auth/logout',},}export default createIo(apis, 'login') // 對(duì)應(yīng)login-mock.json
// src/pages/login/login-mock.json{"login": {"failed": {"success": false,"code": "ERROR_PASS_ERROR","content": null,"message": "賬號(hào)或密碼錯(cuò)誤!"},"success": {"success": true,"code": 0,"content": {"name": "admin","nickname": "超級(jí)管理員","permission": 15},"message": ""}},"logout": {"success": {"success": true,"code": 0,"content": null,"message": ""}}}
// config/conf.json{"title": "前端后臺(tái)模板","pathPrefix": "/react-starter","apiPrefix": "/api/react-starter","debug": true,"delay": 500,"mock": {"login.logout": "success"}}
思路
proxy: {"/api/react-starter/*": {target: `http://192.168.90.68:8888`,changeOrigin: true,secure: true,// onError: (),// onProxyRes,// onProxyReq},},
option.onError?出現(xiàn)錯(cuò)誤
option.onProxyRes?后端響應(yīng)后
option.onProxyReq?請(qǐng)求轉(zhuǎn)發(fā)前
option.onProxyReqWs
option.onOpen
option.onClose
onProxyReq
mock-key 來匹配mock文件如login-mock.json的內(nèi)容, 如login
mock-method 來匹配對(duì)應(yīng)文件內(nèi)容的方法項(xiàng) 如logout
onProxyRes
├── 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": ""}}}
前端接口封裝
使用
// src/pages/login/login-io.jsimport {createIo} from '@src/io'const apis = {// 登錄login: {method: 'POST',url: '/auth/login',},// 登出logout: {method: 'POST',url: '/auth/logout',},}export default createIo(apis, 'login') // login注冊(cè)到header的mock-key
// src/pages/login/login-store.jsimport {observable, action, runInAction} from 'mobx'import io from './login-io'// import {config, log} from './utils'export class LoginStore {// 用戶信息@observable userInfo// 登陸操作@action.boundasync login(params) {const {success, content} = await io.login(params)if (!success) returnrunInAction(() => {this.userInfo = content})}}export default LoginStore
createIo 請(qǐng)求封裝
// src/io/index.jsximport {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-unresolvedmockData = require('@/mock.json')} catch (e) {log(e)}let reloginFlag = false// 創(chuàng)建一個(gè)requestexport const request = creatRequest({// 自定義的請(qǐng)求頭headers: {'Content-Type': 'application/json'},// 配置默認(rèn)返回?cái)?shù)據(jù)處理action: (data) => {// 統(tǒng)一處理未登錄的彈框if (data.success === false && data.code === ERROR_CODE.UN_LOGIN && !reloginFlag) {reloginFlag = true// TODO 這里可能統(tǒng)一跳轉(zhuǎn)到 也可以是彈窗點(diǎn)擊跳轉(zhuǎn)Modal.confirm({title: '重新登錄',content: '',onOk: () => {// location.reload()history.push(`${config.pathPrefix}/login?redirect=${window.location.pathname}${window.location.search}`)reloginFlag = false},})}},// 是否錯(cuò)誤顯示messageshowError: true,message,// 是否以拋出異常的方式 默認(rèn)false {success: boolean判斷}throwError: false,// mock 數(shù)據(jù)請(qǐng)求的等待時(shí)間delay: config.delay,// 日志打印log,})// 標(biāo)識(shí)是否是簡單傳參數(shù), 值為true標(biāo)識(shí)復(fù)雜封裝export const rejectToData = Symbol('flag')/*** 創(chuàng)建請(qǐng)求IO的封裝* @param ioContent {any { url: string method?: string mock?: any apiPrefix?: string}}}* @param name mock數(shù)據(jù)的對(duì)應(yīng)文件去除-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 = {}) => {// 這里判斷簡單請(qǐng)求封裝 rejectToData=true 表示復(fù)雜封裝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]) { // 判斷是否是生產(chǎn)打包 mock注入到代碼中ioContent[key].mock = JSON.parse(JSON.stringify(mockData[name][key][config.mock[`${name}.${key}`]]))} else if (name && config.debug === true) { //注入 mock請(qǐng)求頭if (options.headers) {options.headers['mock-key'] = nameoptions.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.urlreturn request(option)}})return content}
request封裝axios
import axios from 'axios'// 配置接口參數(shù)// declare interface Options {// url: string// baseURL?: string// // 默認(rèn)GET// method?: Method// // 標(biāo)識(shí)是否注入到data參數(shù)// rejectToData?: boolean// // 是否直接彈出message 默認(rèn)是// showError?: boolean// // 指定 回調(diào)操作 默認(rèn)登錄處理// action?(data: any): any// headers?: {// [index: string]: string// }// timeout?: number// // 指定路由參數(shù)// params?: {// [index: string]: string// }// // 指定url參數(shù)// query?: any// // 指定body 參數(shù)// body?: any// // 混合處理 Get到url, delete post 到body, 也替換路由參數(shù) 在createIo封裝// data?: any// mock?: any// }// ajax 請(qǐng)求的統(tǒng)一封裝// TODO 1. 對(duì)jsonp請(qǐng)求的封裝 2. 重復(fù)請(qǐng)求/*** 返回ajax 請(qǐng)求的統(tǒng)一封裝* @param Object option 請(qǐng)求配置* @param {boolean} opts.showError 是否錯(cuò)誤調(diào)用message的error方法* @param {object} opts.message 包含 .error方法 showError true的時(shí)候調(diào)用* @param {boolean} opts.throwError 是否出錯(cuò)拋出異常* @param {function} opts.action 包含 自定義默認(rèn)處理 比如未登錄的處理* @param {object} opts.headers 請(qǐng)求頭默認(rèn)content-type: application/json* @param {number} opts.timeout 超時(shí) 默認(rèn)60秒* @param {number} opts.delay mock請(qǐng)求延遲* @returns {function} {params, url, headers, query, data, mock} data混合處理 Get到url, delete post 到body, 也替換路由參數(shù) 在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},}// 簡單請(qǐng)求處理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)}}// 路由參數(shù)處理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 參數(shù)處理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 = falseretData.message = err.messageif (err.response) {retData.status = err.response.statusretData.content = err.response.dataretData.message = `瀏覽器請(qǐng)求非正常返回: 狀態(tài)碼 ${retData.status}`}}}// 自動(dòng)處理錯(cuò)誤消息if (options.showError && retData.success === false && retData.message && option.message) {option.message.error(retData.message)}// 處理Actionif (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.codeerr.content = retData.contenterr.status = retData.statusthrow err}return retData}}
一鍵生成mock
# "build-mock": "node ./scripts/build-mock.js"# 所有:npm run build-mock mockAll# 單個(gè)mock文件:npm run build-mock login# 單個(gè)mock接口:npm run build-mock login.logout# 復(fù)雜npm run build-mock login.logout user
mock.json文件生成
根據(jù)配置動(dòng)態(tài)生成mock.json的內(nèi)容 監(jiān)聽config文件夾 判斷關(guān)于mock的配置項(xiàng)是否有改變重新生成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 = () => confGlobalexports.getMockJson =() => mockJsonData/*** 初始化項(xiàng)目的配置 動(dòng)態(tài)生成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.devconfGlobal = confJson// 1.根據(jù)環(huán)境變量來生成fs.writeFileSync(path.join(__dirname, '../config/conf.json'), JSON.stringify(confGlobal, null, '\t'))buildMock(confJson)}// 生成mock文件數(shù)據(jù)const buildMock = (conf) => {// 2.動(dòng)態(tài)生成mock數(shù)據(jù) 讀取src文件夾下面所有以 -mock.json結(jié)尾的文件 存儲(chǔ)到io/index.json文件當(dāng)中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)})// 如果是打包環(huán)境, 最小化mock資源數(shù)據(jù)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 = buildMockJsonfs.writeFileSync(path.join(__dirname, '../mock.json'), JSON.stringify(buildMockJson, null, '\t'))}// 監(jiān)聽配置文件目錄下的config.js和config_default.jsconst 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.devif (JSON.stringify(confJson) !== JSON.stringify(confGlobal)) {this.init()}}});}
接口代理處理
onProxyReq和onProxyRes
// scripts/api-proxy-cacheconst 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-consoleconsole.log(`Ajax 請(qǐng)求: ${mockKey}.${mockMethod}`,req.method, req.url)// eslint-disable-next-line no-consolereq.reqBody && console.log(JSON.stringify(req.reqBody, null, '\t'))if (mockKey && mockMethod) {req.mockKey = mockKeyreq.mockMethod = mockMethodconst conf = getConf()const mockJson = getMockJson()if (conf.mock && conf.mock[`${mockKey}.${mockMethod}`] && mockJson[mockKey] && mockJson[mockKey][mockMethod]) {// eslint-disable-next-line no-consoleconsole.log(`use mock data ${mockKey}.${mockMethod}:`, conf.mock[`${mockKey}.${mockMethod}`], 'color: green')res.mock = trueres.append('isMock','yes')res.send(mockJson[mockKey][mockMethod][conf.mock[`${mockKey}.${mockMethod}`]])}}},// 響應(yīng)緩存接口onProxyRes: async (res, req) => {const {method, url, query, path: reqPath, mockKey, mockMethod} = reqif (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-consolefs.writeFile(filePath, JSON.stringify(data,'', '\t'), (err) => { err && console.log('writeFile', err)})}},// 后端服務(wù)沒啟的異常處理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.jsconst {init} = require('../scripts/webpack-init');init();// 接口請(qǐng)求本地緩存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, '..'), // 本地服務(wù)器所加載的頁面所在的目錄inline: true,port: config.port,publicPath: '/',historyApiFallback: {disableDotRule: true,// 指明哪些路徑映射到哪個(gè)htmlrewrites: config.rewrites,},host: '127.0.0.1',hot: true,proxy: config.proxy,},}
總結(jié)
一鍵部署 npm run deploy 支持集群部署配置 是一個(gè)文件服務(wù) 是一個(gè)靜態(tài)資源服務(wù) 在線可視化部署前端項(xiàng)目 配置熱更新 在線Postman及接口文檔 支持前端路由渲染, 支持模板 接口代理及路徑替換 Web安全支持 Ajax請(qǐng)求驗(yàn)證,Referer 校驗(yàn) 支持插件開發(fā)和在線配置 可實(shí)現(xiàn):前端模板參數(shù)注入、請(qǐng)求頭注入、IP白名單、接口mock、會(huì)話、第三方登陸等等
??愛心三連擊 1.看到這里了就點(diǎn)個(gè)在看支持下吧,你的「點(diǎn)贊,在看」是我創(chuàng)作的動(dòng)力。
2.關(guān)注公眾號(hào)
程序員成長指北,回復(fù)「1」加入Node進(jìn)階交流群!「在這里有好多 Node 開發(fā)者,會(huì)討論 Node 知識(shí),互相學(xué)習(xí)」!3.也可添加微信【ikoala520】,一起成長。
“在看轉(zhuǎn)發(fā)”是最大的支持
評(píng)論
圖片
表情
