带你探究webpack究竟是如何解析打包模块语法的

1,940 阅读11分钟

前期准备

在webpack中,我们发现配置我们能天然的使用esmodule这种模块化语法,那大家有没有好奇过呢?他究竟是怎么实现的呢?下面一起来探究一下,webpack究竟是怎么解析打包esmodule语法的。

在研究之前,我们需要有一定的node的基础知识,应为我们如果想要实现webpack类似的功能,那么,我们必须要借助node的一些模块,比如path模块、比如fs模块,等,这些都是node的基础模块 接下来,我们还需要babel的一些模块,给我们做一些转化比如babel/parser模块、比如**@babel/traverse模块**、在比如babel/core模块等等,接下来,我们分别介绍一下用到的这些模块

模块介绍

path

NodeJS中的Path对象,用于处理目录的对象,提高开发效率

我们在配置webpack的时候也经常用到,他的常见用法就是我们的目录转换比如:

//引入进来
const path = require('path');
//拼接这些链接
console.log(path.join('/Users','node/path','../','join.js'));

fs

fs模块可以对文件进行一些读写操作

我们在webpack 中由于要转义语法,所以对文件的读写必不可少,使用方式也非常简单

//引入模块
const fs = require('fs');
//读取文件,readFileSync指的是同步读取文件,filename指的是文件路径,第二个参数指的是格式
const content = fs.readFileSync(filename, 'utf-8');

babel/parser

babel/parser是babel的一个模块,它能帮我们分析代码,并且转换长AST也就是抽象语法树

使用方式也非常简单

//引入进来
const parser = require('@babel/parser');
//解析成抽象语法树 第一个参数表示我们的代码,第二个参数是一系列配置sourceType 表示是哪种语法
const ast = parser.parse(content, {
		sourceType: 'module'
	});

babel/traverse

babel/traverse能根据抽象语法树中的信息解析出代码中的依赖关系,从而可以解析出整个esmodule的代码

使用方式也非常简单

//引入模块 
const traverse = require('@babel/traverse').default;
//第一个参数接受抽象语法树,
//第二个参数是个对象,配置的是我们需要找出的依赖关系的配置
	traverse(ast, {
		ImportDeclaration({ node }) {
		}
	});

babel/core

babel的核心模块,可以给我我们的代码转成浏览器的可以识别的代码

使用方式也不是那么难

//引入模块
const babel = require('@babel/core');
//使用transformFormAst方法
//第一个参数为ast
//最后一个参数是转换规则,转换成啥
const code = babel.transformFromAst(ast, null, {
		presets: ["@babel/preset-env"]
	});

babel/preset-env

babel/preset-env 我们是不是很熟悉,如果我们经常配置webpack的话我们会在.babelrc中配置上这一段东西,其实他就是告诉我们使用哪种规则去转化我们的es6语法,

脚手架搭建

首先我们要新建一个webpack一样的目录,里面有src有index.js的入口文件,只不过不同的是我们需要新建一个webpack.js去代替webpack目录接口如下:

探究原理

前期准备工作完成,接下来,我们开始手撸一个解析打包模块化语法的webpack

1、找到入口文件,解析入口文件语法

首先我们需要找到入口文件解析出入口文件的js语法

//引入node模块
const fs = require('fs');
const path = require('path');
//引入babel模块
const parser = require('@babel/parser');
//创建方法
const webpack=(filename)=>{
//拿到js入口文件中的内容
const content=fs.readFileSync(filename,'utf-8')
//打印内容
console.log(content)
//使用parser的parse解析成ast语法树
const ast = parser.parse(content, {
    sourceType: 'module'
});
//打印抽象语法树
console.log(ast)
}
webpack('./src/index.js')

上述代码中,我们可以拿到ast抽象语法树,我们先开看看长什么样子

我们惊喜的发现,他其实就是用一个对象去描述js语句,以及js的依赖关系,你又会说了,他的代码和依赖关系在哪呢?我们找到program下的body看一看

接下来你会发现一个醒目的value是不是找到了我们的依赖关系,而下面这个就是我们的console这个表达式了

接下来我们就要去拿到依赖关系了,应该怎么处理呢?


//使用parser的parse解析成ast语法树
const ast = parser.parse(content, {
    sourceType: 'module'
});
//打印抽象语法树
// console.log(ast.program.body)
//创建存放解析完依赖关系的对象
const dependencies = {};
//使用traverse梳理依赖关系并且解析到对象中
traverse(ast, {
    //对象参数中,由于需要找到依赖关系放入对象,所以只需要ImportDeclaration类型的回调即可
    ImportDeclaration({ node }) {
        //使用node的path模块,取出当前的文件的路径目录
        const dirname = path.dirname(filename); 
        //拼接处相工程文件根目录下的路径
        const newFile = './' + path.join(dirname, node.source.value);
        //拿到路径存入对象中
        dependencies[node.source.value] = newFile;
    }
});
console.log(dependencies)

