iframe嵌入另一个系统,类似微前端

86 阅读6分钟

一、背景

开发中,经常遇到将一个系统嵌入到另一个系统的情况,以作为另一个系统的一个模块。可以通过iframe嵌入实现,也可以通过微前端实现。

前段时间搞了一个SaaS的微前端架构,使用无界微前端。但是遇到了不少问题,主要就是兼容性的问题,比如老项目作为子应用嵌入后样式改变了,部分依赖加载报错,有些需要对老项目改造适配,问题复杂且不好处理,因此考虑使用iframe嵌入。部分交付项目嵌入到客户的系统中,使用iframe也是比较稳妥和方便的。

iframe的优势

  • 代码隔离,主应用和子应用代码互不影响。
  • 依赖各自加载,也不会出现依赖无法加载的问题。
  • 样式隔离,父子应用间样式互不影响污染。
  • 方便快捷,子应用适配改造不多

iframe的劣势

  • 父子应用资源无法共用,需要各自加载
  • 子应用的窗口大小为iframe标签的大小,导致居中的弹窗等无法在整个页面中居中的情况
  • 浏览器刷新导致路由无法保持,子应用会打开默认的页面
  • 父子应用比较单一,只能用postMessage实现了

二、解决问题

资源共用问题和子应用窗口居中问题暂不考虑,因为子应用资源的加载一般可以接受,iframe标签占用的页面大小也是比较大或者覆盖整个页面的情况,弹窗居中问题基本不用考虑,否则只能考虑用微前端了。

现在要解决的问题是:

  1. 浏览器刷新子应用页面路由保持
  2. 子应用可单独访问,在嵌入主应用时,主应用可以传输数据,以便子应用能正确初始化

1、思路

