Создание настольных приложений с помощью Electron. Руководство для начинающих

Поверите ли вы мне, если я скажу, что настольные приложения Slack, VSCode, Atom, Skype, Discord и Whatsapp, которыми вы сегодня пользуетесь, были написаны с использованием HTML, CSS и JavaScript? Вероятно, нет. Потому что обычно эти языки мы используем только для разработки веб-сайтов.

Но как быть в том случае, если в настольном приложении применяется браузер в виде графического интерфейса (то, что видит пользователь)? Тогда мы могли бы применять эти языки для визуализации пользовательского интерфейса приложения. Именно этот вариант и действует в указанных выше приложениях. Эти приложения используют скрытый браузер для визуализации пользовательского интерфейса, поэтому для рисования можно использовать HTML и CSS, а для интерактивности — JavaScript.

Хотя проектирование графического интерфейса с использованием HTML, CSS и JavaScript кажется простым, вам все равно нужен мост между собственными системными API и браузером (в приложении) для ввода-вывода (файловая система), доступа к сети, оборудованию и другим компонентам системы. Без доступа к системным ресурсам наше настольное приложение было бы просто веб-сайтом.

В приложении мы могли бы использовать Node.js, чтобы он действовал как мост между системными ресурсами и браузером. Если код JavaScript, запущенный в браузере, нуждается в доступе к некоторым файлам из системы, он может сделать такой запрос Node.js. Поскольку Node.js может получить доступ к общесистемным ресурсам без каких-либо ограничений, он идеально подходит для работы с бэкендом приложения, без всяких доработок.

Теоретически это похоже на идеальный метод. Но когда дело доходит до реального создания приложения, необходимы познания о разработке собственных настольных приложений, об интеграции с браузером, API-интерфейсах браузера, интеграции Node.js и о многих других вещах. Но не стоит пугаться, ведь у нас есть Electron.

Electron  —  это инструмент для создания кросс-платформенных настольных приложений с веб-технологиями. Electron использует Node.js для бэкенда приложения и браузер Chromium для фронтенда. Мы можем написать графический интерфейс приложения с помощью HTML, CSS и JavaScript. Electron обеспечивает интеграцию между JavaScript, работающим в браузере, и JavaScript, работающим в Node.js.

На самом деле, указанные выше приложения созданы с помощью Electron. Он имеет огромное сообщество, множество сторонних модулей, большинство компаний полагаются на него в своих настольных приложениях. Вот список таких приложений. В нем вы также можете найти Twitch, Zeplin и WebTorrent.

💡 Подобно Electron, для создания настольных приложений вы также можете применять NW.js. Я лично не считаю, что с NW.js комфортно работать. А чтобы узнать различия между этими двумя инструментами, вы можете ознакомиться с этой документацией.

Архитектура Electron

Electron использует многопроцессорную архитектуру для управления состоянием приложения и пользовательским интерфейсом. Процесс main контролирует состояние приложения, а процесс renderer управляет пользовательским интерфейсом. Для выполнения насыщенных графикой задач Electron использует процесс GPU, но это опционально.

При запуске приложения Electron создается процесс main, который для каждого приложения может быть только один. Он может взаимодействовать с собственными системными API и запускать процессы renderer. Процесс renderer отвечает за отображение пользовательского интерфейса с HTML, CSS и JavaScript.

Точкой входа в приложение Electron является файл JavaScript (назовем его main.js), который выполняется внутри процесса main. Процесс main имеет доступ по умолчанию к API Node.js, но не к API Chromium. Поэтому внутри вы можете использовать любой API Node.js, например fs.writeFile () или require (), но не document или window.addEventListener ().

Приложение на основе Electron  —  это нечто похожее на браузер. Процесс main похож на браузер, а процессы renderer похожи на вкладки браузера. Процесс main может открывать столько вкладок браузера, сколько возможно процессов renderer. В среде Electron мы называем вкладку window, поскольку это буквально окно приложения, как показано ниже.

(окно приложения macOS)

