@tarojs/plugin-http
Version:
Taro 小程序端支持使用 web 请求 的插件
470 lines (394 loc) • 13.5 kB
text/typescript
import { createEvent, Events, parseUrl, TaroEvent, window } from '@tarojs/runtime'
import { isFunction, isString } from '@tarojs/shared'
import { request } from '@tarojs/taro'
declare const ENABLE_COOKIE: boolean
const SUPPORT_METHOD = ['OPTIONS', 'GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'TRACE', 'CONNECT']
const STATUS_TEXT_MAP = {
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 Choices',
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 Suitable',
417: 'Expectation Failed',
500: 'Internal Server Error',
501: 'Not Implemented',
502: 'Bad Gateway',
503: 'Service Unavailable',
504: 'Gateway Timeout',
505: 'HTTP Version Not Supported',
}
export interface XMLHttpRequestEvent extends TaroEvent {
target: XMLHttpRequest
currentTarget: XMLHttpRequest
loaded: number
total: number
}
function createXMLHttpRequestEvent (event: string, target:XMLHttpRequest, loaded: number): XMLHttpRequestEvent {
const e = createEvent(event) as XMLHttpRequestEvent
try {
Object.defineProperties(e, {
currentTarget: {
enumerable: true,
value: target
},
target: {
enumerable: true,
value: target
},
loaded: {
enumerable: true,
value: loaded || 0
},
// 读 Content-Range 字段,目前来说作用不大,先和 loaded 保持一致
total: {
enumerable: true,
value: loaded || 0
}
})
} catch (err) {
// no handler
}
return e
}
// https://developer.mozilla.org/zh-CN/docs/Web/API/XMLHttpRequest
export class XMLHttpRequest extends Events {
static readonly UNSENT = 0
static readonly OPENED = 1
static readonly HEADERS_RECEIVED = 2
static readonly LOADING = 3
static readonly DONE = 4
// 欺骗一些库让其认为是原生的xhr
static toString () {
return 'function XMLHttpRequest() { [native code] }'
}
toString () {
return '[object XMLHttpRequest]'
}
#method: string
#url: string
#data: null
#status: number
#statusText: string
#readyState: number
#header: Record<string, any>
#responseType: string
#resHeader: null | Record<string, any>
#response: null
#timeout: number
#withCredentials: boolean
#requestTask: null | Taro.RequestTask<any>
// 事件正常流转: loadstart => progress(可能多次) => load => loadend
// error 流转: loadstart => error => loadend
// abort 流转: loadstart => abort => loadend
// web在线测试: https://developer.mozilla.org/zh-CN/play
/** 当 request 被停止时触发,例如当程序调用 XMLHttpRequest.abort() 时 */
onabort: ((e: XMLHttpRequestEvent) => void) | null = null
/** 当 request 遭遇错误时触发 */
onerror: ((e: XMLHttpRequestEvent) => void) | null = null
/** 接收到响应数据时触发 */
onloadstart: ((e: XMLHttpRequestEvent) => void) | null = null
/** 请求成功完成时触发 */
onload: ((e: XMLHttpRequestEvent) => void) | null = null
/** 当请求结束时触发,无论请求成功 ( load) 还是失败 (abort 或 error)。 */
onloadend: ((e: XMLHttpRequestEvent) => void) | null = null
/** 在预设时间内没有接收到响应时触发 */
ontimeout: ((e: XMLHttpRequestEvent) => void) | null = null
/** 当 readyState 属性发生变化时,调用的事件处理器 */
onreadystatechange: ((e: XMLHttpRequestEvent) => void) | null = null
constructor () {
super()
this.#method = ''
this.#url = ''
this.#data = null
this.#status = 0
this.#statusText = ''
this.#readyState = XMLHttpRequest.UNSENT
this.#header = {
Accept: '*/*',
}
this.#responseType = ''
this.#resHeader = null
this.#response = null
this.#timeout = 0
/** 向前兼容,默认为 true */
this.#withCredentials = true
this.#requestTask = null
}
addEventListener (event: string, callback: (arg: any) => void) {
if (!isString(event)) return
this.on(event, callback, null)
}
removeEventListener (event: string, callback: (arg: any) => void) {
if (!isString(event)) return
this.off(event, callback, null)
}
/**
* readyState 变化
*/
#callReadyStateChange (readyState) {
const hasChange = readyState !== this.#readyState
this.#readyState = readyState
if (hasChange) {
const readystatechangeEvent = createXMLHttpRequestEvent('readystatechange', this, 0)
this.trigger('readystatechange', readystatechangeEvent)
isFunction(this.onreadystatechange) && this.onreadystatechange(readystatechangeEvent)
}
}
/**
* 执行请求
*/
#callRequest () {
if (!window || !window.document) {
console.warn('this page has been unloaded, so this request will be canceled.')
return
}
if (this.#timeout) {
setTimeout(() => {
if (!this.#status && this.#readyState !== XMLHttpRequest.DONE) {
// 超时
if (this.#requestTask) this.#requestTask.abort()
this.#callReadyStateChange(XMLHttpRequest.DONE)
const timeoutEvent = createXMLHttpRequestEvent('timeout', this, 0)
this.trigger('timeout', timeoutEvent)
isFunction(this.ontimeout) && this.ontimeout(timeoutEvent)
}
}, this.#timeout)
}
// 重置各种状态
this.#status = 0
this.#statusText = ''
this.#readyState = XMLHttpRequest.OPENED
this.#resHeader = null
this.#response = null
// 补完 url
let url = this.#url
url = url.indexOf('//') === -1 ? window.location.origin + url : url
// 头信息
const header = Object.assign({}, this.#header)
// https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Cookies
// @ts-ignore
header.cookie = window.document.$$cookie
if (!this.withCredentials) {
// 不同源,要求 withCredentials 为 true 才携带 cookie
const { origin } = parseUrl(url)
if (origin !== window.location.origin) delete header.cookie
}
this.#requestTask = request({
url,
data: this.#data || {},
header,
// @ts-ignore
method: this.#method,
dataType: this.#responseType === 'json' ? 'json' : 'text',
responseType: this.#responseType === 'arraybuffer' ? 'arraybuffer' : 'text',
success: this.#requestSuccess.bind(this),
fail: this.#requestFail.bind(this),
complete: this.#requestComplete.bind(this),
})
}
/**
* 请求成功
*/
#requestSuccess ({ data, statusCode, header }) {
if (!window || !window.document) {
console.warn('this page has been unloaded, so this request will be canceled.')
return
}
this.#status = statusCode
this.#resHeader = header
this.#callReadyStateChange(XMLHttpRequest.HEADERS_RECEIVED)
if (ENABLE_COOKIE) {
// 处理 set-cookie
const setCookieStr = this.getResponseHeader('set-cookie')
if (setCookieStr && typeof setCookieStr === 'string') {
let start = 0
let startSplit = 0
let nextSplit = setCookieStr.indexOf(',', startSplit)
const cookies: string[] = []
while (nextSplit >= 0) {
const lastSplitStr = setCookieStr.substring(start, nextSplit)
const splitStr = setCookieStr.substr(nextSplit)
// eslint-disable-next-line no-control-regex
if (/^,\s*([^,=;\x00-\x1F]+)=([^;\n\r\0\x00-\x1F]*).*/.test(splitStr)) {
// 分割成功,则上一片是完整 cookie
cookies.push(lastSplitStr)
start = nextSplit + 1
}
startSplit = nextSplit + 1
nextSplit = setCookieStr.indexOf(',', startSplit)
}
// 塞入最后一片 cookie
cookies.push(setCookieStr.substr(start))
cookies.forEach((cookie) => {
window.document.cookie = cookie
})
}
}
// 处理返回数据
if (data) {
this.#callReadyStateChange(XMLHttpRequest.LOADING)
const contentLength = Number(this.getResponseHeader('content-length') || 0)
const loadstartEvent = createXMLHttpRequestEvent('loadstart', this, contentLength)
this.trigger('loadstart', loadstartEvent)
isFunction(this.onloadstart) && this.onloadstart(loadstartEvent)
this.#response = data
const loadEvent = createXMLHttpRequestEvent('load', this, contentLength)
this.trigger('load', loadEvent)
isFunction(this.onload) && this.onload(loadEvent)
}
}
/**
* 请求失败
*/
#requestFail (err) {
// 微信小程序,无论接口返回200还是其他,响应无论是否有错误,都会进入 success 回调;只有类似超时这种请求错误才会进入 fail 回调
//
/**
* 阿里系小程序,接口返回非200状态码,会进入 fail 回调, 此时 err 对象结构如下(当错误码为 14 或 19 时,会多返回 status、data、headers。可通过这些字段获取服务端相关错误信息):
{
data: "{\"code\": 401,\"msg\":\"登录过期,请重新登录\"}"
error: 19
errorMessage: "http status error"
headers: {date: 'Mon, 14 Aug 2023 08:54:58 GMT', content-type: 'application/json;charset=UTF-8', content-length: '52', connection: 'close', access-control-allow-credentials: 'true', …}
originalData: "{\"code\": 401,\"msg\":\"登录过期,请重新登录\"}"
status: 401
}
*/
// 统一行为,能正常响应的,都算 success.
if (err.status) {
this.#requestSuccess({
data: err,
statusCode: err.status,
header: err.headers
})
return
}
this.#status = 0
this.#statusText = err.errMsg || err.errorMessage
const errorEvent = createXMLHttpRequestEvent('error', this, 0)
this.trigger('error', errorEvent)
isFunction(this.onerror) && this.onerror(errorEvent)
}
/**
* 请求完成
*/
#requestComplete () {
this.#requestTask = null
this.#callReadyStateChange(XMLHttpRequest.DONE)
if (this.#status) {
const contentLength = Number(this.getResponseHeader('content-length') || 0)
const loadendEvent = createXMLHttpRequestEvent('loadend', this, contentLength)
this.trigger('loadend', loadendEvent)
isFunction(this.onloadend) && this.onloadend(loadendEvent)
}
}
/**
* 对外属性和方法
*/
get timeout () {
return this.#timeout
}
set timeout (timeout) {
if (typeof timeout !== 'number' || !isFinite(timeout) || timeout <= 0) return
this.#timeout = timeout
}
get status () {
return this.#status
}
get statusText () {
if (this.#readyState === XMLHttpRequest.UNSENT || this.#readyState === XMLHttpRequest.OPENED) return ''
return STATUS_TEXT_MAP[this.#status + ''] || this.#statusText || ''
}
get readyState () {
return this.#readyState
}
get responseType () {
return this.#responseType
}
set responseType (value) {
if (typeof value !== 'string') return
this.#responseType = value
}
get responseText () {
if (!this.#responseType || this.#responseType === 'text') {
return this.#response
}
return null
}
get response () {
return this.#response
}
get withCredentials () {
return this.#withCredentials
}
set withCredentials (value) {
this.#withCredentials = !!value
}
abort () {
if (this.#requestTask) {
this.#requestTask.abort()
const abortEvent = createXMLHttpRequestEvent('abort', this, 0)
this.trigger('abort', abortEvent)
isFunction(this.onabort) && this.onabort(abortEvent)
}
}
getAllResponseHeaders () {
if (this.#readyState === XMLHttpRequest.UNSENT || this.#readyState === XMLHttpRequest.OPENED || !this.#resHeader) { return '' }
return Object.keys(this.#resHeader)
.map((key) => `${key}: ${this.#resHeader![key]}`)
.join('\r\n')
}
getResponseHeader (name) {
if (this.#readyState === XMLHttpRequest.UNSENT || this.#readyState === XMLHttpRequest.OPENED || !this.#resHeader) { return null }
// 处理大小写不敏感
const key = Object.keys(this.#resHeader).find((item) => item.toLowerCase() === name.toLowerCase())
const value = key ? this.#resHeader[key] : null
return typeof value === 'string' ? value : null
}
open (method, url) {
if (typeof method === 'string') method = method.toUpperCase()
if (SUPPORT_METHOD.indexOf(method) < 0) return
if (!url || typeof url !== 'string') return
this.#method = method
this.#url = url
this.#callReadyStateChange(XMLHttpRequest.OPENED)
}
setRequestHeader (header, value) {
if (typeof header === 'string' && typeof value === 'string') {
this.#header[header] = value
}
}
send (data) {
if (this.#readyState !== XMLHttpRequest.OPENED) return
this.#data = data
this.#callRequest()
}
}