关于 React 性能优化和数栈产品中的实践

寻技术 Html/CSS / React 2023年12月18日 80

我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。

本文作者:的卢

引入

在日常开发过程中,我们会使用很多性能优化的 API,比如像使用 memouseMemo优化组件或者值,再比如使用 shouldComponentUpdate减少组件更新频次,懒加载等等,都是一些比较好的性能优化方式,今天我将从组件设计、结构上来谈一下 React 性能优化以及数栈产品内的实践。

如何设计组件会有好的性能?

先看下面一张图:

file

这是一颗 React 组件树,App 下面有三个子组件,分别是 HeaderContentFooter,在 Content组件下面又分别有 FolderTreeWorkBenchSiderBar三个子组件,现在如果在 WorkBench 中触发一次更新,那么 React 会遍历哪些组件呢?Demo1

file

function FolderTree() {
  console.log('render FolderTree');
  return <p>folderTree</p>;
}

function SiderBar() {
  console.log('render siderBar');
  return <p>i'm SiderBar</p>;
}

export const WorkBenchGrandChild = () => {
  console.log('render WorkBenchGrandChild');
  return <p>i'm WorkBenchGrandChild</p>
};

export const WorkBenchChild = () => {
  console.log('render WorkBenchChild');
  return (
    <>
      <p>i'm WorkBenchChild</p>
      <WorkBenchGrandChild />
    </>
  );
};

function WorkBench() {
  const [num, setNum] = useState<number>(1);
  console.log('render WorkBench');
  return (
    <>
      <input
        value={num}
        onChange={(e) => {
          setNum(+e.target.value || 0);
        }}
      />
      <p>num is {num}</p>
      <WorkBenchChild />
    </>
  );
}


function Content() {
  console.log('render content');
  return (
    <>
      <FolderTree />
      <WorkBench />
      <SiderBar />
    </>
  );
};

function Footer() {
  console.log('render footer');
  return <p>i'm Footer</p>
};


function Header() {
  console.log('render header');
  return <p>i'm Header</p>;
}


// Demo1
function App() {
  // const [, setStr] = useState<string>();
  return (
    <>
      <Header />
      <Content />
      <Footer />
      {/* <input onChange={(e) => { setStr(e.target.value) }} /> */}
    </>
  );
};

file

根据上面断点和日志就可以得到下面的结论:

  1. 子孙组件每触发一次更新,React都会重新遍历整颗组件树

input 输入数字,引起 updateNum变更状态后,react-dombeginWorkcurrent由顶层组件依次遍历

  1. React更新时会过滤掉未变化的组件,达到减少更新的组件数的目的

在更新过程中,虽然 React重新遍历了组件树,但 没有打印没有变化的 HeaderFooterFolderTreeSiderBar组件内的日志

  1. 父组件状态变化,会引起子组件更新

WorkBenchChild属于 WorkBench的子组件,虽然 WorkBenchChild没有变化,但仍被重新渲染,打印了输入日志,如果更近一步去断点会发现 WorkBenchChildoldPropsnewProps是不相等的,会触发 updateFunctionComponent更新。

综上我们可以得出一个结论,就是 React自身会有一些性能优化的操作,会尽可能只更新变化的组件,比如 Demo1 中 WorkBenchWorkBenchChildWorkBenchGrandChild组件,而会绕开 不变的 HeaderFooter等组件,那么尽可能的让 React更新的粒度就是性能优化的方向,既然尽可能只更新变化的组件,那么如何定义组件是否变化?

如何定义组件是否变化?

React是以数据驱动视图的单向数据流,核心也就是数据,那么什么会影响数据,以及数据的承载方式,有以下几点:

  • props
  • state
  • context
  • 父组件不变!

父组件与当前组件其实没有关联性,放到这里是因为,上面的例子中 WorkBenchChild组件中没有 state、props、context,理论上来说就不变,实际上却重新 render 了,因为 其父组件 WorkBench有状态的变动,所以这里也提了一下,在不使用性能优化 API 的前提下,只要保证 props、state、context & 其父组件不变,那么组件就不变

还是回到刚刚的例子 Demo WorkBench

export const WorkBenchGrandChild = () => {
  console.log('render WorkBenchGrandChild');
  return <p>i'm WorkBenchGrandChild</p>
};

export const WorkBenchChild = () => {
  console.log('render WorkBenchChild');
  return (
    <>
      <p>i'm WorkBenchChild</p>
      <WorkBenchGrandChild />
    </>
  );
};

function WorkBench() {
  const [num, setNum] = useState<number>(1);
  console.log('render WorkBench');
  return (
    <>
      <input
        value={num}
        onChange={(e) => {
          setNum(+e.target.value || 0);
        }}
      />
      <p>num is {num}</p>
      <WorkBenchChild />
    </>
  );
}