其实也很简单,我们只需要引用babel的模块后,在回调中稍微处理一下,便可拿到,打印结果如下

如此,我们便拿到了抽象对应的依赖关系路径,但是拿到依赖关系还不够,我们现在的代码已经被转换成抽象语法树了,那么我们浏览器没办法运行啊,这时我们需要用babel的一个核心模块,给抽象语法树转换成浏览器的可执行代码,如此依赖,我们便成功了一半,来看代码

//使用babel 的core模块的transformFromAst方法,给抽象语法树转换成我们浏览器可执行的代码
const { code } = babel.transformFromAst(ast, null, {
    presets: ["@babel/preset-env"]
});

他转换后的效果图如下

是不是很像我们平常使用webpack打包之后的代码了,至于中间的这些传参,在开始时我已经介绍过了,这样一来我们简单的打包其实就已经可以使用了,但是,模块间依赖的代码应该怎么处理呢?

2、解析依赖代码,完成整个项目打包

我们在编写上方的webpck的方法时,我们发现他除了解析入口的代码,其实各个依赖的代码也能用同样的套路解析出来,并且存放在一个地方,于是我们就得给他变成一个通用方法,并且在加入一个函数去在这个函数中递归调用解析方法,解析出依赖文件,存入数组中保存,这样我们就能拿到所有的转换后的文件了。好废话少说,开干

const DependenceMap=(entry)=>{
    //首先这个方法中去解析入口文件的语法
    const entryModule = webpack(entry);
    //将解析后的对象存入数组中
    const graphArray = [ entryModule ];
    //遍历数组,递归解析当前数组中的依赖关系
    //注意:数组长度不是固定的为graphArray.length
	for(let i = 0; i < graphArray.length; i++) {
        //拿到数组中的每一项
        const item = graphArray[i];
        //拿到依赖当前解析对象中的dependencies就是依赖的每一项
		const { dependencies } = item;
		if(dependencies) {
            //for in 去遍历对象
			for(let j in dependencies) {
                //再次解析当前文件的依赖文件,然后压入数组
                //注意这块就是数组长度graphArray.length的妙用
                //可以完全的去解析出来所有的文件
				graphArray.push(
					webpack(dependencies[j])
				);
			}
		}
	}
}

我们单独定义一个方法,去解析所有的依赖关系并且存入数组中,其中使用循环次数为数组的长度的妙用,来解析出来整个依赖图谱,如下图我们发现,所有的依赖关系全部在这一个数组中了

上图中我们发现,这跟我们webpack打包后的传入的依赖代码不一样啊,他好像是个对象,并不是一个数组,接下来我们来转化一下,废话少说,上代码:

 //创建一个存转化后的代码的空对象
    const graph = {};
    //遍历数组
	graphArray.forEach(item => {
        //将每一项转换成对象的形式
		graph[item.filename] = {
			dependencies: item.dependencies,
			code: item.code
		}
    });
    console.log(graph)

如上图,这样就和我们webpack中的形式一样了

3、打包生成合并依赖图谱,合并成浏览器可运行的代码

在上面两个步骤中,我们我们通过两个方法,拿到了最终左右的解析后的代码,我们在来一个方法,去初期最终生成的代码,直接上代码

const generateCode=(entry)=>{
    //由于我们要返回一段代码段,所以必须用字符串的方式去返回
    const graph = JSON.stringify(DependenceMap(entry));
    //需要避免全局污染,必须用闭包的形式,去处理
    //我们在看解析完成之后的代码段发现,他有require的语法,于是我们在导出的时候需要自己模拟一个类似的方法,防止报错
    return `
    
    (function(graph){
        //浏览器模拟require方法
        function require(module) { 
            //由于转换后的代码中执行require的时候,他是根据相对路径去执行的
            //但是我们的依赖对象中的key值是一个绝对路径
            //于是我们需要去写一个转换方法
            function localRequire(relativePath) {
                return require(graph[module].dependencies[relativePath]);
            }
            //由于是模拟require方法,我们还需要一个exports导出对象
            var exports = {};
            //在加入一个闭包,防止印象外部已经定义的变量
            (function(require, exports, code){
              //执行代码
                eval(code)
            })(localRequire, exports, graph[module].code);
            return exports;
        };
        //执行require语法
        require('${entry}')
    })(${graph});
    `
}

