6

本文从属于笔者的Web 前端入门与最佳实践中的前端工程化之状态管理实践系列文章。下面列举了几篇关于Redux与MobX的文章或者讨论,有兴趣的可以阅读下。本文并不是希望安利或者作MobX的布道者,也不是单纯地为了吐槽Functional Programming至上主义,只是笔者提出自己再前端实践中的一些困惑与思考以供批判讨论。

Redux是完全的函数式编程思想践行者(如果你对于Redux还不够理解,可以参考下笔者的深入理解Redux:10个来自专家的Redux实践建议),其核心技术围绕遵循Pure Function的Reducer与遵循Immutable Object的Single State Tree,提供了Extreme Predictability与Extreme Testability,相对应的需要大量的Boilerplate。而MobX则是Less Opinioned,其脱胎于Reactive Programming,其核心思想为Anything that can be derived from the application state, should be derived. Automatically,即避免任何的重复状态。Redux使用了Uniform Single State Tree,而在后端开发中习惯了Object Oriented Programming的笔者不由自主的也想在前端引入Entity,或者说在设计思想上,譬如对于TodoList的增删改查,笔者希望能够包含在某个TodoList对象中,而不需要将所有的操作拆分为Creator、Reducer与Selector三个部分,我只是想简单的展示个列表而已。笔者上大学学的第一节课就是讲OOP,包括后面在C#、Java、Python、PHP等等很多后端领域的实践中,都深受OOP思想的熏陶与灌输。不可否认,可变的状态是软件工程中的万恶之源,但是,OOP对于业务逻辑的描述与代码组织的可读性、可理解性的保证相较于声明式的,略为抽象的FP还是要好一点的。我认可函数式编程的思想成为项目构建组织的不可分割的一部分,但是是否应该在任何项目的任何阶段都先谈编程思想,而后看业务需求?这无疑有点政治正确般的耍流氓了,笔者觉得文本唯一声明秉持的思想就是在不同级别/需求的项目发展的不同阶段我们应该使用不同的技术栈与技术搭配,而本文要讨论的核心即是如何寻求一种尽可能平稳与提供碎片化代码的能够贯彻项目整个生命周期的代码组织或者框架选用方案。

Redux Really Do The Right Thing!Redux Always Do The Best Thing?

Redux的DevTools与Time Travel是如此的优雅与酷炫,再也不用担心应用莫名其妙崩溃而找不到原因,还能方便地同构直出了呢。不过就像笔者在2016年里做前端是怎样一种体验一文中所描述的,我只是想简单的从服务端获取个列表数据然后展示出来,你却告诉我要去学习TypeScript+Webpack+SystemJS+Babel+React+Redux等等,怎么看都还是jQuery的时代简单明了啊。以最基础的添加用户功能来说,在jQuery的时代我们只需要调用Ajax然后等待结果即可,这样当然是有问题的,项目的代码混乱度会随着需求的增加而几何倍数增长。而Redux则以较为严格的规范帮我们实现了Single Responsibility,这样我们至少需要写Reducer、ActionCreator、Store、Selector等等几个文件,当然,在有些最佳实践里会把这几个部分归纳到一个文件中,不过自从笔者发现某个上千行的文件我连看都不想看的时候,觉得还是拆开来好:

const initialState = {
  users: [
    {
      name: 'Dan'
    },
    {
      name: 'Michel'
    }
  ]
};

// reducer
function users(state = initialState, action) {
  switch (action.type) {
  case 'USER_ADD':
    return { ...state, users: [ ...state.users, action.user ] };
  default:
    return state;
  }
}

// action
{ type: 'USER_ADD', user: user };

然后你需要在需要调用该Action的地方执行以下操作:

dispatch({ type: 'USER_ADD', user: user });

笔者最近的两个项目中都使用了Redux作为核心的状态管理工具中,之前是觉得只要有合适的脚手架Webpack-React-Redux-Boilerplate就可以了,不过很多时候看着从Action Creator到Reducer,再到Selector这一系列的仅仅为了实现某个小功能而需要写的N多的Boilerplate,就觉得这并不是我所想要的。就好像现在View层人们经常会讨论对比Vue与React,虽然Redux目前在状态管理领域一骑绝尘,但是也有越来越多的人关注MobX。笔者之前翻译过一篇文章,你并不需要Redux,这是一篇来自Redux作者的对于Redux滥用情况的吐槽。从上面的例子中也可以看出,如果你使用Redux的话,你必须要遵循如下规范:

  • 必须使用基本对象与数组来描述应用状态

  • 必须使用基本的对象来描述系统变化

  • 必须使用纯函数来处理系统中的业务逻辑

而Dan推荐的适用Redux的情况典型的有:

笔者觉得,Redux适合于需要强项目健壮度与多人协调规范的大中型团队,对于很多中小型创业性质,项目需求迭代异常快的团队则往往可能起到适得其反的作用。如果你真的喜欢Redux,那么更应该在合适的项目,合适的阶段去接入Redux,而不是在需求尚未成型之处就花费大量精力搭建复杂的脚手架,说不准客户的需求图纸都画反了呢。

