本身写一个Webpack

Webpack学习概论

在使用过Webpack后,它强大的的灵活性给我留下了深入印象,经过Plugin和Loader几乎能够随意扩展功能,因此决定探究Webpack的实现原理,学习做者的编程思想。node

可是在学习源码过程当中仍是遇到了挺大困难,一是它的插件系统设计的错综复杂,刚开始看容易被绕晕,另外是它功能实现覆盖的场景广,有不少内置功能不太熟悉。所以在这记录下学习的过程,将Webpack实现的精华内容提取出来,供后续学习参考。webpack

由于Webpack的代码量也不算少,并且比较绕,若是光看代码会比较枯燥。因此决定以本身实现一个简易Webpack为目标,分步探索实现细节,从构建运行到实现一个能打包代码的工具。为了简化逻辑,不会彻底像Webpack同样实现,如下部分是差别较大的地方:web

  • 使用TS实现:由于方便看类型。
  • 不使用Webpack的插件机制:即不会用Tapable实现Hooks,由于看得太麻烦。

如下是完成计划,但愿能坚持 😄typescript

基础概念

首先咱们须要了解一些基础的Webpack概念,Webpack的构建流程基本是围绕如下概念进行:shell

  • context: 绝对路径目录,默认使用当前目录,加载文件以该目录为基础。
  • Entry: Webpack分析文件的入口点,指定了入口文件后,Webpack会递归分析出这个文件下的全部依赖文件,供后续输出。
  • Module: Webpack将全部文件都当作模块,包含了文件的全部信息。
  • Plugin: Webpack的运行过程就是一个个插件相互调用处理的过程,插件会在编译阶段的各个生命周期中被调用。
  • Loader: 在加载文件后,解析文件前,对文件内容做自定义处理,如对文件内容替换删除等操做。
  • Chunk: 封装了Module,是模块依赖和输出模版代码的桥梁

打包流程分析

初始化流程 webpack.js

  • WebpackOptionsDefaulter合并默认配置,在webpack里已经默认了部分配置,如context设置为当前目录等。
  • 建立编译器Compiler
  • 加载自定义插件
  • WebpackOptionsApply将选项设置给compiler,并加载各类默认插件,如用于引导入口的EntryOptionPlugin插件,加载js文件解析的JavascriptModulesPlugin
  • 运行compiler

初始化编译器 Compiler.js

  • 初始化模块工厂NormalModuleFactoryContextModuleFactory,模块工厂主要用于在后续建立和初始化模块
  • 建立编译工具Compilation,在这里会经过钩子调用各类插件来初始化编译工具,如为入口模块添加解析器,为js类型文件添加解析器,添加模版处理方法等
  • 调用make钩子执行EntryPlugin,运行compilation.addEntry进入模块解析

模块解析流程 Compilation.js

  • 调用ModuleFactory建立入口模块 / 建立依赖模块
    • 解析资源路径,加载原始文件
    • 加载须要的Loader
    • 加载模块解析器
  • 调用buildModule解析模块,输出依赖列表
    • 运行Loader
    • Parser解析出AST
    • walkStatements解析出依赖
  • 调用addModuleDependencies递归建立依赖模块

模块输出流程 Compilation.js

  • 建立ChunkGroupChunk,根据模块依赖解析出ChunkGraph
  • 优化ChunkGraph
  • Template根据Chunk建立输出内容
  • 输出文件

实现一个简易版Webpack

示例代码

本次咱们实现的效果是将两个简单文件打包成一个js,而且能够在浏览器运行,采用Commonjs模块化,咱们再实现一个简单的loader,将代码中的log转换为warn:编程

// example/index.js
const inc = require('./increment')
const dec = require('./decrement')
console.log(inc(8))
console.log(dec(8))

// example/increment.js
exports.default = function(val) {
    return val + 1;
};

// example/decrement.js
exports.default = function(val) {
    return val - 1;
};

// example/loader.js
module.exports = function loader(source) {
    return source.replace(/console.log/g, 'console.warn')
}
复制代码

环境搭建

代码使用typescript编写,因此先安装typescript相关依赖json

# typescript
"typescript": "^3.7.4"
# 帮助识别node相关的类型定义
"@types/node": "^13.1.4",
# 快速编译运行ts项目
"ts-node": "^8.5.4",
复制代码

在package.json添加运行脚本浏览器

"start": "npx ts-node index.js",
复制代码

入口文件

入口文件就是咱们运行Webpack的地方,这里咱们定义一些简单的配置,包括编译入口文件entry,输出文件bundle.js,还有自定义loader。引入咱们的核心编译器Compiler,传入配置运行。bash

// index.js
const path = require('path')
const Compiler = require('./lib/Compiler').default

const options = {
    entry: path.resolve(__dirname, './example/index.js'),
    output: path.resolve(__dirname, './dist/bundle.js'),
    loader: path.resolve(__dirname, './example/loader.js')
}

const compiler = new Compiler(options)
compiler.run()
复制代码

核心编译器Compiler

Compiler建立

编译器负责封装打包过程,输入是用户配置,输出是打包结果,对外提供一个run函数启动编译。
入口模块是编译器解析的起点,从入口文件开始递归加载模块文件,这里咱们没有递归解析只简单地解析了入口文件的依赖,收集到全部依赖后渲染出合并后的代码,最后写出到文件。模块化

// lib/Compiler.ts
import * as fs from 'fs'
import * as path from 'path'
import Module from './Module'
export default class Compiler {
    options: any
    constructor(options: any) {
        this.options = options
    }
    run() {
        // 建立入口模块
        const name = path.basename(this.options.entry)
        const entryModule = this.createModule(name, this.options.entry)
        // 解析依赖模块
        const dependencies = this.parse(entryModule.source)
        this.addModuleDependencies(entryModule, dependencies)
        // 渲染出结果
        const source = this.renderTemplate(entryModule)
        // 写入文件
        this.write(source, this.options.output)
    }
    // ...
}
复制代码

建立模块

Webpack中将一切资源都当作模块,因此咱们要解析的一个个js文件也是用模块表示,首先先定义一个Module类来表示模块:

// lib/Module.ts
export default class Module {
    id: string // 模块惟一标志,这里咱们用文件名表示
    source: string // 文件源码
    absPath: string // 文件绝对路径
    dependencies: Module[] // 文件全部依赖
}
复制代码

有了模块类咱们就能够封装建立模块功能了,除了初始化数据外,咱们还在这里将文件读取出来,而后使用loader对源码进行处理。

// Compiler.createModule
createModule(id: string, absPath: string) {
    const module = new Module()
    module.id = id
    module.absPath = absPath
    module.source = fs.readFileSync(absPath).toString()
    module.dependencies = []

    const loader = require(this.options.loader)
    module.source = loader(module.source)

    return module
}
复制代码

分析模块依赖

webpack的基本功能就是将模块化代码打包成浏览器可运行代码。因为浏览器不能直接识别模块化代码,就须要咱们将多个文件按依赖顺序合并成一个文件,因此识别出模块依赖是咱们要解决的第一个问题。
咱们使用CommonJS来组织代码,就要在代码中识别出require这样的关键字,因此这里咱们简单地使用正则匹配,通过循环匹配后,就能取出包含require('xxx')中的依赖项了。
用正则匹配还须要考虑注释换行等麻烦的校验。Webpack则是将代码解析成AST树来分析依赖,AST里包含了更丰富的信息且不容易出错。

// Compiler.parse
parse(source: string) {
    const dependencies: any[] = []
    let result = []
    let reg = /require[('"].([^']*)[)'"]./g
    while((result = reg.exec(source))) {
        dependencies.push({
            id: result[1]
        })
    }
    return dependencies
}
复制代码

建立依赖模块

在这里咱们已经获取到了父模块和他的全部依赖项,此时咱们就要将依赖也转成一个个模块,由于一个依赖也是一个文件,一个文件在webpack中就是一个模块。

// Compiler.addModuleDependencies
addModuleDependencies(module: Module, dependencies: any[]) {
    const dir = path.dirname(module.absPath)
    for (const dependent of dependencies) {
        const depModule = this.createModule(dependent.id, path.resolve(dir, dependent.id) + '.js')
        module.dependencies.push(depModule)
    }
    return
}
复制代码

渲染模版

上面说了,要想将模块化代码转换成在浏览器环境下执行的代码,咱们应该将全部将要执行的代码合并在一块儿,用一个js文件给浏览器执行,并且浏览器不识别的CommonJS语法也须要咱们给打上补丁,让浏览器能正确识别requireexports,因此咱们的目标代码应该长这样:

(function (modules) {
    function require(moduleId) {
        var module = {
            id: moduleId,
            exports: {}
        }
        modules[moduleId](module, require)
        return module.exports;
    }
    require("index.js");
})({
    'index.js': (function (module, require) {
        const inc = require('./increment')
        const dec = require('./decrement')
        console.warn(inc(8))
        console.warn(dec(8))
    }),
    './increment': (function (module, require) {
        module.exports = function (val) {
            return val + 1;
        };
    }),
    './decrement': (function (module, require) {
        module.exports = function (val) {
            return val - 1;
        };
    }),
})
复制代码

当即执行函数传入合并后的全部代码,并建立了require函数来加载合并后的对象,在咱们的代码中遇到了require就会带入相应的函数,只要初始化后调用一次入口模块代码就能执行了。能够看到除了传参的代码,其余都是固定的模版代码,参数代码咱们能够用前面解析的依赖来建立。

// Compiler.renderTemplate
renderTemplate(module: Module) {
    const buffer = []
    buffer.push(`(function(modules) { function require(moduleId) { var module = { id: moduleId, exports: {} } modules[moduleId](module, require) return module.exports; } require("${module.id}"); })({`)

    buffer.push(`'${module.id}': (function(module, require) { \n ${module.source} \n }),`)
    for (const dependent of module.dependencies) {
        const src = `(function(module, require) { \n ${dependent.source.replace('exports.default', 'module.exports')} \n })`
        buffer.push(`'${dependent.id}':${src},`)
    }

    buffer.push(`})`)
    return buffer.reduce((pre, cur) => pre + cur, '')
}
复制代码

输出文件

输出了模版代码后,只要调用系统方法将其输出到硬盘就能够了,很是简单

// Compiler.write
write(source: string, output: string) {
    fs.writeFileSync(output, source)
}
复制代码
相关文章
相关标签/搜索