webpack是怎样分析、整理、优化代码的呢?
用了几年vue/cli,也就磕磕绊绊地学了几年的webpack,每次都是兴致勃勃的拿起来,又悄无声息地放回去,是什么导致自己总是黑瞎子掰苞米,掰一棒,扔一棒呢,到最后好像还是什么都没学呢?
每次都是从基础配置开始,entry、output、module、plugins开始,走着走着就被淹没在版本的兼容路上,被各种冒出来的error和warning给搞得心烦意乱。
究其本质,webpack实现原理一窍不通,__webpack_require方法到底是干啥的,一看就绕进去了。
这次呢,从一个一直被忽略的地方开始,一个webpack官网上列举的基础学习项目开始,研究一下webpack的最基础实现,是否能够从下往上带来一些触动和启发。
一个webpack官网项目
我们有个实例应用目录如下:
如上目录,我们将要通过三个方法组成的极简打包工具来分析示例项目目录example下的三个文件,得到相应的应用内文件依赖图,从而处理后得到简单webpack打包代码,本示例讲解webpack官网的起步项目:一个简单打包工具的详细说明
createAsset函数
大家都知道,webpack的核心是构建应用中各个文件之间的依赖关系,构建出整个应用所有模块(webpack中一个文件就是一个模块)的依赖图。那么webpack是怎么分析我们代码的呢,它怎么就知道我们的代码中有哪些外部模块依赖呢。
有两种方法:
(1)用字符串匹配,也就是用前端常用的模块引入关键字import、require等来查找匹配相应代码,项目注释里说它是一个笨拙的漂亮方法,确实是,而且我竟然在自己的低代码项目这么用了,欲哭无泪啊!!!
(2)用babylon来解析我们的代码,也可以加上一些选项,以便满足我们的需求,比如有如下文件entry.js
import message from './message.js';
console.log(message);
我们想解析出这段代码中的模块引用关系,我们可以这么做,在我们的minipack.js中写入如下代码:
const babylon = require('babylon');
const content = fs.readFileSync(filename, 'utf-8');
const ast = babylon.parse(content, {
sourceType: 'module',
});
得到了一个如下结构,(由于篇幅有限,只粘贴部分出来)
{
type: 'File',
start: 0,
end: 62,
loc: SourceLocation {
start: Position { line: 1, column: 0 },
end: Position { line: 4, column: 0 }
},
program: Node {
type: 'Program',
start: 0,
end: 62,
loc: SourceLocation { start: [Position], end: [Position] },
sourceType: 'module',
body: [ [Node], [Node] ],
directives: []
},
comments: [],
tokens: [
Token {
type: [KeywordTokenType],
value: 'import',
start: 0,
end: 6,
loc: [SourceLocation]
},
Token {
type: [TokenType],
value: 'message',
start: 7,
end: 14,
loc: [SourceLocation]
},
]
}
上面这种结构是通过分析javascript代码得到的,这种结构叫做AST(abstract syntax tree),通过这种结果可以帮我们更好的分析js代码中都依赖了哪些模块,使用了哪些import语句,大家如果对AST感兴趣的话可以查看AST Explorer,了解AST特性。
当我们得到AST之后,遍历这个AST来获取import依赖,因为js的ES6规定了我们的import只能静态地引入,不可以引入一个变量,或者是有条件地引入,也就是不能在if else中引入,这样当我们在任何时候发现import 声明时,都可以把这个声明当做一个外部依赖。
想要遍历这个AST,需要借助外部工具babel-traverse,代码如下:
const traverse = require('babel-traverse').default;
const dependencies = []
traverse(ast, {
ImportDeclaration: ({ node }) => {
dependencies.push(node.source.value);
},
});
通过遍历AST得到的依赖数组如下:
[ './message.js' ]
通过AST得到了依赖,这时候还有一个问题,为了让我们的代码能够兼容更多的浏览器,我们使用babel来转换代码,以便可以在大多数的浏览器中正常运行,可以登录babel 官网学习babel相关知识。用babel转译代码时,需要为参数presets传入正确的选项,这样babel就可以按照我们的指定的规则来转译代码,这里我们使用babel-preset-env来指定规则。
代码如下:
const { code } = transformFromAst(ast, null, {
presets: ['env'],
});
得到entry.js代码如下:
'"use strict";\n' +
'\n' +
'var _message = require("./message.js");\n' +
'\n' +
'var _message2 = _interopRequireDefault(_message);\n' +
'\n' +
'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n' +
'\n' +
'console.log(_message2.default);'
上面三部分可以得到一个模块中外部依赖,转译后的代码,还有filename,这里还需要唯一的标识符,这里需要设置一个全局变量ID来自增运算,用来为我们的模块计数。
为这个方法取个名字createAsset,createAsset帮助我们分析一个模块内部的依赖项,和转译模块代码。就得到了如下方法
const fs = require('fs');
const path = require('path');
const babylon = require('babylon');
const traverse = require('babel-traverse').default;
const { transformFromAst } = require('babel-core');
let ID = 0;
function createAsset(filename) {
const ast = babylon.parse(content, {
sourceType: 'module',
});
traverse(ast, {
ImportDeclaration: ({ node }) => {
// We push the value that we import into the dependencies array.
dependencies.push(node.source.value);
},
});
const id = ID++;
const { code } = transformFromAst(ast, null, {
presets: ['env'],
});
return {
id,
filename,
dependencies,
code,
};
}
createGraph函数
有了createAsset方法,就可以解析一个模块,并得到我们需要的外部依赖关系和模块代码。那么我们要从入口文件,把应用涉及的所有模块都解析,从而生成相应的依赖图,该怎样做呢?
这时候我们的createGraph就该上场了,用它来创建应用的完整依赖图时,需要使用两个循环:
第一个循环
-
因为我们构建依赖图是从入口模块开始,那么我们迭代模块的路径是个树形,那么该怎样设计这个循环呢?首先使用队列,通过for of循环这个队列,把通过createAsset解析入口文件得到的对象作为数组的第一个元素
-
那么开始循环这个队列后,是怎样实现树形迭代的呢,这个会在第一个循环内的第二个循环做相应处理,当一个模块有外部依赖模块时,我们就把模块的外部依赖模块也放进队列中,这样相当于把复杂的依赖图逐步放在一个数组中来循环,知道所有依赖关系结束。
第二个循环
-
这个循环在第一个循环内,因为从入口文件起,每个模块的外部依赖模块就可能不止一个模块,所以本循环就是把一个模块的外部依赖都遍历一遍,并且通过createAsset方法得到模块内容后,将模块对象放入第一个循环的队列中,这样第一个循环就会一直到所有模块依赖都遍历一遍后停下,
-
并且本循环还会为createAsset方法得到的对象新增一个属性mapping,用来记录每个模块外部依赖路径和模块id的映射关系。
代码如下:
function createGraph(entry) {
const mainAsset = createAsset(entry);
const queue = [mainAsset];
// 第一循环 遍历入口文件作为队列的第一个元素,直到完成所有依赖树
for (const asset of queue) {
asset.mapping = {};
const dirname = path.dirname(asset.filename);
// 第二循环 遍历模块的所有外部依赖
asset.dependencies.forEach((relativePath) => {
const absolutePath = path.join(dirname, relativePath);
// createAsset获得的依赖路径是相对的 而它所需参数必须是 绝对路径+文件名
const child = createAsset(absolutePath);
//记录每个外部依赖所对应的模块ID
asset.mapping[relativePath] = child.id;
queue.push(child);
});
}
return queue;
}
bundle函数
bundle英文里是打包的意思,把一些文件经过处理优化,转化为另一些我们需要的文件。
bundle函数以我们创建的依赖图为工作路径来处理应用模块,把应用打包成一个自调用的函数体,如(function(){})().这个函数只接受一个参数,一个包含依赖图中所有模块信息的object,通过模块ID与每个模块信息映射。用包含两个元素的数组表示每个模块的信息。当然这里构建出来的模块结构都是字符串,如下所示:
modules += `${mod.id}: [
function (require, module, exports) {
${mod.code}
},
${JSON.stringify(mod.mapping)},
],`
如上结构就是我们遍历依赖图中每个模块时,把每个模块的信息放到函数wrapper中,这样我们最终包含所有模块信息的对象就组合完成,用每个模块的id与每个每个模块数组相对应。
可以看到每个数组模块中有两个元素,第一个包含模块代码的函数wrapper,这样我们的代码放在一个函数中,相当于放在了局部作用域中,避免污染其他代码中的变量,或是全局变量。
我们的代码都经过了babel的转译,转化成了commenJS的运行方式,他们正常工作需要有三个变量:require、module、exports,它们不能正常在浏览器使用,所以需要用一个函数来包裹起来。
数组第二个元素是模块依赖的相对路径与模块id的映射,类似这种结构:{ './relative/path': 1 }。
我们转译后的代码,通过require方法来使用,require使用需要每个依赖的相对路径,这样我们的mapping就派上了用场,帮助我们知道每个模块依赖和它所依赖的模块id的对应关系。
现在,轮到最后一个组合结构上场:自调用函数主体,代码如下:
const result = `
(function(modules) {
function require(id) {
const [fn, mapping] = modules[id];
function localRequire(name) {
return require(mapping[name]);
}
const module = { exports : {} };
fn(localRequire, module, module.exports);
return module.exports;
}
require(0);
})({${modules}})
`
我们通过创建一个require函数开始,这个函数接收模块id作为参数,用来在模块object——modules中查找所需模块。每个模块信息包含两部分,第一个是包含模块代码的函数,第二个是模块外部依赖相对路径与模块id的mapping。
为什么要用id作为key与包含两个模块信息的数组做映射来构建模块信息对象呢?模块代码通过require来引用外部依赖,靠相对路径来辨别所需模块位置,而我们定义的模块信息对象需要id来查找模块信息,再通过查找第二个元素所包含的依赖路径与id映射,则可以找到相应模块代码,这样当有多个依赖使用同一个模块时,也就是多个依赖路径应用同一个id时,我们只需要在代码中一个模块id和对应的代码即可,就达到了复用模块的目的,可以帮助减少优化我们的模块对象结构。
为了实现依赖和id的分离,在require函数内部定义了localRequire函数,输入依赖可以得到对应模块,把localRequire当做参数传递给包裹代码的wrapper函数,这样我们的自调用函数从require(0)就可以运行webpack转译后的代码了。
最后,当代码运行在commonJs规则时,当一个模块通过require被引用时,module可以暴露出已经被修改过的exports对象。这个exports对象已经被自定义的require函数的代码所修改,当引用时就会被返回给引用模块。
bundle函数完整代码如下:
function bundle(graph) {
let modules = '';
graph.forEach((mod) => {
modules += `${mod.id}: [
function (require, module, exports) {
${mod.code}
},
${JSON.stringify(mod.mapping)},
],`;
});
const result = `
(function(modules) {
function require(id) {
const [fn, mapping] = modules[id];
function localRequire(name) {
return require(mapping[name]);
}
const module = { exports : {} };
fn(localRequire, module, module.exports);
return module.exports;
}
require(0);
})({${modules}})
`;
return result;
}
本文为学习webpack官网-基础概念内推荐入门项目的一个说明介绍,如有不准确之处请留言指教,感谢!