跨域获取同源资源

AJAX 技术使开发者能够专注于互联网中数据的传输,而不再拘泥于数据传输的载体。通过它,我们获取数据的方式变得更加灵活,可控和优雅。但 AJAX 技术并不是一把万能钥匙,互联网中的数据隐私和数据安全(例如银行账号和密码)非常重要,为了保护用户数据的隐私与安全,浏览器使用同源策略限制了 AJAX 技术获取数据的范围和能力。但有时我们不得不想办法绕过同源策略,实现跨域请求资源。因此跨域技术一直成为开发者们经久不衰的讨论话题。

一、同源策略

1.1 同源的定义

互联网的数据要么存储在服务端(即服务器,如数据库、硬盘等)中,要么存储在客户端(即浏览器,如 cookie,localStorage,sessionStorage)中。互联网数据的传输实际上就是客户端与服务端之间的交互。而所谓的数据隐私,说白了就是数据拥有者对数据索取者发出警告:“不是你的你别动”。

浏览器的同源策略是指:限制不同源之间执行特定操作

一个协议域名端口三部分组成,这三者任意一个不同都会被浏览器识别为不同的源:

URL是否同源原因
http://example.com同源协议、域名、端口都相同
https://example.com不同源协议不同(http vs https)
http://www.example.com不同源域名不同(example.com vs www.example.com)
http://example.com:8080不同源端口不同(默认 80 vs 8080)
http://example.org不同源域名不同(example.com vs example.org)

1.2 同源策略限制的操作

同源策略限制了不同源之间的以下操作:

  • 读取存储数据:Cookie、LocalStorage、IndexedDB 等
  • 获取 DOM 元素:无法通过 iframewindow.open 获取不同源的 DOM
  • 发送 AJAX 请求:无法通过 XMLHttpRequest 或 Fetch API 向不同源发送请求
  • WebAssembly 模块:不同源的 WebAssembly 模块无法共享内存

1.3 同源策略的安全意义

假如浏览器没有同源策略,会带来如下安全风险:

  1. Cookie 窃取:恶意网站可以读取用户在其他网站的 Cookie,从而获取用户身份信息
  2. DOM 操作:恶意网站可以通过 iframe 嵌入银行网站,操作 DOM 窃取用户输入的密码
  3. CSRF 攻击:恶意网站可以模拟用户在其他网站的操作,执行未授权的请求
  4. 数据泄露:恶意网站可以通过 AJAX 请求获取其他网站的敏感数据

1.4 现代浏览器的表现

1.4.1 跨域请求的处理

对于 AJAX 请求,浏览器的处理方式是:

  1. 请求发送:浏览器会发送请求到服务器
  2. 响应拦截:服务器返回响应后,浏览器检查响应头中的 Access-Control-Allow-Origin 字段
  3. 判断是否允许:如果响应头中没有该字段或该字段不包含当前源,浏览器会拦截响应并抛出错误

1.4.2 跨域存储访问

  • Cookie:默认情况下,不同源无法读取 Cookie,但可以通过设置 document.domain 实现同主域下的跨子域访问
  • LocalStorage:严格遵循同源策略,不同源无法访问
  • IndexedDB:严格遵循同源策略,不同源无法访问

1.4.3 跨域 DOM 访问

当使用 iframe 嵌入不同源的页面时:

  • 主页面无法访问 iframe 中的 DOM 元素
  • iframe 中的页面也无法访问主页面的 DOM 元素
  • 两者之间只能通过 postMessage API 进行通信

1.5 同源策略的例外情况

  1. CDN 资源:通过 <script><link><img><video> 等标签加载的资源不受同源策略限制
  2. 表单提交:通过 <form> 标签提交的跨域请求不受同源策略限制
  3. WebSocket:WebSocket 连接不受同源策略限制
  4. CORS:通过 CORS 机制可以允许跨域 AJAX 请求

1.6 同源策略与现代前端开发

在现代前端开发中,同源策略既是安全保障,也是开发挑战:

  • 微前端架构:需要处理不同子应用之间的跨域通信
  • 前后端分离:需要处理前端应用与后端 API 的跨域请求
  • 第三方集成:需要处理与第三方服务的跨域交互

因此,理解同源策略及其解决方案是现代前端开发者的必备技能。

二、跨域请求资源方案

当我们拥有多个站点,并且这些站点又经常共享相同的数据,那么为每个站点存储一份数据看起来就蠢透了。更好的方案是,我们建设一台静态资源存储服务器,然后所有站点都从这一台服务器上获取资源。很理想的方案,但是现实中首要解决的问题便是浏览器的同源策略,不同域之间无法通过 AJAX 技术获取资源。这是需要跨域获取资源的主要情景。

无论是怎样的跨域资源获取方案,本质上都需要服务器端的支持。跨域获取资源之所以能够成功,本质是服务器默许了你有权限获取相应资源。下面所运用的种种方式,实际上是客户端和服务端互相配合,绕过同源策略进行数据交互的工作。

1. JSONP

正如标题所描述的那样,JSONP 技术是早期的跨域资源获取方式,由于该技术的简单易用,逐渐变得流行,最终成为经典的跨域获取资源方案。JSONP 是“JSON with padding”的简写,我将其翻译为“被包裹的 JSON”。

首先,浏览器的同源策略只是阻止了通过 AJAX 技术跨域获取资源,而没有禁止跨域获取资源这件事本身,因此可以通过<link>标签href属性或<img>标签以及<script>标签中的src属性获取异域的 CSS,JS 资源和图片(其实并不能读取这些资源的内容);其次,<script>标签通过src属性加载的 JS 资源,实际上只是将 JS 文件内容原封不动放置在<scritp>的标签内。

也就是说,如果 sayHi.js 文件只有这样一段代码:

1
2
// sayHi.js
alert("Hi");

当我们在 HTML 文件中,成功加载 sayHi.js 文件时,浏览器只不过是做了如下操作:

1
2
3
4
5
6
7
<!-- 加载前 -->
<script src="sayHi.js"></script>

<!-- 加载后 (为了方便阅读,我格式化了代码)-->
<script src="sayHi.js">
alert('Hi')
</script>

这意味着被加载的文件与 HTML 文件下的其他 JS 文件共享一个全局作用域。也就是说,<scritp>标签加载到的资源是可以被全局作用域下的函数所使用。但如果<script>标签加载到的一些数据并不符合 JavaScript 语法规定的数据类型,JavaScript 就无法处理这些错误,而且就算数据类型正常了,我们还应该将数据存储于一个变量内,然后调用这个变量。

但我们已经约定好了数据的格式为 JSON,这是 JavaScript 可以处理的数据类型,并且 JSON 格式的数据可以承载大量信息。那么至于变量问题,我们则会通过向服务器传入一个函数的方式,将数据变为函数的参数,让我们直接看看 JSONP 的使用方式:

1
2
3
4
5
6
function handleResponse(response) {
alert(`You get the data : ${response}`);
}
const script = document.createElement("script");
script.src = "http://somesite.com/json/?callback=handleResponse";
document.body.insertBefore(script, document.body.firstChild);

很容易看到,我们在 1-3 行中创建了一个函数,该函数用来处理我们将要获得的数据,该函数的参数response即是服务器响应的数据。在 4-6 行中我们所做的是利用 JavaScript 动态生成一个 script 标签,并将其插入 HTML 文档。但是注意第 5 行我们制定的 src 值,在 URL 末尾,我们有这样一段查询参数callback=handleResponse,callback 的值正是我们先前创建的函数。

事情开始变得有些令人困惑了,究竟发生了什么呢?我们如何通过上述代码最终实现跨域获取资源?

答案就藏在服务端的代码中,当服务端支持 JSONP 技术时,会做如下一些设置:

  1. 识别请求的 URL,提取 callback 参数的值,并动态生成一个执行该参数值(一个函数)的 JavaScript 语句;
  2. 将需要返回的数据放入动态生成的函数中,等待其加在到页面时被执行;

此时该文件内容看起来就像这样:

1
handleResponse(response); // response 为被请求的 JSON 格式的数据

