likes
comments
collection
share

手把手教你手写一个webpack (webpack核心原理)

作者站长头像
站长
· 阅读数 20

前言

众所周知 一个好的前端就是要于手写各种前端工具链。 咱们今天来写一个玩具webpack,当然市面上有很多手写webpack的教程, 我这个版本省去了webpack中的tapable库和loader,plugin。只保留了最简单的核心原理,让大家能快速理解。

这里是本人的迷你webpack项目 大家可以点个star看看) (实现了plugin和loader 并且模拟了esBuild) github.com/lzy19926/ti…

AST虚拟语法树

首先我们需要知道什么是AST AST简单的说就是: 使用对象的形式来表达一行代码 我们来看一个句简单的AST

import name from './index.js'


 {
        "type": "ImportDeclaration", // 语句的类型 
        "start": 0,
        "end": 31, 
        "loc": {}, 
        "specifiers": [...], // 保存了{name} 部分
        "importKind": "value",
        "source": {...}, // 保存了'./index.js'部分 
        "assertions": []
      }

我们可以通过这句的AST下的source找到import的文件路径

手把手教你手写一个webpack (webpack核心原理)

其中的specifiers保存了import的内容

手把手教你手写一个webpack (webpack核心原理)

AST语句的分类

AST可以分为多种类型

ImportDeclaration // import语句
VariableDeclaration // 变量声明语句 
IfStatement // if/else语句
FunctionDeclaration // 函数声明
Identifier // 变量名/函数名等
...... 还有很多

搭建一个webpack类的架子

webpack主要是由其中的webpackCompiler类进行打包的 我们这里写一个简单的compiler架子 并在期基础上进行功能开发 传入config

class WebpackCompiler {
     constructor(webpackConfig) {
        this.config = webpackConfig
    }
    
    bundle(){ // 最终执行此方法进行打包
        
    }
}

当然别忘了简单配置一下自己的webpackConfig

// webpack.config.js
const path = require('path')
module.exports = {
    rootPath: __dirname, // 项目根路径
    entry: path.join(__dirname, '/src/index.js'), //配置打包入口
    output: path.join(__dirname, '/dist'), // 出口
}

构建一个文件模块

现在我们知道 可以通过AST找到import的文件路径 那么我们需要

  1. 转换代码为AST 解析这些AST
  2. 将代码中import的模块推到数组中 递归解析
  3. 将文件内的ES6代码转化为ES5
  4. 生成文件的依赖图
  5. 生成文件模块(ES5代码+文件绝对路径+文件的依赖图)

我这里使用babel的AST进行转换

const fs = require('fs')
const parser = require('@babel/parser') //转换JS字符串为AST
const traverse = require('@babel/traverse').default //遍历AST的工具
const babel = require('@babel/core') // 转化es6为es5工具

 createAssets(absolutePath) {
         //读取文件内容
        const fileContent = fs.readFileSync(absolutePath, 'utf-8')
        //使用babel/parser将index代码转换为AST语法树  
        const ast = parser.parse(fileContent, {
            sourceType: 'module'
        })

        //! 使用babel/traverse遍历AST语法树  将所有import的文件推入dependencise数组
      
        //traverser传入钩子函数 遍历到对应的语句  就会执行钩子函数  (详见AST Exporer)   
        const dependencies = []
        traverse(ast, {
            ImportDeclaration: (childAst, state) => {
                const depRaletivePath = childAst.node.source.value
                const nextDepRaletivePath = this.addSuffix(depRaletivePath)
                childAst.node.source.value = nextDepRaletivePath //如果没有.js尾缀 自动添加
                dependencies.push(nextDepRaletivePath) //todo 每次遇到import语句  将其文件路径push到依赖数组
            }
        })


        //!使用babel/core 转化ast为ES5语法 支持浏览器运行
        const es5Code = babel.transformFromAstSync(ast, null, {
            presets: ['@babel/preset-env'],
        })

        // 返回由这个文件构建的asste  这就是一个文件模块
        return {
            filePath: absolutePath,
            code: es5Code.code,
            dependencies
        }
    }

将解析来的文件包裹为一个模块

通过AST 我们可以获取文件引用的模块 之后我们会将其推入一个数组 作为文件的依赖图 所以我的一个webpack文件模块最后长这样

//index.js文件
import name from ./name.js
console.log(name)

// 生成的模块 键值对
const modules = {
 "E:\\My_Webpack\\myWebpack\\src\\index.js": [
            function (require, module, exports) { // 文件的代码 转化为ES5后的
                "use strict";
                var _name = require("./name.js");
                console.log(_info.info);
            },
            { "./name.js": "E:\\My_Webpack\\myWebpack\\src\\name.js" } // 文件依赖的模块
         ]
}

