Hooks 之手写 useTitle

July 18, 2020

React

前言

React Hooks 正凭借其 Function Component 的特性,已经在实际项目中被广泛应用,而对于逻辑是重复且可被复用的组件,借助第三方 React Hooks 库来加快开发效率无疑是正确的选择。

我在 Github 上选取了 3 个 React Hooks 库,它们分别是:

以上库中,都包含了 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 还会调用 componentDidMountcomponentDidUpdate 之类的生命周期方法。
  • 提交 阶段通常会很快,但渲染过程可能很慢。因此,即将推出的 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(document.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。

适合自己的,才是最好的。


Written by B2D1(包邦东)