Velo by Wix: Message channel to iFrame
In this post, we consider building a scalable message channel for large numbers of events between Velo and iFrame using the Event and Listener model.
The Wix allows embedding the HtmlComponent (iFrame) to the page. It's one of the powerful tools for customization of your site when you need a very specific UI. The Velo provides the API for interactions with HtmlComponent, which are sent and listen to messages. Inside iFrame, we can use the native browser API represent in the global object window
that provides the same functionality of sending and listening events.
Velo API to work with post messages
// Sends data to iFrame
$w('#html1').postMessage({ data: 123 });
// Listen messages from iFrame
$w('#html1').onMessage((event) => {
console.log(event.data);
});
Browser native API to work with post messages
// Sends data to Velo
window.parent.postMessage({ data: 123 }, '*');
// Listen messages from Velo
window.addEventListener('message', (event) => {
console.log(event.data);
});
Using these simple APIs we can share data/events between our pages. For most cases, when we have a few events that enough. But when we have the count of events type that starts to grow will be better to use the good abstraction.
I like the Event and Listener model. This model builds on two methods .emit()
for fire the events and .on()
for listening to the events.
Example of Event Emitter:
// Sends event with some payload
channel.emit('@event/name', { data: 1 });
// Listen the event
channel.on('@event/name', ({ data }) => {
console.log(data);
});
In this post, we consider building a scalable message channel for large numbers of events between Velo and iFrame using the Event and Listener model.
Terminology
To the avoiding of a mess, I'm going to use a convention of naming pages:
- The Main page - a page where we use the Velo API.
- The iFrame page - a page inside
HtmlComponent
where we use thewindow
object.
Events:
- All events which will fire from the iFrame will have the prefix
@iframe/*
. - And all events which will fire from the Main page will have the prefix
@main/*
.
API
Let's see an example of how to look like the communication between the Main page and iFrame.
Steps:
- When iFrame is load then it sends to the Main page the event ready.
- Main page gets the ready event and starts to fetch collection items.
- When the collection items ready, Main page sends items to iFrame
- iFrame gets the items
Example of communication between pages
/************* iFrame Page **************/
// Send initial event to Main page
channel.emit('@iframe/ready');
// Get the collection items from the main page
channel.on('@main/goods', (items) => {
// ...
});
/**************************************
iFrame End.
Main Page:
*************************************/
// Get init event from iFrame
channel.on('@iframe/ready', () => {
// Retrieve the items from a collection.
wixData.query('goods').find().then((data) => {
// Send the items to iFrame
channel.emit('@main/goods', data.items);
});
});
/************* Main End **************/
How you can see above, the Main page listens to events from iFrame and vice versa the iFrame listens to events from the Main page.
All event objects will build with two properties:
type: string
(required) - the type of eventpayload?: any
(optional) - any data which we want to send
Example of event object
{
"type": "@event/name",
"payload": { "xyz": 123 }
}
Both pages will have the same interface of the channel. The method channel.emit()
accepts "type"
as the first argument and payload
as the second one. The method channel.on()
accepts "type"
as the first argument and callback function with payload
as the second one.
Implementation of channel
I will use a very simple example with the counter. On the Main page, we have a hidden Text component. On iFrame, we have two buttons for increment and decrement.
By click on buttons, we send the events to the Main page and showing the count result in the Text component.
Add iFrame
Here is a snippet of the iFrame page. Below we will write the code inside <script>
tags
iFrame Page
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Counter</title>
</head>
<body>
<!-- Simple HTML page with two buttons -->
<button type="button" id="dec">
- Decrement
</button>
<button type="button" id="inc">
+ Increment
</button>
<script>
// JavaScript goes here
</script>
</body>
</html>
The channel will be created using the function createChannel()
. When the iFrame page loaded, then we send the event "@iframe/ready"
.
iFrame Page
<script>
const createChannel = () => {
return {
emit(type, payload) {
window.parent.postMessage({ type, payload }, '*');
}
};
};
const channel = createChannel();
// Emit the initial event
// iFrame is ready
channel.emit('@iframe/ready');
</script>
Look like simple. We put on the type
and payload
into the object and sending it to the Main page. In the next step, we catch the sent event with Velo API.
Catch the event
Let's move to the Main page, and implement the channel for listening to.
The channel on Main page will create in the same way as we do it on iFrame. But we can have a few iFrames on the page or we can have a few pages on the site where we use the iFrames. That means the better place for the channel it's a public file.
Create a channel.js
in public folder:
Method channel.on()
accepts the event type and a callback function. We need to save callbacks in the associate group and run the callback functions whenever the subscribe event is coming.
Saving the callback functions in an array allows us to subscribe to a few callbacks by one event type.
Example of subscriptions container:
const events = {
"@event/one": [ () => {} ],
"@event/two": [ () => {} ],
};
So, above we speculated that we can have a few iFames. For this case, I propose passing the iFrame ID as an argument to the creator function.
public/channel.js
/**
* @param {string} id
*/
export const createChannel = (id) => {
// Container to hold the events subscription
const events = {};
return {
on(type, cb) {
// Check, is already exist a list for this type of event
if (!Array.isArray(events[type])) {
// Create an empty subscription list for a new event type
events[type] = [];
}
// Add the callback to subscription list
events[type].push(cb);
},
};
};
Greate, we save the callback to the array, and in the next step, we add the handler for post message.
On the Main page, we must wait until the page is ready for interaction. I guess you know this feature of the Velo, it's a method $w.onReady()
.
Example of a dynamic event handler
// Wait when all the page elements have finished loading.
$w.onReady(() => {
// The page is ready for setting event handler
$w('#html1').onMessage((event) => {
// Handling events
});
});
Here I see two ways. The first way we create the channel inside $w.onReady()
or use $w.onReady()
inside the channel creator function. In my point of view, the second way is better when we use $w.onReady()
inside the creator function.
public/channel.js
/**
* @param {string} id
*/
export const createChannel = (id) => {
const events = {};
// @ts-ignore
$w.onReady(() => {
$w(id).onMessage((ev) => {
const data = ev.data || {};
const subs = events[data.type];
// Check, is exist subscriptions for this event type
if (Array.isArray(subs)) {
// Run all callback functions with payload
subs.forEach((cb) => {
cb(data.payload);
});
}
});
});
return {
on(type, cb) {
if (!Array.isArray(events[type])) {
events[type] = [];
}
events[type].push(cb);
},
};
};
Let's unpack this. We have the events
container that holds the callbacks. We wait when the page is ready and then start listening to onMessage()
by iFrame ID. When the post message is received we get data
from the event
, where we have our interface with type
and payload
. Then we check, is there existing subscriptions for this event, and if it is, we pass payload
to each callback.
Let's take a look at how we can create a channel. The iFrame sends the event "@iframe/ready"
we can start to listen to it.
Home Page
import { createChannel } from 'public/channel';
// Initialization of channel
// Pass the ID of HtmlComponent
const channel = createChannel('#html1');
// Listen to the init event from iFrame
channel.on('@iframe/ready', () => {
// Shows the Text element
$w('#text1').show();
});
So, by now, we have a channel between pages. The last one in this example we add the event "@iframe/count"
to the Main page.
Just add two event handlers to iFrame for increment and decrement counter.
iFrame Page
<script>
const createChannel = () => {
return {
emit(type, payload) {
window.parent.postMessage({ type, payload }, '*');
}
};
};
const channel = createChannel();
// Select the buttons by ID
const inc = document.querySelector('#inc');
const dec = document.querySelector('#dec');
// Initial state
let count = 0;
inc.addEventListener('click', () => {
// Increment the count
channel.emit('@iframe/count', ++count);
});
dec.addEventListener('click', () => {
// Decrement the count
channel.emit('@iframe/count', --count);
});
channel.emit('@iframe/ready');
</script>
Listen to increment and decrement events
Home Page
import { createChannel } from 'public/channel';
const channel = createChannel('#html1');
channel.on('@iframe/ready', () => {
$w('#text1').show();
});
// Listens to the event and update counter
channel.on('@iframe/count', (count) => {
$w('#text1').text = String(count);
});
Source Code
We implemented sending events from iFrame with channel.emit()
and listening events in the Velo with channel.on()
.
Below you can see the full implementation of the channel for both pages.
Code Snippets
iFrame Page Source
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>iFrame</title>
</head>
<body>
<button type="button" id="dec">
- Decrement
</button>
<button type="button" id="inc">
+ Increment
</button>
<script>
const createChannel = () => {
const events = {};
window.addEventListener('message', (ev) => {
const data = ev.data || {};
const subs = events[data.type];
if (Array.isArray(subs)) {
subs.forEach((cb) => {
cb(data.payload)
});
}
});
return {
on(type, cb) {
if (!Array.isArray(events[type])) {
events[type] = [];
}
events[type].push(cb);
},
emit(type, payload) {
window.parent.postMessage({ type, payload }, '*');
}
};
};
/****************************************
Use:
*****************************************/
const channel = createChannel();
const inc = document.querySelector('#inc');
const dec = document.querySelector('#dec');
let count = 0;
inc.addEventListener('click', () => {
channel.emit('@iframe/count', ++count);
});
dec.addEventListener('click', () => {
channel.emit('@iframe/count', --count);
});
channel.emit('@iframe/ready');
</script>
</body>
</html>
public/channel.js
/**
* @param {string} id
*/
export const createChannel = (id) => {
const events = {};
// @ts-ignore
$w.onReady(() => {
$w(id).onMessage((ev) => {
const data = ev.data || {};
const subs = events[data.type];
if (Array.isArray(subs)) {
subs.forEach((cb) => {
cb(data.payload);
});
}
});
});
return {
on(type, cb) {
if (!Array.isArray(events[type])) {
events[type] = [];
}
events[type].push(cb);
},
emit(type, payload) {
$w(id).postMessage({ type, payload });
},
};
};
Home Page (code)
import { createChannel } from 'public/channel';
const channel = createChannel('#html1');
channel.on('@iframe/ready', () => {
$w('#text1').show();
});
channel.on('@iframe/count', (count) => {
$w('#text1').text = String(count);
});
Improvements
It's a good idea to use the abstraction instead of directly API. The abstraction able to give to change the implementation of the channel without changing behave of using it. Let's consider a few examples of how we can improve this approach.
Sending message within one page.
For example, we have some events that we need to send to iFrame and doing something within the Main page. Here we can add support to the channel of listening to its own subscribers.
// Support of listening to and emit the events within one page
emit(type, payload) {
const subs = events[type];
// check the own subscribers
if (Array.isArray(subs)) {
subs.forEach((cb) => {
cb(payload);
});
}
$w(id).postMessage({ type, payload });
}
A few iFrames
Sending a message for a few iFrames. Here we can add a new method channel.emitAll()
that allows sending for a few pages.
emitAll(type, payload) {
$w(id1).postMessage({ type, payload });
$w(id2).postMessage({ type, payload });
},
Unsubscribe
For example, we want to unsubscribe from some listener. We may do it like this:
// Returns the function that unsubscribes of this listener
const off = channel.on('@some/event', () => {
// ...
});
channel.on('@unsubscribe', () => {
off();
});
Realisation:
on(type, cb) {
if (!Array.isArray(events[type])) {
events[type] = [];
}
events[type].push(cb);
// Returns the function of unsubscribing
return () => {
events[type] = events[type].filter((i) => i !== cb);
};
},
🎉 Thank you for reading. I hope this post will be useful for your projects! 🎉