webpack构建流程

本文也来自拉勾教育的课程学习笔记,旨在了解webpack打包流程

1、在终端输入npx webpack 或者 yarn webpack执行打包,这时候会找到并执行node_modules/.bin/webpack.cmd

2、执行webpack.cmd其实就是执行webpack/bin/webpack.js

3、在webpack/bin/webpack.js中会

require(webpack-cli/bin/cli.js)
复制代码

也就是执行cli.js这个文件,这个文件里面是个自调用函数,也就是开始webpack打包流程

4、在cli.js里面会先处理options和命令行里面的参数,也就是把用户设置的options和默认options以及命令行参数合并然后通过

const webpack = require("webpack");
复制代码

引入webpack/lib/webpack的导出webpack函数,然后执行这个函数

5、执行webpack(options)返回compiler,其中webpack函数步骤

  1. 实例化compiler对象

    • 把options上的context挂到compiler实例上
    • 初始化hooks对象,也就是初始化相应的hook(生命周期钩子),其中核心生命周期钩子包括:

    entryOption -> beforeRun -> run -> beforCompile -> compile -> thisCompilation -> compilation -> make -> afterCompile -> emit

  2. 传入的options挂载到compiler上

  3. 初始化 NodeEnvironmentPlugin(让compiler具体文件读写能力)

new NodeEnvironmentPlugin().apply(compiler)
复制代码
  1. 将options上的piugin插件挂载到compiler上,也就是遍历执行plugin.apply方法

    if (options.plugins && Array.isArray(options.plugins)) {
        for (const plugin of options.plugins) {
            plugin.apply(compiler)
        }
    }
复制代码
  1. 挂载webpack内置插件,执行entryOption钩子,将compilation.addEntry()方法执行挂载到make钩子上

    // webpack.js
    new WebpackOptionsApply().process(options, compiler);
复制代码
    // WebpackOptionsApply.js
    class WebpackOptionsApply {
        // process的目的就是将compilation.addEntry()方法执行挂载到make钩子上
        process(options, compiler) {
            new EntryOptionPlugin().apply(compiler)
            compiler.hooks.entryOption.call(options.context, options.entry)
        }
    }
复制代码

6、执行compiler.run方法

  1. 将参数callback赋值给变量finalCallback

  2. 定义变量onCompiled,在这里会执行emitAssets,也就是将处理好的 chunk 写入到指定的文件然后输出至dist

  3. 依次执行beforeRun、run生命周期钩子,并在run生命周期的done回调里执行compiler.compile方法

    this.hooks.beforeRun.callAsync(this, (err) => {
        this.hooks.run.callAsync(this, (err) => {
            this.compile(onCompiled)
        })
    })
复制代码

7、执行compiler.compile方法

  1. 调用compiler.newCompilationParams初始化变量params

    newCompilationParams() {
        // 让params具备创建模块的能力
        const params = {
            normalModuleFactory: new NormalModuleFactory()
        }
        return params
    }
复制代码
  1. 依次执行beforeCompile、compile生命周期钩子

  2. 执行完compile生命周期钩子后,调用compiler.newCompilation(params)初始化compilation,在newCompilation方法中也会执行thisCompilation,compilation生命周期钩子

    • 在Compilation的构造函数中会给compilation挂载compiler….很多属性
    // Compiler.js
    newCompilation(params) {
        const compilation = this.createCompilation()
        this.hooks.thisCompilation.call(compilation, params)
        this.hooks.compilation.call(compilation, params)
        return compilation
    }
    createCompilation() {
        return new Compilation(this)
    }

    // Compilation.js
    class Compilation extends Tapable {
        constructor(compiler) {
            super()
            this.compiler = compiler
            this.context = compiler.context
            this.options = compiler.options
            // 让 compilation 具备文件的读写能力
            this.inputFileSystem = compiler.inputFileSystem
            this.outputFileSystem = compiler.outputFileSystem
            this.entries = []  // 存入所有入口模块的数组
            this.modules = [] // 存放所有模块的数据
            this.chunks = []  // 存放当前次打包过程中所产出的 chunk
            this.assets = []
            this.files = []
            this.hooks = {
            succeedModule: new SyncHook(['module']),
            seal: new SyncHook(),
            beforeChunks: new SyncHook(),
            afterChunks: new SyncHook()
        }
    }
复制代码
  1. 生成compilation后执行make生命周期钩子

    • 就是在此处执行了entryOption钩子执行时往make钩子上挂载的compilation.addEntry方法,该方法接受了4个参数context, entry, name, callback
    • addEntry里调用了compilation._addModuleChain方法
    • _addModuleChain里调用了compilation.createModule方法,createModule执行顺序如下:
      1. 通过normalModuleFactory创建一个module,也就是用来加载入口文件的模块
      1. 初始化afterBuild方法,在这里会执行当前创建的模块的依赖模块加载
      1. 调用了compilation.buildModule(module, afterBuild),这里会调用module.build()方法
        • 01 从文件中读取到将来需要被加载的 module 内容,这个
        • 02 如果当前不是 js 模块则需要 Loader 进行处理,最终返回 js 模块
        • 03 上述的操作完成之后就可以将 js 代码转为 ast 语法树
        • 04 当前 js 模块内部可能又引用了很多其它的模块,因此我们需要递归完成
        • 05 前面的完成之后,我们只需要重复执行即可
      1. 执行doAddEntry方法,就是把当前创建的入口文件模块推进compilation.entries中
      1. 把当前的入口文件模块推进compilation.modules中
      1. 执行依赖模块的加载,也就是上述步骤3-5
  2. 执行完make生命周期钩子后,执行make钩子的done回调,这里会执行compilation.seal()方法,它的具体谈执行如下

    • 执行compilation里的seal和beforeChunks钩子
    • 遍历compilation.entries,并将其中的每一个入口模块和它的依赖模块合并成一个chunk,并推入遍历compilation.chunks数组中
    • 调用compilation.createChunkAssets生成最终代码
  3. 调用compilation.emitAssets也就是执行了compiler.run方法中的onCompiled

    // Compilation.js
    /**
    * 完成模块编译操作
    * @param {*} context 当前项目的根
    * @param {*} entry 当前的入口的相对路径
    * @param {*} name chunkName main
    * @param {*} callback 回调
    */
    addEntry(context, entry, name, callback) {
        this._addModuleChain(context, entry, name, (err, module) => {
        callback(err, module)
        })
    }

    _addModuleChain(context, entry, name, callback) {
        this.createModule({
        parser,
        name: name,
        context: context,
        rawRequest: entry,
        resource: path.posix.join(context, entry),
        moduleId: './' + path.posix.relative(context, path.posix.join(context, entry))
        }, (entryModule) => {
        this.entries.push(entryModule)
        }, callback)
    }

    /**
    * 定义一个创建模块的方法,达到复用的目的
    * @param {*} data 创建模块时所需要的一些属性值
    * @param {*} doAddEntry 可选参数,在加载入口模块的时候,将入口模块的id 写入 this.entries
    * @param {*} callback
    */
    createModule(data, doAddEntry, callback) {
        let module = normalModuleFactory.create(data)

        const afterBuild = (err, module) => {
        // 在 afterBuild 当中我们就需要判断一下,当前次module 加载完成之后是否需要处理依赖加载
        if (module.dependencies.length > 0) {
            // 当前逻辑就表示module 有需要依赖加载的模块,因此我们可以再单独定义一个方法来实现
            this.processDependencies(module, (err) => {
            callback(err, module)
            })
        } else {
            callback(err, module)
        }
        }

        this.buildModule(module, afterBuild)

        // 当我们完成了本次的 build 操作之后将 module 进行保存
        doAddEntry && doAddEntry(module)
        this.modules.push(module)
    }

    /**
    * 完成具体的 build 行为
    * @param {*} module 当前需要被编译的模块
    * @param {*} callback
    */
    buildModule(module, callback) {
        module.build(this, (err) => {
        // 如果代码走到这里就意味着当前 Module 的编译完成了
        this.hooks.succeedModule.call(module)
        callback(err, module)
        })
    }

    processDependencies(module, callback) {
        // 01 当前的函数核心功能就是实现一个被依赖模块的递归加载
        // 02 加载模块的思想都是创建一个模块,然后想办法将被加载模块的内容拿进来?
        // 03 当前我们不知道 module 需要依赖几个模块, 此时我们需要想办法让所有的被依赖的模块都加载完成之后再执行 callback?【 neo-async 】
        let dependencies = module.dependencies

        async.forEach(dependencies, (dependency, done) => {
        this.createModule({
            parser,
            name: dependency.name,
            context: dependency.context,
            rawRequest: dependency.rawRequest,
            moduleId: dependency.moduleId,
            resource: dependency.resource
        }, null, done)
        }, callback)
    }

    seal(callback) {
        this.hooks.seal.call()
        this.hooks.beforeChunks.call()

        // 01 当前所有的入口模块都被存放在了 compilation 对象的 entries 数组里
        // 02 所谓封装 chunk 指的就是依据某个入口,然后找到它的所有依赖,将它们的源代码放在一起,之后再做合并

        for (const entryModule of this.entries) {
        // 核心: 创建模块加载已有模块的内容,同时记录模块信息
        const chunk = new Chunk(entryModule)

        // 保存 chunk 信息
        this.chunks.push(chunk)

        // 给 chunk 属性赋值
        chunk.modules = this.modules.filter(module => module.name === chunk.name)

        }

        // chunk 流程梳理之后就进入到 chunk 代码处理环节(模板文件 + 模块中的源代码==》chunk.js)
        this.hooks.afterChunks.call(this.chunks)

        // 生成代码内容
        this.createChunkAssets()

        callback()
    }
    createChunkAssets() {
        for (let i = 0; i < this.chunks.length; i++) {
            const chunk = this.chunks[i]
            const fileName = chunk.name + '.js'
            chunk.files.push(fileName)

            // 01 获取模板文件的路径
            let tempPath = path.posix.join(__dirname, 'temp/main.ejs')
            // 02 读取模块文件中的内容
            let tempCode = this.inputFileSystem.readFileSync(tempPath, 'utf8')
            // 03 获取渲染函数
            let tempRender = ejs.compile(tempCode)
            // 04 按ejs的语法渲染数据
            let source = tempRender({
                entryModuleId: chunk.entryModule.moduleId,
                modules: chunk.modules
            })

            // 输出文件
            this.emitAssets(fileName, source)
        }
    }

    emitAssets(fileName, source) {
        this.assets[fileName] = source
        this.files.push(fileName)
    }
复制代码
// NormalModule.js
class NormalModule {
    constructor(data) {
        this.context = data.context
        this.name = data.name
        this.moduleId = data.moduleId
        this.rawRequest = data.rawRequest
        this.parser = data.parser // TODO: 等待完成
        this.resource = data.resource
        this._source  // 存放某个模块的源代码
        this._ast // 存放某个模板源代码对应的 ast
        this.dependencies = [] // 定义一个空数组用于保存被依赖加载的模块信息
    }

    build(compilation, callback) {
        /**
        * 01 从文件中读取到将来需要被加载的 module 内容,这个
        * 02 如果当前不是 js 模块则需要 Loader 进行处理,最终返回 js 模块
        * 03 上述的操作完成之后就可以将 js 代码转为 ast 语法树
        * 04 当前 js 模块内部可能又引用了很多其它的模块,因此我们需要递归完成
        * 05 前面的完成之后,我们只需要重复执行即可
        */
        this.doBuild(compilation, (err) => {
        this._ast = this.parser.parse(this._source)

        // 这里的 _ast 就是当前 module 的语法树,我们可以对它进行修改,最后再将 ast 转回成 code 代码
        traverse(this._ast, {
            CallExpression: (nodePath) => {
            let node = nodePath.node

            // 定位 require 所在的节点
            if (node.callee.name === 'require') {
                // 获取原始请求路径
                let modulePath = node.arguments[0].value  // './title'
                // 取出当前被加载的模块名称
                let moduleName = modulePath.split(path.posix.sep).pop()  // title
                // [当前我们的打包器只处理 js ]
                let extName = moduleName.indexOf('.') == -1 ? '.js' : ''
                moduleName += extName  // title.js
                // 【最终我们想要读取当前js里的内容】 所以我们需要个绝对路径
                let depResource = path.posix.join(path.posix.dirname(this.resource), moduleName)
                // 【将当前模块的 id 定义OK】
                let depModuleId = './' + path.posix.relative(this.context, depResource)  // ./src/title.js

                // 记录当前被依赖模块的信息,方便后面递归加载
                this.dependencies.push({
                name: this.name, // TODO: 将来需要修改
                context: this.context,
                rawRequest: moduleName,
                moduleId: depModuleId,
                resource: depResource
                })

                // 替换内容
                node.callee.name = '__webpack_require__'
                node.arguments = [types.stringLiteral(depModuleId)]
            }
            }
        })

        // 上述的操作是利用ast 按要求做了代码修改,下面的内容就是利用 .... 将修改后的 ast 转回成 code
        let { code } = generator(this._ast)
        this._source = code
        callback(err)
        })
    }

    doBuild(compilation, callback) {
        this.getSource(compilation, (err, source) => {
        this._source = source
        callback()
        })
    }

    getSource(compilation, callback) {
        compilation.inputFileSystem.readFile(this.resource, 'utf8', callback)
    }
    }
复制代码

7、执行compiler.emitAssets()方法以及compiler.run()执行时传入的回调

  • compiler.emitAssets中回调用compiler的emit生命周期钩子这时候一次构建基本结束了
    emitAssets(compilation, callback) {
        // 当前需要做的核心: 01 创建dist  02 在目录创建完成之后执行文件的写操作

        // 01 定义一个工具方法用于执行文件的生成操作
        const emitFlies = (err) => {
        const assets = compilation.assets
        let outputPath = this.options.output.path

        for (let file in assets) {
            let source = assets[file]
            let targetPath = path.posix.join(outputPath, file)
            this.outputFileSystem.writeFileSync(targetPath, source, 'utf8')
        }

        callback(err)
        }

        // 创建目录之后启动文件写入
        this.hooks.emit.callAsync(compilation, (err) => {
        mkdirp.sync(this.options.output.path)
        emitFlies()
        })

    }
复制代码
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享