手写webpack打包流程

541 阅读13分钟

webpack打包的工作流程:

基本流程

  1. 读到入口文件
  2. 分析入口文件,递归的去读取各个依赖模块中的内容,生成AST语法树
  3. 根据生成的AST语法树,转为浏览器能够运行的代码

搭建本地环境

下面,我们将开始简单的实现一下webpack的打包流程,了解webpack在打包过程中具体做了些什么,怎么样去分析模块之间相互的依赖关系并最后能打包生成可以在浏览器上运行的项目

首先,在任意目录下新建一个文件夹webpack-demo,并初始化项目并安装webpack(需要先下载nodejs)

npm init -y
yarn add webpack webpack-cli

或者可以使用
npm i webpack webpack-cli -S
cnpm i webpack webpack-cli -S

新建一些文件

src/index.js

import add from './add.js'
import multiple from './multiple'

console.log(add(1, 2, 3) + multiple(2, 2)) // 将add函数与multiple函数相加

src/add.js

export default (a, b, c) => a + b + c

src/multiple.js

import { init } from '../common/index.js'

export default (a, b) => init * a * b

common/index.js

export const init = 1

这里随便建了几个文件,写点东西 新建webpack.config.js

const path = require('path')

module.exports = {
    entry: './src/index.js', // 入口文件
    output: {
        path: path.resolve(__dirname, 'dist'), // 打包后的文件存放的路径
        filename: 'bundle.js' // 打包后的文件名称
    }
}

分析源码

npx webpack运行一下,发现根目录下多了一个dist文件夹,里面有一个bundle.js,控制台报了黄色警告,提示需要加上mode属性,这里为了更好的看到打包后的文件是什么样子,因此mode值给成development 后续的打印,都是需要运行npx webpack

打开bundle.js文件,明显文件的内容整体是一个IIFE,先不管上面的,一路看到下面,找到这个函数的入参处,看看入参都传了些什么,里面的注释去一下,格式调成能够容易看懂的格式

{
    "./common/index.js":
        (function (module, __webpack_exports__, __webpack_require__) {
            "use strict";
            eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"init\", function() { return init; });\nconst init = 1\n\n//# sourceURL=webpack:///./common/index.js?");
        }),
    "./src/add.js":
        (function (module, __webpack_exports__, __webpack_require__) {
            "use strict";
            eval("__webpack_require__.r(__webpack_exports__);\n/* harmony default export */ __webpack_exports__[\"default\"] = ((a, b, c) => a + b + c);\n\n//# sourceURL=webpack:///./src/add.js?");
        }),

    "./src/index.js":
        (function (module, __webpack_exports__, __webpack_require__) {
            "use strict";
            eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _add_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./add.js */ \"./src/add.js\");\n/* harmony import */ var _multiple__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./multiple */ \"./src/multiple.js\");\n\r\n\r\n\r\nconsole.log(Object(_add_js__WEBPACK_IMPORTED_MODULE_0__[\"default\"])(1, 2, 3) + Object(_multiple__WEBPACK_IMPORTED_MODULE_1__[\"default\"])(2, 2)) // 将add函数与multiple函数相加\n\n//# sourceURL=webpack:///./src/index.js?");
        }),
    "./src/multiple.js":
        (function (module, __webpack_exports__, __webpack_require__) {
            "use strict";
            eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _common_index_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ../common/index.js */ \"./common/index.js\");\n\r\n\r\n/* harmony default export */ __webpack_exports__[\"default\"] = ((a, b) => _common_index_js__WEBPACK_IMPORTED_MODULE_0__[\"init\"] * a * b);\r\n\n\n//# sourceURL=webpack:///./src/multiple.js?");
        })
}

可以发现,入参的其实是个key:value形式的对象,key就是我们项目里那几个js的路径,value是个函数。 看下'./src/index.js'对应的value项:

function (module, __webpack_exports__, __webpack_require__) {
    "use strict";
    eval("__webpack_require__.r(__webpack_exports__);\n/* harmony import */ var _add_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./add.js */ \"./src/add.js\");\n/* harmony import */ var _multiple__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./multiple */ \"./src/multiple.js\");\n\r\n\r\n\r\nconsole.log(Object(_add_js__WEBPACK_IMPORTED_MODULE_0__[\"default\"])(1, 2, 3) + Object(_multiple__WEBPACK_IMPORTED_MODULE_1__[\"default\"])(2, 2)) // 将add函数与multiple函数相加\n\n//# sourceURL=webpack:///./src/index.js?");
}