上边代码中,我们发现,我们通过一个自定义的require语法就能实现,整个依赖图谱的代码执行,并且不会污染全局环境,我们来看一下导出的结果

上图的代码中我们是不是就发现和webpack导出的代码非常像啊,接下来我们给我们调用fs的写入文件方法,给代码写入js文件中即可,我们便不再赘述。

最后

首先附上完成代码

//引入node模块
const fs = require('fs');
const path = require('path');
//引入babel模块
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const babel = require('@babel/core');
//创建方法
const webpack=(filename)=>{
//拿到js入口文件中的内容
const content=fs.readFileSync(filename,'utf-8')
//打印内容
console.log(content)
//使用parser的parse解析成ast语法树
const ast = parser.parse(content, {
    sourceType: 'module'
});
//打印抽象语法树
// console.log(ast.program.body)
//创建存放解析完依赖关系的对象
const dependencies = {};
//使用traverse梳理依赖关系并且解析到对象中
traverse(ast, {
    //对象参数中,由于需要找到依赖关系放入对象,所以只需要ImportDeclaration类型的回调即可
    ImportDeclaration({ node }) {
        //使用node的path模块,取出当前的文件的路径目录
        const dirname = path.dirname(filename); 
        //拼接处相工程文件根目录下的路径
        const newFile = './' + path.join(dirname, node.source.value);
        //拿到路径存入对象中
        dependencies[node.source.value] = newFile;
    }
});
//使用babel 的core模块的transformFromAst方法,给抽象语法树转换成我们浏览器可执行的代码
const { code } = babel.transformFromAst(ast, null, {
    presets: ["@babel/preset-env"]
});
//导出用到的信息
return {
    filename,
    dependencies,
    code
}
}
const DependenceMap=(entry)=>{
    //首先这个方法中去解析入口文件的语法
    const entryModule = webpack(entry);
    //将解析后的对象存入数组中
    const graphArray = [ entryModule ];
    //遍历数组,递归解析当前数组中的依赖关系
    //注意:数组长度不是固定的为graphArray.length
	for(let i = 0; i < graphArray.length; i++) {
        //拿到数组中的每一项
        const item = graphArray[i];
        //拿到依赖当前解析对象中的dependencies就是依赖的每一项
		const { dependencies } = item;
		if(dependencies) {
            //for in 去遍历对象
			for(let j in dependencies) {
                //再次解析当前文件的依赖文件,然后压入数组
                //注意这块就是数组长度graphArray.length的妙用
                //可以完全的去解析出来所有的文件
				graphArray.push(
					webpack(dependencies[j])
				);
			}
		}
    }
    //console.log(graphArray)
    //创建一个存转化后的代码的空对象
    const graph = {};
    //遍历数组
	graphArray.forEach(item => {
        //将每一项转换成对象的形式
		graph[item.filename] = {
			dependencies: item.dependencies,
			code: item.code
		}
    });
    // console.log(graph)
	 return graph;
}
const generateCode=(entry)=>{
    //由于我们要返回一段代码段,所以必须用字符串的方式去返回
    const graph = JSON.stringify(DependenceMap(entry));
    //需要避免全局污染,必须用闭包的形式,去处理
    //我们在看解析完成之后的代码段发现,他有require的语法,于是我们在导出的时候需要自己模拟一个类似的方法,防止报错
    return `
    
    (function(graph){
        //浏览器模拟require方法
        function require(module) { 
            //由于转换后的代码中执行require的时候,他是根据相对路径去执行的
            //但是我们的依赖对象中的key值是一个绝对路径
            //于是我们需要去写一个转换方法
            function localRequire(relativePath) {
                return require(graph[module].dependencies[relativePath]);
            }
            //由于是模拟require方法,我们还需要一个exports导出对象
            var exports = {};
            //在加入一个闭包,防止印象外部已经定义的变量
            (function(require, exports, code){
              //执行代码
                eval(code)
            })(localRequire, exports, graph[module].code);
            return exports;
        };
        //执行require语法
        require('${entry}')
    })(${graph});
    `
}

const code=generateCode('./src/index.js')
 console.log(code)

当我们完整的看完了一个es模块的打包流程之后,相信大家已经了然于胸,反正我研究完了之后解决了之前的很多困惑,而且当我们掌握了完整的流程之后,对webpack的原理基本也掌握了7、8成了,其实webpack就是在中间我们转换代码的过程中多加了一点lorder,和plugins,从而实现了强大的功能。这样如果想去大厂的你,是不是心中又多了一点信心!

结束,再次感谢巨人dell lee,站在巨人的肩膀上真好!