• 0

  • 534

AJAX 常用的解决方案

智能的司机

我是老司机

4个月前

卷首语

明月多情应笑我,笑我如今,辜负春心,独自闲行独自吟。 ── 纳兰容若 《采桑子》

1. 概述

AJAX 全称: Asynchronous JavaScript and XML (异步的 JavaScript 和 XML 技术)。指的是一套综合了多项技术的浏览器端网页开发技术。

早期的 AJAX 应用模型:

  1. 使用 HTML + CSS 来表达内容
  2. 使用 JavaScript 来操作 DOM 实现动态效果
  3. 使用 XML 和 XSLT 作为数据交换的格式
  4. 使用 XMLHttpRequest 向服务器异步请求资源
  5. 根据返回的数据,JavaScript 局部修改页面

因为早期的 AJAX 的数据交换格式普遍用的是 XML, 所以 AJAX 才叫 AJAX。但是现在主流的数据交换格式已经变成了 JSON,因为其易于解析,对 JavaScript 友好的特性导致只要是 JavaScript 应用,数据交换格式基本上都是 JSON

浏览器对 AJAX 提供了原生的支持。早期的 APIXMLHttpRequest,(不讨论 IEActiveXObject)较新的浏览器的 APIfetch

AJAX 的特点:

  1. 不刷新整个页面即可向服务器请求资源 - 网页在不影响用户操作的情况下,更新页面的局部内容
  2. 没有浏览历史,不能回退
  3. 存在跨域问题
  4. SEO 不友好

2. 原生 AJAX

请求类型:

  • 同步请求
    • 同步请求会阻塞浏览器 JS引擎 的主线程,在服务器响应之前,主线程会一直阻塞,UI 渲染线程会挂起,整个页面是一个冻结的状态。由于其极差的用户体验。主流浏览器大多都已经禁用主线程上的同步 AJAX 请求
    • 设置方式 request.open(method, url, async = false)
  • 异步请求
    • 异步请求执行后, request.send() 方法在发送请求后会立即返回,不会阻塞主线程,用户体验良好。但是通常也会带来表单重复提交之类的问题,需要处理。
    • 默认情况下,浏览器发送的 AJAX 请求都是异步请求,即 async = true

响应类型:

  • "" 默认是 DOMString 类型。是一个文本字符串
  • arraybuffer 是一个包含二进制数据的 JavaScript ArrayBuffer 对象
  • blob 是一个包含二进制数据的 Blob 对象
  • document 是一个 HTML 文档。这取决于响应对象的 content-type
  • json 会自动对响应内容执行 JSON 解析,返回一个对象
  • text 默认是一个 DOMString 字符串

响应类型由 responseType 指定,如果不指定,默认情况下就会返回一个 DOMString。 通常都会将其指定为 json。常用的也就是 jsontext

基本的 AJAX 请求流程:

// 1. 创建 XMLHttpRequest 对象
const request = new XMLHttpRequest()


// 2. 打开一个 API, 设置请求方式,请求 URL, 请求方式
request.open('GET', 'https://api.github.com/repos/octocat/hello-world')

// 2.1 设置请求头
request.setRequestHeader('Accept', 'application/vnd.github.v3+json')
// 2.2 设置响应体内容格式
request.responseType = 'json'

// 2.3. 指定 readyState 改变时的回调,以处理响应
request.onreadystatechange = () => {
    if (request.readyState !== XMLHttpRequest.DONE || (request.status < 200 || request.status >= 300)) {
        return
    }

    console.log(request.response)
}

// 2.4. 发送请求
request.send()
复制代码

2.1 XMLHttpRequest

a. XMLHttpRequest 实例对象的常用属性

检测请求响应是否完成:

  • readyState
    • 0 - unsent - 表示 XMLHttpRequest 对象已经被创建,但是并没有调用 open() 方法
    • 1 - opened - 表示 open() 方法已经被调用
    • 2 - HEADERS_RECEIVED - 表示 send() 方法已经被调用,响应行和响应头的信息已经可以获取
    • 3 - loading - 表示响应体正在下载中。如果 responseType"" 或者 "text", responseText 属性中已经包含了部分数据
    • 4 - done - 全部资源下载完成,响应完毕
  • readyStateChange
    • 这是一个事件处理器的回调,当 readyState 值发生改变时,就会触发这个回调
const request = new XMLHttpRequest()
console.log(request.readyState) // 0

request.open('GET', 'https://api.github.com/repos/octocat/hello-world')
console.log(request.readyState) // 1

request.onprogress = () => {
    console.log('Loading: ', request.readyState)
}

