Приложение на Electron и ReactJS
опубликовано: ⏰ 12Ранее делал примеры на Spring Boot с ReactJS и Go с HTMX, но это были клиент-серверные приложения. Иногда требуется создать простое десктопное приложение, которое должно использоваться локально. Один из вариантов – использовать Electron.
Введение
Вариантов для создания десктопных приложений довольно много: Qt/wxWidgets/GTK/MFC (C++), Swing/JavaFX (Java), WPF/WinForms (C#), Electron (JS/TS), Flutter (Dart) и другие. Но если есть опыт работы с JavaScript/TypeScript и ReactJS, то Electron может быть хорошим выбором.
Electron – это фреймворк, который позволяет использовать веб-технологии (HTML, CSS, JavaScript) для создания кроссплатформенных десктопных приложений. Под капотом он объединяет движок Chromium (для отображения интерфейса) и Node.js (для взаимодействия с операционной системой). Это означает, что любое веб-приложение может стать десктопным практически без изменений в коде интерфейса. В качестве библиотеки для пользовательского интерфейса можно использовать любую, в моём случае ReactJS, так как сейчас использую везде.
Одно из важных преимуществ – единая кодовая база с веб-приложениями. Например, для React может быть ранее созданная библиотека компонентов, которые можно использовать в десктопном приложении. Также можно использовать те же самые стили, что и в веб-приложении. И всё это собирается под разные платформы и выглядит везде одинаково. Про сам React (Vue, Angular) смысла говорить нет, это огромные экосистемы со множеством готовых библиотек и инструментов.
Естественно есть и доступ к нативным API: к файловой системе, меню, трейю, уведомлениям и другим системным функциям.
Конечно, есть и минусы:
- Поскольку вместе с приложением распространяется весь движок Chromium, минимальный размер установщика обычно составляет около 100–150 МБ.
- Наверное, самая большая проблема – это потребление памяти: каждое окно Electron – по сути отдельный процесс браузера, что может потреблять больше оперативной памяти, чем нативные приложения.
- Придётся дополнительно описывать мосты (IPC) между UI и NodeJS.
Архитектура Electron
Для доступа к бизнес-логике в обычном веб-приложении мы просто вызываем серверный API, в нативном всё расположено в одном процессе. В Electron сложнее: процесс рендеринга (frontend) не должен иметь прямого доступа к ОС. Поэтому всё взаимодействие с ОС должно происходить через специальный мост (IPC) между процессами.
Например:
- UI-компонент хочет сохранить файл.
- Он отправляет сообщение через
ipcRendererвpreloadскрипт. preloadскрипт проверяет запрос и пересылает его вmainпроцесс черезipcRenderer.invoke.mainпроцесс выполняет операцию с файловой системой через модульfs.- Результат возвращается обратно по цепочке на UI.
Создание приложения
Пример будет на основе React + TypeScript + Vite в виде простого приложения для управления контентом сайта на Hugo.
Настройка окружения
Самый простой способ начать работу с Electron – это использовать шаблоны. Есть официальные – minimal-repro и Electron Forge. Но в своих проектах, в качестве основной системы сборки, использую Vite. Для интеграции Vite с Electron существует хороший шаблон electron-vite. Он предварительно настроен для разделения процессов (main, preload, renderer). Это сильно экономит время на создание структуры проекта и настройку конфигурационных файлов вручную.
Для создания нового проекта используем команду:
# Создание нового проекта
npm create @quick-start/electron@latest sample-hugo-content-manager -- --template react-ts
# Переход в директорию проекта
cd sample-hugo-content-manager
# Установка зависимостей
npm install
# Запуск приложения
npm run devВ итоге получим готовую структуру проекта, который можно запустить командой npm run dev.

Всё, дальше можно добавлять функционал. Полный код приложения приводить не стану, так как сильно увеличивает объем статьи, поэтому будут ссылки на репозитории и только важные моменты.
IPC (API)
Все действия по взаимодействию с основным процессом в Electron происходят через IPC (Inter-Process Communication). В Electron есть два основных процесса: главный процесс (main process) и процесс рендеринга (renderer process). Для взаимодействия с основным процессом и отображением данных в UI, используются ipcMain и ipcRenderer.
Если кратко, то:
ipcMain– это API для обработки событий из процесса рендеринга (backend).ipcRenderer– это API для отправки событий в основной процесс (frontend -> backend).
В первоначальном шаблоне будет одна простая функция ping, которая покажет в консоли сообщение pong. Так как нам нужно сделать редактор содержимого сайта, то понадобятся следующие функции:
- Получение списка страниц (pages).
- Получение содержимого отдельной страницы.
- Сохранение содержимого страницы.
- Получение статистики по страницам (языки).
- Выбор каталога с содержимым сайта.
Ниже пример описания взаимодействия для получения списка статей (listPages).
Поскольку использую TypeScript, то нужно сообщить компилятору, что в объекте window появится новое свойство api, через которое мы будем обращаться к функциям Electron. В браузере такого свойства нет, поэтому нужно создать файл объявлений index.d.ts.
index.d.ts
declare global {
interface Window {
electron: ElectronAPI
api: {
listPages: () => Promise<ListPagesResponse>
// ... other methods
}
}
}Теперь TypeScript будет понимать вызовы window.api.listPages и подсказывать аргументы.
Дополнительно необходимо добавить наш API в preload (index.ts). Это необходимо для того, чтобы preload мог получить доступ к API и передать его в main процесс.
const api = {
listPages: async (): Promise<ListPagesResponse> => {
return ipcRenderer.invoke('content:list-pages')
}
// ... other methods
}
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('api', api)
} catch (error) {
console.error(error)
}
} else {
window.electron = electronAPI
window.api = api
}contextBridge.exposeInMainWorld – это метод, который позволяет передать данные из preload в main процесс. Он позволяет создать глобальный объект window.api, который будет доступен в процессе renderer с теми методами, которые мы разрешили использовать. Мы не передаем сюда никакой логики взаимодействия с ОС напрямую. Вся логика работы с файлами останется в contentManager.ts.
В моём случае мапинг бизнес-логики расположен в ipcs.ts, чтобы не перегружать index.ts.
ipcs.ts
ipcMain.handle('content:list-pages', async (): Promise<ListPagesResponse> => {
try {
const pages = await scanContentDir()
return {pages}
} catch (error) {
console.error('Error listing pages:', error)
return {pages: [], error: String(error)}
}
})В ipcMain регистрируем обработчик content:list-pages, который будет вызван из рендера. Внутри вызываем scanContentDir(), который возвращает список страниц.
Сама логика реализована в contentManager.ts.
contentManager.ts
export async function scanContentDir(): Promise<PageContent[]> {
try {
const files = await recursivelyListFiles(getContentPath())
const markdownFiles = files.filter((f) => f.relativePath.endsWith('.md'))
const pages: PageContent[] = []
for (const file of markdownFiles) {
try {
const content = await fs.readFile(file.path, 'utf-8')
const page = await parsePageContent(content, file, true)
if (page) {
pages.push(page)
}
} catch (error) {
console.error(`Error reading file ${file.path}:`, error)
}
}
return pages
} catch (error) {
console.error('Error scanning content directory:', error)
throw error
}
}В scanContentDir мы получаем список всех файлов в директории контента, фильтруем только .md файлы и парсим их содержимое.
Использование API в рендере
Для взаимодействия с процессом main из процесса renderer буду использовать глобальный контекст React, в котором будет инкапсулирована вся логика работы с API. Вероятно, лучше использовать альтернативные менеджеры состояния (MobX, Redux, Zustand и т.д.), но для простоты примера реализовано с использованием React.Context и хука useGlobalState.
export interface ApplicationState {
pages: PageContent[] // ... other properties
}
export interface ApplicationContextProviderProps {
children: React.ReactNode
}
const ApplicationContext: React.Context<ApplicationState | undefined> = createContext<
ApplicationState | undefined
>(undefined)
export const ApplicationContextProvider: React.FC<ApplicationContextProviderProps> = (
props: ApplicationContextProviderProps
) => {
const [pages, setPages] = useState<PageContent[]>([])
// ... other state variables
const loadPages = useCallback(async () => {
const result = await window.api.listPages()
// ... other logic
setPages(result.pages)
}, [])
return (
<ApplicationContext.Provider value={{
pages, // ... other properties
}}>{props.children}</ApplicationContext.Provider>
)
}
export function useGlobalState(): ApplicationState {
const ctx = useContext(ApplicationContext)
if (!ctx) {
throw new Error('useGlobalState must be used within a ApplicationContextProvider')
}
return ctx as ApplicationState
}Соответственно ApplicationContextProvider должен быть обёрнут вокруг App:
createRoot(document.getElementById('root')!).render(
<ApplicationContextProvider>
<StrictMode>
<App/>
</StrictMode>
</ApplicationContextProvider>
)Пользовательский интерфейс
После того как мы реализовали логику получения данных, нам нужно отобразить их в пользовательском интерфейсе. Описывать как делать приложения на React нет смысла, это не так важно. Для вызова API в компонентах необходимо использовать ранее созданный useGlobalState хук.
function App(): React.JSX.Element {
const {
pages, loadPages, // ... other state variables
} = useGlobalState()
// ... other state variables
useEffect(() => {
loadPages()
}, [loadPages])
// ... other logic
return (
<ContentPagesTreeWidget
pages={pages}
// ... other props
/>
);
}При открытии приложения, мы вызываем loadPages() для получения данных из API. Все данные будут автоматически сохранены в глобальном состоянии и переданы в компонент ContentPagesTreeWidget (левая панель для навигации по страницам сайта) для отображения.
ℹ️ Примечание
Пример содержимого сайта в каталоге проекта samples.
Далее долго работаем или не очень долго и смотрим, что получилось.



