背景
隨著網際網路應用工程規模的日益複雜化和精細化,我們在開發一個標準web應用的早已開始告別單幹模式,為了提升開發效率,前後端分離的需求越來越被重視,前端負責展現/互動邏輯,後端負責業務/資料介面,基本上也成為了我們日常專案分工中的標配,但是前後端分離一直以來都是一個工程概念,每個團隊在實現工程中都會基於自身的技術棧選擇和開發環境進行具體的實現,本文便根據自身團隊在webapck開發中搭建的前後端分離開發環境進行部分敘述。
理想化的前後端分離環境
目前業界比較有代表性的前後端分離的例子是SPA(Single-page application),所有用到的展現資料都是後端通過非同步介面(AJAX/JSONP/WEBSOCKET)的方式提供的,現如今最火的前端框架如:React, Vue,Angular等也都推薦採用SPA的模式進行開發並且從元件化,資料流,狀態容器再到網路請求,單頁路由等都給出了完善的全家桶方案。從某種意義上來說,SPA確實做到了前後端分離,但這種方式存在如下幾個亟待問題:
- 前端開發本地開發環境下該如何突破域的限制和服務端介面進行通訊?
- 一條命令,能否同時完成webpack和node server的啟動?
- 開發環境下的前端資源路徑應該如何配置?
- mock資料應該怎麼做?
- 打包構建後的檔案能否直接預覽效果?
針對以上的問題,我們來看看怎樣利用webpack現有的一些機制和藉助node的環境搭配來進行逐個擊破,具體設計見下圖:
由此可見,我們理想化的開發環境應根據具備以下幾點要求:
- 操作夠簡單,拉下程式碼後,只需要記住僅有的幾個命令就能直接進入開發狀態
- 解耦夠徹底,開發者只需要修改路由配置表就能無縫在多個請求介面中靈活切換
- 資源夠清晰,所有的開發資源都能到精確可控,同時支援一鍵打包構建,單頁和多頁模式可並存
- 配置夠靈活,可以根據自身專案的實際情況靈活新增各類中介軟體,擴充套件模組和第三方外掛
不得不提的webpack-dev-server
webpack本身的定位是一個資源管理和打包構建工作,本身的強大之處在於對各種靜態資源的依賴分析和預編譯,在實際開發中官方還推薦了一個快速讀取webpack配置的server環境webpack-dev-server,官方的介紹是:”Use webpack with a development server that provides live reloading. The webpack-dev-server is a little Node.js Express server, which uses the webpack-dev-middleware to serve a webpack bundle. It also has a little runtime which is connected to the server via Sock.js.”,一個適用於開發環境的,基於express + webpack-dev-middleware實現的,支援實時更新,記憶體構建資源的開發伺服器,通過簡單的配置即可滿足webpack開發環境中的一系列需求,但是當我們的開發環境日趨複雜和多樣的時候,不僅需要對自定義配置的細節靈活可控,同時需要對進行加入各種第三方的外掛進行功能擴充套件,才能最大程度的發揮webpack環境中的威力。
打造專案專屬的前端開發環境
有了理想環境下的的述求,也瞭解到了webpack-dev-server的實現精髓,那麼,我們就可以一步步地來打造專屬自身的開發環境:
一 、藉助node和http-proxy實現跨域通訊
前後端分離開發中,本地前端開發呼叫介面會有跨域問題,一般有以下幾種解決方法:
- 直接啟動服務端專案,再將專案中的資源url指向到前端服務中的靜態資源地址,好處在於因為始終在服務端的環境中進行資源除錯,不存在介面的跨域訪問問題,但是缺陷也比較明顯,需要同時啟動兩套環境,還需要藉助nginx,charles等工具進行資源地址的代理轉發,配置比較繁瑣,對開發者對網路的理解和環境配置要求較高,資源開銷也大;
- CORS跨域:後端介面在返回的時候,在header中加入’Access-Control-Allow-origin’:* 等配置,利用跨域資源共享實現跨域,前端部分只要求支援xhr2標準的瀏覽器,但是服務端在請求頭中需要在header中做響應頭配置,在一定程度上還是對服務端的介面設定有一定的依賴;
- http-proxy:用nodejs搭建本地http伺服器,並且判斷訪問介面URL時進行轉發,由於利用了http-proxy正向代理的模式進行了轉發,採用的是服務對服務的模式,能較為完美解決本地開發時候的跨域問題,也是本文中推薦的方式,配置如下:
1、搭建node和http-proxy環境
1 2 3 4 |
npm install express # express作為node基礎服務框架 npm install http-proxy-middleware # http-proxy的express中介軟體 npm install body-parser # bodyParser中介軟體用來解析http請求體 npm install querystring # querystring用來字串化物件或解析字串 |
工程專案下可以新建一個server的資料夾放置node資源,如下所示: > server
├── main.js ├── proxy.config.js ├── routes └── views
2、編寫代理配置指令碼:
proxy.config.js中可以配置對應需要代理的url和目標url,如下:
1 2 3 4 5 6 7 8 9 10 11 |
const proxy = [ { url: '/back_end/auth/*', target: 'http://10.2.0.1:8351' }, { url: '/back_end/*', target: 'http://10.2.0.1:8352' } ]; module.exports = proxy; |
main.js中的配置如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
const express = require('express') const bodyParser = require('body-parser') const proxy = require('http-proxy-middleware') const querystring = require('querystring') const app = express() // make http proxy middleware setting const createProxySetting = function (url) { return { target: url, changeOrigin: true, headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, onProxyReq: function (proxyReq, req) { if (req.method === 'POST' && req.body) { const bodyData = querystring.stringify(req.body) proxyReq.write(bodyData) } } } } // parse application/json app.use(bodyParser.json()) // parse application/x-www-form-urlencoded app.use(bodyParser.urlencoded({ extended: false })) // proxy proxyConfig.forEach(function (item) { app.use(item.url, proxy(createProxySetting(item.target))) }) // eg: http://127.0.0.1:3000/back_end/oppor => http://10.2.0.1:8352/back_end/oppor |
通過以上的配置我們就能輕鬆將指定url下的請求自動轉發到匹配成功的目標介面下。
NODE_ENV=development node ./bin/dev-server.js
isDebug: true [HPM] Proxy created: / -> http://10.2.0.1:8351 [HPM] Proxy created: / -> http://10.2.0.1:8352 Listening at 192.168.1.104:3000
webpack built d558389f7a9a453af17f in 2018ms Hash: d558389f7a9a453af17f Version: webpack 1.14.0 Time: 2018ms
二、將webpack配置和node server程式打通
1、解耦webpack中的配置
由於webpack在開發和生產環境中經常需要做各種配置的切換,官方也提供了DefinePlugin來進行環境引數設定,但是大量的判斷語句侵入webpack.config中其實會導致程式碼的可讀性和複用性變差,也容易造成程式碼冗餘,我們在此可以對配置檔案進行重構,將之前的webpack配置檔案拆解成了webpack.config.js,project.config.js和environments.config.js三個檔案,三個檔案各司其職,又可互相協作,減少維護成本,如下:
- environments.config.js: 主要的作用就是存放在特定環境下的需要變化的配置引數,包含有:publicpath, devtools, wanings,hash等
- project.config.js:主要的作用是存放於專案有關的基礎配置,如:server,output,loader,externals,plugin等基礎配置;通過一個overrides實現對environments中的配置資訊過載。
- webpack.config.js:主要是讀取project.config.js中的配置,再按標準的webpack欄位填入project中的配置資訊,原則上是該檔案的資訊只與構建工具有關,而與具體的專案工程無關,可以做到跨專案間複用。
> config ├── environments.config.js ├── project.config.js └── webpack.config.js
environments.config.js中的關鍵實現:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
// Here is where you can define configuration overrides based on the execution environment. // Supply a key to the default export matching the NODE_ENV that you wish to target, and // the base configuration will apply your overrides before exporting itself. module.exports = { // ====================================================== // Overrides when NODE_ENV === 'development' // ====================================================== development : (config) => ({ compiler_public_path : `http://${config.server_host}:${config.server_port}/` }), // ====================================================== // Overrides when NODE_ENV === 'production' // ====================================================== production : (config) => ({ compiler_base_route : '/apps/', compiler_public_path : '/static/', compiler_fail_on_warning : false, compiler_hash_type : 'chunkhash', compiler_devtool : false, compiler_stats : { chunks : true, chunkModules : true, colors : true } }) } |
project.config.js中的關鍵實現:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
// project.config.js const config = { env : process.env.NODE_ENV || 'development', // ---------------------------------- // Project Structure // ---------------------------------- path_base : path.resolve(__dirname, '..'), dir_client : 'src', dir_dist : 'dist', dir_public : 'public', dir_server : 'server', dir_test : 'tests', // ---------------------------------- // Server Configuration // ---------------------------------- server_host : ip.address(), // use string 'localhost' to prevent exposure on local network server_port : process.env.PORT || 3000, // ---------------------------------- // Compiler Configuration // ---------------------------------- compiler_devtool : 'source-map', compiler_hash_type : 'hash', compiler_fail_on_warning : false, compiler_quiet : false, compiler_public_path : '/', compiler_stats : { chunks : false, chunkModules : false, colors : true } }; // 在此通過讀取環境變數讀取environments中對應的配置項,對前面的配置項進行覆蓋 const environments = require('./environments.config') const overrides = environments[config.env] if (overrides) { debug('Found overrides, applying to default configuration.') Object.assign(config, overrides(config)) } else { debug('No environment overrides found, defaults will be used.') } module.exports = config |
webpack.config.js中的關鍵實現:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
const webpack = require('webpack') const project = require('./project.config') const debug = require('debug')('app:config:webpack') const UglifyJsParallelPlugin = require('webpack-uglify-parallel') const __DEV__ = project.globals.__DEV__ const __PROD__ = project.globals.__PROD__ const webpackConfig = { name : 'client', target : 'web', devtool : project.compiler_devtool, resolve : { modules: [project.paths.client(), 'node_modules'], extensions: ['.web.js', '.js', '.jsx', '.json'] }, module : {} } if (__DEV__) { debug('Enabling plugins for live development (HMR, NoErrors).') webpackConfig.plugins.push( new webpack.HotModuleReplacementPlugin() ) } else if (__PROD__) { debug('Enabling plugins for production (UglifyJS).') webpackConfig.plugins.push( new webpack.optimize.OccurrenceOrderPlugin(), new webpack.optimize.DedupePlugin(), new UglifyJsParallelPlugin({ workers: os.cpus().length, mangle: true, compressor: { warnings: false, drop_debugger: true, dead_code: true } }) ) } |
由此可知,三者間的注入關係如下:
> environments -> project -> webpack
2、整合webpack在開發環境中依賴的中介軟體
參考webapck-dev-server中的實現,我們可以將webpack-dev-middleware和webpack-hot-middleware加入到我們的express配置中,
1 2 |
npm install webpack-dev-middleware npm install webpack-hot-middleware |
具體配置如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
const express = require('express') const debug = require('debug')('app:server') const webpack = require('webpack') const webpackConfig = require('../config/webpack.config') const project = require('../config/project.config') const app = express() // ------------------------------------ // Apply Webpack HMR Middleware // ------------------------------------ if (project.env === 'development') { const compiler = webpack(webpackConfig) debug('Enabling webpack dev and HMR middleware') app.use(require('webpack-dev-middleware')(compiler, { publicPath : webpackConfig.output.publicPath, contentBase : project.paths.client(), hot : true, quiet : project.compiler_quiet, noInfo : project.compiler_quiet, lazy : false, stats : project.compiler_stats })) // webpack_hmr app.use(require('webpack-hot-middleware')(compiler, { path: '/__webpack_hmr' })) // proxy ....... } module.exports = app.listen(project.server_port, function (err) { if (err) { console.log(err) return } var uri = project.server_host + ':' + project.server_port console.log('Listening at ' + uri + 'n') }); |
這樣當我們執行下述的時候,就能一鍵完成webpack基礎配置,熱更新以及epxress服務的啟動,並且可以完全根據express的配置說明來自定義擴充套件我們的前端開發資源。
1 |
ENV=development node ./bin/dev-server.js |
三、前端資源路徑設計
實際開發中,所有涉及到的前端資源我們進行歸類一般會有如下幾種:
- html:html頁面,結合到服務後一般稱為模板資源,是所有資源的入口和結果呈現頁;
- js:javascript執行指令碼資源,基於現代Javascript框架開發後通常還需要藉助babel,typescript等進行編譯處理,分為build前後build後兩套程式碼;
- css:樣式資源,如果採用了less,sass等工具處理後會也會從.less和.sass編譯成.css檔案;
- static: 靜態資源,通常會包含有font,image,audio,video等靜態檔案,結合到服務框架中一般需要設定特定的訪問路徑,直接讀取檔案載入。
在wepback的配置中,前端資源路徑我們通常是藉助path和publicPath 對構建出來的前端資源進行索引,由於webpack採用了基於記憶體構建的方式,path通常用來用來存放打包後檔案的輸出目錄,publicPath則用來指定資原始檔引用的虛擬目錄,具體示例如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
module.exports = { entry: path.join(__dirname,"src","entry.js"), output: { /* webpack-dev-server環境下,path、publicPath、--content-base 區別與聯絡 path:指定編譯目錄而已(/build/js/),不能用於html中的js引用。 publicPath:虛擬目錄,自動指向path編譯目錄(/assets/ => /build/js/)。html中引用js檔案時,必須引用此虛擬路徑(但實際上引用的是記憶體中的檔案,既不是/build/js/也不是/assets/)。 --content-base:必須指向應用根目錄(即index.html所在目錄),與上面兩個配置項毫無關聯。 ================================================ 釋出至生產環境: 1.webpack進行編譯(當然是編譯到/build/js/) 2.把編譯目錄(/build/js/)下的檔案,全部複製到/assets/目錄下(注意:不是去修改index.html中引用bundle.js的路徑) */ path: path.join(__dirname,"build","js"), publicPath: "/assets/", //publicPath: "http://cdn.com/assets/",//你也可以加上完整的url,效果與上面一致(不需要修改index.html中引用bundle.js的路徑,但釋出生產環境時,需要使用外掛才能批量修改引用地址為cdn地址)。 filename: 'bundle.js' } }; |
有了如上的概念,我們就可以將path,publicpath和express中的配置結合起來,同時由於在開發環境中我們的資源入口通常又會按特定的目錄來進行檔案存放,如下圖所示:
> project
├── LICENSE ├── README.md ├── app.json ├── dist ├── bin ├── config ├── package.json ├── postcss.config.js ├── public ├── server ├── src └── yarn.lock
從中不難發現node server中需要配置的資源目錄往往會和webpack的工程目錄重疊,那麼我們就需要在express中進行相應的配置,才能實現資源的正確索引。
1、html模板資源讀取
html作為webpack中的templates,在express中則會變成views,讀取方式會發生變化,所以我們需要對資源進行如下配置:
1 |
npm install ejs #讓express支援html模板格式 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
const ejs = require('ejs') const app = express() // view engine, 預設可以指向dist app.set('views', project.paths.dist()) app.engine('.html', ejs.__express) app.set('view engine', 'html') // 通過配置讓express讀取webpack的記憶體打包資源下的template檔案 app.use('/home', function (req, res, next) { const filename = path.join(compiler.outputPath, 'index.html'') compiler.outputFileSystem.readFile(filename, (err, result) => { if (err) { return next(err) } res.set('content-type', 'text/html') res.send(result) res.end() }) }) //讓express所有的路由請求都落到index.html中,再有前端框架中的前端路由接管頁面的跳轉 app.use('*', function (req, res, next) { const filename = path.join(compiler.outputPath, 'index.html') compiler.outputFileSystem.readFile(filename, (err, result) => { if (err) { return next(err) } res.set('content-type', 'text/html') res.send(result) res.end() }) /*也可以指定到特定的views檔案下進行模板資源讀取*/ res.render('home.html', { name:'home.html' }) }) |
2、js和css資源讀取 js和css的引用地址在webpack的開發環境中通常會指向publicpath,所以在開發頁面中會直接如下嵌入如下地址,由於是採用絕對地址指向,所以無需做任何配置:
1 |
<link rel="stylesheet" href="http://127.0.0.1:3000/css/app.qxdfa323434adfc23314.css"/><script src="http://127.0.0.1:3000/js/app.ab92c02d96a1a7cd4919.js"/> |
3、靜態資源讀取 其他類似font,images等靜態讀取,我們可以將一個圖片放到工程結構中的public下,則訪問地址可以按如下書寫,支援真實路徑和虛擬路徑:
1 2 3 4 5 6 7 8 9 10 11 |
// 真實路徑,根目錄訪問:/demo.png -> /pulbic/demo.png app.use(express.static(project.paths.public())) // 真實路徑,子目錄訪問:/static/demo.png -> /pulbic/static/demo.png app.use(express.static(project.paths.public())) // 虛擬路徑,跟目錄訪問:/static/demo.png -> /pulbic/demo.png app.use('/static/', express.static(project.paths.public())) // 虛擬路徑,子目錄訪問:/static/img/demo.png -> /pulbic/img/demo.png app.use('/static/', express.static(project.paths.public())) |
通過以上配置,我們就可以在訪問開發地址( eg: localhost:3000 )時即可得到所需的全部前端資源。
四、mock資料模擬
作為前端經常需要模擬後臺資料,我們稱之為mock。通常的方式為自己搭建一個伺服器,返回我們想要的資料,既然我們已經將express整合到了我們的開發環境下,那麼實現一個mock就會非常簡單,以下介紹兩種mock資料的方式。
1、配置專屬的mock路由模組 我們可以在我們的server專案下的routes模組中加入一個mock模組,如下所示: > server ├── main.js ├── mock │ ├── opporList.json ├── routes │ ├── index.js │ └── mock.js └── views └── home.html
然後再在我們的server下的配置檔案中匯入mock模組配置:
1 2 3 4 5 6 |
// main.js const mock = require('./routes/mock') if (project.env === 'development') { // mock routes app.use('/mock, mock) } |
routes中的mock.js中寫入如下mock資料配置即可:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
const express = require('express') const router = express.Router() const opporList = require('../mock/opporList.json'); const Mock = require('mockjs'); // 直接讀取json檔案匯出 router.get('/backend/opporList', function (req, res) { res.json(opporList) }) // 基於mockjs生成資料, 優勢在於對專案程式碼無侵入,並且支援fetch,xhr等多種方式的攔截 router.get('/backend/employee', function (req, res) { var data = Mock.mock({ // 屬性 list 的值是一個陣列,其中含有 1 到 10 個元素 'list|1-10': [{ // 屬性 id 是一個自增數,起始值為 1,每次增 1 'id|+1': 1 }] }) res.json(data) }) module.exports = router |
配置完成後,訪問如下地址即可拿到mock資料:
再利用我們的proxy.config修改node-proxy配置,將測試自動轉到mock目標地址下:
1 2 3 4 5 6 7 |
const proxy = [ { url: '/backend/*', target: "http://127.0.0.1:3000/mock" } ] module.exports = proxy |
2、搭建獨立的mock服務 如果企業中有部署獨立的mock伺服器,如puer+mock:我們也可以通過修改簡單的proxy.config來直接實現需要mock的請求地址轉發,相對修改就比較簡單,如下:
1 2 3 4 5 6 7 8 |
const proxy = [ { url: '/backend/*', target: "http://10.4.31.11:8080/mock" } ] module.exports = proxy |
五、預覽打包後的資源效果
當我們開發完成後,wepback通過編譯可以得到我們需要的各種靜態資源,這類檔案通常是作為靜態資源存在,需要放到cdn或者部署到伺服器上才能訪問,但是我們通過簡單的配置也可以直接在本地環境下直接預覽打包後的資源效果,具體操作如下:
1. 找到構建資源生成目錄, 確認構建資源已存在:
dist ├── css │ ├── app.5f5af15a.css │ ├── login.7cb6ada6.css │ └── vendors.54895ec1.css ├── images │ ├── login_bg.8953d181.png │ ├── logo.01cf3dce.png │ └── wap_ico.e4e9be83.png ├── index.html ├── js │ ├── app.eb852be2.js │ ├── login.9a049514.js │ ├── manifest.c75a01fc.js │ └── vendors.20a872dc.js └── login.html
2. 修改express的文字配置資訊,加入構建完成後的靜態資源地址配置:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
app.set('views', project.paths.dist()) if (project.env === 'development') { .... } else { debug( 'Server is being run outside of live development mode' ) // 配置預覽環境下的proxy.config,一般可以指向測試環境地址 const proxyConfig = require('./proxy.test.config') const routes = require('./routes') proxyConfig.forEach(function (item) { app.use(item.url, proxy(createProxySetting(item.target))) }) // 修改靜態資源指向地址,可以直接配置到dist目錄下 app.use(project.compiler_public_path,express.static(project.paths.dist()) // 配置訪問路由url,並在設定置真實的template檔案地址,與webpack中的htmlplugin下的filename配置路徑保持一致,一般都在dist目錄下 app.use(project.compiler_base_route, routes) } |
3. 啟動預覽頁面,訪問:localhost:3000即可
1 |
NODE_ENV=production node ./bin/dev-server.js |
完整工程結構目錄結構參考
Project ├── LICENSE ├── README.md ├── app.json ├── bin │ ├── compile.js │ └── dev-server.js ├── config │ ├── environments.config.js │ ├── karma.config.js │ ├── npm-debug.log │ ├── project.config.js │ └── webpack.config.js ├── package.json ├── postcss.config.js ├── public │ ├── favicon.ico │ ├── humans.txt │ └── robots.txt ├── server │ ├── main.js │ ├── proxy.config.js │ ├── routes │ └── views ├── src │ ├── api │ ├── components │ ├── containers │ ├── index.html │ ├── layouts │ ├── main.js │ ├── routes │ ├── static │ ├── store │ └── until ├── tests │ ├── components │ ├── layouts │ ├── routes │ ├── store │ └── test-bundler.js └── yarn.lock
小結
將webpack的各類高階特性和node基礎服務有效相結合,按需打造專屬自身專案的開發平臺,不僅能將專案體系從簡單的頁面開發轉向工程化標準邁進,更能極大的改善前端開發的體驗,提升開發效率,有紕漏的地方也希望能多多指正。