编辑器探索 - Monaco Editor Diff 实现分析

有求职需求的小伙伴,可以传送带直达...

一、背景介绍

Web 编辑器是开发者在编写代码时必不可少的工具。我们在工作中经常进行 code review ,这就需要借助于代码编辑器提供的 code diff 能力。本文将以 monaco editor 为例介绍编辑器进行代码 diff 的过程。

二、diff 原理

2.1 diff 是什么

code diff 通常有双栏和单栏两种展示形式,如图可见,通过对比前后代码,找出增加、删除内容,并通过颜色标识清晰地展示前后改变内容,帮助开发者对更改进行评审。

如图所示,我们把左边的文本称为源文本,右边的文本称为目标文本,图中通过前面的"+"、"-" 标识和颜色装饰来描述本行文本的变化,可以看到,这里的增加和删除的操作都是针对源文本而言的。

如左侧在源文本中删除第12、13行内容。
在右侧的目标文本中第9、10、11、 15、16行内容,是相对于源文本而言新增的内容。

那么总体而言,源文本中先删除第12、13行,再新增第9、10、11、 15、16行内容就变成了目标文本。

diff 的过程就可以描述为:通过对源文本进行增加或者删除行的操作,把源文本变成目标文本。我们把这些操作称为"编辑距离",其可以表示"插入"或者"删除"动作。

举个例子:

这里把文本内容进行抽象,A、B、C 等可以代表一行也可以代表一个字符,其对比含义是一致的。实际上 code diff 过程应该先以行再以字符为单位进行多次对比。

css 复制代码
源文本:                                      目标文本:
------------------------------------------| ------------------------------------------ 
|A                                        |C
|B                                        |B
|C                                        |A
|A                                        |B
|B                                        |A
|B                                        |C
|A                                        |
------------------------------------------ ------------------------------------------ 

如上所示源文本和目标文本,源文本有 7 行内容,目标文本有 6 行内容。

进行对比的过程,就是找出可以在源文本上增加和删除哪些行,实现把源文本转变成目标文本的目的。

首先想到一个最简单的方法:要想将源文本转换为目标文本,可以选择将源文本所有行删除,再将目标文本所有行插入,那么一共需要操作 7 + 6 = 13 次,我们则称此次文本对比过程的编辑距离为 13。很明显,这种变动方式是十分浪费的。

实际上,源文本和目标文本之间的转换方式应该有无数种,那么这无数种方式中应该有开销最小,变动最少的方式,比如可以采取以下两种变动方式:

css 复制代码
方法一:                                  方法二:
--------------------------------------    |------------------------------------------ 
-|A                                      +|C 
-|B                                      -|A 
 |C                                       |B
+|B                                      -|C
 |A                                       |A 
 |B                                       |B 
-|B                                      -|B
 |A                                       |A
+|C                                      +|C	                                   
--------------------------------------    |------------------------------------------ 

如上所示,通过三次删除,两次插入动作即可完成目标,本次编辑距离为 3 + 2 = 5(相同内容,则不需要动作转换,编辑距离不会增加)。也就是两个文本对比的最小编辑距离。

其中方法1采取了先删除再新增的方式,方法2相反。可以看到,方法1选择的变动是更加"直观"的。因为变动是在源文本的基础上进行的,删除原来就存在的内容和增加一个新的内容相比,优先展示原内容更加合理。

那么经过上面的一个例子,我们可以看到,对于文本对比的过程,实际上就是把源文本转换为目标文本的操作过程,我们要做的,就是找出两个文本的最小编辑距离和对应的具体动作序列。

2.2 diff 的实现方法

那么要如何找到源文本和目标文本的最小编辑距离及其对应动作呢,接下来需要将实际问题抽象为具体的数学模型进行解决。

2.2.1 图搜索

同样使用上面的例子,假设源文本内容为 ABCABBA,目标文本为 CBABAC,我们可以构造下面的一个图,满足以下规则:

  • 横轴为源文本内容:对应 ABCABBA
  • 纵轴为目标文本内容:对应 CBABAC
  • 源文本和目标文本相同可增加对应对角线标记,如图中所示

