webpack实现原理

之前经常被webpack的配置搞得头大,chunkbundlemodule的关系傻傻分不清,loaderplugin越整越糊涂,今天为了彻底搞明白webpack的构建原理,决定手撕webpack,干一个简易版的webpack出来!

准备工作

在开始之前,还要先了解 ast 抽象语法树和理解事件流机制 tapablewebpack 在编译过程中将文件转化成ast语法树进行分析和修改,并在特定阶段利用tapable提供的钩子方法广播事件,这篇文章 Step-by-step guide for writing a custom babel transformation 推荐阅读可以更好的理解ast。

安装 webpackast 相关依赖包:

npm install webpack webpack-cli babylon @babel/core tapable -D 

分析模板文件

webpack默认打包出来的bundle.js文件格式是很固定的,我们可以尝试新建一个项目,在根目录下新建src文件夹和index.jssum.js

src
- index.js
- sum.js
- base
    - a.js // module.exports = 'a'
    - b.js // module.exports = 'b'
// sum.js
let a = require("./base/a")
let b = require("./base/b")
module.exports = function () {
  return a + b
}
// index.js
let sum = require("./sum");
console.log(sum());

同时新建 webpack.config.js 输入以下配置:

const {resolve} = require("path");

module.exports = {
  mode: "development",
  entry: "./src/index.js",
  output: {
    filename: "bundle.js",
    path: resolve(__dirname, "./dist"),
  }
}

在控制台输入webpack打包出dist文件夹,可以看到打包后的文件bundle.js,新建一个html文件并引入bundle.js可以在控制台看到打印的结果ab

我们这一步是要分析bundle.jsbundle其实是webpack打包前写好的模板文件,里面有几个关键的信息:

  • __webpack_require__方法,模板文件里面自带require方法,可以看到webpack在自己内部实现了CommonJS规范
  • 入口文件entry module,加载入口文件
  • module,引用路径和文件内容
// bundle.js

(
    function(module){
        ...
        // Load entry module and return exports
        return __webpack_require__(__webpack_require__.s = "./src/index.js");
    }
)({
    "./src/index.js":(function(module, exports, __webpack_require__){eval("let sum = __webpack_require__('./src/sum.js\')")}),
    "./src/sum.js":(function(module, exports){eval("module.exports=function(){return a+b}")})
})

module其实就是引用路径和文件内容的关系组合:

let module = {
    "./index.js":function(module,export,require){},
    "./sum.js":function(module,export){},
}

分析到这一步,我们知道这个模板文件对我们来说很有用了,接下来我们会写个编译器,分析出入口文件和其它文件与内容的关联关系,再导入到这个模板文件中就好了,所以我么可以新建template文件,将上面的内容复制进去先保存一份

准备构建器

首先如果我们要像webpack一样,在控制台输入webpack命令就能打包文件,就需要利用npm link添加命令,我们新建一个工程,切换到工作目录控制台下,输入npm init -y生成package.json文件,并在package.json中添加下面内容:

"bin":{
    "pack":"./bin/www"
}

切换到控制台输入npm link,就可以在全局使用pack命令打包文件了,接下来还要创建执行命令的脚本文件:

bin
    - www
//www

#! /usr/bin/env node
const {resolve} = require("path");
const config = require(resolve("webpack.config.js")); // 需要拿到当前执行命令脚本下的config配置参数

我们在当前目录下新建src文件,存放编译器文件和之前保存的template模板文件:

src
    - Compiler.js
    - template

Compiler作为我们的编译器导出:

class Compiler {
    constructor(config){
        this.config = config;
    }
    run(){

    }
}
module.exports = Compiler;

www导入并执行:

// www
const Compiler = require("../src/Compiler");
const compiler = new Compiler(config);
compiler.run();

分析构建流程

构建器 Compiler已经创建好了,接下来分析构建流程了:

确定入口(entry) -> 编译模块(module) -> 输出资源(bundle)

刚才分析模板文件的时候知道,我们需要确定入口文件,并确定每个模块的路径和内容,路径需要将require转换成__webpack_require__,引入地址需要转换成相对路径,最终渲染到模板文件并导出

确定入口

确定入口文件,我们需要知道两个必要参数:

  • entryName 入口名称
  • root 根路径 process.cwd()

所以我们先在constructor保存:

class Compiler {
  constructor(config) {
        this.config = config;
        this.entryName = "";
        this.root = process.cwd();
    }
}

构建module

接下来当然是构建module了,首先是找到入口文件,然后递归编译每一个模块文件:

