前端开发那些事儿React

React Hook 实践奇技淫巧(上)

2020-05-31  本文已影响0人  vincent_z

关于

React 钩子 是 React 中的新增功能,它使你无需编写类即可使用状态和其他 React 功能。以下提供了易于理解的代码示例,以帮助你了解钩子(hook)如何工作,并激发你在下一个项目中利用它们。

useMemoCompare

这个钩子为我们提供了对象的记忆值,但是我们并没有像使用useMemo一样传递依赖项数组,而是传递了一个自定义的比较函数compare,该函数同时获取前一个值和新值。compare函数可以比较嵌套属性,调用对象方法或确定相等性所需执行的其他任何操作。 如果compare函数返回 true,则该钩子返回旧的对象引用。

该钩子解决了这样的问题:你想向其他开发人员提供一个接口,并且要求他们每次在调用时传传递一一个值。这里讲得有点抽象,我们还是直接上代码吧。

import React, { useState, useEffect, useRef } from "react";

// 用法
function MyComponent({ obj }){
  const [state, setState] = useState();

  // ---------------------------------------------------------
  // 接收一个value和一个比较函数
  const theObj = useMemoCompare(obj, (prev) => prev && prev.id === obj.id);

  // 但是,如果我们直接使用obj,每次渲染上都会生成新的obj,useEffect将在每个渲染上触发
  // 更糟糕的是,如果useEffect中产生了theObj的状态的改变,则可能导致无限循环
  // 这里我们希望在theObj发生变化时触发useEffect
  // (effect runs -> state change causes rerender -> effect runs -> etc ...)
  useEffect(() => {
    return theObj.someMethod().then((value) => setState(value));
  }, [theObj]);

  // ----------------------------------------------------------

  // 那么为什么不只将[obj.id]作为依赖数组传递呢?
  useEffect(() => {
    // 好吧,那么eslint-plug-hooks会报依赖项数组中不存在obj
    // 通过使用上面的钩子,我们可以更明确地将相等性检查与业务逻辑分开
    return obj.someMethod().then((value) => setState(value));
  }, [obj.id]);
  // ----------------------------------------------------------
  return <div> ... </div>;
};

// 钩子
function useMemoCompare(value, compare) {
  // 使用Ref存储上一个value值
  const previousRef = useRef();
  const previous = previousRef.current;

  // 往比较函数传递旧值与新值
  const isEqual = compare(previous, value);

  // isEqual值为false则更新previous值为最新值value并返回最新值
  useEffect(() => {
    if (!isEqual) {
      previousRef.current = value;
    }
  });

  return isEqual ? previous : value;
};

useAsync

通常向用户展示任何异步请求的状态是一种好实践。这里给出两个示例,一个是从API提取数据并在呈现结果之前显示加载指示符。 另一个在使用表单的过程中,你想在submit进行时禁用提交按钮,然后在完成时显示成功或错误消息。

这里并不使用一堆 useState 调来跟踪异步函数的状态,你可以使用我们的自定义钩子,该钩子接收一个异步函数作为入参并返回我们需要正确处理的类似pendeingvalueerror的状态以便更新我们的用户界面。正如你将在下面的代码中看到的那样,我们的钩子返回了一个同时支持立即执行和延迟执行的execute函数。

import React, { useState, useEffect, useCallback } from "react";

// 用法
function App() {
  const { execute, pending, value, error } = useAsync(myFunction, false);

  return (
    <div>
      {value && <div>{value}</div>}
      {error && <div>{error}</div>}
      <button onClick={execute} disabled={pending}>
        {!pending ? "Click me" : "Loading..."}
      </button>
    </div>
  );
};

// 使用一个异步函数来测试我们的钩子,模拟各一半的请求成功率与失败率
function myFunction() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      const rnd = Math.random() * 10;
      rnd <= 5
        ? resolve("Submitted successfully 🙌")
        : reject("Oh no there was an error 😞");
    }, 2000);
  });
};

