[关闭]
@yangfch3 2017-09-21T09:55:19.000000Z 字数 4964 阅读 2857

AJAX 单页面应用的两种实现思路

JavaScript FE


现在,单页面应用已经是一种趋势,这不仅能提升用户体验,还能降低服务器资源的损耗,也是 Web App 与原生 App 一战的最大资本!

在我们还无法完全享受 fetch API 带给我们的便利时,我们的单页面开发的根基仍旧是 AJAX。当我们知道了 AJAX 这个东西后,感觉前路一片光明,但是真正用起来就会发现还有很多问题是我们需要考虑的。

本文我会首先介绍一下我所理解的前后端分离,然后我们介绍 AJAX 实现单页面应用的两种思路及其对比。

前后端分离

做为一个在学校一直和 CMS 打交道,同时还要负责数据库、服务器运维的程序员,我是深深体会过“上古时代” 的前后端耦合带来的痛苦的。所幸的是,前端、后端都是自己一个人做,也就不需要去和前端或者后端撕逼了。

上古时代的撕逼

小前:诶,后端,我的页面有代码更新了,我把新代码传给你,你帮我更新一下后端的模板。
小后:更你妹,你今天都™叫我更新十多次了,还要我更新?没得商量,100块一次,再后面的每次修改加价 20!
小前:咱两谁跟谁呀,谈钱多伤感情呀!
小后:谈感情才伤钱,去去去,自己花点时间学者写我们后端的代码,我可以教你,200 包会!
小前:滚!都赖设计,今天都改了十多次了。
小设:啊,这十多次里有七八次是产品把需求改了,能赖我吗?
小汪:改点需求怎么了,老大说了要让产品做到极致,那就得改。你们是在用代码改变世界,你想让用户边用你的东西边骂你吗?还不快利索点改!
……

AJAXNode 的出现于流行则让整个 Web 开发步入了 “大前端” 时代。网上一大片关于现在前后端分离与 “大前端” 趋势的文章,而想真的尝到这些甜头,自己实践就是必须的了,这里不说太多。

这里先明确一下,下面要讲的内容里的 AJAX 单页面应用的架构是这样(为了方便,这里明确了技术栈每一项的方向,真实的开发可以自己选择语言和数据库):

  1. 后端使用 Java + MySQL 为公司内网的服务器提供内网数据 API,供内网其他 Web 服务器调取。
  2. 前端编写 Node 服务器,模板渲染,吐出首屏,路由管理,以及提供直接面向浏览器的数据 API。
  3. 再靠前一点,使用 Apache 或 Nginx 做负载均衡,转发请求到内网的其他服务器上。
  4. 浏览器端只有在首屏是接收服务器返回的整个页面,之后全部采用 AJAX 来进行数据的更新,利用服务器端 API 返回的数据进行模板渲染,达到页面的更新。

这里有几个问题可以延伸去思考:

  1. 由服务器端进行首屏渲染的好处
  2. 这里面可能存在的数据安全问题?如何避免?

就这样,大家各司其职,前端利用 JavaScript + Node 入侵了服务器端,后端的工作变得更加专一,前端的控制力变得更加强。虽然前端的任务似乎加重了,但是整个开发的效率则是大大提升,前后端唯一需要耦合的就是数据 API 的标准规范!

今天我们主要目标是前端使用 AJAX 进行单页面开发这一环。说到 AJAX 就脱离不了数据 API,网上有着许多免费、公开的的 API 服务提供,当然也可以换一种思路:拦截 AJAX 请求,返回假数据。很幸运,后面那种思路已经有 “轮子” 帮我们做了,这里选择 Mock.js 进行 AJAX 请求的拦截与特定模板假数据的生成。

思路1:url hash + hashChange 事件

页面不刷新而带来 url 变化我们最先想到的肯定就是 url hash 了。我们使用 location.hash 可以轻松的访问与变更 hash 值。

至于 hash 值变动带来页面可能的上下闪动(页面上可能有对应 hash 值 id 的元素),我们只需要禁用锚点点击的默认事件就行。

hash 值的变动同时还会触发全局对象上的 hashChange 事件,在这个事件里我们就能做很多事情了。我们在这个事件阶段需要做的就是依照 hash 值得变动,解析 url 之后,向对应的服务器端 API 发起 AJAX 请求获得数据更新页面。

首先我们来封装一下 AJAX 请求生成器(点击链接后面链接查看源码):ajax.js

准备好首屏页面 index.html(这里简单起见,没有使用模板引擎进行模板+数据的渲染)

  1. <a class="ajax-anchor" data-href="abc" href="/abc">#abc</a>
  2. <a class="ajax-anchor" data-href="def" href="/def">#def</a>
  3. <a class="ajax-anchor" data-href="hij" href="/hij">#hij</a>
  4. <div id="contariner">
  5. 初始数据!
  6. </div>

然后利用 Mock.js 进行 AJAX 拦截,提供假数据模板:

  1. Mock.mock(/http:\/\/yangfch3\.com(\/\w+)*\?[\w^\w]*/, {
  2. "array|+1": [
  3. "AMD",
  4. "CMD",
  5. "UMD"
  6. ]
  7. });

禁用 AJAX 请求锚点的默认点击事件(用到了 ES6 的特性,在实际使用过程中请考虑兼容性)

  1. var ajaxAnchors = document.querySelectorAll('.ajax-anchor');
  2. var contariner = document.querySelector('#contariner');
  3. window.addEventListener('click', function(e) {
  4. if ([...ajaxAnchors].indexOf(e.target) > -1) {
  5. e.preventDefault();
  6. location.hash = e.target.dataset['href'];
  7. }
  8. }, false);

