1. 代码结构以及相关依赖

webpack 的核心功能就是模块打包,从入口文件开始分析依赖关系,将相关的模块打包在一起,
本文实现了一个简易的打包器

本文对应的代码仓库 simple-bundler

1.1 文件结构

入口文件index.js分别引入了模块a.jsb.js,其中a, b两个模块之间又互相引用

  1. // index.js
  2. import a from './a.js'
  3. import b from './b.js'
  4. console.log(a.getB())
  5. console.log(b.getA())
  6. // a.js
  7. import b from './b.js'
  8. const a = {
  9. value: 'a',
  10. getB: () => b.value + ' from a.js'
  11. }
  12. export default a
  13. // b.js
  14. import a from './a.js'
  15. const b = {
  16. value: 'b',
  17. getA: () => a.value + ' from b.js'
  18. }
  19. export default b

显而易见,以上三个文件在浏览器中无法直接运行,因为浏览器不支持 ES Module 的 import/export 方法,一些浏览器比如 chrome 可以设置 <script type="module" src="index.js"></script>支持,但这样每次 import 就要请求一次,请求文件过多,非常影响性能。
正确的处理方法是通过 babel 把 import/export 转换为普通代码,并且所有文件打包成一个文件,接下来的打包器就是要实现这个功能

1.2 相关依赖

实现打包器需要用到一些 babel依赖:

  • @babel/parser :解析代码生成 AST
  • @babel/traverse:遍历 AST,通常用来修改 AST
  • @babel/core:babel 用来转译代码
  • @babel/preset-env:babel 转译的规则

    2. 打包流程

    2.1 生成依赖图谱

    初步得到hash依赖图谱

    指定打包的入口文件为index.js,项目中的deps_4.ts实现了生成依赖图谱的初步功能,
    生成依赖图谱的功能函数collectCodeAndDeps如下,分析入口文件的AST,找到入口文件的依赖并进行递归分析 ```javascript import { parse } from “@babel/parser” import traverse from “@babel/traverse” import { readFileSync } from ‘fs’ import { resolve, relative, dirname } from ‘path’;

// 设置根目录 const projectRoot = resolve(__dirname, ‘project_1’) // 类型声明 type DepRelation = { [key: string]: { deps: string[], code: string } } // 初始化一个空的 depRelation,用于收集依赖 const depRelation: DepRelation = {}

// 将入口文件的绝对路径传入函数, collectCodeAndDeps(resolve(projectRoot, ‘index.js’))

console.log(depRelation) console.log(‘done’)

function collectCodeAndDeps(filepath: string) { const key = getProjectPath(filepath) // 文件的项目路径,如 index.js if (Object.keys(depRelation).includes(key)) { // 注意,重复依赖不一定是循环依赖 return } // 获取文件内容,将内容放至 depRelation const code = readFileSync(filepath).toString() // 初始化 depRelation[key] depRelation[key] = { deps: [], code: code } // 将代码转为 AST const ast = parse(code, { sourceType: ‘module’ }) // 分析文件依赖,将内容放至 depRelation traverse(ast, { enter: path => { if (path.node.type === ‘ImportDeclaration’) { // path.node.source.value 往往是一个相对路径,如 ./a.js,需要先把它转为一个绝对路径 const depAbsolutePath = resolve(dirname(filepath), path.node.source.value) // 然后转为项目路径 const depProjectPath = getProjectPath(depAbsolutePath) // 把依赖写进 depRelation depRelation[key].deps.push(depProjectPath) collectCodeAndDeps(depAbsolutePath) } } }) } // 获取文件相对于根目录的相对路径 function getProjectPath(path: string) { return relative(projectRoot, path).replace(/\/g, ‘/‘) }

  1. 函数用一个hash `depRelation`来维护依赖图谱,具体结构如下,记录了文件名,依赖数组和本身的代码
  2. ```javascript
  3. {
  4. 'index.js': {
  5. deps: [ 'a.js', 'b.js' ],
  6. code: "import a from './a.js'\n" +
  7. "import b from './b.js'\n" +
  8. 'console.log(a.getB())\n' +
  9. 'console.log(b.getA())\n'
  10. },
  11. 'a.js': {
  12. deps: [ 'b.js' ],
  13. code: "import b from './b.js'\n" +
  14. 'const a = {\n' +
  15. " value: 'a',\n" +
  16. " getB: () => b.value + ' from a.js'\n" +
  17. '}\n' +
  18. 'export default a\n'
  19. },
  20. 'b.js': {
  21. deps: [ 'a.js' ],
  22. code: "import a from './a.js'\n" +
  23. 'const b = {\n' +
  24. " value: 'b',\n" +
  25. " getA: () => a.value + ' from b.js'\n" +
  26. '}\n' +
  27. 'export default b\n'
  28. }
  29. }