export default WorkBench;

看一下这个 demoWorkBench组件有一个 num状态,还有一个 WorkBenchChild的子组件,没有状态,纯渲染组件,同时 WorkBenchChild组件也有一个 纯渲染组件 WorkBenchGrandChild子组件,当输入 input改变 num的值时,WorkBenchChild组件 和 WorkBenchGrandChild组件都重新渲染。我们来分析一下在 WorkBench 组件中,它的子组件 WorkBenchChild 自始至终其实都没有变化,有变化的其实是 WorkBench 中的 状态,但是就是因为 WorkBench 中的 状态发生了变化,导致了其子组件也一并更新,这就带来了一定的性能损耗,找到了问题,那么就需要解决问题。

如何优化?

使用性能优化 API

export const WorkBenchGrandChild = () => {
  console.log('render WorkBenchGrandChild');
  return <p>i'm WorkBenchGrandChild</p>
};

export const WorkBenchChild = React.memo(() => {
  console.log('render WorkBenchChild');
  return (
    <>
      <p>i'm WorkBenchChild</p>
      <WorkBenchGrandChild />
    </>
  );
});

// Demo WorkBench
function WorkBench() {
  const [num, setNum] = useState<number>(1);
  console.log('render WorkBench');
  return (
    <>
      <input
        value={num}
        onChange={(e) => {
          setNum(+e.target.value || 0);
        }}
      />
      <p>num is {num}</p>
      <WorkBenchChild />
    </>
  );
}

export default WorkBench;

file

file

我们可以使用 React.memo()包裹 WorkBenchChild组件,在其 diff的过程中 props改为浅对比的方式达到性能优化的目的,通过断点可以知道 通过 memo包裹的组件在 diffoldPropsnewProps仍然不等,进入了 updateSimpleMemoComponent中了,而 updateSimpleMemoComponent 中有个 shallowEqual浅比较方法是结果相等的,因此没有触发更新,而是复用了组件。

状态隔离(将状态隔离到子组件中)

function ExchangeComp() {
  const [num, setNum] = useState<number>(1);
  console.log('render ExchangeComp');
  return (
    <>
      <input
        value={num}
        onChange={(e) => {
          setNum(+e.target.value || 0);
        }}
      />
      <p>num is {num}</p>
    </>
  );
};

// Demo WorkBench
function WorkBench() {
  // const [num, setNum] = useState<number>(1);
  console.log('render WorkBench');
  return (
    <>
      <ExchangeComp />
      <WorkBenchChild />
    </>
  );
}

export default WorkBench;

file

file

上面 Demo1 的结论,父组件更新,会触发子组件更新,就因为 WorkBench状态改变,导致 WorkBenhChild也更新了,这个时候可以手动创造条件,让 WorkBenchChild的父组件也就是 WorkBench组件剥离状态,没有状态改变,这种情况下 WorkBenchChild 满足了 父组件不变的前提,且没有 statepropscontext,那么也能够达到性能优化的结果。

对比

  1. 结果一样,都是对 WorkBenchChild进行了优化,在 WorkBench组件更新时, WorkBenchChildWorkBenchGrandChild没有重新渲染
  2. 出发点不一样,用 memo 性能优化 API 是直接作用到子组件上面,而状态隔离是在父组件上面操作,而受益的是其子组件

结论

  1. 只要结构写的好,性能不会太差
  2. 父组件不变,子组件可能不变

性能优化方向

  1. 找到项目中性能损耗严重的组件(节点)

在业务项目中,找到卡顿、崩溃 的组件(节点)

  1. 在根组件(节点)上使用性能优化 API

在根组件上使用的目的就是避免其祖先组件如果没有做好组件设计会给根组件带来无效的重复渲染,因为上面提到的,父组件更新,子组件也会更新

  1. 在其他节点上使用 状态隔离的方式进行优化

优化祖先组件,避免给子组件造成无效的重复渲染

总结

我们从 组件结构 和 性能优化 API 上介绍了性能优化的两种不同的优化方式,在实际项目使用上,也并非使用某一种优化方式,而是多种优化方式结合着来以达到最好的性能