因此,当资源加载到位,内容显示在 script 标签内时,浏览器引擎会执行这条语句,我们想要的数据就可以以任何想要的方式处理了。你现在知道为什么这项技术被命名为 JSONP 了吧?那个“padding”指的就是我们的“callback”函数,真是恰如其名。

最后,我们还要对 JSONP 技术再强调两点:

  1. JSONP 技术与 AJAX 技术无关:虽然同样牵扯到跨域获取资源这个主题,但 JSONP 的本质是绕过 AJAX 获取资源的机制,使用原始的src属性获取异域资源;
  2. JSONP 技术存在三点缺陷:
    • 无法发送 POST 请求,也就是说 JSONP 技术只能用于请求异域资源,无法上传数据或修改异域数据;
    • 无法监测 JSONP 请求是否失败;
    • 可能存在安全隐患:JSONP 之所以能成功获取异域服务器资源,靠的是服务器动态生成了回调函数,并在页面中执行,那么如果服务器在原有的回调函数下再添加些别的恶意 JavaScript 代码也会被执行!所以在使用 JSONP 技术时,一定要确保请求资源的服务器是值得信赖的;

1.4 JSONP 的实际应用示例

1.4.1 原生 JavaScript 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function jsonp(url, callbackName, callback) {
// 创建回调函数
window[callbackName] = function(data) {
callback(data);
// 清理
delete window[callbackName];
script.parentNode.removeChild(script);
};

// 创建 script 标签
const script = document.createElement('script');
script.src = `${url}?callback=${callbackName}`;
document.body.appendChild(script);
}

// 使用示例
jsonp('https://api.example.com/data', 'handleData', function(data) {
console.log('获取到数据:', data);
});

1.4.2 jQuery 实现

1
2
3
4
5
6
7
8
9
10
11
12
$.ajax({
url: 'https://api.example.com/data',
dataType: 'jsonp',
jsonp: 'callback', // 回调参数名
jsonpCallback: 'handleData', // 回调函数名
success: function(data) {
console.log('获取到数据:', data);
},
error: function(xhr, status, error) {
console.error('请求失败:', error);
}
});

1.4.3 实际应用场景

天气 API 调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function getWeather(city) {
function handleWeather(data) {
console.log(`城市: ${data.city}`);
console.log(`温度: ${data.temperature}`);
console.log(`天气: ${data.condition}`);
}

const script = document.createElement('script');
script.src = `https://api.weather.com/forecast?city=${city}&callback=handleWeather`;
document.body.appendChild(script);
}

// 调用
getWeather('北京');

1.5 JSONP 的安全性考虑

1.5.1 潜在安全风险

  1. 代码注入:服务器可能返回恶意 JavaScript 代码
  2. CSRF 攻击:攻击者可以诱导用户执行 JSONP 请求
  3. 信息泄露:JSONP 请求可能泄露用户的 Cookie 信息
  4. 回调函数劫持:攻击者可能劫持回调函数执行恶意代码

1.5.2 安全防护措施

  1. 验证回调函数名:限制回调函数名的长度和字符范围
  2. 使用随机回调函数名:每次请求生成不同的回调函数名
  3. 设置超时机制:防止请求无限等待
  4. 验证响应数据:确保返回的数据符合预期格式
  5. 使用 HTTPS:保护数据传输安全
  6. 限制来源域名:服务器端验证请求来源
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 使用随机回调函数名
function safeJsonp(url, callback) {
const callbackName = 'jsonp_' + Math.random().toString(36).substr(2, 9);

window[callbackName] = function(data) {
callback(data);
delete window[callbackName];
script.parentNode.removeChild(script);
};

const script = document.createElement('script');
script.src = `${url}?callback=${callbackName}`;
script.onload = function() {
// 清理
if (window[callbackName]) {
delete window[callbackName];
script.parentNode.removeChild(script);
}
};
script.onerror = function() {
// 错误处理
if (window[callbackName]) {
delete window[callbackName];
}
script.parentNode.removeChild(script);
callback(new Error('JSONP request failed'));
};

document.body.appendChild(script);
}

1.6 JSONP 在现代前端开发中的角色

在现代前端开发中,JSONP 已经逐渐被 CORS 取代,主要原因是:

  1. CORS 更安全:由浏览器和服务器共同验证,安全性更高
  2. CORS 功能更强大:支持所有 HTTP 方法,支持自定义头
  3. CORS 更标准:W3C 标准,浏览器广泛支持

但在以下场景中,JSONP 仍然有其用武之地:

  1. 需要支持旧浏览器:如 IE8-9
  2. 第三方 API 只支持 JSONP:一些老旧的 API 可能只提供 JSONP 接口
  3. 简单的跨域数据获取:对于简单的 GET 请求,JSONP 仍然是一种轻量级的解决方案

虽然存在一些缺陷,但 JSONP 的浏览器兼容性却是非常好的,可以说是一种非常小巧高效的跨域资源获取技术。


2. CORS

CORS 是 W3C 颁布的一个浏览器技术规范,其全称为“跨域资源共享”(Cross-origin resource sharing),它是由 W3C 官方推广的允许通过 AJAX 技术跨域获取资源的规范,因此相较于 JSONP 而言,功能更加强大,使用起来也没有了 hack 的味道。

2.1 CORS 的工作原理

CORS 的核心思想是:服务器通过设置特定的响应头,告诉浏览器允许哪些域的跨域请求

根据 AJAX 请求的复杂程度不同,CORS 请求分为两种类型:

2.1.1 简单请求

满足以下条件的请求为简单请求:

  1. 请求方法:只属于 HEAD、GET、POST 中的一种
  2. 请求头:只包含以下字段:
    • Accept
    • Accept-Language
    • Content-Language
    • Last-Event-ID
    • Content-Type(只能为 application/x-www-form-urlencoded、multipart/form-data 或 text/plain)

简单请求的处理流程:

  1. 浏览器发送请求时,自动添加 Origin 头,值为当前源
  2. 服务器检查 Origin 是否在允许列表中
  3. 服务器返回响应时,添加 Access-Control-Allow-Origin
  4. 浏览器检查响应头,允许或拒绝响应

2.1.2 预检请求(复杂请求)

不满足简单请求条件的请求为复杂请求,会先发送一个 OPTIONS 预检请求:

预检请求的处理流程:

  1. 浏览器发送 OPTIONS 请求,包含:
    • Origin:请求源
    • Access-Control-Request-Method:实际请求的方法
    • Access-Control-Request-Headers:实际请求的自定义头
  2. 服务器检查并返回响应,包含:
    • Access-Control-Allow-Origin:允许的源
    • Access-Control-Allow-Methods:允许的方法
    • Access-Control-Allow-Headers:允许的头
    • Access-Control-Max-Age:预检请求的有效期(秒)
  3. 浏览器根据响应决定是否发送实际请求
  4. 如果允许,发送实际请求;否则,抛出错误

2.2 CORS 响应头

服务器需要设置的主要 CORS 响应头:

响应头说明示例
Access-Control-Allow-Origin允许的源*http://example.com
Access-Control-Allow-Methods允许的 HTTP 方法GET, POST, PUT, DELETE
Access-Control-Allow-Headers允许的请求头Content-Type, Authorization
Access-Control-Allow-Credentials是否允许携带凭证truefalse
Access-Control-Max-Age预检请求有效期86400(24小时)
Access-Control-Expose-Headers允许客户端读取的响应头X-Total-Count, X-Pagination

2.3 现代前端框架中的 CORS 使用

