Nuclear 插件开发实战:从零创建、加载并验证你的第一个插件
2026/9/13 17:56:48 网站建设 项目流程

Nuclear 插件开发实战:从零创建、加载并验证你的第一个插件

【免费下载链接】nuclearStreaming music player that finds free music for you项目地址: https://gitcode.com/GitHub_Trending/nu/nuclear

本篇以 Nuclear(一个面向免费流媒体音乐的播放器)官方文档 packages/docs/plugins/getting-started.md 为核心骨架,完整走通"创建一个裸插件 → 在应用内加载 → 验证 SDK 全链路"的全过程。读完后你将掌握 Nuclear 插件的目录约定、package.json清单规范、生命周期钩子语义、设置项自动命名空间机制,并能看懂 PluginLoader 背后的清单校验、TS 即时编译与模块沙箱实现。

插件的本质:磁盘上的一个文件夹

官方文档开篇就给出了定义:插件是磁盘上的文件夹,包含一个package.json和入口文件,应用会在运行时加载它,并向你的代码提供@nuclearplayer/plugin-sdk

这句看似简单,但仓库源码印证了完整的运行时链路:

  • 加载入口是 PluginLoader。构造函数接收插件目录路径,load()依次执行:读取并校验package.json清单 → 解析入口文件路径 → 读取(必要时即时编译)插件代码 → 在受控环境中执行代码 → 调用onLoad(api)
  • 启动阶段由 pluginBootstrap.ts 中的hydratePluginsFromRegistry()驱动:它从注册表按installedAt排序,逐个插件创建 loader 并加载,若该插件处于启用状态则再调用enablePlugin
  • 每个插件拿到的api对象由 createPluginAPI.ts 组装——把SettingsQueuePlaybackProvidersStreamingMetadataDashboardEventsShellHttpYtdlpLogger等各域 API 挂到同一个NuclearPluginAPI实例上,且settingsHostloggerHost是按插件 ID 独立创建的(createPluginSettingsHost(pluginId, displayName))。

这意味着:写插件不需要在应用工程内改任何代码,只需按约定组织好文件夹,Nuclear 自己会处理编译、执行和 API 注入。

第一步:创建插件目录与 package.json 清单

官方文档建议在工作区外创建插件目录,例如~/nuclear-plugins/hello-plugin,然后在目录内执行npm init。最终package.json的最小可用形态如下(直接继承自官方文档):

{ "name": "hello-plugin", "version": "0.1.0", "description": "Minimal Nuclear plugin", "author": "Your Name", "main": "index.ts", "nuclear": { "displayName": "Hello Plugin", "categories": ["other"] } }

清单的运行时校验规则

这些字段不是"建议",而是被 Zod schema 强校验的。pluginManifest.ts 中定义了PackageJsonSchema

  • 必填nameversiondescriptionauthor,且都必须是长度 ≥ 1 的字符串。任何一个缺失或为空,readManifest()会抛错Invalid package.json: ...,插件加载直接失败;
  • 可选main(入口文件相对路径);
  • 可选nuclear对象,其内合法键为displayNamecategory(旧版单值分类,源码注释标注将在注册表迁移到categories后移除)、categoriesiconpermissions
  • 顶层和nuclear都采用passthrough()不会因额外键而报错,但collectUnknownNuclearKeys会把nuclear下的未知键收集成警告nuclear contains unknown keys: ...
  • permissions会被去重并排序,出现重复项时追加警告Duplicate permissions removed.

校验通过后,PluginLoader.buildMetadata() 会生成PluginMetadatadisplayName缺省回落到namecategories缺省为空数组,permissions缺省为空数组。这些元数据会原样出现在应用的 Plugins 列表中。

第二步:编写入口文件 index.ts 与生命周期钩子

官方文档给出的最小入口文件如下,注意它演示了设置项注册、读写与订阅这三个 SDK 最基本的动作:

const CATEGORY = "Examples"; module.exports = { async onLoad(api) { await api.Settings.register([ { id: "hello", title: "Hello world", category: CATEGORY, kind: "boolean", default: true } ]); const v = await api.Settings.get("hello"); await api.Settings.set("hello", !v); }, async onEnable(api) { api.Settings.subscribe("hello", () => {}); } };

