[译]从零开始Redux(四)提炼组件

前言

上一篇,在明白了如何组合状态转移之后,我们回过头来再来看看整个实现,我们提炼了一些简单的展示性的组件,例如FilterLink:

class FilterLink extends Component {
  constructor(props) {
    super(props);
  }

  render(){
    let {curFilter, value, onClick} = this.props
      if(curFilter === value){
          return <span>{this.props.children}&nbsp;</span>
      }else{
          return <span>
                    <a href='#' 
                        onClick={(e)=>{e.preventDefault();onClick(value)}}>
                        {this.props.children}
                    </a>
                    &nbsp;
                </span>
      }
  }
}

export default FilterLink;

它只负责组件的展示而不需要关注页面逻辑问题。例如点击之后的处理是通过属性传递进来的。但是这就导致了一个比较大的问题,大家还记得我们的点击onClick函数是从哪儿传递进来的么?是从最根部的容器组件传递到Todo组件再传递到FilterLink的,这个传递的链条过长并且这个联调上的组件其实并不需要知道每个link具体的逻辑。因此,我们需要根据在这个展示组件的基础上提炼出他们对应的用来管理行为的容器组件。另外,有的组件也太过庞大,包含了太多的展示和逻辑的成分(比如本身的Todo组件,也需要提炼)

提炼容器

我们提炼的步骤一般来说分为两步

  • 提炼出展示组件,这种组件只负责显示样式,逻辑由外部传入
  • 提炼出容器组件,这种组件是在展示组件的基础上形成的,它负责搞定展示组件的逻辑与状态

展示组件

根据这样的精神,我们先提炼出三个展示组件:

  • 添加待办事项组件
  • 待办事项列表组件
  • 显示过滤栏组件
import React, { Component } from 'react';
import './App.css';
    
class AddToDo extends Component {
    constructor(props) {
        super(props);
        this.state = {
            text:'',
        }
    }
    handleChange(event) {
        this.setState({text: event.target.value})
    }

  render() {
    return (
     <div>
        <input type="text" value={this.state.text} onChange={(e)=>this.handleChange(e)}></input>
        <div>
            <button onClick={() => this.props.doAdd(this.state.text)}>+</button>
        </div>
     </div>
      
    );
  }
}

export default AddToDo;
import React, { Component } from 'react';
import './App.css';

class ToDoList extends Component {
    constructor(props) {
        super(props);

    }

  render() {
    let {todos, doToggle} = this.props
    return (
        <ul>
          {todos
          .map(todo => 
            <li 
              style={todo.completed?{textDecoration:'line-through'}:{}} 
              key={todo.id} 
              onClick={()=> doToggle(todo.id)}>
              {todo.text}
            </li>
          )}
        </ul>
      
    );
  }
}

export default ToDoList;
import React, { Component } from 'react';
import './App.css';
    
class FilterLink extends Component {
  constructor(props) {
    super(props);
  }

  render(){
    let {active, onClick} = this.props
      if(active){
          return <span>{this.props.children}&nbsp;</span>
      }else{
          return <span>
                    <a href='#' 
                        onClick={(e)=>{e.preventDefault();onClick()}}>
                        {this.props.children}
                    </a>
                    &nbsp;
                </span>
      }
  }
}
export default FilterLink;

在完成了展示组件的提炼之后,我们就要考虑容器组件的提炼。

容器组件

从我们刚才的分析,容器组件需要完成的事情有:展示组件的状态以及操作逻辑维护,状态更新之后的刷新,因此我们可以相应的提炼出三个容器

  • 添加事项容器
  • 事项列表容器
  • 展示过滤容器
import React, { Component } from 'react';
import './App.css';
import AddToDo from './AddToDo'


class AddTodoContainer extends Component {
  
    componentDidMount(){
      this.unsubscribe = this.props.store.subscribe(()=>{
          this.forceUpdate()
      })
    }
  
    componentWillUnmount(){
      this.unsubscribe()
    }
      constructor(props) {
        super(props);
        this.id = 0;
      }
    
      render() {
        let {store} = this.props;
        return (
          <div>
            <AddToDo doAdd={(input) => {store.dispatch({type: 'ADD_TODO', id: this.id++, text:input})}}></AddToDo>   
          </div>
        );
      }
    }
  
    AddTodoContainer.contextType = MyContext
  
    export default AddTodoContainer;
