课程总纲 / 阶段 3 · 插件开发
L12

第一个插件:函数插件三件套

从零写一个能挂载、能热重载的插件,理解每一行的作用。

预计阅读 30 分钟难度 ★★★☆☆本课起全程写代码
准备:本课基于源码运行方式(L08 方式 B),且已完成 pnpm install && pnpm run build。保留本课创建的 scratch-plugin/ 目录——L13、L14 会继续在它上面做实验。

一、创建项目骨架Scaffold

仓库根目录下建练习目录,目标结构:

mkdir -p scratch-plugin/src
scratch-plugin/
├── cordis.yml      # 声明"挂哪些插件"的配置(下一步创建)
└── src/
    └── my-plugin.ts  # 你的插件本体

二、写插件本体The Plugin

创建 scratch-plugin/src/my-plugin.ts

import type { Context } from '@deepseek-ai/cordis'

export const name = 'hello-plugin'

export function apply(ctx: Context) {
  // 走到这里,inject 声明的依赖都已就绪(本例没有依赖)
  console.log('[hello-plugin] plugin loaded!')
}
成员作用可以省吗
name插件名(日志、配置行引用用它)建议总是写
inject依赖声明:数组里列出必需的服务名无依赖可省
apply(ctx)安装函数:一切注册发生在里面不可省,这是插件的全部

三、写配置并挂载Mount It

先在仓库根目录跑 pwd 拿到绝对路径,然后创建 scratch-plugin/cordis.yml路径必须是绝对的):

- insert:
    - id: hello
      name: '/把这里换成pwd输出的绝对路径/scratch-plugin/src/my-plugin.ts'
pnpm dsh web --patch ./scratch-plugin/cordis.yml

启动时终端会打印 [hello-plugin] plugin loaded!——你的第一个插件已在一棵完整的产品插件树里运行。相当于给一辆行驶中的车换了个零件。

为什么路径要绝对?补丁文件只贡献配置,不改变 Loader 解析模块路径的基准目录(profile 目录);相对路径会以错误的基准解析。本地源码插件一律写绝对路径。

四、体验"注册即效果"Feel the Effect

把插件改成会心跳的版本(ctx.effect 的标准用法):

export function apply(ctx: Context) {
  ctx.effect(() => {
    const timer = setInterval(() => {
      console.log('[hello-plugin] heartbeat')
    }, 5000)
    return () => clearInterval(timer)   // 卸载时自动执行
  })
}

任何注册在 ctx 上的东西——监听器、工具、定时器——插件卸载时自动清理,无需手写 removeListener / clearInterval。

五、声明依赖Dependencies

要用别人的服务(比如工具注册表),在 inject 里声明:

import type { Context } from '@deepseek-ai/cordis'

export const name = 'my-tool-plugin'
export const inject = ['tools']          // ← 等到 ctx.tools 就绪才加载我

export function apply(ctx: Context) {
  ctx.tools.register(/* ... */)   // 此处 ctx.tools 必然可用
}

六、三种插件形态Three Forms

// ① 函数形式(本课主角,大多数场景够用)
export const name = 'my-plugin'
export function apply(ctx: Context) { /* ... */ }

// ② 对象形式:同样三件套,收进一个对象
export default {
  name: 'my-plugin',
  inject: ['tools'],
  apply(ctx: Context) { /* ... */ },
}

// ③ 类形式:继承 Service,适合"给别人提供服务"(L16 专讲)
import { Service } from '@deepseek-ai/cordis'
export default class MyService extends Service {
  static inject = ['tools']
  constructor(ctx: Context) { super(ctx, 'myService') }
}
导出规则(有血泪教训):服务包默认导出服务类;函数插件命名导出 name/inject/Config/apply没有默认导出。两种形态混用会让 Loader 静默丢弃函数插件的命名空间(仓库 postmortem/0001 记录了这次事故)。结论现在记,原理 L16 讲。
apply(ctx)
插件安装函数,一切注册都在这里面。
--patch 挂载
命令行叠加本地配置层,最高优先级。
ctx.effect
注册"带撤销器"的效果:返回的函数在卸载时执行。
✏️ 动手练习(必做,后续课程依赖它)
  1. 完成本课全部步骤,确认终端出现 [hello-plugin] plugin loaded!
  2. 加上心跳版本,确认每 5 秒打印。
  3. 实验热重载:保持服务运行,直接修改 my-plugin.ts 里的日志文案并保存,观察新版本被加载、旧注册被自动清理。
  4. inject 加上 ['tools'],重启确认依然加载成功(说明 tools 服务在你的插件之前就绪)。
📝 自测(点击展开答案)
1. 插件的"三件套"是什么?各自作用?
name(插件名)、inject(必需服务声明)、apply(ctx)(安装函数,一切注册发生在这里)。只有 apply 不可省。
2. 本地插件路径为什么必须写绝对路径?
补丁只贡献配置,不改变 Loader 的模块解析基准(profile 目录);相对路径按错误基准解析,找不到模块。
3. setInterval 为什么应该放进 ctx.effect 而不是直接调用?
直接调用的定时器卸载后仍在运行(幽灵定时器);ctx.effect 的撤销函数会在卸载时自动 clearInterval。
4. 函数插件和服务插件(类形式)的导出规则差异?
函数插件:命名导出 name/inject/Config/apply,无默认导出;服务包:默认导出 Service 子类。混用会让 Loader 静默丢弃函数插件的命名空间(postmortem/0001)。
5. apply 里能用 ctx.tools,是谁保证的?什么时候保证的?
inject 声明的必需服务由框架保证:全部就绪后才调用 apply;运行中服务消失时依赖插件自动销毁、服务回来自动重载。
权威出处:user/develop/basic/index.zh.md(本课主源) · cordis-tutorial/01 · docs/postmortem/