// 钩子
function useAsync(asyncFunction, immediate = true) {
  const [pending, setPending] = useState(false);
  const [value, setValue] = useState(null);
  const [error, setError] = useState(null);

  // useCallback确保不调用以下useEffect
  // 在每个渲染器上,但且仅当asyncFunction更改时
  // 执行函数包装asyncFunction和处理挂起,结果值和错误的状态的设置
  const execute = useCallback(() => {
    setPending(true);
    setValue(null);
    setError(null);
    return asyncFunction()
      .then((response) => setValue(response))
      .catch((error) => setError(error))
      .finally(() => setPending(false));
  }, [asyncFunction]);

  // 否则可以稍后调用execute,例如在onClick处理程序中
  // 如果要立即将其调用,请调用execute
  useEffect(() => {
    if (immediate) {
      execute();
    }
  }, [execute, immediate]);

  return { execute, pending, value, error };
};

useAuth

一个非常常见的场景,有一堆组件,这些组件需要根据当前用户是否登录以及有时需要调用身份验证方法(例如登录,注销,sendPasswordResetEmail等)而呈现不同的组件。

这是useAuth钩子的理想用例,它可以使任何组件获取当前的身份验证状态,并在其更改时重新渲染。这里并不是让useAuth钩子的每个实例都获取当前用户信息,而是简单地调用useContext来获取组件树中更顶层的数据。真正的魔法发生在我们的<ProvideAuth>组件和useProvideAuth钩子中,该钩子包装了我们所有的身份验证方法(在这种情况下,我们使用 Firebase,一个完整的普适性身份验证解决方案),然后使用React Context使当前auth对象可用于所有调用useAuth的子组件。

// 根组件
import React from "react";
import { ProvideAuth } from "./use-auth.js";

function App(props) {
  return <ProvideAuth>{/* 路由组件 */}</ProvideAuth>;
};

// 任何需要验证状态的组件
import React from "react";
import { useAuth } from "./use-auth.js";

function Navbar(props) {
  // 获取身份验证状态并在任何更改时重新渲染
  const auth = useAuth();

  return (
    <NavbarContainer>
      <Logo />
      <Menu>
        <Link to="/about">About</Link>
        <Link to="/contact">Contact</Link>
        {auth.user ? (
          <Fragment>
            <Link to="/account">Account ({auth.user.email})</Link>
            <Button onClick={() => auth.signout()}>Signout</Button>
          </Fragment>
        ) : (
          <Link to="/signin">Signin</Link>
        )}
      </Menu>
    </NavbarContainer>
  );
}

// 钩子 (use-auth.js)
import React, { useState, useEffect, useContext, createContext } from "react";
import * as firebase from "firebase/app";
import "firebase/auth";

// 添加你的Firebase凭据
firebase.initializeApp({
  apiKey: "",
  authDomain: "",
  projectId: "",
  appID: "",
});

const authContext = createContext();

// 包装你的应用,使任何调用useAuth()的子组件都能使用auth对象
export function ProvideAuth({ children }) {
  const auth = useProvideAuth();
  return <authContext.Provider value={auth}>{children}</authContext.Provider>;
}

// 子组件获取auth对象...并在更改时重新渲染。
export const useAuth = () => {
  return useContext(authContext);
};

// Provider程序钩子,用于创建auth对象并处理状态
function useProvideAuth() {
  const [user, setUser] = useState(null);

  // 校验登录
  const signin = (email, password) => {
    return firebase
      .auth()
      .signInWithEmailAndPassword(email, password)
      .then((response) => {
        setUser(response.user);
        return response.user;
      });
  };
  // 校验注册
  const signup = (email, password) => {
    return firebase
      .auth()
      .createUserWithEmailAndPassword(email, password)
      .then((response) => {
        setUser(response.user);
        return response.user;
      });
  };
  // 校验登出
  const signout = () => {
    return firebase
      .auth()
      .signOut()
      .then(() => {
        setUser(false);
      });
  };
  // 校验邮件密码重置
  const sendPasswordResetEmail = (email) => {
    return firebase
      .auth()
      .sendPasswordResetEmail(email)
      .then(() => {
        return true;
      });
  };
  // 校验密码重置
  const confirmPasswordReset = (code, password) => {
    return firebase
      .auth()
      .confirmPasswordReset(code, password)
      .then(() => {
        return true;
      });
  };

  // 组件挂载完订阅用户
  useEffect(() => {
    const unsubscribe = firebase.auth().onAuthStateChanged((user) => {
      if (user) {
        setUser(user);
      } else {
        setUser(false);
      }
    });

    // 组件卸载时清除评阅
    return () => unsubscribe();
  }, []);

  // 返回携带用户信息的user对象及验证方法
  return {
    user,
    signin,
    signup,
    signout,
    sendPasswordResetEmail,
    confirmPasswordReset,
  };
}

useRouter

如果你使用React Router,你可能已经注意到他们最近添加了许多有用的钩子,特别是useParamsuseLocationuseHistoryuseRouteMatch。但是让我们看看是否可以通过将它们包装到一个只暴露我们需要的数据和方法的useRouter钩子中来使其更简单。 在本例子中,我们展示了组合多个钩子并将它们的返回状态组合到一个对象中是多么容易。对于像React Router这样的库提供一系列低阶钩子是很有意义的,因为仅使用所需的钩子就可以最大程度地减少不必要的重新渲染。就是说,有时你希望获得更简单的开发人员体验,而自定义钩子则使之成为功能。

import {
  useParams,
  useLocation,
  useHistory,
  useRouteMatch,
} from "react-router-dom";
import queryString from "query-string";

// 用法
function MyComponent() {
  const router = useRouter();

  // 从查询字符串(?postId=123)或路由参数(/:postId)获取值
  console.log(router.query.postId);
  // 获取当前路径
  console.log(router.pathname);

  // 使用router.push()进行路由
  return <button onClick={(e) => router.push("/about")}>About</button>;
}

// 钩子
export function useRouter() {
  const params = useParams();
  const location = useLocation();
  const history = useHistory();
  const match = useRouteMatch();
  // useMemo以便仅在发生某些更改时才返回新对象
  // 返回我们的自定义路由器对象
  return useMemo(() => {
    return {
      // 为了方便起见,在顶层添加push(),replace(),路径名
      push: history.push,
      replace: history.replace,
      pathname: location.pathname,
      // 将参数和已解析的查询字符串合并为单个“查询”对象,以便它们可以互换使用
      // 例如: /:topic?sort=popular -> { topic: "react", sort: "popular" }
      query: {
        ...queryString.parse(location.search),
        ...params,
      },
      // 包含match,location,history,以便可以使用额外的React Router功能。
      match,
      location,
      history,
    };
  }, [params, match, location, history]);
}

useRequireAuth

常见的一种需求是在用户注销后重定向用户并尝试查看应该要求其进行身份验证的页面。本示例说明了如何轻松地组合我们的useAuthuseRouter钩子来创建一个新的useRequireAuth钩子,该钩子可以做到这一点。当然,可以将此功能直接添加到我们的useAuth钩子中,但是随后我们需要使该钩子了解我们的路由器逻辑。利用钩子组合的功能,我们可以使其他两个钩子尽可能地简单,并且在需要重定向时仅使用新的useRequireAuth

import Dashboard from "./Dashboard.js";
import Loading from "./Loading.js";
import { useRequireAuth } from "./use-require-auth.js";

function DashboardPage(props) {
  const auth = useRequireAuth();

  if (!auth) {
    return <Loading />;
  }

  return <Dashboard auth={auth} />;
}

// 钩子 (use-require-auth.js)
import { useEffect } from "react";
import { useAuth } from "./use-auth.js";
import { useRouter } from "./use-router.js";

function useRequireAuth(redirectUrl = "/signup") {
  const auth = useAuth();
  const router = useRouter();

  useEffect(() => {
    if (auth.user === false) {
      router.push(redirectUrl);
    }
  }, [auth, router]);

  return auth;
}

