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、两个卡片,且每个卡片均显示零。
- 点击 Sumar desde React,两个值从 0 变为 1。
- 点击 Sumar desde Vue,两个值从 1 变为 2。
- 点击 Desmontar Vue,Vue 卡片消失,React 保留 2。
- 点击 Sumar desde React,React 显示 3。
- 点击 Montar Vue,Vue 读取当前数据,也显示 3。
- 点击 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 和通知请参阅共享状态。