webpack那些事:浅入深出-源码解析构建优化

webpack那些事:浅入深出-源码解析构建优化

基础知识回顾

  • 入口(entry)

    module.exports = {
      entry: './path/to/my/entry/file.js'
    };
    //或者
    module.exports = {
      entry: {
        main: './path/to/my/entry/file.js'
      }
    };
    
  • 输出(output)

    module.exports = {
      output: {
        filename:'[name][chunkhash:8].js',
        path:path.resolve(__dirname,'dist')
      }
    };
    
  • loader
    预处理loader

    • css-loader 处理css中路径引用等问题
    • style-loader 动态把样式写入css
    • sass-loader scss编译器
    • less-loader less编译器
    • postcss-loader scss再处理

    处理js loader

    • babel-loader
    • jsx-loader
    • ts-loader

    图片处理loader

    • url-loader
  • 插件(plugin)
    plugins里面放的是插件,插件的作用在于提高开发效率,能够解放双手,让我们去做更多有意义的事情。一些很low的事就统统交给插件去完成。

    const webpackConfig = {
        plugins: [
            //清除文件
            new CleanWebpackPlugin(),
            //css单独打包
            new MiniCssExtractPlugin({
                filename: "[name].css",
                chunkFilename: "[name].css"
            }),
            // 引入热更新插件
            new webpack.HotModuleReplacementPlugin() 
        ]
    }
    
  • 模式(mode)

    • production 生产环境
  • development 开发环境

    • 提升了构建速度
    • 默认为开发环境,不需要专门配置
    • 提供压缩功能,不需要借助插件
    • 提供SouceMap,不需要专门配置
  • 浏览器兼容性(browser compatibility)

  • 环境(environment)

项目中详细配置

构建过程

Webpack 处理应用程序时,它会递归地构建一个依赖关系图(dependency graph),其中包含应用程序需要的每个模块,然后将所有模块打包成一个或多个 bundle

其实就是:Webpack 是一个 JS 代码打包器。

至于图片、CSS、Less、TS等其他文件,就需要 Webpack 配合 loader 或者 plugin 功能来实现。

构建流程

  1. 根据配置,识别入口文件;
  2. 逐层识别模块依赖(包括 Commonjs、AMD、或 ES6 的 import 等,都会被识别和分析);
  3. Webpack 主要工作内容就是分析代码,转换代码,编译代码,最后输出代码;
  4. 输出最后打包后的代码。

webpack构建的三个阶段:

  1. 初始化阶段
  2. 编译阶段
  3. 输出阶段

初始化

  • 初始化参数: 从配置文件和 Shell 语句中读取与合并参数,得出最终的参数。这个过程中还会执行配置文件中的插件实例化语句 new Plugin()。

  • 初始化默认参数配置: new WebpackOptionsDefaulter().process(options)

  • 实例化Compiler对象:用上一步得到的参数初始化Compiler实例,Compiler负责文件监听和启动编译。Compiler实例中包含了完整的Webpack配置,全局只有一个Compiler实例。

  • 加载插件: 依次调用插件的apply方法,让插件可以监听后续的所有事件节点。同时给插件传入compiler实例的引用,以方便插件通过compiler调用Webpack提供的API。

  • 处理入口: 读取配置的Entrys,为每个Entry实例化一个对应的EntryPlugin,为后面该Entry的递归解析工作做准备

编译

1、生成chunk

chunk是webpack内部运行时的概念;一个chunk是对依赖图的部分进行封装的结果(``Chunkthe class is the encapsulation for parts of your dependency graph);可以通过多个entry-point来生成一个chunk
chunk可以分为三类;

  • entry chunk
    • 包含webpack runtime code并且是最先执行的chunk
  • initial chunk
    • 包含同步加载进来的module且不包含runtime code的chunk
    • 在entry chunk执行后再执行的
  • normal chunk
    • 使用require.ensureSystem.importimport()异步加载进来的module,会被放到normal chunk中
webpack那些事:浅入深出-源码解析构建优化

每个chunk都至少有一个属性:

  • name: 默认为main
  • id: 唯一的编号,开发环境和name相同,生产环境是一个数字,从0开始

2、构建依赖模块

var compiler = webpack(options);

webpack那些事:浅入深出-源码解析构建优化

从入口文件index.js开始分析,检查右侧表格中的记录,如果有记录就结束。没有记录就继续读取文件内容,读取完文件内容后,开始进行抽象树语法分析,将代码字符串转换成一个对象的描述文件。并将其中的依赖保存在dependencies数组中

dependencies:["./src/a.js"]

保存完以后,替换依赖函数

console.log("index.js");
_webpack_reuqire("./src/a.js");

将转换后的代码字符串保存在右侧的表格中

模块id 转换后的代码
./src/index.js console.log(“index.js”);_webpack_reuqire("./src/a.js");

因为dependencies中有数据,开始递归解析dependencies中的数据。取出.src/a.js

// .src/a.js
console.log("a.js");
require("b")

查看右侧表格,发现没有a.js,开始读取文件内容,生成ast抽象语法树,将依赖记录在数组中

dependencies: ["./src/b.js"]

然后替换函数依赖

console.log("a.js");
_webpack_require("./src/b.js");
module.exports = "a"

将转换后的代码记录在右侧的表格中

模块id 转换后的代码
./src/index.js console.log(“index.js”);_webpack_reuqire("./src/a.js");
./src/a.js console.log(“a.js”);_webpack_require("./src/b.js");module.exports = “a”

