写在2017的前端数据层不完全指北

1,552 阅读12分钟
原文链接: zhuanlan.zhihu.com

不知不觉间时间已经来到了 2017 年的末尾,在过去的一年中,关于前端数据层的讨论依然在持续升温。不论数据类型层面的 TypeScript,Flow,PropTypes,应用架构层面的 MVC,MVP,MVVM,还是应用状态层面的 Redux,MobX,RxJS,都各自拥有一群忠实的拥趸,却又谁都无法说服对方认同自己的观点。

关于技术选型上的讨论,笔者一直所持的态度都是求同存异。在讨论上述方案差异的文章已汗牛充栋的今天,不如让我们暂且放缓脚步,再回头去看一下这些方案所要解决的共同问题,并试图给出一些最简单的解法。

接下来让我们以通用的 MVVM 架构为例,逐层剖析前端数据层的共同痛点。

Model 层

作为应用数据链路的最下游,前端的 Model 层与后端的 Model 层其实有着很大的区别。其中最核心的就是,相较于后端 Model,前端 Model 并不能起到定义数据结构的目的,而更像是一个容器,用于存放后端接口返回的数据。

在这样的前提下,在 RESTful 风格的接口已然成为业界标准的今天,如果后端的数据已经是按照数据资源的最小粒度返回给前端的话,我们是不是可以直接将每一个接口的标准返回,当做我们最底层的 Model 呢?换句话说,我们好像也别无选择,因为接口的返回数据就是前端数据层的最上游,也是接下来一切数据流动的起点。

在明确了 Model 层的定义之后,我们再来看一下 Model 层存在的问题。

数据资源粒度过细

数据资源粒度过细通常会导致以下两个问题,一是单个页面需要访问多个接口以获取所有的显示数据,二是各个数据资源之间存在获取顺序的问题,需要按顺序依次异步获取。

对于第一个问题,常见的解法为搭建一个 Node.js 的数据中间层,来做接口整合,最终暴露给客户端以页面为粒度的接口,并与客户端路由保持一致。

这种解法的优点和缺点都非常明显,优点是每个页面都只需要访问一个接口,在生产环境下的加载速度可以得到有效的提升。另一方面,因为服务端已经准备好了所有的数据,做起服务端渲染来也是轻松随意。但从开发效率的角度来讲,不过是将业务复杂度后置的一种做法,并且只适用于页面与页面之间关联较少,应用复杂度较低的项目,毕竟页面级别的 ViewModel 粒度还是太粗了,而且因为是 API 级别的解决方案,可复用性几乎为零。

对于第二个问题,笔者提供一个基于最简单的 redux-thunk 的工具函数来链接两个异步请求。

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 = `_${status.toUpperCase()}`;

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

        return resultAction;
      })
  );
}

基于此,我们再提供一个常见的业务场景来帮助大家理解。比如一个类似于知乎的网站,前端在先获取登录用户信息后,才可以根据用户 id 去获取该用户的回答。

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

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

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

    return createChainedAsyncAction(getUser(), handlers);
}

export default {
    getUser,
    getAnswers,
    getUserAnswers,
};

在输出时,我们可以将三个 actions 全部输出,供不同的页面根据情况按需取用。

数据不可复用

每一次的接口调用都意味着一次网络请求,在没有全局数据中心的概念之前,许多前端在开发新需求时都不会在意所要用到的数据是否已经在其他地方被请求过了,而是再次粗暴地去完整地请求一遍自己所有需要用到的数据。

这也就是 Redux 中的 Store 所想要去解决的问题,有了全局的 store,不同页面之间就可以方便地共享同一份数据,从而达到了接口层面也就是 Model 层面的可复用。这里需要注意的一点是,因为 Redux Store 中的数据是存在内存中的,一旦用户刷新页面就会导致所有数据的丢失,所以在使用 Redux Store 的同时,我们也需要配合 Cookie 以及 LocalStorage 去做核心数据的持久化存储,以保证在未来再次初始化 Store 时能够正确地还原应用状态。特别是在做同构时,一定要保证服务端可以将 Store 中的数据注入到 HTML 的某个位置,以供客户端初始化 Store 时使用。