2.3.1 使用 Fetch API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 基本用法
fetch('https://api.example.com/data', {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => console.log(data))
.catch(error => console.error('Error:', error));

// 携带凭证
fetch('https://api.example.com/data', {
method: 'GET',
credentials: 'include' // 携带 Cookie
})
.then(response => response.json())
.then(data => console.log(data));

2.3.2 使用 Axios

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 基本用法
axios.get('https://api.example.com/data')
.then(response => console.log(response.data))
.catch(error => console.error(error));

// 全局配置
axios.defaults.baseURL = 'https://api.example.com';
axios.defaults.headers.common['Authorization'] = 'Bearer token';
axios.defaults.withCredentials = true; // 携带凭证

// 创建实例
const api = axios.create({
baseURL: 'https://api.example.com',
timeout: 10000,
headers: {
'Content-Type': 'application/json'
},
withCredentials: true
});

api.get('/data')
.then(response => console.log(response.data));

2.3.3 React 中使用 CORS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// 使用 fetch
function DataFetching() {
const [data, setData] = useState(null);
const [error, setError] = useState(null);

useEffect(() => {
fetch('https://api.example.com/data')
.then(response => response.json())
.then(data => setData(data))
.catch(error => setError(error));
}, []);

return (
<div>
{data && <pre>{JSON.stringify(data, null, 2)}</pre>}
{error && <p>Error: {error.message}</p>}
</div>
);
}

// 使用 axios
import axios from 'axios';

function DataFetchingWithAxios() {
const [data, setData] = useState(null);
const [error, setError] = useState(null);

useEffect(() => {
axios.get('https://api.example.com/data')
.then(response => setData(response.data))
.catch(error => setError(error));
}, []);

return (
<div>
{data && <pre>{JSON.stringify(data, null, 2)}</pre>}
{error && <p>Error: {error.message}</p>}
</div>
);
}

2.3.4 Vue 中使用 CORS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// 在组件中使用
<template>
<div>
<div v-if="data">
<pre>{{ JSON.stringify(data, null, 2) }}</pre>
</div>
<div v-if="error">
Error: {{ error.message }}
</div>
</div>
</template>

<script>
import axios from 'axios';

export default {
data() {
return {
data: null,
error: null
};
},
mounted() {
// 使用 fetch
fetch('https://api.example.com/data')
.then(response => response.json())
.then(data => this.data = data)
.catch(error => this.error = error);

// 或使用 axios
// axios.get('https://api.example.com/data')
// .then(response => this.data = response.data)
// .catch(error => this.error = error);
}
};
</script>

// 在 Vue 3 Composition API 中使用
<script setup>
import { ref, onMounted } from 'vue';
import axios from 'axios';

const data = ref(null);
const error = ref(null);

onMounted(() => {
axios.get('https://api.example.com/data')
.then(response => data.value = response.data)
.catch(err => error.value = err);
});
</script>

2.4 服务端 CORS 配置示例

2.4.1 Node.js (Express)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const express = require('express');
const cors = require('cors');
const app = express();

// 基本配置
app.use(cors());

// 详细配置
app.use(cors({
origin: 'http://localhost:3000', // 允许的源
methods: ['GET', 'POST', 'PUT', 'DELETE'], // 允许的方法
allowedHeaders: ['Content-Type', 'Authorization'], // 允许的头
credentials: true, // 允许携带凭证
maxAge: 86400 // 预检请求有效期
}));

app.get('/data', (req, res) => {
res.json({ message: 'Hello from CORS-enabled server!' });
});

app.listen(5000, () => {
console.log('Server running on port 5000');
});

2.4.2 Python (Flask)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from flask import Flask, jsonify
from flask_cors import CORS

app = Flask(__name__)

# 基本配置
CORS(app)

# 详细配置
CORS(app, resources={
r"/*": {
"origins": "http://localhost:3000",
"methods": ["GET", "POST", "PUT", "DELETE"],
"allow_headers": ["Content-Type", "Authorization"],
"supports_credentials": True
}
})

@app.route('/data')
def get_data():
return jsonify({"message": "Hello from CORS-enabled server!"})

if __name__ == '__main__':
app.run(port=5000)

2.5 CORS 的优势与注意事项

优势

  • 功能强大:支持所有 HTTP 方法和自定义头
  • 安全可靠:由浏览器和服务器共同验证
  • 使用简单:现代前端框架和库都内置支持
  • 标准化:W3C 标准,浏览器广泛支持

注意事项

  • 凭证处理:当需要携带 Cookie 时,Access-Control-Allow-Origin 不能使用 *
  • 预检请求:复杂请求会发送 OPTIONS 预检,可能影响性能
  • 缓存优化:使用 Access-Control-Max-Age 减少预检请求
  • 安全配置:只允许必要的源和方法,避免过度开放

3. WebSocket

WebSocket 是一种在单个 TCP 连接上进行全双工通讯的协议。HTML5 标准之所以提出了这种新的互联网通信协议,是为了弥补在服务端与客户端的双向通信时使用 HTTP 协议通信的一些不足。但这并不意味 WebSocket 协议可以完全取代 HTTP 协议,两者都有各自擅长的领域,时不时还能一同协作解决难题。

当我们使用 HTTP 协议时,客户端与服务端的通信模式始终是由客户端向服务端发送请求,服务端只负责验证请求并返回响应。而客户端发送的每一个请求,对于服务端而言都是全新的,也即是说 HTTP 协议是无状态的。乍看似乎不合理,但这种设计却使服务器的工作变得简单可控,提升了服务器的工作效率。

但这样的设计仍然存在两个问题:

  1. 每一个请求都需要身份验证,这对于用户而言意味着需要在每一次发送请求时输入身份信息;
  2. 当客户端所请求的资源是动态生成时,客户端无法在资源生成时得到通知;

对于前者,可以使用 Cookie 解决,而对于后者,则轮到 WebSocket 大显身手。在讨论 WebSocket 之前,让我们先稍微绕点路,谈谈“Cookie”是如何解决“每一个请求都需要身份验证”的问题的。

Cookie:为 HTTP 协议添加状态

HTTP 协议下,客户端与服务端的通信是无状态的,即如果服务器中的某部分资源是由特定客户专属的,那么每当这个客户想要获取资源时,都需要先在浏览器中输入账号密码,然后再发送请求,并在被服务器识别身份信息成功后获取请求的资源。为了避免这般繁琐的操作,我们引入了 Cookie:它既可以存储在浏览器,又会被浏览器发送 HTTP 请求时默认发送至服务端,并且还受浏览器同源策略保护,帮助我们提高发起一次请求的效率。

在有了 Cookie 后,我们可以在一次会话中(从用户登录到浏览器关闭)只输入一次账号密码,然后将其保存在 Cookie 中,在整个会话期间,Cookie 都会伴随着 HTTP 请求的发送被服务器识别,从而避免了重复输入身份信息。

而且基于 Cookie 可以保存在浏览器内并在浏览器发送 HTTP 请求时默认携带的特性,服务端也可以操作 Cookie。Cookie 还可以帮助我们节省网络请求的发起数量。例如,在制作一个购物网站时,我们不希望用户在每添加一个商品到购物车就向服务器发送一个请求(请求数量越少,服务器压力就越小),此时我们可以将添加商品所导致的数据变动存储在 Cookie 内,然后等待下次发送请求时,一并发送给服务器处理。Cookie 的出现,为无状态的 HTTP 协议通信添加了状态。

Cookie 多数情况下都保存着用户的身份信息,因此对于 Cookie 的恶意攻击层出不穷。其本质上就是想要获得用户的 Cookie,再利用其中的身份信息伪装成用户获取相应资源,而浏览器的同源策略本质上就是保护用户的 Cookie 信息不会泄露。

WebSocket:让服务器也动起来

客户端无法获知请求的动态资源何时到位。有时候客户端想要请求的资源,服务器需要一定时间后才能返回(比如该资源依赖于其他服务器的计算返回结果),由于在 HTTP 协议下,网络通信是单向的,因此服务器并不具备当资源准备就绪时,通知浏览器的功能(要保障服务器的工作效率)。因此,基于 HTTP 协议通常的做法是,设置一个定时器,每隔一定时间由浏览器向服务器发送一次请求以探测资源是否到位。这种做法显然浪费了很多请求或者说带宽(每个请求都要携带 Cookie 和报头,这些都会占用带宽传输),低效且不够优雅。

我们希望当服务器资源到位时,能主动通知浏览器并返回相应资源。为了实现这一点,HTML5 标准推出了 WebSocket 协议,使浏览器和服务器实现了双向通信。除了 IE9 及以下的 IE 浏览器,所有的浏览器都支持 WebSocket 协议。

客户端告知服务端要升级为 WebSocket 协议的报头:

1
2
3
4
5
6
7
8
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
Origin: http://example.com

服务端向客户端返回的响应报头:

1
2
3
4
5
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
Sec-WebSocket-Protocol: chat

客户端如何发起 WebSocket 请求

像发起 AJAX 请求一样,发起 WebSocket 请求需要借助浏览器提供的 WebSocket 对象,该对象提供了用于创建和管理 WebSocket 连接,以及通过该连接收发数据的 API。所有的浏览器都默认提供了 WebSocket 对象。

和使用 XHRHttpRequest 对象一样,我们首先要实例化一个 WebSocket 对象:

1
var ws = new WebSocket("wss://echo.websocket.org");

传入的参数为响应 WebSocket 请求的地址。

与 AJAX 类似的是,WebSocket 对象也有一个 readyState 属性,用来表示对象实例当前所处的链接状态,有四个值:

  • 0:表示正在连接中(CONNECTING);
  • 1:表示连接成功,可以通信(OPEN);
  • 2:表示连接正在关闭(CLOSING);
  • 3:表示连接已经关闭或打开连接失败(CLOSED);

可以通过判断这个值来执行相应的代码。

除此之外,WebSocket对象还提供一系列事件属性来控制连接过程中的通信行为:

  • onopen:用于指定连接成功后的回调函数;
  • onclose:用于指定连接关闭后的回调函数;
  • onmessage:用于指定收到服务器数据后的回调函数;
  • onerror:用于指定报错时的回调函数;

通过.send()方法,我们拥有了向服务器发送数据的能力(WebSocket 还允许我们发送二进制数据):

1
ws.send("Hi, server!");

WebSocket对象的bufferedAmount属性的返回值表示了还有多少字节的二进制数据没有发送出去,所以可以通过判断该值是否为 0 而确定数据是否发送结束。

1
2
3
4
5
6
7
8
var data = new ArrayBuffer(1000000);
ws.send(data);

if (socket.bufferedAmount === 0) {
// 发送完毕
} else {
// 还在发送
}

WebSocket 是如何绕过浏览器的同源策略实现跨域资源共享,那就是当客户端与服务端创建 WebSocket 连接后,本身就可以天然的实现跨域资源共享,WebSocket 协议本身就不受浏览器同源策略的限制(同源策略只限制了跨域的 AJAX 请求)。

但如果没有浏览器同源策略的限制,那么用户的 Cookie 安全又由谁来保护呢?Cookie 的存在就是为了给无状态的 HTTP 协议通讯添加状态,因为 Cookie 是明文传输的,且通常包含用户的身份信息,所以非常受到网络攻击者的“关注”。但是想想 WebSocket 协议下的通讯机制,客户端和服务端一旦建立连接,就可以顺畅互发数据,因此 WebSocket 协议本身就是“有状态的”,不需要 Cookie 的帮忙,既然没有 Cookie,自然也不需要同源策略去保护,因此其实这个问题也不成立。

3.1 WebSocket 的实际应用场景

3.1.1 实时聊天应用

前端实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 创建 WebSocket 连接
const ws = new WebSocket('wss://chat.example.com');

// 连接成功
ws.onopen = function() {
console.log('WebSocket 连接已建立');
// 发送登录信息
ws.send(JSON.stringify({ type: 'login', username: 'user123' }));
};

// 接收消息
ws.onmessage = function(event) {
const message = JSON.parse(event.data);
console.log('收到消息:', message);
// 处理消息
if (message.type === 'chat') {
renderMessage(message);
} else if (message.type === 'userList') {
updateUserList(message.users);
}
};

// 发送消息
function sendMessage(content) {
ws.send(JSON.stringify({ type: 'chat', content }));
}

// 连接关闭
ws.onclose = function() {
console.log('WebSocket 连接已关闭');
};

// 连接错误
ws.onerror = function(error) {
console.error('WebSocket 连接错误:', error);
};

服务端实现(Node.js + Socket.io)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');

const app = express();
const server = http.createServer(app);
const io = new Server(server, {
cors: {
origin: '*', // 允许所有源
methods: ['GET', 'POST']
}
});

const users = new Map();

io.on('connection', (socket) => {
console.log('新用户连接:', socket.id);

// 处理登录
socket.on('login', (data) => {
users.set(socket.id, data.username);
// 广播用户列表
io.emit('userList', {
users: Array.from(users.values())
});
// 广播新用户加入
socket.broadcast.emit('chat', {
type: 'system',
content: `${data.username} 加入了聊天室`
});
});

// 处理聊天消息
socket.on('chat', (data) => {
const username = users.get(socket.id);
io.emit('chat', {
type: 'chat',
username,
content: data.content,
timestamp: new Date().toISOString()
});
});

// 处理断开连接
socket.on('disconnect', () => {
const username = users.get(socket.id);
users.delete(socket.id);
// 广播用户列表
io.emit('userList', {
users: Array.from(users.values())
});
// 广播用户离开
socket.broadcast.emit('chat', {
type: 'system',
content: `${username} 离开了聊天室`
});
});
});

server.listen(3000, () => {
console.log('服务器运行在端口 3000');
});

3.1.2 实时数据更新

股票行情实时更新

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const ws = new WebSocket('wss://market.example.com');

ws.onopen = function() {
console.log('连接到行情服务器');
// 订阅股票
ws.send(JSON.stringify({ type: 'subscribe', symbols: ['AAPL', 'MSFT', 'GOOGL'] }));
};

ws.onmessage = function(event) {
const data = JSON.parse(event.data);
if (data.type === 'priceUpdate') {
updateStockPrice(data.symbol, data.price, data.timestamp);
}
};

function updateStockPrice(symbol, price, timestamp) {
const element = document.getElementById(`stock-${symbol}`);
if (element) {
element.textContent = `$${price.toFixed(2)}`;
// 添加价格变动动画
element.classList.add('price-update');
setTimeout(() => {
element.classList.remove('price-update');
}, 500);
}
}

3.1.3 在线游戏

多人在线游戏的实时通信

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const ws = new WebSocket('wss://game.example.com');

ws.onopen = function() {
console.log('连接到游戏服务器');
// 加入游戏房间
ws.send(JSON.stringify({ type: 'joinRoom', roomId: 'room123', playerName: 'player1' }));
};

ws.onmessage = function(event) {
const data = JSON.parse(event.data);
switch (data.type) {
case 'gameState':
updateGameState(data.state);
break;
case 'playerMove':
updatePlayerPosition(data.playerId, data.position);
break;
case 'chat':
addChatMessage(data.playerName, data.message);
break;
}
};

// 发送玩家移动
function sendPlayerMove(position) {
ws.send(JSON.stringify({ type: 'playerMove', position }));
}

// 发送聊天消息
function sendChatMessage(message) {
ws.send(JSON.stringify({ type: 'chat', message }));
}

3.2 现代前端框架中的 WebSocket 使用

3.2.1 React 中使用 WebSocket

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import React, { useEffect, useRef, useState } from 'react';

function ChatApp() {
const [messages, setMessages] = useState([]);
const [input, setInput] = useState('');
const wsRef = useRef(null);

useEffect(() => {
// 创建 WebSocket 连接
wsRef.current = new WebSocket('wss://chat.example.com');

const ws = wsRef.current;

ws.onopen = () => {
console.log('WebSocket 连接已建立');
ws.send(JSON.stringify({ type: 'login', username: 'react-user' }));
};

ws.onmessage = (event) => {
const message = JSON.parse(event.data);
setMessages(prev => [...prev, message]);
};

ws.onclose = () => {
console.log('WebSocket 连接已关闭');
};

ws.onerror = (error) => {
console.error('WebSocket 连接错误:', error);
};

// 清理函数
return () => {
if (wsRef.current) {
wsRef.current.close();
}
};
}, []);

const sendMessage = () => {
if (input.trim() && wsRef.current) {
wsRef.current.send(JSON.stringify({ type: 'chat', content: input }));
setInput('');
}
};

return (
<div>
<div className="messages">
{messages.map((msg, index) => (
<div key={index} className={`message ${msg.type}`}>
{msg.username && <span className="username">{msg.username}: </span>}
{msg.content}
</div>
))}
</div>
<div className="input-area">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
/>
<button onClick={sendMessage}>发送</button>
</div>
</div>
);
}

export default ChatApp;

3.2.2 Vue 中使用 WebSocket

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
<template>
<div>
<div class="messages">
<div v-for="(msg, index) in messages" :key="index" :class="['message', msg.type]">
<span v-if="msg.username" class="username">{{ msg.username }}: </span>
{{ msg.content }}
</div>
</div>
<div class="input-area">
<input
type="text"
v-model="input"
@keyup.enter="sendMessage"
/>
<button @click="sendMessage">发送</button>
</div>
</div>
</template>

<script>
export default {
data() {
return {
messages: [],
input: '',
ws: null
};
},
mounted() {
// 创建 WebSocket 连接
this.ws = new WebSocket('wss://chat.example.com');

this.ws.onopen = () => {
console.log('WebSocket 连接已建立');
this.ws.send(JSON.stringify({ type: 'login', username: 'vue-user' }));
};

this.ws.onmessage = (event) => {
const message = JSON.parse(event.data);
this.messages.push(message);
};

this.ws.onclose = () => {
console.log('WebSocket 连接已关闭');
};

this.ws.onerror = (error) => {
console.error('WebSocket 连接错误:', error);
};
},
beforeUnmount() {
if (this.ws) {
this.ws.close();
}
},
methods: {
sendMessage() {
if (this.input.trim() && this.ws) {
this.ws.send(JSON.stringify({ type: 'chat', content: this.input }));
this.input = '';
}
}
}
};
</script>

<script setup>
// Vue 3 Composition API
import { ref, onMounted, onBeforeUnmount } from 'vue';

const messages = ref([]);
const input = ref('');
const ws = ref(null);

onMounted(() => {
ws.value = new WebSocket('wss://chat.example.com');

ws.value.onopen = () => {
console.log('WebSocket 连接已建立');
ws.value.send(JSON.stringify({ type: 'login', username: 'vue3-user' }));
};

ws.value.onmessage = (event) => {
const message = JSON.parse(event.data);
messages.value.push(message);
};

ws.value.onclose = () => {
console.log('WebSocket 连接已关闭');
};

ws.value.onerror = (error) => {
console.error('WebSocket 连接错误:', error);
};
});

onBeforeUnmount(() => {
if (ws.value) {
ws.value.close();
}
});

function sendMessage() {
if (input.value.trim() && ws.value) {
ws.value.send(JSON.stringify({ type: 'chat', content: input.value }));
input.value = '';
}
}
</script>

3.3 WebSocket 的优势与注意事项

优势

  • 实时性:全双工通信,服务器可以主动推送数据
  • 低延迟:建立一次连接,减少了 HTTP 请求的开销
  • 跨域支持:不受同源策略限制
  • 二进制支持:可以直接发送二进制数据
  • 效率高:连接复用,减少了网络开销

注意事项

  • 连接管理:需要处理连接的建立、关闭和重连
  • 心跳机制:需要实现心跳机制防止连接被断开
  • 数据格式:需要定义清晰的数据格式和协议
  • 错误处理:需要处理各种错误情况
  • 服务器负载:需要考虑服务器的并发处理能力
  • 安全性:需要实现身份验证和数据加密

3.4 WebSocket 与 HTTP 的对比

特性WebSocketHTTP
连接类型长连接,全双工短连接,半双工
通信方式服务器可以主动推送只能客户端请求
头部开销建立连接时一次开销每次请求都有开销
实时性
适用场景实时通信、游戏、聊天常规数据请求
跨域支持天然支持需要 CORS

4. postMessage

JSONP,CORS 与 WebSocket 这些跨域技术都只适用于客户端请求异域服务端资源的情景。而有时候我们需要在异域的两个客户端之间共享数据,例如页面与内嵌 iframe 窗口通讯,页面与新打开异域页面通讯。

使用 postMessage 技术实现跨域的原理非常简单,一方面,主窗口通过 postMessageAPI 向异域的窗口发送数据,另一方面我们在异域的页面脚本中始终监听 message 事件,当获取主窗口数据时处理数据或者以同样的方式返回数据从而实现跨窗口的异域通讯。

让我们用具体的业务场景与代码进一步说明,假如我们的页面现在有两个窗口,窗口 1 命名为“window_1”,窗口 2 命名为“window_2”,当然,窗口 1 与窗口 2 的“域”是不同的,我们的需求是由窗口 1 向窗口 2 发送数据,而当窗口 2 接收到数据时,将数据再返回给窗口 1。先让我们看看窗口 1script标签内的代码:

1
2
// window_1 域名为 http://winodow1.com:8080
window.postMessage("Hi, How are you!", "http://window2.com:8080");

可以看到,postMessage函数接收两个参数,第一个为要发送的信息(可以是任何 JavaScript 类型数据,但部分浏览器只支持字符串格式),第二个为信息发送的目标地址。让我们再看看窗口 2script标签内的代码:

1
2
3
4
5
6
7
8
9
10
11
// window_2 域名为 http://window2.com:8080
window.addEventListener("message", receiveMessage, false);

function receiveMessage(event) {
// 对于 Chorme,origin 属性为 originalEvent.origin 属性
var origin = event.origin || event.originalEvent.origin;
if (origin !== "http://window1.com:8080") {
return;
}
window.postMessage("I'm ok", "http://window1.com:8080");
}

我们在 window 上绑定了一个事件监听函数,监听message事件。一旦我们接收到其他域通过postMessage发送的信息,就会触发receiveMessage回调函数。该函数会首先检查发送信息的域是否是我们想要的,如果验证成功则会向窗口 1 发送一条消息。

一方发送信息,一方捕捉信息。但是所有跨域技术都需要关注安全问题。postMessage 技术之所以能实现跨域资源共享,本质上依赖于客户端脚本设置了相应的message监听事件。因此只要有消息通过postMessage发送过来,我们的脚本都会接收并进行处理。由于任何域都可以通过postMessage发送跨域信息,因此对于设置了事件监听器的页面来说,判断到达页面的信息是否是安全的是非常重要的事,因为我们并不想要执行有危险的数据。

那么如何鉴别发送至页面的信息呢?答案是通过 message事件监听函数的事件对象,我们称它为event,该对象有三个属性:

  • data:值为其他 window 传递过来的对象;
  • origin:值为消息发送方窗口的域名;
  • source:值为对发送消息的窗口对象的引用;

应该着重检测event对象的origin属性,建立一个白名单对origin属性进行检测通常是一个明智的做法。

最后,除了 IE8 以下的 IE 浏览器,所有的浏览器都支持 postMessage 方法!

4.1 postMessage 的实际应用场景

4.1.1 页面与 iframe 通信

主页面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<!DOCTYPE html>
<html>
<head>
<title>主页面</title>
</head>
<body>
<h1>主页面</h1>
<iframe id="childFrame" src="https://example.com/iframe.html" width="400" height="300"></iframe>
<button id="sendBtn">向 iframe 发送消息</button>
<div id="response"></div>

<script>
const iframe = document.getElementById('childFrame');
const sendBtn = document.getElementById('sendBtn');
const responseDiv = document.getElementById('response');

// 向 iframe 发送消息
sendBtn.addEventListener('click', () => {
iframe.contentWindow.postMessage(
{ type: 'greeting', message: 'Hello from parent!' },
'https://example.com'
);
});

// 接收 iframe 的响应
window.addEventListener('message', (event) => {
// 验证消息来源
if (event.origin !== 'https://example.com') return;

if (event.data.type === 'response') {
responseDiv.textContent = `收到 iframe 响应: ${event.data.message}`;
}
});
</script>
</body>
</html>

iframe 页面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<!DOCTYPE html>
<html>
<head>
<title>iframe 页面</title>
</head>
<body>
<h2>iframe 页面</h2>
<div id="message"></div>
<button id="replyBtn">回复主页面</button>

<script>
const messageDiv = document.getElementById('message');
const replyBtn = document.getElementById('replyBtn');

// 接收主页面的消息
window.addEventListener('message', (event) => {
// 验证消息来源
if (event.origin !== 'https://parent.com') return;

if (event.data.type === 'greeting') {
messageDiv.textContent = `收到主页面消息: ${event.data.message}`;
}
});

// 回复主页面
replyBtn.addEventListener('click', () => {
window.parent.postMessage(
{ type: 'response', message: 'Hello from iframe!' },
'https://parent.com'
);
});
</script>
</body>
</html>

4.1.2 页面与新打开窗口通信

主页面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 打开新窗口
const newWindow = window.open('https://example.com/new-window.html', '_blank', 'width=600,height=400');

// 向新窗口发送消息
newWindow.postMessage(
{ type: 'userData', user: { id: 1, name: 'John' } },
'https://example.com'
);

// 接收新窗口的消息
window.addEventListener('message', (event) => {
if (event.origin !== 'https://example.com') return;

if (event.data.type === 'confirmation') {
console.log('新窗口确认收到数据:', event.data.message);
}
});

新窗口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 接收主窗口的消息
window.addEventListener('message', (event) => {
if (event.origin !== 'https://parent.com') return;

if (event.data.type === 'userData') {
console.log('收到用户数据:', event.data.user);

// 发送确认消息
event.source.postMessage(
{ type: 'confirmation', message: '数据已收到' },
event.origin
);
}
});

4.1.3 微前端架构中的通信

在微前端架构中,不同的子应用可能运行在不同的域下,postMessage 是一种常用的通信方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 主应用发送消息给子应用
const iframe = document.getElementById('microApp');
iframe.contentWindow.postMessage(
{ type: 'appState', state: { theme: 'dark', language: 'zh-CN' } },
'*' // 或者指定具体的域
);

// 子应用接收消息
window.addEventListener('message', (event) => {
if (event.data.type === 'appState') {
// 更新应用状态
updateAppState(event.data.state);
}
});

// 子应用发送消息给主应用
window.parent.postMessage(
{ type: 'appReady', appName: 'userManagement' },
'*' // 或者指定具体的域
);

4.2 现代前端框架中的 postMessage 使用

4.2.1 React 中使用 postMessage

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import React, { useEffect, useRef, useState } from 'react';

function ParentComponent() {
const iframeRef = useRef(null);
const [message, setMessage] = useState('');
const [response, setResponse] = useState('');

useEffect(() => {
// 接收消息
const handleMessage = (event) => {
if (event.origin !== 'https://example.com') return;
setResponse(event.data.message);
};

window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, []);

const sendMessage = () => {
if (iframeRef.current) {
iframeRef.current.contentWindow.postMessage(
{ message },
'https://example.com'
);
}
};

return (
<div>
<h1>Parent Component</h1>
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
<button onClick={sendMessage}>Send to Iframe</button>
<div>Response: {response}</div>
<iframe
ref={iframeRef}
src="https://example.com/iframe.html"
width="400"
height="300"
/>
</div>
);
}

export default ParentComponent;

4.2.2 Vue 中使用 postMessage

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
<template>
<div>
<h1>Parent Component</h1>
<input v-model="message" type="text" />
<button @click="sendMessage">Send to Iframe</button>
<div>Response: {{ response }}</div>
<iframe
ref="iframe"
src="https://example.com/iframe.html"
width="400"
height="300"
/>
</div>
</template>

<script>
export default {
data() {
return {
message: '',
response: ''
};
},
mounted() {
// 接收消息
window.addEventListener('message', this.handleMessage);
},
beforeUnmount() {
window.removeEventListener('message', this.handleMessage);
},
methods: {
sendMessage() {
if (this.$refs.iframe) {
this.$refs.iframe.contentWindow.postMessage(
{ message: this.message },
'https://example.com'
);
}
},
handleMessage(event) {
if (event.origin !== 'https://example.com') return;
this.response = event.data.message;
}
}
};
</script>

<script setup>
// Vue 3 Composition API
import { ref, onMounted, onBeforeUnmount } from 'vue';

const iframe = ref(null);
const message = ref('');
const response = ref('');

const handleMessage = (event) => {
if (event.origin !== 'https://example.com') return;
response.value = event.data.message;
};

const sendMessage = () => {
if (iframe.value) {
iframe.value.contentWindow.postMessage(
{ message: message.value },
'https://example.com'
);
}
};

onMounted(() => {
window.addEventListener('message', handleMessage);
});

onBeforeUnmount(() => {
window.removeEventListener('message', handleMessage);
});
</script>

4.3 postMessage 的安全性最佳实践

4.3.1 验证消息来源

1
2
3
4
5
6
7
8
9
10
window.addEventListener('message', (event) => {
// 验证消息来源
const allowedOrigins = ['https://trusted-domain.com', 'https://another-trusted-domain.com'];
if (!allowedOrigins.includes(event.origin)) {
return;
}

// 处理消息
console.log('收到来自可信域的消息:', event.data);
});

4.3.2 验证消息格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
window.addEventListener('message', (event) => {
if (event.origin !== 'https://trusted-domain.com') return;

// 验证消息格式
if (typeof event.data !== 'object' || event.data === null) {
return;
}

// 验证消息类型
if (!event.data.type) {
return;
}

// 处理不同类型的消息
switch (event.data.type) {
case 'greeting':
console.log('收到问候:', event.data.message);
break;
case 'data':
console.log('收到数据:', event.data.payload);
break;
default:
console.warn('未知消息类型:', event.data.type);
}
});

4.3.3 使用结构化克隆

postMessage 支持结构化克隆算法,可以传递复杂的数据结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 发送复杂数据
const complexData = {
user: {
id: 1,
name: 'John',
address: {
street: '123 Main St',
city: 'New York'
}
},
timestamp: new Date(),
numbers: [1, 2, 3, 4, 5],
blob: new Blob(['Hello'], { type: 'text/plain' })
};

window.postMessage(complexData, 'https://example.com');

// 接收数据
window.addEventListener('message', (event) => {
if (event.origin !== 'https://example.com') return;
console.log('收到复杂数据:', event.data);
console.log('数据类型:', typeof event.data);
console.log('用户名称:', event.data.user.name);
});

4.4 postMessage 的高级用法

4.4.1 传输对象

对于大型数据,可以使用 Transferable 对象来提高性能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 创建 ArrayBuffer
const buffer = new ArrayBuffer(1024 * 1024); // 1MB
const view = new Uint8Array(buffer);
for (let i = 0; i < view.length; i++) {
view[i] = i % 256;
}

// 发送数据(传输所有权)
window.postMessage(buffer, 'https://example.com', [buffer]);

// 接收数据
window.addEventListener('message', (event) => {
if (event.origin !== 'https://example.com') return;
if (event.data instanceof ArrayBuffer) {
console.log('收到 ArrayBuffer,大小:', event.data.byteLength);
const view = new Uint8Array(event.data);
console.log('第一个字节:', view[0]);
}
});

4.4.2 实现双向通信

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// 主窗口
const iframe = document.getElementById('iframe');
let messageId = 0;
const messagePromises = new Map();

// 发送消息并返回 Promise
function sendMessageToIframe(data) {
return new Promise((resolve) => {
const id = ++messageId;
messagePromises.set(id, resolve);

iframe.contentWindow.postMessage(
{ ...data, id },
'https://example.com'
);
});
}

// 接收响应
window.addEventListener('message', (event) => {
if (event.origin !== 'https://example.com') return;

const { id, response } = event.data;
if (id && messagePromises.has(id)) {
messagePromises.get(id)(response);
messagePromises.delete(id);
}
});

// 使用
async function fetchDataFromIframe() {
const result = await sendMessageToIframe({ type: 'fetchData', url: '/api/data' });
console.log('获取到数据:', result);
}

// iframe 窗口
window.addEventListener('message', (event) => {
if (event.origin !== 'https://parent.com') return;

const { id, type, url } = event.data;

if (type === 'fetchData') {
// 模拟获取数据
fetch(url)
.then(response => response.json())
.then(data => {
event.source.postMessage(
{ id, response: data },
event.origin
);
});
}
});

4.5 postMessage 的优势与限制

优势

  • 跨域支持:天然支持跨域通信
  • 灵活性:可以在任何窗口之间通信
  • 数据类型:支持多种数据类型,包括复杂对象
  • 安全性:可以通过 origin 验证确保安全
  • 浏览器支持:支持所有现代浏览器

限制

  • 性能:对于大量数据传输可能存在性能问题
  • 安全性:需要手动验证消息来源和格式
  • 兼容性:IE8-9 只支持字符串格式的数据
  • 调试:调试跨窗口通信可能比较困难
  • 顺序:消息传递的顺序可能不保证

5. 代理服务器

代理服务器是前端开发中最常用的跨域解决方案之一,其核心思想是:利用服务器端请求不受同源策略限制的特点,让前端先请求到同源的代理服务器,再由代理服务器转发请求到目标服务器,最后将响应返回给前端。

5.1 代理服务器的工作原理

  1. 前端请求:前端向同源的代理服务器发送请求
  2. 代理转发:代理服务器将请求转发到目标服务器
  3. 响应返回:目标服务器返回响应给代理服务器
  4. 代理响应:代理服务器将响应返回给前端

由于前端的请求是发送到同源的代理服务器,因此不受同源策略的限制。

5.2 现代构建工具的代理配置

5.2.1 Vite 代理配置

Vite 是现代前端构建工具,提供了简单的代理配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// vite.config.js
import { defineConfig } from 'vite';

export default defineConfig({
server: {
proxy: {
// 配置多个代理
'/api': {
target: 'https://api.example.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
// 配置 HTTPS
// secure: false,
// 配置 WebSocket
// ws: true
},
'/auth': {
target: 'https://auth.example.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/auth/, '')
}
}
}
});

使用示例

1
2
3
4
5
6
// 前端代码
fetch('/api/users')
.then(response => response.json())
.then(data => console.log(data));

// 实际请求:https://api.example.com/users

5.2.2 Webpack 代理配置

Webpack Dev Server 也提供了代理功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// webpack.config.js
const path = require('path');

module.exports = {
// ...
devServer: {
static: {
directory: path.join(__dirname, 'public'),
},
proxy: {
'/api': {
target: 'https://api.example.com',
changeOrigin: true,
pathRewrite: {
'^/api': ''
}
}
},
port: 3000
}
};

5.2.3 Vue CLI 代理配置

在 Vue CLI 项目中,可以在 vue.config.js 中配置代理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// vue.config.js
module.exports = {
devServer: {
host: "localhost",
port: 8080,
proxy: {
//设置代理
"/api": {
target: "http://0.0.0.0:3000", // 要跨域的域名
changeOrigin: true, // 是否开启跨域
ws: true, // proxy websockets
pathRewrite: {
// 重写接口地址
"^/api": "" //通配符
}
}
}
}
};

5.2.4 Create React App 代理配置

在 Create React App 中,可以在 package.json 中配置代理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// package.json
{
"name": "my-app",
"version": "0.1.0",
"private": true,
"proxy": "https://api.example.com",
"dependencies": {
// ...
}
}

// 或使用 setupProxy.js 文件
// src/setupProxy.js
const { createProxyMiddleware } = require('http-proxy-middleware');

module.exports = function(app) {
app.use(
'/api',
createProxyMiddleware({
target: 'https://api.example.com',
changeOrigin: true,
pathRewrite: {
'^/api': ''
}
})
);
};

5.3 服务器端代理配置

5.3.1 Nginx 反向代理

Nginx 是常用的反向代理服务器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
server {
listen 80;
server_name localhost;

location /api {
proxy_pass https://api.example.com;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}

# 静态资源
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
}

5.3.2 Node.js 代理服务器

使用 Express 和 http-proxy-middleware 创建代理服务器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// server.js
const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const app = express();

// 静态资源
app.use(express.static('build'));

// 代理配置
app.use('/api', createProxyMiddleware({
target: 'https://api.example.com',
changeOrigin: true,
pathRewrite: {
'^/api': ''
}
}));

// 处理所有请求,返回 index.html
app.get('*', (req, res) => {
res.sendFile(__dirname + '/build/index.html');
});

app.listen(3000, () => {
console.log('Server running on port 3000');
});

5.4 代理服务器的优势与注意事项

优势

  • 开发环境友好:无需修改后端代码,前端即可实现跨域请求
  • 配置简单:现代构建工具都提供了简洁的代理配置
  • 安全性高:敏感信息(如 API 密钥)可以存储在服务器端
  • 性能优化:可以缓存响应,减少重复请求

注意事项

  • 生产环境部署:需要在生产环境中配置相应的代理服务器
  • 路径重写:需要正确配置路径重写,确保请求发送到正确的端点
  • HTTPS 配置:如果目标服务器使用 HTTPS,需要正确配置代理的 SSL 选项
  • WebSocket 支持:如果需要 WebSocket 通信,需要启用相应的代理配置

5.5 实际应用场景

5.5.1 前后端分离开发

在前后端分离开发中,前端和后端可能运行在不同的域名或端口上。使用代理服务器可以让前端在开发时直接访问后端 API,而无需担心跨域问题。

5.5.2 多 API 集成

当应用需要集成多个不同域名的 API 时,可以通过代理服务器将所有 API 请求统一到一个前缀下,简化前端代码。

5.5.3 解决 CORS 限制

对于一些不支持 CORS 的旧 API,可以通过代理服务器转发请求,绕过浏览器的同源策略限制。

使用示例

1
2
// 例如本地服务端口 localhost:8080
axios.get("http://localhost:8080/api/login");

六、现代前端框架的跨域处理

6.1 React 跨域处理

6.1.1 Create React App 代理配置

在 Create React App 中,可以通过以下方式配置跨域:

方法一:在 package.json 中配置

1
2
3
4
5
6
7
8
9
10
{
"name": "my-app",
"version": "0.1.0",
"private": true,
"proxy": "https://api.example.com",
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
}

方法二:使用 setupProxy.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// src/setupProxy.js
const { createProxyMiddleware } = require('http-proxy-middleware');

module.exports = function(app) {
app.use(
'/api',
createProxyMiddleware({
target: 'https://api.example.com',
changeOrigin: true,
pathRewrite: {
'^/api': ''
}
})
);
};

6.1.2 React 中使用 Axios 处理跨域

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// src/api.js
import axios from 'axios';

const api = axios.create({
baseURL: process.env.NODE_ENV === 'development' ? '/api' : 'https://api.example.com',
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
});

export const fetchUsers = () => api.get('/users');
export const fetchUser = (id) => api.get(`/users/${id}`);
export const createUser = (user) => api.post('/users', user);

6.2 Vue 跨域处理

6.2.1 Vue CLI 代理配置

在 Vue CLI 项目中,通过 vue.config.js 配置代理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// vue.config.js
module.exports = {
devServer: {
proxy: {
'/api': {
target: 'https://api.example.com',
changeOrigin: true,
pathRewrite: {
'^/api': ''
}
}
}
}
};

6.2.2 Vue 3 组合式 API 中使用 Fetch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<script setup>
import { ref, onMounted } from 'vue';

const users = ref([]);
const error = ref(null);

onMounted(async () => {
try {
const response = await fetch('/api/users');
if (!response.ok) {
throw new Error('Network response was not ok');
}
users.value = await response.json();
} catch (err) {
error.value = err.message;
}
});
</script>

<template>
<div>
<h1>Users</h1>
<div v-if="error">Error: {{ error }}</div>
<ul v-else>
<li v-for="user in users" :key="user.id">{{ user.name }}</li>
</ul>
</div>
</template>

6.3 Next.js 跨域处理

6.3.1 API Routes 代理

在 Next.js 中,可以使用 API Routes 作为代理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// pages/api/proxy.js
export default async function handler(req, res) {
const { url } = req.query;
if (!url) {
return res.status(400).json({ error: 'Missing URL parameter' });
}

try {
const response = await fetch(url);
const data = await response.json();
res.status(200).json(data);
} catch (error) {
res.status(500).json({ error: 'Error fetching data' });
}
}

6.3.2 next.config.js 配置

在 Next.js 12.2+ 中,可以使用 rewrites 配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
async rewrites() {
return [
{
source: '/api/:path*',
destination: 'https://api.example.com/:path*'
}
];
}
};

module.exports = nextConfig;

6.4 Svelte 跨域处理

6.4.1 Vite 代理配置

在 SvelteKit 或 Vite 项目中,通过 vite.config.js 配置代理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// vite.config.js
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';

export default defineConfig({
plugins: [sveltekit()],
server: {
proxy: {
'/api': {
target: 'https://api.example.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
}
});

6.5 跨域处理最佳实践

6.5.1 开发环境 vs 生产环境

  • 开发环境:使用代理服务器(Vite、Webpack Dev Server 等)
  • 生产环境
    • 后端配置 CORS
    • 使用 Nginx 反向代理
    • 使用 API 网关

6.5.2 安全性考虑

  • 不要在前端存储敏感信息:如 API 密钥、令牌等
  • 验证请求来源:在后端验证 Origin
  • 使用 HTTPS:保护数据传输安全
  • 设置合理的 CORS 策略:只允许必要的源和方法

6.5.3 性能优化

  • 使用缓存:减少重复请求
  • 优化预检请求:使用 Access-Control-Max-Age
  • 合并请求:减少跨域请求次数
  • 使用 CDN:加速静态资源加载

七、其他跨域方案

7.1 document.domain

document.domain 是一种用于实现同主域下跨子域通信的方法。当两个页面的主域相同时,可以通过设置 document.domain 为相同的值来实现跨域通信。

7.1.1 工作原理

  1. 两个页面必须属于同一个主域,如 a.example.comb.example.com
  2. 两个页面都设置 document.domain = 'example.com'
  3. 之后,两个页面就可以相互访问对方的 DOM 和 JavaScript 对象

7.1.2 使用示例

**页面 A (a.example.com)**:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
<!DOCTYPE html>
<html>
<head>
<title>Page A</title>
</head>
<body>
<h1>Page A (a.example.com)</h1>
<iframe id="iframe" src="https://b.example.com/pageB.html"></iframe>

<script>
// 设置 document.domain
document.domain = 'example.com';

// 访问 iframe 中的方法
function callIframeMethod() {
const iframe = document.getElementById('iframe');
iframe.contentWindow.sayHello('From Page A');
}

// 暴露方法给 iframe
function sayHello(message) {
console.log('Page A received:', message);
}
</script>

<button onclick="callIframeMethod()">Call Iframe Method</button>
</body>
</html>

**页面 B (b.example.com)**:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<!DOCTYPE html>
<html>
<head>
<title>Page B</title>
</head>
<body>
<h1>Page B (b.example.com)</h1>

<script>
// 设置 document.domain
document.domain = 'example.com';

// 暴露方法给父窗口
function sayHello(message) {
console.log('Page B received:', message);
}

// 访问父窗口中的方法
function callParentMethod() {
window.parent.sayHello('From Page B');
}
</script>

<button onclick="callParentMethod()">Call Parent Method</button>
</body>
</html>

7.1.3 注意事项

  • 只适用于同主域下的跨子域通信
  • 安全性较低,可能导致安全漏洞
  • 现代浏览器中可能受到限制
  • 不推荐在生产环境中使用

7.2 location.hash

location.hash 是一种通过 URL 哈希值实现跨域通信的方法。这种方法利用了 URL 哈希值的变化不会触发页面刷新的特性。

7.2.1 工作原理

  1. 发送方将数据编码到 URL 哈希值中
  2. 接收方通过监听 hashchange 事件来获取数据
  3. 由于哈希值的变化不会触发页面刷新,因此可以实现无刷新的跨域通信

7.2.2 使用示例

发送方

1
2
3
4
5
6
7
8
9
10
11
// 打开接收方页面
const receiver = window.open('https://example.com/receiver.html', '_blank');

// 发送数据
function sendData(data) {
const encodedData = encodeURIComponent(JSON.stringify(data));
receiver.location.hash = encodedData;
}

// 发送示例数据
sendData({ message: 'Hello from sender!', timestamp: new Date().toISOString() });

接收方

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 监听 hashchange 事件
window.addEventListener('hashchange', function() {
const hash = window.location.hash.substring(1); // 移除 # 符号
if (hash) {
try {
const decodedData = decodeURIComponent(hash);
const data = JSON.parse(decodedData);
console.log('Received data:', data);
// 处理数据
processData(data);
} catch (error) {
console.error('Error parsing hash data:', error);
}
}
});

function processData(data) {
// 处理接收到的数据
document.getElementById('message').textContent = data.message;
document.getElementById('timestamp').textContent = data.timestamp;
}

7.2.3 注意事项

  • 数据大小有限制(URL 长度限制)
  • 数据会暴露在 URL 中,不适合传输敏感信息
  • 只适用于单向通信
  • 现代浏览器中已被 postMessage 取代

7.3 window.name

window.name 是一种利用浏览器窗口的 name 属性实现跨域数据传输的方法。window.name 属性在窗口的整个生命周期中保持不变,即使页面导航到不同的域。

7.3.1 工作原理

  1. 在源页面中设置 window.name 为要传输的数据
  2. 导航到目标域的页面
  3. 目标页面读取 window.name 中的数据

7.3.2 使用示例

源页面

1
2
3
4
5
6
7
8
9
// 设置要传输的数据
window.name = JSON.stringify({
user: 'John',
age: 30,
email: 'john@example.com'
});

// 导航到目标域
window.location.href = 'https://example.com/target.html';

目标页面

1
2
3
4
5
6
7
8
9
10
11
// 读取 window.name 中的数据
const data = JSON.parse(window.name);
console.log('Received data:', data);

// 处理数据
document.getElementById('user').textContent = data.user;
document.getElementById('age').textContent = data.age;
document.getElementById('email').textContent = data.email;

// 清空 window.name
window.name = '';

7.3.3 注意事项

  • 数据大小限制较大(通常可以存储几兆字节)
  • 只适用于单向通信
  • 数据会在窗口关闭前一直存在
  • 现代浏览器中已被 postMessage 取代

7.4 服务器端转发

服务器端转发是一种通过服务器端代理实现跨域的方法。前端将请求发送到同源的服务器,服务器再将请求转发到目标服务器,最后将响应返回给前端。

7.4.1 工作原理

  1. 前端向同源服务器发送请求
  2. 服务器接收请求并转发到目标服务器
  3. 服务器接收目标服务器的响应并返回给前端

7.4.2 使用示例

Node.js 服务器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const express = require('express');
const axios = require('axios');
const app = express();

// 转发请求
app.get('/api/*', async (req, res) => {
try {
const url = `https://api.example.com${req.originalUrl.replace('/api', '')}`;
const response = await axios.get(url);
res.json(response.data);
} catch (error) {
res.status(500).json({ error: 'Proxy error' });
}
});

app.listen(3000, () => {
console.log('Server running on port 3000');
});

前端代码

1
2
3
4
5
// 发送请求到同源服务器
fetch('/api/users')
.then(response => response.json())
.then(data => console.log('Users:', data))
.catch(error => console.error('Error:', error));

7.4.3 注意事项

  • 需要服务器端支持
  • 可能增加服务器负载
  • 适合在生产环境中使用
  • 可以处理所有类型的请求

7.5 跨域方案总结

跨域方案适用场景优点缺点
JSONP简单的 GET 请求浏览器兼容性好只支持 GET 请求,安全隐患
CORS现代跨域请求功能强大,安全浏览器兼容性要求高
WebSocket实时通信实时性好,双向通信服务器负载高
postMessage跨窗口通信灵活,支持复杂数据调试困难
document.domain同主域跨子域简单易用只适用于同主域
location.hash简单跨域通信实现简单数据大小有限制
window.name跨域数据传输数据大小限制大只适用于单向通信
代理服务器所有跨域场景功能全面需要服务器支持

在选择跨域方案时,应根据具体的使用场景和需求来决定:

  • 现代前端开发:优先使用 CORS 或代理服务器
  • 实时通信:使用 WebSocket
  • 跨窗口通信:使用 postMessage
  • 同主域跨子域:使用 document.domain
  • 旧浏览器支持:使用 JSONP