摔杯为号—一个以Suspense为切入点的前端性能优化实用技巧

129 阅读6分钟

前言

性能优化是个很大的话题, 仅从代码实现角度来说, “按需”可说是一个相当通用的切入点. 用户对于功能的需求, 是遵循一定的逻辑, 理想的情况是, 用户需要什么功能模块, 就按需加载什么模块, 在保证体验的前提在, 做到资源的”最小化加载“. 实现这种设计的方案有很多种, 本文抛砖引玉, 以React的Suspense特性为切入点, 讨论一种较为方便的实践方案.

以例说明

然后我们以一个例子来展开说明, 在业务设计中, 经常使用Drawer这样的组件, 在抽屉里完成一些信息展示或者操作. 这个Drawer的展示与用户的操作强相关, 一般都是用户点击某个项目后展示此Drawer, 以深度查阅信息或者完成某个操作.

这样一个Drawer组件, 随着业务的发展可能承接越来越多的逻辑, 体积也随之增长.

我们可以这样定义: 用户是否需要这个组件, 取决于用户是否对这个组件进行了呼出操作. 至少在用户的首屏来说, 是不需要加载这一部分的脚本的.如果用户在本次访问中没有呼出过这个组件, 那么其实根本不需要将这部分资源加载进来.

理想的情况是, 用户在第一次需要这个组件时, 加载资源. 这样按需的设计, 可以缩小首屏资源需求, 从而加速首次访问.

因此在技术实现上, 可以将类似这样的功能提取为独立的小模块, 以用户的呼出操作作为信号来动态加载.

Drawer

动态加载

代码切分

从资源打包的角度, 需要将子模块单独打包成一组产物, 以webpack为例, import关键字可以方便地完成打包拆分

从业务代码的使用角度,如何使用这样拆分好的模块呢? 以React为例, React的lazy api 支持方便的动态引入组件, 将按需加载的模块封装为独立的模块后, 使用React.lazy处理, 即可当作正常的组件来使用

// async-chunck.js
export default function Async() {
  return <div>This is Async chunk</div>;
}

// index.js
import { Suspense, lazy } from "react";
// 使用lazy + import 拆分功能模块
const AsyncChunk = lazy(() => import("./async-chunk"));

export default function App() {
  return (
    <div className="App">
      <Suspense fallback={"async loading"}>
        <AsyncChunk />
      </Suspense>
    </div>
  );
}

Suspense

Suspense 是React在v16时引入的新实验特性, v16之后即可使用, 在前几日发布的v18正式版中, 也转变为正式特性

Suspense改变了已有的数据获取到渲染的逻辑, 让异步数据的获取和渲染更加“同步化”. 在大多数项目里, 我们习惯的数据获取到渲染的方式是, 将数据获取放在组件的渲染生命周期钩子内, 通过一个显式的状态变量来管理真正的内容是否被渲染. 这可以被称为fetch-on-render

因为组件已经被渲染了, 然后在生命周期里, 开始执行数据获取的逻辑, 得到了数据之后, 重新渲染需要的内容. 无论如何, 数据的获取是发生在组件渲染之后.

// 一个典型的fetch-on-render
import { useEffect, useState } from "react";
import { getRandomDog } from "./api";
export default function FetchOnRender() {
  const [isLoading, setLoading] = useState(true);
  const [src, setSrc] = useState("");
  useEffect(() => {
    (async () => {
      let res = await getRandomDog();
      setLoading(false);
      setSrc(res);
    })();
  }, []);

  return isLoading ? (
    "This is FetchOnRenderLoading"
  ) : (
    <img style={{ width: 100, height: 100 }} src={src}></img>
  );
}

Suspense 则将这种模式调整为了render-as-fetch 即, 尽早地去获取数据, 不在功能组件件内去感知数据是否获取到了, 而是直接使用数据来进行渲染

// 一个典型的Suspense逻辑组件
import { Suspense } from 'react'
// 尽早获取数据
const dataResource = getDate()

// 功能组件
function SuspenseItem(){
	// 获取数据
	const item = dataResource.read()
	// 直接使用数据
	return <div>{item}</div>
}

export default function App(){
	return <Suspense fallback="Item loading"><SuspenseItem/></Suspense>
}

这样的代码, 看起来逻辑更加简洁, 没有多余的跟业务功能无关的状态变量(例如isLoading). 也可以从一定程度上精简状态管理.

那么Suspense 背后的原理是什么呢?

HOC + Catch + Promise

通过Suspense 的使用方式, 我们可以看到, 是需要用Suspense 来包裹实际需要的功能组件, 因此Suspense 其实是一个高阶组件

那么它又是如何可以‘同步地’读取异步数据并且渲染呢? 这就要介绍一个特别的React生命周期函数**[componentDidCatch](https://zh-hans.reactjs.org/docs/react-component.html#componentdidcatch) ,** 该函数会在组件的commit阶段被调用, 收集内部的运行时错误. 在功能函数获取数据并尝试渲染的时候, 如果数据实际上还未返回, 那么就可以抛出一个错误, 让上层组件的componentDidCatch 收集到这个错误, 中断功能组件的渲染, 等数据获取完成后, 再由上层组件重新渲染正确的功能组件.

抛出的错误较为特殊, 是我们获取数据的包装Promise. 将这个Promise作为错误抛出, 就可以在上层组件内, 根据Promise的状态来控制功能组件的渲染时机了.

模拟实现

以上述原理为基础, 我们可以实现一个最简易版本的Suspense

import { Component } from "react";
class MySuspense extends Component {
  constructor() {
    super();
    this.state = {
      isRender: true,
    };
  }

  componentDidCatch(event) {
    this.setState({
      isRender: false,
    });
		// 捕获到的错误,是一个Promise对象, 可以在这里进一步根据状态决定子组件的渲染
    event.value.then((res) => {
      this.setState({
        isRender: true,
      });
    });
    console.log("componentDidCatch event is", event);
  }

  render() {
    const { fallback, children } = this.props;
    const { isRender } = this.state;

    return isRender ? children : fallback;
  }
}

// 获取数据, 返回一个Promise
export function getRandomDog() {
  return fetch("https://dog.ceo/api/breeds/image/random")
    .then((res) => res.json())
    .then((res) => res.message);
}

// 暴露给功能组件使用的数据
export function getData(apiFn) {
  let value = suspenseWrapper(apiFn);
  return value;
}

// 包装数据
function suspenseWrapper(apiFn) {
  let status = "pending";
  let res;
  let suspender = apiFn()
    .then((r) => {
      status = "success";
      res = r;
    })
    .catch((e) => {
      status = "error";
      res = e;
    });
  return {
    read() {
      if (status === "pending") {
				// 将Promise作为错误对象抛出
        throw { value: suspender };
      } else if (status === "error") {
        throw res;
      } else if (status === "success") {
        return res;
      }
    },
  };
}
export default MySuspense;

注意: React内部兜底了将Promise作为错误对象抛出的情况, React会直接抛出错误提示:

image.png 因此模拟的实现里, 要hack一下, 将其做一层简单的包装

完整的实现请见: My-Suspense-codesandbox

模块的按需加载

在了解了原理后, 我们就可以考虑将这两个特性结合起来, 针对例中提出的场景, 进行按需加载

原理也很简单, 我们针对需要的场景, 对组件进行好封装. 然后使用React.lazy() + 动态import 切分这部分组件.

在使用的时, 使用Suspense 来渲染这个组件.

那么如何做到按需呢? 如前所述, 合理的预期是, 组件的资源加载和渲染可以在用户初次触发的时候执行.

因此, 我们可以使用一个变量, 记录用户是否触发了组件. 据此, 来触发Suspense 所包裹的功能模块的第一次资源加载和渲染

const LazyInitRecord = {
  AsyncChunk: false,
};
export default function App(){
	const [hasLoad, setHasLoad] = useState(false);
	// 在用户触发的位置, 根据记录来触发资源的初次加载
  const handleLoadAsync = () => {
    if (!LazyInitRecord.AsyncChunk) {
      LazyInitRecord.AsyncChunk = true;
      setHasLoad(true);
    }
  };
	return (
   <div>
        <button onClick={handleLoadAsync}>Click to load async chunk</button>
        {hasLoad ? (
          <Suspense fallback={"async loading"}>
            <AsyncChunk />
          </Suspense>
        ) : null}
     </div>
  );
}

至此, 就较为方便地实现模块的拆分和按需加载, 只有在用户触发这些组件的时候, 组件的资源才会被加载进来.

my-suspense-3.gif