useEventListener

如果你发现自己使用useEffect添加了许多事件侦听器,则可以考虑将逻辑移至自定义钩子。在下面的例子中,我们创建一个useEventListener钩子,该钩子用于检查是否支持addEventListener,添加事件侦听器以及清除时移除。

import { useState, useRef, useEffect, useCallback } from "react";

// 用法
function App() {
  const [coords, setCoords] = useState({ x: 0, y: 0 });

  const handler = useCallback(
    ({ clientX, clientY }) => {
      setCoords({ x: clientX, y: clientY });
    },
    [setCoords]
  );

  // 使用我们的钩子添加事件侦听器
  useEventListener("mousemove", handler);

  return (
    <h1>
      The mouse position is ({coords.x}, {coords.y})
    </h1>
  );
}

// 钩子
function useEventListener(eventName, handler, element = window) {
  // 创建引用存储handler
  const savedHandler = useRef();

  useEffect(() => {
    savedHandler.current = handler;
  }, [handler]);

  useEffect(() => {
    // 确保节点支持addEventListener
    const isSupported = element && element.addEventListener;
    if (!isSupported) return;

    // 创建事件侦听器,调用存储在ref中的处理函数
    const eventListener = (event) => savedHandler.current(event);

    // 添加事件侦听
    element.addEventListener(eventName, eventListener);

    // 清除侦听
    return () => {
      element.removeEventListener(eventName, eventListener);
    };
  }, [eventName, element]);
}

useWhyDidYouUpdate

通过此钩子,可以轻松查看是哪些prop的更改导致了组件重新渲染。如果一个函数的运行成本特别高,并且你知道在给定相同的prop的情况下它可以返回相同的结果,则可以使用React.memo高阶组件,就像下面示例中对Counter组件所做的那样。在这种情况下,如果仍然看到所谓的不必要的重新渲染,则可以放入useWhyDidYouUpdate钩子并检查控制台(console),以查看哪些props在渲染之间进行了更改,并查看其上一个值/当前值。

import { useState, useEffect, useRef } from "react";

// 在使用React.memo后如果仍看到不必要的渲染,则可以使用useWhyDidYouUpdate并检查控制台以查看发生了什么。
const Counter = React.memo((props) => {
  useWhyDidYouUpdate("Counter", props);
  return <div style={props.style}>{props.count}</div>;
});

function App() {
  const [count, setCount] = useState(0);
  const [userId, setUserId] = useState(0);

  const counterStyle = {
    fontSize: "3rem",
    color: "red",
  };

  return (
    <div>
      <div className="counter">
        <Counter count={count} style={counterStyle} />
        <button onClick={() => setCount(count + 1)}>Increment</button>
      </div>
      <div className="user">
        <img src={`http://i.pravatar.cc/80?img=${userId}`} />
        <button onClick={() => setUserId(userId + 1)}>Switch User</button>
      </div>
    </div>
  );
}

// 钩子
function useWhyDidYouUpdate(name, props) {
  // 定义一个可变的ref对象,可以在其中存储props以便下次运行此钩子时进行比较。
  const previousProps = useRef();

  useEffect(() => {
    if (previousProps.current) {
      // 拿到上一个和当前props获取所有键
      const allKeys = Object.keys({ ...previousProps.current, ...props });
      // 使用此对象来跟踪更改的props
      const changesObj = {};
      allKeys.forEach((key) => {
        if (previousProps.current[key] !== props[key]) {
          changesObj[key] = {
            from: previousProps.current[key],
            to: props[key],
          };
        }
      });

      if (Object.keys(changesObj).length) {
        console.log("[why-did-you-update]", name, changesObj);
      }
    }
    // 最后用当前props更新以前的props
    previousProps.current = props;
  });
}

useMedia

该钩子使在组件逻辑中利用媒体查询变得非常容易。在下面的示例中,我们根据与当前屏幕宽度匹配的媒体查询来呈现不同数量的列,然后以限制列高差的方式在各列之间分配图像(我们不希望某一列比其他列更长 )。