ViewModel 层

ViewModel 层作为客户端开发中特有的一层,从 MVC 的 Controller 一步步发展而来,虽然 ViewModel 解决了 MVC 中 Model 的改变将直接反应在 View 上这一问题,却仍然没有能够彻底摆脱 Controller 最为人所诟病的一大顽疾,即业务逻辑过于臃肿。另一方面,单单一个 ViewModel 的概念,也无法直接抹平客户端开发所特有的,业务逻辑与显示逻辑之间的巨大鸿沟。

业务逻辑与显示逻辑之间对应关系复杂

举例来说,常见的应用中都有使用社交网络账号登录这一功能,产品经理希望实现在用户连接了社交账户之后,首先尝试直接登录应用,如果未注册则为用户自动注册应用账户,特殊情况下如果社交网络返回的用户信息不满足直接注册的条件(如缺少邮箱或手机号),则跳转至补充信息页面。

在这个场景下,登录与注册是业务逻辑,根据接口返回在页面上给予用户适当的反馈,进行相应的页面跳转则是显示逻辑,如果从 Redux 的思想来看,这二者分别就是 action 与 reducer。使用上文中的链式异步请求函数,我们可以将登录与注册这两个 action 链接起来,定义二者之间的关系(登录失败后尝试验证用户信息是否足够直接注册,足够则继续请求注册接口,不足够则跳转至补充信息页面)。代码如下:

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

function loginWithFacebook(facebookId, facebookToken) {
    return createAsyncAction('APP_LOGIN_WITH_FACEBOOK', () => (
        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', () => (
        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: () => (() => {}), // 用户登陆成功
    }, {
        status: 'error',
        callback: callbackAction, // 使用 facebook 账户登陆失败,尝试帮用户注册新账户
    }];

    return createChainedAsyncAction(firstAction, handlers);
}

这里,只要我们将可复用的 action 拆分到了合适的粒度,并在链式 action 中将他们按照业务逻辑组合起来之后,Redux 就会在不同的情况下 dispatch 不同的 action。可能的几种情况如下:

// 直接登录成功
APP_LOGIN_WITH_FACEBOOK_REQUEST
APP_LOGIN_WITH_FACEBOOK_SUCCESS

// 直接登录失败,注册信息充足
APP_LOGIN_WITH_FACEBOOK_REQUEST
APP_LOGIN_WITH_FACEBOOK_ERROR
APP_SIGNUP_WITH_FACEBOOK_REQUEST
APP_LOGIN_WITH_FACEBOOK_SUCCESS

// 直接登录失败,注册信息不足
APP_LOGIN_WITH_FACEBOOK_REQUEST
APP_LOGIN_WITH_FACEBOOK_ERROR
APP_REDIRECT_USER

于是,在 reducer 中,我们只要在相应的 action 被 dispatch 时,对 ViewModel 中的数据做相应的更改即可,也就做到了业务逻辑与显示逻辑相分离。

这一解法与 MobX 及 RxJS 有相同又有不同。相同的是都定义好了数据的流动方式(action 的 dispatch 顺序),在合适的时候通知 ViewModel 去改变数据,不同的是 Redux 不会在某个数据变动时自动触发某条数据管道,而是需要使用者显式地去调用某一条数据管道,如上述例子中,用户点击『连接社交网络』按钮时。综合起来和 redux-observable 的思路可能更为一致,即没有完全抛弃 redux,又引入了数据管道的概念,只是限于工具函数的不足,无法处理更为复杂的场景。但从另一方面来说,如果业务中确实没有非常复杂的场景,在理解了 redux 之后,使用最简单的 redux-thunk 就可以完美地覆盖到绝大部分需求。

业务逻辑臃肿

最后再让我们来看一下如何解决业务逻辑臃肿的问题,应该说拆分并组合可复用的 action 解决了一部分的业务逻辑,但另一方面,Model 层的数据需要通过组合及格式化后才能成为 ViewModel 的一部分,也是困扰前端开发的一大难题。

