Tauri 中嵌入百度网页:从 iframe 到 Webview 的迁移实践

Tauri 中嵌入百度网页:从 iframe 到 Webview 的迁移实践

问题描述

在开发 Tauri 桌面应用时,我们需要在一个插件窗口中嵌入百度首页。最初使用 iframe 实现,但遇到了点击无响应的问题。最终通过迁移到 Tauri 的 Webview API 成功解决。

问题背景

我们的应用使用 Tauri 2.0 + Vue 3 + TypeScript 技术栈。需求是在 src/plugins/baidu/index.vue 中实现一个显示百度首页的插件窗口,同时保留窗口控制按钮(最小化、最大化、关闭)。

初次尝试:使用 iframe

实现代码

vue 复制代码
<template>
  <main class="size-full rounded-8px bg-#fff dark:bg-#303030" data-tauri-drag-region>
    <ActionBar style="position: absolute; top: 8px; right: 16px"
               :shrink="false" :max-w="true" :icon-color="'black'"
               :top-win-label="WebviewWindow.getCurrent().label"
               :current-label="WebviewWindow.getCurrent().label"/>
    <iframe src="https://www.baidu.com" class="size-full border-none" frameborder="0"></iframe>
  </main>
</template>

<script setup lang="ts">
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
</script>

遇到的问题

实现后,用户反馈:打开百度网页之后点击没反应

问题分析

通过分析发现,问题出在 iframe 的跨域限制上:

  1. X-Frame-Options 限制 :百度在响应头中设置了 X-Frame-Options: SAMEORIGIN,禁止在 iframe 中嵌入
  2. 跨域安全策略:现代浏览器出于安全考虑,阻止了 iframe 内的点击事件
  3. 用户体验差:即使能加载,iframe 内的交互也会受到各种限制

iframe 方案在嵌入第三方网站(尤其是大型网站如百度)时存在根本性限制,不是技术实现的问题,而是浏览器安全策略的限制。

解决方案:使用 Tauri Webview API

研究现有实现

为了找到正确的实现方式,我研究了项目中其他使用外部链接的插件:

  1. dynamic/index.vue:动态内容插件
  2. robot/index.vue:聊天机器人插件
  3. Bot.vue:聊天框组件,包含 Webview 实现

src/components/rightBox/chatBox/Bot.vue 中找到了关键实现:

typescript 复制代码
const createExternalWebview = async (url: string) => {
  const windowInstance = await ensureHostWindow()
  if (!windowInstance || !webviewContainer.value) return

  try {
    const existing = await Webview.getByLabel(webviewLabel)
    await existing?.close()
  } catch (error) {
    // 忽略未找到的情况
  }

  await destroyExternalWebview()
  const rect = webviewContainer.value.getBoundingClientRect()
  const newWebview = new Webview(windowInstance, webviewLabel, {
    url,
    x: rect.left,
    y: rect.top,
    width: rect.width,
    height: rect.height,
    focus: true,
    dragDropEnabled: true
  })

  externalWebview.value = newWebview
  containerResizeObserver = new ResizeObserver(() => {
    updateExternalWebviewBounds()
  })
  containerResizeObserver.observe(webviewContainer.value)
  window.addEventListener('resize', updateExternalWebviewBounds, { passive: true })
}

Webview API 的优势

相比 iframe,Tauri Webview API 具有以下优势:

  1. 无跨域限制:使用系统级 webview 组件,不受浏览器同源策略限制
  2. 完整交互支持:支持所有正常的网页交互(点击、输入、导航等)
  3. 更好的性能:原生组件,性能优于 iframe
  4. 灵活的窗口管理:可以精确控制位置、大小、焦点等

实现迁移

第一步:添加容器元素

将 iframe 替换为一个容器 div,用于挂载 Webview:

vue 复制代码
<template>
  <main class="size-full rounded-8px bg-#fff dark:bg-#303030" data-tauri-drag-region>
    <ActionBar style="position: absolute; top: 8px; right: 16px"
               :shrink="false" :max-w="true" :icon-color="'black'"
               :top-win-label="currentWindow.label"
               :current-label="currentWindow.label"/>
    <div class="size-full rounded-8px bg-#fff dark:bg-#303030" data-tauri-drag-region>
      <div ref="webviewContainer" class="size-full"></div>
    </div>
  </main>
</template>
第二步:实现 Webview 创建逻辑
typescript 复制代码
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { Webview } from '@tauri-apps/api/webview'
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
import { openUrl } from '@tauri-apps/plugin-opener'

const webviewContainer = ref<HTMLElement | null>(null)
const externalWebview = ref<Webview | null>(null)
const webviewLabel = 'baidu-webview'
const currentWindow = getCurrentWebviewWindow()

let containerResizeObserver: ResizeObserver | null = null
let windowResizeListener: (() => void) | null = null

const updateWebviewBounds = async () => {
  if (!webviewContainer.value || !externalWebview.value) return

  try {
    const rect = webviewContainer.value.getBoundingClientRect()
    await externalWebview.value.setPosition(rect.left, rect.top)
    await externalWebview.value.setSize(rect.width, rect.height)
  } catch (error) {
    console.error('更新 webview 边界失败:', error)
  }
}

const initWebview = async () => {
  await nextTick()

  if (!webviewContainer.value) {
    console.error('webviewContainer 未找到')
    return
  }

  const windowInstance = getCurrentWebviewWindow()

  try {
    const existing = await Webview.getByLabel(webviewLabel)
    if (existing) {
      await existing.close()
    }
  } catch (error) {
    console.log('没有找到已存在的 webview')
  }

  try {
    const rect = webviewContainer.value.getBoundingClientRect()

    const newWebview = new Webview(windowInstance, webviewLabel, {
      url: 'https://www.baidu.com',
      x: rect.left,
      y: rect.top,
      width: rect.width,
      height: rect.height,
      focus: true,
      dragDropEnabled: true
    })

    externalWebview.value = newWebview

    containerResizeObserver = new ResizeObserver(() => {
      updateWebviewBounds()
    })
    containerResizeObserver.observe(webviewContainer.value)

    windowResizeListener = () => {
      updateWebviewBounds()
    }
    window.addEventListener('resize', windowResizeListener, { passive: true })

    newWebview.once('tauri://created', async () => {
      console.log('Webview 创建成功')
      await updateWebviewBounds()
    })

    newWebview.once('tauri://error', (error) => {
      console.error('Webview 创建失败:', error)
      externalWebview.value = null
    })
  } catch (error) {
    console.error('创建 webview 失败:', error)
    console.log('尝试在系统浏览器中打开百度')
    try {
      await openUrl('https://www.baidu.com')
    } catch (openError) {
      console.error('在浏览器中打开失败:', openError)
    }
  }
}

onMounted(async () => {
  await initWebview()
})

onUnmounted(async () => {
  if (containerResizeObserver) {
    containerResizeObserver.disconnect()
  }
  if (windowResizeListener) {
    window.removeEventListener('resize', windowResizeListener)
  }
  if (externalWebview.value) {
    try {
      await externalWebview.value.close()
    } catch (error) {
      console.error('关闭 webview 失败:', error)
    }
  }
})
第三步:修复 WebviewWindow 导入错误

在实现过程中遇到了一个错误:

复制代码
Uncaught ReferenceError: WebviewWindow is not defined

原因 :模板中使用了 WebviewWindow.getCurrent(),但在 script 中只导入了 getCurrentWebviewWindow 函数,没有导入 WebviewWindow 类。

解决方法

  1. 在 script 中添加 const currentWindow = getCurrentWebviewWindow() 来获取当前窗口实例
  2. 在模板中将 WebviewWindow.getCurrent().label 替换为 currentWindow.label

修改后的代码:

typescript 复制代码
const currentWindow = getCurrentWebviewWindow()
vue 复制代码
<ActionBar :top-win-label="currentWindow.label"
           :current-label="currentWindow.label"/>

关键技术点

1. Webview 生命周期管理

typescript 复制代码
// 创建
const newWebview = new Webview(windowInstance, label, options)

// 更新位置和大小
await webview.setPosition(x, y)
await webview.setSize(width, height)

// 关闭
await webview.close()

2. 响应式布局处理

使用 ResizeObserver 监听容器尺寸变化,自动调整 Webview 大小:

typescript 复制代码
containerResizeObserver = new ResizeObserver(() => {
  updateWebviewBounds()
})
containerResizeObserver.observe(webviewContainer.value)

