0%

(译)React Hooks的冰山一角 && 实习记录

本文翻译自社区medium,作者Sandro Dolidze

Introduction

React Hooks,不像Class Components那样,React Hooks提供了一种更low-level的优化和构建应用的方式,当然同时,这也会带来隐形的bug和资源泄露的问题。
这篇文章中,我列举了12个例子来说明这些常见的问题和怎么去修复他们

我在实习的这半个月接到过开发进度条的需求,用setInterVal+get来实现,由于对于Hooks的理解不深,使用也不甚熟练,写出了很多bug,正好看到这篇文章,分享出来,也是对知识的巩固

Case Study:Implementing Interval

先看一下这个例子,设置一个从0开始的计时器,然后每隔500ms加1,一共有三个button,分别是:开始||停止||清除

<!-- more -->

level0 Hello World的水平

1
2
3
4
5
6
7
8
9
10
11
export default function Level00() {
console.log('renderLevel00');
const [count, setCount] = useState(0);
return (
<div>
count => {count}
<button onClick={() => setCount(count + 1)}>+</button>
<button onClick={() => setCount(count - 1)}>-</button>
</div>
);
}

很好理解,用一个useState来生成一个State,用户点击来控制count+和-

level1 setInterval(这就是我写的bug版本。。)

1
2
3
4
5
6
7
8
export default function Level01() {
console.log('renderLevel01');
const [count, setCount] = useState(0);
setInterval(() => {
setCount(count + 1);
}, 500);
return <div>count => {count}</div>;
}

从代码逻辑上来看是每隔500ms给count增加1,但是呢这个代码有一个很严重的问题那就是会造成资源泄露,并且这种实现方式也是错误的。很容易就会造成浏览器页面崩溃,因为上面的函数会在页面每次重渲染的时候触发,组件与此同时也会在每次渲染触发的时候创建一个新的setInterval。

Mutations, subscriptions, timers, logging, and other side effects are not allowed inside the main body of a function component (referred to as React’s render phase). Doing so will lead to confusing bugs and inconsistencies in the UI.

处理这种副作用我们要用到useEffect

level2 useEffect

1
2
3
4
5
6
7
8
9
10
export default function Level02() {
console.log('renderLevel02');
const [count, setCount] = useState(0);
useEffect(() => {
setInterval(() => {
setCount(count + 1);
}, 500);
});
return <div>Level 2: count => {count}</div>;
}

这次我们使用useEffect,大多数的的副作用都在useEffect中进行。但是上面的方法也会造成资源泄露。useEffect会在每次渲染完成后运行,所以新的setInterval会在每次count发生变化的时候被创建

level3 run only once

1
2
3
4
5
6
7
8
9
10
export default function Level03() {
console.log('renderLevel03');
const [count, setCount] = useState(0);
useEffect(() => {
setInterval(() => {
setCount(count + 1);
}, 300);
}, []);
return <div>count => {count}</div>;
}

给useEffect加上参数[],这样useEffect只会被在组件mount后调用一次,但是这样的话,count只会从0-1
另外一个问题就是,useEffect没有清除定时器

level4 cleanup

1
2
3
4
5
6
useEffect(() => {
const interval = setInterval(() => {
setCount(count + 1);
}, 300);
return () => clearInterval(interval);
}, []);

这种方法在return里清除了定时器,避免了资源泄露,但是存在的问题依然和之前的那一种方式一样的,count只会从0-1

level5 use Count as dependency

1
2
3
4
5
6
useEffect(() => {
const interval = setInterval(() => {
setCount(count + 1);
}, 500);
return () => clearInterval(interval);
}, [count]);

这次我们把count当做依赖传给useEffect的第二个参数,在这个例子中,useEffect会在mount后和每次count发生改变的时候调用,同时,return清除函数会在count发生变化的时候清除上一次的资源。

这次搞对了,没有bug,但是也有一点误区。setInterval每隔500ms就会创建/销毁。每个setInterval都只会被调用一次

level6 setTimeout

1
2
3
4
5
6
useEffect(() => {
const timeout = setTimeout(() => {
setCount(count + 1);
}, 500);
return () => clearTimeout(timeout);
}, [count]);

和level5的一样

level7 functional updates for useState

1
2
3
4
5
6
useEffect(() => {
const interval = setInterval(() => {
setCount(c => c + 1);
}, 500);
return () => clearInterval(interval);
}, []);

在之前的例子中,我们在每次count发生变化的时候运行useEffect,我们每次都需要获取到最新的count值。

但是useState提供了一个API可以让我们在不用获取到现在的值的情况下就可以更新值,我们可以直接给useState传递一个函数就可以做到。

