之前常常被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.jslet a = require("./base/a")let b = require("./base/b")module.exports = function () {  return a + b}
// index.jslet 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 nodeconst {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导入并执行:

// wwwconst 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 阶段,咱们间接返回了源码,几乎暴殄天物,咱们明明能够对源码做很多事件:

// oldreadSource(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 外面的办法了,咱们能够在执行脚本外面触发它:

//wwwconst {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.jsclass 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面试题