产品中的部分实践

  1. 将状态隔离到子组件内部,避免引起不必要的更新

    import React, { useCallback, useEffect, useState } from 'react';
    import { connect } from 'react-redux';
    import type { SelectProps } from 'antd';
    import { Select } from 'antd';
    
    import { fetchBranchApi } from '@/api/project/optionsConfig';
    
    const BranchSelect = (props: SelectProps) => {
    	const [list, setList] = useState<string[]>([]);
    	const [loading, setLoading] = useState<boolean>(false);
    	const { projectId, project, tenantId, ...otherProps } = props;
    	const init = useCallback(async () => {
    		try {
    			setLoading(true);
    			const { code, data } = await fetchBranchApi(params);
    			if (code !== 1) return;
    			setList(data);
    		} catch (err) {
    		} finally {
    			setLoading(false);
    		}
    	}, []);
    	useEffect(() => {
    		init();
    	}, [init]);
    
    	return (
    		<Select
    			showSearch
    			optionFilterProp="children"
    			filterOption={(input, { label }) => {
    				return ((label as string) ?? '')
    					?.toLowerCase?.()
    					.includes?.(input?.toLowerCase?.());
    			}}
    			options={list?.map((value) => ({ label: value, value }))}
    			loading={loading}
    			placeholder="请选择代码分支"
    			{...otherProps}
    			/>
    	);
    };
    
    export default React.memo(BranchSelect);
    

    比如在中后台系统中很多表单型组件 SelectTreeSelectCheckbox,其展示的数据需要通过接口获取,那么此时,如果将获取数据的操作放到父组件,那么每次请求数据不仅会导致需要数据的那个表单项组件更新,同时,其他的表单项也会更新,这就有一定的性能损耗,那么按照上面的例子这样将其状态封装到内部,避免请求数据影响其他组件更新,就可以达到性能优化的目的,一般建议在外层再加上 memo性能优化 API,避免因为外部组件影响内部组件更新。

  2. Canvas render & Svg render

    file

    // 画一个小十字
    export function createPlus(
    		point: { x: number; y: number },
    		{ radius, lineWidth, fill }: { radius: number; lineWidth: number; fill: string }
    ) {
    		// 竖 横
    		const colWidth = point.x - (1 / 2) * lineWidth;
    		const colHeight = point.y - (1 / 2) * lineWidth - radius;
    		const colTop = 2 * radius + lineWidth;
    		const colBottom = colHeight;
    		const rowWidth = point.x - (1 / 2) * lineWidth - radius;
    		const rowHeight = point.y - (1 / 2) * lineWidth;
    		const rowRight = 2 * radius + lineWidth;
    		const rowLeft = rowWidth;
    		return `
    				<path d="M${colWidth} ${colHeight}h${lineWidth}v${colTop}h-${lineWidth}V${colBottom}z" fill="${fill}"></path>
    				<path d="M${rowWidth} ${rowHeight}h${rowRight}v${lineWidth}H${rowLeft}v-${lineWidth}z" fill="${fill}"></path>
    		`;
    }
    
    
    renderPlusSvg = throttle(() => {
    	const plusBackground = document.getElementById(`plusBackground_${this.randomKey}`);
    	const { scrollTop, scrollLeft, clientHeight, clientWidth } = this._container || {};
    	const minWidth = scrollLeft;
    	const maxWidth = minWidth + clientWidth;
    	const minHeight = scrollTop;
    	const maxHeight = minHeight + clientHeight;
    	const stepping = 30;
    	const radius = 3;
    	const fillColor = '#EBECF0';
    	const lineWidth = 1;
    	let innerHtml = '';
    	try {
    		// 根据滚动情况拿到容器的四个坐标点, 只渲染当前滚动容器内的十字,实时渲染
    		for (let x = minWidth; x < maxWidth; x += stepping) {
    			for (let y = minHeight; y < maxHeight; y += stepping) {
    				// 画十字
    				innerHtml += createPlus({ x, y }, { radius, fill: fillColor, lineWidth });
    			}
    		}
    		plusBackground.innerHTML = innerHtml;
    	} catch (e) {}
    });
    

    问题源于在大数据情况下,由 canvas 渲染的 小十字背景渲染失败,经测试,业务数据在 200条左右 canvas 画布绘制宽度就已经达到了 70000px,需要渲染的小十字 数量级在 10w 左右,canvas 不适合绘制尺寸过大的场景(超过某个阀值就会出现渲染失败,具体阀值跟浏览器有关系),而 svg 不适合绘制数量过多的场景,目前的业务场景却是 画布尺寸大,绘制元素多,后面的解决方式就是 采用 svg 渲染,将 画布渲染出来,同时监听容器的滚动事件,同时只渲染滚动容器中可视区域内的背景,实时渲染,渲染数量在 100 左右,实测就无卡顿现象,问题解决

参考:

  1. React 性能优化的一切
  2. React 源码解析之 Fiber渲染
  3. 魔术师卡颂

最后

欢迎关注【袋鼠云数栈UED团队】~
袋鼠云数栈UED团队持续为广大开发者分享技术成果,相继参与开源了欢迎star

  • 大数据分布式任务调度系统——Taier
  • 轻量级的 Web IDE UI 框架——Molecule
  • 针对大数据领域的 SQL Parser 项目——dt-sql-parser
  • 袋鼠云数栈前端团队代码评审工程实践文档——code-review-practices
  • 一个速度更快、配置更灵活、使用更简单的模块打包器——ko
关闭

用微信“扫一扫”