request.onload = () => {
    console.log('DONE: ', request.readyState)
}

request.send()
复制代码

设置请求头

  • setRequestHeader(name, value)
    • 设置 HTTP 请求头的值。这个方法调用位置必须在 open 之后, send 之前
request.setRequestHeader('Accept', 'application/vnd.github.v3+json')
复制代码

获取响应行

  • status
    • 响应行中的状态码
  • statusText
    • 响应行中的状态说明文本
const request = new XMLHttpRequest()

function sendGet(callback, request) {
    request.open('GET', 'https://api.apiopen.top/getAllUrl')
    request.responseType = 'json'

    request.onreadystatechange = callback

    request.send()
}

sendGet(() => {
    if (request.readyState !== XMLHttpRequest.DONE || (request.status < 200 || request.status >= 300)) {
        return
    }

    console.log('响应状态码: ' + request.status)
    console.log('响应行文本: ' + request.statusText);
}, request)
复制代码

获得响应头内容

  • getResponseHeader(name)
    • 获得某个响应头的值
  • getAllResponseHeaders()
    • 获得所有响应头的 name 是一个数组
const request = new XMLHttpRequest()

function sendGet(callback, request) {
    request.open('GET', 'https://api.apiopen.top/getAllUrl')
    request.responseType = 'json'

    request.onreadystatechange = callback

    request.send()
}

sendGet(() => {
    if (request.readyState !== XMLHttpRequest.DONE || (request.status < 200 || request.status >= 300)) {
        return
    }


    console.log(request.getAllResponseHeaders())
    console.log(request.getResponseHeader('cache-control'))
}, request)
复制代码

解析响应体

  • responseType
    • 指定响应类型
  • responseText
    • 如果响应类型是 "" 或者 "text", 则值会在 responseText 中解析
  • responseXML
    • 如果响应类型是 document 则值会在 responseXML 中解析
  • response
    • 无论是哪种响应类型,其响应内容总会在 response 中,一个好的实践是设置 response = 'json', 在 response 中直接读取对象
const request = new XMLHttpRequest()

function sendGet(callback, request) {
    request.open('GET', 'https://api.apiopen.top/getAllUrl')
    request.responseType = 'json'

    request.onreadystatechange = callback

    request.send()
}

sendGet(() => {
    if (request.readyState !== XMLHttpRequest.DONE || (request.status < 200 || request.status >= 300)) {
        return
    }


    console.log(request.response)
    console.log(request.response[1])
}, request)
复制代码

超时处理

  • timeout
    • 指定一个请求的最大时间,如果超出了这个时间,请求响应还没有完成,则该请求会自动终止
  • ontimeout
    • 超时后的事件处理回调函数
  • abort()
    • 如果请求已经发出,直接中断该请求
const request = new XMLHttpRequest()

request.open('GET', 'https://api.apiopen.top/getAllUrl')

request.timeout = 10
request.ontimeout = () => {
    console.log('您请求的内容已将超时')
}

request.send()
复制代码

携带 Cookie 的请求

  • 重点 - request.withCredentials = true
<button id="btn">get</button>
<script>
    function get() {
    console.log('hello')
    const request = new XMLHttpRequest()
    request.open('GET', 'http://localhost:5000/simple-request')
    // 设置请求携带 Cookie
    request.withCredentials = true
    request.onreadystatechange = () => {
        if (request.readyState === XMLHttpRequest.DONE && (request.status >= 200 && request.status < 300)) {
            console.log(request.response)
        }
    }
    request.send()
}

document.getElementById('btn').onclick = get


</script>
复制代码

b. XMLHttpRequest 请求简单封装

/**
 * 执行 ajax 请求的对象, 返回一个 promise 对象
 * 
 * @param {object} option 请求的配置对象 
 * 
 */
