Di chuyển từ Supabase → Firebase: Kết nối một biểu mẫu liên hệ với Firestore mà không cần backend
Đăng ngày 29 April 2026
- Ngữ cảnh: một biểu mẫu không lưu trữ bất cứ thứ gì
- Tại sao nên chọn Firebase thay vì Supabase?
- Giai đoạn 1: Tạo dự án Firebase
- Giai đoạn 2: Viết lại JavaScript gửi biểu mẫu
- Giai đoạn 3: Dọn dẹp mã thừa Supabase
- Giai đoạn 4: Cấu hình footer
- Giai đoạn 5: Kiến trúc cuối cùng
- Điều mà cuộc di cư này nói về dogfooding
- Các bước tiếp theo (backlog)
- Tài liệu tham khảo
Trong nhiều tháng, biểu mẫu liên hệ của trang web này đã chạy trên một mock JavaScript — một lời hứa với 85% thành công, một Firestore giả, không có dữ liệu nào được lưu trữ. Kế hoạch ban đầu dự kiến một backend Supabase cùng với Google Apps Script để gửi thông báo email. Đã bị bỏ dở. Hôm nay, tôi kể về quá trình di chuyển sang Firebase Firestore: khởi tạo dự án, thiết lập quy tắc bảo mật, viết lại JS, dọn dẹp mã Supabase dư thừa. Và tại sao lựa chọn này lại nói lên điều gì đó rộng lớn hơn về triết lý phát triển.
Ngữ cảnh: một biểu mẫu không lưu trữ bất cứ thứ gì
Trang web này được tạo bởi JBake, plugin Gradle của tôi`bakery`. Nó tĩnh 100% — không backend, không cơ sở dữ liệu. Ngoại trừ việc tôi có một biểu mẫu liên hệ. Trang`contact.html`tồn tại, HTML đã sẵn sàng (các trường tên, email, điện thoại, chủ đề, tin nhắn, xác thực HTML5, honeypot chống spam), các kiểu dáng Bootstrap đã được thiết lập. Về mặt hình ảnh, mọi thứ đều hoàn hảo.
Có điều là khi nhấn gửi, không có gì xảy ra cả.
const firebaseMock = new Promise((resolve, reject) => {
setTimeout(() => {
if (Math.random() < 0.85) {
resolve({ status: 201, message: 'Message stored in Firestore.' });
} else {
reject({ status: 500, message: 'Firestore write failed.' });
}
}, 1500);
});
Một bản mock. Một promise giả vờ. Người dùng thấy một spinner, sau đó là một thông báo « Message envoyé avec succès ! ». Nhưng dữ liệu lại gửi vào hư vô. Không có tin nhắn nào được lưu trữ ở bất cứ đâu.
Tình huống này còn tệ hơn một biểu mẫu bị hỏng — đó là một biểu mẫu biết nói dối.
Di sản Supabase
Kế hoạch ban đầu, được ghi chép trong`content/draft/integration_formulaire_contact_supabase.adoc`, đã dự kiến:
-
Một cơ sở dữ liệu Supabase với bảng`contacts`và Row Level Security
-
Một RPC`handle_contact_form`phía máy chủ
-
Một trigger SQL gọi một webhook Google Apps Script
-
Google Apps Script gửi email thông báo qua Gmail
Mã JavaScript tương ứng vẫn còn tồn tại trong`script.js`. Có một lớp`SupabaseManager`mà khởi tạo một client Supabase với các biến toàn cục`SUPABASE_URL` et SUPABASE_KEY, và một lớp`ContactFormHandler`mà lắng nghe sự kiện submit của biểu mẫu và gọi`SupabaseManager.submitContactForm()`Vui lòng cung cấp văn bản cần dịch.
Vấn đề: các biến toàn cục này không còn được inject vào footer. Le`<script src="supabase-js">`đã bị xóa. Mã gọi`supabase.createClient()`trên một biến`supabase`không còn tồn tại. Vì vậy:
console.error : 'Supabase client library (supabase-js) is not loaded.'
Không những dữ liệu không được lưu trữ, mà mã gửi đi cũng đã chết.
Sự nộp bài bóng ma kép
Tệ hơn nữa, có mộtcạnh tranh trong im lặnggiữa hai handler trên cùng một biểu mẫu:
-
`contact.js`lắng nghe submit, gọi mock Firebase
-
script.js— thông qua`ContactFormHandler`— cũng lắng nghe submit, gọi`SupabaseManager`
Cả hai đều làm`event.preventDefault() + event.stopPropagation()`. Như`contact.js`được tải đầu tiên vào`footer.thyme`l’handler của nó được gắn vào trước. Nó chặn sự lan truyền.`ContactFormHandler`sẽ không bao giờ được kích hoạt.
Đây thậm chí không phải là một bug đang hoạt động — nó là một thây ma. Là đoạn mã không bao giờ có cơ hội được thực thi.
@startuml skinparam backgroundColor #FEFEFE title Trạng thái ban đầu — biểu mẫu liên hệ rectangle "contact.thyme\n(HTML Bootstrap, honeypot)" as FORM rectangle "footer.thyme\n(Firebase SDK avec config placeholder)" as FOOT rectangle "contact.js\n(mock Firebase, 85% thành công)" as CONT rectangle "script.js\n(SupabaseManager + ContactFormHandler, tử vong)" as SCRIPT rectangle "Người dùng" as USER USER -> FORM : Gửi biểu mẫu FORM -> CONT : sự kiện gửi (đính kèm trước) CONT -> CONT : preventDefault + stopPropagation CONT -> CONT : Promise giả lập (1.5s, ngẫu nhiên) note right of CONT Không có dữ liệu được lưu trữ end note FORM ..> SCRIPT : submit event (được chặn bởi stopPropagation) note right of SCRIPT : Chưa từng được kích hoạt note right of SCRIPT : supabase-js chưa được tải @enduml
Tại sao nên chọn Firebase thay vì Supabase?
Quyết định di chuyển được ghi chép trong`AGENT.adoc`Vui lòng cung cấp văn bản cần dịch.
Firebase hiện được chọn vì những lý do sau: gói miễn phí tốt hơn, Firestore gốc, Cloud Functions tích hợp, hệ sinh thái Google phù hợp hơn. Việc triển khai Supabase hiện tại được đánh dấu là « ⚠️ Abandonné ».
Ngoài gói miễn phí, còn có một lý do về mặt kiến trúc. Trang web này nằm trong hệ sinh thái Google: kho lưu trữ đích là`cheroliv.github.io`, bản ghi CNAME trỏ đến GitHub Pages, bản build Gradle được push lên GitHub thông qua JGit. Việc thêm một dịch vụ của Google (Firebase) thay vì một dịch vụ bên thứ ba (Supabase) giúp giảm bớt bề mặt phân tán.
Firestore ở chế độ native (không phải chế độ Datastore) cũng gần gũi hơn với mô hình tư duy NoSQL document mà tôi đang hình dung: collections, documents, các trường được định kiểu, server timestamps, và các quy tắc bảo mật tích hợp.
Giai đoạn 1: Tạo dự án Firebase
Khởi tạo
Vì Firebase CLI chưa được cài đặt trên máy của tôi, tôi sẽ sử dụng console web:
-
Đi đếnhttps://console.firebase.google.com/[Firebase Console]
-
Tạo dự án`cheroliv-contact`(hoặc tái sử dụng một dự án hiện có)
-
Kích hoạt Firestore ở chế độ native (không phải Datastore)
-
Tạo cơ sở dữ liệu trong vùng`eur3`(Châu Âu)
Đối với nhu cầu sử dụng tối giản như của chúng tôi (một collection duy nhất, ghi công khai), chế độ native là lựa chọn đúng đắn. Không cần đến các quy tắc Datastore phức tạp.
Quy tắc bảo mật Firestore
Biểu mẫu này là công khai — bất kỳ ai cũng có thể gửi tin nhắn. Nhưng tôi muốn hạn chế việc lạm dụng:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /contact_messages/{messageId} {
// Lecture : admin uniquement (authentifié)
allow read: if request.auth != null;
// Écriture : publique, mais limitée
allow create: if request.auth == null
&& request.resource.data.name is string
&& request.resource.data.name.size() >= 1
&& request.resource.data.name.size() <= 100
&& request.resource.data.email is string
&& request.resource.data.email.matches('.*@.*\\..*')
&& request.resource.data.email.size() <= 254
&& request.resource.data.subject is string
&& request.resource.data.subject.size() >= 3
&& request.resource.data.subject.size() <= 200
&& request.resource.data.message is string
&& request.resource.data.message.size() >= 10
&& request.resource.data.message.size() <= 5000
&& request.resource.data.created_at == request.time
&& request.resource.data.user_agent is string
&& request.resource.data.user_agent.size() <= 500;
}
}
}
Các điểm chính:
-
allow read— chỉ những người dùng đã được xác thực mới có thể đọc tin nhắn (tôi, thông qua console Firebase) -
allow create— bất kỳ ai cũng có thể tạo tài liệu, nhưng có xác thực các trường -
Xác thực phía máy chủ: kích thước tối thiểu/tối đa, định dạng email,
created_at`phải khớp với`request.time(chống làm giả) -
`user_agent`được gửi để truy xuất nguồn gốc (không quan trọng nhưng hữu ích)
Những quy tắc này nghiêm ngặt hơn một đơn thuần`allow write: if true;`Chúng ngăn chặn kẻ tấn công chèn các payload khổng lồ hoặc các trường không đúng định dạng.
Giai đoạn 2: Viết lại JavaScript gửi biểu mẫu
Hợp đồng rất đơn giản:
-
Đọc dữ liệu từ biểu mẫu
-
Kiểm tra honeypot (trường`hp_name`— nếu nó được điền, đó là một bot, chúng ta giả lập thành công mà không gửi bất cứ điều gì)
-
Gọi`addDoc(window.FIREBASE.collection(db, "contact_messages"), {…})`
-
Hiển thị thành công hoặc lỗi
Sự phụ thuộc `window.FIREBASE
Trong`footer.thyme`, một script module khởi tạo Firebase SDK và công khai một đối tượng toàn cục:
<script type="module">
import { initializeApp } from "https://www.gstatic.com/firebasejs/11.6.0/firebase-app.js";
import { getFirestore, collection, addDoc, serverTimestamp }
from "https://www.gstatic.com/firebasejs/11.6.0/firebase-firestore.js";
const firebaseConfig = { /* valeurs réelles */ };
const app = initializeApp(firebaseConfig);
const db = getFirestore(app);
window.__FIREBASE__ = { db, collection, addDoc, serverTimestamp };
</script>
Các script module chạy trước`DOMContentLoaded`, vì vậy`window.FIREBASE`được đảm bảo khả dụng khi handler`contact.js`được kích hoạt. Để thận trọng, tôi vẫn thêm một lần polling 5 giây trong trường hợp CDN bị chậm.
File contact.js mới
document.addEventListener('DOMContentLoaded', function () {
'use strict';
const form = document.getElementById('contact-form');
if (!form) return;
const submitButton = form.querySelector('button[type="submit"]');
const successMessage = document.getElementById('contact-success-message');
const errorMessage = document.getElementById('contact-error-message');
// Éléments de validation
const nameInput = form.querySelector('input[name="name"]');
const emailInput = form.querySelector('input[name="email"]');
const phoneInput = form.querySelector('input[name="phone"]');
const subjectInput = form.querySelector('input[name="subject"]');
const messageInput = form.querySelector('textarea[name="message"]');
const honeypotInput = form.querySelector('input[name="hp_name"]');
/**
* Attend que window.__FIREBASE__ soit disponible.
* Timeout de 5 secondes — si le CDN Firebase est lent, on abandonne.
*/
function waitForFirebase(timeoutMs = 5000) {
return new Promise((resolve, reject) => {
if (window.__FIREBASE__) {
resolve(window.__FIREBASE__);
return;
}
const start = Date.now();
const interval = setInterval(() => {
if (window.__FIREBASE__) {
clearInterval(interval);
resolve(window.__FIREBASE__);
} else if (Date.now() - start > timeoutMs) {
clearInterval(interval);
reject(new Error('Firebase SDK non disponible après timeout'));
}
}, 100);
});
}
// --- Validation (identique à l'existant) ---
function validateForm() {
nameInput.setCustomValidity('');
emailInput.setCustomValidity('');
if (phoneInput) phoneInput.setCustomValidity('');
subjectInput.setCustomValidity('');
messageInput.setCustomValidity('');
if (nameInput.value.trim().length < 1) {
nameInput.setCustomValidity('Veuillez saisir votre nom.');
}
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(emailInput.value.trim())) {
emailInput.setCustomValidity('Veuillez saisir une adresse email valide.');
}
if (phoneInput && phoneInput.value.trim() !== '') {
const phonePattern = /^\d{10,15}$/;
if (!phonePattern.test(phoneInput.value.trim())) {
phoneInput.setCustomValidity('Veuillez saisir un numéro valide (10 à 15 chiffres).');
}
}
if (subjectInput.value.trim().length < 3) {
subjectInput.setCustomValidity('Veuillez saisir un sujet (3 caractères minimum).');
}
if (messageInput.value.trim().length < 10) {
messageInput.setCustomValidity('Veuillez saisir un message (10 caractères minimum).');
}
form.classList.add('was-validated');
return form.checkValidity();
}
// --- Handler de soumission ---
form.addEventListener('submit', async function (event) {
event.preventDefault();
event.stopPropagation();
if (!validateForm()) return;
// Honeypot : si rempli, simuler un succès sans rien envoyer
if (honeypotInput && honeypotInput.value.trim() !== '') {
successMessage.style.display = 'block';
form.reset();
form.classList.remove('was-validated');
return;
}
// UI : état d'envoi
submitButton.disabled = true;
submitButton.innerHTML = `
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
Envoi en cours...
`;
successMessage.style.display = 'none';
errorMessage.style.display = 'none';
try {
const fb = await waitForFirebase();
const messagesCollection = fb.collection(fb.db, 'contact_messages');
await fb.addDoc(messagesCollection, {
name: nameInput.value.trim(),
email: emailInput.value.trim(),
phone: phoneInput ? phoneInput.value.trim() : '',
subject: subjectInput.value.trim(),
message: messageInput.value.trim(),
created_at: fb.serverTimestamp(),
user_agent: navigator.userAgent.substring(0, 500)
});
successMessage.style.display = 'block';
form.reset();
form.classList.remove('was-validated');
} catch (error) {
console.error('Erreur Firestore:', error);
errorMessage.style.display = 'block';
} finally {
submitButton.disabled = false;
submitButton.innerHTML = `
<i class="bi bi-send me-2"></i>
Envoyer le Message
`;
}
}, false);
});
Những thay đổi so với mock:
-
waitForFirebase()— polling có timeout, hoạt động ổn định ngay cả khi CDN chậm -
honeypot— nếu trường ẩn`hp_name`đã được điền, mô phỏng thành công mà không cần gọi Firestore. Bot tin rằng đã thành công nhưng không có gì được lưu trữ -
addDoc(collection, {…})— lệnh gọi Firestore thực tế với`serverTimestamp()` etuser_agent -
Xử lý lỗi với`try/catch`bất đồng bộ
-
Dọn dẹp`finally`(khôi phục nút)
|
Tại sao`user_agent`? Điều này là tùy chọn, nhưng hữu ích cho việc chẩn đoán. Nếu có một thông báo lạ xuất hiện, việc biết nó đến từ trình duyệt desktop, di động hay một script curl sẽ giúp ích cho việc phân loại. |
Giai đoạn 3: Dọn dẹp mã thừa Supabase
`script.js`chứa 250 dòng dead code:
-
SupabaseManager(dòng 417-481) — 65 dòng -
ContactFormHandler(dòng 490-551) — 62 dòng -
Khối khởi tạo (dòng 645-654) — 10 dòng
Tổng cộng: ~140 dòng cần xóa.
Khối`DOMContentLoaded`tạo một`SupabaseManager`sau đó một`ContactFormHandler`được gắn với biểu mẫu. Như đã giải thích ở trên, mã này không bao giờ được thực thi (bị chặn bởi`contact.js`), và ngay cả khi nó được thực thi, nó cũng sẽ thất bại (không có SDK Supabase nào được tải).
Tôi xóa:
-
Lớp`SupabaseManager`
-
Lớp`ContactFormHandler`
-
Khối khởi tạo trong`DOMContentLoaded`(dòng 645-654)
Le reste de script.js`vẫn còn nguyên vẹn :`ThemeManager, ScrollToTopButton, MobileMenuManager, SmoothScrollWithOffset, NavbarHeightUpdater, DynamicNavbarBreakpoint, CodeBlockManager, TooltipManager, `PhoneInputManager`Vui lòng cung cấp văn bản cần dịch.
Giai đoạn 4: Cấu hình footer
`footer.thyme`đã có sẵn boilerplate Firebase nhưng với các giá trị placeholder. Tôi thay thế:
const firebaseConfig = {
apiKey: "REMPLACER_PAR_VOTRE_API_KEY",
authDomain: "REMPLACER_PAR_VOTRE_AUTH_DOMAIN",
projectId: "REMPLACER_PAR_VOTRE_PROJECT_ID",
storageBucket: "REMPLACER_PAR_VOTRE_STORAGE_BUCKET",
messagingSenderId: "REMPLACER_PAR_VOTRE_SENDER_ID",
appId: "REMPLACER_PAR_VOTRE_APP_ID"
};
Bằng các giá trị thực tế được lấy từCài đặt dự án > Chung > Ứng dụng của bạn > Ứng dụng webtrong bảng điều khiển Firebase.
Các giá trị là nhạy cảm (apiKey`là mặc định là công khai trong Firebase, nhưng tôi muốn tránh commit chúng dưới dạng văn bản thuần túy). Tôi lưu trữ chúng trong`site.yml(đã ở trong`.gitignore`) và plugin`bakery`tiêm chúng vào template thông qua một logic cần thêm ở phía build.
Hiện tại, tôi cho chúng trực tiếp vào`footer.thyme`— bản build`./gradlew serve`sẽ tải chúng cục bộ. Khi triển khai, tôi sẽ di chuyển việc tiêm (injection) sang`site.yml`hoặc tới một biến Gradle.
|
L'`apiKey`Firebase không phải làkhôngmột bí mật. Nó được thiết kế để công khai. Điều bảo vệ dữ liệu của bạn chính là cácquy tắc bảo mật Firestore, không phải khóa API. Đừng đặt nó vào trong một`.env`được tải ở phía máy chủ — nó được dùng để hiển thị cho trình duyệt. |
Giai đoạn 5: Kiến trúc cuối cùng
@startuml
skinparam backgroundColor #FEFEFE
title Kiến trúc cuối cùng — biểu mẫu liên hệ Firebase
actor Utilisateur as USER
package "Trình duyệt" #E8F5E9 {
rectangle "contact.thyme\n(HTML, honeypot)" as FORM
rectangle "contact.js\n(xác thực + Firestore)" as CONT
rectangle "footer.thyme\n(Firebase SDK init)" as FOOT
}
cloud "Firebase" #BBDEFB {
rectangle "Firestore\n(contact_messages)" as FS
}
FORM --> CONT : Gửi sự kiện
CONT --> FOOT : window.__FIREBASE__.addDoc()
FOOT --> FS : Insert document\n(quy tắc an toàn đã được phê duyệt)
note right of FS
Firestore Rules :
- tạo: công khai, đã được xác thực
- đọc: chỉ cho người dùng đã xác thực
end note
@enduml
Điều mà cuộc di cư này nói về dogfooding
Trang web này được tạo bởi plugin Gradle của riêng tôi`bakery`. Biểu mẫu liên hệ nằm bên trong trang web. Quá trình di chuyển Supabase → Firebase được ghi chép trong`AGENT.adoc`, nó được thảo luận trong backlog, nó được kiểm thử thông qua`./gradlew serve`, và nó tạo ra một bài viết blog (bài mà bạn đang đọc).
Đây thuần túy là dogfooding. Trang web là sản phẩm của plugin, plugin là sản phẩm của nhà phát triển, và nhà phát triển tài liệu hóa quy trình ngay trên chính trang web đó.
Vòng tròn đã khép lại.
Việc kéo dài một bản mock trong nhiều tháng (những phiên làm việc mà biểu mẫu im lặng nói dối) đã khiến tôi nhận ra một điều: backlog của một trang web tĩnh cá nhân không bao giờ "kết thúc". Luôn có một US ưu tiên, luôn có một bài viết đang nháp, luôn có một phần bị comment trong một template. Kỷ luật không phải là hoàn thành tất cả mọi thứ — mà là hoàn thành những gì hiển thị với người dùng.
Một biểu mẫu liên hệ bị hỏng còn tệ hơn là không có biểu mẫu nào. Đó là một lời hứa không được thực hiện.
Tóm tắt các thay đổi
| Tệp | Chỉnh sửa | Tác động |
|---|---|---|
|
Tạo bài viết |
Tài liệu hướng dẫn |
|
Viết lại (mock → Firestore thực tế) |
Chức năng |
|
Loại bỏ SupabaseManager + ContactFormHandler + init bloc |
Làm sạch |
|
Thay thế config placeholder → giá trị thực tế |
Cấu hình |
Các bước tiếp theo (backlog)
-
Email thông báo: Một Cloud Function`onCreate`trên`contact_messages`người gửi email qua SendGrid. Biểu mẫu có lưu dữ liệu, nhưng tôi không nhận được thông báo. Ưu tiên trung bình — các tin nhắn vẫn hiển thị trong console Firebase.
-
Giới hạn tốc độ phía client: Thêm một timestamp localStorage để ngăn chặn việc gửi nhiều yêu cầu liên tục. Honeypot chặn các bot đơn giản, còn rate limiter sẽ chặn các bot tinh vi hơn một chút.
-
Kiểm thử: Một bài kiểm tra Playwright gửi biểu mẫu và xác minh rằng tài liệu xuất hiện trong Firestore. Hiện tại, tôi đang kiểm tra thủ công thông qua`./gradlew serve`Vui lòng cung cấp văn bản cần dịch.