Webpack源码浅析

webpack启动方式

webpack有两种启动方式:

  1. 通过webpack-cli脚手架来启动,即可以在Terminal终端直接运行;
    webpack ./debug/index.js --config ./debug/webpack.config.js
    
  2. 通过require('webpack')引入包的方式执行;其实第一种方式最终还是会用require的方式来启动webpack,可以查看./bin/webpack.js文件

webpack编译的起点

const compiler = webpack(config)开始

webpack函数源码(./lib/webpack.js):

const webpack = (options, callback) => {
    let compiler = createCompiler(options)
    // 如果传入callback函数,则自启动
    if(callback){
        compiler.run((err, states) => {
            compiler.close((err2)=>{
                callbacl(err || err2, states)
            })
        })
    }
    return compiler
}

webpack函数执行后返回compiler对象,在webpack中存在两个非常重要的核心对象,分别为compilercompilation,它们在整个编译过程中被广泛使用。

  • Compiler类(./lib/Compiler.js):webpack的主要引擎,在compiler对象记录了完整的webpack环境信息,在webpack从启动到结束,compiler只会生成一次。你可以在compiler对象上读取到webpack config信息,outputPath等;
  • Compilation类(./lib/Compilation.js):代表了一次单一的版本构建和生成资源。compilation编译作业可以多次执行,比如webpack工作在watch模式下,每次监测到源文件发生变化时,都会重新实例化一个compilation对象。一个compilation对象表现了当前的模块资源、编译生成资源、变化的文件、以及被跟踪依赖的状态信息。

两者的区别?
compiler代表的是不变的webpack环境; compilation代表的是一次编译作业,每一次的编译都可能不同;

举个例子:
compiler就像一条手机生产流水线,通上电后它就可以开始工作,等待生产手机的指令; compliation就像是生产一部手机,生产的过程基本一致,但生产的手机可能是小米手机也可能是魅族手机。物料不同,产出也不同。


Compiler类在函数createCompiler中实例化(./lib/index.js):

const createCompiler = options => {
    const compiler = new Compiler(options.context)
    // 注册所有的自定义插件
    if(Array.isArray(options.plugins)){
        for(const plugin of options.plugins){
            if(typeof plugin === 'function'){
                plugin.call(compiler, compiler)
            }else{
                plugin.apply(compiler)
            }
        }
    }
    compiler.hooks.environment.call()
    compiler.hooks.afterEnvironment.call()
    compiler.options = new WebpackOptionsApply().process(options, compiler)  // process中注册所有webpack内置的插件
    return compiler
}

 Compiler类实例化后,如果webpack函数接收了回调callback,则直接执行compiler.run()方法,那么webpack自动开启编译之旅。如果未指定callback回调,需要用户自己调用run方法来启动编译。

process(options, compiler)

WebpackOptionsApply类的工作就是对webpack options进行初始化。 打开源码文件lib/WebpackOptionsApply.js,你会发现前五十行都是各种webpack内置的Plugin的引入,那么可以猜想process方法应该是各种各样的new SomePlugin().apply()的操作,事实就是如此。

compiler.run()

先贴上源码吧(./lib/Compiler.js):

class Compiler {
    constructor(context){
    // 所有钩子都是由`Tapable`提供的,不同钩子类型在触发时,调用时序也不同
    this.hooks = {
            beforeRun: new AsyncSeriesHook(["compiler"]),
            run: new AsyncSeriesHook(["compiler"]),
            done: new AsyncSeriesHook(["stats"]),
            // ...
        }
    }
  
    // ...
	
    run(callback){
        const onCompiled = (err, compilation) => {
            if(err) return
            const stats = new Stats(compilation);
            this.hooks.done.callAsync(stats, err => {
                if(err) return
                callback(err, stats)
                this.hooks.afterDone.call(stats)
            })
        }
        this.hooks.beforeRun.callAsync(this, err => {
            if(err) return
            this.hooks.run.callAsync(this, err => {
                if(err) return
                this.compile(onCompiled)
            })
        })
    }
}

通读一遍run函数过程,你会发现它钩住了编译过程的一些阶段,并在相应阶段去调用已经提前注册好的钩子函数(this.hooks.xxxx.call(this)),效果与React中生命周期函数是一样的。在run函数中出现的钩子有:beforeRun --> run --> done --> afterDone。第三方插件可以钩住不同的生命周期,接收compiler对象,处理不同逻辑。