这里推荐使用抽象出通用的 Selector 和 Formatter 的概念来解决这一问题。

上面我们提到了,后端的 Model 会随着接口直接进入到各个页面的 reducer,这时我们就可以通过 Selector 来组合不同 reducer 中的数据,并通过 Formatter 将最终的数据格式化为可以直接显示在 View 上的数据。

举个例子,在用户的个人中心页面,我们需要显示用户在各个分类下喜欢过的回答,于是我们需要先获取所有的分类,并在所有分类前加上一个后端并不存在的『热门』分类。又因为分类是一个非常常用的数据,所以我们之前已经在首页获取过并存在了首页的 reducer 中。代码如下:

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

function categoriesFormatter(categories) {
    const customCategories = orderBy(categories, 'priority');
    const popular = {
        id: 0,
        name: '热门',
        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,
};

总的来说,在明确了 ViewModel 层需要解决的问题后,有针对性地去复用并组合 action,selector,formatter 就可以得到一个思路非常清晰的解决方案。在保证所有数据都只在相应的 reducer 中存储一份的前提下,各个页面数据不一致的问题也将迎刃而解。反过来说,数据不一致问题的根源就是代码的可复用性太低,才导致了同一份数据以不同的方式流入了不同的数据管道并最终得到了不同的结果。

View 层

在理清楚前面两层之后,作为前端最重要的 View 层反倒简单了许多,通过 mapStateToProps, mapDispatchToProps,我们就可以将粒度极细的显示数据与组合完毕的业务逻辑直接映射到 View 层的相应位置,从而得到一个纯净,易调试的 View 层。

可复用 View

但问题好像又并没有这么简单,因为 View 层的可复用性也是困扰前端的一大问题,基于以上思路,我们又该怎样处理呢?

受益于 React 等框架,前端组件化不再成为一个难题,我们也只需要遵守以下几个原则,就可以较好地实现 View 层的复用。

  1. 所有的页面都隶属于一个文件夹,只有页面级别的组件才会被 connect 到 redux store。每个页面又都是一个独立的文件夹,存放自己的 action,reducer,selector 及 formatter。
  2. components 文件夹中存放业务组件,业务组件不会被 connect 到 redux store,只能从 props 中获取数据,从而保证其可维护性及可复用性。
  3. 另一个文件夹或 npm 包中存放 UI 组件,UI 组件与业务无关,只包含显示逻辑,不包含业务逻辑。

总结

虽然说开发灵活易用的组件库是一件非常难的事情,但在积累了足够多的可复用的业务组件及 UI 组件之后,新的页面在数据层面,又可以从其他页面的 action,selector,formatter 中寻找可复用的业务逻辑时,新需求的开发速度应当是越来越快的,而不是越来越多的业务逻辑与显示逻辑交织在一起,最终导致整个项目内部复杂度过高,无法维护只能推倒重来。

一点心得

在新技术层出不穷的今天,在我们执着于说服别人接受自己的技术观点时,我们还是需要回到当前业务场景下,去看一看要解决的到底是一个什么样的问题。

抛去少部分极端复杂的前端应用来看,目前大部分的前端应用都还是以展示数据为主,在这样的场景下,再前沿的技术与框架都无法直接解决上面提到的这些问题,反倒是一套清晰的数据处理思路及对核心概念的深入理解,再配合上严谨的团队开发规范才有可能将深陷复杂数据泥潭的前端开发者们拯救出来。

作为工程学的一个分支,软件工程的复杂度从来都不在于那些无法解决的难题,而是如何用简单的规则让不同的模块各司其职。这也是为什么在各种框架,库,解决方案层出不穷的今天,大家还是在强调基础,强调经验,强调要看到问题的本质。

王阳明所说的知行合一,现代人往往是知道却做不到。但在软件工程方面,我们又常常会陷入照猫画虎地做到了,却并不理解其中道理的另一极端,这二者显然都是不可取的。