必会!
参考文档:

  • https://mp.weixin.qq.com/s/hhPxPp-osbob6B-KkwLSoQ
  • https://juejin.cn/post/6867471674183254024

    打包过程分析

    1. 打包的主要流程

  • 需要读到入口文件里面的内容

  • 分析入口文件,递归的去读取模块所依赖的文件内容,生成AST语法树
  • 根据AST语法树,生成浏览器能够运行的代码

    2. 打包过程的步骤

  • 获取模块内容

  • 分析模块
  • 收集依赖
  • ES6转成ES5(AST)
  • 递归获取所有依赖
  • 处理两个关键字(import、exports)
  • 输出打包结果代码

    3. 实现打包的步骤

  • 获取主模块内容

  • 分析模块
    • 安装@babel/parser包(转AST)
  • 对模块内容进行处理
    • 安装@babel/traverse包(遍历AST收集依赖)
    • 安装@babel/core和@babel/preset-env包 (es6转ES5)
  • 递归所有模块
  • 生成最终代码

什么是Webpack

Webpack是一个打包工具,它的宗旨是一切静态资源皆可打包。
c6c5211e9e2e3787291f8e34e52f9e35.png

原理分析

首先,我们制作一个打包文件的原型。
假设有两个js模块,这里我们先假设这两个模块是复合commomjs标准的es5模块。
语法和模块化规范转换的事,我们先放一放,后面说。
我们的目的是将这两个模块打包为一个能在浏览器端运行的文件,这个文件其实叫bundle.js。
比如:

  1. // index.js
  2. var add = require('add.js').default
  3. console.log(add(1 , 2))
  4. // add.js
  5. exports.default = function(a,b) {return a + b}

假设在浏览器中直接执行这个程序肯定会有问题,最主要的问题是浏览器中没有exports对象与require方法所以一定会报错
我们需要通过模拟exports对象和require方法

1. 模拟exports对象

首先我们知道如果在nodejs打包的时候我们会使用sfs.readfileSync()来读取js文件,这样的话js文件会是一个字符串。
而如果需要将字符串中的代码运行会有两个方法,分别是new Function与Eval
在这里面我们选用执行效率较高的eval。
640.gif

  1. exports = {};
  2. eval('exports.default = function(a,b) {return a + b}') // node文件读取后的代码字符串
  3. exports.default(1,3)

从零开始实现webpack核心打包过程 - 图3
上面这段代码的运行结果可以将模块中的方法绑定在exports对象中,由于子模块中会声明变量,为了不污染全局我们使用一个自运行函数来封装一下。

  1. var exports = {}
  2. (function (exports, code) {
  3. eval(code)
  4. })(exports, 'exports.default = function(a,b){return a + b}')

2. 模拟require函数

require函数的功能比较简单,就是根据提供的file名称加载对应的模块。
首先我们先看看如果只有一个固定模块应该怎么写。
640 (1).gif

  1. function require(file) {
  2. var exports = {};
  3. (function (exports, code) {
  4. eval(code)
  5. })(exports, 'exports.default = function(a,b){return a + b}')
  6. return exports
  7. }
  8. var add = require('add.js').default
  9. console.log(add(1 , 2))

完成了固定模块,我们下面只需要稍加改动,将所有模块的文件名和代码字符串整理为一张key-value表,就可以根据传入的文件名加载不同的模块了。

  1. (function (list) {
  2. function require(file) {
  3. var exports = {};
  4. (function (exports, code) {
  5. eval(code);
  6. })(exports, list[file]);
  7. return exports;
  8. }
  9. require("index.js");
  10. })({
  11. "index.js": `
  12. var add = require('add.js').default
  13. console.log(add(1 , 2))
  14. `,
  15. "add.js": `exports.default = function(a,b){return a + b}`,
  16. });

当然要说明的一点是:真正webpack生成的bundle.js文件中还需要增加模块间的依赖关系
叫做依赖图(Dependency Graph)
类似下面的情况。

  1. {
  2. "./src/index.js": {
  3. "deps": { "./add.js": "./src/add.js" },
  4. "code": "....."
  5. },
  6. "./src/add.js": {
  7. "deps": {},
  8. "code": "......"
  9. }
  10. }

另外,由于大多数前端程序都习惯使用es6语法所以还需要预先将es6语法转换为es5语法。
总结一下思路,webpack打包可以分为以下三个步骤:

  • 分析依赖
  • ES6转ES5
  • 替换exports和require

下面进入功能实现阶段。

功能实现

我们的目标是将以下两个互相依赖的ES6Module打包为一个可以在浏览器中运行的一个JS文件(bundle.js)

  • 处理模块化
  • 多模块合并打包 - 优化网络请求

/src/add.js

  1. export default (a, b) => a + b

/src/index.js

  1. import add from "./add.js";
  2. console.log(add(1 , 2))

1. 分析模块

分析模块分为以下三个步骤:
模块的分析相当于对读取的文件代码字符串进行解析。
这一步其实和高级语言的编译过程一致,需要将模块解析为抽象语法树AST,我们借助babel/parser来完成。

AST (Abstract Syntax Tree)抽象语法树 在计算机科学中,或简称语法树(Syntax tree),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。(astexplorer.net/)

