Better

Ethan的博客,欢迎访问交流

Hooks 性能考虑与数据获取

最近一个小系统中,使用 Hooks 编写了所有的组件,由于也是初次使用,不禁就在思考,自己的书写是否会存在性能问题呢,毕竟 Hooks 的设计思想和类组件完全不一样,会存在一个观念的转换,但思考的过程中,我发现类组件的优势和优化手段,在 Hooks 中都能找到类似的方案。

概括

首先总体思考:Hook 会在渲染时会比类组件慢吗?

  • Hook 基于闭包,类组件基于类,只有在极端情况下才会有明显的差别
  • Hook 避免了 class 需要的额外开支,像是创建类实例和在构造函数中绑定事件处理器的成本
  • 不需要很深的组件树嵌套,减少了 React 计算开销

Hooks 承诺提供所有类组件好处的同时,避免类组件开销

在以往中,我们要优化 React 的性能,会有哪些考虑呢。首先我们需要知道 React 的渲染时机和渲染时做了什么事情。

在 React 中一切皆组件,因此优化也是面向组件优化,在 React 中引发组件 rerender 的时机很简单,就如下三种情况,第三种我们无需考虑

  • props 更改
  • state 更改
  • forceUpdate

那么 render 的时候做了什么事情呢

  • diff 比较
  • DOM 卸载与挂载

因此优化方向有

  • 合理分配 state,因为父组件修改 state 会导致子组件重新渲染,即使这个 state 的修改和子组件无关,而且实际子组件的 DOM 也不会有操作,但还是进行了 diff 阶段
  • 减少 state 的更新,比如因为某个 state 属性更新,在 componentDidUpdate 中更行另一个属性会导致再次 render
  • 使用 PureComponent 或 React.memo 减少不必要的更新
  • 使用更好的 props
    • 尽量使用原始类型,这一点感觉很难做到哇
    • 不要直接传递箭头函数
  • 使用 shouldComponentUpdate 控制更新
    • 解决尽量传递原始类型很难做到的问题
    • 对于函数式组件,可以传递一个比较函数作为 React.memo 的第二参数
    • 需要注意深比较本身的性能问题

既然 PureComponent 或 React.memo 可以较少更新,为什么不作为默认选项呢

  • Problem with nested objects
    • 对 props 和 state 执行浅比较,而且是严格比较,如果检测不一样,就会重新 render
    • 对于原始类型的比较很有意义,但是对于对象,会导致不必要的行为
  • Problem with function props
    • 方法也是使用引用存储
    • 这也是为什么有人不推荐直接在组件上传递箭头函数的原因

React.memo 等效于 PureComponent,但它只比较 props。也可以通过第二个参数指定一个自定义的比较函数来比较新旧 props。如果函数返回 true,就会跳过更新。

Hooks 怎么做

如果你的组件在装载时有非常昂贵的计算,或者该计算依赖于 props,那么在每次 render 的时候都计算一遍是非常错误的做法

渲染中昂贵的计算,函数式组件不同于类组件,由于没有实例,函数运行完现场就销毁了,因此如果函数运行中有一些昂贵的计算,该如何处理呢,具体例子可以看这里:传送门

  • 如果值需要在某些场景重新计算,则使用 useMemo,这里的值也可以是组件,因此可以用来优化子节点的重新渲染
  • 如果初始化常见 state 很昂贵时,使用 setState 的函数形式,只会在首次渲染时调用这个函数
  • 如果值从不被重新计算,可以惰性初始化一个 ref,ref 不会像 useState 可以接受一个特殊的函数重载,你需要编写自己的函数来创建并设置为惰性的

惰性初始化 ref

function Image(props) {
  const ref = useRef(null);

  // IntersectionObserver 只会被惰性创建一次
  function getObserver() {
    if (ref.current === null) {
      ref.current = new IntersectionObserver(onIntersect);
    }
    return ref.current;
  }

  // 当你需要时,调用 getObserver()
  // ...
}

在 React 中使用内联函数对性能的影响,与每次渲染都传递新的回调会如何破坏子组件的 shouldComponentUpdate 优化有关。Hook 从三个方面解决了这个问题。

  • useCallback Hook 允许你在重新渲染之间保持对相同的回调引用以使得 shouldComponentUpdate 继续工作
  • useMemo Hook 使控制具体子节点何时更新变得更容易
  • useReducer Hook 减少了对深层传递回调的需要,通过 context 用 useReducer 往下传一个 dispatch 函数**

Hooks 重构已有组件

如果有一定的 Hooks 了解,对于如何将一个类组件转换为函数式组件,这里就不多啰嗦了。我们谈谈如下几点

  • state 转换
  • 生命周期转换:这一块比较简单,主要是useEffect 的使用,需要注意的是依赖数组的比较是严格比较,对原始数据类型好使,对象数据类型就难为情了
  • 重构 render props 和 HOC

state 转换

实际项目中,我们一个组件可能会有一个复杂的 state 对象,此时你可能会有两种不太合适的做法

  • 将其拆分成多个 useState:存在的问题是代码看上去很不美观
  • 使用一个 state 对象作为 state:属性之间可能是不相关的,会对增加日后将其封装进一个独立的自定义 hook 的难度

如果一个操作,你需要运行多个 setXXX,说明这些 state 的相关的,你可能会考虑放进一个自定义 hook 中,很多时候还可以考虑使用 useReducer hook

useReducer 不同于 Redux,action 必须是一个带有 type 属性的对象,在 useReducer 只需要确保,reducer 函数返回新的 state 即可,第二个参数是什么,其实完全取决于开发者,你可以这么做

const reducer = (state, newState) => ({ ...state, ...newState })
const [state, setState] = useReducer(reducer, initialState);

生命周期

主要是 useEffect 的使用,需要注意的是依赖数组的比较是严格比较,对原始数据类型好使,对象数据类型就难为情

如果我们要进行对象之间的比较该如何处理呢

  • 使用 JSON.stringify 将对象序列化成字符串:由于性能问题,只用于不复杂对象和容易序列化的数据类型
  • 手动进行条件判断:比如使用 loadashisEqual 函数
  • useMemo hook 记忆值

useEffect 并不完全等同于 componentDidMount + componentDidUpdate + componentWillUnmount,因为 useEffect 是异步的,在每次 render 之后才会执行。

重构 HOC 和 render props

比如如下组件及使用

// render props
class TrivialRenderProps extends Component {
    state = {
        loading: false,
        data: []
    }
    render() {
        return this.props.children(this.state)
    }
}

// HOC
function withTrivial(WrapComponent) {
    return class Trivial extends Component {
        state = {
            loading: false,
            data: []
        }
        render() {
            return (
                <WrapComponent {...this.state} />
            )
        }
    }
}

// use renderProps
function ConsumeTrivialRenderProps() {
    return (
        <TrivialRenderProps>
            {({loading, data}) => {
            return <pre>
                {`loading: ${loading}`} <br />
                {`data: [${data}]`}
            </pre>
            }}
        </TrivialRenderProps>
    )
}

让我们使用 hook 重构一下这个 render props 吧

function useTrivialRenderProps() {
    const [data, setData] = useState([])
    const [loading, setLoading] = useState(false)
    return {
        data,
        loading,
    }
}
function ConsumeTrivialRenderProps() {
    const { loading, setLoading, data } = useTrivialRenderProps()
    return (
        <pre>
            {`loading: ${loading}`} <br />
            `data: [${data}]`}
        </pre>
    )
}

数据获取

你可能会觉得这不是很简单么,有 useEffect hook 帮忙是吧,我本来也是这么觉得,但是我发现有很多可以优化的地方,比如处理 error 和 loading 等。

如果你需要使用 async/await,你可能会将 useEffect 的参数改成 async 修饰,但这是不允许的,因为 async 会返回 promise,而 useEffect 要求只能返回空或者 cleanup 函数,因此你需要在 useEffect 内部创建一个 async 函数。

很多时候我们需要提示用户,数据正在加载中,因为我们可能会有如下的 state

const [isLoading, setIsLoading] = useState(false);

我们可能还会处理错误的情况,因此增加如下 state

const [isError, setIsError] = useState(false);

还有一个问题就是,如果数据拉取的过程中,组件被卸载了,数据拉取成功后,调用 setState 会导致内存溢出,控制台会有错误提示,此时我们可能会这么做,因此我们可能会增加一个 didCancel 参数用来判断组件是否已经卸载。

有了这么多 state,我们可以考虑使用 useReducer 将他们组合起来。

如此多逻辑,看上去像是样板代码,我们应该将他们封装进一个自定义 hook 中,具体如下

const dataFetchReducer = (state, action) => {
  switch (action.type) {
    case 'FETCH_INIT':
      return {
        ...state,
        isLoading: true,
        isError: false
      };
    case 'FETCH_SUCCESS':
      return {
        ...state,
        isLoading: false,
        isError: false,
        data: action.payload,
      };
    case 'FETCH_FAILURE':
      return {
        ...state,
        isLoading: false,
        isError: true,
      };
    default:
      throw new Error();
  }
};
const useDataApi = (initialUrl, initialData) => {
  const [url, setUrl] = useState(initialUrl);

  const [state, dispatch] = useReducer(dataFetchReducer, {
    isLoading: false,
    isError: false,
    data: initialData,
  });

  useEffect(() => {
    let didCancel = false;

    const fetchData = async () => {
      dispatch({ type: 'FETCH_INIT' });

      try {
        const result = await axios(url);

        if (!didCancel) {
          dispatch({ type: 'FETCH_SUCCESS', payload: result.data });
        }
      } catch (error) {
        if (!didCancel) {
          dispatch({ type: 'FETCH_FAILURE' });
        }
      }
    };

    fetchData();

    return () => {
      didCancel = true;
    };
  }, [url]);

  return [state, setUrl];
};

我们在 reducer 中为什么要使用解构呢,这是为了遵循 React 的最佳实践,保证数据的不可变性。

参考



留言