关于webpack:实现一个简易版Webpack

50次阅读

共计 8151 个字符,预计需要花费 21 分钟才能阅读完成。

之前常常被 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了,那么这两者到底有什么区别呢?能够看下这两张图:

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 的生命周期会触发哪些钩子:

在编译器引入 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 以下的版本


参考链接

  • 揭秘 webpack 插件工作流程和原理
  • Webpack 原理浅析
  • webpack 中,module,chunk 和 bundle 的区别是什么?
  • 「吐血整顿」再来一打 Webpack 面试题

正文完
 0