持续更新…

免密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 {
//...
}
}

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

前一阵突发奇想,想写一个汇总所有骑行数据的网页。又想到最近看了下Electron,干脆写一个能够生产这样网页的工具,造福自己,造福他人。

Ahbsc6.png

Electron是啥

Electron一套由Github开发的开源库,基于ChromiumNode.js,支持通过HTML、JavaScript、CSS来构建跨平台(Mac、Windows、Linux)应用。Electron起初是为文本编辑器Atom而开发的一套开发框架,名为Atom Shell。现如今已经为众多应用所用。

从开发者角度看,Electron分为mainrenderer两部分。前者运行在主进程中,以创建web页面的方式显示一个GUI;后者是渲染进程,每个Electron的web页面运行在其中。通常的浏览器内,网页通常运行在一个沙盒的环境不能够进行原生操作。 在Electron中,可以在渲染进程使用Node.js的API和Electron提供的众多API(GUI相关除外),和操作系统进行一些低级别的交互。主进程和渲染进程通过ipcMainipcRenderer相互沟通;也可以通过remote的方式发起,后者要更简洁些。

在项目结构上,官网并没有限制,electron-webpackproject-structure可以参考,安全性上,可以参考官网的介绍。要想获得对Electron概念的快速认识,可以看看关于Electron快速入门,再去知乎Electron精华话题看看,或者看看awesome list也是极好的。

调研准备

地图考虑还是使用百度地图API(因为上个小项目用的就是百度地图,好上手),根据demo演示来看,根据数据点画个折线是没什么问题的。地图的part没问题了。

下面就是数据的part。去确认了下我骑行常用的APP行者,网页和APP都有导出功能。导出格式为.gpx的gps数据文件。OK,数据的来源也有了。

至于怎么把这些点连线搁在地图上,就是我要干的活了。

功能设计

但是事情没那么简单,既然选择Electron来练手,光做个展示的网页出来就很没意思了。这也不是Electron的用武之地。于是能够想到的就是,做一个可以生成上面那个网页的工具,一方面减轻我的负担,让我在日后维护时省心省力;另一方面也能造福他人嘛。

现在整理一下,我拿在手里的是一堆.gpx的文件,产出是可以画图的网页。稍微分解一下:

  • 网页是需要独立存在,不需要用户配置的,这些gps数据必须单独存储,可以使用前端友好的JSON文件。这个转译过程需要在Electron应用中完成
  • 网页需要能够配置生成,不需要用户手写,因此在应用里需要填充HTML模板,生产HTML文件。
  • 页面并不复杂,不需要使用Vue、React甚至webpack的加持,作为我的第一个Electron应用,把握整体感受要紧

开写

相关环境

安装Electron过程中,报错node版本过低。只能重新安转新版本的node,windows下只有nvm-windows这个选择。安装完成后,之前全局安装的npm包只能重头再安一遍。先安装nrm再说。

注意:安装nvm-windows前,强烈建议卸载已有的Node.js

boilerplate

boilerplate即骨架。现在前端的环境里,一门成熟的开源库是一定有一堆boilerplate的,毕竟程序猿懒人多。Electron也不能免俗。可以从awesome list中挑一个看上眼的。如果项目比较大,可以直接用electron-vue这种省心省力,一键式配置,开发打包工具一应齐全。这里我从官网提到的quick start,除了一些样例代码,啥都没有,正合我意。

