webpack是如何进行依赖图谱收集的?

741 阅读5分钟

本文正在参加「金石计划 . 瓜分6万现金大奖」

我自己学习webpack已有很长时间了,但是经常会遇到这样的问题: 可以熟练配置webpack的一些常用配置,但是对一些不常见的api或者概念总是云里雾里。因此,对着网上资料手写了一个简易版的webpack,现在对其中的依赖图谱收集部分进行梳理,搞清楚webpack是如何进行打包的。

打包流程

image.png

获取入口文件

根据 webpack.config.js配置文件 的 entry 属性获取入口文件的绝对路径

getEntry() {
  let entry = Object.create(null)
  const { entry: optionsEntry } = this.options
  if (typeof optionsEntry === 'string') {
    entry['main'] = optionsEntry
  } else {
    entry = optionsEntry
  }
  // 将entry变为绝对路径
  Object.keys(entry).forEach(key => {
    const value = entry[key]
    if (!path.isAbsolute(value)) {
      // 转化为绝对路径的同时统一路径分隔符为 /
      entry[key] = toUnixPath(path.join(this.rootPath, value))
    }
  })
  return entry
}

编译入口文件

编译入口文件主要是要分析入口文件依赖了哪些模块,然后继续递归编译依赖的模块。

buildEntryModule(entry) {
  Object.keys(entry).forEach(entryName => {
    const entryPath = entry[entryName]
    // 编译入口文件
    const entryObj = this.buildModule(entryName, entryPath)
    // 每个文件都是一个模块对象
    this.entries.add(entryObj)
  })
}

buildModule(moduleName, modulePath) {
  // 1. 读取文件原始代码
  const originSourceCode = (this.originSourceCode = fs.readFileSync(
    modulePath,
    'utf-8'
  ))
  // moduleCode为修改后的代码,现在赋初始值
  this.moduleCode = originSourceCode
  // 2. 调用loader进行处理
  this.handleLoader(modulePath)
  // 3. 调用webpack进行模块编译,获得最终的module对象
  const module = this.handleWebpackCompiler(moduleName, modulePath)
  // 4. 返回module
  return module
}

编译的工作主要在handleWebpackCompiler函数中:

handleWebpackCompiler(moduleName, modulePath) {
  // 将当前模块路径相对于项目启动根目录计算出相对路径 作为模块ID
  const moduleId = './' + path.posix.relative(this.rootPath, modulePath)
  // 创建模块对象,初次进来就是入口模块
  const module = {
    id: moduleId,
    dependencies: new Set(), // 该模块所依赖模块相对于根路径的路径地址
    name: [moduleName] // 该模块所属的入口文件
  }
  // 调用babel分析我们的代码
  const ast = parser.parse(this.moduleCode, {
    sourceType: 'module'
  })
  // 深度优先,遍历语法AST
  traverse(ast, {
    // 当遇到require语句时
    CallExpression: nodePath => {
      const node = nodePath.node
      if (node.callee.name === 'require') {
        // 获得源代码中引入模块相对路径
        const requirePath = node.arguments[0].value
        // 寻找模块绝对路径
        // modulePath是当前文件的绝对路径,这一步是为获得当前文件的目录路径
        const moduleDirName = path.posix.dirname(modulePath)
        // 根据extensions获取依赖的绝对路径
        const absolutePath = tryExtensions(
          path.posix.join(moduleDirName, requirePath),
          this.options.resolve.extensions,
          requirePath,
          moduleDirName
        )
        // 生成moduleId - 相对于根路径的模块ID 添加进入新的依赖模块路径
        const moduleId =
          './' + path.posix.relative(this.rootPath, absolutePath)
          
        // 通过babel修改源代码中的require变成__webpack_require__语句
        node.callee = t.identifier('__webpack_require__')
        // 修改源代码中require语句引入的模块 全部修改变为相对于根路径的相对路径来处理
        node.arguments = [t.stringLiteral(moduleId)]
      
        // 为当前模块添加require语句造成的依赖(内容为相对于根路径的模块ID)
        module.dependencies.add(moduleId)
      }
    }
  })
  // 遍历结束根据AST生成新的代码
  const { code } = generator(ast)
  // 为当前模块挂载新的生成的代码
  module._source = code
  
  // 递归依赖深度遍历 存在依赖模块则加入modules中
  module.dependencies.forEach(dependency => {
    const depModule = this.buildModule(moduleName, dependency)
    // 将编译后的任何依赖模块对象加入到modules对象中去
    this.modules.add(depModule)
  })
  // 返回当前模块对象
  return module
}

创建module对象

每一个文件都是一个模块对象:

const module = {
   id: moduleId, // 该模块的相对路径
   dependencies: new Set(), // 该模块依赖了那些模块
   name: [] // 该模块属于哪个入口文件
}

