函数式组件中的构造函数与钩子
[注:我已对本文进行了更新,提供了一个改进的解决方案。您可以在这里阅读:https://dev.to/bytebodger/streamlining-constructors-in-functional-react-components-8pe ]
在 React 中构建函数式组件时,类组件中有一个特性在函数中没有现成的等效功能。这个特性叫做构造函数。
在基于类的组件中,我们经常会看到使用构造函数来初始化状态的代码,如下所示:
class App extends Component {
constructor(props) {
super(props);
this.state = {
counter: 0
};
}
render = () => {
return (
<button
onClick={() =>
this.setState(prevState => {
return { counter: prevState.counter + 1 };
})
}
>
Increment: {this.state.counter}
</button>
);
};
}
说实话,我觉得这样的代码很愚蠢,而且过于冗长。因为即使在基于类的组件领域,同样的事情也可以这样实现:
class App extends Component {
state = { counter: 0 };
render = () => {
return (
<button
onClick={() =>
this.setState(prevState => {
return { counter: prevState.counter + 1 };
})
}
>
Increment: {this.state.counter}
</button>
);
};
}
如您所见,除非需要根据 props初始化状态变量,否则无需使用构造函数来初始化状态变量。如果不需要这样做,可以直接在类内部声明初始状态。
函数的构造函数?
如果我们转而关注函数式编程/Hooks 方面,Hooks 团队似乎也持有同样的观点。因为在 Hooks 的常见问题解答中,有一个章节专门回答了“生命周期方法与 Hooks 有何对应关系?”这个问题。该章节的第一条指出:
constructor函数组件不需要构造函数(重点:我加的)。你可以在调用中初始化状态。如果计算初始状态开销很大,你可以将一个函数传递给它。useStateuseState
哇...
我不知道这个“答案”是无知,还是傲慢,或许两者兼而有之。但这并不让我感到意外。它和我见过的其他一些关于 Hooks 的文档类似,都做了各种各样错误的假设。
这个“答案”是无知的,因为它假定构造函数的唯一目的是初始化状态。
这个“答案”很傲慢,因为它基于错误的假设,竟然断言你不需要构造函数。这就像去看牙医治牙疼——但牙医却不解决问题,只是拍拍你的头说:“好了好了,你其实不需要那颗牙。快走吧……”
他们那轻描淡写的常见问题解答中存在严重的过度简化,忽略了一个基本事实:构造函数(或类似构造函数的功能)还有其他完全合理的用途,与初始化状态变量无关。具体来说,当我想到构造函数时,我会想到以下这些特性。
-
在此组件的生命周期中,在任何其他代码运行之前执行的代码。
-
该代码在该组件的整个生命周期内只运行一次。
需要明确的是,大多数组件通常需要构造函数吗?不需要。当然不需要。事实上,我认为需要构造函数类型的逻辑是例外情况,而非普遍规律。然而,在某些情况下,我确实需要在组件生命周期中的任何其他操作之前执行某些逻辑,并且必须确保该逻辑在整个组件生命周期中只执行一次。
所以尽管 Hooks 团队做出了大胆的断言,但事实是,有时我确实需要一个构造函数(或一些等效的东西)。
功能/挂钩生命周期的挑战
函数/Hooks中生命周期最大的“问题”在于……它们根本没有生命周期。函数没有生命周期,它只会……运行,只要你调用它就会运行。因此,从这个角度来看,函数组件中没有现成的、简单易用的构造函数等效项也就不足为奇了。
尽管 JavaScript 的拥趸们对函数式编程推崇备至,但事实是,函数式组件的运行方式与真正的函数并不相同。诚然,你可以function在代码顶部使用那个令人安心的关键字(或者更好的选择是箭头函数语法)。但是,一旦你在 React 中创建了一个函数式组件,你就失去了对组件调用方式和时机的控制权。
这就是为什么我经常觉得,能够创建一段只运行一次的逻辑,在组件中进行任何其他处理之前执行,是非常有用的。但是,当我们在讨论 React 函数式组件时,我们究竟该如何做到这一点呢?或者更确切地说,我们应该把这段逻辑放在哪里,才能避免它在每次渲染时都被重复调用?
追踪函数/钩子的“生命周期”
(注:如果您想查看后续所有代码的实时示例,可以访问这里:https://stackblitz.com/edit/constructor-hook)
用一些例子来说明这一点会更清楚。所以,我们先来看一个非常简单的函数体逻辑示例:
const App = () => {
const [counter, setCounter] = useState(0);
console.log("Occurs EVERY time the component is invoked.");
return (
<>
<div>Counter: {counter}</div>
<div style={{ marginTop: 20 }}>
<button onClick={() => setCounter(counter + 1)}>Increment</button>
</div>
</>
);
};
这是对函数“生命周期”的最简单说明。在基于类的组件中,我们使用函数非常方便(恕我直言)render()。如果某段逻辑不应该在每次重新渲染时都运行,那么处理起来也很简单:只需不要把这段逻辑放在函数里render()即可。
但函数式组件没有提供开箱即用的等效项。没有 render()函数,只有 `<function>`。每次调用此函数时,return都会调用`<function> return`(以及函数体内的所有其他代码)。
我坦白承认,刚开始编写函数式组件时,这一点让我很困惑。我会在 `<function>`标签上方return添加一些逻辑,然后惊讶/恼火地发现,每次函数被调用时,这些逻辑都会执行。
事后看来,这完全不足为奇。它return并不等同于一个render()函数。换句话说,整个函数等价于该render()函数。
那么,让我们来看看其他一些现成可用的 Hook。首先,我花了一些时间研究它useEffect()。这引出了以下示例:
const App = () => {
const [counter, setCounter] = useState(0);
useEffect(() => {
console.log(
"Occurs ONCE, AFTER the initial render."
);
}, []);
console.log("Occurs EVERY time the component is invoked.");
return (
<>
<div>Counter: {counter}</div>
<div style={{ marginTop: 20 }}>
<button onClick={() => setCounter(counter + 1)}>Increment</button>
</div>
</>
);
};
这让我们离目标更近了一步。具体来说,它满足了我对构造函数的第二个要求:在这个组件的整个生命周期中,它只会运行一次。
问题在于,即使组件已经渲染完毕,它仍然会运行。这与 Hooks 文档完全一致,因为文档中明确指出:
默认情况下,特效会在每次渲染完成后运行(重点:我的)。
我还尝试了一些其他方法useLayoutEffect(),结果得到了以下示例:
const App = () => {
const [counter, setCounter] = useState(0);
useEffect(() => {
console.log(
"Occurs ONCE, AFTER the initial render."
);
}, []);
useLayoutEffect(() => {
console.log(
"Occurs ONCE, but it still occurs AFTER the initial render."
);
}, []);
console.log("Occurs EVERY time the component is invoked.");
return (
<>
<div>Counter: {counter}</div>
<div style={{ marginTop: 20 }}>
<button onClick={() => setCounter(counter + 1)}>Increment</button>
</div>
</>
);
};
useLayoutEffect()这并没有让我们离真正的“构造函数”更近一步。它会在渲染周期之前useLayoutEffect()触发,但仍然会在渲染周期之后触发。公平地说,这仍然完全符合 Hooks 文档的描述,因为它仍然是一个effect。而 effect 总是在渲染之后触发。 useEffect()useLayoutEffect()
因此,如果我们想要一个真正接近构造函数功能的东西,就需要手动控制该函数的触发。幸运的是,如果我们愿意手动编写所需的代码,这完全在我们掌控之中。代码如下所示:
const App = () => {
const [counter, setCounter] = useState(0);
const [constructorHasRun, setConstructorHasRun] = useState(false);
useEffect(() => {
console.log(
"Occurs ONCE, AFTER the initial render."
);
}, []);
useLayoutEffect(() => {
console.log(
"Occurs ONCE, but it still occurs AFTER the initial render."
);
}, []);
const constructor = () => {
if (constructorHasRun) return;
console.log("Inline constructor()");
setConstructorHasRun(true);
};
constructor();
console.log("Occurs EVERY time the component is invoked.");
return (
<>
<div>Counter: {counter}</div>
<div style={{ marginTop: 20 }}>
<button onClick={() => setCounter(counter + 1)}>Increment</button>
</div>
</>
);
};
这让我们离既定目标更近了一步。手动函数在其“生命周期”内只constructor()运行一次。它通过利用一个手动状态变量来实现这一目标——如果该变量被设置为 true,则拒绝重新运行该功能。constructorHasRunconstructor()true
这种方法……“可行”。但感觉非常……繁琐。如果你的函数式组件需要类似构造函数的功能,那么在这种方法下,你必须手动将跟踪变量添加到每个使用该变量的组件的状态中。然后,你还需要确保你的constructor()函数设置正确,使其仅根据该状态变量的值来执行逻辑。
这确实“可行”,但感觉并不令人满意。Hooks 的目的是为了简化我们的开发工作。如果我必须在每个需要类似构造函数功能的组件中手动编写这些功能,那么我不禁要问,我当初为什么要使用函数/Hooks 呢?
定制钩子来救援
我们可以利用自定义 Hook 来规范这个过程。通过将其导出到自定义 Hook,我们可以更接近于拥有一个真正类似构造函数的功能。代码如下:
const useConstructor(callBack = () => {}) => {
const [hasBeenCalled, setHasBeenCalled] = useState(false);
if (hasBeenCalled) return;
callBack();
setHasBeenCalled(true);
}
const App = () => {
useConstructor(() => {
console.log(
"Occurs ONCE, BEFORE the initial render."
);
});
const [counter, setCounter] = useState(0);
const [constructorHasRun, setConstructorHasRun] = useState(false);
useEffect(() => {
console.log(
"Occurs ONCE, but it occurs AFTER the initial render."
);
}, []);
useLayoutEffect(() => {
console.log(
"Occurs ONCE, but it still occurs AFTER the initial render."
);
}, []);
const constructor = () => {
if (constructorHasRun) return;
console.log("Inline constructor()");
setConstructorHasRun(true);
};
constructor();
console.log("Occurs EVERY time the component is invoked.");
return (
<>
<div>Counter: {counter}</div>
<div style={{ marginTop: 20 }}>
<button onClick={() => setCounter(counter + 1)}>Increment</button>
</div>
</>
);
};
如果你想查看不包含使用useEffect()`and`失败的尝试useLayoutEffect()以及手动实现 ` 的过程constructor(),它看起来会是这样:
const useConstructor(callBack = () => {}) => {
const [hasBeenCalled, setHasBeenCalled] = useState(false);
if (hasBeenCalled) return;
callBack();
setHasBeenCalled(true);
}
const App = () => {
useConstructor(() => {
console.log(
"Occurs ONCE, BEFORE the initial render."
);
});
const [counter, setCounter] = useState(0);
return (
<>
<div>Counter: {counter}</div>
<div style={{ marginTop: 20 }}>
<button onClick={() => setCounter(counter + 1)}>Increment</button>
</div>
</>
);
};
通过利用自定义 Hook,我们现在可以import将“类似构造函数”的功能集成到任何需要的函数式组件中。这让我们……嗯……完成了 99% 的目标。
为什么我说它只有 99% 的有效性呢?它满足了我对“构造函数”的两个条件。但是……在上面的例子中,它之所以能实现这个目标,是因为我在函数的最顶端调用了它。
我仍然可以在调用上方添加 100 行逻辑useConstructor()。如果我这样做,就会违背我最初的要求,即该逻辑必须在组件生命周期中的任何其他代码之前运行。不过……它仍然相当像一个“构造函数”——即使其功能取决于我将调用放在函数体中的位置。
因此,将其重命名useConstructor()为`.` 可能更直观useSingleton()。因为它确实能做到这一点。它确保给定的代码块只运行一次。如果您将这段逻辑放在函数声明的最顶端,那么它实际上就相当于一个“构造函数”。