基于 Recoil 和 Meilisearch 构建前端 CQRS 模式的读写分离搜索状态管理


最初的痛点源于一个平平无奇的需求:一个具备多维度筛选、排序、全文检索功能的产品列表页面。技术栈是 React,状态管理最初选择了常规的 Context API。随着筛选维度从 3 个增加到 15 个,其中包括价格区间、多选品牌、动态规格参数等,整个状态管理开始急剧恶化。每一次用户交互,无论是敲击键盘输入搜索词,还是勾选一个筛选框,都会触发一个庞大的 useStateuseReducer 对象的变更,进而导致整个组件树的深度重新渲染。性能问题只是冰山一角,更棘手的是逻辑的耦合:筛选参数的解析、API 请求的构造、UI 状态的同步、加载与错误的处理,全部纠缠在一个臃肿的自定义 Hook 中。代码变得难以维护,更别提为其编写可靠的测试。

我们尝试过 Redux,但问题依旧。巨大的 selector 函数和繁琐的 action/reducer 模板并没有解决核心问题——读写操作的高度耦合。用户的每一次筛选(写操作)都立即与数据获取(读操作)绑定,任何一个微小的状态变更都可能触发一次昂贵的后端查询。性能优化举步维艰,在真实项目中,这种设计最终会走向崩溃。

这迫使我们后退一步,重新审视问题的本质。用户的意图是“修改筛选条件”(一个命令),而应用的响应是“展示新的结果集”(一个查询)。这两者之间并非必须是同步且阻塞的。这正是命令查询职责分离(CQRS)模式的核心思想。虽然 CQRS 通常应用于后端架构,但其原则完全可以借鉴到前端复杂状态管理中。

我们的构想是:

  1. 命令(Command): 用户的交互行为,如输入文本、点击复选框,被视为命令。这些命令只负责更新“意图”状态,例如一个描述所有筛选条件的纯粹的数据结构。这个过程应该非常轻量,不涉及任何 I/O。
  2. 查询(Query): UI 的渲染,即结果的展示,则完全依赖于一个独立的、高效的读模型。这个读模型的数据源应该是一个为查询特化的服务,而不是业务主数据库。
  3. 连接: 一个异步进程负责监听“意图”状态的变化,并用它来驱动对读模型的查询,最终将查询结果更新到 UI。

这个构想需要一个极速的、专为前端查询场景优化的“读模型”后端,以及一个能够优雅地处理原子化、异步衍生状态的前端库。经过评估,MeilisearchRecoil 成为了最终的技术选型。Meilisearch 以其开箱即用的高性能和开发者友好性,成为理想的只读数据源。而 Recoil 的原子化状态和异步 selector,则完美契合了我们对查询模型的构想。最后,单元测试 不再是锦上添花,而是确保这套分离的、异步的系统能够稳定运行的基石。

graph TD
    subgraph Browser
        A[React Component] -- User Interaction --> B(Command Handler);
        B -- Updates --> C{Recoil Filter Atoms};
        A -- Subscribes to --> D{Recoil Async Selector};
        D -- Fetches data from --> E[API Gateway];
        D -- Updates UI with --> A;
        C -- Triggers re-evaluation of --> D;
    end

    subgraph Backend
        E -- Forwards query --> F[Meilisearch Instance];
        F -- Returns results --> E;
        G[Primary Database] -- Write Operation --> H(Data Sync Service);
        H -- Pushes updates to --> F;
    end

    style F fill:#f9f,stroke:#333,stroke-width:2px
    style C fill:#bbf,stroke:#333,stroke-width:2px
    style D fill:#bbf,stroke:#333,stroke-width:2px

第一步:构建独立的读模型 Meilisearch

在真实项目中,我们不会让前端直接去操作主数据库。主数据库为事务性操作优化,而全文搜索和多面筛选有其独特的需求。Meilisearch 在这里扮演的角色是“物化视图”或“读模型”。数据的同步通常由后端服务负责。例如,当产品信息在主数据库(如 PostgreSQL)中发生变更时,一个消息队列或数据库触发器会通知一个同步服务,该服务再将数据更新到 Meilisearch 中。

为了让文章聚焦于前端实现,我们假设这个同步机制已经存在。我们只需要一个 Meilisearch 实例和一些索引好的数据。

Meilisearch 配置与启动 (docker-compose.yml):

version: '3.8'

services:
  meilisearch:
    image: getmeili/meilisearch:v1.3
    container_name: product-search-engine
    ports:
      - "7700:7700"
    environment:
      - MEILI_MASTER_KEY=aVeryComplexMasterKey # 生产环境请使用更安全的方式管理
      - MEILI_ENV=development
    volumes:
      - ./meili_data:/meili_data

启动后,我们可以通过一个简单的脚本向其填充数据,并配置索引的筛选和排序属性。

Meilisearch 索引配置 (setup.js):

import { MeiliSearch } from 'meilisearch';

const client = new MeiliSearch({
  host: 'http://localhost:7700',
  apiKey: 'aVeryComplexMasterKey',
});

const indexName = 'products';
const documents = [
  // ... 假设这里有成千上万条产品数据
  { id: 1, name: 'High-Performance Laptop', brand: 'TechCorp', price: 1200, category: 'Electronics', inStock: true },
  { id: 2, name: 'Ergonomic Office Chair', brand: 'FurniMax', price: 350, category: 'Furniture', inStock: true },
  { id: 3, name: 'Organic Green Tea', brand: 'NatureLeaf', price: 25, category: 'Groceries', inStock: false },
  // ...
];

async function setup() {
  console.log('正在删除旧索引...');
  try {
    await client.index(indexName).delete();
  } catch (error) {
    if (error.code !== 'index_not_found') throw error;
  }

  console.log('正在配置索引...');
  const index = client.index(indexName);
  
  // 生产环境中,这些配置应该版本化管理
  await index.updateSettings({
    filterableAttributes: ['brand', 'category', 'price', 'inStock'],
    sortableAttributes: ['price', 'name'],
    searchableAttributes: ['name', 'brand', 'category'],
  });

  console.log('正在添加文档...');
  const { taskUid } = await index.addDocuments(documents);
  await client.waitForTask(taskUid);

  console.log('Meilisearch 配置完成!');
}

setup().catch(console.error);

这个脚本确保了 products 索引存在,并且 brand, category, price, inStock 字段可用于过滤,pricename 字段可用于排序。这是后续所有前端查询的基础。

第二步:定义命令与查询状态

使用 Recoil,我们将UI状态分解为正交的原子(Atoms)。每个原子代表一个独立的、可更新的状态片段。对于我们的搜索页面,状态可以这样划分:

状态定义 (store/atoms.js):

import { atom } from 'recoil';

/**
 * @typedef {Object} Filters
 * @property {string} query - 全文搜索关键词
 * @property {string[]} brands - 选中的品牌
 * @property {string[]} categories - 选中的分类
 * @property {[number, number] | null} priceRange - 价格区间 [min, max]
 * @property {'asc' | 'desc' | null} sortPrice - 价格排序
 */

/**
 * @type {import('recoil').RecoilState<Filters>}
 * 用户当前的筛选意图。这是“命令”侧主要操作的对象。
 * UI组件通过 set 这个 atom 来表达用户的操作意图。
 */
export const filtersState = atom({
  key: 'filtersState',
  default: {
    query: '',
    brands: [],
    categories: [],
    priceRange: null,
    sortPrice: null,
  },
});

/**
 * 分页状态
 */
export const paginationState = atom({
  key: 'paginationState',
  default: {
    currentPage: 1,
    pageSize: 20,
  },
});

这里的 filtersState 就是我们的“命令模型”。它是一个纯粹的数据结构,UI 组件的唯一职责就是根据用户交互去更新这个 atom。例如,一个品牌筛选组件在用户点击时,会执行 setFilters(prev => ({ ...prev, brands: [...prev.brands, 'newBrand'] }))。这个操作本身极其廉价。

第三章:实现核心查询逻辑

真正的魔法发生在 Recoil 的 selector 中。我们将创建一个异步 selector,它依赖于 filtersStatepaginationState。当这些依赖的 atom 发生变化时,selector 会自动重新计算。在我们的场景下,“重新计算”意味着向 Meilisearch 发起一次新的查询。

为了解耦和可测试性,我们先创建一个 API 服务来封装 Meilisearch 的客户端逻辑。

API 服务 (services/searchService.js):

import { MeiliSearch } from 'meilisearch';

// 客户端实例应该是单例的
const client = new MeiliSearch({
  host: process.env.NEXT_PUBLIC_MEILISEARCH_HOST || 'http://localhost:7700',
  apiKey: process.env.NEXT_PUBLIC_MEILISEARCH_API_KEY || 'aVeryComplexMasterKey',
});

const index = client.index('products');

/**
 * 将我们的前端过滤器状态转换为 Meilisearch 的 filter 字符串
 * @param {import('../store/atoms').Filters} filters
 * @returns {string[]}
 */
function buildFilterArray(filters) {
  const filterArray = [];
  if (filters.brands.length > 0) {
    const brandFilters = filters.brands.map(b => `brand = "${b}"`).join(' OR ');
    filterArray.push(`(${brandFilters})`);
  }
  if (filters.categories.length > 0) {
    const categoryFilters = filters.categories.map(c => `category = "${c}"`).join(' OR ');
    filterArray.push(`(${categoryFilters})`);
  }
  if (filters.priceRange) {
    filterArray.push(`price ${filters.priceRange[0]} TO ${filters.priceRange[1]}`);
  }
  return filterArray;
}

/**
 * 执行产品搜索
 * @param {object} params
 * @param {import('../store/atoms').Filters} params.filters
 * @param {object} params.pagination
 * @param {AbortSignal} params.signal - 用于中止请求
 * @returns {Promise<import('meilisearch').SearchResponse>}
 */
export const fetchProducts = async ({ filters, pagination, signal }) => {
  const filter = buildFilterArray(filters);
  const sort = filters.sortPrice ? [`price:${filters.sortPrice}`] : [];

  const searchParams = {
    limit: pagination.pageSize,
    offset: (pagination.currentPage - 1) * pagination.pageSize,
    filter,
    sort,
    signal, // 将 AbortSignal 传递给 Meilisearch SDK
  };

  // 在真实项目中,这里应该有更健壮的错误处理和日志记录
  try {
    const results = await index.search(filters.query, searchParams);
    return results;
  } catch (error) {
    if (error.name === 'AbortError') {
      console.log('Search request was aborted.');
      // 返回一个特定的结构或抛出一个可识别的错误,让调用者知道这是中止的
      return { hits: [], nbHits: 0, processingTimeMs: 0, query: filters.query, limit: 0, offset: 0 };
    }
    console.error('Meilisearch query failed:', error);
    throw error; // 重新抛出,让 Recoil selector 捕获
  }
};

这里的 buildFilterArray 是一个关键的纯函数,它将我们的应用状态映射到 Meilisearch 的查询语法。它也是单元测试的绝佳对象。此外,fetchProducts 函数接受一个 AbortSignal,这是一个非常重要的实践。当用户快速连续输入时,前一个查询可能还在进行中,新的查询就已经发起了。如果不中止旧的请求,可能会导致竞态条件(race condition),即一个旧的、过时的结果覆盖了新的结果。

现在,我们可以构建核心的 Recoil selector 了。

查询 Selector (store/selectors.js):

import { selector } from 'recoil';
import { filtersState, paginationState } from './atoms';
import { fetchProducts } from '../services/searchService';
import debounce from 'lodash.debounce';

// 创建一个 debounce 版本的 fetch 函数
// 注意:这里的 debounce 逻辑比较复杂,因为它需要处理 AbortController
// 一个更健壮的实现可能需要一个自定义的 debounce hook
const debouncedFetch = debounce(
  (resolve, reject, params) => {
    fetchProducts(params).then(resolve).catch(reject);
  }, 
  300 // 300ms 延迟
);

export const productQuery = selector({
  key: 'productQuery',
  get: ({ get }) => {
    const filters = get(filtersState);
    const pagination = get(paginationState);

    // Recoil 的 selector 支持返回 Promise
    // 当依赖变化时,旧的 Promise 会被取消,Recoil会重新执行 get 函数
    const controller = new AbortController();
    
    // Recoil 的 get 上下文不提供取消回调,所以我们必须在 Promise 外部管理 AbortController
    // 这是一个已知的 Recoil 模式,用于处理异步操作的取消。
    const promise = new Promise((resolve, reject) => {
      debouncedFetch(resolve, reject, {
        filters,
        pagination,
        signal: controller.signal,
      });
    });

    // 这是一个非标准的 Recoil 模式,但对于实现取消是有效的。
    // 我们将取消函数附加到 Promise 上。
    // 当 Recoil 取消这个 selector 的求值时,它不会直接调用这个函数,
    // 但在 React 组件的 effect 中,我们可以利用这个机制。
    // 更现代的 recoil-relay 或其他库有更优雅的模式。
    // 在这个场景中,我们需要手动管理。
    // 或者,一个更简单的模式是,在 React 组件的 useEffect 中处理 debounce 和 abort。
    // 但为了将逻辑保留在 Recoil graph 中,我们选择这种方式。

    // 为了简化,我们暂时移除复杂的 debounce 和 abort 逻辑,聚焦于核心
    // 在生产代码中,这部分是必须的。
    // 以下是简化但仍能工作的版本:
    return fetchProducts({ filters, pagination });
  },
});

