Developer Notes

Build Chrome Extension with Web Components

July 28, 2021

Let’s build a Chrome extension with Web Components. That is what I said last night, and to be honest I never had any idea how to do it. I know that Google Chrome has a way to run complex code inside a isolated sandbox environment, but that is it.

So as always let’s set some goals and try to make it work.

  • Use WebComponents - going with Lit v2 just because I don’t want to have to write from scratch a Web Components framework (yet).
  • Don’t use any other frameworks or packages
  • Keep it super simple and easy to understand
  • Build it with a idea that could be used for blueprint for a new project

So I decide to go with New Tab extension and will go with the most basic and overly developed type of application, “A todo application”. But let start with one big disclaimer - this is super minimal and basic form of “Todo/Task” application, made for a demo purpose - so lot of corner cases and features will be skipped and left for some other time.

Create Chrome Manifest

One important thing is the manifest.json file - in other terms this is the package.json file of Chrome Extensions. Everything that the browser must know about the extension is defined there.

public/manifest.json
1{
2 "manifest_version": 2,
3 "name": "Experimental New Tab Extension",
4 "version": "0.0.0",
5 "description": "Provide playground code",
6 "icons": {
7 "16": "static/icon16.png",
8 "48": "static/icon48.png",
9 "128": "static/icon128.png"
10 },
11 "chrome_url_overrides": {
12 "newtab": "index.html"
13 },
14 "permissions": [
15 "storage"
16 ]
17}

The storage permission is something that we gonna need later but I’m adding it now so we don’t have to scroll back & front to find it later on. Oh and we will need to have some icons for our extension - this is something that I will leave to you to find.

The key thing here is chrome_url_overrides and the newtab key - this will let us overwrite the chrome://newtab and replace it with our index.html whatever it does - it’s up to you. Right now it does nothing and maybe is time to change it.

Index page

public/index.html
1<!doctype html>
2<html>
3
4<head lang='en'>
5 <meta charset='utf-8'>
6 <meta name='viewport' content='width=device-width'>
7 <title>New Tab</title>
8</head>
9
10<body>
11 <app-component></app-component>
12 <script type="module" src='bundle.js'></script>
13</body>
14
15</html>

And here again we are going too fast and we are not going to see anything cause we need to do two more things, load our extension and write our first component.

To load the extension Google Chrome have awesome documentation on the topic here. So I won’t cover it today.

Root component

Let’s create a simple proof of concept component. And test our new extension.

src/main.ts
1import { LitElement, html } from "lit";
2import { customElement } from "lit/decorators.js";
3
4@customElement("app-component")
5export default class AppComponent extends LitElement {
6 render() {
7 return html`<h1>Howdy!</h1>`;
8 }
9}
10
11declare global {
12 interface HTMLElementTagNameMap {
13 "app-component": AppComponent;
14 }
15}

If we are sure that our extension is loaded into Chrome, opening new tab will greet us with a nice message of Hawdy.

Everything is working and there is nothing more that we could do. The End 👋.

Oh you are still here, huh? I want a Todo component that could persist the “todos” in a local storage and show them in a list, let me edit, delete and add new ones. And because I already done this I will start from top to bottom and leave the representation almost for the last step.

I wanted to explore how I could have a root component that could have one goal to keep the data and sync it - something common for a lot of components out there.

Re-Inventing the wheel - Redux

For my demo needs I don’t need to have everything that redux has to offer. I need something minimal so I gonna write a cheap version of it.

src/store.ts
1type Reducers<T> = (data: T[], action: { action: string, data: any; }) => T[];
2
3export default class Store<T> {
4 private key: string;
5 private data: T[] = [];
6 private onUpdateFinish = (_data: any) => { };
7
8 private reducers: Reducers<T> = (data: T[], _action: { action: string, data: any }) => { return data }
9
10 constructor(key: string]) {
11 this.data = [];
12 this.key = key;
13 }
14
15 public dispatch(action: string, data: any) {
16 this.data = this.reducers(this.data, { action, data });
17 this.sync();
18 }
19
20 public onUpdate(callback: (data: T[]) => void) {
21 this.onUpdateFinish = callback;
22
23 // eslint-disable-next-line no-undef
24 chrome.storage.sync.get([this.key], (result: Record<string, any>) => {
25 this.data = result[this.key] || [];
26 this.sync();
27 });
28 }
29
30 public reduce(method: Reducers<T>) {
31 this.reducers = method;
32 }
33
34 sync() {
35 // eslint-disable-next-line no-undef
36 chrome.storage.sync.set({ [this.key]: this.data });
37 this.onUpdateFinish(this.data);
38 }
39}

chrome.storage is the one thing that comes with Chrome Extension API, it’s a wrapper around local storage - but also let us sync between Browsers that have the same account - useful for our feature self.

We gonna create this three basic methods that gonna use inside our wrapper component that we don’t have at the moment but i’ll be adding it later on.

