四种SPI风格:有状态的使用Hashbangs


这是四篇系列文章中的第一篇,内容是关于如何构建与基于Java的网络安全框架相兼容的单页界面公共网站。

我们将在这些文章中描述四种不同的技术:

1.使用Hashbangs的有状态

2.使用Hashbangs实现无状态

3.使用历史应用编程接口的有状态

4.使用历史应用编程接口的无状态

第一篇文章将第一次解释许多概念,下一篇文章将会更轻松,因为许多事情之前已经解释过了,等等。

应该是1.4或更高。

让我们采用第一种方法:

如何构建单页界面(SPI)公共网站搜索引擎优化兼容有状态使用Hashbangs

介绍

单页接口(SPI)模式并不新鲜,如今SPI在“网络应用程序”上非常流行,因为支持AJAX的现代网络框架使这类应用程序比以往任何时候都更容易。

然而,它是一个网络框架,主要关注单页界面网站(以及应用程序)。

本教程展示了如何建立SPI网站,基于页面的网站可以发展为SPI,避免不必要的重新加载,而不会失去基于页面的网站的好处,如书签,搜索引擎优化(SEO)或极端可访问性(JavaScript禁用)The Single Page Interface Manifesto

有几个选项可以让SPI网站使用它的Nat,它可以是有状态的或无状态的,基于hashbangs (#!)或使用历史应用编程接口。

本教程展示了如何使用hashbangs使ItsNat SPI网站有状态。

我们将能够使搜索引擎优化,书签,后退/前进按钮(历史导航)和一些技巧的SPI网站,我们仍然可以使用“页面访问计数器”,例如谷歌分析,尽管单一页面界面用户导航。这些将用代码解释。

要理解本教程,需要对其进行一些基本的了解。

一个网站如何能同时基于SPI和页面?

尽管谷歌搜索最近支持JavaScriptcrawling web sites,在搜索爬虫中获得高排名并确保没有搜索引擎优化问题的最好方法是忽略JavaScript。如果JavaScript被忽略,没有AJAX请求被执行,那么你的站点就不是SPI,这对网络爬虫来说没问题。它是一种技术,当JavaScript被忽略时,它能使网站用页面导航,当JavaScript被执行时,它能使单个页面(没有重新加载,没有页面导航,换句话说,页面导航是模拟的)导航。

ItsNat的典型行为如下:当服务器中的DOM发生变化时,会自动生成JavaScript代码并将其发送到客户端,以相应地更新客户端的DOM。通常在网站中,许多元素是共享的,如页眉、页脚、样式、JavaScript库等,内容区域几乎是唯一被完全改变的区域,在SPI中,这个“内容区域”可以用新的超文本标记语言片段来改变,一个超文本标记语言片段使用W3C的超文本标记语言应用编程接口被动态地插入到服务器中的超文本标记语言文档中,同时这个新的超文本标记语言代码也由客户端中的JavaScript代码自动插入(通常使用innerHTML),不幸的是这种方法对搜索引擎优化不友好,因为网络爬虫可能会忽略JavaScript。

为了提供页面模拟,一个重要的特性是快速加载模式。

如果将它配置为快速加载模式(默认模式),当加载初始页面(基于纯超文本标记语言模板)时,开发人员代码在服务器中对初始模板执行的任何DOM更改都不会以JavaScript形式发送,而是序列化DOM以生成发送给客户端的初始超文本标记语言页面。因此,操作DOM的相同用户代码可以根据执行的阶段生成JavaScript或纯超文本标记语言,当接收到AJAX事件时生成JavaScript,或者在初始页面加载时生成超文本标记语言,将所需的超文本标记语言片段插入内容区域,避免典型的“两个站点”方法(终端用户站点和爬虫站点)或站点的页面缓存。

当快速加载模式被禁用时,使用的模板是发送给客户端的标记,而服务器中的DOM更改是作为JavaScript DOM操作发送的,这不是搜索引擎优化友好的,不推荐用于SPI搜索引擎优化兼容的网站。在本教程中,我们当然会使用快速加载模式。

在SPI网站中,您的点击通常会使用AJAX用新的片段替换网页的部分内容(使用纯超文本标记语言片段来完成这项任务,它的功能非常强大)。href属性。这href通常定义一个“页面”的网址,它会像通常的基于页面的站点一样加载这个页面,通过这种方式网络爬虫看到“页面化”的网站。和替代方案onclick用于单页导航(基于AJAX)。

如果href一个链接(通常是一个导航组件)定义了一个可书签页面的网址,这个页面是遵循SPI宣言术语的“基本状态”。基本状态可以作为页面进行爬网,同时,当JavaScript未被忽略(onclick被执行)时,您可以导航到此状态而无需重新加载页面,在这两种情况下,页面导航忽略JavaScript或AJAX状态导航,浏览器页面的最终状态将是相同的。这是单页界面搜索引擎优化兼容网站的艺术和魔力,用它的自然变得非常容易。

在本教程中,我们将在链接中的URL查询部分使用一个参数来指定基本状态/页面?st=state与此同时,hashbangs (#!st=state)来提供状态导航,在执行JavaScript时无需重新加载。如您所知,我们可以手动或使用window.location添加一个#这种技术允许我们制作书签,网络爬虫也可以识别它们来做书签。

网络应用程序设置

它不需要特殊的设置或应用服务器,任何支持Java 1.6或更高版本的servlet容器就足够了。使用您喜欢的集成开发环境创建一个空的网络应用程序,本教程使用spistfulhashbangtut作为web应用程序和8080端口的名称。

创建ItsNat Servlet

ItsNat没有强制引导,没有“默认”ItsNat servlet,事实上,您可以在您的web应用程序中使用几个使用ItsNat的servlet。使用您的集成开发环境添加一个名为servlet(此名称不是强制的)在默认包中(同样不是强制的)。

中的默认servlet注册web.xml有效,本示例不需要中的特殊初始化参数或筛选器web.xml

根据这个设置,访问我们的servlet的网址是(假设是8080):

http://localhost:8080/spistfulhashbangtut/servlet

因为我们的网站是SPI,我们想要一个更漂亮的网址

http://localhost:8080/spistfulhashbangtut/

我们有两个选择:

1.在中添加servlet作为欢迎文件web.xml

<welcome-file-list>
    <welcome-file>servlet</welcome-file>
</welcome-file-list>

2.添加一个简单的index.jsp(默认情况下,该文件通常是“欢迎文件”)包含以下内容:

<jsp:forward page="/servlet" />

web.xml

<welcome-file-list>
    <welcome-file>index.jsp</welcome-file>
</welcome-file-list>

现在替换生成的代码servlet使用以下代码初始化:

import javax.servlet.ServletConfig;
import javax.servlet.ServletException;
import org.itsnat.core.ItsNatServletConfig;
import org.itsnat.core.ItsNatServletContext;
import org.itsnat.core.http.HttpServletWrapper;
import org.itsnat.core.http.ItsNatHttpServlet;
import org.itsnat.core.tmpl.ItsNatDocumentTemplate;
import org.itsnat.spistfulhashbangtut.SPITutGlobalEventListener;
import org.itsnat.spistfulhashbangtut.SPITutGlobalLoadRequestListener;
import org.itsnat.spistfulhashbangtut.SPITutMainLoadRequestListener;

public class servlet extends HttpServletWrapper
{
    public void init(ServletConfig config) throws ServletException
    {
        super.init(config);

        ItsNatHttpServlet itsNatServlet = getItsNatHttpServlet();

        ItsNatServletContext itsNatCtx = itsNatServlet.getItsNatServletContext();
        itsNatCtx.setMaxOpenDocumentsBySession(4); // To avoid abusive users

        ItsNatServletConfig itsNatConfig = itsNatServlet.getItsNatServletConfig();
        itsNatConfig.setFastLoadMode(true); // Not really needed, is the same as default

        String pathBase = getServletContext().getRealPath("/");
        String pathPages =     pathBase + "/WEB-INF/pages/";
        String pathFragments = pathBase + "/WEB-INF/fragments/";

        itsNatServlet.addEventListener(new SPITutGlobalEventListener());
        itsNatServlet.addItsNatServletRequestListener(new SPITutGlobalLoadRequestListener());

        ItsNatDocumentTemplate docTemplate;
        docTemplate = itsNatServlet.registerItsNatDocumentTemplate("main","text/html",pathPages + "main.html");
        docTemplate.addItsNatServletRequestListener(new SPITutMainLoadRequestListener());

        docTemplate = itsNatServlet.registerItsNatDocumentTemplate("google_analytics","text/html",pathPages + "google_analytics.html");
        docTemplate.setScriptingEnabled(false);

        // Fragments
        itsNatServlet.registerItsNatDocFragmentTemplate("not_found","text/html",pathFragments + "not_found.html");
        itsNatServlet.registerItsNatDocFragmentTemplate("overview","text/html",pathFragments + "overview.html");
        itsNatServlet.registerItsNatDocFragmentTemplate("overview.popup","text/html",pathFragments + "overview_popup.html");
        itsNatServlet.registerItsNatDocFragmentTemplate("detail","text/html",pathFragments + "detail.html");
        itsNatServlet.registerItsNatDocFragmentTemplate("detail.more","text/html",pathFragments + "detail_more.html");
    }
}

如您所见,我们的servlet现在继承自HttpServletWrapper

该类将任何请求重定向到ItsNatHttpServlet对象包装servlet实例。

Web应用程序配置是在标准中完成的init(ServletConfig)方法,ItsNat中的配置是“经典的”,命令式的,调用配置方法。

itsNatCtx.setMaxOpenDocumentsBySession(4); // To avoid abusive users

此调用按用户会话设置4个具有服务器状态的文档,此值试图避免滥用用户打开太多具有相同页面的浏览器窗口,因为这些页面在服务器中是隔离的(页面之间没有共享数据)。

itsNatConfig.setFastLoadMode(true); // Not really needed, is the same as default

这个调用实际上是不必要的,因为快速加载模式是默认的,调用这个方法是为了清楚地说明快速加载模式在具有页面模拟的SPI网站中是强制的。

String pathBase = getServletContext().getRealPath("/");
String pathPages =     pathBase + "/WEB-INF/pages/";
String pathFragments = pathBase + "/WEB-INF/fragments/";

文件夹WEB-INF/pages将用于保存“网页模板”,因为我们的网络应用程序是SPI,只需要一个网页模板,稍后将添加另一个非常简单的页面来监控“网页”访问。到文件夹中WEB-INF/fragments将被保存为“页面片段”,页面片段是纯HTML页面,其中仅使用< body >(或< head >)的内容。

itsNatServlet.addEventListener(new SPITutGlobalEventListener());

SPITutGlobalEventListener是全球性的org.w3c.dom.events.EventListener,这个servlet接收的所有AJAX请求(对于这个servlet加载的任何文档)首先被分派给这个侦听器。这是代码:

package org.itsnat.spistfulhashbangtut;

import org.itsnat.core.ClientDocument;
import org.itsnat.core.event.ItsNatEvent;
import org.w3c.dom.events.Event;
import org.w3c.dom.events.EventListener;

public class SPITutGlobalEventListener implements EventListener
{
    public SPITutGlobalEventListener()
    {
    }

    @Override
    public void handleEvent(Event evt)
    {
        ItsNatEvent itsNatEvt = (ItsNatEvent)evt;
        if (itsNatEvt.getItsNatDocument() == null)
        {
            StringBuilder code = new StringBuilder();
            code.append("if (confirm('Expired session. Reload?'))");
            code.append("  window.location.reload(true);");
            ClientDocument clientDoc = itsNatEvt.getClientDocument();
            clientDoc.addCodeToSend(code.toString());
            itsNatEvt.getItsNatEventListenerChain().stop();
        }
    }
}

当最终用户的web浏览器从服务器中丢失的客户端文档发送事件时(通常是因为用户会话已过期),方法ItsNatEvent.getItsNatDocument()返回null,因为此事件是“孤立的”,服务器中没有附加到客户端文档的文档(在服务器中已过期并被删除)。在这种情况下,我们要求最终用户重新加载页面,您可以不要求就重新加载。

电话:

itsNatEvt.getItsNatEventListenerChain().stop();

不是真正需要的,它会阻止调用其他全局事件侦听器(本教程中没有更多内容)。

SPITutGlobalEventListener类实际上是不需要的,因为它处理孤立事件的默认行为是自动重新加载页面(唯一的区别是没有询问最终用户)。

servlet中的以下内容:

itsNatServlet.addItsNatServletRequestListener(new SPITutGlobalLoadRequestListener());

注册全局文档(页面)侦听器。当请求页面(=文档)加载或任何其他未知页面请求(不是AJAX事件)时,会调用该侦听器。源代码:

package org.itsnat.spistfulhashbangtut;

import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import org.itsnat.core.ItsNatDocument;
import org.itsnat.core.ItsNatServletRequest;
import org.itsnat.core.ItsNatServletResponse;
import org.itsnat.core.event.ItsNatServletRequestListener;

public class SPITutGlobalLoadRequestListener implements ItsNatServletRequestListener
{
    @Override
    public void processRequest(ItsNatServletRequest request, ItsNatServletResponse response)
    {
        ItsNatDocument itsNatDoc = request.getItsNatDocument();
        if (itsNatDoc == null)
        {
            // Requested with a custom URL, not ItsNat standard format,
            // for instance servlet without params or Google AJAX crawling.
            // Internal redirection specifying the target template page:
            ServletRequest servRequest = request.getServletRequest();
            String gAnalytState = servRequest.getParameter("ganalyt_st");
            if (gAnalytState != null)
                servRequest.setAttribute("itsnat_doc_name","google_analytics");
            else
                servRequest.setAttribute("itsnat_doc_name","main");
            ServletResponse servResponse = response.getServletResponse();
            request.getItsNatServlet().processRequest(servRequest,servResponse);
        }
    }
}

如果请求包括默认参数itsnat_doc_name并且指定的页面模板存在于方法中ItsNatServletRequest.getItsNatDocument()返回非空值ItsNatDocument如果请求的网址不包括itsnat_doc_name是一个“自定义”请求getItsNatDocument()返回null。后者是漂亮的网址,本教程不使用itsnat_doc_name在公共网址中。

我们首先检查参数ganalyt_st这个参数将在以后使用谷歌分析进行状态监控。如果此参数不存在,则请求的预期情况如下:

http://localhost:8080/spistfulhashbangtut/

在这种情况下,我们必须加载我们网站的主页,所以itsnat_doc_name用值指定main作为请求属性,值main是我们SPI网站主页模板的名称,它会首先检查itsnat_doc_name被指定为请求属性,然后被指定为请求参数。现在,我们准备将请求重新发送到它的Nat呼叫:

request.getItsNatServlet().processRequest(servRequest,servResponse);

这个新请求在内部具有与显式网址相似的效果:

http://localhost:8080/spistfulhashbangtut/servlet?itsnat_doc_name=main

现在指定并找到目标页面模板request.getItsNatDocument();返回一个非空值,在这种情况下什么也不做,因为这个全局侦听器不是管理正常页面加载请求的典型位置。

主页处理

返回到servlet:

ItsNatDocumentTemplate docTemplate;
docTemplate = itsNatServlet.registerItsNatDocumentTemplate("main","text/html",pathPages + "main.html");

这个调用用名称注册main页面模板文件main.html(保存在WEB-INF/pages/):

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:itsnat="http://itsnat.org/itsnat">
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    <meta http-equiv="Pragma" content="no-cache" />
    <meta http-equiv="expires" content="Wed, 1 Dec 1997 03:01:00 GMT" />
    <meta http-equiv="Cache-Control" content="no-cache, must-revalidate" />
    <title id="titleId" itsnat:nocache="true">Tutorial: Single Page Interface SEO Compatible Web Site With ItsNat STATEFUL Using Hashbangs
    <link rel="stylesheet" type="text/css" href="css/style.css" />
    <script type="text/javascript" src="js/spi_hashbang.js?timestamp=2015-08-18_01"></script>
    <script type="text/javascript">
    function setState(name)
    {
        if (typeof document.getItsNatDoc == "undefined") return; // Too soon, page is not fully loaded
        var itsNatDoc = document.getItsNatDoc();
        var evt = itsNatDoc.createUserEvent("setState");
        evt.setExtraParam("name",name);
        itsNatDoc.dispatchUserEvent(null,evt);
    }
    window.spiSite.onBackForward = setState;
    </script>
</head>
<body>

<div class="main">
    <table style="width:100%; height:100%; padding:0; margin:0;" border="0px" cellpadding="0" cellspacing="0">
    <tbody>
    <tr style="height:50px;">
        <td>
            <h2 style="text-align:center;">Tutorial: Single Page Interface SEO Compatible Web Site With ItsNat STATEFUL <br> Using Hashbangs</h2>
        </td>
    </tr>
    <tr style="height:40px;">
        <td>
            <table style="width:100%; margin:0; padding:0; border: #ED752A solid; border-width: 0 0 2px 0; ">
                <tbody>
                <tr class="mainMenu" itsnat:nocache="true">
                    <td id="menuOpOverviewId">
                        <a onclick="setState('overview'); return false;" class="menuLink" href="?st=overview">Overview</a>
                    </td>
                    <td id="menuOpDetailId">
                        <a onclick="setState('detail'); return false;" class="menuLink" href="?st=detail">Detail</a>
                    </td>
                    <td> 
                </tr>
                </tbody>
            </table>
        </td>
    </tr>
    <tr style="height:70%; /* For MSIE */">
        <td id="contentParentId" itsnat:nocache="true" style="padding:20px; vertical-align:super;" >



        </td>
    </tr>
    <tr style="height:50px">
        <td style="border-top: 1px solid black; text-align:center;">
            SOME FOOTER
        </td>
    </tr>
    </tbody>
    </table>

</div>

<iframe id="googleAnalyticsId" itsnat:nocache="true" src="?ganalyt_st=" style="display:none;" ></iframe>

<!-- For Google Analytics, needed to know how the site is reached by users -->
<script>
  (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
  (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
  m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
  })(window,document,'script','//www.google-analytics.com/analytics.js','ga');

  ga('create', 'UA-2924757-6', 'auto');
  ga('send', 'pageview');

</script>

</body>
</html>

正如您所见,它的网络模板是纯超文本标记语言文件,因为视图逻辑是用W3C DOM Java应用编程接口编码的。默认情况下,模板是缓存的,也就是说,DOM节点在内部被序列化为普通的超文本标记语言,并在用户之间共享;任何缓存的DOM子树在服务器中都被一个包含特殊标记的文本节点替换,当这个子树被发送到客户端时,它会自动发送缓存的标记。DOM缓存对于模板的“静态”(服务器观点)部分很有意思,缓存可以通过配置标志完全避免,但是如果您想节省服务器内存和提高性能,就不推荐使用。

因为我们想要修改页面模板的某些部分,所以这些部分被认为不是静态的(因为我们需要以编程方式访问它们,并且可能改变它们),并且必须用特殊的ItsNat属性标记为“未缓存”itsnat:nocache="true"在哪里itsnat前缀在< html >中声明为xmlns:itsnat="http://itsnat.org/itsnat"是的,它需要XHTML名称空间,但是您的模板通常是HTML 5(看一下DOCTYPE声明),并提供文本/html MIME。

例如:

<title id="titleId" itsnat:nocache="true">Tutorial: Single Page Interface SEO Compatible Web Site With ItsNat Using Hashbangs</title>

当一个新的基本状态被加载时(页面加载时间或AJAX事件),SPI网站的标题将会改变。

<tr class="mainMenu" itsnat:nocache="true">

此行包含网站主菜单,未缓存,因为我们需要访问服务器中的菜单项来更改当前所选选项的颜色。

<td id="contentParentId" itsnat:nocache="true" style="padding:20px; vertical-align:super;" >

该表格单元格是“内容区域”的父单元格,当最终用户单击某个菜单选项时,该区域将相应地改变。

最后:

<iframe id="googleAnalyticsId" itsnat:nocache="true" src="?ganalyt_st=" style="display:none;" ></iframe>

这个< iframe >的URL将被更改,以便在进行单页导航时跟踪基本状态,只需将状态名称添加到src网址,然后重新加载< iframe >内容(因为网址已经改变),谷歌分析检测到这种重新加载。通过检查不同的参数(状态),我们可以跟踪终端用户是如何浏览站点的,而不管SPI的性质如何。在实践中< iframe >网址将被改变使用location.reload()以避免新的不必要的历史条目。

并且:

<!-- For Google Analytics, needed to know how the site is reached by users -->
<script>
  (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
  (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
  m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
  })(window,document,'script','//www.google-analytics.com/analytics.js','ga');

  ga('create', 'UA-2924757-6', 'auto');
  ga('send', 'pageview');

</script>

谷歌分析的最后一个脚本在页面的末尾,需要知道用户是如何到达站点的(它是与iframe加载的脚本相同的脚本),之后单个页面内的导航由iframe监控。

以下代码:

<script type="text/javascript">
function setState(name)
{
    if (typeof document.getItsNatDoc == "undefined") return; // Too soon, page is not fully loaded

    var itsNatDoc = document.getItsNatDoc();
    var evt = itsNatDoc.createUserEvent("setState");
    evt.setExtraParam("name",name);
    itsNatDoc.dispatchUserEvent(null,evt);
}
window.spiSite.onBackForward = setState;
</script>

用户事件是W3C DOM Events的一个ItsNat扩展,通过从JavaScript调用一些公共ItsNat方法来触发。用户事件在本教程中用于通知服务器下一个要加载的基本状态,这些用户事件由先前注册的服务器中的用户事件监听器接收。在本例中,我们使用“用户事件”在基本状态之间导航,因为本教程是有状态的,状态导航也可以用传统的基于AJAX的ItsNat事件侦听器来完成。

基本状态如何改变的一个例子是这个双重链接(菜单项):

<a href="?st=overview" onclick="setState('overview'); return false;"
   class="menuLink">Overview</a>

当最终用户单击此链接时onclick内联处理程序被调用,调用setState('overview');向命令服务器发送用户事件以加载新的基本状态overview,因为onclick返回false链接(进程)的默认行为href)因此被中止href="?st=overview"被忽略,网址保持不变,但页面已被部分更改为新状态。这不是搜索引擎爬虫的情况,这些机器人通常会忽略JavaScript并跟随链接加载新页面overview作为根据该示例的初始状态。这是双链接AJAX/normal的一个例子。

现在是为注册加载侦听器的时候了main模板(回到servlet):

ItsNatDocumentTemplate docTemplate;
docTemplate = itsNatServlet.registerItsNatDocumentTemplate("main","text/html",pathPages + "main.html");
docTemplate.addItsNatServletRequestListener(new SPITutMainLoadRequestListener());

SPITutMainLoadRequestListener.processRequest(…)当servlet收到这个模板的新加载请求时,将调用方法,每个加载请求调用一次。源代码是:

package org.itsnat.spistfulhashbangtut;

import org.itsnat.core.ItsNatServletRequest;
import org.itsnat.core.ItsNatServletResponse;
import org.itsnat.core.event.ItsNatServletRequestListener;
import org.itsnat.core.http.ItsNatHttpServletRequest;
import org.itsnat.core.http.ItsNatHttpServletResponse;
import org.itsnat.spi.SPIMainDocumentConfig;
import org.itsnat.spi.SPIStateDescriptor;
import org.w3c.dom.Document;
import org.w3c.dom.html.HTMLTitleElement;

public class SPITutMainLoadRequestListener implements ItsNatServletRequestListener
{
    @Override
    public void processRequest(ItsNatServletRequest request, ItsNatServletResponse response)
    {
        Document doc = request.getItsNatDocument().getDocument();

        SPIMainDocumentConfig config = new SPIMainDocumentConfig();
        config.setStateNameSeparator('.')
              .setTitleElement((HTMLTitleElement)doc.getElementById("titleId"))
              .setContentParentElement(doc.getElementById("contentParentId"))
              .setGoogleAnalyticsElement(doc.getElementById("googleAnalyticsId"))
              .addMenuElement("overview",doc.getElementById("menuOpOverviewId"))
              .addMenuElement("detail",doc.getElementById("menuOpDetailId"))
              .addSPIStateDescriptor(new SPIStateDescriptor("overview","Overview",true))
              .addSPIStateDescriptor(new SPIStateDescriptor("overview.popup","Overview Popup",false))
              .addSPIStateDescriptor(new SPIStateDescriptor("detail","Detail",true))
              .addSPIStateDescriptor(new SPIStateDescriptor("not_found","Not Found",true))
              .setDefaultStateName("overview")
              .setNotFoundStateName("not_found");


        new SPITutMainDocument((ItsNatHttpServletRequest)request,(ItsNatHttpServletResponse)response,config);
    }
}

新的SPITutMainDocument实例是按每个调用(加载请求)创建的,该实例保存一个ItsNatHTMLDocument对象,无论页面是否在没有页面加载的情况下改变其状态(本教程是有状态的,也可能是无状态的)。

这个对象不会被垃圾收集,因为一些事件侦听器将在中注册ItsNatHTMLDocument并且这个文档实例在客户端文档的生命周期之后自动被其自身持有:当最终用户离开页面时,服务器中的文档被自动丢弃。如果没有接收到卸载事件,当长时间没有接收到事件时(使用会话过期时间)或当用户会话过期时(尽管如此,网络开发人员不使用会话,在网络状态模式下,小服务程序会话是识别终端用户页面的基本机制),网络自动丢弃服务器中的文档,无论如何,存在有效页面(文档)的限制,以避免滥用用户,当超过该限制时,服务器中最旧的超出的文档将被丢弃。

的实例SPIMainDocumentConfig是我们的迷你ItsNat SPI框架的配置对象,基于本教程创建的哈希表。

这个小型框架有三个包含以下类的包:

  1. org.itsnat.spi:通用类评估:SPIMainDocumentConfig,SPIMainDocument,SPIState,SPIStateDescriptor 
  2. org.itsnat.spistful:有状态特定类:SPIStfulMainDocument,SPIStfulState 
  3. org.itsnat.spistfulhashbang: stateful and hashbang processing specific

    类别:SPIStfulHashbangMainDocument 

您可以使用这个小框架创建其他类似的SPI网站(使用hashbangs进行状态化),只需最少的代码,这个小框架就可以被重用和扩展。

包裹org.itsnat.spi包含将用于所有SPI SEO兼容教程的类(有状态和无状态、hashbang和历史api)。

让我们详细解释一下

班级SPIMainDocumentConfig非常简单,它是文档管理器的配置器对象。这是代码:

package org.itsnat.spi;

import java.util.HashMap;
import java.util.Map;
import org.w3c.dom.Element;
import org.w3c.dom.html.HTMLTitleElement;

/**
 *
 * @author jmarranz
 */
public class SPIMainDocumentConfig
{
    protected char stateNameSeparator = 0;
    protected HTMLTitleElement titleElem; // HTMLTitleElement
    protected Element contentParentElem;
    protected Element googleAnalyticsElem;
    protected Map<String,SPIStateDescriptor> stateMap = new HashMap<String,SPIStateDescriptor>();
    protected Map<String,Element> menuElemMap = new HashMap<String,Element>();
    protected String defaultStateName;
    protected String notFoundStateName;

    public char getStateNameSeparator()
    {
        return stateNameSeparator;
    }

    public SPIMainDocumentConfig setStateNameSeparator(char stateNameSeparator)
    {
        this.stateNameSeparator = stateNameSeparator;
        return this;
    }

    public HTMLTitleElement getTitleElement()
    {
        return titleElem;
    }

    public SPIMainDocumentConfig setTitleElement(Element titleElem)
    {
        this.titleElem = (HTMLTitleElement)titleElem;
        return this;
    }

    public Element getContentParentElement()
    {
        return contentParentElem;
    }

    public SPIMainDocumentConfig setContentParentElement(Element contentParentElem)
    {
        this.contentParentElem = contentParentElem;
        return this;
    }

    public Element getGoogleAnalyticsElement()
    {
        return googleAnalyticsElem;
    }

    public SPIMainDocumentConfig setGoogleAnalyticsElement(Element googleAnalyticsElem)
    {
        this.googleAnalyticsElem = googleAnalyticsElem;
        return this;
    }

    public SPIStateDescriptor getSPIStateDescriptor(String stateName)
    {
        return stateMap.get(stateName);
    }

    public SPIMainDocumentConfig addSPIStateDescriptor(SPIStateDescriptor stateDesc)
    {
        SPIStateDescriptor old = stateMap.put(stateDesc.getStateName(),stateDesc);
        if (old != null) throw new RuntimeException("Already registered a state with name: " + stateDesc.getStateName());
        return this;
    }

    public Element getMenuElement(String stateName)
    {
        return menuElemMap.get(stateName);
    }

    public Map<String,Element> getMenuElementMap()
    {
        return menuElemMap;
    }

    public SPIMainDocumentConfig addMenuElement(String stateName,Element menuElem)
    {
        menuElemMap.put(stateName,menuElem);
        return this;
    }

    public String getDefaultStateName()
    {
        return defaultStateName;
    }

    public SPIMainDocumentConfig setDefaultStateName(String defaultStateName)
    {
        this.defaultStateName = defaultStateName;
        return this;
    }

    public String getNotFoundStateName()
    {
        return notFoundStateName;
    }

    public SPIMainDocumentConfig setNotFoundStateName(String notFoundStateName)
    {
        this.notFoundStateName = notFoundStateName;
        return this;
    }

    public SPIMainDocumentConfig check()
    {
        if (stateNameSeparator == 0) throw new RuntimeException("State name list separator is not defined, use any char if you don't need it");
        if (titleElem == null) throw new RuntimeException("Missing titleElement");
        if (contentParentElem == null) throw new RuntimeException("Missing contentParentElement");
        if (googleAnalyticsElem == null) throw new RuntimeException("Missing googleAnalyticsElement");
        for(Map.Entry<String,Element> entry : menuElemMap.entrySet())
        {
            Element menuElem = entry.getValue();
            if (menuElem == null) throw new RuntimeException("Menu element of " + entry.getKey() + " is null");
        }
        // menuElemMap puede estar vacío (?)
        if (defaultStateName == null) throw new RuntimeException("Missing defaultStateName");
        if (notFoundStateName == null) throw new RuntimeException("Missing notFoundStateName");

        if (stateMap.get(defaultStateName) == null) throw new RuntimeException("Missing state declaration for default state: " + defaultStateName);
        if (stateMap.get(notFoundStateName) == null) throw new RuntimeException("Missing state declaration for not found state: " + notFoundStateName);

        return this;
    }
}

在这个配置对象中,我们配置了必要的DOM元素来改变标题、活动菜单、谷歌分析和基本状态的简要描述。

例如menuElemMap集合用菜单项的DOM元素映射状态名,我们需要这些元素来改变它们的外观,当一个菜单选项被选中时,菜单选择改变了当前显示的基本状态。

contentParentElem插入/移除“内容区域”的标记是有用的,googleAnalyticsElem用于通过谷歌分析监控国家访问。

微型框架中最有趣的一类是SPIMainDocument。一些有趣的解释向您展示了如何管理状态以及如何支持基于hashbangs的谷歌AJAX爬行(read thisthis)。

package org.itsnat.spi;

import org.itsnat.core.ItsNatServlet;
import org.itsnat.core.html.ItsNatHTMLDocument;
import org.itsnat.core.http.ItsNatHttpServletRequest;
import org.itsnat.core.http.ItsNatHttpServletResponse;
import org.itsnat.core.tmpl.ItsNatDocFragmentTemplate;
import org.w3c.dom.DocumentFragment;
import org.w3c.dom.Element;

public abstract class SPIMainDocument
{
    protected ItsNatHTMLDocument itsNatDoc;
    protected SPIMainDocumentConfig config;
    protected String title;
    protected String googleAnalyticsIFrameURL;


    public SPIMainDocument(ItsNatHttpServletRequest request, ItsNatHttpServletResponse response,SPIMainDocumentConfig config)
    {
        config.check();

        this.config = config;
        this.itsNatDoc = (ItsNatHTMLDocument)request.getItsNatDocument();

        this.title = config.getTitleElement().getText(); // Initial value
        this.googleAnalyticsIFrameURL = config.getGoogleAnalyticsElement().getAttribute("src");  // Initial value
    }

    public ItsNatHTMLDocument getItsNatHTMLDocument()
    {
        return itsNatDoc;
    }

    public SPIStateDescriptor getSPIStateDescriptor(String stateName)
    {
        return config.getSPIStateDescriptor(stateName);
    }

    public void setStateTitle(String stateTitle)
    {
        String pageTitle = stateTitle + " - " + title;
        if (itsNatDoc.isLoading())
            config.getTitleElement().setText(pageTitle);
        else
            itsNatDoc.addCodeToSend("document.title = \"" + pageTitle + "\";\n");
    }

    public Element getContentParentElement()
    {
        return config.getContentParentElement();
    }

    public ItsNatDocFragmentTemplate getFragmentTemplate(String name)
    {
        ItsNatServlet servlet = itsNatDoc.getItsNatDocumentTemplate().getItsNatServlet();
        return servlet.getItsNatDocFragmentTemplate(name);
    }

    public DocumentFragment loadDocumentFragment(String name)
    {
        ItsNatDocFragmentTemplate template = getFragmentTemplate(name);
        if (template == null)
            throw new RuntimeException("There is no template registered for state or fragment name: " + name);
        return template.loadDocumentFragment(itsNatDoc);
    }

    public String getFirstLevelStateName(String stateName)
    {
        String firstLevelName = stateName;
        int pos = stateName.indexOf(config.getStateNameSeparator());
        if (pos != -1) firstLevelName = stateName.substring(0, pos); // Case "overview.popup"
        return firstLevelName;
    }

    public void registerState(SPIState state)
    {
        setStateTitle(state.getStateTitle());
        String stateName = state.getStateName();
        itsNatDoc.addCodeToSend("spiSite.setStateInURL(\"" + stateName + "\");");
        // googleAnalyticsElem.setAttribute("src",googleAnalyticsIFrameURL + stateName);
        // http://stackoverflow.com/questions/24407573/how-can-i-make-an-iframe-not-save-to-history-in-chrome
        String jsIFrameRef = itsNatDoc.getScriptUtil().getNodeReference(config.getGoogleAnalyticsElement());
        itsNatDoc.addCodeToSend("var elem = " + jsIFrameRef + "; try{ elem.contentWindow.location.replace('" + googleAnalyticsIFrameURL + stateName + "'); } catch(e) {}");
    }
}

这个类是网站的核心,与主模板紧密相关,负责(基本的)状态管理,状态直接依赖于主菜单。

第一句话:

this.itsNatDoc = (ItsNatHTMLDocument)request.getItsNatDocument();

将ItsNat文档对象保存在属性中,因为SPIMainDocument是的包装器(和管理器)ItsNatHTMLDocument

特别有趣的是方法SPIMainDocument.registerState(SPIState)这稳定了客户端的当前状态:

public void registerState(SPIState state)
{
    setStateTitle(state.getStateTitle());
    String stateName = state.getStateName();
    itsNatDoc.addCodeToSend("spiSite.setStateInURL(\"" + stateName + "\");");
    // googleAnalyticsElem.setAttribute("src",googleAnalyticsIFrameURL + stateName);
    // http://stackoverflow.com/questions/24407573/how-can-i-make-an-iframe-not-save-to-history-in-chrome
    String jsIFrameRef = itsNatDoc.getScriptUtil().getNodeReference(config.getGoogleAnalyticsElement());
    itsNatDoc.addCodeToSend("var elem = " + jsIFrameRef + "; try{ elem.contentWindow.location.replace('" + googleAnalyticsIFrameURL + stateName + "'); } catch(e) {}");
}

电话:

setStateTitle(state.getStateTitle());

通过添加状态描述来更改页面标题。

itsNatDoc.addCodeToSend("spiSite.setStateInURL(\"" + stateName + "\");");

这段代码向客户端发送一些JavaScript代码,以指定在页面的网址中加载到客户端的基本状态。JavaScript方法setStateInURL(stateName)包含在文件中spi_hashbang.js,适用于基于hashbang的网站,以及spi_hsapi.js对于历史api文件,这些文件中的一个必须包含在主模板中,并且必须出现在我们的SPI网站中。

班级SPIStateDescriptor只是一个状态描述符:

package org.itsnat.spi;

/**
 *
 * @author jmarranz
 */
public class SPIStateDescriptor
{
    protected final String stateName;
    protected final String stateTitle;
    protected final boolean mainLevel;

    public SPIStateDescriptor(String stateName,String stateTitle,boolean mainLevel)
    {
        this.stateName = stateName;
        this.stateTitle = stateTitle;
        this.mainLevel = mainLevel;
    }

    public String getStateName()
    {
        return stateName;
    }

    public String getStateTitle()
    {
        return stateTitle;
    }

    public boolean isMainLevel()
    {
        return mainLevel;
    }
}

班级SPIState是状态类的基类。

在这种情况下,布尔参数指示状态是否为“主级别”overview.popup不是“主级”,是第二级状态依赖的overview状态,当选择该状态时overview.popup选择菜单项“概述”是因为只有overview零件用于菜单激活。

所有州都继承自SPIState

package org.itsnat.spi;

import org.itsnat.core.html.ItsNatHTMLDocument;

public abstract class SPIState
{
    protected SPIMainDocument spiDoc;
    protected SPIStateDescriptor stateDesc;

    public SPIState(SPIMainDocument spiDoc,SPIStateDescriptor stateDesc,boolean register)
    {
        this.spiDoc = spiDoc;
        this.stateDesc = stateDesc;

        if (register)
            spiDoc.registerState(this);
    }

    public SPIMainDocument getSPIMainDocument()
    {
        return spiDoc;
    }

    public ItsNatHTMLDocument getItsNatHTMLDocument()
    {
        return spiDoc.getItsNatHTMLDocument();
    }

    public String getStateName()
    {
        return stateDesc.getStateName();
    }

    public String getStateTitle()
    {
        return stateDesc.getStateTitle();
    }

}

电话:

spiDoc.registerState(this);

特别有趣的是,这个方法调用SPIMainDocument.registerState(SPIState)这建立了客户端中的当前状态。

主页处理有状态

现在是时候来描述一下这个小型框架如何管理一个有状态的网站了。

包中只有两个类org.itsnat.spistfulSPIStfulMainDocumentSPIStfulState

package org.itsnat.spistful;

import org.itsnat.spi.SPIMainDocumentConfig;
import org.itsnat.spi.SPIStateDescriptor;
import org.itsnat.core.domutil.ItsNatDOMUtil;
import org.itsnat.core.event.ItsNatUserEvent;
import org.itsnat.core.http.ItsNatHttpServletRequest;
import org.itsnat.core.http.ItsNatHttpServletResponse;
import org.itsnat.spi.SPIMainDocument;
import org.w3c.dom.DocumentFragment;
import org.w3c.dom.Element;
import org.w3c.dom.events.Event;
import org.w3c.dom.events.EventListener;

public abstract class SPIStfulMainDocument extends SPIMainDocument
{
    protected Element currentMenuItemElem;
    protected SPIStfulState currentState;

    public SPIStfulMainDocument(ItsNatHttpServletRequest request, ItsNatHttpServletResponse response,SPIMainDocumentConfig config)
    {
        super(request, response,config);

        EventListener listener = new EventListener()
        {
            @Override
            public void handleEvent(Event evt)
            {
                ItsNatUserEvent itsNatEvt = (ItsNatUserEvent)evt;
                ItsNatHttpServletRequest request = (ItsNatHttpServletRequest)itsNatEvt.getItsNatServletRequest();
                ItsNatHttpServletResponse response = (ItsNatHttpServletResponse)itsNatEvt.getItsNatServletResponse();
                String name = (String)itsNatEvt.getExtraParam("name");
                changeState(name,request,response);
            }
        };
        itsNatDoc.addUserEventListener(null,"setState", listener);
    }

    public SPIStfulState changeState(String stateName,ItsNatHttpServletRequest request,ItsNatHttpServletResponse response)
    {
        SPIStateDescriptor stateDesc = config.getSPIStateDescriptor(stateName);
        if (stateDesc == null)
        {
            return changeState(config.getNotFoundStateName(),request,response);
        }

        // Cleaning previous state:
        if (currentState != null)
        {
            currentState.dispose();
            this.currentState = null;
        }

        ItsNatDOMUtil.removeAllChildren(config.getContentParentElement());

        // Setting new state:
        changeActiveMenu(stateName);

        String fragmentName = stateDesc.isMainLevel() ? stateName : getFirstLevelStateName(stateName);
        DocumentFragment frag = loadDocumentFragment(fragmentName);
        config.getContentParentElement().appendChild(frag);

        this.currentState = createSPIState(stateDesc,request,response);

        return currentState;
    }

    public abstract SPIStfulState createSPIState(SPIStateDescriptor stateDesc,ItsNatHttpServletRequest request,ItsNatHttpServletResponse response);


    public void changeActiveMenu(String stateName)
    {
        String mainMenuItemName = getFirstLevelStateName(stateName);

        Element prevActiveMenuItemElem = this.currentMenuItemElem;

        this.currentMenuItemElem = config.getMenuElement(mainMenuItemName);

        Element currActiveMenuItemElem = this.currentMenuItemElem;

        onChangeActiveMenu(prevActiveMenuItemElem,currActiveMenuItemElem);
    }

    public abstract void onChangeActiveMenu(Element prevActiveMenuItemElem, Element currActiveMenuItemElem);
}

班级SPIStfulMainDocument扩展SPIMainDocument并具体说明了在有状态网站中如何管理状态。

让我们来解释一下:

EventListener listener = new EventListener()
{
    @Override
    public void handleEvent(Event evt)
    {
        ItsNatUserEvent itsNatEvt = (ItsNatUserEvent)evt;
        ItsNatHttpServletRequest request = (ItsNatHttpServletRequest)itsNatEvt.getItsNatServletRequest();
        ItsNatHttpServletResponse response = (ItsNatHttpServletResponse)itsNatEvt.getItsNatServletResponse();
        String name = (String)itsNatEvt.getExtraParam("name");
        changeState(name,request,response);
    }
};
itsNatDoc.addUserEventListener(null,"setState", listener);

当单击某个导航链接时,例如当最终用户单击菜单选项时,此代码会注册用户事件侦听器侦听状态的更改。用户事件由handleEvent(Event)此类的方法。JavaScript客户端代码将激发这些用户事件来改变基本状态,在服务器中进行管理并传播到客户端。

方法changeState(String)负责状态变更管理。这个方法用类似的代码清除旧的基本状态currentState.dispose()ItsNatDOMUtil.removeAllChildren(config.getContentParentElement()),加载指定的基本状态,将新标记插入内容区域,更改新活动菜单选项的外观,并将进一步的状态处理委托给适当的SPIState班级。

班级SPIStfulState只需添加一个处理状态所需的抽象方法,因为SPIStfulMainDocument

package org.itsnat.spistful;

import org.itsnat.spi.SPIMainDocument;
import org.itsnat.spi.SPIStateDescriptor;
import org.itsnat.spi.SPIState;

public abstract class SPIStfulState extends SPIState
{
    public SPIStfulState(SPIMainDocument spiDoc,SPIStateDescriptor stateDesc,boolean register)
    {
        super(spiDoc,stateDesc,register);
    }

    public abstract void dispose();
}

主页处理和散列

是时候进一步了解小型框架是如何管理哈希表的了。

包裹org.itsnat.spistfulhashbang只有一个类SPIStfulHashbangMainDocument是的,这个类别继承自SPIStfulMainDocument并指定如何管理hashbangs。

package org.itsnat.spistfulhashbang;

import org.itsnat.spi.SPIMainDocumentConfig;
import javax.servlet.http.HttpServletRequest;
import org.itsnat.core.http.ItsNatHttpServletRequest;
import org.itsnat.core.http.ItsNatHttpServletResponse;
import org.itsnat.spistful.SPIStfulMainDocument;

public abstract class SPIStfulHashbangMainDocument extends SPIStfulMainDocument
{
    public SPIStfulHashbangMainDocument(ItsNatHttpServletRequest request, ItsNatHttpServletResponse response,SPIMainDocumentConfig config)
    {
        super(request, response,config);

        HttpServletRequest servReq = request.getHttpServletRequest();
        String stateName = servReq.getParameter("_escaped_fragment_"); // Google bot, has priority, its value is based on the hash fragment
        if (stateName != null)
        {
            if (stateName.startsWith("st=")) // st means "state"
                stateName = stateName.substring("st=".length(), stateName.length());
            else // Wrong format
                stateName = config.getDefaultStateName();
        }
        else
        {
            stateName = servReq.getParameter("st");
            if (stateName == null)
                stateName = config.getDefaultStateName();
        }


        changeState(stateName,request,response);
    }
}

这个演示应用程序准备由谷歌搜索机器人在它的AJAX Crawling Specification,链接以结尾#!谷歌机器人也紧随其后,在这种情况下,目标网站被访问取代#!带参数_escaped_fragment=随后是以下文本#!。例如这个link被谷歌机器人用这个请求抓取link。像冰这样的其他网络爬虫也支持谷歌AJAX爬行规范。

当导航SPI网站时,自动#!st=somestate被相应地添加到网址中,您可以复制这个网址,并使用它来链接您的网站中的状态/页面,当谷歌机器人遍历您的网站时,谷歌知道必须使用_escaped_fragment大会。

在本教程中,我们将使用另一种基于常规st=somestate参数,当谷歌遍历双链接时href,包含st=somestate参数,用于导航到目标页面。当终端用户使用启用AJAX的传统浏览器导航时,不会显示此参数,因为onclick改为执行。对于不支持_ escaped _ fragment约定的爬网程序来说,这个正常参数很有意思,并且可以在JavaScript被禁用的情况下提供导航。这种方法是可选的,如果你只是想支持带有一个hashbanghref="#!st=somestate"如果您提供以下信息,谷歌搜索就足以索引您的网站了吗_escaped_fragment convention。本教程将使用st=somestate双链路中的参数。

st参数,该值是初始状态。当没有st参数,例如在加载该网址时http://localhost:8080/spistfulhashbangtut,默认状态是加载(overview在本教程中)并选择默认菜单选项(“概述”)。

客户机中的散列管理发生在JavaScript库中spi_hashbang.js,该脚本是基于hashbangs(无状态或有状态)的SPI网站小型框架的一部分。

这是的源代码spi_hashbang.js

function LocationState()
{
    this.getURL = getURL;
    this.setURL = setURL;
    this.getStateName = getStateName;
    this.setStateName = setStateName;
    this.isStateNameChanged = isStateNameChanged;

    this.url = window.location.href;

    function getURL() { return window.location.href; }
    function setURL(url) { window.location.href = url; }

    function getStateName()
    {
        var url = this.getURL();
        var posR = url.lastIndexOf("#!st=");
        if (posR == -1) return null;
        var stateName = url.substring(posR + "#!st=".length);
        if (stateName == "") return null;
        return stateName;
    }

    function setStateName(stateName)
    {
        var url = this.getURL();
        var posR = url.lastIndexOf("#");
        var url2;
        if (posR != -1) url2 = url.substring(0,posR);
        else url2 = url;
        url2 = url2 + "#!st=" + stateName;
        if (url == url2) return;

        window.location.href = url2;
    }

    function isStateNameChanged(newUrl)
    {
        var url = this.getURL();
        if (newUrl == url) return false;
        var posR = url.lastIndexOf("#!st=");
        if (posR == -1) return false;
        var posR2 = newUrl.lastIndexOf("#!st=");
        if (posR2 == -1) return false;
        if (posR != posR2) return false;
        var stateName = url.substring(posR + "#!st=".length);
        var newStateName = newUrl.substring(posR + "#!st=".length);
        if (stateName == newStateName) return false;
        return true;
    }
}

function SPISite()
{
    this.load = load;
    this.detectURLStateChange = detectURLStateChange;
    this.detectURLStateChangeCB = detectURLStateChangeCB;
    this.setStateInURL = setStateInURL;
    this.removeChildren = removeChildren;
    this.onBackForward = null; // Public, user defined


    this.firstTime = true;
    this.initialURLWithState = null;
    this.url = null;
    this.disabled = false;

    this.load();

    function load() // page load phase
    {
        if (this.disabled) return;

        var currLoc = new LocationState();
        var stateName = currLoc.getStateName();
        if (stateName == null) return;
        this.initialURLWithState = currLoc.getURL();
    }

    function setStateInURL(stateName)
    {
        if (this.disabled) return;

        var currLoc = new LocationState();
        currLoc.setStateName(stateName);

        this.url = currLoc.getURL();

        if (!this.firstTime) return;
        this.firstTime = false;

        if (this.initialURLWithState != null)
        {
            // Loads the initial state in URL if different to default
            currLoc.setURL( this.initialURLWithState );
            this.initialURLWithState = null;
        }

        this.detectURLStateChange();
    }

    function detectURLStateChange()
    {
        var onhashchangeSupport = ("onhashchange" in window); // Supported in IE 8
        if (onhashchangeSupport)
        {
            var func = function()
            {
                arguments.callee.spiSite.detectURLStateChangeCB();
            };
            func.spiSite = this;
            if (window.addEventListener) window.addEventListener("hashchange", func, false);
            else window.attachEvent("onhashchange", func); // IE 8  https://msdn.microsoft.com/en-us/library/cc288209(v=vs.85).aspx
        }
        else
        {
            var time = 200;
            var func = function()
            {
                arguments.callee.spiSite.detectURLStateChangeCB();
                window.setTimeout(arguments.callee,time);
            };
            func.spiSite = this;
            window.setTimeout(func,time);
        }
    }

    function detectURLStateChangeCB()
    {
        // Detecting when only the state of the reference part of the URL changes
        var currLoc = new LocationState();
        if (!currLoc.isStateNameChanged(this.url)) return;

        // Only changed the state in reference part
        this.url = currLoc.getURL();

        var stateName = currLoc.getStateName();
        if (this.onBackForward) this.onBackForward(stateName);
        else try { window.location.reload(true); }
             catch(ex) { window.location = window.location; }
    }

    function removeChildren(node) // used by spistless
    {
        while(node.firstChild) { var child = node.firstChild; node.removeChild(child); }; // Altnernative: node.innerHTML = ""
    }
}

window.spiSite = new SPISite();

有些代码很有趣,需要解释:

   ...
    this.load();

    function load() // page load phase
    {
        if (this.disabled) return;

        var currLoc = new LocationState();
        var stateName = currLoc.getStateName();
        if (stateName == null) return;
        this.initialURLWithState = currLoc.getURL();
    }

    function setStateInURL(stateName)
    {
        if (this.disabled) return;

        var currLoc = new LocationState();
        currLoc.setStateName(stateName);

        this.url = currLoc.getURL();

        if (!this.firstTime) return;
        this.firstTime = false;

        if (this.initialURLWithState != null)
        {
            // Loads the initial state in URL if different to default
            currLoc.setURL( this.initialURLWithState );
            this.initialURLWithState = null;
        }

        this.detectURLStateChange();
    }
    ...
}

