webpack是一個js打包工具,不一個完整的前端構建工具。它的流行得益於模塊化和單頁應用的流行。webpack提供擴展機制,在龐大的社區支持下各種場景基本它都可找到解決方案。本文的目的是教會你用webpack解決實戰中常見的問題。
webpack原理
在深入實戰前先要知道webpack的運行原理
webpack核心概念
entry一個可執行模塊或庫的入口文件。chunk多個文件組成的一個代碼塊,例如把一個可執行模塊和它所有依賴的模塊組合和一個chunk這體現了webpack的打包機制。loader文件轉換器,例如把es6轉換為es5,scss轉換為css。plugin插件,用於擴展webpack的功能,在webpack構建生命周期的節點上加入擴展hook為webpack加入功能。
webpack構建流程
從啟動webpack構建到輸出結果經歷了一系列過程,它們是:
- 解析webpack配置參數,合並從shell傳入和
webpack.config.js文件里配置的參數,生產最后的配置結果。 - 注冊所有配置的插件,好讓插件監聽webpack構建生命周期的事件節點,以做出對應的反應。
- 從配置的
entry入口文件開始解析文件構建AST語法樹,找出每個文件所依賴的文件,遞歸下去。 - 在解析文件遞歸的過程中根據文件類型和loader配置找出合適的loader用來對文件進行轉換。
- 遞歸完后得到每個文件的最終結果,根據
entry配置生成代碼塊chunk。 - 輸出所有
chunk到文件系統。
需要注意的是,在構建生命周期中有一系列插件在合適的時機做了合適的事情,比如UglifyJsPlugin會在loader轉換遞歸完后對結果再使用UglifyJs壓縮覆蓋之前的結果。
場景和方案
通過各種場景和對應的解決方案讓你深入掌握webpack
單頁應用
demo redemo
一個單頁應用需要配置一個entry指明執行入口,webpack會為entry生成一個包含這個入口所有依賴文件的chunk,但要讓它在瀏覽器里跑起來還需要一個HTML文件來加載chunk生成的js文件,如果提取出了css還需要讓HTML文件引入提取出的css。web-webpack-plugin里的WebPlugin可以自動的完成這些工作。
webpack配置文件
const { WebPlugin } = require('web-webpack-plugin'); module.exports = { entry: { app: './src/doc/index.js', }, plugins: [ // 一個WebPlugin對應生成一個html文件 new WebPlugin({ //輸出的html文件名稱 filename: 'index.html', //這個html依賴的`entry` requires: ['app'], }), ], };
requires: ['doc']指明這個HTML依賴哪些entry,entry生成的js和css會自動注入到HTML里。
你還可以配置這些資源的注入方式,支持如下屬性:
_dist只有在生產環境下才引入該資源_dev只有在開發環境下才引入該資源_inline把該資源的內容潛入到html里_ie只有IE瀏覽器才需要引入的資源
要設置這些屬性可以通過在js里配置
new WebPlugin({ filename: 'index.html', requires: { app:{ _dist:true, _inline:false, } }, }),
或者在模版里設置,使用模版的好處是靈活的控制資源注入點。
new WebPlugin({ filename: 'index.html', template: './template.html', }),
<!DOCTYPE html>
<html lang="zh-cn"> <head> <link rel="stylesheet" href="app?_inline"> <script src="ie-polyfill?_ie"></script> </head> <body> <div id="react-body"></div> <script src="app"></script> </body> </html>
WebPlugin插件借鑒了fis3的思想,補足了webpack缺失的以HTML為入口的功能。想了解WebPlugin的更多功能,見文檔。
一個項目里管理多個單頁應用
一般項目里會包含多個單頁應用,雖然多個單頁應用也可以合並成一個但是這樣做會導致用戶沒訪問的部分也加載了。如果項目里有很多個單頁應用,為每個單頁應用配置一個entry和WebPlugin?如果項目又新增了一個單頁應用,又去新增webpack配置?這樣做太麻煩了,web-webpack-plugin里的AutoWebPlugin可以方便的解決這些問題。
module.exports = { plugins: [ // 所有頁面的入口目錄 new AutoWebPlugin('./src/'), ] };
AutoWebPlugin會把./src/目錄下所有每個文件夾作為一個單頁頁面的入口,自動為所有的頁面入口配置一個WebPlugin輸出對應的html。要新增一個頁面就在./src/下新建一個文件夾包含這個單頁應用所依賴的代碼,AutoWebPlugin自動生成一個名叫文件夾名稱的html文件。AutoWebPlugin的更多功能見文檔。
代碼分割優化
一個好的代碼分割對瀏覽器首屏效果提升很大。比如對於最常見的react體系你可以
- 先抽出基礎庫
reactreact-domreduxreact-redux到一個單獨的文件而不是和其它文件放在一起打包為一個文件,這樣做的好處是只要你不升級他們的版本這個文件永遠不會被刷新。如果你把這些基礎庫和業務代碼打包在一個文件里每次改動業務代碼都會導致文件hash值變化從而導致緩存失效瀏覽器重復下載這些包含基礎庫的代碼。以上的配置為:
// vender.js 文件抽離基礎庫到單獨的一個文件里防止跟隨業務代碼被刷新 // 所有頁面都依賴的第三方庫 // react基礎 import 'react'; import 'react-dom'; import 'react-redux'; // redux基礎 import 'redux'; import 'redux-thunk';
// webpack配置 { entry: { vendor: './path/to/vendor.js', }, }
- 再通過CommonsChunkPlugin可以提取出多個代碼塊都依賴的代碼形成一個單獨的
chunk。在應用有多個頁面的場景下提取出所有頁面公共的代碼減少單個頁面的代碼,在不同頁面之間切換時所有頁面公共的代碼之前被加載過而不必重新加載。
構建npm包
demo remd
除了構建可運行的web應用,webpack也可用來構建發布到npm上去的給別人調用的js庫。
const nodeExternals = require('webpack-node-externals'); module.exports = { entry: { index: './src/index.js', }, externals: [nodeExternals()], target: 'node', output: { path: path.resolve(__dirname, '.npm'), filename: '[name].js', libraryTarget: 'commonjs2', }, };
這里有幾個區別於web應用不同的地方:
externals: [nodeExternals()]用於排除node_modules目錄下的代碼被打包進去,因為放在node_modules目錄下的代碼應該通過npm安裝。libraryTarget: 'commonjs2'指出entry是一個可供別人調用的庫而不是可執行的,輸出的js文件按照commonjs規范。
構建服務端渲染
服務端渲染的代碼要運行在nodejs環境,和瀏覽器不同的是,服務端渲染代碼需要采用commonjs規范同時不應該包含除js之外的文件比如css。webpack配置如下:
module.exports = { target: 'node', entry: { 'server_render': './src/server_render', }, output: { filename: './dist/server/[name].js', libraryTarget: 'commonjs2', }, module: { rules: [ { test: /\.js$/, loader: 'babel-loader', }, { test: /\.(scss|css|pdf)$/, loader: 'ignore-loader', }, ] }, };
其中幾個關鍵的地方在於:
target: 'node'指明構建出的代碼是要運行在node環境里libraryTarget: 'commonjs2'指明輸出的代碼要是commonjs規范{test: /\.(scss|css|pdf)$/,loader: 'ignore-loader'}是為了防止不能在node里執行服務端渲染也用不上的文件被打包進去。
從fis3遷移到webpack
fis3和webpack有相似的地方也有不同的地方。相似在於他們都采用commonjs規范,不同在於導入css這些非js資源的方式。fis3通過// @require './index.scss'而webpack通過require('./index.scss')。如果想從fis3平滑遷移到webpack可以使用comment-require-loader。比如你想在webpack構建是使用采用了fis3方式的imui模塊,配置如下:
loaders:[{
test: /\.js$/, loaders: ['comment-require-loader'], include: [path.resolve(__dirname, 'node_modules/imui'),] }]
自定義webpack擴展
如果你在社區找不到你的應用場景的解決方案,那就需要自己動手了寫loader或者plugin了。
在你編寫自定義webpack擴展前你需要想明白到底是要做一個loader還是plugin呢?可以這樣判斷:
如果你的擴展是想對一個個單獨的文件進行轉換那么就編寫
loader剩下的都是plugin。
其中對文件進行轉換可以是像:
babel-loader把es6轉換成es5file-loader把文件替換成對應的URLraw-loader注入文本文件內容到代碼里去
編寫 webpack loader
demo comment-require-loader
編寫loader非常簡單,以comment-require-loader為例:
module.exports = function (content) { return replace(content); };
loader的入口需要導出一個函數,這個函數要干的事情就是轉換一個文件的內容。
函數接收的參數content是一個文件在轉換前的字符串形式內容,需要返回一個新的字符串形式內容作為轉換后的結果,所有通過模塊化倒入的文件都會經過loader。從這里可以看出loader只能處理一個個單獨的文件而不能處理代碼塊。想編寫更復雜的loader可參考官方文檔
編寫 webpack plugin
demo end-webpack-pluginplugin應用場景廣泛,所以稍微復雜點。以end-webpack-plugin為例:
class EndWebpackPlugin { constructor(doneCallback, failCallback) { this.doneCallback = doneCallback; this.failCallback = failCallback; } apply(compiler) { // 監聽webpack生命周期里的事件,做相應的處理 compiler.plugin('done', (stats) => { this.doneCallback(stats); }); compiler.plugin('failed', (err) => { this.failCallback(err); }); } } module.exports = EndWebpackPlugin;
loader的入口需要導出一個class, 在new EndWebpackPlugin()的時候通過構造函數傳入這個插件需要的參數,在webpack啟動的時候會先實例化plugin再調用plugin的apply方法,插件需要在apply函數里監聽webpack生命周期里的事件,做相應的處理。
webpack plugin 里有2個核心概念:
Compiler: 從webpack啟動到推出只存在一個Compiler,Compiler存放着webpack配置Compilation: 由於webpack的監聽文件變化自動編譯機制,Compilation代表一次編譯。
Compiler 和 Compilation 都會廣播一系列事件。
webpack生命周期里有非常多的事件可以在event-hooks和Compilation里查到。以上只是一個最簡單的demo,更復雜的可以查看 how to write a plugin或參考web-webpack-plugin。
總結
webpack其實很簡單,可以用一句話涵蓋它的本質:
webpack是一個打包模塊化js的工具,可以通過loader轉換文件,通過plugin擴展功能。
如果webpack讓你感到復雜,一定是各種loader和plugin的原因。
希望本文能讓你明白webpack的原理與本質讓你可以在實戰中靈活應用webpack。
