隨著移動裝置的升級、網路速度的提高,使用者對於web應用的要求越來越高,web應用要提供的功能越來越。功能的增加導致的最直觀的後果就是資原始檔越來越大。為了維護越來越龐大的客戶端程式碼,提出了模組化的概念來組織程式碼。webpack作為一種模組化打包工具,隨著react的流行也越來越流行。
webpack在官方文件上解釋為什麼又做一個模組打包工具的時候,是這樣說的:
The most pressing reason for developing another module bundler was Code Splitting and that static assets should fit seamlessly together through modularization.
開發一個新的模組打包工具最重要的原因就是Code Splitting,並且還要保證靜態資源也可以無縫整合到模組化中。其中Code Splitting是webpack提供的一個重要功能,通過這個功能可以實現按需載入,減少首次載入時間。
Code Splitting
翻譯一下官方文件對於Code Splitting的介紹:
對於大型的web 應用而言,把所有的程式碼放到一個檔案的做法效率很差,特別是在載入了一些只有在特定環境下才會使用到的阻塞的程式碼的時候。Webpack有個功能會把你的程式碼分離成Chunk,後者可以按需載入。這個功能就是Code Spliiting
Code Spliting的具體做法就是一個分離點,在分離點中依賴的模組會被打包到一起,可以非同步載入。一個分離點會產生一個打包檔案。
例如下面使用CommonJS風格的require.ensure作為分離點的程式碼:
1 2 3 4 5 6 7 8 |
// 第一個引數是依賴列表,webpack會載入模組,但不會執行 // 第二個引數是一個回撥,在其中可以使用require載入模組 // 下面的程式碼會把module-a,module-b,module-c打包一個檔案中,雖然module-c沒有在依賴列表裡,但是在回撥裡呼叫了,一樣會被打包進來 require.ensure(["module-a", "module-b"], function(require) { var a = require("module-a"); var b = require("module-b"); var c = require('module-c'); }); |
除了這樣的寫法,還可以在配置檔案中使用CommonChunkPlugin合併檔案
問題
現在進入正題,本文不會針對React或者Vue做示例,因為這兩個框架有很成熟的按需載入方案。
下面這個例子用Backbone Router做路由,但是其中提到的按需載入方式可以用到大多數路由系統中。
假設應用有三個路由:
- 主頁
- 關於
- 支付
開始時的程式碼(index.js):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
import Backbone from 'backbone' import $ from 'jquery' var Route = export default Backbone.Router.extend({ routes: { '': 'home', 'about': 'about', 'pay': 'pay' }, home() { $('#app').html('it is home'); }, about() { $('#app').html('it is about'); }, pay() { $('#app').html('it is pay'); } }); new Route(); Backbone.history.start(); |
這裡有三個url路徑:index, about, pay,對應了三個很簡單的handler。這樣的程式碼量的時候,這樣寫是沒問題的。
但是隨著功能的增加,handler裡的內容會越來越多,所以要先把handler分離到不同的模組裡。
邏輯分離
把about的handler放在新的目錄下(about/index.js):
1 2 3 4 |
import $ from 'jquery' export default () =>{ $('#app').html('it is about'); } |
index,pay也按照同樣的辦法分離出去。
在index.js中修改一下程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
import Backbone from 'backbone' import home from './home/index' import about from './about/index' import pay from './pay/index' var Route = Backbone.Router.extend({ routes: { '': 'home', 'about': 'about', 'pay': 'pay' }, home, about, pay }); Backbone.history.start(); |
這樣分離之後對於開發而言減輕了痛苦,模組化的好處顯而易見。但是分離出去的檔案最終還是需要再引入的,最終生成的打包檔案還是會非常大,使用者從而不得去花很長時間載入一整個大檔案。
開啟瀏覽器的主頁,可以看到請求了一個bundle.js檔案,裡面包含了這個應用的全部模組。
也就是說這樣只是減少了開發的痛苦,對使用者而言不會有改善。
使用Code Splitting進行第一次優化
為了不讓使用者一次載入整個大檔案,稍微好點的做法是讓使用者分開一次一次載入檔案。
正好Code Splitting可以把在分離點中依賴的模組會被打包到一起,然後非同步載入。
修改一下index.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 |
import Backbone from 'backbone' import home from './home/index' // 直接寫路由的原因是主頁訪問頻率高,直接打包會加快訪問速度 var HomeRoute = Backbone.Router.extend({ routes: { '': 'home', }, home }); require.ensure([], function(require) { var about = require('./about/index').default; var AboutRoute = Backbone.Router.extend({ routes: { 'about': 'about' }, about }); new AboutRoute(); }); require.ensure([], function(require) { var pay = require('./pay/index').default; var PayRoute = Backbone.Router.extend({ routes: { 'pay': 'pay' }, pay }); new PayRoute(); }); Backbone.history.start(); |
因為require.ensure會生成一個小的打包檔案,這樣可以保證使用者不一次載入全部檔案,而是先載入bundle.js,再載入兩個小的js檔案。
開啟瀏覽器可以看到載入了三個js檔案
現在瀏覽器要載入三個檔案,增加了http請求數量。但是對於訪問頻率比較高的主頁而言,因為主頁的內容是直接打包的,會首先載入,使用者看到主頁的速度變快了。對於訪問about和pay的使用者而言,因為http請求數量變多,理論上會更慢的看到內容。是否分割程式碼應該根據實際情況來分析,因為這篇文章主要說的是程式碼分割,所以就先假設分離開之後對使用者訪問更有利。
然而類似about和pay這兩個頁面使用者不會每次都訪問,在開啟主頁的時候就載入about和pay頁面的handler是一種浪費,應該等到使用者訪問about和pay連結的時候再載入對應的js檔案。
第二次優化
想法很簡單:初始時只規定主頁的路由,而對於about和pay這種訪問頻率比較低的路由就動態載入。動態載入的方式:在處理未定義路由的handler中,通過匹配當前的路徑,增加router,然後重新解析頁面。
首先增加一個新路由:’*AllMissing’: ‘pathFinder’
pathFinder函式的思路是:先定義好about和pay頁面和路由和入口,然後把路由解析成正規表示式,通過正規表示式可以判斷出來當前的路徑符合哪條路由,然後增加新路由。
routes.js
1 2 3 4 5 |
// 路由:入口 export default { 'about': './about/index.js', 'pay': './pay/index.js' } |
router.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 46 47 |
import Backbone from 'backbone' import $ from 'jquery' import routes from './routes' import home from './home/index' var routesIndex = []; export default Backbone.Router.extend({ routes: { '': 'home', '*allMissing': 'pathFinder' }, home, pathFinder }); // 把路由解析成正規表示式,例如about會被解析成/^about(?:\?([\s\S]*))?$/,通過這個表示式可以確定當前路徑是那個路由 function routeToRegExp(route) { return Backbone.Router.prototype._routeToRegExp.call(null, route); } // 把路由列表對映到新的陣列 Object.keys(routes).forEach(key => { routesIndex.push({ 'entry': routes[key], // 入口 'regex': routeToRegExp(key), // 解析後的正規表示式 'route': key //路由 }); }); function pathFinder() { // 迴圈遍歷所有路由索引,如果找到了對應的路由就載入新路由 for (var i = 0, l = routesIndex.length; i < l; i++) { if (routesIndex[i].regex.test(path)) { var route = {}; var entry = routesIndex[i].entry; require.ensure([], function(require) { var app = require(entry).default; // 新增新的路由,重新解析當前url route[routesIndex[i].route] = 'app'; var Router = Backbone.Router.extend({ routes: route, app }); new Router(); Backbone.history.loadUrl(); }); return; } } $('#app').html('404'); } |
然後在index.js引入router.js,路由就可以工作了
在我們看來,路由現在是動態解析,動態載入檔案的。開啟瀏覽器,再看一下網路皮膚。
開啟主頁,只請求了bundle.js,檔案內容也是隻包含了主頁的程式碼。
再開啟about頁面,請求了一個1.bundle.js,看一下1.bundle.js的內容就會發現,裡面包含了about和pay兩個頁面的內容。這是webpack強大的地方,前文提到過,一個分離點會產生一個打包檔案,而我們因為只有一個require.ensure,所以webpack通過自己的分析就只產生了一個打包檔案,精準的包含了我們需要的內容。不得不說,webpack分析程式碼的功能有點厲害。
直接使用require.ensure是不能保證完全按需載入了,好在有loader可以幫助解決這個問題:bundle-loader.
只用改變一點點就可以按需載入了:
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 |
function pathFinder(path) { for (var i = 0, l = routesIndex.length; i < l; i++) { if (routesIndex[i].regex.test(path)) { var route = {}; var entry = routesIndex[i].entry; if (entry.startsWith('./')) { entry = entry.substr(2, entry.length); } // 這裡使用bundle-loader載入檔案,依賴的檔案不會全部打包到一個檔案裡。 var handler = require('bundle!./' + entry); handler(bundle => { var app = bundle.default; route[routesIndex[i].route] = 'app'; var Router = Backbone.Router.extend({ routes: route, app }); new Router(); Backbone.history.loadUrl(); }); return; } } $('#app').html('404'); } |
bundle-loader是一個用來在執行時非同步載入模組的loader,使用了bundle-loader載入的檔案可以動態的載入。
例如下面的官方示例:
1 2 3 4 5 6 7 8 |
// 在require bundle時,瀏覽器會載入它 var waitForChunk = require("bundle!./file.js"); // 等待載入,在回撥中使用 waitForChunk(function(file) { // 這裡可以使用file,就像是用下面的程式碼require進來一樣 // var file = require("./file.js"); }); |
因為webpack在編譯階段會遍歷到所有可能會使用到的檔案,而bundle-loader就是在所有檔案的外層加了一層wraper:
1 2 3 4 5 6 |
module.exports = function (cb) { require.ensure([], function(require) { var app = require('./file.js'); cb(app); }); }; |
這樣,在require檔案的時候只是引入了wraper,而且因為每個檔案都會產生一個分離點,導致產生了多個打包檔案,而打包檔案的載入只有在條件命中的情況下才產生,也就可以按需載入。
經過這樣的修改,瀏覽器就可以在不同的路徑下載入不同的依賴檔案了
總結
在單頁應用中使用這樣的方式按需載入檔案,對於路由庫的要求也很簡單:
- 建立從路由到正規表示式的對映,如果沒有的話,自己寫也可以
- 能夠動態的新增路由
- 能夠載入指定的路由
大多數路由庫都可以做到上面三點,所以這篇文章提出的是比較普遍的辦法。當然,如果你用React或者Vue,他們配套的路由會比這個優化的更全面。
注:這篇文章的內容參考了https://medium.com/@somebody32/how-to-split-your-apps-by-routes-with-webpack-36b7a8a6231#.ncyca72ms,但是最後作者提出的方案也比較複雜,所以就自己寫了一篇,最後的辦法比較簡單。