加载中...
React Hook
发表于:2023-04-12 | 分类: 前端

类组件的不足(Hooks 要解决的问题)

缺少逻辑复用机制

为了复用逻辑增加无实际渲染效果的组件(高阶组件、渲染属性),它们增加了组件层级显示十分臃肿,增加了调试的难度以及运行效率的降低

类组件经常会变得很复杂难以维护

  • 将一组相干的业务逻辑拆分到了很多个生命周期函数中
  • 在一个生命周期函数内存在多个不相干的业务逻辑

类成员方法不能保证 this 指向的正确性

  • 通过 bind 修改 this 指向
  • 箭头函数

React Hooks 是用来干什么的

它的作用就是对函数型组件进行增强,让函数型组件可以存储状态,可以拥有处理副作用的能力。让开发者在不使用类组件的情况下,实现相同的功能

在 React 中,副作用指的是只要不是将数据变为视图的操作都视为副作用,如:

  • 获取 dom 元素
  • 发起 ajax 请求

useState

  • 接收唯一的参数即状态初始值,初始值可以为任意类型

useReducer

  • useReducer 和 redux 中 reducer 很像
  • useState 内部就是靠 useReducer 来实现的
  • useState 的替代方案,它接收一个形如 (state, action) => newState 的 reducer,并返回当前的 state 以及与其配套的 dispatch 方法
  • 在某些场景下,useReducer 会比 useState 更适用,例如 state 逻辑较复杂且包含多个子值,或者下一个 state 依赖于之前的 state 等
const initialState = 0;
function reducer(state, action) {
  switch (action.type) {
    case "increment":
      return { number: state.number + 1 };
    case "decrement":
      return { number: state.number - 1 };
    default:
      throw new Error();
  }
}
function init(initialState) {
  return { number: initialState };
}
function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState, init);
  return (
    <>
      Count: {state.number}
      <button onClick={() => dispatch({ type: "increment" })}>+</button>
      <button onClick={() => dispatch({ type: "decrement" })}>-</button>
    </>
  );
}

useContext

用于跨组件传递状态,比如下面这个主题数据传输

const themes = {
  light: {
    foreground: "#000000",
    background: "#eeeeee",
  },
  dark: {
    foreground: "#ffffff",
    background: "#222222",
  },
};

const ThemeContext = React.createContext(themes.light);

function App() {
  return (
    <ThemeContext.Provider value={themes.dark}>
      <Toolbar />
    </ThemeContext.Provider>
  );
}

function Toolbar(props) {
  return (
    <div>
      <ThemedButton />
    </div>
  );
}

function ThemedButton() {
  const theme = useContext(ThemeContext);
  return (
    <button style={{ background: theme.background, color: theme.foreground }}>
      I am styled by theme context!
    </button>
  );
}

useEffect()

执行时机

  • useEffect( () => {})
    • 只传入一个回调函数,那么这个钩子会在组件每次挂载时,和更新时被调用。相当于 componentDidMount 和 componentDidUpdate
  • useEffect(() => {}, [])
    • 传入一个函数和空数组,只会在挂载时被调用,相当于 componentDidMount
    • 第二个参数可以用于监控当前组件的状态和传入的 props 中的状态,确保回调在特定的数据变化时才触发更新
  • useEffect(() => () => {}])
    • 传入一个回调函数,在回调中返回一个函数,这个被返回的函数会在组件销毁前调用,相当于 componentDidUnMount

useRef

获取 dom 元素对象

const App = () => {
  const username = useRef();

  const handler = () => {
    console.log(username);
  };

  return <input ref={username} onChange={handler} />;
};

保存数据(跨组件周期)

即使组件重新渲染,保存的数据任然还在。保存的数据被更改不会触发组件重新渲染。

通常用useRef保存程序在运行当中的一些辅助数据

比如,我们要设置一个定时累加的任务,并且需要一个停止的方法。
但是将 timerId 定义在组件中时,每隔 1 秒,count 都会更新一次,组件也会重新渲染,timerId 就会被重新赋值一次:

