Monaco Editor

本次分享的主要内容包括Monaco Editor的基础介绍,自定义语言的语法高亮、代码补全、hover显示等功能,阅读时长大概在20~25分钟左右。

Monaco Editor官网指路

1. 简介

Monaco Editor 是一个浏览器端的代码编辑器库(Monaco Editor并不支持在移动浏览器或者移动WEB框架下使用),同时它也是 VS Code 所使用的编辑器。Monaco Editor 可以看作是一个编辑器控件,只提供了基础的编辑器与语言相关的接口,可以被用于任何基于 Web 技术构建的项目中。

Monaco Editor可以理解为网页版的VSCode,其对TypeScript, JavaScript, CSS, LESS, SCSS, JSON, HTML的支持度较好,拥有丰富的内置智能提示和验证功能;而对于XML, PHP, C#, C++, Razor, Markdown, Diff, Java, VB, CoffeeScript, Handlebars, Batch, Pug, F#, Lua, Powershell, Python, Ruby, SASS, R, Objective-C等语言,只支持基本的语法高亮功能。

1.1 源码仓库

前往ME github的仓库就会发现,其仓库中并不包含任何实际功能的代码,因为它的源代码与 VS Code 是在同一个仓库下,只是在版本发布时会构建出独立的编辑器代码。下面的代码框展示了vscode项目的目录结构,monaco editor的源码位于vs/editor层。

r 复制代码
├── build         # gulp编译构建脚本
├── extensions    # 内置插件
├── product.json  # App meta信息
├── resources     # 平台相关静态资源
├── scripts       # 工具脚本,开发/测试
├── src           # 源码目录
└── typings       # 函数语法补全定义
└── vs
    ├── base        # 通用工具/协议和UI库
    │   ├── browser # 基础UI组件,DOM操作
    │   ├── common  # diff描述,markdown解析器,worker协议,各种工具函数
    │   ├── node    # Node工具函数
    │   ├── parts   # IPC协议(Electron、Node),quickopen、tree组件
    │   ├── test    # base单测用例
    │   └── worker  # Worker factory和main Worker(运行IDE Core:Monaco)
    ├── code        # VSCode主运行窗口
    ├── editor        # IDE代码编辑器
    |   ├── browser     # 代码编辑器核心
    |   ├── common      # 代码编辑器核心
    |   ├── contrib     # vscode 与独立 IDE共享的代码
    |   └── standalone  # 独立 IDE 独有的代码
    ├── platform      # 支持注入服务和平台相关基础服务(文件、剪切板、窗体、状态栏)
    ├── workbench     # 工作区UI布局,功能主界面
    │   ├── api              #
    │   ├── browser          #
    │   ├── common           #
    │   ├── contrib          #
    │   ├── electron-browser #
    │   ├── services         #
    │   └── test             #
    ├── css.build.js  # 用于插件构建的CSS loader
    ├── css.js        # CSS loader
    ├── editor        # 对接IDE Core(读取编辑/交互状态),提供命令、上下文菜单、hover、snippet等支持
    ├── loader.js     # AMD loader(用于异步加载AMD模块)
    ├── nls.build.js  # 用于插件构建的NLS loader
    └── nls.js        # NLS(National Language Support)多语言loader

1.2 谁是爸爸

从源码仓库的纠葛上看就能发现,Monaco Editor与vscode有着剪不断理还乱的联系。从功能上看,与Monaco Editor相比,VS Code 作为一款桌面软件,包含了文件管理、版本控制、插件等一系列比Monaco Editor更加强大的功能。并且 Monaco Editor 作为从 VS Code 开源出来的库,大部分人应该都会认为是先有 VS Code ,再在此基础上抽离并开源了 Monaco Editor,也就是说vscode看起来应该是monaco的爸爸,但事实却正好相反,Monaco 的历史比 VS Code 还要更早。