我们的目标是由源文本转换为目标文本,在这个图上就相当于从坐标(0,0)找到一条通往坐标(7,6)的路径。行进过程中有如下规定:

  • 向右移动表示删除源文本中的一行(编辑距离加一)

  • 向下移动表示在源文本中增加一行(编辑距离加一)

  • 对角线移动表示源文本本行内容不变(编辑距离不变)

  • 行进路径只能落在横纵线或者存在的对角线上

为了便于理解,使用刚才的例子,如果采取最简单的方法,先将源文本全部删除,再将目标文本全部插入,那么在这个图中对应的行进路线就如下图示:

对应操作为:

diff 复制代码
-A
-B
-C
-A
-B
-B
-A
+C
+B
+A
+B
+A
+C

这样就将具体的动作序列与图进行了结合映射。

再选取一条路径:(0, 0) -> (1, 0) -> (2, 0) -> (3, 1) -> (3, 2) -> (4, 3) -> (5, 4) -> (6, 4) -> (7, 5) -> (7, 6)

这条路径对应的文本处理方式即为:

css 复制代码
-A
-B
 C
+B
 A
 B
-B
 A
+C

如上两个例子,整体的移动过程就是编辑距离和对应的动作序列的实现。为了保证最终选取的动作序列是最短且直观的,我们应该遵循以下三个原则:

  • 路径长度最短(对角线长度为0,尽量保证对角线路径最多)

  • 先向右,再向下(先删除,后新增,保证更加直观)

  • 不能走回头路

2.2.2 Myers差分算法

为了找到最短且最直观的路径,介绍一下经典的 Myers 差分算法,其采取了动态规划的思想实现最优路径寻找。

首先进行概念定义:

  • d: d 代表路径的长度(长度的累加过程如上文所述:向左向右移动+1,对角线移动不变)

  • k: k 代表当前坐标 x - y 的值

  • 最优坐标:当 d 和 k 值固定的情况下,x 值最大的坐标(x 越大,向右移动越多,表示更加直观)

然后针对上文举的例子进行模拟。

最开始在坐标(0,0)处,此时 d = 0,k = 0。

当 d = 1 时,如图所示红色标记,可以走到(1,0)或(0,1)两处。对应的 k 为 -1 或 1。

  • 当 k = 1 时,最优坐标为 (1,0)
  • 当 k = -1 时,最优坐标为 (0,1)

当 d = 2 时,根据图示中蓝色标记,从(1,0)和(0,1)两个起点,共有(1,0) -> (2,0) -> (3,1)、(1,0) -> (1,1) -> (2,2)、

(0,1) -> (1,1) -> (2,2)、 (0,1) -> (0,2) -> (1,3) -> (2,4) 四条路径。

  • 当 k = 2 时,最优坐标为 (3,1)
  • 当 k = 0 时,最优坐标为 (2,2)
  • 当 k = -2 时,最优坐标为 (2,4)

依次类推,随着 d 和 k 的增长,当最优坐标为(7,6)时,即表示已经到达了终点。根据上述过程中确定的 d 和 k 值,可画出下图。

其横坐标为 d,纵坐标为 k,中间区域为每一个 d 和 k 对应的最优坐标。

可知当 d = 5 时,可以到达终点,再反溯回(0,0)起点,则可以确认,找到的最优路径即为 (0, 0) -> (1, 0) -> (2, 0) -> (3, 1) -> (3, 2) -> (4, 3) -> (5, 4) -> (6, 4) -> (7, 5) -> (7, 6)。(标注灰色为对角线情况)

可以看到, Myers 算法是一个典型的"动态规划"算法,想要找到 d = 5 时的最优坐标,就需要找到 d = 4时的最优坐标,通过将父问题拆解为独立的子问题进行求解。最终找到一条最短最直观的路径。

2.3 总结

上面介绍了 diff 的算法原理,这里仅以比较容易理解的方式进行了原理的模拟,实际算法实现还需要考虑更多内容,暂不进行过多的深入探讨。

三、monaco editor 中的 diff 实现

在 monaco editor 中,针对文本对比使用的就是 Myers 差分算法。但是除了核心的对比算法之外,前后还有很多流程。下面将对编辑器中完整的 code diff 流程的实现进行介绍。

3.1 diff 的触发过程

对于 diff 的触发,monaco editor 采用了全量监听的方式。onDidChangeModelContent 方法在编辑器内容发生变动时触发,触发之后使用防抖处理避免频繁更新影响性能,当前为200ms。

kotlin 复制代码
// 监听变动,触发 diff
this._register(editor.onDidChangeModelContent(() => {
  if (this._isVisible) {
  this._beginUpdateDecorationsSoon();
  }
}));
// 防抖处理
_beginUpdateDecorationsSoon() {
    // Clear previous timeout if necessary
    if (this._beginUpdateDecorationsTimeout !== -1) {
        window.clearTimeout(this._beginUpdateDecorationsTimeout);
        this._beginUpdateDecorationsTimeout = -1;
    }
    this._beginUpdateDecorationsTimeout = window.setTimeout(() => this._beginUpdateDecorations(), DiffEditorWidget.UPDATE_DIFF_DECORATIONS_DELAY);
}

monaco editor 传递参数过程中不直接传递全量代码,如果直接传递全量代码内容,每一次函数传参都是值传递字符串,都要为其开辟一块新的内存存储,如果代码内容很长,会造成性能浪费。因此使用 model 的唯一 uri(当前文本对象标识) 传递,提高效率。

model 表示编辑器当前的文本模型,可以通过唯一 uri 获取文本相关信息。

kotlin 复制代码
//传递 uri
this._editorWorkerService.computeDiff(currentOriginalModel.uri, currentModifiedModel.uri, this._options.ignoreTrimWhitespace, this._options.maxComputationTime)


// 获取内容
 const original = this._getModel(originalUrl);
 const modified = this._getModel(modifiedUrl);

3.2 diff 过程

1、行 diff

diff 过程触发之后,首先需要进行行级别的对比,以正确展示变动内容。monaco editor 为了提高 diff 效率,会将行内容进行哈希编码后存储在 Arraybuffer 中, 这样进行实际对比就是一串长度固定的二进制内容。

哈希编码是一种将任意长度的输入数据映射为一定长度输出数据的方法。monaco editor 中通过简单的位运算和加法运算对输入值进行混合和变换,输入值的微小变化会导致输出值的显著变化,这有助于确保不同的输入会产生不同的哈希值。

scss 复制代码
// 哈希编码过程
export function numberHash(val, initialHashVal) {
    return (((initialHashVal << 5) - initialHashVal) + val) | 0;
}

export function stringHash(s, hashVal) {
    hashVal = numberHash(149417, hashVal);
    for (let i = 0, length = s.length; i < length; i++) {
        hashVal = numberHash(s.charCodeAt(i), hashVal);
    }
    return hashVal;
}

哈希结果: 

asdadda
// 905905545
123123
// -1822154839
23333
// -57825815

ArrayBuffer 对象表示一段连续的内存区域,可以存储各种类型的二进制数据,例如整数、浮点数、字节等。与普通的 JavaScript 数组不同,ArrayBuffer 的长度是固定的,一旦创建,就无法改变。
在 JavaScript 中,ArrayBuffer 是一种用于表示通用的、固定长度的原始二进制数据缓冲区的对象。它提供了一种在处理二进制数据时更高效和灵活的方式。
Int32Array 表示定型数组,可以处理 32 位有符号整数数据,是一种对内存缓冲区中的原生二进制数据有着读写机制(能力)的一种类数组对象。也就是可以对创建的ArrayBuffer 对象进行读取。

javascript 复制代码
static _getElements(sequence) {
    const elements = sequence.getElements();
    if (LcsDiff._isStringArray(elements)) {
      const hashes = new Int32Array(elements.length);
      for (let i = 0, len = elements.length; i < len; i++) {
        // 对每一行数据进行 hash 编码
        hashes[i] = stringHash(elements[i], 0);
      }
      return [elements, hashes, true];
    }
    if (elements instanceof Int32Array) {
      return [[], elements, false];
    }
    return [[], new Int32Array(elements), false];
}

转换完成后,为了尽量减少对比范围,找出前后文本开始出现不同的开始和结束行,对于完整文件只修改了中间一小部分内容的情况,可以大大降低对比成本。

kotlin 复制代码
// Find the start of the differences
while (originalStart <= originalEnd && modifiedStart <= modifiedEnd && this.ElementsAreEqual(originalStart, modifiedStart)) {
    originalStart++;
    modifiedStart++;
}
// Find the end of the differences
while (originalEnd >= originalStart && modifiedEnd >= modifiedStart && this.ElementsAreEqual(originalEnd, modifiedEnd)) {
    originalEnd--;
    modifiedEnd--;
}

再根据上节内容中的对比原理进行源文本与目标文本的对比。并对 diff 数据进行整理,便于后续装饰时定位使用。

  • 如果有多个插入或删除内容相邻,则将其合并为一个区域,标识该区域的开始位置和对应长度。

  • 源文本和目标文本的增删成对出现:一个 change 对象中包含源文本和目标文本中的 diff 详情。

如下图例子中所示:其中得到的 diff 结果对象中包含两个行差异结果,对应两个 diff 装饰区域(把相邻的插入或删除行当作一个区域):

  • originalLength/modifiedLength 对应一个 diff 区域包含多少行。
  • originalStart/modifiedStart 对应 diff 区域开始的行(从 0 开始)。

2、区域内 diff

行 diff 完成之后,再在每个对比区域内进行字符级别的对比。

首先同样需要对字符内容进行转换,将字符转换为对应的 Unicode 编码,转换完成后再存储在 Arraybuffer 中,之后和行级对比采用相同的策略完成一个区域内的字符 diff 过程。

3、得到最终 diff 结果

最后再对行 diff 结果和区域内 diff 结果进行拼装,得到最终的 changes 内容。如下图所示:

  • 包含其行对比结果:标识本行 diff 区域源文本和目标文本的开始和结束行。

  • 包含其字符对比结果:标识本行 diff 区域每个字符 diff 区域内的源文本和目标文本的开始和结束位置。

得到最终结果如下图所示:

3.3 diff 的渲染过程

1、获取装饰块信息

通过上述计算过程得到的 diff 结果需要转换为装饰块相关信息才能正确的渲染出来,这里的装饰块可以理解为一个 div,代表一个覆盖在编辑器中文本上面的彩色块。monaco editor 中针对 diff 情况的绘制采取了 dom 叠加的方式,本身文本内容区域不动,在其上面覆盖透明装饰层形成 diff 效果。

如下图所示:在目标编辑器中,有三个装饰块,分别是两个行装饰和一个行内装饰覆盖在文本上。比如第一个装饰块,对应的文本结构详情:

  • options:代表本装饰块的一些样式信息,如class, z-index 等内容。
  • range: 代表本装饰块的范围,这里endColumn 为一个很大的数字,可以理解为向后占满全行。

2、绘制效果

得到上述装饰块信息后,编辑器进行装饰块内容的绘制,结果如下图所示:

(1)gutter-insert:覆盖在行号上面的样式(与文本上的样式是两个分开的div)。

(2)insert-sign:插入或删除标记(在目标文本中均是插入标记)。

(3)line-insert:覆盖在行上面的样式,前与行号覆盖相连,后至行尾。

(4)char-insert:覆盖在字符上的样式,只覆盖到具体的字符。

最终在编辑器上呈现出完整的 diff 效果。

四、总结