constructor(config) {
    this.modules = {};// 保存模块文件       
}
run(){
    let entryPath = path.join(this.root, this.config.entry);
    this.buildModule(entryPath, true); // 入口文件
}
buildModule(modulePath, isEntry) {
    // 入口文件相对路径
    const relPath = "./"+path.relative(this.root,modulePath);
    if (isEntry) {
      // 如果是入口文件,保存起来
      this.entryName = relPath
    }
    // 读取文件内容
    let source = this.readSource(modulePath)
    // 父文件路径,迭代的时候传递
    let dirPath = path.dirname(relPath)
    // 编译文件
    let { code, dependencies } = this.parser(source, dirPath)
    // 保存编译后的文件路径和内容
    this.modules[relPath] = code;
    // 迭代
    dependencies.forEach((dep) => {
      this.buildModule(path.join(this.root, dep))
    })
  }
  parser(source, parentPath) {}

parser

parser 负责编译文件,这里主要有两步:
1、转换成ast树,分析并转换require和路径
2、存储该文件依赖的脚本,返回给buildModule继续迭代

编译文件,这里用到的是babylon,转换前可以先将内容放到astexplorer查看分析:

  parser(source, parentPath) {
    let dependencies = [] // 保存该文件引用的依赖
    let ast = babylon.parse(source); // babylon转换成ast
    traverse(ast, {
      // 在 astexplorer 分析
      CallExpression(p){
        let node = p.node;
        if (node.callee.name === "require") {
          // 替换成__webpack_require__
          node.callee.name = "__webpack_require__";
          // 第一个参数是引用路径,转换并保存到dependencies
          let literalVal = node.arguments[0].value;
          literalVal = literalVal + (path.extname(literalVal) ? "" :".js");
          let depPath = "./"+path.join(parentPath,literalVal);
          dependencies.push(depPath);
          node.arguments[0].value = depPath;
        }
      }
    })
    let {code}=generator(ast);
    return {
      code,
      dependencies
    }
  }

readSource

readSource 方法这里直接读取文件内容并返回,当然这里可操作的空间很大,像resolve这些配置,我觉得都可以在这里截取并处理,还有后面的loader

  readSource(p) {
    return fs.readFileSync(p, "utf-8");
  }

输出资源

通过buildModule方法,我们已经获取到了entryName入口文件和modules构建依赖,接下来需要转换成输出文件了,这时候我们可以用到之前保存的template文件,渲染的方式有很多,我这里使用的是ejs

npm install ejs

重命名templatetemplate.ejs,接下来写个emit方法输出文件:

emit() {
    // 读取模板内容
    let template = fs.readFileSync(
        path.resolve(__dirname, "./template.ejs"),
        "utf-8"
    )
    // 引入ejs并渲染模板
    let ejs = require("ejs");
    let renderTmp=ejs.render(template,{
        entryName:this.entryName,
        modules:this.modules
    });
    // 获取输出配置
    let {path:outPath,filename} = this.config.output;
    // 用assets保存输出资源,这里以后可能不止一个输出资源,方便用户处理
    this.assets[filename] = renderTmp;
    Object.keys(this.assets).forEach(assetName=>{
        // 获取输出路径并生成文件
        let bundlePath = path.join(outPath, assetName);
        fs.writeFileSync(bundlePath, this.assets[assetName])
    })
}
// template.ejs
  // Load entry module and return exports
  return __webpack_require__("<%=entryName %>")
  ...
  {
    <% for(let key in modules){ %>
   "<%=key %>":function (module, exports, __webpack_require__) {
        eval(`<%- modules[key]%>`)
      },
    <% } %>
  }

最后在 run 方法中执行 emit 方法:

  run() {
    let entryPath = path.join(this.root, this.config.entry)
    this.buildModule(entryPath, true);
    this.emit();
  }

Loader 和 Plugin

webpack当然少不了loaderplugin了,那么这两者到底有什么区别呢?可以看下这两张图:

image.png
image.png

Loader

Loader 本质就是一个函数,在该函数中对接收到的内容进行转换,返回转换后的结果。因为 Webpack 只认识 JavaScript,所以 Loader 就成了翻译官,对其他类型的资源进行转译的预处理工作。

因为webpack不认识除了JavaScript以外的其它内容,这里我们写几个loader来对less进行转换,这里写个loader来转换less代码,这里先安装 less 依赖包用来转换 less,然后新建loaders文件夹,里面新建style-loader.jsless-loader.js

loaders
 - less-loader.js
 - style-loader.js

