一般的 Vue 项目,组件是在构建时打包进去的——import MyComponent from './MyComponent.vue',Webpack/Vite 把它打进 chunk,页面刷新就加载。这是最常见也最简单的模式。
ServerManager 的插件系统有另一个需求:用户安装一个插件,插件的 Vue 页面要在不重新构建前端的情况下出现在界面上——侧边栏多一个菜单项、仪表盘多一个卡片、设置页多一个配置面板。
这就需要运行时加载 Vue 单文件组件(.vue 文件),也就是把”编译 → 打包 → 部署”这个流程从构建时挪到运行时。技术上靠的是 vue3-sfc-loader。
插件元数据的声明式注册
插件后端在 on_load() 生命周期钩子里声明自己有哪些前端组件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| class MyPlugin(BasePlugin): def on_load(self): self.register_frontend_component( component_name='MyDashboard', component_type='dashboard', entry_file='MyDashboard.vue', title='我的仪表盘', icon='mdi-chart-line', dashboard={ 'default_size': 'large', 'allowed_sizes': ['medium', 'large', 'wide'], 'order': 10, }, )
self.register_config_entry( component_name='MyConfig', entry_file='MyConfig.vue', title='我的插件配置', )
|
register_frontend_component() 在 PluginRegistry 里做了一系列校验:
1 2 3 4 5 6 7 8 9 10 11 12 13
| def normalize_frontend_entry_file(entry_file: str, allowed_extensions=None) -> str: allowed_extensions = tuple(allowed_extensions or ('.vue', '.js', '.ts')) entry_file = str(entry_file or '').strip().replace('\\', '/') if not entry_file: raise ValueError("前端入口文件不能为空") if os.path.isabs(entry_file): raise ValueError("前端入口文件不能使用绝对路径") parts = [part for part in entry_file.split('/') if part] if not parts or any(part == '..' for part in parts): raise ValueError("前端入口文件不能包含路径穿越") if not entry_file.endswith(allowed_extensions): raise ValueError(f"前端入口文件必须是: {', '.join(allowed_extensions)}") return '/'.join(parts)
|
绝对路径?拒绝。父目录引用 ..?拒绝。扩展名不对?拒绝。这是插件的输入,不能让它搞出任意文件读取。
四种组件类型
component_type 决定了组件在界面上的呈现位置:
page:完整的路由页面,出现在侧边栏菜单里,有 route_pathwidget:小的挂件,可以嵌入到其他页面dashboard:仪表盘卡片组件,有尺寸约束(small/medium/large/wide)config:配置页面,出现在系统设置的插件配置区域
每种类型的注册参数略有不同,但核心数据结构是一样的。注册完成后,API 端点 /api/plugins/frontend/ 会返回所有已启用插件的前端组件列表。
仪表盘布局系统
dashboard 类型组件的元数据最丰富,因为它需要布局约束:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| def normalize_dashboard_definition(definition: Dict[str, Any]) -> Dict[str, Any]: allowed_sizes = _normalize_dashboard_sizes(definition.get('allowed_sizes')) default_size = _normalize_dashboard_size(definition.get('default_size')) min_size = _normalize_dashboard_size(definition.get('min_size')) if default_size not in allowed_sizes: default_size = allowed_sizes[0] if min_size not in allowed_sizes: min_size = allowed_sizes[0] return { 'title': str(definition.get('title') or '').strip(), 'icon': str(definition.get('icon') or '').strip(), 'default_size': default_size, 'min_size': min_size, 'allowed_sizes': allowed_sizes, 'order': int(definition.get('order') or 0), 'default_hidden': bool(definition.get('default_hidden', False)), }
|
尺寸有四种:small(1 格宽)、medium(2 格宽)、large(2 行 2 列)、wide(3 格宽)。插件声明自己支持哪些尺寸,前端布局引擎从中选一个最合适的。
还有 layoutConstraints(最小/最大宽高)和 displayModes(卡片、简略、详细三种展示模式的切换),这些校验委托给 normalize_layout_constraints() 和 normalize_display_modes() 来保证数据格式正确。
插件之间的依赖声明
插件可以声明自己依赖其他插件:
1 2 3 4 5 6 7 8 9 10 11
| def register_plugin_dependencies(self, plugin_id: str, dependencies: List[Dict]): normalized = [] for dep in dependencies or []: if not isinstance(dep, dict) or not dep.get('id'): raise ValueError(f"无效的插件依赖定义: {dep}") normalized.append({ 'id': str(dep['id']).strip(), 'version': str(dep.get('version') or '').strip(), 'optional': bool(dep.get('optional', False)), }) self._plugin_dependencies[plugin_id] = normalized
|
依赖不只是声明——在跨插件 API 调用时,系统会检查调用方是否声明了对目标插件的依赖:
1 2 3 4 5 6
| def _assert_cross_plugin_access(self, caller_plugin_id, target_plugin_id, permission, callable_name=''): if caller_plugin_id != target_plugin_id: if not self.plugin_depends_on(caller_plugin_id, target_plugin_id): raise PermissionError( f"插件 {caller_plugin_id} 与插件 {target_plugin_id} 未声明依赖关系" )
|
你想调别人的 API?先在 manifest 里声明依赖。这防止了隐式耦合——没有”恰好能用”这回事,依赖关系是白纸黑字写好的。
跨插件通信:API 和 Hook
插件之间的通信分两种模式:
API 调用——一个插件主动调用另一个插件暴露的方法:
1 2 3 4 5
| self.register_plugin_api('send_sms', handler)
result = self.call_plugin_api('plugin_a_id', 'send_sms', phone, message)
|
Hook 触发——一个插件触发事件,另一个插件响应:
1 2 3 4 5
| self.register_plugin_hook('on_order_created', handler)
result = self.trigger_plugin_hook('plugin_a_id', 'on_order_created', order_data)
|
两者都经过沙箱。调用方和被调用方的代码都在 PluginSandbox.execute_safe_async() 里执行,权限检查在两端都做。
运行时加载 Vue 组件
后端注册完了,前端的活怎么干?
插件的 .vue 文件存在服务端的 plugins/<plugin_id>/frontend/ 目录下。前端通过 API 拿到组件列表后,对每个 entry_file 发起 HTTP 请求获取源码,然后用 vue3-sfc-loader 在浏览器里编译:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| import { loadModule } from 'vue3-sfc-loader'
async function loadPluginComponent(pluginId, entryFile) { const url = `/api/plugins/${pluginId}/frontend/${entryFile}` const component = await loadModule(url, { modules: { vue: Vue, 'vue-router': VueRouter, vuetify: Vuetify, }, }) return defineAsyncComponent(() => component) }
|
vue3-sfc-loader 会在浏览器里解析 .vue 文件的 <template>、<script>、<style>,编译成可渲染的组件对象。这样一来,插件不需要走主项目的构建流程——装了就在,卸了就消失,前端不需要重新构建。
安全考量
运行时加载的代码天然有不信任的问题。后端已经通过 AST 校验和沙箱做了代码层面的安全限制(另一篇文章讲过),前端这边也有防护:
- 入口文件路径校验——绝对路径、路径穿越都被拒绝
- 扩展名白名单——只允许
.vue、.js、.ts - 权限控制——每个组件都可以声明
required_permission,前端拿到 API 返回的组件列表后,只渲染当前用户有权限访问的组件 - 样式隔离——插件组件的样式影响范围需要控制,Vuetify 的 scoped 样式部分解决了这个问题
热插拔:装了就在,卸了就消失
插件卸载时,PluginRegistry.unregister_plugin() 会清理所有注册信息——API 路由、前端组件、配置入口、仪表盘模块、节点动作、数据库模型引用,统统删干净:
1 2 3 4 5 6 7 8 9 10 11
| def unregister_plugin(self, plugin_id: str): self._plugins.pop(plugin_id, None) self._plugin_contexts.pop(plugin_id, None) self._node_actions.pop(plugin_id, None) self._api_routes.pop(plugin_id, None) self._frontend_components.pop(plugin_id, None) self._config_entries.pop(plugin_id, None) module_name = self._plugin_modules.pop(plugin_id, None) if module_name and module_name in sys.modules: del sys.modules[module_name]
|
连 sys.modules 里的引用都被清除,确保 Python 解释器不会缓存旧版代码。前端下次拉取组件列表时,卸载的插件就不在了——get_frontend_components_for_menu() 和 get_dashboard_modules_for_enabled_plugins() 都只返回 status='enabled' 的插件。
运行时加载 Vue 组件不是银弹——每次加载都有编译开销,首屏会比静态打包慢一点,SSR 也变得更复杂(需要服务端有 vue3-sfc-loader 的 Node.js 实例)。但在插件系统这种场景下,灵活性比极致性能更重要:管理员装一个插件就能用,不用改代码、不用重新构建、不用等发版。