webpack源码之js代码压缩

基于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输出一致

当然,我们上面的实现只是一个简单的举例,实际上的混淆器实现会比当前的实现复杂得多,需要考虑非常多的语法上的细节,此处仅抛砖引玉供大家参考学习。

压缩流程总结

  1. 执行seal事件阶段
  2. 执行compilation.hooks.optimizeChunkAssets
  3. 执行uglifyjs-webpack-plugin
  4. 执行optimizeFn
  5. 执行runner.runTasks
  6. 执行runner.runTasks的callback
  7. 执行optimizeFn的callback
  8. 执行compilation.hooks.optimizeChunkAssets的callback

如果考虑到多进程和缓存的使用的话,流程图应该长下面这个样子。


流程图

参(chao)考(xi)资料

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

推荐阅读更多精彩内容

  • 不知道你有没有读过顾城写的‘一个人应该活的是自己并且干净’,我有段时间每天都要读上几遍,不见得是对这篇文章有多深的...
    西瓜可乐不加冰阅读 187评论 0 4
  • 开篇 手机当然是用来打电话的,在手机已取代了手表,成为我们随身携带之物的当下,不妨深挖手机功能,让它物尽其用。言归...
    波动率微笑阅读 2,296评论 3 15
  • 这是我的个人介绍,~ 【我是】:纪瑞瑞 【坐标】:山西 【身份】:国企员工,80后宝妈 【标签】:吃货,学习ing...
    瑞_6e38阅读 430评论 0 1