webpack,简单实现的手写

281 阅读6分钟

大致的流程如下

  1. 初始化参数 从配置文件中 webpack.config.js 读取配置对象 然后和 shell参数进行合并得到最终的 配置对象

  2. 上一个得到的参数 初始化 Complier 对象

  3. 加载所有配置的插件

  4. 执行run 开始编译

  5. 根据配置中的 entry找到入口文件

  6. 从入口文件出发 调用所有的配置的loader 对模块进行编译

  7. 在找出该模块对应的依赖的模块, 再递归本步骤直到所有的入口依赖的文件都经过了本步骤的处理 ./src/entry.js

  8. 根据入口模块之间的依赖关系,组装成一个包含多个模块的 Chunk

9.再把每个chunk 转化为一个单独的文件到输出列表 this.assets 对象 key是文件名 值是文件的内容

整体的具体的流程如下

  1. 读取配置对象 读取shell 参数对象 然后合并配置对象和参数对象

  2. 创建Complier (保存options 创建hooks 初始化一些内部对象entries、mudules、chunks、assets、files)

3.挂载插件 先是run done

  1. 调用Complier的run方法进行编译 找到entry入口文件 然后遍历entry拿到入口文件的路径buildModule函数(1. 读取模块内容 2. rules里找到对应的loaders 3.依次调用所有loader对文件进行转化 )

  2. 创建入口模块对象(对象里有1.模块ID相对于根目录的路径 2.模块的名字就是入口的名字entry1 entry2 3.依赖模块的绝对路径数组)

  3. loader转换后的代码 转为AST语法树, 然后遍历语法树(找源码里的import和require)

  4. 找依赖的模块,找到后放在当前模块的依赖模块的数组里 然后递归编译依赖模块

  5. 把每个入口模块和他的依赖的模块构建成一个chunk代码块

  6. 根据chunk生成 assets 输出文件列表 key就是文件名 value就是 文件内容

  7. 根据assets 写入文件系统

  8. 结束

在以上过程中, webpack会在特点的时间点广播出特定的事件,插件在监听到感兴趣的事件后会执行特定的逻辑,并且插件可以调用webpack 提供的API(run done emit) 改变webpack的运行的结果

其他问题(可以跳过)

// 考虑下优化就是

// 1. 同步操作改为异步

// 2. 能不能通过插件修改输出的文件, 添加 删除 修改 hook.emit

// 如果有多层依赖代码块是如何进行分割的? 答:代码块的分割点就是 import('...')

// 如果不进行代码分割,有几个入口配置就有几个chunk

// compilation 和 complier? 在webpack编译的时候 complier只有一个 但是每次编译后都有又一个新的compilation

// splitChunks webpack5 已经内置了

// let webpack = require('webpack')

// 1. 初始化参数 从配置文件中 webpack.config.js 读取配置对象 然后和 shell参数进行合并得到最终的 配置对象

// shell参数 比如说是 package.json里的 scripts{

// "build": "webpack --mode=development"

// }

// 2. 上一个得到的参数 初始化 Complier 对象

const options = webpack(options)

// 3. 加载所有配置的插件

// 4. 执行run 开始编译

最主要的两个文件如下

complier.run((err,stats) => {

console.log(err)

console.log(stats.toJson({

entries: true, // 入口信息

modules: true, // 本次打包有哪些模块

chunks: true, // 代码块

assets: true, // 产出的资源

files: true, // 最后生产了哪些文件

}))

})

webpack.js文件

webpack.js文件

let Complier = require('./Complier')
function webpack (options) {
    // 初始化参数 从配置文件中 webpack.config.js  读取配置对象 然后和 shell参数进行合并得到最终的 配置对象
    let shellOptions = process.argv.slice(2).reduce((config, args) => {
        let [key,value] = args.split('=') // --mode-production
        config[key.slice(2)] = value
        return config
    },{})

    let finnalOption = {...options,...shellOptions}

    let complier = new Complier(options)

    // 3. 加载所有的配置的插件
    if (finnalOption.plugins && Array.isArray(finnalOption.plugins)) {
        for (let plugin of options.plugins) {
            plugin.apply(complier)
        }
    }
    
    return complier

}

module.exports = webpack

complier.js文件

complier.js文件


let {SyncHook} = require('./doc/tapable')
let {toUnixPath}  = require('./utils')
let fs = require('fs')
let types = require('bable-types') // 判断某个节点是否时某种类型,生成某个新的节点
let parser = require('@bable/parser') // 语法解析 源码生产 AST语法树
let traverse = require('@bable/traverse').default // 遍历器 遍历语法树
let generator = require('@bable/generator').default // 生成器  根据语法树重新生成代码

let rootPath = toUnixPath(process.cwd())


class Complier {
    constructor (options) {
        this.options = options
        this.hooks = {
            run: new SyncHook(), // 开始编译
            emit: new SyncHook(), // 写入文件系统 //这个钩子会在我们写入文件系统的时候触发 所以可以在这里改变文件
            done: new SyncHook() // 编译全部完成
        }
        this.entries = new Set() // 所有的入口模块, wepack4 是数组 现在是 Set
        this.mudules = new Set() // 所有的模块
        this.chunks = new Set() // 所有的代码块
        this.assets = {} // 存放本次产出的资源文件
        this.files = new Set() // 存放这 本次编译所有的产出的文件名
    }
    run (callback) {
        this.hooks.run.call()
        // console.log('开始真正的编译')
        // 5. 根据配置中的 entry找到入口文件
        let entry = {}
        if (typeof this.options.entry === 'string') {
            entry.main = this.options.entry
        } else {
            entry = this.options.entry
        }
        // 6. 从入口文件出发 调用所有的配置的loader 对模块进行编译
        for (let entryName in entry) {
            let entryPath = toUnixPath(path.join(rootPath, entry[entryName]))

            let entryModule = this.buildModule(entryName,entryPath)
            this.entries.add(entryModule)
            // this.modules.add(entryModule)
            // 8. 根据入口模块之间的依赖关系,组装成一个包含多个模块的 Chunk   
            let chunk = {name:entryName,entryModule,modules: Array.from(this.modules).filter(module => module.name === entryName)}
            this.chunks.add(chunk)
        }
        // 9.再把每个chunk 转化为一个单独的文件到输出列表 this.assets 对象 key是文件名 值是文件的内容
        let output = this.options.output
        this.chunks.forEach(chunk => {
            // let fileName = path.join(output.path, output.fileName.replace('[name]',chunk.name))
            let fileName = output.fileName.replace('[name]',chunk.name) // 这里只是文件名没有路径
            this.assets[fileName] = getSource(chunk)
        })

        this.hooks.emit.call()

        this.files = Object.keys(this.assets) // 文件名的一个数组
        for(let fileName in this.assets) {
            let filePath = path.join(output.path, fileName)
            fs.writeFileSync(filePath, this.assets[fileName])
        }
        // 到这里编译全部接受,就可以触发done钩子的回调了
        this.hooks.done.call()
        callback(null, {
            toJson: ()=> ({
                entries: this.entries,
                chunks: this.chunks,
                modules: this.modules,
                files: this.files,
                assets: this.assets
            })
        })
            
    }
    buildModule(entryName,modulePath) {
        // 1. 读取此模块内容
        let originalSourceCode = fs.readFileSync(modulePath, 'utf8')
        let targetSourceCode = originalSourceCode
        // 2. 调用 所有配置的loader 对模块进行编译
        let rules = this.options.module.rules
        // 这个loaders 得打哦本文件生效的loader 有哪些
        this.loaders = []
        for (let i = 0; i < rules.length; i++) {
            // if (rules[i].test.test(modulePath))
            if (modulePath.match(rules[i].test)) {
                loaders = [...this.loaders, ...rules[i].use]
            }
        }

        for(let i = this.loaders.length-1; i >= 0; i--) {
            targetSourceCode = require(this.loaders[i])(targetSourceCode)
        }
        // 7. 在找出该模块对应的依赖的模块, 再递归本步骤直到所有的入口依赖的文件都经过了本步骤的处理  ./src/entry.js

        // A B C 模块ID 都是相对与根目录的相对路径 ./
        //path.posix 把 所有的 都变成 /
        let moduleId = './'+path.posix.relative(rootPath, modulePath)
        let module = {id: moduleId, dependencies:[],name:entryName}
        // 再找出该模块依赖的模块  把转换后的源码转为抽象语法树
        let AST = parser.parser(targetSourceCode,{resourceType:'module'})

        traverse(ast, {
            CallExpression: (node) => {
                if (node.callee.name === 'require') { // 说明有引入模块
                    // 要引入模块的相对路径
                    let moduleName = node.arguments[0].value
                    // 为了获取要加载的模块的绝对路径  第一步获得当前模块的所在目录
                    let dirName = path.posix.dirname(modulePath)
                    let depModulePath = path.posix.join(dirName,moduleName)
                    let extensions = this.options.resolve.extensions
                    depModulePath = tryExtensions(depModulePath,extensions,moduleName,dirName)
                    let depModuleId = './'+path.posix.relative(rootPath,depModulePath) // ./src/title.js
                    node.arguments = [types.stringLiteral(depModuleId)]
                    // 判断现有的已经编译过的 modules里有没有这个板块,如果有就不要在添加依赖了如没有就添加
                    let alreadyModuleIds = Array.from(this.modules).map(module => module.id)
                    if(!alreadyModuleIds.includes(depModuleId)) {
                        module.dependencies.add(depModulePath)
                    }
                        
                }
            }
        })
        let {code} = generator(ast)
        module._source = code // 此模块的 源代码
        // 把当前模块编译完成,找到他所有的依赖的模块,进行递归的编译,添加到 this.modules中
        module.dependencies.forEach(dependency => {
            let depModule = this.buildModule(entryName, dependency)
            this.mudules.add(depModule)
        })
    }
}
//  获取 chunk 对应的 源代码  输出的文件内容
// name 代码块的名字 entryModule入口模块  modules所有的模块
function getSource (chunk) {
    `(() => {
        var modules = ({
            ${
                chunk.modules.map(module => `
                "${module.id}":
                ((module)=>{ ${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}
        })()
    })()`
}
/**
 * 
 * @param {*} modulePath 拼出来的模版路径 c:/src/title
 * @param {*} extensions  ['.js','.jsx','.json']
 * @param {*} originModulePath  ./title
 * @param {*} moduleContext  c:src/
 */
function tryExtensions (modulePath,extensions,originModulePath,moduleContext) {
    extensions.unshift('') // ['','.js','.jsx','.json']
    for (let i = 0; i < extensions.length; i++) {
        if (fs.existsSync(moduleP+extensions[i])) {
            return modulePath + extensions[i]
        }
    }
    // 如果还是没有找到 代码执行到这里 说明没有一个后缀匹配上 需要报错
    throw new Error(`Module not found: Error: Can't resolve ${originModulePath} in ${moduleContext}`)
}

module.exports = Complier