实现流程图和类流程图的工具主要需要解决数据 -> 图形交互两方面问题。在实现图形元素时也有canvas,SVG,canvas with DOM,SVG with DOM,DOM with canvas一些实现方式。

canvas和SVG的实现方式区别比较明显:

  • 大规模元素、频繁重绘上,canvas完胜
  • 强调光影效果上,canvas小胜
  • 强调导出图片上,canvas小胜
  • 强调元素可交互上,SVG完胜
  • 强调画图元素可缩放上,SVG完胜

使用SVG实现时,元素规模大以及频繁重绘时会出现卡顿现象,在大规模元素场景下交互也会有卡顿。使用canvas实现时,保证流程图元素的可交互性将成为头疼的难题,开发者需要自己模拟浏览器的一部分行为。

下面是一些流程图实现基础的对比。

d3

d3着眼在数据可视化,重点在使用不同layout组织数据,完成可视化。

d3最初是天然支持SVG的,这点从类jQuery的API也能看出来。d3和canvas的结合上,绘制需要额外的data binding操作,周期性地将虚拟的DOM节点映射到canvas上,重绘得到下一帧画面。要实现canvas可交互的话也需要一些hack的手段。基于d3实现流程图并不划算。

zrender

zrender是一个canvas画图的基础库。它并不负责数据的组织和渲染,需要自己完成这一部分工作。但是zrender提供了让canvas可交互的重要功能。

zrender下,mixin了Eventful特性的元素上可以监听交互事件。Eventful只是为元素提供了类似EventEmitter的功能。真正实现元素可交互的handler。

handler内会拦截发生在canvas内的click/mousedown/mouseup/mousewheel/dblclick/contextmenu事件,交予prototype内对应的处理方法处理,handler内有下面几个关键方法:

  • mousemove,监听canvas内mousemove事件,调用findHover得到当前位置对应的元素,根据情况调用dispatchToElement方法,分发mouseoutmouseovermousemove给刚才得到的元素实例
  • dispatchToElement,分发事件到对应实例,将事件对象封装,trigger实例的对应事件handler,并通过el.parent向上冒泡
  • findHover,指定x, y坐标寻找该坐标位置的元素。从storage中拿到所有的displayable的list。挨个调用isHover判断displayable和[x, y]坐标的关系
  • isHover函数,根据displayable的rectHover属性,即是否使用包围盒检测鼠标进入。调用displayable的rectContaincontain检测是否在其中。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function isHover(displayable, x, y) {
if (displayable[displayable.rectHover ? 'rectContain' : 'contain'](x, y)) {
var el = displayable;
var isSilent;
while (el) {
// If clipped by ancestor.
// FIXME: If clipPath has neither stroke nor fill,
// el.clipPath.contain(x, y) will always return false.
if (el.clipPath && !el.clipPath.contain(x, y)) {
return false;
}
if (el.silent) {
isSilent = true;
}
el = el.parent;
}
return isSilent ? SILENT : true;
}

return false;
}

先简单看下storage,因为zrender里绘制的元素之间没有逻辑关联,因此需要有一个全局存储storage去统一管理加入的Group或Shape。storage的getDisplayList方法返回所有图形的绘制队列。

1
2
3
4
5
6
7
getDisplayList: function (update, includeIgnore) {
includeIgnore = includeIgnore || false;
if (update) {
this.updateDisplayList(includeIgnore);
}
return this._displayList;
},

注:方法中提到的updateDisplayList用于更新图形的绘制队列,在每次绘制前调用,它会深度优先遍历整个树,更新所有的变换后,根据优先级排序得到新的绘制队列。

在displayable的基类中,contain方法只是单纯调用了rectContain(子类都有区别于rectContain的自己的实现)。在rectContain中,获取到坐标相对于图形的坐标(transformCoordToLocal)和图形的包围盒(getBoundingRect)。这里先说简单的RectContain

1
2
3
4
5
rectContain: function (x, y) {
var coord = this.transformCoordToLocal(x, y);
var rect = this.getBoundingRect();
return rect.contain(coord[0], coord[1]);
}

其中getBoundingRect是各自类自己实现的。除了个别情况,如Text,形状都基于Path类。在Path的getBoundingRect中可以看到,path的绘制又额外包装了一层PathProxygetBoundingRect也是使用的PathProxy的方法。在实现上,PathProxy把绘制路径的操作命令拆分成了命令数组。通过记录每一段子路径上x、y的最大最小值,再将所有这些极值比较得到最后的最值。在PathProxy返回结果后,根据描边粗细得到最终结果。

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
getBoundingRect: function () {
// _rect变量做缓存用,计算完成后只在重绘时置空,避免重复计算
var rect = this._rect;
var style = this.style;
var needsUpdateRect = !rect;
if (needsUpdateRect) {
var path = this.path;
if (!path) {
// Create path on demand.
path = this.path = new PathProxy();
}
if (this.__dirtyPath) {
path.beginPath();
this.buildPath(path, this.shape, false);
}
rect = path.getBoundingRect();
}
this._rect = rect;

if (style.hasStroke()) {
// ...

// Return rect with stroke
return rectWithStroke;
}

return rect;
},

Displayable继承自ElementElement通过mixin得到来自Transformable中的transformCoordToLocal方法。这里要说到,zrender中元素和Group都有一个构造时的初始位置,而后的所有变化都是作为transform叠加在元素上的。例如拖拽元素对应的是“原始位置 + transform”而不是一个“新位置”。

在每次变换后,Transformable中的updateTransform方法都会调用,设置自身invTransform属性为这次变化的逆矩阵。在transformCoordToLocal中对向量[x, y]应用这个逆矩阵即可得到点相对于当前形状的位置(可以理解成将点逆变换到形状变换前的位置)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
transformableProto.updateTransform = function () {
//...

m = m || matrix.create();

// ...

// 保存这个变换矩阵
this.transform = m;

// ...

this.invTransform = this.invTransform || matrix.create();
matrix.invert(this.invTransform, m);
};
// ...
transformableProto.transformCoordToLocal = function (x, y) {
var v2 = [x, y];
var invTransform = this.invTransform;
if (invTransform) {
vector.applyTransform(v2, v2, invTransform);
}
return v2;
};

综合这两个方法即可判断点是否在某元素的包围盒中。

判断contain时,首先需要满足rectContain的关系。之后根据描边和填充情况,执行contain/path下对应的containcontainStroke方法。前者实际上是后者stroke为0时的特殊情况。除了path外,可以判断点是否在元素图形内的所有元素在contain下都有对应文件。基本所有的包含都可以转化为指定闭合路径是否包含指定点的问题。

zrender利用PIP(point-in-polygon)问题winding number的解法判断点是否在path中;canvas提供的API中也有isPointInPathisPointInStroke,不过只能针对当前的path。

综上,zrender可以实现canvas内的元素和交互。

g6

g6是antv的一部分,是一个canvas实现的展示关系型数据的JS可视化库。使用canvas的原因应该也在展示大量数据和重绘上更流畅。

使用canvas实现时,g6一样会遇到zrender遇到的实现元素可交互的难题。从处理event的event.js中能看到,关联事件和元素的实现在_getEventObj处完成,剩下的步骤只是额外的封装操作:

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
Util.each(MouseEventTypes, item => {
_events.push(Util.addEventListener(el, item, ev => {
const oldEventObj = this._currentEventObj;
this._oldEventObj = oldEventObj;
this._processEventObj(ev);
const currentEventObj = this._currentEventObj;

// ...
}
}));
});

_processEventObj(ev) {
const graph = this.graph;
const canvas = graph.get('_canvas');
const frontCanvas = graph.get('_frontCanvas');
const evObj = this._getEventObj(ev, canvas);
const frontEvObj = this._getEventObj(ev, frontCanvas);

// ...
}

_getEventObj(ev, canvas) {
const graph = this.graph;
const clientX = ev.clientX;
const clientY = ev.clientY;
const canvasPoint = canvas.getPointByClient(clientX, clientY);
const point = this._parsePoint(canvasPoint.x, canvasPoint.y); // 根据pixel ratio做一个转换
const shape = canvas.getShape(canvasPoint.x, canvasPoint.y);
const item = graph.getItemByShape(shape);

// ...
// ...
}

p.s. 另说一点,frontCanvas的作用是绘制拖拽状态中的元素和辅助线等信息。

最关键的方法getPointByClientgetShape来自Graph的canvas属性,这个属性通过‘@antv/g’(G2)的canvas构造得来。

1
2
3
const G = require('@antv/g');
// ...
const Canvas = G.Canvas;

在G2中,Canvas继承自Group,可以认为Canvas本身已经扮演了根节点的角色。Canvas判断坐标对应元素的方法getShape(x,y)也来自Group。此方法遍历Group下所有元素(包括单个元素或Group),判断点[x, y]是否在范围内:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function find(children, x, y) {
let rst;
for (let i = children.length - 1; i >= 0; i--) {
const child = children[i];
if (child.__cfg.visible && child.__cfg.capture) {
// 是Group就继续向下寻找
if (child.isGroup) {
rst = child.getShape(x, y);
} else if (child.isHit(x, y)) {
rst = child;
}
}
if (rst) {
break;
}
}
return rst;
}

关键的child.isHit方法类似zrender里的contain方法。区别使用包围盒还是自身范围判断。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
isHit(x, y) {
const self = this;
const v = [ x, y, 1 ];
self.invert(v); // canvas

if (self.isHitBox()) {
const box = self.getBBox();
if (box && !Inside.box(box.minX, box.maxX, box.minY, box.maxY, v[0], v[1])) {
return false;
}
}
const clip = self.__attrs.clip;
if (clip) {
if (clip.inside(x, y)) {
return self.isPointInPath(v[0], v[1]);
}
} else {
return self.isPointInPath(v[0], v[1]);
}
return false;
}