这个函数里面有一个eval,也就是说,这个函数被调用的时候,eval就会执行它里面的代码,但是这个是实参,仅仅是我们传入的参数而已,它是在上面的函数体内被执行的

var installedModules = {};
    
function __webpack_require__(moduleId) {
    // Check if module is in cache
    if (installedModules[moduleId]) {
        return installedModules[moduleId].exports;
    }

    // Create a new module (and put it into the cache)
    var module = installedModules[moduleId] = {
        i: moduleId,
        l: false,
        exports: {}
    };

    // Execute the module function
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

    // Flag the module as loaded
    module.l = true;

    // Return the exports of the module
    return module.exports;
}

这是上面开头的一段代码

modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);

这里有个call,call是用来执行函数的,因此这段代码行下面入参对象value值里面的函数的 接着逆向分析,如果我们想执行那个函数,一般情况下会怎么做?

const obj = {
    './common/index.js': function(){}
}
obj['./common/index.js']() 或者 obj['./common/index.js'].call() 或者obj['./common/index.js'].apply()
modules[moduleId].call() = obj['./common/index.js'].call()

moduleId就是实参里的key值,而modules[moduleId].call()又在__webpack_require__函数体内;
匿名函数函数体内的底部调用__webpack_require__('./src/index.js'),先从入口文件开始,执行了它对应的value代码;
随后碰到了eval,在eval里又碰到了__webpack_require__('./add.js'),执行add.js里面的内容,add.js在index.js中被引入了,所以会执行它;

匿名函数接收一个对象,这个对象是以文件路径为key,文件内容为value的形式传入到匿名函数中,匿名函数中的__webpack_require__接收文件路径作为参数,又会执行其他文件的内容。 这样,从入口文件开始,import引入的模块会被执行,这个引入的模块内部,如果还引入了其他模块,也会去执行,直到所有的模块内容都被执行完毕。
分析的差不多了,下面进入正题,我们也来实现一个类似的,虽然实现的代码不一样,但是效果却是一样的。

实现Compiler编译类工具

根目录下创建一个名为lib的文件夹,再在这个文件夹下创建一个index.js,这个就是我们自己要实现的打包类。 处理文件的过程中,需要用到以下几个包:

yarn add @babel/traverse @babel/core @babel/preset-env @babel/parser

@babel/parser是将传入的文件内容转为ast语法树 @babel/parser 可以更好的拿到每个文件引入的模块 @babel/core配合@babel/preset-env使用,作用是将ast语法树转为浏览器能够识别的代码

lib/index.js

const path = require('path')
const fs = require('fs')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const { transformFromAst } = require('@babel/core')

class Compiler {
    constructor(options) {
        /**
         * 接收一个options配置项,包含entry入口,output出口,loader处理器
         */
        const { entry, output } = options
        this.entry = entry
        this.output = output
        this.build(this.entry)
    }

    // 分析传入的当前模块路径,返回该模块的路径、内容和依赖的对象
    build(file) {
        /**
         * 1. 读取入口文件的内容,转为ast语法树
         * 2. 通过ast语法树找到所有文件的依赖模块
         * 3. 将ast语法树转化为浏览器能够运行的ES5代码
         */
    }

    // 输出处理后的内容到weppack.config.js的output指定的文件内
    outputFile() {

    }
}

module.exports = Compiler

index.js (根目录下)

const Compiler = require('./lib/index.js')
const options = require('./webpack.config.js')

new Compiler(options) // 将webpack配置文件传入

再打开package.json,新增一条命令,使用nodejs运行这个文件

"scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "build": "node index.js"
},

build() 生成模块对象

然后继续回到lib/index.js,这里打印一下ast,看看是否成功转化成了ast语法树

build(file) {
    /**
        * 1. 读取入口文件的内容,转为ast语法树
        * 2. 通过ast语法树找到所有文件的依赖模块
        * 3. 将ast语法树转化为浏览器能够运行的ES5代码
        */
    const content = fs.readFileSync(file, 'utf-8') // 读取入口文件的内容
    const ast = parser.parse(content, {
        sourceType: 'module' // module是告诉这个方法按照ES6的语法处理(分析import export)
    })
    console.log(ast)
}

打印结果如下

Node {
  type: 'File',
  start: 0,
  end: 130,
  loc: SourceLocation {
    start: Position { line: 1, column: 0 },
    end: Position { line: 4, column: 65 }
  },
  errors: [],
  program: Node {
    type: 'Program',
    start: 0,
    end: 130,
    loc: SourceLocation { start: [Position], end: [Position] },
    sourceType: 'module',
    interpreter: null,
    body: [ [Node], [Node], [Node] ],
    directives: []
  },
  comments: [
    {
      type: 'CommentLine',
      value: ' 将add函数与multiple函数相加',
      start: 108,
      end: 130,
      loc: [SourceLocation]
    }
  ]
}

