I’m back with another post about Chrome extensions! This time I wanted to explore how to store data locally using the chrome.storage
API.
In this post, we’re going to add yet another feature to our original extension (Acho, where are we?). This new feature will store the Title and URL of the page each time we call Acho to tell us where we are. We will then list all of the pages and allow the user to navigate to one of them or clear the list.
Here’s a quick demo:
So let’s get started!
1. Add the storage permission to the manifest.json
As usual, the first thing we need to update is our manifest.json
. This time we’re going to add the storage
permission:
{
"manifest_version": 2,
"name": "Acho, where are we?",
...
"permissions": [
"tabs",
"storage" // 👈
]
}
This will allow our extension to use the storage
API.
2. Create the Page Service
Since we already know how to reuse code in chrome extensions, we will create the data access logic in a separate class called PageService
. Here we will add the following methods:
getPages
: Will return the list of stored pages.savePage
: Will receive the page data and store it.clearPages
: Will remove all the pages from the storage.
About the storage API
The chrome.storage
API allows us to store objects using a key that we will later use to retrieve said objects. This API is a bit more robust than the localStorage
API, but it’s not as powerful as an actual database, so we will need to manage some things ourselves.
To save an object we will define a key-value pair and use the set
method. Here’s an example:
const key = 'myKey';
const value = { name: 'my value' };
chrome.storage.local.set({key: value}, () => {
console.log('Stored name: ' + value.name);
});
And to retrieve our value we will use the get
method and the key:
const key = 'myKey';
chrome.storage.local.get([key], (result) => {
console.log('Retrieved name: ' + result.myKey.name);
});
Finally, to clear the storage we have two options:
// Completely clear the storage. All items are removed.
chrome.storage.local.clear(() => {
console.log('Everything was removed');
});
// Remove items under a certain key
const key = 'myKey';
chrome.storage.local.remove([key], (result) => {
console.log('Removed items for the key: ' + key);
});
Another thing to have in mind when working with this API is error handling. When an error occurs using the get
or set
methods, the property chrome.runtime.lastError
will be set. So we need to check for that value after calling the get/set methods. A few examples:
const key = 'myKey';
const value = { name: 'my value' };
chrome.storage.local.set({key: value}, () => {
if (chrome.runtime.lastError)
console.log('Error setting');
console.log('Stored name: ' + value.name);
});
chrome.storage.local.get([key], (result) => {
if (chrome.runtime.lastError)
console.log('Error getting');
console.log('Retrieved name: ' + result.myKey.name);
});
Don’t worry, I promise the actual implementation will be better than a
console.log
.
And, before we move on to the real implementation, I wanted to show you something else. I like to work with async/await
instead of callbacks
. So I created a simple function to promisify the callbacks and still handle errors properly. Here it is:
const toPromise = (callback) => {
const promise = new Promise((resolve, reject) => {
try {
callback(resolve, reject);
}
catch (err) {
reject(err);
}
});
return promise;
}
// Usage example:
const saveData = () => {
const key = 'myKey';
const value = { name: 'my value' };
const promise = toPromise((resolve, reject) => {
chrome.storage.local.set({ [key]: value }, () => {
if (chrome.runtime.lastError)
reject(chrome.runtime.lastError);
resolve(value);
});
});
}
// Now we can await it:
await saveData();
You can replace
chrome.storage.local
withchrome.storage.sync
to sync the data automatically to any Chrome browser where the user is logged into (if they have the sync feature enabled). But keep in mind that there are limits, as specified in the official documentation.
chrome.storage.local
also has limits in the amount of data that can be stored, but that limit can be ignored if we include theunlimitedStorage
permission in themanifest.json
(check the docs).
Let’s move on to our actual implementation!
PageService class
As I said before, our PageService will have 3 methods to store, retrieve and remove our pages
. So here they are:
const PAGES_KEY = 'pages';
class PageService {
static getPages = () => {
return toPromise((resolve, reject) => {
chrome.storage.local.get([PAGES_KEY], (result) => {
if (chrome.runtime.lastError)
reject(chrome.runtime.lastError);
const researches = result.pages ?? [];
resolve(researches);
});
});
}
static savePage = async (title, url) => {
const pages = await this.getPages();
const updatedPages = [...pages, { title, url }];
return toPromise((resolve, reject) => {
chrome.storage.local.set({ [PAGES_KEY]: updatedPages }, () => {
if (chrome.runtime.lastError)
reject(chrome.runtime.lastError);
resolve(updatedPages);
});
});
}
static clearPages = () => {
return toPromise((resolve, reject) => {
chrome.storage.local.remove([PAGES_KEY], () => {
if (chrome.runtime.lastError)
reject(chrome.runtime.lastError);
resolve();
});
});
}
}
A few things to notice about this class:
- We are using the
toPromise
function we talked about earlier. - We are storing an array of
pages
, so every time we add a new page to the storage, we need to retrieve the entire array, add our new element at the end and replace the original array in storage. This is one of a few options I came up with to work with arrays and thechrome.storage
API since it doesn’t allow me to directly push a new element to the array.
3. Make our PageService available to our components
As we saw in the previous posts of this series, we need to make some changes to allow our new class to be used by our extension’s different components.
First, we will add it as a script to our popup.html
so we can later use it in popup.js
:
<!-- popup.html -->
<!DOCTYPE html>
<html lang="en">
<head>
...
</head>
<body>
...
<script src='popup.js'></script>
<script src='acho.js'></script>
<script src='page.service.js'></script> <!-- 👈 -->
</body>
</html>
This will allow us to save pages, retrieve them and clear them from the browser action.
And finally, we’ll add it as a background script
in our manifest.json
so we can also call the savePage
method from our background script when the user uses the shortcut:
{
"manifest_version": 2,
"name": "Acho, where are we?",
...
"background": {
"scripts": [
"background.js",
"acho.js",
"page.service.js" // 👈
],
"persistent": false
},
...
}
4. Update our popup.js
Now let’s update our popup.js to add the new features.
document.addEventListener('DOMContentLoaded', async () => {
const dialogBox = document.getElementById('dialog-box');
const acho = new Acho();
const tab = await acho.getActiveTab();
const bark = acho.getBarkedTitle(tab.title);
dialogBox.innerHTML = bark;
// Store page.
await PageService.savePage(tab.title, tab.url);
// Display history.
await displayPages();
// Clear history.
const clearHistoryBtn = document.getElementById('clear-history');
clearHistoryBtn.onclick = async () => {
await PageService.clearPages();
await displayPages();
};
});
const displayPages = async () => {
const visitedPages = await PageService.getPages();
const pageList = document.getElementById('page-list');
pageList.innerHTML = '';
visitedPages.forEach(page => {
const pageItem = document.createElement('li');
pageList.appendChild(pageItem);
const pageLink = document.createElement('a');
pageLink.title = page.title;
pageLink.innerHTML = page.title;
pageLink.href = page.url;
pageLink.onclick = (ev) => {
ev.preventDefault();
chrome.tabs.create({ url: ev.srcElement.href, active: false });
};
pageItem.appendChild(pageLink);
});
}
So in the previous code, we are using our three methods from PageService
to add the current page to the storage, list the pages on the screen and allow the user to navigate them, and clear the list.
We use the displayPages
method to display the pages: To do that we retrieve the list of pages and generate a <li>
element and an <a>
element for each page. It’s important to notice that we need to override the onclick
event on our <a>
element because if we leave the default functionality, the extension will try to load the page inside our popup, which it’s not what we want and it will cause an error. Instead, we create a new tab and navigate to the link using chrome.tabs.create
.
That’s all we need to do to add the new feature to our popup.
5. Saving the page from the background script
Now let’s make sure the pages are also stored when we use the command shortcut. To achieve that all we need to do is call the savePage
method when the user executes the command:
//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`);
}
});
const barkTitle = async () => {
const acho = new Acho();
const tab = await acho.getActiveTab();
chrome.tabs.sendMessage(tab.id, {
tabTitle: tab.title
});
await PageService.savePage(tab.title, tab.url); // 👈
}
That’s it!
The repo
You can find this and all of the previous examples of this series in my repo:
Let me know what you think! 💬
Are you working on or have you ever built a Chrome extension? How do you manage data storage?