编写插件
插件在 install() 里拿到一个本次连接纪元专属的作用域(LifecycleScope)。每登记一处宿主改动,就在作用域上 acquire() 一条撤销条目;断开连接时宿主按逆序串行释放它们。插件因此不需要自己维护「装了什么、该拆什么」的清单,也不需要终态标记。
契约
import type { IRxDBPlugin, Plugin, RxDB } from '@aiao/rxdb';
import { RxDBPluginBase } from '@aiao/rxdb';
import type { LifecycleScope } from '@aiao/utils';
export class RxDBPluginExample extends RxDBPluginBase implements IRxDBPlugin {
/** 声明本插件已迁移到作用域拆卸;宿主释放完作用域就收手,不再调用 destroy() */
readonly lifecycle = 'scoped' as const;
/** 插件名,用于日志与宿主侧的错误归因,同时决定作用域标签 `plugin:example` */
readonly name: Uncapitalize<string> = 'example';
install(scope: LifecycleScope): void | Promise<void> {
// 在这里登记本纪元的全部宿主改动与外部资源
}
}
export const rxDBPluginExample: Plugin = (db: RxDB) => new RxDBPluginExample(db);
| 成员 | 必需 | 说明 |
|---|---|---|
name | ✅ | 插件名(首字母小写) |
install(scope) | ✅ | 建立本纪元资源;抛错或 reject 视为安装失败 |
inject | ⬜ | 声明依赖;全部就绪后宿主才调 install(),未满足则一次都不调 |
lifecycle | ⬜ | 取 'scoped' 表示拆卸完全交给作用域 |
destroy?() | ⬜ | 已废弃。仅为尚未迁移的插件保留,未声明 lifecycle 时宿主会在释放作用域之后再调用一次 |
实现方不写形参不破坏契约——install() 与 install(scope) 同样满足接口。
系统贡献(system):建表之前的插件
需要自带系统表或参与核心建表的插件,还要声明一个可选的 system 成员(RxDBSystemContribution)。宿主在 use() 里同步读它,因此这类插件必须排在 connect() 之前:
export class RxDBPluginExample extends RxDBPluginBase implements IRxDBPlugin {
readonly lifecycle = 'scoped' as const;
readonly name = 'example';
/** 宿主在 use() 里同步读;连不连库都不影响这份声明的有效性 */
readonly system: RxDBSystemContribution = {
capability: 'example',
version: 1,
packageSpecifier: '@your-org/rxdb-plugin-example',
entities: [ExampleSystemTable],
createInitialRows: (_entityManager, _context) => [],
createMigrations: entityManager => [createExampleMigration(entityManager)]
};
install(): void {}
}
| 字段 | 说明 |
|---|---|
capability | 能力名;非空且不含 :。同时是水位行归因与版本不匹配报错用的名字 |
version | 能力版本;核心拿它写水位行、不比对 |
packageSpecifier | 包说明符(如 @aiao/rxdb-plugin-working-tree);未认领守卫把它原样报给用户 |
entities | 系统表实体类;进核心的系统表身份集(isSystemEntity() 认得它们,跨包消费者不会当接入方数据) |
createInitialRows | 新库建表那一刻写入的初始行 |
createMigrations | 既有库的引导迁移;与核心系统迁移并进同一条链、同一张 rxdb_migration、同一把锁,名字必须是 NNNN- 数字前缀 |
bootstrapExisting | 可选;既有库连接时接通运行期(如装捕获) |
writeBranchRows | 可选;每条新分支创建时贡献行 |
宿主会把每个贡献加工成一条能力水位行(__rxdb_capability__:<capability>:<version>:<packageSpecifier>),新库随建表写入、既有库伪装成一条空转迁移写入。此后没装该插件的客户端再打开这个库,核心拒绝连接并报出 packageSpecifier——这道「未认领能力守卫」对第三方插件同样有效,包名是插件自己写进水位的,核心不需要认识它。
install() 对这类插件往往是空的:入口在构造时挂、表与迁移经 system 由宿主在建表那一刻编排,两者都早于 install()。仍然声明 lifecycle = 'scoped'——它表示「不要调 destroy()」,与登记了几条无关。
内置范例:@aiao/rxdb-plugin-working-tree 是当前唯一的系统贡献插件,见工作树与提交历史插件。
在作用域上登记
scope.acquire(setup, label) 立即执行 setup(),把它返回的清理函数记进清单,并返回一个可提前撤销这一条的句柄。
install(scope: LifecycleScope) {
scope.acquire(() => {
const channel = new BroadcastChannel('example');
return () => channel.close();
}, 'example:channel');
scope.acquire(() => {
const onCreate = (event: EntityEvent) => this.#handle(event);
this.rxdb.addEventListener(ENTITY_LOCAL_CREATE_EVENT, onCreate);
return () => this.rxdb.removeEventListener(ENTITY_LOCAL_CREATE_EVENT, onCreate);
}, 'example:entity-create');
}
label 只用于诊断(scope.getEntries() 与错误信息),但请认真写:拆卸报错时它是唯一能指认「哪一条没退干净」的线索。
宿主 API 若接受作用域,直接把 scope 递进去,不用自己包一层:
install(scope: LifecycleScope) {
// 断开连接时这条注册跟着一起撤销
this.rxdb.repository('GraphRepository', config, scope);
}
一次 acquire() 只包一步可能抛错的获取
这是硬约束,不是风格建议。setup() 抛错时这一条不会进入清单,它内部已经造出来的东西宿主够不着,必然泄漏。
// ❌ open() 成功、subscribe() 抛错 —— store 泄漏,没人关得掉它
scope.acquire(() => {
const store = openStore();
const sub = source$.subscribe(handler); // 抛错则 store 已经开着了
return () => {
sub.unsubscribe();
store.close();
};
}, 'example:everything');
// ✅ N 个资源写 N 次 acquire(),第二步失败时第一步已经在清单里
scope.acquire(() => {
const opened = openStore();
this.#store = opened;
return () => {
this.#store = undefined;
opened.close();
};
}, 'example:store');
scope.acquire(() => {
const sub = source$.subscribe(handler);
return () => sub.unsubscribe();
}, 'example:subscription');
setup() 只返回清理函数,或返回 undefined 表示这一步无需清理。需要在别处用到造出来的值时,在 setup() 里赋给实例字段,并在清理函数里把字段复位——字段的有无就是「本纪元装没装」,不需要额外的布尔量。
acquire() 的返回值是提前单独撤销这一条的句柄,不是资源本身;不需要提前撤销就忽略它。
子作用域
一组资源要能整体提前释放时,开一个子作用域:scope.child('example:session')。释放父作用域会连带释放它,无需手工记账。
构造函数与 install() 的分工
- 构造函数只做注册期的事:发布摘不掉的身份属性(如
rxdb.workspace)、读取 options。这些跨纪元复用,同一个实例要能挺过断开重连。 install()建立本纪元资源:IndexedDB store、BroadcastChannel、订阅、事件监听器。全部按作用域条目登记。
改造前这些资源在构造函数里建、靠一段手写的 rollback() 序列回滚;现在获取与释放成对写在同一处,回滚由作用域负责。
作用域不跨纪元复用:disconnectAll() 之后重新 connect(),install() 会收到一个全新的作用域实例。把它存到实例字段上跨纪元读,读到的是已释放的旧对象。
安装失败
install() 抛错(含 Promise reject)视为安装失败。宿主会先把已经登记进 scope 的部分逆序释放掉,再把原错误传播给 connect()。回滚期间的清理错误只记日志,不会盖掉安装错误。
插件自己不要在 install() 的 catch 里做补偿性清理——清单在宿主手里,重复清理只会把幂等性问题引进来。
声明依赖,不要自己等
需要适配器才能干活的插件,用 inject 声明依赖,由宿主决定装载时机:
class ExampleSearchPlugin implements IRxDBPlugin {
readonly name = 'exampleSearch';
readonly inject = ['adapter:local'] as const;
readonly lifecycle = 'scoped' as const;
install(scope: LifecycleScope) {
// 被调用即代表依赖就绪:引导链(迁移、建表、索引)已经跑完
const adapter = this.rxdb.localAdapterSync;
scope.acquire(() => {
/* 同步登记 */
}, 'example:entry');
return this.#setup(adapter);
}
}
inject 的取值是一个封闭集合:'adapter:local'、'adapter:remote'、`plugin:${string}`(首字母小写的插件名)。编译期挡住的是形状——裸名 'search'、大写开头的 'plugin:Search'、未知前缀 'service:logger'、拼错的 'adapter:cache' 都编译失败。插件名本身拼错('plugin:serch')形状仍然合法,编译期无从判别,只会在运行时以「依赖永远不满足」的警告暴露,见下一节。
依赖另一个插件
plugin:x 里的 x 就是对方的 name。就绪的判据是「对方已经装好」,不是「对方已经注册」:只有提供方的 install() 真正落地之后,依赖方才开始安装。
class ReportPlugin implements IRxDBPlugin {
readonly name = 'report';
readonly inject = ['plugin:search'] as const; // search 装好了我才开工
readonly lifecycle = 'scoped' as const;
}
注册顺序不影响这一点——先 use(reportPlugin) 再 use(searchPlugin) 同样是 search 先装。想拿到提供方实例,用 db.getPlugins('search'),它返回该名字下的全部候选(只读快照,按 use() 顺序)。
释放顺序是装载顺序的逆序,两条规则有优先级:先逆拓扑,同层内再逆插入序。依赖方的清理条目多半还在用提供方建起来的东西,所以 report 一定先于 search 释放。互不依赖的插件之间保持注册顺序不变,因此不声明 plugin:* 的工作区行为与过去完全一致。
重名与歧义
插件名不是唯一键,宿主不阻止重名:两个插件都叫 search 时只有一条 console.warn,两个都照常安装。歧义只在这个名字真的被 inject 时才是错误——此时 use() 同步抛 RxDBPluginAmbiguousDependencyError,错误信息列出全部候选的构造来源,让你知道该给谁改名。
环与半装状态
依赖成环(a 依赖 b、b 依赖 a)在注册时就被拒绝:use() 同步抛 RxDBPluginDependencyCycleError,信息里给出完整环路径(a → b → a)。这类规划期错误发生在任何 install() 之前,因此不会留下半装状态;成环的那个实例也不会进入注册表,不会毒化后续的 init()。
注意区分两类失败:安装失败不从 use() / init() 抛(只记日志,等下一次纪元变化重来),规划期错误(成环、歧义)则同步从 use() 抛出。
依赖没满足会怎样
- 插件不安装,也不会拿到作用域——
install()一次都不调; - 控制台按插件只警告一次,不会每轮调度刷屏;
connect()照常 resolve:它只等真正开工了的安装。等一个永远等不到依赖的插件会把整个连接拖死,所以宿主不等。
也就是说,await db.connect() 返回不代表你的插件装上了。要确认装没装,读插件自己的就绪信号(例如搜索插件的 ready)。
依赖变了会重装
依赖的纪元身份按实例引用判定,不是按名字。断连重连拿到同名但不同实例的适配器,算一次纪元变化:宿主先释放旧作用域,再用新实例重装一轮。所以 install() 必须能被同一个实例调用多次,每轮只认自己收到的那个 scope。
同一纪元内安装失败不会自动重试——重试的唯一触发点是纪元变化。
不要在 install() 里等宿主连接
connect() 的顺序是「适配器就绪 → await 全部插件的 install()」。所以 install() 里 await db.connect() 是在等自己:
install(scope: LifecycleScope) {
- return db.connect().then(() => this.#setup()); // 死锁:connect() 正在等这个 promise
+ const adapter = db.localAdapterSync; // 声明 inject 之后,被调用即代表已就绪
+ return this.#setup(adapter);
}
声明了 inject 就不要再自己等依赖了——不要 db.connect(),不要订阅 db.adapterConnected$(name),也不要 await 另一个插件的就绪 promise。这些等待在依赖调度落地之前是唯一的办法,现在它们只会把已经解掉的死锁重新绑回去。
跨纪元的迟到任务
install() 启动的异步任务可能跨越一次断开重连。每个 await 之后、写实例字段或结算等待者之前,先复核纪元身份还是不是自己那一轮:
const store = this.#store;
const rows = await read(store);
if (this.#store !== store) return; // 纪元已换:结果只能丢弃
this.#rows = rows;
比对的是身份而不是「是否为 undefined」:读取期间断开又重连时字段已经有值了,那份值属于新纪元,不该由这一轮补写。没有稳定字段可比时,把 install() 收到的 scope 存成字段:它天然一纪元一个,scope.state 还顺带给出「活着 / 释放中 / 已释放」三态,不必再自己维护一个纪元号和一套状态枚举。
拆卸顺序
disconnectAll() 时宿主按逆插入序串行拆卸:后装的先拆。
- 释放该插件的作用域(内部条目再按逆登记序串行释放);
- 若插件未声明
lifecycle: 'scoped',补调一次destroy?()。
任一步抛错只记日志,后面的插件照拆——半拆的实例比拆干净的实例危险得多,disconnectAll() 因此始终 resolve。
双版本插件
既要能装进旧宿主又要能装进新宿主时,两样都写:
// 这里 LifecycleScope 当值用,import 不能带 type
import { LifecycleScope } from '@aiao/utils';
export class RxDBPluginDual extends RxDBPluginBase implements IRxDBPlugin {
readonly lifecycle = 'scoped' as const;
readonly name: Uncapitalize<string> = 'dual';
#scope?: LifecycleScope;
// 形参必须可选:旧宿主调的是 `plugin.install()`,一个实参都不传
install(scope?: LifecycleScope) {
// 旧宿主没有作用域可发,插件自己开一个;它的释放入口就是下面的 destroy()
const epoch = scope ?? new LifecycleScope(this.name);
this.#scope = epoch;
epoch.acquire(/* … */);
}
/** @deprecated 新宿主不会调用它 */
destroy(): Promise<void> {
return this.#scope?.dispose() ?? Promise.resolve();
}
}
旧宿主不认识 lifecycle 字段、只会走 destroy();新宿主认识,于是只走作用域。两边都不会清理两次——dispose() 幂等,即便被调两次也只执行一轮。
自建的那个作用域不要在新宿主下也建:宿主发的作用域已经挂在连接纪元上,再包一层只会多一处要自己记得释放的东西。scope ?? … 的两条分支互斥,靠的就是「新宿主一定传、旧宿主一定不传」。
之所以要显式标记而不是去看 install.length 有没有形参:转译产物、Function.prototype.bind 与压缩器都会改写形参个数,把它当契约会误判。
内部状态也挂作用域
作用域的职责是撤销宿主改动,但插件自身的内部状态(缓存、就绪信号)同样可以挂上去——登记一条只做复位的条目就行,不必为它保留 destroy() 和两步拆卸:
install(scope: LifecycleScope) {
// 最先登记 ⇒ 逆序释放时最后跑:前面那些条目的清理函数还读得到这些状态
scope.acquire(() => () => this.#reset(scope), 'example:state');
this.#bindEntityEvents(scope);
}
#reset(scope: LifecycleScope) {
if (this.#scope !== scope) return; // 旧纪元迟到的释放,不许动新纪元刚建好的状态
this.#cache.clear();
}
复位函数同样要比对纪元身份,理由见上面的「跨纪元的迟到任务」。
@aiao/rxdb-plugin-search 走的就是这条路:entity 事件监听与状态复位都是作用域条目,插件声明 lifecycle: 'scoped',拆卸只有一步。