Skip to content

webpack 源码系列之 bundler 实现 #24

@noneven

Description

@noneven

webpack bundler 实现

前言

Q: 为什么我们要做这件事?
A: 太多小伙伴对 webpack 的认识都在 webpack.config.js 上,对 webpack 的使用也都是黑盒的,对其打包、loader、plugin等实现原理知之甚少

Q: webpack 现在如此庞大,应该如何着手?
A: 我们可以从 webpack 的第一个提交版本着手研究,它主要实现了 bundler和 loader,代码量很小,并且可读性很高 webpack 的第一个 commit

bundler 主要功能

  • 将多个符合 CommonJS 规范的模块打包成一个 JS 文件,使其可以运行在浏览器中。
  • 显然,浏览器没法直接执行 CommonJS 规范的模块,怎么办呢?我们可以将 module, module.exports, require 等函数在运行模块前定义好,各个模块分别调用执行逻辑
  • 一个打包后的 bundle.js 如下
/******/(function(modules) {
/******/	var installedModules = {};
/******/	function require(moduleId) {
/******/		if(installedModules[moduleId])
/******/			return installedModules[moduleId].exports;
/******/		var module = installedModules[moduleId] = {
/******/			exports: {}
/******/		};
/******/		modules[moduleId](module, module.exports, require);
/******/		return module.exports;
/******/	}
/******/	return require(0);
/******/})
/******/({
/******/0: function(module, exports, require) {

var a = require(/* ./a.js */1);
var b = require(/* ./b.js */2);
var luna = require(/* @alipay/luna-core */3);
a();
b();


/******/},
/******/
/******/1: function(module, exports, require) {

// module a

module.exports = function () {
    console.log('a')
};

/******/},
/******/
/******/2: function(module, exports, require) {

// module b

module.exports = function () {
    console.log('b')
};

/******/},
/******/
/******/3: function(module, exports, require) {

module.exports = {
    // ...
};

/******/},
/******/
/******/})

bundler 实现思路

分析 bundle.js,我们能够发现:

  • 1、不管有多少个模块,头部那一块都是一样的,它实现了 commonJS 的 module、module.exports、require 等函数,所以可以写成一个模板,也就是 webpack 里面的 templateSingle.js

  • 2、需要分析出各个模块间的依赖关系。也就是说,bundler 需要知道 example 依赖于 a、b 和 模块 luna。

  • 3、luna 模块位于 node_modules 文件夹当中,但是我们调用的时候却可以直接 require('@alipay/luna-core'),所以 bundler 肯定是存在某种自动查找的功能。

  • 4、在生成的 bundle.js 中,每个模块的唯一标识是模块的 ID,所以在拼接bundle.js 的时候,需要将每个模块的名字替换成模块的 ID

    // 转换前
    var a = require('./a.js');
    var b = require('./b.js');
    var luna = require('@alipay/luna-core');
    
    // 转换后
    var a = require(/* ./a.js */1);
    var b = require(/* ./b.js */2);
    var luna = require(/* @alipay/luna-core */3);
    

下面我们逐一分析一下上面的 4 各部分

1、头部模板

  • templateSingle

Q: 为什么叫 templateSingle?
A: 因为 webpack 在打包其他比如代码切割等时,头部模板会不一样,这儿为了区分,就叫 templateSingle 了,算是将所有的模块都打包到一个 JS 文件里面

// templateSingle
/******/(function(modules) {
/******/	var installedModules = {};
/******/	function require(moduleId) {
/******/		if(installedModules[moduleId])
/******/			return installedModules[moduleId].exports;
/******/		var module = installedModules[moduleId] = {
/******/			exports: {}
/******/		};
/******/		modules[moduleId](module, module.exports, require);
/******/		return module.exports;
/******/	}
/******/	return require(0);
/******/})

2、分析模块依赖

CommonJS 不同于 AMD,不会在模板定义时将所有依赖的声明。CommonJS 最显著的特征就是用到的时候再 require,所以我们得在整个文件的范围内查找依赖了哪些模块。

Q: 怎么在整个文件里面查找出依赖?
A: 正则匹配?

