构建 ESLint 内存泄露检测插件入门:提升代码质量与防范运行时风险

前言

本文目的是介绍如何创建开发一个自定义规则 ESLint 插件。利用其能力,检测一些代码中可能存在的内存泄露并及时进行提示,避免潜在的后期影响。

本文实现其中一部分功能--检测事件监听器的使用是否存在内存泄露为例来演示基本的 ESLint 自定义规则插件开发的过程。用以帮助我们理解 ESLint 的运行原理,进而创建出一个满足自定义需求的 Lint 规则用于实际项目中。

背景

为什么要开发 ESLint 内存泄露检测插件?

  • 避免内存泄露潜在的后期影响,通过早期的 Lint 检测来规避这些问题,不仅能够减少内存泄露可能导致的运行时错误和系统崩溃,还能预防更严重的连锁反应。
  • 提升代码质量和维护效率。内存泄露往往难以追踪,一旦代码进入生产环境,问题的定位与修复会变得更加困难。通过引入ESLint内存泄露检测插件,我们能在开发阶段就识别出潜在的内存泄露代码,提前进行优化或重构,这样不仅可以维护代码库的健康,还可以极大减轻开发者的负担,避免在未来花费大量时间和资源去处理由内存泄露引发的问题。

图 1 内存泄露导致的应用崩溃

开发项目

  1. 安装对应包

ESLint官方为了方便开发者开发插件,提供了使用 Yeoman 模板用于生成包含指定框架结构的工程化目录结构。

复制代码
npm install -g yo generator-eslint
  1. 创建项目文件夹并初始化
kotlin 复制代码
$ mkdir custom-eslint-plugin
$ cd custom-eslint-plugin

$ yo eslint:plugin

? What is your name? 		
? What is the plugin ID? 
? Type a short description of this plugin: // 输入这个插件的描述
? Does this plugin contain custom ESLint rules? Yes // 这个插件包含自定义 ESLint 规则吗?
? Does this plugin contain one or more processors? No // 这个插件包含一个或多个处理器吗(用于处理 JS 以外的文件)
   create package.json
   create lib/index.js
   create README.md
   
$ npm i   // 安装项目依赖

这时候文件结构大致如下:

go 复制代码
.
├── README.md
├── docs // 使用文档
│   └── rules // 所有规则的文档
│       └── custom-rule.md // 具体规则文档
├── lib // eslint 规则开发
│   ├── index.js 导入导出规则
│   └── rules // 构建多个规则
│       └── custom-rule.js // 规则细节
├── package.json
└── tests // 单元测试
    └── lib
        └── rules
            └── custom-rule.js // 测试规则文件

AST

抽象语法树(Abstract Syntax Tree,AST)本质上是源代码的树形表示,它将代码分解为一系列节点,每个节点代表代码中的一个构造。它可以将代码抽象成树状数据结构,方便我们后续对代码进行进一步的分析检测。

不同编程语言的AST节点类型可能不同,但对于JavaScript来说,以下是一些常见的ESTree规范(一种用于表示JavaScript源代码的AST的规范)中的节点类型及其含义:

AST 部分节点类型

  1. Program - 整个程序的根节点,包含一个语句列表。
  2. FunctionDeclaration - 函数声明,包含函数名、参数列表和函数体。
  3. VariableDeclaration - 变量声明,包含声明的类型(var、let、const)和声明的变量列表。
  4. VariableDeclarator - 变量声明符,包含变量名和初始化表达式。
  5. ExpressionStatement - 表达式语句,包含一个表达式。
  6. CallExpression - 函数调用表达式,包含被调用的函数和传递给函数的参数列表。
  7. MemberExpression - 成员表达式,访问对象的属性或方法。
  8. Identifier - 标识符,代表变量名或者属性名,比较常用。
  9. Literal - 字面量,代表常量值,例如字符串、数字、布尔值等。
  10. BlockStatement - 代码块,包含一系列语句。
  11. ReturnStatement - 返回语句,包含返回的表达式。
  12. IfStatement - 条件语句,包含条件表达式和两个可能的分支(一个if块和一个else块)。
  13. ForStatement - for循环,包含初始化表达式、条件表达式、更新表达式和循环体。
  14. WhileStatement - while循环,包含条件表达式和循环体。
  15. DoWhileStatement - do...while循环,与 while 循环类似,但条件在循环体之后检查。
  16. BinaryExpression - 二元运算表达式,包含运算符和两个操作数。
  17. UnaryExpression - 一元运算表达式,包含运算符和一个操作数。
  18. UpdateExpression - 更新表达式,用于自增(++)或自减(--)操作。
  19. LogicalExpression - 逻辑运算表达式,比如逻辑与(&&)或逻辑或(||)。
  20. ConditionalExpression - 条件(三元)表达式,包含条件、真分支和假分支。
  21. ArrayExpression - 数组表达式,表示数组字面量。
  22. ObjectExpression - 对象表达式,表示对象字面量,包含一系列属性。

每个节点通常都会包含一些共有的属性,如 type 表示节点类型,loc 表示代码在原始文档中的位置信息等。节点还可能包含特定于节点类型的属性,例如 FunctionDeclaration 节点会包含 id、params 和 body 属性,可以根据需要选择合适的节点以及属性来进行逻辑的编写。

获取代码对应 AST 格式

推荐一个网站,它可以查看我们编写的代码对应的 AST 格式,比较清晰方便。在左 / 右侧选中一个值时,另一侧的对应区域也会高亮,方便查看具体结构细节,很方便我们编写自定义规则 ESLint 代码。

astexplorer.net/

ESLint 运行原理

ESLint 的大致运行原理如下:

  1. 解析代码:ESLint 使用一个解析器(默认为 espree,也可以配置为其他解析器如 babel-eslint 或 @typescript-eslint/parser)来将代码转化为抽象语法树。
  2. 深度遍历 AST:一旦代码被解析成 AST,ESLint 就会遍历这棵树。在遍历的过程中,它会触发与节点类型相对应的事件,ESLint会以「从上至下」再「从下至上」的顺序遍历每个选择器两次。
  3. 应用规则:ESLint 有大量的内置规则,并且可以通过插件添加更多规则。每个规则都定义了它关心的 AST 节点类型,以及当遇到这些节点时如何检查它们。当 ESLint 在遍历 AST 时遇到特定类型的节点,它将调用对应规则的回调函数。这些回调函数会检查节点是否符合规则定义的编码或风格标准。如果不符合,回调函数就会报告一个问题。
  4. 报告问题:当规则的回调函数发现代码不符合规定时,它会调用 context.report 方法来创建一个报告。这个报告包含了问题的位置、描述信息以及修复建议(如果有的话)。所有的问题报告会收集起来,并在 ESLint 运行结束时打印出来。
  5. 自动修复:ESLint 支持自动修复许多规则报告的问题。当你使用 --fix 选项运行 ESLint 时,它会尝试自动修复那些可修复的问题。修复是通过对 AST 进行变换并将变换后的代码输出来实现的。

开发自定义规则

一、修改默认模版

修改一下生成的模版文件,去除不必要的选项。

  1. meta:(object)包含规则的元数据。
  2. type:(string)表示规则的类型,是 problem、suggestion 或 layout 其中之一。
  • problem 意味着该规则正在识别将导致错误或可能导致混乱行为的代码。开发人员应该把它作为一个高度优先事项来解决。
  • suggestion 意味着该规则确定了一些可以用更好的方式完成的事情,但如果不改变代码,就不会发生错误。
  • layout 意味着该规则主要关心的是空白、分号、逗号和括号,所有决定代码外观的部分,而不是代码的执行方式。这些规则对代码中没有在 AST 中指定的部分起作用。
  1. docs:(object)核心规则必须存在此字段,而自定义规则则可自行选择与否。核心规则在 docs 中有特定的条目,而自定义规则可以包含任何需要的属性。以下属性仅适用于核心规则。
  • description:(string)在规则页面中提供规则的简短描述。
  • recommended:(boolean)表示在配置文件 中是否使用 extends: eslint:recommended 属性启用该规则。
  • url:(string)指定可以访问完整文档的链接(使代码编辑器能够在突出显示的规则违反上提供一个有用的链接)

