我在大厂写React学到了什么?性能优化篇

本文涉及的产品
日志服务 SLS,月写入数据量 50GB 1个月
简介: 我工作中的技术栈主要是 React + TypeScript,这篇文章我想总结一下如何在项目中运用 React 的一些技巧去进行性能优化,或者更好的代码组织。性能优化的重要性不用多说,谷歌发布的很多调研精确的展示了性能对于网站留存率的影响,而代码组织优化则关系到后续的维护成本,以及你同事维护你代码时候“口吐芬芳”的频率😁,本篇文章看完,你一定会有所收获。


前言


我工作中的技术栈主要是 React + TypeScript,这篇文章我想总结一下如何在项目中运用 React 的一些技巧去进行性能优化,或者更好的代码组织。

性能优化的重要性不用多说,谷歌发布的很多调研精确的展示了性能对于网站留存率的影响,而代码组织优化则关系到后续的维护成本,以及你同事维护你代码时候“口吐芬芳”的频率😁,本篇文章看完,你一定会有所收获。


神奇的 children


我们有一个需求,需要通过 Provider 传递一些主题信息给子组件:

看这样一段代码:

import React, { useContext, useState } from "react";
const ThemeContext = React.createContext();
export function ChildNonTheme() {
  console.log("不关心皮肤的子组件渲染了");
  return <div>我不关心皮肤,皮肤改变的时候别让我重新渲染!</div>;
}
export function ChildWithTheme() {
  const theme = useContext(ThemeContext);
  return <div>我是有皮肤的哦~ {theme}</div>;
}
export default function App() {
  const [theme, setTheme] = useState("light");
  const onChangeTheme = () => setTheme(theme === "light" ? "dark" : "light");
  return (
    <ThemeContext.Provider value={theme}>
      <button onClick={onChangeTheme}>改变皮肤</button>
      <ChildWithTheme />
      <ChildNonTheme />
      <ChildNonTheme />
      <ChildNonTheme />
      <ChildNonTheme />
      <ChildNonTheme />
      <ChildNonTheme />
      <ChildNonTheme />
    </ThemeContext.Provider>
  );
}

这段代码看起来没啥问题,也很符合撸起袖子就干的直觉,但是却会让 ChildNonTheme 这个不关心皮肤的子组件,在皮肤状态更改的时候也进行无效的重新渲染。

这本质上是由于 React 是自上而下递归更新,<ChildNonTheme /> 这样的代码会被 babel 翻译成 React.createElement(ChildNonTheme) 这样的函数调用,React官方经常强调 props 是immutable 的,所以在每次调用函数式组件的时候,都会生成一份新的 props 引用。

来看下 createElement 的返回结构:

const childNonThemeElement = {
  type: 'ChildNonTheme',
  props: {} // <- 这个引用更新了
}

正是由于这个新的 props 引用,导致 ChildNonTheme 这个组件也重新渲染了。

那么如何避免这个无效的重新渲染呢?关键词是「巧妙利用 children」。

import React, { useContext, useState } from "react";
const ThemeContext = React.createContext();
function ChildNonTheme() {
  console.log("不关心皮肤的子组件渲染了");
  return <div>我不关心皮肤,皮肤改变的时候别让我重新渲染!</div>;
}
function ChildWithTheme() {
  const theme = useContext(ThemeContext);
  return <div>我是有皮肤的哦~ {theme}</div>;
}
function ThemeApp({ children }) {
  const [theme, setTheme] = useState("light");
  const onChangeTheme = () => setTheme(theme === "light" ? "dark" : "light");
  return (
    <ThemeContext.Provider value={theme}>
      <button onClick={onChangeTheme}>改变皮肤</button>
      {children}
    </ThemeContext.Provider>
  );
}
export default function App() {
  return (
    <ThemeApp>
      <ChildWithTheme />
      <ChildNonTheme />
      <ChildNonTheme />
      <ChildNonTheme />
      <ChildNonTheme />
      <ChildNonTheme />
      <ChildNonTheme />
      <ChildNonTheme />
    </ThemeApp>
  );
}

没错,唯一的区别就是我把控制状态的组件和负责展示的子组件给抽离开了,通过 children 传入后直接渲染,由于 children 从外部传入的,也就是说 ThemeApp 这个组件内部不会再有 React.createElement 这样的代码,那么在 setTheme 触发重新渲染后,children 完全没有改变,所以可以直接复用。

让我们再看一下被 ThemeApp 包裹下的 <ChildNonTheme />,它会作为 children 传递给 ThemeAppThemeApp 内部的更新完全不会触发外部的 React.createElement,所以会直接复用之前的 element 结果:

// 完全复用,props 也不会改变。
const childNonThemeElement = {
  type: ChildNonTheme,
  props: {}
}

在改变皮肤之后,控制台空空如也!优化达成。

总结下来,就是要把渲染比较费时,但是不需要关心状态的子组件提升到「有状态组件」的外部,作为 children 或者props传递进去直接使用,防止被带着一起渲染。

神奇的 children - 在线调试地址

当然,这个优化也一样可以用 React.memo 包裹子组件来做,不过相对的增加维护成本,根据场景权衡选择吧。


Context 读写分离


想象一下,现在我们有一个全局日志记录的需求,我们想通过 Provider 去做,很快代码就写好了:

import React, { useContext, useState } from "react";
import "./styles.css";
const LogContext = React.createContext();
function LogProvider({ children }) {
  const [logs, setLogs] = useState([]);
  const addLog = (log) => setLogs((prevLogs) => [...prevLogs, log]);
  return (
    <LogContext.Provider value={{ logs, addLog }}>
      {children}
    </LogContext.Provider>
  );
}
function Logger1() {
  const { addLog } = useContext(LogContext);
  console.log('Logger1 render')
  return (
    <>
      <p>一个能发日志的组件1</p>
      <button onClick={() => addLog("logger1")}>发日志</button>
    </>
  );
}
function Logger2() {
  const { addLog } = useContext(LogContext);
  console.log('Logger2 render')
  return (
    <>
      <p>一个能发日志的组件2</p>
      <button onClick={() => addLog("logger2")}>发日志</button>
    </>
  );
}
function LogsPanel() {
  const { logs } = useContext(LogContext);
  return logs.map((log, index) => <p key={index}>{log}</p>);
}
export default function App() {
  return (
    <LogProvider>
      {/* 写日志 */}
      <Logger1 />
      <Logger2 />
      {/* 读日志 */}
      <LogsPanel />
      </div>
    </LogProvider>
  );
}

我们已经用上了上一章节的优化小技巧,单独的把 LogProvider 封装起来,并且把子组件提升到外层传入。

先思考一下最佳的情况,Logger 组件只负责发出日志,它是不关心logs的变化的,在任何组件调用 addLog 去写入日志的时候,理想的情况下应该只有 LogsPanel 这个组件发生重新渲染。

但是这样的代码写法却会导致每次任意一个组件写入日志以后,所有的 LoggerLogsPanel 都发生重新渲染。

这肯定不是我们预期的,假设在现实场景的代码中,能写日志的组件可多着呢,每次一写入就导致全局的组件都重新渲染?这当然是不能接受的,发生这个问题的本质原因官网 Context 的部分已经讲得很清楚了:

640.png

LogProvider 中的 addLog 被子组件调用,导致 LogProvider重渲染之后,必然会导致传递给 Provider 的 value 发生改变,由于 value 包含了 logssetLogs 属性,所以两者中任意一个发生变化,都会导致所有的订阅了 LogProvider 的子组件重新渲染。

那么解决办法是什么呢?其实就是读写分离,我们把 logs(读)和 setLogs(写)分别通过不同的 Provider 传递,这样负责写入的组件更改了 logs,其他的「写组件」并不会重新渲染,只有真正关心 logs 的「读组件」会重新渲染。

function LogProvider({ children }) {
  const [logs, setLogs] = useState([]);
  const addLog = useCallback((log) => {
    setLogs((prevLogs) => [...prevLogs, log]);
  }, []);
  return (
    <LogDispatcherContext.Provider value={addLog}>
      <LogStateContext.Provider value={logs}>
        {children}
      </LogStateContext.Provider>
    </LogDispatcherContext.Provider>
  );
}

我们刚刚也提到,需要保证 value 的引用不能发生变化,所以这里自然要用 useCallbackaddLog 方法包裹起来,才能保证 LogProvider 重渲染的时候,传递给的LogDispatcherContext的value 不发生变化。

现在我从任意「写组件」发送日志,都只会让「读组件」LogsPanel 渲染。

Context 读写分离 - 在线调试


Context 代码组织


上面的案例中,我们在子组件中获取全局状态,都是直接裸用 useContext

import React from 'react'
import { LogStateContext } from './context'
function App() {
  const logs = React.useContext(LogStateContext)
}

但是是否有更好的代码组织方法呢?比如这样:

import React from 'react'
import { useLogState } from './context'
function App() {
  const logs = useLogState()
}
// context
import React from 'react'
const LogStateContext = React.createContext();
export function useLogState() {
  return React.useContext(LogStateContext)
}

在加上点健壮性保证?

import React from 'react'
const LogStateContext = React.createContext();
const LogDispatcherContext = React.createContext();
export function useLogState() {
  const context = React.useContext(LogStateContext)
  if (context === undefined) {
    throw new Error('useLogState must be used within a LogStateProvider')
  }
  return context
}
export function useLogDispatcher() {
  const context = React.useContext(LogDispatcherContext)
  if (context === undefined) {
    throw new Error('useLogDispatcher must be used within a LogDispatcherContext')
  }
  return context
}

如果有的组件同时需要读写日志,调用两次很麻烦?

export function useLogs() {
  return [useLogState(), useLogDispatcher()]
}
export function App() {
  const [logs, addLogs] = useLogs()
  // ...
}

根据场景,灵活运用这些技巧,让你的代码更加健壮优雅~


