classicemi / blog

🖋 my personal blog
https://wushuang.name/
32 stars 2 forks source link

浅谈前端状态管理 #20

Open classicemi opened 7 years ago

classicemi commented 7 years ago

近两年前端技术的发展如火如荼,大量的前端项目都在使用或转向 Vue 和 React 的阵营,由前端渲染页面的单页应用占比也越来越高,这就代表前端工作的复杂度也在直线上升,前端页面上展示的信息越来越多也越来越复杂。我们知道,任何状态都需要进行管理,那么今天我们来聊聊前端状态管理。

往事

在那个还被称为 web 1.0 的时代,网页开发还不存在前端的概念,网页上也几乎没有什么交互效果。前端开发要做的事情更多的还是显示后端服务器发来的页面,给页面增加样式。仅有的能称作交互行为的也就是表单的提交了,但当时的场景是:如果要更新页面上的信息,需要用户填完表单后提交给服务器,页面刷新,重新加载后的页面显示了更新后的状态。这种页面频繁刷新的方式是很影响体验的,如果一个页面上有多处这样的交互,频繁的刷新会大大降低性能。

另外,依赖后端的状态交互也很难保存前端的交互状态,比如一些组件的显示状态,如果前端不做管理就需要后端去保存,但这些和数据无关的状态也要后端保存也是非常不合理的。

为了解决这个问题,ajax 技术应运而生,通过 XMLHttpRequest 这个东西,使得前端能在避免页面整体刷新的情况下和后端进行交互并局部改变页面的状态。既然不用刷新页面就能改变页面上的东西,而刷新页面又是一个性能很低的事情,那干脆就不要刷新页面了,单页应用的概念因此被提出。

单页应用的想法虽然有了,但是工具还没有跟上,页面上的大量交互效果大大增加了前端开发工作的复杂度。但程序员的智慧是无穷的,很快有人想到了用状态的概念去反应视图的变更。例如在响应用户点击事件的时候,在页面元素上通过添加删除 class 的方式给这个元素应用不同的样式来达到修改试图的目的。这就是比较原始的通过修改一个 state 来改变 view 的方式,元素上的 class 代表了元素的状态,拥有不同状态的元素表现出的样式也是不一样的。

在那个时候,大家关注的重点还在怎么的更方便的进行 DOM 操作并掩盖掉不同浏览器之间的兼容性问题,于是诞生了 jQuery 这样的库。但随着需求的增加,开发者们除了需要样式能够反应 state 的变更之外,也需要 DOM 结构本身能够和 state 同步。于是数据和视图的双向绑定在前端的应用成为了一个重要的课题。在经过一系列前端框架,如 backboneknockout 等的探索后,诞生了 AngularJS。它是第一个比较好的实现了视图和数据比较自然的双向绑定的前端框架,不像 backbone 那样需要在视图中手动订阅数据模型的变更,大大提高了生产力。

Virtual DOM 及 React 诞生

在 Web 应用开发中,AngularJS 扮演了重要角色。然而 AngularJS 数据和视图的双向绑定基于脏检测的机制,在性能上存在短板,任何数据的变更都会重绘整个视图。但是,由状态反应视图、自动更新页面的思想是先进的,为了解决性能上的问题,Facebook 的工程师们提出了 Virtual DOM 的思想。将 DOM 放到内存中,state 发生变化的时候,根据 state 生成新的 Virtual DOM,再将它和之前的 Virtual DOM 通过一个 diff 算法进行对比,将被改变的内容在浏览器中渲染,避免了 JS 引擎频繁调用渲染引擎的 DOM 操作接口,充分利用了 JS 引擎的性能。有了 Virtual DOM 的支持,React 也诞生了。

有了 React,「state => view」的思想也就有了很好的实践,但反过来呢,怎么在 view 中合理地修改 state 成为了一个新的问题,为此,Facebook 提出了 Flux 思想。

Flux 思想

是的,Flux 不是某一个 JS 库的名称,而是一种架构思想,很多 JS 库则是这种思想的实现,例如 AltFluxible 等,它用于构建客户端 Web 应用,规范数据在 Web 应用中的流动方式。

那么这个和状态管理有什么关系呢?我们知道,React 只是一个视图层的库,并没有对数据层有任何的限制,换言之任何视图组件中都可能存在改变数据层的代码,而过度放权对于数据层的管理是不利的,另外一旦数据层出现问题将会很难追溯,因为不知道变更是从哪些组件发起的。另外,如果数据是由父组件通过 props 的方式传给子组件的话,组件之间会产生耦合,违背了模块化的原则。

我们以 AngularJS 应用为例,在 AngularJS 中,controller 是一个包含于作用域 $scope 的闭包,而这个闭包对应了一个视图模板,$scope 中的数据将会被渲染到模板中。但是一个模板可能会对应到多个 model(当前 controller 的 $scope,父级 $scope,指令的 isolated scope 等),同样,一个 model 也可能影响到多个模板的渲染。应用规模一旦变大,数据和视图的关系很容易混乱,由于这个过程中数据和视图会互相影响,思维的负担也会增加。

而 Flux 的思维方式是单向的,将之前放权到各个组件的修改数据层的 controller 代码收归一处,统一管理,组件需要修改数据层的话需要去触发特定的预先定义好的 dispatcher,然后 dispatcher 将 action 应用到 model 上,实现数据层的修改。然后数据层的修改会应用到视图上,形成一个单向的数据流。打个比方,这就像是图书馆的管理,原来是开放式的,所有人可以随意进出书库借书还书,如果人数不多,这种方式可以减少流程,增加效率,一旦人数变多就势必造成混乱。Flux 就像是给这个图书馆加上了一个管理员,所有借书还书的行为都需要委托管理员去做,管理员会规范对书库的操作行为,也会记录每个人的操作,减少混乱的现象。

image

主要 Flux 实现

Flux 的实现有很多,不同的实现也各有亮点,下面介绍一些比较流行的 Flux 的实现。

flux

image

这应该是 Flux 的一个比较“官方”的实现,显得中规中矩,实现了 Flux 架构文档里的基本概念。它的核心是 Dispatcher,通过 Dispatcher,用户可以注册需要相应的 Action 类型,对不同的 Action 注册对应的回调,以及触发 Action 并传递 payload 数据。

下面是一个简单示例:

const dispatcher = new Dispatcher()
const store = {books: []}
​
dispatcher.register((payload) => {
  if (payload.actionType === 'add-book') {
    store.books.push(payload.newBook)
  }
})
dispatcher.dispatch({
  actionType: 'add-book',
  newBook: {
    name: 'cookbook'
  }
})

可以看到,只使用 flux 提供的 Dispatcher 也是可以的,不过推荐使用 flux 提供的一些基础类来构建 store,这些基础类提供了一些方法可供调用,能更好的扩展数据层的功能,具体使用方法可以参考 flux 文档

reflux

image

Reflux 是在 Flux 的基础上编写的一个 Flux 实现,从形式上看,去掉了显式的 Dispatcher,将 action 表现为函数的形式,构建一个 action 的方式为:

const addBook = Reflux.createAction({
  actionName: 'add-book',
  sync: false,
  preEmit: function() {/*...*/},
  // ...
})
addBook({/*...*/})

另外,Reflux 相比 Flux 有一些区别,例如:

依赖

首先 Flux 不是一个库,而是一种架构思想,不过要使用 flux 还是要引入一个 Dispatcher,而 Reflux 则提供了一整套库供你使用,可以方便地通过 npm 来安装。

组件监听事件

在组件内监听事件的写法上,Flux 和 Reflux 也有一些区别,在 Flux 中:

