feat: 插件性能护栏 + 公共组件库——激活/装载限时兜底、renderer 装载三态呈现、@aso/plugin-sdk/ui、withTimeout/withRetry

This commit is contained in:
Rick Ye
2026-09-11 09:56:26 +08:00
parent 4ed31a34fc
commit f77942ee2b
7 changed files with 473 additions and 12 deletions
+25 -1
View File
@@ -13,7 +13,13 @@ import { Search } from './components/Search'
import { BrowserPanel } from './components/browser/BrowserPanel'
import { SidePanel, type PanelTabIcon } from './components/SidePanel'
import { PluginPageFrame, PluginIcon } from './plugins/PluginPageFrame'
import { useExternalPluginComponents, usePluginRendererLoader } from './plugins/renderer-host'
import {
useExternalPluginComponents,
usePluginRendererLoader,
usePluginRenderStatus,
retryPluginRenderer
} from './plugins/renderer-host'
import { LoadingState, ErrorState } from '@aso/plugin-sdk/ui'
import { PluginErrorBoundary } from './plugins/PluginErrorBoundary'
import { PluginStatusCapsules } from './plugins/PluginStatusCapsules'
import { usePluginList, usePluginsUiState, sidebarPlugins } from './plugins/store'
@@ -60,6 +66,20 @@ function loadSideState(): SideState {
return { open: false, width: SIDE_DEFAULT_WIDTH, tabs: [], active: null }
}
/** 外置 renderer 槽位的统一等待/失败态:装载中显示 LoadingState,失败显示 ErrorState + 重试 */
function PluginRendererSlot({ pluginId }: { pluginId: string }): JSX.Element {
const status = usePluginRenderStatus(pluginId)
if (!status || status.state !== 'error') return <LoadingState className="h-full" />
return (
<ErrorState
className="h-full"
title="插件界面装载失败"
description={status.message}
onRetry={() => retryPluginRenderer(pluginId)}
/>
)
}
export default function App(): JSX.Element {
const {
state,
@@ -347,6 +367,8 @@ export default function App(): JSX.Element {
icon: pluginTabIcons.get(id) ?? (LuGlobe as PanelTabIcon),
content: PanelComponent ? (
<PanelComponent />
) : plugin.hasRenderer ? (
<PluginRendererSlot pluginId={id} />
) : plugin.hasPage ? (
<PluginPageFrame plugin={plugin} onOpenView={handleOpenView} reloadToken={pluginReloadToken} />
) : (
@@ -477,6 +499,8 @@ export default function App(): JSX.Element {
>
{ActivePluginComponent ? (
<ActivePluginComponent />
) : activePlugin.hasRenderer ? (
<PluginRendererSlot pluginId={activeView.view} />
) : activePlugin.hasPage ? (
<PluginPageFrame
plugin={activePlugin}
@@ -6,16 +6,21 @@
* (dev 页面源是 http://localhost,直接 import(plugin://) 会被 CORS 拦),
* 默认导出 mount(ctx) 注册 view/panel 组件;列表/热重载变化即整模块重装
* - 仅开发版装载(与主进程 EXTERNAL_PLUGINS_ENABLED 同开关语义;用户版主进程不下发外置条目)
* - 错误隔离到单插件:装载/注册失败仅 console.error 并清空该插件注册,不影响宿主与其他插件;
* - 性能护栏:读源与 import 均限时 8s(挂死的装载不得无限占位),按插件记录
* loading/ready/error 状态供槽位呈现统一等待/失败态;错误隔离到单插件,
* 组件渲染期崩溃由 App 层 PluginErrorBoundary 兜底
*/
import { useEffect, useSyncExternalStore } from 'react'
import type { ComponentType } from 'react'
import * as React from 'react'
import * as jsxRuntime from 'react/jsx-runtime'
import { withTimeout } from '@aso/plugin-sdk'
import type { PluginHostGlobal, PluginRendererModule } from '@aso/plugin-sdk/renderer'
import type { PluginInfo } from '@shared/types'
/** 单个 renderer 模块读源/装载限时 */
const RENDERER_LOAD_TIMEOUT_MS = 8_000
/** 插件已注册组件(按槽位;缺省 = 未注册该槽位) */
interface PluginComponents {
view?: ComponentType
@@ -49,6 +54,52 @@ export function useExternalPluginComponents(): Map<string, PluginComponents> {
return registry
}
// ---- 装载状态(loading/ready/error,按插件;槽位据此呈现统一等待/失败态) ----
export type PluginRenderStatus = { state: 'loading' } | { state: 'ready' } | { state: 'error'; message: string }
const renderStatus = new Map<string, PluginRenderStatus>()
const statusListeners = new Set<() => void>()
let statusVersion = 0
function setStatus(pluginId: string, status: PluginRenderStatus): void {
renderStatus.set(pluginId, status)
statusVersion++
for (const fn of statusListeners) fn()
}
function deleteStatus(pluginId: string): void {
if (!renderStatus.delete(pluginId)) return
statusVersion++
for (const fn of statusListeners) fn()
}
function subscribeStatus(fn: () => void): () => void {
statusListeners.add(fn)
return () => statusListeners.delete(fn)
}
/** 某插件 renderer 的当前装载状态(未跟踪 = undefined,视为 loading) */
export function usePluginRenderStatus(pluginId: string): PluginRenderStatus | undefined {
useSyncExternalStore(subscribeStatus, () => statusVersion)
return renderStatus.get(pluginId)
}
// ---- 重试:槽位失败态的重试按钮触发整轮重装 ----
let retryTick = 0
/** 重试装载某插件的 renderer(清掉 error 态并重跑装载循环) */
export function retryPluginRenderer(pluginId: string): void {
setStatus(pluginId, { state: 'loading' })
retryTick++
for (const fn of statusListeners) fn()
}
function usePluginRendererRetryTick(): number {
return useSyncExternalStore(subscribeStatus, () => retryTick)
}
// 宿主全局:插件构建期把 react / react/jsx-runtime 别名到这里的实例(见 docs/plugins.md)
;(window as unknown as Record<'__ASO_PLUGIN_HOST__', PluginHostGlobal | undefined>).__ASO_PLUGIN_HOST__ = {
react: React,
@@ -56,12 +107,13 @@ export function useExternalPluginComponents(): Map<string, PluginComponents> {
}
/**
* 外置插件 renderer 模块装载循环:插件列表 / 热重载令牌变化即重装。
* 外置插件 renderer 模块装载循环:插件列表 / 热重载令牌 / 重试信号变化即重装。
* 源码经 IPC 取回后以 Blob URL import——dev 下页面源是 http://localhost,
* 直接 import(plugin://) 会被 CORS 拦截;Blob 模块须自包含(相对导入无基路径)。
* 不在列表(禁用/卸载/关闭开关)或主进程侧报错的插件被移除,其组件随之卸载。
*/
export function usePluginRendererLoader(plugins: PluginInfo[], reloadToken: number): void {
const retryToken = usePluginRendererRetryTick()
useEffect(() => {
if (!import.meta.env.DEV) return
let cancelled = false
@@ -72,12 +124,17 @@ export function usePluginRendererLoader(plugins: PluginInfo[], reloadToken: numb
if (!plugin.enabled || plugin.source !== 'external' || plugin.error || !file) continue
const id = plugin.manifest.id
keep.add(id)
setStatus(id, { state: 'loading' })
try {
const code = await window.api.pluginsReadRenderer(id)
const code = await withTimeout(window.api.pluginsReadRenderer(id), RENDERER_LOAD_TIMEOUT_MS, `读取 ${id} renderer 源码`)
if (cancelled) return
const url = URL.createObjectURL(new Blob([code], { type: 'text/javascript' }))
try {
const mod = (await import(/* @vite-ignore */ url)) as { default?: unknown }
const mod = (await withTimeout(
import(/* @vite-ignore */ url),
RENDERER_LOAD_TIMEOUT_MS,
`装载 ${id} renderer 模块`
)) as { default?: unknown }
if (cancelled) return
const mount = mod.default
if (typeof mount !== 'function') throw new Error('renderer 模块默认导出必须是 mount(ctx) 函数')
@@ -87,21 +144,27 @@ export function usePluginRendererLoader(plugins: PluginInfo[], reloadToken: numb
registerView: (component) => upsert(id, { view: component }),
registerPanel: (component) => upsert(id, { panel: component })
})
setStatus(id, { state: 'ready' })
} finally {
URL.revokeObjectURL(url)
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`[plugins] ${id} renderer 装载失败:`, err)
setStatus(id, { state: 'error', message })
remove(id)
}
}
for (const id of [...registry.keys()]) {
if (!keep.has(id)) remove(id)
if (!keep.has(id)) {
remove(id)
deleteStatus(id)
}
}
}
void load()
return () => {
cancelled = true
}
}, [plugins, reloadToken])
}, [plugins, reloadToken, retryToken])
}