本文首先介绍了文本对比相关的内容,将文本对比过程抽象为图搜索的过程,并且可以采用 Myers 差分算法找到最短最直观的操作路径将源文本转换为目标文本。然后介绍了 monao editor 中 diff 的触发、计算和渲染过程。在这个过程中,编辑器为了提高 diff 效率,采取了一定的优化措施,如哈希转换、最小化 diff 区域等。

常见的其他编辑器如 CodeMirror,在进行文本对比时需要引入单独的 diff 包: codemirror/merge 进行diff 内容计算,其基本原理仍然是使用 Myers 差分算法,具体实现过程可能有所不同。

diff 用来比较前后文本差异,如果业务中有相关需求,也可以脱离编辑器使用 google 提供的 diff-match-patch 包,实现文本 diff 能力,其已经支持多种语言使用。

五、参考资料

  1. chenshinan.github.io/2019/05/02/...

  2. blog.jcoglan.com/2017/02/12/...

  3. zh.javascript.info/arraybuffer...

  4. blog.csdn.net/Coo123_/art...

六、编辑器相关分享往期回顾

团队招聘

我们是快手数平前端团队,主要负责快手大数据平台中从采集,加工,消费整条链路相关产品的前端建设工作,涉及到生产、分析、流量、AB、专题等多个领域,目标是建设更自助,更快速,更先进的数据产品。

来到这里你能接触到:

  1. 快手数据平台是如何通过先进技术支持起 EB 级别数据量的;
  2. 日均百亿级别的日志上报的埋点基础设施的是如何设计与迭代的;
  3. 超过百万代码量的独立项目带来的工程化挑战;
  4. 2D/3D可视化、在线编辑器、电子表格等细分领域的探索;
  5. 自研工具产品来提升团队效率,用数据来说话;
  6. ......

我们希望能你:

  1. 有坚实的计算机/前端基础,不只是 MVVM 工程师;
  2. 有一定的 B 端项目/可视化相关的经验更佳,或非常热爱;
  3. 能发现问题,分析问题,并解决问题;
  4. 敢于创新,善于沟通,时刻关注前沿技术;
  5. 【加分项】有一定产品/交互/设计 sense;
  6. 【加分项】开源项目/社区;

团队氛围:

  1. 来这里你能接触到大数据中各个领域的专家,大家都非常 nice,没有架子,随时坦诚沟通,互相学习;
  2. 技术很重要,业务更重要,定期的培训分享都少不了;
  3. 团队氛围融洽,就事论事,风清气正,拒绝勾心斗角;
  4. 团队年轻有活力,既要工作好,也要玩的好; 加入我们,一起做点好玩的!

投递邮箱

感兴趣的同学,欢迎投寄简历: 609413629@qq.com

相关推荐
Qrun11 分钟前
Windows11安装nvm管理node多版本
前端·vscode·react.js·ajax·npm·html5
中国lanwp12 分钟前
全局 npm config 与多环境配置
前端·npm·node.js
JELEE.1 小时前
Django登录注册完整代码(图片、邮箱验证、加密)
前端·javascript·后端·python·django·bootstrap·jquery
TeleostNaCl3 小时前
解决 Chrome 无法访问网页但无痕模式下可以访问该网页 的问题
前端·网络·chrome·windows·经验分享
前端大卫5 小时前
为什么 React 中的 key 不能用索引?
前端
你的人类朋友5 小时前
【Node】手动归还主线程控制权:解决 Node.js 阻塞的一个思路
前端·后端·node.js
小李小李不讲道理7 小时前
「Ant Design 组件库探索」五:Tabs组件
前端·react.js·ant design
毕设十刻7 小时前
基于Vue的学分预警系统98k51(程序 + 源码 + 数据库 + 调试部署 + 开发环境配置),配套论文文档字数达万字以上,文末可获取,系统界面展示置于文末
前端·数据库·vue.js
mapbar_front7 小时前
在职场生存中如何做个不好惹的人
前端
牧杉-惊蛰8 小时前
纯flex布局来写瀑布流
前端·javascript·css