React hooks 实践

React Hooks 是 React V16.8 加入的新特性,它可以让你在不编写 class 的情况下使用 state 以及其他的 React 特性。

它主要为了解决 React 长期以来的以下几个问题

  1. 在组件之间复用状态逻辑很难,例如分页,上传文件等附带状态的逻辑需要每个页面都写一份
  2. 复杂组件变得难以理解,生命周期函数如componentWillReciceProps需要大量的条件对比代码
  3. 难以理解的 class

缓存函数(Memoization)

缓存函数是计算机提高计算性能一个手段,它将之前的计算结果存储起来,如果函数传入了相同的参数,就会取之前计算过的值,而不是从新执行一遍函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 斐波纳契数列,模拟耗时函数
function fib(n) {
if (n === 0 || n === 1) return 1;
return fib(n - 1) + fib(n - 2);
}

let s1 = Date.now();
fib(40);
console.log("1 Fib cost: ", Date.now() - s1, "ms"); // 1 Fib cost: 1464 ms
let s2 = Date.now();
fib(40);
console.log("2 Fib cost: ", Date.now() - s2, "ms"); // 2 Fib cost: 1427 ms
console.log("All Fib cost: ", Date.now() - s1, "ms"); // All Fib cost: 2892 ms

在上面的例子中执行二次fib函数,每一次几乎执行相同时间,而我们可以看出第二次的计算完全是没有必要的,因为一般来说,函数在相同输入的情况下,输出值也是相同的。这种情况下可以使用高阶函数增强原有函数,使其拥有缓存计算结果的能力。

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
// 缓存单个计算结果
function memorizeOne(fn) {
let cache = null;
let cacheArgs = [];
return (...args) => {
if (
cache &&
cacheArgs.length === args.length &&
cacheArgs.every((item, index) => item === cacheArgs[index])
) {
return cache;
}
cache = fn(...args);
cacheArgs = args;
return cache;
};
}

s1 = Date.now();
memoFib(40);
console.log("1 MemoFib cost: ", Date.now() - s1, "ms"); // 1 MemoFib cost: 1435 ms
s2 = Date.now();
memoFib(40);
console.log("2 MemoFib cost: ", Date.now() - s2, "ms"); // 2 MemoFib cost: 0 ms
console.log("All MemoFib cost: ", Date.now() - s1, "ms"); // All MemoFib cost: 1435 ms

由于两次都是相同的输入,所以第二次计算时直接返回了上一次的结果,节约了大量的执行时间。

缓存函数也可以设计成缓存多个值

由于存储了额外的变量和数据,可以看出缓存函数是用空间换时间的一种优化方式。为了要先说这个呢,因为在 React 中,我们最重要的优化点在于减少组件虚拟 dom 的执行次数,而在减少渲染虚拟 dom 的核心点是减少组件stateprops的变化(假设我们已经开启了shouldComponentUpdate优化),缓存函数可以减少stateprops的变化,同时别忘了函数组件本身就是函数。下面我们将会看到缓存函数在 React 中的实际运用。

Hooks

React 内置了很多基础的 hooks, 通过组合可以生成逻辑更复杂的 hooks。

useState

useState赋予了函数组件拥有状态的能力

Edit long-firefly-nz5px

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
28
29
30
31
32
33
34
import React, { useState } from "react";

function Child() {
console.log("Child render");
return null;
}

export default () => {
const [count, setCount] = useState(0);

console.log("Render");

return (
<div>
<h2>UseState</h2>
<p>点击了{count}次</p>
<button
onClick={() => {
setCount(count + 1);
}}
>
按钮+1
</button>
<button
onClick={() => {
setCount(count);
}}
>
按钮不变
</button>
<Child />
</div>
);
};

文档:调用 State Hook 的更新函数并传入当前的 state 时,React 将跳过子组件的渲染及 effect 的执行。(React 使用 Object.is 比较算法 来比较 state。)

也就是说,使用 hooks 组件可以理解成类组件的PureComponent。当我们点击 按钮不变 按钮式,组件并不会渲染(未输出 “Render” log)。值得一提的是,文档上只是承诺了跳过了子组件的渲染,如果我们先点击+1再点击不变,当前组件还是会打印Render,但是子组件确实是没有渲染的。这个问题我在 github 提了issue,希望能找到原因。文档的下一段就说明了这个原因(多么粗心的人!!!)

需要注意的是,React 可能仍需要在跳过渲染前渲染该组件。不过由于 React 不会对组件树的“深层”节点进行不必要的渲染,所以大可不必担心。如果你在渲染期间执行了高开销的计算,则可以使用 useMemo 来进行优化。

不过官方的回复还是有一些额外的信息值得参考

The reasons for why this is can’t be explained without going fairly deep into the current implementation details- which probably wouldn’t be that useful and they’re likely to change in an upcoming release as we continue working on new APIs like concurrent mode and suspense.

简单的说就是:你没必要了解那么多细节,知道怎么用就行了。。 ̄ □  ̄||

使用 useState 还有一些技巧。

函数初始化

State 的初始值需要一些复杂的计算,由于使用 hooks 只能在函数组件中使用,每一次渲染会时整个函数内部的代码都会执行一次。

1
2
3
4
5
6
7
function Comp() {
// 一些非常复杂的计算
const initialState = someExpensiveCal();
const [state, setState] = useState(initialState);

// ...
}

因此 useState 可以传入一个函数,React 会保证这个函数只会在初始化执行,也就是相当于类组件的contructor

1
2
3
4
5
6
7
8
function Comp() {
const [state, setState] = useState(() => {
// 一些非常复杂的计算
return someExpensiveCal();
});

// ...
}

类似于 setState,setCount 也可以传入一个回调函数

