/* global window, document, location, Event, setTimeout */
|
/*
|
## MockXMLHttpRequest
|
|
期望的功能:
|
1. 完整地覆盖原生 XHR 的行为
|
2. 完整地模拟原生 XHR 的行为
|
3. 在发起请求时,自动检测是否需要拦截
|
4. 如果不必拦截,则执行原生 XHR 的行为
|
5. 如果需要拦截,则执行虚拟 XHR 的行为
|
6. 兼容 XMLHttpRequest 和 ActiveXObject
|
new window.XMLHttpRequest()
|
new window.ActiveXObject("Microsoft.XMLHTTP")
|
|
关键方法的逻辑:
|
* new 此时尚无法确定是否需要拦截,所以创建原生 XHR 对象是必须的。
|
* open 此时可以取到 URL,可以决定是否进行拦截。
|
* send 此时已经确定了请求方式。
|
|
规范:
|
http://xhr.spec.whatwg.org/
|
http://www.w3.org/TR/XMLHttpRequest2/
|
|
参考实现:
|
https://github.com/philikon/MockHttpRequest/blob/master/lib/mock.js
|
https://github.com/trek/FakeXMLHttpRequest/blob/master/fake_xml_http_request.js
|
https://github.com/ilinsky/xmlhttprequest/blob/master/XMLHttpRequest.js
|
https://github.com/firebug/firebug-lite/blob/master/content/lite/xhr.js
|
https://github.com/thx/RAP/blob/master/lab/rap.plugin.xinglie.js
|
|
**需不需要全面重写 XMLHttpRequest?**
|
http://xhr.spec.whatwg.org/#interface-xmlhttprequest
|
关键属性 readyState、status、statusText、response、responseText、responseXML 是 readonly,所以,试图通过修改这些状态,来模拟响应是不可行的。
|
因此,唯一的办法是模拟整个 XMLHttpRequest,就像 jQuery 对事件模型的封装。
|
|
// Event handlers
|
onloadstart loadstart
|
onprogress progress
|
onabort abort
|
onerror error
|
onload load
|
ontimeout timeout
|
onloadend loadend
|
onreadystatechange readystatechange
|
*/
|
|
var Util = require('../util')
|
|
// 备份原生 XMLHttpRequest
|
window._XMLHttpRequest = window.XMLHttpRequest
|
window._ActiveXObject = window.ActiveXObject
|
|
/*
|
PhantomJS
|
TypeError: '[object EventConstructor]' is not a constructor (evaluating 'new Event("readystatechange")')
|
|
https://github.com/bluerail/twitter-bootstrap-rails-confirm/issues/18
|
https://github.com/ariya/phantomjs/issues/11289
|
*/
|
try {
|
new window.Event('custom')
|
} catch (exception) {
|
window.Event = function(type, bubbles, cancelable, detail) {
|
var event = document.createEvent('CustomEvent') // MUST be 'CustomEvent'
|
event.initCustomEvent(type, bubbles, cancelable, detail)
|
return event
|
}
|
}
|
|
var XHR_STATES = {
|
// The object has been constructed.
|
UNSENT: 0,
|
// The open() method has been successfully invoked.
|
OPENED: 1,
|
// All redirects (if any) have been followed and all HTTP headers of the response have been received.
|
HEADERS_RECEIVED: 2,
|
// The response's body is being received.
|
LOADING: 3,
|
// The data transfer has been completed or something went wrong during the transfer (e.g. infinite redirects).
|
DONE: 4
|
}
|
|
var XHR_EVENTS = 'readystatechange loadstart progress abort error load timeout loadend'.split(' ')
|
var XHR_REQUEST_PROPERTIES = 'timeout withCredentials'.split(' ')
|
var XHR_RESPONSE_PROPERTIES = 'readyState responseURL status statusText responseType response responseText responseXML'.split(' ')
|
|
// https://github.com/trek/FakeXMLHttpRequest/blob/master/fake_xml_http_request.js#L32
|
var HTTP_STATUS_CODES = {
|
100: "Continue",
|
101: "Switching Protocols",
|
200: "OK",
|
201: "Created",
|
202: "Accepted",
|
203: "Non-Authoritative Information",
|
204: "No Content",
|
205: "Reset Content",
|
206: "Partial Content",
|
300: "Multiple Choice",
|
301: "Moved Permanently",
|
302: "Found",
|
303: "See Other",
|
304: "Not Modified",
|
305: "Use Proxy",
|
307: "Temporary Redirect",
|
400: "Bad Request",
|
401: "Unauthorized",
|
402: "Payment Required",
|
403: "Forbidden",
|
404: "Not Found",
|
405: "Method Not Allowed",
|
406: "Not Acceptable",
|
407: "Proxy Authentication Required",
|
408: "Request Timeout",
|
409: "Conflict",
|
410: "Gone",
|
411: "Length Required",
|
412: "Precondition Failed",
|
413: "Request Entity Too Large",
|
414: "Request-URI Too Long",
|
415: "Unsupported Media Type",
|
416: "Requested Range Not Satisfiable",
|
417: "Expectation Failed",
|
422: "Unprocessable Entity",
|
500: "Internal Server Error",
|
501: "Not Implemented",
|
502: "Bad Gateway",
|
503: "Service Unavailable",
|
504: "Gateway Timeout",
|
505: "HTTP Version Not Supported"
|
}
|
|
/*
|
MockXMLHttpRequest
|
*/
|
|
function MockXMLHttpRequest() {
|
// 初始化 custom 对象,用于存储自定义属性
|
this.custom = {
|
events: {},
|
requestHeaders: {},
|
responseHeaders: {}
|
}
|
}
|
|
MockXMLHttpRequest._settings = {
|
timeout: '10-100',
|
/*
|
timeout: 50,
|
timeout: '10-100',
|
*/
|
}
|
|
MockXMLHttpRequest.setup = function(settings) {
|
Util.extend(MockXMLHttpRequest._settings, settings)
|
return MockXMLHttpRequest._settings
|
}
|
|
Util.extend(MockXMLHttpRequest, XHR_STATES)
|
Util.extend(MockXMLHttpRequest.prototype, XHR_STATES)
|
|
// 标记当前对象为 MockXMLHttpRequest
|
MockXMLHttpRequest.prototype.mock = true
|
|
// 是否拦截 Ajax 请求
|
MockXMLHttpRequest.prototype.match = false
|
|
// 初始化 Request 相关的属性和方法
|
Util.extend(MockXMLHttpRequest.prototype, {
|
// https://xhr.spec.whatwg.org/#the-open()-method
|
// Sets the request method, request URL, and synchronous flag.
|
open: function(method, url, async, username, password) {
|
var that = this
|
|
Util.extend(this.custom, {
|
method: method,
|
url: url,
|
async: typeof async === 'boolean' ? async : true,
|
username: username,
|
password: password,
|
options: {
|
url: url,
|
type: method
|
}
|
})
|
|
this.custom.timeout = function(timeout) {
|
if (typeof timeout === 'number') return timeout
|
if (typeof timeout === 'string' && !~timeout.indexOf('-')) return parseInt(timeout, 10)
|
if (typeof timeout === 'string' && ~timeout.indexOf('-')) {
|
var tmp = timeout.split('-')
|
var min = parseInt(tmp[0], 10)
|
var max = parseInt(tmp[1], 10)
|
return Math.round(Math.random() * (max - min)) + min
|
}
|
}(MockXMLHttpRequest._settings.timeout)
|
|
// 查找与请求参数匹配的数据模板
|
var item = find(this.custom.options)
|
|
function handle(event) {
|
// 同步属性 NativeXMLHttpRequest => MockXMLHttpRequest
|
for (var i = 0; i < XHR_RESPONSE_PROPERTIES.length; i++) {
|
try {
|
that[XHR_RESPONSE_PROPERTIES[i]] = xhr[XHR_RESPONSE_PROPERTIES[i]]
|
} catch (e) {}
|
}
|
// 触发 MockXMLHttpRequest 上的同名事件
|
that.dispatchEvent(new Event(event.type /*, false, false, that*/ ))
|
}
|
|
// 如果未找到匹配的数据模板,则采用原生 XHR 发送请求。
|
if (!item) {
|
// 创建原生 XHR 对象,调用原生 open(),监听所有原生事件
|
var xhr = createNativeXMLHttpRequest()
|
this.custom.xhr = xhr
|
|
// 初始化所有事件,用于监听原生 XHR 对象的事件
|
for (var i = 0; i < XHR_EVENTS.length; i++) {
|
xhr.addEventListener(XHR_EVENTS[i], handle)
|
}
|
|
// xhr.open()
|
if (username) xhr.open(method, url, async, username, password)
|
else xhr.open(method, url, async)
|
|
// 同步属性 MockXMLHttpRequest => NativeXMLHttpRequest
|
for (var j = 0; j < XHR_REQUEST_PROPERTIES.length; j++) {
|
try {
|
xhr[XHR_REQUEST_PROPERTIES[j]] = that[XHR_REQUEST_PROPERTIES[j]]
|
} catch (e) {}
|
}
|
|
return
|
}
|
|
// 找到了匹配的数据模板,开始拦截 XHR 请求
|
this.match = true
|
this.custom.template = item
|
this.readyState = MockXMLHttpRequest.OPENED
|
this.dispatchEvent(new Event('readystatechange' /*, false, false, this*/ ))
|
},
|
// https://xhr.spec.whatwg.org/#the-setrequestheader()-method
|
// Combines a header in author request headers.
|
setRequestHeader: function(name, value) {
|
// 原生 XHR
|
if (!this.match) {
|
this.custom.xhr.setRequestHeader(name, value)
|
return
|
}
|
|
// 拦截 XHR
|
var requestHeaders = this.custom.requestHeaders
|
if (requestHeaders[name]) requestHeaders[name] += ',' + value
|
else requestHeaders[name] = value
|
},
|
timeout: 0,
|
withCredentials: false,
|
upload: {},
|
// https://xhr.spec.whatwg.org/#the-send()-method
|
// Initiates the request.
|
send: function send(data) {
|
var that = this
|
this.custom.options.body = data
|
|
// 原生 XHR
|
if (!this.match) {
|
this.custom.xhr.send(data)
|
return
|
}
|
|
// 拦截 XHR
|
|
// X-Requested-With header
|
this.setRequestHeader('X-Requested-With', 'MockXMLHttpRequest')
|
|
// loadstart The fetch initiates.
|
this.dispatchEvent(new Event('loadstart' /*, false, false, this*/ ))
|
|
if (this.custom.async) setTimeout(done, this.custom.timeout) // 异步
|
else done() // 同步
|
|
function done() {
|
that.readyState = MockXMLHttpRequest.HEADERS_RECEIVED
|
that.dispatchEvent(new Event('readystatechange' /*, false, false, that*/ ))
|
that.readyState = MockXMLHttpRequest.LOADING
|
that.dispatchEvent(new Event('readystatechange' /*, false, false, that*/ ))
|
|
that.status = 200
|
that.statusText = HTTP_STATUS_CODES[200]
|
|
// fix #92 #93 by @qddegtya
|
that.response = that.responseText = JSON.stringify(
|
convert(that.custom.template, that.custom.options),
|
null, 4
|
)
|
|
that.readyState = MockXMLHttpRequest.DONE
|
that.dispatchEvent(new Event('readystatechange' /*, false, false, that*/ ))
|
that.dispatchEvent(new Event('load' /*, false, false, that*/ ));
|
that.dispatchEvent(new Event('loadend' /*, false, false, that*/ ));
|
}
|
},
|
// https://xhr.spec.whatwg.org/#the-abort()-method
|
// Cancels any network activity.
|
abort: function abort() {
|
// 原生 XHR
|
if (!this.match) {
|
this.custom.xhr.abort()
|
return
|
}
|
|
// 拦截 XHR
|
this.readyState = MockXMLHttpRequest.UNSENT
|
this.dispatchEvent(new Event('abort', false, false, this))
|
this.dispatchEvent(new Event('error', false, false, this))
|
}
|
})
|
|
// 初始化 Response 相关的属性和方法
|
Util.extend(MockXMLHttpRequest.prototype, {
|
responseURL: '',
|
status: MockXMLHttpRequest.UNSENT,
|
statusText: '',
|
// https://xhr.spec.whatwg.org/#the-getresponseheader()-method
|
getResponseHeader: function(name) {
|
// 原生 XHR
|
if (!this.match) {
|
return this.custom.xhr.getResponseHeader(name)
|
}
|
|
// 拦截 XHR
|
return this.custom.responseHeaders[name.toLowerCase()]
|
},
|
// https://xhr.spec.whatwg.org/#the-getallresponseheaders()-method
|
// http://www.utf8-chartable.de/
|
getAllResponseHeaders: function() {
|
// 原生 XHR
|
if (!this.match) {
|
return this.custom.xhr.getAllResponseHeaders()
|
}
|
|
// 拦截 XHR
|
var responseHeaders = this.custom.responseHeaders
|
var headers = ''
|
for (var h in responseHeaders) {
|
if (!responseHeaders.hasOwnProperty(h)) continue
|
headers += h + ': ' + responseHeaders[h] + '\r\n'
|
}
|
return headers
|
},
|
overrideMimeType: function( /*mime*/ ) {},
|
responseType: '', // '', 'text', 'arraybuffer', 'blob', 'document', 'json'
|
response: null,
|
responseText: '',
|
responseXML: null
|
})
|
|
// EventTarget
|
Util.extend(MockXMLHttpRequest.prototype, {
|
addEventListener: function addEventListener(type, handle) {
|
var events = this.custom.events
|
if (!events[type]) events[type] = []
|
events[type].push(handle)
|
},
|
removeEventListener: function removeEventListener(type, handle) {
|
var handles = this.custom.events[type] || []
|
for (var i = 0; i < handles.length; i++) {
|
if (handles[i] === handle) {
|
handles.splice(i--, 1)
|
}
|
}
|
},
|
dispatchEvent: function dispatchEvent(event) {
|
var handles = this.custom.events[event.type] || []
|
for (var i = 0; i < handles.length; i++) {
|
handles[i].call(this, event)
|
}
|
|
var ontype = 'on' + event.type
|
if (this[ontype]) this[ontype](event)
|
}
|
})
|
|
// Inspired by jQuery
|
function createNativeXMLHttpRequest() {
|
var isLocal = function() {
|
var rlocalProtocol = /^(?:about|app|app-storage|.+-extension|file|res|widget):$/
|
var rurl = /^([\w.+-]+:)(?:\/\/([^\/?#:]*)(?::(\d+)|)|)/
|
var ajaxLocation = location.href
|
var ajaxLocParts = rurl.exec(ajaxLocation.toLowerCase()) || []
|
return rlocalProtocol.test(ajaxLocParts[1])
|
}()
|
|
return window.ActiveXObject ?
|
(!isLocal && createStandardXHR() || createActiveXHR()) : createStandardXHR()
|
|
function createStandardXHR() {
|
try {
|
return new window._XMLHttpRequest();
|
} catch (e) {}
|
}
|
|
function createActiveXHR() {
|
try {
|
return new window._ActiveXObject("Microsoft.XMLHTTP");
|
} catch (e) {}
|
}
|
}
|
|
|
// 查找与请求参数匹配的数据模板:URL,Type
|
function find(options) {
|
|
for (var sUrlType in MockXMLHttpRequest.Mock._mocked) {
|
var item = MockXMLHttpRequest.Mock._mocked[sUrlType]
|
if (
|
(!item.rurl || match(item.rurl, options.url)) &&
|
(!item.rtype || match(item.rtype, options.type.toLowerCase()))
|
) {
|
// console.log('[mock]', options.url, '>', item.rurl)
|
return item
|
}
|
}
|
|
function match(expected, actual) {
|
if (Util.type(expected) === 'string') {
|
return expected === actual
|
}
|
if (Util.type(expected) === 'regexp') {
|
return expected.test(actual)
|
}
|
}
|
|
}
|
|
// 数据模板 => 响应数据
|
function convert(item, options) {
|
return Util.isFunction(item.template) ?
|
item.template(options) : MockXMLHttpRequest.Mock.mock(item.template)
|
}
|
|
module.exports = MockXMLHttpRequest
|