避免在简单的增量器中使用状态

4
我试图理解如何使用JavaScript函数式编程技术避免状态问题。我已经掌握了许多基本的fp技术,例如闭包、柯里化等等。但是我不知道如何处理状态。
我想知道一个创建函数式程序的人如何实现以下非常简单的应用:
用户在浏览器中点击一个按钮(jQuery实现可以),屏幕上的值每次点击按钮都会增加1。
如果需要避免状态变异,我们该怎么做?如果必须变更状态,从功能的角度来看,最好的方法是什么?

1
函数式编程的方法不是为了避免状态,而是使状态显式化。在某些抽象层次上,我们甚至无法做到这一点,因为DOM是可变的,用户交互是有状态的。 - Bergi
我理解DOM是可变的,需要一个具有副作用的函数来更新DOM。因此,在这种情况下,存在一些权衡。您如何使状态在此情况下明确? - elemental13
请查看函数响应式编程Elm架构 - Bergi
2个回答

1

下面是如何实现一个简单的计数器应用程序,除了DOM之外,不会修改任何东西。

const h1 = document.querySelector("h1");

const [decrement, reset, increment] = document.querySelectorAll("button");

const render = count => {
    h1.innerHTML = count; // output
    decrement.onclick = event => render(count - 1); // -+
    reset.onclick     = event => render(0);         //  |-- transition functions
    increment.onclick = event => render(count + 1); // -+
};

render(0);
//     ^
//     |
//     +-- initial state
<h1></h1>
<button>-</button>
<button>Reset</button>
<button>+</button>

这是一个Moore机的例子。Moore机是一种有限状态机,由三部分组成:
1. 机器的初始状态。在我们的例子中,初始状态为0. 2. 过渡函数,给定当前状态和一些输入,产生一个新状态。 3. 输出函数,给定当前状态,产生一些输出。
在我们的例子中,我们将转换函数和输出函数合并为单个render函数。这是可能的,因为转换函数和输出函数都需要当前状态。
当提供当前状态时,渲染函数会产生一些输出,以及一个过渡函数,该函数提供一些输入后,会产生一个新状态并更新状态机。
在我们的例子中,我们将转换函数分成多个共享当前状态的转换函数。
我们还可以使用事件委托来提高性能。在下面的示例中,我们仅在整个文档上注册一个单击事件侦听器。当用户单击文档中的任何位置时,我们检查目标元素是否具有onClick方法。如果有,我们将其应用于事件。
接下来,在渲染函数中,我们不再为每个按钮注册单独的onclick侦听器,而是将它们存储为常规方法,命名为onClick(不同的大小写)。这更有效率,因为我们不会注册多个事件侦听器,这会减慢应用程序。

const h1 = document.querySelector("h1");

const [decrement, reset, increment] = document.querySelectorAll("button");

const render = count => {
    h1.innerHTML = count;
    decrement.onClick = event => render(count - 1);
    reset.onClick     = event => render(0);
    increment.onClick = event => render(count + 1);
};

render(0);

document.addEventListener("click", event => {
    if (typeof event.target.onClick === "function") {
        event.target.onClick(event);
    }
});
<h1></h1>
<button>-</button>
<button>Reset</button>
<button>+</button>


我喜欢这个解决方案和对摩尔机的引用。render()函数也让人想起React,所以这是一个很酷的解决方案。虽然这个解决方案在逻辑上并没有技术上改变状态,但它仍然依赖于状态存储在DOM中。这不一定是一件可怕的事情,但这是理想的吗? - elemental13
2
类似于React的相似之处并不肤浅。React本质上模拟了一个Moore机器,尽管它被副作用扭曲了。我不是React的忠实粉丝。Facebook有正确的想法,但他们搞砸了实现。React比jQuery更加函数化,但它并不是真正的函数式。即使是React hooks也不是真正的函数式。如果您想要一个真正的函数式框架来创建Web应用程序,那么看看Elm吧。 - Aadit M Shah
1
@AaditMShah 技术上说,状态存储在变量作用域中,由存储在DOM中的函数封闭,但你不能真正避免这种情况,而且我认为提供的封装比将状态存储在全局作用域中要好。然而,重要的是它不会从可变DOM中读取任何状态。 - Bergi
1
@Hitmands 如果使用事件委托,您甚至不必在每次渲染时重新分配事件监听器。这里是一个例子。 https://jsfiddle.net/aaditmshah/dj8c45zt/。现在,您只有一个事件监听器,并且在每次渲染时更新DOM对象上的属性,这比添加事件监听器要快得多。 - Aadit M Shah
1
@scriptum 将事件委托移动到答案的末尾,并添加了为什么这样做更好的解释。 - Aadit M Shah
显示剩余10条评论