reduce is the method that will be used to change the reducer of the store. Or simple term - let us transform the data based on some actions. You know Redix way.

onUpdate is our trigger it will return the data after we transform it and let us know when we need to do something else like re-render.

dispatch that is easy, trigger some data change on the store.

The class will also hide the chrome.storage calls and will only return the data to us. Everything else is just a nice wrapper and TypeScript magic.

So let’s define our data model and the reducer.

src/reducer.ts
1export const TASK_INSERT_EVENT = "INSERT";
2export const TASK_DROP_EVENT = "DROP";
3export const TASK_UPDATE_EVENT = "UPDATE";
4
5export type Task = {
6 id: string;
7 title: string;
8 done: boolean;
9};
10
11export const reducers = (store: Task[], request: Record<string, any>) => {
12 switch (request.action) {
13 case TASK_INSERT_EVENT:
14 // Add new task on top of the array
15 store = [request.data, ...store];
16 break;
17 case TASK_UPDATE_EVENT:
18 store = store.map((task: Task) => {
19 if (task.id === request.data.id) {
20 return { ...task, ...request.data };
21 }
22 return task;
23 });
24 break;
25 case TASK_DROP_EVENT:
26 store = store.filter((task: Task) => task.id !== request.data.id);
27 break;
28 default:
29 break;
30 }
31
32 return store;
33
34}

As additional or let’s say a safety net we gonna provide a basic polyfill for the chrome.storage so we could even try to test our code outside of the browser.

src/chrome-extension.polyfill.ts
1// @ts-ignore
2if (chrome?.storage === undefined) {
3
4 const methods = {
5 // @ts-ignore
6 get: (items: string[], callback: (result: Record<string, any>) => void) => {
7 // get from local storage
8 let result: Record<string, any> = {};
9 for (const key in items) {
10 result[items[key]] = JSON.parse(window.localStorage.getItem(items[key]) + '');
11 }
12 callback(result);
13 },
14 // @ts-ignore
15 set(items: { [key: string]: any; }) {
16 // set to local storage
17 for (const key in items) {
18 window.localStorage.setItem(key, JSON.stringify(items[key]));
19 }
20 }
21 }
22
23 // @ts-ignore
24 chrome = {
25 storage: {
26 sync: methods,
27 local: methods,
28 }
29 };
30}

Now this will work for our development goals, but won’t let us sync between browsers - and that’s ok for the moment, we don’t have a extension that could benefit from that anyway.

Wrapper

The component that will be used to wrap the store around the task component and handle all the data changes and re-renders. Starting be reuse the main.ts file.

src/main.ts
1import { LitElement, html } from "lit";
2import { customElement } from "lit/decorators.js";
3
4import { reducers, Task } from './reducer';
5
6import Store from "./store";
7
8@customElement("app-component")
9export default class AppComponent extends LitElement {
10
11 private store!: Store<Task>;
12
13 private tasks: Task[] = [];
14
15 connectedCallback() {
16 super.connectedCallback();
17
18 this.store = new Store<Task>('tasks', []);
19 this.store.reduce(reducers)
20
21 this.store.onUpdate((data: Task[]) => {
22 this.tasks = data;
23 this.requestUpdate();
24 })
25 }
26
27 render() {
28 return html`
29 <h1>Todo App</h1>
30 `;
31 }
32}
33
34declare global {
35 interface HTMLElementTagNameMap {
36 "app-component": AppComponent;
37 }
38}

Ok so we have something but, how it works ? this.store will be our data holder and instance of Store class that we already done above. We gonna attach a function to the onUpdate method that will be called when the data changes and will trigger requestUpdate method to refresh the view.

the.store.reduce will get the reducers that we defined into src/reducer.ts and will be used to transform the data.

Everything seems to work but we still don’t have a todo app at this point. Nothing is creating task and nothing is updating them. So our next goal will be to make the action component that we started all of this.

Task Component

So for our task component we gonna need to have a property that will accept Array of Tasks from outside and bubble events to our wrapper when we want to change some of them. This way our top component will deal with all data related issues and our simple task component will only solve the view problem.