修改 webpack.config.js ,引入新建的loader

module: {
    rules: [
        {
        test: /\.(less)$/,
        use: [
            path.resolve(__dirname, "./loaders/style-loader.js"),
            path.resolve(__dirname, "./loaders/less-loader.js"),
        ],
        },
    ],
},

less-loader

const less = require("less");

function loader(source) {
    // 转换 less 代码
    less.render(source, function (err, result) {
      source = result.css
    });
    // 返回转换后的结果
    return source;
}

module.exports = loader;

style-loader

前面说了,webpack 只识别 js 代码,所以最终返回的结果,需要是webpack能识别的 js 字符串

function loader(source) {
  // 将代码转成js字符串,webpack才能识别
  let code = `
    let styleEl = document.createElement("style");
    styleEl.innerHTML = ${JSON.stringify(source)};
    document.head.appendChild(styleEl);
  `;
  // 返回转换后的结果,替换换行符
  return code.replace(/\\n/g, '');
}

module.exports = loader;

compiler.readSource

接下来回到编译方法,之前在 readSource 阶段,我们直接返回了源码,简直暴殄天物,我们明明可以对源码做很多事情:

// old
readSource(p) {
    return fs.readFileSync(p, "utf-8");
}

这时候 loader 派上用场了,我们可以把读取的文件内容交给 loader 先处理一遍,再返回给后面的编译流程:

  readSource(p) {
    let content = fs.readFileSync(p, "utf-8");
    // 获取 rules 
    let {rules} = this.config.module;
    // 对 rules 进行遍历,不过 webpack 里面这里是从最后一个开始读取的,这里没做处理了
    rules.forEach(rule => {
        let { test, use } = rule;
        // 匹配到对应文件
        if(test.test(p)){
          let len = use.length-1;
          // 从右到左依次取出
          for(let i=len;i>=0;i--){
            content = require(use[i])(content);
          }
        }
    });
    return content
  }

到了这里,工程里面的less代码就可以成功识别和编译了。

Plugin

Plugin 就是插件,基于事件流框架 Tapable,插件可以扩展 Webpack 的功能,在 Webpack 运行的生命周期中会广播出许多事件,Plugin 可以监听这些事件,在合适的时机通过 Webpack 提供的 API 改变输出结果。

tabable 到底干嘛用的呢? 我们可以把它理解成webpack的生命周期管理器,tabablewebpack生命周期的每个阶段创建对应的发布钩子,用户可以通过订阅这些钩子函数,改变输出的结果。

通过这张图可以看到 webpack 的生命周期会触发哪些钩子:

image.png

在编译器引入 tapable ,声明一些常用的钩子吧:

const {SyncHook} = require("tapable");
class Compiler {
  constructor(config) {
    this.hooks = {
      entryOption: new SyncHook(),
      run: new SyncHook(),
      emit: new SyncHook(),
      done: new SyncHook()
    }
    run(){
            this.hooks.run.call()
            let entryPath = path.join(this.root, this.config.entry)
            this.buildModule(entryPath, true);
            this.hooks.emit.call()
            this.emit();
            this.hooks.done.call()
    }
  }
}

当然 webpack 里面的钩子肯定不止这些,具体还需要查文档了。

接下来就是执行 plugins 里面的方法了,我们可以在执行脚本里面触发它:

//www
const {resolve} = require("path");
const config = require(resolve("webpack.config.js"))
const Compiler = require("../src/Compiler");
const compiler = new Compiler(config);

if(Array.isArray(config.plugins)){
    config.plugins.forEach(plugin=>{
        // 插件的 apply 方法传入 compiler
        plugin.apply(compiler)
    })
}
compiler.hooks.entryOption.call()

compiler.run();

创建 plugins 文件夹,新建一个 EmitPlugin.js 脚本:

plugins 
 -EmitPlugin.js
// EmitPlugin.js
class EmitPlugin {
    apply(compiler){
        compiler.hooks.emit.tap("EmitPlugin",()=>{
            console.log("emitPlugin");
        })
    }
}
module.exports = EmitPlugin;

autodll-webpack-plugin

说到插件最后还要讲讲 autodll-webpack-plugin,之前遇到无法将打包后的dll插入到html文件的情况,到官方 issues 下看到有人提到 html-webpack-plugin 4.0 以后的版本把 beforeHtmlGeneration 钩子重命名成了 beforeAssetTagGenerationautodll-webpack-plugin 没有更新还是用的旧钩子,现在用这个插件还得保证html-webpack-plugin在4以下的版本


参考链接