浅谈H5 HTTP请求拦截

背景

在前端开发中,经常会遇到针对全局HTTP请求拦截的需求,比如底层统一检测第三方代码发送的请求是否合规,对不合规请求进行拦截;底层统一进行接口加固的改造,需要对请求添加额外参数;进行统一埋点统计或者性能监控等场景。

前端发起HTTP请求的方式

要拦截HTTP请求,首先就要搞清楚有哪些发送HTTP请求的方式。翻阅资料了解到前端大致有以下几种方式可以发起HTTP请求:

  • XMLHttpRequest
  • fetch
  • jQuery.ajax(底层基于XMLHttpRequest进行封装)
  • axios(H5端底层基XMLHttpRequestfetch进行封装)
  • Taro.request(H5端底层基于fetch进行封装)
  • JSONP(通过<script>标签方式获取数据) 以上几种请求方式,只有XMLHttpRequestfetchJSONP三种方式是最底层的发起请求的方法,像jQuery.ajax,axios和Taro.request基本上都是通过对上面某种方式的二次封装。

HTTP请求拦截

通过以上分析,我们知道只需要对XMLHttpRequestfetchJSONP这三种底层的方式进行请求拦截即可满足大部分的场景。 我们可以在以下三个阶段调用一些钩子函数以获得对请求处理权:

  • onRequest发送请求之前
  • onResponse收到响应之后
  • onError发生错误时

当然像jqueryaxiosTaro.request这些库本身也都提供了一些类似的拦截器设计,他们大致可以分为发送请求之前和响应返回之后(包括成功和失败),但是他们属于上层库的实现,无法从全局对所有HTTP请求进行拦截。

XMLHttpRequest拦截

XMLHttpRequest使用示例

var xhr = new XMLHttpRequest();
xhr.open("POST", "https://example.com/api/data", true);
xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
xhr.onreadystatechange = function() {
  if (xhr.readyState === 4 && xhr.status === 200) {
    var response = JSON.parse(xhr.responseText);
    console.log(response);
  }
};
var params = "username=test&password=12345"; // 参数字符串
xhr.send(params);

拦截方式

/**
 * 检测是否需要触发请求
 * @param {*} thisObj 
 * @param {*} onRequest 
 * @param {*} config 
 * @returns 
 */
const checkNeedRequest = (thisObj, onRequest, config) => {
    return new Promise((resolve) => {
        let needRequest = true
        if (typeof onRequest === "function") {
            // sendHookResult=false才会阻止发送请求,否则都发送请求
            let sendHookResult = onRequest.apply(thisObj, [config])
            if (sendHookResult instanceof Promise) {
                sendHookResult.then((realResult) => {
                    needRequest = realResult
                    resolve(needRequest)
                })
                    .catch((error) => {
                        resolve(needRequest)
                    })
            }
            else {
                needRequest = sendHookResult
                resolve(needRequest)
            }
        }
        else {
            resolve(needRequest)
        }
    })
}

/**
 * 拦截XMLHttpRequest
 * @param window
 * @param {*} option
 */
function XMLHttpRequestInterceptor(window, option) {
    if (window.XMLHttpRequest) {
        const { onRequest } = option
        // 保存原始函数
        const originalSend = window.XMLHttpRequest.prototype.send
        const originalOpen = window.XMLHttpRequest.prototype.open
        const originalAbort = window.XMLHttpRequest.prototype.abort
        window.XMLHttpRequest.prototype.open = function (method, url, async, user, password) {
            this.method = method
            this.url = url
            this.async = async
            this.user = user
            this.password = password
            originalOpen.apply(this, arguments)
        }
        window.XMLHttpRequest.prototype.abort = function () {
            // 已经调用了abort方法的标识
            this._abort = true
            originalAbort.apply(this, arguments)
        }
        // 重新发送方法
        window.XMLHttpRequest.prototype.send = function (body) {
            console.log("XMLHttpRequest url:", this.url)
            const xhr = this
            xhr.body = body
            xhr.addEventListener('abort', (e) => {
                console.log("e:", e)
                // 已经收到请求终止事件的标识,可能由多种愿意触发,比如调用了abort()方法,获取其他方式
                xhr._aborted = true
            })
            const config = { type: "XMLHttpRequest", url: xhr.url, body, method: xhr.method, async: xhr.async, user: xhr.user, password: xhr.password }
            checkNeedRequest(xhr, onRequest, config).then((needRequest) => {
                xhr._needSend = needRequest
                if (needRequest) {
                    // 是否调用了send()方法
                    xhr._send = true
                    originalSend.apply(xhr, arguments)
                }
            })
        }
    }
    else {
        console.error("window.XMLHttpRequest is null")
    }
}

