把 Vue 3 组件在运行时塞进页面:插件系统的前端注入术

一般的 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_path
  • widget:小的挂件,可以嵌入到其他页面
  • 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
# 插件 A 注册 API
self.register_plugin_api('send_sms', handler)

# 插件 B 调用插件 A 的 API
result = self.call_plugin_api('plugin_a_id', 'send_sms', phone, message)

Hook 触发——一个插件触发事件,另一个插件响应:

1
2
3
4
5
# 插件 A 注册 Hook
self.register_plugin_hook('on_order_created', handler)

# 插件 B 触发插件 A 的 Hook
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 校验和沙箱做了代码层面的安全限制(另一篇文章讲过),前端这边也有防护:

  1. 入口文件路径校验——绝对路径、路径穿越都被拒绝
  2. 扩展名白名单——只允许 .vue.js.ts
  3. 权限控制——每个组件都可以声明 required_permission,前端拿到 API 返回的组件列表后,只渲染当前用户有权限访问的组件
  4. 样式隔离——插件组件的样式影响范围需要控制,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)
# 连 Python 模块都从 sys.modules 里删掉
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 实例)。但在插件系统这种场景下,灵活性比极致性能更重要:管理员装一个插件就能用,不用改代码、不用重新构建、不用等发版。

欢迎关注我的其它发布渠道