了解下MobX

MobX中核心的概念即是Observable,相信接触过响应式编程的肯定非常熟悉,从后端的典型代表RxJava到Android/iOS开发中的各种响应式框架都各领风骚。这里我们以构建简单的TODOList为例,代码参考了笔者的mobx-react-webpack-boilerplate这个库。首先,以典型的OOP的思想来考虑,我们需要构建ToDo的实体类:

import {observable} from 'mobx';

export default class TodoModel {
 store;
 id;
 @observable title;
 @observable completed;

 constructor(store, id, title, completed) {
  this.store = store;
  this.id = id;
  this.title = title;
  this.completed = completed;
 }
 ... //还有一些功能函数
}

这里@observable注解标注某个变量为被观测值,一旦某个被观测的变量发生了变化,即可以触发观测值相对应的响应。在写好了模型类之后,我们需要编写Store,这里的Store同时包含了数据存储与对数据的操作,和Redux中的Single State Tree差别还是较大的:

import {observable, computed, reaction} from 'mobx';
import TodoModel from '../models/TodoModel'
import * as Utils from '../utils';


export default class TodoStore {
 @observable todos = [];

 @computed get activeTodoCount() {
  return this.todos.reduce(
   (sum, todo) => sum + (todo.completed ? 0 : 1),
   0
  )
 }

 @computed get completedCount() {
  return this.todos.length - this.activeTodoCount;
 }

 subscribeServerToStore() {
  reaction(
   () => this.toJS(),
   todos => fetch('/api/todos', {
    method: 'post',
    body: JSON.stringify({ todos }),
    headers: new Headers({ 'Content-Type': 'application/json' })
   })
  );
 }

 subscribeLocalstorageToStore() {
  reaction(
   () => this.toJS(),
   todos => localStorage.setItem('mobx-react-todomvc-todos', todos)
  );
 }

 addTodo (title) {
  this.todos.push(new TodoModel(this, Utils.uuid(), title, false));
 }

 toggleAll (checked) {
  this.todos.forEach(
   todo => todo.completed = checked
  );
 }

 clearCompleted () {
  this.todos = this.todos.filter(
   todo => !todo.completed
  );
 }
 ...

}

这里有使用@computed注解,这里的@computed注解即是表示该变量是可以从被观测值中推导而出,而不需要你手动触发判断的。最后在我们的View层,同样可以将其设置为Observer来响应状态的变换:

@observer
export default class TodoApp extends React.Component {
    render() {
        const {todoStore, viewStore} = this.props;
        return (
            <div>
                <DevTool />
                <header className="header">
                    <h1>todos</h1>
                    <TodoEntry todoStore={todoStore} />
                </header>
                <TodoOverview todoStore={todoStore} viewStore={viewStore} />
                <TodoFooter todoStore={todoStore} viewStore={viewStore} />
            </div>
        );
    }

    componentDidMount() {
        var viewStore = this.props.viewStore;
        var router = Router({
            '/': function() { viewStore.todoFilter = ALL_TODOS; },
            '/active': function() { viewStore.todoFilter = ACTIVE_TODOS; },
            '/completed': function() { viewStore.todoFilter = COMPLETED_TODOS; }
        });
        router.init('/');
    }
}

TodoApp.propTypes = {
    viewStore: React.PropTypes.object.isRequired,
    todoStore: React.PropTypes.object.isRequired
};

我们需要状态管理吗?

笔者在我的前端之路这篇综述中提过,前端一直在从随意化到工程化的变革,而笔者认为的工程化的几个特征,即视图组件化、功能模块化与状态管理。笔者觉得,在构建前端项目,乃至于编写简单的HTML页面时,能够考虑状态管理这个概念,并且为以后引入专门的状态管理预留一定的接口空间是件很有意义的事情。而状态管理也并不意味着你就需要Redux或者MobX这样专门的框架,就像你不一定需要Redux中所说的,Local State is Fine,有何不可呢?技术应该服务于业务,服务于产品,那状态管理给予了我们什么样的便捷?建议先阅读下笔者的Web开发中所谓状态浅析:Domain State&UI State,对于某个前端应用,其状态大体可以分为UI State与Domain State两大类:

而当我们考量某个状态管理框架时,我们往往希望其能够提供以下的特征或者接口:

  • 不同的组件之间能够共享状态。这一点应该算是将组件内状态提取到外部的重要原因之一,早期的React中如果你不同组件之间需要共享状态,只能一层一层地Props传递或者通过公共父节点来传递。虽然现在React引入了Context,不过笔者认为其还是更适合于用作一些全局配置的传递。

  • 状态能够在任意地方被访问,这是为了方便我们在纯粹的业务逻辑函数中也能够操作状态。

  • 组件能够修改状态。

  • 组件能够修改其他组件的状态。

