分类
http

解决跨域问题笔记

跨域问题常遇常新,每次都觉得再也不会有问题了,结果过几天又会掉进新坑。

因为种种原因,最近一个项目需要跨域请求 API。然后就随手设置了一下,结果 GET 没问题,POST 就不行,很明显是撞到跨域墙上了。

最后发现原因:

  1. 我们启用了 basic auth 验证用户身份
  2. OPTIONS 也会被要求验证
  3. 预请求失败,后面的正式请求就不会发出

趁着还没忘,总结一下跨域的处理过程:

  1. 首先,熟读《MDN HTTP访问控制(CORS)》
  2. 跨域时,复杂请求(除 HEADGETPOST) API 需要返回 CORS 头
  3. 发起复杂请求前,会发送一个 preflight 请求,也就是 OPTIONS,很多坑都在这个请求上
  4. OPTIONS 是浏览器自动发送的,不受我们控制,在开发者工具的 Network 面板里也看不到。我们经常需要模拟它,检查返回是否符合预期。请求头在后面。
  5. OPTIONS 无法处理 Basic auth,如果开了的话,要做特殊处理
  6. 需要返回 Access-Control-Allow-Origin 允许跨域的域名,简单点可以写 *,但如果要上传 cookie,则必须写明域名,且只能是 一个 域名
  7. 所以如果有多个域名要跨域访问 API,需要在服务器端判断来源,并返回不同的域名
  8. 如果要上传 cookie,需要在请求时声明 withCredential: true
  9. 服务器还要返回许可的方法,即 Access-Control-Allow-Methods: GET, DELETE, PATCH 等,让浏览器判断
  10. 如果前面都通过了,浏览器才会发送正式请求。如果正式请求失败,则看不到任何返回。

测试 OPTIONS 请求头

OPTIONS /resource/foo 
Access-Control-Request-Method: DELETE 
Access-Control-Request-Headers: origin, x-requested-with
Origin: https://foo.bar.org
分类
js

使用Backbone的正确姿势

2012年来到点乐之后,我开始投身Web应用开发。当时我选择Backbone作为主力框架,刚开始做难免会带着各种旧习惯,只有一边使用一边摸索。最近我终于搞明白使用Backbone的正确姿势,记叙于此,希望能让后来者少走些弯路。

使用框架时,我的原则是:既然使用了这个框架,就应该按照这个框架的思路解决问题,一定要把它的功能都用上,要按照它的方式组织代码,这样才对得起学习使用的成本。所以下面的“正确姿势”,自然也是奔着全面使用Backbone的内建功能、尽量符合Backbone的设计思路,这样的目的总结的。

本文假定读者熟悉JavaScript,对Backbone有一定程度的了解。

View

我比较同意《JavaScript设计模式》中的观点,Backbone的设计很难讲是更接近MVP还是更接近MVC,而是在Web前端这个大环境下,基于其技术特点,吸取各种设计模式的优点做出最合适的的实现。Web应用中,视图的实现自然应该交给HTML和CSS负责,而在数据变化时更新视图,以及响应用户操作的事情,就交给Backbone.View

Backbone.View提供非常直观的events帮助我们注册事件,免受使用jQuery的.on.on.on之苦。事件被委托给$el,不仅节省资源,并且做列表类应用需要操作多个子节点时,无需再绑定事件。

不过请注意,这里的事件委托仍然有赖冒泡机制,所以诸如loaderror等不冒泡的事件无法委托给$el处理(submit在早期IE下也不行,但是jQuery会代发冒泡版),只能手工侦听具体节点。另外,Backbone会把处理函数代理给View的实例,所以函数中的this指向是实例,而不再是触发事件的DOM元素。

列表的前世今生

接着,来点代码,看看最常用的UI范式——列表。Backbone中,搭配使用Backbone.CollectionBackbone.View可以方便的实现列表类组件,不过因为Backbone本身的限制很少,实现方法很多。早期我习惯于通过reset方法和reset事件来操作列表,后来慢慢体会到,接下来的做法更合适。

HTML部分:

    // 这里假设我们要做一个todo列表
    <ul>
      <script type="text/x-handlebars-template">
      <li id="{{id}}">
        <input type="checkbox" name="todo" value="{{id}}">
        {{title}} <time datetime="{{create_time}}">{{create_time}}</time>
      </li>
      </script>
    </ul>

