前言
性能优化是个很大的话题, 仅从代码实现角度来说, “按需”可说是一个相当通用的切入点. 用户对于功能的需求, 是遵循一定的逻辑, 理想的情况是, 用户需要什么功能模块, 就按需加载什么模块, 在保证体验的前提在, 做到资源的”最小化加载“. 实现这种设计的方案有很多种, 本文抛砖引玉, 以React的Suspense特性为切入点, 讨论一种较为方便的实践方案.
以例说明
然后我们以一个例子来展开说明, 在业务设计中, 经常使用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会直接抛出错误提示:
因此模拟的实现里, 要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>
);
}
至此, 就较为方便地实现模块的拆分和按需加载, 只有在用户触发这些组件的时候, 组件的资源才会被加载进来.