背景
Webpack 特别难学!!!我之前看文档,看别人的学习笔记,又多又长,看看就困了,也尝试看源码,更多更长了,太难了,看不懂,什么模块打包、代码分割、按需加载、HMR、Tree-shaking、文件监听、sourcemap、Module Federation、devServer、DLL、多进程等等,看不懂
后面我就想先从实践入手,我做一个最简单的webpack,这样就能大致知道webpack主要内容是什么,本文是基于以上想法,在网上搜索的资料,我已经从头实践了一遍,对webpack学习有困难的朋友可以跟着我的步骤走一走,我这里也是记录一下自己的学习和实践思路
学习一个最简单的mini-webpack
什么是webpack?
我们先来看看webpack官方的定义:
本质上,webpack 是一个现代 JavaScript 应用程序的静态模块打包器(module bundler)。当 webpack 处理应用程序时,它会递归地构建一个依赖关系图(dependency graph),其中包含应用程序需要的每个模块,然后将所有这些模块打包成一个或多个 bundle。
所以,本质上就是:将每个模块打包成相应的bundle
实操场景
现在有以下文件
// word.js
export const word = 'hello'
// message.js
import {word} from './word.js'
const message = `say ${word}`
export default message
// index.js
import message from './message.js'
console.log(message)
请编写一个compiler.js,将其中的ES6代码转换为ES5代码,并将这些文件打包,生成一段能在浏览器正确运行起来的代码。(最后输出say hello)
我们将需求进行拆解成下面的三个步骤
- 利用bebel完成代码转换,并生成单个文件的依赖。
- 生成依赖图谱
- 生成最后的打包代码
第一步:转换代码、生成依赖
//先安装好相应的包
npm install @babel/parser @babel/traverse @babel/core @babel/preset-env -D
转换代码需要利用@bebel/parser生成AST抽象语法树,然后利用@babel/traverse进行AST遍历,记录依赖关系,最后通过@babel/core和@babel/preset-env进行代码的转换。
// 核心函数
depAnalyse(filename) {
// 读取模块的内容
let content = fs.readFileSync(filename, "utf-8");
// 用于存取当前模块的所有依赖。便于后面遍历
let dependencies = {};
// 对文件内容进行解析并生成初始的抽象语法树
const ast = parser.parse(content, {
sourceType: "module", //babel官方规定必须加这个参数,不然无法识别ES Module
});
// 遍历ast 通过import找到依赖,存储依赖
traverse(ast, {
ImportDeclaration({ node }) {
// 去掉文件名 返回目录
const dirname = path.dirname(filename);
const newFile = path.join(dirname, node.source.value);
//保存所依赖的模块
dependencies[node.source.value] = newFile;
},
});
// transformFromAst 相当于是traverse和generate的结合
const { code } = babel.transformFromAst(ast, null, {
presets: ["@babel/preset-env"],
});
// generate 将ast转换为代码
// 把当前的依赖,和文件内容推到对象里面去
return {
filename,
dependencies, //该文件所依赖的模块集合(键值对存储)
code, //转换后的代码
};
}
第二步:生成依赖图谱
getAtlas(entry) {
// 转换代码、生成依赖
const entryModule = this.depAnalyse(entry);
this.analyseObj = [entryModule];
for (let i = 0; i < this.analyseObj.length; i++) {
const item = this.analyseObj[i];
const { dependencies } = item; //拿到文件所依赖的模块集合(键值对存储)
for (let j in dependencies) {
this.analyseObj.push(this.depAnalyse(dependencies[j])); //敲黑板!关键代码,目的是将入口模块及其所有相关的模块放入数组
}
}
//接下来生成图谱
const graph = {};
this.analyseObj.forEach((item) => {
graph[item.filename] = {
dependencies: item.dependencies,
code: item.code,
};
});
return graph;
}
一边遍历依赖数组,一边根据最开始入口的依赖信息,分析出更深层的依赖文件,并调用第一步的函数生成依赖,并把其依次加入到依赖数组中。
最终遍历生成的这个数组,为了展示他们之间的关系,我们使用map来存储, 生成最终的依赖关系图。
{
'index.js': {
dependencies: {'message.js': 'message.js'},
code: '转换后的代码' // 使用 babel.code 和 babel/preset-env转换之后的代码。
}
}
第三步:生成代码字符串
toEmitCode(entry, graph) {
//要先把对象转换为字符串,不然在下面的模板字符串中会默认调取对象的toString方法,参数变成[Object object],显然不行
graph = JSON.stringify(graph);
return `
(function(graph) {
//require函数的本质是执行一个模块的代码,然后将相应变量挂载到exports对象上
function require(module) {
//localRequire的本质是拿到依赖包的exports变量
function localRequire(relativePath) {
return require(graph[module].dependencies[relativePath]);
}
var exports = {};
(function(require, exports, code) {
eval(code);
})(localRequire, exports, graph[module].code);
return exports;//函数返回指向局部变量,形成闭包,exports变量在函数执行后不会被摧毁
}
return require('${entry}')
})(${graph})`;
}
这样一个简单的webpack就做好了,执行一下文件编译,会出现以下内容
学习实现一个最简单的loader
Webpack 默认只能处理 JS 文件,要处理其他类型的文件就要依赖 loader 了,比如 css-loader。
module: {
rules: [
{
test: /\.css$/, // 匹配 *.css 文件
use: ['style-loader', 'css-loader'], // 处理 *.css 文件的 loader
},
],
},
实现一个 textLoader
这里实现了一个简单的读取 .txt 文本文件的 textLoader,可以实现如下的功能: main.js
import article from './article.txt'; // 引入文本文件
console.log(article); // 打印字符串内容
article.txt (里面就是纯文本)
txt ceshi
经过我们的 textLoader 处理,JS代码就能直接引入 .txt 文件,并将内容读取为字符串了。nice~👍
那么是怎么做到的呢?其实只有一行代码:
module.exports = function loader(source) {
// loader的唯一参数,即包含源文件文本内容的字符串
return `export default ${JSON.stringify(source)}`;
};
webpack loader 只接收一个参数,它就是包含源文件文本内容的字符串,这里只是用 export default 包装了下就可以了😺,然后返回处理后的文本字符串。
在 webpack.config.js 中引入这个 loader:
module: {
rules: [
{
test: /\.txt$/, // 匹配 *.txt 文件
use: ['./loaders/textLoader.js'], // 处理 *.txt 文件的 loader
},
],
},
目前最简单的loader
raw-loader和json-loader几乎都是一样的,他们的目的就是把原文件所有的内容作为一个字符串导出,而json-loader多了一个json.parse的过程
学习实现一个最简单的plugin
创建自定义plugin步骤
- 声明一个js函数或class类(大驼峰命名)
- 定义一个原型方法apply,apply方法接收一个compiler对象,我们可以在apply方法中调用compiler对象的hooks事件。使用compilation操纵修改 webpack 内部实例特定数据。
- 在功能完成后调用 webpack 提供的回调。
基于以上的基础一个简单的plugin插件就出来啦!
class CleanWebpackPlugin {
// 构造函数
// 查看options具体参数: https://github.com/johnagan/clean-webpack-plugin#options-and-defaults-optional
constructor(options) {
console.log(options, 'options');
this.outputPath = '';
}
apply(compiler) {
console.log(compiler, 'compiler')
// compiler.options获取config文件或shell命令初始化的配置信息
this.outputPath = compiler.options.output.path;
// 绑定钩子事件
const hooks = compiler.hooks;
hooks.done.tap('clean-webpack-plugin', stats => {
console.log('done~')
});
}
}
引入自定义plugin
这个就很简单了,像平时引入plugin那样引入就OK了
// 引入插件
const { CleanWebpackPlugin } = require('./clean-webpack-plugin');
module.exports = {
plugins: [
// 实例化构造函数
new CleanWebpackPlugin(),
]
}
总结
以上就是学习一个简单的webpack一共走过的流程啦,希望能对大家有所帮助,从webpack主体流程=》loader实现=》plugin实现,一个简单的webpack是不是就成型了,有迹可循了,从这个基础上再进行深入的学习也会更简单一点
webpack主流程
- 转换代码、生成依赖
- 生成依赖图谱
- 生成代码字符串
Loader
- 转换代码字符串内容,image,txt等等,参数为文档代码
Plugin
- 扩展 webpack 能力,打包优化,资源管理,注入环境变量等等
实践代码如下
const fs = require("fs");
const path = require("path");
// 整个文件只有一个默认导出
const traverse = require("@babel/traverse").default;
// 整个文件的导出都放在一个exports对象中
const parser = require("@babel/parser");
const { SyncHook } = require("tapable");
const babel = require('@babel/core');
class Compiler {
constructor(config) {
this.config = config;
this.entry = config.entry;
// this.root = process.cwd();
this.analyseObj = [];
this.rules = config.module.rules || [];
this.hooks = {
// 生命周期的定义
compile: new SyncHook(),
afterCompile: new SyncHook(),
emit: new SyncHook(),
afterEmit: new SyncHook(),
done: new SyncHook(),
test:new SyncHook(['compilation','args'])
};
// plugins数组中所有插件对象,调用apply方法,相当于注册事件
if (Array.isArray(this.config.plugins)) {
this.config.plugins.forEach((plugin) => {
plugin.apply(this);
});
}
debugger
}
start() {
// 开始编译了
this.hooks.compile.call();
// 分析依赖 生成图谱
const graph = this.getAtlas(this.entry);
// 编译完成了
this.hooks.afterCompile.call();
// 开始输出文件了
this.hooks.emit.call();
this.emitFile(graph);
this.hooks.afterEmit.call();
this.hooks.done.call();
this.hooks.test.call(graph);
}
emitFile(graph) {
let result = this.toEmitCode(this.entry, graph);
let outputPath = path.join(
this.config.output.path,
this.config.output.filename
);
fs.writeFileSync(outputPath, result);
// console.log(result)
}
getOriginPath(path1, path2) {
return path.resolve(path1, path2);
}
// 核心函数
depAnalyse(filename) {
// 读取模块的内容
let content = fs.readFileSync(filename, "utf-8");
// 用于存取当前模块的所有依赖。便于后面遍历
let dependencies = {};
// 对文件内容进行解析并生成初始的抽象语法树
const ast = parser.parse(content, {
sourceType: "module", //babel官方规定必须加这个参数,不然无法识别ES Module
});
// 遍历ast 通过import找到依赖,存储依赖
traverse(ast, {
ImportDeclaration({ node }) {
// 去掉文件名 返回目录
const dirname = path.dirname(filename);
const newFile = path.join(dirname, node.source.value);
//保存所依赖的模块
dependencies[node.source.value] = newFile;
},
});
// transformFromAst 相当于是traverse和generate的结合
let { code } = babel.transformFromAst(ast, null, {
presets: ["@babel/preset-env"],
});
this.rules.forEach(element => {
// filename.match()
if(element.test.test(filename)){
let loader = require(path.resolve(element.use[0]))
// console.log(loader);
code = loader(code)
}
});
// generate 将ast转换为代码
// 把当前的依赖,和文件内容推到对象里面去
return {
filename,
dependencies, //该文件所依赖的模块集合(键值对存储)
code, //转换后的代码
};
}
readFile(modulePath) {
return fs.readFileSync(modulePath, "utf-8");
}
getAtlas(entry) {
// 转换代码、生成依赖
const entryModule = this.depAnalyse(entry);
this.analyseObj = [entryModule];
for (let i = 0; i < this.analyseObj.length; i++) {
const item = this.analyseObj[i];
const { dependencies } = item; //拿到文件所依赖的模块集合(键值对存储)
for (let j in dependencies) {
this.analyseObj.push(this.depAnalyse(dependencies[j])); //敲黑板!关键代码,目的是将入口模块及其所有相关的模块放入数组
}
}
//接下来生成图谱
const graph = {};
this.analyseObj.forEach((item) => {
graph[item.filename] = {
dependencies: item.dependencies,
code: item.code,
};
});
return graph;
}
toEmitCode(entry, graph) {
//要先把对象转换为字符串,不然在下面的模板字符串中会默认调取对象的toString方法,参数变成[Object object],显然不行
graph = JSON.stringify(graph);
return `
(function(graph) {
//require函数的本质是执行一个模块的代码,然后将相应变量挂载到exports对象上
function require(module) {
//localRequire的本质是拿到依赖包的exports变量
function localRequire(relativePath) {
return require(graph[module].dependencies[relativePath]);
}
var exports = {};
(function(require, exports, code) {
eval(code);
})(localRequire, exports, graph[module].code);
return exports;//函数返回指向局部变量,形成闭包,exports变量在函数执行后不会被摧毁
}
return require('${entry}')
})(${graph})`;
}
}
module.exports = Compiler;
start部分
const path = require('path')
// 1. 读取需要打包项目的配置文件
// console.log('webpack---------',path.resolve('webpack.config.js'));
let config = require('../webpack.config.js')
// 核心 编译器
const Compiler = require('../lib/compiler')
let myCompiler = new Compiler(config)
myCompiler.start()