program属性下有个body属性,我们要看到的是这个,打印下看看

console.log(ast.program.body)
[
  Node {
    type: 'ImportDeclaration',
    start: 0,
    end: 26,
    loc: SourceLocation { start: [Position], end: [Position] },
    specifiers: [ [Node] ],
    source: Node {
      type: 'StringLiteral',
      start: 16,
      end: 26,
      loc: [SourceLocation],
      extra: [Object],
      value: './add.js'
    }
  },
  Node {
    type: 'ImportDeclaration',
    start: 28,
    end: 61,
    loc: SourceLocation { start: [Position], end: [Position] },
    specifiers: [ [Node] ],
    source: Node {
      type: 'StringLiteral',
      start: 49,
      end: 61,
      loc: [SourceLocation],
      extra: [Object],
      value: './multiple'
    }
  },
  Node {
    type: 'ExpressionStatement',
    start: 65,
    end: 107,
    loc: SourceLocation { start: [Position], end: [Position] },
    expression: Node {
      type: 'CallExpression',
      start: 65,
      end: 107,
      loc: [SourceLocation],
      callee: [Node],
      arguments: [Array]
    },
    trailingComments: [ [Object] ]
  }
]

打印出来一个数组,里面的两项都有一个type为ImportDeclaration的属性; 并且value就是入口文件通过import引入的文件,OK,我们要做的就是拿到它。 当然了,你可以直接遍历这个数组去拿,可以,但没必要,因为有更好的方式,@babel/traverse

build(file) {
    /**
        * 1. 读取入口文件的内容,转为ast语法树
        * 2. 通过ast语法树找到所有文件的依赖模块
        * 3. 将ast语法树转化为浏览器能够运行的ES5代码
        */
    const content = fs.readFileSync(file, 'utf-8') // 读取入口文件的内容
    const ast = parser.parse(content, {
        sourceType: 'module' // module是告诉这个方法按照ES6的语法处理(分析import export)
    })
    console.log(ast)
    traverse(ast,{
        ImportDeclaration({node}) {
            console.log(node)
        }
    })
}

打印结果如下:

Node {
  type: 'ImportDeclaration',
  start: 0,
  end: 26,
  loc: SourceLocation {
    start: Position { line: 1, column: 0 },
    end: Position { line: 1, column: 26 }
  },
  specifiers: [
    Node {
      type: 'ImportDefaultSpecifier',
      start: 7,
      end: 10,
      loc: [SourceLocation],
      local: [Node]
    }
  ],
  source: Node {
    type: 'StringLiteral',
    start: 16,
    end: 26,
    loc: SourceLocation { start: [Position], end: [Position] },
    extra: { rawValue: './add.js', raw: "'./add.js'" },
    value: './add.js'
  }
}
Node {
  type: 'ImportDeclaration',
  start: 28,
  end: 61,
  loc: SourceLocation {
    start: Position { line: 2, column: 0 },
    end: Position { line: 2, column: 33 }
  },
  specifiers: [
    Node {
      type: 'ImportDefaultSpecifier',
      start: 35,
      end: 43,
      loc: [SourceLocation],
      local: [Node]
    }
  ],
  source: Node {
    type: 'StringLiteral',
    start: 49,
    end: 61,
    loc: SourceLocation { start: [Position], end: [Position] },
    extra: { rawValue: './multiple', raw: "'./multiple'" },
    value: './multiple'
  }
}

value是我们想要的,所以要通过node.source.value去拿

const deps = {} // 声明一个对象,用来存储每个文件中的依赖模块,联想到webpack打包后的源码,这里也将每个模块的路径作为key
traverse(ast, {
    ImportDeclaration({ node }) {
        let files = path.dirname(file)
        /*
        value值做个处理,返回这个模块的完整路径,比如:moduleObj['./add.js'] = './src/add.js',分析源码的时候,__webpack_require__('./add.js')能够获取到key值为'./src/add.js属性下面的内容',说明它有某种方法能够通过'./add.js'读取到'./src/add.js',所以这里就应该对路径做处理
        **/

        deps[node.source.value] = `./${path.join(files, node.source.value)}` 
    }
})
// 将生成的ast语法树转为浏览器能够识别的ES5语法
const code = transformFromAst(ast, null, {
    presets: ['@babel/preset-env']
})
console.log(code)

