Monaco Editor 實現一個日誌檢視器

袋鼠云数栈前端發表於2024-10-16

我們是袋鼠雲數棧 UED 團隊,致力於打造優秀的一站式資料中臺產品。我們始終保持工匠精神,探索前端道路,為社群積累並傳播經驗價值。

本文作者:文長

前言

在 Web IDE 中,控制檯中展示日誌是至關重要的功能。Monaco Editor 作為一個強大的程式碼編輯器,提供了豐富的功能和靈活的 API ,支援為內容進行“裝飾”,非常適合用來構建日誌展示器。如下圖:
file

除了實時日誌外,還有一些需要檢視歷史日誌的場景。如下圖:
file

Monarch

Monarch 是 Monaco Editor 自帶的一個語法高亮庫,透過它,我們可以用類似 Json 的語法來實現自定義語言的語法高亮功能。這裡不做過多的介紹,只介紹在本文中使用到的那部分內容.

一個語言定義基本上就是描述語言的各種屬性的JSON值,部分通用屬性如下:

  • tokenizer
    (必填項,帶狀態的物件)這個定義了tokenization的規則。 Monaco Editor 中用於定義語言語法高亮和解析的一個核心元件。它的主要功能是將輸入的程式碼文字分解成一個個的 token,以便於編輯器能夠根據這些 token 進行語法高亮、錯誤檢查和其他編輯功能。
  • ignoreCase
    (可選項,預設值:false)語言是否大小寫不敏感?tokenizer(分詞器)中的正規表示式使用這個屬性去進行大小寫(不)敏感匹配,以及case場景中的測試。
  • brackets
    (可選項,括號定義的陣列)tokenizer使用這個來輕鬆的定義大括號匹配,更多資訊詳見 @bracketsbracket 部分。每個方括號定義都是一個由3個元素或物件組成的陣列,描述了open左大括號close右大括號token令牌類。預設定義如下:
[ ['{','}','delimiter.curly'],
['[',']','delimiter.square'],
['(',')','delimiter.parenthesis'],
['<','>','delimiter.angle'] ]

tokenizer

tokenizer 屬性描述瞭如何進行詞法分析,以及如何將輸入轉換成 token ,每個 token 都會被賦予一個 css 類名,用於在編輯器中渲染,內建的 css token 包括:

identifier         entity           constructor
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,透過以下方式將自定義的 css token 注入。

editor.defineTheme("vs", {
  base: "vs",
  inherit: true,
  rules: [
    {
      token: "token-name",
      foreground: "#117700",
    }
  ],
  colors: {},
});

一個 tokenizer 由一個描述狀態的物件組成。tokenizer 的初始狀態由 tokenizer 定義的第一個狀態決定。這句話什麼意思呢?檢視下方例子,root就是 tokenizer 定義的第一個狀態,就是初始狀態。同理,如果把 afterIfroot 兩個狀態調換位置,那麼 afterIf 就是初始狀態。

monaco.languages.setMonarchTokensProvider('myLanguage', {
    tokenizer: {
        root: [
            // 初始狀態的規則
            [/\d+/, 'number'], // 識別數字
            [/\w+/, 'keyword'], // 識別關鍵字
            // 轉移到下一個狀態
            [/^if$/, { token: 'keyword', next: 'afterIf' }],
        ],
        afterIf: [
            // 處理 if 語句後的內容
            [/\s+/, ''], // 忽略空白
            [/[\w]+/, 'identifier'], // 識別識別符號
            // 返回初始狀態
            [/;$/, { token: '', next: 'root' }],
        ]
    }
});

如何獲取 tokenizer 定義的第一個狀態呢?

class MonarchTokenizer {
  ...
  public getInitialState(): languages.IState {
    const rootState = MonarchStackElementFactory.create(null, this._lexer.start!);
    return MonarchLineStateFactory.create(rootState, null);
  }
  ...
}

透過 getInitialState 獲取初始的一個狀態,透過程式碼可以看到 確認哪個是初始狀態是透過 this._lexer.start 這個屬性。這個屬性又是怎麼被賦值的呢?

function compile() {
  ...
  for (const key in json.tokenizer) {
    if (json.tokenizer.hasOwnProperty(key)) {
      if (!lexer.start) {
        lexer.start = key;
      }
  
      const rules = json.tokenizer[key];
      lexer.tokenizer[key] = new Array();
      addRules('tokenizer.' + key, lexer.tokenizer[key], rules);
    }
	}
  ...
}

在 compile 解析 setMonarchTokensProvider 傳入的語言定義物件時,會將讀取出來的第一個 key 作為初始狀態。可能會有疑問,就一定能保證在定義物件時,寫入的第一個屬性,在讀取時一定第一個被讀出嗎?

在 JavaScript 中,物件屬性的順序有一些特定的規則:

  1. 整數鍵:如果屬性名是一個整數(如 "1""2" 等),這些屬性會按照數值的升序排列。
  2. 字串鍵:對於非整數的字串鍵,屬性的順序是按照它們被新增到物件中的順序。
  3. Symbol 鍵:如果屬性的鍵是 Symbol 型別,這些屬性會按照它們被新增到物件中的順序。

因此,當使用 for...in 迴圈遍歷物件的屬性時,屬性的順序如下:

  • 首先是所有整數鍵,按升序排列。
  • 然後是所有字串鍵,按新增順序排列。
  • 最後是所有 Symbol 鍵,按新增順序排列。

看個例子:

file

上述例子可以看出,“1”、“2”雖然被寫在了後面,但仍然會被排序優先輸出,其後才是字串鍵根據新增順序輸出。所以,儘可能不要使用整數鍵去定義狀態名。

當 tokenizer 處於某種狀態時,只有那個狀態的規則才能匹配。所有規則是按順序進行匹配的,當匹配到第一個規則時,它的 action 將被用來確定 token 的型別。不會再使用後面的規則進行嘗試,因此,以一種最有效的方式排列規則是很重要的。比如空格和識別符號優先。

如何定義一個狀態?

每個狀態定義為一個用於匹配輸入的規則陣列,規則可以有如下形式:

  • [regex, action]
    {regex: regex, action: action}形式的簡寫。
  • [regex, action, next]
    { regex: regex, action: action{ next: next} }形式的簡寫。
monaco.languages.setMonarchTokensProvider('myLanguage', {
    tokenizer: {
        root: [
            // [regex, action]
            [/\d+/, 'number'], 
            /** 
             * [regex, action, next]
             * [/\w+/, { token: 'keyword', next: '@pop' }] 的簡寫
             */
            [/\w+/, 'keyword', '@pop'], 
        ]
    }
});

regex 是正規表示式,action 分為以下幾種:

  • string
    { token: string } 的簡寫
  • [action, ..., actionN]
    多個 action 組成的陣列。這僅在正規表示式恰好由 N 個組(即括號部分)組成時才允許。舉個例子:
[/(\d)(\d)(\d)/, ['string', 'string', 'string']
  • { token: tokenClass }
    這個 tokenClass 可以是內建的 css token,也可以是自定義的 token。同時,還規定了一些特殊的 token 類:
    • "@rematch"
      備份輸入並重新呼叫 tokenizer 。這隻在狀態發生變化時才有效(或者我們進入了無限的遞迴),所以這個通常和 next 屬性一起使用。例如,當你處於特定的 tokenizer 狀態,並想要在看到某些結束標記時退出,但是不想在處於該狀態時使用它們,就可以使用這個。例如:
monaco.languages.setMonarchTokensProvider('myLanguage', {
    tokenizer: {
        root: [
            [/\d+/, 'number', 'word'],
        ],
        word: [
            [/\d/, '@rematch', '@pop'],
            [/[^\d]+/, 'string']
        ]
    }
});

這個 language 的狀態流轉圖是怎麼樣的呢?

file

可以看出,在定義一個狀態時,應保證狀態存在出口即沒有定義轉移到其他狀態的規則),否則可能會導致死迴圈,不斷的使用狀態內的規則去匹配。

  • "@pop"
    彈出 tokenizer 棧以返回到之前的狀態。
  • "@push"
    推入當前狀態,並在當前狀態中繼續。
monaco.languages.setMonarchTokensProvider('myLanguage', {
    tokenizer: {
    root: [
      // 當匹配到開始標記時,推送新的狀態
      [/^\s*function\b/, { token: 'keyword', next: '@function' }],
    ],
    function: [
      // 在 function 狀態下的匹配規則
      [/^\s*{/, { token: 'delimiter.bracket', next: '@push' }],
      [/[^}]+/, 'statement'],
      [/^\s*}/, { token: 'delimiter.bracket', next: '@pop' }],
    ],
  }
});
- $n  

