Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

原来你是这样的jsonp(原理与具体实现细节) #4

Open
qianlongo opened this issue Jun 11, 2017 · 8 comments
Open

原来你是这样的jsonp(原理与具体实现细节) #4

qianlongo opened this issue Jun 11, 2017 · 8 comments

Comments

@qianlongo
Copy link
Owner

qianlongo commented Jun 11, 2017

前言

原文地址

仓库地址

jsonp(JSON with padding)你一定不会陌生,前端向后端拿数据的方式之一,也是处理跨域请求的得利助手。

我们早已习惯,早已熟练了jQ或者zepto的ajax调用方式。但是有可能还不太它内部具体是如何实现一个jsonp的,从请求的发出,到指定的成功(success)或失败(error)回调函数的执行。

  1. 这中间前端需要做什么?
  2. 后端又需要做些什么来支持?
  3. 超时场景又该如何处理?
  4. 整个生命周期会有多个钩子可以被触发,而我们可以监听哪些钩子来得知请求的状况?

让我们从zepto.js的源码出发,一步步揭开它的面纱。

(该篇文章重点是想说jsonp实现过程,如果你想了解跨域相关的更多的知识,可以谷歌,度娘一把)

絮叨一下jsonp的基本原理

jsonp是服务器与客户端跨源通信的常用方法之一,具有简单易用,浏览器兼容性好等特点。

基本思想是啥呢

  1. 客户端利用script标签可以跨域请求资源的性质,向网页中动态插入script标签,来向服务端请求数据。

  2. 服务端会解析请求的url,至少拿到一个回调函数(比如callback=myCallback)参数,之后将数据放入其中返回给客户端。

  3. 当然jsonp不同于平常的ajax请求,它仅仅支持get类型的方式

如何使用

这里简单的介绍一下zepto.js是如果使用jsonp形式请求数据的,然后从使用的角度出发一步步分析源码实现。

使用

$.ajax({
  url: 'http://www.abc.com/api/xxx', // 请求的地址
  type: 'get', // 当然参数可以省略
  data: { // 传给服务端的数据,被加载url?的后面
    name: 'qianlongo',
    sex: 'boy'
  },
  dataType: 'jsonp', // 预期服务器返回的数据类型
  jsonpCallback: 'globalCallback', // 全局JSONP回调函数的 字符串(或返回的一个函数)名
  timeout: 100, // 以毫秒为单位的请求超时时间, 0 表示不超时。
  success: function (data) { // 请求成功之后调用
    console.log('successCallback')
    console.log(data)
  },
  error: function (err) { // 请求出错时调用。 (超时,解析错误,或者状态码不在HTTP 2xx)
    console.log('errorCallback')
    console.log(err)
  },
  complete: function (data) { // 请求完成时调用,无论请求失败或成功。
    console.log('compelete')
    console.log(data)
  }
})

function globalCallback (data) {
  console.log('globalCallback')
  console.log(data)
}   

在zepto中一个常见的jsonp请求配置就是这样了,大家都很熟悉了。但是不知道大家有没有发现.

  1. 如果设置了timeout超时了,并且没有设置jsonpCallback字段,那么控制台几乎都会出现一处报错,如下图

  1. 同样还是发生在timeout,此时如果请求超时了,并且设置了jsonpCallback字段(注意这个时候是设置了),但是如果请求在超时之后完成了,你的jsonpCallback还是会被执行。照理说这个函数应该是请求在超时时间内完成才会被执行啊!为毛这个时候超时了,还是会被执行啊!!!

不急等我们一步步分析完就会知道这个答案了。

先看一下完整的代码

因为zepto中完成jsonp请求的处理基本都在$.ajaxJSONP完成,我们直接从该函数出发开始分析。先整体看看这个函数,有一个大概的印象,已经加了大部分注释。或者可以点击这里查看

 $.ajaxJSONP = function (options, deferred) {
  // 直接调ajaxJSONP没有传入type,去走$.ajax
  if (!('type' in options)) return $.ajax(options)
  // 获取callback函数名,此时未指定为undefined
  var _callbackName = options.jsonpCallback,
    // jsonpCallback可以是一个函数或者一个字符串
    // 是函数时,执行该函数拿到其返回值作为callback函数
    // 为字符串时直接赋值
    // 没有传入jsonpCallback,那么使用类似'Zepto3726472347'作为函数名
    callbackName = ($.isFunction(_callbackName) ?
      _callbackName() : _callbackName) || ('Zepto' + (jsonpID++)),
    // 创建一个script标签用来发送请求  
    script = document.createElement('script'),
    // 先读取全局的callbackName函数,因为后面会对该函数重写,所以需要先保存一份
    originalCallback = window[callbackName],
    responseData,
    // 中止请求,触发script元素上的error事件, 后面带的参数是回调函数接收的参数
    abort = function (errorType) {
      $(script).triggerHandler('error', errorType || 'abort')
    },
    xhr = { abort: abort }, abortTimeout

  if (deferred) deferred.promise(xhr)
  // 给script元素添加load和error事件
  $(script).on('load error', function (e, errorType) {
    // 清除超时定时器
    clearTimeout(abortTimeout)
    // 移除添加的元素(注意这里还off了,不然超时这种情况,请求回来了,还是会走回调)
    $(script).off().remove()
    // 请求出错或后端没有给callback中塞入数据,将触发error
    if (e.type == 'error' || !responseData) {
      ajaxError(null, errorType || 'error', xhr, options, deferred)
    } else {
      // 请求成功,调用成功回调,请塞入数据responseData[0]
      ajaxSuccess(responseData[0], xhr, options, deferred)
    }
    // 将originalCallback重新赋值回去
    window[callbackName] = originalCallback
    // 并且判断originalCallback是不是个函数,如果是函数,便执行
    if (responseData && $.isFunction(originalCallback))
      originalCallback(responseData[0])
    // 清空闭包,释放空间
    originalCallback = responseData = undefined
  })

  if (ajaxBeforeSend(xhr, options) === false) {
    abort('abort')
    return xhr
  }
  // 重写全局上的callbackName
  window[callbackName] = function () {
    responseData = arguments
  }
  // 将回调函数名追加到?后面
  script.src = options.url.replace(/\?(.+)=\?/, '?$1=' + callbackName)
  // 添加script元素
  document.head.appendChild(script)
  // 超时处理函数
  if (options.timeout > 0) abortTimeout = setTimeout(function () {
    abort('timeout')
  }, options.timeout)

  return xhr
}

参数的基本处理

在执行原理的第一步时,zepto会先处理一下我们传入的参数。

我们先来看看针对上面的例子我们发送请求的url最终会变成什么样子,而参数处理正是为了得到这条url

传了jsonpCallback时的url

http://www.abc.com/api/xxx?name=qianlongo&sex=boy&_=1497193375213&callback=globalCallback

没有传jsonpCallback时的url

http://www.abc.com/api/xxx?name=qianlongo&sex=boy&_=1497193562726&callback=Zepto1497193562723

相信你已经看出来这两条url有什么不同之处了。

_后面跟的时间戳不一样

callback后面跟的回调函数名字不一样

也就是说如果你指定了成功的回调函数就用你的,没指定他自己生成一个。

上参数处理代码

var jsonpID = +new Date()

var _callbackName = options.jsonpCallback,
callbackName = ($.isFunction(_callbackName) ?
  _callbackName() : _callbackName) || ('Zepto' + (jsonpID++))

对于回调函数名的处理其实挺简单的,根据你是否在参数中传了jsonpCallback,传了是个函数就用函数的返回值,不是函数就直接用。
否则的话,就生成类似Zepto1497193562723的函数名。

继续看

// 创建一个script标签用来发送请求 
script = document.createElement('script'),
// 先读取全局的callbackName函数,因为后面会对该函数重写,所以需要先保存一份
originalCallback = window[callbackName],
// 请求完成后拿到的数据
responseData,
// 中止请求,触发script元素上的error事件, 后面带的参数是回调函数接收的参数
abort = function (errorType) {
  $(script).triggerHandler('error', errorType || 'abort')
},
xhr = { abort: abort }, abortTimeout
// 对.then或者.catch形式调用的支持,本文暂时不涉及这方面的解析
if (deferred) deferred.promise(xhr)

好啦,看到这里我们主要要关注的是

  1. originalCallback = window[callbackName]

  2. abort函数

对于1为什么要把全局的callbackName函数先保存一份呢?这里涉及到一个问题。

请求回来的时候到底是不是直接执行的你传入的jsonpCallback函数?

解决这个问题请看

// 重写全局上的callbackName
window[callbackName] = function () {
  responseData = arguments
}

zepto中把全局的callbackName函数给重写掉了,,导致后端返回数据时执行该函数,就干了一件事,就是把数据赋值给了responseData这个变量。

那说好的真正的callbackName函数呢? 如果我传了jsonpCallback,我是会在里面做一些业务逻辑的啊,你都把我给重写了,我的逻辑怎么办?先留个疑问在这里

对于关注点2abort函数,这个函数的功能,就是手动触发添加在创建好的script元素身上的error事件的回调函数。后面的超时处理timeout以及请求出错都是利用的该函数。

超时处理

在看监听script元素on error事件回调逻辑前,我们直接看最后一点东西

// 将回调函数名追加到?后面
script.src = options.url.replace(/\?(.+)=\?/, '?$1=' + callbackName)
// 添加script元素
document.head.appendChild(script)
// 超时处理函数
if (options.timeout > 0) abortTimeout = setTimeout(function () {
  abort('timeout')
}, options.timeout)

代理做了简单的注释,这里除了将script元素插入网页还定义了一个超时处理函数,判断条件是传入的参数timeout是否大于0,所以当你传小于0或者负数啥的进去,是不会当做超时处理的。超时后其实就是触发了script元素的error事件,并传了参数timeout

真正的回调逻辑处理

接下来就是本文的重点了,zepto通过监听script元素的load事件来监听请求是否完成,以及给script添加了error事件,方便请求出错和超时处理。而用户需要的成功和失败的处理也是在这里面完成

clearTimeout(abortTimeout)
$(script).off().remove()
if (e.type == 'error' || !responseData) {
  ajaxError(null, errorType || 'error', xhr, options, deferred)
} else {
  ajaxSuccess(responseData[0], xhr, options, deferred)
}
window[callbackName] = originalCallback
if (responseData && $.isFunction(originalCallback))
  originalCallback(responseData[0])
originalCallback = responseData = undefined

script元素真正的事件处理程序代码也不多,开头有这两句话

// 清楚超时定时器
clearTimeout(abortTimeout)
// 从网页中移除创建的script元素以及将挂在它上面的所有事件都移除
$(script).off().remove()

起什么作用呢?

第一句自然是针对超时处理,如果请求在指定超时时间之前完成,自然是要把他清除一下,不然指定的时间到了,超时的回调还是会执行,这是不对的。

第二句话,把创建的script元素从网页中给删除掉,绑定的事件('load error')也全部移除,干嘛要把事件都给移除呢?你想想,一个请求已经发出去了,我们还能让他半途停止吗?该是不能吧,但是我们能够阻止请求回来之后要做的事情呀!而这个回调不就是请求回来之后要做的事情么。

请求成功或失败的处理

if (e.type == 'error' || !responseData) {
  ajaxError(null, errorType || 'error', xhr, options, deferred)
} else {
  ajaxSuccess(responseData[0], xhr, options, deferred)
}

那么再接下来,就是请求的成功或失败的处理了。失败的条件就是触发了error事件(不管是超时还是解析错误,又或者状态码不在HTTP 2xx),甚至如果后端没有正确给到数据responseData也是错误。

再回顾一下responseData是怎么来的

// 重写全局上的callbackName
window[callbackName] = function () {
  responseData = arguments
}

ajaxErro函数究竟做了些啥事呢?

ajaxError

// type: "timeout", "error", "abort", "parsererror"
function ajaxError(error, type, xhr, settings, deferred) {
  var context = settings.context
  // 执行用户传进去的error函数,注意这里的context决定了error函数中的this执行
  settings.error.call(context, xhr, type, error)
  if (deferred) deferred.rejectWith(context, [xhr, type, error])
  // 触发全局的钩子ajaxError
  triggerGlobal(settings, context, 'ajaxError', [xhr, settings, error || type])
  // 调用ajaxComplete函数
  ajaxComplete(type, xhr, settings)
}

