在线代码编辑器介绍与选型

avatar
前端开发 @快手

引言

作为数据生产和管理的平台,数据平台的一大核心功能是在线数据开发,工欲善其事必先利其器,所以平台具备一个功能较为丰富、用户体验友好的在线代码编辑器,就成为了前提条件。

经历最近一两年的代码编辑器方案调研、选型和开发,我们对内部平台使用的代码编辑器进行了统一和升级,并根据用户需求和业务场景进行了插件化定制,其底层是使用了 Monaco Editor 来进行二次开发。

本文主要是结合自己的理解,对代码编辑器相关知识进行整理,跟大家分享。

1. 在线代码编辑器是什么?

1.1 介绍

在线代码编辑器是一种基于 Web 技术开发的代码文本编辑器,可以在 Web 浏览器中直接使用。它通常包括用户界面模块、文本处理模块、插件扩展模块等模块;用户可以通过 Web 编辑器创建、编辑各种类型的文本文件,例如 HTML、CSS、JavaScript、Markdown 等。

1.2 分类

我们先来看看编辑器的分类:

类型描述典型产品优势劣势
远古编辑器textarea 或contentEditable+execCommand早期轻型编辑器(《100行代码带你实现一个编辑器》系列)门槛低,短时间内快速研发无法定制
contentEditable+文档模型借助contentEditable,各种拦截用户操作draftjs (react)、quilljs (vue)、prosemirror(util)站在浏览器的肩膀上,可以实现绝大多数的业内需求无法突破浏览器本身的限制(排版)
独立开发脱离浏览器自带编辑能力,独立做光标和排版引擎Google Docs、WPS等所有内容都把握在自己手上,排版随意个性化技术难度较高,研发成本较大

第一类编辑器,其劣势明显:由于重度依赖浏览器 execCommand 接口,而该接口支持的能力非常有限,故大多数功能无法订制,比如 fontSize 只能设置 1 - 7。另外兼容性也是一大问题,例如 Safari 并没有支持 heading 的设置。参考 MDN。而且该类编辑器基本都会直接将 HTML 作为数据模型(Model)来使用,这样会引发另外一个问题:相同的UI,可能对应了不同的DOM结构。举个例子,对于“加粗字体”这个用户输入,在 chrome 上,是添加了<blod>标签,ie11上则是添加了<strong>标签。

第二类编辑器与上一类编辑器最大的不同是定义了自己的 Model 层,所有视图(View)都与 Model 一一对应,并且一切 View 的变化都将由 Model 层的变化引发。为了做到这一点,需要拦截一切用户操作,准确识别用户意图,再对 Model 层进行正确的修改。坑点主要来自于对用户操作的拦截以及浏览器实现层面上的一些疑难杂症。故该类编辑器实现中的 hack 代码会非常多,理解起来比较困难。

第三类编辑器,采用隐藏textarea方案,它只负责接收输入事件,其他视图输出全靠自己,相对来说,更容易解耦。因为基本脱离了浏览器原生的光标,这块可以实现出更强大的功能。排版引擎可以自己搞,只要码力够强,想搞一个从从上往下从右往左的富文本编辑器也没问题,也带来了各种各样的可能,比如可以通过将 View 层用 canvas 实现,以规避很多兼容性问题。

2. 一款优秀的在线代码编辑器需要有哪些功能?

下面我们来看一下一个可用于生产环境的在线代码编辑器需要有哪些能力和模块:

2.1 核心模块

模块名模块描述
文本编辑用于处理用户输入的文本内容,管理文本状态,还包括实现文本的插入、删除、替换、撤销、重做等操作
语言实现语言高亮、代码分析、代码补全、代码提示&校验等能力
主题主要用于实现主题的管理、注册、切换、等功能
渲染主要完成编辑器的整体设计与生命周期管理
命令 & 快捷键管理注册和编辑的各种命令,比如查找文件、撤销、复制&粘贴等,同时也支持将命令以快捷键的形式暴露给用户
通信 & 数据流管理编辑器各模块之前的通信,以及数据存储、流转过程

2.2 扩展模块

模块名模块描述
文本能力扩展在现有处理文本的基础上进行功能扩展,比如修改获取文本方式。
语言扩展包括自定义新语言,扩展现有语言的关键字,完善代码解析、提示&校验等能力。
主题扩展包括自定义新主题,扩展现有主题的能力
命令扩展增加新命令,或者改写&扩展现有命令

3. 开源市场上有哪些代码编辑器?

目前开源市场使用较多的代码编辑器主要有 3 个,分别是 Monaco Editor(第三类)、Ace(第三类)和 Code Mirror(第二类)。本文也将带大家去了解他们的整体架构,做一些对比分析。

3.1 Monaco Editor

基本介绍:

类别描述
介绍是一个功能相对比较完整的代码编辑器,实现使用了 MVP 架构,采用了模块化和组件化的思想,其中编辑器核心代码部分是与 vscode 共用的,从源码目录中能看到有很多 browser 与 common 的目录区分。
仓库地址github.com/microsoft/v…
入口文件/editor/editor.main.ts
开始使用editor.create()方法来自 /editor/standalone/browser/standaloneEditor.ts

目录结构:

├── base        			# 通用工具/协议和UI库
│   ├── browser 			# 基础UI组件,DOM操作,事件
│   ├── common  			# diff计算、处理,markdown解析器,worker协议,各种工具函数
├── editor        		        # 代码编辑器核心
|   ├── browser     		# 在浏览器环境下的实现,包括了用于处理 DOM 事件、测量文本尺寸和位置、渲染文本等功能的代码。
|   ├── common      		# 浏览器和 Node.js 环境下共用的代码,其中包括了文本模型、文本编辑操作、语法分析等功能的实现
|   ├── contrib     		# 扩展模块,包含很多额外功能 查找&替换,代码片段,多光标编辑等等
|   └── standalone  		# 实现了一个完整的编辑器界面,也是我们通常使用的完整编辑器
├── language        	        # 前端需要的几种语言类型,与basic-languages不同的是,这里的实现语言功能更完整,包含关键字提示与语法校验等
├── basic-languages                # 基础语言声明,里面只包含了关键字的罗列,主要用于关键字的高亮,不包含提示和语法校验

特点:

  • 多线程处理,主要分为 主线程 和 语言服务线程(使用了 Web Worker 技术 来模拟多线程,主要通过 postMessage 来进行消息传递)
    • 主线程:主要负责处理用户与编辑器的交互操作,以及渲染编辑器的 UI 界面,还负责管理编辑器的生命周期和资源,例如创建和销毁编辑器实例、加载和卸载语言服务、加载和卸载扩展等。
    • 语言服务线程:负责提供代码分析、语法检查等功能,以及处理与特定语言相关的操作。

DOM 结构:

<div class="monaco-editor" role="presentation">
  <div class="overflow-guard" role="presentation">
    <div class="monaco-scrollable-element editor-scrollable" role="presentation">
      <!--实现行高亮-->
      <div class="monaco-editor-background" role="presentation"></div>
      <!--实现关键字背景高亮-->
      <div class="view-overlays" role="presentation">
      	<div>...</div>
      </div>
      <!--每一行内容-->
      <div class="view-lines" role="presentation">
        <div>...</div>
      </div>
      <!--光标-->
      <div class="monaco-cursor-layer" role="presentation"></div>
      <!--文本输入框-->
      <textarea class="monaco-editor-textarea"></textarea>
      <!--横向滚动条-->
      <div class="scrollbar horizontal"></div>
      <!--纵向滚动条-->
      <div class="scrollbar vertical"></div>
    </div>
  </div>
</div>

3.2 Code Mirror

基本介绍:

类别描述
介绍CodeMirror 6 是一款浏览器端代码编辑器,基于 TypeScript,该版本进行了完全的重写,核心思想是模块化和函数式,支持超过 14 种语言的语法高亮,亮点是高性能、可扩展性高以及支持移动端。
仓库地址github.com/codemirror
入口文件由于高度模块化,没有一个集成的入口文件,这里放上核心库@codemirror/view的入口文件:src/index.ts

开始使用

import { EditorState } from '@codemirror/state'; import { EditorView, keymap } from '@codemirror/view';
import { defaultKeymap } from '@codemirror/commands';
let startState = EditorState.create({
    doc: 'console.log("hello, javascript!")',
    extensions: [keymap.of(defaultKeymap)],
});
let view = new EditorView({
    state: startState,
    parent: document.body,
});

目录结构:

高度模块化(分为多个仓库),这里放上比较核心的库的分布和内部结构

核心模块:提供了编辑器视图(@codemirror/view)、编辑器状态(@codemirror/state)、基础命令(@codemirror/commands)等基础功能。

语言模块:提供了不同编程语言的语法高亮、自动补全、缩进等功能,例如@codemirror/lang-javascript@codemirror/lang-sql@codemirror/lang-python 等。

主题模块:提供了不同风格的编辑器主题,例如 @codemirror/theme-one-dark

扩展模块:提供了一些额外的编辑器功能,例如行号(@codemirror/gutter)、折叠(@codemirror/fold)、括号匹配(@codemirror/matchbrackets)等。