对 code 进行es5转译

在初步得到依赖图谱后我们发现一个问题:那就是 import/export 还是存在
这时候我们需要使用 babel 实现代码转译,在函数collectCodeAndDeps中增加以下部分将写入依赖图谱的 code 转译成 ES5,完整代码见项目中的 bundler_1.ts

  1. const code = readFileSync(filepath).toString()
  2. const { code: es5Code } = babel.transform(code, {
  3. presets: ['@babel/preset-env']
  4. })

此时,依赖图谱depRelation的结构如下:

  1. {
  2. 'index.js': {
  3. deps: [ 'a.js', 'b.js' ],
  4. code: '"use strict";\n' +
  5. '\n' +
  6. 'var _a = _interopRequireDefault(require("./a.js"));\n' +
  7. '\n' +
  8. 'var _b = _interopRequireDefault(require("./b.js"));\n' +
  9. '\n' +
  10. 'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n' +
  11. '\n' +
  12. 'console.log(_a["default"].getB());\n' +
  13. 'console.log(_b["default"].getA());'
  14. },
  15. 'a.js': {
  16. deps: [ 'b.js' ],
  17. code: '"use strict";\n' +
  18. '\n' +
  19. 'Object.defineProperty(exports, "__esModule", {\n' +
  20. ' value: true\n' +
  21. '});\n' +
  22. 'exports["default"] = void 0;\n' +
  23. '\n' +
  24. 'var _b = _interopRequireDefault(require("./b.js"));\n' +
  25. '\n' +
  26. 'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n' +
  27. '\n' +
  28. 'var a = {\n' +
  29. " value: 'a',\n" +
  30. ' getB: function getB() {\n' +
  31. ` return _b["default"].value + ' from a.js';\n` +
  32. ' }\n' +
  33. '};\n' +
  34. 'var _default = a;\n' +
  35. 'exports["default"] = _default;'
  36. },
  37. 'b.js': {
  38. deps: [ 'a.js' ],
  39. code: '"use strict";\n' +
  40. '\n' +
  41. 'Object.defineProperty(exports, "__esModule", {\n' +
  42. ' value: true\n' +
  43. '});\n' +
  44. 'exports["default"] = void 0;\n' +
  45. '\n' +
  46. 'var _a = _interopRequireDefault(require("./a.js"));\n' +
  47. '\n' +
  48. 'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n' +
  49. '\n' +
  50. 'var b = {\n' +
  51. " value: 'b',\n" +
  52. ' getA: function getA() {\n' +
  53. ` return _a["default"].value + ' from b.js';\n` +
  54. ' }\n' +
  55. '};\n' +
  56. 'var _default = b;\n' +
  57. 'exports["default"] = _default;'
  58. }
  59. }

这时候depRelation中每个模块对应的code都是字符串的形式,我们以 a.js 的 code 为例,把它转换为js分析一下

"use strict";
Object.defineProperty(exports, "__esModule", {value: true}); // 疑惑 1
exports["default"] = void 0;                                 // 疑惑 2
var _b = _interopRequireDefault(require("./b.js"));          // 细节 1
function _interopRequireDefault(obj) {                       // 细节 1
  return obj && obj.__esModule ? obj : { "default": obj };   // 细节 1
}
var a = {
  value: 'a',  
  getB: function getB() {
    return _b["default"].value + ' from a.js';               // 细节 1
  }
};
var _default = a;                                            // 细节 2
exports["default"] = _default;                               // 细节 2

疑惑1

该部分等同于exports.__esModule = true,表式原来是 ES Module 模块,方便与 Common JS 模块区分

疑惑2

该部分等同于exports.default = undefined,表示导出的初始化

细节1

import b from './b.js'转译为了var _b = _interopRequireDefault(require("./b.js"))
其中这个 require 函数还没有定义,注意这个不是Common JS中的那个 require,我们这里就先假设它已经被定义,而且作用就是引入模块 b
函数_interopRequireDefault的作用是给引入的模块添加 default 属性,特别是 Common JS 模块没有默认导出,然后访问引入模块时,要通过 default 属性访问

细节2

该部分等同于exports.default = a,此时 exports 对象的结构为

{
  __esModule: true,
    default: a
}

总结

进行转译后,import 变为了 require 函数,export 变为了 exports 对象

2.2 根据打包文件倒推打包器

既然我们的最终目标是打包出一个文件 dist.js,那这个文件应该包含所有的模块,并且能够执行所有的模块,我们可以根据dist.js 倒推 bundelr.js

打包文件分析

所以这个打包得到的 dist.js 应该包含什么内容呢:

  1. 首先应该有依赖图谱相关的内容
  2. 其次应该有一个执行函数可以执行入口

dist.js 内容如下,详见项目 dist.js

var depRelation = [{
  key: "index.js",
  deps: ["a.js", "b.js"],
  code: function (require, module, exports) {
    "use strict";

    var _a = _interopRequireDefault(require("./a.js"));

    var _b = _interopRequireDefault(require("./b.js"));

    function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }

    console.log(_a["default"].getB());
    console.log(_b["default"].getA());
  }
}, {
  key: "a.js",
  deps: ["b.js"],
  code: function (require, module, exports) {
    "use strict";

    Object.defineProperty(exports, "__esModule", {
      value: true
    });
    exports["default"] = void 0;

    var _b = _interopRequireDefault(require("./b.js"));

    function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }

    var a = {
      value: 'a',
      getB: function getB() {
        return _b["default"].value + ' from a.js';
      }
    };
    var _default = a;
    exports["default"] = _default;
  }
}, {
  key: "b.js",
  deps: ["a.js"],
  code: function (require, module, exports) {
    "use strict";

    Object.defineProperty(exports, "__esModule", {
      value: true
    });
    exports["default"] = void 0;

    var _a = _interopRequireDefault(require("./a.js"));

    function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }

    var b = {
      value: 'b',
      getA: function getA() {
        return _a["default"].value + ' from b.js';
      }
    };
    var _default = b;
    exports["default"] = _default;
  }
}]

// 核心代码
var modules = {}
execute(depRelation[0].key)

