写了一个 vscode 插件:自动添加可选链

前几天有朋友问了我个问题:

是否可以通过 eslint 插件实现自动把属性访问变成可选链的方式。

这当然是可以的,我们来实现下:

mkdir auto-optional-chain

cd auto-optional-chain

npm init -y

创建项目,新建 package.json

安装 eslint 的包

npm install --save eslint

然后在 src/index.js 写这样一段代码:

const { ESLint } = require("eslint");

const engine = new ESLint({
    fix: false,
    overrideConfig: {
        parser: "@babel/eslint-parser",
        rules: {
            'semi': ['error', 'never']
        }
    },
    useEslintrc: false
});

async function main() {
    const results = await engine.lintText(`
        function handleRes(data) {
            const res = data.a.b.c + data.e.f.g;

        }
    `)
    
    console.log(results);
}
main();

eslint 一般我们用的是命令行的方式,当然,它也有 api 的方式。

我们 new 了 ESLint 的对象,指定配置,不自动 fix。

然后用 lintText 来检查一段代码,打印结果。

这里我们就用了一个 rule ,也就是检查末尾分号的,设置为不加分号。

这里用到了 @babel/eslint-parser,安装一下:

npm install @babel/eslint-parser

然后创建 babel 配置文件:

然后跑一下:

可以看到确实有一个错误。

展开是这样的:

在第 3 行第 48 列有一个额外的分号。

但这样看起来太费劲了,我们把它格式化一下:

const formatter = await engine.loadFormatter('stylish');
const resultText = formatter.format(results);
console.log(resultText);

eslint 内置了一些 formatter 用它格式化一下再打印:

这种错误格式就是我们经常见的那种了。

然后我们再把 fix 改为 true,也就是自动修复:

打印下 result[0].output,也就是第一个错误自动修复后的结果:

可以看到,末尾分号被去掉了。

这就是 eslint 的 api 方式的用法。

下面我们来写一个自动添加可选链的插件。

新建 src/auto-optional-chain.js

module.exports = {
    meta: {
        docs: {
            description: "自动添加可选链"
        },

        fixable: true
    },

    create(context) {
        return {
           BlockStatement(node) {
           }
       }
   }
};

meta 部分是指定这个插件的元信息,比如文档、是否可以自动 fix 等。

create 部分是插件的实现逻辑,指定对什么节点做什么处理。

那我们要处理的是什么 AST 节点呢?

可以用 astexplorer.net 看一下:

选择 javascript,用 @babel/parser 解析,在后边可以看到 parse 出的 AST。

可以看到,这种 data.name 的语法叫做 MemberExpression 成员表达式。

如果多个 . 的话就是 MemberExpression 嵌套了:

那 data?.name 呢?

可以看到,叫做 OptionalMemberExpression

也就是说我们找到 MemberExpression,给它报个错,然后 fix 的时候修复为可选链的方式就好了。

也就是这样:

module.exports = {
    meta: {
        docs: {
            description: "自动添加可选链"
        },

        fixable: true
    },

    create(context) {
        return {
            MemberExpression(node) {
                context.report({
                    node,
                    loc: {
                        line: 111,
                        column: 222
                    },
                    message: '应该用可选链'
                })
            }
       }
   }
};

指定 rulePaths 也就是去哪里找 rule,然后配置这个 rule 为 error 级别:

测试下:

可以看到,确实是报了 6 个错误。

只不过现在的位置不太对。

拿到 . 的位置需要用 token 相关的 api。

也就是这样:

module.exports = {
    meta: {
        docs: {
            description: "自动添加可选链"
        },

        fixable: true
    },

    create(context) {
        const sourceCode = context.getSourceCode();

        return {
            MemberExpression(node) {
                const tokens = sourceCode.getTokens(node);

                context.report({
                    node,
                    loc: {
                        line: 111,
                        column: 222
                    },
                    message: '应该用可选链'
                })
            }
       }
   }
};

我们断点调试下:

创建 launch.json

创建 node 类型的调试配置:

在代码里打个断点:

点击调试启动:

代码会在断点处断住:

可以看到有 7 个 token,分别是 data 和 . 和 a 和 b 和 . 和 c

那我们取哪个 . 的 loc 呢?

可以看到,第一次断住是这样的:

第二次是这样的:

第三次是这样的:

也就是说 data.a.b.c 是从右向左解析的,所以我们要拿到的是最后一个 . 的 token 的位置。

取倒数第二个,可以用数组的 at 方法:

也就是这样:

module.exports = {
    meta: {
        docs: {
            description: "自动添加可选链"
        },

        fixable: true
    },

    create(context) {
        const sourceCode = context.getSourceCode();

        return {
            MemberExpression(node) {
                const tokens = sourceCode.getTokens(node);

                context.report({
                    node,
                    loc: tokens.at(-2).loc,
                    message: '应该用可选链'
                })
            }
       }
   }
};

现在的位置就都对了:

光报错意义不大,我们再实现自动 fix。

eslint 的 fix 是基于字符串替换实现的,它提供了一个 fixer api。

打断点看看:

很明显,这里比较适合用 insertTextBefore 来做。

也就是这样:

context.report({
    node,
    loc: dotToken.loc,
    message: '应该用可选链',
    fix: fixer => {
        return fixer.insertTextBefore(dotToken, '?')
    }
})

但要注意,fix 之后会再次 lint,这时候拿到的 token 就这样了:

这种应该不再 fix,直接跳过:

module.exports = {
    meta: {
        docs: {
            description: "自动添加可选链"
        },

        fixable: true
    },

    create(context) {
        const sourceCode = context.getSourceCode();

        return {
            MemberExpression(node) {
                const tokens = sourceCode.getTokens(node);

                const dotToken = tokens.at(-2);

                if(dotToken.value === '?.'){
                    return;
                }

                context.report({
                    node,
                    loc: dotToken.loc,
                    message: '应该用可选链',
                    fix: fixer => {
                        return fixer.insertTextBefore(dotToken, '?')
                    }
                })
            }
       }
   }
};

测试下:

修复后的代码是对的。

只要项目里用到了这个 rule,开启自动 fix 就可以自动加上可选链。

但这样其实有个问题:不是所有的 data.xxx 都需要变成可选链的方式,而现在这个 eslint rule 是把所有的 data.xxx 都自动 fix 了。

也可以写个 babel 插件来做这件事情,不修改源代码,只是在编译的时候做:

const { transformSync } = require('@babel/core');

function autoOptionalPlugin() {
    return {
        visitor: {
            MemberExpression(path, state) {
                const text = path.toString();

                path.replaceWithSourceString(text.replace(/\./g, '?.'));
            }
        }
    }
}

const res = transformSync(`
    function handleRes(data) {
        const res = data.a.b.c + data.e.f.g;

    }
`, {
    plugins: [autoOptionalPlugin]
});

console.log(res.code);

用 transformSync 来编译源代码为目标代码,过程中调用 autoOptionalPlugin。

插件里处理 MemberExpression,拿到代码对应的字符串,然后把 . 改成 ?. 再替换回去。

效果和 eslint 插件是一样的:

babel 插件的好处是不修改源码,可以在编译过程中无感的做这件事情。

那我如果就是想把代码改了,但是还不能全部改,而是我选中哪部分就自动修复哪部分代码呢?

这种就要用 vscode 插件来做了。

安装 vscode 插件的脚手架:

npm install -g yo generator-code

生成 vscode 插件项目:

yo code 

生成的项目是这样的:

它已经配置好了调试配置,点击就可以调试:

它会启动一个新的 vscode 窗口,然后输入 hello world 命令,右下角会有提示框:

这就代表 vscode 插件运行成功了。

然后想一下我们的插件要做成什么样子:

选中一段代码,右键菜单里会有转换为可选链的选项,点击就可以转换。

或者选中之后,按快捷键也可以转换。

在 src/extention.ts 里实现下命令的注册:

import * as vscode from 'vscode';

export function activate(context: vscode.ExtensionContext) {
    const transformCommand = vscode.commands.registerCommand('transformToOptionalChain', () => {
            vscode.window.showInformationMessage('转换成功!');
    });

    context.subscriptions.push(transformCommand);
}

然后在 package.json 里也要声明:

"contributes": {
    "commands": [
      {
        "command": "transformToOptionalChain",
        "title": "xxx"
      }
    ],
    "menus": {
      "editor/context": [
        {
          "command": "transformToOptionalChain"
        }
      ]
    }
},

commands 里声明这个 command,指定 title。

