stimulus-coder

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Stimulus Coder

Stimulus 控制器开发指南

Audience: Developers building interactive UIs with Stimulus.js and Hotwire.
Goal: Write maintainable Stimulus controllers where state lives in HTML and controllers add behavior.
受众: 使用Stimulus.js和Hotwire构建交互式UI的开发者。
目标: 编写可维护的Stimulus控制器,将状态存储在HTML中,由控制器添加交互行为。

Core Concepts

核心概念

  • Controllers attach behavior to HTML elements
  • Actions respond to DOM events
  • Targets reference important elements
  • Values manage state through data attributes
  • 控制器:为HTML元素附加交互行为
  • 事件响应(Actions):处理DOM事件
  • 目标元素(Targets):引用页面中的重要元素
  • 值(Values):通过data属性管理状态

Controller Design Principles

控制器设计原则

Keep Controllers Small and Reusable

保持控制器小巧且可复用

javascript
// 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)
  }
}
javascript
// 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)
  }
}

Use Data Attributes for Configuration

使用Data属性进行配置

javascript
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)
  }
}
erb
<%= form_with data: { controller: "auto-submit", auto_submit_delay_value: 500 } %>
javascript
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)
  }
}
erb
<%= form_with data: { controller: "auto-submit", auto_submit_delay_value: 500 } %>

Compose Multiple Controllers

组合多个控制器

erb
<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>
erb
<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>

Targets and Values

Targets与Values的使用

Targets for Element References

用Targets引用元素

javascript
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))
  }
}
javascript
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))
  }
}

Action Handling

事件响应处理

erb
<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">
erb
<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">

Action Parameters

事件响应参数

javascript
open(event) {
  const modalId = event.params.id
  document.getElementById(modalId)?.showModal()
}
javascript
open(event) {
  const modalId = event.params.id
  document.getElementById(modalId)?.showModal()
}

Common Controller Patterns

常见控制器模式

Dropdown Controller

下拉菜单控制器

javascript
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 })
  }
}
javascript
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 })
  }
}

Clipboard Controller

剪贴板控制器

javascript
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)
  }
}
javascript
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 Integration

Turbo 集成

javascript
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() }
}
javascript
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() }
}

Architecture Patterns

架构模式

Make Controllers Configurable

让控制器可配置

Externalize hardcoded values into data attributes. Never embed CSS classes, selectors, or thresholds in controller logic.
javascript
// 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) }
}
将硬编码的值提取到data属性中。切勿在控制器逻辑中嵌入CSS类、选择器或阈值等内容。
javascript
// 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) }
}

Mixins Over Deep Inheritance

使用Mixin而非深层继承

Use mixins when behavior is shared but doesn't represent specialization.
Decision framework:
  • "is a" → inheritance (class extends BaseController)
  • "acts as" → mixin (apply behavior at connect)
  • "has a" → composition (separate controller + outlets)
javascript
// 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)
  }
}
当需要共享行为但不属于特殊化场景时,使用Mixin模式。
决策框架:
  • "是一种" → 继承(class extends BaseController)
  • "表现为" → Mixin(在connect阶段应用行为)
  • "包含" → 组合(独立控制器 + 关联引用)
javascript
// 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)
  }
}

Targetless Controllers

无Target的控制器

If a controller mixes element-level and target-level concerns, split it. Controller acting on
this.element
is one responsibility; acting on targets is another.
Communicate between split controllers via custom events or outlets.
如果一个控制器同时处理元素级和目标元素级的逻辑,应将其拆分。仅作用于
this.element
的控制器是一个职责;作用于targets的是另一个职责。
拆分后的控制器之间可通过自定义事件或关联引用(outlets)进行通信。

Namespaced Attributes

命名空间属性

For flexible parameter sets without explicitly defining each value:
javascript
// 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
    }, {})
}
See architecture-patterns.md for SOLID principles applied to Stimulus.
如需灵活的参数集而无需显式定义每个值时:
javascript
// 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
    }, {})
}
更多将SOLID原则应用于Stimulus的内容,请参考 architecture-patterns.md

Controller Communication

控制器间通信

Choose pattern based on coupling needs:
PatternCouplingDirectionUse When
Custom eventsLooseBroadcast (1→many)Sender doesn't know receivers
OutletsStructuredDirect (1→1, 1→few)Known relationships in layout
CallbacksRead-onlyRequest/responseSharing state without triggering actions
根据耦合需求选择合适的模式:
模式耦合度方向使用场景
自定义事件松散广播(一对多)发送方无需知道接收方的情况
关联引用(Outlets)结构化直接(一对一、一对少)布局中存在明确关系的情况
回调函数只读请求/响应共享状态但无需触发事件的情况

Custom Events (Preferred Default)

自定义事件(推荐默认方式)

javascript
// Sender
this.dispatch("submitted", { detail: { id: this.idValue }, bubbles: true })

