手把手带你写一个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 插件的基本概念:
- 插件的本质
-
Webpack 插件本质上是一个带有
apply
方法的类 -
通过
apply
方法可以访问 Webpack 的整个生命周期 -
插件可以监听构建过程中的各种事件,并进行干预
- 基本结构
class MyPlugin {
constructor(options) {
this.options = options;
}
apply(compiler) {
// 插件逻辑
}
}
了解完插件的基本结构,我们来实现把 tsconfig.json
的 paths
别名,自动注入到webpack.config.js
的alias
中的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 是如何解析模块的:
- 模块解析流程
-
Webpack 遇到
import
/require
语句时会创建新的模块 -
通过
NormalModuleFactory
处理模块的创建 -
使用
resolver
解析模块路径
- 读取解析
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/*"]
}
}
}
运行结果:
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/components
和packages/A/src/utils
目录地下创建Button.ts
和helper.ts
这两个测试文件:
// /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();
输出:
4.添加所有包的支持
之前我们完成的部分只能处理packages/A
的 tsconfig.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
测试包
// 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"
}
6.使用方法
- 安装插件:
{
"dependencies": {
"@monorepo/alias-plugin": "workspace:*"
}
}
- 配置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…
如果有任何问题,欢迎讨论~