Webpack 到底是如何打包的?

1,294 阅读4分钟

首先还是一段官网的描述:

webpack 是一个用于现代 JavaScript 应用程序的静态模块打包工具。当 webpack 处理应用程序时,它会在内部构建一个 依赖图(dependency graph),此依赖图对应映射到项目所需的每个模块,并生成一个或多个 bundle。

打包,是指处理某些文件并将其输出为其他文件的能力。

下面以一个简单的 demo 来分析 webpack 是如何做打包的。

首先在 src 下新建三个文件:

// index.js
import message from "./message.js";
console.log(message);
// message.js
import { word } from "./word.js";
const message = `say ${word}`;
export default message;
// word.js
export const word = "hello";

src 下文件肯定不能直接在浏览器运行,因为不识别 import 等语法,所以需要对其编译打包。

下面直接进入今天的主题,编写一个打包文件 bundler.js,通过运行 node bundler.js 来模拟 webpack 的打包流程。

Step1 读取项目的入口文件

首先,要对文件进行打包,第一步肯定是读取入口文件

// bundler.js
const fs = require("fs");

const moduleAnalyser = (filename) => {
  // step1 通过 nodejs fs 模块,获取文件内容
  const content = fs.readFileSync(filename, "utf-8");
  console.log(content);
};

// 传入入口文件,进行分析
moduleAnalyser("./src/index.js");
$ node bundler.js
import message from "./message.js";
console.log(message);

Step2 分析入口文件中的代码

安装 npm i @babel/parser 来分析源代码

// bundler.js
const fs = require("fs");
const paser = require("@babel/parser");

const moduleAnalyser = (filename) => {
  // step1 通过 nodejs fs 模块,获取文件内容
  const content = fs.readFileSync(filename, "utf-8");

  // step2 paser.parse 将 JS代码转化成抽象语法树 AST (js对象)
  const ast = paser.parse(content, {
    sourceType: "module",
  });
  // 通过 AST 可以找到声明的语句 "import",就能找到依赖关系了
  // 可以看到 ast.program.body 里面,可以分析出节点对应语句的类型,如 type: 'ImportDeclaration'
  console.log("ast", ast.program.body);
};

// 传入入口文件,进行分析
moduleAnalyser("./src/index.js");
body image

这里推荐一个高亮显示代码的小工具 npm i highlight -g 高亮显示代码的工具 运行 node bundler.js | highlight 即可

Step3 获取模块依赖关系

安装 npm i @babel/traverse 快速找到依赖文件(通过 import 节点)

// bundler.js
const fs = require("fs");
const path = require("path");
const paser = require("@babel/parser");
const traverse = require("@babel/traverse").default;

const moduleAnalyser = (filename) => {
  // step1 通过 nodejs fs 模块,获取文件内容
  const content = fs.readFileSync(filename, "utf-8");

  // step2 paser.parse 将 JS代码转化成抽象语法树 AST (js对象)
  const ast = paser.parse(content, {
    sourceType: "module",
  });

  // step3 存放依赖对象
  const dependencies = {};
  // traverse 方法可以快速找到import节点
  traverse(ast, {
    // 遍历AST, 找出type是ImportDeclaration的元素,会执行下面的函数
    ImportDeclaration({ node }) {
      const dirname = path.dirname(filename); // './src'
      const newFile = "./" + path.join(dirname, node.source.value); // ./src/message.js
      dependencies[node.source.value] = newFile; // 以键值对的形式存储,便于后续打包
      console.log("dependencies", dependencies); // { './message.js': './src/message.js' }
    },
  });
};

// 传入入口文件,进行分析
moduleAnalyser("./src/index.js");

Step4 将 AST 编译成浏览器可以运行的代码

安装 npm i @babel/core

其中 transformFromAst 方法需要配置 presets,安装:npm install @babel/preset-env

// bundler.js
const fs = require("fs");
const path = require("path");
const paser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const babel = require("@babel/core");

const moduleAnalyser = (filename) => {
  // step1 通过 nodejs fs 模块,获取文件内容
  const content = fs.readFileSync(filename, "utf-8");

  // step2 paser.parse 将 JS代码转化成抽象语法树 AST (js对象)
  const ast = paser.parse(content, {
    sourceType: "module",
  });

  // step3 存放依赖对象
  const dependencies = {};
  // traverse 方法可以快速找到import节点
  traverse(ast, {
    // 遍历AST, 找出type是ImportDeclaration的元素,会执行下面的函数
    ImportDeclaration({ node }) {
      const dirname = path.dirname(filename); // './src'
      const newFile = "./" + path.join(dirname, node.source.value); // ./src/message.js
      dependencies[node.source.value] = newFile; // 以键值对的形式存储,便于后续打包
    },
  });

  // step4 将AST编译成浏览器可以运行的代码
  const { code } = babel.transformFromAst(ast, null, {
    presets: ["@babel/preset-env"],
  });
  console.log("code", code);

  // 将模块分析结果返回
  return {
    filename,
    dependencies,
    code,
  };
};

