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)資料

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • 不知道你有沒有讀過顧城寫的‘一個人應該活的是自己并且干凈’,我有段時間每天都要讀上幾遍,不見得是對這篇文章有多深的...
    西瓜可樂不加冰閱讀 205評論 0 4
  • 開篇 手機當然是用來打電話的,在手機已取代了手表,成為我們隨身攜帶之物的當下,不妨深挖手機功能,讓它物盡其用。言歸...
    波動率微笑閱讀 2,340評論 3 15
  • 這是我的個人介紹,~ 【我是】:紀瑞瑞 【坐標】:山西 【身份】:國企員工,80后寶媽 【標簽】:吃貨,學習ing...
    瑞_6e38閱讀 444評論 0 1