所以我们可以通过入口文件 递归执行createAssets方法 并将其推入数组 即可生成多个modules 多个模块先构成依赖图Manifest 我们这里使用队列循环方式 替代递归流程 话不多说上代码

  //构建文件依赖图
    createManifest(entry) {

        //1 通过入口文件构建文件资源
        const mainAsset = this.createAssets(entry)

        // todo  这里需要进行模块比较  重复的模块不推入
        //2 使用队列循环方式构建依赖图(遍历+递归 使用createAssets处理每个js文件)
        const manifestQueue = [mainAsset]

        for (const asset of queue) {

            const dirname = path.dirname(asset.filePath) // 获取当前处理文件的绝对路径
            asset.mapping = {} // 文件的依赖map

            asset.dependencies.forEach(relativePath => {// 遍历文件依赖的文件(import)

                const absolutePath = path.join(dirname, relativePath) // 获取import文件的绝对路径
                const childAsset = this.createAssets(absolutePath) //! 通过绝对路径构建子文件资源

                asset.mapping[relativePath] = absolutePath //!通过相对路径和绝对路径匹配 构建资源依赖图
                manifestQueue.push(childAsset) // 处理好的资源推入数组 (childAsset会进入下个循环继续执行)
            })
        }

        return manifestQueue
    }

打包这些模块(将Manifest包裹为模块键值对)

现在我们有了一个模块数组 里面保存了所有项目的模块 其中的ES6代码已经被babel转化为了ES5代码 我们需要将这些模块构建为一个对象 使用键值对保存模块

    
    createModules(manifest) {

        let modulesStr = '';
        // 构建每个module为键值对 并添加进modules对象(所有资源都以字符串形式构建)
        //todo 注意  (1.处理模块为键值对 绝对路径为key 值保存模块的code和mapping)
        //todo 2. 模块的code应放在一个函数里 因为每个模块的code中使用了require,exports两个API 需要传入
        //todo 3 文件中的依赖是相对路径  需要使用绝对路径
        manifest.forEach(module => {
            renderProgressBar(`打包模块${module.filePath}`); //! ------------------------进度显示

            const key = JSON.stringify(module.filePath)
            const mapping = JSON.stringify(module.mapping)
            const code = `function(require,module,exports){
            ${module.code}
        } `

            // 单个模块资源
            const modulesPart = `${key}:[\n ${code},\n ${mapping} \n ],\n`
            modulesStr += modulesPart
        })

        return `{${modulesStr}}`
    }

生成最终的bundle代码

现在我们有了构建好的模块对象 如下

const modules = {
 "E:\\My_Webpack\\myWebpack\\src\\index.js": [ // 文件的绝对路径
            function (require, module, exports) { // 文件的代码 转化为ES5后的
                "use strict";
                var _name = require("./name.js");
                console.log(_info.info);
            },
            { "./name.js": "E:\\My_Webpack\\myWebpack\\src\\name.js" } // 文件依赖的模块
         ]
    ......
}

我们将其用立即执行函数包裹起来(使用字符串) 并自定义一个require方法 传入模块中进行使用

createOutputCode(modulesStr) {

 let result = ` // bundle最终生成的代码 
        (function(){
            //todo 传入modules
            var modules = ${modulesStr}
            //todo 创建require函数 获取modules的函数代码和mapping对象
            function require(raletivePath){
                const [fn,mapping]  = modules[raletivePath]
                //(loaclRequire 通过相对路径获取绝对路径(id)并执行require)
                const loaclRequire =(relativePath)=>{                    
                    return  require(mapping[relativePath])
                }
                //! 构造模拟Node的module对象
                const module = {
                    exports:{}
                }
                //! 将三个参数传入fn并执行
                fn(loaclRequire,module,module.exports)
                
                return module.exports
            }

            //! 执行require(entry)入口模块
             require(${JSON.stringify(this.config.entry)})
        })();`
        
        
     return result
        }

得到bundle代码 创建文件

outputDist(result) {
    //todo 没有dist时创建dist文件夹
    const hasDir = fs.existsSync(this.config.output)
    if (!hasDir) {
        fs.mkdirSync(this.config.output)
    }

    //todo 写入文件
    fs.writeFileSync(this.config.output + `/bundle.js`, result)

}

将上述方法整合起来 构建bundle方法

bundleModules(){
        const manifest = this.createManifest(this.config.entry)  // 创建文件依赖图(Manifest)
        const modules = this.createModules(manifest) // 生成modules
        let result = this.createOutputCode(modules)// 打包模块生成bundle代码
        outputDist(result)
}

实例化我们的webpackCompiler类 执行bundle方法 ,我们就生成了bundle.js文件

const webpack = new WebpackCompiler(config)
webpack.bundle()