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

Prueba estas herramientas que se ejecutan en tu navegador — no requieren registro →

#Electron#桌面应用#跨平台#Node.js#教程