单向数据流

支持组件化开发的前端框架如 ReactVue,组件间的参数传递都是很重要的,而 React 中数据传递是单向的,也被称为单向数据流,即数据只能从父组件传递到子组件,而子组件只需要通过 props 属性渲染即可,如果顶层组件的某个属性的值改变了,React 将由外向内遍历整个组件树,将使用了该属性的组件重新渲染。

创建项目

首先使用 create-react-app 脚手架创建 React 项目,项目生成后删除 src 文件目录下的多余文件,留下 index.js,命令如下:

# 安装脚手架
$ npm install -g create-react-app
# 创建项目
$ create-react-app transfer-props

该项目最后的目录结构如下:

  
    transfer-props
      |- public
      | |- favicon.ico
      | |- index.html
      | |- manifest.json
      |- src
      | |- components
      | | |- App.js
      | | |- Child.js
      | | |- Parent.js
      | |- context.js
      | |- index.js
      |- .gitignore
      |- package.json
      |- README.md
      |- yarn.lock
  

父组件传参给子组件

创建一个最外层组件 App,并在 index.js 中进行渲染。

/* 路径:~transfer-props/src/index.js */
import React from 'react';
import ReactDOM from 'react';
import App from './components/App';

ReactDOM.render(<App />, window.root);

App 组件内部状态中含有 users 属性,值为数组,含有 title 属性,现在要将这两个参数传递给 Parent 组件,传参方式如下:

/* 路径:~transfer-props/src/components/App.js */
import React, { Component } from 'react';
import Parent from './Parent';

export default class App extends Component {
  // 状态
  state = {
    users: [
      { id: 1, name: 'panda', age: '28' },
      { id: 2, name: 'shen', age: '18' }
    ],
    title: '学生信息'
  }

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

Parent 组件中接收到参数,要根据参数中数组的数量来渲染下一个子组件 ChildChild 组件中需要使用父组件 users 数组的学生 id,传参如下:

/* 路径:~transfer-props/src/components/Parent.js */
import React, { Component } from 'react';
import Child from './Child';

export default class App extends Component {
  render() {
    const { users, title } = this.props;

    return (
      <div>
        <h1>{title}</h1> {/* 显示标题 */}
        <ul>
          {
            // 循环创建 Child 组件
            users.map(item => {
              return (
                <Child key={item.id} {...item} />
              )
            })
          }
        </ul>
      </div>
    )
  }
}

最后是 Child 组件,用来渲染学生的基本信息,在 Parent 中我们已经将参数传递,最后看看在 Child 中的接收。

/* 路径:~transfer-props/src/components/Child.js */
import React, { Component } from 'react';

export default class App extends Component {
  render() {
    const { id, name, age } = this.props;
    return (
      <li>
        <span>{id}</span>
        <span>{name}</span>
        <span>{age}</span>
      </li>
    )
  }
}

注意:子组件接收父组件的 props 属性是只读的,不可以修改,修改会报错。

其实在这个过程中参数经历了三个组件,都是由父组件传向子组件,可以看出 React 单向数据流的特点,但是子组件是不可以直接修改父组件的数据的,下面来看看子组件如何修改父组件的数据。

子组件修改父组件的数据

React 中如果要修改父组件的参数,可以给子组件传入一个修改父组件参数的函数,然后在子组件中执行这个函数,就可以实现父组件数据的更新。

我们创建一个与 Parent 组件平行的 Input 组件,两个组件都是 App 的直接子组件,在 Input 组件内通过某些操作给父组件的状态中的 users 属性新增一条数据。

/* 路径:~transfer-props/src/components/Input.js */
import React, { Component } from 'react';

export default class Input extends Component {
  name = React.createRef();
  age = React.createRef();

  handleSubmit = e => {
    // 取消默认事件
    e.preventDefault();

    // 执行父组件方法,取出输入框的值构造成对象作为参数传入
    this.props.addStudent({
      name: this.name.current.value,
      age: this.age.current.value
    });
  }

  render() {
    return (
      <form onSubmit={this.handleSubmit}>
        姓名:<input type="text" required ref={this.name} />
        <br />
        年龄:<input type="text" required ref={this.age} />
        <br />
        <button type="submit">Add</button>
      </form>
    )
  }
}

上面是 Input 组件,在修改时没有直接使用按钮的点击事件,而是添加了 form 标签并使用 submit 事件,是因为可以使用 H5 的自带的校验功能,但是使用 form 会自动提交页面,所以在执行 submit 事件时应取消默认事件,然后调用父组件传来的方法 addStudent,并传入输入框获取的值(非受控组件的取值方式),父组件 App 修改如下:

/* 路径:~transfer-props/src/components/App.js —— 修改后 */
import React, { Component } from 'react';
import Parent from './Parent';
import Input from './Input';

export default class App extends Component {
  // 状态
  state = {
    users: [
      { id: 1, name: 'panda', age: '28' },
      { id: 2, name: 'shen', age: '18' }
    ],
    title: '学生信息'
  }

