手写Webpack源码

178 阅读5分钟

前言

本文主要介绍Webpack打包流程和其中的工作细节,会使用到BabelTypeScript等相关技术

有关Webpack基本使用请参考文章Webpack基本使用

环境搭建

  1. 初始化项目,执行命令
npm init -y
tsc --init
  1. 根目录下新建webpack.config.ts,作为webpack的配置文件,内容如下
import path from "node:path";

export default {
  entry: "./main.js",
  output: {
    filename: "bundle.js",
    path: path.resolve(process.cwd(), "dist")
  }
}

1、使用ESM导入模块(导入node模块会存在代码提示),因此使用process.cwd()而非__dirname

2、若导入path模块报错:Cannot find module 'node:path' or its corresponding type declarations.,执行npm install @types/node -D安装其类型声明文件即可,其他同类报错同理

  1. 根目录下新建main.jsapp.js用例文件
// main.js
import add from "./app.js";

var a = 1;

add(a, 2); // 3

// app.js
var a = 1;

const add = (a, b) => {
  return a + b;
}

export default add;
  1. 根目录下新建lib/index.ts文件
  2. 修改package.json
{
  "name": "webpack_source_code",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "nodemon lib/index.ts"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@types/node": "^20.12.7"
  }
}

Webpack Lib库

搭建框架

  1. 新建lib/build/index.ts用于编写打包相关的函数
import fs from "node:fs";
import path from "node:path";

export function createAssets(filePath: string) {
  console.log("Creating assets for", filePath);
  const content = fs.readFileSync(path.resolve(process.cwd(), filePath), "utf-8");
  console.log(content);
}
  1. 编写lib/index.ts文件
import config from "../webpack.config";

import { createAssets } from "./build";

createAssets(config.entry);
  1. 可以读取到配置文件内容和入口文件内容

处理依赖关系

  1. 使用Babel处理依赖关系

Babel又名巴别塔,目的是统一所有语言,主要作用是提供一个过程,例如JS AST --> Transform --> Generate

  1. 安装依赖@babel/parser@babel/traverse
// 分别用于生成 AST抽象语法树 和 遍历AST
npm install @babel/parser -D
npm install @babel/traverse -D
  1. 修改lib/build/index.ts文件处理入口文件及其依赖
import fs from "node:fs";
import path from "node:path";

import { parse } from "@babel/parser";
import traverse from "@babel/traverse";

function createAssets(filePath: string) {
  const content = fs.readFileSync(
    path.resolve(process.cwd(), filePath),
    "utf-8"
  );
  // sourceType: "module" is required for ES module syntax
  const ast = parse(content, { sourceType: "module" });
  const deps: string[] = [];
  traverse(ast, {
    ImportDeclaration: ({ node }) => {
      deps.push(node.source.value);
    }
  });
  return {
    filePath,
    deps
  };
}
// 数据结构 使用图的方式来表示
export function createGraph(entry: string) {
  const graph = createAssets(entry);
  const queue = [graph];
  for (let assets of queue) {
    assets.deps.forEach((dep: string) => {
      const child = createAssets(dep);
      queue.push(child);
    });
  }
  return queue;
}

构建输出文件模板

思考:若入口文件及全部依赖都打包到一个输出文件bundle.js中,会发生什么问题?

显而易见,变量重名是最直接的问题,此时可以使用函数来解决,大致模板如下:

function main() {
  import add from "./app.js";

  var a = 1;

  add(a, 2); // 3
}

function app() {
  var a = 1;

  const add = (a, b) => {
    return a + b;
  }

  export default add;
}

OK的,这样我们就解决了变量重名冲突的问题,同时接踵而来的问题是import只可以用于文件顶部,很显然不可以放在函数体内

cjs(CommonJS)可以帮助我们解决这个问题,所以接下来的工作是将依赖文件变为函数并且将esm变为cjs

  1. 根据上述思路,编写如下代码,具体思路是重写require函数、并使用立即执行函数执行
!(function (modules) {
  function require(path) {
    const fn = modules[path];
    const module = { exports: {} };
    fn(require, module, module.exports);
    return module.exports;
  }

  require("./main.js");
})({
  "./main.js": function (require, module, exports) {
    const add = require("./app.js");

    var a = 1;

    add(a, 2); // 3
  },
  "./app.js": function app(require, module, exports) {
    var a = 1;

    const add = (a, b) => {
      return a + b;
    }

    module.exports = add;
  }
});
  1. 编写模板