const App = () => {
  const [count, setCount] = useState(0);
  let timerId = null;
  useEffect(() => {
    timerId = setInterval(() => {
      setCount(count + 1);
    }, 1000);
  });

  const stopCount = () => {
    clearInterval();
  };

  return (
    <div>
      {count}
      <button onClick={stopCount}>停止</button>
    </div>
  );
};

这个时候我们就需要用到 useRef 来保存这个数据,组件重新渲染后,数据也不会消失

const App = () => {
  const [count, setCount] = useState(0);
  let timerId = useRef();
  useEffect(() => {
    timerId.current = setInterval(() => {
      setCount(count + 1);
    }, 1000);
  });

  const stopCount = () => {
    clearInterval(timerId.current);
  };

  return (
    <div>
      {count}
      <button onClick={stopCount}>停止</button>
    </div>
  );
};

失控的 Ref

对于Ref,什么叫失控?

首先是不失控的情况:

  • 执行ref.currentfocus,blur等方法
  • 执行ref.current.scrollIntoView使element滚动到视野内
  • 执行ref.current.getBoundingClientRect测量 DOM 尺寸

这些情况,虽然操作了DOM,但涉及的都是 React 控制范围外的因素,所以不算失控

但是下面的情况:

  • 执行ref.current.remove移除 DOM
  • 执行ref.current.appendChild插入子节点

同样是操作 DOM,但是这些原本应该在 React 控制范围内的操作,通过ref执行就属于失控的情况

限制失控

所以在 react 中,函数组件可以访问自己的宿主元素的 DOM 节点,但父函数组件是无法直接通过ref来访问到子函数组件内的宿主元素的 DOM。这样就将ref失控的范围控制在了单个组件内,不会出现跨层级的失控
比如以下代码,点击了就会报错:

function MyInput(props) {
  return <input {...props} />;
}

function Form() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <>
      <MyInput ref={inputRef} />
      <button onClick={handleClick}>input聚焦</button>
    </>
  );
}

使用 forwardRef 取消限制

但是在某些场景下,比如组件库开发,就需要通过forwardRefref暴露给使用者。
当然,这种是将整个 DOM 通过 ref 传递给了使用者,需要使用者自己来承担误用的风险

const MyInput = forwardRef((props, ref) => {
  return <input {...props} ref={ref} />;
});

function Form() {
  const inputRef = useRef(null);

  function handleClick() {
    inputRef.current.focus();
  }

  return (
    <>
      <MyInput ref={inputRef} />
      <button onClick={handleClick}>Focus the input</button>
    </>
  );
}

useImperativeHandle 限制 ref 中的方法

比如,还是用上面的MyInput组件举例,这个组件,我们在封装是只能暴露给用户focus方法,需要屏蔽其他增删改的方法。那么就可以使用useImerativeHandle修改MyInput

const MyInput = forwardRef((props, ref) => {
  const realInput = useRef(null);
  useImerativeHandle(ref, () => ({
    focus() {
      realInputRef.current.focus();
    },
  }));
  return <input {...props} ref={ref} />;
});

现在,Form组件中通过inputRef.current只能取到如下结构:

{
  focus() {
    realInputRef.current.focus();
  },
}

这样就杜绝了开发者通过 ref 取到 DOM 后,执行不该被使用的 API,出现 ref 失控的情况。

使用 useImperativeHandle 回传子组件内定义的方法

在一些情况下,父组件不光需要获取子组件的 DOM,可能还需要获取子组件内的方法。那我们也可以通过useImperativeHandle来将方法绑定到 ref 上,然后再传给父组件

type ChildRef = {
  getBoundingClientRect: () => DOMRect | undefined;
  handleClick: () => void;
};
const Father: React.FC = () => {
  const childRef = useRef<ChildRef>(null);

  useEffect(() => {
    console.log(childRef.current?.getBoundingClientRect());
  }, []);

  const handleClick = () => {
    childRef.current?.handleClick();
  };
  return (
    <div>
      <h1>father page</h1>
      <button onClick={handleClick}>父组件添加</button>
      <Child ref={childRef} />
    </div>
  );
};

export default Father;