现在就比较完美了,我们仅仅使用了一个setInterval就做到了这一点,return后的clearInterval清除函数也就会在组件销毁时运行

level8 local vairable(这就是我写的那一坨屎)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
export default function Level08() {
console.log('renderLevel08');
const [count, setCount] = useState(0);
let interval = null;
const start = () => {
interval = setInterval(() => {
setCount(c => c + 1);
}, 500);
};
const stop = () => {
clearInterval(interval);
};
return (
<div>
count => {count}
<button onClick={start}>start</button>
<button onClick={stop}>stop</button>
</div>
);
}

我们添加了start和stop的按钮,但是这个程序是不正确的,stop按钮并不起作用。每次渲染的过程中就会创建出一个新的setInterval引用,所以,stop的引用就会变成null

level9 useRef

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
export default function Level09() {
console.log('renderLevel09');
const [count, setCount] = useState(0);
const intervalRef = useRef(null);
const start = () => {
intervalRef.current = setInterval(() => {
setCount(c => c + 1);
}, 500);
};
const stop = () => {
clearInterval(intervalRef.current);
};
return (
<div>
count => {count}
<button onClick={start}>start</button>
<button onClick={stop}>stop</button>
</div>
);
}

useRef就像是go-to hooks一样,如果你需要一个可变的变量,你可以用useRef获取。和局部变量不同的是,React确保了useRef在每次渲染的过程中返回的是相同的引用

这下代码看起来没问题了,但是还有一个隐形的bug,如果start被多次重复调用后,setInterval也会被重复调用,造成内存泄露

level10 useCallback

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
export default function Level10() {
console.log('renderLevel10');
const [count, setCount] = useState(0);
const intervalRef = useRef(null);
const start = () => {
if (intervalRef.current !== null) {
return;
}
intervalRef.current = setInterval(() => {
setCount(c => c + 1);
}, 500);
};
const stop = () => {
if (intervalRef.current === null) {
return;
}
clearInterval(intervalRef.current);
intervalRef.current = null;
};
return (
<div>
count => {count}
<button onClick={start}>start</button>
<button onClick={stop}>stop</button>
</div>
);
}

在Interval已经开始的时候,我们就不去触发了。当然cleanrInterval(null)也不会产生任何bug。这样也避免了资源泄露。

但是这种方法也有一个问题就是,可能会有性能问题。

memorization是React性能优化的一个主要工具,React.memo进行浅比较,如果引用是相同的话,就不会进行重渲染。但是呢,如果把stop和start都传递给一个memorized组件的话,整个memorization就会失效,因为每次return都会返回新的引用。

React Hooks: Memoization

level11 useCallback

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
export default function Level11() {
console.log('renderLevel11');
const [count, setCount] = useState(0);
const intervalRef = useRef(null);
const start = useCallback(() => {
if (intervalRef.current !== null) {
return;
}
intervalRef.current = setInterval(() => {
setCount(c => c + 1);
}, 500);
}, []);
const stop = useCallback(() => {
if (intervalRef.current === null) {
return;
}
clearInterval(intervalRef.current);
intervalRef.current = null;
}, []);
return (
<div>
count => {count}
<button onClick={start}>start</button>
<button onClick={stop}>stop</button>
</div>
);
}

现在组件我们使用useCallback来包裹,现在每次我们返回的都是相同的引用。现在代码没有资源泄露,运行正确,同时也没有性能问题,但是代码复杂度增加了

level12 custom Hook

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function useCounter(initialValue, ms) {
const [count, setCount] = useState(initialValue);
const intervalRef = useRef(null);
const start = useCallback(() => {
if (intervalRef.current !== null) {
return;
}
intervalRef.current = setInterval(() => {
setCount(c => c + 1);
}, ms);
}, []);
const stop = useCallback(() => {
if (intervalRef.current === null) {
return;
}
clearInterval(intervalRef.current);
intervalRef.current = null;
}, []);
const reset = useCallback(() => {
setCount(0);
}, []);
return { count, start, stop, reset };
}

为了简化代码,我们把复杂的逻辑封装到useCounter中,同时向外暴露出{ count, start, stop, reset } API,然后我们就可以这么使用useCounter了

1
2
3
4
5
6
7
8
9
10
11
12
export default function Level12() {
console.log('renderLevel12');
const { count, start, stop, reset } = useCounter(0, 500);
return (
<div>
count => {count}
<button onClick={start}>start</button>
<button onClick={stop}>stop</button>
<button onClick={reset}>reset</button>
</div>
);
}

Summary

总的来说还是见的业务太少了,对React Hooks的使用场景不熟悉,还是要多写,多练多思考,才能真正融会贯通