构建基于 Tonic Web Components 与 Recoil 原子模型的跨框架状态管理总线


我们面临的技术难题很具体:在一个由多个独立团队维护的大型应用中,需要将多个异构技术栈(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定义、getsetsubscribe

我们将这个核心命名为 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是整个架构的心脏。它的设计非常保守:

  1. MapSet: 使用MapSet而不是普通对象和数组,是因为它们在频繁增删成员时性能更好,并且Set能自动处理重复订阅。
  2. 值变化检查: setAtomValueoldValue !== newValue的判断至关重要,它避免了不必要的重渲染和通知风暴。
  3. 错误处理: 简单的错误和警告日志是必要的。在生产环境中,这会接入我们统一的日志系统。
  4. 取消订阅: 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: 'guest@example.com',
    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);

现在,HeaderDisplayUserProfileEditor是两个完全独立的组件。它们通过共享的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

局限性与未来展望

这个方案并非银弹,它是在特定约束下(框架无关、轻量、样式隔离)的权衡结果。在真实生产环境中,有几个方面需要进一步强化:

  1. 异步操作: 当前的StateBus是完全同步的。对于需要从API获取数据的场景,我们需要引入类似Recoil selector的概念,它能够封装异步逻辑,并处理加载中和错误状态。这会显著增加状态总线的复杂度。
  2. 性能: 当atom数量和组件订阅数急剧增加时,setAtomValue中的同步通知循环可能会成为性能瓶颈。可以考虑实现批处理更新(batching)或使用更高效的调度策略,将同一事件循环中的多次更新合并为一次。
  3. 开发者工具: 缺少调试工具是手写状态管理方案最大的痛点。一个理想的未来迭代是开发一个简单的浏览器扩展,它可以连接到StateBus,实时显示所有atom的当前值、订阅者数量和变更历史,就像Redux DevTools一样。
  4. 类型安全: 在大型项目中,使用TypeScript为createAtomgetAtomValue等函数提供类型支持至关重要,它可以保证atom的defaultValue和后续setAtomValue的值类型一致,在编译阶段就捕获潜在错误。

尽管存在这些局限,这个基于Tonic、Tailwind和Recoil原子模型的微前端架构,为我们解决跨技术栈组件通信和样式隔离问题提供了一个坚实、可控且高度可定制的基础。


  目录