Guía completa de desarrollo de aplicaciones de escritorio con Electron: Desde la configuración hasta la producción
Por qué Electron
El desarrollo de escritorio multiplataforma siempre ha sido un dolor de cabeza. Escribe aplicaciones Windows en C# y WPF, aplicaciones macOS en Swift y AppKit, aplicaciones Linux en GTK o Qt — tres bases de código, el triple de mantenimiento. Electron cambió las reglas del juego: una sola base de código HTML/CSS/JavaScript, empaquetada para Windows, macOS y Linux simultáneamente.
Si usas VS Code, Figma, Slack o Discord, ya estás ejecutando aplicaciones Electron todos los días. La velocidad de inicio de VS Code y el rendimiento de renderizado de Figma demuestran que Electron puede igualar la experiencia nativa con la arquitectura adecuada.
Ayudé a migrar una herramienta interna de C# WinForms a Electron. La velocidad de entrega de funcionalidades se triplicó en los dos trimestres siguientes. La razón es simple: contratar desarrolladores frontend es mucho más fácil que contratar desarrolladores de escritorio en C#, y el ecosistema de componentes es vastamente más rico.
Este artículo es mi resumen completo post-migración. No recitaré la documentación de la API — puedes consultarla tú mismo — me centraré en lo que realmente falla en producción y cómo solucionarlo.
Inicialización del proyecto: No copies el Quick-Start oficial
El electron-quick-start oficial usa JavaScript puro, sin herramientas de construcción, sin módulos, sin hot reload. Comenzar un proyecto real de esta manera garantiza código espagueti en dos meses.
Usa electron-vite o configura manualmente Vite + Electron:
npm create @quick-start/electron@latest my-app -- --template vue-ts
O configuración manual:
mkdir my-electron-app && cd my-electron-app
npm init -y
npm i -D electron electron-builder vite @vitejs/plugin-vue typescript
npm i vue
Estructura de directorios recomendada:
my-electron-app/
├── electron/ # Proceso principal
│ ├── main.ts # Punto de entrada, creación de ventanas
│ ├── preload.ts # Puente de seguridad
│ └── ipc/ # Gestión de canales IPC
│ ├── file.ts # Operaciones de archivos
│ └── system.ts # Información del sistema
├── src/ # Renderizador (frontend)
│ ├── App.vue
│ ├── main.ts
│ └── components/
├── resources/ # Iconos de la app, recursos estáticos
├── electron-builder.yml
├── vite.config.ts
└── package.json
La decisión arquitectónica crítica: separación completa de los procesos principal y renderizador. El proceso principal (electron/) maneja solo operaciones a nivel de sistema. El renderizador (src/) es un proyecto frontend estándar. Esto permite que los especialistas de cada lado trabajen de forma independiente sin fricciones.
Modelo de procesos: Saber quién hace qué
Electron tiene dos procesos principales. Si no interiorizas este concepto, los problemas de seguridad y rendimiento te obligarán a reescribir el código repetidamente.
| Proceso | Runtime | Capacidades | Limitaciones |
|---|---|---|---|
| Principal | Node.js | Crear ventanas, leer/escribir archivos, llamar APIs del sistema, gestionar ciclo de vida | No puede manipular el DOM directamente |
| Renderizador | Chromium | Renderizar HTML/CSS, responder a entrada del usuario, manipular DOM | No puede acceder a las APIs de Node.js por defecto |
La comunicación ocurre a través de IPC (Inter-Process Communication). El proceso principal expone una superficie de API limitada a los renderizadores mediante contextBridge.
Un flujo de comunicación típico:
El usuario hace clic en "Guardar archivo"
→ Renderizador: ipcRenderer.invoke('save-file', fileData)
→ Principal: ipcMain.handle('save-file', async (event, data) => {
dialog.showSaveDialog() → fs.writeFileSync()
})
→ Retorna el resultado al renderizador
Entrada del proceso principal
// electron/main.ts
import { app, BrowserWindow } from 'electron';
import { registerIpcHandlers } from './ipc';
let mainWindow: BrowserWindow | null = null;
function createWindow() {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true, // Debe estar habilitado
nodeIntegration: false, // Debe estar deshabilitado
sandbox: true, // Modo sandbox
},
});
if (process.env.NODE_ENV === 'development') {
mainWindow.loadURL('http://localhost:5173');
mainWindow.webContents.openDevTools();
} else {
mainWindow.loadFile(path.join(__dirname, '../dist/index.html'));
}
}
app.whenReady().then(() => {
registerIpcHandlers();
createWindow();
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit();
}
});
Script de preload — La puerta de seguridad de tu aplicación
// electron/preload.ts
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('electronAPI', {
saveFile: (content: string, defaultName?: string) =>
ipcRenderer.invoke('save-file', content, defaultName),
openFile: () =>
ipcRenderer.invoke('open-file'),
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
getPlatform: () => process.platform,
minimizeWindow: () => ipcRenderer.send('window-minimize'),
maximizeWindow: () => ipcRenderer.send('window-maximize'),
closeWindow: () => ipcRenderer.send('window-close'),
});
Llamada desde el renderizador:
// src/components/SaveButton.vue
const handleSave = async () => {
const result = await window.electronAPI.saveFile(
editorContent.value,
'untitled.txt'
);
if (result.success) {
showToast(`Guardado en ${result.filePath}`);
}
};
contextBridge es crucial — restringe lo que el renderizador puede llamar. Nunca expongas ipcRenderer directamente al renderizador; eso daría al contenido web acceso completo a Node.js.
Seguridad: Siete reglas innegociables
El modelo de seguridad de Electron ha evolucionado a través de múltiples iteraciones. Los tutoriales antiguos a menudo muestran patrones que ahora son inseguros. A partir de 2026, estos son los requisitos estrictos:
| # | Regla | Config | Por qué |
|---|---|---|---|
| 1 | contextIsolation: true |
webPreferences | Aísla el contexto JS del renderizador del preload |
| 2 | nodeIntegration: false |
webPreferences | Bloquea require de Node.js directo en el renderizador |
| 3 | sandbox: true |
webPreferences | Sandbox a nivel de SO, restringe permisos del proceso |
| 4 | Usar contextBridge |
preload.ts | Exposición de API basada en lista blanca |
| 5 | Validar origen del mensaje IPC | ipcMain.handle | Verificar URL de event.senderFrame |
| 6 | Sin contenido remoto sin CSP | webPreferences | Política CSP requerida para URLs remotas |
| 7 | Firmar y notarizar | electron-builder | macOS requiere notarización, Windows recomienda firma |
Validación de origen IPC
// electron/ipc/file.ts
ipcMain.handle('save-file', async (event, content: string) => {
if (!event.senderFrame?.url.startsWith('file://')) {
throw new Error('Llamante no autorizado');
}
if (typeof content !== 'string' || content.length > 10 * 1024 * 1024) {
throw new Error('Contenido inválido');
}
const { filePath } = await dialog.showSaveDialog({
defaultPath: 'untitled.txt',
filters: [{ name: 'Text', extensions: ['txt', 'md'] }],
});
if (!filePath) return { success: false, canceled: true };
await fs.promises.writeFile(filePath, content, 'utf-8');
return { success: true, filePath };
});
Gestión de ventanas: Más allá de new BrowserWindow
Ventanas sin marco y barras de título personalizadas
La mayoría de las aplicaciones de escritorio modernas abandonan las barras de título nativas — VS Code, Figma, Notion todas lo hacen. Implementación:
const mainWindow = new BrowserWindow({
frame: false,
titleBarStyle: 'hidden',
...(process.platform === 'darwin'
? { titleBarStyle: 'hiddenInset' }
: { frame: false }),
});
Barra de título personalizada en el renderizador:
<!-- src/components/TitleBar.vue -->
<template>
<div class="title-bar" :class="{ 'is-mac': isMac }">
<div class="title-bar-drag" @dblclick="toggleMaximize">
<span class="app-title">{{ title }}</span>
</div>
<div class="window-controls" v-if="!isMac">
<button @click="minimize" class="control-btn minimize">─</button>
<button @click="toggleMaximize" class="control-btn maximize">□</button>
<button @click="close" class="control-btn close">✕</button>
</div>
</div>
</template>
Nota sobre -webkit-app-region: drag — esta propiedad CSS habilita arrastrar para mover en el área de la barra de título. Las áreas de botones deben sobrescribirla con no-drag, de lo contrario hacer clic en un botón activa el arrastre de la ventana.
Comunicación entre múltiples ventanas
Las aplicaciones complejas a menudo necesitan múltiples ventanas (configuración, vista previa, diálogos). Enruta la comunicación entre ventanas a través del proceso principal:
ipcMain.on('broadcast-to-windows', (event, channel: string, data: unknown) => {
BrowserWindow.getAllWindows().forEach(win => {
if (win.webContents.id !== event.sender.id) {
win.webContents.send(channel, data);
}
});
});
Integración de módulos nativos: Cuando Node.js no es suficiente
Algunas tareas son demasiado lentas en JavaScript puro — procesamiento por lotes de imágenes, codificación de video. Ahí es donde entran los módulos nativos de C++.
Opción 1: Addon nativo de Node.js (node-addon-api)
// native/thumbnail.cc
#include <napi.h>
Napi::Value GenerateThumbnail(const Napi::CallbackInfo& info) {
Napi::Env env = info.Env();
std::string inputPath = info[0].As<Napi::String>();
std::string outputPath = info[1].As<Napi::String>();
// Usar FFmpeg/libvips para generar miniatura
return Napi::String::New(env, outputPath);
}
Napi::Object Init(Napi::Env env, Napi::Object exports) {
exports.Set("generateThumbnail",
Napi::Function::New(env, GenerateThumbnail));
return exports;
}
NODE_API_MODULE(thumbnail, Init)
Opción 2: Proceso hijo llamando herramientas externas
Prefiere esta opción — desacoplada, los fallos no afectan el proceso principal:
// electron/ipc/image.ts
import { spawn } from 'child_process';
ipcMain.handle('compress-image', async (event, inputPath: string) => {
return new Promise((resolve, reject) => {
const child = spawn('oxipng', ['--opt', 'max', '--strip', 'safe', inputPath]);
let stderr = '';
child.stderr.on('data', (data) => { stderr += data; });
child.on('close', (code) => {
if (code === 0) resolve({ success: true });
else reject(new Error(`oxipng salió con ${code}: ${stderr}`));
});
});
});
Auto-actualización: Actualizaciones sin interrupciones
La auto-actualización es estándar para aplicaciones de escritorio. electron-updater (basado en electron-builder) es la opción más madura:
// electron/updater.ts
import { autoUpdater } from 'electron-updater';
import { BrowserWindow, dialog } from 'electron';
export function setupAutoUpdater(mainWindow: BrowserWindow) {
autoUpdater.setFeedURL({
provider: 'generic',
url: 'https://releases.your-app.com/updates/',
});
setInterval(() => {
autoUpdater.checkForUpdates();
}, 4 * 60 * 60 * 1000);
autoUpdater.on('update-available', () => {
dialog.showMessageBox(mainWindow, {
type: 'info',
title: 'Actualización disponible',
message: 'Descargando actualización en segundo plano...',
});
});
autoUpdater.on('update-downloaded', () => {
dialog.showMessageBox(mainWindow, {
type: 'info',
title: 'Actualización lista',
message: 'Reinicia ahora para instalar la última versión.',
}).then(() => {
autoUpdater.quitAndInstall();
});
});
autoUpdater.on('error', (err) => {
console.error('Error de actualización:', err.message);
});
setTimeout(() => autoUpdater.checkForUpdates(), 5000);
}
Empaquetado y distribución
Ejemplo de electron-builder.yml:
appId: com.yourcompany.yourapp
productName: YourApp
directories:
output: release
files:
- dist/**/*
- electron/**/*.js
- package.json
win:
target:
- target: nsis
arch: [x64, arm64]
icon: resources/icon.ico
mac:
target:
- target: dmg
arch: [x64, arm64]
icon: resources/icon.icns
category: public.app-category.developer-tools
hardenedRuntime: true
notarize:
teamId: YOUR_TEAM_ID
linux:
target:
- target: AppImage
arch: [x64]
- target: deb
arch: [x64]
category: Development
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
Comandos de construcción:
npm run dev # desarrollo local
npm run build # plataforma actual
npm run build:win # Windows
npm run build:mac # macOS
npm run build:linux # Linux
Optimización de rendimiento: No dejes que tu aplicación se convierta en Chrome
La mayor crítica a Electron es el consumo de memoria. Una aplicación Electron básica usa 50-80MB, y con contenido real, fácilmente supera los 200MB. Aquí hay estrategias validadas en producción:
1. Carga diferida de ventanas no críticas
let settingsWindow: BrowserWindow | null = null;
ipcMain.handle('open-settings', async () => {
if (settingsWindow && !settingsWindow.isDestroyed()) {
settingsWindow.focus();
return;
}
settingsWindow = new BrowserWindow({ /* ... */ });
settingsWindow.loadURL('...');
settingsWindow.on('closed', () => { settingsWindow = null; });
});
2. Limitación en segundo plano
const win = new BrowserWindow({
webPreferences: {
backgroundThrottling: true,
offscreen: false,
},
});
win.on('blur', () => {
win.webContents.setBackgroundThrottling(true);
});
3. Usar BrowserView para contenido embebido
const view = new BrowserView({
webPreferences: { sandbox: true, contextIsolation: true },
});
mainWindow.setBrowserView(view);
view.setBounds({ x: 0, y: 0, width: 800, height: 600 });
view.webContents.loadURL('https://preview.your-app.com/doc/123');
4. Lista de verificación de fugas de memoria
| Verificación | Herramienta | Métrica |
|---|---|---|
| Procesos zombi tras cerrar ventana | Administrador de tareas / Activity Monitor | Procesos electron persistentes |
| webContents no destruidos | webContents.getAllWebContents() |
Conteo creciente |
| Listeners IPC no limpiados | ipcMain.eventNames() |
Falta removeHandler |
| Referencias a objetos grandes | Panel Memory de Chrome DevTools | Comparación de instantáneas del heap |
Consejos de depuración
Depuración del proceso principal
# Iniciar con depurador adjunto
electron --inspect=5858 .
# Abrir chrome://inspect en Chrome para conectar
VSCode launch.json:
{
"type": "node",
"request": "launch",
"name": "Debug Main Process",
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron",
"runtimeArgs": ["--inspect=5858", "."],
"port": 5858
}
Depuración del proceso renderizador
DevTools se abren automáticamente en modo desarrollo. En producción, fuerzalos con un flag:
if (process.argv.includes('--devtools')) {
mainWindow.webContents.openDevTools({ mode: 'detach' });
}
Errores comunes
Error 1: La notarización de macOS falla
"notarization failed: The binary is not signed"
Solución: Asegúrate de hardenedRuntime: true, archivo de entitlements correcto y un certificado de Apple Developer ID.
Error 2: Ruta de Windows demasiado larga
Windows tiene un límite de 260 caracteres en las rutas. Los node_modules profundamente anidados lo superan fácilmente. Habilita el soporte de rutas largas en Windows 10 1607+ mediante Política de Grupo, o usa electron-builder install-app-deps.
Error 3: Los módulos nativos fallan tras el empaquetado asar
electron-builder empaqueta el código en app.asar por defecto, pero los módulos nativos .node no pueden cargarse desde dentro de asar:
# electron-builder.yml
asarUnpack:
- "node_modules/sharp/**"
- "node_modules/better-sqlite3/**"
- "native/**/*.node"
Error 4: Pantalla blanca prolongada al iniciar
- Divide el código de salida de Vite, carga código mínimo para el primer pintado
- Usa una pantalla de splash para cubrir el período en blanco
- Muestra la ventana solo en el evento
ready-to-show:
mainWindow.once('ready-to-show', () => { mainWindow.show(); });
Herramientas relacionadas
- Codificar/Decodificar Base64 — Manejar recursos inline y transferencias de archivos
- Formateador JSON — Depurar mensajes IPC y archivos de configuración
- Compresor de imágenes — Optimizar el tamaño de recursos de imagen en la aplicación
Prueba estas herramientas que se ejecutan en tu navegador — no requieren registro →