手摸手带你写一个简易版的webpack

822 阅读6分钟

大家好,我是作曲家种太阳
之前对webpack了解的只限于表面,边查边用
虽然能勉强应付的过去,但是对webpack内部的运行机制并谈不上透彻,直到最近开始系统学习,才谈得上有了一些深入的了解
这篇文章是带你手写一个简易的webpack,你会学到:

  1. 了解webpack工作流程
  2. 熟悉webpack配置
  3. 了解webpack内部实现原理

不过要说明的是,这篇是我的学习笔记,简易webpack核心源码并非原创,是加上自己理解的产物 感兴趣的小伙伴们可以一起讨论~

0.了解webpack的核心概念

Entry

入口起点(entry point)指示 webpack 应该使用哪个模块,来作为构建其内部依赖图的开始。

进入入口起点后,webpack 会找出有哪些模块和库是入口起点(直接和间接)依赖的。

每个依赖项随即被处理,最后输出到称之为 bundles 的文件中。

Output

output 属性告诉 webpack 在哪里输出它所创建的 bundles,以及如何命名这些文件,默认值为 ./dist。

基本上,整个应用程序结构,都会被编译到你指定的输出路径的文件夹中。

Module

模块,在 Webpack 里一切皆模块,一个模块对应着一个文件。Webpack 会从配置的 Entry 开始递归找出所有依赖的模块。

Chunk

代码块,一个 Chunk 由多个模块组合而成,用于代码合并与分割。

Loader

loader 让 webpack 能够去处理那些非 JavaScript 文件(webpack 自身只理解 JavaScript)。

loader 可以将所有类型的文件转换为 webpack 能够处理的有效模块,然后你就可以利用 webpack 的打包能力,对它们进行处理。

本质上,webpack loader 将所有类型的文件,转换为应用程序的依赖图(和最终的 bundle)可以直接引用的模块。

Plugin

loader 被用于转换某些类型的模块,而插件则可以用于执行范围更广的任务。

插件的范围包括,从打包优化和压缩,一直到重新定义环境中的变量。插件接口功能极其强大,可以用来处理各种各样的任务。

参考链接: juejin.cn/post/684490…

1.认识Webpack的工作流程(重点)

  1. 初始化参数:从配置文件和Shell语句中读取并合并参数,得出最终的配置对象
  2. 用上一步得到的参数初始化Compiler对象
  3. 加载所有配置的插件
  4. 执行对象的run方法开始执行编译
  5. 根据配置中的entry找出入口文件
  6. 从入口文件出发,调用所有配置的Loader对模块进行编译
  7. 再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理
  8. 根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 chunks
  9. 再把每个chunks转换成一个单独的文件加入到输出列表
  10. 在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统

不用一开始就理解所有的流程,因为后面代码会按照上面的流程
在脑海有个映像就行,下面我画的图可以有助于你的理解:

image.png

2.初始化准备工作

(1).创建一个文件mini-webpack,并初始化npm

npm init  

(2).在pageckage.json上加入babel依赖包


"devDependencies": {
 "@babel/generator": "^7.14.5",
 "@babel/parser": "^7.14.6",
 "@babel/traverse": "^7.14.5",
 "babel-types": "^6.26.0",

},

(3).加入npm执行命令

"scripts": {
  "build": "node   debugger.js"
},

注意这个是执行debugger.js命令 (4)新建 mini-webpack/debugger.js文件

2.编写mini-webpack/debugger.js文件

const webpack = require('./webpack')
const webpackOptions = require("./webpack.config")

// compiler 代表整个编译过程,是一个对象
const compiler = webpack(webpackOptions)
// 调用run方法可以启动编译
compiler.run((err, stats) => {

    const result = stats.toJson({
        files: true, // 产出了那些文件
        assets: true, // 生成了哪些资源
        chunks: true, // 生成了哪些代码块
        modules: true,  // 模块信息
        entries: true,  // 入口信息
    })
    // 查看编译结果
    console.log(JSON.stringify(result, null, 2));
})

这个文件主要是调用webpack实例,并且返回一个编译result结果:
不用细致的看这个编译结果,了解大致的字段的作用就行了

 
 {
    hash: '50ee8c2052552e5a4565',  // 本次编译`产出的hash值
    version: '5.51.1',  // webpack版本
    time: 65,    // 话费的时间
    builtAt: 1630300492483,  // 构建时间戳
    publicPath: 'auto',    // 资源的访问路径
    outputPath: '/XXX//XXX/XXX/XXX/mini-webpack/dist',   // 输出的目录
    assetsBychunksName: {main: ['main.js']},
    "assets":
        [
            {
                "type": "asset",
                "name": "main.js",
                "size": 167,
                "emitted": true,
                "comparedForEmit": false,
                "cached": false,
                "info": {
                    "javascriptModule": false,
                    "size": 167
                },
                "chunksNames": [
                    "main"
                ],
                "chunksIdHints": [],
                "auxiliarychunksNames": [],
                "auxiliarychunksIdHints": [],
                "related": {},
                "chunks": [
                    "main"
                ],
                "auxiliarychunks": [],
                "isOverSizeLimit": false
            }
        ],
    "chunks":
        [
            {
                "names": [
                    "main"
                ],
                "files": [
                    "main.js"
                ],
            }
        ],
    "modules":
        [
            {
                "type": "module",
                "moduleType": "javascript/auto",
                "layer": null,
                "size": 1,
                "sizes": {
                    "javascript": 1
                },
                "built": true,
                "codeGenerated": true,
                "buildTimeExecuted": false,
                "cached": false,
                "identifier": "/Users/peiyahui/Desktop/code/xxx/mini-webpack/src/index.js",
                "name": "./src/index.js",
                "nameForCondition": "/Users/peiyahui/Desktop/code/xxx/mini-webpack/src/index.js",
                "index": 0,
                "preOrderIndex": 0,
                "index2": 0,
                "postOrderIndex": 0,
                "cacheable": true,
                "optional": false,
                "orphan": false,
                "issuer": null,
                "issuerName": null,
                "issuerPath": null,
                "failed": false,
                "errors": 0,
                "warnings": 0,
                "id": "./src/index.js",
                "issuerId": null,
                "chunks": [
                    "main"
                ],
                "assets": [],
                "reasons": [
                    {
                        "moduleIdentifier": null,
                        "module": null,
                        "moduleName": null,
                        "resolvedModuleIdentifier": null,
                        "resolvedModule": null,
                        "type": "entry",
                        "active": true,
                        "explanation": "",
                        "userRequest": "./src/index.js",
                        "loc": "main",
                        "moduleId": null,
                        "resolvedModuleId": null
                    }
                ],
                "usedExports": null,
                "providedExports": null,
                "optimizationBailout": [],
                "depth": 0
            }
        ],
    // 入口点
    "entrypoints":
        {
            "main":
                {
                    "name":
                        "main",
                    "chunks":
                        [
                            "main"
                        ],
                    "assets":
                        [
                            {
                                "name": "main.js",
                                "size": 167
                            }
                        ],
                    "filteredAssets":
                        0,
                    "assetsSize":
                        167,
                    "auxiliaryAssets":
                        [],
                    "filteredAuxiliaryAssets":
                        0,
                    "auxiliaryAssetsSize":
                        0,
                    "children":
                        {}
                    ,
                    "childAssets":
                        {}
                    ,
                    "isOverSizeLimit":
                        false
                }
        }


}
 

3.编写mini-webpack/webpack.js文件

第一步我们引入了webpack.js文件,它是webpack入口文件,接下来编写:

// webpack
function webpack(options) {
    //1.初始化参数:从配置文件和Shell语句中读取并合并参数,得出最终的配置对象
    let shellConfig = process.argv.slice(2).reduce((shellConfig, item) => {
        let [key, value] = item.split("=");
        shellConfig[key.slice(2)] = value
        return shellConfig
    }, {})
    let finalConfig = {...options, ...shellConfig};
    // 2. 用上一步得到的参数初始化Compiler对象
    let compiler = new Compiler(finalConfig)
    // 3.加载所有配置的插件
    let {plugins} = finalConfig
    //
    for (let plugin of plugins) {
        plugin.apply(compiler)
    }
    return compiler
}
module.exports = webpack

process.argv.slice(2) 这个是解析webpack执行命令参数的
比如 执行 webpack --mode=development 的时候
会解析出来--mode=development根据 = 拆分出key(mode)和value(development)

4.编写mini-webpack/webpack.config.js文件

第一步我们引入了webpack.config.js文件,它是webpack配置文件

const path = require('path');
const RunPlugin = require('./plugins/run-plugin');
const DonePlugin = require('./plugins/done-plugin');
const AssetPlugin = require('./plugins/assets-plugin');
module.exports = {
    mode:'development',   // 生产模式
    devtool:false,       
    context:process.cwd(),//上下文目录, ./src .默认代表根目录 默认值其实就是当前命令执行的时候所在的目录
    entry:{        // 入口
        entry1:'./src/entry1.js',
        entry2:'./src/entry2.js'
    },
    output:{        // 输出地址
        path:path.join(__dirname,'dist'),
        filename:'[name].js'
    },
    resolve:{     // 默认识别文件后缀
        extensions:['.js','.jsx','.json']
    },
    module:{      // 匹配对应的test文件后缀字段正则,加载对应的loader
        rules:[
            {
                test:/.js$/,
                use:[
                    path.resolve(__dirname,'loaders','logger1-loader.js'),
                    path.resolve(__dirname,'loaders','logger2-loader.js')
                ]
            }
        ]
    },
    plugins:[   // webpack插件
        new RunPlugin(),
        new DonePlugin(),
        new AssetPlugin()
    ]
}

webpack的配置,相信你有所了解,主要是理解上面备注的配置字段即可

5.编写两个loader和两个plugin

编写的loader和plguin都很简单了,主要是配合webpack加载loader和plugin原理的 (1)mini-webpack/loader/logger1-loader.js

function loader(source) {
    console.log("loading")
    return source + "//1"
}
module.exports = loader

(2)mini-webpack/loader/logger2-loader.js

function loader(source) {
    console.log("loading22222222222")
    return source + "//2"
}
module.exports = loader

(3)mini-webpack/plugins/done-plugins.js

class RunPlugins {
    apply(compiler) {
        compiler.hooks.done.tap("DonePlugins", () => {
            console.log("编译结束了")
        })
    }
}

module.exports = RunPlugins

(4)mini-webpack/plugins/run-plugins.js

class RunPlugins {
    apply(compiler) {
        compiler.hooks.run.tap("RunPlugins",()=>{
            console.log("开始编译了")
        })
    }
}

module.exports = RunPlugins

6.编写编写 Compiler 类(重点)

还记得第一步中debuger.js调用了Compiler的run方法返回一个result么?
还记得第三步骤的时候需要生成一个Compiler类的实例么?

嘿嘿,相信你已经串联起来了.

这个 Compiler 类的主要功能就是串联整个编译过程,并触发钩子函数响应对应的插件
但不不做具体编译的工作,而是交给了 Complication 类,可暂不理会

mini-webpack/Compiler.js

const fs = require("fs");
const path = require("path");
const Complication = require("./compilcation");
let {SyncHook} = require("tapable")

class Compiler {
    constructor(options) {
        this.options = options
        this.hooks = {
            run: new SyncHook(), // 开始启动编译  刚刚开始
            emit: new SyncHook(['assets']), // 会在将要写入的文件时候触发
            done: new SyncHook(),  // 将会在完成变的时候出发  全部完成
        }
    }

