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.

Chủ đề: Công cụ & Tự động hoá Ngày đăng: 13/08/2026
Chrome ExtensionManifest V3Tự động hoá

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.

Đ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.

Content Script (chạy trong trang web) Popup UI (khi bấm icon) Background Service Worker (bộ não trung tâm) chrome.storage.local (dữ liệu bền vững) gửi nội dung trang trả kết quả dịch yêu cầu tab hiện tại đọc / ghi
Bốn mảnh này không share biến trực tiếp — mọi trao đổi đều phải đi qua chrome.runtime.sendMessage. Đây chính là điều đầu tiên 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<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.

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ả:

tv-translate — popup.html
Dịch trang này Cài đặt
Trạng tháiĐang lắng nghe phụ đề
Ngôn ngữ đíchTiếng Việt
Bộ nhớ đệm128 dòng đã lưu
✓ Đã gửi yêu cầu tới background — chờ kết quả dịch...
1tin nhắn gửi đi mỗi lượt bấm
3ngữ cảnh phải đồng bộ
0biến toàn cục có thể tin tưởng
Sai lầm ban đầuHậu quảCách tôi sửa
Xin <all_urls> cho chắcCảnh báo quyền rộng mỗi lần cập nhậtKhai đúng domain cần dùng
Gọi thẳng hàm giữa content script và popupIm lặng không chạy, không lỗi rõ ràngChuyển sang sendMessage / onMessage
Lưu dữ liệu vào biến toàn cụcMất dữ liệu khi service worker bị tắtChuyể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ậtDùng await / callback đúng cách
Tìm console.log ở DevTools của trangTưởng background không chạyInspect 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ả.

  • 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
Ơ*Dev

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.