首先还是一段官网的描述:
webpack 是一个用于现代 JavaScript 应用程序的静态模块打包工具。当 webpack 处理应用程序时,它会在内部构建一个 依赖图(dependency graph),此依赖图对应映射到项目所需的每个模块,并生成一个或多个 bundle。
打包,是指处理某些文件并将其输出为其他文件的能力。
下面以一个简单的 demo 来分析 webpack 是如何做打包的。
首先在 src 下新建三个文件:
// index.js
import message from "./message.js";
console.log(message);
// message.js
import { word } from "./word.js";
const message = `say ${word}`;
export default message;
// word.js
export const word = "hello";
src 下文件肯定不能直接在浏览器运行,因为不识别 import 等语法,所以需要对其编译打包。
下面直接进入今天的主题,编写一个打包文件 bundler.js
,通过运行 node bundler.js
来模拟 webpack 的打包流程。
Step1 读取项目的入口文件
首先,要对文件进行打包,第一步肯定是读取入口文件
// bundler.js
const fs = require("fs");
const moduleAnalyser = (filename) => {
// step1 通过 nodejs fs 模块,获取文件内容
const content = fs.readFileSync(filename, "utf-8");
console.log(content);
};
// 传入入口文件,进行分析
moduleAnalyser("./src/index.js");
$ node bundler.js
import message from "./message.js";
console.log(message);
Step2 分析入口文件中的代码
安装 npm i @babel/parser
来分析源代码
// bundler.js
const fs = require("fs");
const paser = require("@babel/parser");
const moduleAnalyser = (filename) => {
// step1 通过 nodejs fs 模块,获取文件内容
const content = fs.readFileSync(filename, "utf-8");
// step2 paser.parse 将 JS代码转化成抽象语法树 AST (js对象)
const ast = paser.parse(content, {
sourceType: "module",
});
// 通过 AST 可以找到声明的语句 "import",就能找到依赖关系了
// 可以看到 ast.program.body 里面,可以分析出节点对应语句的类型,如 type: 'ImportDeclaration'
console.log("ast", ast.program.body);
};
// 传入入口文件,进行分析
moduleAnalyser("./src/index.js");
这里推荐一个高亮显示代码的小工具
npm i highlight -g
高亮显示代码的工具 运行node bundler.js | highlight
即可
Step3 获取模块依赖关系
安装 npm i @babel/traverse
快速找到依赖文件(通过 import 节点)
// bundler.js
const fs = require("fs");
const path = require("path");
const paser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const moduleAnalyser = (filename) => {
// step1 通过 nodejs fs 模块,获取文件内容
const content = fs.readFileSync(filename, "utf-8");
// step2 paser.parse 将 JS代码转化成抽象语法树 AST (js对象)
const ast = paser.parse(content, {
sourceType: "module",
});
// step3 存放依赖对象
const dependencies = {};
// traverse 方法可以快速找到import节点
traverse(ast, {
// 遍历AST, 找出type是ImportDeclaration的元素,会执行下面的函数
ImportDeclaration({ node }) {
const dirname = path.dirname(filename); // './src'
const newFile = "./" + path.join(dirname, node.source.value); // ./src/message.js
dependencies[node.source.value] = newFile; // 以键值对的形式存储,便于后续打包
console.log("dependencies", dependencies); // { './message.js': './src/message.js' }
},
});
};
// 传入入口文件,进行分析
moduleAnalyser("./src/index.js");
Step4 将 AST 编译成浏览器可以运行的代码
安装 npm i @babel/core
其中 transformFromAst
方法需要配置 presets,安装:npm install @babel/preset-env
// bundler.js
const fs = require("fs");
const path = require("path");
const paser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const babel = require("@babel/core");
const moduleAnalyser = (filename) => {
// step1 通过 nodejs fs 模块,获取文件内容
const content = fs.readFileSync(filename, "utf-8");
// step2 paser.parse 将 JS代码转化成抽象语法树 AST (js对象)
const ast = paser.parse(content, {
sourceType: "module",
});
// step3 存放依赖对象
const dependencies = {};
// traverse 方法可以快速找到import节点
traverse(ast, {
// 遍历AST, 找出type是ImportDeclaration的元素,会执行下面的函数
ImportDeclaration({ node }) {
const dirname = path.dirname(filename); // './src'
const newFile = "./" + path.join(dirname, node.source.value); // ./src/message.js
dependencies[node.source.value] = newFile; // 以键值对的形式存储,便于后续打包
},
});
// step4 将AST编译成浏览器可以运行的代码
const { code } = babel.transformFromAst(ast, null, {
presets: ["@babel/preset-env"],
});
console.log("code", code);
// 将模块分析结果返回
return {
filename,
dependencies,
code,
};
};
// 传入入口文件,进行分析
const moduleInfo = moduleAnalyser("./src/index.js");
console.log("moduleInfo", moduleInfo);
Step5 递归解析依赖项,生成依赖图谱 DependencyGragh
// step5 对所有模块进行分析,递归分析依赖结果
const getDependenciesGragh = (entry) => {
// 获取入口文件分析结果,存入一个数组
const entryModule = moduleAnalyser(entry); // { filename, dependencies, code}
const graphArray = [entryModule];
// 递归开始
for (let i = 0; i < graphArray.length; i++) {
const { dependencies } = graphArray[i]; // { './message.js': './src/message.js' }
if (dependencies) {
for (let j in dependencies) {
graphArray.push(moduleAnalyser(dependencies[j]));
// 此时graphArray长度+1,继续循环遍历,直到把所有依赖一层层push进graghArray,递归结束
}
}
}
console.log("graphArray: ", graphArray);
// 格式转化
const dependencyGraph = {};
graphArray.forEach((item) => {
dependencyGraph[item.filename] = {
dependencies: item.dependencies,
code: item.code,
};
});
return dependencyGraph;
};
// 传入入口文件,进行分析
const dependencyGraph = getDependenciesGragh("./src/index.js");
// 至此入口文件分析完成,我们打印依赖图谱看看效果~
console.log("dependencyGraph: ", dependencyGraph);
Step6 通过 dependencyGraph 生成可以在浏览器运行的代码
前面已经完成了入口文件的分析完成,并且生成了需要的依赖图谱。接下来就是如何根据依赖图谱生成可以在浏览器运行的代码。
这步不是很好理解,下面我们分步骤解析~
1. 首先创建一个 generateCode 函数
目标是创建一个函数 generateCode,返回在浏览器运行的代码,返回的结果是一个字符串
// step6 通过 dependencyGraph 生成可以在浏览器运行的代码
const generateCode = (entry) => {
const graph = JSON.stringify(getDependenciesGragh(entry)); // 对象转字符串
// 网页中的代码,都要在一个闭包中执行,避免污染全局环境,基本格式如下
return `
(function(graph){
})(${graph})
`;
};
const code = generateCode("./src/index.js");
console.log("code: ", code);
打印看一下 code~
2. 在依赖图谱中找到对应文件内容,执行代码
接下来需要在依赖图谱中通过 entry 找到对应文件内容,执行 code 里面的代码
index.js 中通过前文编译后的代码是这样的
"use strict";
var _message = _interopRequireDefault(require("./message.js"));
function _interopRequireDefault(obj) {
return obj && obj.__esModule ? obj : { default: obj };
}
console.log(_message["default"]);
可以看到文件内容包含 require、exports 对象,这在浏览器中是无法识别与运行的
3. 创建 require 函数和 exports 对象
// step6 通过 dependencyGraph 生成可以在浏览器运行的代码
const generateCode = (entry) => {
const graph = JSON.stringify(getDependenciesGragh(entry)); // 对象转字符串
//网页中的代码,都要在一个闭包中执行,避免污染全局环境,基本格式如下
return `
(function(graph){
// 1. 创建一个require函数,根据依赖图谱,通过entry找到对应文件内容,通过eval()执行代码
function require(module){
(function(code){
eval(code)
})(graph[module].code)
}
require('${entry}') // 注意加引号,是字符串拼接不是运行
})(${graph})
`;
};
通过 eval(code)
执行代码时,里面也会调用 require 方法: require("./message.js")
,但 require 入参 module 需要的是绝对路径(dependenciesGragh 里面存的 key 是绝对路径),而此时传入的 ./message.js
是相对路径
4. 完善 require 函数
自定义一个 localRequire 方法,进行“相对路径”的进行转换
// step6 通过 dependencyGraph 生成可以在浏览器运行的代码
const generateCode = (entry) => {
const graph = JSON.stringify(getDependenciesGragh(entry)); // 对象转字符串
//网页中的代码,都要在一个闭包中执行,避免污染全局环境,基本格式如下
return `
(function(graph){
// 3. 在依赖图谱中,dependencies字段就存储了依赖的相对路径对应的绝对路径值
function localRequire(relativePath){
// 4. 然后再调用创建的require函数
return require(graph[module].dependencies[relativePath])
}
// 1. 创建一个require函数,根据依赖图谱,通过entry找到对应文件内容,通过eval()执行代码
function require(module){
(function(require, code){
eval(code) // 2. 执行到require方法,先调用内部定义的localRequire,参数是相对路径 ./message.js
})(localRequire, graph[module].code)
}
require('${entry}')
})(${graph})
`;
};
5. 完善 exports 对象
定义一个 exports 对象并传入立即执行函数,它也会捕获执行代码中的 exports 并写入定义的 exports 空对象中。下面就是完整的 generateCode 方法啦!
// step6 通过 dependencyGraph 生成可以在浏览器运行的代码
const generateCode = (entry) => {
const graph = JSON.stringify(getDependenciesGragh(entry));
return `
(function(graph){
function require(module){
function localRequire(relativePath){
return require(graph[module].dependencies[relativePath])
}
var exports = {};
(function(require,exports,code){
eval(code)
})(localRequire,exports,graph[module].code)
return exports;
}
require('${entry}')
})(${graph});
`;
};
const code = generateCode("./src/index.js");
console.log("code: ", code);
至此我们完成了从项目入口文件到生成浏览器可识别代码的全过程~
Step7 输出文件到 dist 目录下
下面将文件输出到dist目录下。这里我们简单写一下配置文件,只配置输入输出文件路径即可
// webpack.config.js
const path = require("path");
module.exports = {
entry: {
index: "./src/index.js",
},
output: {
path: path.resolve(__dirname, "dist"),
filename: "bundle.js",
},
};
将 step6 中生成的可以在浏览器运行的代码 code,写入到指定目录下
const config = require("./webpack.config");
const filePath = path.join(config.output.path, config.output.filename);
fs.writeFileSync(filePath, code, "utf-8");
完整 bundler.js 代码
// bundler.js
const fs = require("fs");
const path = require("path");
const paser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const babel = require("@babel/core");
const config = require("./webpack.config");
const moduleAnalyser = (filename) => {
// step1 通过 nodejs fs 模块,获取文件内容
const content = fs.readFileSync(filename, "utf-8");
// step2 paser.parse 将 JS代码转化成抽象语法树 AST (js对象)
const ast = paser.parse(content, {
sourceType: "module",
});
// step3 存放依赖对象
const dependencies = {};
// traverse 方法可以快速找到import节点
traverse(ast, {
ImportDeclaration({ node }) {
const dirname = path.dirname(filename); // './src'
const newFile = "./" + path.join(dirname, node.source.value); // ./src/message.js
dependencies[node.source.value] = newFile;
},
});
// step4 将AST编译成浏览器可以运行的代码
const { code } = babel.transformFromAst(ast, null, {
presets: ["@babel/preset-env"],
});
// 将模块分析结果返回
return {
filename,
dependencies,
code,
};
};
// step5 对所有模块进行分析,递归分析依赖结果
const getDependenciesGragh = (entry) => {
// 获取入口文件分析结果,存入一个数组
const entryModule = moduleAnalyser(entry); // { filename, dependencies, code}
const graphArray = [entryModule];
// 递归开始
for (let i = 0; i < graphArray.length; i++) {
const { dependencies } = graphArray[i]; // { './message.js': './src/message.js' }
if (dependencies) {
for (let j in dependencies) {
graphArray.push(moduleAnalyser(dependencies[j]));
// 此时graphArray长度+1,继续循环遍历,直到把所有依赖一层层push进graghArray,递归结束
}
}
}
// 格式转化
const dependencyGraph = {};
graphArray.forEach((item) => {
dependencyGraph[item.filename] = {
dependencies: item.dependencies,
code: item.code,
};
});
return dependencyGraph;
};
// step6 通过 dependencyGraph 生成可以在浏览器运行的代码
const generateCode = (entry) => {
const graph = JSON.stringify(getDependenciesGragh(entry));
return `
(function(graph){
function require(module){
function localRequire(relativePath){
return require(graph[module].dependencies[relativePath])
}
var exports = {};
(function(require,exports,code){
eval(code)
})(localRequire,exports,graph[module].code)
return exports;
}
require('${entry}')
})(${graph});
`;
};
const code = generateCode("./src/index.js");
// step7 输出文件到 dist 目录下
const filePath = path.join(config.output.path, config.output.filename);
fs.writeFileSync(filePath, code, "utf-8");
再次执行 node bundler.js
,此时 dist 目录下会默认生成 bundle.js
文件。可以将这段代码直接 copy 到浏览器的控制台中运行,可以正确输出结果 say hello
!
看!是不是很神奇呢!
至此打包流程结束,大功告成!!撒花 ✿✿ ヽ(°▽°)ノ ✿ ✌️✌️✌️