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),而且必須回傳 HtmlOutput 或 TextOutput。瀏覽器開啟網址會觸發 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 的工作表。第一列預留四個欄位:id、name、status、updatedAt。接著從 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.html。google.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 邊界拆開。
部署與驗證順序
- 在 Apps Script 編輯器先手動執行一次
listItems(),完成必要授權。 - 選擇 Deploy → New deployment → Web app,設定誰可以存取,以及程式以誰的身分執行。
- 先用
/dev測試網址確認 HTML、讀取、新增、更新、刪除都成功;/dev只適合編輯者驗證。 - 用正式 Web App URL 測試部署版本,而不是只確認編輯器裡的函式能執行。
- 分別以部署者、一般使用者與不應有權限的帳號測試,記錄每個請求能讀寫哪些資料。
最後在試算表檢查:每筆資料都有穩定 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 的權限也正確。
參考資料:
回報錯字、失效連結,或告訴我你想看的延伸主題。