考古: Monaco Editor 由 Erich Gamma 在苏黎世带领的团队所开发的(Erich Gamma 就是《设计模式》一书的作者之一),关于 Monaco Editor 可以追溯到 2011 年,最早的 Monaco 是被广泛用于微软内部及外部一些 Web 产品的编辑器控件,在这篇2013年的博客 「A rich new JavaScript code editor spreading to several Microsoft web sites」 中介绍了一些在使用 Monaco 的微软产品,包括 Sky Drive、Azure、TypeScript 等站点都有 Monaco 的身影。而更为人所熟知的是早期的 Visual Studio Online 。VS Online 是 2013 年就已经上线运营的产品,界面与较老版本的 VS Code 非常类似,可以说 VS Code 是将 VS Online 搬到了桌面端,(VS Online 当时的代号是 Monaco,在新的 VS Online (2019 年,基于 VS Code Web 版本)短暂的运营了一段时间以后,目前已经改名叫做 GitHub Codespaces。)而新的 Github Codespaces 又将其搬到了 Web 端。你可以在这篇博客「A look at the new Visual Studio Online "Monaco" code editor」中了解 VS Online 所提供的能力。另外还有一系列的视频教程「Visual Studio Online "Monaco"」详细介绍相关的特性,这时已经可以看出一些设计被一直沿用至今(例如界面布局、版本控制、输出面板、终端等)。

1.3 基础部件

Monaco Editor 的核心功能与组件和 VS Code 基本一致,它们基本上都包含下图中这些小部件。

  1. 行号
  2. Overlay widget。可以渲染任意内容小部件,能选择放置在顶部、底部或编辑器中间。例如编辑器内的查找框即是一个 Overlay Widget
  3. ViewLine,每一行都表示一个 ViewLine
  4. Decorations 装饰块,可以指定某个位置的代码块以何种样式呈现,例如修改其背景色、前景色等
  5. Content Widget,与 Overlay Widget 类似,但可以基于行、列指定其位置。例如自动补全的列表框就是一个 Content Widget
  6. View Zone, 与 Overlay、Content Widget 不同,它可以插入到特定的行之间将其撑开。

2.语法高亮

2.1 基本使用

js 复制代码
import * as monaco from 'monaco-editor'

// 创建一个js编辑器
const editor = monaco.editor.create(document.getElementById('container'), {
    value: ['function x() {', '\tconsole.log("Hello world!");', '}'].join('\n'),
    language: 'javascript', // 'myJavascript'
    theme: 'vs-dark' // 'myStyle'
})

这样就可以在container元素上创建一个js语言的编辑器,并且使用了内置的vs-dark主题。如果要使用自定义的语言和主题规则,可以在languagetheme处替换成自定义语言的语言名称和主题名称。

2.2 自定义语言

因为内置的语言已经自带语法高亮,为了探究monaco如何使用它的语法高亮能力,我们从自定义的语言出发。

首先,我们需要在 monaco 里注册一下我们的语言。

js 复制代码
// Register a new language
monaco.languages.register({ id: 'myJavascript' });

现在这个语言除了有个名字,还空空如也。所以,接下来,我们就要开始给 myJavascript 语言加上我们的语法高亮功能。该功能主要是通过 setMonarchTokensProvider 的函数实现。

2.3 setMonarchTokensProvider

js 复制代码
// Register a tokens provider for the language
monaco.languages.setMonarchTokensProvider('myJavascript', myJsDef);

Monaco Editor 自带的一个语法高亮库,名字叫做Monarch ,通过它,我们可以用类似 Json 的语法来实现自定义语言的语法高亮功能 。(这里提一下,Monaco Editor和VScode的高亮库是不一样的,VScode用的是textmate,你如果要问区别的话,最明显的区别就是ME的高亮库看起来简陋很多......),他有许多属性,其中最重要的就是 tokenizer 属性,我们描述语法的代码就写在这里面。先来看一个简单的例子

js 复制代码
monaco.languages.setMonarchTokensProvider('myjs。。', { 
    tokenizer: { 
        root:[ 
            [/\d+/,{token:"keyword"}],  // 数字
            [/[a-z]+/,{token:"string"}]  // 小写字符串
           ], 
        } 
  });

我们在 tokenizer 中定义了一个 root 属性,roottokenizer 中的一个 state , 是我们用来编写解析规则(rule )的地方,在 rule 中,我们可以编写匹配文本的正则表达式 ,然后再给匹配到的文本设置一个执行动作的 action ,在 action 中,我们可以给匹配到的文本设置 token class

在上面的栗子中,分别写了两个rule,用来匹配数字和小写字母,匹配成功后就接着执行对应的 action;在 action 中,我们使用的是Monarch中自带的 token class : keyword和string。

2.4 CSS token class

上面的代码中,tokenizer 属性描述了词法分析是如何发生的,以及输入如何被划分为tokens。 每个tokens都有一个 CSS 类名,用于在编辑器中呈现每个不同样式的tokens。 标准 CSS token class如下

js 复制代码
identifier         entity           constructo
operators          tag              namespace
keyword            info-token       type
string             warn-token       predefined
string.escape      error-token      invalid
comment            debug-token
comment.doc        regexp
constant           attribute

// 分隔符
delimiter .[curly,square,parenthesis,angle,array,bracket]
number    .[hex,octal,binary,float]
variable  .[name,value]
meta      .[content]

CSS token 对应的一些Class我们可以在 theme.ts 中看到

js 复制代码
{ token: 'keyword', foreground: '0000FF' }, // #RRGGBB 这是一个蓝色
{ token: 'string', foreground: 'CE9178' },  // 这是一个棕色

运行后我们就可以发现,所有的数字都变成了蓝色,所有小写字母都变成了棕色。

2.5 JS 的token高亮

上面的例子我们通过简单的正则表达式,去匹配了基础的数字和字符串,实现了ME上的语法高亮,那对于更复杂的例子是怎么实现的呢?我们接下来来看一个js语言的tokens高亮解析实现。主要是把下面代码框中标黄的内容替换成了下面代码框的内容。第二个代码框的内容点击上面的链接可以看到完整版的信息。

js 复制代码
monaco.languages.setMonarchTokensProvider('log', { 
    tokenizer: { 
        root:[ 
            [/\d+/,{token:"keyword"}],  // 数字
            [/[a-z]+/,{token:"string"}]  // 小写字符串
           ], 
        } 
  });