以B系统嵌入A系统为例,A、B系统均为vue3项目(其他项目可根据情况处理,原理一样)

  • 针对第一点,要想A系统刷新后,B系统路由保持,可以做如下操作:

    1. B系统改变路由时将当前的路由通过postMessage传给A系统
    /**
     * 使用iframe嵌入到其他应用时,向父页面发送消息
     * @param {*} type 消息类型
     * @param {*} data 数据
     */
    export const postMessage = function (type, data) {
      if (window.parent !== window) {
        window.parent.postMessage(
          {
            type: type,
            data: data
          },
          '*'
        )
      }
    }
    
    // 为了让URL地址尽早地更新,这段代码需要尽可能前置,例如可以直接放在document.head中
    postMessage('afterHistoryChange', { url: location.href })
    
    // 在路由守卫,路由改变后处理
    router.afterEach((to) => {
      // 先判断是子应用,然后跟主应用通信
      if (window !== window.parent && window.__IS_IFRAME_APP__) {
        postMessage('routerChange', {
          url: '#' + to.fullPath
        })
      }
    })
    
    1. A系统监听message事件,并通过 window.history.replaceState方法将其作为参数entry记录在浏览器地址中。
    onMounted(() => {
      console.log('IframeApp: 组件已挂载')
      // 监听 message 事件
      window.addEventListener('message', handleMessage)
    })
    
    // 处理 iframe 的 postmessage 事件
    const handleMessage = (e: MessageEvent) => {
      try {
        const origin = e.origin
        const messageData = e.data as MessageData
    
        // 安全验证:确保消息来自预期的域名
        if (transformUrl.value && !origin.includes(new URL(transformUrl.value).hostname)) {
          console.warn('IframeApp: 拒绝来自未知源的消息', origin)
          return
        }
    
        // 触发自定义 message 事件
        emit('message', messageData)
    
        // 处理特定类型的消息
        switch (messageData.type) {
          // 处理子页面路由变化
          case 'routerChange':
            if (props.enableRouteKeep && messageData.data?.url) {
              handleRouterChange(messageData.data.url)
            }
            break
    
          // 子应用挂载前事件,此时发送初始化数据
          case 'beforeSetup':
            sendInitData()
            break
    
          default:
            break
        }
      } catch (err) {
        console.error('IframeApp: 处理消息失败', err)
      }
    }
    
    // 处理子页面路由变化
    const handleRouterChange = (url: string) => {
      if (!url) return
    
      // 标准化 URL 格式
      let iframeUrl = url.startsWith('/') ? url.substring(1) : url
    
      // 如果路由已变化,更新父页面路由
      if (route?.query?.entry !== iframeUrl) {
        const newUrl = new URL(window.location.href)
        newUrl.searchParams.set('entry', iframeUrl)
        window.history.replaceState(null, '', newUrl.toString())
      }
    }
    
    1. 当浏览器刷新时,A系统可以用浏览器地址解析出B系统最后所在的页面路由entry,跟B系统的主地址拼接后传给iframe.src,B系统即可加载该页面。
    // 计算 iframe 的完整 src
    const iframeSrc = computed(() => {
      if (!transformUrl.value) return ''
      let src = transformUrl.value
      let entry: any = getUrlParam('entry') || ''
      entry = decodeURIComponent(entry)
      if (entry) {
        // 如果 entry 以 '/' 开头,直接拼接
        src += entry.startsWith('/') ? entry : `/${entry}`
      }
    
      return src
    })
    
    /**
     * 从当前页面 URL 的查询字符串中获取指定参数的值
     * @param {string} name 参数名
     * @param {string} [url] 可选,自定义 URL(默认使用 window.location.href)
     * @returns {string|null} 参数值(已解码),未找到则返回 null
     */
    function getUrlParam(name, url?) {
      // 如果不传 url,则使用当前浏览器地址
      const targetUrl = url || window.location.href
      // 提取问号后的查询字符串(包括 hash 部分前面的参数)
      const queryString = targetUrl.split('?')[1]
      if (!queryString) return null
    
      // 移除 hash 部分(#后面的内容,因为 hash 里一般不是 query 参数)
      const pureQuery = queryString.split('#')[0]
      // 使用 URLSearchParams 解析(现代浏览器推荐)
      const params = new URLSearchParams(pureQuery)
      return params.get(name)
    }
    

    这样就能实现B系统路由改变时,浏览器地址也会跟着改变;也能实现浏览器刷新时,B系统还是能正确加载刷新前的页面

  • 针对第二点,可做如下操作:

    1. B系统的main.ts文件中,增加message事件的监听,如果接收到主应用的postMessage数据,且判断为需要将当前应用作为子应用初始化的,就将已挂载的app卸载了,然后根据主应用的数据初始化子应用,比如用户信息、token及其他一些数据的初始化,之后再重新挂载app。
    window.addEventListener('message', (e) => {
        // 接收来自主应用的数据
        console.log('subapp--message', e)
        const messageData = e.data
        // 判断是否是为子应用初始化
        if(messageData.type === 'initIframeApp') {
          window.__IS_IFRAME_APP__ = true // 标记为iframe子应用
          if(app) {
            // 如果app已挂载,则先卸载
            app.unmount()
          }
          // todo: 这里可以在app初始化时做一些操作和准备
          // 比如设置一些全局变量,或者初始化一些数据等
    
          // 重新挂载app
          setupAll()
        }
      })
    
    1. B系统设置监听后,向A系统发送beforeSetup事件通知A系统,让A系统传输数据。

      注: 这一步的目的是确保B系统已经做好监听准备了再让A系统传输数据,保证B系统可以监听到

    // 传输数据
    postMessage('beforeSetup', {test: 'test'})
    const postMessage = function (type, data) {
      if (window.parent !== window) {
        window.parent.postMessage(
          {
            type: type,
            data: data
          },
          '*'
        )
      }
    }
    
    1. A系统中,监听message事件,接收到beforeSetup事件时,给B系统传输数据
    
    onMounted(() => {
      console.log('IframeApp: 组件已挂载')
      // 监听 message 事件
      window.addEventListener('message', handleMessage)
    })
    
    // 处理 iframe 的 postmessage 事件
    const handleMessage = (e: MessageEvent) => {
      try {
        const origin = e.origin
        const messageData = e.data as MessageData
    
        // 安全验证:确保消息来自预期的域名
        if (transformUrl.value && !origin.includes(new URL(transformUrl.value).hostname)) {
          console.warn('IframeApp: 拒绝来自未知源的消息', origin)
          return
        }
    
        // 触发自定义 message 事件
        emit('message', messageData)
    
        // 处理特定类型的消息
        switch (messageData.type) {
          // 处理子页面路由变化
          case 'routerChange':
            if (props.enableRouteKeep && messageData.data?.url) {
              handleRouterChange(messageData.data.url)
            }
            break
    
          // 子应用挂载前事件,此时发送初始化数据
          case 'beforeSetup':
            sendInitData()
            break
    
          default:
            break
        }
      } catch (err) {
        console.error('IframeApp: 处理消息失败', err)
      }
    }
    
    // 发送初始化数据到子页面
    const sendInitData = () => {
      const iframe = iframeRef.value
      if (!iframe || !iframe.contentWindow || !transformUrl.value) return
    
      try {
        // 向 iframe 发送初始化数据
        iframe.contentWindow.postMessage(
          {
            type: 'initIframeApp',
            data: props.initData
          },
          transformUrl.value // 只允许指定的目标源
        )
        console.log('IframeApp: 已发送初始化数据到子页面', props.initData)
      } catch (err) {
        console.error('IframeApp: 发送初始化数据失败', err)
      }
    }
    
    

    这样就能实现在嵌入主应用时,如果主应用给子应用传输相应的数据,嵌入的系统会按照子应用的方式重新初始化并挂载,否则按原来的单独访问的方式初始化挂载