const Child = React.forwardRef((props, ref: Ref<ChildRef>) => {
  // 子组件内重新定义ref获取子组件的dom
  const divRef = useRef<HTMLDivElement>(null);
  const [count, setCount] = useState(0);
  const handleClick = () => {
    setCount((prev) => prev + 1);
  };

  useImperativeHandle(ref, () => {
    return {
      // 获取子组件DOM的getBoundingClientRect 方法
      getBoundingClientRect: () => {
        return divRef.current?.getBoundingClientRect();
      },
      // 获取子组件自定义的 handleClick 方法
      handleClick: () => handleClick(),
    };
  });

  return (
    <div ref={divRef}>
      {count}
      <button onClick={handleClick}>添加</button>
    </div>
  );
});

useCallback

useCallback 返回一个 memoized 回调函数。一般和React.memo()一起用于性能优化。

我们首先可以看下面这个没有使用useCallback的例子,即使使用了 React.memo,且DemoChildren的依赖看上去并没有变化(每次都是传入 getInfo 方法)
但是无论是input还是number产生变化都会触发DemoChildren更新。这是因为无论哪一个,只要触发了更新,App这个函数组件都会被重新渲染。
然后getInfo这个函数的引用就会被重新分配,所以即使看上去getInfo没有变化,但是到了React.memo的默认compair中还是判断组件发生了变化,
进而导致子组件DemoChildren重新渲染。

const DemoChildren = React.memo((props) => {
  console.log("子组件更新");
  useEffect(() => {
    props.getInfo("子组件");
  }, []);
  return <div>子组件</div>;
});

const App = () => {
  const [number, setNumber] = useState(1);
  const [input, setInput] = useState("");
  const getInfo = (tmp) => {
    console.log(tmp);
  };
  return (
    <div>
      <input value={input} onChange={(e) => setInput(e.target.value)} />
      <br />
      <span>{number}</span>
      <button onClick={() => setNumber(number + 1)}>增加</button>
      <DemoChildren getInfo={getInfo} />
    </div>
  );
};

然后我们在看这个例子,这个子组件DemoChildren只有两种情况下会产生更新

  • 初始化
  • 父组件的numberstate 变化时

因为在父组件中对getInfo函数使用了useCallback进行函数缓存。所以子组件使用了 React.memo 后发现 props 并没有更新

const DemoChildren = React.memo((props) => {
  console.log("子组件更新");
  useEffect(() => {
    props.getInfo("子组件");
  }, []);
  return <div>子组件</div>;
});

const App = () => {
  const [number, setNumber] = useState(1);
  const [input, setInput] = useState("");
  const getInfo = useCallback(
    (tmp) => {
      console.log(tmp);
    },
    [number]
  );
  return (
    <div>
      <input value={input} onChange={(e) => setInput(e.target.value)} />
      <br />
      <span>{number}</span>
      <button onClick={() => setNumber(number + 1)}>增加</button>
      <DemoChildren getInfo={getInfo} />
    </div>
  );
};

useMemo

作用: 它可以针对耗时计算来缓存值,并根据依赖项是否更新决定是否再一次进行耗时计算

比如,组件内需要展示一个耗时计算的数据.
这时,可以通过 useMemo 来进行一个性能优化

const computeExpensiveValue = (a, b) => {
  // do some expensive operations;
  return expensiveValue;
};
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

自定义的 hooks

作用: 将自定义 hook 内的内容平铺到组件内部

首先我们先写一个简单的 useTimeout,该 hook 就是在指定时间后返回一个数据

const useTimeout = (wait) => {
  const [data, setData] = useState(null);

  setTimeout(() => {
    setData("test");
  }, wait * 1000);

  return { data };
};

const App = () => {
  const { data } = useTimeout(1);
  return <span>{data}</span>;
};

上面的代码等价于

const App = () => {
  const [data, setData] = useState("");
  setTimeout(() => {
    setData("test");
  }, 1000);

  return <span>{test}</span>;
};