使用 hashChange 事件来触发请求

  1. var callback = function(responseText, status, xhr) {
  2. contariner.innerHTML = responseText;
  3. };
  4. window.addEventListener('hashchange', function(e) {
  5. var api = 'https://api.yangfch3.com?q=' + location.hash.substr(1);
  6. new Ajax(api, callback);
  7. }, false)

现在我们,点击对应的链接,页面只进行了局部的数据更新,并且我们点击浏览器后退、前进按钮可以恢复之前的页面状态!

浏览器的状态缓存机制(back-forward cache)让我们能在不做任何处理的情况下回到或前进到某一状态。

如果需要在用户每次后退进入或前进进入时页面做出相应的响应,则可以监听 pageshowpahehide 事件进行相应的处理!

pageshow 会在当前页面加载完后、点击浏览器后退/前进按钮重新进入当前页时触发(问题:调用 history 后退/前进 API 时会不会触发? -会);pagehide 在浏览器卸载页面的时候触发,而且是在 unload 事件之前触发

pageshowpagehide 事件对象 persisted 属性可以用于检测当前页是否是由 BFCache 载入。

现在我们总结一下这个方案的优点:

  1. 实现简单
  2. 符合我们的一般思路,兼容性也强
  3. 状态的回退与前进十分方便

那么缺点呢?或者说在某些情境下存在的缺点。

直说吧,这套方案在我们的页面内容需要被搜索引擎收录的时候存在缺陷。搜索引擎收录爬虫在到达某个地址后不会执行页面的 JS,收录时不会像我们的浏览器一样先发起一个 Ajax 请求生成完整内容再收录,这就对网站的 SEO(如果需要的话)带来了不便。

网上有着这个问题的探讨,例如以下文章:

  1. 用 PhantomJS 来给 AJAX 站点做 SEO 优化

  2. 单页面架构的 SEO

  3. 常见搜索引擎 UA

基本思路

  1. 后端:准备两套服务器代码,一套给 AJAX 单页面应用用的数据服务器,一套专门给搜索引擎爬虫用的 旁路渲染服务器(提供的是完整的对应页面的 HTML 代码)。

  2. 后端接入层:一般是 NgnixApache,根据请求的 UA,判断请求来自用户还是引擎爬虫,分流至上面后端的某台服务器上。

  3. 浏览器端:给爬虫用的 <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/yyyabc.com/#!xxx/yyy 这样的,同时输入网址时,还需要 shift + 数组组合输入,不方便!

例如以前 twitter 的 https://twitter.com/#!/yangfch3,引来了用户的大量抱怨。

当然,如果你的单页面应用是无需 SEO 的话(例如后台管理界面),那么事情就相对简单一些了!

下面我们开始介绍 Ajax 单页面应用的第二种实现思路,开始逃离 ##!

思路2:histroy API + popstate 事件

有没有一种方案,能够:

  1. 实现页面 url 的变化
  2. 同时不会引起页面刷新
  3. 并且无需采用 ##! 结构,页面的 url 是直观的、贴近用户平时习惯的

很幸运,我们能找到这个东西,HTML5 中 history 新 API 加上 popstate 事件能够完美地做到这一点。

history 对象里的 pushState()replaceState() 来无更新地改变页面的 url,使用 popState 事件来实现浏览器工具栏前进、后退时的状态管理。

流程是这样的:

  1. 页面第一次加载,可以使用 replaceState() 来初始化 history.state 以及处理一些相关的页面初始化事务。
  2. 用户点击链接,触发点击事件
  3. 点击事件的处理函数中,禁用链接的默认跳转,使用 pushState() 来更新页面的 url,同时根据新 url 的对应 API 发起 Ajax 请求获得数据,更新页面内容,同时更新 history.state 对象
  4. 用户点击浏览器的前进、后退按钮,触发 popState 事件,我们在 popstate 事件的处理中实现前、后状态的恢复

相关实现代码,可以查看 demo 的源码。

这样,我们就实现了对用户的友好,接下来就是另外一件事了:解决搜索引擎的收录问题(SEO)

Discourse 做出了很好的探索:因为不使用井号结构,每个URL都是一个不同的请求。所以,要求服务器端对所有这些请求,返回给用户的不能是 404,同时 返回给搜索引擎爬虫的 HTML 也需要包含页面的 SEO 内容!能否将这两者做一下结合呢?看下面的解构:

  1. <html>
  2.   <body>
  3.     <section id='container'></section>
  4.     <noscript>
  5.       ... ...
  6.     </noscript>
  7.   </body>
  8. </html>

奥秘就在 noscript 标签那,对于不能执行 JS 的引擎爬虫来说,noscript 里的内容专门为其准备,而对于用户来说,这个返回的页面又能正常使用。

当然,对于用户来说,noscript 显得冗余了,所以我们还是可以在服务器上针对用户与爬虫准备两套方案

总而言之,使用 history API 和 popState 事件的最大原因就是我们想去掉 url 里的 ##!,让我们的 url 变得更加亲近、自然!而相比思路 1 麻烦了的一点就是我们需要使用 popState 事件来手动恢复前后的状态,好在这并不是困难的一件事,一般的框架(Vue、React、pjax 等)都有着非常方便地自动管理解决方案。

小结

这两种思路各有好处,到底采用哪一个你需要做出决断,决断的做出需要考虑对用户的友好、实现的难易程度、是否需要 SEO、服务器端解决方案……

总之,单页面应用的前景是光明的,在现阶段,Single Page Web App 是唯一能在移动端叫板原生 App 的角色。

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注