Q: 如果 require 是写在注释里面了怎么办?性能如何?
A: 正则行不通,我们可以采用 babel 等语言编译器的原理,将 JS 代码解析转换成 抽象语法树(AST),再对 AST 进行遍历,找到所有的 require 依赖。

Q: 如果模块 a 依赖 b,b 又依赖 c,然后 c 又依赖 d 这又怎么办?
A: 当解析 a 模块时,如果模块 a 中又 require 了其他模块,那么将继续解析依赖的模块。也就是说,总体上遵循深度优先遍历

  • 解析依赖具体实现:(详见 parse.js
/**
 * @file 解析模块依赖
 * @author chunk.cj
 */

const esprima = require('esprima');

/**
 * 解析模块包含的依赖
 * @param {string} source 模块内容字符串
 * @returns {object} module 解析模块得出的依赖关系
 */
module.exports = source => {
  const ast = esprima.parse(source, {
    range: true
  });
  const module = {};
  walkStatements(module, ast.body);
  module.source = source;
  return module;
};

/**
 * 遍历块中的语句
 * @param {object} module 模块对象
 * @param {object} statements AST语法树
 */
function walkStatements(module, statements) {
  statements.forEach(statement => walkStatement(module, statement));
}

/**
 * 分析每一条语句
 * @param {object} module 模块对象
 * @param {object} statement AST语法树
 */
function walkStatement(module, statement) {
  switch (statement.type) {
  case 'VariableDeclaration':
    if (statement.declarations) {
      walkVariableDeclarators(module, statement.declarations);
    }
    break;
  }
}

/**
 * 处理定义变量的语句
 * @param {object} module 模块对象
 * @param {object} declarators
 */
function walkVariableDeclarators(module, declarators) {
  declarators.forEach(declarator => {
    switch (declarator.type) {
    case 'VariableDeclarator':
      if (declarator.init) {
        walkExpression(module, declarator.init);
      }
      break;
    }
  });
}

/**
 * 处理表达式
 * @param {object} module  模块对象
 * @param {object} expression 表达式
 */
function walkExpression(module, expression) {
  switch (expression.type) {
  case 'CallExpression':
    // 处理普通的require
    if (expression.callee && expression.callee.name === 'require' && expression.callee.type === 'Identifier' && expression.arguments && expression.arguments.length === 1) {
      // TODO 此处还需处理require的计算参数
      module.requires = module.requires || [];
      const param = Array.from(expression.arguments)[0];
      module.requires.push({
        name: param.value,
        nameRange: param.range
      })
    }
    break;
  }
}

3、深度优先遍历构建依赖树:(详见 buildDeep.js

const fs = require('fs');
const path = require('path');
const parse = require('./parse');
const resolve = require('./resolve');

module.exports = async(mainModule, options) => {

  let depTree = {
    // 递增模块 id
    nextModuleId: 0,
    // 用于存储各个模块对象
    modules: {},
    // 用于映射模块名到模块 id 之间的关系
    mapModuleNameToId: {},
  };

  depTree = await parseModule(depTree, mainModule, options.context, options);
  return depTree;
};

const parseModule = async(depTree, moduleName, context, options) => {
  // 查找模块
  const absoluteFileName = resolve(moduleName, context, options.resolve);
  // 用模块的绝对路径作为模块的键值,保证唯一性
  module = depTree.modules[absoluteFileName] = {
    id: depTree.nextModuleId++,
    filename: absoluteFileName,
    name: moduleName
  };

  if (!absoluteFileName) {
    throw `找不到文件${absoluteFileName}`;
  }
  const source = fs.readFileSync(absoluteFileName).toString();
  const parsedModule = parse(source);

  module.requires = parsedModule.requires || [];
  module.source = parsedModule.source;

  // 写入映射关系
  depTree.mapModuleNameToId[moduleName] = depTree.nextModuleId - 1;

  // 如果此模块有依赖的模块,采取深度遍历的原则,遍历解析其依赖的模块
  const requireModules = parsedModule.requires;
  if (requireModules && requireModules.length > 0) {
    for (let require of requireModules) {
      depTree = await parseModule(depTree, require.name, path.dirname(absoluteFileName), options);
    }
  }
  return depTree;
}

4、模块寻址:(详见 resolve.js

简单的寻址方法

  • 如果给出的是绝对路径/相对路径,只查找一次。找到?返回绝对路径。找不到?返回 false。
  • 如果给出的是模块的名字,先在入口 js(example.js)文件所在目录下寻找同名JS文件(可省略扩展名)。找到?返回绝对路径。找不到?走第3步。
  • 在入口js(example.js)同级的 node_modules 文件夹(如果存在的话)查找。找到?返回绝对路径。找不到?返回 false。

这儿可以再考虑实现逐层往上查找 node_modules,可以参考 nodejs 默认的模块查找算法

/**
 * 查找模块所在绝对路径
 * @author chunk.cj
 */

const fs = require('fs');
const path = require('path');

// 判断给出的文件是否存在
const isFile = path => {
  try {
    const stats = fs.statSync(path);
    return stats && stats.isFile()
  } catch(e) {
    return false;
  }
};
const isDir = path => {
  try {
    const stats = fs.statSync(path);
    return stats && stats.isDirectory()
  } catch(e) {
    return false;
  }
};

/**
 * 根据模块的标志查找到模块的绝对路径
 * @param {string} moduleIdentifier 模块的标志,可能是模块名/相对路径/绝对路径
 * @param {string} context 上下文,入口 js 所在目录
 * @returns {string} 返回模块绝对路径
 */
module.exports = (moduleIdentifier, context, options) => {
  // 模块是绝对路径,只查找一次
  if (path.isAbsolute(moduleIdentifier)) {
    if (!path.extname(moduleIdentifier)) {
      moduleIdentifier += '.js';
    }
    if (isFile(moduleIdentifier)) {
      return moduleIdentifier;
    };
  } else if (moduleIdentifier.startsWith('./') || moduleIdentifier.startsWith('../')) {
    if (!path.extname(moduleIdentifier)) {
      moduleIdentifier += '.js';
    }
    moduleIdentifier = path.resolve(context, moduleIdentifier);
    if (isFile(moduleIdentifier)) {
      return moduleIdentifier;
    };
  } else {
    // 如果上述的方式都找不到,那么尝试在当前目录的 node_modules 里面找
    
    // 1、直接是node_modules文件夹下的文件
    if (isFile(path.resolve(context, './node_modules', moduleIdentifier))) {
      return moduleIdentifier;
    };
    if (isFile(path.resolve(context, './node_modules', `${moduleIdentifier}.js`))) {
      return `${moduleIdentifier}.js`;
    }

    // 2、node_modules 文件夹下的文件夹
    if (isDir(path.resolve(context, './node_modules', moduleIdentifier))) {
      var pkg = fs.readFileSync(path.resolve(context, './node_modules', moduleIdentifier, 'package.json'));
      var pkgJSON = JSON.parse(pkg);
      var main = path.resolve(context, './node_modules', moduleIdentifier, pkgJSON.main);
      if (isFile(main)) {
        return main;
      }
    } else {
      // 逐层往根目录查找
      const dirList = context.split('/');
      dirList.shift();
      while (dirList.length > 0) {
        dirList.pop();
        const dir = `/${dirList.join('/')}`;
        const moduleDir = path.resolve(dir, './node_modules', moduleIdentifier);

        if (isFile(moduleDir)) {
          return moduleDir;
        };
        if (isFile(`${moduleDir}.js`)) {
          return `${moduleDir}.js`;
        }
        if (isDir(moduleDir)) {
          // 解析 package.json 的 main 字段获取入口
          const pkg = fs.readFileSync(path.resolve(moduleDir, 'package.json'), 'utf-8');
          const pkgJSON = JSON.parse(pkg);
          const main = path.resolve(moduleDir, pkgJSON.main);
          if (isFile(main)) {
            return main;
          }
        }
      }
    }

    return moduleIdentifier;
  }
};

拼接 bundle:(详见 webpack.js

生成的 deepTree 如下:

{
  "nextModuleId": 5,
  "modules": {
    "/Users/chunk/Alipay/github/webpack-bundler/example/bundle/entry.js": {
      "id": 0,
      "filename": "/Users/chunk/Alipay/github/webpack-bundler/example/bundle/entry.js",
      "name": "/Users/chunk/Alipay/github/webpack-bundler/example/bundle/entry.js",
      "requires": [
        {
          "name": "./a.js",
          "nameRange": [
            16,
            24
          ]
        },
        {
          "name": "./b.js",
          "nameRange": [
            43,
            51
          ]
        }
      ],
      "source": "var a = require('./a.js');\nvar b = require('./b.js');\n\na();\nb();"
    },
    "/Users/chunk/Alipay/github/webpack-bundler/example/bundle/a.js": {
      "id": 1,
      "filename": "/Users/chunk/Alipay/github/webpack-bundler/example/bundle/a.js",
      "name": "./a.js",
      "requires": [
        {
          "name": "./c.js",
          "nameRange": [
            16,
            24
          ]
        }
      ],
      "source": "var c = require('./c.js');\n\nmodule.exports = function() {\n  console.log('a');\n};"
    },
    "/Users/chunk/Alipay/github/webpack-bundler/example/bundle/c.js": {
      "id": 4,
      "filename": "/Users/chunk/Alipay/github/webpack-bundler/example/bundle/c.js",
      "name": "./c.js",
      "requires": [],
      "source": "module.exports = function() {\n  console.log('c');\n};"
    },
    "/Users/chunk/Alipay/github/webpack-bundler/example/bundle/b.js": {
      "id": 3,
      "filename": "/Users/chunk/Alipay/github/webpack-bundler/example/bundle/b.js",
      "name": "./b.js",
      "requires": [
        {
          "name": "./c.js",
          "nameRange": [
            16,
            24
          ]
        }
      ],
      "source": "var c = require('./c.js');\n\nmodule.exports = function() {\n  console.log('b');\n  c();\n};"
    }
  },
  "mapModuleNameToId": {
    "/Users/chunk/Alipay/github/webpack-bundler/example/bundle/entry.js": 0,
    "./a.js": 1,
    "./c.js": 4,
    "./b.js": 3
  }
}

循环遍历 modules,拼接 module 模块的 source,拼接结构如下:

/******/(function(modules) {
/******/  var installedModules = {};
/******/  function require(moduleId) {
/******/    if(installedModules[moduleId])
/******/      return installedModules[moduleId].exports;
/******/    var module = installedModules[moduleId] = {
/******/      exports: {}
/******/    };
/******/    modules[moduleId](module, module.exports, require);
/******/    return module.exports;
/******/  }
/******/  return require(0);
/******/})

// 上面是 templateSingle

({

// 入口
0: function(module, exports, require) {
  // 模块 source
},

// 模块
1: function(module, exports, require) {
  // 模块 source
},

// ...

// 结尾
});

具体实现如下:

const buffer = [];
const modules = deepTree.modules;
for(let moduleName in modules) {
  const module = modules[moduleName];
    
  buffer.push("/******/");
  buffer.push(module.id);
  buffer.push(": function(module, exports, require) {\n\n");

  buffer.push(writeSource(module, deepTree));
  buffer.push("\n\n/******/},\n/******/\n");
}

return buffer.join("");

我们发现模块 source 里面的模块名还需要替换成模块 id。具体实现如下:

module.exports = function(module, deepTree) {
  const source = module.source;
  if (!module.requires || !module.requires.length) {
    return source.split('\n').map(line => `  ${line}\n`).join('');
  }

  const replaces = [];
  module.requires.forEach(requireItem => {
    if(requireItem.nameRange && requireItem.name) {
      const prefix = `/* ${requireItem.name} */`;
      replaces.push({
        from: requireItem.nameRange[0],
        to: requireItem.nameRange[1],
        value: prefix + deepTree.mapModuleNameToId[requireItem.name]
      });
    }
  });

  const result = [source];
  //  模块替换算法: https://github.com/coderwin/__/issues/20
  replaces.sort((a, b) => b.from - a.from).forEach(replace => {
    const remSource = result.shift();
    result.unshift(
      remSource.substr(0, replace.from),
      replace.value,
      remSource.substr(replace.to)
    );
  });

  // 给每行加上两个空格
  return result.join('').split('\n').map(line => `  ${line}\n`).join('');
};

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions