Андрей Друченко
Event-driven AJAX application architecture
В этом материале мы поговорим о том как порядочно организовать структуру и взаимодействие внутри «чистого» AJAX приложения. Под «чистым» AJAX приложением подразумевается то которое работает не вызывая перезагрузок начальной страницы. Т.е. после того как приложение загрузилось, никакие действия пользователя не превращаются в перезагрузку и типовый переход на новую страницу (в частности по переходу по ссылке)Проблема.
Концептуальная проблема заключается в том что в AJAX приложении наблюдается совершенно иной тип взаимодействия пользователя с страницой, а именно — пользователь уже не ходит по обычным ссылкам, и не загружает обычные страницы, вместо этого он вызывает события, которые будучи обработаны браузером в свою очередь вызывают асинхронные AJAX вызовы и динамически обновляют DOM дерево страницы.Такое положение вещей подводит нас к тому что нужно организовывать наше приложение по событийной модели (event-driven), типичный пример этой модели — это программирование десктопных GUI приложений, где операционная система делает вызовы пользовательских обработчиков событий на те или иные события, то же нажатие кнопки(onClick), или появление формы (onShow).
Тем кто был знаком, например, с Delphi должно быть знакомо что-то наподобии:
procedure TFormMain.BtnConvertClick(Sender: TObject);
begin
CurrConvert;
end;
procedure TFormMain.RGCurrencyClick(Sender: TObject);
begin
CurrConvert;
end;
procedure TFormMain.RGDecimalsClick(Sender: TObject);
begin
CurrConvert;
end;
// At start of program, do first "automatic" conversion
procedure TFormMain.FormCreate(Sender: TObject);
begin
CurrConvert;
end;
{ Convert contents of the EDIT to EURO, convert to other currencies, show results in LABELs. }
procedure TFormMain.CurrConvert;
var
Amount, // amount to convert
CFactor: real; // conversion factor
begin
MessageDlg('Illegal input!', mtWarning, [mbOk], 0);
end;
Весь вышеприведенный код занимается тем что обрабатывает события поступившие от экземпляра класса TFormMain который представляет собой не что иное как обычное GUI окно. Точно таким же образом описывается и поведение других GUI контролов (controls) — кнопок, радиокнопок, выпадающих списков(combobox), текстовых полей ввода и т.п.
Заметим, что типовое Delphi приложение состоит из
- Кода (обработчики событий для форм, кнопок, пользовательские модули и функционал, etc.)
- Представления (часть где описывается непосредственное расположение элементов на экране)
Представление создается через визуальный редактор форм, в котором разработчик может точно определить где и какие элементы находятся а также задать им умолчательные параметры.
На этом закончим наше отступление в область настольных GUI приложений и вернемся к веб-приложениям.
Разделение кода и представления.
В случае с веб-приложениями которые интенсивно используют AJAX мы приходим к выводу что Представление это (X)HTML/CSS, а Код это Javascript. Но вспомним типовое веб-приложение и глянем на его код:<table> <tr> <td> <div id="calendarsBottomChrome"> <span class="lk" id="manage_cals_link" onmousedown="_GenSettings(1)"> Управление календарями</span> </div></div> <div style="width:100%; height:2px"> <div style="background: #c3d9ff; border-color: #c3d9ff" class="t2" id="calendarsBottomChrome1"> </div> <div style="background: #c3d9ff; border-color: #c3d9ff" class="t1" id="calendarsBottomChrome2"> </div></div></div> </td> <td id="maincell" valign="top"> <script type="text/javascript"> _setUid("YW5kcmV3LmRydWNoZW5rb0BnbWFpbC5jb20"); _es_setDisplayTz("Europe/Kiev"); _es_setDisplayTzOffsetMillis(7200000); _SE_setTranslatedTzid("(GMT+02:00) Киев"); _setStaticFilePrefix("c0ecebc4c5c8caded65ab40a9e1d029d"); _ol_init(); _ol_checkApp(); _Dispatch(['u','YW5kcmV3LmRydWNoZW5rb0BnbWFpbC5jb20', [['hideInvitations','false',0], ['YW5kcmV3LmRydWNoZW5rb0BnbWFpbC5jb20/color','1',0], ['locale','ru',0], ['weekView5','false',1], ['format24HourTime','true',1], ['dtFldOrdr','DMY',1], ['dG5lcnRyMThka3JjZzBwZ240Z2swNWMza3NAZ3JvdXAuY2FsZW5kYXIuZ29vZ2xlLmNvbQ/color','2',0]] ]); document.write([ '<div id=mainbody>', _GenerateTopFrame('main'), _GenerateGrid(), _GenerateBottomFrame('main'), '</div>', '<div id=cover style="display:none">', _GenerateTopFrame('cover'), '<div id=coverinner></div>', _GenerateBottomFrame('cover'), '</div>' ].join('')); </script> </td></tr> </table> <iframe id="historyFrame" style="display: none; left: 0; position: absolute; top: 0" src="about:blank" scrolling="no"></iframe>
Что плохого мы можем сразу же отметить невооруженным глазом?
- Использование инлайновых обработчиков событий (onmousedown="_GenSettings(1)")
- Яваскриптовая вставка внутри табличного элемента <tr><td> </td></tr> (мешаем HTML c Javascript)
Казалось бы, в данном конкретном случае в подобном нет ничего плохого, но взглянем на другой код:
<div class="rbox"><div><div> <h3>Today's Top People</h3> <img src="http://site.com/img/noimg_48.gif" style="width:48px; height:48px;" alt="" /> <a href="http://site.com/profiles/EndTheDebate" onclick="return false;" id="top_people_viewprofile_EndTheDebate"> EndTheDebate </a><br /> <small>Audios: 1, Playlists: 0<br /> Listens: 72915</small> <br class="clear" /> <script> if ($('top_people_viewprofile_EndTheDebate')) Event.observe('top_people_viewprofile_EndTheDebate', 'click', function(event) { go('home_users_viewprofile_EndTheDebate'); } ); </script> <img src="http://site.com/img/noimg_48.gif" style="width:48px; height:48px;" alt="" /> <a href="http://site.com/profiles/gagaroot" onclick="return false;" id="top_people_viewprofile_gagaroot"> gagaroot </a><br /> <small>Audios: 13, Playlists: 0<br /> Listens: 129</small> <br class="clear" /> <script> if ($('top_people_viewprofile_gagaroot')) Event.observe('top_people_viewprofile_gagaroot', 'click', function(event) { go('home_users_viewprofile_gagaroot'); } ); </script> <img src="http://site.com/13_avatar_user_48.jpg?check=81676" style="width:48px; height:48px;" alt="" /> <a href="http://site.com/profiles/krugvs" onclick="return false;" id="top_people_viewprofile_krugvs"> krugvs </a><br /> <small>Audios: 65, Playlists: 5<br /> Listens: 37</small> <br class="clear" /> <script> if ($('top_people_viewprofile_krugvs')) Event.observe('top_people_viewprofile_krugvs', 'click', function(event) { go('home_users_viewprofile_krugvs'); } ); </script> <img src="http://site.com/85_avatar_user_48.jpg?check=87579" style="width:48px; height:48px;" alt="" /> <a href="http://site.com/profiles/zweb" onclick="return false;" id="top_people_viewprofile_zweb"> zweb </a><br /> <small>Audios: 176, Playlists: 1<br /> Listens: 24</small> <br class="clear" /> <script> if ($('top_people_viewprofile_zweb')) Event.observe('top_people_viewprofile_zweb', 'click', function(event) { go('home_users_viewprofile_zweb'); } ); </script> </div></div></div>
Отделяем компот и мух
В этом коде используется библиотека JS Prototype для обработки событий. При беглом взгляде сложно даже понять что вообще происходит в коде, и почему такая большая каша собственно HTML и яваскрипта. При более детальном анализе можно обнаружить что в данном примере есть некое подобие списка, в элементах которого есть ссылки, и переходы по этим ссылкам перехватываются и вызывается функция go(). Судя по всему, эта функция должна эмулировать обычный переход на новую страницу по ссылке.
Заметим что
* Представление и обработчики — перемешаны, вследствие чего
* Код плохо читаем и сложен для поддержки в будущем (например, когда потребуется поменять либо представление, либо обработчики)
* Использование инлайновых конструкций типа onclick="return false;"
* В данном конкретном примере — нарушение принципа DRY (дублирование кода, обработчик один и тот же, меняются только параметры)
Все это очень плохо, особенно когда наше приложение огромное и содержит множество всяческих элементов управления которые с одной стороны, представлены своим XHTML кодом, а с другой тут же обработчиками Javascript.* Код плохо читаем и сложен для поддержки в будущем (например, когда потребуется поменять либо представление, либо обработчики)
* Использование инлайновых конструкций типа onclick="return false;"
* В данном конкретном примере — нарушение принципа DRY (дублирование кода, обработчик один и тот же, меняются только параметры)
Попробуем улучшить этот код чтобы убрать вышеперечисленные недостатки.
<div class="rbox"><div><div> <h3>Today's Top People</h3> <img src="http://site.com/img/noimg_48.gif" style="width:48px; height:48px;" alt="" /> <a href="http://site.com/profiles/EndTheDebate"> EndTheDebate </a><br /> <small>Audios: 1, Playlists: 0<br /> Listens: 72915</small> <br class="clear" /> <img src="http://site.com/img/noimg_48.gif" style="width:48px; height:48px;" alt="" /> <a href="http://site.com/profiles/gagaroot"> gagaroot </a><br /> <small>Audios: 13, Playlists: 0<br /> Listens: 129</small> <br class="clear" /> <img src="http://site.com/13_avatar_user_48.jpg?check=81676" style="width:48px; height:48px;" alt="" /> <a href="http://site.com/profiles/krugvs"> krugvs </a><br /> <small>Audios: 65, Playlists: 5<br />Listens: 37</small> <br class="clear" /> <img src="http://site.com/85_avatar_user_48.jpg?check=87579" style="width:48px; height:48px;" alt="" /> <a href="http://site.com/profiles/zweb"> zweb </a><br /> <small>Audios: 176, Playlists: 1<br /> Listens: 24</small> <br class="clear" /> </div></div></div>
<script> var hrefs = $$('div.rbox a'); // getting all <a> elements hrefs.each(function(o) { Event.observe(o, 'click', function(event) { ref = o.href.replace(/^http://site.com/profiles//, ''); go('home_users_viewprofile_'+ref); Event.stop(o); // this a significant replacement for onclick="return false;" }); }); </script>
Данный код уже смотрится намного лучше, как минимум мы убрали все недостатки, но самое главное отделили Javascript и HTML. Теперь взглянем на код еще раз и опять подумаем. Многочисленные вызовы Event.observe() по сути, тоже являются дубликатами одного и того же кода, но с разным входным параметром — а именно, DOM элементом <a>. Держа в голове этот момент мы приходим к важному моменту — обработчик событий на все элементы <a> должен быть один. Попробуем переписать наш код с учетом этого момента (html код оставляем неизменным):
<script> var root = $$('div.rbox')[0]; // getting root element <div class="rbox"> Event.observe(root, 'click', function(event) { element = Event.element(event); // getting element which triggered onlclick event if (element.tagName == 'A') { // handling onclicks only for <a> element ref = o.href.replace(/http://site.com/profiles/, ''); go('home_users_viewprofile_'+ref); Event.stop(event); } }); </script>
Понятное дело что после подобной модификации возникает идея сделать подобное не для части какого-то XHTML кода а для всего приложения, создав таким образом единую точку входа для всех событий типа 'onclick' (другие типы событий используются на порядок меньше поэтому в их случае подобного можно и не делать).
Прототип Event-driven AJAX приложения.
Прежде чем реализовывать идею о общей точке входа для приложения по событиям 'onclick' подумаем также о других вещах:- Namespace
- Компонентная структура приложения
- Организация взаимодействия между компонентами
- Интерфейсы для Flex/Flash компонентов, двухсторонние(Flex->DHTML, DHTML->Flex)
- Способ организации DOM элементов вызывающих onClick события
- Решение принципиальных UI проблем связанных с AJAX вообще (в частности решение проблемы с историей, и прямыми ссылками)
1) Namespace
По аналогии с JS фреймворком Yahoo UI придумаем для себя namespace — FOO. Все что касается нашего приложения будем помещать там, включая переменные, ссылки на обьекты, функции и т.п.2) Компонентная структура приложения.
Компонентом назовем логически организованный набор функций и переменных который представляет собой некую сущность в нашем приложении (Это может быть как и обычное текстовое поле ввода, так и JS обьект предоставляющий некоторый функционал). Вспоминая аналогию с Delphi — в нашем случае большинство компонентов будут представлять собой визуальные элементы управления, а также компоненты обьединяющие целые группы элементов (например меню, представляющее собой набор ссылок)3) Организация взаимодействия между компонентами
Взаимодействие компонентов будем организовывать через массив ссылок на все доступные компоненты, сами же компоненты должны быть организованы по принципу черного ящика (хотя private методы в Javascript отсутствуют, предлагается использовать практику которая была в PHP4, а именно — именовать такие методы со знака подчеркивания. Есть методики эмуляции private методов в Javascript но особого смысла их использовать в нашем случае нет )4) Интерфейсы для Flex/Flash компонентов, двухсторонние(Flex->DHTML, DHTML->Flex)
В качестве интерфейса для доступа к JS функциям конкретного Flex компонента будем использовать wrapper обьект в private переменной которого будет ссылка на сам flex обьект в конкретном DOM элементе. Наш код может выглядеть приблизительно так:// Flex prototype object, common methods for all Flex Wrappers FOO.FlexComponent = { _flexRef : null, // a reference to Flex object _initReference: function(flexName) { // initialize if (navigator.appName.indexOf("Microsoft") != -1) { this._flexRef = window[flexName]; } else { this._flexRef = document[flexName]; } } }; // An example usage of Flex component -- mp3player FOO.use(Object.extend(FOO.FlexComponent, { // static properties name : 'TestFlexComponent', // methods onInitialize: function() { this._initReference('mp3player_obj'); }, testCall: function(param) { this._flexRef.refreshAll(); // calling flex method alert('Calling flex method!'); } }));
Вызовы типа Flex->DHTML должны быть организованы по принципу FOO.core.components.ComponentName.onSomeEvent(), т.е. Flex компонент напрямую вызывает конкретный обработчик конкретного компонента.
5) Способ организации DOM элементов вызывающих onClick события
Нам нужно каким-то образом пометить конкретные DOM элементы для того чтобы вызвать на них соответствующий обработчик. Аттрибут ID для этих целей не годится, т.к. у нас может быть один обработчик на несколько элементов, а ID — в пределах документа должен быть уникальный. Далее под наше внимание подпадают атрибуты которые удовлетворяют требованиям:- Присутствуют почти в любом DOM элементе
- Кроссбраузерные
- Потенциально малоопасные из-за нестандартного использования
Исходя из вышесказанного наш XHTML код может выглядеть например, вот так:
<div id="root"> <a href="/test2.html"> Test Link1 </a> <br/> <a href="/test1.html" lang="onTestComponent_SomeEvent"> Test link2 </a> <br/> <p> <a href="/local/next.html"> Another link </a> <br/> <!-- simple test component --> <div> <a href="/controller/action/" lang="onTestComponent_SomeEvent"> Some link </a> <strong id="test" lang="onTestComponent_Yahoo" > YopT! </strong> </div> </p> <a href="/yopt/" lang="onTestFlexComponent_testCall"> Test Flex Call! </a> <div id="mp3player"> Install Flash Player </div> </div>
Обратим внимание на то, какие значения находятся в атрибутах lang некоторых DOM элементов нашего документа. Строка должна обязательно начинаться с префикса on, далее следует название компонента, и после подчеркивания идет конкретный метод компонента который будет обрабатывать событие onclick.
6. Решение принципиальных UI проблем связанных с AJAX вообще (в частности решение проблемы с историей)
Разберем некоторые пункты недостатков AJAX перечисленных Alex Bosworth1) Использование Ajax ради Ajax. Cледует четко понимать для чего вам нужен AJAX. В случае веб-приложения FOO — использование AJAX повсеместно обусловлено только требованием непрерывного проигрывания музыки в флекс-плеере.
2) Поломка кнопки «Назад». Эта проблема решается либо с помощью чисто DHTML библиотеки dHtmlHistory (использование которой крайне не рекомендуется т.к. построена она исключительно на хаках разных браузеров) либо с помощью SWFAddress, которая использует флеш компонент из-за чего работает более надежно.
3) Отсутствие видимого сигнала о происходящем действии. В приложении обязательно должно отображаться его состояние (Идет загрузка, Загрузка закончена и т.п. )
4) Неожиданное мигание и изменение частей страницы. Решение этой проблемы полностью зависит от проектирования UI и реакции приложения на действия пользователя.
5) Отсутствие ссылок, которые я могу послать друзьям или сохранить в закладки. Эта проблема перестает быть актуальной, т.к. с помощью компонента истории можно реализовать восстановление состояния приложения для конретной ссылки (например для http://foo.com/#/media_objects/view/dfglkdfgd435345kfdgfg/ )
6) Не применение локальных изменений к другим частям страницы. Здесь речь прежде всего о Title браузерного окна. Проблема решается тщательным проектированием UI.
7) Асинхронное выполнение групповых операций. Этот случай можно занести в типовые анти-паттерны применения AJAX, обусловленные непониманием того что собой представляет технология.
Групповые операции должны быть атомарными с точки зрения AXHR(Asynchrous Xml Http Request)
8) Блокирование поисковых машин. Проблема может быть решена с помощью определения User-Agent поисковой машины и подсовывания ей нужного контента в зависимости от ссылки. Для браузеров же такая ссылка превращается посредством mod_rewrite в ссылку с якорем. Например a)http://foo.com/media_objects/view/dfglkdfgd435345kfdgfg/ превращается в б)http://foo.com/#/media_objects/view/dfglkdfgd435345kfdgfg/
Нерешенным остается важный момент с оценкой ссылок типа б) поисковыми машинами, которые учитывают ключевые слова в ссылке только до знака '#'.
Пишем код
Сделаем первые наброски кода будущего приложения. В первую очередь организуем обработчик событий onclick таким образом чтобы не нарушать поведение обычных ссылок у которых отсутствует аттрибут lang. Также требуется несложная валидация на то, что атрибут lang имеет форму //onComponentName_methodName//./// define global namespace var FOO = {}; // globals FOO.globals = { default_title: 'FOO - explore the difference!' }; /// core utils FOO.core = {}; // An array holding references to all created Components during // user interaction with an AJAX Application, including those created by remote AJAX calls, etc. FOO.core.components = []; // A procedure that handles all the 'onclick' events received inside 'root' DOM element. // The dispatcher should work only on elements having 'lang' attribute set to // smth. like 'onMycomponent_Eventname', the only validation applied is first prefix 'on' & the presence of '_' // FOO.core.dispatch = function(element) { if ( (element.lang) && (element.lang.match(/^on/)) && (element.lang.match(/_/)) ) { //dispatch event which corresponds to this element var temp = element.lang.split('_'); var componentName = temp[0].replace(/on/, ''); var methodName = 'on'+temp[1]; if( (FOO.core.components[componentName]) && (FOO.core.components[componentName][methodName]) ) { //call corresponding event handler FOO.core.components[componentName][methodName](element); } return true; } return false; }; // Initialization FOO.core.initialize = function(rootElement) { Event.observe(rootElement, 'click', function(event) { obj = Event.element(event); // run event dispatcher res = FOO.core.dispatch(obj); // act as usual url if( (res == false) && ((obj.tagName == 'A') && (obj.href)) ){ // default event handling for <a> } else { Event.stop(event); return false; } }); }
Заметим также, что метод FOO.core.initialize должен быть вызван только в случае полной загрузки страницы, т.е. по событию onLoad, причем его параметр должен быть корневой элемент страницы, в нашем случае это <div id="root">, но это также может быть например, и window.
Теперь придумаем пару методов и согласимся о политике именования методов и компонентов. В каждом компоненте должен быть метод 'onInitialize'. Другие методы могут иметь любые другие имена, но обработчики событий должны иметь вид onSomeEvent, часть следующая после on должна в точности совпадаnm с тем названием события которое указано после символа подчеркивания в атрибуте lang.
// A method for adding Application components for dispatch process // returns true on success FOO.use = function (component) { if (component['name']) { FOO.core.components[component['name']] = component; //initialize if (component['onInitialize']) { component.onInitialize(); } return true; } return false; }
Каждый компонент также обязан иметь уникальное имя. Приведем пример использования функции use() для создания тестового компонента:
<div id="root"> <a href="/rockit.thtml" lang="onTestComponent_SomeEvent"> Test link2 </a> <br/> <a href="/local/next.html"> Usual link </a> <br/> <!-- simple test component --> <div> <a href="/controller/action/" lang="onTestComponent_SomeEvent"> Some link </a> <strong id="test" lang="onTestComponent_Yahoo" > Yo! </strong> </div> </div>
FOO.use({ // static properties name: 'TestComponent', // methods onInitialize: function() { //alert('Initialize!'+this.name); }, onSomeEvent: function(element) { alert("I'm a dispatched event of "+this.name+' component. href= '+element.href); FOO.core.components.history.setValue(element.href); }, onYahoo: function(element) { alert('Yo!'); FOO.core.components.history.setValue(element.href); } });
После выполнения данного кода по кликам на некоторые ссылки должны выскочить соответствующие сообщения, а остальные ссылки должны работать как обычно.
Преимущества предложенного решения
Выделим несколько преимущств исходя из всего вышенаписанного.1) Единая точка входа в приложение. Достигается путем назначения главного обработчика всех событий на корневой DOM элемент всего документа (например, BODY)
2) Разделение Javascript и HTML. Вообще говоря, такое разделение можно проводить не над всем документом а над его логическими частями (например в пределах одного View в модели MVC)
3) Удобность при изменениях в DOM. Здесь речь идет о обработчиках событий на списки элементов, которые в результате действий пользователя изменяются. Для понятности, покажем на примере.
Допустим у нас есть некий список, каждый его элемент представим тэгом <span>. При клике на каждый элемент у нас должно выполняться некое действие. Напишем это в коде, используя обычный подход:
<div id="list"> <span> First Element </span> <span> Second Element </span> <span> Third Element </span> <span> Fourth Element </span> </div>
<script> $$('#list span').each(function(o) { Event.observe(o, 'click', function (event) { alert("I'm clicked!"); }); }); </script>
Теперь представим что этот список из элементов <span> может динамически меняться посредством яваскрипта. Это означает что на каждый новый добавленный элемент вам нужно будет установить новый обработчик события 'onclick'.
Эта проблема исчезает как только события 'onclick' приходят от родительского DOM элемента <div> :
Event.observe('list', 'click', function(event) { element = EVent.element(event); if (element.tagName == "SPAN") { alert("I'm clicked!"); } });
Недостатки
Проблемы могут возникнуть в той части DOM дерева которая стилизована таким образом что родительские элементы полностью визуально перекрыты их наследниками.Например:
<div> <span style="display:block;margin:0;padding:0;border:0;float:left;"> 1 </span> <dfn style="display:block;margin:0;padding:0;border:0;float:left;"> 2 </dfn> </div>
В подобных случаях может случиться так, что родительский элемент <div> не получит нотификации о событии, т.к. его первыми получат элементы либо <span> либо <dfn>
Материалы по теме
На тему onclick="return false;"Handling JavaScript events on multiple elements
Слабое связывание компонентов в JavaScript. Произвольные события
Object Oriented Programming in JavaScript
Злой материал на тему яваскриптовых замыканий(closures)
Event driven application design
Event-driven AJAX
методики эмуляции private методов в Javascript
Последние комментарии:
идеологический вопрос по статье. | Zag |
---|---|
> Под «чистым» AJAX приложением подразумевается то которое работает не > вызывая перезагрузок начальной страницы. Т.е. после того как приложение > загрузилось, никакие действия пользователя не превращаются в перезагрузку > и типовый переход на новую страницу (в частности по переходу по ссылке). В чем глубокий смысл создания «чистого ajax-приложения»? Одно из преимуществ ajax-а – экономия на объеме загружаемой информации (то есть, если надо обновить на странице только пару цифр – не надо ждать загрузки всей страницы). В «чистом ajax-приложении» этого преимущества мы автоматически лишаемся, зато обретаем кучу гемора: нет (если об этом спецом не побеспокоиться, конечно) индикатора загрузки - пользователь не знает, происходит ли вообще что-то; нет (если об этом опять же спецом не побеспокоиться) поддержки back, forward, refresh; не работает (подозреваю, даже если побеспокоиться об этом) Save as..., разве что у клиента броузер с хитрожопым плагином для сохранения страницы с учетом выполненного javascript-а; нормальное взаимодействие с поисковиками опять же требует усилий; попытки избежать вышеуказанной фигни требуют дополнительного кода, что автоматически увеличивает вероятность повышения багов. Безусловно, если взяться за проблему всерьез – эти недостатки можно обойти, обработать, и даже поисправлять все возникающие баги. Однако, главный вопрос остается: ради чего? что мы получаем такого, из-за чего стоило такой ценой отказываться от полной перезагрузки страницы? |
|
Обсудить (комментариев: 1)