跳到主要内容

认识 eslint 插件

· 阅读需 8 分钟
泥豆君
哇!是泥豆侠,我们没救了

Rollup 是一款面向 ES 模块(ESM) 的打包工具。其核心优势是「摇树(Tree Shaking)」和打包产物简洁。

:::info 听人劝,吃饱饭

该插件并没有发布,原因有三:

  • 该需求不正常,这种垃圾插件发布了也不会有人下载使用
  • 插件本身对打包会造成性能影响
  • 使用 rollup 本身优秀的 Tree-Shaking 能力胜于画蛇添足

:::

一、打包核心原理​

1. 入口分析与依赖图构建​

Rollup 从配置文件中 input 配置的入口文件开始,通过静态分析 ES 模块的 import/export 语句,递归遍历所有依赖模块,不会执行代码(这是 Tree Shaking 的基础),最终构建出一个「模块依赖图」,记录所有模块的依赖关系、导出内容、导入内容。

2. 插件生命周期执行(核心扩展点)​

Rollup 本身核心功能有限,所有自定义转换、路径解析、文件加载等能力都依赖插件。在打包的各个阶段,Rollup 会按顺序调用插件的钩子函数,对模块进行处理(这部分是实现你需求的核心)。

3. AST 解析与转换​

Rollup 内置 acorn 解析器,会将每个模块的代码解析为 ESTree 规范的抽象语法树(AST)。所有代码的修改、替换、移除,都不是直接操作字符串(易出错),而是通过操作 AST 节点实现的,修改完成后再将 AST 转换回可执行的 JS 代码。

4. 树摇(Tree Shaking)​

基于 ES 模块的「静态特性」( import/export 只能在模块顶层,不能动态判断、不能嵌套在代码块中),Rollup 会分析模块依赖图,移除所有未被使用的导出内容和死代码,减少打包产物体积。你的需求 2、3 本质上是「增强版树摇」,需要自定义插件辅助实现。

5. 产物生成与输出​

根据配置文件 output 中的格式( es/cjs/umd 等)、输出路径、拆分策略等,将处理后的所有模块合并,生成最终的打包文件,若配置了 sourcemap ,还会生成对应的源码映射文件。

二、插件工作原理​

1. 插件的本质​

Rollup 插件是一个「返回钩子对象的函数」,函数可以接收插件配置参数,返回的对象中包含了 Rollup 打包生命周期的各种钩子, Rollup 在对应阶段会自动调用这些钩子。

javascript
function customRollupPlugin(options = {}) {
return {
// 插件名称,用于报错信息和日志标识,必填
name: 'custom-transform-plugin',
// 各种生命周期钩子
options(config) {
/* 修改打包配置 */
},
resolveId(source) {
/* 解析模块真实路径 */
},
transform(code, id) {
/* 转换模块代码,核心实现需求 */
},
generateBundle(outputOptions, bundle) {
/* 处理生成后的产物 */
},
};
}

2. 核心钩子​

  • options :打包开始前执行,用于修改 Rollup 原始配置(比如补充输出格式)。
  • resolveId :解析模块 ID(路径),用于处理别名、第三方包、虚拟模块(比如返回自定义模块 ID 屏蔽原始模块)。
  • load :根据模块 ID 加载模块内容,用于加载非 JS 文件、虚拟模块内容(比如返回自定义代码替换原始模块)。
  • transform :加载模块后、AST 分析前执行,接收模块代码和模块 ID,返回修改后的代码和 sourcemap。这是实现「代码转换 / 移除」的核心钩子,我们会在这里操作 AST 完成所有需求

3. 插件实现的核心技术​

要实现代码的精准转换 / 移除,不能直接用字符串替换(易出现边界错误,比如误匹配变量名),核心依赖 3 个工具库:

  • estree-walker :遍历 ESTree 规范的 AST 节点,方便找到需要修改 / 移除的节点。
  • magic-string :安全修改代码字符串,同时保留 sourcemap(避免打包后无法映射到源码,难以调试)。
  • @rollup/pluginutils :提供实用工具(比如 createFilter),用于过滤需要处理的文件(比如只处理 .js/.ts 文件,忽略 node_modules)

三、小插件: rollup 版移除打印狗​

1. 安装依赖​

# Rollup 内置,但单独安装可保证版本一致,用于解析代码为 AST
npm install --save-dev estree-walker magic-string @rollup/pluginutils acorn
备注

若需要处理 TypeScript 文件,还需要安装 @rollup/plugin-typescript 和 typescript,且自定义插件要放在 typescript 插件之后执行

2. 代码片段​

// 自定义 Rollup 插件:prod环境下代码转换与死代码移除
import acorn from 'acorn';
import { walk } from 'estree-walker';
import MagicString from 'magic-string';
import { createFilter } from '@rollup/pluginutils';

