Core · 开发插件 · 客户端

客户端服务

当多个页面需要协作刷新公告列表时,可以由插件提供一个变更通知服务。它负责发布“已保存”的通知,页面负责请求数据和显示反馈。本例只维护当前浏览器应用实例中的版本号,不实现保存接口、请求缓存或跨浏览器同步。

应用层如何组织请求和共享业务操作,见客户端服务;服务容器原理见业务服务与依赖注入。本篇重点是插件注册、公开契约和 React 订阅。

前提与文件

在已有 @nocobase/app-plugin-announcements 中增加 Client 服务,不要求先完成 Context 或数据库章节。文件路径相对于插件根目录。新建插件时可选 client.service-providers;已有插件合并下面的实现和声明。保留 @nocobase/service-provider、React 和客户端运行时的兼容 peer 依赖。

使用 Agent

纯文本
为公告插件增加客户端变更通知服务,通过原始 Token 共享同一实例。
只有保存成功后才通知页面刷新,失败和取消不能发出成功通知。
提供 useSyncExternalStore Hook,保证快照稳定、卸载取消订阅,
应用关闭时释放插件拥有的监听器。保留已有客户端贡献并测试真实注册。

定义服务与 Token

新增 client/services/changes.ts。services/ 是组织实现的目录,不会自动注册服务。契约、Token 和实现放在同一文件便于本例阅读,较大的插件可以拆开维护。

TypeScript
import { createServiceToken, type ServiceToken } from '@nocobase/service-provider';

export interface AnnouncementChanges {
  getSnapshot(): number;
  subscribe(listener: () => void): () => void;
  notifySaved(): void;
}
export const announcementChangesToken: ServiceToken<AnnouncementChanges> =
  createServiceToken<AnnouncementChanges>('@nocobase/app-plugin-announcements/changes');

export class DefaultAnnouncementChanges implements AnnouncementChanges {
  private revision: number = 0;
  private readonly listeners: Set<() => void> = new Set();

  public readonly getSnapshot = (): number => this.revision;
  public readonly subscribe = (listener: () => void): (() => void) => {
    this.listeners.add(listener);
    return () => { this.listeners.delete(listener); };
  };
  public notifySaved(): void {
    this.revision += 1;
    for (const listener of this.listeners) listener();
  }
  public dispose(): void {
    this.listeners.clear();
  }
}

getSnapshot() 在没有通知时返回相同的数字,适合 React 判断变化。subscribe() 返回注销函数;dispose() 仅由拥有实现的 Provider 调用,不加入使用方的公开契约。

通过 Provider 注册与清理

新增或合并 client/providers/announcements.ts:

TypeScript
import type { ClientApplication } from '@nocobase/app-client';
import { ServiceProvider } from '@nocobase/service-provider';
import {
  DefaultAnnouncementChanges,
  announcementChangesToken,
} from '../services/changes.js';

export class AnnouncementChangesProvider extends ServiceProvider<ClientApplication> {
  public readonly name: string = '@nocobase/app-plugin-announcements/changes';
  private changes: DefaultAnnouncementChanges | undefined;
  public override register(): void {
    this.app.container.singleton(announcementChangesToken, () => {
      this.changes = new DefaultAnnouncementChanges();
      return this.changes;
    });
  }
  public override async shutdown(): Promise<void> {
    this.changes?.dispose();
    this.changes = undefined;
  }
}

Provider 的私有引用只记录自己实际创建的实例。关闭时不会为清理而首次解析服务,也不释放应用共享的 API Client 或其他插件的资源。

将 AnnouncementChangesProvider 加入 client/providers/index.ts 的构造函数数组,并确认 client/plugin.ts 已把该数组赋给 serviceProviders。保留其他 Provider,不能在数组里实例化类,也不要为同一 Token 再注册第二个实现。注册与生命周期的共用规则见 ServiceProvider。

在 React 中订阅

新增 client/hooks/use-announcement-revision.ts:

TypeScript
import { useSyncExternalStore } from 'react';
import { useService } from '@nocobase/app-client';
import { announcementChangesToken } from '../services/changes.js';

export function useAnnouncementRevision(): number {
  const changes = useService(announcementChangesToken);
  return useSyncExternalStore(changes.subscribe, changes.getSnapshot, changes.getSnapshot);
}

useService() 只解析实例;useSyncExternalStore() 才会订阅变化,并在消费者卸载时取消订阅。页面取得 revision 后,可让已有的数据读取逻辑在 revision 改变时重新加载,保留请求取消和旧响应处理,见数据请求与状态管理。

保存入口从同一容器解析 announcementChangesToken,在真实保存请求成功后调用 notifySaved()。不要在 finally 中调用,也不要把通知当作数据库保存成功的证明。本篇没有实现写接口;接入时沿用应用已校验权限的业务接口。不同浏览器或进程中的更新需要额外的服务端通知机制。

对外公开并接入应用

在 client/index.ts 保留插件默认导出,增加:

TypeScript
export { announcementChangesToken } from './services/changes.js';
export type { AnnouncementChanges } from './services/changes.js';
export { useAnnouncementRevision } from './hooks/use-announcement-revision.js';

调用方从插件公开入口导入原始 Token 或 Hook,并在 App 的 client/plugins.ts 注册插件工厂。不要用相同字符串重新创建 Token;只导入 Hook 不会注册服务。包路径与产物映射见插件声明与公开入口。

验证行为

把行为测试放到 tests/client/service-provider.test.tsx,更新已有的脚手架断言,保留其他仍有效的测试:

TSX
import { act, screen } from '@testing-library/react';
import { renderWithApp } from '@nocobase/app-testing/client';
import type { ReactElement } from 'react';
import { expect, it, vi } from 'vitest';
import plugin from '../../client/index.js';
import { announcementChangesToken } from '../../client/services/changes.js';
import { useAnnouncementRevision } from '../../client/hooks/use-announcement-revision.js';

function Revision({ id }: { id: string }): ReactElement {
  return <output data-testid={id}>{useAnnouncementRevision()}</output>;
}
it('shares revisions and releases listeners', async () => {
  const view = await renderWithApp(<><Revision id="a" /><Revision id="b" /></>, {
    plugins: [plugin()],
  });
  const changes = view.app.container.resolve(announcementChangesToken);
  act(() => changes.notifySaved());
  expect(screen.getByTestId('a').textContent).toBe('1');
  expect(screen.getByTestId('b').textContent).toBe('1');
  const listener = vi.fn();
  const stop = changes.subscribe(listener);
  stop();
  act(() => changes.notifySaved());
  expect(listener).not.toHaveBeenCalled();
  changes.subscribe(listener);
  view.unmount();
  await view.app.shutdown();
  changes.notifySaved();
  expect(listener).not.toHaveBeenCalled();
});

还需在实际保存入口验证成功、失败和取消路径:只有成功写入才改变 revision。两个消费者收到同一通知,卸载后不再更新,应用关闭后释放所有自有监听器。

Bash
pnpm --filter @nocobase/app-plugin-announcements check
pnpm nocobase plugin register @nocobase/app-plugin-announcements --no-install --dry-run
pnpm nocobase plugin register @nocobase/app-plugin-announcements --no-install
pnpm build

Context 管理的显示偏好和这里的服务状态各自有明确归属;不必再把同一个 revision 复制到另一份 Context 中。

NocoBase 文档助手

关于 NocoBase,你想了解什么?

每次提问独立回答,不会参考之前的问答。请提供完整背景。