可以看到他调用了我们穿进去的error函数,并且触发了全局的ajaxError钩子,所以我们其实可以在document上监听一个钩子

$(document).on('ajaxError', function (e) {
  console.log('ajaxError')
  console.log(e)
})

这个时候便可以拿到请求出错的信息了

ajaxComplete

// status: "success", "notmodified", "error", "timeout", "abort", "parsererror"
function ajaxComplete(status, xhr, settings) {
  var context = settings.context
  // 调用传进来的complete函数
  settings.complete.call(context, xhr, status)
  // 触发全局的ajaxComplete钩子
  triggerGlobal(settings, context, 'ajaxComplete', [xhr, settings])
  // 请求结束
  ajaxStop(settings)
}

ajaxStop

function ajaxStop(settings) {
  if (settings.global && !(--$.active)) triggerGlobal(settings, null, 'ajaxStop')
}

同理我们可以监听ajaxCompleteajaxStop钩子

$(document).on('ajaxComplete ajaxStop', function (e) {
  console.log('ajaxComplete')
  console.log(e)
})

处理完失败的情况那么接下来就是成功的处理了,主要调用了ajaxSuccess函数

ajaxSuccess

function ajaxSuccess(data, xhr, settings, deferred) {
  var context = settings.context, status = 'success'
  // 调用传进来的成功的回调函数
  settings.success.call(context, data, status, xhr)
  if (deferred) deferred.resolveWith(context, [data, status, xhr])
  // 触发全局的ajaxSuccess
  triggerGlobal(settings, context, 'ajaxSuccess', [xhr, settings, data])
  // 执行请求完成的回调,成功和失败都执行了该回调
  ajaxComplete(status, xhr, settings)
}

原来我们平时传入的success函数是在这里被执行的。但是有一个疑问啊!,我们知道我们是可以不传入success函数的,当我们指定jsonpCallback的时,请求成功同样会走jsonpCallback函数,但是好像ajaxSuccess没有执行这个函数,具体在处理的呢?

继续往下看

// 重写全局上的callbackName
window[callbackName] = function () {
  responseData = arguments
}


// 将originalCallback重新赋值回去
window[callbackName] = originalCallback
// 并且判断originalCallback是不是个函数,如果是函数,便执行
if (responseData && $.isFunction(originalCallback))
  originalCallback(responseData[0])

为了彻底搞清楚zepto把我们指定的回调函数重写的原因,我再次加了重写的代码在这里。可以看出,重写的目的,就是为了拿到后端返回的数据,而拿到数据之后便方便我们在其他地方灵活的处理了,当然指定的回调函数还是要重新赋值回去(这也是开头要保留一份该函数的本质原因),如果是个函数,就将数据,塞进去执行。

分析到这里我相信你已经几乎明白了jsonp实现的基本原理,文章顶部说的几个问题,我们也在这个过程中解答了。

  1. 这中间前端需要做什么?
  2. 后端又需要做些什么来支持?(接下来以例子说明)
  3. 超时场景又该如何处理?
  4. 整个生命周期会有多个钩子可以被触发,而我们可以监听哪些钩子来得知请求的状况?

砰砰砰!!!,亲们还记得开头的时候留了这两个问题吗?

在zepto中一个常见的jsonp请求配置就是这样了,大家都很熟悉了。但是不知道大家有没有发现.

  1. 如果设置了timeout超时了,并且没有设置jsonpCallback字段,那么控制台几乎都会出现一处报错,如下图

  1. 同样还是发生在timeout,此时如果请求超时了,并且设置了jsonpCallback字段(注意这个时候是设置了),但是如果请求在超时之后完成了,你的jsonpCallback还是会被执行。照理说这个函数应该是请求在超时时间内完成才会被执行啊!为毛这个时候超时了,还是会被执行啊!!!

问题1:为什么会报错呢?

对于没有指定jsonpCallback

此时我们给后端的回调函数名是类似Zepto1497193562723

window[callbackName] = originalCallback

超时的时候同样会走load error的回调,当这句话执行的时候,Zepto1497193562723被设置成了undefined,当然后端返回数据的时候去执行

Zepto1497193562723({xxx: 'yyy'})

