Реализация Content Script (модификация страниц) в браузерном расширении
Content script — это JavaScript-файл, который браузер внедряет в контекст целевой страницы. Он работает в изолированном окружении («isolated world»): имеет доступ к DOM страницы, но не к переменным JS-кода самой страницы. Это и защита, и ограничение одновременно.
Как браузер загружает content script
В manifest.json (MV3) объявляете список скриптов и условия их запуска:
{
"manifest_version": 3,
"content_scripts": [
{
"matches": ["https://*.example.com/*", "https://other-site.com/app/*"],
"js": ["content/injected.js"],
"css": ["content/injected.css"],
"run_at": "document_idle",
"all_frames": false,
"world": "ISOLATED"
}
]
}
run_at принимает три значения: document_start (до построения DOM), document_end (DOM готов, ресурсы ещё грузятся), document_idle (после DOMContentLoaded — безопасный дефолт для большинства задач).
Если нужно внедрить скрипт динамически — из service worker или по требованию:
// background/service-worker.js
chrome.action.onClicked.addListener(async (tab) => {
await chrome.scripting.executeScript({
target: { tabId: tab.id, allFrames: false },
files: ['content/injected.js'],
world: 'ISOLATED' // или 'MAIN' для доступа к JS-контексту страницы
});
});
world: 'MAIN' даёт доступ к переменным страницы, но теряете изоляцию — используйте только когда это реально нужно (перехват вызовов нативных API, monkey-patching).
Работа с DOM
Content script видит полный DOM, включая Shadow DOM (с оговорками). Простая задача — подсветить все цены на странице:
// content/price-highlighter.js
function highlightPrices() {
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_TEXT,
{
acceptNode(node) {
return /\$[\d,]+\.?\d{0,2}/.test(node.textContent)
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_SKIP;
}
}
);
const nodes = [];
while (walker.nextNode()) nodes.push(walker.currentNode);
nodes.forEach(node => {
const span = document.createElement('span');
span.innerHTML = node.textContent.replace(
/(\$[\d,]+\.?\d{0,2})/g,
'<mark class="ext-price-highlight">$1</mark>'
);
node.parentNode.replaceChild(span, node);
});
}
// Страница могла загрузить часть контента через XHR/fetch после DOMContentLoaded
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.addedNodes.length > 0) {
highlightPrices();
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
highlightPrices();
MutationObserver — обязательный инструмент для SPA, где контент постоянно меняется без перезагрузки страницы.
Общение с background service worker
Content script не может напрямую обращаться к chrome API (например, chrome.tabs), поэтому сообщения идут через messaging:
// content/injected.js — отправка сообщения
const response = await chrome.runtime.sendMessage({
type: 'FETCH_USER_SETTINGS',
payload: { domain: location.hostname }
});
if (response.theme === 'dark') {
document.documentElement.classList.add('ext-dark-mode');
}
// background/service-worker.js — обработка
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'FETCH_USER_SETTINGS') {
chrome.storage.sync.get(['settings'], (result) => {
sendResponse(result.settings ?? {});
});
return true; // Важно: говорит браузеру, что ответ будет асинхронным
}
});
Для двустороннего потока данных (например, стриминг) используйте chrome.runtime.connect():
// content script
const port = chrome.runtime.connect({ name: 'content-stream' });
port.onMessage.addListener((msg) => {
if (msg.type === 'DATA_CHUNK') appendChunk(msg.data);
});
port.postMessage({ type: 'START_STREAM', url: location.href });
Внедрение стилей без конфликтов
CSS content script применяется к странице и может конфликтовать со стилями сайта. Два подхода:
1. Shadow DOM — полная изоляция:
const host = document.createElement('div');
host.id = 'my-extension-root';
document.body.appendChild(host);
const shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host { all: initial; display: block; }
.panel { background: #1a1a2e; color: #fff; padding: 16px; }
</style>
<div class="panel">Контент расширения</div>
`;
2. CSS с высокой специфичностью + уникальные префиксы классов:
/* injected.css — все классы с префиксом ext- */
.ext-toolbar {
all: initial;
display: flex !important;
position: fixed !important;
top: 0;
right: 0;
z-index: 2147483647; /* максимальный z-index */
}
Передача данных из страницы в content script
Поскольку JS-контекст изолирован, для получения данных из скриптов страницы (window-переменных, событий) используют window.postMessage:
// Скрипт внутри страницы (или MAIN world)
window.postMessage({ source: 'my-extension-page', type: 'AUTH_TOKEN', token: authToken }, '*');
// Content script (ISOLATED world) — слушает сообщения
window.addEventListener('message', (event) => {
if (event.source !== window) return;
if (event.data?.source !== 'my-extension-page') return;
if (event.data.type === 'AUTH_TOKEN') {
chrome.runtime.sendMessage({ type: 'STORE_TOKEN', token: event.data.token });
}
});
Типичные проблемы
Content script не срабатывает на SPA-навигацию. Браузер не перезагружает скрипт при history.pushState. Решение — слушать popstate или использовать MutationObserver на изменения <title>:
let lastUrl = location.href;
const urlObserver = new MutationObserver(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
onNavigate(location.href);
}
});
urlObserver.observe(document.querySelector('title') ?? document.head, {
subtree: true, characterData: true, childList: true
});
CSP блокирует inline-стили. Если страница использует строгий Content-Security-Policy, добавление inline-стилей через element.style работает, но <style> тег — нет. Используйте chrome.scripting.insertCSS() из service worker вместо добавления тега вручную.
Производительность. Тяжёлые операции с DOM блокируют рендеринг. Batch-обновления через requestAnimationFrame, обходы большого DOM — через requestIdleCallback.
Структура файлов типичного content script модуля
extension/
├── manifest.json
├── content/
│ ├── index.js # точка входа, инициализация
│ ├── dom-modifier.js # работа с DOM
│ ├── messaging.js # обмен сообщениями с background
│ └── styles.css
└── background/
└── service-worker.js
Сборка через webpack или Vite с несколькими точками входа — content script и service worker компилируются отдельно, потому что у них разные среды выполнения и разные ограничения на импорты.







