背景
组件通信应该是组件化开发中最重要的一环了,毕竟在实际的业务开发中,各组件之间或多或少的都有着各种的耦合,并且越是复杂的页面,组件间的联动耦合越复杂。常见的通信方式有以下几种:
props 方式
props 方式是 React 中最常见的父子组件通信方式了,父组件主要通过属性的方式将对应的值传递给子组件,同时也可以将自身的一些方法传递给子组件,供后续调用
这种方式的优点很明确:
- 简单方便
- 符合 React 的单向数据流的理念,数据的更新始终都是从父组件开始,一步步传递到子组件
但这种方式的缺点也很多:
- 只能实现父组件到子组件的通信,而无法实现子组件到父组件,以及兄弟组件间的通信
- 对于多级嵌套的组件,props 的方式需要一层层传递下去,比较繁琐。当然,react 官方也提供了 Context 的解决方案,但也会有一些代价,同时多层嵌套的 Context 也不利于维护
- 单项数据流的方式虽然直观,易于管理,但会导致性能的极大损失,父组件更新后,往往会导致其下所有的子组件的全量更新
redux 方式
一个全局的状态管理方案,组件可以通过 dispatch 来触发各种数据的变更
这种方式的优点:
- 所有组件共用全局的状态管理,不仅可以方便的实现不同组件间的状态共享(弥补了 props 方式的不足),也能实现跨页面间的数据共享
缺点也有:
- 使用起来比较复杂,有各种概念:dispatch,action,reducer, connect,immutable data 等等,上手成本较高
- 它只是一个状态管理框架,还是只能通过数据状态来控制组件,无法实现组件中方法的通信,例如:无法让一个组件调用另一个组件的内部方法
- 接入成本比较高,往往项目接入后,对开发方式有一些要求
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>
</>
);
}
优点:
- 弥补了 props 和 redux 的状态管理的不足,能实现直接调用组件 API 的能力
- 需要借助 forwardRef 和 useImperativeHandle 才能实现组件间 API 的调用,使用上比较繁琐
缺点:
- 在多层嵌套的组件中,refs 会失效
events 方式
事件总线也可以用来实现组件间的通信,如下,通过触发事件,监听事件可以很容易的实现不同组件间的通信
evnets 方式在本业务中也实践过一段时间,确实也解决了组件间的通信问题
但还是存在一些问题:
- 事件的分发和监听缺少一个集中注册的地方,一旦事件多了以后难以管理
- 对于监听同一事件的多个组件,触发顺序取决于组件监听的先后,无法实现更加精细化的顺序控制
- 事件总线的方式本质上是一个发布/订阅模式,组件之间的通信都需要经过:定义事件类型-被通信组件监听事件-通信组件触发事件这一过程,是一种间接通信,步骤较为繁琐,不够直观
分布式状态方式(react-eva)
在分析完上述的传统的组件间通信方式后,接下来就重点讲下一个新的组件间的通信方式:基于分布式状态管理的组件通信。在实际的业务中我们使用 react-eva(formily 框架中也是基于这个框架管理表单状态的) 这个工具来实现分布式的状态管理
首先来讲下分布式状态管理的理念:组件各自的状态完全由各自内部管理,对外只暴露必要的 API 来实现组件之间的通信。下图说明了分布式状态管理的通信方式:
如上图,组件间的通信很简单,只要组件暴露出自己内部的 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")
);
框架的运行过程如下:
上述就是基于 react-eva 的分布式状态管理的通信,该通信方式有以下优点:
- 将组件看作黑盒,只通过透出内部的 API 来进行通信,简洁方便,也更利于组件之间的解偶
- 所有 API 统一注册,方便后续管理
- 将组件更新只局限在该组件内部,解决了原先的数据刷新导致全量的组件更新问题,提升了性能
- 支持嵌套多级传递(只需要传递 actions、implementActions、dispatch 参数即可),解决了 refs 无法嵌套的问题
下面给一个实际例子,例子中包括了兄弟组件之间的通信、父-子组件之间的通信,如下:
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")
);