上一篇文章《深入理解webpack原理(上)》中,我主要是从理论层面上介绍了webpack的一些基本原理。而本文将会从代码层面上带大家一起来认识webpack。
一、手写实现webapck打包原理
1、AST抽象语法树
由于webpack的打包过程实际上是利用了AST语法树来实现的,因为在具体讲解webpack打包原理之前,我们一起来了解下什么是AST语法树。
(1) 什么是AST抽象语法树?
在计算机科学中,抽象语法树(Abstract Syntax Tree,AST),或简称语法树(Syntax tree),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。之所以说语法是“抽象”的,是因为这里的语法并不会表示出真实语法中出现的每个细节。比如,嵌套括号被隐含在树的结构中,并没有以节点的形式呈现;而类似于 if-condition-then 这样的条件跳转语句,可以使用带有两个分支的节点来表示。
(2) 源码和AST转换对比
- 推荐一个源码转换为AST的网站: astexplorer.net
- 用JavaScript编写一个Person类,观察源码和转换后的结构
(3) AST抽象语法树应用场景
在不了解AST抽象语法树之前,我们会觉得它好像很陌生,离我们很遥远。然而,其实并不是。平常开发中,应用到AST抽象语法树的场景还是蛮多的,例如:Babel、Sass、Less、ESLint、TypeScript 等
2、webpack打包流程
(1) 读取入口文件
(2) 将源码转换为AST语法树
(3) 深度遍历AST语法树,找到所有的依赖,并加入到一个数组中
(4) 将AST代码转换为可执行的JavaScript代码
(5) 编写 require 函数,自动执行完所有依赖
3、具体代码实现
(1) 读取入口文件
使用node中fs.readFileSync读取入口文件的内容
var fs = require('fs');
var code = fs.readFileSync(filename, 'utf-8');
(2) 将源码转换为AST语法树
利用 @babel/parser 将ES6的代码转换为AST语法树,并且
var fs = require('fs');
var babely = require('@babel/parser');
var code = fs.readFileSync(filename, 'utf-8');
var ast = babely.parse(code, {
sourceType: 'module'
});
(3) 深度遍历AST语法树,找到所有的依赖,并加入到一个数组中
- craeteAsset主要做:
- 使用 nodejs 中的 file 模块获取文件内容。
- 利用 @babel/parser 将ES6的代码转换为AST语法树
- 使用 @babel/traverse 对 AST 语法树进行遍历,把依赖保存在 dependencies 数组中
- 使用 @babel/core 将 AST代码转化为浏览器可以直接识别运行的 ES5 代码
- 最后,函数返回: id、filename、dependencies、code
- createGraph 主要做:
- 接收入口文件路径, 递归调用craeteAsset方法
- 返回一个文件依赖图情况: queue
var path = require('path');
var fs = require('fs');
var babely = require('@babel/parser');
var walker = require('@babel/traverse').default;
const babel = require('@babel/core');
var id = 0;
// 创建一个资源文件
function craeteAsset(filename) {
var code = fs.readFileSync(filename, 'utf-8');
var dependencies = [];
var ast = babely.parse(code, {
sourceType: 'module'
});
// 把依赖的文件写入进来
walker(ast, {
// 每当遍历到import语法的时候
ImportDeclaration: ({ node }) => {
// 把依赖的模块加入到数组中
dependencies.push(node.source.value);
}
});
const result = babel.transformFromAstSync(ast, null, {
presets: ['@babel/preset-env']
});
var module = {
id: id++,
filename: filename,
dependencies,
code: result.code
};
return module;
}
// 深度遍历
function createGraph(entry) {
var mainAsset = craeteAsset(entry);
var queue = [mainAsset];
for (let asset of queue) {
var baseDirPath = path.dirname(asset.filename);
asset.mapping = {};
asset.dependencies.forEach(filename => {
var realPath = path.join(baseDirPath, filename);
var childAsset = craeteAsset(realPath);
// 给子依赖项赋值
asset.mapping[filename] = childAsset.id;
queue.push(childAsset);
});
}
return queue;
}
(4) 实现CMD API 整合模块化代码
- bundle函数的参数是调用上一步 createGraph生成的结果(数组)。
- 遍历文件依赖图,以字符串拼接的形式,将可执行代码、mapping信息等组装起来。
- 编写require方法,循环加载所有依赖项,并执行(因为打包出来的代码是 commonjs 语法,这里为了解析 require 方法)。
- 返回最终的处理结果
function bundle(graph) {
var modules = `{`;
// 拼接modules字符串
graph.forEach((item, index) => {
modules += `
${index}:{
fn:function(require,module,exports){
${item.code}
},
mapping:${JSON.stringify(item.mapping)}
},
`;
});
modules += '}';
var result = `
(function(graph){
var module = {exports:{}};
function require(id){
var {fn,mapping} = graph[id];
function localRequire(name){
// 处理依赖映射,把依赖的文件名,转换成对应的对象索引
return require(mapping[name]);
}
// 运行asset代码
fn(localRequire,module,module.exports);
return module.exports;
}
// 运行入口文件
return require(0);
})(${modules})
`;
return result;
}
4、完整的代码实现
var path = require('path');
var fs = require('fs');
var babely = require('@babel/parser');
var walker = require('@babel/traverse').default;
const babel = require('@babel/core');
var id = 0;
// 创建一个资源文件
function craeteAsset(filename) {
var code = fs.readFileSync(filename, 'utf-8');
var dependencies = [];
var ast = babely.parse(code, {
sourceType: 'module'
});
// 把依赖的文件写入进来
walker(ast, {
// 每当遍历到import语法的时候
ImportDeclaration: ({ node }) => {
// 把依赖的模块加入到数组中
dependencies.push(node.source.value);
}
});
const result = babel.transformFromAstSync(ast, null, {
presets: ['@babel/preset-env']
});
var module = {
id: id++,
filename: filename,
dependencies,
code: result.code
};
return module;
}
// 深度遍历
function createGraph(entry) {
var mainAsset = craeteAsset(entry);
var queue = [mainAsset];
for (let asset of queue) {
var baseDirPath = path.dirname(asset.filename);
asset.mapping = {};
asset.dependencies.forEach(filename => {
var realPath = path.join(baseDirPath, filename);
var childAsset = craeteAsset(realPath);
// 给子依赖项赋值
asset.mapping[filename] = childAsset.id;
queue.push(childAsset);
});
}
return queue;
}
function bundle(graph) {
var modules = `{`;
// 拼接modules字符串
graph.forEach((item, index) => {
modules += `
${index}:{
fn:function(require,module,exports){
${item.code}
},
mapping:${JSON.stringify(item.mapping)}
},
`;
});
modules += '}';
var result = `
(function(graph){
var module = {exports:{}};
function require(id){
var {fn,mapping} = graph[id];
function localRequire(name){
// 处理依赖映射,把依赖的文件名,转换成对应的对象索引
return require(mapping[name]);
}
// 运行asset代码
fn(localRequire,module,module.exports);
return module.exports;
}
// 运行入口文件
return require(0);
})(${modules})
`;
return result;
}
var graph = createGraph('./example/entry.js');
var result = bundle(graph);
二 、编写一个简单的 webapck plugin
1、webpack plugin 通俗介绍
- plugi通常是在webpack在打包的某个时间点做一些操作
- plugin是一个类: 因为我们使用plugin时候,一般都是 new Plugin()的形式
- plugin类需实现apply方法: webpack打包时,会调用plugin的apply方法来执行相关逻辑,这个方法需要接受一个compiler作为参数
2、手写一个简单的 webpack plugin
(1) 配置webpack.config.js
引入自定义的plugin文件 demo-plugin.js, 并在 plugins中实例化配置
'use strict'
const path = require('path');
const demoPlugin = require('./demo-plugin.js');
module.exports = {
mode:'development',
entry:'./src/index.js',
output:{
path: path.join(__dirname, 'dist'),
filename: 'bundle.js'
},
plugins: [
new demoPlugin()
]
}
(2) 编写自定义plugin
plugin是一个类,并且拥有apply方法,接受webpack编译器compiler作为参数。这样在apply函数中,可以使用 compiler 上定义个各种钩子函数等,进行编写plugin。
class demoPlugin{
constructor(){
console.log('初始化...');
}
apply(compiler){
// 同步
compiler.hooks.compile.tap('fn1', (compilation) =>{
console.log(compilation)
});
// 异步
compiler.hooks.emit.tapAsync('fn2',(compilation,fn) =>{
// 生成一个 index.md文件
compilation.assets["index.md"] = {
source: function(){
return '我是一个自定义plugin,请查阅'
},
size: function(){
return 25;
}
}
fn();
})
}
}
module.exports = demoPlugin;
(3) 输出结果
运行结果为: 生成一个内容为 “我是一个自定义plugin,请查阅”的index.md文件
三、编写一个简单的 webapck loader
loader本质上是一个函数,这个函数会在我们加载一些文件时候执行
1、编写loader的注意事项
- 编写的loader函数返回值必须是一个 buffer 或者 string 函数
- loader 中的this包含了很多信息,所以在编写时候建议不要使用箭头函数
- 可以借助 loader-utils 来编写很多复杂的loader功能
2、实现同步loader
(1) 在webpack.config.js 中配置自定义loader:syncLoader
'use strict'
const path = require('path');
module.exports = {
mode:'development',
entry:'./src/index.js',
output:{
path: path.join(__dirname, 'dist'),
filename: 'bundle.js'
},
//加载loader的查找方式
resolveLoader: {
modules: ['node_modules', './']
},
module: {
rules: [
{
test: /\.js$/,
use: [{
loader: 'syncLoader',
options: {
message: '大松鼠'
}
}]
}
]
}
}
======= 入口文件 index.js ======
var a = '我是码农'
console.log(a);
(2) 编写 syncLoader
- 根据webapck.config.js配置的options.message, 将文件中的“码农”文字替换为 “大松鼠”
- 使用 loader-utils 中的 getOptions函数,传递上下文作用域,可以获取到webpack.config.js中配置的options参数
- 最后需要返回处理过后的值,这里需要注意:
- 单个处理结果,可以在同步模式中直接返回
- 如果有多个处理结果,则必须调用 this.callback()
const {
getOptions
} = require("loader-utils");
module.exports = function(source){
// 必须有返回值,而且必须是 buffer 或者是字符串类型
console.log(getOptions(this).message);
let res = source.replace("码农", getOptions(this).message);
this.callback(null, res)
}
(3) 编译情况
在package.json文件中配置命令:npm run build执行webpack.config.js文件。项目中根据配置文件,生成一个dist文件夹,包含编译结果bundle.js
==== package.json 配置编译命令 =====
"scripts": {
"build": "webpack webpack.config.js"
}
3、实现异步loader
实现异步的loader和同步的方式大同小异,但是需要注意的是:在异步模式中,必须使用 this.async() 来返回结果。相当于将同步中的this.callback() 改为使用 this.async()。
(1) 在webpack.config.js 中配置自定义loader:asyncLoader
在同步loader配置文件中,增加 asyncLoader 的 配置
'use strict'
const path = require('path');
module.exports = {
mode:'development',
entry:'./src/index.js',
output:{
path: path.join(__dirname, 'dist'),
filename: 'bundle.js'
},
//加载loader的查找方式
resolveLoader: {
modules: ['node_modules', './']
},
module: {
rules: [
{
test: /\.js$/,
use: [{
loader: 'syncLoader',
options: {
message: '大松鼠'
}
},
{
loader: 'asyncLoader'
}
]
}
]
}
}
(2) 编写 asyncLoader
使用this.async()来返回处理结果。将文件中的“码农”替换为“大松鼠君”
module.exports = function(source){
// 必须有返回值,而且必须是buffer或者字符串类型
let asyncCallback = this.async();
let res = source.replace("码农", "大松鼠君");
console.log('大松鼠君')
asyncCallback(null,res)
}
(3) 编译结果
- 观看编译结果,最终“码农”被替换成“大松鼠”而非“大松鼠君”。
- 根据loader执行顺序我们知道,asyncLoader配置在syncLoader的后面,按理应该是先执行asyncLoader,将“码农”替换为“大松鼠君”,再执行syncLoader查找是否有“码农”字样,将其替换为“大松鼠”。
- 而结果并不是,原因是: 但是由于asyncLoader是异步loader,所以编译时候先进行了同步loader的处理,导致最终编译结果是“大松鼠”