webpack 解密-webpack原理分析?

什么是网页包?

webpack是一个静态资源的模块打包工具。 通过分析模块之间的依赖关系,打包一个或多个bundle文件。

问题是要思考什么是模块化,js中的模块化包含哪些内容? webpack是如何分析模块之间的依赖关系并最终打包的呢? 包装中的bundle和chunk有什么区别? loader和plugin在打包过程中的作用是什么? 后知识ES6、CommonJS、AMD、CMD等一些模块化设计规范,AST句子树设计步骤有哪些优缺点查找入口文件,解析入口文件,提取依赖关系,递归创建文件间依赖图,并描述所有文件之间的依赖关系将所有文件打包成一个文件场景假设(自动实现一个webpack)

场景1

场景关系依赖分析

index.js(条目)-> person.js(依赖项)-> name.js(依赖项)

按照设计步骤,自动实现了myWebpack,文件名为myWebpack.js,与src同目录第一步:找到入口文件

const fs = require("fs");
// 封装读取文件内容方法
function readContent(filename) {
	const fileContent = fs.readFileSync(filename, "utf-8");
	return fileContent;
}
// 1. 找到入口文件
const content = readContent("./src/index.js");

步骤2:这里需要对ast句子树有一定的了解。 简单来说,它是源代码的另一种表达方式。 只有以对象的形式描述内容,才能清楚地表达文件的内容。 依赖关系的在线 ast 转换生成器

观察发现,好像各种属性代表各种js代码(这个过程包括词法分析、句子分析等)

// 2. 解析入口文件内容,生成AST语法树(这里通过插件@babel/parser先转义成ast语法树)
const astTree = require("@babel/parser").parse(content, {
	// parse in strict mode and allow module declarations
	sourceType: "module",
	plugins: [
		// enable jsx and flow syntax
		"jsx",
		"flow",
	],
});
console.log("astTree", astTree);

3、第3步:深度遍历AST句子树webpack 解密,获取entry.js依赖

// 3. babel-traverse 作用是像遍历对象一样 对 AST 进行遍历转译,得到新的 AST(通过astTree中的astTree->program Node -> body Node -> ImportDeclaration Node -> source -> value )
const dependencies = [];
const deepTraverseAstTree = require("babel-traverse").default(astTree, {
	// 需要遍历语法树中的属性
	ImportDeclaration: ({ node }) => {
            dependencies.push(node.source.value);
		console.log("node", node);
	},
});

4、第四步:封装获取所有文件依赖的方法,解析入口文件并提取依赖

let id = 0;
function createAsset(filename) {
	const content = readContent(filename);
	const astTree = babylon.parse(content, {
		sourceType: "module",
	});
	const dependencies = [];
	babelTraverse(astTree, {
		// 需要遍历语法树中的属性
		ImportDeclaration: ({ node }) => {
			dependencies.push(node.source.value);
		},
	});
	return {
		id: id++,
		filename,
		dependencies,
	};
}
const mainAsset = createAsset("./src/index.js");
console.log("mainAsset", mainAsset);

步骤5:递归创建文件之间的依赖关系图,需要一个图来表示路径和资源之间的依赖关系

//递归的创建一个文件间的依赖图,需要一个map表示路径和资源的依赖关系
function createGraph(entry) {
	const mainAsset = createAsset(entry);
	// 遍历所有的资源文件
	const allAssets = [mainAsset];
	for (const asset of allAssets) {
		const dirname = path.dirname(asset.filename);
		asset.map = {};
		asset.dependencies.forEach(relativePath => {
			// 转换成绝对路径
			const absolutePath = path.join(dirname, relativePath);
			const childAsset = createAsset(absolutePath);
			asset.map[relativePath] = childAsset.id;
			allAssets.push(childAsset);
		});
	}
	return allAssets;
}
const graph = createGraph("./src/index.js");
console.log("graph", graph);

