为 Google Workspace 插件打造 Webpack 脚手架

Celestial Globe with Clockwork, Metropolitan Museum of Art
天球仪与钟表机构 — 在精密的约束框架内,用齿轮组合出复杂的运行机制。为 GSuite 沙箱设计构建工具链,也是类似的工程
这篇文章的价值:Google Workspace 插件的开发体验很原始——没有 npm、没有构建工具、所有资源必须内联在单个 HTML 文件里。这篇文章分享一套 Webpack 脚手架方案,让你可以用 React + TypeScript 开发 GSuite 插件,自动发现新入口、自动内联 JS/CSS、一次构建覆盖所有 GSuite 产品。如果你需要在 GSuite 插件里做复杂的 UI 交互,这套脚手架可以省掉大量重复劳动。

Google Workspace 插件是什么

Google Workspace(原 GSuite)允许开发者为 Docs、Sheets、Slides、Forms 等产品开发插件。插件的 UI 形态有两种:Sidebar(侧边栏,常驻)和 Dialog(弹窗,临时)。

插件的架构分两层:

┌──────────────────────────────────────────┐
│           Google Docs / Sheets / ...      │
│                                           │
│   ┌─────────────┐    ┌───────────────┐   │
│   │   Sidebar    │    │    Dialog     │   │
│   │  (HTML/JS)   │    │  (HTML/JS)   │   │
│   │  ← iframe →  │    │  ← iframe → │   │
│   └──────┬───────┘    └──────┬───────┘   │
│          │                    │           │
│          │  google.script.run │           │
│          ▼                    ▼           │
│   ┌──────────────────────────────────┐   │
│   │        AppScript (服务端)         │   │
│   │  - 访问 Drive / Gmail / Sheets   │   │
│   │  - 操作文档内容                    │   │
│   │  - 发送邮件                       │   │
│   └──────────────────────────────────┘   │
└──────────────────────────────────────────┘

这个模型有三个硬性约束,直接决定了我们的构建方案:

  1. CSP 限制:iframe 沙箱禁止加载外部脚本和样式表。所有 JS 和 CSS 必须内联在 HTML 文件里
  2. 单文件交付HtmlService.createHtmlOutputFromFile("sidebar") 只接受一个 HTML 文件名。不能拆分成多个文件
  3. 跨产品部署:同一个插件要分别部署到 Docs、Sheets、Slides、Forms,每个产品是独立的 AppScript 项目

官方推荐的开发方式是直接在 Apps Script 编辑器里写 HTML + JavaScript。这对简单插件够用,但一旦 UI 有状态管理、多步表单、条件渲染这些需求,没有组件化和构建工具的开发体验会很痛苦。

下面分享我用 Webpack 打造的一套脚手架,解决这些问题。

脚手架的目标

我希望这套脚手架做到:

  1. React + TypeScript 开发:用现代前端的方式写 GSuite 插件 UI
  2. 自包含 HTML 输出:构建产物是单个 HTML 文件,JS/CSS 全部内联,直接满足 GSuite 的 CSP 限制
  3. 自动发现:新增一个 dialog,只需要在约定目录下建文件夹,不需要改任何构建配置
  4. 多产品矩阵构建:一次构建,为每个 GSuite 产品生成独立的部署目录
  5. 本地开发:不部署到 GSuite 也能在浏览器里调试 UI

项目结构约定

project/
├── src/
│   ├── sidebar/           # 主 sidebar(必须存在)
│   │   ├── App.tsx
│   │   └── index.tsx
│   └── dialogs/           # dialog 目录(自动发现)
│       ├── about/
│       │   ├── App.tsx
│       │   └── index.tsx
│       └── settings/
│           ├── App.tsx
│           └── index.tsx
├── appscript/
│   └── shared.gs          # AppScript 服务端代码
├── clasp/
│   ├── docs/
│   │   ├── .clasp.json    # Docs 的 AppScript 项目 ID
│   │   └── appsscript.json
│   ├── sheets/
│   │   └── .clasp.json    # Sheets 的 AppScript 项目 ID
│   └── ...
├── webpack.config.js
└── package.json

这个结构的核心约定是:src/sidebar/ 是固定入口,src/dialogs/ 下每个含 index.tsx 的子目录自动成为一个 dialog 入口

第一步:自动发现 Entry Points

Webpack 的 entry 通常是手动配置的。但我们希望加 dialog 不改配置。所以在 Webpack 启动时扫描文件系统:

const path = require("path");
const fs = require("fs");

function discoverDialogs() {
  const dialogsPath = path.resolve(__dirname, "src/dialogs");
  if (!fs.existsSync(dialogsPath)) return [];

  return fs.readdirSync(dialogsPath, { withFileTypes: true })
    .filter(item => item.isDirectory())
    .filter(dir => {
      return fs.existsSync(
        path.join(dialogsPath, dir.name, "index.tsx")
      );
    })
    .map(dir => dir.name);
}

// 动态生成 entry points
const dialogs = discoverDialogs();
const entry = { sidebar: "./src/sidebar/index.tsx" };
dialogs.forEach(name => {
  entry[name] = `./src/dialogs/${name}/index.tsx`;
});

这样 entry 会变成 { sidebar: "...", about: "...", settings: "..." }。想加一个新 dialog?在 src/dialogs/ 下建个文件夹、放一个 index.tsx,下次构建自动生效。

第二步:内联 JS 和 CSS

正常的 Webpack 构建产物是独立的 .js.css 文件,HTML 通过 <script src><link rel="stylesheet"> 引用。这在 GSuite 里行不通——iframe 沙箱会阻止加载这些外部资源。

解决方案是两个插件的组合:

const HtmlWebpackPlugin = require("html-webpack-plugin");
const HtmlInlineScriptPlugin =
  require("html-inline-script-webpack-plugin");

// JS 内联:HtmlInlineScriptPlugin 把 Webpack 输出的 JS bundle
//          直接塞进 HTML 的 <script> 标签里
plugins: [
  new HtmlWebpackPlugin({
    template: "./public/index.html",
    chunks: ["sidebar"],
    inject: "body",
  }),
  new HtmlInlineScriptPlugin(),
]

// CSS 内联:用 style-loader 代替 mini-css-extract-plugin
module: {
  rules: [
    {
      test: /\.css$/,
      use: ["style-loader", "css-loader"],
      // style-loader 会在运行时把 CSS 注入 <style> 标签
      // 而不是生成独立的 .css 文件
    },
  ],
}

构建后每个 HTML 文件是完全自包含的——零外部依赖。HtmlService.createHtmlOutputFromFile("sidebar") 直接能用。

第三步:多产品矩阵生成

同一个插件要部署到 Docs、Sheets、Slides、Forms 四个产品。每个产品是独立的 AppScript 项目,需要独立的部署目录。Webpack 配置动态生成所有 HtmlWebpackPlugin 实例:

const GSUITE_PRODUCTS = ["docs", "slides", "sheets", "forms"];
const htmlPlugins = [];

GSUITE_PRODUCTS.forEach(product => {
  // sidebar
  htmlPlugins.push(new HtmlWebpackPlugin({
    template: "./public/index.html",
    filename: `${product}/sidebar.html`,
    chunks: ["sidebar"],
    inject: "body",
  }));

  // 每个自动发现的 dialog
  dialogs.forEach(name => {
    htmlPlugins.push(new HtmlWebpackPlugin({
      template: "./public/index.html",
      filename: `${product}/${name}.html`,
      chunks: [name],
      inject: "body",
    }));
  });
});

构建产物的结构:

dist/
├── docs/
│   ├── sidebar.html       # 自包含 HTML
│   ├── about.html
│   ├── settings.html
│   └── appscript/
│       └── shared.gs      # 复制过来的 AppScript 代码
├── sheets/
│   ├── sidebar.html
│   ├── about.html
│   └── settings.html
├── slides/
│   └── ...
└── forms/
    └── ...

4 个产品 × 3 个入口 = 12 个 HTML 文件,全自动生成。JS 代码只编译一次,通过 chunks 配置分发到对应的 HTML 里。

第四步:AppScript 文件复制

AppScript 的 .gs 文件不需要编译,但需要复制到每个产品的输出目录。用 CopyWebpackPlugin

const CopyWebpackPlugin = require("copy-webpack-plugin");

GSUITE_PRODUCTS.forEach(product => {
  plugins.push(new CopyWebpackPlugin({
    patterns: [{
      from: "appscript/",
      to: `${product}/appscript/`,
    }],
  }));
});

这样 clasp push 时,每个产品目录下既有 HTML 又有 .gs,是一个完整的 AppScript 项目。

第五步:本地开发支持

google.script.run 只在 GSuite 环境里存在。在本地浏览器开发时,它是 undefined。解决思路很简单:检测环境,不存在时用 mock 数据。

// 封装 AppScript 调用
function callAppScript<T>(
  methodName: string,
  ...args: any[]
): Promise<T> {
  return new Promise((resolve, reject) => {
    if (typeof google !== "undefined" && google.script) {
      const runner = google.script.run
        .withSuccessHandler(resolve)
        .withFailureHandler(reject);
      runner[methodName](...args);
    } else {
      // 本地开发:返回 mock 数据
      resolve(getMockData(methodName, args) as T);
    }
  });
}

// 使用
const sheets = await callAppScript<Sheet[]>("getGoogleSheets");

这样做有两个好处:

  1. 本地开发不依赖 GSuite 环境:用 webpack-dev-server 起一个本地服务,在浏览器里调试 UI 和交互逻辑
  2. 把 callback 风格的 google.script.run 包装成 Promise:在 React 组件里可以用 async/await,代码更干净

第六步:TypeScript 类型声明

google.script.run 没有官方的类型定义。需要手动声明,让 TypeScript 知道有哪些可调用的 AppScript 函数:

// types/google.d.ts
interface AppScriptRunner {
  getGoogleSheets: () => void;
  getWorksheets: (spreadsheetId: string) => void;
  getSelectedSheetData: (
    spreadsheetId: string,
    worksheetName: string
  ) => void;
  getDocumentHtml: (docId: string) => void;
  sendMailMerge: (
    spreadsheetId: string,
    worksheetName: string,
    docId: string,
    subject: string,
    replyTo: string,
    testMode: boolean
  ) => void;
}

declare const google: {
  script: {
    run: {
      withSuccessHandler: (cb: (result: any) => void) => {
        withFailureHandler: (
          cb: (error: any) => void
        ) => AppScriptRunner;
      };
    };
  };
};

每新增一个 AppScript 函数,在这里加一行声明。虽然返回值类型还是 any(AppScript 是动态类型的),但至少调用参数有类型检查。

第七步:部署流水线

用 Google 的 clasp CLI 把构建产物推送到 AppScript 项目。每个 GSuite 产品是独立的 AppScript 项目,有独立的 .clasp.json

# clasp/docs/.clasp.json
{
  "scriptId": "1a2b3c...",        # Docs 的 AppScript 项目 ID
  "rootDir": "../../dist/docs"    # 指向构建产物
}

# 部署流程
npm run build                     # 构建所有产品的 HTML
cd clasp/docs && clasp push       # 推送到 Docs 的 AppScript 项目
cd clasp/sheets && clasp push     # 推送到 Sheets 的 AppScript 项目
# ... 每个产品分别推送

clasp push 会上传 rootDir 下的所有 .html.gs 文件到 Google Apps Script。可以写一个脚本把所有产品的推送串起来:

# deploy.sh
npm run build
for product in docs sheets slides forms; do
  echo "Deploying to $product..."
  (cd clasp/$product && clasp push)
done

完整的 webpack.config.js

把上面所有步骤组合起来:

const path = require("path");
const fs = require("fs");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const HtmlInlineScriptPlugin =
  require("html-inline-script-webpack-plugin");
const CopyWebpackPlugin = require("copy-webpack-plugin");
const TerserPlugin = require("terser-webpack-plugin");

// ---- 自动发现 ----
function discoverDialogs() {
  const dialogsPath = path.resolve(__dirname, "src/dialogs");
  if (!fs.existsSync(dialogsPath)) return [];
  return fs.readdirSync(dialogsPath, { withFileTypes: true })
    .filter(d => d.isDirectory())
    .filter(d =>
      fs.existsSync(path.join(dialogsPath, d.name, "index.tsx"))
    )
    .map(d => d.name);
}

const dialogs = discoverDialogs();
const PRODUCTS = ["docs", "slides", "sheets", "forms"];

// ---- Entry ----
const entry = { sidebar: "./src/sidebar/index.tsx" };
dialogs.forEach(name => {
  entry[name] = `./src/dialogs/${name}/index.tsx`;
});

// ---- HTML + Copy 插件 ----
const plugins = [];

PRODUCTS.forEach(product => {
  // sidebar HTML
  plugins.push(new HtmlWebpackPlugin({
    template: "./public/index.html",
    filename: `${product}/sidebar.html`,
    chunks: ["sidebar"],
    inject: "body",
  }));
  // dialog HTMLs
  dialogs.forEach(name => {
    plugins.push(new HtmlWebpackPlugin({
      template: "./public/index.html",
      filename: `${product}/${name}.html`,
      chunks: [name],
      inject: "body",
    }));
  });
  // AppScript 文件复制
  plugins.push(new CopyWebpackPlugin({
    patterns: [{
      from: "appscript/",
      to: `${product}/appscript/`,
    }],
  }));
});

plugins.push(new HtmlInlineScriptPlugin());

// ---- 导出 ----
module.exports = {
  mode: "production",
  entry,
  output: {
    path: path.resolve(__dirname, "dist"),
    clean: true,
  },
  resolve: {
    extensions: [".ts", ".tsx", ".js", ".jsx"],
  },
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        use: "ts-loader",
        exclude: /node_modules/,
      },
      {
        test: /\.css$/,
        use: ["style-loader", "css-loader"],
      },
    ],
  },
  plugins,
  optimization: {
    minimizer: [
      new TerserPlugin({
        terserOptions: { compress: { drop_console: true } },
      }),
    ],
  },
};

GSuite AppScript 侧的模式

脚手架解决了前端的构建问题,但 AppScript 侧也有一些值得记录的模式。

跨产品适配:同一个插件运行在 Docs/Sheets/Slides/Forms 里,但 AppScript 没有"当前是哪个产品"的 API。只能用 try/catch 检测:

function getUi() {
  try { return DocumentApp.getUi(); } catch(e) {}
  try { return SpreadsheetApp.getUi(); } catch(e) {}
  try { return SlidesApp.getUi(); } catch(e) {}
  try { return FormApp.getUi(); } catch(e) {}
  return null;
}

function showSidebar() {
  var ui = getUi();
  if (!ui) return;
  var html = HtmlService.createHtmlOutputFromFile("sidebar")
    .setTitle("My Plugin").setWidth(320);
  ui.showSidebar(html);
}

google.script.run 的工作方式:前端调用后端函数,通过 callback 拿结果。每个 AppScript 的全局函数都可以被前端调用:

// AppScript 侧——定义全局函数
function getGoogleSheets() {
  var files = DriveApp.getFilesByType(MimeType.GOOGLE_SHEETS);
  var sheets = [];
  while (files.hasNext()) {
    var file = files.next();
    sheets.push({
      id: file.getId(),
      name: file.getName(),
      url: file.getUrl(),
    });
  }
  return { success: true, sheets: sheets };
}

// React 侧——调用它
google.script.run
  .withSuccessHandler((result) => {
    setSheets(result.sheets);
  })
  .withFailureHandler((error) => {
    console.error(error);
  })
  .getGoogleSheets();

实用 API:AppScript 可以调用 Google Docs 导出 HTML、读 Sheets 数据、发 Gmail 等。这些是 GSuite 插件的核心能力:

// 读 Sheets 数据
function getSheetData(spreadsheetId, worksheetName) {
  var spreadsheet = SpreadsheetApp.openById(spreadsheetId);
  var sheet = spreadsheet.getSheetByName(worksheetName);
  var values = sheet.getDataRange().getValues();
  return { headers: values[0], rows: values.slice(1) };
}

// 读 Docs 内容(导出为 HTML,保留格式)
function getDocumentHtml(docId) {
  var url = "https://docs.google.com/feeds/download/"
    + "documents/export/Export?id=" + docId
    + "&exportFormat=html";
  var response = UrlFetchApp.fetch(url, {
    headers: {
      Authorization: "Bearer " + ScriptApp.getOAuthToken()
    }
  });
  return response.getContentText();
}

// 发邮件
function sendEmail(to, subject, htmlBody) {
  GmailApp.sendEmail(to, subject, "", {
    htmlBody: htmlBody,
  });
}

实际案例:Mail Merge

用这套脚手架做了一个 Mail Merge 功能:从 Google Sheets 读收件人列表,用 Google Docs 做模板,批量发送个性化邮件。

用户操作(sidebar 里的 4 步向导):

Step 1: 选数据源 → 列出 Drive 里的 Sheets → 选择 sheet tab → 预览 headers
Step 2: 选模板   → 列出 Drive 里的 Docs → 预览 HTML 内容
Step 3: 配置     → 邮件主题(支持 {{Name}} 合并字段)→ reply-to 地址
Step 4: 执行     → 汇总确认 → Test mode → 发送并显示结果

前端是标准的 React 多步表单(用 state 控制 step),每一步的数据通过 callAppScript 从后端获取。后端做字段替换并发邮件:

function sendMailMerge(spreadsheetId, worksheetName,
                       docId, subject, replyTo, testMode) {
  var data = getSheetData(spreadsheetId, worksheetName);
  var template = getDocumentHtml(docId);
  var results = [];

  for (var i = 0; i < data.rows.length; i++) {
    var row = {};
    data.headers.forEach(function(h, j) { row[h] = data.rows[i][j]; });

    var body = template.replace(/\{\{([^}]+)\}\}/g,
      function(match, field) {
        return row[field.trim()] || match;
      });
    var emailSubject = subject.replace(/\{\{([^}]+)\}\}/g,
      function(match, field) {
        return row[field.trim()] || match;
      });

    var recipient = testMode
      ? Session.getActiveUser().getEmail()
      : row["Email"];

    try {
      GmailApp.sendEmail(recipient, emailSubject, "", {
        htmlBody: body,
        replyTo: replyTo || undefined,
      });
      results.push({ row: i + 1, status: "sent" });
    } catch (e) {
      results.push({ row: i + 1, status: "error", error: e.message });
    }

    Utilities.sleep(100); // 控制发送速率
  }

  return { success: true, results: results };
}

这个 Mail Merge 展示了脚手架的价值:前端是复杂的多步交互(选文件、预览数据、配置合并字段、显示发送结果),如果用原生 HTML 写会很痛苦。React 的组件化和状态管理在这里有明显优势。

开发体验的痛点

这套方案不完美。几个绕不过的问题:

总结

这套脚手架的核心思路:用 Webpack 把"现代前端开发"和"GSuite 沙箱约束"桥接起来

如果你需要在 GSuite 插件里做复杂的 UI 交互,这套脚手架可以省掉很多重复劳动。如果只是一两个按钮的简单插件,直接写原生 HTML 就够了——不需要这套东西。