自然就报错了。

问题2呢? 其实同样还是上面那句话,只不过此时我们指定了jsonpCallback,超时的时候虽然取消了script元素的的load error事件,意味着在超时之后请求即便回来了,也不会走到对应的回调函数中去。但是别忘记,超时我们手动触发了script元素的error事件

$(script).triggerHandler('error', errorType || 'abort')

原本被重写的callback函数也会被重新赋值回去,此刻,即便script元素的load error回调不会被执行,但我们指定的jsonpCallback还是会被执行的。这也就解了问题2.

用koa做服务端,zepto发jsonp请求

最后我们再用koa,模拟服务端的api,用zepto来请求他。

如果你对源码感兴趣可以点击这里查看koa-todo-list

找到根目录的testJsonp.js文件即是服务端主要代码

前端代码

html

<button>请求后端jsonp数据</button>

js

$('button').on('click', () => {
  $.ajax({
    type: 'get',
    url: '/showData',
    data: {
      name: 'qianlongo',
      sex: 'boy'
    },
    dataType: "jsonp",
    success: function (res) {
      console.log('success')
      console.log(res)
      $('<pre>').text(JSON.stringify(res)).appendTo('body')
    },
    error: function (res) {
      console.log('error')
      console.log(res)
    }
  })
})

服务端主要代码

var koa = require('koa');
var route = require('koa-route');
var path = require('path');
var parse = require('co-body');
var render = require('./app/lib/render.js');
var app = koa();

app.use(route.get('/showJsonpPage', showJsonpPage))
app.use(route.get('/showData', showData))

function * showJsonpPage () {
  var sHtml = yield render('jsonp')
  this.body = sHtml
}

function * showData (next) {
  let {callback, name, sex, randomNum} = this.query
  
  this.type = 'text/javascript'
  let callbackData = {
    status: 0,
    message: 'ok',
    data: {
      name,
      sex,
      randomNum
    }
  }

  this.body = `${callback}(${JSON.stringify(callbackData)})`
  console.log(this.query)
}

app.listen(3000);
console.log('listening port 3000');

运行截图

结尾

希望把jsonp的实现原理说清楚了,欢迎大家拍砖。

如果对你有一点点帮助,点击这里,加一个小星星好不好呀

如果对你有一点点帮助,点击这里,加一个小星星好不好呀

如果对你有一点点帮助,点击这里,加一个小星星好不好呀

@ployer900
Copy link

var script = document.createElement('script');
script.src = 'http://example.com/index.do?city=&area=&_=1504668099525&callback=jsonp1';
script.onload = function() {
console.log('load事件触发');
}
window['jsonp1'] = function() {
console.log('全局函数执行');
}
document.head.appendChild(script);

这里console.log输出顺序是: 全局函数执行 ---> load事件触发,这个顺序如何保证?看zepto的源码这个顺序是对的,也是能保证的,里面的原理是什么呢?你知道吗?

@qianlongo
Copy link
Owner Author

@ployer900 不好意思回复晚了,其实Zepto中也没有做什么处理,因为script元素的load事件本身表示的是其指向的资源已完成加载时才触发,所以顺序是按照你说的这种来的。

@liumt1993
Copy link

老哥,帮到我了,谢谢。

@qianlongo
Copy link
Owner Author

qianlongo commented Apr 21, 2018

@liumt1993 很开心能对你有帮助,嘿嘿。

@bmxklYzj
Copy link

老哥,看到你的文章,你的md中有不少乱码字符?,我之前遇到过,是用vscode开着preview时遇到的,参考链接。强迫症表示不能忍
https://www.zhihu.com/question/61638859/answer/361418428

@qianlongo
Copy link
Owner Author

qianlongo commented Apr 28, 2018

@bmxklYzj 哈哈,感谢。已经去掉了,舒服多了。哈哈

@helongquan
Copy link

跨域之后有数据返回,但是老是进入到error里面,怎么办?

@helongquan
Copy link

使用jsonp跨域请求后可以获得数据,但是进入error方法,返回parseerror,检查了服务端日志,返回的是string也可以自己设置成数组,但是前端从控制台看有数据返回,但是不进入success里面而直接进入到error里面

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

5 participants