Vue 3 多实例 + 缓存复用:理念及实践

Vue 3 多实例 + 缓存复用:理念、实践与挑战

在一些复杂的 Web 应用场景中,我们希望在同一页面或多个入口动态创建多个 Vue 实例,它们界面功能完全相同、逻辑相同,但内部数据与状态互不干扰;同时,用户切换回来后实例要能复用,不重建、不丢失状态。本文从需求拆解、设计思路、技术细节、难点与性能优化讲起,给出可运行的代码模板与注意事项,助你在项目中构建健壮的多实例架构。

目录

  • 背景与动机
  • 设计目标与核心需求
  • 实例工厂 + 缓存池 模式
  • 注入初始数据与状态隔离
  • 切换 / 显示 / 隐藏 / 卸载 策略
  • 常见难点与坑
  • 性能 / 内存 优化
  • 对比设计方案
  • 案例演示:多聊天窗口实例
  • 总结与未来方向

1. 背景与动机

1.1 为什么传统单实例模式不够?

在常规 SPA 架构下,一个 Vue 实例管理整个页面的路由、状态、组件树,是主流、也是最清晰的方式。但在以下几类场景中,单实例往往无法满足:

  • 插件 / SDK 嵌入:你希望你的应用片段可以被其他页面插入多个位置,每个位置运行独立模块;
  • 多个浮窗 / 工具面板:每个浮窗其实是一个完整的小应用,希望状态隔离;
  • 多个子页面 / 多入口实例:同一页面可能存在多个 "子应用" 实例并行存在;
  • 切换回来状态要保留:当用户从 A 切换到 B,再回 A,希望 A 的状态维持,不要重置。

如果你用单实例 + 组件切换,要么共享状态,要么每次切换清空,非常影响体验。

1.2 需求拆解:我们到底要什么?

把需求拆成几个关键点:

  • 界面 / 功能 一致:每个实例使用相同组件结构、逻辑代码;
  • 状态隔离:一个实例的修改不影响其他实例;
  • 缓存复用:切回来时还能继续原来状态,不要重 mount/unmount;
  • 按需创建:不是所有 tab / 页面都提前创建,只有访问时才启动;
  • 可控销毁:避免无限实例消耗资源,设计回收机制;
  • 简洁接口:对业务使用方暴露的 API 简单易用,不要让使用方关心内部细节。

2. 设计目标与核心机制

整体架构可以设计成以下模块,各模块职责清晰,协同工作以实现多实例的高效管理与缓存复用。

模块 职责
实例工厂 接收挂载 id + 初始化数据,返回或创建 Vue 实例
容器管理 在 DOM 上创建 / 隐藏 / 显示对应容器节点
状态注入 给实例注入其独立的业务上下文数据
缓存池 保存已创建实例的引用与其状态快照
卸载 / 销毁 过期或不再使用时卸载实例,释放资源
切换逻辑 在显示 / 隐藏之间切换,而不是反复卸载 / 重挂

在这个框架下,最核心是 createAppInstance(targetId, initData) 函数 + instancePool 缓存策略。通过实例工厂函数统一创建和获取实例,借助缓存池实现实例的复用,避免重复创建带来的性能损耗,同时保证实例状态的稳定。

3. 实例工厂 + 缓存池 模式

3.1 缓存池结构

我们使用一个 Map<string, InstanceRecord> 来缓存每个实例,这种数据结构能够快速进行键值对的查找、插入和删除操作,非常适合用于实例的缓存管理。

typescript 复制代码
interface InstanceRecord {
  app: ReturnType<typeof createApp> // Vue 应用实例
  cache: any                        // 业务数据快照,用于保存实例相关的业务数据
  mounted: boolean                  // 标记实例是否已挂载
  lastUsedAt: number                // 最后使用时间,用于判断实例是否过期
}

const instancePool = new Map<string, InstanceRecord>()

