🌋 WebIDE 的开发记录其五(monaco-editor + textmate)
!本篇文章过于久远,其中观点和内容可能已经不准确,请见谅!~
实现 IDE 的过程中好奇为什么 moanco-editor 和 vscode 的高亮不太一样,比较简陋很不舒服,一番搜索发现
monaco-editor
的语言支持使用的是内置的 Monarch 这个语法高亮支持。主要就是因为
Textmate
语法解析依赖的 Oniguruma 是一个 C 语言下的解析功能,VSCode 可以使用 node 环境来调用原生的模块,但是在 web 环境下无法实现,即使通过 asm.js 转换后,性能依然会有 100-1000 倍的损失(16年9月的说明,目前未测试),而且 IE 不支持~~~而官方说的 asm.js 的方式的缺点,我认为都是可以接受的,所以仍然想尝试使用 textmate。
ps: 最近 [Apr 16, 2020] vscode 将 oniguruma 从原生模块 node-oniguruma 独立出来为 wasm 版本的 vscode-oniguruma,之前的文字做了些修改。
考虑到能用和性能,先解决能用问题,考虑 asm.js 下的实现(vscode-textmate 测试三种获取 OnigLib 的方式思路提供了很大帮助):
name | project | desc |
---|---|---|
onigurumaLib | oniguruma | 是用 c 的一个解释器,不是 node 或者 浏览器模块,无法在前端项目中使用 |
node-oniguruma] | node-oniguruma | atom 对 oniguruma 封装的 node 模块,无法在浏览器端运行。VSCode 之前使用的模块 |
vscode-oniguruma | vscode-oniguruma | [最近更新] vscode 用 wasm 对 oniguruma 封装的模块,应该可以在浏览器端运行,但是[Apr 16, 2020]刚创建的项目还没试 |
onigasm | onigasm | 使用 WebAssembly 的技术将 c 的版本移植到 wasm,这个是可以在 web 端运行(因为没有 V8 的一些定制加成,性能比 node-oniguruma 差 2 倍,相比 C 下的 oniguruma 未知) |
vscode-textmate 下有 benchmark 测试可以看下 原生下的 oniguruma 和 asm 下的 onigasm 性能差异
现在的思路如下:
- 使用 moanco-editor-core 代替 monaco-editor 去除自带的语法支持
- 使用 onigasm 的 WebAssembly 的技术将 c 的版本移植到 wasm,作为解释器在前端引入
- 使用 vscode-textmate 库来加载语法解释器,转为 monaco-editor 支持的语法主题,此时即可实现 textmate 的语法解析了
- 至此高亮主题就实现了 textmate 的替代
- 再使用 vscode 里面的主题文件转换下给 monaco 使用,高亮和 VScode 保持一致
好像并不是很复杂的样子,该有的工具都提供给了我们,只需要粘在一起就行(可是好像也并没有这么简单)~
集成的过程有些复杂,涉及到很多的封装和概念,当时做的时候踩了很多坑,这里也不可能太详尽,凑活看,想要看更详尽的可以到 theia 项目中看相关代码:
- monaco-editor 接受一个主题,来负责对元素着色
- monaco-editor 接受一个语言解析器,来决定代码中的某一段是什么元素
- textmate 提供语言的语法配置,再加上一些 monaco-editor 的语言支持,生成 grammarProvider
- textmate 注册 TextmateRegistry,然后挂载不同语言的 grammarProvider 保存解析配置之类的
- 使用 onigasm 注册语法解析器 grammarRegistry,然后创建不同语言实际的解析器 TextmateTokenizer
- monaco 的 monaco.languages.setTokensProvider 使用 TextmateTokenizer 解析不同语言语法
- 完成整个语法解析的任务
- vscode 的高亮颜色之类的转换一下能被 monaco-editor 直接使用
- 语法解析 + 语法高亮完成
流程比较简单,但是其中涉及的逻辑还是比较多的,仅仅 grammarProvider 就涉及到每个语言的注册和实现,TextmateRegistry 也有很多的声明获取、配置读取、解析等流程。
项目中进行的划分,不是必须的:
name | desc |
---|---|
onigasm | 用来提供 textmate 解析内核 onig 的加载和引用 |
vscode-textmate | 用来提供语法解析、语法注册器、主题注册器等,vscode 实现的 textmate 相关核心功能 |
textmate-registry | 用来提供不同语言的加载、配置、注册功能 |
languages | 不同语言的具体解析功能、配置和语法设置等 |
textmate-tokenizer | 封装词法分析的接口,让 monaco 能够使用 textmate 的解析器 |
theme-registry | 提供主题的转换、加载、管理、切换 |
先找找 html.tmLanguage.json 类似的 textmate 语法文件,这个比较重要,是语法解析的配置文件。一般可以在 https://github.com/textmate/html.tmbundle 类似的地址中拿到。
html.tmLanguage.json
这个文件没办法直接使用,需要通过 vscode-textmate 再进行转换才能给 monaco-editor 使用。而且仅有这个文件还不够,还需要针对 monaco-editor 再设置一些额外的动作,比如 语言后缀、自动关闭、折叠等逻辑:
// 语法支持的统一接口export interface LanguageGrammarDefinitionContribution {registerTextmateLanguage(registry: TextmateRegistry): void;}export interface GrammarDefinition {format: 'json' | 'plist';content: object | string;location?: string;}
然后是具体实现,更多的实现可以在 Theia 项目中找到,这里有一些整理和抽象。因为具体每个语言的实现没办法花精力去做,很不现实,直接拿来用了(EPL v2 开源证书,用的话需要注意):
import { LanguageGrammarDefinitionContribution, GrammarDefinition } from '../';import { TextmateRegistry } from '@extension/Writer/textmate/textmate-registry';const EMPTY_ELEMENTS: string[] = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'menuitem', 'meta', 'param', 'source', 'track', 'wbr'];export class HtmlContribution implements LanguageGrammarDefinitionContribution {readonly id = 'html';readonly scopeName = 'text.html.basic';registerTextmateLanguage(registry: TextmateRegistry): void {monaco.languages.register({id: this.id,extensions: ['.html', '.htm', '.shtml', '.xhtml', '.mdoc', '.jsp', '.asp', '.aspx', '.jshtm'],aliases: ['HTML', 'htm', 'html', 'xhtml'],mimetypes: ['text/html', 'text/x-jshtm', 'text/template', 'text/ng-template'],});monaco.languages.setLanguageConfiguration(this.id, {wordPattern: /(-?\d*\.\d\w*)|([^\`\~\!\@\$\^\&\*\(\)\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\s]+)/g,comments: {blockComment: ['<!--', '-->']},brackets: [['<!--', '-->'],['<', '>'],['{', '}'],['(', ')']],autoClosingPairs: [{ open: '{', close: '}' },{ open: '[', close: ']' },{ open: '(', close: ')' },{ open: '"', close: '"' },{ open: '\'', close: '\'' }],surroundingPairs: [{ open: '"', close: '"' },{ open: '\'', close: '\'' },{ open: '{', close: '}' },{ open: '[', close: ']' },{ open: '(', close: ')' },{ open: '<', close: '>' },],onEnterRules: [{beforeText: new RegExp(`<(?!(?:${EMPTY_ELEMENTS.join('|')}))([_:\\w][_:\\w-.\\d]*)([^/>]*(?!/)>)[^<]*$`, 'i'),afterText: /^<\/([_:\w][_:\w-.\d]*)\s*>$/i,action: { indentAction: monaco.languages.IndentAction.IndentOutdent }},{beforeText: new RegExp(`<(?!(?:${EMPTY_ELEMENTS.join('|')}))(\\w[\\w\\d]*)([^/>]*(?!/)>)[^<]*$`, 'i'),action: { indentAction: monaco.languages.IndentAction.Indent }}],folding: {markers: {start: new RegExp('^\\s*<!--\\s*#region\\b.*-->'),end: new RegExp('^\\s*<!--\\s*#endregion\\b.*-->')}}});const grammar = require('../data/html.tmLanguage.json');registry.registerTextmateGrammarScope(this.scopeName, {async getGrammarDefinition(): Promise<GrammarDefinition> {return {format: 'json',content: grammar};}});registry.mapLanguageIdToTextmateGrammar(this.id, this.scopeName);}}
有一些因为篇幅问题没有写在这,其他语言的配置也是类似的,甚至更加复杂,我项目中引用了 41 个语言配置文件。
// 这里导出实例提供给 textmate 使用export const languageContributions = [new HtmlContribution(),// ......]
PS: 这些逻辑在 theia 的项目中找到的,这里有抽象整合之类的不另说了~~
// 如果不支持 WebAssembly,那么什么都没有了if (typeof (window as any).WebAssembly === 'undefined') {logger.error('Textmate support deactivated because WebAssembly is not detected.')throw Error();}// 初始化一个 textmate 注册器,各个语言挂载用const textmateRegistry = new TextmateRegistry();// 将全部语言配置注册到 textmate 中,但是还不能直接使用for (const grammarProvider of languageContributions) {try {grammarProvider.registerTextmateLanguage(textmateRegistry);} catch (err) {console.error(err);}}// onigasm 加载过来const onigasmPromise = fetchOnigasm().then(async buffer => {await loadWASM(buffer);return new OnigasmLib();});// 初始化一个注册器,整个语法支持的核心。包含了 主题、语法加载、语法注入之类的功能grammarRegistry = new Registry({getOnigLib: () => onigasmPromise,theme: monacoThemeRegistry.getTheme(currentEditorTheme),loadGrammar: async (scopeName: string) => {const provider = textmateRegistry.getProvider(scopeName);if (provider) {const definition = await provider.getGrammarDefinition();let rawGrammar: IRawGrammar;if (typeof definition.content === 'string') {rawGrammar = parseRawGrammar(definition.content, definition.format === 'json' ? 'grammar.json' : 'grammar.plist');} else {rawGrammar = definition.content as IRawGrammar;}return rawGrammar;}return undefined;},getInjections: (scopeName: string) => {const provider = textmateRegistry.getProvider(scopeName);if (provider && provider.getInjections) {return provider.getInjections(scopeName);}return [];}});// --------- 下面是 onigasm 的加载// onigasm 的接口实现function fetchOnigasm(): Promise<ArrayBuffer> {return new Promise((resolve, reject) => {const onigasmPath = require('onigasm/lib/onigasm.wasm'); // webpack doing its magic hereconst request = new XMLHttpRequest();request.onreadystatechange = function (): void {if (this.readyState === XMLHttpRequest.DONE) {if (this.status === 200) {logger.log('语法高亮分析模块 onigasm 下载成功.')resolve(this.response);} else {logger.error('textmate onigasm file downloaded failed.')reject(new Error('Could not fetch onigasm'));}}};request.open('GET', onigasmPath, true);request.responseType = 'arraybuffer';request.send();});}class OnigasmLib implements IOnigLib {createOnigScanner(sources: string[]): OnigScanner {return new OnigScanner(sources);}createOnigString(sources: string): OnigString {return new OnigString(sources);}}
至此我们完成了基本的 textmate 几个重要的步骤,这些步骤很难在网上找到,相关逻辑一般也不是特别清楚,好在最终还是实现了 textmate 内核的加载。
// 直接拷贝 VSCode 的主题配置const dark_plus = require('./theme/vscode/dark_plus.json')const dark_defaults = require('./theme/vscode/dark_defaults.json')const dark_vs = require('./theme/vscode/dark_vs.json')const light_plus = require('./theme/vscode/light_plus.json')const light_defaults = require('./theme/vscode/light_defaults.json')const light_vs = require('./theme/vscode/light_vs.json')// 简单实现一个主题管理功能(MonacoThemeRegistry 是 theia 中的一个实现,直接拿来用了)export const monacoThemeRegistry = new MonacoThemeRegistry();export const DARK_DEFAULT_THEME: string = monacoThemeRegistry.register(dark_plus,{'./dark_defaults.json': dark_defaults,'./dark_vs.json': dark_vs},'dark-plus','vs-dark').name!;const currentEditorTheme = DARK_DEFAULT_THEME// 将默认的主题应用document.body.classList.add(currentEditorTheme);monaco.editor.setTheme(currentEditorTheme);
这块不细说了,将 VSCode 的主题文件简单转换为 monaco-editor 的主题。
使用到了那个语言,再去注册具体的语法解析,更好些:
// 在用到对应语言才会激活对应的语言for (const { id } of monaco.languages.getLanguages()) {monaco.languages.onLanguage(id, () => activateLanguage(id));}
具体的语法分析实例化:
// 激活语言支持const _activatedLanguages = new Set<string>();const activateLanguage = async (languageId: string): Promise<void> => {// 缓存已经打开的语言if (_activatedLanguages.has(languageId)) return;_activatedLanguages.add(languageId);// 看 textmate 是否注册了对应的语法const scopeName = textmateRegistry.getScope(languageId);if (!scopeName) return;// 看 textmate 是否提供了了对应的 providerconst provider = textmateRegistry.getProvider(scopeName);if (!provider) return;// 获取语法配置const configuration = textmateRegistry.getGrammarConfiguration(languageId);const initialLanguage = monaco.languages.getEncodedLanguageId(languageId)await onigasmPromise;try {// 实例化一个语法分析器const grammar = await grammarRegistry.loadGrammarWithConfiguration(scopeName, initialLanguage, configuration);const options = configuration.tokenizerOption ? configuration.tokenizerOption : TokenizerOption.DEFAULT;// 将语法分析器挂载到 monaco-editor 提供解析服务monaco.languages.setTokensProvider(languageId, createTextmateTokenizer(grammar, options));} catch (error) {logger.warn('No grammar for this language id ' + languageId);console.warn(error);}logger.info('开启语言 ' + languageId + '支持')}
然后就可以尝试是否出现了 textmate 解析出来的语法高亮了
这块的实现刚开始非常艰难,知道 vscode-textmate,但是没有和 monaco-editor 结合,所以一头雾水。直到深入分析 theia 的源码才知道什么个流程和概念。
好在最后的结果还是很好的,相比 monaco-editor 自带的语法高亮简直高级感满满,和 VSCode 的高亮逻辑一模一样~~
感谢您的阅读,本文由 Ubug 版权所有。如若转载,请注明出处:Ubug(https://ubug.io/blog/workpad-part-5)