js 复制代码
return {
    // 将 defaultToken 设置为 invalid 以查看您尚未标记的内容
    defaultToken: 'invalid', // foreground: 'f44747'
    tokenPostfix: '.js',
    keywords: [
        'break', 'case', 'catch', 'class', 'continue', 'const'...
    ],
    typeKeywords: [
        'any', 'boolean', 'number', 'object', 'string', 'undefined'
    ],
    operators: [
        '<=', '>=', '==', '!=', '===', '!==', '=>', '+', '-', '**',...
    ],

    // we include these common regular expressions
    symbols: /[=><!~?:&|+-*/^%]+/,
    escapes: /\(?:[abfnrtv\"']|x[0-9A-Fa-f]{1,4}|u[0-9A-Fa-f]{4}|U[0-9A-Fa-f]{8})/,
    digits: /\d+(_+\d+)*/,
    octaldigits: /[0-7]+(_+[0-7]+)*/,
    binarydigits: /[0-1]+(_+[0-1]+)*/,
    hexdigits: /[[0-9a-fA-F]+(_+[0-9a-fA-F]+)*/,
    regexpctl: /[(){}[]$^|-*+?.]/,
    regexpesc: /\(?:[bBdDfnrstvwWn0\/]|@regexpctl|c[A-Z]|x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4})/,


    // The main tokenizer for our languages
    tokenizer: {
        root: [
            [/[{}]/, 'delimiter.bracket'],
            { include: 'common' } 
        ],
        common: [
            // identifiers and keywords
            [/[a-z_$][\w$]*/, { // 以字母或者下划线开头包括字母数字下划线的
                cases: { // case下的规则会一一匹配,匹配成功后就直接应用规则
                    '@typeKeywords': 'keyword'
                    '@keywords': 'keyword',
                    '@default': 'identifier' // 匹配默认情况
                }
            }],
           ...........
            // regular expression: ensure it is terminated before beginning (otherwise it is an opeator)[//(?=([^\/]|\.)+/([gimsuy]*)(\s*)(.|;|/|,|)|]|}|$))/, { token: 'regexp', bracket: '@open', next: '@regexp' }],...

这个书写action-class的方式在页面下方有相关文档介绍,这里我挑几个部分做一个简单的讲解,让大家有个大概的认识。

第一个是

js 复制代码
// The main tokenizer for our language
tokenizer: {
    root: [
        [/[{}]/, 'delimiter.bracket'],
        { include: 'common' }
    ],
    ......
}

include这种书写方式只是为了让你的rule有个更好的组织形式,可以理解为从common中我们可以看到tokenizer的大部分规则。

js 复制代码
{ include: state }

然后是一个

js 复制代码
{ cases: { guard1: action1, ..., guardN: actionN } }

cases和普通的if /else if语法一样,可以写多个判断条件(guard),然后根据不同guard去执行对应的actionguard和正则表达式类似,功能是用来匹配文本,当他不以@$开头时,他就是一个普通的正则表达式,不过,当他以@$开头时,他才是一个真正意义上的 guard。

guard有固定的结构 [pat][op]matchpat代表匹配的文本,op代表一个比较符,match则是要比较的内容。pat 可以以$开头,但文档中没有用到我就跳过了。主要讲后两个。

opmatch 稍微复杂点,可以是这几个内容:

  • regex or !regex :匹配/不匹配一个正则
  • @attribute or !@attribute :匹配/不匹配一个属性,属性定义在 Monarch 的根层级下,可以是数组、字符串、正则。
  • ==str or != str :匹配/不匹配一个字符串
  • @default :匹配默认情况(永远为真)
  • @eos : 一行结束,则匹配成功

所以回到js的monarch文档中,像上面这个代码段的含义,就是对所有匹配左侧正则的内容(即 以字母或者下划线开头包含字母数字下划线的字符串),按顺序检查case,如果匹配@typeKeywords的属性,就匹配keyword的token class,如果不匹配就继续,直到最后@default,就直接匹配到一个identifier的token class。

Next属性

js 复制代码
// 如果定义,它将当前状态推送到tokenizer堆栈,并把这个状态变成当前状态。
['/\*', 'comment', '@comment' 
{ regex: '/\*', action: { token: 'comment', next: '@comment' } }

// 这里匹配的 /* 被赋予"comment"的token class
// 然后tokenizer继续使用状态 @comment 中的规则匹配输入。
 //一些特殊状态可用于next属性: @pop @push @popall 

2.6 自定义token class的样式

事实上除了使用内置的monarch样式之外,我们还可以根据自己的需要定制不同token显示的样式。

js 复制代码
monaco.languages.setMonarchTokensProvider('mySpecialLanguage', {
    tokenizer: {
        root: [
        // 左边显示的
            [/[error.*/, "custom-error"],
            [/[notice.*/, "custom-notice"],
            [/[info.*/, "custom-info"],
            [/[[a-zA-Z 0-9:]+]/, "custom-date"],
        ]
    }
});

// Define a new theme that contains only rules that match this language
monaco.editor.defineTheme('myCoolTheme', {
    base: 'vs',
    inherit: false,
    rules: [
        { token: 'custom-info', foreground: '808080' },
        { token: 'custom-error', foreground: 'ff0000', fontStyle: 'bold' },
        { token: 'custom-notice', foreground: 'FFA500' },
        { token: 'custom-date', foreground: '008800' },
    ]
});

monaco.editor.create(document.getElementById("container"), {
    theme: 'myCoolTheme',
    value: getCode(),
    language: 'mySpecialLanguage'
});

3.代码补全

3.1 一个简单的官网 🌰

这个例子主要是通过registerCompletionItemProvider 函数实现一个简单的json自定义代码提示补全功能。

js 复制代码
monaco.languages.registerCompletionItemProvider('json', 
    provideCompletionItems: function (model, position) { // position表示当前光标的位置
        // 获取第一行第一列开始到光标所在行列的text
        var textUntilPosition = model.getValueInRange({
            startLineNumber: 1,
            startColumn: 1,
            endLineNumber: position.lineNumber,
            endColumn: position.column

        });

        // 利用text去匹配自定义的部分规则
        var match = textUntilPosition.match(/"dependencies"\s*:\s*{\s*("[^"]*"\s*:\s*"[^"]*"\s*,\s*)*([^"]*)?$/);
        // 未匹配上,提示内容就为空
        if (!match) {
            return { suggestions: [] };
        }

        var word = model.getWordUntilPosition(position);
        var range = {
            startLineNumber: position.lineNumber,
            endLineNumber: position.lineNumber,
            startColumn: word.startColumn,
            endColumn: word.endColumn
        };

        // 如果能匹配上相应的规则,就根据当然的输入值匹配可能的补全项
        return {
            suggestions: createDependencyProposals(range)
        };
    }

});
js 复制代码
function createDependencyProposals(range) {
    // 返回一个静态的proposal列表,此处过滤由monaco editor完成
    return [
        {
            // label, 默认情况下,也是选择此完成时插入的文本。
            label: '"lodash"', 
            // completion item的类别,编辑器根据种类选择图标
            kind: monaco.languages.CompletionItemKind.Function,
            // 文档注释
            documentation: 'The Lodash library exported as Node.js modules.',
            // 选择补全时应插入到文档中的字符串或片段
            insertText: '"lodash": "*"',
            // completion item要被替换的文本范围
            range: range
        },
         { // snippets
            label: 'list2d_basic', // 用户键入list2d_basic的任意前缀即可触发自动补全,选择该项即可触发添加代码片段
            kind: monaco.languages.CompletionItemKind.Snippet,
            documentation: "2D-list with built-in basic type elements",
            insertText: '[[${1:0}]*${3:cols} for _ in range(${2:rows})]',   // ${i:j},其中i表示按tab切换的顺序编号,j表示默认串
            insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
            range: range
        },
        {
            label: '"express"',
            kind: monaco.languages.CompletionItemKind.Function,
            documentation: 'Fast, unopinionated, minimalist web framework',
            insertText: '"express": "*"',
            range: range
        },
        {
            label: '"mkdirp"',
            kind: monaco.languages.CompletionItemKind.Function,
            documentation: 'Recursively mkdir, like <code>mkdir -p</code>',
            insertText: '"mkdirp": "*"',
            range: range
        },
        {
            label: '"mkdhh"',
            kind: monaco.languages.CompletionItemKind.Function,
            documentation: 'Recursively mkdir, like <code>mkdir -p</code>',
            insertText: '"mkdhh": "*"',
            range: range
        },
        {
            label: '"my-third-party-library"',
            kind: monaco.languages.CompletionItemKind.Function,
            documentation: 'Describe your library here',
            insertText: '"${1:my-third-party-library}": "${2:1.2.3}"',
            insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
            range: range
        }
    ];
}

3.2 基于已输入词(Token)的动态补全

当上述snippets和keywords均没有设置时,Monaco Editor会使用当前文档的所有词汇进行"代码补全提示"。但增加任何自定义补全规则后,原来的naive版词汇补全将会失效,且现在没有好的办法能做到既保留原始word-based补全又使自定义规则生效。

Monaco Editor使用Monarch进行代码解析,但暂时没发现一个好的接口能直接获取parse出的当前文档的所有token。因此我们可以通过正则表达式自己进行简单的parsing,将当前代码的所有token取出,加入上述createDependencyProposals()中,从而间接达到基于token的word-based completion。

在Javascript中使用正则表达式进行全局多次模式匹配:

js 复制代码
const identifierPattern = "([a-zA-Z_]\w*)";        

// 正则表达式定义 注意转义\w
export function getTokens(code) {
    let identifier = new RegExp(identifierPattern, "g");        
    // 注意加入参数"g"表示多次查找
    let tokens = [];
    let array1;
    while ((array1 = identifier.exec(code)) !== null) {
        tokens.push(array1[0]);
    }
    return Array.from(new Set(tokens));  // 去重
}

再添加到补全规则中即可实现实时更新的token补全:

js 复制代码
function createDependencyProposals(range, languageService = false, editor, curWord) {
    let words = [];
    let tokens = getTokens(editor.getModel().getValue());
    for (const item of tokens) {
        if (item != curWord.word) {
            words.push({
                label: item,
                kind: monaco.languages.CompletionItemKind.Text,        
                // Text 没有特殊意义 这里表示基于文本&单词的补全
                documentation: "",
                insertText: item,
                range: range
            });
        }
    }

    // 把所有的Proposal list 拼起来就可以啦~
    return snippets.concat(keys).concat(words);
}

4. hover提示

4.1 一个简单的官网 🌰

官网的🌰

此处就可以实现,当鼠标hover在range区间的时候,就会显示contents的内容。一个{}是一行。

value内的内容可以直接通过markdown的方式,渲染出丰富的样式。

4.2 一个简单的实现

注册hover事件

js 复制代码
  monaco.languages.registerHoverProvider('go', {
    provideHover(model, position) {
      // 获取当前hover的单词
      const word = model.getWordAtPosition(position).word;
      const match = getMatchWord(model, position);
      return getHoverContent(word, match, goKeyWordList);
    }
  });

获取hover内容

js 复制代码
export const getHoverContent = (word: string, matchArr: string[], goKeyWordList: any[]) => {
  let keyWordList = [];
  if (matchArr) {
    // 若有匹配的.,逐层获取.中数据,构建keywordlist
    const dotSplitArr = matchArr[0].split('.');
    dotSplitArr.pop();
    for (const item of dotSplitArr) {
      keyWordList = getPosition(goKeyWordList, item);
    }
    return getContent(keyWordList, word);
  } else {
    // 若无匹配的.,将token放入数据,遍历第一层数据寻找keyType为function的item
    return getContent(goKeyWordList, word);
  }
};

将获取的内容hover展示

js 复制代码
// hover getContent
const getContent = (funcList, word) => {
  for (let i = 0; i < funcList.length; i++) {
  // 如果类型为function才做处理
    if (funcList[i].Key === word && funcList[i].FuncInfo !== null && funcList[i].KeyType === 'function') {
      return {
        contents: [
          {
            value:
              ````go\n${funcList[i].FuncInfo.ReturnType !== '' ? `${funcList[i].FuncInfo.ReturnType} ` : ''}${
                funcList[i].Key
              }(${getParamsItem(funcList[i].FuncInfo.Params, funcList[i].FuncInfo.ParamOrder)})\n` + '```\n'
          },
          {
            value: ````plainText${funcList[i].FuncInfo.DetailInfo}\n` + '```\n'
          }
        ]
      };
    }
  }
  return null;
};

5. Monaco Editor 样式能力

5.1 基于行列指定位置的内容小部件 contentWidget

内容小部件与文本内联呈现,滑动时也可以跟随行列移动

5.2 选中行样式高亮 currentLineHighLight

js 复制代码
renderLineHighlight: IEditorOption<EditorOption.renderLineHighlight, 'all' | 'line' | 'none' | 'gutter'>;

下图展示的是选中某行时的 line样式

5.3 装饰 decoration

行级别或者字母级别的装饰

tips:在控制台element下搜索 cdr 可以看到装饰相关的div

5.4 滚动条 scrollbar

5.5 字形边距 glyphMargin

字形边距是编辑器窗口左侧的垂直条

tips:在控制台element下搜索 cgmr 可以看到装饰相关的div

5.6 缩进指南 indentGuide

5.7 行号 lineNumbers

5.8 线条装饰 lineDecorations

放置在行号和编辑器内容之间的装饰

5.9 小地图 minimap

5.10 可覆盖于文本之上显示的 overlayWidget

渲染任意的内容小部件,能选择放置在顶部、底部或编辑器中间。例如编辑器内的查找框即是一个 Overlay Widget

5.11 概览标尺 OverviewRuler

5.12 可撑开的视图区域 viewZone

视图区域是一个完整的水平矩形,可将文本"向下推"。 编辑器在渲染时为视图区域保留空间。

相关推荐
霍先生的虚拟宇宙网络9 分钟前
webp 网页如何录屏?
开发语言·前端·javascript
温吞-ing11 分钟前
第十章JavaScript的应用
开发语言·javascript·ecmascript
彪82512 分钟前
第十章 JavaScript的应用 习题
javascript·css·ecmascript·html5
Myli_ing2 小时前
考研倒计时-配色+1
前端·javascript·考研
余道各努力,千里自同风2 小时前
前端 vue 如何区分开发环境
前端·javascript·vue.js
PandaCave2 小时前
vue工程运行、构建、引用环境参数学习记录
javascript·vue.js·学习
软件小伟2 小时前
Vue3+element-plus 实现中英文切换(Vue-i18n组件的使用)
前端·javascript·vue.js
醉の虾2 小时前
Vue3 使用v-for 渲染列表数据后更新
前端·javascript·vue.js
张小小大智慧2 小时前
TypeScript 的发展与基本语法
前端·javascript·typescript
疯狂的沙粒3 小时前
对 TypeScript 中高级类型的理解?应该在哪些方面可以更好的使用!
前端·javascript·typescript