当您在浏览器中明确地写下这样一个网址时http://localhost:8080/spistfulhashbangtut/#!st=somestate,服务器只接收http://localhost:8080/spistfulhashbangtut/如果没有片段/hashbang,这是正常的,也是预期的,hashbang不是一个正常的参数,不会被web浏览器发送到服务器。服务器返回并设置默认状态(overview在本教程中),但是JavaScript代码第一次自动检测到这种情况(initialURLWithState属性)并设置用户指定的初始状态。网络爬虫没有问题,因为链接是使用?st=somestate或者当一个网址#!st=somestate被检测到_escaped_fragment_用于获取页面。

在传统的网站中,用户习惯于通过点击后退/前进按钮进行页面导航,大多数时候这种动作是“自动”的,要求某些用户不要点击后退按钮,几乎是不可能的。SPI网站必须支持用户操作的历史导航,幸运的是历史导航是由我们的小框架的JavaScript代码管理/模拟的。

在我们的示例中,后退/前进按钮不受重载支持,因为window.spiSite.onBackForward注册于main.html通过电话:

<script>
function setState(name)
{
    ...
}

window.spiSite.onBackForward = setState;
</script>

当检测到单击“后退/前进”按钮时,通过调用在中注册的方法,将URL的散列中的状态更改转发到服务器window.spiSite.onBackForward从网址获取的状态名称。在本教程中,用户定义的方法setName向服务器发送用户事件以设置新的状态名。通过这种方式,我们的单页界面保持纯串行接口,包括后退/前进行为(通常是手动历史导航)。如果在不重新加载页面的情况下,任意状态变化转换过于复杂,请使用默认方法(页面重新加载)离开window.spiSite.onBackForward未定义。

