手把手写一个vscode翻译插件
1. 背景
写这篇文章的初衷是看到vscode
市场上的中英翻译插件都是将翻译结果以弹窗的形式做的,体验感非常不好。如果有像有道字典那种打开一个弹窗或者新tab的翻译面板来进行使用就好了。但是找了很久都没有找到,所以就自己写了一个翻译面板插件
。成品如下:
如果想体验该翻译面板插件,请在vscode插件市场中搜索translation panel
并安装:
右键打开菜单栏,并选择
translation panel
,即可打开该翻译插件:
2. 技术栈
开发所需的技术是vscode
、vue
、webpack
。选择vue的原因是本人开发多以vue为主,所以就首选了vue。单从开发web来说,使用jq是比较合适的,毕竟页面的交互是比较简单的,难点是对vscode内部插件机制和api的熟练程度。
3. 新建模版工程
3.1 安装yo并创建工程
yo
是开发vscode插件的一个官方脚手架。
安装yo:
npm install -g yo generator-code
创建模板工程:
yo code
运行yo code
后出现以下信息:
我们选择
New Extension(JavaScript)
即可,也可以选择typescripe版本,本文章选择的是javascript版本。
选择完后,将下面的信息一一补充完整即可:
等待创建完毕安装并所需依赖,模板工程如下:
vscode的插件资源都是在
package.json
文件中指定开启的,extension.js
是整个插件的入口文件。
3.2 运行和调试
在打开extension.js
文件的面板上,我们按下f5
,这个时候就会进入调试状态,vscode会打开一个新的运行窗口:
在新的窗口中,我们同时按下
Ctrl+Shift+P
后输入helloWorld
,这个时候激发插件,然后会在控制台打印“Congratulations, your extension "translation-panel2" is now active!”,同时弹窗显示“Hello World from translation panel2!”:
4. 开发核心功能
4.1 注册资源
vscode插件的资源开启都是在package.json
中指定的,所以我们需要在package.json
在指定打开插件命令和激活插件时机:
// package.json
{
"activationEvents": [
"*" // 打开vscode的时候就激活插件
],
"main": "./extension.js",
"contributes": {
"commands": [
{
"command": "translation-panel2.helloWorld",
"title": "Hello World"
},
{
"command": "translation-panel2.open", // 打开命令
"title": "translation panel" // 在menus上会显示‘translation panel’
}
],
"menus": {
// 在vscode面板位置注册右键菜单指令,也就是‘translation-panel2.open’,
// 同时titl为上面的‘translation panel’
"editor/context": [
{
"when": "editorFocus",
"command": "translation-panel2.open", // 指令名称
"group": "navigation@1" // 在右键菜单上显示的位置
}
],
"explorer/context": [
{
"command": "translation-panel2.open", // 指令名称
"group": "navigation" // 在右键菜单上显示的位置
}
]
}
}
}
通过上面的配置,我们在vscode打开的时候就激活插件,同时注册了一个translation-panel2.open
指令用于打开翻译插件;在右键菜单栏上(menus)配置了translation-panel2.open
指令,那么在右键打开菜单的时候就可以看到translation panel
的条目,这个条目指定的就是translation-panel2.open
命令。
4.2 extension.js核心功能开发
除了需要在package.json中指定资源外,我们还要在入口文件(extension.js
)中注册我们的指令:
// extension.js
function activate(context) {
console.log("translation panel activated")
// 注册translation-panel2.open指令
context.subscriptions.push(vscode.commands.registerCommand('translation-panel2.open', (uri) => {
// 这里将会执行translation-panel2.showPanel指令
vscode.commands.executeCommand("extension.demo.showPanel")
}));
// 注册translation-panel2.showPanel指令
context.subscriptions.push(vscode.commands.registerCommand("translation-panel2.showPanel", function (uri) {
// 这里主要编写打开新窗口
}))
}
上面我们注册了两个指令:translation-panel2.open
和translation-panel2.showPanel
指令,translation-panel2.open用于右键菜单用,translation-panel2.open调用了translation-panel2.showPanel,translation-panel2.showPanel用于真正打开一个窗口来放我们的翻译面板代码。
我们在translation-panel2.showPanel在加入以下代码:
function activate(context) {
context.subscriptions.push(vscode.commands.registerCommand("translation-panel2.showPanel", function (uri) {
const panel = vscode.window.createWebviewPanel(
"translationPanel", // viewType
"translation panel", // title
vscode.ViewColumn.One, // position
{
enableScripts: true, // 支持js环境
retainContextWhenHidden: true // webview被隐藏时保持状态,避免被重置
}
)
let global = { panel }
// 打开的资源
panel.webview.html = getWebViewContent(context, 'src/view/panel.html')
}))
// vscode.commands.executeCommand("extension.demo.showPanel")
}
vscode.window.createWebviewPanel
用于创建一个新窗口,getWebViewContent
加载我们页面需要的html文件。
getWebViewContent代码如下:
const fs = require('fs');
const path = require('path');
/**
* 从某个HTML文件读取能被Webview加载的HTML内容
* @param {*} context 上下文
* @param {*} templatePath 相对于插件根目录的html文件相对路径
*
* panel.webview.html = getWebViewContent(context, 'src/view/test-webview.html')
*/
function getWebViewContent(context, templatePath) {
const resourcePath = path.join(context.extensionPath, templatePath);
const dirPath = path.dirname(resourcePath);
let html = fs.readFileSync(resourcePath, 'utf-8');
// vscode不支持直接加载本地资源,需要替换成其专有路径格式,这里只是简单的将样式和JS的路径替换
html = html.replace(/(<link.+?href="|<script.+?src="|<img.+?src=")(.+?)"/g, (m, $1, $2) => {
return $1 + vscode.Uri.file(path.resolve(dirPath, $2)).with({ scheme: 'vscode-resource' }).toString() + '"';
});
return html;
}
4.3 panel.html翻译面板页面开发
我们在src/view中新建三个文件panel.html、panel.css和panel.js,由于都是静态页面,我们需要新建lib文件来放我们的静态资源(vue.js).
panel.html代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<link rel="stylesheet" href="./base.css">
<link rel="stylesheet" href="./panel.css">
</head>
<body>
<div id="app" class="container-fluid">
<div class="container">
<div class="header">
<div class="header-left">
<div class="dropdown" style="width:50%">
<div class="dropdown-btn" @click="changeShowSourceMenu">
<span class="not-select">{{isChina?source.labelChinese:source.label}}</span>
<div :class="['triangle', {'triangle__rotate': showSourceMenu}]"></div>
</div>
<ul class="dropdown-menu" aria-labelledby="dropdownMenu1" v-if="showSourceMenu">
<li class="dropdown-menu-item not-select" v-for="(item, index) in option" :key="index"
@click="changeSource(item)">{{isChina?item.labelChinese:item.label}}</li>
</ul>
</div>
<?xml version="1.0" encoding="UTF-8"?><svg width="16" height="16" viewBox="0 0 48 48" fill="none"
@click="switchSourceTarget" class="switch" xmlns="http://www.w3.org/2000/svg">
<rect width="48" height="48" fill="white" fill-opacity="0.01" />
<path d="M42 19H5.99998" stroke="white" stroke-width="4" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M30 7L42 19" stroke="white" stroke-width="4" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M6.79897 29H42.799" stroke="white" stroke-width="4" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M6.79895 29L18.799 41" stroke="white" stroke-width="4" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</div>
<div class="header-right" style="margin-left:20px;width:50%">
<div class="dropdown">
<div class="dropdown-btn" @click="changeShowTargetMenu">
<span class="not-select">{{isChina?target.labelChinese:target.label}}</span>
<div :class="['triangle', {'triangle__rotate': showTargetMenu}]"></div>
</div>
<ul class="dropdown-menu" aria-labelledby="dropdownMenu1" v-if="showTargetMenu">
<li class="dropdown-menu-item not-select" v-for="(item, index) in option" :key="index"
@click="changeTarget(item)">{{isChina?item.labelChinese:item.label}}</li>
</ul>
</div>
<div class="translation-btn not-select" @click="translation">
<?xml version="1.0" encoding="UTF-8"?><svg width="16" height="16" viewBox="0 0 48 48"
fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="48" height="48" fill="white" fill-opacity="0.01" />
<path d="M17 32L19.1875 27M31 32L28.8125 27M19.1875 27L24 16L28.8125 27M19.1875 27H28.8125"
stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />
<path d="M43.1999 20C41.3468 10.871 33.2758 4 23.5999 4C13.9241 4 5.85308 10.871 4 20L10 18"
stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />
<path
d="M4 28C5.85308 37.129 13.9241 44 23.5999 44C33.2758 44 41.3468 37.129 43.1999 28L38 30"
stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span
class="translation-btn-text">{{isChina?translateBtnText.chinese:translateBtnText.english}}</span>
</div>
</div>
</div>
<div class="content">
<div class="inputContainer">
<textarea rows="10" class="input" v-model="inputText" @keydown="inputKeyDown"
:placeholder="isChina?placeholder.chinese:placeholder.english"></textarea>
</div>
<div class="result">
<p v-if="loading===false">{{result}}</p>
<div v-else class="sk-circle">
<div class="sk-circle1 sk-child"></div>
<div class="sk-circle2 sk-child"></div>
<div class="sk-circle3 sk-child"></div>
<div class="sk-circle4 sk-child"></div>
<div class="sk-circle5 sk-child"></div>
<div class="sk-circle6 sk-child"></div>
<div class="sk-circle7 sk-child"></div>
<div class="sk-circle8 sk-child"></div>
<div class="sk-circle9 sk-child"></div>
<div class="sk-circle10 sk-child"></div>
<div class="sk-circle11 sk-child"></div>
<div class="sk-circle12 sk-child"></div>
</div>
</div>
</div>
</div>
</div>
<script src="../../lib/vue-2.5.17/vue.js"></script>
<script src="../../src/view/panel.js"></script>
</body>
</html>
panel.html主要放了我们的页面接口,同时引入了css资源和vue.js,以及我们panel.js
4.4 panel.js开发
panel.js中我们主要解决点击翻译的时候调用vscode的内部通信,然后在extension.js中调用翻译api的过程。为什么不在panel.js
直接调用翻译api进行请求呢?由于我们开发的是静态页面,直接调用翻译api会跨域
,这个问题是不可以避免的。但是在extension.js中进行请求时不会跨域的,因为vscode在extension.js没有做跨域限制。
const vue = new Vue({
el: '#app',
methods: {
// 翻译调用
translation() {
callVscode({
cmd: 'translation',
queryParams: {
inputText: that.inputText,
from: that.source.value,
to: that.target.value,
}
}, (data) => {
// console.log("data:", data)
const result = data.trans_result || []
if (result && result.length > 0) {
that.result = result[0].dst
}
});
},
}
});
在翻译方法中,我们调用callVscode
方法,这个主要是调用vscode.postMessage()
方法进行全局通信:
const testMode = false; // 为true时可以在浏览器打开不报错
// vscode webview 网页和普通网页的唯一区别:多了一个acquireVsCodeApi方法
const vscode = testMode ? {} : acquireVsCodeApi();
const callbacks = {}; // 缓存callback
/**
* 调用vscode原生api
* @param data 可以是类似 {cmd: 'xxx', param1: 'xxx'},也可以直接是 cmd 字符串
* @param cb 可选的回调函数
*/
function callVscode(data, cb) {
if (typeof data === 'string') {
data = { cmd: data };
}
if (cb) {
// 时间戳加上5位随机数
const cbid = Date.now() + '' + Math.round(Math.random() * 100000);
callbacks[cbid] = cb;
data.cbid = cbid;
}
vscode.postMessage(data);
}
我们调用panel.js的翻译方法的时候,向全局发送了一个{cmd: 'translation'}
的信息,我们只要在extension.js接收,同时调用翻译api,最后再向panel.js返回翻译结果即可:
// extension.js
function activate(context) { context.subscriptions.push(vscode.commands.registerCommand("translation-panel2.showPanel", function (uri) {
let global = { panel }
// 注册监听全局事件
panel.webview.onDidReceiveMessage(message => {
// 响应固定响应信息 {cmd: 'translation'}
if (messageHandler[message.cmd]) {
messageHandler[message.cmd](global, message)
} else {
util.showError(`未找到名为${message.cmd}回调方法!`)
}
}, undefined, context.subscriptions)
}))
}
我们在上面的代码中加入了注册监听全局事件,同时响应特殊的全局信息({cmd: 'translation'}
)。
messageHandler主要是存放特殊信息的响应:
// extension.js
/**
* 存放所有消息回调函数,根据 message.cmd 来决定调用哪个方法
*/
const messageHandler = {
translation(global, message) {
const {
inputText = "",
from = "",
to = "",
} = message.queryParams
let appId = '20220505001204018'
let appKey = 'iTQzl__y2EL_sq3iaDmy'
const baseUrl = 'https://fanyi-api.baidu.com/api/trans/vip/translate'
const salt = uuidv4.v4();
const sign = Md5(appId + inputText + salt + appKey).toString()
const queryParams = {
q: inputText,
from: from,
to: to,
appid: appId,
salt: salt,
sign: sign
}
axios({
method: 'post',
url: baseUrl,
params: queryParams
}).then(res => {
invokeCallback(global.panel, message, res.data); // 调用通信
}).catch(err => {
}).finally(() => {
global.panel.webview.postMessage({ cmd: 'loading' });
})
}
};
messageHandler中主要响应translation
,也就是翻译指令。上面的翻译api是有道翻译,可以换成自己想用的翻译api,同时替换自己的key。翻译结果回来后,我们调用了invokeCallback(global.panel, message, res.data)
,用于发送信息到panel.js。
// extension.js
/**
* 执行回调函数
* @param {*} panel
* @param {*} message
* @param {*} resp
*/
function invokeCallback(panel, message, resp) {
// 向webview中发送信息
panel.webview.postMessage({ cmd: 'vscodeCallback', cbid: message.cbid, data: resp });
}
这个时候我们需要在panel.js中监听extension发送的翻译结果信息
:
window.addEventListener('message', event => {
const message = event.data;
switch (message.cmd) {
case 'vscodeCallback':
(callbacks[message.cbid] || function () { })(message.data);
delete callbacks[message.cbid];
break;
}
});
panel.js的监听信息主要是响应{ cmd: 'vscodeCallback'}
的指令,上面我们缓存的callbacks是加了唯一标识来缓存的,直接调用即可,也就是callbacks[message.cbid]
。
const vue = new Vue({
el: '#app',
methods: {
// 翻译调用
translation() {
callVscode({
cmd: 'translation',
queryParams: {
inputText: that.inputText,
from: that.source.value,
to: that.target.value,
}
}, (data) => {
// 回调函数
const result = data.trans_result || []
if (result && result.length > 0) {
that.result = result[0].dst
}
});
},
}
});
至此,一个翻译流程已经闭环了。从panel.js
中发送翻译指令到extension.js
中,extension.js
调用翻译api,然后将翻译结果发送回panel.js
。
5. 打包
5.1 打包extension.js
打包的时候我们需要借助webpack,由于我们在extension调用api的时候引入了axios、md5等资源,需要打包压缩。
新建build文件夹,同时新建node-extension.webpack.config.js:
// build/node-extension.webpack.config.js
'use strict';
const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const config = {
target: 'node', // vscode extensions run in a Node.js-context 📖 -> https://webpack.js.org/configuration/node/
mode: 'none', // this leaves the source code as close as possible to the original (when packaging we set this to 'production')
entry: './extension.js',
output: {
path: path.resolve(__dirname, '..', 'dist'),
filename: 'extension.min.js',
libraryTarget: 'commonjs2',
devtoolModuleFilenameTemplate: '../[resource-path]'
},
devtool: 'source-map',
externals: {
vscode: 'commonjs vscode'
},
resolve: {
// extensions: ['.js']
},
plugins: [
new CleanWebpackPlugin()
],
module: {
rules: []
}
};
module.exports = config;
然后我们在package.js中加入打包命令:
{
"scripts": {
"build": "webpack --mode production --config ./build/node-extension.webpack.config.js",
},}
5.2 打包插件
先全局安装vsce
npm i vsce -g
然后执行打包命令
vsce package ## yarn方式
vsce package --no-yarn ## npm 方式
6. 本地测试
经过上线的打包插后生成了一个以.vsix
后缀的插件包,我们在插件包的上右键,选择安装。
安装完毕后,我们在空白处右键菜单,就可以看到我们的插件名称了,同时选择translation panel
,就可以打开我们的插件:
最后的成品如下:
7. 发布
7.1 申请Microsoft账号
访问 Sign in to your Microsoft account 登录你的Microsoft
账号,没有的先注册一个
7.2 创建Azure DevOps
组织
点击继续,默认会创建一个以邮箱前缀为名的组织。
7.3 创建令牌
进入组织的主页后,点击右上角的Security,
点击创建新的个人访问令牌,这里特别要注意Organization
要选择all accessible organizations
,Scopes
要选择Full access
,否则后面发布会失败。
创建令牌成功后你需要本地记下来
,因为网站是不会帮你保存的,后面登录需要用到,如果没保存,后面需要用到的话,就只能重新创建令牌了
7.4 创建发布账号
7.5 发布
登录账号:
vsce login atdow
发布:
vsce publish
发布成功后我们可以在marketplace.visualstudio.com/manage/publ… 看到我们插件的状态:
7.6 发布后安装
当插件成功发布后,我们可以在插件插件市场中找到我们插件,并安装使用:
8. 总结
我们从零开始,从搭建项目、开发插件到最后的插件发布,基本涵盖了制作一个vscode翻译插件流程,虽然拓展的vscode api细节没有在本文中提到。如果有需要学习更多的vscode插件的知识,可以到官方网站中查阅相关文章。
转载自:https://juejin.cn/post/7158440931320922120