ByteMD 插件系統詳解
ByteMD 的插件系統是其強大擴展性的核心。它允許開發者在 Markdown 解析、AST 轉換、HTML 渲染、以及編輯器 UI 交互的各個階段注入自定義邏輯。這得益于 ByteMD 深度集成了 unified
處理器和其豐富的生態系統(remark
用于 Markdown,rehype
用于 HTML)。
1. 插件的本質
一個 ByteMD 插件是一個返回 BytemdPlugin
接口對象的函數。這個對象包含了多個可選的鉤子 (Hooks),每個鉤子都對應 ByteMD 內部處理流程的不同階段。
BytemdPlugin
接口的主要成員:
-
viewerEffect?(el: HTMLElement): BytemdViewerContext | void
:- 時機: 當
Viewer
組件渲染完成并掛載到 DOM 后執行。 - 參數:
el
是Viewer
的根 DOM 元素。 - 用途: 適合用于對最終渲染的 HTML 內容進行 DOM 操作,例如添加事件監聽器、初始化第三方 JS 庫(如流程圖渲染庫)、或者對圖片進行懶加載處理等。
- 返回值: 可以返回一個包含
destroy?: () => void
方法的對象,當Viewer
卸載時,會調用destroy
方法進行清理。
- 時機: 當
-
editorEffect?(editor: Editor): BytemdEditorContext | void
:- 時機: 當
Editor
組件初始化完成(通常是 CodeMirror/ProseMirror 實例創建后)并掛載到 DOM 后執行。 - 參數:
editor
是CodeMirror
的實例(如果 ByteMD 內部使用 CodeMirror)。這個實例提供了對編輯器核心功能的直接訪問,例如獲取/設置內容、插入文本、注冊快捷鍵等。 - 用途: 適合用于與編輯器本身進行低級別交互,例如自定義快捷鍵、實現圖片拖拽上傳、自定義粘貼行為、或者在編輯器內容變化時觸發額外邏輯。
- 返回值: 同樣可以返回一個包含
destroy?: () => void
方法的對象,用于編輯器卸載時的清理。
- 時機: 當
-
remark?(processor: RemarkProcessor): RemarkProcessor
:- 時機: 在 Markdown 文本被
remark-parse
解析為 MDAST (Markdown Abstract Syntax Tree) 之后,但在轉換為 HAST 之前。 - 參數:
processor
是一個remark
處理器實例。 - 用途: 這是處理 Markdown 語法的核心鉤子。你可以通過
processor.use()
方法來注冊自定義的remark
插件。這些remark
插件會遍歷并修改 MDAST,例如:- 添加對 GFM (GitHub Flavored Markdown) 的支持(表格、任務列表)。
- 識別和處理自定義的 Markdown 語法(例如特殊的塊引用、自定義標簽)。
- 在 AST 級別進行內容轉換或驗證。
- 時機: 在 Markdown 文本被
-
rehype?(processor: RehypeProcessor): RehypeProcessor
:- 時機: 在 MDAST 被
remark-rehype
轉換為 HAST (Hypertext Abstract Syntax Tree) 之后,但在轉換為最終 HTML 字符串之前。 - 參數:
processor
是一個rehype
處理器實例。 - 用途: 這是處理 HTML 語法的核心鉤子。你可以通過
processor.use()
方法來注冊自定義的rehype
插件。這些rehype
插件會遍歷并修改 HAST,例如:- 為圖片添加
loading="lazy"
屬性。 - 處理代碼塊,添加行號或復制按鈕。
- 將數學公式的 AST 節點渲染為 KaTeX 或 MathJax。
- 在 HTML 級別進行內容轉換或優化。
- 為圖片添加
- 時機: 在 MDAST 被
-
actions?: BytemdAction[]
:- 時機: 在編輯器工具欄渲染時。
- 用途: 用于在 ByteMD 的工具欄中添加自定義按鈕。每個
BytemdAction
對象定義了按鈕的圖標、標題和點擊時的處理函數。這允許你為自定義功能提供用戶友好的界面。
-
i18n?: Record<string, string>
:- 用途: 提供插件內部文本的國際化支持。
-
override?: Partial<BytemdLocale>
:- 用途: 覆蓋 ByteMD 默認的國際化文本。
2. 插件的工作流
- 初始化: 當
BytemdEditor
或BytemdViewer
組件被實例化時,它會接收一個plugins
數組。 - 鉤子注冊: ByteMD 核心會遍歷這個
plugins
數組,收集每個插件返回對象中的所有鉤子(remark
,rehype
,editorEffect
,viewerEffect
,actions
等)。 - Markdown 解析與 AST 轉換:
- 當 Markdown 內容變化時,
unified
處理器被激活。 - 首先執行
remark-parse
將 Markdown 解析為 MDAST。 - 然后,所有注冊的
remark
插件會依次處理 MDAST。 - 接著,
remark-rehype
將 MDAST 轉換為 HAST。 - 之后,所有注冊的
rehype
插件會依次處理 HAST。 - 最后,
rehype-stringify
將 HAST 轉換為 HTML 字符串。
- 當 Markdown 內容變化時,
- UI 交互:
actions
鉤子定義的按鈕會被添加到工具欄,其handler
在點擊時執行。editorEffect
在編輯器初始化后執行,允許對 CodeMirror 實例進行操作。viewerEffect
在預覽器渲染 HTML 后執行,允許對渲染結果的 DOM 進行操作。
自定義插件示例:添加一個“插入日期時間”按鈕和高亮特定文本
我們將創建一個自定義插件,實現兩個功能:
- 工具欄按鈕: 在工具欄添加一個“插入日期時間”按鈕,點擊后在光標處插入當前日期時間。
- 高亮特定關鍵詞: 自動將 Markdown 中出現的特定關鍵詞(例如“重要”、“注意”)在預覽時用
<mark>
標簽高亮顯示。
1. 創建插件文件 (bytemd-plugin-custom.ts
)
// src/plugins/bytemd-plugin-custom.ts
import type { BytemdPlugin } from 'bytemd';
import type { RemarkPlugin } from 'unified';
import type { Node } from 'unist'; // MDAST/HAST 節點的通用類型
import { visit } from 'unist-util-visit'; // 遍歷 AST 的工具// 定義插件的選項(如果需要)
interface CustomPluginOptions {highlightKeywords?: string[];
}// Remark 插件:查找并標記需要高亮的文本
const remarkCustomHighlight: RemarkPlugin<[CustomPluginOptions?]> = (options) => {const keywords = options?.highlightKeywords || ['重要', '注意'];return (tree) => {visit(tree, 'text', (node: Node) => {// 確保是文本節點且有值if (typeof node.value === 'string') {let newValue = node.value;keywords.forEach(keyword => {// 使用正則表達式替換,以便處理多個出現和避免替換已經替換過的部分// 這里的替換比較簡單,如果涉及到復雜的嵌套或HTML實體,需要更復雜的AST操作newValue = newValue.replace(new RegExp(`(${keyword})`, 'g'),`==$1==` // CommonMark 規范中雙等號可以表示高亮(雖然不常用,但可以被rehype處理)// 或者自定義一個Markdown語法,例如 [[$1]],然后在rehype中處理);});node.value = newValue; // 更新節點值}});};
};// Rehype 插件:將特殊的 ==text== 標記轉換為 <mark> 標簽
// 這里我們需要處理 remark 階段插入的 ==...== 標記
// Bytemd 的 gfm 插件默認可能會處理 ==,如果沒有,則需要自定義rehype插件
// 實際上,更Robust的方式是remark插件創建自定義MDAST節點,然后rehype插件渲染它
const rehypeCustomHighlight: RemarkPlugin<[]> = () => (tree) => {visit(tree, { type: 'text' }, (node: Node) => {if (typeof node.value === 'string' && node.value.includes('==')) {// 匹配 ==text== 模式const parts = node.value.split(/(==[^=]+==)/g); // 分割字符串const newChildren = parts.flatMap(part => {if (part.startsWith('==') && part.endsWith('==')) {return {type: 'element',tagName: 'mark',properties: {},children: [{ type: 'text', value: part.slice(2, -2) }], // 移除 ==};}return { type: 'text', value: part };});// 替換當前文本節點為新的元素/文本節點數組// 這里需要更高級的unist-util-flatmap 或 unist-util-splice 等工具來替換節點// 簡單起見,這里直接修改當前節點的兄弟節點,但這不是標準做法// 在實際的unified插件中,通常是返回新的樹,或者替換當前節點// 為了演示方便,我們假設直接修改 text 節點的 value,并依賴rehype默認的HTML渲染// 但更嚴謹的自定義渲染,remark會創建custom node,rehype會識別并渲染// 由于bytemd-plugin-gfm包含了對 ==highlight== 的支持,這里可以直接利用// 如果bytemd默認不處理,我們需要構建一個真正的rehype插件來解析HTML文本// 假設 gfm 插件處理了 `==highlight==`,我們這里就不需要特殊的 rehype 轉換// 而是讓 remark 插件將文本轉換為類似 `==關鍵詞==` 的格式,讓 gfm 插件去渲染}});
};const bytemdPluginCustom = (options?: CustomPluginOptions): BytemdPlugin => {return {// 插件名稱,用于調試name: 'custom-plugin',// 工具欄動作actions: [{title: '插入日期時間',icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.11 0-1.99.9-1.99 2L3 19c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11zM7 10h5v5H7z"></path></svg>', // 一個簡單的日期時間圖標 SVGhandler: {type: 'action',click({ editor, appendBlock }) {const now = new Date();const year = now.getFullYear();const month = (now.getMonth() + 1).toString().padStart(2, '0');const day = now.getDate().toString().padStart(2, '0');const hours = now.getHours().toString().padStart(2, '0');const minutes = now.getMinutes().toString().padStart(2, '0');const seconds = now.getSeconds().toString().padStart(2, '0');const dateTimeString = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;editor.replaceSelection(dateTimeString); // 在光標處插入文本},},},],// Remark 插件:在 Markdown AST 階段處理remark: (processor) => processor.use(remarkCustomHighlight, options),// Rehype 插件:在 HTML AST 階段處理// 注意:如果 bytemd 的 plugin-gfm 已經支持 ==highlight==,這里就不需要額外的 rehype 插件了// 如果需要更復雜的自定義高亮,則可能需要編寫一個 `rehype` 插件來解析 `==` 或自定義語法// 為了簡化,我們假設 `plugin-gfm` 已經能把 `==text==` 渲染為 `<mark>`,// 所以這里的 rehypeCustomHighlight 暫時不啟用,或者只做調試用// rehype: (processor) => processor.use(rehypeCustomHighlight),};
};export default bytemdPluginCustom;
代碼解釋:
remarkCustomHighlight
(Remark 插件):- 接收
options
來配置要高亮的關鍵詞。 - 使用
unist-util-visit
遍歷 MDAST 中的所有text
節點。 - 對于每個文本節點,檢查是否包含任何關鍵詞。
- 如果包含,就將關鍵詞用
==...==
包裹起來。這是因為 ByteMD 的plugin-gfm
默認支持 CommonMark 的==highlight==
語法,它會被渲染為<mark>
標簽。這樣我們就直接利用了現有的渲染能力。
- 接收
rehypeCustomHighlight
(Rehype 插件 - 備用/調試):- 這里為了說明
rehype
插件的作用,提供了一個簡單的例子。 - 它的作用是遍歷 HAST 中的文本節點,查找
==...==
模式,并將其替換為mark
元素。 - 但在我們的例子中,如果
plugin-gfm
已經處理==highlight==
,這個rehype
插件就不是必須的。 實際應用中,rehype
插件更常用于添加額外的 HTML 屬性、修改已生成的 HTML 結構、或者處理一些remark
階段無法處理的 HTML 特性。
- 這里為了說明
bytemdPluginCustom
(ByteMD 插件):- 返回一個
BytemdPlugin
接口的對象。 name
: 插件的唯一標識。actions
: 定義了一個工具欄按鈕。title
: 按鈕的提示文本。icon
: 按鈕的 SVG 圖標。handler
: 點擊按鈕時執行的邏輯。editor.replaceSelection()
是 CodeMirror 提供的方法,用于在當前光標處插入或替換選中的文本。
remark
: 注冊了我們自定義的remarkCustomHighlight
插件,并傳遞了配置選項。
- 返回一個
2. 在 ByteMD 編輯器中使用自定義插件
將 bytemd-plugin-custom.ts
導入到你的 ByteMarkdownEditor.tsx
中,并將其添加到 plugins
數組。
// components/Editor/ByteMarkdownEditor.tsx
'use client';import React, { useState } from 'react';
import { Editor } from '@bytemd/react';
import gfm from '@bytemd/plugin-gfm';
import highlight from '@bytemd/plugin-highlight';
import math from '@bytemd/plugin-math';
import gemoji from '@bytemd/plugin-gemoji';
import frontmatter from '@bytemd/plugin-frontmatter';// 導入你的自定義插件
import bytemdPluginCustom from '../../plugins/bytemd-plugin-custom';// Import Bytemd styles
import 'bytemd/dist/index.css';
import 'highlight.js/styles/github.css'; // 代碼高亮主題
import 'katex/dist/katex.css'; // 數學公式樣式// 定義插件數組
const plugins = [gfm(), // 提供 ==highlight== 語法的支持highlight(),math(),gemoji(),frontmatter(),// 添加你的自定義插件bytemdPluginCustom({ highlightKeywords: ['重要', '注意', '提示'] }),
];interface ByteMarkdownEditorProps {initialValue?: string;onChange?: (value: string) => void;
}const ByteMarkdownEditor: React.FC<ByteMarkdownEditorProps> = ({ initialValue = '', onChange }) => {const [value, setValue] = useState(initialValue);const handleChange = (newValue: string) => {setValue(newValue);if (onChange) {onChange(newValue);}};return (<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}><Editorvalue={value}plugins={plugins}onChange={handleChange}/></div>);
};export default ByteMarkdownEditor;
運行效果:
- 你的 ByteMD 編輯器工具欄會多出一個日期時間圖標的按鈕。點擊它,會在編輯器光標處插入當前的日期時間。
- 在編輯器中輸入 “這是一段重要內容” 或 “請注意以下幾點”,在預覽模式下,“重要”和“注意”字樣將以高亮(通常是黃色背景)顯示,因為
plugin-gfm
將==...==
轉換為<mark>
標簽。
總結向面試官介紹自定義插件
面試官您好,我來詳細介紹一下 ByteMD 的插件系統,并結合自定義插件的實現。
ByteMD 的插件系統是其高度可擴展性的核心。它允許我們在不修改核心庫代碼的前提下,輕松地添加、修改或擴展編輯器的行為和功能。
其設計精妙之處在于:
-
分階段的鉤子機制: 插件通過實現不同的鉤子函數,在 ByteMD 內部的 Markdown 處理流程中精確介入。
remark
鉤子: 負責在 Markdown 解析成 MDAST (Markdown Abstract Syntax Tree) 后,對 AST 進行操作。例如,我可以定義一個remark
插件來識別自定義的 Markdown 語法,或者對內容進行前置處理(如我自定義插件中的高亮關鍵詞處理,將普通文本轉換為==關鍵詞==
形式)。rehype
鉤子: 負責在 MDAST 轉換為 HAST (Hypertext Abstract Syntax Tree) 后,對 HTML 的 AST 進行操作。這允許我對最終生成的 HTML 結構進行修改,例如為圖片添加lazy-load
屬性,或者將特定 AST 節點渲染為復雜的自定義 HTML 結構。editorEffect
鉤子: 允許我直接與底層的編輯器實例(如 CodeMirror)進行交互。這對于實現圖片拖拽上傳、自定義快捷鍵、或者監聽編輯器狀態變化等功能至關重要。viewerEffect
鉤子: 允許我在預覽區域的 HTML 渲染完成后,對其 DOM 進行操作。這適合于初始化第三方渲染庫(如 Mermaid、ECharts),或者對預覽內容進行后期處理。actions
鉤子: 最直觀的擴展方式,允許我在工具欄添加自定義按鈕,實現特定的交互功能,例如我自定義插件中的“插入日期時間”按鈕。
-
擁抱
unified
生態: ByteMD 沒有“重新發明輪子”,而是巧妙地利用了unified
這一成熟且強大的內容處理框架。這使得插件的開發能夠復用remark
和rehype
社區大量的現有插件和工具,極大地加速了開發。
以我剛剛實現的自定義插件為例,它實現了兩個功能:
-
“插入日期時間”按鈕:
- 我通過
actions
鉤子在 ByteMD 的工具欄添加了一個自定義按鈕。 - 在按鈕的
handler
中,我利用editorEffect
鉤子提供給我的editor
實例(即 CodeMirror 實例),調用其replaceSelection()
方法,實現了在光標處插入當前日期時間字符串的功能。這體現了actions
和editorEffect
在 UI 交互和編輯器控制上的協同作用。
- 我通過
-
高亮特定關鍵詞:
- 我利用
remark
鉤子,注冊了一個自定義的remark
插件 (remarkCustomHighlight
)。 - 這個插件會遍歷 Markdown 的 AST,查找預定義的關鍵詞(如“重要”、“注意”)。
- 一旦找到,它會修改 AST 中的文本節點,將關鍵詞用
==...==
包裹起來。由于 ByteMD 的plugin-gfm
已經內置了對==text==
渲染為<mark>
標簽的支持,我得以復用其渲染能力,實現了在預覽時自動高亮關鍵詞的效果,而無需編寫復雜的rehype
邏輯。這展示了如何通過利用現有插件的能力來簡化自定義。
- 我利用
總之,ByteMD 的插件系統是一個設計精良、高度開放的架構。它通過分層的鉤子和對 unified
生態的集成,使得開發者可以靈活地在不同階段對 Markdown 內容進行處理和定制編輯器行為,從而構建出滿足各種復雜需求的強大內容創作工具。