再比如项目中有一些接口请求需要加上 loading 并根据这个 loading 加一些样式,例如 tab, 分页,下拉框或者下拉刷新这类的请求。
但是这个逻辑其实都是一样的,都是变一下 true 和 false。
那么通过对 hook 的认知,我们可以自定义一个 useRequest

// utils/hooks.js
export const useRequest = (fn, dependencies) => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);

  const request = () => {
    setLoading(true);
    fn()
      .then(setData)
      .finally(() => {
        setLoading(false);
      });
  };

  useEffect(() => {
    request();
  }, dependencies);

  return { data, loading };
};

// App.js

import { useRequest } from "./utils/hooks";
// 这个是请求库
import request from "./utils/request";

// 该页面中有一个分页请求,组件用的是antd的Table
const App = () => {
  const [{ current, pageSize, total }, paginationChange] =
    useState <
    PaginationConfig >
    {
      current: 1,
      pageSize: 10,
      total: 0,
    };

  const { data, loading } = useRequest(() => {
    // 当页面发生改变后就产生请求,并loading,然后返回数据以及loading的状态
    return request("GET", "/getData", { current, pageSize });
  }, [current, pageSize]);

  const handleOnChange = ({ current, total, pageSize }) => {
    paginationChange({ current, total, pageSize });
  };
  return (
    <div>
      <Table
        loading={loading}
        columns={columns}
        dataSource={data}
        onChange={handleOnChange}
        pagination={{
          current: current,
          total: total,
          pageSize: pageSize,
        }}
        rowKey="id"
      />
    </div>
  );
};

当然,我们也可以将一些鼠标事件封装在 hook 中,例如获取鼠标位置的操作

// useMousePosition.js
const useMousePosition = () => {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  useEffect(() => {
    const updateMousePosition = (e) => {
      setPosition({ x: e.clientX, y: e.clientY });
    };
    document.addEventListener("mousemove", updateMousePosition);
    return () => {
      document.removeEventListener("mousemove", updateMousePosition);
    };
  });
  return position;
};

// App.js
import React from "react";
import useMousePosition from "./useMousePosition";

const App = () => {
  const { x, y } = useMousePosition();
  return (
    <div>
      <div>x: {x}</div>
      <div>y: {y}</div>
    </div>
  );
};

Hooks 解决了什么问题

1. 组件之间复用状态逻辑很难

React 没有提供将一些将可复用性行为“附加”到组件的途径(例如,把组件连接到 store)。常见的解决方案为 ”render prop“和”高阶组件“。但是它们需要重新组织你的组件结构,这会使你的代码变得非常难以理解和维护。我们可以在 React DevTools 中观察到,由 providers,consumers,高阶组件,render props 等其他抽象层组成的组件会形成“嵌套地狱”。
而 Hooks 则解决了:为共享状态逻辑提供更好的原生途径的问题。

举个例子,项目中有一些接口请求需要加上 loading 并根据这个 loading 加一些样式,例如 tab, 分页,下拉框或者下拉刷新这类的请求。但是这个状态逻辑其实都是一样的。都是变 true 和 false。那么,在使用了 hooks 之后,我们可以这么写一个 hooks。

// utils/hooks.js
const useLoading = (fn, deps) => {
  const [loading, setLoading] = useState(false);
  const [data, setData] = useState(nul);

  const request = () => {
    setLoading(true);
    fn()
      .then(setData)
      .finally(() => {
        setLoading(false);
      });
  };

  useEffect(() => {
    request();
  }, deps);

  return { data, loading };
};

2. 复杂组件变得难以理解

期初,组件都很简单,但是逐渐被状态逻辑和副作用占据。每个生命周期常常包含一些不相关的逻辑。例如,组件常常 componentDidMount 和 compoentDidUpdate 中获取数据。但是,同一个 componentDidMout 中可能也包含很多其他的逻辑,如事件监听,而之后需要在 componentWillUnMount 中清除。相互关联且需要对照修改的代码被进行了拆分,而完全不相关的代码却在同一个方法中组合在一起。

且在多数情况下,由于一个状态逻辑在一个组件中无处不在,不可能将组件拆分成更小的粒度。虽然可以通过引入状态管理工具来结合使用。但是,这回增加额外的抽象概念。

