28

Redux 快速上手指南

 5 years ago
source link: https://www.tuicool.com/articles/rUvuEz7
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

目录

  • React 的数据流向
    • React 组件通信 demo
  • 什么是 Redux?
  • 关键概念
    • state
    • action
    • reducer
    • middleware
  • 使用 Redux 实现一个简单的 Todo App.
    • 设计 state 结构
    • 定义 Action
    • 编写 reducer
    • 拆分 reducers
    • middleware
  • 总结
  • 参考

React 的数据流向

React 中,数据都是自顶向下流动,即父组件到子组件,这条原则让组件之间的关系变得可预测。

mA7BVbv.png!web

在一个 React 组件中,数据有两个来源,即 propsstate ,props 是来自父组件的数据,这个数据是不可更改的,state 则是组件内部的数据,修改它只需要使用 setState 方法。

在 React 应用中,通常都会有很多组件,那么组件之间如何通信?我们可以使用从父组件向子组件传递数据,然后子组件通过事件通知父组件需要更改数据。

React 组件通信 demo

components/View.js

// ...
const { title, authorName, createAt } = props;

return (
    <div>
        <h1>{title}</h1>
        <p>
            {authorName} | {createAt}
        </p>
    </div>
);
// ...

这里是 view 组件的部分代码,这个组件的功能就是展示数据,我们接下来定义一个修改数据的组件 Edit :

components/Edit.js

import React from 'react';
import PropTypes from 'prop-types';

export default class Edit extends React.Component {
    static propTypes = {
        title: PropTypes.string.isRequired,
        authorName: PropTypes.string.isRequired,
        createAt: PropTypes.string.isRequired,
        handlerChange: PropTypes.func.isRequired,
    }

    constructor(props) {
        super(props);

        // 使用了非受控组件
        this.titleInput = React.createRef();
        this.authorNameInput = React.createRef();
        this.createAtInput = React.createRef();

        this.change = this.change.bind(this);
    }

    change(event) {
        event.preventDefault();
        let newTitle = this.titleInput.current.value;
        let newAuthorName = this.authorNameInput.current.value;
        let newCreateAt = this.createAtInput.current.value;

        this.props.handlerChange(newTitle, newAuthorName, newCreateAt);
    }
    
    render () {
        const { title, authorName, createAt } = this.props;

        return (
            <form onSubmit={this.change}>
                <div>
                    <input type="text" defaultValue={title} name="title" ref={this.titleInput} />
                </div>
                <div>
                    <input type="text" defaultValue={authorName} name="authorName" ref={this.authorNameInput} />
                </div>
                <div>
                    <input type="text" defaultValue={createAt} name="createAt" ref={this.createAtInput} />
                </div>

                <button type="submit">修改</button>
            </form>
        );
    }
}

我们在这个组件的 change 方法中拿到了新的数据后,我们调用了父组件的 handlerChange 来通知父组件需要更新数据。我们再看看父组件( App组件 )是怎么保存新数据的:

App.js

//...
handlerChange(newTitle, newAuthorName, newCreateAt) {
    this.setState({
        title: newTitle,
        authorName: newAuthorName,
        createAt: newCreateAt
    })
} 

render() {
    return (
        <div>
            <View {...this.state} />

            <hr />
            <Edit {...this.state} handlerChange={this.handlerChange} />
        </div>
    )
}
// ...

上面代码中,我们在将新的数据保存到了 state 中。这样我们在 Edit 组件中更改数据后,在 View 组件中也就马上能看到数据的更改了。

我们只有两个组件,为了让它们通信,已经写了这么多代码了,那几十甚至几百个组件的项目中要是都这么写的话,应该离 ICU 不远了吧。那有什么好办法嘛?当然,就是我们今天的主角 Redux .

什么是 Redux?

随着大前端的普及,JavaScript 需要管理的状态越来越多。管理不断变化的 state 非常困难。如果一个 model 的变化会引起另一个 model 变化,那么当 view 变化时,就可能引起对应 model 以及另一个 model 的变化,依次地,可能会引起另一个 view 的变化。直至你搞不清楚到底发生了什么。

