什么是单页面应用(SPA)?
维基百科上的描述是这样的:
1 2 3 |
“A single-page application (SPA), is a web application or web site that fits on a single web page with the goal of providing a more fluid user experience akin to a desktop application.” |
也就是说,单页面应用是仅包含单个网页的应用,目的是为了提供类似于本地应用的流畅用户体验。
需不需要框架?
要实现单页面应用,现在已经有很多现成的框架了,比如AngularJS
、Ember.js
、Backbone.js
等等。它们都是很全面的开发平台,为单页面应用开发提供了必需的页面模板、路径解析和处理、后台服务api访问、DOM操作等功能。
事实上,现代的web应用开发基本都离不开一个甚至多个框架,开发无框架应用的想法听起来蛮不靠谱的,对吧?
但是我总觉得现在是时候抛弃框架了。前两年我都在用AngularJS
做开发,可以说已经比较熟悉它了,我的第一个单页面应用就是在AngularJS
的启发下做出来的。框架曾经是我的挚爱。
但是现在每次看着它们那庞大臃肿的身躯和晦涩的语法,我都会想到诸葛亮的那句名言:“好累,感觉不会再爱了”
。还有不同框架下各种工具、插件难以混用的现状,让我不得不经常需要自己写原生代码解决很多问题。时间长了,我自然冒出一个想法:“为啥不干脆抛弃框架,直接写原生代码呢?毕竟,框架也是原生代码写出来的嘛。”
怎么实现无框架SPA?
在微博里表达了这个想法之后,有不少朋友提出了各种意见和建议,我非常感谢。其中还有个小朋友评论道:“我看到了一个从大型机到web的大叔,在抠性能[偷笑]这是职业病嘛。”
。看到这条评论,我含笑不语。
这种职业病在我们从90年代过来的老码农里还是比较普遍的,当年内存64K,磁盘360K,必须精打细算才能过日子。1个byte要掰成2个4位用,链表要自己实现,每一K内存里放了啥都门清。后来工作了,在ES/9000上做开发,系统资源也是非常金贵的。
记得有一次我们单位因为某个数据库应用系统吃内存太厉害,找IBM加了128K内存,一下子就花了60多万人民币,60多万哪!当时我的心在滴血:“把钱给我一半,我帮你们优化一下,省下这些内存行不?”。后来有机会瞻仰了一下那个系统的代码,我滴个妈呀,无数的join操作,当时骂娘的心都有了,但代码是我们部门一位元老写的,我一个新来的菜鸟惹不起…
总之,那时写代码是艺术,现在有的同学动不动就把一堆东西全load到内存里,反正内存不够了就加,这不是败家子么!哼!(老码农倚老卖老,不能算新闻)
好了,一不小心扯远了,还是说单页面应用的事情。
总之,无框架单页面应用看似可行,但难度有多大?我还是心里没底,需要一点理论依据给自己壮胆。所以我就在网上到处寻摸了一番,偶然找到了这篇 Google 工程师 Joe Gregorio 写的文章《别再用JS框架了》,里面的分析有一种与我心有戚戚的感觉,看完还给它翻译成中文了。
不过,他提出的方法是更超前的,例如 imports
和 Polymer
,我曾经试过,印象中只有 Google 的 Chrome Canary 才有支持,而且要先在选项中打开一些试验功能,浏览器会变得不那么稳定。而 X-Tag 和 Bosonic 也要依赖于一个小的库。而我想做的是现在的浏览器就已经能支持的功能,用原生代码来实现。所以他这篇文章只能让我坚定方向,但是具体的做法还得靠自己去发现。
后来又看了几篇比较偏学术的文章,例如这篇 Mixu 写的《Single page apps in depth》,对我也不太适用。他的模板都需要先编译为JS对象存放,和 AngularJS 的方法类似,但我觉得在一个小规模应用里应该有更加优雅的实现方法。
找了好几天文档,我突然意识到自己浪费了不少时间。所谓理论依据应该是高层次的,解决可行性的问题,剩下的就是自己去想办法实现了。可行性不是明摆着的嘛,那么多框架不也是用原生代码实现的么?
想到这儿,我就开始自己尝试了。前后一共只花了两三天时间,写出来一共一百多行JS,就基本解决了问题。其实把代码写完了回顾一下,这些方法都算不上什么创新,都是标准的东西而已。肯定有别人也这么做了,只是我不知道而已吧。
可能有读者看到这儿不耐烦了:“Talk is cheap. Show me the code.”
好吧,下面就是代码的描述。
老码农的实现方法
基础对象
首先是定义缺省的两个页面片段(缺省页面和出错页面,这两个页面是基础功能,所以放在库里)相关代码,对每个片段对应的url(例如home
)定义一个同名的对象,里面存放了对应的 html 片段文件路径、初始化方法。
1 2 3 4 5 6 7 8 9 10 11 |
var home = {}; //default partial page, which will be loaded initially home.partial = "lib/home.html"; home.init = function(){ //bootstrap method //nothing but static content only to render } var notfound = {}; //404 page notfound.partial = "lib/404.html"; notfound.init = function(){ alert('URL does not exist. please check your code.'); } |
随后是全局变量,包含了 html 片段代码的缓存、局部刷新所在 div 的 DOM 对象和向后端服务请求返回的根数据(rootScope
,初始化时未出现,在后面的方法中才会用到):
1 2 3 |
var settings = {}; //global parameters settings.partialCache = {}; //cache for partial pages settings.divDemo = document.getElementById("demo"); //div for loading partials, defined in index.html |
主程序
下面就是主程序了,所有的公用方法打包放到一个对象miniSPA
中,这样可以避免污染命名空间:
1 2 |
// Main Object here var miniSPA = {}; |
然后是 changeUrl 方法,对应在index.html
中有如下触发定义:
1 |
<body onhashchange="miniSPA.changeUrl();"> |
onhashchange
是在location.hash发生改变的时候触发的事件,能够通过它获取局部 url 的改变。在index.html
中定义了如下的链接:
1 2 3 4 5 6 |
<h1> Demo Contents:</h1> <a href="#home">Home (Default)</a> <a href="#postMD">POST request</a> <a href="#getEmoji">GET request</a> <a href="#wrong">Invalid url</a> <div id="demo"></div> |
每个 url 都以#
号开头,这样就能被onhashchange
事件抓取到。最后的 div 就是局部刷新的 html 片段嵌入的位置。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
miniSPA.changeUrl = function() { //handle url change var url = location.hash.replace('#',''); if(url === ''){ url = 'home'; //default page } if(! window[url]){ url = "notfound"; } miniSPA.ajaxRequest(window[url].partial, 'GET', '',function(status, page){ if(status == 404){ url = 'notfound'; //404 page miniSPA.ajaxRequest(window[url].partial,'GET','',function(status, page404){ settings.divDemo.innerHTML = page404; miniSPA.initFunc(url); //load 404 controller }); } else{ settings.divDemo.innerHTML = page; miniSPA.initFunc(url); //load url controller } }); } |
上面的代码先获取改变后的 url,先通过window[url]
找到对应的对象(类似于最上部定义的home
和notfound
),如对象不存在(无定义的路径)则转到404
处理,否则通过ajaxRequest
方法获取window[url].partial
中定义的 html 片段并加载到局部刷新的div,并执行window[url].init
初始化方法。
ajaxRequest
方法主要是和后端的服务进行交互,通过XMLHttpRequest
发送请求(GET
或POST
),如果获取的是 html 片段就把它缓存到settings.partialCache[url]
里,因为 html 片段是相对固定的,每次请求返回的内容不会变化。如果是其他请求(比如向 Github 的 markdown 服务 POST 一个字符串)就不能缓存了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
miniSPA.ajaxRequest = function(url, method, data, callback) { //load partial page if(settings.partialCache[url]){ callback(200, settings.partialCache[url]); } else { var xmlhttp; if(window.XMLHttpRequest){ xmlhttp = new XMLHttpRequest(); xmlhttp.open(method, url, true); if(method === 'POST'){ xmlhttp.setRequestHeader("Content-type","application/x-www-form-urlencoded"); } xmlhttp.send(data); xmlhttp.onreadystatechange = function(){ if(xmlhttp.readyState == 4){ switch(xmlhttp.status) { case 404: //if the url is invalid, show the 404 page url = 'notfound'; break; default: var parts = url.split('.'); if(parts.length>1 && parts[parts.length-1] == 'html'){ //only cache static html pages settings.partialCache[url] = xmlhttp.responseText; //cache partials to improve performance } } callback(xmlhttp.status, xmlhttp.responseText); } } } else{ alert('Sorry, your browser is too old to run this app.') callback(404, {}); } } } |
对于不支持XMLHttpRequest
的浏览器(主要是 IE 老版本),本来是可以在 else 里加上xmlhttp = new ActiveXObject(‘Microsoft.XMLHTTP’);的,不过,我手头也没有那么多老版本 IE 用于测试,而且老版本 IE 本来就是我深恶痛绝的东西,凭什么要支持它啊?所以就干脆直接给个alert
完事。
render
方法一般在每个片段的初始化方法中调用,它会设定全局变量中的根对象,并通过