Статья представляет собой пошаговое описание моего опыта создания кроссплатформенного десктопного приложения с помощью Webix, Electron и Node.js.
Однажды мне пришла в голову светлая мысль создать десктопное приложение на базе стэка веб-технологий, который мне хорошо знаком. Знаю, что программисты, пишущие под десктоп, обычно используют C++, Java, C#, а на стэк веб-технологий для этих целей смотрят свысока. Но, поскольку я писал приложение для себя, то справедливо решил, что использование знакомых инструментов ускорит процесс. Ну и конечно захотелось «скрестить ужа с ежом» и посмотреть что получится. Если вкратце, то получившийся результат можно запускать и как обычное веб-приложение, и как десктоп.
Код приложения можно
скачать с GitHub.
Что будет делать наше приложение… Это TODO-list (а как же иначе...), в который мы сможем добавлять события, редактировать их и удалять. Событие будет иметь заголовок, содержание, место проведения, дату и приоритет. Также будет доступна возможность перевода интерфейса на русский и английский языки. Назовем его «Data master».
Для создания веб-приложения я использовал
Webix. Он представляет собой кроссплатформенную и кроссбраузерную UI библиотеку, использующие компоненты для быстрого построения приложения с использованием JavaScript синтаксиса. Для компиляции веб-приложения в десктоп использовался
Electron. Это кроссплатформенный инструмент, работающий на базе Node.js и позволяющий компилировать веб-приложение для запуска на различных платформах различной разрядности: Windows, Linux, Mac. Для всяких вспомогательных вещей используются инструменты на базе Node.js.
Начнем со структуры папок. В корне проекта я создал ее в таком виде:
- css — стили
- data — бэкенд
- img — изображения
- js — скрипты JavaScript
После установки модулей Node.js добавится папка «node_modules», для Webix будет использоваться папка «codebase», в папке "~/release/DataMaster" будут версии десктопного приложения для различных платформ.
В итоге структура проекта у меня получилась такая:
Корневая папка проекта должна быть расположена на сервере. В моем случае это Apache.
Итак, для начала я зашел на
страницу загрузки Webix и нажал «Скачать Webix Standard». Это бесплатная версия библиотеки (лицензия «GNU GPLV3»), которая вполне подойдет для наших нужд. Имеется еще коммерческая версия «Webix PRO», которая отличается главным образом расширенной библиотекой виджетов, а также возможностями техподдержки. Из полученного архива «webix.zip» копируем папку «codebase» в корень нашего проекта. Внутри папки «codebase» обратите внимание на файлы webix.js и webix.css. Подключение этих файлов в приложении позволяет работать с Webix. В папке «skins» содержатся css-файлы с темами.
Создадим в корне проекта файл index.html.
index.html<!DOCTYPE HTML>
<html>
<head>
<link rel="stylesheet" href="codebase/skins/contrast.css" type="text/css">
<link rel="stylesheet" href="css/main.css" type="text/css">
<script src="codebase/webix.js" type="text/javascript"></script>
<script src="codebase/i18n/en.js" type="text/javascript"></script>
<script src="codebase/i18n/ru.js" type="text/javascript"></script>
</head>
<body>
<script src="bundle.js" type="text/javascript"></script>
</body>
</html>
Добавим webix.js. Подключение webix.css дает нам возможность использовать стандартную тему. Я же решил подключить симпатичную темненькую тему, которая лежит в «codebase/skins/contrast.css». Также мы подключили файлы из папки «codebase/i18n» для использования встроенной возможности локализации Webix. В индексного файла подключаем файл «bundle.js». Там будет находиться сборка всего нашего js-кода. Для сборки нам понадобится Node.js и Gulp.
Если у вас еще не установлена Node.js, то сделать это можно
отсюда. Командами
$ node -v
и
$ npm -v
проверьте корректность установки Node.js и пакетного менеджера платформы — NPM.
Теперь в папке «js» мы будем создавать основную логику приложения. Файл internalization.js содержит объект для интернационализации интерфейса приложения. По аналогии с уже имеющимися языками (русский, английский) вы можете добавить туда другие языки в случае необходимости.
internalization.jsvar translations = {
// English
"en-US": {
localeName: "en-US",
headerTitle: "Data master",
resetFilters: "Reset filters",
changeLocale: "Change language:",
loadData: "Load data",
addRow: "Add row",
clearSelection: "Clear selection",
deleteRow: "Delete row",
saveData: "Save data",
title: "Title",
noItemSelected: "No item selected",
dataSaved: "Data saved",
reservedButton: "Reserved botton"
},
// Russian
"ru-RU": {
localeName: "ru-RU",
headerTitle: "Мастер данных",
resetFilters: "Сбросить фильтры",
changeLocale: "Сменить язык:",
loadData: "Загрузить данные",
addRow: "Добавить ряд",
clearSelection: "Снять выделение",
deleteRow: "Удалить ряд",
saveData: "Сохранить",
title: "Название",
noItemSelected: "Нет выбранных рядов",
dataSaved: "Данные сохранены",
reservedButton: "Зарезервировано..."
}
};
В файле logic.js содержатся функции, назначение которых вы можете понять из их названия и из комментариев к коду.
logic.js
var defaultLocale = "en-US";
// object from translations.js
var localizator = translations[defaultLocale];
/**
* Get data from backend and fill datatable grid
*/
function getData() {
$$("dataFromBackend").clearAll();
$$("dataFromBackend").load("http://localhost/data_master/data/data.php");
}
/**
* Add new row to datatable
*/
function addRow() {
$$("dataFromBackend").add(
{
title: "-----",
content: "-----",
place: "-----"
//date: "-----",
//priority: "-----"
}
);
}
/**
* Reset selection in datatable grid
*/
function clearSelection() {
$$("dataFromBackend").unselectAll();
}
/**
* Delete selected row
*/
function deleteRow() {
if (!$$("dataFromBackend").getSelectedId()) {
webix.alert(localizator.noItemSelected);
return;
}
//removes the selected item
$$("dataFromBackend").remove($$("dataFromBackend").getSelectedId());
}
/**
* Save data to backend from datatable grid
*/
function saveData() {
var grid = $$("dataFromBackend");
var serializedData = grid.serialize();
webix.ajax().post("http://localhost/data_master/data/save.php", {data: serializedData});
webix.alert(localizator.dataSaved);
}
/**
* Reset filters settings
*/
function resetFilters() {
$$("dataFromBackend").getFilter("title").value = null;
$$("dataFromBackend").getFilter("content").value = null;
$$("dataFromBackend").getFilter("place").value = null;
$$("dataFromBackend").getFilter("date").value = null;
$$("dataFromBackend").getFilter("priority").value = null;
// reload grid
$$("dataFromBackend").clearAll();
$$("dataFromBackend").load("http://localhost/data_master/data/data.php");
}
/**
* Change translation to selected
*/
function changeLocale(locale) {
localizator = translations[locale];
$$("headerContainer").define("template", localizator.headerTitle);
$$("headerContainer").refresh();
$$("resetFiltersContainer").define("value", localizator.resetFilters);
$$("resetFiltersContainer").refresh();
$$("changeLocale").define("label", localizator.changeLocale);
$$("changeLocale").refresh();
$$("loadData").define("value", localizator.loadData);
$$("loadData").refresh();
$$("addRow").define("value", localizator.addRow);
$$("addRow").refresh();
$$("clearSelection").define("value", localizator.clearSelection);
$$("clearSelection").refresh();
$$("deleteRow").define("value", localizator.deleteRow);
$$("deleteRow").refresh();
$$("saveData").define("value", localizator.saveData);
$$("saveData").refresh();
$$("reservedButton").define("value", localizator.reservedButton);
$$("reservedButton").refresh();
webix.i18n.setLocale(locale);
}
/**
* Function for reserved button
*/
function reservedButton() {
// your code...
}
Большинство функций являются обработчиками события «onclick» кнопок. Код функций в основном представляет собой способы работы с Webix-элементами. В общих чертах он интуитивно понятен, если нужна более подродная информация — добро пожаловать на страницу
документации Webix.
В файле objects.js планировалось хранить функции-конструкторы, которые являются обертками над стандартными компонентами Webix. Я планировал поместить туда часто используемые в приложении виджеты, но ограничился лишь одним — наиболее повторяющимся — элементом Button. Чуть ниже я поясню его использование.
objects.js
/**
* Create object with type "Button"
*
* @constructor
*/
function Button(id, value, type, width, onClickFunction) {
this.view = "button";
this.id = id;
this.value = value;
this.type = type;
this.width = width;
this.on = {
"onItemClick": function(){
onClickFunction();
}
}
}
Как это работает… В метод webix.ui() передается объект, имеющий многоуровневую структуру. Свойство view определяет тип виджета Webix: в нашем случае «layout». Этих типов очень много, каждый из них имеет свои методы и свойства. Кроме того, мы можем расширять стандартные компоненты Webix с помощью метода webix.protoUI(), добавляя или переопределяя необходимую нам функциональность. Как видите, работа с Webix осуществляется с помощью Javascript, поэтому весь код работы с этой библиотекой мы помещаем в теги <script>. В методе webix.ui() мы задали последовательность из рядов и колонок, некоторые из которых, в свою очередь, имеют вложенные ряды и колонки, образуя сетку, параметры элементов которой мы можем задать, например, с помощью свойств «width» и «height». В колонки и ряды мы «вкладываем» элементы, настраивая их. Например, вот так можно определить кнопку:
{
view: "button",
id: "loadData",
value: "Load data",
type: "form",
width: 150,
on: {
"onItemClick": function(id, e, trg){
getData();
}
}
Свойство «id» — это свойство Webix «view_id», через которое мы можем получить доступ к элементу с помощью метода $$(). Например, $$(«loadData») вернет нам объект кнопки, описанной в коде выше. Свойство «value» определяет надпись на кнопке, «type» — тип, «width» — ширину. В объекте «on» можно задать обработчики событий для элемента. В примере выше — он один («onItemClick») и соответствует событию «onclick», которое вызывает функцию getData().
Вместо описанной выше структуры для создания элемента Button (в файле «objects.js») я использовал функцию-конструктор. Она создает и возвращает объект Button в соответствии с переданными параметрами. Это позволяет устранить дублирование кода и создавать объект таким образом: new Button(«loadData», «Load data», «form», 150, getData). Кстати, я добавил зарезервированную кнопку для лучшего UX в скомпилированном приложении. Функциональности для нее я не придумал, поэтому можете использовать ее, как вам вздумается.
В нижней части файла components.js имеется код вида $$(«buttonContainer»).define(«css», «buttonContainerClass»). Таким способом мы можем определять и изменять свойства элементов (в примере: добавление атрибута класс со значением «buttonContainerClass»). Способ, приведенный здесь, указан для наглядности. Мы можем изначально инициализировать объект каким либо классом, присвоив значение свойству «css».
Webix имеет различные способы загрузки данных в приложение и в отдельные элементы. В функции getData() я использовал метод load() для загрузки данных в грид. Метод убращается к нашему бэкенду по URL «data/data.php».
Бэкенд нашего приложения до неприличия прост. Я решил не использовать базы данных для такого маленького приложения. Данные хранятся в файле data.json, читаются оттуда с помощью data.php, и сохраняются туда же с помощью save.php.
data.php<?php
$dataFromFile = json_decode(file_get_contents("data.json"));
echo json_encode($dataFromFile);
/*$example_json_data = array(
array (title => "My Fair Lady", year => 1964, votes => 533848, rating => 8.9, rank => 5),
array (title => "Film 1", year => 1984, votes => 933848, rating => 6.9, rank => 4),
array (title => "Film 2", year => 1966, votes => 53848, rating => 4.3, rank => 5),
array (title => "Film 3", year => 1975, votes => 567848, rating => 2.9, rank => 2),
array (title => "Film 4", year => 1981, votes => 433788, rating => 6.3, rank => 1)
);*/
//echo json_encode($example_json_data);
save.php<?php
$data = $_POST["data"];
file_put_contents("data.json", $data);
В коммерческом проекте, конечно, следовало бы сделать различные проверки данных и обработку ошибок, но для наглядности я их опустил. В файл data-example.json я поместил образец структуры данных для загрузки в Webix элемент «datatable», взятый с сайта документации.
data-example.json[
{"title":"My Fair Lady", "year":1964, "votes":533848, "rating":8.9, "rank":5},
{"title":"Film 1", "year":1984, "votes":933848, "rating":6.9, "rank":4},
{"title":"Film 2", "year":1966, "votes":53848, "rating":4.3, "rank":5},
{"title":"Film 3", "year":1975, "votes":567848, "rating":2.9, "rank":2},
{"title":"Film 4", "year":1981, "votes":433788, "rating":6.3, "rank":1}
]
Сохранение данных осуществляется в функции saveData() с помощью AJAX-метода webix.ajax().post(), которому передается URL на бэкенде и объект с данными. Вообще Webix может работать с данными по-разному, принимая и отдавая, например, json или xml. Кстати, в скачанном архиве с версией Webix, кроме папки codebase есть папка samples, в которой можно глянуть примеры работы с различными компонентами системы. В папке «samples/common/connector» есть «родная» основа для работы с бэкендом.
Таким образом, в общих чертах работа нашего приложения выполняется так… Создается сетка с рядами и колонками, в которые помещаются элементы. При взаимодействии с элементами происходят события, и выполняются обработчики, определенные для этих событий. Некоторые из обработчиков используют методы для общения с бэкендом для получения и сохранения данных. Итого мы имеем SPA-приложение, где получение и обработка данных не требуют перезагрузки страницы. Перевод интерфейса приложения осуществляется за счет взятия свойств объекта translations в соответствии с выбранной локалью, задания нового значения свойств «value» элементов и обновления этих элементов. Логика висит на событии «onChange» комбобокса и вызывает нашу функцию changeLocale(). В этой функции мы, кстати, встроенный метод webix.i18n.setLocale(locale), куда передаем локаль из комбобокса. Подробнее можно
глянуть здесь.
Затем нам нужно собрать весь js код в бандл. Но сначала проделаем небольшую подготовительную работу. Создадим в корне проекта файл package.json с основными настройками приложения.
package.json{
"name": "data_master",
"description": "Simple ToDo list with desktop building",
"version": "0.0.1",
"homepage": "https://github.com/paratagas/data_master",
"repository": {
"type": "git",
"url": "git+https://github.com/paratagas/data_master.git"
},
"author": {
"name": "Yauheni Svirydzenka",
"email": "partagas@mail.ru",
"url": "https://github.com/paratagas"
},
"tags": [
"node.js",
"webix",
"electron",
"ToDo list"
],
"main": "main.js",
"scripts": {
"start": "electron .",
"package": "electron-packager ./ DataMaster --all --out ~/release/DataMaster --overwrite"
},
"dependencies": {
"electron-prebuilt": "^0.35.6",
"electron-packager": "^8.4.0"
},
"devDependencies": {
"gulp": "^3.9.0",
"gulp-concat": "^2.6.0",
"gulp-uglify": "^1.2.0",
"gulp-sourcemaps": "^1.5.2"
},
"license": "GPL-3.0"
}
Затем выполним команду
$ npm install
для загрузки необходимых компонентов. В файле gulpfile.js в корне проекта зададим настройки нашей сборки.
gulpfile.js
var gulp = require('gulp'),
uglify = require('gulp-uglify'),
concat = require('gulp-concat');
// to create source mapping
sourcemaps = require('gulp-sourcemaps');
/*
* Collect all js files to one bundle script
* Command: "gulp bundle"
*/
gulp.task('bundle', function() {
// choose any files in directories and it's subfolders
return gulp.src('js/**/*.js')
.pipe(sourcemaps.init())
.pipe(concat('bundle.js'))
.pipe(sourcemaps.write('./'))
//.pipe(uglify())
// output result to current directory
.pipe(gulp.dest('./'));
});
/*
* Watch js files changing and run task
* Command: "gulp watch"
*/
gulp.task('watch', function () {
gulp.watch('./js/**/*.js', ['bundle']);
});
Я закомментировал выполнение минификации, чтобы можно было посмотреть как в итоге выглядит bindle.js со всем нашим кодом. Кроме того, я не использовал минификацию CSS, так как у нас только один файл с небольшим количеством стилей. Вы можете изменить это поведение, если захотите. Теперь мы можем собрать проект, выполнив команду
$ gulp bundle
в корне проекта. В процессе разработки команда
$ gulp watch
позволяет отслеживать изменения js файлов и при наличии таковых выполнять команду
$ gulp bundle
.
Наше веб-приложение уже готово и мы можем запустить его на рабочем сервере. У меня получилось что-то вроде:
Теперь давайте сделаем из него десктоп с помощью Electron. Выбрать и скачать свежую версию можно
здесь. Внутри страницы каждого релиза есть список версий для различных платформ. В нашем «package.json» определены два модуля, которые позволят нам сделать основную работу. Модуль «electron-prebuilt» отвечает за предварительную сборку и запуск приложения. Отдельно модуль можно установить командой
$ npm install --save-dev electron-prebuilt
. В свою очередь, модуль «electron-packager» позволяет компилировать приложения для целевой платформы или для всех возможных платформ. Отдельно устанавливается командой
$ npm install --save-dev electron-packager
.
Обратите внимание на секцию:
"scripts": {
"start": "electron .",
"package": "electron-packager ./ DataMaster --all --out ~/release/DataMaster --overwrite"
},
Определив ее, вы можем запускать предсборку приложения командой
$ npm start
, а компиляцию — командой
$ npm run-script package
. Кстати, если мы изменим команду package, например, на
"package": "electron-packager ./ DataMaster --win32-x64 --out ~/release/DataMaster --overwrite"
то приложение будет скомпилировано для целевой платформы — в нашем случае Windows x64. На данный момент Electron поддерживает платформы: Windows x32/x64, Linux x32/x64/armv7, OS X/x64. Для более полного понимания можно глянуть
документацию.
Создадим в корне проекта файл main.js. Он нужен для настроек Electron.
main.js
/*
* Commands:
* npm init - initialize npm in current directory
* npm install - install modules
* npm install --save-dev electron-prebuilt - install module for pred-build
* npm install --save-dev electron-packager - install module for build
* npm start - to start app
* npm run-script package - to compile app
*/
const electron = require('electron');
// lifecycle of our app
const app = electron.app;
// create window for our app
const BrowserWindow = electron.BrowserWindow;
// To send crash reports to Electron support
// electron.crashReporter.start();
// set global link
// if not, the window will be closed after garbage collection
var mainWindow = null;
/**
* Check that all windows are closed before quiting app
*/
app.on('window-all-closed', function() {
// OS X apps are active before "Cmd + Q" command. Close app
if (process.platform != 'darwin') {
app.quit();
}
});
/**
* Create main window menu
*/
function createMenu() {
var Menu = electron.Menu;
var menuTemplate = [
{
label: 'File',
submenu: [
{
label: 'New window',
click: function() {
createSubWindow();
}
},
{type: "separator"},
{
label: 'Exit',
click: function() {
app.quit();
}
}
]
},
{
label: 'Edit',
submenu: [
{
label: 'Cut',
role: 'cut'
},
{
label: 'Copy',
role: 'copy'
},
{
label: 'Paste',
role: 'paste'
}
]
},
{
label: 'About',
submenu: [
{
label: 'Name',
click: function() {
console.log(app.getName());
}
},
{
label: 'Version',
click: function() {
console.log(app.getVersion());
}
},
{
label: 'About',
click: function() {
console.log('ToDo list');
}
}
]
},
{
label: 'Help',
submenu: [
{
label: 'Node.js docs',
click: function() {
require('electron').shell.openExternal("https://nodejs.org/api/");
}
},
{
label: 'Webix docs',
click: function() {
require('electron').shell.openExternal("http://docs.webix.com/");
}
},
{
label: 'Electron docs',
click: function() {
require('electron').shell.openExternal("http://electron.atom.io/docs/all");
}
}
]
}
];
var menuItems = Menu.buildFromTemplate(menuTemplate);
Menu.setApplicationMenu(menuItems);
}
/**
* Create main window
*/
function createMainWindow() {
mainWindow = new BrowserWindow({
title: "Data master",
resizable: false,
width: 910,
height: 800,
// set path to icon for compiled app
icon: 'resources/app/img/icon.png',
// set path to icon for launched app
//icon: 'img/icon.png'
center: true
// to open dev console: The first way
//devTools: true
});
createMenu();
// load entry point for desktop app
mainWindow.loadURL('file://' + __dirname + '/index.html');
// to open dev console: The second way
//mainWindow.webContents.openDevTools();
// Close all windows when main window is closed
mainWindow.on('closed', function() {
mainWindow = null;
newWindow = null;
});
}
/**
* Create sub menu window
*/
function createSubWindow() {
newWindow = new BrowserWindow({
title: "Go to GitHub",
resizable: false,
// imitate mobile device
width: 360,
height: 640,
icon: 'resources/app/img/mobile.png',
center: true
});
newWindow.loadURL("https://github.com/");
newWindow.on('closed', function() {
newWindow = null;
});
}
/**
* When Electron finish initialization and is ready to create browser window
*/
app.on('ready', function() {
createMainWindow();
});
В комментариях в файле описывается назначение некоторых шагов. В общих чертах мы создаем объект electron, затем окно приложения, после чего настраиваем его. После этого в окно передается основной URL приложения, например, так:
mainWindow.loadURL('file://' + __dirname + '/index.html')
. В нашем случае это файл «index.html» в корне проекта. В конце выражением
mainWindow = null
удаляем ссылку на окно, так как если приложение поддерживает несколько окон, то нужно ловить момент когда следует удалить соответствующий элемент. Закрытие основного окна приложения в нашем случае закрывает (присваивает null) дочернее окно. В настройках также можно задать иконку готового десктоп-приложения. Для этого указываем
icon: 'resources/app/img/icon.png'
, где «resources/app» — место, где хранится исходный код в уже скомпилированном варианте приложения.
Electron также позволяет создавать кастомизированное меню окон приложения. В демонстрационных целях я добавил несколько пунктов меню, чтобы показать, как это делается. Хорошая инфа на эту тему есть
вот тут и в
официальной документации. В пункте меню
File > New window
я добавил новое окно. Оно имитирует просмотр контента на мобильном устройстве и открывает страницу GitHub. Можно задать стартовый URL для нового окна и в нашем веб-приложении, создав таким образом еще одну точка входа, если, например, требуется обособить какой-либо функционал.
В режиме разработки можно активировать Chrome Dev Tools. В комментариях файла «main.js» указана пара способов сделать это.
Выполняем команду
$ npm run-script package
и в "~/release/DataMaster" появляются готовые приложения под различные платформы.
С открытым дополнительным окном вид такой:
В итоге у нас получилось вполне работоспособное приложение, которое может кому-нибудь пригодиться. Код проекта не претендует на лучшие практики разработки (хотя я и старался), но, возможно, кому-то покажутся интересными использованные технологии и их взаимодействие. Собственно, для этого я и написал эту статью. Ведь именно из таких вот статей на Хабре я в свое время узнал об этих инструментах и теперь с удовольствием их использую. Отмечу, что в приложении используется лишь небольшая часть возможностей Webix и Electron. На самом деле эти инструменты обладают довольно обширным функционалом, владение которым позволяет создавать солидные кроссплатформенные приложения.
комментарии (34)
igormatyushkin1, (был удалён)