匹配輸入的第n組,或者是$0代表這個匹配的輸入。
- $Sn
狀態的第 n 個部分,比如,狀態 @tag.foo,用 $S0 代表整個狀態名(即 tag.foo ),$S1 返回 tag,$S2 返回 foo 。

實時日誌

在本篇文章中,Monaco Editor 的使用就不再提及,不是本文的重點。利用 Monaco Editor 實現日誌檢視器主要是為了讓不同的型別的日誌有不同的高亮主題。

實時日誌中,存在不同的日誌型別,如:info、error、warning 等。

/**
 * 日誌構造器
 * @param {string} log 日誌內容
 * @param {string} type 日誌型別
 */
export function createLog(log: string, type = '') {
    let now = moment().format('HH:mm:ss');
    if (process.env.NODE_ENV == 'test') {
        now = 'test';
    }
    return `[${now}] <${type}> ${log}`;
}

根據日誌可以看出,每條日誌都是[xx:xx:xx]開頭,緊跟著 <日誌型別>,後面的是日誌內容。(日誌型別:info 、error、warning。)

註冊一個自定義語言 realTimeLog 作為實時日誌的一個 language

這裡規則也很簡單,在 root 中設定了兩條解析規則,分別是匹配日誌日期和日誌型別。在匹配到對應的日誌型別後,給匹配到的內容打上 token ,然後透過 next 攜帶匹配的引用標識( $1 表示正則分組中的第1組)進入下一個狀態 consoleLog,在狀態consoleLog 中,匹配日誌內容,並打上 token ,直到遇見終止條件(日誌日期)。

import { languages } from "monaco-editor/esm/vs/editor/editor.api";
import { LanguageIdEnum } from "./constants";

languages.register({ id: LanguageIdEnum.REALTIMELOG });

languages.setMonarchTokensProvider(LanguageIdEnum.REALTIMELOG, {
  keywords: ["error", "warning", "info", "success"],
  date: /\[[0-9]{2}:[0-9]{2}:[0-9]{2}\]/,
  tokenizer: {
    root: [
      [/@date/, "date-token"],
      [
        /<(\w+)>/,
        {
          cases: {
            "$1@keywords": { token: "$1-token", next: "@log.$1" },
            "@default": "string",
          },
        },
      ],
    ],
    log: [
      [/@date/, { token: "@rematch", next: "@pop" }],
      [/.*/, { token: "$S2-token" }],
    ],
  },
});

// ===== 日誌樣式 =====
export const realTimeLogTokenThemeRules = [
  {
    token: "date-token",
    foreground: "#117700",
  },
  {
    token: "error-token",
    foreground: "#ff0000",
    fontStyle: "bold",
  },
  {
    token: "info-token",
    foreground: "#999977",
  },
  {
    token: "warning-token",
    foreground: "#aa5500",
  },
  {
    token: "success-token",
    foreground: "#669600",
  },
];

狀態流轉圖:

file

普通日誌

普通日誌與實時日誌有些許不同,他是的日誌型別是不展示出來的,沒有一個起始/結束識別符號供Monarch高亮規則匹配。所以需要一個在文字中不展示,又能作為起始/結束的識別符號。

也確實存在這麼一個東西,不佔寬度,又能被匹配——“零寬字元”。

零寬字元(Zero Width Characters)是指在文字中佔用零寬度的字元,通常用於特定的文字處理或編碼目的。它們在視覺上不可見,但在程式處理中可能會產生影響。

利用零寬字元建立不同日誌型別的標識。

// 使用零寬字元作為不同型別的日誌標識
// U+200B
const ZeroWidthSpace = '';
// U+200C
const ZeroWidthNonJoiner = '‌';
// U+200D
const ZeroWidthJoiner = '‍';
// 不同型別日誌的起始 / 結束標識,用於 Monarch 語法檔案的解析
const jobTag = {
    info: `${ZeroWidthSpace}${ZeroWidthNonJoiner}${ZeroWidthSpace}`,
    warning: `${ZeroWidthNonJoiner}${ZeroWidthSpace}${ZeroWidthNonJoiner}`,
    error: `${ZeroWidthJoiner}${ZeroWidthNonJoiner}${ZeroWidthJoiner}`,
    success: `${ZeroWidthSpace}${ZeroWidthNonJoiner}${ZeroWidthJoiner}`,
};

