webpack原理與實戰
4 人贊了文章
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體系你可以
- 先抽出基礎庫react react-dom redux react-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轉換成es5
- file-loader把文件替換成對應的URL
- raw-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-plugin
plugin應用場景廣泛,所以稍微複雜點。以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。閱讀原文推薦閱讀:
※webpack載入jQuery plugin或者其他legacy第三方庫
※webpack源碼學習系列之二:code-splitting(代碼切割)
※webpack plugin內部運行機制
※webpack持久化緩存優化策略
※webpack教程