
字节笔记本
2026年10月7日 · 约 9 分钟读完
用 React 搭建 HTML 实时预览工具:四个关键细节
做组件文档、写教程或者临时调试一段营销页 HTML 时,经常需要一个「左边贴代码、右边看效果」的小工具。CodePen、StackBlitz 这类平台当然能胜任,但它们是全功能 IDE:页面重、要联网、界面无法融入自己的产品。而文档站里嵌的示例、内部工具里的一个小预览框,要的是加载快、零配置、可定制——这些恰好是一个两三百行的 React 组件就能给足的。最近完整地把这样一个组件从零搭了一遍:从十几行的初版,到代码高亮、Tailwind 免引入、布局切换,再到可拖动的全屏预览,一路踩了不少典型坑。本文把最值得沉淀的四个细节整理出来,照着做可以少走弯路。
一、预览端:srcDoc 够用,document.write 更灵活
预览端的第一直觉是 iframe 的 srcDoc 属性:把左侧状态里的 HTML 字符串直接喂给它,内容一变,iframe 就重新渲染。十几行就能跑起来,而且天然安全隔离:用户贴进来的代码哪怕带着 script 标签,也被关在独立的浏览上下文里,不会污染父页面。这比用 dangerouslySetInnerHTML 把 HTML 直接插进自家 DOM 稳妥得多,是这类工具必须守住的底线。
srcDoc 的局限在于它渲染的是「整份文档」。如果希望用户只写 body 内容片段、由组件自动补全 doctype 和 head,就得换个思路:用 ref 拿到 iframe,在 useEffect 里通过 contentDocument 的 open()、write()、close() 手动拼一份完整文档写进去。文档怎么拼、head 里放什么,主动权全在组件手里。常见的替代招数是给 iframe 加一个随内容自增的 key,靠强制重挂载来刷新——能跑,但整个元素重建,视觉抖动更明显;srcDoc 属性变化本就会更新,真正需要手动拼文档,看中的正是「可以往 head 里加私货」这一点。顺带一提,每次 write 相当于整页刷新,输入密集时会闪,debounce 一百到三百毫秒、等输入停后半拍再刷新,肉眼几乎无感。

二、编辑端:放弃「透明 textarea 叠高亮」的 trick
编辑端要语法高亮,网上流行一个 trick:底层放只读的 react-syntax-highlighter 渲染色块,上面叠一层背景透明、文字也透明的 textarea,只留一个可见光标,靠两层严格对齐伪装成「可编辑的高亮编辑器」。这个方案看着巧,实际很脆:字体、行高、内边距任何一处差一像素,文字就整体错位,换行与滚动同步更是重灾区。
更省心的做法是直接上 CodeMirror。React 里常用 @uiw/react-codemirror 封装,配 @codemirror/lang-html 提供 HTML 语法与标签补全,theme 传 @uiw/codemirror-theme-vscode 一行就有 VS Code 暗色观感;行号、代码折叠、括号匹配、多选编辑这些都是 basicSetup 的开箱能力。代码量反而比叠加方案更少。编辑器选型还有第三条路 Monaco——VS Code 同源、功能最全,但体积以 MB 计,放进预览小组件里头重脚轻;CodeMirror 6 按包拆分、按需引入,一个语言包加一个主题包就是全部依赖,轻量场景下是标准答案。
这里有个几乎人人会撞的坑:编辑器里的代码莫名「居中」显示,先去调 basicSetup 的各种选项毫无效果,因为问题根源不在编辑配置,而在样式层。正解是用 @codemirror/view 的 EditorView.theme() 定义一个自定义主题扩展,对编辑器根节点、.cm-content 和 .cm-line 显式声明 textAlign 为 left、justifyContent 为 flex-start,必要时加上 !important,再把扩展放进 extensions 数组,左对齐才真正生效。CodeMirror 6 的定制入口是 extensions 而非 props,理解这一点,很多「配置不生效」的困惑就化解了。
const leftAlignTheme = EditorView.theme({
"&": { textAlign: "left" },
".cm-content": { justifyContent: "flex-start" },
".cm-line": { textAlign: "left" },
});
// 用法: extensions={[html(), leftAlignTheme]}三、Tailwind 免引入:注入逻辑收进组件
最初的版本要求用户在左侧代码里手写一段 Tailwind 的 CDN script 标签,忘了写,右边预览就「裸奔」。更好的交互是把这件事从用户手里拿走:既然文档是组件拼出来写进 iframe 的,那就把 Tailwind 的 CDN 固定注入到 head 里,左侧编辑器只保留内容片段。默认示例也缩成几行带 class 的 div,打开页面立即可见效果,用户的心智负担为零。
代价也要说清楚:cdn.tailwindcss.com 是运行时 JIT 的 Play CDN,官方定位就是开发与原型环境。每次重写 iframe 都要重新加载脚本、现场编译用到的工具类,输入频繁时能感觉到延迟。要面向生产,可以预编译一份常用工具类的静态 CSS 注入,或者至少限制文档重写的频率。这个方案还有个隐藏收益:Tailwind 的 preflight 全局样式重置只作用于 iframe 内部,不会波及宿主页面——假如把用户 HTML 直接渲染在自家 DOM 里,一段通配符选择器就能把整站样式掀翻,iframe 的隔离在这里再次救场。

四、交互层:两个必踩的状态 bug
布局切换做好后(并排、隐藏预览、全屏预览三种形态),第一类 bug 立刻出现:隐藏预览再点显示,右侧一片空白。原因是显示与隐藏用条件渲染控制 iframe 的挂载,重新显示时挂上来的是一个全新空文档;而更新预览的 useEffect 只依赖了 htmlCode,内容没变就不会重写。解法是把 showPreview 一起放进依赖数组,并抽一个 updatePreview 函数供两处复用。顺带记住 React 的惯例:布尔翻转用函数式更新 setShowPreview(prev => !prev),避免闭包里读到旧状态。
useEffect(() => {
if (showPreview) updatePreview();
}, [htmlCode, showPreview]);第二类 bug 在拖拽调宽。第一版把 mousemove 挂在拖拽手柄自己的 DOM 节点上,鼠标稍一拖快就滑出手柄,监听「断线」,拖拽卡死。标准做法是 mousedown 时把 mousemove 和 mouseup 挂到 document 上,mouseup 时统一移除,鼠标跑到哪里都能收到事件;mousedown 里记得 preventDefault,否则拖动时顺手选中文本,体验立刻掉一档。更现代的写法是 Pointer Events 配 setPointerCapture,拖拽期间事件被持续捕获在手柄上,连挂 document 的步骤都省了。要两侧边框都能拖,就记录起点坐标与初始宽度,按位移方向和左右手柄决定加减,最后换算成百分比,用 Math.min 与 Math.max 把宽度钳制在 10% 到 90% 之间,防止把编辑器挤没。顶部再实时回显 getBoundingClientRect 拿到的像素宽度,就能模拟不同屏宽、观察响应式断点的表现了。
写在最后
回顾整个组件:预览端用 contentDocument.write 换来「用户只写片段」的体验;编辑端用 CodeMirror 替掉脆弱的透明叠加 trick;Tailwind 注入收进组件内部,用户零配置;交互层修掉依赖数组缺失与拖拽事件丢失两个典型 bug。这四件事单独看都不难,难在各自有一层「想当然就会错」的膜:srcDoc 能用但不够灵活,叠加 trick 能看但不能摸,CDN 能跑但有代价,事件能绑但会丢。把它们逐个戳破,组件才算真正可用。整体不到三百行,却把 playground 类工具的核心矛盾过了一遍:安全隔离、实时性与性能、状态同步的正确性。
想再进一步的话,有几个顺理成章的方向:给 iframe 加 sandbox 属性进一步收紧权限;对写入做 debounce;把用户代码持久化到 localStorage;用 postMessage 实现预览与编辑器的双向通信。工具类小组件是最好的练手场,动手搭一个吧。



