目录
前言
上一篇博文中,我们实现了为文字添加和修改加粗、斜体、下划线、删除线。
这篇博文是《前端canvas项目实战——在线图文编辑器》付费专栏系列博文的第七篇——加粗、斜体、下划线、删除线(下),主要的内容有:
- 在上一篇实现加粗、斜体、下划线和删除线等功能时遇到bug及其解决方案。
- 在上一篇的实现中,我们发现了可以对数据进行优化的地方,降低数据存储和前后端传输的数据大小,减少浪费。
博主自己是一个有代码洁癖的人,写代码和文章的时候喜欢精益求精。因此,在写代码的时候遇到bug就一定会修复,遇到可以优化时间复杂度或者空间复杂度的地方,就一定会去优化。这样,一份我自认为比较接近完美的代码和一篇去繁就简、条理清晰的博文就一并出现在了你的眼前。
最近我经常思考,一个「初学者」除了想要学会如何去实现一个需求,更重要的是学习到「老码客」遇到问题是怎么思考和分解的。基于此,从本篇博文开始,我新增了这样一个小节「Bug点和优化点」,旨在分享“我实现过程中遇到了什么样的bug,怎么解决的?”和“有什么点优化了会更好,是怎么优化的?”这两部分的思考和解决问题的方法。
如有需要,你可以:
- 点击这里,阅读序文《前端canvas项目实战——在线图文编辑器:序》
- 点击这里,返回上一篇《前端canvas项目实战——在线图文编辑器(六):加粗、斜体、下划线、删除线(上)》
- 点击这里,前往下一篇《前端canvas项目实战——在线图文编辑器(八):复制、删除、锁定、层叠顺序》
一、实现过程中发现的4个Bug
在实现的过程中,我会对页面进行丰富的测试,尽可能的避免代码中UI或者逻辑上的异常,只把验证过正确的东西讲给大家。在这个过程中一共发现了4个Bug,3个是我自身的代码逻辑考虑不周导致的,1个是
fabric.js
框架本身的问题。
Bug点1:_shouldUpdateTheWholeTextbox方法
如需回顾这个方法的完整代码,可以点击这里回到上一篇博文。
在前文中,我们使用这个方法判断当前用户选中的Textbox的状态,进而判断这个时候是要更新整个Textbox的属性还是只想更新自己选中的部分文字的属性。
const_shouldUpdateTheWholeTextbox=(object, key)=>{...// Bug点1:容易忘记考虑编辑状态时,选中了全部文字if(!isEditing || isSelectAll){returntrue;}return key ==="lineHeight";};
这个方法在上篇博文中有贴过,但当时判断条件只写了
if(!isEditing)
,即只要用户不处于编辑状态就应该更新整个Textbox。**这里产生了一个bug:有一种情况,用户在编辑状态,使用
Ctrl + A
组合键,选中了全部的文字**,此时也应该更新整个Textbox的属性。
经过改动后,
if (!isEditing || isSelectAll)
的判断逻辑,就兼容了这种之前未考虑到的状态。
Bug点2:_updateFontPropertyForWholeObject方法
如需回顾这个方法的完整代码,可以点击这里回到上一篇博文。
在上文中,这个方法用于更新整个Textbox的属性值。
const_updateFontPropertyForWholeObject=(key, newValue)=>{...// Bug点2:由于部分文字的样式优先级高于整个文本框的样式,先清除整个文本框的对应属性
activeObject.removeStyle(key);...};
先看看出现bug的场景:
这个Textbox共有「只有中间的几个字有下划线」这几个字,我们事先为「中间的几个字」局部设置了下划线。当我们对整个文本框设置了下划线,再取消时,bug出现了,「中间的几个字」的下划线没被取消掉。
经过分析,我们需要从数据层面入手。 在
fabric.Textbox
的实现中,上面这个文本框的属性字典形如:
{"type":"textbox",...,"underline":false,"styles":[{"startIndex":"2","endIndex":"7","style":{"underline":true}}]}
- 「
"underline": false
」 表示文本框整体不设置下划线 - 「
styles: [...]
」 表示文本框中从第2到第7个字符设置了下划线
产生bug的原因是: 在
fabric
的设定中,
styles
里设置的局部属性的优先级高于
underline
、
strikethrough
这些对象的整体属性。
也就是说,**想要全局的
underline
对所有文字生效,就要先去除优先级更高的
styles
数组中的局部设置。** 因此,我们在这里加了
activeObject.removeStyle(key);
。因为示例是在设置下划线,我们将
key="underline"
带入代码,即
activeObject.removeStyle("underline");
。
再来看修复了Bug之后,先设置全局下划线,再取消时,文本框的属性经历了怎样的变化
// 0. 原本的数据字典{"type":"textbox",...,"underline":false,"styles":[{"startIndex":"2","endIndex":"7","style":{"underline":true}}]}// 1. 先通过removeStyle方法删掉所有underline的定义{"type":"textbox",...,"styles":[]}// 2. 再为全局设置上underline{"type":"textbox",...,"underline":true,"styles":[]}// 3. 全局取消underline{"type":"textbox",...,"underline":false,"styles":[]}
最后看看bug修复后的效果
可以看到,对全局设置/取消属性不再受到已经设置的局部属性的影响。
Bug点3:_updateFontPropertyForSelection方法
如需回顾这个方法的完整代码,可以点击这里回到上一篇博文。
在上文中,这个方法用于更新用户选中的部分文字的属性值。
const_updateFontPropertyForSelection=(key, newValue)=>{...// Bug点3:编辑态时,如果没有选中任何文字,不做任何处理if(selectionEnd === selectionStart){return;}...};
这个问题比较简单,只是一开始没有想到,测试时报错了。当文本框处于编辑态,用户也没有选中任何文字时,点击任何的按钮都不应该做任何更新数据的操作,让方法直接返回。
Bug点4:选择部分文字加大字号,会导致文字超出选择框
先看看这个bug的表现:
可以看到,当部分文字被设置了很大的字号时,本应被「折行」到下一行的文字仍在原行,导致文字超出了选择框。经过搜索,我们发现fabric.js的Github仓库已经有人提了相似的issue。
经过探索之后发现,是
fabric.Textbox.prototype._wrapLine
这个私有方法存在问题。这个方法是用来处理文字「折行」逻辑的,篇幅有限,我们只展示关注的代码行:
_wrapLine:function(_line, lineIndex, desiredWidth, reservedSpace){var splitByGrapheme =this.splitByGrapheme,
words = splitByGrapheme ? fabric.util.string.graphemeSplit(_line): _line.split(this._wordJoiners),
wordWidth =0,
additionalSpace =this._getWidthOfCharSpacing();...for(var i =0; i < words.length; i++){// if using splitByGrapheme words are already in graphemes.
word = splitByGrapheme ? words[i]: fabric.util.string.graphemeSplit(words[i]);// 1. 计算行宽度,逐个加上字符宽度
lineWidth += infixWidth + wordWidth - additionalSpace;// 2. 如果行宽度大于选择框宽度,就折行if(lineWidth > desiredWidth &&!lineJustStarted){
graphemeLines.push(line);
line =[];
lineWidth = wordWidth;
lineJustStarted =true;}else{
lineWidth += additionalSpace;}// 3. 依赖offset值计算字符的宽度
wordWidth =this._measureWord(word, lineIndex, offset);...// 4. offset在每次循环都自增1
offset++;...}...};
在这个方法中,有个
for
循环逐个计算字符的宽度来判断一行是否超过了选择框的宽度,如果超过了,就进行「折行」,然后继续累加,判断下一行,直到遍历完每个字符。
根据
fabric.js
的实现:
splitByGrapheme
是一个
boolean
型的属性,表示文本框是否把每个字符当做一个整体。
- 当
splitByGrapheme
为false(默认值)
时,文本框把每个单词当做一个整体,用「空格」间隔,适用于英文等语言。 - 当
splitByGrapheme
为true
时,文本框把每个字符当做一个整体,没有间隔。用于兼容中文、日文等语言。
**问题就出在
offset++
这里,没有做判断,统一让
offset
自增
1
。** 而实际上:
- 当遍历完一个英文单词,后面有一个「空格」,这个时候应该
offset++
; - 当遍历完一个中文字符,后面没有「空格」,不应该让
offset
自增。
那么问题就找到了,我们为
offset++
这一行加上判断条件:
_wrapLine:function(_line, lineIndex, desiredWidth, reservedSpace){...// 只有splitByGrapheme是false时,offset才自增1if(!splitByGrapheme){
offset++;}...};
现在看看修复后的效果
二、 1个优化点——降低空间复杂度
minifySelectionStyles方法
如需回顾这个方法的完整代码,可以点击这里回到上一篇博文。
对这个方法的调用也出现在上文中的
_updateFontPropertyForSelection
方法中。
const_updateFontPropertyForSelection=(key, newValue)=>{...// 优化点1:删除冗余的数据
activeObject.minifySelectionStyles();};
先看看这个优化点涉及的操作:
这个过程中,文本框的属性字典变化如下:
// 0. 初始状态{"type":"textbox",...,"strikethrough":false,"styles":[]}// 1. 为选中的部分文字设置删除线{"type":"textbox",...,"strikethrough":false,"styles":[{"startIndex":"2","endIndex":"7","style":{"strikethrough":true}}]}// 2. 再取消选中的部分文字的删除线{"type":"textbox",...,"strikethrough":false,"styles":[{"startIndex":"2","endIndex":"7","style":{"strikethrough":false}}]}
两次点击按钮之后,预期的数据字典应该恢复成一开始的样子,但实际的情况是:
- 全局的
"strikethrough": false
本来就对所有文字都生效,意为全部文字都不设置删除线。 styles
中还有冗余的局部属性设置,表示第2到第7个文字不设置下划线。
因此,一个合理的解决方案就是在每次为局部的文字设置属性后,尝试查找并删除
styles
中冗余的属性设置。
这样做并不是因为我们吹毛求疵。一个Textbox中冗余几十个字符确实不算很大的存储开销,但时可以想象,如果我们制作一份简历,就会有几十个文本框,如果每个都冗余几十个字符,一共就会多存几千个字符,这对传输和存储都会造成很大的浪费。
所以,对于这种
fabric
本应考虑在内,但是并没有的方法,我们直接修改
fabric.Textbox
的原型,为其添加一个方法:
/**
* 针对 Textbox 的 styles 属性,删除冗余的数据
* 数据结构形如:{"0":{"20":{"fontSize":36},"21":{"fontSize":36,"fontWeight":800}}},3层字典结构
* 当 Textbox 整体的 fontSize 为 36时,styles 中的第 20和 21位单独设置fontSize 为 36就是冗余的,需要删除
* 删除后的 styles 为 {"0":{"21":{"fontWeight":800}}}
*/
fabric.Textbox.prototype.minifySelectionStyles=function(){// 遍历第一层 keyfor(let entryKey inthis.styles){// 第一层字典的 value,形如{"20":{"fontSize":36},"21":{"fontSize":36,"fontWeight":800}}let stylesMapOfEntry =this.styles[entryKey];// 遍历第二层 keyfor(let indexKey in stylesMapOfEntry){// 第二层字典的 value,形如{"fontSize":36,"fontWeight":800}let stylesMapOfIndex = stylesMapOfEntry[indexKey];// 遍历第三层 keyfor(let PropertyKey in stylesMapOfIndex){// 如果第三层字典的 value 和 Textbox 对应属性的值相等if(stylesMapOfIndex[PropertyKey]===this[PropertyKey]){// 则为冗余属性,从字典中删除deletethis.styles[entryKey][indexKey][PropertyKey];}}// 如果经过删除,第三层字典为空的{},例如"20":{}
stylesMapOfIndex =this.styles[entryKey][indexKey];// 则删除第二层字典中的"20"if(Object.keys(stylesMapOfIndex).length ===0){deletethis.styles[entryKey][indexKey];}}// 如果经过删除,第二层字典为空的{},例如"0":{}
stylesMapOfEntry =this.styles[entryKey];// 则删除第一层字典中的"0"if(Object.keys(stylesMapOfEntry).length ===0){deletethis.styles[entryKey];}}};
代码的逻辑很清晰,且添加了逐行的注释,不再过多解释。
唯一要说明的是——styles的结构在内存中和序列化之后并不相同。 所以实现过程中需要在脑内经常转换这两种数据结构。
- 在内存中按行和索引,二维结构:- 形如
{"0":{"20":{"fontSize":36},"21":{"fontSize":36,"fontWeight":800}}}
- 表示第0
行的第20
位设置了fontSize=36
,第0
行的第21
位设置了fontSize
和fontWeight
两个属性。 - 通过
JSON.stringify(textbox)
序列化之后,为了便于传输和存储,一维结构:- 形如[{"startIndex": "20", "endIndex": "21", "style": {"fontSize": 36}}, {"startIndex": "21", "endIndex": "21", "style": {"fontWeight":800}}]
- 表示从第20
位到第21
位,都设置了fontSize=36
,第21
位还设置了fontWeight=800
后记
单纯的实现需求没有太大的意义。在这过程中会遇到的一个一个功能上、性能上的问题,我们如何思考,如何解决,这些都是难得的实践经验。
在我看来,这篇博文中的内容,远远比上一篇博文中的基础实现来的重要。因此单独拆成一篇博文,花了很大篇幅和精力来讲解。希望其中「分析问题的方法」和「解决问题的思路」能给你带来收获!
如有需要,你可以:
- 点击这里,阅读序文《前端canvas项目实战——在线图文编辑器:序》
- 点击这里,返回上一篇《前端canvas项目实战——在线图文编辑器(六):加粗、斜体、下划线、删除线(上)》
- 点击这里,前往下一篇《前端canvas项目实战——在线图文编辑器(八):复制、删除、锁定、层叠顺序》
版权归原作者 IMplementist 所有, 如有侵权,请联系我们删除。