1
正如您所看到的,在非函数式模式下,计数器仅仅是一个有状态的 stateful 对象。它保存状态以便可以根据其 API 进行增量或减量操作。

const createCounter = () => {
  let value = 0;
  
  return {
    get value() {
      return value;
    },
    increment() {
      value = value + 1;
    },
    decrement() {
      value = value - 1;
    },
  };
};


const counter = createCounter();
console.log('initial value', counter.value);

counter.increment();
counter.increment();
console.log('value after two increments', counter.value);


counter.decrement();
console.log('value after one decrement', counter.value);

构建计数器的功能方法是“让消费者提供状态”。函数只知道如何改变它:

const incrementCounter = counter => counter + 1;
const decrementCounter = counter => counter - 1;

const value = 0;
console.log('initial value', value);

const valueAfterTwoIncrements = incrementCounter(
  incrementCounter(value),
);

console.log('value after two increments', valueAfterTwoIncrements);


const valueAfterOneDecrement = decrementCounter(valueAfterTwoIncrements);

console.log('value after one decrement', valueAfterOneDecrement);

这种方法的优点数不胜数,函数是纯的,输出确定性强,因此测试非常容易等。

问答:

  1. "让消费者提供状态": 这些功能(增加/减少)不使用自己的状态,而是将其作为参数并返回其新版本。试着想象一下redux reducer,它们仅嵌入了改变状态的逻辑...但最终状态是作为参数传递的。
  2. "值继续增加/减少的地方": 状态永远不会改变,这被称为不可变性,纯函数总是返回它的新副本,因此如果您想要,您必须将其存储在其他地方。

使用DOM

将视图层与实际业务逻辑层分离

正如您在以下示例中所看到的那样,视图是无法感知数据的,DOM仅用于呈现或触发UI事件,而实际的业务逻辑(当前状态值及如何增加/减少)存储在不同且良好分离的层中。编排层最终用于将这两个层绑定在一起。

/***** View Layer *****/
const IncBtn = ({ dispatch }) => {
  dispatch({ type: 'INC' });
};

const DecBtn = ({ dispatch }) => {
  dispatch({ type: 'DEC' });
};

const Value = ({ getState }) => {
  document.querySelector('#value').value = getState();
};


/***** Business Logic Layer *****/
const counter = (state = 0, { type }) => {
  switch(type) {
    case 'INC':
      return state + 1;
      
    case 'DEC':
      return state - 1;
    
    default:
      return state;
  }
};


/***** Orchestration Layer *****/
const createStore = (reducer) => {
  let state = reducer(undefined, { type: 'INIT' });
  
  return {
    dispatch: (action) => {
      state = reducer(state, action);
    },
    getState: () => state,
  };
}


(() => {
  const store = createStore(counter);
  // first render
  Value(store);
  
  document
    .querySelector('#inc')
    .addEventListener('click', () => {
      IncBtn(store);
      
      Value(store);
    });
  
  document
    .querySelector('#dec')
    .addEventListener('click', () => {
      DecBtn(store);
      
      Value(store);
    });
})();
<button id="inc">Increment</button>
<button id="dec">Decrement</button>
<hr />

<input id="value" readonly disabled/>


嗯,虽然在调用堆栈中存储状态在技术上是正确的,但您没有解决DOM部分,即如何将此方法与DOM连接起来。 - user5536315
如果答案能清楚地展示状态应如何更新,我可以接受不提及DOM方面的内容。但是,“让使用者提供状态”这句话我不太明白。我也没有看到值实际上是如何持续增加的。它可以从0增加到2,但这不会使其从2增加到4。 - elemental13
你的DOM示例不可用,因为你的存储是不纯的。你能否使你的示例在不改变存储状态的情况下工作?提示:这是可能的,而且结果代码更简洁。 - Aadit M Shah
我不太确定你所说的“你的store不纯”的意思是什么,真正需要纯粹的是你的reducer函数......根据定义,store是有状态的,你可以看看redux是如何实现它的。 - Hitmands
Redux是一个不好的例子。它通过actions、reducers和dispatching无谓地使事情变得复杂。考虑你的例子。当用户按下“inc”按钮时,将调用IncBtn函数,该函数将分派一个操作到reducer,后者将增加的值返回给store,后者将更新其内部状态。之后,您必须手动调用Value函数以呈现更新后的状态。这会导致代码膨胀。我能够使用递归在19行代码中完成您在68行代码中完成的工作。所有东西都散落在各处,这使得维护变得困难。 - Aadit M Shah
显示剩余3条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接