在自定义规则或插件中,你可以省略 docs 或在其中包含你需要的任何属性。

  1. fixable:(string)是 code 或 whitespace,如果命令行上的 --fix 选项自动修复规则报告的问题。注意 :fixable 属性对于可修复规则强制性的。如果没有指定这个属性,ESLint 将在规则试图产生一个修复时抛出一个错误。如果规则不是可修复的,则省略 fixable 属性。
  2. create():返回一个对象,该对象具有 ESLint 调用的方法,对象的属性设为选择器,在遍历 JavaScript 代码的抽象语法树 AST 时 visit 节点,执行所有监听该选择器的回调。
  • 如果键是节点类型或选择器,ESLint 在「从上至下」 时调用该 visitor 函数。
  • 如果键是节点类型或选择器加 :exit,ESLint 在「从下至上」时调用该 visitor 函数。
  • 如果一个键是一个事件名称,ESLint 调用该 handler 函数进行代码链路分析。

更详细的配置说明可参考下面官方文档:

Custom Rule Tutorial - ESLint - Pluggable JavaScript Linter

二、编写自定义规则

如下图所示,开发一个 ESLint 的自定义规则可以通过观察代码对应的 AST 的具体结构来定位到涉及你的规则定义的部分,根据这些关键部分进而更好地书写规则。

开始编写规则,我们这里以一些情况下检测代码中未销毁的事件监听器为例子,可以定义如下规则代码:

原理 就是「依据 AST 解析的结果,做自定义规则针对性的检测,过滤出我们要选中的代码,然后对代码的值进行逻辑判断,最后通知报告代码是否有问题」

要是实现这个功能需要满足以下条件:

  • addEventListener 和 removeEventListener 要成对出现
  • 具体入参要一致,也就是代码中事件监听、注销的事件和函数要完全一致,比如上图中 click -> click handleClick -> handleClick

话不多说,直接上代码:

javascript 复制代码
/* 检测代码中是否存在未销毁的事件监听器的 ESLint 规则简单实现 */
module.exports = {
  meta: {
    type: "problem",
    docs: {
      description: "代码中注意及时销毁事件监听器等,避免内存泄露",
      recommended: false,
    },
    messages: {
      memoryLeakError: "代码中存在未销毁的事件监听器,可能引起内存泄露", // 报错类型以及提示
    },
    schema: [],
  },

  create: function (context) {
    const eventListenersMap = new Map();

    // 生成事件监听器的标识符
    function getListenerIdentifier(node) {
      return `${node.callee.object.name}_${node.arguments[0].value}_${node.arguments[1].name}`;
    }

    function checkForClear(node, clearMethod) {
      if (node.type !== "CallExpression") return;
      const callee = node.callee;

      if (
        callee.type === "MemberExpression" &&
        callee.property.name === clearMethod
      ) {
        const listenerIdentifier = getListenerIdentifier(node);
        eventListenersMap.delete(listenerIdentifier);
      }
    }

    function reportLeaks(refMap) {
      if (refMap.size !== 0) {
        // 如果存储事件监听器节点的 Map 不为空,就在这里报告问题。
        refMap.forEach((node, _) => {
          context.report({
            messageId: "memoryLeakError", // 对应上文定义的报错类型
            node,
          });
        });
      }
    }

    return {
      CallExpression: function (node) {
        const callee = node.callee;
        if (callee.type !== "MemberExpression") return;

        const functionName = callee.property.name;

        if (functionName === "addEventListener") {
          const listenerIdentifier = getListenerIdentifier(node);
          eventListenersMap.set(listenerIdentifier, node);
        }

        checkForClear(node, "removeEventListener");
      },

      "Program:exit": function () {
        reportLeaks(eventListenersMap);
      },
    };
  },
};

三、测试用例

为了对上述过程有一个更加直接和具体的理解,下面采用编写测试用例的方法来验证我们的 ESLint 自定义规则插件是否按照预期工作。这可以很好地验证规则正确性,而且通过实际的例子,可以更直观地看到规则是如何应用于代码的,更好理解规则触发的条件。

首先,我们需要准备一些测试代码样本,这些样本应该包括各种场景,以确保我们的规则能够正确地在预期的代码片段中报错,同时不会在不应该报错的代码上触发。然后,我们会编写两组测试用例:一组包含应该违反我们自定义规则的代码示例(即错误案例),另一组包含遵守规则的代码(即正确案例)。

实现上述的测试过程可以利用 ESLint 提供的 RuleTester 工具。这个工具允许我们根据一定的格式编写测试代码,并且会帮助我们断言规则的执行结果是否符合预期。通过一系列断言来检查规则是否能够在正确的地方报告错误,并且提供合适的错误信息。后续我们还可以用 IDE 调试打断点 debug 来观察代码的具体运行细节,方便理解和修改规则。如下代码所示:

scss 复制代码
"use strict";
const rule = require("../../../lib/rules/clean-the-listener.js"), // 引入你的自定义规则
  RuleTester = require("eslint").RuleTester;

const ruleTester = new RuleTester({
  parserOptions: {
    ecmaVersion: 7, // 默认支持语法为 es5
  },
});

// 运行测试用例
ruleTester.run("memory-leak", rule, {
  valid: [
    {
      code: `
        document.addEventListener('click', handleClick);
        document.removeEventListener('click', handleClick);
      `,
    },
    {
      code: `function notSettimeout() {
            setTimeout(()=>{},0)
        }`,
    },
    // 使用匿名函数添加事件监听器,并在同一作用域内移除
    {
      code: `
        document.addEventListener('click', function() {});
        document.removeEventListener('click', function() {});
      `,
    },
    // 在类方法中添加和移除事件监听器
    {
      code: `
        class MyClass {
          constructor() {
            this.handleClick = this.handleClick.bind(this);
            document.addEventListener('click', this.handleClick);
            document.removeEventListener('click', this.handleClick);
          }
          handleClick() {
            // handle click event
          }
        }
      `,
    },
    // 在函数作用域中添加和移除事件监听器
    {
      code: `
        function setupClickListener() {
          document.addEventListener('click', handleClick);
          return function cleanupClickListener() {
            document.removeEventListener('click', handleClick);
          }
        }
        const cleanup = setupClickListener();
        cleanup();
      `,
    },
  ],
  invalid: [
    {
      code: `
        function test() {
          function handleClick() {}
          document.addEventListener('click', handleClick);
          document.removeEventListener('mouseover', handleClick);
        }
  
        test();
          `,
      errors: [{ messageId: "memoryLeakError" }],
    },
    // 添加了事件监听器,但没有移除
    {
      code: `
        document.addEventListener('click', handleClick);
      `,
      errors: [{ messageId: "memoryLeakError" }],
    },
    // 添加了事件监听器,但移除了不同的回调函数
    {
      code: `
        document.addEventListener('click', handleClick);
        document.removeEventListener('click', handleOtherClick);
      `,
      errors: [{ messageId: "memoryLeakError" }],
    },
    // 删除了事件监听器但是使用了不同的事件类型
    {
      code: `
        document.addEventListener('click', handleClick);
        document.removeEventListener('mouseover', handleClick);
      `,
      errors: [{ messageId: "memoryLeakError" }],
    },
  ],
});

四、在 VSC 中调试 Node 文件

如果在插件中打 console.log 一步步看太慢了,效率比较低还不方便。而且 AST 中的节点信息比较复杂,很可能看不全信息,用 debugger 调试信息更全面,也可以观察具体我们写的自定义规则是怎么样一步步被执行的,调试效率会高很多。

具体在 VSC 中调试 Node 文件的步骤如下:

  1. 点击 VSC 下图中的按钮,会生成并打开一个文件 launch.json
  1. 在 launch.json 中写入如下代码,用于调试编写的 Node 文件
kotlin 复制代码
{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "node",
      "request": "launch",
      "name": "启动程序", // 调试界面的名称
      "program": "${workspaceFolder}/tests/lib/rules/clean-the-listener.js", // 运行项目文件的路径
      "args": [] // Node 文件参数
    }
  ]
}
  1. 在文件中左侧加入小红点,点击图中启动程序左边的绿箭头开始进入调试,我们就可以一步步调试自己的代码了,观察具体变量信息、运行细节等

(测试用例全部通过的话,如果还没在代码里打 console.log 控制台没有任何输出是正确的)

集成到项目

首先发布插件,ESLint 插件以 NPM 包的形式来引用:

  1. 登录 NPM: npm login
  2. 发布 NPM:npm publish

其次安装 NPM 包后引入,介绍两种方法:

  1. 引入插件按需写入规则
json 复制代码
// .eslintrc.json
{
  "plugins": ["custom-rule"],
    "rules": {
    "custom-rule/clean-the-listener": 1 
    /* off 或 0:表示不验证规则。
    warn 或 1:表示验证规则,当不满足时,给警告。
    error或 2 :表示验证规则,不满足时报错。 */
  }
}
  1. 使用 extends 关键字继承插件配置

