SSE

SSE

Server-Sent Events(SSE)是一种在客户端和服务端之间实现单向事件流的机制,允许服务端主动向客户端发送事件数据。在 SSE 中,可以使用自定义事件(Custom Events)来发送具有特定类型的事件数据。

webSocket属于全双工通讯,也就是前端可以给后端实时发送,后端也可以给前端实时发送,SSE属于单工通讯,只能服务端实时主动给前端发送消息。

SSE 是 HTML5 中一个与通信相关的 API,主要由两部分组成:服务端与客户端的通信协议(HTTP 协议)及客户端可供 JavaScript 使用的 EventSource 对象。

Server-Sent Events API WebSockets API
基于 HTTP 协议 基于 TCP 协议
单工,只能服务端单向发送消息 全双工,可以同时发送和接收消息
轻量级,使用简单 相对复杂
内置断线重连和消息追踪的功能 不在协议范围内,需手动实现
文本或使用 Base64 编码和 gzip 压缩的二进制消息 类型广泛
支持自定义事件类型 不支持自定义事件类型
连接数 HTTP/1.1 6 个,HTTP/2 可协商(默认 100) 连接数无限制

服务端实现

协议

SSE 协议非常简单,本质是浏览器发起 http 请求,服务端在收到请求后,返回状态与数据,并附带以下响应头字段:

js 复制代码
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
  • SSE API规定推送事件流的 MIME 类型为 text/event-stream

  • 必须指定浏览器不缓存服务端发送的数据,以确保浏览器可以实时显示服务端发送的数据。

  • SSE 是一个一直保持开启的 TCP 连接,所以 Connection 为 keep-alive。

消息格式

EventStream(事件流)为 UTF-8 格式编码的文本或使用 Base64 编码和 gzip 压缩的二进制消息。

每条消息的字段名有eventidretrydata组。以冒号开头的行为注释行,会被浏览器忽略。

每一次发送的信息,由若干个message组成,每个message之间用\n\n分隔。每个message内部由若干行组成,每一行都是如下格式。

js 复制代码
字段名:字段值\n

注意:

  • 除上述四个字段外,其他所有字段都会被忽略。
  • 如果一行字段中不包含冒号,则整行文本将被视为字段名,字段值为空。
  • 注释行可以用来防止链接超时,服务端可以定期向浏览器发送一条消息注释行,以保持连接不断。
js 复制代码
event: message
data: Hello, world

data: Another message

1、2、4行就是3个数据行,1、2两个数据行是一条消息,4行也是一条消息,这两个消息组成了整个接口返回的文本。 即服务端向客户端推送了两条消息,第一条消息的事件名称为 message,事件数据为 Hello, world;第二条消息没有指定事件名称,事件数据为 Another message。

event字段

事件类型,如果指定了该字段,则在客户端收到该条消息时,会在当前 EventSource 对象上触发一个事件,事件类型就是该字段的字段值。可以使用 addEventListener 方法在当前 EventSource 对象上监听任意类型的命名事件。

如果该条消息没有 event 字段,则会触发 EventSource 对象 onmessage 属性上的事件处理函数。

id字段

事件ID,事件的唯一标识符,客户端会跟踪事件ID,如果发生断连,客户端会把收到的最后一个事件ID放到 HTTP Header Last-Event-Id 中进行重连,作为一种简单的同步机制。

例如,可以在服务端将每次发送的事件ID值自动加 1,当浏览器接收到该事件ID后,下次与服务端建立连接后再请求的 Header 中将同时提交该事件ID,服务端检查该事件ID是否为上次发送的事件ID,如果与上次发送的事件ID不一致则说明客户端存在与服务器连接失败的情况,本次需要同时发送前几次客户端未接收到的数据。

retry字段

重连时间,整数值,单位 ms,如果与服务端的连接丢失,客户端将等待指定时间,然后尝试重新连接。如果该字段不是整数值,会被忽略。

当服务端没有指定客户端的重连时间时,由客户端自行决定每隔多久与服务端建立一次连接(一般为 30s)。

两种情况会导致浏览器重新发起连接:一种是时间间隔到期,二是由于网络错误等原因,导致连接出错

data字段

消息数据,数据内容只能以一个字符串的文本形式进行发送,如果需要发送一个对象时,需要将该对象以一个 JSON 格式的字符串的形式进行发送。在客户端接收到该字符串后,再把它还原为一个 JSON 对象。

如果消息很长,可以分成多行,最后一行用\n\n结尾,前面行都用\n结尾。

js 复制代码
data: begin message\n
data: continue message\n\n

下面是一个发送 JSON 数据的例子

js 复制代码
data: {\n
data: "foo": "bar",\n
data: "baz", 555\n
data: }\n\n

客户端实现

在浏览器端,可以使用 JavaScript 的 EventSource API 创建 EventSource 对象监听服务器发送的事件。一旦建立连接,服务器就可以使用 HTTP 响应的 'text/event-stream' 内容类型发送事件消息,浏览器则可以通过监听 EventSource 对象的 onmessageonopenonerror 事件来处理这些消息。

建立连接

EventSource 接受两个参数:URL 和 options。

url 为 http 事件来源,一旦 EventSource 对象被创建后,浏览器立即开始对该 url 地址发送过来的事件进行监听。url可以与当前网址同域,也可以跨域。跨域时,可以指定第二个参数,打开withCredentials属性,表示是否一起发送 Cookie

options 是一个可选的对象,包含 withCredentials 属性,表示是否发送凭证(cookie、HTTP认证信息等)到服务端,默认为 false。

js 复制代码
const eventSource = new EventSource('http_api_url', { withCredentials: true })

与 XMLHttpRequest 对象类型,EventSource 对象有一个 readyState 属性值,表明连接的当前状态。该属性只读,可以取以下值:

  • 0:相当于常量EventSource.CONNECTING,表示连接还未建立,或者断线正在重连。
  • 1:相当于常量EventSource.OPEN,表示连接已经建立,可以接受数据。
  • 2:相当于常量EventSource.CLOSED,表示连接已断,且不会重连。

可以使用 EventSource 对象的 close 方法关闭与服务端之间的连接,使客户端不再建立与服务端之间的连接。

js 复制代码
// 建立连接
const eventSource = new EventSource('http_api_url', { withCredentials: true })
// 关闭连接 
eventSource.close()

监听事件

可以使用 addEventListener() 方法来监听事件。EventSource 对象触发的事件主要包括以下三种:

  • open 事件:当成功连接到服务端时触发。
  • message 事件:当接收到服务器发送的消息时触发。该事件对象的 data 属性包含了服务器发送的消息内容。
  • error 事件:当发生错误时触发。该事件对象的 event 属性包含了错误信息。
js 复制代码
// 初始化 eventSource 
const eventSource = new EventSource('http_api_url', { withCredentials: true })

eventSource.addEventListener('open', function(event) {
  console.log('Connection opened')
})

eventSource.addEventListener('message', function(event) {
  console.log('Received message: ' + event.data);
})

// 监听自定义事件
eventSource.addEventListener('xxx', function(event) {
  console.log('Received message: ' + event.data);
})

eventSource.addEventListener('error', function(event) {
  console.log('Error occurred: ' + event.event);
})

默认情况下,服务器发来的数据,总是触发浏览器EventSource实例的message事件。开发者还可以自定义 SSE 事件,这种情况下,发送回来的数据不会触发message事件。

js 复制代码
// 客户端对 SSE 的foo事件进行监听
source.addEventListener('foo', function (event) {
  var data = event.data;
  // handle message
}, false);
// 服务端
app.get('/sse', (req, res) => {
    res.set({
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        'Connection': 'keep-alive'
    });
    res.status(200);
    res.write(': 注释行第一条数据\n');
    res.write("retry: 10000\n");
    // 服务端发送foo事件
    res.write("event: foo\n");
    res.write('data: {"username": "bobby", "time": "02:33:48"}\n');
})

也可以采用属性监听(onopenonmessageonerror)的形式

js 复制代码
eventSource.onopen = function(event) {
  console.log('Connection opened')
}

eventSource.onmessage = function(event) {
  console.log('Received message: ' + event.data);
}

eventSource.onerror = function(event) {
  console.log('Error occurred: ' + event.event);
})

EventSource 对象的属性监听只能监听预定义的事件类型(openmessageerror)。不能用于监听自定义事件类型。如果要实现自定义事件类型的监听,可以使用 addEventListener() 方法。

实践

服务端

express 增加响应头text/event-stream设置推送事件流的 MIME 类型

js 复制代码
import express from 'express';
// 引入中间件
import loggerMiddleware from './middleware/logger.js'
// express是函数
const app = express();
app.use(loggerMiddleware);
app.use('/assets', express.static('static'));
app.get('/sse', (req, res) => {
    res.set({
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        'Connection': 'keep-alive'
    });
    res.status(200);
    const url = req.url
    // 每隔 1 秒发送一条消息
    let id = 0
    const intervalId = setInterval(() => {
        id++
        res.write(`event: customEvent\n`)
        res.write(`id: ${id}\n`)
        res.write(`retry: 30000\n`)
        const params = url.split('?')[1]
        const data = { id, time: new Date().toISOString(), params }
        res.write(`data: ${JSON.stringify(data)}\n\n`)
        if (id > 10) {
            clearInterval(intervalId)
            res.end()
        }
    }, 1000)
    // 当客户端关闭连接时停止发送消息
    req.on('close', () => {
        clearInterval(intervalId)
        id = 0
        res.end()
    })
})
app.listen(8089, () => {
    console.log("8089端口已启动");
});

客户端

html 复制代码
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>SSE Demo</title>
</head>
<body>
  <h1>SSE Demo</h1>
  <button onclick="connectSSE()">建立 SSE 连接</button>  
  <button onclick="closeSSE()">断开 SSE 连接</button>
  <br />
  <br />
  <div id="message"></div>

  <script>
    const messageElement = document.getElementById('message')

    let eventSource

    // 建立 SSE 连接
    const connectSSE = () => {
      eventSource = new EventSource('/sse?content=xxx')

      // 监听消息事件
      eventSource.addEventListener('customEvent', (event) => {
        const data = JSON.parse(event.data)
        messageElement.innerHTML += `${data.id} --- ${data.time} --- params参数:${JSON.stringify(data.params)}` + '<br />'
      })

      eventSource.onopen = () => {
        messageElement.innerHTML += `SSE 连接成功,状态${eventSource.readyState}<br />`
      }

      eventSource.onerror = () => {
        messageElement.innerHTML += `SSE 连接错误,状态${eventSource.readyState}<br />`
      }
    }

    // 断开 SSE 连接
    const closeSSE = () => {
      eventSource.close()
      messageElement.innerHTML += `SSE 连接关闭,状态${eventSource.readyState}<br />`
    }
  </script>
</body>
</html>

sse在服务端是无法主动关闭连接的,必须由客户端关闭

对此,一种常见的方案就是模拟close事件。可以自定义事件发送消息:

js 复制代码
event: close
retry: 3
data: bye

客户端只需要监听自定义事件,主动关闭连接。

Fetch实现

fetch配置对象的完整 API:

js 复制代码
const response = fetch(url, {
  method: "GET",
  headers: {
    "Content-Type": "text/plain;charset=UTF-8"
  },
  body: undefined,
  referrer: "about:client",
  referrerPolicy: "no-referrer-when-downgrade",
  mode: "cors", 
  credentials: "same-origin",
  cache: "default",
  redirect: "follow",
  integrity: "",
  keepalive: false,
  signal: undefined
});

