От автора: компонентное представление в веб-разработке отмечалось как до, так и после. Обычно в качестве преимуществ упоминали повторное использование и модульность. Хорошо определенные кусочки, с помощью которых можно строить наши сайты, как детальки лего. Оказалось, что эта компонентная структура заложила хорошее основание для возможности повышать производительность сайта.
Мы четко знаем зависимости, поэтому знаем, какой код нужно запустить, чтобы запустить определенный компонент. Ленивая загрузка и разбиение пакета могут оказать значительное влияние на производительность страницы: запрашивается, парсится и выполняется меньше кода. Это применимо не только к JS, но и ко всем типам файлов.
Я знаю много сайтов, которые могли бы воспользоваться этим преимуществом. Я хочу показать вам базовые техники загрузки контента.
В этой статье мы будем использовать Preact/React, но идеи можно применить к любой другой компонентной библиотеке. Мы обсудим следующие темы:
Композиционный шаблоны: обзор нескольких шаблонов, с помощью которых можно строить сложные компоненты.
Улучшение производительности сайтов путем загрузки только необходимого: практический пример применения ленивой загрузки.
Маленький компонент для определения видимости: простой компонент, в котором хранится логика уведомления, когда элемент появляется на экране.
Дополнительные примеры использования: увидим, что компонент определения видиости может быть полезен и в других ситуациях.
Polyfilling IntersectionObserver по требованию: как подключить полифил только при необходимости
Полезные реализации: существующие npm библиотеки, реализующие изученный нами шаблон
Приступим!
Композиционные шаблоны
В компонентном мире компоненты используются не только для рендера реальных пикселей на экране. Они также могут хранить функциональность, передаваемую в дочерние компоненты.
Это обычно достигается через High Order Components (HOC). Эти компоненты получают другой компонент и добавляют функциональность типа поведения.
Если вы пользовались Redux, функция connect – это HOC, получающий ваш несвязанный компонент. Больше примеров можно найти в статье React Higher Order Components in depth от Fran Guijarro.
const MyComponent = props => ( <div> {props.id} - {props.name} </div> ); // ... const ConnectedComponent = connect(mapStateToProps, mapDispatchToProps)( MyComponent );
Функции как Child Component (или Render Callback) – еще один шаблон, используемый в похожих сценариях. Последнее время он стал довольно популярным. Вы могли сталкиваться с ними в react-media или unstated.
Взгляните на пример, взятый из react-media:
const MyComponent = () => ( <Media query="(max-width: 599px)"> {matches => matches ? ( <p>The document is less than 600px wide.</p> ) : ( <p>The document is at least 600px wide.</p> ) } </Media> );
Компонент Media вызывает свои дочерние компоненты, передавая аргумент matches. Таким образом, дочерним компонентам не нужно знать о медиа запросе. Компонентное представление облегчает тестирование и обслуживание.
Улучшение производительности сайтов путем загрузки только необходимого
Представьте типичную веб-страницу. Вдохновение можете взять в «Website Sameness or Web Design Trends: Why Do All Websites Look The Same?». В нашем примере страницы будет несколько секций или блоков:
Хедер (сейчас это большое баннерное изображение, занимающее всю видимую область экрана после загрузки)
Секция с парой изображений
Еще одна секция с тяжелым компонентом типа карты
Футер
Для React компонентов код был бы примерно следующим:
const Page = () => { <div> <Header /> <Gallery /> <Map /> <Footer /> </div> };
Когда пользователь зайдет на страницу, с высокой вероятностью он увидит хедер на экране. Это самый верхний компонент. Менее вероятно, что он увидит карту и футер, если не проскролит.
В большинстве случаев необходимо подключать все скрипты и CSS, нужные для отображения всех разделов, как только пользователь посещает страницу. До сих пор было сложно определить зависимости модуля и загружать необходимое.
Много лет назад, еще до pre-ES6, большие компании сами придумали, как определять зависимости и загружать их по необходимости. Yahoo создал YUI Loader, а Facebook написал Haste, Bootloader and Primer.
Когда вы посылаете пользователю код, который ему не нужен, вы впустую тратите его и свои ресурсы. Требуется более широкий канал передачи данных, больше нагрузка на CPU для парсинга и выполнения, больше памяти для хранения. Эти файлы украдут ограниченные ресурсы у других критичных файлов, которые нужны сильнее.
В чем смысл запрашивать ресурсы, ненужные пользователю, такие как изображения, на которые пользователь не прокрутит страницу? Или загрузка сторонних компонентов типа Google Map со всеми его дополнительными файлами, необходимыми для рендера?
Отчет о покрытии кода, как, например, от Google Chrome не даст нам достаточно информации. JS код будет выполняться, а CSS будет применяться к невидимым элементам.
Как и у всего, у ленивой загрузки есть минусы. Нам не нужно применять ленивую загрузку ко всему. Что нужно учесть:
Нельзя лениво загружать верхнюю часть сайта, видимую сразу же после загрузки. Во многих случаях необходимо, чтобы эта часть рендерилась как можно быстрее. Во всех техниках ленивой загрузки будет задержка. Браузер обязан запустить JS, который вставляет HTML в документ, парсит его и запрашивает файлы по ссылкам.
Как определить видимую часть страницы после загрузки? Это сложно, здесь все зависит от устройства пользователя, а они очень разные, и вашего макета.
Начинайте ленивую загрузку чуть раньше, чем нужно. Вы не хотите показывать пользователям пустые области. Для этого можно загружать файл, когда он очень близко к видимой области. Например, пользователь скролит вниз, и если до загружаемого изображения осталось 100px, запрашивайте его.
Невидимый контент в некоторых сценариях. Нужно учитывать, что в некоторых ситуациях нужно показывать ленивый контент:
Если ленивый контент не загружен, он не отобразится при печати страницы
То же самое касается RSS ридеров, которые могут не выполнять JS, необходимый для загрузки контента
С точки зрения SEO у вас могут быть проблемы с индексацией ленивого контента в google. На момент написания статьи Googlebot поддерживает IntersectionObserver и запускает его колбек с изменениями в верхней видимой части вьюпорта. Однако он не запускает колбек для контента, расположенного за пределами видимой части. Поэтому контент не виден и не индексируется google. Если у вас важный контент, можно, например, рендерить текст и лениво загружать компоненты типа изображений и других виджетов (например, карты).
В виде ниже я рендерю тестовую страницу (исходники тут) с помощью Google Webmaster Tools’ “Fetch as Google”. Googlebot рендерит контент в блоке, который входит во вьюпорт. Контент ниже не рендерится.
Маленький компонент для определения видимости области
Раньше я уже говорил о ленивой загрузке изображений. Это всего лишь тип файла, который подвергается ленивой загрузке, но технику можно применить к другим элементам.
Создадим простой компонент, который будет определять видимость области во вьюпорте. Для краткости я буду использовать Intersection Observer API, экспериментальную технологию с довольно хорошей поддержкой.
class Observer extends Component { constructor() { super(); this.state = { isVisible: false }; this.io = null; this.container = null; } componentDidMount() { this.io = new IntersectionObserver([entry] => { this.setState({ isVisible: entry.isIntersecting }); }, {}); this.io.observe(this.container); } componentWillUnmount() { if (this.io) { this.io.disconnect(); } } render() { return ( // we create a div to get a reference. // It's possible to use findDOMNode() to avoid // creating extra elements, but findDOMNode is discouraged <div ref={div => { this.container = div; }} > {Array.isArray(this.props.children) ? this.props.children.map(child => child(this.state.isVisible)) : this.props.children(this.state.isVisible)} </div> ); } }
Компонент использует IntersectionObserver для определения того, как контейнер пересекается с вьюпортом, т.е. становится видимым. Воспользуемся методами жизненного цикла React для очищения IntersectionObserver, отсоединяя его при отключении.
Этот базовый компонент можно расширить дополнительными свойствами, переданными как опции в IntersectionObserver (например, margin или threshold), чтобы уметь определять элементы, которые близки к вьюпорту, но не пересекают его. Опции задаются в конструкторе, доступны только для чтения. Поэтому добавление поддержки опций означает, что нам понадобится заново создать объект IntersectionObserver с новыми опциями, когда они изменятся, добавив дополнительную логику в componentWillReceiveProps (не будем разбирать в этой статье).
Теперь этот компонент можно использовать для ленивой загрузки наших компонентов Gallery и Map:
const Page = () => { <div> <Header /> <Observer> {isVisible => <Gallery isVisible />} </Observer> <Observer> {isVisible => <Map isVisible />} </Observer> <Footer /> </div> }
В коде сверху я просто передаю свойство isVisible в компоненты Gallery и Map, и они его обрабатывают. Или же можно было при видимости возвращать компонент, в обратном случае пустой элемент.
В любом случае проверьте, что зарезервировали место для ленивой загрузки компонента. Вам не нужно, чтобы контент прыгал. Поэтому если знаете, что Map составляет 400px в высоту, отрендерьте сначала пустой контейнер 400px перед рендером карты.
Как компоненты Map и Gallery используют свойство isVisible? Взглянем на Map:
class Map extends Component { constructor() { super(); this.state = { initialized: false }; this.map = null; } initializeMap() { this.setState({ initialized: true }); // loadScript loads an external script, its definition is not included here. loadScript("https://maps.google.com/maps/api/js?key=<your_key>", () => { const latlng = new google.maps.LatLng(38.34, -0.48); const myOptions = { zoom: 15, center: latlng }; const map = new google.maps.Map(this.map, myOptions); }); } componentDidMount() { if (this.props.isVisible) { this.initializeMap(); } } componentWillReceiveProps(nextProps) { if (!this.state.initialized && nextProps.isVisible) { this.initializeMap(); } } render() { return ( <div ref={div => { this.map = div; }} /> ); } }
Когда контейнер отображается во вьюпорте, мы делаем запрос на вставку скрипта Google Map и после загрузки создаем карту. Это хороший пример ленивой загрузки JS, который не нужен в начале, а остальные ресурсы для отображения карты нужны.
В компоненте есть состояние, дабы избежать повторной вставки скрипта Google Map. Разберем компонент Gallery:
class Gallery extends Component { constructor() { super(); this.state = { hasBeenVisible: false }; } componentDidMount() { if (this.props.isVisible) { this.setState({ hasBeenVisible: true }); } } componentWillReceiveProps(nextProps) { if (!this.state.hasBeenVisible && nextProps.isVisible) { this.setState({ hasBeenVisible: true }); } } render() { return ( <div> <h1>Some pictures</h1> Picture 1 {this.state.hasBeenVisible ? ( <img src="http://example.com/image01.jpg" width="300" height="300" /> ) : ( <div className="placeholder" /> )} Picture 2 {this.state.hasBeenVisible ? ( <img src="http://example.com/image02.jpg" width="300" height="300" /> ) : ( <div className="placeholder" /> )} </div> ); } }
Пример сверху определяет еще один компонент состояния. По факту, в состоянии здесь хранится та же информация, что и в Map.
Если Gallery отображается внутри вьюпорта и позже уходит за его пределы, изображения остаются в DOM. Это то, что нам нужно при работе с изображениями во большинстве случаев.
Дочерние компоненты без состояния
Компонент без состояния тоже может быть интересным. Он позволяет удалять изображения, которые больше не видны, заменяя их обратно на плейсхолдеры:
const Gallery = ({ isVisible }) => ( <div> <h1>Some pictures</h1> Picture 1 {isVisible ? ( <img src="http://example.com/image01.jpg" width="300" height="300" /> ) : ( <div className="placeholder" /> )} Picture 2 {isVisible ? ( <img src="http://example.com/image02.jpg" width="300" height="300" /> ) : ( <div className="placeholder" /> )} </div> );
Если делаете так, убедитесь, что у изображений правильные ответные заголовки кэша, чтобы последующие запросы браузера подтягивали кэш и не загружали изображение повторно.
Если вы создаете компоненты состояния ленивой загрузки только для отслеживания того, что они хотя бы раз видны, то эту логику можно добавить в компонент Observer. В конце концов, Observer уже имеет состояние, он легко может вызывать свои дочерние компоненты с дополнительным аргументом hasBeenVisible.
const Page = () => { ... <Observer> {(isVisible, hasBeenVisible) => <Gallery hasBeenVisible /> // Gallery can be now stateless } </Observer> ... }
Или можно иметь вариант компонента Observer, который передает только свойство типа hasBeenVisible. Преимущество в том, что мы можем отключать IntersectionObserver, как только элемент попал в поле зрения, так как мы не будем менять его значение. Назовем этот компонент ObserverOnce:
class ObserverOnce extends Component { constructor() { super(); this.state = { hasBeenVisible: false }; this.io = null; this.container = null; } componentDidMount() { this.io = new IntersectionObserver(entries => { entries.forEach(entry => { if (entry.isIntersecting) { this.setState({ hasBeenVisible: true }); this.io.disconnect(); } }); }, {}); this.io.observe(this.container); } componentWillUnmount() { if (this.io) { this.io.disconnect(); } } render() { return ( <div ref={div => { this.container = div; }} > {Array.isArray(this.props.children) ? this.props.children.map(child => child(this.state.visible)) : this.props.children(this.state.visible)} </div> ); } }
Дополнительные примеры использования
Мы использовали компонент Observer для загрузки ресурсов по требованию. Его также можно использовать для анимации компонента, как только он попадает в поле зрения пользователя.
Ниже показан пример, взятый с сайта React Alicante. В примере анимируются цифры с конференции, когда пользователь прокручивает страницу до раздела.
Можно воссоздать (пример на Codepen):
class ConferenceData extends Component { constructor() { super(); this.state = { progress: 0 }; this.interval = null; this.animationDuration = 2000; this.startAnimation = null; } componentWillReceiveProps(nextProps) { if ( !this.props.isVisible && nextProps.isVisible && this.state.progress !== 1 ) { this.startAnimation = Date.now(); const tick = () => { const progress = Math.min( 1, (Date.now() - this.startAnimation) / this.animationDuration ); this.setState({ progress: progress }); if (progress < 1) { requestAnimationFrame(tick); } }; tick(); } } render() { return ( <div> {Math.floor(this.state.progress * 3)} days · {Math.floor(this.state.progress * 21)} talks · {Math.floor(this.state.progress * 4)} workshops · {Math.floor(this.state.progress * 350)} attendees </div> ); } }
Затем мы используем его точно так же, как и остальные компоненты. Это показывает мощь абстрагирования логики определения видимости за пределы компонентов, которым она нужна.
Polyfilling IntersectionObserver по требованию
До сих пор мы использовали IntersectionObserver для определения видимости элемента. На момент написания статьи некоторые браузеры (Safari) не поддерживают его, т.е. получение объекта IntersectionObserver вызовет ошибку.
Когда IntersectionObserver недоступен, можно установить isVisible в true, что на практике отключит ленивую загрузку. В некотором смысле ленивая загрузка рассматривается как прогрессивное улучшение:
class Observer extends Component { constructor() { super(); // isVisible is initialized to true if the browser // does not support IntersectionObserver API this.state = { isVisible: !(window.IntersectionObserver) }; this.io = null; this.container = null; } componentDidMount() { // only initialize the IntersectionObserver if supported if (window.IntersectionObserver) { this.io = new IntersectionObserver(entries => { ... } } } }
Также мне нравится подключать полифил как w3c’s IntersectionObserver полифил. Так IntersectionObserver будет работать во всех браузерах.
Следуя теме загрузки ресурсов по требованию и руководствуясь примером, мы воспользуемся разбиением кода только на запрос полифила по необходимости. Так если браузер поддерживает API, ему не понадобится скачивать полифил:
class Observer extends Component { ... componentDidMount() { (window.IntersectionObserver ? Promise.resolve() : import('intersection-observer') ).then(() => { this.io = new window.IntersectionObserver(entries => { entries.forEach(entry => { this.setState({ isVisible: entry.isIntersecting }); }); }, {}); this.io.observe(this.container); }); } ... }
Смотрите демо здесь (исходный код). Safari делает дополнительный запрос на загрузку npm пакета intersection-observer, так как браузер не поддерживает IntersectionObserver.
Это достигается разбиением кода. Есть инструменты типа Parcel или Webpack, которые создадут сборку для импортированных пакетов и логики, необходимой для запроса файла.
Разбиение кода и CSS-in-JS
До сих пор мы использовали HOC для определения видимости элемента. Мы также узнали. Как загружать дополнительный JS по необходимости.
Разбиение кода довольно распространено и доступно для реализации на уровне роутов, поэтому браузер загружает дополнительные пакеты, пока пользователь перемещается по разным URL на сайте. Реализацию упростили инструменты типа react-router и Next.js.
В примерах статьи мы видели, как того же самого можно достичь внутри одно роута, загружая код для компонентов по требованию. Это очень полезно, если у нас есть компоненты, которым нужно много определенного кода, не только JS.
Один компонент может ссылаться на другие ресурсы или лаже встраивать их. Вспомните SVG или CSS.
Нет смысла запрашивать стили, которые не будут применены ни к одному элементу. Динамический запрос и вставка CSS вызывают FOUC (Flash of Unstyled Content или мигание нестилизованного контента). Браузеры показывают HTML элементы с существующими стилями, а как только вставляются новые стили, он заново применяет их к контенту. С появлением решения CSS-in-JS (или JSS) это больше не проблема. CSS встроен внутрь компонента, и мы получаем настоящее разбиение кода для компонентов. С CSS-in-JS мы продвигаем разбиение кода еще дальше, загружая CSS по требованию.
Полезные реализации
В этой статья я объяснил, как реализовать базовый компонент Observer. Есть реализации похожих компонентов, которые лучше протестированы, поддерживают больше опций и дополнительные способы интеграции в проект.
Настоятельно рекомендую изучить эти 2 библиотеки:
Заключение
Надеюсь, я показал, как компонентное представление может сделать разбиение кода и загрузку ресурсов по требованию легче, чем когда-либо. Определите, от чего зависит ваш код и используйте упаковщики и современные инструменты для запроса зависимостей при необходимости, когда пользователь посещает новые пути, или когда на странице отображаются новые компоненты.
Автор: José M. Pérez
Источник: https://jmperezperez.com/
Редакция: Команда webformyself.