function prodCodeTransformPlugin(options = {}) {
// 过滤需要处理的文件:默认处理 .js/.ts 文件,忽略 node_modules
const filter = createFilter(
options.include || ['**/*.js', '**/*.ts'],
options.exclude || ['node_modules/**'],
);

// 判断是否为 production 环境
const isProduction = process.env.NODE_ENV === 'production';

return {
name: 'qqi-rollup-plugin-remove-dog', // 插件名称,必填
/**
* 核心转换钩子:处理每个模块的代码
* @param {string} code - 模块原始代码
* @param {string} id - 模块 ID(文件路径)
* @returns {object|void} - 修改后的代码和 sourcemap
*/
transform(code, id) {
// 1. 非 production 环境、无需处理的文件,直接返回
if (!isProduction || !filter(id)) return;

// 2. 初始化 MagicString,用于安全修改代码并保留 sourcemap
const magicStr = new MagicString(code);
// 3. 解析代码为 AST(ESTree 规范)
let ast;
try {
ast = acorn.parse(code, {
ecmaVersion: 'latest',
sourceType: 'module', // 按 ES 模块解析
ranges: true, // 保留节点的位置范围(start/end),方便 magicStr 操作
locations: true,
});
} catch (err) {
this.warn(`解析模块 ${id} 失败:${err.message}`);
return;
}

// 标记:是否存在 dun = false(用于后续移除 if (dun) 代码块)
let hasDunFalse = false;
// 存储需要移除的代码节点范围(避免重复操作)
const removeRanges = new Set();

// 4. 遍历 AST,处理所有需求
walk(ast, {
// 进入 AST 节点时触发(核心处理逻辑)
enter(node) {
/************************** 需求 1:转换 Dog 导入 **************************/
if (
node.type === 'ImportDeclaration' &&
node.source.value === '@qqi/log'
) {
// 遍历 import 声明的所有命名导入
node.specifiers.forEach(specifier => {
// 匹配:import { Dog } from '@qqi/log'
if (
specifier.type === 'ImportSpecifier' &&
specifier.imported.name === 'Dog'
) {
// 找到 Dog 对应的代码起始和结束位置,替换为 DogVirtual
const importedNameStart = specifier.imported.start;
const importedNameEnd = specifier.imported.end;
magicStr.overwrite(
importedNameStart,
importedNameEnd,
'DogVirtual',
);
}
});
}

/************************** 需求 2:移除 dog 相关导入和调用 **************************/
// 2.1 移除 import { dog } from 'xxx'
if (node.type === 'ImportDeclaration') {
let needRemoveImport = false;
// 标记是否只导入了 dog(若有其他导入,只移除 dog 对应的部分)
let onlyDogImport = node.specifiers.length === 1;

node.specifiers.forEach((specifier, index) => {
if (
specifier.type === 'ImportSpecifier' &&
specifier.imported.name === 'dog'
) {
if (onlyDogImport) {
// 整个 import 语句都移除
removeRanges.add(`${node.start}-${node.end}`);
needRemoveImport = true;
} else {
// 只移除 dog 对应的部分(处理逗号分隔)
const specStart = specifier.start;
const specEnd = node.specifiers[index + 1]
? node.specifiers[index + 1].start - 1
: node.end;
removeRanges.add(`${specStart}-${specEnd}`);
}
}
});

// 若标记了移除整个 import,直接跳过后续处理
if (needRemoveImport) return;
}

// 2.2 移除 dog('xxx')、dog.warn('xxx')、dog.error('xxx')
if (node.type === 'CallExpression') {
let isDogCall = false;

// 匹配:dog('xxx')(直接调用)
if (
node.callee.type === 'Identifier' &&
node.callee.name === 'dog'
) {
isDogCall = true;
}

// 匹配:dog.warn('xxx')、dog.error('xxx')(成员调用)
if (
node.callee.type === 'MemberExpression' &&
node.callee.object.type === 'Identifier' &&
node.callee.object.name === 'dog' &&
['warn', 'error', 'info', 'type'].includes(
node.callee.property.name,
)
) {
isDogCall = true;
}

// 标记需要移除的调用语句范围
if (isDogCall) {
removeRanges.add(`${node.start}-${node.end}`);
}
}

/************************** 需求 3:移除 dun 相关导出和 if 代码块 **************************/
// 3.1 匹配:export const dun = false
if (
node.type === 'ExportNamedDeclaration' &&
node.declaration &&
node.declaration.type === 'VariableDeclaration' &&
node.declaration.kind === 'const'
) {
node.declaration.declarations.forEach(declaration => {
if (
declaration.id.name === 'dun' &&
declaration.init &&
declaration.init.type === 'Literal' &&
(declaration.init.value === false ||
declaration.init.value === true)
) {
// 标记存在 dun = false,且移除该导出语句
hasDunFalse = true;
removeRanges.add(`${node.start}-${node.end}`);
}
});
}

// 3.2 匹配:if (dun) { ... }(仅当 hasDunFalse 为 true 时移除)
if (hasDunFalse && node.type === 'IfStatement') {
const testNode = node.test;
if (testNode.type === 'Identifier' && testNode.name === 'dun') {
// 移除整个 if 语句块
removeRanges.add(`${node.start}-${node.end}`);
}
}
},
});

// 5. 执行代码移除(处理所有标记的移除范围)
Array.from(removeRanges).forEach(range => {
const [start, end] = range.split('-').map(Number);
magicStr.remove(start, end);
});

// 6. 返回修改后的代码和 sourcemap
return {
code: magicStr.toString(),
map: magicStr.generateMap({ hires: true }), // 生成高精度 sourcemap
};
},
};
}

module.exports = prodCodeTransformPlugin;

3. 插件使用方式​

在 Rollup 配置文件(rollup.config.js)中引入并使用该插件:

const prodCodeTransform = require('./prodCodeTransformPlugin');
const { terser } = require('rollup-plugin-terser'); // 可选,production 环境压缩代码

module.exports = {
input: 'src/index.js', // 你的入口文件
output: {
file: 'dist/bundle.js',
format: 'es', // 输出 ES 模块
},
plugins: [
// 引入自定义插件,放在其他转换插件之后(比如 ts 插件)
prodCodeTransform(),
// 可选:production 环境压缩代码(建议添加)
process.env.NODE_ENV === 'production' && terser(),
],
};