База данных
Для хранения состояния приложения необязательно использовать базу данных, можно использовать Electron Store или localStorage. Но в моём случае решил использовать SQLite для примера того, как можно реализовать работу с базой для сохранения пути к содержимому сайта.
Основная логика инициализации базы данных находится в файле database/index.ts основного процесса. В этом файле мы создаём подключение к базе данных, инициализируем таблицы и выполняем миграции. Сама схема для первичной инициализации находится в файле resources/database/schema.sql.
database/index.ts
let db: Database.Database | null = null
function getSchemaPath(): string {
const outPath = join(__dirname, '../../resources/database/schema.sql')
if (!fs.existsSync(outPath)) {
throw Error('Schema file not found: ' + outPath)
}
return outPath
}
export function getDB(): Database.Database {
if (db) {
return db
}
const userDataPath = app.getPath('userData')
const dbPath = join(userDataPath, 'hcm.db')
const isNewDb = !fs.existsSync(dbPath)
const schema = isNewDb ? fs.readFileSync(getSchemaPath(), 'utf-8') : null
db = new Database(dbPath)
db.pragma('journal_mode = WAL')
if (isNewDb) {
db.exec(schema)
}
return db
}
export function closeDB(): void {
if (db) {
db.close()
db = null
}
}
export default getDBschema.sql
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY,
key TEXT UNIQUE NOT NULL,
value TEXT NOT NULL
);Безопасность
Этот раздел – отдельная тема вне объёма этой статьи, не хочется усложнять. Но стоит упомянуть, что при разработке Electron важно понимать, что ваше приложение по сути является браузером с расширенными правами и доступом к ОС.
Что нужно учитывать:
- Context Isolation и Node Integration:
nodeIntegration(false) запрещает использованиеrequire('electron')илиrequire('fs')прямо в коде React. Это предотвращает ситуацию, когда злоумышленник через XSS (внедрение скрипта) получает доступ к файловой системе.contextIsolation(true) гарантирует, что скрипты preload работают в отдельном контексте. Это значит, что переменные, определённые в preload, не могут быть перезаписаны кодом со страницы (например, через window.api = maliciousObject).
- Выполнять валидацию данных в IPC. В обработчиках
ipcMain.handleмы принимаем данные от рендерера, их необходимо проверять. Всё делается так же, как и в обычных веб-приложениях: клиенту не доверяем. - Content Security Policy (CSP) – это механизм безопасности, позволяющий контролировать ресурсы, которые могут быть загружены в приложении. Можно установить через meta-теги или заголовки HTTP. Пример:
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' data:"/>- Отключение переходов по внешним ссылкам. По умолчанию, если пользователь нажмёт на ссылку
<a href="http://scammer.com">, то Electron откроет этот сайт внутри окна приложения. Нужно перехватывать навигацию и открывать внешние ссылки в системном браузере.
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return {action: 'deny'}
})- Обновление зависимостей (
npm audit).
Сборка приложения
Когда приложение готово, его нужно упаковать в архив, удобный для распространения и установки. Для этого используется инструмент electron-builder. Он поддерживает сборку под все платформы, создание инсталляторов (NSIS для Windows, DMG для macOS, Deb/AppImage для Linux) и настройку автообновлений.
Конфигурация electron-builder задается в файле electron-builder.yml. Пример ниже:
electron-builder.yml
appId: com.gihub.devmix.hugocontentmanager
productName: hugo - content - manager
directories:
buildResources: build
files:
-'!**/.vscode/*'
- '!src/*'
- '!electron.vite.config.{js,ts,mjs,cjs}'
- '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}'
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
asarUnpack:
-resources/**
// ... packages
npmRebuild: false
publish:
provider: generic
url: https://example.com/auto-updates
electronDownload:
mirror: https://npmmirror.com/mirrors/electron/
В files указываем, какие файлы включать в сборку. Обычно это скомпилированные файлы из папки dist (результат работы Vite) и исходники main/preload процессов, так как они выполняются в Node-окружении.
Если приложение создавалось через electron-vite, то сборку можно выполнить одной из следующих команд:
npm run build:linux
npm run build:win
npm run build:macЭти команды сначала компилируют код через Vite, а затем запускают electron-builder. После завершения в папке dist появятся установочные файлы:
- .exe для Windows.
- .dmg для macOS.
- .AppImage, .deb, snap для Linux.
Заключение
В целом, Electron + ReactJS вполне подходит для создания полноценных desktop-приложений, особенно если есть опыт с React/Node.js и наработанные шаблоны. Дополнительно UI можно впоследствии мигрировать в Web-приложение переписав логику из основного процесса, если потребуется.
При этом есть некоторые негативные моменты:
- Приходится писать код для IPC для описания взаимодействия между основным процессом и процессом рендеринга. В нативных приложениях этого не требуется.
- Чувствуются задержки при работе с UI, это всё-таки браузерное приложение, а не нативное. Да и сам JavaScript может быть медленным, особенно если выполняются интенсивные вычисления.
- Возможно, это не минус, но внешний вид отличается от интерфейса оболочки ОС.
ℹ️ Примечание
Пример проекта доступен в открытом доступе.