Из файла main.js (процесс main) мы создаем окно (как вкладку с нашей предыдущей аналогией), создавая экземпляр класса BrowserWindow, предоставленный пакетом electron. Этот экземпляр создает окно приложения, но по умолчанию оно будет пустым.

💡 Пакет electron предоставляет связанные с Electron модули (например, BrowserWindow) и модули системного API высокого уровня (например, notification, dialog, touch bar и т. д.). Модули, предоставляемые этим пакетом, доступны как в процессе main, так и в процессе renderer. Но не все модули, предоставляемые этим пакетом, являются доступными в этих процессах (подробнее об этом позже).

Мы загружаем HTML-страницу внутри этого окна (win), вызывая метод win.loadFile (path) или win.loadURL(url). После Electron создает процесс renderer и отображает веб-страницу из локального файла или удаленного URL-адреса, который также может использовать протокол file: //.

// main.js
const { BrowserWindow } = require( 'electron' );// create a window
const win = new BrowserWindow( { width: 600, height: 300 } );
win.loadFile( './index.html' );

Если кратко, JavaScript, выполняемый в основном процессе main, является координатором всего приложения, но он не может отображать пользовательский интерфейс. Для этого необходимо создать процесс renderer через интерфейс BrowserWindow.

BrowserWindow  —  это просто интерфейс высокого уровня для отображения и управления веб-страницей, но сам он не может отображать веб-страницы. Интерфейс webContent  —  это низкоуровневый API, который отвечает за визуализацию и управление веб-страницей с помощью процесса визуализации Chromium.

Процесс renderer  —  это процесс операционной системы, связанный с интерфейсом webContents. Вы можете получить доступ к интерфейсу webContents из интерфейса BrowserWindow, используя свойство win.webContents. Чтобы получить PID ОС, примените метод win.webContents.getOSProcessId(), а для получения PID рендеринга Chromium примените метод win.webContents.getProcessId ().

💡 Поскольку BrowserWindow  —  это просто высокоуровневый интерфейс для интерфейса webContents, вы можете вызвать win.webContents.loadFile () вместо win.loadFile () для рендеринга HTML-страницы внутри окна.

На первый взгляд, на каждое окно приходится один процесс renderer, но это не всегда так. Процесс renderer создается тогда, когда мы загружаем веб-страницу внутри окна. Окно может порождать обработку другим renderer с помощью тега HTML <webview>, предоставленного Electron, или добавляя объект BrowserView в существующее окно.

Процесс Main против Renderer

Совершенно очевидно, что процесс main управляет состоянием приложения, а процессы renderer управляют пользовательским интерфейсом приложения. Хотя, есть несколько элементов, к которым процесс renderer не может иметь доступ.

По умолчанию, процесс main имеет доступ к API Node.js. При создании процесса renderer через new BrowserWindow (options) мы можем указать, должен ли процесс renderer также иметь доступ к API-интерфейсам Node.js. Это контролируется свойством options.webPreferences.nodeIntegration.

Значение этого свойства по умолчанию  —  false, поэтому нам нужно установить его в true, если мы хотим, чтобы JavaScript, работающий внутри процесса renderer, имел доступ к таким API-интерфейсам Node.js, как fs.writeFile () или require ().

💡 Причина, по которой Electron не предоставляет доступ к API-интерфейсам Node.js из процессов renderer по умолчанию, заключается в том, что вредоносный сторонний код JavaScript, например сторонняя библиотека, может получить доступ к системе пользователя, вызвав API-интерфейсы Node. Поэтому будьте осторожны.

Кроме того, вы не можете создать окно браузера из другого окна. Это означает, что JavaScript, выполняющийся внутри процесса renderer, не может создать экземпляр new BrowserWindow (options) для создания нового окна. Создание окон должно выполняться процессом main.

💡 Однако вызов window.open () создаст другое окно, создав экземпляр класса BrowserWindow, который запустит процесс renderer. Но это окно имеет ограниченную функциональность, как описано здесь.

