Guia completo de desenvolvimento de aplicativos desktop com Electron: Da configuração à produção

前端工程

Por que Electron

O desenvolvimento desktop multiplataforma sempre foi uma dor de cabeça. Escreva aplicativos Windows em C# e WPF, aplicativos macOS em Swift e AppKit, aplicativos Linux em GTK ou Qt — três bases de código, triplo de manutenção. Electron mudou o jogo: uma base de código HTML/CSS/JavaScript, empacotada para Windows, macOS e Linux simultaneamente.

Se você usa VS Code, Figma, Slack ou Discord, já está executando aplicativos Electron todos os dias. A velocidade de inicialização do VS Code e o desempenho de renderização do Figma provam que Electron pode igualar a experiência nativa com a arquitetura certa.

Ajudei a migrar uma ferramenta interna de C# WinForms para Electron. A velocidade de entrega de funcionalidades triplicou nos dois trimestres seguintes. A razão é simples: contratar desenvolvedores frontend é muito mais fácil do que contratar desenvolvedores desktop em C#, e o ecossistema de componentes é vastamente mais rico.

Este artigo é meu resumo completo pós-migração. Não vou recitar a documentação da API — você pode consultar isso — vou focar no que realmente dá errado em produção e como consertar.


Inicialização do projeto: Não copie o Quick-Start oficial

O electron-quick-start oficial usa JavaScript puro, sem ferramentas de build, sem módulos, sem hot reload. Começar um projeto real dessa forma garante código espaguete em dois meses.

Use electron-vite ou configure manualmente Vite + Electron:

npm create @quick-start/electron@latest my-app -- --template vue-ts

Ou configuração 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

Estrutura de diretórios recomendada:

my-electron-app/
├── electron/              # Processo principal
│   ├── main.ts           # Ponto de entrada, criação de janelas
│   ├── preload.ts        # Ponte de segurança
│   └── ipc/              # Gerenciamento de canais IPC
│       ├── file.ts       # Operações de arquivo
│       └── system.ts     # Informações do sistema
├── src/                   # Renderizador (frontend)
│   ├── App.vue
│   ├── main.ts
│   └── components/
├── resources/             # Ícones do app, recursos estáticos
├── electron-builder.yml
├── vite.config.ts
└── package.json

A decisão arquitetônica crítica: separação completa dos processos principal e renderizador. O processo principal (electron/) gerencia apenas operações de nível de sistema. O renderizador (src/) é um projeto frontend padrão. Isso permite que especialistas de cada lado trabalhem independentemente sem atrito.


Modelo de processos: Saiba quem faz o quê

Electron tem dois processos principais. Se você não interiorizar esse conceito, problemas de segurança e desempenho vão forçar reescritas repetidas.

Processo Runtime Capacidades Limitações
Principal Node.js Criar janelas, ler/escrever arquivos, chamar APIs do sistema, gerenciar ciclo de vida Não pode manipular o DOM diretamente
Renderizador Chromium Renderizar HTML/CSS, responder à entrada do usuário, manipular DOM Não pode acessar APIs do Node.js por padrão

A comunicação acontece através de IPC (Inter-Process Communication). O processo principal expõe uma superfície de API limitada aos renderizadores via contextBridge.

Um fluxo de comunicação típico:

Usuário clica em "Salvar Arquivo"
  → Renderizador: ipcRenderer.invoke('save-file', fileData)
    → Principal: ipcMain.handle('save-file', async (event, data) => {
        dialog.showSaveDialog() → fs.writeFileSync()
      })
    → Retorna resultado ao renderizador

Entrada do processo 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,      // Deve estar habilitado
      nodeIntegration: false,      // Deve estar desabilitado
      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 — O portão de segurança do seu app

// 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'),
});

Chamada a partir do renderizador:

// src/components/SaveButton.vue
const handleSave = async () => {
  const result = await window.electronAPI.saveFile(
    editorContent.value,
    'untitled.txt'
  );
  if (result.success) {
    showToast(`Salvo em ${result.filePath}`);
  }
};

contextBridge é crucial — ele restringe o que o renderizador pode chamar. Nunca exponha o ipcRenderer bruto ao renderizador; isso daria ao conteúdo web acesso completo ao Node.js.


Segurança: Sete regras inegociáveis

O modelo de segurança do Electron evoluiu através de múltiplas iterações. Tutoriais antigos frequentemente mostram padrões que agora são inseguros. A partir de 2026, estes são os requisitos rigorosos:

# Regra Config Por quê
1 contextIsolation: true webPreferences Isola o contexto JS do renderizador do preload
2 nodeIntegration: false webPreferences Bloqueia require do Node.js direto no renderizador
3 sandbox: true webPreferences Sandbox em nível de SO, restringe permissões do processo
4 Usar contextBridge preload.ts Exposição de API baseada em lista de permissão
5 Validar origem da mensagem IPC ipcMain.handle Verificar URL de event.senderFrame
6 Sem conteúdo remoto sem CSP webPreferences Política CSP obrigatória para URLs remotas
7 Assinar e notarizar electron-builder macOS requer notarização, Windows recomenda assinatura

Validação de origem IPC

// electron/ipc/file.ts
ipcMain.handle('save-file', async (event, content: string) => {
  if (!event.senderFrame?.url.startsWith('file://')) {
    throw new Error('Chamador não autorizado');
  }

  if (typeof content !== 'string' || content.length > 10 * 1024 * 1024) {
    throw new Error('Conteúdo 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 };
});

Gerenciamento de janelas: Além do new BrowserWindow

Janelas sem moldura e barras de título personalizadas

A maioria dos aplicativos desktop modernos abandona as barras de título nativas — VS Code, Figma, Notion todos fazem isso. Implementação:

const mainWindow = new BrowserWindow({
  frame: false,
  titleBarStyle: 'hidden',
  ...(process.platform === 'darwin'
    ? { titleBarStyle: 'hiddenInset' }
    : { frame: false }),
});

Barra de título personalizada no 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>

Note sobre -webkit-app-region: drag — esta propriedade CSS habilita arrastar para mover na área da barra de título. Áreas de botões devem sobrescrever com no-drag, caso contrário clicar em um botão aciona o arraste da janela.

Comunicação entre múltiplas janelas

Aplicativos complexos frequentemente precisam de múltiplas janelas (configurações, pré-visualização, diálogos). Rotear comunicação entre janelas através do processo 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);
    }
  });
});

Integração de módulos nativos: Quando Node.js não é suficiente

Algumas tarefas são muito lentas em JavaScript puro — processamento em lote de imagens, codificação de vídeo. É aí que entram os módulos nativos C++.

Opção 1: Addon nativo do 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 gerar 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)

Opção 2: Processo filho chamando ferramentas externas

Prefira esta opção — desacoplada, falhas não afetam o processo 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 saiu com ${code}: ${stderr}`));
    });
  });
});

Auto-atualização: Atualizações sem interrupções

A auto-atualização é padrão para aplicativos desktop. electron-updater (baseado no electron-builder) é a opção mais 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: 'Atualização disponível',
      message: 'Baixando atualização em segundo plano...',
    });
  });

  autoUpdater.on('update-downloaded', () => {
    dialog.showMessageBox(mainWindow, {
      type: 'info',
      title: 'Atualização pronta',
      message: 'Reinicie agora para instalar a versão mais recente.',
    }).then(() => {
      autoUpdater.quitAndInstall();
    });
  });

  autoUpdater.on('error', (err) => {
    console.error('Erro de atualização:', err.message);
  });

  setTimeout(() => autoUpdater.checkForUpdates(), 5000);
}

Empacotamento e distribuição

Exemplo 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 build:

npm run dev           # desenvolvimento local
npm run build         # plataforma atual
npm run build:win     # Windows
npm run build:mac     # macOS
npm run build:linux   # Linux

Otimização de desempenho: Não deixe seu app virar Chrome

A maior crítica ao Electron é o uso de memória. Um app Electron básico usa 50-80MB, e com conteúdo real, facilmente ultrapassa 200MB+. Aqui estão estratégias validadas em produção:

1. Carregamento preguiçoso de janelas não 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. Limitação em segundo plano

const win = new BrowserWindow({
  webPreferences: {
    backgroundThrottling: true,
    offscreen: false,
  },
});

win.on('blur', () => {
  win.webContents.setBackgroundThrottling(true);
});

3. Usar BrowserView para conteúdo embarcado

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. Checklist de vazamento de memória

Verificação Ferramenta Métrica
Processos zumbi após fechar janela Gerenciador de Tarefas / Activity Monitor Processos electron persistentes
webContents não destruídos webContents.getAllWebContents() Contagem crescente
Listeners IPC não limpos ipcMain.eventNames() Falta removeHandler
Referências a objetos grandes Painel Memory do Chrome DevTools Comparação de snapshots do heap

Dicas de depuração

Depuração do processo principal

# Iniciar com depurador anexado
electron --inspect=5858 .
# Abrir chrome://inspect no 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
}

Depuração do processo renderizador

DevTools são abertos automaticamente em modo de desenvolvimento. Em produção, force com uma flag:

if (process.argv.includes('--devtools')) {
  mainWindow.webContents.openDevTools({ mode: 'detach' });
}

Armadilhas comuns

Armadilha 1: Notarização do macOS falha

"notarization failed: The binary is not signed"

Correção: Garanta hardenedRuntime: true, arquivo de entitlements correto e um certificado Apple Developer ID.

Armadilha 2: Caminho do Windows muito longo

O Windows tem um limite de 260 caracteres para caminhos. node_modules profundamente aninhados facilmente o excedem. Habilite suporte a caminhos longos no Windows 10 1607+ via Política de Grupo, ou use electron-builder install-app-deps.

Armadilha 3: Módulos nativos falham após empacotamento asar

electron-builder empacota o código em app.asar por padrão, mas módulos nativos .node não podem ser carregados de dentro do asar:

# electron-builder.yml
asarUnpack:
  - "node_modules/sharp/**"
  - "node_modules/better-sqlite3/**"
  - "native/**/*.node"

Armadilha 4: Tela branca longa na inicialização

  • Divida o código de saída do Vite, carregue código mínimo para a primeira pintura
  • Use uma tela de splash para cobrir o período em branco
  • Mostre a janela apenas no evento ready-to-show:
mainWindow.once('ready-to-show', () => { mainWindow.show(); });

Ferramentas relacionadas

Experimente estas ferramentas executadas localmente no navegador — nenhum cadastro necessário →

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