本系列删改自Miguel Grinberg的Flask Mega-Tutorial系列。点此查看作者原文
服务端支持
在我们探究客户端之前,让我们在服务端做点工作,对于支持弹出框来说,这是必要的方式。弹出框的内容将会在新路由中返回,这是现有的用户资料路由的简化版本。这是视图函数:
app/main/routes.py
@bp.route('/user/<username>/popup')
@login_required
def user_popup(username):
user = User.query.filter_by(username=username).first_or_404()
return render_template('user_popup.html', user=user)
这个路由将与 /user/<username>/popup URL相关联,然后会加载被请求用户然后渲染模板。其模板是用户资料页面的简短版本:
app/templates/user_popup.html
<table class="table">
<tr>
<td width="64" style="border: 0px;"><img src="{{ user.avatar(64) }}"></td>
<td style="border: 0px;">
<p>
<a href="{{ url_for('main.user', username=user.username) }}">
{{ user.username }}
</a>
</p>
<small>
{% if user.about_me %}<p>{{ user.about_me }}</p>{% endif %}
{% 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.followed.count()) }}</p>
{% if user != current_user %}
{% if not current_user.is_following(user) %}
<a href="{{ url_for('main.follow', username=user.username) }}">
{{ _('Follow') }}
</a>
{% else %}
<a href="{{ url_for('main.unfollow', username=user.username) }}">
{{ _('Unfollow') }}
</a>
{% endif %}
{% endif %}
</small>
</td>
</tr>
</table>
当用户将鼠标指针悬停于一个用户名上时,下面我写的JavaScript代码将会调用此路由。服务器将会将HTML内容作为响应返回到弹出框,随后客户端将显示它。当用户将鼠标移开,弹出框将会消失。
如果你想知道弹出框看起来什么样,你现在就可以运行应用,进入任何一个用户的资料页面,然后在地址栏的URL后面追加 /popup,就能看到全屏版本的弹出框内容了。
Bootstrap Popover组件
在第11章中我介绍了Bootstrap框架来方便的创建好看的web页面。到目前为止,我只用到这个框架中很小的一部分。Bootstrap捆绑了很多常用的UI元素,Bootstrap文档 https://getbootstrap.com 中有所有这些组件的演示和示例。其中就有 Popover,文档中称其为“覆盖小部分内容,用于放置次要信息”,正是我需要的!
大多Bootstrap组件都是HTML标记定义的,引用Bootstrap CSS来添加好看的样式。其中有一些高级的依赖JavaScript。一般使在web页面中使用这些组件的方法是在适当的位置添加HTML,然后为需要脚本支持的组件调用初始化或激活它的JavaScript函数。而这个popover组件就是依赖JavaScript支持的。
实现popover的HTML部分是很简单的,你只需要定义用于触发popover显示的元素就行。在我的例子中,将会是每个博客帖子的可点击的用户名。 app/templates/_post.html 子模版中有已经定义好的用户名:
<a href="{{ url_for('main.user', username=post.author.username) }}">
{{ post.author.username }}
</a>
根据popover的文档,和上面那个一样,我得在每个链接上调用 popover() JavaScript函数,这将会初始化popup。该初始化调用接受许多选项来配置该popup,包括传递你想要显示在popup的内容的选项,用什么方法触发popup显示或不显示(一个点击,在元素上悬停,等等),内容是不是空白文本或HTML,在文档中你还能看到几个其他的选项。不幸的是,看完这些信息我的疑问更多了,因为这个组件似乎并没有被设计成我需要的方式工作。要实现这个功能,我有以下几个问题需要解决:
- 页面中会有很多的用户名链接,每个都对应一个博客帖子。我得找到一个方法来在页面被渲染后找到所有这些链接,这样我才能将它们初始化为popover。
- Bootstrap文档中的popover例子都将popover的内容作为一个data-content属性来添加到目标HTML元素,因此当悬停事件被触发后,Bootstrap要做的就算显示popover。这对我来说真的很麻烦,因为我想像服务器发起Ajax调用来获取内容,并且只有在接收到服务器响应后才显示popup。
- 当使用“hover”模式时,只要你吧指针指向目标的元素,popup会一直保持显示。当你移开鼠标,popup就会消失。如果用户想把鼠标指针移向popup本身,会带来很丑的副作用,popup会消失。我得想个办法拓展悬停行为,使之包括popup本身,这样用户可以把指针移向popup,然后,比如说,点击其中的链接。
事实上,在使用基于浏览器的应用时,事情很快变得很复杂,这并不少见。你必须仔细的思考DOM元素是如何相互作用的,并使它们的行为方式能给用户良好的用户体验。
在页面加载时执行函数
很明显在每个页面加载时我需要运行一些JavaScript代码。我要运行的函数会搜索页面中所有username链接,然后将它们配置到Bootstrap的popover组件。
JavaScript的jQuery库是Bootstrap的依赖项,所以我会利用它。使用jQuery时,你可以通过在$(...)中包装页面来加载该函数以运行,我可以将它添加到 app/templates/base.html 模板中,这样使之在应用程序中的每个页面都运行:
app/template/base.html
...
<script>
// ...
$(function() {
// write start up code here
});
</script>
如你所见,我已经将我的起始函数添加到了<script>元素中。
用选择器查找DOM元素
我的第一个问题是创建一个JavaScript函数来查找页面中所有的user链接。这个函数会在页面载入结束后运行,完成后会为它们配置悬停和popup行为。现在我要专注于找到链接。
如果你还记得在第14章中,涉及到在线翻译的HTML元素有唯一ID。比如,ID为123的帖子有一个 id="post123"属性。然后使用jQuery,$('#post123')用于在DOM中找到这个元素。$()函数非常强大,并且基于CSS选择器可以搜索到DOM元素。
我用于翻译功能的选择器是设计来查找具有唯一标识符比如id属性的特定元素。标识元素的另一个选择是使用class属性,同一个class可以被分配给页面的多个元素。比如,我可以将所有的用户链接用 class="user_popup"标记,然后我可以用JavaScript的 $('.user_popup')获取链接列表(在CSS选择器中,#前缀用于搜索ID,而.前缀用于搜索class)。在本例中的返回值会是具有该class的所有属性的集合。
Popover和DOM
通过在Bootstrap文档中播放Popover示例并在浏览器调试器中检查DOM,我确定了Bootstrap将popover组件创建为DOM中目标元素的兄弟元素。正如我先前提到的,这影响了悬停事件的行为,用户一把鼠标从<a>链接移开到popup本身,,就会触发“mouse out”。
让popover成为目标元素的子元素,就可以拓展悬停事件来包含popover本身,这样就可以继承悬停事件。查看文档中popover的选项,可以通过在container选项传递父元素来实现。
对于按钮,一般的<div>,<span>元素来说,让popover成为其子元素会很好使,但在本例中,popover的目标是个显示可点击用户名的链接的<a>元素。问题是,让popover成为<a>元素的子元素会使其获得其父元素<a>的链接行为。最后的结果会是像这样的:
<a href="..." class="user_popup">
username
<div> ... popover elements here ... </div>
</a>
为了避免popover早<a>元素里面,我会使用另一个技巧。我会将<a>元素包含在一个<span>元素中,然后将悬停事件和popover也放在<span>里。最后的结构会是这样的:
<span class="user_popup">
<a href="...">
username
</a>
<div> ... popover elements here ... </div>
</span>
<div>和<span>元素是不可见的,所以这是用于组织和构造DOM的绝佳元素。<div>元素是一个块元素,和HTML文档中的段落有点像,而<span>元素是个是个内联元素,和一个单词差不多。在本例中我决定用<span>元素,因为内嵌的<a>元素也是一个内联元素。
接下来我将重构 app/templates/_post.html 子模版,使其包含<span>元素:
...
{% set user_link %}
<span class="user_popup">
<a href="url_for('main.user', username=post.author.username)">
{{ post.author.username }}
</a>
</span>
{% endset %}
...
如果你好奇popover HTML元素在哪,好消息是不用担心。当我在刚创建的<span>元素上调用popover()初始化函数时, Bootstrap框架将会动态的插入popup组件。
悬停事件
正如前面提到的,Bootstrap中使用的popover的悬停行为不够灵活,无法满足我的需要,但如果你看一下文档中的trigger选项,“悬停”只是其中一个可能的值。吸引我的是“manual”模式,通过JavaScript调用可以手动地显示或者移除popover。这个模式使我可以自由实现自己的悬停逻辑,所以我将使用这个选项,实现我自己的悬停逻辑来按照我需要的方式工作。
下一步我要做的是将“hover”事件添加到本页所有的链接上。使用jQuery调用 element.hover(handlerIn, handlerOut) 可以将悬停事件添加到任何HTML元素上。如果在一组元素上调用此函数,jQuery可以将事件添加到所有元素上。两个参数是两个函数,当用户将鼠标指针移到或移出目标元素时会分别被调用。
app/templates/base.html
$(function() {
$('.user_popup').hover(
function(event) {
// mouse in event handler
var elem = event.currentTarget;
},
function(event) {
// mouse out event handler
var elem = event.currentTarget;
}
)
});
event参数是一个事件对象,包含了有用的信息。在本例中,我用 event.currentTarget 提取了事件的目标元素。
浏览器在鼠标进入受影响的元素后立即发起悬停事件。在popup的情况中,我只希望在鼠标停留在元素上一段事件后才激活此事件,这样当鼠标指针快速滑过元素但没有停留时不会有popup闪现。由于此事件不支持延时,这是另一个我需要自己实现的功能。所以我将会向“mouse in”事件处理器加入1秒计时器。
app/templates/base.html
$(function() {
var timer = null;
$('.user_popup').hover(
function(event) {
// mouse in event handler
var elem = event.currentTarget;
timer = setTimeout(function() {
timer = null;
// popup logic goes here
}, 1000);
},
function(event) {
// mouse out event handler
var elem = event.currentTarget;
if (timer) {
clearTimeout(timer);
timer = null;
}
}
)
});
setTimeout()函数在浏览器环境中可用。它接收两个参数,一个函数和一个以毫秒为单位的时间。setTimeout() 的作用是函数是在给定延时之后执行的。所以我现在添加了一个空的函数,它会在悬停事件发生一秒后被调用。多亏了JavaScript语言中的闭包,这个函数可以访问在外部作用域定义的变量,比如 elem。
我将timer对象存储在我定义的hover()调用外的timer变量中,为了让“mouse out”处理器也能访问timer对象。我这么做的原因是为了良好的用户体验。如果用户将鼠标指针移入其中一个用户链接并且停留在那,比如说,半秒后移开,我不想计时器依然调用那个显示popup的函数。所以我的鼠标移开事件处理器检查是否还有有效的timer对象,如果有,则取消。
Ajax请求
Ajax请求并不是什么新的话题,在14章,作为在线语言翻译功能的一部分,我介绍了这个话题。使用jQuery时,$.ajax()函数像服务端发送一个匿名请求。
我发送给服务器的请求有 /user/<username>/popup URL,也就是本章开头我添加的URL。这个请求的响应将包含我要插入到popup的HTML。
目前我对这个请求的问题是,如何知道在URL中所需的username的值。事件处理函数的鼠标是通用的,它将查找页面中找到的所有用户链接,所以函数需要搞清楚上下文环境中的用户名。
elem变量包含了悬停事件的目标元素, 也就是<span>元素包围的<a>元素。要获取用户名,我可以从DOM的<span>元素开始,移动到其第一个子元素,也就是<a>元素,然后获取其文本,也就是我的URL中所需的用户名。用jQuery的DOM遍历函数很简单:
elem.first().text().trim()
应用到DOM节点的first()函数返回其第一个子元素。text()函数返回节点的文本内容。这个函数不会对文本做其他的事情,比如说,如果一个行中有个<a>,文本在下一行,</a>则在另一伙,text()会返回文本周围的所有空白符。我使用JavaScript的trim()函数消除所有的空白符,只留下文本。
这是我所需的能够向服务器端发起请求的所有信息:
app/templates/base.html
$(function() {
var timer = null;
var xhr = null;
$('.user_popup').hover(
function(event) {
// mouse in event handler
var elem = $(event.currentTarget);
timer = setTimeout(function() {
timer = null;
xhr = $.ajax(
'/user/' + elem.first().text().trim() + '/popup').done(
function(data) {
xhr = null
// create and display popup here
}
);
}, 1000);
},
function(event) {
// mouse out event handler
var elem = $(event.currentTarget);
if (timer) {
clearTimeout(timer);
timer = null;
}
else if (xhr) {
xhr.abort();
xhr = null;
}
else {
// destroy popup here
}
}
)
});
这里我在外部作用域定义了一个新的变量,xhr。这个变量将用于保存异步请求对象,也就是我初始化到$.ajax()的。不幸的是,我不能用Flask的url_for()在JavaScript端直接创建URL,所以我将显式地将URL拼接起来。
$.ajax()返回一个promise,这是表示异步操作的特殊JavaScript对象。通过添加一个.done(function)我可以加上完成回调,因此一旦请求完成,我就可以调用我的回调函数。回调函数会接收响应作为参数,即上面代码中我命名为data的参数,这就是我要放在popover中的HTML内容。
在实现popover之前,还有一个可以提升用户体验的细节要解决。回想一下,我给“mouse out”事件处理器函数添加了逻辑,当用户将鼠标指针移出时,取消一秒延迟。我需要向异步请求应用同样的想法,所以我添加了第二个子句,当xhr请求对象已存在时中止它。
创建与取消Popover
最后,我终于可以创建我自己的popover组件了,使用Ajax回调函数中传递给我的data参数:
app/templates/base.html
function(data) {
xhr = null;
elem.popover({
trigger: 'manual',
html: true,
animation: false,
container: elem,
content: data
}).popover('show');
flask_moment_render_all();
}
popup的实际创建很简单,Bootstrap的popover函数做了所有的主要工作。popover的选项以参数的形式给定。我已经将此popover配置了“manual”触发模式,HTML内容,没有渐变动画(使它快速出现与消失),以及我把<span>元素的父元素设置为其本身,使得popover继承其悬停行为。最后,我将data参数作为content参数传递到Ajax回调。
popover()调用的返回是新创建的popover组件,由于奇怪的原因,有另一个也叫popover()的方法用于显示它。因此我得添加第二个 popover('show')调用来时popover可以显示到页面上。
popup的内容包含了“last seen”日期,由Flask-Moment插件生成,当新的Flask-Moment元素通过Ajax添加,需要调用flask_moment_render_all()函数来呈现这些元素。
现在剩下的就是处理鼠标移除时间处理器的删除popup问题。这个处理器已经有了在用户将鼠标移出目标元素时中止popover操作的逻辑。如果这些条件都不适用,则表示popover当前已经显示,用户正在移开目标区域,因此在这种情况下,popover('destroy')调用将会目标元素进行清理。
app/templates/base.html
function(event) {
// mouse out event handler
var elem = $(event.currentTarget);
if (timer) {
clearTimeout(timer);
timer = null;
}
else if (xhr) {
xhr.abort();
xhr = null;
}
else {
elem.popover('destroy');
}
}