必读: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);
}
});

持续更新…

免密ssh步骤

一句命令代替繁琐的ssh远程登录开发机。

Step 1:免密

  1. ssh-keygen生成公钥。ssh-keygen
  2. 拷贝公钥。ssh-copy-id -i ~/.ssh/id_rsa.pub <your-remote-host>
  3. 免密登录。ssh <your-remote-host>

Step 2:简化命令

使用alias,比如:alias timetowork="ssh <your-remote-host>"

Step 3:get back to work

输入timetowork

参考:

fis-receiver

简写为fisrcv。使用fis进行项目构建时,若需要release到远端开发机,可以通过配置fis-conf.js里的deploy项目实现,fis会通过HTTP的方式上传压缩过的代码到远端指定位置,这需要远端有receiver接收上传的文件。

fis-receiver是在远端接收上传文件的服务端脚本,node、python、PHP等都可以。fisrcv实际上是使用node服务在远端接收deploy文件的服务端脚本而已。

参考:

webpack-release

等同于webpack版的fis release,不过原先写在fis-conf.js中的部署设置,现在写在webpack.config.js中。receiveUrlremotePath即远端开发机位置。实现上也采用HTTP POST的方式。

参考:

tmux

tmux是终端复用工具,允许在单个终端下相互隔离地运行多个后台程序。甚至在关闭终端时可以让程序在后台运行。使用tmux attachtmux detach进入和离开各个session。attach后还可以接-t指定连接的session。

参考:

HtmlWebpackPlugin

把html和js或css文件对应组织起来,可以指定filenametemplatechunks等。

参考:

encodeURI和encodeURIComponent

前者用于对整段URI转码,后者用于对URI中被分割符隔开的部分进行边编码。因此,

  • encodeURI会忽略允许出现在URI的符号,包括特殊符号。对空格、中文等进行转码
  • encodeURIComponent也会转码特殊符号,如/,$,@,.等

origami

origami是sublime中的一个拆分窗口的插件,用快捷键可以像在vim中一样方便地创建和转移到各个窗口编码。通过command + K开启快捷键。

  • +up/down/left/right 转移到其他窗口
  • +command+up/down/left/right 在该方向上打开新的工作窗口
  • +shift+command+up/down/left/right 销毁该方向上的新窗口

nrm与n

npm registry管理工具nrm,能够查看和切换当前使用的registry,在切换和查看registry时非常有用。常用命令:

  • nrm ls
  • nrm use
  • nrm help
  • nrm home
  • nrm add/delete 增加和删除registry
  • nrm test 测速

n是类似nvm的node.js版本管理工具。

Promise then的链式调用

then()方法返回一个Promise 。它最多需要有两个参数:Promise的成功和失败情况的回调函数。

then方法会返回一个Promise,它的行为与then中指定的回调函数返回值有关:

  • 如果then中的回调函数返回一个值,那么then返回的Promise将会成为接受状态(即使原Promise始Rejected状态),并且将返回的值作为接受状态的回调函数的参数值。
  • 如果then中的回调函数抛出一个错误,那么then返回的Promise将会成为拒绝状态,并且将抛出的错误作为拒绝状态的回调函数的参数值。
  • 如果then中的回调函数返回一个已经是接受状态的Promise,那么then返回的Promise也会成为接受状态,并且将那个Promise的接受状态的回调函数的参数值作为该被返回的Promise的接受状态回调函数的参数值。
  • 如果then中的回调函数返回一个已经是拒绝状态的Promise,那么then返回的Promise也会成为拒绝状态,并且将那个Promise的拒绝状态的回调函数的参数值作为该被返回的Promise的拒绝状态回调函数的参数值。
  • 如果then中的回调函数返回一个未定状态(pending)的Promise,那么then返回Promise的状态也是未定的,并且它的终态与那个Promise的终态相同;同时,它变为终态时调用的回调函数参数与那个Promise变为终态时的回调函数的参数是相同的。