内部结构,以@codemirror/view为例:

├── src                         # 源文件夹
│   ├── editorview.ts           # 编辑器视图层
│   ├── decoration.ts           # 视图装饰
│   ├── cursor.ts               # 光标的渲染
│   ├── domchange.ts            # DOM 改变相关的逻辑
│   ├── domobserver.ts          # 监听 DOM 的逻辑
│   ├── draw-selection.ts       # 绘制选区
│   ├── placeholder.ts          # placeholder的渲染
│   ├── ...
├── test                        # 测试用例
|   ├── webtest-domchange.ts    # 测试监听到 DOM 变化后的一系列处理。
|   ├── ...

特点:

指导 CodeMirror 架构设计的核心观点是函数式代码(纯函数),它会创建一个没有副作用的新值,和命令式代码交互更方便。而浏览器 DOM 很明显也是命令式思维,和 CodeMirror 集成的大部分系统类似。

CodeMirror 6 的 state 表现层是严格函数式的 - 即 document 和 state 数据结构都是不可变的,而能操作它们的都是纯函数,view 包将它们封装在一个命令式接口中。

所以即使 editor 已经转到了新的 state,而旧的 state 依然原封不动的存在,保存旧状态和新状态在面对处理 state 改变的情况下极为有利,这也意味着直接改变一个 state 值,或者添加额外 state 属性的命令式扩展都是不建议的,后果也不太可控。

CodeMirror 处理状态更新的方式受 Redux 启发,除了极少数情况(如组合和拖拽处理),视图的状态完全是由 EditorState 里的 state 属性决定的。

通过创建一个描述改变document、selection 或其他 state 属性的 transaction,以这种函数调用方式来更新 state。这个 transaction 之后可以通过 dispatched 分发,告诉 view 更新 state,更新新 state 对应的 DOM 展示。

let transaction = view.state.update({ changes: { from: 0, insert: "0" }})
console.log(transaction.state.doc.toString()) // "0123"
// 此刻视图依然显示的旧状态
view.dispatch(transaction)
// 现在显示新状态了

典型的用户交互数据流如下图:

view 监听事件变化。当 DOM 事件发生时(或者快捷键触发的命令,或者由扩展注册的事件处理器),CodeMirror会把这些事件转换为新的状态 transcation,然后分发。此时生成一个新的 state,当接收到新 state 后就会去更新 DOM。

DOM 结构:

<div class="cm-editor [theme scope classes]">
  <div class="cm-scroller">
    <div class="cm-content" contenteditable="true">
      <div class="cm-line">Content goes here</div>
      <div class="cm-line">...</div>
    </div>
  </div>
</div>

cm-editor 为一个 editor view 实例(在 merge-view,也就是代码对比情况下,给做了一个合并,其实还是两个 editor view 合在一起)

cm-scroller 为编辑器主展示区,并且展示了滚动条

cm-tooltip-autocomplete 为展示一些独立的层,比如代码提示,代码补全等

cm-gutter 是行号

cm-content 是编辑器的内容区

cm-layer 是跟 content 平级的,主要负责自定义指针和选区的展示

view-port 为CodeMirror 的一个优化,只解析和渲染了这个可视区域内的 DOM

cm-line 是每一行的内容,里面就是真实的 DOM 了

line-decorator 是提供给插件使用,用来装饰每一行的

在这个架构下,每个 editor 比较独立,可以渲染多个

3.3 Ace

基本介绍:

类别描述
介绍基于 Web 技术的代码编辑器,可以在浏览器中运行,高性能,体积小,功能全是它的主要优点。支持了超过120种语言的语法高亮,超过20个不同风格的主题,与 Sublime,Vim 和 TextMate 等本地编辑器的功能和性能相匹配。
仓库地址github.com/ajaxorg/Ace
入口文件/src/Ace.js
开始使用Ace.edit()

目录结构:

Ace 的目录结构相对简单,按功能分成了一个个不同的 js 文件,我这里列举其中一部分,部分较为复杂的功能除了提供了入口 js 文件以外,还在对应同级建立了文件夹里面实现各种逻辑,这里列举了 layer (渲染层) 为例子。

src/
├── layer				    #渲染分层实现
	   ├── cursor.js		    #鼠标滑入层
      ├── decorators.js		    #装饰层,例如波浪线
      ├── lines.js			    #行渲染层
      ├── text.js    		    #文本内容层
      ├── ...		
