На сегодняшний день существует великое множество javascript фреймворков, по многим из них написаны горы документации. Я хотел бы остановиться на фреймворке, который, по неизвестной мне причине, не пользуется особой популярностью у российских разработчиков.
Фреймворк называется qooxdoo. Произносится «куксду» (кому удобнее английская транстрипция: ['kuksdu:]).
На Хабре было несколько попыток написать про этот фреймворк, но все они свелись к новостям о выходе новой версии или к парам абзацев в статьях типа «смотрите каких фреймворков понаписали». Я несколько лет работаю с qooxdoo и мне хотелось бы восполнить этот пробел.
Вкратце о том, что это за зверь и с чем его едят. Больше всего фреймворк «похож» на ExtJS. Слово «похож» не совсем корректное, в данном случае, но я затрудняюсь подобрать более подходящее. Разработка проекта началась в недрах компании 1&1 Internet AG. Первая публичная версия 0.1 вышла в 2005 году. Текущая актуальная версия 4.1, про нее и будем вести речь. Некоторые моменты позволяют мне сказать, что разработчики вдохновлялись Qt при создании своего детища. Основная изначальная задумка разработчиков дать возможность разрабатывать веб приложения людям без знания HTML, CSS и DOM модели. С помощью qooxdoo это возможно. Новичок, которому требуется написать, например, админку в виде single page application (далее SPA) и который не знает ни одного HTML тега, а про CSS вообще никогда не слышал, действительно, сможет это сделать. Это не означает, что знания HTML, CSS и DOM модели вдруг резко стали не нужны. Просто, поначалу, можно обойтись без них. Что будет особенно интересно, например, разработчикам десктопных приложений, которым потребовалось что-то сделать в вебе.
В конце статьи вы можете найти немного полезных ссылок. В частности, там есть ссылки на разнообразные демо и примеры реального использования фреймворка в продакшене.
Просто так рассказывать о фреймворке скушно и неинтересно. К тому же, разработчики это уже и так сделали. Поэтому я решил сделать какой-нибудь простенький пример для демонстрации возможностей фреймворка. Многие знают о проекте http://todomvc.com/. Вот и мы с вами сделаем что-то максимально похожее с использованием qooxdoo. Справедливости ради, разработчики уже сделали демо todo листа, но это не совсем то, что нам нужно.
Итак, приступим.
Следует оговориться, что рассматриваться будет именно SPA (Desktop в терминологии qooxdoo). Для начала необходимо загрузить qooxdoo sdk. Сделать это можно по этой ссылке. SDK содержит ряд утилит, которые позволяют сгенерировать шаблон приложения и собрать отладочную и релизную версию, собрать автоматическую докуентацию, туты и т.д. Ознакомиться с документацией по тулчейну можно тут.
Для создания шаблона приложения мы запустим:
create-application.py --name=todos
После этой операции мы получим следующий каркас приложения:
Приложение сгенерируется не пустым. Оно будет иметь кнопку, по нажатию на которую будет выводиться alert.
Основной файл Application.js будет содержать следующий код:
/**
* This is the main application class of your custom application "todos"
*
* @asset(todos/*)
*/
qx.Class.define("todos.Application", {
extend : qx.application.Standalone,
members : {
/**
* This method contains the initial application code and gets called
* during startup of the application
*
* @lint ignoreDeprecated(alert)
*/
main : function() {
// Call super class
this.base(arguments);
// Enable logging in debug variant
if (qx.core.Environment.get("qx.debug")) {
// support native logging capabilities, e.g. Firebug for Firefox
qx.log.appender.Native;
// support additional cross-browser console. Press F7 to toggle visibility
qx.log.appender.Console;
}
/*
-------------------------------------------------------------------------
Below is your actual application code...
-------------------------------------------------------------------------
*/
// Create a button
var button1 = new qx.ui.form.Button("First Button", "todos/test.png");
// Document is the application root
var doc = this.getRoot();
// Add button to document at fixed coordinates
doc.add(button1, {left: 100, top: 50});
// Add an event listener
button1.addListener("execute", function(e) {
alert("Hello World!");
});
}
}
});
Для того, чтобы увидеть задумку авторов, нам нужно будет собрать дебажную или продакшн версию приложения.
Первый вариант получится, если перейти в папку проекта и запустить:
./generate.py source
второй можно получить после запуска:
./generate.py build
После этого грузим в браузере соответствующий index.html файл и видим вот такую картинку:
На кнопку можно нажимать, а можно не нажимать. Можно грабить корованы. На этом возможности приложения заканчиваются. Чуда не случилось, дальше придется писать код, чем мы, собственно, и займемся.
Для нетерпеливых сразу даю ссылку на github с готовым вариантом, с которым можно играться. Для того, чтобы получилось, кроме исходников с гитхаба необходимо скачать SDK и прописать в файле config.json корректный путь «QOOXDOO_PATH». После чего необходимо собрать требуемую версию, как описано выше.
Ну а мы рассмотрим процесс создания приложения последовательно, в его естественном виде.
Для начала мы создадим заготовку для виджета окна для нашего todo листа и безжалостно удалим из Application.js все что там нам нагенерировал генератор. Получится у нас следущее.
Window.js
qx.Class.define("todos.Window", {
extend : qx.ui.window.Window,
construct: function(){
this.base(arguments);
this.set({
caption: "todos",
width: 480,
height: 640,
allowMinimize: false,
allowMaximize: false,
allowClose: false
});
this.addListenerOnce("appear", function(){
this.center();
}, this);
}
});
Application.js
/**
* @asset(todos/*)
*/
qx.Class.define("todos.Application", {
extend : qx.application.Standalone,
members : {
main : function() {
// Call super class
this.base(arguments);
var wnd = new todos.Window;
wnd.show();
}
}
});
После сборки мы увидим вот такую красоту:
Пора наполнить ее смыслом. Нам будут необходимы следующие элементы: тулбар, запись todo листа и элемент добавления записи в лист. Запись todo листа является повторяющимся элементом, оформим его в виде отдельного виджета. Тулбар и элемент добавления записи в лист можно сделать как отдельными виджетами, что позволит их использовать повторно, так и частью Window. Тулбар сделаем отдельным виджетом, а элемент добавления записи оставим частью Window, чтобы показать, что можно и так и так. Сделаем все вышеописанное и наполним виджеты жизнью.
ToDo.js
qx.Class.define("todos.ToDo", {
extend: qx.ui.core.Widget,
events : {
remove : "qx.event.type.Event"
},
properties: {
completed: {
init: false,
check: "Boolean",
event: "completedChanged"
},
appearance: {
refine: true,
init: "todo"
}
},
construct: function(text){
this.base(arguments);
var grid = new qx.ui.layout.Grid;
grid.setColumnWidth(0, 20);
grid.setColumnFlex(1, 1);
grid.setColumnWidth(2, 20);
grid.setColumnAlign(0, "center", "middle");
grid.setColumnAlign(1, "left", "middle");
grid.setColumnAlign(2, "center", "middle");
this._setLayout(grid);
this._add(this.getChildControl("checkbox"), {row: 0, column: 0});
this._add(this.getChildControl("text-container"), {row: 0, column: 1});
this._add(this.getChildControl("icon"), {row: 0, column: 2});
this.getChildControl("label").setValue(text);
this.addListener("mouseover", function(){this.getChildControl("icon").show();}, this);
this.addListener("mouseout", function(){this.getChildControl("icon").hide();}, this);
this.getChildControl("icon").hide();
this.getChildControl("text-container").addListener("dblclick", this.__editToDo, this);
},
members : {
// overridden
_createChildControlImpl: function(id) {
var control;
switch(id) {
case "checkbox":
control = new qx.ui.form.CheckBox;
this.bind("completed", control, "value");
control.bind("value", this, "completed");
break;
case "text-container":
control = new qx.ui.container.Composite(new qx.ui.layout.HBox);
control.add(this.getChildControl("label"), {flex: 1});
break;
case "label":
control = new qx.ui.basic.Label;
control.bind("value", control, "toolTipText");
break;
case "textfield":
control = new qx.ui.form.TextField;
control.addListener("keypress", function(event){
var key = event.getKeyIdentifier();
switch(key) {
case "Enter":
this.__editComplete();
break;
case "Escape":
this.__editCancel();
break;
}
}, this);
control.addListener("blur", this.__editComplete, this);
break;
case "icon":
control = new qx.ui.basic.Image("todos/icon-remove-circle.png");
control.addListener("click", function(){
this.fireEvent("remove");
}, this);
break;
}
return control || this.base(arguments, id);
},
__editToDo : function() {
var tc = this.getChildControl("text-container");
var tf = this.getChildControl("textfield");
tc.removeAll();
tc.add(tf, {flex: 1});
tf.setValue(this.getChildControl("label").getValue());
tf.focus();
tf.activate();
},
__editComplete : function() {
this.getChildControl("label").setValue(this.getChildControl("textfield").getValue());
this.__editCancel();
},
__editCancel : function() {
var tc = this.getChildControl("text-container");
tc.removeAll();
tc.add(this.getChildControl("label"), {flex: 1});
}
}
});
StatusBar.js
qx.Class.define("todos.StatusBar", {
extend: qx.ui.core.Widget,
events: {
removeCompleted: "qx.event.type.Event"
},
properties: {
todos: {
init: [],
check: "Array"
},
filter: {
init: "all",
check: ["all", "active", "completed"],
event: "filterChanged"
}
},
construct: function() {
this.base(arguments);
var grid = new qx.ui.layout.Grid;
grid.setColumnWidth(0, 100);
grid.setColumnFlex(1, 1);
grid.setColumnWidth(2, 130);
grid.setColumnAlign(0, "left", "middle");
grid.setColumnAlign(1, "center", "middle");
grid.setColumnAlign(2, "right", "middle");
grid.setRowHeight(0, 26);
this._setLayout(grid);
this._add(this.getChildControl("info"), {row: 0, column: 0});
this._add(this.getChildControl("filter"), {row: 0, column: 1});
this._add(this.getChildControl("remove-completed-button"), {row: 0, column: 2});
this.update();
},
destruct: function() {
this.__rgFilter.dispose();
},
members : {
__rgFilter: null,
update: function() {
var todosCount = this.getTodos().length;
var itemsLeft = this.getTodos().filter(function(item){return !item.getCompleted();}).length;
this.getChildControl("info").setValue("<b>"+itemsLeft+"</b> items left");
if (itemsLeft === todosCount) {
this.getChildControl("remove-completed-button").exclude();
} else {
this.getChildControl("remove-completed-button").setLabel("Clear completed ("+(todosCount-itemsLeft)+")");
this.getChildControl("remove-completed-button").show();
}
},
// overridden
_createChildControlImpl: function(id) {
var control;
switch(id) {
case "info":
control = new qx.ui.basic.Label;
control.setRich(true);
break;
case "filter":
control = new qx.ui.container.Composite(new qx.ui.layout.HBox);
control.add(this.getChildControl("rb-filter-all"));
control.add(this.getChildControl("rb-filter-active"));
control.add(this.getChildControl("rb-filter-completed"));
this.__rgFilter = new qx.ui.form.RadioGroup(
this.getChildControl("rb-filter-all"),
this.getChildControl("rb-filter-active"),
this.getChildControl("rb-filter-completed")
);
this.__rgFilter.addListener("changeSelection", this.__onFilterChanged, this);
break;
case "rb-filter-all":
control = new qx.ui.form.RadioButton("All");
control.setUserData("value", "all");
break;
case "rb-filter-active":
control = new qx.ui.form.RadioButton("Active");
control.setUserData("value", "active");
break;
case "rb-filter-completed":
control = new qx.ui.form.RadioButton("Completed");
control.setUserData("value", "completed");
break;
case "remove-completed-button":
control = new qx.ui.form.Button;
control.addListener("execute", function(){
this.fireEvent("removeCompleted");
}, this);
break;
}
return control || this.base(arguments, id);
},
__onFilterChanged : function(event) {
this.setFilter(event.getData()[0].getUserData("value"));
}
}
});
Window.js
qx.Class.define("todos.Window", {
extend: qx.ui.window.Window,
properties: {
appearance: {
refine: true,
init: "todo-window"
},
todos: {
init: [],
check: "Array",
event: "todosChanged"
},
filter: {
init: "all",
check: ["all", "active", "completed"],
apply: "__applyFilter"
}
},
construct: function(){
this.base(arguments);
this.set({
caption: "todos",
width: 480,
height: 640,
allowMinimize: false,
allowMaximize: false,
allowClose: false
});
this.setLayout(new qx.ui.layout.VBox(2));
this.add(this.getChildControl("todo-writer"));
this.add(this.getChildControl("todos-scroll"), {flex: 1});
this.add(this.getChildControl("statusbar"));
this.addListenerOnce("appear", function(){
this.center();
}, this);
},
destruct : function() {
var todoItems = this.getTodos();
for (var i= 0, l=todoItems.length; i<l; i++) {
todoItems[i].dispose();
}
},
members : {
// overridden
_createChildControlImpl: function(id) {
var control;
switch(id) {
case "todo-writer":
var grid = new qx.ui.layout.Grid;
grid.setColumnWidth(0, 20);
grid.setColumnFlex(1, 1);
grid.setColumnAlign(0, "center", "middle");
grid.setColumnAlign(1, "left", "middle");
control = new qx.ui.container.Composite(grid);
control.add(this.getChildControl("checkbox"), {row: 0, column: 0});
control.add(this.getChildControl("textfield"), {row: 0, column: 1});
break;
case "checkbox":
control = new qx.ui.form.CheckBox;
control.addListener("changeValue", this.__onCheckAllChanged, this);
break;
case "textfield":
control = new qx.ui.form.TextField;
control.setPlaceholder("What needs to be done?");
control.addListener("keydown", this.__onWriterTextFieldKeydown, this);
break;
case "todos-scroll":
control = new qx.ui.container.Scroll;
control.add(this.getChildControl("todos-container"));
break;
case "todos-container":
control = new qx.ui.container.Composite(new qx.ui.layout.VBox(1));
break;
case "statusbar":
control = new todos.StatusBar;
control.bind("filter", this, "filter");
this.bind("todos", control, "todos");
control.addListener("removeCompleted", this.__onRemoveCompleted, this);
break;
}
return control || this.base(arguments, id);
},
__onWriterTextFieldKeydown : function(event) {
var key = event.getKeyIdentifier();
switch(key) {
case "Enter":
var value = event.getTarget().getValue();
if (value) {
event.getTarget().setValue("");
var todo = new todos.ToDo(value);
this.getTodos().push(todo);
todo.addListenerOnce("remove", this.__onTodoRemove, this);
todo.addListener("completedChanged", this.__onTodoCompletedChanged, this);
this.__updateTodoList();
this.getChildControl("statusbar").update();
var cbAll = this.getChildControl("checkbox");
cbAll.removeListener("changeValue", this.__onCheckAllChanged, this);
cbAll.setValue(false);
cbAll.addListener("changeValue", this.__onCheckAllChanged, this);
}
break;
case "Escape":
event.getTarget().setValue("");
break;
}
},
__updateTodoList : function() {
var toList;
switch(this.getFilter()) {
case "all":
toList = this.getTodos();
break;
case "active":
toList = this.getTodos().filter(function(item){return !item.getCompleted();});
break;
case "completed":
toList = this.getTodos().filter(function(item){return item.getCompleted();});
break;
}
var container = this.getChildControl("todos-container");
container.removeAll();
toList.forEach(function(item){
container.add(item);
});
},
__applyFilter : function() {
this.__updateTodoList();
},
__onTodoRemove : function(event) {
var todo = event.getTarget();
this.setTodos(this.getTodos().filter(function(item){return item !== todo;}));
this.getChildControl("todos-container").remove(todo);
todo.dispose();
this.getChildControl("statusbar").update();
},
__onTodoCompletedChanged : function() {
var cbAll = this.getChildControl("checkbox");
cbAll.removeListener("changeValue", this.__onCheckAllChanged, this);
cbAll.setValue(this.getTodos().length === this.getTodos().filter(function(item){return item.getCompleted();}).length);
cbAll.addListener("changeValue", this.__onCheckAllChanged, this);
this.__updateTodoList();
this.getChildControl("statusbar").update();
},
__onCheckAllChanged : function(event) {
var value = event.getData();
this.getTodos().forEach(function(todo){
todo.removeListener("completedChanged", this.__onTodoCompletedChanged, this);
todo.setCompleted(value);
todo.addListener("completedChanged", this.__onTodoCompletedChanged, this);
}, this);
this.__updateTodoList();
this.getChildControl("statusbar").update();
},
__onRemoveCompleted : function() {
var completed = this.getTodos().filter(function(item){return item.getCompleted();});
this.setTodos(this.getTodos().filter(function(item){return !item.getCompleted();}));
completed.forEach(function(todo){
this.getChildControl("todos-container").remove(todo);
todo.dispose();
}, this);
this.getChildControl("statusbar").update();
this.getChildControl("checkbox").setValue(false);
}
}
});
На этом этапе мы получили вполне себе функционально законченное приложение. Есть только один нюанс, оно страшно, как атомная война:
Попробуем привести его к пристойному виду. Оговорюсь сразу, дизайнер из меня, как из козла балерина, поэтому задача максимум для меня добиться, чтобы наш todo лист выглядел просто аккуратно, без изысков.
За внешний вид приложения в qooxdoo отвечают темы. Фреймворк поставляется с 4 темами. Темы можно расширять, переписывать и т.д. Тема в qooxdoo имеет 5 составляющих и определяется таким образом:
qx.Theme.define("todos.theme.Theme", {
meta : {
color : todos.theme.Color,
decoration : todos.theme.Decoration,
font : todos.theme.Font,
icon : qx.theme.icon.Tango,
appearance : todos.theme.Appearance
}
});
Подробнее про темы можно почитать тут.
Итак, сделаем следующие изменения:
Appearance.js
/**
* * @asset(qx/icon/Tango/*
*/
qx.Theme.define("todos.theme.Appearance", {
extend : qx.theme.simple.Appearance,
appearances : {
"todo-window" : {
include : "window",
alias : "window",
style : function(){
return {
contentPadding: 0
};
}
},
"checkbox": {
alias : "atom",
style : function(states) {
var icon;
if (states.checked) {
icon = "todos/checked.png";
} else if (states.undetermined) {
icon = qx.theme.simple.Image.URLS["todos/undetermined.png"];
} else {
icon = qx.theme.simple.Image.URLS["blank"];
}
return {
icon: icon,
gap: 8,
cursor: "pointer"
}
}
},
"radiobutton": {
style : function(states) {
return {
icon : null,
font : states.checked ? "bold" : "default",
textColor : states.checked ? "green" : "black",
cursor: "pointer"
}
}
},
"checkbox/icon" : {
style : function(states) {
return {
decorator : "checkbox",
width : 16,
height : 16,
backgroundColor : "white"
}
}
},
"todo-window/checkbox" : "checkbox",
"todo-window/textfield" : "textfield",
"todo-window/todos-scroll" : "scrollarea",
"todo-window/todo-writer" : {
style : function() {
return {
padding : [2, 2, 0, 0]
};
}
},
"todo-window/statusbar" : {
style : function() {
return {
padding : [ 2, 6],
decorator : "statusbar",
minHeight : 32,
height : 32
};
}
},
"todo-window/statusbar/info" : "label",
"todo-window/statusbar/rb-filter-all" : "radiobutton",
"todo-window/statusbar/rb-filter-active" : "radiobutton",
"todo-window/statusbar/rb-filter-completed" : "radiobutton",
"todo-window/statusbar/remove-completed-button" : {
include : "button",
alias : "button",
style : function() {
return {
width : 150,
allowGrowX : false
};
}
},
"todo/label" : {
include : "label",
alias : "label",
style : function(states) {
return {
font : (states.completed ? "line-through" : "default"),
textColor : (states.completed ? "light-gray" : "black"),
cursor : "text"
};
}
},
"todo/icon" : {
style : function() {
return {
cursor : "pointer"
};
}
},
"todo/text-container" : {
style : function() {
return {
allowGrowY : false
};
}
},
"todo/checkbox" : "checkbox"
}
});
Color.js
qx.Theme.define("todos.theme.Color",
{
extend : qx.theme.simple.Color,
colors :
{
"light-gray" : "#BBBBBB",
"border-checkbox": "#B6B6B6"
}
});
Decoration.js
qx.Theme.define("todos.theme.Decoration", {
extend : qx.theme.simple.Decoration,
decorations : {
"statusbar" : {
style : {
backgroundColor : "background",
width: [2, 0, 0, 0],
color : "window-border-inner"
}
},
"checkbox" : {
decorator : [
qx.ui.decoration.MBorderRadius,
qx.ui.decoration.MSingleBorder
],
style : {
radius : 3,
width : 1,
color : "border-checkbox"
}
}
}
});
Font.js
qx.Theme.define("todos.theme.Font",
{
extend : qx.theme.simple.Font,
fonts :
{
"line-through" :
{
size : 13,
family : ["arial", "sans-serif"],
decoration : "line-through"
}
}
});
После этого наш TODO лист будет выглядеть так:
На этом пока можно закончить. Я не затронул огромное количество вопросов, но это просто невозмозможно в рамках одной статьи. Хотелось познакомить с фреймворком на примере небольшой задачи, как можно меньше углубляясь в детали. Подробнее можно почитать по приведенным ссылкам. Обо всех ошибках и опечатках прошу писать в личку. Спасибо за внимание.
Полезные ссылки:
Домашняя страница qooxdoo: http://qooxdoo.org/
Страница загрузки SDK: http://qooxdoo.org/downloads
Разнообразные демо: http://qooxdoo.org/demos
Примеры использования: http://qooxdoo.org/community/real_life_examples
SPA туториал: http://manual.qooxdoo.org/current/pages/desktop/tutorials/tutorial-part-1.html
Код примера на гитхабе: https://github.com/VasisualyLokhankin/todolist_qooxdoo
Автор: Dolios