总结

通过以上的操作,可以将子应用挂载到主应用,子应路由可保持且子应用根据主应用的数据进行初始化,享有作为子应用的功能和不同初始化方式,同时也不影响子应用的单独访问。

此方案需要在不考虑父子应用资源共享以及不考虑弹窗居中的情况下使用。同时也需要注意子页面的一些跳转,比如a标签跳转、location.href跳转、window.open跳转、historyAPI跳转等,是在iframe页面内跳转的,如有需要在主应用跳转,需要进行单独拦截处理,可参考:为iframe正名,你可能并不需要微前端

附上我封装的vue3组件:

<template>
  <div class="iframe-container">
    <div v-if="loading" class="loading-overlay">
      <div class="loading-spinner">加载中...</div>
    </div>
    <iframe
      class="iframe"
      ref="iframeRef"
      :src="iframeSrc"
      @load="handleIframeLoad"
      @error="handleIframeError"
    ></iframe>
  </div>
</template>

<script lang="ts" setup>
import { ref, onMounted, onBeforeUnmount, computed, watch } from 'vue'
import { useRoute } from 'vue-router'

// 定义父子通信消息类型
interface MessageData {
  type: string
  data?: any
}

interface IframeProps {
  // iframe 基础 URL
  url: string
  // 传递给子页面的初始化数据
  initData?: Record<string, any>
  // 是否启用路由保持功能
  enableRouteKeep?: boolean
}

// 定义组件属性
const props = withDefaults(defineProps<IframeProps>(), {
  url: '',
  initData: () => ({}),
  enableRouteKeep: true
})

// 定义组件事件
const emit = defineEmits<{
  // iframe 加载完成事件
  (e: 'load'): void
  // iframe 加载失败事件
  (e: 'error', error: Error): void
  // 接收子页面消息事件
  (e: 'message', data: MessageData): void
}>()

const route = useRoute()
const iframeRef = ref<HTMLIFrameElement | null>(null)
const loading = ref(true)
const error = ref<Error | null>(null)

// 转换 URL,确保格式正确
const transformUrl = computed(() => {
  if (!props.url) return ''
  // 移除末尾的 '/' 并确保有协议头
  let url = props.url.replace(/\/$/, '')
  // 如果没有协议头,添加当前页面的协议头
  if (!/^https?:\/\//i.test(url)) {
    url = `${window.location.protocol}//${url}`
  }
  return url
})

// 计算 iframe 的完整 src
const iframeSrc = computed(() => {
  if (!transformUrl.value) return ''

  let src = transformUrl.value
  // const entry = route?.query?.entry as string
  let entry: any = getUrlParam('entry') || ''
  entry = decodeURIComponent(entry)
  console.log('entry', entry)
  if (entry) {
    // 如果 entry 以 '/' 开头,直接拼接
    src += entry.startsWith('/') ? entry : `/${entry}`
  }

  return src
})

// 监听路由变化,更新 iframe src
watch(
  () => route.query?.entry,
  (newEntry, oldEntry) => {
    if (newEntry !== oldEntry && props.enableRouteKeep) {
      const iframe = iframeRef.value
      if (iframe) {
        iframe.src = iframeSrc.value
      }
    }
  }
)

// 监听 URL 变化,重新加载 iframe
watch(
  () => props.url,
  (newUrl, oldUrl) => {
    if (newUrl !== oldUrl && transformUrl.value) {
      loading.value = true
      error.value = null
      // 延迟更新 src,确保 loading 状态显示
      setTimeout(() => {
        const iframe = iframeRef.value
        if (iframe) {
          iframe.src = iframeSrc.value
        }
      }, 100)
    }
  }
)

// 处理 iframe 加载完成
const handleIframeLoad = () => {
  loading.value = false
  error.value = null
  emit('load')
  window.postMessage({
    type: 'iframe-loaded',
    data: {
      __Is_Iframe: true
    }
  })
  console.log('IframeApp: 子页面加载完成')
}

