半年前,因为VPS未续费导致所有数据丢失,直至今日终于重新恢复了所有的文章数据(虽然丢失了全部的评论),并且借此机会对所有文章进行了一次重新审视,修改了部分问题,并将所有示例迁移到 jsfiddle和 jsperf上,总算造一段落。
新的博客完全独立建设,不使用任何第三方的CMS系统,后端使用ASP.NET MVC实现,数据库使用MySQL,通过Mono部署于Ubuntu Server之上,前端使用nginx作为静态服务器。
也正因为完全独立构建,不受任何系统出于安全、简便等奇怪理由而附加的限制,这个博客系统也成了自己练手的娱乐场。就比如本篇要介绍的OPOA化实践。
OPOA,全称 One Page One Application,中文可以称之为 单页应用。
顾名思义,在OPOA下,一个页面组成一个应用,不再以传统的超链接跳转导航的方式,而是通过javascript以 XMLHttpRequest加载数据,通过 DOM操作展现数据。
作为一个单页应用,其优势主要有:
纵观我的博客的结构:
可以发现,博客基本上由 页首、 侧边栏 、主内容3部分组成。而其中 页首和 侧边栏 的内容是始终不变的,博客中所有的页面均只是填充主内容区域。
从后端的实现上来说,大致的结构是这样的:
标题
副标题
@RenderBody()
其中侧边栏的内容虽然永远是固定的,但是会调用 TagCloud
和 ArchiveList
两个子Action,显而易见这2个Action又会有数据库的查询。因此,如果排除不变的这部分,只更新 div[id="main"]
部分的内容,每一次浏览均可减少2次的数据库查询和一定量的HTML内容传输,有着不错的收益。
本次实现OPOA的目标有:
因此尽管业界有不少OPOA的解决方案,如 Backbone等,但是其引入的复杂性会导致后端(包括输出的HTML)的大量的变更,并不适合本次实现。还是自己实现一次来得更有优势。
再回到最初的目的,我们要把 超链接导航改为 XMLHttpRequest加载数据并渲染,而超链接是由 元素产生的。因此从基本的解决方案而言,我们需要做的是:
元素的点击事件。div[id="main"]
容器中。当然其中会有很多的细节,后文主要就讲述这些问题。
也许在几年前, 拦截所有 元素这事看上去并不那么简单,由于动态的脚本的存在,很有可能动态地加上
元素,这些新增的元素如何绑定事件会变成一个课题。
然而在 事件冒泡这一概念已经普及的如今,在jQuery推出了 delegate
函数,并进一步整合进 on
函数之后,这一需求的实现之简单也已经被全民所理解。
在这一块唯一需要注意的是,并不是所有的链接都属于本站,因此需要对链接的 href
属性进行一定的判断。判断的条件无非2个:
对于第1点太容易判断了,而第2点需要解析 href
属性分出 protocol、 domain 、port等信息,由于浏览器中的javascript并没有相应的方法,自己实现也不怎么有趣,而个人的博客在后端输出时,应当被拦截的链接地址都是相对地址,因此暂时忽略了。
综合以上,对于元素的点击事件的拦截,代码相当简单:
$('#page').on(
'click',
'a',
function() {
var href = $(this).attr('href');
if (href.indexOf('/') !== 0) {
return;
}
loadPage(href, true);
return false;
}
);
对于 加载远程页面这一需求,自然不再赘述,随便用个 $.get
函数就搞定问题了。比较麻烦的是,获取到后端给定的HTML之后,如何有效地更新当前页面。这一动作需要满足以下需求:
div[id="main"]
部分。而后端为了 尽可能少地改造,返回回来的必然是一个HTML片段,而不会像一些成熟的OPOA应用一样,返回一个JSON结构,其中包含了依赖资源、HTML片段等一系列数据的描述。因此javascript需要做的是,从HTML片段中分析出 相关资源 以及具体内容并插入到当前页面中。
好在我们有jQuery的帮助,将整个HTML片段传给jQuery,看是否可以得到需要的内容:
console.log($(html));
输出:
[#text, , #text, , #text,
通过 IsAjaxRequest
方法判断是否为AJAX请求(具体实现是通过对 X-Request-With
头的判断),如果非Ajax请求则全页输出,反之则只输出必要的部分。当然这其中还有一些冗余(比如DOCTYPE),但并不重要,最主要影响性能的2个子Action被省略,已经有足够的收益。
最后还有一个很棘手的问题,一直无法得到解决,我将其归结与History接口设计的问题。
当你点击页面中一个改变hash的锚点,即一个 href
属性以 #开头的 元素时,会触发
popstate
事件,并且其中的 e.state
是 null。这显然是合理的,通过对 e.state
的判断, popstate
事件的处理函数不会进行任何动作,进而浏览器会对锚点进行跟踪,改变页面滚动条的位置。
问题出现在这之后,如果你点击“后退”按钮,由于hash再一次改变,又会触发一次 popsate
事件。在这一事件中, e.state
是之前一次 pushState
函数调用时存放的内容。
也就是说,针对这一次 popstate
事件,在代码层面,是无法判断 从另一个页面的跳转还是 hash的改变。然则针对这2种情况,显然应当进行不同的处理:如果是页面的跳转,需要重新渲染 div[id="main"]
部分,而如果仅仅是hash的变化,页面不应该进行重新渲染。
可惜的是, popstate
事件并没有提供足够的信息来判断这一点,因此现在的系统中,点击一个改变hash的锚点后,再点击“后退”按钮,页面是会出现一个动画效果的。这虽然并不影响浏览,但与真实浏览器的表现有所区别,并不是那么让人愉快的事情。
假设 popstate
事件可以提供更多的信息,比如通过 relatedURL
提供来源页面的URL,则可以通过对URL的分析,假定 pathname
和 search
相同的情况为锚点的跳转,不执行 updatePage
函数,便可以保持与浏览器的标准行为一致。
本文使用一个实际的案例,分步骤地讲述了一个十分简单的多页系统改变为OPOA的过程,并且解释了其中容易遇到的一些问题,以及一些细节上的处理。
同时,本文作为对History接口应用的一次尝试,发现了接口设计和实现中一些存在的问题,并提供了部分问题的解决方案。
对于History接口的进一步说明,可以参考以下资料: