Quantcast
Channel: w3cplus
Viewing all articles
Browse latest Browse all 1557

浅谈前端状态管理

$
0
0

本文转载于@吴双 Clarence的浅谈前端状态管理的两篇文章。如果需要转找,烦请注明原文出处。

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

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,然后 dispatcheraction应用到 model上,实现数据层的修改。然后数据层的修改会应用到视图上,形成一个单向的数据流。打个比方,这就像是图书馆的管理,原来是开放式的,所有人可以随意进出书库借书还书,如果人数不多,这种方式可以减少流程,增加效率,一旦人数变多就势必造成混乱。Flux 就像是给这个图书馆加上了一个管理员,所有借书还书的行为都需要委托管理员去做,管理员会规范对书库的操作行为,也会记录每个人的操作,减少混乱的现象。

主要 Flux 实现

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

Flux

这应该是 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

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

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

在函数式编程中,Reduce 操作的意思是通过遍历一个集合中的元素并依次将前一次的运算结果代入下一次运算,并得到最终的产物,在 Redux 中,reducer通过合并计算旧 stateaction并得到一个新 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 的工作方式遵循了严格的单向数据流原则,从上面的代码示例中可以看出,整个生命周期分为:

  • store中调用 dispatch,并传入 action对象。action对象是一个描述变化的普通对象,在示例中,它由一个 creator函数生成。
  • 接下来,store会调用注册 store时传入的 reducer函数,并将当前的 stateaction作为参数传入,在 reducer中,通过计算得到新的 state并返回。
  • storereducer生成的新 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 中类 Flux 架构的状态管理工具我们就先聊到这里,接下来我们会聊到其他框架技术栈中的状态管理工具,看看它们会有什么特点。

Vuex

我们业务中使用 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去识别具体的某个 mutationhandler则是对 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的作用是将这些副作用记录下来,这样就形成了一个完整数据流闭环,数据流的顺序如下:

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

MobX

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 的特点总结起来有以下几点:

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

和 Redux 对单向数据流的严格规范不同,Mobx 只专注于从 storeview的过程。在 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 只专注于从 storeview的过程,所以业务逻辑的规划没有一定的标准遵循,社区目前也没有很好的最佳实践,需要开发者们在实际开发中积累经验,规划好代码。

总结

前文的评论中有人提到,「 怎么都是在说 React 」其实并不是作者本人偏爱 React,而且实际上在大前端内部 React 技术栈所占比例并不算高。我们对待各个技术栈的态度是不站队,而是以包容的心态学习不同技术的优点。

虽然大家对 React 的看法一度褒贬不一,但不可否认的是 Facebook 的技术团队确实给前端界带来了很大的技术冲击,状态管理这一块的架构思想大多是由 React 社区所引导的,其他社区或多或少都受到了 React 的影响,并在他们的基础上加以改进。前端技术也正是在这样的氛围中不断发展的。

另一方面,状态管理的研究并不是前端领域独有的问题,实际上前端状态管理的很多思想都是借鉴于成熟很多的软件开发体系。相对于软件开发,前端还是一个很新的领域,只有多学习其他领域的优秀经验前端界才能发展得更好。


Viewing all articles
Browse latest Browse all 1557

Trending Articles