下面是几个官网上的例子:

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
Promise.resolve("foo")
// 1. 接收 "foo" 并与 "bar" 拼接,并将其结果做为下一个resolve返回。
.then(function(string) {
return new Promise(function(resolve, reject) {
setTimeout(function() {
string += 'bar';
resolve(string);
}, 1);
});
})
// 2. 接收 "foobar", 放入一个异步函数中处理该字符串
// 并将其打印到控制台中, 但是不将处理后的字符串返回到下一个。
.then(function(string) {
setTimeout(function() {
string += 'baz';
console.log(string);
}, 1)
return string;
})
// 3. 打印本节中代码将如何运行的帮助消息,
// 字符串实际上是由上一个回调函数之前的那块异步代码处理的。
.then(function(string) {
console.log("Last Then: oops... didn't bother to instantiate and return " +
"a promise in the prior then so the sequence may be a bit " +
"surprising");

// 注意 `string` 这时不会存在 'baz'。
// 因为这是发生在我们通过setTimeout模拟的异步函数中。
console.log(string);
});
1
2
3
4
5
6
7
8
9
10
11
Promise.resolve()
.then( () => {
// 使 .then() 返回一个 rejected promise
throw 'Oh no!';
})
.catch( reason => {
console.error( 'onRejected function called: ', reason );
})
.then( () => {
console.log( "I am always called even if the prior then's promise rejects" );
});

SOLID原则

程序设计领域,尤其是面向对象编程的优秀实践里,有着一些实现原则,如SOLID(单一功能、开闭原则、里氏替换、接口隔离、依赖翻转)。这些设计模式原则可以有助于编写可维护、可拓展、清晰可读的代码。

  • S,Single Responsibility Principle,每个类都应有单一的功能,且被类封装起来。
  • O,Open-Closed Principle,对象(类、接口、函数等)对于拓展是开放的,对于修改是封闭的。即易拓展、保证可靠。
  • L,Liskov Substitution Principle,子类可以在不改变正确性的情况下替换父类
  • I,Interface-segregation Principle,多个特定功能的接口好于单个宽泛功能的接口
  • D,Dependency Inversion Principle,方法应该依赖于一个抽象(接口)而不是一个实例(类)

axios-mock-adpter

使用axios获取数据时,通过axios-mock-adaptermock数据。MockAdapter可以绑定在axios上,拦截通过绑定的axios发送的请求。使用方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var axios = require('axios');
var MockAdapter = require('axios-mock-adapter');

// This sets the mock adapter on the default instance
var mock = new MockAdapter(axios);

// Mock any GET request to /users
// arguments for reply are (status, data, headers)
mock.onGet('/users').reply(200, {
users: [
{ id: 1, name: 'John Smith' }
]
});

axios.get('/users')
.then(function(response) {
console.log(response.data);
});

另外,可以用mock.restore()撤销所有mocking行为,或通过mock.reset()除去所有mocking的handler。通过mock.on<方法名>还可以链式调用其他方法:

  • onAny() 绑定任何方法
  • networkError() 返回网络错误
  • timeout() 返回请求超时
  • passThrough() 跳过mocking直接请求

在reply中可以使用函数进行更复杂的操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var normalAxios = axios.create();
var mockAxios = axios.create();
var mock = MockAdapter(mockAxios);

mock
.onGet('/orders')
.reply(() => Promise.all([
normalAxios
.get('/api/v1/orders')
.then(resp => resp.data),
normalAxios
.get('/api/v2/orders')
.then(resp => resp.data),
{ id: '-1', content: 'extra row 1' },
{ id: '-2', content: 'extra row 2' }
]).then(
sources => [200, sources.reduce((agg, source) => agg.concat(source))]
)
);

移动端Charles调试经验

  1. 设置http代理为8888端口
  2. 设置SSL代理,填写对应的域名,设置端口为443,安装根证书
  3. 手机打开无线设置,设置代理IP和端口8888
  4. 打开chls.pro/ssl,安装根证书并信任
  5. 使用Map remote访问开发机位置
  6. 使用Map local可以劫持WebView中请求的JS等资源到本地,通过alert的方式打印调试信息,进行临时的线上Webview环境debug

官网:https://quilljs.com

quill可以是一个文本编辑器JS库或是文本编辑器构建库。它提供了结构化数据方式用脱离语言的方式描述编辑器内容,同时预置了内置插件,支持自定义插件,有助于在此基础上进行和业务相关编辑器开发。

准备工作

关于contenteditable属性,selection对象和range对象的介绍,可以参考这篇文章

配置

1
2
3
const editor = new Quill("#container");
// 或是直接传入DOM对象
const editor = new Quill(document.body);

quill通常使用上面的方式初始化。在初始化时,支持用丰富的配置项定义生成的编辑器。下面是一个例子。