打印结果

{
  metadata: {},
  options: {
    cloneInputAst: true,
    babelrc: false,
    configFile: false,
    passPerPreset: false,
    envName: 'development',
    cwd: 'E:\\myProject\\webpack-demo',
    root: 'E:\\myProject\\webpack-demo',
    plugins: [
      [Plugin], [Plugin], [Plugin], [Plugin],
      [Plugin], [Plugin], [Plugin], [Plugin],
      [Plugin], [Plugin], [Plugin], [Plugin],
      [Plugin], [Plugin], [Plugin], [Plugin],
      [Plugin], [Plugin], [Plugin], [Plugin],
      [Plugin], [Plugin], [Plugin], [Plugin],
      [Plugin], [Plugin], [Plugin], [Plugin],
      [Plugin], [Plugin], [Plugin], [Plugin],
      [Plugin], [Plugin], [Plugin], [Plugin],
      [Plugin], [Plugin], [Plugin], [Plugin]
    ],
    presets: [],
    parserOpts: {
      sourceType: 'module',
      sourceFileName: undefined,
      plugins: [Array]
    },
    generatorOpts: {
      filename: undefined,
      auxiliaryCommentBefore: undefined,
      auxiliaryCommentAfter: undefined,
      retainLines: undefined,
      comments: true,
      shouldPrintComment: undefined,
      compact: 'auto',
      minified: undefined,
      sourceMaps: false,
      sourceRoot: undefined,
      sourceFileName: 'unknown'
    }
  },
  ast: null,
  code: '"use strict";\n' +
    '\n' +
    'var _add = _interopRequireDefault(require("./add.js"));\n' +
    '\n' +
    'var _multiple = _interopRequireDefault(require("./multiple"));\n' +
    '\n' +
    'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n' +
    '\n' +
    'console.log((0, _add["default"])(1, 2, 3) + (0, _multiple["default"])(2, 2)); // 将add函数与multiple函数相加',
  map: null,
  sourceType: 'script'
}

里面的code对应的内容就是我们要拿到的转换后的模块内容

class Compiler {
    constructor(options) {
        /**
         * 接收一个options配置项,包含entry入口,output出口,loader处理器
         */
        const { entry, output } = options
        this.entry = entry
        this.output = output
        this.modules = [] // 声明一个存放所有文件依赖对象的数组
    }

    // 分析传入的当前模块路径,返回该模块的路径、内容和依赖的对象
    build(file) {
        for (let i = 0; i < this.modules.length; i++) { // 防止循环引用,例如a.js引入了b.js,b.js又引入了a.js
            if (this.modules[i] && this.modules[i].file === file) {
                return 
            }
        }
        /**
         * 1. 读取入口文件的内容,转为ast语法树
         * 2. 通过ast语法树找到所有文件的依赖模块
         * 3. 将ast语法树转化为浏览器能够运行的ES5代码
         */
        const content = fs.readFileSync(file, 'utf-8') // 读取入口文件的内容
        const ast = parser.parse(content, {
            sourceType: 'module' // module是告诉这个方法按照ES6的语法处理(分析import export)
        })

        const deps = {} // 声明一个对象,用来存储每个模块中依赖的模块,联想到webpack打包后的源码,这里也将每个模块的路径作为key
        traverse(ast, {
            ImportDeclaration({ node }) {
                let files = path.dirname(file)
        /*
            value值做个处理,返回这个模块的完整路径,比如:moduleObj['./add.js'] = './src/add.js',分析源码的时候,__webpack_require__('./add.js')能够获取到key值为'./src/add.js属性下面的内容',说明它有某种方法能够通过'./add.js'读取到'./src/add.js',所以这里就应该对路径做处理
        **/

                deps[node.source.value] = `./${path.join(files, node.source.value)}` 
            }
        })

        // 将生成的ast语法树转为浏览器能够识别的ES5语法
        const { code } = transformFromAst(ast, null, {
            presets: ['@babel/preset-env']
        })

        return {
            file,
            code,
            deps,
        }

    }

    // traversal方法,递归去分析每个对象下的deps依赖,最终返回一个IIFE的入参对象
    traversal(){
        
    }

    // 输出处理后的内容到weppack.config.js的output指定的文件内
    outputFile() {

    }
}

修改index.js(根目录)

const Compiler = require('./lib/index.js')
const options = require('./webpack.config.js')

new Compiler(options).traversal() // 将webpack配置文件传入

traversal 递归收集模块依赖,返回入参对象

