基于webpack 4.x.x的版本
由于tapable類的重寫,所以4.x版本和3.x版本在插件機制上有很打區別
如果你對tapable對象不熟悉,可以假裝他是一個事件訂閱/發布系統,雖然tapable沒那么簡單就是了。
webpack中的兩個重要對象
- compiler對象
其實就是webpack本身暴露出來給我們使用的一個對象。經常我們在自定義node啟動webpack中的方法就可以得到compiler對象,當然一般來說該對象全局唯一的,后續再有compiler對象的創建,就是childComplier
例如:
const compiler = webpack(config,[callback]);
這樣我們就可以得到compiler實例了,callback是可選的意思。
- compilation實例
compilation實例是每次編譯的時候就會獲得的對象,在watch模式下,每次watch都會獲得一個新的compilation實例,所以comppiler是每次啟動webpack獲得的全局唯一對象,而compilation則每次更新都會重新獲取一遍。獲取compilation方法如下:
// webpack4.x版本
compiler.hooks.compilation.tap(name,function(compilation){
// 恭喜你獲得compilation對象
})
// webpack3.x版本
compiler.plugin('compilation',function(compilation){
// 恭喜你獲得compilation對象
})
compilation.seal方法
webpack 中處理資源生成chunks和優化壓縮主要是在webpack的seal階段,由于我們講的是資源的壓縮,所以我們主要看seal中關于壓縮的代碼在哪一塊。
seal的代碼在compilation.js中的seal方法中,重點我們要關注的方法如下:
/**
* @param {Callback} callback signals when the seal method is finishes
* @returns {void}
*/
seal(callback) {
//...別的代碼
this.hooks.additionalAssets.callAsync(err => {
if (err) {
return callback(err);
}
//......
// 這邊就是我們要關注的js代碼壓縮的地方了,webpack本身不做壓縮這個功能,具體的功能由插件負責完成。
this.hooks.optimizeChunkAssets.callAsync(this.chunks, err => {
// 這邊的意思是我的seal方法調用完成了
return this.hooks.afterSeal.callAsync(callback);
});
});
});
}
然后具體的js代碼壓縮的方式在uglifyjs-wepack-plugin中,如下:
compilation.hooks.optimizeChunkAssets.tapAsync(
plugin,
optimizeFn.bind(this, compilation)
);
uglifyjs-wepack-plugin
前一部分,我們只是簡單對uglifyjs-wepack-plugin的源碼開了個頭,不過為什么我分析webpack的js壓縮流就突然要研究uglifyjs-wepack-plugin這個三方包了呢,人生真是處處都是驚喜。
接下來我們看看uglifyjs-wepack-plugin中optimizeFn到底干了什么,uglifyjs-wepack-plugin源碼傳送門
首先我們看到第一段代碼。
const taskRunner = new TaskRunner({
// 是否緩存結果
cache: this.options.cache,
// 是否多進程壓縮
parallel: this.options.parallel,
});
taskRunner呢是一個多進程的任務執行系統,這個從名字就可以看出來,主要是來自于TaskRunner.js他也是uglifyjs-webpack-plugin的核心,taskRunner有個方法叫run,需要兩個參數,第一個是tasks的對象數組,第二個是first-error類型的回調函數,表示任務執行運行完成,當然這里的任務主要是指壓縮任務啦.
接下來一大串代碼就是為了組織定義tasks這個參數是什么樣子的。
const processedAssets = new WeakSet();
// 這段代碼的主要目的就是組裝tasks
const tasks = [];
const { chunkFilter } = this.options;
// 根據相關條件篩選要壓縮的chunks,跟主流程沒太多關系。
Array.from(chunks)
.filter((chunk) => chunkFilter && chunkFilter(chunk))
.reduce((acc, chunk) => acc.concat(chunk.files || []), [])
.concat(compilation.additionalChunkAssets || [])
.filter(ModuleFilenameHelpers.matchObject.bind(null, this.options))
.forEach((file) => {
let inputSourceMap;
// compilation.assets其實是每個chunk生成的文件的最后結果
const asset = compilation.assets[file];
// 防止資源的重復壓縮
if (processedAssets.has(asset)) {
return;
}
try {
let input;
// 是否需要壓縮sourceMap,可以跳過不看。
if (this.options.sourceMap && asset.sourceAndMap) {
const { source, map } = asset.sourceAndMap();
input = source;
if (UglifyJsPlugin.isSourceMap(map)) {
inputSourceMap = map;
} else {
inputSourceMap = map;
compilation.warnings.push(
new Error(`${file} contains invalid source map`)
);
}
} else {
// input資源就是代碼沒有被壓縮之前的字符串的樣子
input = asset.source();
inputSourceMap = null;
}
// Handling comment extraction
let commentsFile = false;
if (this.options.extractComments) {
commentsFile =
this.options.extractComments.filename || `${file}.LICENSE`;
if (typeof commentsFile === 'function') {
commentsFile = commentsFile(file);
}
}
const task = {
file,
input,
inputSourceMap,
commentsFile,
extractComments: this.options.extractComments,
uglifyOptions: this.options.uglifyOptions,
minify: this.options.minify,
};
if (this.options.cache) {
const defaultCacheKeys = {
'uglify-js': uglifyJsPackageJson.version,
node_version: process.version,
// eslint-disable-next-line global-require
'uglifyjs-webpack-plugin': require('../package.json').version,
'uglifyjs-webpack-plugin-options': this.options,
hash: crypto
.createHash('md4')
.update(input)
.digest('hex'),
};
task.cacheKeys = this.options.cacheKeys(defaultCacheKeys, file);
}
tasks.push(task);
} catch (error) {
compilation.errors.push(
UglifyJsPlugin.buildError(
error,
file,
UglifyJsPlugin.buildSourceMap(inputSourceMap),
new RequestShortener(compiler.context)
)
);
}
});
代碼真長,也是真丑,咳咳 但是我們還是要繼續看,直接看重點,
const task = {
file,
input,
inputSourceMap,
commentsFile,
extractComments: this.options.extractComments,
uglifyOptions: this.options.uglifyOptions,
minify: this.options.minify,
};
這里有幾個重要屬性,其中file就是要生成的文件名,input就是文件中的字符串的內容,inputSourceMap就是對應的sourcemap文件內容。
好了,現在我們的tasks已經組裝好了,還記得前面的taskRunner我們就可以愉快執行taskRunner的run方法來壓縮了。
taskRunner.run(tasks, (tasksError, results) => {
// 無盡的代碼
})
TaskRunner.js
為了降低大腦負荷,我們考慮,假設taskRunner.js中沒有緩存和多進程的情況。
于是整體的taskRunner.run里的代碼可以簡化成以下這個樣子。
run(tasks, callback) {
if (!tasks.length) {
callback(null, []);
return;
}
this.boundWorkers = (options, cb) => {
try {
// 壓縮js代碼的壓縮
cb(null, minify(options));
} catch (error) {
cb(error);
}
};
// 所有任務數量
let toRun = tasks.length;
// 結果集,存儲所有文件的結果
const results = [];
const step = (index, data) => {
toRun -= 1;
results[index] = data;
// 所有js代碼的壓縮都完成了,就
if (!toRun) {
callback(null, results);
}
};
// 同時執行所有的js代碼的壓縮程序
tasks.forEach((task, index) => {
const enqueue = () => {
this.boundWorkers(task, (error, data) => {
const result = error ? { error } : data;
const done = () => step(index, result);
done();
});
};
enqueue();
});
}
這邊大概的流程就是,我們有一個專門執行js代碼壓縮的程序任務叫boundWorkers,然后有一個存儲結果集的results,然后我們異步并行執行壓縮js任務,注:這邊并不是多進程js壓縮。等所有壓縮js的任務執行完了,就執行done函數,done函數的主要作用就是閉包index,可以使得到的結果按照順序插入results里,這點就很想promise.all了,所以如果自己實現一個promise.all的話就可以考慮這個喲。
等所有任務都執行完了,就調用run的callbcak,也就UglifyJsPlugin的optimizeFn中的taskRunner的回調,而該回調的主要作用就是把獲得的results放到compilation的assets上,然后再執行optimizeChunkAssets的callbcak,我們就繼續回到了webpack的seal流程中啦。接下來我們繼續看看minify.js中到底做了什么壓縮操作。
minify.js
來來來,我們先不管別的,把minify的代碼主要流程抽取一下,抽取之后就變成這樣了。
import uglify from 'uglify-js';
// 復制uglify的選項,用于uglify.minify
const buildUglifyOptions = ()=>{/*.......*/}
const minify = (options) => {
const {
file,
input,
inputSourceMap,
extractComments,
minify: minifyFn,
} = options;
// 如果自己定義了minify的函數,也就是壓縮函數,那就調用它
if (minifyFn) {
return minifyFn({ [file]: input }, inputSourceMap);
}
// 獲得最終的uglify選項
const uglifyOptions = buildUglifyOptions(options.uglifyOptions);
// 獲得壓縮之后的結果
const { error, map, code, warnings } = uglify.minify(
{ [file]: input },
uglifyOptions
);
return { error, map, code, warnings, extractedComments };
};
export default minify;
以上代碼的核心在這一段
const { error, map, code, warnings } = uglify.minify(
{ [file]: input },
uglifyOptions
);
這樣看來,所有的所有的壓縮都是uglify.minify操作的,而uglify又是來自于uglify-js,好了,我們追到現在有點追不動了。不過我們可以試試uglify-js這個三方包,比如這個樣子:
const input = `var name = 123;
var age = "123";
function say(name,age){return name+age};
say(name,age);`
const { code } = uglify.minify({
'index.js':input
});
console.log(code)
// var name=123,age="123";function say(a,e){return a+e}say(name,age);
到現在我們的已經把整個流程梳理的差不多了,我們可以稍微嘗試(臆想)著自己寫一個壓縮程序的demo,只實現部分功能。
讓我們嘗試寫一段壓縮程序
Javascript混淆器的原理并不復雜,其核心是對目標代碼進行AST Transformation(抽象語法樹改寫),我們依靠現有的Javascript的AST Parser庫,能比較容易的實現自己的Javascript混淆器。以下我們借助 acorn來實現一個if語句片段的改寫。
假設我們存在這么一個代碼片段:
for(var i = 0; i < 100; i++){
if(i % 2 == 0){
console.log("foo");
}else{
console.log("bar");
}
}
那我們就這樣操作一下:
const {Parser} = require("acorn")
const MyUglify = Parser.extend();
const codeStr = `
for(var i = 0; i < 100; i++){
if(i % 2 == 0){
console.log("foo");
}else{
console.log("bar");
}
}
`;
function transform(node){
const { type } = node;
switch(type){
case 'Program':
case 'BlockStatement':{
const { body } = node;
return body.map(transform).join('');
}
case 'ForStatement':{
const results = ['for', '('];
const { init, test, update, body } = node;
results.push(transform(init), ';');
results.push(transform(test), ';');
results.push(transform(update), ')');
results.push(transform(body));
return results.join('');
}
case 'VariableDeclaration': {
const results = [];
const { kind, declarations } = node;
results.push(kind, ' ', declarations.map(transform));
return results.join('');
}
case 'VariableDeclarator':{
const {id, init} = node;
return id.name + '=' + init.raw;
}
case 'UpdateExpression': {
const {argument, operator} = node;
return argument.name + operator;
}
case 'BinaryExpression': {
const {left, operator, right} = node;
return transform(left) + operator + transform(right);
}
case 'IfStatement': {
const results = [];
const { test, consequent, alternate } = node;
results.push(transform(test), '?');
results.push(transform(consequent), ":");
results.push(transform(alternate));
return results.join('');
}
case 'MemberExpression':{
const {object, property} = node;
return object.name + '.' + property.name;
}
case 'CallExpression': {
const results = [];
const { callee, arguments } = node;
results.push(transform(callee), '(');
results.push(arguments.map(transform).join(','), ')');
return results.join('');
}
case 'ExpressionStatement':{
return transform(node.expression);
}
case 'Literal':
return node.raw;
case 'Identifier':
return node.name;
default:
throw new Error('unimplemented operations');
}
}
const ast = MyUglify.parse(codeStr);
console.log(transform(ast)); // 與UglifyJS輸出一致
當然,我們上面的實現只是一個簡單的舉例,實際上的混淆器實現會比當前的實現復雜得多,需要考慮非常多的語法上的細節,此處僅拋磚引玉供大家參考學習。
壓縮流程總結
- 執行seal事件階段
- 執行compilation.hooks.optimizeChunkAssets
- 執行uglifyjs-webpack-plugin
- 執行optimizeFn
- 執行runner.runTasks
- 執行runner.runTasks的callback
- 執行optimizeFn的callback
- 執行compilation.hooks.optimizeChunkAssets的callback
如果考慮到多進程和緩存的使用的話,流程圖應該長下面這個樣子。