mcpField
S
stimulus-coder

stimulus-coder

github.com/majesticlabs-dev开发50179 次安装7 周前更新
代码仓库

关于 stimulus-coder

stimulus-coder 是面向使用 Stimulus.js 和 Hotwire 构建交互界面的开发者的技能,提供编写可维护控制器的模式。内容强调将状态放在 HTML 中,并通过 Targets、Values、Actions 和生命周期管理组织行为。

主要功能

  1. 01用 Targets 引用界面元素
  2. 02通过 Values 在 HTML 管理状态
  3. 03以 Actions 声明事件处理
  4. 04用事件和 Outlets 协调控制器
  5. 05配对清理 connect 与 disconnect

适用场景

  1. 01实现可复用的 Stimulus 控制器
  2. 02为控制器添加可配置的 HTML 属性
  3. 03协调多个控制器的交互
  4. 04处理 Turbo 导航中的资源清理

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/majesticlabs-dev/majestic-marketplace --skill stimulus-coder

安装后在 Claude Code 中输入/stimulus-coder确认已加载。

附带文件references/architecture-patterns.mdreferences/lifecycle-and-events.md

详细介绍

Stimulus 编码

受众: 使用 Stimulus.js 和 Hotwire 构建交互式 UI 的开发者。

目标: 编写易于维护的 Stimulus 控制器,让状态保存在 HTML 中,由控制器添加行为。

核心概念

  • 控制器 为 HTML 元素附加行为
  • 动作 响应 DOM 事件
  • 目标 引用重要元素
  • 值 通过数据属性管理状态

控制器设计原则

保持控制器小巧且可复用
// Good: Generic, reusable controller
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["content"]
  static values = { open: Boolean }

  toggle() { this.openValue = !this.openValue }

  openValueChanged() {
    this.contentTarget.classList.toggle("hidden", !this.openValue)
  }
}
使用数据属性进行配置
export default class extends Controller {
  static values = {
    delay: { type: Number, default: 300 },
    event: { type: String, default: "input" }
  }

  connect() {
    this.element.addEventListener(this.eventValue, this.submit.bind(this))
  }

  submit() {
    clearTimeout(this.timeout)
    this.timeout = setTimeout(() => this.element.requestSubmit(), this.delayValue)
  }
}
<%= form_with data: { controller: "auto-submit", auto_submit_delay_value: 500 } %>
组合多个控制器
<div data-controller="toggle clipboard" data-toggle-open-value="false">
  <button data-action="toggle#toggle">Show</button>
  <div data-toggle-target="content" class="hidden">
    <code data-clipboard-target="source">secret-code</code>
    <button data-action="clipboard#copy">Copy</button>
  </div>
</div>

目标与值

使用目标引用元素
export default class extends Controller {
  static targets = ["tab", "panel"]
  static values = { index: { type: Number, default: 0 } }

  select(event) { this.indexValue = this.tabTargets.indexOf(event.currentTarget) }

  indexValueChanged() {
    this.panelTargets.forEach((panel, i) => panel.classList.toggle("hidden", i !== this.indexValue))
    this.tabTargets.forEach((tab, i) => tab.setAttribute("aria-selected", i === this.indexValue))
  }
}

动作处理

<button data-action="click->toggle#toggle">Toggle</button>
<input data-action="input->search#update focus->search#expand">
<button data-action="modal#open" data-modal-id-param="confirm-dialog">Open</button>
<input data-action="keydown.enter->form#submit keydown.escape->form#cancel">
动作参数
open(event) {
  const modalId = event.params.id
  document.getElementById(modalId)?.showModal()
}

常见控制器模式

下拉菜单控制器
export default class extends Controller {
  static targets = ["menu"]
  static values = { open: Boolean }

  toggle() { this.openValue = !this.openValue }

  close(event) {
    if (!this.element.contains(event.target)) this.openValue = false
  }

  openValueChanged() {
    this.menuTarget.classList.toggle("hidden", !this.openValue)
    if (this.openValue) document.addEventListener("click", this.close.bind(this), { once: true })
  }
}
剪贴板控制器
export default class extends Controller {
  static targets = ["source", "button"]
  static values = { successMessage: { type: String, default: "Copied!" } }

  async copy() {
    const text = this.sourceTarget.value || this.sourceTarget.textContent
    await navigator.clipboard.writeText(text)
    this.showSuccess()
  }

  showSuccess() {
    const original = this.buttonTarget.textContent
    this.buttonTarget.textContent = this.successMessageValue
    setTimeout(() => this.buttonTarget.textContent = original, 2000)
  }
}

Turbo 集成

export default class extends Controller {
  connect() {
    document.addEventListener("turbo:before-visit", this.dismiss.bind(this))
    this.timeout = setTimeout(() => this.dismiss(), 5000)
  }

  disconnect() { clearTimeout(this.timeout) }
  dismiss() { this.element.remove() }
}

架构模式

让控制器可配置

将硬编码的值移至数据属性中。切勿在控制器逻辑中嵌入 CSS 类、选择器或阈值。

// Bad: hardcoded
export default class extends Controller {
  toggle() { this.element.classList.toggle("hidden") }
}

// Good: configurable
export default class extends Controller {
  static classes = ["toggle"]
  toggle() { this.element.classList.toggle(this.toggleClass) }
}
优先使用混入,而非深层继承

当行为可以共享,但不代表特化关系时,使用混入。

决策框架:

  • “是一种” → 继承(class extends BaseController)
  • “表现得像” → 混入(在 connect 时应用行为)
  • “拥有一个” → 组合(独立控制器 + outlets)
// Mixin pattern
const Sortable = (controller) => {
  const original = controller.prototype.connect
  controller.prototype.connect = function() {
    if (original) original.call(this)
    this.sortable = new Sortable(this.element, this.sortableOptions)
  }
}
无目标控制器

如果一个控制器混合了元素级和目标级关注点,就将其拆分。对 this.element 操作是一项职责;对目标操作则是另一项职责。

通过自定义事件或 outlets 在拆分后的控制器之间通信。

命名空间属性

如需使用灵活的参数集,而不显式定义每个值:

// Read arbitrary data-chart-* attributes
get chartOptions() {
  return Object.entries(this.element.dataset)
    .filter(([key]) => key.startsWith("chart"))
    .reduce((opts, [key, val]) => {
      opts[key.replace("chart", "").toLowerCase()] = val
      return opts
    }, {})
}

有关应用于 Stimulus 的 SOLID 原则,请参阅 architecture-patterns.md。

控制器通信

根据耦合需求选择模式:

模式耦合程度方向适用场景
自定义事件松散广播(1→多)发送方不知道接收方
Outlets结构化直接(1→1、1→少数)布局中存在已知关系
回调只读请求/响应共享状态但不触发动作
自定义事件(默认首选)
// Sender
this.dispatch("submitted", { detail: { id: this.idValue }, bubbles: true })

// Receiver (in HTML)
// data-action="sender:submitted->receiver#handleSubmit"

规则:

  • 跨控制器事件始终设置 bubbles: true
  • 为事件名称添加命名空间:form:submitted、cart:updated
  • 记录 detail 契约
Outlets(结构化关系)
export default class extends Controller {
  static outlets = ["result"]

  search() {
    const results = this.performSearch()
    this.resultOutlets.forEach(outlet => outlet.update(results))
  }

  resultOutletConnected(outlet) { /* setup */ }
  resultOutletDisconnected(outlet) { /* cleanup */ }
}

生命周期最佳实践

不要过度使用 connect()

connect() 仅用于初始化第三方插件。不要用于设置状态(使用 Values API)或添加事件监听器(使用 data-action)。

// Good: plugin init in connect
connect() {
  this.chart = new Chart(this.canvasTarget, this.chartConfig)
}

disconnect() {
  this.chart.destroy()
  this.chart = null
}
始终配对使用 connect/disconnect

在 connect() 中获取的每项资源,都必须在 disconnect() 中释放。Turbo 导航期间,控制器可能会多次连接和断开连接。

Turbo 缓存清理

防止缓存页面返回时出现“内容被操作后的闪烁”:

connect() {
  document.addEventListener("turbo:before-cache", this.teardown.bind(this))
  this.slider = new Swiper(this.element, this.config)
}

teardown() {
  this.slider?.destroy()
  // Restore original DOM state before caching
}

disconnect() {
  this.teardown()
}

事件监听器管理

保存绑定后的引用

每次调用 .bind() 都会创建一个新函数。保存引用以便正确移除:

connect() {
  this.boundResize = this.resize.bind(this)
  window.addEventListener("resize", this.boundResize, { passive: true })
}

disconnect() {
  window.removeEventListener("resize", this.boundResize)
}
优先使用声明式动作
<%# Good: Stimulus manages lifecycle %>
<div data-controller="search"
     data-action="resize@window->search#layout keydown.escape@window->search#close">

<%# Bad: manual addEventListener in connect() %>

全局事件在 data-action 中使用 @window 或 @document 后缀。

完整模式请参阅 lifecycle-and-events.md。

应用控制器

创建 app/javascript/controllers/application_controller.js,作为共享功能的基类:

import { Controller } from "@hotwired/stimulus"

export default class ApplicationController extends Controller {
  handleError(error, context = {}) {
    console.error(`[${this.identifier}]`, error, context)
    // Sentry.captureException(error, { extra: context })
  }
}

在领域控制器中继承该类:

import ApplicationController from "./application_controller"

export default class extends ApplicationController {
  async save() {
    try {
      await this.persist()
    } catch (error) {
      this.handleError(error, { action: "save", id: this.idValue })
    }
  }
}

规则:

  • 对异步操作和第三方库调用使用 try-catch
  • 切勿吞掉错误——使用 handleError() 记录或报告错误
  • 表单使用 requestSubmit() 而非 submit()——前者会触发表单验证并允许 Turbo 拦截

反模式

反模式问题解决方案
大量创建 DOM违背 Stimulus 理念让服务器渲染 HTML
在 JS 中存储状态导航后状态丢失在 HTML 中使用 Values
控制器过于具体无法复用设计通用行为
手动使用 querySelector脆弱,绕过 Stimulus使用 targets
内联事件处理程序难以维护使用 data-action
connect() 过度承载逻辑臃肿、职责混杂使用 Values 管理状态,使用 data-action 处理事件
控制器间耦合过紧脆弱、难以测试使用自定义事件或 outlets
未在 disconnect 时清理内存泄漏、监听器重复始终配对使用 connect/disconnect
未绑定事件引用无法调用 removeEventListener 移除监听器保存 .bind() 的结果

输出格式

创建 Stimulus 控制器时,请提供:

  1. 控制器 - 完整的 JavaScript 实现
  2. HTML 示例 - 展示用法的示例标记
  3. 配置 - 可用的 values 和 targets
  4. 集成 - 适用时说明它与 Turbo 的协作方式

评论

支持 Markdown