手写webpack核心打包功能

139 阅读4分钟

我正在参加「掘金·启航计划」
webpack最核心的功能就是模块打包,本文中我们尝试手写一下这部分分的核心逻辑

项目创建

项目的主要结构如下所示 image.png 打包配置 webpack.config.js

const path=reuqire('path')

module.exports={
    entry:'./src/index.js',
    output:{
        path:path.resolve(__dirname,"./dist"),
        filename:"main.js"
    },
    mode:"development"
}

src/index.js

import {str1} from "./a.js"
console.log(`${str1}`+'webpack')

src/a.js

import {str2} from "./b.js"
export const str1=`str1 ${str2}`

src/b.js

export const str2="b"

项目启动器

const webpack=require('./lib/webpack.js')
const options=require('./webpack.config.js')
new webpack(options).run()

需求分析

  • 创建webpack类,包含核心方法 run
  • 读取打包配置
  • 正确解析模块,包括得到模块件的相互依赖关系
  • 根据模块依赖关系输出可执行函数到指定输出目录

mini-webpack

miniwebpack 基本结构

webpack本质上是一个类,打包时会调用其中的run方法

 class webpack{
   constructor(options){
    const {entry,output}=options;
    this.entry=entry;
    this.output=output;

   }

   run(){


   }


}

module.exports=webpack

模块解析

webpack会从入口模块开始通过分析import,export等关键词来解析各个模块的依赖关系,这里我们可以通过babel工具来完成这一解析过程。

const parser = require('@babel/parser')//
class webpack{
  //...
  
  run(){
     const info=this.parse(this.entry)
     
  }
  
  parse(entryFile){
     const content = fs.readFileSync(entryFile, "utf-8");//拿到入口模块内容
     
     //用babel转化成抽象语法树
      const ast = parser.parse(content, {
         sourceType: "module"
      })
      // 将ast打印出来会发现是以下的结构
       //    Node {
      //   type: 'File',
      //   start: 0,
      //   end: 60,
      //   loc: SourceLocation {
      //     start: Position { line: 1, column: 0, index: 0 },
      //     end: Position { line: 2, column: 32, index: 60 },
      //     filename: undefined,
      //     identifierName: undefined
      //   },
      //   errors: [],
      //   program: Node {
      //     type: 'Program',
      //     start: 0,
      //     end: 60,
      //     loc: SourceLocation {
      //       start: [Position],
      //       end: [Position],
      //       filename: undefined,
      //       identifierName: undefined
      //     },
      //     sourceType: 'module',
      //     interpreter: null,
      //     body: [ [Node], [Node] ],
      //     directives: []
      //   },
      //   comments: []
      // }
      
      // console.log(ast.program.body) 将 ast.program.body打印出以后是如下的结构
      // 可以看到babel分析了入口模块,并指明了导入声明 以及依赖的文件
      // [
      //    Node {
      //      type: 'ImportDeclaration',//导入声明
      //      start: 0,
      //      end: 27,
      //      loc: SourceLocation {
      //        start: [Position],
      //        end: [Position],
      //        filename: undefined,
      //        identifierName: undefined
      //      },
      //      specifiers: [ [Node] ],
      //      source: Node {
      //        type: 'StringLiteral',
      //        start: 19,
      //        end: 27,
      //        loc: [SourceLocation],
      //        extra: [Object],
      //        value: './a.js'  //依赖的文件名
      //      }
      //    },
      //    Node {
      //      type: 'ExpressionStatement',
      //      start: 28,
      //      end: 60,
      //      loc: SourceLocation {
      //        start: [Position],
      //        end: [Position],
      //        filename: undefined,
      //        identifierName: undefined
      //      },
      //      expression: Node {
      //        type: 'CallExpression',
      //        start: 28,
      //        end: 60,
      //        loc: [SourceLocation],
      //        callee: [Node],
      //        arguments: [Array]
      //      }
      //    }
      //  ]
      
      
  }

}

接下去我们要从抽象语法树开始获取到各个模块的依赖关系,这里可以使用babel提供了另外一个工具 @babel/traverse

获取入口模块的依赖

我们的目标是获取入口模块的路径和可执行代码

//...
const traverse = require("@babel/traverse").default;
const { transformFromAst } = require("@babel/core");
class webpack {
 //...
  parse(entryFile) {
      const content = fs.readFileSync(entryFile, "utf-8");//拿到入口模块内容

      //用babel转化成抽象语法树
      const ast = parser.parse(content, {
         sourceType: "module"
      })
      
       const yilai = {};//路径保存对象
      //通过 traverse过滤抽象语法树中的导入声明
      traverse(ast, {
         ImportDeclaration({ node }) {
            // console.log(`node`, node)
            //console.log(node.source.value)  ./a.js  babel得到的路径是模块相对于入口模块的路径  但bundle 文件中的路径是相对于项目的路径 因此该路径也要保存下来
            const newPath = './' + path.join(path.dirname(entryFile), node.source.value)
            yilai[node.source.value] = newPath //{ './a.js': './src/a.js' }
         }
      })
      //上文中node打印后结果
       // Node {
      //   type: 'ImportDeclaration',
      //   start: 0,
      //   end: 27,
      //   loc: SourceLocation {
      //     start: Position { line: 1, column: 0, index: 0 },
      //     end: Position { line: 1, column: 27, index: 27 },
      //     filename: undefined,
      //     identifierName: undefined
      //   },
      //   specifiers: [
      //     Node {
      //       type: 'ImportSpecifier',
      //       start: 8,
      //       end: 12,
      //       loc: [SourceLocation],
      //       imported: [Node],
      //       local: [Node]
      //     }
      //   ],
      //   source: Node {
      //     type: 'StringLiteral',
      //     start: 19,
      //     end: 27,
      //     loc: SourceLocation {
      //       start: [Position],
      //       end: [Position],
      //       filename: undefined,
      //       identifierName: undefined
      //     },
      //     extra: { rawValue: './a.js', raw: '"./a.js"' },
      //     value: './a.js'
      //   }
      // }
      
      //可执行代码 ,这里顺便用 preset-env对代码做个转义,可以兼容 es5
      const { code } = transformFromAst(ast, null, {
         presets: ["@babel/preset-env"],
      })
      
       return {
         entryFile,
         yilai,
         code
      }  
       
       //{
          //entryFile: './src/index.js',
          //yilai: { './a.js': './src/a.js' },
          //code: '"use strict";\n' +
          //  '\n' +
          //  'var _a = require("./a.js");\n' +
          //  '\n' +
          //  `console.log("".concat(_a.str1) + 'webpack');`
       // }

  }
}


迭代遍历得到所有模块的依赖

处理完入口模块,我们可以从入口模块开始递归得到所有模块的路径,依赖以及可执行代码

class webpack {
   constructor(options) {
      const { entry, output } = options;
      this.entry = entry;
      this.output = output;
      this.modules = [] //入口模块处理后  存放所有处理好的模块
   }
   
      run() {

      const info = this.parse(this.entry) //先处理入口模块
      this.modules.push(info)

      //双层 for循环遍历
      for (let i = 0; i < this.modules.length; i++) {
         const item = this.modules[i];
         const { yilai } = item;
         if (yilai) {
            for (let key in yilai) {
               this.modules.push(this.parse(yilai[key])) //
            }
         }
      }

      console.log(this.modules)
      //[
      //{
        //entryFile: './src/index.js',
        //yilai: { './a.js': './src/a.js' },
        //code: '"use strict";\n' +
        //  '\n' +
        //  'var _a = require("./a.js");\n' +
        //  '\n' +
        //  `console.log("".concat(_a.str1) + 'webpack');`
      //},
      //{
        //entryFile: './src/a.js',
        //yilai: { './b.js': './src/b.js' },
        //code: '"use strict";\n' +
        //  '\n' +
        //  'Object.defineProperty(exports, "__esModule", {\n' +
        //  '  value: true\n' +
        //  '});\n' +
        //  'exports.str1 = void 0;\n' +
        //  '\n' +
        //  'var _b = require("./b.js");\n' +
        //  '\n' +
        //  'var str1 = "str1 ".concat(_b.str2);\n' +
        //  'exports.str1 = str1;'
      //},
      //{
        //entryFile: './src/b.js',
        //yilai: {},
        //code: '"use strict";\n' +
        //  '\n' +
        //  'Object.defineProperty(exports, "__esModule", {\n' +
        //  '  value: true\n' +
        //  '});\n' +
        //  'exports.str2 = void 0;\n' +
        //  'var str2 = "b";\n' +
        //  'exports.str2 = str2;'
      //}
    //]
   }
   
 }

生成启动器函数

我们知道模块最终打包生成的是一个自执行函数,因此当我们获取了所有模块的关系以及其可执行代码后,就能生成启动器函数

模块数组转化

之前我们将各模块存放在数组中,为了索引方便 require(${path}),之后最为关键的函数 require是依靠路径来查找模块,因此这里也可以将模块数组转化为以路径为key值的对象

//...
class webpack {
  constructor(){
    //...
  }
  run(){
  
  //..迭代获取所有依赖
    //转化成对象
      const obj = {}
      this.modules.forEach(module => {
         obj[module.entryFile] = {
            yilai: module.yilai,
            code: module.code
         }
      })
      // console.log(obj)
      //{
        //  './src/index.js': {
        //    yilai: { './a.js': './src/a.js' },
        //    code: '"use strict";\n' +
        //      '\n' +
        //      'var _a = require("./a.js");\n' +
        //      '\n' +
        //      `console.log("".concat(_a.str1) + 'webpack');`
        //  },
        //  './src/a.js': {
        //    yilai: { './b.js': './src/b.js' },
        //    code: '"use strict";\n' +
        //      '\n' +
        //      'Object.defineProperty(exports, "__esModule", {\n' +
        //      '  value: true\n' +
        //      '});\n' +
        //      'exports.str1 = void 0;\n' +
        //      '\n' +
        //      'var _b = require("./b.js");\n' +
        //      '\n' +
        //      'var str1 = "str1 ".concat(_b.str2);\n' +
        //      'exports.str1 = str1;'
        //  },
        //  './src/b.js': {
        //    yilai: {},
        //    code: '"use strict";\n' +
        //      '\n' +
        //      'Object.defineProperty(exports, "__esModule", {\n' +
        //      '  value: true\n' +
        //      '});\n' +
        //      'exports.str2 = void 0;\n' +
        //      'var str2 = "b";\n' +
        //      'exports.str2 = str2;'
        //  }
        //}
      //webpack 启动器函数
      this.file(obj)
  
  
  }

}

启动器函数

我们的目标是从入口模块的可执行函数开始依次开始执行所有被依赖模块的可执行代码。并将这最后生成的函数,即bundle文件输出到指定目录中。

//入口模块可执行代码
         '"use strict";\n' +
              '\n' +
              'var _a = require("./a.js");\n' +
              '\n' +
              `console.log("".concat(_a.str1) + 'webpack');`

上述这段代码如果放到浏览其中执行,必然会报错,因为浏览器并不知道 require是什么,因此需要我们构造出 require 函数,接受一个模块路径,取出其code 并执行,当然expots也需要补上。

class webpack {
  constructor(){
     //...
  }
  run(){
    //...
  }
  parse(entryFile){
    //...
  }
  
   file(code) {
      //生成main.js 到 dist 目录
      //生成 bundle内容
      const filePath = path.join(this.output.path, this.output.filename)
      const newCode = JSON.stringify(code) //字符串序列化后执行
      //缺失 require exports
      const bundle = `(function(modules){
        function require(module){
         const exports={}
          (function(code){
            eval(code)
          })(modules[module].code)
          return exports;
        }
        require('${this.entry}')
     })(${newCode})`

    
      fs.writeFileSync(filePath, bundle, "utf-8")
   }
}

上述代码利用闭包将依赖对象和模块路径传入require函数中,这样从入口模块开始,能取出对应的code,执行后将exports返回,然后 在eval(code)中,遇到require调用,又可以递归执行require函数。

但是这段代码真执行的话并不会达到我们的预期,我们观察一下 code 中 require() 传入的是模块的相对路径,而我们保存的对象索引是相对于当前根目录的路径,上文中 modules[module].code module传入的是相对路径,code 没法被正确找到,因此对于传入的require函数需要做进一步的修正,让其可以通过相对路径也能找到对应模块的code(这也就是之前 yilai 中要保存两者关系的原因 yilai: { './a.js': './src/a.js' })

//入口模块可执行代码
         '"use strict";\n' +
              '\n' +
              'var _a = require("./a.js");\n' +  //这里是相对于该模块的路径
              '\n' +
              `console.log("".concat(_a.str1) + 'webpack');`
              
              
//我们保存的obj 中 每一项都是相对于根目录的路径
  //  './src/index.js': {
        //    yilai: { './a.js': './src/a.js' },//但是两者的关系已被保存
        //    code: '"use strict";\n' +
        //      '\n' +
        //      'var _a = require("./a.js");\n' +
        //      '\n' +
        //      `console.log("".concat(_a.str1) + 'webpack');`
        //  },

