Это двадцатая часть серии мега-учебника Flask, в которой я собираюсь добавить приятное всплывающее окно при наведении курсора мыши на ник пользователя.
Оглавление
Глава 20: Немного магии JavaScript (Эта статья)
В настоящее время невозможно создать веб-приложение, которое не использует хотя бы немного JavaScript. Как я уверен, вы знаете, причина в том, что JavaScript - единственный язык, который изначально запускается в веб-браузерах. В главе 14 вы видели, как я добавил простую ссылку с поддержкой JavaScript в шаблон Flask, чтобы обеспечить перевод сообщений блога на язык реального времени. В этой главе я собираюсь углубиться в тему и показать вам еще один полезный прием JavaScript, позволяющий сделать приложение более интересным и привлекательной для пользователей.
Распространенный шаблон пользовательского интерфейса для социальных сайтов, в которых пользователи могут взаимодействовать друг с другом, заключается в отображении краткой информации о пользователе во всплывающей панели при наведении курсора мыши на имя пользователя в любом месте, где оно появляется на странице. Если вы никогда не обращали на это внимания, зайдите в Twitter, Facebook, LinkedIn или любую другую крупную социальную сеть, и когда вы увидите имя пользователя, просто наведите на него указатель мыши на пару секунд, чтобы увидеть всплывающее окно. Эта глава будет посвящена созданию этой функции для микроблога, предварительный просмотр которой вы можете увидеть ниже:
Следующая глава => Ссылки на GitHub для этой главы: Browse, Zip, Diff.
Поддержка на стороне сервера
Прежде чем мы углубимся в клиентскую часть, давайте разберемся с работой сервера, необходимой для поддержки этих пользовательских всплывающих окон. Содержимое всплывающего окна пользователя будет возвращено новым маршрутом, который будет упрощенной версией существующего маршрута профиля пользователя. Вот функция просмотра.:
app/main/routes.py: Функция просмотра всплывающих окон пользователем.
@bp.route('/user/<username>/popup')
@login_required
def user_popup(username):
user = db.first_or_404(sa.select(User).where(User.username == username))
form = EmptyForm()
return render_template('user_popup.html', user=user, form=form)
Этот маршрут будет привязан к URL-адресу /user/<имя пользователя>/popup и просто загрузит запрошенного пользователя, а затем отобразит шаблон с его помощью. Шаблон представляет собой сокращенную версию того, который использовался для страницы профиля пользователя:
app/templates/user_popup.html: Пользовательский всплывающий шаблон.
<div>
<img src="{{ user.avatar(64) }}" style="margin: 5px; float: left">
<p><a href="{{ url_for('main.user', username=user.username) }}">{{ user.username }}</a></p>
{% if user.about_me %}<p>{{ user.about_me }}</p>{% endif %}
<div class="clearfix"></div>
{% if user.last_seen %}
<p>{{ _('Last seen on') }}: {{ moment(user.last_seen).format('lll') }}</p>
{% endif %}
<p>{{ _('%(count)d followers', count=user.followers_count()) }}, {{ _('%(count)d following', count=user.following_count()) }}</p>
{% if user != current_user %}
{% if not current_user.is_following(user) %}
<p>
<form action="{{ url_for('main.follow', username=user.username) }}" method="post">
{{ form.hidden_tag() }}
{{ form.submit(value=_('Follow'), class_='btn btn-outline-primary btn-sm') }}
</form>
</p>
{% else %}
<p>
<form action="{{ url_for('main.unfollow', username=user.username) }}" method="post">
{{ form.hidden_tag() }}
{{ form.submit(value=_('Unfollow'), class_='btn btn-outline-primary btn-sm') }}
</form>
</p>
{% endif %}
{% endif %}
</div>
Компоненты всплывающего окна, которые я добавлю в следующих разделах, будут вызывать этот маршрут, когда пользователь наводит указатель мыши на имя пользователя. В ответ сервер вернет HTML-содержимое всплывающего окна, которое будет отображаться клиентом. Когда пользователь уберет курсор мыши, всплывающее окно будет удалено.
Введение в компонент Bootstrap Popover
В главе 11 я познакомил вас с фреймворком Bootstrap как удобным способом создания великолепно выглядящих веб-страниц. До сих пор я использовал лишь минимальную часть этого фреймворка. Bootstrap поставляется в комплекте со многими распространенными элементами пользовательского интерфейса, все из которых имеют демонстрации и примеры в документации по Bootstrap по адресу https://getbootstrap.com. Одним из таких компонентов является всплывающее окно, которое описано в документации как "небольшое наложение содержимого для размещения вторичной информации". Именно то, что мне нужно!
Большинство компонентов Bootstrap определяются с помощью HTML-разметки, которая ссылается на определения Bootstrap CSS, добавляющие приятный стиль. Некоторые из самых продвинутых компонентов также требуют JavaScript. Стандартный способ, которым приложение включает эти компоненты в веб-страницу, заключается в добавлении HTML в нужном месте, а затем для компонентов, которым требуется поддержка сценариев, вызывает функцию JavaScript, которая инициализирует или активирует их. Компоненту popover действительно требуется поддержка JavaScript.
Для начала мне просто нужно решить, какие элементы на странице будут вызывать появление всплывающих окон. Для этого я собираюсь использовать кликабельные имена пользователей, которые появляются в каждом сообщении. Во вложенном шаблоне app/templates/_post.html имя пользователя уже определено:
<a href="{{ url_for('main.user', username=post.author.username) }}">
{{ post.author.username }}
</a>
Теперь, согласно документации по всплывающим окнам, мне нужно создать объект класса bootstrap.Popover
для каждой из ссылок, подобных приведенной выше, которые появляются на странице, и инициализируют всплывающие окна. К сожалению, после прочтения этого у меня осталось больше вопросов, чем ответов, потому что этот компонент, похоже, разработан не для работы так, как мне нужно. Ниже приведен список проблем, которые мне нужно решить для реализации этой функции:
На странице будет много ссылок на имя пользователя, по одной на каждую отображаемую запись в блоге. Мне нужен способ найти все эти ссылки, чтобы затем я мог инициализировать их как всплывающие окна в функции JavaScript, которая запускается до того, как пользователь получит возможность взаимодействовать со страницей.
Все примеры всплывающих окон в документации Bootstrap предоставляют содержимое всплывающего окна в виде атрибута
data-bs-content
, добавляемого к целевому HTML-элементу. Это действительно неудобно для меня, потому что я хочу вызвать Ajax на сервере, чтобы получить содержимое для отображения во всплывающем окне.При использовании режима "наведения" всплывающее окно будет оставаться видимым до тех пор, пока вы удерживаете указатель мыши в пределах целевого элемента. Когда вы уберете курсор мыши, всплывающее окно исчезнет. У этого есть неприятный побочный эффект, заключающийся в том, что если пользователь захочет переместить указатель мыши в само всплывающее окно, всплывающее окно исчезнет. Мне нужно будет придумать способ расширить поведение при наведении курсора, включив в него также всплывающее окно, чтобы пользователь мог перейти во всплывающее окно и, например, нажать там на ссылку.
На самом деле не так уж редко при работе с браузерными приложениями все усложняется очень быстро. Вы должны очень конкретно продумать, как элементы DOM взаимодействуют друг с другом, и заставить их вести себя таким образом, чтобы пользователю было удобно. В следующих разделах я буду рассматривать вышеперечисленные проблемы одну за другой.
Выполнение функции при загрузке страницы
Всплывающий компонент должен быть явно инициализирован JavaScript, поэтому ясно, что мне нужно будет запускать некоторый код, как только загружается каждая страница, который будет искать все ссылки на имена пользователей на странице и инициализировать всплывающие компоненты из Bootstrap на них.
Стандартный способ запуска кода инициализации в современных браузерах после завершения загрузки страницы - определить обработчик для события DOMContentLoaded
. Я могу добавить этот обработчик в шаблон app/templates/base.html, чтобы он запускался на каждой странице приложения:
app/templates/base.html: Запуск функции после загрузки страницы.
...
<script>
// ...
function initialize_popovers() {
// write initialization code here
}
document.addEventListener('DOMContentLoaded', initialize_popovers);
</script>
Как вы видите, я добавил свою функцию инициализации внутрь элемента <script>
, в котором я определил функцию translate()
в главе 14.
Поиск элементов DOM с помощью селекторов
Моя следующая задача - написать логику JavaScript, которая находит все пользовательские ссылки на странице.
Если вы помните из главы 14, HTML-элементы, задействованные в прямых переводах, имели уникальные идентификаторы. Например, к записи с ID=123 был добавлен атрибут id="post123"
. Тогда я мог бы использовать функцию document.getElementById()
, чтобы найти этот элемент в DOM. Эта функция является частью группы, которая позволяет приложениям, запущенным в браузере, находить элементы на основе их характеристик.
Для функции перевода мне нужно было найти определенный элемент с атрибутом id
, который однозначно идентифицирует элементы на странице. Другой вариант поиска, который более подходит при поиске групп элементов, - это добавить к ним класс CSS. В отличие от этого, один класс CSS может быть присвоен нескольким элементам, поэтому он идеально подходит для поиска всех ссылок, требующих всплывающих окон. Что я собираюсь сделать, так это пометить все пользовательские ссылки атрибутом class="user_popup"
, а затем я получу список ссылок из JavaScript с помощью document.getElementsByClassName('user_popup')
. Возвращаемым значением в этом случае будет коллекция всех элементов, у которых есть нужный мне класс.
app/templates/base.html: Запуск функции после загрузки страницы.
...
<script>
// ...
function initialize_popovers() {
const popups = document.getElementsByClassName('user_popup');
for (let i = 0; i < popups.length; i++) {
// create popover here
}
}
document.addEventListener('DOMContentLoaded', initialize_popovers);
</script>
Всплывающие окна и DOM
В предыдущем разделе я добавил код для инициализации, который ищет все элементы на странице, которым был назначен класс user_popup
. Этот класс должен быть добавлен к ссылкам с именами пользователей, которые определены в подшаблоне страницы _post.html.
app/templates/_post.html: Шаблон со всплывающей информацией о пользователе.
...
{% set user_link %}
<a class="user_popup"
href="{{ url_for('main.user', username=post.author.username) }}">
{{ post.author.username }}
</a>
{% endset %}
...
Если вам интересно, где определяются всплывающие HTML-элементы, хорошей новостью является то, что мне не нужно беспокоиться об этом. Когда я создаю Popover
обратите внимание, что фреймворк Bootstrap будет динамически вставлять элементы для меня элементы, связанные со всплывающим окном.
Создание всплывающих компонентов
Теперь я готов создать компоненты всплывающего окна для всех ссылок с именами пользователей, найденных на странице.
app/templates/base.html: Задержка при наведении курсора мыши.
function initialize_popovers() {
const popups = document.getElementsByClassName('user_popup');
for (let i = 0; i < popups.length; i++) {
const popover = new bootstrap.Popover(popups[i], {
content: 'Loading...',
trigger: 'hover focus',
placement: 'right',
html: true,
sanitize: false,
delay: {show: 500, hide: 0},
container: popups[i],
customClass: 'd-inline',
});
}
}
document.addEventListener('DOMContentLoaded', initialize_popovers);
Конструктор bootstrap.Popover
принимает элемент, который получает всплывающее окно, в качестве первого аргумента, и объект с параметрами в качестве второго аргумента. Параметры включают содержимое, которое будет отображаться во всплывающем окне, какой метод использовать, чтобы вызвать появление или исчезновение всплывающего окна (щелчок, наведение курсора мыши на элемент и т.д.). Размещение всплывающего окна, если содержимое представляет собой обычный текст или HTML, и еще несколько параметров.
Я упоминал выше, что большая проблема с этой реализацией всплывающего окна заключается в том, что содержимое HTML, которое необходимо отобразить, получается путем отправки запроса на сервер. По этой причине я инициализирую содержимое с помощью текста Loading...
, который будет динамически заменяться, как только HTML-содержимое пользователя будет получено с сервера. Готовясь к этому, я установил для параметра html
значение true
, а также отключил опцию очистки содержимого HTML. Очистка HTML - это функция безопасности, которую очень важно использовать, когда содержимое поступает от пользователей. В этом случае HTML-содержимое генерируется сервером Flask с помощью шаблона Jinja, который по умолчанию очищает все динамическое содержимое.
Опция delay
настраивает компонент всплывающего окна так, чтобы он появлялся через полсекунды после наведения курсора мыши. Задержка не настраивается, когда всплывающее окно удаляется в результате того, что пользователь отводит указатель мыши в сторону. Опция container
сообщает Bootstrap вставить всплывающий компонент в качестве дочернего элемента ссылки . Это часто рекомендуемый прием, позволяющий пользователю перемещать указатель мыши во всплывающее окно, не вызывая исчезновения всплывающего окна. Опция customClass
присваивает элементу <div>
, представляющему всплывающий компонент, стиль отображения, равный d-inline
. Это гарантирует, что при вставке компонента он не добавляет разрыв строки в этом месте страницы из-за стиля отображения по умолчанию равному block
для элементов <div>
.
Ajax - запросы
Ajax-запросы - тема не новая, поскольку я уже представлял ее в Главе 14 как часть функции перевода на живой язык. Как и прежде, я собираюсь использовать функцию fetch()
для отправки асинхронного запроса на сервер.
Запрос должен содержать URL /user/<username>/popup, который я добавил в приложение в начале этой главы. Ответ на этот запрос будет содержать HTML, который мне нужно вставить во всплывающий компонент, заменив начальное сообщение "Loading ...".
Моя первая проблема заключается в том, как запустить запрос во время запроса всплывающего окна пользователем путем наведения курсора мыши на ссылку пользователя. Просматривая документацию по всплывающему окну, в разделе, описывающем события, поддерживаемые этим компонентом, есть одно, называемое show.bs.popover
, которое срабатывает, когда вот-вот будет показан всплывающий компонент.
app/templates/base.html: Задержка при наведении курсора мыши.
function initialize_popovers() {
const popups = document.getElementsByClassName('user_popup');
for (let i = 0; i < popups.length; i++) {
const popover = new bootstrap.Popover(popups[i], {
...
});
popups[i].addEventListener('show.bs.popover', async (ev) => {
// send request here
});
}
}
document.addEventListener('DOMContentLoaded', initialize_popovers);
Следующий вопрос заключается в том, как узнать, какое имя пользователя мне нужно включить в URL этого запроса. Это имя есть в тексте тега <a>
. Событие show для компонента popover получает аргумент ev
с объектом события . Элемент, который вызвал событие, может быть получен с помощью ev.target
, а выражение для извлечения текста этой ссылки:
ev.target.innerText.trim()
Свойство innerText
элемента страницы возвращает текстовое содержимое узла. Текст возвращается без какой-либо обрезки, так что, например, если у вас есть <a>
в одной строке, текст в следующей строке и закрывающий </a>
в другой строке, innerText
вернет перевод строк и дополнительные пробелы, окружающие текст. Чтобы убрать все эти пробелы и оставить только текст, я использую функцию JavaScript trim()
.
И это вся информация, которая мне нужна, чтобы иметь возможность отправить запрос на сервер:
app/templates/base.html: Запрос XHR.
function initialize_popovers() {
const popups = document.getElementsByClassName('user_popup');
for (let i = 0; i < popups.length; i++) {
const popover = new bootstrap.Popover(popups[i], {
...
});
popups[i].addEventListener('show.bs.popover', async (ev) => {
const response = await fetch('/user/' + ev.target.innerText.trim() + '/popup');
const data = await response.text();
// update popover here
});
}
}
document.addEventListener('DOMContentLoaded', initialize_popovers);
Здесь я использую функцию fetch()
для запроса URL-адреса /popup для пользователя, представленного элементом ссылки. К сожалению, при создании URL-адресов непосредственно на стороне JavaScript я не могу использовать url_for()
из Flask, поэтому в этом случае мне нужно явно объединить части URL. Получив ответ, я извлекаю из него текст в data
. Это HTML-код, который необходимо сохранить во всплывающем компоненте.
Всплывающее обновление
Так, наконец, теперь я могу обновить свой компонент с помощью HTML, который был получен с сервера и хранится data
:
app/templates/base.html: Отображение всплывающего окна.
function initialize_popovers() {
const popups = document.getElementsByClassName('user_popup');
for (let i = 0; i < popups.length; i++) {
const popover = new bootstrap.Popover(popups[i], {
...
});
popups[i].addEventListener('show.bs.popover', async (ev) => {
if (ev.target.popupLoaded) {
return;
}
const response = await fetch('/user/' + ev.target.innerText.trim() + '/popup');
const data = await response.text();
const popover = bootstrap.Popover.getInstance(ev.target);
if (popover && data) {
ev.target.popupLoaded = true;
popover.setContent({'.popover-body': data});
flask_moment_render_all();
}
});
}
}
document.addEventListener('DOMContentLoaded', initialize_popovers);
Для выполнения этого обновления я сначала получаю всплывающий компонент, который Bootstrap делает доступным с помощью метода getInstance()
. Если у меня есть всплывающий компонент и HTML-содержимое, я вызываю у всплывающего компонента метод setContent()
для обновления его тела.
В качестве оптимизации я также установил для атрибута popupLoaded
значение true
в элементе ссылки . Это для того, чтобы я не отправлял повторно тот же запрос, если всплывающее окно открывается во второй раз. Обратите внимание, как я обновил начало обработчика событий show
, чтобы проверить наличие атрибута popupLoaded
, и если он установлен, то выполнение функции просто прекратится, поскольку содержимое всплывающего окна уже было обновлено.
Последняя деталь, о которой необходимо позаботиться, - это дата и время, включенные в HTML-содержимое, вставляемое во всплывающие компоненты. Эти временные метки генерируются с помощью функции moment()
Flask-Moment в шаблоне Jinja. Flask-Moment добавляет на страницу код JavaScript, который отображает все временные метки при загрузке страницы. Когда новые временные метки добавляются динамически после загрузки страницы, функцию flask_moment_render_all()
необходимо вызывать вручную для рендеринга, поэтому я вставил вызов этой функции после обновления содержимого HTML.