JavaScript部分(使用Handlebars作为模板引擎):

    var ListView = Backbone.View.extend({
      fragment: '',
      events: {

      },
      initialize: function () {
        this.template = Handlebars.compile(this.$('script').remove().html().replace(/r|n|s{2,}/g, '');

        this.collection.on('add', this.collection_addHandler, this);
        this.collection.on('remove', this.collection_removeHandler, this);
        this.collection.on('sync', this.collection_syncHandler, this);
        this.collection.fetch();
      },
      collection_addHandler: function (model) {
        this.fragment += this.template(model.toJSON());
      },
      collection_removeHandler: function (model) {
        this.$('#' + model.id).remove();
      },
      collection_syncHandler: function () {
        if (this.fragment) {
          this.$el.append(this.fragment);
          this.fragment = '';
        }
      }
    });

之所以建议这么做,因为Backbone.Collection有三个特点:

  1. model的事件会由collection向外转播(相当于冒泡)
  2. 取得数据后,collection会逐个创建model,每次都会广播add事件
  3. .fetch().set()时,新数据中不曾出现的对象(以id为标识)会被移除

列表中还有一些技巧,将在后文呈现。

钦差大臣options

早先取数据时为了向服务器传递变量,或者使用特定的jQuery参数,我经常覆写.sync()方法,甚至直接使用$.ajax()。后来发现,各函数的options(除去少数几个return之外)都会传递到下一个函数,直至最后;期间每一步的参数都会合并进来向后传递。

所以我们只需要在第一次调用函数时,将需要的值放在options里即可 。比如,要保存model里的数据,API服务器和当前服务器不在同域,就可以这样:

// 关于xhrFields,可以参考jQuery文档:http://api.jquery.com/jQuery.ajax/
model.save(null, {
  xhrFields: {
    withCredentials: true
  }
});

其实,options是个很巧妙的设计。Backbone作为框架,必须给其它库和业务逻辑留出足够的空间,使用options,随便其他开发者传什么值,最后都能传回业务逻辑中。另外,当参数个数比较多的时候,使用options也有助于阅读代码。

options进阶

基于“从头传到尾”这个性质,我们还可以发明一些特殊用法。比如上一节的例子,我希望给每个元素增加一个删除按钮,点击后移除元素。重点是:以渐隐动画来表现移除动作。在Backbone中,.destroy()方法会通知服务器删除对象(.remove()方法只是从当前集合中移除model,无法满足需要),并且触发destroy事件,我们可以在这里插入动画;但立刻就又会触发remove事件,所以只是在collection_destroyHandler的时候fadeOut是不够的,还要防止collection_removeHandler在动画结束前直接移除dom。

这个时候,我们就可以利用optionsdestroy()支持参数{wait: true},可以等待服务器返回成功后才移除model。于是我们就能确保对象已经从服务器上清除后,再以视图体现;同时,只要检查options里是否包含这个属性,就可以知道当前触发collection_removeHandler的是.destroy()还是.remove(),再决定是否立刻移除节点就很容易了。(其实随便传个什么标记都可以,这里使用{wait: true}可以获得更好的体验。)

    // 一致的代码我就不写了
    events: {
      'click .delele-button': 'deleteButton_clickHandler'
    },
    initialize: function () {
      // 一致的代码
      // ....
      // 不再重写
      this.collection.on('destroy', this.collection_destroyHandler, this);
    },
    collection_destroyHandler: function (model) {
      this.$('#' + model.id).fadeOut(function () { $(this).remove(); });
    },
    collection_removeHandler: function (model, collection, options) {
      if (!options.wait) { // 没有wait
        this.$('#' + model.id).remove();
      }
    },
    deleteButton_clickHandler: function (event) {
      var id = $(event.target).closest('li').attr('id');
      this.collection.get(id).destroy({wait: true}); // 服务器返回确认才真正移除
    }

利用options能达成的效果还有很多。比如,有些时候我们想往model里放一些特殊用途的数据,只在渲染时候用,不保存到服务器上。通过研究源码我们发现,同样调用.toJSON().save()会传入含有各种参数的options,而手动调用则不会。于是我们又能根据options里的属性返回不同的数据,满足不同的需要。

这些实现本文不再一一详述,大家请自行琢磨,欢迎留言探讨。

和服务器步调一致—— sync & fetch

有些习惯延续自之前的项目,比如请求远程数据。早期我总想用$.ajax从服务器端把数据取来,然后再resetcollection或者model。直到最近开发新项目:tiger-prawn + lemon-grass(也即点乐后台V5),我开始开发RESTful的后端,配合专为RESTful设计的Backbone,贯彻“同步”思路,于是我终于醒悟,发出文章开头那句感慨——我终于明白使用Backbone的正确姿势了。

“同步”是Backbone非常重要的设计思路,也是用户体验里非常重要的一环。我们经常要面对多端的环境,尤其是开发企业级应用,多人协作办公,必须保证数据在每个终端看起来一致。我一开始很难理解为什么.fetch()回来数据后,除非指定{remove: false},否则新数据中不再存在的对象会被移出collection。这个疑问后来在开发集体协作的todo列表时得到解答。

还拿前文的todo列表做例子。想象列表面向一个工作组,组内成员均从列表当中接受工作,完成后勾上checkbox表示结案;别的地方还会有需求源源不断的塞入这个列表中。这个时候,数据同步就显得尤为重要。某甲勾掉一条任务,需要从其他人的列表中勾掉同一条任务。由于我们数据交互的主要手段仍然是Ajax,服务器无法向浏览器发出指令,只能由浏览器通过返回值进行操作,所以,将“同步”作为强制性要求,本地collection根据返回值的变化,该修改的修改,该移除的移除,就是最佳选择了。

trigger: false未必都有用

最后再说个小问题。有时候我希望改变路径,但不要刷新页面,比如创建文章/article/create/,.save()后得到文章id,跳转到/article/id。因为仍然处于编辑状态,所以不需要刷新页面。一开始我以为router.navigate(‘#/article/id’, {trigger: false});,加个参数{trigger: false}`就可以防止路由生效,后来发现不行。

出于种种原因,比如服务器没做重定向,我一直没用pushState。此种状态下,Backbone使用setInterval检查地址栏变化,所以只要地址栏有修改,就会触发相应的路由。

相关项目

下面是我做过并且在维护的一些使用了Backbone的项目。这些项目未必都做到了以上几点,所以仅供参考。

团队培训项目团队培训项目二期,其实从这两个项目中就能看出我思路的变化。

游戏宝典 手机应用,虽然项目停摆了……找时间更新移植到phonegap上。

总结

Backbone是一个轻量级,入侵程度很低的框架。可以很方便的结合各种其他库来使用,对使用者的要求也很少,易于学习。不过如果能以正确的姿势操作,又能达到事半功倍的效果。这篇文章写得很费劲,有些东西总是感觉话到嘴边写不出来,反复修改多次对最后两段仍不满意。哎,以后再说吧,先发了。

分类
jQuery php

HTML5跨域开发

HTML5中提供了跨域加载数据的方法,让我们得以从JSONP或者Flash中介等各种绕行方案中解脱出来,更加顺畅地与服务器交流。另一方面,因为PHP是最好的语言……所以在它与Node.js之间,我选择前者作为后端语言开发内容服务。

这篇文章记录使用jQuery+PHP开发跨域应用时的小心得。

身份验证

做身份验证,最简单的办法就是使用PHP的SESSION保存用户信息,于是就要用到Cookie。默认情况下,跨域Ajax请求发起时候不包含Cookie,需要我们主动将XHRwithCredentials属性设为true才行。

jQuery会把XHR封装成jqXHR,并且不暴露真正的XHR(说实话这点有点难以理解,尤其是在做上传进度条的时候)。然后它提供一个给真正XHR赋值的接口xhrField,所以写成代码就是这样事儿的:

$.ajax(url, {
  xhrField: {
    withCredentials: true
  }
}

各种HTTP头

如果不需要验证用户身份,直接在HTTP头中输出Access-Control-Allow-Origin: *即可。

我的产品需要验证,那么首先,HTTP头中必须有Access-Control-Allow-Credentials: true;此时对域的限制也严格许多,不再允许像前面那样使用*放开给任何来源,必须指明哪个具体域可以接受。

关于Access-Control-Allow-Origin的值,规范中的说明是“域名列表或null”,然则接下来的“注意”有点诡异:“实际生产中,‘列表或null’要求更严格。你可以认为它实际只允许单一域名或null,而非空格分隔的域名列表。”——既然如此你干脆写个“域名或null”不就完了……

总之对于我们而言,返回的HTTP头中还要包含Access-Control-Allow-Origin: http://域名,指定允许作为来源的协议、域名、端口,并且只能有一个(组)。因为通常来说我们开发环境和生产环境不一样,所以这里的域名最好不要写在服务器配置里;使用PHP,通过$_SERVER['HTTP_ORIGIN']取出访问来源,与白名单比对,通过后再输出相应的头,更加合适。

调试

我选择JSON作为前后端交流的格式。为了方便浏览器解析(也是HTML5的要求),我还返回了Content-type: application/json头。

使用PHP少不了使用Xdebug。出现错误时,Xdebug会返回完整的栈,有利排查。但是为了方便阅读,Xdebug还会给返回信息套上<table>结构,这时Chrome的Network工具就会把它解析成奇怪的格式,所以Content-type一定要最后和数据一起返回。

与之相反的是前文说到的Access-Control-Allow-OriginAccess-Control-Allow-Credentials,这二位必须放在最前面。不然如果出现500错,响应头不包含这两个跨域标记,Chrome就会理所当然地不显示返回内容,也就无法看到错误描述,根本无法排查。

参考资料

  1. Using CORS
  2. Cross-Origin Resource Sharing
  3. HTTP access control (CORS)
  4. jQuery.ajax()