而 hooks 可以将相互关联的部分拆分为更小的函数,而非强制按照生命周期划分。

如下,我们将一个鼠标移动事件封装在一个 hooks 中, 而 useEffect hooks 则可以代替componentDidMountcompoentDidUpdatecomponentWillUnMount。使得逻辑更加的清晰

// useMousePosition.js
const useMousePosition = () => {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  useEffect(() => {
    const updateMousePosition = (e) => {
      setPosition({ x: e.clientX, y: e.clientY });
    };
    document.addEventListener("mousemove", updateMousePosition);
    return () => {
      document.removeEventListener("mousemove", updateMousePosition);
    };
  }, []);
  return position;
};

// App.js
import React from "react";
import useMousePosition from "./useMousePosition";

const App = () => {
  const { x, y } = useMousePosition();
  return (
    <div>
      <div>x: {x}</div>
      <div>y: {y}</div>
    </div>
  );
};

那么,如果我们使用 Class 的话,需要使用 render prop 获取 children prop 才能解决以上这种横切问题(封装状态或行为共享给其他需要相同状态的组件)

class MousePosition extends React.Component {
  constructor(props) {
    super(props);

    this.state = {
      x: 0,
      y: 0,
    };
  }

  updateMousePosition = (e) => {
    this.setState({ x: e.clientX, y: e.clientY });
  };

  componentDidMount() {
    document.addEventListener("mousemove", this.updateMousePosition);
  }

  componentWillUnMount() {
    document.removeEventListener("mousemove", this.updateMousePosition);
  }

  render() {
    return this.props.render(this.state);
  }
}

const App = () => {
  return (
    <div>
      <MousePosition
        render={(data) => {
          return [<div>x: {data.x}</div>, <div>y: {data.y}</div>];
        }}
      />
    </div>
  );
};

或者 HOC

function withMousePosition(Component) {
  class Comp extends React.Component {
    render() {
      return (
        <MousePosition
          render={(data) => <Component {...this.props} data={data} />}
        />
      );
    }
  }

  return Comp;
}

class MyApp extends React.Component {
  render() {
    const { data } = this.props;
    return (
      <div>
        <div>x: {data?.x}</div>
        <div>y: {data?.y}</div>
      </div>
    );
  }
}

const App = withMousePosition(MyApp);

ReactDOM.render(<App />, document.getElementById("root"));

3. 难以理解的 Class

除了代码的复用和管理困难外,学习 Class 你还需要理解 this 的指向问题。现在,我们可以通过箭头函数来避免 this 的指向问题。

但是,我们需要知道,在不支持箭头函数之前,事件函数都需要通过 bind 这类方法来修正 this 的指向。否则,在调用一个事件处理函数时,this 会指向 window;

总结

  1. hooks 解决了 class 组件难以解决的状态或行为复用问题,可以创建涵盖各种场景的自定义 Hook,如表单处理、动画、订阅声明、计时器等
  2. hooks 可以有效避免在书写复杂组件时使用 render prop 或者高阶组件所带来的的让组件难以理解的问题。
  3. 减少了多个生命周期的概念,学习和使用成本降低
  4. 避免 class 中 this 的指向问题

React Hooks 踩坑记录

只在最顶层使用 Hook

React 是通过 Hook 的调用顺序来确定 state 对应的useState

所以,我们需要保证 Hook 的调用顺序在多次渲染之间保持一致。

看一下官网的例子

// ------------
// 首次渲染
// ------------
useState("Mary"); // 1. 使用 'Mary' 初始化变量名为 name 的 state
useEffect(persistForm); // 2. 添加 effect 以保存 form 操作
useState("Poppins"); // 3. 使用 'Poppins' 初始化变量名为 surname 的 state
useEffect(updateTitle); // 4. 添加 effect 以更新标题

// -------------
// 二次渲染
// -------------
useState("Mary"); // 1. 读取变量名为 name 的 state(参数被忽略)
useEffect(persistForm); // 2. 替换保存 form 的 effect
useState("Poppins"); // 3. 读取变量名为 surname 的 state(参数被忽略)
useEffect(updateTitle); // 4. 替换更新标题的 effect

