It’s the end of 2017, and the discussion about the front-end data layer has been heating up over the past year. TypeScript, Flow, and PropTypes on the data type side, MVC, MVP, and MVVM on the application architecture side, and Redux, MobX, and RxJS on the application state side all have a strong following, but none of them can convince the other side.

On the discussion of technology selection, the author has been holding the attitude of seeking common ground while reserving differences. With so many articles written about these differences, let’s take a step back, look at the common problems these solutions address, and try to come up with some of the simplest solutions.

Let’s take the common MVVM architecture as an example and break down the common pain points of the front-end data layer layer by layer.

Model layer

As the most downstream of the application data link, the Model layer at the front end is quite different from the Model layer at the back end. The core of this is that, compared to the back-end Model, the front-end Model does not serve the purpose of defining data structures, but is more like a container for storing the data returned by the back-end interface.

Under such a premise, RESTful interface has become the industry standard today, if the back-end data has been returned to the front end according to the minimum granularity of data resources, can we directly return the standard of each interface, as our lowest Model? In other words, we don’t seem to have a choice, because the data returned by the interface is the top of the front-end data layer and the starting point for all subsequent data flows.

Now that the Model layer is defined, let’s look at the problems with the Model layer.

The granularity of data resources is too fine

Too fine granularity of data resources usually leads to the following two problems: one is that a single page needs to access multiple interfaces to obtain all the displayed data; the other is that there is a problem of obtaining the data resources asynchronously in sequence.

The common solution to the first problem is to build a Node.js data middle layer for interface consolidation, ultimately exposing the client to page-grained interfaces that are consistent with the client routing.

The advantages and disadvantages of this approach are obvious. The advantage is that each page only needs to access one interface, and the loading speed can be significantly increased in production environments. On the other hand, because the server already has all the data ready, it is easy to do server rendering. However, from the perspective of development efficiency, it is nothing more than a postponement of business complexity, and is only suitable for projects with low application complexity and few page-to-page relationships. After all, the granularity of the page-level ViewModel is too coarse, and since it is an API-level solution, the reusability is almost zero.

For the second problem, I provide a utility function based on the simplest redux-Thunk to link two asynchronous requests.

import isArray from 'lodash/isArray';

function createChainedAsyncAction(firstAction. handlers) {
  if (!isArray(handlers)) {
    throw new Error('[createChainedAsyncAction] handlers should be an array');
  }

  return dispatch = > (
    firstAction(dispatch)
      .then((resultAction) = > {
        for (let i = 0; i < handlers.length; i + = 1) {
          const { status. callback } = handlers[i];
          const expectedStatus = ` _The ${status.toUpperCase(a)}`;

          if (resultAction.type.indexOf(expectedStatus) ! = = -1) {
            return callback(resultAction.payload) (dispatch);
          }
        }

        return resultAction;
      })
  );
}
Copy the code

Based on this, we provide a common business scenario to help you understand. For example, in a website similar to Zhihu, the front end can obtain the user’s answers according to the user ID after obtaining the user’s information.

// src/app/action.js
function getUser(a) {
    return createAsyncAction('APP_GET_USER'. (a) = > (
        api.get('/api/me')
    ));
}

function getAnswers(user) {
    return createAsyncAction('APP_GET_ANSWERS'. (a) = > (
        api.get(`/api/answers/The ${user.id}`)
    ));
}

function getUserAnswers(a) {
    const handlers = [{
        status: 'success'.
        callback: getAnswers.
    }, {
        status: 'error'.
        callback: payload = > (() = > {
            console.log(payload);
        }),
    }];

    return createChainedAsyncAction(getUser(), handlers);
}

export default {
    getUser.
    getAnswers.
    getUserAnswers.
};
Copy the code

In the output, we can output all three actions for different pages to use as needed.

Data not reusable

Every interface call meant a network request, and before the concept of a global data center, many front ends developing new requirements did not care if the data they needed had already been requested elsewhere, but rather rudely requested all the data they needed in full.

This is the problem that stores in Redux are trying to solve. With a global Store, different pages can easily share the same data, thus achieving interface level (Model level) reuse. One thing to note here is that since the data in the Redux Store is stored in memory, all data will be lost once the user refreshes the page, so when using the Redux Store, We also need to cooperate with Cookie and LocalStorage to make persistent storage of core data, so as to ensure that the application state can be correctly restored when the Store is initialized again in the future. In particular, when doing isomorphism, it is important to ensure that the server can inject data from the Store into a location in the HTML that the client can use when initializing the Store.

The ViewModel layer

The ViewModel layer, as a unique layer in client development, has evolved step by step from the MVC Controller. Although the ViewModel solves the problem that Model changes in MVC will be directly reflected in the View, But it still hasn’t been able to get rid of one of Controller’s most notorious ailments: bloated business logic. On the other hand, the concept of a ViewModel alone cannot directly bridge the yawning gap between business logic and display logic that characterizes client development.

The correspondence between business logic and display logic is complex

Common application, for example, are using social networks to login this functionality, product managers want to achieve after the user to connect the social account, first try to login application directly, if not registered, automatic registration application account for the user, if special circumstances social network to return to the user directly registered information does not meet the conditions, such as lack of email or phone number), The supplementary information page is displayed.

In this scenario, login and registration are business logic, give users appropriate feedback on the page according to the interface return, and make corresponding page jump is display logic. From the perspective of Redux, these two are action and reducer respectively. Using the chained asynchronous request function described above, we can link the login and registration actions together and define the relationship between them (if the login fails, try to verify that the user information is enough to register directly, if it is enough, continue to request the registration interface, if it is not enough, jump to the supplementary information page). The code is as follows:

function redirectToPage(redirectUrl) {
  return {
      type: 'APP_REDIRECT_USER'.
      payload: redirectUrl.
  }
}

function loginWithFacebook(facebookId. facebookToken) {
    return createAsyncAction('APP_LOGIN_WITH_FACEBOOK'. (a) = > (
        api.post('/auth/facebook'. {
            facebook_id: facebookId.
            facebook_token: facebookToken.
        })
    ));
}

function signupWithFacebook(facebookId. facebookToken. facebookEmail) {
    if (!facebookEmail) {
      redirectToPage('/fill-in-details');
    }

    return createAsyncAction('APP_SIGNUP_WITH_FACEBOOK'. (a) = > (
        api.post('/accounts'. {
            authentication_type: 'facebook'.
            facebook_id: facebookId.
            facebook_token: facebookToken.
            email: facebookEmail.
        })
    ));
}

function connectWithFacebook(facebookId. facebookToken. facebookEmail) {
    const firstAction = loginWithFacebook(facebookId. facebookToken);
    const callbackAction = signupWithFacebook(facebookId. facebookToken. facebookEmail);

    const handlers = [{
        status: 'success'.
        callback: (a) = > (() = > {}), // The user logged in successfully
    }, {
        status: 'error'.
        callback: callbackAction. // Failed to log in using facebook account, try to help the user register a new account
    }];

    return createChainedAsyncAction(firstAction. handlers);
}
Copy the code

Here, as long as we split the reusable actions down to the right granularity and group them together according to business logic in chained actions, Redux will dispatch different actions in different cases. The possible scenarios are as follows:

// The login succeeded
APP_LOGIN_WITH_FACEBOOK_REQUEST
APP_LOGIN_WITH_FACEBOOK_SUCCESS

// Failed to log in directly
APP_LOGIN_WITH_FACEBOOK_REQUEST
APP_LOGIN_WITH_FACEBOOK_ERROR
APP_SIGNUP_WITH_FACEBOOK_REQUEST
APP_LOGIN_WITH_FACEBOOK_SUCCESS

// Failed to log in directly due to insufficient registration information
APP_LOGIN_WITH_FACEBOOK_REQUEST
APP_LOGIN_WITH_FACEBOOK_ERROR
APP_REDIRECT_USER
Copy the code

Therefore, in reducer, we only need to make corresponding changes to the data in the ViewModel when the corresponding actions are dispatched, thus separating the business logic from the display logic.

This solution is the same and different from MobX and RxJS. The same thing is that the data flow (the dispatch order of the action) is defined and the ViewModel is notified to change the data when appropriate. The difference is that Redux does not automatically trigger a data pipe when the data changes. Instead, it requires the user to explicitly invoke a data pipe. As in the example above, when the user clicks the “Connect to Social Network” button. In general, it is more consistent with the idea of Redux-Observable, that is, redux is not completely abandoned and the concept of data pipeline is introduced, but it is limited to the shortcomings of tool functions and cannot handle more complex scenes. On the other hand, if you really don’t have a very complex scenario in your business, the simplest redux-Thunk will cover most requirements perfectly once you understand Redux.

