从一个简单的webpack学起

93 阅读5分钟

背景

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)
我们将需求进行拆解成下面的三个步骤

  1. 利用bebel完成代码转换,并生成单个文件的依赖。
  2. 生成依赖图谱
  3. 生成最后的打包代码

第一步:转换代码、生成依赖

//先安装好相应的包
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转换之后的代码。   
    } 
}

image.png

第三步:生成代码字符串

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就做好了,执行一下文件编译,会出现以下内容

image.png

学习实现一个最简单的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主流程

  1. 转换代码、生成依赖
  2. 生成依赖图谱
  3. 生成代码字符串

Loader

  1. 转换代码字符串内容,image,txt等等,参数为文档代码

Plugin

  1. 扩展 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()

参考文档

实现一个简单的webpack - 掘金

实践-写一个简单的 webpack-loader - 掘金

从零实现一个 Webpack Plugin - 掘金

[万字总结] 一文吃透 Webpack 核心原理