run函数钩住了webpack编译的前期和后期的阶段,那么中期最为关键的代码编译过程就交给了this.compile()来完成了。在this.comille()中,另一个主角compilation粉墨登场了。

compiler.compile()

compile(callback){
    const params = this.newCompilationParams()  // 初始化模块工厂对象
    this.hooks.beforeCompile.callAsync(params, err => {
        this.hooks.compile.call(params)
        // compilation记录本次编译作业的环境信息 
        const compilation = new Compilation(this)
        this.hooks.make.callAsync(compilation, err => {
            compilation.finish(err => {
                compilation.seal(err=>{
                    this.hooks.afterCompile.callAsync(compilation, err => {
                        return callback(null, compilation)
                    })
                })
            })
        })
    })
}

compile函数和run一样,触发了一系列的钩子函数,在compile函数中出现的钩子有:beforeCompile --> compile --> make --> afterCompile

其中make就是我们关心的编译过程。但在这里它仅是一个钩子触发,显然真正的编译执行是注册在这个钩子的回调上面。

webpack因为有Tapable的加持,代码编写非常灵活,node中流行的callback回调机制(说的就是回调地狱),webpack使用的炉火纯青。

this.parser其实就是JavascriptParser的实例对象,最终JavascriptParser会调用第三方包acorn提供的parse方法对JS源代码进行语法解析。

const result = this.parser.parse(source)


parse(code, options){
    // 调用第三方插件`acorn`解析JS模块
    let ast = acorn.parse(code)
    // 省略部分代码
    if (this.hooks.program.call(ast, comments) === undefined) {
        this.detectStrictMode(ast.body)
        this.prewalkStatements(ast.body)
        this.blockPrewalkStatements(ast.body)
        // 这里webpack会遍历一次ast.body,其中会收集这个模块的所有依赖项,最后写入到`module.dependencies`中
        this.walkStatements(ast.body)
    }
}

有个线上小工具 AST explorer 可以在线将JS代码转换为语法树AST,将解析器选择为acorn即可。

通常我们会使用一些类似于babel-loader等 loader 预处理源文件,那么webpack 在这里的parse具体作用是什么呢?parse的最大作用就是收集模块依赖关系,比如调试代码中出现的import {is} from 'object-is'const xxx = require('XXX')的模块引入语句,webpack会记录下这些依赖项,记录在module.dependencies数组中。

compilation.seal()

至此,从入口文件开始,webpack收集完整了该模块的信息和依赖项,接下来就是如何进一步打包封装模块了。

compiler.hooks.emit.callAsync()

在seal执行后,关于模块所有信息以及打包后源码信息都存在内存中,是时候将它们输出为文件了。接下来就是一连串的callback回调,最后我们到达了compiler.emitAssets方法体中。在compiler.emitAssets中会先调用this.hooks.emit生命周期,之后根据webpack config文件的output配置的path属性,将文件输出到指定的文件夹。至此,你就可以在./debug/dist中查看到调试代码打包后的文件了。

this.hooks.emit.callAsync(compilation, () => {
    outputPath = compilation.getPath(this.outputPath, {})
    mkdirp(this.outputFileSystem, outputPath, emitFiles)
 })

简单总结一下 webpack 编译模块的基本流程:

  1. 调用webpack函数接收config配置信息,并初始化compiler,在此期间会apply所有 webpack 内置的插件;
  2. 调用compiler.run进入模块编译阶段;
  3. 每一次新的编译都会实例化一个compilation对象,记录本次编译的基本信息;
  4. 进入make阶段,即触发compilation.hooks.make钩子,从entry为入口: a. 调用合适的loader对模块源码预处理,转换为标准的JS模块; b. 调用第三方插件acorn对标准JS模块进行分析,收集模块依赖项。同时也会继续递归每个依赖项,收集依赖项的依赖项信息,不断递归下去;最终会得到一颗依赖树;
  5. 最后调用compilation.seal render 模块,整合各个依赖项,最后输出一个或多个chunk

以下为时序图:

Webpack源码浅析_第1张图片

你可能感兴趣的:(webpack,前端,node.js)