我們是袋鼠雲數棧 UED 團隊,致力於打造優秀的一站式資料中臺產品。我們始終保持工匠精神,探索前端道路,為社群積累並傳播經驗價值。
本文作者:文長
前言
在 Web IDE 中,控制檯中展示日誌是至關重要的功能。Monaco Editor 作為一個強大的程式碼編輯器,提供了豐富的功能和靈活的 API ,支援為內容進行“裝飾”,非常適合用來構建日誌展示器。如下圖:
除了實時日誌外,還有一些需要檢視歷史日誌的場景。如下圖:
Monarch
Monarch 是 Monaco Editor 自帶的一個語法高亮庫,透過它,我們可以用類似 Json 的語法來實現自定義語言的語法高亮功能。這裡不做過多的介紹,只介紹在本文中使用到的那部分內容.
一個語言定義基本上就是描述語言的各種屬性的JSON
值,部分通用屬性如下:
- tokenizer
(必填項,帶狀態的物件)這個定義了tokenization
的規則。 Monaco Editor 中用於定義語言語法高亮和解析的一個核心元件。它的主要功能是將輸入的程式碼文字分解成一個個的 token,以便於編輯器能夠根據這些 token 進行語法高亮、錯誤檢查和其他編輯功能。 - ignoreCase
(可選項,預設值:false
)語言是否大小寫不敏感?tokenizer
(分詞器)中的正規表示式使用這個屬性去進行大小寫(不)敏感匹配,以及case
場景中的測試。 - brackets
(可選項,括號定義的陣列)tokenizer
使用這個來輕鬆的定義大括號匹配,更多資訊詳見 @brackets 和 bracket 部分。每個方括號定義都是一個由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 定義的第一個狀態,就是初始狀態。同理,如果把 afterIf
和 root
兩個狀態調換位置,那麼 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"
、"2"
等),這些屬性會按照數值的升序排列。 - 字串鍵:對於非整數的字串鍵,屬性的順序是按照它們被新增到物件中的順序。
- Symbol 鍵:如果屬性的鍵是 Symbol 型別,這些屬性會按照它們被新增到物件中的順序。
因此,當使用 for...in
迴圈遍歷物件的屬性時,屬性的順序如下:
- 首先是所有整數鍵,按升序排列。
- 然後是所有字串鍵,按新增順序排列。
- 最後是所有 Symbol 鍵,按新增順序排列。
看個例子:
上述例子可以看出,“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 狀態,並想要在看到某些結束標記時退出,但是不想在處於該狀態時使用它們,就可以使用這個。例如:
- "@rematch"
monaco.languages.setMonarchTokensProvider('myLanguage', {
tokenizer: {
root: [
[/\d+/, 'number', 'word'],
],
word: [
[/\d/, '@rematch', '@pop'],
[/[^\d]+/, 'string']
]
}
});
這個 language 的狀態流轉圖是怎麼樣的呢?
可以看出,在定義一個狀態時,應保證狀態存在出口即沒有定義轉移到其他狀態的規則),否則可能會導致死迴圈,不斷的使用狀態內的規則去匹配。
- "@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",
},
];
狀態流轉圖:
普通日誌
普通日誌與實時日誌有些許不同,他是的日誌型別是不展示出來的,沒有一個起始/結束
識別符號供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",
},
];
狀態流轉圖:
其他
在 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 想到一個思路:
- 在生成文字時,在需要展示為 a 元素的地方使用
#link#${JSON.stringify(attrs)}#link#
包裹,attrs 是一個物件,其中包含了 a 元素的attribute
。 - 在文字內容傳遞給 Monaco Editor 之前,解析文字的內容,利用正則將
a 元素標記
匹配出來,使用attrs
的連結文字
替換標記文字
,並記錄替換後連結文字
在文字內容中的索引位置。利用 Monaco Editor 的getPositionAt
獲取連結文字在編輯器中的位置(起始/結束行列資訊),生成Range
。 - 使用一個容器收集對應的日誌中的
Link
資訊。在透過 linkProvider 將編輯器中對應的連結文字
識別為連結高亮。 - 給 editor 例項繫結點選事件
onMouseDown
,如果點選的內容位置在收集的 Link 中時,觸發對外提供的自定義連結點選事件。
根據這一思路進行實現:
- 生成 a 元素標記。
interface IAttrs {
attrs: Record<string, string>;
props: {
innerHTML: string;
};
}
/**
*
* @param attrs
* @returns
*/
export function createLinkMark(attrs: IAttrs) {
return `#link#${JSON.stringify(attrs)}#link#`;
}
- 解析文字內容
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;
}
- 使用一個容器儲存解析出來的 link
const value = `這是一串帶連結的文字:${createLinkMark({
props: {
innerHTML: '連結a'
},
attrs: {
href: 'http://www.abc.com'
}
})}`
const links = getLinkMark(value)
- 利用儲存的 links 註冊 LinkProvider
languages.registerLinkProvider('taskLog', {
provideLinks() {
return { links: links || [] };
},
});
- 繫結自定義事件
在點選 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]);
缺點:在日誌實時列印時,出現連結不會立馬高亮,需要等一會才會變成連結高亮。
參考
- Monarch
最後
歡迎關注【袋鼠雲數棧UED團隊】~
袋鼠雲數棧 UED 團隊持續為廣大開發者分享技術成果,相繼參與開源了歡迎 star
- 大資料分散式任務排程系統——Taier
- 輕量級的 Web IDE UI 框架——Molecule
- 針對大資料領域的 SQL Parser 專案——dt-sql-parser
- 袋鼠雲數棧前端團隊程式碼評審工程實踐文件——code-review-practices
- 一個速度更快、配置更靈活、使用更簡單的模組打包器——ko
- 一個針對 antd 的元件測試工具庫——ant-design-testing