前言 作者去年就開始使用webpack, 最早的接觸就來自於vue-cli。那個時候工作重點主要也是 vue 的使用,對webpack的配置是知之甚少,期間有問題也是詢問大牛 @呂大豹。順便說一句,對於前端知識體系迷茫的童鞋可以關註豹哥的微信公眾號,《大豹雜說》。豹哥對於剛開始小白的自己(雖然現在也 ...
前言
作者去年就開始使用webpack, 最早的接觸就來自於vue-cli。那個時候工作重點主要也是 vue 的使用,對webpack的配置是知之甚少,期間有問題也是詢問大牛 @呂大豹。順便說一句,對於前端知識體系迷茫的童鞋可以關註豹哥的微信公眾號,《大豹雜說》。豹哥對於剛開始小白的自己(雖然現在也白)知無不談,而且回覆超快超認真。這裡真的很感謝豹哥。前段時間工作不忙,自己就啃了啃webpack的官方文檔,畢竟知識還是在自己腦袋裡踏實。然後根據vue-cli的配置文件豐富了一點新的東西,發佈出來大家共用,同時自己也有點疑問,也歡迎各位評論給小子指正。## webpack的學習在前端領域我們總要面對各種的新框架新工具,那麼怎麼有效快速的學習掌握一門技能呢?作者的方法是實踐是最好的老師,建議新東西瞭解一些核心的API啊功能啊立刻就上手使用,這個過程肯定會出現各種問題,在尋求解決問題的途徑中逐漸也就加深了理解,帶著問題學習總歸會事半功倍。拿webpack來講,瞭解他的一些核心概念,配置文件的入口輸出解析loader,plugin等等就可以簡單使用了。這裡建議一點,學習新知識的時候建議大家最終還是從官網啊官方文檔中學習,英文真的不是事,得試試才知道自己能看懂的。看博客主要都是別人消化之後的東西,再有基礎之上再看這些文章當然能起到查漏補缺的功效,但是一開始就看,就很容易受到作者思路局限的影響。
當然這些都是自己的建議啊。所以本篇文章面對的是對webpack有一些簡單使用的朋友,大家分享經驗而已,如果對webpack還沒開始使用的朋友,建議還是先瞭解一下webpack的核心知識。官網有中文版,翻譯的也很好。
webpack環境的區別
webpack本質就是一個打包工具,是一種模塊化開發的實現,它與gulp與grunt這一類的自動化構建工具不同,構建工具是優化我們自己的工作流程,將眾多的手工方式改為自動化,比如壓縮js、css,編譯scss,less。當然webpack的loader與plugin也可以完成這些工作,工具使用看個人公司需求。webpack的主要工作是將我們我編寫的模塊化的文件打包編譯為瀏覽器所能辨識的方式。
直白來講,開發環境,就是你的代碼在本地伺服器上在測試、更改、運行,生產環境你的代碼就是已經開始在真實伺服器中使用。webpack 可以適用於開發環境主要是運用了node.js 搭建一個本地服務。記得去年我剛開始想需要一個本地服務的時候開始是使用Hbuilder,後來單獨用了一個小工具名字好像叫webservice。
package.json
前面提到了nodejs,node.js是一個javascript運行的平臺而不是什麼js的框架,它實現的是js不僅可以開發客戶端瀏覽器也可以開發服務端。現在的前端項目中都會發現一個package.json
{ "name": "webpack_environment", "version": "1.0.0", "description": "A webpack environment test", "author": "abzerolee", "scripts": { "dev": "node build/dev-server.js", "build": "node build/build.js" }, "dependencies": { "nimble": "^0.0.2" }, "devDependencies": { "autoprefixer": "^7.1.2", "babel-core": "^6.22.1", "babel-loader": "^7.1.1", "babel-preset-stage-2": "^6.22.0", "chalk": "^2.0.1", "clean-webpack-plugin": "^0.1.16", "connect-history-api-fallback": "^1.3.0", "css-loader": "^0.28.0", "eventsource-polyfill": "^0.9.6", "express": "^4.14.1", "extract-text-webpack-plugin": "^3.0.0", "file-loader": "^0.11.1", "glob": "^7.1.2", "html-webpack-plugin": "^2.28.0", "http-proxy-middleware": "^0.17.3", "less": "^2.7.2", "less-loader": "^4.0.5", "mockjs": "^1.0.1-beta3", "opn": "^5.1.0", "ora": "^1.3.0", "postcss-loader": "^2.0.6", "rimraf": "^2.6.1", "style-loader": "^0.18.2", "url-loader": "^0.5.8", "webpack": "^3.1.0", "webpack-dev-middleware": "^1.10.0", "webpack-hot-middleware": "^2.18.0", "webpack-merge": "^4.1.0" } }
這個文件可以用npm 模塊管理器生成的,它描述了一個項目的各種信息,註意到script這個屬性他對應的dev build就是開發環境與生產環境了,我們運行命令的話是用 ‘npm run dev’或‘npm run build’其實執行的就是對應的node編譯。可以發現這個配置文件告訴我們開發環境與生產環境的入口文件/build/dev-server.js,/build/build.js。剩下的dependencies / devDependencies則代表兩種環境對應的依賴需要。
目錄結構
先介紹/node_modules 我們使用npm install 就是通過package.json中的依賴配置對應安裝你需要的一些庫,可以發先我在生產環境需要的是nimble。那麼這些庫存放的地方就是在/node_moudles中。當然你也可以用曾經古老的方法新建一個/lib 然後去官網下載對應js文件,再放入/lib。但是這樣對於整個項目的管理並不十分友好,我們查看項目的依賴庫只需要查看package.json就夠了 而不是去html頁面一個個找<script>標簽。
接下來介紹一系列的.[文件名]這樣的配置文件。.[文件名]都是一些你安裝的依賴工具的配置文件,比如Babel的.babelrc postcss的.postcssrc ,最後就是一些[文件名].md的文件,md擴展名指的markdown 標記語言編寫的文檔。比如README.md 介紹的一般是項目的內容簡介一些API的使用方法等等。
/build 是項目啟動時的一些文件,如 webpack 的配置文件 開發環境服務配置文件 一些簡單工具函數/utils.js等等。這裡自己也有個問題就是關於dev-client.js的配置,dev-client是模塊熱載入的一個模塊,應該就是當項目在開發環境運行之後命令行中新開的那個窗體的配置。不知道理解的對不對。當然我現在沒用這個,項目跑起來也是可以的。
/config 是關於整個項目的環境配置包括開發與生產。我們在node引入模塊的時候可以直接引入目錄,
require('./config');
他預設查找的就是該目錄下的index.js文件。當然也可以不叫index.js這個需要一個/config目錄下再去寫一個package.json指定文件。
/dist與/src /dist目錄下是將/src 目錄下的源碼編譯之後生成的文件。一般項目部署就直接可以將/dist目錄下的文件放在網站的根目錄。/dist就對應生產環境的文件,/src對應開發環境的文件。
/mock 是前臺開發的模擬數據介面的文件,裡面就是一些後臺介面的模擬數據
var Mock = require('mockjs'); var User = { login: { code: 0, info: null, msg: '登錄成功!' }, getVerifyCode: { code: 0, info: Mock.mock('@string("lower", 4)'), msg: '操作成功' } }; module.exports = User;
這裡使用了mock.js 來生成模擬數據,用CommonJS規範中module.exports來暴露出數據。對於AMD,CMD,CommonJS這幾種模塊規範,大家還是應該有適當的理解,為什麼要有模塊,模塊的工作方式有什麼。當然這是一種規避跨域問題的模擬測試,項目中也通過http-proxy-middleware的方式解決跨域問題。但是如果後臺的進度慢於前臺的情況下,這種mock也是一種良好的開發方式。
開發環境 dev-server.js
作者最開始學習webpack的時候,也是從把a.js與b.js引入main.js最後打包生成bundle.js開始的。那個時候對node.js也是一知半解,當然現在瞭解的更多了,並不代表精通。總會好奇一個點就是 剛開始編譯的時候是使用
webpack -config webpack.conf.js
後面怎麼開始用node編譯了。其實這是webpack提供了一個Node.js API,可以直接在Node.js運行時使用。這也就是為什麼入口文件從webpack.conf.js變成了dev-server.js|build.js的原因。使用node編譯的好處是可以更好的利用一下node的特性 讀取文件,模擬API介面等等。
var config = require('../config'); if(!process.env.ENV) { process.env.ENV = config.dev.ENV; } var utils = require('./utils'); var opn = require('opn'); var path = require('path'); var fs = require('fs'); var express = require('express'); var webpack = require('webpack'); var proxyMiddleware = require('http-proxy-middleware'); var webpackConfig = require('./webpack.dev.conf'); var port = process.env.PORT || config.dev.port; var autoOpenBrowser = config.dev.autoOpenBrowser; var proxyTable = config.dev.proxyTable; var app = express() var compiler = webpack(webpackConfig); var apiRouter = express.Router(); var apis = fs.readdirSync(utils.resolve('/mock')); var apiClass = apis.map(it => it.replace(/\.js$/, '')); apiRouter.route('/:apiClass/:apiName').all(function(req, res) { var params = req.params; var apiIndex = apiClass.indexOf(params.apiClass) var err = {code: 99,info: null, msg: 'no such api'} if(apis.length < 1 || apiIndex === -1) return res.json(err); var klass = require('../mock/'+ apis[apiIndex]); if(klass[params.apiName]){ res.json(klass[params.apiName]); }else{ res.json(err); } }) app.use('/api', apiRouter); var devMiddleware = require('webpack-dev-middleware')(compiler, { publicPath: webpackConfig.output.publicPath, quiet: true }); var hotMiddleware = require('webpack-hot-middleware')(compiler, { log: () => {}, heartbeat: 2000 }) compiler.plugin('compilation', function (compilation) { compilation.plugin('html-webpack-plugin-after-emit', function (data, cb) { hotMiddleware.publish({ action: 'reload' }) cb() }) }) Object.keys(proxyTable).forEach(function (context) { var options = proxyTable[context] if (typeof options === 'string') { options = { target: options } } app.use(proxyMiddleware(options.filter || context, options)) }); // app.use(require('connect-history-api-fallback')()) app.use(devMiddleware) app.use(hotMiddleware) var staticPath = path.posix.join(config.dev.assetsPublicPath, config.dev.assetsSubDirectory) app.use(staticPath, express.static('./static')); var uri = 'http://localhost:'+ port; var _resolve; var readyPromise = new Promise(resolve => { _resolve = resolve }) console.log('> Starting Server...'); devMiddleware.waitUntilValid(() => { console.log('> Listening at ' + uri + '\n') // when env is testing, don't need open it if (autoOpenBrowser && process.env.ENV !== 'testing') { opn(uri) } _resolve() }) var server = app.listen(port); module.exports = { ready: readyPromise, close: () => { server.close() } }
上面的代碼用過vue-cli的朋友應該很熟悉。對於vue-cli的介紹大家可以自己去官網查看。這裡推薦一個對配置文件逐句註釋的[文章](https://github.com/DDFE/DDFE-blog/issues/10),細微之處還是有差異的,但是大體不離。
我們儘量用直白的語言來分析一下這個文件,
1. 程式開始運行,引入環境的配置文件/config 這裡前文提到為什麼可以省略index.js。然後判斷process.env表示的用戶環境變數 ENV 為何種環境,官網翻譯進程對象process是一個全局的,它提供有關當前Node.js進程的信息和控制。這個環境變數我們可以在命令行中啟動程式時輸入,當node無法判斷環境時我們手動的設置為開發環境的變數,在/config/index.js config.dev.ENV <=> 'dev'。然後引入我們需要的庫和文件,比如工具函數庫utils 自啟動瀏覽器opn(服務啟動後自動打開瀏覽器) 文件系統fs nodejs框架express(用來啟動本地伺服器,部署靜態服務,模擬路由介面)。
2. 引入庫之後便是定義我們的整個項目服務app,通過webpack的nodeAPI編譯開發環境的配置文件,定義webpack提供的服務的中間件webpack-dev-middleware,將編譯內容寫入記憶體中,啟用熱載入的中間件,html模板template更新則強制刷新頁面,以及配置跨域代理請求的中間件。中間件的概念其實就是工作流的思想,記得有一個例子很直白
可樂的生成:水 -> 凈化 -> 調配 -> 裝瓶 -> 質檢 -> 飲用可樂,水到可樂,每一個中間過程都認為是一個中間件
3. 通過express.Router()來定義介面,所有本地請求的/api開頭的url都解析之後的/api/:apiClass/:apiName,apiName對應/mock文件下的js文件名,apiName對應js文件暴露出的對象的屬性也就是數據。。
4. 這裡因為配置了mock的原因我就去除了connect-history-api-fallback,它的作用因為找不到介面的話指定一個頁面重定向,如果介面API找不到它就會預設定向到index.html。接下來是拼接/static文件路徑,我的靜態資源都是放在assets目錄下就就刪除了該文件夾。(對這點我也存有疑問就是vue-cli的這個/static文件夾到底是指哪些靜態資源?)。之後是服務啟動,監聽埠打開瀏覽器。
到這裡,我們就可以通過對src的源碼進行修改開發了。
生產環境 build.js
process.env.ENV = 'prod'; var ora = require('ora'); var path = require('path') var chalk = require('chalk') var webpack = require('webpack') var config = require('../config') var webpackConfig = require('./webpack.prod.conf') var spinner = ora('building for production...'); spinner.start() webpack(webpackConfig, function (err, stats) { spinner.stop() if (err) throw err process.stdout.write(stats.toString({ colors: true, modules: false, children: false, chunks: true, chunkModules: false }) + '\n\n') console.log(chalk.cyan(' Build complete.\n')) console.log(chalk.yellow( ' Tip: built files are meant to be served over an HTTP server.\n' + ' Opening index.html over file:// won\'t work.\n' )) })
編譯打包功能就不需要配置服務了,當然打包的時候需要一下提示,進度,就需要ora chalk這些模塊了。打包這裡和vue-cli不太一樣得是我沒有使用rmrf 而是用了一個插件CleanWebpackPlugin來清空/dist目錄下的文件。當然也可以只清空某個文件而不是整個目錄。
配置文件
/config/index.js && /build/utils.js
1. /config/index.js主要暴露了兩個對象一個屬性
var path = require('path'); module.exports = { // 項目根目錄 _root_: path.resolve(__dirname, '../'), // 生產環境設置 build: { ENV: 'prod', index: path.resolve(__dirname, '../dist/index.html'), // 編譯完成首頁 assestsRoot: path.resolve(__dirname, '../dist'), // 靜態根目錄 assetsSubDirectory: 'static', assetsPublicPath: '', prodSourceMap: false, productionGzip: false, productionGzipExtensions: ['js', 'css'] }, // 開發環境配置 dev: { ENV: 'dev', port: '3000', autoOpenBrowser: false, assetsSubDirectory: 'static', assetsPublicPath: '/', cssSourceMap: false, proxyTable: { // '/api': { // target: 'http://localhost:3100', // changeOrigin: true // } } } }
這裡註意的一個點就是build.assetsPublicPath <=> 編譯發佈的根目錄,可配置為資源伺服器功能變數名稱或 CDN 功能變數名稱,那麼很多朋友vue編譯完本地File://打不開就是因為這裡配置的是'/'指的是伺服器的根目錄,部署到伺服器上是沒有問題的,如果你要本地打開,設為空字元串即可。
第二個需要註意的就是dev.proxyTable的介面屬性,如我的配置其實就是跨域請求'http://localhost:3100/api'註意介面名的對應。
2. utils是在編寫配置文件時你需要的一些函數,比如vue-cli中關於樣式的loader都是在這裡配置的
var path = require('path'); var config = require('../config'); var ExtractTextPlugin = require('extract-text-webpack-plugin'); var glob = require('glob'); exports.assetsPath = function(_path) { var assetsSubDirectory = process.env.ENV === 'prod' ? config.build.assetsSubDirectory : config.build.assetsSubDirectory; return path.posix.join(assetsSubDirectory, _path) } exports.resolve = function(dir) { return path.join(__dirname, '..', dir); } exports.cssLoaders = function(options) { var cssLoader = { loader: 'css-loader', options: { minmize: process.env.ENV === 'prod', sourceMap: options.sourceMap } } function generLoaders(loader, loaderOptions) { var loaders = [cssLoader, ]; if(loader) { loaders.push({ loader: loader +'-loader', options: Object.assign({}, loaderOptions, { sourceMap: options.sourceMap }) }) } if(options.extract) { return ExtractTextPlugin.extract({ use: loaders, fallback: 'style-loader', }) }else { return ['style-loader'].concat(loaders) } } return { css: generLoaders(), postcss: generLoaders(), less: generLoaders('less'), sass: generLoaders('sass', {indentedSyntax: true}), scss: generLoaders('sass') } } exports.styleLoader = function(option) { var output = []; var loaders = exports.cssLoaders(option); for(var extension in loaders){ output.push({ test: new RegExp('\\.'+ extension +'$'), use: loaders[extension] }) } return output } exports.getEntries = function(_path) { var entries = {}; glob.sync(_path).forEach(function(entry) { var basename = path.basename(entry, path.extname(entry)); var pathname = entry.split('/').splice(-3).splice(0, 1) +'/'+ basename; entries[basename] = entry; }); return entries; }
1. assetsPath(_path)是返回靜態資源_path的全路徑,
2. resolve(dir)是返回dir的絕對路徑,為什麼會單獨寫resolve主要是webpack的配置文件不在項目根目錄而是在/build下。
3. getEntries(_path) 是通過glob(路徑模式匹配模塊)匹配多頁面入口文件的函數,最終返回一個入口對象,在這裡網上很多其他得例子都是
{ 'module/index': ... 'module/user': ... }
這導致開發環境下需要在url去添加http://localhost:3000/module/index.html才能查看文件,生產環境編譯之後的文件也是在/dist/module/index.html 這裡直接將basename 作為屬性名則會解決。
4. styleLoader() 返回一個webpack配置文件中moudle.rules對應的數組,內部調用cssLoader(來生成對應的sass、less載入編譯) 這裡不太明白的朋友建議可以在vscode下斷點調試一下,看他每次生成對象對應的一些配置。
webpack.*.conf.js
webpack的配置文件各種各樣,這是因為他高度自定義決定的,你可以配置任何你想要的loader plugin來完成你的工作。像vue-cli便是定義了一個基礎的base配置,之後區分開發與生產需要的不同插件,都是代碼復用。base.conf中應該註意的是多入口與單入口的配置
... var entries = utils.getEntries('./src/modules/**/*.js'); module.exports = { // entry: { // app: utils.resolve('/src/main.js'), // }, entry: entries, output: { path: config.build.assestsRoot, filename: '[name].js', publicPath: process.env.ENV === 'prod' ? config.build.assetsPublicPath : config.dev.assetsPublicPath, }, ...
dev.conf的配置
module.exports = merge(baseWebpackConfig, { module: { rules: utils.styleLoader({ sourceMap: config.dev.cssSourceMap }) }, plugins: [ new webpack.DefinePlugin({ 'process.env': config.dev.ENV, dev_port: '"http://localhost:3000/api"' }), new webpack.HotModuleReplacementPlugin(), // spa 則應用如下配置 // new HtmlWebpackPlugin({ // title: 'Single-Page'+pathname, // filename: 'index.html', // template: utils.resolve('/src/index.html'), // inject: true // }) ] }) // 多頁面應用配置 根據modules 動態生成html var pages = utils.getEntries('./src/modules/**/*.html'); for(var pathname in pages){ var conf = { filename: pathname +'.html', template: pages[pathname], chunks: [pathname], inject: true } module.exports.plugins.push(new HtmlWebpackPlugin(conf)) }
該配置只使用了三個插件 DefinePlugin這個插件可以用來定義全局變數,在編譯時將你的引用的dev_port 轉換為 "http://locahost:3000/api" 要註意的是他轉化的是值,比如 dev_port <=> 'b' 那麼你在編寫代碼時 引用了dev_port實際上他是將變數名替換為b而不是'b'字元串,可以看如下報錯,所以要使用字元串時需要外層包裹單引號。
// dev.conf ... new webpack.DefinePlugin({ 'process.env': config.dev.ENV, dev_port: 'b' }), ... // /src/modules/index.js ... console.log(dev_port); ...
HotModuleReplacementPlugint插件在頁面進行變更的時候只會重繪對應的頁面模塊,不會重繪整個html文件。
HtmlWebpackPlugin有幾個頁面則對應生成幾個配置。
prod.conf
與dev.conf類似的有,
DefinePlugin 但是這個時候要把dev_port切換後臺介面所在伺服器的功能變數名稱。這樣不用每次編譯前再去修改 當然叫host可能更準確(忽略我的瞎起名字)。HtmlWebpackPlugin就是一些生成html文件是否壓縮是否去除屬性引用的配置。
不同之處有配置了CommonsChunkPlugin提取公共模塊,(要註意minChunks最少引用次數的配置),ExtractTextPlugin提取CSS文件 而不是style標簽插入html。
結語
啃文檔啃了一個星期多,邊啃邊練一個星期,構思寫作三天,起碼現在對weback的配置再恐懼了,文章有點過長能看到這的朋友首先謝謝你的閱讀,源碼在[github](https://github.com/abzerolee/webpack_env) 這個環境也是當時用來打包一個以前用jquery的項目的所以沒有配框架vue react之類的。過段時間啃完了create-react-app 的實現應該還會出一期關於 webpack 原理的學習筆記。還希望繼續關註。文中如有一些問題也希望大家及時指正。