@yangfch3
2017-09-21T09:55:19.000000Z
字数 4964
阅读 2857
JavaScript
FE
现在,单页面应用已经是一种趋势,这不仅能提升用户体验,还能降低服务器资源的损耗,也是 Web App 与原生 App 一战的最大资本!
在我们还无法完全享受 fetch
API 带给我们的便利时,我们的单页面开发的根基仍旧是 AJAX。当我们知道了 AJAX 这个东西后,感觉前路一片光明,但是真正用起来就会发现还有很多问题是我们需要考虑的。
本文我会首先介绍一下我所理解的前后端分离,然后我们介绍 AJAX 实现单页面应用的两种思路及其对比。
做为一个在学校一直和 CMS 打交道,同时还要负责数据库、服务器运维的程序员,我是深深体会过“上古时代” 的前后端耦合带来的痛苦的。所幸的是,前端、后端都是自己一个人做,也就不需要去和前端或者后端撕逼了。
上古时代的撕逼
小前:诶,后端,我的页面有代码更新了,我把新代码传给你,你帮我更新一下后端的模板。
小后:更你妹,你今天都™叫我更新十多次了,还要我更新?没得商量,100块一次,再后面的每次修改加价 20!
小前:咱两谁跟谁呀,谈钱多伤感情呀!
小后:谈感情才伤钱,去去去,自己花点时间学者写我们后端的代码,我可以教你,200 包会!
小前:滚!都赖设计,今天都改了十多次了。
小设:啊,这十多次里有七八次是产品把需求改了,能赖我吗?
小汪:改点需求怎么了,老大说了要让产品做到极致,那就得改。你们是在用代码改变世界,你想让用户边用你的东西边骂你吗?还不快利索点改!
……
而 AJAX
和 Node
的出现于流行则让整个 Web 开发步入了 “大前端” 时代。网上一大片关于现在前后端分离与 “大前端” 趋势的文章,而想真的尝到这些甜头,自己实践就是必须的了,这里不说太多。
这里先明确一下,下面要讲的内容里的 AJAX 单页面应用的架构是这样(为了方便,这里明确了技术栈每一项的方向,真实的开发可以自己选择语言和数据库):
- 后端使用 Java + MySQL 为公司内网的服务器提供内网数据 API,供内网其他 Web 服务器调取。
- 前端编写 Node 服务器,模板渲染,吐出首屏,路由管理,以及提供直接面向浏览器的数据 API。
- 再靠前一点,使用 Apache 或 Nginx 做负载均衡,转发请求到内网的其他服务器上。
- 浏览器端只有在首屏是接收服务器返回的整个页面,之后全部采用 AJAX 来进行数据的更新,利用服务器端 API 返回的数据进行模板渲染,达到页面的更新。
这里有几个问题可以延伸去思考:
就这样,大家各司其职,前端利用 JavaScript + Node 入侵了服务器端,后端的工作变得更加专一,前端的控制力变得更加强。虽然前端的任务似乎加重了,但是整个开发的效率则是大大提升,前后端唯一需要耦合的就是数据 API 的标准规范!
今天我们主要目标是前端使用 AJAX 进行单页面开发这一环。说到 AJAX 就脱离不了数据 API,网上有着许多免费、公开的的 API 服务提供,当然也可以换一种思路:拦截 AJAX 请求,返回假数据。很幸运,后面那种思路已经有 “轮子” 帮我们做了,这里选择 Mock.js
进行 AJAX 请求的拦截与特定模板假数据的生成。
页面不刷新而带来 url 变化我们最先想到的肯定就是 url hash 了。我们使用 location.hash
可以轻松的访问与变更 hash 值。
至于 hash 值变动带来页面可能的上下闪动(页面上可能有对应 hash 值 id 的元素),我们只需要禁用锚点点击的默认事件就行。
hash 值的变动同时还会触发全局对象上的 hashChange
事件,在这个事件里我们就能做很多事情了。我们在这个事件阶段需要做的就是依照 hash 值得变动,解析 url 之后,向对应的服务器端 API 发起 AJAX 请求获得数据更新页面。
首先我们来封装一下 AJAX 请求生成器(点击链接后面链接查看源码):ajax.js
准备好首屏页面 index.html
(这里简单起见,没有使用模板引擎进行模板+数据的渲染)
<a class="ajax-anchor" data-href="abc" href="/abc">#abc</a>
<a class="ajax-anchor" data-href="def" href="/def">#def</a>
<a class="ajax-anchor" data-href="hij" href="/hij">#hij</a>
<div id="contariner">
初始数据!
</div>
然后利用 Mock.js 进行 AJAX 拦截,提供假数据模板:
Mock.mock(/http:\/\/yangfch3\.com(\/\w+)*\?[\w^\w]*/, {
"array|+1": [
"AMD",
"CMD",
"UMD"
]
});
禁用 AJAX 请求锚点的默认点击事件(用到了 ES6 的特性,在实际使用过程中请考虑兼容性)
var ajaxAnchors = document.querySelectorAll('.ajax-anchor');
var contariner = document.querySelector('#contariner');
window.addEventListener('click', function(e) {
if ([...ajaxAnchors].indexOf(e.target) > -1) {
e.preventDefault();
location.hash = e.target.dataset['href'];
}
}, false);
使用 hashChange 事件来触发请求
var callback = function(responseText, status, xhr) {
contariner.innerHTML = responseText;
};
window.addEventListener('hashchange', function(e) {
var api = 'https://api.yangfch3.com?q=' + location.hash.substr(1);
new Ajax(api, callback);
}, false)
现在我们,点击对应的链接,页面只进行了局部的数据更新,并且我们点击浏览器后退、前进按钮可以恢复之前的页面状态!
浏览器的状态缓存机制(back-forward cache)让我们能在不做任何处理的情况下回到或前进到某一状态。
如果需要在用户每次后退进入或前进进入时页面做出相应的响应,则可以监听
pageshow
和pahehide
事件进行相应的处理!
pageshow
会在当前页面加载完后、点击浏览器后退/前进按钮重新进入当前页时触发(问题:调用 history 后退/前进 API 时会不会触发? -会);pagehide
在浏览器卸载页面的时候触发,而且是在 unload 事件之前触发
pageshow
与pagehide
事件对象persisted
属性可以用于检测当前页是否是由BFCache
载入。
现在我们总结一下这个方案的优点:
那么缺点呢?或者说在某些情境下存在的缺点。
直说吧,这套方案在我们的页面内容需要被搜索引擎收录的时候存在缺陷。搜索引擎收录爬虫在到达某个地址后不会执行页面的 JS,收录时不会像我们的浏览器一样先发起一个 Ajax 请求生成完整内容再收录,这就对网站的 SEO(如果需要的话)带来了不便。
网上有着这个问题的探讨,例如以下文章:
基本思路:
后端:准备两套服务器代码,一套给 AJAX 单页面应用用的数据服务器,一套专门给搜索引擎爬虫用的 旁路渲染服务器(提供的是完整的对应页面的 HTML 代码)。
后端接入层:一般是
Ngnix
会Apache
,根据请求的 UA,判断请求来自用户还是引擎爬虫,分流至上面后端的某台服务器上。浏览器端:给爬虫用的
<a>
的href
使用跳转型链接,这样爬虫遇到这个链接时才会继续跳转、深爬,爬虫遇到#xxx
这样的href
是不理会的;我们的 JavaScript 代码则禁用这些跳转链接的默认行为,代之为变更hash
值,使页面无需刷新。说通俗点就是:给爬虫看的是一套,对用户做的是另一套!
Google 当然也是考虑到了这一点的,所以提出了 #!
方案。
搜索引擎爬虫虽然不会去对你的
#xxx
做出例会,但是能够智能地识别#!xxx
这样的href
,转化为请求?_escaped_fragment=xxx
,你需要做的就是在服务器上准备好?_escaped_fragment=xxx
对应的 HTML 代码,就能被搜索引擎收录了。
#
#!
结构对于程序员来说还是比较容易接受的,但是对于需要直观的链接用于记忆的站点来说就不那么友好了。
有些站点是
abc.com/#/xxx/yyy
,有些是abc.com/#xxx/yyy
,还有abc.com/#!/xxx/yyy
、abc.com/#!xxx/yyy
这样的,同时输入网址时,还需要 shift + 数组组合输入,不方便!例如以前 twitter 的 https://twitter.com/#!/yangfch3,引来了用户的大量抱怨。
当然,如果你的单页面应用是无需 SEO 的话(例如后台管理界面),那么事情就相对简单一些了!
下面我们开始介绍 Ajax 单页面应用的第二种实现思路,开始逃离 #
和 #!
。
有没有一种方案,能够:
#
或 #!
结构,页面的 url 是直观的、贴近用户平时习惯的很幸运,我们能找到这个东西,HTML5 中 history 新 API 加上 popstate 事件能够完美地做到这一点。
history 对象里的 pushState()
和 replaceState()
来无更新地改变页面的 url,使用 popState
事件来实现浏览器工具栏前进、后退时的状态管理。
流程是这样的:
replaceState()
来初始化 history.state
以及处理一些相关的页面初始化事务。pushState()
来更新页面的 url,同时根据新 url 的对应 API 发起 Ajax 请求获得数据,更新页面内容,同时更新 history.state
对象popState
事件,我们在 popstate
事件的处理中实现前、后状态的恢复相关实现代码,可以查看 demo 的源码。
这样,我们就实现了对用户的友好,接下来就是另外一件事了:解决搜索引擎的收录问题(SEO)。
Discourse 做出了很好的探索:因为不使用井号结构,每个URL都是一个不同的请求。所以,要求服务器端对所有这些请求,返回给用户的不能是 404,同时 返回给搜索引擎爬虫的 HTML 也需要包含页面的 SEO 内容!能否将这两者做一下结合呢?看下面的解构:
<html>
<body>
<section id='container'></section>
<noscript>
... ...
</noscript>
</body>
</html>
奥秘就在 noscript
标签那,对于不能执行 JS 的引擎爬虫来说,noscript
里的内容专门为其准备,而对于用户来说,这个返回的页面又能正常使用。
当然,对于用户来说,noscript
显得冗余了,所以我们还是可以在服务器上针对用户与爬虫准备两套方案!
总而言之,使用 history API 和 popState 事件的最大原因就是我们想去掉 url 里的 #
和 #!
,让我们的 url 变得更加亲近、自然!而相比思路 1 麻烦了的一点就是我们需要使用 popState
事件来手动恢复前后的状态,好在这并不是困难的一件事,一般的框架(Vue、React、pjax 等)都有着非常方便地自动管理解决方案。
这两种思路各有好处,到底采用哪一个你需要做出决断,决断的做出需要考虑对用户的友好、实现的难易程度、是否需要 SEO、服务器端解决方案……
总之,单页面应用的前景是光明的,在现阶段,Single Page Web App 是唯一能在移动端叫板原生 App 的角色。