2211 字
11 分鐘

Google Apps Script 試算表 Web App CRUD:用 google.script.run 寫出可驗證資料操作

如果只是要做一個讓團隊查詢和維護資料的小工具,不一定要先架資料庫與獨立後端。Google Apps Script Web App 可以用 doGet() 提供 HTML 介面,再透過 google.script.run 呼叫伺服器端函式,把資料寫進 Google Sheets。

這篇用一個 Items 工作表示範 CRUD:新增、讀取、更新、刪除都在 Apps Script 後端完成,瀏覽器只負責表單與畫面。這個做法適合內部工具或低流量原型;若要讓外部程式直接用 HTTP 傳 JSON,文末再補上 doPost(e) 的入口。

先選對 Web App 的資料流#

Google 官方文件規定,Web App 至少要有 doGet(e)doPost(e),而且必須回傳 HtmlOutputTextOutput。瀏覽器開啟網址會觸發 doGet,程式送出 HTTP POST 則會觸發 doPost。這是入口點,不等於每個 CRUD 操作都必須自己解析 HTTP。

使用情境建議入口CRUD 呼叫方式
自己控制的 HTML 頁面doGet()google.script.run 非同步呼叫伺服器函式
外部前端、Webhook 或腳本doPost(e)e.postData.contents 解析 JSON
只想讓使用者直接查看資料doGet(e)讀取 query string 或回傳 HTML

如果你還沒處理過 Web App 的存取者與執行身分,先看 Google Apps Script Web App 的 execute as 與 access 權限邊界。本文的程式碼解決的是資料操作,不會替你決定誰有權限使用資料。

建立試算表與 Apps Script#

先建立一份 Google 試算表,新增名為 Items 的工作表。第一列預留四個欄位:idnamestatusupdatedAt。接著從 Extensions → Apps Script 開啟繫結腳本,建立 Code.gs

下面的後端程式會在工作表為空時補上標題列,並將輸入限制在必要欄位。id 使用 Apps Script 產生的 UUID,因此更新或刪除時不需要依賴列號;這也避免使用者在試算表排序後,前端仍拿舊列號操作資料。

const SHEET_NAME = 'Items';
const HEADERS = ['id', 'name', 'status', 'updatedAt'];
function doGet() {
return HtmlService.createHtmlOutputFromFile('Index')
.setTitle('Items 管理');
}
function getSheet_() {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
if (!sheet) {
throw new Error(`找不到工作表:${SHEET_NAME}`);
}
if (sheet.getLastRow() === 0) {
sheet.getRange(1, 1, 1, HEADERS.length).setValues([HEADERS]);
}
return sheet;
}
function normalizeItem_(input) {
const name = String(input?.name ?? '').trim();
const status = String(input?.status ?? 'todo').trim();
if (!name || name.length > 120) {
throw new Error('名稱必須是 1 到 120 個字元');
}
if (!['todo', 'doing', 'done'].includes(status)) {
throw new Error('status 只能是 todo、doing 或 done');
}
return { name, status };
}
function listItems() {
const sheet = getSheet_();
const lastRow = sheet.getLastRow();
if (lastRow < 2) return [];
return sheet
.getRange(2, 1, lastRow - 1, HEADERS.length)
.getValues()
.filter((row) => row[0])
.map(([id, name, status, updatedAt]) => ({
id: String(id),
name: String(name),
status: String(status),
updatedAt: updatedAt instanceof Date ? updatedAt.toISOString() : String(updatedAt),
}));
}
function createItem(input) {
const item = normalizeItem_(input);
const sheet = getSheet_();
const now = new Date();
const row = [Utilities.getUuid(), item.name, item.status, now];
sheet.appendRow(row);
return { id: row[0], name: row[1], status: row[2], updatedAt: now.toISOString() };
}
function findRowById_(id) {
const target = String(id ?? '').trim();
if (!target) throw new Error('缺少 item id');
const sheet = getSheet_();
const dataRows = Math.max(sheet.getLastRow() - 1, 0);
if (dataRows === 0) throw new Error('找不到指定資料');
const values = sheet.getRange(2, 1, dataRows, 1).getValues();
const index = values.findIndex(([value]) => String(value) === target);
if (index === -1) throw new Error('找不到指定資料');
return { sheet, rowNumber: index + 2 };
}
function updateItem(input) {
const item = normalizeItem_(input);
const { sheet, rowNumber } = findRowById_(input?.id);
const now = new Date();
sheet.getRange(rowNumber, 2, 1, 3).setValues([[item.name, item.status, now]]);
return {
id: String(input.id),
name: item.name,
status: item.status,
updatedAt: now.toISOString(),
};
}
function deleteItem(id) {
const { sheet, rowNumber } = findRowById_(id);
sheet.deleteRow(rowNumber);
return { id: String(id), deleted: true };
}

