github.com/happylindz/…javascript
最近在重拾 webpack 一些知識點,但願對前端模塊化有更多的理解,之前對 webpack 打包機制有所好奇,沒有理解深刻,淺嘗則止,最近經過對 webpack 打包後的文件進行查閱,對其如何打包 JS 文件有了更深的理解,但願經過這篇文章,可以幫助讀者你理解:前端
本文全部示例代碼所有放在個人 Github 上,看興趣的能夠看看:java
git clone https://github.com/happylindz/blog.git
cd blog/code/webpackBundleAnalysis
npm install
複製代碼
首先如今 webpack 做爲當前主流的前端模塊化工具,在 webpack 剛開始流行的時候,咱們常常經過 webpack 將全部處理文件所有打包成一個 bundle 文件, 先經過一個簡單的例子來看:webpack
// src/single/index.js
var index2 = require('./index2');
var util = require('./util');
console.log(index2);
console.log(util);
// src/single/index2.js
var util = require('./util');
console.log(util);
module.exports = "index 2";
// src/single/util.js
module.exports = "Hello World";
// 經過 config/webpack.config.single.js 打包
const webpack = require('webpack');
const path = require('path')
module.exports = {
entry: {
index: [path.resolve(__dirname, '../src/single/index.js')],
},
output: {
path: path.resolve(__dirname, '../dist'),
filename: '[name].[chunkhash:8].js'
},
}
複製代碼
經過 npm run build:single
可看到打包效果,打包內容大體以下(通過精簡):git
// dist/index.xxxx.js
(function(modules) {
// 已經加載過的模塊
var installedModules = {};
// 模塊加載函數
function __webpack_require__(moduleId) {
if(installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
var module = installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {}
};
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
module.l = true;
return module.exports;
}
return __webpack_require__(__webpack_require__.s = 3);
})([
/* 0 */
(function(module, exports, __webpack_require__) {
var util = __webpack_require__(1);
console.log(util);
module.exports = "index 2";
}),
/* 1 */
(function(module, exports) {
module.exports = "Hello World";
}),
/* 2 */
(function(module, exports, __webpack_require__) {
var index2 = __webpack_require__(0);
index2 = __webpack_require__(0);
var util = __webpack_require__(1);
console.log(index2);
console.log(util);
}),
/* 3 */
(function(module, exports, __webpack_require__) {
module.exports = __webpack_require__(2);
})]);
複製代碼
將相對無關的代碼剔除掉後,剩下主要的代碼:es6
__webpack_require__
模塊加載,先判斷 installedModules 是否已加載,加載過了就直接返回 exports 數據,沒有加載過該模塊就經過 modules[moduleId].call(module.exports, module, module.exports, __webpack_require__)
執行模塊而且將 module.exports 給返回。很簡單是否是,有些點須要注意的是:github
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__)
保證了模塊加載時 this 的指向 module.exports 而且傳入默認參數,很簡單,不過多解釋。webpack 單文件打包的方式應付一些簡單場景就足夠了,可是咱們在開發一些複雜的應用,若是沒有對代碼進行切割,將第三方庫(jQuery)或框架(React) 和業務代碼所有打包在一塊兒,就會致使用戶訪問頁面速度很慢,不能有效利用緩存,你的老闆可能就要找你談話了。web
那麼 webpack 多文件入口如何進行代碼切割,讓我先寫一個簡單的例子:shell
// src/multiple/pageA.js
const utilA = require('./js/utilA');
const utilB = require('./js/utilB');
console.log(utilA);
console.log(utilB);
// src/multiple/pageB.js
const utilB = require('./js/utilB');
console.log(utilB);
// 異步加載文件,相似於 import()
const utilC = () => require.ensure(['./js/utilC'], function(require) {
console.log(require('./js/utilC'))
});
utilC();
// src/multiple/js/utilA.js 可類比於公共庫,如 jQuery
module.exports = "util A";
// src/multiple/js/utilB.js
module.exports = 'util B';
// src/multiple/js/utilC.js
module.exports = "util C";
複製代碼
這裏咱們定義了兩個入口 pageA 和 pageB 和三個庫 util,咱們但願代碼切割作到:npm
那麼 webpack 須要怎麼配置呢?
// 經過 config/webpack.config.multiple.js 打包
const webpack = require('webpack');
const path = require('path')
module.exports = {
entry: {
pageA: [path.resolve(__dirname, '../src/multiple/pageA.js')],
pageB: path.resolve(__dirname, '../src/multiple/pageB.js'),
},
output: {
path: path.resolve(__dirname, '../dist'),
filename: '[name].[chunkhash:8].js',
},
plugins: [
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor',
minChunks: 2,
}),
new webpack.optimize.CommonsChunkPlugin({
name: 'manifest',
chunks: ['vendor']
})
]
}
複製代碼
單單配置多 entry 是不夠的,這樣只會生成兩個 bundle 文件,將 pageA 和 pageB 所須要的內容所有放入,跟單入口文件並無區別,要作到代碼切割,咱們須要藉助 webpack 內置的插件 CommonsChunkPlugin。
首先 webpack 執行存在一部分運行時代碼,即一部分初始化的工做,就像以前單文件中的 __webpack_require__
,這部分代碼須要加載於全部文件以前,至關於初始化工做,少了這部分初始化代碼,後面加載過來的代碼就沒法識別並工做了。
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor',
minChunks: 2,
})
複製代碼
這段代碼的含義是,在這些入口文件中,找到那些引用兩次的模塊(如:utilB),幫我抽離成一個叫 vendor 文件,此時那部分初始化工做的代碼會被抽離到 vendor 文件中。
new webpack.optimize.CommonsChunkPlugin({
name: 'manifest',
chunks: ['vendor'],
// minChunks: Infinity // 可寫可不寫
})
複製代碼
這段代碼的含義是在 vendor 文件中幫我把初始化代碼抽離到 mainifest 文件中,此時 vendor 文件中就只剩下 utilB 這個模塊了。你可能會好奇爲何要這麼作?
由於這樣能夠給 vendor 生成穩定的 hash 值,每次修改業務代碼(pageA),這段初始化時代碼就會發生變化,那麼若是將這段初始化代碼放在 vendor 文件中的話,每次都會生成新的 vendor.xxxx.js,這樣不利於持久化緩存,若是不理解也不要緊,下次我會另外寫一篇文章來說述這部份內容。
另外 webpack 默認會抽離異步加載的代碼,這個不須要你作額外的配置,pageB 中異步加載的 utilC 文件會直接抽離爲 chunk.xxxx.js 文件。
因此這時候咱們頁面加載文件的順序就會變成:
mainifest.xxxx.js // 初始化代碼
vendor.xxxx.js // pageA 和 pageB 共同用到的模塊,抽離
pageX.xxxx.js // 業務代碼
當 pageB 須要 utilC 時候則異步加載 utilC
複製代碼
執行 npm run build:multiple
便可查看打包內容,首先來看下 manifest 如何作初始化工做(精簡版)?
// dist/mainifest.xxxx.js
(function(modules) {
window["webpackJsonp"] = function webpackJsonpCallback(chunkIds, moreModules) {
var moduleId, chunkId, i = 0, callbacks = [];
for(;i < chunkIds.length; i++) {
chunkId = chunkIds[i];
if(installedChunks[chunkId])
callbacks.push.apply(callbacks, installedChunks[chunkId]);
installedChunks[chunkId] = 0;
}
for(moduleId in moreModules) {
if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
modules[moduleId] = moreModules[moduleId];
}
}
while(callbacks.length)
callbacks.shift().call(null, __webpack_require__);
if(moreModules[0]) {
installedModules[0] = 0;
return __webpack_require__(0);
}
};
var installedModules = {};
var installedChunks = {
4:0
};
function __webpack_require__(moduleId) {
// 和單文件一致
}
__webpack_require__.e = function requireEnsure(chunkId, callback) {
if(installedChunks[chunkId] === 0)
return callback.call(null, __webpack_require__);
if(installedChunks[chunkId] !== undefined) {
installedChunks[chunkId].push(callback);
} else {
installedChunks[chunkId] = [callback];
var head = document.getElementsByTagName('head')[0];
var script = document.createElement('script');
script.type = 'text/javascript';
script.charset = 'utf-8';
script.async = true;
script.src = __webpack_require__.p + "" + chunkId + "." + ({"0":"pageA","1":"pageB","3":"vendor"}[chunkId]||chunkId) + "." + {"0":"e72ce7d4","1":"69f6bbe3","2":"9adbbaa0","3":"53fa02a7"}[chunkId] + ".js";
head.appendChild(script);
}
};
})([]);
複製代碼
與單文件內容一致,定義了一個自執行函數,由於它不包含任何模塊,因此傳入一個空數組。除了定義了 __webpack_require__
,還另外定義了兩個函數用來進行加載模塊。
首先講解代碼前須要理解兩個概念,分別是 module 和 chunk
__webpack_require__
加載的模塊,一樣的使用數組下標做爲 moduleId 且是惟一不重複的。那麼爲何要區分 chunk 和 module 呢?
首先使用 installedChunks 來保存每一個 chunkId 是否被加載過,若是被加載過,則說明該 chunk 中所包含的模塊已經被放到了 modules 中,注意是 modules 而不是 installedModules。咱們先來簡單看一下 vendor chunk 打包出來的內容。
// vendor.xxxx.js
webpackJsonp([3,4],{
3: (function(module, exports) {
module.exports = 'util B';
})
});
複製代碼
在執行完 manifest 後就會先執行 vendor 文件,結合上面 webpackJsonp 的定義,咱們能夠知道 [3, 4] 表明 chunkId,當加載到 vendor 文件後,installedChunks[3] 和 installedChunks[4] 將會被置爲 0,這代表 chunk3,chunk4 已經被加載過了。
webpackJsonpCallback
一共有兩個參數,chuckIds 通常包含該 chunk 文件依賴的 chunkId 以及自身 chunkId,moreModules 表明該 chunk 文件帶來新的模塊。
var moduleId, chunkId, i = 0, callbacks = [];
for(;i < chunkIds.length; i++) {
chunkId = chunkIds[i];
if(installedChunks[chunkId])
callbacks.push.apply(callbacks, installedChunks[chunkId]);
installedChunks[chunkId] = 0;
}
for(moduleId in moreModules) {
if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
modules[moduleId] = moreModules[moduleId];
}
}
while(callbacks.length)
callbacks.shift().call(null, __webpack_require__);
if(moreModules[0]) {
installedModules[0] = 0;
return __webpack_require__(0);
}
複製代碼
簡單說說 webpackJsonpCallback
作了哪些事,首先判斷 chunkIds 在 installedChunks 裏有沒有回調函數函數未執行完,有的話則放到 callbacks 裏,而且等下統一執行,並將 chunkIds 在 installedChunks 中所有置爲 0, 而後將 moreModules 合併到 modules。
這裏面只有 modules[0] 是不固定的,其它 modules 下標都是惟一的,在打包的時候 webpack 已經爲它們統一編號,而 0 則爲入口文件即 pageA,pageB 各有一個 module[0]。
而後將 callbacks 執行並清空,保證了該模塊加載開始前因此前置依賴內容已經加載完畢,最後判斷 moreModules[0], 有值說明該文件爲入口文件,則開始執行入口模塊 0。
上面解釋了一大堆,可是像 pageA 這種同步加載 manifest, vendor 以及 pageA 文件來講,每次加載的時候 callbacks 都是爲空的,由於它們在 installedChunks 中的值要嘛爲 undefined(未加載), 要嘛爲 0(已被加載)。installedChunks[chunkId] 的值永遠爲 false,因此在這種狀況下 callbacks 里根本不會出現函數,若是僅僅是考慮這樣的場景,上面的 webpackJsonpCallback
徹底能夠寫成下面這樣:
var moduleId, chunkId, i = 0, callbacks = [];
for(;i < chunkIds.length; i++) {
chunkId = chunkIds[i];
installedChunks[chunkId] = 0;
}
for(moduleId in moreModules) {
if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
modules[moduleId] = moreModules[moduleId];
}
}
if(moreModules[0]) {
installedModules[0] = 0;
return __webpack_require__(0);
}
複製代碼
可是考慮到異步加載 js 文件的時候(好比 pageB 異步加載 utilC 文件),就沒那麼簡單,咱們先來看下 webpack 是如何加載異步腳本的:
// 異步加載函數掛載在 __webpack_require__.e 上
__webpack_require__.e = function requireEnsure(chunkId, callback) {
if(installedChunks[chunkId] === 0)
return callback.call(null, __webpack_require__);
if(installedChunks[chunkId] !== undefined) {
installedChunks[chunkId].push(callback);
} else {
installedChunks[chunkId] = [callback];
var head = document.getElementsByTagName('head')[0];
var script = document.createElement('script');
script.type = 'text/javascript';
script.charset = 'utf-8';
script.async = true;
script.src = __webpack_require__.p + "" + chunkId + "." + ({"0":"pageA","1":"pageB","3":"vendor"}[chunkId]||chunkId) + "." + {"0":"e72ce7d4","1":"69f6bbe3","2":"9adbbaa0","3":"53fa02a7"}[chunkId] + ".js";
head.appendChild(script);
}
};
複製代碼
大體分爲三種狀況,(已經加載過,正在加載中以及從未加載過)
咱們經過 utilC 生成的 chunk 來進行講解:
webpackJsonp([2,4],{
4: (function(module, exports) {
module.exports = "util C";
})
});
複製代碼
pageB 須要異步加載這個 chunk:
webpackJsonp([1,4],[
/* 0 */
(function(module, exports, __webpack_require__) {
const utilB = __webpack_require__(3);
console.log(utilB);
const utilC = () => __webpack_require__.e/* nsure */(2, function(require) {
console.log(__webpack_require__(4))
});
utilC();
})
]);
複製代碼
當 pageB 進行某種操做須要加載 utilC 時就會執行 __webpack_require__.e(2, callback)
2,表明須要加載的模塊 chunkId(utilC),異步加載 utilC 並將 callback 添加到 installedChunks[2] 中,而後當 utilC 的 chunk 文件加載完畢後,chunkIds 包含 2,發現 installedChunks[2] 是個數組,裏面還有以前還未執行的 callback 函數。
既然這樣,那我就將我本身帶來的模塊先放到 modules 中,而後再統一執行以前未執行完的 callbacks 函數,這裏指的是存放於 installedChunks[2] 中的回調函數 (可能存在多個),這也就是說明這裏的前後順序:
// 先將 moreModules 合併到 modules, 再去執行 callbacks, 否則以前未執行的 callback 依賴於新來的模塊,你不放進 module 我豈不是得不到想要的模塊
for(moduleId in moreModules) {
if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
modules[moduleId] = moreModules[moduleId];
}
}
while(callbacks.length)
callbacks.shift().call(null, __webpack_require__);
複製代碼
通過我對打包文件的觀察,從 webpack1 到 webpack2 在打包文件上有下面這些主要的改變:
首先,moduleId[0] 再也不爲入口執行函數作保留,因此說不用傻傻看到 moduleId[0] 就認爲是打包文件的入口模塊,取而代之的是 window["webpackJsonp"] = function webpackJsonpCallback(chunkIds, moreModules, executeModules) {}
傳入了第三個參數 executeModules,是個數組,若是參數存在則說明它是入口模塊,而後就去執行該模塊。
if(executeModules) {
for(i=0; i < executeModules.length; i++) {
result = __webpack_require__(__webpack_require__.s = executeModules[i]);
}
}
複製代碼
其次,webpack2 中會默認加載 OccurrenceOrderPlugin 這個插件,即你不用 plugins 中添加這個配置它也會默認執行,那它有什麼用途呢?主要是在 webpack1 中 moduleId 的不肯定性致使的,在 webpack1 中 moduleId 取決於引入文件的順序,這就會致使這個 moduleId 可能會時常發生變化, 而 OccurrenceOrderPlugin 插件會按引入次數最多的模塊進行排序,引入次數的模塊的 moduleId 越小,好比說上面引用的 utilB 模塊引用次數爲 2(最多),因此它的 moduleId 爲 0。
webpackJsonp([3],[
/* 0 */
(function(module, exports) {
module.exports = 'util B';
})
]);
複製代碼
最後說下在異步加載模塊時, webpack2 是基於 Promise 的,因此說若是你要兼容低版本瀏覽器,須要引入 Promise-polyfill
,另外爲引入請求添加了錯誤處理。
__webpack_require__.e = function requireEnsure(chunkId) {
var promise = new Promise(function(resolve, reject) {
installedChunkData = installedChunks[chunkId] = [resolve, reject];
});
installedChunkData[2] = promise;
// start chunk loading
var head = document.getElementsByTagName('head')[0];
var script = document.createElement('script');
script.type = 'text/javascript';
script.charset = 'utf-8';
script.async = true;
script.timeout = 120000;
script.src = __webpack_require__.p + "" + chunkId + "." + {"0":"ae9c5f5f","1":"0ac69acb","2":"20651a9c","3":"0cdc6c84"}[chunkId] + ".js";
var timeout = setTimeout(onScriptComplete, 120000);
script.onerror = script.onload = onScriptComplete;
function onScriptComplete() {
// 防止內存泄漏
script.onerror = script.onload = null;
clearTimeout(timeout);
var chunk = installedChunks[chunkId];
if(chunk !== 0) {
if(chunk) {
chunk[1](new Error('Loading chunk ' + chunkId + ' failed.'));
}
installedChunks[chunkId] = undefined;
}
};
head.appendChild(script);
return promise;
};
複製代碼
能夠看出,本來基於回調函數的方式已經變成基於 Promise 作異步處理,另外添加了 onScriptComplete
用於作腳本加載失敗處理。
在 webpack1 的時候,若是因爲網絡緣由當你加載腳本失敗後,即便網絡恢復了,你再次進行某種操做須要同個 chunk 時候都會無效,主要緣由是失敗以後沒把 installedChunks[chunkId] = undefined;
致使以後不會再對該 chunk 文件發起異步請求。
而在 webpack2 中,當腳本請求超時了(2min)或者加載失敗,會將 installedChunks[chunkId] 清空,當下次從新請求該 chunk 文件會從新加載,提升了頁面的容錯性。
這些是我在打包文件中看到主要的區別,不免有所遺漏,若是你有更多的看法,歡迎在評論區留言。
什麼是 tree shaking,即 webpack 在打包的過程當中會將沒用的代碼進行清除(dead code)。通常 dead code 具備一下的特徵:
是否是很神奇,那麼須要怎麼作才能使 tree shaking 生效呢?
首先,模塊引入要基於 ES6 模塊機制,再也不使用 commonjs 規範,由於 es6 模塊的依賴關係是肯定的,和運行時的狀態無關,能夠進行可靠的靜態分析,而後清除沒用的代碼。而 commonjs 的依賴關係是要到運行時候才能肯定下來的。
其次,須要開啓 UglifyJsPlugin 這個插件對代碼進行壓縮。
咱們先寫一個例子來講明:
// src/es6/pageA.js
import {
utilA,
funcA, // 引入 funcA 但未使用, 故 funcA 會被清除
} from './js/utilA';
import utilB from './js/utilB'; // 引入 utilB(函數) 未使用,會被清除
import classC from './js/utilC'; // 引入 classC(類) 未使用,不會被清除
console.log(utilA);
// src/es6/js/utilA.js
export const utilA = 'util A';
export function funcA() {
console.log('func A');
}
// src/es6/js/utilB.js
export default function() {
console.log('func B');
}
if(false) { // 被清除
console.log('never use');
}
while(true) {}
console.log('never use');
// src/es6/js/utilC.js
const classC = function() {} // 類方法不會被清除
classC.prototype.saySomething = function() {
console.log('class C');
}
export default classC;
複製代碼
打包的配置也很簡單:
const webpack = require('webpack');
const path = require('path')
module.exports = {
entry: {
pageA: path.resolve(__dirname, '../src/es6/pageA.js'),
},
output: {
path: path.resolve(__dirname, '../dist'),
filename: '[name].[chunkhash:8].js'
},
plugins: [
new webpack.optimize.CommonsChunkPlugin({
name: 'manifest',
minChunks: Infinity,
}),
new webpack.optimize.UglifyJsPlugin({
compress: {
warnings: false
}
})
]
}
複製代碼
經過 npm run build:es6
對壓縮的文件進行分析:
// dist/pageA.xxxx.js
webpackJsonp([0],[
function(o, t, e) {
'use strict';
Object.defineProperty(t, '__esModule', { value: !0 });
var n = e(1);
e(2), e(3);
console.log(n.a);
},
function(o, t, e) {
'use strict';
t.a = 'util A';
},
function(o, t, e) {
'use strict';
for (;;);
console.log('never use');
},
function(o, t, e) {
'use strict';
const n = function() {};
n.prototype.saySomething = function() {
console.log('class C');
};
}
],[0]);
複製代碼
引入可是沒用的變量,函數都會清除,未執行的代碼也會被清除。可是類方法是不會被清除的。由於 webpack 不會區分不了是定義在 classC 的 prototype 仍是其它 Array 的 prototype 的,好比 classC 寫成下面這樣:
const classC = function() {}
var a = 'class' + 'C';
var b;
if(a === 'Array') {
b = a;
}else {
b = 'classC';
}
b.prototype.saySomething = function() {
console.log('class C');
}
export default classC;
複製代碼
webpack 沒法保證 prototype 掛載的對象是 classC,這種代碼,靜態分析是分析不了的,就算能靜態分析代碼,想要正確徹底的分析也比較困難。因此 webpack 乾脆不處理類方法,不對類方法進行 tree shaking。
更多的 tree shaking 的反作用能夠查閱:Tree shaking class methods
scope hoisting,顧名思義就是將模塊的做用域提高,在 webpack 中不能將全部全部的模塊直接放在同一個做用域下,有如下幾個緣由:
在 webpack3 中,這些狀況生成的模塊不會進行做用域提高,下面我就舉個例子來講明:
// src/hoist/utilA.js
export const utilA = 'util A';
export function funcA() {
console.log('func A');
}
// src/hoist/utilB.js
export const utilB = 'util B';
export function funcB() {
console.log('func B');
}
// src/hoist/utilC.js
export const utilC = 'util C';
// src/hoist/pageA.js
import { utilA, funcA } from './utilA';
console.log(utilA);
funcA();
// src/hoist/pageB.js
import { utilA } from './utilA';
import { utilB, funcB } from './utilB';
funcB();
import('./utilC').then(function(utilC) {
console.log(utilC);
})
複製代碼
這個例子比較典型,utilA 被 pageA 和 pageB 所共享,utilB 被 pageB 單獨加載,utilC 被 pageB 異步加載。
想要 webpack3 生效,則須要在 plugins 中添加 ModuleConcatenationPlugin。
webpack 配置以下:
const webpack = require('webpack');
const path = require('path')
module.exports = {
entry: {
pageA: path.resolve(__dirname, '../src/hoist/pageA.js'),
pageB: path.resolve(__dirname, '../src/hoist/pageB.js'),
},
output: {
path: path.resolve(__dirname, '../dist'),
filename: '[name].[chunkhash:8].js'
},
plugins: [
new webpack.optimize.ModuleConcatenationPlugin(),
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor',
minChunks: 2,
}),
new webpack.optimize.CommonsChunkPlugin({
name: 'manifest',
minChunks: Infinity,
})
]
}
複製代碼
運行 npm run build:hoist
進行編譯,簡單看下生成的 pageB 代碼:
webpackJsonp([2],{
2: (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
var utilA = __webpack_require__(0);
// CONCATENATED MODULE: ./src/hoist/utilB.js
const utilB = 'util B';
function funcB() {
console.log('func B');
}
// CONCATENATED MODULE: ./src/hoist/pageB.js
funcB();
__webpack_require__.e/* import() */(0).then(__webpack_require__.bind(null, 3)).then(function(utilC) {
console.log(utilC);
})
})
},[2]);
複製代碼
經過代碼分析,能夠得出下面的結論:
好了,講到這差很少就完了,理解上面的內容對前端模塊化會有更多的認知,若是有什麼寫的不對或者不完整的地方,還望補充說明,但願這篇文章能幫助到你。