
Por Matthew Curtis, ingeniero de software sénior
Fondo
Hola, soy Matthew y trabajo en un proyecto llamado Perseus. Es un proyecto de código abierto responsable de la experiencia de ejercicio en Khan Academy. Si alguna vez respondiste una pregunta usando el widget de radio o escribiste una ecuación usando nuestro teclado matemático, ¡has usado Perseus!
Dado que Perseus está separado de nuestro sitio web principal (también conocido como Webapp), a veces podemos encontrarnos con problemas que son difíciles de depurar: no podemos agregar fácilmente el registro al código de Perseus y, a veces, la dependencia de Webapp de los subpaquetes de Perseus no está sincronizada. Contamos con un conjunto de herramientas de desarrollo integradas en Webapp, pero se agregan al DOM, lo que crea una desconexión entre lo que ven los usuarios internos y lo que ven nuestros alumnos.
Entonces, para un hackathon reciente, decidí crear un prototipo de Perseus Dev Tools, una extensión de navegador que agrega herramientas adicionales a las herramientas de desarrollo integradas del navegador, inspirada en React Dev Tools y Redux Dev Tools. El objetivo es hacer lo siguiente:
- Danos una mirada en profundidad al ejercicio que estamos viendo en Webapp.
- Compare las dependencias de Perseus de Webapp con las últimas versiones de npm
- Danos un lugar para colocar otras herramientas de desarrollo específicas de Khan Academy.
- Evite cambiar el DOM para que podamos ver lo que ven los alumnos.
Como no había muchos ejemplos sobre cómo hacer que esto funcionara, decidí crear una extensión simulada que muestre esta funcionalidad y escribir algunos aspectos destacados del código. Sin embargo, este no será un tutorial completo. El código está disponible y MDN ha escrito mejores documentos que yo. Esta es solo una descripción general de alto nivel de cómo encajan las piezas, junto con un ejemplo práctico.
Hablando de eso, aquí está el código de demostración.
La demostración
El sitio de demostración es un sitio de la aplicación Create React que tiene un par de componentes Counter. Queremos que la extensión haga lo siguiente:
- Muestra el estado de conteo actual de cada componente del contador.
- A medida que cambia el estado del sitio, el estado de la extensión debería cambiar.
- La extensión tiene un botón de “restablecer” que restablecerá un contador específico.
- Si el sitio está abierto en varias pestañas, la extensión no debería modificar los contadores en otras pestañas.
Para configurarlo:
La estructura de las extensiones de herramientas de desarrollo
Una extensión de herramienta de desarrollador es solo un tipo específico de extensión de navegador. En el manifiesto de extensión, especificamos una ruta para la interfaz de usuario de la herramienta de desarrollo usando devtools_page
y en el JS para esa UI, creamos un panel usando browser.devtools.panels.create
. Cada pestaña tendrá una instancia de la interfaz de usuario, y la interfaz de usuario puede tener cualquier número de paneles.
Mi expectativa original era que la aplicación y la herramienta de desarrollo pudieran comunicarse directamente, pero resultó ser incorrecta. Hay varias capas a considerar y, por razones de seguridad, estas capas se comunican publicando y escuchando mensajes. El sitio debe optar explícitamente por comunicarse con estas capas. El script inyectado en la aplicación (el script de contenido) tiene acceso muy limitado a la aplicación host, pero poder recibir mensajes que publica la aplicación anfitriona; así es como transmitiremos los datos.

Estas son las capas:
- El Solicitud es el sitio web del que queremos recibir datos y enviar comandos.
- El Guión de contenido es el código que se inyecta en la aplicación. Esto nos permite enviar mensajes entre la aplicación y el script en segundo plano.
- El Guión de fondo es un código que se ejecuta en el nivel del navegador (en lugar de en el nivel de la aplicación). Puede coordinar mensajes entre múltiples pestañas y paneles de herramientas de desarrollo.
- Ahí está el Herramienta de desarrollo en general, que es capaz de renderizar múltiples paneles.
- Finalmente, está el Panel, que utilizamos para la interfaz real de la herramienta. Puede enviar/recibir mensajes hacia/desde el script en segundo plano.
Entonces, para enviar un mensaje desde un Panel hacia Solicitud: Panel → Fondo → Contenido → Aplicación. Para enviar un mensaje desde el Solicitud hacia Panel: Aplicación → Contenido → Fondo → Panel.
⚠️⚠️⚠️ Hay un “¡Te tengo!” aunque. El script en segundo plano funciona con muchas pestañas y muchos paneles. Tenemos que asegurarnos de comunicarnos con las instancias de aquellas con las que pretendemos interactuar. ⚠️⚠️⚠️
Mensajes
Si alguna vez trabajó con iFrames, probablemente haya usado mensajes. Es una forma de que el código en el entorno sandbox se comunique fuera de su entorno sandbox. Para cada capa, usaremos un sistema para escuchar y enviar mensajes.
Una consideración es que otras extensiones pueden estar enviando mensajes. Otras partes de nuestro El código puede estar enviando mensajes. Para facilitar el seguimiento y garantizar que solo respondamos a los mensajes que queremos responder, así es como se verán nuestros mensajes:
const message = {
extension: "blog-ext",
source: "application",
action: "rendered",
data: {
widgetId: "counter-exercise-1",
count: 42,
},
}
usaremos extension
para diferenciar nuestros mensajes de otros mensajes, y usaremos source
para realizar un seguimiento de qué capa está enviando el mensaje. action
será lo que sucedió, y los datos serán la carga útil.
Envío de datos desde la aplicación
Vamos a empezar enviando un mensaje desde la aplicación con algunos datos; esto significa que necesitaremos controlar la aplicación host (como en este ejemplo) o podemos publicar mensajes desde una dependencia de la aplicación host (como React envía mensajes a React Dev Tools a través de la aplicación que usa React).
En este caso, cada vez que rendericemos, enviaremos un mensaje similar a este:
// application/src/Counter.js
window.postMessage({
extension: "blog-ext",
source: "application",
action: "rendered",
data: {
widgetId: "counter-exercise-1",
count: 42,
},
})
El script de contenido, que se inyecta en la aplicación web, lo captará y enviará su propia versión:
// dev-tools/scripts/content.js
/**
* Listen to messages from the application,
* forward them to the background script
*/
window.addEventListener("message", (event) => {
// Only accept messages from the same frame
if (event.source !== window) {
return;
}
const message = event.data;
// Only accept messages that we know are ours
if (message?.extension !== "blog-ext" || message?.source !== "application") {
return;
}
browser.runtime.sendMessage({
extension: "blog-ext",
source: "content",
action: message.action,
data: message.data,
});
});
La secuencia de comandos en segundo plano es un poco más complicada ya que solo hay una instancia de la secuencia de comandos en segundo plano que coordina los datos para varias pestañas. Necesita realizar un seguimiento de cada pestaña y de múltiples conexiones a los paneles de herramientas de desarrollo. También realizaremos un seguimiento del estado para que cuando un panel se conecte, pueda solicitar el estado de una pestaña específica.
// dev-tools/scripts/background.js
const connections = {};
const latestState = {};
/**
* Listen to messages from the content script,
* save data for when/if the dev tools panel connects,
* and forward messages to the dev tools panel
*/
browser.runtime.onMessage.addListener((message, sender) => {
// Only accept messages that we know are ours
if (message?.extension !== "blog-ext" || message?.source !== "content") {
return;
}
// Messages from content scripts should have sender.tab set
if (sender.tab) {
const tabId = sender.tab.id;
// store state for when dev tool panel connects
if (message.action === "rendered") {
if (!latestState[tabId]) {
latestState[tabId] = {};
}
latestState[tabId][message.data.widgetId] = message.data.count;
} else if (message.action === "removed") {
if (!latestState[tabId]) {
return;
}
delete latestState[tabId][message.data.widgetId];
}
// foward messages
if (tabId in connections) {
connections[tabId].postMessage({
extension: "blog-ext",
source: "background",
action: message.action,
data: message.data,
});
} else {
console.log("Tab not found in connection list.");
}
} else {
console.log("sender.tab not defined.");
}
});
Finalmente, podemos recibir esos datos en el código del panel:
// dev-tools/ui/panel/panel.js
let widgets = {};
/**
* Create a connection to the background script
*/
const backgroundPageConnection = browser.runtime.connect({
name: "panel",
});
/**
* Listen for messages from background script
*/
backgroundPageConnection.onMessage.addListener((request) => {
// Only accept messages that we know are ours
if (message?.extension !== "blog-ext" || message?.source !== "background") {
return;
}
switch (request.action) {
case "rendered":
widgets[request.data.widgetId] = request.data.count;
break;
case "removed":
delete widgets[request.data.widgetId];
break;
case "hydrate-state":
widgets = { ...widgets, ...request.data };
break;
}
// update panel UI
render();
});
Envío de comandos desde herramientas de desarrollo
Ahora hagamos las cosas al revés. Comenzaremos con un mensaje de “inicio” para obtener el estado guardado del script en segundo plano. También enviaremos un mensaje de “restablecimiento” desde el panel, pero al igual que en el ejemplo anterior, necesitaremos agregar algunos datos para ayudar a que este mensaje llegue a la pestaña correcta.
// dev-tools/ui/panel/panel.js
/**
* Send an init message back to background script
* to request existing state
*/
backgroundPageConnection.postMessage(
formatMessage({
extension: "blog-ext",
source: "panel",
action: "init",
data: {
tabId: browser.devtools.inspectedWindow.tabId,
},
})
);
/**
* Callback for UI button press
*/
function handleClickReset(widgetId) {
backgroundPageConnection.postMessage(
formatMessage({
extension: "blog-ext",
source: "panel",
action: "reset",
data: {
widgetId,
tabId: browser.devtools.inspectedWindow.tabId,
},
})
);
}
Una vez más, el guión de fondo es un poco más complicado. Agregaremos un oyente para los mensajes del panel, pero también agregaremos un oyente de desconexión para limpiar las conexiones cuando sea necesario.
// dev-tools/scripts/background.js
/**
* Listen to messages from the dev tools panel,
* and either respond or foward them to the content script
*/
browser.runtime.onConnect.addListener((port) => {
function extensionListener(message) {
// Only accept messages that we know are ours
if (message?.extension !== "blog-ext" || message?.source !== "panel") {
return;
}
// Listen for the panel to connect, save a reference to it,
// and hydrate its state
if (message.action === "init") {
connections[message.data.tabId] = port;
port.postMessage({
extension: "blog-ext",
source: "background",
action: "hydrate-state",
data: latestState[message.data.tabId],
});
return;
}
// forward everything else to the content script
browser.tabs.sendMessage(
message.data.tabId,
{
extension: "blog-ext",
source: "background",
action: message.action,
data: message.data,
}
);
}
// Listen to messages sent from the DevTools page
port.onMessage.addListener(extensionListener);
port.onDisconnect.addListener((port) => {
port.onMessage.removeListener(extensionListener);
var tabs = Object.keys(connections);
for (let i = 0; i < tabs.length; i++) {
if (connections[tabs[i]] == port) {
delete connections[tabs[i]];
break;
}
}
});
});
Captaremos los mensajes reenviados en el script de contenido y los reenviaremos a la aplicación:
// dev-tools/scripts/content.js
/**
* Listen to messages from the background script,
* forward them to the application
*/
browser.runtime.onMessage.addListener((message) => {
// Only accept messages that we know are ours
if (message?.extension !== "blog-ext" || message?.source !== "background") {
return;
}
window.postMessage({
extension: "blog-ext",
source: "content",
action: message.action,
data: message.data,
});
});
Luego, finalmente, podemos juntar todo esto en el componente Contador:
// application/src/Counter.js
// Listen to messages from the content script
function handleMessage(event) {
const message = event.data;
// Only accept messages that we know are ours
if (message?.extension !== "blog-ext" || message?.source !== "content") {
return;
}
const messageWidgetId = message.data.widgetId;
const messageAction = message.action;
if (messageWidgetId === widgetId && messageAction === "reset") {
onChange(0);
}
}
useEffect(() => {
window.postMessage({
extension: "blog-ext",
source: "application",
action: "rendered",
data: {
widgetId,
count,
},
});
window.addEventListener("message", handleMessage);
return () => {
window.postMessage({
extension: "blog-ext",
source: "application",
action: "removed",
data: {
widgetId,
},
});
window.removeEventListener("message", handleMessage);
};
});
Conclusión
¡Eso es todo! Parte de este código es un poco detallado y algunas de las comprobaciones son demasiado cautelosas, pero esperamos que esto ayude a mostrar cómo interactúan las capas entre sí. ¡Gracias por leer!
Fuentes:
Fuente Original Khan Academy Blog