一个基本的定时器按钮设计需要这么两个功能
- 点击触发倒计时
- 倒计时结束自动恢复
我们可以把上面的功能抽象成 React Hook 函数。
function useTimer() {
const [count, setCount] = React.useState(0);
React.useEffect(() => {
if (count <= 0) {
return;
}
const handler = setTimeout(() => {
setCount(prev => prev - 1);
});
return () => {
clearTimeout(handler);
}
}, [count]);
return {
count,
start(second) {
setCount(second);
}
};
}
在组件中调用 useTimer。
function Compo() {
const {count, start} = useTimer();
const press = async () => {
start(60);
// 调用一些接口
await fetch();
}
return (
<Button onPress={press} disabled={count !== 0}>
{count === 0 ? "press me" : `${count} s`}
</Button>
);
}
但是,这样就足够了吗?反正我是不够的。
功能增强
上面的设计存在几个问题:
- 无法恢复
- 无法退出计时
- 没有精细化的生命周期
无法恢复
当组件卸载时,重新加载组件的时候,无法恢复到原来的状态。这个解决方法是存结束的时间戳。
// 使用这个库来存时间戳
// npm install @react-native-async-storage/async-storage
import AsyncStorage from "@react-native-async-storage/async-storage";
function useTimer(id) {
const [count, setCount] = React.useState(0);
// 使用结束时的时间戳
React.useEffect(() => {
(async () => {
// 使用结束时的时间戳
const timestamp = parseInt(await AsyncStorage.getItem(id), 10);
// 判断时间戳是否存在
if (Number.isNaN(timestamp)) {
return;
}
const current = Date.now();
const second = Math.floor((timestamp - current) / 1000);
// 判断时间戳是否过期了
if (second <= 0) {
return;
}
setCount(second);
})();
}, []);
React.useEffect(() => {
if (count <= 0) {
// 移除相关存储对象
AsyncStorage.removeItem(id);
return;
}
const handler = setTimeout(() => {
setCount(prev => prev - 1);
}, 1000);
return () => {
clearTimeout(handler);
}
}, [count]);
return {
count,
start(second) {
const timestamp = Date.now() + second * 1000;
// 存结束的时间戳
AsyncStorage.setItem(id, timestamp.toString());
setCount(second);
}
};
}
在这里,增加了一个id参数,这个参数的目的是为了存储时间戳的标识,建议用组件名。
function Compo() {
...
const {count, start} = useTimer("Compo");
...
}
在 React Native 中,当应用在后台时,系统会中断应用的定时器。这样的中断过程并不会触发组件卸载,那么即便恢复过来,也会有很明显的误差。所以我们需要使用通过AppState 来获取应用状态,并记录下应用退出时的时间戳。
import { AppState } from "react-native";
function useAppState(fn, deps = []) {
const appState = React.useRef();
React.useEffect(() => {
const subscription = AppState.addEventListener("change", (nextState) => {
fn(appState.current, nextState);
appState.current = nextState;
});
return () => {
subscription?.remove();
};
}, deps);
}
function useAppTimeOffset() {
const timestampRef = React.useRef(0);
useAppState((state, nextState) => {
if (state === "active" && nextState.match(/inactive|background/)) {
timestampRef.current = Date.now();
}
});
return () => {
let offset = 0;
if (timestampRef.current > 0) {
offset = Date.now() - timestampRef.current;
timestampRef.current = 0;
}
return Math.floor(offset / 1000);
};
}
当 userTimer 中的定时器恢复时,就能计算出来应用在后台经过了多久。
function userTimer(id) {
...
const offset = useAppTimeOffset();
React.useEffect(() => {
...
const handler = setTimeout(() => {
setCount(prev => prev - 1 - offset());
}, 1000);
...
}, []);
}
无法退出计时
针对这一点,可以增加一个结束调用。
function useTimer(id) {
...
return {
...
end() {
AsyncStorage.removeItem(id);
setCount(0);
}
};
}
一般来说,当组件执行的相关提交操作成功,可以判定结束。
function Compo() {
...
const { count, start, end } = useTimer("Compo");
const submit = async () => {
await fetch("submit");
end();
}
...
}
没有精细化的生命周期
这一点怎么理解呢?可以直接看定时器的生命周期图。
一般来说,定时器的有这三个状态:
- 关闭
- 开启
- 暂停
为了实现上述状态,我们需要规定计数器的初始值为-1,只有这样才能保证开启和关闭状态不冲突。针对暂停状态,我们需要得到组件卸载的钩子函数。
下面给出代码。
function useTimer({ id, onStart, onPause, onResume, onEnd }) {
...
// 使用 -1 作为起始值
const [count, setCount] = React.useState(-1);
// 恢复期注意的是,有成功和失败两个状态
React.useEffect(() => {
(async () => {
// 使用结束时的时间戳
const timestamp = parseInt(await AsyncStorage.getItem(id), 10);
const current = Date.now();
const second = Math.floor((timestamp - current) / 1000);
// 判断时间戳是否过期了
if (Number.isNaN(second) || second <= 0) {
setCount(-1);
onResume?.(false);
} else {
setCount(second);
onResume?.(true);
}
})();
}, []);
...
React.useEffect(() => {
if (count < 0) {
return;
}
if (count === 0) {
onEnd?.();
// 回到初始值
setCount(-1);
return;
}
...
}, [count]);
useTimerPause(count, onPause);
...
return {
...
start() {
...
onStart?.();
},
end() {
...
onEnd?.();
// 回到初始值
setCount(-1);
}
}
}
// 注册定时器的卸载函数,只有组件卸载的时候且定时器在运行中才进入暂停
function useTimerPause(count, onPause) {
const countRef = React.useRef(-1);
React.useEffect(() => {
countRef.current = count;
}, [count]);
React.useEffect(() => () => {
if (countRef.current <= 0) {
return;
}
onPause?.();
}, []);
}
在组件中,就可以这样调用。
function Compo() {
...
const [authCodeSended, setAuthCodeSended] = React.useState(false);
const {
count,
start,
end
} = useTimer({
id,
onStart() {
setAuthCodeSended(true);
},
onResume() {
setAuthCodeSended(true);
},
onEnd() {
setAuthCodeSended(false);
}
});
...
const press = () => {
await fetch("authcode");
// 调用成功才执行
start(60);
};
const submit = () => {
await fetch("submit");
end();
}
return (
...
)
}
完整代码
import React from "react";
import { AppState } from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
function useAppState(fn, deps = []) {
const appState = React.useRef();
React.useEffect(() => {
const subscription = AppState.addEventListener("change", (nextState) => {
fn(appState.current, nextState);
appState.current = nextState;
});
return () => {
subscription?.remove();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
}
function useAppTimeOffset() {
const timestampRef = React.useRef(0);
useAppState((state, nextState) => {
if (state === "active" && nextState.match(/inactive|background/)) {
timestampRef.current = Date.now();
}
});
return () => {
let offset = 0;
if (timestampRef.current > 0) {
offset = Date.now() - timestampRef.current;
timestampRef.current = 0;
}
return Math.floor(offset / 1000);
};
}
function useTimerPause(count, onPause) {
const countRef = React.useRef(-1);
React.useEffect(() => {
countRef.current = count;
}, [count]);
React.useEffect(() => () => {
if (countRef.current <= 0) {
return;
}
onPause?.();
}, []);
}
function useTimer({ id, onStart, onEnd, onResume }) {
const idRef = React.useRef(id);
const [count, setCount] = React.useState(-1);
// 使用结束时的时间戳
React.useEffect(() => {
(async () => {
// 使用结束时的时间戳
const timestamp = parseInt(await AsyncStorage.getItem(idRef.current), 10);
const current = Date.now();
const second = Math.floor((timestamp - current) / 1000);
// 判断时间戳是否过期了
if (Number.isNaN(second) || second <= 0) {
onResume(false);
setCount(-1);
return;
}
setCount(second);
onResume(true);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const offset = useAppTimeOffset();
React.useEffect(() => {
if (count < 0) {
return;
}
if (count === 0) {
// 移除相关存储对象
AsyncStorage.removeItem(idRef.current);
setCount(-1);
onEnd?.();
return;
}
const handler = setTimeout(() => {
setCount(prev => {
const result = prev - 1 - offset();
return result >= 0 ? result : 0;
});
}, 1000);
return () => {
clearTimeout(handler);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [count]);
useTimerPause(count, onPause);
return {
count,
start(second) {
const timestamp = Date.now() + second * 1000;
// 存结束的时间戳
AsyncStorage.setItem(idRef.current, timestamp.toString());
setCount(second);
onStart?.();
},
end() {
AsyncStorage.removeItem(idRef.current);
setCount(-1);
onEnd?.();
}
};
}
做个总结
本文介绍了如何在 React Native 里面使用定时器按钮逻辑并给出了设计思路和代码。如果喜欢本文,欢迎点赞和收藏。如果对本文有任何疑问,欢迎在评论区指出,不胜感激。