Xây Chrome Extension Đầu Tiên: Những Điều Tôi Ước Biết Trước Khi Bắt Đầu
Những va vấp thực tế khi lần đầu xây một tiện ích Chrome hoàn chỉnh, và lời khuyên cho ai muốn bắt đầu từ số 0 như tôi.
Trước khi làm TV Translate, tôi chưa từng viết một dòng code cho trình duyệt. Tôi biết Google Sheets, biết công thức, nhưng "extension" với tôi khi đó chỉ là mấy cái icon nhỏ nằm góc phải thanh địa chỉ mà tôi chưa từng nghĩ mình tự làm được. Bài này là những thứ tôi ước có ai đó nói cho tôi biết trước — thay vì để tôi tự đâm đầu vào từng cái một.
Kiến trúc một Chrome Extension: bốn mảnh phải ăn khớp
Điều đầu tiên khiến tôi bối rối: một extension không phải một chương trình, mà là bốn chương trình nhỏ sống trong bốn "thế giới" tách biệt, chỉ nói chuyện được với nhau bằng cách gửi tin nhắn qua lại.
chrome.runtime.sendMessage. Đây chính là điều đầu tiên tôi ước biết trước khi bắt đầu.Năm điều tôi ước biết trước khi bắt đầu
1. Manifest V3 không còn background page thường trực
Hầu hết hướng dẫn cũ tôi tìm được đầu tiên đều dạy theo Manifest V2, nơi có một "background page" chạy liên tục, biến số giữ nguyên giá trị bao lâu tuỳ thích. Manifest V3 — bắt buộc với extension mới từ lâu — thay nó bằng "service worker": một tiến trình có thể bị trình duyệt tắt đi bất cứ lúc nào để tiết kiệm tài nguyên, rồi bật lại khi có sự kiện. Biến toàn cục tôi lưu trong bộ nhớ RAM biến mất không báo trước — bài học: dữ liệu cần giữ lâu phải lưu vào chrome.storage.local, không phải một biến JavaScript thường.
2. Permissions xin đúng, đừng xin thừa
Lần đầu, tôi khai host_permissions là <all_urls> cho "chắc ăn" — extension chạy được trên mọi trang. Nhưng xin quyền truy cập mọi website là một cờ đỏ lớn, kể cả khi chỉ tự dùng cá nhân: mỗi lần cập nhật, Chrome sẽ cảnh báo lại quyền này, và nếu sau này đăng lên Chrome Web Store, phạm vi quyền càng rộng thì càng dễ bị từ chối duyệt. Sửa lại thành đúng domain cần thiết — ví dụ chỉ domain của trang xem video — vừa an toàn hơn, vừa ít bị Chrome nhắc cảnh báo hơn.
3. Content script và popup sống trong hai thế giới khác nhau
Tôi từng thử gọi thẳng một hàm JavaScript viết trong content script từ file popup.js, kiểu như gọi hàm trong cùng một trang — không hoạt động, không lỗi rõ ràng, chỉ im lặng không chạy. Lý do: mỗi phần chạy trong một ngữ cảnh (context) riêng, không share bộ nhớ. Cách duy nhất để nói chuyện là chrome.runtime.sendMessage / chrome.tabs.sendMessage — gửi một tin nhắn dạng dữ liệu, và phía nhận lắng nghe bằng onMessage.addListener. Hiểu đúng mô hình "nhắn tin" này ngay từ đầu tiết kiệm cho tôi cả một buổi tối đoán mò.
4. chrome.storage.local không phải là localStorage
Hai cái tên nghe giống nhau, hành vi khác hẳn. localStorage gắn với từng trang web cụ thể và đồng bộ (synchronous); chrome.storage.local là bộ nhớ riêng của extension, dùng chung được giữa popup, background và content script, nhưng luôn là bất đồng bộ (asynchronous) — phải dùng callback hoặc await. Tôi từng viết const data = chrome.storage.local.get('key') rồi thắc mắc tại sao data luôn là một Promise treo lơ lửng thay vì giá trị thật.
5. Debug một extension khác hẳn debug web bình thường
Console log trong content script hiện ở DevTools của chính trang web đó. Console log trong background service worker lại nằm ở một trang riêng, mở qua chrome://extensions → "Service worker" → "Inspect". Console log trong popup chỉ hiện được khi popup đang mở, đóng lại là mất log. Ba nơi log khác nhau cho ba phần khác nhau — hiểu điều này sớm sẽ đỡ tốn thời gian nhìn console trống trơn rồi tưởng code không chạy, trong khi log thực ra đang nằm ở một tab DevTools khác hoàn toàn.
Minh hoạ: một lượt bấm icon, từ popup đến kết quả
Mô phỏng lại đúng luồng thật của TV Translate — bấm icon, popup gửi yêu cầu, background xử lý và lưu lại kết quả:
Sai lầm ban đầu và cách tôi sửa
| Sai lầm ban đầu | Hậu quả | Cách tôi sửa |
|---|---|---|
Xin <all_urls> cho chắc | Cảnh báo quyền rộng mỗi lần cập nhật | Khai đúng domain cần dùng |
| Gọi thẳng hàm giữa content script và popup | Im lặng không chạy, không lỗi rõ ràng | Chuyển sang sendMessage / onMessage |
| Lưu dữ liệu vào biến toàn cục | Mất dữ liệu khi service worker bị tắt | Chuyển sang chrome.storage.local |
| Đọc storage.local như một giá trị đồng bộ | Nhận về Promise thay vì dữ liệu thật | Dùng await / callback đúng cách |
| Tìm console.log ở DevTools của trang | Tưởng background không chạy | Inspect qua chrome://extensions |
Cột in đậm là điều thực sự giải quyết được vấn đề, sau khi cách "trực giác ban đầu" ở cột giữa gây ra hậu quả.
Checklist trước khi coi một extension là "xong"
- ☐Manifest khai đúng version 3, không copy mẫu Manifest V2 cũ
- ☐host_permissions chỉ gồm domain thực sự cần, không có <all_urls> nếu không bắt buộc
- ☐Dữ liệu cần giữ lâu nằm trong chrome.storage.local, không phải biến JS thường
- ☐Đã test lại sau khi tắt/bật service worker thủ công, không chỉ test lần đầu load
- ☐Đã kiểm tra console ở cả ba nơi: trang web, popup, và service worker
Chrome Extension đầu tiên khó không phải vì code phức tạp, mà vì mô hình tư duy khác hẳn một trang web bình thường: bốn ngữ cảnh tách biệt, chỉ nói chuyện qua tin nhắn, và một service worker có thể biến mất bất cứ lúc nào. Hiểu đúng bốn mảnh kiến trúc này trước khi viết dòng code đầu tiên sẽ tiết kiệm cho bạn đúng những buổi tối tôi đã mất với TV Translate.
Sơ đồ kiến trúc và widget popup trong bài dùng SVG/CSS dựng trực tiếp trên trang, không phải ảnh chụp hay video ngoài — các chấm sáng và đường nối có animation để mô phỏng luồng tin nhắn thật giữa các phần của extension.