65.9K
CodeProject 正在变化。 阅读更多。
Home

简单的单页应用程序框架

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.99/5 (44投票s)

2015年11月4日

CPOL

14分钟阅读

viewsIcon

78152

downloadIcon

703

85行 Javascript,1441字节压缩后

引言

我写这篇文章的动机是我想

  1. 了解SPA(单页应用)工作原理的细节
  2. 一个框架,它不像MVC、数据绑定等那样与其他很多冗余的东西纠缠在一起。
  3. 易于使用的东西——我研究过的SPA框架似乎都异常复杂,我想知道它是否真的需要那么复杂

与一些自制的SPA示例不同,此代码不采用隐藏和显示div标签等容器的方法。这里提出的实现

  • 从服务器动态加载HTML和Javascript内容
  • 自动卸载上一页的Javascript
  • 实现一个简单的路由器来导航到任何SPA页面
  • 实现浏览器导航栏历史

请注意,这里所有的Javascript代码仅依赖于jQuery。

运行演示

我包含了一个简单的Web服务器,以便您可以“开箱即用”地运行演示。只需启动SimpleServer应用程序并在浏览器中导航到localhost。当然,我假设端口80是空闲的,并且有时添加侦听器到 https:/// 会有问题,但希望您不会遇到任何问题。Web服务器代码发布在本文章的最后。

一个简单的HTML更改示例

让我们从一个简单的概念开始——如何在运行时更改HTML。这当然是SPA的要求,它要么已经拥有所需的所有HTML供用户在网站上导航,要么最多,它只需要从服务器查询*仅*它所需的内容。

所以,让我们从一个简单的例子开始,说明如何动态更改HTML的一个区域。

<!DOCTYPE html>
<html lang="en">
<head>
  <title>Index</title>
</head>
<body>
  <script type="text/javascript">
    var state = 0
    $(document).ready(function () {
      var hello = "<div>Hello World!</div>"
      var goodbye = "<div>Goodbye Cruel World</div>"

      $("#region1").html(hello)

      $("#switcher").click(function () {
        if (state == 0) {
          $("#region1").html(goodbye)
          state = 1
        } else {
          $("#region1").html(hello)
          state = 0
        }
      })
    })
  </script>

  <div id="region1"></div>

  <div id="region2">
    <button id="switcher">Switch HTML</button>
  </div>
</body>
</html>

从上面的标记应该可以清楚地看出,“region1”div的内容是动态更新的。这是一个JSFiddle演示。

稍后讨论

  • 更新文档的特定区域
  • 验证身份验证/授权,以确保用户有权查看页面
  • 使用哈希标签为更改创建浏览器历史记录

一个简单的Javascript更改示例

好的,没那么简单。但其思想是,与HTML一样,我们希望能够加载(和卸载)页面所需的任何Javascript。虽然我们可以避免这种情况,一次性加载网站所需的所有Javascript,但这是一种非常不切实际的假设,因为这可能是几十甚至几百兆字节的Javascript(天哪)。

使用Eval()

似乎有几种方法可以做到这一点,最简单的是使用eval()

<script type="text/javascript">
  var state = 0
  $(document).ready(function () {
    var hello = "<div>Hello World!</div>"
    var goodbye = "<div>Goodbye Cruel World</div>"

    var jsHello = "$('#region1').html(hello);state = 0"
    var jsGoodbye = "$('#region1').html(goodbye);state = 1"

    eval(jsHello)

    $('#switcher').click(function () {
      if (state == 0) {
        eval(jsGoodbye)
      } else {
        eval(jsHello)
      }
    })
  })
</script>

好的,这很有趣,您可以在这里看到它的运行。然而,更有趣的是实际重新连接按钮的点击事件处理程序,从而有效地消除单独的state变量。

$(document).ready(function () {
  var hello = "<div>Hello World!</div>"
  var goodbye = "<div>Goodbye Cruel World</div>"

  var jsHello = "\
    $('#region1').html(hello);\
    $('#switcher').off('click');\
    $('#switcher').click(function () {eval(jsGoodbye)});"

  var jsGoodbye = "\
    $('#region1').html(goodbye);\
    $('#switcher').off('click');\
    $('#switcher').click(function () {eval(jsHello)});"

  eval(jsHello)
})