使用包围盒时用getBBox()判断,类似zrender;否则使用isPointInPath。这点上g2不同,它只对特殊的闭合曲线如圆、矩形、贝塞尔曲线等等进行自己的实现。对一般性的path,直接使用上面提到的canvas的API来判断。

processOn

processOn严格意义上是一个产品,类似于在线的visio,编辑很流畅。使用DOM + canvas实现。具体来说:

  • DOM绘制每个元素占位,响应交互
  • canvas绘制每个DOM内的图形本身

这么做的好处在有二:1. 天然解决了元素交互的问题;2. 更平滑的元素拖拽效果。

类似的还有jsPlumb这样的使用SVG的方案,使用SVG的优势体现在交互更容易实现。

一个合法检测的Validator

合法检测是在有表单或数据提交时常见的需求,随手撸了一个。由于考虑的业务场景比较复杂,代码也有点臃肿。拓展时,只需要修改上面的提示信息常量和新的test方法即可。也支持自己传入判断函数和message,针对比较复杂的校验情况。

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
const TYPE_VALIDATE_MESSAGE = {
isNumber: '不是一个数字',
isObject: '不是一个对象',
isArray: '不是一个数组',
lt: '数值过大',
gt: '数值过小',
st: '长度过短',
ht: '长度过长'
notEmpty: '需要非空',
len: '长度不合规范',
in: '不在可选范围内',
reg: '无法匹配指定模式'
}

// 供validate使用
util.test = {
isObject: _ => Object(_) === _,
isNumber: _ => typeof _ == 'number',
isArray: _ => Array.isArray(_),
notEmpty: _ => _.length > 0,
lt: (num, limit) => num < limit,
gt: (num, limit) => num > limit,
st: (_, limit) => _.length < limit,
ht: (_, limit) => _.length > limit,
in: (_, range) => ~range.indexOf(_),
len: (_, length) => _.length === length,
reg: (_, reg) => reg.test(_)
}

/**
* 数据校验
* @author: shenlvmeng
* @params value {any} 校验数值 输入单个数值使用verify校验 输入对象使用verifyMap校验
* @method verify 校验单个数值
* @method verifyMap 校验对象
* @return {Object} 包含success和message的对象
*/
util.validate = (value) => {
// 方便传递到外界修改
let message = { content: '' };
function verify(val, validators, key) {
if (!Array.isArray(validators)) validators = [validators];
return validators.every(v => {
let isRight;
if (util.test.isObject(v)) {
isRight = util.test[Object.keys(v)[0]](val, Object.values(v)[0])
!isRight && (message.content = `${key || val}${TYPE_VALIDATE_MESSAGE[Object.keys(v)[0]]}`);
} else {
isRight = util.test[v](val);
!isRight && (message.content = `${key || val}${TYPE_VALIDATE_MESSAGE[v]}`);
}
return isRight;
});
}
return {
/**
* 单个数值使用
* @params validators { String|Array|Object } 校验函数 必须是util中已定义的函数
* @params String 类型时为函数名; Object类型时为函数名和对应入参; Array时为以上的列表
*/
verify(validators) {
return {
success: verify(value, validators),
message: message.content,
}
},
/**
* 校验整个对象时使用
* @params config { Object } 校验函数 针对Object中每个key的校验
* @params 每个key的value服从verify的格式,当key为function类型时交给调用方自己处理
*/
verifyMap(config) {
if (!util.test.isObject(value)) throw new Error('Invalid value type. It should be an Object.');
return {
success: Object.keys(config).every(key => (
typeof config[key] === 'function' ? config[key](value[key], message)
: verify(value[key], config[key], key))
),
message: message.content,
}
}
}
}

实际应用中发现,verifyMap方法用的比较多。使用样例如下;

1
2
3
4
5
6
7
8
9
10
examine() {
return util.validate(formData).verifyMap({
userName: ['notEmpty', { st: 6 }, { ht: 15 }],
password: ['notEmpty', { st: 6 }, { ht: 15 }],
age: ['isNumber', { lt: 18 }],
gender: {in: ['male', 'female']},
email: {'reg': /email pattern/},
isAccepted: (value, message) => value ? value : (message.content = '请接收用户协议', false)
});
},

Linux清理大文件

  • df -h查看存储情况
  • du -sh * | grep G查找当前目录下大于1G的大文件,挨个确认删除即可

当前页面窗口活动状态检查

利用visibilityChangeAPI,配合visibilityState检查窗口状态。

1
2
3
4
5
6
7
8
9
10
11
// subscribe to visibility change events
document.addEventListener('visibilitychange', function () {
// fires when user switches tabs, apps, goes to homescreen, etc.
if (document.visibilityState === 'hidden') {
document.title = 'Baby, Come Back!'
}
// fires when app transitions from prerender, user returns to the app / tab.
if (document.visibilityState === 'visible') {
document.title = defaultTitle;
}
});

IntersectionObserver

监测页面元素和视口的交错关系的DOM API,目前浏览器支持度不高。可以用来方便地实现懒加载和页面无限滚动。

参考

图种

  • Windows,copy /b image.jpg+zip.rar output.jpg,更改输出图片后缀为rar得到压缩包
  • Linux,cat image.jpg zip.rar > output.jpg,同上

draggable with Vue

业务中有拖拽需求,在试用多个轮子后,最终选择了vue-slicksort

  • Vue Draggable
  • Vue Dragula
  • Vue slicksort
GitHub名 流行度 依赖/原理 功能完善度 文档完善度 使用舒适度 备注
vuedraggable star数3859,很流行 依赖sortable.js,后者依赖HTML5的draggable API 功能丰富强大,有很多别人踩过的坑 文档比较丰富 通过组件包裹形式使用 侵入性小 优 有兼容性问题,Chrome66.0.3359.181 Mac OSX 10.13.1下无法通过拖动换位
vue-dragula star数100+,上次更新1年前 依赖dragula.js 功能一般 不完善,几乎都是dragula的API 通过Vue.use的形式 + directives的方式引入,侵入式一般 使用效果不好,实现时没有拖动和移动的动效
vue-slicksort star数100+,更新较活跃 0依赖,不基于HTML5 drag API,支持触摸屏,兼容性好 功能一般,支持平面拖动 较完善,有样例、API解释和FAQ 略糟糕,需要使用mixin的形式,通常意味着额外的组件定义 展示效果优秀,能满足需求

综上,考虑到只有vue-slicksort能满足需求,故选择之。

canvas由Apple首先提出,现在已经有非常好的浏览器支持度。它和<img>标签很像,但是只有widthheight两个属性,在未设置时,为300px和150px。canvas类似<video><audio>可以设置替换内容,应对标签本身不被支持的情况。另外,canvas不同于<img>,它必须要有一个</canvas>作为闭合标志。

canvas是一个2D渲染上下文环境(就像webGL是3D渲染上下文环境),在获取到<canvas>元素后,可以通过其getContext方法得到渲染上下文和相关功能,通常传入2d,用来绘制2D图案。

形状

canvas.getContext('2d')得到一个CanvasRenderingContext2D对象。剩下的绘制操作都通过调用对象上的API实现。

canvas的坐标系系统和svg一样,从左上角开始,向右和向下为正,坐标轴单位为像素。下面列出最常见的绘制图形API。

  • fillRect(x, y, width, height) 绘制矩形
  • strokeRect(x, y, width, height) 绘制矩形边框
  • clearRect(x, y, width, height) 擦除矩形范围

path被用来绘制通用曲线,注意path都是封闭的。绘制路径有4步:

  • beginPath() 新建一条路径,生成之后,图形绘制命令被指向到路径上生成路径。
  • 使用画图命令去画出路径
  • closePath() 闭合路径
  • 可选 fill() 通过填充路径的内容区域生成实心的图形,使用fill()时可以不手动闭合路径
  • stroke(),为路径添加描边
1
2
3
4
5
6
// 样例
ctx.beginPath();
ctx.moveTo(75,50);
ctx.lineTo(100,75);
ctx.lineTo(100,25);
ctx.fill();
所有的路径通过`stroke()`和`fill()`才能体现效果。

里面包含了常见的moveTolineTo,表示移动画笔/画直线到(x,y)处。画曲线时可以选择:

  • arc(x, y, radius, startAngle, endAngle, anticlockwise)
    画一个以(x,y)为圆心的以radius为半径的圆弧(圆),从startAngle开始到endAngle结束,按照anticlockwise给定的方向(默认为顺时针)来生成。
  • arcTo(x1, y1, x2, y2, radius) 根据给定的控制点和半径画一段圆弧,再以直线连接两个控制点。
  • quadraticCurveTo(cp1x, cp1y, x, y) 绘制二次贝塞尔曲线,cp1x,cp1y为一个控制点,x,y为结束点。
  • bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y) 绘制三次贝塞尔曲线,cp1x,cp1y为控制点一,cp2x,cp2y为控制点二,x,y为结束点。

path2D

通过new path2D()用path2D声明子路径,允许你保留和重用路径对象。除了CanvasRenderingContext2D对象的API外,还可以用addPath新增路径到path2D对象中,它还支持通过SVG格式的字符串导入为路径。

样式

通过修改fillStylestrokeStyle改变当前填充和描边的默认颜色。支持的颜色格式有

  • 颜色名
  • hex
  • rgb
  • rgba

通过globalAlpha修改画布里的所有图形的透明度,取值在0到1之间。

线型样式选择有:

  • lineWidth = value 设置线条宽度。
  • lineCap = type 设置线条末端样式。默认为butt,还有round、square可选。
  • lineJoin = type 设定线条与线条间接合处的样式。round, bevel 和 miter三种可选,miter时,miterLimit可以限制尖角的高度。
  • miterLimit = value 限制当两条线相交时交接处最大长度;所谓交接处长度(斜接长度)是指线条交接处内角顶点到外角顶点的长度

设置虚线时,setLineDash(segments)设置当前虚线样式。lineDashOffset = value设置虚线样式的起始偏移量。getLineDash()返回一个包含当前虚线样式,长度为非负偶数的数组。

渐变

渐变需要先指定类型和覆盖范围:

  • createLinearGradient(x1, y1, x2, y2) 方法接受4个参数,表示渐变的起点 (x1,y1) 与终点 (x2,y2)。
  • createRadialGradient(x1, y1, r1, x2, y2, r2) 方法接受6个参数,前三个定义一个以 (x1,y1) 为原点,半径为r1的圆,后三个参数则定义另一个以 (x2,y2) 为原点,半径为r2的圆。

创建的渐变通过addColorStop(position, color)添加多个color stop。position参数是一个0.0与1.0之间的数值,表示渐变中颜色所在的相对位置。渐变是一种样式,通过指定给fillStyle或是strokeStyle发挥作用

模式

通过createPattern(image, type)创建pattern,Image可以是一个Image对象的引用,或者另一个canvas对象。Type描述重复的格式,是下面的字符串值之一:repeat,repeat-x,repeat-y 和 no-repeat。

同样,通过指定给fillStylestrokeStyle使用。

阴影

  • shadowOffsetX = float
  • shadowOffsetY = float
    shadowOffsetX和shadowOffsetY用来设定阴影在X和Y轴的延伸距离,它们是不受变换矩阵所影响的。负值表示阴影会往上或左延伸,正值则表示会往下或右延伸,它们默认都为0。
  • shadowBlur = float shadowBlur 用于设定阴影的模糊程度,默认为 0。
  • shadowColor = color,默认为黑色

canvas有两种填充规则non-zeroeven-odd,默认为前者。

文字

  • fillText(text, x, y [, maxWidth]) 在指定的(x,y)位置填充指定的文本,绘制的最大宽度是可选的
  • strokeText(text, x, y [, maxWidth]) 在指定的(x,y)位置绘制文本边框,绘制的最大宽度是可选的
1
2
ctx.font = "48px PingFangSC";
ctx.strokeText("Hello world", 10, 50);

除了font(语法和CSS的font相同)外,还有下面的选择:

  • textAlign = value 文本对齐选项. 可选的值包括:start, end, left, right or center. 默认值是 start。
  • textBaseline = value 基线对齐选项. 可选的值包括:top, hanging, middle, alphabetic, ideographic, bottom。默认值是 alphabetic。
  • direction = value 文本方向

另外,可以通过measureText(),得到文本绘制的宽度。

使用图片

canvas里可以引入Image对象或其他canvas元素,或者通过URL方式使用图片。

  • 使用相同页面内的图片,使用正常地获取元素的方式获取即可
  • 使用其它域名下的图片,在HTMLImageElement上使用crossOrigin属性,可以请求加载其它域名上的图片。若服务器不允许跨域加载,则会污染canvas,即不能导出数据
  • 使用canvas,按获取元素的方式获取即可

image可以指定src属性为URL或data:url的形式。甚至引入<video>使用视频帧作为image。绘制图片时,使用:

  • drawImage(image, x, y) 其中image是image或者canvas对象,x和y是其在目标canvas里的起始坐标。
  • drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight)是包含了缩放和切片后完整的drawImage用法

变形和裁剪

saverestore用来保存和恢复canvas状态。每当save()方法被调用后,当前的状态就被推送到栈中保存。状态包括所有变形、样式信息。每次restore时会弹出栈顶的状态。建议在做变形和裁剪前保存状态

变形用到的属性和CSS很像:

  • translate(x,y)改变当前原点位置,
  • rotate(angle)以当前原点为圆心旋转画布,angle为弧度值。
  • scale(x, y)缩放当前canvas中的图形大小,x和y分别表示两轴上的缩放因子
  • transform(m11, m12, m21, m22, dx, dy)通过变形矩阵进行变换
  • setTransform(m11, m12, m21, m22, dx, dy)先还原为单位矩阵,再按入参的矩阵变换
  • resetTransform重置变形为单位矩阵

globalCompositeOperation定义了图形相互重叠时的处理策略,类似PS中图层的混合模式,默认为darker,还有很多别的选项

clip()即裁剪方法,和fill以及stroke类似,不过clip将路径对应的部分裁剪出指定区域。

动画

canvas只是一个画布,画出的东西都会保持原样。制作动画只能采取重绘,逐帧绘制,而每一帧包括下面几步:

  • 清空canvas,可以使用clearRect方法
  • 可选,保存当前状态
  • 使用上面提过的种种方法绘制下一帧
  • 可选,恢复状态

绘制动画通常要结合用户交互以及setTimeoutsetIntervalrequestAnimationFrame

MDN给个一个小球的组合动画可以参考。

像素级操作

ImageData接口描述<canvas>元素的一个包含像素数据的区域。它包含width, height, data单个只读属性。ImageData可以通过ctx.createImageData(width, height)或者从已有对象中创建,除此创建的所有像素都是透明黑。

可以用getImageData(left, top, width, height)方法获取指定范围的ImageData信息,当widthheight都为1时,取得当前像素信息。

使用putImageData(imageData, dx, dy)可以在当前画布(dx, dy)处绘制imageData像素数据。imageSmoothingEnabled默认开启,关闭后可以在图片缩放时看到清楚颗粒化的细节。

导出

主要有三种用法:

  • canvas.toDataURL('image/png'),默认将canvas导出成png文件
  • canvas.toDataURL('image/jpeg', quality),quality指定在0到1之间,默认为0.92。
  • canvas.toBlob(callback, type, encoderOptions),这个创建了一个在画布中的代表图片的Blob对像

toDataURL除了上面两种导出格式还有别的选择。

交互

<canvas>标签只是一个位图,它并不提供任何已经绘制在上面的对象的信息。canvas的内容不能像语义化的HTML一样暴露给一些协助工具。一般来说,你应该避免在交互型的网站或者App上使用canvas。

addHitRegion(options)可以将添加一个点击区域,options可以参考MDN文档,鼠标事件如果触发在点击区域中,会带上region用于定位点击区域。

另外drawFocusIfNeeded()scrollPathIntoView()方法还可以绘制焦点圈。

性能问题

  • 预渲染相似或重复对象
  • 为了避免抗锯齿带来的额外运算,减少使用浮点数
  • 在离屏canvas中缓存图片的不同尺寸,不要用drawImage()去缩放它们
  • 使用多层画布去画一个复杂的场景,比如使用多层画布,描绘不同层级的内容。
    1
    2
    3
    4
    5
    <div id="stage">
    <canvas id="ui-layer" width="480" height="320"></canvas>
    <canvas id="game-layer" width="480" height="320"></canvas>
    <canvas id="background-layer" width="480" height="320"></canvas>
    </div>
  • 用CSS画大的背景图
  • 用CSS transforms特性缩放画布,建议不要将小画布放大,而是去将大画布缩小
  • 尽可能避免text rendering
  • 尽可能避免 shadowBlur
  • window.requestAnimationFrame()性能优于window.setInterval()

本文是《计算机程序的构造和解释》的笔记

序中其实也包含了很多睿智的观点,值得细细体会。

  • “每一个计算机程序都是现实中的或者精神中的某个过程的一个模型”
  • “我们很少能通过自己的程序将这种过程模拟到永远令人满意的程度”
  • “不幸的是,随着程序变得更大更复杂(实际上它们几乎总是如此),这种描述本身的适宜性,一致性和正确性也都变得非常值得怀疑了”
  • “如何利用一些已经证明和有价值的组织技术,将这些结构组合成更大的结构,这些都是至关重要的”
  • “将我们的Lisp程序变换到‘机器’程序的过程本身也是抽象模型,是通过程序设计做出来的。研究和构造它们,能使人更加深刻地理解与任何模型的程序设计有关的程序组织问题”
  • “计算机永远都不够大也不够快。硬件技术的每一次突破都带来了更大规模的程序设计事业,新的组织原理,以及更加丰富的抽象模型。每个读者都应该反复问自己‘到哪里才是头儿,到哪里才是头儿’——但是不要问的过于频繁,以免忽略了程序设计的乐趣,使自己陷入一种喜忧参半的呆滞状态中”
  • “Pascal是为了建造金字塔——壮丽辉煌,令人震撼,是由各就其位的沉重巨石筑起的静态结构,而Lisp则是为了构造有机体——同样壮丽辉煌并令人震撼,由各就其位但却永不静止的无数简单的有机体片段构成的动态结构”
  • “Lisp程序大大抬高了函数库的地位,使其可用性超越了催生它们的那些具体应用”
  • “采用100个函数在一种数据结构上操作,远远优于用10个函数在10个数据结构上操作。作为这些情况的必然后果,金字塔矗立在那里千年不变,而有机体则必须演化,否则会死亡”
  • “在任何非常大的程序设计工作中,一条有用的组织原则就是通过发明新语言,去控制和隔离作业模块之间的信息流动”

过程抽象

  • 应用序和正则序
  • 递归和迭代在展开式上的区分,以及尾递归
  • 过程(函数)作为入参、返回值
  • 匿名函数和高阶函数

数据抽象

  • 构造函数和方法函数
  • conscarcdr
  • 序对和list(层次化数据)
  • 表操作和表映射
  • 序列化操作
  • 符号数据(类似字符串)
  • 数据的多种表示(类型)与通用操作

模块化、对象和状态

  • 面向对象和面向流
  • 从时间角度理解赋值和局部状态
  • 赋值的利与弊
  • 赋值带来的环境模型解释(作用域、作用域链)
    • 局部状态
    • 作用域模型的解释
  • 变动的表
    • 区分共享和相等(相同的指针、相同的值)
    • 队列与键值对
  • 描述约束系统
  • 并发(交错进行的读写操作)
    • 串行化和串行化组
    • mutex(mutual exclusion)和实现
    • 死锁(多共享资源)
      • 按顺序获取资源列表
      • 死锁恢复
      • 屏障同步
    • 延时求值的表序列
    • 延时求值的原理
    • 无穷流的构造
    • 流操作和组合

元语言设计

  • 求值器(解释器)的工作与意义
    • 在基本过程上提供组合与抽象构建一个语言
      • 表达式的嵌套
      • 变量维护
      • 过程复合
  • 求值器内核
    • eval 过程体解释
    • apply 过程求值解释
    • 表达式规范化和实现 / 派生表达式
    • 环境模型的数据结构
    • 求值器程序初始化
  • 数据即程序
    • 图灵机和停机问题
  • 内部定义
    • 内部定义是否应该具有时序
    • Y结合子与lambda演算
  • 语法分析与执行分离
  • 惰性求值
    • thunk化,关联表达式和环境
    • 惰性的表
  • 非确定性求值(满足约束的所有可行解)
    • amb和自动回溯
    • amb实现,成功与失败继续过程
  • 逻辑语言设计
    • 类SQL语言基于amb的实现

解释与编译

  • 机器描述
    • 基本指令与子程序(label)
    • 堆栈实现递归
  • 基本指令的实现
    • 类汇编语言
  • 内存管理
    • 表与堆栈的实现
    • garbage collection机制
  • 解释
    • 基础操作实现
    • 尾递归优化解释
  • 编译
    • 与解释有何区别,各自优势
    • env/argl/proc/val/continue寄存器
    • 编译器结构
      • 语法分派
      • 入参:target(存储表达式值的寄存器)与linkage(continue寄存器)
      • 指令序列的结构与构造,分析指令序列,preserving机制避免无谓的堆栈操作
    • 表达式的编译
      • linkage的编译,检查nextreturn的情况
      • 简单、条件表达式、表达式序列的编译
      • lambda表达式的编译
    • 过程的编译
      • 入参的处理
      • 尾递归
    • 指令序列的组合
    • 代码编译的实例
    • 优化变量查找
      • 词法地址
    • 解释与编译
      • 解释:机器语言 -> 用户程序
      • 编译:用户程序 -> 机器语言

最后吐槽下,书是本好书,就是翻译的不太给力,在有些地方强行提高了理解难度。

必读:WebRTC API - Web API接口 | MDN
必读:WebRTC的前世今生

Web端用户视频推流使用webRTC方案,使用已有JS SDK(和这个兼容)。

WebRTC由Google主推,全称Web Browser Real Time Communication。目标是希望在Web端进行点对点音视频通信。

整个P2P过程很复杂,但是对于浏览器而言,WebRTC实际上只是提供了三个API:

  • MediaStream, 即getUserMedia(navigator.getUserMedia),获取媒体数据,如来自用户摄像头和麦克风的音视频流
  • RTCPeerConnection,用于peer跟peer之间呼叫和建立连接以便传输音视频数据流;这个不同浏览器的实现不同,官网推荐使用adapter.js进行适配
  • RTCDataChannel,用于peer跟peer之间传输音视频之外的一般数据。

MediaStream

参考MDN。来自navigator.getUserMedia(),这个方法接收三个参数:

  • 一个约束对象,如{ audio: false, video: true },除了这两种,其他外设也可以作为输入
  • 一个成功回调
  • 一个失败回调

返回的MediaStream对象有addTrack, getAudioTracks, getVideoTracks等方法。通过这些方法取出的MediaStreamTrack数组代表对应类型的流,可以把取出的这些流导入到<video>等标签输出。在Chrome或Opera中,URL.createObjectURL()方法可以转换一个MediaStream到一个Blob URL,可以被设置作为视频的源。除了这种方法,还可以使用AudioContextAPI,对音频做处理后输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
function gotStream(stream) {
window.AudioContext = window.AudioContext || window.webkitAudioContext;
var audioContext = new AudioContext();

// Create an AudioNode from the stream
var mediaStreamSource = audioContext.createMediaStreamSource(stream);

// Connect it to destination to hear yourself
// or any other node for processing!
mediaStreamSource.connect(audioContext.destination);
}

navigator.getUserMedia({audio:true}, gotStream);

使用HTTPS请求getUserMedia会向用户给出一次提示。不建议在HTTP环境下使用。

这里是一个demo,打开console,查看全局变量stream就明白MediaStream结构了。

约束对象中可以商议视频分辨率等信息。它会影响获取到的视频流。

RTCPeerConnection

WebRTC使用RTCPeerConnection在浏览器间传递数据流,但在之前需要有一个交换信令的过程。这个过程不在WebRTC中定义,开发者可以使用任何想用的消息协议,比如WebSocket或XHR轮询什么的。信令过程需要传递三种信息:

  • 连接控制信息:初始化或者关闭连接报告错误。
  • 网络配置:对于外网,我们电脑的 IP 地址和端口?
  • 多媒体数据:使用什么编码解码器,浏览器可以处理什么信息

点对点的连接需要ICE(Interactive Connectivity Establishment)的帮助,ICE靠STUN和TURN服务器处理NAT穿透等复杂问题。起初连接建立在UDP之上,STUN服务器让位于NAT中的client获知自己的公网IP和端口。如果UDP建立失败,考虑TCP连接,再考虑HTTP和HTTPS连接。否则使用TURN服务器做中转工作。

W3C给了RTCPeerConnection的样例,

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
var signalingChannel = new SignalingChannel();
var configuration = { "iceServers": [{ "url": "stun:stun.example.org" }] };
var pc;

// 调用start()建立连接
function start() {
pc = new RTCPeerConnection(configuration);

// 有任何 ICE candidates 可用,
// 通过signalingChannel传递candidate给对方
pc.onicecandidate = function (evt) {
if (evt.candidate)
signalingChannel.send(JSON.stringify({ "candidate": evt.candidate }));
};

// let the "negotiationneeded" event trigger offer generation
pc.onnegotiationneeded = function () {
pc.createOffer(localDescCreated, logError);
}

// 远端流到达时,在remoteView中做展示
pc.onaddstream = function (evt) {
remoteView.src = URL.createObjectURL(evt.stream);
};

// 获取本地流,展示并传递
navigator.getUserMedia({ "audio": true, "video": true }, function (stream) {
selfView.src = URL.createObjectURL(stream);
pc.addStream(stream);
}, logError);
}

function localDescCreated(desc) {
pc.setLocalDescription(desc, function () {
signalingChannel.send(JSON.stringify({ "sdp": pc.localDescription }));
}, logError);
}

signalingChannel.onmessage = function (evt) {
if (!pc)
start();

var message = JSON.parse(evt.data);
if (message.sdp) {
pc.setRemoteDescription(new RTCSessionDescription(message.sdp), function () {
// 接收到offer时,回应一个answer
if (pc.remoteDescription.type == "offer")
pc.createAnswer(localDescCreated, logError);
}, logError);
} else {
// 接收对方candidate并加入自己的RTCPeerConnection
pc.addIceCandidate(new RTCIceCandidate(message.candidate));
}
};

function logError(error) {
log(error.name + ": " + error.message);
}

在开始建立连接时,调用start(),创建RTCPeerConnection对象,接着完成下面步骤:

  • 交换网络信息onicecandidate回调在有任何candidate出现时,将通过SignalChannel(使用额外方法创建,如WebSocket)传递给对方。同样地,在通过SignalChannel接收到对方发来的该信息时,加入这个candidate到RTCPeerConnection中。
  • 交换多媒体信息,使用SDP(Session Description Protocol)与对端交换多媒体资讯,在onnegotiationneeded中,调用createOffer通过setLocalDescription创建RTCSessionDecription对象进行本地存储,并传给对方。接收方通过setRemoteDescription方法设定remote description。

上述过程称为JavaScript Session Establishment Protocol(JSEP)。一旦这个signaling完成了,数据可以直接的在端到端之间进行数据传输。如果失败了,通过中介服务器relay服务进行转发。

通常RTCPeerConnection的API太复杂,所以有很多在此基础上的库封装出了更加友善的API。

JS-SDK接入流程

参考:Agora:视频通话API

  1. 准备工作,包括界面绘制等
  2. 向远端注册当前用户,获取token,为后面做准备
  3. 使用createClient()方法创建客户端
  4. 指定回调,包括已订阅流、已添加流、移除、失败等生命周期事件的回调
  5. 初始化客户端,传入appId和成功回调
  6. 初始化成功后,调用join方法根据获取到的token加入指定房间(原理是WebRTC的stream有id)
  7. 指定配置创建本地流(getUserMedia),发布本地流,播放本地流

RTMP相关

必读:RTMP H5 直播流技术解析

一次RTMP握手的模拟。

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
// 握手协议模拟
class C {
constructor(time, random) {
this.time = time || 0;
this.random = random || Buffer.alloc(0); // Buffer类型
}

get C0() {
const buf = Buffer.alloc(1, 3);
return buf;
}

get C1() {
const buf = Buffer.alloc(1536);
return buf;
}

/**
* write C2
*/
get C2() {
let buf = Buffer.alloc(1536);
buf.writeUInt32BE(this.time, 4);
this.random.copy(buf, 8, 0, 1528);
}
}

// 客户端
const client = new net.socket();
const RTMP_C = new C();

client.connect({
port: 1935,
host: 'example.live.com',
() => {
console.log('connected');
}
});

client.on('data', (data) => {
if (!data) {
console.warn('Empty Buffer.');
}
if (!RTMP_C.S0 && data.length > 0) {
RTMP_C.S0 = data.readUInt8(0);
data = data.slice(1);
}

if (!RTMP_C.S1 && data.length >= 1536) {
RTMP_C.time = data.readUInt32BE(0);
RTMP_C.random = data.slice(8, 1536);
RTMP_C.S1 = true;
data = data.slice(1536)
console.log('send C2');
client.write(RTMP_C.C2);
}

if (!RTMP_C.S2 && data.length >= 1536) {
RTMP_C.S2 = true;
data = data.slice(1536);
}
});
0%