我们面临的技术难题很具体:在一个由多个独立团队维护的大型应用中,需要将多个异构技术栈(React、Vue、原生JS)的模块,整合成一个统一的用户界面。微前端是显而易见的架构方向,但随之而来的最大挑战是状态管理。如何让一个React组件的状态变化,能被一个原生Web Component或Vue组件实时感知并响应,同时又避免创建一个重量级的、与特定框架强绑定的中央store?
传统的Redux或Vuex方案在这里显得格格不入,它们会迫使所有微应用都依赖于同一个UI框架的生态。我们需要的是一个轻量、框架无关、且符合直觉的解决方案。Recoil的原子化状态(Atom)模型给了我们启发:状态被分散在独立的、可订阅的单元中,组件按需订阅自己关心的数据。这个模型本身是纯粹的逻辑,完全可以脱离React实现。
我们的目标是:构建一个极简的、受Recoil启发的全局状态总线,并将其与基于Tonic.js的纯Web Components进行深度集成。Tonic以其极致的简洁和对原生Web Components生命周期的良好封装,成为我们实现框架无关微前端的最佳载体。同时,为了解决微前端中最棘手的样式隔离问题,我们决定在每个Web Component的Shadow DOM内部署Tailwind CSS。
这篇日志记录了从概念验证到核心实现的全过程。
第一步:设计Recoil-like的状态核心
一切的起点是一个能够存储状态、处理订阅和通知更新的中心化模块。我们不需要Recoil的全部功能,比如Selector或异步Atom,我们只需要它的核心:atom定义、get、set和subscribe。
我们将这个核心命名为 StateBus。它必须是纯粹的ECMAScript模块,不依赖任何外部库。
// src/state-bus.js
/**
* @typedef {Object} Atom
* @property {string} key - The unique key for the atom.
* @property {*} defaultValue - The default value of the atom.
*/
// 使用Map来存储所有的atom定义和它们的当前值
const atomRegistry = new Map();
const atomValues = new Map();
// 使用Map来存储每个atom的订阅者列表
const atomSubscriptions = new Map();
/**
* 创建一个原子状态单元。
* 在真实项目中,这里应该增加对key冲突的检查。
* @param {Atom} atomConfig
* @returns {Atom}
*/
export function createAtom(atomConfig) {
if (atomRegistry.has(atomConfig.key)) {
// 在生产环境中,我们可能会返回已存在的atom或抛出更详细的错误
console.warn(`[StateBus] Atom with key "${atomConfig.key}" already exists.`);
return atomRegistry.get(atomConfig.key);
}
atomRegistry.set(atomConfig.key, atomConfig);
atomValues.set(atomConfig.key, atomConfig.defaultValue);
atomSubscriptions.set(atomConfig.key, new Set());
return atomConfig;
}
/**
* 获取一个atom的当前值。
* @param {Atom} atom
* @returns {*}
*/
export function getAtomValue(atom) {
if (!atomRegistry.has(atom.key)) {
throw new Error(`[StateBus] Atom with key "${atom.key}" is not defined.`);
}
return atomValues.get(atom.key);
}
/**
* 设置一个atom的值,并通知所有订阅者。
* @param {Atom} atom
* @param {*} newValue
*/
export function setAtomValue(atom, newValue) {
if (!atomRegistry.has(atom.key)) {
throw new Error(`[StateBus] Atom with key "${atom.key}" is not defined.`);
}
const oldValue = atomValues.get(atom.key);
// 只有在值确实发生变化时才更新和通知,这是性能优化的关键
if (oldValue !== newValue) {
atomValues.set(atom.key, newValue);
// 通知所有订阅者
const subscribers = atomSubscriptions.get(atom.key);
if (subscribers) {
subscribers.forEach(callback => {
try {
callback(newValue, oldValue);
} catch (err) {
// 避免一个订阅者的错误影响其他订阅者
console.error(`[StateBus] Error in subscriber for atom "${atom.key}":`, err);
}
});
}
}
}
/**
* 订阅一个atom的变化。
* @param {Atom} atom
* @param {Function} callback - 当atom值变化时调用的回调函数
* @returns {Function} - 返回一个用于取消订阅的函数
*/
export function subscribeToAtom(atom, callback) {
if (!atomSubscriptions.has(atom.key)) {
console.warn(`[StateBus] Attempting to subscribe to a non-existent atom "${atom.key}".`);
return () => {}; // 返回一个无操作的函数
}
const subscribers = atomSubscriptions.get(atom.key);
subscribers.add(callback);
// 返回一个闭包,用于从订阅者集合中移除自身
return () => {
subscribers.delete(callback);
};
}
/**
* 用于调试的辅助函数,在真实项目中可以扩展得更强大。
*/
export function inspectStateBus() {
const state = {};
for (const [key, value] of atomValues.entries()) {
state[key] = {
value,
subscribers: atomSubscriptions.get(key)?.size || 0
};
}
return state;
}
这个state-bus.js是整个架构的心脏。它的设计非常保守:
-
Map和Set: 使用Map和Set而不是普通对象和数组,是因为它们在频繁增删成员时性能更好,并且Set能自动处理重复订阅。 - 值变化检查:
setAtomValue中oldValue !== newValue的判断至关重要,它避免了不必要的重渲染和通知风暴。 - 错误处理: 简单的错误和警告日志是必要的。在生产环境中,这会接入我们统一的日志系统。
- 取消订阅:
subscribeToAtom返回一个取消订阅的函数,这是内存管理的关键。忘记取消订阅是导致内存泄漏的常见原因。
第二步:将状态总线与Tonic组件生命周期绑定
有了状态总线,下一个问题是如何让Tonic组件优雅地使用它。我们不希望每个组件都手动在connectedCallback中订阅,在disconnectedCallback中取消订阅。这不仅繁琐,而且容易出错。
解决方案是创建一个可复用的基类或一个高阶组件函数。考虑到Tonic的类继承模型,我们选择创建一个ConnectedComponent基类。
// src/components/connected-component.js
import Tonic from 'tonic-ssr';
import { subscribeToAtom } from '../state-bus.js';
export class ConnectedComponent extends Tonic {
constructor() {
super();
// 存储所有取消订阅的函数
this.unsubscribers = [];
}
/**
* 提供一个统一的API来订阅atom,并自动管理取消订阅的逻辑。
* @param {import('../state-bus.js').Atom} atom
* @param {Function} callback
*/
subscribe(atom, callback) {
const unsubscribe = subscribeToAtom(atom, callback);
this.unsubscribers.push(unsubscribe);
}
// Tonic 的 disconnectedCallback 会在元素从DOM中移除时触发
disconnected() {
// 这是保证没有内存泄漏的关键一步
// 当组件被销毁时,自动执行所有取消订阅函数
if (this.unsubscribers.length > 0) {
console.log(`[${this.props.id || 'Component'}] Cleaning up ${this.unsubscribers.length} subscriptions.`);
this.unsubscribers.forEach(unsub => unsub());
this.unsubscribers = [];
}
}
// 方便子类调用,强制Tonic重新渲染
// 在真实项目中,我们可能会实现更精细的渲染控制
forceReRender(newState = {}) {
this.reRender(props => ({
...props,
...newState
}));
}
}
这个ConnectedComponent抽象类极大地改善了开发体验。组件开发者只需要继承它,然后调用this.subscribe即可,无需关心底层的生命周期管理。
第三步:在Shadow DOM中驾驭Tailwind CSS
这是实践中遇到的一个大坑。Tailwind CSS通过扫描HTML、JS等文件来生成用到的原子类,但它默认无法感知到在JS字符串中定义的、将被注入到Shadow DOM的模板。此外,Tailwind的预设样式(preflight)也无法穿透Shadow DOM。
解决方案是为每个组件单独构建其CSS,并将其作为<style>标签注入Shadow DOM。
1. 配置文件 tailwind.config.js
我们需要配置content来扫描我们的组件文件。
// tailwind.config.js
module.exports = {
content: [
'./src/components/**/*.js', // 扫描所有JS组件文件
'./index.html'
],
theme: {
extend: {},
},
plugins: [],
}
2. PostCSS构建脚本
我们使用PostCSS为每个组件生成独立的CSS文件。
// build-styles.js
const postcss = require('postcss');
const tailwindcss = require('tailwindcss');
const fs = require('fs/promises');
const path = require('path');
const glob = require('glob');
async function buildComponentStyles() {
const componentFiles = glob.sync('./src/components/**/*.js');
const tailwindConfig = require('./tailwind.config.js');
for (const file of componentFiles) {
// 假设每个组件都有一个同名的CSS文件来放置@tailwind指令
const cssEntryPath = file.replace('.js', '.css');
const cssOutputPath = file.replace('.js', '.styles.js');
try {
await fs.access(cssEntryPath);
const cssContent = await fs.readFile(cssEntryPath, 'utf8');
const result = await postcss(tailwindcss(tailwindConfig))
.process(cssContent, { from: cssEntryPath });
// 将编译后的CSS包装成一个JS模块
const jsModuleContent = `export const styles = \`${result.css}\`;`;
await fs.writeFile(cssOutputPath, jsModuleContent);
console.log(`[CSS] Built styles for ${path.basename(file)}`);
} catch (error) {
// 如果没有对应的CSS文件,就跳过
if (error.code !== 'ENOENT') {
console.error(`Error processing ${file}:`, error);
}
}
}
}
buildComponentStyles();
这个脚本的逻辑是:
- 遍历所有组件JS文件。
- 寻找一个同名的
.css文件(例如user-profile.js对应user-profile.css)。 - 这个
.css文件内容很简单,就是引入Tailwind:/* user-profile.css */ @tailwind base; @tailwind components; @tailwind utilities; - 使用PostCSS和Tailwind处理这个文件,生成最终的CSS。
- 将生成的CSS字符串包装在一个JS模块(
.styles.js)中导出。
这样,组件就可以导入自己的样式了。
第四步:组合一切,构建交互式微前端
现在,我们拥有了状态总线、连接器基类和样式方案。让我们构建两个互相通信的组件:一个UserProfileEditor用于修改用户信息,一个HeaderDisplay用于显示用户名。
1. 定义共享状态
// src/atoms.js
import { createAtom } from './state-bus.js';
export const userProfileAtom = createAtom({
key: 'userProfile',
defaultValue: {
name: 'Guest',
email: '[email protected]',
lastUpdated: null
},
});
2. HeaderDisplay 组件 (消费者)
// src/components/header-display/header-display.css
@tailwind base;
@tailwind components;
@tailwind utilities;
/* 这里可以添加组件特有的样式 */
:host {
display: block;
}
// src/components/header-display/header-display.js
import { ConnectedComponent } from '../connected-component.js';
import { userProfileAtom, getAtomValue } from '../../state-bus.js';
import { styles } from './header-display.styles.js'; // 导入构建好的样式
class HeaderDisplay extends ConnectedComponent {
constructor() {
super();
// 从状态总线初始化state
this.state = {
profile: getAtomValue(userProfileAtom)
};
}
// connected生命周期在组件被添加到DOM后触发
connected() {
// 订阅userProfileAtom的变化
this.subscribe(userProfileAtom, (newProfile) => {
console.log('[HeaderDisplay] Received profile update:', newProfile);
// 当状态更新时,使用forceReRender来触发重新渲染
this.forceReRender({ profile: newProfile });
});
}
static get styles () {
return styles; // Tonic会自动将此样式注入Shadow DOM
}
render() {
return this.html`
<div class="bg-gray-800 text-white p-4 flex justify-between items-center rounded-lg shadow-lg">
<span class="font-bold text-xl">My App</span>
<span class="text-sm">
Welcome, <strong class="font-semibold text-teal-300">${this.state.profile.name}</strong>
</span>
</div>
`;
}
}
Tonic.add(HeaderDisplay);
3. UserProfileEditor 组件 (生产者)
// src/components/user-profile-editor/user-profile-editor.css
@tailwind base;
@tailwind components;
@tailwind utilities;
// src/components/user-profile-editor/user-profile-editor.js
import { ConnectedComponent } from '../connected-component.js';
import { userProfileAtom, getAtomValue, setAtomValue } from '../../state-bus.js';
import { styles } from './user-profile-editor.styles.js';
class UserProfileEditor extends ConnectedComponent {
constructor() {
super();
this.state = {
...getAtomValue(userProfileAtom)
};
this.boundOnChange = this.onChange.bind(this);
this.boundOnSubmit = this.onSubmit.bind(this);
}
connected() {
// 这个组件也订阅了变化,以防其他地方修改了用户信息
this.subscribe(userProfileAtom, (newProfile) => {
this.forceReRender({ ...newProfile });
});
}
onChange(e) {
this.state[e.target.name] = e.target.value;
}
onSubmit(e) {
e.preventDefault();
console.log('[UserProfileEditor] Submitting new profile...');
// 获取当前atom的值,然后进行合并更新
const currentProfile = getAtomValue(userProfileAtom);
setAtomValue(userProfileAtom, {
...currentProfile,
name: this.state.name,
email: this.state.email,
lastUpdated: new Date().toISOString()
});
}
static get styles() { return styles; }
render() {
return this.html`
<form
id=${this.id}
class="mt-6 p-6 border border-gray-300 rounded-lg bg-white shadow-md"
@submit=${this.boundOnSubmit}>
<h2 class="text-2xl font-bold mb-4 text-gray-700">Edit User Profile</h2>
<div class="mb-4">
<label for="name-input" class="block text-gray-600 mb-1">Name</label>
<input
id="name-input"
name="name"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
.value=${this.state.name}
@input=${this.boundOnChange}
/>
</div>
<div class="mb-4">
<label for="email-input" class="block text-gray-600 mb-1">Email</label>
<input
id="email-input"
name="email"
type="email"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
.value=${this.state.email}
@input=${this.boundOnChange}
/>
</div>
<button
type="submit"
class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition duration-150">
Save Changes
</button>
</form>
`;
}
}
Tonic.add(UserProfileEditor);
现在,HeaderDisplay和UserProfileEditor是两个完全独立的组件。它们通过共享的userProfileAtom进行通信。当用户在编辑器中修改并保存姓名时,setAtomValue被调用,状态总线会通知所有订阅者,HeaderDisplay接收到新值后自动重新渲染。我们成功地解耦了两个微前端。
架构图谱
为了更清晰地展示数据流,以下是该架构的Mermaid图。
graph TD
subgraph App Shell
A[index.html]
end
subgraph Micro-Frontend 1
direction LR
C1[header-display Component] -- Renders --> DOM1[Shadow DOM]
CSS1[header-display.styles.js] --> DOM1
end
subgraph Micro-Frontend 2
direction LR
C2[user-profile-editor Component] -- Renders --> DOM2[Shadow DOM]
CSS2[user-profile-editor.styles.js] --> DOM2
end
subgraph State Management Core
B[StateBus]
ATOM[userProfileAtom]
B -- Manages --> ATOM
end
A --> C1
A --> C2
C1 -- "subscribeToAtom(userProfileAtom)" --> B
C2 -- "subscribeToAtom(userProfileAtom)" --> B
C2 -- "setAtomValue(userProfileAtom)" --> B
B -- "Notifies with new value" --> C1
B -- "Notifies with new value" --> C2
局限性与未来展望
这个方案并非银弹,它是在特定约束下(框架无关、轻量、样式隔离)的权衡结果。在真实生产环境中,有几个方面需要进一步强化:
- 异步操作: 当前的
StateBus是完全同步的。对于需要从API获取数据的场景,我们需要引入类似Recoilselector的概念,它能够封装异步逻辑,并处理加载中和错误状态。这会显著增加状态总线的复杂度。 - 性能: 当atom数量和组件订阅数急剧增加时,
setAtomValue中的同步通知循环可能会成为性能瓶颈。可以考虑实现批处理更新(batching)或使用更高效的调度策略,将同一事件循环中的多次更新合并为一次。 - 开发者工具: 缺少调试工具是手写状态管理方案最大的痛点。一个理想的未来迭代是开发一个简单的浏览器扩展,它可以连接到
StateBus,实时显示所有atom的当前值、订阅者数量和变更历史,就像Redux DevTools一样。 - 类型安全: 在大型项目中,使用TypeScript为
createAtom、getAtomValue等函数提供类型支持至关重要,它可以保证atom的defaultValue和后续setAtomValue的值类型一致,在编译阶段就捕获潜在错误。
尽管存在这些局限,这个基于Tonic、Tailwind和Recoil原子模型的微前端架构,为我们解决跨技术栈组件通信和样式隔离问题提供了一个坚实、可控且高度可定制的基础。