写点什么

Iframe 在 Vue 中的状态保持技术 | 京东云技术团队

  • 2023-05-25
    北京
  • 本文字数:4354 字

    阅读完需:约 14 分钟

Iframe在Vue中的状态保持技术 | 京东云技术团队

引言

Iframe 是一个历史悠久的 HTML 元素,根据 MDN WEB DOCS 官方介绍,Iframe 定义为 HTML 内联框架元素,表示嵌套的 Browsing Context,它能够将另一个 HTML 页面嵌入到当前页面中。Iframe 可以廉价实现跨应用级的页面共享,并且具有使用简单、高兼容性、内容隔离等优点,因此以 Iframe 为核心形成了前端平台架构领域第 1 代技术。



众所周知,当 Iframe 在 DOM 中初始渲染时,会自动加载其指向的资源链接 Url,并重置内部的状态。在一个典型的平台应用中,一个父应用主页面要挂载多个窗口(每一个窗口对应一个 Iframe),那么如何在切换窗口时,实现每一个窗口中的状态(包括输入状态、锚点信息等)不丢失,也即“状态保持”呢?


如果采用父子应用通信来记录窗口状态,那么改造成本是非常巨大的。答案是利用 Iframe 的 CSS Display 特性,切换窗口时,非激活状态的窗口并不消失,仅是 Display 状态变更为 none,激活状态窗口的 Display 状态变更为非 none。在 Display 状态切换时,Iframe 不会重新加载。在 Vue 应用中,一行 v-show 指令即可替我们实现这一需求。

竞争机制

上述的状态保持模型存在一个性能缺陷,即父应用主页面实际上要提前摆放多个 Iframe 窗口。即使是这些不可见的窗口,也会发出资源 request 请求。大量的并发请求,会导致页面性能下降。(值得一提的是,Chrome 最新版本已经支持了 Iframe 的滚动懒加载策略,但是在此场景下,并不能改善并发请求的问题。)因此,我们需要引入资源池和竞争机制来管理多个 Iframe。



引入一个容量为 N 的 Iframe 资源池来管理多开窗口,当资源池未满时,新激活的窗口可以直接插入至资源池中;当资源池已满时,资源池按照竞争策略,淘汰若干池中的窗口并丢弃,然后插入新激活的窗口至资源池中。通过调整容量 N,可以限制父应用主页面上多开窗口的数量,从而限制并发请求数量,实现资源管控的目的。

Vue Patch 原理探索

日前遇到了一个基于 Vue 应用的 Iframe 状态保持问题,在上述模型下,资源池不仅保存窗口对象,而且记录了每个窗口的点击激活时间。资源池使用以下竞争淘汰策略:对窗口激活时间进行先后次序排序,激活时间排序次序较前的窗口优先被淘汰。当资源池满时,会偶发池中窗口状态不能保持的问题。


在 Vue 中,组件是一个可复用的 Vue 实例,Vue 会尽可能高效地渲染元素,通常会复用已有元素而不是从头开始渲染。组件状态是否正确保持,依赖关键属性 key。基于此,首先排查了 Iframe 组件的 key 属性。事实上,Iframe 组件已经正确分配了唯一的 Uid,此种情况可以排除。


既然不是组件复用的问题,那么在 Vue 内部的 Diff Patch 机制到底是如何运行的呢?让我们看一下 Vue 2.0 的源代码:


/** * 页面首次渲染和后续更新的入口位置,也是 patch 的入口位置  */Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {  if (!prevVnode) {    // 老 VNode 不存在,表示首次渲染,即初始化页面时走这里    ……  } else {    // 响应式数据更新时,即更新页面时走这里    vm.$el = vm.__patch__(prevVnode, vnode)  }}
复制代码


(1)在 update 生命周期下,主要执行了vm.__patch__方法。


/** * vm.__patch__ * 1、新节点不存在,老节点存在,调用 destroy,销毁老节点 * 2、如果 oldVnode 是真实元素,则表示首次渲染,创建新节点,并插入 body,然后移除老节点 * 3、如果 oldVnode 不是真实元素,则表示更新阶段,执行 patchVnode */function patch(oldVnode, vnode, hydrating, removeOnly) {  …… // 1、新节点不存在,老节点存在,调用 destroy,销毁老节点  if (isUndef(oldVnode)) {    …… // 2、老节点不存在,执行创建新节点  } else {    // 判断 oldVnode 是否为真实元素    const isRealElement = isDef(oldVnode.nodeType)    if (!isRealElement && sameVnode(oldVnode, vnode)) {      // 3、不是真实元素,但是老节点和新节点是同一个节点,则是更新阶段,执行 patch 更新节点      patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)    } else {      ……// 是真实元素,则表示初次渲染    }  }  invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)  return vnode.elm}
复制代码


(2)在__patch__方法内部,触发patchVnode方法。


function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {  ……  if (isUndef(vnode.text)) {// 新节点不为文本节点    if (isDef(oldCh) && isDef(ch)) {// 新旧节点的子节点都存在,执行diff递归      if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)    } else {      ……    }  } else {    ……  }}
复制代码


(3)在patchVnode方法内部,触发updateChildren方法。


/** * diff 过程: *   diff 优化:做了四种假设,假设新老节点开头结尾有相同节点的情况,一旦命中假设,就避免了一次循环,以提高执行效率 *   如果不幸没有命中假设,则执行遍历,从老节点中找到新开始节点 *   找到相同节点,则执行 patchVnode,然后将老节点移动到正确的位置 *   如果老节点先于新节点遍历结束,则剩余的新节点执行新增节点操作 *   如果新节点先于老节点遍历结束,则剩余的老节点执行删除操作,移除这些老节点 */function updateChildren(parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {  // 老节点的开始索引  let oldStartIdx = 0  // 新节点的开始索引  let newStartIdx = 0  // 老节点的结束索引  let oldEndIdx = oldCh.length - 1  // 第一个老节点  let oldStartVnode = oldCh[0]  // 最后一个老节点  let oldEndVnode = oldCh[oldEndIdx]  // 新节点的结束索引  let newEndIdx = newCh.length - 1  // 第一个新节点  let newStartVnode = newCh[0]  // 最后一个新节点  let newEndVnode = newCh[newEndIdx]  let oldKeyToIdx, idxInOld, vnodeToMove, refElm
// 遍历新老两组节点,只要有一组遍历完(开始索引超过结束索引)则跳出循环 while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { if (isUndef(oldStartVnode)) { // 如果节点被移动,在当前索引上可能不存在,检测这种情况,如果节点不存在则调整索引 oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left } else if (isUndef(oldEndVnode)) { oldEndVnode = oldCh[--oldEndIdx] } else if (sameVnode(oldStartVnode, newStartVnode)) { // 老开始节点和新开始节点是同一个节点,执行 patch patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx) // patch 结束后老开始和新开始的索引分别加 1 oldStartVnode = oldCh[++oldStartIdx] newStartVnode = newCh[++newStartIdx] } else if (sameVnode(oldEndVnode, newEndVnode)) { // 老结束和新结束是同一个节点,执行 patch patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx) // patch 结束后老结束和新结束的索引分别减 1 oldEndVnode = oldCh[--oldEndIdx] newEndVnode = newCh[--newEndIdx] } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right // 老开始和新结束是同一个节点,执行 patch …… } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left // 老结束和新开始是同一个节点,执行 patch …… } else { // 如果上面的四种假设都不成立,则通过遍历找到新开始节点在老节点中的位置索引 …… // 在老节点中找到新开始节点了 if (sameVnode(vnodeToMove, newStartVnode)) { // 如果这两个节点是同一个,则执行 patch patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx) // patch 结束后将该老节点置为 undefined oldCh[idxInOld] = undefined canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm) } else { // 最后这种情况是,找到节点了,但是发现两个节点不是同一个节点,则视为新元素,执行创建 …… } // 老节点向后移动一个 newStartVnode = newCh[++newStartIdx] } } // 走到这里,说明老姐节点或者新节点被遍历完了,执行剩余节点的处理 ……}
复制代码


(4)咱们终于来到了主角updateChildren。在updateChildren内部实现中,使用了 2 套指针分别指向新旧 Vnode 头尾,并向中间聚拢递归,以实现新旧数据对比刷新。



在前述资源池模型下,当查找到新旧 Iframe 组件时,会执行如下逻辑:


if (sameVnode(vnodeToMove, newStartVnode)) {          // 如果这两个节点是同一个,则执行 patch          patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)          // patch 结束后将该老节点置为 undefined          oldCh[idxInOld] = undefined          canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)}
复制代码


看来出现问题的罪魁祸首是执行了nodeOps.insertBefore。在 WEB 的运行环境下实际上执行的是 DOM 的 insertBefore API。那么我们移步来看看在 DOM 环境下,Iframe 究竟是采取了何种刷新策略。

Iframe 的状态刷新机制

为了更清晰地看到 DOM 节点的变化情况,我们可以引入 MutationObserver 在最新版 Chrome 中来观测 DOM 根节点。


首先设置容器节点下有两个子节点:<span/><iframe/>,分别执行以下方案并记录结果:


对比方案 A:使用 insertBefore 在 iframe 节点前再插入一个新的 span 节点


对比方案 B:使用 insertBefore 在 iframe 节点后再插入一个新的 span 节点


对比方案 C:使用 insertBefore 交换 span 和 iframe 节点


对比方案 D:使用 insertBefore 原地操作 iframe 自身


其结果如下:



实验结果显示,对 Iframe 执行 insertBefore 时,实际上 DOM 会依次执行移除、新增节点操作,导致 Iframe 状态刷新。


在 Vuejs Issues #9473 中提到了类似的问题,一种解决方案是在 Vue Patch 时优先对非 Iframe 类型元素进行 DOM 操作,但是目前这个优化策略尚未被采用,在 Vue 3.0 版本中也依然存在这个问题。


那么在资源池模型下,如何才能保证 Iframe 不执行 insertBefore 呢?重新回到 Vue Patch 机制下,我们发现,只有新旧 Iframe 在新旧 Vnode 列表中的相对位置保持不变时,才会只执行 patchVnode 方法,而不会触发 insertBefore 方法。


因此,采取的最终解决方案是,更改淘汰机制,将排序操作改为搜索操作,保证了多开窗口在 Vue 中的状态保持。


作者:京东零售 陈震

内容来源:京东云开发者社区

发布于: 刚刚阅读数: 3
用户头像

拥抱技术,与开发者携手创造未来! 2018-11-20 加入

我们将持续为人工智能、大数据、云计算、物联网等相关领域的开发者,提供技术干货、行业技术内容、技术落地实践等文章内容。京东云开发者社区官方网站【https://developer.jdcloud.com/】,欢迎大家来玩

评论

发布
暂无评论
Iframe在Vue中的状态保持技术 | 京东云技术团队_html_京东科技开发者_InfoQ写作社区