Monorepo工程化实践:零配置实现跨包引用的Webpack插件开发指南

458 阅读4分钟

手把手带你写一个Webpack提效插件

前言

大家好,今天我想和大家分享如何从零开始写一个实用的 Webpack 提效插件(完整源码在文章最后).作为前端开发者,我们经常在 monorepo 项目中遇到一个问题:如何方便地在不同包之间相互引用代码?

比如,我们想这样引入其他包的代码:

// 使用路径别名引入
import { Button } from '@components/Button';
import { helper } from '@utils/helper';

// 直接引入其他包
import { add } from '@monorepo/b';

为了实现这种引入方式,我们来开发一个 MonorepoAliasPlugin 插件来自动处理这些路径别名。

为什么要写这个插件?

Monorepo 项目中,我们经常需要在不同的包之间相互引用代码。比如:

// 从 package-a 引入组件
import { Button } from 'package-a/components';

// 从公共库引入工具函数
import { utils } from '@common/utils';

但是默认情况下,Webpack 并不知道这些别名该如何解析。这就需要我们配置很多 alias:

// webpack.config.js
module.exports = {
  resolve: {
    alias: {
      'package-a': path.resolve(__dirname, '../packages/package-a'),
      'package-b': path.resolve(__dirname, '../packages/package-b'),
      '@common': path.resolve(__dirname, '../packages/common'),
    }
  }
}

这样的配置不仅繁琐,而且在新增包时都需要手动更新。因此我们需要一个插件来自动处理这些别名解析。


Webpack 插件是什么?

在开始写插件之前,我们先来了解下 Webpack 插件的基本概念:

  1. 插件的本质
  • Webpack 插件本质上是一个带有 apply 方法的类

  • 通过 apply 方法可以访问 Webpack 的整个生命周期

  • 插件可以监听构建过程中的各种事件,并进行干预

  1. 基本结构
class MyPlugin {
  constructor(options) {
    this.options = options;
  }

  apply(compiler) {
    // 插件逻辑
  }
}

了解完插件的基本结构,我们来实现把 tsconfig.jsonpaths 别名,自动注入到webpack.config.jsalias中的MonorepoAliasPlugin插件

开发 MonorepoAliasPlugin

项目目录:

monorepo-root/
├── packages/          # 存放各个项目包
│   ├── A/            # 测试项目 A
│   └── B/            # 测试项目 B
├── plugins/          # 插件目录
│   └── monorepo-alias-plugin/
│       ├── package.json
│       └── src/
│           └── index.js  # 插件代码
└── pnpm-workspace.yaml   # workspace 配置

1. 基础结构搭建

首先创建插件的基本结构:

const path = require('path');
const fs = require('fs');

class MonorepoAliasPlugin {
  constructor(options = {}) {
    // 验证必要的配置项
    if (!options.root) {
      throw new Error('必须提供 root 配置项,用于指定 monorepo 的根目录');
    }
    this.options = {
      root: options.root
    };
  }

  apply(compiler) {
    // webpack 会调用这个方法并传入 compiler 对象
    console.log('MonorepoAliasPlugin 已初始化');
    console.log('Monorepo 根目录:', this.options.root);
  }
}

module.exports = MonorepoAliasPlugin;

在根目录运行pnpm init安装依赖

2. 理解 Webpack 模块解析

在实现具体功能前,我们需要了解 Webpack 是如何解析模块的:

  1. 模块解析流程
  • Webpack 遇到 import/require 语句时会创建新的模块

  • 通过 NormalModuleFactory 处理模块的创建

  • 使用 resolver 解析模块路径

  1. 读取解析tsconfig中的路径别名