您还会注意到,我们正在解除先前的事件处理程序,因为按钮点击的行为现在已经改变了。

注意:可以使用jQuery的on/off函数和命名空间来实现事件的选择性绑定/解除绑定,如此处所讨论。

重要提示:在处理SPA时,解除事件处理程序非常重要,以防止内存泄漏和其他奇怪的行为。

上面示例的JSFiddle在这里。这个示例真正有趣的是Javascript可以从AJAX调用加载。

添加/删除脚本元素

上述方法可能存在内存泄漏,尤其是在使用jQuery的loadScript()方法时,因为页面在不断地被更改、加载和执行Javascript,而从不删除。

其他方法,如此处此处所讨论的,涉及使用document.write或从head动态添加/删除script元素。我将借鉴javascriptkit.com关于加载和删除外部Javascript的讨论来进行以下演示(这部分需要服务器)。

function loadjs(filename) {
  var fileref = document.createElement('script')
  fileref.setAttribute("type", "text/javascript")
  fileref.setAttribute("src", filename)
  $("head")[0].appendChild(fileref)
}

function unloadjs(filename) {
  var targetElement = "script"
  var targetAttr = "src"
  var allSuspects = $(targetElement)
  for (var i = allSuspects.length; i >= 0; i--) {
    if (allSuspects[i] &&
      allSuspects[i].getAttribute(targetAttr) != null &&
      allSuspects[i].getAttribute(targetAttr).indexOf(filename) != -1) {
      allSuspects[i].parentNode.removeChild(allSuspects[i])
    }
  }
}

观察内存使用情况(使用Chrome的任务管理器——在Chrome浏览器上按Shift+Esc)可以明确显示,使用loadScript会导致内存泄漏(页面所需的内存不断增长),而上述方法,即从head附加和删除Javascript文件,则不会。这并不是说loadScript会导致内存泄漏,它只是不正确的方法。

描述我们从服务器需要什么

为了创建比将HTML和Javascript嵌入字符串更复杂的东西,我们需要一个函数来描述页面渲染所需的服务器内容,以及如何清理我们当前页面的事件。以下是我们这个函数所需内容的原型:

function loadRegion(pageName, containerId, htmlFile, loadJavascriptFiles, unloadJavascriptFiles, fncUnload) { }

这包括:

  • 新页面名称(稍后使用,以便浏览器历史记录正常工作)
  • 容器ID(一个容器,例如一个div,HTML将被替换到其中)
  • 来自服务器(或其他位置)的实际HTML文件,它定义了HTML内容
  • 一个包含零个或多个Javascript文件的集合,它们将被附加到head
  • 一个包含零个或多个Javascript文件的集合,当页面不再需要它们时将被删除
  • 用于卸载(解除绑定)当前页面的函数

重要提示:查询HTML文件的服务器是执行身份验证/授权的一种方法。

实现

利用jQuery的实现很简单。

function loadRegion(pageName, containerId, htmlFile, loadJavascriptFiles, unloadJavascriptFiles, fncUnload) {
  fncUnload()
  $('#' + containerId).load(htmlFile)
  unloadJavascriptFiles.map(function (jsFile) {
    unloadjs(jsFile)
  })
  loadJavascriptFiles.map(function (jsFile) {
    loadjs(jsFile)
  })
}

演示

真的就这么简单吗?是的。这是我们新的“主”页面,以及 nicely separated out(猜猜看,我们刚刚将HTML和Javascript分离到了一个视图和一个控制器!)的文件(注意我喜欢使用“spa”扩展名来区分我的SPA文件)。

loadRegion.html(仅Javascript部分,区域保持不变)

<script type="text/javascript">
  $(document).ready(function () {
    loadRegion('hello', 'region1', 'helloworld.spa', ['hello.js'], [], function() {$('#switcher').off('click')})
  })
  ...
}
</script>

我们的初始文档加载hello.js,并且在页面更改时没有需要卸载的Javascript。

helloWorld.spa

<div>Hello World!</div>

goodbyeWorld.spa

<div>Goodbye Cruel World</div>

hello.js

function hello() {
  loadRegion('hello', 
    'region1', 
    'helloworld.spa', 
    ['goodbye.js'], 
    ['hello.js'], 
    function () { $('#switcher').off('click') })
}
$('#switcher').click(function () { hello() });

goodbye.js

function goodbye() {
  loadRegion('goodbye', 
    'region1', 
    'goodbyeworld.spa', 
    ['hello.js'], 
    ['goodbye.js'], 
    function () { $('#switcher').off('click') })
}
$('#switcher').click(function () { goodbye() });

这种方法的优点(至少我认为是这样)是,我可以精细地控制哪些区域根据特定SPA的要求进行更新。当然,它看起来有点笨拙,我们稍后会改进它。

浏览器历史

这包括两部分:能够告知浏览器我们已更改了站点URI,以及在给定特定URI时能够导航到正确的SPA。后者需要一个客户端路由表!

首先,我们将这一行添加到我们的loadRegion函数中。

setCurrentPage(pageName)

这会设置全局变量currentPage,以便我们可以跟踪是通过编程设置的页面还是用户在地址栏输入的请求的页面。

function setCurrentPage(pageName) {
  currentPage = pageName
  window.location.hash = pageName
}

现在,我们在地址栏中有一个可定位的页面。

https:///loadregion#hello
https:///loadregion#goodbye 

我们还可以挂钩哈希更改事件并处理渲染正确的SPA。

$(window).on('hashchange', function () {
  render(window.location.hash);
});

function render(hash) {
  if ('#' + currentPage != hash) {
    ...
  }
}

现在到了复杂的部分

无论我们在哪个页面上,用户都可以手动输入地址前往一个完全不同的页面。为了正确清理现有页面,我们需要知道我们刚才在哪里。知道我们在哪里和我们要去哪里都需要一个客户端路由表。

var router = [
  {
    pageName: '', region: 'region1', spa: 'helloworld.spa', load: ['hello.js'], unbind: function () {}
  },
  {
    pageName: 'hello', region: 'region1', spa: 'helloworld.spa', load: ['hello.js'], unbind: function () { $('#switcher').off('click') }
  },
  {
    pageName: 'goodbye', region: 'region1', spa: 'goodbyeworld.spa', load: ['goodbye.js'], unbind: function () { $('#switcher').off('click') }
  }
]

请注意,每个页面都知道如何解除绑定自身。我们也不必指定要卸载的Javascript,因为我们假设下一个页面渲染之前需要卸载页面加载的Javascript(在此迭代中,我们不处理公共Javascript代码)。我们现在可以实现renderloadPage函数,并重构loadRegion函数。

function render(hash) {
  if ('#' + currentPage != hash) {
    loadPage(hash.substring(1))
  }
}

function loadPage(pageName) {
  var oldPage = $.grep(router, function (r) { return r.pageName == currentPage })
  var newPage = $.grep(router, function (r) { return r.pageName == pageName })

  if (newPage.length == 1) {
    if (oldPage.length == 1) {
      oldPage[0].unbind()
      oldPage[0].js.map(function (jsFile) {
        unloadjs(jsFile)
      })
    }

    loadRegion(newPage[0])
    setCurrentPage(newPage[0].pageName)
  }
}

function loadRegion(pageInfo) {
  $('#' + pageInfo.region).load(pageInfo.spa)

  pageInfo.js.map(function (jsFile) {
    loadjs(jsFile)
  })
}

我们特定于每个SPA的Javascript现在更加简单了。

hello.js

$('#switcher').click(function () { loadPage('goodbye') });

goodbye.js

$('#switcher').click(function () { loadPage('hello') });

这就是全部了。我们可以通过重构页面加载器/卸载器来迭代匹配的页面集合,从而支持多个区域。

