Приложение на 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) между процессами.

arch.svg

Например:

  • 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). Это сильно экономит время на создание структуры проекта и настройку конфигурационных файлов вручную.

Для создания нового проекта используем команду:

Shell
# Создание нового проекта
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

TypeScript
declare global {
    interface Window {
        electron: ElectronAPI
        api: {
            listPages: () => Promise<ListPagesResponse>
            // ... other methods
        }
    }
}

Теперь TypeScript будет понимать вызовы window.api.listPages и подсказывать аргументы.

Дополнительно необходимо добавить наш API в preload (index.ts). Это необходимо для того, чтобы preload мог получить доступ к API и передать его в main процесс.

TypeScript
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

TypeScript
  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

TypeScript
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.

TypeScript

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:

TypeScript
createRoot(document.getElementById('root')!).render(
    <ApplicationContextProvider>
        <StrictMode>
            <App/>
        </StrictMode>
    </ApplicationContextProvider>
)

Пользовательский интерфейс

После того как мы реализовали логику получения данных, нам нужно отобразить их в пользовательском интерфейсе. Описывать как делать приложения на React нет смысла, это не так важно. Для вызова API в компонентах необходимо использовать ранее созданный useGlobalState хук.

TypeScript
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
TypeScript
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 getDB

schema.sql

SQL
CREATE TABLE IF NOT EXISTS settings (
    id INTEGER PRIMARY KEY,
    key TEXT UNIQUE NOT NULL,
    value TEXT NOT NULL
);

Безопасность

Этот раздел – отдельная тема вне объёма этой статьи, не хочется усложнять. Но стоит упомянуть, что при разработке Electron важно понимать, что ваше приложение по сути является браузером с расширенными правами и доступом к ОС.

Что нужно учитывать:

  1. Context Isolation и Node Integration:
    • nodeIntegration(false) запрещает использование require('electron') или require('fs') прямо в коде React. Это предотвращает ситуацию, когда злоумышленник через XSS (внедрение скрипта) получает доступ к файловой системе.
    • contextIsolation(true) гарантирует, что скрипты preload работают в отдельном контексте. Это значит, что переменные, определённые в preload, не могут быть перезаписаны кодом со страницы (например, через window.api = maliciousObject).
  2. Выполнять валидацию данных в IPC. В обработчиках ipcMain.handle мы принимаем данные от рендерера, их необходимо проверять. Всё делается так же, как и в обычных веб-приложениях: клиенту не доверяем.
  3. Content Security Policy (CSP) – это механизм безопасности, позволяющий контролировать ресурсы, которые могут быть загружены в приложении. Можно установить через meta-теги или заголовки HTTP. Пример:
HTML
<meta
        http-equiv="Content-Security-Policy"
        content="default-src 'self'; 
            script-src 'self'; 
            style-src 'self' 'unsafe-inline'; 
            img-src 'self' data:"/>
  1. Отключение переходов по внешним ссылкам. По умолчанию, если пользователь нажмёт на ссылку <a href="http://scammer.com">, то Electron откроет этот сайт внутри окна приложения. Нужно перехватывать навигацию и открывать внешние ссылки в системном браузере.
TypeScript
 mainWindow.webContents.setWindowOpenHandler((details) => {
    shell.openExternal(details.url)
    return {action: 'deny'}
})
  1. Обновление зависимостей (npm audit).

Сборка приложения

Когда приложение готово, его нужно упаковать в архив, удобный для распространения и установки. Для этого используется инструмент electron-builder. Он поддерживает сборку под все платформы, создание инсталляторов (NSIS для Windows, DMG для macOS, Deb/AppImage для Linux) и настройку автообновлений.

Конфигурация electron-builder задается в файле electron-builder.yml. Пример ниже:

electron-builder.yml
TypeScript
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, то сборку можно выполнить одной из следующих команд:

Shell
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 может быть медленным, особенно если выполняются интенсивные вычисления.
  • Возможно, это не минус, но внешний вид отличается от интерфейса оболочки ОС.

ℹ️ Примечание

Пример проекта доступен в открытом доступе.

Electron Reactjs Desktop Nodejs Devmix