更新: 上述关于 AbortControllerdebounce 直接在 selector 中使用的注释指出了一个 Recoil 的复杂之处。一个更干净、更符合 Recoil 哲学的模式是将这种副作用逻辑放在 React 组件的 useEffect 中,或者使用 atom effects。但为了演示数据流的核心,我们先采用简化的直接调用方式。一个关键的坑在于:selectorget 函数应该是纯粹的。引入 debounceAbortController 这样的副作用使得它不再纯粹。在真实项目中,我们会创建一个自定义的 React Hook,如 useDebouncedSearchQuery,它内部使用 useEffect 来处理这些副作用,然后将最终的 Loadable 状态返回给组件。

但为了保持逻辑的集中,我们这里采用一个折中方案,假设 fetchProducts 内部能处理好重复请求。

第四步:编写可信赖的单元测试

这套架构的优势之一是其可测试性。

  1. Command 逻辑: 更新 filtersState 的函数(如果封装起来的话)是同步的,易于测试。
  2. 查询转换逻辑: buildFilterArray 是一个纯函数,是测试的完美目标。
  3. API 服务: 我们可以用 msw (Mock Service Worker) 或 jest.mock 来模拟 meilisearch-js 客户端,测试 fetchProducts 的逻辑。
  4. Recoil Selectors: recoil-test-render 或类似的库可以让我们在测试环境中渲染 Recoil 状态,并断言其值。

测试 buildFilterArray (services/searchService.test.js):

import { buildFilterArray } from './searchService'; // 假设导出此函数

describe('buildFilterArray', () => {
  it('should return an empty array for default filters', () => {
    const filters = { query: '', brands: [], categories: [], priceRange: null, sortPrice: null };
    expect(buildFilterArray(filters)).toEqual([]);
  });

  it('should build filter for a single brand', () => {
    const filters = { query: '', brands: ['TechCorp'], categories: [], priceRange: null, sortPrice: null };
    expect(buildFilterArray(filters)).toEqual(['(brand = "TechCorp")']);
  });

  it('should build filter for multiple brands using OR', () => {
    const filters = { query: '', brands: ['TechCorp', 'FurniMax'], categories: [], priceRange: null, sortPrice: null };
    expect(buildFilterArray(filters)).toEqual(['(brand = "TechCorp" OR brand = "FurniMax")']);
  });

  it('should build filter for price range', () => {
    const filters = { query: '', brands: [], categories: [], priceRange: [100, 500], sortPrice: null };
    expect(buildFilterArray(filters)).toEqual(['price 100 TO 500']);
  });

  it('should combine multiple filter types with AND logic', () => {
    const filters = { 
      query: '', 
      brands: ['TechCorp'], 
      categories: ['Electronics'], 
      priceRange: [1000, 2000], 
      sortPrice: null 
    };
    const result = buildFilterArray(filters);
    // 顺序不重要,但内容必须正确
    expect(result).toHaveLength(3);
    expect(result).toContain('(brand = "TechCorp")');
    expect(result).toContain('(category = "Electronics")');
    expect(result).toContain('price 1000 TO 2000');
  });
});

这种针对纯函数的测试编写起来非常简单,而且能提供极高的信心。

测试 Recoil Selector (store/selectors.test.js):
测试异步 selector 需要一些设置。我们需要一个测试工具来渲染 Recoil hook 并等待异步操作完成。

import React, { Suspense } from 'react';
import { renderRecoilHook } from 'react-recoil-hooks-testing-library';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { productQuery } from './selectors';
import * as searchService from '../services/searchService';

// Mock 整个 searchService 模块
jest.mock('../services/searchService');
const mockedFetchProducts = searchService.fetchProducts;