其中,key 是挂载容器的 targetId,通过它可以唯一标识一个实例;cache 用于存储业务层传入的初始数据或数据快照,确保实例复用时有数据可恢复;lastUsedAt 则用于后续的过期销毁判断,当实例长时间未使用时,可根据该时间进行清理,释放资源。

3.2 工厂函数 createAppInstance

工厂函数是创建和获取实例的核心入口,它首先检查缓存池中是否已存在该实例,如果存在则直接返回,不存在则创建新实例并加入缓存池。下面是可直接使用或改造的模板代码:

typescript 复制代码
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

export function createAppInstance(targetId: string, initData: any = {}) {
  // 检查缓存池,存在则更新最后使用时间并返回
  if (instancePool.has(targetId)) {
    const rec = instancePool.get(targetId)!
    rec.lastUsedAt = Date.now()
    return rec
  }

  // 确保挂载容器存在,不存在则创建并添加到文档中
  let el = document.getElementById(targetId)
  if (!el) {
    el = document.createElement('div')
    el.id = targetId
    document.body.appendChild(el)
  }

  // 创建 Vue 应用实例,配置 Pinia 状态管理
  const app = createApp(App)
}

代码解释要点:

  • 缓存优先:如果之前已创建该实例,直接从缓存池获取并更新最后使用时间,避免重复创建;
  • 容器保障:确保挂载容器存在,为实例提供稳定的挂载目标;
  • 状态隔离:使用 app.provide 注入初始化数据时进行深拷贝,防止不同实例间因数据引用导致的状态污染;
  • 缓存记录:创建实例后,将其相关信息(应用实例、数据快照、挂载状态、最后使用时间)记录到缓存池,便于后续管理。

4. 注入初始数据与状态隔离策略

4.1 初始化数据注入

在 createAppInstance 函数中,我们通过 app.provide('initData', ...) 向实例注入初始化业务数据。这种方式可以在实例内部的任意组件中通过 inject 方法获取数据,且每个实例获取到的都是独立的一份数据。

注入代码回顾:

typescript 复制代码
app.provide('initData', JSON.parse(JSON.stringify(initData)))

组件中获取数据:

在组件(如 App.vue 或子组件)中,可以通过 inject 方法轻松拿到注入的初始化数据,代码如下:

html 复制代码
<script setup>
import { inject } from 'vue'
// 获取注入的初始化数据,默认值为空对象
const initData = inject('initData', {})
console.log('当前实例初始化数据:', initData)
</script>

由于注入数据时进行了深拷贝,每个实例内部拿到的都是自己独有的数据,修改数据不会影响其他实例,保证了数据层面的状态隔离。

4.2 独立状态管理(Pinia)

在多实例场景下,状态管理的隔离至关重要。如果多个实例共用一个 store 或 Pinia 实例,会导致状态共享,无法实现状态隔离。因此,关键策略是:每个 Vue 实例对应一个独立的 Pinia 实例。

实现方式:

在工厂函数 createAppInstance 中,为每个新创建的 Vue 实例单独创建 Pinia 实例并挂载,代码如下:

这样一来,当在不同实例的组件中使用 useMyStore() 获取状态时,得到的是对应 Pinia 实例下的状态,不同实例的状态完全独立,互不干扰。例如,在组件中使用 Pinia 状态:

通过这种方式,实现了状态管理层面的彻底隔离,确保每个实例的状态独立可控。

5. 切换 / 显示 / 隐藏 / 卸载 策略

在多实例场景中,实例的切换、显示、隐藏和卸载是高频操作。合理的操作策略不仅能保证用户体验(状态不丢失),还能优化性能(减少资源消耗)。

5.1 切换展示(隐藏 / 显示,而不是卸载)

用户在不同实例之间切换时,传统的卸载旧实例、挂载新实例的方式会导致状态丢失,且频繁的 DOM 操作和实例重建会消耗大量性能。因此,我们采用隐藏 / 显示容器的方式实现实例切换,实例本身不进行卸载,从而保留状态并提升性能。

切换逻辑代码:

typescript 复制代码
/**
 * 显示指定实例的容器
 * @param targetId 实例挂载容器的 id
 */
export function showApp(targetId: string) {
  const el = document.getElementById(targetId)
  if (el) el.style.display = 'block'
  // 可选:更新实例最后使用时间
  const rec = instancePool.get(targetId)
  if (rec) rec.lastUsedAt = Date.now()
}

/**
 * 隐藏指定实例的容器
 * @param targetId 实例挂载容器的 id
 */
export function hideApp(targetId: string) {
  const el = document.getElementById(targetId)
  if (el) el.style.display = 'none'
}

优势:

  • 状态保留:实例未被卸载,内部的响应式状态、定时器、事件监听等均保持正常,用户切换回来时可继续之前的操作;
  • 性能优化:避免了实例卸载和重建过程中的 DOM 销毁与创建、组件生命周期重新执行等开销,提升切换速度。

5.2 卸载 / 销毁实例

虽然隐藏 / 显示策略能很好地实现实例复用,但如果实例长时间不用,一直保留在内存中会造成资源浪费。因此,需要设计实例卸载 / 销毁机制,对过期或不再使用的实例进行清理。

销毁逻辑代码:

过期销毁机制设计:

为了自动清理长时间未使用的实例,我们可以设计一个定时检查机制,遍历缓存池中的实例,根据 lastUsedAt 判断实例是否过期(例如,超过 30 分钟未使用),若过期则执行销毁操作。

通过这种主动清理机制,可以有效防止实例无限累积导致的内存泄漏和性能下降问题。

6. 常见难点与坑

在实现 Vue 3 多实例 + 缓存复用的过程中,会遇到一些常见的难点和问题,需要提前规避和解决。

6.1 console.log (app) 太庞大,调试困难

Vue 实例包含大量内部属性(如响应式代理、VNode 树、依赖收集相关数据等),直接打印整个实例会输出海量信息,导致控制台卡顿甚至崩溃,难以定位关键问题。

解决方案:

调试时只打印关键信息,避免打印完整实例。例如,打印实例对应的 targetId、缓存数据、挂载容器等核心信息:

typescript 复制代码
// 推荐:只打印关键信息
const rec = instancePool.get(targetId)
if (rec) {
  console.log('实例调试信息:', {
    targetId,
    cache: rec.cache,
    container: rec.app._container,
    lastUsedAt: new Date(rec.lastUsedAt).toLocaleString()
  })
}

// 不推荐:直接打印完整实例
// console.log(rec.app) // 会输出大量冗余信息

6.2 watch /computed 重复触发

如果在模块级别定义共享的 ref 或 reactive 数据,或者多个实例共用同一个未隔离的状态源(如未单独创建 Pinia 实例),会导致不同实例中的 watch 或 computed 对同一数据进行监听,当数据变化时,所有实例的监听逻辑都会触发,造成不必要的性能消耗和逻辑混乱。

解决方案:

  • 状态私有化:所有状态(包括 ref、reactive、Pinia Store)都在实例内部创建,不在模块层共享;
  • Pinia 独立:确保每个 Vue 实例对应一个独立的 Pinia 实例(如 4.2 节所述);
  • 避免模块级共享数据:模块中只定义工具函数、类型接口等,不定义可修改的响应式数据。

反例(错误做法):

typescript 复制代码
// 模块级共享的响应式数据,会导致多实例监听冲突
export const sharedRef = ref(0)
html 复制代码
<script setup>
import { sharedRef, watch } from 'vue'
// 多个实例都会监听 sharedRef,数据变化时所有实例的 watch 都会触发
watch(sharedRef, (newVal) => {
  console.log('sharedRef 变化:', newVal)
})
</script>

正例(正确做法):

html 复制代码
<script setup>
import { ref, watch } from 'vue'
// 组件内部创建响应式数据,每个实例独立
const privateRef = ref(0) 
watch(privateRef, (newVal) => {
  console.log('当前实例 privateRef 变化:', newVal)
})
</script>

6.3 生命周期 & 异步 / 订阅清理不及时

组件中如果使用定时器(setTimeout、setInterval)、WebSocket 连接、全局事件监听(window.addEventListener)、订阅流(如 RxJS)等资源,若未在组件的 onUnmounted 钩子中及时清理,即使实例容器被隐藏,这些资源仍会在后台运行,导致内存泄漏、不必要的网络请求或逻辑错误。

解决方案:

在组件的 onUnmounted 钩子中,彻底清理所有占用的资源。例如:

html 复制代码
<script setup>
import { onUnmounted } from 'vue'

// 1. 定时器清理
const timer = setInterval(() => {
  console.log('定时器执行')
}, 1000)

// 2. 全局事件监听清理
function handleResize() {
  console.log('窗口大小变化')
}
window.addEventListener('resize', handleResize)

// 3. WebSocket 连接清理
const ws = new WebSocket('wss://example.com')
ws.onopen = () => {
  ws.send('连接建立')
}

// 在组件卸载时清理所有资源
onUnmounted(() => {
  // 清理定时器
  clearInterval(timer)
  // 移除全局事件监听
  window.removeEventListener('resize', handleResize)
  // 关闭 WebSocket 连接
  if (ws.readyState === WebSocket.OPEN) {
    ws.close(1000, '组件卸载')
  }
  // 若使用 RxJS 等订阅流,需取消订阅
  // subscription.unsubscribe()
})
</script>

关键原则:所有 "跨生命周期" 的资源(即创建后不会自动随组件卸载而释放的资源),都必须在 onUnmounted 中手动清理,避免内存泄漏。

6.4 容器 id 冲突 / DOM 被意外删除

多实例依赖唯一的 targetId 挂载容器,若出现以下情况,会导致实例挂载失败或状态异常:

  • id 重复:不同实例使用相同的 targetId,后创建的实例会覆盖先创建的实例;
  • DOM 被删除:业务代码意外删除了实例的挂载容器(如 document.getElementById(targetId).remove()),但未调用 destroyApp,导致实例引用残留。

解决方案:

  • id 生成规范:使用 "前缀 + 唯一标识" 生成 targetId,例如 chat-window-userId−{userId}-userId−{timestamp},避免手动指定重复 id;
  • DOM 操作封装:禁止业务代码直接操作实例挂载容器,所有容器的创建 / 删除通过 createContainer/destroyContainer 工具函数进行:
typescript 复制代码
// 工具函数:创建并返回唯一容器
export function createContainer(prefix: string): string {
  const targetId = `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`
  const el = document.createElement('div')
  el.id = targetId
  document.body.appendChild(el)
  return targetId
}

// 工具函数:安全删除容器
export function destroyContainer(targetId: string) {
  const el = document.getElementById(targetId)
  if (el && el.parentNode) {
    el.parentNode.removeChild(el)
  }
}
  • 实例状态校验:在 showApp/hideApp 等函数中增加容器存在性校验,避免操作不存在的 DOM:
typescript 复制代码
export function showApp(targetId: string) {
  const el = document.getElementById(targetId)
  if (!el) {
    throw new Error(`实例容器 ${targetId} 已被删除,请检查 DOM 操作`)
  }
  el.style.display = 'block'
}

6.5 CSS / 样式污染

多个实例共用相同的组件结构,若样式未做隔离,会出现 "一个实例的样式影响另一个实例" 的问题(例如,两个聊天窗口的标题样式互相覆盖)。

解决方案:

  • 基础隔离:优先使用 Vue 内置的