由于模板使用ejs编写,所以需要安装依赖ejs,执行命令

npm install ejs -D

模板如下:

!(function (modules) {
  function require(path) {
    const fn = modules[path];
    const module = { exports: {} };
    fn(require, module, module.exports);
    return module.exports;
  }

  require("<%- entry %>");
})({
  <% data.forEach(function(item) { %>
    "<%- item.filePath %>": function (require, module, exports) {
      <%- item.code %>
    },
  <% }); %>
});
  1. 由于上述模板需要使用变量dataentry等,所以需要更改lib/build/index.ts文件并在lib/index.ts中执行函数生成模板

新建lib/types/index.ts

export interface Graph {
  filePath: string;
  deps: string[];
  code?: string;
}

export interface Config {
  entry: string;
  output: {
    filename: string;
    path: string;
  }
}

修改lib/build/index.ts文件,添加build函数用于生成模板并输出到配置文件的指定目录下,同时需要获取到依赖文件的代码并将其转为cjs,此时需要使用到依赖@babel/core@babel/preset-env,需要提前安装

npm install @babel/core -D
npm install @babel/preset-env -D

更新后的代码如下:

import fs from "node:fs";
import path from "node:path";

import { parse } from "@babel/parser";
import traverse from "@babel/traverse";
import { transformFromAstSync } from "@babel/core";

import ejs from "ejs";

import { Graph, Config } from "../types";

function createAssets(filePath: string) {
  const content = fs.readFileSync(
    path.resolve(process.cwd(), filePath),
    "utf-8"
  );
  // sourceType: "module" is required for ES module syntax
  const ast = parse(content, { sourceType: "module" });
  const deps: string[] = [];
  traverse(ast, {
    ImportDeclaration: ({ node }) => {
      deps.push(node.source.value);
    },
  });
  // 获取文件代码并且 esm --> cjs
  const source = transformFromAstSync(ast, undefined, {
    presets: ["@babel/preset-env"]
  });

  return {
    filePath,
    deps,
    code: source?.code as string
  };
}

// 其它代码...

export function build(graph: Graph[], config: Config) {
  const template = fs.readFileSync(path.resolve(process.cwd(), "lib/template/bundle.ejs"), "utf-8");
  const content = ejs.render(template, { data: graph, entry: config.entry });
  const filename = config.output.filename;
  const outputDir = config.output.path;
  fs.mkdirSync(outputDir, { recursive: true });
  fs.writeFileSync(path.resolve(outputDir, filename), content);
}

修改lib/index.ts执行build函数

import config from "../webpack.config";

import { createGraph, build } from "./build";

const graph = createGraph(config.entry);

build(graph, config);
console.log(graph);

优化输出文件模板

OK的,目前可以进行初步的打包了,但当前模板中的modules依赖映射是使用相对路径或者绝对路径来表示的(取决于编码者),相对路径会存在隐患,所以要将映射部分进行优化

优化后模板如下:

!(function (modules) {
  function require(path) {
    const [ fn, mapping ] = modules[path];
    const module = { exports: {} };
    function localRequire(relativePath) {
      return require(mapping[relativePath]);
    }
    fn(localRequire, module, module.exports);
    return module.exports;
  }

  require("<%- entry %>");
})({
  <% data.forEach(function(item) { %>
    "<%- item.id %>": [function (require, module, exports) {
      <%- item.code %>
    }, <%- JSON.stringify(item.mapping) %>],
  <% }); %>
});

其它代码请自行查阅源代码部分

Loader

Loader 实质上是一个函数,主要在打包前被调用

import { Config } from "../types";

export function Loader(source: string, config: Config) {
  const module = config.rules.module;
  module.forEach((item) => {
    if (Array.isArray(item.use)) {
      item.use.reverse().forEach((fn) => {
        source = fn(source);
      });
    } else {
      source = item.use(source);
    }
  })
  return source;
}

注意:若Loaderuse部分是函数数组,需要按照从后往前的顺序执行,并且将前一次的结果传给下一次

源代码

Webpack 手写源码