Aller au contenu principal

Les MessagePorts dans Electron

Les MessagePorts sont une fonctionnalité web permettant l'échange de messages entre différents contextes. C'est un peu comme window.postMessage, mais sur différents canaux. L’objectif de ce document est de décrire comment Electron étend le modèle Channel Messaging et de donner quelques exemples de la façon dont vous pouvez utiliser les MessagePorts dans votre application.

Voici un petit exemple des MessagePorts et de leur fonctionnement:

renderer.js (Renderer Process)
Les MessagePorts sont créés par paires. Et on appelle canal une paire de MessagePort connectés .
const channel = new MessageChannel()

// La seule différence entre port1 et port2 est dans la façon dont vous les
// utilisez. Les messages envoyés à port1 seront reçus par port2 et vice-versa.
const port1 = channel.port1
const port2 = channel. ort2

// Il est possible d'envoyer un message sur le canal avant que l'autre extrémité
// n'ait enregistré un écouteur. Les messages seront dans ce cas mis en file d'attente
// jusqu'à ce qu'un écouteur soit enregistré.
port2.postMessage({ answer: 42 })

// Ici nous envoyons l'autre extrémité du canal, port1, au processus principal.
// Il est également possible d'envoyer des MessagePorts à d'autres frames, Web Workers, etc.
ipcRenderer.postMessage('port', null, [port1])
main.js (Main Process)
// Dans le processus principal, nous recevons le port.
ipcMain.on('port', (event) => {
// Lorsque nous recevons un MessagePort dans le processus principal, il devient un
// MessagePortMain.
const port = event.ports[0]

// MessagePortMain utilise une API des événements du style Node.js plutôt que web.
// Donc .on('message', ...) au lieu de .onmessage = ...
port.on('message', (event) => {
// data is { answer: 42 }
const data = event.data
})

// MessagePortMain met les messages en attente jusqu'à ce que la méthode .start() soit invoquée.
port.start()
})

La documentation de Channel Messaging API est un excellent moyen d'en apprendre plus sur le fonctionnement des MessagePorts.

Les MessagePorts dans le processus principal

Dans le moteur de rendu, la classe MessagePort se comporte exactement comme pour le Web. Le processus principal n'étant pas pas une page web, il n'y a pas l'intégration de Blink(moteur de rendu de chromium) — et donc pas de classe MessagePort ou MessageChannel. Afin de gérer les MessagePorts et et d'interagir avec eux dans le processus principal, Electron ajoute deux nouvelles classes : MessagePortMain et MessageChannelMain. Celles-ci se comportent comme les classes analogues dans le moteur de rendu.

Les objets de type MessagePort peuvent être créés soit dans le moteur de rendu soit dans le processus principal, et passés dans les deux sens en utilisant les méthodes ipcRenderer.postMessage et WebContents.postMessage. Notez bien que les méthodes IPC usuelles telles que send et invoke ne peuvent pas être utilisées pour transférer des MessagePort, seules la méthode postMessage peut transférer des MessagePort.

En transmettant des MessagePorts via le processus principal, vous pouvez connecter deux pages qui sans cela n'auraient pas été en mesure de communiquer (par ex à cause de restrictions en cas d'origine différente).

Extension : événement close

Electron ajoute une fonctionnalité à MessagePort non présente dans le cas du web afin de rendre les MessagePorts plus utiles. Il s'agit de l'événement close, qui est émis lorsque l'autre extrémité du canal est fermée. Les ports peuvent également être implicitement fermés par une purge du garbage-collector.

Dans le moteur de rendu, vous pouvez ajouter des écouteurs sur l'événement close soit par assignation avec port.onclose, soit en invoquant la méthode port.addEventListener('close', ...). Et dans le processus principal, vous pouvez le faire en écoutant l'évenement close à l'aide de port.on('close', ...).

Exemple de cas d'utilisation

Configuration d’un MessageChannel entre deux moteurs de rendu

Dans cet exemple, le processus principal met en place un MessageChannel, puis envoie chaque port à un moteur de rendu différent. Cela permet aux moteurs de rendu de s'envoyer des messages entre eux sans avoir besoin d'utiliser le processus principal comme intermédiaire.

main.js (Main Process)
const { BrowserWindow, app, MessageChannelMain } = require('electron')

app.whenReady().then(async () => {
// création des fenêtres.
const mainWindow = new BrowserWindow({
show: false,
webPreferences: {
contextIsolation: false,
preload: 'preloadMain.js'
}
})

const secondaryWindow = new BrowserWindow({
show: false,
webPreferences: {
contextIsolation: false,
preload: 'preloadSecondary.js'
}
})

// set up the channel.
const { port1, port2 } = new MessageChannelMain()

// une fois que les webContents sont prêts, envoie d'un port à chaque webContents avec postMessage.
mainWindow.once('ready-to-show', () => {
mainWindow.webContents.postMessage('port', null, [port1])
})

secondaryWindow.once('ready-to-show', () => {
secondaryWindow.webContents.postMessage('port', null, [port2])
})
})

Ensuite, dans vos scripts de préchargement, le port sera reçu via IPC et vous configurerez les écouteurs.

preloadMain.js and preloadSecondary.js (Preload scripts)
const { ipcRenderer } = require('electron')

ipcRenderer.on('port', e => {
// la réception du port le rend globalement disponible.
window.electronMessagePort = e.ports[0]

window.electronMessagePort.onmessage = messageEvent => {
// gestion du message
}
})

Dans cet exemple, messagePort est lié directement à l’objet window . Il est préférable cependant d’utiliser contextIsolation et de configurer des appels spécifiques au contextBridge pour chacun des messages escomptés, mais nous ne le faisons pas ici pour garder l'exemple suffisamment simple. Vous pouvez trouver un exemple d’isolation du contexte plus bas dans cette page à Communiquer directement entre le processus principal et le monde principal d’une page isolée du contexte

Cela signifie que window.electronMessagePort est disponible globalement et que vous pouvez appeler postMessage depuis n’importe où dans votre application pour envoyer un message à l’autre moteur de rendu.

renderer.js (Renderer Process)
//ailleurs dans votre code, vous exécuterez:
window.electronMessagePort.postMessage('ping')
//pour envoyer un message aux autres gestionnaires des moteurs de rendu

Processus d’arrière-plan (worker)

Dans cet exemple, un processus worker est implémenté par une fenêtre masquée dans votre application. Et on suppose que vous souhaiteriez que la page de l’application puisse communiquer directement avec le processus worker, et ceci, sans altération des performances liée au relais via le processus principal.

main.js (Main Process)
const { BrowserWindow, app, ipcMain, MessageChannelMain } = require('electron')

app.whenReady().then(async () => {
// Le processus worker est une BrowserWindow cachée, de sorte qu’ellel aura accès
// à un contexte Blink complet (y compris, par exemple, <canvas>, audio, fetch(), etc.)
const worker = new BrowserWindow({
show: false,
webPreferences: { nodeIntegration: true }
})
await worker.loadFile('worker.html')

// La fenêtre principale enverra du travail au processus worker et recevra des résultats
// sur un MessagePort.
const mainWindow = new BrowserWindow({
webPreferences: { nodeIntegration: true }
})
mainWindow.loadFile('app.html')

// Nous ne pouvons pas ici, utiliser ipcMain.handle(),car la réponse doit transférer un
// MessagePort.
// Ecoute des message provenant de la frame de plus haut niveau
mainWindow.webContents.mainFrame.on('request-worker-channel', (event) => {
// Création d'un nouveau canal ...
const { port1, port2 } = new MessageChannelMain()
// ... envoie d'une extrémité au worker ...
worker.webContents.postMessage('new-client', null, [port1])
// ... et de l'autre bout à la fenêtre principale.
event.senderFrame.postMessage('provide-worker-channel', null, [port2])
// maintenant la fenêtre principale et le worker peuvent communiquer
// sans avoir à passer par le processus principal!
})
})
worker.html
<script>
const { ipcRenderer } = require('electron')

const doWork = (input) => {
// Quelque chose d’intensif coté cpu.
return input * 2
}

// Nous pourrions obtenir plusieurs clients, par exemple s'il y a plusieurs fenêtres,
// ou si la fenêtre principale se recharge.
ipcRenderer.on('new-client', (event) => {
const [ port ] = event.ports
port. nmessage = (event) => {
// Les données de l'événement peuvent être n'importe quel objet sérialisable (et l'événement
// pourrait même y transporter d'autres MessagePorts !)
const result = doWork(event.data)
port.postMessage(result)
}
})
</script>
app.html
<script>
const { ipcRenderer } = require('electron')

// Nous demandons que le processus principal nous envoie un canal que nous pourons utiliser pour
// communiquer avec le worker.
ipcRenderer.send('request-worker-channel')

ipcRenderer.once('provide-worker-channel', (event) => {
// Une fois la réponse reçue, nous pouvons récupérer le port...
const [ port ] = event.ports
// ... inscrire un gestionnaire pour recevoir les résultats ...
port.onmessage = (event) => {
console.log('received result:', event.data)
}
// ... et commencez à lui envoyer du travail!
port.postMessage(21)
})
</script>