├── ...				    #其他功能,例如 keybord
├── Ace.js                             #入口文件
├── ...
├── autocomplete.js		    #定义了编辑器补全相关内容
├── clipboard.js			    #定义了pc移动端兼容的剪切板
├── config.js
├── document.js
├── edit_session.js		    #定义了 Session 对象
├── editor.js													 #定义了 editor 对象
├── editor_keybinding.js		    #键盘事件绑定
├── editor_mouse_handler.js
├── virtual_renderer.js                #定义了渲染对象 Renderer,引用了 layer 中定义的个种类
├── ...
├── mode.js
├── search.js
├── selection.js
├── split.js
└── theme.js

特点:

  • 事件驱动
    • Ace 中提供了丰富的事件系统,以供使用者直接使用或者自定义,并且通过对事件的触发和响应来进行内部数据通信实现代码检查,数据更新等等
  • 多线程
    • Ace 编辑器将解析代码的任务交给 Web Worker 处理,以提高代码解析的速度并避免阻塞用户界面。在 Web Worke r中,Ace 使用 Acorn库来解析 JavaScript 代码,并将解析结果发送回主线程进行处理

DOM 结构:

<div class="ace-editor">

  <textarea 
      class="ace_text-input" 
      wrap="off" 
      autocorrect="off"     
      autocapitalize="off" 
      spellcheck="false" 
    >
  </textarea>
  <!-- 行号区域 -->
  <div class="ace_gutter" aria-hidden="true">
    <div 
        class="ace_layer ace_gutter-layer"
    >
      <div class="ace_gutter-cell" >1 <span></span></div>
    </div>
  </div>
  <!-- 内容区域 -->
  <div class="ace_scroller" >
    <div class="ace_content">
      <div class="ace_layer ace_print-margin-layer">
        <div class="ace_print-margin" style="left: 580px; visibility: visible;"></div>
      </div>
      <div class="ace_layer ace_marker-layer">
        <div class="ace_active-line"></div>
      </div>
      <div class="ace_layer ace_text-layer" >
        <div class="ace_line" >
          <span class="ace_keyword">select</span> 
          <span class="ace_keyword">from</span>
          <span class="ace_string">'xxx'</span>
        </div>
        <div class="ace_line"></div>
      </div>
      <div class="ace_layer ace_marker-layer"></div>
      <div class="ace_layer ace_cursor-layer ace_hidden-cursors">
        <!-- 光标 -->
        <div class="ace_cursor"></div>
      </div>
    </div>
  </div>
  <!-- 纵向滚动条 -->
  <div class="ace_scrollbar ace_scrollbar-v">
    <div class="ace_scrollbar-inner" >&nbsp;</div>
  </div>
  <!-- 横行滚动条 -->
  <div class="ace_scrollbar ace_scrollbar-h">
    <div class="ace_scrollbar-inner">&nbsp;</div>
  </div>

</div>

4. 整体对比

4.1 功能完整度

类别Monaco EditorCode MirrorAce
代码主题内置 3 种,可扩展基于扩展来支持,现有官方 1 种内置 20+,可扩展
语言内置 70+, 可扩展基于扩展来支持,现有官方 16 种内置 110+,可扩展
代码提示/自动补全只支持 4 种语言,官方提供了自动补全的基础插件,可自行实现基于扩展来支持,官方提供了自动补全的基础插件只支持 4 种语言,官方提供了自动补全的基础插件,可自行实现
代码折叠
快捷键
多光标编辑
代码检查只支持 4 种语言,官方提供了自动补全的基础插件,可自行实现基于扩展来支持,官方提供了代码检查的基础插件只支持 4 种语言,官方提供了自动补全的基础插件,可自行实现
代码对比❌,需自己扩展
MiniMap❌,需自己扩展❌,需自己扩展
多文本管理❌,需自己扩展
多视图❌,需自己扩展
协同编辑可引入额外插件支持 github.com/convergence…架构支持
移动端支持

4.2 性能体验

类别Monaco EditorCode MirrorAce
核心包大小800KB 左右核心包 115 KB 左右(未压缩)200KB 左右(不同版本有轻微出入)
编辑器渲染 (无代码)400ms 左右仅核心包情况下,120ms 左右185 ms 左右(实际使用包)

5. 结论与展望

一年前我们因为Monaco Editor丰富的生态、迅猛的迭代速度、开箱即用的特性和 VSCode 同款编辑器背书等原因选择了基于它来进行二次开发和插件化定制(后续文章会对这些定制开发做分享)。但由于编辑器的使用场景日渐多样化,个性化,以及移动端的占比日渐增加,我们对 Monaco Editor 的底层支持也越来越感觉到不足和乏力。对于这些点,我们的计划是先使用CodeMirror 6来支持移动端的代码编辑,然后逐步实现代码编辑器的自研。