Business logic bloat

Finally, let’s look at how to solve the problem of bloated business logic. It should be said that splitting and combining reusable actions solves part of the business logic, but on the other hand, the Model layer data needs to be combined and formatted to become a part of the ViewModel, which is also a big problem that puzzles front-end development.

The idea of abstracting a generic Selector and Formatter is recommended to solve this problem.

As we mentioned above, the Model on the back end will directly enter the reducer of each page with the interface. At this time, we can combine the data in different Reducer through the Selector. And format the final data by Formatter into data that can be displayed directly on the View.

For example, on the user’s personal-centric page, we need to show the user’s favorite answers under each category, so we need to grab all categories first and add a “popular” category in front of them that doesn’t exist on the back end. And because classification is a very common data, we have obtained it from the home page and stored it in the Reducer of the home page. The code is as follows:

// src/views/account/formatter.js
import orderBy from 'lodash/orderBy';

function categoriesFormatter(categories) {
    const customCategories = orderBy(categories. 'priority');
    const popular = {
        id: 0.
        name: 'hot'.
        shortname: 'popular'.
    };
    customCategories.unshift(popular);

    return customCategories;
}

// src/views/account/selector.js
import formatter from './formatter.js';
import homeSelector from '.. /home/selector.js';

const categoriesWithPopularSelector = state = >
    formatter.categoriesFormatter(homeSelector.categoriesSelector(state));

export default {
  categoriesWithPopularSelector.
};
Copy the code

In general, once the ViewModel layer is clear about what needs to be solved, you can get a very clear solution by deliberately reusing and combining actions, selectors, and formatters. On the premise that all data is only stored in the corresponding reducer, the problem of inconsistent data on each page will be solved. On the other hand, the root of the data inconsistency problem is the low reusability of the code, which causes the same data to flow into different data pipes in different ways and ultimately get different results.

The View layer

With mapStateToProps and mapDispatchToProps, we can map the fine-grained display data and the combined business logic directly to the corresponding position in the View layer. The result is a clean, easy-to-debug View layer.

Reusable View

But the problem seems to be not so simple, because the reusability of the View layer is also a big problem plaguing the front end. Based on the above ideas, how should we deal with it?

Thanks to frameworks like React, componentalization of the front end is no longer a problem, and we only need to follow the following principles to achieve the reuse of the View layer.

  1. All pages belong to a folder, and only page-level components are connected to the Redux Store. Each page is a separate file containing its own actions, Reducer, selector, and Formatter.
  2. The Components folder holds business components, which are not connected to the Redux store and can only fetch data from props to ensure their maintainability and reusability.
  3. Another folder or NPM package holds the UI components, which are business-neutral and contain only display logic, not business logic.

conclusion

Although it is very difficult to develop a flexible and easy-to-use component library, after accumulating enough reusable business components and UI components, a new page can look for reusable business logic in the data level and from other pages’ actions, selectors, and formatters. The development of new requirements should be faster and faster, rather than more and more business logic and display logic intertwined, resulting in the internal complexity of the entire project, which cannot be maintained and has to be demolished from scratch.

A little insight

In today’s world of new technologies, when we are obsessed with persuading others to accept our technical ideas, we still need to go back to the current business scenario to see what kind of problem we are trying to solve.

Discarding a small number of extremely complex front-end applications, most front-end applications are still focused on data display. In such a scenario, no cutting-edge technology or framework can directly solve the problems mentioned above, instead, it is a set of clear data processing ideas and in-depth understanding of core concepts. This, coupled with rigorous team development practices, can save front-end developers from getting bogged down in complex data.

As a branch of engineering, the complexity of software engineering has never been about unsolvable problems, but about how to use simple rules to make different modules do their job. This is why, with all the frameworks, libraries and solutions emerging, people still emphasize the basics, emphasize the experience, and emphasize the essence of the problem.

Wang Yangming said that the unity of knowledge and action, modern people often know but can not do. But in software engineering, we often fall into the other extreme of doing things as they are, without understanding them, and neither of these is clearly desirable.