webpack是一个重要的项目打包工具,在很多框架中都有用到,比如Vue2里面的vue-cli就是基于webpack的。因此学习webpack就变得尤为重要,而学习webpack的源码,手写一个webpack,则更有助于我们理解并运用webpack。本项目的代码链接为github.com/bdbdbdsbd/m…
本文参照源码,手写实现了一个简易版本的webpack,包含了打包功能
1 原生HTML/JS项目
在打包工具出现以前,我们的项目是由一个个html文件、css文件、js文件组成的。
比如说下面这个最简易的项目,我们创建了main.js foo.js bar.js以及index.html,结构如下
├─example
| ├─main.js
| ├─foo.js
| ├─bar.js
| ├─index.html
// main.js
import foo from "./foo.js"
foo.foo();
console.log("main.js")
// foo.js
import bar from "./bar.js"
function foo(){
console.log("foo")
}
export default {foo}
// bar.js
export default {}
<!-- 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>webpack</title>
</head>
<body>
<script type="module" src="./main.js"></script>
</body>
</html>
打开devtool,会发现输出了
foo
main.js
在没有学到打包工具的时候,我们的线上项目也是这样一个个文件错综复杂的引用。
2 webpack
2.1 webpack核心步骤
看了很多关于webpack打包步骤的文章,觉得下面这个流程划分比较合理
1、初始化阶段 - webpack
- 合并配置项,这个步骤我们会从配置文件和 Shell 语句中读取与合并参数
- 创建 compiler
- 注册插件(plugins)
2、编译阶段 - build
- 读取入口文件
- 从入口文件开始进行编译
- 调用 loader 对源代码进行转换
- 借助 babel 解析为 AST 收集依赖模块
- 递归对依赖模块进行编译操作
3、生成阶段 - seal
- 创建 chunk 对象
- 生成 assets 对象
4、写入阶段 - emit
作者:明里人
链接:juejin.cn/post/713099…
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
在本文中,我们先实现一个最简单webpack,先忽略上文中的部分步骤,实现以下功能
读取入口文件->从入口文件开始进行编译->借助 babel 解析为 AST 收集依赖模块->递归对依赖模块进行编译操作->生成资源并写入
2.2 读取文件代码内容以及合并准备
本文的主要目的就是实现webpack的基本功能,我们称这个项目为mini-webpack。首先初始化项目npm init -y,再创建一个index.js,这个文件将放置我们的mini-webpack里面的核心逻辑
├─index.js
├─package.json
├─example
| ├─bar.js
| ├─foo.js
| ├─index.html
| └main.js
如果后面是想用ejs风格的代码,在package.json里面加入"type": "module",如果不加入,后面的代码就不要使用import,使用require(commonJS风格)
...
"version": "1.0.0",
"type": "module",
...
我们使用fs模块读取到了文件里面的代码
const fs = require("fs")
const filePath = "./example/main.js"
const source = fs.readFileSync(filePath,{
encoding:"utf-8"
});
// import foo from "./foo.js"
// foo.foo();
// console.log("main.js")
import开头的部分代表了文件之间的依赖关系,后面则是代码部分,有些文件后面还有export,这里的代码部分保留。
在合并打包之后,文件之间的引用(依赖关系)变成函数之间的引用,而函数里面是不能写import,因此我们需要去模拟import和export的过程, 我们的处理方式为
直观的看,我们把EJS风格的代码变成了CJS风格的。其实我们是设置自定义require函数(在后文中定义),来实现自定义的函数之间的相互引用,其实名字不一定是require,只是命名为require更方便实现。同时exports也只是一对象。
那么下一步就是将EJS风格的代码转换为CJS风格的,使用ast树来做,先将EJS风格的代码转成ast树,再将ast树转换为CJS风格的代码。
首先安装babel-preset-env,@babel/parser,babel-core,然后在index.js里写入
import fs from "fs"
import parser from "@babel/parser"
import {transformFromAst} from "babel-core"
const filePath = "./example/foo.js"
const source = fs.readFileSync(filePath,{
encoding:"utf-8"
});
const ast = parser.parse(source,{
sourceType:"module"
})
const {code} = transformFromAst(ast,null,{
presets:["env"]
})
console.log(code)
// "use strict";
// Object.defineProperty(exports, "__esModule", {
// value: true
// });
// var _bar = require("./bar.js");
// var _bar2 = _interopRequireDefault(_bar);
// function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
// function foo() {
// console.log("foo");
// }
// exports.default = {
// foo: foo
// };
可以发现,输出的代码已经是cjs规范的了。
2.3 读取文件依赖关系
这里先用一个数组去存储依赖关系,比如说foo.js里面引用了bar.js,那么就在deps里面添加['./bar.js'],作为依赖关系的数组。这个依赖关系里面的文件的获取,还是通过AST树。
首先,使用AST树的一个分析的网站,放入foo.js代码可以得到
{
"body": [
{
"type": "ImportDeclaration",
"start": 0,
"end": 26,
"source": {
"type": "Literal",
"start": 16,
"end": 26,
"value": "./bar.js",
"raw": "\"./bar.js\""
}
},
...
],
"sourceType": "module"
}
可以发现,在body的一个type为ImportDeclaration的对象里,通过source.value就可以直接获取到"./bar.js",当然这里处于方便,使用了traverse来获取依赖。
// 入口文件
import fs from "fs"
import parser from "@babel/parser"
import traverse from "@babel/traverse"
import {transformFromAst} from "babel-core"
const deps = []
const filePath = "./example/foo.js"
const source = fs.readFileSync(filePath,{
encoding:"utf-8"
});
const ast = parser.parse(source,{
sourceType:"module"
})
traverse.default(ast,{
// 针对import类的节点
ImportDeclaration({node}){
// 收集到了
// 添加到依赖关系里面
deps.push(node.source.value)
}
})
const {code} = transformFromAst(ast,null,{
presets:["env"]
})
console.log(deps)
// [ './bar.js' ]
最后将这部分代码打包为一个函数createAsset
// 入口文件
import fs from "fs"
import parser from "@babel/parser"
import traverse from "@babel/traverse"
import {transformFromAst} from "babel-core"
function createAsset(filePath){
const deps = []
const source = fs.readFileSync(filePath,{
encoding:"utf-8"
});
const ast = parser.parse(source,{
sourceType:"module"
})
traverse.default(ast,{
// 针对import类的节点
ImportDeclaration({node}){
// 收集到了
// 添加到依赖关系里面
deps.push(node.source.value)
}
})
const {code} = transformFromAst(ast,null,{
presets:["env"]
})
return {
filePath,
code,
deps
}
}
2.4 依赖关系的处理
处理策略:先进行main.js的依赖处理,得到了一个数组deps = ["./foo.js"],再处理deps里面的文件的依赖,是一种递归的思想。所以mian.js一般也可以认为是入口文件,因为依赖关系的处理是从这个文件开始的。
这部分处理代码如下
const mainAsset = createAsset("./example/main.js")
const queue = [mainAsset]
for(const asset of queue){
asset.deps.forEach(ralativePath=>{
const child = createAsset(path.resolve("./example",ralativePath))
queue.push(child)
})
}
console.log(queue)
// [
// {
// filePath: './example/main.js',
// code: '"use strict";\n' +
// '\n' +
// 'var _foo = require("./foo.js");\n' +
// '\n' +
// 'var _foo2 = _interopRequireDefault(_foo);\n' +
// '\n' +
// 'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n' +
// '\n' +
// '_foo2.default.foo();\n' +
// '\n' +
// 'console.log("main.js");',
// deps: [ './foo.js' ]
// },
// {
// filePath: 'D:\\3_code\\webpack\\test\\example\\foo.js',
// code: '"use strict";\n' +
// '\n' +
// 'Object.defineProperty(exports, "__esModule", {\n' +
// ' value: true\n' +
// '});\n' +
// '\n' +
// 'var _bar = require("./bar.js");\n' +
// '\n' +
// 'var _bar2 = _interopRequireDefault(_bar);\n' +
// '\n' +
// 'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n' +
// '\n' +
// 'function foo() {\n' +
// ' console.log("foo");\n' +
// '}\n' +
// '\n' +
// 'exports.default = {\n' +
// ' foo: foo\n' +
// '};',
// deps: [ './bar.js' ]
// },
// {
// filePath: 'D:\\3_code\\webpack\\test\\example\\bar.js',
// code: '"use strict";\n' +
// '\n' +
// 'Object.defineProperty(exports, "__esModule", {\n' +
// ' value: true\n' +
// '});\n' +
// 'exports.default = {};',
// deps: []
// }
// ]
queue就是我们得到的一个数组,里面有依赖关系,也有代码。得到了这些重要信息,我们就可以打包生成js文件。
3 打包生成js文件
3.1 ejs模板
使用ejs模板来生成打包后的js文件,我们只要把代码和文件的依赖放入,就可以得到打包后的js文件。
那么这个模板应该怎么做比较好,首先就是看我们之前获取的code
Object.defineProperty(exports, "__esModule", {
value: true
});
var _bar = require("./bar.js");
var _bar2 = _interopRequireDefault(_bar);
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
function foo() {
console.log("foo");
}
exports.default = {
foo: foo
};
那么,首先封装一下这部分代码为一个函数,方便其他函数调用、向外传数据
{"./foo.js":function (require,module,exports){
Object.defineProperty(exports, "__esModule", {
value: true
});
var _bar = require("./bar.js");
var _bar2 = _interopRequireDefault(_bar);
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
function foo() {
console.log("foo");
}
exports.default = {
foo: foo
};
}}
封装成一个对象+匿名函数的形式,其实是方便做成模板,这样我们只需要像下面这样的形式填充模板就可以得到一个对象,里面存储了一个个函数
const modules = {
item1.filePath:function(require,module,exports){
item1.code
},
item2.filePath:function(require,module,exports){
item2.code
},
item3.filePath:function(require,module,exports){
item3.code
},
}
那么其实接下来的逻辑就很清楚了,对于任意一个xx.js函数,整体逻辑为
引入xx.js的依赖的代码(通过require函数)->执行xx.js代码-> 通过exports返回相应的内容
JS里面require函数是会去引入一个文件的,但是我们webpack打包后其实希望的是去引入同一个文件里面的其他函数。而exports也是同理,所以我们只是模拟了require/exports的功能,并不是真正调用了require/exports。那么接下来就去实现这个自定义的require函数以及module,exports
const require=(filePath)=>{
const fn = modules[filePath]
const module = {
exports:{}
}
fn(require,module,module.exports)
return module.exports
}
require("./main.js")
通过两个对象来实现module以及exports的功能,配合require返回相应的数据
综上所述,可以得到如下的一个模板
(function(modules){
const require=(filePath)=>{
const fn = modules[filePath]
const module = {
exports:{}
}
fn(require,module,module.exports)
return module.exports
}
require("./main.js")
})({
item1.filePath:function(require,module,exports){
item1.code
},
item2.filePath:function(require,module,exports){
item2.code
},
item3.filePath:function(require,module,exports){
item3.code
},
})
在根目录下新建一个模板文件bundle.ejs,将上文代码整理成ejs文件就是
(function(modules){
const require=(filePath)=>{
const fn = modules[filePath]
const module = {
exports:{}
}
fn(require,module,module.exports)
return module.exports
}
require("./main.js")
})({
<% data.forEach((item)=>{%>
"<%-item["filePath"]%>":function (require,module,exports){
<%- item["code"]%>
},
<%}) %>
})
data就是传给ejs文件的参数,是一个对象数组,对应了上文的queue。 新建一个文件夹"./dist",用于存放bundle.js(打包后的js文件),现在的结构就成这样了
├─bundle.ejs
├─index.js
├─package.json
├─pnpm-lock.yaml 不使用pnpm的朋友不会有这个文件
├─example
| ├─bar.js
| ├─foo.js
| ├─index.html
| └main.js
├─dist
| └bundle.js
在index.js中,将queue传给ejs模板。
// 入口文件
import fs from "fs"
import parser from "@babel/parser"
import traverse from "@babel/traverse"
import {transformFromAst} from "babel-core"
import path from "path"
import ejs from "ejs"
function createAsset(filePath){
const deps = []
const source = fs.readFileSync(filePath,{
encoding:"utf-8"
});
const ast = parser.parse(source,{
sourceType:"module"
})
traverse.default(ast,{
// 针对import类的节点
ImportDeclaration({node}){
// 收集到了
// 添加到依赖关系里面
deps.push(node.source.value)
}
})
const {code} = transformFromAst(ast,null,{
presets:["env"]
})
return {
filePath,
code,
deps
}
}
function createGraph(){
const mainAsset = createAsset("./example/main.js")
const queue = [mainAsset]
for(const asset of queue){
asset.deps.forEach(ralativePath=>{
const child = createAsset(path.resolve("./example",ralativePath))
queue.push(child)
})
}
return queue
}
function build(graph){
const template = fs.readFileSync('./bundle.ejs',{
encoding:"utf-8"
})
const data = graph.map((asset)=>{
const {filePath,code} = asset
return {
filePath,
code,
}
})
const code1 = ejs.render(template,{data})
console.log(code1)
fs.writeFileSync("./dist/bundle.js",code1)
}
const graph = createGraph()
build(graph)
修改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>webpack</title>
</head>
<body>
<!-- <script src="../dist/bundle.js"></script> -->
<script type="module" src="../dist/bundle.js"></script>
</body>
</html>
执行node index.js,得到了打包后的文件bundle.js
(function(modules){
const require=(filePath)=>{
const fn = modules[filePath]
const module = {
exports:{}
}
fn(require,module,module.exports)
return module.exports
}
require("./main.js")
})({
"./example/main.js":function (require,module,exports){
"use strict";
var _foo = require("./foo.js");
var _foo2 = _interopRequireDefault(_foo);
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
_foo2.default.foo();
console.log("main.js");
},
"D:\3_code\webpack\test\example\foo.js":function (require,module,exports){
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
var _bar = require("./bar.js");
var _bar2 = _interopRequireDefault(_bar);
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
function foo() {
console.log("foo");
}
exports.default = {
foo: foo
};
},
"D:\3_code\webpack\test\example\bar.js":function (require,module,exports){
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = {};
},
})
3.2 mapping 映射
然后用liveServer打开,意料之中的带着bug。原因很简单,我们reqire的时候是"./bar.js",但是输入变量里面是"D:\3_code\webpack\test\example\bar.js",那就做一个简简单单的映射就可以了
index.js
import fs from "fs"
import parser from "@babel/parser"
import traverse from "@babel/traverse"
import path from "path"
import ejs from "ejs"
import {transformFromAst} from "babel-core"
let id = 0
// 获取文件内容
// 获取依赖关系
function createAsset(filePath){
const deps = []
const source = fs.readFileSync(filePath,{
encoding:"utf-8"
});
// 得到ast树
const ast = parser.parse(source,{
sourceType:"module"
})
// traverse去遍历ast
traverse.default(ast,{
// 针对import类的节点
ImportDeclaration({node}){
// 收集到了
// 添加到依赖关系里面
deps.push(node.source.value)
}
})
const {code} = transformFromAst(ast,null,{
presets:["env"]
})
return {filePath,code,deps,mapping:{},id:id++,};
}
function createGraph(){
const mainAsset = createAsset("./example/main.js")
const queue = [mainAsset]
for(const asset of queue){
asset.deps.forEach(ralativePath=>{
console.log("depsssss",path.resolve("./example",ralativePath),ralativePath)
const child = createAsset(path.resolve("./example",ralativePath))
asset.mapping[ralativePath] = child.id
queue.push(child)
})
}
return queue
}
function build(graph){
const template = fs.readFileSync('./bundle.ejs',{
encoding:"utf-8"
})
const data = graph.map((asset)=>{
const {id,code,mapping} = asset
return {
id,
code,
mapping,
}
})
const code1 = ejs.render(template,{data})
fs.writeFileSync("./dist/bundle.js",code1)
}
const graph = createGraph()
build(graph)
bundle.ejs
(function(modules){
const require=(id)=>{
const [fn,mapping] = modules[id]
const module = {
exports:{}
}
function localRequire(filePath){
const id = mapping[filePath]
return require(id)
}
fn(localRequire,module,module.exports)//相当于在执行foojs、mainjs
return module.exports
}
require(0)
// require,module,exports 传进来的特性?
// exports 是输出
})({
<% data.forEach((item)=>{%>
"<%-item["id"]%>":[function (require,module,exports){
<%- item["code"]%>
},<%- JSON.stringify(item["mapping"]) %> ],
<%}) %>
})
// ()立即执行
// 使用commonJS规范里面的require 代替import
对每一个文件生成的依赖数组,增加了一个id和一个mapping,mapping里面映射了文件名和对应的id。
执行node index.js 打包后得到
(function(modules){
const require=(id)=>{
const [fn,mapping] = modules[id]
const module = {
exports:{}
}
function localRequire(filePath){
const id = mapping[filePath]
return require(id)
}
fn(localRequire,module,module.exports)//相当于在执行foojs、mainjs
return module.exports
}
require(0)
// require,module,exports 传进来的特性?
// exports 是输出
})({
"0":[function (require,module,exports){
"use strict";
var _foo = require("./foo.js");
var _foo2 = _interopRequireDefault(_foo);
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
_foo2.default.foo();
console.log("main.js");
},{"./foo.js":1} ],
"1":[function (require,module,exports){
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
var _bar = require("./bar.js");
var _bar2 = _interopRequireDefault(_bar);
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
function foo() {
console.log("foo");
}
exports.default = {
foo: foo
};
},{"./bar.js":2} ],
"2":[function (require,module,exports){
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.default = {};
},{} ],
})
可以发现,之前的"xx.js"被替换成了id,并且每一个js文件名都映射了一个id。 这个时候再用liveServer打开index.html,就可以正常打开了。
4 总结
- webpack打包的大体流程就是,获取代码内容、获取依赖,使用ejs模板处理代码以及相应的依赖。
- 从代码中可以发现,我们所有的操作都是从main.js开始的,从main.js开始处理依赖关系,所以main.js常被称为入口文件。
- 同时也发现很多了webpack的常用的配置项,比如说打包输出的文件夹等。