mcpField
S
stimulus

stimulus

github.com/thinkoodle开发7191 次安装8 周前更新
代码仓库

关于 stimulus

面向 Rails 开发者的 Stimulus 技能,指导通过 HTML 数据属性为服务端渲染页面添加交互。它强调小型、可组合的控制器、渐进增强,以及由 CSS 管理视觉状态。

主要功能

  1. 01通过数据属性连接 HTML 与控制器
  2. 02使用 targets、values、classes 和 outlets
  3. 03组合小型控制器实现交互
  4. 04管理生命周期并清理资源
  5. 05用 CSS 类控制视觉状态
  6. 06调试连接、目标和动作

适用场景

  1. 01为服务端渲染页面添加切换与弹窗
  2. 02配置表单、搜索或剪贴板交互
  3. 03排查控制器连接或动作未触发
  4. 04组织控制器并实现跨控制器通信

安装

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

$npx skills add https://github.com/thinkoodle/rails-skills --skill stimulus

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

附带文件reference.md

详细介绍

Rails Stimulus 专家

构建小巧、专注的 JavaScript 控制器,通过数据属性将 HTML 与行为连接起来。

理念

核心原则:

  1. HTML 优先 — Stimulus 为服务器渲染的 HTML 增强功能,而不是取代它
  2. 小型控制器 — 一个控制器 = 一种行为。将多个控制器叠加到元素上进行组合
  3. 渐进式增强 — 页面必须在没有 JavaScript 的情况下正常运行;控制器负责添加交互功能
  4. 不在 JS 中渲染 — 控制器操作 DOM 状态(类、属性、可见性),绝不构建 HTML 字符串
  5. 约定优于配置 — 数据属性负责连接所有内容;无需手动绑定事件

Stimulus 的心智模型:

HTML (data attributes)  →  Controller (JS behavior)  →  DOM changes (classes, text, visibility)
     ↑ source of truth        ↑ small & focused            ↑ CSS does the heavy lifting

何时使用此技能

  • 创建新的 Stimulus 控制器
  • 通过数据属性将控制器连接到 HTML
  • 为服务器渲染的视图添加交互功能(切换、模态框、剪贴板、闪现消息、表单)
  • 调试控制器连接问题
  • 组织控制器文件和导入
  • 使用 values、targets、classes、outlets 和生命周期回调
  • 通过 outlets 或自定义事件实现控制器间通信

说明

步骤 1:检查现有控制器

创建新控制器前,务必先搜索现有控制器:

# List all controllers
ls app/javascript/controllers/

# Search for similar behavior
rg "static targets" app/javascript/controllers/
rg "static values" app/javascript/controllers/

# Check if there's a matching controller already
rg "data-controller=\"toggle\"" app/views/

遵循项目现有约定 — 命名、风格、模式。保持一致比追求“理想”更重要。

步骤 2:生成或创建控制器

使用 Rails 生成器:

bin/rails generate stimulus example
# Creates: app/javascript/controllers/example_controller.js
# Updates: app/javascript/controllers/index.js (if not using auto-loading)

或手动创建:

// app/javascript/controllers/example_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  connect() {
  }
}

app/javascript/controllers/ 中的控制器会通过 index.js 自动注册:

// app/javascript/controllers/index.js
import { application } from "./application"
import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
eagerLoadControllersFrom("controllers", application)
步骤 3:定义控制器接口

在静态声明区顶部声明 targets、values、classes 和 outlets:

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["input", "output", "submit"]
  static values = {
    url: String,
    count: { type: Number, default: 0 },
    enabled: Boolean,
    items: Array,
    config: Object
  }
  static classes = ["active", "loading", "hidden"]
  static outlets = ["other-controller"]

  // Lifecycle, then actions
  connect() { }
  disconnect() { }

  // Action methods
  toggle() { }
  submit() { }
}

顺序约定: 静态声明 → 生命周期回调 → 动作 → 私有辅助方法。

步骤 4:使用数据属性连接 HTML

⚠️ 关键:数据属性命名是导致错误的首要原因。

规则:

  • 控制器名称:HTML 中使用 kebab-case(data-controller="my-thing"),文件名使用 snake_case(my_thing_controller.js),HTML 中绝不使用 camelCase
  • 多词 values:HTML 属性中使用 kebab-case,JavaScript 访问时使用 camelCase
  • Target 属性格式:data-{controller}-target="{name}"
  • Value 属性格式:data-{controller}-{name}-value="{val}"
  • Class 属性格式:data-{controller}-{class}-class="{css-class}"
  • Action 格式:data-action="{event}->{controller}#{method}"
<%# Controller with values, targets, and actions %>
<div data-controller="search"
     data-search-url-value="<%= search_path %>"
     data-search-debounce-value="300"
     data-search-active-class="is-active">

  <input data-search-target="input"
         data-action="input->search#query keydown.escape->search#clear"
         type="text"
         placeholder="Search...">

  <div data-search-target="results"></div>
</div>

Agent 常犯的命名错误:

<%# WRONG — camelCase in HTML attribute %>
<div data-controller="myThing">
<div data-myThing-url-value="/api">

<%# CORRECT — kebab-case in HTML %>
<div data-controller="my-thing">
<div data-my-thing-url-value="/api">

<%# WRONG — wrong target format %>
<div data-target="search.input">

<%# CORRECT — namespaced target format %>
<div data-search-target="input">
步骤 5:正确处理 actions

默认事件(可省略事件名称):

元素默认事件
<button>click
<input>input
<select>change
<form>submit
<a>click
<textarea>input
<details>toggle
<%# These are equivalent for a button: %>
<button data-action="click->toggle#flip">Toggle</button>
<button data-action="toggle#flip">Toggle</button>

<%# Multiple actions on one element: %>
<input data-action="input->search#query focus->search#expand blur->search#collapse">

<%# Keyboard modifiers: %>
<input data-action="keydown.enter->form#submit keydown.escape->form#cancel">

<%# Event options: %>
<a data-action="click->nav#toggle:prevent">Link</a>
<button data-action="click->menu#close:stop">Close</button>
<div data-action="scroll->lazy#load:once">Load once</div>

可用的按键修饰符: enter、tab、esc、space、up、down、left、right、home、end,以及任何 KeyboardEvent.key 值。

Action 选项: :prevent(preventDefault)、:stop(stopPropagation)、:once(首次调用后移除)、:self(仅当 event.target 就是元素本身时触发)。

步骤 6:使用生命周期回调
export default class extends Controller {
  // Called once when controller class is first instantiated
  // Use for: one-time setup like binding methods for callbacks
  initialize() {
    this.search = this.search.bind(this)
  }

  // Called every time the controller's element enters the DOM
  // Use for: setting up timers, observers, fetching initial data
  connect() {
    this.interval = setInterval(() => this.poll(), 5000)
  }

  // Called every time the controller's element leaves the DOM
  // Use for: cleanup! Timers, observers, event listeners
  disconnect() {
    clearInterval(this.interval)
  }

  // Target connected/disconnected callbacks
  outputTargetConnected(element) {
    // Called when a new output target appears in DOM
  }

  outputTargetDisconnected(element) {
    // Called when an output target is removed from DOM
  }

  // Value change callbacks
  countValueChanged(newValue, oldValue) {
    this.outputTarget.textContent = newValue
  }
}

⚠️ 始终在 disconnect() 中清理。 Stimulus 控制器会随着 DOM 变化而连接或断开(Turbo 导航、Turbo Streams 等)。未清理的计时器和观察器是最常见的 Stimulus 错误。

步骤 7:保持控制器小巧

每个控制器只负责一种行为。通过叠加控制器进行组合。

<%# Good — two focused controllers %>
<div data-controller="dropdown tooltip">
  <button data-action="click->dropdown#toggle mouseenter->tooltip#show mouseleave->tooltip#hide">
    Options
  </button>
</div>

<%# Bad — one mega-controller doing everything %>
<div data-controller="dropdown-with-tooltip-and-keyboard-nav">

如果控制器超过约 80 行,它很可能承担了过多职责。 将其拆分。

步骤 8:使用 CSS 表示视觉状态

控制器切换类。CSS 负责渲染。

// Good — controller manages state
toggle() {
  this.element.classList.toggle(this.activeClass)
}

// Bad — controller manages appearance
toggle() {
  this.element.style.display = this.element.style.display === "none" ? "block" : "none"
  this.element.style.opacity = "1"
  this.element.style.transform = "translateY(0)"
}
/* CSS handles all visual transitions */
.dropdown { display: none; }
.dropdown.is-active { display: block; }
步骤 9:使用 Outlets 进行跨控制器通信

Outlets 可让一个控制器引用另一个控制器并调用其方法:

// tabs_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static outlets = ["panel"]

  select(event) {
    const index = event.currentTarget.dataset.index
    this.panelOutlets.forEach((panel, i) => {
      panel.toggle(i === parseInt(index))
    })
  }
}
<div data-controller="tabs" data-tabs-panel-outlet=".tab-panel">
  <button data-action="click->tabs#select" data-index="0">Tab 1</button>
  <button data-action="click->tabs#select" data-index="1">Tab 2</button>

  <div class="tab-panel" data-controller="panel">Content 1</div>
  <div class="tab-panel" data-controller="panel">Content 2</div>
</div>

**Outlet 命名:**在 data-{controller}-{outlet-name}-outlet 属性中使用 kebab-case 控制器名称。Outlet 的值是一个 CSS 选择器,用于匹配带有目标控制器的元素。

替代方案:自定义事件 —— 当 Outlet 耦合过紧时,可使用这种更松散的方式:

// Dispatching controller
this.dispatch("selected", { detail: { index: 0 } })

// Listening in HTML
<div data-action="tabs:selected->panel#activate">
步骤 10:调试
// Enable debug mode in browser console
Stimulus.debug = true
// Shows: connect/disconnect events, action dispatches, value changes

// Add logging in connect() for troubleshooting
connect() {
  console.log(`${this.identifier} connected`, this.element)
  console.log("targets:", this.outputTargets)
  console.log("values:", this.urlValue, this.countValue)
}

常见问题:

  1. 控制器未连接 → 检查:data-controller 中是否有拼写错误、文件命名是否正确(snake_case_controller.js)、控制器是否已在 index.js 中注册
  2. 未找到 Target → 检查:Target 元素是否位于控制器元素内部,data-{controller}-target 格式是否正确
  3. Action 未触发 → 检查:data-action 格式是否为 event->controller#method,方法是否存在且没有拼写错误
  4. Value 未更新 → 检查:data-{controller}-{name}-value 格式是否正确,值类型是否与静态声明匹配
  5. 控制器意外断开 → Turbo 导航替换了 DOM。确保控制器元素保持不变或能正确重新连接。

快速参考

访问 Targets
this.outputTarget          // First matching target (throws if missing)
this.outputTargets         // Array of all matching targets
this.hasOutputTarget       // Boolean — does at least one exist?
访问 Values
this.urlValue              // Get
this.urlValue = "/new"     // Set (triggers valueChanged callback)
this.hasUrlValue           // Boolean — was it specified in HTML?
访问 Classes
this.activeClass           // Single class string, e.g. "is-active"
this.activeClasses         // Array of classes
this.hasActiveClass        // Boolean
访问 Outlets
this.panelOutlet           // First matching outlet controller
this.panelOutlets          // Array of all matching outlet controllers
this.hasPanelOutlet        // Boolean
this.panelOutletElement    // The DOM element of the first outlet
this.panelOutletElements   // Array of DOM elements
Value 类型
类型HTML 示例JS 默认值
Stringdata-x-name-value="hello"""
Numberdata-x-count-value="5"0
Booleandata-x-open-value="true"false
Arraydata-x-items-value='["a","b"]'[]
Objectdata-x-config-value='{"k":"v"}'{}
文件组织
app/javascript/
├── application.js                    # Entry point, imports controllers
├── controllers/
│   ├── application.js                # Base controller (extend this)
│   ├── index.js                      # Auto-loader registration
│   ├── clipboard_controller.js       # Simple, focused controllers
│   ├── dropdown_controller.js
│   ├── flash_controller.js
│   ├── modal_controller.js
│   ├── toggle_controller.js
│   └── form_validation_controller.js

命名:{behavior}_controller.js —— 按其行为命名,而不是按其用途命名。

  • ✅ toggle_controller.js、clipboard_controller.js、auto_submit_controller.js
  • ❌ sidebar_controller.js、header_controller.js、user_form_controller.js

常见模式

完整实现请参见 reference.md:

  • 带视觉反馈的剪贴板复制
  • 自动消失的 Flash 消息
  • 模态对话框(使用 <dialog>)
  • 切换/展开收起
  • 表单验证
  • 防抖搜索
  • 字符计数器
  • 自动提交表单
  • 嵌套/命名空间控制器

应避免的反模式

  1. 大型控制器 —— 如果超过 80 行,请将其拆分为可组合的小块
  2. 在 JS 中渲染 HTML —— 使用 Turbo Streams 处理动态内容;Stimulus 只负责切换状态
  3. 直接操作样式 —— 切换类,让 CSS 处理外观
  4. 忘记在断开连接时清理 —— 在 connect() 中使用的每个 setInterval、addEventListener、MutationObserver 都需要在 disconnect() 中清理
  5. 在 HTML 属性中使用 camelCase —— 始终使用 kebab-case:data-my-thing-url-value,而不是 data-myThing-url-value
  6. 访问控制器元素之外的内容 —— 使用 outlets 或事件进行跨控制器通信,不要在控制器内部使用 document.querySelector
  7. 在控制器中编写业务逻辑 —— 保持控制器精简;复杂逻辑应放在服务器端
  8. 未使用 values 配置 —— 不要对 URL、时长或阈值进行硬编码;使用 values,以便通过 HTML 配置行为

评论

支持 Markdown