博客单页化实践

标签: 博客 实践 | 发表时间:2013-01-06 00:00 | 作者:[email protected]
出处:http://otakustay.com/

半年前,因为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属性分出 protocoldomain 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会产生干扰。

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

  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中,因此使用以上代码渲染页面后,这个统计的请求就不会发了,若干时间后大概会发现自己的博客访客少得可怜,进而对世界产生绝望,做出怒删系统之类的傻事吧……

当然知道了问题的存在,解决也很容易,无非再加载一下统计的脚本:

  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.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接口的进一步说明,可以参考以下资料:

相关 [博客 实践] 推荐:

博客单页化实践

- - 宅居
半年前,因为VPS未续费导致所有数据丢失,直至今日终于重新恢复了所有的文章数据(虽然丢失了全部的评论),并且借此机会对所有文章进行了一次重新审视,修改了部分问题,并将所有示例迁移到 jsfiddle和 jsperf上,总算造一段落. 新的博客完全独立建设,不使用任何第三方的CMS系统,后端使用ASP.NET MVC实现,数据库使用MySQL,通过Mono部署于Ubuntu Server之上,前端使用nginx作为静态服务器.

【实践】CTR中xgboost/gbdt +lr - CSDN博客

- -
自学习 CTR预估中GBDT与LR融合方案 ,有意用简单暴利的python实现一版GBDT/XGboost做特征选择,融合LR进行CTR的代码demo. # lr对原始特征样本模型训练. print('基于原有特征的LR AUC: %.5f' % lr_test_auc). # 对所有特征进行ont-hot编码.

Kafka MirrorMaker实践 - (a != b) ? b : a - ITeye博客

- -
最近准备使用Kafka Mirrormaker做两个数据中心的数据同步,以下是一些要点:. mirrormaker必须提供一个或多个consumer配置,一个producer配置,一个whitelist或一个blacklist(支持java正则表达式). 启动多个mirrormaker进程,单个进程启动多个consuemr streams, 可以提高吞吐量和提高性能.

实践k8s istio熔断 - fat_girl_spring - 博客园

- -
熔断主要是无感的处理服务异常并保证不会发生级联甚至雪崩的服务异常. 在微服务方面体现是对异常的服务情况进行快速失败,它对已经调用失败的服务不再会继续调用,如果仍需要调用此异常服务,它将立刻返回失败. 与此同时,它一直监控服务的健康状况,一旦服务恢复正常,则立刻恢复对此服务的正常访问. 这样的快速失败策略可以降低服务负载压力,很好地保护服务免受高负载的影响.

携程Hadoop跨机房架构实践_yukangkk的技术博客-CSDN博客

- -
陈昱康,携程架构师,对分布式计算和存储、调度、查询引擎、在线离线混部、高并发等方面有浓厚兴趣. 本文将分享携程Hadoop跨机房架构实践,包含Hadoop在携程的发展情况,整个跨机房项目的背景,我们跨机房的架构选型思路和落地实践,相关的改造和对未来的展望,希望给大家一些启迪. 一、Hadoop在携程的落地及发展情况.

eBay Elasticsearch性能优化实践 - CSDN博客

- -
eBay网Elasticsearch性能优化实践. 摘要:Elasticsearch是基于Apache Lucene的开源搜索和分析引擎,允许用户以近乎实时的方式存储,搜索和分析数据. 虽然Elasticsearch专为快速查询而设计,但其性能在很大程度上取决于用于应用程序的场景,索引的数据量以及应用程序和用户查询数据的速率.

异地多活(异地双活)实践经验 - CSDN博客

- -
异地多活(异地双活)是最近业界讨论比较多的话题,特别是前一阵子支付宝机房光纤故障和携程网数据库丢失之后,更加唤起了技术人员们对异地容灾的考虑. 而异地多活比异地容灾更高一级,因为异地容灾仅仅是一个冷备的概念,而异地多活却是指有两个或者多个可以同时对外服务的节点,任意一个点挂了,也可以迅速切换到其他节点对外服务,节点之间的数据做到准实时同步.

ASP.NET Core Web API 最佳实践指南 - hippieZhou - 博客园

- -
当我们编写一个项目的时候,我们的主要目标是使它能如期运行,并尽可能地满足所有用户需求. 但是,你难道不认为创建一个能正常工作的项目还不够吗. 同时这个项目不应该也是可维护和可读的吗. 事实证明,我们需要把更多的关注点放到我们项目的可读性和可维护性上. 这背后的主要原因是我们或许不是这个项目的唯一编写者.

五大Kubernetes最佳实践_Docker的专栏-CSDN博客

- -
在最近的一次Weave用户组在线会议WOUG[1]上两个工程师做了Kubernetes相关的分享. 谷歌云的开发者布道师Sandeep Dinesh(@SandeepDinesh)做了一个演讲,给大家列举了在Kubernetes上运行应用的最佳实践清单;Jordan Pellizzari(@jpellizzari),是来自Weaveworks的工程师,随后也做了一个分享,内容是在他们使用Kubernetes开发运行SaaS Weave Cloud两年之后学到的经验教训.

【实践】Spark 协同过滤ALS之Item2Item相似度计算优化 - CSDN博客

- -
CF召回优化,自之前第一版自己实现的基于item的协同过滤算法. http://blog.csdn.net/dengxing1234/article/details/76122465,考虑到用户隐型评分的. 稀疏性问题,所以尝试用Spark ml包(非mllib)中的ALS算法的中间产物item的隐性向量,进行进一步item到item的余弦相似度计算.