Sheet.appendRow() 會把陣列追加到目前資料區的最後一列;讀取與更新則使用 getRange() 取得明確範圍。這兩個方法的授權範圍與行為,請以 Google Apps Script Sheet 類別文件 為準。

google.script.run 接上 HTML#

新增 Index.htmlgoogle.script.run 是非同步 API,所以每個操作都要提供成功與失敗處理;不要在呼叫後立刻假設資料已經寫入。Google 官方也提醒,同時送出的伺服器函式呼叫不保證執行順序,因此這個範例每次操作完成後才重新載入清單。

<!doctype html>
<html lang="zh-Hant">
<head>
<base target="_top">
<meta charset="utf-8">
<title>Items 管理</title>
</head>
<body>
<main>
<h1>Items</h1>
<form id="item-form">
<input id="item-id" type="hidden">
<label>
名稱
<input id="item-name" maxlength="120" required>
</label>
<label>
狀態
<select id="item-status">
<option value="todo">待處理</option>
<option value="doing">進行中</option>
<option value="done">完成</option>
</select>
</label>
<button type="submit">儲存</button>
<button id="cancel-edit" type="button" hidden>取消編輯</button>
</form>
<p id="message" role="status"></p>
<ul id="item-list"></ul>
</main>
<script>
const form = document.querySelector('#item-form');
const idInput = document.querySelector('#item-id');
const nameInput = document.querySelector('#item-name');
const statusInput = document.querySelector('#item-status');
const list = document.querySelector('#item-list');
const message = document.querySelector('#message');
const cancelButton = document.querySelector('#cancel-edit');
function callServer(method, value) {
return new Promise((resolve, reject) => {
const runner = google.script.run
.withSuccessHandler(resolve)
.withFailureHandler((error) => reject(new Error(error.message || error)));
runner[method](value);
});
}
function render(items) {
list.replaceChildren();
for (const item of items) {
const row = document.createElement('li');
row.textContent = `${item.name}(${item.status})`;
const edit = document.createElement('button');
edit.type = 'button';
edit.textContent = '編輯';
edit.addEventListener('click', () => {
idInput.value = item.id;
nameInput.value = item.name;
statusInput.value = item.status;
cancelButton.hidden = false;
});
const remove = document.createElement('button');
remove.type = 'button';
remove.textContent = '刪除';
remove.addEventListener('click', async () => {
if (!window.confirm(`確定刪除「${item.name}」?`)) return;
await callServer('deleteItem', item.id);
await loadItems();
});
row.append(' ', edit, ' ', remove);
list.append(row);
}
}
async function loadItems() {
try {
render(await callServer('listItems'));
message.textContent = '';
} catch (error) {
message.textContent = `讀取失敗:${error.message}`;
}
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
const item = { id: idInput.value, name: nameInput.value, status: statusInput.value };
try {
await callServer(idInput.value ? 'updateItem' : 'createItem', item);
form.reset();
idInput.value = '';
cancelButton.hidden = true;
message.textContent = '已儲存';
await loadItems();
} catch (error) {
message.textContent = `儲存失敗:${error.message}`;
}
});
cancelButton.addEventListener('click', () => {
form.reset();
idInput.value = '';
cancelButton.hidden = true;
});
loadItems();
</script>
</body>
</html>

這裡沒有直接把使用者輸入拼成 HTML,而是用 textContent 放入清單,避免把輸入當成標記解讀。若要加入格式化或預覽,先定義允許的欄位與轉義規則,不要直接把資料塞入 innerHTML

多人寫入時,補上鎖與重複提交檢查#

範例為了保持清楚,createItem() 直接 appendRow()。若多位使用者可能同時儲存,應在新增、更新、刪除的關鍵區段使用 Lock Service 控制競爭,並讓前端在送出期間停用儲存按鈕。這能降低雙擊造成重複資料的機會,但不會替你建立完整交易系統。

此外,刪除前仍應由後端重新確認 id 存在;不能只因按鈕是從合法清單產生,就把前端送來的 ID 視為可信。

何時改用 doPost(e)#

如果呼叫端不是這個 HTML 頁面,而是外部前端或 webhook,可以用 doPost(e) 接收 JSON。這個版本只示範把請求導向同一組後端函式,實際部署仍要配合 Web App 的 access 與 execute as 設定。

function doPost(e) {
try {
const body = JSON.parse(e?.postData?.contents || '{}');
const action = String(body.action || '');
let result;
if (action === 'create') result = createItem(body.item);
else if (action === 'update') result = updateItem(body.item);
else if (action === 'delete') result = deleteItem(body.id);
else throw new Error('不支援的 action');
return ContentService
.createTextOutput(JSON.stringify({ ok: true, result }))
.setMimeType(ContentService.MimeType.JSON);
} catch (error) {
return ContentService
.createTextOutput(JSON.stringify({ ok: false, error: error.message }))
.setMimeType(ContentService.MimeType.JSON);
}
}

不要把這段程式誤解成「只要有 URL 就能安全公開資料庫」。外部 HTTP 呼叫需要另外處理驗證、重放、權限、速率限制與錯誤紀錄;若只是內部畫面,google.script.run 通常更容易收斂權限範圍。既有的 Google Apps Script WebApp 商品管理實戰則偏向表單與雙介面設計,本文則把資料層的 CRUD 邊界拆開。

部署與驗證順序#

  1. 在 Apps Script 編輯器先手動執行一次 listItems(),完成必要授權。
  2. 選擇 Deploy → New deployment → Web app,設定誰可以存取,以及程式以誰的身分執行。
  3. 先用 /dev 測試網址確認 HTML、讀取、新增、更新、刪除都成功;/dev 只適合編輯者驗證。
  4. 用正式 Web App URL 測試部署版本,而不是只確認編輯器裡的函式能執行。
  5. 分別以部署者、一般使用者與不應有權限的帳號測試,記錄每個請求能讀寫哪些資料。

最後在試算表檢查:每筆資料都有穩定 id、時間欄位格式一致、刪除後沒有誤刪標題列,以及錯誤輸入沒有留下半筆資料。這些檢查比「畫面看起來能用」更能證明 CRUD 流程真的完成。

常見問題#

Q: google.script.run 可以當成一般 REST API 嗎?#

A: 不行。它是 HTML Service 頁面可用的非同步客戶端 API,呼叫同一個 Apps Script 專案的伺服器函式;外部程式應使用 doGet(e)doPost(e),並自行處理驗證與權限。

Q: 為什麼更新資料不直接保存列號?#

A: 列號會因排序、插入或刪除而改變。範例替每筆資料建立 UUID,更新與刪除時先以 id 找到當前列,再操作那一列,對簡單工具比較不容易誤寫其他資料。

Q: Web App 顯示成功,但試算表沒有資料怎麼查?#

A: 先看部署使用的 execute as 身分,再用同一個部署 URL 測試,接著檢查 Apps Script 執行記錄與工作表名稱是否完全等於 Items。不要只用編輯器內的函式測試結果推論 Web App 的權限也正確。

參考資料:

Google Apps Script:Web Apps

Google Apps Script:HTML Service 與伺服器函式溝通

Google Apps Script:Sheet 類別

Google Apps Script 試算表 Web App CRUD:用 google.script.run 寫出可驗證資料操作
https://laplusda.com/posts/google-apps-script-sheets-webapp-crud/
作者
Zero
發佈於
2026-08-04
許可協議
CC BY-NC-SA 4.0
這篇文章有幫助嗎?

回報錯字、失效連結,或告訴我你想看的延伸主題。