基于vuedraggable实现跨iframe页面拖拽

1,334 阅读5分钟

前言

“所见即所得”是实现低代码平台的要点之一。用户在使用低代码平台的时候,他只关心预览页面,和低代码平台本身没有任何关系,无需考虑组件区和配置区的逻辑。而iframe带来的天然的浏览器级别的隔离性可以很好的防止用户在使用设计器的时候受到其他区域的影响。此外,嵌入iframe页面可以很方便地实现媒体查询和移动端预览,缩放页面等功能。

vueDraggable

Vue.Draggable是一款基于Sortable.js实现的vue拖拽插件。支持移动设备、拖拽和选择文本、智能滚动,可以在不同列表间拖拽、不依赖jQuery为基础、vue 2过渡动画兼容、支持撤销操作,总之是一款非常优秀的vue拖拽组件。

关于插件的使用,在掘金和其他网站上已经有很详细的教程。

需求分析

vueDraggable可以很方便的实现拖拽功能。但是这个需求的难点在于嵌入iframe后,拖动主页面的元素,iframe页面要及时做出响应,当鼠标拖动元素进入iframe页面区域的时候,要触发iframe页面的拖动事件。

经过以上分析,我们可以将问题拆分为主要的两个部分:

  • 问题一:需要找到一种合适的,代价足够小的通信方案,实现主页面和iframe页面的数据交互;
  • 问题二:拖动主页面元素进入iframe界面时,iframe页面要做出响应。

实现思路

针对问题一

实现iframe通信的方式有很多,主要考虑的方法有以下三种:

  • postMessage

    postMessage是HTML5引入的一种新特性,用于在跨域的两个窗口之间传递数据。该方法基于事件模型,传递的消息只有在对方窗口正确响应的情况下,才能接收到。

    示例代码:

    父页面

       var ifr = document.getElementById('childFrame');
    
       ifr.contentWindow.postMessage('你好子窗口', 'http://www.child.com');
    
       window.addEventListener('message', function(e) {
         if (e.origin === 'http://www.child.com') {
           console.log(e.data);
         }
       }, false);
    
    

    子页面

       window.addEventListener('message', function(e) {
         if (e.origin === 'http://www.parent.com') {
           console.log(e.data);
           e.source.postMessage('你好父窗口', 'http://www.parent.com');
         }
       }, false);
    

    vueuse(useBroadcastChannel | VueUse) 针对这一通信方式做了很好的封装。

  • location.hash

    在同一个域名下,当改变浏览器的地址栏时,会触发hashchange事件。因此,可以通过修改iframe的location.hash值,来实现同源下的跨窗口通信。

    示例代码:

    父页面

       var ifr = document.getElementById('childFrame');
    
       txt.onkeyup = function() {
         ifr.src = ifr.src.split('#')[0] + '#' + txt.value;
       };
    
       window.addEventListener('hashchange', function() {
         console.log(ifr.contentWindow.location.hash);
       }, false);
    
    

    子页面

       window.onhashchange = function() {
         console.log(location.hash);
         parent.window.location.hash = location.hash;
       };
    
  • window.name

    window.name属性在同一窗口下,即使页面跳转,值也不会发生变化。因此,可以通过给iframe的window.name属性赋值,来实现同源下的跨窗口通信。

    父页面

       var ifr = document.getElementById('childFrame');
    
       ifr.onload = function() {
         ifr.contentWindow.name = '你好子窗口';
       };
    
       window.onmessage = function(e) {
         console.log(e.data);
       };
    
    

    子页面

       window.name = '你好父窗口';
       window.onload = function() {
         window.parent.postMessage(window.name, '*');
       };
    

经过测试,以上三种方案均可以实现跨窗口通信。以上三种方式,首先考虑的是postMessage,但是在项目实测过程中发现,当数据量较大的时候,postMessage的传递方式会存在一定的延迟(不确定是否是我使用不当的问题)。

最后,选择采用window.name的方式。通过给iframe.contentWindow添加接收数据的函数,在主页面调用函数,并给函数传参的方式,实现跨窗口通信。实测这种方式基本上不存在延迟(或者延迟很小)。

针对问题二

image.png

框架在实现拖拽功能的时候,本质上是触发了被拖拽元素的 "dragstart" 事件。而我们需求的本质是要在拖动组件区域的元素时,可以触发iframe窗口的拖拽效果。但是,由于组件区和设计器被iframe分割开了,直接使用vuedraggable框架我们会发现,被iframe窗口包裹起来的设计器,不能和组件区域的拖拽组件联动。