function ajax({
    url,
    method = 'GET',
    params = {},
    data = {},
    headers = {}
}) {

    return new Promise((resolve, reject) => {
        const request = new XMLHttpRequest()
        // 1. 初始化连接
        // 1.1 解析 url , 如果需要携带查询参数,则拼接url
        let parsedUrl = parseParams(url, params)
        method = method.toUpperCase()
        // 1.2 使用解析后的url打开连接
        request.open(method, parsedUrl)

        // 2. 做发送请求前的处理
        // 2.1 设置请求头
        if (Object.keys(headers) > 0) {
            for (const key of Object.keys(headers)) {
                request.setRequestHeader(key, headers[key])                
            }
        }

        // 2.2 设置响应
        request.responseType = 'json'
        request.onreadystatechange = () => {
            if (!isReady(request)) {
                return
            }

            if (isSuccess(request)) {
                // 请求成功
                const headers = parseResponseHeaders(request)
                const response = {
                    data: request.response,
                    status: request.status,
                    statusText: request.statusText,
                    headers                 
                }
                resolve(response)
            }else {
                // 请求失败
                reject(new Error('request error status is ' + request.status + '\nReason: ' + request.statusText))
            }
        }
        

        // 3. 发送请求
        switch (method) {
            case 'GET':
                request.send()
                break
            case 'POST':
                request.setRequestHeader('Content-Type', 'application/json;charset=utf-8')
                request.send(JSON.stringify(data))
                break
            case 'DELETE':
                request.send()
                break
            case 'PUT':
                request.setRequestHeader('Content-Type', 'application/json;charset=utf-8')
                request.send(JSON.stringify(data))
                break
            default: 
                break
        }

    })

}

/**
 * 将请求的原始 url 和请求参数对象拼接在一起,形成一个新的url
 * 
 * @param {string} url 请求的url
 * @param {object} params 请求参数
 */
function parseParams(url, params) {
    if (Object.keys(params).length <= 0) {
        return url
    }

    let newUrl = url + '?'

    for (const key of Object.keys(params)) {
        newUrl += `${key}=${window.encodeURI(params[key])}&`
    }

    return newUrl.substring(0, newUrl.length - 1)
}

/**
 * 检查请求响应是否已经完成
 * 
 * @param {XMLHttpRequest} request 请求对象
 */
function isReady(request) {
    return request.readyState === XMLHttpRequest.DONE
}

/**
 * 响应状态码是否是请求成功的状态码
 * 
 * @param {XMLHttpRequest} request 
 */
function isSuccess(request) {
    return request.status >= 200 && request.status < 300
}

/**
 * 解析响应头,返回一个响应头对象
 * 
 * @param {XMLHttpRequest} request 
 */
function parseResponseHeaders(request) {
    const headers = {}
    let headersStr = request.getAllResponseHeaders()
    let headersArr = headersStr.split('\n')

    headersArr.forEach(headerLine => {
        if (headerLine.trim()) {
            let [key, value] = headerLine.split(':')
            headers[key] = value.trim()
        }
    })

    return headers
}

复制代码

2.2 Fetch API

Fetch API 是最新的用于取代传统的 XMLHttpRequest 的异步 HTTP 请求方案。在现代浏览器中都已经得到实现。唯一的问题是完全不支持 IE。(0202年了,不会真有人还在用 IE 吧, 不会吧不会吧)。

Fetch API 的核心方法是 fetch(url, init?) ,必须的参数是资源的 URL,其返回值是一个 Promise 对象。fetch 函数是用来发送请求的,返回值 resolve 的值是 Response 构造函数的实例。

现代浏览器提供了 Request 构造函数用于直接创建请求实例,Response 构造函数直接创建响应,但是这是实验性功能,只能在较新的浏览器中使用。所以现在还是使用 fetch() 发送请求,在返回的 Promise 对象中处理响应。

a. fetch 函数注意事项

  1. 当接收到代表错误的 HTTP 状态码时,fetch() 返回的 Promise 也会被标记为 resolve 。换句话说,只有在网络故障无法获得响应或者无法获得响应的时候,才会标记为 reject
  2. fetch 可以接受跨域的 Cookies, 可以通过在参数中的 init 对象中指定跨域的设置
  3. fetch 默认情况下不会发送 Cookies, 除非使用了 init 对象进行配置

b. fetch 函数基本使用

基本的不带参数的 GET 请求

const baseUrl = 'http://localhost:3000'
fetch(`${baseUrl}/posts`)
    .then(
    response => { // response 对象是 Response 构造函数的实例
        return response.json() // response.json() 对象返回的是一个 Promise 对象,其resolve的值就是从服务器取到的data
    }
).then(
    posts => {
        console.log(posts)
    }
).catch(
    err => {
        console.log(err)
    }
)
复制代码

fetch 的第二个参数 init 对象常用的配置属性

  • method - 请求方法
  • headers - 请求的头信息
  • body - 请求体
  • mode - 请求的模式
    • cors - 允许跨域 - 默认值
    • no-cors - 不允许跨域
    • same-origin - 只允许同源访问
  • credentials - 为了在当前域名内自动发送 cookie , 必须提供这个选项
  • cache - 请求的缓存模式

带请求体的 POST 请求

const baseUrl = 'http://localhost:3000'