// 传入入口文件,进行分析
const moduleInfo = moduleAnalyser("./src/index.js");
console.log("moduleInfo", moduleInfo);
code print

Step5 递归解析依赖项,生成依赖图谱 DependencyGragh

// step5 对所有模块进行分析,递归分析依赖结果
const getDependenciesGragh = (entry) => {
  // 获取入口文件分析结果,存入一个数组
  const entryModule = moduleAnalyser(entry); // { filename, dependencies, code}
  const graphArray = [entryModule];
  // 递归开始
  for (let i = 0; i < graphArray.length; i++) {
    const { dependencies } = graphArray[i]; //  { './message.js': './src/message.js' }
    if (dependencies) {
      for (let j in dependencies) {
        graphArray.push(moduleAnalyser(dependencies[j]));
        // 此时graphArray长度+1,继续循环遍历,直到把所有依赖一层层push进graghArray,递归结束
      }
    }
  }
  console.log("graphArray: ", graphArray);
  // 格式转化
  const dependencyGraph = {};
  graphArray.forEach((item) => {
    dependencyGraph[item.filename] = {
      dependencies: item.dependencies,
      code: item.code,
    };
  });
  return dependencyGraph;
};

// 传入入口文件,进行分析
const dependencyGraph = getDependenciesGragh("./src/index.js");

// 至此入口文件分析完成,我们打印依赖图谱看看效果~
console.log("dependencyGraph: ", dependencyGraph);
graghArray dependencyGraph

Step6 通过 dependencyGraph 生成可以在浏览器运行的代码

前面已经完成了入口文件的分析完成,并且生成了需要的依赖图谱。接下来就是如何根据依赖图谱生成可以在浏览器运行的代码。

这步不是很好理解,下面我们分步骤解析~

1. 首先创建一个 generateCode 函数

目标是创建一个函数 generateCode,返回在浏览器运行的代码,返回的结果是一个字符串

// step6 通过 dependencyGraph 生成可以在浏览器运行的代码
const generateCode = (entry) => {
  const graph = JSON.stringify(getDependenciesGragh(entry)); // 对象转字符串
  // 网页中的代码,都要在一个闭包中执行,避免污染全局环境,基本格式如下
  return `
  (function(graph){
    
  })(${graph})
  `;
};

const code = generateCode("./src/index.js");
console.log("code: ", code);

打印看一下 code~

getcode

2. 在依赖图谱中找到对应文件内容,执行代码

接下来需要在依赖图谱中通过 entry 找到对应文件内容,执行 code 里面的代码

index.js 中通过前文编译后的代码是这样的

"use strict";
var _message = _interopRequireDefault(require("./message.js"));
function _interopRequireDefault(obj) {
  return obj && obj.__esModule ? obj : { default: obj };
}
console.log(_message["default"]);

可以看到文件内容包含 require、exports 对象,这在浏览器中是无法识别与运行的

3. 创建 require 函数和 exports 对象

// step6 通过 dependencyGraph 生成可以在浏览器运行的代码
const generateCode = (entry) => {
  const graph = JSON.stringify(getDependenciesGragh(entry)); // 对象转字符串
  //网页中的代码,都要在一个闭包中执行,避免污染全局环境,基本格式如下
  return `
  (function(graph){
    // 1. 创建一个require函数,根据依赖图谱,通过entry找到对应文件内容,通过eval()执行代码
    function require(module){ 
      (function(code){
        eval(code)
      })(graph[module].code)
    }
    require('${entry}') // 注意加引号,是字符串拼接不是运行
  })(${graph})
  `;
};

通过 eval(code) 执行代码时,里面也会调用 require 方法: require("./message.js"),但 require 入参 module 需要的是绝对路径(dependenciesGragh 里面存的 key 是绝对路径),而此时传入的 ./message.js 是相对路径

4. 完善 require 函数

自定义一个 localRequire 方法,进行“相对路径”的进行转换

// step6 通过 dependencyGraph 生成可以在浏览器运行的代码
const generateCode = (entry) => {
  const graph = JSON.stringify(getDependenciesGragh(entry)); // 对象转字符串
  //网页中的代码,都要在一个闭包中执行,避免污染全局环境,基本格式如下
  return `
  (function(graph){
    // 3. 在依赖图谱中,dependencies字段就存储了依赖的相对路径对应的绝对路径值
    function localRequire(relativePath){
      // 4. 然后再调用创建的require函数
      return require(graph[module].dependencies[relativePath])
    }

    // 1. 创建一个require函数,根据依赖图谱,通过entry找到对应文件内容,通过eval()执行代码
    function require(module){ 
      (function(require, code){
        eval(code) // 2. 执行到require方法,先调用内部定义的localRequire,参数是相对路径 ./message.js
      })(localRequire, graph[module].code)
    }

    require('${entry}')
  })(${graph})
  `;
};

