VSCode插件开发-webview
说明
在实际插件业务开发中,webview是最常用的功能之一,可以自定义和嵌套在大部分视图中,包括TreeDataProvider, Panel等
基础使用
注意:注册的命令必须在package.json中的contributes.commands声明,名称要保持一致
const disposable = vscode.commands.registerCommand('vscode-plugin.openWebview', () => {
const panel = vscode.window.createWebviewPanel(
'testWebview', // ID
"WebView演示", // 标题
vscode.ViewColumn.Beside, // 显示在编辑器的哪个部位
{
enableScripts: true, // 启用JS,默认禁用
retainContextWhenHidden: true, // webview被隐藏时保持状态,避免被重置
}
);
panel.webview.html = `<html><body>Hello World</body></html>`
});
context.subscriptions.push(disposable);
"contributes": {
"commands": [
{
"command": "vscode-plugin.openWebview",
"title": "openWebview"
}
]
},
参数说明
- viewType 相当于ID,必须唯一
- title 标题
- showOptions 展示的位置
- Active 当前活跃编辑器中新建一个视图
- Beside 当前活跃编辑器的右边新建一个视图(如果没有会新建),也就是并排展示
- One - Nine 第几个编辑器,如果不足,每次会新建一个编辑器,
- options
- enableScripts 是否启用JS,为了安全默认禁用
- retainContextWhenHidden 是否保持在后台,为了性能, 默认需要重新加载
通信
实际开发中,我们需要使用vue3或者react, 下面以vue3示例展示如何相互通信,script setup 语法需要构建工具才可以使用
项目根目录新建一个文件test.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
</head>
<body>
<div id="app">
<h1>{{ count }}</h1>
<button @click="increment">Increment</button>
</div>
<script>
const vscode = acquireVsCodeApi();
const { createApp, ref } = Vue;
createApp({
setup() {
const count = ref(0);
const increment = () => {
count.value++;
vscode.postMessage({
command: 'alert',
text: '🐛 on line ' + count.value
})
};
window.addEventListener('message', event => {
const message = event.data;
switch (message.command) {
case 'change':
count.value = message.value
break;
}
});
return {
count,
increment
};
}
}).mount('#app');
</script>
</body>
</html>
extension.ts
const getWebViewContent = (pagePath: string, basePath: string) => {
const resourcePath: string = path.join(basePath, pagePath);
return fs.readFileSync(resourcePath, 'utf-8')
}
export function activate(context: vscode.ExtensionContext) {
let panel: vscode.WebviewPanel | null = null;
const disposable = vscode.commands.registerCommand('vscode-plugin.openWebview', () => {
panel = vscode.window.createWebviewPanel(
'testWebview', // ID
"WebView演示", // 标题
vscode.ViewColumn.Active, // 显示在编辑器的哪个部位
{
enableScripts: true, // 启用JS,默认禁用
retainContextWhenHidden: false, // webview被隐藏时保持状态,避免被重置
}
);
panel.webview.html = getWebViewContent('test.html', context.extensionPath)
panel.webview.onDidReceiveMessage(
message => {
switch (message.command) {
case 'alert':
vscode.window.showErrorMessage(message.text);
return;
}
},
undefined,
context.subscriptions
);
});
const disposable1 = vscode.commands.registerCommand('vscode-plugin.changeCount', () => {
if(!panel) return;
panel.webview.postMessage({ command: 'change', value: 123 });
});
context.subscriptions.push(disposable);
context.subscriptions.push(disposable1);
}
package.json
"contributes": {
"commands": [
{
"command": "vscode-plugin.openWebview",
"title": "openWebview"
},
{
"command": "vscode-plugin.changeCount",
"title": "changeCount"
}
]
},
正常表现是点击按钮,右下角会弹出提示,执行changeCount命令可以看到页面中的数值被更改为123
嵌入vue3项目
webview实际上还是普通的页面开发,只是有些信息需要通过vscode的api获取,我们可以把vscode相关的部分抽离出来
本地嵌套
方案:在插件项目中可以用git submodule内嵌入一个vue3子项目,从项目打包后的dist获取对应页面代码
- 缺点:调试不方便,必须用观察编译模式,编译完还要重新加载调试插件才能生效
- 优点:集成在项目中,不用单独部署,方便查看
资源文件的处理
为了安全,Webview中所有静态资源必须使用Webview.asWebviewUri函数转换,
必须要替换html中资源文件路径为vscode内部路径
html = html.replace(/(<link.+?href=|<script.+?src=|<img.+?src=)(.+?)(\s+|>)/g, (m, $1, $2, $3) => {
return $1 + '"' + webviewPanel.asWebviewUri(vscode.Uri.file(path.join(basePath, 'dist', $2.replace(/"/g, '')))).toString() + '"' + $3;
});
base标签无法对css中的url(),比如图标等进行单独替换
嵌套iframe
方案: 使用iframe嵌套,第一层webview作为中转,第二层是实际的业务开发,后期可以和IDEA复用
- 缺点:需要单独部署
- 优点:可以独立发布,不受插件影响
下面用选择文件路径或文件夹的功能
为例:
插件部分
extension.ts
const getWebViewContent = (pagePath: string, basePath: string, url: string) => {
const resourcePath: string = path.join(basePath, pagePath);
return fs.readFileSync(resourcePath, 'utf-8').replace('${url}', url)
}
// 选择文件或目录
const selectFileOrPath = async (params?: any) => {
const defaultUri = params?.initPath ? vscode.Uri.file(params.initPath) : undefined
const defaultOptions: vscode.OpenDialogOptions = {
defaultUri,
canSelectFiles: true,
canSelectFolders: false,
canSelectMany: true,
title: '选择文件'
}
const options = Object.assign(defaultOptions, params)
const result: vscode.Uri[] | undefined = await vscode.window.showOpenDialog(options)
return result?.length ? result.map((item) => item.fsPath.toString()) : []
}
const defaultMessgae: {
to: string
from: string
command: string
data: any | null
} = {
to: 'webview',
from: 'vscode',
command: '',
data: null
}
export function activate(context: vscode.ExtensionContext) {
let panel: vscode.WebviewPanel | null = null;
const disposable = vscode.commands.registerCommand('vscode-plugin.openWebview', () => {
panel = vscode.window.createWebviewPanel(
'testWebview', // ID
"WebView演示", // 标题
vscode.ViewColumn.Active, // 显示在编辑器的哪个部位
{
enableScripts: true, // 启用JS,默认禁用
retainContextWhenHidden: false, // webview被隐藏时保持状态,避免被重置
}
);
panel.webview.html = getWebViewContent('template.html', context.extensionPath, 'http://127.0.0.1:5173/')
const sendMessage = (message: any) => {
panel?.webview.postMessage(message);
}
panel.webview.onDidReceiveMessage(
async event => {
console.log('onDidReceiveMessage', event);
const message = event.data;
switch (message.callName) {
case 'selectFileOrPath':
const res = await selectFileOrPath(message.callParams);
if (message.callbackName) {
sendMessage({
...defaultMessgae,
command: message.callbackName,
data: res
})
}
}
},
undefined,
context.subscriptions
);
});
context.subscriptions.push(disposable);
}
template.html
<!DOCTYPE html>
<html style="height: 100%">
<body style="height: 100%;padding: 0;overflow: hidden">
<iframe id="iframe" src="${url}" width="100%" height="100%"></iframe>
</body>
<script>
const vscode = acquireVsCodeApi();
const iframe = document.getElementById('iframe')
window.addEventListener('message', event => {
const message = event.data;
console.log('中转', event);
if (message.from === 'webview' && message.to === 'vscode') {
vscode.postMessage(message);
return
}
if (message.from === 'vscode' && message.to === 'webview') {
iframe.contentWindow.postMessage(message, '*')
}
});
</script>
</html>
vue3项目
新项目
pnpm i mitt
App.vue
<script setup lang="ts">
import mitt from 'mitt';
type Events = {
[x: string]: any
}
const emitter = mitt<Events>();
provide('emitter', emitter);
window.addEventListener('message', (event: any) => {
console.log('接收消息', event);
const message = event.data;
emitter.emit(message.command, message.data)
});
const selectFilePath = reactive({
value: '',
label: '文件路径',
type: 'file'
})
const selectFolderPath = reactive({
value: '',
label: '文件路径',
type: 'folder'
})
</script>
<template>
<div class="bg-white h-screen flex items-center justify-center">
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }" autocomplete="off">
<a-form-item label="选择文件">
<div class="flex-1 flex">
<a-input v-model:value="selectFilePath.value" />
<v-select-path v-model:value="selectFilePath.value" :type="selectFilePath.type" />
</div>
</a-form-item>
<a-form-item label="选择文件夹">
<div class="flex-1 flex">
<a-input v-model:value="selectFolderPath.value" />
<v-select-path v-model:value="selectFolderPath.value" :type="selectFolderPath.type" />
</div>
</a-form-item>
</a-form>
</div>
</template>
<style scoped></style>
components/vscode/v-select-path.vue
<script setup lang="ts">
const props = defineProps<{
value: string;
type?: string;
filters?: any;
}>();
const emit = defineEmits(['update:value']);
const path = computed({
get: () => {
return props.value;
},
set: (val: string) => {
emit('update:value', val);
},
});
const selectParams = ref<any>({
canSelectFiles: true,
canSelectFolders: false,
canSelectMany: true,
title: '选择文件',
});
watchEffect(() => {
if (props.type === 'folder') {
selectParams.value = {
canSelectFiles: false,
canSelectFolders: true,
canSelectMany: true,
title: '选择目录',
};
}
if (props.filters) {
selectParams.value.filters = props.filters;
}
});
const emitter = inject('emitter') as any;
const callbackName = uniqueId('selectFileOrPath');
const selectFile = async () => {
executeCommand('selectFileOrPath', callbackName, toRaw(selectParams.value))
}
const updatePath = (value: string[]) => {
if (value && value.length > 0) {
path.value = value.join(',');
}
};
const callbackObj = {
[callbackName]: updatePath,
};
onMounted(() => {
emitter.on(callbackName, updatePath);
});
onUnmounted(() => {
emitter.off(callbackName, callbackObj[callbackName]);
});
</script>
<template>
<a-button type="primary" @click="selectFile()">选择</a-button>
</template>
<style scoped>
.select-item {
flex: 1;
display: flex;
align-items: center;
gap: 2;
}
</style>
utils/index.ts
export const uniqueId = (
counter =>
(str = '') =>
`${str}${++counter}`
)(0)
export const executeCommand = (callName = '', callbackName = '', callParams: any = null,) => {
console.log('executeCommand', callParams);
if (!window.parent) return
window.parent.postMessage(
{
data: {
callName,
callParams,
callbackName,
},
from: 'webview',
to: 'vscode',
},
"*",
);
}
iframe嵌套响应头
allow-from已被废弃
"Content-Security-Policy": "frame-ancestors '*'"
转载自:https://juejin.cn/post/7369527074590212147