然后继续取出来dependencies的内容./src/b.js

console.log("b.js");
module.exports = "b";

发现右侧表格中没有b.js这个文件,就继续读取文件内容,进行ast抽象语法树分析,发现没有依赖项,就不需要往数组中放东西,也不需要替换依赖项,将代码字符串存在表格中

模块id 转换后的代码
./src/index.js console.log(“index.js”);_webpack_reuqire("./src/a.js");
./src/a.js console.log(“a.js”);_webpack_require("./src/b.js");module.exports = “a”
./src/b.js console.log(“b.js”);module.exports = “b”;

然后递归回去,发现index下产生的数组是空,整个文件依赖就解析完毕

3、产生chunk assets

在第二步完成以后,chunk中会产生一个模块列表,列表中包含了模块id模块转换后的代码

接下来,webpack会根据配置为chunk生成一个资源列表,即chunk assets,资源列表可以理解为是生成到最终文件的文件名和文件内容

  • 为什么叫资源列表呢?
  • 因为有可能配置devtool生成的除了./dist/main.js还有./dist/main.js.map

即:文件名:./dist/main.js

文件内容:

(function(){

})({
    "./src/index.js": function(){
        //是否是eval可以根据devtool来设置,有很多种方式
        eval("console.log(\"index module\");\nvar a = __webpack_require__(/*! ./a */ \"./src/a.js\"); \na.abc();\nconsole.log(a);\n\n\n//# sourceURL=webpack:///./src/index.js?")
    }
})

webpack那些事:浅入深出-源码解析构建优化

chunk hash: 是根据所有的chunk assets的内容生成的一个hash字符串
hash: 一种算法,具有很多分类。特点是将一个任意长度的字符串转换成一个固定长度的字符串,而且可以保证原始内容不变

就是将我们上面生成的文件内容,全部联合起来,然后生成一个固定长度的哈希值链接

简图: webpack那些事:浅入深出-源码解析构建优化

多个chunk assets就是一个bundle(一捆)

4、合并chunk assets

将多个chunk的assets合并到一起,并产生一个总的hash webpack那些事:浅入深出-源码解析构建优化

输出

webpack将利用node中的fs模块(文件处理模块),根据编译产生的总的assets,生成相应的文件

webpack那些事:浅入深出-源码解析构建优化

涉及术语

  1. module: 模块,分割的代码单元,webpack中的模块可以是任何内容的文件,不仅限于JS
  2. chunk: webpack内部构建模块的块,一个chunk中包含多个模块,这些模块是从入口模块通过依赖分析得来的
  3. bundle:chunk构建好模块后会生成chunk的资源清单,清单中的每一项就是一个bundle,可以认为bundle就是最终生成的文件
  4. hash:最终的资源清单所有内容联合生成的hash值
  5. chunkhash: chunk生成的资源清单内容联合生成的hash值
  6. chunkname:chunk的名称,如果没有配置则使用main
  7. id: 通常指chunk的唯一编号,如果在开发环境下构建,和chunkname相同;如果是生产环境下构建,则使用一个从0开始的数字进行编号

HMR热更新原理

简介

Hot Module Replacement(以下简称:HMR 模块热替换)是 Webpack 提供的一个非常有用的功能,它允许在 JavaScript 运行时更新各种模块,而无需完全刷新

当我们修改代码并保存后,Webpack 将对代码重新打包,HMR 会在应用程序运行过程中替换、添加或删除模块,而无需重新加载整个页面。
HMR 主要通过以下几种方式,来显著加快开发速度:

  • 保留在完全重新加载页面时丢失的应用程序状态;
  • 只更新变更内容,以节省宝贵的开发时间;
  • 调整样式更加快速 - 几乎相当于在浏览器调试器中更改样式。

服务启动

webpack-dev-server:不是一个插件,而是一个web服务器

服务启动流程

webpack那些事:浅入深出-源码解析构建优化

webpack-dev-server源码解析

//启动服务的具体方法
function startDevServer(config, options) {
  const log = createLogger(options);
  //声明全局webpack实例
  let compiler;

  try {
    compiler = webpack(config);
  } catch (err) {
    if (err instanceof webpack.WebpackOptionsValidationError) {
      log.error(colors.error(options.stats.colors, err.message));
      // eslint-disable-next-line no-process-exit
      process.exit(1);
    }

    throw err;
  }

  try {
    //创建server服务
    server = new Server(compiler, options, log);
    serverData.server = server;
  } catch (err) {
    if (err.name === 'ValidationError') {
      log.error(colors.error(options.stats.colors, err.message));
      // eslint-disable-next-line no-process-exit
      process.exit(1);
    }
    throw err;
  }
  if (options.socket) {
    //设置服务监听
    server.listeningApp.on('error', (e) => {
      if (e.code === 'EADDRINUSE') {
        //使用socket建立长连接
        //初始化socket
        const clientSocket = new net.Socket();

        clientSocket.on('error', (err) => {
          if (err.code === 'ECONNREFUSED') {
            // No other server listening on this socket so it can be safely removed
            fs.unlinkSync(options.socket);

            server.listen(options.socket, options.host, (error) => {
              if (error) {
                throw error;
              }
            });
          }
        });

        clientSocket.connect({ path: options.socket }, () => {
          throw new Error('This socket is already used');
        });
      }
    });

    server.listen(options.socket, options.host, (err) => {
      if (err) {
        throw err;
      }

      // chmod 666 (rw rw rw)
      const READ_WRITE = 438;

      fs.chmod(options.socket, READ_WRITE, (err) => {
        if (err) {
          throw err;
        }
      });
    });
  } else {
    server.listen(options.port, options.host, (err) => {
      if (err) {
        throw err;
      }
    });
  }
}
//启动webpack-dev-server服务器
processOptions(config, argv, (config, options) => {
  startDevServer(config, options);
});

