ui-next: refactor component registry structure

This commit is contained in:
Baoshuo committed 2026-05-10 01:12:47 +08:00
1 parent 8bb08926f8
commit 28f977f6c4
11 files changed
+294 -108

No files matched your search

+6 -1
View File
@@ -7,4 +7,9 @@ export { type RouterState, useNavigate, useRouterState } from './src/context/rou
export { useUrl } from './src/hooks/use-url';
// Registry
export type { ComponentName, RegistryContext } from './src/registry';
export type {
Interceptor, InterceptorEntry, InterceptorOptions,
PluginAPI, PluginDefinition,
SlotName,
} from './src/registry';
export { defineSlot } from './src/registry';
+11 -2
View File
@@ -1,8 +1,8 @@
import { Link } from './components/link';
import { usePageData } from './context/page-data';
import { Component } from './registry';
import { before, defineSlot } from './registry';
const AppInner = Component('page:app', () => {
const AppInner = defineSlot('page:app', () => {
const data = usePageData();
return (
@@ -15,4 +15,13 @@ const AppInner = Component('page:app', () => {
);
});
before('page:app', () => {
console.log('before app');
return (
<>
<div>before app</div>
</>
);
});
export default AppInner;
+2 -3
View File
@@ -4,11 +4,10 @@ import plugins from 'virtual:hydro-plugins';
import App from './app';
import { type PageData, PageDataProvider } from './context/page-data';
import { RouterProvider } from './context/router';
import { createRegistryContext } from './registry';
import { installPlugin } from './registry';
const ctx = createRegistryContext();
for (const plugin of plugins) {
plugin.apply(ctx);
installPlugin(plugin);
}
const injectionEl = document.getElementById('__HYDRO_INJECTION__');
+5
View File
@@ -0,0 +1,5 @@
export { after, before, intercept, patch, replace, wrap } from './interceptors';
export { installPlugin } from './plugin';
export type { PluginAPI, PluginDefinition } from './plugin';
export { defineSlot } from './slot';
export type { Interceptor, InterceptorEntry, InterceptorOptions, SlotName } from './types';
-100
View File
@@ -1,100 +0,0 @@
/* eslint-disable react-refresh/only-export-components */
import React from 'react';
type Scope = 'page' | 'component';
export type ComponentName = `${Scope}:${string}`;
interface BeforeAfterEntry {
id: string;
Comp: React.FC<any>;
}
interface PatchEntry {
id: string;
cb: (props: any) => any;
}
function createRegistry() {
return {
components: {} as Record<string, React.FC<any>>,
before: {} as Record<string, BeforeAfterEntry[]>,
after: {} as Record<string, BeforeAfterEntry[]>,
patch: {} as Record<string, PatchEntry[]>,
};
}
const registry: ReturnType<typeof createRegistry> = import.meta.hot?.data?.registry ?? createRegistry();
if (import.meta.hot) import.meta.hot.data.registry = registry;
let idCounter = import.meta.hot?.data?.idCounter ?? 0;
if (import.meta.hot) {
Object.defineProperty(import.meta.hot.data, 'idCounter', {
get: () => idCounter,
set: (v: number) => { idCounter = v; },
});
}
function genId() { return `__auto_${++idCounter}`; }
function upsert<T extends { id: string }>(list: T[], entry: T) {
const idx = list.findIndex((e) => e.id === entry.id);
if (idx !== -1) list[idx] = entry;
else list.push(entry);
}
export function Component<P extends Record<string, any>>(
name: ComponentName,
Inner: React.FC<P>,
): React.FC<P> {
if (registry.components[name]) {
console.warn(`[Registry] Component "${name}" is being re-registered.`);
}
const Wrapped: React.FC<P> = (props) => {
let patched = props;
for (const { cb } of registry.patch[name] ?? []) {
try {
patched = cb(patched);
} catch (e) {
console.error(`[Registry] patch error in "${name}":`, e);
}
}
return (
<>
{(registry.before[name] ?? []).map(({ id, Comp }) => <Comp key={id} {...patched} />)}
<Inner {...patched} />
{(registry.after[name] ?? []).map(({ id, Comp }) => <Comp key={id} {...patched} />)}
</>
);
};
Wrapped.displayName = `Registry(${name})`;
registry.components[name] = Wrapped;
return Wrapped;
}
export function before(name: ComponentName, Comp: React.FC<any>, id?: string) {
const list = registry.before[name] ??= [];
upsert(list, { id: id ?? genId(), Comp });
}
export function after(name: ComponentName, Comp: React.FC<any>, id?: string) {
const list = registry.after[name] ??= [];
upsert(list, { id: id ?? genId(), Comp });
}
export function patch(name: ComponentName, cb: (props: any) => any, id?: string) {
const list = registry.patch[name] ??= [];
upsert(list, { id: id ?? genId(), cb });
}
export interface RegistryContext {
before: typeof before;
after: typeof after;
patch: typeof patch;
register: typeof Component;
}
export function createRegistryContext(): RegistryContext {
return {
before, after, patch, register: Component,
};
}
@@ -0,0 +1,85 @@
import { store } from './store';
import type { Interceptor, InterceptorOptions, SlotName } from './types';
export function intercept<P = any>(
name: SlotName,
interceptor: Interceptor<P>,
opts?: InterceptorOptions,
): () => void {
return store.addInterceptor(name, interceptor, opts);
}
/** default priority: -100 */
export function before<P extends Record<string, any> = Record<string, any>>(
name: SlotName,
Comp: React.FC<P>,
opts?: InterceptorOptions,
): () => void {
return intercept<P>(
name,
(props, next) => (
<>
<Comp {...props} />
{next()}
</>
),
{ priority: -100, ...opts },
);
}
/** default priority: 100 */
export function after<P extends Record<string, any> = Record<string, any>>(
name: SlotName,
Comp: React.FC<P>,
opts?: InterceptorOptions,
): () => void {
return intercept<P>(
name,
(props, next) => (
<>
{next()}
<Comp {...props} />
</>
),
{ priority: 100, ...opts },
);
}
/** default priority: -50 */
export function patch<P = any>(
name: SlotName,
patcher: (props: P) => Partial<P>,
opts?: InterceptorOptions,
): () => void {
return intercept<P>(
name,
(props, next) => next(patcher(props)),
{ priority: -50, ...opts },
);
}
/** default priority: 0 */
export function replace<P extends Record<string, any> = Record<string, any>>(
name: SlotName,
Replacement: React.FC<P>,
opts?: { id?: string, priority?: number },
): () => void {
return intercept<P>(
name,
(props, _next) => <Replacement {...props} />,
{ priority: 0, ...opts },
);
}
/** default priority: -10 */
export function wrap<P extends Record<string, any> = Record<string, any>>(
name: SlotName,
Wrapper: React.FC<{ children: React.ReactNode } & P>,
opts?: InterceptorOptions,
): () => void {
return intercept<P>(
name,
(props, next) => <Wrapper {...props}>{next()}</Wrapper>,
{ priority: -10, ...opts },
);
}
+25
View File
@@ -0,0 +1,25 @@
import { after, before, intercept, patch, replace, wrap } from './interceptors';
export interface PluginAPI {
intercept: typeof intercept;
before: typeof before;
after: typeof after;
patch: typeof patch;
replace: typeof replace;
wrap: typeof wrap;
}
export function createPluginAPI(): PluginAPI {
return { intercept, before, after, patch, replace, wrap };
}
export interface PluginDefinition {
name: string;
setup: (api: PluginAPI) => (() => void) | void;
}
export function installPlugin(plugin: PluginDefinition): () => void {
const api = createPluginAPI();
const cleanup = plugin.setup(api);
return () => cleanup?.();
}
+44
View File
@@ -0,0 +1,44 @@
import { useMemo, useSyncExternalStore } from 'react';
import { store } from './store';
import type { InterceptorEntry, SlotName } from './types';
function buildChain<P extends Record<string, any>>(
interceptors: InterceptorEntry<P>[],
DefaultComp: React.FC<P>,
): (props: P) => React.ReactNode {
let pipeline: (props: P) => React.ReactNode = (props) => <DefaultComp {...props} />;
for (let i = interceptors.length - 1; i >= 0; i--) {
const { interceptor } = interceptors[i];
const downstream = pipeline;
pipeline = (props: P) =>
interceptor(props, (overrideProps) =>
downstream(overrideProps ? { ...props, ...overrideProps } : props),
);
}
return pipeline;
}
export function defineSlot<P extends Record<string, any>>(
name: SlotName,
DefaultComp: React.FC<P>,
): React.FC<P> {
store.setDefault(name, DefaultComp);
const subscribeSlot = (cb: () => void) => store.subscribe(name, cb);
const getSnapshot = () => store.getVersion(name);
const SlotComponent: React.FC<P> = (props) => {
const version = useSyncExternalStore(subscribeSlot, getSnapshot);
// eslint-disable-next-line react-hooks/exhaustive-deps
const chain = useMemo(() => buildChain(store.getInterceptors(name), store.getDefault(name)!), [version]);
return <>{chain(props)}</>;
};
SlotComponent.displayName = `Slot(${name})`;
return SlotComponent;
}
+94
View File
@@ -0,0 +1,94 @@
import type { InterceptorEntry, InterceptorOptions, SlotName } from './types';
type Listener = () => void;
interface RegistryState {
interceptors: Record<string, InterceptorEntry[]>;
defaults: Record<string, React.FC<any>>;
}
function createRegistryStore() {
const state: RegistryState = {
interceptors: {},
defaults: {},
};
const slotListeners: Record<string, Set<Listener>> = {};
const versions: Record<string, number> = {};
let idCounter = 0;
const genId = () => `__slot_${++idCounter}`;
function subscribe(name: SlotName, cb: Listener) {
const set = (slotListeners[name] ??= new Set());
set.add(cb);
return () => set.delete(cb);
}
function notify(name: SlotName) {
slotListeners[name]?.forEach((l) => l());
}
function getVersion(name: SlotName): number {
return versions[name] ?? 0;
}
function bumpVersion(name: SlotName) {
versions[name] = (versions[name] ?? 0) + 1;
notify(name);
}
function addInterceptor<P>(
name: SlotName,
interceptor: (props: P, next: (overrideProps?: Partial<P>) => React.ReactNode) => React.ReactNode,
opts?: InterceptorOptions,
): () => void {
const entry: InterceptorEntry<P> = {
id: opts?.id ?? genId(),
priority: opts?.priority ?? 0,
interceptor,
};
const list = (state.interceptors[name] ??= []);
const idx = list.findIndex((e) => e.id === entry.id);
if (idx !== -1) list[idx] = entry;
else list.push(entry);
list.sort((a, b) => a.priority - b.priority);
bumpVersion(name);
return () => {
const arr = state.interceptors[name];
if (!arr) return;
const i = arr.findIndex((e) => e.id === entry.id);
if (i !== -1) {
arr.splice(i, 1);
bumpVersion(name);
}
};
}
function getInterceptors(name: SlotName): InterceptorEntry[] {
return state.interceptors[name] ?? [];
}
function setDefault(name: SlotName, comp: React.FC<any>) {
state.defaults[name] = comp;
}
function getDefault(name: SlotName): React.FC<any> | undefined {
return state.defaults[name];
}
return {
subscribe,
getVersion,
addInterceptor,
getInterceptors,
setDefault,
getDefault,
};
}
export const store: ReturnType<typeof createRegistryStore> = import.meta.hot?.data?.slotStore ?? createRegistryStore();
if (import.meta.hot) import.meta.hot.data.slotStore = store;
+20
View File
@@ -0,0 +1,20 @@
type Scope = 'page' | 'component' | 'layout' | string;
export type SlotName = `${Scope}:${string}`;
export type Interceptor<P = any> = (
props: P,
next: (overrideProps?: Partial<P>) => React.ReactNode,
) => React.ReactNode;
export interface InterceptorOptions {
id?: string;
/** 越小越靠外,越先执行 */
priority?: number;
}
export interface InterceptorEntry<P = any> {
id: string;
/** 越小越靠外,越先执行 */
priority: number;
interceptor: Interceptor<P>;
}
+2 -2
View File
@@ -1,7 +1,7 @@
/// <reference types="vite/client" />
declare module 'virtual:hydro-plugins' {
import type { RegistryContext } from './registry';
const plugins: Array<{ apply: (ctx: RegistryContext) => void }>;
import type { PluginDefinition } from './registry';
const plugins: PluginDefinition[];
export default plugins;
}