WPSJS 调用 taskpane 与 dialog 的封装示例
2026-03-23
可以。我直接按“能放进你现有 Vue 工程里”的标准来封。
先说结论:
WPS 侧我建议你封成三层:
一、封装目标
1. UI 层不直接碰 WPS API
你的 Vue 页面里只调用:
openTaskPane()openDialog()closeDialog()sendMessageToHost()onMessage()
这样后面你切换真实 WPS API、浏览器调试、补兼容,都只改一层。
2. 页面通信不要依赖“同一个 Vue 实例”
而改成统一消息机制:
优先
BroadcastChannel其次
localStorage再加
postMessage兜底
因为 taskpane 和 dialog 本质是不同网页容器,WPS 官方也明确说 taskpane 是通过网页路径创建,dialog 也是根据 URL 创建 web 页面。(WPS开放平台)
3. TaskPane 做“单例优先”
WPS 有 CreateTaskpane 和 GetTaskpane 这组能力,说明它本身就支持创建与获取 taskpane。(WPS开放平台)
所以封装时应该优先考虑:
已存在就显示/复用
不存在再创建
二、你可以直接用的封装目录
建议加这几个文件:
src/shared/wps/ ├─ types.ts ├─ runtime.ts ├─ channel.ts ├─ dialog.ts ├─ taskpane.ts ├─ app.ts └─ index.ts
三、核心类型定义
src/shared/wps/types.ts
export type WpsMessageType = | 'dialog:ready' | 'dialog:close' | 'dialog:result' | 'taskpane:ready' | 'taskpane:refresh' | 'auth:success' | 'auth:logout' | 'picker:selected' | 'custom' export interface WpsMessage<T = any> { id: string type: WpsMessageType | string source: 'taskpane' | 'dialog' | 'host' | 'unknown' target?: 'taskpane' | 'dialog' | 'host' | 'all' payload?: T timestamp: number } export interface OpenDialogOptions { url: string title?: string width?: number height?: number modal?: boolean resizable?: boolean singletonKey?: string query?: Record<string, string | number | boolean | undefined | null> } export interface OpenTaskPaneOptions { url: string title?: string visible?: boolean width?: number singletonKey?: string query?: Record<string, string | number | boolean | undefined | null> } export interface WpsTaskPaneRef { visible?: boolean width?: number title?: string show?: () => void hide?: () => void close?: () => void [key: string]: any } export interface WpsDialogRef { close?: () => void [key: string]: any } export interface WpsApplicationLike { CreateTaskpane?: (...args: any[]) => any GetTaskpane?: (...args: any[]) => any ShowDialog?: (...args: any[]) => any alert?: (message: string) => void confirm?: (message: string) => boolean [key: string]: any } declare global { interface Window { wps?: any WPS?: any WpsApplication?: any Application?: any } }四、运行时探测层
这个文件的作用是:
找到宿主里的 WPS 对象
统一判断当前是不是在 WPS 里
非 WPS 环境时自动降级到浏览器调试模式
src/shared/wps/runtime.ts
import type { WpsApplicationLike } from './types' export function getWpsApplication(): WpsApplicationLike | null { return ( window.wps || window.WPS || window.WpsApplication || window.Application || null ) } export function isWpsRuntime(): boolean { return !!getWpsApplication() } export function safeAlert(message: string) { const app = getWpsApplication() if (app?.alert) { app.alert(message) return } window.alert(message) } export function safeConfirm(message: string): boolean { const app = getWpsApplication() if (app?.confirm) { return !!app.confirm(message) } return window.confirm(message) }五、统一消息总线
这部分是关键。
因为多个 taskpane / dialog 不是同一个 Vue 实例,所以我们用统一通道来做页面间通信。
设计思路:
1. 优先 BroadcastChannel
现代 WebView 通常支持得不错,使用最干净。
2. 降级 localStorage
兼容性更稳。
3. 顺手支持 postMessage
方便浏览器调试和父子窗口场景。
src/shared/wps/channel.ts
import type { WpsMessage } from './types' const CHANNEL_NAME = 'wps-addin-channel' const STORAGE_KEY = '__wps_addin_channel__' type MessageHandler = (message: WpsMessage) => void let bc: BroadcastChannel | null = null const handlers = new Set<MessageHandler>() let initialized = false function uuid() { return `${Date.now()}_${Math.random().toString(36).slice(2, 10)}` } export function createMessage<T = any>( type: string, payload?: T, source: WpsMessage['source'] = 'unknown', target: WpsMessage['target'] = 'all', ): WpsMessage<T> { return { id: uuid(), type, source, target, payload, timestamp: Date.now(), } } function dispatch(message: WpsMessage) { handlers.forEach((fn) => { try { fn(message) } catch (error) { console.error('[wps-channel] handler error:', error) } }) } function init() { if (initialized) return initialized = true if ('BroadcastChannel' in window) { bc = new BroadcastChannel(CHANNEL_NAME) bc.onmessage = (event) => { if (event?.data) dispatch(event.data) } } window.addEventListener('storage', (event) => { if (event.key !== STORAGE_KEY || !event.newValue) return try { const message = JSON.parse(event.newValue) as WpsMessage dispatch(message) } catch (error) { console.error('[wps-channel] storage parse error:', error) } }) window.addEventListener('message', (event) => { const data = event.data if (!data || typeof data !== 'object') return if (data.__wps_addin_message__ !== true) return dispatch(data.message as WpsMessage) }) } export function emitMessage(message: WpsMessage) { init() if (bc) { bc.postMessage(message) } try { localStorage.setItem(STORAGE_KEY, JSON.stringify(message)) } catch (error) { console.error('[wps-channel] localStorage error:', error) } try { if (window.opener) { window.opener.postMessage( { __wps_addin_message__: true, message }, '*', ) } if (window.parent && window.parent !== window) { window.parent.postMessage( { __wps_addin_message__: true, message }, '*', ) } } catch (error) { console.error('[wps-channel] postMessage error:', error) } } export function onMessage(handler: MessageHandler) { init() handlers.add(handler) return () => handlers.delete(handler) }六、TaskPane 封装
这里我按“单例优先 + 浏览器可调试”来写。
WPS 官方已有 CreateTaskpane 和 GetTaskpane。(WPS开放平台)
src/shared/wps/taskpane.ts
import { getWpsApplication, isWpsRuntime } from './runtime' import type { OpenTaskPaneOptions, WpsTaskPaneRef } from './types' const taskPaneMap = new Map<string, WpsTaskPaneRef>() function buildUrl( baseUrl: string, query?: Record<string, string | number | boolean | undefined | null>, ) { const url = new URL(baseUrl, window.location.origin) if (query) { Object.entries(query).forEach(([key, value]) => { if (value === undefined || value === null) return url.searchParams.set(key, String(value)) }) } return url.toString() } function getCacheKey(options: OpenTaskPaneOptions) { return options.singletonKey || options.url } export function getTaskPane(singletonKey: string): WpsTaskPaneRef | null { if (taskPaneMap.has(singletonKey)) { return taskPaneMap.get(singletonKey) || null } const app = getWpsApplication() if (!app?.GetTaskpane) return null try { const pane = app.GetTaskpane(singletonKey) if (pane) { taskPaneMap.set(singletonKey, pane) return pane } } catch (error) { console.warn('[wps-taskpane] GetTaskpane failed:', error) } return null } export function openTaskPane(options: OpenTaskPaneOptions): WpsTaskPaneRef | null { const app = getWpsApplication() const cacheKey = getCacheKey(options) const finalUrl = buildUrl(options.url, options.query) const existed = getTaskPane(cacheKey) if (existed) { try { existed.visible = true if (typeof existed.show === 'function') existed.show() if (options.width != null) existed.width = options.width if (options.title) existed.title = options.title return existed } catch (error) { console.warn('[wps-taskpane] reuse existing pane failed:', error) } } if (!isWpsRuntime() || !app?.CreateTaskpane) { console.warn('[wps-taskpane] not in WPS runtime, fallback to window.open') window.open(finalUrl, '_blank') return null } try { // 注意: // 官方文档说明 CreateTaskpane 存在,但页面抓取无法稳定拿到完整签名。 // 下面做“宽松调用”,适配不同运行环境。 const pane = app.CreateTaskpane(finalUrl, options.title || '', options.visible ?? true) || app.CreateTaskpane(finalUrl) if (pane) { if (options.width != null) pane.width = options.width if (options.title) pane.title = options.title if (options.visible != null) pane.visible = options.visible taskPaneMap.set(cacheKey, pane) return pane } } catch (error) { console.error('[wps-taskpane] CreateTaskpane failed:', error) } return null } export function showTaskPane(singletonKey: string) { const pane = getTaskPane(singletonKey) if (!pane) return try { pane.visible = true if (typeof pane.show === 'function') pane.show() } catch (error) { console.error('[wps-taskpane] show failed:', error) } } export function hideTaskPane(singletonKey: string) { const pane = getTaskPane(singletonKey) if (!pane) return try { pane.visible = false if (typeof pane.hide === 'function') pane.hide() } catch (error) { console.error('[wps-taskpane] hide failed:', error) } } export function closeTaskPane(singletonKey: string) { const pane = getTaskPane(singletonKey) if (!pane) return try { if (typeof pane.close === 'function') { pane.close() } else { pane.visible = false } taskPaneMap.delete(singletonKey) } catch (error) { console.error('[wps-taskpane] close failed:', error) } }七、Dialog 封装
WPS 官方说明 ShowDialog 用给定 url、title、width、height 等信息来创建对话框,而且内容本身就是 web 页面。(WPS开放平台)
这里封装目标:
1. 支持 query 传参
2. 支持浏览器降级调试
3. 支持“逻辑单例”
避免一个按钮狂点弹出多个相同 dialog
src/shared/wps/dialog.ts
import { getWpsApplication, isWpsRuntime } from './runtime' import type { OpenDialogOptions, WpsDialogRef } from './types' const dialogMap = new Map<string, WpsDialogRef>() function buildUrl( baseUrl: string, query?: Record<string, string | number | boolean | undefined | null>, ) { const url = new URL(baseUrl, window.location.origin) if (query) { Object.entries(query).forEach(([key, value]) => { if (value === undefined || value === null) return url.searchParams.set(key, String(value)) }) } return url.toString() } function getCacheKey(options: OpenDialogOptions) { return options.singletonKey || options.url } export function openDialog(options: OpenDialogOptions): WpsDialogRef | null { const app = getWpsApplication() const finalUrl = buildUrl(options.url, options.query) const cacheKey = getCacheKey(options) const existed = dialogMap.get(cacheKey) if (existed) { return existed } if (!isWpsRuntime() || !app?.ShowDialog) { console.warn('[wps-dialog] not in WPS runtime, fallback to window.open') const child = window.open( finalUrl, '_blank', `width=${options.width || 720},height=${options.height || 520}`, ) const mockRef: WpsDialogRef = { close: () => child?.close(), rawWindow: child, } dialogMap.set(cacheKey, mockRef) return mockRef } try { // 注意: // 官方页面能确认 ShowDialog 存在,但完整签名抓取不稳定。 // 这里做宽松兼容调用。 const dialog = app.ShowDialog( finalUrl, options.title || '', options.width || 720, options.height || 520, ) || app.ShowDialog(finalUrl) if (dialog) { dialogMap.set(cacheKey, dialog) return dialog } } catch (error) { console.error('[wps-dialog] ShowDialog failed:', error) } return null } export function closeDialog(singletonKeyOrUrl: string) { const dialog = dialogMap.get(singletonKeyOrUrl) if (!dialog) return try { if (typeof dialog.close === 'function') { dialog.close() } } catch (error) { console.error('[wps-dialog] close failed:', error) } finally { dialogMap.delete(singletonKeyOrUrl) } } export function clearDialogRef(singletonKeyOrUrl: string) { dialogMap.delete(singletonKeyOrUrl) } export function closeSelfDialog() { try { window.close() } catch (error) { console.error('[wps-dialog] self close failed:', error) } }八、对外统一门面
这个文件给你的 Vue 页面直接用。
src/shared/wps/app.ts
import { createMessage, emitMessage, onMessage } from './channel' import { openDialog, closeDialog, closeSelfDialog, clearDialogRef } from './dialog' import { openTaskPane, showTaskPane, hideTaskPane, closeTaskPane, getTaskPane } from './taskpane' import { safeAlert, safeConfirm, isWpsRuntime } from './runtime' export { openDialog, closeDialog, closeSelfDialog, clearDialogRef, openTaskPane, showTaskPane, hideTaskPane, closeTaskPane, getTaskPane, safeAlert, safeConfirm, isWpsRuntime, createMessage, emitMessage, onMessage, }src/shared/wps/index.ts
export * from './types' export * from './runtime' export * from './channel' export * from './dialog' export * from './taskpane' export * from './app'
九、Vue 页面里怎么用
下面我直接给你最实用的用法。
十、TaskPane 页面示例
src/pages/taskpane-home/App.vue
<template> <div> <h1>TaskPane 首页</h1> <div> <button @click="handleOpenLogin">打开登录弹窗</button> <button @click="handleOpenPicker">打开选择器弹窗</button> <button @click="handleOpenSettings">打开设置窗格</button> </div> <div> <p>token:{{ token || '未登录' }}</p> <p>选择结果:{{ pickedValue || '暂无' }}</p> <p>最后一条消息:{{ lastMessage }}</p> </div> </div> </template> <script setup> import { onMounted, onUnmounted, ref } from 'vue' import { openDialog, openTaskPane, onMessage, } from '@/shared/wps' const token = ref('') const pickedValue = ref('') const lastMessage = ref('') function syncFromStorage() { token.value = localStorage.getItem('wps_token') || '' pickedValue.value = localStorage.getItem('wps_picked_value') || '' } function handleOpenLogin() { openDialog({ url: '/dialog-login.html', title: '登录', width: 520, height: 360, singletonKey: 'dialog-login', query: { from: 'taskpane-home', }, }) } function handleOpenPicker() { openDialog({ url: '/dialog-picker.html', title: '选择器', width: 640, height: 480, singletonKey: 'dialog-picker', query: { mode: 'single', }, }) } function handleOpenSettings() { openTaskPane({ url: '/taskpane-settings.html', title: '设置', singletonKey: 'taskpane-settings', visible: true, width: 360, }) } let off: null | (() => void) = null onMounted(() => { syncFromStorage() off = onMessage((message) => { lastMessage.value = `${message.type} @ ${new Date(message.timestamp).toLocaleTimeString()}` if (message.type === 'auth:success') { syncFromStorage() } if (message.type === 'picker:selected') { syncFromStorage() } if (message.type === 'dialog:close') { syncFromStorage() } }) }) onUnmounted(() => { off?.() }) </script> <style scoped> .page { padding: 16px; } .btns { display: flex; gap: 12px; flex-wrap: wrap; margin: 16px 0; } button { padding: 8px 14px; cursor: pointer; } .card { border: 1px solid #ddd; border-radius: 8px; padding: 12px; } </style>十一、登录 Dialog 页面示例
src/pages/dialog-login/App.vue
<template> <div> <h2>登录</h2> <div> <label>账号:</label> <input v-model="username" /> </div> <div> <label>密码:</label> <input v-model="password" type="password" /> </div> <div> <button @click="handleSubmit">登录</button> <button @click="handleClose">关闭</button> </div> </div> </template> <script setup> import { createMessage, emitMessage, closeSelfDialog, } from '@/shared/wps' import { onMounted } from 'vue' let username = $ref('') let password = $ref('') onMounted(() => { emitMessage( createMessage('dialog:ready', { page: 'dialog-login' }, 'dialog', 'host'), ) }) function handleSubmit() { const token = `token_${Date.now()}` localStorage.setItem('wps_token', token) localStorage.setItem('wps_user', username || 'anonymous') emitMessage( createMessage( 'auth:success', { token, user: username || 'anonymous' }, 'dialog', 'host', ), ) emitMessage( createMessage('dialog:close', { key: 'dialog-login' }, 'dialog', 'host'), ) handleClose() } function handleClose() { closeSelfDialog() } </script> <style scoped> .page { padding: 20px; } .form-item { margin-bottom: 12px; } label { display: inline-block; width: 60px; } input { width: 220px; padding: 6px 8px; } .btns { margin-top: 16px; display: flex; gap: 12px; } button { padding: 8px 14px; } </style>十二、选择器 Dialog 页面示例
src/pages/dialog-picker/App.vue
<template> <div> <h2>选择模板</h2> <ul> <li v-for="item in list" :key="item.value"> <button @click="handlePick(item.value)">{{ item.label }}</button> </li> </ul> <div> <button @click="handleClose">关闭</button> </div> </div> </template> <script setup> import { createMessage, emitMessage, closeSelfDialog, } from '@/shared/wps' import { onMounted } from 'vue' const list = [ { label: '模板 A', value: 'A' }, { label: '模板 B', value: 'B' }, { label: '模板 C', value: 'C' }, ] onMounted(() => { emitMessage( createMessage('dialog:ready', { page: 'dialog-picker' }, 'dialog', 'host'), ) }) function handlePick(value: string) { localStorage.setItem('wps_picked_value', value) emitMessage( createMessage( 'picker:selected', { value }, 'dialog', 'host', ), ) emitMessage( createMessage('dialog:close', { key: 'dialog-picker' }, 'dialog', 'host'), ) handleClose() } function handleClose() { closeSelfDialog() } </script> <style scoped> .page { padding: 20px; } ul { list-style: none; padding-left: 0; } li { margin-bottom: 10px; } button { padding: 8px 14px; } .btns { margin-top: 16px; } </style>十三、设置 TaskPane 页面示例
src/pages/taskpane-settings/App.vue
<template> <div> <h2>设置</h2> <div> <label>API 地址:</label> <input v-model="apiBaseUrl" /> </div> <div> <label>主题:</label> <select v-model="theme"> <option value="light">浅色</option> <option value="dark">深色</option> </select> </div> <div> <button @click="handleSave">保存</button> </div> </div> </template> <script setup> import { onMounted } from 'vue' import { createMessage, emitMessage } from '@/shared/wps' let apiBaseUrl = $ref('') let theme = $ref('light') onMounted(() => { apiBaseUrl = localStorage.getItem('wps_api_base_url') || '' theme = localStorage.getItem('wps_theme') || 'light' emitMessage( createMessage('taskpane:ready', { page: 'taskpane-settings' }, 'taskpane', 'host'), ) }) function handleSave() { localStorage.setItem('wps_api_base_url', apiBaseUrl) localStorage.setItem('wps_theme', theme) emitMessage( createMessage( 'taskpane:refresh', { page: 'taskpane-settings', saved: true }, 'taskpane', 'host', ), ) alert('保存成功') } </script> <style scoped> .page { padding: 16px; } .form-item { margin-bottom: 12px; } label { display: inline-block; width: 80px; } input, select { min-width: 220px; padding: 6px 8px; } .btns { margin-top: 16px; } button { padding: 8px 14px; } </style>十四、这套封装的真实价值
不是“把 API 包一层”这么简单,而是统一了三件事。
1. 打开方式统一
你业务层永远只写:
openDialog({...}) openTaskPane({...})不用到处写 app.ShowDialog(...)、app.CreateTaskpane(...)。
2. 通信方式统一
你业务层永远只写:
emitMessage(createMessage(...)) onMessage(...)
而不需要纠结:
这个页面是不是 opener 打开的
这个环境支不支持 postMessage
storage 事件能不能收到
3. 浏览器调试统一
在非 WPS 环境下,自动降级成:
window.openwindow.close
这样你写 Vue 页面时不必每次都先进 WPS 宿主。
十五、我替你提前指出两个关键现实问题
这里我必须跟你说清楚,不乱吹。
1. 官方能确认方法存在,但完整签名抓取不稳定
我查到的 WPS 官方文档可以确认这些方法存在:
Application.CreateTaskpaneApplication.GetTaskpaneApplication.ShowDialog
并且官方摘要说明 taskpane 是通过网页路径创建,dialog 是根据 URL 创建 web 页面。(WPS开放平台)
但官方页面在当前抓取环境里没有稳定吐出完整方法签名,所以我在封装里采用了“宽松调用 + 浏览器降级”策略。
这意味着你第一次接入真实 WPS Runtime 时,可能只需要把这两行细调一下:
app.CreateTaskpane(...) app.ShowDialog(...)
整体架构不用改。
2. Dialog 关闭方式可能跟宿主版本有关
我现在用了:
dialog.close?.() window.close()
这已经是最稳的工程化写法。
如果你本地接 WPS 时发现它要求特定关闭 API,我们只需要补在 dialog.ts 一处。
十六、我建议你下一步这样做
最实用的顺序是:
第一步
先把上面这 7 个封装文件放进去。
第二步
先在浏览器里跑:
taskpane-home.html
dialog-login.html
dialog-picker.html
taskpane-settings.html
看消息回传是否通。
第三步
再在 WPS 里把 CreateTaskpane / ShowDialog 的真实参数形式对上。
因为官方能确认 API 存在,但你本地版本的具体调用细节可能略有差异。(WPS开放平台)
十七、最短总结
你现在这套封装已经满足:
打开 taskpane
打开 dialog
关闭 dialog
页面间传参
dialog 回传结果给 taskpane
浏览器/WPS 双环境可调试
而且思路是对的:
WPS 插件整体是多页面容器,封装层要围绕“页面打开 + 页面通信 + 宿主兼容”来设计,而不是围绕一个总 SPA 来设计。
发表评论: