前几天有朋友问了我个问题:
是否可以通过 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 插件以后,再遇到这种情况,只要选中文本,按个快捷键就可以搞定。