你可以创建一个直接测量屏幕宽度的钩子,而不使用媒体查询。这种方法使在 JS 和样式表之间共享媒体查询变得容易。

import { useState, useEffect } from "react";

function App() {
  const columnCount = useMedia(
    // 媒体查询
    ["(min-width: 1500px)", "(min-width: 1000px)", "(min-width: 600px)"],
    // 列数(根据数组索引取值,与上述媒体查询有关)
    [5, 4, 3],
    //默认列数
    2
  );

  // 以列高值创建数组(从0开始)
  let columnHeights = new Array(columnCount).fill(0);

  // 创建二组数组,容纳每一列项目
  let columns = new Array(columnCount).fill().map(() => []);

  data.forEach((item) => {
    const shortColumnIndex = columnHeights.indexOf(Math.min(...columnHeights));
    columns[shortColumnIndex].push(item);
    columnHeights[shortColumnIndex] += item.height;
  });

  return (
    <div className="App">
      <div className="columns is-mobile">
        {columns.map((column) => (
          <div className="column">
            {column.map((item) => (
              <div
                className="image-container"
                style={{
                  // Size image container to aspect ratio of image
                  paddingTop: (item.height / item.width) * 100 + "%",
                }}
              >
                <img src={item.image} alt="" />
              </div>
            ))}
          </div>
        ))}
      </div>
    </div>
  );
}

// 钩子
function useMedia(queries, values, defaultValue) {
  const mediaQueryLists = queries.map((q) => window.matchMedia(q));

  // 根据匹配的媒体查询拿到value
  const getValue = () => {
    // 获取匹配的第一个媒体查询的索引
    const index = mediaQueryLists.findIndex((mql) => mql.matches);
    // 返回相关值,如果没有则返回默认值
    return typeof values[index] !== "undefined" ? values[index] : defaultValue;
  };
  const [value, setValue] = useState(getValue);

  useEffect(() => {
    // setValue可以接收一个函数来处理值更新
    const handler = () => setValue(getValue);
    // 为每个媒体查询设置一个侦听器,并以上述处理程序作为回调。
    mediaQueryLists.forEach((mql) => mql.addListener(handler));
    return () => mediaQueryLists.forEach((mql) => mql.removeListener(handler));
  }, []);

  return value;
}

useLockBodyScroll

有时,当特定组件绝对位于页面上时,你可能想阻止用户滚动页面的主体(典型的如模态滑动穿透)。看到背景内容在模态下滚动可能会造成混淆,特别是如果你打算滚动模态内的某个区域。这个钩子解决了这样的问题,只需在任何组件中调用useLockBodyScroll钩子,主体滚动将被锁定,直到该组件卸载为止。

import { useState, useLayoutEffect } from "react";

// 用法
function App() {
  const [modalOpen, setModalOpen] = useState(false);

  return (
    <div>
      <button onClick={() => setModalOpen(true)}>Show Modal</button>
      <Content />
      {modalOpen && (
        <Modal
          title="Try scrolling"
          content="I bet you you can't! Muahahaha 😈"
          onClose={() => setModalOpen(false)}
        />
      )}
    </div>
  );
}

function Modal({ title, content, onClose }) {
  // 调用钩子以锁定主体滚动
  useLockBodyScroll();
  return (
    <div className="modal-overlay" onClick={onClose}>
      <div className="modal">
        <h2>{title}</h2>
        <p>{content}</p>
      </div>
    </div>
  );
}

// 钩子
function useLockBodyScroll() {
  useLayoutEffect(() => {
    // 拿到原始body overflow的值
    const originalStyle = window.getComputedStyle(document.body).overflow;
    // 防止body在模态显示的情况下滚动
    document.body.style.overflow = "hidden";
    // 模态组件卸载时重置overflow
    return () => (document.body.style.overflow = originalStyle);
  }, []);
}

参考

Easy to understand React hook recipes by Gabe Ragland

React Hooks FAQ.

上一篇下一篇

猜你喜欢

热点阅读