function postData(url, data) {
    const init = {
        method: 'POST',
        mode: 'cors',
        headers: {
            'Content-Type': 'application/json',
            'user-agent': 'rin yii example'
        },
        cache: 'no-cache',
        credentials: 'same-origin',
        body: JSON.stringify(data)
    }
    return fetch(url, init).then(response => response.json())
}

postData(`${baseUrl}/posts`, {
    title: 'fetch 1',
    content: 'fetch content 1'
}).then(
    data => {
        console.log(data)
    }
).catch(
    err => {
        console.log(err.messsage)
    }
)
复制代码

fetch 发送带 Cookie 的请求

  • 核心 credentials: 'include'
<button id="btn">GET</button>
<script>
    function get() {
    fetch('http://localhost:5000/simple-request', {
        method: 'GET',
        mode: 'cors',
        credentials: 'include'
    }).then(
        response => {
            return response.json()
        }
    ).then(
        data => {
            console.log(data)
        }
    ).catch(
        err => {
            console.log(err.message)
        }
    )
}

document.querySelector('#btn').onclick = () => {
    get()
}
    </script>
复制代码

其余的请求方式都是类似的,配置其 init 对象来满足需求即可。直接使用还是不大方便,还是需要封装。


实际使用不建议使用原生 AJAX 发送请求,axios 通常都是更好的选择。

3. axios

3.1 基本特点

  1. 基于 promise 的异步 AJAX 请求库, 轻量级
  2. 浏览器端/node 端都可以使用
  3. 支持请求 / 响应拦截器
  4. 支持请求取消
  5. 支持请求 / 响应数据自动转换
  6. 可以批量发送多个请求

axios 在浏览器环境和 Node.js 环境中都可以使用。 浏览器环境中 axios 是对原生 ajax API 的封装, Node.js 环境中是对 HTTP 请求的封装。

具体基本使用方式请查看官方文档,已经很简单了。

3.2 axios 的一些重要的 API

  1. axios.defaults - 这个对象是 axios 的配置对象,可以在其中设置请求的全局默认配置,具体的配置项请查阅文档
  2. axios.interceptors.request.use() - 配置请求拦截器
  3. axios-interceptors.response.use() - 配置响应拦截器
  4. axios.create([config]) - 创建一个新的 axios, 如果指定了配置对象,则使用传入的配置对象作为全局配置。如果没有指定,使用默认配置。新创建的 axios 发起的请求无法取消,且无法批量发送请求

3.3 axios 源码分析系列

a. axios 实例和 Axios 构造函数之间的关系 - axios 实例的构造过程

  1. axios 实例 语法上是一个函数,所以语法上 axios 实例 不是 Axios 的实例
  2. axios 实例 拥有 Axios 实例对象的全部功能,即 axios 实例 可以访问 Axios 实例对象的属性已经所有的方法
  3. axios 实例 实际上是 Axios.prototype.request() 通过 bind() 返回的函数
  4. axios 实例 的属性中拥有 Axios.prototype 中的所有属性,以及 Axios 实例对象上的所有属性
// axios 实例是调用 createInstance() 返回的
var axios = createInstance(defaults)

function createInstance(defaultConfig) {
  // 1. 创建一个上下文对象, context 是 Axios 的实例对象
  var context = new Axios(defaultConfig);
  // 2. 创建 axios 实例,实际调用语义相当于 instance = Axios.prototype.request.bind(context)
  // 将 context 作为 instance 函数调用时的this
  var instance = bind(Axios.prototype.request, context);

  // 3. 将 Axios 原型中所有的方法的this绑定到 context 上并作为 instance 上的属性
  utils.extend(instance, Axios.prototype, context);

  // 4. 将 context 中所有的属性复制到 instance 中
  utils.extend(instance, context);

  return instance;
}
复制代码
执行完 axios 实例的构造过程后:
axios: - Axios.prototype.request.bind(context)
    context 中所有的属性 context = new Axios(defaultConfig)
    Axios.prototype 中所有的方法,且 this 都绑定为 context
复制代码
// Axios 结构分析
// constructor
function Axios(instanceConfig) {
  this.defaults = instanceConfig;
  this.interceptors = {
    request: new InterceptorManager(),
    response: new InterceptorManager()
  };
}

// Axios 实例中拥有的属性:
// {defaults, interceptors}
// Axios.prototype
Axios.prototype.request()
Axios.prototype.getUri()
// 不带请求体的请求
Axios.prototype.get(url, config)
Axios.prototype.head(url, config)
Axios.prototype.options(url, config)
Axios.prototype.delete(url, config)
// 带请求体的请求
Axios.prototype.post(url, data, config)
Axios.prototype.put(url, data, config)
Axios.prototype.patch(url, data, config)
复制代码

b. axios 库导出的 axios 与 axios.create(config) 创建的 axios 的区别

axios 库导出的 axios 的创建过程

// axios 库导出的 axios 的创建过程(对源码进行了简化,理解其语义即可)
var axios = createInstance(defaults);

// 将 Axios 构造函数添加为 axios 的属性
axios.Axios = Axios;

// 添加创建 axios 实例的方法
axios.create = function(){}

// 取消请求相关的内容
axios.Cancel = Cancel
axios.CancelToken = CancelToken
axios.isCancel = isCancel

// 同时执行多个请求
axios.all = function(){}
// 一个 apply 的语法糖函数
axios.spread = function(){}
复制代码

axios.create(config) 创建 axios 实例的过程

// axios.create(config) 创建 axios 实例的过程
axios.create = function create(instanceConfig) {
  return createInstance(mergeConfig(axios.defaults, instanceConfig));
}
复制代码

从以上可以看出,axios.create(config) 仅仅执行了创建 axios 实例 的过程,并没有添加额外的功能。

instance 和 导出的 axios 的区别

  1. 相同点:
    • 都是一个能够发送任意请求的函数: Axios.prototype.request.bind(context)
    • 都有发特定请求的各种方法: get()/post()/put()/delete()/patch()/head()/options()
    • 都有默认配置和拦截器的属性: defaults/interceptors
  2. 不同点:
    • 配置因为可以自定义设置,所以可能不一样
    • instance 没有 导出的 axios 添加的那些额外的功能 create() / 取消请求相关 / all()/ spread()

c. axios 执行流程

具体请求的步骤就是 request 请求的步骤:

  1. 请求前的准备 - 主要工作内容是将处理请求的 URL, 准备好一个新的当次请求的配置对象。这个配置对象是通过默认配置对象和用户传入的请求配置对象合并生成的。每次执行 request 就会生成一个新的 config 配置对象。

  2. 组装执行链:

    var chain = [dispatchRequest, undefined];
    var promise = Promise.resolve(config);
    this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
        chain.unshift(interceptor.fulfilled, interceptor.rejected);
    });
    this.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
        chain.push(interceptor.fulfilled, interceptor.rejected);
    });
    复制代码

    整个执行链是通过 Promise 链式调用串联起来的。Promise每次执行都需要有 onFullfilled 和 onRejected 两个回调函数。我们可以将一对 onFullfilled 和 onRejected 看做是一个任务。那么每个拦截器就是一个任务。chain = [dispatchRequest, undefined] 这一行就是将具体发送请求的操作封装成一个 Promise 任务。

    请求拦截器的组装是从第一个请求拦截器开始,依次以 onFullFilled, onRejected 这样的方式插入到执行链头部.最终组装结果是:

    [onFullfilled_n, onRejected_n, onFullfilled_n-1, onRejected_n-1 .... dispatchRequest, undefined]
    复制代码

    响应拦截器的组装是从第一个响应拦截器开始,依次以 onFullFilled, onRejected 这样的方式插入到执行链尾部,最终组装结果是:

    [request interceptors, dispatchRequest, undefined, onFullfilled_1, onRejected_1 ... onFullfilled_n, onRejected_n]
    复制代码
  3. 执行执行链,返回执行结果

    while (chain.length) {
        promise = promise.then(chain.shift(), chain.shift());
    }
    
    // 返回最终执行的结果
    return promise;
    复制代码

    执行链的执行很简单,就是依次取出执行链的 Onfullfilled 和 onRejected, 然后作为一个任务执行,形成一条任务执行链。

    由于这种调用方式,文档中关于拦截器的限制也就浅显易懂了.

    请求拦截器:

    axios.interceptors.request.use(function (config) {
        // 返回 config 是保证在执行到 dispatchRequest 时,能够正确调用
        return config;
      }, function (error) {
        return Promise.reject(error);
      });
    复制代码

    响应拦截器

    axios.interceptors.response.use(function (response) {
        
        // 保证请求获得的响应不会在响应拦截器执行过程中丢失,无法传递给用户
        return response;
      }, function (error) {
        return Promise.reject(error);
      });
    复制代码

    拦截器执行顺序:

    请求拦截器 n -> 请求拦截器 n - 1 -> ... -> 请求拦截器 1 -> 发送请求,获得响应 -> 响应拦截器 1 -> 响应拦截器 2 ...
    复制代码

    拦截器的本质:

    拦截器实际上是注册了一个 Promise 任务,在请求过程中被链式调用。所以注册一个拦截器需要提供两个回调函数
    复制代码

d. dispatchRequest(config)

这个函数的主要作用就是发送请求,获得响应数据,并对响应数据做数据转换。其中主要的执行流程有三步:

  1. 转换请求数据,做发送请求前的准备,比如设置请求头之类的。

    config.headers = config.headers || {};
    
    // 转换请求数据
    config.data = transformData(
        config.data,
        config.headers,
        config.transformRequest
    );
    
    // Flatten headers
    config.headers = utils.merge(
        config.headers.common || {},
        config.headers[config.method] || {},
        config.headers
    );
    
    utils.forEach(
        ['delete', 'get', 'head', 'post', 'put', 'patch', 'common'],
        function cleanHeaderConfig(method) {
            delete config.headers[method];
        }
    );
    复制代码
  2. 调用具体的 adapter 发送请求

    var adapter = config.adapter || defaults.adapter; // 浏览器环境下是 xhrAdapter node 环境下是httpAdapter
    return adapter(config).then(onAdapterResolution, onAdapterRejection) // 发送请求
    复制代码
  1. 请求返回后转换响应数据,封装成 Promise 返回

    // 如果请求成功,将请求回来的数据进行 JSON 解析,然后返回
    response.data = transformData(
        response.data,
        response.headers,
        config.transformResponse
    );
    
    return response;
    
    // 请求失败,则将 reason 进行 reject
    if (!isCancel(reason)) {
        throwIfCancellationRequested(config);
    
        // Transform response data
        if (reason && reason.response) {
            reason.response.data = transformData(
                reason.response.data,
                reason.response.headers,
                config.transformResponse
            );
        }
    }
    
    return Promise.reject(reason);
    复制代码

e. xhrAdapter(config)

这个函数的功能就是封装了原生的 XMLHttpRquest API。考虑的问题更加全面, 封装的更为完善和便捷使用。返回值是一个 Promise

响应对象的结构

var response = {
    data: responseData,
    status: request.status,
    statusText: request.statusText,
    headers: responseHeaders,
    config: config,
    request: request
};
复制代码

error 对象的结构

return {
    // Standard
    message: this.message,
    name: this.name,
    // Microsoft
    description: this.description,
    number: this.number,
    // Mozilla
    fileName: this.fileName,
    lineNumber: this.lineNumber,
    columnNumber: this.columnNumber,
    stack: this.stack,
    // Axios
    config: this.config,
    code: this.code
};
复制代码

4. 跨域

4.1 同源策略

说到跨域就要提到浏览器的 同源策略 。它是一个浏览器的安全策略,用于限制来自某个服务器的文档或者它加载的脚本如何能和来自另一个服务器的资源进行交互。

同源的判断方式:

  1. 两个 URL 的 protocol 相同 : HTTP 和 HTTPS 不同
  2. 两个 URL 的 host 相同
  3. 两个 URL 的 port 相同

当这三个条件都满足的时候,两个源才能叫同源。在满足同源条件下,两个源之间互相读写资源都是可以的。但是在跨源的情况下,资源的读写就受到了很大的限制。(跨源又叫跨域,以下称跨域)

跨源的限制:

  • 跨源的写操作一般是允许的 - 表单提交, 重定向, 超链接。这三类请求是允许跨域的
  • 跨域的资源嵌入一般也是允许的 - script标签嵌入 - jsonp原理, link标签请求资源, img图片, video, audio, object, embed, @font-face, iframe 这些通常都是允许的。除非服务器做了特别的跨域限制
  • 跨域的读操作,比如 ajax 请求,一般都是不允许的。需要一些特定的解决方案来处理 - JSONP, CORS

其实对于跨域的限制很复杂,这里只是列出了常见的情况,如果需要详细了解,请查阅文档。

4.2 跨域的常见解决方案

一般来说,浏览器对于页面标签的资源访问请求是允许跨域的,所以这些情况通常都是不需要开发人员考虑的。开发人员最多接触的就是接口访问,而接口访问是会受到跨域的限制。浏览器通常都会限制接口访问的跨域请求。所以就需要一些解决方案来处理接口请求的跨域限制。

JSONP 解决跨域

前面提到,浏览器通常对于资源嵌入的标签是允许跨域请求的。所以可以利用这个特性来绕过跨域的限制。具体实现方法如下:

  1. 服务器端实现接口,返回字符串类型的数据(字符串形式的 JavaScript 代码)
  2. 前端创建一个 script 标签,将 src 属性设置为接口的 URL,并插入到文档中。浏览器会自动请求目标接口,获取响应内容并解析为 JavaScript 代码并执行
  3. 执行完代码后,将 script 标签从代码中删除

由于 script 的限制,这种方式只能向服务器发送 GET 请求。

JSONP 实例:

// server.js
import express from 'express'
import http from 'http'
const app = express()

app.get('/jsonp-server', (request, response) => {
    const data = {
        name: 'Rin yii',
        age: 22
    }

    response.send(`handle(${JSON.stringify(data)})`)
})

http.createServer(app).listen(5000)
复制代码
// client
<button id="btn">GET</button>
<div id="result"></div>

<script>

    function handle(data) {
        const result = document.querySelector('#result')
        result.textContent = data.name
    }
    const btn = document.querySelector('#btn')

    btn.addEventListener('click', event => {
        const scriptElement = document.createElement('script')
        scriptElement.src = 'http://localhost:5000/jsonp-server'
        document.body.appendChild(scriptElement)
    })
</script>
复制代码

这种套路是很早以前的解决方案了。现在通常不使用这种方式进行跨域了。

CORS 解决跨域问题

CORS 全称 Cross - origin resource sharing - 跨域资源共享。它是 W3C 标准,专门用于处理跨域问题。它需要浏览器和服务器同时支持才能使用。通常来说,不需要担心支持度问题。CORS 整个通信过程都由浏览器自动处理,因为解决方案是通过 HTTP 的头信息来处理的。浏览器在检测到是跨域请求时,会自动在 HTTP 请求头信息中添加一些特殊的字段,如果是非简单请求还会多出一次附加请求。所以前端并不需要处理。需要处理的只有后端服务器,后端服务器需要实现 CORS,这样就可以跨域通信。

CORS 将请求分为两类;

  • 简单请求
    • 请求方法 - HEAD | GET | POST
    • 请求头信息不超出以下字段
      • Accept
      • Accept-Language
      • Content-Language
      • Last-Event-ID
      • Content-Type - 只能是 application/x-www/form-urlencoded | multipart/form-data | text/plain
    • 只要同时满足这两个要求,就是简单请求
  • 非简单请求
    • 除了简单请求之外,都是非简单请求。现在的请求基本上很少有简单请求了(因为数据都是 JSON),基本上都是非简单请求

简单请求的处理

对于简单请求,浏览器直接发出 CORS 请求。

浏览器端: 在 HTTP 请求头中添加 Origin: protocol://host:port ,指明本次请求来自的源。服务器根据这个值,来判断是否响应

服务器端: 查看 Access-Control-Allow-Origin 字段的值,如果请求源不在允许通信的范围中,则向浏览器抛出错误,不响应资源。如果请求源在许可的范围内,则响应头会多出如下字段:

  • Access-Control-Allow-Credentials - 表示服务器是否允许浏览器发送 Cookie,如果这个字段在响应头中,说明允许发送 Cookie
  • Access-Control-Allow-Origin - 必须字段,要么是请求时 Origin 的值,要么是 *
  • Access-Control-Expose-Headers - 浏览器端是否可以拿到除基本字段之外的响应头的值,一般都会设置为 true
  • Content-Type

在浏览器发送 CORS 请求时,默认是不会发送 CookieHTTP 认证信息,如果需要发送,则要遵守以下规则:

  1. 根据你使用的 AJAX API 提供的方式指定发送请求的时候携带 Cookie
  2. 服务器的响应头要有 Access-Control-Allow-Credentials: true
  3. Access-Control-Allow-Origin 必须是和请求源相同的值
  4. Cookie 遵守同源策略,只会发送与服务器同源的 Cookie 才会被上传

实例:

// server.js
app.get('/simple-request', (request, response) => {
    response.setHeader('Access-Control-Allow-Origin', 'http://127.0.0.1:5500')
    response.setHeader('Access-Control-Expose-Headers', 'true')
    response.setHeader('Access-Control-Allow-Credentials', 'true')
    if (!request.headers.cookie) {
        response.cookie('hobby', 'swim', {
            maxAge: 60 * 1000,
            httpOnly: true
        })
    }else {
        console.log('cookie exists')
    }
    response.send({
        name: 'Rin yii',
        age: 20
    })
})
复制代码
<button id="btn">GET</button>
<script>
    function get() {
        fetch('http://localhost:5000/simple-request', {
            method: 'GET',
            mode: 'cors',
            credentials: 'include'
        }).then(
            response => {
                return response.json()
            }
        ).then(
            data => {
                console.log(data)
            }
        ).catch(
            err => {
                console.log(err.message)
            }
        )
    }

    document.querySelector('#btn').onclick = () => {
        get()
    }
</script>
复制代码

注: 不要在 Chrome 中测试,Chrome 对于本地代理服务器的 Cookie 处理好像有点问题。

非简单请求的处理

浏览器对于非简单请求,会预先发出一个 预检查请求。这个预检查请求使用的 HTTP Method 是 OPTIONS。这个请求主要询问以下内容:

  1. 当前网页所在的域名是否在服务器的许可名单中
  2. 可以使用哪些 HTTP Method
  3. 可以使用哪些 HTTP 请求头

只有得到服务器的肯定答复,浏览器才会发出正式的 AJAX 请求。

预检查请求头:

  • Access-Control-Request-Method - 必须字段,列出浏览器将要发出的 CORS 请求会用到的 HTTP Method
  • Access-Control-Request-Headers - 这个字段是一个逗号分隔的字符串,指定浏览器将要发出的 CORS 请求要发送的非标准头信息字段
  • Origin - 请求源

预检查成功响应头:

  • Access-Control-Allow-Origin
  • Access-Control-Allow-Methods
  • Access-Control-Allow-Headers

这三个是最重要的,如果服务器否定了预检查请求,会返回一个正常的 HTTP 响应,但是不会有任何 CORS 响应头的字段。这时,浏览器认为预检查失败,就会报错,本次 CORS 请求失败。

预检查成功后,非简单请求和简单请求的处理方式是基本上相同的。

实例:

// server.js
app.put('/complex-request', (request, response) => {
    response.set({
        'Access-Control-Allow-Origin': request.headers.origin,
        'Access-Control-Allow-Credentials': 'true',
        'Access-Control-Expose-Headers': '*',
        'Content-Type': 'application/json',
    })

    if (!request.headers.cookie) {
        response.cookie('hobby', 'swim', {
            maxAge: 1000 * 60,
            httpOnly: true
        })
    }

    response.send({
        name: 'Rin yii',
        age: 22
    })

})

app.options('/complex-request', (request, response) => {
    
    response.set({
        'Access-Control-Allow-Methods': 'GET, PUT, POST, DELETE, OPTIONS',
        'Access-Control-Allow-Headers': 'extra-header',
        'Access-Control-Allow-Credentials': 'true',
        'Access-Control-Max-Age': '1728000',
        'Access-Control-Allow-Origin': request.headers.origin
        
    })

    response.send()

})
复制代码
<button id="btn">GET</button>
<script>
    function put() {
        fetch('http://localhost:5000/complex-request', {
            method: 'PUT',
            mode: 'cors',
            credentials: 'include',
            headers: {
                'extra-header': 'extra'
            }
        }).then(
            response => {
                return response.json()
            }
        ).then(
            data => {
                console.log(data)
            }
        ).catch(
            err => {
                console.log(err.message)
            }
        )
    }

    document.querySelector('#btn').onclick = () => {
        put()
    }
</script>
复制代码

结论:

本质上 CORS 是利用 HTTP 协议来解决跨域问题。只要熟悉 HTTP 协议,那么 CORS 就很好理解。因为它本质上就是利用 HTTP 协议来做请求端和响应端的身份验证。 CORS 可以支持任意的请求方式,而 JSONP 只能解决 GET 请求。现在基本上都是使用 CORS, JSONP 已经渐渐的淹没在了历史潮流中。

CORS 的配置主要是服务器端的支持,所以只需要做服务器的配置

// express 中间件处理跨域问题
app.use((request, response, next) => {

    // 1. 判断请求路径, 路径要求是一个接口,不能是静态资源
    // 这个请求路径的语义可以自定义,看实际应用中接口的 URL 来定
    if (request.url !== '/' && request.path.includes('.')) {
        response.set({
            'Access-Control-Allow-Credentials': 'true', // CORS 允许后端向前端发送 Cookie, 后端可以接收前端发来的 Cookie
            'Access-Control-Allow-Origin': request.headers.origin || '*', // 允许可以跨域的源路径
            'Access-Control-Allow-Headers': '', // 允许的自定义的请求头,依情况而定
            'Access-Control-Allow-Methods': 'PUT, POST, GET, DELETE, OPTIONS, HEAD', // 允许的请求方式
            'Content-Type': 'application/json; charset=utf-8'  // 返回内容是 json
        })
    }

    request.method.toUpperCase() === 'OPTIONS' ? response.status(204).end() : next()
})
复制代码
免责声明:文章版权归原作者所有,其内容与观点不代表Unitimes立场,亦不构成任何投资意见或建议。

534

相关文章推荐

未登录头像

暂无评论