安装依赖

  1. yarn add @babel/parser @babel/traverse @babel/core @babel/preset-env
  • 读取文件
  • 收集依赖
  • 编译与AST解析 ```javascript // 手写webpack核心打包流程 // 获取主入口文件 const fs = require(“fs”); const path = require(“path”); const parser = require(“@babel/parser”); const traverse = require(“@babel/traverse”).default; const babel = require(“@babel/core”);

// 解析单个文件,获取文件路径,依赖文件列表,编译成es5的代码 function getModuleInfo(file) { // 读取文件 const body = fs.readFileSync(path.resolve(__dirname, file), ‘utf-8’);

// 转化AST语法树 const ast = parser.parse(body, { sourceType: “module”, // 表示我们要解析的是ES模块 });

// 依赖收集 const deps = {}; traverse(ast, { ImportDeclaration({ node }) { const dirname = path.dirname(file); const abspath = “./“ + path.join(dirname, node.source.value); deps[node.source.value] = abspath; }, });

// ES6转成ES5 const { code } = babel.transformFromAst(ast, null, { presets: [“@babel/preset-env”], }); const moduleInfo = { file, deps, code }; return moduleInfo; } const info = getModuleInfo(“./src/index.js”); console.log(“info:”, info);

  1. **目录结构**<br />![image.png](https://cdn.nlark.com/yuque/0/2022/png/2932776/1656931689116-46faa367-f777-473f-a567-f033ea69f076.png#clientId=u6785ebeb-eb4a-4&crop=0&crop=0&crop=1&crop=1&from=paste&height=323&id=u6fcca352&margin=%5Bobject%20Object%5D&name=image.png&originHeight=404&originWidth=485&originalType=binary&ratio=1&rotation=0&showTitle=false&size=25532&status=done&style=none&taskId=udbcb2de8-4b9d-4d49-aac1-28f916b740b&title=&width=388)<br />运行
  2. ```javascript
  3. node index.js

结果如下:
image.png

2. 收集依赖

上一步开发的函数可以单独解析某一个模块,这一步我们需要开发一个函数从入口模块开始根据依赖关系进行递归解析,最后将依赖关系构成为依赖图(Dependency Graph)

// 编译入口文件,非递归遍历AST依赖树,将所有文件解析后生成平铺的映射对象
/**
 * 模块解析
 * @param {*} file 
 * @returns 
 */
function parseModules(file) {
  const entry = getModuleInfo(file);
  const temp = [entry];
  const depsGraph = {};

  getDeps(temp, entry);

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

/**
 * 获取依赖
 * @param {*} temp 
 * @param {*} param1 
 */
function getDeps(temp, { deps }) {
  Object.keys(deps).forEach((key) => {
    const child = getModuleInfo(deps[key]);
    temp.push(child);
    getDeps(temp, child);
  });
}

3. 生成bundle文件

这一步我们需要将刚才编写的执行函数和依赖图合成起来,输出最后的打包文件。

JSON.stringify JSON.stringify(value[, replacer [, space]]) value:将要序列化成 一个 JSON 字符串的值。 replacer 可选:如果该参数是一个函数,则在序列化过程中,被序列化的值的每个属性都会经过该函数的转换和处理;如果该参数是一个数组,则只有包含在这个数组中的属性名才会被序列化到最终的 JSON 字符串中;如果该参数为 null 或者未提供,则对象所有的属性都会被序列化。 space 可选:指定缩进用的空白字符串,用于美化输出(pretty-print);如果参数是个数字,它代表有多少的空格;上限为 10。该值若小于 1,则意味着没有空格;如果该参数为字符串(当字符串长度超过 10 个字母,取其前 10 个字母),该字符串将被作为空格;如果该参数没有提供(或者为 null),将没有空格。

// 打包,递归遍历require
// 执行依赖关系树的执行器和AST依赖关系树
function bundle(file) {
  const depsGraph = JSON.stringify(parseModules(file), null, 4);
  return `;(function (graph) {
        function require(file) {
            function absRequire(relPath) {
                return require(graph[file].deps[relPath])
            }
            var exports = {};
            (function (require,exports,code) {
                eval(code)
            })(absRequire,exports,graph[file].code)
            return exports
        }
        require('${file}')
    })(${depsGraph});`;
}

const entry = './src/index.js';
const content = bundle(entry);

console.log(content);

// 输出代码
!fs.existsSync("./dist") && fs.mkdirSync("./dist");
fs.writeFileSync("./dist/bundle.js", content);

运行 node index.js 命令
生成 dist/bundle.js 文件

;(function (graph) {
        function require(file) {
            if (!file) {
              return
            }
            function absRequire(relPath) {
                return require(graph[file].deps[relPath])
            }
            var exports = {};
            (function (require, exports, code) {
                eval(code);
            })(absRequire, exports, graph[file].code);
            return exports;
        }
        require('./src/index.js');
    })({
    "./src/index.js": {
        "deps": {
            "./add.js": "./src\\add.js"
        },
        "code": "\"use strict\";\n\nvar _add = _interopRequireDefault(require(\"./add.js\"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { \"default\": obj }; }\n\nconsole.log((0, _add[\"default\"])(1, 2));"
    },
    "./src\\add.js": {
        "deps": {},
        "code": "\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n  value: true\n});\nexports[\"default\"] = void 0;\n\nvar _default = function _default(a, b) {\n  return a + b;\n};\n\nexports[\"default\"] = _default;"
    }
});

最后可以编写一个简单的测试程序测试一下结果。

<script src="./dist/bundle.js"></script>

浏览器运行,控制台输出 3