2019独角兽企业重金招聘Python工程师标准>>>

本文由云+社区发表

作者:Dan Abramov

接触 React Hooks 一定时间的你,也许会碰到一个神奇的问题: setInterval 用起来没你想的简单。

Ryan Florence 在他的推文里面说到:

不少朋友跟我提起,setInterval 和 hooks 一起用的时候,有种蛋蛋的忧伤。

老实说,这些朋友也不是胡扯。刚开始接触 Hooks 的时候,确实还挺让人疑惑的。

但我认为谈不上 Hooks 的毛病,而是 React 编程模型和 setInterval 之间的一种模式差异。相比类(Class),Hooks 更贴近 React 编程模型,使得这种差异更加突出。

虽然有点绕,但是让两者和谐相处的方法,还是有的。

本文就来探索一下,如何让 setInterval 和 Hooks 和谐地玩耍,为什么是这种方式,以及这种方式给你带来了什么新能力


声明:本文采用循序渐进的示例来解释问题。所以有一些示例虽然看起来可以有捷径可走,但是我们还是一步步来。

如果你是 Hooks 新手,不太明白我在纠结啥,不妨读一下 React Hooks 的介绍和官方文档。本文假设读者已经使用 Hooks 超过一个小时。


代码呢?

通过下面的方式,我们可以轻松地实现一个每秒自增的计数器:

import React, { useState, useEffect, useRef } from 'react';function Counter() {let [count, setCount] = useState(0);useInterval(() => {// Your custom logic heresetCount(count + 1);}, 1000);return <h1>{count}</h1>;
}

CodeSandbox 线上示例

上述 useInterval 并不是内置的 React Hook,而是我实现的一个自定义 Hook:

import React, { useState, useEffect, useRef } from 'react';function useInterval(callback, delay) {const savedCallback = useRef();// Remember the latest callback.useEffect(() => {savedCallback.current = callback;});// Set up the interval.useEffect(() => {function tick() {savedCallback.current();}if (delay !== null) {let id = setInterval(tick, delay);return () => clearInterval(id);}}, [delay]);
}

(如果你在错过了,这里也有一个一样的 CodeSandbox 线上示例

我实现的 useInterval Hook 设置了一个计时器,并且在组件 unmount 的时候清理掉了。 这是通过组件生命周期上绑定 setIntervalclearInterval 的组合完成的。

这是一份可以在项目中随意复制粘贴的实现,你甚至可以发布到 NPM 上。

不关心为什么这样实现的读者,就不用继续阅读了。下面的内容是为希望深入理解 React Hooks 的读者而准备的。


哈?! ?

我知道你想什么:

Dan,这代码不对劲。说好的“纯粹 JavaScript”呢?React Hooks 打了 React 哲学的脸?

哈,我一开始也是这么想的,但是后来我改观了,现在,我准备也改变你的想法。开始之前,我先介绍下这份实现的能力。


为什么 useInterval() 是一个更合理的 API?

注意下,useInterval Hook 接收一个函数和一个延时作为参数:

  useInterval(() => {// ...}, 1000);

这个跟原生的 setInterval 非常的相似:

  setInterval(() => {// ...}, 1000);

那为啥不干脆使用 setInterval 呢?

setIntervaluseInterval Hook 最大的区别在于,useInterval Hook 的参数是“动态的”。乍眼一看,可能不是那么明显。

我将通过一个实际的例子来说明这个问题:


如果我们希望 interval 的间隔是可调的:

一个延时可输入的计时器

此时无需手动控制延时,直接动态调整 Hooks 参数就行了。比方说,我们可以在用户切换到另一个选项卡时,降低 AJAX 更新数据的频率。

如果按照类(Class)的方式,怎么通过 setInterval 实现上述需求呢?我折腾出这个:

class Counter extends React.Component {state = {count: 0,delay: 1000,};componentDidMount() {this.interval = setInterval(this.tick, this.state.delay);}componentDidUpdate(prevProps, prevState) {if (prevState.delay !== this.state.delay) {clearInterval(this.interval);this.interval = setInterval(this.tick, this.state.delay);}}componentWillUnmount() {clearInterval(this.interval);}tick = () => {this.setState({count: this.state.count + 1});}handleDelayChange = (e) => {this.setState({ delay: Number(e.target.value) });}render() {return (<><h1>{this.state.count}</h1><input value={this.state.delay} onChange={this.handleDelayChange} /></>);}
}

(CodeSandbox 在线示例)

太熟悉了!

那改成使用 Hooks 怎么实现呢?

???表演开始了!

function Counter() {let [count, setCount] = useState(0);let [delay, setDelay] = useState(1000);useInterval(() => {// Your custom logic heresetCount(count + 1);}, delay);function handleDelayChange(e) {setDelay(Number(e.target.value));}return (<><h1>{count}</h1><input value={delay} onChange={handleDelayChange} /></>);
}

(CodeSandbox 线上示例)

没了,就这么多!

不用于 class 实现的版本,useInterval Hook “升级到”支持到支持动态调整延时的版本,没有增加任何复杂度。

使用 useInterval 新增动态延时能力,几乎没有增加任何复杂度。这个优势是使用 class 无法比拟的。

// 固定延时
useInterval(() => {setCount(count + 1);
}, 1000);// 动态延时
useInterval(() => {setCount(count + 1);
}, delay);

useInterval 接收到另一个 delay 的时候,它就会重新设置计时器。

我们并没有通过执行代码来设置或者清理计时器,而是声明了具有特定延时的计时器 - 这是我们实现的 useInterval 的根本原因。

如果想临时暂停计时器呢?我可以这样来:

const [delay, setDelay] = useState(1000);
const [isRunning, setIsRunning] = useState(true);useInterval(() => {setCount(count + 1);
}, isRunning ? delay : null);

(线上示例)

这就是 Hooks 和 React 再一次让我兴奋的原因。我们可以把原有的调用式 API,包装成声明式 API,从而更加贴切地表达我们的意图。就跟渲染一样,我们可以描述当前时间每个点的状态,而无需小心翼翼地通过具体的命令来操作它们。


到这里,我希望你已经确信 useInterval Hook 是一个更好的 API - 至少在组件层面使用的时候是这样。

可是为什么在 Hooks 里使用 setInterval 和 clearInterval 这么让人恼火? 回到刚开始的计时器例子,我们尝试手动去实现它。


第一次

最简单的,渲染初始状态:

function Counter() {const [count, setCount] = useState(0);return <h1>{count}</h1>;
}

现在我希望它每秒定时更新。我准备使用 useEffect() 并且返回一个清理方法,因为它是一个需要清理的 Side Effect:

function Counter() {let [count, setCount] = useState(0);useEffect(() => {let id = setInterval(() => {setCount(count + 1);}, 1000);return () => clearInterval(id);});return <h1>{count}</h1>;
}

(查看 CodeSandbox 线上示例)

看起来很简单?

然而,这段代码有个诡异的行为。

React 默认会在每次渲染时,都重新执行 effects。这是符合预期的,这机制规避了早期在 React Class 组件中存在的一系列问题。

通常来说,这是一个好特性,因为大部分的订阅 API 都允许移除旧的订阅并添加一个新的订阅来替换。但是,这不包括 setInterval。调用了 clearInterval 后重新 setInterval 的时候,计时会被重置。如果我们频繁重新渲染,导致 effects 频繁执行,计时器可能根本没有机会被触发!

通过使用在一个更小的时间间隔重新渲染我们的组件,可以重现这个 BUG:

setInterval(() => {// 重新渲染导致的 effect 重新执行会让计时器在调用之前,// 就被 clearInterval() 清理掉,之后 setInterval()// 重新设置的计时器,会重新开始计时ReactDOM.render(<Counter />, rootElement);
}, 100);

(查看这个 BUG 的线上示例)


第二次

部分读者可能知道,useEffect 允许我们控制重新执行的实际。通过在第二个参数指定依赖数组,React 就会只在这个依赖数组变更的时候重新执行 effect。

useEffect(() => {document.title = `You clicked ${count} times`;
}, [count]);

如果我们希望 effect 只在组件 mount 的时候执行,并且在 unmount 的时候清理,我们可以传递空数组 [] 作为依赖。

但是!不是特别熟悉 JavaScript 闭包的读者,很可能会犯一个共性错误。我来示范一下!(我们在设计 lint 规则来帮助定位此类错误,不过现在还没有准备好。)

第一次的问题在于,effect 的重新执行导致计时器太早被清理掉了。如果不重新执行它们,也许可以解决这个问题:

function Counter() {let [count, setCount] = useState(0);useEffect(() => {let id = setInterval(() => {setCount(count + 1);}, 1000);return () => clearInterval(id);}, []);return <h1>{count}</h1>;
}

如果这样实现,计时器更新到 1 之后,就停止不动了。(查看这个 BUG 的线上示例)

发生了啥?

问题在于,useEffect 使用的 count 是在第一次渲染的时候获取的。 获取的时候,它就是 0。由于一直没有重新执行 effect,所以 setInterval 在闭包中使用的 count 始终是从第一次渲染时来的,所以就有了 count + 1 始终是 1 的现象。呵呵哒!

我感觉你已经开始怼天怼地了。Hooks 是什么鬼嘛!

解决这个问题的一个方案,是把 setCount(count + 1) 替换成“更新回调”的方式 setCount(c => c + 1)。从回调参数中,可以获取到最新的状态。此非万全之策,新的 props 就无法读取到。

另一个解决方案是使用 useReducer()。此方案更为灵活。在 reducer 内部,可以访问当前的状态,以及最新的 props。dispatch 方法本身不会改变,所以你可以在闭包里往里面灌任何数据。使用 useReducer() 的一个限制是,你不能在内部触发 effects。(不过,你是可以通过返回一个新 state 来触发一些 effect)。

为何如此艰难?


阻抗不匹配

这个术语(译者注:术语原文为 "Impedance Mismatch")在很多地方被大家使用,Phil Haack 是这样解释的:

有人说数据库来自火星,对象来自金星。数据库不能天然的和对象模型建立映射关系。这就像尝试将两块磁铁的 N 极挤在一起一样。

我们此处的“阻抗不匹配”,说的不是数据库和对象。而是 React 编程模型,与命令式的 setInterval API 之间的不匹配。

一个 React 组件可能会被 mount 一段时间,并且经历多个不同的状态,不过它的 render 结果一次性地描述了所有这些状态

// 描述了每一次渲染的状态
return <h1>{count}</h1>

同理,Hooks 让我们声明式地使用一些 effect:

// 描述每一个计数器的状态
useInterval(() => {setCount(count + 1);
}, isRunning ? delay : null);

我们不需要去设置计时器,但是指明了它是否应该被设置,以及设置的间隔是多少。我们事先的 Hook 就是这么做的。通过离散的声明,我们描述了一个连续的过程。

相对应的,setInterval 却没有描述到整个过程 - 一旦你设置了计时器,它就无法改变了,只能清除它。

这就是 React 模型和 setInterval API 之间的“阻抗不匹配”。


React 组件的 props 和 state 会变化时,都会被重新渲染,并且把之前的渲染结果“忘记”的一干二净。两次渲染之间,是互不相干的。

useEffect() Hook 同样会“遗忘”之前的结果。它清理上一个 effect 并且设置新的 effect。新的 effect 获取到了新的 props 和 state。所以我们第一次的事先在某些简单的情况下,是可以执行的。

但是 setInterval() 不会 “忘记”。 它会一直引用着旧的 props 和 state,除非把它换了。但是只要把它换了,就没法不重新设置时间了。

等会,真的不能吗?


Refs 是救星!

先把问题整理下:

  • 第一次渲染的时候,使用 callback1 进行 setInterval(callback1, delay)
  • 下一次渲染的时候,使用 callback2 可以访问到新的 props 和 state
  • 我们无法用 callback2 替换掉 callback1 但是又不重设计时器

如果我们压根不替换计时器,而是传入一个 savedCallback 变量,始终指向最新的计时器回调呢??

现在我们的方案看起来是这样的:

  • 设置计时器 setInterval(fn, delay),其中 fn 调用 savedCallback
  • 第一次渲染,设置 savedCallbackcallback1
  • 第二次渲染,设置 savedCallbackcallback2
  • ???
  • 行了

可变的 savedCallback 需要在多次渲染之间“持久化”,所以不能使用常规变量。我们需要像类似实例字段的手段。

从 Hooks 的 FAQ 中,我们得知 useRef() 可以帮我们做到这点:

const savedCallback = useRef();
// { current: null }

(你可能已经对 React 的 DOM refs 比较熟悉了。Hooks 引用了相同的概念,用于持有任意可变的值。一个 ref 就行一个“盒子”,可以放东西进去。)

useRef() 返回了一个字面量,持有一个可变的 current 属性,在每一次渲染之间共享。我们可以把最新的计时器回调保存进去。

function callback() {// 可以读取到最新的 state 和 propssetCount(count + 1);
}// 每次渲染,保存最新的回调到 ref 中
useEffect(() => {savedCallback.current = callback;
});

后续就可以在计时器回调中调用它了:

useEffect(() => {function tick() {savedCallback.current();}let id = setInterval(tick, 1000);return () => clearInterval(id);
}, []);

由于传入了 [],我们的 effect 不会重新执行,所以计时器不会被重置。另一方面,由于设置了 savedCallback ref,我们可以获取到最后一次渲染时设置的回调,然后在计时器触发时调用。

再看一遍完整的实现:

function Counter() {const [count, setCount] = useState(0);const savedCallback = useRef();function callback() {setCount(count + 1);}useEffect(() => {savedCallback.current = callback;});useEffect(() => {function tick() {savedCallback.current();}let id = setInterval(tick, 1000);return () => clearInterval(id);}, []);return <h1>{count}</h1>;
}

(查看 CodeSandbox 线上示例)


提取为自定义 Hook

不得不承认,上面的代码有点迷。各种花里胡哨的操作让人费解不说,还有可能让 state 和 refs 与其它逻辑里的搞混。

我认为,虽然 Hooks 相比 Class 提供了更底层的能力 - 不过 Hooks 的牛逼在于允许我们重组、抽象后创造出声明语意更优的 Hooks

事实上,我就想这样来写:

function Counter() {const [count, setCount] = useState(0);useInterval(() => {setCount(count + 1);}, 1000);return <h1>{count}</h1>;
}

于是我把我的实现核心拷贝到自定义 Hook 中:

function useInterval(callback) {const savedCallback = useRef();useEffect(() => {savedCallback.current = callback;});useEffect(() => {function tick() {savedCallback.current();}let id = setInterval(tick, 1000);return () => clearInterval(id);}, []);
}

延时值 1000 是硬编码的,把它参数化:

function useInterval(callback, delay) {

在设置计时器的时候使用:

let id = setInterval(tick, delay);

现在 delay 可能在多次渲染之间变更,我需要把它声明为计时器 effect 的依赖:

useEffect(() => {function tick() {savedCallback.current();}let id = setInterval(tick, delay);return () => clearInterval(id);
}, [delay]);

慢着,我们之前不是为了避免计时器重设,才传入了一个 [] 的吗?不完全是。我们只是希望 Hooks 不要在 callback 变更的重新执行。如果 delay 变更了,我们是想要重新启动计时器的。

现在来看下我们的代码是不是能跑:

function Counter() {const [count, setCount] = useState(0);useInterval(() => {setCount(count + 1);}, 1000);return <h1>{count}</h1>;
}function useInterval(callback, delay) {const savedCallback = useRef();useEffect(() => {savedCallback.current = callback;});useEffect(() => {function tick() {savedCallback.current();}let id = setInterval(tick, delay);return () => clearInterval(id);}, [delay]);
}

(读者可以在 CodeSandbox 上试一下)

棒棒的!现在,我们可以无需关注实现细节,在任何组件里面需要的时候,直接使用 useInterval() 了。

Bonus: 暂停计时器

我们希望在给 delaynull 的时候暂停计时器:

const [delay, setDelay] = useState(1000);
const [isRunning, setIsRunning] = useState(true);useInterval(() => {setCount(count + 1);
}, isRunning ? delay : null);

怎么实现?简单:不设置计时器就可以了。

useEffect(() => {function tick() {savedCallback.current();}if (delay !== null) {let id = setInterval(tick, delay);return () => clearInterval(id);}
}, [delay]);

(CodeSandbox 线上示例)

就这样了。这段代码可以处理各种可能的变更了:延时值改变、暂停和继续。虽然 useEffect() API 需要我们前期花更多的精力进行设置和清理工作,添加新能力却是轻松了。

Bonus: 有趣的 Demo

这个 useInterval() Hook 其实很好玩。现在 side effects 是声明式的,所以组合使用变得轻松多了。

比方说,我们可以使用一个计时器来控制另一个计时器的 delay:

自动加速的计时器

function Counter() {const [delay, setDelay] = useState(1000);const [count, setCount] = useState(0);// Increment the counter.useInterval(() => {setCount(count + 1);}, delay);// Make it faster every second!useInterval(() => {if (delay > 10) {setDelay(delay / 2);}}, 1000);function handleReset() {setDelay(1000);}return (<><h1>Counter: {count}</h1><h4>Delay: {delay}</h4><button onClick={handleReset}>Reset delay</button></>);
}

(CodeSandbox 线上示例)

总结

Hooks 需要我们慢慢适应 - 尤其是在面对命令式和声明式代码的区别时。你可以创造出像 React Spring 一样强大的声明式抽象,但是他们复杂的用法偶尔会让你紧张。

Hooks 还很年轻,还有很多我们可以研究和对比的模式。如果你习惯于按照“最佳实践”来的话,大可不必着急使用 Hooks。社区还需时间来尝试和挖掘更多的内容。

使用 Hooks 的时候,涉及到类似 setInterval() 的 API,会碰到一些问题。阅读本文后,希望读者能够理解并且解决它们,同时,通过创建更加语义化的声明式 API,享受其带来的好处。

此文已由腾讯云+社区在各渠道发布

获取更多新鲜技术干货,可以关注我们腾讯云技术社区-云加社区官方号及知乎机构号

转载于:https://my.oschina.net/qcloudcommunity/blog/3012629

通过 React Hooks 声明式地使用 setInterval相关推荐

  1. 为什么说 Compose 的声明式代码最简洁 ?Compose/React/Flutter/SwiftUI 语法对比

    前言 Comopse 与 React.Flutter.SwiftUI 同属声明式 UI 框架,有着相同的设计理念和相似的实现原理,但是 Compose 的 API 设计要更加简洁.本文就这几个框架在代 ...

  2. React Hooks 分享

    目录 一,什么是Hooks 二,为什么要使用Hooks 三,React hooks 四, useState 使用及实现 五,useEffect 使用及实现 六,如何实现多个useState, useE ...

  3. 其它React Hooks以及自定义Hooks和第三方Hooks库的使用

    - useMemo 作用:相当于vue中的计算属性.用于组件中复杂运算的优化 特点:当它所关联的声明式变量发生变化,它才会重新运算:反之,不会 语法:const result = useMemo(fn ...

  4. 【译】什么是React Hooks

    原文:What are React Hooks? 作者:Robin Wieruch 译者:博轩 React Hooks 于 2018年10月的React Conf 中引入,作为在 React 函数组件 ...

  5. (十三)react hooks

    react hooks react hooks 出几道react hooks面试题 class组件存在哪些问题 用useState实现state和setState功能 用useEffect模拟组件生命 ...

  6. React Hooks 完全使用指南

    大家好,我是若川.最近组织了源码共读活动,感兴趣的可以点此加我微信 ruochuan12 参与,每周大家一起学习200行左右的源码,共同进步.同时极力推荐订阅我写的<学习源码整体架构系列> ...

  7. vue概述、vue文件特点、vue核心思想、双向数据流、单文件、启动一个vue项目、声明式渲染

    vue介绍: Vue:当前较火的MVVM框架,轻量.简介.高效.组件化.数据驱动,模块和渲染函数的弹性选择,简单的语法及项目创建,渲染速度极快,基于Virtual Dom,利用虚拟DOM实现快速渲染: ...

  8. 声明式和命令式编程_命令式与声明式编程

    声明式和命令式编程 At this point you've undoubtedly heard about imperative programming vs declarative program ...

  9. 通过构建Paint App学习React Hooks

    According to people in the know, React Hooks are hot, hot, hot. In this article, we follow Christian ...

最新文章

  1. visual studio 2010 professional , premium, ultimate各版本功能对比
  2. springboot原生mysql写法_【Rainbond最佳实践】Spring Boot框架配置MySQL
  3. 学术墙报模板_【学院动态】水产学院2020年研究生学术年会科研墙报评比活动开始...
  4. linux线程和进程详解,linux 进程和线程简介
  5. 百度全面开放HTTPS之我见
  6. synchronized概念
  7. c# out原理 ref_移植贪吃蛇——从C#到C++
  8. 如何把网上下载的前端页面在Spring Boot中跑起来(CSS,JavaScript,程序运行等路径设置)
  9. Free Mybatis plugin
  10. 成功送小米上市的米粉们,反被 P2P 爆雷炸成了灰!
  11. python交互模式什么意思_python交互模式是什么
  12. 牛客暑期多校第五场A:gpa题解(简单01分数规划)
  13. MyBatis教程看这一篇就够啦,简单又全面(IDEA版)
  14. 08-05-09pe_xscan 增加IE版本检测
  15. 前端书籍推荐之《精通JavaScript+jQuery》
  16. STM32CubeMX系列TIM
  17. linux串口结构termios,linux串口termios.doc
  18. Raspberry 静态IP配置
  19. android手机获取系统短信sqlite数据库并查看内容
  20. 【点云系列】基于图结构的点云快速重采样 翻译

热门文章

  1. Win 7/10 安装Oracle 11g
  2. mysql语句将日期转换为时间戳的方法
  3. 数据类型_插入数据_选取数据_修改数据——删除行
  4. poj2987最大权闭包(输出最少建塔个数)
  5. hdu4291 暴力循环节+矩阵快速幂
  6. 【C 语言】二级指针作为输入 ( 二维数组 | 二维数组内存大小计算 | 指针跳转步长问题 )
  7. 【C 语言】内存四区原理 ( 栈内存属性增长方向 | 栈内存开口方向 | 代码示例 )
  8. 【开发环境】Windows 安装 PyCharm 开发环境 ( 下载 PyCharm | 安装 PyCharm | 在 PyCharm 中创建 Python 工程 )
  9. 【EventBus】EventBus 源码解析 ( EventBus 构建 | EventBus 单例获取 | EventBus 构造函数 | EventBus 构建者 )
  10. 【组合数学】递推方程 ( 递推方程示例 1 | 列出递推方程 )