官方文档特别强调:应用会对 TS 即时编译,不需要额外构建步骤。这一点在 pluginCompiler.ts 中有完整的工程实现,值得了解,因为它直接决定了插件作者能写什么、不能写什么:

  • 编译只在入口文件后缀为.ts/.tsx时发生(isTs判断),纯 JS 入口会被 PluginLoader.readPluginCode() 直接以文本读取,跳过编译;
  • 编译器是esbuild-wasm(运行在 Tauri webview 内),通过globalThis上的单例状态__NUCLEAR_ESBUILD_WASM__保证initialize()每个 JS 上下文只执行一次,以兼容 Vite HMR 反复重执行模块的场景;
  • 入口源码通过stdin喂给 esbuild,所有相对导入由名为tauri-fs的虚拟文件系统插件解析——文件内容全部经 Tauri 的readTextFile读取,不触碰 Node 的 fs;
  • 构建参数为bundle: trueformat: 'cjs'jsx: 'automatic'target: ['es2022'],且external: ['@nuclearplayer/plugin-sdk']——SDK 不打包进插件 bundle,运行时由应用注入
  • 每个入口的编译产物有缓存,但缓存新鲜度判定会重新哈希"上一次构建参与过的所有文件",任何一个被改动都会触发重编译,避免改了utils.ts却拿到旧 bundle 的问题。

插件对象的形状(Plugin shape)

官方文档给出了插件导出对象的类型定义,这与 SDK 中 types.ts 的NuclearPlugin类型一致:

type Plugin = { onLoad?(api: NuclearPluginAPI): void | Promise<void>; onEnable?(api: NuclearPluginAPI): void | Promise<void>; onDisable?(api: NuclearPluginAPI): void | Promise<void>; onUnload?(api: NuclearPluginAPI): void | Promise<void>; };

四个钩子全部可选。结合 pluginBootstrap.ts 与 PluginLoader.load() 的调用时序,可以确认:

  1. 插件被加载(registry hydrate 或手动 Add Plugin)时,onLoad在代码求值后立即执行;
  2. 用户把开关拨到启用时执行onEnable
  3. 用户禁用时执行onDisable,从内存移除前执行onUnload

所以官方文档的加载说明"onLoadruns at import time;onEnableruns when you enable"是有源码依据的:load()if (instance.onLoad && api) { await instance.onLoad(api); },而onEnable由应用 store 的enablePlugin流程触发。

第三步:在 Nuclear 中加载插件

官方文档的加载步骤为:

  1. 打开 Nuclear → Preference → Plugins(左侧边栏);
  2. 点击Add Plugin,选择你的插件文件夹;
  3. 打开开关启用。

从源码看,"Add Plugin" 之后插件信息会写入注册表。pluginRegistry.ts 使用 Tauri 的LazyStore持久化到plugins.jsonREGISTRY_FILE = 'plugins.json'),每条记录以plugins.<id>为键,字段包括:

type PluginRegistryEntry = { id: string; version: string; path: string; // 插件实际所在目录 installationMethod: 'dev' | 'store'; originalPath?: string; enabled: boolean; installedAt: string; lastUpdatedAt: string; warnings?: string[]; };

启动时hydratePluginsFromRegistry()会按installedAt升序遍历(即按安装日期顺序加载,与 packages/docs/plugins/plugin-system.md 中"loaded in the order of installation dates"一致)。另外注意 plugin-system.md 描述的托管安装流程:应用读取插件清单后,会把内容复制到 appdata 下的plugins/<pluginName>/<pluginVersion>目录并从那里加载;pluginBootstrap中的isManagedPath()校验表明启动时只会加载位于受管插件目录(getPluginsDir())下的条目,注册表里指向其他路径(如 dev 插件)的条目目前会被跳过(源码中留有TODO: Support non-managed paths (dev plugins))。加载失败时,错误信息会合并写入注册表的warnings字段而不是让整个启动崩溃。

验证 SDK:设置项如何落地与持久化

官方文档的验证方法:

  • 打开 Settings,找到 "Examples" 分组,应能看到 "Hello world" 开关;
  • 拨动开关。值会被持久化到磁盘,并更新所有订阅者。

这里的机制在 settingsHost.ts 中可以看得很清楚,也是官方文档那条 warning 提示的实现来源:

const normalizeId = (source: SettingSource, id: string): string => { if (source.type === 'plugin') { return `plugin.${source.pluginId}.${id}`; } return `core.${id}`; };

即:设置 ID 会被自动加命名空间。插件里用裸 IDhello,实际存储键为plugin.hello-plugin.hellopluginId取自package.jsonname)。这保证了不同插件的同名设置互不冲突。get/set/subscribe三个操作内部都会先经过normalizeId,所以插件代码中始终只写裸 ID,无需也不能手动拼前缀。

subscribe的实现基于 zustand store 的订阅:每次 store 变化时比较新旧值,值真正发生变化时才调用监听器,并返回一个unsubscribe函数供onDisable/onUnload时清理。而Settings类的公开接口(register/get/set/getGlobal/setGlobal/subscribe/registerWidget)定义在 plugin-sdk 的 api/settings.ts,其中getGlobal/setGlobal面向应用核心设置(core.前缀域),插件一般用不到。

package.json 键位速查(loader 实际读取的字段)