Redux 就是用于解决上述问题的一个比较流行的框架,它为应用提供了一个可预测的状态容器。Redux 基于简化版本的 Flux 框架,和 Flux 不同的是,Redux 严格的限制了数据只能在一个方向上流动。如图:

IBzUV3F.png!web

在 Redux 中,所有的数据都被保存在一个称为 store 的容器中,在一个应用中,应当有且仅有一个 store 容器。store 中存储的是整个应用程序的 state 树。任何组件都可以从 store 中访问特定的数据,但是要更新数据,则只能构建一个 action(包含了要更新的参数) 并发送给 store, 当 store 接收到 action 后,会将这个 action 代理给相关的 reducer。reducer 会返回一个新的 state。

虽然 Redux 经常和 React 一起使用,但是它本身和 React 并没有什么关系, Redux 可以应用于任何 JavaScript 应用程序。

关键概念

state

state 是整个应用的状态树,state 本身是不能被修改的,要更改 state 只能通过 dispatch action,在 Redux 中,无需定义 state,这点和 vuex 不一样。但是在写代码前,建议先设计好 state 的结构。例如,你正在开发一款清单应用,那么你的 state 可能是这样:

{
    visibilityFilter: 'SHOW_ALL',
    todos: [
        {
            text: 'Read book',
            completed: false
        },
        {
            text: 'other things',
            completed: false
        }
    ]
}

action

Redux 官方文档对 action 的定义是:action 是把数据从应用传到 store 的有效载荷。它是 store 数据的唯一来源。action 本质上只是一个简单的对象,它只要求你必须有个 type 属性来描述你要做的事情,至于其它的属性则不做要求。需要更新到 state 中的数据也应该放入到 action 中。例如你需要删除某个 todo,你可以定义如下的 action:

{
    type: 'DELETE_TODO',    // 强制必须要有此字段
    payload: {              // 这里的 payload 是自定义的属性,你可以传入任何你想要的值
        id: 1               // 删除 id 为1的 todo
    }
}

在我们的代码中,我们需要动态的构建一个 action 以便传入不同的参数到 action 中,这个时候我们应该有个动态创建 action 的函数:

function deleteTodo(id) {
    return {
        type: 'DELETE_TODO',
        playload: {
            id: 1
        }
    }
}

这个函数不会做太多的事,它只返回一个用我们的参数动态的构建的 action。

在 Redux 中,所有的对 action 的变更都在 reducer 中进行, 那么为什么不直接将参数传入到 reducer 中,而是通过 action 给我们携带过去?强制使用 action 的最大的一个好处,使所有对 state 的修改都变得非常直观,你可以一眼就看出数据是如何变化的。

reducer

reducer 是 redux 中负责对 state 进行更新的地方,reducer 就是一个纯函数(没有副作用的函数,只是简单的返回一个新的 state,只要传入参数一样,多次调用的结果必然一样)。所有有副作用的操作不应该放入到 reducer 中(可以使用 middleware 来执行有副作用的操作,比如请求远程 api)。在 redux 的官方文档中,明确的指明了不应该在 reducer 中执行的操作:

  • 修改传入参数
  • 执行有副作用的操作,比如 API 请求或者路由跳转
  • 调用非纯函数,比如 Date.now() 或者 Math.random()

在上一节中,我们定义了一个删除 todo 的 action, 我们接下来为这个 action 编写一个 reducer:

function todos(state, action) {
    switch(action.type) {
        case 'DELETE_TODO':
            return state.filter(todo => 
                todo.id !=== action.payload.id
            )
        default:
            return state;
    }
}

从上面代码中可以看出,一个 reducer 接收两个参数,一个是当前的 state,另外一个参数是你要执行的操作,我们在函数内通过 switch...case 来匹配要执行的操作,然后返回一个新的 state(不能更新旧的 state)。

需要注意的是,上面的例子中,我们在 default 分支上,我们原封不动的返回了 state,这是保证了在没有匹配到要执行的操作时,还能保证程序正常运行。

middleware

