web应用适配workspace插件
26天前6 阅读
核心流程
Vue/React App (iframe)
↓ postMessage (GOOGLE_SCRIPT_CALL)
Sidebar.html (父窗口)
↓ google.script.run
Google Apps Script (服务端)
↓ 返回结果
Sidebar.html
↓ postMessage (GOOGLE_SCRIPT_RESPONSE)
Vue/React App (iframe)
- 通过嵌入iframe的形式,可以做到直接迁移到workspace 插件上
- 在Sidebar.html文件中增加中转代码,监听从iframe中收听到的消息,并调取相关的google.script.run接口,对文档进行操作,成功后发送对应消息到iframe中
核心代码
sidebar.html (中转层)
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
}
iframe {
width: 100%;
height: 100%;
border: none;
display: block;
}
</style>
</head>
<body>
<!-- 嵌入 Vue/React 应用 -->
<iframe src="对应的网页地址"
allow="clipboard-read; clipboard-write">
</iframe>
<!-- Google Script Bridge: 接收 iframe 消息并中转到 Google Apps Script -->
<script>
(function () {
console.log('Google Script Bridge initialized');
// 监听来自 iframe 的消息
window.addEventListener('message', function (event) {
const { type, callId, functionName, args } = event.data;
// 只处理 GOOGLE_SCRIPT_CALL 类型的消息
if (type !== 'GOOGLE_SCRIPT_CALL') {
return;
}
console.log('Received message from iframe:', {
type,
callId,
functionName,
args
});
// 验证 functionName 是否存在
if (!functionName) {
console.error('Missing functionName in message');
sendResponse(event.source, callId, false, null, 'Missing function name');
return;
}
try {
// 使用 google.script.run 调用服务端函数
console.log('Calling Google Script function:', functionName, 'with args:', args);
// 链式调用 withFailureHandler 和 withSuccessHandler
google.script.run
.withFailureHandler(function (error) {
console.error('Google Script error:', functionName, error);
sendResponse(event.source, callId, false, null, error.message || error.toString());
})
.withSuccessHandler(function (result) {
console.log('Google Script success:', functionName, result);
sendResponse(event.source, callId, true, result, null);
})
[functionName].apply(google.script.run, args || []);
} catch (error) {
console.error('Error calling Google Script:', error);
sendResponse(event.source, callId, false, null, error.message || error.toString());
}
});
// 发送响应回 iframe
function sendResponse(source, callId, success, data, error) {
const response = {
type: 'GOOGLE_SCRIPT_RESPONSE',
callId: callId,
success: success,
data: data,
error: error
};
console.log('Sending response to iframe:', response);
// 发送消息回 iframe
source.postMessage(response, '*');
}
console.log('Message listener registered');
})();
</script>
</body>
</html>
googleScriptBridge.js (桥接器)
/**
* Google Script Bridge - 用于替换 google.script.run 的 postMessage 实现
* 当应用作为 iframe 嵌入 Google Workspace 侧边栏时使用
*/
class GoogleScriptBridge {
constructor() {
this.pendingCalls = new Map();
this.callId = 0;
this.setupMessageListener();
}
/**
* 设置消息监听器,接收来自父窗口的响应
*/
setupMessageListener() {
window.addEventListener('message', (event) => {
// 验证消息来源(可根据需要添加更严格的验证)
const { type, callId, success, data, error } = event.data;
if (type === 'GOOGLE_SCRIPT_RESPONSE' && this.pendingCalls.has(callId)) {
const { successHandler, failureHandler } = this.pendingCalls.get(callId);
if (success && successHandler) {
successHandler(data);
} else if (!success && failureHandler) {
failureHandler(error);
}
// 清理已完成的调用
this.pendingCalls.delete(callId);
}
});
}
/**
* 创建一个新的调用链
*/
createCall() {
const callId = this.callId++;
const callChain = {
_callId: callId,
_successHandler: null,
_failureHandler: null,
_functionName: null,
_args: [],
/**
* 设置成功回调
*/
withSuccessHandler(handler) {
this._successHandler = handler;
return this;
},
/**
* 设置失败回调
*/
withFailureHandler(handler) {
this._failureHandler = handler;
return this;
},
/**
* 执行远程函数调用
*/
_execute(functionName, ...args) {
this._functionName = functionName;
this._args = args;
// 保存回调处理器
bridge.pendingCalls.set(this._callId, {
successHandler: this._successHandler,
failureHandler: this._failureHandler,
});
// 序列化参数以确保可以通过 postMessage 传递
let serializedArgs;
try {
// 使用 JSON 序列化和反序列化来深度克隆并移除不可序列化的内容
serializedArgs = JSON.parse(JSON.stringify(this._args));
} catch (error) {
console.error('Failed to serialize arguments:', error);
// 如果序列化失败,尝试使用原始参数
serializedArgs = this._args;
}
// 发送消息到父窗口
const message = {
type: 'GOOGLE_SCRIPT_CALL',
callId: this._callId,
functionName: this._functionName,
args: serializedArgs,
};
console.log('Sending message to parent:', message);
try {
window.parent.postMessage(message, '*');
} catch (error) {
console.error('Failed to send message:', error);
// 如果发送失败,触发失败回调
if (this._failureHandler) {
this._failureHandler(error.message || 'Failed to send message');
}
}
},
};
// 动态添加所有可能的函数调用方法
const functionNames = [
'write',
'getUserInfo',
'getUserProfile',
'getAddonUserInfo',
'openDialog',
'saveToDrive',
'getFileList',
'createSpreadsheet',
'appendToSheet',
'readFromSheet',
];
functionNames.forEach((fnName) => {
callChain[fnName] = function (...args) {
this._execute(fnName, ...args);
};
});
return callChain;
}
}
// 创建单例实例
const bridge = new GoogleScriptBridge();
/**
* 导出的 google.script.run 替代对象
*/
export const googleScriptRun = {
withSuccessHandler(handler) {
return bridge.createCall().withSuccessHandler(handler);
},
withFailureHandler(handler) {
return bridge.createCall().withFailureHandler(handler);
},
// 直接调用方法(不带回调)
write(...args) {
bridge.createCall()._execute('write', ...args);
},
getUserInfo(...args) {
bridge.createCall()._execute('getUserInfo', ...args);
},
getUserProfile(...args) {
bridge.createCall()._execute('getUserProfile', ...args);
},
getAddonUserInfo(...args) {
bridge.createCall()._execute('getAddonUserInfo', ...args);
},
openDialog(...args) {
bridge.createCall()._execute('openDialog', ...args);
},
saveToDrive(...args) {
bridge.createCall()._execute('saveToDrive', ...args);
},
getFileList(...args) {
bridge.createCall()._execute('getFileList', ...args);
},
createSpreadsheet(...args) {
bridge.createCall()._execute('createSpreadsheet', ...args);
},
appendToSheet(...args) {
bridge.createCall()._execute('appendToSheet', ...args);
},
readFromSheet(...args) {
bridge.createCall()._execute('readFromSheet', ...args);
},
};
/**
* 检测当前环境并返回合适的 script runner
*/
export function getScriptRunner() {
// 检查是否有原生的 google.script.run
const hasNativeGoogleScript = typeof google !== 'undefined' &&
google.script &&
google.script.run;
// 如果在 iframe 中或者没有原生 google.script,使用 bridge
if (!hasNativeGoogleScript) {
return googleScriptRun;
}
// 否则使用原生的 google.script.run
return google.script.run;
}
export default googleScriptRun;
网站端代码调用示例
import { getScriptRunner } from '@/utils/googleScriptBridge';
const scriptRunner = getScriptRunner();
scriptRunner
.withFailureHandler((error) => {
console.error('失败:', error);
ElMessage.error('操作失败: ' + error.message);
})
.withSuccessHandler((res) => {
console.log('成功:', res);
ElMessage.success('操作成功!');
})
.write(rowData);
适配工作
原网页
- UI适配,原网页需要能够适配300px宽的移动端
- 原网页可通过判断query参数或是google.script.run对象实例,来判断是否处于workspace环境
- 原网页根据是否处于workspace环境,显示相关如添加按钮,并调取上述桥接器中的googleScriptRun 方法发送消息
workspace端
- workspace端的code.gs文件需要添加相关的修改文档方法(这部分可以统一接口数据结构,复用代码)
好处
- 直接复用大部分代码,无需重复开发,做到网站端代码能应用于workspace插件
- 网页端发布后,无需重复打包发布workspace端,自动更新workspace端
- 原网页端适配代码较为清晰,只需添加文档读写相关的代码,修改一些UI的显隐条件(如一些不想在插件端显示的UI)
- workspace端文档操作相关代码全在workdspace端的code.gs文件,不用与网页端代码混在一起