visit
In this article:
React is a renowned framework for creating robust web applications. It is one of the most popular and is supported by millions of developers who create incredible tools for it. As a developer, I love it, and I know the community shares this sentiment. So, why wouldn't we use it for more than just regular web apps, like browser extensions, for instance? That's what occurred to me when I needed to create one. I had an array of company-wide tools at my disposal and several team members skilled in React who could help maintain it alongside me. So, it was the obvious choice.
Unified development approach in the team: React, MobX, Redux, you name it - we're all on the same page. It's not just about using the same tools, it's about building a shared understanding. React, especially, brings a lot to the table. Its component-based structure is like a gift for browser extensions, allowing us to create reusable parts that ensure consistency and efficiency. What's more, React's virtual DOM works magic with updating and rendering components. It's all about keeping things smooth and speedy.
Reusability of tools: UI Kit, utility functions, Storybook - it's all about making the most of what we have. It's not just about efficiency, but consistency too. Thanks to React's wide community and rich ecosystem, we're never short of solutions or advice for common challenges. This combination speeds up our development process and makes it a lot more fun.
Simplicity of maintenance: With our unified approach and reusable tools, maintenance is a breeze. If you're a front-end developer with a handle on these technologies, you can jump right in. React's learning curve is more of a gentle slope than a steep hill, making it easier for new team members to get on board.
Flexibility: One of the things I love about React is that it doesn't lock you into a specific way of doing things. It gives us the freedom to choose the best tools and libraries for our browser extensions. It's all about finding what works best for us.
The extension consists of the following main parts: popup
, content scripts
, and service worker
(formerly a background script), with properties described in the manifest.json
file.
"action": {
"default_popup": "index.html"
},
runtime.onMessage.addListener((message: Message) => {
if (message.from === Participant.Background) {
// Do your cool stuff
}
if (message.from === Participant.Content) {
// Do your nice stuff
}
})
popup.ts
Background or Service Worker.
This script operates independently in the background, irrespective of what's happening with browser tabs. It's akin to the backbone of a web application, taking care of various tasks such as managing HTTP requests, data storage, redirects, and authentication.
"background": {
"service_worker": "./static/js/background.js"
},
manifest.json
permissions - this category contains a list of APIs that the extension needs to function properly. These permissions need to be granted before the extension is used, ensuring the user is aware of the data and features the extension will access.
optional_permissions - these permissions are very similar to the main permissions
category, but they are requested dynamically during runtime. This means the extension can ask for these permissions as needed, rather than all at once during installation. This can help build trust with users by only requesting access when necessary and providing a clear reason why.
host_permissions - these permissions involve that provide access to specific hosts or websites. This allows the extension to interact with webpages on these hosts, expanding its reach and functionality.
optional_host_permissions - similar to host_permissions
, these permissions are requested during runtime. This allows the extension to request access to additional hosts or websites as needed, rather than requiring all permissions upfront.
Installation of Yarn globally:
sudo npm i --g yarn
TypeScript is a superset of JavaScript that adds static type definitions. Types provide a way to describe the shape of an object, providing better documentation, and allowing TypeScript to validate that your code is working correctly. This results in robust, well-structured, and more maintainable code. TypeScript's static typing catches errors during development rather than when the code is running. TypeScript provides better autocompletion and helps in code editors, which speeds up development and reduces the chance of errors. Its static types make refactoring more straightforward and safer.
Create React App is a popular tool for creating new React applications without having to manually configure tools like Webpack or Babel. It allows us to focus on the code and not on the setup. CRA comes with a preconfigured setup that includes a web server for development, a testing environment, and scripts for building and deploying your application. It also eliminates the need to spend time setting up and configuring the development environment. CRA uses sensible defaults and best practices for React development.
yarn create react-app my-extension --template typescript
The webextension-polyfill
is a handy tool that we'll be using in our project. Its primary role is to let us use the WebExtension API in Chrome and provide associated types. This API is pretty much the building blocks of our browser extension, enabling us to interact with the browser's functionality.
But here's the thing, not all browsers have the same level of support for the WebExtension API. This is where webextension-polyfill
comes into play. It's kind of like a translator, making sure our extension can speak the same language as Chrome, regardless of the original API compatibility.
In other words, webextension-polyfill
is a major time-saver and helps keep our code clean and efficient. It lets us focus on building great features for our extension, rather than getting caught up in browser compatibility issues.
yarn add webextension-polyfill
yarn add -D @types/webextension-polyfill
yarn add -D @types/chrome
src/
├── background/
│ ├── index.ts
├── content/
│ ├── index.ts
├── popup/
└── public/
└── manifest.json
import { runtime } from 'webextension-polyfill'
runtime.onInstalled.addListener(() => {
console.log('[background] loaded ')
})
export {}
background/index.ts
console.log('[content] loaded ')
export {}
content/index.ts
We need to move the files created by CRA App.*
to src/popup
.
src/popup/index.tsx
becomes the entry point of the Popup.
In src/index.ts
, we add an import of Popup so that the build
command works without additional modifications.
import './popup/index'
src/index.ts
src/
├── background/
│ ├── index.ts
├── content/
│ ├── index.ts
├── popup/
│ ├── App
│ │ ├── App.css
│ │ ├── App.test.css
│ │ └── App.tsx
│ ├── index.tsx
└── public/
└── manifest.json
To build our code, we need to adjust Webpack which is responsible for bundling under the bonnet of CRA. For this, we install two libraries: react-app-rewired
and customize-cra
. The latter is essentially a layer built on top of the former, allowing us to modify the Webpack configuration that comes with Create React App.
yarn add -D customize-cra react-app-rewired
echo > config-overrides.js
entry
- this is the initial point from which Webpack starts building the dependency tree. At this stage, we specify paths to the three main components of the extension: popup, background service worker, and content scripts.
const overrideEntry = (config) => {
config.entry = {
main: './src/popup', // the extension UI
background: './src/background',
content: './src/content',
}
return config
}
output
- this defines the paths and names of the files that will be created as a result of the build.
// ...
const overrideOutput = (config) => {
config.output = {
...config.output,
filename: 'static/js/[name].js',
chunkFilename: 'static/js/[name].js',
}
return config
}
We end up with this config-overrides.js
file:
const { override } = require('customize-cra')
const overrideEntry = (config) => {
config.entry = {
main: './src/popup', // the extension UI
background: './src/background',
content: './src/content',
}
return config
}
const overrideOutput = (config) => {
config.output = {
...config.output,
filename: 'static/js/[name].js',
chunkFilename: 'static/js/[name].js',
}
return config
}
module.exports = {
webpack: (config) => override(overrideEntry, overrideOutput)(config),
}
config-overrides.js
An essential file for an extension manifest.json
describes its main properties. Let's create a basic configuration for our extension.
{
"manifest_version": 3,
"name": "My Extension",
"version": "1.0.0",
"description": "A simple Chrome extension with React.",
"icons": {
"16": "logo192.png",
"48": "logo192.png",
"128": "logo192.png"
},
"background": {
"service_worker": "./static/js/background.js"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["./static/js/content.js"]
}
],
"action": {
"default_popup": "index.html"
},
"permissions": ["storage", "tabs"]
}
manifest.json
Let's have a look at its contents:
manifest_version
- this refers to the version of the manifest file format that the extension adheres to. The current latest version is 3. It's essential to set this correctly as the various versions have differing capabilities and requirements.
name, description
- these fields define the public-facing name and a brief summary of your extension's functionality. They're what users will see in the Chrome Web Store and the Extensions Management page in the browser.
icons
- this section denotes the extension's icons in various sizes. These icons appear in multiple places: the Chrome Web Store, the Extensions Management page, and the toolbar button if the extension has one.
background
- if your extension maintains a long-term state, or performs long-term operations, you can include a background script here. The service_worker
field points to a JavaScript file that the browser will keep running as long as it's needed.
content_scripts
- a set of scripts that get injected into the pages that match the specified patterns. These scripts can read and manipulate the DOM, and are isolated from the scripts loaded by the page itself. The matches
field specifies which pages the scripts should be injected into.
action, default_popup
- the default_popup
field inside the action
field is used to specify the HTML file that will be rendered inside the popup when the toolbar button is clicked. This is a primary way of interacting with users.
permissions
- this section is for requesting access to various browser features that aren't available to web pages. For instance, the storage
permission allows the extension to use the chrome.storage
API to store and retrieve data that persists across browser sessions, and the tabs
permission gives the extension the ability to interact with browser tabs. It's important to note that users will see a warning when the extension requests these permissions.
The created manifest file should be placed in src/public
, from where Webpack will take it to the root of the extension build.
Now we can execute the build with the command I added to the package.json
→ scripts
:
"scripts": {
"build": "INLINE_RUNTIME_CHUNK=false react-app-rewired build",
}
Setting INLINE_RUNTIME_CHUNK=false
when building your app with Create React App is important in the context of Chrome Extensions because of the Content Security Policy (CSP).
When Create React App builds the project for production, by default, it includes the Webpack runtime script inline in the index.html
file. That's done to optimize performance by saving a network request, but it violates the CSP of a Chrome extension. Therefore, we need to set INLINE_RUNTIME_CHUNK=false
to make Create React App put the runtime script into a separate file rather than inlining it, thus ensuring it doesn't violate the CSP.
To do this, enable Developer mode
in chrome://extensions/
and click Load unpacked
.
Further, upon updating the code, the build needs to be executed again, but the extension doesn't need to be reinstalled. Just clicking the Refresh
button is enough.
body {
width: 300px;
height: 600px;
}
// ...
So, the task we’re solving:
console.log('[content] loaded ')
// add a naive counter
let count = 0
src/content/index.ts
Next, we'll write a function to add a listener to the global window
object. We'll also declare a type for the event listener.
// ...
type Listener = (event: MouseEvent) => void
function registerClickListener(listener: Listener) {
window.addEventListener('click', listener)
}
src/content/index.ts
We'll then create a function to increment the counter:
// ...
function countClicks() {
count++
console.log('click(): ', count)
}
src/content/index.ts
src/content/index.ts
// ...
export function init() {
registerClickListener(countClicks)
}
init()
src/content/index.ts
You can also access the service worker console from the chrome://extensions/
page, clicking on service worker
link.
Background
import { runtime } from 'webextension-polyfill'
console.log('[content] loaded ')
type Listener = (event: MouseEvent) => void
let count = 0
function registerClickListener(listener: Listener) {
window.addEventListener('click', listener)
}
function countClicks() {
count++
console.log('click(): ', count)
return runtime.sendMessage({
from: 'content',
to: 'background',
action: 'click'
}
}
export function init() {
registerClickListener(countClicks)
}
init()
src/content/index.ts
content.js:1 Uncaught (in promise) Error: Could not establish connection.
Receiving end does not exist.
To fix this, we need to establish a receiver in our background script src/background/index.ts
import { runtime } from 'webextension-polyfill'
type Message = {
from: string
to: string
action: string
}
export function init() {
// the message receiver
runtime.onMessage.addListener((message: Message) => {
if (message.to === 'background') {
console.log('background handled: ', message.action)
}
})
console.log('[background] loaded ')
}
runtime.onInstalled.addListener(() => {
init()
})
src/background/index.ts
// ...
import { tabs } from 'webextension-polyfill'
async function getCurrentTab() {
const list = await tabs.query({ active: true, currentWindow: true })
return list[0]
}
export function init() {
runtime.onMessage.addListener(async (message: Message) => {
if (message.to === 'background') {
console.log('background handled: ', message.action)
const tab = await getCurrentTab()
const tabId = tab.id
}
})
console.log('[background] loaded ')
}
// ...
src/background/index.ts
When we capture the tabId
and its associated clicks, we need to store this data somewhere. However, the service worker, which might be a place to consider for storage, isn't always running and can stop unexpectedly, potentially losing data.
So, we need a more stable place to store data. This is why we use storage.local
the web extension API. This storage option is designed to hold onto data reliably, even when the service worker isn't active. By using storage.local
, we can securely store and manage the tabId
and click data, ensuring our extension works properly.
import { runtime, storage, tabs } from 'webextension-polyfill'
type Message = {
from: string
to: string
action: string
}
async function getCurrentTab() {
const list = await tabs.query({ active: true, currentWindow: true })
return list[0]
}
async function incrementStoredValue(tabId: string) {
const data = await storage.local.get(tabId)
const currentValue = data?.[tabId] ?? 0
return storage.local.set({ [tabId]: currentValue + 1 })
}
export async function init() {
await storage.local.clear()
runtime.onMessage.addListener(async (message: Message) => {
if (message.to === 'background') {
console.log('background handled: ', message.action)
const tab = await getCurrentTab()
const tabId = tab.id
if (tabId) {
return incrementStoredValue(tabId.toString())
}
}
})
}
runtime.onInstalled.addListener(() => {
init().then(() => {
console.log('[background] loaded ')
})
})
src/background/index.ts
We'll need to use the getCurrentTab
function again, so let's move it into a helper file for better code organization and reuse.
import { tabs } from 'webextension-polyfill'
export async function getCurrentTab() {
const list = await tabs.query({ active: true, currentWindow: true })
return list[0]
}
src/helpers/tabs.ts
export const Counter = () => {
const value = 0
return (
<div
style={{
height: '100vh',
fontSize: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
Clicks: {value}
</div>
)
}
src/popup/Counter/index.tsx
So, the next step is to fetch the click count from storage
using the same API as we did in the service worker file.
import { useEffect, useState } from 'react'
import { storage } from 'webextension-polyfill'
import { getCurrentTab } from '../../helpers/tabs'
export const Counter = () => {
const [value, setValue] = useState()
useEffect(() => {
const readBackgroundMessage = async () => {
const tab = await getCurrentTab()
const tabId = tab.id
if (tabId) {
const data = await storage.local.get(tabId.toString())
const currentValue = data?.[tabId] ?? 0
setValue(currentValue)
}
}
readBackgroundMessage()
}, [])
return (
<div
style={{
height: '100vh',
fontSize: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
Clicks: {value}
</div>
)
}
src/popup/Counter/index.tsx