redux 官方文档中对于 middleware 给出的定义是: 它提供的是位于 action 被发起后,到达 reducer 之前的扩展点 。这么说你可能会觉得有点迷糊。实际上,middleware 就是扩展了 dispatch 函数。下面我们自己实现一个 middleware 来更具体的理解它:

const store = createStore(reducer);
const next = store.dispatch;

// 重写 dispatch, 加入更多的功能
store.dispatch = (action) => {
    console.log('the state', store.getState());
    console.log('action', action);

    next(action);

    console.log('next state', store.getState());
}


store.dispatch({
    type: 'DELETE_TODO',
    payload: {
        id: 1
    }
})

上面的代码中,我们是实现了一个最简单的日志记录功能,将 reducer 执行前的 state 和执行后的 state 输出到日志中。redux 的 middleware 大致就是为了做这么一件事(当然实际上 middleware 并没有这么简单,我们只是把为了让你理解什么是 middleware)。

以上就是 redux 中比较核心的几个概念,下面我们通过使用 redux 来实现一个简单的 todo List 应用来加深你的理解。

使用 redux 实现一个简单的 todo List

为了方便,我们通过 create-react-app 来快速的创建一个项目。创建完成后的项目结构如下:

Eraq6zu.png!web

这里我们先不涉及 react,所以我们将 src 目录下除了 index 以外的所有文件先删除。并删除 index.js 中的所有代码。

好了,我们现在有了一个空项目了,我们就在它的基础上开发我们的 todo list。

设计 state 结构

在开发前,我们的好好思考下我们需要在 state 中存放哪些数据,我们在 src 目录下新建一个 state.js 文件:

const state = {
    visibilityFilter: 'SHOW_ALL',   // SHOW_ALL, SHOW_COMPLETED, SHOW_ACTIVE
    todos: [
        {
            text: 'some thing.',
            completed: false
        }, {
            text: 'other thing.',
            completed: true
        }
    ]
}

上面就是我们的 state 结构, visibilityFilter 用于存储显示的过滤器,todos 是一个数组,数组的每一项就是一个 todo。 todo 有两个属性, text 是需要做的事情, completed 标识事情是否已经完成。

在实际开发中,比不需要 state.js 这个文件,我们这里也没有用到它,之所以创建出来,就是让我们更直观的看到 state 中到底存储了什么。

到这里我们就已经定义好了 state 的结构,接下来就应该定义 action。

定义 action

action 描述了我们需要做什么事情,在这个应用中,我们需要做一以下几件事:

  1. 添加 todo
  2. 删除 todo
  3. 修改 todo
  4. 完成 todo
  5. 筛选要显示的 todo (所有的、已完成的、未完成的)

在 src 目录中新建一个 action.js 文件用于定义 action:

let nextTodoId = 0;

export const addTodo = text => {
   return {
       type: 'ADD_TODO',
       id: nextTodoId++,
       text
   }
}

export const deleteTodo = id => {
   return {
       type: 'DELETE_TODO',
       id
   }
}

export const updateTodo = (id, text) => {
   return {
       type: 'UPDATE_TODO',
       id,
       text
   }
}

export const completeTodo = id => {
   return {
       type: 'COMPLETE_TODO',
       id
   }
}

export const setVisibilityFilter = filter => {
   return {
       type: 'SET_VISIBILITY_FILTER',
       filter
   }
}

上面定义了我们应用作需要执行的所有操作,接下里就是定义 reducer 来完成 action 中的具体操作。

编写 reducer

在 src 目录下新建一个 reducers.js 文件来定义 reducer:

let initialState = {
    visibilityFilter: 'SHOW_ALL',
    todos: []
}
export default (state = initialState, action) => {
    switch (action.type) {
        case 'ADD_TODO':
            return Object.assign({}, state, {
                todos: [
                    ...state.todos,
                    {
                        id: action.id,
                        text: action.text,
                        completed: false
                    }
                ]
            })
        case 'UPDATE_TODO':
            return Object.assign({}, state, {
                todos: state.todos.map(todo => 
                    todo.id === action.id ?
                        { ...todo, text: action.text } :
                        todo
                )
            })
        case 'DELETE_TODO':
            return Object.assign({}, state, {
                todos: state.todos.filter(todo =>
                    todo.id !== action.id
                )
    
            })
        case 'COMPLETE_TODO':
            return Object.assign({}, state, {
                todos: state.todos.map(todo => 
                    todo.id === action.id ?
                        { ...todo, completed: !todo.completed} :
                        todo
                )
            })
        case 'SET_VISIBILITY_FILTER':
            return Object.assign({}, state, {
                visibilityFilter: action.filter
            })
        default:
            return state
    }
}

我们在上面说到过,不要在 reducer 中改变就的 state, 所以我们这里通过 Object.assign() 复制了一个新的 state。

写了这么多代码了,我们应该去执行看看结果了,不过在执行前,我们还有一件最终要的事要做:我们要去创建一个 store 来存储我们的 state.

我们通过 createStore() 来创建一个 store,这个方法存在于 redux 包中,所以我们还需要去安装这个包。

yarn add redux

安装完成后,去 index.js 创建创建 store:

import { createStore } from 'redux';
import reducers from './reducers';

const store = createStore(reducers);

console.log(store.getState());

这里我们通过 createStore() 方法创建了一个 store, createStore() 方法的参数是一个 reducer。

然后我们通过 store.getState() 方法获取到当前的 state 树的信息。打开浏览器的调试面板,看看输出了什么?

neYnua2.png!web

这里已经输出了值来了,但是这个数据哪来的呢?有没有觉得眼熟,眼熟就对了,这里的数据就是我们在 reducers.js 中传入进去的默认值,在没有 state 给 reducers 时,就会用我们设置的默认值。

let initialState = {
    visibilityFilter: 'SHOW_ALL',
    todos: []
}
export default (state = initialState, action) => {
    // ...
}

接下来我们添加两条 todo 进去看看,添加数据时,是通过 store.dispatch(action)

//...

import {
    addTodo
} from './action';

store.dispatch(addTodo('some think.'));
store.dispatch(addTodo('second think.'));

为了能看到数据变动,我们还需要对 state 进行监听。要监听数据变动,可以使用 store.subscribe() 来创建一个监听器,这个方法接收一个回调函数,在回调函数你可以定义任何想要的操作,我们这只简单的打印 state 状态就好。subscribe() 方法的返回一个用于取消监听的函数,所以在你不需要监听数据变更时,一定记得调用它来取消监听。

import { createStore } from 'redux';
import reducers from './reducers';

import { 
    addTodo
} from './action';

const store = createStore(reducers);

console.log(store.getState());

let unsubscribe = store.subscribe(() => {
    console.log(store.getState());
})

store.dispatch(addTodo('some think.'));
store.dispatch(addTodo('second think.'));

unsubscribe();

从浏览器上,我们可以看到输出结果:

JNZ7f2e.png!web

第一次输出时创建 store 时的输出,所以这个 todos 还是空的,第二次输出是我们第一次调用 dispatch,这个时候,todos 中已经有了一项,第三次输出是我们的第二次添加的时候的输出,这个时候 todos 中有了两项。

其它几个 action 可以自己试试,然后在调试面板中看看是否得到了预期的结果:

store.dispatch(addTodo('some think.'));
store.dispatch(addTodo('second think.'));
store.dispatch(completeTodo(0));
store.dispatch(updateTodo(0, 'is change.'));
store.dispatch(deleteTodo(1));

到此,我们就已经成功的使用 redux 来完成我们应用的状态维护了。下一步我们干嘛呢?是配合 react? 还是使用中间件来记录日志?还没那么快,我们上面的程序还有点小问题。

在我们的 reducers.js 文件中,我们将所有的 reducer 都写在了一起,对于 todo list 这样的小应用来说问题不大,但是对于一个拥有几十甚至几百个组件的应用来说,全部写在一起,就很难维护了,所以下一步我们将 reducer 拆分开来,然后再使用 combineReducers 来组合它们。

拆分 reducers

在我们的应用中,对 todo 列表等更新和 visibilityFilter 明显是不相关的两个部分,我们将他们拆分放在两个文件中:

src/reducers/todos.js
const todos = function (state = [], action) {
    switch (action.type) {
        case 'ADD_TODO':
            return [
                ...state,
                {
                    id: action.id,
                    text: action.text,
                    completed: false
                }
            ]
        
        case 'UPDATE_TODO':
            return state.map(todo => 
                todo.id === action.id ?
                    { ...todo, text: action.text} :
                    todo
            )
        
        case 'DELETE_TODO':
            return state.filter(todo => 
                todo.id !== action.id
            )
        
        case 'COMPLETE_TODO':
            return state.map(todo =>
                todo.id === action.id ?
                    { ...todo, completed: !todo.completed } :
                    todo
            )
        
        default:
            return state;
    }
}

export default todos;
src/reducers/visibilityFilter.js
const visibilityFilter = function (state = 'SHOW_ALL', action) {
    switch (action.type) {
        case 'SET_VISIBILITY_FILTER':
            return action.filter;
        default:
            return state;
    }
}

export default visibilityFilter;

我们现在已经将两个不相关的 reducer 拆分成不同的模块了,接下来我们还需要将两个模块合并再一起才能传递给 createStore(), 再将 redux 提供的方法前,我们先来尝试下自己实现这个函数,在 reducers 目录下再创建一个 index.js 文件,用于合并两个 reducers:

/src/reducers/index.js
import todos from './todos';
import visibilityFilter from './visibilityFilter';

const todoApp = function (state = {}, action) {
    return {
        visibilityFilter: visibilityFilter(state.visibilityFilter, action),
        todos: todos(state.todos, action)
    }
}

export default todoApp;

在 index.js 文件中,我们导入了 visibilityFilter 和 todos 这两个 reducers,并将他们合并到了 todoApp 中。这个地方并不复杂。

这个地方唯一需要注意的是,我们在设置 visibilityFilter 和 todos 时,已经将它们的值给展开了,在 visibilityFilter 中拿到的值就是一个 filter,而在 todos 中得到的是一个包含所有 todo 的数组。

现在我们的 reducers 已经拆分开了,我们把代码跑起来看看结果吧。如果你没有敲错代码的话,那么你应该能够看到你的程序已经正确执行了。

每次都要我们来合并这些 reducers 有点麻烦,redux 为我们提供了一个方便的工具 combineReducers ,让我们可以不需要自己构建 todoApp 这个对象,我们通过代码来看看这个东西有多方便:

src/reducers/index.js
import todos from './todos';
import visibilityFilter from './visibilityFilter';
import { combineReducers } from 'redux';    // 别忘了导入 combineReducers 哦!

const todoApp = combineReducers({
    visibilityFilter,
    todos
});

export default todoApp;

这这个新版本的 index.js 文件中,我们不需要手动的去构建 todoApp 对象,也不需要将 state 展开给对应的 reducers,只需要简单的将他们传入到 combineReducers 中就好了。

到这里,我们就讲完了如何将 reducers 拆分与合并,妈妈再也不用担心我把所有的代码都写在一个文件里了。

在我们上面的代码中,所有的 action.type 我们都是直接通过字符串字面量,在真实的开发场景中,你应该把 type 单独提取到一个常量中来,而不是到处写字面量,关于这个部分我们就不单独说了。

middleware

Redux 有一个最大的优点是,每次发起一个 action 后,state 本身并不能被修改,而是会返回一个新的 state 并存储到 store 中。这使得 state 的变化过程变得更加透明。

我们可以在应用中每一个 action 被发起以及每次新的 state 被计算完成时将它们记录到日志中,当出现问题后,我们马上就能得知是哪个 action 导致了数据异常。

要记录这些状态,最简单的办法就是在每次 dispatch 前后记录下被发起的 action 和新的 state。

import { createStore, combineRedcers } from 'redux';
import reducers from './reducers';

import { 
    addTodo,
    updateTodo,
    deleteTodo,
    completeTodo,
    setVisibilityFilter
} from './action';

const store = createStore(reducers);

