
前后端开发者经常会接到一类需求在页面上放一块可以输入、编辑、预览内容的“编辑器区域”。从最简单的留言框到内容管理后台的富文本编辑器再到支持 Markdown 的文档工具本质上都在解决同一个问题怎么让用户方便地编辑内容并且把这些内容安全可靠地展示出来。这个系列我计划用几篇文章从零手写一个轻量级在线编辑器。本文是第一篇选择从 Markdown 编辑器入手实现输入区、实时预览、本地自动保存三个核心能力。项目代号就叫This is my editing。这套实践不需要依赖大型前端框架用原生 HTML、CSS、JavaScript 就能完成。它适合前端初学者理解编辑器实现原理也适合后端开发者在内部管理系统中快速搭建一个可用的文档编辑工具。1. 编辑器开发的背景与核心概念1.1 为什么自己动手写编辑器市面上已经有很多成熟的编辑器无论是富文本类的 TinyMCE、wangEditor还是代码编辑器类的 CodeMirror、Monaco Editor功能都很强大。那为什么还要自己写第一个原因是定制成本。很多内部系统的编辑需求并不复杂比如只需要输入 Markdown、实时预览、自动保存。此时引入一个重型编辑器往往要面对主题定制、工具栏裁剪、国际化、包体积等多方面问题。自己写一个最小实现反而更可控。第二个原因是理解原理。编辑器并不是魔法。Markdown 编辑器的核心就是“文本解析”和“HTML 渲染”富文本编辑器的核心则是“选区操作”和“文档模型”。当你亲手实现一遍解析流程再去看第三方编辑器源码时会轻松很多。第三个原因是功能边界。在不依赖后端的情况下浏览器提供了 localStorage 等本地存储能力足以实现一个单机版的知识管理工具。很多个人笔记类应用的第一版其实就是一个增强过的 Markdown 编辑器。1.2 常见编辑器方案对比在动手之前有必要先梳理一下常见方案避免选错方向。方案原理优点缺点适用场景textarea 预览用户编辑纯文本解析后渲染到另一个容器实现简单、不依赖复杂API编辑体验弱无法所见即所得Markdown 编辑器、代码输入contenteditable execCommand让元素可编辑用浏览器命令做加粗、斜体等所见即所得execCommand 已标记废弃兼容性和行为不一致富文本编辑器但需要封装很多细节第三方编辑器库封装好 DOM 操作和渲染逻辑功能丰富、维护成本低定制困难、包体积大生产环境优先选择自研编辑器自己实现 DOM 操作、选区和模型完全可控、可做深度定制开发成本高、难点多学习实践、特殊交互场景从这张表能看出如果只是做一个 Markdown 编辑器最稳的起点是textarea 预览方案。它可以避开 contenteditable 带来的各种浏览器兼容问题同时把重心放在“解析器”和“数据持久化”上这也是本篇文章选择的路线。1.3 本文编辑器的功能范围本文要实现的编辑器包含以下功能在左侧文本域中输入 Markdown 内容。在右侧实时渲染解析后的 HTML 内容。内容自动保存到浏览器 localStorage刷新页面后恢复。编辑器支持标题、引用、列表、代码块、行内加粗、行内代码等常用语法。对用户输入进行 HTML 转义避免脚本注入。这套功能组合起来已经可以支撑一个简单的在线笔记工具。2. 环境准备与项目初始化2.1 运行环境这个项目是纯前端项目不依赖 Node.js、npm 或构建工具。只需要一个现代浏览器即可运行推荐使用 Chrome、Edge 或 Firefox 最新版。开发工具选用 VS Code 或者其他你习惯的编辑器都可以。如果希望体验更接近生产环境可以安装 VS Code 的 Live Server 插件通过本地静态服务访问页面。不安装也没关系直接双击打开 HTML 文件也能运行。注意本文示例代码以原生 JavaScript 为准。Angular、Vue、React 等技术栈的读者可以把核心逻辑迁移到对应组件中原理是相通的。2.2 项目目录结构创建项目文件夹this-is-my-editing目录结构如下this-is-my-editing/ ├── index.html ├── css/ │ └── style.css └── js/ └── editor.jsindex.html编辑器页面结构。css/style.css页面样式。js/editor.js编辑器全部核心逻辑包括 Markdown 解析、预览渲染、自动保存。2.3 运行方式在项目根目录执行# 如果安装了 Live Server可以直接右键 index.html 打开 # 或者使用任意静态文件服务例如 Python python3 -m http.server 8080然后在浏览器访问http://localhost:8080。如果不想启动服务直接双击index.html也可以因为 LocalStorage 在 file 协议下通常也能正常工作。3. 核心设计与实现思路3.1 整体架构编辑器整体可以分成三个部分输入层、解析层、输出层。输入层使用textarea它天然支持键盘交互、移动端光标控制并且值就是纯文本处理起来非常安全。我们不直接把textarea的内容放进预览区域而是先交给解析层处理。解析层负责将 Markdown 文本转换成 HTML 字符串。这里需要做两件事对原始文本进行 HTML 转义把、、等字符变成实体字符防止脚本注入。识别 Markdown 语法规则比如#标题、-列表、行内代码然后在转义后的字符串上替换成对应的 HTML 标签。输出层将生成的 HTML 字符串插入到预览容器中。因为已经做过转义所以可以放心使用innerHTML。需要提醒的是innerHTML本身存在安全风险所有动态内容必须经过可靠过滤后再使用。3.2 Markdown 解析流程Markdown 解析可以简化为四个阶段行切割把长文本按换行符拆成数组。代码块识别遇到标记时进入代码块状态代码块内部的内容不解析 Markdown只做 HTML 转义。块级解析标题、引用、列表、段落等处理。行内解析加粗、斜体、行内代码等处理。下面是一个流程图式的解析结构不是严格的代码而是方便理解的设计思路原始文本 ↓ 按换行拆分成 lines ↓ 遍历每一行 ↓ 判断是否在代码块内 ├── 是 → 累积代码块内容遇到结束标记时输出 precode └── 否 → 判断块级语法 ├── 标题 → 输出 h1~h6 ├── 引用 → 输出 blockquote ├── 无序列表 → 输出 ul li ├── 有序列表 → 输出 ol li ├── 分隔线 → 输出 hr └── 普通行 → 输出 p ↓ 行内解析加粗、斜体、行内代码3.3 自动保存策略自动保存有两个核心问题保存时机和保存位置。保存时机使用防抖策略。用户输入过程中会不断触发input事件如果每次都写入 localStorage没有必要。常见的做法是用户停止输入 300 毫秒后再保存。等到下一次刷新页面再从 localStorage 中恢复。保存位置用 localStorage。它的优点是使用简单、容量大约为 5~10MB适合保存文本内容。缺点是浏览器清理缓存后会丢失隐私模式下也可能被隔离。所以 localStorage 只适合做临时草稿生产环境仍然需要将内容提交到后端数据库。3.4 安全边界编辑器最容易被忽略的问题是 XSS。如果直接把用户输入的img onerroralert(1)插入页面浏览器会执行这段脚本。所以解析器必须遵守下面的安全原则原始文本必须先做 HTML 转义。Markdown 语法匹配要在转义后的字符串上进行且只替换自己生成的安全标签。不要允许任何原始 HTML 通过“原样输出”的方式进入预览区。如果后续要支持高级 HTML 标签请引入成熟的 HTML 过滤库比如 DOMPurify。4. 完整实战案例实现一个简洁的 Markdown 编辑器4.1 创建 HTML 页面结构在项目根目录新建index.html代码如下!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleThis is my editing - Markdown 编辑器/title link relstylesheet hrefcss/style.css /head body header classeditor-header h1This is my editing/h1 span classsave-status idsaveStatus未保存/span /header main classeditor-container section classeditor-pane div classpane-titleMarkdown 输入/div textarea ideditor placeholder在此输入 Markdown 内容.../textarea /section section classpreview-pane div classpane-title实时预览/div article idpreview classpreview-content/article /section /main script srcjs/editor.js/script /body /html结构上非常直观头部区域显示标题和保存状态主体区域分为左右两栏。左侧是输入区右侧是预览区。两个区域用 id 关联到 JavaScript。4.2 编写 CSS 样式在css/style.css中写入样式。这里重点关注布局和预览内容的排版。* { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, Segoe UI, PingFang SC, Microsoft YaHei, sans-serif; background: #f5f6f8; color: #333; min-height: 100vh; } .editor-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 24px; background: #fff; border-bottom: 1px solid #e5e6eb; } .editor-header h1 { font-size: 20px; font-weight: 600; } .save-status { font-size: 14px; color: #999; } .save-status.saved { color: #52c41a; } .editor-container { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; padding: 16px; height: calc(100vh - 68px); } .editor-pane, .preview-pane { display: flex; flex-direction: column; background: #fff; border: 1px solid #e5e6eb; border-radius: 8px; overflow: hidden; } .pane-title { padding: 10px 16px; font-size: 13px; color: #666; border-bottom: 1px solid #f0f0f0; background: #fafafa; } #editor { flex: 1; width: 100%; padding: 16px; border: none; outline: none; resize: none; font-family: SFMono-Regular, Consolas, Liberation Mono, Menlo, monospace; font-size: 15px; line-height: 1.7; color: #333; } .preview-content { flex: 1; padding: 16px; overflow-y: auto; font-size: 15px; line-height: 1.8; word-break: break-word; } .preview-content h1, .preview-content h2, .preview-content h3 { margin: 16px 0 8px; font-weight: 600; } .preview-content p { margin: 8px 0; } .preview-content ul, .preview-content ol { margin: 8px 0; padding-left: 24px; } .preview-content blockquote { margin: 12px 0; padding: 8px 16px; border-left: 4px solid #1890ff; background: #f0f6ff; color: #555; } .preview-content pre { margin: 12px 0; padding: 16px; background: #1e1e1e; color: #d4d4d4; border-radius: 6px; overflow-x: auto; } .preview-content code { background: #f0f0f0; padding: 2px 4px; border-radius: 4px; font-size: 14px; font-family: SFMono-Regular, Consolas, Menlo, monospace; } .preview-content pre code { background: transparent; padding: 0; } .preview-content hr { margin: 16px 0; border: none; border-top: 1px solid #e5e6eb; }CSS 里区分了普通行内代码和代码块。pre code的背景被设置为透明避免出现双重背景色。4.3 编写 JavaScript 核心逻辑下面进入重点部分创建js/editor.js。4.3.1 HTML 转义函数escapeHtml是所有安全性的基础。它把、、、引号等字符替换为 HTML 实体。// 文件路径js/editor.js function escapeHtml(str) { return str .replace(//g, amp;) .replace(//g, lt;) .replace(//g, gt;) .replace(//g, quot;) .replace(//g, #39;); }转义顺序中必须放在第一位。否则如果先替换为lt;后续的替换会把lt;中的再转义一次导致结果变成amp;lt;预览区会显示乱码。4.3.2 行内解析函数function inlineParse(text) { // 先整体转义再识别 Markdown 行内语法 let html escapeHtml(text); // 加粗**加粗** html html.replace(/\*\*(.?)\*\*/g, strong$1/strong); // 斜体*斜体* html html.replace(/\*(.?)\*/g, em$1/em); // 行内代码code html html.replace(/([^])/g, code$1/code); return html; }这里使用了非贪婪匹配.?可以尽可能匹配最短内容。例如**a** 和 **b**会被正确处理成两个加粗文本而不是从第一对星号匹配到最后一对星号。4.3.3 块级解析函数parseMarkdown负责整体解析流程。为了方便阅读我把它拆成几部分逻辑但最终都可以放在一个函数中。function parseMarkdown(md) { const lines md.split(\n); let html ; let inCodeBlock false; let codeBlockLines []; // 列表状态 let listTag null; let inList false; for (const line of lines) { const trimmed line.trim(); // 处理代码块开始与结束 if (trimmed.startsWith()) { if (!inCodeBlock) { inCodeBlock true; codeBlockLines []; } else { html precode escapeHtml(codeBlockLines.join(\n)) /code/pre; inCodeBlock false; } continue; } if (inCodeBlock) { codeBlockLines.push(line); continue; } // 空行处理 if (trimmed ) { if (inList) { html / listTag ; inList false; } continue; } // 标题 const headingMatch trimmed.match(/^(#{1,6})\s(.*)/); if (headingMatch) { closeListIfNeeded(); const level headingMatch[1].length; html h${level}${inlineParse(headingMatch[2])}/h${level}; continue; } // 分隔线 if (/^([-*_]\s*){3,}$/.test(trimmed)) { closeListIfNeeded(); html hr; continue; } // 引用 if (trimmed.startsWith( )) { closeListIfNeeded(); html blockquote${inlineParse(trimmed.substring(2))}/blockquote; continue; } // 无序列表 const ulMatch trimmed.match(/^[-*]\s(.*)/); if (ulMatch) { if (!inList || listTag ! ul) { closeListIfNeeded(); html ul; listTag ul; inList true; } html li${inlineParse(ulMatch[1])}/li; continue; } // 有序列表 const olMatch trimmed.match(/^\d\.\s(.*)/); if (olMatch) { if (!inList || listTag ! ol) { closeListIfNeeded(); html ol; listTag ol; inList true; } html li${inlineParse(olMatch[1])}/li; continue; } // 普通段落 closeListIfNeeded(); html p${inlineParse(trimmed)}/p; } // 收尾处理 if (inCodeBlock) { html precode escapeHtml(codeBlockLines.join(\n)) /code/pre; } closeListIfNeeded(); return html; function closeListIfNeeded() { if (inList) { html / listTag ; inList false; listTag null; } } }这段代码中代码块使用独立状态机管理块内所有内容都原样输出并转义不会误解析#、*等符号。列表使用listTag区分无序和有序切换列表类型时会先关闭上一个列表。4.3.4 自动保存与实时预览接下来定义编辑器初始化、渲染、保存逻辑。const STORAGE_KEY this-is-my-editing-content; const editor document.getElementById(editor); const preview document.getElementById(preview); const saveStatus document.getElementById(saveStatus); // 初始化内容 function loadContent() { const saved localStorage.getItem(STORAGE_KEY); if (saved null) { return [ ## 欢迎使用 This is my editing, , 这是一个 **Markdown 编辑器**支持以下语法, , - 标题# 一级标题、## 二级标题, - 加粗**加粗**, - 斜体*斜体*, - 行内代码code, - 代码块使用三个反引号包裹, - 引用 引用内容, , 内容会自动保存到浏览器本地刷新页面后仍然存在。, , javascript, console.log(This is my editing);, ].join(\n); } return saved; } // 保存内容 function saveContent() { localStorage.setItem(STORAGE_KEY, editor.value); saveStatus.textContent 已保存; saveStatus.classList.add(saved); } // 渲染预览 function renderPreview() { const md editor.value; preview.innerHTML parseMarkdown(md); } // 防抖函数 function debounce(fn, delay) { let timer null; return function (...args) { if (timer) { clearTimeout(timer); } timer setTimeout(() fn.apply(this, args), delay); }; } const debouncedSave debounce(saveContent, 300); // 输入事件 editor.addEventListener(input, function () { renderPreview(); saveStatus.textContent 编辑中...; saveStatus.classList.remove(saved); debouncedSave(); }); // 页面初始化 function init() { editor.value loadContent(); renderPreview(); saveContent(); } init();防抖函数是自动保存的关键。当用户连续输入时debouncedSave会被反复重置只有停止输入 300ms 后才会真正执行保存。这样可以减少 localStorage 写入次数避免频繁序列化。4.3.5 完整代码汇总js/editor.js最终内容就是把上面的函数拼接在一起。读者可以按照小节顺序依次复制到文件中也可以直接按下面的顺序组织escapeHtmlinlineParseparseMarkdownSTORAGE_KEY与 DOM 元素获取loadContentsaveContentrenderPreviewdebounce事件监听与init4.4 运行与验证完成以上三个文件后启动本地静态服务打开index.html。预期效果如下页面默认展示一段示例 Markdown右侧预览区实时渲染。修改左侧文本时右侧内容会同步更新。停止输入约 0.3 秒后右上角状态变为“已保存”。刷新浏览器页面左侧文本仍然保留上次编辑内容。可以手动输入下面这段 Markdown 来验证解析效果# 一级标题 ## 二级标题 这是一个 **加粗文本**这是 *斜体文本*还有行内代码 console.log(1)。 这是一段引用内容。 无序列表 - 第一项 - 第二项 有序列表 1. 第一步 2. 第二步 代码块 javascript const editor new Editor(); editor.render();只要右侧预览区能正确显示不同层级标题、加粗斜体、引用、列表和代码块就说明编辑器核心流程已经跑通。 ### 4.5 结果说明 从运行效果可以看到一个基于原生 JavaScript 的 Markdown 编辑器并不复杂。它没有使用任何依赖却实现了日常笔记工具的大部分基础能力。 这个版本的编辑器还有不少限制 - 没有处理表格、任务列表、嵌套列表、链接图片等扩展语法。 - 没有实现撤销重做的自定义管理。 - 预览区不支持编辑只能输入和展示分离。 - 保存位置局限于浏览器本地无法多端同步。 这些限制都可以在后续迭代中逐步完善不影响当前作为学习示例的价值。 ## 5. 常见问题与排查思路 ### 5.1 预览内容不更新 **问题现象**修改左侧文本右侧没有变化。 **常见原因** - input 事件未正确绑定。 - JavaScript 代码报错导致渲染函数未执行。 - 预览 DOM 元素 id 与 HTML 不一致。 **排查与解决** 打开浏览器开发者工具切换到 Console 标签页查看是否有红色报错。重点检查 getElementById 获取的元素是否存在。 如果确认没有报错可以在 renderPreview 函数第一行加 console.log(md)观察是否触发。没有触发说明事件绑定有问题检查编辑器初始化代码是否在 /body 前执行。 ### 5.2 HTML 标签被原样输出 **问题现象**输入 b文字/b预览区直接显示 HTML 标签或被执行。 **常见原因** - 没有先调用 escapeHtml。 - 在转义之前就进行了 Markdown 语法替换导致原始 HTML 被保留。 - 使用了 innerHTML 直接插入未经处理的用户输入。 **解决方案**保证所有用户输入先进入 escapeHtml 转义再进行后续语法替换。后续版本如果要支持更多 HTML 标签不要自己写白名单建议引入成熟库。 ### 5.3 代码块中的内容被错误解析 **问题现象**代码块里的 # 注释 被渲染成标题**内容** 被渲染成加粗。 **常见原因**解析器没有维护代码块状态仍然对代码块内部执行了块级或行内解析。 **解决方案**在遍历行时只要遇到 就切换 inCodeBlock 状态。处于代码块内时所有行只累积到数组中不做任何 Markdown 解析直到遇到结束标记。 ### 5.4 自动保存数据丢失 **问题现象**刷新页面后内容恢复成默认值。 **常见原因** - 浏览器处于无痕模式localStorage 被隔离。 - 浏览器禁止写入 localStorage。 - 保存逻辑未触发比如防抖后函数没有执行。 **排查与解决** 在 Console 中执行 localStorage.getItem(STORAGE_KEY) 查看是否有数据。如果没有再手动执行 localStorage.setItem(STORAGE_KEY, test)看是否抛出异常。如果异常说明浏览器环境不允许写 localStorage。 生产环境需要将内容同步到后端不能只依赖 localStorage。 ### 5.5 加粗与斜体的替换顺序导致误判 **问题现象**输入 *斜体* 和 **加粗** 时加粗和斜体互相干扰。 **常见原因**先执行了斜体替换导致 ** 被拆成两个 *加粗失效。 **解决方案**先替换加粗 **再替换斜体 *。本文代码中已经按这个顺序实现但仍然要提醒正则解析并不是 Markdown 标准解析器无法覆盖所有边界情况。 下面用表格汇总这些问题 | 问题现象 | 常见原因 | 解决思路 | | --- | --- | --- | | 预览不更新 | input 事件未绑定或 JS 报错 | 查看 Console检查 id 和事件监听 | | HTML 原样输出 | 缺少转义或过滤 | 先 escapeHtml再替换语法 | | 代码块被解析 | 缺少代码块状态机 | 用 inCodeBlock 状态隔离代码块 | | 自动保存丢失 | localStorage 不可用 | 检查浏览器环境接入后端存储 | | 加粗斜体误判 | 替换顺序错误 | 先替换 **再替换 * | ## 6. 最佳实践与工程建议 ### 6.1 安全永远是第一位 编辑器的数据来源是用户输入任何用户输入都是不可信任的。即使只是内部系统也必须在输出前做转义或过滤。 如果后续要支持更复杂的语法一定不要自己维护一份不完整的 HTML 过滤器。DOMPurify 是业内常用的 XSS 过滤库它可以与任何 HTML 渲染流程配合。在生产项目中建议在 innerHTML 赋值前增加一层 DOMPurify 过滤。 javascript // 生产环境建议的过滤写法 preview.innerHTML DOMPurify.sanitize(parseMarkdown(md));6.2 不要长期依赖 localStoragelocalStorage 适合做草稿自动保存但不是可靠的数据库。它存在以下限制过期时间需要自己实现。没有服务端备份。浏览器清理缓存后数据会丢失。容量有限不适合保存大文件。生产环境至少需要提供后端接口将 Markdown 原文保存到数据库并且支持导出下载。如果需要版本历史每次保存应生成一个新版本而不是覆盖旧内容。6.3 解析器功能拆细我在这篇文章中把解析器写成了一个函数目的是方便演示。工程化项目中建议拆分成独立的解析模块markdown/parser.js入口负责整体流程。markdown/block.js块级解析。markdown/inline.js行内解析。markdown/utils.js工具函数。这样后续增加表格、任务列表、图片链接时不需要改动核心结构。每类语法对应一个独立函数测试用例也好写。6.4 引入 TypeScript 收益明显这个编辑器的状态不算多但对于更复杂的编辑器项目TypeScript 能带来很大帮助。比如listTag应该是ul | ol | null如果使用 TypeScript编译器会在赋值错误时立即提醒。再比如解析函数返回值一定是字符串类型标注可以提高代码可读性。建议从项目一开始就使用 TypeScript或者至少在 JSDoc 中补充关键函数注释。6.5 预览区性能优化当文档内容变长时每次input都全量解析并重新设置innerHTML会带来卡顿。可以考虑下面的优化策略解析频率使用防抖从逻辑上降低渲染次数。预览区使用虚拟滚动只渲染可视区域内容。将 Markdown 解析结果缓存内容未变化时不重复解析。必要时使用 Web Worker 解析超大文档避免阻塞主线程。对于学习项目只要做到第一点就足够了。6.6 逐步向生产级编辑器演进这篇文章实现的是最简版本。如果要从“能跑”变成“好用”核心模块需要扩展工具栏插入标题、加粗、列表等语法实际是向 textarea 的光标位置插入文本。快捷键Ctrl B插入**加粗**Ctrl K插入链接。文件管理列表展示历史文档、重命名、删除、导出。协同编辑接入 WebSocket实现多人同时编辑。每一步都值得单独写一篇技术文章这个项目可以作为长期迭代的起点。7. 总结与后续学习方向通过这篇文章我们已经从零实现了一个具备实时预览和本地自动保存的 Markdown 编辑器。核心代码不到两百行却覆盖了 HTML 转义、Markdown 解析、防抖、localStorage 持久化这几个关键知识。动手实践是理解编辑器的最好方式。复制代码运行起来以后可以试着改一改解析器增加一个语法比如任务列表- [ ] 待办事项 - [x] 已完成事项实现思路并不难在无序列表的li内容里检测[ ]或[x]替换成带disabled属性的 checkbox 即可。下一步可以继续学习这些方向正则表达式在文本解析中的应用。浏览器的 Selection 与 Range API这是富文本编辑器的底层基础。contenteditable 的选区控制和 execCommand 的历史局限。第三方编辑器源码阅读比如 CodeMirror、Monaco Editor。编辑器是一门“看起来简单做起来细节极多”的前端方向。希望这篇实战笔记能帮你建立第一版原型也欢迎把文章收藏起来等需要做内部文档工具时直接参考。