手写一个Webpack,带你了解构建流程

如果对前端八股文感兴趣,可以留意公重号:码农补给站,总有你要的干货。

前言

Webpack是一个强大的打包工具,拥有灵活、丰富的插件机制,网上关于如何使用WebpackWebpack原理分析的技术文档层出不穷。最近自己也是发现面试官问到Webpack特别喜欢问构建流程,那么本文主要探讨,Webpack的一次构建流程中,主要干了哪些事儿,带领您手写一个打包工具。

手写一个Webpack,带你了解构建流程_第1张图片

真是卷...

本文主要讲的是基本的构建和输出打包,不包含treeshaking、热更新等其他功能的内容。

基本架构

手写一个Webpack,带你了解构建流程_第2张图片

构建流程

准备阶段

从配置文件中读取到配置参数,传入配置参数实例化一个Compiler编译器,执行编译器的run方法开始编译。

 
  
const path = require('path');
const Compiler = require('../lib/Compiler.js');
let config = require(path.resolve('webpack.config.js')); // 从webpack.config.js中获取配置

let compiler = new Compiler(config); // 实例化一个Compiler编译器

compiler.run();  // 执行编译器的run方法

开始编译

 
  
class Compiler {
  constructor(config) {
    this.config = config; // 配置文件
    this.entryId; // 入口文件名字
    this.modules = {}; // 依赖模块的集合
    this.entry = config.entry; // 入口路径
    this.root = process.cwd();
  }

  run() {
    this.buildModule(path.resolve(this.root, this.entry), true);
  }
}

Compiler初始化阶段就存储了配置文件config、入口路径entry、根路径root,定义了依赖模块的集合modules和入口文件名entryId。其中后续我们解析到的所有模块内容都会存储在modules

run方法从配置中获取入口文件,从入口文件开始buildModule

 
  
buildModule(modulePath, isEntry) { // modulePath 模块路径  isEntry是否是入口文件
    // 拿到模块内容
    let source = this.getSource(modulePath); 
    let moduleName = './' + path.relative(this.root, modulePath); // src/index.js
    if (isEntry) {
        // 如果是入口文件获取入口文件名
      this.entryId = moduleName;
    }
    
    // 开始解析文件依赖
    const { sourceCode, dependencies } = this.parse(source, path.dirname(moduleName));
    this.modules[moduleName] = sourceCode;
    dependencies.forEach(dep => { // 递归加载模块
      this.buildModule(path.join(this.root, dep), false);
    })
 }
 
 parse(source, parentPath) { // 解析源码返回依赖列表  parentPath ./src
 
    // 解析源码获取ast语法树
    let ast = babylon.parse(source);
    let dependencies = [];
    
    // 解析ast语法树获取关联的依赖
    traverse(ast, {
      CallExpression(p) {
        let node = p.node;
        if (node.callee.name === 'require') {
          node.callee.name = '__webpack_require__';
          let moduleName = node.arguments[0].value; // 取到模块的引用名字
          moduleName = moduleName + (path.extname(moduleName) ? '' : '.js');
          moduleName = './' + path.join(parentPath, moduleName);
          dependencies.push(moduleName);
          node.arguments = [t.stringLiteral(moduleName)];
        }
      }
    })
    let sourceCode = generator(ast).code;
    return {
      sourceCode, //  源码
      dependencies // 关联的依赖
    };
  }

过程如下: buildModule中接收两个参数modulePath模块路径、isEntry是否是入口文件。拿到模块文件中内容,并获取入口文件名称。 parse中也是接收两个参数source文件内容,以及父路径parentPath。将文件内容通过babylon插件解析成AST语法树,然后通过@babel/traverse解析语法树获取其关联的依赖文件。递归解析依赖文件将所有模块都存入modules中。

打包输出

 
  
run() {
    this.buildModule(path.resolve(this.root, this.entry), true);
    // 发射一个文件
    this.emitFile();
  }

  emitFile() { // 发射一个文件
    // 从配置文件中获取打包输入路径和文件名
    let main = path.join(this.config.output.path, this.config.output.filename);
    // 获取模板
    let templateStr = this.getSource(path.join(__dirname, 'main.ejs'));
    let code = ejs.render(templateStr, { entryId: this.entryId, modules: this.modules });
    this.assets = {};
    this.assets[main] = code;
    fs.writeFileSync(main, this.assets[main]);
  }

再此之间前我们的文件都已经解析好了存在modules中。从入口文件获取打包输出的文件路径和文件名,然后获取一个打包输出的文件模板,文件模板是要一个.ejs文件。

 
  
// main.ejs

(() => {
    var __webpack_modules__ = ({
      <%for(let key in modules){%>
        "<%-key%>":
        ((module, exports, __webpack_require__) => {
  
          eval(`<%-modules[key]%>`);
        }),
      <%}%>
    });
    var __webpack_module_cache__ = {};
    function __webpack_require__(moduleId) {
      var cachedModule = __webpack_module_cache__[moduleId];
      if (cachedModule !== undefined) {
        return cachedModule.exports;
      }
      var module = __webpack_module_cache__[moduleId] = {
        exports: {}
      };
      __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
      return module.exports;
    }
    var __webpack_exports__ = __webpack_require__("<%-entryId%>");
  })()
    ;

文件模板中我们可以看到,其实里面是一个自我执行函数,从入口<%-entryId%>开始依次从modules中获取文件代码内容,并执行。

最终生成assets,将每个assets打包到指定位置。

loader

loader本质上是一个函数,参数content是一段字符串,存储着文件的内容,最后将loader函数导出就可以提供给webpack使用。

我们来实现一个less-loaderstyle-loader:

 
  
// less-loader

const less = require('less'); // npm install less -D

function loader(content) {
  let css = '';
  less.render(content, (err, c) => {
    css= c.css;
  })
  return css;
}

module.exports = loader;

 
  
// style-loader
function loader(content) {
  let style = `
    let style = document.createElement('style');
    style.innerHTML = ${JSON.stringify(content)}
    document.head.appendChild(style)
  `;
  return style;
}

module.exports = loader;

所以我们编译阶段获取文件内容的时候就需要匹配文件名来判断是否需要使用该loader

 
  
getSource(modulePath) {
    // 获取我们配置的rules
    let rules = this.config.module.rules || [];
    // 获取到指定路径的文件内容
    let content = fs.readFileSync(modulePath, 'utf-8');
     // 循环匹配,拿到每个规则来处理
    for (let i = 0; i < rules.length; i++) {
      let rule = rules[i];
      let { test, use = [] } = rule;
      let len = use.length - 1;
      // test正则匹配文件路径
      if (test.test(modulePath)) {
        function normalLoader() {
          let loader = require(use[len--]);
          if (loader) {
            content = loader(content);
          }
          if (len >= 0) {
            normalLoader();
          }
        }
        normalLoader();
      }
    }
    return content;
  }

总结

手写一个Webpack,带你了解构建流程_第3张图片

  1. 初始化参数。获取用户在webpack.config.js文件配置的参数
  2. 开始编译。初始化Compiler对象,执行run方法开始编译。
  3. 从入口文件出发,获取文件内容,如果配置了loader就匹配对应的loader来改变文件内容,开始解析文件构建AST语法树,找到依赖项,递归下去,并且将每个模块存储下来。
  4. 完成编译并输出。递归结束,得到每个文件结果,包含转换后的模块以及他们之前的依赖关系,根据entry以及output等配置生成代码块chunk
  5. 输出文件。


原文链接:https://juejin.cn/post/7298927442488197157
 

你可能感兴趣的:(前端)