function execute(key) {
  // 如果已经 require 过,就直接返回上次的结果
  if (modules[key]) { return modules[key] }
  // 找到要执行的项目
  var item = depRelation.find(i => i.key === key)
  // 找不到就报错,中断执行
  if (!item) { throw new Error(`${item} is not found`) }
  // 把相对路径变成项目路径
  var pathToKey = (path) => {
    var dirname = key.substring(0, key.lastIndexOf('/') + 1)
    var projectPath = (dirname + path).replace(/\.\//g, '').replace(/\/\//, '/')
    return projectPath
  }
  // 创建 require 函数
  var require = (path) => {
    return execute(pathToKey(path))
  }
  // 初始化当前模块
  modules[key] = { __esModule: true }
  // 初始化 module 方便 code 往 module.exports 上添加属性
  var module = { exports: modules[key] }
  // 调用 code 函数,往 module.exports 上添加导出属性
  // 第二个参数 module 大部分时候是无用的,主要用于兼容旧代码
  item.code(require, module, module.exports)
  // 返回当前模块
  return modules[key]
}

console.log(modules)

对打包得到的文件进行分析:

数组形式的依赖图谱

打包文件中的依赖图谱没有采用2.1中打包器中初步生成的hash形式,而是采用了数组形式,这样可以通过索引0开始执行入口文件,
数组形式的依赖图谱可以由打包器中的hash依赖图谱转换和写入打包文件生成
具体代码见项目中 bundler_2.ts

依赖图谱中的code为函数

依赖图谱中的code不再是字符串了,而变为了函数,只要打包器依赖图谱的code字符串外面包裹上对应字符串再写入文件就可以得到
生成的函数 function(require, module, exports){...},里面的三个参数是 Common JS2 规范规定的,这样我们也能够确定 code 中的require函数和exports对象是外面传进来的

execute 执行函数

主体思路:

const modules = {} // modules 用于缓存所有模块
function execute(key) { 
  if (modules[key]) { return modules[key] }
  var item = depRelation.find(i => i.key === key)
  var require = (path) => { // 定义 require 函数
    return execute(pathToKey(path))
  }
  modules[key] = { __esModule: true } 
  var module = { exports: modules[key] } // 定义 module 对象
  item.code(require, module, module.exports) 
  return modules.exports
}

其中对象 modules 用于缓存所有的模块,执行dist.js 后打印 modules

{
  'index.js': { __esModule: true },
  'a.js': { __esModule: true, default: { value: 'a', getB: [Function: getB] } },
  'b.js': { __esModule: true, default: { value: 'b', getA: [Function: getA] } }
}

倒推打包器

有了打包文件,倒推打包器也就很简单了,通过拼接对应的字符串即可,代码见项目 bundler_3.ts

import { parse } from "@babel/parser"
import traverse from "@babel/traverse"
import { writeFileSync, readFileSync } from 'fs'
import { resolve, relative, dirname } from 'path';
import * as babel from '@babel/core'

// 设置根目录
const projectRoot = resolve(__dirname, 'project_1')
// 类型声明
type DepRelation = { key: string, deps: string[], code: string }[]
// 初始化一个空的 depRelation,用于收集依赖
const depRelation: DepRelation = [] // 数组!

// 将入口文件的绝对路径传入函数
collectCodeAndDeps(resolve(projectRoot, 'index.js'))

writeFileSync('dist_2.js', generateCode())
console.log('done')

function generateCode() {
  let code = ''
  code += 'var depRelation = [' + depRelation.map(item => {
    const { key, deps, code } = item
    return `{
      key: ${JSON.stringify(key)}, 
      deps: ${JSON.stringify(deps)},
      code: function(require, module, exports){
        ${code}
      }
    }`
  }).join(',') + '];\n'
  code += 'var modules = {};\n'
  code += `execute(depRelation[0].key)\n`
  code += `
  function execute(key) {
    if (modules[key]) { return modules[key] }
    var item = depRelation.find(i => i.key === key)
    if (!item) { throw new Error(\`\${item} is not found\`) }
    var pathToKey = (path) => {
      var dirname = key.substring(0, key.lastIndexOf('/') + 1)
      var projectPath = (dirname + path).replace(\/\\.\\\/\/g, '').replace(\/\\\/\\\/\/, '/')
      return projectPath
    }
    var require = (path) => {
      return execute(pathToKey(path))
    }
    modules[key] = { __esModule: true }
    var module = { exports: modules[key] }
    item.code(require, module, module.exports)
    return modules[key]
  }
  `
  return code
}

function collectCodeAndDeps(filepath: string) {
  const key = getProjectPath(filepath) // 文件的项目路径,如 index.js
  if (depRelation.find(i => i.key === key)) {
    // 注意,重复依赖不一定是循环依赖
    return
  }
  // 获取文件内容,将内容放至 depRelation
  const code = readFileSync(filepath).toString()
  const { code: es5Code } = babel.transform(code, {
    presets: ['@babel/preset-env']
  })
  // 初始化 depRelation[key]
  const item = { key, deps: [], code: es5Code }
  depRelation.push(item)
  // 将代码转为 AST
  const ast = parse(code, { sourceType: 'module' })
  // 分析文件依赖,将内容放至 depRelation
  traverse(ast, {
    enter: path => {
      if (path.node.type === 'ImportDeclaration') {
        // path.node.source.value 往往是一个相对路径,如 ./a.js,需要先把它转为一个绝对路径
        const depAbsolutePath = resolve(dirname(filepath), path.node.source.value)
        // 然后转为项目路径
        const depProjectPath = getProjectPath(depAbsolutePath)
        // 把依赖写进 depRelation
        item.deps.push(depProjectPath)
        collectCodeAndDeps(depAbsolutePath)
      }
    }
  })
}
// 获取文件相对于根目录的相对路径
function getProjectPath(path: string) {
  return relative(projectRoot, path).replace(/\\/g, '/')
}

3. 总结

综上,我们实现了一个简易的打包器将三个js文件打包成了一个,但功能还比较简陋,有以下不足:

  • 生成的代码有重复部分,比如_interopRequireDefault函数
  • 只能打包 js
  • 支持ES Module,不支持 Common JS
  • 不支持插件
  • 不支持配置入口出口等

功能虽然简陋,但是实现了 webpack 的核心原理,有助于深入学习