模块化与webpack
在实现一个简单的webpack
之前,我们需要了解为什么需要webpack
等打包器存在。在前端刚开始没有模块化工具的时候,会遇到什么问题呢?
你可能需要把js都依次引入html中。这样做有几个问题
- 多标签引入意味这这么多次的资源请求,对性能是极大的挑战
- 如果资源有依赖关系,那么需要确保资源的引入顺序,可能会出问题
- 文件中的东西不一定都能用到,但是都会进行资源传输,无法通过依赖分析只传输用到的内容
- 如果在两个文件中定义了同名函数会冲突,除非全都给一个命名空间,把函数作为对象方法来使用
<script src="a.js"></script>
<script src="b.js"></script>
<script src="main.js"></script>
复制代码
在这种情况下,模块化方案应运而生。如CommonJS和浏览器端的模块化规范AMD、CMD等。以其中amd规范的require.js来看对这个问题的解决。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<script src="./js/require.js" data-main='js/main'></script>
</body>
</html>
复制代码
main.js
// main.js
console.log('before require')
require(['a', 'b'], function (a, b) {
a.outputa('hello')
b.outputb('hello')
})
console.log('after require')
复制代码
// a.js
define(function () {
function outputa (message) {
console.log(message+'a')
}
return {
outputa: outputa
}
})
复制代码
define(function () {
console.log('define a.js')
function outputa (message) {
console.log(message+'a')
}
return {
outputa: outputa
}
})
复制代码
可以看到依赖关系相对直接引入就清晰了很多,也可以避免命名冲突问题,但仍然无法解决资源多次下载的问题。要知道网络请求是前端优化的瓶颈,页面渲染再快也得资源能加载过来。这是因为cmd/amd
的模块化是相当于在线“编译”的。而webpack
则可以实现预编译,让浏览器只引用打包后的资源。
实现简易版webpack
模块化工具主要都是为了解决本地开发与生产环境对代码的不同需求而出现的。在开发环境我们希望代码是易懂可维护的,也会通过模块划分来提升代码的可复用性。而对于生产环境运行而言资源的请求越少越好,资源的大小越小越好。既然是这样,我们完全可以按照便于开发的方式开发代码在发布到生产环境之前在按照生产环境打包一份用于生产环境的代码而不影响我们的本地开发。
而webpack作为模块打包器,则恰好可以解决我们的这个需求。因此webpack的核心功能是资源打包。那么webpack是如何实现资源打包的呢?
前期准备
首先准备一份需要打包的代码。这里模块暴露的方案分别采用default和对象形式暴露。为了便于理解这里采用一个简单的输出例子。
// main.js
import a from './a.js'
import { outputb } from './b.js'
a('hello')
outputb('hello')
复制代码
// a.js
export default function outputa(message) {
console.log(message+'a')
}
复制代码
// b.js
function outputb(message) {
console.log(message+'bbb')
}
module.exports = {
outputb
}
复制代码
模块分析
因为打包是将浏览器看不懂的语法转换成浏览器可以看懂的语法。可以从入口文件进行分析,生成依赖图谱后将依赖的文件作为模块打包。
模块分析这里需要生成ast语法树,这个步骤我们使用@babel/parser
进行打包。
const parse = require('@babel/parser');
const ast = parse.parse(content, {
sourceType: 'module',
tokens: true
});
复制代码
这里其实也可以使用
Babylon
。因为Babylon
就是Babel
中使用的JavaScript解析器。不过Babylon
已经封仓了,转仓用@babel/parser
继续维护。因此可以将Babylon
看作@babel/parser
前身。使用babylon会默认提供token。我最开始使用Babylon
,不过考虑到仓库不在维护了就转用了@babel/parser
。
const parse = require('babylon');
const content = fs.readFileSync(filename, 'utf-8');
const ast = parse.parse(content, {
sourceType: 'module',
})
复制代码
我们可以打印看下ast
树的结构,可以看到解析出的文件语法树描述了类型、位置、程序信息、注释、标记等信息。
其中token这个部分是标记拆解,这里并非以字母进行逐个拆解,而是以关键字进行拆解。接下来我们看program
可以看到其中body中是类型为ImportDeclaration的语法树。看上去好像也是对我们的代码部分进行拆解,为什么有俩个字段都对代码进行了拆解?
这是因为token是词法分析,拆解了具体的标记组成。而body部分则是语法分析,加入了文法特性。
例如,a = b + c
,我们首先进行词法分析拆解为标签,此时需要五个对象描述这条语句的五个关键字。但是比如说我们解析a的时候,只能记录下值和位置等信息,无法判定其作用。等拆解完了,我们发现这是个赋值语句,分析a的位置发现他是左值。
如果我们想要语法含义进行操作,例如将赋值语句改成判断语句,则应该等到语法分析之后,而非去操作词法树将=直接改成“==”。
依赖收集
还记得刚刚我们说的词法分析和语法分析吗?如果我们要分析依赖这是个语法概念,因此需要用到语法树的遍历。此处我们会用到@babel/traverse
进行语法树遍历。可以看到文档中提供了两种用法
- 在遍历路径中通过断言更新节点
- 选中语法树特定节点类型
此处我们使用节点类型命中节点并进行依赖收集
const traverse = require('@babel/traverse').default;
const dependencies = [];
traverse(ast, {
ImportDeclaration: ({ node }) => {
dependencies.push(node.source.value);
},
});
复制代码
到这里我们通过ast的依赖分析拿到了文件的依赖。
[ './a.js', './b.js' ]
复制代码
语法转化
接下来我们对语法进行转换,这里的选项preset-env将所有ES2015-ES2020代码转换为与ES5兼容。
此处我们使用
transformFromAstSync
进行ast
转换,在es8中将不支持transformFromAst
,因此这里最好显示的指定为同步。
const code = babel.transformFromAstSync(ast, null, {
presets:["@babel/preset-env"]
})
复制代码
到了这里我们已经完成了单个文件的模块分析及依赖图谱生成,接下来怎么做呢?没错就是继续递归入口文件的模块。并将用到的模块都存储到队列中。所以我们将上面解析的函数封装起来。
let globalId = 0;
function createModule(filename) {
const content = fs.readFileSync(filename, 'utf-8');
// 模块分析
const ast = parse.parse(content, {
sourceType: 'module',
});
// 依赖收集
const dependencies = [];
traverse(ast, {
ImportDeclaration: ({ node }) => {
dependencies.push(node.source.value);
},
});
// 语法转换后生成代码
const code = babel.transformFromAstSync(ast, null, {
presets:["@babel/preset-env"]
})
// 暴露模块
return {
id: globalId++,
filename,
dependencies,
code,
};
}
复制代码
生成依赖图谱
接下来进行依赖分析并对依赖进行模块分析:
function createDependenceMap(filename) {
// 创建入口模块
const entryModule = createModule(filename);
// 创建模块队列,并将入口模块放入
const moduleQueue = [entryModule]
for (const module of moduleQueue) {
const dirname = path.dirname(module.filename);
module.map = {}
// 对入口模块的依赖进行模块分析及依赖收集
module.dependence.forEach(dependencePath => {
const absolutePath = path.join(dirname, dependencePath);
// 如果依赖仍有依赖则不断重复该过程,创建子模块
const child = createModule(absolutePath);
// 创建子模块Id与路径的映射关系
module.map[dependencePath] = child.id
// 将依赖模块也放入模块队列
moduleQueue.push(child);
})
console.log(module.map)
}
// 返回模块队列
return moduleQueue
}
复制代码
到了这一步我们完成了从文件到模块的转化
生成代码
因为浏览器是不识别require
、exports
、module
等关键字的。而且实际上我们已经把文件转换成了模块,所以我们只需要把require用刚刚的映射图谱map解释成模块调用就可以了。而exports和module不需要解释,只需要定义空的解释器就行。因为模块暴露已经完成了。所以只需要定义空的模块暴露,让浏览器能够解释exports及moodule即可。
我们创建一个函数,在代码调用时注入依赖
function moduleEnv(filename) {
const [code, map] = modules[filename];
function require(name) {
return moduleEnv(map[name])
}
const module = { exports: {} }
// 为模块注入模块化解释器
code(require, module, module.exports)
return module.exports
}
复制代码
然后我们将模块传入。在传入之前我们需要为执行代码创建环境,将其放在函数中,这样就可以将解释器通过参数注入了。此外我们时基于模块的依赖进行模块引用解释的,因此这里模块依赖映射也是需要的,分别在我们刚刚生成的图谱的code及map上。我们对刚刚的依赖图进行遍历处理
graph.forEach(mod => {
modules += `${mod.id}: [
function (require, module, exports) {
${mod.code}
},
${JSON.stringify(mod.map)},
],`;
});
复制代码
然后我们进行逻辑整合:
const fs = require('fs');
const path = require('path');
const parse = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const babel = require('@babel/core');
let globalId = 0;
function createModule(filename) {
console.log(filename, 'createmodule')
const content = fs.readFileSync(filename, 'utf-8');
// 模块分析
const ast = parse.parse(content, {
sourceType: 'module',
});
// 依赖收集
const dependence = [];
console.log(dependence)
traverse(ast, {
ImportDeclaration: ({ node }) => {
dependence.push(node.source.value);
},
});
// 语法转换后生成代码
const { code }= babel.transformFromAstSync(ast, null, {
presets:["@babel/preset-env"]
})
// 暴露模块
return {
id: globalId++,
filename,
dependence,
code,
};
}
function createDependenceMap(filename) {
// 创建入口模块
const entryModule = createModule(filename);
// 创建模块队列,并将入口模块放入
const moduleQueue = [entryModule]
for (const module of moduleQueue) {
const dirname = path.dirname(module.filename);
module.map = {}
// 对入口模块的依赖进行模块分析及依赖收集
module.dependence.forEach(dependencePath => {
const absolutePath = path.join(dirname, dependencePath);
// 如果依赖仍有依赖则不断重复该过程,创建子模块
const child = createModule(absolutePath);
// 创建子模块Id与路径的映射关系
module.map[dependencePath] = child.id
// 将依赖模块也放入模块队列
moduleQueue.push(child);
})
console.log(module.map)
}
// 返回模块队列
return moduleQueue
}
function bundle(graph) {
let modules = '';
// 对依赖模块进行处理
graph.forEach(mod => {
modules += `${mod.id}: [
function (require, module, exports) {
${mod.code}
},
${JSON.stringify(mod.map)},
],`;
});
// 进行逻辑整合
const result = `
(function(modules) {
function moduleEnv(filename) {
const [code, map] = modules[filename];
function require(name) {
return moduleEnv(map[name])
}
const module = { exports: {} }
// 为模块注入模块化解释器
code(require, module, module.exports)
return module.exports
}
moduleEnv(0)
})({${modules}})
`;
return result;
}
const graph = createDependenceMap('./src/main.js')
const result = bundle(graph);
// 将文件写入index.html引入的入口文件,作为output
fs.writeFileSync('./main.js', result)
console.log('打包完成!');
复制代码
此时我们来看打包后的入口文件
到了这里,我么初步的打包工作就完成了,来看看浏览器执行结果,确认可以成功执行
过程梳理
实际上的webpack打包流程要复杂得多,但这仍不妨碍我们通过一个简单的例子来了解webpack打包的大概原理。总的来说,webpack是根据文件间的依赖关系对其进行静态分析,将这些模块按指定规则生成静态资源,当webpack处理程序时,它会递归地构建一个依赖关系图,将所有这些模块打包成一个或多个预期环境可以执行的bundle。
参考
github.com/estree/estr…
www.npmjs.com/package/@ba…
babeljs.io/docs/en/bab…
www.npmjs.com/package/bab…
babeljs.io/docs/en/bab…
www.npmjs.com/package/bab…
github.com/ronami/mini…
stackoverflow.com/questions/3…
stackoverflow.com/questions/3…
github.com/babel/babel…
babeljs.io/docs/en/bab…
github.com/jamiebuilds…