Monaco Editor中的代码高亮实现分析

一、背景介绍

Web编辑器是开发者在编写代码时必不可少的工具,而Monaco Editor作为一款功能强大的Web编辑器,被广泛应用于各种开发环境中。本文将探讨Monaco Editor中的代码高亮实现,帮助读者更好地理解这些关键功能的实现原理。

1.1 Monaco-Editor简介

Monaco Editor是由Microsoft开发的一款基于Web技术的代码编辑器,它最初是为Visual Studio Code(简称VS Code)所开发,后来被独立出来作为一个独立的项目。Monaco Editor具有高度可定制性和扩展性,支持多种编程语言,并提供了丰富的编辑功能。

Monaco-Editor展示图,它是vscode编辑器的核心部分

1.2 代码高亮

代码高亮是编辑器中的一项重要功能,它能够根据语法规则将代码中的关键字、注释、字符串等进行不同的样式渲染,以提升代码的可读性。代码高亮的实现被称为Tokenlize,也叫做词法分析。

代码高亮在VsCode中的展示

那么Monaco-Editor如何实现代码高亮的?

二、Monarch(/ˈmɒn.ək/)

Monaco-Editor使用一种称为"Monarch"的语法定义格式来实现Tokenlize。Monarch是一种基于正则表达式的语法定义语言,用于描述代码的语法结构和标记规则。用官网的话来说,就是一个声明式的JSON语法结构,可以清晰和有效地进行字符串解析。

以下是Monarch官网的描述:
Monarch 允许您使用声明性词汇规范(写为 JSON 值)来指定有效的语法突出显示。该规范具有足够的表现力,可以指定具有复杂状态转换、动态大括号匹配、自动完成、其他语言嵌入等的复杂功能。

2.1 Monarch-示例

接下来介绍一个简单的Monarch示例:

以下代码定义了一个名为myLanguage的语言,其中包含了三种标记规则:namespace、identifier和number。正则表达式用于匹配对应的文本,而标记则指定了匹配到的文本应该被标记为哪种类型。

javascript 复制代码
{
  id: 'myLanguage',
  tokenizer: {
    root: [
      // 匹配由小写字母组成的字符串,形如 "abc.xyz"
      [/[a-z]+.[a-z]+/, 'namespace'],
      // 纯小写字母
      [/[a-z]+/, 'identifier'],
      // 数字
      [/\d+/, 'number']
    ]
  }
}

2.2 Monarch-核心概念简介

一个完整的Monarch文件并不止包含了代码高亮的功能,还有代码折叠,自动补充等,其中有一些核心概念用于定义语言的词法和语法规则。为了便于之后的理解,接下来我们将分析Monarch中的一个简单示例,来了解其比较重要的核心概念。

Monaco-Editor中的TypeScript语言定义示例:

js 复制代码
export const conf = {
  /**
   * @type 词法单元
   * 是代码中的最小语法单元,例如关键字、标识符、字符串、注释等。
   * 在Monarch中,可以为每个词法单元定义一个唯一的标识符,称为"token"。
   * 通过定义不同的"token",可以在代码高亮、代码折叠等功能中对不同的语法单元应用不同的样式。
   */
  wordPattern:
    /(-?\d*.\d\w*)|([^`~!@#%^&*()-=+[{]}\|;:'",.<>/?\s]+)/g,

  /**
   * @type root state
   * 代码的初始状态或根状态,通常会从这里开始分析文本。
   */
  root: [[/[{}]/, "delimiter.bracket"], { include: "common" }],
  /**
   * @type next state 和state
   * next state
   * 用于将状态切换到另一个状态,但不会将其推送到状态堆栈中。
   * state:
   * 状态用于定义词法分析器在不同情况下的行为。
   * 在Monarch中,可以定义多个状态,并为每个状态指定一组规则。词法分析器根据当前的状态和输入的字符来确定下一个状态,并执行相应的动作。
   */
  string_backtick: [
    [/${/, { token: "delimiter.bracket", next: "@bracketCounting" }],
    [/[^\`$]+/, "string"],
  ],
  /**
   * @type rule
   * 规则用于描述如何识别和处理代码中的词法单元。每个规则由一个正则表达式和一个动作(action)组成。
   * 正则表达式用于匹配代码中的字符序列,而动作则定义了在匹配到该规则时执行的操作,例如应用样式、触发回调等。
   * 例如下面的onEnterRule,在匹配了一个多行注释的开头后,在换行的时候执行自动插入*和缩进
   */
  onEnterRules: [
    {
      beforeText: /^\s*/**(?!/)([^*]|*(?!/))*$/,
      afterText: /^\s**/$/,
      action: {
        indentAction:
          monaco_editor_core_exports.languages.IndentAction.IndentOutdent,
        appendText: " * ",
      },
    },
  ],
  /* 定义了自动闭合配对。当输入某个字符时,会自动插入对应的闭合字符。例如,输入 { 时会自动插入 },
字符串,或者多行注释也是同理 */
  autoClosingPairs: [
    { open: "{", close: "}" },
    { open: '"', close: '"', notIn: ["string"] },
    { open: "'", close: "'", notIn: ["string", "comment"] },
  ],
  /*括号的配对*/
  brackets: [
    ["{", "}"],
    ["[", "]"],
  ],
};

三、Monaco-Editor中的Token处理

3.1 有限状态机简介

通过上面的分析,可以发现在Monarch中有一个比较核心的概念:state,state表示一种语言的状态。为了易于理解Monarch的工作机理,我们将从词法分析中常见的有限状态机入手,理解一下它在词法分析器中的作用。

有限状态机(Finite State Machine,FSM)是一种数学模型,用于描述具有有限个状态和转移规则的系统。它是一种抽象的计算模型。在Monarch中,词法分析器可以被视为一个有限状态机,其中每个状态表示词法分析器在不同情况下的行为。状态之间的转换由规则定义,这些规则指定了在匹配到特定字符或字符序列时应该执行的动作,并且可以指定下一个状态。

有限状态机由以下几个要素组成:

  1. 状态(State):表示系统的某个特定状态或条件。在有限状态机中,状态是离散的,即系统在任何时刻只能处于有限个状态之一。

  2. 转移(Transition):表示状态之间的转换关系。转移规则定义了在给定输入条件下,系统从当前状态切换到下一个状态的条件和动作。

  3. 输入(Input):触发状态转移的输入条件或事件。当满足某个输入条件时,有限状态机可以根据转移规则进行状态转换。

  4. 输出(Output):与状态转移相关联的动作或输出。在状态转移时,有限状态机可以执行特定的操作或生成输出。

以下显示了一个状态机,圆圈表示不同的状态。

这里举一个简单的例子进行分析,首先,需要强调以下几点:

  1. 变量由单词,下划线,$组成
  2. // 表示注释
  3. /asdasd/表示正则表达式
  4. var是关键字,varText是变量
js 复制代码
// 这是一段注释
var varText = 1;

具体分析过程如下:

第一行代码的分析 : // 这是一段注释

3.2 Monaco中的语言服务

参考代码:monaco-editor/esm/vs/editor/common/tokens

接下来我们将分析Monaco-Editor中是如何通过Monarch来实现一个Token的解析。

首先在编辑器实例初始化的时候,Monaco-Editor会生成一个TokenizationRegistry进行全局语言服务的处理,包括:handleChange(处理文本变化),factory(仓库)等信息。当一个语言服务被注册之后,会产生一个对应的MonarchTokenlizer,这里打印到控制台看一下具体的Tokenlize包含什么:

这里的Tokenlize是一个建立在Tokenlize基础上的完整语言服务,可以看到有一定的复杂性,除了高亮解析之外,还涉及到了自动填充,括号匹配等其他功能,接下来我们将只讨论和语法高亮的部分。

3.3 基于行的Token处理

在Monaco中,对于Token的处理并不是全量字符的,而是通过行为单位来进行处理,因为token的处理通常来说是不用考虑上下文的信息,可以进行独立处理。并且Monaco对于行的处理并不是完全基于字符流的有限状态机解析,而是通过定义不同顺序的状态正则表达式进行优先级匹配,具体如下:

虽然它是基于行的Token处理,但是一个行可能有多个token,所以每个行可能应用到多个rule,这里使用了一个pos指针来定位当前行已经处理到的位置。

js 复制代码
// 遍历每一个rule
for (const rule of rules) {
        matches = restOfLine.match(rule.regex);
        if (matches) {
            matched = matches[0];
            action = rule.action;
            break;
        }
}
if (matched === null) {
  // should never happen, needed for strict null checking
  break;
}
// advance stream
pos += matched.length;

通常来说,定义不同的状态,对状态规则的排序是很重要的,因为它决定了下一步的状态转化路径,而在词法解析的状态机中,这些状态往往是不可逆的。这里以JavaScript语言为例,Monaco定义了从0-24个数组的匹配规则,其中regex是核心的正则匹配规则,可以发现,他的处理顺序依次是:

  1. 括号
  2. 注释
  3. 变量标识符
  4. 空格
  5. 其他...

Monarch中的正则表达式大致一览

这里对正则表达式进行简单的释义:

可以看到,上面的字符串基本都是以/^(?:开头,主要目的如下:

  • ^(表示开头分组,这里表示以某种规则出现的一个组(满足统一规则的连续字符串)
  • (?:x) 定义一个子表达式,但不会将其匹配结果存储到匹配结果中的捕获组中(只需匹配到了某个符合我们定义的字符串,但是不需要关心具体匹配到了什么。即表示匹配成功。)

简单的说,因为在Monarch中,每次捕获都会重置捕获在当前行的开始,所以每一次匹配都是基于某种规则的字符匹配。除了使用正则表达式引擎来实现匹配,Monaco Editor使用Monarch词法分析器的方式与传统的基于字符流和有限状态机的处理方式还有一些区别。

3.4 Monarch-Action

定义了当匹配到的模板字符串具有多种形式的时候,你需要定义一个action数组([action1,action2,...])来处理不同的情况。下图展示了同一个token(注释)的不同形式。

  1. 注释中的语法高亮,jsdoc。(JSDoc是一种用于为JavaScript代码添加文档注释的标准格式,它可以描述函数、类、变量等的用法、参数、返回值等信息。)
  1. 多行注释,传递当前的状态给下一个rule解析
  2. 普通的注释

3.5 Monarch-nextState

在Monarch中,nextState是一个关键的概念,用于指定在词法分析过程中的状态转换。

在词法分析过程中,Monarch会根据当前的状态和输入的字符来确定下一个状态。nextState属性用于定义从当前状态转换到下一个状态的规则。

在Monarch的规则中,可以为每个状态定义一个或多个nextState规则。每个nextState规则由一个正则表达式和一个目标状态组成。当输入的字符与当前状态的正则表达式匹配时,词法分析器会转换到目标状态。

  • 例子:

当你在当前的字符匹配到了注释开头的字符串,比如/*,这时候,因为匹配到了一个rule,因为它是基于行的匹配,如果每一次处理都是独立的的,那么接下来的匹配可能会导致出错,因为下一行代码很有可能不是注释,这时候,可以强制一个nextState,无论接下来的rule匹配到什么,都会被当做comment处理,而不是其他token

css 复制代码
{ regex: '/\*', action: { token: 'comment', next: '@comment' } }

3.6 其他补充

在实际的token处理过程中,一个正确的字符串解析处理只是最基础的第一步,我们可能还需要考虑到其他更复杂的情况。比如说:

上下文敏感的关键词提取

有些语言的关键词提取需要考虑上下文信息。例如,在Python中,关键字yield可以是一个标识符,也可以是一个关键字,取决于它出现的位置。为了实现上下文敏感的关键词提取,我们可能需要使用到上下文无关文法(context-free grammar)。(这里的上下文无关文法并不是表示上下文没有关系,而是表示可以在当前的上下文产生一个语言的树形结构)

错误处理和恢复

在实际的代码编辑器中,用户可能会输入错误的代码。为了提供更好的输入体验,我们可以实现错误处理和恢复机制。当遇到无法识别的标记时,我们可以尝试恢复并继续进行语法关键词提取,同时可提示用户输入的语法错误,而不是直接导致分析器的崩溃。

举个例子:

kotlin 复制代码
// 变量?关键字?
const va@a = '1'

3.7 总结

通过以上讨论,我们分析了Monaco是通过定义一个名为Monarch的JSON结构,主要通过正则表达式和状态转化来实现对token的解析处理。为了实现代码的高亮,我们还需要对应的token信息来进行上色,比如字体,颜色,以及对于的位置等,接下来我们将继续探讨代码实现的第二部分,即进行token解析后,Monaco是如何实现高亮的?

四、Monaco-Editor中的高亮实现

4.1 Token长城数组

通过上述的操作,Monarch可以将我们传入的字符串转化成一系列的token,比如说可能是这样的:

正常情况下的token结果,好像没啥问题,

js 复制代码
tokens = [
  { startIndex: 0, type: 'keyword.js' },
  { startIndex: 8, type: '' },
  { startIndex: 9, type: 'identifier.js' },
  { startIndex: 11, type: 'delimiter.paren.js' },
  { startIndex: 12, type: 'delimiter.paren.js' },
  { startIndex: 13, type: '' },
  { startIndex: 14, type: 'delimiter.curly.js' }
];

但是在实际情况中,Monaco并不会直接传输这些token,而是转化成了一系列类似于长城数组(一高一低)的Arraybuffer:

什么是Arraybuffer?什么导致了token数组的高低交错?Monaco编辑器为何这么操作?接下来我们将进行分析。

4.2 ArrayBuffer简介

考虑到Arraybuffer对于大多数人来说并不常见,为了易于接下来的理解,这里先对Arraybuffer进行简单介绍。

在JavaScript中,ArrayBuffer是一种用于表示通用的、固定长度的原始二进制数据缓冲区的对象。它提供了一种在处理二进制数据时更高效和灵活的方式。

ArrayBuffer对象表示一段连续的内存区域,可以存储各种类型的二进制数据,例如整数、浮点数、字节等。与普通的JavaScript数组不同,ArrayBuffer的长度是固定的,一旦创建,就无法改变。

JavaScript中的Arraybuffer中可存储的二进制类型

名称(TypedArray) 占用字节 描述
Int8Array 1 8位有符号整数
Uint8Array 1 8位无符号整数
Uint8ClampedArray 1 8位无符号整型固定数组(数值在0~255之间)
Int16Array 2 16位有符号整数
Uint16Array 2 16位无符号整数
Int32Array 4 32 位有符号整数
Uint32Array 4 32 位无符号整数
Float32Array 4 32 位 IEEE 浮点数
Float64Array 8 64 位 IEEE 浮点数

4.3 在Monaco中定义主题颜色

通常来说,我们在实现代码高亮的第一步都会定义一个统一的颜色主题设置,否则就会显得很冲突。

首先,我们会定义一些和主题的相关的颜色规则,在这些规则上,我们最终决定要将不同的token进行不同的上色处理。

Monaco中的一些token规则示例:

4.4 将颜色规则转化为一个二进制数据

对于所有的parsedTokenThemeRule(一个token的规则类),Monaco将进行遍历处理,转化成一个32位的二进制数字。这个二进制位数就是和Arraybuffer32的位数一样。

将_fontStyle左移11位(使用了MetadataConsts.FONT_STYLE_OFFSET),

将_foreground左移15位(使用了MetadataConsts.FOREGROUND_OFFSET),

将_background左移24位(使用了MetadataConsts.BACKGROUND_OFFSET),

然后进行位或运算得到的结果。最后,使用无符号右移0位来确保metadata是一个无符号32位整数。

js 复制代码
while (parsedThemeRules.length >= 1 && parsedThemeRules[0].token === '') {
    const defaults = new ThemeTrieElementRule(defaultFontStyle, foregroundColorId, backgroundColorId);
    const root = new ThemeTrieElement(defaults);
}
// 将字体样式、前景色和背景色,通过位运算将这些属性合并到一个32位的无符号整数中。
class ThemeTrieElementRule {
    constructor(fontStyle=0, foreground=1, background=2) {
        this._themeTrieElementRuleBrand = undefined;
        this._fontStyle = fontStyle;
        this._foreground = foreground;
        this._background = background;
        this.metadata = ((this._fontStyle << 11 /* MetadataConsts.FONT_STYLE_OFFSET */)
            | (this._foreground << 15 /* MetadataConsts.FOREGROUND_OFFSET */)
            | (this._background << 24 /* MetadataConsts.BACKGROUND_OFFSET */)) >>> 0;
    }
}

这个时候,再看一下token数组,是不是就有头绪了,3375210等数字就是将字体,前景色,背景颜色合在一起的数据。

这时候你可能会疑惑,怎么这些Arraybuffer有些短有些长,是有什么区别?

确实,这里的8,14虽然也是二进制的表示,但是这里的意思只是单纯的数字,表示对应的token在编辑器中的位置。

js 复制代码
// 解析示例:将每个token的终点进行标记
function  name
01234567891234

这时候,我们再对上面的数组进行解析:

  • 8表示第一个token 的终点位置位置,0-8,他的样式为33752100( 背景色 + 前景色 + 字体 = token )
  • 14表示第二个token 的终点位置,8-14,他的样式为:33588260
  • ....

因为一个Arraybuffer只表示某一个行,所以不同的token之间只会存在空格,只要我们有每个token的终点位置,我们就可以计算出每个token对应的位置,再加上其经过位运算的颜色信息,我们就可以计算出要更新的token 的位置,和应用的样式,实现高亮渲染。

4.5 如何从Arraybuffer中提取数据

要从metadata中提取这三个数据,可以使用逆向的位运算操作。具体来说,可以使用位掩码和位移操作来提取每个数据的值。以下是一个示例:

ini 复制代码
const metadata = // 32位无符号整数,来自ThemeTrieElementRule实例的metadata属性

const fontStyle = (metadata >> 11) & 0x1F;  // 提取字体样式,右移11位并使用位掩码0x1F(31)来获取低5位的值
const foreground = (metadata >> 15) & 0x1F;  // 提取前景色,右移15位并使用位掩码0x1F(31)来获取低5位的值
const background = (metadata >> 24) & 0x1F;  // 提取背景色,右移24位并使用位掩码0x1F(31)来获取低5位的值

通过上述代码,可以从metadata中提取出fontStyleforegroundbackground的值。这些值将是原始构造函数中传入的参数的值。

4.6 补充:Why Arraybuffer?

  1. Arraybuffer的设计初衷

为了满足 JavaScript 与显卡之间(webGPU)大量的、实时的数据交换,它们之间的数据通信必须是二进制的,而不能是传统的文本格式。文本格式传递一个 32 位整数,两端的 JavaScript 脚本与显卡都要进行格式转化,将非常耗时。这时要是存在一种机制,可以像 C 语言那样,直接操作字节,将 4 个字节的 32 位整数,以二进制形式原封不动地送入显卡,脚本的性能就会大幅提升。

  1. Arraybuffer和数组的区别

Arraybuffer中存储的内容为typeArray

  • TypedArray内的成员只能是同一类型
  • TypedArray只是视图,本身不存储数据,数据都存储在底层的ArrayBuffer中,要获取底层对象必须使用buffer属性

  • TypedArray可以直接操作内存,不需要进行类型转换,所以比数组快

  • TypedArray数组有的方法都可以使用,但不能使用cancat方法

  1. 为什么是Uint32Array而不是Uint64Array

不同位数的二进制数据在表示范围和精度上有所不同。随着位数的增加,可以表示的整数范围也随之增大。此外,较大的位数还可以提供更高的精度,用于表示更复杂的数据类型,如浮点数。选择适当的位数取决于所需表示的数据的范围和精度。

  1. Arraybuffer如何提升性能

从理论的角度看二进制和对象来存储数据的区别:

  • 无需类型转化

ArrayBuffer 是专门用于存储原始二进制数据的缓冲区,它以字节为单位存储数据。相比之下,普通的数组存储的是 JavaScript 对象和值,需要进行类型转换和封装。对于处理原始二进制数据的场景,ArrayBuffer 可以直接存储原始数据,无需额外的类型转换。

  • 内存分配效率

ArrayBuffer 在创建时需要指定固定的字节长度,这使得内存分配更加高效。一旦分配了内存,ArrayBuffer 的长度是固定的,不会发生动态调整。相比之下,普通的数组在创建时不需要指定长度,并且可以动态增长或缩小。动态调整数组大小可能涉及到内存重新分配和数据复制,这会导致性能开销。

  • 数据视图操作

ArrayBuffer 可以通过 TypedArray 或 DataView 对象来访问和操作其中的数据。这些数据视图提供了对二进制数据的类型化访问,可以按照指定的数据类型(如整数、浮点数等)直接读取和写入数据。相比之下,普通的数组需要通过索引来访问和操作数据,这可能涉及到类型转换和额外的操作。

具体来说,想象一下以下的15个字符组成的代码字符串:

该字符串对应的token是这样:

js 复制代码
tokens = [
  { startIndex: 0, type: 'keyword.js' },
  { startIndex: 8, type: '' },
  { startIndex: 9, type: 'identifier.js' },
  { startIndex: 11, type: 'delimiter.paren.js' },
  { startIndex: 12, type: 'delimiter.paren.js' },
  { startIndex: 13, type: '' },
  { startIndex: 14, type: 'delimiter.curly.js' }
];

在 Chrome 中保存该 tokens 数组需要 648 个字节,因此存储这样的对象在内存方面非常昂贵(每个对象实例必须保留空间来指向其原型、属性列表等)。我们当前的计算确实有大量 RAM(也就是内存),但为 15 个字符行存储 648 字节是不可接受的(对于vscode来说)。

下图展示了Vscode使用二进制的token处理后对于之前版本的提升,分别对ts,css和c三类不同的文件进行处理

总的来说,使用二进制进行数据传输和转化都是为了提示编辑器的性能。在我们的日常编码中,十万行的代码字符串,产生的token信息很可能就是百万级的,这时候,就需要一个更高效的数据处理和传输方式。Monaco将代码高亮中的一些核心数据,如token的类型,位置,颜色信息合并在一起存储到一个二进制中,可以有效的提升性能。

五、总结

在本文中,我们探讨了MonacoEditor的代码高亮功能,并介绍了其背后的部分技术实现。

对于Vscode这种通用的代码编辑器来说,需要支持几乎所有的编程语言,统一且规范的词法分析器实现方式可以说是不可或缺的,于是诞生了Monarch。通过使用Monarch规则进行词法分析,我们可以声明式地定义和应用自定义的语法规则,实现代码高亮效果。

此外,我们还讨论了MonacoEditor如何利用ArrayBuffer来传输数据,以提升JavaScript的性能。然而Monaco的代码高亮实现实现仍有一些缺点,比如说:

  • 使用Monarch规则进行词法分析也有一些限制。

Monarch规则的定义可能会变得复杂(从上面的正则表达式也可以看得出),特别是对于复杂的语法结构和嵌套规则的处理。在处理大型语言或具有复杂语法的语言时,可能需要更多的规则定义和调试,这可能会增加开发的复杂性

  • 对于ArrayBuffer的性能提升,具体的性能增益取决于具体的使用场景和数据量。

尽管ArrayBuffer可以提高JavaScript对数据处理的性能,但它并不是适用于所有情况的银弹。在某些情况下,性能提升可能并不明显或甚至可以忽略不计。二进制数据的数据传输在大文本下对于MonacoEditor有明显的提示,但是在我们的实际项目中,我们仍要考虑是否有选择的必要。

相关参考

  1. macromates.com/manual/en/l...
  2. code.visualstudio.com/api/languag...
  3. microsoft.github.io/monaco-edit...
  4. juejin.cn/post/684490...
  5. zhuanlan.zhihu.com/p/46347732
相关推荐
敲敲了个代码6 小时前
从硬编码到 Schema 推断:前端表单开发的工程化转型
前端·javascript·vue.js·学习·面试·职场和发展·前端框架
dly_blog7 小时前
Vue 响应式陷阱与解决方案(第19节)
前端·javascript·vue.js
消失的旧时光-19438 小时前
401 自动刷新 Token 的完整架构设计(Dio 实战版)
开发语言·前端·javascript
console.log('npc')8 小时前
Table,vue3在父组件调用子组件columns列的方法展示弹窗文件预览效果
前端·javascript·vue.js
用户47949283569158 小时前
React Hooks 的“天条”:为啥绝对不能写在 if 语句里?
前端·react.js
我命由我123458 小时前
SVG - SVG 引入(SVG 概述、SVG 基本使用、SVG 使用 CSS、SVG 使用 JavaScript、SVG 实例实操)
开发语言·前端·javascript·css·学习·ecmascript·学习方法
用户47949283569159 小时前
给客户做私有化部署,我是如何优雅搞定 NPM 依赖管理的?
前端·后端·程序员
C_心欲无痕9 小时前
vue3 - markRaw标记为非响应式对象
前端·javascript·vue.js
qingyun9899 小时前
深度优先遍历:JavaScript递归查找树形数据结构中的节点标签
前端·javascript·数据结构
熬夜敲代码的小N9 小时前
Vue (Official)重磅更新!Vue Language Tools 3.2功能一览!
前端·javascript·vue.js