    //
    //4. 执行Compiler对象的run方法开始执行编译
    run(callback) {
        this.hooks.run.call();//触发run钩子
        //5. 根据配置中的entry找出入口文件
        this.compile((err, stats) => {
            this.hooks.emit.call(stats.assets);
            //10. 在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统
            for (let filename in stats.assets) {
                let filePath = path.join(this.options.output.path, filename);
                fs.writeFileSync(filePath, stats.assets[filename], 'utf8');
            }

            callback(null, {
                toJson: () => stats
            });
        });

        //中间是我们编译流程
        this.hooks.done.call();  //编译之后触发done钩子
    }

    //
    compile(callback) {
        let complication = new Complication(this.options)
        complication.build(callback)
    }
}

module.exports = Compiler

这里的钩子函数,是用了tapable.js实现的,有兴趣的同学可以了解下tapble.js用法,和node的发布订阅模式很像

7. 编写 Complication 类(难点)

Complication 类主要就是做编译工作

mini-webpack/Complication.js

/**
 * @name: compilcation
 * @author: peiyahui
 * @date: 2021/8/31 12:51 下午
 * @description:compilcation
 * @update: 2021/8/31 12:51 下午
 */
const path = require('path')
const fs = require('fs')
const types = require('babel-types');
const parser = require('@babel/parser');//源代码转成AST抽象语法树
const traverse = require('@babel/traverse').default;//遍历语法树
const generator = require('@babel/generator').default;//把语法树重新生成代码
const baseDir = toUnitPath(process.cwd());

//
function toUnitPath(filePath) {
// return filePath.replace(/\/g, '/');// 不知道为什么在掘金的代码快中会报错,coding的时候请打开
}

class Complication {
    constructor(options) {
        this.options = options;
        this.entries = [] // 入口信息
        this.modules = []  // 模块信息
        this.chunks = []  // 生成了哪些代码块
        this.files = []  // 产出了那些文件
        this.assets = []  // 生成了哪些资源
    }


    build(callback) {
        let entry = {}
        // 5.根据配置中的entry找出入口文件
        if (typeof this.options.entry === "string") {
            entry.main = this.options.entry
        } else {
            entry = this.options.entry
        }

        // 处理入口
        for (let entryName in entry) {
            // 获取entry1的绝对路径,context是上线文(执行的环境),默认是根目录
            let entryFilePath = path.join(this.options.context, entry[entryName])
            // 6.从入口文件出发,调用所有配置的Loader对模块进行编译
            let entryModule = this.buildModule(entryName, entryFilePath)  // 返回
            //
            // this.modules.push(entryModulePath);

            // 8.根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 chunks
            let chunk = {
                name: entryName, entryModule, modules: this.modules.filter(item => {
                    return item.name === entryName || item.extraNames.includes(entryName);
                })
            };
            this.entries.push(chunk)
            this.chunks.push(chunk) // 添加到代码块中
        }
        // 9. 再把每个Chunk转化成一个单独的文件加入到输出列表
        //9. 再把每个Chunk转换成一个单独的文件加入到输出列表
        this.chunks.forEach(chunk => {
            // 替换文件名
            let filename = this.options.output.filename.replace('[name]', chunk.name);
            // this.assets就是输出列表 key输出的文件名 值就是输出的内容
            this.assets[filename] = getSource(chunk);
        });
        console.log('this.chunks--------->', this.chunks)
        //    Compiler.run()里面的函数         (err, stats) => {}
        callback(null, {
            entries: this.entries,
            chunks: this.chunks,
            modules: this.modules,
            files: this.files,
            assets: this.assets
        });
    }