html 复制代码
<!-- ChatWindow.vue:使用 scoped 样式 -->
<style scoped>
.chat-title {
  font-size: 16px;
  color: #333;
}
</style>
html 复制代码
<!-- 或使用 CSS Modules -->
<style module>
.title {
  font-size: 16px;
  color: #333;
}
</style>
<template>
  <h2 :class="$style.title">聊天窗口</h2>
</template>
  • 进阶隔离:若需更强的样式隔离(如实例间有完全不同的主题),可结合 Shadow DOM 封装实例容器:
typescript 复制代码
// 改造 createAppInstance:使用 Shadow DOM 隔离样式
export function createAppInstance(targetId: string, initData: any = {}) {
  // ... 省略缓存检查逻辑 ...

  let el = document.getElementById(targetId)
  if (!el) {
    el = document.createElement('div')
    el.id = targetId
    // 创建 Shadow DOM 并附加到容器
    const shadowRoot = el.attachShadow({ mode: 'open' })
    // 在 Shadow DOM 中创建挂载点
    const mountPoint = document.createElement('div')
    mountPoint.id = `mount-${targetId}`
    shadowRoot.appendChild(mountPoint)
    document.body.appendChild(el)
  }

  // 注意:此时挂载目标需改为 Shadow DOM 中的挂载点
  const shadowRoot = el.shadowRoot
  if (!shadowRoot) {
    throw new Error(`容器 ${targetId} 未初始化 Shadow DOM`)
  }
  const mountPoint = shadowRoot.getElementById(`mount-${targetId}`)
  if (!mountPoint) {
    throw new Error(`Shadow DOM 挂载点不存在`)
  }

  // 挂载到 Shadow DOM 内的节点
  const app = createApp(App)
  app.mount(mountPoint)
  // ... 省略后续逻辑 ...
}
  • 命名规范:若无法使用 Shadow DOM,可通过 BEM 命名规范 为每个实例的样式添加唯一前缀(如基于 targetId 生成前缀):
typescript 复制代码
// 实例中生成唯一样式前缀
const stylePrefix = `chat-window-${targetId}`
html 复制代码
<template>
  <div :class="stylePrefix">
    <h2 :class="`${stylePrefix}__title`">聊天窗口</h2>
  </div>
</template>
<style>
.chat-window-${targetId}__title {
  font-size: 16px;
  color: #333;
}
</style>

7. 性能 / 内存 优化

多实例架构若不做优化,会因实例数量累积、资源占用过高导致页面卡顿。以下是针对性的优化策略:

7.1 限制实例总数,避免无限创建

通过 "最大实例数阈值" 控制缓存池大小,当实例数量超过阈值时,销毁 "最久未使用(LRU)" 的实例:

typescript 复制代码
// 配置:最大实例数
const MAX_INSTANCE_COUNT = 5

export function createAppInstance(targetId: string, initData: any = {}) {
  // 1. 检查缓存,存在则直接返回
  if (instancePool.has(targetId)) {
    // ... 省略缓存逻辑 ...
  }

  // 2. 若实例数超过阈值,销毁最久未使用的实例
  if (instancePool.size >= MAX_INSTANCE_COUNT) {
    // 按 lastUsedAt 排序,取最久未使用的实例
    const sortedInstances = Array.from(instancePool.entries()).sort(
      ([, a], [, b]) => a.lastUsedAt - b.lastUsedAt
    )
    const [oldTargetId] = sortedInstances[0]
    destroyApp(oldTargetId)
    console.log(`实例数超过阈值,销毁最久未使用实例:${oldTargetId}`)
  }

  // 3. 创建新实例
  // ... 省略实例创建逻辑 ...
}

7.2 懒加载非核心组件与逻辑

实例初始化时,仅加载当前必需的组件(如聊天窗口的输入框、消息列表),非核心组件(如历史消息搜索、设置面板)通过 "按需加载" 延迟加载:

html 复制代码
<!-- ChatWindow.vue:懒加载非核心组件 -->
<template>
  <div class="chat-window">
    <MessageList /> <!-- 核心组件:立即加载 -->
    <ChatInput />   <!-- 核心组件:立即加载 -->
    <template v-if="showSearchPanel">
      <!-- 非核心组件:懒加载 -->
      <Suspense>
        <template #default>
          <SearchPanel />
        </template>
        <template #fallback>
          <div>加载中...</div>
        </template>
      </Suspense>
    </template>
  </div>
</template>

<script setup>
import MessageList from './MessageList.vue'
import ChatInput from './ChatInput.vue'
// 懒加载非核心组件
const SearchPanel = defineAsyncComponent(() => import('./SearchPanel.vue'))
const showSearchPanel = ref(false)
</script>

7.3 仅缓存轻量业务数据,避免序列化复杂对象

instancePool 的 cache 字段仅存储 "必要的业务快照数据"(如聊天窗口的用户 ID、未读消息数),不缓存复杂对象(如完整消息列表、DOM 引用),减少内存占用:

typescript 复制代码
// 错误:缓存复杂对象(消息列表)
const rec: InstanceRecord = {
  app,
  cache: {
    userId: initData.userId,
    messages: initData.messages // 复杂数组,占用内存大
  },
  // ... 其他字段 ...
}

// 正确:仅缓存轻量快照
const rec: InstanceRecord = {
  app,
  cache: {
    userId: initData.userId,
    unreadCount: initData.messages.filter(m => !m.read).length // 仅缓存统计结果
  },
  // ... 其他字段 ...
}

若需恢复复杂数据(如消息列表),可在实例复用时分发 API 请求重新获取,而非缓存完整数据。

7.4 异步销毁,避免阻塞主线程

实例销毁时(尤其是包含大量 DOM 节点的实例),直接执行 unmount 和 DOM 删除可能阻塞主线程,导致页面卡顿。可通过 requestIdleCallback 或 setTimeout 异步执行销毁逻辑:

typescript 复制代码
export function destroyApp(targetId: string) {
  const rec = instancePool.get(targetId)
  if (!rec) return

  // 异步执行销毁,避免阻塞主线程
  requestIdleCallback(() => {
    // 1. 卸载 Vue 应用
    rec.app.unmount()
    // 2. 移除缓存记录
    instancePool.delete(targetId)
    // 3. 删除 DOM 容器
    const el = document.getElementById(targetId)
    if (el && el.parentNode) {
      el.parentNode.removeChild(el)
    }
    console.log(`实例 ${targetId} 已异步销毁`)
  }, { timeout: 1000 }) // 1 秒内若未空闲,强制执行
}

8. 对比设计方案

在 "多实例" 相关场景中,常见的替代方案有 "Tab + 组件切换" 和 "iframe",以下是三者的对比分析:

方案 优点 缺点 适用场景
多实例(本文方案) 1. 状态完全隔离; 2. 复用灵活,切换无状态丢失; 3. 通信成本低(可通过全局事件 / 状态管理通信) 1. 实例管理逻辑复杂; 2. 内存占用高于组件切换; 3. 需手动处理样式隔离 1. 插件 / SDK 嵌入; 2. 多浮窗 / 工具面板; 3. 小子应用并行运行
Tab + 组件切换 1. 实现简单,无需额外管理实例; 2. 内存占用低(仅一个实例); 3. 样式天然隔离 1. 状态隔离困难(需手动重置 / 保存状态); 2. 切换时需重新渲染,可能有卡顿; 3. 无法并行运行多个组件 1. 管理后台 Tab 页; 2. 数据仪表盘; 3. 无状态保留需求的切换场景
iframe 1. 完全隔离(DOM、CSS、JavaScript 环境); 2. 无需担心样式 / 脚本冲突; 3. 可嵌入第三方应用 1. 通信成本高(仅支持 postMessage); 2. 内存占用极高; 3. 性能损耗大(页面重绘 / 回流独立) 1. 第三方应用嵌入; 2. 需完全隔离的沙盒环境; 3. 插件平台(如浏览器插件)