编译模块

  1. 利用babel把入口文件代码转化为AST抽象语法树,找出模块中的require语句,并把require替换为__webpack_require__,因为我们自己会实现一个__webpack_require__方法,所以在开发过程中就可以使用require而不会报错。

  2. 找到依赖的模块的相对路径,通过module.dependencies.add(moduleId)加入到父模块的dependencies, 这样就构成了一个依赖关系。

  3. 对依赖模块递归编译,找出依赖的依赖,这样就构成了依赖图谱的收集。

  4. this.entries用来存储多入口文件,比如:

entry: {
  main: path.resolve(__dirname, './src/entry1.js'),
  second: path.resolve(__dirname, './src/entry2.js')
}
  1. this.modules用来收集整个项目的依赖模块,除了入口文件模块,每个模块里面都有一个name属性,它的值用来表示那个入口文件引用了该模块。比如:

现在有两个入口文件都引用了一个模块:

// 入口文件1
const depModule = require('./module')

// 入口文件2
const depModule = require('./module')

// 模块文件
const name = '19Qingfeng'
module.exports = {
  name
}

this.modules打印如下:

{
    id: './example/src/module.js',
    dependencies: Set(1) { './example/src/module1.js' },
    name: [ 'main', 'second' ],
    _source: '
      "const name = '19Qingfeng';\n" +
      'module.exports = {\n' +
      '  name,\n' +
      '};\n' +
      "const loader2 = '19Qingfeng';\n" +
      "const loader1 = 'https://github.com/19Qingfeng';"
  }

可以发现这个模块的name属性包含了['main', 'second'],即两个入口文件名。

生成chunks

buildEntryModule(entry) {
  Object.keys(entry).forEach(entryName => {
    const entryPath = entry[entryName]
    const entryObj = this.buildModule(entryName, entryPath)
    this.entries.add(entryObj)
    // 根据当前入口文件和模块的相互依赖关系,组装成为一个个包含当前入口所有依赖模块的chunk
    this.buildUpChunk(entryName, entryObj)
  })
}

buildUpChunk(entryName, entryObj) {
  const chunk = {
    // 每一个入口文件作为一个chunk
    name: entryName,
    // entry编译后的对象
    entryModule: entryObj,
    // 寻找与当前entry有关的所有module
    modules: Array.from(this.modules).filter(i => i.name.includes(entryName))
  }
  // 将chunk添加到this.chunks中去
  this.chunks.add(chunk)
}

buildUpChunk的主要作用是根据入口文件名称,从this.modules把依赖于入口文件的依赖全部挑选出来,组成一个chunks

所以,我们知道了什么是chunks,即一个入口文件对应一个chunks。后面会将代码分割,分割出来的也是chunks

生成最终代码assets

this.chunks.forEach(chunk => {
  // 把webpack.config.js配置中的占位符替换为定义的文件名
  const parseFileName = output.filename.replace('[name]', chunk.name)
  // assets中 { 'main.js': '生成的字符串代码...' }
  this.assets[parseFileName] = getSourceCode(chunk)
})


/**
 * 把入口文件和起所依赖的文件拼接到一起
 * @param {*} chunk
 * name 属性入口文件名称
 * entryModule 入口文件module对象
 * modules 依赖模块对象
 */
function getSourceCode(chunk) {
  const { name, entryModule, modules } = chunk
  return `
  (() => {
    var __webpack_modules__ = {
      ${modules
        .map(module => {
          return `
          '${module.id}': (module) => {
            ${module._source}
      }
        `
        })
        .join(',')}
    };
    // The module cache
    var __webpack_module_cache__ = {};

    function __webpack_require__(moduleId) {

      var cachedModule = __webpack_module_cache__[moduleId];
      if (cachedModule !== undefined) {
        return cachedModule.exports;
      }

      var module = (__webpack_module_cache__[moduleId] = {
        exports: {},
      });

      __webpack_modules__[moduleId](module, module.exports, __webpack_require__);

      return module.exports;
    }

    var __webpack_exports__ = {};
 
    (() => {
      ${entryModule._source}
    })();
  })();
  `
}

可以发现,我们定义了一个__webpack_require__方法,它用来替换我们在代码中写的require 或者 import。同时,定义了一个__webpack_modules__变量用来存放该入口文件依赖的所有模块。

输出打包文件

Object.keys(this.assets).forEach(fileName => {
   const filePath = path.join(output.path, fileName)
   fs.writeFileSync(filePath, this.assets[fileName])
})

通过fs.writeFileSync把最终的代码输出到具体文件中。这样就完成了整个打包过程。

总结

强烈推荐风佬的这篇文章Webapck5核心打包原理全流程解析,本文就是对风佬文章的简写。

image.png