cache

cache属性指定如何处理缓存。可能的取值如下:

  • default:默认值,先在缓存里面寻找匹配的请求。
  • no-store:直接请求远程服务器,并且不更新缓存。
  • reload:直接请求远程服务器,并且更新缓存。
  • no-cache:将服务器资源跟本地缓存进行比较,有新的版本才使用服务器资源,否则使用缓存。
  • force-cache:缓存优先,只有不存在缓存的情况下,才请求远程服务器。
  • only-if-cached:只检查缓存,如果缓存里面不存在,将返回504错误。

mode

mode属性指定请求的模式。可能的取值如下:

  • cors:默认值,允许跨域请求。
  • same-origin:只允许同源请求。
  • no-cors:请求方法只限于 GET、POST 和 HEAD,并且只能使用有限的几个简单标头,不能添加跨域的复杂标头,相当于提交表单所能发出的请求。

credentials

credentials属性指定是否发送 Cookie。可能的取值如下:

  • same-origin:默认值,同源请求时发送 Cookie,跨域请求时不发送。
  • include:不管同源请求,还是跨域请求,一律发送 Cookie。
  • omit:一律不发送。

跨域请求发送 Cookie,需要将credentials属性设为include

signal

signal属性指定一个 AbortSignal 实例,用于取消fetch()请求。

fetch()请求发送以后,如果中途想要取消,需要使用AbortController对象。

js 复制代码
let controller = new AbortController();
let signal = controller.signal;

fetch(url, {
  signal: controller.signal
});

signal.addEventListener('abort',
  () => console.log('abort!')
);

controller.abort(); // 取消

console.log(signal.aborted); // true

首先新建 AbortController 实例,然后发送fetch()请求,配置对象的signal属性必须指定接收 AbortController 实例发送的信号controller.signal。controller.abort()方法用于发出取消信号。这时会触发abort事件,这个事件可以监听,也可以通过controller.signal.aborted属性判断取消信号是否已经发出。

js 复制代码
let controller = new AbortController();
setTimeout(() => controller.abort(), 1000);

try {
  let response = await fetch('/long-operation', {
    signal: controller.signal
  });
} catch(err) {
  if (err.name == 'AbortError') {
    console.log('Aborted!');
  } else {
    throw err;
  }
}

以上是一个1秒后自动取消请求的例子。

keepalive

keepalive属性用于页面卸载时,告诉浏览器在后台保持连接,继续发送数据。

一个典型的场景就是,用户离开网页时,脚本需要向服务端提交一些用户行为的统计信息。这时,如果不用keepalive属性,数据可能无法发送,因为浏览器已经把页面卸载了。

js 复制代码
window.onunload = function() {
  fetch('/analytics', {
    method: 'POST',
    body: "statistics",
    keepalive: true
  });
};

redirect

redirect属性指定 HTTP 跳转的处理方法。可能的取值如下:

  • follow:默认值,fetch()跟随 HTTP 跳转。
  • error:如果发生跳转,fetch()就报错。
  • manualfetch()不跟随 HTTP 跳转,但是response.url属性会指向新的 URL,response.redirected属性会变为true,由开发者自己决定后续如何处理跳转。

integrity

integrity属性指定一个哈希值,用于检查 HTTP 回应传回的数据是否等于这个预先设定的哈希值。

比如,下载文件时,检查文件的 SHA-256 哈希值是否相符,确保没有被篡改。

js 复制代码
fetch('http://site.com/file', {
  integrity: 'sha256-abcdef'
});

referrer

referrer属性用于设定fetch()请求的referer标头。

这个属性可以为任意字符串,也可以设为空字符串(即不发送referer标头)。

js 复制代码
fetch('/page', {
  referrer: ''
});

referrerPolicy

referrerPolicy属性用于设定Referer标头的规则。可能的取值如下:

  • no-referrer-when-downgrade:默认值,总是发送Referer标头,除非从 HTTPS 页面请求 HTTP 资源时不发送。
  • no-referrer:不发送Referer标头。
  • originReferer标头只包含域名,不包含完整的路径。
  • origin-when-cross-origin:同源请求Referer标头包含完整的路径,跨域请求只包含域名。
  • same-origin:跨域请求不发送Referer,同源请求发送。
  • strict-originReferer标头只包含域名,HTTPS 页面请求 HTTP 资源时不发送Referer标头。
  • strict-origin-when-cross-origin:同源请求时Referer标头包含完整路径,跨域请求时只包含域名,HTTPS 页面请求 HTTP 资源时不发送该标头。
  • unsafe-url:不管什么情况,总是发送Referer标头。

Response.body 属性

Response.body属性是 Response 对象暴露出的底层接口,返回一个 ReadableStream 对象,供用户操作。

它可以用来分块读取内容,应用之一就是显示下载的进度。

js 复制代码
const response = await fetch('flower.jpg');
const reader = response.body.getReader();

while(true) {
  const {done, value} = await reader.read();

  if (done) {
    break;
  }

  console.log(`Received ${value.length} bytes`)
}

response.body.getReader()方法返回一个遍历器。这个遍历器的read()方法每次返回一个对象,表示本次读取的内容块。这个对象的done属性是一个布尔值,用来判断有没有读完;value属性是一个 arrayBuffer 数组,表示内容块的内容,而value.length属性是当前块的大小。

使用fetch方式进行sse流式处理

使用浏览器提供的EventSource API可以快速简单的实现SSE连接,但是也有一定的限制:

  • 不能自定义请求头;
  • 不能使用POST(默认为GET),所以如果传参太长的话可能超过浏览器的长度限制;

fetch请求的缺点:

  • 需要手动解析数据:使用 fetch() 方法需要手动解析从 SSE 服务器端点接收的数据流,这需要一些额外的代码和技术。

  • 无法自动重连:使用 fetch() 方法无法自动重连 SSE 服务器端点。如果与 SSE 服务器端点的连接断开,我们需要手动重新连接。

  • 无法处理错误:使用 fetch() 方法无法处理 SSE 数据流的错误。如果发生错误,我们需要手动处理并调试代码。

插件:@microsoft/fetch-event-source,可以使用post请求,也可以自定义请求头。

插件API

参数名 描述
headers 仅支持字符串键值对 { "xx": "xx"}
onopen 成功链接时回调
onmessage 服务器返回消息回调 返回{ data,event,id,retry } ,data即服务器返回数据
onclose 响应完成回调
onerror 发生任何错误的回调,并会自动在1s后重新尝试目前测试函数体内throw err;会阻断重试
openWhenHidden 页面文档被隐藏时会关闭sse链接并在显示时重连,true无论隐藏与否都不关闭
fetch 默认 window.fetch

插件使用

@microsoft/fetch-event-source该插件没有介绍在项目中使用script脚本标签引入,只介绍了通过npm 引入的方式。 可以借助 webpack 工具来解决。

第一步初始化包管理文件:

js 复制代码
npm install webpack --save-dev
npm install webpack-cli --save-dev

第二步项目根目录新建 webpack.config.js 文件:

js 复制代码
import path from 'node:path'
 
export default {
  entry: "./test.js", //打包入口
  output: {
    //打包文件输出路径
    path: path.resolve(process.cwd(), "dist"),
    filename: "index.js",
  },
};

代码中 test.js 文件是使用了 @microsoft/fetch-event-source,打包入口文件可以根据自己项目文件路径填写

第三步打包文件:在项目终端 执行 npx webpack

js 复制代码
npx webpack

第四步把打包生成的文件(index.js)在项目中引入

html 复制代码
<script src="./index.js"></script>

客户端index.html代码:

html 复制代码
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">

  <title>SSE Demo</title>
</head>