parseTsConfig(packagePath) {
    try {
      // 1. 构建 tsconfig.json 的完整路径
      const tsconfigPath = path.join(packagePath, 'tsconfig.json');
      
      // 2. 检查文件是否存在
      if (!fs.existsSync(tsconfigPath)) {
        console.log(`${packagePath} 目录下未找到 tsconfig.json 文件`);
        return {};
      }

      // 3. 读取并解析 tsconfig.json 文件
      const tsconfig = JSON.parse(fs.readFileSync(tsconfigPath, 'utf-8'));
      
      // 4. 获取 paths 和 baseUrl 配置
      const paths = tsconfig.compilerOptions?.paths;
      const baseUrl = tsconfig.compilerOptions?.baseUrl || '.';

      // 5. 如果没有 paths 配置,返回空对象
      if (!paths) {
        console.log(`${packagePath} 的 tsconfig.json 中没有 paths 配置`);
        return {};
      }

      console.log('成功读取 tsconfig.json:', {
        packagePath,
        paths,
        baseUrl
      });

      return { paths, baseUrl };
    } catch (error) {
      console.error(`解析 ${packagePath} 的 tsconfig.json 失败:`, error);
      return {};
    }
  }
  
  apply(compiler) {
    // 测试读取 tsconfig.json
    compiler.hooks.afterResolvers.tap('MonorepoAliasPlugin', () => {
      // 先测试读取当前包的 tsconfig.json
      const testPath = path.resolve(this.options.root, 'packages/A');
      const config = this.parseTsConfig(testPath);
      console.log('测试读取结果:', config);
    });
  }
}

我们在packages/A 目录下的 tsconfig.json 文件里创建一个简单的测试配置:

{
  "compilerOptions": {
    "baseUrl": "src",
    "paths": {
      "@/*": ["*"],
      "@components/*": ["components/*"]
    }
  }
}

运行结果: image.png

3.路径别名的解析和转换

...

  // 解析 tsconfig.json 文件的方法
  parseTsConfig(packagePath) {
    try {
      const tsconfigPath = path.join(packagePath, 'tsconfig.json');
      
      if (!fs.existsSync(tsconfigPath)) {
        console.log(`${packagePath} 目录下未找到 tsconfig.json 文件`);
        return {};
      }

      const tsconfig = JSON.parse(fs.readFileSync(tsconfigPath, 'utf-8'));
      const paths = tsconfig.compilerOptions?.paths;
      const baseUrl = tsconfig.compilerOptions?.baseUrl || '.';

      if (!paths) {
        console.log(`${packagePath} 的 tsconfig.json 中没有 paths 配置`);
        return {};
      }

      // 转换路径别名为 webpack 格式
      const aliases = {};
      Object.entries(paths).forEach(([aliasKey, pathArray]) => {
        if (!pathArray || !pathArray.length) return;

        const relativePath = pathArray[0];
        if (!relativePath || typeof relativePath !== 'string') return;

        try {
          // 移除路径中的通配符 /* 
          const cleanAlias = aliasKey.replace('/*', '');
          const cleanPath = relativePath.replace('/*', '');
          
          // 将相对路径转换为绝对路径
          aliases[cleanAlias] = path.resolve(
            packagePath,
            baseUrl,
            cleanPath
          );
          
          console.log(`转换别名: ${cleanAlias} -> ${aliases[cleanAlias]}`);
        } catch (error) {
          console.error(`处理别名 ${aliasKey} 的路径时出错:`, error);
        }
      });

      return aliases;
    } catch (error) {
      console.error(`解析 ${packagePath} 的 tsconfig.json 失败:`, error);
      return {};
    }
  }

  // 合并别名配置到 webpack 配置中
  mergeAliasIntoWebpackConfig(compiler, aliases) {
    compiler.options.resolve = {
      ...compiler.options.resolve,
      alias: {
        ...compiler.options.resolve?.alias,
        ...aliases
      }
    };
    console.log('Webpack 别名配置:', compiler.options.resolve.alias);
  }

  apply(compiler) {
    // 使用 initialize 钩子来设置别名
    compiler.hooks.initialize.tap('MonorepoAliasPlugin', () => {
      try {
        const packagePath = path.resolve(this.options.root, 'packages/A');
        const aliases = this.parseTsConfig(packagePath);
        this.mergeAliasIntoWebpackConfig(compiler, aliases);
      } catch (error) {
        console.error('MonorepoAliasPlugin 处理失败:', error);
      }
    });

    // 处理 @monorepo 前缀的模块解析
    compiler.hooks.normalModuleFactory.tap('MonorepoAliasPlugin', (normalModuleFactory) => {
      normalModuleFactory.hooks.beforeResolve.tap('MonorepoAliasPlugin', (resolveData) => {
        if (!resolveData) return;
        
        if (resolveData.request.startsWith('@monorepo/')) {
          const packageName = resolveData.request;
          const packagePath = path.resolve(this.options.root, 'packages', packageName.replace('@monorepo/', ''));
          
          if (fs.existsSync(packagePath)) {
            const packageJson = require(path.join(packagePath, 'package.json'));
            resolveData.request = path.resolve(packagePath, packageJson.main || 'src/index');
          }
        }
       
        return true;
      });
    });
  }
}