import React, { Component } from 'react';
import './App.css';
import ToDoList from './ToDoList'

class TodoContainer extends Component {
  
  componentDidMount(){
    this.unsubscribe = this.props.store.subscribe(()=>{
        this.forceUpdate()
    })
  }

  componentWillUnmount(){
    this.unsubscribe()
  }
    constructor(props) {
      super(props);
    }
  
    render() {
      let {store} = this.props;
      let data = store.getState();
      let todos = data.todos.filter(todo => {
        switch (data.visibilityFilter) {
          case 'SHOW_ALL':
            return true
          case 'SHOW_COMPLETED':
            return todo.completed
        
          case 'SHOW_UNCOMPLETED':
            return !todo.completed  
        }
      })
      return (
        <div>
          <ToDoList 
            doToggle={(input) => {store.dispatch({type: 'TOGGLE_TODO', id:input})}}
            todos={todos}>
          </ToDoList>
        </div>
      );
    }
  }

  TodoContainer.contextType = MyContext

  export default TodoContainer;
import React, { Component } from 'react';
import './App.css';
import FilterLink from './FilterLink'


class LinkContainer extends Component {
  componentDidMount(){
      this.unsubscribe = this.props.store.subscribe(()=>{
          this.forceUpdate()
      })
  }

  componentWillUnmount(){
      this.unsubscribe()
  }
  constructor(props) {
    super(props);
  }

  render(){
    let { store, filter} = this.props;
    let state = store.getState();
    return <FilterLink 
            onClick={()=> store.dispatch({type:'SET_VISIBILITY_FILTER', filter: filter})} 
            active={state.visibilityFilter === filter}>
            {this.props.children}
        </FilterLink>
  }
}

export default LinkContainer

上面这两个组件通过传递进来的store对象维护各自展示组件的状态和操作逻辑,并且在自己componentDidMountcomponentWillUnmount的时候向store注册和注销状态变更回调。

整体重构

有了容器组件,我们整个项目的结构就可以简化成这样了:

import React, { Component } from 'react';
import './App.css';
import LinkContainer from './LinkContainer'
    

class Footer extends Component {
  constructor(props) {
    super(props);
  }

  render(){
    let {store} = this.props
    return <div>
        <LinkContainer store={store} filter="SHOW_ALL">ALL</LinkContainer>
        <LinkContainer store={store} filter="SHOW_COMPLETED">COMPLETED</LinkContainer>
        <LinkContainer store={store} filter="SHOW_UNCOMPLETED">UNCOMPLETED</LinkContainer>
    </div>
  }
}


export default Footer;
import React, { Component } from 'react';
import './App.css';
import LinkContainer from './LinkContainer'
import TodoContainer from './TodoContainer'
import PropTypes from 'prop-types';
import Footer from './Footer'
import AddTodoContainer from './AddTodoContainer'

class Todo extends Component {
  constructor(props) {
    super(props);
  }

  render() {
    let { store } = this.props
    return (
      <div>
        <AddTodoContainer store={store}></AddTodoContainer>
        <TodoContainer store={store}></TodoContainer>
        <Footer store={store}></Footer>
      </div>
    );
  }
}

export default Todo;

在index.js文件中

const todoApp = combineReducers({
    todos: reducerTodo,
    visibilityFilter: reducerFilter
})

let store = createStore(todoApp);
ReactDOM.render(<Todo
    store={store}
/>, document.getElementById('root'));
/*
const render = () => 
    ReactDOM.render(<Todo {...store.getState()} 
        doAdd={(input) => {store.dispatch({type: 'ADD_TODO', id: idnum++, text:input})}}
        doToggle={(input) => {store.dispatch({type: 'TOGGLE_TODO', id:input})}}
        doFilter={(input) => {store.dispatch({type:'SET_VISIBILITY_FILTER', filter: input})}}
    />, document.getElementById('root'));
store.subscribe(()=> render());
render();
*/

可以看到在index.js文件内,我们不需要显式的声明并执行一个render方法,而只需要调用一次ReactDOM.render就行了,因为刷新的工作我们都委托给了下游的容器组件了。另外我们也不用传递一堆回调函数了,这些也都由容器组件来管理了。
可以看到,我们通过提炼组件可以慢慢的把不必要的回调和状态传递链条给消除,但是由引入了一个新的问题,我们实际上是将我们的store状态对象一层一层的传递了下去,对于少量的组件可能这样显式的传递倒还好,一旦组件数量增多,这样显式传递会非常痛苦,因此,我们需要一种能够隐式传递的机制。Redux使用了React里的context来解决这个问题。