但如果我们将一个 Hook (例如 persistForm effect) 调用放到一个条件语句中,在第一次渲染中 name !== ‘’ 这个条件值为 true,所以我们会执行这个 Hook。
但是在下一次渲染时我们可能清空了表单,表达式值变为 false。此时的渲染会跳过 hook

if (name !== "") {
  useEffect(function persistForm() {
    localStorage.setItem("formData", name);
  });
}

useState("Mary"); // 1. 读取变量名为 name 的 state(参数被忽略)
// useEffect(persistForm)  // 此 Hook 被忽略!
useState("Poppins"); // 2 (之前为 3)。读取变量名为 surname 的 state 失败
useEffect(updateTitle); // 3 (之前为 4)。替换更新标题的 effect 失败

hook 的依赖项

在 hook 中,useEffectuseCallbackuseMemo等都提供第二个参数,它是一个依赖项数组(当我们不传时,每一次的状态更新都会被调用)。
当在第一个参数的回调函数中使用了 props 和 state,就需要在依赖项中标明。不然,使用的都是初始值。

在 useEffect 等中,

  • 当依赖项指定为 [] 数组时,则只会在首次更新时调用回调
  • 当不指定依赖项时,则任意 state 更新都会触发回调
  • 当指定依赖项时,则只在依赖项发生改变时执行回调

在 useCallback、useMemo 中,

  • 当依赖项指定为 [] 数组时,都会执行,但是回调中的 state 都是初始值
  • 当不指定依赖项时,则任意 state 更新都会触发回调
  • 当指定依赖项时,则只在依赖项发生改变时执行回调

useEffect 和 useLayoutEffect 的区别

简单的说,就是调用时机不同,useLayoutEffect和原来componentDidMountcomponentDidUpdate一致。
在 react 完成 DOM 更新后马上同步调用的代码,会阻塞页面渲染。而 useEffect 是会在整个页面渲染完才会调用的代码。

状态修改是异步的

const MyComponent = () => {
  const [value, setValue] = useState(0);

  function handleClick() {
    setValue(1);
    console.log(value); // <- 0
  }

  return (
    <div>
      <span>value: {value} </span>
      <button onClick={handleClick}>点击</button>
    </div>
  );
};

useState 返回的修改函数是异步的,调用后并不会直接生效,因此立马读取 value 获取到的是旧值(0)
React 这样设计的目的是为了性能考虑,争取把所有状态改变后只重绘一次就能解决更新问题,而不是改一次重绘一次,也是很容易理解的。

在 timeout 中读不到其他状态的新值

const MyComponent = () => {
  const [value, setValue] = useState(0);
  const [anotherValue, setAnotherValue] = useState(0);

  useEffect(() => {
    window.setTimeout(() => {
      console.log("setAnotherValue", value); // <- 0
      setAnotherValue(value);
    }, 1000);
    setValue(1);
  }, []);

  return (
    <span>
      Value:{value}, AnotherValue:{anotherValue}
    </span>
  );
};

因为在生成 timeout 闭包时,value 的值是 0。
虽然之后通过 setValue 修改了状态,但 React 内部已经指向了新的变量,而旧的变量仍被闭包引用,所以闭包拿到的依然是旧的初始值,也就是 0。

我们可以通过使用 useRef

const MyComponent = () => {
  const [value, setValue] = useState(0);
  const [anotherValue, setAnotherValue] = useState(0);
  const valueRef = useRef(value);
  valueRef.current = value;

  useEffect(() => {
    window.setTimeout(() => {
      console.log("setAnotherValue", valueRef.current); // <- 0
      setAnotherValue(valueRef.current);
    }, 1000);
    setValue(1);
  }, []);

  return (
    <span>
      Value:{value}, AnotherValue:{anotherValue}
    </span>
  );
};

参考

https://zhuanlan.zhihu.com/p/87713171

上一篇:
前端性能优化篇
下一篇:
《从零实现React18》学习总结
本文目录
本文目录