module.exports = MonorepoAliasPlugin;

我们在packages/A/src/componentspackages/A/src/utils目录地下创建Button.tshelper.ts这两个测试文件:

image.png

// /components/Button.ts
export const Button = {
  name: 'Button Component'
}; 

//utils/helper.ts
export const helper = {
  sayHello: () => console.log('Hello from helper!')
}; 

// src/index.ts入口文件
import { Button } from '@components/Button';
import { helper } from '@utils/helper';

console.log('Testing aliases:');
console.log(Button.name);
helper.sayHello();

输出:

image.png

4.添加所有包的支持

之前我们完成的部分只能处理packages/Atsconfig.json 里的path,所以我们来完善一下apply方法使其可以支持整个 workspace 中的所有包

...

  // 获取所有 workspace 包的路径
  getWorkspacePackages() {
    try {
      const pnpmWorkspacePath = path.join(this.options.root, 'pnpm-workspace.yaml');
      if (!fs.existsSync(pnpmWorkspacePath)) {
        throw new Error('未找到 pnpm-workspace.yaml 文件');
      }

      const workspaceContent = fs.readFileSync(pnpmWorkspacePath, 'utf-8');
      const packages = workspaceContent.match(/packages:[\s\S]*?- (.+)/g)
        ?.map(line => line.replace(/packages:|\s*-\s*/g, '').trim())
        .filter(Boolean) || [];

      return packages.map(pkg => {
        if (pkg.includes('/*')) {
          const basePath = pkg.replace('/*', '');
          const fullPath = path.join(this.options.root, basePath);
          return fs.readdirSync(fullPath)
            .map(dir => path.join(fullPath, dir))
            .filter(dir => fs.statSync(dir).isDirectory());
        }
        return [path.join(this.options.root, pkg)];
      }).flat();
    } catch (error) {
      console.error('读取 workspace 包失败:', error);
      return [];
    }
  }

  parseTsConfig(packagePath) {
    try {
      const tsconfigPath = path.join(packagePath, 'tsconfig.json');
      
      if (!fs.existsSync(tsconfigPath)) {
        return {};
      }

      const tsconfig = JSON.parse(fs.readFileSync(tsconfigPath, 'utf-8'));
      const paths = tsconfig.compilerOptions?.paths;
      const baseUrl = tsconfig.compilerOptions?.baseUrl || '.';

      if (!paths) {
        return {};
      }

      const aliases = {};
      Object.entries(paths).forEach(([aliasKey, pathArray]) => {
        if (!pathArray || !pathArray.length) return;

        const relativePath = pathArray[0];
        if (!relativePath || typeof relativePath !== 'string') return;

        try {
          const cleanAlias = aliasKey.replace('/*', '');
          const cleanPath = relativePath.replace('/*', '');
          
          aliases[cleanAlias] = path.resolve(
            packagePath,
            baseUrl,
            cleanPath
          );
        } catch (error) {
          console.error(`处理别名 ${aliasKey} 的路径时出错:`, error);
        }
      });

      return aliases;
    } catch (error) {
      console.error(`解析 ${packagePath} 的 tsconfig.json 失败:`, error);
      return {};
    }
  }

  mergeAliasIntoWebpackConfig(compiler, aliases) {
    compiler.options.resolve = {
      ...compiler.options.resolve,
      alias: {
        ...compiler.options.resolve?.alias,
        ...aliases
      }
    };
  }

  apply(compiler) {
    // 处理所有包的 tsconfig 别名
    compiler.hooks.initialize.tap('MonorepoAliasPlugin', () => {
      try {
        // 获取所有 workspace 包
        const packages = this.getWorkspacePackages();
        const workspaceAliases = {};

        // 处理每个包的 tsconfig.json
        packages.forEach(packagePath => {
          const aliases = this.parseTsConfig(packagePath);
          Object.assign(workspaceAliases, aliases);
        });

        // 合并所有别名配置
        this.mergeAliasIntoWebpackConfig(compiler, workspaceAliases);
        console.log('已注入所有包的别名配置');
      } catch (error) {
        console.error('MonorepoAliasPlugin 处理失败:', error);
      }
    });

    // 处理 @monorepo 前缀的模块解析
    compiler.hooks.normalModuleFactory.tap('MonorepoAliasPlugin', (normalModuleFactory) => {
      normalModuleFactory.hooks.beforeResolve.tap('MonorepoAliasPlugin', (resolveData) => {
        if (!resolveData) return true;
        
        if (resolveData.request.startsWith('@monorepo/')) {
          const packageName = resolveData.request;
          const packagePath = path.resolve(
            this.options.root, 
            'packages', 
            packageName.replace('@monorepo/', '')
          );
          
          if (fs.existsSync(packagePath)) {
            const packageJson = require(path.join(packagePath, 'package.json'));
            resolveData.request = path.resolve(
              packagePath, 
              packageJson.main || 'src/index'
            );
          }
        }
        
        return true;
      });
    });
  }
}

