[FE] webpack群侠传(七):代码压缩和缓存

1. 回顾

上文我们介绍了webpack在代码生成阶段做的事情。

我们知道,webpack调用了compiler.hooks.make加载资源,
它会先加载loader,然后用loader加载源文件,
对于js而言,babel-loader会返回转换后的es5代码,而不是AST

加载完资源之后,webpack就会调用compilation.seal来生成代码,
compilation.seal中调用了一大堆hooks,
其中最重要的两件事情是,createChunkAssetsoptimizeChunkAssets

(1)createChunkAssets会填充compilation.assets对象,
compilation.assets中保存了待生成的目标文件名,和文件内容。

(2)optimizeChunkAssets会调用uglifyjs-webpack-plugin进行代码压缩,
而uglifyjs-webpack-plugin则引用了uglify-esworker-farm协助完成工作。

本文就从uglifyjs-webpack-plugin开始介绍,
其中worker-farm还涉及了Node.js内置模块child_process

2. 进入uglifyjs-wepack-plugin

2.1 compilation.hooks.optimizeChunkAssets

上一篇中我们介绍了,之所以会用到uglifyjs-wepack-plugin,
是因为compilation.seal中调用了compilation.hooks.optimizeChunkAssets

而compilation.hooks.optimizeChunkAssets的实现,位于 uglifyjs-webpack-plugin/src/index.js 第339行

compilation.hooks.optimizeChunkAssets.tapAsync(plugin, optimizeFn.bind(this, compilation));

该hooks的代码逻辑主要位于optimizeFn 中,它在 index.js 第138行

const optimizeFn = (compilation, chunks, callback) => {
    ...
    runner.runTasks(tasks, (tasksError, results) => {
        ...
        callback();
    });
};

2.2 runner.runTasks

我们看到,optimizeFn 函数调用了runner.runTasks
其中runner是由 uglifyjs-webpack-plugin/src/uglify/Runner.js 导出的。

export default class Runner {
    ...
    runTasks(tasks, callback) {
        ...
    }
    ...
}

runTasks方法在 Runner.js 第25行

runTasks(tasks, callback) {
    ...
    if (this.maxConcurrentWorkers > 1) {
        ...
        this.workers = workerFarm(workerOptions, workerFile);
        this.boundWorkers = (options, cb) => this.workers(serialize(options), cb);
    } else {
        this.boundWorkers = (options, cb) => {
            ...
            cb(null, minify(options));
        };
    }
    ...
    const step = (index, data) => {
        ...
        callback(null, results);
    };

    tasks.forEach((task, index) => {
        const enqueue = () => {
            this.boundWorkers(task, (error, data) => {
                ...
                const done = () => step(index, result);
                ...
                done();
            });
        };

        ...
        cacache.get(this.cacheDir, serialize(task.cacheKeys)).then(({ data }) => step(index, JSON.parse(data)), enqueue);
    });
}

下面我们仔细分析一下这个函数,这是一个关键点

