跳到主要内容

认识 eslint 插件

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

Rollup 是一款面向 ES 模块(ESM) 的打包工具。其核心优势是「摇树(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(),
],
};

更多的辅助工具

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

这些工具不会增加维护成本,反而能大幅提升团队协作效率、规范度、开发速度,还能规避很多隐性问题。

其他辅助/规范开发的工具。(鉴于自己造破轮的经验,还是记录一下的好,省的以后在电器时代发现了磨石起火还自以为是)

eslint 插件

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

现在才发现有时候真的是“磨刀不误砍柴工”,构建良好的工具可以辅助快速的开发。

之前比较忽略 eslint 插件的使用,现在发现还不错。是我肤浅了。

样式之谜

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

一直以来,我是不喜欢处理样式的。直到我遇见了麻烦。

直接在另一文件中使用一个库(以 enr 为例)的 scss 文件,可以这么做:

库的一些知识

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

嗯,怎么说呢。 也挺滑稽的。

写了很多的包,却不知道 'package.json' 下居然要加 "sideEffects": false 。 简直让人汗颜,就好像说一个士兵居然不知道怎么上膛。

移除讨厌的打印狗

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

在 npm 项目上使用 dog 时发现挺便捷,关键在使用时添加 xxx_dev=all 的启动环境变量,既可以在正式环境也观察到数据流转及发现错误的具体原因。

后来就在 web 项目中使用,发现效果尚可,其实不如 babel 的插件 babel-plugin-transform-remove-console 移除 console 彻底些。

布局的学习

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

先写了一个布局,发现,总是在 NextJs 中显示水合错误

现在想着使用 FlexBox 或是 Grid 来更改。