Redux与MobX都能满足上述几个需求,能够允许你将状态保存于视图之外,并且允许更改与通知视图重绘,这里我们不纠结于具体的GUI应用程序架构模式,有兴趣深入了解的可以参考笔者的GUI应用程序架构的十年变迁:MVC,MVP,MVVM,Unidirectional,Clean。总而言之,当我们的界面希望获取或者更改某个数据状态时,其有明确的接口供其使用。对于理想的状态管理工具,笔者认可其应该具备以下特征:
(1)Predictable View Rendering:可预测的视图渲染,Redux提出的概念是Deterministic View Render,即视图状态完全脱离于视图存在,最终呈现的视图永远由输入的状态所决定。笔者是坚定的界面组件化的支持者,我们应该将纯界面展示与数据剥离开来,这样可以保证我们代码的职责分割与可测试性,并且能够在下文所述的项目衍化过程中尽可能保证代码的可用性与复用性。
(2)Pure Business Logic:纯函数方式编写的核心业务逻辑,这一点主要是为了保证核心业务逻辑的可测试性与未来的迁移性。

High Order Component

笔者在React设计模式:深入理解React&Redux原理套路一文中探讨了我们在React/Redux开发中常用的一些模式,而笔者较为推崇的将状态管理工具引入组件中的方式即HOC模式。无论Redux还是MobX都是采用了这种模式,我们所熟知的在Redux中的应用方式为:

import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';

...

function mapStateToProps(state, props) {
  const { id } = props;
  const user = state.users[id];

  return {
    user,
  };
}

function mapDispatchToProps(dispatch) {
  return {
    onUpdateUser: bindActionCreators(actions.updateUser, dispatch),
  };
}

const UserProfileContainer = connect(mapStateToProps, mapDispatchToProps)(UserProfile);

而类似的在MobX中的应用方式为:

import { observer, inject } from 'mobx-react';
...
const UserProfileContainer = inject(
  'userStore'
)(observer(({
  id,
  userStore,
}) => {
  return (
    <UserProfile
      user={userStore.getUser(id)}
      onUpdateUser={userStore.updateUser}
    />
  );
}));

Immutable State Tree in Single Store

Redux 有一个很不错的特性就是Undo/Redo,这样会帮助我们在调试时重现之前的状态。那么该特性主要是基于Immutable Data实现的,我们同样的也可以在MobX的Store中,通过强行定义所有的数据操作为Immutable操作,也能实现类似的功能:

export default class SlidesStore {
  // Observable history array
  @observable history = Immutable.from([{
    currentSlideIndex: 0,
    slides: [{
      // Default first slide
    }]
  }])

  // Start state at the first entry in history
  @observable historyIndex = 0;
}
addToHistory(snapshot) {
  this.history = this.history.concat([Immutable.from(snapshot)]);
  this.historyIndex += 1;
}

项目增长过程中的状态管理方案衍化

王国维先生说过人生有三个境界,我觉得根据项目的需求不同或者,。技术应该是为业务需求所服务,仅仅为了使用新的技术而罔顾实际的业务需求就是耍流氓。笔者在思索自己应该使用的状态管理框架时,有一个重要的考虑点就是项目尽可能地小代价的演进与迭代。譬如在立项之初,需求并不明确,功能逻辑尚不复杂的时候,我们可以直接从View层构造,尽可能地先实现Stateless与Fractal的视图组件。笔者认为是需要有独立的API/Model层存在的,其意义在于:
(1)可重用的测试代码。
(2)多个Endpoint的组合。
(3)适当的容错与业务处理。

原型:Local State

这个阶段我们可能直接将数据获取的函数放置到componentDidMount中,并且将UI State与Domain State都利用setState函数存放在LocalState中。这种方式的开发效率最高,毕竟代码量最少,不过其可扩展性略差,并且不利于视图之间共享状态。

// component
<button onClick={() => store.users.push(user)} />

这里的store仅仅指纯粹的数据存储或者模型类。

项目增长:External State

随着项目逐渐复杂化,我们需要寻找专门的状态管理工具来进行外部状态的管理了:

// component
<button onClick={() => store.addUser(user)} />

// store
@action addUser = (user) => {
  this.users.push(user);
}

这个时候你也可以直接在组件内部修改状态,即还是使用第一个阶段的代码风格,直接操作store对象,不过也可以通过引入Strict模式来避免这种不良好的实践:

// root file
import { useStrict } from 'mobx';

useStrict(true);

多人协作/严格规范/复杂交互:Redux

随着项目体量进一步的增加与参与者的增加,这时候使用声明式的Actions就是最佳实践了,也应该是Redux闪亮登场的时候了。这时候Redux本来最大的限制,只能通过Action而不能直接地改变应用状态也就凸显出了其意义所在(Use Explicit Actions To Change The State)。

// reducer
(state, action) => newState

前端之路,从无止境,本文只是笔者旅行期间的一篇随笔,欢迎指导讨论。


王下邀月熊_Chevalier
22.5k 声望8.5k 粉丝

爱代码 爱生活 希望成为全栈整合师