如何在函数组件里模拟构造函数

转自公司内大佬的文章,深入浅出的写法值得学习

TLDR

源码实现

1
2
3
4
5
/** 可在同一函数重复多次使用,互不干扰 */
export function useSingleton<T>(callBack: () => T): T {
const [T] = useState(callBack);
return T
}

使用例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function Example() {
// 不带返回值,只会触发一次,相比 useEffect,该函数会在 render 之前触发
useSingleton(() => {
console.log('init before render once');
});

// 带返回值
const store:IStore = useSingleton(()=>{
return new FileStore();
});

const [count, setCount] = useState(store.get('click_count'));

return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}

特点

  1. 可以模拟构造函数,只有一次调用
  2. 可以在渲染之前调用
  3. 有返回值时,多次调用的返回值结果一样,就像单例函数

详细说明

函数组件是 React 推出的新的组件形式,让写组件变得简单些,它抛弃了复杂的生命周期回调,专注于展示和数据监听。下面这是一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import React, { useState } from 'react';

function Example() {
const [count, setCount] = useState(0);

return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}

函数组件返回一个样式,react 会在每次数据变化的时候就调用一次此方法获取最新样式。
大部分情况,函数组件都能满足需求。但有一个业务场景函数组件不支持,就是构造函数。函数组件实际是一个函数,自然肯定没有 class 的构造函数。那对一些一次性的初始化逻辑该如何实现呢?React 的官方解释是函数组件不需要构造函数,因为状态初始化可以用 useState。

官方解释对不对是值得商榷的,因为初始化阶段不只是状态初始化,也会包含一些业务逻辑。那既然官方不准备支持,那我们有什么办法模拟呢?
首先我们能想到的是 useEffect 方法。useEffect 会在 render 结束后调用(useLayoutEffect同理,只是时机不同)。它会接受第二个参数,只关注某些状态变化才调用。利用这一点,我们可以传入空数组,也就是只关注第一次:

1
2
3
useEffect(() => {
document.title = `You clicked ${count} times`;
}, []); //此时只会在第一次渲染后调用。

这样的实现基本满足我们只执行一次的要求,但有一个缺点,执行的时机是在渲染后,我们有时候会想在渲染前作准备。要适应这种场景,我们可以用 useState 来模拟,设置一个标志位来来表示是不是第一次执行。可以写一个自定义 hook 工具方法:

1
2
3
4
5
6
const useConstructor(callBack = () => {}) => {
const [hasBeenCalled, setHasBeenCalled] = useState(false);
if (hasBeenCalled) return;
callBack();
setHasBeenCalled(true);
}

这样,我们在函数组件第一行调用这个方法,包住初始化逻辑就能满足在渲染前调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function Example() {
useConstructor(()=>{
// 初始化逻辑
});
const [count, setCount] = useState(0);

return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}

这样是不是完美模拟了呢?其实还是有两个小缺点:

  1. 调用 setHasBeenCalled 会触发一次不必要的刷新;
  2. useConstructor 命名不合适,因为不是真的构造函数,实际调用点是靠使用方在什么时候调用;
    基于这两点,我们可以优化一下成这样:
1
2
3
4
5
6
const useSingleton = (callBack = () => {}) => {
const hasBeenCalled = useRef(false);
if (hasBeenCalled.current) return;
callBack();
hasBeenCalled.current = true;
}

这个函数实际是一个单例函数,确保传入的函数只执行一次。useRef 可以理解为函数组件的全局变量,对它修改不会触发刷新。
这里有一点需要说一下,useSingleton 是可以在同一个函数组件里多次使用,useRef 会每次都生成新的变量,React 会保证不会相互干扰并正确执行。

这个模拟构造函数还能不能进一步优化呢,比如期望 useSingleton 有返回值,一次执行,每次都获取相同的结果?
可以的。根据 Reactjs 官方文档的惰性初始State, 可以给 useState 传函数,并且只会在第一次初始化时调用。所以我们可以进一步优化 useSingleton,再加上考虑返回值的话,最终版以及用法见文章开头

参考:https://dev.to/bytebodger/constructors-in-functional-components-with-hooks-280m

  1. TLDR
  2. 详细说明