JavaScript stimulus.js 初体验

martin91 · 2022年07月31日 · 最后由 heroyct 回复于 2022年09月10日 · 1629 次阅读

stimulus.js 框架是一个轻量的 JavaScript 框架,由大名鼎鼎的 Basecamp 公司开发,也就是 Ruby on Rails 框架核心开发团队所在的公司。老早就听说了 stimulus.js 框架,但是没有实际使用过。最近刚好在自己的一个小项目中有了实践的机会,有了一些心得体会,总结分享一下。

提醒:如果想快速体验 stimulus.js 做出来的 demo,可以看看这个 todomvc-stimulus

一个克制的前端 JavaScript 框架

谈起对 stimulus.js 框架总的印象,我觉得这是一个非常克制的前端 JavaScript 框架。它聚焦于在 HTML 元素与 JavaScript 对象的绑定这件事情上,并且这种绑定是单向的,不是前端开发中早已非常普遍的双向绑定。除此之外,它没有提供其他额外的功能。

由于它的克制,轻量是它必然而然的第一个优点。其次,配合其所设计的 controller 的概念,可以实现交互逻辑里状态的隔离与解耦。最后,它在 controller 代码的组织上,也让熟悉 Rails 开发的人感到亲切:约定大于配置。每个 controller 的定义,都需要按照约定,一个 controller 对应一个文件,放在 controllers 目录下,且文件名与 controller 的名字一致。

Stimulus.js 的轻量

Stimulus.js 的核心概念非常少,想要上手 stimulus.js 框架的使用,只有 4 个核心概念是需要了解的。

Controllers

Controllers 是声明了诸如 data-controller="todos" 这样的 data 属性的 HTML 元素所绑定的 JavaScript 对象:

<div data-controller="todos"></div>

stimulus.js 会自动为所有此类元素实例化对应的 controller,每个此类元素各自绑定一个实例。以上述例子来说,stimulus.js 会自动查找位于 app/javascript/controllers/todos_controller.js 的文件,并且导入其中导出的默认类,这是一个经典的约定大于配置的做法:

// app/javascript/controllers/todos_controller.js

import { Controller } from 'stimulus';
export default class extends Controller {
  connect() {
  }
}

当然,如果不想使用或者无法使用约定的形式,也可以通过 stimulus.js 提供的函数进行 controller 的显式注册:

application.register("todos", TodosController)

这类元素以及其子孙元素,都是元素绑定的 controller 的可见范围。也就是说,在 stimulus.js 框架中,controller 的各种操作,只能作用于 controller 绑定的元素以及此元素的子孙元素。这个原则同样适用于嵌套 Controllers 的情况下。

Controllers 之间可以通过事件的方式相互协作,这个会在后面讲 Actions 的时候再补充讲一下。

Targets

Targets 是另一种在 HTML 元素与 JavaScript 对象之间实现绑定的方法,但是它作用于具体的 Controller 之下:

<div data-controller="todos">
    <!-- ... -->

    <button data-todos-target="addBtn">Add</button>
</div>

声明 target 的规则是 data-<controller>-target=<target-name>,相应的,需要在 controller 声明绑定的对象:

// app/javascript/controllers/todos_controller.js

import { Controller } from 'stimulus';
export default class extends Controller {
  static target = ["addBtn"]

完成了 target 的绑定之后,就可以按照 stimulus.js 对 targets 的约定,在 controllers 方法中使用类似 this.addBtnTarget 或者 this.addBtnTargets (针对有多个 HTML 元素绑定了同一个 Target 的情况)来访问这些绑定的 HTML 元素了。

Controllers 和 Targets 的生命周期回调

上面说的 Controllers 和 Targets,都是 HTML 元素和 JavaScript 对象之间的绑定功能,因为 HTML 元素随着浏览器的加载以及后续的 DOM 操作,就带来了一个问题,这些对象的生命周期是怎样的?

这几个生命周期的回调函数都与 DOM 的变化紧密相关,一般来说看这几个条件:

