mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-17 09:36:08 +08:00
ui-next: refactor component registry structure
This commit is contained in:
11 files changed
+294
-108
No files matched your search
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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__');
|
||||
|
||||
@@ -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';
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -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?.();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>;
|
||||
}
|
||||
Vendored
+2
-2
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user