# React 与 Vue 共享计数器

通过 WU store 连接教程的两个应用，包含完整文件、权限、订阅和卸载测试。

<span id="lo-que-vas-a-construir"></span>

<span class="section-anchor" id="section-1"></span>

## 将要构建的内容

继续使用[添加第二个应用](/zh-cn/docs/guides/add-an-app/)中的 `mi-plataforma` workspace。将两个本地计数器替换为共享计数器：**从 React 增加会改变 Vue，从 Vue 增加也会改变 React**。

Astro 页面 `/contador/` 同时显示两个应用，各自仍保留组件和 renderer。共同数据存于 `wu.store` 的 `demo.counter` 路径。

需要 Node.js 24，以及由 WU CLI 1.2.0 创建、含 React `mi-plataforma` 和 Vue `inspector` 的 workspace。代码使用 `wu-framework` 4.0.0 公开 API，及此前 `wu install` 安装的依赖。

<span id="1-aplicar-los-archivos-al-mismo-proyecto"></span>

<span class="section-anchor" id="section-2"></span>

## 1. 将文件应用到同一项目

按 **Ctrl+C** 停止服务器。[下载计数器完整文件](/examples/wu-shared-counter.zip)，将内容复制到 **`mi-plataforma/` 根目录内部**，与 `wu.config.json` 同级。

ZIP 的文件用于合并到教程 workspace，不单独安装或执行。替换同名文件，保留项目其他目录。若已自定义这些文件，替换前保存自己的变更。

| 文件 | 职责 |
| --- | --- |
| `mf-mi-plataforma/src/App.tsx` | 从 React 显示并更新数据。 |
| `mf-mi-plataforma/src/main.tsx` | 将 React 挂载和卸载连接到 WU。 |
| `mf-inspector/src/App.vue` | 从 Vue 显示并更新相同数据。 |
| `mf-inspector/src/main.ts` | 将 Vue 挂载和卸载连接到 WU。 |
| 每个应用内的 `wu.json` | 仅申请访问 `demo.counter`。 |
| 每个应用内的 `vite.config.js` | 将源 manifest 带入应用构建。 |
| `wu.config.json` | 保留两个应用，并在生成的 shell 中授予权限。 |
| `shell/src/pages/contador.astro` | 挂载两个应用、授予权限并初始化数据。 |
| `shell/astro.config.mjs` | 将 shell CSS 输出为 `wu serve` 可提供的文件。 |

**若 workspace 已添加更多应用**，请保留自己的 `wu.config.json`，只向现有 `mi-plataforma` 和 `inspector` 条目添加 `"grants": ["store.read:demo.counter", "store.write:demo.counter"]`。ZIP 文件对应双应用教程。

也可手动复制下方十一份完整文件，无需添加组件库或安装新包。

<span id="2-construir-y-ejecutar"></span>

<span class="section-anchor" id="section-3"></span>

## 2. 构建并运行

在 workspace 根目录运行：

```sh
npm --prefix shell run build
wu dev --native --port 3000 --no-open
```

等待 `npm --prefix shell run build` 成功完成。这会在启动前准备新的 Astro 页面，因为已有 shell 构建可能被复用。`wu dev --native` 运行时，CLI 1.2.0 会监视 shell 源文件，在更改后重新构建并重新加载页面。可以继续编辑而无需停止服务器；如果 Astro 报错，请查看终端。

打开 [http://127.0.0.1:3000/contador/](http://127.0.0.1:3000/contador/)。应看到标题 **Un contador, React y Vue**、两个卡片，且每个卡片均显示零。

1. 点击 **Sumar desde React**，两个值从 **0 变为 1**。
2. 点击 **Sumar desde Vue**，两个值从 **1 变为 2**。
3. 点击 **Desmontar Vue**，Vue 卡片消失，React 保留 **2**。
4. 点击 **Sumar desde React**，React 显示 **3**。
5. 点击 **Montar Vue**，Vue 读取当前数据，也显示 **3**。
6. 点击 **Restar desde Vue**，两个应用都显示 **2**。

卸载应用移除其界面和订阅，**不会删除共享数据**。刷新整个页面会开始新会话并归零：本示例只在内存存储数据，不持久化到浏览器或服务器。

<span id="3-cómo-llega-un-cambio-a-ambas-apps"></span>

<span class="section-anchor" id="section-4"></span>

## 3. 变更如何到达两个应用

shell 只初始化一次 `demo.counter`。各组件读取初始值并用 `store.on` 订阅。按钮调用 `store.update`，WU 写入新值并通知两个组件。React 更新 `useState`，Vue 更新 `ref`。

`update` 根据 store 当前数据计算新值，因此按钮不依赖上一通知是否已显示。通知通过 microtask 到达，`on` 不会自动发送初始值。

应用使用 WU 挂载时提供的 **`context.wu`** facade。`waitForWu` 定位 shell runtime，用 `wu.define` 注册生命周期；组件读写通过自身应用 facade。

<span id="el-componente-react"></span>

<span class="section-anchor" id="section-5"></span>

### React 组件

**`mf-mi-plataforma/src/App.tsx`** · [打开完整文件](/examples/browser/shared-counter/mf-mi-plataforma/src/App.tsx.txt)

```tsx
import { useEffect, useState } from 'react';
import type { WuAppFacade } from 'wu-framework';

export default function App({ appWu }: { appWu: WuAppFacade }) {
  const [count, setCount] = useState(() => appWu.store.get('demo.counter') ?? 0);

  useEffect(() => {
    const stop = appWu.store.on('demo.counter', (value) => setCount(value));
    setCount(appWu.store.get('demo.counter') ?? 0);
    return stop;
  }, [appWu]);

  function change(delta: number) {
    appWu.store.update('demo.counter', (current) => (current ?? 0) + delta);
  }

  return (
    <section aria-label="Contador React">
      <h2>React · mi-plataforma</h2>
      <p>Valor compartido: <output aria-live="polite">{count}</output></p>
      <button type="button" onClick={() => change(-1)}>Restar desde React</button>{' '}
      <button type="button" onClick={() => change(1)}>Sumar desde React</button>
    </section>
  );
}
```

effect 先订阅再读取，覆盖首次 render 与连接之间的时间窗口。React 卸载组件时，`return stop` 释放订阅。

**`mf-mi-plataforma/src/main.tsx`** · [打开完整文件](/examples/browser/shared-counter/mf-mi-plataforma/src/main.tsx.txt)

```tsx
import React from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { waitForWu } from 'wu-framework/adapters/shared';
import App from './App.tsx';

const wu = await waitForWu();
let root: Root | undefined;

wu.define('mi-plataforma', {
  mount(container, context) {
    if (!context?.wu) throw new Error('Esta app necesita el contexto de WU.');
    root = createRoot(container);
    root.render(<App appWu={context.wu} />);
  },
  unmount() {
    root?.unmount();
    root = undefined;
  },
});
```

生命周期创建 React root，并把 `context.wu` 作为 prop 传入。微应用卸载时，`root.unmount()` 执行 effect 清理。

<span id="el-componente-vue"></span>

<span class="section-anchor" id="section-6"></span>

### Vue 组件

**`mf-inspector/src/App.vue`** · [打开完整文件](/examples/browser/shared-counter/mf-inspector/src/App.vue.txt)

```vue
<script setup lang="ts">
import { onUnmounted, ref } from 'vue';
import type { WuAppFacade } from 'wu-framework';

const { appWu } = defineProps<{ appWu: WuAppFacade }>();
const count = ref(appWu.store.get('demo.counter') ?? 0);
const stop = appWu.store.on('demo.counter', (value) => { count.value = value; });
onUnmounted(stop);

function change(delta: number) {
  appWu.store.update('demo.counter', (current) => (current ?? 0) + delta);
}
</script>

<template>
  <section aria-label="Contador Vue">
    <h2>Vue · inspector</h2>
    <p>Valor compartido: <output aria-live="polite">{{ count }}</output></p>
    <button type="button" @click="change(-1)">Restar desde Vue</button>
    {{ ' ' }}
    <button type="button" @click="change(1)">Sumar desde Vue</button>
  </section>
</template>
```

Vue 保留 `ref` 以显示数据。组件移除时，`onUnmounted(stop)` 释放订阅。

**`mf-inspector/src/main.ts`** · [打开完整文件](/examples/browser/shared-counter/mf-inspector/src/main.ts.txt)

```ts
import { createApp, type App as VueApp } from 'vue';
import { waitForWu } from 'wu-framework/adapters/shared';
import App from './App.vue';

const wu = await waitForWu();
let app: VueApp | undefined;

wu.define('inspector', {
  mount(container, context) {
    if (!context?.wu) throw new Error('Esta app necesita el contexto de WU.');
    app = createApp(App, { appWu: context.wu });
    app.mount(container);
  },
  unmount() {
    app?.unmount();
    app = undefined;
  },
});
```

`app.unmount()` 执行 Vue 清理。重新挂载创建新实例，并读取 WU 当前值；组件不会重置计数器。

<span id="4-qué-permisos-se-conceden"></span>

<span class="section-anchor" id="section-7"></span>

## 4. 授予哪些权限

各 manifest 仅为 `demo.counter` 申请 `store.read` 和 `store.write`。读取权限允许查询和订阅，`update` 同时需要读写权限。shell 通过 `grants` 授予相同资源。

知道 path 不会让应用获得完整 store 访问权。WU 根据应用申请和宿主授予计算有效权限。`required: true` 使未获覆盖的申请阻止应用注册。

**`mf-mi-plataforma/wu.json`** · [打开完整文件](/examples/browser/shared-counter/mf-mi-plataforma/wu.json.txt)

```json
{
  "name": "mi-plataforma",
  "version": "1.0.0",
  "entry": "src/main.tsx",
  "styleMode": "shared",
  "wu": {
    "permissions": [
      { "capability": "store.read", "resource": "demo.counter", "required": true },
      { "capability": "store.write", "resource": "demo.counter", "required": true }
    ]
  }
}
```

**`mf-inspector/wu.json`** · [打开完整文件](/examples/browser/shared-counter/mf-inspector/wu.json.txt)

```json
{
  "name": "inspector",
  "version": "1.0.0",
  "entry": "src/main.ts",
  "styleMode": "shared",
  "wu": {
    "permissions": [
      { "capability": "store.read", "resource": "demo.counter", "required": true },
      { "capability": "store.write", "resource": "demo.counter", "required": true }
    ]
  }
}
```

<span id="conservar-el-manifest-al-compilar"></span>

<span class="section-anchor" id="section-8"></span>

### 编译时保留 manifest

原生开发模式直接提供各微应用根目录的 `wu.json`。各 Vite 配置添加小型插件，将同一文件写入 `dist/`。随后 `wu build` 保留权限并更新 `entry`，指向编译 JavaScript。无需手动维护第二份 manifest。

**`mf-mi-plataforma/vite.config.js`** · [打开完整文件](/examples/browser/shared-counter/mf-mi-plataforma/vite.config.js.txt)

```js
import { readFileSync } from 'node:fs';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [
    react(),
    {
      name: 'emit-wu-manifest',
      generateBundle() {
        this.emitFile({
          type: 'asset',
          fileName: 'wu.json',
          source: readFileSync(new URL('./wu.json', import.meta.url), 'utf8'),
        });
      },
    },
  ],
  build: { target: 'esnext' },
});
```

**`mf-inspector/vite.config.js`** · [打开完整文件](/examples/browser/shared-counter/mf-inspector/vite.config.js.txt)

```js
import { readFileSync } from 'node:fs';
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [
    vue(),
    {
      name: 'emit-wu-manifest',
      generateBundle() {
        this.emitFile({
          type: 'asset',
          fileName: 'wu.json',
          source: readFileSync(new URL('./wu.json', import.meta.url), 'utf8'),
        });
      },
    },
  ],
  build: { target: 'esnext' },
});
```

<span id="mantener-el-shell-generado"></span>

<span class="section-anchor" id="section-9"></span>

### 保留生成的 shell

此配置保留教程两个应用，并添加 grants，因此仍可从 workspace 首页导航打开它们。该导航一次显示一个应用，`/contador/` 则同时显示两个。

**`wu.config.json`** · [打开完整文件](/examples/browser/shared-counter/wu.config.json.txt)

```json
{
  "name": "mi-plataforma",
  "version": "1.1.0",
  "shell": {
    "dir": "shell", "port": 4321, "framework": "astro",
    "dev_cmd": "npx astro dev", "build_cmd": "npx astro build"
  },
  "apps": [
    {
      "name": "mi-plataforma", "dir": "mf-mi-plataforma", "framework": "react", "port": 5001,
      "grants": ["store.read:demo.counter", "store.write:demo.counter"]
    },
    {
      "name": "inspector", "dir": "mf-inspector", "framework": "vue", "port": 5002,
      "grants": ["store.read:demo.counter", "store.write:demo.counter"]
    }
  ],
  "proxy": { "port": 3000, "open_browser": true }
}
```

<span id="5-la-página-astro-que-las-conecta"></span>

<span class="section-anchor" id="section-10"></span>

## 5. 连接它们的 Astro 页面

以下为完整页面。script 在浏览器运行，初始化 WU 并将各应用挂载到对应容器。由于它是生成 shell 之外的额外宿主，因此显式声明本页 grants。

宿主在挂载前初始化数据。Vue 按钮使用带 `force: true` 的 `unmount`，等待立即清理完成后才允许再次挂载。

**`shell/src/pages/contador.astro`** · [打开完整文件](/examples/browser/shared-counter/shell/src/pages/contador.astro.txt)

```astro
<!doctype html>
<html lang="es">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Un contador compartido · WU</title>
    <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
  </head>
  <body>
    <main>
      <a href="/">Volver al workspace</a>
      <h1>Un contador, React y Vue</h1>
      <p>Las dos apps leen y escriben el mismo dato en WU.</p>
      <p id="estado" role="status">Cargando las apps…</p>
      <div class="contadores">
        <div id="react-counter"></div>
        <div id="vue-counter"></div>
      </div>
      <button id="toggle-vue" type="button" disabled>Desmontar Vue</button>
      <p>Desmonta Vue, cambia el valor desde React y vuelve a montar Vue.</p>
    </main>
    <script>
      import { wu } from 'wu-framework';

      const status = document.querySelector<HTMLElement>('#estado')!;
      const toggle = document.querySelector<HTMLButtonElement>('#toggle-vue')!;
      let vueMounted = false;

      try {
        await wu.init({
          sandbox: 'module',
          apps: [
            {
              name: 'mi-plataforma', url: '/mf-mi-plataforma',
              grants: ['store.read:demo.counter', 'store.write:demo.counter'],
            },
            {
              name: 'inspector', url: '/mf-inspector',
              grants: ['store.read:demo.counter', 'store.write:demo.counter'],
            },
          ],
        });
        if (wu.store.get('demo.counter') === undefined) wu.store.set('demo.counter', 0);
        await wu.mount('mi-plataforma', '#react-counter');
        await wu.mount('inspector', '#vue-counter');
        vueMounted = true;
        toggle.disabled = false;
        status.textContent = 'React y Vue conectados.';
      } catch (error) {
        status.textContent = `No se pudo iniciar: ${error}`;
      }

      toggle.addEventListener('click', async () => {
        toggle.disabled = true;
        try {
          if (vueMounted) {
            await wu.unmount('inspector', { force: true });
          } else {
            await wu.mount('inspector', '#vue-counter');
          }
          vueMounted = !vueMounted;
          toggle.textContent = vueMounted ? 'Desmontar Vue' : 'Montar Vue';
          status.textContent = vueMounted ? 'React y Vue conectados.' : 'Vue desmontado. React sigue conectado.';
        } catch (error) {
          status.textContent = `No se pudo cambiar Vue: ${error}`;
        } finally {
          toggle.disabled = false;
        }
      });
    </script>
    <style is:global>
      body { margin: 0; font: 1rem/1.6 system-ui, sans-serif; color: #192a3b; background: #f5f7fb; }
      main { max-width: 64rem; margin: auto; padding: 2rem 1rem; }
      a { color: #124ca6; }
      .contadores { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr)); gap: 1rem; margin: 1.5rem 0; }
      section { padding: 1.5rem; border: 1px solid #bac7d6; border-radius: 1rem; background: white; }
      h2 { margin-top: 0; font-size: 1.25rem; }
      output { font-size: 2rem; font-weight: 700; }
      button { font: inherit; padding: .6rem .9rem; margin-block: .25rem; border: 1px solid #617387; border-radius: .4rem; background: white; color: #192a3b; cursor: pointer; }
      button:focus-visible, a:focus-visible { outline: 3px solid #155fc9; outline-offset: 3px; }
      button:disabled { cursor: wait; opacity: .6; }
    </style>
  </body>
</html>
```

<span id="emitir-el-css-del-shell-en-archivos"></span>

<span class="section-anchor" id="section-11"></span>

### 将 shell CSS 输出为文件

此配置让 Astro 生成外部 CSS，包括 `/contador/` 样式，符合 `wu serve` 允许同源资源的样式策略。自定义 shell 配置时请保留此选项。

**`shell/astro.config.mjs`** · [打开完整文件](/examples/browser/shared-counter/shell/astro.config.mjs.txt)

```js
import { defineConfig } from 'astro/config';

export default defineConfig({
  server: { port: 4321 },
  build: { inlineStylesheets: 'never' },
});
```

<span id="6-comprobar-la-salida-de-producción"></span>

<span class="section-anchor" id="section-12"></span>

## 6. 检查生产输出

按 **Ctrl+C** 停止服务器，在根目录运行：

```sh
wu build
wu serve 4100 --mode static
```

打开 [http://127.0.0.1:4100/contador/index.html](http://127.0.0.1:4100/contador/index.html)，重复六项交互。刷新同一路径，两个应用应再次加载并从零开始。此时提供的是 workspace 合并后的 `dist/`。

`wu serve 4100 --mode static` 解析目录索引，并为不存在的路径返回真实 404。仅在客户端路由需要 shell 回退时使用 `--mode spa`。

<span id="si-algo-no-coincide"></span>

<span class="section-anchor" id="section-13"></span>

### 结果不符合预期时

| 观察到的现象 | 检查内容 |
| --- | --- |
| `/contador/` 显示旧页面或不存在 | 检查 `shell/src/pages/contador.astro` 并查看终端中的 shell 构建。如果在开发服务停止时复制了文件，请在启动前运行 `npm --prefix shell run build`。 |
| 权限被拒绝 | 各应用必须有 `wu.json`，宿主 grants 需覆盖 `demo.counter` 的 `store.read` 和 `store.write`。 |
| 开发可用，生产权限被拒绝 | 确认复制两个 `vite.config.js`，并确认 `dist/mf-mi-plataforma/wu.json` 和 `dist/mf-inspector/wu.json` 保留 `wu.permissions`。 |
| 只改变一个计数器 | 各应用的 `App` 和 `main` 都要替换，四个文件必须对应本示例。 |
| 初始值未显示 | 页面必须在挂载前初始化 store，各组件连接时必须读取。 |

下一步是[向另一应用请求求和](/zh-cn/docs/guides/rpc-between-apps/)，由一个应用提供操作，另一个请求结果。深入学习 paths、snapshots 和通知请参阅[共享状态](/zh-cn/docs/framework/state/)。