Почему вам стоит обратить внимание на Manifest V3 и TypeScript

Если вы подумывали о создании расширения для Chrome, но вас пугала перспектива устаревания Manifest V2, приготовьтесь — это ваш шанс. Manifest V3 пришёл, чтобы остаться, а его сочетание с TypeScript превращает разработку расширений из «отладки загадочных условий гонки в 2 часа ночи» в нечто по-настоящему профессиональное.

Признаюсь честно: раньше создание браузерных расширений было похоже на борьбу с осьминогом вслепую. Но сегодня? Сегодня это больше похоже на экскурсию, где TypeScript держит вас за руку и мягко указывает, когда вы собираетесь выстрелить себе в ногу.

Архитектурный ландшафт

Прежде чем мы погрузимся в код, давайте разберёмся, как все части соединяются вместе. Современное расширение для Chrome с Manifest V3 — это не просто что-то одно, это целая экосистема скриптов, каждый из которых имеет свои специфические обязанности и ограничения.

graph TB User["Взаимодействие пользователя"] Popup["Popup UI
React/HTML"] ContentScript["Content Script
Выполняется в контексте страницы"] ServiceWorker["Service Worker
Логика в фоновом режиме"] StorageAPI["Chrome Storage API"] User -->|Нажатие на значок расширения| Popup User -->|Взаимодействие с страницей| ContentScript Popup -->|Отправка сообщения| ServiceWorker ContentScript -->|Отправка сообщения| ServiceWorker ServiceWorker -->|Сохранение данных| StorageAPI Popup -->|Чтение данных| StorageAPI ServiceWorker -->|Общение| ContentScript

Эта архитектура существует по соображениям безопасности — каждый компонент работает в разных контекстах с разными разрешениями. Это как крепость со специализированными стражами, каждый из которых защищает свой периметр.

Настройка среды разработки

Давайте перейдём к практике. Сначала создайте каталог вашего проекта и инициализируйте его:

mkdir my-awesome-extension
cd my-awesome-extension
npm init -y

Затем установите необходимые зависимости:

npm install --save-dev typescript webpack webpack-cli ts-loader @types/chrome @types/node
npm install react react-dom
npm install --save-dev @types/react @types/react-dom

Теперь инициализируйте TypeScript:

npx tsc --init

Это создаст файл tsconfig.json. Обновите его, чтобы он был строг к TypeScript, но разумен:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "lib": ["ES2020", "DOM"],
    "jsx": "react-jsx",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "declaration": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}

Создание основы Manifest V3

Файл manifest.json — это паспорт вашего расширения. Он объявляет, что делает ваше расширение и что ему для этого нужно. Вот хорошо структурированный пример, который работает с TypeScript:

{
  "manifest_version": 3,
  "name": "Моё потрясающее расширение",
  "version": "1.0.0",
  "description": "Расширение, которое делает потрясающие вещи, очевидно",
  "permissions": ["storage", "scripting", "tabs"],
  "action": {
    "default_popup": "popup.html",
    "default_title": "Моё потрясающее расширение"
  },
  "background": {
    "service_worker": "background.js"
  },
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content.js"],
      "run_at": "document_start"
    }
  ],
  "host_permissions": ["<all_urls>"]
}

Обратите внимание, что мы больше не используем фоновые страницы — только service_worker. Это способ Manifest V3 поддерживать эффективность и компактность. Сервис-воркер просыпается, когда это необходимо, и снова засыпает, экономя системные ресурсы. Представьте его как высококвалифицированного ассистента, который появляется только по вызову.

Создание пользовательского интерфейса всплывающего окна с помощью React и TypeScript

Создайте src/popup/index.tsx:

import React, { useState, useEffect } from 'react';
import ReactDOM from 'react-dom/client';
import './popup.css';
interface CounterData {
  clickCount: number;
  lastClicked?: string;
}
const Popup: React.FC = () => {
  const [count, setCount] = useState<number>(0);
  const [loading, setLoading] = useState<boolean>(true);
  useEffect(() => {
    chrome.storage.local.get(['clickCount'], (result) => {
      setCount(result.clickCount || 0);
      setLoading(false);
    });
  }, []);
  const handleClick = async (): Promise<void> => {
    const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
    if (tab.id) {
      chrome.tabs.sendMessage(tab.id, { action: 'doSomethingAwesome' });
      const newCount = count + 1;
      setCount(newCount);
      chrome.storage.local.set({
        clickCount: newCount,
        lastClicked: new Date().toISOString()
      });
    }
  };
  if (loading) {
    return <div className="popup">Загрузка...</div>;
  }
  return (
    <div className="popup">
      <h1>Привет, расширение!</h1>
      <p>Вы нажали кнопку <strong>{count}</strong> раз</p>
      <button onClick={handleClick}>Сделать что-то потрясающее</button>
    </div>
  );
};
const root = ReactDOM.createRoot(document.getElementById('root')!);
root.render(<Popup />);

И соответствующий popup.html:

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <style>
    body {
      width: 300px;
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto;
      margin: 0;
      padding: 0;
    }
  </style>
</head>
<body>
  <div id="root"></div>
  <script src="popup.js"></script>
</body>
</html>

Контент-скрипт: где происходит волшебство на странице

Создайте src/content/index.ts:

interface ExtensionMessage {
  action: string;
  payload?: unknown;
}
interface ContentScriptResponse {
  success: boolean;
  message: string;
}
chrome.runtime.onMessage.addListener(
  (request: ExtensionMessage, sender: chrome.runtime.MessageSender, sendResponse: (response: ContentScriptResponse) => void) => {
    if (request.action === 'doSomethingAwesome') {
      try {
        // Ваша потрясающая логика здесь
        const elements = document.querySelectorAll('body');
        elements.forEach((element) => {
          element.style.borderRadius = '8px';
          element.style.boxShadow = '0 0 10px rgba(0, 0, 0, 0.1)';
        });
        sendResponse({
          success: true,
          message: 'Что-то потрясающее было сделано!'
        });
      } catch (error) {
        sendResponse({
          success: false,
          message: `Ошибка: ${error instanceof Error ? error.message : 'Неизвестная ошибка'}`
        });
      }
    }
  }
);

Сервис-воркер: мозг вашего расширения

Создайте src/background/index.ts:

interface StorageData {
  clickCount: number;
  lastClicked?: string;
  extensionEnabled: boolean;
}
// Инициализация хранилища при установке расширения
chrome.runtime.onInstalled.addListener((): void => {
  chrome.storage.local.set({
    clickCount: 0,
    extensionEnabled: true
  } as StorageData);
  console.log('Расширение установлено и готово к работе!');
});
// Прослушивание сообщений от контент-скриптов и всплывающего окна
chrome.runtime.onMessage.addListener(
  (request: { action: string }, sender: chrome.runtime.MessageSender, sendResponse: (response: any) => void)