React Hooks 设计思想
(给前端大全加星标,提升前端技能)
作者:繁星
https://zhuanlan.zhihu.com/p/103692400
聊聊 React 的 class 组件
组件是 React 应用的构建块,自上而下的数据流结合组件可以将 UI 解构为独立且可复用的单元。组件主要做的事情主要有以下三点:
将传入的 props 和 内部 state 渲染到页面上;
管理内部 state,并根据 state 变化渲染出最新的结果;
处理与组件外部的交互;
假如现在有一个新闻列表页面,列表的每一项都包含有标题、概要、详情和缩略图,如图所示:
只是渲染内容。如果不考虑查看详情这个交互,新闻列表的每一项是很纯的,也就是 props 传入什么数据,就能渲染出一一对应的结果:
letNewsItem= (props) => {
return(
<li>
<img src={props.imgUrl} />
<div>
<h2>{props.title}</h2>
<p>{props.summary}</p>
<p style={{display: 'none'}}>{props.detail}</p>
<a>查看详情</a>
</div>
</li>
)
}
要考虑查看详情这个交互,就必须在 NewsItem 里加入一个 isDetailShow 的 state 来表示新闻摘要与详情的互斥显示。到目前为止,NewsItem 还是很纯的,并没有和外部有交互。
要实现新闻图片的懒加载,只有 NewsItem 进入可视区时才将 img 的 src 替换为真实的 url,这就要求 NewsItem 必须监听浏览器事件,并在组件被卸载时移除这些监听(防止内存泄漏)。此时,NewsItem 便不是一个纯的组件了,因为与外部有了交互,这种与外部的交互被称为副作用(函数式编程里没有任何副作用的函数被称为纯函数)。
组件的副作用是不可避免的,最常见的有 fetch data,订阅事件,进行 DOM 操作,使用其他 JavaScript 库(比如 jQuery,Map 等)。在这个例子中,NewsItem 并没有 fetch data,相关职责由不纯的父组件来承担。
综上,我们的组件需要 state 来存储一定的逻辑状态,并且需要可以访问并更改 state 的方法函数。
class 就是一个很好的表现形式:要渲染的内容(props 或 state)放在类的属性里,那些处理用户交互的回调函数和生命周期函数放在类的方法里。方法与属性通过 class 的形式建立了关联,有能力访问和更改属性。回调函数通过更改对应属性处理用户操作,生命周期函数则给予开发者处理组件与外部的交互能力(处理副作用)。
这样通过 class 组件,ReactDOM 就能做到渲染数据,绑定事件,并在不同的生命周期调用开发者所编写的代码,按需求将数据渲染成 HTML DOM,然后被浏览器渲染展示出来。
将组件渲染粗暴地分为若干个阶段,通过生命周期函数处理副作用会带来一些问题:
重复逻辑,被吐槽最多的例子如下:
async componentDidMount() {
const res = awaitget(`/users`);
this.setState({ users: res.data });
};
async componentDidUpdate(prevProps) {
if(prevProps.resource !== this.props.resource) {
const res = awaitget(`/users`);
this.setState({ users: res.data });
}
};
同一职责代码有可能需要被强行分拆到不同的生命周期,例如同一个事件的订阅与取消订阅;
一部分代码被分割到不同生命周期中,会导致组件没有优雅的复用 state 逻辑代码的能力,高阶组件或 render props 等模式引入了嵌套,复杂且不灵活;
越来越多逻辑被放入不同生命周期函数中,这种组织方式导致代码越来越复杂难懂;
除了这些,class 组件中的 this 也常被人们拿出来吐槽。那么,是否有更优雅的设计呢?
闭包为什么在某种程度上能取代 class?
我们的程序在执行的时候主要做了两件事:
I/O读写,声明变量,系统为之分配内存,程序执行时读取或更改变量所存储的数据;
处理运算,程序的结构(顺序、分支与循环)以及对数据的运算等,也就是程序的逻辑实现;
为了实现复用,我们将具有特定单一功能的逻辑放在函数里,这样既可以消灭掉重复代码,又可以让我们在思考问题时能够进行合理的分解,降低代码复杂度。
但是只有函数是不够的,函数是一个标准的输入-加工-输出模型,输入和输出的都是变量里所存储的数据,当一个系统的复杂度高到一定程度的时候,将函数与其所操作的数据(环境)关联起来就很有必要了。
注:函数式编程要求把I/O限制到最小,干掉所有不必要的读写行为,保持计算过程的单纯性。
最常见的将变量与函数关联起来方式有:
利用全局变量,在全局变量里建一个大表,将特定函数与表中对应字段关联起来;
面向对象编程。对象允许我们将某些数据(对象的属性)与一个或多个函数方法相关联;
闭包;
函数对于其词法环境(lexical environment)的引用共同构成闭包(closure),简单说,一个函数内部能够访问到函数外的变量,如果这 个函数内部引用了其外部的变量,且自身又被别处引用,那这个不会被销毁的函数就和它所引用的外部变量一起构成闭包。例如:
// 模块化下可以将 makeCounter 内部代码放在 makeCounter.js 中,并将 return 改为 export
const makeCounter = () => {
let privateCounter = 0;
function changeBy(val) {
privateCounter += val;
}
return{
increment: function() {
changeBy(1);
},
decrement: function() {
changeBy(-1);
},
value: function() {
return privateCounter;
}
}
};
// 使用 makeCounter
const counter = makeCounter();
console.log(counter.value()); /* logs 0 */
counter.increment();
counter.increment();
console.log(counter.value()); /* logs 2 */
counter.decrement();
console.log(counter.value()); /* logs 1 */
看,我们使用闭包将变量 privateCounter 与几个函数关联了起来,从这点来讲能力与面向对象编程相同。
组件的 API 设计
API 的核心在于表达能力,对于 React 组件来说,就是如何让开发者将需求良好地表达出来,然后被 ReactDOM 识别并渲染。
class 组件和 functional 组件所要表达的内容是是一样的,只是表现形式不同。它们都努力做到了一点:将存储组件状态的 state 与处理这些 state 的方法关联起来。具体一点说就是一下三点:
state 存储组件的状态,并可被渲染成 HTML DOM;
用来处理用户操作事件的回调函数可以访问并变更 state,触发组件重新渲染;
用来处理与组件外部交互(副作用)的函数可以访问并变更 state,触发组件重新渲染;
2 中函数的执行是确定的,用户的操作触发某个事件后就会执行相应的回调函数,更改 state,触发新的渲染。开发者需要有能力控制 3 中的函数执行,确定要不要执行以及在什么时候执行。在 class 组件中,生命周期函数给开发者提供了这种控制能力。
那么,如果我们通过一套 API 设计实现以上三点且避开 class 组件的缺陷,提供更好的分离关注点能力,让代码复用更加简易,是不是一件很值得期待的事情呢?React Hooks 就是满足这些要求的新设计。
React Hooks 原理
先来看一个使用 React Hooks 的例子:
functionCounter() {
const[counter, setCounter] = useState(0);
function increment() {
setCounter(counter+1);
}
function decrement() {
setCounter(counter-1);
}
return(
<div className="content">
<h1>MyAwesomeCounter</h1>
<hr/>
<h2 className="count">{counter}</h2>
<div className="buttons">
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
</div>
</div>
);
}
是的,你看到了这个例子与闭包例子中的 makeCounter 十分相似。makerCounter 使用程序控制并通过 console 出结果,Counter 通过用户点击控制,输出包含结果且可以被渲染的组件。除了这点不同,其他部分代码原理是完全一致的,只是 Hook 进行了一些封装,让开发者编写代码体验更好。
我们来看下 useState 的简化实现:
// React useState hooks
constReact= (function() {
let hooks = [];
let idx = 0;
return{
render(Component) {
const C = Component();
C.render();
idx = 0; // reset for next render
return C;
},
useState(initVal) {
const state = hooks[idx] || initVal;
const _idx = idx;
const setState = newVal => {
hooks[_idx] = newVal;
};
idx++;
return[state, setState];
}
};
})();
// Component which use useState
const{ useState, render } = React;
functionCounter() {
const[count, setCount] = useState(0);
const[text, setText] = useState('apple');
return{
render() {
console.log(`text: ${text}, count: ${count}`);
},
click() {
setCount(count + 1);
},
type(type) {
setText(type)
}
};
}
// simulate render
const counter = render(Counter); // text: apple, count: 0
counter.click();
render(Counter); // text: apple, count: 1
counter.type("pear");
render(Counter); //text: pear, count: 1
代码很简单,这里不做解读,这里重点说几点:
React 的 API 设计能力确实不错,用解构赋值将 state 和对应的 setState 放在一起,简洁明了;
useState 的第一次执行可以取代 class 的构造函数初始化过程,值为 useState 的参数 initVal,运行后存储在闭包中所对应的 hooks[index] 变量里。从第二次 render 时开始访问 hooks[index] 而不是 initVal;
初始化时每调用一次 useState ,闭包里 hooks 便会递增分配对应的 index key 来存储对应的值。render 结束后 index 会重置为 0,下一次 render 执行 useState 时会按照相同顺序访问 hooks[index];
正是因为 hooks 是这样实现的,我们在调用 hooks 的时候必须要严格保证每一次 render 都能获得一致的执行顺序,所以必须要做到:
不要在循环、条件语句或嵌套函数中调用 Hooks;
只能在 React 函数中调用 Hooks;
到目前为止,我们已经可以通过 hooks 的形式管理 state,并通过调用包含 setState 的回调函数处理用户操作。剩下要解决的便是副作用的问题,useEffect 是 hooks 所提供的方案,下面来看一下 useEffect 的简化实现原理(并不完整):
useEffect(cb, depArray) {
const hasNoDeps = !depArray;
hooks[idx] = hooks[idx] || {};
const{deps, cleanup} = hooks[idx]; // undefined when first render
const hasChanged = deps
? !depArray.every((el, i) => el === deps[i])
: true;
if(hasNoDeps || hasChanged) {
cleanup && cleanup();
hooks[idx].cleanup = cb();
hooks[idx].deps = depArray;
}
idx++;
}
完整简化代码地址:https://stackblitz.com/edit/behind-react-hook
useEffect 提供了一个函数(上面代码中的 cb)运行的容器,这个容器有以下几个特点:
useEffect 容器在每次 render 后运行;
不区分 Mounting 和 Updating ,每次 render 后都会执行容器 useEffect;
cb 运行时可以访问到 Functional 组件的内部变量(包含通过 useState 生成的任何 state 和 setState);
cb 是否执行取决于依赖数组里的依赖项是否发生变化。如果没有依赖数组,每次 render 后都会调用 cb。如果依赖数组为[],仅在第一次 render 后调用;
容器中的 cb 执行后可以返回一个函数 cleanup,在下一次执行 cb 之前会调用 cleanup;
在 Unmounting 时如果有返回的 cleanup,也会调用(简化代码没有实现);
通过将副作用相关代码放在 useEffect 的 cb 中,并在 cb 返回的函数里移除副作用,我们可以在一个 useEffect 中实现任何想要的生命周期控制:
依赖数组为 [] 可以实现仅在 Mouting 时执行;
不写依赖数组可以实现 Mouting 和 Updating 时执行;
cb 返回的 cleanup 函数可以执行 Unmounting 时执行的代码;
可以通过依赖数组里的内容是否变更来控制 cb 是否执行;
这种设计最大的好处就是我们可以将单一职责的代码放在一个独立的 useEffect 容器里,而不是粗暴地将它们拆分在各个生命周期函数中。同时也要注意的是,useEffect 的 cb 必须要返回一个 cleanup 函数或者 undefined,所以不可以是 async 函数;
React Hooks 的优点
通过 Hooks 我们可以对 state 逻辑进行良好的封装,轻松做到隔离和复用,优点主要体现在:
复用代码更容易:hooks 是普通的 JavaScript 函数,所以开发者可以将内置的 hooks 组合到处理 state 逻辑的自定义 hooks中,这样复杂的问题可以转化一个单一职责的函数,并可以被整个应用或者 React 社区所使用;
使用组合方式更优雅:不同于 render props 或高阶组件等的模式,hooks 不会在组件树中引入不必要的嵌套,也不会受到 mixins 的负面影响;
更少的代码量:一个 useEffect 执行单一职责,可以干掉生命周期函数中的重复代码。避免将同一职责代码分拆在几个生命周期函数中,更好的复用能力可以帮助优秀的开发者最大限度降低代码量;
代码逻辑更清晰:hooks 帮助开发者将组件拆分为功能独立的函数单元,轻松做到“分离关注点”,代码逻辑更加清晰易懂;
单元测试:处理 state 逻辑的自定义 hooks 可以被独立进行单元测试,更加可靠;
本文主要介绍了 React Hooks 设计思想和优点,但 hooks 也是有不少”坑点“的,我们在使用的时候要利用好优点,努力避开”坑点“。后面我会单独写一篇文章来介绍 React Hooks 的实践。
觉得本文对你有帮助?请分享给更多人
关注「前端大全」加星标,提升前端技能
好文章,我在看❤️