前段时间公司项目中需要集成一个富文本编辑器, 由于之前使用过
ckeditor 4
, 然后这回也就依旧选择了
ckeditor
但
4
是很多年前用的, 那个时候
5
才刚刚出来, 而现在
ckeditor 5
已经被开发出来很久了, 于是决定直接使用
ckeditor 5
,
5
有自己的
react
组件, 使用起来方便快捷, 再也不需要像
4
一样去封装对应的
react
组件了, 可就在我愉快地使用
ckeditor 5
的时候, 我发现了一个问题: 服务端返回的
html
字符串数据中某些标签会被过滤! 而这些标签如果被过滤了, 那最后提交的内容发生了变更, 标签丢失, 样式就会受到影响, 这就麻烦了, 具体标签被过滤的问题解决方案如下:
Ckeditor removes html5 video block during initialization
Why does the editor filter out my content (styles, classes, elements)? Where is config.allowedContent = true?
解决方法就是需要自己写插件, 可是这个功能比较紧急, 以及我当时手上还有其他的活, 加之之前用过
ckeditor 4
, 因此没有去研究如何开发插件, 而是直接使用了
ckeditor 4
, 因为
4
里面有一个
api
可以直接控制是否过滤标签, 方便快捷
而在我使用
ckeditor 5
的过程中, 在
ckeditor 5 online-builder
中构建符合自己需求的编辑器的时候, 偶然发现了页面和服务端交互的一个细节: 在最后一步构建完成, 点击
Start
之后, 页面向服务端发起了一个请求, 此时服务端给页面返回了一个
content-type
为
text/event-stream
的相应内容, 在谷歌浏览器的
Network
中查看的时候并没有看到我熟悉的
Preview
和
Response
选项卡, 而是看到了一个我从没见过的选项卡:
EventStream
, 点开之后里面的内容有点像
WebSocket
的消息,
WebSocket
的相关内容有需要的小伙伴可以看看这篇文章:
使用eggjs+websocket(socket.io)处理刷新/关闭页面
, 而这激起了我的好奇心, 于是便有了这篇文章
什么是事件源(EventSource)
我个人的理解是响应内容中的一种, 就好像我们常见的
content-type
为
application/json
那样, 只不过这个的
content-type
为
text/event-stream
, 同时它和
WebSocket
有些类似, 都是长连接, 关闭页面连接会断开, 或者可以主动关闭连接, 但又有一点不同:
WebSocket
是双向的, 而这个是单向的, 详细的解释参考这篇文章:
EventSource
网页一侧使用
EventSource
api
来监听服务端发送的事件:
const evtSource = new EventSource("ssedemo.php")
参数就是服务端生成这个事件的URL
, 同时还支持第二个可选参数, 比如跨域的凭证相关配置:
const evtSource = new EventSource(
"ssedemo.php",
withCredentials: true
创建了EventSource
示例之后就可以用它来监听服务端发送过来的事件啦:
evtSource.onmessage = function(event) {
const newElement = document.createElement("li")
const eventList = document.getElementById("list")
newElement.textContent = "message: " + event.data
eventList.appendChild(newElement)
evtSource.addEventListener("ping", function(event) {
const newElement = document.createElement("li")
const eventList = document.getElementById("list")
const time = JSON.parse(event.data).time
newElement.textContent = "ping at " + time
eventList.appendChild(newElement)
可以看到这里有两段监听的代码, 它们的区别在于: 第一段没有监听具体的事件名称, 而第二段监听了一个叫ping
的事件, 这取决于服务端发送事件的时候是否添加event
字段, 没有添加则需要使用第一个监听方式, 添加了event
字段为ping
, 那么我们才能监听叫ping
的事件, 而当服务端没有设置event
字段的时候, 它会有一个默认的值, 叫message
关于事件的名称字段, mdn
文档中说的是event
, 而我自己尝试之后发现是type
, 可能是这个EventSource
的规范后来改过, 而文档是之前的了
服务端发送事件的时候需要留意这么几点:
设置content-type
为text/event-stream
每条通知以文本形式发送并且以一对换行符结尾
发送的内容符合事件流(EventStream)
的格式要求
一个可参考的示例代码如下:
date_default_timezone_set("America/New_York");
header("Cache-Control: no-store");
header("Content-Type: text/event-stream");
$counter = rand(1, 10);
while (true) {
echo "event: ping\n";
$curDate = date(DATE_ISO8601);
echo 'data: {"time": "' . $curDate . '"}';
echo "\n\n";
$counter--;
if (!$counter) {
echo 'data: This is a message at time ' . $curDate . "\n\n";
$counter = rand(1, 10);
ob_end_flush();
flush();
if ( connection_aborted() ) break;
sleep(1);
上面这段代码会每秒生成一个事件, 以及这个事件的event
字段为ping
, 而且每个事件的data
字段都是JSON
, 同时这个JSON
还包含与事件生成时间相对应的ISO 8601
时间戳, 还有就是这段代码在随机的时间间隔内还会发送一个不带event
字段的简单消息. 最后, 这个循环将一直运行, 不受连接状态的影响, 因此包含了一个检查, 如果连接关闭(比如客户端关闭页面), 那么这个循环就会终止, 也就是此时将不再生成事件
上方客户端和服务端的示例代码来自于mdn
: Using server-sent events, 详情可以点击查看
亲自试一试
了解了概念和基本的用法之后, 接下来我打算亲自尝试一下, 而上一次尝试WebSocket
的时候我使用的是EggJS
, 这回我打算看看NestJS
, 这是最近这几年比较火的NodeJS
框架, 同时也是我司数据中台的后端小伙伴使用的技术栈, 而在我看它文档的时候, 我发现了NestJS
的gitbub
中有一个sse
的例子, 这里我也贴一下示例中的代码吧, 这样阅读起来也更方便, 包含配置和目录结构的完整示例代码可以点击这个链接查看: nestjs sse sample
app.controller.ts
:
import { Controller, Get, MessageEvent, Res, Sse } from '@nestjs/common';
import { Response } from 'express';
import { readFileSync } from 'fs';
import { join } from 'path';
import { interval, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Controller()
export class AppController {
@Get()
index(@Res() response: Response) {
response
.type('text/html')
.send(readFileSync(join(__dirname, 'index.html')).toString());
@Sse('sse')
sse(): Observable<MessageEvent> {
return interval(1000).pipe(
map((_) => ({ data: { hello: 'world' } } as MessageEvent)),
app.module.ts
:
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
@Module({
imports: [],
controllers: [AppController],
providers: [],
export class AppModule {}
main.ts
:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
console.log(`Application is running on: ${await app.getUrl()}`);
bootstrap();
html
:
<script type="text/javascript">
const eventSource = new EventSource('/sse')
eventSource.onmessage = ({ data }) => {
const message = document.createElement('li')
message.innerText = 'New message: ' + data
document.body.appendChild(message)
</script>
核心的代码在app.controller.ts
中: http://localhost:3000
返回一个html
文件, http://localhost:3000/sse
则是我们服务端发送事件的服务, 这个服务1
秒发一次, 只有data
字段, 而没有event
字段(实际应该是type
字段), 这里我修改一下, 实际应用中前端和后端项目一般不会部署到同一个域下, 那么这里就需要做个跨域的配置, 以及不用服务端返回html
文件, 前后端分离, 前端项目前端维护, 后端只需要发送事件过来即可, 综上, 修改之后代码如下:
app.controller.ts
:
import { Controller, MessageEvent, Sse, Header } from '@nestjs/common';
import { interval, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Controller()
export class AppController {
@Sse('sse')
@Header('Access-Control-Allow-Origin', 'http://localhost:9090')
sse(): Observable<MessageEvent> {
return interval(1000).pipe(
map((_) => ({
data: { hello: 'world' }
} as MessageEvent)),
sse.html
:
<!DOCTYPE html>
<html lang="en">
<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</title>
</head>
<script>
const evtSource = new EventSource('http://localhost:3000/sse');
evtSource.addEventListener('message', e => {
const { data } = e;
console.log(data);
setTimeout(
() => {
evtSource.close();
</script>
</body>
</html>
这段代码是能正常运行的, 服务端发送事件, 客户端监听这个事件并打印data
, 然后5
秒之后关闭连接
在没有指定type
字段的时候默认是message
, 所以客户端监听的是叫message
的事件, 而当指定type
字段的时候, 客户端监听的事件名称就要和type
字段一样了:
app.controller.ts
:
import { Controller, MessageEvent, Sse, Header } from '@nestjs/common';
import { interval, Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Controller()
export class AppController {
@Sse('sse')
@Header('Access-Control-Allow-Origin', 'http://localhost:9090')
sse(): Observable<MessageEvent> {
return interval(1000).pipe(
map((_) => ({
type: 'sse', //这里设置了type字段的值
data: { hello: 'world' }
} as MessageEvent)),
sse.html
:
<!DOCTYPE html>
<html lang="en">
<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</title>
</head>
<script>
const evtSource = new EventSource('http://localhost:3000/sse');
evtSource.addEventListener('sse', e => {
const { data } = e;
console.log(data);
setTimeout(
() => {
evtSource.close();
</script>
</body>
</html>
我也是第一次接触nestjs
, 第一次用它来写demo
, 以前接触过一点java
, 看了nestjs
后发现它的写法有点像java
, 但对于nestjs
也只是浅尝辄止, 更深入的用法还要再花时间学习和练习
而关于本文的主题sse
, 坦白说我并没有能够想到一些实际的使用场景, 服务端向客户端发送消息, 使用常见的application/json
形式的数据传输是非常常见通用的一个做法, 而且EventSource
还是一个长连接, 如果客户端不手动关闭, 连接则会一直占用资源, 如果遇到客户端需要给服务端回传数据, 那么还是需要用我们常见的诸如application/json
这样形式的请求来完成, 这样一看的话EventSource
就显得有些鸡肋了
但mdn
文档中有这样一句话给我了更多的思考:
Unlike WebSockets, server-sent events are unidirectional; that is, data messages are delivered in one direction, from the server to the client (such as a user's web browser). That makes them an excellent choice when there's no need to send data from the client to the server in message form. For example, EventSource
is a useful approach for handling things like social media status updates, news feeds, or delivering data into a client-side storage mechanism like IndexedDB or web storage.
和WebSocket
不同, sse
是单向的, 也就是说, 数据信息是单向传递的, 方向是从服务端到客户端(比如从服务端到用户的网页浏览器). 在不需要以消息的形式从客户端向服务端发送数据的时候sse
是一个非常好的选择. 比如处理社交媒体的状态更新, 新闻推送或者将数据发送到客户端, 然后由客户端来存储(例如IndexedDB
或者web storage
)的时候是很有用的
也就是说sse
比较适合做社交媒体的消息推送以及新闻推送等从服务端向客户端发送数据的场景, 确切的说是需要服务端通知客户端实时更新数据的场景, 但我个人在实际工作中暂未遇到, 暂未使用过, 有在项目中用过sse
的小伙伴欢迎在评论区分享自己的经验心得
最后, 如果你觉得这篇文章写得还不错, 别忘了给我点个赞, 如果你觉得对你有帮助, 可以点个收藏, 以备不时之需
参考文献:
EventSource
Using server-sent events
_音魂不散_
前端开发工程师
粉丝