阅读 435

140行实现一个乞丐版webpack

webpack的打包核心流程

  • 读取 webpack.config.js 配置 options, 必须包含 entryouput 配置项
  • 实例化一个 Compiler 类, 执行 run 方法开始编译打包工作
  • 根据入口文件内容递归解析模块依赖关系,通过正则改写 require 关键字为 __wepack_require__
  • 根据模块依赖数据,发射文件并实例 Compilation
  • 每个过程都可以响应 tapable 注册的 hooks 的回调

compilercompilation 的概念

  • Compiler: compiler 实例是负责编译(compile)打包(emitFile)的一个对象,它包含了配置文件的信息,原型上有执行编译 run 方法,模块解析 buildModule方法(这个方法会执行loader), compiler 会调用 tapable 注册的 hooks 的回调, 来响应插件的注册的钩子
  • Compilation: compilation 代表的是这次编译过程的资源对象,提供了对打包生成的资源(chunks, modules)增删改查的方法,大部分的插件都是通过操作compilation 来完成优化

beggar-webapck 目录结构

.
|____Compilation.js // 资源对象
|____Compiler.js  // 编译对象
|____index.js // toy-webpack的主入口     
|____template.ejs // 渲染模板
复制代码

用来测试 beggar-webapck 的项目结构

|____console-loader.js // 用于测试loader 
|____html-plugin.js  // 用于测试plugin 
|____index.html  // html 模板      
|____src
| |____index.js // 入口文件
| |____utils.js // 入口文件的依赖文件
|____webpack.config.js // webpack的配置文件
复制代码

入口文件index.js

const add = require('./utils.js');
console.log(add(1,2))
复制代码

utils.js

module.exports = function add (...args) {
    return args.reduce((a, b) => a + b, 0)
}
复制代码

console-loader.js

// loader就是一个函数,当前的上下文this的指向compiler实例, consoleLoader.call(compiler, content, sourcemap, ...);
// 第一个参数文件的字符串(string或者buffer)
// loader同时也必须要返回字符串(string或者buffer)
// 我们这个loader十分简单,只是把js文件中的所有的console.log改成console.error
module.exports = function consoleLoader(content) {
    // 把console.log 改成console.error
    return `${content.replace(/console\.log\(/g, 'console.error(')}`;
}
复制代码

html-plugin

// 这个插件很简单,在发射bundle之前把bundle的文件名,通过script标签插入到body尾部,这样就不用我们每次新建一个html文件手动插入
const BODY_RE = /<\/body>/gi;
const fs = require('fs');
const path = require('path')
let htmlTemplate = fs.readFileSync(path.resolve('./index.html')).toString();

module.exports = class HtmlPlugin {
    apply(compiler) {
        compiler.hooks.emit.tapAsync('HtmlPlugin', compilation => {
            const { bundleName, assets } = compilation; 
            htmlTemplate = htmlTemplate.replace(BODY_RE, _ => `<script src=${bundleName}></script>\n` + _);
            assets['index.html'] = {
                source: () => htmlTemplate,
                size: () => htmlTemplate.length,
            }
            console.log('script has inserted!')
        });

        compiler.hooks.done.tap('Done', () => {
        	// 打印成功日志
            console.log('Build Success!')
        })
    }
}
复制代码

webpack.config.js

// 必须提供entry,output
const path = require('path');
const HtmlPlugin = require('./html-plugin')
module.exports = {
    entry: './src/index.js',
    output: {
        filename: 'app.js',
        publicPath: './',
        path: path.resolve('dist')
    },
    module: {
        rules: [
            {
                test: /\.js$/,
                use: [
                    path.resolve('./banner-loader.js')
                ]
            }
        ]
    },
    plugins: [
        new HtmlPlugin()
    ]
}
复制代码

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
</body>
</html>
复制代码

开始编写toy-webpack

下面的beggar-webpack实现会尽量每行都添加注释

主入口文件index.js

  • 这个文件可以理解为第三方库中bin目录下的xxx.js
  • 也是package.json中的main字段的值
const Compiler = require("./Compiler");
const path = require("path");
// 获取webpack.config.js配置
const webpackConfig = require(path.resolve("webpack.config.js"));

