往期回顾
-
- 对bpmn-js的基本概念以及BPMN基础元素进行解释说明,并演示了如何使用bpmn-js来构建和配置一个简单的工作流程。
-
- 更具体地介绍了什么是流程图,一张规范的流程图里都有哪些元素,配置流程图的编辑器都具备哪些功能,以及如何使用流程编辑器去配置一张流程图。
-
- 正式进入手写代码阶段,通过npm依赖安装的方式导入相关模块,成功创建
Modeler
实例,并在此之上成功绘制一张符合规范的流程图。
- 正式进入手写代码阶段,通过npm依赖安装的方式导入相关模块,成功创建
观前提醒
本期包含大量的源码分析 和 问题的定位解决过程 ,因此篇幅较长,阅读本文时,方便的同学可以备好一些糖果🍬和巧克力🍫,边吃边看😋。
同样的,我在文章的最后附上了本文中相关项目的代码,如果你期望复现本文所提到的各种场景,可以git clone到本地。
前言
⚡⚡⚡开幕雷击!⚡⚡⚡
什么情况,为什么我们在上一篇文章中创建好的流程图,在拖动的时候长成了这个鬼样子?
出于一个老前端的直觉,这应该是样式出了问题。
在本篇文章,我们将探讨bpmn-js的绘图原理,并解决开篇出现的样式问题。然后我们会增加左侧工具栏,并给我们的画布新增导出按钮,确保我们能够实现自定义绘制流程图,并能够将绘制好的流程图导出,从而完成我们期望这个组件能够实现的最最基本的要求。
初始化过程发生了什么
还记得在上一篇文章中的【创建实例】章节里的这部分内容嘛?
在本篇文章里,我们就结合 bpmn-js
的源码聊一聊,这个初始化操作到底做了什么。
DOM元素结构
回顾一下我们之前是如何创建一个 Modeler
实例的:
jsx
setBpmnModeler(new Modeler({ container: "#canvas" }));
我们将一个包含 container
属性的对象传递给了构造函数,用于创建实例。
然后我们用 F12 打开控制到,可以看到这样的一个DOM元素结构:
由上图可以看到,container: "#canvas"
,这行代码所指定的对应id的DOM元素(即 <div id='canvas'/>
)下面生成了很多节点。
👆我们找到 <svg/>
标签,发现它正是对应了画布内容本身。👆
接下来我们拖动画布中的部分节点,将其置为下图状态:
再次审查元素:
我们可以看到一些似乎是造成展示异常的 style
属性设置。并且通过这几步审查元素的操作,我们也明白了,之前我们调用的 Modeler
的实例方法importXML
所做的事情就是将 xml
转换成 svg
。
这里我们顺便也回顾一下 <svg/>
、 <rect/>
、 <g/>
、 <path/>
这些标签。
<svg/>
可缩放矢量图形 (Scalable Vector Graphics,SVG )基于 XML 标记语言,用于描述二维的矢量图形 ------MDN
要理解上方的定义文字,我们就得先理解 XML 和 矢量图形。
-
XML:XML(Extensible Markup Language)是一种类似于 HTML,但是没有使用预定义标记 的语言。因此,可以根据自己的设计需求定义专属的标记。这是一种强大将数据存储在一个可以存储、搜索和共享的格式中的方法。最重要的是,因为 XML 的基本格式是标准化的,如果你在本地或互联网上跨系统或平台共享或传输 XML,由于标准化的 XML 语法,接收者仍然可以解析数据。
-
定义太长了,会审美疲劳,这里我们其实着重关注
预定义标记
和没有使用预定义标记
这一不同点即可。这个不同点其实可以用这样一句话概括:
html的预定义是指我们写一个html,使用的标签元素一定得是html定义好的,而我们写xml,我们可以自定义标签。
- HTML:
html<!DOCTYPE html> <html> <head> <title>示例页面</title> </head> <body> <h1>这是一个标题</h1> <p>这是一个段落。</p> <img src="image.jpg" alt="示例图片"> </body> </html>
- XML
xml<?xml version="1.0" encoding="UTF-8"?> <library xmlns:books="http://www.example.com/books"> <books:book> <books:title>我喜欢的人是红色的</books:title> <books:author>亚历山大 · 达威尼亚</books:author> </books:book> </library>
-
SVG 图像及其相关行为被定义于 XML 文本文件之中,这意味着可以对它们进行搜索、索引、编写脚本以及压缩。此外,这也意味着可以使用任何文本编辑器和绘图软件来创建和编辑它们。
-
-
矢量图形:和传统的点阵图像模式(如 JPEG 和 PNG)不同的是,SVG 格式提供的是矢量图,这意味着它的图像能够被无限放大而不失真或降低质量,并且可以方便地修改内容,无需图形编辑器。通过使用合适的库进行配合,SVG 文件甚至可以随时进行本地化。
-
失真是啥意思呢,比如我截图一张
117*112
大小的png图片,我将其放大几倍,图片会很显然地变得模糊。这是因为当我们放大图像时,每个像素的大小增加,但像素本身并没有增加额外的信息。放大过程通常是简单地复制周围的像素来填充空间,这个过程称为插值。因为原始图像的像素信息是固定的,所以放大后的图像看起来会模糊,尤其是在像素边界处更为明显。
我们可以通过下方的动图演示直观地看到:
-
但是SVG就没这个问题,我们也可以通过动图演示直观地看一下:
-
说了半天,我们最后再以一个简单的例子做个展示:
xml
<svg version="1.1"
baseProfile="full"
width="300" height="200"
xmlns="http://www.w3.org/2000/svg">
<rect width="100%" height="100%" fill="#007fff" />
<text x="150" y="125" font-size="60" text-anchor="middle" fill="white">海石</text>
</svg>
我们创建了一个很简单的带有矩形和文本的 <svg/>
。
接下来,我们就要讲一讲用于SVG绘图的一些基本形状(上面那个展示用的例子就使用到了矩形:<rect/>
)
<rect/>
rect
元素会在屏幕上绘制一个矩形。
我们可以通过设置它的6个基本元素,来控制一个矩形在屏幕上的位置和形状。
比如我们期望绘制一个宽为400,高为300,以左上角为坐标原点,水平距离为60,垂直距离为10的矩形,我们可以这一设置:
xml
<svg version="1.1"
baseProfile="full"
width="300" height="200"
xmlns="http://www.w3.org/2000/svg">
<rect width="400" height="300" x="60" y="10" />
</svg>
还可以通过设置剩余2个元素:rx 和 ry,来设置圆角的x方位和y方位的半径。
<path/>
path
可能是 SVG 中最常见的形状。可以使用 path 元素绘制矩形(直角矩形或者圆角矩形)、圆形、椭圆、折线形、多边形,以及一些其他的形状,例如贝塞尔曲线、2 次曲线等曲线。
xml
<svg version="1.1"
baseProfile="full"
width="300" height="200"
xmlns="http://www.w3.org/2000/svg">
<svg width="190" height="160" xmlns="http://www.w3.org/2000/svg">
<path d="M 10 10 C 20 20, 40 20, 50 10" stroke="black" fill="transparent"/>
<path d="M 70 10 C 70 20, 110 20, 110 10" stroke="black" fill="transparent"/>
<path d="M 130 10 C 120 20, 180 20, 170 10" stroke="black" fill="transparent"/>
<path d="M 10 60 C 20 80, 40 80, 50 60" stroke="black" fill="transparent"/>
<path d="M 70 60 C 70 80, 110 80, 110 60" stroke="black" fill="transparent"/>
<path d="M 130 60 C 120 80, 180 80, 170 60" stroke="black" fill="transparent"/>
<path d="M 10 110 C 20 140, 40 140, 50 110" stroke="black" fill="transparent"/>
<path d="M 70 110 C 70 140, 110 140, 110 110" stroke="black" fill="transparent"/>
<path d="M 130 110 C 120 140, 180 140, 170 110" stroke="black" fill="transparent"/>
</svg>
</svg>
<g/>
元素
g
是用来组合对象的容器。添加到g
元素上的变换会应用到其所有的子元素上。添加到g
元素的属性会被其所有的子元素继承。此外,g
元素也可以用来定义复杂的对象,之后可以通过<use>
元素来引用它们。------MDN
在我们的流程设计器中,我们可以看到,这种容器出现的频率非常的高:
我们也可以自己写一个<g/>
标签,去尝试体验一把成组管理元素的感受:
xml
<svg
width="100%"
height="100%"
viewBox="0 0 195 150"
xmlns="http://www.w3.org/2000/svg">
<g stroke="#007fff" fill="white" stroke-width="5">
<rect x="25" y="25" width="40" height="30" />
<rect x="40" y="25" width="40" height="30" />
<rect x="55" y="25" width="40" height="30" />
<rect x="70" y="25" width="40" height="30" />
<rect x="85" y="25" width="40" height="30" />
<rect x="100" y="25" width="40" height="30" />
<rect x="115" y="25" width="40" height="30" />
</g>
</svg>
我们统一设置了组内元素(7个矩形)的填充颜色和线条颜色。
OK,在对DOM元素结构以及几个标签元素有了一定程度的了解后,我们正式去看 bpmn-js
的源码,看看 new Modeler()
到底做了什么。
源码分析
我们可以从依赖的路径(bpmn-js/lib/Modeler
)快速定位到源码中相关文件的位置:
在源码中我们可以很快速地找到Modeler的构造函数:
js
export default function Modeler(options) {
BaseModeler.call(this, options);
}
inherits(Modeler, BaseModeler);
这是一个很标准的原型链继承,Modeler
继承了 BaseModeler
。
当然,得出这个结论我们需要去看一下 inherits
方法的源码,它来自于 inherits-browser
这个包。
js
if (typeof Object.create === 'function') {
// implementation from standard node.js 'util' module
module.exports = function inherits(ctor, superCtor) {
if (superCtor) {
ctor.super_ = superCtor
ctor.prototype = Object.create(superCtor.prototype, {
constructor: {
value: ctor,
enumerable: false,
writable: true,
configurable: true
}
})
}
};
} else {
// old school shim for old browsers
module.exports = function inherits(ctor, superCtor) {
if (superCtor) {
ctor.super_ = superCtor
var TempCtor = function () {}
TempCtor.prototype = superCtor.prototype
ctor.prototype = new TempCtor()
ctor.prototype.constructor = ctor
}
}
}
源码读到这里,不知道你是否和我一样产生了以下几个疑问:
- 为什么还在使用原型链继承,而不是使用ES6+提供的
class
关键字去实现继承? - 为啥
inherits
的源码中有2种实现方式? - 什么是原型链继承?(对于直接就接触
class
关键字的同学来说,可能并不清楚)
遇到问题不解决可不行呀,放着不管继续看源码的话,心里也会痒痒,影响后续的学习。因此,我们先来一一回答上述的疑问。
问题答疑
问题一
为什么还在使用原型链继承,而不是使用ES6+提供的
class
关键字去实现继承?
这个问题是最好回答的,因为 bpmn-js
是十几年前的代码了,我们将源码 git clone 到本地之后,查看【时间线】:
写的时候还没 class
关键字可以用呢。
后续的一系列改造(本文还未提到的部分),全都是以原型链继承为基础的,因此如果要重构成ES6+的class写法,显然是巨额工作量。
问题二
为啥
inherits
的源码中有2种实现方式?
我们从源码中可以看到,typeof Object.create === 'function'
这行条件语句决定了实现方式的不同。这是因为并不是所有浏览器都支持Object.create
这个静态方法的。
因为Object.create
是ES5引入的,旧版的浏览器比如IE8、chorme3等不支持。
于是我们就需要通过一个函数作为原型链继承的中转站,让父级的原型先赋值给TempCtor
的原型, 然后再把TempCtor
的实例作为子级的原型。
js
var TempCtor = function () {}
TempCtor.prototype = superCtor.prototype
ctor.prototype = new TempCtor()
最后,不要忘记修改原型后,constructor
也会改变,我们还需要把子级原型对象上的constructor
改回它本身。
js
ctor.prototype.constructor = ctor
实际上,当我们抛开Object.create
的其他逻辑不谈,它的核心内容就是上述这样的一个过程:
js
Object.create = function (proto, propertiesObject) {
// .....
// 上方被省略的内容是一些边界处理的逻辑
function F() {}
F.prototype = proto;
return new F();
};
问题三
什么是原型链继承?(对于直接就接触
class
关键字的同学来说,可能并不清楚)
要完整地回答这个问题,估计可以单独出一篇博客了,一起聊聊原生链继承、寄生继承、寄生组合继承等等。
这里为了避免喧宾夺主,简单地提一嘴:
原型链继承是 JavaScript
中实现继承的一种机制。在 JavaScript
中,每个函数都有一个原型属性(prototype
),而每个对象都有一个指向其原型对象的内部链接,称为__proto__
(在现代浏览器中,建议通过Object.getPrototypeOf()
访问,因为 __proto__
已经添加了废弃声明)。当访问一个对象的属性或方法时,如果这个对象本身没有这个属性或方法,解释器就会沿着这个对象的__proto__
链向上查找,直到找到这个属性或方法,或者到达原型链的顶端(通常是Object.prototype
)。
简单来说,原型链继承允许子类的原型(prototype
)指向父类的实例,从而实现子类能够访问父类原型上的属性和方法。
以下是一个简单的原型链继承的例子:
js
function Parent() {
this.parentProperty = true;
}
Parent.prototype.getParentProperty = function() {
return this.parentProperty;
};
function Child() {
this.childProperty = false;
}
// 继承Parent
Child.prototype = new Parent();
// 修正构造函数指向,我们在回答第二个问题时提到过。
Child.prototype.constructor = Child;
// 创建Child的一个实例
var childInstance = new Child();
console.log(childInstance.getParentProperty()); // 输出 true
在这个例子中,Child
的原型(Child.prototype
)被设置为 Parent
的一个实例。这意味着 Child
的实例 childInstance
可以通过原型链访问 Parent
原型上的方法 getParentProperty
。
好的,经过这几个问题的回答,我们扫清了眼前的阴霾,继续往下阅读源码。
问题根因的定位和解决
因为我们发现Modeler
继承了 BaseModeler
,那么想搞明白构造函数中的内部逻辑,就得再去找 BaseModeler
。
我们很快便找到了 BaseModeler
的源码,如下:
js
export default function BaseModeler(options) {
BaseViewer.call(this, options);
// hook ID collection into the modeler
this.on('import.parse.complete', function(event) {
if (!event.error) {
this._collectIds(event.definitions, event.elementsById);
}
}, this);
this.on('diagram.destroy', function() {
this.get('moddle').ids.clear();
}, this);
}
inherits(BaseModeler, BaseViewer);
可以看到 BaseModeler
又继承了 BaseViewer
,于是我们再去找 BaseViewer
的源码。
(这里的 this.on
的写法不知道你是否感兴趣,是不是和热门的面试考题:"手写一个 发布/订阅 " 很相似?下回再说吧。)
我们照样很快便找到了 BaseViewer
的源码,将近860行的代码量,看来秘密就藏在它这了。
这里我想要多一句嘴🥺,那就是我们不以速成为目标,而是循序渐进,按部就班。因此我们不会在本文中就把这几百行的代码全部都搞懂,这不现实,也不好看。
别忘了我们最开始期望通过阅读源码来找到解决方法的那个问题:
为什么我们在上一篇文章中创建好的流程图,在拖动的时候长成了这个鬼样子
因此我们这一次阅读源码的目的就只要搞明白这个问题就可以了,其他的我们会在系列后续的文章中慢慢聊~
线索一
我们检索和 container
相关的代码,找到了几处地方,先来看第一处:
js
BaseViewer.prototype._init = function(container, moddle, options) {
const baseModules = options.modules || this.getModules(options),
additionalModules = options.additionalModules || [],
staticModules = [
{
bpmnjs: [ 'value', this ],
moddle: [ 'value', moddle ]
}
];
const diagramModules = [].concat(staticModules, baseModules, additionalModules);
const diagramOptions = assign(omit(options, [ 'additionalModules' ]), {
canvas: assign({}, options.canvas, { container: container }),
modules: diagramModules
});
// invoke diagram constructor
Diagram.call(this, diagramOptions);
if (options && options.container) {
this.attachTo(options.container);
}
};
注意这行注释:
js
// invoke diagram constructor
这意味着,我们创建Modeler实例的过程,实际上调用的构造函数来自 Diagram
的,我们通过 Diagram.call
的形式去让 Diagram
帮我们完成了绝大部分的初始化操作。
好的,我们先记下这个结论,等会再一起看。
线索二
接着,我们看下一处源码:
js
BaseViewer.prototype._createContainer = function(options) {
const container = domify('<div class="bjs-container"></div>');
assignStyle(container, {
width: ensureUnit(options.width),
height: ensureUnit(options.height),
position: options.position
});
return container;
};
还记得我们之前审查元素时看到的结构嘛?
来自 'min-dom'
的 assignStyle()
方法赋予了画布(<svg/>
)外层容器 <div class="bjs-container"></div>
的样式。
看样子,源码里确实有样式相关的处理逻辑。
线索三
再看最后一处代码:
js
function addProjectLogo(container) {
const img = BPMNIO_IMG;
const linkMarkup =
'<a href="http://bpmn.io" ' +
'target="_blank" ' +
'class="bjs-powered-by" ' +
'title="Powered by bpmn.io" ' +
'>' +
img +
'</a>';
const linkElement = domify(linkMarkup);
assignStyle(domQuery('svg', linkElement), LOGO_STYLES);
assignStyle(linkElement, LINK_STYLES, {
position: 'absolute',
bottom: '15px',
right: '15px',
zIndex: '100'
});
container.appendChild(linkElement);
domEvent.bind(linkElement, 'click', function(event) {
openPoweredBy();
event.preventDefault();
});
}
这个是基于 bpmn-js
的开源协议,要求一定要带上logo,用于在实例初始化时自动给画布绘制logo图案的方法。
根据找到的这几处代码,我们发现,最终的秘密还是藏在了来自第三方依赖 'diagram-js'
的 Diagram
这个构造函数中。
因此,我们去看一下 'diagram-js'
。
'diagram-js'
我们可以在之前提到的《操作手册》中看到这样的信息:
我们使用 diagram-js 来绘制形状和连接。它为我们提供了与这些图形元素互动的方式,以及额外的工具,如覆盖层,帮助用户构建强大的 BPMN 视图。对于高级用例,如图形建模,它提供了上下文面板、调色板以及重做/撤销等功能。
我们查看 Diagram
的测试文件,可以直观地看到它的使用传参:
js
let diagram = new Diagram();
diagram = new Diagram({
modules: [
CoreModule,
CommandModule,
ModelingModule
],
canvas: {
deferUpdate: true
}
});
这里的使用方式和我们在 BaseViewer
中是一样的:
js
const diagramOptions = assign(omit(options, [ 'additionalModules' ]), {
canvas: assign({}, options.canvas, { container: container }),
modules: diagramModules
});
// invoke diagram constructor
Diagram.call(this, diagramOptions);
同样都具备 modules
和 canvas
属性。
此刻我们得知了一个信息,那就是画布上所有的图形、线条,也就是之前我们在审查元素中查看到的<svg/>
下的各种元素,均是 'diagram-js'
创建的。
这也就意味着,这些被创建的元素的样式应该都是被 'diagram-js'
所设置好的。
这里面的 className
均是以 djs-
开头,如果我们在 'diagram-js'
的源码中尝试搜索这些类名时:
我们能够找到diagram-js.css
这份样式文件,里面定义了这些图元应该有的样式。
这下我们知道为什么我们的线条被hover 被拖动 被点击时展示的样子是那么的奇怪了,因为根本就没有对应的样式文件应用在上面!
于是我们尝试在 bpmn-js
中搜索是否存在和diagram-js.css
相关的样式文件,果不其然:
TestHelper.js
文件,用于设置和配置测试环境,包括样式表的加载、Chai匹配器的配置以及文件拖放功能的实现。它是测试套件的一部分,用于测试BPMN图的可视化和编辑功能。
解决
于是我们照猫画虎,将TestHelper.js
文件所插入的CSS文件,全部也在我们的项目中引入,并把画布的宽高改成100%:
tsx
import "bpmn-js/dist/assets/bpmn-font/css/bpmn-codes.css";
import "bpmn-js/dist/assets/bpmn-font/css/bpmn-embedded.css";
import "bpmn-js/dist/assets/bpmn-font/css/bpmn.css";
import "bpmn-js/dist/assets/diagram-js.css";
import "bpmn-js/dist/assets/bpmn-js.css";
//...
return <div id="canvas" style={{ width: "100%", height: "100%" }}></div>;
改版后,我们在试着去做一些操作,如下图所示:
可以看到,样式问题已经完全解决,非常的正常,美观👍。
导出
经历过上一章节的阅读,我想大家多少有一丝丝疲惫,那么在这个章节,我们就做一些简单粗暴的事情,调库!
读了那么多代码,是时候写点代码转换转换心情了。
我们写一个简单的导出按钮,让它处于画布的中上方:
tsx
<div style={{ position: "relative", width: "100%", height: "100%" }}>
<div id="canvas" style={{ width: "100%", height: "100%" }} />
<Button
style={{ position: "absolute", top: 20, right: "50%" }}
size="small"
onClick={() => {
// exportXML();
}}
>
导出
</Button>
</div>
然后我们再去写点击后的导出逻辑,即 exportXML()
。
我们期望能够以 SVG 的形式保存我们的流程图,于是我们去源码里找一下相关的方法:
我们找到了 BaseViewer.prototype.saveSVG
这个方法,它会返回一个包含错误信息和svg的对象。
由于我们的 Modeler
是通过原型链继承,继承了 BaseViewe
,因此我们可以直接调用,如下:
tsx
const { svg } = await bpmnModeler.saveSVG();
接下来就是很常见的需求了,即如何在网页上提供用户一个下载文件的入口。
在掘金上随便搜一下都有这么多的解决方案:
这里我们就使用最基础的方式,即利用 <a/>
标签结合 href
的形式帮我们下载文件。
tsx
const fileDownload = (href: string, filename: string) => {
if (href && filename) {
let aLink = document.createElement("a");
aLink.download = filename; //指定下载的文件名
aLink.href = href; // URL对象
aLink.click(); // 模拟点击
document.body.removeChild(aLink); // 从文档中移除a标签
URL.revokeObjectURL(aLink.href); // 释放URL 对象
}
};
最后的问题就是如何获取到这个 href
了。
我们需要调用encodeURIComponent
对之前利用BaseViewer.prototype.saveSVG
生成的字符串格式的SVG进行编码。
tsx
const encodedData = encodeURIComponent(data);
编码后,再拼接成 href
期望的格式即可。
tsx
href: `data:application/${
type === "svg" ? "text/xml" : "bpmn20-xml"
};charset=UTF-8,${encodedData}`,
完整代码如下:
tsx
// 根据所需类型进行转码并返回下载地址
const encodedFile = (type: string, filename = "diagram", data: string) => {
const encodedData = encodeURIComponent(data);
return {
filename: `${filename}.${type}`,
href: `data:application/${
type === "svg" ? "text/xml" : "bpmn20-xml"
};charset=UTF-8,${encodedData}`,
data: data,
};
};
最终代码如下:
tsx
const fileDownload = (href: string, filename: string) => {
if (href && filename) {
let aLink = document.createElement("a");
aLink.download = filename; //指定下载的文件名
aLink.href = href; // URL对象
aLink.click(); // 模拟点击
document.body.removeChild(aLink); // 从文档中移除a标签
URL.revokeObjectURL(aLink.href); // 释放URL 对象
}
};
// 根据所需类型进行转码并返回下载地址
const encodedFile = (type: string, filename = "diagram", data: string) => {
const encodedData = encodeURIComponent(data);
return {
filename: `${filename}.${type}`,
href: `data:application/${
type === "svg" ? "text/xml" : "bpmn20-xml"
};charset=UTF-8,${encodedData}`,
data: data,
};
};
const exportXML = async () => {
try {
const allShapes = bpmnModeler.get("elementRegistry").getAll();
const { svg } = await bpmnModeler.saveSVG();
const { href, filename } = encodedFile(
"SVG",
allShapes[0].businessObject.name,
svg
);
fileDownload(href, filename);
} catch (e) {}
};
运行后的效果如下图所示:
结语
在本篇文章里,我们通过源码阅读,定位样式问题出现的根因,并找到了相应的解决方案,成功解决。之后,我们写了一个简单的导出按钮,使得我们的流程设计器成功地能够导出自定义的流程图。
可以说,经过这一次的改造,我们的组件确实称得上"羽翼渐丰"。
但是离成熟的组件还存在不少距离:
- 如何完成流程图的合法校验?一张不符合规范的流程图没有存在的必要。(比如包含多个开始节点的流程图)
- 属性面板呢?我期望给节点进行名称、状态、类型的修改,目前没有这种入口。
- 解决完样式异常后,自定义样式该如何实现?
我们将在本系列的后续文章里接着完善我们的组件,并且也是时候聊一聊Next.js了。
期待与你在下一篇文章相遇~
示例完整代码
我将代码push到了我的 git仓库里,大家可以在这里找到。