function loadPage(pageName) {
  var oldPages = $.grep(router, function (r) { return r.pageName == currentPage })
  var newPages = $.grep(router, function (r) { return r.pageName == pageName })

  if (newPages.length > 0) {
    if (oldPages.length > 0) {
      oldPages.map(function(oldPage) {
        oldPage.unbind()
        oldPage.js.map(function (jsFile) {
          unloadjs(jsFile)
        })
      })
    }

    newPages.map(function(newPage) {
      loadRegion(newPage)
    })

    setCurrentPage(newPages[0].pageName)
  }
}

这使我们可以定义页面更新的不同区域,如下所示。

{
  pageName: 'hello', region: 'region1', spa: 'helloworld.spa', js: ['hello.js'], unbind: function () { $('#switcher').off('click') }
},
{
  pageName: 'hello', region: 'region3', spa: 'hellospa.spa', js: [], unbind: function () { }
},

在这里,“hello”页面使用不同的HTML更新div region1和region3。只有region1有相关的Javascript。您可以在本文档中下载的演示代码说明了此功能。

身份验证/授权

由于HTML是从服务器获取的(而不是从预加载的容器中隐藏/显示的),服务器可以执行会话身份验证检查并确定用户是否有权查看内容。这里令人烦恼的问题是,当SPA页面因为某些错误(典型的错误包括页面未找到、会话过期、无身份验证、未授权)需要重定向时会发生什么?

页面未找到

页面未找到足够简单,有趣的是,它现在由客户端处理(至少对于找不到的哈希标记页面而言)。

function loadPage(pageName) {
  var oldPages = $.grep(router, function (r) { return r.pageName == currentPage })
  var newPages = $.grep(router, function (r) { return r.pageName == pageName })

  if (newPages.length > 0) {
    ...
  } else {
    loadPage('pageNotFound')
  }
}

我们只需要提供一个“pageNotFound”路由,类似这样。

{
  pageName: 'pageNotFound', region: 'region1', spa: 'pageNotFound.spa', js: [], unbind: function () { }
}

当然,实现“pageNotFound.spa”HTML片段。

<div>We're sorry, the page you're looking for doesn't exist.</div>

过期/未授权

我们可以使用jQuery的load函数的完成回调来测试其他服务器错误,这当然需要我们的服务器端代码进行协调以产生所需的错误。

function loadRegion(pageInfo) {
  $('#' + pageInfo.region).load(pageInfo.spa, responseHandler)
  ...
}
function responseHandler(responseText, responseStatus, jqXHR) {
  if (responseStatus == 'error') {
    if (responseText == 'Authentication Required') {
      loadPage('authenticationRequired')
    }
  }
}

“authenticationRequired”页面需要一个客户端路由。

{
  pageName: 'authenticationRequired', region: 'region1', spa: 'notAuthorized.spa', js: [], unbind: function () { }
},

在我提供的示例中(不一定是最佳方法),它需要了解服务器发出的响应文本。在我的服务器中,我有这两个字符串。

switch (session.GetState(context))
{
  case SessionState.New:
    proc.ProcessInstance<WebServerMembrane, StringResponse>(r =>
      {
        r.Context = context;
        r.Message = "Authentication Required";
        r.StatusCode = 500;
      });

   break;

  case SessionState.Expired:
    proc.ProcessInstance<WebServerMembrane, StringResponse>(r =>
      {
        r.Context = context;
        r.Message = "Session Expired";
        r.StatusCode = 500;
      });

  break;
}

因此,我演示身份验证/过期问题的特定方法(而且,我不会详细介绍WebServerMembrane是什么,那是另一篇文章的内容。)

上下文

理想情况下,如果用户有权访问特定URL路径(定义了一个上下文),那么该上下文中的每个页面都应该可以查看。因此,不要在SPA上玩得太过火——整个站点不应该变成一个SPA。将您的站点划分为上下文,例如管理、报告、用户数据管理等。

LoadRegion的一个问题

在我为实际网站实现SPA解决方案时,我发现加载HTML和Javascript存在一个问题。这段代码。

function loadRegion(pageInfo) {
  $('#' + pageInfo.region).load(pageInfo.spa, , responseHandler)
  pageInfo.js.map(function (jsFile) {
    Clifton.Spa.loadjs(jsFile)
  })
}

导致了竞态条件:在Javascript处理完成之前,HTML可能已加载,也可能未加载。为了解决这个问题,我们必须确保Javascript在HTML加载*之后*加载,确保在Javascript对其进行任何处理之前HTML已经存在。

function loadRegion(pageInfo) {
  $('#' + pageInfo.region).load(pageInfo.spa, function (responseText, responseStatus, jqXHR) {
    pageInfo.js.map(function (jsFile) {
      Clifton.Spa.loadjs(jsFile)
    })
  })
}

在这里,Javascript是同步加载的,*在*HTML加载之后。我在处理客户端动态创建的HTML内容时发现了这一点——Javascript试图将内容填充到尚未存在的HTML元素中!一个可能的增强是指定特定页面的Javascript是同步加载还是异步加载。

Web服务器代码

一个非常简单的服务器。

using System;
using System.IO;
using System.Net;
using System.Text;
using System.Threading.Tasks;

namespace SimpleServer
{
  class Program
  {
    static HttpListener listener;

    static void Main(string[] args)
    {
      listener = new HttpListener();
      listener.Prefixes.Add("https:///");
      listener.Start();
      Task.Run(() => WaitForConnection(listener));
      Console.WriteLine("Press ENTER to exit the server.");
      Console.ReadLine();
    }

    static void WaitForConnection(object objListener)
    {
      HttpListener listener = (HttpListener)objListener;

      while (true)
      {
        HttpListenerContext context = listener.GetContext();
        string filename = context.Request.RawUrl.Substring(1); // strip off leading /
        Console.WriteLine(filename);

        if (filename == "")
        {
          filename = "index.html";
        }

        if (File.Exists(filename))
        {
          byte[] data = File.ReadAllBytes(filename);

          switch (Path.GetExtension(filename))
          {
            case ".js":
              context.Response.ContentType = "text/javascript";
              break;

            case ".spa":
            case ".html":
              context.Response.ContentType = "text/html";
              break;

            default:
              break;
          }

          context.Response.ContentEncoding = Encoding.UTF8;
          context.Response.ContentLength64 = data.Length;
          context.Response.OutputStream.Write(data, 0, data.Length);
          context.Response.Close();
        }
	else
        {
          context.Response.StatusCode = 500;
          context.Response.Close();
        }
      }
    }
  }
}

学到的教训

不使用框架编写SPA很简单,那么SPA为什么会复杂?Sebastian Porto的文章《构建单页应用过程中吸取的教训》是任何考虑转向SPA的人的绝佳读物。

什么让SPA变得复杂?

使SPA复杂化的不是使您的网站成为单页应用程序的技术,而是您的网站是否能正确处理从一个页面到另一个页面的所有可能的转换。一种减轻这种复杂性方法是将SPA隔离到“上下文”中,其中每个上下文都需要完整的页面加载。只要用户停留在该上下文内,您的SPA转换就是可管理的。当他们更改上下文时,完整的页面加载会切换客户端路由到新的上下文,当然也会用上下文操作的区域替换之前上下文的所有页面区域。如上所述,上下文也便于管理身份验证/授权,因为服务器正在验证上下文,而不是单个哈希页面。

SPA是一个架构决策,而不仅仅是一个框架

虽然有人可能会认为支持SPA的代码是一个框架,但编写SPA网站是一种架构的转变,需要仔细规划完整的页面上下文、这些上下文内的页面以及这些页面内正在更新的区域。它还涉及到对错误消息返回给客户端、客户端路由以及持续警惕身份验证/授权的仔细考虑,以确保您不会意外地暴露您网站的某些重要且安全的区域。

将网站转换为SPA可能并非易事