Flux de réponses

Les méthodes IPC intégrées dans Electron ne prennent en charge que deux modes : déclenche-et-oublie (par exemple, send), ou requête-réponse (par exemple invoke). En utilisant les MessageChannels, vous pouvez implémenter un "flux de réponse", où une seule requête provoque un flux de données en réponse.

renderer.js (Renderer Process)
const makeStreamingRequest = (élément, rappel) => {
//Les MessageChannels sont légers - et cela n'implique pas de surcharge d’en créer
//un nouveau pour chaque requête.
const { port1, port2 } = new MessageChannel()

// Nous envoyons une extrémité du port au processus principal ...
ipcRenderer.postMessage(
'give-me-a-stream',
{ element, count: 10 },
[port2]
)

// ... et nous passons à l'autre extrémité. Le processus principal enverra des messages
// à l'extrémité du port lui correspondant, et le fermera quand ce sera terminé.
port1.onmessage = (event) => {
callback(event.data)
}
port1.onclose = () => {
console.log('stream ended')
}
}

makeStreamingRequest(42, (data) => {
console.log('got response data:', data)
})
// We will see "got response data: 42" 10 times.
main.js (Main Process)
ipcMain.on('give-me-a-stream', (event, msg) => {
// Le moteur de rendu nous a envoyé un MessagePort qu’il souhaite que nous
//utilisions pour envoyer notre réponse.
const [replyPort] = événement. orts

// Ici, nous envoyons les messages de manière synchrone, mais nous pourrions tout aussi
// facilement stocker le port quelque part et envoyer des messages de manière asynchrone.
for (let i = 0; i < msg.count; i++) {
replyPort.postMessage(msg.element)
}

// Nous fermerons le port lorsque nous aurons terminé pour indiquer à l’autre extrémité
//que nous n’enverrons plus de messages. Ce n’est pas strictement nécessaire - si nous
// ne fermons pas explicitement le port, ce serait éventuellement supprimé par le garbage
// collector ce qui déclencherait également l’événement « close » dans le moteur de rendu.
replyPort.close()
})

Communication directe entre le processus principal et le monde principal (main world) d'une page isolée du contexte

Lorsque l'isolement du contexte est activé, Les messages IPC du processus principal au moteur de rendu sont transmis au monde isolé plutôt qu'au monde principal. Mais parfois, vous souhaitez transmettre des messages directement au monde principal, sans avoir à traverser le monde isolé.

main.js (Main Process)
const { BrowserWindow, app, MessageChannelMain } = require('electron')
const path = require('node:path')

app.whenReady().then(async () => {
// Création d'une BrowserWindow avec contextIsolation activé.
const bw = new BrowserWindow({
webPreferences: {
contextIsolation: true,
preload: path.join(__dirname, 'preload.js')
}
})
bw.loadURL('index.html')

// On va envoyer une extrémité de ce canal au monde principal de
// la page isolée du contexte.
const { port1, port2 } = new MessageChannelMain()

// Il est acceptable d’envoyer un message sur le canal avant que l’autre extrémité n’ait
// enregistré un écouteur. Les messages seront mis en file d'attente jusqu'à ce qu'un listener soit
// enregistré.
port2.postMessage({ test: 21 })

// Nous pouvons également recevoir des messages du monde principal du renderer.
port2.on('message', (event) => {
console.log('venant du main world du renderer:', event.data)
})
port2.start()

// Le script de préchargement recevra ce message IPC et transférera le port
// vers le monde principal.
bw.webContents.postMessage('main-world-port', null, [port1])
})
preload.js (Preload Script)
const { ipcRenderer } = require('electron')

// Nous devons attendre que le monde principal soit prêt à recevoir le message avant
// d'envoyer le port. Nous créons cette promesse dans le préchargement afin de garantir que
// l'écouteur du onload soit enregistré avant que l'événement load ne soit déclenché.
const windowLoaded = new Promise(resolve => {
window.onload = resolve
})
ipcRenderer.on('main-world-port', async (event) => {
await windowLoaded
// On utilise un window.postMessage normal pour transferrer le port du monde isolé
// vers le monde principal.
window.postMessage('main-world-port', '*', event.ports)
})
index.html
<script>
window.onmessage = (event) => {
// event.source === window means the message is coming from the preload
// script, as opposed to from an <iframe> or other source.
if (event.source === window && event.data === 'main-world-port') {
const [ port ] = event.ports
// Once we have the port, we can communicate directly with the main
// process.
port.onmessage = (event) => {
console.log('from main process:', event.data)
port.postMessage(event.data.test * 2)
}
}
}
</script>