(1)parallel 模式
首先,if (this.maxConcurrentWorkers > 1) { ,这个条件,
是判断 uglifyjs-webpack-plugin是否开启了parallel,可参考github仓库中的文档,README.md #Options

值得注意的是,这里虽然文档上写了parallel默认为false
但是webpack内部集成uglifyjs-webpack-plugin的时候,显式传入了true
代码位于,webpack/lib/WebpackOptionsDefaulter.js 第310行

this.set("optimization.minimizer", "make", options => [
    {
        apply: compiler => {
            // Lazy load the uglifyjs plugin
            const UglifyJsPlugin = require("uglifyjs-webpack-plugin");
            const SourceMapDevToolPlugin = require("./SourceMapDevToolPlugin");
            new UglifyJsPlugin({
                cache: true,
                parallel: true,
                sourceMap:
                    (options.devtool && /source-?map/.test(options.devtool)) ||
                    (options.plugins &&
                        options.plugins.some(p => p instanceof SourceMapDevToolPlugin))
            }).apply(compiler);
        }
    }
]);

所以,我们使用示例工程进行调试的时候,if (this.maxConcurrentWorkers > 1) { 为真,
表示启用了parallel模式进行压缩。

注:
如果我们在webpack.config.js中,手动引入uglifyjs-webpack-plugin,并设置parallelfalse

...
const UglifyJsPlugin = require('uglifyjs-webpack-plugin');

module.exports = {
    ...
    plugins: [
        new UglifyJsPlugin({
            parallel: false,
        }),
    ],
    ...
};

就会关闭parallel模式,逻辑走到这里,

else {
    this.boundWorkers = (options, cb) => {
        ...
        cb(null, minify(options));
    };
}

this.boundWorkers的值绑定为不同的值。
this.boundWorkers什么时候被调用,我们之后再详细介绍(本文第2.3节)。

(2)worker-farm

下面我们再回到parallel模式,parallel模式中会调用workerFarm建workers
最后,this.boundWorkers的调用会导致workers被调用。

if (this.maxConcurrentWorkers > 1) {
    ...
    this.workers = workerFarm(workerOptions, workerFile);
    this.boundWorkers = (options, cb) => this.workers(serialize(options), cb);
}

可是workers 是什么呢?workerFarm又是什么?
workerFarm实际上是引用了一个独立的代码库,worker-farm(v1.6.0)。
它内部会调用Node.js内置模块child_process来完成任务。

具体用法如下,
首先我们要新建一个child.js文件,子进程中会执行这些代码,

module.exports = function (inp, callback) {
  callback(null, inp + ' BAR (' + process.pid + ')')
}

然后我们在main.js文件中,使用worker-farm开启子进程,

var workerFarm = require('worker-farm')
  , workers    = workerFarm(require.resolve('./child'))
  , ret        = 0

for (var i = 0; i < 10; i++) {
  workers('#' + i + ' FOO', function (err, outp) {
    console.log(outp)
    if (++ret == 10)
      workerFarm.end(workers)
  })
}

以上代码,只有到了第6行workers被调用的时候,
Node.js才会加载并执行子进程中的代码,
被加载的子进程文件,我们可以查看下workerFile

~/Test/debug-webpack/node_modules/_uglifyjs-webpack-plugin@1.3.0@uglifyjs-webpack-plugin/dist/uglify/worker.js

源代码位置,位于uglifyjs-webpack-plugin/src/uglify/worker.js
其中,第17行,调用了minify来进行代码压缩。

callback(null, minify(options));

worker-farm的内部逻辑,我们这里暂且略过,
唯一会引起我们困扰的是,child.js中无法打断点,使用vscode调试的时候,也不会跳进去。
所以,我们只能在里面写log来确定child.js执行了

child.js中代码执行完了之后,调用callback,会触发main.js 中workers的回调。
因此对于uglifyjs-webpack-plugin而言,

this.boundWorkers = (options, cb) => this.workers(serialize(options), cb);

this.workers中工作做完后,会导致this.boundWorkers的回调cb被触发。

(3)一路callback

我们再来看下runTasks的代码,

runTasks(tasks, callback) {
    ...
    if (this.maxConcurrentWorkers > 1) {
        ...
        this.workers = workerFarm(workerOptions, workerFile);
        this.boundWorkers = (options, cb) => this.workers(serialize(options), cb);
    } else {
        this.boundWorkers = (options, cb) => {
            ...
            cb(null, minify(options));
        };
    }
    ...
    const step = (index, data) => {
        ...
        callback(null, results);
    };

    tasks.forEach((task, index) => {
        const enqueue = () => {
            this.boundWorkers(task, (error, data) => {
                ...
                const done = () => step(index, result);
                ...
                done();
            });
        };

        ...
        cacache.get(this.cacheDir, serialize(task.cacheKeys)).then(({ data }) => step(index, JSON.parse(data)), enqueue);
    });
}

以上代码第6行,this.workers完成后回调,会导致this.boundWorkers返回,
this.boundWorkers返回,在第23行,会调用done();
done(); 会调用stepstep会调用callback

step中的callback,就是runTaskscallback
这样runTasks就结束了,回到了optimizeFn中,继而完成了compilation.hooks.optimizeChunkAssets
最终回到了 Compilation.js 中,第1283行

this.hooks.optimizeChunkAssets.callAsync(this.chunks, err => {
    // 这里
});

这样就完成compilation.hooks.optimizeChunkAssets调用了。

2.3 enqueue

上文中我们留下了一个疑问,this.boundWorkers到底是什么时候触发的呢?
答案是,它是在runTasks中enqueue函数里触发的。

源码位于,Runner.js 第59行

tasks.forEach((task, index) => {
    const enqueue = () => {
        this.boundWorkers(task, (error, data) => {
            ...
        });
    };

    if (this.cacheDir) {
        cacache.get(this.cacheDir, serialize(task.cacheKeys)).then(({ data }) => step(index, JSON.parse(data)), enqueue);
    } else {
        enqueue();
    }
});

enqueue可能会由cacahe.get调用,也可能在else语句中直接调用。

(1)cacache缓存

通过调试,我们发现,cacheDir总是有值的,默认开启了缓存,

~/Test/debug-webpack/node_modules/.cache/uglifyjs-webpack-plugin

我们进入该目录查看一下文件结构,

uglifyjs-webpack-plugin
├── content-v2
│   └── sha512
│       └── d9
│           └── 62
│               └── a6889cb7fcb5f74679b4995fc488b42058fa7d1974386c75e566747c31bff92b1690152d887937e411caa9618019c796801b4879f3c927bff72da41e4080
├── index-v5
│   └── f9
│       └── 62
│           └── ab8974c954e9577998f607845e6ff5dc6acf034140aaf851b6a3fbf93ead
└── tmp

其中,content-v2/sha512/d9/62/... 那个长文件的内容如下,

{"code":"!function(e){var t={};function r(n){if(t[n])return t[n].exports;var o=t[n]={i:n,l:!1,exports:{}};return e[n].call(o.exports,o,o.exports,r),o.l=!0,o.exports}r.m=e,r.c=t,r.d=function(e,t,n){r.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:n})},r.r=function(e){\"undefined\"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:\"Module\"}),Object.defineProperty(e,\"__esModule\",{value:!0})},r.t=function(e,t){if(1&t&&(e=r(e)),8&t)return e;if(4&t&&\"object\"==typeof e&&e&&e.__esModule)return e;var n=Object.create(null);if(r.r(n),Object.defineProperty(n,\"default\",{enumerable:!0,value:e}),2&t&&\"string\"!=typeof e)for(var o in e)r.d(n,o,function(t){return e[t]}.bind(null,o));return n},r.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return r.d(t,\"a\",t),t},r.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},r.p=\"\",r(r.s=0)}([function(e,t){}]);","extractedComments":[]}

index-v5/f9/62/... 这个长文件的内容如下,

3f12b2ef5f09ba45b021cbeb26a3b0356b1e42df    {
    "key": "{\"uglify-es\":\"3.3.9\",\"uglifyjs-webpack-plugin\":\"1.3.0\",\"uglifyjs-webpack-plugin-options\":{\"test\":/\\.js(\\?.*)?$/i,\"warningsFilter\":function () {\n      return true;\n    },\"extractComments\":false,\"sourceMap\":false,\"cache\":true,\"cacheKeys\":function (defaultCacheKeys) {\n      return defaultCacheKeys;\n    },\"parallel\":true,\"uglifyOptions\":{\"compress\":{\"inline\":1},\"output\":{\"comments\":/^\\**!|@preserve|@license|@cc_on/}}},\"path\":\"\\u002FUsers\\u002Fthzt\\u002FTest\\u002Fdebug-webpack\\u002Fdist\\u002Findex.js\",\"hash\":\"27c9fda4f852c4a1e09c203bd9f77a56\"}",
    "integrity": "sha512-2WKmiJy3/LX3Rnm0mV/EiLQgWPp9GXQ4bHXlZnR8Mb/5KxaQFS2IeTfkEcqpYYAZx5aAG0h588knv/ctpB5AgA==",
    "time": 1540290187493,
    "size": 980
}

它们分别存储了缓存的keyvaluevalue就是uglifyjs minify后的代码。

(2)then(..., enqueue)
如果有缓存,就不会触发enqueue,也就不会触发this.boundWorkers,继而不会触发this.workers被调用,
代码就不会再次被minify了。

而如果没有缓存,会发生什么呢?
我们把缓存目录删掉,

~/Test/debug-webpack/node_modules/.cache/uglifyjs-webpack-plugin

通过逐行调试,我们发现最终在 cacache/get.js 第38行 抛了一个异常,

return (
    ...
  ).then(entry => {
    if (...) {
      throw new index.NotFoundError(cache, key)
    }
    ...
  })

这个异常是在promise.then中抛出的,
因此,Runner.js 第72行,调用cacahe.get的地方,就会触发then的第二个回调参数,

cacache.get(this.cacheDir, serialize(task.cacheKeys)).then(({ data }) => step(index, JSON.parse(data)), enqueue);

这个回调正好是enqueue

欲知后事如何,且待我下回分解。


参考

uglifyjs-webpack-plugin v1.3.0
worker-farm v1.6.0
cacache v10.0.4

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,458评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,454评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,171评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,062评论 0 207
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,440评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,661评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,906评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,609评论 0 200
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,379评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,600评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,085评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,409评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,072评论 3 237
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,088评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,860评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,704评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,608评论 2 270

推荐阅读更多精彩内容