Еще одно отличие состоит в том, что все собственные системные API (внешние для Node.js) доступны только из процесса main. Например, если вы хотите открыть системный диалог, то можете использовать модуль dialog внутри JavaScript, запущенного в процессе main, а не внутри окна (процесс renderer).

Есть несколько модулей, доступных из процессов main и renderer. Ниже их список вместе с API. Документацию по каждому модулю вы можете найти на этой странице.

(Таблица доступности модулей / источник: gist.github.com)

Взаимодействие между процессами

Теперь, когда мы выяснили, что процесс main предназначен для координации работы приложения и связи между системными ресурсами и приложением, а процесс renderer для визуализации пользовательского интерфейса приложения, осталось выяснить, как мы можем справиться с ограничениями.

Например, как мы можем открыть другое окно приложения, создав экземпляр класса BrowserWindow? Допустим пользователь нажимает в окне на элемент <button>, чтобы увидеть изображение с элементами выбора масштаба. Именно здесь на первый план выходит межпроцессная коммуникация (IPC).

Мы можем запросить процесс main открыть окно, отправив сообщение из процесса renderer или вызвав функцию, написанную в процессе main (JavaScript работает в процессе main). Взаимодействие между процессами main и renderer происходит через модули IPC. Процесс main может обращаться к модулю ipcMain, а процесс renderer  —  к модулю ipcRenderer.

Модуль ipcMain может прослушивать события процесса renderer, используя функцию ipcMain.on (), или обрабатывать выполнение функции, вызванной процессом renderer, через метод ipcMain.handle(). Точно так же модуль ipcRenderer может отправлять сообщения процессу main с помощью метода ipcRenderer.send() или вызывать процедуру внутри main с помощью ipcRenderer.invoke().

Удачный пример процесса main, зависящий от процесса renderer  —  статус сетевого подключения. Мы можем прослушивать события в браузере online или offline (документация) и получить сетевое подключение устройства пользователя в процессе renderer. Но мы не можем сделать то же самое в процессе main. Таким образом, единственным для процесса main способом получения статуса сетевого подключения является запрос к renderer. Следовательно, renderer может отправлять сообщение процессу main каждый раз, когда этот статус изменяется (с модулями ipcMain и ipcRenderer).

💡 Эти коммуникации могут происходить синхронно или асинхронно в зависимости от методов модуля.

Если вы хотите посылать сообщения между двумя процессами renderer, тогда вам нужно будет использовать main в качестве моста. Или, если у вас есть доступ к интерфейсу webContents процесса renderer, тогда вы можете использовать метод webContents.send() для отправки сообщения другому процессу renderer, а также использовать метод ipcRenderer.on() для прослушивания этого сообщения.

Фоновые задания

Если вы хотите выполнить задачу, интенсивно использующую ЦП, то вам не нужно запускать внутри процесса рендеринга код JavaScript с длительным исполнением, который заблокировал бы намертво пользовательский интерфейс на несколько секунд или минут. Однако, если вы это сделаете, предположим, случайно, тогда другие окна не будут затронуты, поскольку они выполняются в отдельных процессах renderer.

Но все же блокировка пользовательского интерфейса нежелательна, поскольку это плохо сказывается на взаимодействии с пользователем. Что хорошего в таком приложении, если пользователь не может взаимодействовать с пользовательским интерфейсом? Можно сказать, давайте использовать процессы main, поскольку они не связаны с renderer, но, к сожалению, блокировка main приведет к тому, что также не станет работать и ваше приложение (и все его окна).

Но чтобы выполнять фоновые задания, не нужно изобретать колесо. Все-таки, у нас есть несколько хитростей в запасе. Для запуска фоновой задачи мы можем использовать API WebWorker из JavaScript, запущенный внутри процесса renderer. Мы также могли бы использовать встроенный модуль Node.js worker_threads для запуска рабочего потока точно так же, как потока WebWorker.