1
2
3
4
5
6
7
8
9
useEffect(() => {
let timer = setInterval(() => {
setCount(count => count + 1);
}, 1000);

return () => {
clearInterval(timer);
};
}, []); // 这样可以不传入count,避免每次都会重启定时器

forceUpdate

上面提到了使用 useState 的状态是的,而 useState 并没有提供forceUpdate之类的方法,所以如果想要强制重新渲染组件。我们只能通过添加额外的状态来实现

1
2
3
4
5
6
7
8
9
10
function Comp() {
// 一些非常复杂的计算
const [count, setCount] = useState(0);

const forceUpdate = useCallback(() => {
setCount(count + 1);
}, [count]);

return <button onClick={forceUpdate}>强制刷新</button>;
}

或者自己写一个 hooks 来实现

1
2
3
4
5
6
7
function useForceUpdate() {
const [count, setCount] = useState(0);

return useCallback(() => {
setCount(count => count + 1);
}, []);
}

useEffect

useEffect 替换了componentDidMount,componentWillUpdate,componentDidUpdate,componentWillUnmount等生命周期函数,它使对状态改变的监听变得简单。

如我们想在组件挂载后从远端加载数据

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
28
import React, { useState, useEffect } from "react";

export default () => {
const [count, setCount] = useState(0);
const [data, setData] = useState<any>(null);

useEffect(() => {
fetch("/data.json")
.then(res => res.text())
.then(res => {
setData(res);
});
});

return (
<div>
<h2>UseEffect</h2>
<p>{data}</p>
<button
onClick={() => {
setCount(count + 1);
}}
>
触发render
</button>
</div>
);
};

在组件挂载后,useEffect的回调函数就会执行,而每次 render 回调函数都会执行。这当然不是我们想要的,所以可以传入第二参数 - 依赖数据列表。当传入的数据项没有变化时,回调函数就不会重新执行。如果不传入空数组,就相当于componentDidMount

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
useEffect(() => {
fetch("/data.json")
.then(res => res.text())
.then(res => {
setData(res);
});
}, []);

useEffect(() => {
fetch("/data.json?count=" + count)
.then(res => res.text())
.then(res => {
setData(res);
});
}, [count]); // 当count变化时会重新请求

Edit useEffect

useEffect 的回调函数支持返回一个函数,便于在 effect 重新执行前进行上一次操作需要清理的操作。比如

1
2
3
4
5
6
7
8
9
useEffect(() => {
const timer = setInterVal(() => {
console.log(count, new Date());
}, 1000);

return () => {
clearInterval(timer);
};
}, [count]);

这时我们会发现一个问题,我可能只是想输出 count,并不想将我的定时器中断,于是我们把 count 从依赖中移除。然后点击按钮后我们会发现,无论当前的 count 是多少,我们的定时器永远只会输出 0 了。

想想我们的 count 来自于哪里?- 函数外部的组件。
外部组件是什么?- 函数。
函数内引用外部作用域变量在 javascript 中叫做什么?- 闭包!

没错,每次 render 时我们的函数组件都会执行一遍,然后内部会生成新的闭包。记得我们最开始提到的缓存函数吗?如果把第二个参数数组看做函数的arguments,那么我们可以认为当其没有变化时,effect 的回调函数并没有刷新(也没有清理),所以第一次渲染时生成的回调函数仍然在运行着,而内部的 count 仍然是上一次渲染的组件函数内的 count。所以 count 始终为 0.

那怎样才能不清理状态也能输出 count 呢,我们可以移花接木,通过组件变量来同步当前的状态值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const countRef = useRef(0); // hooks函数组件通过ref保存变量并能保证在组件声明周期内不变

useEffect(() => {
countRef.current = count;
}, [count]);

useEffect(() => {
const timer = setInterval(() => {
console.log(countRef.current, new Date());
}, 1000);

return () => {
console.log("Clear...");
clearInterval(timer);
};
}, []); // 由于Ref在组件声明周期是不变的,所以不需要写入依赖

在 ui 频繁变动的情况下,使用 Ref 同步 state 可以有效的减少依赖变化引起了状态变更。

UI 更新

useEffect 的调用并非总在 ui 更新完成之后,也可能在状态更变和浏览器绘制之前,React 会更新当前浏览器的渲染状态动态选择。所以我们无法在 useEffect 获取 UI 更新。

useMemo

1
2
3
useMemo(() => {
return a + b;
}, [a, b]);

useMemo 就是 Hooks 版本的 Memoization。

useCallback

useCallback = (fn, deps)=> useMemo(() => fn, deps)

每一次 render 时,函数组件内部都会执行,内部的回调函数也会重新创建传给子组件。

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
const ItemComp = React.memo((props: ItemProps) => {
console.log("Render:", props.data.id);
return (
<div onClick={() => props.onClick(props.data)} style={{ padding: 10 }}>
{props.data.name}
</div>
);
});

function App() {
const [now, setNow] = useState(new Date());
const [data] = useState(() => {
const list = [];

for (let i = 0; i < 100; i++) {
list.push({
id: i,
name: i + "-" + getName()
});
}

return list;
});

useEffect(() => {
const timer = setInterval(() => {
setNow(new Date());
}, 1000);

return () => {
clearInterval(timer);
};
}, []);

const handleClick = (item: Item) => {
alert(item.name);
};

return (
<div>
<h2>useCallback</h2>
<p>{now.toString()}</p>
<div style={{ height: 300, overflow: "auto" }}>
{data.map(item => (
<ItemComp key={item.id} data={item} onClick={handleClick} />
))}
</div>
</div>
);
}

Edit useCallback

在这个长列表的 demo 中,我们可以在控制台中发现,每一次定时器的更新,都会触发子组件的重新渲染。

未完待续