以上代码展示了一个基本的拦截方案。 拦截XMLHttpRequest请求的整体思路是实现一个XMLHttpRequest的代理对象,然后覆盖全局的XMLHttpRequest,这样一但上层调用new XMLHttpRequest这样的代码时,其实创建的是我们代理对象实例,就可以在发送请求之前,以及返回后进行一些预处理。 其实对于XMLHttpRequest的拦截,业内有一些比较优秀的实现方案,比如:Ajax-hook,这个库已经被多个大厂使用,相关代码也已经达到商用的标准。实现细节和详细原理也可以参考文档Ajax-hook原理解析。 需要注意的是:因为xhr的responseText属性并不是writeable的,这也就意味着你无法直接更改xhr.responseText的值,而Ajax-hook最新版放弃了直接重写XMLHttpRequest.prototype的实现方式,转而代理XMLHttpRequest的实例就不会有这个问题。

fetch拦截

fetch使用示例


fetch('http://example.com/api/data', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ name: 'John' }),
})
  .then(response => response.json())
  .then(data => {
    // 请求成功的处理逻辑
    console.log(data);
  })
  .catch(error => {
    // 请求失败的处理逻辑
    console.error(error);
  })

拦截方式

// fetch-proxy.js
var prototype = 'prototype'

export default function fetchProxy(proxy, win) {
  win = win || window;
  return proxyFetch(proxy, win)
}

function Handler(resolve, reject) {
  this._resolve = resolve
  this._reject = reject
}

Handler[prototype] = Object.create({
  resolve: function resolve(res) {
    this._resolve && this._resolve(res.response)
  },
  reject: function reject(err) {
    this._reject && this._reject(err.error)
  }
})

function makeHandler(next) {
  function sub(resolve, reject) {
    Handler.call(this, resolve, reject)
  }

  sub[prototype] = Object.create(Handler[prototype])
  sub[prototype].next = next
  return sub
}

function proxyFetch(proxy, win) {
  const onRequest = proxy.onRequest
  const onResponse = proxy.onResponse
  const onError = proxy.onError
  // 保存原始函数
  const originalFetch = win.fetch
  if (!originalFetch) {
    console.error("fetch is null")
    return {}
  }

  /**
   * @param input url字符串或者Request对象
   * @param init 配置项
   */
  win.fetch = function (input, init) {
    let config = { input, init }
    return new Promise((resolve, reject) => {
      let RequestHandler = makeHandler(function (cfg) {
        cfg = cfg || config;
        callFetch(cfg)
      })
      let ResponseHandler = makeHandler(function (res) {
        this.resolve(res)
      })
      let ErrorHandler = makeHandler(function (err) {
        this.reject(err)
      })

      // 调用真实请求接口
      const callFetch = (config) => {
        originalFetch.apply(this, [config.input, config.init]).then((response) => {
          let handler = new ResponseHandler(resolve, reject)
          let ret = {
            config: config,
            response: response,
          }
          if (!onResponse) {
            return handler.resolve(ret)
          }
          onResponse(ret, handler)
        }).catch((error) => {
          let handler = new ErrorHandler(resolve, reject)
          let err = { config: config, error: error }
          if (onError) {
            onError(err, handler)
          } else {
            handler.next(err)
          }
        })
      }
      if (onRequest) {
        onRequest(config, new RequestHandler(resolve, reject))
      }
      else {
        callFetch(config)
      }
    })
  }

  function unProxy() {
    win.fetch = originalFetch
    originalFetch = undefined
  }

  return {
    originalFetch,
    unProxy
  }
}

以下是使用fetchProxy的示例

// fetchProxy使用展示
const {originalFetch, unProxy} = fetchProxy({
  //请求发起前进入
  onRequest: (config, handler) => {
    setTimeout(() => {
      handler.next(config)
    }, 1000)
  },
  //请求发生错误时进入
  onError: (err, handler) => {
    handler.next(err)
  },
  //请求成功后进入
  onResponse: (response, handler) => {
    handler.next(response)
  }
})

拦截fetch请求的整体思路是实现一个fetch的代理对象,然后覆盖全局的fetch,这样一但上层调用fetch()这样的代码时,其实调用的是我们代理函数,就可以在发送请求之前,以及返回后进行一些预处理。

JSONP拦截

JSONP实现基本原理

JSONP实现跨域请求的原理简单的说,就是动态创建<script>标签,然后利用<script>src不受同源策略约束来跨域获取数据。

1、动态方式

function jsonpCallback(data) {
  // 在这里处理响应数据
}

var scriptElement = document.createElement('script');
scriptElement.src = "//api.domain.com/getData?callback=jsonpCallback";
document.body.appendChild(scriptElement);

document.body.removeChild(scriptElement); // 请求发送后立即删除 <script> 元素

// 服务器将会返回类似于以下的响应:
jsonpCallback({ "name": "John", "age": 30 })

以上代码展示了通过动态创建<script>标签的方式实现JSONP的请求。由于浏览器加载<script>标签返回的数据默认会按照javascript代码执行,所以返回的字符串中的jsonpCallback会被当做函数执行,因此我们可以在全局定义的函数jsonpCallback中获取到后端传递过来的数据data

拦截方式

根据以上实现方式,整个加载流程分为以下几个阶段:

  • 创建<script>标签:document.createElement
  • 赋值src属性
  • 添加子节点到dom树的方法:appendChildinsertBeforeinsertAdjacentElement
  • 加载完成事件onload
  • 加载报错事件onerror

可以发现,对于动态创建<script>标签的JSONP实现方案,我们唯一能做的就只能是创建和赋值src阶段进行拦截,当加载完成时回调函数的代码会自动执行,我们无法干涉。

// jsonp-proxy.js 拦截JSONP请求示例
(function () {
  var originalCreateElement = document.createElement
  function proxy(dom) {
    var src
    Object.defineProperty(dom, 'src', {
      get: function () {
        return src
      },
      set: function (newVal) {
        src = newVal
        dom.setAttribute('src', newVal)
      }
    })

    var originalSetAttribute = dom.setAttribute
    dom.setAttribute = function () {
      var args = Array.prototype.slice.call(arguments)
      var key = args[0]
      var val = args[1]
      // 根据val即url地址上是否存在callback=回调函数字样粗略的判断,是否请求的是JSONP的调用,但是此方法不是很可靠,因为像jquery里面的JSONP实现是可以指定callback的命名的。
      if (key === 'src' && val.includes('callback=')) {
        let url = val
        console.log('请求地址:', url)
        // 在调用之前做一些事情,比如修改url
        originalSetAttribute.apply(dom, [key, url])
      }
      else {
        originalSetAttribute.apply(dom, args)
      }
    }
  }
  // 重写创建节点函数
  document.createElement = function (tagName) {
    var dom = originalCreateElement.call(document, tagName)
    dom.onload = function (evt) {
      console.log("onload:", evt)
    }
    dom.onerror = function (err) {
      console.log("onerror:", err)
    }
    if(tagName.toLowerCase() === 'script'){
      proxy(dom)
    }
    return dom
  }
})()

2、静态方式

不排除也可以通过非动态创建的<script>标签实现JSONP的调用,示例如下:

<script type="text/javascript">
    function jsonpCallback(data) {
      // 在这里处理响应数据
    }
</script>
<script type="text/javascript" src="//api.domain.com/getData?callback=jsonpCallback"></script>

// 服务器将会返回类似于以下的响应:
jsonpCallback({ "name": "John", "age": 30 })

拦截方式

由于<script>标签已经提前放在了HTML页面上,所以我们很难干涉这样的JSONP的请求,目前暂无办法进行拦截。

总结

由以上分析可以知道,目前在XMLHttpRequestfetch的拦截都可以做到onRequest发送请求之前、onResponse收到响应之后和onError发送错误时三个阶段的完全拦截进行预处理。但是针对JSONP的请求,目前只能对动态方式进行onRequest发送请求之前的部分拦截。 不过综合以上三种拦截方式的实现,已经可以满足绝大部分场景的拦截需求了。

风险提示

重写底层请求组件可能带来的问题:前端上层应用埋点或性能监控引入的库本身就会重写,二者容易冲突,复写大概率也不怎么健壮,因此使用时需要考虑到兼容性问题。

全部评论

相关推荐

某牛奶:一觉醒来全球程序员能力下降200%,小伙成功scanf惊呆在座个人。
点赞 评论 收藏
分享
微风不断:兄弟,你把四旋翼都做出来了那个挺难的吧
点赞 评论 收藏
分享
点赞 收藏 评论
分享
牛客网
牛客企业服务