traversal方法,递归去分析每个对象下的deps依赖,最终返回一个IIFE的入参对象

traversal(){
    let info = this.build(this.entry)
    // this.modules需要存放的是所有的模块,因此还需要递归去分析每个对象下的deps依赖
    this.modules.push(info)
    for (let i = 0; i < this.modules.length; i++) {
        let dep = this.modules[i] ? this.modules[i].deps : null
        if (dep) {
            for (let key in dep) {
                if (dep.hasOwnProperty(key)) {
                    this.modules.push(this.build(dep[key])) // 递归解析每个模块里是否还包含依赖模块,添加到this.modules数组中
                }
            }
        }
    }

    const obj = {}
    this.modules.forEach(item => {
        item && item.file && (obj[item.file] = {
            code: eval(item.code), // eval作为方法执行模块里的内容
            deps: item.deps,
        })
    })
    this.outputFile(obj)
}

index.js(根目录下)改写一下:

const Compiler = require('./lib/index.js')
const options = require('./webpack.config.js')

new Compiler(options).traversal() // 将webpack配置文件传入

outputFile 输出最终的内容

输出处理后的内容到weppack.config.js的output指定的文件内

outputFile(contentObj) {
    // 为了方便我们写好这个方法,我们拿一组code的键值对

    console.log(contentObj)

    // './src/index.js': {
        //     code: '"use strict";\n' +
        //         '\n' +
        //         'var _add = _interopRequireDefault(require("./add.js"));\n' +
        //         '\n' +
        //         'var _multiple = _interopRequireDefault(require("./multiple.js"));\n' +
        //         '\n' +
        //         'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n' +
        //         '\n' +
        //         'console.log((0, _add["default"])(1, 2, 3) + (0, _multiple["default"])(2, 2)); // 将add函数与multiple函数相加',
        //         deps: {
        //         './add.js': './src\\add.js',
        //             './multiple.js': './src\\multiple.js'
        //     }
        // },

    /**
        * 声明一个字符串,这个字符串最终要写入到output指定的文件里  
        * IIFE传入生成的对象,在函数体里通过形参module拿到,先执行一次require函数,将入口文件传进去,module[moduleId]就是./src/index.js对应的对象,这个对象的code就是要执行的内容  
        * 但是可以看到eval里出现了require,浏览器并不认识这个require,因此在IIFE里,需要声明一个require函数,这样后续函数里面执行到code中的require就能认识这个require了  
        * 在require函数体内,通过eval执行模块里的内容  
        */
    let outputContent = `
        (function(module){
            function require(moduleId){
                eval(module[moduleId].code)
            }
            require('${this.entry}')
        })(${contentObj})
    `
}

问题这个时候出现了,require现在是认识了,但是目前只能执行一个入口文件的内容;
因为遇到其他文件时,比如执行./src/index.js对应的code时,执行到里面的require函数,发现了这个:

require("./add.js") 

然后调用我们声明在IIFE里声明的reuqire函数,传入,module[moduleId]就成了contentObj['./add.js']; 而contentObj里没有这个属性,只有'./src/add.js'这个属性,就需要路径转化一下;
最后发现了'./src/index.js'属性下有一个deps,那把这个'./add.js'放到deps里,就拿到了它的完整路径。

let outputContent = `
        (function(module){
            function require(moduleId){
                function localRequire(file){
                    return require(module[moduleId].deps[file])
                }
                (function(require,code){
                    eval(code)
                })(localRequire,module[moduleId].code)
            }
            require('${this.entry}')
        })(${contentObj})
    `

解释一下这段代码:
首先定义IIFE里定义了一个require,这个require方法立即执行,把入口文件'./src/index.js'传进去执行入口文件里面的代码

'./src/index.js': {
    code: '"use strict";\n' +
      '\n' +
      'var _add = _interopRequireDefault(require("./add.js"));\n' +
      '\n' +
      'var _multiple = _interopRequireDefault(require("./multiple.js"));\n' +
      '\n' +
      'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n' +
      '\n' +
      'console.log((0, _add["default"])(1, 2, 3) + (0, _multiple["default"])(2, 2)); // 将add函数与multiple函数相加',
    deps: {
      './add.js': './src/add.js',
      './multiple.js': './src/multiple.js'
    }
  },

也就是上面对象里的code,所以写成这样最初是没问题的

let outputContent = `
        (function(module){
            function require(moduleId){
                eval(module[moduleId].code)
            }
            require('${this.entry}')
        })(${contentObj})
    `

