likes
comments
collection
share

「一劳永逸」从零实现webpack

作者站长头像
站长
· 阅读数 55

前言

为什么我们要学习webpack执行流程?

第一:webpack我们经常使用,开发必备,囊括了很多很有趣的功能,比如热更新,启动服务器,babel解析等等,简直就是一个包工头,让我们的开发变得简单,高效率

第二:从架构的角度,我们可以通过学习执行流程,进一步深入源码,来看看webpack的框架设计,学习一些优点

学习不是漫无目的的,不是看完文章就觉得自己掌握了他,要多问几个为什么?,多动手,多互动

源码github地址请参考 github.com/Y-wson/Dail…

通过这篇文章我们能学到什么?

    1. loader和plugin的原理,以及简单的loader和plugin的书写
    1. tapable的简单使用
    1. babel和ast是如何解析的
    1. webpack执行流程
    1. webpack打包的文件是如何执行的
    1. 实现一个简单的webpack

执行流程

我们先来用文字说一下webpack的执行流程

  • 初始化参数:从配置文件和Shell语句中读取与合并参数,得出最终的参数;
  • 开始编译:用上一步得到的参数初始化Compiler类,加载所有配置的插件,执行对象的run方法开始执行编译; 确定入口:根据配置中的entry找出所有的入口文件
  • 编译模块:从入口文件出发,调用所有配置的Loader对模块进行编译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理;
  • 完成模块编译:在经过第3步使用Loader翻译完所有模块后,得到了每个模块被翻译后的最终内容以及它们之间的依赖关系
  • 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个Chunk转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会
  • 输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统

「一劳永逸」从零实现webpack

代码实现

接下来我们根据上面的执行流程来一点一点展开讲解

在写代码之前,我们先把项目用到的库说一下

@babel/core             babel核心库,可以用来依据plugin和presets转换代码
@babel/preset-env       用来把es6代码转换为es5代码
ejs                     模板文件,支持在html中写js
tapable                 一种发布订阅模式,webpack实现把插件串联起来的核心

大家yarn add安装一下

目录结构如下

「一劳永逸」从零实现webpack

然后我们先简单写一个webpack.config.js,webpack.config.js大家都知道是做什么的,就是webpack的配置项

// webpack.config.js
const path = require("path");

module.exports = {
  context: process.cwd(), // 当前的根目录
  mode: "development", // 工作模式
  entry: path.join(__dirname, "src/index.js"), // 入口文件
  output: { // 出口文件
    filename: "bundle.js",
    path: path.join(__dirname, "./dist"),
  },
  module: {// 要加载模块转化loader
    rules: [
      {
        test: /\.js$/,
        use: [
          {
            loader: path.join(__dirname, "./loaders/babel-loader.js"),
            options: {
              presets: ["@babel/preset-env"],
            },
          },
        ],
      },
    ],
  },
  plugins: [new RunPlugin(), new DonePlugin()], //插件
};

第一步:初始化参数:从配置文件和Shell语句中读取与合并参数,得出最终的参数;

这一步实现起来很简单

// lib/Compiler.js
class Compiler {

}

let options = require("../webpack.config");

第二步: 开始编译:用上一步得到的参数初始化Compiler类,

这里我们写一个Compiler类,代表编译的意思,初始化options为webpack.config.js中配置的内容

//lib/Compiler
class Compiler {
    constructor(options){
        this.options = options;
    }
}

let options = require("../webpack.config");

第三步:加载所有配置的插件(说的加载,其实是注册的意思,这里只是注册,然后在相应的阶段,执行相应的参数,比如在编译开始的时候,我们执行runPlugin插件,输出开始编译文字),执行对象的run方法开始执行编译;

大家都知道插件是一个类,这就是我们为什么在webpack.config.js中配置plugins中配置类的时候,要new一下,因为插件是一个类的原因,把类的实例传给plugins

plugins: [new RunPlugin(), new DonePlugin()], //插件

我们这里面加载了两个类,new RunPlugin,开始编译的意思 new DonePlugin就是执行完成的意思

每一个插件里面都要有一个apply方法,用于注册插件,然后把Compiler的实例传给插件,插件就可以监听run钩子了,注意这里只是监听,等待广播后才可以执行

是不是感觉很熟悉,对的,这就是发布订阅者模式的应用,那webpack里面是如何实现发布订阅者模式的呢?

// plugins/RunPlugin
module.exports = class RunPlugin {
// 注册插件
    apply(compiler) {
        compiler.hooks.run.tap("RunPlugin", () => {
            console.log("RunPlugin");
        });
    }
};

这里我们要说一个库, tapable,这个库非常重要,webpack之所以能把插件串联起来,完全归功于tapable,

let { SyncHook } = require("tapable");
let hook = new SyncHook();
// 监听
hook.tap("some name", () => {
    console.log("some name");
});
// 触发
hook.call();

第四步:确定入口:根据配置中的entry找出所有的入口文件

定义了两个钩子,一个run钩子,一个done钩子,再来解释一下这个hooks,我们可以把这个钩子想象为react中的生命周期函数,有开始的声明周期run,有结束的声明周期done,而插件可以想想为组件,只要webpack执行相应的生命周期函数,那么插件中的生命周期函数中的代码块就会执行

// 使用同步钩子
let { SyncHook } = require("tapable");

class Compiler {

  constructor(options) {
    
    this.options = options;
    // 定义两个钩子
    this.hooks = {
         run: new SyncHook(),
         done: new SyncHook(),
    };
  }
    run() {
        this.hooks.run.call(); // 触发run钩子执行
        let entry = path.join(this.options.context, this.options.entry);
    }
}

let options = require("../webpack.config");
let compiler = new Compiler(options);
if (options.plugins && Array.isArray(options.plugins)) {
    for (const plugin of options.plugins) {
        // 调用插件的注册方法
        plugin.apply(compiler);
    }
}

我们可以把new Compiler这一部分放到一个单独的文件夹webpack.js中

// bin/webpack.js
const Compiler = require("../lib/Compiler");

// 1.获取打包配置
const config = require("../webpack.config");

// 2.创建一个compiler实例

const createCompiler = function () {
  // 创建compiler实例
  const compiler = new Compiler(config);
  // 加载插件
  if (Array.isArray(config.plugins)) {
    for (const plugin of config.plugins) {
      plugin.apply(compiler);
    }
  }
  return compiler;
};

const compiler = createCompiler();
// 3.开启编译
compiler.run();

那么package.json配置一下build就可以指向webpack.js

"scripts": {
    "build": "node bin/webpack.js"
},

第五步:从入口文件出发,调用所有配置的Loader对模块进行编译,


run(){
        this.hooks.run.call(); // 触发run钩子执行
        let entry = path.join(this.options.context, this.options.entry);
        // 构建模块
        this.buildModule(entry, true);
}

我们知道webpack里面有module,chunk,file三种概念

file>chunk>module

时间原因,我们这里只通过module演示

在构造函数中我们初始化modules,用来保存模块

 constructor(options) {
    this.options = options;
    this.modules = [];
    this.hooks = {
      run: new SyncHook(),
      done: new SyncHook(),
    };
  }

第六步:构建模块,并进行广度优先遍历所有依赖的子模块

读取源代码


buildModule(modulePath, isEntry) {
    // 模块源代码
    const source = this.getSource(modulePath);
}

模块的源代码我们要经过loader编译

所以先写一个loader,配置到webpack.config.js中

module: {
    rules: [
      {
        test: /\.js$/,
        use: [
          {
            loader: path.join(__dirname, "./loaders/babel-loader.js"),
            options: {
              presets: ["@babel/preset-env"],
            },
          },
        ],
      },
    ],
  },

babel-loader.js是非常具有代表性的loader,所以我们就写这个loader

babel-loader中我们直接选择把options中的presets加载一下就可以了

loader/babel-loader.js
const babel = require("@babel/core");

const loader = function (source, options) {
  let result = babel.transform(source, {
    presets: options.presets,
  });
  return result.code;
};

module.exports = loader;

然后我们的getSource也就能写出来了,返回进行编译后的源码

getSource(modulePath) {
    // 读取文件内容
    let content = fs.readFileSync(modulePath, "utf-8");
    const rules = this.options.module.rules;
    for (let rule of rules) {
      const { test, use } = rule;
      if (test.test(modulePath)) {
        // 递归所有loader
        // use此处我们当做数组来处理,按照从右往左的方式执行
        let length = use.length - 1;
        function loopLoader() {
          // 按照从右往左执行
          const { loader, options } = use[length--];
          let loaderFunc = require(loader);
          // loader是一个函数
          content = loaderFunc(content, options);
          if (length >= 0) {
            loopLoader();
          }
        }
        if (length >= 0) {
          loopLoader();
        }
      }
    }
    return content;
  }

第七步:再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理;

我们如何找出该模块依赖的模块呢,我们要使用ast(抽象语法树了),可以方便的获取到依赖的模块,这是第一点我们要做的,还有一点要做的就是我们加载的模块里面的路径都是相对路径,所以我们也要把路径传进去,得到如何当前解析模块的相对路径

 // 构建模块,并进行广度优先遍历所有依赖的子模块
  buildModule(modulePath, isEntry) {
    // 模块源代码
    const source = this.getSource(modulePath);
    // replace是在兼容windows系统
    modulePath =
      "./" + path.relative(this.root, modulePath).replace(/\\/g, "/");
    const { sourceCode, dependencies } = this.parse(source, modulePath);
    // 这里一个完整的模块就好了,保存到modules中
    this.modules[modulePath] = JSON.stringify(sourceCode);
    // 递归获取所有的模块依赖,并保存所有的路径与依赖的模块
    dependencies.forEach((d) => {
      this.buildModule(path.join(this.root, d));
    }, false);
  }

而相对的解析代码为

 // 根据模块的源码进行解析
  parse(source, moduleName) {
    let dependencies = [];
    const dirname = path.dirname(moduleName);
    const requirePlugin = {
      visitor: {
        // 替换源码中的require为__webpack_require__
        CallExpression(p) {
          const node = p.node;
          if (node.callee.name === "require") {
            node.callee.name = "__webpack_require__";
            // 路径替换
            let modulePath = node.arguments[0].value;
            modulePath =
              "./" + path.join(dirname, modulePath).replace(/\\/g, "/");
            node.arguments = [t.stringLiteral(modulePath)];
            dependencies.push(modulePath);
          }
        },
      },
    };
    let result = babel.transform(source, {
      plugins: [requirePlugin],
    });
    return {
      sourceCode: result.code,
      dependencies,
    };
  }

第八步:输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个Chunk转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会

因为时间关系,我们就不把模块组装成chunks,file了。

经过上面的一番操作,我们已经完成90%的工作量了,接下来就可以把文件打包出来了,完善一下我们的run方法,

第九步:输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统

run() {
    this.hooks.run.call();
    const entry = this.options.entry;
    this.buildModule(entry, true);
    const outputPath = path.resolve(this.root, this.options.output.path);
    const filePath = path.resolve(outputPath, this.options.output.filename);
    // 输出文件
    this.mkdirp(outputPath, filePath);
  }

在写一下mkdirp方法

 mkdirp(outputPath, filePath) {
    console.log("simple-webpack ------------------> 文件输出");
    const { modules, entryPath } = this;
    //创建文件夹
    if (!fs.existsSync(outputPath)) {
      fs.mkdirSync(outputPath);
    }
    ejs
      .renderFile(path.join(__dirname, "Template.ejs"), { modules, entryPath })
      .then((code) => {
        fs.writeFileSync(filePath, code);
        console.log("simple-webpack ------------------> 打包完成");
      });
  }

ejs模板为

(function (modules) {
  var installedModules = {};

  function __webpack_require__(moduleId) {
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }

    var module = installedModules[moduleId] = {
      i: moduleId,
      l: false,
      exports: {}
    };

    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    module.l = true;
    return module.exports;
  }

  return __webpack_require__("<%-entryPath%>");
})
({
  <%for (const key in modules) {%>
      "<%-key%>":
      (function (module, exports, __webpack_require__) {
          eval(<%-modules[key]%>);
      }),
  <%}%>
});

好啦,终于完成了,执行一下打包命令,npm run build

(function (modules) {
  var installedModules = {};

  function __webpack_require__(moduleId) {
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }

    var module = (installedModules[moduleId] = {
      i: moduleId,
      l: false,
      exports: {},
    });

    modules[moduleId].call(
      module.exports,
      module,
      module.exports,
      __webpack_require__
    );
    module.l = true;
    return module.exports;
  }

  return __webpack_require__("./src/index.js");
})({
  "./src/index.js": function (module, exports, __webpack_require__) {
    eval(
      '"use strict";\n\nvar _app = __webpack_require__("./src/app.js");\n\nconsole.log(_app.a);'
    );
  },

  "./src/app.js": function (module, exports, __webpack_require__) {
    eval(
      '"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n  value: true\n});\nexports.a = void 0;\nvar a = "app";\nexports.a = a;'
    );
  },
});

然后我们用index.html文件引用一下我们打包出来的bundle.js,看是否报错 index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body></body>
  <script src="./dist/bundle.js"></script>
</html>

打开浏览器一看,发现我们想要的结果就历历在目,大功告成

「一劳永逸」从零实现webpack

小伙伴们可以按照上面的代码进行手写一下,如果有任何疑问,欢迎评论区留言,

总结

根据开头的webpack执行流程,我们手写了一遍webpack源码,通过手写源码,我们加深了对webpack的执行流程的认识,各位小伙伴们一定要多动手写起来

参考:

手写一个webpack,看看AST怎么用

simple-webpack

转载自:https://juejin.cn/post/7058217261336625182
评论
请登录