src/task.component.ts
1import { LitElement, html, svg, unsafeCSS } from "lit";
2import { customElement, property } from "lit/decorators.js";
3import { classMap } from "lit/directives/class-map.js";
4import { repeat } from 'lit/directives/repeat.js';
5
6import styles from './styles.css';
7
8import { TASK_DROP_EVENT, TASK_INSERT_EVENT, Task, TASK_UPDATE_EVENT } from './reducer';
9
10export const TASKS_EVENT = 'DispatchChanges';
11
12const closeIcon = svg`
13<svg
14 class="icon"
15 fill="none"
16 stroke-linecap="round"
17 stroke-linejoin="round"
18 stroke-width="2"
19 viewBox="0 0 24 24"
20 stroke="currentColor"
21>
22 <path d="M6 18L18 6M6 6l12 12"></path>
23</svg>
24`;
25
26@customElement("app-tasks")
27export default class AppTasks extends LitElement {
28
29 static styles = unsafeCSS(styles);
30
31 @property()
32 tasks: Task[] = []
33
34 private onEnter(e: KeyboardEvent) {
35 if (e.key === 'Enter') {
36 this.createTask();
37 }
38 }
39
40 private statusChange(id: string, checked: boolean) {
41 this.dispatchEvent(new CustomEvent(TASKS_EVENT, {
42 detail: {
43 action: TASK_UPDATE_EVENT,
44 data: { id, done: checked }
45 }
46 }))
47 }
48
49 private createTask() {
50 const Entry = this.renderRoot.querySelector('#entry');
51
52 // @ts-ignore
53 if (Entry && Entry.value === '') {
54 return;
55 }
56
57 this.dispatchEvent(new CustomEvent(TASKS_EVENT, {
58 detail: {
59 action: TASK_INSERT_EVENT,
60 data: {
61 id: `#${Math.random().toString(36).substr(2, 9)}`,
62 // @ts-ignore
63 title: Entry.value,
64 done: false,
65 }
66 }
67 }));
68
69 // @ts-ignore
70 Entry.value = null;
71 }
72
73 private editTask(id: string, title: string) {
74 this.dispatchEvent(new CustomEvent(TASKS_EVENT, {
75 detail: {
76 action: TASK_UPDATE_EVENT,
77 data: { id, title }
78 }
79 }))
80 }
81
82 private drop(id: string) {
83 this.dispatchEvent(new CustomEvent(TASKS_EVENT, {
84 detail: {
85 action: TASK_DROP_EVENT,
86 data: { id }
87 }
88 }))
89 }
90
91 render() {
92 return html`
93 <div class="container">
94 <div class="card">
95 <div class="title">Tasks</div>
96 <div class="form">
97 <input id="entry" type="text" @keyup="${this.onEnter}" placeholder="what is your plan for today" class="input" />
98 </div>
99 <ul class="tasks">
100 ${this.tasks &&
101 repeat(this.tasks, (task) => task.id, (task) => html`
102 <li id="${task.id}" class="task">
103 <div class="task-content ${classMap({ "task-done": task.done })}">
104 <input @change=${(e: any)=> this.statusChange(task.id, e.target.checked)}
105 .checked="${task.done}"
106 type="checkbox"
107 />
108 <input type="text" @blur=${(e: any)=> this.editTask(task.id, e.target.value)}
109 .value="${task.title}"
110 class="task-edit"
111 />
112 </div>
113 <button @click=${()=> this.drop(task.id)}>
114 ${closeIcon}
115 </button>
116 </li>`
117 )
118 }
119 </ul>
120 </div>
121 </div>
122 `;
123 }
124}
125
126declare global {
127 interface HTMLElementTagNameMap {
128 "app-tasks": AppTasks;
129 }
130}

Our component will dispatch events for every action that we need to do and our wrapper component must handle it. Going with custom event is just so I could show how we could do that and don’t mess with some of already defined events.

1// Dispatch Event outside this component
2this.dispatchEvent(new CustomEvent(TASKS_EVENT, {
3 // Use `detail` to pass data
4 detail: {
5 // Action for our store
6 action: TASK_INSERT_EVENT,
7 data: {
8 // Generate a random id for every new task
9 id: GenerateId(),
10 title: Entry.value,
11 done: false,
12 }
13 }
14}));

This event will be handle by app-component and passed inside the store, there it will be added to the store internal copy of the data and in the same time sync with the Chrome internal storage.

Modify the App Component

Let’s integrate the two components into our app.

src/main.ts
1// Add Task Component
2import { TASKS_EVENT } from './task.component';
3
4@customElement("app-component")
5export default class AppComponent extends LitElement {
6
7 // ...
8
9 // Add additional methods to handle events
10 async firstUpdated() {
11 const AppTasks = this.renderRoot.querySelector('app-tasks');
12 AppTasks?.addEventListener(TASKS_EVENT, this.handleChanges.bind(this))
13 }
14
15 private handleChanges(event: any) {
16 const { action, data } = event.detail;
17 this.store.dispatch(action, data)
18 }
19
20 // Update the render method
21 render() {
22 return html`
23 <app-tasks .tasks=${this.tasks}></app-tasks>
24 `;
25 }
26}

Now if we render the app we should see our new component working as best friends. Pretty much if this run from the first time - that is a miracle.

The result of all of this is that we have a HOC that will handle all the logic from where the data comes and how the data is modify if needed and let the Task component handle how the data is presented. We gonna have a super basic storage but in the same time a blueprint on how to implement something much more complex.

The how project could be find here in Github Repository - feel free to modify and use it as a starting point.


My name is Bozhidar Dryanovski and I'm a Software Engineer at Clarity
You should follow me on Twitter or Check my work at Github

© 2021, Build version 2.0.5