寫在前面,本文閱讀須要必定Nodejs的相關知識,由於會擴展webpack的相關功能,而且實現須要遵照必定約定和Ajax封裝。沉澱的腳手架也放到Github上供給同窗參考React-Starter, 使用手冊還沒寫完善, 總體思路和React仍是Vue無關,若是對你們有收穫記得Star下。
它有這些功能:html
市面上講前端mock怎麼作的文章不少,總體上閱讀下來的沒有一個真正站在前端角度上讓我以爲強大和易用的。下面就說下我指望的前端mock要有哪些功能:前端
上面的這些功能我講其中幾點的做用:node
對於第7點的做用是後續項目開發完成,在徹底沒有開發後端服務的狀況下,也能夠進行演示。這對於一些ToB定製的項目來沉澱項目地圖(案例)頗有做用。
對於第8點在開發環境後端服務常常不穩定下,不依賴後端也能作頁面開發,核心是能實現一鍵生成mock數據。
什麼是前端配置解耦,首先讓咱們看下平時配置耦合狀況有哪些:react
前端依賴的配置解耦的思路是配置文件conf.json是在dev或build的時候動態生成的,而後該文件在前端項目引用:webpack
├── config │ ├── conf.json # git 不跟蹤 │ ├── config.js # git 不跟蹤 │ ├── config_default.js │ ├── index.js │ └── webpack.config.js ├── jsconfig.json ├── mock.json # git 不跟蹤
webpack配置文件引入js的配置,生成conf.jsonios
// 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便可。git
// 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文件內容github
// 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
web
而後在webpack.config.js
中引入配置並生成conf.json文件ajax
// 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, '\t'))
在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 ( <Router history={history}> <Switch> <Route path={`${config.pathPrefix}`} component={Home} /> <Redirect from="/" to={`${config.pathPrefix}`} /> </Switch> </Router> ) } } ReactDOM.render( <App />, document.getElementById('root'), )
爲了實現咱們想要的mock的相關功能,首先是否開啓mock的配置解耦能夠經過上面說的方式來實現,咱們通常在頁面異步請求的時候都會目錄定義一個io.js的文件, 裏面定義了當前頁面須要調用的相關後端接口:
// src/pages/login/login-io.js import {createIo} from '@src/io' const apis = { // 登陸 login: { method: 'POST', url: '/dtwave-boot/sys/login', }, // 登出 logout: { method: 'POST', url: '/dtwave-boot/sys/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 軟件包, 咱們約定proxy的配置格式是:
proxy: { "/api/react-starter/*": { target: `http://192.168.90.68:8888`, changeOrigin: true, secure: true, // onError: (), // onProxyRes, // onProxyReq }, },
它有幾個事件觸發的配置:
因此咱們須要定製這幾個事情的處理,主要是請求轉發前和請求處理後
想在這裏來實現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: '/dtwave-boot/sys/login', }, // 登出 logout: { method: 'POST', url: '/dtwave-boot/sys/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
爲了在build打包的時候把mock數據注入到前端代碼中去,使得mock.json文件內容儘量的小,會根據conf.json的配置項來動態生成mock.json的內容,若是build裏面沒有開啓mock項,內容就會是一個空json數據。 固然後端接口代理處理內存中也映射了一份該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, '\t')) 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, '\t')) } // 監聽配置文件目錄下的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 響應處理
// 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, '\t')) 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,'', '\t'), (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 主要功能包含: