博客单页化实践
半年前,因为VPS未续费导致所有数据丢失,直至今日终于重新恢复了所有的文章数据(虽然丢失了全部的评论),并且借此机会对所有文章进行了一次重新审视,修改了部分问题,并将所有示例迁移到 jsfiddle和 jsperf上,总算造一段落。
新的博客完全独立建设,不使用任何第三方的CMS系统,后端使用ASP.NET MVC实现,数据库使用MySQL,通过Mono部署于Ubuntu Server之上,前端使用nginx作为静态服务器。
也正因为完全独立构建,不受任何系统出于安全、简便等奇怪理由而附加的限制,这个博客系统也成了自己练手的娱乐场。就比如本篇要介绍的OPOA化实践。
概念
OPOA,全称 One Page One Application,中文可以称之为 单页应用。
顾名思义,在OPOA下,一个页面组成一个应用,不再以传统的超链接跳转导航的方式,而是通过javascript以 XMLHttpRequest加载数据,通过 DOM操作展现数据。
作为一个单页应用,其优势主要有:
- 多个页面拥有相同的结构时,一些相同的内容(如侧边栏、LOGO等)不需要重复加载,节省流量(及一定的数据库查询)。
- 没有浏览器跳转地址导致的短暂空白页面状态,提升用户体验。
- 可以增加过渡效果(如渐隐、渐显等),进一步提升体验。
纵观我的博客的结构:
可以发现,博客基本上由 页首、 侧边栏 、主内容3部分组成。而其中 页首和 侧边栏 的内容是始终不变的,博客中所有的页面均只是填充主内容区域。
从后端的实现上来说,大致的结构是这样的:
<header>
<hgroup>
<h1>标题</h1>
<h2>副标题</h2>
</hgroup>
</header>
<div id="page">
<div id="main">
@RenderBody()
</div>
<aside id="sidebar">
<section id="search">
<!-- 搜索框 -->
</section>
<section id="links">
<!-- 订阅链接 -->
</section>
<section id="tag-cloud">
<h1>标签</h1>
@Html.Action("TagCloud")
</section>
<section id="archive">
<h1>存档</h1>
@Html.Action("ArchiveList")
</section>
</aside>
</div>
<script src="http://hm.baidu.com/h.js" async="async"></script>
其中侧边栏的内容虽然永远是固定的,但是会调用 TagCloud
和 ArchiveList
两个子Action,显而易见这2个Action又会有数据库的查询。因此,如果排除不变的这部分,只更新 div[id="main"]
部分的内容,每一次浏览均可减少2次的数据库查询和一定量的HTML内容传输,有着不错的收益。
设计
本次实现OPOA的目标有:
- 在 尽量少地引入后端改动的前提下完成。
- 仅针对相关API支持到位的浏览器,其它浏览器保持降级,使用标准的超链接导航方式浏览。
因此尽管业界有不少OPOA的解决方案,如 Backbone等,但是其引入的复杂性会导致后端(包括输出的HTML)的大量的变更,并不适合本次实现。还是自己实现一次来得更有优势。
再回到最初的目的,我们要把 超链接导航改为 XMLHttpRequest加载数据并渲染,而超链接是由 <a>
元素产生的。因此从基本的解决方案而言,我们需要做的是:
- 拦截所有
<a>
元素的点击事件。 - 点击发生时,取消掉默认的跳转行为,改用XMLHttpRequest加载相应页面数据。
- 解析相应数据,放到
div[id="main"]
容器中。 - 模拟浏览器的行为,改变地址栏、标题等。
当然其中会有很多的细节,后文主要就讲述这些问题。
实现
元素拦截
也许在几年前, 拦截所有 <a>
元素这事看上去并不那么简单,由于动态的脚本的存在,很有可能动态地加上 <a>
元素,这些新增的元素如何绑定事件会变成一个课题。
然而在 事件冒泡这一概念已经普及的如今,在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之后,如何有效地更新当前页面。这一动作需要满足以下需求:
- 相关的资源都正确加载,包含但不限于css和js文件。
- 只更新需要更新的部分,即
div[id="main"]
部分。
而后端为了 尽可能少地改造,返回回来的必然是一个HTML片段,而不会像一些成熟的OPOA应用一样,返回一个JSON结构,其中包含了依赖资源、HTML片段等一系列数据的描述。因此javascript需要做的是,从HTML片段中分析出 相关资源 以及具体内容并插入到当前页面中。
好在我们有jQuery的帮助,将整个HTML片段传给jQuery,看是否可以得到需要的内容:
console.log($(html));
输出:
[#text, <meta>, #text, <title>, #text, <script>, ..., <header>, ..., <div>]
很遗憾,从结果来看,完整的HTML结构是丢失了,至少找不到应该有的 <head>
和 <body>
元素,因而也没办法从 <head>
中提取出相关的资源,包括 <script>
和 <link>
元素。
究其原因,在jQuery的实现中,当输入的是一个HTML片段时,其会调用 parseHTML
方法,进一步调用 buildFragment
方法,而 buildFragment
方法创建一个 DocumentFragment后,调用 clean
方法进行构建。
而其核心问题就在于, clean
方法使用一个 <div>
元素为容器,设置其 innerHTML
属性来解析HTML片段。从HTML的内容模式上来看, <div>
元素里面自然不能有 <head>
和 <body>
元素,因此浏览器的容错机制将这些元素给去除了,导致结构的丧失。
显然,事已至此,指望jQuery是不怎么现实的了,需要寻找其它的途径。从前面的尝试中,我们至少得到了一个有效的结论: innerHTML
属性自带HTML解析功能(废话!)。那么,是不是我们找到一个元素,可以使用 innerHTML
,又允许有 <head>
和 <body>
标签作为其子元素,是不是就解决问题了呢?
这样的元素有哪些呢?显然只有一个, <html>
元素:
var doc = document.createElement('html');
doc.innerHTML = html;
doc = $(doc);
console.log(doc);
这下给出的结果就对了,可以用 getElementsByTagName
找到 <head>
和 <body>
元素,而DOCTYPE则被容错机制自动忽略,形成了一个结构良好符合要求的DOM结构。
处理依赖资源
后续的任务,是找出一个页面依赖的资源,并将这些资源放到现有的 <head>
元素中。
从简单地角度看,这是个相当容易的事:
doc.find('head > script').appendTo('head');
doc.find('head > link').appendTo('head');
这显然没有什么错,但是问题远不止这么简单:
- 有些css和js是全局的,每个页面都会有,不断重复的执行没有意义甚至有副作用。
- 上一个页面的相关资源不清除的话,特别是css会产生干扰。
因此,现在目标又被细化为:
- 清除上一个页面的相关资源,但保留全局的部分。
- 找出仅仅与页面相关的,非全局的资源。
- 将相关资源放入
<head>
中。
如果没有后端相应的配合,这个问题并不好办,一个比较简单的方案是,在javascript中显式地声明 哪些资源是全局的,在移除资源时略过这部分。这种方案是正确且合理的,但在扩展性上并不是十分优秀,每一次增减全局资源,都需要在javascript中进一步做相应的标记,如有遗漏,则会导致系统的错误。
因此,个人的解决方案是,在后端输出时,对于全局的资源,添加了一个 data-persist
属性来标识,之后只需要在选择器中将带有这一属性的忽略即可:
// 另外需要注意的是,<link>元素还用来标识favicon等,因此根据rel提取
var styleSelector = 'head > link[rel="stylesheet"]:not([data-persist])';
var scriptSelector = 'head > script:not([data-persist])';
而资源的移除和添加的相关代码也十分简单:
$(styleSelector).remove();
doc.find(styleSelector).appendTo('head');
$(scriptSelector).remove();
doc.find(scriptSelector).appendTo('head');
渲染页面内容
粗略来看,这一步简单到不值一提:
$('#main').hide().html(doc.find('#main').html()).fadeIn();
把一边的 #main
的数据放到另一边的 #main
即可,通过 fadeIn
之类的函数提供一个动态的过渡效果,也可以使用CSS Animation,这些都无所谓。
而在这一步,一个容易被忽略的问题是,在传统的超链接导航的模式下,每一个页面都部署由一个百度统计的脚本,会发送一个统计请求,以便百度统计给出正确的访客信息。但是这个脚本显然不会在 #main
中,因此使用以上代码渲染页面后,这个统计的请求就不会发了,若干时间后大概会发现自己的博客访客少得可怜,进而对世界产生绝望,做出怒删系统之类的傻事吧……
当然知道了问题的存在,解决也很容易,无非再加载一下统计的脚本:
var hostname = location.hostname.toLowerCase();
if (hostname !== 'localhost' && hostname.indexOf('127.0.0.') !== 0) {
// 不用$.getScript是有多蛋疼?
var script = document.createElement('script');
script.async = true;
script.src = 'http://hm.baidu.com/h.js?{code}';
var placeholder = document.getElementsByTagName('script')[0];
placeholder.parentNode.insertBefore(script, placeholder);
}
体验增强
至此,其实基本的OPOA已经完成了,代码相当少:
var styleSelector = 'head > link[rel="stylesheet"]:not([data-persist])';
var scriptSelector = 'head > script:not([data-persist])';
function updatePage(html) {
var doc = document.createElement('html');
doc.innerHTML = html;
doc = $(doc);
$(styleSelector).remove();
doc.find(styleSelector).appendTo('head');
$(scriptSelector).remove();
doc.find(scriptSelector).appendTo('head');
$('#main').hide().html(doc.find('#main').html()).fadeIn();
loadHolmes(); // 加载百度统计脚本
return doc;
}
function loadPage(url) {
$.get(url, updatePage, 'html');
}
$('#page').on(
'click',
'a',
function() {
var href = $(this).attr('href');
if (href.indexOf('/') !== 0) {
return;
}
loadPage(href);
return false;
}
);
当然,作为一个杰出的工程师(你滚!),虽然用这么点代码就能实现OPOA很高兴,但还远远不够。这样产生的OPOA并没有真正浏览器的体验,主要集中在:
- 没有请求的并发控制,短时间内乱点链接会错乱。
- 前进后退用不了。
因此,后续的工作便是进一步优化体验。
控制并发
这个相当简单,当浏览器在链接中导航的时候,简单来说是根本没有并发的概念的。当点击一个链接后,页面还在读取时,再点击另一个链接,前一次加载会立刻被中断,转而执行第二次跳转。
因此,将这一思路转为javascript的实现,是要保证只有一个XMLHttpRequest对象,当第二次请求发起时,把将一次请求通过 abort
函数中止。这只需要改造 loadPage
函数(你以为我为啥把一行代码写成一个函数)即可:
var xhr;
function loadPage(url) {
if (xhr) {
xhr.abort();
}
$.get(
url,
function(html) {
xhr = null;
updatePage(html);
},
'html'
);
}
处理前进后退
这个相信大部分人是明白的,使用 HTML5的History接口即可。
简单来说,History接口有以下几个函数:
-
replaceState(data, title, url)
用于把当前的历史记录项替换掉。 -
pushState(data, title, url)
用于新加一个历史记录项。 -
popstate
事件会在历史记录项发生变化时触发。
需要注意的是, pushState
虽然确实会 改变历史记录项,但却不会触发 popstate
事件。
从接口上来看,很显然,我们只需要在一个页面加载后,调用一下 pushState
即可加入一个历史记录,后续就能回退到前一个。
pushState
的第一个参数 data
是与当前历史记录项关联的数据,由于这一数据在 pushState
函数执行过程中会被复制一份,因此只能是一个纯纯的对象,不能是DOM元素之类的东西。因此,一个比较合理的选择是,将后端返回的整串HTML作为数据存放。因此继续改造 loadPage
函数,将其中 $.get
的回调函数修改如下:
function(html) {
xhr = null;
var doc = updatePage(html);
// 从<title>元素中找出标题
history.pushState(html, doc.find('title').text(), url);
}
其后,监听 popstate
事件,把当时保留的HTML拿出来,使用 updatePage
渲染即可:
window.addEventListener(
'popstate',
function(e) {
var html = e.state;
if (html) {
updatePage(html);
e.preventDefault();
}
}
);
至此,基本上算是把前提和后退的功能实现了。但是其实由于History接口设计的一些不合理之处,还是会遇上一些小问题。
首先,最初进入的页面(比如首页)是没有相关的state的(因为不是通过XMLHttpRequest加载的),那么当回退到这一个页面时, popstate
事件会被触发,但又没有 e.state
这东西,导致函数不会采取任何行为。但是不采取任何行为,浏览器也不采取任何行为(说好的 preventDefault
有和没有一样),结果就是,再也别想回退到最初的页面了。
对于这个问题,解决方案不难,在 DOMContentLoaded
事件时,使用 replaceState
函数先存一份数据与当前的历史记录项关联上即可:
function setInitialState() {
var html = document.documentElement.outerHTML;
// url参数可选
history.replaceState(html, document.title);
}
$(document).ready(setInitialState);
另一个问题则是,Webkit系浏览器,当进入最初页面时,在 load
事件之后,会触发一次 popstate
事件,而其它浏览器并没有这一行为。
对于这个问题,一种方案是通过检测浏览器,忽略掉这第一次的 popstate
事件,但是考虑到未来Webkit系会不会修改这一行为,检测浏览器并不是靠谱的方案。而前面已经提到,在 DOMContentLoaded
时已经设置了相关的状态,因此这一次 popstate
事件可以正确取到数据,执行一次 updatePage
函数,并不会造成什么影响,所以暂时也不作处理了。
后端改造
工作至此,完整的、与浏览器基本一致的一个OPOA系统也算完成了,代码量不过数十行。但是遗留了一个问题,后端没有做过任何改造,那么每一次请求依旧会加载全部的HTML,读取子Action,进行不必要的数据库查询,浪费带宽和IO。因此,为了让OPOA真正在性能上有所收益,后端的改造也是必须的。
好在前端是以 后端尽可能少改造为前提进行实现的,因此后端确实只需要非常少的改造。
这一次改造的主要目的是,当在OPOA架构之下工作时,后端只需要返回有意义的内容,其中自然包括:
-
<head>
部分,用于提供所有相关的资源的声明。 -
div[id="main"]
部分,作为真正的页面的内容。
而剩下的主要是页首和侧边栏,则并不需要输出。因此,后端的模板进行一些简单的发行,加一个 if
判断,就轻松地实现了这一要求:
@if (!Context.Request.IsAjaxRequest()) {
<header>
<hgroup>
<h1>标题</h1>
<h2>副标题</h2>
</hgroup>
</header>
}
<div id="page">
<div id="main">
@RenderBody()
</div>
@if (!Context.Request.IsAjaxRequest()) {
<aside id="sidebar">
<section id="search">
<!-- 搜索框 -->
</section>
<section id="links">
<!-- 订阅链接 -->
</section>
<section id="tag-cloud">
<h1>标签</h1>
@Html.Action("TagCloud")
</section>
<section id="archive">
<h1>存档</h1>
@Html.Action("ArchiveList")
</section>
</aside>
}
</div>
<script src="http://hm.baidu.com/h.js" async="async"></script>
通过 IsAjaxRequest
方法判断是否为AJAX请求(具体实现是通过对 X-Request-With
头的判断),如果非Ajax请求则全页输出,反之则只输出必要的部分。当然这其中还有一些冗余(比如DOCTYPE),但并不重要,最主要影响性能的2个子Action被省略,已经有足够的收益。
遗留问题
最后还有一个很棘手的问题,一直无法得到解决,我将其归结与History接口设计的问题。
当你点击页面中一个改变hash的锚点,即一个 href
属性以 #开头的 <a>
元素时,会触发 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接口的进一步说明,可以参考以下资料: