安装
通过 npm 或 yarn 命令安装 X6。
# npm
npm install @antv/x6 --save
# yarn
yarn add @antv/x6
安装完成之后,使用 import
或 require
进行引用。
import { Graph } from '@antv/x6';
缩放与平移
画布的拖拽、缩放也是常用操作,Graph 中通过 panning
和 mousewheel
配置来实现这两个功能,鼠标按下画布后移动时会拖拽画布,滚动鼠标滚轮会缩放画布。
const graph = new Graph({
...,
panning: true,
mousewheel: true
})
添加节点
节点和边都有共同的基类 Cell,除了从 Cell
继承属性外,还支持以下选项。
属性名 | 类型 | 默认值 | 描述 |
---|---|---|---|
x | number | 0 | 节点位置 x 坐标,单位为 px。 |
y | number | 0 | 节点位置 y 坐标,单位为 px。 |
width | number | 1 | 节点宽度,单位为 px。 |
height | number | 1 | 节点高度,单位为 px。 |
angle | number | 0 | 节点旋转角度。 |
graph.addNode({
shape: 'rect',
x: 100,
y: 40,
width: 100,
height: 40,
})
内置节点
上面使用 shape
来指定了节点的图形,shape
的默认值为 rect
。X6 内置节点与 shape
名称对应关系如下表。
构造函数 | shape 名称 | 描述 |
---|---|---|
Shape.Rect | rect | 矩形。 |
Shape.Circle | circle | 圆形。 |
Shape.Ellipse | ellipse | 椭圆。 |
Shape.Polygon | polygon | 多边形。 |
Shape.Polyline | polyline | 折线。 |
Shape.Path | path | 路径。 |
Shape.Image | image | 图片。 |
Shape.HTML | html | HTML 节点,使用 foreignObject 渲染 HTML 片段。 |
定制节点
我们可以通过 markup
和 attrs
来定制节点的形状和样式,markup
可以类比 HTML
,attrs
类比 CSS
。强烈建议仔细阅读 markup 和 attrs 文档。
接下来我们会遇到一个问题,定制的内容要被多个节点使用,是不是需要每个节点都重新定义一次呢?答案是否定的,X6 提供了便捷的方式,可以让不同的节点复用配置。
修改节点(大小颜色)
在渲染完成之后,我们还可以通过 API 修改节点的所有属性。我们会常用到下面两个方法:
添加边
节点和边都有共同的基类 Cell,除了从 Cell
继承属性外,还支持以下选项。
属性名 | 类型 | 默认值 | 描述 |
---|---|---|---|
source | TerminalData | - | 源节点或起始点。 |
target | TerminalData | - | 目标节点或目标点。 |
vertices | Point.PointLike[] | - | 路径点。 |
router | RouterData | - | 路由。 |
connector | ConnectorData | - | 连接器。 |
labels | Label[] | - | 标签。 |
defaultLabel | Label | 默认标签 | 默认标签。 |
graph.addEdge({
shape: 'edge',
source: 'node1',
target: 'node2',
})
配置边
下面分别看下上面的配置如何使用。
source/target
边的源和目标节点(点)。
graph.addEdge({ source: rect1, // 源节点 target: rect2, // 目标节点})
graph.addEdge({ source: 'rect1', // 源节点 ID target: 'rect2', // 目标节点 ID})
graph.addEdge({ source: { cell: rect1, port: 'out-port-1' }, // 源节点和连接桩 ID target: { cell: 'rect2', port: 'in-port-1' }, // 目标节点 ID 和连接桩 ID})
graph.addEdge({ source: 'rect1', // 源节点 ID target: { x: 100, y: 120 }, // 目标点})
router
路由 router
将对 vertices
进一步处理,并在必要时添加额外的点,然后返回处理后的点。例如,经过 orth 路由处理后,边的每一条链接线段都是水平或垂直的。
graph.addEdge({
source: rect1,
target: rect2,
vertices: [
{ x: 100, y: 200 },
{ x: 300, y: 120 },
],
// 如果没有 args 参数,可以简写为 router: 'orth'
router: {
name: 'orth',
args: {},
},
})
X6 默认提供了以下几种路由,点击下面的链接查看每种路由的使用方式。
另外,我们也可以注册自定义路由
使用箭头
我们定义了 sourceMarker
和 targetMarker
两个特殊属性来为边定制起始和终止箭头。例如,对 Shape.Edge
我们可以通过 line
选择器来指定起始和终止箭头。
内置箭头
X6 提供了以下几种内置箭头,使用时只需要指定箭头名和参数(可省略)即可。
graph.addEdge({
shape: 'edge',
sourece: [100, 100],
target: [500, 500],
attrs: {
line: {
sourceMarker: 'block', // 实心箭头
targetMarker: {
name: 'ellipse', // 椭圆
rx: 10, // 椭圆箭头的 x 半径
ry: 6, // 椭圆箭头的 y 半径
},
},
},
})
连接桩
阅读时间约 6 分钟
在本章节中主要介绍连接桩相关的知识,通过阅读你可以了解到
- 如何在节点中配置连接桩
- 连接桩的增、删、改
- 如何配置连接桩的位置
- 如何配置连接桩上标签的位置
配置连接桩
首先我们将具有相同行为和外观的连接桩归为同一组,并通过 groups
选项来设置分组,该选项是一个对象 { [groupName: string]: PortGroupMetadata }
,组名为键,值为每组连接桩的默认选项,支持的选项如下:
interface PortGroupMetadata {
markup?: Markup // 连接桩 DOM 结构定义。
attrs?: Attr.CellAttrs // 属性和样式。
zIndex?: number | 'auto' // 连接桩的 DOM 层级,值越大层级越高。
// 群组中连接桩的布局。
position?: [number, number] | string | { name: string; args?: object }
label?: {
// 连接桩标签
markup?: Markup
position?: {
// 连接桩标签布局
name: string // 布局名称
args?: object // 布局参数
}
}
}
然后我们配置 items
,items
是一个数组 PortMetadata[]
,数组的每一项表示一个连接桩,连接桩支持的选项如下:
interface PortMetadata {
id?: string // 连接桩唯一 ID,默认自动生成。
group?: string // 分组名称,指定分组后将继承分组中的连接桩选项。
args?: object // 为群组中指定的连接桩布局算法提供参数, 我们不能为单个连接桩指定布局算法,但可以为群组中指定的布局算法提供不同的参数。
markup?: Markup // 连接桩的 DOM 结构定义。指定该选项后将覆盖 `group` 指代的群组提供的默认选项。
attrs?: Attr.CellAttrs // 元素的属性样式。指定该选项后将覆盖 `group` 指代的群组提供的默认选项。
zIndex?: number | 'auto' // 连接桩的 DOM 层级,值越大层级越高。指定该选项后将覆盖 `group` 指代的群组提供的默认选项。
label?: {
// 连接桩的标签。指定该选项后将覆盖 `group` 指代的群组提供的默认选项。
markup?: Markup // 标签 DOM 结构
position?: {
// 标签位置
name: string // 标签位置计算方法的名称
args?: object // 标签位置计算方法的参数
}
}
}
从下面例子代码中可以清晰看到连接桩的定义方式。
修改连接桩
节点上有丰富的 API 对连接桩进行增、删、改操作。
// 添加连接桩node.addPort({ group: 'top', attrs: { text: { text: 'xx', }, },})
// 删除连接桩node.removePort(portId)
// 更新连接桩node.portProp(portId, 'attrs/circle/stroke', color)
连接桩位置
连接桩布局算法只能通过 groups
中的 position
选项来指定,因为布局算法在计算连接桩位置时需要考虑到群组中的所有连接桩,我们在单个连接桩中可以通过 args
选项来影响该连接桩的布局结果。
我们默认提供了下面几种连接桩布局算法,同时支持自定义连接桩布局算法并注册使用,点击下面的链接可以了解每种布局算法的使用方法。
absolute
绝对定位。left
矩形节点左侧均匀分布。right
矩形节点右侧均匀分布。top
矩形节点顶部均匀分布。bottom
矩形节点底部均匀分布。line
沿指定的线均匀分布。ellipse
沿椭圆圆弧分布。ellipseSpread
沿椭圆均匀分布。
连接桩标签位置
在 groups
的 label.position
选项和节点的 items.label.position
选项中都可以指定标签的位置。
我们默认提供了下面几种标签位置,也支持自定义标签位置并注册使用,点击下面的链接了解每种标签位置的使用方法。
left
标签位于连接桩左侧。right
标签位于连接桩右侧。top
标签位于连接桩上方。bottom
标签位于连接桩下方。inside
标签位于节点内围(靠近边线的内侧)。outside
标签位于节点外围(靠近边线的外侧)。insideOriented
标签位于节点内围,而且根据所在方位自动调整文本的方向。outsideOriented
标签位于节点外围,而且根据所在方位自动调整文本的方向。radial
标签位于圆形或椭圆形节点的外围。radialOriented
标签位于圆形或椭圆形节点的外围,并使标签文本自动沿圆弧方向旋转。
交互
连线
连线交互规则都是通过 connecting
配置来完成,完整的配置参考 API。下面介绍一些常用的功能。
allowXXX
可以通过 allowXXX
配置来定义连线能否连接到对应的位置。默认支持以下项:
allowBlank
:是否允许连接到画布空白位置的点,默认为true
。allowLoop
:是否允许创建循环连线,即边的起始节点和终止节点为同一节点,默认为true
。allowNode
:是否允许边连接到节点(非节点上的连接桩),默认为true
。allowEdge
:是否允许边连接到另一个边,默认为true
。allowPort
:是否允许边连接到连接桩,默认为true
。allowMulti
:是否允许在相同的起始节点和终止之间创建多条边,默认为true
。
它们的值都支持以下两种类型:
new Graph({
connecting: {
allowNode: true, // boolean
},
})
// 函数形式,多用于动态控制连接限制
new Graph({
connecting: {
allowNode(args) {
return true
},
},
})
提示
allowMulti
支持设置为字符串 withPort
,代表在起始和终止节点的相同连接桩之间只允许创建一条边(即起始和终止节点之间可以创建多条边,但必须要连接在不同的连接桩上)。
视图交互事件
通过鼠标、键盘或者各种可交互的组件与应用产生交互时触发的事件。
鼠标事件
事件 | cell 节点/边 | node 节点 | port 连接桩 | edge 边 | blank 画布空白区域 |
---|---|---|---|---|---|
单击 | cell:click | node:click | node:port:click | edge:click | blank:click |
双击 | cell:dblclick | node:dblclick | node:port:dblclick | edge:dblclick | blank:dblclick |
右键 | cell:contextmenu | node:contextmenu | node:port:contextmenu | edge:contextmenu | blank:contextmenu |
鼠标按下 | cell:mousedown | node:mousedown | node:port:mousedown | edge:mousedown | blank:mousedown |
移动鼠标 | cell:mousemove | node:mousemove | node:port:mousemove | edge:mousemove | blank:mousemove |
鼠标抬起 | cell:mouseup | node:mouseup | node:port:mouseup | edge:mouseup | blank:mouseup |
鼠标滚轮 | cell:mousewheel | node:mousewheel | - | edge:mousewheel | blank:mousewheel |
鼠标进入 | cell:mouseenter | node:mouseenter | node:port:mouseenter | edge:mouseenter | graph:mouseenter |
鼠标离开 | cell:mouseleave | node:mouseleave | node:port:mouseleave | edge:mouseleave | graph:mouseleave |
注意
需要注意的是,这里的 mousemove
事件和通常的鼠标移动事件有所区别,它需要在鼠标按下后移动鼠标才能触发。
除了 mouseenter
和 mouseleave
外,事件回调函数的参数都包含鼠标相对于画布的位置 x
、y
和鼠标事件对象 e
等参数。
graph.on('cell:click', ({ e, x, y, cell, view }) => {})
graph.on('node:click', ({ e, x, y, node, view }) => {})
graph.on('edge:click', ({ e, x, y, edge, view }) => {})
graph.on('blank:click', ({ e, x, y }) => {})
graph.on('cell:mouseenter', ({ e, cell, view }) => {})
graph.on('node:mouseenter', ({ e, node, view }) => {})
graph.on('edge:mouseenter', ({ e, edge, view }) => {})
graph.on('graph:mouseenter', ({ e }) => {})
自定义点击事件
我们可以在节点/边的 DOM 元素上添加自定义属性 event
或 data-event
来监听该元素的点击事件,例如:
node.attr({
// 表示一个删除按钮,点击时删除该节点
image: {
event: 'node:delete',
xlinkHref: 'trash.png',
width: 20,
height: 20,
},
})
可以通过绑定的事件名 node:delete
或通用的 cell:customevent
、node:customevent
、edge:customevent
事件名来监听。
graph.on('node:delete', ({ view, e }) => {
e.stopPropagation()
view.cell.remove()
})
graph.on('node:customevent', ({ name, view, e }) => {
if (name === 'node:delete') {
e.stopPropagation()
view.cell.remove()
}
})
import React from 'react'
import { Graph } from '@antv/x6'
import './index.less'
Graph.registerNode(
'custom-click-node',
{
markup: [
{
tagName: 'rect',
selector: 'body',
},
{
tagName: 'text',
selector: 'label',
},
{
tagName: 'g',
children: [
{
tagName: 'text',
selector: 'btnText',
},
{
tagName: 'rect',
selector: 'btn',
},
],
},
],
attrs: {
btn: {
refX: '100%',
refX2: -28,
y: 4,
width: 24,
height: 18,
rx: 10,
ry: 10,
fill: 'rgba(255,255,0,0.01)',
stroke: 'red',
cursor: 'pointer',
event: 'node:delete',
},
btnText: {
fontSize: 14,
fill: 'red',
text: 'x',
refX: '100%',
refX2: -19,
y: 17,
cursor: 'pointer',
pointerEvent: 'none',
},
body: {
stroke: '#8f8f8f',
strokeWidth: 1,
fill: '#fff',
rx: 6,
ry: 6,
refWidth: '100%',
refHeight: '100%',
},
label: {
fontSize: 14,
fill: '#333333',
refX: '50%',
refY: '50%',
textAnchor: 'middle',
textVerticalAnchor: 'middle',
},
},
},
true,
)
export default class Example extends React.Component {
private container: HTMLDivElement
componentDidMount() {
const graph = new Graph({
container: this.container,
background: {
color: '#F2F7FA',
},
})
const source = graph.addNode({
shape: 'custom-click-node',
x: 40,
y: 80,
width: 120,
height: 40,
attrs: {
label: {
text: 'Source',
},
},
})
const target = graph.addNode({
shape: 'custom-click-node',
x: 360,
y: 80,
width: 120,
height: 40,
attrs: {
label: {
text: 'Target',
},
},
})
graph.addEdge({
source,
target,
attrs: {
line: {
stroke: '#8f8f8f',
strokeWidth: 1,
},
},
})
graph.on('node:delete', ({ view, e }: any) => {
e.stopPropagation()
view.cell.remove()
})
}
refContainer = (container: HTMLDivElement) => {
this.container = container
}
render() {
return (
<div className="custom-click-app">
<div className="app-content" ref={this.refContainer} />
</div>
)
}
}
节点/边
添加/删除/修改
当节点/边被添加到画布时,触发以下事件:
added
cell:added
node:added
(仅当 cell 是节点时才触发)edge:added
(仅当 cell 是边时才触发)
当节点/边被移除时,触发以下事件:
removed
cell:removed
node:removed
(仅当 cell 是节点时才触发)edge:removed
(仅当 cell 是边时才触发)
当节点/边发生任何改变时,触发以下事件:
changed
cell:changed
node:changed
(仅当 cell 是节点时才触发)edge:changed
(仅当 cell 是边时才触发)
可以在节点/边上监听:
cell.on('added', ({ cell, index, options }) => {})
cell.on('removed', ({ cell, index, options }) => {})
cell.on('changed', ({ cell, options }) => {})
或者在 Graph 上监听:
graph.on('cell:added', ({ cell, index, options }) => {})
graph.on('cell:removed', ({ cell, index, options }) => {})
graph.on('cell:changed', ({ cell, options }) => {})
graph.on('node:added', ({ node, index, options }) => {})
graph.on('node:removed', ({ node, index, options }) => {})
graph.on('node:changed', ({ node, options }) => {})
graph.on('edge:added', ({ edge, index, options }) => {})
graph.on('edge:removed', ({ edge, index, options }) => {})
graph.on('edge:changed', ({ edge, options }) => {})
change:xxx
当调用 setXxx(val, options)
和 removeXxx(options)
方法去改变节点/边的数据时,并且 options.silent
不为 true
时,都将触发对应的 change
事件,并触发节点/边重绘。例如:
cell.setZIndex(2)
cell.setZIndex(2, { silent: false })
cell.setZIndex(2, { anyKey: 'anyValue' })
将触发 Cell 上的以下事件:
change:*
change:zIndex
和 Graph 上的以下事件:
cell:change:*
node:change:*
(仅当 cell 是节点时才触发)edge:change:*
(仅当 cell 是边时才触发)cell:change:zIndex
node:change:zIndex
(仅当 cell 是节点时才触发)edge:change:zIndex
(仅当 cell 是边时才触发)
可以在节点/边上监听:
// 当 cell 发生任何改变时都将被触发,可以通过 key 来确定改变项
cell.on(
'change:*',
(args: {
cell: Cell
key: string // 通过 key 来确定改变项
current: any // 当前值
previous: any // 改变之前的值
options: any // 透传的 options
}) => {
if (key === 'zIndex') {
//
}
},
)
cell.on(
'change:zIndex',
(args: {
cell: Cell
current?: number // 当前值
previous?: number // 改变之前的值
options: any // 透传的 options
}) => {},
)
或者在 Graph 上监听:
graph.on(
'cell:change:zIndex',
(args: {
cell: Cell
current?: number // 当前值
previous?: number // 改变之前的值
options: any // 透传的 options
}) => {},
)
// 当 cell 为节点时触发
graph.on(
'node:change:zIndex',
(args: {
cell: Cell
node: Node
current?: number // 当前值
previous?: number // 改变之前的值
options: any // 透传的 options
}) => {},
)
// 当 cell 为边时触发
graph.on(
'edge:change:zIndex',
(args: {
cell: Cell
edge: Edge
current?: number // 当前值
previous?: number // 改变之前的值
options: any // 透传的 options
}) => {},
)
其他 change
事件如下列表,回调函数的参数与上面提到的 change:zIndex
的参数结构一致。
- Cell
change:*
change:attrs
change:zIndex
change:markup
change:visible
change:parent
change:children
change:tools
change:view
change:data
- Node
change:size
change:angle
change:position
change:ports
change:portMarkup
change:portLabelMarkup
change:portContainerMarkup
ports:added
ports:removed
- Edge
change:source
change:target
change:terminal
change:router
change:connector
change:vertices
change:labels
change:defaultLabel
vertexs:added
vertexs:removed
labels:added
labels:removed
除了上述这些内置的 Key,我们也支持监听自定义的 Key,例如
cell.on('change:custom', ({ cell, current, previous, options }) => {
console.log(current)
})
当通过 cell.prop('custom', 'any data')
方法修改 custom
属性的值时将触发 change:custom
事件。
视图
由于 X6 实现了异步的渲染调度算法,所以节点的添加不一定意味着挂载到画布上。节点在被挂载到画布时以及从画布上卸载时会分别触发单独的事件。
事件名 | 回调参数 | 说明 |
---|---|---|
view:mounted | { view: CellView } | 节点被挂载到画布上时触发。 |
view:unmounted | { view: CellView } | 节点从画布上卸载时触发。 |
graph.on('view:mounted', ({ view }) => {})
graph.on('view:unmounted', ({ view }) => {})
大家还有经常需要在调用 fromJSON
或者 resetCells
后监听画布完成渲染事件,这时候可以使用 render:done
事件来监听 (2.15.1 版本新增)。
graph.on('render:done', () => { // pass})
graph.fromJSON([...])
数据
导出
我们可以调用 graph.toJSON()
方法来导出图中的节点和边,返回一个具有 { cells: [] }
结构的对象,其中 cells
数组按渲染顺序保存节点和边。
其中,导出的节点结构如下:
{
id: string,
shape: string,
position: {
x: number
y: number
},
size: {
width: number
height: number
},
attrs: object,
zIndex: number,
}
边的结构如下:
{
id: string,
shape: string,
source: object,
target: object,
attrs: object,
zIndex: number,
}
{
"cells": [
{
"position": {
"x": 0,
"y": 0
},
"size": {
"width": 100,
"height": 40
},
"attrs": {
"text": {
"text": "Hello"
},
"body": {
"stroke": "#8f8f8f",
"strokeWidth": 1,
"fill": "#fff",
"rx": 6,
"ry": 6
}
},
"visible": true,
"shape": "rect",
"id": "f56d0228-1b2d-4d1f-a549-624a112f1013",
"zIndex": 1
},
{
"position": {
"x": 0,
"y": 240
},
"size": {
"width": 100,
"height": 40
},
"attrs": {
"text": {
"text": "World"
},
"body": {
"stroke": "#8f8f8f",
"strokeWidth": 1,
"fill": "#fff",
"rx": 6,
"ry": 6
}
},
"visible": true,
"shape": "ellipse",
"id": "4aa790be-3370-4735-bc1d-15e6f9aeb9f7",
"zIndex": 2
},
{
"shape": "edge",
"attrs": {
"line": {
"stroke": "#8f8f8f",
"strokeWidth": 1
}
},
"id": "a363d4ea-b0bf-468d-9dfc-0afd87d8691f",
"source": {
"cell": "f56d0228-1b2d-4d1f-a549-624a112f1013"
},
"target": {
"cell": "4aa790be-3370-4735-bc1d-15e6f9aeb9f7"
},
"labels": [
{
"attrs": {
"label": {
"text": "X6"
}
}
}
],
"zIndex": 3
}
]
}
导入
支持节点/边元数据数组 graph.fromJSON(cells: (Node.Metadata | Edge.Metadata)[])
。
graph.fromJSON([
{
id: 'node1',
x: 40,
y: 40,
width: 100,
height: 40,
label: 'Hello',
shape: 'rect',
},
{
id: 'node2',
x: 40,
y: 40,
width: 100,
height: 40,
label: 'Hello',
shape: 'ellipse',
},
{
id: 'edge1',
source: 'node1',
target: 'node2',
shape: 'edge',
},
])
或者提供一个包含 cells
、nodes
、edges
的对象,按照 [...cells, ...nodes, ...edges]
顺序渲染。
graph.fromJSON({
nodes: [],
edges: [],
})
通常,我们通过 graph.fromJSON(...)
来渲染 graph.toJSON()
导出的数据。
提示
当数据中没有提供 zIndex
时,则按照节点/边在数组中的顺序渲染,也就是说越靠前的节点/边,其 zIndex
越小,在画布中的层级就越低
内置工具
工具是渲染在节点/边上的小部件,用于增强节点/边的交互能力,我们分别为节点和边提供了以下内置工具:
节点:
- button 在指定位置处渲染一个按钮,支持自定义按钮的点击交互。
- button-remove 在指定的位置处,渲染一个删除按钮,点击时删除对应的节点。
- boundary 根据节点的包围盒渲染一个包围节点的矩形。注意,该工具仅仅渲染一个矩形,不带任何交互。
边:
- vertices 路径点工具,在路径点位置渲染一个小圆点,拖动小圆点修改路径点位置,双击小圆点删除路径点,在边上单击添加路径点。
- segments 线段工具。在边的每条线段的中心渲染一个工具条,可以拖动工具条调整线段两端的路径点的位置。
- boundary 根据边的包围盒渲染一个包围边的矩形。注意,该工具仅仅渲染一个矩形,不带任何交互。
- button 在指定位置处渲染一个按钮,支持自定义按钮的点击交互。
- button-remove 在指定的位置处,渲染一个删除按钮,点击时删除对应的边。
- source-arrowhead 和 target-arrowhead 在边的起点或终点渲染一个图形(默认是箭头),拖动该图形来修改边的起点或终点。
Server-Sent Events 教程
服务器向浏览器推送信息,除了 WebSocket,还有一种方法:Server-Sent Events(以下简称 SSE)。本文介绍它的用法。
一、SSE 的本质
严格地说,HTTP 协议无法做到服务器主动推送信息。但是,有一种变通方法,就是服务器向客户端声明,接下来要发送的是流信息(streaming)。
也就是说,发送的不是一次性的数据包,而是一个数据流,会连续不断地发送过来。这时,客户端不会关闭连接,会一直等着服务器发过来的新的数据流,视频播放就是这样的例子。本质上,这种通信就是以流信息的方式,完成一次用时很长的下载。
SSE 就是利用这种机制,使用流信息向浏览器推送信息。它基于 HTTP 协议,目前除了 IE/Edge,其他浏览器都支持。
二、SSE 的特点
SSE 与 WebSocket 作用相似,都是建立浏览器与服务器之间的通信渠道,然后服务器向浏览器推送信息。
总体来说,WebSocket 更强大和灵活。因为它是全双工通道,可以双向通信;SSE 是单向通道,只能服务器向浏览器发送,因为流信息本质上就是下载。如果浏览器向服务器发送信息,就变成了另一次 HTTP 请求。
但是,SSE 也有自己的优点。
- SSE 使用 HTTP 协议,现有的服务器软件都支持。WebSocket 是一个独立协议。
- SSE 属于轻量级,使用简单;WebSocket 协议相对复杂。
- SSE 默认支持断线重连,WebSocket 需要自己实现。
- SSE 一般只用来传送文本,二进制数据需要编码后传送,WebSocket 默认支持传送二进制数据。
- SSE 支持自定义发送的消息类型。
因此,两者各有特点,适合不同的场合。
三、客户端 API
3.1 EventSource 对象
SSE 的客户端 API 部署在EventSource
对象上。下面的代码可以检测浏览器是否支持 SSE。
if ('EventSource' in window) { // ... }
使用 SSE 时,浏览器首先生成一个EventSource
实例,向服务器发起连接。
var source = new EventSource(url);
上面的url
可以与当前网址同域,也可以跨域。跨域时,可以指定第二个参数,打开withCredentials
属性,表示是否一起发送 Cookie。
var source = new EventSource(url, { withCredentials: true });
EventSource
实例的readyState
属性,表明连接的当前状态。该属性只读,可以取以下值。
- 0:相当于常量
EventSource.CONNECTING
,表示连接还未建立,或者断线正在重连。- 1:相当于常量
EventSource.OPEN
,表示连接已经建立,可以接受数据。- 2:相当于常量
EventSource.CLOSED
,表示连接已断,且不会重连。
3.2 基本用法
连接一旦建立,就会触发open
事件,可以在onopen
属性定义回调函数。
source.onopen = function (event) { // ... }; // 另一种写法 source.addEventListener('open', function (event) { // ... }, false);
客户端收到服务器发来的数据,就会触发message
事件,可以在onmessage
属性的回调函数。
source.onmessage = function (event) { var data = event.data; // handle message }; // 另一种写法 source.addEventListener('message', function (event) { var data = event.data; // handle message }, false);
上面代码中,事件对象的data
属性就是服务器端传回的数据(文本格式)。
如果发生通信错误(比如连接中断),就会触发error
事件,可以在onerror
属性定义回调函数。
source.onerror = function (event) { // handle error event }; // 另一种写法 source.addEventListener('error', function (event) { // handle error event }, false);
close
方法用于关闭 SSE 连接。
source.close();
3.3 自定义事件
默认情况下,服务器发来的数据,总是触发浏览器EventSource
实例的message
事件。开发者还可以自定义 SSE 事件,这种情况下,发送回来的数据不会触发message
事件。
source.addEventListener('foo', function (event) { var data = event.data; // handle message }, false);
上面代码中,浏览器对 SSE 的foo
事件进行监听。如何实现服务器发送foo
事件,请看下文。
四、服务器实现
4.1 数据格式
服务器向浏览器发送的 SSE 数据,必须是 UTF-8 编码的文本,具有如下的 HTTP 头信息。
Content-Type: text/event-stream Cache-Control: no-cache Connection: keep-alive
上面三行之中,第一行的Content-Type
必须指定 MIME 类型为event-steam
。
每一次发送的信息,由若干个message
组成,每个message
之间用\n\n
分隔。每个message
内部由若干行组成,每一行都是如下格式。
[field]: value\n
上面的field
可以取四个值。
- data
- event
- id
- retry
此外,还可以有冒号开头的行,表示注释。通常,服务器每隔一段时间就会向浏览器发送一个注释,保持连接不中断。
: This is a comment
下面是一个例子。
: this is a test stream\n\n data: some text\n\n data: another message\n data: with two lines \n\n
4.2 data 字段
数据内容用data
字段表示。
data: message\n\n
如果数据很长,可以分成多行,最后一行用\n\n
结尾,前面行都用\n
结尾。
data: begin message\n data: continue message\n\n
下面是一个发送 JSON 数据的例子。
data: {\n data: "foo": "bar",\n data: "baz", 555\n data: }\n\n
4.3 id 字段
数据标识符用id
字段表示,相当于每一条数据的编号。
id: msg1\n data: message\n\n
浏览器用lastEventId
属性读取这个值。一旦连接断线,浏览器会发送一个 HTTP 头,里面包含一个特殊的Last-Event-ID
头信息,将这个值发送回来,用来帮助服务器端重建连接。因此,这个头信息可以被视为一种同步机制。
4.4 event 字段
event
字段表示自定义的事件类型,默认是message
事件。浏览器可以用addEventListener()
监听该事件。
event: foo\n data: a foo event\n\n data: an unnamed event\n\n event: bar\n data: a bar event\n\n
上面的代码创造了三条信息。第一条的名字是foo
,触发浏览器的foo
事件;第二条未取名,表示默认类型,触发浏览器的message
事件;第三条是bar
,触发浏览器的bar
事件。
下面是另一个例子。
event: userconnect data: {"username": "bobby", "time": "02:33:48"} event: usermessage data: {"username": "bobby", "time": "02:34:11", "text": "Hi everyone."} event: userdisconnect data: {"username": "bobby", "time": "02:34:23"} event: usermessage data: {"username": "sean", "time": "02:34:36", "text": "Bye, bobby."}
4.5 retry 字段
服务器可以用retry
字段,指定浏览器重新发起连接的时间间隔。
retry: 10000\n
两种情况会导致浏览器重新发起连接:一种是时间间隔到期,二是由于网络错误等原因,导致连接出错。
五、Node 服务器实例
SSE 要求服务器与浏览器保持连接。对于不同的服务器软件来说,所消耗的资源是不一样的。Apache 服务器,每个连接就是一个线程,如果要维持大量连接,势必要消耗大量资源。Node 则是所有连接都使用同一个线程,因此消耗的资源会小得多,但是这要求每个连接不能包含很耗时的操作,比如磁盘的 IO 读写。
下面是 Node 的 SSE 服务器实例。
var http = require("http"); http.createServer(function (req, res) { var fileName = "." + req.url; if (fileName === "./stream") { res.writeHead(200, { "Content-Type":"text/event-stream", "Cache-Control":"no-cache", "Connection":"keep-alive", "Access-Control-Allow-Origin": '*', }); res.write("retry: 10000\n"); res.write("event: connecttime\n"); res.write("data: " + (new Date()) + "\n\n"); res.write("data: " + (new Date()) + "\n\n"); interval = setInterval(function () { res.write("data: " + (new Date()) + "\n\n"); }, 1000); req.connection.addListener("close", function () { clearInterval(interval); }, false); } }).listen(8844, "127.0.0.1");
请将上面的代码保存为server.js
,然后执行下面的命令。
$ node server.js
上面的命令会在本机的8844
端口,打开一个 HTTP 服务。
然后,打开这个网页,查看客户端代码并运行。