结论:若需 "状态隔离 + 复用保留" 且不希望过高通信 / 性能成本,优先选择本文的 "多实例" 方案;若仅需简单切换且无状态保留需求,选择 "Tab + 组件切换";若需完全隔离第三方内容,选择 "iframe"。

9. 案例演示:多聊天窗口实例

基于前文的设计思路,我们实现一个 "多聊天窗口" 案例,支持打开多个独立聊天窗口、切换保留状态、关闭销毁实例。

9.1 核心工具函数(chatInstanceFactory.ts)

typescript 复制代码
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ChatWindow from './ChatWindow.vue'

// 实例记录接口
interface InstanceRecord {
  app: ReturnType<typeof createApp>
  cache: { userId: string; title: string }
  mounted: boolean
  lastUsedAt: number
}

// 缓存池
const instancePool = new Map<string, InstanceRecord>()
// 配置:最大实例数
const MAX_INSTANCE_COUNT = 3

// 创建/获取聊天窗口实例
export function openChatWindow(initData: { userId: string; title: string }): string {
  // 生成唯一 targetId(基于用户 ID + 时间戳)
  const targetId = `chat-window-${initData.userId}-${Date.now().toString().slice(-4)}`

  // 1. 检查缓存,存在则显示并返回
  if (instancePool.has(targetId)) {
    const rec = instancePool.get(targetId)!
    rec.lastUsedAt = Date.now()
    showChatWindow(targetId)
    return targetId
  }

  // 2. 实例数超过阈值,销毁最久未使用实例
  if (instancePool.size >= MAX_INSTANCE_COUNT) {
    const sortedInstances = Array.from(instancePool.entries()).sort(
      ([, a], [, b]) => a.lastUsedAt - b.lastUsedAt
    )
    const [oldTargetId] = sortedInstances[0]
    closeChatWindow(oldTargetId)
  }

  // 3. 创建容器(含 Shadow DOM 样式隔离)
  const container = document.createElement('div')
  container.id = targetId
  container.style.position = 'fixed'
  container.style.bottom = '20px'
  container.style.right = `${(instancePool.size * 320) + 20}px` // 窗口横向排列
  container.style.width = '300px'
  container.style.height = '400px'
  container.style.border = '1px solid #eee'
  container.style.borderRadius = '8px'
  container.style.overflow = 'hidden'

  // 初始化 Shadow DOM
  const shadowRoot = container.attachShadow({ mode: 'open' })
  const mountPoint = document.createElement('div')
  shadowRoot.appendChild(mountPoint)
  document.body.appendChild(container)

  // 4. 创建 Vue 实例
  const app = createApp(ChatWindow)
  const pinia = createPinia()
  app.use(pinia)
  // 注入初始化数据
  app.provide('chatInitData', { ...initData, targetId })
  // 挂载到 Shadow DOM 内的节点
  app.mount(mountPoint)

  // 5. 加入缓存池
  const rec: InstanceRecord = {
    app,
    cache: { userId: initData.userId, title: initData.title },
    mounted: true,
    lastUsedAt: Date.now()
  }
  instancePool.set(targetId, rec)

  return targetId
}

// 显示聊天窗口
export function showChatWindow(targetId: string) {
  const container = document.getElementById(targetId)
  if (container) container.style.display = 'block'
}

// 隐藏聊天窗口
export function hideChatWindow(targetId: string) {
  const container = document.getElementById(targetId)
  if (container) container.style.display = 'none'
}
相关推荐
崔庆才丨静觅6 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
陌上丨7 小时前
Redis的Key和Value的设计原则有哪些?
数据库·redis·缓存
passerby60617 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了7 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅7 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅7 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅8 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment8 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅8 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊8 小时前
jwt介绍
前端