开发无框架单页面应用
基础对象
首先是定义缺省的两个页面片段(缺省页面和出错页面,这两个页面是基础功能,所以放在库里)相关代码,对每个片段对应的url(例如home
)定义一个同名的对象,里面存放了对应的 html 片段文件路径、初始化方法。
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
,初始化时未出现,在后面的方法中才会用到):
var settings = {}; //global parameters settings.partialCache = {}; //cache for partial pages settings.divDemo = document.getElementById("demo"); //div for loading partials, defined in index.html
主程序
下面就是主程序了,所有的公用方法打包放到一个对象miniSPA
中,这样可以避免污染命名空间:
// Main Object here var miniSPA = {};
然后是 changeUrl 方法,对应在index.html
中有如下触发定义:
<body onhashchange="miniSPA.changeUrl();">
onhashchange
是在location.hash发生改变的时候触发的事件,能够通过它获取局部 url 的改变。在index.html
中定义了如下的链接:
<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 片段嵌入的位置。
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 一个字符串)就不能缓存了。
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
方法一般在每个片段的初始化方法中调用,它会设定全局变量中的根对象,并通过refresh
方法渲染 html 片段。
miniSPA.render = function(url){ settings.rootScope = window[url]; miniSPA.refresh(settings.divDemo, settings.rootScope); }
获取后端数据后,如何渲染 html 片段是个比较复杂的问题。这就是 DOM 操作了。总体思想就是从 html 片段的根部入手,遍历 DOM 树,逐个替换属性和文本中的占位变量(例如<img src="emojis.value">
和<p>{{emojis.key}}</p>
),匹配和替换是在feedData
方法中完成的。
这里最麻烦的是data-repeat
属性,这是为了批量渲染格式相同的一组元素用的。比如从 Github 获取了全套的 emoji 表情,共计 888 个(也许下次升级到1000个),就需要渲染 888 个元素,把 888 个图片及其说明放到 html 片段中去。而 html 片段中对此只有一条定义:
<ul> <li data-repeat="emojis" data-item="data"> <figure> <img src='{{data.value}}' width='100' height='100'> <figcaption>{{data.key}}</figcaption> </figure> </li> </ul>
等 888 个 emoji 表情来了之后,就要自动把<li>
元素扩展到 888 个。这就需要先clone
定义好的元素,然后根据后台返回的数据逐个替换元素中的占位变量。
miniSPA.refresh = function(node, scope) { var children = node.childNodes; if(node.nodeType != 3){ //traverse child nodes, Node.TEXT_NODE == 3 for(var k=0; k<node.attributes.length; k++){ node.setAttribute(node.attributes[k].name, miniSPA.feedData(node.attributes[k].value, scope)); //replace variables defined in attributes } var childrenCount = children.length; for(var j=0; j<childrenCount; j++){ if(children[j].nodeType != 3 && children[j].hasAttribute('data-repeat')){ //handle repeat items var item = children[j].dataset.item; var repeat = children[j].dataset.repeat; children[j].removeAttribute('data-repeat'); var repeatNode = children[j]; for(var prop in scope[repeat]){ repeatNode = children[j].cloneNode(true); //clone sibling nodes for the repeated node node.appendChild(repeatNode); var repeatScope = scope; var obj = {}; obj.key = prop; obj.value = scope[repeat][prop]; //add the key/value pair to current scope repeatScope[item] = obj; miniSPA.refresh(repeatNode,repeatScope); //iterate over all the cloned nodes } node.removeChild(children[j]); //remove the empty template node } else{ miniSPA.refresh(children[j],scope); //not for repeating, just iterate the child node } } } else{ node.textContent = miniSPA.feedData(node.textContent, scope); //replace variables defined in the template } }
从上面的代码可以看到,refresh
方法是一个递归执行的函数,每次处理当前 node 之后,还会递归处理所有的孩子节点。通过这种方式,就能把模板中定义的所有元素的占位变量都替换为真实数据。
feedData
用来替换文本节点中的占位变量。它通过正则表达式获取{{...}}
中的内容,并把多级属性(例如data.map.value
)切分开,逐级循环处理,直到最底层获得相应的数据。
miniSPA.feedData = function(template, scope){ //replace variables with data in current scope return template.replace(/\{\{([^}]+)\}\}/gmi, function(model){ var properties = model.substring(2,model.length-2).split('.'); //split all levels of properties var result = scope; for(var n in properties){ if(result){ switch(properties[n]){ //move down to the deserved value case 'key': result = result.key; break; case 'value': result = result.value; break; case 'length': //get length from the object var length = 0; for(var x in result) length ++; result = length; break; default: result = result[properties[n]]; } } } return result; }); }
initFunc
方法的作用是解析片段对应的初始化方法,判断其类型是否为函数,并执行它。这个方法是在changeUrl
方法里调用的,每次访问路径的变化都会触发相应的初始化方法。
miniSPA.initFunc = function(partial) { //execute the controller function responsible for current template var fn = window[partial].init; if(typeof fn === 'function') { fn(); } }
最后是miniSPA
库自身的初始化。很简单,就是先获取404.html
片段并缓存到settings.partialCache.notfound
中,以便在路径变化时使用。当路径不合法时,就会从缓存中取出404片段并显示在局部刷新的 div 中。
miniSPA.ajaxRequest('lib/404.html', 'GET','',function(status, partial){ settings.partialCache.notfound = partial; }); //cache 404 page first
好了,核心的代码就是这么多。整个 js 文件才区区 155 行,比起那些动辄几万行的框架是不是简单得不能再简单了?
有了上面的miniSPA.js
代码以及配套的404.html
和home.html
,并把它们打包放在lib
目录下,下面就可以来看我的应用里有啥内容。
应用代码
说到应用那就更简单了,app.js
一共30行,实现了一个GET
和一个POST
访问。
首先是getEmoji
对象,定义了一个 html 片段文件路径和一个初始化方法。初始化方法中分别调用了miniSPA
中的ajaxRequest
方法(用于获取 Github API 提供的 emoji 表情数据, JSON格式)和render
方法(用来渲染对应的 html 片段)。
var getEmoji = {}; getEmoji.partial = "getEmoji.html" getEmoji.init = function(){ document.getElementById('spinner').style.visibility = 'visible'; document.getElementById('content').style.visibility = 'hidden'; miniSPA.ajaxRequest('https://api.github.com/emojis','GET','',function(status, partial){ getEmoji.emojis = JSON.parse(partial); miniSPA.render('getEmoji'); //render related partial page with data returned from the server document.getElementById('content').style.visibility = 'visible'; document.getElementById('spinner').style.visibility = 'hidden'; }); }
然后是postMD
对象,它除了 html 片段文件路径和初始化方法(因为初始化不需要获取外部数据,所以只需要调用render
方法就可以了)之外,重点在于submit
方法。submit
会把用户提交的输入文本和其他两个选项打包 POST 给 Github 的 markdown API,并获取后台解析标记返回的 html。
var postMD = {}; postMD.partial = "postMD.html"; postMD.init = function(){ miniSPA.render('postMD'); //render related partial page } postMD.submit = function(){ document.getElementById('spinner').style.visibility = 'visible'; var mdText = document.getElementById('mdText'); var md = document.getElementById('md'); var data = '{"text":"'+mdText.value.replace(/\n/g, '<br>')+'","mode": "gfm","context": "github/gollum"}'; miniSPA.ajaxRequest('https://api.github.com/markdown', 'POST', data,function(status, page){ document.getElementById('spinner').style.visibility = 'hidden'; md.innerHTML = page; //render markdown partial returned from the server }); mdText.value = ''; } miniSPA.changeUrl(); //initialize
这两个对象对应的 html 片段如下:
getEmoji.html :
<h2>GET request: Fetch emojis from Github pulic API.</h2> <p> This is a list of emojis get from https://api.github.com/emojis: </p> <i id="spinner" class="csspinner duo"></i> <span id="content"> <h4>Get <strong class="highlight">{{emojis.length}}</strong> items totally.</h4> <hr> <ul> <li data-repeat="emojis" data-item="data"> <figure> <img src='{{data.value}}' width='100' height='100'> <figcaption>{{data.key}}</figcaption> </figure> </li> </ul> </span>
postMD.html :
<h2> POST request: send MD text and get rendered HTML</h2> <p> markdown text here (for example: <strong>Hello world github/linguist#1 **cool**, and #1! </strong>): </p> <textarea id="mdText" cols="80" rows="6"></textarea> <button onclick="postMD.submit();">submit</button> <hr> <h4>Rendered elements from Github API (https://api.github.com/markdown):</h4> <i id="spinner" class="csspinner duo"></i> <div id="md"></div>