Webpack 打包原理 - 实战

520 阅读8分钟

简介

众所周知,webpack 能够进行项目代码的打包,编译等,将各个模块的依赖进行编译为执行文件,因此这个打包的流程也是相当复杂的,文本将通过一个实战一个 Mini Webpack 的方式来理解 Webpack 是如何对项目进行打包的。

初始化

// main.js
import add from './add.js';
import minus from './minus.js';
import { data } from './data.js';

const result_1 = add(1, 2);
const result_2 = minus(2, 1);

console.log('result_1: ', result_1);
console.log('result_2: ', result_2);
console.log('data: ', data);

// add.js
function add(a, b) {
  return a + b;
}
export default add;

// minus.js
function minus(a, b) {
  return a - b;
}
export default minus;

// data.js
export const data = 'My name is 小滨'

以上的代码中,按照现在游览器,使用 type="module" 也是可以有办法识别 import 语法的,当然需要一个服务支持,例如:Vite

但对于 Webpack 来说,都是进行打包转义,使其能够被游览能够识别和运行的,下面就开始来看看如何将上述的代码进行打包。

读取文件

首先,如果要进行打包,那第一步当然需要能够读取到即将要打包的代码内容,否者无从谈起,那一般来说我们读取可能是有一个入口文件,这里入口文件就是 main.js,通过 Node fs 模块读取文件。

/**
 * 分析模块
 */
function getModuleInfo(file) {
  // 读取文件, 这里需要加上 uft-8,否者返回 Buffer 类型
  const body = fs.readFileSync(file, 'uft-8');
  console.log(body);
}

getModuleInfo('./src/main.js')

读取到文件后,接下来就要对内容进行分析了,这里读取到的内容无非就是长长的字符串,对字符串进行分析,想当然肯定要用到正则了,并且最好能够得到一个有一定结构的数据才能方便分析,就像 Vue、React 这些框架一样,将写的伪 Html 代码转换成 AST 树、Virtual 树等。

这里其实就是对读取到的内容,将其转换成 AST 树,下面就来试试转成 AST 树。

转化成 AST 语法树

换成成 AST 树,并不是一个简单的事情,这里就不从新造轮子,而是使用 babel 的库来对代码进行动手术,转换成 AST 树。

function getModuleInfo(file) {
  // 读取文件
  // ...
  
  // 转换为 AST 树,采用的是 @babel/parser
  // 文档:https://babeljs.io/docs/en/babel-parser
  const ast = parser.parse(body, {
    sourceType: 'module',  // 表示我们要解析的是 ES 模块
  })
  console.log(ast);
}

通过 @babel/parser 库将内容转换成了 AST 树,得到的结果大概长在这样:

Node {
  type: 'File',
  // ...
  program: Node {
    // ...
    body: [
      ImportDeclaration: {
        type: 'ImportDeclaration',
        // ...
        source: {
          type: 'Literal',
          // ...
          value: './add'
        }
      },
      // ...
      ExpressionStatement: {}
      // ...
    ],
    directives: []
  },
  comments: []
}

上面 AST 树省略了很多,其中最最重要的就是 body.source.value 的内容,这个内容记载了代码中的依赖关系。

说到依赖关系,接下来就是对 AST 树进行分析,找出入口文件所依赖的其他模块,这样才能相对应的引入入口文件所需要的其他模块的方法或者数据。

从上面的 AST 中,可以获取到当前分析文件所需要的依赖模块入口,但是需要分析 AST 树,肯定是需要遍历递归的,这工作量也是不小的,在这里继续使用 babel 的库 @babel/traverse 来进行依赖收集的工作

依赖收集

const fs = require('fs');
const path = require('path');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse')

/**
 * 分析模块
 */
function getModuleInfo(file) {
  // 读取文件
  // ...

  // 转换为 AST 树,采用的是 @babel/parser
  // 文档:https://babeljs.io/docs/en/babel-parser
  // ...

  // 收集依赖
  const deps = {};
  // 文档:https://babeljs.io/docs/en/babel-traverse
  traverse.default(ast, {
    // visitor 函数,解析 import 语法糖
    ImportDeclaration({ node }) {
      const dirname = path.dirname(file); // 获取文件目录 ./src 
      const abspath = './' + path.join(dirname, node.source.value); // 拼接地址 ./src/add 
      deps[node.source.value] = abspath;
    }
  })
  /**
   * 输出:
   * {
   *   './add': './src/add',
   *   './minus': './src/minus',
   *   './data': './src/data'
   * }
   */
  console.log(deps)
}

通过使用 @babel/traverse 对 AST 树进行分析,获取依赖关系。

这里有一个小坑的是 @babel/traverse 按照官方的写法是有一点小问题的,需要使用 traverse.default() 才行

到此,从上面的处理过程中,可以得到了文件的内容和内容里面的模块的依赖关系了。但是还有一个需要处理的是,从哪个上面读取文件的内容中,可以看到里面是 ES6 的写法 import 虽然对于现代游览器来说,可以加上 type="module" 识别这种类型,但是在 webpack 的处理中,为了更好的兼容,是会吧 ES6 写法进行转换的,比如:import 会被转换成 require, 下面,还是采用 babel 进行转换,使用的是:@babel/code

