深入理解webpack原理 (下)

1,103 阅读6分钟

上一篇文章《深入理解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的处理,导致最终编译结果是“大松鼠”