6、第六步:创建总体结果代码块webpack 解密,需要接收参数并立即执行,因此定义一个自执行函数包,遍历图获取所有模块,而上面的createAsset方法只获取了三个属性id、filename、dependency 只能表示模块之间的依赖关系,但并不能接收到真正的代码,所以需要安装插件通过babel将ast转换为代码

//编译所有代码,获取模块内容,并返回code
const { code } = babel.transformFromAst(astTree, null, {
        presets: ["@babel/preset-env"],
});

7、第七步:用babel编译打包后,可以分析数据,得到模块需求

function(require,module,exports){
  ${module.code}
}

第8步:封装require函数

function require(id){
      const [fn,map] = modules[id];
      function localRequire(relativePath) {
        return require(map[relativePath]);
      }
      const module = {exports: {}}
      fn(localRequire,module,module.exports)
      return module.exports;
 }

步骤9:编译源代码并将编译后的代码添加到结果中

function bundle(graph) {
	let modules = "";
        // 遍历依赖关系图,拿到将每个模块代码,依赖关系进行组装
	graph.forEach(module => {
            modules += `${module.id}: [
                function(require,module,exports){
                  ${module.code}
                },
                ${JSON.stringify(module.map)},
            ],`;
	});
	// 实现require方法,自执行函数,从入口开始引入执行,然后fn(localRequire,module,module.exports)加载执行每个模块diamante
        const result = `
            (function(modules){
             function require(id){
              const [fn,map] = modules[id];
              function localRequire(relativePath) {
                return require(map[relativePath]);
              }
              const module = {exports: {}}
              fn(localRequire,module,module.exports)
              return module.exports;
             }
             require(0)
            })({${modules}})
          `;
        return result;
}

总结

什么是模块化,js中模块化又包含哪些内容? 模块化就像积木一样,彼此独立,并且有自己的作用范围。 通过定义一些套接字和方法来与外界交互,遵循开闭原则。 单一责任原则可以前馈,提高开发效率,提高复用性。 后端模块化程度主要是ES6,CommonJs标准webpack如何分析模块之间的依赖关系?

Chunk是webpack打包过程中模块的集合,是打包过程中的概念。 Bundle是我们最终打包的一个或多个文件。

大多数情况下,块和束之间存在一一对应的关系,也有例外。 如果添加了source-map,一个entry和一个chunk也会形成两个bundle

loader和plugin在打包过程中的作用是什么?

loader是一个文件处理器,将各种非js文件处理成webpack可识别的js和json文件。 本质上,它把所有类型的文件转换成引用程序的依赖图,而可以直接引用的模块扩展插件,就是运行在webpack打包的每个阶段,都会广播自己的骚乱,然后插件会监听相应的骚乱,然后处理一些事情。 项目地址

如今,后端使用Webpack打包JS等文件已经是主流。 再加上Node的流行,后端的工程方法和前端越来越相似。 最后一切都被模块化并统一编译。 由于Webpack版本的不断更新以及各种错综复杂的配置选项,使用中一些令人困惑的错误常常让人感到不知所措。 所以了解Webpack如何组织和编译模块,以及生成的代码如何执行是很有用的,否则它永远是一个黑匣子。 其实我是一个后端新手,最近才开始研究Webpack的原理,所以在这里做一点记录。

编译模块

编译这两个字听起来很黑科技,而且生成的代码往往是一大堆看不懂的东西,所以常常让人感叹,但毕竟上面的核心原理并不难。 所谓Webpack编译只是Webpack在分析你的源代码之后进行一定的更改,然后将所有源代码组织在一个文件中。 最后生成一个大的bundleJS文件webpack原理,由浏览器或其他Javascript引擎执行并返回结果。

这里用一个简单的案例来说明Webpack打包模块的原理。 例如我们有一个模块 mA.js

varaa=1;functiongetDate(){returnnewDate();}module.exports={aa:aa,getDate:getDate}

