iframe 渲染请求到的 html (邮件预览), 避免样式污染
背景:
之前弄了邮件系统, 但显示邮件内容时是直接 v-html , 导致邮件内容和项目样式互相污染; 之前代码是去掉邮件内容的样式文件, 结果导致部分内容显示错位, 现在想不改邮件内容, 用 iframe 包裹邮件内容显示
思路:
需要解决几个难点
- iframe 宽高如何随内容变化而变化
- iframe 如何与父级通讯
- 如何将不同类型的返回内容渲染成 html 放到 iframe 中
解决:
代码以 Vue 形式写的, 下面的代码是最终代码(iframe 渲染 + 自定义水平固定滚动条 + 打印 + 其他删除功能)拆分而来, 可能有些错漏/未删除变量
-
新建一个空白 HTML 页面, mailDetail.html , 只有最基本的 html 格式, 无任何内容
-
详情页新增 iframe 框, 引用此 html , 但先设置 height=“0” , 不显示内容
<!-- publicPath: process.env.BASE_URL --> <!-- mailFrameName: 'mailContentFrame' --> <iframe :src="`${publicPath}mailDetail.html`" :name="mailFrameName" width="100%" height="0" style="border: none;"></iframe>
-
请求到邮件内容, 并对邮件内容做了处理, 调用方法渲染邮件内容到 iframe 中
// 处理邮件详情代码并渲染到iframe中 renderCodeToIframe(mailContent) { /** handleMailHTML方法 和 renderMailHTML方法都来源mixins(mailContent) */ // 生成 iframe documentElement 代码 this.content = this.handleMailHTML(mailContent); // 渲染代码到 iframe 中 this.renderMailHTML(this.mailFrameName, this.content); },
-
渲染相关方法, from mixins(mailContent), 可以理解为提取出公共的方法到某处, 方便其他地方复用
-
对原始邮件内容做一些处理, 返回 html 字符串
handleMailHTML(mailContent) { // 解析邮件内容为 Document 对象 const parser = new DOMParser(); const doc = parser.parseFromString(mailContent, 'text/html'); // 邮件内容中的 base 标签会导致页面跳转时,指向 base 标签指定的地址,而非当前系统的页面,因此去除全部的 base 标签 Array.from(doc.querySelectorAll('base')).forEach(node => { node.remove(); }); // 邮件详情页的正文中超链接更改为新标签窗口打开 Array.from(doc.querySelectorAll('a')).forEach(node => { if (node.target && node.target !== '_blank') { node.target = '_blank'; } }); // 设置 body margin 默认为 0 , 避免浏览器默认样式给 body 加上 margin doc.body.style.margin = '0'; // 设置 body overflow-x hidden , 不允许出现横向滚动条 --- 外部模拟水平滚动条 doc.body.style['overflow-x'] = 'hidden'; // 设置 body overflow-y hidden + 去掉 body 的高度限制, 避免出现右侧滚动条 doc.body.style['overflow-y'] = 'hidden'; doc.body.style['min-height'] = 'auto'; doc.body.style['max-height'] = 'auto'; doc.body.style.height = 'auto'; // 添加高度自适应 script const heightWatcher = doc.createElement('script'); heightWatcher.type = 'text/javascript'; heightWatcher.innerHTML = ` // 监听元素高度变化(200ms 定时查询元素 offsetHeight 是否发生变化) // 注意, 不同浏览器, 不同版本, 对各种 height 实现不同, documentElement 和其他元素也有区别 // 这里是用 documentElement.offsetHeight 来获取整个文档高度, 别的元素的行为不确定, 可能要用 scrollHeight 来获取高度 function onElementHeightChange(elm, callback){ var lastHeight = elm.offsetHeight, newHeight; (function run(){ newHeight = elm.offsetHeight; if( lastHeight != newHeight ) { callback(newHeight, lastHeight); } lastHeight = newHeight; if( elm.onElementHeightChangeTimer ) { clearTimeout(elm.onElementHeightChangeTimer); } // 更新 hash 值, 供外部监听获取相应传参 // iframe document 实际宽度 var hashStr = 'documentWidth=' + elm.scrollWidth + ';' // iframe 元素宽度 hashStr += 'iframeWidth=' + window.frameElement.clientWidth + ';' // 转码, 赋值 location.hash = encodeURIComponent(hashStr); elm.onElementHeightChangeTimer = setTimeout(run, 200); })(); } // 监听 documentElement offsetHeight 变化, 变化后设置父页面 frame 元素 height 属性为变化后的高度 onElementHeightChange(document.documentElement, function(newHeight, oldHeight){ console.error('onElementHeightChange', newHeight, oldHeight) if (window.frameElement) { // 设置 frame height 为变化后的新高度 + 滚动条高度, 以避免元素底部出现水平滚动条时垂直方向不能占满 window.frameElement.height = (newHeight || 50) + (window.innerWidth - document.documentElement.clientWidth); } }); // 初次加载完成时, 设置父页面 frame 元素 height 属性为 documentElement.offsetHeight window.addEventListener('DOMContentLoaded', function(e) { console.error('DOMContentLoaded'); if (window.frameElement) { // 设置 frame height 为页面高度 + 滚动条高度, 以避免元素底部出现水平滚动条时垂直方向不能占满 window.frameElement.height = document.documentElement.offsetHeight + (window.innerWidth - document.documentElement.clientWidth); } })`; doc.body.append(heightWatcher); // 设置 DOCTYPE 以避免页面内容缩小时, iframe 高度不变, 导致多出空白区域(参考 https://segmentfault.com/a/1190000014586956#item-3) const docType = '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">'; // 返回最终的 HTML 字符串 return docType + doc.documentElement.outerHTML; }
-
渲染 html 到 iframe 中
renderMailHTML(frameName, strHTML, callBack) { // 不加 $nextTick 或 $nextTick 位置放错(参见 git 文件提交日志), 可能导致内容不显示 --- 实际原因是多了一个 iframe , 不清楚咋出现的 this.$nextTick(() => { // 获取指定 iframe 的 window let ifr = window.frames[frameName]; if (!ifr) { return; } // 清除原有 iframe , 避免其内容对新 iframe 造成影响, 同时也避免原有 iframe 中的各种监听之类的残留 const ifrElm = ifr.frameElement; const newIfrElm = ifr.frameElement.cloneNode(); ifrElm.parentElement.replaceChild(newIfrElm, ifrElm); // 写入新 iframe 内容 ifr = window.frames[frameName]; if (ifr) { // 写入 HTML ifr.document.open(); ifr.document.write(strHTML); ifr.document.close(); // 触发回调函数 if (callBack) { callBack(); } } }); }
-
接上一条, 打印 iframe 邮件详情
-
新增一个打印用的 iframe , 隐藏不显示
<!-- 邮件打印的iframe容器 --> <iframe id="printf" name="printf" style="display: none;"></iframe>
-
调用下方打印方法打印
// strHTML: 原始邮件内容 // containerNode: 邮件完整内容(包括 iframe 和其他信息如收件人发件人等)所在的 node // frameName: 邮件详情页 iframe 的 name // printFrameName: 之前初始化的打印用 iframe 的 name printMailHtml(strHTML, containerNode, frameName, printFrameName) { // 初始化打印 Document const parser = new DOMParser(); // 指定打印样式和 onload 打印 const doc = parser.parseFromString(`<html><head><style media="print">* {word-wrap: break-word; word-break: break-word;}ul li {font-size: 12px;line-height: 18px;font-weight: 400; list-style-type:none;}</style></head><body οnlοad="window.print()">${containerNode.innerHTML}</body></html>`, 'text/html'); // 替换邮件内容 iframe 为 iframe 内部文档 const ifr = doc.querySelector(`iframe[name=${frameName}]`); if (ifr) { ifr.outerHTML = strHTML; } // 写入数据到打印 iframe 中, 打印 const printWin = window.frames[printFrameName]; if (printWin) { printWin.document.write(doc.documentElement.outerHTML); printWin.document.close(); } }
接上一条, iframe 预览邮件时, 要求固定水平滚动条在视口底部
背景:
邮件过长时, 页面要滚动到最底部才能拖拽 iframe 的水平滚动条, 操作不方便; 因此希望水平滚动条固定显示在视口底部, 用户可以直接拖拽查看详情; 并且, 在 iframe 垂直方向滚动到底后, 水平滚动条应取消固定, 随着 iframe 继续向上移动
解决:
做一个模拟滚动条满足此需求, 其原理为:
- 邮件详情 iframe 本身不显示水平滚动条, 在 iframe 底部新增一个两层 div , 外部 div 宽度与 iframe 保持一致, 内部 div 宽度为 iframe 内部文档实际宽度
- 监听模拟滚动条容器(外层 div)的 scroll 事件, 同步将内部 div 的 scrollLeft 赋值给 iframe documentElement 的 scrollLeft
- 模拟滚动条容器默认设置为 position: absolute , 监听 iframeElement 的 offsetParent (其所在的 overflow div)的 scroll 事件, 当 iframe 的边界进入视口后, 设置 position 为 relative
具体代码如下:
-
滚动条 div
<!-- 邮件详情 iframe --> <!-- 自定义滚动条 --> <div @scroll="handleMailHorizontalScroll" ref="mailIframeScroll" :style="mailScrollContainerStyleObj"> <div :style="mailScrollInnerStyleObj"></div> </div>
-
相关变量/滚动监听器定义
data() { return { // 邮件自定义水平滚动条样式 -- 外部与 iframe 等宽 div 的样式 mailScrollContainerStyleObj: { // 固定属性 // 允许出现水平滚动条, 此水平滚动条即为最终显示的水平滚动条 'overflow-x': 'auto', // 尽量减少滚动条占位高度 'line-height': '0', // 背景透明 'background-color': 'transparent', // 变动属性 // 控制鼠标穿透, 确保滚动条不显示时鼠标不会误触滚动条 'pointer-events': 'none', // 滚动条外部宽度, 因为显示区域和 offsetParent 不一定等宽, 这个也是要调整的, 避免滚动条从固定变为正常时宽度发生变化 width: '100%', // 固定显示时 absolute, 正常显示时 relative position: 'relative', bottom: '0' }, // 邮件自定义水平滚动条样式 -- 内部与 iframe documentElement 等宽 div 的样式 mailScrollInnerStyleObj: { // 固定属性 // 高度尽可能小 height: '1px', // 背景透明 'background-color': 'transparent', // 变动属性 // 模拟 iframe 内部文档宽度, 保证外部 div 滚动条显示逻辑和 iframe 系统水平滚动条逻辑一致 width: '0' }, // 监听: iframe 所在 overflow div 发生垂直滚动 ($debounce 是自己写的防抖方法) handleMailVerticalScroll: this.$debounce(() => { // 暂存 iframe 元素 const ifrEle = document.querySelector(`iframe[name="${this.mailFrameName}"]`); if (ifrEle) { // 获取 iframe rect.bottom 和其 offsetParent rect.bottom , 以判断 iframe 底部是否在其 offsetParent 下方(还要算上指定 bottom , 避免 offsetParent 和滚动容器位置不一致) const ifrRec = ifrEle.getBoundingClientRect(); const scrollRec = ifrEle.offsetParent.getBoundingClientRect(); if (ifrRec.bottom > scrollRec.bottom + this.scrollBarBottom) { // iframe 底部在其 offsetParent 下方 // 固定显示水平滚动条在 offsetParent 底部 this.mailScrollContainerStyleObj.position = 'absolute'; this.mailScrollContainerStyleObj.bottom = `${this.scrollBarBottom}px`; } else { // iframe 底部不在其 offsetParent 下方 // 水平滚动条正常显示在原位置(iframe 之下) this.mailScrollContainerStyleObj.position = 'relative'; this.mailScrollContainerStyleObj.bottom = '0'; } } }, 10), // 监听: iframe 下方模拟水平滚动条 发生水平滚动 handleMailHorizontalScroll: this.$debounce((e) => { // 暂存 iframe window const ifr = window.frames[this.mailFrameName]; // 控制 iframe documentElement 左偏移量 if (ifr && ifr.document && ifr.document.documentElement) { ifr.document.documentElement.scrollLeft = e.target.scrollLeft; } }, 10) }; }
-
监听 iframe hash 值变化(之前的渲染方法里写了, 文档宽度变化时更新数据到 hash 中), 调整自定义滚动条和其容器的 width
// 之前 renderMailHtml 方法预留有参数 callBack , 调用时给此参数传入下面的方法就行了 // 监听: iframe hash 值变化 handleIframeHashChange() { setTimeout(() => { // 暂存 iframe window const ifr = window.frames[this.mailFrameName]; ifr.onhashchange = () => { // hash 值解码 const hashVal = decodeURIComponent(ifr.location.hash); if (hashVal) { // 取到 iframe clientWidth let temp = hashVal.match(/iframeWidth=(.*?);/); // 设置模拟滚动条外部 div 宽度为 iframe clientWidth if (temp[1]) { this.mailScrollContainerStyleObj.width = `${temp[1]}px`; } // 取到 iframe document scrollWidth temp = hashVal.match(/documentWidth=(.*?);/); // 设置模拟滚动条内部 div 宽度为 iframe documentElement scrollWidth if (temp[1]) { this.mailScrollInnerStyleObj.width = `${temp[1]}px`; } } }; }, 100); }
-
监听滚动条的宽度, 避免页面宽度足够, 不用显示水平滚动条时, 水平滚动条仍然占位, 导致底部无法点击
watch: { 'mailScrollInnerStyleObj.width': { handler(val) { // 获取模拟滚动条容器 const scrollBarDiv = this.$refs.mailIframeScroll; if (scrollBarDiv) { // 模拟滚动条内部 div 宽度大于容器宽度时, 才允许鼠标点击滚动条区域(避免用户想点击邮件内容却点中滚动条, 导致点击无效) this.mailScrollContainerStyleObj['pointer-events'] = parseFloat(val) > scrollBarDiv.clientWidth ? 'auto' : 'none'; // 主动触发垂直滚动方法, 判断当前滚动条应该固定显示还是正常显示 this.handleMailVerticalScroll(); } } } }