Поверите ли вы мне, если я скажу, что настольные приложения 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
, поскольку это буквально окно приложения, как показано ниже.
Из файла 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. Документацию по каждому модулю вы можете найти на этой странице.
Взаимодействие между процессами
Теперь, когда мы выяснили, что процесс 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
:
Рассмотрим наши действия в этом файле. Сначала импортируем из пакета 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
— это лишь простая веб-страница в формате 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
для запуска приложения.
Когда мы запускаем команду $ npm run start
, Electron выполняет файл main.js
и запускается событие ready
, которое, в свою очередь, открывает окно с файлом hello.html
, подобное представленному выше. Обратите внимание на заголовок окна. Эта команда заблокирует терминал, откуда она была выполнена, и любые операторы console.log
, выполняемые внутри main
, будут регистрироваться здесь.
Так как у меня macOS, то я открываю 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 ()
.
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.
Читайте также:
- Как создавать собственные хуки на React
- Основные принципы темного UI-дизайна
- Аспектно-ориентированное программирование в JavaScript
Читайте нас в Telegram, VK и Дзен
Перевод статьи Uday Hiwarale: A beginner’s guide to creating desktop applications using Electron