基本状态的基础设施

现在是时候使用我们的小型框架深入一个具体的例子了。我们必须定义在这个SPI网站中用作示例的基本状态。

回到servlet:

// Fragments
itsNatServlet.registerItsNatDocFragmentTemplate("not_found","text/html",pathFragments + "not_found.html");
itsNatServlet.registerItsNatDocFragmentTemplate("overview","text/html",pathFragments + "overview.html");
itsNatServlet.registerItsNatDocFragmentTemplate("overview.popup","text/html",pathFragments + "overview_popup.html");
itsNatServlet.registerItsNatDocFragmentTemplate("detail","text/html",pathFragments + "detail.html");
itsNatServlet.registerItsNatDocFragmentTemplate("detail.more","text/html",pathFragments + "detail_more.html");

碎片overview包含< body >中的标记,当用户选择“概述”菜单选项或加载我们网站的URL指定时,该标记将包含在主页的内容区域中overview作为初始状态。对...也一样detail

碎片overview.popup将被插入到overview片段,对于detail.more。两者之间有很大的区别overview.popupdetail.more那是overview.popup被注册为基本状态,也就是说,它是可登记的,detail.more只是一个没有可书签的子状态(不是一个基本状态)。

这就是为什么overview.popup已注册并detail.more不是,你记得吗SPITutMainLoadRequestListener

.addSPIStateDescriptor(new SPIStateDescriptor("overview","Overview",true))
.addSPIStateDescriptor(new SPIStateDescriptor("overview.popup","Overview Popup",false))
.addSPIStateDescriptor(new SPIStateDescriptor("detail","Detail",true))
.addSPIStateDescriptor(new SPIStateDescriptor("not_found","Not Found",true))

替代状态detail.more没有注册为基本状态。

在这种情况下,布尔参数指示状态是否为“主级别”overview.popup不是“主级”,是第二级状态依赖的overview状态,当选择该状态时overview.popup选择菜单项“概述”是因为只有overview部分用于菜单激活(这是我们指定分隔符的原因)。

这是混凝土的源代码SPITutMainDocument继承自SPIStfulHashbangMainDocument

package org.itsnat.spistfulhashbangtut;

import org.itsnat.spistful.SPIStfulState;
import org.itsnat.core.http.ItsNatHttpServletRequest;
import org.itsnat.core.http.ItsNatHttpServletResponse;
import org.itsnat.spistfulhashbang.SPIStfulHashbangMainDocument;
import org.itsnat.spi.SPIMainDocumentConfig;
import org.itsnat.spi.SPIStateDescriptor;
import org.w3c.dom.Element;

public class SPITutMainDocument extends SPIStfulHashbangMainDocument
{
    public SPITutMainDocument(ItsNatHttpServletRequest request, ItsNatHttpServletResponse response,SPIMainDocumentConfig config)
    {
        super(request,response,config);
    }

    @Override
    public SPIStfulState changeState(String stateName,ItsNatHttpServletRequest request,ItsNatHttpServletResponse response)
    {
        SPIStfulState state = super.changeState(stateName,request,response);

        itsNatDoc.addCodeToSend("try{ window.scroll(0,-5000); }catch(ex){}");
        // try/catch is used to avoid exceptions when some (mobile) browser does not support window.scroll()

        return state;
    }

    @Override
    public SPIStfulState createSPIState(SPIStateDescriptor stateDesc,ItsNatHttpServletRequest request,ItsNatHttpServletResponse response)
    {
        String stateName = stateDesc.getStateName();
        if (stateName.equals("overview")||stateName.equals("overview.popup"))
        {
            boolean popup = false;
            if (stateName.equals("overview.popup"))
            {
                popup = true;
                stateDesc = getSPIStateDescriptor("overview");
            }
            return new SPITutStateOverview(this,stateDesc,popup);
        }
        else if (stateName.equals("detail"))
            return new SPITutStateDetail(this,stateDesc);
        else
            return null;
    }


    @Override
    public void onChangeActiveMenu(Element prevActiveMenuItemElem,Element currActiveMenuItemElem)
    {
        if (prevActiveMenuItemElem != null)
            prevActiveMenuItemElem.removeAttribute("class");
        if (currActiveMenuItemElem != null)
            currActiveMenuItemElem.setAttribute("class","menuOpSelected");
    }
}

稍后我们将解释SPITutState*,它们都继承自SPIStfulState

支持谷歌分析监控基本状态

最后两行SPIMainDocument.registerState(...)是:

String jsIFrameRef = itsNatDoc.getScriptUtil().getNodeReference(config.getGoogleAnalyticsElement());
itsNatDoc.addCodeToSend("var elem = " + jsIFrameRef + "; try{ elem.contentWindow.location.replace('" + googleAnalyticsIFrameURL + stateName + "'); } catch(e) {}");

更改用于谷歌分析的< iframe >的当前页面的URL,添加新的基本状态的名称,因为location.replace()使用时,页面会在谷歌分析中重新加载已更改的状态,但根据replace()行为,不会在历史中添加新条目(避免不必要的历史条目)。通过这种方式,您可以监控终端用户访问您的SPI网站的基本状态的次数(以及访问者和方式)。

参数ganalyt_st在中检测到SPITutGlobalLoadRequestListener并通过以下调用重定向了加载在servlet中注册的google_analytics模板的请求:

docTemplate = itsNatServlet.registerItsNatDocumentTemplate("google_analytics","text/html", pathPages + "google_analytics.html");
docTemplate.setScriptingEnabled(false);

此模板(google_analytics.html)仅包含谷歌分析的脚本(当然具体的令牌值对您无效):

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    <meta http-equiv="Pragma" content="no-cache" />
    <meta http-equiv="expires" content="Wed, 1 Dec 1997 03:01:00 GMT" />
    <meta http-equiv="Cache-Control" content="no-cache, must-revalidate" />
    <title>Google Analytics
</head>
<body style="margin:0; padding:0;">

<script>
  (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
  (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
  m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
  })(window,document,'script','//www.google-analytics.com/analytics.js','ga');

  ga('create', 'UA-2924757-6', 'auto');
  ga('send', 'pageview');

</script>

</body>
</html>

这个文件可能是一个简单的html(或JSP)文件,不在它的Nat控制之内,但是它的Nat曾经添加响应头来禁用浏览器中的页面缓存(这是一个很好的强制和确保重新加载和脚本执行的方法)。

注意通话:

docTemplate.setScriptingEnabled(false);

此配置调用将此页面模板设置为不可脚本化的,ItsNat不会将JavaScript代码添加到页面中以实现客户机-服务器同步,因此没有AJAX,此外,ItsNat文档仅在服务器中创建并在加载时使用,然后被丢弃,因为此页面是无状态的(服务器观点)。

基本状态:overviewoverview.popup

具体的基本状态(继承自SPIStfulState)是SPITutStateOverview,SPITutStateOverviewPopup,SPITutStateDetail

SPITutStateOverviewSPITutStateDetail直接由SPITutMainDocument因为两者都是主菜单选项。SPITutStateOverviewPopup非常有趣,因为尽管它是SPITutStateOverview我们希望这种状态(“显示弹出窗口的概述”)成为一种基本状态,即可书签标记、搜索引擎爬虫可访问并由谷歌分析监控的内容,因此它是从SPIStfulState因为SPIMainDocument.registerState(SPIState)必须调用方法。

现在我们准备好展示了SPITutStateOverview

package org.itsnat.spistfulhashbangtut;

import org.itsnat.spistful.SPIStfulState;
import org.itsnat.spi.SPIStateDescriptor;
import org.w3c.dom.Element;
import org.w3c.dom.events.Event;
import org.w3c.dom.events.EventListener;
import org.w3c.dom.events.EventTarget;
import org.w3c.dom.html.HTMLDocument;

public class SPITutStateOverview extends SPIStfulState implements EventListener
{
    protected Element popupElem;
    protected SPITutStateOverviewPopup popup;

    public SPITutStateOverview(SPITutMainDocument spiTutDoc,SPIStateDescriptor stateDesc,boolean showPopup)
    {
        super(spiTutDoc,stateDesc,!showPopup);

        HTMLDocument doc = getItsNatHTMLDocument().getHTMLDocument();
        this.popupElem = doc.getElementById("popupId");
        ((EventTarget)popupElem).addEventListener("click",this,false);

        if (showPopup) showOverviewPopup();
    }

    @Override
    public void dispose()
    {
        if (popup != null) popup.dispose();
        ((EventTarget)popupElem).removeEventListener("click",this,false);
    }

    @Override
    public void handleEvent(Event evt)
    {
        showOverviewPopup();
    }

    public void showOverviewPopup()
    {
        ((EventTarget)popupElem).removeEventListener("click",this,false); // Avoids two consecutive clicks
        this.popup = new SPITutStateOverviewPopup(this);
    }

    public void onDisposeOverviewPopup()
    {
        this.popup = null;
        ((EventTarget)popupElem).addEventListener("click",this,false); // Restores
        spiDoc.registerState(this);
    }

}

为了理解这个类在做什么,我们需要overview.html片段模板,当选择概览状态时,< body >中包含的标记被插入到我们网站的“内容区域”(未插入< head >中的标记):

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:itsnat="http://itsnat.org/itsnat">
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    <title>Overview
</head>
<body>

    <div>
        <h2>Overview</h2>
        <p>This "fundamental" state is processed by crawlers of search engines (Google, Yahoo, Bing...)</p>
        <p>To understant how this SPI web site is "seen" by search engines, disable
            JavaScript in your browser.
        </p>
        <p>You can load a new text like a pop-up window, the link
           used to open this pop-up contains a URL specifying as fundamental
           state this state with the popup already loaded, this URL is used
           by crawlers because "return false" is not executed.
           By this way text contained in pop-up is also processed by crawlers.
        </p>
        <a itsnat:nocache="true" id="popupId" href="?st=overview.popup" onclick="return false;">Show popup</a>
    </div>
</body>
</html>

因为“概述”是默认状态,所以下图显示了我们的网络应用程序的初始状态:

请注意以下链接:

<a id="popupId" href="?st=overview.popup" onclick="return false;">Show popup</a>

这个链接是双重的,AJAX和普通的,在这种情况下,我们直接在服务器中绑定一个事件监听器,调用:

((EventTarget)popupElem).addEventListener("click",this,false);

单击时,子状态overview.popup(也是基本状态)被实例化,并注册为当前状态,显示带有一些文本的弹出模态窗口。

当网络爬虫到达该链接时,也尝试加载具有初始状态的链接网站overview.popup因为onclick处理程序被忽略。在SPITutMainDocument我们知道这个基本状态是overview所以一个SPITutStateOverview对象是用创建的popup参数设置为true:

public SPIState createSPIState(SPIStateDescriptor stateDesc,ItsNatHttpServletRequest request,ItsNatHttpServletResponse response)
{
    String stateName = stateDesc.getStateName();
    if (stateName.equals("overview")||stateName.equals("overview.popup"))
    {
        boolean popup = false;
        if (stateName.equals("overview.popup"))
        {
            popup = true;
            stateDesc = getSPIStateDescriptor("overview");
        }
        return new SPITutStateOverview(this,stateDesc,popup);
    }
    ...
}

然后弹出窗口会在加载时间自动显示,因为它的状态是快速加载模式,弹出窗口中的标记会被呈现并作为标记发送给客户端,从而被搜索引擎爬虫访问。

班级SPITutStateOverviewPopup加载带有一些标记的片段,并将该标记插入到页面的“模态层”之上:

package org.itsnat.spistfulhashbangtut;

import org.itsnat.spistful.SPIStfulState;
import org.itsnat.comp.ItsNatComponentManager;
import org.itsnat.comp.layer.ItsNatModalLayer;
import org.itsnat.core.domutil.ItsNatTreeWalker;
import org.itsnat.core.html.ItsNatHTMLDocument;
import org.itsnat.spi.SPIMainDocument;
import org.itsnat.spi.SPIStateDescriptor;
import org.w3c.dom.DocumentFragment;
import org.w3c.dom.Element;
import org.w3c.dom.events.Event;
import org.w3c.dom.events.EventListener;
import org.w3c.dom.events.EventTarget;
import org.w3c.dom.html.HTMLBodyElement;
import org.w3c.dom.html.HTMLDocument;

public class SPITutStateOverviewPopup extends SPIStfulState implements EventListener
{
    protected SPITutStateOverview parent;
    protected Element container;
    protected ItsNatModalLayer layer;

    public SPITutStateOverviewPopup(SPITutStateOverview parent)
    {
        this(parent,parent.getSPIMainDocument().getSPIStateDescriptor("overview.popup"));
    }

    public SPITutStateOverviewPopup(SPITutStateOverview parent,SPIStateDescriptor stateDesc)
    {
        super(parent.getSPIMainDocument(),stateDesc,true);
        this.parent = parent;

        SPIMainDocument spiTutDoc = getSPIMainDocument();
        ItsNatHTMLDocument itsNatDoc = getItsNatHTMLDocument();
        HTMLDocument doc = itsNatDoc.getHTMLDocument();
        ItsNatComponentManager compMgr = itsNatDoc.getItsNatComponentManager();
        this.layer = compMgr.createItsNatModalLayer(null,false,1,0.5f,"black",null);
        HTMLBodyElement body = (HTMLBodyElement)doc.getBody();

        DocumentFragment frag = spiTutDoc.loadDocumentFragment("overview.popup");
        this.container = ItsNatTreeWalker.getFirstChildElement(frag);
        body.appendChild(container);

        ((EventTarget)container).addEventListener("click", this, false);

        //itsNatDoc.addCodeToSend("try{ window.scroll(0,-1000); }catch(ex){}");
        // try/catch is used to prevent some mobile browser does not support it
    }

    @Override
    public void handleEvent(Event evt)
    {
        dispose();
    }

    @Override
    public void dispose()
    {
        ((EventTarget)container).removeEventListener("click",this, false);
        container.getParentNode().removeChild(container);
        layer.dispose();

        parent.onDisposeOverviewPopup();
    }
}

这是与该名称相关联的模板文件overview.popup

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:itsnat="http://itsnat.org/itsnat">
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    <title>Overview Popup
</head>
<body>

    <div style="position:absolute; z-index:1; background:white; width:80%; height:80%; left:10%;  top:5%; padding:30px;">
        <h2>Overview Popup</h2>

        <p>Overview + popup is also a fundamental state so this text is processed by
            search engine crawlers (Google,Yahoo,Bing...), because you can reach this
            state on load time with a URL <a href="?st=overview.popup">like this</a>
            (you can find this text in the end of the page loaded).
        </p>

        <p><a onclick="setState('overview'); return false;" itsnat:nocache="true" href="?st=overview">Click to exit</a></p>

    </div>

</body>
</html>

基本情况detail和次要状态“更多细节”

SPITutStateDetail是另一个基本状态,还包含一个子状态(“更多细节”),但是在这种情况下,这个子状态不是基本的,不可书签标记,因此没有从继承的新类SPIStfulState没有必要SPIMainDocument.registerState(SPIState)当达到该子状态时。

的源代码SPITutStateDetail

package org.itsnat.spistfulhashbangtut;

import org.itsnat.spistful.SPIStfulState;
import org.itsnat.core.domutil.ItsNatTreeWalker;
import org.itsnat.spi.SPIStateDescriptor;
import org.w3c.dom.DocumentFragment;
import org.w3c.dom.Element;
import org.w3c.dom.events.Event;
import org.w3c.dom.events.EventListener;
import org.w3c.dom.events.EventTarget;
import org.w3c.dom.html.HTMLDocument;

public class SPITutStateDetail extends SPIStfulState implements EventListener
{
    protected Element detailMoreLink;
    protected Element detailMoreElem;
    protected boolean inserted = false;

    public SPITutStateDetail(SPITutMainDocument spiTutDoc,SPIStateDescriptor stateDesc)
    {
        super(spiTutDoc,stateDesc,true);

        HTMLDocument doc = getItsNatHTMLDocument().getHTMLDocument();
        this.detailMoreLink = doc.getElementById("detailMoreId");
        ((EventTarget)detailMoreLink).addEventListener("click",this,false);
    }

    @Override
    public void dispose()
    {
        ((EventTarget)detailMoreLink).removeEventListener("click",this,false);
    }

    @Override
    public void handleEvent(Event evt)
    {
        if (detailMoreElem == null)
        {
            DocumentFragment frag = spiDoc.loadDocumentFragment("detail.more");
            this.detailMoreElem = ItsNatTreeWalker.getFirstChildElement(frag);
        }

        if (!inserted)
        {
            Element contentParentElem = spiDoc.getContentParentElement();
            contentParentElem.appendChild(detailMoreElem);
            detailMoreLink.setTextContent("Hide");
            this.inserted = true;
        }
        else
        {
            detailMoreElem.getParentNode().removeChild(detailMoreElem);
            detailMoreLink.setTextContent("More Detail");
            this.inserted = false;
        }
    }
}

和模板片段detail.html

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:itsnat="http://itsnat.org/itsnat">
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    <title>Detail
</head>
<body>
    <h2>Detail</h2>
    <p>This "fundamental" state is processed by crawlers of search engines (Google, Yahoo, Bing...).
       The link below is used to load some new text, in this case the new
       state is not fundamental (is secondary) and the new text cannot be
       reached by crawlers of search engines.
    </p>
    <a href="javascript:;" id="detailMoreId">More Detail</a><br>
</body>
</html>

现在,链接是纯AJAX为基础的,更多信息的标记(“更多细节”)只有在最终用户点击链接时才被插入,因此新插入的标记不会被网络爬虫访问,也没有人要求将这种状态设置为可书签,在单页界面声明之后,这种状态(显示“更多细节”文本)是次要的,而不是基本状态。

名为的片段detail.more是文件吗detail_more.html

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:itsnat="http://itsnat.org/itsnat">
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    <title>More Detail
</head>
<body>
    <span>
    <h3>More Detail</h3>
    <p>This text cannot be reached by search engines, because there is no
    fundamental state registered including this fragment on load time.
    </p>
    </span>
</body>
</html>

基本情况not_found

我们终于有了国家not_found,提供此状态是为了在加载带有未知状态名的主页时显示“错误状态”(例如,在网站重新设计更改状态名之前保存的旧书签)。当这个not_found达到<正文>内容的状态not_found.html插入:

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:itsnat="http://itsnat.org/itsnat">
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    <title>State Not Found
</head>
<body>

    <h2>State Not Found</h2>

</body>
</html>

结论

本文展示了一个通用的例子,说明如何使用hashbangs网站构建有状态的SPI,它的状态类似于基于页面的对等网站,而不牺牲页面范例的典型特性,如书签、搜索引擎优化、禁用JavaScript、后退/前进按钮(历史导航)、页面访问计数器等。当一个网站被移植到SPI时,页面通常被转换成状态,这些状态可以是基本的,而不是基本的(次要的)。本教程展示了基本状态是如何与页面非常相似的,包括基于纯超文本标记语言的模板设计,以及由于其以服务器为中心的特性、浏览器即服务器的方法和快速加载特性,我们如何将基本状态设置为几乎任何状态。

本教程中的大部分代码是基础设施代码,小型框架可以在许多项目中重用,这是一个SPI网站的固定成本,任何新的基本状态只需要一个新的简单的HTML模板、一些最小的注册代码和一个从继承而来的新类SPIState,这个类可以是空的,也可以是共享的(同一个类),只包含静态内容。总之,添加一个只有静态内容的新基本状态的成本几乎与添加一个简单的超文本标记语言文件(如基于页面的开发)的成本相同,其优点是无需关注和不重复页眉、页脚,并且一般来说,在改变状态时,添加内容之外的静态内容,避免了基于页面的开发中的重复负担,通过使用AJAX事件向高度交互的代码开放的SPI,改善了最终用户体验。

这不是唯一的方法,还有其他选择,比如无状态和/或使用历史应用编程接口,下一篇文章将展示这些选项。

下载、在线演示和链接

Running online

Source code在GitHub中