// Receiver (in HTML)
// data-action="sender:submitted->receiver#handleSubmit"
Rules:
  • Always set
    bubbles: true
    for cross-controller events
  • Namespace event names:
    form:submitted
    ,
    cart:updated
  • Document the
    detail
    contract
javascript
// 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 (Structured Relationships)

关联引用(Outlets)(结构化关系)

javascript
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 */ }
}
javascript
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 */ }
}

Lifecycle Best Practices

生命周期最佳实践

Don't Overuse
connect()

避免过度使用
connect()

connect()
is for third-party plugin initialization only. Not for state setup (use Values API) or event listeners (use
data-action
).
javascript
// Good: plugin init in connect
connect() {
  this.chart = new Chart(this.canvasTarget, this.chartConfig)
}

disconnect() {
  this.chart.destroy()
  this.chart = null
}
connect()
仅用于第三方插件初始化。状态初始化应使用Values API,事件监听应使用
data-action
javascript
// Good: plugin init in connect
connect() {
  this.chart = new Chart(this.canvasTarget, this.chartConfig)
}

disconnect() {
  this.chart.destroy()
  this.chart = null
}

Always Pair connect/disconnect

始终配对connect与disconnect

Every resource acquired in
connect()
must be released in
disconnect()
. Controllers can connect/disconnect multiple times during Turbo navigation.
connect()
中获取的所有资源,必须在
disconnect()
中释放。在Turbo导航过程中,控制器可能会多次连接和断开。

Turbo Cache Teardown

Turbo 缓存清理

Prevent "flash of manipulated content" when cached pages return:
javascript
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()
}
避免缓存页面恢复时出现"已修改内容闪现"的问题:
javascript
connect() {
  document.addEventListener("turbo:before-cache", this.teardown.bind(this))
  this.slider = new Swiper(this.element, this.config)
}

teardown() {
  this.slider?.destroy()
  // 缓存前恢复原始DOM状态
}

disconnect() {
  this.teardown()
}

Event Listener Hygiene

事件监听规范

Store Bound References

存储绑定后的引用

.bind()
creates a new function each call. Store the reference for proper removal:
javascript
connect() {
  this.boundResize = this.resize.bind(this)
  window.addEventListener("resize", this.boundResize, { passive: true })
}

disconnect() {
  window.removeEventListener("resize", this.boundResize)
}
.bind()
每次调用都会创建新函数。需存储绑定后的引用以便正确移除监听:
javascript
connect() {
  this.boundResize = this.resize.bind(this)
  window.addEventListener("resize", this.boundResize, { passive: true })
}

disconnect() {
  window.removeEventListener("resize", this.boundResize)
}

Prefer Declarative Actions

优先使用声明式事件响应

erb
<%# Good: Stimulus manages lifecycle %>
<div data-controller="search"
     data-action="resize@window->search#layout keydown.escape@window->search#close">

<%# Bad: manual addEventListener in connect() %>
Global events use
@window
or
@document
suffix in
data-action
.
See lifecycle-and-events.md for complete patterns.
erb
<%# 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

Anti-Patterns

反模式

Anti-PatternProblemSolution
Creating DOM extensivelyFighting Stimulus philosophyLet server render HTML
Storing state in JSState lost on navigationUse Values in HTML
Over-specific controllersNot reusableDesign generic behaviors
Manual querySelectorFragile, bypasses StimulusUse targets
Inline event handlersUnmaintainableUse data-action
Overloading connect()Bloated, mixes concernsValues for state, data-action for events
Tight controller couplingFragile, hard to testCustom events or outlets
Missing disconnect cleanupMemory leaks, duplicate listenersAlways pair connect/disconnect
Unbound event referencesCan't removeEventListenerStore
.bind()
result
反模式问题解决方案
大量创建DOM元素违背Stimulus设计理念由服务器渲染HTML
在JS中存储状态导航时状态丢失使用HTML中的Values存储状态
控制器过于特定无法复用设计通用的交互行为
手动使用querySelector脆弱,绕过Stimulus机制使用Targets
内联事件处理函数难以维护使用data-action
过度使用connect()代码臃肿,职责混合用Values管理状态,data-action处理事件
控制器间强耦合脆弱,难以测试使用自定义事件或outlets
未在disconnect中清理内存泄漏,重复监听始终配对connect与disconnect
未存储绑定的事件引用无法移除事件监听存储
.bind()
的结果

Output Format

输出格式

When creating Stimulus controllers, provide:
  1. Controller - Complete JavaScript implementation
  2. HTML Example - Sample markup showing usage
  3. Configuration - Available values and targets
  4. Integration - How it works with Turbo if applicable
创建Stimulus控制器时,需提供以下内容:
  1. 控制器代码 - 完整的JavaScript实现
  2. HTML示例 - 展示使用方式的示例标记
  3. 配置说明 - 可用的values和targets
  4. 集成说明 - 若涉及Turbo,需说明如何与Turbo集成