Webpack核心原理
发布于 3 年前 作者 jing51 3095 次浏览 来自 分享

Webpack解决的问题

Webpack做的事情简单来说就一件事情打包,先看下边这段简单的代码



这段代码不能在浏览器中直接运行,因为浏览器不支持运行带有import和export关键字的代码,现代浏览器可以通过<script type=module>来支持,但是ie浏览器不支持,当项目比较大的时候我们对于代码拆分为多个文件的需求有很多,所以我们对于这个问题急需要解决。
webpack就是提供给解决这个问题的一个方案:把关键字转译为普通代码,并把所有文件打包成一个文件。

babel原理

要将代码打包webpack就需要借助babel对代码进行解析、转译等等工作。

babel工作步骤

babel转译代码分为三个步骤:

  1. 解读代码生成ast树
  2. 遍历ast树修改树节点属性生成新的ast树
  3. 通过ast生成代码

简单案例

通过一个简单案例,来理解下babel转换的过程,这个例子是个简单的将let 转化为var的过程:

import { parse } from "[@babel](/user/babel)/core";
import traverse from "[@babel](/user/babel)/traverse";
import generate from "[@babel](/user/babel)/generator";

const code = `let a = 'let'; let b = 2`;
const ast = parse(code, { sourceType: "module" });

if (ast) {
  traverse(ast, {
    enter: (item) => {
      if (item.node.type === 'VariableDeclaration') {
        if (item.node.kind === "let") {
          item.node.kind = "var";
        }
      }
    },
  });
  const result = generate(ast, {}, code);
  console.log(result.code);
}

先使用parse库将代码转换为ast树,然后通过traverse库遍历ast树,将所有类型声明语句中的类型从var改成let,最终结果为:

babel库

import的几个库就是对应babel的转换过程:
生成ast树 @babel/core
遍历ast树 @babel/traverse
ast树生成代码 @babel/generator

AST

AST是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,将上边的ast对象打印出来格式如下:

ast是一个对象格式,program就是对代码做的整体抽象,例子中有两条声明语句,所以这里body数组中就是两个节点对象。

type属性表示这个语句的类型,例子中是个变量声明,kind表示这个语句用的是var声明变量。declarations语句由是一个节点数组,因为一个变量声明语句可以声明多个变量,declarations数组中的节点结构如下图:

es6转es5案例

一个频繁使用babel的场景就是将js的新特性转换成低版本的js代码,要将新版本js转换成低版本js需要使用@babel/preset-env库,告诉babel新老版本js之间的转换关系。

import * as fs from "fs";
import  { parse } from "[@babel](/user/babel)/core";
import  * as babel from "[@babel](/user/babel)/core";

const code = fs.readFileSync("./test.js").toString();
const ast = parse(code, { sourceType: "module" });
if (ast) {
  const result = babel.transformFromAstSync(ast, code, {
    presets: ["[@babel](/user/babel)/preset-env"],
  });
  if (result?.code) {
    fs.writeFileSync("./test.es5.js", result.code);
  }
}

test.js原代码:

let a = "let";
let b = 2;
const c = 3;
function sum() {
  let a = 1,
    b = 1;
  return a + b;
}

转义后的代码:

"use strict";

var a = "let";
var b = 2;
var c = 3;

function sum() {
  var a = 1,
      b = 1;
  return a + b;
}

打包文件

处理文件依赖关系

案例

通过一个简单的例子来了解webpack是怎么将几个文件的代码打包成一个文件。

代码结构

project_1/index.js

import a from "./a.js";
import b from "./b.js";
console.log(a.value + b.value);

project_1/a.js

const a = {
    value: 1,
}
export default a

project_1/b.js

const b = {
    value: 2,
}
export default b
打包文件代码
import * as babel from "[@babel](/user/babel)/core";
import { parse } from "[@babel](/user/babel)/parser";
import traverse from "[@babel](/user/babel)/traverse";
import { readFileSync, writeFileSync } from "fs";
import { resolve, relative, dirname } from "path";

// 设置根目录
const projectRoot = resolve(__dirname, "project_2");

