从模块学习到深入webpack源码
一、webpack简介
什么是webpack
一个javascript模块打包工具,其核心功能就是解决模块间的依赖关系,将各个模块按照特定的规则和顺序组织起来,最终合并成一个JS文件(可能有多个,大部分是一个)
使用webpack的意义
javascript语言没有模块之说,javascript语言之父-Brendan当初在设计这门语言的时候就没有考虑到用它来实现今天这么复杂的场景;那为了解决复杂的业务场景,引入多个文件到页面中变成一种常态,但这带来的问题也是巨大的
- 需要手动维护各个文件的加载顺序,引入到一个页面的多个文件通常来说是具有依赖关系的,而这种依赖关系是隐性的,我们只能通过注释来说明各个文件的依赖关系
- 每一个script标签都意味着需要向服务器请求一次资源,在http2之前,建立连接的成本是相当到高的,而且过多的请求也会导致页面运行速度变慢进而影响用户体验
- 声明在文件顶部的变量都默认成为全局变量,如果每个文件中都有这样的全局变量,那么就会造成命名冲突的问题,而且全局变量也会带来环境污染的问题
模块化则解决了上述所有问题
- 通过导入导出可以清楚的知道各个文件的依赖关系
- 可以通过打包工具将多个文件合并成一个文件,减少网络请求
- 每个模块都有自己的作用域,各模块的命名不会有冲突
二、模块及打包原理
项目中常用的模块就是CommonJS和ES6 Module,这篇文章就只讨论这两种模块及它们之间的区别 ####CommonJS
在CommonJS中,通过module.exports可以导出模块的内容,如
module.exports = {
name: 'xiaomai',
add: function(a, b) {
return a + b
}
}
CommonJS内部会有一个module对象用于存放当前模块的信息,可以理解为模块在代码最开始就定义了对象moduleconst module = {...}
,CommonJS同时也支持另外一种导出方式,即直接使用exports
exports.name = 'xiaomai';
exports.add = function(a, b) {
return a + b;
}
在实现上exports和module.exports没有任何不同,其机制就是将exports指向了module.exports,可以简单的认为,在每个模块的首部添加了以下代码
const module = {
exports: {}
}
const exports = module.exports;
不过在使用exports的时候需要注意一个问题,就是不要直接给exports直接赋值,否则会导致其失效,如:
exports = {
name: 'xiaomai'
}
上面的代码中,给exports进行了赋值,因为这个时候module.exports还是个空对象,这样导致的问题就是name属性并不会被导出 另外需要注意的地方就是应该要避免将export和module.exports混用,如
exports.add = function(a, b) {
return a + b;
}
module.exports = {
name: 'xiaomai'
}
上面的代码中,首先使用exports将add属性导出了,然后再将modele.exports重新赋值为另外一个对象,而这个对象并没有add属性,这会导致原本有add属性的对象丢失了,结果就只会导出name属性
在CommonJS中使用require进行模块导入,如:
// util.js
module.exports = {
add: function(a, b) {
return a + b;
}
}
// index.js
const Util = require('./util.js');
console.log(Util.add(2, 3)); // 5
当使用require导入模块的时候,会有两种情况:
- require的模块是第一次被加载,然后首先执行该模块内容,并导出该模块内容
- require的模块曾经被加载过,这时模块的代码不会再执行,而是直接导出上次执行的结果
// util.js
console.log('running in util.js');
module.exports = {
name: 'xiaomai',
add: function(a, b) {
return a + b;
}
}
// index.js
const Util = require('./util.js');
console.log('sum:', Util.add(2, 3));
const moduleName = require('./util.js').name;
console.log('end');
控制台的输出结果为:
running in util.js
sum: 5
end
从结果可以看到,虽然util.js模块被导入了两次,但其内部代码只执行了一次;其机制为模块内部的module会有一个属性loaded,用于记录模块是否被导出过, 默认为false,当模块第一次被加载切被执行之后该值将会变成true,再从加载时检查到module.loaded值为true,则不会再执行代码
ES6 Module
在ES6 Module中用export导出模块,export有两种形式:一种为命名导出,另外一种为默认导出 一个模块有多个命名导出,它有两种写法:
// 写法1
export const name = 'xiaomai';
export const add = function(a, b) {
return a + b;
}
// 写法2
export {
name: 'xiaomai',
add: function(a, b) {
return a + b;
}
}
在使用命名导出时,可以使用关键字as对变量名重新命名
const name = 'xiaomai';
const add = function(a, b) {
return a + b;
}
export { name, add as getSum };
与命名导出不同,默认导出只能有一个,可以将export default理解为对外输出了一个default对象,因此不能向命名导出一样对变量名进行重命名
export default {
name: 'xiaomai',
add: function(a, b) {
return a + b;
}
}
ES6 Module中用import导入模块,可以对导入的变量进行重命名
// util.js
export const name = 'xiaomai';
export const add = function() {};
// index.js
import { name, add as getSum } from './util.js';
CommonJS和ES6 Module的区别
- 动态与静态 CommonJS和ES6 Module最本质的区别在于前者对模块的依赖是“动态的”,而后者是“静态的”,这里“动态”的含义是,模块依赖的关系建立在代码运行阶段,而“静态”则是模块的依赖建立在代码编译阶段。例:
// CommonJS
// util.js
module.exports = {
name : 'xiaomai';
}
//index.js
const name = require('./util.js').name;
// ES6 Module
// util.js
export cosnt name = 'xiaomai';
//index.js
import { name } from './util.js'
在CommonJS中,require中的语句可以动态指定,支持传入一个表达式,甚至可以使用if语句来判断是否需要加载某个模块,因此,在代码执行前,并没有办法确定明确的依赖关系,模块的导入、导出发生在代码的运行阶段。 而在ES6 Module中,模块的导入、导出都是声明式的,不支持传入表达式,导入、导出语句也必须放在顶层作用域(比如不能放在if语句中),所以在代码运行之前就可以确定模块的依赖关系。 ES6 Module相比CommonJS来说,有以下几个优点:
- 死代码检查。比如在引入工具类库时,可能只用到了其中某些接口,但实际上引入了工具的全部类库,那未被使用的模块永远都不会被执行,也就成了死代码,通过静态分析工具可以将这部分代码剔除,以减少打包资源
- 模块变量类型检查。Javascript属于动态类型的语言,不会在代码执行前检查变量类型的错误。ES6 Module的静态模块有助于确保模块之间传递的值或者接口类型是正确的
- 编译器优化。CommonJS导入/导出的是一个对象,而在ES6 Module中支持导出/导入某个变量,减少了引用层级,程序效率更高
- 值拷贝与动态映射 在导入一个模块的时候,CommonJS获取的是一份导出值的拷贝,而ES6 Module则是值的动态映射,并且这个值是只读的,举个例子来说明一下
// util.js
let count = 0;
module.exports = {
count: count,
add: function(a, b) {
count += 1;
return a + b;
}
}
// index.js
let count = require('./util.js').count;
const add = require('./util.js').add;
console.log(count); // 0,这里的count是对util.js中count值的拷贝
add(2, 3);
console.log(count); // 0, util.js中count值的改变不会影响到index.js
count += 1;
console.log(count); // 1, 值可以被改变
index.js中的count是对util.js中count的一份拷贝,因此在调用add函数是,虽然改变了原本util.js中的值,但不会对index.js中的 副本count造成影响。 另一方面,在CommonJS中允许对导出值进行更改,这些操作也不会影响到util.js本身
用ES6 Module改写已上代码
// util.js
let count = 0;
const add = function(a, b) {
count += 1;
return a, b;
}
exports { count, add };
// index.js
import { count, add } from './util.js';
console.log(count); // 0, 是util.js中count值的映射
add(2, 3);
console.log(count); // 1
count += 1; // 不可改变,会报错
上面的例子中反映出ES6 Module导入的变量其实是对原有值的动态映射,当我们调用add方法对count进行修改的时候,index.js中的值也跟着改变了;在ES6 Module中,是不可以对导入值进行修改的,可以理解为我们可以从镜子里面看到原有实物,但不可以操纵镜子里的影像;
- 循环依赖 循环依赖就是模块A依赖模块B,同时模块B又依赖A;这种简单的依赖关系还是很容易被发现的,但实际情况可能是模块A依赖模块B,模块B依赖模块C,模块C依赖模块D,饶了一大圈,模块D又依赖模块A;举个例子看一下在CommonJS中的循环依赖
// a.js
const b = require('./b.js');
console.log('value of b:', b);
module.exports = "this is a.js";
// b.js
const a = require('./a.js');
console.log('value of a:', a);
module.exports = "this is b.js";
// index.js
require('./a.js');
我们期望在控制台输出:
value of a: this is a.js
value of b: this.is b.js
但实际上输出的是
value of a: {}
value of b: this is b.js
为什么会输出已上结果,我们理一下代码的执行顺序 1)在index.js中导入了a.js,此时开始执行a.js的代码 2)在a.js中的第一行导入了b.js,此时执行权交给b.js,开始执行b.js的代码 3)在b.js中又循环依赖了a.js,而此时a.js并没有执行完毕,也不会将执行权交给a.js,而是直接获取其导出值;也就是module.exports,但由于a.js并没有执行完毕,所以此时module.exports的值为空对象{}; 4)b.js执行完毕,执行权交给a.js 5)a.js继续往下执行,取出b.js的导出值,控制台打印value of b: this is b.js,程序结束
通用用ES6 Module的方式运行已上代码,控制台将会输出
value of a: undefined
valie of b: this is b.js
为什么第一行输出会是undefined呢?其原因就在于在ES6 Module中并不会在代码开始默认声明module.exports = {},当执行到第三步的时候,其导出值自然就是undefined;
上文说到在ES6 Module的导出/导入是动态映射,那么是否可以利用这一特性解决循环依赖呢?看个例子
// index.js
import a from './a';
a('index.js');
// a.js
import b from './b';
function a(invoker) {
console.log(invoker + ' invokes a.js');
b('a.js');
}
export default a;
// b.js
import a from './a';
let invoked = false;
function b(invoker) {
// invoked变量的作用是避免程序进入死循环
if (!invoked) {
invoked = true;
console.log(invoker + ' invokes b.js');
a('b.js');
}
}
export default b;
控制台输出的结果为:
index.js invokes a.js
a.js invokes b.js
b.js invokes a.js
可以看到,a.js和b.js这一对循环依赖的模块都获取到了正确的值,下面分析一下代码的执行过程 1)在index.js中导入了a.js,此时开始执行a.js的代码 2)在a.js中的第一行导入了b.js,此时执行权交给b.js,开始执行b.js的代码 3)在b.js中一直执行到程序结束,完成b函数的定义,此时a的值还是undefined 4)b.js执行完毕之后执行权又回到a.js,在a.js中一直执行到程序结束,完成a函数的定义,由于ES6 Module的动态映射,此时在b.js中a的值已经从undefined变成了我们定义的函数,这里就是ES6 Module解决循环依赖的本质 5)执行权最后回到index.js,并调用a函数,此时会依次执行函数a-b-a,并在控制台打印正确的值
打包原理
// util.js
export {
name: 'xiaomai'
}
// index.js
const { name } from './util.js';
以上代码经过webpack打包之后将会成为如下形式
(function(){
let installedModules = {};
function __webpack__require__(moduleId) {
// some code
};
return __webpack__require__(__webpack__require__.s = 0);
})({
'0': function(module, exports, __webpack__require__) {
module.exports = __webpack__require__('3qiv')
},
'3qiv': function(module, exports, __webpack__require__) {
// index.js的内容
},
'jaxx': function(module, exports) {
// util.js的内容
},
})
这是一个最简单的webpack打包结果,生成一个bundle,但已经可以清晰地知道webpac是如何将具有依赖关系的模块串联在一起的,其内部流程主要为以下几个步骤。
1)最外层的匿名函数将会做一些初始化工作,包括定义installedModules对象、__webpack__require__
函数等,为模块的加载做一些准备工作
2)加载入口模块,每个bundle有且只有一个入口模块,在上面的例子中,index.js文件就是入口模块
3)执行模块代码,如果执行到了module.exports则记录其导出值,第二次再加载的时候直接使用缓存;如果遇到了__webpack__require__
函数,则会暂时交出执行权,进入__webpack__require__
函数体内执行其他模块的代码
4)重复第三步
5)当所有的模块都已经执行完毕,执行权又会重新回到入口模块,直到程序结束;不难看出,第三步和第四步其实是个递归的过程,这里就是webpack打包的奥秘;
webpack源码解读
webpack编译的主要流程其实就是:找到入口文件并初始化配置参数——构建——打包——生成打包后的代码——完成;
处理入口文件的配置参数
在命令行输出webpack的时候,首先会进入webpack/lib/webpack.js文件,执行webpack方法 webpack方法有两个参数,options和callback,options就是从webpack.config.js文件读取到的配置,callback就是编译完成之后的回调函数
webpack/lib/webpack.js (代码有删减)
function webpack(options, callback) {
// 校验参数是否正确
const webpackOptionsValidationErrors = validateSchema(webpackOptionsSchema, options);
let compiler;
if(Array.isArray(options)) {
// 判断参数类型,如果是数组,则生成一个MultiCompiler
compiler = new MultiCompiler(options.map(options => webpack(options)));
} else if(typeof options === "object") {
// 生成默认配置,就是说找不到webpack.config.js的时候,webpack会有一份默认配置
new WebpackOptionsDefaulter().process(optionss);
compiler = new Compiler();
if(options.plugins && Array.isArray(options.plugins)) {
// 注册options上的外部插件
compiler.apply.apply(compiler, options.plugins);
}
// 注册内部插件
compiler.options = new WebpackOptionsApply().process(options, compiler);
}
if(callback) {
// ...
compiler.run(callback);
}
return compiler;
}
看webpack源码,其实就是一堆plugin文件,在编译的过程中,就是依赖类似于发布订阅的模式按照流程执行这一系列plugin
看一下注册内部插件的代码new WebpackOptionsApply().process(options, compiler);
,在WebpackOptionsApply.js
内部有这么一个插件EntryOptionPlugin
,这里就是注册内部插件的逻辑
EntryOptionPlugin.js(代码有删减)
function itemToPlugin(context, item, name) {
if(Array.isArray(item)) {
return new MultiEntryPlugin(context, item, name);
}
return new SingleEntryPlugin(context, item, name);
}
module.exports = class EntryOptionPlugin {
apply(compiler) {
compiler.plugin("entry-option", (context, entry) => {
// 字符串 || 数组
if(typeof entry === "string" || Array.isArray(entry)) {
compiler.apply(itemToPlugin(context, entry, "main"));
} else if(typeof entry === "object") {
// 对象配置参数
Object.keys(entry).forEach(name => compiler.apply(itemToPlugin(context, entry[name], name)));
} else if(typeof entry === "function") {
compiler.apply(new DynamicEntryPlugin(context, entry));
}
return true;
});
}
};
EntryOptionPlugin处理逻辑非常简洁,该函数的作用就是判断webpack options中的entry是什么类型,然后根据不同的类型使用不同的entryPlugin
总结一下处理配置参数(第一阶段)的主要流程: 1)校验配置文件的正确性 2)然后通过New Compiler生成一个compiler实例,这个compiler代表了完整的webpack环境 3)注册内外部插件,便于后面流程的使用
构建
从执行webpack函数里的compiler.run(callback),webpack进入构建阶段; 进入构建流程,首先会执行compiler.readRecords方法,这个方法用来获取编译记录的结果,如果没有的话就直接返回;执行readRecords方法之后会进入compile方法,这个方法是webpack构建过程中最主要的方法 compile方法代码(代码有删减)
compile(callback) {
// 首先会创建Compilation参数
const params = this.newCompilationParams();
self.applyPluginsAsync("before-compile", params, function(err) {
// 触发compile钩子函数
this.applyPlugins("compile", params);
// 生成compilation实例(每构建一次都会生成一个compilation),compilation实例用来存储构建过程中的出现的实体
const compilation = this.newCompilation(params);
// 触发make钩子函数,给compilation对象设置入口文件
this.applyPluginsParallel("make", compilation, err => {
if(err) return callback(err);
// 构建完成
compilation.finish();
// 开始进入打包流程
compilation.seal(err => {
// some code
});
});
});
}
在创建Compilation实例的时候会执行this.newCompilation(params)方法,在这个方法里面会触发compilation钩子函数,而在第一阶段,webpack在注册内部插件的时候,在EntryOptionPlugin中有注册SingleEntryPlugin,DynamicEntryPlugin,MultiEntryPlugin这几个插件,这几个插件都有在compilation钩子函数被触发的时候对compilation的dependencyFactories进行了赋值, 而近一步触发make钩子函数的时候会将入口文件加到compilation对象上, 看一下SingleEntryPlugin.js文件 SingleEntryPlugin方法代码(代码有删减)
class SingleEntryPlugin {
apply(compiler) {
compiler.plugin("compilation", (compilation, params) => {
const normalModuleFactory = params.normalModuleFactory;
// 关键代码
compilation.dependencyFactories.set(SingleEntryDependency, normalModuleFactory);
});
compiler.plugin("make", (compilation, callback) => {
const dep = SingleEntryPlugin.createDependency(this.entry, this.name);
// 关键代码
compilation.addEntry(this.context, dep, this.name, callback);
});
}
}
addEntry方法里回调用__addModuleChain,该函数主要作用是用来解析entry文件,解析完成之后调用buileModule方法,使用acorn生成抽象语法树(AST),遍历AST遇到require时,将依赖加入到依赖数组,这个过程可能会递归执行;所有模块都build完成之后,webpack会进入打包流程,构建流程可用下面流程图表示
打包
从执行compile函数里的compilation.seal方法进入打包流程,seal方法是webpack打包流程的主要方法,打包这一流程做的事情就是根据编译阶段输出的模块,将这些模块打包成成一个文件,(对于单入口文件,每个入口文件把自己所依赖的资源全部打包到一起,即使一个资源循环加载的话,也只会打包一份;对于多入口文件的情况,分别独立执行单个入口的情况,每个入口文件各不相干) seal函数首先会调用addChunk()方法,针对webpack的config配置文件里面的entry进行遍历,对每个入口文件都进行处理,生成一个新的chunk compilation.seal方法(代码有删减)
for (const preparedEntrypoint of this._preparedEntrypoints) {
const module = preparedEntrypoint.module;
const name = preparedEntrypoint.name;
const chunk = this.addChunk(name);
chunk.entryModule = module;
chunk.name = name;
// 以模块module根节点的模块依赖树中的节点分配各自的深度值
this.assignDepth(module);
// 生成modulesID,每个模块都有自己的模块ID
this.applyModuleIds();
}
生成
经过compilation.seal方法之后,compilation的任务算是完成了,完成之后回到compiler主流程中执行 this.emitAssets
方法,一次生成打包文件
this.emitAssets(compilation, err => {
if (err) return finalCallback(err);
this.emitRecords(err => {
if (err) return finalCallback(err);
const stats = new Stats(compilation);
this.hooks.done.callAsync(stats, err => {
if (err) return finalCallback(err);
return finalCallback(null, stats);
});
});
});
};
完成
执行玩生成文件之后流程就结束了,最后生成统计文件
self.applyPlugins("done", stats);
self.applyPluginsAsync("additional-pass", function(err) {
if(err) return callback(err);
self.compile(onCompiled);
});
最后引用网上一张图来更加直观的了解webpack的工作流程