1
2
3
4
5
6
7
8
9
10
const options = {
debug: 'info',
modules: {
toolbar: '#toolbar'
},
placeholder: 'Tell a story...',
readOnly: false,
them: 'snow'
}
const editor = new Quill('#container', options);

Quill支持的配置项有:

  • bounds,Quill UI元素的限制范围,默认document.body
  • debug,是Quill.debug的快捷方式,用于打印调试信息,默认级别为warn
  • formats,Quill中允许出现的格式,默认为所有格式,它和toolbar是解耦的
  • modules,注册在Quill中的功能模块和与之对应的配置信息,Quill会有默认配置
  • placeholder,提示信息
  • readOnly,是否可写
  • scrollContainer,编辑器滚动条的父级,默认为编辑器本身
  • strict,版本更新配置,默认为true
  • theme,整体外观,默认为snowbubble可选

支持的文本格式

分为行内块级嵌入式三大类。

  • 行内:加粗/背景色/字体颜色/字体/行内代码/斜体/下划线/删除线/链接/字体大小/上、下标
  • 块级:引用/标题/行首缩进/有序、无序列表/对齐/文本方向/代码块
  • 嵌入式:音频/视频/公式

API

按照由浅入深,分为修改内容修改格式选取编辑器本身事件数据模型操作拓展几大类。涉及到内容修改的都会返回代表更改的delta。

修改内容

涉及到修改时,最后一个参数都可以选择userapisilentuser类型下,disabled时会没有效果。

  • deleteText,输入起始点和长度,删除特定范围的内容,返回delta类型数据。如quill.deleteText(6, 4)
  • getContents,获取delta格式的编辑器内容,如quill.getContents()
  • getLength,获取文本长度,quill默认会有一个空行,所以默认返回1
  • getText,获取文本内容,跳过非文本如音视频元素,如quill.getText(0, 10)
  • insertEmbed,输入位置,类型,值,插入嵌入式内容,如quill.insertEmbed(10, 'image', 'https://quilljs.com/images/cloud.png')
  • insertText,插入文本,可带格式。如下
    1
    2
    3
    4
    5
    6
    quill.insertText(0, 'Hello', 'bold', true);

    quill.insertText(5, 'Quill', {
    'color': '#ffff00',
    'italic': true
    });
  • setContent,输入delta,重设编辑器内容,需以\n结尾。如下:
    1
    2
    3
    4
    5
    quill.setContents([
    { insert: 'Hello ' },
    { insert: 'World!', attributes: { bold: true } },
    { insert: '\n' }
    ]);
  • setText,设置文本,返回代表改变的delta
  • updateContent,输入delta,更新内容,返回代表更新的delta

修改格式

  • format,设置用户当前所选的文本格式,如quill.format('color', 'red');
  • formatLine,设置给定选择当前整行样式,使用类似format的方法设置样式,也支持直接传入格式对象。类似quill.formatLine(1, 2, { 'align': 'right'})
  • formatText,设置给定范围内文本格式,类似formatLine
  • getFormat,获取给定范围内的格式,没有输入时,返回当前选择的格式
  • removeFormat,移除范围内样式

选取

  • getBounds(index, length = 0),返回的top、width、height、left相对于编辑器容器而言
  • getSelection(focus = false),返回用户的选取范围,由index、length组成
  • setSelection(index, length = 0),设置选区范围,会自动focus,输入null会自动blur

编辑器本身

  • blur,失焦
  • disable,禁用
  • enable(enabled = false),启用
  • focus,聚焦
  • hasFocus,是否聚焦
  • update,同步用户改动,协同工作时常用

事件

事件通过on方法绑定在quill对象上。

  • text-change,quill内的内容改变时触发,回调函数可以获取delta、oldContent,source。通常来自’user’,source为’silent’时,该事件不会触发。
  • selection-change,回调函数可以获取range,oldRange,source
  • editor-change,上述两个事件触发时触发,即使source为silent

除了on方法,还有once用于绑定一次和off方法解绑。

数据模型操作

  • find,寻找DOM节点对应的quill或Blot对象
  • getIndex,返回文档开头到给定Blot的偏移量
  • getLeafBlot,返回给定位置的Blot
  • getLine,返回给定位置整行的Blot
  • getLines,返回给定范围的Blot