describe('productQuery selector', () => {
  
  beforeEach(() => {
    // 在每次测试前重置 mock
    mockedFetchProducts.mockClear();
  });

  it('should fetch products and return data on success', async () => {
    const mockResponse = { hits: [{ id: 1, name: 'Laptop' }], nbHits: 1 };
    mockedFetchProducts.mockResolvedValue(mockResponse);

    const { result, waitForNextUpdate } = renderRecoilHook(
      () => useRecoilValue(productQuery),
      { wrapper: RecoilRoot }
    );
    
    // 初始状态是 loading (因为 Suspense)
    expect(result.current).toBeUndefined();

    // 等待 Promise resolve
    await waitForNextUpdate();

    // 检查 selector 的返回值
    expect(result.current).toEqual(mockResponse);
    expect(mockedFetchProducts).toHaveBeenCalledTimes(1);
  });

  it('should throw an error when fetch fails', async () => {
    const mockError = new Error('Network Error');
    mockedFetchProducts.mockRejectedValue(mockError);

    // 对于 Recoil 错误,我们需要一个 ErrorBoundary 来捕获
    const ErrorBoundary = ({ children }) => {
      try {
        return children;
      } catch (error) {
        if (error instanceof Promise) {
          throw error; // This is Suspense
        }
        return <div>Error caught</div>;
      }
    };

    const { result, waitForNextUpdate } = renderRecoilHook(
        () => {
            try {
                return useRecoilValue(productQuery);
            } catch (e) {
                // 在测试中捕获抛出的错误
                return e;
            }
        },
        { wrapper: ({ children }) => <RecoilRoot><Suspense fallback={null}>{children}</Suspense></RecoilRoot> }
    );
    
    // 这部分测试异步错误有点 tricky,取决于测试库的实现。
    // 一个更常见的模式是使用 `useRecoilValueLoadable`
    // 但核心思想是验证当 Promise reject 时,selector 状态会反映错误。
    // 由于 `recoil-test-render` 已不维护,这里仅作示意。
    // 在真实项目中,可使用 @testing-library/react 配合自定义组件测试。
  });
});

单元测试的价值在于,它迫使我们写出更模块化、更少副作用的代码。我们的 buildFilterArrayfetchProducts 都是因此而设计的。

第五步:组装 React UI

有了坚实的状态管理和数据获取层,UI 组件的实现变得异常清晰。它只负责两件事:

  1. 响应用户交互,调用 set 函数更新 filtersState (Command)。
  2. 使用 useRecoilValueLoadable 订阅 productQuery,并根据其状态(loading, hasValue, hasError)渲染不同内容 (Query)。

产品列表组件 (components/ProductList.js):

import React from 'react';
import { useRecoilValueLoadable, useSetRecoilState } from 'recoil';
import { productQuery }s from '../store/selectors';
import { filtersState } from '../store/atoms';

const ProductList = () => {
  const productsLoadable = useRecoilValueLoadable(productQuery);
  const setFilters = useSetRecoilState(filtersState);

  const handleQueryChange = (e) => {
    setFilters(prev => ({ ...prev, query: e.target.value }));
  };

  return (
    <div>
      <input 
        type="text" 
        placeholder="Search products..." 
        onChange={handleQueryChange} 
      />
      
      {/* 其他筛选组件... */}

      {productsLoadable.state === 'loading' && <div>Loading...</div>}
      {productsLoadable.state === 'hasError' && <div>Error loading products.</div>}
      {productsLoadable.state === 'hasValue' && (
        <ul>
          {productsLoadable.contents.hits.map(product => (
            <li key={product.id}>{product.name} - ${product.price}</li>
          ))}
        </ul>
      )}
    </div>
  );
};

export default ProductList;

这个组件本身几乎没有逻辑。所有的复杂性都被封装在了 Recoil 的状态图中。这就是关注点分离带来的好处。当我们需要修改搜索逻辑时,我们去修改 selectorservice;当我们需要调整 UI 时,我们修改这个组件。它们之间互不干扰。

方案的局限性与展望

这种基于前端 CQRS 模式的架构并非银弹。它引入了额外的复杂性,对于简单的页面来说是一种过度设计。其主要价值体现在状态交互复杂、对响应性能要求高的场景。

一个核心的权衡是最终一致性。由于 Meilisearch 的数据同步于主数据库存在延迟,用户在执行写操作(如更新一个产品价格)后,在搜索页面看到的可能还是旧数据,直到同步完成。在大多数场景下,这种秒级的延迟是可以接受的。若要求强一致性,则此方案不适用。

另一个挑战是服务端状态与客户端状态的同步。如果筛选条件需要通过 URL 参数持久化,以便分享链接,那么我们需要额外的逻辑(通常在 useEffect 中)来同步 URL 查询参数和 Recoil atom

未来的优化路径可以探索:

  1. 实时更新: 使用 WebSocket 或 Server-Sent Events。当 Meilisearch 索引更新时,服务端可以主动推送一个消息,通知前端某个查询结果“已失效”,前端可以自动重新执行 productQuery selector,实现近乎实时的界面更新。
  2. 更智能的缓存: Recoil 的 selector 自带缓存,但这是内存缓存。可以结合 recoil-persist 或自定义 atom effects,将某些查询结果持久化到 localStorageIndexedDB,实现更快的初始加载和离线支持。
  3. 查询编排: 对于更复杂的依赖关系,例如一个查询依赖于另一个异步查询的结果,Recoil 的 selector 链可以非常优雅地处理,而这是传统 useEffect 方案难以管理的。

  目录