  // 添加学生信息事件
  addStudent = val => {
    // 使用 push 添加
    // this.state.users.push({ id: this.state.users.length + 1, ...val });
    // this.setState({});

    // 使用 setState 添加
    this.setState({
      users: [
        ...this.state.users,
        { id: this.state.users.length + 1, ...val }
      ]
    });
  }

  render() {
    return (
      <div>
        <Parent {...this.state} />
        <Input addStudent={this.addStudent} />
      </div>
    )
  }
}

首先父组件 App 应该创建 addStudent 方法作为参数传递给子组件 Input,而在 addStudent 方法内部通过 pushsetState 两种方式进行添加,发现都可以更新状态和视图,区别是 push 操作的原来的引用,而 setState 创建了新的引用空间。

React 所有状态的更改都不建议操作原来的引用,通常做法都是通过 setState 返回一个新的 state(创建新的引用),使用解构赋值的方式来保留原始数据,用新数据覆盖旧数据,原因是在 React 类组件种有一个 PureComponent 纯组件类型,对 shouldComponentUpdate 生命周期 “钩子” 做了优化,使用了 propsstate 的浅比较,所以在纯组件类型操作原来的引用是无法更新视图的。

context 实现跨组件传参

在上面的案例当中,父子组件关系的层级是三层,无论是普通的数据还是修改父组件的事件都是作为参数一级一级往下传的,如果组件的层级多了,当跨组件传参时是非常不方便的(通常三级还可以接受)。

跨组件传参是指父级组件与非直接子组件的传参、同级组件之间的传参,同级之间可以找到相同的父级,没有相同的父级就创造相同的父级,最后将问题统一到了父级组件与非直接子组件的传参传递。

React 中给我们提供了 context API 用来实现组件树数据的共享,分为新旧两个版本,这里旧版和新版的 API 都会介绍。

旧版 context

在旧版的 context 需要配合属性类型检测的 prop-types 模块共同使用,需要在共同的父组件上定义一个方法 getChildContext,返回值为一个对象,对象中存储的是当前要传递给其他子组件的数据,同时还有一个静态属性 childContextTypes,值为一个对象,属性的值与 getChildContext 方法内返回的对象的属性一一对应,并用 prop-types 模块对每一个传递给子组件属性的数据类型进行定义,在使用父组件传递属性的子组件中需要定义静态属性 contextTypes 对所使用的属性的数据类型进行校验,需要父组件与 childContextTypes 内的定义一致,然后可以通过子组件实例的 context 属性获取,我们可以使用 context 将上面的案例修改如下:

/* 路径:~transfer-props/src/components/App.js —— 旧版 context */
import React, { Component } from 'react';
import Parent from './Parent';
import Input from './Input';
import PropTypes from 'prop-types'; // 引入参数类型检测模块

export default class App extends Component {
  // 状态
  state = {
    users: [
      { id: 1, name: 'panda', age: '28' },
      { id: 2, name: 'shen', age: '18' }
    ],
    title: '学生信息'
  }

  // 定义参数类型
  static childContextTypes = {
    state: PropTypes.object,
    addStudent: PropTypes.func
  }

  // 上下文对象传给子组件的参数
  getChildContext() {
    return {
      state: this.state,
      addStudent: this.addStudent
    }
  }

  // 添加学生信息事件
  addStudent = val => {
    // 使用 setState 添加
    this.setState({
      users: [
        ...this.state.users,
        { id: this.state.users.length + 1, ...val }
      ]
    });
  }

  render() {
    return (
      <div>
        {/* 不再需要传参 */}
        <Parent />
        <Input />
      </div>
    )
  }
}

上面只是将 APP 组件中原本传给子组件的参数去掉,按照要求添加了 getChildContext 方法和 childContextTypes 静态属性。

/* 路径:~transfer-props/src/components/Input.js —— 旧版 context */
import React, { Component } from 'react';

export default class Input extends Component {
  name = React.createRef();
  age = React.createRef();

  // 类型检测与父组件定义的类型对应
  static contextTypes = {
    addStudent: PropTypes.func
  }

  handleSubmit = e => {
    // 取消默认事件
    e.preventDefault();

    // 从上下文对象上获取父组件的方法并执行
    this.context.addStudent({
      name: this.name.current.value,
      age: this.age.current.value
    });
  }

  render() {
    return (
      <form onSubmit={this.handleSubmit}>
        姓名:<input type="text" required ref={this.name} />
        <br/>
        年龄:<input type="text" required ref={this.age} />
        <br/>
        <button type="submit">Add</button>
      </form>
    )
  }
}

Input 组件中定义 contextTypes 属性,将 addStudent 方法从原来的 props 获取改为了从 context 上获取。

/* 路径:~transfer-props/src/components/Parent.js —— 旧版 context */
import React, { Component } from 'react';
import Child from './Child';

export default class Parent extends Component {
  // 类型检测与父组件定义的类型对应
  static contextTypes = {
    state: PropTypes.object
  }

  render() {
    // 从 context 对象上获取 state 并解构
    const { users, title } = this.context.state;
    return (
      <div>
        <h1>{title}</h1> {/* 显示标题 */}
        <ul>
          {
            // 循环创建 Child 组件
            users.map(item => {
              return (
                <Child key={item.id} {...item} />
              )
            })
          }
        </ul>
      </div>
    )
  }
}

Parent 组件中同样定义 contextTypes 属性,将 state 属性从原来的 props 获取改为了从 context 上获取。

新版 context

新版 context 其实是 React 对象提供给我们的方法 createContext 实现的,方法在调用时返回一个对象,对象上有两个组件分别为 Provider(提供者)和 Consumer(消费者),由于两个配合使用的组件必须由同一次调用 createContext 时创建,所以我们单独创建文件 context.js 代码如下:

/* 路径:~transfer-props/src/context.js —— 新版 context */
import React from 'react';

// 创建上下文对象
const { Provider, Consumer } = React.createContext();

// 到处上下文对象的组件
export { Provider, Consumer };

还是之前的案例,我们可以使用新版 context 修改如下:

// 路径:~transfer-props/src/components/App.js —— 新版 context
import React, { Component } from 'react';
import Parent from './Parent';
import Input from './Input';
import { Provider } from '../context';

export default class App extends Component {
  // 状态
  state = {
    users: [
      { id: 1, name: 'panda', age: '28' },
      { id: 2, name: 'shen', age: '18' }
    ],
    title: '学生信息'
  }

  // 添加学生信息事件
  addStudent = val => {
    // 使用 setState 添加
    this.setState({
      users: [
        ...this.state.users,
        { id: this.state.users.length + 1, ...val }
      ]
    });
  }

  render() {
    return (
      <Provider value={{
        addStudent: this.addStudent,
        state: this.state
      }}>
        <div>
          <Parent {...this.state} />
          <Input addStudent={this.addStudent} />
        </div>
      </Provider>
    )
  }
}

提供参数的父组件 App 应该使用 Provider 进行包裹,将传入的参数以 value 为参数名(规定),传入 context 对象中。

/* 路径:~transfer-props/src/components/Input.js —— 新版 context */
import React, { Component } from 'react';
import { Consumer } from '../context.js';

export default class Input extends Component {
  name = React.createRef();
  age = React.createRef();

  handleSubmit = (e, addStudent) => {
    // 取消默认事件
    e.preventDefault();

    // 执行父组件方法,取出输入框的值构造成对象作为参数传入
    addStudent({
      name: this.name.current.value,
      age: this.age.current.value
    });
  }

  render() {
    return (
      <Consumer>
        {
          ({ addStudent }) => (
            <form onSubmit={e => handleSubmit(e, addStudent)}>
              姓名:<input type="text" required ref={this.name} />
              <br/>
              年龄:<input type="text" required ref={this.age} />
              <br/>
              <button type="submit">Add</button>
            </form>
          )
        }
      </Consumer>
    )
  }
}

在使用 “提供者” 提供数据的 “消费者” 子组件中,应该引入与 Provider 对应的 Consumer 组件,用 Consumer 组件替换原本组件返回的 JSX,内部传入一个函数,函数的形参即为 context 对象,函数内部返回值为原本子组件返回的 JSX,子组件使用父组件的属性可直接从函数的形参获取或解构。

/* 路径:~transfer-props/src/components/Parent.js —— 新版 context */
import React, { Component } from 'react';
import Child from './Child';
import { Consumer } from '../context.js';

export default class App extends Component {
  render() {
    return (
      <Consumer>
        {
          ({ states }) => (
            <div>
              <h1>{states.title}</h1> {/* 显示标题 */}
              <ul>
                {
                  // 循环创建 Child 组件
                  states.users.map(item => (
                    <Child {...item} key={item.id}></Child>
                  ))
                }
              </ul>
            </div>
          )
        }
      </Consumer>
    )
  }
}

Parent 作为 App 的子组件,修改的方式同 Input 组件相同,如上面代码。

总结

关于 React 组件之间传参的各中放式上面基本介绍完了,但是这些传参方式并不能满足于所有的需求,如果是两个毫不相关的组件并且距离共同的父组件层级比较远,即使使用 context 的方式也会显得有些无力,组件间互相传参的需求比较多代码也会冗余和繁琐,因此就有了 ReduxMobx 等数据状态管理工具,可以将各个组件的状态数据统一管理,各个组件的之间的参数都更容易获取。