基于React的代码高亮优化之路

1,731 阅读10分钟
原文链接: zhuanlan.zhihu.com

基于React的代码高亮优化之路

作为支撑研发工程效率的部门,代码的托管是我们最为重要的职责之一。因而在代码托管系统上,如何更好地让用户浏览、阅读代码,也一直是我们工作的重要方向。

在我们的系统中,代码的浏览与高亮并没有简单地使用社区里已有的工具库,因为在这个界面:

除了,代码的展示外,还有着众多其它的功能:

  1. 行号的显示,不仅仅是展示一个行号,还提供了选中行(以及按住SHIFT选择多行)并分享URL的能力。
  2. 代码的智能分析,如一个函数的定义和引用,这些引用关系可能在其它文件甚至在其它的代码库中。
  3. 对于不符合公司的编码规范或最佳实践部分的提示,例如我们不希望代码以\r\n作为换行,因此\r字符会被用比较显眼的红色符号表示出来。

由于这样种种的要求,我们并不能简单地使用社区的库,将一整个<pre> 元素交给现成的highlight函数来完成工作。在定制这个高亮算法的过程中,我们先后经历了3个阶段:

按行拆解高亮

在第一个阶段中,由于可用于开发的时间的限制,我们做了一个最简单的方案:先将代码按行拆分成字符串,再将每一行交由社区的工具进行高亮。

在高亮的工具选择上,当前主流的有highlight.jsPrism,经过对比后,我们发现highlight.js对于JSX语法的高亮简直就是一坨不可明着之物,至今还遗留着大量Issue没有解决,高亮的效果也惨不忍睹。因此从一开始,我们就将Prism作为了高亮的方案。

在这个阶段,我们的实现非常简单,类似于:

import Prism from 'prism';

class Row extends PureComponent {

    highlight(container) {
        Prism.highlight(container);
    }

    render() {
        const {lineNumber, content} = this.props;

        return (
            <tr>
                <td className="line-number">{lineNumber}</td>
                <td ref={this.highlight} className="content">{content}</td>
            </tr>
        );
    }
}

const SourceCode = ({content}) => {
    const lines = content.split('\n');

    return (
        <table>
            <tbody>
                {lines.map((line, i) => <Row key={i} lineNumber={i + 1} content={line} />)}
            </tbody>
        </table>
    );
};

非常简单粗暴地来进行高亮,但这种方式必然带来了一些问题:

  1. 跨行的元素无法识别,参考上图的1-4行的多行注释,只有第一行被正确识别(标记为绿色字体),后续的行都无法识别出来。
  2. 直接在DOM上进行高亮,大量的DOM运算导致性能非常的差,过千行的文件总要卡住浏览器很久(当时我们美其名曰“对烂代码的惩罚”)。
  3. 必须有完整的组件生命周期才能控制高亮,上面示例代码中的ref事实上是不够的,真正完美实现要么用key 标记content唯一性,要么使用componentDidMountcomponentDidUpdate的组合来高亮。
  4. 要添加自己的元素,如代码的定义和引用、CR字符的高亮等,都需要对DOM进行操作。而在高亮后,一段文本会被变成未知个数的<span>元素,甚至还有复杂的嵌套关系,在这上面操作非常困难,也进一步导致了代码的定义和引用标记并不准确的问题。

转向虚拟数据结构

为了解决第一阶段产生的种种问题,我们在近期对代码高亮进行了一次升级。从一开始,我们就将目标定为“脱离DOM”,即基于一个虚拟的数据结构来完成高亮和各种进一步的计算。

最初,我们将目光放在了万能的语法解析工具antlr上,但随着调研的深入,我们发现这条路并不是那么好走:

  1. antlr的本体和语法定义过于庞大,在体积上很难控制。
  2. 其JavaScript运行时的实现并非稳定可靠,在Java运行时正常运行的代码,转到JavaScript中就会出现错误。
  3. 某些语言(如C++)的解析会丢到空格、换行之类的元素,虽然可以通过源代码字符串和AST中每个元素的位置重新再补回去,但实现之复杂让人觉得毫无意义。

在antlr并不能作为一个优秀的方案的情况下,我们转而在社区寻找一些替代方案。我们知道highlight.js有一个叫lowlight的包,可以兼容highlight.js的语法定义的同时,返回虚拟的数据结构而不是实实在在的DOM。顺藤摸瓜地,我们发现Prism也有一个类似的库叫refractor,简单来说,当你给它一段源码时,它会返回一个类似这样的结构:

[ { type: 'element',
    tagName: 'span',
    properties: { className: [ 'token', 'string' ] },
    children: [ { type: 'text', value: '"use strict"' } ] },
  { type: 'element',
    tagName: 'span',
    properties: { className: [ 'token', 'punctuation' ] },
    children: [ { type: 'text', value: ';' } ] } ]

在研究了它的实现和数据结构后,我们得到一些结论:

  1. type其实只有elementtext两种,其中element一定有properties.className来表示其类型,同时一定有children
  2. 每一个树的叶子节点一定是text类型的。

基于这两个结论,我们想到了一个方法,即使用深度优先的算法对树进行遍历,把每条路径上得到的语法元素类型信息(string或者punctuation等这些语法信息)放到叶子节点上,这样我们就可以将一个树压缩成一个扁平的数组,每一项都对应一段文本,上面附带了语法的类型,如:

[
    {
        syntaxTypes: ['string'],
        text: '"use strict"'
    },
    {
        syntaxTypes: ['punctuation'],
        text: ';'
    }
]

然后我们就能对每一个文本节点进行任意的操作,一切都是字符串的拆分与合并,包括但并不局限于:

  1. 遇到\n时把节点变成2个,以便于加上行号。
  2. 遇到\r或者\t等字符时,单独创建一个节点来表示这些特殊符号,用于定制化地显示。
  3. 如果文本中包含了代码定义与引用的内容,拆分并添加上相应的信息。
  4. 如果有节点是不可见的(比如只包含空格),和它的后继节点合并,减少最终DOM的数量。

我们将语法树的解析、树到数组的转换、基于数组的进一步计算等都放到了WebWorker中,在高亮的所有计算完成前,先为用户展示没有高亮的原始代码,即便代码量很大,用户也可以在高亮期间自由的浏览、操作代码。

当上述的工作都完成后,将对应的内容送回视图,React使用简单的map方法来将元素转为元素:

const Row = ({lineNumber, text, syntaxTokens}) => {
    const content = syntaxTokens
        ? syntaxTokens.map(({syntaxTypes, text}) => <span className={syntaxTypes.join(' ')}>{text}</span>)
        : text;

    return (
        <tr>
            <td className="line-number">{lineNumber}</td>
            <td className="content">{content}</td>
        </tr>
    );
};

这个基于refractor的简化模型前后的调研、开发、测试用了大约3天的时间,从最初的图片的比较可以看到,多行注释这种明显的问题得到了有效的解决,但它依然存在着一个问题:丢失了语法树的父子关系后,依赖父子关系的CSS选择器失效,导致元素无法高亮。从图中我们可以看到,11-16行的JSX语法中的标签名和属性值都失去了高亮的效果,因为在将树结构打平成数组的时候,它们失去了自己的“作用域”,并不知道自己是一段完整的JSX的一部分了。

基于树的精确计算

第二阶段虽然为我们解决了跨行的语法元素的高亮问题,却依然不够准确,这让我们意识到需要得到“完美”的高亮结果,绝对无法绕开树这个数据结构,通过丢失信息为代价将树转为数组是不成立的。

但是基于数去做我们想要的这些操作,又是非常困难的,例如我们有这样的一个树结构:

B -> text(foo\nbar)
root -> A -> C -> text(alice)
     -> D -> E -> text(bob)

我们要在它的基础上,进行按行的拆分,就要把这个树变成:

-> A1 -> B1 -> text(foo)
           -> B2 -> text(bar)
root -> A2 -> C -> text(alice)
     -> D -> E -> text(bob)

text(foo\nbar)这个节点所影响的父节点(B和A)都要进行一次复制操作,将1条尝试优先的遍历路径拆解为2条,这在树的数据结构上并不是几行代码就能实现的简单玩意,而我们而对的现实情况和要进行的操作远比一个简单的换行来得复杂。

于是为了在不丢失信息的基础上简化一下操作,我们做了一个方案:

  1. 先在树的每个节点上标记idparent属性,从这两个属性上来表达层级结构,而不依赖于children这样的属性。
  2. 把树的每一个深度优先遍历的路径算出来,变成一个路径的数组。这一步在打平了树结构的同时,因为idparent的存在,又没有丢掉父子的关系。每一个深度遍历的路径上,只会有一个节点,也肯定是最后一个节点是text类型的,其它节点都是element类型。
  3. 对每条路径进行按行拆分的逻辑,如果一个路径的text节点中有换行符,则把路径拆成2条,这个路径本身是一个父-子的顺序,所以只要路径上每个节点都复制一下,修改idparent就能创造出新的路径。
  4. 按行把路径分组,形成一个新的数组,这个数组的每一项表示一行,每一行包含一条或多条路径。这些路径进一步被合并成一个数组,这个数组中就会包含多个text节点,但因为idparent的存在,并不会影响父子关系。
  5. 随后在每一行上,进行标记特殊符合、添加代码定义和引用等一系列的工作,这些都是基于“路径”这个数据结构进行的计算,虽然有些操作较为复杂,但没有DOM等视图元素的干扰,总体上还是在可控的范围内。
  6. 通过idparent,把每个路径再重新变回一个树,每一行就会形成多个树。
  7. 再拿树去递归渲染出React的元素。

这个计算过程远比第二个阶段简单地形成text节点的数组来得复杂,但很好地保留了树的父子关系,又一定程度上简化了对树的大量遍历产生的复杂度。

最终的效果是,我们可以完美地复用Prism的100多种语法定义来准确地高亮代码,同时也可以自由地插入行号、特殊字符、代码定义与引用等等定制化的内容,WebWorker的引入也让复杂的高亮计算可以在后台线程中运行,为用户提供最佳的体验。

第三阶段的调研、实现、测试又进一步消耗了将近4天的时间,我们通过迭代式地优化,逐步地交付给用户高亮的效果优化,并最终达到了令自己满意的效果。