В первых пяти частях мы построили полноценный почтовый клиент Ext Mail: создали Universal-приложение, модели и хранилища, Grid и Reader, дерево меток, динамический тулбар, форму создания письма и работу с черновиками. Приложение работало и на десктопе (Classic Toolkit), и на мобильных устройствах (Modern Toolkit).
Однако у нашего приложения был один существенный недостаток: данные загружались из статических JSON-файлов и никогда не сохранялись. При обновлении страницы все изменения — отправленные письма, удалённые черновики, новые метки — исчезали.
В шестой части мы исправим это: подключим приложение к настоящему REST API (созданному с помощью RAD Server) и настроим загрузку и сохранение данных. Это последний шаг перед тем, как приложение станет полностью production-ready.
Все примеры кода взяты из оригинальной книги Stuart Ashworth (Sencha MVP) и адаптированы для русскоязычного читателя.
🎯 Что мы сделаем
-
Создадим утилитарный класс BaseUrl для централизованного управления адресом API.
-
Переключим Contacts Store на REST-эндпоинт.
-
Настроим Labels Store для загрузки дерева меток с API.
-
Проведём рефакторинг модели Message — добавим связь
hasManyдля меток. -
Подключим Messages Store к API с полной поддержкой CRUD.
-
Настроим сохранение изменений с буферизацией запросов.
🌐 Создаём утилитарный класс BaseUrl
Прежде чем менять хранилища, нужно решить одну проблему: все AJAX-запросы должны идти на наш API, а не на статические файлы. Можно было бы прописать полный URL в каждом хранилище, но это привело бы к дублированию и усложнило бы переключение между окружениями (разработка, тестирование, продакшен).
Решение — создать singleton-класс, который будет перехватывать все AJAX-запросы и добавлять к ним базовый URL API.
Создаём класс ExtMail.util.BaseUrl в общей папке app/util/:
Ext.define('ExtMail.util.BaseUrl', { singleton: true, requires: ['Ext.Ajax'], config: { baseUrl: '' }, constructor: function(config) { this.initConfig(config); Ext.Ajax.on('beforerequest', this.onBeforeAjaxRequest, this); }, onBeforeAjaxRequest: function(connection, options) { options.url = this.getBaseUrl() + options.url; } });
Мы подписываемся на глобальное событие beforerequest класса Ext.Ajax. Оно срабатывает перед каждым AJAX-запросом, и мы просто добавляем к URL базовый адрес API.
Важно: В больших приложениях этот подход может быть слишком грубым, если часть запросов идёт на другие домены. В таком случае нужно добавить проверку или использовать более гибкую логику.
Класс автоматически загрузится благодаря wildcard-зависимости ExtMail.* в app.js. Осталось задать базовый URL при запуске приложения в Application.js:
onBeforeLaunch: function() { ExtMail.util.BaseUrl.setBaseUrl('https://your-api-server.com/'); this.callParent(arguments); }
В продакшене этот URL обычно подставляется из конфигурации окружения на этапе сборки.
📇 Подключаем Contacts Store
Начнём с самого простого хранилища — контактов. Оно загружается при старте приложения и не меняется в течение жизненного цикла.
Меняем прокси с ajax на rest:
proxy: { type: 'rest', url: 'data/contacts/', reader: { type: 'json', transform: function(data) { return Ext.Array.map(data.result, ExtMail.util.Object.snakeCaseToCamelCase); } } }
Ключевые изменения:
-
Тип прокси —
restвместоajax. Это значит, что запросы будут использовать правильные HTTP-глаголы (GET, POST, PUT, DELETE) и следовать REST-паттерну. -
URL — теперь указывает на эндпоинт API, а не на статический файл. Базовый URL добавится автоматически через наш
BaseUrl. -
Transform — функция, которая вызывается после загрузки данных и позволяет преобразовать структуру перед тем, как она попадёт в хранилище.
В нашем случае API возвращает данные в SNAKE_CASE, а приложение ожидает camelCase. Утилитарный класс ExtMail.util.Object конвертирует ключи объекта из одного формата в другой.
После этих изменений хранилище контактов работает как раньше, но данные приходят с реального API.
🌳 Загружаем Labels Store
Дерево меток устроено сложнее — это иерархическая структура с вложенными узлами. Поэтому transform-функция должна рекурсивно обрабатывать каждый узел.
proxy: { type: 'rest', url: 'data/labelsnested', reader: { type: 'json', transform: function(data) { var transformRow = function(row) { row = ExtMail.util.Object.snakeCaseToCamelCase(row); if (row.children && Ext.isArray(row.children)) { row.children = Ext.Array.map(row.children, transformRow); } return row; }; return Ext.Array.map(data, transformRow); } } }
Мы определяем внутреннюю функцию transformRow, которая:
-
Конвертирует ключи текущего узла в camelCase.
-
Если у узла есть дочерние элементы (
children), рекурсивно применяет себя к каждому из них. -
Возвращает преобразованный узел.
Это классический пример рекурсивной обработки дерева. В большинстве случаев такая трансформация не нужна — если API сразу возвращает данные в нужном формате, шаг можно пропустить.
🔗 Рефакторинг меток сообщений
Прежде чем подключать основное хранилище Messages, нужно провести рефакторинг связи между письмами и метками. Новый API использует нормализованную структуру с промежуточной таблицей message_labels, и нам нужно отразить это на фронтенде.
Обновляем модель Message
Сначала меняем схему генерации ID на sequential — API использует числовые идентификаторы:
Ext.define('ExtMail.model.Message', { extend: 'Ext.data.Model', requires: [ 'Ext.data.identifier.Sequential', 'ExtMail.store.MessageLabels' ], identifier: 'sequential', // ... });
Затем убираем поле labels из сохраняемых на сервер — теперь метки будут управляться отдельной моделью:
{ name: 'labels', type: 'auto', defaultValue: [], persist: false }
И добавляем связь hasMany:
hasMany: [{ model: 'ExtMail.model.MessageLabel', name: 'labels', storeConfig: { type: 'MessageLabels' } }]
Связь hasMany создаёт вложенное хранилище (sub-store) внутри каждой модели Message. Оно будет содержать экземпляры модели MessageLabel — промежуточной сущности, связывающей письмо и метку.
Создаём MessageLabel Model и Store
Модель MessageLabel содержит всего два поля:
Ext.define('ExtMail.model.MessageLabel', { extend: 'Ext.data.Model', fields: [ 'messageId', 'labelId' ], // ... });
Прокси для этой модели настраивается нестандартно — URL формируется динамически из двух полей:
proxy: { type: 'ajax', noCache: false, url: 'labels/', actionMethods: { create: 'POST', destroy: 'DELETE' }, writer: { type: 'json', writeAllFields: true, transform: function(data) { if (data) { data = ExtMail.util.Object.camelCaseToSnakeCase(data); } return data; } } }
Мы используем ajax-прокси, а не rest, потому что нам нужно вручную собрать URL вида labels/<messageId>/<labelId>. Делаем это в конструкторе модели:
constructor: function() { this.callParent(arguments); this.getProxy().setUrl( Ext.String.format( 'labels/{0}/{1}', this.get('messageId'), this.get('labelId') ) ); }
Хранилище MessageLabels настраивается просто:
Ext.define('ExtMail.store.MessageLabels', { extend: 'Ext.data.Store', alias: 'store.MessageLabels', model: 'ExtMail.model.MessageLabel', autoSync: true });
Ключевой параметр — autoSync: true. Это значит, что при добавлении или удалении записи хранилище автоматически отправит запрос на сервер. Нам не нужно вызывать sync() вручную.
Обновляем вспомогательные методы модели Message
Три метода работы с метками — hasLabel, addLabel, removeLabel — нужно переписать, чтобы они работали с новой связью.
hasLabel — использует findExact вложенного хранилища:
hasLabel: function(labelId) { return this.labels().findExact('labelId', labelId) >= 0; }
addLabel — добавляет запись во вложенное хранилище:
addLabel: function(labelId) { var labels = this.get('labels') || []; labels.push(labelId); this.set('labels', Ext.clone(labels)); this.labels().add({ messageId: this.getId(), labelId: labelId }); }
removeLabel — находит и удаляет запись:
removeLabel: function(labelId) { var labels = this.get('labels') || []; labels = Ext.Array.remove(labels, labelId); this.set('labels', Ext.clone(labels)); var index = this.labels().findExact('labelId', labelId); this.labels().removeAt(index); }
Во всех трёх методах мы по-прежнему обновляем массив labels в самой модели — это нужно для UI, чтобы компоненты, привязанные к этому полю, обновились. А операции с вложенным хранилищем обеспечивают синхронизацию с API.
📬 Подключаем Messages Store
Теперь настроим главное хранилище — Messages. Здесь мы используем конфигурацию api, чтобы указать разные эндпоинты для разных операций:
proxy: { type: 'rest', noCache: false, api: { read: 'data/messagesfull', create: 'data/messages/', update: 'data/messages/', destroy: 'data/messages/' }, reader: { type: 'json', transform: function(data) { if (data) { if (Ext.isArray(data)) { data = Ext.Array.map(data, ExtMail.util.Object.snakeCaseToCamelCase); Ext.each(data, function(row) { row.labels = Ext.Array.map( row.labels || [], function(labelId) { return { messageId: row.id, labelId: labelId }; } ); }); } else { data = ExtMail.util.Object.snakeCaseToCamelCase(data); } } return data; } } }
Здесь transform-функция делает две вещи:
-
Конвертирует ключи из
SNAKE_CASEвcamelCase. -
Преобразует массив ID меток в массив объектов
{ messageId, labelId }, которые затем станут экземплярами моделиMessageLabelво вложенном хранилище.
В большинстве случаев API уже возвращает данные в правильной структуре, и этот шаг не нужен.
💾 Сохраняем изменения сообщений
Последний шаг — настроить writer для сохранения изменений в модели Message:
writer: { type: 'json', writeAllFields: true, transform: function(data) { if (data) { data = ExtMail.util.Object.camelCaseToSnakeCase(data); delete data.ID; data.DATE = Ext.Date.format( Ext.Date.parse(data.DATE, 'c'), 'm/d/Y H:i:s' ); } return data; } }
Writer выполняет три преобразования:
-
Конвертирует ключи из
camelCaseвSNAKE_CASE. -
Удаляет поле
ID— оно уже включено в URL. -
Переформатирует поле
DATEиз ISO-формата в формат, который ожидает API (m/d/Y H:i:s).
Буферизация запросов
Если оставить всё как есть, каждая нажатая клавиша в поле сообщения будет вызывать sync() и отправлять запрос на сервер. Это создаст огромную нагрузку.
Решение — буферизация. Переопределяем метод sync в конструкторе хранилища, оборачивая его в Ext.Function.createBuffered:
constructor: function() { this.callParent(arguments); this.sync = Ext.Function.createBuffered(this.sync, 500, this); }
Теперь повторные вызовы sync() в течение 500 миллисекунд будут игнорироваться, и запрос отправится только один раз — после того, как пользователь перестанет печатать.
💎 Заключение
В шестой части мы успешно перевели приложение с статических JSON-файлов на полноценный REST API:
-
Создали утилитарный класс BaseUrl для централизованного управления адресом API.
-
Переключили Contacts Store и Labels Store на REST-эндпоинты.
-
Провели рефакторинг модели Message: добавили связь
hasManyс модельюMessageLabel. -
Создали MessageLabel Model и MessageLabels Store с автоматической синхронизацией.
-
Подключили Messages Store к API с поддержкой CRUD-операций.
-
Настроили сохранение изменений с буферизацией запросов.
Теперь состояние приложения сохраняется на сервере и не теряется при обновлении страницы. В заключительной, седьмой части мы добавим deep linking и Router — механизм, который позволит пользователям делиться ссылками на конкретные письма и метки, а также использовать кнопки «Назад»/«Вперёд» в браузере.