React网站性能优化

By | 7月 7, 2022

1. 前言

之前同事分享过一本书籍《前端性能优化原理与实践》

这本书主要是基于广义上的Frontend进行性能剖析,下面是我们从中已经采用的一些优化方法:

  • 使用gzip压缩传输数据
  • 浏览器缓存
  • 缓存API数据
  • 防抖(debounce)
  • Lazy-Load

但是基于React应用的优化,我们还要具体问题,具体分析。下面是一个React网站性能优化过程中碰到的一些问题以及总结。

2. 分析生成的bundle大小及优化

Bundle即是React生成的Javascript文件,文件太大会导致页面Load很耗时。

优先级我觉得不是很高。首先,我们开发的React网站规模通常不大,生成的javascript也不是很大。其次,即使bundle有点大,作为单页面网站,也只会在家一次资源(componet mount),对于后续刷新不影响。

2.1 分析 – Webpack Bundle Analyzer

UMI自带了analyze模块,可以分析build之后各个文件的大小,package.json -> scripts里加入:

"analyze": "cross-env ANALYZE=1 umi build"

然后运行 yarn analyzenpm run analyze,就会启动Webpack Bundle Analyzer。

访问Webpack Bundle Analyzer本地服务地址,就可以查看所有bundle的大小,方块越大,代表size越大。

2.2 优化 – 代码分割

React官方文档总结的很好,直接看官方文档就好:代码分割

  • import():动态import module
  • React.lazy:动态引入React component
    • <Suspense fallback={JSX}>:组件没加载完成时显示内容
    • startTransition():等异步组件加载完了,再更新UI,不显示<Suspense>
  • Error boundaries:捕获子组件的错误,类似Java或其它语言的catch

3. 确保后端API返回都很快

前端调用后端API来获取数据,更新组件。如果API就很慢,前端无论怎么优化,都会很卡。

3.1 拆分大API

如果API响应慢,是因为API粒度太大,返回太多冗余用不上的数据,可以考虑拆分API。

3.2 优化后端代码

如果API粒度合适,还是响应慢,就要优化后端代码。这里的一个case是,后端API逻辑里,执行了太多的Java反射调用,拖慢了API的响应。

4. 减少Model, State更新引起的多次渲染

4.1 合并相关联Reducer

Reducer reducer是用来更新model状态的,model状态的更新,会引起相关组件重新渲染。

下面代码可能会引起三次组件渲染,取决于三次dispatch间隔是否够短:

const plan = ...
dispatch({ type: "planning/setSelectedPlan", plan});
const product = ...
dispatch({ type: "planning/setSelectedProduct", product});
const productPlan = ...
dispatch({ type: "planning/setSelectedProductPlan", productPlan: productPlan});

前两次渲染是无效的,因为data不是完整的,虽然人眼看不到差异,只是有卡顿。合并成一个reducer,只有一次刷新:

const plan = ...
const product = ...
const productPlan = ...
dispatch({ type: "planning/setInitState", plan, product, productPlan});

4.2 合并相关联setState

State是组件的状态,更新state会引起组件重新渲染。

下面代码可能会引起三次组件渲染,取决于三次setState间隔是否够短:

const dataSource = ...
this.setState({dataSource});
const groupRule = ...
this.setState({groupRule});
const columns = ...
this.setState({columns});

前两次state改变引起的rerender是无效的,因为数据不完整。合并成一个setState,就会只有一次刷新:

const dataSource = ...
const groupRule = ...
const columns = ...
this.setState({dataSource, groupRule, columns});

4.3 stateB由stateA计算而来,没必要声明stateB

错误示例,onFileChange执行后页面会刷新,刷新之后执行useEffect更新state后,页面又刷新一次。

function FileInfo(props) {
  const [filePath, setFilePath] = useState('');
  const [fileName, setFileName] = useState('');

  function onFileChange(e) {
    setFilePath(e.target.value);
  }

  useEffect(() => {
    setFileName(FileUtil.getFileName(filePath));
  }, [filePath])

  return <Fragment>
    <Input value={filePath} onChange={onFileChange} />
    <label>File path: {filePath}</label>
    <label>File name: {fileName}</label>
  </Fragment>;
}

通常来说,如果stateB是由stateA计算出来,就没必要声明stateB。把生成stateB的逻辑声明成一个方法,任何使用stateB的地方,就直接调用这个方法。如果转换过程比较耗时,可以使用5.2的useMemo来缓存,避免多次执行。下面是删除fileName的代码:

function FileInfo2(props) {
  const [filePath, setFilePath] = useState('');

  function onFileChange(e) {
    setFilePath(e.target.value);
  }

  return <Fragment>
    <Input value={filePath} onChange={onFileChange} />
    <label>File path: {filePath}</label>
    <label>File name: {FileUtil.getFileName(filePath)}</label>
  </Fragment>;
}

如果stateB是由stateA计算出来,而又要执意声明stateB,那么应当把stateB的更新和stateA的更新放在一起。

function FileInfo3(props) {
  const [filePath, setFilePath] = useState('');
  const [fileName, setFileName] = useState('');

  function onFileChange(e) {
    setFilePath(e.target.value);
    setFileName(FileUtil.getFileName(filePath));
  }

  return <Fragment>
    <Input value={filePath} onChange={onFileChange} />
    <label>File path: {filePath}</label>
    <label>File name: {fileName}</label>
  </Fragment>;
}

4.4 stateB由props计算而来,没必要声明stateB

