1. 环境准备与依赖选型
在构建一个现代 Markdown 实时预览器前,明确技术栈边界是安全与可维护性的第一道防线。本教程默认采用纯前端、浏览器运行环境(兼容 Vite/React/Vue/甚至原生 HTML 页面),不依赖服务端渲染——这意味着所有解析、渲染、防护逻辑必须在客户端健壮执行。
✅ 基础要求:
- Node.js ≥ 18.0(确保
ESM原生支持与现代 API 兼容性) - 构建工具无强绑定:
markdown-it是纯 JS 库,import即用,Vite/Webpack/Rollup 均无缝支持
🔍 主流 Markdown 解析库横向对比:
| 库 | XSS 默认防护 | 插件生态 | 性能(10KB 文档) | 维护状态 | 备注 |
|---|---|---|---|---|---|
marked | ❌(需手动禁用 html: true) | 中等 | ⚡ 快(但 v4+ 移除同步 API) | 活跃 | 配置项少,扩展性弱于 markdown-it |
remark | ✅(纯 AST,无 HTML 输出) | ⚙️ 极强(统一 AST 生态) | 🐢 中等(AST 转换链长) | 活跃 | 学习成本高,需搭配 rehype-stringify 等,适合复杂处理流 |
markdown-it | ✅(默认 html: false,xhtmlOut 安全) | 🌟 丰富(>200 官方/社区插件) | ⚡⚡ 快(C 语言级优化 parser) | 活跃 | 推荐首选:开箱即用的安全基线 + 插件即插即用 |
⚠️ 明确避坑:
showdown(v2.x 已停止维护,v3.x 重构未稳定,且默认开启 HTML 解析)markdown(npm 上同名废弃包,非markdown-it)commonmark(规范严格但生态单薄,无语法高亮原生支持)
📌 推荐初始化命令:
npm install markdown-it dompurify highlight.js
# 或使用 pnpm/yarn
2. 基础渲染器搭建:从字符串到 HTML
我们从最简实例出发,验证核心链路是否通畅。注意:这一步仅验证「解析能力」,不涉及任何 DOM 插入或安全处理。
import MarkdownIt from 'markdown-it';
// 创建默认配置实例(已禁用 HTML 标签解析)
const md = new MarkdownIt({
html: false, // 🔒 关键!禁止原始 HTML 解析
xhtmlOut: true, // 输出自闭合标签(如 <br />),更规范
breaks: false, // 暂不启用 \n → <br>(避免干扰列表/代码块)
langPrefix: 'language-', // 为 <code> 添加 class 前缀,便于高亮
});
const input = '# Hello\n\n- Item 1\n- Item 2';
const html = md.render(input);
console.log(html);
// 输出:
// <h1>Hello</h1>
// <ul>
// <li>Item 1</li>
// <li>Item 2</li>
// </ul>
💡 关键理解:md.render() 返回的是未经转义的 HTML 字符串,它本质是「可信中间产物」——但一旦你把它交给 innerHTML,就等于把解析权交给了浏览器的 HTML 解析器,而该解析器完全不关心你的 Markdown 来源是否可信。
⚠️ 绝对禁止:
element.innerHTML = md.render(userInput)—— 这是 XSS 的黄金入口。
3. 实现实时预览:监听输入并触发重渲染
将 <textarea> 与预览区联动,需兼顾响应性与性能:
- ✅ 使用
input事件(捕获所有输入:键盘、粘贴、语音输入) - ✅ 防抖(debounce)控制渲染频率(避免每敲一个字都解析)
- ✅ DOM 更新策略:预览区用
innerHTML(因内容经后续净化),输入区保持value不变(防光标跳动)
const textarea = document.getElementById('md-input');
const preview = document.getElementById('html-preview');
const md = new MarkdownIt({ html: false });
let debounceTimer;
textarea.addEventListener('input', () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
try {
const html = md.render(textarea.value);
// ⚠️ 此处仍不安全!待第4节加固后才可赋值
preview.innerHTML = html;
} catch (err) {
preview.textContent = '⚠️ 解析错误,请检查语法';
}
}, 300); // 推荐 200–500ms:短于 200ms 易卡顿,长于 500ms 感知延迟明显
});
🔍 常见问题排查:
- 中文标点后光标跳动? → 检查是否误写了
textarea.value = textarea.value(触发重排重绘) - 防抖失效? → 在
setTimeout外加console.log(performance.now()),连续输入应只触发 1 次日志 - 移动端 iOS 光标错位? → 确保
textarea无resize: none+min-height固定,或监听scrollIntoView补偿
4. 安全加固:防止 XSS 攻击
假设用户输入:
[点击](javascript:alert('xss'))
<img src=x onerror=alert(1)>
若直接 innerHTML 渲染,上述 payload 将立即执行——这是典型的「DOM-based XSS」。
🛡️ 双保险策略(推荐组合):
方案 A(生产环境首选):markdown-it 基础防护 + DOMPurify 终极清洗
npm install dompurify
import DOMPurify from 'dompurify';
// 渲染后立即净化
preview.innerHTML = DOMPurify.sanitize(html);
// ✅ DOMPurify 默认白名单:仅保留 <h1>~<h6>, <p>, <ul>, <ol>, <li>, <pre><code>, <strong> 等语义化标签
// ✅ 自动移除 javascript:、onerror、style="background:url(javascript:..." 等危险属性
方案 B(超轻量场景):纯 markdown-it 配置禁用
const md = new MarkdownIt({
html: false, // 禁用原始 HTML 解析(默认即 true)
typographer: false, // 禁用智能引号等富文本转换(减少潜在注入面)
linkify: false, // 禁用自动链接(避免 `http://x.com` 被包裹为 <a>)
});
⚠️ 注意:html: false 后 <br> 不会生成,需显式设置 breaks: true 并接受其对列表缩进的微小影响(见第7节速查)。
5. 增强体验:语法高亮与样式美化
没有高亮的代码块如同无盐的汤。我们集成 highlight.js(体积小、语言全、主题丰富):
npm install highlight.js
# 加载主题 CSS(Vite 中可直接 import)
import 'highlight.js/styles/github-dark.css';
import hljs from 'highlight.js/lib/core';
import javascript from 'highlight.js/lib/languages/javascript';
import typescript from 'highlight.js/lib/languages/typescript';
import python from 'highlight.js/lib/languages/python';
import css from 'highlight.js/lib/languages/css';
hljs.registerLanguage('javascript', javascript);
hljs.registerLanguage('typescript', typescript);
hljs.registerLanguage('python', python);
hljs.registerLanguage('css', css);
const md = new MarkdownIt({
highlight: (str, lang) => {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(str, { language: lang }).value;
} catch (e) {
console.warn(`Highlight failed for ${lang}:`, e);
}
}
return md.utils.escapeHtml(str); // 降级为转义
}
});
🎨 样式提示:
- 确保
<pre><code>未被全局 CSS 重置(如white-space: pre-line会破坏缩进) - 推荐添加最小样式保障:
pre code { display: block; padding: 1rem; overflow-x: auto; }
6. 进阶优化:性能监控与错误处理
面向真实用户,需主动观测与兜底:
textarea.addEventListener('input', () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
console.time('markdown-render');
try {
const html = md.render(textarea.value);
console.timeEnd('markdown-render'); // 如 "markdown-render: 8.23ms"
// 超长文本降级(>50KB 时改用 textContent 提示)
if (textarea.value.length > 50 * 1024) {
preview.textContent = '📝 文档过长,已切换为纯文本预览(支持 Ctrl+F 搜索)';
return;
}
preview.innerHTML = DOMPurify.sanitize(html);
} catch (err) {
console.error('Markdown parse error:', err);
preview.textContent = '⚠️ 解析失败,请检查语法(可能含未闭合的 ``` 或 $)';
}
}, 300);
});
🚀 Web Worker 分离(可选高阶):
当文档 >100KB 时,主线程可能卡顿。可将 md.render() 移至 Worker:
// worker.js
import MarkdownIt from 'markdown-it';
const md = new MarkdownIt();
self.onmessage = ({ data }) => {
self.postMessage({ html: md.render(data) });
};
主页面通过 postMessage 通信,彻底解耦渲染与 UI 线程。
7. 调试清单与部署检查
上线前,请逐项核验(建议打印为 checklist 贴在团队看板):
-
markdown-it实例已明确设置html: false - 所有
innerHTML = ...赋值前均调用DOMPurify.sanitize() - 防抖生效:连续快速输入 5 次,控制台
console.time日志 ≤ 1 条 - 高亮覆盖:测试
js/ts/py/html/css五种语言代码块均正确着色 - 移动端实测:iOS Safari 下
textarea输入、粘贴、光标定位无异常
🔍 高频问题速查表:
Q:LaTeX 数学公式(如
$E=mc^2$)不渲染?
A:markdown-it默认不支持。安装插件:npm install markdown-it-texmath,并引入 KaTeX:import katex from 'katex'; import 'katex/dist/katex.min.css'; import texmath from 'markdown-it-texmath'; md.use(texmath, { engine: katex, delimiters: 'dollars' });Q:列表项缩进丢失,变成段落?
A:检查是否误启breaks: true—— 它会将所有换行转为<br>,破坏 Markdown 列表解析规则。关闭即可:breaks: false(默认值)。
至此,你已构建出一个安全、高性能、可扩展、跨平台兼容的 Markdown 实时预览核心引擎。下一步可按需接入:TOC 自动生成、图片拖拽上传、导出 PDF、协同编辑等高级能力——而这一切,都建立在今天打下的坚实基础上。