前言
React Hooks 正凭借其 Function Component 的特性,已经在实际项目中被广泛应用,而对于逻辑是重复且可被复用的组件,借助第三方 React Hooks 库来加快开发效率无疑是正确的选择。
我在 Github 上选取了 3 个 React Hooks 库,它们分别是:
- streamich / react-use ⭐14.2k
- alibaba / hooks ⭐3.6k
- ecomfe / react-hooks ⭐227
以上库中,都包含了 useTitle
这个 hook 函数,调用它能改变当前页面的文档标题(document.title
),需要注意的是,当调用 useTitle
的组件卸载时,需要将文档标题还原。
你可以先尝试自己手写,思考过后,我们依次来看这三个库是怎么进行设计的。
streamich / react-use
react-use 作为热度最高的 hooks 库,早在 18 年由国外开发者开源,发展至今,包含了大量的处理函数,但质量层次不一,为什么我会这么说,且看下面分析。
以 useTitle
为例,先展示该库的源码:
// src/useTitle.ts
/* eslint-disable */
import { useRef, useEffect } from "react";
export interface UseTitleOptions {
restoreOnUnmount?: boolean;
}
const DEFAULT_USE_TITLE_OPTIONS: UseTitleOptions = {
restoreOnUnmount: false,
};
function useTitle(
title: string,
options: UseTitleOptions = DEFAULT_USE_TITLE_OPTIONS
) {
const prevTitleRef = useRef(document.title);
document.title = title;
useEffect(() => {
if (options && options.restoreOnUnmount) {
return () => {
document.title = prevTitleRef.current;
};
} else {
return;
}
}, []);
}
export default typeof document !== "undefined"
? useTitle
: (_title: string) => {};
复制代码
大致就是 useTitle
在每次调用时,先调用 useRef(document.title)
将初始的 document.title
保存至 prevTitleRef.current
中,随后修改文档标题(注意,这是个伏笔)。
在组件被销毁时,调用 useEffect
返回的函数,将 document.title
设置成之前保存的标题。
有同学可能会疑惑,为什么能导出一个三元表达式,这是因为 ES Modules 导出的是一个引用,等到真正执行该模块时,才会调用三元表达式,从而动态判断当前应用是否具有 document 对象,具体可查看 利用 webpack 理解 CommonJS 和 ES Modules 的差异 。
为了更直观的体验,我使用 create-react-app 初始化了一个新项目,并安装 react-use.
修改 App.js :
import React, { useState } from "react";
import { useTitle } from "react-use";
const Demo = () => {
useTitle("Hello world!", {
restoreOnUnmount: true,
});
return <h1>document.title has changed</h1>;
};
export default () => {
const [showDemo, setShowDemo] = useState(true);
return (
<div>
<button onClick={() => setShowDemo(!showDemo)}>
{showDemo ? "unmount" : "mount"}
</button>
{showDemo ? <Demo /> : ""}
</div>
);
};
复制代码
首次加载,显示 document.title 已被修改(原标题为 React App,可查看 public/index.html)。
当我点击按钮,卸载组件,却发现标题还是 Hello world!
这是因为在 index.js
中,使用了严格模式:
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById("root")
);
复制代码
当我将包裹在最外层的 <React.StrictMode></React.StrictMode>
注释后,当组件被卸载时,就能正确显示初始标题,完整文档参照 严格模式 – React
检测意外的副作用
从概念上讲,React 分两个阶段工作:
-
渲染 阶段会确定需要进行哪些更改,比如 DOM。在此阶段,React 调用
render
,然后将结果与上次渲染的结果进行比较。 提交 阶段发生在当 React 应用变化时。(对于 React DOM 来说,会发生在 React 插入,更新及删除 DOM 节点的时候。)在此阶段,React 还会调用componentDidMount
和componentDidUpdate
之类的生命周期方法。 -
提交 阶段通常会很快,但渲染过程可能很慢。因此,即将推出的 concurrent 模式 (默认情况下未启用) 将渲染工作分解为多个部分,对任务进行暂停和恢复操作以避免阻塞浏览器。这意味着 React 可以在提交之前多次调用渲染阶段生命周期的方法,或者在不提交的情况下调用它们(由于出现错误或更高优先级的任务使其中断)。
严格模式不能自动检测到你的副作用,但它可以帮助你发现它们,使它们更具确定性。通过故意重复调用以下函数来实现的该操作:
- 函数组件体
- 函数组件通过使用 useState,useMemo 或者 useReducer
也就是说,useTitle
在严格模式下,初始化阶段和更新阶段都会被执行了两次。
回顾之前的源码:
function useTitle(
title: string,
options: UseTitleOptions = DEFAULT_USE_TITLE_OPTIONS
) {
const prevTitleRef = useRef(document.title);
document.title = title;
useEffect(() => {
...
}, []);
}
复制代码
document.title = title
这个语句具有副作用(side effect),但却没包裹在 useEffect()
中,这是不严谨的,显然违背了 React Hooks 的设计初衷。
注意:
这仅适用于开发模式。生产模式下生命周期不会被调用两次。
alibaba / hooks
ahooks 作为阿里集团内部沉淀的 Hooks 库,基于 UI、SideEffect、LifeCycle、State、DOM 等分类提供了常用的 Hooks。
话不多上,直接上源码:
// packages/hooks/src/useTitle/index.ts
import { useEffect, useRef } from "react";
export interface Options {
restoreOnUnmount?: boolean;
}
const DEFAULT_OPTIONS: Options = {
restoreOnUnmount: false,
};
function useTitle(title: string, options: Options = DEFAULT_OPTIONS) {
const titleRef = useRef(document.title);
document.title = title;
useEffect(() => {
if (options && options.restoreOnUnmount) {
return () => {
document.title = titleRef.current;
};
}
}, []);
}
export default typeof document !== "undefined"
? useTitle
: (_title: string) => {};
复制代码
令人失望的是,代码几乎与 react-use 如出一致,所以上一节提到的开发模式下的小 bug,依旧是会存在。
对于相同的 useTitle
,react-use 的首次 commit 时间是 Oct 27, 2018,而 ahooks 是 Jul 5, 2020,大家也就见仁见智(前端重复造轮子的不良风气 or KPI 驱使的开源)。
ecomfe / react-hooks
react-hooks 是由百度在实际开发过程的基础上开源的 hooks 工具集合。
这里想夸夸百度,不愧是技术的“黄埔军校”,直接上源码:
// packages/document-title/src/index.ts
import { useEffect } from "react";
export function useDocumentTitle(title: string) {
useEffect(() => {
const previous = document.title;
document.title = title;
return () => {
document.title = previous;
};
}, [title]);
}
复制代码
代码非常简洁,它将 document.title = title
置于 useEffect()
中,避免了副作用产生的影响。
用 previous
常量去保存初始的标题,并在组件卸载时,还原标题。别忘了在 deps 数组中加入 title 变量。
自己写
但我个人觉得,下面这种写法是最好的:
import { useEffect } from "react";
export function useTitle(title: string) {
const prevTitleRef = useRef(title);
useEffect(() => {
document.title = title;
return () => {
document.title = prevTitleRef.current;
};
}, [title]);
}
复制代码
由于 useRef
返回的对象存在于当前组件的整个生命周期(The returned object will persist for the full lifetime of the component.),相较于百度的写法:
- 便于在函数其他位置访问存入的 title(如 useEffect()外,JSX 中)
- 更加语义化
由于在 useEffect 中使用到了 prevTitleRef.current,lint 工具会报 react-hooks/exhaustive-deps 警告。
可以尝试使用 // eslint-disable-next-line 注释。
总结
我们从一个简简单单的 useTitle
,看到了三个库之间的差距,总之你需要切实来选择正确的 library,也不要盲目信任 library。
适合自己的,才是最好的。