关于 stimulus-coder
stimulus-coder 是面向使用 Stimulus.js 和 Hotwire 构建交互界面的开发者的技能,提供编写可维护控制器的模式。内容强调将状态放在 HTML 中,并通过 Targets、Values、Actions 和生命周期管理组织行为。
主要功能
- 01用 Targets 引用界面元素
- 02通过 Values 在 HTML 管理状态
- 03以 Actions 声明事件处理
- 04用事件和 Outlets 协调控制器
- 05配对清理 connect 与 disconnect
适用场景
- 01实现可复用的 Stimulus 控制器
- 02为控制器添加可配置的 HTML 属性
- 03协调多个控制器的交互
- 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 控制器时,请提供:
- 控制器 - 完整的 JavaScript 实现
- HTML 示例 - 展示用法的示例标记
- 配置 - 可用的 values 和 targets
- 集成 - 适用时说明它与 Turbo 的协作方式