之後的編寫語法高亮規則,與實時日誌相同。

import { languages } from "monaco-editor/esm/vs/editor/editor.api";
import { LanguageIdEnum } from "./constants";

languages.register({ id: LanguageIdEnum.NORMALLOG });

languages.setMonarchTokensProvider(LanguageIdEnum.NORMALLOG, {
  info: /\u200b\u200c\u200b/,
  warning: /\u200c\u200b\u200c/,
  error: /\u200d\u200c\u200d/,
  success: /\u200b\u200c\u200d/,
  tokenizer: {
    root: [
      [/@success/, { token: "success-token", next: "@log.success" }],
      [/@error/, { token: "error-token", next: "@log.error" }],
      [/@warning/, { token: "warning-token", next: "@log.warning" }],
      [/@info/, { token: "info-token", next: "@log.info" }],
    ],
    log: [
      [
        /@info|@warning|@error|@success/,
        { token: "$S2-token", next: "@pop" },
      ],
      [/.*/, { token: "$S2-token" }],
    ],
  },
});

// ===== 日誌樣式 =====
export const normalLogTokenThemeRules = [
  {
    token: "error-token",
    foreground: "#BB0606",
    fontStyle: "bold",
  },
  {
    token: "info-token",
    foreground: "#333333",
    fontStyle: "bold",
  },
  {
    token: "warning-token",
    foreground: "#EE9900",
  },
  {
    token: "success-token",
    foreground: "#669600",
  },
];

狀態流轉圖:

file

其他

在 Monaco Editor 中支援a元素

Monaco Editor 本身是不支援在內容中插入 HTML 元素的,原生只支援對連結進行高亮,並且支援cmd + 點選開啟連結。但仍可能會存在需要實現類似a元素的效果。

另闢蹊徑,查詢 Monaco Editor 的 API 後,linkProvider 也許可以大致滿足,但仍有不足。

以下是介紹:

在 Monaco Editor 中,linkProvider 是一個用於提供連結功能的介面。它允許開發者為編輯器中的特定文字或程式碼片段提供連結,當使用者懸停或點選這些連結時,可以執行特定的操作,比如開啟文件、跳轉到定義等。

具體用法:

const linkProvider = {
    provideLinks: function(model, position) {
        // 返回連結陣列
        return [
            {
                range: new monaco.Range(1, 1, 1, 5), // 連結的範圍
                url: 'https://example.com', // 連結的 URL
                tooltip: '點選訪問示例' // 懸停提示
            }
        ];
    }
};
monaco.languages.registerLinkProvider('javascript', linkProvider);

它是針對已註冊的語言進行註冊,不會影響到其他語言。在文字內容發生變化時就會觸發 provideLinks

根據這個 API 想到一個思路:

  1. 在生成文字時,在需要展示為 a 元素的地方使用 #link#${JSON.stringify(attrs)}#link# 包裹,attrs 是一個物件,其中包含了 a 元素的attribute
  2. 在文字內容傳遞給 Monaco Editor 之前,解析文字的內容,利用正則將a 元素標記匹配出來,使用 attrs連結文字替換標記文字,並記錄替換後連結文字在文字內容中的索引位置。利用 Monaco Editor 的 getPositionAt 獲取連結文字在編輯器中的位置(起始/結束行列資訊),生成 Range
  3. 使用一個容器收集對應的日誌中的 Link 資訊。在透過 linkProvider 將編輯器中對應的連結文字識別為連結高亮。
  4. 給 editor 例項繫結點選事件 onMouseDown ,如果點選的內容位置在收集的 Link 中時,觸發對外提供的自定義連結點選事件。

根據這一思路進行實現:

  1. 生成 a 元素標記。
interface IAttrs {
    attrs: Record<string, string>;
    props: {
        innerHTML: string;
    };
}
/**
 *
 * @param attrs
 * @returns
 */
export function createLinkMark(attrs: IAttrs) {
    return `#link#${JSON.stringify(attrs)}#link#`;
}
  1. 解析文字內容
