解析 webpack 核心——Webpack 原理

Webpack 要解决的两个问题

  • 编译 import 和 export 关键字

  • 将多个文件打包成一个

如何编译 import 和 export 关键字

  1. 不同浏览器功能不同
  • 现代浏览器可以通过<script type="module">来支持 import/export
  • IE 8~15不支持 import/export
  1. 兼容策略
  • 激进兼容策略 把代码全部放到<script type="module">里面

  • 缺点 不被IE 8~15支持;而且会导致文件请求过多

  • 平稳兼容策略 把关键字转译为普通代码(通过转译函数完成),并把所有文件打包成一个文件

  • 缺点 需要写复杂代码来完成这件事情

  1. 那么怎么写这个转译函数?
  • @babel/core已经帮我们做了

  • 示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// project_1/index.js
import a from './a.js'
import b from './b.js'

console.log(a.getB())
console.log(b.getA())

// project_1/a.js
import b from './b.js'

const a = {
value: 'a',
getB: () => b.value + ' from a.js'
}
export default a

// project_1/b.js
import a from './a.js'

const b = {
value: 'b',
getA: () => a.value + ' from b.js'
}
export default b

执行node -r ts-node/register bundler_1.ts结果如下

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

// 疑惑2
// exports["default"] = void 0;等同于exports["default"] = undefined
// 清空exports["default"]的值

// 细节1
// import b from './b.js' 变成了
// var _b = _interopRequireDefault(require("./b.js"))

// b.value 变成了
// _b['default'].value

// 解释 _interopRequireDefault(module)
// _ 下划线前缀是为了避免与其他变量重名
// 该函数的意图是给模块添加 'default'
// 为什么要加 default:CommonJS 模块没有默认导出,加上方便兼容
// 内部实现:return m && m.__esModule ? m : { "default": m }
// 其他 _interop 开头的函数大多都是为了兼容旧代码

// 细节2
// var _default = a; exports["default"] = _default;
// 相当于exports["default"] = a
  1. 总结
  • import 关键字会变成 require 函数

  • export 关键字会变成 exports 对象

  • 本质:ESModule 语法变成了 CommonJS 规则

  • 但是目前我们不知道 require 函数怎么写,先不管,假设 require 已经写好了

如何将多个文件打包成一个

  1. 打包后的文件会是什么样子的?
  • 肯定包含所有模块,并且能执行所有模块

  • 预想如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var depRelation = [
{key: 'index.js', deps: ['a.js', 'b.js'], code: function... },
{key: 'a.js', deps: ['b.js'], code: function... },
{key: 'b.js', deps: ['a.js'], code: function... }
]
// 为什么把 depRelation 从对象改为数组?
// 因为数组的第一项就是入口,而对象没有第一项的概念
execute(depRelation[0].key) // 执行入口文件
function execute(key){
var item = depRelation.find(i => i.key === key)
item.code(???) // 执行 item 的代码,因此 code 最好是个函数,方便执行
// 但是目前还不知道要传什么参数给 code
// 代码待完善......
}
  • 现在有三个问题待解决

    • depRelation 是对象,需要变成一个数组

    • code 是字符串,需要变成一个函数, 函数遵循 CJS2 规范

    • execute 函数待完善

  • 解决上述第一个问题

    depRelation[key] = { deps: [], code: es5Code }

    改为了

    const item = { key, deps: [], code: es5Code } depRelation.push(item)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// bundler_2.ts

// 请确保你的 Node 版本大于等于 14
// 请先运行 yarn 或 npm i 来安装依赖
// 然后使用 node -r ts-node/register 文件路径 来运行,
// 如果需要调试,可以加一个选项 --inspect-brk,再打开 Chrome 开发者工具,点击 Node 图标即可调试
import { parse } from "@babel/parser";
import traverse from "@babel/traverse";
import { 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 = []; // 数组!

// 将入口文件的绝对路径传入函数,如 D:\demo\fixture_1\index.js
collectCodeAndDeps(resolve(projectRoot, "index.js"));

console.log(depRelation);
console.log("done");

function collectCodeAndDeps(filepath: string) {
const key = getProjectPath(filepath); // 文件的项目路径,如 index.js
if (depRelation.find((i) => i.key === key)) {
console.warn(`duplicated dependency: ${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, "/");
}

可以执行node -r ts-node/register bundler_2.ts看看

  • 解决上述第二个问题

    • 把 code 字符串外面包一个 function(require, module, exports){...}

    • 执行node project_2/string_code_to_function.js{code: ${code2}}写到文件project_2/result_fun.json

    • 最终文件里面的 code 就是函数, 代码参见project_2

  • 解决上述第三个问题

    • 代码参考dist.js

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      73
      74
      75
      76
      77
      78
      79
      80
      81
      82
      83
      84
      85
      86
      87
      88
      89
      90
      91
      92
      93
      94
      95
      96
      97
      98
      99
      100
      101
      102
      103
      104
      105
      // 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]
      }
    • 执行node dist.js得到结果

    1
    2
    b from a.js
    a from b.js
  1. 如何自动生成最终文件?

    • 模板拼接——var dist = ""; dist += content; writeFileSync('dist.js', dist)

    • 代码参考bundler_3.ts, 最终生成文件dist_auto.jsdist.js相差无几

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      73
      74
      75
      76
      77
      78
      79
      80
      81
      82
      83
      84
      85
      86
      87
      88
      89
      90
      91
      92
      93
      94
      95
      96
      // bundler_3.ts
      // 请确保你的 Node 版本大于等于 14
      // 请先运行 yarn 或 npm i 来安装依赖
      // 然后使用 node -r ts-node/register 文件路径 来运行,
      // 如果需要调试,可以加一个选项 --inspect-brk,再打开 Chrome 开发者工具,点击 Node 图标即可调试
      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 = [] // 数组!

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

      writeFileSync('dist_auto.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, '/')
      }

简易打包器

至此,我们就实现了一个简易打包器,但是存在如下问题

  • 生成的代码中有多个重复的_interopXXX函数

  • 只能引入和运行 JS 文件

  • 只能理解 import,无法理解 require

  • 不支持插件

  • 不支持配置入口文件和 dist 文件名

源码参考:https://github.com/Matthrews/webpack-core