/ JavaScript

博客单页化实践

这个单页化的博客现在也没在用了,Ghost平台挺好的……如果需要看代码的话,请参考GitHub上的仓库

半年前,因为VPS未续费导致所有数据丢失,直至今日终于重新恢复了所有的文章数据(虽然丢失了全部的评论),并且借此机会对所有文章进行了一次重新审视,修改了部分问题,并将所有示例迁移到jsfiddlejsperf上,总算造一段落。

新的博客完全独立建设,不使用任何第三方的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>

其中侧边栏的内容虽然永远是固定的,但是会调用TagCloudArchiveList两个子Action,显而易见这2个Action又会有数据库的查询。因此,如果排除不变的这部分,只更新div[id="main"]部分的内容,每一次浏览均可减少2次的数据库查询和一定量的HTML内容传输,有着不错的收益。

设计

本次实现OPOA的目标有:

  • 尽量少地引入后端改动的前提下完成。
  • 仅针对相关API支持到位的浏览器,其它浏览器保持降级,使用标准的超链接导航方式浏览。

因此尽管业界有不少OPOA的解决方案,如Backbone等,但是其引入的复杂性会导致后端(包括输出的HTML)的大量的变更,并不适合本次实现。还是自己实现一次来得更有优势。

再回到最初的目的,我们要把超链接导航改为XMLHttpRequest加载数据并渲染,而超链接是由<a>元素产生的。因此从基本的解决方案而言,我们需要做的是:

  1. 拦截所有<a>元素的点击事件。
  2. 点击发生时,取消掉默认的跳转行为,改用XMLHttpRequest加载相应页面数据。
  3. 解析相应数据,放到div[id="main"]容器中。
  4. 模拟浏览器的行为,改变地址栏、标题等。

当然其中会有很多的细节,后文主要就讲述这些问题。

实现

元素拦截

也许在几年前,拦截所有<a>元素这事看上去并不那么简单,由于动态的脚本的存在,很有可能动态地加上<a>元素,这些新增的元素如何绑定事件会变成一个课题。

然而在事件冒泡这一概念已经普及的如今,在jQuery推出了delegate函数,并进一步整合进on函数之后,这一需求的实现之简单也已经被全民所理解。

在这一块唯一需要注意的是,并不是所有的链接都属于本站,因此需要对链接的href属性进行一定的判断。判断的条件无非2个:

  1. 是相对地址。
  2. 是绝对地址,但和当前页面是同域的。

对于第1点太容易判断了,而第2点需要解析href属性分出protocoldomainport等信息,由于浏览器中的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会产生干扰。

因此,现在目标又被细化为:

  1. 清除上一个页面的相关资源,但保留全局的部分。
  2. 找出仅仅与页面相关的,非全局的资源。
  3. 将相关资源放入<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中,因此使用以上代码渲染页面后,这个统计的请求就不会发了,若干时间后大概会发现自己的博客访客少得可怜,进而对世界产生绝望,做出怒删系统之类的傻事吧……

当然知道了问题的存在,解决也很容易,统计的开放平台API提供了相应的方法:

function loadHolmes() {
    _hmt.push(['_trackPageview', location.pathname + location.search]);
}

体验增强

至此,其实基本的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.statenull**。这显然是合理的,通过对e.state的判断,popstate事件的处理函数不会进行任何动作,进而浏览器会对锚点进行跟踪,改变页面滚动条的位置。

问题出现在这之后,如果你点击“后退”按钮,由于hash再一次改变,又会触发一次popsate事件。在这一事件中,e.state是之前一次pushState函数调用时存放的内容。

也就是说,针对这一次popstate事件,在代码层面,是无法判断从另一个页面的跳转还是hash的改变。然则针对这2种情况,显然应当进行不同的处理:如果是页面的跳转,需要重新渲染div[id="main"]部分,而如果仅仅是hash的变化,页面不应该进行重新渲染。

可惜的是,popstate事件并没有提供足够的信息来判断这一点,因此现在的系统中,点击一个改变hash的锚点后,再点击“后退”按钮,页面是会出现一个动画效果的。这虽然并不影响浏览,但与真实浏览器的表现有所区别,并不是那么让人愉快的事情。

假设popstate事件可以提供更多的信息,比如通过relatedURL提供来源页面的URL,则可以通过对URL的分析,假定pathnamesearch相同的情况为锚点的跳转,不执行updatePage函数,便可以保持与浏览器的标准行为一致。

总结

本文使用一个实际的案例,分步骤地讲述了一个十分简单的多页系统改变为OPOA的过程,并且解释了其中容易遇到的一些问题,以及一些细节上的处理。

同时,本文作为对History接口应用的一次尝试,发现了接口设计和实现中一些存在的问题,并提供了部分问题的解决方案。

对于History接口的进一步说明,可以参考以下资料: