Eslint 自定义插件
需求来源
在整理 import 的时候,发现 {}
中属性换行调整不到想要的结果。
例如:
// 编写的代码
import {A, B, C} from 'xxx'
// 期望格式化后的结果
import {
A,
B,
C
} from 'xxx'
// object-curly-newline 规则的结果,我期望的是每一行一个属性
// 'object-curly-newline': [
// 'error',
// {
// ImportDeclaration: {
// multiline: true,
// minProperties: 3,
// },
// }
// ]
import {
A, B, C
} from 'xxx'
于是打算自己实现一个 eslint plugin 来实现该功能。
思路
根据 eslint plugin 文档,创建项目结构,eslint 有自带的工具 yo
,这里就没用使用,其实是一个道理。
├── README.md
├── build.js
├── jest.config.js
├── lib
│ ├── helper.js
│ ├── index.js
│ └── rules
│ └── newline.js
├── package-lock.json
├── package.json
└── tests
└── newline.test.js
代码说明
package.json
比较核心的字段:
main: ./lib/index.js,对应commonjs引入方式的程序入口文件
exports: ./lib/index.js,默认导出的文件
scripts.build: 打包出较小的文件目录结构
scripts.test: 单元测试,包括了代码覆盖率
peerDependencies: 限制了 eslint 版本
{
"name": "eslint-plugin-import-curly",
"version": "1.0.1",
"description": "import curly",
"keywords": [
"eslint",
"eslintplugin",
"eslint-plugin"
],
"main": "./lib/index.js",
"exports": "./lib/index.js",
"scripts": {
"build": "node ./build.js",
"test": "jest --coverage",
"fix": "eslint lib build.js --fix"
},
"devDependencies": {
"@typescript-eslint/parser": "^6.18.1",
"eslint": "^8.19.0",
"eslint-doc-generator": "^1.0.0",
"eslint-plugin-eslint-plugin": "^5.0.0",
"eslint-plugin-jest": "^27.6.2",
"fs-extra": "^11.2.0",
"jest": "^29.7.0",
"typescript": "^5.3.3"
},
"engines": {
"node": "^14.17.0 || ^16.0.0 || >= 18.0.0"
},
"peerDependencies": {
"eslint": ">=7"
},
"license": "ISC"
}
build.js
先看看如何打包,思路很简单,核心思路,拷贝 package.json README.md 和 lib 目录,package.json 去掉 devDependencies 中的所有依赖即可,
然后把这些文件放入到 build 中
'use strict'
const fs = require('fs')
const fse = require('fs-extra')
const { join } = require('path')
const buildDir = join(__dirname, 'build')
if (fs.existsSync(buildDir)) {
fs.rmdirSync(buildDir, {recursive: true})
}
fs.mkdirSync(buildDir)
const packageJSON = JSON.parse(fs.readFileSync('./package.json', 'utf8'))
packageJSON.devDependencies = {}
fse.copySync('./lib', join(buildDir, 'lib'))
fs.writeFileSync(join(buildDir, 'package.json'), JSON.stringify(packageJSON, undefined, 2), 'utf8')
fs.copyFileSync('./README.md', join(buildDir, 'README.md'))
lib/index.js
正文开始,现在来看看 index.js 写了什么。
这文件也很简单,导入了一个包,然后导出了一个 rules
, rules
里面包含的这是规则名称,即 newline
。
文档参考 Eslint plugin
"use strict";
const newline = require('./rules/newline')
module.exports.rules = {
newline,
}
lib/rules/newline.js
现在来看看规则是如何实现的。
文档参考 Custom rules
module.exports = {
meta: {
// 该规则主要关注空格、分号、逗号和括号,程序的所有部分决定代码的外观而不是代码的执行方式。这些规则适用于 AST 中未指定的部分代码。
type: 'layout',
// whitespace: 可以修复代码中的空白字符问题,例如缩进不一致、换行不规范、空格过多或过少等。
fixable: 'whitespace',
// 传入参数的规则校验
schema: [
{
type: 'object',
properties: {
count: {
type: 'number',
},
},
additionalProperties: false,
},
],
// 规则文档
docs: {
url: 'xxxxx'
},
// key: reportId, value: 错误消息提示
messages: {
'import-curly-newline': 'Run autofix to import curly newline!',
},
},
// 返回一个对象,其中包含 ESLint 在遍历 JavaScript 代码的抽象语法树(ESTree 定义的 AST)时调用到 “visit” 节点的方法
// AST 结构可以在网站 https://astexplorer.net/ 上查看
create: (context) => {
// 初始化默认值,超过了三个属性则换行
const option = getDefaultValues({ ...context.options[0] }, { count: 3 })
return {
// 遍历 import 语句的回调方法,node 则为当前 import 节点
ImportDeclaration: node => {
// 保留花括号中的属性,去掉 default 的导入: import A, {B, C} from 'xxx',即去掉 A
const importSpecifiers = node.specifiers.filter(v => v.type === 'ImportSpecifier')
if (!(importSpecifiers.length && option.count)) {
return
}
if (importSpecifiers.length < option.count) {
return
}
// 判断和处理代码
handleFailedImport(context, node, importSpecifiers, 'import-curly-newline')
},
}
}
}
function handleFailedImport(context, node, importSpecifiers, messageId) {
const sourceCode = context.getSourceCode()
const {
// 左花括号节点
bracesLeft,
// 后花括号节点
bracesRight,
// 最后一个属性是否是逗号结尾
isHavaLastComma
} = getBracesTokens(sourceCode.getTokens(node))
if (
bracesLeft &&
bracesRight &&
importSpecifiers &&
// 判断是否是每个属性都在单独的一行
isImportNewline(importSpecifiers, bracesLeft.loc.start.line, bracesRight.loc.start.line)
) {
// 报告错误
reportFailedImport(
context,
[bracesLeft.range[0], bracesRight.range[1]],
messageId,
// 修复后的代码
fixFailedImportCurlyNewline(sourceCode.getText(node), isHavaLastComma)
)
}
}
// true 表示代码不满足规则
function isImportNewline(nodes, bracesLeftLine, bracesRightLine) {
// 初始化上一行为左花括号的行数
let lastNodeLine = bracesLeftLine
for (const node of nodes) {
// 判断同一个节点是否在同一行 A as AA, A \n as AA
if (node.loc.start.line !== node.loc.end.line) {
return true
}
// 判断上一个节点和该节点是否在同一行
if (lastNodeLine === node.loc.start.line) {
return true
} else {
lastNodeLine = node.loc.start.line
}
}
// 判断最后一个节点是否和右花括号在 同一行
return nodes[nodes.length - 1].loc.start.line === bracesRightLine
}
// 修复代码
function fixFailedImportCurlyNewline(codeStr, isHavaLastComma) {
// 判断结束符
const lineOff = /\r\n/.test(codeStr) ? '\r\n' : (/\r/.test(codeStr) ? '\r' : '\n')
// 思路是把花括号中的内容,先去掉换行符,然后按照逗号分成数组,过滤掉空行,最后在每个节点前加两个空格
// TODO 这里固定了加两个空格,应该做兼容,暂时没有想到有什么好的方案
const params = codeStr.substring(codeStr.indexOf('{') + 1, codeStr.indexOf('}'))
.replace(/\n/g, '')
.replace(/\r/g, '')
.split(',')
.filter(v => v.trim())
.map(v => ` ${v.trim()}`)
// 如果最后有逗号,添加一个空节点
if (isHavaLastComma) {
params.push('')
}
// 拼接修复后的代码即可
return `{${lineOff}${params.join(`,${lineOff}`)}${isHavaLastComma ? '}' : `${lineOff}}`}`
}
lib/helper.js
相对通用的方法
// 获取左右花括号的位置,判断最后是否有逗号
function getBracesTokens(tokens) {
let bracesLeft
let bracesRight
let isHavaLastComma = false
tokens.forEach((v, i) => {
if (v.type === 'Punctuator' && v.value === '{') {
bracesLeft = v
}
if (v.type === 'Punctuator' && v.value === '}') {
if (i > 0 && tokens[i - 1].value === ',') {
isHavaLastComma = true
}
bracesRight = v
}
})
return {
bracesLeft,
bracesRight,
isHavaLastComma,
}
}
// 报告错误代码,并且提示修复
function reportFailedImport(context, ranges, messageId, fixedCode) {
const sourceCode = context.getSourceCode()
context.report({
messageId,
loc: {
start: sourceCode.getLocFromIndex(ranges[0]),
end: sourceCode.getLocFromIndex(ranges[1])
},
fix: fixer => fixer.replaceTextRange(ranges, fixedCode)
})
}
// 生成默认参数
function getDefaultValues(options, defaultValues) {
for (const key of Object.keys(defaultValues)) {
if (options[key] === undefined) {
options[key] = defaultValues[key]
}
}
return options
}
使用方法
先安装该插件
npm install eslint-plugin-import-curly --save-dev
在 .eslintrc
文件中配置该插件,去掉 eslint-plugin-
{
"plugins": ["import-curly"]
}
规则:import-curly/newline
用于格式化换行操作,import 花括号中属性默认超过了 3 个,则每个属性都换一行。
name | 类型 | 默认值 | 描述 |
---|---|---|---|
count | number | 3 | 如果花括号中的属性超过了 3 个,则会触发该规则 |
{
"rules": {
"import-curly/newline": "error"
}
}
或者
{
"rules": {
"import-curly/newline": [
"error",
{
"count": 4
}
]
}
}
case:
// invalid
import {
A as AA,B, C} from 'C'
// ||
// \/
// valid
import {
A as AA,
B,
C
} from 'C'
规则:import-curly/sort-params
用于 import 花括号中的属性进行排序
name | 类型 | 默认值 | 可选值 | 描述 |
---|---|---|---|---|
typeLocation | string | ignore | ignore,first,last | ignore: 忽略 ts 中 type 的位置,first: type 的位置在最前方, last: type 的位置在最后放 |
orderBy | string | alphabeticalOrder | alphabeticalOrder,letterNumber | alphabeticalOrder: 按照字母排序, letterNumber: 按照字数排序。如果按照字数排序,相同字数的会按照字母排序 |
sortBy | string | aec | aec,desc | aec: 增序, desc: 降序 |
ignoreCase | boolean | true | true: 忽略大小写,false: 不忽略大小写 |
{
"rules": {
"import-curly/sort-params": "error"
}
}
或者
{
"rules": {
"import-curly/sort-params": [
"error",
{
"typeLocation": "first",
"orderBy": "letterNumber",
"sortBy": "desc",
"ignoreCase": false
}
]
}
}
默认规则
{
"rules": {
"import-curly/sort-params": "error",
"import-curly/sort-params": [
"error",
{
"typeLocation": "ignore",
"orderBy": "alphabeticalOrder",
"sortBy": "aec",
"ignoreCase": true
}
]
}
}
有效的样例
import type {a,b,c,d} from 'a'
import {A,B,C,D} from 'a'
import type {a,B,c,D} from 'a'
import {A,b,c,D} from 'a'
import {type a,b,type c,d} from 'a'
import {a,type b,c,default as d} from 'a'
错误的样例
// invalid
import {type A,type c,d,B} from 'a'
// ||
// \/
// valid
import {type A,B,type c,d} from 'a'
// invalid, ignore type, compare with 'default'
import {c,default as d,type b,a} from 'a'
// ||
// \/
// valid
import {a,type b,c,default as d} from 'a'
type 在最前方
{
"rules": {
"import-curly/sort-params": [
"error",
{
"typeLocation": "first"
}
],
"import-curly/sort-params": [
"error",
{
"typeLocation": "ignore",
"orderBy": "alphabeticalOrder",
"sortBy": "aec",
"ignoreCase": true
}
]
}
}
有效的样例
// type first
import {type B,type c,A, d} from 'a'
import {type b,type d,a,c} from 'a'
错误的样例
// invalid, compare with 'default'
import {c,a,default as d,type b} from 'a'
// ||
// \/
// valid
import {type b,a,c,default as d} from 'a'
按照字数排序
{
"rules": {
"import-curly/sort-params": [
"error",
{
"orderBy": "letterNumber"
}
],
"import-curly/sort-params": [
"error",
{
"typeLocation": "ignore",
"orderBy": "letterNumber",
"sortBy": "aec",
"ignoreCase": true
}
]
}
}
有效的样例
import {a, ba, bb, ccc} from 'a'
import {a, ba, bb, bbb} from 'a'
错误的样例
// invalid bb ba letterNumber is the same, should order by alpha
import {a, bb, ba} from 'a'
// ||
// \/
// valid
import {a, ba, bb} from 'a'
按照字母降序排序
{
"rules": {
"import-curly/sort-params": [
"error",
{
"sortBy": "desc"
}
],
"import-curly/sort-params": [
"error",
{
"typeLocation": "ignore",
"orderBy": "alphabeticalOrder",
"sortBy": "desc",
"ignoreCase": true
}
]
}
}
有效的样例
import {d, c, b, a} from 'a'
按照字数降序排序
{
"rules": {
"import-curly/sort-params": [
"error",
{
"orderBy": "letterNumber",
"sortBy": "desc"
}
],
"import-curly/sort-params": [
"error",
{
"typeLocation": "ignore",
"orderBy": "letterNumber",
"sortBy": "desc",
"ignoreCase": true
}
]
}
}
有效的样例
import {aaa, cc, aa, d} from 'a'
不忽略大小写
{
"rules": {
"import-curly/sort-params": [
"error",
{
"ignoreCase": false
}
],
"import-curly/sort-params": [
"error",
{
"typeLocation": "ignore",
"orderBy": "alphabeticalOrder",
"sortBy": "aec",
"ignoreCase": false
}
]
}
}
有效的样例
import {A, B, C, a, b, c} from 'a'
import {A, B, C, a, b, c, default as d} from 'a'
总结
如果大佬有什么现有的更好的方案,欢迎讨论
附上项目地址: eslint-plugin-import-curly