当规则比较多的时候使用起来比较方便,不用一条条写:

json 复制代码
{
  "extends": ["custom-rule/clean-the-listener"],
}

故障排除

  1. 在测试开发 ESLint 的自定义规则的时候更新的插件包 B 可能是包 A 的一部分,这时候你想在已有的实际项目中测试你的自定义规则有没有生效,也就是新包 B,理论上有两种方法。使用 npm link 或者 overrides 方法,推荐前者实时进行本地调试比较稳而且方便快捷。具体 npm link 使用方法如下:
  • 进入本地 npm 包目录。
  • 运行 npm link 命令,它会将当前目录中的 npm 包注册到全局 npm 模块目录中。
  • 在实际项目目录中执行命令npm link 你的本地 npm 包包名,成功后如下所示。现在,你可以在项目中对已链接的本地 npm 包进行调试和修改。
  • 当你对本地npm包的代码进行了更改时,无需重新运行 npm link 命令,修改会立即生效。
  • 当完成本地 npm 包调试后,记得将链接断开。在项目目录中执行npm unlink 你的本地 npm 包包名命令取消链接。
  • 要查看已经通过npm link命令连接的包,可以使用以下方法:
  • 查看全局已链接的包:使用以下命令查看全局 npm 模块目录中顶层 npm 包,--depth=0 表明只显示顶级包,而不显示这些包的子依赖:
    • npm ls -g --depth=0 这将列出全局 npm 模块目录中的所有包,包括已经通过 npm link 连接的包。输出结果中,你可以找到以箭头 -> 标记的包,这些包是已经链接到全局模块目录中的包。
    • 查看项目已链接的包:npm ls --depth=0
  1. 已经应用了新发的包,但是功能没有生效?

可能有以下原因:

  • 如果你的插件在发布前需要构建 ,比如使用 Babel 转译等,确保你在做了更改后运行了「构建」过程。注意 dist 里面的文件要是最新 build 的,在你 tnmp publish 发包的之前需要构建一下。
  • 缓存问题:ESLint 在执行时可能会缓存一些结果,特别是如果你使用了 --cache 标志。试着清除 ESLint 缓存后再运行它,通常是通过删除 .eslintcache 文件或使用 eslint --cache --cache-location "./.eslintcache" 指定缓存位置后,删除指定的缓存文件。
  • Node.js 需要重新加载模块:当你对插件进行修改后,Node.js 可能没有重新加载已经缓存的模块版本。你可以尝试重启你的开发服务器或命令行工具,确保 Node.js 能够加载最新的插件代码。
  • IDE 缓存:如果你在一个集成开发环境(IDE)中工作,IDE 本身也可能缓存了一些信息,尝试着重启你的 IDE。

结语

以上就是开发一个基本的 ESLint rule 的基本流程,利用 ESLint 可以省掉后期一些不必要的麻烦,比如内存泄露检测。作者前端小白,有什么说的不到位的地方请大哥包涵,欢迎各位大哥指导。

相关推荐
Deepsleep.8 分钟前
前端性能优化面试回答技巧
前端·面试·性能优化
阿伟来咯~39 分钟前
vue3+Nest.js项目 部署阿里云
开发语言·javascript·ecmascript
不想上班只想要钱2 小时前
vue3使用<el-date-picker分别设置开始时间和结束时间时,设置开始时间晚于当前时间,开始时间早于结束时间,结束时间晚于开始时间
前端·javascript
Li_Ning212 小时前
为什么 Vite 速度比 Webpack 快?
前端·webpack·node.js
2501_915373882 小时前
Electron 入门指南
前端·javascript·electron
同志327133 小时前
用HTML+CSS做了一个网易云音乐客户端首页
前端·css
小猪欧巴哟3 小时前
pnpm install 安装项目依赖遇到 illegal operation on a directory, symlink 问题
前端·vue.js
独角仙梦境3 小时前
🚀🚀🚀学习这个思路,你也能手撸自己的专属vip脚手架🚀🚀🚀
前端
CJWbiu3 小时前
Github Action + docker 实现自动化部署
前端·自动化运维
关山3 小时前
在TS中如何在子进程中动态实例化一个类
前端