拓展

  • debug,设置调试信息级别,info | log | warn | error
  • import,导出quill相关库,输入相对于quill的路径
    1
    2
    3
    4
    5
    var Parchment = Quill.import('parchment');
    var Delta = Quill.import('delta');

    var Toolbar = Quill.import('modules/toolbar');
    var Link = Quill.import('formats/link');
  • register,注册module到quill中,有下面几种用法
    1
    2
    3
    Quill.register(format: Attributor | BlotDefinintion, supressWarning: Boolean = false)
    Quill.register(path: String, def: any, supressWarning: Boolean = false)
    Quill.register(defs: { [String]: any }, supressWarning: Boolean = false)
  • addContainer,新增容器并返回
  • enable/disable,启用、禁用编辑器

delta

delta是quill中最重要的概念。据介绍所说,quill是“第一个”使用delta(结构化数据)这个概念的。不同于其他大多数文本编辑器需要反复执行修改编辑器中的HTML文档。quill维护一个delta数组,使用JSON数据的方式描述了文档的内容。

使用delta一词,并没有问题,因为可以理解成文档本身是由空内容 + delta一点点得到的。delta主要有两个特性:

  • 权威性,delta和对应的生成结果是一一对应的,没有歧义
  • 压缩性,delta中描述的操作是经过压缩后的

delta中的操作可以分为增、删、修改格式,分别对应insertdeleteretain操作。对文本编辑器的一次改动(真实世界中的改动行为)只可能涉及上述三种行为的一种(Quill并不允许Ctrl多处选中)。其中retain的意义类似于光标的移动,它使得这三种操作并不需要使用index描述,便于Quill做优化和压缩。

delta的操作实际上是对parchment进行的,它类似于vdom,使用JS的数据结构对文本编辑器中可能出现的各元素进行了抽象,称作Blot。Blot有scroll,inline、block、text,break几种。父Blot下必须包含至少一个子Blot,而所有的Blot都包含在一个scroll Blot下。文本编辑器中特定格式的文本块都用特定的Blot表示,每个这样的Blot都必须继承自上面的一种Blot类型。就像通过下面的方式继承了Blot,就可以使对应的行内元素得到对应的编辑器样式元素对应起来,并使用在后面的编辑器里。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let Inline = Quill.import('blots/inline');

class BoldBlot extends Inline { }
BoldBlot.blotName = 'bold';
BoldBlot.tagName = 'strong';

class ItalicBlot extends Inline { }
ItalicBlot.blotName = 'italic';
ItalicBlot.tagName = 'em';

Quill.register(BoldBlot);
Quill.register(ItalicBlot);

// in your editor

quill.insertText(0, 'Test', { bold: true });
quill.formatText(0, 4, 'italic', true);

类似地,我们定义一个Link Blot。它相比bold,italic不同的是,它需要一个string而不是boolean初始化。因此需要定义createformat两个函数。其中create在构造Blot时使用,value即输入的href,formats将用户的format字段和真实DOM的字段相关联。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class LinkBlot extends Inline {
static create(value) {
let node = super.create();
// Sanitize url value if desired
node.setAttribute('href', value);
// Okay to set other non-format related attributes
// These are invisible to Parchment so must be static
node.setAttribute('target', '_blank');
return node;
}

static formats(node) {
// We will only be called with a node already
// determined to be a Link blot, so we do
// not need to check ourselves
return node.getAttribute('href');
}
}
LinkBlot.blotName = 'link';
LinkBlot.tagName = 'a';

Quill.register(LinkBlot);

定义引用这样的块级元素时,对应地继承Block Blot即可。和inline Blot不同的是,Block Blot无法嵌套,在对已有的块级元素应用时会替换而不是嵌套绑定在元素上。以Header元素为例,可以指定tagName为一个数组,可以在format时使用1、2的方式指定具体哪种tag。

1
2
3
4
5
6
7
8
9
class HeaderBlot extends Block {
static formats(node) {
return HeaderBlot.tagName.indexOf(node.tagName) + 1;
}
}
HeaderBlot.blotName = 'header';
// Medium only supports two header sizes, so we will only demonstrate two,
// but we could easily just add more tags into this array
HeaderBlot.tagName = ['H1', 'H2'];

类似的,可以在插入embed Blot,这种类型效果是插入在元素中间的新的tag。如Image。

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
let BlockEmbed = Quill.import('blots/block/embed');

class ImageBlot extends BlockEmbed {
static create(value) {
let node = super.create();
node.setAttribute('alt', value.alt);
node.setAttribute('src', value.url);
return node;
}

static value(node) {
return {
alt: node.getAttribute('alt'),
url: node.getAttribute('src')
};
}
}
ImageBlot.blotName = 'image';
ImageBlot.tagName = 'img';

// 使用时
let range = quill.getSelection(true);
quill.insertText(range.index, '\n', Quill.sources.USER);
quill.insertEmbed(range.index + 1, 'image', {
alt: 'Quill Cloud',
url: 'https://quilljs.com/0.20/assets/images/cloud.png'
}, Quill.sources.USER);
quill.setSelection(range.index + 2, Quill.sources.SILENT);

modules

quill中的module位于quill的应用层。可以通过定制modules,利用quill的功能;或是更改quill内置module,修改quill本身的行为和功能。clipboard、keyboard、history三个module是quill默认加载的。用户完全可以根据业务需求定义自己的module。官网给了简单的例子展示了module的大致骨架:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Quill.register('modules/counter', function(quill, options) {
var container = document.querySelector(options.container);
quill.on('text-change', function() {
var text = quill.getText();
if (options.unit === 'word') {
container.innerText = text.split(/\s+/).length + ' words';
} else {
container.innerText = text.length + ' characters';
}
});
});

var quill = new Quill('#editor', {
modules: {
counter: {
container: '#counter',
unit: 'word'
}
}
});

只需要定义一个可以接收quill对象的函数即可,在函数内部利用quill事件监听即可完成应用层的建设。

Toolbar和ClipBoard

ToolbarClipboard是Quill内置的两个module,对你构建自己的文本编辑器有很大的借鉴意义。

Toolbar用来定制工具栏上的按钮,是自定义编辑器(尤其是业务相关的编辑器)逃不开的一部分。它有几个基本配置:

  • container,放置工具栏的DOM容器
  • handler,点击ToolBar图标时注册的函数,传入Blot的value,通过调用Quill的API完成功能。也可以通过下面方式注册。
1
2
3
// Handlers can also be added post initialization
var toolbar = quill.getModule('toolbar');
toolbar.addHandler('image', showImageUI);

在遇到从别的文本编辑器拷贝内容过来的情况时,需要修改ClipBoard Module中addMatcher的定义。这个方法向ClipBoard中注册了新的Matcher匹配拷贝过来的HTML文本,将之转换为对应的Blot。如:

1
2
3
4
5
6
7
8
quill.clipboard.addMatcher(Node.TEXT_NODE, function(node, delta) {
return new Delta().insert(node.data);
});

// Interpret a <b> tag as bold
quill.clipboard.addMatcher('.custom-class', function(node, delta) {
return delta.compose(new Delta().retain(delta.length(), { bold: true }));
});

或者在configuration中,注入新定义的matcher即可。

1
2
3
4
5
6
7
8
9
10
var quill = new Quill('#editor', {
modules: {
clipboard: {
matchers: [
['B', customMatcherA],
[Node.TEXT_NODE, customMatcherB]
]
}
}
});

参考

最近在业务中遇到使用生成图片的需求,图片只需要展示数据,没有计算密集型工作。后端生成的图片字体太单一,工作就交给了前端。从0开始作图做像素级的操作自然是不现实的,有幸的是,HTML本身就是一个很不错的做UI的语言,有CSS的支持。再借助HTML to canvas或是SVG的库,可以完成想要的需求。

实际上,后端大多数也是通过起chrome内核,绘制DOM节点生成图片的

需求

  • 按照指定格式生成图片
  • 保证格式正确清晰度高
  • 生成过程用户无感知
  • 对图片格式没有明确要求

解决方案

HTML to image有两种方案比较流行,一个是html2canvas,一个是dom-to-image。它们的设计初衷其实都是将已有DOM结构转成图片类型。对比来看

  • 流行度上,html2canvas流行度更高,资料更好找,但更新缓慢
  • 格式支持上,dom-to-image可以将图转成SVG等更多格式,html2canvas只能输出canvas,需要用户自行处理
  • 清晰度上,dom-to-image可以导出SVG,html2canvas则需要hack的方式(设置更大的canvas绘制再等比缩放)
  • 实现原理上,都是通过遍历DOM树,读取格式化数据,dom-to-image通过浏览器解析CSS语法,因此支持度更高;html2canvas则自己实现了CSS解析

渲染图片的HTML模板在通常情况下,不应该展示给用户。即生成过程短暂停留的DOM需要用户不可见。不可见的方式大致有下面几种:

  • display: none,这种情况,两个方案度都输出空白图片
  • visibility: hidden,在输出图片时,DOM结构会短暂闪现,两种方案都输出空白图片
  • 将DOM移出视口,html2canvas可以正确输出图片,dom-to-image不行

本场景下生成的图片需要上传,并最后展示给C端,没有对SVG的需求。测试来看,两者的输出结果清晰度类似,且html2canvas输出格式还原度更高。综合考虑,选择html2canvas。

在其他场景下,如支持SVG、需要高清截图、需要导出更多图片时,可以考虑使用dom-to-image。两者的API实际上非常类似。

容器组件

考虑到未来仍可能存在的前端图片渲染需求,将相关逻辑内聚成一个组件,同时开发接口给外部使用。

组件需要输入:

  • hide,因为渲染过程是componentDidMount阶段完成的,在每次渲染完成后要在父组件手动卸载该组件,这部分需要在hide中实现
  • success,可选的成功回调,入参是生成的canvas,hide作为可选第二个入参,可以异步卸载组件
  • {children},无状态的函数组件,只负责图片的HTML模板
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
/*
* @author: shenlvmeng
* @desc: 渲染图片的容器组件,加载时根据内部DOM生成图片,输出data64编码到回调
* @props: hide {Function} required 图片生成完成后需要在父组件执行的卸载该组件操作
* @props: success {Function} 成功回调
**/
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import html2canvas from 'html2canvas';

class GeneratedImage extends Component {
static defaultProps = {
hide: () => {},
success: (canvas) => {document.body.appendChild(canvas);}
};

static propTypes = {
hide: PropTypes.func,
success: PropTypes.func
};

componentDidMount() {
html2canvas(document.getElementById('html2canvas'))
.then((canvas) => {
// 可以将hide操作作为success的回调使用
if (this.props.success.length > 1) {
this.props.success(canvas, this.props.hide);
} else {
this.props.success(canvas);
this.props.hide();
}
})
.catch((e) => {
console.error(e);
this.props.hide();
});
}

render() {
return (
<div
id="html2canvas"
style={{
position: 'fixed',
left: '-9999px'
}}
>
{this.props.children}
</div>
);
}
}

export default GeneratedImage;

使用时,像如下这样,在对应的时机展示组件即可:

1
2
3
4
5
6
7
8
9
{ this.state.isGenerating ?
<GeneratedImage
hide={() => {this.setState({ isGenerating: false })}}
success={(canvas) => { console.log(canvas.toDataURL()); }}
>
<Image />
</GeneratedImage>
: null
}

已知缺陷

  • 对部分CSS属性支持度有限,如box-shadow-webkit-line-clampbackground-position
  • 使用时需要额外的卸载操作

后端

生成图片的业务需求大多数是用内容填充的,因此使用浏览器渲染页面再截图是比较直观的生成方式(qrcode这种简单的图片需求另说)。在使用python的场景下,可以用selenium生成,代码非常简单。

首先,pip install selenium,如果是python3,就pip3 install selenium

然后,安装chromedriver。使用headless模式打开chrome,并根据图片位置和大小截图即可。

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
from selenium import webdriver
from PIL import Image
from io import BytesIO
from os import path

def screenshot(path):
# Headless chrome

DRIVER = 'chromedriver' # add this to your $PATH
chrome_options = webdriver.ChromeOptions()
chrome_options.add_argument('--headless')
chrome_options.add_argument('--disable-gpu')

# get screenshot

browser = webdriver.Chrome(chrome_options=chrome_options, executable_path=DRIVER)
browser.get(path)
ele = browser.find_element_by_id('demo')
location = ele.location
size = ele.size
image = browser.get_screenshot_as_png()
browser.quit()

# crop image

im = Image.open(BytesIO(image))

left = location['x']
top = location['y']
right = location['x'] + size['width'] * 2
bottom = location['y'] + size['height'] * 2

im = im.crop((left, top, right, bottom))
im.save('screenshot.png')

if (__name__ == '__main__'):
curr_path = path.dirname(path.realpath(__file__))
screenshot('file://' + curr_path + '/demo.html')

这样就可以获取到示例页面的截图。

Ahbr1x.png

使用egg.js开发PhotoGallery管理后台过程中,遇到了一些典型需求,将处理过程整理如下,方面后面开发类似应用

用户管理

数据库

新建User表,填充表结构,至少包含账号密码两个字段。

Controller

(Ajax)登录页面,对应login行为,处理登录请求,包括以下工作:

  • 调用服务匹配用户名密码
  • (可选) 检查用户类型
  • 密码需要加密存储
  • 储存用户信息到session
  • 返回提示信息

(Ajax)注册页面,对应register行为,处理注册请求,包括以下工作:

  • 寻找重名等逻辑
  • 调用服务创建用户
  • 密码需要加密存储
  • 储存用户信息到session
  • 返回提示信息

Service

用户管理,和数据库连接。对应到controller中大多数是POST请求。

  • 新增用户
  • 获取用户
  • 用户资料修改
  • 用户删除(使用用户状态更新实现)

Router与中间件

  • 添加中间件检查是否有session,否则同一跳转/login(业务逻辑)
  • (可选) Router上使用重定向让path更友好
  • 一定不要瞎用301状态码

页面js

  • 使用Ajax或jsonp请求

无限滚动

HTML部分

  • 提前加载所有图片(后续API动态请求,DOM插入时间损耗太大)。
  • 前若干张图片(假设为K)正常展示
  • 后若干张容器使用display:noneheight: 0等手段避免展示
  • 后若干张图片使用data-src存储真实路径,避免提前加载,影响首屏时间

JS部分

  • 设置参数保存当前展示图片的数目
  • 判断是否滚动到底端
  • 上述情况下增加展示的图片数目,删除避免展示的class,替换真正的src加载图片
  • 使用节流,保证弱网络环境下,没有连续的过多图片加载,使用flag控制程序触发,在最后一张图片加载完成后,更新flag布尔值,开放权限
  • (可选),网络环境很差时,可以考虑setTimeout兜底,但不推荐
  • 注意在所有图片都加载完成时,停止监听scroll事件
1
2
3
4
5
const figure = $($('#figures').children()[index]);
if (!figure) {
$(window).off('scroll');
return;
}

文件上传

HTML部分

  • 使用包裹<input type='file'><form>
  • <input type='file'>使用display: none,通过更友好的方式trigger它的点击
  • 注意:type=fileinput标签一定要有name属性,否则不会被包裹在FormData中。
1
2
3
4
<span id="upload" class="upload">+ 点击上传</span>
<form id="upload-form" enctype="multipart/form-data">
<input type="file" accept="image/*" name="files" id="upload-file" multiple>
</form>

JS部分

  • 使用Ajax提交替换form表单替换,来实现更复杂的回调和逻辑控制
  • 通过构造FormData,提交域内文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$('#upload').on('click', () => {
$('#upload-file').trigger('click');
});

$('#upload-file').on('change', () => {
$.ajax({
url: '/gallery/upload',
type: 'POST'
cache: false,
data: new FormData($('#upload-form')[0]),
processData: false,
contentType: false,
success: data => {
console.log(data)
}
});
});

后台路由

  • 配置Ajax和jsonp安全检查

控制器端

使用插件,参考examples/multiple.js at master · eggjs/examples

HTML模板

使用ES6的模板字符串。

已知问题:

  • 似乎有些视图、逻辑未分离

使用七牛API上传文件

使用服务端上传,一次只能单张上传。官网API文档Node.js版描述的并不清楚,下面是上传的简单流程展示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const mac = new qiniu.auth.digest.Mac(config.accessKey, config.secretKey);
const options = {
scope: config.bucketName,
};
const putPolicy = new qiniu.rs.PutPolicy(options);
const uploadToken = putPolicy.uploadToken(mac);
const qiniuConfig = new qiniu.conf.Config();
qiniuConfig.zone = qiniu.zone.Zone_z1;
const formUploader = new qiniu.form_up.FormUploader(qiniuConfig);
const putExtra = new qiniu.form_up.PutExtra();

formUploader.putFile(uploadToken, key, localFile, putExtra, function(respErr, respBody, respInfo) {
if (respErr) {}
if (respInfo.statusCode == 200) {
//...
} else {
//...
}
}

多文件上传使用外部队列暂存所有任务,并和回调函数关联即可实现。

0%