const webpack = (options) => {
 // 把配置传给我们的compiler
  const compiler = new Compiler(options);
  // 执行所以插件的apply方法, 插件必须要提供一个apply的方法,这跟vue的插件很相似
  if (options.plugins && Array.isArray(options.plugins)) {
      options.plugins.forEach(plugin => plugin.apply(compiler));
  }
 // 开始编译
  compiler.run();
};

webpack(webpackConfig);
module.exports = webpack;
复制代码

Compiler.js

compiler 是我们乞丐版webpack的最关键实现,loader执行时机,递归获取模块依赖关系,发射打包的文件都在这里

const { SyncHook, AsyncSeriesHook } = require('tapable')
const Compilation = require('./Compilation');
const path = require('path');
const ejs = require('ejs')
const fs = require('fs');

// 匹配commonjs中require关键字
const COMMONJS_REQUIRE_RE = /require\(['"]([^'"]+)['"]\)/g;

// 这里只考虑入口工程是都是commonjs写法,如果是es module写法需要用到babel相关的工具,我们先不考虑es module的场景
// @babel/core, @babel/preset-env, @babel/traverse, @babel/genertor 转为commonjs module
// 删除文件中的多行和单行注释,处理在注释写require的场景

const MULTI_LINE_COMMENT_RE = /(?:^|\n|\r)\s*\/\*[\s\S]*?\*\/\s*(?:\r|\n|$)/g;
const SINGLE_LINE_COMMENT_RE = /(?:^|\n|\r)\s*\/\/.*(?:\r|\n|$)/g;
const removeComnent = c => c.replace(MULTI_LINE_COMMENT_RE, '').replace(SINGLE_LINE_COMMENT_RE, '');

class Compiler {
    constructor(options) {
        // 获取传进来的webpack配置
        this.options = options;
        // Node.js 进程的当前工作目录,一般来说你的package.json所在的位置等于process.cwd()
        this.root = process.cwd();
        // 入口文件的绝对路径
        this.entryModuleAbsPath = path.join(this.root, options.entry);
        // 入口文件的mouleId
        this.entryID = '';
        // 输出打包文件的目录
        this.outputPath = this.options.output.path;
        // 入口文件打包后的文件名
        this.bundleName = this.options.output.filename;
        // 这里只提供几个hook作为说明
        this.hooks = {
            run: new AsyncSeriesHook(["compiler"]), // 异步串行钩子
            done: new SyncHook(), // 同步钩子的回调不能用异步代码,不然无法保证执行顺序
            fail: new SyncHook(),
            emit: new AsyncSeriesHook(["compilation"])
        }
        // 获取loader的配置信息
        this.rules = this.options.module.rules;

        // 获取跟入口文件有关的依赖,a依赖b,b依赖c,则b、c都是a的依赖,这个属性用于渲染我们的template模板
        this.modules = [];
    }

    run() {
        // 执行run钩子,如果plugin有注册该钩子的事件会被执行
        // tapable中的钩子的注册和调用关系
        // 异步钩子 
        // 注册:tapAsync 调用:callAsync 或者 注册:tapPrmise(回调必须返回一个prmosise) 调用:callPromise
        // 同步钩子
        // 注册:tap 调用:call
        this.hooks.run.callAsync(this, err => {
            if (err) {
                return this.hooks.fail.call(err);
            }
            // 获取模块依赖关系,收集modules,执行loader
            this.buildModule(this.entryModuleAbsPath, true);
            // 发射文件到ouput
            this.emitAssets();
        })
    }

    buildModule(modulePath, isEntry) {
        // getSource传入一个绝对路径
        const content = this.getSource(modulePath);
        // 得到转换后的内容,和该文件中的依赖项, 则require的值
        const { source, subs } = this.parse(content, modulePath);
        // 构造一个格式都是相对于src  比如'./src/index.js' , './src/utils/js'
        const moduleId = this.getRelativePath(this.root, modulePath);

        if (isEntry) {
            // 如果是入口文件, 这个entryID用于渲染template
            this.entryID = moduleId;
        }
        // 加入到modules
        this.modules.push({
            moduleId,
            source: source,
            dependencies: subs,
        })

        // 递归解析所依赖的子模块
        subs.forEach(subAbsPath => {
            this.buildModule(subAbsPath, false)
        })
    }