Для выполнения фоновых и ресурсоемких задач некоторые специалисты могут предложить окна frameless transparent, которые пользователи не видят. Однако, когда есть способы получше, такик уловки следует избегать.

Веб-воркер не имеет автоматического доступа к API Node.js. При создании объекта BrowswerWindow, нам нужно установить значение true для свойства options.webPreferences.nodeIntegrationInWorker. Это свойство работает независимо от свойства nodeIntegration.

💡 Хотя мы можем получить доступ ко всем API-интерфейсам Node.js внутри веб-воркера, получить доступ к собственным API-интерфейсам Electron невозможно. Для получения дополнительной информации о многопоточности в Electron прочтите эту документацию.

Общее описание

Все, что мы обсуждали до сих пор  —  это просто обзор архитектуры Electron. А для тех, кто хочет знать больше, можно почитать вот эту документацию. Подведем итог проведенному обсуждению с помощью упрощенной диаграммы.

Вся архитектура процессов и коммуникаций Electron основана на архитектуре Chromium. Например, концепция процессов main и renderer, а также механизма связи IPC. 

Создание приложения с помощью Electron

Давайте создадим простое настольное приложение, отображающее в одном окне сообщение “Hello World!”, и имеющее кнопку, которая открывает окно со случайным изображением из Интернета. Вначале создадим каталог проекта. Мой каталог проекта называется electron-lessons. Структура проекта выглядит следующим образом, но мы рассмотрим каждый из этих файлов по отдельности.

Теперь, чтобы создавать настольные приложения с помощью Electron, нам не нужна причудливая настройка или установка приложения. Нам нужен лишь пакет electron, который мы можем установить с помощью команды npm install. Итак, давайте инициализируем package.json в каталоге проекта и установим electron:

Есть еще один важный момент, который нельзя забывать. Точкой входа в приложение Electron является файл JavaScript. Этот файл JavaScript выполняется внутри процесса main и открывает окна приложений. Итак, создадим внутри проекта файл main.js:

(main.js / источник:gist.github.com)

Рассмотрим наши действия в этом файле. Сначала импортируем из пакета electron модули app, BrowserWindow и ipcMain. Мы уже рассматривали модули BrowserWindow и ipcMain, поэтому пока оставим их и сосредоточимся на модуле app.

Модуль app  —  это интерфейс для управления жизненным циклом приложения. Если посмотреть на таблицу доступности модулей, то видим, что этот модуль доступен только процессу main. Этот модуль позволяет нам прослушивать события жизненного цикла приложения, вызывать методы для изменения состояния приложения и считывать состояние приложения через статические свойства.

В приведенном выше файле main.js мы прослушиваем событие приложения ready, используя метод app.on(event, handler). Это событие запускается только один раз, когда Electron завершает инициализацию приложения и можно безопасно создавать окна.

💡 Вместо app.on (‘ready’, handler) вы также можете использовать метод app.whenReady (), который возвращает промис. В обработчике then () этого промиса вы можете делать то, что мы делаем в приведенном выше файле main.js.

Событие window-all-closed генерируется после того, как закрывается последнее открытое окно приложения. В обработчике этого события мы должны выйти из приложения, вызвав метод app.quit(), за исключением случаев использования macOS, поскольку это противоречит поведению macOS по умолчанию.

💡 Переменная process поступает из Node.js, поскольку процесс main всегда имеет доступ к API-интерфейсам Node.js. Следовательно process.platform дает название базовой платформы (ядра или ОС), на которой выполняется Node.js.

Событие activate специфично для macOS и запускается при нажатии значка приложения на док-станции (и в других местах). Поскольку закрытие всех окон в macOS не приводит к закрытию приложения (процесс main), нам нужно будет открыть окно (если ни одно из них не открыто) при повторной активации приложения.

Когда приложение готово, мы вызываем функцию openWindow (), которая создает окно размером 600x300 и по умолчанию открывает файл hello.html, который находится в том же каталоге, что и main.js. Если значением аргумента type является image, он создаст другое окно и откроет в этом окне файл image.html.