interface Dep {
  key: string;
  deps: string[];
  code: string;
}

// 类型声明
type DepRelation = Dep[];
// 初始化一个空的 depRelation,用于收集依赖
const depRelation: DepRelation = []; // 数组!

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

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

/**
 *
 * [@param](/user/param) filepath 文件绝对地址
 */
function collectCodeAndDeps(filepath: string) {
  const key = getProjectPath(filepath); // 文件的项目路径,如 index.js
  if (depRelation.find((i) => i.key === key)) {
    // 注意,重复依赖不一定是循环依赖
    return;
  }
  // 获取文件内容,将内容放至 depRelation
  const code = readFileSync(filepath).toString();
  // 初始化 depRelation[key]
  // 将代码转为 AST
  const transformCode = babel.transform(code, {
    presets: ["[@babel](/user/babel)/preset-env"],
  });
  const es5Code = transformCode?.code;
  if (!es5Code) {
    return;
  }
  const item: Dep = { key, deps: [], code: es5Code };
  depRelation.push(item);
  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, "/");
}

代码思路

depRelation是依赖数组,存储所有的文件依赖,第一个元素是入口文件的依赖,depRelation数据项格式{ deps: [‘依赖的文件路径’], code: ‘文件的源码’ }
collectCodeAndDeps是处理文件依赖的主函数,主要步骤是:

  1. 判断文件路径是否已经存在依赖数组中,如果已经存在不再重复处理
  2. 根据文件路径读取源代码
  3. 将源代码准换ast树
  4. 通过@babel/preset-env将代码转义然后存在依赖数据项的code属性中
  5. 遍历ast树,找到import语句,存入deps属性中,然后取出import路径,递归调用collectCodeAndDeps

打包后代码执行

最终需要通过生成的依赖关系数组对象,生成可以执行的代码,生成最终代码的函数如下:

function generateCode() {
  let code = "";
  code +=
    "var depRelation = [" +
    depRelation
      .map((item) => {
        const { key, code, deps } = 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;
}
  1. 通过依赖关系数组拼装依赖对象,将code替换为方法
  2. 使用modules对象存储各个文件export出去的内容
  3. 自定义require函数替换每个文件中require方法,用modules对象替换每个文件的export方法对象
  4. pathToKey方法处理"./"这种import

最终代码

import * as babel from "[@babel](/user/babel)/core";
import { parse } from "[@babel](/user/babel)/parser";
import traverse from "[@babel](/user/babel)/traverse";
import { readFileSync, writeFileSync } from "fs";
import { resolve, relative, dirname } from "path";

// 设置根目录
const projectRoot = resolve(__dirname, "project_2");

interface Dep {
  key: string;
  deps: string[];
  code: string;
}

// 类型声明
type DepRelation = Dep[];
// 初始化一个空的 depRelation,用于收集依赖
const depRelation: DepRelation = []; // 数组!

// 将入口文件的绝对路径传入函数,如 D:\demo\fixture_1\index.js
collectCodeAndDeps(resolve(projectRoot, "index.js"));
function generateCode() {
  let code = "";
  code +=
    "var depRelation = [" +
    depRelation
      .map((item) => {
        const { key, code, deps } = 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;
}
writeFileSync("dist_2.js", generateCode());
console.log(depRelation);
console.log("done");

/**
 *
 * [@param](/user/param) filepath 文件绝对地址
 */
function collectCodeAndDeps(filepath: string) {
  const key = getProjectPath(filepath); // 文件的项目路径,如 index.js
  if (depRelation.find((i) => i.key === key)) {
    // 注意,重复依赖不一定是循环依赖
    return;
  }
  // 获取文件内容,将内容放至 depRelation
  const code = readFileSync(filepath).toString();
  // 初始化 depRelation[key]
  // 将代码转为 AST
  const transformCode = babel.transform(code, {
    presets: ["[@babel](/user/babel)/preset-env"],
  });
  const es5Code = transformCode?.code;
  if (!es5Code) {
    return;
  }
  const item: Dep = { key, deps: [], code: es5Code };
  depRelation.push(item);
  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, "/");
}

回到顶部