    getSource(absPath) {
        // 获取文件后缀名 'xxx.vue' ==> '.vue'
        const ext = path.extname(absPath);
        let content = fs.readFileSync(absPath).toString();
        // 筛选符合的loader
        const filterRules = this.rules && this.rules.find(rule => rule.test.test(ext));
        if (filterRules && Array.isArray(filterRules.use)) {
            // 这里假设ues的配置都是字符串,不是对象的形式 --> use: ['style-loader', 'css-loader']
            content = this.runLoader(content, filterRules.use.reverse())
        }
        return content;
    }

    getRelativePath(rootContext, absPath) {
        return './' + path.relative(rootContext, absPath).replace(/\\/, '/')
    }

    parse(content, modulePath) {
        // 子依赖
        const subs = [];
        // 删除注释
        content = removeComnent(content);
        content = content.replace(COMMONJS_REQUIRE_RE, (_, subModuleRelativePath) => {
            // 依赖的子模块require('./util.js')  那么uitl.js是他依赖项
            const subModuleAbsPath = path.join(modulePath, '..', subModuleRelativePath);
            subs.push(subModuleAbsPath);
            const moduleId = this.getRelativePath(this.root, subModuleAbsPath);
            // 把require改成模板定义的__webpack_require__函数, 这个函数用于加载和缓存模块
            return `__webpack_require__('${moduleId}')`
        })

        return {
            source: content,
            subs,
        }
    }

    emitAssets() {
        // 渲染模板
        const webpackTemplate = fs.readFileSync(path.join(__dirname, './template.ejs')).toString();

        const content = ejs.render(webpackTemplate, {
            entryModuleId: this.entryID,
            modules: this.modules
        })
        // 实例化我们的资源对象,它目前的功能只有一个存放生成的文件信息
        const compilation = new Compilation({
            bundleName: this.bundleName
        });

        const assets = compilation.assets;
        assets[this.bundleName] = {
            source: () => content,
            size: () => content.size
        }

        // 调用插件注册的emit回调
       this.hooks.emit.callAsync(compilation);
       
       // 通过fs.writeFileSync 把compilation.assets的资源文件写入到output
        Object.keys(assets).forEach(filename => {
            if (!fs.existsSync(this.outputPath)) fs.mkdirSync(this.outputPath);
            fs.writeFileSync(path.join(this.outputPath, filename), assets[filename].source(), 'utf-8')
        })
        // 调用插件注册的done回调
        this.hooks.done.call();
    }

    // 执行我们的loader
    runLoader(content, uses) {
        // 因为loader的执行顺序是倒序,从后面开始执行,传送门https://github.com/webpack/loader-runner(不考虑pitch)
        return uses.reduce((retContent, loader) => {
            const loaderFn = require(loader);
            // 绑定我们的compiler实例作为上下文
            return loaderFn.call(this, retContent);
        }, content)
    }
}

module.exports = Compiler
复制代码

Compilation.js

我们这个类功能十分单一,仅存放资源信息

module.exports = class Compilation {
  constructor(options) {
    this.assets = [];
    this.bundleName = options.bundleName;
  }
};
复制代码

template.ejs

渲染我们的模板, 这个是webpack的web模式的MainTemplate中的模板之一,只保留加载模块的方法 __webpack_require__, 看他的注释就可以知道什么意思,__webpack_require__模拟的是commonjsrequire方法,里面暴露的对象module.exportsexportscommonjs的一致,所以我们才只需要处理require这个关键字,这个方法帮我们减少不少工作。

/******/ (function(modules) { // webpackBootstrap
    /******/ 	// The module cache
    /******/ 	var installedModules = {};
    /******/
    /******/ 	// The require function
    /******/ 	function __webpack_require__(moduleId) {
    /******/
    /******/ 		// Check if module is in cache
    /******/ 		if(installedModules[moduleId]) {
    /******/ 			return installedModules[moduleId].exports;
    /******/ 		}
    /******/ 		// Create a new module (and put it into the cache)
    /******/ 		var module = installedModules[moduleId] = {
    /******/ 			i: moduleId,
    /******/ 			l: false,
    /******/ 			exports: {}
    /******/ 		};
    /******/
    /******/ 		// Execute the module function
    /******/ 		modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    /******/
    /******/ 		// Flag the module as loaded
    /******/ 		module.l = true;
    /******/
    /******/ 		// Return the exports of the module
    /******/ 		return module.exports;
    /******/ 	}
    /******/ 	// Load entry module and return exports
    /******/ 	return __webpack_require__( "<%-entryModuleId%>"); // 我们入口模块的ID, './src/index.js'
    /******/ })
    /************************************************************************/
    ({
  <%for (var module of modules){%>
    "<%-module.moduleId%>":
    (function (module, exports, __webpack_require__) {
      <%-module.source%>
    }),
  <%}%>
    });