React上下文

React提供了一个上下文机制,只要在通过api创建一个上下文并且包裹原有的组件,那这个组件及其子节点以及其后代都可以通过上下文获取到变量,Redux就是通过这样的机制将store变量隐式传递的(原视频中所使用的react版本应该比较老,这里根据v16.8的官方demo重新做了一版)。
首先我们根据React的api创建一个上下文:

import React, { Component} from 'react';

export const MyContext = React.createContext({})
export const Provider = MyContext.Provider;

然后从上到下修改组件,首先是index.js:

import {MyContext, Provider} from './MyContext'

const todoApp = combineReducers({
    todos: reducerTodo,
    visibilityFilter: reducerFilter
})

let store = createStore(todoApp);
ReactDOM.render(<Provider value={ {store:store} }>
        <Todo/>
</Provider>, document.getElementById('root'));

然后是todo:

class Todo extends Component {
  constructor(props) {
    super(props);
  }

  render() {
    return (
      <div>
        <TodoContainer></TodoContainer>
        <LinkContainer></LinkContainer>
      </div>
    );
  }
}

然后以TodoContainer为例:

import {MyContext} from './MyContext'

class TodoContainer extends Component {
  
  componentDidMount(){
    console.log('TodoContainer:' + this.context)
    this.unsubscribe = this.context.store.subscribe(()=>{
        this.forceUpdate()
    })
  }

  componentWillUnmount(){
    this.unsubscribe()
  }
    constructor(props) {
      super(props);
      this.id = 0;
    }
  
    render() {
      let {store} = this.context;
      let data = store.getState();
      let todos = data.todos.filter(todo => {
        switch (data.visibilityFilter) {
          case 'SHOW_ALL':
            return true
          case 'SHOW_COMPLETED':
            return todo.completed
        
          case 'SHOW_UNCOMPLETED':
            return !todo.completed  
        }
      })
      return (
        <div>
          <AddToDo doAdd={(input) => {store.dispatch({type: 'ADD_TODO', id: this.id++, text:input})}}></AddToDo>
          <ToDoList 
            doToggle={(input) => {store.dispatch({type: 'TOGGLE_TODO', id:input})}}
            todos={todos}>
          </ToDoList>
        </div>
      );
    }
  }

  TodoContainer.contextType = MyContext

  export default TodoContainer;

从上面代码我们可以看到,以上下文隐式传递实际上就是在外层包裹一个Provider组件,将store放进上下文进行传递,Provider包含的后代节点都可以通过context获取到store,避免了store显式的传递。

还没结束

我们这时候在回过头来看看,实际上在我们的容器组件内有非常多的冗余代码:

  • componentDidMountcomponentWillUnmount里的回调注册注销代码
  • 声明上下文类型

还有很重要的但是不易被发现的一点,就是容器组件所包含的展示组件,无论是回调操作逻辑还是状态取值,其实都是通过store来衍生变化的,其中:

  • 属性来源于store.getState获取的值
  • 操作回调来源于store.dispatch操作

因此,我们可以通过一些操作来简化我们的代码,使我们的容器组件生成模型化。redux提供了一个叫connect的函数来完成我们想要的操作,他需要两个入参函数:

  • mapStateToProps 将store.getState和本身的props映射到一个新的对象,这个对象中的字段就是展示参数的属性
  • mapDispatchToProps 将store.dispatch和本身的props映射到一个新的对象,这个对象中的字段就是展示参数的回调操作

这样说会比较抽象,我们以过滤显示为例来说明:

class LinkContainer extends Component {
  componentDidMount(){
      this.unsubscribe = this.context.store.subscribe(()=>{
          this.forceUpdate()
      })
  }

  componentWillUnmount(){
      this.unsubscribe()
  }
  constructor(props) {
    super(props);
  }

  render(){
    let { store } = this.context;
    let state = store.getState();
    let { filter } = this.props;
    return <FilterLink 
            onClick={()=> store.dispatch({type:'SET_VISIBILITY_FILTER', filter: filter})} 
            active={state.visibilityFilter === filter}>
            {this.props.children}
        </FilterLink>
  }
}