(我曾经尝试使用electron-webpack-quick-start,想着顺便打包了electron-builder,还有webpack、热加载,岂不美哉。不过根据这里所说,是没有index.html暴露给你的,这几乎就意味着必须要用Vue、React这样的解决方案,但是electron-webpack这个库并没有包括,需要自己add-on,但是按照文档所说的操作后,并不能识别.vue文件,而且也没有vue-devtool。这是坑之一

转译

转译过程是在renderer.js中完成的。实际上,项目里大多数业务逻辑也是在渲染进程中完成的。核心在把gpx文件里的信息解析出来,除了<desc></desc>中的meta信息之外,其余的点结构大致像下面这样;

1
2
3
4
5
6
<trkpt lat="40.106419" lon="116.369812">
<ele>40.6</ele>
<time>2017-03-04T16:52:36Z</time></trkpt>
<trkpt lat="40.106531" lon="116.369916">
<ele>59.8</ele>
<time>2017-03-04T16:52:39Z</time></trkpt>

幸运的是,npm上早就有gpx的parser。gpx-parse的功能足够满足我们需要了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var gpxParse = require("gpx-parse");

//from file
gpxParse.parseGpxFromFile("/path/to/gpxFile", function(error, data) {
//do stuff
});

//or from string
gpxParse.parseGpx("<gpx></gpx>", function(error, data) {
//do stuff
});

// or an external file via HTTP(S)
gpxParse.parseRemoteGpxFile("http://host.tld/my.gpx", function(error, data) {
//do stuff
});

顺带写几个input框(包括<input type="file">),测试一下,没啥问题(排除掉中间处理yarn和npm冲突问题的话)。观察一下,返回值是一个GpxResult类型,里面有metadataroutestracks等字段,只有tracks中记录着点的信息。考虑到tracks和里面segments字段是数组的情况,要进行一下flatten的处理。最后,整个转译过程大概如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function serialize(file, index) {
gpxParse.parseGpxFromFile(file.path, function(error, data) {
if (error || !data.tracks) {
alert('文件内容错误')
return
}
const track = data.tracks;
// 扁平化处理
const flattenTrack = track.reduce((acc, cur) => (cur.segments.reduce((acc, cur) => acc.concat(cur), []).concat(acc)), [])
const points = flattenTrack.map(({lat, lon}) => ({lat, lng: lon}))
try {
const jsonData = JSON.stringify(points)
const pathStr = path.join(OUTPUT_PATH, `${index}.json`)
remote.require('fs').writeFile(pathStr, jsonData,'utf8', err => {
if (err) throw err
})
} catch (e) {
console.warn(e)
alert('文件序列化失败')
}
});
}

写一个示例网页

既然最后的网页是生成出来的,就有第一个能够成功工作起来的网页作为模子,好抽离范本出来。先搭一个最简单的HTML架子,插入百度地图的script标签。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<style>
body, html, #map {width: 100%; height: 100%; overflow: hidden; margin:0;}
</style>
<title>我的骑行轨迹</title>
</head>
<body>
<div id="map"></div>
<script src="https://api.map.baidu.com/api?v=2.0&ak=你的秘钥"></script>
</body>
</html>

下面我们把工作稍微分析一下:

  • 从本地读取JSON文件,意味着自己实现一个ajax,考虑兼容性(毕竟没了babel和webpack),使用XMLHttpRequest
  • 读取当然得是异步的,JSON文件很有可能很多,需要依次进行
  • 地图配置和画图就很简单了,参考API就行了

第一个工作不难:

1
2
3
4
5
6
7
8
9
10
function getJSON(path, callback) {
var request = new XMLHttpRequest();
request.open('GET', path, true);
request.onreadystatechange = function() {
if (request.readyState === 4 && request.status === 200) {
callback(request.response);
}
}
request.send();
}

第二个工作也不难,在递归函数的外面设置控制递归的变量就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var pool = Array.apply(null, Array(length)).map(function(v, i){return i+'.json';});
function paint() {
if (!pool.length) return;
getJSON(pool.shift(), function(res) {
if (res) paint();
try {
var pois = JSON.parse(res).map(function(point) {
return new BMap.Point(point.lng, point.lat);
});
var polyline = new BMap.Polyline(pois);
map.addOverlay(polyline);
} catch(e) {
console.warn(e);
}
})
}

OK,大功告成(排除其余逻辑bug之后),赶紧接上renderer.js那边转译好的JSON文件看看骑行轨迹吧!

你以为事情会这么简单么?

当然不。

坐标换算

图是出来了,但是路线有偏差,发现明显有所平移。这是怎么回事,搜索过后才发现,百度所采用的坐标并不是gps数据中的真实大地坐标,而是在火星坐标基础上再次加密的百度坐标(更多)。官网示例上也给出了gps坐标转成百度坐标的API。

得,那就在转译成JSON数据前多map一段呗。仔细一看,Convertor的介绍里赫然写着“一次最多10个点”,居然还限流(其实不只是限流的问题,递归的写法也要变化)。一条路线至少上千个点呢,算了先试试看速度吧。

两条路线用了30s才显示出来,果然很慢……

只能自己实现转译过程了,网上倒是有一些例子,都差不多。尝试了一下,发现有点效果,但是路线还是有偏移。试了半个多小时后,总算找到了一个完美的JS转译代码,感谢原作者。

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
function delta(lat, lon) {
// Krasovsky 1940
//
// a = 6378245.0, 1/f = 298.3
// b = a * (1 - f)
// ee = (a^2 - b^2) / a^2;
const a = 6378245.0; // a: 卫星椭球坐标投影到平面地图坐标系的投影因子。
const ee = 0.00669342162296594323; // ee: 椭球的偏心率。
let dLat = transformLat(lon - 105.0, lat - 35.0);
let dLon = transformLon(lon - 105.0, lat - 35.0);
const radLat = lat / 180.0 * PI;
let magic = Math.sin(radLat);
magic = 1 - ee * magic * magic;
const sqrtMagic = Math.sqrt(magic);
dLat = (dLat * 180.0) / ((a * (1 - ee)) / (magic * sqrtMagic) * PI);
dLon = (dLon * 180.0) / (a / sqrtMagic * Math.cos(radLat) * PI);
return {lat: dLat, lon: dLon};
}
// ...
function transformLat (x, y) {
let ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x));
ret += (20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0 / 3.0;
ret += (20.0 * Math.sin(y * PI) + 40.0 * Math.sin(y / 3.0 * PI)) * 2.0 / 3.0;
ret += (160.0 * Math.sin(y / 12.0 * PI) + 320 * Math.sin(y * PI / 30.0)) * 2.0 / 3.0;
return ret;
}
function transformLon (x, y) {
let ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x));
ret += (20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0 / 3.0;
ret += (20.0 * Math.sin(x * PI) + 40.0 * Math.sin(x / 3.0 * PI)) * 2.0 / 3.0;
ret += (150.0 * Math.sin(x / 12.0 * PI) + 300.0 * Math.sin(x / 30.0 * PI)) * 2.0 / 3.0;
return ret;
}
// ...

这转译过程,要不是有先行者,我怕是要倒在这里了。

HTML模板

示例HTML已经工作起来了,现在就是抽出模子的过程。网页并不复杂,可以用简单的HTML template解决问题。John Resig的方案如下:

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
// Simple JavaScript Templating
// John Resig - https://johnresig.com/ - MIT Licensed
(function(){
var cache = {};

this.tmpl = function tmpl(str, data){
// Figure out if we're getting a template, or if we need to
// load the template - and be sure to cache the result.
var fn = !/\W/.test(str) ?
cache[str] = cache[str] ||
tmpl(document.getElementById(str).innerHTML) :

// Generate a reusable function that will serve as a template
// generator (and which will be cached).
new Function("obj",
"var p=[],print=function(){p.push.apply(p,arguments);};" +

// Introduce the data as local variables using with(){}
"with(obj){p.push('" +

// Convert the template into pure JavaScript
str
.replace(/[\r\t\n]/g, " ")
.split("<%").join("\t")
.replace(/((^|%>)[^\t]*)'/g, "$1\r")
.replace(/\t=(.*?)%>/g, "',$1,'")
.split("\t").join("');")
.split("%>").join("p.push('")
.split("\r").join("\\'")
+ "');}return p.join('');");

// Provide some basic currying to the user
return data ? fn( data ) : fn;
};
})();

看上去很眼熟,以前的项目似乎见到过。

把之前的示例HTML放在index.html<script type="text/template"></script>中,在渲染进程里加上代码看看?

1
console.log(tmpl('template'));

嗯……报错了。提示“Missing ')' after argument list ”。加断点调试发现是标签里的"打断了new Function的语句。尝试了多种方法无效后,索性使用encodeURIComponent想处理掉麻烦的特殊符号,但是这么做之后就无法匹配<%=%>了。

于是最后选择underscore的template方法。再试试……

没问题了。之后把允许用户填写的部分抽出来,就可以把index.html的生成放在转译代码身旁了。

1
2
3
4
5
6
7
const template = document.getElementById('template').innerHTML
// ...
remote.require('fs').writeFile(path.join(OUTPUT_PATH, 'index.html'), tmpl(template)(data).replace(/&lt;/g, '<'),'utf8', err => {
if (err) throw err
else alert('生成完毕!\n将output文件夹下所有文件上传到服务器即可查看效果!')
})
})

再次运行,测试生成的网页能否工作?答案当然是可以。

苦力活

技术上的问题解决了,现在从用户填写信息到最后生成能用的展示页面也没有问题了。初版下面的问题就是美化了。

  • CSS修饰样式
  • 将模板HTML文件压缩(包括JS和CSS),因为反正用户不会修改内容,不需要考虑可读性
  • 一些保护性编程和边缘情况兜底

最后测试结果如下:

AhbD91.png

生成效果如上。

发布

初版开发已经完成了,只剩发布出来给别人用了,考虑到官网文档讲得实在不清不楚,不如用一个好工具帮我们完成。

这里使用electron-builder。跟着介绍里一步步完善package.json和项目结构。加上依赖后,执行yarn dist生成可分发的文件。

嗯……果然失败了。原因很简单,网络错误,Electron镜像文件下载失败。还好淘宝有electron镜像。通过设置ELECTRON_MIRROR环境变量,可以大大加快速度。

1
export ELECTRON_MIRROR=https://npm.taobao.org/mirrors/electron/

然后,再次执行yarn dist,在从Github下载其他相关文件的时候,仍然会网络错误。于是我机智的从网页上下载下来,直接放在了目标目录下。再次执行任务,居然不能识别出来。好吧……

故事的最后,打包还是完成了。不过由于生成文件的目录写成了相对目录,生成的文件得通过搜索才能找到,考虑后面生成在桌面。

–END–

参考

0%