实战:Markdown 预览增强插件
把前几章的 Webview、消息通信、自定义编辑器能力综合运用,实现一个 Markdown 预览增强插件:打开 .md 文件即可实时预览、自定义渲染样式、一键导出 HTML。
目标
- 自定义编辑器:用增强预览打开 Markdown 文件
- 实时渲染:编辑左侧文档,右侧预览即时更新
- 自定义渲染:支持任务列表、代码高亮、主题适配
- 导出 HTML:把渲染结果导出为独立 HTML 文件
第一步:注册自定义编辑器
package.json 中声明:
json
{
"contributes": {
"customEditors": [
{
"viewType": "myExt.mdPreview",
"displayName": "Markdown 增强预览",
"selector": [
{ "filenamePattern": "*.md" }
],
"priority": "option"
}
]
}
}priority: option 让用户在「打开方式」中选择,不覆盖默认编辑器。
第二步:实现 Provider
创建 src/mdPreviewProvider.ts:
typescript
import * as vscode from 'vscode';
import { renderMarkdown } from './renderer';
export class MdPreviewProvider
implements vscode.CustomTextEditorProvider {
constructor(private readonly extensionUri: vscode.Uri) {}
async resolveCustomTextEditor(
document: vscode.TextDocument,
panel: vscode.WebviewPanel
): Promise<void> {
// 配置 Webview
panel.webview.options = {
enableScripts: true,
localResourceRoots: [
vscode.Uri.joinPath(this.extensionUri, 'media')
]
};
// 资源 URI
const styleUri = panel.webview.asWebviewUri(
vscode.Uri.joinPath(this.extensionUri, 'media', 'preview.css')
);
const scriptUri = panel.webview.asWebviewUri(
vscode.Uri.joinPath(this.extensionUri, 'media', 'preview.js')
);
// 初始渲染
panel.webview.html = this.getHtml(
document.getText(),
styleUri,
scriptUri
);
// 监听文档变更,实时更新预览
const changeSub = vscode.workspace.onDidChangeTextDocument(
(event) => {
if (event.document.uri.toString() === document.uri.toString()) {
panel.webview.postMessage({
type: 'update',
content: document.getText()
});
}
}
);
panel.onDidDispose(() => changeSub.dispose());
// 接收页面消息
panel.webview.onDidReceiveMessage(async (message) => {
if (message.type === 'export') {
await this.exportHtml(document.getText());
}
});
}
private getHtml(
content: string,
styleUri: vscode.Uri,
scriptUri: vscode.Uri
): string {
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy"
content="default-src 'none';
style-src ${panel.webview.cspSource};
script-src ${panel.webview.cspSource};">
<link rel="stylesheet" href="${styleUri}">
</head>
<body>
<div class="toolbar">
<button onclick="exportHtml()">导出 HTML</button>
</div>
<div id="content" class="markdown-body"></div>
<script src="${scriptUri}"></script>
</body>
</html>`;
}
private async exportHtml(content: string): Promise<void> {
const uri = await vscode.window.showSaveDialog({
title: '导出 HTML',
defaultUri: vscode.Uri.file('preview.html'),
filters: { 'HTML 文件': ['html'] }
});
if (!uri) {
return;
}
const html = renderMarkdown(content, true);
await vscode.workspace.fs.writeFile(
uri,
Buffer.from(html, 'utf8')
);
vscode.window.showInformationMessage('导出成功');
}
}第三步:实现渲染器
创建 src/renderer.ts,实现轻量 Markdown 渲染:
typescript
export function renderMarkdown(
markdown: string,
standalone = false
): string {
const lines = markdown.split('\n');
let html = '';
let inList = false;
let inCode = false;
let codeBuffer: string[] = [];
for (const line of lines) {
// 代码块
if (line.trim().startsWith('```')) {
if (inCode) {
html += escapeHtml(codeBuffer.join('\n'));
html += '</code></pre>';
inCode = false;
codeBuffer = [];
} else {
html += '<pre><code>';
inCode = true;
}
continue;
}
if (inCode) {
codeBuffer.push(line);
continue;
}
// 标题
const heading = line.match(/^(#{1,6})\s+(.*)/);
if (heading) {
closeList(inList);
inList = false;
const level = heading[1].length;
html += `<h${level}>${escapeHtml(heading[2])}</h${level}>`;
continue;
}
// 列表项
const listItem = line.match(/^[-*]\s+(.*)/);
if (listItem) {
if (!inList) {
html += '<ul>';
inList = true;
}
html += `<li>${escapeHtml(listItem[1])}</li>`;
continue;
}
// 任务列表
const taskItem = line.match(/^[-*]\s+\[([ x])\]\s+(.*)/);
if (taskItem) {
if (!inList) {
html += '<ul>';
inList = true;
}
const checked = taskItem[1] === 'x'
? 'checked' : '';
html += `<li><input type="checkbox" ${checked} disabled>` +
escapeHtml(taskItem[2]) + '</li>';
continue;
}
// 空行
if (line.trim() === '') {
closeList(inList);
inList = false;
html += '<br>';
continue;
}
// 普通段落
html += `<p>${inlineMarkdown(line)}</p>`;
}
closeList(inList);
return wrapHtml(html, standalone);
}
function inlineMarkdown(line: string): string {
return escapeHtml(line)
// 粗体
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
// 斜体
.replace(/\*(.*?)\*/g, '<em>$1</em>')
// 行内代码
.replace(/`(.*?)`/g, '<code>$1</code>')
// 链接
.replace(/\[(.*?)\]\((.*?)\)/g,
'<a href="$2" target="_blank">$1</a>');
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
}
function closeList(inList: boolean): void {
if (inList) {
// 由调用方管理状态
}
}
function wrapHtml(body: string, standalone: boolean): string {
if (!standalone) {
return body;
}
return `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }
pre { background: #f6f8fa; padding: 12px; border-radius: 4px; overflow: auto; }
code { font-family: monospace; }
</style>
</head>
<body>${body}</body>
</html>`;
}第四步:Webview 脚本
创建 media/preview.js:
javascript
const vscode = acquireVsCodeApi();
// 接收渲染内容
window.addEventListener('message', (event) => {
if (event.data.type === 'update') {
// 由插件渲染后推送 HTML
document.getElementById('content').innerHTML =
event.data.content;
}
});
// 导出按钮
function exportHtml() {
vscode.postMessage({ type: 'export' });
}第五步:样式
创建 media/preview.css:
css
body {
background: var(--vscode-editor-background);
color: var(--vscode-editor-foreground);
font-family: var(--vscode-font-family);
padding: 20px;
}
.toolbar {
position: sticky;
top: 0;
padding: 8px 0;
background: var(--vscode-editor-background);
border-bottom: 1px solid var(--vscode-panel-border);
}
.toolbar button {
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
border: none;
padding: 6px 12px;
cursor: pointer;
border-radius: 4px;
}
.markdown-body h1, .markdown-body h2,
.markdown-body h3 { border-bottom: 1px solid var(--vscode-panel-border); }
.markdown-body pre {
background: var(--vscode-textBlockQuote-background);
padding: 12px;
border-radius: 4px;
overflow-x: auto;
}
.markdown-body code {
font-family: var(--vscode-editor-font-family);
}
.markdown-body a {
color: var(--vscode-textLink-foreground);
}第六步:注册与激活
src/extension.ts:
typescript
import * as vscode from 'vscode';
import { MdPreviewProvider } from './mdPreviewProvider';
export function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(
vscode.window.registerCustomEditorProvider(
'myExt.mdPreview',
new MdPreviewProvider(context.extensionUri),
{ webviewOptions: { retainContextWhenHidden: true } }
)
);
}运行与验证
按 F5 启动调试:
| 步骤 | 操作 | 预期 |
|---|---|---|
| 1 | 打开任意 .md 文件 | 右键 → 打开方式 → Markdown 增强预览 |
| 2 | 预览显示渲染结果 | 标题/列表/代码正确渲染 |
| 3 | 修改源文件 | 预览实时更新 |
| 4 | 点击「导出 HTML」 | 保存独立 HTML 文件 |
| 5 | 切换深浅主题 | 预览样式随之适配 |
功能扩展
| 扩展方向 | 实现 |
|---|---|
| 代码高亮 | 集成 highlight.js / Prism |
| 图片支持 | 用 asWebviewUri 加载工作区图片 |
| 目录大纲 | 解析标题生成跳转列表 |
| 数学公式 | 集成 KaTeX |
| 自动滚动同步 | 按光标位置定位预览 |
常见问题
| 问题 | 处理 |
|---|---|
| 打开方式没有选项 | 检查 customEditors 的 filenamePattern |
| 预览不更新 | 确认 onDidChangeTextDocument 监听 |
| 导出乱码 | 写文件时指定 utf8 |
| CSP 拦截资源 | 对应指令加入 cspSource |
本实战打通了自定义编辑器、Webview 消息通信、资源加载、文件导出的完整链路,是构建「文件格式工具类」插件的标准模板。