// 重新定义 dispatch 函数,
// 在每次真正执行 dispatch 之前先记录下 action
// 并且在 dispatch 之后记录下新的 state
const next = store.dispatch;
store.dispatch = (action) => {
    console.log('dispatching: ', action);
    next(action);
    console.log('next state: ', store.getState());
}

// console.log(store.getState());
let unsubscribe = store.subscribe(() => {
    // console.log(store.getState());
})

store.dispatch(addTodo('some think.'));
store.dispatch(addTodo('second think.'));

store.dispatch(completeTodo(0));
store.dispatch(updateTodo(0, 'is change.'));
store.dispatch(deleteTodo(1));

unsubscribe();

为了避免干扰我们看日志,这里将监听器和初始化的 console.log() 给注释掉了。下图是我们程序执行中打印出来的日志:

ABNbmeZ.png!web

我们这里已经将所有的日志打印出来了,这就是最终解决方案了吗?在回答这个问题之前,我们先考虑另外一个问题:我们需要在程序每次崩溃时将错误日志打印出来,在刚刚记录 state 的基础上,如何实现这个功能呢?是再类似上面的日志一样,再次封装一个 dispatch 来记录崩溃信息吗?

// 为 dispatch 添加一个日志功能,
// 返回带有日志功能的新的 dispatch
function addLogToDispatch(store) {
    const next = store.dispatch;

    store.dispatch = function dispatchWithLog(action) {
        console.log('dispatching: ', action);
        let result = next(action);
        console.log('next state', store.getState());
        return result;
    }
}

// 为 dispatch 添加一个异常捕获机制
function addTryToDispatch(store) {
    const next = store.dispatch;
    store.dispatch = function dispatchWithTry(action) {
        try {
            return next(action);
        } catch (e) {
            console.log("捕获到一个异常");
            throw e;
        }
    }
}

// 分别为 dispatch 添加日志和异常捕获功能
addLogToDispatch(store);
addTryToDispatch(store);

我们上面创建了两个新的函数,用于扩展 dispatch 来增加日志和异常捕获机制,这么看也没什么问题,程序也能正常的执行。

但是如果需要执行更多的操作呢?这个时候就到了 Redux 的 redux middleware 出场的时候了,我们只需要分别将 logger 和 trycatch 封装成两个 middleware,然后再通过 applyMiddleware() 函数添加到 store 中即可:

import { createStore, applyMiddleware } from 'redux';
import reducers from './reducers';

import { 
    addTodo,
    updateTodo,
    deleteTodo,
    completeTodo,
} from './action';


// logger 中间件
const logger = store => next => action => {
    console.log('dispatching: ', action);
    let result = next(action);
    console.log('next state: ', store.getState());
    return result;
}

// 异常捕获中间件
const trycatch = store => next => action => {
    try {
        return next(action);
    } catch (e) {
        console.log(e);
        throw e;
    }
}

// 在创建 store 的时候,使用 applyMiddleware 将
// 中间件加入到 store 中。
const store = createStore(reducers, applyMiddleware(
    logger, 
    trycatch
));

// console.log(store.getState());
let unsubscribe = store.subscribe(() => {
    // console.log(store.getState());
})

store.dispatch(addTodo('some think.'));
store.dispatch(addTodo('second think.'));

store.dispatch(completeTodo(0));
store.dispatch(updateTodo(0, 'is change.'));
store.dispatch(deleteTodo(1));

unsubscribe();

现在所有被发送到 store 的 action 都会经过 logger 和 trycache 中间件。

我们这里只简单的讲了中间件是什么,以及如何实现两个简单的中间件,并没有太过深入的讲解中间件,如果想深入的理解中间件,可以去参考 官方文档的 middleware 部分 ,或者是砖家的 深入理解 Redux - 从零实现一个 Redux 中的中间件部分。

总结

本篇文章到此就结束了,在写之前,打算是将在 React 中使用 Redux 也一起讲了,考虑到篇幅,决定放在下一篇文章中,单独讲 React 中使用 Redux 以及 容器组件和界面组件分离。

本文的代码放在了 GitHub

参考


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK