hacker0limbo / my-blog

个人博客
5 stars 1 forks source link

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

Open hacker0limbo opened 2 years ago

hacker0limbo commented 2 years ago

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

Demo 效果大致如下:

demo

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

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

一些基本总结

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

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

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 有兴趣可以观看视频, 印象可能更深

参考

Ryan-eng-del commented 1 year ago

受教了,大佬

Ryan-eng-del commented 1 year ago

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