If you're new to this series and don't want to read the previous posts, here's a quick recap:
- I started this series building a very simple chrome extension that I've been updating and improving in every post.
- The chrome extension we're working with is called "Acho, where are we?"
- Acho is the name of my dog 🐶, and in this extension, he will bark and tell you the Title of the page you're currently navigating:
- Through a browser action (a popup that appears at the right of the navigation bar in Chrome)
- Or through a keyboard shortcut that shows a notification at the bottom-right of the screen.
Table of contents
- Introduction
- Centralize the shared logic in a separate file
- Accessing the reusable code
- Conclusion
- The repo
- Let me know what you think! 💬
Introduction
So far, our extension has the following features:
- Display a browser action (popup) with the title of the active tab
- A command that duplicates the current tab
- A command that shows a notification at the bottom-right of the screen with the active Tab title.
And these are the components we built to manage the logic of these features:
The functions "Get active tab" and "Show tab title" are used by multiple components, but right now, their logic is duplicated inside each of the components. As you may have imagined, we need to find a way to write that logic a single time and share it across our project.
ℹ️ About reusability: Reusing code allows us to save time and reduce redundancy in our project. By avoiding writing the same code multiple times, we're also making our project easier to maintain since our code gets cleaner and updates to the shared logic can be done in a single place.
So, a better version of our app would look something like this:
In this version, our components are only responsible for their particular logic, and the shared logic is separated in the acho.js
file, where it can be easily maintained and shared. There's also no duplicated logic.
Let's see how to achieve that in our sample chrome extension.
Centralize the shared logic in a separate file
For starters, we need our reusable logic to be centralized in a separate file. So we are going to create a new file called acho.js
. Here we will create a class named Acho and add the methods that will later be called from each component.
In a real example, you'd probably use more than one file for your shared logic. We are using just one to keep the example simple.
Here's how the acho.js
file looks like:
/** Shared logic */
class Acho {
/**
* Gets the active Tab
* @returns {Promise<*>} Active tab
*/
getActiveTab = async () => {
const query = { active: true, currentWindow: true };
const getTabTitlePromise = new Promise((resolve, reject) => {
chrome.tabs.query(query, (tabs) => {
resolve(tabs[0]);
});
});
return getTabTitlePromise;
}
/**
* Concatenates the tab title with Acho's barks.
* @param {String} tabTitle Current tab title
* @returns {String}
*/
getBarkedTitle = (tabTitle) => {
const barkTitle = `${this.getRandomBark()} Ahem.. I mean, we are at: <br><b>${tabTitle}</b>`
return barkTitle;
}
/**
* Array of available bark sounds
* @private
* @returns {String[]}
*/
getBarks = () => {
return [
'Barf barf!',
'Birf birf!',
'Woof woof!',
'Arf arf!',
'Yip yip!',
'Biiiirf!'
];
}
/**
* Returns a random bark from the list of possible barks.
* @private
* @returns {String}
*/
getRandomBark = () => {
const barks = this.getBarks();
const bark = barks[Math.floor(Math.random() * barks.length)];
return bark;
}
}
We have two public methods:
-
getActiveTab
returns the active tab. -
getBarkedTitle
generates a string concatenated with a random bark sound and the tab title. We'll use this both in the browser action (the popup) and the notification.
Then we have a few private methods just to simplify the logic in our public methods.
Accessing the reusable code
Great. Now our reusable logic is ready to be used by many components, but that's not all. We need to figure out how to access this logic from each component:
- Background script (
background.js
) - Content script (
content.js
) - Browser action script (
popup.js
)
To approach this issue it's important to remember that, even though all of these components are part of the same extension, they run in different contexts:
- The
popup.js
runs in the context of our Browser Action - The content script runs in the context of the web page.
- The background script handles events triggered by the browser and is only loaded when needed. It works independently from the current web page and the browser action.
So how can we make our reusable code available to all of these different contexts?
From the Browser Action
This one will probably feel familiar to you since the solution we are going to implement it's what we do in static HTML + JS websites: We are going to add the file acho.js
as a script in our browser action HTML file (popup.html
) using the <script>
tag:
Open the popup.html
file and add the script at the bottom of the <body>
tag, like so:
<body>
<!-- the rest of the body -->
<script src='popup.js'></script>
<script src='acho.js'></script> <!-- 👈 -->
</body>
Done! Now we can use the Acho
class from popup.js
, and our code will be significantly reduced:
document.addEventListener('DOMContentLoaded', async () => {
const dialogBox = document.getElementById('dialog-box');
const query = { active: true, currentWindow: true };
const acho = new Acho(); // 👈
const tab = await acho.getActiveTab();
const bark = acho.getBarkedTitle(tab.title);
dialogBox.innerHTML = bark;
});
From the content script
The solution here may not be as obvious, but it's pretty simple: Just add acho.js
to the js
array inside our current content script object in the manifest.json
file:
{
"manifest_version": 2,
"name": "Acho, where are we?",
...
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js", "acho.js"], // 👈
"css": ["content.css"]
}
],
}
And now we can instantiate and use the Acho
class in content.js
to generate the "barked title" string:
// Notification body.
const notification = document.createElement("div");
notification.className = 'acho-notification';
// Notification icon.
const icon = document.createElement('img');
icon.src = chrome.runtime.getURL("images/icon32.png");
notification.appendChild(icon);
// Notification text.
const notificationText = document.createElement('p');
notification.appendChild(notificationText);
// Add to current page.
document.body.appendChild(notification);
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
const notification = document.getElementsByClassName('acho-notification')[0];
const notificationText = notification.getElementsByTagName('p')[0];
// 👇👇👇
const acho = new Acho();
notificationText.innerHTML = acho.getBarkedTitle(request.tabTitle);
notification.style.display = 'flex';
setTimeout(function () {
notification.style.display = 'none';
}, 5000);
return true;
});
From the background script
Here the solution is similar: We need to add acho.js
to the scripts
array of our background
object in the manifest.json
:
{
"manifest_version": 2,
"name": "Acho, where are we?",
...
"background": {
"scripts": [ "background.js", "acho.js" ], // 👈
"persistent": false
}
}
And just like that, we can now access the Acho
class from background.js
:
chrome.commands.onCommand.addListener(async (command) => {
switch (command) {
case 'duplicate-tab':
await duplicateTab();
break;
case 'bark':
await barkTitle();
break;
default:
console.log(`Command ${command} not found`);
}
});
/**
* Gets the current active tab URL and opens a new tab with the same URL.
*/
const duplicateTab = async () => {
const acho = new Acho(); // 👈
const tab = await acho.getActiveTab();
chrome.tabs.create({ url: tab.url, active: false });
}
/**
* Sends message to the content script with the currently active tab title.
*/
const barkTitle = async () => {
const acho = new Acho(); // 👈
const tab = await acho.getActiveTab();
chrome.tabs.sendMessage(tab.id, {
tabTitle: tab.title
});
}
I had to make the functions
async
so I could await the promise fromacho.getActiveTab()
. You can useacho.getActiveTab().then((tab) => { })
instead if you like.
That's it! Now all our components are reusing the logic from acho.js
.
Conclusion
We managed to remove our duplicated code and apply reusability by creating a separate file containing the shared logic and using different strategies to make that file available in every component.
Now our extension's code is easier to read and maintain 👌
The repo
You can find all my Chrome Extensions examples in this repo:
pawap90 / acho-where-are-we
Acho (a cute pup) tells you the title of the current page on your browser. A sample chrome extension.
Let me know what you think! 💬
Are you working on or have you ever built a Chrome extension?
Do you know any other strategies for code reusability in Chrome extensions?
Top comments (13)
But you can't use this technique if you have the popup stuff in an inner folder, which is what I do to keep the main folder clean. So what would be a clean way to do this in my case?
I think you should be able to make it work using relative paths to reference the shared logic including the folder. Did you try something like this?:
In which file?
In your popup file. In my example it would be in
popup.html
Not really, because I would have liked to put the file in the main folder, and in the src you can't put files which are in a parent directory, I tried suspecting it wouldn't work, and in fact I was right. Just like a normal web app you can only access files in the same directory as the index.html
So, I was curious and decided to make some tests in another branch. I changed the structure of the project to organize files in different directories, like this:
Then updated every reference to each file to make it point to the correct relative path. It works!
You can see how I did it here on this branch.
If you want to see how I set up the relative paths, I did it all here in a single commit for clarity
Thanks a lot! I really didn't expect you to do all of this! I'm happy that I was wrong because this is gonna make the dev process for extensions easier. In a perfect world we could also be able to use ES6 modules and the import syntax for more clarity while using classes from other files! I'll try to come up with some way to use ES6 modules (if it's possible) and keep you informed!
Thanks again for the time you took to make this awesome example!
That's a good idea. It's supposed to be supported! But I don't know if you can get away with it without declaring each file in the manifest.json, even if it's imported as a module. Let me know if you figure it out!
Thank you for the great series, really enjoy seeing the progress from a dead simple extension for beginners to a more structural yet still simple one 😃
I also made a post to share my experience for building a cross-browser extension with Svelte: dev.to/khangnd/build-a-browser-ext...
An unrelated question, which tool did you use to generate those awesome header images for the posts?
I'm glad you like the series! I'm working on a new post for it. Hopefully it'll be done for tomorrow.
Love Svelte! I'll check your post :)
I use Figma for the banner!
Would anything have to be done differently for manifest v3?
Yes, because manifest v3 allows only one background script now, and it's now called serviceWorker. See the docs for more info!
Yes! I migrated this same project and explained every change I had to do here:
Chrome Extensions: Migrating to Manifest v3
Paula Santamaría ・ Mar 26 '21 ・ 7 min read