手把手写一个vscode翻译插件

978 阅读8分钟

1. 背景

写这篇文章的初衷是看到vscode市场上的中英翻译插件都是将翻译结果以弹窗的形式做的,体验感非常不好。如果有像有道字典那种打开一个弹窗或者新tab的翻译面板来进行使用就好了。但是找了很久都没有找到,所以就自己写了一个翻译面板插件。成品如下:

image.png

仓库地址:github.com/atdow/trans…

如果想体验该翻译面板插件,请在vscode插件市场中搜索translation panel并安装:

image.png 右键打开菜单栏,并选择translation panel,即可打开该翻译插件:

image.png

2. 技术栈

开发所需的技术是vscodevuewebpack。选择vue的原因是本人开发多以vue为主,所以就首选了vue。单从开发web来说,使用jq是比较合适的,毕竟页面的交互是比较简单的,难点是对vscode内部插件机制和api的熟练程度。

3. 新建模版工程

3.1 安装yo并创建工程

yo是开发vscode插件的一个官方脚手架。

安装yo:

npm install -g yo generator-code

创建模板工程:

yo code

运行yo code后出现以下信息:

image.png 我们选择New Extension(JavaScript)即可,也可以选择typescripe版本,本文章选择的是javascript版本。

选择完后,将下面的信息一一补充完整即可:

image.png

等待创建完毕安装并所需依赖,模板工程如下:

image.png vscode的插件资源都是在package.json文件中指定开启的,extension.js是整个插件的入口文件。

3.2 运行和调试

在打开extension.js文件的面板上,我们按下f5,这个时候就会进入调试状态,vscode会打开一个新的运行窗口:

image.png 在新的窗口中,我们同时按下Ctrl+Shift+P后输入helloWorld,这个时候激发插件,然后会在控制台打印“Congratulations, your extension "translation-panel2" is now active!”,同时弹窗显示“Hello World from translation panel2!”:

image.png

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.opentranslation-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

image.png

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后缀的插件包,我们在插件包的上右键,选择安装。

image.png

安装完毕后,我们在空白处右键菜单,就可以看到我们的插件名称了,同时选择translation panel,就可以打开我们的插件:

image.png

最后的成品如下:

image.png

7. 发布

7.1 申请Microsoft账号

访问 Sign in to your Microsoft account 登录你的Microsoft账号,没有的先注册一个

image.png

7.2 创建Azure DevOps组织

访问: aka.ms/SignupAzure…

image.png 点击继续,默认会创建一个以邮箱前缀为名的组织。

7.3 创建令牌

进入组织的主页后,点击右上角的Security,点击创建新的个人访问令牌,这里特别要注意Organization要选择all accessible organizationsScopes要选择Full access,否则后面发布会失败。

image.png

image.png

image.png 创建令牌成功后你需要本地记下来,因为网站是不会帮你保存的,后面登录需要用到,如果没保存,后面需要用到的话,就只能重新创建令牌了

7.4 创建发布账号

访问:aka.ms/vscode-crea…

image.png

image.png

7.5 发布

登录账号:

vsce login atdow

image.png 发布:

vsce publish

发布成功后我们可以在marketplace.visualstudio.com/manage/publ… 看到我们插件的状态:

image.png

7.6 发布后安装

当插件成功发布后,我们可以在插件插件市场中找到我们插件,并安装使用:

image.png

8. 总结

我们从零开始,从搭建项目、开发插件到最后的插件发布,基本涵盖了制作一个vscode翻译插件流程,虽然拓展的vscode api细节没有在本文中提到。如果有需要学习更多的vscode插件的知识,可以到官方网站中查阅相关文章。