聊一下组件间通信

599 阅读6分钟

背景

组件通信应该是组件化开发中最重要的一环了,毕竟在实际的业务开发中,各组件之间或多或少的都有着各种的耦合,并且越是复杂的页面,组件间的联动耦合越复杂。常见的通信方式有以下几种:

image.png

props 方式

props 方式是 React 中最常见的父子组件通信方式了,父组件主要通过属性的方式将对应的值传递给子组件,同时也可以将自身的一些方法传递给子组件,供后续调用

这种方式的优点很明确:

  1. 简单方便
  2. 符合 React 的单向数据流的理念,数据的更新始终都是从父组件开始,一步步传递到子组件

但这种方式的缺点也很多:

  1. 只能实现父组件到子组件的通信,而无法实现子组件到父组件,以及兄弟组件间的通信
  2. 对于多级嵌套的组件,props 的方式需要一层层传递下去,比较繁琐。当然,react 官方也提供了 Context 的解决方案,但也会有一些代价,同时多层嵌套的 Context 也不利于维护
  3. 单项数据流的方式虽然直观,易于管理,但会导致性能的极大损失,父组件更新后,往往会导致其下所有的子组件的全量更新

redux 方式

一个全局的状态管理方案,组件可以通过 dispatch 来触发各种数据的变更

这种方式的优点:

  1. 所有组件共用全局的状态管理,不仅可以方便的实现不同组件间的状态共享(弥补了 props 方式的不足),也能实现跨页面间的数据共享

缺点也有:

  1. 使用起来比较复杂,有各种概念:dispatch,action,reducer, connect,immutable data 等等,上手成本较高
  2. 它只是一个状态管理框架,还是只能通过数据状态来控制组件,无法实现组件中方法的通信,例如:无法让一个组件调用另一个组件的内部方法
  3. 接入成本比较高,往往项目接入后,对开发方式有一些要求

refs 方式

refs 是提供了一种能让用户操作原生 DOM 的能力。但它也可以实现操作组件的内部 API 的能力,这个可以弥补上述的 props 和 redux 等状态通信的不足

一个通过 refs 来操作组件内部 API 的例子如下:

const TextInput =  forwardRef((props,ref) => {
  const inputRef = useRef();
  // 对外暴露内部的 API
  useImperativeHandle(ref, () => ({
    focus: () => {
      inputRef.current.focus();
    }
  }));
  return <input ref={inputRef} />
})


function TextInputWithFocusButton() {
  const inputEl = useRef(null);
  const onButtonClick = () => {
    // 直接操作组件 TextInput 的内部方法
    inputEl.current.focus();
  };
  return (
    <>
      // 关键代码
      <TextInput ref={inputEl}></TextInput>
      <button onClick={onButtonClick}>Focus the input</button>
    </>
  );
}

优点:

  1. 弥补了 props 和 redux 的状态管理的不足,能实现直接调用组件 API 的能力
  2. 需要借助 forwardRef 和 useImperativeHandle 才能实现组件间 API 的调用,使用上比较繁琐

缺点:

  1. 在多层嵌套的组件中,refs 会失效

events 方式

事件总线也可以用来实现组件间的通信,如下,通过触发事件,监听事件可以很容易的实现不同组件间的通信

image.png

evnets 方式在本业务中也实践过一段时间,确实也解决了组件间的通信问题

但还是存在一些问题:

  1. 事件的分发和监听缺少一个集中注册的地方,一旦事件多了以后难以管理
  2. 对于监听同一事件的多个组件,触发顺序取决于组件监听的先后,无法实现更加精细化的顺序控制
  3. 事件总线的方式本质上是一个发布/订阅模式,组件之间的通信都需要经过:定义事件类型-被通信组件监听事件-通信组件触发事件这一过程,是一种间接通信,步骤较为繁琐,不够直观

分布式状态方式(react-eva)

在分析完上述的传统的组件间通信方式后,接下来就重点讲下一个新的组件间的通信方式:基于分布式状态管理的组件通信。在实际的业务中我们使用 react-eva(formily 框架中也是基于这个框架管理表单状态的) 这个工具来实现分布式的状态管理

首先来讲下分布式状态管理的理念:组件各自的状态完全由各自内部管理,对外只暴露必要的 API 来实现组件之间的通信。下图说明了分布式状态管理的通信方式:

image.png

如上图,组件间的通信很简单,只要组件暴露出自己内部的 API,就可以在其他组件中调用该 API

下面是代码示例:App 和 Child 组件暴露了 getText, setText, getName, setName 这 4 个 API,然后所有组件都可以使用这 4 个 API

import React, { useState } from "react";
import ReactDOM from "react-dom";
import { useEva, createAsyncActions, createEffects } from "react-eva";

// 全局注册所有的 API
const actions = createAsyncActions("getText", "setText", "getName", "setName");

const effects = createEffects(async $ => {
  console.log(await actions.getText());
  $("onClick").subscribe(() => {
    actions.setText("hello world");
  });
});

const App = ({ actions, effects }) => {
  const [state, setState] = useState({ text: "default" });
  const { implementActions, dispatch } = useEva({ actions, effects });
  // App 组件暴露 getText 和 setText 两个 API
  implementActions({
    getText: () => state.text,
    setText: text => setState({ text })
  });
  return (
    <div className="sample">
      <div className="text">{state.text}</div>
      <button className="inner-btn" onClick={() => dispatch("onClick")}>
        button
      </button>
			{* 通过 actions 直接调用 getName, setName *}
			<button onClick={() => actions.setName('new name')}>
        set child
      </button>
			<Child actions={actions} dispatch={dispatch} implementActions={implementActions} />
    </div>
  );
};

const Child = ({ actions, implementActions, dispatch }) => {
	const [name, setName] = useState('child');
  // Child 组件暴露 getName 和 setName 两个 API
  implementActions({
  	getName: () => name,
    setName: value => setName(value),
  });
  return (
  	<div>
	    {* 通过 actions 直接调用 getText, setText *}
    	<div>{`parent: ${actions.getText()}`}</div>
    	{`child: ${name}`}
			<div onClick={() => actions.setText('hello world')}>click to set parent</div>
    </div>
  )
}

ReactDOM.render(
  <App actions={actions} effects={effects} />,
  document.getElementById("root")
);

框架的运行过程如下:

image.png

上述就是基于 react-eva 的分布式状态管理的通信,该通信方式有以下优点:

  1. 将组件看作黑盒,只通过透出内部的 API 来进行通信,简洁方便,也更利于组件之间的解偶
  2. 所有 API 统一注册,方便后续管理
  3. 将组件更新只局限在该组件内部,解决了原先的数据刷新导致全量的组件更新问题,提升了性能
  4. 支持嵌套多级传递(只需要传递 actions、implementActions、dispatch 参数即可),解决了 refs 无法嵌套的问题

下面给一个实际例子,例子中包括了兄弟组件之间的通信、父-子组件之间的通信,如下:

image.png

import React, { useState, useMemo } from "react";
import ReactDOM from "react-dom";
import { useEva, createAsyncActions, createEffects } from "react-eva";

const App = () => {
  // 这里使用 useMemo 包裹,确保只运行一次
  const actions = useMemo(() => createAsyncActions('getUserSelect', 'getLoginForm', 'setLoginFormUserSelect'), []);

  const effects = useMemo(() => createEffects(async $ => {
    // 监听事件,并调用子组件的 actions 方法同步状态
    $('useSelect').subscribe(async (val) => {
      actions.setLoginFormUserSelect(val);
    });

    $('submit').subscribe(async () => {
      const userSelect = await actions.getUserSelect();
      const logins = await actions.getLoginForm();
      const params = {
        ...userSelect,
        ...logins,
      }
      console.log('do submit: ', params);
    });
  }), []);

  const { implementActions, dispatch } = useEva({ actions, effects });

  return (
    <div>
      <UserSelect dispatch={dispatch} implementActions={implementActions} />
      <LoginForm dispatch={dispatch} implementActions={implementActions} />
      <Submit dispatch={dispatch} implementActions={implementActions} />
    </div>
  );
};

const UserSelect = ({ dispatch, implementActions }) => {
  const [noPassword, setNoPassword] = useState(false);

  const handleInput = (evt) => {
    setNoPassword(evt.target.checked);
    // 以事件的形式同步这个组件的状态
    dispatch('useSelect', evt.target.checked);
  }

  implementActions({
    async getUserSelect() {
      return {
        noPassword
      };
    }
  })

  return (
    <div>
      <input type="checkbox" name="nopassword" checked={noPassword} onChange={handleInput} />
      <label for="nopassword">不设置密码</label>
    </div>
  )
}

const LoginForm = ({ dispatch, implementActions }) => {
  const [noPassword, setNoPassword] = useState(false);
  const [name, setName] = useState('');
  const [password, setPassword] = useState('');

  // 对外暴露 api,供其他组件直接调用
  implementActions({
    async getLoginForm() {
      return {
        name, password,
      }
    },
    async setLoginFormUserSelect(val) {
      setNoPassword(val);
    }
  })

  const handleInput = (evt) => {
    if (evt.target.name === 'name') {
      return setName(evt.target.value);
    }
    if (evt.target.name === 'password') {
      return setPassword(evt.target.value);
    }
  }

  return (
    <div>
      <label for="name">用户名: </label>
      <input name="name" onChange={handleInput} value={name} />
      <br />
      {
        noPassword ? null : (
        <>
          <label for="password">密码: </label>
          <input name="password" onChange={handleInput} value={password} />
        </>
        )
      }
    </div>
  )
}

const Submit = ({ dispatch, implementActions }) => {
  return (
    <div>
      <button className="inner-btn" onClick={() => dispatch('submit')}>
        提交
      </button>
    </div>
  )
}

ReactDOM.render(
  <App />,
  document.getElementById("root")
);