  • 元素是否存在?
  • 绑定的标识是否存在元素的属性列表中,比如 data-controller 或者 data-<controller>-target

当条件从部分或者全部不满足变为满足时,则 connect 类型的回调函数被调用;相反,如果由于 DOM 的一些操作导致不再满足全部条件时,disconnect 类事件的回调函数被调用。

controllers 可以在 connect() 回调中定义初始化的工作,比如 controller 的一些状态的初始化,相应的,[name]TargetConnected 也可以用于某个 target 的初始化。

Actions

Actions 是 stimulus.js 中的事件回调机制,类似 HTML 中 onclickonchange 一类的语法。

<div data-controller="todos">
    <!-- ... -->

    <button data-todos-target="addBtn" data-action="click->todos#add">Add</button>
</div>

Actions 支持多个事件回调声明,这样同时也方便了实现 Controllers 之间的协作:

<div data-controller="todos submitter">  <!-- 注意,这里使用了多个 controller 绑定 -->
    <!-- ... -->

    <button data-todos-target="addBtn" data-action="click->todos#add todos:added->submitter#submit">Add</button>
</div>
// app/javascript/controllers/todos_controller.js
import { Controller } from 'stimulus';
export default class extends Controller {
  add() {
    // do something to maintain the status of todos controller
    this.dispatch("added", {detail: {todos: [xxxx]}}}})
  }
}

// app/javascript/controllers/submitter_controller.js
import { Controller } from 'stimulus';
export default class extends Controller {
  submit(event) {
    const todos = event.detail.todos;   // extract event data
    // do something else
  }
}

以这个例子来说,程序执行的流程是这样的:

  1. 用户点击 Add 按钮后,按钮触发的 click 事件触发了对 todos controller 的 added 的方法的回调;
  2. added 方法执行完自身的核心逻辑后,通过调用 this.dispatch 方法触发 todos:added 事件,注意这里的 todos: 前缀是框架自动加上的;
  3. todos:added 事件的产生,触发了对 submitter controller 的 submit 方法的回调。

就这样,通过抽象出不同的 controllers,实现逻辑的分离和解耦,再通过事件机制,将逻辑实现拼装和编排。

理解了上面这 4 个核心概念,就足以使用 stimulus.js 开发出一个交互相对简单的前端逻辑了。当然,stimulus.js 还有其他几个概念,但是在我看来只是一些锦上添花的功能,这里就没必要赘述了。

也谈谈 stimulus.js 不适合的场景

尽管是一个小项目,但是在使用 stimulus.js 的过程中,也遇到了一些觉得比较繁琐的问题,这些问题体现在:

  1. 缺乏 DOM 操作的封装:因为 stimulus.js 只提供 HTML 元素与 JavaScript 对象之间的绑定,并没有提供对 DOM 操作的封装,所以在需要操作 DOM 的时候,就会经常需要直接使用原生 DOM 对象的操作,比如 Element.classList.add() 一类,如果是在早期的浏览器中,还需要担心兼容性问题等,但是好在现在的浏览器兼容性问题已经少了很多,这倒不是太大的问题;
  2. 缺乏前端渲染的支持:因为 stimulus.js 中的绑定并非双向绑定,在一些需要根据 JavaScript 对象渲染不同页面内容或者视觉效果的情况下,如果不借助其他框架的支持,就只能编写各种字符串插值,以及 Element.innerHTML = xxxx 的代码,同样效率比较低。

所以,总结来说,如果你的前端页面是一个重交互的页面,可能只使用 stimulus.js 并不是一个明智之选。以我自己来选择的话,如果是一些内容类的轻交互场景,比如博客或者论坛,一般需要交互的就是评论区,简单的文本输入以及追加展示等,我觉得用 stimulus.js 会比较舒服,轻量,又是原汁原味的 DOM;但是其他情况下,我可能会直接上 vue.js 之类功能更全面的框架,最大程度减少在页面与逻辑之间状态同步的代码。