复制代码

到这里我们的beggar-webpack已经实现好了,我们来用上面的demo测试下

完整的测试项目结构

新建一个bagger-webpack目录,把我相关的webpack文件都丢进去

.
|____banner-loader.js
|____beggar-webpack
| |____Compilation.js
| |____Compiler.js
| |____index.js
| |____template.ejs
|____dist
| |____app.js
| |____index.html
|____html-plugin.js
|____index.html
|____package.json
|____src
| |____index.js
| |____utils.js
|____webpack.config.js

// 在package.json的script加上
  "scripts": {
    "build": "node ./beggar-webpack/index.js"
  }
复制代码

执行npm run build, 工程多了dist目录, 里面是我们的打包后的文件,多出的index.html说明我的插件执行ok

[bigham@DESKTOP-MKMH2OT /d/Work_Space/test-mini-webpack/dist]$ npm run build
> test-mini-webpack@1.0.0 build D:\Work_Space\test-mini-webpack
> node ./beggar-webpack/index.js

script has inserted!
Build Success!
//插件emit、done的回调也在日志输出了
复制代码
dist
.
|____app.js    
|____index.html
复制代码

app.js 的内容

/******/ (function(modules) { // webpackBootstrap
    /******/ 	// The module cache
    /******/ 	var installedModules = {};
    /******/
    /******/ 	// The require function
    /******/ 	function __webpack_require__(moduleId) {
    /******/
    /******/ 		// Check if module is in cache
    /******/ 		if(installedModules[moduleId]) {
    /******/ 			return installedModules[moduleId].exports;
    /******/ 		}
    /******/ 		// Create a new module (and put it into the cache)
    /******/ 		var module = installedModules[moduleId] = {
    /******/ 			i: moduleId,
    /******/ 			l: false,
    /******/ 			exports: {}
    /******/ 		};
    /******/
    /******/ 		// Execute the module function
    /******/ 		modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    /******/
    /******/ 		// Flag the module as loaded
    /******/ 		module.l = true;
    /******/
    /******/ 		// Return the exports of the module
    /******/ 		return module.exports;
    /******/ 	}
    /******/ 	// Load entry module and return exports
    /******/ 	return __webpack_require__(__webpack_require__.s = "./src/index.js");
    /******/ })
    /************************************************************************/
    ({
  
    "./src/index.js":
    (function (module, exports, __webpack_require__) {
      const add = __webpack_require__('./src/utils.js');
      console.error(add(1,2))
    }),
  
    "./src/utils.js":
    (function (module, exports, __webpack_require__) {
      module.exports = function add (...args) {
      return args.reduce((a, b) => a + b, 0)
    }
    }),
});
复制代码

app.js嗯有webpack那味道了,用浏览器打开index.html,康康~

正确的输出了3,loader也生效了 至此我们的140行乞丐版webpack就完成了,还包含了loader和插件机制。

chunkmodule 的区别

  • chunk: 指的是打包出来的文件,app.js就是一个chunk,我们日常工程使用懒加载打包的文件也是一个chunk
  • modulechunkmodule是包含关系,一个chunk文件里面可能包含了很多module

比如app.js这个chunk中就包含了两个module

  {
    "./src/index.js": function (module, exports, __webpack_require__) { // 这是一个module
      const add = __webpack_require__("./src/utils.js");
      console.error(add(1, 2));
    },

    "./src/utils.js": function (module, exports, __webpack_require__) { // 这是一个module
      module.exports = function add(...args) {
        return args.reduce((a, b) => a + b, 0);
      };
    },
  }

复制代码
文章分类
前端
文章标签