Redxu(RTK) 基础 性能与数据范式化 6.3 提升渲染性能

调研渲染行为 (1.使用profiler观察组件的渲染情况,引出某个组件的“异常渲染”问题,再解释useSelector和组件重渲染的关系 )

这里使用到了redux profiler这个调试工具(当然得提前安装好React devtools这个chrome),
以我们已经编写好的页面举例子:

Profiler可以“录制”组件是如何渲染的,当点击“刷新动态”按钮后,该UI发起一个thunk向后端发起请求,并最终获得后端返回的数据,当数据发生变化,动态tab上的badge发生了变化,从0变成了5,通过录制这个过程,我们可以在profiler中发现,Navbar和Navbar组件里的两个Link组件 render了(当然这里是指数据变化引起了这两个组件的‘重渲染rerender’),另外UserPage组件也render了。

(如果你按照文档或者是我的博客编写到这里却不能复现Profiler的内容,我建议你可以去看看我对Profiler操作步骤的介绍视频)

下面是该页面的组件树:

因为点击刷新动态后,动态数据发生了变化,react可以是数据驱动的,而navbar的动态标签中的badge是由动态数据驱动的,所以navbar 发生了render行为可以理解。

但是为什么UserPage也render了? UserPage中的什么数据发生了变化而导致它的rerender?

这里要介绍一下useSelector钩子,摘抄它的几个特点

  1. 当dispatch 任何一个action,当前在页面上存续的组件中的 useSelector都会把前一次selector函数返回的数据和现在selector函数返回的数据做比较,如果数据不同,组件就会被强制重渲染,如果两次选取数据结果相同,组件就不会重渲染。
  2. 这个比较过程中,useSelector默认用严格比较/深比较===进行引用是否相等的比较(reference equality )。
  3. 当使用useSelector,如果其内部的selector函数每次都返回一个新对象,因为深比较结果一定不相等,所以每次dispatch action,都会触发重渲染。

在理解了上面三点后,再看下面代码

export const UserPage = ({}) => {
  const { userId } = useParams();

  const user = useAppSelector((state) => selectUserById(state, userId));

  const postsForUser = useAppSelector((state) => {
    const allPosts = selectAllPosts(state);
    return allPosts.filter((post) => post.user === userId);
  });

可见,postsForUser 对应的selector内,return allPosts.filter((post) => post.user === userId);这一行因为使用了filter,所以每一次都会返回一个新的数组,引用不同,所以useSelector的“严格比较”不通过,所以只要这个页面存续期间,任何action被dispatch,都会导致该页面的重渲染。

那么应该怎么做?办法其实有很多,但是我也建议使用rtk提供的api,必经你也不想手动重写useSelector的比较函数,或者是引用其他的库却只为了解决这个相对来说小的问题吧?

记忆化的selector

rtk提供了createSelector这个api,可以创建 “有记忆”的selector,只要输入不变化,记忆化的selector就不会去重新计算,也就不会触发重渲染呢。
createSelector接受两个参数,分别是一个数组和一个函数,数组里边可以传入一个或多个selector,称为输入selector,那个函数就是 输出selector.

上面的那个postsForUser里边因为有用到filter,那么每次返回的数组即使内容一样,其引用不一样,也会被useSelector看做“结果不同”,所以会引发重渲染,而记忆化的selector不一样:

因为其“输入不变,不重新计算”的特性,就不会引发useSelector的重渲染。

具体代码如下:

export const UserPage = ({}) => {
  const { userId } = useParams();

  const user = useAppSelector((state) => selectUserById(state, userId));
  //这里使用的是旧的selector
  // const postsForUser = useAppSelector((state) => {
  //   const allPosts = selectAllPosts(state);
  //   return allPosts.filter((post) => post.user === userId);
  // });
  const selectPostsByUser = createSelector(
    [selectAllPosts, (state, userId) => userId],
    (posts, userId) => posts.filter((post) => post.user === userId)
  );
  const postsForUser = useAppSelector((state) =>
    selectPostsByUser(state, userId)
  );

  const postTitles = postsForUser.map((post) => (
    <li key={post.id + Math.random().toString()}>
      <Link to={`/posts/${post.id}`}>{post.title}</Link>
    </li>
  ));
//省略其他内容!
避免子组件不必要的重渲染part1 React.memo

首先修复一个React.strictMode(严格模式)引起的bug,因为严格模式会让开发模式下的某些 钩子函数强制运行两次,我们在PostList中使用useEffect向后端拉取数据的操作也会进行两次。

我们使用useRef来进行缓存避免该行为。

const cached = React.useRef(false);
  const dispatch = useAppDispatch();
  const posts = useAppSelector(selectAllPosts);

  const postStatus = useAppSelector((state) => state.posts.status);
  const error = useAppSelector((state) => state.posts.error);

  useEffect(() => {
    if (postStatus === "idle" && cached.current === false) {
      cached.current = true;
      dispatch(fetchPosts());
    }
  }, [postStatus, dispatch]);

//省略其他内容

好,上个bug解决了,下面开始讨论另外一个问题,首先要知道,React默认渲染模式是:React 的默认行为是当父组件渲染时,React 会递归渲染其中的所有子组件!
具体可以看这个博客详细解释React渲染特性的博客

然后我们看一下我们页面的组件树:看下图!

可见,一个PostList作为父组件,渲染了多个PostExcerpt组件。

PostList组件由redux store中的 posts数据驱动渲染,

//这是PostList接受用于渲染页面的数据的部分
export const PostList = () => {
  const cached = React.useRef(false);
  const dispatch = useAppDispatch();
  const posts = useAppSelector(selectAllPosts);

  const postStatus = useAppSelector((state) => state.posts.status);
  const error = useAppSelector((state) => state.posts.error);
//省略其他内容

在PostList中,对posts进行map,在每次遍历的循环体内,把post数据传递给PostExcerpt使用,来进行其渲染。

 if (postStatus === "pending") {
    content = <div>正在加载捏!</div>;
  } else if (postStatus === "success") {
    // Sort posts in reverse chronological order by datetime string
    const orderedPosts = posts
      .slice()
      .sort((a, b) => b.date.localeCompare(a.date));

    content = orderedPosts.map((post) => (
      <PostExcerpt key={post.id + Math.random().toString()} post={post} />
    ));
  } else if (postStatus === "failed") {
    content = <div>{error}</div>;
  }

如果我们点击PostExcerpt上的图标,会引起Redux中posts数据的变化,这是由于该图标的状态数据也存储在posts数据中,

而posts数据的变化定然会引起PostList的重渲染,这是毫无疑问的,因为PostList中,这一行 const posts = useAppSelector(selectAllPosts);就选取使用了posts数据。

而PostList的子组件PostExcerpt会因为父组件的重渲染而被递归渲染,文档推荐我们使用React.memo对PostExcerpt进行封装,React.memo的机制是:
对被封装的组件的prop进行检测,如果两次渲染之间prop没有发生变化,那么被封装的组件不会被重新渲染,这个思路正确,但是对我们编写的代码是无效的,

因为我编写的代码内部存在另一个Bug,啊啊啊愁死我了。
(我尝试使用React.memo封装组件但失败了,后来我给React.memo传入第二个参数做比较函数,但是发现重渲染过程中,比较函数没有被运行,说明React.memo没有被触发,所以我意识到问题不出在React.memo上)
首先咱们看看这一行: <PostExcerpt key={post.id+Math.random().toString()} post={post} />

注意key重使用了Math.random生成随机数,默认情况下每个post.id应该是不同的,而我为其增加随机数原因在于上一个bug(严格模式useEffect拉取同样数据两次),

看下面伪代码的解释:

//这是简化的posts数据数组
//他代表从后端拉取两次后得到的数据
//因为严格模式导致的重复拉取,数组中存在重复数据,比如数组中前两个对象,内容都是{id:1 }
let posts=[
{id:1}
{id:1}
{id:2}
{id:2}
]
//那么对posts数组进行遍历并使用遍历结果进行渲染,传入id到PostExcerpt的key,就会出现下面情况
 <PostExcerpt1 key=1>
 <PostExcerpt2 key=1>
 <PostExcerpt3 key=2>
 <PostExcerpt4 key=2>

因为react中遍历生成的组件的key必须不同,我为其添加了Math.random()确保每个组件接收到的key必然不同, <PostExcerpt key={post.id+Math.random().toString()} post={post} />
但是这恰恰引起了Bug,啊靠!原因如下:
React.memo - why is my equality function not being called?
看上去有人跟我经历了同样问题:

In short, the reason of this behaviour is due to the way React works.
React expects a unique key for each of the components so it can keep track and know which is which. By using shortid.generate() a new value of the key is created, the reference to the component changes and React thinks that it is a completely new component, which needs rerendering.
In your case, on any change of props in the parent, React will renrender all of the children because the keys are going to be different for all of the children as compared to the previous render.

简单说,React中遍历生成的每个组件都需要独一无二的key,用于帮助react跟踪区分哪个是哪个,
但是这个值在多次渲染中不应该发生变化,首次渲染,该组件的key是1,重渲染时,该组件key还应该是是1,如果发生变化,那么react会认为该组件是全新的组件。
所以我们一般不使用随机生成的值,而使用后端拉取到的post.id/user.id这样的确认不变且独一无二的值,
如果使用Math.random生成值,虽然独一无二,但是每次组件渲染,Math.random都会重新运行,这导致react认为该组件是全新组件,react就一定会重新刷新组件,这个时候 React.memo就已经被跳过了,所以看上去React.memo失效了,实际上是因为我传入了随机的key导致react按照其默认行为渲染了组件。

下面就是修改bug后的新的memo封装的组件:

//这是注释,显示文件路径捏:/src/features/posts/PostList.tsx

import { PostAuthor } from "./PostAuthor";
import { TimeAgo } from "./TimeAgo";
import { ReactionButtons } from "./ReactionButtons";
import { selectAllPosts, fetchPosts, IPost } from "./postsSlice";
import { Link } from "react-router-dom";
import { useAppDispatch, useAppSelector } from "../../app/hooks";
import React, { useEffect } from "react";
import { createSelector } from "@reduxjs/toolkit";

let PostExcerpt = React.memo(({ post }: { post: IPost }) => {
  return (
    <article className="post-excerpt" key={post.id + Math.random().toString()}>
      <h6
        style={{
          color: "skyblue",
        }}
      >
        这是PostExcerpt组件捏
      </h6>
      <h3>{post.title}</h3>
      <div>
        <PostAuthor userId={post.user} />
        <TimeAgo timestamp={post.date} />
      </div>
      <p className="post-content">{post.content.substring(0, 100)}</p>

      <ReactionButtons post={post} />
      <Link to={`/posts/${post.id}`} className="button muted-button">
        View Post
      </Link>
    </article>
  );
});

export const PostList = () => {
  const cached = React.useRef(false);
  const dispatch = useAppDispatch();
  const posts = useAppSelector(selectAllPosts);

  const postStatus = useAppSelector((state) => state.posts.status);
  const error = useAppSelector((state) => state.posts.error);

  useEffect(() => {
    if (postStatus === "idle" && cached.current === false) {
      cached.current = true;
      dispatch(fetchPosts());
    }
  }, [postStatus, dispatch]);

  let content;

  if (postStatus === "pending") {
    content = <div>正在加载捏!</div>;
  } else if (postStatus === "success") {
    // Sort posts in reverse chronological order by datetime string
    const orderedPosts = posts
      .slice()
      .sort((a, b) => b.date.localeCompare(a.date));

    content = orderedPosts.map((post) => (
      <PostExcerpt key={post.id} post={post} />
    ));
  } else if (postStatus === "failed") {
    content = <div>{error}</div>;
  }

  return (
    <section className="posts-list">
      <h6
        style={{
          color: "skyblue",
        }}
      >
        这是PostList捏
      </h6>
      <h2>Posts</h2>
      {content}
    </section>
  );
};

export default PostList;

可见,现在React.memo生效,PostExcerpt不再进行不必要的渲染了!

避免父组件不必要的渲染 part2 让组件选择(selector)的数据尽可能少!

啊好棒,下面开始介绍文档说的另外两个避免不必要的渲染的思路!

上面我们使用memo去规避了父组件重渲染对子组件的影响,现在我们考虑另外一种情况,PostList 使用useSelector接受了三组数组,posts,postStatus,error,后两者仅仅在fetchPost 这个thunk运行的时候发生改变,但是posts数据因为包含了具体的每一个post的reactions数据,所以每次点击页面上的图标按钮,都会导致posts发生变化变化,而posts数据的变化就会引起选择该数据的useSelector钩子进行对应的重渲染。

也就是说每点击PostExcerpt上的按钮,都会导致其父组件重渲染,额,怎么规避?

文档介绍的思路是这样的:

  1. 首先,让PostList接受尽可能少、尽可能简单的数据
  2. 为PostList接受的数据做记忆化的selector
    核心就是上面两点,我们看具体实现:
//这是注释,显示文件路径捏:/src/features/posts/PostList.tsx

import { PostAuthor } from "./PostAuthor";
import { TimeAgo } from "./TimeAgo";
import { ReactionButtons } from "./ReactionButtons";
import {
  selectAllPosts,
  fetchPosts,
  IPost,
  selectPostIdList,
  selectPostById,
} from "./postsSlice";
import { Link } from "react-router-dom";
import { useAppDispatch, useAppSelector } from "../../app/hooks";
import React, { useEffect } from "react";
import { createSelector } from "@reduxjs/toolkit";

let PostExcerpt = ({ id }: { id: string }) => {
  const post = useAppSelector((state) => selectPostById(state, id));
  if (post === undefined) {
    return <div>不存在</div>;
  }
  return (
    <article className="post-excerpt" key={id}>
      <h6
        style={{
          color: "skyblue",
        }}
      >
        这是PostExcerpt组件捏
      </h6>
      <h3>{post.title}</h3>
      <div>
        <PostAuthor userId={post.user} />
        <TimeAgo timestamp={post.date} />
      </div>
      <p className="post-content">{post.content.substring(0, 100)}</p>

      <ReactionButtons post={post} />
      <Link to={`/posts/${post.id}`} className="button muted-button">
        View Post
      </Link>
    </article>
  );
};

export const PostList = () => {
  const cached = React.useRef(false);
  const dispatch = useAppDispatch();
  const selectIds = createSelector([selectPostIdList], (idList) => {
    return idList;
  });
  const postIdList = useAppSelector(selectIds, (pre, next) => {
    return pre.toString() === next.toString();
  });

  const postStatus = useAppSelector((state) => state.posts.status);
  const error = useAppSelector((state) => state.posts.error);

  useEffect(() => {
    if (postStatus === "idle" && cached.current === false) {
      cached.current = true;
      dispatch(fetchPosts());
    }
  }, [postStatus, dispatch]);

  let content;

  if (postStatus === "pending") {
    content = <div>正在加载捏!</div>;
  } else if (postStatus === "success") {
    // Sort posts in reverse chronological order by datetime string
    // const orderedPosts = posts
    //   .slice()
    //   .sort((a, b) => b.date.localeCompare(a.date));

    content = postIdList.map((id) => <PostExcerpt key={id} id={id} />);
  } else if (postStatus === "failed") {
    content = <div>{error}</div>;
  }

  return (
    <section className="posts-list">
      <h6
        style={{
          color: "skyblue",
        }}
      >
        这是PostList捏
      </h6>
      <h2>Posts</h2>
      {content}
    </section>
  );
};

export default PostList;

注意,这里对useSelector传入了第二个参数,现在默认的selector比较前后两个数据的行为被改写成我们新函数的逻辑。
另外,为了实现选择idList的功能,在PostSlice中编写了selector,而为了保持PostList渲染post是按照发帖时间排列,但是PostList已经不能接收posts数据了,就直接在reducer中进行排序处理了捏!

//这是注释,显示文件路径捏:/src/features/posts/postsSlice.ts

import { RootState } from "./../../app/store";
import {
  createAsyncThunk,
  createSlice,
  nanoid,
  PayloadAction,
  createSelector,
} from "@reduxjs/toolkit";
import { client } from "../../api/client";
interface IState {
  posts: IPost[];
  status: "idle" | "pending" | "success" | "failed";
  error: null | any;
}
const initialState: IState = {
  posts: [],
  status: "idle",
  error: null,
};
export type Treactions = {
  thumbsUp: number;
  hooray: number;
  heart: number;
  rocket: number;
  eyes: number;
};
export type IPost = {
  id: string;
  content: string;
  title: string;
  //类型见usersSlice里边的内容捏
  user: string;
  //注意这里也新增了date,我试验了一下,他对ui中dispatch action没任何影响,
  //但它可以直接约束reducer的prepare中payload的构建
  //我想了一下,prepare的形参就是dispatch action时候,actionCreator的形参
  // 但是prepare内部预构建了id/date,所以这样看逻辑也很通,但是具体Typescript怎么做的,我实在不知道。
  date: string;
  reactions: Treactions;
};

export const fetchPosts = createAsyncThunk("posts/fetchPosts", async () => {
  const response = await client.get("/fakeApi/posts");
  return response.data;
});
export const addNewPost = createAsyncThunk(
  "posts/addNewPost",
  // payload 创建者接收部分“{title, content, user}”对象
  async (initialPost: Pick<IPost, "title" | "user" | "content">) => {
    // 我们发送初始数据到 API server
    const response = await client.post("/fakeApi/posts", initialPost);
    // 响应包括完整的帖子对象,包括唯一 ID
    return response.data;
  }
);

const postsSlice = createSlice({
  name: "posts",
  initialState: initialState,
  //记住吖,处理数据就是reducer的责任捏!
  //同步的数据处理就全在这里呢!
  reducers: {
    postAdd: {
      reducer: (state, action: PayloadAction<IPost>) => {
        state.posts.push(action.payload);
      },
      prepare: (title, content, userId) => {
        return {
          payload: {
            id: nanoid(),
            date: new Date().toISOString(),
            title,
            content,
            user: userId,
            reactions: {
              thumbsUp: 0,
              hooray: 0,
              heart: 0,
              rocket: 0,
              eyes: 0,
            },
          },
        };
      },
    },
    postUpdated: (state, action) => {
      const { id, title, content } = action.payload;
      const existingPost = state.posts.find((post) => post.id === id);
      if (existingPost) {
        existingPost.title = title;
        existingPost.content = content;
      } else {
        // 好歹报个错吧
        console.log("花Q,你在干啥?");
      }
    },

    reactionAdded(state, action) {
      const { postId, reaction } = action.payload;
      const existingPost = state.posts.find((post) => post.id === postId);
      if (existingPost) {
        // https://stackoverflow.com/questions/57086672/element-implicitly-has-an-any-type-because-expression-of-type-string-cant-b
        existingPost.reactions[reaction as keyof Treactions]++;
      }
    },
    //end

    //如果你想给UI加上交互式的 排序。。。就得这么做,但是文档没介绍,我们先注释掉。。
    // postSort: (state) => {
    //   state.sort((a, b) => b.date.localeCompare(a.date));
    // },
  },
  extraReducers(builder) {
    builder
      .addCase(fetchPosts.pending, (state, action) => {
        state.status = "pending";
      })
      .addCase(fetchPosts.fulfilled, (state, action) => {
        state.status = "success";
        // Add any fetched posts to the array
        state.posts = state.posts.concat(action.payload);
        state.posts.sort((a, b) => b.date.localeCompare(a.date));
      })
      .addCase(fetchPosts.rejected, (state, action) => {
        state.status = "failed";
        state.error = action.error.message;
      })
      .addCase(addNewPost.fulfilled, (state, action) => {
        state.posts = state.posts.concat(action.payload);
      });
  },
});

export const { postAdd, postUpdated, reactionAdded } = postsSlice.actions;

export const selectAllPosts = (state: RootState) => {
  return state.posts.posts;
};

export const selectPostById = (state: RootState, postId: string | undefined) =>
  state.posts.posts.find((post) => post.id === postId);

export const selectPostIdList = (state: RootState) =>
  state.posts.posts.map((item) => item.id);
export default postsSlice.reducer;

上面的内容,我再絮叨一下,核心在于,减少父组件接受的数组的“范围”,这样父组件收到的数据变化的影响的可能性就减少,不必要的渲染就会被避免,而在这种遍历渲染的子组件中,使用id这种标识来有针对性的进行重渲染,同样也把可能引起子组件重渲染的数据(变化)限制在了比较小的范围里呢捏!

posted @ 2023-03-20 16:43  刘老六  阅读(86)  评论(0编辑  收藏  举报