Bootstrap

iframe 渲染请求到的 html (邮件预览), 避免样式污染 + 打印 iframe 邮件详情 + iframe 预览邮件时固定水平滚动条在视口底部

iframe 渲染请求到的 html (邮件预览), 避免样式污染

背景:
之前弄了邮件系统, 但显示邮件内容时是直接 v-html , 导致邮件内容和项目样式互相污染; 之前代码是去掉邮件内容的样式文件, 结果导致部分内容显示错位, 现在想不改邮件内容, 用 iframe 包裹邮件内容显示

思路:

需要解决几个难点

  1. iframe 宽高如何随内容变化而变化
  2. iframe 如何与父级通讯
  3. 如何将不同类型的返回内容渲染成 html 放到 iframe 中

解决:

代码以 Vue 形式写的, 下面的代码是最终代码(iframe 渲染 + 自定义水平固定滚动条 + 打印 + 其他删除功能)拆分而来, 可能有些错漏/未删除变量

  1. 新建一个空白 HTML 页面, mailDetail.html , 只有最基本的 html 格式, 无任何内容

  2. 详情页新增 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>
    
  3. 请求到邮件内容, 并对邮件内容做了处理, 调用方法渲染邮件内容到 iframe 中

    // 处理邮件详情代码并渲染到iframe中
    renderCodeToIframe(mailContent) {
        /** handleMailHTML方法 和 renderMailHTML方法都来源mixins(mailContent) */
        // 生成 iframe documentElement 代码
        this.content = this.handleMailHTML(mailContent);
        // 渲染代码到 iframe 中
        this.renderMailHTML(this.mailFrameName, this.content);
    },
    
  4. 渲染相关方法, from mixins(mailContent), 可以理解为提取出公共的方法到某处, 方便其他地方复用

    1. 对原始邮件内容做一些处理, 返回 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;
      }
      
    2. 渲染 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 邮件详情

  1. 新增一个打印用的 iframe , 隐藏不显示

    <!-- 邮件打印的iframe容器 -->
    <iframe id="printf" name="printf" style="display: none;"></iframe>
    
  2. 调用下方打印方法打印

    // 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 继续向上移动

解决:

做一个模拟滚动条满足此需求, 其原理为:

  1. 邮件详情 iframe 本身不显示水平滚动条, 在 iframe 底部新增一个两层 div , 外部 div 宽度与 iframe 保持一致, 内部 div 宽度为 iframe 内部文档实际宽度
  2. 监听模拟滚动条容器(外层 div)的 scroll 事件, 同步将内部 div 的 scrollLeft 赋值给 iframe documentElement 的 scrollLeft
  3. 模拟滚动条容器默认设置为 position: absolute , 监听 iframeElement 的 offsetParent (其所在的 overflow div)的 scroll 事件, 当 iframe 的边界进入视口后, 设置 position 为 relative

具体代码如下:

  1. 滚动条 div

    <!-- 邮件详情 iframe -->
    
    <!-- 自定义滚动条 -->
    <div @scroll="handleMailHorizontalScroll"
         ref="mailIframeScroll"
         :style="mailScrollContainerStyleObj">
        <div :style="mailScrollInnerStyleObj"></div>
    </div>
    
  2. 相关变量/滚动监听器定义

    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)
        };
    }
    
  3. 监听 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);
    }
    
  4. 监听滚动条的宽度, 避免页面宽度足够, 不用显示水平滚动条时, 水平滚动条仍然占位, 导致底部无法点击

    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();
                }
            }
        }
    }
    
;