likes
comments
collection
share

VSCode插件开发-webview

作者站长头像
站长
· 阅读数 30

说明

在实际插件业务开发中,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
评论
请登录