ES6 转换成 ES5

const fs = require('fs');
const path = require('path');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse');
const babel = require("@babel/core");

/**
 * 分析模块
 */
function getModuleInfo(file) {
  // 读取文件
  // ...

  // 转换为 AST 树,采用的是 @babel/parser
  // 文档:https://babeljs.io/docs/en/babel-parser
  // ...

  // 收集依赖
  // ...

  // ES6 语法转换 ES5
  // https://www.babeljs.cn/docs/babel-core
  // https://www.babeljs.cn/docs/babel-preset-env
  const code = babel.transformFromAst(ast, null, {
    presets: ['@babel/preset-env'] // 根据当前环境使用兼容转换
  })
  console.log(code);
}

这样就能够将 ES6 进行转换了,转换的结果如下:

{
  metadata: {},
  options: {
    // ...
  code: '"use strict";\n' +
    '\n' +
    'var _add = _interopRequireDefault(require("./add"));\n' +
    '\n' +
    'var _minus = _interopRequireDefault(require("./minus"));\n' +
    '\n' +
    'var _data = require("./data");\n' +
    '\n' +
    'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n' +
    '\n' +
    'var result_1 = (0, _add["default"])(1, 2);\n' +
    'var result_2 = (0, _minus["default"])(2, 1);\n' +
    "console.log('result_1: ', result_1);\n" +
    "console.log('result_2: ', result_2);\n" +
    "console.log('data: ', _data.data);",
 // ...
}

最后 getModuleInfo 就算完成了,将分析后的内容进行返回了:

function getModuleInfo(file) {
  // 读取文件
  // ...

  // 转换为 AST 树,采用的是 @babel/parser
  // 文档:https://babeljs.io/docs/en/babel-parser
  // ...

  // 收集依赖
  // ...

  // ES6 语法转换 ES5
  // ...
  
  const moduleInfo = { file, code, deps };
  return moduleInfo;
}

目前,我们只对一个入口文件进行了解析,入口文件汇总所依赖的其他模块,肯定也是要一一进行读取内容,然后进行解析的,其实也就是重新再走一遍入口文件所走过的路,那这里其实就是从入口文件的依赖关系中遍历递归就行了。

递归解析依赖

function getDeps(temp, entry) {
  const { deps } = entry;
  Object.keys(deps).forEach(key => {
    const child = getModuleInfo(deps[key]);
    temp.push(child);
    getDeps(temp, child);
  })
}

function parseModules(file) {
  const entry = getModuleInfo(file); // 获取入口文件的模块信息
  const temp = [entry]; // 收集所有文件的模块信息集合
  const depsGraph = {}; // 收集所有依赖的集合

  getDeps(temp, entry); // 递归入口文件的依赖集合,手机所有依赖信息

  temp.forEach(item => {
    depsGraph[item.file] = {
      deps: item.deps,
      code: item.code
    }
  })
  return depsGraph;
}

parseModules('./src/main.js')

通过对依赖模块 deps 进行遍历递归,最终得到:

{
  './src/main.js': {
    deps: {
      './add.js': './src/add.js',
      './minus.js': './src/minus.js',
      './data.js': './src/data.js'
    },
    code: '"use strict";\n' +
      '\n' +
      'var _add = _interopRequireDefault(require("./add.js"));\n' +
      '\n' +
      'var _minus = _interopRequireDefault(require("./minus.js"));\n' +
      '\n' +
      'var _data = require("./data.js");\n' +
      '\n' +
      'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n' +
      '\n' +
      'var result_1 = (0, _add["default"])(1, 2);\n' +
      'var result_2 = (0, _minus["default"])(2, 1);\n' +
      "console.log('result_1: ', result_1);\n" +
      "console.log('result_2: ', result_2);\n" +
      "console.log('data: ', _data.data);"
  },
  './src/add.js': {
    deps: {},
    code: '"use strict";\n' +
      '\n' +
      'Object.defineProperty(exports, "__esModule", {\n' +
      '  value: true\n' +
      '});\n' +
      'exports["default"] = void 0;\n' +
      '\n' +
      'function add(a, b) {\n' +
      '  return a + b;\n' +
      '}\n' +
      '\n' +
      'var _default = add;\n' +
      'exports["default"] = _default;'
  },
  './src/minus.js': {
    deps: {},
    code: '"use strict";\n' +
      '\n' +
      'Object.defineProperty(exports, "__esModule", {\n' +
      '  value: true\n' +
      '});\n' +
      'exports["default"] = void 0;\n' +
      '\n' +
      'function minus(a, b) {\n' +
      '  return a - b;\n' +
      '}\n' +
      '\n' +
      'var _default = minus;\n' +
      'exports["default"] = _default;'
  },
  './src/data.js': {
    deps: {},
    code: '"use strict";\n' +
      '\n' +
      'Object.defineProperty(exports, "__esModule", {\n' +
      '  value: true\n' +
      '});\n' +
      'exports.data = void 0;\n' +
      "var data = 'My name is 小滨';\n" +
      'exports.data = data;'
  }
}

从以上结果可以很好的得到所需要的结果了,每一个文件的依赖关系,内容数据集合,接下来就是对这个数据进行执行了,问题是,如果执行每一个文件的 code 字段的内容。

仔细看看每一个模块的 code 字段,会发现,这字段中存在 requireexports.dataexports["default"],其实这些东西,在原本的 JS 引擎中是没有的东西,那如果没有,那就有我们赋予即可,无非就是一些方法和数据。

其次,我们要执行 code 的代码也是一个问题,因为 code 其实就是一个字符串。如果需要执行一个字符串,可以采用 new Function(code) 来创建一个函数,之后进行调用执行,这也其实就是 Vue 生成 render 方法的手段,另外也可以使用 eval(code) 来做这件事情,文本就采用 eval(code) 的方式。

编写 bundle

在编写 bundle 时,可以先使用上面得到的模块信息集合写一个 bundle 试试,如下:

(function (graph) {
  function require(file) {
    function absRequire(path) {
      const filePath = graph[file].deps[path];
      return require(filePath);
    }
    
    let exports = {}; // require 就是返回 export,定义一个 export
    (function (require, exports, code) {
      eval(code)
    }(absRequire, exports, graph[file].code))

    return exports
  }
  require('./src/main.js');
}({
  './src/main.js': {
    deps: {
      './add.js': './src/add.js',
      './minus.js': './src/minus.js',
      './data.js': './src/data.js'
    },
    code: '"use strict";\n' +
      '\n' +
      'var _add = _interopRequireDefault(require("./add.js"));\n' +
      '\n' +
      'var _minus = _interopRequireDefault(require("./minus.js"));\n' +
      '\n' +
      'var _data = require("./data.js");\n' +
      '\n' +
      'function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { "default": obj }; }\n' +
      '\n' +
      'var result_1 = (0, _add["default"])(1, 2);\n' +
      'var result_2 = (0, _minus["default"])(2, 1);\n' +
      "console.log('result_1: ', result_1);\n" +
      "console.log('result_2: ', result_2);\n" +
      "console.log('data: ', _data.data);"
  },
  './src/add.js': {
    deps: {},
    code: '"use strict";\n' +
      '\n' +
      'Object.defineProperty(exports, "__esModule", {\n' +
      '  value: true\n' +
      '});\n' +
      'exports["default"] = void 0;\n' +
      '\n' +
      'function add(a, b) {\n' +
      '  return a + b;\n' +
      '}\n' +
      '\n' +
      'var _default = add;\n' +
      'exports["default"] = _default;'
  },
  './src/minus.js': {
    deps: {},
    code: '"use strict";\n' +
      '\n' +
      'Object.defineProperty(exports, "__esModule", {\n' +
      '  value: true\n' +
      '});\n' +
      'exports["default"] = void 0;\n' +
      '\n' +
      'function minus(a, b) {\n' +
      '  return a - b;\n' +
      '}\n' +
      '\n' +
      'var _default = minus;\n' +
      'exports["default"] = _default;'
  },
  './src/data.js': {
    deps: {},
    code: '"use strict";\n' +
      '\n' +
      'Object.defineProperty(exports, "__esModule", {\n' +
      '  value: true\n' +
      '});\n' +
      'exports.data = void 0;\n' +
      "var data = 'My name is 小滨';\n" +
      'exports.data = data;'
  }
}))

以上执行后,即可得到:

result_1:  3
result_2:  1
data:  My name is 小滨

说明根据解析出来的模块信息,最终得到了正确的输出结果。 下面就来继续编写真正的 bundle 方法

function bundle(file) {
  const depsGraph = JSON.stringify(parseModules('./src/main.js'));
  return `(function (graph) {
        function require(file) {
            function absRequire(path) {
                const filePath = graph[file].deps[path];
                return require(filePath)
            }
            var exports = {};
            (function (require,exports,code) {
                eval(code)
            })(absRequire,exports,graph[file].code)
            return exports
        }
        require('${file}')
    })(${depsGraph})`;
}

const content = bundle("./src/main.js");

这里输出的 content 就是最后打包后的内容了,下面就将打包后的内容,生成 bundle.js 文件吧

创建 bundle 文件

使用 Node 的 fspath 模块进行创建,其次还可以使用 prettier 库,对输出的内容进行格式化一下~

function bundle(file) {
  // ...
}

const content = bundle("./src/main.js");

const distPath = path.resolve(__dirname, './dist');
!fs.existsSync(distPath) && fs.mkdirSync(distPath);
const bundlePath = path.resolve(__dirname, './dist/bundle.js');
fs.writeFileSync(bundlePath, prettier.format(content, { parser: 'babel' }))

到此,就大功告成啦!~

本文通过一个编写一个 Mini Webpack 的方式来理解 Webpack 的打包原理,希望能够帮助到阅读到此的同学,有帮助的话,那就点赞再走吧~

附件