getLinkMark(value: string, key?: string) {
    if (!value) return value;
    const links: ILink[] = [];

    const logRegexp = /#link#/g;
    const splitPoints: any[] = [];
    let indexObj = logRegexp.exec(value);
    /**
     * 1. 正則匹配相應的起始 / 結束標籤 #link# , 兩兩為一組
     */
    while (indexObj) {
        splitPoints.push({
            index: indexObj.index,
            0: indexObj[0],
            1: indexObj[1],
        });
        indexObj = logRegexp.exec(value);
    }

    /**
     * 2. 根據步驟 1 獲取的 link 標記範圍,處理日誌內容,並收集 link 資訊
     */
      /** l為起始標籤,r為結束標籤 */
    let l = splitPoints.shift();
    let r = splitPoints.shift();
    /** 字串替換中移除字元個數 */
    let cutLength = 0;
    let processedString = value;
    /** link 資訊集合 */
    const collections:[number, number, string, ILink['attrs']][] = [];
    while (l && r) {
      const infoStr = value.slice(l.index + r[0].length, r.index);
      const info = JSON.parse(infoStr);
      /**
       * 手動補一個空格是由於後面沒有內容,導致點選連結後面的空白處,游標也是在連結上的,
       * 導致當前的range也在link的range中,觸發自定義點選事件
       */
      const splitStr = info.props.innerHTML + ' ';

      /** 將 '#link#{"attrs":{"href":"xxx"},"props":{"innerHTML":"logDownload"}}#link#' 替換為 innerHTML 中的文字 */
      processedString =
          processedString.slice(0, l.index - cutLength) +
          splitStr +
          processedString.slice(r.index + r[0].length - cutLength);
      collections.push([
          /** 連結的開始位置 */
          l.index - cutLength,
          /** 連結的結束位置 */
          l.index + splitStr.length - cutLength - 1,
          /** 連結地址 */
          info.attrs.href,
          /** 工作流中應用,點選開啟子任務tab */
          info.attrs,
      ]);

      /** 記錄文字替換過程中,替換文字和原文字的差值 */
      cutLength += infoStr.length - splitStr.length + r[0].length * 2;
      l = splitPoints.shift();
      r = splitPoints.shift();
    }
  
    /**
     * 3. 處理收集的 link 資訊
     */
    const model = editor.createModel(processedString, 'xxx');
    for (const [start, end, url, attrs] of collections) {
        const startPosition = model.getPositionAt(start);
        const endPosition = model.getPositionAt(end);

        links.push({
            range: new Range(
                startPosition.lineNumber,
                startPosition.column,
                endPosition.lineNumber,
                endPosition.column
            ),
            url,
            attrs,
        });
    }
    model.dispose();

    return processedString;
}
  1. 使用一個容器儲存解析出來的 link
const value = `這是一串帶連結的文字:${createLinkMark({
  props: {
    innerHTML: '連結a'
  },
  attrs: {
    href: 'http://www.abc.com'
  }
})}`
const links = getLinkMark(value)
  1. 利用儲存的 links 註冊 LinkProvider
languages.registerLinkProvider('taskLog', {
    provideLinks() {
        return { links: links || [] };
    },
});
  1. 繫結自定義事件
    在點選 editor 中的內容時都會觸發onMouseDown,在其中可以獲取當前點選位置的Range資訊,迴圈遍歷收集的所有 Link,判斷當前點選位置的Range是否在其中。containsRange方法可以判斷一個Range是否在另一個Range中。
useEffect(() => {
  const disposable = logEditorInstance.current?.onMouseDown((e) => {
      const curRange = e.target.range;
      if (curRange) {
        const link = links.find((e) => {
          return (e.range as Range)?.containsRange(curRange);
        });
        if (link) {
          onLinkClick?.(link);
        }
      }
    });

  return () => {
    disposable?.dispose();
  };
}, [logEditorInstance.current]);

缺點:在日誌實時列印時,出現連結不會立馬高亮,需要等一會才會變成連結高亮。

file

參考

  • Monarch

最後

歡迎關注【袋鼠雲數棧UED團隊】~
袋鼠雲數棧 UED 團隊持續為廣大開發者分享技術成果,相繼參與開源了歡迎 star

  • 大資料分散式任務排程系統——Taier
  • 輕量級的 Web IDE UI 框架——Molecule
  • 針對大資料領域的 SQL Parser 專案——dt-sql-parser
  • 袋鼠雲數棧前端團隊程式碼評審工程實踐文件——code-review-practices
  • 一個速度更快、配置更靈活、使用更簡單的模組打包器——ko
  • 一個針對 antd 的元件測試工具庫——ant-design-testing

相關文章