在开始手写之前,我们先来看下为什么要使用webpack呢?我们用个例子来演示热身:
不使用webpack会有什么问题?
为了说明问题,我们先创建几个文件add.js
,index.js
,index.html
使用es5写法导入导出模块,这里是使用commonjs规范
// src/add.js
exports.default = function(a, b) {return a + b;}
// src/index.js
var add = require('./add.js')
console.log(add(1,3))
<!-- src/index.html -->
<!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>测试模块化开发的代码</title>
<script src="./index.js"></script>
</head>
<body>
</body>
</html>
image.png
我们直接使用模块化开发时,可以看到浏览器并不识别commonjs的模块化用法,会提示require is not defined
,这就不利于我们进行工程化开发了,所以webpack最核心解决的问题,它使用将读取这些文件,按照模块间的依赖关系,重新组装成了能运行的脚本。
webpack是怎么解决这个问题的呢?
一、实现最初的打包bundle.js
先看下它有几个问题和它们各自的解决方案:
第一步,加载子模块
往往模块是别的库(比如nodejs),用的commonjs来写的,那么我们就要处理加载模块的问题:
1. 读取子模块add.js
文件后的代码字符串是不能直接运行的
// 读取到的文件内容,它返回的是一个字符串,并不是一个可执行的语句,比如下面这样:
`exports.default = function(a, b) {return a + b;}`
那么,如何使字符串能够变成可执行代码呢?
- 使用
new Function
new Function(`1+5`)
// 等同于
function (){
1+5
}
(new Function(`1+5`))() // 6
image.png
- 使用
eval
console.log(eval(`1+5`)) //6
可以看出,使用eval非常简洁方便,所以这里我们使用eval来解决。解决第一步后,我们将其放在html的script脚本运行一下:
<script>
// 读取到的文件内容
`exports.default = function(a, b) {return a + b;}`
// 第一种运行方式:使用new Function
// (new Function(`exports.default = function(a, b) {return a + b;}`))()
// 第二种运行方式:eval
eval(`exports.default = function(a, b) {return a + b;}`)
</script>
image.png
2. 导出的变量提示不存在
解决:创建一个exports对象,这是为了符合commonjs的规范的导出写法
// 创建一个exports对象,为了使其符合cjs规范
var exports = {}
eval(`exports.default = function(a, b) {return a + b;}`)
console.log(exports.default(1, 5))
这时,再看浏览器已经不报错了,继续
image.png
3. 变量全局污染
如果在导出的文件中,还要一些其它的变量,比如var a = 1;
之类的,就会造成全局污染
解决:为了避免全局污染,我们使用自执行函数包裹起来,它会为其创建一个独立的作用域,这也是很多框架中会使用到的技巧
// 2. 创建一个exports对象,为了使其符合cjs规范
var exports = {}; // 注意要加分号,否则会提示{} is not a function,它会默认跟下面语句整合
// 1. 使用eval将字符串转化为可执行脚本
// eval(`exports.default = function(a, b) {return a + b;}`)
// 3. 为了避免全局污染
(function(exports, code) {
eval(code)
})(exports, `exports.default = function(a, b) {return a + b;}`)
console.log(exports.default(1, 5))
再打开浏览器,还是显示结果6,没毛病,继续!
第二步,实现加载模块
这一步,是实现 index.js
中,调用子模块中方法,并执行的步骤,我们可以先将index.js
内容拷贝到脚本,看会提示什么错误,再根据错误,一步步去解决
<!-- src/index.html -->
<script>
var exports = {}; // 注意要加分号,否则会提示{} is not a function,它会默认跟下面语句整合
(function(exports, code) {
eval(code)
})(exports, `exports.default = function(a, b) {return a + b;}`)
// index.js的内容
var add = require('./add.js')
console.log(add(1,3))
</script>
image.png
-
提示
require
未定义
解决:自己模拟实现一个require
方法,在刚刚的立即执行函数外,封装一个require方法,并将exports.default(也就是add方法,这里写成exports.default也是为了符合cjs规范)返回
// 4. 实现require方法
function require(file) {
(function(exports, code) {
eval(code)
})(exports, `exports.default = function(a, b) {return a + b;}`)
return exports.default;
}
var add = require('./add.js');
console.log(add(1,3))
image.png
第三步,文件读取
require('./add.js')
这时的文件是写死的,还不能按照参数形式处理
// 5. 这时的文件是写死的,还不能按照参数形式处理
var add = require('./add.js');
console.log(add(1,3))
解决:文件我们可以用对象映射方式,再套一个自执行函数,以它的参数形式传入
// 文件列表对象大概长这样
{
"index.js": `
var add = require('./add.js')
console.log(add(1,3))
`,
"add.js": `
exports.default = function(a, b) {return a + b;}
`
}
<!-- src/index.html -->
<head>
<!-- <script src="./index.js"></script> -->
</head>
<body>
<script>
var exports = {}; // 注意要加分号,否则会提示{} is not a function,它会默认跟下面语句整合
(function(list) {
function require(file) {
(function(exports, code) {
eval(code)
})(exports, list[file])
return exports.default;
}
require('./index.js')
})({
"./index.js": `
var add = require('./add.js')
console.log(add(1,3))
`,
"./add.js": `
exports.default = function(a, b) {return a + b;}
`
})
</script>
</body>
</html>
再看下结果:
image.png
没毛病,噔噔噔噔,有没有觉得,这一串东东老熟悉了?
// 打包后的结果,bundle.js
(function(list) {
function require(file) {
(function(exports, code) {
eval(code)
})(exports, list[file])
return exports.default;
}
require('./index.js')
})({
"./index.js": `
var add = require('./add.js')
console.log(add(1,3))
`,
"./add.js": `
exports.default = function(a, b) {return a + b;}
`
})
这就是我们平常用webpack
打包后看到的那一堆看都不想看的结果了='=(也就是万恶的bundle.js
),这就是一个webpack
最小模块打包的雏形了
二、分析模块间的依赖关系,获取依赖图
刚刚的例子呢,是为了让大家快速了解webpack的原理,我们是人工分析依赖关系,来写的一个小demo,但是实际情况要比我们刚刚说的复杂多了,比如依赖之间是往往一个模块依赖多个模块,模块之间还有嵌套问题,比如下面这样的图形结构;使用的还不是ES5
的语法,而是ES6语
法,还需要我们转义。
{
"./src/index.js": {
"deps": { "./add.js": "./src/add.js" },
"code": "....."
},
"./src/add.js": {
"deps": {},
"code": "......"
}
}
我们还要处理的问题,大概可以总结为:
- 收集依赖
-
ES6
转ES5
- 实现
import
和export
为了高大上些,我们使用ES6语法改下文件:
// src/add.js
// ES6语法
export default (a, b) => a + b;
// src/index.js
// ES6语法
import add from './add.js'
console.log(add(1,3))
开始来实现我们的webpack.js
1、实现单个模块的分析方法 getModuleInfo
第一步,使用fs模块读取文件
// 引入fs模块,用来读写文件
const fs = require('fs')
/**
* 模块分析
* @param {*} file
*/
function getModuleInfo(file) {
// 1. 读取文件
const body = fs.readFileSync(file, 'utf-8')
}
第二步,使用babel的parser模块,将文件字符串内容转换成AST树
- 什么是AST树?
ast是源代码的抽象语法结构的树状表示,树上的每个节点都表示源代码中的一种结构,这所以说是抽象的,是因为抽象语法树并不会表示出真实语法出现的每一个细节,比如说,嵌套括号被隐含在树的结构中,并没有以节点的形式呈现。抽象语法树并不依赖于源语言的语法,也就是说语法分析阶段所采用的上下文无文文法,因为在写文法时,经常会对文法进行等价的转换(消除左递归,回溯,二义性等),这样会给文法分析引入一些多余的成分,对后续阶段造成不利影响,甚至会使合个阶段变得混乱。因些,很多编译器经常要独立地构造语法分析树,为前端,后端建立一个清晰的接口
-
体验AST树
image.png
网站:https://astexplorer.net/
下面图,是将console.log(111)
转换成AST的结果
-
安装babel
一般将字符串转换为抽象语法树,我们都是通过工具来完成的,这点上babel就已经实现的比较完美了,在使用前,我们需要先安装相关依赖:
npm i @babel/parser @babel/traverse @babel/core @babel/preset-env
-
使用babel转换AST
const parser = require('@babel/parser')
function getModuleInfo(file) {
// 1. 读取文件
const body = fs.readFileSync(file, 'utf-8')
// 2. 转换AST语法树
const ast = parser.parse(body, {
sourceType: 'module' // ES模块
})
}
第三步,使用babel的traverse模块,分析AST树,收集依赖
const traverse = require('@babel/traverse').default
function getModuleInfo(file) {
// 1. 读取文件
const body = fs.readFileSync(file, 'utf-8')
// 2. 转换AST语法树
const ast = parser.parse(body, {
sourceType: 'module' // ES模块
})
// console.log("ast:", ast)
// 3. 收集依赖
const deps = {}
traverse(ast, {
ImportDeclaration({node}) {
// 获取当前目录名
const dirname = path.dirname(file)
// 设置绝对路径
const abspath = './' + path.join(dirname, node.source.value)
deps[node.source.value] = abspath
}
})
console.log("deps:", deps) // deps: { './add.js': './src\\add.js' }
}
getModuleInfo('./src/index.js')
这时候,就可以看到index.js
的依赖文件为add.js
了
第四步,使用babel的transformFromAst模块,将ES6转为ES5
const babel = require("@babel/core");
// 4. ES6转换ES5
const { code } = babel.transformFromAst(ast, null, {
presets: ["@babel/preset-env"]
})
console.log("code:", code)
结果输出如下:
image.png
第五步,输出模块信息
// 5. 输出模块信息
const moduleInfo = {
file,
deps,
code
}
return moduleInfo
完整的单个模块分析代码:
// 引入fs模块,用来读写文件
const fs = require('fs')
// 引入path模块,处理路径问题
const path = require('path')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const babel = require('@babel/core')
/**
* 模块分析
* @param {*} file
*/
function getModuleInfo(file) {
// 1. 读取文件
const body = fs.readFileSync(file, 'utf-8')
// 2. 转换AST语法树
const ast = parser.parse(body, {
sourceType: 'module' // ES模块
})
// 3. 收集依赖
const deps = {}
traverse(ast, {
ImportDeclaration({node}) {
// 获取当前目录名
const dirname = path.dirname(file)
// 设置绝对路径
const abspath = './' + path.join(dirname, node.source.value)
deps[node.source.value] = abspath
}
})
// 4. ES6转换ES5
const { code } = babel.transformFromAst(ast, null, {
presets: ["@babel/preset-env"]
})
// 5. 输出模块信息
const moduleInfo = {
file,
deps,
code
}
return moduleInfo
}
const info = getModuleInfo('./src/index.js')
console.log({info})
// { info:
// { file: './src/index.js',
// deps: { './add.js': './src\\add.js' },
// code:
// '"use strict";\n\nvar _add = _interopRequireDefault(require("./add.js"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n\n// src/index.js\n// ES5语法\n// var add = require(\'./add.js\')\n// console.log(add(1,3))\n// ES6语法\nconsole.log((0, _add["default"])(1, 3));' } }
2、分析模块间的依赖关系
// 解析模块间的关系
function parseModules(file) {
// 从入口开始
const entry = getModuleInfo(file)
const temp = [entry]
// 依赖关系图
const depsGraph = {}
// 递归获取依赖关系
getDeps(temp, entry)
// 组装依赖
temp.forEach((moduleInfo) => {
depsGraph[moduleInfo.file] = {
deps: moduleInfo.deps,
code: moduleInfo.code,
};
});
return depsGraph
}
// 递归获取依赖关系
function getDeps(temp, {deps}) {
Object.keys(deps).forEach(key => {
const child = getModuleInfo(deps[key])
temp.push(child)
getDeps(temp, child)
})
}
const graph = parseModules('./src/index.js')
console.log('graph:', graph)
可以看到,输出结果,就是我们想要的依赖图结构了:
image.png
三、最终组合打包
有了依赖树,前面第一个demo我们写了bundle.js,那么我们将它们组装起来,就是我们想要最终打包结果了
// 9. 打包
function bundle(file) {
// 获取依赖图
const depsGraph = JSON.stringify(parseModules(file))
// 跟第一个demo中的打包文件,拼接起来
return `
(function (graph) {
function require(file) {
function absRequire(relPath) {
return require(graph[file].deps[relPath])
}
var exports = {};
(function (require,exports,code) {
eval(code)
})(absRequire,exports,graph[file].code)
return exports
}
require('${file}')
})(${depsGraph})`;
}
const content = bundle('./src/index.js')
console.log(content)
四、最后,输出 dist/bundle.js 文件
// 判断有没dist目录,没有就创建
!fs.existsSync("./dist") && fs.mkdirSync("./dist");
// 将打包后的文件写入./dist/bundle.js中
fs.writeFileSync("./dist/bundle.js", content);
结果展示:
// dist/bundle.js
(function (graph) {
function require(file) {
function absRequire(relPath) {
return require(graph[file].deps[relPath])
}
var exports = {};
(function (require,exports,code) {
eval(code)
})(absRequire,exports,graph[file].code)
return exports
}
require('./src/index.js')
})({"./src/index.js":{"deps":{"./add.js":"./src\\add.js"},"code":"\"use strict\";\n\nvar _add = _interopRequireDefault(require(\"./add.js\"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { \"default\": obj }; }\n\n// src/index.js\n// ES5语法\n// var add = require('./add.js')\n// console.log(add(1,3))\n// ES6语法\nconsole.log((0, _add[\"default\"])(1, 3));"},"./src\\add.js":{"deps":{},"code":"\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\nexports[\"default\"] = void 0;\n\n// src/add.js\n// 使用es5导出模块:src/add.js\n// exports.default = function(a, b) {return a + b;}\n// ES6语法\nvar _default = function _default(a, b) {\n return a + b;\n};\n\nexports[\"default\"] = _default;"}})
五、测试,这个打包结果
<!-- index2.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<title>测试手写的webpack.js</title>
<script src="../dist/bundle.js"></script>
</head>
<body>
</body>
</html>
image.png
至此,一个基本的手写webpack
就完成了,总结下webpack的处理流程:
- 读取⼊⼝⽂件;
- 基于 AST(抽象语法树) 分析⼊⼝⽂件,并产出依赖列表;
- 使⽤ Babel 将相关模块编译到 ES5;
- webpack有⼀个智能解析器(各种babel),⼏乎可以处理任何第三⽅库。⽆论它们的模块形式是
CommonJS、AMD还是普通的JS⽂件;甚⾄在加载依赖的时候,允许使⽤动态表require("、/templates/"+name+"、jade")。以下这些⼯具底层依赖了不同的解析器⽣成AST,⽐如eslint使⽤了espree、babel使⽤了acorn - 对每个依赖模块产出⼀个唯⼀的 ID,⽅便后续读取模块相关内容;
- 将每个依赖以及经过 Babel 编译过后的内容,存储在⼀个对象中进⾏维护;
- 遍历上⼀步中的对象,构建出⼀个依赖图(Dependency Graph);
- 将各模块内容 bundle 产出
附上完整代码:
// ./webpack.js
// 引入fs模块,用来读写文件
const fs = require('fs')
// 引入path模块,处理路径问题
const path = require('path')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const babel = require('@babel/core')
/**
* 模块分析
* @param {*} file
*/
function getModuleInfo(file) {
// 1. 读取文件
const body = fs.readFileSync(file, 'utf-8')
// 2. 转换AST语法树
const ast = parser.parse(body, {
sourceType: 'module' // ES模块
})
// console.log("ast:", ast)
// 3. 收集依赖
const deps = {}
traverse(ast, {
ImportDeclaration({node}) {
// 获取当前目录名
const dirname = path.dirname(file)
// 设置绝对路径
const abspath = './' + path.join(dirname, node.source.value)
deps[node.source.value] = abspath
}
})
console.log("deps:", deps) // deps: { './add.js': './src\\add.js' }
// 4. ES6转换ES5
const { code } = babel.transformFromAst(ast, null, {
presets: ["@babel/preset-env"]
})
// console.log("code:", code)
// 5. 输出模块信息
const moduleInfo = {
file,
deps,
code
}
return moduleInfo
}
// const info = getModuleInfo('./src/index.js')
// console.log({info})
// 6. 解析模块间的关系
function parseModules(file) {
// 从入口开始
const entry = getModuleInfo(file)
const temp = [entry]
// 依赖关系图
const depsGraph = {}
// 7. 递归获取依赖关系
getDeps(temp, entry)
// 8. 组装依赖
temp.forEach((moduleInfo) => {
depsGraph[moduleInfo.file] = {
deps: moduleInfo.deps,
code: moduleInfo.code,
};
});
return depsGraph
}
// 递归获取依赖关系
function getDeps(temp, {deps}) {
Object.keys(deps).forEach(key => {
const child = getModuleInfo(deps[key])
temp.push(child)
getDeps(temp, child)
})
}
// console.log('graph:', graph)
// 9. 打包
function bundle(file) {
// 获取依赖图
const depsGraph = JSON.stringify(parseModules(file))
// 跟第一个demo中的打包文件,拼接起来
return `
(function (graph) {
function require(file) {
function absRequire(relPath) {
return require(graph[file].deps[relPath])
}
var exports = {};
(function (require,exports,code) {
eval(code)
})(absRequire,exports,graph[file].code)
return exports
}
require('${file}')
})(${depsGraph})`;
}
const content = bundle('./src/index.js')
console.log(content)
// 判断有没dist目录,没有就创建
!fs.existsSync("./dist") && fs.mkdirSync("./dist");
// 将打包后的文件写入./dist/bundle.js中
fs.writeFileSync("./dist/bundle.js", content);
网友评论