<body>
  <h1>SSE Demo</h1>
  <button id="connectSSE">建立 SSE 连接</button>
  <button id="closeSSE">断开 SSE 连接</button>
  <br />
  <br />
  <div id="message"></div>
</body>
<script src="./index.js"></script>

</html>

test.js代码:使用@microsoft/fetch-event-source插件

js 复制代码
import { fetchEventSource } from '@microsoft/fetch-event-source'
const messageElement = document.getElementById('message')
const connectElement = document.getElementById('connectSSE')
const closeElement = document.getElementById('closeSSE')
let controller = new AbortController();
// 建立 SSE 连接
const connectSSE = async () => {
    fetchEventSource('http://localhost:8089/fetch-sse', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
        },
        body: JSON.stringify({
            content:'i am gloria'
        }),
        signal: controller.signal,
        openWhenHidden: true,
        async onopen(response) {
            console.log('onopen', response);
            messageElement.innerHTML += `FETCH 连接成功<br />`;
        },
        onmessage(msg) {
            console.log('fetchEventSource:', msg);
            const value = JSON.parse(msg.data);
            messageElement.innerHTML += `id:${value.id}--time:${value.time}--body参数:${JSON.stringify(value.data)}<br />`
        },
        onclose() {
            console.log('onclose');
            messageElement.innerHTML += `FETCH 连接关闭<br />`;
        },
        onerror(err) {
            console.log('onerror', err);
            controller.abort();
            throw err;
        }
    });
}
connectElement.onclick = connectSSE;
// 断开 SSE 连接
const closeSSE = () => {
    if (controller) {
        controller.abort();
        // 中断请求无法再次请求,所以再次创建对象
        controller = new AbortController();
        messageElement.innerHTML += `FETCH 连接关闭<br />`;
    }
}
closeElement.onclick = closeSSE;

服务端代码app.js:

js 复制代码
import express from 'express';
// 引入中间件
import loggerMiddleware from './middleware/logger.js'
// express是函数
const app = express();
app.use(express.json());
app.use(loggerMiddleware);
app.use('/assets', express.static('static'));
app.post('/fetch-sse', (req, res) => {
    let body = req.body;
    res.writeHead(200, {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        'Connection': 'keep-alive'
    });
    let count = 0;
    // 每隔 1 秒发送一条消息
    let timer = setInterval(() => {
        const data = { id: count, time: new Date().toISOString, data: body }
        res.write(`data: ${JSON.stringify(data)}\n\n`);
        count++;
        if (count >= 5) {
            clearInterval(timer);
            res.end();
        }
    }, 1000);

})
app.listen(8089, () => {
    console.log("8089端口已启动");
});

效果呈现:

相关推荐
汪子熙16 分钟前
Angular 服务器端应用 ng-state tag 的作用介绍
前端·javascript·angular.js
Envyᥫᩣ24 分钟前
《ASP.NET Web Forms 实现视频点赞功能的完整示例》
前端·asp.net·音视频·视频点赞
Мартин.4 小时前
[Meachines] [Easy] Sea WonderCMS-XSS-RCE+System Monitor 命令注入
前端·xss
昨天;明天。今天。6 小时前
案例-表白墙简单实现
前端·javascript·css
数云界6 小时前
如何在 DAX 中计算多个周期的移动平均线
java·服务器·前端
风清扬_jd6 小时前
Chromium 如何定义一个chrome.settingsPrivate接口给前端调用c++
前端·c++·chrome
安冬的码畜日常6 小时前
【玩转 JS 函数式编程_006】2.2 小试牛刀:用函数式编程(FP)实现事件只触发一次
开发语言·前端·javascript·函数式编程·tdd·fp·jasmine
ChinaDragonDreamer6 小时前
Vite:为什么选 Vite
前端
小御姐@stella6 小时前
Vue 之组件插槽Slot用法(组件间通信一种方式)
前端·javascript·vue.js
GISer_Jing6 小时前
【React】增量传输与渲染
前端·javascript·面试