我们可以看到展示组件需要一个onClick回调和一个active属性,我们可以这么来编写我们需要的两个函数:

const mapStateToProps = (state, ownProps) => {
    return {
        active: state.visibilityFilter === ownProps.filter
    }
}


const mapDispatchToProps = (dispatch, ownProps) => {
    return {
        onClick: ()=> dispatch({type:'SET_VISIBILITY_FILTER', filter: ownProps.filter})
    }
}

接着我们就尝试编写我们自己的connect函数。按照connect函数的定义,他的入参是mapStateToPropsmapDispatchToProps(简化版,实际上还有其他函数),返回是另一个函数,入参是展示组件,返回是容器组件,因此我们可以写出大致框架:

const connect = (mapStateToProps, mapDispatchToProps) =>{
    return (WrappedComponent) => {
        class Connect extends Component {
        }
        return Connect
    }
}

然后按照我们之前的逻辑,我们可以将componentDidMountcomponentWillUnmount里的回调注册注销代码以及声明上下文类型的代码拷贝进来,然后在返回的容器组件中调用我们传入的mapStateToPropsmapDispatchToProps方法来生成新的属性并传递给展示组件返回即可,最终成型的代码如下:

import React, {Component} from 'react'
import {MyContext} from './MyContext'

const connect = (mapStateToProps, mapDispatchToProps) =>{
    return (WrappedComponent) => {
        class Connect extends Component {
            componentDidMount(){
                this.unsubscribe = this.context.store.subscribe(()=>{
                    this.forceUpdate()
                })
            }
          
            componentWillUnmount(){
                this.unsubscribe()
            }
            
            constructor(props){
                super(props)
            }

            render() {
    
                let { store } = this.context;
                let state = store.getState();
                return <WrappedComponent 
                    {...mapStateToProps(state, this.props)} 
                    {...mapDispatchToProps(store.dispatch, this.props)}>
                    {this.props.children}
                </WrappedComponent>
            }
        }
        Connect.contextType = MyContext
        return Connect
    }
}

export default connect;

然后我们使用这个方法来重新创建我们的容器组件:

export default connnect(mapStateToProps, mapDispatchToProps)(FilterLink);

就完成了之前那一堆代码做的事情,同样的,我们还可以针对添加事项容器和事项列表容器做同样的操作,先来看添加事项容器:

  let id = 0;
  export default connect(
    ()=>{}, 
    (dispatch, props)=> {
      return {
        doAdd: (input)=> dispatch({type: 'ADD_TODO', id: id++, text:input})
      }
    })(AddToDo);

因为添加事项没有需要传入的属性,所以connect函数的第一个参数是个空,第二个方法是传入了doAdd的逻辑。再来看事项列表容器:

  export default connect(
    (state, props) => {
      return {todos: state.todos.filter(todo => {
        switch (state.visibilityFilter) {
          case 'SHOW_ALL':
            return true
          case 'SHOW_COMPLETED':
            return todo.completed
        
          case 'SHOW_UNCOMPLETED':
            return !todo.completed  
        }
      })}
    },
    (dispatch, props) => {
      return {doToggle: (input)=> dispatch({type: 'TOGGLE_TODO', id:input})}
    }
    )(ToDoList)

经试验这两个改造都是可用的。

结语

作为从零开始Redux的最后一篇,这次的内容显得多了一点,但是我觉得也是最充实和最能体现Redux价值的一篇。其实我为什么选择Redux作为我一个前端菜鸡的分享题目也是因为,我在看作者整个教程的时候感觉没有任何障碍,完全可以用后端开发的思路来理解

  • 函数式
  • 不可变性
  • 模块化
  • 逻辑分层

可能未来真有可能天下大同吧。
最后再贴一次作者的系列视频

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 225,337评论 6 524
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 96,560评论 3 406
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 172,632评论 0 370
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 61,219评论 1 303
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 70,219评论 6 401
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 53,670评论 1 316
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 42,018评论 3 431
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 41,000评论 0 280
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 47,552评论 1 326
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 39,565评论 3 347
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 41,692评论 1 355
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 37,280评论 5 351
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 43,009评论 3 341
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 33,435评论 0 25
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 34,587评论 1 277
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 50,276评论 3 383
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 46,752评论 2 367

推荐阅读更多精彩内容