Перейти к основному содержимому

Первая программа Electron с React

Разработчику Инженеру

Первая программа Electron с React

Electron и React - первое десктопное окно

Вы уже делали интерфейс в React в браузере и хотите то же самое в окне на рабочем столе — с доступом к файлам, системным диалогам и меню. Здесь собран первый рабочий проект: счётчик в React и кнопка «Открыть файл», которая вызывает стандартный диалог Windows/macOS/Linux.

Обзор процессов Electron — в Electron. API Node.js — 262. Общие правила десктопа (не блокировать UI) — 112.md.


Electron и React — десктоп на веб-стеке

Electron — это способ упаковать веб-интерфейс (HTML, CSS, JavaScript) в отдельное приложение с окном, как VS Code или Discord. Внутри — движок Chromium (рисует страницу) и Node.js (читает файлы, показывает диалоги). Вы пишете UI на React, а «системные» действия — в main-процессе.

Схема в одном предложении:

ЧастьРольАналогия
RendererТо, что видит пользователь (React, кнопки)Витрина магазина
MainОкна, файлы, меню, диалогиОфис с ключами от склада
PreloadУзкий мост: только разрешённые вызовы из UI в mainОхрана на входе со списком «можно / нельзя»
Пользователь нажимает кнопку в React (renderer)


window.desktop.openFile() ← preload выставил эту функцию


ipcRenderer.invoke('dialog:openFile') ← сообщение в main


ipcMain.handle(...) → dialog.showOpenDialog() ← ОС показывает окно выбора файла


Путь к файлу возвращается в React и показывается на экране

React рисует кнопки, main открывает файл, preload соединяет их безопасно. Прямой доступ к диску из UI в production обычно отключают — иначе уязвимость в странице превратится в чтение любых файлов на компьютере.


Словарь терминов (прочитайте до кода)

ТерминЧто это простыми словами
Main processГлавный процесс Node.js: создаёт окна, живёт всё время работы приложения. Один на приложение.
Renderer processПроцесс «вкладки»: внутри него крутится ваша React-страница. Окно = отдельный renderer (часто).
PreloadСкрипт, который выполняется до загрузки страницы и может безопасно передать в window только нужные функции.
IPC (Inter-Process Communication)Обмен сообщениями между main и renderer. В коде: ipcMain / ipcRenderer.
invoke / handleПара «запрос — ответ»: UI вызывает invoke, main обрабатывает в handle и возвращает Promise с результатом.
contextBridgeAPI Electron: публикует объект в window renderer, не ломая изоляцию контекстов.
contextIsolation: trueRenderer и preload в разных «мирах»; глобалы страницы не видят require напрямую.
nodeIntegration: falseВ renderer нет встроенного Node (require, fs) — только то, что разрешил preload.
ViteСборщик для React: быстрая пересборка при сохранении файла, в dev отдаёт UI на localhost:5173.

Что получится

Окно с:

  1. Счётчиком — чистый React, данные только в renderer.
  2. Кнопкой «Открыть файл» — путь к файлу приходит из main через ipcRenderer.invoke.

Так вы увидите оба слоя: UI без привилегий ОС и main с доступом к диалогам.


Создание проекта

Рекомендуемый старт — шаблон с Vite (удобно для React: горячая перезагрузка UI):

npm create @quick-start/electron@latest my-app -- --template react
cd my-app
npm install
npm run dev

Что делает npm run dev: обычно одновременно поднимает Vite (страница React) и Electron (окно, которое грузит этот URL). Вы правите src/App.jsx — окно обновляется без полной пересборки.

Если мастер недоступен — минимальная структура вручную:

my-app/
package.json # точка входа: "main": "electron/main.js"
electron/
main.js # main process
preload.js # мост в renderer
src/ # React (Vite)
main.jsx
App.jsx
index.html

В package.json укажите "main": "electron/main.js" и скрипты dev / start по README шаблона. Поле "main" говорит Electron, какой файл запустить первым — это всегда main-процесс, не React.


Main process — окно и IPC

Файл electron/main.js:

const { app, BrowserWindow, ipcMain, dialog } = require('electron');
const path = require('path');

function createWindow() {
const win = new BrowserWindow({
width: 900,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
});

// dev: Vite на localhost:5173; production: file://...
if (process.env.VITE_DEV_SERVER_URL) {
win.loadURL(process.env.VITE_DEV_SERVER_URL);
} else {
win.loadFile(path.join(__dirname, '../dist/index.html'));
}
}

app.whenReady().then(createWindow);

ipcMain.handle('dialog:openFile', async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'Text', extensions: ['txt', 'md'] }],
});
if (canceled || filePaths.length === 0) return null;
return filePaths[0];
});

Разбор по блокам

Строки / блокСмысл
require('electron')В main доступен полный Node и модули Electron.
appЖизненный цикл приложения: готовность, выход, активация на macOS.
BrowserWindowСоздаёт окно ОС с веб-содержимым внутри.
webPreferences.preloadПуть к preload; выполняется до React и подключает мост.
contextIsolation: trueБезопасная изоляция: страница не подменяет внутренности Electron.
nodeIntegration: falseВ renderer нет require('fs') — только IPC через preload.
VITE_DEV_SERVER_URLВ разработке грузим React с Vite; в production — собранный dist/index.html.
ipcMain.handle('dialog:openFile', ...)Регистрируем обработчик: renderer вызовет канал с тем же именем.
dialog.showOpenDialogНативный диалог выбора файла; возвращает canceled и массив filePaths.
return filePaths[0]Значение уйдёт в Promise на стороне React (await openFile()).

Почему nodeIntegration: false: renderer — по сути веб-страница. Прямой require('fs') в UI даёт полный доступ к диску любому скрипту на странице. Всё опасное остаётся в main; в UI — узкий мост preload.

Имя канала 'dialog:openFile'договорённость: в preload должно быть invoke('dialog:openFile') с тем же строковым литералом.


Preload — мост в React

Файл electron/preload.js:

const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('desktop', {
openFile: () => ipcRenderer.invoke('dialog:openFile'),
});

Разбор

ЭлементСмысл
contextBridgeОфициальный способ «пробросить» API в window renderer.
exposeInMainWorld('desktop', { ... })В React появится window.desktop.
openFileОдна разрешённая операция — открыть диалог файла.
ipcRenderer.invoke(...)Асинхронный запрос в main; результат — Promise (путь или null).

В React вызываете window.desktop.openFile(). Для TypeScript добавьте global.d.ts:

interface DesktopAPI {
openFile: () => Promise<string | null>;
}
interface Window {
desktop: DesktopAPI;
}

Без этой декларации TypeScript будет ругаться на window.desktop.


React — интерфейс

Файл src/App.jsx:

import { useState } from 'react';

export default function App() {
const [count, setCount] = useState(0);
const [filePath, setFilePath] = useState('');

async function handleOpen() {
const path = await window.desktop.openFile();
if (path) setFilePath(path);
}

return (
<main style={{ fontFamily: 'system-ui', padding: 24 }}>
<h1>Electron + React</h1>
<p>Счётчик: {count}</p>
<button type="button" onClick={() => setCount((c) => c + 1)}>
+1
</button>
<button type="button" onClick={handleOpen} style={{ marginLeft: 8 }}>
Открыть файл
</button>
{filePath && <p>Файл: {filePath}</p>}
</main>
);
}

Разбор

ФрагментСмысл
useState(0)Локальное состояние счётчика; main не участвует.
useState('')Строка пути после выбора файла.
async function handleOpeninvoke возвращает Promise — нужен await.
await window.desktop.openFile()Ждём ответ main; пользователь может нажать «Отмена» → null.
if (path) setFilePath(path)Обновляем UI только при реальном выборе.
onClick={() => setCount((c) => c + 1)}Функциональное обновление: актуальное значение из предыдущего рендера.

Содержимое файла на диск пока не читаем — только путь. Следующий шаг в «Что попробовать» — fs.promises.readFile в main и второй канал IPC.


Запуск и отладка

Команда / действиеЗачем
npm run devVite + Electron; в DevTools (Ctrl+Shift+I) видны ошибки React и сети.
DevTools → ConsoleОшибки window.desktop is undefined видны сразу.
DevTools → SourcesПроверить, что preload подключился (вкладка preload иногда отдельно).

Типичные сбои

СимптомВероятная причинаЧто проверить
desktop is undefinedPreload не подключён или опечатка в exposeInMainWorldПуть в preload: path.join(__dirname, 'preload.js'), имя 'desktop'.
IPC не отвечает / зависаетРазные имена каналаСтрока в handle и в invoke должна совпадать побайтно.
Белый экранНеверный URL Vite или пустой distВ dev — работает ли http://localhost:5173 в браузере; в prod — npm run build перед electron ..
require is not definedВключили nodeIntegration: true в renderer и пишут Node в ReactВерните false, перенесите логику в main + preload.
Огромный установщикВ bundle попали devDependenciesНастройте files в electron-builder — 114.md.

Частые ошибки (сводка)

СимптомПричина
require is not definedNode в renderer вместо preload
IPC не отвечаетРазные имена канала в ipcMain.handle и invoke
Огромный установщикЛишние пакеты в сборке

Что попробовать дальше

  1. Читать файл в main: fs.promises.readFile(path, 'utf8') внутри handle, вернуть текст в UI вторым методом desktop.readFile(path).
  2. Меню приложения: Menu.buildFromTemplate в main — пункты «Файл → Открыть» вызывают тот же dialog.
  3. Сборка установщика: electron-builder — см. 114.md.
  4. Тяжёлые вычисления: не в UI-потоке renderer — Web Workers или задача в main с прогрессом через IPC.

Связанные материалы


См. также

Другие статьи этого же раздела в боковом меню (как на странице «О разделе»).