如果您打算采用SPA,从头开始比更改现有网站要容易得多。如果您必须更改现有网站(我目前就处于这种情况),我发现将其分解为逻辑上下文并一次处理一个上下文很有用。虽然我确实经历过将一个大约有40个页面的网站转换为SPA的过程,尽管它变成了一个自动化模板化的过程。

  1. 提取HTML并将其放入唯一的.spa文件中(纯属我的约定)
  2. 提取Javascript并将其放入唯一的.js文件中。
  3. 输入路由。
  4. 修复我的主菜单。
  5. 测试

而且我可以大约在2分钟内完成所有这五个步骤。关键是,如果我在编写网站之前就有了这个,我就可以节省几个小时。考虑到我正在处理的网站相当简单,这不算糟,但我可以想象一个复杂的网站,或者一个与ASP.NET或MVC Razor(我两者都没用)严重纠缠的网站,可能不会那么容易。

清理您的对象

我经常使用jqWidgets,在我的一些页面上,我使用一个jqxWindow弹出窗口来添加新记录。为了使事情更加复杂,弹出窗口的内容是根据视图模式动态创建的。我注意到的是,即使我重新将jqxWindow分配给正确的div元素,并且div元素确实已经用新的动态内容正确更新了,当窗口弹出时,它显示的是我在其中添加记录的*第一个*SPA适用的内容。结果是,我不得不添加。

$('#newRecord').jqxWindow('destroy')

作为当前页面的“析构函数”(其中#newRecord是jqxWindow弹出窗口div的ID)。

这非常直接地向我展示了编写SPA的隐藏复杂性——再怎么强调也不为过,您需要跟踪您的事件绑定,并且在使用第三方库(如jqWidgets)时,请确保您正确地解除了对象的绑定。虽然技术上我认为这是一个jqWidgets bug,但另一方面,它生动地演示了编写SPA的潜在陷阱。

不要执行页面重定向

在服务器端,不要执行页面重定向。这将重新加载整个页面,从而破坏了SPA仅注入页面特定Javascript和HTML的目的。

不要使用表单提交

不执行页面重定向的推论是,不要使用表单提交。在提交表单数据时,通常需要将其重写为AJAX调用,并处理成功/错误/失败的返回。表单提交通常在服务器端通过最终页面重定向来实现,重定向到另一个“ok”页面或错误页面。如果您有一个现有的网站,有很多表单希望将其页面转换为SPA,请考虑使用jQuery Form Plugin。我不得不说,使用jQuery Form为我节省了大量的Javascript工作,但我仍然不得不处理服务器端(移除重定向并添加错误返回)和客户端(检查错误返回并执行相应操作)。然而,完成之后,用户体验非常出色——用户在完成多个注册屏幕时页面加载速度很快。

与视图引擎集成

我没有尝试过,但我猜想,对于一个高度依赖于服务器端页面渲染(即视图引擎)的现有站点,在将其交付给浏览器之前,可能会使事情变得更加复杂。即使从头开始构建SPA并大量依赖服务器端渲染而不是客户端模板(例如,使用underscore或handlebars)似乎过于复杂。但正如我所说,我从未真正尝试过在SPA的上下文中处理像Razor这样的东西,所以它可能比我猜测的要容易得多。

其他SPA方式

我在这里演示的是一种简单的“从服务器获取新的HTML和Javascript”的实现。在许多情况下,SPA的外观和感觉可以通过简单地设置元素的可见性来实现。这是一个完全有效的设yì决定,如果它适合您的需求。当然,各种重量级的框架中都内置了SPA实现,例如Backbone(请参阅使用Backbone.js开发单页应用),但我宁愿不读十四章关于这个主题的内容——我没有足够的耐心,也没有足够的容忍度来解决简单问题的复杂解决方案。

我错过了什么?

我有一种挥之不去的感觉,我错过了一些重要的事情,尤其是关于防止内存泄漏方面,所以如果任何读者发现了这种方法的缺陷,请告诉我!

更新

2015年11月9日 - 忘记点击“将选定的zip文件添加到文章”了!感谢Sander指出这一点。

 

一个简单的单页应用程序框架 - CodeProject - 代码之家
© . All rights reserved.