手把手教你用TypeScript写一个简单的eslint插件并发布到npm

1,728 阅读5分钟

引言

看到参考链接1以后,觉得用TS写一个eslint插件应该很简单🐔⌨️🍚,尝试下来确实如此。

前置知识

本文假设

  • 你对AST遍历有所了解。
  • 你写过单测用例。

作者:hans774882968以及hans774882968以及hans774882968

本文52pojie:www.52pojie.cn/thread-1742…

本文CSDN:blog.csdn.net/hans7748829…

本文juejin:juejin.cn/post/719651…

Usage

npm install -D @hans774882968/eslint-plugin-use-i18n
# or
yarn add -D @hans774882968/eslint-plugin-use-i18n

then in .eslintrc.js

module.exports = {
  plugins: [
    // other plugins ...
    '@hans774882968/use-i18n',
  ],
  extends: [
    // other extends ...
    'plugin:@hans774882968/use-i18n/all',
  ],
  rules: {
    // override other rules ...
    '@hans774882968/use-i18n/i18n-usage': ['error', {
      i18nFunctionNames: ['$i18n', '$t'],
    }],
  }
}

rules:

  • no-console
  • i18n-usage

Rule: i18n-usage

  • legal: $gt('abc'), $gt('hello {world}', null, { world: 'world' })
  • illegal: $gt(), $gt(12), $gt(1 + 2), $gt(null), $gt(undefined), $gt(x), $gt(x, null, {})

第一个eslint规则:no-console

为了简单,我们只使用tsc进行构建。首先package.json需要设置入口"main": "dist/index.js",tsconfig.json需要设置"outDir": "dist""include": ["src"]。接下来设计一下单元测试和构建命令:

"scripts": {
  "clean": "rm -Rf ./dist/",
  "build": "yarn clean && mkdir ./dist && tsc",
  "test": "jest",
  "test:help": "jest --help",
  "lint": "eslint \"src/**/*.{js,jsx,ts,tsx}\" \"test/**/*.{js,jsx,ts,tsx}\" \"*.{js,jsx,ts,tsx}\" --fix"
},

ESLintUtils.RuleTester写的.test.tsmocha或者jest都能运行,我选择了jest

当我们运行yarn lint时,node_modules/@eslint/eslintrc/lib/config-array-factory.jsthis._loadPlugin会加载插件,相当于在node环境运行上面指定的入口点dist/index.js。所以我们需要知道@eslint如何描述一个eslint插件,才能写出src/index.ts。查看this._loadPlugin可知,plugin.definition的类型应为:

type Plugin = {
  configs?: Record<string, ConfigData> | undefined;
  environments?: Record<string, Environment> | undefined;
  processors?: Record<string, Processor> | undefined;
  rules?: Record<...> | undefined;
}

结合参考链接1,我们得出结论:一般来说需要提供rulesconfigs属性。rules可以理解为具体的规则定义;configs可以理解为规则的集合,可以称为“最佳实践”,最常见的configsrecommended

于是写出src/index.ts

import rules from './rules';
import configs from './configs';

const configuration = {
  rules,
  configs
};

export = configuration;

src/rules/index.ts

import noConsole from './noConsole';

const allRules = {
  'no-console': noConsole
};

export default allRules;

src/configs/index.ts

import all from './all';
import recommended from './recommended';

const allConfigs = {
  all,
  recommended
};

export default allConfigs;

src/configs/all.ts

export default {
  parser: '@typescript-eslint/parser',
  parserOptions: { sourceType: 'module' },
  rules: {
    '@hans774882968/use-i18n/no-console': 'error'
  }
};

我们用createRule函数来创建一条规则,createRule函数定义如下:

import { ESLintUtils } from '@typescript-eslint/utils';

export default ESLintUtils.RuleCreator(
  () => 'rule documentation url'
);

createRule需要传一个对象,我列举一下这个对象常用的几个属性:

  • meta.schema:配置eslint规则的时候可以指定的options参数。通常传入的值为{}(不接收参数)和object[]
  • meta.messages:一个对象,{ messageId: text }
  • create方法:eslint需要建立AST并遍历,所以要拿到这个方法的返回值作为遍历AST的配置。输入参数是context对象,常用的方法有:context.options[0]获取传入的参数;context.getFilename()获取当前yarn lint正在解析的文件名;context.report函数向用户报错,通常这么用:context.report({ node, messageId: 'xxMessageId' })messageId必须符合meta.messages给出的定义。create方法返回的对象有点类似于@babel/traversetraverse方法的第二个参数,具体写法看参考链接1的项目就行。
import { TSESLint, ASTUtils } from '@typescript-eslint/utils';
import createRule from '../utils/createRule';
import path from 'path';
import multimatch from 'multimatch';

// 模仿babel中的写法 import { isIdentifier } from '@babel/types';
const {
  isIdentifier
} = ASTUtils;

const whiteList = ['memory'];

const rule = createRule({
  name: 'no-console',
  meta: {
    docs: {
      description: 'Remember to delete console.method()',
      recommended: 'error',
      requiresTypeChecking: false
    },
    messages: {
      rememberToDelete: 'Remember to delete console.method()'
    },
    type: 'problem',
    schema: {}
  },
  create (
    context: Readonly<TSESLint.RuleContext<'rememberToDelete', never[]>>
  ) {
    return {
      MemberExpression (node) {
        if (isIdentifier(node.object) && node.object.name === 'console' &&
            isIdentifier(node.property) && Object.prototype.hasOwnProperty.call(console, node.property.name) &&
            !whiteList.includes(node.property.name)
        ) {
          context.report({ node, messageId: 'rememberToDelete' });
        }
      }
    };
  }
});

export default rule;

代码传送门:src/rules/noConsole.ts

本地测试

单元测试:

yarn test

测试用例的编写

这里只是一个简单的介绍,更具体的介绍参见下一个名为《测试用例的编写》的章节。基本格式如下:

import rule from '../src/rules/noConsole';
import { ESLintUtils } from '@typescript-eslint/utils';

const ruleTester = new ESLintUtils.RuleTester({
  parser: '@typescript-eslint/parser'
});

ruleTester.run('no-console', rule, {
  valid: [
    { code }
  ],
  invalid: [
    {
      code,
      options: [
        { param1, param2 }
      ], // 向待测试规则传入的参数
      // 不含有修复建议的eslint错误
      errors: [{ messageId: 'rememberToDelete' }]
    }
  ]
});

在IDE中,将鼠标放上去即可看到类型推断结果和官方解释,单击即可查看完整的类型定义。

本地查看效果

首先:

yarn build

接下来有两种方式模拟已发布的npm包。

(1)在另一个项目(这里用了相对路径,用绝对路径也行):

yarn add -D file:../eslint-plugin-use-i18n-hans

注意:每次重新build后都需要在另一个项目重新yarn add

这样会得到:

{
  "devDependencies": {
    "@hans/eslint-plugin-use-i18n-hans": "file:../eslint-plugin-use-i18n-hans",
  }
}

(2)先在本项目根目录运行yarn link,再在另一个项目:

yarn link @hans774882968/eslint-plugin-use-i18n

这个做法不会改变package.json

但这两种做法都有一个问题:不能检测出应列举在dependencies的包被错误地列举在devDependencies的情况。TODO:佬们教教我如何检测出这种情况。

接下来配置.eslintrc.js

module.exports = {
  plugins: [
    '@hans774882968/use-i18n',
  ],
  extends: [
    'plugin:@hans774882968/use-i18n/recommended',
  ],
}

插件名为@hans774882968/use-i18n,使用了configs中的recommended

最后重启vscode或运行yarn lint就能看到我们的第一个eslint插件生效了。

<path>/file-encrypt/webpack-plugin-utils.js
  16:5  error  Remember to delete console.log()  @hans774882968/use-i18n/no-console

第一个eslint插件第一个规则

no-console规则添加功能:排除用户指定的文件

修改一下meta.schema,新增输入参数:

schema = [
  {
    properties: {
      excludedFiles: {
        type: 'array'
      }
    }
  }
]

和对应的类型定义:

type Options = [{
  excludedFiles: string[];
}];

{
  create (
    context: Readonly<TSESLint.RuleContext<'rememberToDelete', Options>>
  ) {}
}

然后在create函数体加几句判定:

const fileName = context.getFilename();
const options = context.options[0] || {};
const { excludedFiles } = options;
if (Array.isArray(excludedFiles)) {
  const excludedFilePaths = excludedFiles.map(excludedFile => path.resolve(excludedFile));
  if (multimatch([fileName], excludedFilePaths).length > 0) {
    return {};
  }
}

context.getFilename()文档:eslint.org/docs/latest… 。其特性:在yarn test时会返回file.ts,在作为npm包引入另一个项目后,可以正常获取文件的绝对路径。

为了支持glob语法,我们引入了multimatch。但需要指定版本为5.0.0,因为multimatch6.0.0只支持es module,而我反复尝试都无法找到一个可以生效的jest配置。transformIgnorePatterns等配置项的资料都极少,这篇blog看上去操作性很强,但尝试后依旧无效……TODO:让佬们教教我。

构建完成后,我们可以在另一个项目尝试配置@hans774882968/use-i18n/no-console规则:

'@hans774882968/use-i18n/no-console': ['error', {
  excludedFiles: [
    'add-copyright-plugin.js',
    'copyright-print.js',
    'webpack-plugin-utils.js',
    'src/utils/my-eslint-plugin-tests/no-warn-folder/**/*.js',
    'tests/**/*.js',
    'src/utils/my-eslint-plugin-tests/i18n-tests/*.js',
  ],
}],

.eslintrc.js取消或添加注释并保存,vscode应该能立刻看到报错的产生和消失。

TODO:是否能够mock context.getFilename(),让本地可以写测试用例?

i18n-usage规则:检测不合法的i18n方法使用方式

在Vue里,我们通过调用i18n方法来实现国际化。于是我们可能会希望实现一个eslint规则,指出用户调用i18n方法的方式不合法。输入参数:i18nFunctionNames: string[],指定是i18n的方法名,也就是这条eslint规则的检测范围,比如['$gt', '$t', '$i18n']。不合法情形:

  • 不传入参数。如:$gt()
  • 第一个参数不是字符串字面量(String Literal)。如:$gt(12), $gt(1 + 2), $gt(null), $gt(undefined), $gt(x), $gt(x, null, {})

从本质上来说,实现它并不比上文的no-console规则难。所以我仅指出实现上的注意点:

  1. 规则的meta.messages的一条消息可以是string template,context.report可以向string template传入参数。比如:meta.messages = { a: '{{var}}' }context.report({ node, messageId: 'a', data: { var } }),我们通过data属性向消息的string template传入参数。这个功能有什么用呢?我们在给出eslint提示的时候,希望给出我们检测出的用户正在使用的i18n方法名,就可以用这个功能实现。
  2. 面对判断节点类型的需求,@typescript-eslint/utilsTSESTree确实不如@babel/types好用。我们可以用ASTUtils.isIdentifier判断node is TSESTree.Identifier,但对于MemberExpression等类型,则需要node.type === AST_NODE_TYPES.MemberExpression来判定。更麻烦的一个例子是:为了检测字符串字面量,我不得不使用node.type !== AST_NODE_TYPES.Literal || typeof node.value !== 'string'。TODO:是否能找到更好的判定方式?
  3. eslint单测ESLintUtils.RuleTester的测试用例,可以指定errors: TestCaseError<messageId[]>[]这个数组,errorsTestCaseError应按文本从上往下列出。

代码传送门

构建完成后,另一个项目的eslint配置:

module.exports = {
  plugins: [
    '@hans774882968/use-i18n',
  ],
  extends: [
    'plugin:@hans774882968/use-i18n/all',
  ],
  rules: {
    '@hans774882968/use-i18n/i18n-usage': ['error', {
      i18nFunctionNames: ['$i18n', '$t'],
    }],
  }
}

效果:

2-i18n方法用法检测效果图

i18n-usage规则:自动修复功能——eslint fix函数的使用

接下来我们为上面实现的i18n-usage规则添加一个自动修复功能。为了优化用户体验,我们约定:

  • 对于$i18n(x)这种第一个参数是标识符的情况,修复为$i18n('{x}', null, { x })
  • 对于$i18n(a[0]+a[1]*a[2])这种第一个参数不是标识符的情况,修复为$i18n('{value}', null, { value: a[0] + a[1] * a[2] })。注意这里进行了格式化,因为我在实现时使用了escodegen来输出第一个参数的AST节点的代码。
  • 对于$i18n(arg0, arg1, ...)这种多于1个参数,和没有参数的情况,不修复。

另外:

  1. 要求在执行yarn lint时自动修复。为了实现这条需求,我们可以为context.report方法传入的对象指定一个fix函数。
  2. 在vscode可以看到“快速修复”的建议,我们希望在那显示一条建议信息。为了实现这条需求,我们可以为context.report方法传入的对象指定一个suggest数组。

下面是一个能同时实现上面两点需求的简单🌰:

context.report({
  *fix(fixer) {
    yield fixer.replaceText(node, 'str');
    yield fixer.insertBefore(node, 'str');
  },
  suggest: [
    {
      messageId: 'autofixFirstArgSuggest',
      data: { i18nFunctionName, replaceResult },
      *fix (fixer) {
        yield fixer.replaceText(node, 'str');
        yield fixer.insertBefore(node, 'str');
      }
    }
  ]
});

fixer提供的方法可在IDE中点击变量查看:

interface RuleFixer {
  insertTextAfter(nodeOrToken: TSESTree.Node | TSESTree.Token, text: string): RuleFix;
  insertTextAfterRange(range: Readonly<AST.Range>, text: string): RuleFix;
  insertTextBefore(nodeOrToken: TSESTree.Node | TSESTree.Token, text: string): RuleFix;
  insertTextBeforeRange(range: Readonly<AST.Range>, text: string): RuleFix;
  remove(nodeOrToken: TSESTree.Node | TSESTree.Token): RuleFix;
  removeRange(range: Readonly<AST.Range>): RuleFix;
  replaceText(nodeOrToken: TSESTree.Node | TSESTree.Token, text: string): RuleFix;
  replaceTextRange(range: Readonly<AST.Range>, text: string): RuleFix;
}

实现修复功能唯一的难点是:第一个参数不是标识符的情况如何修复。有一种可行的方式是:

yield fixer.insertTextBefore(args[0], '\'{value}\', null, { value: ');
yield fixer.insertTextAfter(args[0], ' }');

但后来了解到,类似于@babel/generatorescodegen这个包可以输入espree(eslint所使用的AST)的AST节点,输出代码。因此我们有了更简单的实现方式:

const args0Code = escodegen.generate(args[0]);
yield fixer.replaceText(args[0], `'{value}', null, { value: ${args0Code} }`);

代码传送门

测试用例的编写

添加自动修复功能后,测试用例的格式会有些变化。代码传送门

const v = {
  code: basicCaseInputCodes[6],
  options: [
    { i18nFunctionNames: ['$i18n'] }
  ], // 向待测试规则传入的参数
  output: basicCaseOutputCodes[6], // 所有修复都应用后输出的完整代码
  errors: [
    {
      messageId: 'firstArgShouldBeString',
      // 修复建议的数组
      suggestions: [
        {
          // 显示在IDE上的修复建议
          messageId: 'autofixFirstArgSuggest',
          data: { i18nFunctionName, replaceResult },
          // 这条修复建议应用后输出的完整代码
          output
        }
      ]
    }
    // 不含有修复建议的eslint错误
    { messageId: 'parameter' },
    // ...
  ]
}

需要注意“两个output”的区别:output是所有修复都应用后输出的完整代码errors[i].suggestion[j].output只有这条修复建议应用后输出的完整代码。官方解释如下:

  • output:The expected code after autofixes are applied. If set to null, the test runner will assert that no autofix is suggested.
  • errors[i].suggestion[j].output:Suggestions will be applied as a stand-alone change, without triggering multi-pass fixes. Each individual error has its own suggestion, so you have to show the correct, isolated output for each suggestion.

效果

修复前:

3-1-i18n-usage-修复前.png

format on save修复后:

3-2-i18n-usage-修复后.png

修复建议:

3-3-i18n-usage-修复建议.png

发布npm包

参考链接3

首次发布包

  1. 首先npm config set registry https://registry.npmjs.org/确保源地址为官方源。
  2. www.npmjs.com/ 注册账号。之后在命令行执行npm login登录。可以用npm whoami确保自己已经登录成功。
  3. npm publish发布包。但如果你的包名以@开头,即使用了命名空间,则需要保证:
  4. @后面跟的是自己的账号名,否则会报错404 You should bug the author to publish it (or use the name yourself!)参考链接4是对的,Stack Overflow上说的,重新npm login、使用NPM_TOKENNPM_TOKEN=xxx npm publish --access public)等方式也可以作为备选项。
  5. 发布私有包是要钱💰的,而使用命名空间后npm会默认这是一个私有包,直接发布会报错402 Payment Required,所以需要声明为公有包。做法有:(1)npm publish --access public。(2)package.json配置publishConfig。(3)npm config set access publicnpm publish
{
  "publishConfig": {
    "registry": "https://registry.npmjs.org/"
  }
}

后续发布

package.jsonversion没变就不能再次发布。可以手动改版本号。也可以用npm version patch来升版本,注意:这条命令会自动产生一次commit,可以直接push。

npm version可用参数如下:

// patch:补丁号,修复bug,小变动,如 1.0.0 -> 1.0.1
npm version patch

// minor:次版本号,增加新功能,如 1.0.0 -> 1.1.0
npm version minor

// major:主版本号,不兼容的修改,如 1.0.0 -> 2.0.0
npm version major

标记某个版本为deprecated

npm deprecate <package_name>@<version> "deprecate reason"

一般不建议使用unpublish,而是用deprecated代替。

修复vue文件的Parsing error

这个版本有一个小问题:对于有vue文件的项目,报错error Parsing error: '>' expected

我一开始想用context.getFilename()获取文件后缀名,避免解析vue文件。但看到参考链接7后尝试了一下,发现也可行。

src/configs/all.ts为例,其他config同理。首先yarn add vue-eslint-parser,然后把parser: '@typescript-eslint/parser'修改为:

export default {
  parser: 'vue-eslint-parser',
  parserOptions: {
    parser: '@typescript-eslint/parser',
    sourceType: 'module'
  },
  rules: {
    '@hans774882968/use-i18n/no-console': 'error',
    '@hans774882968/use-i18n/i18n-usage': 'error'
  }
};

参考资料

  1. 值得参考的教程:www.darraghoriordan.com/2021/11/06/…
  2. eslint有编写自定义规则的官方文档:eslint.org/docs/latest…
  3. juejin.cn/post/717063…
  4. npm publish包报404,is not in the npm registry错误:juejin.cn/post/714398…
  5. stackoverflow.com/questions/3…
  6. reactjs.org/docs/how-to…
  7. 同时使用@typescript-eslint/parservue-eslint-parsereslint.vuejs.org/user-guide/…