За шесть частей мы построили полноценный почтовый клиент Ext Mail: от фундамента и моделей данных до мобильной версии и интеграции с REST API. Однако до сих пор наше приложение существовало под одним URL, который никогда не менялся. Это означало, что:
-
при обновлении страницы приложение сбрасывалось в исходное состояние;
-
нельзя было поделиться ссылкой на конкретное письмо или метку;
-
кнопка «Назад» в браузере работала не так, как ожидает пользователь.
В заключительной, седьмой части мы исправим это, добавив Deep Linking с помощью встроенных Router-классов Ext JS. Это сделает наше приложение по-настоящему веб-ориентированным: URL будут меняться, состояние сохраняться, а ссылки — делиться.
Все примеры кода взяты из оригинальной книги Stuart Ashworth (Sencha MVP) и адаптированы для русскоязычного читателя.
🔗 Что такое Deep Linking и зачем это нужно
Deep Linking — это возможность переходить напрямую к определённому разделу приложения по URL. В традиционных серверных приложениях каждая страница имела свой адрес: myapp.com/users, myapp.com/orders/123. В современных Single-Page Applications (SPA) пользователь никогда не покидает корневую страницу, и все переходы происходят внутри JavaScript. Это ломает привычную веб-навигацию.
Преимущества Deep Linking:
-
Пользователь может обновить страницу и остаться на том же месте.
-
Ссылкой на конкретное письмо или метку можно поделиться.
-
Кнопка «Назад» работает ожидаемым образом.
-
Улучшается SEO и аналитика.
Ext JS предоставляет набор классов в пространстве имён Ext.route, которые позволяют реализовать маршрутизацию на основе хэша URL. Хэш не вызывает перезагрузку страницы, но его изменения отслеживаются приложением.
Пример URL с хэшем: www.myapp.com#user/view/123
🧩 Ключевые возможности Ext JS Router
Router в Ext JS поддерживает:
-
Параметры в маршрутах — например,
user/:action/:id. -
Необязательные параметры.
-
Валидацию параметров — можно проверять, что ID является числом.
-
Несколько активных маршрутов одновременно — полезно, когда на экране открыто несколько независимых областей.
-
before-хуки — функции, которые выполняются перед применением маршрута. Их можно использовать для проверки авторизации или загрузки данных. -
Именованные маршруты — позволяют обновлять один маршрут, не затрагивая другие.
🗺️ Добавляем три маршрута в Ext Mail
Мы добавим три маршрута:
-
label/:label— выбор метки (Inbox, Starred, Drafts и т.д.). -
view/:messageId— просмотр конкретного письма. -
draft/:messageId— открытие черновика в форме редактирования.
Эти маршруты могут быть активны одновременно, а их комбинация определяет состояние приложения.
🏷️ Добавляем поле slug для меток
Чтобы использовать метки в URL, нужно преобразовать их названия в URL-безопасный формат — slug. Добавим вычисляемое поле в модель Label:
{ name: 'slug', calculate: function(data) { return (data.name || '').toLowerCase() .replace(/ /g, '-') .replace(/[^\w-]+/g, ''); } }
Теперь метка «All Mail» превратится в all-mail, «Drafts» — в drafts и т.д.
🧭 Маршрут для выбора метки
Определяем маршруты в MainControllerBase:
routes: { 'label/:label': { name: 'label', before: 'onBeforeViewLabel', action: 'onViewLabel' } }
before-хук проверяет, загружено ли хранилище меток. Если нет — ждёт события load:
onBeforeViewLabel: function(label, action) { var labelsStore = this.getViewModel().getStore('labels'); if (labelsStore.loadCount > 0) { action.resume(); } else { labelsStore.on('load', function() { action.resume(); }, this, { single: true }); } }
action-метод находит метку по slug и устанавливает её как выбранную:
onViewLabel: function(label) { var labelsStore = this.getViewModel().getStore('labels'); var labelRecord = labelsStore.findRecord('slug', label); if (!labelRecord) { labelRecord = labelsStore.first(); } this.getViewModel().set('selectedMessage', null); this.getViewModel().set('selectedLabel', labelRecord); }
Чтобы URL обновлялся при клике по дереву меток, добавляем обработчик selectionchange:
onLabelSelectionChange: function(labelTree, selectedLabelRecords) { var selectedLabelRecord = selectedLabelRecords[0]; var slug = ''; if (selectedLabelRecord) { slug = selectedLabelRecord.get('slug'); } this.redirectTo({ label: Ext.String.format('label/{0}', slug), message: null }); }
📩 Маршрут для просмотра письма
Добавляем маршрут view/:messageId:
'view/:messageId': { name: 'message', before: 'onBeforeViewMessage', action: 'onViewMessage' }
before-хук аналогичен предыдущему — ждёт загрузки хранилища messages.
onBeforeViewMessage: function(messageId, action) { var store = this.getViewModel().getStore('messages'); if (store.loadCount > 0) { action.resume(); } else { store.on('load', function() { action.resume(); }, this, { single: true }); } }
action-метод находит письмо по ID и устанавливает его как выбранное:
onViewMessage: function(messageId) { var store = this.getViewModel().getStore('messages'); var messageRecord = store.getById(messageId); this.getViewModel().set('selectedMessage', messageRecord); if (!messageRecord) { this.redirectTo({ message: null }); } }
Теперь нужно изменить обработчик клика по письму в handleMessageClick, чтобы он не менял состояние напрямую, а вызывал redirectTo:
handleMessageClick: function(messageRecord) { if (messageRecord.get('draft')) { this.redirectTo({ draft: Ext.String.format('draft/{0}', messageRecord.getId()) }); } else { this.redirectTo({ message: Ext.String.format('view/{0}', messageRecord.getId()) }); } }
✏️ Маршрут для черновика
Третий маршрут — draft/:messageId:
'draft/:messageId': { name: 'draft', action: 'onDraftMessage' }
onDraftMessage: function(messageId) { var messageRecord = this.getViewModel().getStore('messages').getById(messageId); if (!messageRecord) { this.redirectTo({ draft: null }); } else { this.showDraftWindow(messageRecord); } }
И обновляем onComposeMessage, чтобы создание нового черновика тоже шло через маршрут:
onComposeMessage: function() { var messageRecord = Ext.create('ExtMail.model.Message', { labels: [ExtMail.enums.Labels.DRAFTS], outgoing: true, draft: true }); messageRecord.addLabel(ExtMail.enums.Labels.DRAFTS); this.getViewModel().getStore('messages').add(messageRecord); this.getViewModel().getStore('messages').commitChanges(); this.redirectTo({ draft: Ext.String.format('draft/{0}', messageRecord.getId()) }); }
🧠 Смена парадигмы: UI → URL → Состояние
Самое важное изменение в этой части — рефакторинг мышления. Раньше клики по элементам интерфейса напрямую меняли состояние ViewModel. Теперь все взаимодействия должны вызывать изменение URL, а маршруты — реагировать на это и обновлять состояние.
Это может потребовать пересмотра некоторых участков кода, но в результате приложение становится предсказуемым, состояние сохраняется, а ссылки работают.
🖥️ Как это выглядит в браузере
После добавления маршрутов URL может выглядеть так:
http://localhost:1841/#label/all-mail/view/85/draft/1
Это означает, что одновременно активны три маршрута: выбрана метка All Mail, открыто письмо с ID 85, и в фоне редактируется черновик с ID 1. При обновлении страницы приложение восстановит это состояние.
💎 Заключение
Мы завершили серию из семи частей, создав полнофункциональный почтовый клиент на Ext JS:
-
Часть 1: Фундамент приложения, модели, хранилища, Grid и Reader.
-
Часть 2: Метки, дерево, динамические действия, подсчёт непрочитанных.
-
Часть 3: Создание писем, работа с черновиками.
-
Часть 4: Мобильная версия на Modern Toolkit.
-
Часть 5: Выдвижное меню и Compose в Modern.
-
Часть 6: Интеграция с REST API.
-
Часть 7: Deep Linking и Router.
Приложение готово к использованию в production: оно работает на десктопе и мобильных устройствах, сохраняет данные на сервере, поддерживает ссылки и кнопку «Назад». Это отличный пример того, как Ext JS позволяет создавать сложные корпоративные приложения с единой кодовой базой.