server.js源码解析

class Server {
  constructor(compiler, options = {}, _log) {
    ......
    //构造函数初始化服务
  }
	//创建初始化express应用
  setupApp() {
    this.app = new express();
  }
 	// 绑定监听事件
  setupHooks() {
    //当监听到一次webpack编译结束,就会调用_sendStats方法通过websocket给浏览器发送通知,
    //ok和hash事件,这样浏览器就可以拿到最新的hash值了,做检查更新逻辑
    const addHooks = (compiler) => {
      const { compile, invalid, done } = compiler.hooks;
      compile.tap('webpack-dev-server', invalidPlugin);
      invalid.tap('webpack-dev-server', invalidPlugin);
      // 监听webpack的done钩子,tapable提供的监听方法
      done.tap('webpack-dev-server', (stats) => {
        this._sendStats(this.sockets, this.getStats(stats));
        this._stats = stats;
      });
    };
		......
  }

	//使用webpack-dev-middleware中间件,返回生成的bundle文件
  setupDevMiddleware() {
    // middleware for serving webpack bundle
    this.middleware = webpackDevMiddleware(
      this.compiler,
      Object.assign({}, this.options, { logLevel: this.log.options.level })
    );
  }
	......
  //创建http服务,并启动服务
  createServer() { ... }
	//创建socket服务器建立长连接
  createSocketServer() {
    ......
  }
  //使用socket在服务器和浏览器直接建立一个websocket长连接
  listen(port, hostname, fn){ ... }
  // 通过websoket给客户端发消息
  _sendStats(sockets, stats, force) {
    ......
    this.sockWrite(sockets, 'hash', stats.hash);
    if (stats.errors.length > 0) {
      this.sockWrite(sockets, 'errors', stats.errors);
    } else if (stats.warnings.length > 0) {
      this.sockWrite(sockets, 'warnings', stats.warnings);
    } else {
      this.sockWrite(sockets, 'ok');
    }
  }

client/index.js源码解析

var onSocketMessage = { 
  hash: function hash(_hash) {
        // 更新currentHash值
        status.currentHash = _hash;
    },
    ok: function ok() {
        sendMessage('Ok');
        // 进行更新检查等操作
        reloadApp(options, status);
    },
}
// 连接服务地址socketUrl,?http://localhost:8080,本地服务地址
socket(socketUrl, onSocketMessage);

热更新

热更新流程

webpack那些事:浅入深出-源码解析构建优化

  1. 文件系统发生变化
  2. 当监听到文件发生变化时,webpack 使用HotModuleReplacementPlugin编译文件,并将代码保存在内存中(webpack-dev-middleware)。
  3. 同时,webpack-dev-server通过编译器compiler获得文件的编译情况。
  4. 在compiler的 done 钩子函数(生命周期)里调用_sendStats发送向client发送hash值,在client保存下来。
  5. client接收到ok或warning消息后调用reloadApp发布客户端检查更新事件webpackHotUpdate。
  6. webpack/hot监听到webpackHotUpdate事件,调用check方法进行hash值对比以及检查各modules是否需要更新。
  7. 调用JsonpMainTemplate.runtime的hotDownloadManifest方法向server端发送ajax请求
  8. 服务端返回hot-update.json(manifest)文件,该文件包含所有要更新模块的hash值和chunk名。
  9. JsonpRuntime根据返回的json值使用jsonp请求具体的代码块
  10. jsonp返回最新的chunk代码,并直接执行。
  11. HotModulePlugin 将会对新旧模块进行对比,决定是否更新模块,检查模块之间的依赖关系,更新模块的同时更新模块间的依赖引用。
  12. HMR runtime本身并不会处理代码修改,它会将不同文件交给对应的loader runtime处理
  13. 更新代码
  14. 如果更新失败,则直接刷新

webpaserver端源码

在项目初始化时,服务端与客户端已经开启了长连接服务,当webpack对文件编译产生变化时,服务端会及时通知客户端。

class Server {
  ...
  setupHooks() {
    //添加webpack的done事件回调
    const addHooks = (compiler) => {
      const { compile, invalid, done } = compiler.hooks;
      //通知正在客户端编译  
      compile.tap(\'webpack-dev-server\', invalidPlugin);
      done.tap(\'webpack-dev-server\', (stats) => {
        //编译完成向客户端发送消息
        this._sendStats(this.sockets, this.getStats(stats)); 
        this._stats = stats;
      });
    };
    addHooks(this.compiler);
  } 
  _sendStats(sockets, stats, force) {
    if (...) { //无变化则return
      return this.sockWrite(sockets, \'still-ok\');
    }
    //如果有变化,则发送hash值
    this.sockWrite(sockets, \'hash\', stats.hash);
    
    if (stats.errors.length > 0) {
      this.sockWrite(sockets, \'errors\', stats.errors);
    } else {//没有报错发送ok
      this.sockWrite(sockets, \'ok\');
    }
  }
  ...
  //使用sockjs在浏览器端和服务端之间建立一个 websocket 长连接
  listen(port, hostname, fn) {...}
}

这里仍然是Server.js中的代码,我详细的写展示了setupHooks中的代码,setupHooks 调用 webpack api 监听 compile的 done 事件,当编译完成,执行done钩子,调用_sendStats,在_sendStats方法中如果文件变化则发送hash。最后发送ok,客户端在接受到OK后会执行reload。

client端源码

客户端socket接受到hash后保存起来,随后接受到ok执行reload命令。

//Client/index.js
var onSocketMessage = {
  ...
  hash: function hash(_hash) {
    //将hash保存到全局currentHash中
    status.currentHash = _hash; 
  },
  ok: function ok() {
    ...
  	//执行更新的reloadApp函数
    reloadApp(options, status); 
  },
  ...
};
socket(socketUrl, onSocketMessage);
//Client/utils/reloadApp.js
function reloadApp(_ref, _ref2) {
  if (hot) {
    //hotEmitter是events类,webpack-dev-server发布webpackHotUpdate给webapck
    var hotEmitter = require(\'webpack/hot/emitter\');
    hotEmitter.emit(\'webpackHotUpdate\', currentHash);

    if (typeof self !== \'undefined\' && self.window) {
      // broadcast update to window
      self.postMessage("webpackHotUpdate".concat(currentHash), \'*\');
    }
  } 
}

客户端接收到ok指令后,执行reloadApp函数。reloadApp函数中,hotEmitter其实是events模块的实例,即在全局实现发布订阅模式,hotEmitter发布了webpackHotUpdate事件,同时webpack订阅这个指令。

在这里以后,浏览器端进入webpack的代码,webpack-dev-server在客户端的部分完成。

订阅webpackHotUpdate事件的代码在webpack/hot/dev-server.js中:

if (module.hot) {
	var lastHash;
	var check = function check() {
		module.hot
			.check(true)
			.then(function (updatedModules) {
				//检查所有要更新的模块,如果没有模块要更新那么回调函数就是null
				if (!updatedModules) {
					window.location.reload();
					return;
				}
				if (!upToDate()) {//如果还有更新
					check();
				}
			})
	};
	var hotEmitter = require("./emitter");
	hotEmitter.on("webpackHotUpdate", function (currentHash) {
		lastHash = currentHash;
		check(); //调用check方法
	});
}

module为全局对象,module.hot的代码在HMR runtime中,module.hot.check对应hotCheck方法:

hotCheck = () => { //module.hot.check方法
	return hotDownloadManifest.then((update) => { 
	    //保存全局的热更新信息
        hotAvailableFilesMap = update.c;
        hotUpdateNewHash = update.h;
	    /*globals chunkId */
        hotEnsureUpdateChunk(chunkId)
    })
}
hotDownloadManifest(){ //ajax请求模块manifest
    return new Promise(...);
}
hotEnsureUpdateChunk(){ //检测模块
    if (!hotAvailableFilesMap[chunkId]) {...} else 
    {
        hotRequestedFilesMap[chunkId] = true;
        hotDownloadUpdateChunk();
    }
}
hotDownloadUpdateChunk(){} //jsonp格式请求代码模块chunk

//chunk是js代码块,格式是webpackHotUpdate("main", {...}),收到后直接执行,window全局中有对应方法
window["webpackHotUpdate"]=function webpackHotUpdateCallback(){
    hotAddUpdateChunk()
}
hotAddUpdateChunk(){//动态的更新代码模块
    for (var moduleId in moreModules) {
        //记录全局的热更新模块
        hotUpdate[moduleId] = moreModules[moduleId];
    }
    hotUpdateDownloaded()
}
hotUpdateDownloaded(){ //执行hotApply模块
	hotApply()
}
hotApply(){
    //将代码更新到modules中
}

主要包含了两个请求,在hotDownloadManifest中客户端请求了ajax的manifest,他的格式为 {"h":"bbff25e45ca71af784d0","c":{"main":true}} 包含了要更新模块的hash值和chunk名;另一个hotDownloadUpdateChunk通过jsonp方法请求更新的代码块,
webpack那些事:浅入深出-源码解析构建优化
获取到的代码块可以直接执行,webpack已经在window中注册了webpackHotUpdate方法,执行后调用hotApply热模块替换方法。

function hotApply(options) {
    function getAffectedStuff(updateModuleId) {
        ...
        return { //返回过期的模块和依赖
            type: "accepted",
            moduleId: updateModuleId,
            outdatedModules: outdatedModules,
            outdatedDependencies: outdatedDependencies
        };
    }
    ...
                result = getAffectedStuff(moduleId);
    ...
        {
            switch (result.type) {
                case "self-declined":
                    ...
                    break;
                case "accepted"://对结果进行标记及处理
                    if (options.onAccepted) options.onAccepted(result);
                    doApply = true; 
                    break;
                case "disposed":
                    ...
                    break;
                default:
                    ...
            }
    ...
    while (queue.length > 0) {
        moduleId = queue.pop();
        ...
        delete installedModules[moduleId];//删除过期的模块和依赖
        delete outdatedDependencies[moduleId];
    }
    ...
    for (moduleId in appliedUpdate) { 
        if (Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) {
            //新的模块添加到modules中
            modules[moduleId] = appliedUpdate[moduleId];
        }
    }
    ...
}

模块热替换主要分三个部分,首先是找出 outdatedModules 和 outdatedDependencies;然后从缓存中删除这些;最后,将新的模块添加到 modules 中,当下次调用 webpack_require (webpack 重写的 require 方法)方法的时候,就是获取到了新的模块代码了。

如果在热更新过程中出现错误,热更新将回退到刷新浏览器。

当用新的模块代码替换老的模块后,但是我们的业务代码并不能知道代码已经发生变化,也就是说,当入口文件修改后,我们需要在入口文件中调用 HMR 的 accept 方法

// index.js
if(module.hot) {
    module.hot.accept(\'./main.js\', function() {
        render()
    })
}

更新的代码每次在下面这个循环中执行, cb(moduleOutdatedDependencies)
就是module.hot.accept的内容,从而实现对代码的渲染

function hotApply(options) {
    ...
    for (moduleId in outdatedDependencies) {
        ...
        moduleOutdatedDependencies = outdatedDependencies[moduleId];
        var callbacks = [];
        for (i = 0; i < moduleOutdatedDependencies.length; i++) {
            dependency = moduleOutdatedDependencies[i];
            cb = module.hot._acceptedDependencies[dependency];
            callbacks.push(cb); //获取所有的模块
        }
        for (i = 0; i < callbacks.length; i++) {
            cb = callbacks[i];
            cb(moduleOutdatedDependencies);//执行代码模块
        }
        ...
    }
    ...
}

手写webpack构建工具

手写webpack流程

webpack那些事:浅入深出-源码解析构建优化

AST

AST(Abstract Syntax Tree)

抽象语法树,源代码语法结构的一种抽象表示

  • 以树状的形式表现编程语言的语法结构
  • 树上的每个节点都表示源代码中的一种结构

webpack那些事:浅入深出-源码解析构建优化

AST生成过程

抽象语法树的生成主要依靠的是Javascript Parser(js解析器)

  • 词法分析(Lexical Analysis)
  • 语法分析(Parse Analysis)

webpack那些事:浅入深出-源码解析构建优化

在手写webpack中使用

通过Visitor完成依赖的收集

访问者(visitor)是一个用于 AST 遍历的跨语言模式,定义 了用于在一个树状结构中获取具体节点的方法

webpack那些事:浅入深出-源码解析构建优化

树的宽度优先搜索(BFS)算法思想

应用于循环分析依赖

webpack那些事:浅入深出-源码解析构建优化

树的宽度优先搜索(BFS)算法思想

循环分析结果

webpack那些事:浅入深出-源码解析构建优化

打包结果为一个IIFE

webpack那些事:浅入深出-源码解析构建优化

打包结果分析

webpack那些事:浅入深出-源码解析构建优化

结果运行分析

webpack那些事:浅入深出-源码解析构建优化

webpack构建优化

背景

如今前端工程化的概念早已经深入人心,选择一款合适的编译和资源管理工具已经成为了所有前端工程中的标配,而在诸多的构建工具中,webpack以其丰富的功能和灵活的配置而深受业内吹捧,逐步取代了grunt和gulp成为大多数前端工程实践中的首选,React,Vue,Angular等诸多知名项目也都相继选用其作为官方构建工具,极受业内追捧。但是,随者工程开发的复杂程度和代码规模不断地增加,webpack暴露出来的各种性能问题也愈发明显,极大的影响着开发过程中的体验。

webpack那些事:浅入深出-源码解析构建优化

问题归纳

历经了多个web项目的实战检验,我们对webapck在构建中逐步暴露出来的性能问题归纳主要有如下几个方面:

代码全量构建速度过慢,即使是很小的改动,也要等待长时间才能查看到更新与编译后的结果(引入HMR热更新后有明显改进);
随着项目业务的复杂度增加,工程模块的体积也会急剧增大,构建后的模块通常要以M为单位计算;
多个项目之间共用基础资源存在重复打包,基础库代码复用率不高;
node的单进程实现在耗cpu计算型loader中表现不佳;
针对以上的问题,我们来看看怎样利用webpack现有的一些机制和第三方扩展插件来逐个击破。

慢在何处

作为工程师,我们一直鼓励要理性思考,用数据和事实说话,“我觉得很慢”,“太卡了”,“太大了”之类的表述难免显得太笼统和太抽象,那么我们不妨从如下几个方面来着手进行分析:

webpack那些事:浅入深出-源码解析构建优化

从项目结构着手,代码组织是否合理,依赖使用是否合理;
从webpack自身提供的优化手段着手,看看哪些api未做优化配置;
从webpack自身的不足着手,做有针对性的扩展优化,进一步提升效率;
在这里我们推荐使用一个wepback的可视化资源分析工具:webpack-bundle-analyzer,在webpack构建的时候会自动帮你计算出各个模块在你的项目工程中的依赖与分布情况,方便做更精确的资源依赖和引用的分析。

从上图中我们不难发现大多数的工程项目中,依赖库的体积永远是大头,通常体积可以占据整个工程项目的7-9成,而且在每次开发过程中也会重新读取和编译对应的依赖资源,这其实是很大的的资源开销浪费,而且对编译结果影响微乎其微,毕竟在实际业务开发中,我们很少会去主动修改第三方库中的源码,改进方案如下:

方案一、合理配置 CommonsChunkPlugin

webpack的资源入口通常是以entry为单元进行编译提取,那么当多entry共存的时候,CommonsChunkPlugin的作用就会发挥出来,对所有依赖的chunk进行公共部分的提取,但是在这里可能很多人会误认为抽取公共部分指的是能抽取某个代码片段,其实并非如此,它是以module为单位进行提取。

假设我们的页面中存在entry1,entry2,entry3三个入口,这些入口中可能都会引用如utils,loadash,fetch等这些通用模块,那么就可以考虑对这部分的共用部分机提取。通常提取方式有如下四种实现:

1、传入字符串参数,由chunkplugin自动计算提取

new webpack.optimize.CommonsChunkPlugin('common.js')

这种做法默认会把所有入口节点的公共代码提取出来, 生成一个common.js

2、有选择的提取公共代码

new webpack.optimize.CommonsChunkPlugin('common.js',['entry1','entry2']);

只提取entry1节点和entry2中的共用部分模块, 生成一个common.js

3、将entry下所有的模块的公共部分(可指定引用次数)提取到一个通用的chunk中

new webpack.optimize.CommonsChunkPlugin({
    name: 'vendors',
    minChunks: function (module, count) {
       return (
          module.resource &&
          /\.js$/.test(module.resource) &&
          module.resource.indexOf(
            path.join(__dirname, '../node_modules')
          ) === 0
       )
    }
});

提取所有node_modules中的模块至vendors中,也可以指定minChunks中的最小引用数;

4、抽取enry中的一些lib抽取到vendors中

entry = {
    vendors: ['fetch', 'loadash']
};
new webpack.optimize.CommonsChunkPlugin({
    name: "vendors",
    minChunks: Infinity
});

添加一个entry名叫为vendors,并把vendors设置为所需要的资源库,CommonsChunk会自动提取指定库至vendors中。

方案二、通过 externals 配置来提取常用库

在实际项目开发过程中,我们并不需要实时调试各种库的源码,这时候就可以考虑使用external选项了。

webpack那些事:浅入深出-源码解析构建优化

简单来说external就是把我们的依赖资源声明为一个外部依赖,然后通过script外链脚本引入。这也是我们早期页面开发中资源引入的一种翻版,只是通过配置后可以告知webapck遇到此类变量名时就可以不用解析和编译至模块的内部文件中,而改用从外部变量中读取,这样能极大的提升编译速度,同时也能更好的利用CDN来实现缓存。

external的配置相对比较简单,只需要完成如下三步:

1、在页面中加入需要引入的lib地址,如下:

<head>
<script src="//cdn.bootcss.com/jquery.min.js"></script>
<script src="//cdn.bootcss.com/underscore.min.js"></script>
<script src="/static/common/react.min.js"></script>
<script src="/static/common/react-dom.js"></script>
<script src="/static/common/react-router.js"></script>
<script src="/static/common/immutable.js"></script>
</head>

2、在webapck.config.js中加入external配置项:

module.export = {
    externals: {
        'react-router': {
            amd: 'react-router',
            root: 'ReactRouter',
            commonjs: 'react-router',
            commonjs2: 'react-router'
        },
        react: {
            amd: 'react',
            root: 'React',
            commonjs: 'react',
            commonjs2: 'react'
        },
        'react-dom': {
            amd: 'react-dom',
            root: 'ReactDOM',
            commonjs: 'react-dom',
            commonjs2: 'react-dom'
        }
    }
}

这里要提到的一个细节是:此类文件在配置前,构建这些资源包时需要采用amd/commonjs/cmd相关的模块化进行兼容封装,即打包好的库已经是umd模式包装过的,如在node_modules/react-router中我们可以看到umd/ReactRouter.js之类的文件,只有这样webpack中的requireimport * from 'xxxx'才能正确读到该类包的引用,在这类js的头部一般也能看到如下字样:

if (typeof exports === ‘object’ && typeof module === ‘object’) {
module.exports = factory(require(“react”));
} else if (typeof define === ‘function’ && define.amd) {
define([“react”], factory);
} else if (typeof exports === ‘object’) {
exports[“ReactRouter”] = factory(require(“react”));
} else {
root[“ReactRouter”] = factory(root[“React”]);
}

3、非常重要的是一定要在output选项中加入如下一句话:

output: {
  libraryTarget: 'umd'
}

由于通过external提取过的js模块是不会被记录到webapckchunk信息中,通过libraryTarget可告知我们构建出来的业务模块,当读到了externals中的key时,需要以umd的方式去获取资源名,否则会有出现找不到module的情况。

通过配置后,我们可以看到对应的资源信息已经可以在浏览器的source map中读到了。

webpack那些事:浅入深出-源码解析构建优化

对应的资源也可以直接由页面外链载入,有效地减小了资源包的体积。

webpack那些事:浅入深出-源码解析构建优化

方案三、利用 DllPlugin 和 DllReferencePlugin 预编译资源模块

我们的项目依赖中通常会引用大量的npm包,而这些包在正常的开发过程中并不会进行修改,但是在每一次构建过程中却需要反复的将其解析,如何来规避此类损耗呢?这两个插件就是干这个用的。

简单来说DllPlugin的作用是预先编译一些模块,而DllReferencePlugin则是把这些预先编译好的模块引用起来。这边需要注意的是DllPlugin必须要在DllReferencePlugin执行前先执行一次,dll这个概念应该也是借鉴了windows程序开发中的dll文件的设计理念。

相对于externals,dllPlugin有如下几点优势:

  • dll预编译出来的模块可以作为静态资源链接库可被重复使用,尤其适合多个项目之间的资源共享,如同一个站点pc和手机版等;
  • dll资源能有效地解决资源循环依赖的问题,部分依赖库如:react-addons-css-transition-group这种原先从react核心库中抽取的资源包,整个代码只有一句话:
module.exports = require('react/lib/ReactCSSTransitionGroup');

却因为重新指向了react/lib中,这也会导致在通过externals引入的资源只能识别react,寻址解析react/lib则会出现无法被正确索引的情况。

  • 由于externals的配置项需要对每个依赖库进行逐个定制,所以每次增加一个组件都需要手动修改,略微繁琐,而通过dllPlugin则能完全通过配置读取,减少维护的成本;

1、配置dllPlugin对应资源表并编译文件

那么externals该如何使用呢,其实只需要增加一个配置文件:webpack.dll.config.js

const webpack = require('webpack');
const path = require('path');
const isDebug = process.env.NODE_ENV === 'development';
const outputPath = isDebug ? path.join(__dirname, '../common/debug') : path.join(__dirname, '../common/dist');
const fileName = '[name].js';

// 资源依赖包,提前编译
const lib = [
  'react',
  'react-dom',
  'react-router',
  'history',
  'react-addons-pure-render-mixin',
  'react-addons-css-transition-group',
  'redux',
  'react-redux',
  'react-router-redux',
  'redux-actions',
  'redux-thunk',
  'immutable',
  'whatwg-fetch',
  'byted-people-react-select',
  'byted-people-reqwest'
];

const plugin = [
  new webpack.DllPlugin({
    /**
     * path
     * 定义 manifest 文件生成的位置
     * [name]的部分由entry的名字替换
     */
    path: path.join(outputPath, 'manifest.json'),
    /**
     * name
     * dll bundle 输出到那个全局变量上
     * 和 output.library 一样即可。
     */
    name: '[name]',
    context: __dirname
  }),
  new webpack.optimize.OccurenceOrderPlugin()
];

if (!isDebug) {
  plugin.push(
    new webpack.DefinePlugin({
      'process.env.NODE_ENV': JSON.stringify('production')
    }),
    new webpack.optimize.UglifyJsPlugin({
      mangle: {
        except: ['$', 'exports', 'require']
      },
      compress: { warnings: false },
      output: { comments: false }
    })
  )
}

module.exports = {
  devtool: '#source-map',
  entry: {
    lib: lib
  },
  output: {
    path: outputPath,
    filename: fileName,
    /**
     * output.library
     * 将会定义为 window.${output.library}
     * 在这次的例子中,将会定义为`window.vendor_library`
     */
    library: '[name]',
    libraryTarget: 'umd',
    umdNamedDefine: true
  },
  plugins: plugin
};

然后执行命令:

$ NODE_ENV=development webpack --config  webpack.dll.lib.js --progress
$ NODE_ENV=production webpack --config  webpack.dll.lib.js --progress 

即可分别编译出支持调试版和生产环境中lib静态资源库,在构建出来的文件中我们也可以看到会自动生成如下资源:

common
├── debug
 │   ├── lib.js
 │   ├── lib.js.map
 │   └── manifest.json
└── dist
    ├── lib.js
    ├── lib.js.map
    └── manifest.json

文件说明:

lib.js可以作为编译好的静态资源文件直接在页面中通过src链接引入,与externals的资源引入方式一样,生产与开发环境可以通过类似charles之类的代理转发工具来做路由替换;
manifest.json中保存了webpack中的预编译信息,这样等于提前拿到了依赖库中的chunk信息,在实际开发过程中就无需要进行重复编译;

2、dllPlugin的静态资源引入

lib.js和manifest.json存在一一对应的关系,所以我们在调用的过程也许遵循这个原则,如当前处于开发阶段,对应我们可以引入common/debug文件夹下的lib.js和manifest.json,切换到生产环境的时候则需要引入common/dist下的资源进行对应操作,这里考虑到手动切换和维护的成本,我们推荐使用add-asset-html-webpack-plugin进行依赖资源的注入,可得到如下结果:

<head>
<script src="/static/common/lib.js"></script>
</head>
在webpack.config.js文件中增加如下代码:

const isDebug = (process.env.NODE_ENV === 'development');
const libPath = isDebug ? '../dll/lib/manifest.json' : 
'../dll/dist/lib/manifest.json';

// 将mainfest.json添加到webpack的构建中

module.export = {
  plugins: [
       new webpack.DllReferencePlugin({
       context: __dirname,
       manifest: require(libPath),
      })
  ]
}

配置完成后我们能发现对应的资源包已经完成了纯业务模块的提取

webpack那些事:浅入深出-源码解析构建优化

多个工程之间如果需要使用共同的lib资源,也只需要引入对应的lib.js和manifest.js即可,plugin配置中也支持多个webpack.DllReferencePlugin同时引入使用,如下:

module.export = {
  plugins: [
     new webpack.DllReferencePlugin({
        context: __dirname,
        manifest: require(libPath),
      }),
      new webpack.DllReferencePlugin({
        context: __dirname,
        manifest: require(ChartsPath),
      })
  ]

方案四、使用 Happypack 加速你的代码构建

以上介绍均为针对webpack中的chunk计算和编译内容的优化与改进,对资源的实际体积改进上也较为明显,那么除此之外,我们能否针对资源的编译过程和速度优化上做些尝试呢?

众所周知,webpack中为了方便各种资源和类型的加载,设计了以loader加载器的形式读取资源,但是受限于node的编程模型影响,所有的loader虽然以async的形式来并发调用,但是还是运行在单个 node的进程以及在同一个事件循环中,这就直接导致了当我们需要同时读取多个loader文件资源时,比如babel-loader需要transform各种jsx,es6的资源文件。在这种同步计算同时需要大量耗费cpu运算的过程中,node的单进程模型就无优势了,那么happypack就针对解决此类问题而生。

开启happypack的线程池

happypack的处理思路是将原有的webpack对loader的执行过程从单一进程的形式扩展多进程模式,原本的流程保持不变,这样可以在不修改原有配置的基础上来完成对编译过程的优化,具体配置如下:

 const HappyPack = require('happypack');
 const os = require('os')
 const HappyThreadPool = HappyPack.ThreadPool({ size: os.cpus().length}); // 启动线程池});

module:{
    rules: [
      {
        test: /\.(js|jsx)$/,
        // use: ['babel-loader?cacheDirectory'],
        use: 'happypack/loader?id=jsx',
        exclude: /^node_modules$/
      }
    ]
  },
  plugins:[
    new HappyPack({
     id: 'jsx',
     cache: true,
     threadPool: HappyThreadPool,
     loaders: ['babel-loader']
   })
  ]

我们可以看到通过在loader中配置直接指向happypack提供的loader,对于文件实际匹配的处理 loader,则是通过配置在plugin属性来传递说明,这里happypack提供的loader与plugin的衔接匹配,则是通过id=happybabel来完成。配置完成后,laoder的工作模式就转变成了如下所示:

webpack那些事:浅入深出-源码解析构建优化

happypack在编译过程中除了利用多进程的模式加速编译,还同时开启了cache计算,能充分利用缓存读取构建文件,对构建的速度提升也是非常明显的,经过测试,最终的构建速度提升如下:

优化前:

webpack那些事:浅入深出-源码解析构建优化

优化后:

webpack那些事:浅入深出-源码解析构建优化

关于happyoack的更多介绍可以查看:

happypack

happypack 原理解析

方案五、增强 uglifyPlugin

uglifyJS凭借基于node开发,压缩比例高,使用方便等诸多优点已经成为了js压缩工具中的首选,但是我们在webpack的构建中观察发现,当webpack build进度走到80%前后时,会发生很长一段时间的停滞,经测试对比发现这一过程正是uglfiyJS在对我们的output中的bunlde部分进行压缩耗时过长导致,针对这块我们可以使用webpack-uglify-parallel来提升压缩速度。

从插件源码中可以看到,webpack-uglify-parallel的是实现原理是采用了多核并行压缩的方式来提升我们的压缩速度。

plugin.nextWorker().send({
    input: input,
    inputSourceMap: inputSourceMap,
    file: file,
    options: options
});

plugin._queue_len++;
                
if (!plugin._queue_len) {
    callback();
}               

if (this.workers.length < this.maxWorkers) {
    var worker = fork(__dirname + '/lib/worker');
    worker.on('message', this.onWorkerMessage.bind(this));
    worker.on('error', this.onWorkerError.bind(this));
    this.workers.push(worker);
}

this._next_worker++;
return this.workers[this._next_worker % this.maxWorkers];

使用配置也非常简单,只需要将我们原来webpack中自带的uglifyPlugin配置:

new webpack.optimize.UglifyJsPlugin({
   exclude:/\.min\.js$/
   mangle:true,
   compress: { warnings: false },
   output: { comments: false }
})
修改成如下代码即可:

const os = require('os');
    const UglifyJsParallelPlugin = require('webpack-uglify-parallel');
    
    new UglifyJsParallelPlugin({
      workers: os.cpus().length,
      mangle: true,
      compressor: {
        warnings: false,
        drop_console: true,
        drop_debugger: true
       }
    })

目前webpack官方也维护了一个支持多核压缩的UglifyJs插件:uglifyjs-webpack-plugin,使用方式类似,优势在于完全兼容webpack.optimize.UglifyJsPlugin中的配置,可以通过uglifyOptions写入,因此也做为推荐使用,参考配置如下:

 const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
  new UglifyJsPlugin({
    uglifyOptions: {
      ie8: false,
      ecma: 8,
      mangle: true,
      output: { comments: false },
      compress: { warnings: false }
    },
    sourceMap: false,
    cache: true,
    parallel: os.cpus().length * 2
  })

方案六、Tree-shaking & Scope Hoisting

wepback在2.X和3.X中从rolluo中借鉴了tree-shaking和Scope Hoisting,利用es6的module特性,利用AST对所有引用的模块和方法做了静态分析,从而能有效地剔除项目中的没有引用到的方法,并将相关方法调用归纳到了独立的webpack_module中,对打包构建的体积优化也较为明显,但是前提是所有的模块写法必须使用ES6 Module进行实现,具体配置参考如下:

 // .babelrc: 通过配置减少没有引用到的方法
  {
    "presets": [
      ["env", {
        "targets": {
          "browsers": ["last 2 versions", "safari >= 7"]
        }
      }],
      // https://www.zhihu.com/question/41922432
      ["es2015", {"modules": false}]  // tree-shaking
    ]
  }

  // webpack.config: Scope Hoisting
  {
    plugins:[
      // https://zhuanlan.zhihu.com/p/27980441
      new webpack.optimize.ModuleConcatenationPlugin()
    ]
  }

适用场景

在实际的开发过程中,可灵活地选择适合自身业务场景的优化手段。

优化手段 开发环境 生产环境
CommonsChunk
externals
DllPlugin
Happypack
uglify-parallel
上一篇:spring笔记01


下一篇:全国800创新创业代表共讨双创升级,“科技创新创业高峰论坛”在全国双创周举行