menus 声明 editor/context 也就是编辑器的上下文菜单,添加一个。

测试下:

确实多了一个菜单项,点击之后会执行 command 的逻辑:

回过头来看下这段配置:

这里的 editor/context 是注册编辑器的右键菜单,当然,还有很多别的地方的菜单可以注册:

这个菜单项还可以指定出现的时机,显示的分组:

比如 1_modification 就是这里:

而 navigation 就是这里:

这个分组在文档里也有写:

然后指定菜单项出现的时机:

当语言类型 为 js 或者 ts,并且选中文本的时候才出现:

这样在非 js、ts 文件里是没这个菜单的:

在 js、ts 里不选中也是没有的:

只有在 js、ts 文件,并且选中文本,才会出现这个菜单项:

然后我们就可以写具体的逻辑了:

import * as vscode from 'vscode';

export function activate(context: vscode.ExtensionContext) {

	const transformCommand = vscode.commands.registerCommand('transformToOptionalChain', () => {
		const editor = vscode.window.activeTextEditor;

		if(editor) {
			const selectedText = editor.document.getText(editor.selection);
		
			editor.edit(builder => {
				builder.replace(editor.selection, selectedText.toUpperCase());
			});
			vscode.window.showInformationMessage('转换成功!');
		}
	});

	context.subscriptions.push(transformCommand);
}

通过 vscode.window.activeTextEditor 拿到当前的 editor,然后拿到选中区域的文本,执行替换。

这里只是替换为了大写。

打个断点试试:

代码执行到这里会断住:

可以看到,拿到的文本就是选中的。

那我们把之前用 babel 插件做代码转换的逻辑拿过来就好了。

安装用到的 @babel/core 包和它的 ts 类型包:

npm install --save @babel/core

npm i --save-dev @types/babel__core

然后用它来做下选中代码的转换:

import * as vscode from 'vscode';
import * as babel from '@babel/core';
import type { NodePath, types} from '@babel/core';

function transform(code: string): string{
	function autoOptionalPlugin() {
		return {
			visitor: {
				MemberExpression(path: NodePath<types.MemberExpression>) {
					const text = path.toString();

					path.replaceWithSourceString(text.replace(/\./g, '?.'));
				}
			}
		}
	}

	const res = babel.transformSync(code, {
		plugins: [autoOptionalPlugin]
	});

	return res?.code || '';
}

export function activate(context: vscode.ExtensionContext) {

	const transformCommand = vscode.commands.registerCommand('transformToOptionalChain', () => {
		const editor = vscode.window.activeTextEditor;

		if(editor) {
			const selectedText = editor.document.getText(editor.selection);
		
			if(!selectedText) {
				return;
			}

			editor.edit(builder => {
				builder.replace(editor.selection, transform(selectedText));
			});
			vscode.window.showInformationMessage('转换成功!');
		}
	});

	context.subscriptions.push(transformCommand);
}

这里的 transform 方法就是前面讲过的用 babel 做代码转换的实现。

测试下:

只有选中的代码才会做转换,没选中的不会:

还可以把这个功能注册成快捷键:

"keybindings": [
  {
    "command": "transformToOptionalChain",
    "key": "ctrl+y",
    "mac": "cmd+y",
    "when": "(resourceLangId == javascript || resourceLangId == typescript) && editorHasSelection"
  }
]

在 windows 下是 ctrl + y,在 mac 下是 command + y

是不是用起来超级方便?

总结

我们想自动把代码里的 data.xxx 转成可选链的形式 data?.xxx。

于是写了 eslint 插件、babel 插件来做这件事。

eslint 插件的 fix 是通过字符串替换的方式修改源码。

babel 插件是通过 ast 的方式修改代码,而且只是改了编译后的代码。

但这俩都是全局替换的,还是自己选择替换哪部分更好,所以我们又写了一个 vscode 插件。

vscode 插件可以在 package.json 的 contributes 里配置 commands、menus、keybindings。

我们注册了一个命令,配置了编辑器右键菜单,并且绑定了快捷键。

当执行这个命令的时候,拿到选中的文本内容,通过 babel 插件来做转换,之后替换回去。

写完这个 vscode 插件以后,再遇到这种情况,只要选中文本,按个快捷键就可以搞定。

猜你喜欢

转载自juejin.im/post/7264012271364554810