通过eval就能开始执行入口文件的代码了,然后,执行过程中遇到了这个:require("./add.js");
这是为什么要声明require函数的原因,而不是直接eval(module['${this.entry}'].code);
不声明这个require函数,到这里就gg了,而声明了require函数,首先保证的是,eval执行代码的过程中,碰到了require便不会报错;

eval执行的环境在require函数内部,碰到了require就去找这个函数。 继续往下执行,require('./add.js')又进到了reuqire函数,这时候传进来的moduleId是什么?
是'./add.js',很遗憾,在最外层的IIFE入参的对象里,并没有这个属性,只有'./src/add.js',想要执行,就得传成这个。

'./src/index.js'对应的值里有个deps: { './add.js': './src/add.js', './multiple.js': './src/multiple.js' } 把./add.js放进来就能拿到了deps['./add.js'] = './src/add.js',但是它是通过require传进来的,可require函数就这样了,那就只能在函数里面继续想办法了 首先转换一下路径,把'/add.js'转成'/src/add.js'

(function(module){
    function require(moduleId){
        function getPath(file){
            return module[moduleId].deps[file]
        }
        eval(module[moduleId].code)
    }
    require('${this.entry}')
})(${contentObj})

这样就可以拿到了,再看那段代码: require("./add.js") 把getPath函数当做参数传进下面的IIFE里

(function(module){
    function require(moduleId){
        function getPath(file){
            return module[moduleId].deps[file]
        }
        (function(require){
            eval(module[moduleId].code)
        })(getPath)
        
    }
    require('${this.entry}')
})(${contentObj})

接着执行,又遇到了require('/add.js'),实际上是执行了getPath('/add.js'),这样虽然拿到了值,但是代码进行不下去了;
因为require('/add.js')变成了一个返回值,无法继续执行require函数里面的逻辑。 此时让getPath函数返回一个require函数的调用

function getPath(file){
    return require(module[moduleId].deps[file])
}

首先执行到了require('/add.js'),实际上执行了getPath('/add.js');
这个函数返回的是require('/src/add.js'),代码得以继续执行下去,进入require函数,进行相同的操作,直到执行完毕。

这里是可能比较绕,多看几遍代码就能理解。 require解决后,还有exports

'./src\\add.js': {
    code: '"use strict";\n' +
      '\n' +
      'Object.defineProperty(exports, "__esModule", {\n' +
      '  value: true\n' +
      '});\n' +
      'exports["default"] = void 0;\n' +
      '\n' +
      'var _default = function _default(a, b, c) {\n' +
      '  return a + b + c;\n' +
      '};\n' +
      '\n' +
      'exports["default"] = _default;',
    deps: {}
  },

exports可以声明为一个空对象,增加属性,然后返回

(function(module){
    function require(moduleId){
        var exports = {}
        function getPath(file){
            return require(module[moduleId].deps[file])
        }
        (function(require){
            eval(module[moduleId].code)
        })(getPath)
        return exports
    }
    require('${this.entry}')
})(${contentObj})

继续完善代码,将生成的字符串写入到webpack.config.js的output指定的文件中

let outputContent = `
        (function(module){
            function require(moduleId){
                var exports = {}
                function getPath(file){
                    return require(module[moduleId].deps[file])
                }
                (function(require){
                    eval(module[moduleId].code)
                })(getPath)
                return exports
            }
            require('${this.entry}')
        })(${contentObj})
        `
        fs.writeFileSync(path.join(this.output.path, this.output.filename), outputContent,'utf-8')

处理一个细节

运行npm run build看下效果,发现报错:


        (function(module){
            function require(moduleId){
                var exports = {}
                function getPath(file){
                    return module[moduleId].deps[file]
                }
                (function(require){
                    eval(module[moduleId].code)
                })(getPath)
                return exports
            }
            require('./src/index.js')
        })([object Object])
        

传入的对象成了[object Object],JSON.stringify处理下

// 输出处理后的内容到weppack.config.js的output指定的文件内
outputFile(contentObj) {
    let newObj = JSON.stringify(contentObj)
    let outputContent = `
    (function(module){
        function require(moduleId){
            var exports = {}
            function getPath(file){
                return module[moduleId].deps[file]
            }
            (function(require){
                eval(module[moduleId].code)
            })(getPath)
            return exports
        }
        require('${this.entry}')
    })(${newObj})
    `
    fs.writeFileSync(path.join(this.output.path, this.output.filename), outputContent, 'utf-8')
}

我们在dist目录下新建一个html文件:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>webpack打包流程</title>
</head>
<body>
    
    <script src='./bundle.js'></script>
</body>
</html>

看下控制台打印,如果有值,那就说明我们打包成功 ./src/index.js

import add from './add.js'
import multiple from './multiple.js'

console.log(add(1, 2, 3) + multiple(2, 2)) // 这里应该打印的是10

增加loader处理

简单的流程写完了,那么再尝试加入loader处理
根目录下新建一个loader文件夹,包含两个js文件
first-laoder.js last-loader.js,分别写点处理逻辑

// first-loader.js
module.exports = source => {
    let reg = /("([^\\\"]*(\\.)?)*")|('([^\\\']*(\\.)?)*')|(\/{2,}.*?(\r|\n|$))|(\/\*(\n|.)*?\*\/)/g;
    // 网上搜一下,抄一段去除文件内容中注释的正则
    return source.replace(reg, word => /^\/{2,}/.test(word) || /^\/\*/.test(word) ? '' : word);
}

//last-loader.js
module.exports = source => {
    // 给每个文件后面加个console.log
    source += " ;console.log('我是loader加进来的console')"
    return source
}

给add.js和multiple.js添加点注释进去

// add.js

export default (a, b, c) => a + b + c // 这是add.js的注释

// multiple.js

import { init } from '../common/index.js'

export default (a, b) => init * a * b // 这是multiple.js的注释

运行npx webpack,查看打包后的文件,能找到这些注释

改写webpack.config.js

const path = require('path')

module.exports = {
    mode: 'development',
    entry: './src/index.js', // 入口文件
    output: {
        path: path.resolve(__dirname, 'dist'), // 打包后的文件存放的路径
        filename: 'bundle.js' // 打包后的文件名称
    },
    module: {
        rules: [
            {
                test: /\.js$/, // 匹配后缀为js的文件
                use: ['last-loader', 'first-loader'], // loader的匹配是从后往前执行
            }
        ]
    }
}

Compiler类的constructor里新增一行代码,拿到配置项module下面的rules

this.rules = module.rules

完成getLoaderContent函数

// 根据webpack.config.js的module项,遍历每个loader,处理文件内容,并返回loader处理后的内容
getLoaderContent(file) {
    let content = fs.readFileSync(file, 'utf-8')
    for (let i = 0; i < this.rules.length; i++) {
        // rules可能有多个loader项,所以使用循环

        // 获取test的属性值
        let test = this.rules[i].test
        // 获取use的属性值
        let use = this.rules[i].use
        let resolveCount = use.length - 1
        if (test.test(file)) { // 匹配到.js后缀的文件
            function requireLoader() {
                // 从后往前依次获取use的每一项loader路径
                let loaders = require(use[resolveCount--]); // 加载loader,并且执行
                content = loaders(content)
                if (resolveCount >= 0) {
                    requireLoader() // 递归调用,直到use中的每一项都加载完毕
                }
            }
            requireLoader()
        }
    }

    return content
}

build函数也需要改动一下

 // const content = fs.readFileSync(file, 'utf-8') // 读取入口文件的内容

const content = this.getLoaderContent(file) // 拿到loader处理后的内容

npx webpack看下打包后的bundle.js,发现每个文件对应的内容里,注释都被删掉了,并且还加了console.log
运行index.html,看下控制台打印:

Complier完整代码:


const path = require('path')
const fs = require('fs')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const { transformFromAst, transformFromAstAsync } = require('@babel/core')

class Compiler {
    constructor(options) {
        /**
         * 接收一个options配置项,包含entry入口,output出口,loader处理器
         */
        const { entry, output, module } = options
        this.entry = entry
        this.output = output
        this.rules = module.rules
        this.modules = [] // 声明一个存放所有文件依赖对象的数组
    }

    // 根据webpack.config.js的module项,遍历每个loader,处理文件内容,并返回loader处理后的内容
    getLoaderContent(file) {
        let content = fs.readFileSync(file, 'utf-8')
        for (let i = 0; i < this.rules.length; i++) {
            // rules可能有多个loader项,所以使用循环

            // 获取test的属性值
            let test = this.rules[i].test
            // 获取use的属性值
            let use = this.rules[i].use
            let resolveCount = use.length - 1
            if (test.test(file)) { // 匹配到.js后缀的文件
                function requireLoader() {
                    // 从后往前依次获取use的每一项loader路径
                    let loaders = require(use[resolveCount--]); // 加载loader,并且执行
                    content = loaders(content)
                    if (resolveCount >= 0) {
                        requireLoader() // 递归调用,直到use中的每一项都加载完毕
                    }
                }
                requireLoader()
            }
        }

        return content
    }