module.exports = MonorepoAliasPlugin;

现在我们的MonorepoAliasPlugin可以:

  • 自动读取所有 workspace 包中的 tsconfig.json

  • 解析所有包中的路径别名配置

  • 支持 @monorepo 前缀的模块引用(前缀自定义,也可改成packages等)

  • 处理通配符路径

5.测试插件

创建packages/B测试包

image.png

// math.ts
export const add = (a: number, b: number) => a + b;
export const multiply = (a: number, b: number) => a * b;

//ts.config.json
{
  "compilerOptions": {
    "target": "es5",
    "module": "commonjs",
    "baseUrl": "src",
    "paths": {
      "@math/*": ["utils/*"]
    },
    "esModuleInterop": true,
    "skipLibCheck": true
  }
}

//package.json
{
  "name": "@monorepo/b",
  "version": "1.0.0",
  "main": "src/index.ts"
}

image.png

6.使用方法

  1. 安装插件:
{
  "dependencies": {
    "@monorepo/alias-plugin": "workspace:*"
  }
}
  1. 配置webpack
// webpack.config.js
const MonorepoAliasPlugin = require('@monorepo/alias-plugin');

module.exports = {
  plugins: [
    new MonorepoAliasPlugin({
      root: path.resolve(__dirname, "../..") // 指向 monorepo 根目录
    })
  ]
}

总结

通过MonorepoAliasPlugin插件的开发,我们可以:

  • 自动处理 tsconfig.json 中的路径别名

  • 支持使用 @monorepo 前缀引用其他包

  • 不需要手动配置 webpack 的 alias

这样可以让我们在 monorepo 项目中更方便地管理和引用代码。希望这篇文章对你有帮助!完整源码地址:github.com/JiMei-Unive…

如果有任何问题,欢迎讨论~