写在前面
本篇文章所讲内容需要对vscode插件有一定的了解,因篇幅所限,所以某些地方本人讲的不是很详细,也希望大家提出宝贵的意见
背景
去年年底的时候,领导接到上面的一个任务,需要将页面中所有的中文都进行国际化,原因是客户不想在页面上看到中文,这个时候我们就犯了难,因为现有程序中有的做了国际化的也有没做国际化,并且需要翻译的中文实在是太多了,而且步骤大抵相同:
- 复制中文,粘贴到百度
- 点击翻译,将中文翻译成英文或者是其他语言
- 将翻译好的英文加入多语言文件(一般是src/locales文件夹),并取一个牛逼的key
- 前端页面展示:intl.getStr('牛逼的key')
那么有没有一款插件可以实现这几步操作,答案是:有地,而且还很多
vite-plugin-auto-i18n (全量翻译之后拦截替换,只针对vite)
i18n-ally (自己提取中文,放入插件帮忙翻译)
easy-i18n-helper (右键vscode编辑器,逐个页面翻译)
得出的结论是没有非常符合我们当前场景的插件,我们的场景如下
- 可以自己修改'牛逼的key'
- 可以操作现有的多语言树节点
- 可以按照现有多语言树的结构生成新的语言文件
- 对冗余的管理,手动同步
这里先给大家展示下完成的效果:
流程概要
右键vscode代码 -> 筛选中文 -> 通过第三方(百度api)翻译 -> 选定插入翻译树的节点 -> 插入翻译 -> 点击保存回填代码块
主要步骤及实现
俗话说,我们之所以获得现在的成就,是站在巨人的肩膀上,因为我们并不需要全量,也不想自己提取中文,那么就借鉴一下easy-i18n-helper的操作习惯,在把我们想做的功能加进去就好,大体流程如下:
- 右键面板,在package.json中加入配置:
"menus": {
"editor/context": [
{
"when": "resourceLangId == javascript || resourceLangId == javascriptreact || resourceLangId == typescript || resourceLangId == typescriptreact",
"command": "multilingual-extension.translate", // 与extension的入口对应
"group": "navigation@6"
}
]
}
"contributes": {
"commands": [
{
"command": "multilingual-extension.translate",
"title": "翻译此页面"
}
],
}
- 获取vscode的settings配置(包括百度翻译的配置和用户项目的配置)
package.json中
"configuration": {
"title": "multilingual-extension",
"properties": {
"multilingual-extension.APP ID": {
"type": "string",
"default": "xxx",
"description": "百度翻译申请的APP ID."
},
"multilingual-extension.token": {
"type": "string",
"default": "ppp",
"description": "百度翻译申请的密钥."
},
"multilingual-extension.itemLanguages": {
"type": "array",
"default": [
"zh:zh-CN",
"en:en-US"
],
"items": {
"type": "string"
},
"description": "语言列表对应文件名称."
},
"multilingual-extension.Locales Path": {
"type": "string",
"default": "./src/locales",
"description": "多语言文件存放位置."
},
"multilingual-extension.Import Code": {
"type": "string",
"default": "import intl from '@/utils/intl';\n",
"description": "多语言引入语句."
},
}
},
// 代码获取
const res = workspace.getConfiguration().get('multilingual-extension');
const appId = res['APP ID']; // appid
const token = res['token']; // token
const localesPath = res['Locales Path'] // 多语言路径
- 右键拦截项目中展示的中文
这里简单介绍下babel:
因为本人使用babel主要是封装一些公共的方法和类,babel插件的形式并不符合我的使用场景所以安装了这三个包:
@babel/generator,@babel/parser,@babel/traverse,对应babel编译的三个流程:
① 将文件内容转成ast树,生成的ast可以访问ast.net
import { parse } from '@babel/parser';
let ast = parse(fileContent, {
sourceType: 'unambiguous', // 自动识别是否解析模块语法
plugins: ['jsx', 'typescript'] // 解析react和typescript
});
② 拦截ast中的某个类型的节点,并用正则识别出中文
traverse(ast, {
// 字符串字面量:let aaa = '哈哈哈'
StringLiteral: (path) => {
if(/[\u4e00-\u9fa5]/.test(path.node.value)) {
// 记录位置loc预备替换,id标识,
words.push({
loc: path.node.loc,
value: path.node.value,
id: uuid(),
// 表示是否是jsx的属性
// <div attr='哈哈哈'></div> ==> <div attr={intl.getStr('haha')}></div>
isJSXAttribute: path.parent.type === 'JSXAttribute',
});
}
},
// 模板字符中有中文 let aaa = `哈哈哈`
TemplateElement: (path) => {
if(/[\u4e00-\u9fa5]/.test(path.node.value.raw)) {
words.push({
loc: path.node.loc,
// 砍掉所有的\n \t等符号
value: path.node.value.cooked.trim().replace(/\s/, ''),
id: uuid(),
// { raw: ' 测试中文 \\t\n\n', cooked: ' 测试中文 \t\n\n' }
originalValue: path.node.value,
isTemplate: true,
});
}
},
// 识别react jsx
JSXText: (path) => {
if(/[\u4e00-\u9fa5]/.test(path.node.value)) {
words.push({
loc: path.node.loc,
value: path.node.value.trim(),
id: uuid(),
originalValue: path.node.value,
isJSXText: true,
});
}
}
});
- 将得到的中文喂给百度api,这里的步骤和数据格式可以参考百度翻译开放平台,当然其他的第三方翻译也都可以
const appId = res['APP ID']; // appid
const salt = (new Date).getTime(); // 随机数
const from = 'zh';
const to = lang;
const transWords = words.join('\n'); // 翻译多个需要用\n拼接
const code = `${appId + transWords + salt + res['token']}`;
const sign = crypto.MD5(code).toString();
const params = querystring.stringify({
q: transWords,
appid: appId,
salt,
from,
to,
sign
});
const options = {
host: 'api.fanyi.baidu.com',
port: 80,
path: `/api/trans/vip/translate?${params}`,
method: 'GET',
};
// 发起HTTP GET请求
const req = http.request(options, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
console.log(`Response body: ${data}`, typeof data);
try {
const res = JSON.parse(data);
if(res.trans_result.length > 0) {
// 结果在这里需要自行处理解析
console.log(res.trans_result);
}
} catch(err) {
console.error('err----', err);
}
});
})
- 需要一个vscode的交互面板,展示我们的中文和翻译结果,这里可以参考这位大佬的流程,也介绍了如何跟页面进行通信
这里有一个实现细节,右键翻译的时候如何定位我们的项目?
这里给出两个思路:
① 用package.json文件位置来寻找,向外层遍历文件
// 根据路径查找项目
export function getProjectPathByPackage(filePath: string, packagePath: string = 'package.json'): string {
// 递归地查找 package.json 文件
function findPackageJson(directory) {
let newDirPath = directory.split('/').slice(0,-1).join('/');
if(fse.statSync(newDirPath).isDirectory()) {
const files = fse.readdirSync(newDirPath);
if(files.includes(packagePath)) {
return newDirPath;
} else {
const result = findPackageJson(`${newDirPath}`);
if(result) {
return result;
}
}
} else {
// 一般肯定是项目文件夹,不是就在往上找
findPackageJson(directory);
}
return null;
}
// 调用函数进行查找
return findPackageJson(filePath);
}
② 根据vscode的activeTextEditor寻找工作空间目录
// 获取当前项目工作路径
let editor = window.activeTextEditor;
const currentDocumentUri = editor.document.uri;
const selectedWorkspaceFolder = vscode.workspace.getWorkspaceFolder(currentDocumentUri);
let channelPath = selectedWorkspaceFolder && selectedWorkspaceFolder.uri.fsPath;
编辑中的定制化功能
locales文件树
点击编辑按钮,进入到定制化的页面
可以看到左上角是src/locales下的所有多语言树
那么问题来了,我们怎么能获取到这个对象呢(这个问题当初做的时候很让我头秃,尝试了N多方法)
- 直接require或者import():
let path = '/users/project/xxx/src/locales/zh-CN.js'; // 项目路径
import(path); // 不支持
因为Import是js编译阶段运行,所以必须指定具体路径,不能是变量,而且据本人观察vscode的require底层调用的也是import,所以也是不行的
- 写脚本,然后通过tsx执行:
编译的时候获取不到变量,那么就在运行的时候获取,用ts执行一下js脚本
① 创建一个文件a.js,内容如下
init();
async function init(){
let path = '/Users/project/company/xxx/src/locales/index';
const data = await import(path);
console.log('default-----', data.default.default);
}
② 在package.json中加入命令然后执行npm run ppp
"scripts": {
"ppp": "npx tsx ./a.js",
}
执行可以拿到导出的export结果,但还是有缺陷:
tsx命令是用来替代原生 node 指令执行 TS 文件的,node20以上才支持,而且必须在运行项目的package.json中加入 type: "module"。
如果多语言文件数据量大一些,脚本执行的时间就会成倍的增加。
- 将src/locales通过文件流导入一个临时目录,在展示的webview中
import这个目录
// html页面中
import '/tmp/locales'
这个方案我在本地调试的时候非常顺利,一点问题都没有,但是打包之后就发现另一个问题:
/tmp/locales文件内容都变了,但是import导入的还是原来的对象,原因也不难理解:
vscode插件打包的时候会将路径中的对象当成快照存入插件的某个变量中,说到底还是因为在webview import的变量都是编译时候静态查找的
如果静态读取行不动,那能不能写入临时目录之后vscode端动态读取,然后在通过通信的方式传给webview呢,答案是可以,如下所示:
- 读写文件,根据文件内容自己拼装成对象树,分为以下几步:
① 将读取的文件内容通过babel转换成ast
const data = await fse.readFileSync('/Users/project/company/xxx/src/locales/index', 'utf8');
const ast = getTransAst(data);
② 拦截属性节点筛选出是对象节点还是普通的key
traverse(ast, {
// 属性
Property(path, state) {
if(path.get('value').isObjectExpression()) { // 节点是对象
} else {
// 节点是单个属性
}
}
})
③ 如果是对象,通过start和end的位置来确定父子关系,构造一个栈,栈里最后一个start位置小于当前节点并且end位置大于当前节点的就是它的父节点(因为babel是按照从上到下依次输出的)
ExportDefaultDeclaration: {
enter(path, state) {
// 栈的初值是最外层default根节点
stack.push({
startPos: path.node.start,
endPos: path.node.end,
name: 'default'
});
}
}
const { start, end } = path.get('value').node;
let handlePropertyArr = stack.filter((item) => item.startPos <= start && item.endPos >= end).slice(1); // 去掉最外层default
if(path.get('value').isObjectExpression()) {
currentWorkingPropertyObj = {}; // 每次对象都需要根据栈重新构建,因为不确定具体的包含关系
handlePropertyArr.push({
startPos: start,
endPos: end,
name: name,
});
stack.push({
startPos: start,
endPos: end,
name: name,
});
for(let i = handlePropertyArr.length - 1;i >= 0;i--) {
currentWorkingPropertyObj = {[handlePropertyArr[i].name]: currentWorkingPropertyObj}; // 顺序是由内向外,所以反向遍历栈
}
}
④ 普通属性直接拦截合并
let schema = currentWorkingPropertyObj;
const { value } = path.get('value').node;
let name = path.get('key').node.name;
// 正常的obj -> { aaa: 111 }
if(value) {
// 只有最外层default
if(handlePropertyArr.length === 0) {
schema[name] = value;
} else {
// 将{a: {b: {c: {}}}} -> {a: {b: {c: {d: 123}}}}
for(let i = 0;i < handlePropertyArr.length - 1;i++) {
schema = schema[handlePropertyArr[i].name];
}
// 合并属性
schema[handlePropertyArr[handlePropertyArr.length - 1].name] = {...schema[handlePropertyArr[handlePropertyArr.length - 1].name], [name]: value};
}
⑤ 将每层结果合并
propertysResult = deepMerge(propertysResult, currentWorkingPropertyObj)
为什么用deepMerge而不用Object.assign?
因为Object.assign只能进行浅合并,无法解决这种情况:
Object.assign({aaa: 123}, {aaa: {bbb: 456}}) // 会丢掉123只剩下 {bbb:456}
⑥ 放入临时目录中 注意,这里的临时目录并不是固定路径,而是node的os模块提供的临时目录路径
临时目录的目的是为了我们可以定制化自己的文件目录和数据格式(例如src/locales下没有index.js的情况)
import fse from 'fs-extra';
targetPath: string = `${os.tmpdir()}/locales`; // 临时文件存储路径
fse.copy(srcPath, targetPath).then(() => { // 将src/locales下的文件copy到临时目录中
instance[callback] && instance[callback](); // 之后渲染插件页面
});
可编辑key
因为table展示是自己画的table所以借助antd的可编辑输入框就能实现
冗余检查
如下图,值是否存在就是冗余检查,举个例子:
如果我要添加的对象是它的中文是
'测试',我会搜索整个多语言树里的测试,如果有多个就全都筛出来,让用户来判断用谁的key,在同步之后还可以撤回
解决办法就是把多语言中所有的中文都打平成一个map:
{
aaa: {
bbb: '测试'
}
}
// 转换为
let obj = { 'aaa.bbb': '测试' }
这样判断Object.values(obj)是否包含'测试'就可以了
保存功能
我把保存功能单拉出来说一下,是因为保存分为以下几个小功能
- 将代码里的中文替换为intl.getStr('xxKey')
- 将key:value添加到正确的位置
- 检测是否有intl的引入,如果没有需要加入import代码
- 检测settings中配置的语言是否有对应的国际化文件,如果没有需要按照现有中文的结构全量翻译
代码替换
使用vscode相关api来实现,与直接使用node进行文件读写的区别是需要自己点ctrl + s保存
- 先把代码置为编辑态
// 开启编辑态
await window.showTextDocument(window.activeTextEditor?.document.uri);
- 在前文拦截中文的时候记录下中文的位置loc
const ast = this.getTransAst();
traverse(ast, {
// 字符串字面量:let aaa = '哈哈哈'
StringLiteral: (path) => {
if(/[\u4e00-\u9fa5]/.test(path.node.value)) {
// 记录位置loc预备替换,id标识,
words.push({
loc: path.node.loc,
value: path.node.value,
id: uuid(),
// 表示是否是jsx的属性
// <div attr='哈哈哈'></div> ==> <div attr={intl.getStr('haha')}></div>
isJSXAttribute: path.parent.type === 'JSXAttribute',
});
}
},
// 模板字符中有中文 let aaa = `哈哈哈`
TemplateElement: (path) => {
if(/[\u4e00-\u9fa5]/.test(path.node.value.raw)) {
words.push({
loc: path.node.loc,
// 砍掉所有的\n \t等符号
value: path.node.value.cooked.trim().replace(/\s/, ''),
id: uuid(),
// { raw: ' 测试中文 \\t\n\n', cooked: ' 测试中文 \t\n\n' }
originalValue: path.node.value,
isTemplate: true,
});
}
},
JSXText: (path) => {
if(/[\u4e00-\u9fa5]/.test(path.node.value)) {
words.push({
loc: path.node.loc,
value: path.node.value.trim(),
id: uuid(),
originalValue: path.node.value,
isJSXText: true,
});
}
}
});
- 遍历words数组,替换为对应的模板字符串
async replaceEditorText(): Promise<void> {
await window?.activeTextEditor.edit(editBuilder => {
words.forEach((element) => {
const { loc } = element as any;
const startPosition = new Position(loc.start.line - 1, loc.start.column);
const endPosition = new Position(loc.end.line - 1, loc.end.column);
const selection = new Range(startPosition, endPosition);
if(!selection) {
return;
}
editBuilder.replace(selection, `intl.getStr(${element.key})`);
});
});
}
将key:value添加到多语言树的对应位置
在上文用户已经选择了具体的树节点:
记录下用户选择,用vscode的通信机制传到插件端就好,我们重点来说下如何根据节点查找到文件的具体位置:
- 首先需要找到所有语种的文件路径例如
src/locales/zh-CN.js,src/locales/en-US.js,读取文件内容 - 拿到用户选择的节点路径,例如数组
arr = [license,testLicTips] - 以语言文件作为递归的根节点,使用babel筛选节点的属性,如果能匹配上,那么将这个属性从路径数组中移除
- 直到路径数组长度为0,停止查找,并记录下此时节点的位置:
node.loc - 遍历属性,如果遇到import导入其他文件的情况,继续第一步读取文件内容
let loc = {}; // 要插入的具体位置
const filePath = importPath.resolve(localesPath, fileRelativePath); // 要插入的文件路径
try {
// 读取文件
const data = await fse.readFileSync(filePath, 'utf8');
const ast = getTransAst(data);
let importMap = {};
let fileNodeName: any = {};
traverse(ast, {
// 用户选择的路径只有一层根节点
ExportDefaultDeclaration: (nodePath) => {
if(arr.length === 0) {
loc = nodePath.node.loc;
nodePath.stop();
}
},
Program: {
enter(nodePath, state) {
nodePath.traverse({
ImportDeclaration(path) {
// nodePath.node拿到整个语句
const { specifiers, source: { value } } = path.node;
if(specifiers && specifiers.length > 0) {
specifiers.forEach((node) => {
// import aaa from path
if(['ImportSpecifier', 'ImportDefaultSpecifier', 'ImportNamespaceSpecifier'].includes(node.type)) {
importMap[node.local.name] = value;
}
});
}
},
});
}
}
});
traverse(ast, {
'Property|SpreadElement': (path) => {
path.traverse({
Identifier:(p) => {
const { node } = p;
// 1.匹配名字
// 2.arr的长度为0代表找到了位置和路径
let name = node.key ? node.key.name : node.name;
let value = node.value ? node.value.name : node.name;
if(name === copyArr[0]) {
copyArr.shift();
if(copyArr.length === 0) {
// 记录loc
loc = node.loc;
path.stop();
p.stop();
} else if(Object.keys(importMap).includes(value)) {
// 3.如果匹配到importMap读文件继续查找
isRecursion = true;
fileNodeName = name;
}
}
}
});
},
});
if(isRecursion) {
// 如果有import导入文件的情况,继续递归,然后再次查找
return await recursion(localesPath, importMap[fileNodeName], copyArr);
} else {
return {
filePath,
loc,
};
}
} catch(err) {
console.log('error-----', err);
}
- 拿到具体位置loc,在这个位置上插入key: value 读取的语言文件是个大的字符串,要想插入必须得打散,用什么打散呢? 用换行
import fse from 'fs-extra';
let data = fse.readFileSync(filePath, 'utf8').split(/\r\n|\n|\r/gm);
这样可以得到多个字符串片段
还有一个问题,我们得到的其实是个{'key': 'value'} 这样的对象,要怎么加工成 key: value这样的字符串呢?
JSON.stringify({aaa: 'value'}, null, 4).slice(2,-2) // 结果是 "aaa": 111
我们现在有要插入的位置loc,要插入的对象data,插入内容"aaa": 111,是不是直接用data.splice(loc.start, 0 , "aaa": 111) 这样就可以了呢,其实这里还有个问题:
在babel中拦截对象的字符串key和普通key用的不是一种类型,如下所示:
大家可以尝试一下
所以在插入之前需要对对象的key去除引号:
// 去除引号
function getNoQuoteObjByCode(code) {
return code.replace(/("(\\[^]|[^\\"])*"(?!\s*:))|"((\\[^]|[^\\"])*)"(?=\s*:)/g, '$1$3');
}
export async function writeWordsToLocalesFile(filePath, value, loc){
let data = fse.readFileSync(filePath, 'utf8').split(/\r\n|\n|\r/gm); // 用换行拆分
data.splice(loc.start.line, 0, `${getNoQuoteObjByCode(JSON.stringify(value, null, 4)).slice(2,-2)},`); // 这里根据路径所对应的函数来插入
fse.writeFileSync(filePath, data.join('\r\n')); // 拼接换行写入文件
}
检测intl的引入
- 首先在配置中插入需要的代码片段:
"configuration": {
"title": "multilingual-extension",
"properties": {
"multilingual-extension.Import Code": {
"type": "string",
"default": "import intl from '@/utils/intl';\n",
"description": "多语言引入语句."
},
"multilingual-extension.Import Name": {
"type": "string",
"default": "intl",
"description": "引入语句的引入变量."
},
}
}
- 在vscode插件中拿到:
const res = workspace.getConfiguration().get('multilingual-extension');
let importCodes = res['Import Code'];
let methodName = res['Import Name'];
- 使用babel拦截import,检测intl是否存在,如果不存在插入importCodes语句
let isImported = false;
const ast = this.getTransAst();
traverse(ast, {
Program: {
enter(nodePath, state) {
nodePath.traverse({
// 获取intl
ImportDefaultSpecifier: (path, state) => {
if (path.node.local.name === methodName) {
isImported = true;
path.stop();
}
},
// import { intl } from '@/utils/intl'获取intl
ImportSpecifier: (path) => {
if (path.node.local.name === methodName) {
isImported = true;
path.stop();
}
},
});
}
}
});
// 这里走vscode添加逻辑
if (!isImported) {
// nodejs读取文件插入
// importCode = generate(template('import intl from "@/utils/intl"')()).code
// const { code, map } = generate(ast);
// let content = `${importCode}\n${code}`
// fse.writeFileSync(path.join('./sourceCode.js'), content);
await window?.activeTextEditor?.edit(editBuilder => {
editBuilder.insert(new Position(0, 0), importCodes); // 通过vscode的api插入importCodes
});
}
检查国际化文件,没有就自动生成
- 拿到用户配置的语言列表
"multilingual-extension.itemLanguages": {
"type": "array",
"default": [
"zh:zh-CN",
"en:en-US",
"kor:kor-KOR"
],
"items": {
"type": "string"
},
"description": "语言列表对应文件名称."
},
const res = workspace.getConfiguration().get('multilingual-extension');
this.languages = res['itemLanguages']
- 判断用户配置的语言列表是否在locales文件夹里
import fse from 'fs-extra';
this.languages.forEach((item) => {
const isFileExists = fse.existsSync(`${this.localesFullPath}/${item.localFileName}.${this.fileType}`);
if(!isFileExists) {
this.readyGenerateLangs.push(item);
} else {
this.existLangs.push(item);
}
});
根据readyGenerateLangs长度是否为0判断语言文件是否能对齐,如果没对齐询问用户是否需要生成新的文件:
如果用户选择是,我们需要把中文的多语言对象树打平成Map格式,并喂给百度翻译
private async handleGenarateLangs(targetZHLocales) {
const { valuesObj } = convertToChildren(targetZHLocales, []);
const len = Object.values(valuesObj).length;
const generateKeys = Object.keys(valuesObj);
for(let i = 0;i < this.readyGenerateLangs.length;i++) {
let langGenRes = {};
const { langType, localFileName } = this.readyGenerateLangs[i];
let langGenerateArr = [];
let transPromises = [];
// 每次翻译上限大约390个
for(let j = 1;j <= Math.ceil(len / 390); j++) {
const transPromise = transWordsByLang(Object.values(valuesObj).slice((j - 1) * 390, j * 390) as string[], langType);
transPromises.push(transPromise);
}
Promise.all(transPromises).then(res => {
for(let i = 0;i < res.length;i++) {
let transResult = res[i];
transResult.forEach((ite, idx) => {
ite.genKey = generateKeys[i * 390 + idx];
});
langGenerateArr = [...langGenerateArr, ...transResult];
}
langGenerateArr.forEach((item) => {
const itemArr = item.genKey.split('.').reverse();
let currentItem = {[itemArr[0]]: item.dst};
for(let k = 1;k < itemArr.length;k++) {
currentItem = {[itemArr[k]]: currentItem};
}
langGenRes = deepMerge(langGenRes, currentItem);
});
let exportStr = 'export default ' + getNoQuoteObjByCode(JSON.stringify(langGenRes, null, 4)) + ';\n';
fse.writeFileSync(`${this.localesFullPath}/${localFileName}.${this.fileType}`, exportStr);
}
}
这里有个需要注意的点,百度翻译每次的上限大概是390个词条,超过了就会报错,所以将每批词条数量控制在390,然后把map中的key item.genKey拆解成对象合并到最后的结果langGenRes中,写入到多语言文件中
总结和展望
至此,插件的所有功能都已讲述完成,当然还有一些缺陷和改进:
- 支持vue文件的解析
- 设计上无法支持以下情况
// zh-CN.js中
{aaa: '将在【{remainDay}】天后过期'}
// react代码中动态注入变量
{intl.getStr('license.testLicTips.inDate.main', {
remainDay: licenseInfo?.remainDay,
})}
- 无法兼容seo
- 都是对代码进行操作,真正用起来冲突的地方会很多,需要人工merge
- 插件自动翻译之后,如果跟需求方(pm)有冲突,还需要再进行补充和修改
- 插件地址在此,如果需要可以下载
所以未来能不能有一个平台,来完美解决现有的这些问题呢?如果有下篇的话这些都会在下篇为大家揭晓
当然以后还会写一些vscode插件开发相关的文章,水平有限,希望大家可以提一些宝贵的意见和建议