5. 完善 exports 对象

定义一个 exports 对象并传入立即执行函数,它也会捕获执行代码中的 exports 并写入定义的 exports 空对象中。下面就是完整的 generateCode 方法啦!

// step6 通过 dependencyGraph 生成可以在浏览器运行的代码
const generateCode = (entry) => {
  const graph = JSON.stringify(getDependenciesGragh(entry));
  return `
  (function(graph){
    function require(module){
      function localRequire(relativePath){
        return require(graph[module].dependencies[relativePath])
      }
      var exports = {};
      (function(require,exports,code){
        eval(code)
      })(localRequire,exports,graph[module].code)
      return exports;
    }
    require('${entry}')
  })(${graph});
  `;
};

const code = generateCode("./src/index.js");
console.log("code: ", code);

至此我们完成了从项目入口文件到生成浏览器可识别代码的全过程~

Step7 输出文件到 dist 目录下

下面将文件输出到dist目录下。这里我们简单写一下配置文件,只配置输入输出文件路径即可

// webpack.config.js
const path = require("path");

module.exports = {
  entry: {
    index: "./src/index.js",
  },
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "bundle.js",
  },
};

将 step6 中生成的可以在浏览器运行的代码 code,写入到指定目录下

const config = require("./webpack.config");
const filePath = path.join(config.output.path, config.output.filename);

fs.writeFileSync(filePath, code, "utf-8");

完整 bundler.js 代码

// bundler.js
const fs = require("fs");
const path = require("path");
const paser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const babel = require("@babel/core");
const config = require("./webpack.config");

const moduleAnalyser = (filename) => {
  // step1 通过 nodejs fs 模块,获取文件内容
  const content = fs.readFileSync(filename, "utf-8");

  // step2 paser.parse 将 JS代码转化成抽象语法树 AST (js对象)
  const ast = paser.parse(content, {
    sourceType: "module",
  });

  // step3 存放依赖对象
  const dependencies = {};
  // traverse 方法可以快速找到import节点
  traverse(ast, {
    ImportDeclaration({ node }) {
      const dirname = path.dirname(filename); // './src'
      const newFile = "./" + path.join(dirname, node.source.value); // ./src/message.js
      dependencies[node.source.value] = newFile;
    },
  });

  // step4 将AST编译成浏览器可以运行的代码
  const { code } = babel.transformFromAst(ast, null, {
    presets: ["@babel/preset-env"],
  });

  // 将模块分析结果返回
  return {
    filename,
    dependencies,
    code,
  };
};

// step5 对所有模块进行分析,递归分析依赖结果
const getDependenciesGragh = (entry) => {
  // 获取入口文件分析结果,存入一个数组
  const entryModule = moduleAnalyser(entry); // { filename, dependencies, code}
  const graphArray = [entryModule];
  // 递归开始
  for (let i = 0; i < graphArray.length; i++) {
    const { dependencies } = graphArray[i]; //  { './message.js': './src/message.js' }
    if (dependencies) {
      for (let j in dependencies) {
        graphArray.push(moduleAnalyser(dependencies[j]));
        // 此时graphArray长度+1,继续循环遍历,直到把所有依赖一层层push进graghArray,递归结束
      }
    }
  }
  // 格式转化
  const dependencyGraph = {};
  graphArray.forEach((item) => {
    dependencyGraph[item.filename] = {
      dependencies: item.dependencies,
      code: item.code,
    };
  });
  return dependencyGraph;
};

// step6 通过 dependencyGraph 生成可以在浏览器运行的代码
const generateCode = (entry) => {
  const graph = JSON.stringify(getDependenciesGragh(entry));
  return `
  (function(graph){
    function require(module){
      function localRequire(relativePath){
        return require(graph[module].dependencies[relativePath])
      }
      var exports = {};
      (function(require,exports,code){
        eval(code)
      })(localRequire,exports,graph[module].code)
      return exports;
    }
    require('${entry}')
  })(${graph});
  `;
};

const code = generateCode("./src/index.js");

// step7 输出文件到 dist 目录下
const filePath = path.join(config.output.path, config.output.filename);
fs.writeFileSync(filePath, code, "utf-8");

再次执行 node bundler.js,此时 dist 目录下会默认生成 bundle.js 文件。可以将这段代码直接 copy 到浏览器的控制台中运行,可以正确输出结果 say hello !

打包结果

看!是不是很神奇呢!

至此打包流程结束,大功告成!!撒花 ✿✿ ヽ(°▽°)ノ ✿ ✌️✌️✌️

DEMO 地址