A while ago, I set out to build my first Chrome extension. Having recently gotten into the world of web development and getting my hands dirty by building a React project, I felt I had the tools necessary to take on this new challenge.
While I wasn't completely wrong to think this, it wouldn't be the whole truth to say I didn't have to rethink the way I approached designing my project.
I realized this pretty early on into development. You see, when developing any sort of app, as our projects get bigger, we'll tend to inevitably break it up into separate classes, functions, and eventually scripts. Nothing forces us to do this, but unless you want to end up with a remake of 1958's The Blob, it would be smart to do so.
In my case, the extension needed to do the following: Whenever a user makes any changes in the text field, its content needs to be parsed and displayed accordingly as entries in the table to its right. Then, as soon as the "Log entries!" button is clicked, these parsed entries are to be used to invoke changes on the web page.
To that end, I broke up my code's functionality into the following scripts:
popup.js: Contains the behavior of the pop-up and its components. For example, what happens when text is inserted into the text field or when a button is pressed.
parser.js: Contains functionality to parse text following certain rules and returns the parsed result in a specific format.
crawler.js: Contains functionality that utilizes data to crawl a web page in search of specific elements and make certain modifications.
There's an obvious interdependence here.
crawler.js needs data presented to it in a certain format in order to be able to successfully crawl and modify the web page. This data is provided by
parser.js, which in turn receives its input from the pop-up's text field, managed by
If, like me, you were spoiled by the simplicity of using ES6 modules in React, your first notion might be to say, "Well, no problem. I'll just export the relevant functions in
crawler.js and import them in
Fun fact: On the road to ES6 integration, I did eventually make the leap to Parcel (which I can highly recommend to anyone getting started using bundlers after a brief incident with Webpack left me questioning my life choices). The use of a bundler was motivated partly by the need to easily reference external libraries.
Since Parcel comes preconfigured with Babel, I was then also able to use ES6 features such as import/export, which did enable that more familiar way of working with different files. Nevertheless, that isn't the way communication is intended in Chrome extensions, as we'll see shortly.
A Chrome extension will typically consist of various cohesive parts or components, each with a different set of responsibilities. In order for all these components to work together, they communicate via messaging.
In our example,
crawler.js needs to interact with the web page and is thus declared as a so-called content script. Content scripts are those that need to be able to perform actions on web pages, such as DOM manipulations.
On the other hand,
parser.js doesn't need this, but it still needs to receive data from
popup.js and send it back. Thus, we'll declare it as a background script.
A background script, as the name implies, runs in the background. Its roles include listening and reacting to browser events (e.g. closing a tab, perform actions when the extension is (un-)installed), as well as sending and receiving messages.
The declaration of content and background scripts is done in the extension's manifest.json.
Now we know enough to finally get to the nitty-gritty.
popup.js, being the communication initiator here, will need to send out two messages. One whenever the text field is changed and another when the button is clicked. Depending on who the recipient is, it does this using one of two ways. If the recipient is a content script, chrome.tabs.sendMessage() is used. Otherwise, it's chrome.runtime.sendMessage().
Let's start with the second case. Here's an example of what that might look like in
Here, we're assuming this piece of code gets executed in
popup.js whenever a change happens in the text field. As you can see, we've passed
runtime.sendMessage() two parameters: a required object and an optional callback. What the object should contain is entirely up to you, but in my case, I've included two properties. The first,
msg, contains a string identifier that is checked by the receiving end to determine how to handle the request. The second property,
data, simply contains the new content of the text field following the change.
The callback function passed as the second argument to
runtime.sendMessage() must have a single parameter. This function handles the response sent by the recipient of this message.
Note: The intended recipient of this message is
parser.js. However, as we'll see shortly, any background script listening for
onMessage events will receive it. This is another reason why it's useful to have a property such as
msg in the passed object. It acts as an identifier so that recipients can determine whether a message is intended for them.
As mentioned before, when the recipient is a content script, we use
tabs.sendMessage(). Here's what that could look like in
You'll notice this time around that we don't send the message straight away. With
tabs.sendMessage(), we need to know which tab to send the message to. To do that, we first call
tabs.query(), which retrieves all tabs that match the properties specified in the first argument. Since my extension pop-up only activates when I'm on a specific URL, I can simply get the active tab in the current window and be sure that it's the one I need.
💡 Hint: To retrieve all tabs, pass an empty object as the first argument.
The retrieved tabs are passed to the callback specified in the second argument. This is where we send our actual message, which should now look familiar. The only difference is that with
tabs.sendMessage(), we need to pass the ID of the relevant tab. The rest follows the same structure as before.
On the receiving end, it's quite straightforward. There, we use chrome.runtime.onMessage.addListener(). Essentially, what it does is add a listener to the
onMessage event, which gets fired whenever a message is sent using either of the
sendMessage() variations we've seen.
This method takes a callback function as its single argument, which gets called when the event is fired (i.e. a message is received). That callback, in turn, takes three arguments: the content of the message, its sender, and a function that is called if a response is to be sent back. This function takes a single argument of type object. That was verbose. Let's look at some code.
So far, so good. But what if we had not just one content script, as was the case here with
crawler.js, but two that wanted to communicate? To continue with our running example, say we broke up
crawler.js into two separate content scripts:
filler.js. As the names imply, the former searches for certain elements on the webpage, while the latter fills those elements with content.
finder.js wants to be able to send the elements it finds to
filler.js. "Well, no big deal," I hear you saying. We'll just use
onMessage.addListener() like before. As much as I hate to be the bearer of bad news, not quite. As it turns out, content scripts can't communicate directly. This actually had me scratching my head for a while. Fortunately, the solution is simple.
Fun fact: In case you're wondering why I even ran into this problem since I only have one content script, at some point, I unnecessarily had
popup.js registered as a content script too and consequently its messages weren't reaching
crawler.js using the direct path of communication. I've since removed this error, but the lesson learned remains.
All we need to do is have a background script act as a middleman in this exchange. This then looks as follows. Don't be intimidated by the size. I've essentially jammed code from three scripts into one gist for display purposes.
Essentially, there's nothing new here except a slight logistical change. Instead of direct point-to-point communication, we're using a background script to relay messages between the communicating parties (i.e. the content scripts).
One thing to note here is that we're returning
true in the background script's
addListener(). Without going too much into detail, this keeps the communication channel at the background script open to allow for
filler.js's response to make it through to
finder.js. For more on that, take a look at the description provided in Chrome's documentation for the
sendResponse parameter of
Thanks for sticking around! Chrome extensions can be quite idiosyncratic and there's often not much to go on on the internet when you're stuck. So I hope you found some of this useful.
I'd be happy to hear your thoughts and answer any questions you may have.
- Project repo
- Chrome Extensions: great starting point with links to further resources
- Overview of ES6 features
- The Blob anti-pattern
Hungry for more? You might also like:
- From Static to Dynamic CSS Values
- The 10 Browser Extensions I Can’t Live Without
- Detecting Document Similarity With Doc2vec
Originally published on Medium