web应用适配workspace插件

26天前6 阅读

#workspace#插件#前端

核心流程

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文件,不用与网页端代码混在一起