• 登录
社交账号登录

腾讯问卷文本编辑模式重构与改进

背景 

腾讯问卷有两种创建问卷的模式,分别是高级编辑与文本编辑。
高级编辑提供方便的UI界面,用户通过拖拽形式即可完成问卷创建。
腾讯问卷文本编辑模式重构与改进(图1)
而文本编辑则是通过纯文本来生成问卷,适合需要大量创建题目的用户。
腾讯问卷文本编辑模式重构与改进(图2)
当前文本编辑模式的解析器基于正则表达式实现,代码逻辑十分分散且复杂,使得想添加新语法会变得非常困难。
而且并没有实现文本与问题ID的绑定,每次对文本内容修改时,会重新解析并实时生成新的问题ID
问题ID的变化会使之前设置的DSL(根据ID来处理逻辑)全部失效,同时还会导致以往关联的答案数据因为ID的丢失,而变得无效。

 

任务

基于以上背景介绍的问题,本次任务的目标是:
  • 解决每次编辑会重新生成ID的问题

 

前置工作

在行动前,我们还组织了一次方案评审会议,在这次会议中,也提到了一些没有考虑到的问题,比如:
  • 复制题目时ID冲突,解决方法:粘贴时先触发diff,若文本中已有相同ID,则将当前要粘贴的文本ID去除。
  • 改题型时导致已回收数据无法使用,解决方法:修改题型时,重新分配ID

是一次十分有意义的会议。

 

行动

基于PEG.js改造

 

动机

文本生成问卷的结构化对象(SurveyJson),这无异于是在做类似Markdown的事,说白了,就是写一门问卷语言
既然是要创建一门新语言,经过我们的研究,PEG.js是一项比较好的选择。
从维基百科我们可以了解到,PEG在实现一门语言的方面,十分有优势。
  • 因为PEG更加严格更加强大,PEG可以成为很好的正则表达式的替代品。例如,一个正则表达式本身是无法匹配嵌套的括号对,因为正则表达式不是递归的,但是PEG却能做到这点。
  • 所有的PEG 都能通过使用Parkrat Parser达到线性时间解析,如同上文所述。
  • CFG表达的解析器,比如LR解析器,需要首先进行一个单独的断词步骤。这个步骤根据空白的位置或者发音等等因素把输入分成词。分词是必要的,因为这类解析器使用向前检查来判断上下文无关文法是否匹配要求。PEG不需要单独的断词步骤,断词的规则和其他文法规则可以用同样的方式写在一起。
  • 许多CFG固有的存在二义性,即使它们原本要描述的东西并不具有二义性。C, C++, Java里面著名的悬空else问题就是一个例子。这个问题通常都是应用文法之外的一个规则解决。而在PEG里面,因为使用了优先权,所以根本不存在这种问题。

基于上述优势,使用PEG.js来实现解析器还是很有说服力的。

 

思路 

PEG.js来实现文本SurveyJson的话,大概会经历这么几个步骤:
腾讯问卷文本编辑模式重构与改进(图3)
由上述时序图我们可以清楚得知道,
Parser仅负责输出问卷文本AST,而Transformer则将AST转换成SurveyJson即可。
要转换成SurveyJson,还需要校验用户输入的文本是否正确,即ASTQuestion 是否正确。按照单一职责原则的话,是需要单独遍历一遍AST来校验后,再转换成 SurveyJson的,但是为了性能考虑,我还是选择了在一次遍历中,完成两件事情的操作,即有了如下的流程:
腾讯问卷文本编辑模式重构与改进(图4)
按照上述流程,我们很快就完成了基于PEG.js的解析器改造。

 

解决 ID 问题

要解决每次ID重新生成的问题,我们想到最好的方法是在文本中显式写入ID,即使文本被编辑,也能根据显式写入的ID实现与SurveyJson中已有的问题绑定。
首先ID的来源有以下几个:
  • ID文本时,使用文本所记录的ID
  • ID文本时,自动按照规则生成ID

然后,既然显式地写入ID,那就会有这几个问题:
  • 什么时候生成ID
  • 怎么判断ID是否需要写入
  • 怎么写入ID

其实,第一和第二个问题是可以放在一起解决的。
Parser解析文本时,大概会生成这样的ASTQuestion结构:
    type Location = {  start: { offset: number, line: number, column: number }  end: { offset: number, line: number, column: number }}
    type ASTQuestion = {  id: string | null  title: {    content: object[],    location: Location  }}
    const astQuestion = {  id: 'q-1-abcd' || null,  title: {    content: ['a', 'b'],    location: {      start: { offset: 0, line: 1, column: 1 },      end: { offset: 2, line: 1, column: 3 },    }  }}
    而经过Tramsformer转换后,会变成这样的Question:
      type Question = {  id: string,  title: string,}const question = {  id: 'q-1-abcd',  title: 'ab',}
      我们可以看出,只有在Question中,ID才是一定要存在的,因此,我们可以在Transformer中,实现ID的绑定或重新生成,即astQuestion.id===null为True时,自动生成ID,否则,沿用astQuestion.id。这样也给解决第三个问题打下了非常好的基础。
      现在我们可以明确整个文本解析会有如下流程:
      腾讯问卷文本编辑模式重构与改进(图5)
      为了解决ID回填的问题,我们设计了以下函数:
        const refillQid = (text: string, astQuestion, question: Question) {    // 旧文本中没有 id  if (astQuestion.id === null) {    text = insert(text, question.id, astQuestion.title.location)  }      return text}

        这样,我们就可以做到仅向没有ID的题目写入新生成的ID了。


        衍生问题

        但是也带来了一个新问题:文本中有ID的存在,若是直接显示且允许编辑会造成更大的混乱。
        幸运的是,文本编辑器基于CodeMirror实现,给这个问题带来了比较好的解决方案。我们可以用CodeMirror提供的markText方法来实现ID的隐藏,然后通过各种输入或删除事件的拦截,实现ID总是在处于正确位置。
        比如在输入完一道题目后要动态的插入ID
        腾讯问卷文本编辑模式重构与改进(图6)
        类型变化时,重新生成ID要替换文本中的ID
        腾讯问卷文本编辑模式重构与改进(图7)
        题目变成选项,或者选项变成题目时ID类型的改变:
        腾讯问卷文本编辑模式重构与改进(图8)
        还有在输入时,自动略过ID
        腾讯问卷文本编辑模式重构与改进(图9)
        在内容删除完后,自动删除ID
        腾讯问卷文本编辑模式重构与改进(图10)
        在上面的输入情况的处理之后,使用CodeMirror隐藏ID选项,即可实现几乎无感知的输入:
        腾讯问卷文本编辑模式重构与改进(图11)

         

        迁移

        鉴于该功能在业务端较为重要,且具有较高使用量,因此要迁移到新版本,必须要有足够的测试度。
        我们选择了由文本编辑创建,回收量最多的前1000份问卷作为测试材料,对比新旧两个编辑器解析出的结果,发现能全部通过测试。
        这也使我们有了迁移的底气。

         

        总结 

        经过上述努力,现在已经实现了PEG.js的改造,以及ID丢失问题的解决。
        在用户输入体验上,基本可以做到无感知地迁移,对于用户来说,可以非常平滑地过渡到新版本中。
        算是一次比较成功的重构和改进。
        但是,通过事件拦截来确保ID能处在一个正确的位置是一个比较困难的问题,毕竟有输入输出的形式有很多,不一定能覆盖到所有情况。
        因此,这是一个需要长期测试,发现BUG并改进的任务。
        收藏