Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

简单聊一聊 React, Context 和 Redux 的性能优化 #30

Open
hacker0limbo opened this issue Jul 19, 2022 · 2 comments
Open

简单聊一聊 React, Context 和 Redux 的性能优化 #30

hacker0limbo opened this issue Jul 19, 2022 · 2 comments
Labels
react react 笔记整理 redux redux 笔记整理

Comments

@hacker0limbo
Copy link
Owner

该篇文章主要使用一个 demo, 针对使用传统 props&state, context, redux 做状态管理来进行性能优化.

Demo 效果大致如下:

demo

三种方法均尝试实现同一个效果, 有三个 room, 每个 room 是一个组件, 可以通过点击按钮改变背景颜色, 并且原则上改变其中一个 room 的背景颜色时其他 room 不会被重新渲染, 可以通过 console 里的输出查看.

完整代码如下: https://stackblitz.com/edit/react-tqmejp

一些基本总结

首先放一些有关 React 渲染和 Context 的总结:

  • React 默认递归式的渲染组件, 所以当一个父组件被渲染时, 所有子组件默认也会被渲染
  • Class 组件的 this.setState(), this.forceUpdate(), hooks 组件的 useState 的 setters, useReducer 的 dispatches 都会触发该组件的 re-render
  • React.memo() 可以跳过一些无必要渲染, 如果该组件的 props 和上一次对比没有改变
  • Context providers 会比较提供的 value 的引用, 这个 value 可以是任意类型, 比如一个字符串, 一个对象, 但注意的是如果该 value 的引用发生改变, 所有 consumers 会 re-render, 即使该 consumer 只用到了 value 的一部分, 比如 value 对象的某个属性值.
  • 一个好的建议是, 在 context provider 下的子组件用 React.memo() 包裹, 这样即使 context value 发生了改变导致 re-render, 或者因为 React 本身递归式的渲染, 这些被包裹的组件可能能避免 re-render

更详细的说明和总结可以查看这两篇文章:

Props & State

先看最简单的使用 props 和 state 实现的 demo:

import React, { useState } from 'react';

const Room = ({ isLit, flipLight, index }) => {
  console.log('render room', index);

  return (
    <div className={`room ${isLit ? 'lit' : 'dark'}`}>
      Room {index} is {isLit ? 'lit' : 'dark'}
      <br />
      <button onClick={() => flipLight(index)}>Flip</button>
    </div>
  );
};

export default function PropsStateDemo() {
  const [lights, setLights] = useState([true, false, false]);

  const flipLight = (index) => {
    setLights((lights) =>
      lights.map((light, i) => (i === index ? !light : light))
    );
  };

  return (
    <div>
      <p className="title">Props and State Demo</p>
      {[0, 1, 2].map((index) => (
        <Room
          key={index}
          isLit={lights[index]}
          flipLight={memoedFlipLight}
          index={index}
        />
      ))}
    </div>
  );
}

虽然功能上实现了, 但是存在性能问题, 每当点击其中一个 room 的 flip 按钮, 其余 room 组件一样会被重新渲染, 具体效果大致如下:

problem

原因也很简单, 每次点击按钮触发 flipLight 方法, 都会触发父组件 PropsStateDemo 里的 setLights, lights 状态改变. 由于默认行为是父组件被渲染时, 子组件也会默认被渲染. 因此所有 Room 组件都被渲染了一次

优化也很简单, Room 组件的 props 分别是 isLit, flipLight, index. 每一个 Room 组件的 isLit 都应该是独立的, 也就是说当某个 Room 改变了 isLit 状态, 虽然这会导致 lights 状态变更, 且确实无法避免, 因为 lights 状态是一个数组, 但单个的 isLit 状态是独立的, 别的 Room 的 isLit 状态是不会影响到其他 Room 的. 同理 index 也是独立的. 而 flipLight 这个函数每次在父组件渲染的时候都会被传一份新的引用, 那尝试保证引用不变就行了.

所以只要用 React.memo() 包裹 Room 组件, 同时使用 useCallback 保证每次 flipLight 引用一样即可

优化后的代码:

import React, { useState, useCallback } from 'react';

const MemoedRoom = React.memo(({ isLit, flipLight, index }) => {
  console.log('render room', index);

  return (
    <div className={`room ${isLit ? 'lit' : 'dark'}`}>
      Room {index} is {isLit ? 'lit' : 'dark'}
      <br />
      <button onClick={() => flipLight(index)}>Flip</button>
    </div>
  );
});

export default function PropsStateDemo() {
  const [lights, setLights] = useState([true, false, false]);

  const memoedFlipLight = useCallback((index) => {
    setLights((lights) =>
      lights.map((light, i) => (i === index ? !light : light))
    );
  }, []);

  return (
    <div>
      <p className="title">Props and State Demo</p>
      {[0, 1, 2].map((index) => (
        <MemoedRoom
          key={index}
          isLit={lights[index]}
          flipLight={memoedFlipLight}
          index={index}
        />
      ))}
    </div>
  );
}

效果即是开头贴的示例效果, 就不重复放了.

Context

虽然这里完全没有必要使用到 Context, 但为了演示模拟一下.

import React, { useState, useContext } from 'react';

const RoomContext = React.createContext();

const RoomProvider = ({ children }) => {
  const [lights, setLights] = useState([false, true, false]);

  const flipLight = (index) => {
    setLights((lights) =>
      lights.map((light, i) => (i === index ? !light : light))
    );
  };

  const value = {
    lights,
    flipLight,
  };

  return <RoomContext.Provider value={value}>{children}</RoomContext.Provider>;
};

const Room = ({ index }) => {
  const { lights, flipLight } = useContext(RoomContext);
  const isLit = lights[index];

  console.log('render room', index);

  return (
    <div className={`room ${isLit ? 'lit' : 'dark'}`}>
      Room {index} is {isLit ? 'lit' : 'dark'}
      <br />
      <button onClick={() => flipLight(index)}>Flip</button>
    </div>
  );
};

export default function ContextDemo() {
  return (
    <div>
      <p className="title">Context Demo</p>
      <RoomProvider>
        {[0, 1, 2].map((index) => (
          <Room key={index} index={index} />
        ))}
      </RoomProvider>
    </div>
  );
}

和之前一样, 当更改其中一个 Room 的背景颜色后, 其余 Room 也会被重新渲染. 原因为, flipLight 会导致 RoomProvider 重新渲染, 导致每次产生一份新的 value, value 引用变化导致所有 Room 作为 consumer 都被重新渲染了

如果按照之前的做法, 尝试用 React.memo, useMemouseCallback 进行性能优化, 代码大致如下:

const RoomProvider = ({ children }) => {
  const [lights, setLights] = useState([false, true, false]);

  const memoedFlipLight = useCallback((index) => {
    setLights((lights) =>
      lights.map((light, i) => (i === index ? !light : light))
    );
  }, []);

  const memoedValue = useMemo(
    () => ({
      lights,
      flipLight: memoedFlipLight,
    }),
    [lights, memoedFlipLight]
  );

  return <RoomContext.Provider value={value}>{children}</RoomContext.Provider>;
}

const Room = React.memo(({ index }) => {
  const { lights, flipLight } = useContext(RoomContext);
  // ...
})

仍旧失败, 原因在于, 虽然使用 React.memo 包裹了 Room 组件, 但由于内部又使用了 useContext, 同时 lights 状态其实每次都是变化的, 因此即使使用了 useMemo, 每次 value 还是不一样, 这样 Room 组件作为消费者又被迫重新被渲染.

要解决这个问题需要将 useContext 抽出来, 也就是不能再 React.memo 里使用, 因为 React.memo 优化只针对 props. 同时因为 lights 一直变化的缘故, 传递的状态最好和之前一样是 isLit 这种单一的状态, 而非整个完整的状态. 修改后的代码如下:

import React, { useState, useContext, useCallback, useMemo } from 'react';

const RoomContext = React.createContext();

