手把手实现一个可扩展的前端插件系统架构

南宫一可 框架 阅读 1,637
赞 14 收藏
二维码
手机扫码查看
反馈

插件系统里动态注册组件,结果组件不更新?

上周在搞一个后台系统的插件架构,想实现插件能动态注入自己的 UI 组件。本来以为就是个简单的 provide/inject 或者全局注册的事儿,结果页面死活不渲染新插件的组件,控制台也没报错,折腾了大半天才找到症结。

手把手实现一个可扩展的前端插件系统架构

先说下我的预期结构:主应用提供一个 PluginManager,插件通过调用 registerComponent(name, component) 注册自己的 Vue 组件,然后主应用里的某个容器组件(比如叫 <PluginSlot name="sidebar" />)会根据插件注册的名字动态渲染对应的组件。听起来挺合理的对吧?

第一轮尝试:直接往全局 components 里塞

我一开始偷懒,想着 Vue 的全局组件注册不是现成的吗?于是写了这么一段:

// pluginManager.js
export const registerComponent = (name, component) => {
  app.component(name, component);
};

然后在插件初始化时调用:registerComponent('MyCustomPanel', MyPanel)。结果呢?页面刷新后组件能出来,但如果是运行时动态加载插件(比如用户点了个“安装插件”按钮),页面根本不会重新渲染,因为 Vue 在编译模板的时候就已经确定了哪些组件是已知的,后面再往 app._context.components 里加东西,已经编译好的 vnode 压根不知道有这回事。

这里我踩了个大坑:以为全局注册是响应式的。其实不是!Vue 3 的模板编译阶段是静态分析的,除非你用动态组件 <component :is="xxx" />,否则后续注册的组件对已存在的模板无效。

转向动态组件 + 响应式存储

意识到问题后,我立刻改用 <component :is> 方案。关键是要让 :is 绑定的值是响应式的,并且插件注册时能触发更新。

于是我建了个响应式存储:

// pluginStore.js
import { reactive } from 'vue';

export const componentRegistry = reactive({});

export const registerComponent = (name, component) => {
  componentRegistry[name] = component;
};

然后在 PluginSlot 组件里这么写:

<template>
  <component :is="componentRegistry[name]" v-if="componentRegistry[name]" />
</template>

<script setup>
import { componentRegistry } from '@/pluginStore';
defineProps({
  name: String
});
</script>

看起来没问题了吧?结果还是不行!插件注册后,PluginSlot 根本没重新渲染。我一度怀疑是不是 reactive 对象新增属性不触发更新?但查了文档,Vue 3 的 reactive 是基于 Proxy 的,新增属性是响应式的啊。

真相:setup() 里没建立依赖关系

后来我打开 Vue DevTools,发现 componentRegistry 确实更新了,但 PluginSlot 组件的渲染函数压根没被触发。突然意识到:在 <script setup> 里直接使用 componentRegistry[name],但 name 是 props,而 componentRegistry 是个全局对象,Vue 的响应式系统可能没正确追踪到这个访问路径。

试了下把逻辑挪到 computed 里:

<template>
  <component :is="resolvedComponent" v-if="resolvedComponent" />
</template>

<script setup>
import { computed } from 'vue';
import { componentRegistry } from '@/pluginStore';

const props = defineProps({
  name: String
});

const resolvedComponent = computed(() => {
  return componentRegistry[props.name];
});
</script>

这次成了!页面终于能动态显示新注册的组件了。原来问题出在:直接在模板里写 componentRegistry[name],虽然语法上合法,但 Vue 的编译器在生成渲染函数时,可能没有为 componentRegistry 这个全局变量建立有效的依赖收集,尤其是在 name 是动态 prop 的情况下。而显式用 computed 包裹后,依赖关系就清晰了——当 props.name 变化,或者 componentRegistry[props.name] 对应的值变化时,都会触发重新计算。

还有一点小瑕疵

不过上线后发现个小问题:如果同一个插槽名字被多个插件注册,后面的会覆盖前面的。这其实符合预期(毕竟 key 是唯一的),但有些场景可能需要支持多组件挂载。暂时先不管,业务上目前一个插槽只允许一个插件占用,真要扩展的话,可以让 registerComponent 支持传数组,或者改用 registerComponents(slotName, [comp1, comp2]) 的形式。反正当前需求够用了,先这样。

完整核心代码贴一下

怕有人需要,把最终能跑通的核心逻辑整理出来:

// pluginStore.js
import { reactive } from 'vue';

export const componentRegistry = reactive({});

export const registerComponent = (name, component) => {
  if (componentRegistry[name]) {
    console.warn(Component ${name} already registered. Overriding.);
  }
  componentRegistry[name] = component;
};

// 可选:提供获取方法
export const getComponent = (name) => componentRegistry[name];
<!-- PluginSlot.vue -->
<template>
  <div class="plugin-slot">
    <component :is="resolvedComponent" v-if="resolvedComponent" />
    <!-- 可以加 loading 或 placeholder -->
  </div>
</template>

<script setup>
import { computed } from 'vue';
import { componentRegistry } from '@/pluginStore';

const props = defineProps({
  name: {
    type: String,
    required: true
  }
});

const resolvedComponent = computed(() => {
  return componentRegistry[props.name];
});
</script>

插件那边怎么用?很简单:

// my-plugin.js
import { registerComponent } from '@/pluginStore';
import MyPanel from './MyPanel.vue';

export default {
  install() {
    registerComponent('sidebar-panel', MyPanel);
  }
};

主应用在加载插件时调用 plugin.install() 就行。

为什么不用 provide/inject?

有人可能会问:为啥不直接 provide 一个 registry,然后 inject 到 PluginSlot 里?其实我也试过,但问题类似——inject 进来的值如果不是响应式的引用,或者访问方式不对,一样会有更新不触发的问题。而且全局状态在这里反而更简单,不需要层层传递。当然,如果你的插件系统层级很深,provide/inject 可能更合适,但记得要用 refreactive 包裹,并确保消费者正确建立依赖。

最后提醒几个坑

  • 别直接在模板里访问全局响应式对象的动态 key,尤其是 key 来自 props 时,尽量用 computed 包一层
  • Vue 3 的响应式对新增属性是支持的,但前提是访问路径要被正确追踪
  • 动态组件的 :is 值必须是组件选项对象或异步组件函数,不能是字符串(除非是全局注册过的)

以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。比如有没有办法做到自动卸载组件?或者支持插槽多组件?我现在这个方案虽然 work,但总觉得还能更优雅点。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论