使用 stimulus.js 踩过的坑

  1. 在 Controllers scope 之外的 action 无法回调到 Controller 的方法
    这个问题最开始排查了一些时间,一直没想明白为什么,后来才顿悟,原来是因为踩了 Controller scope 的坑。因为我的 action 声明需要回调的 controller 在当前 DOM 中不在可见范围,于是触发回调失败。
  2. 先于 controller 初始化前触发的 action 无法回调到 Controller 的方法 这个问题是因为我在代码中声明了一个 action,并且在 controller 中也执行了 dispatch,但是此时因为目标的 controller 还没有初始化,导致看似代码没有任何语法或者使用错误,但是 action 无可能触发回调成功。

相关资料链接

import { Controller } from 'stimulus';

很老的代码了

感谢用了我的 todo-mvc 例子,这个代码比较旧,也刚好展示了 stimulus 的弱点,就是没有包含前端渲染。

这是它设计的定位决定的,stimulus 适合那些后端渲染已经解决大部分问题,只是要添加一点交互的场景。todo-mvc 是一个全前端渲染的例子,所以用 stimulus 要进行很多 dom 操作,变得很繁琐。

需要前端渲染的组件,我推荐看一下 https://lit.dev/ ,它的代码看上去跟 stimulus 很像,但是多了前端模版和数据绑定。而且是基于 web component 通用性更好,不像 vue 和 react 基本上一引入就引入了全家桶。

但另一方面,stimulus 对后端渲染是最友好的,可以用后端的方式调整 html 结构和样式。

我还有一些 stimulus 实践例子可以看这里 https://geeknote.net/Rei/collections/32

hellonunam 回复

:D 感谢,太久没有接触 Rails 的实际开发,果然还是落后了,哈哈!

Rei 回复

赞,我也是轻量党,对全家桶有莫名的抗拒。 使用 todo-mvc 也是巧合,本来在官方 todo-mvc 仓库中搜寻,但是没有找到 stimulus.js 的例子,本来已经 clone 了想自己撸一个了,但是转念又 google 了下,刚好是你的,就引用了。快速看了你的代码,对我其实是有额外的收获的,就是确证了我说的 stimulus.js 不适合的一些场景是理解正确的。 😄

轻量用 stimulus 不行就用一下 alpine 就可以了

hellonunam 回复

不错,看起来挺精悍

stimulus.js 的复用真的很好,它可以把所有的状态都放在 html 上,所以抽象出来一个 rails 的 partial view 就可以导出复用了。

有个 typo static 打成 stitic 了 ^-^

rc_plan 回复

感谢,已更正。

我说两句: 优点

  1. 兼容大多数 js 框架或库,比如在里面直接用 jquery 来查找元素
  2. 把原先底部加载 js 或者写 js 的平滑内容移植到里面
  3. css 风格的 js 写法很亲切

缺点:

  1. 按文档获取元素,传值有点麻烦
  2. js 量大的话文件组织有点庞大,不太适合中度以上的前端交互

最大的问题还是生态不好,用起来轮子少,很多组件都得自己撸,很费时间。去 NPM 上搜到很多组件都是 react/vue 的,就是很羡慕。

mfb777 回复

兼容其他轮子

mfb777 回复

所以才有选型的问题,适合的场景用适合的工具,不也是挺好的吗?

新项目试了下 turbo + stimulus,感觉还不错。复杂的组件在 Controller 里面 render react。

heroyct 回复

Render react 时候,有什么好办法能把 Stimulus 的 value 和 React 的 state 关联起来吗?由 stimulus 引入 react 这种方式管理组件状态时候会遇到很多麻烦。

微信内置浏览器(ios)跑不动,safari 可以

mfb777 回复

确实比较麻烦,我是把 react 的 state bind 到 DOM 的 data 属性里面,再从 DOM 的属性读取。
交互度高的页面目前是整个页面全部写成 react。

需要 登录 后方可回复, 如果你还没有账号请 注册新账号