看完这篇文章,希望你可以从整体上对 Hooks 有个认识,并对其设计哲学有一些理解
React组件设计理论
React以一种全新的编程范式定义了前端开发约束,它为视图开发带来了一种全新的心智模型:
- React认为,UI视图是数据的一种视觉映射,即UI = F(DATA),这里的F需要负责对输入数据进行加工、并对数据的变更做出响应
- 公式里的F在React里抽象成组件,React是以组件(Component-Based)为粒度编排应用的,组件是代码复用的最小单元
- 在设计上,React采用props属性来接收外部的数据,使用state属性来管理组件自身产生的数据(状态),而为了实现(运行时)对数据变更做出响应需要,React采用基于类(Class)的组件设计!
除此之外,React认为组件是有生命周期的,因此开创性地将生命周期的概念引入到了组件设计,从组件的create到destory提供了一系列的API供开发者使用
这就是React组件设计的理论基础,我们最熟悉的React组件一般长这样:
// React基于Class设计组件
class MyConponent extends React.Component {
// 组件自身产生的数据
state = {
counts: 0
}
// 响应数据变更
clickHandle = () => {
this.setState({ counts: this.state.counts++ });
if (this.props.onClick) this.props.onClick();
}
// lifecycle API
componentWillUnmount() {
console.log('Will mouned!');
}
// lifecycle API
componentDidMount() {
console.log('Did mouned!');
}
// 接收外来数据(或加工处理),并编排数据在视觉上的呈现
render(props) {
return (
<>
Input content: {props.content}, btn click counts: {this.state.counts}
>
);
}
}
Class Component的问题
组件复用困局
组件并不是单纯的信息孤岛,组件之间是可能会产生联系的,一方面是数据的共享,另一个是功能的复用:
- 对于组件之间的数据共享问题,React官方采用单向数据流(Flux)来解决
- 对于(有状态)组件的复用,React团队给出过许多的方案,早期使用CreateClass + Mixins,在使用Class Component取代CreateClass之后又设计了Render Props和Higher Order Component,直到再后来的Function Component+ Hooks设计,React团队对于组件复用的探索一直没有停止
HOC使用(老生常谈)的问题:
- 嵌套地狱,每一次HOC调用都会产生一个组件实例
- 可以使用类装饰器缓解组件嵌套带来的可维护性问题,但装饰器本质上还是HOC
- 包裹太多层级之后,可能会带来props属性的覆盖问题
Render Props:
- 数据流向更直观了,子孙组件可以很明确地看到数据来源
- 但本质上Render Props是基于闭包实现的,大量地用于组件的复用将不可避免地引入了callback hell问题
- 丢失了组件的上下文,因此没有this.props属性,不能像HOC那样访问this.props.children
Javascript Class的缺陷
this的指向(语言缺陷)
class People extends Component {
state = {
name: 'dm',
age: 18,
}
handleClick(e) {
// 报错!
console.log(this.state);
}
render() {
const { name, age } = this.state;
return (My name is {name}, i am {age} years old.);
}
}
createClass不需要处理this的指向,到了Class Component稍微不慎就会出现因this的指向报错。
编译大小(还有性能)问题
// Class Component
class App extends Component {
state = {
count: 0
}
componentDidMount() {
console.log('Did mount!');
}
increaseCount = () => {
this.setState({ count: this.state.count + 1 });
}
decreaseCount = () => {
this.setState({ count: this.state.count - 1 });
}
render() {
return (
<>
Counter
Current count: {this.state.count}
>
);
}
}
// Function Component
function App() {
const [ count, setCount ] = useState(0);
const increaseCount = () => setCount(count + 1);
const decreaseCount = () => setCount(count - 1);
useEffect(() => {
console.log('Did mount!');
}, []);
return (
<>
Counter
Current count: {count}
>
);
}
Class Component编译结果(Webpack):
var App_App = function (_Component) {
Object(inherits["a"])(App, _Component);
function App() {
var _getPrototypeOf2;
var _this;
Object(classCallCheck["a"])(this, App);
for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) {
args[_key] = arguments[_key];
}
_this = Object(possibleConstructorReturn["a"])(this, (_getPrototypeOf2 = Object(getPrototypeOf["a"])(App)).call.apply(_getPrototypeOf2, [this].concat(args)));
_this.state = {
count: 0
};
_this.increaseCount = function () {
_this.setState({
count: _this.state.count + 1
});
};
_this.decreaseCount = function () {
_this.setState({
count: _this.state.count - 1
});
};
return _this;
}
Object(createClass["a"])(App, [{
key: "componentDidMount",
value: function componentDidMount() {
console.log('Did mount!');
}
}, {
key: "render",
value: function render() {
return react_default.a.createElement(/*...*/);
}
}]);
return App;
}(react["Component"]);
Function Component编译结果(Webpack):
function App() {
var _useState = Object(react["useState"])(0),
_useState2 = Object(slicedToArray["a" /* default */ ])(_useState, 2),
count = _useState2[0],
setCount = _useState2[1];
var increaseCount = function increaseCount() {
return setCount(count + 1);
};
var decreaseCount = function decreaseCount() {
return setCount(count - 1);
};
Object(react["useEffect"])(function () {
console.log('Did mount!');
}, []);
return react_default.a.createElement();
}
- Javascript实现的类本身比较鸡肋,没有类似Java/C++多继承的概念,类的逻辑复用是个问题
- Class Component在React内部是当做Javascript Function类来处理的
- Function Component编译后就是一个普通的function,function对js引擎是友好的
Function Component缺失的功能
不是所有组件都需要处理生命周期,在React发布之初Function Component被设计了出来,用于简化只有render时Class Component的写法。
Function Component是纯函数,利于组件复用和测试
Function Component的问题是只是单纯地接收props、绑定事件、返回jsx,本身是无状态的组件,依赖props传入的handle来响应数据(状态)的变更,所以Function Component不能脱离Class Comnent来存在!
function Child(props) {
const handleClick = () => {
this.props.setCounts(this.props.counts);
};
// UI的变更只能通过Parent Component更新props来做到!!
return (
<>
{this.props.counts}
>
);
}
class Parent extends Component() {
// 状态管理还是得依赖Class Component
counts = 0
render () {
const counts = this.state.counts;
return (
<>
sth...
this.setState({counts: counts++})} />
>
);
}
}
所以,Function Comonent是否能脱离Class Component独立存在,关键在于让Function Comonent自身具备状态处理能力,即在组件首次render之后,“组件自身能够通过某种机制再触发状态的变更并且引起re-render”,而这种“机制”就是Hooks!
Hooks的出现弥补了Function Component相对于Class Component的不足,让Function Component取代Class Component成为可能。
Function Component + Hooks组合
1、功能相对独立、和render无关的部分,可以直接抽离到hook实现,比如请求库、登录态、用户核身、埋点等等,理论上装饰器都可以改用hook实现(如react-use,提供了大量从UI、动画、事件等常用功能的hook实现)。
case:Popup组件依赖视窗宽度适配自身显示宽度、相册组件依赖视窗宽度做单/多栏布局适配
function useWinSize() {
const html = document.documentElement;
const [ size, setSize ] = useState({ width: html.clientWidth, height: html.clientHeight });
useEffect(() => {
const onSize = e => {
setSize({ width: html.clientWidth, height: html.clientHeight });
};
window.addEventListener('resize', onSize);
return () => {
window.removeEventListener('resize', onSize);
};
}, [ html ]);
return size;
}
// 依赖win宽度,适配图片布局
function Article(props) {
const { width } = useWinSize();
const cls = `layout-${width >= 540 ? 'muti' : 'single'}`;
return (
<>
{props.content}
recommended thumb list
>
);
}
// 弹层宽度根据win宽高做适配
function Popup(props) {
const { width, height } = useWinSize();
const style = {
width: width - 200,
height: height - 300,
};
return ({props.content});
}
2、有render相关的也可以对UI和功能(状态)做分离,将功能放到hook实现,将状态和UI分离
case:表单验证
function App() {
const { waiting, errText, name, onChange } = useName();
const handleSubmit = e => {
console.log(`current name: ${name}`);
};
return (
);
}
React Hooks 的本质
稍微复杂点的项目肯定是充斥着大量的 React 生命周期函数(注意,即使你使用了状态管理库也避免不了这个),每个生命周期里几乎都承担着某个业务逻辑的一部分,或者说某个业务逻辑是分散在各个生命周期里的。
而 Hooks 的出现本质是把这种面向生命周期编程变成了面向业务逻辑编程,你不用再去关心本不该关心的生命周期。
一个 Hooks 演变
我们先假想一个常见的需求,一个 Modal 里需要展示一些信息,这些信息需要通过 API 获取且跟 Modal 强业务相关,要求我们:
- 因为业务简单,没有引入额外状态管理库
- 因为业务强相关,并不想把数据跟组件分开放
- API 数据会随机变动,因此需要每次打开 Modal 才获取最新数据
- 为了后期优化,不可以有额外的组件创建和销毁
我们可能的实现如下:
class RandomUserModal extends React.Component {
constructor(props) {
super(props);
this.state = {
user: {},
loading: false,
};
this.fetchData = this.fetchData.bind(this);
}
componentDidMount() {
if (this.props.visible) {
this.fetchData();
}
}
componentDidUpdate(prevProps) {
if (!prevProps.visible && this.props.visible) {
this.fetchData();
}
}
fetchData() {
this.setState({ loading: true });
fetch('https://randomuser.me/api/')
.then(res => res.json())
.then(json => this.setState({
user: json.results[0],
loading: false,
}));
}
render() {
const user = this.state.user;
return (
{this.state.loading ?
loading...
:
- Name: {`${(user.name || {}).first} ${(user.name || {}).last}`}
- Gender: {user.gender}
- Phone: {user.phone}
}
)
}
}
我们抽象了一个包含业务逻辑的 RandomUserModal,该 Modal 的展示与否由父组件控制,因此会传入参数 visible 和 handleCloseModal(用于 Modal 关闭自己)。
为了实现在 Modal 打开的时候才进行数据获取,我们需要同时在 componentDidMount 和 componentDidUpdate 两个生命周期里实现数据获取的逻辑,而且 constructor 里的一些初始化操作也少不了。
其实我们的要求很简单:在合适的时候通过 API 获取新的信息,这就是我们抽象出来的一个业务逻辑,为了这个业务逻辑能在 React 里正确工作,我们需要将其按照 React 组件生命周期进行拆解。这种拆解除了代码冗余,还很难复用。
下面我们看看采用 Hooks 改造后会是什么样:
function RandomUserModal(props) {
const [user, setUser] = React.useState({});
const [loading, setLoading] = React.useState(false);
React.useEffect(() => {
if (!props.visible) return;
setLoading(true);
fetch('https://randomuser.me/api/').then(res => res.json()).then(json => {
setUser(json.results[0]);
setLoading(false);
});
}, [props.visible]);
return (
// View 部分几乎与上面相同
);
}
很明显地可以看到我们把 Class 形式变成了 Function 形式,使用了两个 State Hook 进行数据管理(类比 constructor
),之前 cDM
和 cDU
两个生命周期里干的事我们直接在一个 Effect Hook 里做了(如果有读取或修改 DOM 的需求可以看 这里)。做了这些,最大的优势是代码精简,业务逻辑变的紧凑,代码行数也从 50+ 行减少到 30+ 行。
Hooks 的强大之处还不仅仅是这个,最重要的是这些业务逻辑可以随意地的的抽离出去,跟普通的函数没什么区别(仅仅是看起来没区别),于是就变成了可以复用的自定义 Hook。具体可以看下面的进一步改造:
// 自定义 Hook
function useFetchUser(visible) {
const [user, setUser] = React.useState({});
const [loading, setLoading] = React.useState(false);
React.useEffect(() => {
if (!visible) return;
setLoading(true);
fetch('https://randomuser.me/api/').then(res => res.json()).then(json => {
setUser(json.results[0]);
setLoading(false);
});
}, [visible]);
return { user, loading };
}
function RandomUserModal(props) {
const { user, loading } = useFetchUser(props.visible);
return (
// 与上面相同
);
}
这里的 useFetchUser 为自定义 Hook,它的地位跟自带的 useState 等比也没什么区别,你可以在其它组件里使用,甚至在这个组件里使用两次,它们会天然地隔离开。
业务逻辑复用
这里说的业务逻辑复用主要是需要跨生命周期的业务逻辑。单单按照组件堆积的形式组织代码虽然也可以达到各种复用的目的,但是会导致组件非常复杂,数据流也会很乱。组件堆积适合 UI 布局,但是不适合逻辑组织。为了解决这些问题,在 React 发展过程中,产生了很多解决方案,我认知里常见的有以下几种:
Mixins
坏处远远大于带来的好处,因为现在已经不再支持,不多说,可以看看这篇文章:Mixins Considered Harmful。
Class Inheritance
官方 很不推荐此做法,实际上我也没真的看到有人这么做。
High-Order Components (HOC)
React 高阶组件 在封装业务组件上简直是屡试不爽,它的实现是把自己作为一个函数,接受一个组件,再返回一个组件,这样它可以统一处理掉一些业务逻辑并达到复用目的。
比较常见的一个就是 react-redux
里的 connect
函数:
但是它也被很多人吐槽嵌套问题:
Render Props
Render Props 其实很常见,比如 React Context API:
class App extends React.Component {
render() {
return (
{val => {val}}
)
}
}
它的实现思路很简单,把原来该放「组件」的地方,换成了回调,这样当前组件里就可以拿到子组件的状态并使用。
但是,同样这会产生 Wrapper Hell 问题:
Hooks
Hooks 本质上面说了,是把面向生命周期编程变成了面向业务逻辑编程,写法上带来的优化只是顺带的。
这里,做一个类比,await/async
本质是把 JS 里异步编程思维变成了同步思维,写法上表现出来的特点就是原来的 Callback Hell 被打平了。
总结对比:
-
await/async
把 Callback Hell 干掉了,异步编程思维变成了同步编程思维 - Hooks 把 Wrapper Hell 干掉了,面向生命周期编程变成了面向业务逻辑编程
这里不得不客观地说,HOC 和 Render Props 还是有存在的必要,一方面是支持 React Class,另一方面,它们不光适用于纯逻辑封装,很多时候也适合逻辑 + 组件的封装场景,虽然此时使用 Hooks 也可以,但是会显得啰嗦点。另外,上面诟病的最大的问题 Wrapper Hell,我个人觉得使用 Fragment 也可以基本解决。
状态盒子
首先,React Hooks 的设计是反直觉的,为什么这样说呢?可以先试着问自己:为什么 Hooks 只能在其它 Hooks 的函数或者 React Function 组件里?
在我们的认知里,React 社区一直推崇函数式、纯函数等思想,引入 Hooks 概念后的 Functional Component
变的不再纯了,useXxx
与其说是一条执行语句,不如说是一个声明。声明这里放了一个「状态盒子」,盒子有输入和输出,剩下的内部实现就一无所知,重要的是,盒子是有记忆的,下次执行到此位置时,它有之前上下文信息。
类比「代码」和「程序」的区别,前者是死的,后者是活的。表达式 c = a + b
表示把 a
和 b
累加后的值赋值给 c
,但是如果写成 c := a + b
就表示 c
的值由 a
和 b
相加得到。看起来表述差不多,但实际上,后者隐藏着一个时间的维度,它表示的是一种联系,而不单单是个运算。这在 RxJS 等库中被大量使用。
这种声明目前是通过很弱的 use 前缀标识的(但是设计上会简洁很多),为了不弄错每个盒子和状态的对应关系,书写的时候 Hooks 需要 use 开头且放在顶层作用域,即不可以包裹 if/switch/when/try 等。如果你按文章开头引入了那个 ESLint Plugin 就不用担心会弄错了。
总结
这篇文章可能并没有一个很条理的目录结构,大多是一些个人理解和相关思考。因此,这不能替代你去看真正的文档了解更多。如果你看完后还是觉得废话太多,不知所云,那我希望你至少可以在下面几点上跟作者达成共鸣:
- Hooks 本质是把面向生命周期编程变成了面向业务逻辑编程;
- Hooks 使用上是一个逻辑状态盒子,输入输出表示的是一种联系;
- Hooks 是 React 的未来,但还是无法完全替代原始的 Class。
参考:
https://zhuanlan.zhihu.com/p/92211533
https://segmentfault.com/a/1190000017182184