// 处理 iframe 加载错误
const handleIframeError = (e: Event) => {
  loading.value = false
  error.value = new Error('Iframe 加载失败')
  emit('error', error.value)
  console.error('IframeApp: 子页面加载失败', e)
}

// 处理 iframe 的 postmessage 事件
const handleMessage = (e: MessageEvent) => {
  try {
    const origin = e.origin
    const messageData = e.data as MessageData

    // 安全验证:确保消息来自预期的域名
    if (transformUrl.value && !origin.includes(new URL(transformUrl.value).hostname)) {
      console.warn('IframeApp: 拒绝来自未知源的消息', origin)
      return
    }

    // 触发自定义 message 事件
    emit('message', messageData)

    // 处理特定类型的消息
    switch (messageData.type) {
      // 处理子页面路由变化
      case 'routerChange':
        if (props.enableRouteKeep && messageData.data?.url) {
          handleRouterChange(messageData.data.url)
        }
        break

      // 子应用挂载前事件,此时发送初始化数据
      case 'beforeSetup':
        sendInitData()
        break

      default:
        break
    }
  } catch (err) {
    console.error('IframeApp: 处理消息失败', err)
  }
}

// 处理子页面路由变化
const handleRouterChange = (url: string) => {
  if (!url) return

  // 标准化 URL 格式
  let iframeUrl = url.startsWith('/') ? url.substring(1) : url

  // 如果路由已变化,更新父页面路由
  if (route?.query?.entry !== iframeUrl) {
    const newUrl = new URL(window.location.href)
    newUrl.searchParams.set('entry', iframeUrl)
    window.history.replaceState(null, '', newUrl.toString())
  }
}

// 发送初始化数据到子页面
const sendInitData = () => {
  const iframe = iframeRef.value
  if (!iframe || !iframe.contentWindow || !transformUrl.value) return

  try {
    // 向 iframe 发送初始化数据
    iframe.contentWindow.postMessage(
      {
        type: 'initIframeApp',
        data: props.initData
      },
      transformUrl.value // 只允许指定的目标源
    )
    console.log('IframeApp: 已发送初始化数据到子页面', props.initData)
  } catch (err) {
    console.error('IframeApp: 发送初始化数据失败', err)
  }
}

// 向子页面发送消息
const sendMessage = (type: string, data?: any) => {
  const iframe = iframeRef.value
  if (!iframe || !iframe.contentWindow || !transformUrl.value) return

  try {
    iframe.contentWindow.postMessage({ type, data }, transformUrl.value)
    console.log('IframeApp: 已发送消息到子页面', { type, data })
  } catch (err) {
    console.error('IframeApp: 发送消息失败', err)
  }
}

/**
 * 从当前页面 URL 的查询字符串中获取指定参数的值
 * @param {string} name 参数名
 * @param {string} [url] 可选,自定义 URL(默认使用 window.location.href)
 * @returns {string|null} 参数值(已解码),未找到则返回 null
 */
function getUrlParam(name, url?) {
  // 如果不传 url,则使用当前浏览器地址
  const targetUrl = url || window.location.href
  // 提取问号后的查询字符串(包括 hash 部分前面的参数)
  const queryString = targetUrl.split('?')[1]
  if (!queryString) return null

  // 移除 hash 部分(#后面的内容,因为 hash 里一般不是 query 参数)
  const pureQuery = queryString.split('#')[0]
  // 使用 URLSearchParams 解析(现代浏览器推荐)
  const params = new URLSearchParams(pureQuery)
  return params.get(name)
}

// 暴露方法给父组件
defineExpose({
  sendMessage,
  sendInitData
})

onMounted(() => {
  console.log('IframeApp: 组件已挂载')
  // 监听 message 事件
  window.addEventListener('message', handleMessage)
})

onBeforeUnmount(() => {
  console.log('IframeApp: 组件即将卸载')
  // 移除 message 事件监听
  window.removeEventListener('message', handleMessage)
})
</script>

<style lang="less" scoped>
.iframe-container {
  position: relative;
  width: 100%;
  height: 100%;
}

.loading-overlay {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background-color: rgba(255, 255, 255, 0.8);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 10;
}

.loading-spinner {
  padding: 16px 32px;
  background-color: #fff;
  border-radius: 8px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
  font-size: 14px;
  color: #666;
}

.iframe {
  width: 100%;
  height: 100%;
  border: none;
}
</style>