那么,我们只要在组件区域的元素被拖动的时候,通知iframe窗口,设计器渲染对应的组件元素,然后再使用js代码触发对应元素的 "dragstart" 事件就可以实现对应的效果。但是,这种方式会导致我们在拖动组件区元素的时候,设计器就渲染了对应的组件,而需求应该是当我们把鼠标移入设计器区域,并释放鼠标之后,再把组件渲染出来。

面对这一情况,我在设计器外创建了一块用来存放临时数据的空间(宽高为0,在视图上不可见),组件区域的元素被拖动,首先会往临时数据区域添加数据,而当我们的鼠标进入设计器,并释放鼠标的时候,才会在设计器中渲染对应的组件。这样,我们就成功实现了需求想要的效果。

代码实现(部分)

  • 组件区域
 <draggable
  :list="item.cpnList"
  :clone="handleDragClone"
  item-key="id"
>
  <template #item="{ element, index: comIndex }">
    <div
      v-if="!element.hidden"
      :component-name="element.componentName"
      @dragstart="() => handleDragStart(comIndex, index)"
    >
      {{element.componentLabel}}
    </div>
  </template>
</draggable>

const handleDragClone = (original) => {
    let data = cloneObject(original);
    let { componentName } = data;
    let cloneId = generateComponentId(componentName); // 生成随机组件id
    const cloneData = {
      ...data,
      id: cloneId,
    };
    emits('cloneComData', cloneData); // 抛出组件克隆事件
    return cloneData;
};
  • 主页面
<ComponentList
  @dragEnd="comListDragEnd"
  ref="comListRef"
  @cloneComData="handleCloneComponent"
/>

<div">
  <iframe
    width="100%"
    height="100%"
    id="draw-iframe"
    src="./designIframe"
  ></iframe>
</div>

// iframe 为子窗口对象
const handleCloneComponent = (cloneData) => {
    // 触发子窗口的拖拽事件
    iframe.contentWindow._my_drag_.triggerDragEvent(cloneData)
}
  • 设计器
 <div>
   <Draggable
     v-model={templateData.value}
     ghostClass="moving"
     itemKey="id"
     group="all"
     onEnd={listenDragEnd}
   >
     {{
       item: ({ element, index }) => {
         let { componentName, id } = element;
         return (
           <div
             id={id + 'Temp'}
             component={element}
             component-name={componentName}
           ></div>
         );
       },
     }}
   </Draggable>
   <Draggable
     v-model={schema.value}
   >
     // 渲染真实组件
     {{
       item: ({ element, index }) => {
         let { componentName, id } = element;
         return (
           <div
             id={id + 'Com'}
             component={element}
             component-name={componentName}
           ></div>
         );
       },
     }}
   </Draggable>
</div>

let dragEl = null;
const dispatchDragEl = (id) => {
  nextTick(() => {
    dragEl = document.getElementById(id + 'Temp');
    var startEvt = new DragEvent('dragstart', { bubbles: true });
    var downEvt = new PointerEvent('pointerdown', {
      bubbles: true,
      cancelable: true,
      isPrimary: true,
    });
    // 这里触发 dragstart 事件之前,想要先触发 pointerdown 事件
    dragEl.dispatchEvent(downEvt);
    dragEl.dispatchEvent(startEvt);
  });
};
// 接收到组件区域的事件
cosnt triggerDragEvent = () => {
    const cloneCompData = JSON.parse(JSON.stringify(data.value));
    templateData.value.push(cloneCompData);
    dispatchDragEl(cloneCompData.id);
}
// 将函数挂载到window对象上
window._my_drag_.triggerDragEvent = triggerDragEvent

样例

全力更新ing...

最后

以上是比较简单的思考过程和粗略的解决方案,完整的代码还有许多细节需要处理。后续笔者有空的话(不会太懒的情况下),会整理出一份完整的Demo。

最后,写下本文的目的更多是为了记录一下自己的思考过程和实现方案。如果有机会帮到遇到同样问题的朋友和伙伴绝对是我的荣幸。同时,笔者其实是一名刚毕业的新人前端,文章中可能存在一些错误或者是大佬们有更好的实现方式欢迎指教。