第一次点击按钮有一个奇怪的行为

First click on button has an odd behaviour

我正在使用

开发待办事项列表应用程序

当我使用像下面代码这样的简单模型时,一切都按预期工作。

class TodoModel() {
  private object State {
    var todos = Seq.empty[Todo]

    def mod(f: Seq[Todo] => Seq[Todo]): Callback = {
      val newTodos = f(todos)
      Callback(todos = newTodos)
    }
  }

  def add(t: Todo): Callback = State.mod(_ :+ t)
  def todos: Seq[Todo] = State.todos
}

一旦我使用来自猫的免费单子,我就会有一个奇怪的行为。第一次点击总是插入两个待办事项条目。之后的每次点击都按预期工作。见下图。

这里有什么问题?

import cats.free.Free
import cats.free.Free.liftF
import japgolly.scalajs.react._
import japgolly.scalajs.react.vdom.html_<^._
import org.scalajs.dom

case class Todo(text: String)

sealed trait TodoModelOp[A]
case class Add(todo: Todo) extends TodoModelOp[Unit]
case class Todos() extends TodoModelOp[Seq[Todo]]

object FreeTodoModelOps {
  // type alias for lifted TodoModelOp
  type TodoModelOpF[A] = Free[TodoModelOp, A]

  def add(Todo: Todo): TodoModelOpF[Unit] = liftF[TodoModelOp, Unit](Add(Todo))
  def todos: TodoModelOpF[Seq[Todo]] = liftF[TodoModelOp, Seq[Todo]](Todos())
}

object StateInterpreter {
  import cats.arrow.FunctionK
  import cats.{ Id, ~> }

  val interpet: TodoModelOp ~> Id = new (TodoModelOp ~> Id) {
    val todos = scala.collection.mutable.ArrayBuffer.empty[Todo]

    def apply[A](fa: TodoModelOp[A]): Id[A] = fa match {
      case Add(todo) => todos += todo; ()
      case Todos() => todos.toSeq
    }
  }

}

class TodoModel() {
  import cats.instances.list._
  import cats.syntax.traverse._
  import FreeTodoModelOps._

  def add(t: Todo): Callback = {
    def program: TodoModelOpF[Unit] = for {
      _ <- FreeTodoModelOps.add(t)
    } yield ()

    Callback(program.foldMap(StateInterpreter.interpet))
  }

  def todos: Seq[Todo] = {
    def program: TodoModelOpF[Seq[Todo]] = for {
      n <- FreeTodoModelOps.todos
    } yield n

    program.foldMap(StateInterpreter.interpet)
  }
}

object TodoPage {

  case class Props(model: TodoModel)

  case class State(todos: Seq[Todo])

  class Backend($: BackendScope[Props, State]) {
    val t = Todo("a new todo")

    def onSubmit(e: ReactEventFromInput) =
      e.preventDefaultCB >>
        $.modState(s => State(s.todos :+ t)) >>
        $.props.flatMap(P => P.model.add(t))

    def render(S: State) =
      <.div(
        <.form(
          ^.onSubmit ==> onSubmit,
          <.button("Add #", S.todos.length + 1)),
        <.ul(S.todos.map(t => <.li(t.text)): _*))

  }

  val component = ScalaComponent.builder[Props]("Todo")
    .initialStateFromProps(p => State(p.model.todos))
    .renderBackend[Backend]
    .build

  def apply(model: TodoModel) = component(Props(model))
}

object Test {
  val model = new TodoModel()

  def main(args: Array[String]): Unit = {
    TodoPage.apply(model).renderIntoDOM(dom.document.getElementById("mount-node"))
  }
}

空,没有点击按钮

首先点击按钮

第二次点击按钮

在您的第一个片段中有一个错误:

这里有一个变量 todos(非纯),您在纯上下文中访问它:

def mod(f: Seq[Todo] => Seq[Todo]): Callback = {
  val newTodos = f(todos)
  Callback(todos = newTodos)

杂质应在 Callback 中。即使在回调之外读取变量也是不安全的,所以它应该是:

def mod(f: Seq[Todo] => Seq[Todo]): Callback =
  Callback(todos = f(todos))

(请参阅 scalajs-react 的 Ref.scala 安全使用变量的示例。)

其次,关于您的较大代码段,scalajs-react 对 FP 非常友好,但这是非常不合常规的尝试使用它的方式,并且存在一些重大问题:

  • StateInterpreter.interpet 不是 referentially-transparent;这背后有共享的全球状态。未通过 FP 测试。不再是合法的自然转变。
  • 您正在分别跟踪两组相同的状态:组件状态和 TodoModel 中的状态(不纯,未通过 FP 测试)。这种方法不仅是多余的,而且冒着两个状态得到 out-of-sync 的风险,而且还降低了组件的可重用性;假设您决定在同一屏幕上为相同的数据绘制两次——它们将不同步。最好保持组件无状态和纯净。
  • 如果要将自由结构转换为组件效果,最好将其转换为状态 monad,请参见 here 示例。

您学习免费的 monad 和 scalajs-react 真是太棒了。 FP 将使您的整个程序真正非常容易推理并防止行为中出现令人困惑的意外,但您必须不偷工减料并确保保持所有代码的纯净。 任何 杂质都会使整个堆栈一直到入口点不纯,并从这些层中删除那些可靠的 FP 属性。我建议使用以上几点作为起点使所有内容尽可能纯净,然后我认为您会发现错误消失了,或者至少很容易检测到。干杯