Поскольку мы хотим открывать окно image, когда пользователь нажимает кнопку в окне default, нам нужно отправить сообщение из окна default (процесс renderer) в main.js (процесс main). Мы слушаем это сообщение в main.js с помощью метода ipcMain.on(). Когда main.js получает событие app: display-image, он открывает окно image.

💡 Для настраиваемых событий я использую формат app: <event-name>, но соглашения о выборе названий для событий не существует. Единственное указание, что это должна быть строка.

(hello.html / источник:gist.github.com)

Наш файл hello.html  —  это лишь простая веб-страница в формате HTML. При загрузке веб-страницы внутри окна Electron рекомендует добавлять заголовок отклика (Content Security Policy). При загрузке локального HTML-файла заголовок CSP может быть добавлен с помощью мета-тега. Этот шаг необходим для того, чтобы добавить дополнительный уровень безопасности от XSS-атаки, как описано здесь.

Элемент <title> определяет заголовок документа, и Electron использует это значение для заголовка окна, которое мы увидим через минуту. При создании экземпляра BrowserWindow вы также можете указать заголовок окна через свойство title.

На нашей странице есть сообщение Hello World!, заключенное внутри элемента <h1> , и кнопка с идентификатором #btn. Элемент <script> имеет вид JavaScript, который немного отличается от обычного JavaScript, используемого в браузере.

Как уже отмечалось, процесс renderer также имеет доступ к API-интерфейсам Node.js, а это значит, что любой JavaScript, работающий внутри окна, также имеет доступ к этим API-интерфейсам. Вот почему мы можем использовать функцию Node require внутри элемента <script>. Такое же правило применяется, если мы импортируем внешний файл JavaScript с помощью элемента <script src=”path-to-file.js”>.

В этом JavaScript мы импортируем модуль ipcRenderer из пакета electron, чтобы отправить сообщение app: display-image в процесс main, когда пользователь нажимает кнопку Click to open a photo. Вы также можете получить доступ к таким встроенным модулям Node, как fs или path, а также к глобальным переменным, таким как process и прочие.

Итак, запустим наше приложение Electron. Для этого нам нужно ввести команду $ electron [options] <path>. Она предусмотрена пакетом electron. Здесь path  —  это путь к main.js или каталогу, содержащему index.js (который будет точкой входа), или к каталогу, содержащему package.json. Этот package.json должен иметь точку входа (файл JavaScript), путь указанный в поле main.

В нашем случае мы выполним $ electron .  —  команду из каталога проекта. Поскольку в этом каталоге у нас есть package.json, нам нужно указать путь к main.js, который является точкой входа приложения через поле main в package. json.

Поскольку electron является локальным пакетом, нельзя запустить команду $ electron . из терминала. Вы можете запустить $ npx electronic., но было бы удобнее добавить скрипт внутри package.json, как мы это сделали ранее, и ввести команду $ npm run start для запуска приложения.

(Окно hello.html / hello)

Когда мы запускаем команду $ npm run start, Electron выполняет файл main.js и запускается событие ready, которое, в свою очередь, открывает окно с файлом hello.html, подобное представленному выше. Обратите внимание на заголовок окна. Эта команда заблокирует терминал, откуда она была выполнена, и любые операторы console.log, выполняемые внутри main, будут регистрироваться здесь.

Так как у меня macOS, то я открываю Activity Monitor, чтобы увидеть процессы, запущенные этим приложением.

(Activity Monitor)

Процесс с названием Electron является основным  —  main. Процесс с названием Electron Helper (Renderer)  —  это процесс визуализации renderer. Поскольку у нас открыто только одно окно, есть только один процесс renderer. Другие процессы являются просто вспомогательными (helper process) для процесса main.

Нажмем кнопку Click to open a photo, отображаемую в этом окне, и посмотрим, что произойдет. Ничего. Чтобы увидеть проблему, нам нужно открыть DevTools. Чтобы прикрепить DevTools к текущему окну, нажмите Cmd + Option + I (macOS), и в этом окне откроется панель DevTools.

💡 Панель DevTools по сути является еще одним окном, поэтому она запустит еще один процесс renderer, который вы можете увидеть в Activity Monitor. Если вы хотите открыть DevTools программно, используйте метод webContents.openDevTools ().

(окно hello)

JavaScript, работающий внутри этого окна, имеет исключение. Это говорит о том, что переменная require в строке №. 24 файла hello.html не существует. Но функция require () должна там присутствовать, поскольку она предоставляется API-интерфейсами Node.js. Так или иначе JavaScript, работающий внутри процесса renderer, не имеет доступа к API Node.js. Причина заключается в том, что при создании окна мы не установили для свойства nodeIntegration значение true.

// открыть окно
const openWindow = ( type ) => {
  const win = new BrowserWindow( {
    ...
    webPreferences: {
      nodeIntegration: true,
    },
  } );  ...
}

С приведенным выше изменением все должно отлично работать. Но прежде чем нажать кнопку и увидеть результат, посмотрим, как выглядит файл image.html.

<!DOCTYPE html>
<html lang='en'>
    <head>
        <meta charset='UTF-8'>
        <meta name='viewport' content='width=device-width, initial-scale=1.0'>
        <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';" />
        <title>Image Window</title>

        <style>
            body {
                font-size: 0;
                background-color: #eee;
                font-family: sans-serif;
                text-align: center;
                vertical-align: middle;
                padding: 0;
                margin: 0;
            }
        </style>
    </head>
    <body>
        <!-- random image -->
        <h3 id='loading' style="font-size: 14px;">Loading...</h3>
        <img id='img' src='' style="display: none;">

        <script>
            const path = require( 'path' );
            const axios = require( 'axios' );
            const sharp = require( 'sharp' );

            // fetch a random image
            axios.get( 'https://source.unsplash.com/random', {
                responseType: 'arraybuffer',
            } )
            .then( ( response ) => {

                // create Buffer object
                const buffer = Buffer.from( response.data, 'binary' );

                // get output path of the image
                const outPath = path.resolve( __dirname, 'img.jpeg' );

                // resize image using sharp
                sharp( buffer )
                .resize( 600, 300 )
                .toFile( outPath ) // save image
                .then( () => {

                    // display image in `img` tag
                    document.getElementById( 'img' ).setAttribute( 'src', `file://${ outPath }` );
                    document.getElementById( 'img' ).setAttribute( 'style', '' );
                    document.getElementById( 'loading' ).setAttribute( 'style', 'display:none;' );
                } );
            } );
        </script>
    </body>
</html>

В image.html, мы извлекаем случайное изображение (jpeg) из URL-адреса https://source.unsplash.com/random с использованием пакета axios. Затем мы конвертируем это изображение в объект Buffer. Интерфейс Buffer предоставляется Node.js. Затем мы изменяем размер этого изображения с помощью пакета sharp. Поэтому нам нужно установить эти пакеты:

$ npm install — save axios sharp

Поскольку переменная _dirname, предоставленная Node.js, указывает на абсолютный путь image.html в файловой системе, путь outPath —  это <project-path> /img.jpeg. Когда мы сохраняем это изображение с помощью .Tofile(outpath), оно сохраняется в каталоге проекта.

Протокол file: // используется для загрузки файла из локальной файловой системы. URL-адресом атрибута src является file://<absolute-path-to-img.jpeg, это означает, что элемент <img /> будет отображать недавно сохраненный файл. Поэтому, когда мы нажмем на кнопку Click to open a photo, откроется новое окно и отобразится загруженный файл. Вы также должны проверить файл img.jpeg в каталоге проекта.

(окно изображения)

Найти использованные в уроке примеры можно в ветке basic этого репозитория GitHub.

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Uday Hiwarale: A beginner’s guide to creating desktop applications using Electron

Предыдущая статьяМутационное тестирование: создай мутанта и прокачай тест
Следующая статья8 полезных на практике приёмов для веб-разработчиков