const RoomProvider = ({ children }) => {
  const [lights, setLights] = useState([false, true, false]);

  const memoedFlipLight = useCallback((index) => {
    setLights((lights) =>
      lights.map((light, i) => (i === index ? !light : light))
    );
  }, []);

  const memoedValue = useMemo(
    () => ({
      lights,
      flipLight: memoedFlipLight,
    }),
    [lights, memoedFlipLight]
  );

  return <RoomContext.Provider value={memoedValue}>{children}</RoomContext.Provider>;
};

const withRoom =
  (Component) =>
  ({ index }) => {
    const { lights, flipLight } = useContext(RoomContext);

    return (
      <Component index={index} isLit={lights[index]} flipLight={flipLight} />
    );
  };

const MemoedRoom = withRoom(
  React.memo(({ index, isLit, flipLight }) => {
    console.log('render room', index);

    return (
      <div className={`room ${isLit ? 'lit' : 'dark'}`}>
        Room {index} is {isLit ? 'lit' : 'dark'}
        <br />
        <button onClick={() => flipLight(index)}>Flip</button>
      </div>
    );
  })
);

export default function ContextDemo() {
  return (
    <div>
      <p className="title">Context Demo</p>
      <RoomProvider>
        {[0, 1, 2].map((index) => (
          <MemoedRoom key={index} index={index} />
        ))}
      </RoomProvider>
    </div>
  );
}

这里新抽出一个高阶函数 withRoom, 在这个高阶组件里进行 context 的消费, 然后返回需要的组件, 传递的 props 均为单一的属性, 而非一直会变化的 lights 状态, 这样 React.memo 就能进行优化了.

可以看到这样的优化代码非常丑陋, 而且可能会需要花费一些时间.

Redux

如果使用 Redux 来编写一般就不需要考虑这些性能优化的问题, 因为 Redux 内部其实都有做好这些脏活. 这里使用 Redux Toolkit 实现:

import React from 'react';
import { configureStore, createSlice } from '@reduxjs/toolkit';
import { Provider, useSelector, useDispatch } from 'react-redux';

const {
  actions: { flipLight },
  reducer: roomReducer,
} = createSlice({
  name: 'room',
  initialState: {
    lights: [false, false, true],
  },
  reducers: {
    flipLight: (state, action) => {
      state.lights[action.payload] = !state.lights[action.payload];
    },
  },
});

const store = configureStore({
  reducer: {
    room: roomReducer,
  },
});

function Room({ index }) {
  const isLit = useSelector((state) => state.room.lights[index]);
  const dispatch = useDispatch();

  console.log('render room', index);

  return (
    <div className={`room ${isLit ? 'lit' : 'dark'}`}>
      Room {index} is {isLit ? 'lit' : 'dark'}.
      <br />
      <button onClick={() => dispatch(flipLight(index))}>Flip</button>
    </div>
  );
}

function ReduxDemo() {
  return (
    <div>
      <p className="title">Redux Demo</p>
      {[0, 1, 2].map((index) => (
        <Room key={index} index={index} />
      ))}
    </div>
  );
}

export default function Root() {
  return (
    <Provider store={store}>
      <ReduxDemo />
    </Provider>
  );
}

这里使用了 useSelector 进行状态的获取, useSelector 默认行为是在一个 action 被 dispatch 之后, 会对返回的选取状态进行严格比较, 如果相同组件不渲染, 否则重新渲染. isLit 作为 primitive type, 能够进行严格地址比较, 因此不再触发重新渲染.

当然也可以使用 connectmapState, 而且性能方面会比 useSelector 更好, 因为做的是浅比较, 且 connect 返回的组件是用 React.memo 包裹的. 这里不多细究, 细节方面可以查阅官方文档

总结

这篇文章的示例参考的是一个视频: React Context API vs. Redux 有兴趣可以观看视频, 印象可能更深

参考

@hacker0limbo hacker0limbo added redux redux 笔记整理 react react 笔记整理 labels Jul 19, 2022
@Ryan-eng-del
Copy link

受教了,大佬

@Ryan-eng-del
Copy link

说的非常清楚,谢谢大佬,解决了我对React 状态管理优化的很多疑惑和问题

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
react react 笔记整理 redux redux 笔记整理
Projects
None yet
Development

No branches or pull requests

2 participants