WU / 文档通用微前端
中文

React 与 Vue 共享计数器

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

将要构建的内容

继续使用添加第二个应用中的 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 安装的依赖。

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

按 Ctrl+C 停止服务器。下载计数器完整文件,将内容复制到 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 文件对应双应用教程。

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

2. 构建并运行

在 workspace 根目录运行:

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/。应看到标题 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。

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

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。

React 组件

mf-mi-plataforma/src/App.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 · 打开完整文件

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 清理。

Vue 组件

mf-inspector/src/App.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 · 打开完整文件

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 当前值;组件不会重置计数器。

4. 授予哪些权限

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

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

mf-mi-plataforma/wu.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 · 打开完整文件

{
  "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 }
    ]
  }
}

编译时保留 manifest

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

mf-mi-plataforma/vite.config.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 · 打开完整文件

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' },
});

保留生成的 shell

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

wu.config.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 }
}

5. 连接它们的 Astro 页面

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

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

shell/src/pages/contador.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>

将 shell CSS 输出为文件

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

shell/astro.config.mjs · 打开完整文件

import { defineConfig } from 'astro/config';

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

6. 检查生产输出

按 Ctrl+C 停止服务器,在根目录运行:

wu build
wu serve 4100 --mode static

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

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

结果不符合预期时

观察到的现象 检查内容
/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,各组件连接时必须读取。

下一步是向另一应用请求求和,由一个应用提供操作,另一个请求结果。深入学习 paths、snapshots 和通知请参阅共享状态。

一起完善 WU

发现了问题?有新的想法?

你的使用体验能让 WU 变得更好。告诉我们遇到的问题,或支持项目的持续开发。