不知不觉间时间已经来到了 2017 年底尾。前端
在过去一年中,关于前端数据层的讨论依然在持续升温。不管是数据类型层面的 TypeScript,Flow,PropTypes,应用架构层面的 MVC,MVP,MVVM,仍是应用状态层面的 Redux,MobX,RxJS,都各自拥有一批忠实的拥趸,却又谁都没法说服别人认同本身的观点。npm
关于技术选型上的讨论,笔者一直所持的态度都是求同存异。在讨论上述方案差别的文章已汗牛充栋的今天,不如让咱们暂且放缓脚步,回头去看一下这些方案所要解决的共同的问题,并试图给出一些最简单的解法。redux
接下来让咱们以通用的 MVVM 架构为例,逐层剖析前端数据层的共同痛点。后端
做为应用数据链路的最下游,前端的 Model 层与后端的 Model 层其实有着很大的区别。相较于后端 Model,前端 Model 并不能起到定义数据结构的做用,而更像是一个容器,用于存放后端接口返回的数据。api
在这样的前提下,在 RESTful 风格的接口已然成为业界标准的今天,若是后端数据是按照数据资源的最小粒度返回给前端的话,咱们是否是能够直接将每一个接口的标准返回,当作咱们最底层的数据 Model 呢?换句话说,咱们好像也别无选择,由于接口返回的数据就是前端数据层的最上游,也是接下来一切数据流动的起点。bash
在明确了 Model 层的定义以后,让咱们来看一下 Model 层存在的问题。网络
数据资源粒度过细一般会致使如下两个问题,一是单个页面须要访问多个接口以获取全部的显示数据,二是各个数据资源之间存在获取顺序的问题,须要按顺序依次异步获取。数据结构
对于第一个问题,常见的解法为搭建一个 Node.js 的数据中间层,来作接口整合,最终暴露给客户端以页面为粒度的接口,并与客户端路由保持一致。架构
这种解法的优势和缺点都很是明显,优势是每一个页面都只须要访问一个接口,在生产环境下的页面加载速度能够获得有效的提高。另外一方面,由于服务端已经准备好了全部的数据,作起服务端渲染来也很轻松。但从开发效率的角度来说,不过是将业务复杂度后置的一种作法,而且只适用于页面与页面之间关联较少,应用复杂度较低的项目,毕竟页面级别的 ViewModel 粒度仍是太粗了,并且由于是接口级别的解决方案,可复用性几乎为零。app
对于第二个问题,笔者提供一个基于最简单的 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 层做为客户端开发中特有的一层,从 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 层反而简单了许多,经过 mapStateToProps
和 mapDispatchToProps
,咱们就能够将粒度极细的显示数据与组合完毕的业务逻辑直接映射到 View 层的相应位置,从而获得一个纯净,易调试的 View 层。
但问题好像又并无那么简单,由于 View 层的可复用性也是困扰前端的一大难题,基于以上思路,咱们又该怎样处理呢?
受益于 React 等框架,前端组件化再也不是一个问题,咱们也只须要遵照如下几个原则,就能够较好地实现 View 层的复用。
虽说开发灵活易用的组件库是一件很是难的事情,但在积累了足够多的可复用的业务组件及 UI 组件以后,新的页面在数据层面,又能够从其余页面的 action,selector,formatter 中寻找可复用的业务逻辑时,新需求的开发速度应当是愈来愈快的。而不是愈来愈多的业务逻辑与显示逻辑交织在一块儿,最终致使整个项目内部复杂度太高没法维护后只能推倒重来。
在新技术层出不穷的今天,在咱们执着于说服别人接受本身的技术观点时,咱们仍是须要回到当前业务场景下,去看一看要解决的究竟是一个什么样的问题。
抛去少部分极端复杂的前端应用来看,目前大部分的前端应用都仍是以展现数据为主,在这样的场景下,再前沿的技术与框架都没法直接解决上面提到的这些问题,反却是一套清晰的数据处理思路及对核心概念的深刻理解,再配合上严谨的团队开发规范才有可能将深陷复杂数据泥潭的前端开发者们拯救出来。
做为工程学的一个分支,软件工程的复杂度历来都不在于那些没法解决的难题,而是如何制定简单的规则让不一样的模块各司其职。这也是为何在各类框架,库,解决方案层出不穷的今天,咱们仍是在强调基础,强调经验,强调要看到问题的本质。
王阳明所说的知行合一,现代人每每是知道却作不到。但在软件工程方面,咱们又经常会陷入照猫画虎地作到了,却并不理解其中原理的另外一极端,而这两者显然都是不可取的。