下面的例子props改变会引起一次render,useEffect执行setTotalPrice后又会引起一次render。

function TotalWithEffect({price, quantity}) {
  const [totalPrice, setTotalPrice] = useState(0);

  useEffect(() => {
    setTotalPrice(price * quantity);
  }, [price, quantity]);

  return (<label>Total: {totalPrice}</label>);
}

其实没必要将totalPrice声明成state,声明成一个变量或者方法便可。

function Total({price, quantity}) {
  const getTotalPrice = () => price * quantity;
  return (<label>Total: {getTotalPrice()}</label>);
}

对于class component,如果仍然要声明成state,可以用getDerivedStateFromProps(),它会在render前执行,不会引起多余render。

class Child extends React.Component {
  state = {
    totalPrice: 0,
  }

  static getDerivedStateFromProps(props, state) {
    return {
      totalPrice: props.price * props.quantity,
    }
  }

  render() {
    return <>A component</>
  }
}

5. React提供的优化方法

5.1 React.memo – 相同props跳过渲染

const MyComponent = React.memo(function MyComponent(props) {
  /* 使用 props 渲染 */
});

使用React.memo包装组件,当props相同时,React将跳过渲染组件的操作。

默认情况下,只会对props做浅比较,如果要控制props的比较,可以添加第二个参数。

function MyComponent(props) {
  /* 使用 props 渲染 */
}
function areEqual(prevProps, nextProps) {
  /*
  如果把 nextProps 传入 render 方法的返回结果与
  将 prevProps 传入 render 方法的返回结果一致则返回 true,
  否则返回 false
  */
}
export default React.memo(MyComponent, areEqual);

5.2 React.PureComponent和shouldComponentUpdate

React.Component会在每次props或state更新重新render组件。React.PureComponent会用当前的props和state与之前的进行浅比较,如果相同就不会render。React.PureComponent类似React.memo,但是React.memo只比较props。

如果想控制props和state的比较,可以使用shouldComponentUpdate方法,Component和PureComponent都可用。返回true表示要render;false表示不render。

shouldComponentUpdate(nextProps, nextState) {
  return true;
}

5.3 useCallback – 记住function instance

随着每次render,都会创建一个新的function,如果function作为props传递给子组件,就会导致子组件产生无意义的刷新。Note: <Hello /> 时一个React.memo包装的组件,仅在props变时rerender。

function App() {
  function countLetter(name: string) {
    return name.length;
  }
  return (
    <div className="App">
      <Hello name="Alex" countLetter={countLetter} />
    </div>
  );
}

使用useCallback包装函数,仅在某个依赖项改变时才重新生成新的function,上面代码可以改写为:

function App() {
  const countLetter = useCallback((name: string) => {
    return name.length;
  }, []);  //no deps, 只创建一次
  return (
    <div className="App">
      <Hello name="Alex" countLetter={countLetter} />
    </div>
  );
}

不要再JSX里使用lambda表达式,因为它每次render时都会生成一个新的函数,浅比较就每次都不一样。下面例子就算name不变,每次render App时,都会导致Hello组件重新render。

function App() {
  const countLetter = useCallback((name: string) => {
    return name.length;
  }, []);  //no deps, 只创建一次
  return (
    <div className="App">
      <Hello name="Alex" countLetter={name => countLetter(name)} />
    </div>
  );
}

5.4 useMemo – 缓存耗时计算的结果

useMemo API:

// allow undefined, but don't make it optional as that is very likely a mistake
function useMemo<T>(factory: () => T, deps: DependencyList | undefined): T;
// Example:
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

仅在依赖项改变时,才会重新计算memoized的值,避免高开销的计算。

useMemo返回的结果可以是任何值,返回的如果是function的话,就等价于useCallback。

const setValue1 = useMemo(() => {
  return (value: boolean) => {
    setState(value);
  };
}, []);
const setValue2 = useCallback((value: boolean) => {
  setState(value);
}, []);

下面的useMemo返回React组件:

const menuItemRows = useMemo(
  () => thousandsOfMenuItems.map(menuItem => (
      <MenuItemRow key={menuItem.uuid} name={menuItem.name} />
  )),
  [thousandsOfMenuItems]
);

如果后端API调用比较耗时,也可以用useMemo把API返回结果缓存起来。项目里有用 react-query 的 userQuery 来缓存API调用结果,它的功能更强大些,例如可以设置缓存何时到期,到期后重新call API拿数据。

6. 优化大Table组件

Ant design的Table组件,在每个cell更新时,都会去rerender所有的Table Cell。当Table数据少的时候,感觉不出来性能问题。当Table数据很大的时候,例如50列的Table,300行数据,rerender所有table会带来非常大的性能问题。

6.1 编辑状态下才显示复杂组件

下面时一个Table的截图,它的每一个cell都时一个复杂组件。

我们可以给组件一个editing状态,editing 是 false 时,直接显示文本;editing 是 true,才显示高级组件,让用户编辑。

6.2 延迟调用耗时API

例如点击 Select时,会弹出一个Dialog显示数据,而数据获取又很耗时。

那么数据的获取阶段,就不应该发生在 Select 组件渲染的周期里面,应当在点击 Select 时才获取数据。总结:啥时候用,啥时候去取数据。

6.3 分页

对大Table进行分页,这是很常用的手法。分页后,table小了,性能自然会提升。

6.4 Virtual Table

And design 4.X 版本之后,通过react-window,引入了虚拟滚动方案。仅会渲染当前视窗里的内容,就算有10万条数据,依然性能很好。

Category: SWT