    // 分析传入的当前模块路径,返回该模块的路径、内容和依赖的对象
    build(file) {
        for (let i = 0; i < this.modules.length; i++) { // 防止循环引用
            if (this.modules[i] && this.modules[i].file === file) {
                return
            }
        }
        /**
         * 1. 读取入口文件的内容,转为ast语法树
         * 2. 通过ast语法树找到所有文件的依赖模块
         * 3. 将ast语法树转化为浏览器能够运行的ES5代码
         */
        const content = this.getLoaderContent(file) // 拿到loader处理后的内容
        const ast = parser.parse(content, {
            sourceType: 'module' // module是告诉这个方法按照ES6的语法处理(分析import export)
        })

        const deps = {} // 声明一个对象,用来存储每个文件中的依赖模块,联想到webpack打包后的源码,这里也将每个模块的路径作为key
        traverse(ast, {
            ImportDeclaration({ node }) {
                let files = path.dirname(file)
                deps[node.source.value] = `./${path.join(files, node.source.value)}` // value值做个处理,返回这个模块的完整路径,比如:moduleObj['./add.js'] = './src/add.js',可能有人要问为什么,一开始分析源码的时候,__webpack_require__在执行到__webpack_require__('./add.js')的时候,能够获取到key值为'./src/add.js属性下面的内容',说明它有某种方法能够通过'./add.js'读取到'./src/add.js',所以这里就应该对路径做处理
            }
        })


        // 将生成的ast语法树转为浏览器能够识别的ES5语法
        const { code } = transformFromAst(ast, null, {
            presets: ['@babel/preset-env']
        })

        return {
            file,
            code,
            deps,
        }
    }

    // traversal方法,递归去分析每个对象下的deps依赖,最终返回一个IIFE的入参对象
    traversal() {
        let info = this.build(this.entry)
        this.modules.push(info)

        for (let i = 0; i < this.modules.length; i++) {
            let dep = this.modules[i] ? this.modules[i].deps : null
            if (dep) {
                for (let key in dep) {
                    if (dep.hasOwnProperty(key)) {
                        this.modules.push(this.build(dep[key]))
                    }
                }
            }
        }

        const obj = {}
        this.modules.forEach(item => {
            item && item.file && (obj[item.file] = {
                code: item.code, // eval作为方法执行模块里的内容
                deps: item.deps,
            })
        })
        this.outputFile(obj)
    }

    // 输出处理后的内容到weppack.config.js的output指定的文件内
    outputFile(contentObj) {
        // 为了方便我们写好这个方法,我们拿一组code的键值对

        let newObj = JSON.stringify(contentObj)

        // './src/index.js': {
        //     code: '"use strict";\n' +
        //         '\n' +
        //         'var _add = _interopRequireDefault(require("./add.js"));\n' +
        //         '\n' +
        //         'var _multiple = _interopRequireDefault(require("./multiple.js"));\n' +
        //         '\n' +
        //         'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n' +
        //         '\n' +
        //         'console.log((0, _add["default"])(1, 2, 3) + (0, _multiple["default"])(2, 2)); // 将add函数与multiple函数相加',
        //         deps: {
        //         './add.js': './src\\add.js',
        //             './multiple.js': './src\\multiple.js'
        //     }
        // },

        /**
         * 声明一个字符串,这个字符串最终要写入到output指定的文件里
         * IIFE传入生成的对象,在函数体里通过形参module拿到,先执行一次require函数,将入口文件传进去,module[moduleId]就是./src/index.js对应的对象,这个对象的code就是要执行的内容
         * 但是可以看到eval里出现了require,浏览器并不认识这个require,因此在IIFE里,需要声明一个require函数,这样后续函数里面执行到code中的require就能认识这个require了
         * 在require函数体内,通过eval执行模块里的内容
         * 问题这个时候就出现了,在用eval执行代码的时候,
         */
        let outputContent = `
        (function(module){
            function require(moduleId){
                var exports = {}
                function getPath(file){
                    return require(module[moduleId].deps[file])
                }
                (function(require){
                    eval(module[moduleId].code)
                })(getPath)
                return exports
            }
            require('${this.entry}')
        })(${newObj})
        `
        fs.writeFileSync(path.join(this.output.path, this.output.filename), outputContent, 'utf-8')
    }

}

module.exports = Compiler

参考链接

《手写webpack核心原理,再也不怕面试官问我webpack原理》
《前端铁蛋儿-最新版本手写Webpack核心原理》