组合 Providers


假设我们使用上面的办法管理一些全局的小状态,Provider 变的越来越多了,有时候会遇到嵌套地狱的情况:

const StateProviders = ({ children }) => (
  <LogProvider>
    <UserProvider>
      <MenuProvider>
        <AppProvider>
          {children}
        </AppProvider>
      </MenuProvider>
    </UserProvider>
  </LogProvider>
)
function App() {
  return (
    <StateProviders>
      <Main />
    </StateProviders>
  )
}

有没有办法解决呢?当然有,我们参考 redux 中的 compose 方法,自己写一个 composeProvider 方法:

function composeProviders(...providers) {
  return ({ children }) =>
    providers.reduce(
      (prev, Provider) => <Provider>{prev}</Provider>,
      children,
    )
}

代码就可以简化成这样:

const StateProviders = composeProviders(
  LogProvider,
  UserProvider,
  MenuProvider,
  AppProvider,
)
function App() {
  return (
    <StateProvider>
      <Main />
    </StateProvider>
  )
}


总结


本篇文章主要围绕这 Context 这个 API,讲了几个性能优化和代码组织的优化点,总结下来就是:

1、尽量提升渲染无关的子组件元素到「有状态组件」的外部。

2、在需要的情况下对 Context 进行读写分离。

3、包装Context 的使用,注意错误处理。

4、组合多个 Context,优化代码。

相关实践学习
日志服务之使用Nginx模式采集日志
本文介绍如何通过日志服务控制台创建Nginx模式的Logtail配置快速采集Nginx日志并进行多维度分析。
相关文章
|
2月前
|
存储 前端开发 JavaScript
深入理解React Fiber架构及其性能优化
【10月更文挑战第5天】深入理解React Fiber架构及其性能优化
110 1
|
25天前
|
前端开发 JavaScript 算法
探索现代前端框架——React 的性能优化策略
探索现代前端框架——React 的性能优化策略
21 0
|
25天前
|
前端开发 JavaScript API
探索现代前端框架——React 的性能优化策略
探索现代前端框架——React 的性能优化策略
27 0
|
1月前
|
Web App开发 前端开发 JavaScript
React性能优化指南:打造流畅的用户体验
React性能优化指南:打造流畅的用户体验
|
7月前
|
前端开发 API 开发者
你可能没有关注过的 React 性能优化,帮你突破瓶颈
你可能没有关注过的 React 性能优化,帮你突破瓶颈
|
3月前
|
前端开发 JavaScript UED
深入React Hooks与性能优化实践
深入React Hooks与性能优化实践
54 0
|
4月前
|
开发者 搜索推荐 Java
超越传统:JSF自定义标签库如何成为现代Web开发的个性化引擎
【8月更文挑战第31天】JavaServer Faces(JSF)框架支持通过自定义标签库扩展其内置组件,以满足特定业务需求。这涉及创建`.taglib`文件定义标签库及组件,并实现对应的Java类与渲染器。本文介绍如何构建和应用JSF自定义标签库,包括定义标签库、实现标签类与渲染器逻辑,以及在JSF页面中使用这些自定义标签,从而提升代码复用性和可维护性,助力开发更复杂且个性化的Web应用。
79 0
|
4月前
|
前端开发 测试技术 UED
React性能优化的神奇之处:如何用懒加载与代码分割让你的项目一鸣惊人?
【8月更文挑战第31天】在现代Web开发中,性能优化至关重要。本文探讨了React中的懒加载与代码分割技术,通过示例展示了如何在实际项目中应用这些技术。懒加载能够延迟加载组件,提高页面加载速度;代码分割则将应用程序代码分割成多个块,按需加载。两者结合使用,可以显著提升用户体验。遵循合理使用懒加载、编写测试及关注性能等最佳实践,能够更高效地进行性能优化,提升应用程序的整体表现。随着React生态的发展,懒加载与代码分割技术将在未来Web开发中发挥更大作用。
55 0
|
4月前
|
缓存 前端开发 JavaScript
React.memo 与 useMemo 超厉害!深入浅出带你理解记忆化技术,让 React 性能优化更上一层楼!
【8月更文挑战第31天】在React开发中,性能优化至关重要。本文探讨了`React.memo`和`useMemo`两大利器,前者通过避免不必要的组件重渲染提升效率,后者则缓存计算结果,防止重复计算。结合示例代码,文章详细解析了如何运用这两个Hook进行性能优化,并强调了合理选择与谨慎使用的最佳实践,助你轻松掌握高效开发技巧。
108 0
|
5月前
|
缓存 监控 前端开发
react 性能优化方案?
【7月更文挑战第15天】改善React应用性能的关键策略包括:使用生产环境构建减少体积,避免不必要的渲染(如用React.memo或PureComponent),正确设置列表渲染的key,简化组件层级,实施懒加载,避免render中的复杂计算,选择优化过的库,控制重渲染范围,监控性能并合并state更新。这些优化能提升响应速度和用户体验。
63 0