3. 窗口大小变化处理

监听窗口 resize 事件,确保 Webview 始终正确显示:

typescript 复制代码
windowResizeListener = () => {
  updateWebviewBounds()
}
window.addEventListener('resize', windowResizeListener, { passive: true })

4. 清理资源

在组件卸载时清理所有监听器和 Webview 实例:

typescript 复制代码
onUnmounted(async () => {
  if (containerResizeObserver) {
    containerResizeObserver.disconnect()
  }
  if (windowResizeListener) {
    window.removeEventListener('resize', windowResizeListener)
  }
  if (externalWebview.value) {
    await externalWebview.value.close()
  }
})

5. 错误处理和降级方案

当 Webview 创建失败时,降级到在系统浏览器中打开:

typescript 复制代码
try {
  const newWebview = new Webview(windowInstance, webviewLabel, options)
} catch (error) {
  console.error('创建 webview 失败:', error)
  try {
    await openUrl('https://www.baidu.com')
  } catch (openError) {
    console.error('在浏览器中打开失败:', openError)
  }
}

iframe vs Webview 对比

特性 iframe Tauri Webview
跨域限制 受限,很多网站禁止嵌入 无限制
交互支持 受限 完整支持
性能 较差 优秀
窗口管理 有限 灵活
适用场景 同源内容、简单嵌入 外部网站、复杂交互

最佳实践

1. 何时使用 iframe

  • 嵌入同源内容
  • 简单的静态内容展示
  • 不需要复杂交互的场景

2. 何时使用 Tauri Webview

  • 嵌入第三方网站(如百度、Google)
  • 需要完整网页交互
  • 需要精确控制窗口行为
  • 需要更好的性能

3. 注意事项

  1. 获取正确的窗口实例 :使用 getCurrentWebviewWindow() 而不是 WebviewWindow.getCurrent()
  2. 清理资源:务必在组件卸载时清理 Webview 和监听器
  3. 响应式布局:使用 ResizeObserver 处理容器尺寸变化
  4. 错误处理:提供降级方案,提升用户体验
  5. 唯一标识:为每个 Webview 设置唯一的 label,避免冲突

总结

通过这次从 iframe 到 Tauri Webview 的迁移实践,我们成功解决了百度网页点击无响应的问题。关键经验包括:

  1. 理解技术限制:iframe 在嵌入第三方网站时存在根本性限制
  2. 选择正确方案:Tauri Webview API 是嵌入外部网站的最佳选择
  3. 注意 API 使用 :正确使用 getCurrentWebviewWindow() 而不是 WebviewWindow
  4. 完善生命周期管理:妥善处理创建、更新、销毁等各个环节
  5. 提供降级方案:在 Webview 创建失败时提供备选方案

这次实践不仅解决了具体问题,也加深了对 Tauri 框架的理解,为后续开发积累了宝贵经验。

参考资料

相关推荐
小北方城市网4 小时前
Redis 分布式锁高可用实现:从原理到生产级落地
java·前端·javascript·spring boot·redis·分布式·wpf
毕设源码-钟学长6 小时前
【开题答辩全过程】以 基于SpringBoot的智能书城推荐系统的设计与实现为例,包含答辩的问题和答案
java·spring boot·后端
cyforkk6 小时前
MyBatis Plus 字段自动填充:生产级实现方案与原理分析
mybatis
九皇叔叔6 小时前
【03】SpringBoot3 MybatisPlus BaseMapper 源码分析
java·开发语言·mybatis·mybatis plus
不吃香菜学java9 小时前
springboot左脚踩右脚螺旋升天系列-整合开发
java·spring boot·后端·spring·ssm
奋进的芋圆10 小时前
Java 锁事详解
java·spring boot·后端
qq_124987075310 小时前
基于springboot的河南特色美食分享系统的设计与实现(源码+论文+部署+安装)
java·大数据·人工智能·spring boot·计算机毕设·计算机毕业设计
Eaxker11 小时前
Java后端学习3:分层解耦
java·spring boot
马猴烧酒.11 小时前
【Mybatis出现bug】应为 <statement> 或 DELIMITER,得到 ‘id‘
java·bug·mybatis
科威舟的代码笔记11 小时前
SpringBoot配置文件加载顺序:一场配置界的权力游戏
java·spring boot·后端·spring