修正后的file函数

//...
   file(code) {
      //生成main.js 到 dist 目录
      //生成 bundle内容
      const filePath = path.join(this.output.path, this.output.filename)
      const newCode = JSON.stringify(code)
      //缺失 require exports
      const bundle = `(function(modules){
        function require(module){
         const exports={}
          function pathRequire(relativePath){
            return require(modules[module].yilai[relativePath])
          }
          (function(require,code){
            eval(code)
          })(pathRequire,modules[module].code)
         
          return exports;
        }
        require('${this.entry}')
     })(${newCode})`
      fs.writeFileSync(filePath, bundle, "utf-8")
   }
//...

这里定义了一个pathRequire来改写require函数,让其可以接受相对路径找到正确模块,并且通过闭包的方式传入,这样在 执行 eval(code) 时,实际执行的 require函数已经是经过改写的新函数了

生成结果

(function(modules){
        function require(module){
         const exports={}
          function pathRequire(relativePath){
            return require(modules[module].yilai[relativePath])
          }
          (function(require,code){
            eval(code)
          })(pathRequire,modules[module].code)
         
          
          return exports;
        }
        require('./src/index.js')
     })({"./src/index.js":{"yilai":{"./a.js":"./src/a.js"},"code":"\"use strict\";\n\nvar _a = require(\"./a.js\");\n\nconsole.log(\"\".concat(_a.str1) + 'webpack');"},"./src/a.js":{"yilai":{"./b.js":"./src/b.js"},"code":"\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n  value: true\n});\nexports.str1 = void 0;\n\nvar _b = require(\"./b.js\");\n\nvar str1 = \"str1 \".concat(_b.str2);\nexports.str1 = str1;"},"./src/b.js":{"yilai":{},"code":"\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n  value: true\n});\nexports.str2 = void 0;\nvar str2 = \"b\";\nexports.str2 = str2;"}})

image.png

#完整代码 最后附上完成代码



const fs = require('fs');
const parser = require('@babel/parser')
const traverse = require("@babel/traverse").default;
const path = require('path')
const { transformFromAst } = require("@babel/core")
class webpack {
   constructor(options) {
      const { entry, output } = options;
      this.entry = entry;
      this.output = output;
      this.modules = [] //入口模块处理后  存放所有处理好的模块

   }

   run() {

      const info = this.parse(this.entry) //先处理入口模块
      console.log(info)
      this.modules.push(info)

      //双层 for循环遍历
      for (let i = 0; i < this.modules.length; i++) {
         const item = this.modules[i];
         const { yilai } = item;
         if (yilai) {
            for (let key in yilai) {
               this.modules.push(this.parse(yilai[key])) 
            }
         }
      }


      //转化成对象
      const obj = {}
      this.modules.forEach(module => {
         obj[module.entryFile] = {
            yilai: module.yilai,
            code: module.code
         }
      })

      //webpack 启动器函数
      this.file(obj)
   }

   file(code) {
      //生成main.js 到 dist 目录
      //生成 bundle内容
      const filePath = path.join(this.output.path, this.output.filename)
      const newCode = JSON.stringify(code)
      //缺失 require exports
      const bundle = `(function(modules){
        function require(module){
         const exports={}
          function pathRequire(relativePath){
            return require(modules[module].yilai[relativePath])
          }
          (function(require,code){
            eval(code)
          })(pathRequire,modules[module].code)
         
          
          return exports;
        }
        require('${this.entry}')
     })(${newCode})`

      //require("./b.js") ./src/b.js 拿不到对应的code 路径要做一个转化
      fs.writeFileSync(filePath, bundle, "utf-8")
   }

   parse(entryFile) {

      const content = fs.readFileSync(entryFile, "utf-8");//拿到入口模块内容
      //对模块化语句作处理

      //用babel转化成抽象语法树
      const ast = parser.parse(content, {
         sourceType: "module"
      })

      const { code } = transformFromAst(ast, null, {
         presets: ["@babel/preset-env"],
      })

  


      //babel traverse //过滤// ImportDeclaration
      const yilai = {};//路径保存

      traverse(ast, {
         ImportDeclaration({ node }) {
            const newPath = './' + path.join(path.dirname(entryFile), node.source.value)
              console.log(newPath,entryFile)
            yilai[node.source.value] = newPath 
         }
      })



      return {
         entryFile,
         yilai,
         code
      }


   }


}

module.exports = webpack