我随意定义了一个变量aa和一个函数getDate,然后导出。 下面是CommonJS的写法。

然后定义一个app.js作为主文件,一直是CommonJS风格:

varmA=require('./mA.js');console.log('mA.aa='+mA.aa);mA.getDate();

现在我们有两个模块,它们是用Webpack打包的。 入口文件为app.js,依赖于mA.js模块。 Webpack 需要做几件事:

从入口模块app.js开始,分析所有模块的依赖关系,读取所有用到的模块。

每个模块的源代码被组织成立即执行的函数。

重写模块代码中require、export相关的句型,及其对应的引用变量。

只有在最终生成的bundle文件中构建模块管理系统,才能在运行时动态加载所使用的模块。

我们可以看一下前面的反例,Webpack打包的结果。 最终的捆绑文件通常是一个立即执行的大函数。 组织层次比较复杂,大量的名字也比较冗长,所以我在这里重新编写和修改,使其尽可能简单易懂。

首先是列出所有使用的模块,并使用它们的文件名(通常是完整路径)作为 ID 来建表:

varmodules={'./mA.js': generated_mA,'./app.js': generated_app}

关键是里面的generate_xxx是什么? 它是一个函数,将每个模块的源代码包装在其上,使其成为局部作用域,这样内部变量就不会暴露出来,它实际上将每个模块变成了一个执行函数。 它通常是这样定义的:

function generated_module(module,exports,webpack_require){//模块的具体代码。 //...}

这里模块的具体代码指的是生成的代码,Webpack 称之为 generatedcode。 如mA,重写后结果为:

function generated_mA(模块,exports,webpack_require){varaa=1;functiongetDate(){returnnewDate();}module.exports={aa:aa,getDate:getDate}}

乍一看,它与源代码一模一样。 确实,mA 不需要或导入其他模块,并且导出使用传统的 CommonJS 风格,因此生成的代码没有任何变化。 不过值得注意的是最后一个module.exports=...,这里的module是从外部传入的参数module,它实际上告诉我们,当这个函数运行的时候,模块mA的源代码将会是执行了,但是最终需要将export的内容保存到外部,这就标志着mA加载完成,而那种外部的东西其实就是前面提到的模块管理系统。

接下来看app.js的生成代码:

function generated_app(module,exports,webpack_require){varmA_imported_module=webpack_require('./mA.js');console.log('mA.aa='+mA_imported_module['aa']);mA_imported_module['getDate'](); }

可以看到app.js源码中导入模块mA的部分被改动了,因为无论是require/exports还是ES6风格的import/export都不能被JavaScript协程直接执行,它需要依赖模块管理系统将这些具体关键字具体化。 也就是说,webpack_require是require的具体实现,它也可以动态加载模块mA,并将结果返回给app。

至此,你的脑海中可能已经逐渐建立起了一个模块管理体系。 我们看一下webpack_require的实现:

//加载所有模块。 varinstalledModules={};functionwebpack_require(moduleId){//如果模块已经加载,则直接从Cache中读取。 if(installedModules[moduleId]){returninstalledModules[moduleId].exports;}//创建一个新模块并将其添加到installedModules中。 varmodule=installedModules[moduleId]={id:moduleId,exports:{}};//加载模块,即运行模块生成的代码,modules[moduleId].call(module.exports,module,module.导出,webpack_require); 返回模块。 出口; }

请注意,倒数第二句中的模块是我们之前定义的所有模块的生成代码:

varmodules={'./mA.js': generated_mA,'./app.js': generated_app}

webpack_require的逻辑写得很清楚了。 首先检查模块是否已加载,如果已加载,则直接从Cache中返回模块的导出结果。 如果是一个全新的模块,那么对应的数据结构模块就会完善,但是这个模块的generatecode运行的时候,这个函数传入的是我们构建的模块对象和它的exports字段,其实就是exports和exports字段CommonJS 中的模块。 当这个函数运行时,模块被加载,需要导出的结果被保存在模块对象中。

于是我们就看到了所谓的模块管理系统。 虽然原理很简单,但是只要你有耐心去梳理一下,其实并没有什么高深的东西。 它由这三个部分组成:

//所有模块的生成代码 varmodules; //所有已经加载的模块,作为缓存表 varinstalledModules; //加载模块的函数 functionwebpack_require(moduleId);

其实上面所有的代码都被包裹在一个大的匿名函数中,在整个编译好的bundle文件中立即执行,最终返回的是这句话:

return webpack_require('./app.js');

也就是说,当入口模块app.js加载时,之前的所有依赖都会在运行时动态递归加载。 事实上,Webpack 实际生成的代码略有不同,其结构大致是这样的:

(function(modules){variinstalledModules={};functionwebpack_require(moduleId){//...}returnwebpack_require('./app.js');})({'./mA.js': generated_mA,'./ app.js': generated_app});

可以看到,它直接将模块作为立即执行函数的参数传递,而不是单独定义它们。 其实这和里面写的并没有本质的区别。 我重写是为了更清楚地解释它。

ES6导入导出

在上面的例子中,使用了传统的 CommonJS 风格。 现在比较常见的ES6风格使用的是import和export关键字,用法也略有不同。 然而,对于Webpack或其他模块管理系统来说,这个新功能应该只被视为语法糖,它们本质上与require/exports相同,例如export:

exportaa //相当于: module.exports['aa']=aaexportdefaultbb //相当于: module.exports['default']=bb

对于进口:

import{aa}from'./mA.js'//相当于varaa=require('./mA.js')['aa']

比较特别的是这个:

从'./m.js'导入m

情况会复杂一点,它需要加载模块m的defaultexport,而模块m可能不是ES6导出写的,也可能根本没有exportdefault,所以当Webpack为模块生成generatecode时,会判断它不是 ES6 风格的导出。 例如,我们定义模块 mB.js:

letx=3;letprintX=()=>{console.log('x='+x);}export{printX}exportdefaultx

它使用了ES6的export,所以Webpack会在mB的generatecode中添加一句:

function generated_mB(module,exports,webpack_require){Object.defineProperty(module.exports,'__esModule',{value:true});//mB的具体代码//....}

换句话说,它为mB的导出标记了一个__esModule,表明它是ES6风格的导出。 这样,在其他模块中,当以 importmfrom './m.js' 的形式加载依赖模块时,会首先判断是否是 ES6 导出的模块。 如果是webpack原理,则返回其默认值,如果不是,则返回整个导出对象。 比如前面的mA是传统的CommonJS,mB是ES6风格:

//mAisCommonJSmoduleimportmAfrom'./mA.js'console.log(mA);//mBisES6moduleimportmBfrom'./mB.js'console.log(mB);

我们定义 get_export_default 函数:

函数 get_export_default(module){returnmodule&&module.__esModule?module['default']:module;}

这样,生成的代码运行后,在 mA 和 mB 上会得到不同的结果:

varmA_imported_module=webpack_require('./mA.js');//复制完整的mA_imported_module console.log(get_export_default(mA_imported_module));varmB_imported_module=webpack_require('./mB.js');//复制mB_imported_module['default' ] console.log(get_export_default(mB_imported_module));

这是 Webpack 需要对 ES6 导入进行一些特殊处理的地方。 但总的来说,ES6 的 import/export 本质上和 CommonJS 是一样的,只不过 Webpack 生成的代码仍然是基于 CommonJS 的 module/exports 机制来实现模块的加载。

模块管理系统

以上就是Webpack如何打包组织模块并实现运行时模块加载的分析。 虽然其原理并不难,但核心思想是构建模块管理系统,而且这种做法也是通用的。 如果你看过 Node.js 中 Module 部分的源码,似乎也使用了类似的方法。 这里有一篇文章可供参考。