最近在做一个实时预览 html 内容功能,多方调研,利弊权衡后选用 iframe 实现 html 的渲染。但在实际使用中,更新 html 时,遇到了 iframe 更新闪烁的问题。每次内容更新,页面就会白屏一下,虽然只有几十毫秒,但看起来很不舒服。试了几种方案后,最终用双缓冲的思路解决了。记录一下整个过程。
问题是怎么来的
先说说我遇到的具体场景:一个 html 编辑器,让 AI 生成 html 内容,流式更新 stream 返回 html 内容,实时渲染到 iframe 中。最初的实现很直接:
// 最初的实现
const iframe = document.querySelector('#preview');
iframe.srcdoc = newHtmlContent;
看起来没问题对吧?但实际运行时,每次更新都会有明显的白屏:
- 浏览器先卸载旧的内容
- 然后解析新的 HTML
- 最后才渲染出来
这个过程虽然快,但肉眼能看出来。在一些低端设备上更明显。
解决思路
想到React Fiber 架构中的双缓冲技术:屏幕上显示一个 fiber 节点,后台渲染下一个 fiber 节点,渲染好了直接切换,这样就不会看到画面卡顿。
iframe 也可以用类似的思路:
- 准备两个 iframe,一个显示给用户看,一个藏在后面
- 在隐藏的 iframe 里加载新内容
- 等加载完了,瞬间切换
- 下次更新时,角色对调
这样用户永远看到的都是加载好的内容。
原理图大概是这样:
初始状态:
┌─────────────┐ ┌─────────────┐
│ iframe-main│ │iframe-shadow│
│ (可见) │ │ (隐藏) │
│ 内容 A │ │ 空白 │
└─────────────┘ └─────────────┘
第一步:在隐藏的 iframe 中加载新内容
┌─────────────┐ ┌─────────────┐
│ iframe-main│ │iframe-shadow│
│ (可见) │ │ (隐藏) │
│ 内容 A │ │ 加载内容 B │
└─────────────┘ └─────────────┘
第二步:加载完成后瞬间切换
┌─────────────┐ ┌─────────────┐
│ iframe-main│ │iframe-shadow│
│ (隐藏) │ │ (可见) │
│ 内容 A │ │ 内容 B │
└─────────────┘ └─────────────┘
第三步:下次更新时角色互换
┌─────────────┐ ┌─────────────┐
│ iframe-main│ │iframe-shadow│
│ (隐藏) │ │ (可见) │
│ 加载内容 C │ │ 内容 B │
└─────────────┘ └─────────────┘
具体实现
HTML 结构
先准备两个 iframe,用绝对定位让它们重叠:
<div class="iframe-container">
<iframe id="iframe-main" class="visible"></iframe>
<iframe id="iframe-shadow" class="hidden"></iframe>
</div>
CSS 样式
几个关键点:
.iframe-container {
position: relative;
width: 800px;
height: 600px;
}
iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
transition: opacity 0.3s ease; /* 可选:添加平滑过渡 */
}
.visible {
opacity: 1;
pointer-events: auto;
z-index: 1;
}
.hidden {
opacity: 0;
pointer-events: none;
z-index: 0;
}
JavaScript 核心逻辑
重点是切换的时机,必须等新内容加载完成:
const iframeMain = document.getElementById('iframe-main');
const iframeShadow = document.getElementById('iframe-shadow');
let isMainVisible = true; // 跟踪当前显示的是哪个 iframe
function updateIframe(newContent) {
// 确定当前可见和隐藏的 iframe
const visibleIframe = isMainVisible ? iframeMain : iframeShadow;
const hiddenIframe = isMainVisible ? iframeShadow : iframeMain;
// 在隐藏的 iframe 中加载新内容
hiddenIframe.srcdoc = newContent;
// 监听加载完成事件
hiddenIframe.onload = () => {
// 隐藏当前可见的 iframe
visibleIframe.classList.remove('visible');
visibleIframe.classList.add('hidden');
// 显示新加载的 iframe
hiddenIframe.classList.remove('hidden');
hiddenIframe.classList.add('visible');
// 切换状态标记
isMainVisible = !isMainVisible;
};
}
// 使用示例
updateIframe('<html><body>新内容</body></html>');
完整可运行的 Demo
把上面的整合起来,做了个演示页面:Iframe 无闪烁更新 Demo
效果对比
用传统方式,用户看到的是:
内容A → 白屏闪烁 → 内容B
████░░░░████
可见 空白 可见
用双缓冲,用户看到的是:
内容A → 内容B(瞬间切换)
████████████
始终可见
差别还是很明显的。
一些细节
为什么用 opacity 而不是 display?
/* 用这个 */
.hidden { opacity: 0; }
/* 别用这个 */
.hidden { display: none; }
display: none 会把元素从渲染树中移除,可能导致 iframe 内容被卸载。而 opacity: 0 只是让元素透明,iframe 内容还在。另外用 opacity 还能加 transition 做淡入淡出效果。
pointer-events 别忘了加
.hidden {
opacity: 0;
pointer-events: none; /* 这个很重要 */
z-index: 0;
}
隐藏的 iframe 如果不加 pointer-events: none,还是会捕获鼠标事件,用户点击时可能会点到下面那层,造成一些奇怪的问题。
onload 的时机
hiddenIframe.onload = () => {
// 这时新内容已经加载和渲染完成了
// 可以安全地切换
};
onload 会等 iframe 里的 DOM、CSS、图片等资源都加载完。如果内容比较大,可能要等一会儿。我之前试过用 DOMContentLoaded,但有时候 CSS 还没加载完就切换了,画面会闪一下,所以还是用 onload 比较保险。
内存占用的问题
两个 iframe 意味着内存占用翻倍。如果内容比较简单还好,复杂的页面就得注意了:
// 在切换完成后,可以清空旧 iframe 的内容(可选)
hiddenIframe.onload = () => {
// 切换显示
// ...
// 可选:延迟清空旧内容以释放内存
setTimeout(() => {
visibleIframe.srcdoc = '';
}, 500);
};
不过我实际用下来,除非内容特别大,一般不用太担心内存问题。
其他变式
iframe src 属性更新
如果用的是 src 而不是 srcdoc,改一下就行:
function updateIframe(newUrl) {
const hiddenIframe = isMainVisible ? iframeShadow : iframeMain;
hiddenIframe.src = newUrl;
hiddenIframe.onload = () => {
switchIframes();
};
}
错误处理
hiddenIframe.onload = () => {
switchIframes();
};
hiddenIframe.onerror = () => {
console.error('加载失败了');
// 不切换,保持原内容
};
// 加个超时保护
const timeout = setTimeout(() => {
console.error('加载超时');
}, 10000);
hiddenIframe.onload = () => {
clearTimeout(timeout);
switchIframes();
};
实际使用中,超时处理还是挺重要的,我之前遇到过 iframe 卡住不 onload 的情况。
其他方案
除了双缓冲,还有几种方案:
直接操作 contentWindow
const doc = iframe.contentWindow.document;
doc.open();
doc.write(newContent);
doc.close();
这样不会重新加载 iframe,但要小心,可能会破坏现有的 DOM 结构。
用 postMessage 通信
让 iframe 内部自己更新 DOM,外部只负责发消息。这样也不会重新加载,但前提是 iframe 内容得是你自己能控制的。
Web Components + Shadow DOM
更现代的方案,封装性更好。不过浏览器支持还不够全面。
写在最后
双缓冲这个方案实现起来不复杂,核心代码也就几十行,但效果很明显。虽然会多占用一些内存,但对于注重用户体验的应用来说,还是很值得的。