const _books = {}
const BookStore = assign({}, EventEmitter.prototype, {
  emitChange () {
    this.emit(CHANGE_EVENT)
  },
  addChangeListener (callback) {
    this.on(CHANGE_EVENT, callback)
  },
  removeChangeListener (callback) {
    this.removeListener(CHANGE_EVENT, callback)
  }
})
const Book = React.createClass({
  componentDidMount:function(){
    bookStore.addChangeListener(this.onAddBook)
  }
})

而在 Reflux 中,写法有些不同,它通过在组件中引入 Mixin 的方式使得在组件中可调用 listenTo 这个方法:

var BookStore = React.createClass({
  mixins: [Reflux.ListenerMixin],
  componentDidMount: function() {
    this.listenTo(bookStore, this.onAddBook)
  },
})

Store 和 Action 的写法

在 Flux 中,初始化一个 Store 以及编写 Action 都是比较麻烦的,这导致了代码量的增加,可维护性也会降低,例如我们仍然要写一个 Store 和对应的 Action,创建 Store 的写法在上面的示例中已经有了,而创建 Action 在两者之间区别也很大,首先是 Flux:

const fluxActions = {
  addBook: function(book) {
    Dispatcher.handleViewAction({
      actionType: 'ADD_BOOK',
      book
    })
  },
  // more actions
}

Reflux 和 Flux 相比就简单很多:

const refluxActions = Reflux.createActions([
  'addBook',
  // more actions
])

之所以 Reflux 会简单这么多,是因为它可以在 Store 中直接注册事件的回调函数,而去掉了 Dispatcher 这一中间层,或者说将 Dispatcher 的功能整合进了 Store 中。

总的来看,Reflux 相当于是 Flux 的改进版,补全了 Flux 在 Store 上缺少的功能,并去掉了 Dispatcher(实际上并不是去掉,而是和 Store 合并),减少了冗余的代码。

Redux

image

Redux 实际上相当于 Reduce + Flux,和 Flux 相同,Redux 也需要你维护一个数据层来表现应用的状态,而不同点在于 Redux 不允许对数据层进行修改,只允许你通过一个 Action 对象来描述需要做的变更。在 Redux 中,去掉了 Dispatcher,转而使用一个纯函数来代替,这个纯函数接收原 state tree 和 action 作为参数,并生成一个新的 state tree 代替原来的。而这个所谓的纯函数,就是 Redux 中的重要概念 —— Reducer。

在函数式编程中,Reduce 操作的意思是通过遍历一个集合中的元素并依次将前一次的运算结果代入下一次运算,并得到最终的产物,在 Redux 中,reducer 通过合并计算旧 state 和 action 并得到一个新 state 则反映了这样的过程。

因此,Redux 和 Flux 的第二个区别则是 Redux 不会修改任何一个 state,而是用新生成的 state 去代替旧的。这实际上是应用了不可变数据(Immutable Data),在 reducer 中直接修改原 state 是被禁止的,Facebook 的 Immutable 库可以帮助你使用不可变数据,例如构建一个可以在 Redux 中使用的 Store。

下面是一个用 Redux 构建应用的状态管理的示例:

const { List } = require('immutable')
const initialState = {
  books: List([])
}
import { createStore } from 'redux'

// action
const addBook = (book) => {
  return {
    type: ADD_BOOK,
    book
  }
}

// reducer
const books = (state = initialState, action) => {
  switch (action.type) {
    case ADD_BOOK:
      return Object.assign({}, state, {
        books: state.books.push(action.book)
      })
  }
  return state
}

// store
const bookStore = createStore(books, initialState)

// dispatching action
store.dispatch(addBook({/* new book */}))

Redux 的工作方式遵循了严格的单向数据流原则,从上面的代码示例中可以看出,整个生命周期分为:

  1. 在 store 中调用 dispatch,并传入 action 对象。action 对象是一个描述变化的普通对象,在示例中,它由一个 creator 函数生成。
  2. 接下来,store 会调用注册 store 时传入的 reducer 函数,并将当前的 state 和 action 作为参数传入,在 reducer 中,通过计算得到新的 state 并返回。
  3. store 将 reducer 生成的新 state 树保存下来,然后就可以用新的 state 去生成新的视图,这一步可以借助一些库的帮助,例如官方推荐的 React Redux