汇总官方文档 "Plugin shape" 小节与源码,loader 实际使用的package.json键如下:

必填说明
name插件唯一 ID,同时是设置命名空间前缀与注册表键
version语义化版本,用于安装目录plugins/<name>/<version>
description一句话描述,展示在插件列表
author作者名
main入口文件路径;缺失时按顺序尝试index.jsindex.tsindex.tsxdist/index.jsdist/index.tsdist/index.tsx(见 PluginLoader.resolveEntryPath())
nuclear.displayNameUI 名称,缺省回落为name
nuclear.categories展示在 Plugins 列表的分类,字符串数组
nuclear.icon图标,当前仅支持{ type: "link", link: "..." }(见 types.ts 的PluginIcon
nuclear.permissions能力声明数组;当前为信息性字段,未知权限只会产生警告

入口文件解析的完整逻辑在resolveEntryPath()中:有main就直接用;没有则按上表候选列表依次尝试readTextFile,全部失败时抛错并明确列出所有尝试过的文件名。测试用例 PluginLoader.test.ts 覆盖了清单校验失败、入口解析失败等分支,可作为行为依据。

插件代码能 require 什么:模块白名单

官方文档没有明说但源码里非常关键的一点:插件不是在全局 require 环境里跑的。PluginLoader.evaluatePlugin() 用new Function('exports', 'module', 'require', code)(...)执行编译产物,并注入一个白名单 require

const ALLOWED_MODULES: Record<string, unknown> = { '@nuclearplayer/plugin-sdk': { NuclearPluginAPI }, '@nuclearplayer/ui': nuclearUI, react: React, 'react/jsx-runtime': jsxRuntime, };

含义有三:

  1. 插件只被允许引用 SDK、UI 组件库和 React 运行时这四个模块,其余一律抛Module <id> not found
  2. 这解释了为什么入口文件里可以直接module.exports = {...}(CJS 形态被保留),也解释了 plugin-sdk README 里"bundle needs to work in a CommonJS environment (module.exportsorexports.default)"的要求;
  3. 求值后会取module.exports.default || module.exports,因此export default(经 esbuild 转成exports.default)和module.exports =两种写法都合法——官方示例用module.exports,SDK 文档示例用export default,两者等价。

开发循环与注意事项

结合官方文档与 plugin-sdk README 的 "Development" 一节,日常循环是:修改插件文件夹 → 在 Nuclear 中重新加载插件(改动后需要 reload 才能生效)→ 观察行为。编译缓存的失效是文件哈希级的,重新加载即触发对参与文件的重检。

几个容易踩的坑,均有源码依据:

  • name/version/description/author任一缺失:插件加载抛Invalid package.json错误,注册表会记录 warning;
  • 忘写main:不会报错,但会产生package.json missing "main"; will attempt fallback resolution...警告,且只有入口恰好命中候选列表(index.*/dist/index.*)才能加载成功;
  • nuclear下写了拼错的键(如display-name):得到nuclear contains unknown keys: display-name警告,键本身被忽略;
  • 设置 ID 手动加前缀(如get("plugin.hello-plugin.hello")):会二次命名空间化导致读取不到值,始终使用裸 ID。

延伸:SDK 的完整能力面

入门示例只碰了api.Settings,但 createPluginAPI.ts 组装的 API 对象覆盖面远超入门场景。按 plugin-sdk README 的域 API 表:

API能力
api.Settings定义、读取、持久化插件设置(本篇验证过)
api.Queue读取和操作播放队列
api.Playback控制播放、音量、随机与循环
api.Events订阅播放器生命周期事件(如曲目结束)
api.Favorites管理用户收藏曲目
api.Playlists创建、更新、删除播放列表
api.Providers注册/注销音频源 provider
api.Streaming解析曲目音频流地址
api.Metadata检索艺术家/专辑/曲目元数据
api.Dashboard提供 Dashboard 内容(热门曲目、新发行等)
api.Discovery从 provider 获取推荐曲目
api.Shell在系统浏览器中打开 URL
api.Http从插件发起 HTTP 请求并绕过 CORS
api.Ytdlpyt-dlp 集成

每个域 API 在仓库内都有对应的宿主实现(packages/player/src/services/下的playbackHost.tsqueueHost.tsprovidersHost.ts等)与文档(如 playback.md、queue.md)。当入门插件验证通过(Settings 中出现 "Examples" 分组、开关值可持久化并可被订阅),即说明从文件夹约定、清单校验、TS 编译、模块沙箱到 API 注入的整条链路已端到端跑通,可以在此基础上按域文档逐步扩展功能。

相关深入文档:插件体系总览、插件市场与发布、publishing.md。

【免费下载链接】nuclearStreaming music player that finds free music for you项目地址: https://gitcode.com/GitHub_Trending/nu/nuclear

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询