Flux Architecture
上QQ阅读APP看书,第一时间看更新

Challenges with MV*

MV* is the prevailing architectural pattern of frontend JavaScript applications. We're referring to this as MV* because there's a number of accepted variations on the pattern, each of which have models and views as core concepts. For our discussions in this book, they can all be considered the same style of JavaScript architecture.

MV* didn't gain traction in the development community because it's a terrible set of patterns. No, MV* is popular because it works. Although Flux can be thought of as a sort of MV* replacement, there's no need to go out and tear apart a working application.

There's no such thing as a perfect architecture, and Flux is by no means immune to this fact. The goal of this section isn't to downplay MV* and all the things it does well, but rather to look at some of the MV* weaknesses and see how Flux steps in and improves the situation.

Separation of concerns

One thing MV* is really good at is establishing a clear separation of concerns. That is, a component has one responsibility, while another component is responsible for something else, and so on, all throughout the architecture. Complementary to the separation of concerns principle is the single responsibility principle, which enforces a clear separation of concerns.

Why do we care though? The simple answer is that when we separate responsibilities into different components, different parts of the system are naturally decoupled from one another. This means that we can change one thing without necessarily impacting the other. This is a desired trait of any software system, regardless of the architecture. But, is this really what we get with MV*, and is this actually something we should shoot for?

For example, maybe there's no clear advantage in dividing a feature into five distinct responsibilities. Maybe the decoupling of the feature's behavior doesn't actually achieve anything because we would have to touch all five components every time we want to change something anyway. So rather than help us craft a robust architecture, the separation of concerns principle has amounted to nothing more than needles indirection that hampers productivity. Here's an example of a feature that's broken down into several pieces of focused responsibility:

Separation of concerns

Anytime a developer needs to pull apart a feature so that they can understand how it works, they end up spending more time jumping between source code files. The feature feels fragmented, and there's no obvious advantage to structuring the code like this. Here's a look at the moving parts that make up a feature in a Flux architecture:

Separation of concerns

The Flux feature decomposition leaves us with a feeling of predictability. We've left out the potential ways in which the view itself could be decomposed, but that's because the views are outside Flux. All we care about in terms of our Flux architecture is that the correct information is always passed to our views when state changes occur.

You'll note that the logic and state of a given Flux feature are tightly coupled with one another. This is in contrast to MV*, where we want application logic to be a standalone entity that can operate on any data. The opposite is true with Flux, where we'll find the logic responsible for change state in close proximity to that state. This is an intentional design trait, with the implication being that we don't need to get carried away with separating concerns from one another, and that this activity can sometimes hurt rather than help.

As we'll see in the coming chapters, this tight coupling of data and logic is characteristic of Flux stores. The preceding diagram shows that with complex features, it's much easier to add more logic and more state, because they're always near the surface of the feature, rather than buried in a nested tree of components.

Cascading updates

It's nice when we have a software component that just works. This could mean any number of things, but it's meaning is usually centered around automatically handling things for us. For instance, instead of manually having to invoke this method, followed by that method, and so on, everything is handled by the component for us. Let's take a look at the following illustration:

Cascading updates

When we pass input into a larger component, we can expect that it will do the right thing automatically for us. What's compelling about these types of components is that it means less code for us to maintain. After all, the component knows how to update itself by orchestrating the communication between any subcomponents.

This is where the cascading effect begins. We tell one component to perform some behavior. This, in turn, causes another component to react. We give it some input, which causes another component to react, and so on. Soon, it's very difficult to comprehend what's going on in our code. This is because the things that are taken care of for us are hidden from view. Intentional by design, with unintended consequences.

The previous diagram isn't too bad. Sure, it might get a little difficult to follow depending on how many subcomponents get added to the larger component, but in general, it's a tractable problem. Let's look at a variation of this diagram:

Cascading updates

What just happened? Three more boxes and four more lines just happened, resulting in an explosion of cascading update complexity. The problem is no longer tractable because we simply cannot handle this type of complexity, and most MV* applications that rely on this type of automatic updating have way more than six components. The best we can hope for is that once it works the way we want it to, it keeps working.

This is the naive assumption that we make about automatically updating components—this is something we want to encapsulate. The problem is that this generally isn't true, at least not if we ever plan to maintain the software. Flux sidesteps the problem of cascading updates because only a store can change it's own state, and this is always in response to an action.

Model update responsibilities

In an MV* architecture, state is stored within models. To initialize model state, we could fetch data from the backend API. This is clear enough: we create a new model, then tell that model to go fetch some data. However, MV* doesn't say anything about who is responsible for updating these models. One might think it's the controller component that should have total control over the model, but does this ever happen in practice?

For example, what happens in view event handlers, called in response to user interactivity? If we only allow controllers to update the state of our models, then the view event handler functions should talk directly to the controller in question. The following diagram is a visualization of a controller changing the state of models in different ways:

Model update responsibilities

At first glance, this controller setup makes perfect sense. It acts as a wrapper around the models that store state. It's a safe assumption the anything that wants to mutate any of these models needs to go through the controller. That's its responsibility after all—to control things. Data that comes from the API, events triggered by the user and handled by the view, and other models—these all need to talk to the controller if they want to change the state of the models.

As our controller grows, making sure that model state changes are handled by the controller will produce more and more methods that change the model state. If we step back and look at all of these methods as they accumulate, we'll start to notice a lot of needless indirection. What do we stand to gain by proxying these state changes?

Another reason the controller is a dead-end for trying to establish consistent state changes in MV* is the changes that models can make to themselves. For example, setting one property in a model could end up changing other model properties as a side-effect. Worse, our models could have listeners that respond to state changes, somewhere else in the system (the cascading updates problem).

Flux stores deal with the cascading updates problem by only allowing state changes via actions. This same mechanism solves the MV* challenges discussed here; we don't have to worry about views or other stores directly changing the state of our store.