From 437b882f039ce6775154a046416945fd687dd305 Mon Sep 17 00:00:00 2001 From: cirry <812852553@qq.com> Date: Sun, 21 Jun 2026 21:09:04 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=BB=E5=BA=94=E7=94=A8=E5=AD=90=E5=BA=94?= =?UTF-8?q?=E7=94=A8globalData=E9=80=9A=E4=BF=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/micro-app面试题.md | 585 +++++++++++++++++++++++++++++++++++++ src/main.ts | 37 +++ src/stores/counterStore.ts | 50 ++++ src/views/Home.vue | 274 +++++++++++++++++ 4 files changed, 946 insertions(+) create mode 100644 src/stores/counterStore.ts diff --git a/docs/micro-app面试题.md b/docs/micro-app面试题.md index 838d121..354da6e 100644 --- a/docs/micro-app面试题.md +++ b/docs/micro-app面试题.md @@ -671,6 +671,591 @@ microApp.setData('app', { --- +### Q12-2:如何实现「主→子」「子→主」「子→子」完整的跨应用通信?给出实际代码。 + +**答:** + +以下代码基于本项目真实实现,覆盖三种通信方向。 + +--- + +**一、架构总览** + +``` + ┌────────────────────────────────────────┐ + │ 主应用 (消息中枢) │ + │ │ + │ microApp.addDataListener('vue2', cb) │ ← 监听子→主 + │ microApp.addDataListener('vue3', cb) │ + │ │ + │ 转发逻辑: │ + │ 收到 vue2 → setData('vue3', ...) │ ← 子→子中转 + │ 收到 vue3 → setData('vue2', ...) │ + └────┬──────────────────────┬───────────┘ + │ setData() │ setData() + ↓ ↓ + ┌──────────────────┐ ┌──────────────────┐ + │ vue2-app │ │ vue3-app │ + │ │ │ │ + │ addDataListener() │ │ addDataListener() │ ← 监听主→子 + │ dispatch() │ │ dispatch() │ ← 子→主 + └──────────────────┘ └──────────────────┘ +``` + +--- + +**二、主→子通信(主应用推送数据给子应用)** + +主应用: +```ts +// src/main.ts — 在 lifeCycles.mounted 中自动推送 +mounted(_e, appName) { + microApp.setData(appName, { + type: 'countUpdate', + count: sharedCount.value, + from: 'main', + }) +} + +// src/views/Home.vue — 手动推送 +function pushToVue2() { + microApp.setData('vue2-app', { type: 'countUpdate', count: sharedCount.value, from: 'main' }) +} +``` + +子应用接收: +```ts +// vue2-app / vue3-app — onMounted 中监听 +onMounted(() => { + window.microApp?.addDataListener((data: any) => { + if (data.type === 'countUpdate') { + count.value = data.count // 更新本地计数器 + addLog(`收到来自 ${data.from} 的计数: ${data.count}`) + } + }) +}) +``` + +--- + +**三、子→主通信(子应用通知主应用)** + +子应用: +```ts +function sendToMain() { + count.value++ + window.microApp?.dispatch({ + type: 'countUpdate', + count: count.value, + from: 'vue2-app', // 标识来源 + }) +} +``` + +主应用(在 `main.ts` 中全局注册,避免组件重复挂载时重复监听): +```ts +// src/main.ts — 全局一次性注册 +microApp.addDataListener('vue2-app', (data: any) => { + if (data.type === 'countUpdate') { + sharedCount.value = data.count // 更新全局计数 + addLog(`vue2-app → 主: 收到计数 ${data.count}`, 'child') + } +}) +``` + +--- + +**四、子→子通信(子应用 A 通知子应用 B,主应用中转)** + +子应用 A: +```ts +function sendToVue3() { + count.value++ + // 仍然是 dispatch 给主应用,由主应用中转 + window.microApp?.dispatch({ + type: 'countUpdate', + count: count.value, + from: 'vue2-app', + target: 'vue3-app', // 标注目标 + }) +} +``` + +主应用中转: +```ts +// src/main.ts — 在 addDataListener 回调中转发 +microApp.addDataListener('vue2-app', (data: any) => { + if (data.type === 'countUpdate') { + sharedCount.value = data.count + + // 转发给另一个子应用 + microApp.setData('vue3-app', { + type: 'countUpdate', + count: data.count, + from: 'vue2-app', // 保留原始来源 + }) + addLog(`主 → vue3-app: 转发计数 ${data.count}`, 'relay') + } +}) +``` + +**关键:micro-app 不提供子→子直接通信 API,必须通过主应用中转。** + +--- + +**五、状态持久化(解决路由切换后计数器归零)** + +**问题**:主应用 `Home.vue` 中的 `ref(0)` 是组件局部状态,路由切走(组件卸载)后销毁,切回来时重新初始化为 0。 + +**解决方案:模块级单例 Store** + +```ts +// src/stores/counterStore.ts +import { ref } from 'vue' + +// ✅ 模块级 ref — 绑定到模块单例,不随组件卸载而销毁 +export const sharedCount = ref(0) +export const counterLogs = ref([]) + +export function addLog(msg: string, type: 'main' | 'child' | 'relay' = 'main') { + // ... +} +``` + +主应用组件使用: +```ts +// src/views/Home.vue +import { sharedCount, counterLogs, addLog } from '@/stores/counterStore' +// sharedCount 现在不会因路由切换而丢失 +``` + +**同时处理其他生命周期同步问题:** + +```ts +// src/main.ts — microApp.start() 的 lifeCycles +lifeCycles: { + mounted(_e, appName) { + // ✅ 子应用首次加载时,自动推送当前计数 + microApp.setData(appName, { type: 'countUpdate', count: sharedCount.value, from: 'main' }) + }, + beforeshow(_e, appName) { + // ✅ keepAlive 恢复时,子应用可能状态过期,重新推送 + microApp.setData(appName, { type: 'countUpdate', count: sharedCount.value, from: 'main' }) + }, +} +``` + +--- + +**六、完整通信时序(子→子 为例)** + +``` +vue2-app 主应用(main.ts) vue3-app + │ │ │ + │ dispatch({ │ │ + │ count: 5, │ │ + │ from: 'vue2-app' │ │ + │ }) │ │ + │ ─────────────────────→ │ │ + │ │ addDataListener 触发 │ + │ │ sharedCount.value = 5 │ + │ │ │ + │ │ setData('vue3-app', { │ + │ │ count: 5, │ + │ │ from: 'vue2-app' │ + │ │ }) │ + │ │ ───────────────────────→ │ + │ │ │ addDataListener 触发 + │ │ │ count.value = 5 + │ │ │ 日志: "收到来自 vue2-app" +``` + +--- + +### Q12-3:有没有比 `setData`/`dispatch` 更优雅的微前端通信方案?对比各方案的优劣。 + +**答:** + +有。`setData`/`dispatch` 是最基础的"点对点消息"模式,适合简单场景。对于复杂状态管理,有以下更优雅的方案。 + +--- + +**方案一:micro-app `globalData`(官方全局状态)** + +micro-app 内置了全局状态管理,主应用设置,所有子应用都能监听。 + +主应用: +```ts +import microApp from '@micro-zoe/micro-app' + +// 设置全局数据(所有子应用都能收到) +microApp.setGlobalData({ count: 5, theme: 'dark' }) + +// 强制覆盖(即使值相同也触发通知) +microApp.forceSetGlobalData({ count: 6 }) + +// 主应用自己也可以监听 +microApp.addGlobalDataListener((data) => { + console.log('全局状态变化:', data) +}, true) // autoTrigger: 立即用当前值触发一次 +``` + +子应用(任何子应用): +```ts +// 获取当前值 +const data = window.microApp?.getGlobalData() // { count: 5, theme: 'dark' } + +// 监听变化 +window.microApp?.addGlobalDataListener((data) => { + console.log('全局状态变化:', data) +}, true) +``` + +```ts +// 子应用也可以设置全局数据 +window.microApp?.setGlobalData({ count: 10 }) +``` + +**优点**:任何应用(主/子)都可以读/写/监听,天然的全局状态树 +**缺点**:全局命名空间,多子应用时注意 key 冲突;不利于精细的"只发给某个子应用" + +--- + +**方案二:BroadcastChannel API(浏览器原生)** + +如果所有子应用都和主应用**同源**部署,可以用 BroadcastChannel 实现真正的去中心化通信。 + +```ts +// 任何应用(主或子)创建同名 channel +const channel = new BroadcastChannel('micro-app-bus') + +// 发送消息(广播给所有同源窗口/iframe) +channel.postMessage({ type: 'countUpdate', count: 5, from: 'vue2-app' }) + +// 接收消息 +channel.onmessage = (event) => { + const { type, count, from } = event.data + if (type === 'countUpdate') { + console.log(`收到 ${from} 的计数: ${count}`) + } +} + +// 清理 +channel.close() +``` + +**优点**: +- 真正的去中心化 — 不需要主应用中转 +- 浏览器原生 API,零依赖 +- 同源下 window / iframe / web worker 都能通 +- 比 postMessage 语义更清晰(专门为消息设计) + +**缺点**: +- 仅限同源部署 +- 不兼容 IE +- 消息全广播,需自行过滤 + +--- + +**方案三:CustomEvent + DOM 事件总线(同 document 场景)** + +仅适用于 `iframe: false` 模式(with 沙箱),因为共享 document。 + +```ts +// 主应用 — 创建事件总线 +const bus = new EventTarget() + +// 子应用 — 通过 window 访问 +// 主应用中挂载 +;(window as any).__MICRO_BUS__ = bus + +// 任何应用发送 +window.__MICRO_BUS__?.dispatchEvent( + new CustomEvent('count-update', { detail: { count: 5, from: 'vue2' } }) +) + +// 任何应用接收 +window.__MICRO_BUS__?.addEventListener('count-update', (e: CustomEvent) => { + console.log(e.detail) +}) +``` + +**缺点**:`iframe: true` 模式不可用(不同 document),仅限 with 沙箱。 + +--- + +**方案四:模块级单例 Store + reactive Watch(本项目采用的改进版)** + +将状态抽到模块级单例,用 Vue `watch` 驱动行为。 + +```ts +// src/stores/counterStore.ts — 模块级单例 +import { ref, watch } from 'vue' +import microApp from '@micro-zoe/micro-app' + +export const sharedCount = ref(0) + +// ✅ 状态变化自动推送到所有子应用 +watch(sharedCount, (val) => { + const activeApps = microApp.getActiveApps({ excludeHiddenApp: false }) + activeApps.forEach(name => { + microApp.setData(name, { type: 'countUpdate', count: val, from: 'main' }) + }) +}) +``` + +这样主应用组件里只要操作 `sharedCount.value++`,无需手动调用 `setData`。 + +--- + +**五、各方案对比** + +| 方案 | 通信方向 | 去中心化 | 子→子 | iframe | 复杂度 | 适用场景 | +|------|----------|----------|-------|--------|--------|----------| +| `setData`/`dispatch` | 主↔子点对点 | ❌ 主中转 | 需转发 | ✅ | 低 | 简单数据传递 | +| `globalData` | 全局广播 | ✅ 任何应用可写 | ✅ | ✅ | 低 | 全局配置、主题、用户信息 | +| `BroadcastChannel` | 全局广播 | ✅ 完全去中心 | ✅ | ✅ (同源) | 低 | 同源部署的复杂协作 | +| `CustomEvent` | DOM 事件 | ✅ | ✅ | ❌ (with沙箱) | 低 | iframe:false 的轻量场景 | +| 模块 Store + watch | 主→子自动 | ❌ 主驱动 | 需转发 | ✅ | 中 | 复杂状态、自动同步 | +| `EventCenterForMicroApp` | 主↔子点对点 | ❌ | ❌ | ✅ | 低 | 按需精确通信 | + +--- + +**六、推荐选型** + +``` +简单传参(初始化配置) → setData / getData +全局共享(主题、用户、语言) → globalData ✅ +同源复杂协作 → BroadcastChannel ✅ +复杂跨应用状态管理 → 模块 Store + watch + setData 自动推送 ✅ +子应用间点对点通信 → dispatch + 主应用中转(目前唯一方式) +``` + +--- + +### Q12-4:用 `globalData` 改造项目的完整过程是怎样的?前后架构对比? + +**答:** + +以下记录了本项目从 `setData`/`dispatch` 点对点模式迁移到 `globalData` 全局广播模式的完整过程。 + +--- + +**一、改造前的架构(setData + dispatch + 主应用中转)** + +``` +vue2-app 主应用 vue3-app + │ │ │ + │ dispatch({count:5}) │ │ + │ ───────────────────────→ │ │ + │ │ addDataListener('vue2') │ + │ │ sharedCount = 5 │ + │ │ │ + │ │ setData('vue3', {count:5})│ + │ │ ───────────────────────→ │ + │ │ │ addDataListener() + │ │ │ count = 5 +``` + +**痛点:** +- 主应用需要为每个子应用单独注册 `addDataListener` +- 子→子通信必须经过主应用中转 +- 代码量大,新增子应用时需要在主应用中添加新的监听和中转逻辑 + +--- + +**二、改造后的架构(globalData 全局广播)** + +``` + ┌──────────────────────────┐ + │ globalData 池 │ + │ { count: 5, from: ... } │ + └─────┬──────────┬─────────┘ + │ │ + setGlobalData() addGlobalDataListener() + getGlobalData() + │ │ + ┌──────────┼──────────┼──────────┐ + │ │ │ │ + 主应用 vue2-app vue3-app 任何一方 +``` + +**任何一方 `setGlobalData` → 其他所有方 `addGlobalDataListener` 都触发。** + +--- + +**三、主应用改造(main.ts)** + +```ts +// ❌ 改造前:per-app 监听 + 中继 +microApp.start({ + lifeCycles: { + mounted(_e, appName) { + microApp.setData(appName, { type: 'countUpdate', count: sharedCount.value, from: 'main' }) + } + } +}) + +microApp.addDataListener('vue2-app', (data: any) => { + if (data.type === 'countUpdate') { + sharedCount.value = data.count + microApp.setData('vue3-app', { type: 'countUpdate', count: data.count, from: 'vue2-app' }) // 中继 + } +}) +microApp.addDataListener('vue3-app', (data: any) => { + // ... 同样的中继逻辑 +}) +``` + +```ts +// ✅ 改造后:单一 globalData 监听,无需中继 +microApp.setGlobalData({ count: 0, from: 'main' }) + +microApp.addGlobalDataListener((data: Record) => { + if (data.count !== undefined) { + sharedCount.value = data.count + const from = data.from || 'unknown' + if (from !== 'main') { + addLog(`${from} → globalData: 计数 ${sharedCount.value}`, 'child') + } + } +}) +// 无需 mounted/beforeshow 手动推送 — 子应用通过 autoTrigger 自动拿值 +``` + +**关键变化:** +- `setData(appName, ...)` → `setGlobalData(...)`(不再需要指定 appName) +- `addDataListener(appName, cb)` × N → `addGlobalDataListener(cb)` × 1 +- 删除所有中继逻辑 + +--- + +**四、主应用 Home.vue 改造** + +```ts +// ❌ 改造前:分别推送到不同子应用 +function pushToVue2() { + microApp.setData('vue2-app', { type: 'countUpdate', count: sharedCount.value, from: 'main' }) +} +function pushToVue3() { + microApp.setData('vue3-app', { type: 'countUpdate', count: sharedCount.value, from: 'main' }) +} +``` + +```ts +// ✅ 改造后:一键广播,所有子应用同时收到 +function doBroadcast(action: string) { + microApp.setGlobalData({ count: sharedCount.value, from: 'main' }) + // vue2-app 和 vue3-app 同时收到,无需区分目标 +} + +function increment() { + sharedCount.value++ + doBroadcast('+1') // +1 后自动广播 +} +``` + +**UI 变化:** +``` +改造前: 改造后: +[− 1] [+ 1] [− 1] [+ 1] [📡 广播到所有子应用] +[📤 推送到 Vue2] [📤 推送到 Vue3] ↑ +1/−1 自动广播,按钮为手动同步 +``` + +--- + +**五、子应用改造(vue2-app / vue3-app)** + +```ts +// ❌ 改造前:dispatch + addDataListener +created() { + const data = window.microApp?.getData() + if (data && data.count !== undefined) this.count = data.count + + window.microApp?.addDataListener((data) => { + if (data.type === 'countUpdate') { + this.count = data.count + } + }) +}, +methods: { + sendToMain() { + this.count++ + window.microApp.dispatch({ type: 'countUpdate', count: this.count, from: 'vue2-app' }) + }, + sendToVue3() { + this.count++ + // 需要主应用中转 + window.microApp.dispatch({ type: 'countUpdate', count: this.count, from: 'vue2-app', target: 'vue3-app' }) + }, +} +``` + +```ts +// ✅ 改造后:setGlobalData + addGlobalDataListener(autoTrigger) +created() { + // autoTrigger: true → 立即用当前 globalData 触发一次,无需手动 getData + window.microApp?.addGlobalDataListener((data) => { + if (data.count !== undefined) { + this.count = data.count + } + }, true) // ← 这个 true 替代了 getData() + addDataListener 两件事 +}, +methods: { + broadcastIncrement() { + this.count++ + // globalData 自动广播给主应用 + 所有其他子应用! + window.microApp.setGlobalData({ count: this.count, from: 'vue2-app' }) + // vue3-app 直接收到,无需主应用中转 ✅ + }, +} +``` + +**关键变化:** +- `dispatch({ type: 'countUpdate', ... })` → `setGlobalData({ count: N, from: 'xxx' })` +- `getData()` + `addDataListener(cb)` → `addGlobalDataListener(cb, true)` +- `sendToMain()` 和 `sendToVue3()` 合并为 `broadcastIncrement()` — 都是广播 + +--- + +**六、架构对比总结** + +| 维度 | 改造前 (setData/dispatch) | 改造后 (globalData) | +|------|--------------------------|---------------------| +| 主→子 | `setData(appName, data)` 逐个推送 | `setGlobalData(data)` 全部广播 | +| 子→主 | `dispatch(data)` → `addDataListener(appName, cb)` | `setGlobalData(data)` → `addGlobalDataListener(cb)` | +| 子→子 | dispatch → 主应用中转 → setData | setGlobalData → 其他子应用直接收到 ✅ | +| 生命周期同步 | `mounted`/`beforeshow` 中手动 `setData` | `autoTrigger: true` 自动同步 | +| 主应用监听器 | N 个(每个子应用一个) | 1 个 | +| 新增子应用 | 需加新的 addDataListener + 转发逻辑 | 不需要改任何代码 | +| 数据格式 | `{ type: 'countUpdate', count, from }` | `{ count, from }` 更简洁 | +| 类型声明(子应用) | `addDataListener / dispatch` | 加 `addGlobalDataListener / setGlobalData / getGlobalData` | + +--- + +**七、globalData 的注意事项** + +1. **广播语义**:`setGlobalData` 会通知**所有**应用,无法定向推送给特定子应用。如需定向,在 data 中带 `target` 字段并在子应用端过滤。 + +2. **合并策略**:`setGlobalData` 会**合并**(不是替换)到现有 globalData,所以多次调用会累积 key。 + +3. **不要高频调用**:每次 `setGlobalData` 都会触发所有子应用的回调。计数器这种低频场景没问题,但高频场景(如鼠标移动、实时输入)需要用节流或改用点对点 `setData`。 + +4. **autoTrigger 陷阱**:`addGlobalDataListener(cb, true)` 中的 `autoTrigger` 会传递**整个 globalData 对象**,不是单次变更的 delta。回调逻辑要做兼容: + +```ts +// ✅ 正确:通过 count 字段判断 +addGlobalDataListener((data) => { + if (data.count !== undefined) { // 不是 if (data.type === 'countUpdate') + this.count = data.count + } +}, true) +``` + +5. **与 keepAlive 的配合**:`autoTrigger: true` 天然解决了 keepAlive 恢复时的状态同步 — 子应用恢复后重新挂载时,`addGlobalDataListener(cb, true)` 会立即用最新 globalData 触发。 + +--- + ### Q13:`keep-alive` 保活机制的原理是什么?有什么注意事项? **答:** diff --git a/src/main.ts b/src/main.ts index 1f88a48..3dc6412 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,7 @@ import { createApp } from 'vue' import microApp from '@micro-zoe/micro-app' import App from './App.vue' import router from './router' +import { sharedCount, addLog } from './stores/counterStore' // 启动 micro-app 微前端框架 // https://jd-opensource.github.io/micro-app/docs.html#/start @@ -21,15 +22,51 @@ microApp.start({ }, mounted(_e, appName) { console.log(`[micro-app] 子应用 ${appName} 挂载完成`) + // 子应用挂载后会通过 addGlobalDataListener(autoTrigger=true) 自动拿到当前值 + addLog(`子应用 ${appName} 挂载完成(将通过 globalData 自动同步)`, 'main') + }, + beforeshow(_e, appName) { + console.log(`[micro-app] 子应用 ${appName} 即将显示(keepAlive 恢复)`) + addLog(`子应用 ${appName} 恢复显示(globalData 自动同步)`, 'main') }, unmount(_e, appName) { console.log(`[micro-app] 子应用 ${appName} 已卸载`) }, error(_e, appName) { console.error(`[micro-app] 子应用 ${appName} 加载错误:`, _e) + }, + }, +}) + +// ============================================================ +// 全局通信(globalData 方案) +// +// globalData 是 micro-app 内置的全局状态池: +// - 主应用 / 任何子应用 都可以 setGlobalData / getGlobalData +// - 任何一方调用 setGlobalData → 其他所有方的 addGlobalDataListener 都触发 +// - 子→子通信不再需要主应用中转! +// +// 架构变化: +// 之前:setData(per-app) + addDataListener(per-app) + 主应用中转 +// 现在:setGlobalData(broadcast) + addGlobalDataListener(global) +// ============================================================ + +// 初始化全局数据 +microApp.setGlobalData({ count: 0, from: 'main' }) + +// 单一监听器 — 接收任何子应用发来的 globalData 更新 +microApp.addGlobalDataListener((data: Record) => { + if (data.count !== undefined) { + const prev = sharedCount.value + sharedCount.value = data.count + const from = data.from || 'unknown' + if (from !== 'main') { + // 子应用发起的更新 + addLog(`${from} → globalData: 计数 ${prev} → ${data.count}`, 'child') } } }) +// 注意:不需要 autoTrigger,main.ts 是入口文件,sharedCount 默认就是 0 const app = createApp(App) app.use(router) diff --git a/src/stores/counterStore.ts b/src/stores/counterStore.ts new file mode 100644 index 0000000..c761528 --- /dev/null +++ b/src/stores/counterStore.ts @@ -0,0 +1,50 @@ +/** + * 共享计数器 Store + * + * 使用模块级 ref,状态绑定到模块单例而不是 Vue 组件实例。 + * 即使 Home.vue 被卸载(路由切走),计数器也不会重置。 + * + * 通信流程: + * 主应用 ←→ 子应用(双向,主应用为消息中转站) + * 子←→子:子A → dispatch → 主应用 → relay setData → 子B + */ +import { ref } from 'vue' + +export interface LogEntry { + time: string + msg: string + type: 'main' | 'child' | 'relay' +} + +// ============================================================ +// 模块级状态 — 不随组件卸载而销毁 +// ============================================================ + +/** 共享计数器 */ +export const sharedCount = ref(0) + +/** 通信日志 */ +export const counterLogs = ref([]) + +// ============================================================ +// 工具函数 +// ============================================================ + +/** 添加一条通信日志 */ +export function addLog(msg: string, type: 'main' | 'child' | 'relay' = 'main') { + const now = new Date() + const time = [ + String(now.getHours()).padStart(2, '0'), + String(now.getMinutes()).padStart(2, '0'), + String(now.getSeconds()).padStart(2, '0'), + ].join(':') + counterLogs.value.push({ time, msg, type }) + if (counterLogs.value.length > 30) { + counterLogs.value.shift() + } +} + +// AddLog function but without reactivity side effects - for use in main.ts +export function addSystemLog(msg: string, type: 'main' | 'child' | 'relay' = 'main') { + addLog(msg, type) +} diff --git a/src/views/Home.vue b/src/views/Home.vue index fe120bc..b406a67 100644 --- a/src/views/Home.vue +++ b/src/views/Home.vue @@ -74,11 +74,110 @@

请在 src/config/subApps.ts 中配置子应用信息

+ + +
+
+ 📡 跨应用通信演示 +
+
+
+ 共享计数器 + {{ sharedCount }} +
+
+ + + +
+
+
📋 通信日志(主应用视角)
+
+

+ {{ log.time }} + {{ log.msg }} +

+

暂无通信记录,点击按钮开始测试 👆

+
+
+
+