如果一个应用规模比较大的话,可能会面临 reducer 过大的问题。这时候我们可以对 reducer 进行拆分,例如使用 combineReducers,将多个 reducer 作为参数传入,生成新的 reducer。当触发一个 action 的时候,新 reducer 会触发原有的多个 reducer:

const book(state = [], action) => {
  // ...
  return newState
}
const author(state = {}, action) => {
  // ...
  return newState
}
const reducer = combineReducers({ book, author })

关于 Redux 的更多用法,可以仔细阅读文档,这里就不多介绍了。

React 技术栈中可用的状态管理库还有更多,例如 Relay,不过它需要配合 GraphQL,在没有 GraphQL 的支持下不好引入,这里就不多赘述了(其实是我没有研究过😝)。

非 React 技术栈状态管理库及其它

介绍了这么多基本都是在围绕 React,不可否认的是 React 社区在 Web 应用架构方面的产出的确非常多,不过除了这些产品之外也有一些状态管理的工具是值得一提的。

Vuex

image

我们业务中使用 Vue 的比例是最高的,说到 Vue 中的状态管理就不得不提到 Vuex。Vuex 也是基于 Flux 思想的产品,所以在某种意义上它和 Redux 很像,但又有不同,下面通过 Vuex 和 Redux 的对比来看看 Vuex 有什么区别。

首先,和 Redux 中使用不可变数据来表示 state 不同,Vuex 中没有 reducer 来生成全新的 state 来替换旧的 state,Vuex 中的 state 是可以被修改的。这么做的原因和 Vue 的运行机制有关系,Vue 基于 ES5 中的 getter/setter 来实现视图和数据的双向绑定,因此 Vuex 中 state 的变更可以通过 setter 通知到视图中对应的指令来实现视图更新。

另外,在 Vuex 中也可以记录每次 state 改变的具体内容,state 的变更可被记录与追踪。例如 Vue 的官方调试工具中就集成了 Vuex 的调试工具,使用起来和 Redux 的调试工具很相似,都可以根据某次变更的 state 记录实现视图快照。

上面说到,Vuex 中的 state 是可修改的,而修改 state 的方式不是通过 actions,而是通过 mutations。一个 mutation 是由一个 type 和与其对应的 handler 构成的,type 是一个字符串类型用以作为 key 去识别具体的某个 mutation,handler 则是对 state 实际进行变更的函数。

// store
const store = {
  books: []
}

// mutations
const mutations = {
  [ADD_BOOKS](state, book) {
    state.books.push(book)
  }
}

那么 action 呢?Vuex 中的 action 也是 store 的组成部分,它可以被看成是连接视图与 state 的桥梁,它会被视图调用,并由它来调用 mutation handler,向 mutation 传入 payload。

这时问题来了,Vuex 中为什么要增加 action 这一层呢,是多此一举吗?

当然不是,在知乎上有这样一个问题可以当做很好的栗子:Vue.js中ajax请求代码应该写在组件的methods中还是vuex的actions中?这个问题的答案并不唯一,但通过这个问题可以很好的说明一个 Vuex 的概念——mutation 必须是同步函数,而 action 可以包含任意的异步操作。

回到这个问题本身,如果在视图中不进行异步操作(例如调用后端 API)只是触发 action 的话,异步操作将会在 action 内部执行:

const actions = {
  addBook({ commit }) {
    request.get(BOOK_API).then(res => commit(ADD_BOOK, res.body.new_book))
  }
}

可以看出,这里的状态变更相当于是 action 产生的副作用,mutation 的作用是将这些副作用记录下来,这样就形成了一个完整数据流闭环,数据流的顺序如下:

  1. 在视图中触发 action,并根据实际情况传入需要的参数。
  2. 在 action 中触发所需的 mutation,在 mutation 函数中改变 state。
  3. 通过 getter/setter 实现的双向绑定会自动更新对应的视图。

Mobx

image

MobX 是一个比较新的状态管理库,它的前身是 Mobservable,实际上 MobX 相当于是 Mobservable 的 2.0 版本。它的上升势头很猛,在 React 社区中很受关注,在不久前刚结束的 React Conf 2017 中也有相关的分享(需翻墙):Preethi Kasireddy - MobX vs Redux: Comparing the Opposing Paradigms - React Conf 2017

Mobx 和 Redux 相比,差别就比较大了。如果说 Redux 吸收并发扬了很多函数式编程思想的话,Mobx 则更多体现了面向对象及的特点。MobX 的特点总结起来有以下几点:

  1. Observable。它的 state 是可被观察的,无论是基本数据类型还是引用数据类型,都可以使用 MobX 的 (@)observable 来转变为 observable value。
  2. Reactions。它包含不同的概念,基于被观察数据的更新导致某个计算值(computed values),或者是发送网络请求以及更新视图等,都属于响应的范畴,这也是响应式编程(Reactive Programming)在 JavaScript 中的一个应用。
  3. Actions。它相当于所有响应的源头,例如用户在视图上的操作,或是某个网络请求的响应导致的被观察数据的变更。

和 Redux 对单向数据流的严格规范不同,Mobx 只专注于从 store 到 view 的过程。在 Redux 中,数据的变更需要监听(可见上文 Redux 示例代码),而 Mobx 的数据依赖是基于运行时的,这点和 Vuex 更为接近。它的 store 组织起来大概像这样:

class BookStore {
  books = []
  @observable admin = ''
  @computed get availableBooks() {
    return this.books.filter(book => !book.isAvailable);
  }
}

和 Vuex 一样,比较直观。

而在修改数据方面,Mobx 的操作成本是最低的,它的 store 基于 class 实现,因此可以直接进行修改,不需要像 Vuex 一样触发 mutation 或是和 Redux 一样调用 reducer 并返回新的 state,对开发更友好。

那么 Mobx 是怎么将数据和视图关联起来的呢?我们知道,在 React 中,组件是由无状态函数(stateless function)渲染的,我们只要在组件中加入 mobx-react 这个包提供的 (@)observer 函数(或使用 ES7 decorator 语法),就可以在 store 被改变时自动 re-render 引用了相应数据的 React 组件。

import React, {Component} from 'react'
import ReactDOM from 'react-dom'
import {observer} from 'mobx-react'
​
@observer
class BookStoreView extends Component {
  render() {
    return (
      <div>
        <ul>
          {this.props.bookStore.books.map(book =>
            <BookView book={book} author={book.author} />
          )}
        </ul>
      </div>
    )
  }
}
​
const BookView = observer(({book}) =>
  <li>
    <input
      type="checkbox"
      checked={book.isAvailable}
      onClick={() => book.isAvailable = !book.isAvailable}
    />{book.title}
  </li>
)
​
const store = new BookStore();
ReactDOM.render(<BookStoreView bookStore={store} />, document.getElementById('app'));

可以看到,所有操作数据的方式在组件中直接进行。

虽然 Mobx 提供了便捷的代码书写方式,但这样容易造成 store 被随意修改,在项目规模比较大的时候,像 Vuex 和 Redux 一样对修改数据的入口进行限制可以提高安全性。在 Mobx 2.2 之后的版本中可以通过 useStrict 限制只能通过 action 对数据进行修改。

上文提到,Mobx 只专注于从 store 到 view 的过程,所以业务逻辑的规划没有一定的标准遵循,社区目前也没有很好的最佳实践,需要开发者们在实际开发中积累经验,规划好代码。

holidaying commented 7 years ago

全局变量和vuex的状态有什么区别,优点和缺点?

classicemi commented 7 years ago

@holidaying 这个区别很大的吧,很重要的一点是 vuex 状态可追踪,全局变量就做不到,有了 vuex 我想不到在 vue 应用内部还有什么地方是应该用全局变量的。