Как мы заменили заголовок и навигацию Bronto приложением React «Micro Frontend»
Начиная с сегодняшнего дня, Bronto повсеместно поставляет нашим клиентам обновленный макет приложения [1] [2] [3]. Мы приняли ряд ключевых решений по внедрению, которые помогли нам сделать это на перспективу, одновременно управляя рисками капитального ремонта нашего устаревшего PHP-приложения. Мы сделали это довольно новым и интересным способом, о котором я хотел бы поделиться некоторыми подробностями.
Задний план
Bronto начала планировать свою инициативу по редизайну где-то в конце февраля или начале марта. Тем не менее, проекты не были нарисованы, а объем четко не определен, по крайней мере, еще пару месяцев. Однако в начале июня команда разработчиков почувствовала, что у нас достаточно информации, чтобы попытаться создать прототип. Используя разрабатываемую нами инфраструктуру «Micro Frontend» (MFE) [4], мы продемонстрировали независимо развернутое и асинхронно загружаемое приложение React, управляющее макетом устаревшей страницы, которая была загружена скелетом [5]. Не беспокойтесь. Я объясню. Достаточно сказать, что мы внесли минимальные изменения в наш унаследованный монолит, максимально использовали передовые технологии и сделали большой шаг вперед в нашем плане по созданию нашего веб-приложения вне PHP. Мы обдумали уроки, извлеченные из прототипа, и загрузили приложение React с именем @bronto/emerald-layout, которое мы здесь описываем.
Асинхронный поток
Прежде чем углубляться в детали, я хотел бы описать асинхронный процесс загрузки страницы при применении нашего нового макета. Все начинается с запроса html страницы.
GET https://app.bronto.com/mail/index/dashboard
Сторона сервера
- Наше PHP-приложение будет определять, следует ли отображать наш новый макет для данного запроса, используя шаблон спецификации [6]. Когда макет неприменим к запросу, ничего не меняется, и устаревший ответ остается неизменным. Когда это применимо, мы указываем нашей веб-инфраструктуре использовать специально предназначенный макет Zend.
- В новом минималистичном макете Zend отсутствует наша устаревшая разметка верхнего колонтитула, навигации и нижнего колонтитула. Скорее, он предоставляет минималистский скелет с содержимым устаревшей страницы рядом с ним.
- Макет включает в себя тег скрипта для получения нашего «динамического загрузчика модулей» и фрагмент для динамической загрузки, инициализации и рендеринга MFE «@bronto/emerald-layout», приложения JavaScript.
- Макет сериализует, а html кодирует данные JSON, которые в настоящее время недоступны из API (мы работаем над этим), которые будут использоваться при начальной загрузке приложения React.
Сторона клиента
- Браузер анализирует и начинает отображать ответ сервера, включая любую устаревшую разметку страницы и скрипты. Это позволяет пользователю потенциально взаимодействовать со страницами немедленно, пока наш хром рендерится асинхронно.
- Браузер загружает наш динамический загрузчик модулей и начинает загрузку модуля «@bronto/emerald-layout».
- Когда модуль «@bronto/emerald-layout» завершает загрузку и срабатывает событие DOMContentLoaded, мы инициализируем «@bronto/emerald-layout», анализируя наши данные начальной загрузки, полученные с помощью разметки, и загружая кеш GraphQL.
- React отображает «@bronto/emerald-layout» в элементе
#emeraldLayout
, разрушая скелет макета и передавая контроль над большей частью страницы нашему приложению React. - Компонент React «PrimedContent» берет элемент
#emeraldLayoutPrimedContent
и переопределяет его как родитель внутри и внутри дерева «@bronto/emerald-layout».
Изобилие осторожности
Приступая к этому проекту, у нас было ощущение, что нам нужно подходить к делу по-другому. Пока мы разрабатывали этот новый макет, мы хотели избежать случайного внесения регрессий в существующий макет. Нам нужно было двигаться быстрее, чем обычно, с независимым циклом разработки и снизить риски, связанные с изменением нашей большой базы кода PHP. В этом духе мы свели к минимуму влияние наших дополнений на существующий поток выполнения PHP. Тот небольшой тщательный рефакторинг, который мы сочли необходимым, был хорошо спланирован и тщательно изучен. Мы ввели абстракцию LayoutProcessor
в поток запросов, извлекли и сохранили существующий поток в файле LegacyLayoutProcessor
. После того, как этот рефакторинг был завершен, мы представили спецификацию для запуска нового процессора, который на первых этапах запускался просто переключением функций.
/** * Find and create the first layout processor capable of processing * this request. * @param \Req $request * @return \Bronto\Platform\Layout\LayoutProcessor */ public function create(\Req $request) { if ($this->emeraldLayoutSpecification->isSatisfiedBy($request)) { return new EmeraldLayoutProcessor($request); } else { return new LegacyLayoutProcessor($request); } }
При таком подходе мы смогли разветвить и скопировать любые .phtml
файлов и классов, необходимых для завершения обработки запроса, в «изумрудное» пространство имен. Мы эффективно изолировали новый макет и его сложности, и мы могли безопасно вносить радикальные изменения без какого-либо риска для нормального потока запросов.
Загрузка скелета
Чтобы отделиться от нашего устаревшего приложения, мы решили использовать гибрид разметки, созданной сервером, и разметки, управляемой клиентом. Этот подход означает, что наш макет React загружается и отображается асинхронно. Чтобы избежать мерцания пользовательского интерфейса или снижения воспринимаемой производительности, мы отображаем скелетный заголовок и панель навигации при загрузке страницы, которые соответствуют размерам нашего приложения React. В усеченном примере шага № 2 на стороне сервера вы можете видеть, что элемент #emeraldLayout
, который будет уничтожен React, включает разметку, стиль которой соответствует размерам и цветам конечного результата.
<html> <head> <title>Bronto - Home Dashboard</title> </head> <body> <div id="emeraldLayout"> <!-- The Skeleton Chrome! --> <div id="emeraldNavBar" style="position: absolute; top: 0px; height: 100%; width: 248px; background-color: #FFFFFF;" /> <div id="emeraldAppBar" style="position: absolute; width: 100%; height: 64px; background-color: #343A40;" /> </div> <div id="emeraldPrimedContent" /> <!-- Legacy Page Content Gets Inserted Here! --> </div> </body> </html>
В нашем первоначальном приращении мы в основном заботились о том, чтобы избежать мерцания пользовательского интерфейса. Нашему скелету не помешало бы немного любви, но он служит нашей цели — представить содержимое страницы как можно скорее. Любые дальнейшие улучшения скелета потребуют работы в рамках нашего унаследованного монолита. Мы намеренно избегали этого, потому что большая часть нашей скорости связана с работой с нашими новыми инструментами и шаблонами, а скелет должен отображаться только в течение очень короткого промежутка времени.
Раздельные развертывания, динамическая загрузка
Первым и, осмелюсь сказать, наиболее важным решением, которое мы приняли в нашем плане реализации, было использование нашей инфраструктуры MFE [4] для независимого развертывания и загрузки модулей JavaScript. Это освободило нас от цикла выпуска нашего устаревшего монолита и позволило нам развиваться быстрее и не опасаясь случайных и каскадных регрессий. На самом деле, большая часть прироста эффективности нашей команды стала результатом такого разделения [8].
Мы разработали плагин и оболочку для SystemJS [9] (наш «динамический загрузчик модулей»), которые позволяют нам загружать модули JavaScript, развернутые активы которых будут разрешены во время выполнения. Это позволяет нам развертывать модули «микроинтерфейса» независимо от загружаемого PHP-приложения, загружая и извлекая текущую активированную версию актива во время выполнения. Если мы разделим фрагмент кода до его ядра, то, что мы включим в <body>
, будет выглядеть так.
<script type="text/javascript"> (function() { loader.init() .import("@bronto/emerald-layout/application.js") .then(function(EmeraldLayout) { (new EmeraldLayout()) .init() .then(function(app){ app.render( document.getElementById('emeraldLayout') ); }); }); })(); </script>
Наш динамический загрузчик сначала запрашивает манифест приложения «@bronto/emerald-layout» (app-manifest.json
), который обеспечивает сопоставление именованных ресурсов с URL-адресами ресурсов с отпечатками пальцев, доступными и кэшируемыми. Манифест приложения MFE может быть изменен и в конечном итоге будет разрешен из поддерживающего его микросервиса. Развертывая новые версии микросервиса «@bronto/emerald-layout», мы переназначаем наши ресурсы на стороне клиента и указываем браузеру получить и отобразить новую версию нашего приложения React.
GET /@bronto/emerald-layout/app-manifest.json { "version": "1.1.1", "assets": { "application.js": "@bronto/emerald-layout/static/js/application.3a9cee90.js", "application.js.map": "@bronto/emerald-layout/static/js/application.3a9cee90.js.map" } }
Мы ожидаем, что подключаемый модуль SystemJS запросит app-manifest.json
приложения MFE, а затем динамически разрешит актив с отпечатком пальца, который можно навсегда кэшировать. После того, как мы разрешили URL-адрес актива, мы позволяем SystemJS загрузить модуль. Эта динамическая загрузка (и изоляция, которую она обеспечивает) позволяет нам доставлять обновления макета гораздо быстрее и с большей уверенностью, чем если бы мы вносили изменения в файлы .php
и .phtml
, общие для всех Bronto.
Асинхронный рендеринг на стороне клиента с рендерингом на сервере, устаревшее содержимое
В результате нашей динамической загрузки и использования React мы вынуждены учитывать асинхронное поведение в основных частях взаимодействия с нашими пользователями. Мы полностью намерены перенести MFE-приложения на JavaScript, отображаемый на сервере, но в настоящее время мы поддерживаем страницы, отображаемые PHP, которые основаны на каталоге старых технологий и фреймворков. Итак, мы хотели гибридное серверное и клиентское приложение, которое могло бы быстро получать и отображать наше приложение React, позволяя пользователю взаимодействовать с устаревшим контентом, как только он станет доступным. Мы приняли два решения, критически важных для поддержки этого, казалось бы, парадоксального требования.
Отображение содержимого страницы сразу, управление позже
В нашем первоначальном прототипе мы решили скрыть содержимое страницы с помощью display: none
до тех пор, пока приложение React не инициализируется и не отобразится. Я хотел бы отдать должное Олексию Кващенко за то, что он подтолкнул нас к скелетной модели загрузки, которая позволяет пользователям взаимодействовать с устаревшими страницами, как только они становятся доступными (независимо от нашего асинхронного макета). Мы используем простой компонент, который извлекает элемент #emeraldLayoutPrimedContent
и перемещает его в приложении React для поддержания иерархии элементов DOM.
export class PrimedContent extends Component { rootEl = React.createRef(); componentDidMount() { const { primedContentElement } = this.props; this.rootEl.current.appendChild(primedContentElement); // Clear any provisional styling from the skeleton primedContentElement.setAttribute("style", ""); } render() { return <div ref={this.rootEl} />; } }
В настоящее время наше приложение «@bronto/emerald-layout» будет отображать себя поверх устаревших страниц, предоставляя <PrimedContent>
в качестве дочернего элемента.
ReactDOM.render( <EmeraldLayoutComponent> <PrimedContent primedContentElement={ document.getElementById("emeraldLayoutPrimed") } /> </EmeraldLayoutComponent>, rootNode );
По мере того, как мы движемся к созданию приложений и страниц за пределами нашего устаревшего приложения php, мы сможем отображать их, не ожидая устаревшего, «загрунтованного» контента. Нам нужно только указать другого ребенка. Таким образом, будущие приложения MFE смогут отображать себя в нашем макете без участия PHP.
export const EmeraldLayoutComponent = props => { return ( <div> <Header /> <Sidenav /> <main> {props.children} </main> </div> ); };
Заполните кэш клиентских данных закодированным JSON
В нашем исторически управляемом сервером веб-приложении отсутствует API общего назначения для использования нашим клиентским приложением. Нам нужно было быстро передавать данные в наше асинхронное приложение, и мы хотели избежать создания одноразовых API-интерфейсов на PHP. Следуя рекомендациям OWASP по предоставлению сериализованного JSON в разметке[10], мы отправляем большое количество встроенных данных в HTML-ответ сервера. JSON кодируется в формате html и записывается в скрытые элементы.
<div id="mfeDataUser" style="display: none;"> {"username":"patrick.winters"} </div>
Состояние ссылки Apollo [11] и закодированный JSON позволили нам загрузить приложение с подготовленным кешем данных GraphQL. Наше приложение React, как правило, может отображать заголовок и навигационную информацию сразу после динамической загрузки и рендеринга.
Сохраняйте активы, открывайте новые
По общему признанию, заголовок поста ироничен. В Bronto у нас есть очень мощное и многофункциональное веб-приложение. Мы хотим сохранить как можно больше, заложив фундамент на будущее. Я полагаю, что мы тщательно шли по линии с нашим редизайном макета, удивительно хорошо соблюдая баланс между затратами, рисками и возможностями переписывания программного обеспечения. Проделав иглу, нам удалось доставить это замечательное обновление клиентам, одновременно заложив основу, необходимую для ускорения будущих планов. Мы предоставляем современный асинхронный пользовательский интерфейс наряду с нашим существующим устаревшим пользовательским интерфейсом; и мы смогли сделать это быстро и уверенно!
использованная литература
[1] Новая навигация делает опыт пользователей Bronto еще лучше. http://blog.bronto.com/product/new-navigation-makes-bronto-users-experiences-even-better/
[2] Попробуйте новый способ навигации по Бронто. https://www.youtube.com/watch?v=nHNAF0QpNQo
[3] [Вебинар] Испытайте новый способ навигации по Бронто https://www.youtube.com/watch?v=6ackcMmXGV0
[4] Манифест Micro Frontend. https://medium.com/@patrick.winters/the-micro-front-end-manifesto-fd65f5984d20
[5] Улучшите свой UX с помощью Skeleton Pattern. https://medium.com/@rohit971/boost-your-ux-with-skeleton-pattern-b8721929239f
[6] Шаблон спецификации. Википедия. https://en.wikipedia.org/wiki/Specification_pattern
[7] React — интеграция с другими библиотеками. https://reactjs.org/docs/integrating-with-other-libraries.html
[8] Дисциплина — это мост между целями и достижениями. https://medium.com/@patrick.winters/discipline-is-the-bridge-between-goals-and-accomplishment-7015f95793a8