    // name:名称   modulePath模块的绝对路径
    buildModule(name, modulePath) {
        // 6.从入口文件出发,调用所有配置的Loader对模块进行编译
        // 6.1 读取文件内容
        let sourceCode = fs.readFileSync(modulePath, 'utf8'); //console.log('entry1');
        let rules = this.options.module.rules    // loader的路径
        let loaders = []  // 寻找匹配的loader
        //
        for (let i = 0; i < rules.length; i++) {
            let {test} = rules[i];
            //如果此rule的正则和模块的路径匹配的话
            if (modulePath.match(test)) {
                loaders = [...loaders, ...rules[i].use];
            }
        }
        // 从后往前加载loader
        sourceCode = loaders.reduceRight((sourceCode, loader) => {
            // 同步加载
            return require(loader)(sourceCode);
        }, sourceCode);
        // 7.再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理
        //获得当前模块模块ID ./src/index.js
        let moduleId = './' + path.posix.relative(baseDir, modulePath);
        let module = {id: moduleId, dependencies: [], name, extraNames: []};  // 生成一个module格式
        let ast = parser.parse(sourceCode, {sourceType: 'module'}); // 生成ast语法树
        traverse(ast, {
            //
            CallExpression: ({node}) => {
                if (node.callee.name === 'require') {
                    //依赖的模块的相对路径
                    let moduleName = node.arguments[0].value;//./title1
                    //获取当前模块的所有的目录
                    let dirname = path.posix.dirname(modulePath);// /
                    // 兼容平台的路径写法(/////)
                    let depModulePath = path.posix.join(dirname, moduleName);
                    let extensions = this.options.resolve.extensions;   // 扩展名
                    depModulePath = tryExtensions(depModulePath, extensions);//已经包含了拓展名了
                    //得到依赖的模块ID C:/aproject/zhufengwebpack202106/4.flow/src/title1.js
                    //相对于项目根目录 的相对路径 ./src/title1.js
                    let depModuleId = './' + path.posix.relative(baseDir, depModulePath);
                    //require('./title1');=>require('./src/title1.js');
                    node.arguments = [types.stringLiteral(depModuleId)];  // 给节点添加参数
                    //依赖的模块绝对路径放到当前的模块的依赖数组里


                    module.dependencies.push({depModuleId, depModulePath});

                }
            }
        })

        //
        let {code} = generator(ast); // 编译代码
        module._source = code;//模块源代码指向语法树转换后的新生成的源代码
        //7. 再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理
        module.dependencies.forEach(({depModuleId, depModulePath}) => {
            // 缓存
            let depModule = this.modules.find(item => item.id === depModuleId);
            if (depModule) {
                depModule.extraNames.push(name);
            } else {
                let dependencyModule = this.buildModule(name, depModulePath);  // 递归编译,依赖模块
                this.modules.push(dependencyModule);
            }
        });
        return module;
    }

}

// 
function getSource(chunk) {
    return `
    (() => {
        var modules = ({
            ${chunk.modules.map(module => `
                    "${module.id}":(module,exports,require)=>{
                        ${module._source}
                    }
                `).join(',')
    }
        });
        var cache = {};
        function require(moduleId) {
          var cachedModule = cache[moduleId];
          if (cachedModule !== undefined) {
            return cachedModule.exports;
          }
          var module = cache[moduleId] = {
            exports: {}
          };
          modules[moduleId](module, module.exports, require);
          return module.exports;
        }
        var exports = {};
        (() => {
         ${chunk.entryModule._source}
        })();
      })()
        ;
    `
}

//加载文件
function tryExtensions(modulePath, extensions) {
    extensions.unshift('');
    for (let i = 0; i < extensions.length; i++) {
        let filePath = modulePath + extensions[i];// ./title.js
        // 如果有就返回
        if (fs.existsSync(filePath)) {
            return filePath;
        }
    }
    throw new Error(`Module not found`);
}


module.exports = Complication

这里比较重要的两个函数就是build和buildModule
所以我们只要了解了这两个函数工作流程,就会明白Complication 类的作用
下面我们来看下这两个函数的流程图

8.build函数工作流程解析

接受参数:callback image.png

9.buildModule函数工作流程解析

image.png

相信你看到这里,对webpack执行流程和内部实现原理有了一定了解 欢迎小伙伴们一起讨论~